"""HTTP surface for 放松一下 (Security Arcade). All endpoints are self-scoped, any signed-in user. Zero push — the arcade is pull-only by design (no notifications, no reminders); the daily play cap + entry cost keep total time investment bounded (~5 min/day). """ from __future__ import annotations from typing import Any from uuid import UUID from fastapi import APIRouter, Depends, Query, Request from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db from app.core.dependencies import get_current_user from app.core.exceptions import ForbiddenError from app.core.rbac import GLOBAL_ADMIN_ROLE, MANAGEMENT_ROLE, has_role from app.core.response import success_response from app.domains.arcade import ( content_service, economy, leaderboard, service, trivia_service, ) from app.identity.person_models import IdentityPerson router = APIRouter(prefix="/api/v1/arcade", tags=["arcade"]) admin_router = APIRouter(prefix="/api/v1/arcade/admin", tags=["arcade-admin"]) def _require_admin(user: IdentityPerson) -> None: roles = user.role_names or [] if not (has_role(roles, GLOBAL_ADMIN_ROLE) or has_role(roles, "it_admin")): raise ForbiddenError("Arcade administration requires global_admin.") def _require_admin_or_management(user: IdentityPerson) -> None: roles = user.role_names or [] if not ( has_role(roles, GLOBAL_ADMIN_ROLE) or has_role(roles, "it_admin") or has_role(roles, MANAGEMENT_ROLE) ): raise ForbiddenError("Arcade participation read requires global_admin or management.") @router.get("/overview", summary="游戏厅总览(余额/今日局数/游戏墙,懒结算答题积分)") async def overview( request: Request, user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): data = await service.get_overview(db, user) return success_response(data=data, request=request) @router.get("/trivia/categories", summary="知识题库品类与题量(任意登录用户,供品类选择)") async def trivia_categories( request: Request, user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): data = await trivia_service.bank_overview(db) return success_response(data=data, request=request) @router.get("/leaderboard", summary="游戏排行榜(知识榜/单游戏/单品类 + 我的名次)") async def leaderboard_read( request: Request, board: str = Query(default="knowledge"), game: str | None = Query(default=None), variant: str | None = Query(default=None), limit: int = Query(default=20, ge=1, le=100), user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): data = await leaderboard.get_leaderboard( db, user, board=board, game_key=game, variant=(variant or None), limit=limit, ) return success_response(data=data, request=request) @router.get("/my-titles", summary="我的称号(科举段位 + 品类达人)") async def my_titles( request: Request, user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): data = await leaderboard.get_my_titles(db, user) return success_response(data=data, request=request) @router.get("/ledger", summary="我的积分流水(自助只读)") async def ledger( request: Request, limit: int = Query(default=20, ge=1, le=100), user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): data = await service.get_ledger(db, user, limit=limit) return success_response(data=data, request=request) class _StartBody(BaseModel): game_key: str # Optional game sub-mode (e.g. trivia category / 'mixed'). Validated # against the game's declared variants server-side. variant: str | None = None @router.post("/sessions", summary="开一局(扣入场积分,受每日局数硬闸约束)") async def start( request: Request, body: _StartBody, user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): variant = (body.variant or "").strip() or None data = await service.start_session(db, user, body.game_key.strip(), variant) return success_response(data=data, request=request) @router.get("/sessions", summary="我的战绩(自助只读:已完成/超时的历次对局)") async def history( request: Request, limit: int = Query(default=30, ge=1, le=100), user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): data = await service.get_history(db, user, limit=limit) return success_response(data=data, request=request) @router.get("/sessions/{session_id}", summary="查询/恢复一局(active 返回公开题面)") async def get_session( request: Request, session_id: UUID, user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): data = await service.get_session(db, user, session_id) return success_response(data=data, request=request) class _SubmitBody(BaseModel): # Game-specific submission; engines validate their own shape. Kept as a # free dict so one endpoint serves every engine. submission: dict[str, Any] @router.post("/sessions/{session_id}/submit", summary="交卷(服务端判分,幂等)") async def submit( request: Request, session_id: UUID, body: _SubmitBody, user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): data = await service.submit_session(db, user, session_id, body.submission) return success_response(data=data, request=request) # ── Admin: 经济参数 (global_admin) ────────────────────────────────── @admin_router.get("/economy", summary="经济参数(积分/局数/入场费,admin)") async def get_economy( request: Request, user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_admin(user) cfg = await economy.get_economy_config(db) return success_response( data={ "config": economy.economy_to_dict(cfg), "defaults": economy.economy_to_dict(economy.DEFAULT_ECONOMY), "bounds": {k: list(v) for k, v in economy.ECONOMY_BOUNDS.items()}, }, request=request, ) class _EconomyBody(BaseModel): # All optional — a partial edit only changes the fields sent. points_correct: int | None = None points_wrong: int | None = None points_correct_streak: int | None = None streak_threshold: int | None = None backfill_cap: int | None = None entry_cost: int | None = None daily_play_cap: int | None = None @admin_router.put("/economy", summary="调整经济参数(admin,仅改所传字段)") async def update_economy( request: Request, body: _EconomyBody, user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_admin(user) updates = {k: v for k, v in body.model_dump().items() if v is not None} cfg = await economy.set_economy_config(db, updates, actor_id=user.id) return success_response(data={"config": economy.economy_to_dict(cfg)}, request=request) # ── Admin: 知识题库管理 (global_admin) ────────────────────────────── @admin_router.get("/trivia/overview", summary="知识题库概览(各品类题量,admin)") async def trivia_overview_admin( request: Request, user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_admin(user) data = await trivia_service.bank_overview(db) return success_response(data=data, request=request) @admin_router.get("/trivia/questions", summary="知识题库列表(admin)") async def trivia_list( request: Request, category: str | None = None, status: str | None = None, user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_admin(user) data = await trivia_service.list_questions(db, category=category, status=status) return success_response(data=data, request=request) class _TriviaBody(BaseModel): prompt: str options: list[dict] correct_key: str category: str explanation: str | None = None difficulty: str = "normal" @admin_router.post("/trivia/questions", summary="新增知识题(admin)") async def trivia_create( request: Request, body: _TriviaBody, user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_admin(user) data = await trivia_service.create_question( db, actor=user, prompt=body.prompt, options=body.options, correct_key=body.correct_key, explanation=body.explanation, category=body.category, difficulty=body.difficulty, ) return success_response(data=data, request=request) class _TriviaStatusBody(BaseModel): status: str @admin_router.post("/trivia/questions/{question_id}/status", summary="启用/归档知识题(admin)") async def trivia_set_status( request: Request, question_id: UUID, body: _TriviaStatusBody, user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_admin(user) data = await trivia_service.set_status(db, question_id, body.status) return success_response(data=data, request=request) class _TriviaGenBody(BaseModel): category: str count: int = 10 @admin_router.post("/trivia/generate", summary="AI 生成知识题(admin,无 LLM 时优雅 no-op)") async def trivia_generate( request: Request, body: _TriviaGenBody, user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_admin(user) data = await trivia_service.generate_ai_trivia( db, actor=user, category=body.category, count=body.count, ) return success_response(data=data, request=request) # ── Admin: 钓鱼样本库 + 找茬场景库 (global_admin) ──────────────────── @admin_router.get("/participation", summary="放松一下 参与看板(admin + management)") async def participation( request: Request, window_days: int = Query(default=30, ge=1, le=366), user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_admin_or_management(user) from app.domains.arcade import participation as _p data = await _p.get_participation(db, window_days=window_days) return success_response(data=data, request=request) @admin_router.get("/sessions", summary="对局审计台账(逐局:玩家/游戏/得分/时长,admin + management)") async def admin_sessions( request: Request, person_id: UUID | None = Query(default=None), game: str | None = Query(default=None), status: str | None = Query(default=None), limit: int = Query(default=50, ge=1, le=200), offset: int = Query(default=0, ge=0), user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_admin_or_management(user) data = await service.admin_list_sessions( db, person_id=person_id, game_key=(game or None), status=(status or None), limit=limit, offset=offset, ) return success_response(data=data, request=request) @admin_router.get("/content/overview", summary="钓鱼/找茬内容库概览(admin)") async def content_overview( request: Request, user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_admin(user) data = await content_service.bank_overview(db) return success_response(data=data, request=request) @admin_router.get("/content/phishing", summary="钓鱼样本列表(admin)") async def phishing_list( request: Request, status: str | None = None, user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_admin(user) data = await content_service.list_phishing(db, status=status) return success_response(data=data, request=request) @admin_router.post("/content/phishing/{sample_id}/status", summary="启用/归档钓鱼样本(admin)") async def phishing_status( request: Request, sample_id: UUID, body: _TriviaStatusBody, user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_admin(user) data = await content_service.set_phishing_status(db, sample_id, body.status) return success_response(data=data, request=request) @admin_router.post("/content/phishing/generate", summary="AI 生成钓鱼样本(admin,无 LLM 优雅 no-op)") async def phishing_generate( request: Request, body: _TriviaGenBody, user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_admin(user) data = await content_service.generate_ai_phishing(db, actor=user, count=body.count) return success_response(data=data, request=request) @admin_router.get("/content/scenes", summary="找茬场景列表(admin)") async def scenes_list( request: Request, status: str | None = None, user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_admin(user) data = await content_service.list_scenes(db, status=status) return success_response(data=data, request=request) class _SceneBody(BaseModel): title: str elements: list[dict] @admin_router.post("/content/scenes", summary="新增找茬场景(admin)") async def scenes_create( request: Request, body: _SceneBody, user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_admin(user) data = await content_service.create_scene(db, actor=user, title=body.title, elements=body.elements) return success_response(data=data, request=request) @admin_router.post("/content/scenes/{scene_id}/status", summary="启用/归档找茬场景(admin)") async def scenes_status( request: Request, scene_id: UUID, body: _TriviaStatusBody, user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_admin(user) data = await content_service.set_scene_status(db, scene_id, body.status) return success_response(data=data, request=request) __all__ = ["router", "admin_router"]