"""手机号验证码登录路由(T11,FR-ACC-04 / FR-ACC-14)。 POST /auth/sms/send、POST /auth/sms/verify;与 auth.py(华为/dev-token/ refresh,T10)分文件以避免并行冲突,且**不 import 华为相关任何内容**—— 登录成功只依赖 identity 域已有设施(get_or_create_by_external_identity + issue_session + TokenPairResponse)。 ``_access_token``/``_token_pair`` 与 auth.py 各持一份小拷贝:私有助手不跨 路由复用,避免 T10 并行改动 auth.py 时把冲突引进本文件。 """ from __future__ import annotations from typing import Annotated from fastapi import APIRouter, Depends, Request from apps.api.deps import IdentityServiceDep, SettingsDep from packages.identity.schemas import TokenPairResponse from packages.identity.service import SessionIssued from packages.identity.sms_login import ( SmsAuthService, SmsSendRequest, SmsVerifyRequest, build_sms_auth_service, ) from packages.shared.config import Settings, get_settings from packages.shared.redis_client import get_redis from packages.shared.response import ok from packages.shared.security import create_access_token router = APIRouter(prefix="/auth", tags=["auth-sms"]) def sms_auth_dep(request: Request) -> SmsAuthService: """惰性装配并缓存到 ``app.state``(与 deps.gateway_dep 同一模式)。 缓存到 app.state 同时是集成测试的钩子:测试在发请求前把捕获型 fake sender 的 service 放进 ``app.state.sms_auth`` 即可拿到验证码——生产代码 不为测试留任何后门。 """ service: SmsAuthService | None = getattr(request.app.state, "sms_auth", None) if service is None: service = build_sms_auth_service(get_settings(), get_redis()) request.app.state.sms_auth = service return service SmsAuthDep = Annotated[SmsAuthService, Depends(sms_auth_dep)] def _client_ip(request: Request) -> str: """直连对端 IP(ASGI scope)。反向代理场景的字段解析属网关层职责,V1 不做。""" return request.client.host if request.client is not None else "unknown" def _access_token(settings: Settings, user_id: int) -> str: return create_access_token( user_id=user_id, secret=settings.jwt_secret, algorithm=settings.jwt_algorithm, ttl_seconds=settings.jwt_access_ttl_seconds, issuer=settings.jwt_issuer, ) def _token_pair( settings: Settings, access_token: str, issued: SessionIssued ) -> dict[str, str | int]: """与 auth.py 的 dev-token/refresh 完全一致的双令牌响应形态。""" return TokenPairResponse( access_token=access_token, refresh_token=issued.refresh_token, expires_in=settings.jwt_access_ttl_seconds, refresh_expires_in=settings.refresh_token_ttl_seconds, user_id=str(issued.user_id), ).model_dump() @router.post("/sms/send", summary="发送短信验证码") async def send_sms_code( payload: SmsSendRequest, request: Request, sms: SmsAuthDep ) -> dict[str, object]: # 限流/格式错误(42910/42911/40000/61000)由统一异常处理器透传给客户端 await sms.request_code(payload.phone, ip=_client_ip(request), device_id=payload.device_id) return ok({"sent": True}) @router.post("/sms/verify", summary="校验验证码并登录(双令牌)") async def verify_sms_code( payload: SmsVerifyRequest, request: Request, sms: SmsAuthDep, settings: SettingsDep, identity: IdentityServiceDep, ) -> dict[str, object]: union_id = await sms.verify( payload.phone, payload.code, ip=_client_ip(request), device_id=payload.device_id ) user = await identity.get_or_create_by_external_identity( provider="phone", union_id=union_id, register_source="phone" ) issued = await identity.issue_session(user.user_id) return ok(_token_pair(settings, _access_token(settings, user.user_id), issued))