Files
life-echo/api/app/tasks/chapter_cover_enqueue.py

134 lines
4.1 KiB
Python
Raw Normal View History

2026-03-20 15:15:35 +08:00
"""
章节封面 Celery 任务入队闸门DB 二次校验 + Redis 短时去重避免重复 delay 同一 chapter
"""
from __future__ import annotations
from typing import Literal
import redis
from sqlalchemy import select
from sqlalchemy.orm import joinedload
from app.core.config import settings
from app.core.db import get_sync_db
from app.core.logging import get_logger
from app.features.memoir.asset_resolver import strip_legacy_image_placeholders
from app.features.memoir.cover_eligibility import (
chapter_eligible_for_cover_by_inline_body_image_count,
chapter_needs_cover_enqueue,
primary_chapter_memoir_image,
)
2026-03-20 15:15:35 +08:00
from app.features.memoir.models import Chapter
logger = get_logger(__name__)
CHAPTER_COVER_ENQUEUE_DEDUP_TTL_SECONDS = 450
_ENQUEUE_KEY_PREFIX = "enqueue:chapter-cover:"
def _enqueue_dedup_key(chapter_id: str) -> str:
return f"{_ENQUEUE_KEY_PREFIX}{chapter_id}"
def _chapter_eligible_for_http_enqueue(chapter: Chapter | None) -> bool:
"""与 MemoirService.check_and_trigger_cover_generation 循环条件一致。"""
if not chapter:
return False
if not chapter.category or chapter.status == "empty":
return False
if getattr(chapter, "cover_asset_id", None):
return False
md = (chapter.canonical_markdown or "").strip()
body = md or ""
if not body.strip():
return False
body = strip_legacy_image_placeholders(body).strip()
if not body:
return False
if not chapter_eligible_for_cover_by_inline_body_image_count(chapter):
return False
2026-03-20 15:15:35 +08:00
cover_rec = primary_chapter_memoir_image(chapter)
if cover_rec and (cover_rec.status or "").strip() == "completed":
return False
return True
def _chapter_eligible_for_pipeline_enqueue(chapter: Chapter | None) -> bool:
"""尚无 cover_asset、正文插图数 > 3与 HTTP 闸门共用 chapter_needs_cover_enqueue 核心)。"""
return bool(chapter_needs_cover_enqueue(chapter))
2026-03-20 15:15:35 +08:00
def _load_chapter_for_enqueue_sync(chapter_id: str) -> Chapter | None:
with get_sync_db() as db:
stmt = (
select(Chapter)
.where(Chapter.id == chapter_id)
.options(
joinedload(Chapter.images),
)
)
return db.execute(stmt).unique().scalar_one_or_none()
def try_enqueue_generate_chapter_cover(
chapter_id: str,
source: Literal["http", "pipeline"] = "pipeline",
) -> bool:
"""
若章节仍需要生成封面 SET NX 去重后派发 generate_chapter_cover
Returns:
True 当且仅当成功调用 delay通过闸门
"""
chapter = _load_chapter_for_enqueue_sync(chapter_id)
if source == "http":
eligible = _chapter_eligible_for_http_enqueue(chapter)
else:
eligible = _chapter_eligible_for_pipeline_enqueue(chapter)
if not eligible:
return False
key = _enqueue_dedup_key(chapter_id)
try:
client = redis.from_url(settings.redis_url, decode_responses=True)
if not client.set(
key, "1", nx=True, ex=CHAPTER_COVER_ENQUEUE_DEDUP_TTL_SECONDS
):
logger.debug(
"chapter_cover enqueue skipped (dedup): chapter=%s source=%s",
chapter_id,
source,
)
return False
except Exception as exc:
logger.warning(
"chapter_cover enqueue dedup redis failed, allowing enqueue: chapter=%s error=%s",
chapter_id,
exc,
)
from app.tasks.chapter_cover_tasks import generate_chapter_cover
try:
generate_chapter_cover.delay(chapter_id)
except Exception as exc:
logger.warning(
"chapter_cover delay failed: chapter=%s error=%s",
chapter_id,
exc,
)
try:
client = redis.from_url(settings.redis_url, decode_responses=True)
client.delete(key)
except Exception:
pass
return False
logger.info(
"chapter_cover enqueued: chapter=%s source=%s",
chapter_id,
source,
)
return True