This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user