Files
life-echo/api/app/features/auth/router.py

654 lines
20 KiB
Python
Raw Normal View History

import io
import time
from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from fastapi.responses import FileResponse
from PIL import Image
from app.core.config import settings
from app.core.cos_url_keys import (
avatar_url_for_api_response,
best_effort_delete_cos_object_for_url,
extract_cos_object_key_if_owned,
)
from app.core.dependencies import get_current_user
refactor(api,expo): 多智能体与会话收敛、回忆录兼容层移除、后端测试集大幅删减 - 对齐「多智能体收敛」与「回忆录 stories-first / markdown-first」方向:收紧运行时契约、 删除过渡兼容路径与双轨逻辑,并同步更新客户端与文档。 - Chat:以 ChatOrchestrator 为实时编排入口;删除独立 conversation_agent,精简 prompts。 - Memoir:删除 memory_agent;MemoirOrchestrator、classification / story_route 与 prompts 收敛到 prepare_batches + run_story_pipeline_for_category_batch 主链路。 - 将 agents 侧 processor 迁入 feature 层为 background_runner,并移除 features 下重复/过时 processor 封装。 - 新增 history_store,强化「conversation_messages 为 DB 真源、Redis 为缓存」模型。 - 调整 models、repo、service、session_history;精简 WS message_types,重构 pipeline 与 router。 - 移除章节占位、整章再生等旧路径;章节列表与封面逻辑要求 story 关联;收紧 cover 资格与 enqueue。 - helpers、repo、service、router、reading_segment_materialize、story_pipeline_sync、pdf_service 等按 canonical markdown / cover_asset_id 收缩;删除 memoir_images/provider 等冗余。 - tasks:memoir_tasks、chapter_cover_tasks 等大幅瘦身;story_image_tasks 等与当前图片任务对齐。 - core:config、logging、redis、task_tracker 小幅调整。 - auth / user / payment / quota:路由或服务侧删减过时接口或逻辑(如 payment router 行数减少)。 - pyproject.toml、development.sh、.env.example / .env.production、README 等同步说明或变量。 - Alembic 0001_initial_schema 微调(与当前 schema 叙事一致的小改动)。 - 回忆录:types / mappers / api、章节页与 memoir 页与后端契约对齐;markdown-renderer 调整。 - 语音:删除 voice/player,voice-segment-store 相应精简。 - api/tests:删除 conftest 及绝大部分既有测试文件(websocket_baseline、conversation、memoir 图片、PDF、SMS 等),属有意收缩/待按 backend-test-system 重建的信号。 - docs:新增多智能体收敛与移除兼容层计划摘要;更新 story-first 设计、backend-test-system、 multi-agent-refactor-plan、实施总结等。 BREAKING CHANGE: 后端对外契约、回忆录章节字段与若干路由/任务行为已变更;大量 API 测试被移除, CI 若依赖这些用例需按新策略补测或调整流水线。
2026-03-22 16:45:57 +08:00
from app.core.logging import get_logger
from app.features.auth.deps import get_auth_service
from app.features.auth.preset_avatars import (
avatar_url_for_preset_filename,
list_preset_items,
preset_filename_for_id,
preset_file_path,
safe_avatar_upload_path,
)
from app.features.auth.schemas import (
AvatarPresetItem,
ChangePasswordRequest,
ChangePhoneRequest,
LoginRequest,
MockSmsLoginRequest,
RefreshTokenRequest,
RegisterRequest,
ResetPasswordRequest,
SendSmsRequest,
SetAvatarPresetRequest,
SmsLoginRequest,
SmsRegisterRequest,
TokenResponse,
UpdateNicknameRequest,
UserResponse,
)
from app.features.auth.service import AuthError, AuthService
from app.features.user.models import User
logger = get_logger(__name__)
router = APIRouter(
prefix="/api/auth",
tags=["auth"],
responses={401: {"description": "认证失败"}},
)
AVATAR_DIR = Path("uploads/avatars")
# ── helpers ──────────────────────────────────────────────────
_ERROR_STATUS: dict[str, int] = {
"INVALID_CREDENTIALS": status.HTTP_401_UNAUTHORIZED,
"INVALID_TOKEN": status.HTTP_401_UNAUTHORIZED,
"TOKEN_REVOKED": status.HTTP_401_UNAUTHORIZED,
"TOKEN_EXPIRED": status.HTTP_401_UNAUTHORIZED,
"USER_NOT_FOUND": status.HTTP_404_NOT_FOUND,
"PHONE_EXISTS": status.HTTP_400_BAD_REQUEST,
}
def _map_auth_error(e: AuthError) -> HTTPException:
code = _ERROR_STATUS.get(e.code, status.HTTP_400_BAD_REQUEST)
return HTTPException(status_code=code, detail=e.message)
def _user_response(user: User) -> UserResponse:
raw_lang = getattr(user, "language_preference", "zh")
lang = str(raw_lang).strip().lower() if isinstance(raw_lang, str) else "zh"
if lang not in ("zh", "en"):
lang = "zh"
return UserResponse(
id=user.id,
phone=user.phone,
email=user.email,
nickname=user.nickname,
avatar_url=avatar_url_for_api_response(user.avatar_url),
subscription_type=user.subscription_type,
created_at=user.created_at.isoformat(),
language_preference=lang,
)
def _check_terms(agreed: bool) -> None:
if not agreed:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="请先阅读并同意用户协议和隐私政策",
)
def _mock_sms_login_route_enabled() -> bool:
env = (settings.app_environment or "").lower().strip()
if env == "production":
return False
return bool(settings.mock_sms_login_enabled)
# ── registration & login ─────────────────────────────────────
@router.post(
"/register",
response_model=TokenResponse,
status_code=status.HTTP_201_CREATED,
summary="手机号密码注册",
responses={400: {"description": "手机号/邮箱已注册或参数错误"}},
)
async def register(
request: RegisterRequest,
service: AuthService = Depends(get_auth_service),
):
_check_terms(request.agreed_to_terms)
try:
result = await service.register(
phone=request.phone,
password=request.password,
nickname=request.nickname,
email=request.email,
language=request.language,
)
except AuthError as e:
raise _map_auth_error(e)
return TokenResponse(
access_token=result["access_token"],
refresh_token=result["refresh_token"],
)
@router.post(
"/login",
response_model=TokenResponse,
summary="手机号密码登录",
responses={401: {"description": "手机号或密码错误"}},
)
async def login(
request: LoginRequest,
service: AuthService = Depends(get_auth_service),
):
_check_terms(request.agreed_to_terms)
try:
result = await service.login(
phone=request.phone,
password=request.password,
)
except AuthError as e:
raise _map_auth_error(e)
return TokenResponse(
access_token=result["access_token"],
refresh_token=result["refresh_token"],
)
@router.post(
"/refresh",
response_model=TokenResponse,
summary="刷新访问令牌",
responses={401: {"description": "刷新令牌无效/已撤销/已过期"}},
)
async def refresh_token(
request: RefreshTokenRequest,
service: AuthService = Depends(get_auth_service),
):
try:
result = await service.refresh_tokens(
refresh_token=request.refresh_token,
)
except AuthError as e:
raise _map_auth_error(e)
return TokenResponse(
access_token=result["access_token"],
refresh_token=result["refresh_token"],
)
# ── logout ────────────────────────────────────────────────────
@router.post(
"/logout",
status_code=status.HTTP_200_OK,
summary="登出当前设备",
)
async def logout(
request: RefreshTokenRequest,
current_user: User = Depends(get_current_user),
service: AuthService = Depends(get_auth_service),
):
await service.logout(request.refresh_token, current_user.id)
return {"message": "登出成功"}
@router.post(
"/logout/all",
summary="登出所有设备",
)
async def logout_all_devices(
current_user: User = Depends(get_current_user),
service: AuthService = Depends(get_auth_service),
):
count = await service.logout_all(current_user.id)
return {"message": f"已登出所有设备,共撤销 {count} 个令牌"}
# ── user profile ──────────────────────────────────────────────
@router.get(
"/me",
response_model=UserResponse,
summary="获取当前用户信息",
)
async def get_me(
current_user: User = Depends(get_current_user),
):
return _user_response(current_user)
@router.put(
"/me/nickname",
response_model=UserResponse,
summary="修改昵称",
)
async def update_nickname(
request: UpdateNicknameRequest,
current_user: User = Depends(get_current_user),
service: AuthService = Depends(get_auth_service),
):
try:
user = await service.update_nickname(current_user.id, request.nickname)
except AuthError as e:
raise _map_auth_error(e)
return _user_response(user)
# ── avatar ────────────────────────────────────────────────────
@router.post(
"/me/avatar",
response_model=UserResponse,
summary="上传头像",
responses={400: {"description": "文件类型或大小不符合要求"}},
)
async def upload_avatar(
file: UploadFile = File(...),
current_user: User = Depends(get_current_user),
service: AuthService = Depends(get_auth_service),
):
allowed_types = ["image/jpeg", "image/png", "image/webp"]
if file.content_type not in allowed_types:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"不支持的文件类型。仅支持: {', '.join(allowed_types)}",
)
file_content = await file.read()
if not file_content or len(file_content) == 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="文件内容为空",
)
if len(file_content) > 5 * 1024 * 1024:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="文件大小超过5MB限制",
)
refactor(api,expo): 多智能体与会话收敛、回忆录兼容层移除、后端测试集大幅删减 - 对齐「多智能体收敛」与「回忆录 stories-first / markdown-first」方向:收紧运行时契约、 删除过渡兼容路径与双轨逻辑,并同步更新客户端与文档。 - Chat:以 ChatOrchestrator 为实时编排入口;删除独立 conversation_agent,精简 prompts。 - Memoir:删除 memory_agent;MemoirOrchestrator、classification / story_route 与 prompts 收敛到 prepare_batches + run_story_pipeline_for_category_batch 主链路。 - 将 agents 侧 processor 迁入 feature 层为 background_runner,并移除 features 下重复/过时 processor 封装。 - 新增 history_store,强化「conversation_messages 为 DB 真源、Redis 为缓存」模型。 - 调整 models、repo、service、session_history;精简 WS message_types,重构 pipeline 与 router。 - 移除章节占位、整章再生等旧路径;章节列表与封面逻辑要求 story 关联;收紧 cover 资格与 enqueue。 - helpers、repo、service、router、reading_segment_materialize、story_pipeline_sync、pdf_service 等按 canonical markdown / cover_asset_id 收缩;删除 memoir_images/provider 等冗余。 - tasks:memoir_tasks、chapter_cover_tasks 等大幅瘦身;story_image_tasks 等与当前图片任务对齐。 - core:config、logging、redis、task_tracker 小幅调整。 - auth / user / payment / quota:路由或服务侧删减过时接口或逻辑(如 payment router 行数减少)。 - pyproject.toml、development.sh、.env.example / .env.production、README 等同步说明或变量。 - Alembic 0001_initial_schema 微调(与当前 schema 叙事一致的小改动)。 - 回忆录:types / mappers / api、章节页与 memoir 页与后端契约对齐;markdown-renderer 调整。 - 语音:删除 voice/player,voice-segment-store 相应精简。 - api/tests:删除 conftest 及绝大部分既有测试文件(websocket_baseline、conversation、memoir 图片、PDF、SMS 等),属有意收缩/待按 backend-test-system 重建的信号。 - docs:新增多智能体收敛与移除兼容层计划摘要;更新 story-first 设计、backend-test-system、 multi-agent-refactor-plan、实施总结等。 BREAKING CHANGE: 后端对外契约、回忆录章节字段与若干路由/任务行为已变更;大量 API 测试被移除, CI 若依赖这些用例需按新策略补测或调整流水线。
2026-03-22 16:45:57 +08:00
logger.debug(
"上传头像: user_id={} filename={} content_type={} size={}",
refactor(api,expo): 多智能体与会话收敛、回忆录兼容层移除、后端测试集大幅删减 - 对齐「多智能体收敛」与「回忆录 stories-first / markdown-first」方向:收紧运行时契约、 删除过渡兼容路径与双轨逻辑,并同步更新客户端与文档。 - Chat:以 ChatOrchestrator 为实时编排入口;删除独立 conversation_agent,精简 prompts。 - Memoir:删除 memory_agent;MemoirOrchestrator、classification / story_route 与 prompts 收敛到 prepare_batches + run_story_pipeline_for_category_batch 主链路。 - 将 agents 侧 processor 迁入 feature 层为 background_runner,并移除 features 下重复/过时 processor 封装。 - 新增 history_store,强化「conversation_messages 为 DB 真源、Redis 为缓存」模型。 - 调整 models、repo、service、session_history;精简 WS message_types,重构 pipeline 与 router。 - 移除章节占位、整章再生等旧路径;章节列表与封面逻辑要求 story 关联;收紧 cover 资格与 enqueue。 - helpers、repo、service、router、reading_segment_materialize、story_pipeline_sync、pdf_service 等按 canonical markdown / cover_asset_id 收缩;删除 memoir_images/provider 等冗余。 - tasks:memoir_tasks、chapter_cover_tasks 等大幅瘦身;story_image_tasks 等与当前图片任务对齐。 - core:config、logging、redis、task_tracker 小幅调整。 - auth / user / payment / quota:路由或服务侧删减过时接口或逻辑(如 payment router 行数减少)。 - pyproject.toml、development.sh、.env.example / .env.production、README 等同步说明或变量。 - Alembic 0001_initial_schema 微调(与当前 schema 叙事一致的小改动)。 - 回忆录:types / mappers / api、章节页与 memoir 页与后端契约对齐;markdown-renderer 调整。 - 语音:删除 voice/player,voice-segment-store 相应精简。 - api/tests:删除 conftest 及绝大部分既有测试文件(websocket_baseline、conversation、memoir 图片、PDF、SMS 等),属有意收缩/待按 backend-test-system 重建的信号。 - docs:新增多智能体收敛与移除兼容层计划摘要;更新 story-first 设计、backend-test-system、 multi-agent-refactor-plan、实施总结等。 BREAKING CHANGE: 后端对外契约、回忆录章节字段与若干路由/任务行为已变更;大量 API 测试被移除, CI 若依赖这些用例需按新策略补测或调整流水线。
2026-03-22 16:45:57 +08:00
current_user.id,
file.filename,
file.content_type,
len(file_content),
)
if not (
(settings.tencent_cos_secret_id or "").strip()
and (settings.tencent_cos_secret_key or "").strip()
and (settings.tencent_cos_bucket or "").strip()
):
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="头像存储服务未配置,请稍后再试",
)
try:
image_bytes = io.BytesIO(file_content)
image_bytes.seek(0)
header = image_bytes.read(16)
image_bytes.seek(0)
is_valid_image = False
if header.startswith(b"\xff\xd8\xff"):
is_valid_image = True
elif header.startswith(b"\x89PNG\r\n\x1a\n"):
is_valid_image = True
elif header.startswith(b"RIFF") and b"WEBP" in header[:12]:
is_valid_image = True
else:
refactor(api,expo): 多智能体与会话收敛、回忆录兼容层移除、后端测试集大幅删减 - 对齐「多智能体收敛」与「回忆录 stories-first / markdown-first」方向:收紧运行时契约、 删除过渡兼容路径与双轨逻辑,并同步更新客户端与文档。 - Chat:以 ChatOrchestrator 为实时编排入口;删除独立 conversation_agent,精简 prompts。 - Memoir:删除 memory_agent;MemoirOrchestrator、classification / story_route 与 prompts 收敛到 prepare_batches + run_story_pipeline_for_category_batch 主链路。 - 将 agents 侧 processor 迁入 feature 层为 background_runner,并移除 features 下重复/过时 processor 封装。 - 新增 history_store,强化「conversation_messages 为 DB 真源、Redis 为缓存」模型。 - 调整 models、repo、service、session_history;精简 WS message_types,重构 pipeline 与 router。 - 移除章节占位、整章再生等旧路径;章节列表与封面逻辑要求 story 关联;收紧 cover 资格与 enqueue。 - helpers、repo、service、router、reading_segment_materialize、story_pipeline_sync、pdf_service 等按 canonical markdown / cover_asset_id 收缩;删除 memoir_images/provider 等冗余。 - tasks:memoir_tasks、chapter_cover_tasks 等大幅瘦身;story_image_tasks 等与当前图片任务对齐。 - core:config、logging、redis、task_tracker 小幅调整。 - auth / user / payment / quota:路由或服务侧删减过时接口或逻辑(如 payment router 行数减少)。 - pyproject.toml、development.sh、.env.example / .env.production、README 等同步说明或变量。 - Alembic 0001_initial_schema 微调(与当前 schema 叙事一致的小改动)。 - 回忆录:types / mappers / api、章节页与 memoir 页与后端契约对齐;markdown-renderer 调整。 - 语音:删除 voice/player,voice-segment-store 相应精简。 - api/tests:删除 conftest 及绝大部分既有测试文件(websocket_baseline、conversation、memoir 图片、PDF、SMS 等),属有意收缩/待按 backend-test-system 重建的信号。 - docs:新增多智能体收敛与移除兼容层计划摘要;更新 story-first 设计、backend-test-system、 multi-agent-refactor-plan、实施总结等。 BREAKING CHANGE: 后端对外契约、回忆录章节字段与若干路由/任务行为已变更;大量 API 测试被移除, CI 若依赖这些用例需按新策略补测或调整流水线。
2026-03-22 16:45:57 +08:00
logger.warning("无法识别的图片文件头")
logger.debug("无法识别的文件头 hex={}", header[:12].hex())
if not is_valid_image:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"无效的图片文件格式。文件头: {header[:12].hex()}",
)
image = Image.open(image_bytes)
refactor(api,expo): 多智能体与会话收敛、回忆录兼容层移除、后端测试集大幅删减 - 对齐「多智能体收敛」与「回忆录 stories-first / markdown-first」方向:收紧运行时契约、 删除过渡兼容路径与双轨逻辑,并同步更新客户端与文档。 - Chat:以 ChatOrchestrator 为实时编排入口;删除独立 conversation_agent,精简 prompts。 - Memoir:删除 memory_agent;MemoirOrchestrator、classification / story_route 与 prompts 收敛到 prepare_batches + run_story_pipeline_for_category_batch 主链路。 - 将 agents 侧 processor 迁入 feature 层为 background_runner,并移除 features 下重复/过时 processor 封装。 - 新增 history_store,强化「conversation_messages 为 DB 真源、Redis 为缓存」模型。 - 调整 models、repo、service、session_history;精简 WS message_types,重构 pipeline 与 router。 - 移除章节占位、整章再生等旧路径;章节列表与封面逻辑要求 story 关联;收紧 cover 资格与 enqueue。 - helpers、repo、service、router、reading_segment_materialize、story_pipeline_sync、pdf_service 等按 canonical markdown / cover_asset_id 收缩;删除 memoir_images/provider 等冗余。 - tasks:memoir_tasks、chapter_cover_tasks 等大幅瘦身;story_image_tasks 等与当前图片任务对齐。 - core:config、logging、redis、task_tracker 小幅调整。 - auth / user / payment / quota:路由或服务侧删减过时接口或逻辑(如 payment router 行数减少)。 - pyproject.toml、development.sh、.env.example / .env.production、README 等同步说明或变量。 - Alembic 0001_initial_schema 微调(与当前 schema 叙事一致的小改动)。 - 回忆录:types / mappers / api、章节页与 memoir 页与后端契约对齐;markdown-renderer 调整。 - 语音:删除 voice/player,voice-segment-store 相应精简。 - api/tests:删除 conftest 及绝大部分既有测试文件(websocket_baseline、conversation、memoir 图片、PDF、SMS 等),属有意收缩/待按 backend-test-system 重建的信号。 - docs:新增多智能体收敛与移除兼容层计划摘要;更新 story-first 设计、backend-test-system、 multi-agent-refactor-plan、实施总结等。 BREAKING CHANGE: 后端对外契约、回忆录章节字段与若干路由/任务行为已变更;大量 API 测试被移除, CI 若依赖这些用例需按新策略补测或调整流水线。
2026-03-22 16:45:57 +08:00
logger.debug(
"头像解码: format={} mode={} size={}",
refactor(api,expo): 多智能体与会话收敛、回忆录兼容层移除、后端测试集大幅删减 - 对齐「多智能体收敛」与「回忆录 stories-first / markdown-first」方向:收紧运行时契约、 删除过渡兼容路径与双轨逻辑,并同步更新客户端与文档。 - Chat:以 ChatOrchestrator 为实时编排入口;删除独立 conversation_agent,精简 prompts。 - Memoir:删除 memory_agent;MemoirOrchestrator、classification / story_route 与 prompts 收敛到 prepare_batches + run_story_pipeline_for_category_batch 主链路。 - 将 agents 侧 processor 迁入 feature 层为 background_runner,并移除 features 下重复/过时 processor 封装。 - 新增 history_store,强化「conversation_messages 为 DB 真源、Redis 为缓存」模型。 - 调整 models、repo、service、session_history;精简 WS message_types,重构 pipeline 与 router。 - 移除章节占位、整章再生等旧路径;章节列表与封面逻辑要求 story 关联;收紧 cover 资格与 enqueue。 - helpers、repo、service、router、reading_segment_materialize、story_pipeline_sync、pdf_service 等按 canonical markdown / cover_asset_id 收缩;删除 memoir_images/provider 等冗余。 - tasks:memoir_tasks、chapter_cover_tasks 等大幅瘦身;story_image_tasks 等与当前图片任务对齐。 - core:config、logging、redis、task_tracker 小幅调整。 - auth / user / payment / quota:路由或服务侧删减过时接口或逻辑(如 payment router 行数减少)。 - pyproject.toml、development.sh、.env.example / .env.production、README 等同步说明或变量。 - Alembic 0001_initial_schema 微调(与当前 schema 叙事一致的小改动)。 - 回忆录:types / mappers / api、章节页与 memoir 页与后端契约对齐;markdown-renderer 调整。 - 语音:删除 voice/player,voice-segment-store 相应精简。 - api/tests:删除 conftest 及绝大部分既有测试文件(websocket_baseline、conversation、memoir 图片、PDF、SMS 等),属有意收缩/待按 backend-test-system 重建的信号。 - docs:新增多智能体收敛与移除兼容层计划摘要;更新 story-first 设计、backend-test-system、 multi-agent-refactor-plan、实施总结等。 BREAKING CHANGE: 后端对外契约、回忆录章节字段与若干路由/任务行为已变更;大量 API 测试被移除, CI 若依赖这些用例需按新策略补测或调整流水线。
2026-03-22 16:45:57 +08:00
image.format,
image.mode,
image.size,
)
if image.mode != "RGB":
image = image.convert("RGB")
width, height = image.size
size = min(width, height)
left = (width - size) // 2
top = (height - size) // 2
right = left + size
bottom = top + size
image = image.crop((left, top, right, bottom))
if size > 512:
image = image.resize((512, 512), Image.Resampling.LANCZOS)
jpeg_buffer = io.BytesIO()
image.save(jpeg_buffer, format="JPEG", quality=85, optimize=True)
jpeg_bytes = jpeg_buffer.getvalue()
cos_key = f"avatars/{current_user.id}.jpg"
old_url = current_user.avatar_url
old_key = extract_cos_object_key_if_owned(old_url) if old_url else None
if old_key and old_key != cos_key:
best_effort_delete_cos_object_for_url(old_url)
from app.core.dependencies import get_object_storage
storage = get_object_storage()
try:
avatar_url = storage.upload(cos_key, jpeg_bytes, "image/jpeg")
except Exception as exc:
logger.exception("COS 头像上传失败: {}", exc)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="头像存储暂时不可用,请稍后再试",
) from exc
user = await service.update_avatar_url(current_user.id, avatar_url)
return _user_response(user)
except HTTPException:
raise
except Exception as e:
logger.exception("头像上传失败: {}", e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="处理图片失败,请重试",
refactor(api,expo): 多智能体与会话收敛、回忆录兼容层移除、后端测试集大幅删减 - 对齐「多智能体收敛」与「回忆录 stories-first / markdown-first」方向:收紧运行时契约、 删除过渡兼容路径与双轨逻辑,并同步更新客户端与文档。 - Chat:以 ChatOrchestrator 为实时编排入口;删除独立 conversation_agent,精简 prompts。 - Memoir:删除 memory_agent;MemoirOrchestrator、classification / story_route 与 prompts 收敛到 prepare_batches + run_story_pipeline_for_category_batch 主链路。 - 将 agents 侧 processor 迁入 feature 层为 background_runner,并移除 features 下重复/过时 processor 封装。 - 新增 history_store,强化「conversation_messages 为 DB 真源、Redis 为缓存」模型。 - 调整 models、repo、service、session_history;精简 WS message_types,重构 pipeline 与 router。 - 移除章节占位、整章再生等旧路径;章节列表与封面逻辑要求 story 关联;收紧 cover 资格与 enqueue。 - helpers、repo、service、router、reading_segment_materialize、story_pipeline_sync、pdf_service 等按 canonical markdown / cover_asset_id 收缩;删除 memoir_images/provider 等冗余。 - tasks:memoir_tasks、chapter_cover_tasks 等大幅瘦身;story_image_tasks 等与当前图片任务对齐。 - core:config、logging、redis、task_tracker 小幅调整。 - auth / user / payment / quota:路由或服务侧删减过时接口或逻辑(如 payment router 行数减少)。 - pyproject.toml、development.sh、.env.example / .env.production、README 等同步说明或变量。 - Alembic 0001_initial_schema 微调(与当前 schema 叙事一致的小改动)。 - 回忆录:types / mappers / api、章节页与 memoir 页与后端契约对齐;markdown-renderer 调整。 - 语音:删除 voice/player,voice-segment-store 相应精简。 - api/tests:删除 conftest 及绝大部分既有测试文件(websocket_baseline、conversation、memoir 图片、PDF、SMS 等),属有意收缩/待按 backend-test-system 重建的信号。 - docs:新增多智能体收敛与移除兼容层计划摘要;更新 story-first 设计、backend-test-system、 multi-agent-refactor-plan、实施总结等。 BREAKING CHANGE: 后端对外契约、回忆录章节字段与若干路由/任务行为已变更;大量 API 测试被移除, CI 若依赖这些用例需按新策略补测或调整流水线。
2026-03-22 16:45:57 +08:00
) from e
@router.get(
"/avatar-presets",
response_model=list[AvatarPresetItem],
summary="预设头像列表",
)
async def list_avatar_presets():
return [
AvatarPresetItem(id=item_id, url=item_url)
for item_id, item_url in list_preset_items()
]
@router.put(
"/me/avatar/preset",
response_model=UserResponse,
summary="使用预设头像",
responses={400: {"description": "无效的预设编号"}},
)
async def set_avatar_preset(
request: SetAvatarPresetRequest,
current_user: User = Depends(get_current_user),
service: AuthService = Depends(get_auth_service),
):
filename = preset_filename_for_id(request.preset_id)
if filename is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的预设头像编号",
)
path = preset_file_path(filename)
if path is None or not path.exists():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="预设头像不可用",
)
best_effort_delete_cos_object_for_url(current_user.avatar_url)
avatar_url = f"{avatar_url_for_preset_filename(filename)}?v={time.time_ns()}"
try:
user = await service.update_avatar_url(current_user.id, avatar_url)
except AuthError as e:
raise _map_auth_error(e)
return _user_response(user)
@router.get(
"/avatar-presets/{filename}",
summary="获取预设头像图片",
responses={404: {"description": "预设不存在"}},
)
async def get_avatar_preset(filename: str):
path = preset_file_path(filename)
if path is None or not path.exists():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="预设头像不存在",
)
return FileResponse(path, media_type="image/png")
@router.get(
"/avatars/{filename}",
summary="获取头像图片",
responses={404: {"description": "头像不存在"}},
)
async def get_avatar(filename: str):
AVATAR_DIR.mkdir(parents=True, exist_ok=True)
file_path = safe_avatar_upload_path(filename, AVATAR_DIR)
if file_path is None or not file_path.exists():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="头像不存在",
)
return FileResponse(file_path, media_type="image/jpeg")
# ── SMS verification ──────────────────────────────────────────
@router.post(
"/sms/send",
summary="发送短信验证码",
responses={
400: {"description": "手机号格式或用途不合法"},
429: {"description": "发送过于频繁"},
503: {"description": "短信服务不可用"},
},
)
async def send_sms_code(
request: SendSmsRequest,
service: AuthService = Depends(get_auth_service),
):
if not request.phone.isdigit():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="手机号格式不正确",
)
valid_purposes = ["register", "login", "reset_password", "change_phone"]
if request.purpose not in valid_purposes:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"无效的用途,必须是: {', '.join(valid_purposes)}",
)
try:
success, message, expires_in = await service.send_sms_code(
phone=request.phone,
purpose=request.purpose,
ip_address=None,
)
except AuthError as e:
raise _map_auth_error(e)
if not success:
if "频繁" in message:
status_code = status.HTTP_429_TOO_MANY_REQUESTS
elif "配置" in message or "配置错误" in message or "授权失败" in message:
status_code = status.HTTP_503_SERVICE_UNAVAILABLE
else:
status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
raise HTTPException(status_code=status_code, detail=message)
return {"message": message, "expires_in": expires_in}
@router.post(
"/login/sms",
response_model=TokenResponse,
summary="短信验证码登录(新用户自动注册)",
responses={400: {"description": "验证码错误"}},
)
async def login_with_sms(
request: SmsLoginRequest,
service: AuthService = Depends(get_auth_service),
):
_check_terms(request.agreed_to_terms)
try:
result = await service.login_with_sms(
phone=request.phone,
code=request.code,
nickname=request.nickname,
language=request.language,
)
except AuthError as e:
raise _map_auth_error(e)
return TokenResponse(
access_token=result["access_token"],
refresh_token=result["refresh_token"],
)
@router.post(
"/mock/sms-login",
response_model=TokenResponse,
summary="[评测] Mock 短信登录(跳过验证码)",
description=(
"需 MOCK_SMS_LOGIN_ENABLED=1 且 APP_ENV 非 production。"
"供 Eval Web 等内网工具联调,勿在生产环境开启。"
),
responses={404: {"description": "未启用或生产环境已禁用"}},
)
async def mock_sms_login_route(
request: MockSmsLoginRequest,
service: AuthService = Depends(get_auth_service),
):
if not _mock_sms_login_route_enabled():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found")
_check_terms(request.agreed_to_terms)
try:
result = await service.mock_sms_login(
phone=request.phone,
nickname=request.nickname,
language=request.language,
)
except AuthError as e:
raise _map_auth_error(e)
return TokenResponse(
access_token=result["access_token"],
refresh_token=result["refresh_token"],
)
@router.post(
"/register/sms",
response_model=TokenResponse,
status_code=status.HTTP_201_CREATED,
summary="短信验证码注册",
responses={400: {"description": "验证码错误或手机号/邮箱已注册"}},
)
async def register_with_sms(
request: SmsRegisterRequest,
service: AuthService = Depends(get_auth_service),
):
_check_terms(request.agreed_to_terms)
try:
result = await service.register_with_sms(
phone=request.phone,
code=request.code,
password=request.password,
nickname=request.nickname,
email=request.email,
language=request.language,
)
except AuthError as e:
raise _map_auth_error(e)
return TokenResponse(
access_token=result["access_token"],
refresh_token=result["refresh_token"],
)
# ── password & phone management ───────────────────────────────
@router.post(
"/password/reset",
summary="通过短信验证码重置密码",
responses={
400: {"description": "验证码错误"},
404: {"description": "用户不存在"},
},
)
async def reset_password(
request: ResetPasswordRequest,
service: AuthService = Depends(get_auth_service),
):
try:
await service.reset_password(
phone=request.phone,
code=request.code,
new_password=request.new_password,
)
except AuthError as e:
raise _map_auth_error(e)
return {"message": "密码重置成功"}
@router.post(
"/password/change",
summary="修改密码(需旧密码)",
responses={400: {"description": "旧密码错误"}},
)
async def change_password(
request: ChangePasswordRequest,
current_user: User = Depends(get_current_user),
service: AuthService = Depends(get_auth_service),
):
try:
await service.change_password(
user_id=current_user.id,
old_password=request.old_password,
new_password=request.new_password,
)
except AuthError as e:
raise _map_auth_error(e)
return {"message": "密码修改成功"}
@router.post(
"/phone/change",
response_model=UserResponse,
summary="更换手机号",
responses={400: {"description": "验证码错误或手机号已被占用"}},
)
async def change_phone(
request: ChangePhoneRequest,
current_user: User = Depends(get_current_user),
service: AuthService = Depends(get_auth_service),
):
try:
user = await service.change_phone(
user_id=current_user.id,
new_phone=request.new_phone,
code=request.code,
)
except AuthError as e:
raise _map_auth_error(e)
return _user_response(user)