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

@@ -15,7 +15,7 @@ from PIL import Image
from sqlalchemy import and_, func, or_, select, update
from app.agents.image_prompt import get_image_prompt_orchestrator
from app.core.db import get_sync_db
from app.core.db import get_sync_db, transactional_sync
from app.core.dependencies import get_image_generator
from app.core.logging import get_logger
from app.core.memoir_pipeline_progress import merge_fanout_item
@@ -34,21 +34,25 @@ STORY_IMAGE_LOCK_TTL_SECONDS = 1800
STORY_IMAGE_CLAIM_TTL_SECONDS = 1800
class _ClaimSkipped(Exception):
"""Concurrent worker won the intent claim; abort transactional block."""
def _enqueue_chapter_effects_after_image_backfill(story_id: str) -> None:
"""主图回填后标记关联章节 dirty并经统一 post-commit 入口派发章节物化与 compaction。"""
try:
with get_sync_db() as session:
from app.features.memoir import repo as memoir_repo
story = session.get(Story, story_id)
if not story:
return
uid = str(story.user_id)
memoir_repo.mark_chapters_dirty_for_story_sync(session, story_id)
chapter_ids = memoir_repo.get_chapter_ids_linked_to_story_sync(
session, story_id
)
session.commit()
with transactional_sync(session):
story = session.get(Story, story_id)
if not story:
return
uid = str(story.user_id)
memoir_repo.mark_chapters_dirty_for_story_sync(session, story_id)
chapter_ids = memoir_repo.get_chapter_ids_linked_to_story_sync(
session, story_id
)
user_id = uid
except Exception as exc:
logger.warning(
@@ -120,37 +124,40 @@ def _claim_story_image_intent_sync(db, story_id: str, claim_token: str):
if not candidate_id:
return None
claimed = db.execute(
update(StoryImageIntent)
.where(StoryImageIntent.id == candidate_id)
.where(_story_image_claimable_clause(now))
.values(
status="processing",
claim_token=claim_token,
claimed_at=now,
updated_at=now,
error=None,
attempt_count=func.coalesce(StoryImageIntent.attempt_count, 0) + 1,
)
)
if (claimed.rowcount or 0) != 1:
db.rollback()
return None
try:
with transactional_sync(db):
claimed = db.execute(
update(StoryImageIntent)
.where(StoryImageIntent.id == candidate_id)
.where(_story_image_claimable_clause(now))
.values(
status="processing",
claim_token=claim_token,
claimed_at=now,
updated_at=now,
error=None,
attempt_count=func.coalesce(StoryImageIntent.attempt_count, 0)
+ 1,
)
)
if (claimed.rowcount or 0) != 1:
raise _ClaimSkipped()
row = (
db.execute(
select(StoryImageIntent, Story)
.join(Story, StoryImageIntent.story_id == Story.id)
.where(StoryImageIntent.id == candidate_id)
)
.unique()
.first()
)
db.commit()
row = (
db.execute(
select(StoryImageIntent, Story)
.join(Story, StoryImageIntent.story_id == Story.id)
.where(StoryImageIntent.id == candidate_id)
)
.unique()
.first()
)
except _ClaimSkipped:
return None
return row
@shared_task(bind=True, max_retries=3, default_retry_delay=30)
@shared_task(bind=True, max_retries=3, default_retry_delay=30, ignore_result=True)
def generate_story_image(self, story_id: str, memoir_correlation_id: str | None = None):
"""
为 story 生成主插图。
@@ -208,14 +215,14 @@ def generate_story_image(self, story_id: str, memoir_correlation_id: str | None
).strip()
if len(plain) < min_body:
with get_sync_db() as db:
intent_db = db.get(StoryImageIntent, intent.id)
if intent_db and (intent_db.status or "").strip() == "processing":
intent_db.status = "skipped"
intent_db.error = f"body_below_min_chars:{len(plain)}"
intent_db.claim_token = None
intent_db.claimed_at = None
intent_db.updated_at = datetime.now(timezone.utc)
db.commit()
with transactional_sync(db):
intent_db = db.get(StoryImageIntent, intent.id)
if intent_db and (intent_db.status or "").strip() == "processing":
intent_db.status = "skipped"
intent_db.error = f"body_below_min_chars:{len(plain)}"
intent_db.claim_token = None
intent_db.claimed_at = None
intent_db.updated_at = datetime.now(timezone.utc)
logger.info(
"generate_story_image: skipped body too short story={} len={} min={}",
story_id,
@@ -299,96 +306,93 @@ def generate_story_image(self, story_id: str, memoir_correlation_id: str | None
)
return {"status": "superseded_or_cancelled"}
asset = Asset(
id=asset_id,
asset_type="story_image",
storage_key=cos_key,
url=url,
provider=settings.provider,
style_profile=style_for_image,
prompt_final=prompt_final,
status="completed",
)
db.add(asset)
db.flush()
story_db = db.get(Story, story_id)
target_vid = intent_db.story_version_id or story_db.current_version_id
current_vid = story_db.current_version_id
intent_db.asset_id = asset_id
intent_db.status = "completed"
intent_db.claim_token = None
intent_db.claimed_at = None
intent_db.error = None
intent_db.updated_at = datetime.now(timezone.utc)
db.flush()
# 仅当 intent 仍指向当前版本时回填正文,避免慢任务/重试把图插到新版本上
if not target_vid or target_vid != current_vid:
db.commit()
logger.debug(
"generate_story_image: stale intent skip backfill story={} "
"intent_ver={} current={} url={} asset={}",
story_id,
target_vid,
current_vid,
url,
asset_id,
with transactional_sync(db):
asset = Asset(
id=asset_id,
asset_type="story_image",
storage_key=cos_key,
url=url,
provider=settings.provider,
style_profile=style_for_image,
prompt_final=prompt_final,
status="completed",
)
merge_fanout_item(
memoir_correlation_id,
list_name="story_images",
id_field="story_id",
item_id=story_id,
task_id=celery_tid,
status="success_stale",
db.add(asset)
db.flush()
story_db = db.get(Story, story_id)
target_vid = intent_db.story_version_id or story_db.current_version_id
current_vid = story_db.current_version_id
intent_db.asset_id = asset_id
intent_db.status = "completed"
intent_db.claim_token = None
intent_db.claimed_at = None
intent_db.error = None
intent_db.updated_at = datetime.now(timezone.utc)
db.flush()
# 仅当 intent 仍指向当前版本时回填正文,避免慢任务/重试把图插到新版本上
if not target_vid or target_vid != current_vid:
logger.debug(
"generate_story_image: stale intent skip backfill story={} "
"intent_ver={} current={} url={} asset={}",
story_id,
target_vid,
current_vid,
url,
asset_id,
)
merge_fanout_item(
memoir_correlation_id,
list_name="story_images",
id_field="story_id",
item_id=story_id,
task_id=celery_tid,
status="success_stale",
)
return {"status": "success_stale", "asset_id": asset_id}
ver = db.get(StoryVersion, target_vid)
if not ver:
merge_fanout_item(
memoir_correlation_id,
list_name="story_images",
id_field="story_id",
item_id=story_id,
task_id=celery_tid,
status="success_no_snapshot",
)
return {"status": "success_no_snapshot", "asset_id": asset_id}
base_md = strip_asset_image_refs_from_markdown(ver.markdown_snapshot or "")
alt_text = (getattr(intent_db, "prompt_brief", None) or "").strip()
if not alt_text:
alt_text = (getattr(intent_db, "caption", None) or "").strip()
backfilled_md = backfill_image_into_markdown(
base_md,
asset_id=asset_id,
image_alt=alt_text or "主插图",
)
return {"status": "success_stale", "asset_id": asset_id}
ver = db.get(StoryVersion, target_vid)
if not ver:
db.commit()
merge_fanout_item(
memoir_correlation_id,
list_name="story_images",
id_field="story_id",
item_id=story_id,
task_id=celery_tid,
status="success_no_snapshot",
max_stmt = select(func.max(StoryVersion.version_no)).where(
StoryVersion.story_id == story_id
)
return {"status": "success_no_snapshot", "asset_id": asset_id}
base_md = strip_asset_image_refs_from_markdown(ver.markdown_snapshot or "")
alt_text = (getattr(intent_db, "prompt_brief", None) or "").strip()
if not alt_text:
alt_text = (getattr(intent_db, "caption", None) or "").strip()
backfilled_md = backfill_image_into_markdown(
base_md,
asset_id=asset_id,
image_alt=alt_text or "主插图",
)
max_stmt = select(func.max(StoryVersion.version_no)).where(
StoryVersion.story_id == story_id
)
max_no = db.execute(max_stmt).scalar()
version_no = (max_no or 0) + 1
new_ver = StoryVersion(
id=str(uuid.uuid4()),
story_id=story_id,
version_no=version_no,
markdown_snapshot=backfilled_md,
change_summary="主插图回填",
actor_type="system",
source_type="image_backfill",
parent_version_id=story_db.current_version_id,
)
db.add(new_ver)
db.flush()
story_db.current_version_id = new_ver.id
story_db.canonical_markdown = backfilled_md
db.commit()
max_no = db.execute(max_stmt).scalar()
version_no = (max_no or 0) + 1
new_ver = StoryVersion(
id=str(uuid.uuid4()),
story_id=story_id,
version_no=version_no,
markdown_snapshot=backfilled_md,
change_summary="主插图回填",
actor_type="system",
source_type="image_backfill",
parent_version_id=story_db.current_version_id,
)
db.add(new_ver)
db.flush()
story_db.current_version_id = new_ver.id
story_db.canonical_markdown = backfilled_md
_enqueue_chapter_effects_after_image_backfill(story_id)
@@ -420,18 +424,18 @@ def generate_story_image(self, story_id: str, memoir_correlation_id: str | None
except Exception as exc:
if intent is not None:
with get_sync_db() as db:
intent_db = db.get(StoryImageIntent, intent.id)
if (
intent_db
and (intent_db.status or "").strip() != "completed"
and (intent_db.claim_token or "").strip() == claim_token
):
intent_db.status = "failed"
intent_db.claim_token = None
intent_db.claimed_at = None
intent_db.error = str(exc)
intent_db.updated_at = datetime.now(timezone.utc)
db.commit()
with transactional_sync(db):
intent_db = db.get(StoryImageIntent, intent.id)
if (
intent_db
and (intent_db.status or "").strip() != "completed"
and (intent_db.claim_token or "").strip() == claim_token
):
intent_db.status = "failed"
intent_db.claim_token = None
intent_db.claimed_at = None
intent_db.error = str(exc)
intent_db.updated_at = datetime.now(timezone.utc)
merge_fanout_item(
memoir_correlation_id,
list_name="story_images",