refactor(api): TOML 配置 SSOT、统一错误契约、Auth/事务加固与可观测性 (#33)

配置 SSOT(TOML + .env)
统一错误契约
Auth 与事务边界
Redis / Celery 可靠性:业务 Redis(DB/0)与 Celery broker/backend(DB/1)显式拆分;连接池、sync client
可观测性(OpenTelemetry + LGTM)
This commit is contained in:
Sully
2026-05-22 13:44:50 +08:00
committed by GitHub
parent f09ae248f9
commit 53e0065e3e
298 changed files with 15247 additions and 4344 deletions

View File

@@ -4,7 +4,6 @@ import asyncio
import uuid
from datetime import datetime, timezone
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.chat.personas import agent_name
@@ -13,10 +12,18 @@ from app.core.cos_url_keys import (
collect_cos_keys_from_tts_url_list,
extract_cos_object_key_if_owned,
)
from app.core.db import transactional
from app.core.errors import (
AuthorizationError,
BadRequestError,
NotFoundError,
QuotaExceededError,
)
from app.core.logging import get_logger
from app.core.redis import redis_service
from app.core.storage_purge import delete_object_storage_keys_best_effort
from app.features.conversation import repo
from app.features.conversation.history_store import ConversationHistoryStore
from app.features.conversation.models import Conversation, Segment
from app.features.conversation.session_history import (
conversation_messages_to_redis_history,
@@ -132,8 +139,8 @@ class ConversationService:
started_at=datetime.now(timezone.utc),
status="active",
)
self._db.add(conv)
await self._db.commit()
async with transactional(self._db):
self._db.add(conv)
await self._db.refresh(conv)
return conv, ""
if conv.user_id != user_id:
@@ -152,7 +159,7 @@ class ConversationService:
audio_duration_seconds: int | None = None,
) -> Segment:
if conversation.user_id != user_id:
raise ValueError("conversation ownership mismatch")
raise AuthorizationError("无权访问此对话")
segment = Segment(
id=str(uuid.uuid4()),
conversation_id=conversation.id,
@@ -161,9 +168,9 @@ class ConversationService:
audio_duration_seconds=audio_duration_seconds,
processed=False,
)
self._db.add(segment)
conversation.last_message_at = datetime.now(timezone.utc)
await self._db.commit()
async with transactional(self._db):
self._db.add(segment)
conversation.last_message_at = datetime.now(timezone.utc)
await self._db.refresh(segment)
return segment
@@ -183,6 +190,10 @@ class ConversationService:
logger.warning("conversation history cache read skipped: {}", exc)
history = []
if history:
try:
await redis_service.extend_session_ttl(conversation_id)
except Exception as exc:
logger.debug("conversation history ttl extend skipped: {}", exc)
return history
rows = await repo.get_conversation_messages(conversation_id, self._db)
@@ -196,6 +207,13 @@ class ConversationService:
return []
async def record_ai_only_turn(
self, conversation_id: str, texts: list[str]
) -> str | None:
return await ConversationHistoryStore(self._db).record_ai_only_turn(
conversation_id, texts
)
async def list_for_user(self, user_id: str) -> list[dict]:
conversations = await repo.get_user_conversations(user_id, self._db)
# Fetch language once for fallback title localization (no per-row N+1).
@@ -235,8 +253,8 @@ class ConversationService:
started_at=datetime.now(timezone.utc),
status="active",
)
repo.add_conversation(conv, self._db)
await self._db.commit()
async with transactional(self._db):
repo.add_conversation(conv, self._db)
await self._db.refresh(conv)
return {
"id": conv.id,
@@ -248,7 +266,7 @@ class ConversationService:
async def get_or_404(self, conversation_id: str, user_id: str) -> Conversation:
conv = await repo.get_conversation(conversation_id, self._db)
if not conv or conv.user_id != user_id or conv.deleted_at is not None:
raise HTTPException(status_code=404, detail="Conversation not found")
raise NotFoundError("Conversation not found")
return conv
async def get_one(self, conversation_id: str, user_id: str) -> dict:
@@ -267,13 +285,13 @@ class ConversationService:
async def end(self, conversation_id: str, user_id: str) -> dict:
conv = await self.get_or_404(conversation_id, user_id)
conv.status = "ended"
conv.ended_at = datetime.now(timezone.utc)
if conv.started_at:
conv.duration_seconds = int(
(conv.ended_at - conv.started_at).total_seconds()
)
await self._db.commit()
async with transactional(self._db):
conv.status = "ended"
conv.ended_at = datetime.now(timezone.utc)
if conv.started_at:
conv.duration_seconds = int(
(conv.ended_at - conv.started_at).total_seconds()
)
return {
"id": conv.id,
"status": conv.status,
@@ -305,8 +323,8 @@ class ConversationService:
)
await self._clear_history(conversation_id)
conv.deleted_at = datetime.now(timezone.utc)
await self._db.commit()
async with transactional(self._db):
conv.deleted_at = datetime.now(timezone.utc)
delete_object_storage_keys_best_effort(
self._object_storage,
@@ -328,6 +346,22 @@ class ConversationService:
except Exception:
return []
async def align_conversation_stage_from_memoir(
self, conversation: Conversation, memoir_stage: str
) -> None:
"""Align conversation_stage with memoir state without regressing stage order."""
from app.agents.stage_constants import STAGE_TO_ORDER
ms = (memoir_stage or "").strip()
if not ms:
return
cs = (conversation.conversation_stage or "").strip()
async with transactional(self._db):
if not cs:
conversation.conversation_stage = ms
elif STAGE_TO_ORDER.get(ms, -1) >= STAGE_TO_ORDER.get(cs, -1):
conversation.conversation_stage = ms
async def organize(
self, conversation_id: str, user_id: str, subscription_type: str
) -> dict:
@@ -335,12 +369,12 @@ class ConversationService:
pending_p1 = await repo.get_segments_pending_phase1(conversation_id, self._db)
has_p2 = await repo.conversation_has_pending_phase2(conversation_id, self._db)
if not pending_p1 and not has_p2:
raise HTTPException(status_code=400, detail="该对话没有可整理的内容")
raise BadRequestError("该对话没有可整理的内容")
can_submit, quota_message = await self._quota.check_can_submit_organize(
user_id, subscription_type
)
if not can_submit:
raise HTTPException(status_code=403, detail=quota_message)
raise QuotaExceededError(quota_message)
if pending_p1:
segment_ids = [s.id for s in pending_p1]
process_memoir_phase1.delay(conv.user_id, segment_ids)