Initial commit
This commit is contained in:
34
play-life-backend/.gitignore
vendored
Normal file
34
play-life-backend/.gitignore
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
# Environment variables with secrets
|
||||
.env
|
||||
|
||||
# Go build artifacts
|
||||
main
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool
|
||||
*.out
|
||||
|
||||
# Dependency directories
|
||||
vendor/
|
||||
|
||||
# Go workspace file
|
||||
go.work
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
57
play-life-backend/Dockerfile
Normal file
57
play-life-backend/Dockerfile
Normal file
@@ -0,0 +1,57 @@
|
||||
# Multi-stage build для единого образа frontend + backend
|
||||
|
||||
# Stage 1: Build Frontend
|
||||
FROM node:20-alpine AS frontend-builder
|
||||
WORKDIR /app/frontend
|
||||
COPY play-life-web/package*.json ./
|
||||
RUN npm ci
|
||||
# Копируем исходники (node_modules исключены через .dockerignore)
|
||||
COPY play-life-web/ .
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Build Backend
|
||||
FROM golang:1.21-alpine AS backend-builder
|
||||
WORKDIR /app/backend
|
||||
COPY play-life-backend/go.mod play-life-backend/go.sum ./
|
||||
RUN go mod download
|
||||
COPY play-life-backend/ .
|
||||
RUN go mod tidy
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .
|
||||
|
||||
# Stage 3: Final image
|
||||
FROM alpine:latest
|
||||
|
||||
# Устанавливаем необходимые пакеты
|
||||
RUN apk --no-cache add \
|
||||
ca-certificates \
|
||||
nginx \
|
||||
supervisor \
|
||||
curl
|
||||
|
||||
# Создаем директории
|
||||
WORKDIR /app
|
||||
|
||||
# Копируем собранный frontend
|
||||
COPY --from=frontend-builder /app/frontend/dist /usr/share/nginx/html
|
||||
|
||||
# Копируем собранный backend
|
||||
COPY --from=backend-builder /app/backend/main /app/backend/main
|
||||
COPY play-life-backend/admin.html /app/backend/admin.html
|
||||
|
||||
# Копируем конфигурацию nginx
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
COPY nginx-unified.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Копируем конфигурацию supervisor для запуска backend
|
||||
COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
||||
|
||||
# Создаем директории для логов
|
||||
RUN mkdir -p /var/log/supervisor && \
|
||||
mkdir -p /var/log/nginx && \
|
||||
mkdir -p /var/run
|
||||
|
||||
# Открываем порт 80
|
||||
EXPOSE 80
|
||||
|
||||
# Запускаем supervisor, который запустит nginx и backend
|
||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
||||
76
play-life-backend/ENV_SETUP.md
Normal file
76
play-life-backend/ENV_SETUP.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# Настройка переменных окружения
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
1. Скопируйте файл `env.example` в `.env`:
|
||||
```bash
|
||||
cp env.example .env
|
||||
```
|
||||
|
||||
2. Откройте `.env` и заполните реальные значения:
|
||||
```bash
|
||||
nano .env
|
||||
# или
|
||||
vim .env
|
||||
```
|
||||
|
||||
3. **ВАЖНО**: Файл `.env` уже добавлен в `.gitignore` и не будет попадать в git.
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
### Обязательные (для работы приложения)
|
||||
|
||||
- `DB_HOST` - хост базы данных (по умолчанию: localhost)
|
||||
- `DB_PORT` - порт базы данных (по умолчанию: 5432)
|
||||
- `DB_USER` - пользователь БД (по умолчанию: playeng)
|
||||
- `DB_PASSWORD` - пароль БД (по умолчанию: playeng)
|
||||
- `DB_NAME` - имя БД (по умолчанию: playeng)
|
||||
- `PORT` - порт сервера (по умолчанию: 8080)
|
||||
|
||||
### Опциональные (для Telegram интеграции)
|
||||
|
||||
- `TELEGRAM_BOT_TOKEN` - токен бота от @BotFather
|
||||
- `TELEGRAM_CHAT_ID` - ID чата для отправки сообщений
|
||||
|
||||
## Использование в коде
|
||||
|
||||
Приложение автоматически читает переменные окружения через `os.Getenv()`.
|
||||
|
||||
Для загрузки `.env` файла в локальной разработке можно использовать:
|
||||
|
||||
### Вариант 1: Установить переменные вручную
|
||||
```bash
|
||||
export DB_PASSWORD=your_password
|
||||
export TELEGRAM_BOT_TOKEN=your_token
|
||||
go run main.go
|
||||
```
|
||||
|
||||
### Вариант 2: Использовать библиотеку godotenv (рекомендуется)
|
||||
|
||||
1. Установить библиотеку:
|
||||
```bash
|
||||
go get github.com/joho/godotenv
|
||||
```
|
||||
|
||||
2. Добавить в начало `main()`:
|
||||
```go
|
||||
import "github.com/joho/godotenv"
|
||||
|
||||
func main() {
|
||||
// Загрузить .env файл
|
||||
godotenv.Load()
|
||||
// ... остальной код
|
||||
}
|
||||
```
|
||||
|
||||
### Вариант 3: Использовать docker-compose
|
||||
|
||||
В `docker-compose.yml` уже настроена передача переменных окружения из `.env` файла.
|
||||
|
||||
## Безопасность
|
||||
|
||||
- ✅ Файл `.env` добавлен в `.gitignore`
|
||||
- ✅ Файл `env.example` содержит только шаблоны без реальных значений
|
||||
- ✅ Никогда не коммитьте `.env` в git
|
||||
- ✅ Используйте разные токены для dev/prod окружений
|
||||
|
||||
358
play-life-backend/admin.html
Normal file
358
play-life-backend/admin.html
Normal file
@@ -0,0 +1,358 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Play Life Backend - Admin Panel</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: white;
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
font-size: 2.5em;
|
||||
text-shadow: 2px 2px 4px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
padding: 25px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
color: #667eea;
|
||||
margin-bottom: 20px;
|
||||
font-size: 1.3em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.card textarea {
|
||||
width: 100%;
|
||||
min-height: 150px;
|
||||
padding: 12px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 5px;
|
||||
font-size: 14px;
|
||||
font-family: monospace;
|
||||
resize: vertical;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.card textarea:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.card button {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 5px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.card button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.card button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.result {
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 5px;
|
||||
border-left: 4px solid #667eea;
|
||||
}
|
||||
|
||||
.result h3 {
|
||||
color: #333;
|
||||
margin-bottom: 10px;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.result pre {
|
||||
background: #2d2d2d;
|
||||
color: #f8f8f2;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
overflow-x: auto;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.result.success {
|
||||
border-left-color: #4caf50;
|
||||
}
|
||||
|
||||
.result.error {
|
||||
border-left-color: #f44336;
|
||||
}
|
||||
|
||||
.result.loading {
|
||||
border-left-color: #ff9800;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.status.success {
|
||||
background: #4caf50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status.error {
|
||||
background: #f44336;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status.loading {
|
||||
background: #ff9800;
|
||||
color: white;
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🎯 Play Life Backend - Admin Panel</h1>
|
||||
|
||||
<div class="grid">
|
||||
<!-- Message Post Card -->
|
||||
<div class="card">
|
||||
<h2>
|
||||
📨 Message Post
|
||||
<span class="status" id="messageStatus" style="display: none;"></span>
|
||||
</h2>
|
||||
<textarea id="messageText" placeholder="Введите сообщение с паттернами **Project+10.5** или **Project-5.0**...
|
||||
|
||||
Пример:
|
||||
Сегодня работал над проектами:
|
||||
**Frontend+15.5**
|
||||
**Backend+8.0**
|
||||
**Design-2.5**"></textarea>
|
||||
<button onclick="sendMessage()">Отправить сообщение</button>
|
||||
<div id="messageResult"></div>
|
||||
</div>
|
||||
|
||||
<!-- Daily Report Trigger Card -->
|
||||
<div class="card">
|
||||
<h2>
|
||||
📈 Daily Report Trigger
|
||||
<span class="status" id="dailyReportStatus" style="display: none;"></span>
|
||||
</h2>
|
||||
<p style="margin-bottom: 15px; color: #666;">
|
||||
Нажмите кнопку для отправки ежедневного отчёта по Score и Целям в Telegram (обычно отправляется автоматически в 11:59).
|
||||
</p>
|
||||
<button onclick="triggerDailyReport()">Отправить отчёт</button>
|
||||
<div id="dailyReportResult"></div>
|
||||
</div>
|
||||
|
||||
<!-- Weekly Goals Setup Card -->
|
||||
<div class="card">
|
||||
<h2>
|
||||
🎯 Weekly Goals Setup
|
||||
<span class="status" id="goalsStatus" style="display: none;"></span>
|
||||
</h2>
|
||||
<p style="margin-bottom: 15px; color: #666;">
|
||||
Нажмите кнопку для установки целей на текущую неделю на основе медианы за последние 3 месяца (с отправкой в чат). Обычно срабатывает автоматически в начале недели.
|
||||
</p>
|
||||
<button onclick="setupWeeklyGoals()">Обновить цели</button>
|
||||
<div id="goalsResult"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function getApiUrl() {
|
||||
// Автоматически определяем URL текущего хоста
|
||||
// Админка обслуживается тем же бекендом, поэтому используем текущий origin
|
||||
return window.location.origin;
|
||||
}
|
||||
|
||||
function showStatus(elementId, status, text) {
|
||||
const statusEl = document.getElementById(elementId);
|
||||
statusEl.textContent = text;
|
||||
statusEl.className = `status ${status}`;
|
||||
statusEl.style.display = 'inline-block';
|
||||
}
|
||||
|
||||
function hideStatus(elementId) {
|
||||
document.getElementById(elementId).style.display = 'none';
|
||||
}
|
||||
|
||||
function showResult(elementId, data, isError = false, isLoading = false) {
|
||||
const resultEl = document.getElementById(elementId);
|
||||
resultEl.innerHTML = '';
|
||||
|
||||
if (isLoading) {
|
||||
resultEl.innerHTML = '<div class="result loading"><h3>⏳ Загрузка...</h3></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.className = `result ${isError ? 'error' : 'success'}`;
|
||||
|
||||
const h3 = document.createElement('h3');
|
||||
h3.textContent = isError ? '❌ Ошибка' : '✅ Успешно';
|
||||
div.appendChild(h3);
|
||||
|
||||
const pre = document.createElement('pre');
|
||||
pre.textContent = JSON.stringify(data, null, 2);
|
||||
div.appendChild(pre);
|
||||
|
||||
resultEl.appendChild(div);
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
const text = document.getElementById('messageText').value.trim();
|
||||
if (!text) {
|
||||
alert('Пожалуйста, введите сообщение');
|
||||
return;
|
||||
}
|
||||
|
||||
showStatus('messageStatus', 'loading', 'Отправка...');
|
||||
showResult('messageResult', null, false, true);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${getApiUrl()}/message/post`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
body: {
|
||||
text: text
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
showStatus('messageStatus', 'success', 'Успешно');
|
||||
showResult('messageResult', data, false);
|
||||
} else {
|
||||
showStatus('messageStatus', 'error', 'Ошибка');
|
||||
showResult('messageResult', data, true);
|
||||
}
|
||||
} catch (error) {
|
||||
showStatus('messageStatus', 'error', 'Ошибка');
|
||||
showResult('messageResult', { error: error.message }, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function setupWeeklyGoals() {
|
||||
showStatus('goalsStatus', 'loading', 'Обновление...');
|
||||
showResult('goalsResult', null, false, true);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${getApiUrl()}/weekly_goals/setup`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
showStatus('goalsStatus', 'success', 'Успешно');
|
||||
showResult('goalsResult', data, false);
|
||||
} else {
|
||||
showStatus('goalsStatus', 'error', 'Ошибка');
|
||||
showResult('goalsResult', data, true);
|
||||
}
|
||||
} catch (error) {
|
||||
showStatus('goalsStatus', 'error', 'Ошибка');
|
||||
showResult('goalsResult', { error: error.message }, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerDailyReport() {
|
||||
showStatus('dailyReportStatus', 'loading', 'Отправка...');
|
||||
showResult('dailyReportResult', null, false, true);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${getApiUrl()}/daily-report/trigger`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
showStatus('dailyReportStatus', 'success', 'Успешно');
|
||||
showResult('dailyReportResult', data, false);
|
||||
} else {
|
||||
showStatus('dailyReportStatus', 'error', 'Ошибка');
|
||||
showResult('dailyReportResult', data, true);
|
||||
}
|
||||
} catch (error) {
|
||||
showStatus('dailyReportStatus', 'error', 'Ошибка');
|
||||
showResult('dailyReportResult', { error: error.message }, true);
|
||||
}
|
||||
}
|
||||
|
||||
// Разрешаем отправку формы по Enter (Ctrl+Enter для textarea)
|
||||
document.getElementById('messageText').addEventListener('keydown', function(e) {
|
||||
if (e.ctrlKey && e.key === 'Enter') {
|
||||
sendMessage();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
43
play-life-backend/docker-compose.yml
Normal file
43
play-life-backend/docker-compose.yml
Normal file
@@ -0,0 +1,43 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:15-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${DB_USER:-playeng}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-playeng}
|
||||
POSTGRES_DB: ${DB_NAME:-playeng}
|
||||
ports:
|
||||
- "${DB_PORT:-5432}:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-playeng}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
env_file:
|
||||
- ../.env
|
||||
- .env # Локальный .env имеет приоритет
|
||||
|
||||
backend:
|
||||
build: .
|
||||
ports:
|
||||
- "${PORT:-8080}:8080"
|
||||
environment:
|
||||
DB_HOST: db
|
||||
DB_PORT: 5432
|
||||
DB_USER: ${DB_USER:-playeng}
|
||||
DB_PASSWORD: ${DB_PASSWORD:-playeng}
|
||||
DB_NAME: ${DB_NAME:-playeng}
|
||||
PORT: ${PORT:-8080}
|
||||
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
|
||||
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./migrations:/migrations
|
||||
env_file:
|
||||
- ../.env
|
||||
- .env # Локальный .env имеет приоритет
|
||||
|
||||
17
play-life-backend/env.example
Normal file
17
play-life-backend/env.example
Normal file
@@ -0,0 +1,17 @@
|
||||
# Database Configuration
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_USER=playeng
|
||||
DB_PASSWORD=playeng
|
||||
DB_NAME=playeng
|
||||
|
||||
# Server Configuration
|
||||
PORT=8080
|
||||
|
||||
# Telegram Bot Configuration (optional - for direct Telegram integration)
|
||||
# Get token from @BotFather in Telegram: https://t.me/botfather
|
||||
# To get chat ID: send a message to your bot, then visit: https://api.telegram.org/bot<YOUR_BOT_TOKEN>/getUpdates
|
||||
# Look for "chat":{"id":123456789} - that number is your chat ID
|
||||
TELEGRAM_BOT_TOKEN=your_telegram_bot_token_here
|
||||
TELEGRAM_CHAT_ID=123456789
|
||||
|
||||
14
play-life-backend/go.mod
Normal file
14
play-life-backend/go.mod
Normal file
@@ -0,0 +1,14 @@
|
||||
module play-eng-backend
|
||||
|
||||
go 1.21
|
||||
|
||||
require (
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 // indirect
|
||||
github.com/joho/godotenv v1.5.1 // indirect
|
||||
)
|
||||
8
play-life-backend/go.sum
Normal file
8
play-life-backend/go.sum
Normal file
@@ -0,0 +1,8 @@
|
||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc=
|
||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
3672
play-life-backend/main.go
Normal file
3672
play-life-backend/main.go
Normal file
File diff suppressed because it is too large
Load Diff
105
play-life-backend/migrations/001_create_schema.sql
Normal file
105
play-life-backend/migrations/001_create_schema.sql
Normal file
@@ -0,0 +1,105 @@
|
||||
-- Migration: Create database schema for play-life project
|
||||
-- This script creates all tables and materialized views needed for the project
|
||||
|
||||
-- ============================================
|
||||
-- Table: projects
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
priority SMALLINT,
|
||||
CONSTRAINT unique_project_name UNIQUE (name)
|
||||
);
|
||||
|
||||
-- ============================================
|
||||
-- Table: entries
|
||||
-- ============================================
|
||||
-- This table stores entries with creation dates
|
||||
-- Used in weekly_report_mv for grouping by week
|
||||
CREATE TABLE IF NOT EXISTS entries (
|
||||
id SERIAL PRIMARY KEY,
|
||||
text TEXT NOT NULL,
|
||||
created_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- ============================================
|
||||
-- Table: nodes
|
||||
-- ============================================
|
||||
-- This table stores nodes linked to projects and entries
|
||||
-- Contains score information used in weekly reports
|
||||
CREATE TABLE IF NOT EXISTS nodes (
|
||||
id SERIAL PRIMARY KEY,
|
||||
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
entry_id INTEGER NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
||||
score NUMERIC(8,4)
|
||||
);
|
||||
|
||||
-- Create index on project_id for better join performance
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_project_id ON nodes(project_id);
|
||||
-- Create index on entry_id for better join performance
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_entry_id ON nodes(entry_id);
|
||||
|
||||
-- ============================================
|
||||
-- Table: weekly_goals
|
||||
-- ============================================
|
||||
-- This table stores weekly goals for projects
|
||||
CREATE TABLE IF NOT EXISTS weekly_goals (
|
||||
id SERIAL PRIMARY KEY,
|
||||
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
goal_year INTEGER NOT NULL,
|
||||
goal_week INTEGER NOT NULL,
|
||||
min_goal_score NUMERIC(10,4) NOT NULL DEFAULT 0,
|
||||
max_goal_score NUMERIC(10,4),
|
||||
actual_score NUMERIC(10,4) DEFAULT 0,
|
||||
priority SMALLINT,
|
||||
CONSTRAINT weekly_goals_project_id_goal_year_goal_week_key UNIQUE (project_id, goal_year, goal_week)
|
||||
);
|
||||
|
||||
-- Create index on project_id for better join performance
|
||||
CREATE INDEX IF NOT EXISTS idx_weekly_goals_project_id ON weekly_goals(project_id);
|
||||
|
||||
-- ============================================
|
||||
-- Materialized View: weekly_report_mv
|
||||
-- ============================================
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS weekly_report_mv AS
|
||||
SELECT
|
||||
p.id AS project_id,
|
||||
agg.report_year,
|
||||
agg.report_week,
|
||||
-- Используем COALESCE для установки total_score в 0.0000, если нет данных (NULL)
|
||||
COALESCE(agg.total_score, 0.0000) AS total_score
|
||||
FROM
|
||||
projects p
|
||||
LEFT JOIN
|
||||
(
|
||||
-- 1. Предварительная агрегация: суммируем score по неделям
|
||||
SELECT
|
||||
n.project_id,
|
||||
EXTRACT(YEAR FROM e.created_date)::INTEGER AS report_year,
|
||||
EXTRACT(WEEK FROM e.created_date)::INTEGER AS report_week,
|
||||
SUM(n.score) AS total_score
|
||||
FROM
|
||||
nodes n
|
||||
JOIN
|
||||
entries e ON n.entry_id = e.id
|
||||
GROUP BY
|
||||
1, 2, 3
|
||||
) agg
|
||||
-- 2. Присоединяем агрегированные данные ко ВСЕМ проектам
|
||||
ON p.id = agg.project_id
|
||||
ORDER BY
|
||||
p.id, agg.report_year, agg.report_week;
|
||||
|
||||
-- Create index on materialized view for better query performance
|
||||
CREATE INDEX IF NOT EXISTS idx_weekly_report_mv_project_year_week
|
||||
ON weekly_report_mv(project_id, report_year, report_week);
|
||||
|
||||
-- ============================================
|
||||
-- Comments for documentation
|
||||
-- ============================================
|
||||
COMMENT ON TABLE projects IS 'Projects table storing project information with priority';
|
||||
COMMENT ON TABLE entries IS 'Entries table storing entry creation timestamps';
|
||||
COMMENT ON TABLE nodes IS 'Nodes table linking projects, entries and storing scores';
|
||||
COMMENT ON TABLE weekly_goals IS 'Weekly goals for projects';
|
||||
COMMENT ON MATERIALIZED VIEW weekly_report_mv IS 'Materialized view aggregating weekly scores by project';
|
||||
|
||||
53
play-life-backend/migrations/002_add_dictionaries.sql
Normal file
53
play-life-backend/migrations/002_add_dictionaries.sql
Normal file
@@ -0,0 +1,53 @@
|
||||
-- Migration: Add dictionaries table and dictionary_id to words
|
||||
-- This script creates the dictionaries table and adds dictionary_id field to words table
|
||||
|
||||
-- ============================================
|
||||
-- Table: dictionaries
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS dictionaries (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL
|
||||
);
|
||||
|
||||
-- Insert default dictionary "Все слова" with id = 0
|
||||
-- Note: PostgreSQL SERIAL starts from 1, so we need to use a workaround
|
||||
-- First, set the sequence to allow inserting 0, then insert, then reset sequence
|
||||
DO $$
|
||||
BEGIN
|
||||
-- Set sequence to -1 so next value will be 0
|
||||
PERFORM setval('dictionaries_id_seq', -1, false);
|
||||
|
||||
-- Insert the default dictionary with id = 0
|
||||
INSERT INTO dictionaries (id, name)
|
||||
VALUES (0, 'Все слова')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Set the sequence to start from 1 (so next auto-increment will be 1)
|
||||
PERFORM setval('dictionaries_id_seq', 1, false);
|
||||
END $$;
|
||||
|
||||
-- ============================================
|
||||
-- Alter words table: Add dictionary_id column
|
||||
-- ============================================
|
||||
ALTER TABLE words
|
||||
ADD COLUMN IF NOT EXISTS dictionary_id INTEGER DEFAULT 0 REFERENCES dictionaries(id);
|
||||
|
||||
-- Update all existing words to have dictionary_id = 0
|
||||
UPDATE words
|
||||
SET dictionary_id = 0
|
||||
WHERE dictionary_id IS NULL;
|
||||
|
||||
-- Make dictionary_id NOT NULL after setting default values
|
||||
ALTER TABLE words
|
||||
ALTER COLUMN dictionary_id SET NOT NULL,
|
||||
ALTER COLUMN dictionary_id SET DEFAULT 0;
|
||||
|
||||
-- Create index on dictionary_id for better join performance
|
||||
CREATE INDEX IF NOT EXISTS idx_words_dictionary_id ON words(dictionary_id);
|
||||
|
||||
-- ============================================
|
||||
-- Comments for documentation
|
||||
-- ============================================
|
||||
COMMENT ON TABLE dictionaries IS 'Dictionaries table storing dictionary information';
|
||||
COMMENT ON COLUMN words.dictionary_id IS 'Reference to dictionary. Default is 0 (Все слова)';
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Migration: Remove UNIQUE constraint from words.name
|
||||
-- This script removes the unique constraint on the name column in the words table
|
||||
|
||||
-- Drop the unique constraint on words.name if it exists
|
||||
ALTER TABLE words
|
||||
DROP CONSTRAINT IF EXISTS words_name_key;
|
||||
|
||||
-- Also try to drop constraint if it was created with different name
|
||||
ALTER TABLE words
|
||||
DROP CONSTRAINT IF EXISTS words_name_unique;
|
||||
|
||||
21
play-life-backend/migrations/004_add_config_dictionaries.sql
Normal file
21
play-life-backend/migrations/004_add_config_dictionaries.sql
Normal file
@@ -0,0 +1,21 @@
|
||||
-- Migration: Add config_dictionaries table (many-to-many relationship)
|
||||
-- This script creates the config_dictionaries table linking configs and dictionaries
|
||||
|
||||
-- ============================================
|
||||
-- Table: config_dictionaries
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS config_dictionaries (
|
||||
config_id INTEGER NOT NULL REFERENCES configs(id) ON DELETE CASCADE,
|
||||
dictionary_id INTEGER NOT NULL REFERENCES dictionaries(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (config_id, dictionary_id)
|
||||
);
|
||||
|
||||
-- Create indexes for better query performance
|
||||
CREATE INDEX IF NOT EXISTS idx_config_dictionaries_config_id ON config_dictionaries(config_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_config_dictionaries_dictionary_id ON config_dictionaries(dictionary_id);
|
||||
|
||||
-- ============================================
|
||||
-- Comments for documentation
|
||||
-- ============================================
|
||||
COMMENT ON TABLE config_dictionaries IS 'Many-to-many relationship table linking configs and dictionaries. If no dictionaries are selected for a config, all dictionaries will be used.';
|
||||
|
||||
29
play-life-backend/migrations/005_fix_weekly_report_mv.sql
Normal file
29
play-life-backend/migrations/005_fix_weekly_report_mv.sql
Normal file
@@ -0,0 +1,29 @@
|
||||
-- Migration: Fix weekly_report_mv to use ISOYEAR instead of YEAR
|
||||
-- This fixes incorrect week calculations at year boundaries
|
||||
-- Date: 2024
|
||||
|
||||
-- Drop existing materialized view
|
||||
DROP MATERIALIZED VIEW IF EXISTS weekly_report_mv;
|
||||
|
||||
-- Recreate materialized view with ISOYEAR
|
||||
CREATE MATERIALIZED VIEW weekly_report_mv AS
|
||||
SELECT
|
||||
n.project_id,
|
||||
-- 🔑 ГЛАВНОЕ ИСПРАВЛЕНИЕ: Используем ISOYEAR
|
||||
EXTRACT(ISOYEAR FROM e.created_date)::INTEGER AS report_year,
|
||||
EXTRACT(WEEK FROM e.created_date)::INTEGER AS report_week,
|
||||
SUM(n.score) AS total_score
|
||||
FROM
|
||||
nodes n
|
||||
JOIN
|
||||
entries e ON n.entry_id = e.id
|
||||
GROUP BY
|
||||
1, 2, 3
|
||||
WITH DATA;
|
||||
|
||||
-- Recreate index
|
||||
CREATE INDEX IF NOT EXISTS idx_weekly_report_mv_project_year_week
|
||||
ON weekly_report_mv(project_id, report_year, report_week);
|
||||
|
||||
COMMENT ON MATERIALIZED VIEW weekly_report_mv IS 'Materialized view aggregating weekly scores by project using ISOYEAR for correct week calculations';
|
||||
|
||||
81
play-life-backend/migrations/README.md
Normal file
81
play-life-backend/migrations/README.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# Database Migrations
|
||||
|
||||
Этот каталог содержит SQL миграции для создания структуры базы данных проекта play-life.
|
||||
|
||||
## Использование
|
||||
|
||||
### Создание базы данных с нуля
|
||||
|
||||
Выполните миграцию для создания всех таблиц и представлений:
|
||||
|
||||
```bash
|
||||
psql -U your_user -d your_database -f 001_create_schema.sql
|
||||
```
|
||||
|
||||
Или через docker-compose:
|
||||
|
||||
```bash
|
||||
docker-compose exec db psql -U playeng -d playeng -f /migrations/001_create_schema.sql
|
||||
```
|
||||
|
||||
## Структура базы данных
|
||||
|
||||
### Таблицы
|
||||
|
||||
1. **projects** - Проекты
|
||||
- `id` (SERIAL PRIMARY KEY)
|
||||
- `name` (VARCHAR(255) NOT NULL, UNIQUE)
|
||||
- `priority` (SMALLINT)
|
||||
|
||||
2. **entries** - Записи с текстом и датами создания
|
||||
- `id` (SERIAL PRIMARY KEY)
|
||||
- `text` (TEXT NOT NULL)
|
||||
- `created_date` (TIMESTAMP WITH TIME ZONE NOT NULL, DEFAULT CURRENT_TIMESTAMP)
|
||||
|
||||
3. **nodes** - Узлы, связывающие проекты и записи
|
||||
- `id` (SERIAL PRIMARY KEY)
|
||||
- `project_id` (INTEGER NOT NULL, FK -> projects.id ON DELETE CASCADE)
|
||||
- `entry_id` (INTEGER NOT NULL, FK -> entries.id ON DELETE CASCADE)
|
||||
- `score` (NUMERIC(8,4))
|
||||
|
||||
4. **weekly_goals** - Недельные цели для проектов
|
||||
- `id` (SERIAL PRIMARY KEY)
|
||||
- `project_id` (INTEGER NOT NULL, FK -> projects.id ON DELETE CASCADE)
|
||||
- `goal_year` (INTEGER NOT NULL)
|
||||
- `goal_week` (INTEGER NOT NULL)
|
||||
- `min_goal_score` (NUMERIC(10,4) NOT NULL, DEFAULT 0)
|
||||
- `max_goal_score` (NUMERIC(10,4))
|
||||
- `actual_score` (NUMERIC(10,4), DEFAULT 0)
|
||||
- `priority` (SMALLINT)
|
||||
- UNIQUE CONSTRAINT: `(project_id, goal_year, goal_week)`
|
||||
|
||||
### Materialized View
|
||||
|
||||
- **weekly_report_mv** - Агрегированные данные по неделям для каждого проекта
|
||||
- `project_id` (INTEGER)
|
||||
- `report_year` (INTEGER)
|
||||
- `report_week` (INTEGER)
|
||||
- `total_score` (NUMERIC)
|
||||
|
||||
## Обновление Materialized View
|
||||
|
||||
После изменения данных в таблицах `nodes` или `entries`, необходимо обновить materialized view:
|
||||
|
||||
```sql
|
||||
REFRESH MATERIALIZED VIEW weekly_report_mv;
|
||||
```
|
||||
|
||||
## Связи между таблицами
|
||||
|
||||
- `nodes.project_id` → `projects.id` (ON DELETE CASCADE)
|
||||
- `nodes.entry_id` → `entries.id` (ON DELETE CASCADE)
|
||||
- `weekly_goals.project_id` → `projects.id` (ON DELETE CASCADE)
|
||||
|
||||
## Индексы
|
||||
|
||||
Созданы индексы для оптимизации запросов:
|
||||
- `idx_nodes_project_id` на `nodes(project_id)`
|
||||
- `idx_nodes_entry_id` на `nodes(entry_id)`
|
||||
- `idx_weekly_goals_project_id` на `weekly_goals(project_id)`
|
||||
- `idx_weekly_report_mv_project_year_week` на `weekly_report_mv(project_id, report_year, report_week)`
|
||||
|
||||
20
play-life-backend/start_backend.sh
Normal file
20
play-life-backend/start_backend.sh
Normal file
@@ -0,0 +1,20 @@
|
||||
#!/bin/bash
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# Настройки подключения к БД (можно изменить через переменные окружения)
|
||||
export DB_HOST=${DB_HOST:-localhost}
|
||||
export DB_PORT=${DB_PORT:-5432}
|
||||
export DB_USER=${DB_USER:-postgres}
|
||||
export DB_PASSWORD=${DB_PASSWORD:-postgres}
|
||||
export DB_NAME=${DB_NAME:-playlife}
|
||||
export PORT=${PORT:-8080}
|
||||
|
||||
echo "Starting backend server..."
|
||||
echo "DB_HOST: $DB_HOST"
|
||||
echo "DB_PORT: $DB_PORT"
|
||||
echo "DB_USER: $DB_USER"
|
||||
echo "DB_NAME: $DB_NAME"
|
||||
echo "PORT: $PORT"
|
||||
echo ""
|
||||
|
||||
go run main.go
|
||||
Reference in New Issue
Block a user