爆款开头API开发完成文件追加

This commit is contained in:
2026-06-10 15:23:03 +08:00
parent b4a9ca9e9b
commit fa2e298efa
12 changed files with 115 additions and 11 deletions
+2
View File
@@ -15,6 +15,7 @@ from app.api.v1.recharge_packages import router as recharge_packages_router
from app.api.v1.video_engines import router as video_engines_router from app.api.v1.video_engines import router as video_engines_router
from app.api.v1.image_engines import router as image_engines_router from app.api.v1.image_engines import router as image_engines_router
from app.api.v1.generation_ai import router as generation_ai_router from app.api.v1.generation_ai import router as generation_ai_router
from app.api.v1.hot_opening_replicate import router as hot_opening_replicate_router
from app.api.v1.test import router as test_router from app.api.v1.test import router as test_router
from app.api.v1.user_oauth import router as user_oauth_router from app.api.v1.user_oauth import router as user_oauth_router
from app.api.v1.user_oauth_app import router as user_oauth_app_router from app.api.v1.user_oauth_app import router as user_oauth_app_router
@@ -35,6 +36,7 @@ api_router.include_router(recharge_packages_router)
api_router.include_router(video_engines_router) api_router.include_router(video_engines_router)
api_router.include_router(image_engines_router) api_router.include_router(image_engines_router)
api_router.include_router(generation_ai_router) api_router.include_router(generation_ai_router)
api_router.include_router(hot_opening_replicate_router)
api_router.include_router(test_router) api_router.include_router(test_router)
api_router.include_router(user_oauth_router) api_router.include_router(user_oauth_router)
api_router.include_router(user_oauth_app_router) api_router.include_router(user_oauth_app_router)
+7 -1
View File
@@ -87,7 +87,7 @@ class Settings(BaseSettings):
# ChatAPI async generation pipeline settings # ChatAPI async generation pipeline settings
CELERY_BROKER_URL: str = "" CELERY_BROKER_URL: str = ""
CELERY_RESULT_BACKEND: str = "" CELERY_RESULT_BACKEND: str = ""
CHATAPI_REQUEST_TIMEOUT_SECONDS: int = 120 CHATAPI_REQUEST_TIMEOUT_SECONDS: int = 180
CHATAPI_VIDEO_FPS: float = 0.5 CHATAPI_VIDEO_FPS: float = 0.5
CHATAPI_ASYNC_MAX_RETRIES: int = 3 CHATAPI_ASYNC_MAX_RETRIES: int = 3
CHATAPI_ASYNC_RETRY_BACKOFF_SECONDS: int = 30 CHATAPI_ASYNC_RETRY_BACKOFF_SECONDS: int = 30
@@ -128,5 +128,11 @@ class Settings(BaseSettings):
RESOURCE_SIGN_ARG_EXPIRE: str = "exp" RESOURCE_SIGN_ARG_EXPIRE: str = "exp"
RESOURCE_SIGN_ARG_SIGNATURE: str = "sign" RESOURCE_SIGN_ARG_SIGNATURE: str = "sign"
# 爆款开头复刻默认配置。素材校验由前端完成,后端只接收已有上传接口返回的链接。
HOT_OPENING_DEFAULT_VIDEO_DURATION: int = 4
HOT_OPENING_DEFAULT_VIDEO_RATIO: str = "9:16"
HOT_OPENING_DEFAULT_VIDEO_RESOLUTION: str = "480p"
HOT_OPENING_DEFAULT_TARGET_PLATFORM: str = "抖音"
settings = Settings() settings = Settings()
+3
View File
@@ -21,6 +21,8 @@ from app.models.chat_provider_call_log import ChatProviderCallLog
from app.models.generated_resource import GeneratedResource from app.models.generated_resource import GeneratedResource
from app.models.user_resource_month_stat import UserResourceMonthStat from app.models.user_resource_month_stat import UserResourceMonthStat
from app.models.user_resource_total_stat import UserResourceTotalStat from app.models.user_resource_total_stat import UserResourceTotalStat
from app.models.module_generation_project import ModuleGenerationProject
from app.models.module_generation_step import ModuleGenerationStep
from app.models.user_oauth import UserOAuth from app.models.user_oauth import UserOAuth
from app.models.user_oauth_account import UserOAuthAccount from app.models.user_oauth_account import UserOAuthAccount
from app.models.user_oauth_app import UserOAuthApp from app.models.user_oauth_app import UserOAuthApp
@@ -34,5 +36,6 @@ __all__ = [
"MenuConfig", "RechargePackage", "OperationLog", "MenuConfig", "RechargePackage", "OperationLog",
"ChatGenerationTask", "ChatGenerationTaskEvent", "ChatProviderCallLog", "ChatGenerationTask", "ChatGenerationTaskEvent", "ChatProviderCallLog",
"GeneratedResource", "UserResourceMonthStat", "UserResourceTotalStat", "GeneratedResource", "UserResourceMonthStat", "UserResourceTotalStat",
"ModuleGenerationProject", "ModuleGenerationStep",
"UserOAuth", "UserOAuthAccount", "UserOAuthApp", "UserOAuth", "UserOAuthAccount", "UserOAuthApp",
] ]
@@ -1,6 +1,6 @@
from datetime import datetime from datetime import datetime
from sqlalchemy import DateTime, Float, ForeignKey, Index, Integer, String, Text from sqlalchemy import DateTime, Float, ForeignKey, Index, Integer, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin, SoftDeleteMixin from app.models.base import Base, TimestampMixin, SoftDeleteMixin
@@ -18,7 +18,14 @@ class ChatGenerationTask(Base, TimestampMixin, SoftDeleteMixin):
__table_args__ = ( __table_args__ = (
# 防止前端按钮连点/网络重试时同一个 idempotency_key 并发创建多条任务。 # 防止前端按钮连点/网络重试时同一个 idempotency_key 并发创建多条任务。
# nullable unique 兼容不传 idempotency_key 的普通请求。 # nullable unique 兼容不传 idempotency_key 的普通请求。
Index("uq_chat_generation_tasks_user_mode_idempotency", "user_id", "generation_mode", "idempotency_key", unique=True), Index(
"uq_chat_generation_tasks_user_mode_idempotency",
"user_id",
"generation_mode",
"idempotency_key",
unique=True,
postgresql_where=text("deleted_at IS NULL AND idempotency_key IS NOT NULL"),
),
) )
@@ -20,6 +20,7 @@ CHARGE_MEDIA = "media"
OWNER_GENERATION_RECORD = "generation_record" OWNER_GENERATION_RECORD = "generation_record"
OWNER_CHAT_GENERATION_TASK = "chat_generation_task" OWNER_CHAT_GENERATION_TASK = "chat_generation_task"
OWNER_MODULE_GENERATION_STEP = "module_generation_step"
_BIZ_KEY_PATTERN = re.compile( _BIZ_KEY_PATTERN = re.compile(
r"^(?P<owner_type>[^:]+):(?P<owner_id>[^:]+):attempt:(?P<attempt_no>\d+):(?P<charge_kind>[^:]+):(?P<action>charge|refund)$" r"^(?P<owner_type>[^:]+):(?P<owner_id>[^:]+):attempt:(?P<attempt_no>\d+):(?P<charge_kind>[^:]+):(?P<action>charge|refund)$"
@@ -287,6 +288,45 @@ async def charge_chatapi_prompt_usage(
return BillingSummary(record_id=record.id, user_id=record.user_id, items=items) return BillingSummary(record_id=record.id, user_id=record.user_id, items=items)
async def charge_module_prompt_usage(
db: AsyncSession,
*,
user_id: str,
step_id: str,
usage: Mapping[str, Any],
description: str,
attempt_no: int = 1,
) -> BillingSummary:
"""爆款开头复刻模块图片/视频 AI 提词扣文本积分。
文本提词属于已经发生的 LLM 消费:
- 调用成功后按 input_tokens + output_tokens 扣费。
- 不参与后续图片/视频媒体生成失败退款。
- 通过 module_generation_step:{step_id}:attempt:1:text_prompt:charge 幂等。
"""
input_tokens = _safe_int(usage.get("input_tokens"))
output_tokens = _safe_int(usage.get("output_tokens"))
text_credits = await calc_text_credits(db, input_tokens, output_tokens)
biz_key = build_credit_biz_key(
owner_type=OWNER_MODULE_GENERATION_STEP,
owner_id=step_id,
attempt_no=attempt_no,
charge_kind=CHARGE_TEXT_PROMPT,
action="charge",
)
item = await deduct_credits_locked_once(
db,
user_id=user_id,
amount=text_credits,
description=description,
related_id=step_id,
charge_key=CHARGE_TEXT_PROMPT,
biz_key=biz_key,
attempt_no=attempt_no,
)
return BillingSummary(record_id=step_id, user_id=user_id, items=[item])
async def charge_generation_media_by_params( async def charge_generation_media_by_params(
db: AsyncSession, db: AsyncSession,
*, *,
@@ -17,10 +17,13 @@ from app.services.celery_download_recovery_service import (
remove_download_active, remove_download_active,
) )
from app.services.generation_log_service import log_task_event from app.services.generation_log_service import log_task_event
from app.services.generation_module_hook_service import notify_chat_generation_task_finished
from app.services.generation_refund_service import mark_chat_generation_task_failed_and_refund_once from app.services.generation_refund_service import mark_chat_generation_task_failed_and_refund_once
logger = logging.getLogger("video_gen") logger = logging.getLogger("video_gen")
ALLOWED_GENERATION_MODES = {"chatapi_async", "hot_opening_replicate"}
def _now() -> datetime: def _now() -> datetime:
return datetime.now(timezone.utc) return datetime.now(timezone.utc)
@@ -73,7 +76,7 @@ async def recover_one_download_task(
if not task: if not task:
return "skip_missing_task" return "skip_missing_task"
if task.generation_mode != "chatapi_async": if task.generation_mode not in ALLOWED_GENERATION_MODES:
await remove_download_active(task.id) await remove_download_active(task.id)
return "clean_invalid_mode" return "clean_invalid_mode"
if _is_final_task_state(task): if _is_final_task_state(task):
@@ -218,7 +221,7 @@ async def recover_download_tasks_once(db: AsyncSession) -> dict[str, Any]:
select(ChatGenerationTask) select(ChatGenerationTask)
.where( .where(
ChatGenerationTask.deleted_at.is_(None), ChatGenerationTask.deleted_at.is_(None),
ChatGenerationTask.generation_mode == "chatapi_async", ChatGenerationTask.generation_mode.in_(["chatapi_async", "hot_opening_replicate"]),
ChatGenerationTask.status == "generating", ChatGenerationTask.status == "generating",
ChatGenerationTask.remote_result_url.is_not(None), ChatGenerationTask.remote_result_url.is_not(None),
ChatGenerationTask.pipeline_stage.in_( ChatGenerationTask.pipeline_stage.in_(
@@ -263,7 +266,7 @@ async def recover_generation_tasks_once(db: AsyncSession) -> dict[str, Any]:
select(ChatGenerationTask) select(ChatGenerationTask)
.where( .where(
ChatGenerationTask.deleted_at.is_(None), ChatGenerationTask.deleted_at.is_(None),
ChatGenerationTask.generation_mode == "chatapi_async", ChatGenerationTask.generation_mode.in_(["chatapi_async", "hot_opening_replicate"]),
ChatGenerationTask.status == "generating", ChatGenerationTask.status == "generating",
ChatGenerationTask.pipeline_stage.in_( ChatGenerationTask.pipeline_stage.in_(
[ [
@@ -290,6 +293,7 @@ async def recover_generation_tasks_once(db: AsyncSession) -> dict[str, Any]:
error_message="任务超时", error_message="任务超时",
pipeline_stage="timeout", pipeline_stage="timeout",
) )
await notify_chat_generation_task_finished(db, task)
await db.commit() await db.commit()
await log_task_event( await log_task_event(
task, task,
@@ -180,7 +180,7 @@ async def mark_chat_generation_task_failed_and_refund_once(
select(ChatGenerationTask) select(ChatGenerationTask)
.where( .where(
ChatGenerationTask.id == task_id, ChatGenerationTask.id == task_id,
ChatGenerationTask.generation_mode == "chatapi_async", ChatGenerationTask.generation_mode.in_(["chatapi_async", "hot_opening_replicate"]),
ChatGenerationTask.deleted_at.is_(None), ChatGenerationTask.deleted_at.is_(None),
) )
.with_for_update() .with_for_update()
+1
View File
@@ -10,6 +10,7 @@ try:
generation_poll_tasks, generation_poll_tasks,
generation_download_tasks, generation_download_tasks,
generation_recovery_tasks, generation_recovery_tasks,
hot_opening_replicate_tasks
) )
except Exception: except Exception:
pass pass
+2
View File
@@ -49,6 +49,8 @@ if broker_url:
"generation.chatapi_create_generation_task": {"queue": "gen_chatapi_create"}, "generation.chatapi_create_generation_task": {"queue": "gen_chatapi_create"},
"generation.poll_generation_task": {"queue": "gen_provider_poll"}, "generation.poll_generation_task": {"queue": "gen_provider_poll"},
"generation.download_generation_result_task": {"queue": "gen_result_download"}, "generation.download_generation_result_task": {"queue": "gen_result_download"},
"hot_opening.start_image_prompt_optimize": {"queue": "gen_chatapi_create"},
"hot_opening.start_video_prompt_optimize": {"queue": "gen_chatapi_create"},
"generation.recover_download_tasks_once": {"queue": "gen_result_download"}, "generation.recover_download_tasks_once": {"queue": "gen_result_download"},
"generation.recover_generation_tasks_once": {"queue": "gen_result_download"}, "generation.recover_generation_tasks_once": {"queue": "gen_result_download"},
"app.tasks.cleanup.*": {"queue": "default"}, "app.tasks.cleanup.*": {"queue": "default"},
@@ -13,6 +13,8 @@ from app.services.generation_refund_service import mark_chat_generation_task_fai
from app.services.generation_provider_service import create_provider_task from app.services.generation_provider_service import create_provider_task
from app.tasks.celery_app import celery_app from app.tasks.celery_app import celery_app
ALLOWED_GENERATION_MODES = {"chatapi_async", "hot_opening_replicate"}
def _get_first_value(obj: Any, *field_names: str) -> Optional[Any]: def _get_first_value(obj: Any, *field_names: str) -> Optional[Any]:
""" """
@@ -63,6 +65,14 @@ def _build_optimized_prompt_by_params(task: ChatGenerationTask) -> str:
base_prompt = original_prompt.rstrip(",。;; \n\t") base_prompt = original_prompt.rstrip(",。;; \n\t")
gen_type = (_to_clean_str(getattr(task, "gen_type", None)) or "").lower() gen_type = (_to_clean_str(getattr(task, "gen_type", None)) or "").lower()
generation_mode = _to_clean_str(getattr(task, "generation_mode", None)) or ""
# 爆款开头复刻第5步的视频生成,original_prompt 已经是视频提词 JSON schema。
# 不能再追加“时长/比例/分辨率”中文参数,否则会污染 schema。
if generation_mode == "hot_opening_replicate" and gen_type == "video":
stripped = base_prompt.strip()
if stripped.startswith("{") or stripped.startswith("["):
return base_prompt
duration = _get_first_value(task, "duration") duration = _get_first_value(task, "duration")
aspect_ratio = _get_first_value(task, "aspect_ratio") aspect_ratio = _get_first_value(task, "aspect_ratio")
@@ -113,7 +123,7 @@ async def _run(task_id: str):
).with_for_update().limit(1)) ).with_for_update().limit(1))
task = result.scalar_one_or_none() task = result.scalar_one_or_none()
if not task or task.generation_mode != "chatapi_async": if not task or task.generation_mode not in ALLOWED_GENERATION_MODES:
return return
if task.status != "generating": if task.status != "generating":
@@ -128,6 +138,9 @@ async def _run(task_id: str):
) )
await db.commit() await db.commit()
await log_task_event(task, event_type="TASK_TIMEOUT", to_status="failed", to_stage="timeout") await log_task_event(task, event_type="TASK_TIMEOUT", to_status="failed", to_stage="timeout")
from app.services.generation_module_hook_service import notify_chat_generation_task_finished
await notify_chat_generation_task_finished(db, task)
await db.commit()
return return
if task.pipeline_stage not in ("queued", "preparing", "creating_provider_task"): if task.pipeline_stage not in ("queued", "preparing", "creating_provider_task"):
@@ -253,6 +266,9 @@ async def _run(task_id: str):
) )
await db.commit() await db.commit()
await log_task_event(task, event_type="TASK_FAILED", message=task.error_message) await log_task_event(task, event_type="TASK_FAILED", message=task.error_message)
from app.services.generation_module_hook_service import notify_chat_generation_task_finished
await notify_chat_generation_task_finished(db, task)
await db.commit()
if celery_app: if celery_app:
@@ -21,6 +21,8 @@ from app.services.generation_refund_service import mark_chat_generation_task_fai
from app.services.resource_accounting_service import record_chat_task_generated_resource from app.services.resource_accounting_service import record_chat_task_generated_resource
from app.tasks.celery_app import celery_app from app.tasks.celery_app import celery_app
ALLOWED_GENERATION_MODES = {"chatapi_async", "hot_opening_replicate"}
DOWNLOAD_QUEUE = "gen_result_download" DOWNLOAD_QUEUE = "gen_result_download"
DOWNLOAD_STAGE_QUEUED = "download_queued" DOWNLOAD_STAGE_QUEUED = "download_queued"
DOWNLOAD_STAGE_DOWNLOADING = "downloading" DOWNLOAD_STAGE_DOWNLOADING = "downloading"
@@ -108,7 +110,7 @@ async def enqueue_download_task(
countdown: int | None = None, countdown: int | None = None,
) -> str | None: ) -> str | None:
"""统一投递图片/视频下载任务,并同步 DB + Redis active 注册表。""" """统一投递图片/视频下载任务,并同步 DB + Redis active 注册表。"""
if not task or task.generation_mode != "chatapi_async": if not task or task.generation_mode not in ALLOWED_GENERATION_MODES:
return None return None
if task.status != "generating": if task.status != "generating":
return None return None
@@ -159,7 +161,7 @@ async def _reload_task(db: AsyncSession, task_id: str) -> ChatGenerationTask | N
async def _claim_download_lease(db: AsyncSession, task: ChatGenerationTask) -> bool: async def _claim_download_lease(db: AsyncSession, task: ChatGenerationTask) -> bool:
now = _now() now = _now()
if not task or task.generation_mode != "chatapi_async": if not task or task.generation_mode not in ALLOWED_GENERATION_MODES:
return False return False
if task.status != "generating": if task.status != "generating":
return False return False
@@ -310,6 +312,10 @@ async def _run(task_id: str):
await db.commit() await db.commit()
await remove_download_active(task.id) await remove_download_active(task.id)
from app.services.generation_module_hook_service import notify_chat_generation_task_finished
await notify_chat_generation_task_finished(db, task)
await db.commit()
await log_task_event( await log_task_event(
task, task,
event_type="DOWNLOAD_SUCCESS", event_type="DOWNLOAD_SUCCESS",
@@ -348,6 +354,10 @@ async def _run(task_id: str):
await remove_download_active(task.id) await remove_download_active(task.id)
from app.services.generation_module_hook_service import notify_chat_generation_task_finished
await notify_chat_generation_task_finished(db, task)
await db.commit()
await log_task_event( await log_task_event(
task, task,
event_type="DOWNLOAD_FAILED", event_type="DOWNLOAD_FAILED",
@@ -13,6 +13,8 @@ from app.services.generation_refund_service import mark_chat_generation_task_fai
from app.services.generation_provider_service import poll_provider_task from app.services.generation_provider_service import poll_provider_task
from app.tasks.celery_app import celery_app from app.tasks.celery_app import celery_app
ALLOWED_GENERATION_MODES = {"chatapi_async", "hot_opening_replicate"}
def _is_success(status: str) -> bool: def _is_success(status: str) -> bool:
return status in ("succeeded", "success", "completed", "done") return status in ("succeeded", "success", "completed", "done")
@@ -29,6 +31,12 @@ def _engine_snapshot(task: ChatGenerationTask) -> dict:
return {} return {}
async def _notify_finished(db, task: ChatGenerationTask) -> None:
from app.services.generation_module_hook_service import notify_chat_generation_task_finished
await notify_chat_generation_task_finished(db, task)
async def _reload_task(db, task_id: str) -> ChatGenerationTask | None: async def _reload_task(db, task_id: str) -> ChatGenerationTask | None:
""" """
rollback 后重新查询任务对象。 rollback 后重新查询任务对象。
@@ -54,7 +62,7 @@ async def _run(task_id: str):
ChatGenerationTask.deleted_at.is_(None), ChatGenerationTask.deleted_at.is_(None),
).with_for_update().limit(1)) ).with_for_update().limit(1))
task = result.scalar_one_or_none() task = result.scalar_one_or_none()
if not task or task.generation_mode != "chatapi_async": if not task or task.generation_mode not in ALLOWED_GENERATION_MODES:
return return
# 只处理正在生成,且处于远程等待/轮询中的任务。 # 只处理正在生成,且处于远程等待/轮询中的任务。
@@ -68,6 +76,7 @@ async def _run(task_id: str):
error_message="任务轮询超时", error_message="任务轮询超时",
pipeline_stage="timeout", pipeline_stage="timeout",
) )
await _notify_finished(db, task)
await db.commit() await db.commit()
await log_task_event(task, event_type="TASK_TIMEOUT", to_status="failed", to_stage="timeout") await log_task_event(task, event_type="TASK_TIMEOUT", to_status="failed", to_stage="timeout")
return return
@@ -79,6 +88,7 @@ async def _run(task_id: str):
error_message="缺少外部任务ID", error_message="缺少外部任务ID",
pipeline_stage="failed", pipeline_stage="failed",
) )
await _notify_finished(db, task)
await db.commit() await db.commit()
await log_task_event(task, event_type="POLL_FAILED", message=task.error_message) await log_task_event(task, event_type="POLL_FAILED", message=task.error_message)
return return
@@ -130,6 +140,7 @@ async def _run(task_id: str):
error_message="供应商任务成功但未返回结果URL", error_message="供应商任务成功但未返回结果URL",
pipeline_stage="failed", pipeline_stage="failed",
) )
await _notify_finished(db, task)
await db.commit() await db.commit()
await log_task_event(task, event_type="POLL_FAILED", message=task.error_message) await log_task_event(task, event_type="POLL_FAILED", message=task.error_message)
return return
@@ -153,6 +164,7 @@ async def _run(task_id: str):
error_message=poll_result.get("error") or f"供应商任务失败: {status}", error_message=poll_result.get("error") or f"供应商任务失败: {status}",
pipeline_stage="failed", pipeline_stage="failed",
) )
await _notify_finished(db, task)
await db.commit() await db.commit()
await log_task_event(task, event_type="POLL_FAILED", message=task.error_message, detail=poll_result) await log_task_event(task, event_type="POLL_FAILED", message=task.error_message, detail=poll_result)
return return
@@ -194,6 +206,7 @@ async def _run(task_id: str):
error_message=error_message, error_message=error_message,
pipeline_stage="failed", pipeline_stage="failed",
) )
await _notify_finished(db, task)
await db.commit() await db.commit()
await log_task_event(task, event_type="POLL_FAILED", message=task.error_message) await log_task_event(task, event_type="POLL_FAILED", message=task.error_message)
else: else: