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
+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)