feat: launch PK testing application
Verify and deploy API / verify-deploy (push) Failing after 1m3s

This commit is contained in:
2026-09-18 21:50:25 +08:00
commit 4d79c82695
22 changed files with 1189 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
.git
.gitea
.pytest_cache
.ruff_cache
__pycache__
*.pyc
.env
pk-lab.db
+12
View File
@@ -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=
+72
View File
@@ -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"
+8
View File
@@ -0,0 +1,8 @@
.env
.venv/
__pycache__/
*.py[cod]
.pytest_cache/
.ruff_cache/
pk-lab.db
+19
View File
@@ -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"]
+29
View File
@@ -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`。
+47
View File
@@ -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
+67
View File
@@ -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 海报、相册权限、长名称、轮空赛程与网络失败重试。
- 根据平台审核要求补齐隐私政策、用户数据删除入口和客服联系方式。
+32
View File
@@ -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
+10
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
"""PK Lab backend."""
+260
View File
@@ -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),
)
+38
View File
@@ -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()
+22
View File
@@ -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
+44
View File
@@ -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"}
+101
View File
@@ -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)
+87
View File
@@ -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]
+86
View File
@@ -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
+126
View File
@@ -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()
+65
View File
@@ -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)
+7
View File
@@ -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"
+46
View File
@@ -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"] == "蛋炒饭"