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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user