feat: 优化图片资源删除管理,生图任务列表管理

This commit is contained in:
2026-08-30 14:50:33 +08:00
parent 4ee667142b
commit 957289f717
46 changed files with 4180 additions and 524 deletions
+470
View File
@@ -0,0 +1,470 @@
"""数据库驱动的后台任务仓库与调度状态机。
已承载图片生成与批量水印;表结构刻意通用,后续可复用到 ZIP 导出等耗时操作。
Worker 不依赖 FastAPI 进程;第三方 provider task id 保存在子项中,重启后继续轮询。
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any
from sqlalchemy import case, func, or_, select, update
from db import get_session_factory
from models import BackgroundJob, BackgroundJobItem
JOB_QUEUED = "queued"
JOB_RUNNING = "running"
JOB_DONE = "done"
JOB_PARTIAL = "partial"
JOB_FAILED = "failed"
JOB_CANCELLED = "cancelled"
ITEM_QUEUED = "queued"
ITEM_RUNNING = "running"
ITEM_WAITING_PROVIDER = "waiting_provider"
ITEM_DONE = "done"
ITEM_FAILED = "failed"
ITEM_CANCELLED = "cancelled"
TERMINAL_ITEM_STATUSES = (ITEM_DONE, ITEM_FAILED, ITEM_CANCELLED)
TERMINAL_JOB_STATUSES = (JOB_DONE, JOB_PARTIAL, JOB_FAILED, JOB_CANCELLED)
def utcnow() -> datetime:
return datetime.now(timezone.utc)
@dataclass(frozen=True)
class ClaimedItem:
job_id: str
item_id: str
job_kind: str
product_id: str | None
provider: str
model: str | None
platform: str
lang: str
ratio: str
style_set: int
request_payload: dict[str, Any]
item_payload: dict[str, Any]
provider_task_id: str | None
provider_task_kind: str | None
async def create_image_job(
*,
product_id: str | None,
kind: str,
priority: int,
provider: str,
model: str | None,
platform: str,
lang: str,
ratio: str,
style_set: int,
request_payload: dict[str, Any],
item_payloads: list[dict[str, Any]],
) -> BackgroundJob:
"""一次事务写入父任务和逐图子项,API 成功返回即意味着任务可由 Worker 恢复。"""
parsed_product_id = uuid.UUID(product_id) if product_id else None
job = BackgroundJob(
product_id=parsed_product_id,
kind=kind,
status=JOB_QUEUED,
priority=priority,
provider=provider,
model=model,
platform=platform,
lang=lang,
ratio=ratio,
style_set=style_set,
request_payload=request_payload,
total=len(item_payloads),
)
async with get_session_factory()() as db:
db.add(job)
await db.flush()
db.add_all([
BackgroundJobItem(job_id=job.id, position=index, payload=payload, status=ITEM_QUEUED)
for index, payload in enumerate(item_payloads)
])
await db.commit()
await db.refresh(job)
return job
async def _refresh_job_status(db, job: BackgroundJob) -> None:
"""从子项计算父任务进度与终态;调用方已在同一事务中修改子项。"""
rows = (await db.execute(
select(BackgroundJobItem.status, func.count(BackgroundJobItem.id))
.where(BackgroundJobItem.job_id == job.id)
.group_by(BackgroundJobItem.status)
)).all()
counts = {status: int(count) for status, count in rows}
done = counts.get(ITEM_DONE, 0)
failed = counts.get(ITEM_FAILED, 0)
cancelled = counts.get(ITEM_CANCELLED, 0)
terminal = done + failed + cancelled
job.completed = done
job.failed = failed
if terminal < job.total:
job.status = JOB_RUNNING
return
job.completed_at = utcnow()
if done == 0 and cancelled == job.total:
job.status = JOB_CANCELLED
elif failed == 0 and cancelled == 0:
job.status = JOB_DONE
elif done > 0:
job.status = JOB_PARTIAL
elif job.cancel_requested and cancelled > 0:
job.status = JOB_CANCELLED
else:
job.status = JOB_FAILED
errors = (await db.scalars(
select(BackgroundJobItem.error)
.where(BackgroundJobItem.job_id == job.id, BackgroundJobItem.status == ITEM_FAILED)
.order_by(BackgroundJobItem.position)
)).all()
if errors:
brief = [str(error)[:200] for error in errors if error]
job.error = ";".join(brief[:6]) or "任务失败"
async def recover_expired_leases() -> int:
"""把上个 Worker 遗留的工作单元重新放回队列;provider id 不会被清空。"""
now = utcnow()
async with get_session_factory()() as db:
result = await db.execute(
update(BackgroundJobItem)
.where(
BackgroundJobItem.status == ITEM_RUNNING,
BackgroundJobItem.lease_expires_at.is_not(None),
BackgroundJobItem.lease_expires_at < now,
)
.values(status=ITEM_QUEUED, lease_owner=None, lease_expires_at=None, next_run_at=now)
)
await db.commit()
return int(result.rowcount or 0)
async def claim_next_item(owner: str, lease_seconds: int = 90) -> ClaimedItem | None:
"""领取一个到期子项。单机 SQLite Worker 使用租约,未来迁移 PostgreSQL 后可直接强化为 SKIP LOCKED。"""
now = utcnow()
async with get_session_factory()() as db:
# 先回收过期租约;即便 Worker 非正常退出,也不会永久卡住。
await db.execute(
update(BackgroundJobItem)
.where(
BackgroundJobItem.status == ITEM_RUNNING,
BackgroundJobItem.lease_expires_at.is_not(None),
BackgroundJobItem.lease_expires_at < now,
)
.values(status=ITEM_QUEUED, lease_owner=None, lease_expires_at=None, next_run_at=now)
)
query = (
select(BackgroundJobItem, BackgroundJob)
.join(BackgroundJob, BackgroundJob.id == BackgroundJobItem.job_id)
.where(
BackgroundJob.cancel_requested.is_(False),
BackgroundJob.status.not_in(TERMINAL_JOB_STATUSES),
BackgroundJobItem.status.in_((ITEM_QUEUED, ITEM_WAITING_PROVIDER)),
or_(BackgroundJobItem.next_run_at.is_(None), BackgroundJobItem.next_run_at <= now),
)
.order_by(BackgroundJob.priority.desc(), BackgroundJob.created_at.asc(), BackgroundJobItem.position.asc())
.limit(1)
)
row = (await db.execute(query)).first()
if row is None:
await db.commit()
return None
item, job = row
item.status = ITEM_RUNNING
if not item.provider_task_id:
item.attempts += 1
item.lease_owner = owner
item.lease_expires_at = now + timedelta(seconds=lease_seconds)
item.next_run_at = None
item.started_at = item.started_at or now
job.status = JOB_RUNNING
job.started_at = job.started_at or now
await db.commit()
return ClaimedItem(
job_id=str(job.id), item_id=str(item.id), job_kind=job.kind,
product_id=str(job.product_id) if job.product_id else None,
provider=job.provider, model=job.model, platform=job.platform,
lang=job.lang, ratio=job.ratio, style_set=job.style_set,
request_payload=dict(job.request_payload or {}), item_payload=dict(item.payload or {}),
provider_task_id=item.provider_task_id, provider_task_kind=item.provider_task_kind,
)
async def mark_waiting_provider(item_id: str, provider_task_id: str, provider_task_kind: str, delay_seconds: int = 3) -> None:
"""先持久化外部任务 ID,再安排下一次查询,是避免重启重复扣费的关键提交点。"""
async with get_session_factory()() as db:
item = await db.get(BackgroundJobItem, uuid.UUID(item_id))
if item is None:
return
item.provider_task_id = provider_task_id
item.provider_task_kind = provider_task_kind
item.status = ITEM_WAITING_PROVIDER
item.lease_owner = None
item.lease_expires_at = None
item.next_run_at = utcnow() + timedelta(seconds=delay_seconds)
await db.commit()
async def mark_item_done(
item_id: str,
*,
result_url: str,
source_url: str | None,
watermark_config: dict | None,
generated_asset_id: str | None,
) -> None:
async with get_session_factory()() as db:
item = await db.get(BackgroundJobItem, uuid.UUID(item_id))
if item is None:
return
job = await db.get(BackgroundJob, item.job_id)
item.status = ITEM_DONE
item.result_url = result_url
item.source_url = source_url
item.watermark_config = watermark_config
item.generated_asset_id = uuid.UUID(generated_asset_id) if generated_asset_id else None
item.error = None
item.lease_owner = None
item.lease_expires_at = None
item.completed_at = utcnow()
if job is not None:
await _refresh_job_status(db, job)
await db.commit()
async def mark_item_failed(item_id: str, error: str) -> None:
async with get_session_factory()() as db:
item = await db.get(BackgroundJobItem, uuid.UUID(item_id))
if item is None:
return
job = await db.get(BackgroundJob, item.job_id)
item.status = ITEM_FAILED
item.error = error[:500]
item.lease_owner = None
item.lease_expires_at = None
item.completed_at = utcnow()
if job is not None:
await _refresh_job_status(db, job)
await db.commit()
async def job_can_write(job_id: str) -> bool:
"""结果落盘前的最后一道确认:取消或商品被硬删除时禁止回写素材。"""
async with get_session_factory()() as db:
job = await db.get(BackgroundJob, uuid.UUID(job_id))
if job is None or job.cancel_requested:
return False
if job.product_id is None:
return True
# 仅用主键查询避免导入 Product 形成循环;不存在即不允许生成结果复活商品。
from models import Product
return await db.get(Product, job.product_id) is not None
async def mark_item_cancelled(item_id: str) -> None:
async with get_session_factory()() as db:
item = await db.get(BackgroundJobItem, uuid.UUID(item_id))
if item is None:
return
job = await db.get(BackgroundJob, item.job_id)
item.status = ITEM_CANCELLED
item.lease_owner = None
item.lease_expires_at = None
item.completed_at = utcnow()
if job is not None:
await _refresh_job_status(db, job)
await db.commit()
async def cancel_job(job_id: str) -> BackgroundJob | None:
async with get_session_factory()() as db:
job = await db.get(BackgroundJob, uuid.UUID(job_id))
if job is None:
return None
if job.status in TERMINAL_JOB_STATUSES:
return job
job.cancel_requested = True
await db.execute(
update(BackgroundJobItem)
.where(BackgroundJobItem.job_id == job.id, BackgroundJobItem.status.in_((ITEM_QUEUED, ITEM_WAITING_PROVIDER)))
.values(status=ITEM_CANCELLED, completed_at=utcnow(), lease_owner=None, lease_expires_at=None)
)
await _refresh_job_status(db, job)
await db.commit()
await db.refresh(job)
return job
async def cancel_product_jobs(product_id: str) -> int:
"""硬删除商品前取消其未完成任务,Worker 随后只会丢弃结果而不会回写素材。"""
try:
parsed = uuid.UUID(product_id)
except ValueError:
return 0
async with get_session_factory()() as db:
jobs = (await db.scalars(
select(BackgroundJob).where(
BackgroundJob.product_id == parsed,
BackgroundJob.status.not_in(TERMINAL_JOB_STATUSES),
)
)).all()
now = utcnow()
for job in jobs:
job.cancel_requested = True
await db.execute(
update(BackgroundJobItem)
.where(BackgroundJobItem.job_id == job.id, BackgroundJobItem.status.in_((ITEM_QUEUED, ITEM_WAITING_PROVIDER)))
.values(status=ITEM_CANCELLED, completed_at=now, lease_owner=None, lease_expires_at=None)
)
await _refresh_job_status(db, job)
await db.commit()
return len(jobs)
async def retry_failed_items(job_id: str) -> BackgroundJob | None:
"""仅把失败子项重新排队;外部失败任务不可复用,清空 provider id 后重新提交。"""
async with get_session_factory()() as db:
job = await db.get(BackgroundJob, uuid.UUID(job_id))
if job is None:
return None
now = utcnow()
result = await db.execute(
update(BackgroundJobItem)
.where(BackgroundJobItem.job_id == job.id, BackgroundJobItem.status == ITEM_FAILED)
.values(
status=ITEM_QUEUED, error=None, provider_task_id=None, provider_task_kind=None,
result_url=None, source_url=None, watermark_config=None, generated_asset_id=None,
next_run_at=now, lease_owner=None, lease_expires_at=None, completed_at=None,
)
)
if not result.rowcount:
return job
job.cancel_requested = False
job.status = JOB_QUEUED
job.completed_at = None
job.error = None
await _refresh_job_status(db, job)
await db.commit()
await db.refresh(job)
return job
async def retry_failed_item(job_id: str, item_id: str) -> BackgroundJob | None:
"""只重试一个失败子项,供批量水印等任务逐图补偿。"""
try:
parsed_job = uuid.UUID(job_id)
parsed_item = uuid.UUID(item_id)
except ValueError:
return None
async with get_session_factory()() as db:
job = await db.get(BackgroundJob, parsed_job)
item = await db.get(BackgroundJobItem, parsed_item)
if job is None or item is None or item.job_id != job.id:
return None
if item.status != ITEM_FAILED:
return job
# 水印任务等待期间,素材可能被用户再次处理过。逐项重试时改为以
# 最新 current 版本为基线,避免把已经更新的图片误判为永久失败。
if job.kind == "watermark_batch":
raw_asset_id = (item.payload or {}).get("asset_id")
try:
from models import ProductAsset
asset = await db.get(ProductAsset, uuid.UUID(str(raw_asset_id)))
except (TypeError, ValueError):
asset = None
if asset is not None:
payload = dict(item.payload or {})
payload["expected_current_storage_key"] = asset.current_storage_key
payload["expected_current_url"] = asset.stored_url
item.payload = payload
item.status = ITEM_QUEUED
item.error = None
item.provider_task_id = None
item.provider_task_kind = None
item.result_url = None
item.source_url = None
item.watermark_config = None
item.generated_asset_id = None
item.next_run_at = utcnow()
item.lease_owner = None
item.lease_expires_at = None
item.completed_at = None
job.cancel_requested = False
job.status = JOB_QUEUED
job.completed_at = None
job.error = None
await _refresh_job_status(db, job)
await db.commit()
await db.refresh(job)
return job
async def get_job(job_id: str) -> tuple[BackgroundJob, list[BackgroundJobItem]] | None:
try:
parsed = uuid.UUID(job_id)
except ValueError:
return None
async with get_session_factory()() as db:
job = await db.get(BackgroundJob, parsed)
if job is None:
return None
items = (await db.scalars(
select(BackgroundJobItem).where(BackgroundJobItem.job_id == job.id).order_by(BackgroundJobItem.position)
)).all()
return job, items
async def list_product_jobs(
product_id: str,
active_only: bool = False,
limit: int = 20,
kinds: tuple[str, ...] | None = None,
) -> list[BackgroundJob]:
try:
parsed = uuid.UUID(product_id)
except ValueError:
return []
async with get_session_factory()() as db:
query = select(BackgroundJob).where(BackgroundJob.product_id == parsed)
if kinds:
query = query.where(BackgroundJob.kind.in_(kinds))
if active_only:
query = query.where(BackgroundJob.status.not_in(TERMINAL_JOB_STATUSES))
return (await db.scalars(query.order_by(BackgroundJob.created_at.desc()).limit(limit))).all()
def suite_view(job: BackgroundJob, items: list[BackgroundJobItem]) -> dict[str, Any]:
"""兼容现有 /suites/{id} 契约,同时增加任务类型、取消状态与子项状态。"""
return {
"id": str(job.id), "status": job.status, "kind": job.kind,
"style_set": job.style_set, "platform": job.platform, "lang": job.lang,
"ratio": job.ratio, "provider": job.provider, "model": job.model,
"total": job.total, "completed": job.completed, "failed": job.failed,
"cancel_requested": job.cancel_requested, "error": job.error,
"created_at": job.created_at, "started_at": job.started_at, "completed_at": job.completed_at,
"images": [
{
"type_id": (item.payload or {}).get("kind", "custom"),
"item_id": str(item.id),
"name": (item.payload or {}).get("title", "AI 生图"),
"url": item.result_url or "", "status": item.status,
"error": item.error,
}
for item in items
],
}