Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b574d94f4a | ||
|
|
398b35e843 | ||
|
|
3b26c59219 |
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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"]
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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)))
|
||||||
|
|||||||
@@ -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,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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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.
|
||||||
|
|
||||||
После каждого пункта необходимо:
|
После каждого пункта необходимо:
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user