Compare commits

...
3 Commits
Author SHA1 Message Date
ik b574d94f4a docs: expand pilot readiness roadmap 2026-09-04 07:51:21 +07:00
ik 398b35e843 feat: add privacy-safe structured logging 2026-09-04 07:49:14 +07:00
ik 3b26c59219 feat: add dependency readiness checks 2026-09-04 07:38:34 +07:00
13 changed files with 339 additions and 25 deletions
+3
View File
@@ -10,5 +10,8 @@ S3_BUCKET=catch-screenshots
OFFICIAL_RECORDS_URL=https://rf4game.de/records/region/RU/ OFFICIAL_RECORDS_URL=https://rf4game.de/records/region/RU/
OFFICIAL_RECORDS_REGION=RU OFFICIAL_RECORDS_REGION=RU
OFFICIAL_RECORDS_CATEGORY=records OFFICIAL_RECORDS_CATEGORY=records
# true только если scheduler официального импорта обязателен для readiness
OFFICIAL_IMPORT_REQUIRED=false
IMPORT_INTERVAL_SECONDS=3600 IMPORT_INTERVAL_SECONDS=3600
RATE_LIMIT_SECRET=change-rate-limit-secret RATE_LIMIT_SECRET=change-rate-limit-secret
LOG_LEVEL=INFO
+6 -3
View File
@@ -46,10 +46,13 @@ docker compose up --build
- сайт: <http://localhost:4321>; - сайт: <http://localhost:4321>;
- OpenAPI: <http://localhost:8000/docs>; - OpenAPI: <http://localhost:8000/docs>;
- проверка API: <http://localhost:8000/health>; - liveness API: <http://localhost:8000/health>;
- readiness PostgreSQL, MinIO и импорта: <http://localhost:8000/ready>;
- консоль MinIO: <http://localhost:9001>. - консоль MinIO: <http://localhost:9001>.
Контейнер API сам выполняет `alembic upgrade head`, затем идемпотентный seed. PostgreSQL хранит данные в именованном volume `postgres_data`, а MinIO — в `minio_data`. Контейнер API сам выполняет `alembic upgrade head`, затем идемпотентный seed. PostgreSQL хранит данные в именованном volume `postgres_data`, а MinIO — в `minio_data`. Compose ожидает readiness PostgreSQL и MinIO перед API, а API-контейнер проверяет `/ready`. Официальный импорт по умолчанию необязателен; при включённом scheduler установите `OFFICIAL_IMPORT_REQUIRED=true`, тогда отсутствующий, неуспешный или просроченный запуск сделает readiness отрицательным.
API и scheduler пишут по одной JSON-записи на событие. HTTP-лог содержит только сгенерированный `request_id`, метод, путь без query string, статус и длительность; IP, заголовок авторизации и пользовательский payload не журналируются. `X-Request-ID` возвращается клиенту. Стандартный access-log Uvicorn отключён. Уровень управляется `LOG_LEVEL`.
Остановка: Остановка:
@@ -69,7 +72,7 @@ docker compose up --build
## Что реализовано ## Что реализовано
- FastAPI и SQLAlchemy 2; - FastAPI и SQLAlchemy 2;
- PostgreSQL 17 и миграции Alembic до `0008`; - PostgreSQL 17 и миграции Alembic до `0009`;
- идемпотентный seed с двумя точками и свежими демо-уловами; - идемпотентный seed с двумя точками и свежими демо-уловами;
- `GET /api/v1/activity` с фильтрами периода, водоёма, рыбы, способа и сортировки; - `GET /api/v1/activity` с фильтрами периода, водоёма, рыбы, способа и сортировки;
- `GET /api/v1/spots/{id}` и `/catches`; - `GET /api/v1/spots/{id}` и `/catches`;
+1 -1
View File
@@ -6,4 +6,4 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY apps/api . COPY apps/api .
COPY rf4_research ./rf4_research COPY rf4_research ./rf4_research
EXPOSE 8000 EXPOSE 8000
CMD ["sh", "-c", "alembic upgrade head && python -m app.seed && uvicorn app.main:app --host 0.0.0.0 --port 8000"] CMD ["sh", "-c", "alembic upgrade head && python -m app.seed && uvicorn app.main:app --host 0.0.0.0 --port 8000 --no-access-log"]
+2
View File
@@ -14,8 +14,10 @@ class Settings(BaseSettings):
official_records_url: str = "https://rf4game.de/records/region/RU/" official_records_url: str = "https://rf4game.de/records/region/RU/"
official_records_region: str = "RU" official_records_region: str = "RU"
official_records_category: str = "records" official_records_category: str = "records"
official_import_required: bool = False
import_interval_seconds: int = Field(default=3600, ge=3600) import_interval_seconds: int = Field(default=3600, ge=3600)
rate_limit_secret: str = "change-rate-limit-secret" rate_limit_secret: str = "change-rate-limit-secret"
log_level: str = "INFO"
model_config = SettingsConfigDict(env_file=".env", extra="ignore") model_config = SettingsConfigDict(env_file=".env", extra="ignore")
+48
View File
@@ -0,0 +1,48 @@
from __future__ import annotations
import json
import logging
import re
from datetime import datetime, timezone
SAFE_FIELDS = (
"request_id", "method", "path", "status_code", "duration_ms",
"component", "event", "result", "rows_seen", "rows_created",
"rows_updated", "not_modified", "interval_seconds", "error_type",
)
SENSITIVE = re.compile(
r"(?i)(bearer\s+)[^\s]+|((?:token|password|secret|authorization)\s*[=:]\s*)[^\s,;]+"
)
def redact(value: str) -> str:
return SENSITIVE.sub(lambda match: f"{match.group(1) or match.group(2)}[REDACTED]", value)
class JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
payload: dict[str, object] = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"level": record.levelname.lower(),
"logger": record.name,
"message": redact(record.getMessage()),
}
for field in SAFE_FIELDS:
value = getattr(record, field, None)
if value is not None:
payload[field] = value
if record.exc_info and "error_type" not in payload:
payload["error_type"] = record.exc_info[0].__name__
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"), default=str)
def configure_logging(level: str = "INFO") -> None:
root = logging.getLogger()
if not any(getattr(handler, "_rf4_json", False) for handler in root.handlers):
handler = logging.StreamHandler()
handler.setFormatter(JsonFormatter())
handler._rf4_json = True # type: ignore[attr-defined]
root.handlers.clear()
root.addHandler(handler)
root.setLevel(getattr(logging, level.upper(), logging.INFO))
+46 -1
View File
@@ -4,12 +4,16 @@ from collections import Counter
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
import hashlib import hashlib
import hmac import hmac
import logging
import time as time_module
from typing import Annotated, Literal from typing import Annotated, Literal
from uuid import UUID from uuid import UUID
import uuid
import httpx import httpx
from fastapi import Depends, FastAPI, File, Header, HTTPException, Query, Request, Response, UploadFile from fastapi import Depends, FastAPI, File, Header, HTTPException, Query, Request, Response, UploadFile
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from sqlalchemy import delete, func, select, text from sqlalchemy import delete, func, select, text
from sqlalchemy.orm import Session, joinedload from sqlalchemy.orm import Session, joinedload
@@ -18,11 +22,15 @@ from .database import get_session
from .config import settings from .config import settings
from .community_review import ExternalReviewError, map_observation, publish_observation, reject_observation from .community_review import ExternalReviewError, map_observation, publish_observation, reject_observation
from .importer import ImportSourceError, import_records, normalize from .importer import ImportSourceError, import_records, normalize
from .logging_config import configure_logging
from .models import Bait, BaitKind, CatchReport, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody from .models import Bait, BaitKind, CatchReport, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody
from .readiness import readiness_report
from .schemas import ActivityOut, AdminCatchReportOut, BaitOut, CatchOut, CatchReportCreate, CatchReportCreated, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, FishOut, ImportRunOut, ModerationUpdate, OfficialRecordOut, SpotOut, WaterbodyOut from .schemas import ActivityOut, AdminCatchReportOut, BaitOut, CatchOut, CatchReportCreate, CatchReportCreated, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, FishOut, ImportRunOut, ModerationUpdate, OfficialRecordOut, SpotOut, WaterbodyOut
from .storage import ScreenshotError, delete_screenshot, signed_screenshot_url, upload_screenshot from .storage import ScreenshotError, client as storage_client, delete_screenshot, signed_screenshot_url, upload_screenshot
configure_logging(settings.log_level)
logger = logging.getLogger("rf4.api")
app = FastAPI(title="RF4 Spotter API", version="0.1.0") app = FastAPI(title="RF4 Spotter API", version="0.1.0")
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
@@ -33,11 +41,48 @@ app.add_middleware(
Db = Annotated[Session, Depends(get_session)] Db = Annotated[Session, Depends(get_session)]
@app.middleware("http")
async def structured_request_log(request: Request, call_next):
request_id = uuid.uuid4().hex
started = time_module.perf_counter()
status_code = 500
try:
response = await call_next(request)
status_code = response.status_code
response.headers["X-Request-ID"] = request_id
return response
except Exception as exc:
logger.error("request failed", extra={"request_id": request_id, "error_type": type(exc).__name__})
raise
finally:
logger.log(
logging.DEBUG if request.url.path in {"/health", "/ready"} else logging.INFO,
"request completed",
extra={
"request_id": request_id, "method": request.method,
"path": request.url.path, "status_code": status_code,
"duration_ms": round((time_module.perf_counter() - started) * 1000, 2),
},
)
@app.get("/health") @app.get("/health")
def health() -> dict[str, str]: def health() -> dict[str, str]:
return {"status": "ok"} return {"status": "ok"}
@app.get("/ready")
def ready(db: Db) -> JSONResponse:
is_ready, components = readiness_report(
db, storage_client(), import_required=settings.official_import_required,
import_interval_seconds=settings.import_interval_seconds,
)
return JSONResponse(
status_code=200 if is_ready else 503,
content={"status": "ready" if is_ready else "not_ready", "components": components},
)
@app.get("/api/v1/fishes", response_model=list[FishOut]) @app.get("/api/v1/fishes", response_model=list[FishOut])
def fishes(db: Db) -> list[Fish]: def fishes(db: Db) -> list[Fish]:
return list(db.scalars(select(Fish).order_by(Fish.name_ru))) return list(db.scalars(select(Fish).order_by(Fish.name_ru)))
+61
View File
@@ -0,0 +1,61 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any
from sqlalchemy import select, text
from sqlalchemy.orm import Session
from .models import ImportStatus, OfficialRecordImport
def readiness_report(
session: Session, s3: Any, *, import_required: bool,
import_interval_seconds: int, now: datetime | None = None,
) -> tuple[bool, dict[str, dict[str, object]]]:
current = now or datetime.now(timezone.utc)
components: dict[str, dict[str, object]] = {}
ready = True
try:
session.execute(text("SELECT 1"))
components["postgresql"] = {"status": "ready"}
except Exception:
components["postgresql"] = {"status": "unavailable"}
ready = False
try:
s3.list_buckets()
components["minio"] = {"status": "ready"}
except Exception:
components["minio"] = {"status": "unavailable"}
ready = False
try:
latest = session.scalar(select(OfficialRecordImport).order_by(
OfficialRecordImport.started_at.desc(), OfficialRecordImport.id.desc(),
).limit(1))
if not import_required:
components["official_import"] = {
"status": "optional",
"last_run_status": latest.status.value if latest else None,
}
elif latest is None:
components["official_import"] = {"status": "not_run"}
ready = False
else:
started = latest.started_at if latest.started_at.tzinfo else latest.started_at.replace(tzinfo=timezone.utc)
stale = started < current - timedelta(seconds=import_interval_seconds * 2)
healthy = latest.status == ImportStatus.success and not stale
components["official_import"] = {
"status": "ready" if healthy else ("stale" if stale else latest.status.value),
"last_run_status": latest.status.value,
"last_started_at": started.isoformat(),
}
ready = ready and healthy
except Exception:
components["official_import"] = {"status": "unknown"}
if import_required:
ready = False
return ready, components
+10 -5
View File
@@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
from .config import settings from .config import settings
from .database import SessionLocal from .database import SessionLocal
from .importer import import_records from .importer import import_records
from .logging_config import configure_logging
from .models import OfficialRecordImport from .models import OfficialRecordImport
@@ -42,20 +43,24 @@ def run_due_import() -> bool:
category=settings.official_records_category, category=settings.official_records_category,
) )
logger.info( logger.info(
"official import completed status=%s seen=%d created=%d updated=%d not_modified=%s", "official import completed",
run.status.value, run.rows_seen, run.rows_created, run.rows_updated, run.not_modified, extra={
"event": "official_import_completed", "result": run.status.value,
"rows_seen": run.rows_seen, "rows_created": run.rows_created,
"rows_updated": run.rows_updated, "not_modified": run.not_modified,
},
) )
return True return True
def main() -> None: def main() -> None:
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s") configure_logging(settings.log_level)
logger.info("scheduler started interval_seconds=%d", settings.import_interval_seconds) logger.info("scheduler started", extra={"event": "scheduler_started", "interval_seconds": settings.import_interval_seconds})
while True: while True:
try: try:
run_due_import() run_due_import()
except Exception: except Exception:
logger.exception("scheduled official import failed") logger.exception("scheduled official import failed", extra={"event": "official_import_failed"})
time.sleep(settings.import_interval_seconds) time.sleep(settings.import_interval_seconds)
+6
View File
@@ -55,6 +55,12 @@ def test_invalid_period_is_rejected() -> None:
assert client.get("/api/v1/activity?sort=unknown").status_code == 422 assert client.get("/api/v1/activity?sort=unknown").status_code == 422
def test_liveness_does_not_probe_dependencies() -> None:
response = client.get("/health?token=must-not-be-logged")
assert response.json() == {"status": "ok"}
assert len(response.headers["X-Request-ID"]) == 32
def test_spot_detail_and_catches() -> None: def test_spot_detail_and_catches() -> None:
spot_id = client.get("/api/v1/activity").json()[0]["spot_id"] spot_id = client.get("/api/v1/activity").json()[0]["spot_id"]
detail = client.get(f"/api/v1/spots/{spot_id}") detail = client.get(f"/api/v1/spots/{spot_id}")
+33
View File
@@ -0,0 +1,33 @@
from __future__ import annotations
import json
import logging
from app.logging_config import JsonFormatter, redact
def test_json_formatter_whitelists_fields_and_redacts_secrets() -> None:
record = logging.LogRecord(
name="rf4.test", level=logging.INFO, pathname=__file__, lineno=1,
msg="authorization=Bearer-secret token=top-secret Bearer abc.def",
args=(), exc_info=None,
)
record.request_id = "safe-request-id"
record.method = "GET"
record.path = "/api/v1/activity"
record.player_name = "Must Not Leak"
payload = json.loads(JsonFormatter().format(record))
assert payload["request_id"] == "safe-request-id"
assert payload["method"] == "GET"
assert payload["path"] == "/api/v1/activity"
assert "player_name" not in payload
assert "top-secret" not in payload["message"]
assert "abc.def" not in payload["message"]
assert payload["message"].count("[REDACTED]") == 3
def test_redact_covers_common_credential_forms() -> None:
cleaned = redact("password=hunter2 secret: swordfish Authorization: token-value")
assert "hunter2" not in cleaned
assert "swordfish" not in cleaned
assert "token-value" not in cleaned
+74
View File
@@ -0,0 +1,74 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from app.database import Base
from app.models import ImportStatus, OfficialRecordImport
from app.readiness import readiness_report
class AvailableStorage:
def list_buckets(self) -> dict[str, list[object]]:
return {"Buckets": []}
class UnavailableStorage:
def list_buckets(self) -> None:
raise ConnectionError("fixture unavailable")
def test_optional_import_does_not_block_dependencies() -> None:
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
with Session(engine) as session:
ready, components = readiness_report(
session, AvailableStorage(), import_required=False, import_interval_seconds=3600,
)
assert ready is True
assert components == {
"postgresql": {"status": "ready"},
"minio": {"status": "ready"},
"official_import": {"status": "optional", "last_run_status": None},
}
def test_required_import_must_be_recent_and_successful() -> None:
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
now = datetime.now(timezone.utc)
with Session(engine) as session:
session.add(OfficialRecordImport(
started_at=now - timedelta(minutes=30), finished_at=now - timedelta(minutes=29),
status=ImportStatus.success, source_url="fixture://records", rows_seen=1,
rows_created=1, rows_updated=0,
))
session.commit()
ready, components = readiness_report(
session, AvailableStorage(), import_required=True,
import_interval_seconds=3600, now=now,
)
assert ready is True
assert components["official_import"]["status"] == "ready"
def test_unavailable_storage_and_stale_import_fail_readiness() -> None:
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
now = datetime.now(timezone.utc)
with Session(engine) as session:
session.add(OfficialRecordImport(
started_at=now - timedelta(hours=3), finished_at=now - timedelta(hours=3),
status=ImportStatus.success, source_url="fixture://records", rows_seen=1,
rows_created=1, rows_updated=0,
))
session.commit()
ready, components = readiness_report(
session, UnavailableStorage(), import_required=True,
import_interval_seconds=3600, now=now,
)
assert ready is False
assert components["minio"]["status"] == "unavailable"
assert components["official_import"]["status"] == "stale"
+15 -2
View File
@@ -22,6 +22,11 @@ services:
ports: ports:
- "9000:9000" - "9000:9000"
- "9001:9001" - "9001:9001"
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:9000/minio/health/live"]
interval: 5s
timeout: 3s
retries: 10
volumes: volumes:
- minio_data:/data - minio_data:/data
@@ -40,16 +45,18 @@ services:
OFFICIAL_RECORDS_URL: ${OFFICIAL_RECORDS_URL:-https://rf4game.de/records/region/RU/} OFFICIAL_RECORDS_URL: ${OFFICIAL_RECORDS_URL:-https://rf4game.de/records/region/RU/}
OFFICIAL_RECORDS_REGION: ${OFFICIAL_RECORDS_REGION:-RU} OFFICIAL_RECORDS_REGION: ${OFFICIAL_RECORDS_REGION:-RU}
OFFICIAL_RECORDS_CATEGORY: ${OFFICIAL_RECORDS_CATEGORY:-records} OFFICIAL_RECORDS_CATEGORY: ${OFFICIAL_RECORDS_CATEGORY:-records}
OFFICIAL_IMPORT_REQUIRED: ${OFFICIAL_IMPORT_REQUIRED:-false}
RATE_LIMIT_SECRET: ${RATE_LIMIT_SECRET:-change-rate-limit-secret} RATE_LIMIT_SECRET: ${RATE_LIMIT_SECRET:-change-rate-limit-secret}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
minio: minio:
condition: service_started condition: service_healthy
ports: ports:
- "8000:8000" - "8000:8000"
healthcheck: healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"] test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/ready')"]
interval: 5s interval: 5s
timeout: 3s timeout: 3s
retries: 12 retries: 12
@@ -64,6 +71,11 @@ services:
condition: service_healthy condition: service_healthy
ports: ports:
- "4321:4321" - "4321:4321"
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://localhost:4321').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
interval: 5s
timeout: 3s
retries: 10
importer: importer:
build: build:
@@ -89,6 +101,7 @@ services:
OFFICIAL_RECORDS_REGION: ${OFFICIAL_RECORDS_REGION:-RU} OFFICIAL_RECORDS_REGION: ${OFFICIAL_RECORDS_REGION:-RU}
OFFICIAL_RECORDS_CATEGORY: ${OFFICIAL_RECORDS_CATEGORY:-records} OFFICIAL_RECORDS_CATEGORY: ${OFFICIAL_RECORDS_CATEGORY:-records}
IMPORT_INTERVAL_SECONDS: ${IMPORT_INTERVAL_SECONDS:-3600} IMPORT_INTERVAL_SECONDS: ${IMPORT_INTERVAL_SECONDS:-3600}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
depends_on: depends_on:
api: api:
condition: service_healthy condition: service_healthy
+34 -13
View File
@@ -2,6 +2,8 @@
Этот файл — рабочий источник правды по развитию проекта. После завершения задачи её чекбокс меняется с `[ ]` на `[x]`, рядом добавляется ссылка на коммит или короткое подтверждение проверки. Новые задачи добавляются в соответствующий этап, а не хранятся только в переписке. Этот файл — рабочий источник правды по развитию проекта. После завершения задачи её чекбокс меняется с `[ ]` на `[x]`, рядом добавляется ссылка на коммит или короткое подтверждение проверки. Новые задачи добавляются в соответствующий этап, а не хранятся только в переписке.
Последняя сверка плана со спецификацией и кодом: 4 сентября 2026 года.
Обозначения: Обозначения:
- `[x]` — выполнено и проверено; - `[x]` — выполнено и проверено;
@@ -21,7 +23,8 @@
- [x] Добавить административный endpoint ручного запуска импорта `POST /api/v1/admin/imports/official-records` (проверено API-тестом). - [x] Добавить административный endpoint ручного запуска импорта `POST /api/v1/admin/imports/official-records` (проверено API-тестом).
- [x] Привести журнал импорта к административному контракту `GET /api/v1/admin/imports` с авторизацией, пагинацией и стабильной сортировкой (проверено API-тестом). - [x] Привести журнал импорта к административному контракту `GET /api/v1/admin/imports` с авторизацией, пагинацией и стабильной сортировкой (проверено API-тестом).
- [x] Добавить HTTP-кэширование источника (`ETag`/`Last-Modified`, если источник их отдаёт) и сохранить диагностические метаданные ответа (миграция `0005`, тест условного запроса и `304`). - [x] Добавить HTTP-кэширование источника (`ETag`/`Last-Modified`, если источник их отдаёт) и сохранить диагностические метаданные ответа (миграция `0005`, тест условного запроса и `304`).
- [x] Добавить планировщик импорта с безопасной частотой по умолчанию один раз в 60 минут; отдельный контейнер/процесс без дублирования запусков (opt-in профиль `scheduler`, обычным запуском не активируется). - [x] Добавить планировщик импорта с безопасной частотой по умолчанию один раз в 60 минут; отдельный opt-in контейнер/процесс (профиль `scheduler`, обычным запуском не активируется).
- [ ] Защитить официальный импорт PostgreSQL advisory lock или эквивалентом, чтобы ручной endpoint и несколько scheduler-процессов не импортировали одну категорию одновременно.
- [x] Проверить актуальные `robots.txt` и условия использования перед включением расписания; результат записать в `docs/data-sources.md` (`robots.txt` вернул `404`; автоматический профиль оставлен выключенным до явного разрешения). - [x] Проверить актуальные `robots.txt` и условия использования перед включением расписания; результат записать в `docs/data-sources.md` (`robots.txt` вернул `404`; автоматический профиль оставлен выключенным до явного разрешения).
- [x] Добавить интеграционные тесты: повторный импорт не создаёт дубликаты, сбой источника не удаляет данные, изменение DOM завершается понятной ошибкой. - [x] Добавить интеграционные тесты: повторный импорт не создаёт дубликаты, сбой источника не удаляет данные, изменение DOM завершается понятной ошибкой.
@@ -53,15 +56,23 @@
## Подготовка MVP к пилоту ## Подготовка MVP к пилоту
- [ ] Добавить health/readiness-проверки PostgreSQL, MinIO, API и импорта; отразить их в Compose. - [x] Добавить health/readiness-проверки PostgreSQL, MinIO, API и импорта; отразить их в Compose (`/health` без зависимостей, `/ready` с компонентами и режимом обязательного импорта).
- [ ] Добавить структурированные логи без пользовательских секретов и персональных технических данных. - [x] Добавить структурированные JSON-логи без пользовательских секретов и персональных технических данных (whitelist полей, redaction, request ID; Uvicorn access-log отключён).
- [ ] Добавить резервное копирование и документированное восстановление PostgreSQL и MinIO. - [ ] Добавить CI: backend tests, Astro check/build, E2E и применение всех миграций на чистой PostgreSQL; сохранять диагностические артефакты при падении.
- [ ] Провести security-проверку admin-аутентификации, CORS, заголовков, загрузок и управления секретами. - [ ] Добавить отдельный тест полного bootstrap: пустые volumes → миграции → seed → readiness → основной E2E.
- [ ] Добавить CI: backend tests, Astro check/build, E2E и проверка миграций на чистой БД. - [ ] Сделать seed устойчивым к частично заполненной БД и покрыть повторный/частичный запуск тестом; текущая реализация прекращает работу при наличии любой рыбы.
- [ ] Проверить списочные API по требованию раздела 12: пагинация, предсказуемая сортировка и валидация фильтров для справочников, импортов, модерации и внешнего staging.
- [ ] Проверить необходимые индексы PostgreSQL и планы запросов для activity, модерации, дедупликации и очистки rate limit; зафиксировать допустимый бюджет запросов пилота.
- [ ] Провести security-проверку admin-аутентификации, CORS, security headers, загрузок и управления секретами; вынести допустимые origins в конфигурацию и исключить демонстрационные секреты в production-режиме.
- [ ] Проверить авторизацию повторной загрузки скриншота: один UUID pending-заявки не должен быть достаточным полномочием для изменения чужой записи.
- [ ] Определить сроки хранения ников, исходных payload, staging-наблюдений, moderation events и submission attempts; добавить документированную очистку/анонимизацию.
- [ ] Добавить резервное копирование и документированное восстановление PostgreSQL и MinIO; проверить восстановление на отдельных временных volumes.
- [ ] Проверить доступность интерфейса: клавиатура, focus states, контраст, подписи полей и семантика таблиц/карточек. - [ ] Проверить доступность интерфейса: клавиатура, focus states, контраст, подписи полей и семантика таблиц/карточек.
- [ ] Провести Lighthouse-проверку основных страниц и устранить критические проблемы производительности. - [ ] Провести Lighthouse-проверку основных страниц и устранить критические проблемы производительности.
- [ ] Обновить README: архитектура, все переменные окружения, импорт, модерация, backup/restore и известные ограничения. - [ ] Добавить smoke-проверку административной очереди внешних источников на desktop/mobile без публикации реальных записей.
- [ ] Обновить README: архитектура, все переменные окружения, импорт, модерация, backup/restore, эксплуатация логов и известные ограничения.
- [ ] Выбрать лицензию кода и политику использования данных. - [ ] Выбрать лицензию кода и политику использования данных.
- [ ] Определить production-профиль Compose/развёртывания: TLS/reverse proxy, домены, CORS, volumes, restart policy, resource limits и порядок обновления миграций.
- [ ] Заменить демонстрационные секреты и определить целевое размещение перед внешней публикацией. - [ ] Заменить демонстрационные секреты и определить целевое размещение перед внешней публикацией.
## Источники данных и согласование ## Источники данных и согласование
@@ -73,13 +84,19 @@
- [x] Добавить отдельную фикстуру и безопасный ручной импорт недельных официальных рекордов одной категории. - [x] Добавить отдельную фикстуру и безопасный ручной импорт недельных официальных рекордов одной категории.
- [x] Получено подтверждение владельца проекта о разрешениях RF4DB и RF4-STAT; добавлены пилотные HTML-парсеры и отчёт `docs/community-source-pilot.md`. - [x] Получено подтверждение владельца проекта о разрешениях RF4DB и RF4-STAT; добавлены пилотные HTML-парсеры и отчёт `docs/community-source-pilot.md`.
- [x] Добавлены общий nullable-контракт, парсер detail-страницы RF4DB и ограниченный read-only CLI для RF4DB/RF4-STAT. - [x] Добавлены общий nullable-контракт, парсер detail-страницы RF4DB и ограниченный read-only CLI для RF4DB/RF4-STAT.
- [x] Зафиксировать проектное подтверждение разрешений, текущую атрибуцию и консервативные пилотные лимиты в `docs/data-permissions.md`; первичный документ и точные продуктивные условия ещё обязательны перед scheduler. - [x] Зафиксировать проектное подтверждение разрешений, текущую атрибуцию и консервативные пилотные лимиты в `docs/data-permissions.md`.
- [ ] Приложить или сослаться на первичный документ разрешения и зафиксировать точные продуктивные лимиты, срок хранения, удаление и обязательную атрибуцию до включения scheduler RF4DB/RF4-STAT.
- [x] Добавить staging-модель внешних наблюдений и идемпотентный импорт RF4DB/RF4-STAT без автоматического влияния на индекс (миграция `0008`, сквозной контрактный тест). - [x] Добавить staging-модель внешних наблюдений и идемпотентный импорт RF4DB/RF4-STAT без автоматического влияния на индекс (миграция `0008`, сквозной контрактный тест).
- [x] Добавить административную очередь сопоставления staging-записей с каноническими рыбами/водоёмами и явную публикацию в `catch_report` (`/admin/external-sources`, миграция `0009`; неполные записи публиковать запрещено). - [x] Добавить административную очередь сопоставления staging-записей с каноническими рыбами/водоёмами и явную публикацию в `catch_report` (`/admin/external-sources`, миграция `0009`; неполные записи публиковать запрещено).
- [ ] Найти разрешённый способ получать полные наблюдения с рыбой, водоёмом, координатами и весом из одного источника либо через подтверждённый общий ID; текущие 139 записей неполны и не публикуются.
- [ ] Добавить безопасные подсказки алиасов по уже подтверждённым сопоставлениям без автоматической публикации и тест конфликтующих алиасов.
- [ ] Добавить управляемый повторный импорт staging с отчётом `created/updated/rejected`, лимитами запросов и наблюдаемым отказом при изменении DOM; регулярный запуск оставить выключенным до фиксации условий.
- [ ] Определить процедуру повторной проверки опубликованного внешнего улова при изменении или удалении записи у источника.
- [ ] Согласовать один добровольный канал сообщества и правила происхождения, модерации и удаления сообщений. - [ ] Согласовать один добровольный канал сообщества и правила происхождения, модерации и удаления сообщений.
## Этап 5 — пилот ## Этап 5 — пилот
- [ ] Зафиксировать короткий сценарий приёмки пилота и измеримые критерии: успешная отправка/модерация, понятность оценки, свежесть данных и допустимое время ответа.
- [ ] Согласовать первые категории рекордов, водоёмы и виды рыб. - [ ] Согласовать первые категории рекордов, водоёмы и виды рыб.
- [ ] Наполнить базу небольшим разрешённым набором реальных данных. - [ ] Наполнить базу небольшим разрешённым набором реальных данных.
- [ ] Провести тестирование с несколькими игроками по подготовленному сценарию. - [ ] Провести тестирование с несколькими игроками по подготовленному сценарию.
@@ -89,12 +106,16 @@
## Ближайший рабочий пакет ## Ближайший рабочий пакет
Лёгкий пакет по парсерам и фильтрам завершён. Следующий пакет готовит MVP к пилоту: Технические health/readiness и безопасные логи готовы. Следующие пункты выполняются строго по одному:
1. health/readiness PostgreSQL, MinIO, API и импорта; 1. CI: backend, Astro, E2E и миграции на чистой PostgreSQL;
2. структурированные логи без пользовательских секретов; 2. защита официального импорта от конкурентных запусков;
3. CI для тестов, Astro build, E2E и миграций; 3. полный bootstrap-тест и исправление seed для частично заполненной БД;
4. backup/restore PostgreSQL и MinIO. 4. security-аудит admin/CORS/headers/secrets и повторной загрузки скриншота;
5. backup/restore PostgreSQL и MinIO с реальной проверкой восстановления;
6. пагинация/сортировка списочных API и индексы PostgreSQL;
7. accessibility и Lighthouse;
8. production-профиль и финальное обновление README.
После каждого пункта необходимо: После каждого пункта необходимо: