"""认证相关 endpoint。

路由前缀 `/api/v1/auth`,包含:
  POST /jverify-login         极光一键登录(loginToken → 手机号 → 注册即登录 → 签 JWT)
  POST /sms/send              发短信验证码(mock 阶段任意 6 位通过)
  POST /sms/login             手机号 + 验证码登录
  POST /refresh               用 refresh_token 换新的 token 对
  GET  /me                    当前登录用户(Bearer access_token)
  POST /logout                客户端登出(目前服务端无状态,仅返回 ok。后续加 jti 黑名单)
"""
from __future__ import annotations

import logging

from fastapi import APIRouter, HTTPException, Request
from sqlalchemy.exc import IntegrityError

from app.api.deps import CurrentUser, DbSession
from app.core import test_account
from app.core.ratelimit import (
    RateLimitRule,
    check_rate_limits,
    enforce_rate_limit,
    record_rate_limits,
)
from app.core.security import (
    TokenError,
    create_bind_ticket,
    create_conflict_ticket,
    decode_bind_ticket,
    decode_conflict_ticket,
    decode_token,
    issue_token_pair,
)
from app.integrations import wxpay
from app.integrations.jiguang import JiguangError, mask_phone, verify_and_get_phone
from app.integrations.sms import SmsError, send_code, verify_code
from app.repositories import onboarding as onboarding_repo
from app.repositories import phone_rebind as rebind_repo
from app.repositories import risk as risk_repo
from app.repositories import user as user_repo
from app.schemas.auth import (
    JverifyLoginRequest,
    LogoutResponse,
    OccupiedAccountInfo,
    RefreshRequest,
    SmsLoginRequest,
    SmsSendRequest,
    SmsSendResponse,
    TokenPair,
    TokenWithUser,
    UserOut,
    WechatBindPhoneJverifyRequest,
    WechatBindPhoneSmsRequest,
    WechatBindResultResponse,
    WechatConflictContinueRequest,
    WechatConflictRebindRequest,
    WechatLoginRequest,
    WechatLoginResponse,
)

logger = logging.getLogger("shagua.auth")

router = APIRouter(prefix="/api/v1/auth", tags=["auth"])

# 手机号登录防刷:同一设备(device_id) + 同一 IP 每小时最多的登录尝试次数(成功/失败都计)。
SMS_LOGIN_MAX_PER_HOUR = 5
# 发码防刷(同一设备 device_id + 同一 IP,**只按成功发码计数**;被单号 60s 冷却挡下的重发不占额度):
# 堵「换手机号绕开单号 60s 冷却」的洞 —— 冷却是单号维度,一机换号能绕开。
SMS_SEND_MAX_PER_HOUR_PER_DEVICE = 5     # 每小时上限
SMS_SEND_MAX_PER_DAY_PER_DEVICE = 20     # 每天上限(再叠一层日封顶,挡低频长时间轰炸)


def _client_ip(request: Request) -> str:
    xff = request.headers.get("x-forwarded-for")
    if xff:
        return xff.split(",")[0].strip()
    return request.client.host if request.client else ""


def _device_subject(device_id: str, request: Request) -> str:
    # 兼容旧客户端空 device_id；新客户端均上传稳定硬件标识。
    return device_id or f"legacy-ip:{_client_ip(request)}"


def _login_response(
    user, *, onboarding_completed: bool, force_onboarding: bool = False
) -> TokenWithUser:
    """登录类接口共用的响应组装。onboarding_completed 据登录请求的 device_id 算好后传入;
    force_onboarding 仅测试号置 True(让应用内重新登录也重走引导)。"""
    tokens = issue_token_pair(user.id)
    return TokenWithUser(
        **tokens,
        user=UserOut.model_validate(user),
        onboarding_completed=onboarding_completed,
        force_onboarding=force_onboarding,
    )


# ===================== 极光一键登录 =====================

@router.post("/jverify-login", response_model=TokenWithUser, summary="极光一键登录")
def jverify_login(req: JverifyLoginRequest, request: Request, db: DbSession) -> TokenWithUser:
    subject_id = _device_subject(req.device_id, request)
    if req.device_id and risk_repo.is_restricted(
        db,
        subject_type="device",
        subject_id=req.device_id,
        scope=risk_repo.SCOPE_AUTH_DEVICE,
    ):
        raise HTTPException(status_code=403, detail="当前设备环境异常，暂无法登录")
    logger.info(
        "jverify_login operator=%s token_len=%d",
        req.operator or "-",
        len(req.login_token),
    )

    try:
        phone = verify_and_get_phone(req.login_token)
    except JiguangError as e:
        risk_repo.record_behavior_event(
            db,
            event_type=risk_repo.EVENT_ONECLICK_LOGIN,
            subject_type="device",
            subject_id=subject_id,
            device_id=req.device_id or None,
            device_model=req.device_model or None,
            client_ip=_client_ip(request),
            outcome="failed",
            reason=str(e),
            details={"operator": req.operator or None},
            evaluate_rule=risk_repo.RULE_ONECLICK_DAILY,
        )
        logger.error("[JG] verify+decrypt failed: %s", e, exc_info=True)
        raise HTTPException(status_code=502, detail=f"jiguang verify failed: {e}") from e

    user = user_repo.upsert_user_for_login(db, phone=phone, register_channel="jverify")
    risk_repo.record_behavior_event(
        db,
        event_type=risk_repo.EVENT_ONECLICK_LOGIN,
        subject_type="device",
        subject_id=subject_id,
        user_id=user.id,
        device_id=req.device_id or None,
        device_model=req.device_model or None,
        phone=phone,
        client_ip=_client_ip(request),
        outcome="success",
        details={"operator": req.operator or None},
        evaluate_rule=risk_repo.RULE_ONECLICK_DAILY,
    )
    if user.status != "active":
        raise HTTPException(status_code=403, detail="account disabled")

    completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=req.device_id)
    logger.info("jverify_login ok user_id=%d phone=%s onboarded=%s", user.id, mask_phone(phone), completed)
    return _login_response(user, onboarding_completed=completed)


# ===================== 短信登录 =====================

@router.post(
    "/sms/send",
    response_model=SmsSendResponse,
    summary="发送短信验证码",
)
def sms_send(req: SmsSendRequest, request: Request, db: DbSession) -> SmsSendResponse:
    subject_id = _device_subject(req.device_id, request)
    if req.device_id and risk_repo.is_restricted(
        db,
        subject_type="device",
        subject_id=req.device_id,
        scope=risk_repo.SCOPE_AUTH_DEVICE,
    ):
        raise HTTPException(status_code=403, detail="当前设备环境异常，暂无法发送验证码")
    # 测试账号:不真发短信(号码非真实手机号,真发会失败/浪费),直接放行让客户端进入填码界面。
    # 真正的"免验证码"在 /sms/login 跳过校验;每日上限也在 login 处算(send 不耗额度)。
    # (也不受下面设备发码限流约束:QA 联调要反复发码。)
    if test_account.is_test_account(req.phone):
        logger.info("test_account sms_send short-circuit (不真发)")
        risk_repo.record_behavior_event(
            db,
            event_type=risk_repo.EVENT_SMS_SEND,
            subject_type="device",
            subject_id=subject_id,
            device_id=req.device_id or None,
            device_model=req.device_model or None,
            phone=req.phone,
            client_ip=_client_ip(request),
            # 测试号不真发短信，也豁免正式频控；保留流水供排查，但不能污染
            # “今日短信下发”与短信风控报警。
            outcome="test",
            details={"mock": True, "test_account": True},
        )
        return SmsSendResponse(sent=True, mock=True, cooldown_sec=0)

    # 发码防刷:同一设备(device_id) + 同一 IP,每小时 / 每天两道闸,**均只按成功发码计数**。
    # 补「换手机号绕开单号 60s 冷却」的洞(冷却是单号维度,一机换号能绕);设备维度按机器封顶,挡短信轰炸/烧钱。
    # 关键:被单号 60s 冷却挡下的重发是「没真发、没烧钱」→ 不该占额度。故 check(先判)放在真发之前
    # (超限直接 429、不真发),record(计数)只在 send_code 成功后调 —— 冷却/供应商失败抛 429 时直接返回、不计数。
    send_rules = [
        RateLimitRule("sms-send-device", SMS_SEND_MAX_PER_HOUR_PER_DEVICE, 3600,
                      "操作过于频繁,请稍后再试"),
        RateLimitRule("sms-send-device-daily", SMS_SEND_MAX_PER_DAY_PER_DEVICE, 86400,
                      "今日验证码发送次数过多,请明天再试"),
    ]
    check_rate_limits(request, subject=req.device_id, rules=send_rules)

    try:
        cooldown = send_code(req.phone)
    except SmsError as e:
        risk_repo.record_behavior_event(
            db,
            event_type=risk_repo.EVENT_SMS_SEND,
            subject_type="device",
            subject_id=subject_id,
            device_id=req.device_id or None,
            device_model=req.device_model or None,
            phone=req.phone,
            client_ip=_client_ip(request),
            outcome="failed",
            reason=str(e),
        )
        raise HTTPException(status_code=e.status_code, detail=str(e)) from e

    # 发码成功 → 两道闸各 +1(被单号冷却挡下的重发走不到这里,故不占额度)
    record_rate_limits(request, subject=req.device_id, rules=send_rules)

    from app.core.config import settings  # 局部 import 避免循环

    risk_repo.record_behavior_event(
        db,
        event_type=risk_repo.EVENT_SMS_SEND,
        subject_type="device",
        subject_id=subject_id,
        device_id=req.device_id or None,
        device_model=req.device_model or None,
        phone=req.phone,
        client_ip=_client_ip(request),
        outcome="success",
        details={"mock": settings.SMS_MOCK},
        evaluate_rule=risk_repo.RULE_SMS_HOURLY,
    )

    return SmsSendResponse(sent=True, mock=settings.SMS_MOCK, cooldown_sec=cooldown)


@router.post(
    "/sms/login",
    response_model=TokenWithUser,
    summary="手机号+验证码登录",
)
def sms_login(req: SmsLoginRequest, request: Request, db: DbSession) -> TokenWithUser:
    subject_id = _device_subject(req.device_id, request)
    # 测试账号:免验证码登录 + 每日上限 + 每次都走新手引导(详见 app/core/test_account.py)。
    # 放在最前面:命中即不校验验证码;先扣当日额度,超限直接拒,挡住有人猜到号后脚本刷。
    # 测试账号走自己的每日额度、不受下面 (设备+IP) 每小时限流约束(QA 需在一小时内反复登录联调)。
    if test_account.is_test_account(req.phone):
        if not test_account.try_consume_quota():
            raise HTTPException(status_code=429, detail="测试账号今日使用次数已达上限,请明天再试")
        user = user_repo.upsert_user_for_login(db, phone=req.phone, register_channel="sms")
        risk_repo.record_behavior_event(
            db,
            event_type=risk_repo.EVENT_SMS_LOGIN,
            subject_type="device",
            subject_id=subject_id,
            user_id=user.id,
            device_id=req.device_id or None,
            device_model=req.device_model or None,
            phone=req.phone,
            client_ip=_client_ip(request),
            outcome="success",
            details={"test_account": True},
        )
        if user.status != "active":
            raise HTTPException(status_code=403, detail="account disabled")
        logger.info("sms_login test_account ok user_id=%d phone=%s", user.id, mask_phone(req.phone))
        # onboarding 恒 false + force_onboarding=true:每次登录都重走引导(含应用内退出后重登),
        # 不读/不写 onboarding_completion 表。
        return _login_response(user, onboarding_completed=False, force_onboarding=True)

    # 防刷:同一设备(device_id) + 同一 IP 每小时最多 SMS_LOGIN_MAX_PER_HOUR 次登录尝试。放在验证码校验
    # **之前** → 输错验证码的失败尝试也计数,才挡得住撞库/爆破(另有单码失败 SMS_MAX_VERIFY_ATTEMPTS 次即作废兜底)。
    # ⚠️ 按设备而非手机号 → 一台机器换不同手机号刷登录也受限(防一机狂登多号);device_id 空(老客户端)时
    #    退化为该 IP 下所有空设备聚一桶,仍受限。
    enforce_rate_limit(
        request,
        scope="sms-login-device",
        subject=req.device_id,
        limit=SMS_LOGIN_MAX_PER_HOUR,
        window_sec=3600,
        detail="登录尝试过于频繁,请稍后再试",
    )

    try:
        ok = verify_code(req.phone, req.code)
    except SmsError as e:  # provider 校验降级(如阿里云接口异常)→ 原样透出其状态码(503),别误报「验证码错误」
        raise HTTPException(status_code=e.status_code, detail=str(e)) from e
    if not ok:
        # 校验码错误才记风控失败事件(provider 降级 503 已在上面提前 raise,不算「验证失败」)
        risk_repo.record_behavior_event(
            db,
            event_type=risk_repo.EVENT_SMS_LOGIN,
            subject_type="device",
            subject_id=subject_id,
            device_id=req.device_id or None,
            device_model=req.device_model or None,
            phone=req.phone,
            client_ip=_client_ip(request),
            outcome="failed",
            reason="invalid sms code",
        )
        raise HTTPException(status_code=400, detail="invalid sms code")

    user = user_repo.upsert_user_for_login(db, phone=req.phone, register_channel="sms")
    risk_repo.record_behavior_event(
        db,
        event_type=risk_repo.EVENT_SMS_LOGIN,
        subject_type="device",
        subject_id=subject_id,
        user_id=user.id,
        device_id=req.device_id or None,
        device_model=req.device_model or None,
        phone=req.phone,
        client_ip=_client_ip(request),
        outcome="success",
    )
    if user.status != "active":
        raise HTTPException(status_code=403, detail="account disabled")

    completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=req.device_id)
    logger.info("sms_login ok user_id=%d phone=%s onboarded=%s", user.id, mask_phone(req.phone), completed)
    return _login_response(user, onboarding_completed=completed)


# ===================== 微信登录 =====================

@router.post(
    "/wechat-login",
    response_model=WechatLoginResponse,
    summary="微信登录(openid 命中即登入,否则发绑号令牌)",
)
def wechat_login(req: WechatLoginRequest, db: DbSession) -> WechatLoginResponse:
    from app.core.config import settings  # 局部 import,避免循环

    # 微信登录只需 code→openid(sns/oauth2),不需要商户转账证书;故只校验 APP_ID/SECRET。
    if not (settings.WECHAT_APP_ID and settings.WECHAT_APP_SECRET):
        raise HTTPException(status_code=503, detail="wechat login not configured")

    try:
        info = wxpay.code_to_userinfo(req.code)  # {openid, nickname, avatar_url, raw};失败抛 ValueError
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e)) from e

    openid = info["openid"]
    user = user_repo.get_user_by_wechat_openid(db, openid)
    if user is not None:
        # openid 命中 → 直接登入(绝不套用提现 bind-wechat 的"撞号即 409"逻辑)
        if user.status != "active":
            raise HTTPException(status_code=403, detail="account disabled")
        user_repo.touch_last_login(db, user)
        completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=req.device_id)
        logger.info("wechat_login hit user_id=%d openid=%s*** onboarded=%s", user.id, openid[:6], completed)
        return WechatLoginResponse(
            status="logged_in",
            token=_login_response(user, onboarding_completed=completed),
        )

    # 未命中 → 签发短时 bind_ticket,进手机号绑定流程(账号此刻还不建)
    ticket = create_bind_ticket(
        openid=openid,
        wechat_nickname=info["nickname"],
        wechat_avatar_url=info["avatar_url"],
    )
    logger.info("wechat_login new openid=%s*** issue bind_ticket", openid[:6])
    return WechatLoginResponse(
        status="need_bind_phone",
        bind_ticket=ticket,
        wechat_nickname=info["nickname"],
        wechat_avatar_url=info["avatar_url"],
    )


def _finish_wechat_bind(
    db,
    *,
    openid: str,
    wechat_nickname: str | None,
    wechat_avatar_url: str | None,
    phone: str,
    device_id: str,
) -> WechatBindResultResponse:
    """绑手机建号的公共尾段:手机号被占用 → 返回 phone_occupied(M2 处理 3 选 1);
    未占用 → 新建微信账号(channel=wechat,昵称头像取微信)→ 签 token 登入。"""
    existing = user_repo.get_user_by_phone(db, phone)
    if existing is not None:
        from app.core.config import settings  # 局部 import,避免循环

        ticket = create_conflict_ticket(
            openid=openid,
            wechat_nickname=wechat_nickname,
            wechat_avatar_url=wechat_avatar_url,
            phone=phone,
        )
        blocked = rebind_repo.rebound_within_days(db, phone, settings.PHONE_REBIND_LIMIT_DAYS)
        logger.info(
            "wechat bind phone occupied phone=%s by user_id=%d has_wechat=%s",
            mask_phone(phone), existing.id, bool(existing.wechat_openid),
        )
        return WechatBindResultResponse(
            status="phone_occupied",
            occupied_account=OccupiedAccountInfo(
                nickname=existing.nickname,
                avatar_url=existing.avatar_url,
                created_at=existing.created_at,
                has_wechat=bool(existing.wechat_openid),
            ),
            conflict_ticket=ticket,
            rebind_available=not blocked,
            rebind_blocked_days=(
                rebind_repo.remaining_block_days(db, phone, settings.PHONE_REBIND_LIMIT_DAYS)
                if blocked else 0
            ),
        )
    user = user_repo.create_wechat_user(
        db,
        phone=phone,
        openid=openid,
        wechat_nickname=wechat_nickname,
        wechat_avatar_url=wechat_avatar_url,
    )
    completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=device_id)
    logger.info("wechat bind ok user_id=%d phone=%s openid=%s*** onboarded=%s",
                user.id, mask_phone(phone), openid[:6], completed)
    return WechatBindResultResponse(
        status="logged_in",
        token=_login_response(user, onboarding_completed=completed),
    )


@router.post(
    "/wechat/bind-phone/sms",
    response_model=WechatBindResultResponse,
    summary="微信登录·其他手机号(短信)绑定",
)
def wechat_bind_phone_sms(
    req: WechatBindPhoneSmsRequest, request: Request, db: DbSession
) -> WechatBindResultResponse:
    try:
        claims = decode_bind_ticket(req.bind_ticket)
    except TokenError as e:
        raise HTTPException(status_code=401, detail="授权已过期,请重新用微信登录") from e

    # 防刷:同 sms/login,按 设备+IP 每小时限流(放在验证码校验之前,失败也计数)
    enforce_rate_limit(
        request,
        scope="wechat-bind-sms-device",
        subject=req.device_id,
        limit=SMS_LOGIN_MAX_PER_HOUR,
        window_sec=3600,
        detail="登录尝试过于频繁,请稍后再试",
    )

    try:
        ok = verify_code(req.phone, req.code)
    except SmsError as e:  # provider 校验降级(如阿里云接口异常)→ 原样透出其状态码(503),别误报「验证码错误」
        raise HTTPException(status_code=e.status_code, detail=str(e)) from e
    if not ok:
        raise HTTPException(status_code=400, detail="invalid sms code")

    return _finish_wechat_bind(
        db,
        openid=claims["openid"],
        wechat_nickname=claims["wnk"],
        wechat_avatar_url=claims["wav"],
        phone=req.phone,
        device_id=req.device_id,
    )


@router.post(
    "/wechat/bind-phone/jverify",
    response_model=WechatBindResultResponse,
    summary="微信登录·本机号(极光)绑定",
)
def wechat_bind_phone_jverify(
    req: WechatBindPhoneJverifyRequest, db: DbSession
) -> WechatBindResultResponse:
    try:
        claims = decode_bind_ticket(req.bind_ticket)
    except TokenError as e:
        raise HTTPException(status_code=401, detail="授权已过期,请重新用微信登录") from e

    try:
        phone = verify_and_get_phone(req.login_token)
    except JiguangError as e:
        logger.error("[JG] verify+decrypt failed: %s", e, exc_info=True)
        raise HTTPException(status_code=502, detail=f"jiguang verify failed: {e}") from e

    return _finish_wechat_bind(
        db,
        openid=claims["openid"],
        wechat_nickname=claims["wnk"],
        wechat_avatar_url=claims["wav"],
        phone=phone,
        device_id=req.device_id,
    )


# ===================== 微信占用冲突(M2) =====================

@router.post(
    "/wechat/conflict/continue",
    response_model=WechatBindResultResponse,
    summary="微信占用冲突·继续绑定(登录老账号,能绑就绑)",
)
def wechat_conflict_continue(
    req: WechatConflictContinueRequest, request: Request, db: DbSession
) -> WechatBindResultResponse:
    try:
        claims = decode_conflict_ticket(req.conflict_ticket)
    except TokenError as e:
        raise HTTPException(status_code=401, detail="操作超时,请重新用微信登录") from e

    enforce_rate_limit(
        request, scope="wechat-conflict-device", subject=req.device_id,
        limit=SMS_LOGIN_MAX_PER_HOUR, window_sec=3600, detail="操作过于频繁,请稍后再试",
    )

    user = user_repo.get_user_by_phone(db, claims["phone"])
    if user is None:
        # P 期间被腾空(老账号改号/注销)→ 前提已变,让前端重走
        raise HTTPException(status_code=409, detail="账号状态已变化,请重新登录")
    if user.status != "active":
        raise HTTPException(status_code=403, detail="account disabled")

    if user.wechat_openid is None:
        try:
            user_repo.attach_wechat_to_user(
                db, user, openid=claims["openid"],
                wechat_nickname=claims["wnk"], wechat_avatar_url=claims["wav"],
            )
        except IntegrityError:
            db.rollback()  # openid 被别处绑走 → 只登入不绑
            user_repo.touch_last_login(db, user)
    else:
        user_repo.touch_last_login(db, user)  # X 已绑别的微信 → 只登入,丢弃本次 openid

    completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=req.device_id)
    logger.info("wechat conflict continue user_id=%d openid=%s***", user.id, claims["openid"][:6])
    return WechatBindResultResponse(
        status="logged_in",
        token=_login_response(user, onboarding_completed=completed),
    )


@router.post(
    "/wechat/conflict/rebind",
    response_model=WechatBindResultResponse,
    summary="微信占用冲突·换绑(注销老账号+用该号重建全新账号)",
)
def wechat_conflict_rebind(
    req: WechatConflictRebindRequest, request: Request, db: DbSession
) -> WechatBindResultResponse:
    from app.core.config import settings  # 局部 import,避免循环

    try:
        claims = decode_conflict_ticket(req.conflict_ticket)
    except TokenError as e:
        raise HTTPException(status_code=401, detail="操作超时,请重新用微信登录") from e

    enforce_rate_limit(
        request, scope="wechat-conflict-device", subject=req.device_id,
        limit=SMS_LOGIN_MAX_PER_HOUR, window_sec=3600, detail="操作过于频繁,请稍后再试",
    )

    phone = claims["phone"]
    if rebind_repo.rebound_within_days(db, phone, settings.PHONE_REBIND_LIMIT_DAYS):
        days = rebind_repo.remaining_block_days(db, phone, settings.PHONE_REBIND_LIMIT_DAYS)
        raise HTTPException(status_code=409, detail=f"该手机号 {days} 天内已换绑过,暂不能再次换绑")

    user = user_repo.rebind_account(
        db, phone=phone, openid=claims["openid"],
        wechat_nickname=claims["wnk"], wechat_avatar_url=claims["wav"],
    )
    completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=req.device_id)
    logger.info("wechat conflict rebind new_user_id=%d phone=%s openid=%s***",
                user.id, mask_phone(phone), claims["openid"][:6])
    return WechatBindResultResponse(
        status="logged_in",
        token=_login_response(user, onboarding_completed=completed),
    )


# ===================== Refresh =====================

@router.post("/refresh", response_model=TokenPair, summary="用 refresh_token 换新 token 对")
def refresh(req: RefreshRequest, db: DbSession) -> TokenPair:
    try:
        payload = decode_token(req.refresh_token, expected_type="refresh")
    except TokenError as e:
        raise HTTPException(status_code=401, detail=str(e)) from e

    user_id = int(payload["sub"])
    user = user_repo.get_user_by_id(db, user_id)
    if user is None or user.status != "active":
        raise HTTPException(status_code=401, detail="user not found or disabled")

    return TokenPair(**issue_token_pair(user.id))


# ===================== 当前用户 =====================

@router.get("/me", response_model=UserOut, summary="获取当前登录用户")
def me(user: CurrentUser) -> UserOut:
    return UserOut.model_validate(user)


# ===================== Logout =====================

@router.post("/logout", response_model=LogoutResponse, summary="登出(占位,客户端清 token)")
def logout(user: CurrentUser) -> LogoutResponse:
    # 当前服务端无状态,真正的 token 失效靠客户端删 token。
    # TODO: 加 jti 黑名单表后,这里把 access_token 的 jti 写黑名单
    logger.info("logout user_id=%d", user.id)
    return LogoutResponse(ok=True)
