This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
.git
|
||||
.gitea
|
||||
.pytest_cache
|
||||
.ruff_cache
|
||||
__pycache__
|
||||
*.pyc
|
||||
.env
|
||||
pk-lab.db
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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"
|
||||
@@ -0,0 +1,8 @@
|
||||
.env
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
pk-lab.db
|
||||
|
||||
+19
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 海报、相册权限、长名称、轮空赛程与网络失败重试。
|
||||
- 根据平台审核要求补齐隐私政策、用户数据删除入口和客服联系方式。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
"""PK Lab backend."""
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
|
||||
@@ -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"] == "蛋炒饭"
|
||||
|
||||
Reference in New Issue
Block a user