This commit is contained in:
2026-10-09 19:48:52 +07:00
parent 9d259e2151
commit 9a5fcce8cc
11 changed files with 522 additions and 14 deletions
+23 -13
View File
@@ -1,20 +1,30 @@
.PHONY: check build prod-build publish
.PHONY: check build prod-build publish venue venue-update
PROD_DIR ?= prod
build:
$(MAKE) -C evening_detective_server build-linux
npm --prefix evening_detective_frontend run build
@test -n "$(PROD_DIR)"
@set -e; for dir in "$(PROD_DIR)" venue; do \
mkdir -p "$$dir/backend" "$$dir/frontend/dist"; \
rm -rf "$$dir/migrations"; \
mkdir -p "$$dir/migrations"; \
cp evening_detective_server/bin/evening_detective_server "$$dir/backend/evening_detective_server"; \
cp evening_detective_server/migrations/*.sql "$$dir/migrations/"; \
done
CGO_ENABLED=0 GOBIN="$(abspath venue/backend)" go install github.com/pressly/goose/v3/cmd/goose@v3.27.1
cp evening_detective_server/scripts/dev-user.sql venue/backend/dev-user.sql
npm --prefix evening_detective_frontend run type-check
npm --prefix evening_detective_frontend run build-only -- --outDir "$(abspath $(PROD_DIR)/frontend/dist)" --emptyOutDir
VITE_API_URL=/. npm --prefix evening_detective_frontend run build-only -- --outDir "$(abspath venue/frontend/dist)" --emptyOutDir
prod-build:
rm -rf prod/backend prod/frontend/dist prod/migrations
mkdir -p prod/backend prod/frontend/dist prod/migrations
$(MAKE) -C evening_detective_server build-linux
cp evening_detective_server/bin/evening_detective_server prod/backend/evening_detective_server
npm --prefix evening_detective_frontend run build
cp -R evening_detective_frontend/dist/. prod/frontend/dist/
cp evening_detective_server/migrations/*.sql prod/migrations/
prod-build: build
venue:
bash venue/deploy.sh
venue-update:
bash venue/deploy.sh --build
publish:
@test -n "$(MSG)" || (echo 'Usage: make publish MSG="commit message"' >&2; exit 1)
+84 -1
View File
@@ -61,6 +61,75 @@ make dev-user
По умолчанию создаётся пользователь `dev@example.com` с паролем `dev123456`.
Для изменения используйте `DEV_USER_EMAIL` и `DEV_USER_PASSWORD`.
## Автономная площадка (ED-029, этапы 1–2)
`venue/` — окружение для проведения игры на площадке без интернета.
Локальная разработка описана выше и запускается отдельно.
Из корня workspace запустите:
```sh
make venue
```
Первый запуск с интернетом сам создаёт настройки и случайные секреты в
`venue/.env`, готовит образы, запускает миграции и создаёт организатора.
Повторные запуски используют готовое окружение без интернета.
Email и пароль для входа указаны в `VENUE_USER_EMAIL` и `VENUE_USER_PASSWORD`
в `venue/.env`; существующий файл настроек не перезаписывается.
Аккаунт организатора при запуске синхронизируется с этими значениями.
После изменения кода обновите площадку, пока интернет доступен:
```sh
make venue-update
```
Сайт доступен по `http://localhost:8088`; API и материалы обслуживаются тем же
прокси. Для подключения по LAN задайте `VENUE_BIND_IP=0.0.0.0` и
`APP_BASE_URL=http://<постоянный-IP-ноутбука>:8088` в `venue/.env`, затем
повторите `make venue`. Прежние домены и доверенный HTTPS
требуют отдельной настройки DNS и сертификатов на этапе 3.
Образы приложения, мигратора и прокси готовятся заранее. Runtime запускается
без сборки и скачивания; приложение, база и RustFS подключены только к
внутренней Docker-сети. Прокси дополнительно подключён к сети публикации порта.
База и RustFS сохраняются в именованных томах.
Миграции Goose выполняются до старта приложения. Для остановки без потери данных:
```sh
docker compose -f venue/docker-compose.yml --env-file venue/.env down
```
Не используйте `down -v`: это удаляет данные. После изменений кода или миграций
выполните `make venue-update`, пока интернет доступен. Секреты, сборка,
сертификаты и пакеты игры игнорируются Git в `venue/`.
`APP_MODE=venue` отключает SMTP, фоновые письма участия/отзывов и внешний webhook
регистрации. Аккаунты готовятся через bootstrap; почтовая регистрация аккаунта
и сброс пароля возвращают явную ошибку без изменения данных. Команды создаёт
организатор на странице игры; участие подтверждается по существующим правилам:
при создании в последние 24 часа до игры или при первом игровом действии.
Для дополнительных операторов используйте отдельные локальные email
и пароли в `.env` и снова выполните `make venue` (старые аккаунты сохраняются).
Используйте только тестовые игры до реализации возврата результатов онлайн.
Проверки для разработчика находятся в `tests/venue/` и не нужны для запуска
площадки. `deploy.py` проверяет сценарии запуска без Docker; `smoke.py` проверяет
работающее окружение и создаёт тестовый сценарий, игру, команду и материал через API:
```sh
python3 tests/venue/deploy.py
python3 tests/venue/smoke.py
docker compose -f venue/docker-compose.yml --env-file venue/.env down
docker compose -f venue/docker-compose.yml --env-file venue/.env up -d --no-build --pull never
python3 tests/venue/smoke.py --verify
```
Последняя команда проверяет ранее созданные записи и файл после перезапуска.
Тестовые данные остаются в локальной базе; повторное создание запускайте только
когда нужен новый набор. Дождитесь ответа `/api/test/ping` перед проверкой.
## Проверки перед изменениями
Сервер:
@@ -93,12 +162,26 @@ make publish MSG="Исправить подсчёт ходов в игре"
```
Команда вызывает локальные build-команды репозиториев, создаёт коммит с
указанным сообщением и выполняет `git push`. Для одной только сборки используйте:
указанным сообщением и выполняет `git push`.
Общая команда собирает бэкенд один раз и сохраняет артефакты в обе папки:
`prod/` и `venue/`. Обе используют `backend/evening_detective_server`,
`frontend/dist/` и `migrations/`; Compose называется `docker-compose.yml`,
образ бэкенда описан в `Dockerfile.backend`. Фронтенд собирается отдельно для
каждого окружения: production сохраняет текущие Vite-настройки, venue использует
API через свой прокси (`VITE_API_URL=/.`). Локальные Goose и SQL подготовки
аккаунта лежат в `venue/backend/`.
Для сборки артефактов обоих окружений используйте:
```sh
make build
```
`make prod-build` выполняет ту же общую сборку. `make venue-update` вызывает её
и дополнительно подготавливает Docker-образы для автономного запуска. Команды
обновляют только генерируемые артефакты в `prod`; конфигурация `prod` не меняется.
## Окружение и секреты
Файлы `.env` предназначены только для локальных значений и не коммитятся.
+14
View File
@@ -0,0 +1,14 @@
# Проверки площадки для разработчика
Для работы площадки эти файлы не нужны. Обычный запуск: `make venue`.
- `deploy.py` — проверяет первый запуск, запуск без интернета и обновление без Docker.
- `smoke.py` — проверяет работающую площадку через API. Без `--verify` создаёт
тестовые данные; с `--verify` проверяет ранее созданные данные.
Команды из корня проекта:
```sh
python3 tests/venue/deploy.py
python3 tests/venue/smoke.py --verify
```
+52
View File
@@ -0,0 +1,52 @@
"""Check first, offline and update runs without Docker or production writes."""
from pathlib import Path
import os
import shutil
import subprocess
import tempfile
source = Path(__file__).resolve().parents[2] / "venue" / "deploy.sh"
for name, cold, update in [("first", True, False), ("offline", False, False), ("update", False, True)]:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
venue = root / "venue"
venue.mkdir()
shutil.copy(source, venue / "deploy.sh")
log = root / "commands.log"
bin_dir = root / "bin"
bin_dir.mkdir()
for command in ["docker", "make", "npm"]:
stub = bin_dir / command
stub.write_text(
'#!/bin/sh\n'
'echo "$(basename "$0") $*" >> "$TEST_LOG"\n'
'if [ "$1 $2" = "image inspect" ] && [ "$TEST_COLD" = 1 ]; then exit 1; fi\n'
)
stub.chmod(0o755)
if not cold:
(venue / "migrations").mkdir()
(venue / ".env").write_text("EXISTING_SECRET=unchanged\n")
for artifact in ["backend/evening_detective_server", "backend/goose", "backend/dev-user.sql", "frontend/dist/index.html"]:
path = venue / artifact
path.parent.mkdir(parents=True, exist_ok=True)
path.touch()
env = {**os.environ, "PATH": str(bin_dir) + os.pathsep + os.environ["PATH"], "TEST_LOG": str(log), "TEST_COLD": str(int(cold))}
subprocess.run(["bash", str(venue / "deploy.sh"), *(["--build"] if update else [])], env=env, check=True, capture_output=True)
commands = log.read_text()
assert "volume create evening-detective-local_postgres_data" in commands, name
assert "volume create evening-detective-local_rustfs_data" in commands, name
assert "compose up -d --no-build --pull never" in commands, name
assert "compose --profile setup run --rm bootstrap" in commands, name
assert ("make -C .. build" in commands) == (cold or update), name
assert ("compose pull" in commands) == (cold or update), name
assert ("compose build --pull=false" in commands) == (cold or update), name
if cold:
config = dict(line.split("=", 1) for line in (venue / ".env").read_text().splitlines())
assert len(config["JWT_SECRET"]) == 64
assert len(config["VENUE_USER_PASSWORD"]) == 16
assert (venue / ".env").stat().st_mode & 0o777 == 0o600
else:
assert (venue / ".env").read_text() == "EXISTING_SECRET=unchanged\n", name
print("PASS: automatic first setup, offline reuse, explicit update and preserved secrets")
+110
View File
@@ -0,0 +1,110 @@
"""Create a venue test game, then verify persistence with --verify after restart."""
import base64
import datetime
import json
from pathlib import Path
import sys
import urllib.error
import urllib.request
root = Path(__file__).resolve().parents[2] / "venue"
assert (root / "backend" / "evening_detective_server").is_file(), "Backend artifact missing"
assert (root / "backend" / "goose").is_file(), "Migration tool missing"
assert (root / "backend" / "dev-user.sql").is_file(), "Account bootstrap SQL missing"
assert (root / "frontend" / "dist" / "index.html").is_file(), "Frontend artifact missing"
assert list((root / "migrations").glob("*.sql")), "Migrations missing"
config = dict(
line.split("=", 1)
for line in (root / ".env").read_text().splitlines()
if line and not line.startswith("#")
)
bind = config.get("VENUE_BIND_IP", "127.0.0.1")
if bind == "0.0.0.0":
bind = "127.0.0.1"
base = f'http://{bind}:{config.get("VENUE_PORT", "8088")}'
token = ""
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
def request(path, body=None, raw=False):
headers = {"Content-Type": "application/json"}
if token:
headers["Authorization"] = "Bearer " + token
req = urllib.request.Request(
base + path,
data=None if body is None else json.dumps(body).encode(),
headers=headers,
)
with opener.open(req, timeout=10) as response:
data = response.read()
return data if raw else json.loads(data)
def verify(state):
game = request(f'/api/games/{state["gameID"]}')["game"]
teams = game["teams"]
assert any(
team["ID"] == state["teamID"]
and team["participationStatus"] == "confirmed"
for team in teams
), "Test team or confirmed participation was not preserved"
assert request("/api/files/" + state["filename"], raw=True) == base64.b64decode(
state["data"]
), "Uploaded material was not preserved"
assert b'<div id="app">' in request("/games", raw=True), "SPA route failed"
try:
token = request(
"/api/auth/login",
{
"email": config["VENUE_USER_EMAIL"],
"password": config["VENUE_USER_PASSWORD"],
},
)["accessToken"]
state_file = root / "packages" / "smoke.json"
if "--verify" in sys.argv:
state = json.loads(state_file.read_text())
else:
scenario = request("/api/scenario", {"name": "TEST venue smoke"})["ID"]
start = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=2)
game = request(
"/api/games",
{
"name": "TEST venue smoke",
"description": "Disposable offline environment check",
"startAt": start.isoformat(),
"scenarioID": scenario,
"format": "online",
},
)["ID"]
team = request(
"/api/teams",
{"gameID": game, "name": "TEST team", "participantsCount": 2},
)["ID"]
data = base64.b64encode(b"%PDF-1.4\n%venue-smoke\n%%EOF\n").decode()
filename = request(
"/api/files/upload", {"filename": "venue-smoke.pdf", "data": data}
)["filename"]
state = {"gameID": game, "teamID": team, "filename": filename, "data": data}
state_file.parent.mkdir(exist_ok=True)
state_file.write_text(json.dumps(state))
verify(state)
for path, body in [
("/api/auth/signup", {"username": "Test", "email": "test@venue.invalid", "acceptTerms": True, "acceptPrivacy": True}),
("/api/auth/refresh-password", {"email": config["VENUE_USER_EMAIL"]}),
]:
try:
request(path, body)
except urllib.error.HTTPError as error:
assert error.code == 400, "Unexpected disabled operation status"
assert json.loads(error.read())["code"] == 9, "Expected FailedPrecondition"
else:
raise AssertionError("Email-based account operation succeeded locally")
print("PASS: login, test game, confirmed team, material, SPA and disabled email operations")
except urllib.error.HTTPError as error:
# Do not print response bodies: authentication responses may contain credentials.
print(f"FAIL: venue API returned HTTP {error.code}", file=sys.stderr)
sys.exit(1)
+7
View File
@@ -0,0 +1,7 @@
*
!Dockerfile.backend
!backend/
!backend/evening_detective_server
!backend/goose
!migrations/
!migrations/*.sql
+6
View File
@@ -0,0 +1,6 @@
.env
backend/
frontend/dist/
migrations/
certificates/
packages/
+5
View File
@@ -0,0 +1,5 @@
FROM alpine:3.22
COPY backend/evening_detective_server /usr/local/bin/evening_detective_server
COPY backend/goose /usr/local/bin/goose
COPY migrations /migrations
CMD ["/usr/local/bin/evening_detective_server"]
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
set -euo pipefail
cd -- "$(dirname -- "${BASH_SOURCE[0]}")"
if [[ ! -f .env ]]; then
command -v openssl >/dev/null
(
umask 077
cat > .env <<EOF
APP_BASE_URL=http://localhost:8088
VENUE_BIND_IP=127.0.0.1
VENUE_PORT=8088
POSTGRES_USER=evening_detective
POSTGRES_DB=evening_detective
POSTGRES_PASSWORD=$(openssl rand -hex 32)
JWT_SECRET=$(openssl rand -hex 32)
PARTICIPATION_SECRET=$(openssl rand -hex 32)
S3_USER=evening_detective
S3_PASSWORD=$(openssl rand -hex 32)
S3_BUCKET=evening-detective
GAME_TIMEZONE=Asia/Barnaul
VENUE_USER_USERNAME=Организатор
VENUE_USER_EMAIL=organizer@venue.invalid
VENUE_USER_PASSWORD=$(openssl rand -hex 8)
EOF
)
fi
prepare=false
case "${1:-}" in
--build) prepare=true ;;
'') ;;
*) echo 'Используйте make venue или make venue-update' >&2; exit 1 ;;
esac
for image in evening-detective-venue-app:1 postgres:15 rustfs/rustfs:latest byjg/static-httpserver:latest nginx:alpine; do
if ! docker image inspect "$image" >/dev/null 2>&1; then prepare=true; fi
done
for artifact in backend/evening_detective_server backend/goose backend/dev-user.sql frontend/dist/index.html; do
if [[ ! -f "$artifact" ]]; then prepare=true; fi
done
if [[ ! -d migrations ]]; then prepare=true; fi
if [[ "$prepare" == true ]]; then
echo 'Подготовка площадки: потребуется интернет.'
if ! docker image inspect my-go-builder >/dev/null 2>&1; then
make -C ../evening_detective_server build-builder
fi
if [[ ! -d ../evening_detective_frontend/node_modules ]]; then
npm --prefix ../evening_detective_frontend ci
fi
make -C .. build
docker compose pull postgres_evening_detective_server rustfs_evening_detective_server evening_detective_frontend proxy
docker pull alpine:3.22
docker compose build --pull=false
fi
for volume in evening-detective-local_postgres_data evening-detective-local_rustfs_data; do
docker volume create "$volume" >/dev/null
done
docker compose up -d --no-build --pull never
docker compose --profile setup run --rm bootstrap
docker compose ps
echo 'Площадка запущена. Адрес и данные входа находятся в venue/.env.'
+143
View File
@@ -0,0 +1,143 @@
name: evening-detective-venue
x-app: &app
image: evening-detective-venue-app:1
build:
context: .
dockerfile: Dockerfile.backend
environment:
APP_MODE: venue
DATABASE_URL: postgresql://${POSTGRES_USER:?}:${POSTGRES_PASSWORD:?}@postgres_evening_detective_server:5432/${POSTGRES_DB:?}?sslmode=disable
APP_BASE_URL: ${APP_BASE_URL:?}
FILE_PREFIX_DOMAIN: ${APP_BASE_URL:?}/api/files/
GAME_TIMEZONE: ${GAME_TIMEZONE:-Asia/Barnaul}
JWT_SECRET: ${JWT_SECRET:?}
PARTICIPATION_SECRET: ${PARTICIPATION_SECRET:?}
S3_HOST: http://rustfs_evening_detective_server:9000
S3_USER: ${S3_USER:?}
S3_PASSWORD: ${S3_PASSWORD:?}
S3_BUCKET: ${S3_BUCKET:-evening-detective}
services:
postgres_evening_detective_server:
image: postgres:15
environment:
POSTGRES_DB: ${POSTGRES_DB:?}
POSTGRES_USER: ${POSTGRES_USER:?}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?}
TZ: ${GAME_TIMEZONE:-Asia/Barnaul}
PGTZ: ${GAME_TIMEZONE:-Asia/Barnaul}
volumes:
- postgres_data:/var/lib/postgresql/data
restart: unless-stopped
healthcheck:
test: [CMD-SHELL, 'pg_isready -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}" -h localhost']
interval: 5s
timeout: 3s
retries: 12
rustfs_evening_detective_server:
image: rustfs/rustfs:latest
environment:
RUSTFS_ACCESS_KEY: ${S3_USER:?}
RUSTFS_SECRET_KEY: ${S3_PASSWORD:?}
RUSTFS_CONSOLE_ENABLE: "false"
RUSTFS_ADDRESS: 0.0.0.0:9000
volumes:
- rustfs_data:/data
restart: unless-stopped
healthcheck:
test: [CMD-SHELL, 'curl -f http://localhost:9000/health']
interval: 5s
timeout: 3s
retries: 12
start_period: 30s
migrate:
<<: *app
entrypoint: [/bin/sh, -ec]
command: ['exec goose -dir /migrations postgres "$$DATABASE_URL" up']
depends_on:
postgres_evening_detective_server:
condition: service_healthy
restart: "no"
evening_detective_server:
<<: *app
depends_on:
migrate:
condition: service_completed_successfully
rustfs_evening_detective_server:
condition: service_healthy
restart: unless-stopped
healthcheck:
test: [CMD, wget, -q, -T, '2', -O, /dev/null, 'http://127.0.0.1:8090/api/test/ping']
interval: 5s
timeout: 3s
retries: 12
bootstrap:
image: postgres:15
profiles: [setup]
environment:
PGHOST: postgres_evening_detective_server
PGUSER: ${POSTGRES_USER:?}
PGPASSWORD: ${POSTGRES_PASSWORD:?}
PGDATABASE: ${POSTGRES_DB:?}
VENUE_USER_USERNAME: ${VENUE_USER_USERNAME:-Организатор}
VENUE_USER_EMAIL: ${VENUE_USER_EMAIL:-organizer@venue.invalid}
VENUE_USER_PASSWORD: ${VENUE_USER_PASSWORD:?}
volumes:
- ./backend/dev-user.sql:/bootstrap.sql:ro
entrypoint: [/bin/sh, -ec]
command:
- >-
exec psql -v ON_ERROR_STOP=1
-v dev_username="$$VENUE_USER_USERNAME"
-v dev_email="$$VENUE_USER_EMAIL"
-v dev_password="$$VENUE_USER_PASSWORD"
-f /bootstrap.sql
depends_on:
migrate:
condition: service_completed_successfully
restart: "no"
evening_detective_frontend:
image: byjg/static-httpserver:latest
environment:
SPA_MODE: "true"
volumes:
- ./frontend/dist:/static:ro
expose:
- "8080"
restart: unless-stopped
proxy:
image: nginx:alpine
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
ports:
- '${VENUE_BIND_IP:-0.0.0.0}:${VENUE_PORT:-8088}:80'
depends_on:
evening_detective_server:
condition: service_healthy
evening_detective_frontend:
condition: service_started
networks:
- default
- entrance
restart: unless-stopped
volumes:
# Preserve existing data from the former Compose project.
postgres_data:
name: evening-detective-local_postgres_data
external: true
rustfs_data:
name: evening-detective-local_rustfs_data
external: true
networks:
default:
internal: true
entrance:
+17
View File
@@ -0,0 +1,17 @@
server {
listen 80;
server_name _;
client_max_body_size 257m;
location /api/ {
proxy_pass http://evening_detective_server:8090;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 120s;
}
location / {
proxy_pass http://evening_detective_frontend:8080;
proxy_set_header Host $host;
}
}