2026-03-18 17:18:23 +08:00
|
|
|
|
import uuid
|
|
|
|
|
|
|
|
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
|
|
|
|
|
|
|
|
|
|
from app.core.config import settings
|
2026-05-18 15:34:50 +08:00
|
|
|
|
from app.core.cos_url_keys import avatar_url_for_api_response
|
2026-03-20 15:15:35 +08:00
|
|
|
|
from app.core.dependencies import get_current_user, get_object_storage
|
|
|
|
|
|
from app.core.logging import get_logger
|
2026-03-18 17:18:23 +08:00
|
|
|
|
from app.features.user.deps import get_user_service
|
|
|
|
|
|
from app.features.user.models import User
|
|
|
|
|
|
from app.features.user.schemas import (
|
|
|
|
|
|
FeedbackResponse,
|
2026-03-20 15:15:35 +08:00
|
|
|
|
PurgeUserDataRequest,
|
|
|
|
|
|
PurgeUserDataResponse,
|
2026-03-18 17:18:23 +08:00
|
|
|
|
SubmitFeedbackRequest,
|
|
|
|
|
|
TestSubscriptionRequest,
|
|
|
|
|
|
TestSubscriptionResponse,
|
|
|
|
|
|
UpdateUserProfileRequest,
|
|
|
|
|
|
UserProfileResponse,
|
|
|
|
|
|
)
|
feat(i18n): persist language preference and thread through chat, memoir, TTS
- Add users.language_preference (Alembic 0018, default zh); capture at signup/SMS
only; expose on auth and profile APIs
- Lite English prompts for chat and memoir; localized stage labels and agent
names (Life Echo / 岁月知己)
- Tencent TTS: language-aware synthesis, ModelType=1 for 501004, English chunking
- WebSocket pipeline: emit all AGENT_RESPONSE segments when TTS cancels; INFO logs
for tts_this_turn and TTS decisions; on-demand TTS logging
- Expo: device language on auth, i18n tiers/agent name, [SPLIT] streaming UX fixes
- Tests for migration, prompts, pipeline, router tts_this_turn, reply segments
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 16:16:49 +08:00
|
|
|
|
from app.features.user.service import UserService, _coerce_language as _coerce_language_token
|
2026-03-20 15:15:35 +08:00
|
|
|
|
from app.ports.storage import ObjectStorage
|
2026-03-18 17:18:23 +08:00
|
|
|
|
|
|
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
_SHARED_RESPONSES = {
|
|
|
|
|
|
401: {"description": "认证失败"},
|
|
|
|
|
|
403: {"description": "权限不足"},
|
|
|
|
|
|
404: {"description": "资源不存在"},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
router = APIRouter(
|
|
|
|
|
|
prefix="/api/user",
|
|
|
|
|
|
tags=["user"],
|
|
|
|
|
|
responses=_SHARED_RESPONSES,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
feedback_router = APIRouter(
|
|
|
|
|
|
prefix="/api/feedback",
|
|
|
|
|
|
tags=["feedback"],
|
|
|
|
|
|
responses=_SHARED_RESPONSES,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/profile", response_model=UserProfileResponse)
|
|
|
|
|
|
async def get_user_profile(
|
|
|
|
|
|
current_user: User = Depends(get_current_user),
|
|
|
|
|
|
):
|
|
|
|
|
|
return UserProfileResponse(
|
|
|
|
|
|
id=current_user.id,
|
|
|
|
|
|
phone=current_user.phone,
|
|
|
|
|
|
email=current_user.email,
|
|
|
|
|
|
nickname=current_user.nickname,
|
2026-05-18 15:34:50 +08:00
|
|
|
|
avatar_url=avatar_url_for_api_response(current_user.avatar_url),
|
2026-03-18 17:18:23 +08:00
|
|
|
|
subscription_type=current_user.subscription_type,
|
|
|
|
|
|
created_at=current_user.created_at.isoformat(),
|
|
|
|
|
|
birth_year=current_user.birth_year,
|
|
|
|
|
|
birth_place=current_user.birth_place,
|
|
|
|
|
|
grew_up_place=current_user.grew_up_place,
|
|
|
|
|
|
occupation=current_user.occupation,
|
feat(i18n): persist language preference and thread through chat, memoir, TTS
- Add users.language_preference (Alembic 0018, default zh); capture at signup/SMS
only; expose on auth and profile APIs
- Lite English prompts for chat and memoir; localized stage labels and agent
names (Life Echo / 岁月知己)
- Tencent TTS: language-aware synthesis, ModelType=1 for 501004, English chunking
- WebSocket pipeline: emit all AGENT_RESPONSE segments when TTS cancels; INFO logs
for tts_this_turn and TTS decisions; on-demand TTS logging
- Expo: device language on auth, i18n tiers/agent name, [SPLIT] streaming UX fixes
- Tests for migration, prompts, pipeline, router tts_this_turn, reply segments
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 16:16:49 +08:00
|
|
|
|
language_preference=_coerce_language_token(
|
|
|
|
|
|
getattr(current_user, "language_preference", "zh")
|
|
|
|
|
|
),
|
2026-03-18 17:18:23 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.put("/profile", response_model=UserProfileResponse)
|
|
|
|
|
|
async def update_user_profile(
|
|
|
|
|
|
body: UpdateUserProfileRequest,
|
|
|
|
|
|
current_user: User = Depends(get_current_user),
|
|
|
|
|
|
service: UserService = Depends(get_user_service),
|
|
|
|
|
|
):
|
2026-05-08 17:28:31 +08:00
|
|
|
|
logger.info(
|
|
|
|
|
|
"更新用户档案 user_id={} fields={}",
|
|
|
|
|
|
current_user.id,
|
|
|
|
|
|
sorted(body.model_fields_set),
|
|
|
|
|
|
)
|
2026-03-18 17:18:23 +08:00
|
|
|
|
return await service.update_profile(current_user.id, body)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-20 15:15:35 +08:00
|
|
|
|
@router.post("/data/purge", response_model=PurgeUserDataResponse)
|
|
|
|
|
|
async def purge_user_data(
|
|
|
|
|
|
body: PurgeUserDataRequest,
|
|
|
|
|
|
current_user: User = Depends(get_current_user),
|
|
|
|
|
|
service: UserService = Depends(get_user_service),
|
|
|
|
|
|
object_storage: ObjectStorage = Depends(get_object_storage),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""
|
|
|
|
|
|
永久删除当前账号下的业务数据:对话与片段、记忆层、故事与插图意图、书籍与章节(含图片任务行)、
|
|
|
|
|
|
回忆录状态、订单记录、刷新令牌;并清理会话 Redis 历史、任务追踪与相关分布式锁 key;
|
|
|
|
|
|
对 memory_sources / memoir_images / 关联 Asset 中记录的 storage_key 尽力删除对象存储对象。
|
2026-03-27 16:01:28 +08:00
|
|
|
|
保留 users 表中的账号与登录字段(手机号、密码等),并清空出生年/出生地/成长地/职业等档案字段。
|
|
|
|
|
|
口令见请求体 schema 说明。
|
2026-03-20 15:15:35 +08:00
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
return await service.purge_all_user_data(
|
|
|
|
|
|
current_user.id,
|
|
|
|
|
|
confirmation=body.confirmation,
|
|
|
|
|
|
object_storage=object_storage,
|
|
|
|
|
|
)
|
|
|
|
|
|
except ValueError as e:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail=str(e)) from e
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-18 17:18:23 +08:00
|
|
|
|
@router.post("/test-subscription", response_model=TestSubscriptionResponse)
|
|
|
|
|
|
async def test_subscription(
|
|
|
|
|
|
body: TestSubscriptionRequest,
|
|
|
|
|
|
current_user: User = Depends(get_current_user),
|
|
|
|
|
|
service: UserService = Depends(get_user_service),
|
|
|
|
|
|
):
|
|
|
|
|
|
if not settings.enable_test_subscription:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="测试订阅功能未开放")
|
|
|
|
|
|
if body.action == "activate" and body.plan_id not in ("pro", "pro_plus"):
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="plan_id 仅支持 pro 或 pro_plus")
|
|
|
|
|
|
return await service.toggle_test_subscription(
|
|
|
|
|
|
current_user.id, body.action, body.plan_id
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-19 14:36:14 +08:00
|
|
|
|
@feedback_router.post(
|
|
|
|
|
|
"", response_model=FeedbackResponse, status_code=status.HTTP_201_CREATED
|
|
|
|
|
|
)
|
2026-03-18 17:18:23 +08:00
|
|
|
|
async def submit_feedback(
|
|
|
|
|
|
request: SubmitFeedbackRequest,
|
|
|
|
|
|
current_user: User = Depends(get_current_user),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""提交用户反馈。用户可通过此接口提交反馈意见或联系客服。"""
|
|
|
|
|
|
feedback_id = str(uuid.uuid4())
|
|
|
|
|
|
logger.info(
|
2026-03-26 12:13:36 +08:00
|
|
|
|
"用户反馈已提交 feedback_id={} user_id={} content_len={} has_contact={}",
|
2026-03-18 17:18:23 +08:00
|
|
|
|
feedback_id,
|
|
|
|
|
|
current_user.id,
|
2026-03-22 16:45:57 +08:00
|
|
|
|
len(request.content or ""),
|
|
|
|
|
|
bool(request.contact and str(request.contact).strip()),
|
|
|
|
|
|
)
|
|
|
|
|
|
logger.debug(
|
2026-03-26 12:13:36 +08:00
|
|
|
|
"用户反馈详情: feedback_id={} user_id={} content={} contact={}",
|
2026-03-22 16:45:57 +08:00
|
|
|
|
feedback_id,
|
|
|
|
|
|
current_user.id,
|
|
|
|
|
|
request.content,
|
|
|
|
|
|
request.contact,
|
2026-03-18 17:18:23 +08:00
|
|
|
|
)
|
|
|
|
|
|
return FeedbackResponse(
|
|
|
|
|
|
id=feedback_id,
|
|
|
|
|
|
message="反馈已提交,我们会尽快处理",
|
|
|
|
|
|
)
|