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

@@ -14,7 +14,7 @@ from app.core.logging import get_logger
if TYPE_CHECKING:
from app.features.quota.service import QuotaService
from sqlalchemy import select, update
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.chat import ChatOrchestrator
@@ -27,6 +27,10 @@ from app.core.cos_url_keys import (
extract_cos_object_key_if_owned,
)
from app.core.db import AsyncSessionLocal
from app.features.conversation.ws.persist import (
persist_message_tts_url_segment,
persist_voice_segment_row,
)
from app.core.dependencies import get_asr_provider, get_object_storage, get_tts_provider
from app.features.conversation.chat_turn import (
ChatTurnContext,
@@ -37,7 +41,6 @@ from app.features.conversation.history_store import (
AI_RESPONSE_SEGMENT_JOIN,
ConversationHistoryStore,
)
from app.features.conversation.lineage_schemas import DialogueLineage
from app.features.conversation.models import Conversation, ConversationMessage, Segment
from app.features.conversation.ws.connection_manager import manager
from app.features.conversation.ws.message_types import MessageType
@@ -47,11 +50,12 @@ from app.features.conversation.ws.profile_collector import (
get_missing_profile_fields,
)
from app.features.conversation.ws.topic_chips_push import maybe_send_topic_chips_ws
from app.features.memoir.state_service import get_or_create_state
from app.features.memoir.background_runner import BackgroundTaskRunner
from app.features.memoir.ingest_scheduler import MemoirIngestScheduler
from app.features.memoir.ingest_scheduler import MemoirIngestScheduler, MemoirTrigger
from app.features.memoir.state_service import get_or_create_state
from app.features.user.models import User
from app.ports.asr import ASRTranscriptionError
from app.core.runtime_constants import tts_defaults
logger = get_logger(__name__)
@@ -118,20 +122,20 @@ async def _send_tts_audio(
chunk_index,
language,
(text or "")[:30],
settings.tts_provider,
tts_defaults.provider,
)
return None
if _tts_epoch_value(conversation_id) != tts_epoch_start:
return None
ext = _tts_object_ext(settings.tts_codec)
content_type = _tts_codec_to_content_type(settings.tts_codec)
ext = _tts_object_ext(tts_defaults.codec)
content_type = _tts_codec_to_content_type(tts_defaults.codec)
storage = get_object_storage()
key = f"conversations/{conversation_id}/tts/{uuid.uuid4().hex}.{ext}"
public_url = storage.upload(key, audio_bytes, content_type)
# 与 `tts_delivery.apply_presigned_tts_urls_to_messages` / 回忆录图片 presign 一致:下发可播 URL
playback_url = storage.get_url(key, expires=TTS_PRESIGNED_EXPIRES_SEC)
payload_data: Dict[str, Any] = {
"format": settings.tts_codec,
"format": tts_defaults.codec,
"audio_base64": base64.b64encode(audio_bytes).decode("utf-8"),
"audio_url": playback_url,
"index": chunk_index,
@@ -182,7 +186,7 @@ async def handle_tts_request_on_demand(
segment_index,
len(segment_text or ""),
settings.enable_tts,
settings.tts_provider,
tts_defaults.provider,
)
conv = await db.get(Conversation, conversation_id)
@@ -260,7 +264,7 @@ async def handle_tts_request_on_demand(
"conversation_id": conversation_id,
"data": {
"audio_url": playback_url,
"format": settings.tts_codec,
"format": tts_defaults.codec,
"index": segment_index,
"total": chunk_total,
"assistant_message_id": assistant_message_id,
@@ -319,11 +323,7 @@ async def handle_tts_request_on_demand(
)
return False, "语音合成失败"
while len(urls) <= segment_index:
urls.append("")
urls[segment_index] = url_stored
msg.tts_audio_urls = urls
await db.commit()
await persist_message_tts_url_segment(db, msg, segment_index, url_stored)
store = ConversationHistoryStore(db)
await store._sync_redis_best_effort(conversation_id)
@@ -344,6 +344,24 @@ _background_runner = BackgroundTaskRunner()
memoir_ingest_scheduler = MemoirIngestScheduler(_background_runner)
async def _schedule_memoir_ingest_for_segment(
user_id: str,
segment: Segment,
*,
trigger: MemoirTrigger = "turn",
) -> None:
"""Queue memoir phase1 after segment text (and ideally lineage) is durable."""
text = (segment.user_input_text or "").strip()
if not text:
return
await memoir_ingest_scheduler.queue_segment(
user_id,
str(segment.id),
text_char_count=len(text),
trigger=trigger,
)
# ── 分段流状态 ──────────────────────────────────────────────────
@@ -457,14 +475,6 @@ def _utc_now() -> datetime:
return datetime.now(timezone.utc)
def _mark_conversation_active(
conversation: Conversation, at: Optional[datetime] = None
) -> datetime:
activity_time = at or _utc_now()
conversation.last_message_at = activity_time
return activity_time
def _voice_session_id_from_client_segment_id(
client_segment_id: Optional[str],
) -> Optional[str]:
@@ -825,15 +835,9 @@ async def process_audio_segment(
else None,
processed=False,
)
db.add(segment)
user_message_timestamp = _mark_conversation_active(conversation)
await db.commit()
await persist_voice_segment_row(db, segment, conversation)
user_message_timestamp = conversation.last_message_at
await db.refresh(segment)
await memoir_ingest_scheduler.queue_segment(
conversation.user_id,
segment.id,
text_char_count=len((transcript_text or "").strip()),
)
ready_segments: List[Tuple[int, str, Segment]] = []
tts_flag_this_voice_session = False
@@ -943,6 +947,8 @@ async def process_user_message(
*,
force_skip_tts: bool = False,
tts_this_turn: Optional[bool] = None,
memoir_trigger: MemoirTrigger = "turn",
schedule_memoir: bool = True,
) -> None:
"""处理用户消息,生成 Agent 回应。由 ChatOrchestrator 路由到 ProfileAgent 或 InterviewAgent。"""
with business_span("conversation.ws.process_turn"):
@@ -956,6 +962,8 @@ async def process_user_message(
user_message_timestamp,
force_skip_tts=force_skip_tts,
tts_this_turn=tts_this_turn,
memoir_trigger=memoir_trigger,
schedule_memoir=schedule_memoir,
)
@@ -970,6 +978,8 @@ async def _process_user_message_inner(
*,
force_skip_tts: bool = False,
tts_this_turn: Optional[bool] = None,
memoir_trigger: MemoirTrigger = "turn",
schedule_memoir: bool = True,
) -> None:
store = ConversationHistoryStore(db)
tts_urls: list[str] = []
@@ -1022,18 +1032,17 @@ async def _process_user_message_inner(
want_tts,
)
segment.agent_response = AI_RESPONSE_SEGMENT_JOIN.join(responses)
_mark_conversation_active(conversation)
turn_ids = await store.record_human_ai_turn(
agent_response = AI_RESPONSE_SEGMENT_JOIN.join(responses)
turn_ids = await store.record_human_ai_turn_with_segment(
conversation_id=conversation_id,
user_message=user_message,
responses=responses,
segment=segment,
user_message_timestamp=user_message_timestamp,
is_from_voice=is_from_voice,
voice_session_id=voice_session_id,
audio_duration_seconds=audio_dur,
tts_audio_urls=None,
segment_id=segment.id,
agent_response=agent_response,
memory_retrieval_trace=turn.memory_retrieval_trace,
)
if not turn_ids:
@@ -1053,23 +1062,22 @@ async def _process_user_message_inner(
"timestamp": datetime.now(timezone.utc).isoformat(),
},
)
owner_id = (user.id if user is not None else None) or conversation.user_id
if schedule_memoir:
await _schedule_memoir_ingest_for_segment(
owner_id,
segment,
trigger=memoir_trigger,
)
return
lineage = DialogueLineage.for_single_turn(
conversation_id=conversation_id,
user_message_id=turn_ids.human_message_id,
assistant_message_id=turn_ids.assistant_message_id,
segment_ids=[str(segment.id)],
)
await db.execute(
update(Segment)
.where(Segment.id == segment.id)
.values(
user_message_id=turn_ids.human_message_id,
lineage_json=lineage.model_dump(mode="json"),
owner_id = (user.id if user is not None else None) or conversation.user_id
if schedule_memoir:
await _schedule_memoir_ingest_for_segment(
owner_id,
segment,
trigger=memoir_trigger,
)
)
await db.commit()
ai_msg_id = turn_ids.assistant_message_id
tts_epoch_start = _tts_epoch_value(conversation_id)
@@ -1156,32 +1164,20 @@ async def _process_user_message_inner(
logger.warning("after-turn topic chips skipped: {}", chip_err)
if tts_urls:
await store.attach_ai_tts_audio_urls(
await store.attach_ai_tts_for_turn(
conversation_id,
tts_audio_urls=tts_urls,
segment_id=segment.id,
segment=segment,
)
await db.execute(
update(Segment)
.where(Segment.id == segment.id)
.values(tts_audio_urls=tts_urls)
)
await db.commit()
except Exception as e:
if tts_urls:
try:
await store.attach_ai_tts_audio_urls(
await store.attach_ai_tts_for_turn(
conversation_id,
tts_audio_urls=tts_urls,
segment_id=segment.id,
segment=segment,
)
await db.execute(
update(Segment)
.where(Segment.id == segment.id)
.values(tts_audio_urls=tts_urls)
)
await db.commit()
except Exception as persist_error:
logger.warning("补写 TTS 元数据失败: {}", persist_error)
logger.exception("处理用户消息失败: {}", e)