From 4d79c8269565603656e369d26ae876d82bdd8866 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Fri, 18 Sep 2026 21:50:25 +0800 Subject: [PATCH] feat: launch PK testing application --- .dockerignore | 9 + .env.example | 12 ++ .gitea/workflows/docker-deploy.yml | 72 ++++++++ .gitignore | 8 + Dockerfile | 19 +++ README.md | 29 ++++ compose.yml | 47 ++++++ docs/architecture.md | 67 ++++++++ pyproject.toml | 32 ++++ scripts/deploy.sh | 10 ++ src/pk_lab_server/__init__.py | 2 + src/pk_lab_server/api.py | 260 +++++++++++++++++++++++++++++ src/pk_lab_server/config.py | 38 +++++ src/pk_lab_server/db.py | 22 +++ src/pk_lab_server/main.py | 44 +++++ src/pk_lab_server/models.py | 101 +++++++++++ src/pk_lab_server/schemas.py | 87 ++++++++++ src/pk_lab_server/security.py | 86 ++++++++++ src/pk_lab_server/seeds.py | 126 ++++++++++++++ src/pk_lab_server/xhs_auth.py | 65 ++++++++ tests/conftest.py | 7 + tests/test_api.py | 46 +++++ 22 files changed, 1189 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitea/workflows/docker-deploy.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 compose.yml create mode 100644 docs/architecture.md create mode 100644 pyproject.toml create mode 100644 scripts/deploy.sh create mode 100644 src/pk_lab_server/__init__.py create mode 100644 src/pk_lab_server/api.py create mode 100644 src/pk_lab_server/config.py create mode 100644 src/pk_lab_server/db.py create mode 100644 src/pk_lab_server/main.py create mode 100644 src/pk_lab_server/models.py create mode 100644 src/pk_lab_server/schemas.py create mode 100644 src/pk_lab_server/security.py create mode 100644 src/pk_lab_server/seeds.py create mode 100644 src/pk_lab_server/xhs_auth.py create mode 100644 tests/conftest.py create mode 100644 tests/test_api.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..85f78e2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +.gitea +.pytest_cache +.ruff_cache +__pycache__ +*.pyc +.env +pk-lab.db + diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d82c4f6 --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +DEPLOY_IMAGE=pk-lab-server:local +DEPLOY_PORT=18082 +POSTGRES_PASSWORD=replace-with-a-long-random-value +TOKEN_SECRET=replace-with-at-least-32-random-bytes +PUBLIC_BASE_URL=https://api.example.com +CORS_ORIGINS=https://pk.example.com,https://rice.example.com +TEST_AUTH_ENABLED=false +XHS_HUB_APP_ID= +XHS_HUB_APP_SECRET= +XHS_RICE_APP_ID= +XHS_RICE_APP_SECRET= + diff --git a/.gitea/workflows/docker-deploy.yml b/.gitea/workflows/docker-deploy.yml new file mode 100644 index 0000000..796a063 --- /dev/null +++ b/.gitea/workflows/docker-deploy.yml @@ -0,0 +1,72 @@ +name: Verify and deploy API + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +concurrency: + group: pk-lab-server-production + cancel-in-progress: false + +jobs: + verify-deploy: + runs-on: ubuntu-latest + container: + image: docker:27-cli + timeout-minutes: 30 + env: + SOURCE_SERVER: ${{ gitea.server_url }} + SOURCE_REPOSITORY: ${{ gitea.repository }} + SOURCE_REVISION: ${{ gitea.sha }} + DEPLOY_PORT: ${{ vars.DEPLOY_PORT || '18082' }} + PUBLIC_BASE_URL: ${{ vars.PUBLIC_BASE_URL || '' }} + CORS_ORIGINS: ${{ vars.CORS_ORIGINS || 'http://47.76.92.159:3001,http://47.76.92.159:3002,http://localhost:3001,http://localhost:3002' }} + TEST_AUTH_ENABLED: ${{ vars.TEST_AUTH_ENABLED || 'true' }} + POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} + TOKEN_SECRET: ${{ secrets.TOKEN_SECRET }} + XHS_HUB_APP_ID: ${{ secrets.XHS_HUB_APP_ID }} + XHS_HUB_APP_SECRET: ${{ secrets.XHS_HUB_APP_SECRET }} + XHS_RICE_APP_ID: ${{ secrets.XHS_RICE_APP_ID }} + XHS_RICE_APP_SECRET: ${{ secrets.XHS_RICE_APP_SECRET }} + steps: + - name: Prepare runner and fetch exact revision + env: + SOURCE_TOKEN: ${{ secrets.GITEA_TOKEN }} + run: | + set -eu + apk add --no-cache python3 curl + docker info >/dev/null + python3 - <<'PY' + import io, os, shutil, tarfile, tempfile, urllib.request + from pathlib import Path + url = f"{os.environ['SOURCE_SERVER'].rstrip('/')}/api/v1/repos/{os.environ['SOURCE_REPOSITORY']}/archive/{os.environ['SOURCE_REVISION']}.tar.gz" + request = urllib.request.Request(url, headers={'Authorization': 'token ' + os.environ['SOURCE_TOKEN']}) + with urllib.request.urlopen(request, timeout=60) as response: + data = response.read() + with tempfile.TemporaryDirectory() as directory: + with tarfile.open(fileobj=io.BytesIO(data), mode='r:gz') as archive: + archive.extractall(directory, filter='data') + roots = list(Path(directory).iterdir()) + if len(roots) != 1: + raise RuntimeError('Unexpected archive structure') + shutil.copytree(roots[0], Path.cwd(), dirs_exist_ok=True) + PY + - name: Run checks in image build + run: docker build --target test --tag "pk-lab-server:$SOURCE_REVISION-test" . + - name: Build runtime image + if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/main' + run: docker build --target runtime --tag "pk-lab-server:$SOURCE_REVISION" . + - name: Update permanent stack without deleting data + if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/main' + run: | + set -eu + test -n "$POSTGRES_PASSWORD" + test -n "$TOKEN_SECRET" + export DEPLOY_IMAGE="pk-lab-server:$SOURCE_REVISION" + sh scripts/deploy.sh + curl --fail --silent --show-error "http://127.0.0.1:$DEPLOY_PORT/health" + - name: Probe public endpoint + if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/main' && env.PUBLIC_BASE_URL != '' + run: curl --fail --silent --show-error --connect-timeout 10 --max-time 20 "${PUBLIC_BASE_URL%/}/health" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b37489d --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.env +.venv/ +__pycache__/ +*.py[cod] +.pytest_cache/ +.ruff_cache/ +pk-lab.db + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b0e5e9b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.12-slim-bookworm AS base +ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1 +WORKDIR /app +RUN addgroup --system --gid 1001 app && adduser --system --uid 1001 --ingroup app app +COPY pyproject.toml README.md ./ +COPY src ./src + +FROM base AS test +RUN pip install . pytest ruff +COPY tests ./tests +RUN ruff check src tests && pytest -q + +FROM base AS runtime +RUN pip install . +USER app +EXPOSE 8000 +HEALTHCHECK --interval=10s --timeout=3s --start-period=15s --retries=6 CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2)"] +CMD ["uvicorn", "pk_lab_server.main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers"] + diff --git a/README.md b/README.md new file mode 100644 index 0000000..0769528 --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +# PK Lab Server + +`pk-lab-xhs` 与 `rice-pk-xhs` 共用的 FastAPI + PostgreSQL 后端。它保存首批七套题库、PK 决策轨迹、最终排名和用户历史,并负责把两个小红书小程序各自的 `open_id` 安全映射到内部用户。 + +## 核心设计 + +- `xhs.login()` 的一次性 `code` 只发给 `/api/v1/auth/xhs`;服务端换取 `open_id`,绝不把 `session_key` 下发。 +- 小红书 `open_id` 只在单个小程序内唯一。综合版与饭类独立版通过 6 位、10 分钟有效、单次使用的绑定码合并内部用户。 +- 完整 PK 决策轨迹和 Top 排名写入 PostgreSQL,可复现结果并支持后续统计。 +- 固定 Compose project `pk-lab-production` 和具名卷 `pk-lab-postgres-data`。CI 只执行 `up -d` 更新,不执行 `down -v`,发布后服务与数据持续存在。 + +## CI 所需配置 + +Secrets:`GITEA_TOKEN`、`POSTGRES_PASSWORD`、`TOKEN_SECRET`,上线真实登录前再配置两套 `XHS_*_APP_ID/SECRET`。Variables:`DEPLOY_PORT`(默认 `18082`)、`PUBLIC_BASE_URL`、`CORS_ORIGINS`、`TEST_AUTH_ENABLED`。 + +当前流水线默认 `TEST_AUTH_ENABLED=true`,让两个公网预览可以创建明确隔离的测试身份。接入真实小红书 App ID/Secret 后,必须把该变量改成 `false`,关闭 `dev:` 登录路径。 + +生产 API 必须使用 HTTPS,并在两个小程序后台加入 request 合法域名。首次配置 App ID/Secret 后重新运行流水线即可启用真实登录。 + +## 本地命令(可选) + +```bash +python -m venv .venv +. .venv/bin/activate +pip install . pytest ruff +TEST_AUTH_ENABLED=true pytest -q +``` + +接口文档部署后位于 `/docs`,健康检查为 `/health`。 diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..4537516 --- /dev/null +++ b/compose.yml @@ -0,0 +1,47 @@ +services: + postgres: + image: postgres:16-bookworm + restart: unless-stopped + environment: + POSTGRES_DB: pk_lab + POSTGRES_USER: pk_lab + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD} + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: [CMD-SHELL, "pg_isready -U pk_lab -d pk_lab"] + interval: 5s + timeout: 5s + retries: 30 + security_opt: [no-new-privileges:true] + mem_limit: 512m + + api: + image: ${DEPLOY_IMAGE:?Set DEPLOY_IMAGE} + restart: unless-stopped + read_only: true + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + tmpfs: [/tmp:rw,noexec,nosuid,size=64m] + mem_limit: 512m + pids_limit: 128 + ports: + - "${DEPLOY_BIND_IP:-0.0.0.0}:${DEPLOY_PORT:-18082}:8000" + environment: + DATABASE_URL: postgresql+psycopg://pk_lab:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}@postgres:5432/pk_lab + TOKEN_SECRET: ${TOKEN_SECRET:?Set TOKEN_SECRET} + PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-http://localhost:18082} + CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:3001,http://localhost:3002} + TEST_AUTH_ENABLED: ${TEST_AUTH_ENABLED:-false} + XHS_HUB_APP_ID: ${XHS_HUB_APP_ID:-} + XHS_HUB_APP_SECRET: ${XHS_HUB_APP_SECRET:-} + XHS_RICE_APP_ID: ${XHS_RICE_APP_ID:-} + XHS_RICE_APP_SECRET: ${XHS_RICE_APP_SECRET:-} + depends_on: + postgres: + condition: service_healthy + +volumes: + postgres-data: + name: pk-lab-postgres-data + diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..eb8a8ba --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,67 @@ +# PK Lab 上线架构 + +## 三仓关系 + +```text +pk-lab-xhs (综合小程序 + :3001 公网预览) ─┐ + ├─ HTTPS ─ pk-lab-server (:18082) ─ PostgreSQL 16 +rice-pk-xhs (饭类小程序 + :3002 公网预览) ───┘ +``` + +小程序源码不是由 Nginx 托管的 H5。两个前端容器是功能一致的浏览器验收入口;正式小程序仍由小红书开发者工具或后续接入 `xhs-mp-cli` 上传。 + +## 身份 + +1. 前端调用 `xhs.login()`,把一次性 `code` 发到服务端。 +2. 服务端获取应用调用凭证并调用 `code2Session`,只在服务端使用 `session_key`,数据库保存 `(app_key, open_id) -> internal_user_id`。 +3. `open_id` 仅在一个小程序内唯一。综合版用户生成 6 位绑定码,独立版用户主动输入确认;服务端检查两个用户没有同 app 身份后,以事务方式迁移身份和历史。 +4. 公网预览只在 `TEST_AUTH_ENABLED=true` 时接受 `dev:` code。真实 App ID/Secret 生效后必须关闭。 + +## 数据模型 + +- `users`:内部用户,不保存小红书密码或 session key。 +- `app_identities`:两套小程序各自的 `open_id` 映射。 +- `tests` / `candidates`:题库和候选项;首批 7 套、288 项。 +- `test_runs`:冠军、亚军、Top 排名、每轮左右选项与胜者、来源 app。 +- `link_codes`:SHA-256 后的一次性绑定码、10 分钟过期时间与使用时间。 + +## 第一次 CI 配置 + +### `pk-lab-server` + +Secrets: + +- `GITEA_TOKEN`:CI 下载精确 commit 归档。 +- `POSTGRES_PASSWORD`:长随机密码,首次部署后不要随意更换。 +- `TOKEN_SECRET`:至少 32 字节随机值,变更会使现有登录失效。 +- `XHS_HUB_APP_ID` / `XHS_HUB_APP_SECRET`:综合版。 +- `XHS_RICE_APP_ID` / `XHS_RICE_APP_SECRET`:饭类独立版。 + +Variables: + +- `DEPLOY_PORT=18082` +- `PUBLIC_BASE_URL=https://你的-api-域名` +- `CORS_ORIGINS=https://综合预览域名,https://饭类预览域名` +- `TEST_AUTH_ENABLED=true`(公网预览阶段);小程序真实登录验收后改为 `false`。 + +### 两个前端 + +每个仓库配置 `GITEA_TOKEN` Secret、`API_BASE_URL` Variable;综合版默认 `DEPLOY_PORT=3001`,独立版默认 `DEPLOY_PORT=3002`。 + +## 永久运行保证 + +- API 与 PostgreSQL 均为 `restart: unless-stopped`。 +- PostgreSQL 使用固定具名卷 `pk-lab-postgres-data`。 +- 后端部署固定 Compose project 为 `pk-lab-production`,只执行 `docker compose up -d --wait`。 +- 前端以固定容器名更新并设置重启策略;CI 结束不会删除新容器。 +- PostgreSQL 不映射公网端口,只有 API 容器能访问。 + +## 上小红书前 + +- 为 API 配置可信 HTTPS 证书;小程序 `xhs.request` 必须使用 HTTPS。 +- 在两套小程序后台分别加入 request 合法域名。 +- 把两个 `app.js` 的 `apiBase` 改为最终 HTTPS 域名。 +- 配置 App ID/Secret,确认真实 `xhs.login -> code2Session` 后关闭测试登录。 +- 在开发者工具和真机检查 Canvas 海报、相册权限、长名称、轮空赛程与网络失败重试。 +- 根据平台审核要求补齐隐私政策、用户数据删除入口和客服联系方式。 + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..12b2950 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,32 @@ +[project] +name = "pk-lab-server" +version = "0.1.0" +description = "Shared API and PostgreSQL persistence for PK Lab mini programs" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.116,<1", + "httpx>=0.28,<1", + "psycopg[binary]>=3.2,<4", + "pydantic-settings>=2.10,<3", + "sqlalchemy>=2.0,<3", + "uvicorn>=0.35,<1", +] + +[dependency-groups] +dev = ["pytest>=8,<10", "ruff>=0.12,<1"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/pk_lab_server"] + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.ruff] +target-version = "py312" +line-length = 100 + diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100644 index 0000000..563934b --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,10 @@ +#!/bin/sh +set -eu + +: "${DEPLOY_IMAGE:?Set DEPLOY_IMAGE}" +: "${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}" +: "${TOKEN_SECRET:?Set TOKEN_SECRET}" + +docker compose -p pk-lab-production up -d --remove-orphans --wait +docker compose -p pk-lab-production ps + diff --git a/src/pk_lab_server/__init__.py b/src/pk_lab_server/__init__.py new file mode 100644 index 0000000..8b22e17 --- /dev/null +++ b/src/pk_lab_server/__init__.py @@ -0,0 +1,2 @@ +"""PK Lab backend.""" + diff --git a/src/pk_lab_server/api.py b/src/pk_lab_server/api.py new file mode 100644 index 0000000..06b14e8 --- /dev/null +++ b/src/pk_lab_server/api.py @@ -0,0 +1,260 @@ +import hashlib +import secrets +from datetime import UTC, datetime, timedelta + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import func, select, update +from sqlalchemy.orm import Session, selectinload + +from .config import get_settings +from .db import get_db +from .models import AppIdentity, Candidate, LinkCode, TestDefinition, TestRun, User, now_utc +from .schemas import ( + CompleteRunRequest, + LinkConfirmIn, + LinkConfirmOut, + LinkStartOut, + LoginRequest, + LoginResponse, + RunOut, + TestCard, + TestDetail, +) +from .security import TokenClaims, current_claims, current_user, issue_token +from .xhs_auth import XhsAuthError, exchange_code + + +router = APIRouter(prefix="/api/v1") + + +def _linked_apps(db: Session, user_id: str) -> list[str]: + return sorted(set(db.scalars(select(AppIdentity.app_key).where(AppIdentity.user_id == user_id)).all())) + + +@router.post("/auth/xhs", response_model=LoginResponse) +async def login(body: LoginRequest, db: Session = Depends(get_db)) -> LoginResponse: + try: + open_id = await exchange_code(body.app_key, body.code, get_settings()) + except (XhsAuthError, ValueError) as exc: + raise HTTPException(status_code=401, detail=str(exc)) from exc + identity = db.scalar( + select(AppIdentity).where( + AppIdentity.app_key == body.app_key, + AppIdentity.open_id == open_id, + ) + ) + if identity is None: + user = User() + db.add(user) + db.flush() + identity = AppIdentity(user_id=user.id, app_key=body.app_key, open_id=open_id) + db.add(identity) + else: + identity.last_seen_at = now_utc() + user = db.get(User, identity.user_id) + if user is None: + raise HTTPException(status_code=500, detail="identity references a missing user") + db.commit() + return LoginResponse( + token=issue_token(user.id, body.app_key), + user_id=user.id, + app_key=body.app_key, + linked_apps=_linked_apps(db, user.id), + ) + + +@router.get("/tests", response_model=list[TestCard]) +def list_tests(db: Session = Depends(get_db)) -> list[TestCard]: + tests = db.scalars( + select(TestDefinition) + .where(TestDefinition.active.is_(True)) + .options(selectinload(TestDefinition.candidates)) + .order_by(TestDefinition.sort_order) + ).all() + counts = dict(db.execute(select(TestRun.test_id, func.count()).group_by(TestRun.test_id)).all()) + return [ + TestCard( + slug=test.slug, + title=test.title, + subtitle=test.subtitle, + emoji=test.emoji, + accent=test.accent, + candidate_count=len(test.candidates), + run_count=counts.get(test.id, 0), + ) + for test in tests + ] + + +@router.get("/tests/{slug}", response_model=TestDetail) +def get_test(slug: str, db: Session = Depends(get_db)) -> TestDetail: + test = db.scalar( + select(TestDefinition) + .where(TestDefinition.slug == slug, TestDefinition.active.is_(True)) + .options(selectinload(TestDefinition.candidates)) + ) + if test is None: + raise HTTPException(status_code=404, detail="test not found") + run_count = db.scalar(select(func.count()).select_from(TestRun).where(TestRun.test_id == test.id)) or 0 + return TestDetail( + slug=test.slug, + title=test.title, + subtitle=test.subtitle, + emoji=test.emoji, + accent=test.accent, + candidate_count=len(test.candidates), + run_count=run_count, + candidates=[ + {"id": c.id, "name": c.name, "emoji": c.emoji, "seed_order": c.seed_order} + for c in test.candidates + ], + ) + + +def _run_out(run: TestRun) -> RunOut: + candidate_by_id = {candidate.id: candidate.name for candidate in run.test.candidates} + return RunOut( + id=run.id, + test_slug=run.test.slug, + test_title=run.test.title, + champion_id=run.champion_id, + champion_name=run.champion.name, + runner_up_name=run.runner_up.name if run.runner_up else None, + ranking_names=[candidate_by_id[item] for item in run.ranking_ids if item in candidate_by_id], + completed_at=run.completed_at, + ) + + +@router.post("/tests/{slug}/runs", response_model=RunOut, status_code=201) +def complete_run( + slug: str, + body: CompleteRunRequest, + user: User = Depends(current_user), + claims: TokenClaims = Depends(current_claims), + db: Session = Depends(get_db), +) -> RunOut: + if body.app_key != claims.app_key: + raise HTTPException(status_code=403, detail="app_key does not match token") + if body.app_key == "rice" and slug != "rice": + raise HTTPException(status_code=403, detail="rice app can only submit the rice test") + test = db.scalar( + select(TestDefinition) + .where(TestDefinition.slug == slug, TestDefinition.active.is_(True)) + .options(selectinload(TestDefinition.candidates)) + ) + if test is None: + raise HTTPException(status_code=404, detail="test not found") + valid_ids = {candidate.id for candidate in test.candidates} + if body.champion_id not in valid_ids or (body.runner_up_id and body.runner_up_id not in valid_ids): + raise HTTPException(status_code=422, detail="result contains a candidate from another test") + for decision in body.decisions: + if not {decision.left_id, decision.right_id, decision.winner_id}.issubset(valid_ids): + raise HTTPException(status_code=422, detail="decision contains an unknown candidate") + if decision.winner_id not in {decision.left_id, decision.right_id}: + raise HTTPException(status_code=422, detail="decision winner must be one of the pair") + ranking = [] + for candidate_id in [body.champion_id, *body.ranking_ids]: + if candidate_id in valid_ids and candidate_id not in ranking: + ranking.append(candidate_id) + run = TestRun( + user_id=user.id, + test_id=test.id, + app_key=body.app_key, + champion_id=body.champion_id, + runner_up_id=body.runner_up_id, + ranking_ids=ranking[:12], + decisions=[item.model_dump() for item in body.decisions], + bracket_size=len(test.candidates), + ) + db.add(run) + db.commit() + db.refresh(run) + return _run_out(run) + + +@router.get("/me/results", response_model=list[RunOut]) +def my_results( + test_slug: str | None = Query(default=None), + limit: int = Query(default=30, ge=1, le=100), + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> list[RunOut]: + statement = ( + select(TestRun) + .join(TestDefinition) + .where(TestRun.user_id == user.id) + .options( + selectinload(TestRun.test).selectinload(TestDefinition.candidates), + selectinload(TestRun.champion), + selectinload(TestRun.runner_up), + ) + .order_by(TestRun.completed_at.desc()) + .limit(limit) + ) + if test_slug: + statement = statement.where(TestDefinition.slug == test_slug) + return [_run_out(run) for run in db.scalars(statement).all()] + + +@router.get("/me/results/{run_id}", response_model=RunOut) +def result_detail( + run_id: str, user: User = Depends(current_user), db: Session = Depends(get_db) +) -> RunOut: + run = db.scalar( + select(TestRun) + .where(TestRun.id == run_id, TestRun.user_id == user.id) + .options( + selectinload(TestRun.test).selectinload(TestDefinition.candidates), + selectinload(TestRun.champion), + selectinload(TestRun.runner_up), + ) + ) + if run is None: + raise HTTPException(status_code=404, detail="result not found") + return _run_out(run) + + +def _code_hash(code: str) -> str: + return hashlib.sha256(code.encode()).hexdigest() + + +@router.post("/me/link-code", response_model=LinkStartOut) +def create_link_code(user: User = Depends(current_user), db: Session = Depends(get_db)) -> LinkStartOut: + alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" + code = "".join(secrets.choice(alphabet) for _ in range(6)) + expires_at = datetime.now(UTC) + timedelta(minutes=10) + db.add(LinkCode(code_hash=_code_hash(code), source_user_id=user.id, expires_at=expires_at)) + db.commit() + return LinkStartOut(code=code, expires_at=expires_at) + + +@router.post("/me/link-code/confirm", response_model=LinkConfirmOut) +def confirm_link_code( + body: LinkConfirmIn, + user: User = Depends(current_user), + claims: TokenClaims = Depends(current_claims), + db: Session = Depends(get_db), +) -> LinkConfirmOut: + link = db.scalar( + select(LinkCode) + .where(LinkCode.code_hash == _code_hash(body.code.upper())) + .with_for_update() + ) + if link is None or link.used_at is not None or link.expires_at < datetime.now(UTC): + raise HTTPException(status_code=422, detail="link code is invalid or expired") + target_id = link.source_user_id + if target_id != user.id: + target_apps = set(db.scalars(select(AppIdentity.app_key).where(AppIdentity.user_id == target_id)).all()) + current_apps = set(db.scalars(select(AppIdentity.app_key).where(AppIdentity.user_id == user.id)).all()) + if target_apps & current_apps: + raise HTTPException(status_code=409, detail="both users already contain the same app identity") + db.execute(update(AppIdentity).where(AppIdentity.user_id == user.id).values(user_id=target_id)) + db.execute(update(TestRun).where(TestRun.user_id == user.id).values(user_id=target_id)) + db.delete(user) + link.used_at = now_utc() + db.commit() + return LinkConfirmOut( + token=issue_token(target_id, claims.app_key), + user_id=target_id, + linked_apps=_linked_apps(db, target_id), + ) diff --git a/src/pk_lab_server/config.py b/src/pk_lab_server/config.py new file mode 100644 index 0000000..95816f3 --- /dev/null +++ b/src/pk_lab_server/config.py @@ -0,0 +1,38 @@ +from functools import lru_cache + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", extra="ignore") + + database_url: str = "sqlite:///./pk-lab.db" + token_secret: str = "development-only-change-me" + token_ttl_seconds: int = 60 * 60 * 24 * 30 + cors_origins: str = "http://localhost:3001,http://localhost:3002" + public_base_url: str = "http://localhost:18082" + test_auth_enabled: bool = False + xhs_token_url: str = "https://miniapp.xiaohongshu.com/api/rmp/token" + xhs_session_url: str = "https://miniapp.xiaohongshu.com/api/rmp/session" + xhs_hub_app_id: str = "" + xhs_hub_app_secret: str = Field(default="", repr=False) + xhs_rice_app_id: str = "" + xhs_rice_app_secret: str = Field(default="", repr=False) + + @property + def cors_origin_list(self) -> list[str]: + return [item.strip() for item in self.cors_origins.split(",") if item.strip()] + + def app_credentials(self, app_key: str) -> tuple[str, str]: + if app_key == "hub": + return self.xhs_hub_app_id, self.xhs_hub_app_secret + if app_key == "rice": + return self.xhs_rice_app_id, self.xhs_rice_app_secret + raise ValueError("unknown app_key") + + +@lru_cache +def get_settings() -> Settings: + return Settings() + diff --git a/src/pk_lab_server/db.py b/src/pk_lab_server/db.py new file mode 100644 index 0000000..39dd78e --- /dev/null +++ b/src/pk_lab_server/db.py @@ -0,0 +1,22 @@ +from collections.abc import Generator + +from sqlalchemy import create_engine +from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker + +from .config import get_settings + + +class Base(DeclarativeBase): + pass + + +settings = get_settings() +connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {} +engine = create_engine(settings.database_url, pool_pre_ping=True, connect_args=connect_args) +SessionLocal = sessionmaker(bind=engine, expire_on_commit=False) + + +def get_db() -> Generator[Session, None, None]: + with SessionLocal() as session: + yield session + diff --git a/src/pk_lab_server/main.py b/src/pk_lab_server/main.py new file mode 100644 index 0000000..0ca23e4 --- /dev/null +++ b/src/pk_lab_server/main.py @@ -0,0 +1,44 @@ +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from sqlalchemy import text + +from .api import router +from .config import get_settings +from .db import Base, SessionLocal, engine +from .seeds import seed_database + + +@asynccontextmanager +async def lifespan(_: FastAPI): + Base.metadata.create_all(engine) + with SessionLocal() as session: + seed_database(session) + yield + + +app = FastAPI( + title="PK Lab API", + version="0.1.0", + docs_url="/docs", + redoc_url=None, + lifespan=lifespan, +) +settings = get_settings() +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origin_list, + allow_credentials=False, + allow_methods=["GET", "POST", "OPTIONS"], + allow_headers=["Authorization", "Content-Type"], +) +app.include_router(router) + + +@app.get("/health") +def health() -> dict[str, str]: + with SessionLocal() as session: + session.execute(text("SELECT 1")) + return {"status": "ok"} + diff --git a/src/pk_lab_server/models.py b/src/pk_lab_server/models.py new file mode 100644 index 0000000..eb71d31 --- /dev/null +++ b/src/pk_lab_server/models.py @@ -0,0 +1,101 @@ +import uuid +from datetime import UTC, datetime + +from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from .db import Base + + +def uuid_str() -> str: + return str(uuid.uuid4()) + + +def now_utc() -> datetime: + return datetime.now(UTC) + + +class User(Base): + __tablename__ = "users" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str) + display_name: Mapped[str | None] = mapped_column(String(80)) + avatar_url: Mapped[str | None] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now_utc) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now_utc, onupdate=now_utc) + + +class AppIdentity(Base): + __tablename__ = "app_identities" + __table_args__ = (UniqueConstraint("app_key", "open_id", name="uq_identity_app_openid"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str) + user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) + app_key: Mapped[str] = mapped_column(String(20), index=True) + open_id: Mapped[str] = mapped_column(String(160)) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now_utc) + last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now_utc) + + user: Mapped[User] = relationship() + + +class TestDefinition(Base): + __tablename__ = "tests" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str) + slug: Mapped[str] = mapped_column(String(80), unique=True, index=True) + title: Mapped[str] = mapped_column(String(120)) + subtitle: Mapped[str] = mapped_column(String(240)) + emoji: Mapped[str] = mapped_column(String(16)) + accent: Mapped[str] = mapped_column(String(16)) + sort_order: Mapped[int] = mapped_column(Integer, default=0) + active: Mapped[bool] = mapped_column(Boolean, default=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now_utc) + + candidates: Mapped[list["Candidate"]] = relationship( + back_populates="test", cascade="all, delete-orphan", order_by="Candidate.seed_order" + ) + + +class Candidate(Base): + __tablename__ = "candidates" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str) + test_id: Mapped[str] = mapped_column(ForeignKey("tests.id", ondelete="CASCADE"), index=True) + name: Mapped[str] = mapped_column(String(80)) + emoji: Mapped[str | None] = mapped_column(String(16)) + seed_order: Mapped[int] = mapped_column(Integer) + + test: Mapped[TestDefinition] = relationship(back_populates="candidates") + + +class TestRun(Base): + __tablename__ = "test_runs" + __table_args__ = (Index("ix_runs_user_created", "user_id", "created_at"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str) + user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) + test_id: Mapped[str] = mapped_column(ForeignKey("tests.id", ondelete="CASCADE"), index=True) + app_key: Mapped[str] = mapped_column(String(20)) + champion_id: Mapped[str] = mapped_column(ForeignKey("candidates.id")) + runner_up_id: Mapped[str | None] = mapped_column(ForeignKey("candidates.id")) + ranking_ids: Mapped[list[str]] = mapped_column(JSON, default=list) + decisions: Mapped[list[dict]] = mapped_column(JSON, default=list) + bracket_size: Mapped[int] = mapped_column(Integer) + completed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now_utc) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now_utc) + + test: Mapped[TestDefinition] = relationship() + champion: Mapped[Candidate] = relationship(foreign_keys=[champion_id]) + runner_up: Mapped[Candidate | None] = relationship(foreign_keys=[runner_up_id]) + + +class LinkCode(Base): + __tablename__ = "link_codes" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid_str) + code_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True) + source_user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now_utc) diff --git a/src/pk_lab_server/schemas.py b/src/pk_lab_server/schemas.py new file mode 100644 index 0000000..e656932 --- /dev/null +++ b/src/pk_lab_server/schemas.py @@ -0,0 +1,87 @@ +from datetime import datetime + +from pydantic import BaseModel, Field, field_validator + + +class LoginRequest(BaseModel): + app_key: str + code: str = Field(min_length=3, max_length=512) + + @field_validator("app_key") + @classmethod + def known_app(cls, value: str) -> str: + if value not in {"hub", "rice"}: + raise ValueError("app_key must be hub or rice") + return value + + +class LoginResponse(BaseModel): + token: str + user_id: str + app_key: str + linked_apps: list[str] + + +class CandidateOut(BaseModel): + id: str + name: str + emoji: str | None + seed_order: int + + +class TestCard(BaseModel): + slug: str + title: str + subtitle: str + emoji: str + accent: str + candidate_count: int + run_count: int + + +class TestDetail(TestCard): + candidates: list[CandidateOut] + + +class DecisionIn(BaseModel): + round: int = Field(ge=1, le=16) + left_id: str + right_id: str + winner_id: str + + +class CompleteRunRequest(BaseModel): + app_key: str + champion_id: str + runner_up_id: str | None = None + ranking_ids: list[str] = Field(default_factory=list, max_length=128) + decisions: list[DecisionIn] = Field(min_length=1, max_length=256) + + +class RunOut(BaseModel): + id: str + test_slug: str + test_title: str + champion_id: str + champion_name: str + runner_up_name: str | None + ranking_names: list[str] + completed_at: datetime + share_templates: list[str] = Field( + default_factory=lambda: ["celebration", "editorial", "sticker"] + ) + + +class LinkStartOut(BaseModel): + code: str + expires_at: datetime + + +class LinkConfirmIn(BaseModel): + code: str = Field(pattern=r"^[A-Z2-9]{6}$") + + +class LinkConfirmOut(BaseModel): + token: str + user_id: str + linked_apps: list[str] diff --git a/src/pk_lab_server/security.py b/src/pk_lab_server/security.py new file mode 100644 index 0000000..be50ee2 --- /dev/null +++ b/src/pk_lab_server/security.py @@ -0,0 +1,86 @@ +import base64 +import binascii +import hashlib +import hmac +import json +import secrets +import time +from dataclasses import dataclass + +from fastapi import Depends, HTTPException +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy.orm import Session + +from .config import get_settings +from .db import get_db +from .models import User + + +bearer = HTTPBearer(auto_error=False) + + +def _b64(data: bytes) -> str: + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") + + +def _unb64(value: str) -> bytes: + return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) + + +def issue_token(user_id: str, app_key: str) -> str: + settings = get_settings() + payload = { + "sub": user_id, + "app": app_key, + "iat": int(time.time()), + "exp": int(time.time()) + settings.token_ttl_seconds, + "nonce": secrets.token_hex(8), + } + encoded = _b64(json.dumps(payload, separators=(",", ":"), sort_keys=True).encode()) + signature = hmac.new(settings.token_secret.encode(), encoded.encode(), hashlib.sha256).digest() + return encoded + "." + _b64(signature) + + +@dataclass(frozen=True) +class TokenClaims: + user_id: str + app_key: str + + +def parse_token(token: str) -> TokenClaims: + settings = get_settings() + try: + encoded, supplied = token.split(".", 1) + expected = hmac.new(settings.token_secret.encode(), encoded.encode(), hashlib.sha256).digest() + if not hmac.compare_digest(expected, _unb64(supplied)): + raise ValueError("signature") + payload = json.loads(_unb64(encoded)) + if int(payload["exp"]) < int(time.time()): + raise ValueError("expired") + return TokenClaims(user_id=str(payload["sub"]), app_key=str(payload["app"])) + except ( + KeyError, + ValueError, + TypeError, + UnicodeDecodeError, + binascii.Error, + json.JSONDecodeError, + ) as exc: + raise HTTPException(status_code=401, detail="invalid or expired token") from exc + + +def current_claims( + credentials: HTTPAuthorizationCredentials | None = Depends(bearer), +) -> TokenClaims: + if credentials is None or credentials.scheme.lower() != "bearer": + raise HTTPException(status_code=401, detail="authentication required") + return parse_token(credentials.credentials) + + +def current_user( + claims: TokenClaims = Depends(current_claims), db: Session = Depends(get_db) +) -> User: + user = db.get(User, claims.user_id) + if user is None: + raise HTTPException(status_code=401, detail="user no longer exists") + return user diff --git a/src/pk_lab_server/seeds.py b/src/pk_lab_server/seeds.py new file mode 100644 index 0000000..01ab973 --- /dev/null +++ b/src/pk_lab_server/seeds.py @@ -0,0 +1,126 @@ +SEED_TESTS = [ + { + "slug": "rice", + "title": "本命饭 PK", + "subtitle": "48 种饭一路二选一,选出今天最想吃的那碗", + "emoji": "🍚", + "accent": "#ff6b35", + "items": [ + "蛋炒饭", "卤肉饭", "煲仔饭", "叉烧饭", "打抛饭", "竹筒饭", "猪脚饭", "海南鸡饭", + "石锅拌饭", "蛋包饭", "扬州炒饭", "酱油炒饭", "牛肉丼", "鳗鱼丼", "三文鱼丼", "天妇罗丼", + "咖喱猪排饭", "照烧鸡腿饭", "脆皮鸡米饭", "黄焖鸡米饭", "沙县大排饭", "沙县鸭腿饭", "沙县鸡腿饭", "梅菜扣肉饭", + "菠萝炒饭", "芒果糯米饭", "羊肉手抓饭", "土豆牛腩饭", "福清菜干饭", "上海菜饭", "番茄炒蛋盖饭", "麻婆豆腐饭", + "椰浆饭", "西班牙海鲜饭", "鲍汁捞饭", "蟹黄拌饭", "香菇滑鸡饭", "三杯鸡饭", "波奇饭", "滑蛋虾仁饭", + "泡菜炒饭", "印度香饭", "宫保鸡丁饭", "鱼香肉丝饭", "猪油捞饭", "荷叶饭", "芝士焗饭", "意式烩饭", + ], + }, + { + "slug": "drink", + "title": "下馆子配什么喝", + "subtitle": "从快乐水到奶茶,选出你的万能搭餐饮料", + "emoji": "🥤", + "accent": "#4c7cff", + "items": [ + "可口可乐", "百事可乐", "雪碧", "芬达", "七喜", "椰汁", "王老吉", "红牛", + "尖叫", "苹果醋", "橙汁", "酸梅汤", "西瓜汁", "柠檬水", "气泡水", "苏打水", + "酸奶", "纯牛奶", "啤酒", "白酒", "矿泉水", "豆奶", "红茶", "绿茶", + "大麦茶", "乌龙茶", "北冰洋", "大窑", "旺仔牛奶", "AD钙奶", "咖啡", "奶茶", + ], + }, + { + "slug": "noodle", + "title": "本命面 PK", + "subtitle": "南北中外 48 碗面,哪一碗能走到最后", + "emoji": "🍜", + "accent": "#e59a28", + "items": [ + "北京炸酱面", "刀削面", "热干面", "臊子面", "担担面", "重庆小面", "biangbiang面", "河南烩面", + "兰州牛肉面", "阳春面", "油泼面", "打卤面", "锅盖面", "苏氏面", "片儿川", "葱油拌面", + "小刀面", "延吉冷面", "鸡丝凉面", "烤冷面", "广式云吞面", "虾籽捞面", "豆角焖面", "河南蒸面", + "牛肉板面", "拉条子", "甜水面", "沙茶面", "竹升面", "福州线面", "天津捞面", "蟹黄拌面", + "南宁老友面", "担仔面", "宜宾燃面", "贵阳肠旺面", "南京皮肚面", "遵义豆花面", "韩式炸酱面", "火鸡面", + "乌冬面", "日式拉面", "意大利面", "千层面", "通心粉", "叻沙面", "炒方便面", "泡面", + ], + }, + { + "slug": "bbq", + "title": "烧烤菜品 PK", + "subtitle": "荤素主食饮料同台,决定你的烧烤局 MVP", + "emoji": "🍢", + "accent": "#f24f4f", + "items": [ + "羊肉串", "五花肉", "里脊肉", "骨肉相连", "掌中宝", "猪脆骨", "大油边", "鱿鱼须", + "鸡中翅", "鸡爪", "火腿肠", "牛油", "牛肉串", "烤猪蹄", "牛舌", "羊腰子", + "大虾", "烤鱼", "蒜蓉生蚝", "粉丝扇贝", "猪肉串", "牛板筋", "鸡腿", "鸡胗", + "金针菇", "香菇", "素鸡", "土豆片", "面筋", "臭豆腐", "花菜", "韭菜", + "蒜头", "玉米", "茄子", "青椒", "面包片", "年糕", "豆皮", "鱼豆腐", + "小龙虾", "烤羊排", "炒饭", "炒面", "啤酒", "椰汁", "汽水", "凉茶", + ], + }, + { + "slug": "dumpling", + "title": "决战饺子之巅", + "subtitle": "家常与脑洞馅料混战,选出你的饺子冠军", + "emoji": "🥟", + "accent": "#7f6ad8", + "items": [ + "胡萝卜猪肉", "玉米猪肉", "白菜猪肉", "猪肉大葱", "虾饺", "马蹄猪肉", "韭菜鸡蛋", "玉米虾仁", + "猪肉豆腐", "韭菜猪肉", "芹菜猪肉", "素三鲜", "莲藕猪肉", "羊肉大葱", "香菜牛肉", "黄瓜鸡蛋", + "酸菜猪肉", "茴香鸡蛋", "豆角猪肉", "西葫芦鸡蛋", "紫菜猪肉", "芥菜猪肉", "鲅鱼水饺", "香菇猪肉", + "香菇木耳", "紫菜猪肉", "番茄鸡蛋", "南瓜猪肉", "紫菜虾仁", "冬瓜羊肉", "白菜豆腐", "黄瓜虾仁", + ], + }, + { + "slug": "hotpot", + "title": "火锅菜品 PK", + "subtitle": "锅底、荤菜、素菜和主食,谁是下锅第一名", + "emoji": "🍲", + "accent": "#d9393e", + "items": [ + "番茄锅", "牛油锅", "酸汤锅", "猪肚鸡", "菌汤锅", "骨汤锅", "肥牛卷", "牛上脑", + "羊肉卷", "羊上脑", "牛板腱", "吊龙", "猪脑花", "鸭掌", "黄喉", "郡肝", + "毛肚", "千层肚", "鹅肠", "鸭血", "午餐肉", "火腿肠", "牛蛙", "鱿鱼须", + "虾滑", "鱼籽福袋", "贡丸", "牛肉丸", "娃娃菜", "生菜", "藕片", "竹笋", + "土豆片", "山药", "金针菇", "香菇", "响铃卷", "豆腐", "豆皮", "腐竹", + "玉米", "海带", "面条", "粉丝", "炒饭", "小馒头", "小酥肉", "油条", + ], + }, + { + "slug": "milk-tea", + "title": "决战奶茶之巅", + "subtitle": "果茶、奶绿和经典奶茶,一杯一杯选到本命", + "emoji": "🧋", + "accent": "#aa6f4c", + "items": [ + "黑糖珍珠牛乳", "烧仙草", "杨枝甘露", "牛油果甘露", "薄荷奶绿", "茉莉奶绿", "四季春茶", "棒打鲜橙", + "百香果汁", "蜜桃乌龙", "西瓜椰椰", "芋泥啵啵", "草莓奶冻", "莓莓麻薯", "多肉葡萄", "芝士葡萄", + "生椰抹茶", "生椰拿铁", "柠檬红茶", "锡兰红茶", "海盐冰奶", "芭乐冰奶", "桃胶木薯炖奶", "绿豆牛乳", + "港奶", "泰奶", "黑巧拿铁", "布蕾蛋糕", "咸法酪", "奶麻薯", "椰果", "脆啵啵", + ], + }, +] + + +def seed_database(session) -> None: + from sqlalchemy import select + + from .models import Candidate, TestDefinition + + existing = set(session.scalars(select(TestDefinition.slug)).all()) + for order, seed in enumerate(SEED_TESTS): + if seed["slug"] in existing: + continue + test = TestDefinition( + slug=seed["slug"], + title=seed["title"], + subtitle=seed["subtitle"], + emoji=seed["emoji"], + accent=seed["accent"], + sort_order=order, + ) + session.add(test) + session.flush() + for index, name in enumerate(seed["items"]): + session.add(Candidate(test_id=test.id, name=name, seed_order=index)) + session.commit() + diff --git a/src/pk_lab_server/xhs_auth.py b/src/pk_lab_server/xhs_auth.py new file mode 100644 index 0000000..e0ee292 --- /dev/null +++ b/src/pk_lab_server/xhs_auth.py @@ -0,0 +1,65 @@ +import time +from dataclasses import dataclass + +import httpx + +from .config import Settings + + +class XhsAuthError(RuntimeError): + pass + + +@dataclass +class _CachedToken: + value: str + expires_at: float + + +_tokens: dict[str, _CachedToken] = {} + + +async def _access_token(app_key: str, settings: Settings) -> str: + cached = _tokens.get(app_key) + if cached and cached.expires_at > time.time() + 60: + return cached.value + app_id, app_secret = settings.app_credentials(app_key) + if not app_id or not app_secret: + raise XhsAuthError(f"XHS credentials for {app_key} are not configured") + async with httpx.AsyncClient(timeout=10) as client: + response = await client.post( + settings.xhs_token_url, + json={"app_id": app_id, "app_secret": app_secret}, + ) + response.raise_for_status() + payload = response.json() + data = payload.get("data", payload) + token = data.get("access_token") + if not token: + raise XhsAuthError(payload.get("msg", "XHS token response did not include access_token")) + expires_in = int(data.get("expires_in", 7200)) + _tokens[app_key] = _CachedToken(token, time.time() + expires_in) + return token + + +async def exchange_code(app_key: str, code: str, settings: Settings) -> str: + if settings.test_auth_enabled and code.startswith("dev:"): + return code + app_id, _ = settings.app_credentials(app_key) + token = await _access_token(app_key, settings) + async with httpx.AsyncClient(timeout=10) as client: + response = await client.post( + settings.xhs_session_url, + # code2Session currently expects `appid` and the one-time code in query. + # Sending code in the JSON body as well keeps compatibility with the + # published schema while avoiding the known query/body mismatch. + params={"appid": app_id, "access_token": token, "code": code}, + json={"code": code}, + ) + response.raise_for_status() + payload = response.json() + data = payload.get("data", payload) + open_id = data.get("open_id") or data.get("openid") + if not open_id: + raise XhsAuthError(payload.get("msg", "XHS session response did not include open_id")) + return str(open_id) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..d9eb20d --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,7 @@ +import os +from pathlib import Path + +os.environ["DATABASE_URL"] = f"sqlite:///{Path('/tmp/pk-lab-test.db')}" +os.environ["TOKEN_SECRET"] = "test-secret" +os.environ["TEST_AUTH_ENABLED"] = "true" + diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..468f572 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,46 @@ +from fastapi.testclient import TestClient + +from pk_lab_server.main import app + + +def test_health_and_seeded_tests(): + with TestClient(app) as client: + assert client.get("/health").json() == {"status": "ok"} + tests = client.get("/api/v1/tests").json() + assert len(tests) == 7 + rice = next(item for item in tests if item["slug"] == "rice") + assert rice["candidate_count"] == 48 + + +def test_login_complete_and_read_result(): + with TestClient(app) as client: + login = client.post( + "/api/v1/auth/xhs", json={"app_key": "rice", "code": "dev:user-one"} + ) + assert login.status_code == 200 + token = login.json()["token"] + headers = {"Authorization": f"Bearer {token}"} + detail = client.get("/api/v1/tests/rice").json() + left, right = detail["candidates"][:2] + created = client.post( + "/api/v1/tests/rice/runs", + headers=headers, + json={ + "app_key": "rice", + "champion_id": left["id"], + "runner_up_id": right["id"], + "ranking_ids": [left["id"], right["id"]], + "decisions": [ + { + "round": 1, + "left_id": left["id"], + "right_id": right["id"], + "winner_id": left["id"], + } + ], + }, + ) + assert created.status_code == 201, created.text + history = client.get("/api/v1/me/results?test_slug=rice", headers=headers).json() + assert history[0]["champion_name"] == "蛋炒饭" +