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