积分冻结释放

This commit is contained in:
2026-07-24 09:18:05 +08:00
parent 920d884e92
commit 68e902b4a4
38 changed files with 4743 additions and 391 deletions
+31 -1
View File
@@ -2,7 +2,7 @@ from datetime import datetime, timezone, timedelta
import json
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import delete, func, select, update
from sqlalchemy import delete, func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, get_admin_user
@@ -50,6 +50,8 @@ from app.schemas.credit_ratio import CreditRatioCreate, CreditRatioOut
from app.services.credits import add_credits, deduct_credits
from app.services.credit_record_meta_service import build_admin_adjust_meta
from app.services.admin_credit_record_service import list_admin_credit_records
from app.services.system_config_cache import invalidate_system_config_cache
from app.services.llm_billing.config import validate_llm_system_config_value
from app.services.notification import create_notification
from app.services.auth import hash_password, verify_password
from app.services.operation_log import log_operation
@@ -502,6 +504,7 @@ async def list_credit_records(
credit_subject: str | None = Query(None),
media_type: str | None = Query(None),
charge_kind: str | None = Query(None),
charge_action: str | None = Query(None),
source_module: str | None = Query(None),
source_step_code: str | None = Query(None),
billing_scene: str | None = Query(None),
@@ -524,6 +527,7 @@ async def list_credit_records(
credit_subject=credit_subject,
media_type=media_type,
charge_kind=charge_kind,
charge_action=charge_action,
source_module=source_module,
source_step_code=source_step_code,
billing_scene=billing_scene,
@@ -1625,6 +1629,10 @@ async def create_system_config(
db: AsyncSession = Depends(get_db),
):
from app.utils.id_gen import generate_id
try:
await validate_llm_system_config_value(db, key=req.key, value=str(req.value))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
config = SystemConfig(
id=generate_id(),
key=req.key,
@@ -1643,6 +1651,8 @@ async def create_system_config(
detail=json.dumps({"key": req.key, "value": req.value}, ensure_ascii=False),
)
await db.commit()
await invalidate_system_config_cache([req.key])
await db.refresh(config)
return config
@@ -1657,6 +1667,10 @@ async def update_system_config(
config = result.scalar_one_or_none()
if not config:
raise HTTPException(status_code=404, detail="配置不存在")
try:
await validate_llm_system_config_value(db, key=str(config.key), value=str(req.value))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
config.value = str(req.value)
await db.flush()
await log_operation(
@@ -1675,7 +1689,10 @@ async def update_system_config(
ensure_ascii=False,
),
)
updated_key = str(config.key)
await db.commit()
await invalidate_system_config_cache([updated_key])
await db.refresh(config)
return config
@@ -1785,9 +1802,16 @@ async def get_stats(
)
)).scalar() or 0
# 预扣占用不是实际消费;历史流水 charge_action 为空时仍按真实扣费兼容。
real_credit_charge_filter = or_(
CreditRecord.charge_action.is_(None),
CreditRecord.charge_action == "charge",
)
credits_consumed = (await db.execute(
select(func.coalesce(func.sum(func.abs(CreditRecord.amount)), 0)).where(
CreditRecord.type == "consume",
real_credit_charge_filter,
CreditRecord.created_at >= date_start,
CreditRecord.created_at <= date_end,
)
@@ -1858,6 +1882,7 @@ async def get_stats(
last_period_credits_consumed = (await db.execute(
select(func.coalesce(func.sum(func.abs(CreditRecord.amount)), 0)).where(
CreditRecord.type == "consume",
real_credit_charge_filter,
CreditRecord.created_at >= last_period_start,
CreditRecord.created_at <= last_period_end,
)
@@ -1877,6 +1902,7 @@ async def get_stats(
)
.where(
CreditRecord.type == "consume",
real_credit_charge_filter,
CreditRecord.created_at >= _chart_start_dt,
CreditRecord.created_at <= _chart_end_dt,
)
@@ -1903,6 +1929,7 @@ async def get_stats(
)
.where(
CreditRecord.type == "consume",
real_credit_charge_filter,
CreditRecord.created_at >= date_start,
CreditRecord.created_at <= date_end,
)
@@ -1929,6 +1956,7 @@ async def get_stats(
)
.where(
CreditRecord.type == "consume",
real_credit_charge_filter,
CreditRecord.created_at >= date_start,
CreditRecord.created_at <= date_end,
)
@@ -1949,6 +1977,7 @@ async def get_stats(
)
.where(
CreditRecord.type == "consume",
real_credit_charge_filter,
CreditRecord.created_at >= date_start,
CreditRecord.created_at <= date_end,
)
@@ -1972,6 +2001,7 @@ async def get_stats(
.where(
ChatGenerationTask.gen_type == "video",
CreditRecord.type == "consume",
real_credit_charge_filter,
ChatGenerationTask.created_at >= date_start,
ChatGenerationTask.created_at <= date_end,
ChatGenerationTask.deleted_at.is_(None),
+103 -108
View File
@@ -15,7 +15,6 @@ from app.dependencies import get_db, get_current_user
from app.models.user import User
from app.models.project import Project
from app.models.generation_record import GenerationRecord
from app.models.system_config import SystemConfig
from app.schemas.generation import (
OptimizeParams,
GenerationRecordOut,
@@ -27,7 +26,6 @@ from app.services.generation.pipeline.db_lock_service import (
DatabaseRowLockBusy,
execute_with_lock_timeout,
)
from app.services.credits import deduct_credits, add_credits, calc_text_credits
from app.services.llm import optimize_prompt
from app.services.video_url import validate_and_get_record_id, get_video_stream_url
from app.services.private_portrait.reference_resolver import batch_resolve_private_portrait_reference_display_urls, resolve_private_portrait_reference_display_urls
@@ -45,14 +43,27 @@ from app.enums.generation_status import (
GenerationType,
)
from app.enums.common import LogEventStatusEnum
from app.enums.credit_record import (
CreditRecordBillingScene,
CreditRecordChargeKind,
CreditRecordSourceModule,
)
from app.enums.llm_billing import LlmBillingConfigKey
from app.services.llm_billing import (
LlmBillingContext,
log_provider_failure,
log_provider_start,
log_provider_success,
release_on_failure,
settle_success,
start_hold,
)
from app.enums.generation_record import (
GenerationRecordConfigSourceEnum,
GenerationRecordEventTypeEnum,
)
from app.services.generation.billing_service import (
CHARGE_TEXT_PROMPT,
OWNER_GENERATION_RECORD,
build_credit_biz_key,
charge_generation_media_for_record,
get_next_credit_attempt_no,
)
@@ -76,7 +87,6 @@ from app.services.generation.media_reference_service import (
calculate_media_reference_usage,
validate_media_reference_usage_for_engine,
)
from app.services.credit_record_meta_service import build_generation_record_prompt_meta
from app.enums.audio_reference import (
AUDIO_ALLOWED_EXTENSIONS,
AUDIO_ALLOWED_MIME_TYPES,
@@ -506,29 +516,26 @@ async def optimize(
)
hold_credits = 5
hold_result = await db.execute(
select(SystemConfig).where(SystemConfig.key == "optimize_hold_credits").limit(1)
)
hold_row = hold_result.scalar_one_or_none()
if hold_row and hold_row.value:
try:
hold_credits = max(0, int(hold_row.value))
except (ValueError, TypeError):
hold_credits = 5
hold_scope = req.idempotency_key or generate_id()
hold_biz_key = f"optimize_hold:{hold_scope}"
hold_refund_biz_key = f"optimize_hold_refund:{hold_scope}"
await deduct_credits(
db,
user_id_snapshot,
hold_credits,
"AI创作预扣积分",
biz_key=hold_biz_key,
record_id_value = generate_id()
prompt_attempt_no = 1
llm_billing_context = LlmBillingContext(
user_id=user_id_snapshot,
owner_type=OWNER_GENERATION_RECORD,
owner_id=record_id_value,
attempt_no=prompt_attempt_no,
charge_kind=CreditRecordChargeKind.TEXT_PROMPT.value,
billing_scene=CreditRecordBillingScene.GENERATION_RECORD_TEXT_PROMPT_OPTIMIZE.value,
source_module=CreditRecordSourceModule.GENERATION_RECORD.value,
related_id=record_id_value,
hold_config_key=LlmBillingConfigKey.HOLD_GENERATION_RECORD_PROMPT.value,
description_prefix="AI创作提示词优化",
trace_id=f"generation-optimize:{record_id_value}",
request_id=req.idempotency_key,
)
await start_hold(db, llm_billing_context)
await db.commit()
log_provider_start(llm_billing_context, detail={"gen_type": req.gen_type.value})
try:
optimized, token_usage = await optimize_prompt(
db,
@@ -544,55 +551,63 @@ async def optimize(
log_module="generation_record",
log_step="prompt_optimize",
log_project_id=req.project_id,
log_owner_type=OWNER_GENERATION_RECORD,
log_owner_id=record_id_value,
generation_attempt_no=prompt_attempt_no,
)
log_provider_success(llm_billing_context, usage=token_usage)
except Exception as exc:
from app.services.error_codes import extract_error_message
await db.rollback()
await add_credits(
db,
user_id_snapshot,
hold_credits,
"AI创作预扣积分退还",
record_type="refund",
biz_key=hold_refund_biz_key,
refund_for_biz_key=hold_biz_key,
)
log_provider_failure(llm_billing_context, error=str(exc))
await release_on_failure(db, llm_billing_context, error=str(exc))
await db.commit()
raise HTTPException(
status_code=502,
detail=f"AI模型调用失败: {extract_error_message(exc, '提示词')}",
) from exc
try:
text_credits = await calc_text_credits(
db,
int(token_usage.get("input_tokens", 0) or 0),
int(token_usage.get("output_tokens", 0) or 0),
)
record = GenerationRecord(
id=generate_id(),
user_id=user_id_snapshot,
project_id=req.project_id,
original_prompt=req.prompt,
optimized_prompt=optimized,
gen_type=req.gen_type.value,
duration=req.duration if req.gen_type == GenerationType.video else None,
aspect_ratio=req.aspect_ratio if req.gen_type == GenerationType.video else None,
resolution=req.resolution if req.gen_type == GenerationType.video else None,
image_size=req.image_size if req.gen_type == GenerationType.image else None,
image_proportion=req.image_proportion if req.gen_type == GenerationType.image else None,
image_px=req.image_px if req.gen_type == GenerationType.image else None,
status="prompt_optimized",
pipeline_stage=None,
credits_cost=0,
text_credits_cost=round(text_credits, 2),
text_tokens_used=int(token_usage.get("total_tokens", 0) or 0),
media_references=json.dumps(req.references, ensure_ascii=False) if req.references else None,
include_media_references=bool(req.include_media_references),
idempotency_key=req.idempotency_key,
async def _persist_optimized_result() -> str:
existing_result = await db.execute(
select(GenerationRecord)
.where(GenerationRecord.id == record_id_value)
.with_for_update()
.limit(1)
)
record = existing_result.scalar_one_or_none()
if record is None:
record = GenerationRecord(
id=record_id_value,
user_id=user_id_snapshot,
project_id=req.project_id,
original_prompt=req.prompt,
optimized_prompt=optimized,
gen_type=req.gen_type.value,
duration=req.duration if req.gen_type == GenerationType.video else None,
aspect_ratio=req.aspect_ratio if req.gen_type == GenerationType.video else None,
resolution=req.resolution if req.gen_type == GenerationType.video else None,
image_size=req.image_size if req.gen_type == GenerationType.image else None,
image_proportion=req.image_proportion if req.gen_type == GenerationType.image else None,
image_px=req.image_px if req.gen_type == GenerationType.image else None,
status="prompt_optimized",
pipeline_stage=None,
credits_cost=0,
text_credits_cost=0,
text_tokens_used=int(token_usage.get("total_tokens", 0) or 0),
media_references=json.dumps(req.references, ensure_ascii=False) if req.references else None,
include_media_references=bool(req.include_media_references),
idempotency_key=req.idempotency_key,
)
db.add(record)
else:
# commit 结果不确定或本地持久化重试时,复用同一主键和同一账务 attempt。
record.optimized_prompt = optimized
record.status = "prompt_optimized"
record.pipeline_stage = None
record.error_message = None
record.text_credits_cost = 0
record.text_tokens_used = int(token_usage.get("total_tokens", 0) or 0)
if req.gen_type == GenerationType.video:
from app.services.video_upscale.snapshot_service import build_video_upscale_snapshot
@@ -619,61 +634,41 @@ async def optimize(
engine=engine_snapshot_source,
source=GenerationRecordConfigSourceEnum.PROMPT_OPTIMIZE,
)
db.add(record)
await db.flush()
prompt_attempt_no = 1
prompt_biz_key = build_credit_biz_key(
owner_type=OWNER_GENERATION_RECORD,
owner_id=record.id,
attempt_no=prompt_attempt_no,
charge_kind=CHARGE_TEXT_PROMPT,
action="charge",
)
prompt_meta = await build_generation_record_prompt_meta(
billing = await settle_success(
db,
record_id=record.id,
attempt_no=prompt_attempt_no,
charge_kind=CHARGE_TEXT_PROMPT,
llm_billing_context,
usage=token_usage,
description=f"提示词优化 - {project_name_snapshot}",
)
# Release the hold and charge the exact prompt usage in one transaction.
await add_credits(
db,
user_id_snapshot,
hold_credits,
f"AI创作预扣积分退还 - {project_name_snapshot}",
related_id=record.id,
record_type="refund",
biz_key=hold_refund_biz_key,
refund_for_biz_key=hold_biz_key,
)
await deduct_credits(
db,
user_id_snapshot,
text_credits,
f"提示词优化 - {project_name_snapshot}",
related_id=record.id,
biz_key=prompt_biz_key,
record_meta=prompt_meta,
charge_item = next(
(item for item in billing.items if item.biz_key == llm_billing_context.charge_biz_key),
None,
)
if charge_item:
record.text_credits_cost = round(charge_item.amount, 2)
record_id_snapshot = str(record.id)
await db.commit()
except Exception:
return record_id_snapshot
try:
record_id_snapshot = await _persist_optimized_result()
except Exception as first_exc:
await db.rollback()
# Any local pricing/snapshot/persistence failure after the provider call
# must release the committed hold. The refund key is idempotent.
await add_credits(
db,
user_id_snapshot,
hold_credits,
"AI创作预扣积分退还",
record_type="refund",
biz_key=hold_refund_biz_key,
refund_for_biz_key=hold_biz_key,
logger.exception(
"prompt optimize local persistence/settlement failed after provider success; retry once: record_id=%s",
record_id_value,
)
await db.commit()
raise
try:
record_id_snapshot = await _persist_optimized_result()
except Exception:
await db.rollback()
logger.exception(
"prompt optimize idempotent persistence retry failed; active HOLD retained for repair: record_id=%s",
record_id_value,
)
raise first_exc
refreshed = await db.execute(
select(GenerationRecord, Project.name)
+208 -26
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
from datetime import datetime
from types import SimpleNamespace
from typing import Any
from fastapi import APIRouter, Body, Depends, File, HTTPException, Path, Query, UploadFile
from sqlalchemy import inspect as sa_inspect
@@ -11,6 +12,12 @@ from app.dependencies import get_current_user, get_db
from app.models.user import User
from app.enums.common import ModuleProjectStatusEnum, ModuleEventTypeEnum
from app.enums.generation_task import GenerationOwnerType
from app.enums.credit_record import (
CreditRecordBillingScene,
CreditRecordChargeKind,
CreditRecordOwnerType,
)
from app.enums.llm_billing import LlmBillingConfigKey
from app.enums.hot_opening_replicate import HotOpeningLogEventEnum, HotOpeningStepCodeEnum, ModuleCodeEnum
from app.schemas.hot_opening_replicate import (
HotOpeningActionOut,
@@ -43,10 +50,20 @@ from app.services.hot_opening_replicate_service import (
update_hot_opening_video_prompt_schema,
)
from app.services.module_generation_log_service import log_module_error, log_module_event_file
from app.services.llm_billing import (
LlmBillingContext,
log_celery_dispatch_compensated,
log_celery_dispatch_failure,
log_celery_dispatch_start,
log_celery_dispatch_success,
)
from app.services.module_async_recovery_service import (
OBJECT_MODULE_STEP,
TASK_HOT_IMAGE_PROMPT,
TASK_HOT_VIDEO_PROMPT,
has_live_object_lock,
register_module_step_task,
remove_active_task,
)
from app.tasks.celery_app import celery_app
from app.enums.upload_resource import UploadResourceEventEnum, UploadResourceModuleEnum, UploadResourceSourceModelEnum, UploadResourceTypeEnum
@@ -138,6 +155,47 @@ def _log_api_exception_from_locals(exc: BaseException, local_values: dict, messa
exc=exc,
)
def _prompt_dispatch_billing_context(
*,
user_id: str,
project_id: str,
step_id: str,
step_code: str,
attempt_no: int,
celery_task_id: str,
) -> LlmBillingContext:
is_image = step_code == HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value
return LlmBillingContext(
user_id=user_id,
owner_type=CreditRecordOwnerType.MODULE_GENERATION_STEP.value,
owner_id=step_id,
attempt_no=attempt_no,
charge_kind=CreditRecordChargeKind.TEXT_PROMPT.value,
billing_scene=(
CreditRecordBillingScene.HOT_OPENING_IMAGE_PROMPT_OPTIMIZE.value
if is_image
else CreditRecordBillingScene.HOT_OPENING_VIDEO_PROMPT_OPTIMIZE.value
),
source_module=MODULE,
source_project_id=project_id,
source_step_id=step_id,
source_step_code=step_code,
related_id=step_id,
hold_config_key=(
LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value
if is_image
else LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value
),
description_prefix=(
"爆款开头复刻图片AI提词优化"
if is_image
else "爆款开头复刻视频提词优化"
),
trace_id=f"hot-opening-prompt:{step_id}:attempt:{attempt_no}",
celery_task_id=celery_task_id,
)
async def _reload_project_detail(
db: AsyncSession,
current_user: User,
@@ -161,10 +219,26 @@ async def _mark_dispatch_failed_and_raise(
project_id: str,
step_id: str | None,
message: str,
billing_context: LlmBillingContext | None = None,
) -> None:
"""Celery 投递失败后,数据库事务已提交,单独标记步骤失败,避免一直 processing。"""
"""Celery 投递失败后补偿步骤和冻结积分,避免一直 processing。"""
if billing_context is not None:
log_celery_dispatch_failure(billing_context, error=message)
compensated = False
if step_id:
try:
if await has_live_object_lock(object_type=OBJECT_MODULE_STEP, object_id=step_id):
log_module_error(
module=MODULE,
event_type=HotOpeningLogEventEnum.CELERY_DISPATCH_FAILED.value,
project_id=project_id,
step_id=step_id,
user_id=_safe_user_id(current_user),
message="Celery 投递返回异常,但 worker 已领取任务,跳过失败补偿",
detail={"reason": "uncertain_dispatch_worker_started", "dispatch_error": message},
error=message,
)
raise HTTPException(status_code=503, detail=f"{message};任务可能已被 worker 接收,请勿重复提交")
await mark_hot_opening_step_dispatch_failed(
db,
current_user=_user_context(current_user),
@@ -173,6 +247,23 @@ async def _mark_dispatch_failed_and_raise(
error_message=message,
)
await db.commit()
compensated = True
if billing_context is not None:
log_celery_dispatch_compensated(billing_context, error=message)
try:
await remove_active_task(object_type=OBJECT_MODULE_STEP, object_id=step_id)
except Exception as cleanup_exc:
_log_api_error(
event_type=HotOpeningLogEventEnum.CELERY_DISPATCH_MARK_FAILED.value,
current_user=current_user,
project_id=project_id,
step_id=step_id,
message="Celery 投递补偿完成,但清理 active registry 失败",
detail={"dispatch_error": message},
exc=cleanup_exc,
)
except HTTPException:
raise
except Exception as exc:
await db.rollback()
_log_api_error(
@@ -191,12 +282,89 @@ async def _mark_dispatch_failed_and_raise(
step_id=step_id,
user_id=_safe_user_id(current_user),
message=message,
detail={"reason": "celery_dispatch_failed"},
detail={"reason": "celery_dispatch_failed", "compensated": compensated},
error=message,
)
raise HTTPException(status_code=503, detail=message)
async def _dispatch_prompt_task(
db: AsyncSession,
*,
current_user: User,
project_id: str,
step_id: str,
step_code: str,
task_name: str,
celery_task: Any,
celery_task_id: str,
billing_context: LlmBillingContext,
error_prefix: str,
) -> None:
"""Redis 注册与 Celery 直投任一成功即视为可恢复投递。"""
registry_error: Exception | None = None
try:
await register_module_step_task(
module=MODULE,
project_id=project_id,
step_id=step_id,
step_code=step_code,
task_name=task_name,
)
except Exception as exc:
registry_error = exc
log_module_error(
module=MODULE,
event_type=HotOpeningLogEventEnum.CELERY_DISPATCH_FAILED.value,
project_id=project_id,
step_id=step_id,
user_id=_safe_user_id(current_user),
message="提词任务 Redis 活跃注册失败,将继续尝试 Celery 直投",
detail={"channel": "active_registry"},
exc=exc,
)
celery_error: Exception | None = None
try:
celery_task.apply_async(
args=[project_id, step_id],
queue="gen_chatapi_create",
countdown=0,
task_id=celery_task_id,
)
except Exception as exc:
celery_error = exc
if celery_error is None:
log_celery_dispatch_success(billing_context)
return
if registry_error is None:
log_celery_dispatch_failure(
billing_context,
error=f"Celery 直投失败,已保留 active registry 等待恢复:{celery_error}",
)
log_module_event_file(
module=MODULE,
event_type=HotOpeningLogEventEnum.CELERY_DISPATCH_FAILED.value,
project_id=project_id,
step_id=step_id,
user_id=_safe_user_id(current_user),
message="Celery 直投失败,任务将由 active registry 恢复投递",
detail={"recoverable": True, "celery_task_id": celery_task_id},
error=str(celery_error),
)
return
await _mark_dispatch_failed_and_raise(
db,
current_user=current_user,
project_id=project_id,
step_id=step_id,
message=f"{error_prefix}: Redis 注册失败({registry_error});Celery 投递失败({celery_error}",
billing_context=billing_context,
)
@router.get(
"/spec",
response_model=HotOpeningSpecOut,
@@ -514,6 +682,17 @@ async def generate_image_prompt(
project, step = await submit_image_prompt_optimize(db, current_user=current_user, project_id=project_id, material_step_id=step_id)
project_id_value = str(project.id)
step_id_value = str(step.id)
user_id_value = str(project.user_id)
attempt_no_value = int(step.version or 1)
celery_task_id = f"hot-opening:image-prompt:{step_id_value}"
billing_context = _prompt_dispatch_billing_context(
user_id=user_id_value,
project_id=project_id_value,
step_id=step_id_value,
step_code=HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value,
attempt_no=attempt_no_value,
celery_task_id=celery_task_id,
)
await db.commit()
except HTTPException:
await db.rollback()
@@ -525,23 +704,19 @@ async def generate_image_prompt(
from app.tasks.hot_opening_replicate_tasks import start_image_prompt_optimize
await register_module_step_task(
module=MODULE,
log_celery_dispatch_start(billing_context)
await _dispatch_prompt_task(
db,
current_user=current_user,
project_id=project_id_value,
step_id=step_id_value,
step_code=HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value,
task_name=TASK_HOT_IMAGE_PROMPT,
celery_task=start_image_prompt_optimize,
celery_task_id=celery_task_id,
billing_context=billing_context,
error_prefix="图片提词任务投递失败",
)
try:
start_image_prompt_optimize.apply_async(args=[project_id_value, step_id_value], queue="gen_chatapi_create", countdown=0)
except Exception as exc:
await _mark_dispatch_failed_and_raise(
db,
current_user=current_user,
project_id=project_id_value,
step_id=step_id_value,
message=f"图片提词任务投递失败: {exc}",
)
return HotOpeningActionOut(
message="图片 AI 提词任务已提交",
@@ -657,6 +832,17 @@ async def generate_video_prompt(
project, step = await submit_video_prompt_optimize(db, current_user=current_user, project_id=project_id, image_step_id=step_id, req=req)
project_id_value = str(project.id)
step_id_value = str(step.id)
user_id_value = str(project.user_id)
attempt_no_value = int(step.version or 1)
celery_task_id = f"hot-opening:video-prompt:{step_id_value}"
billing_context = _prompt_dispatch_billing_context(
user_id=user_id_value,
project_id=project_id_value,
step_id=step_id_value,
step_code=HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value,
attempt_no=attempt_no_value,
celery_task_id=celery_task_id,
)
await db.commit()
except HTTPException:
await db.rollback()
@@ -668,23 +854,19 @@ async def generate_video_prompt(
from app.tasks.hot_opening_replicate_tasks import start_video_prompt_optimize
await register_module_step_task(
module=MODULE,
log_celery_dispatch_start(billing_context)
await _dispatch_prompt_task(
db,
current_user=current_user,
project_id=project_id_value,
step_id=step_id_value,
step_code=HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value,
task_name=TASK_HOT_VIDEO_PROMPT,
celery_task=start_video_prompt_optimize,
celery_task_id=celery_task_id,
billing_context=billing_context,
error_prefix="视频提词任务投递失败",
)
try:
start_video_prompt_optimize.apply_async(args=[project_id_value, step_id_value], queue="gen_chatapi_create", countdown=0)
except Exception as exc:
await _mark_dispatch_failed_and_raise(
db,
current_user=current_user,
project_id=project_id_value,
step_id=step_id_value,
message=f"视频提词任务投递失败: {exc}",
)
return HotOpeningActionOut(
message="视频 AI 提词任务已提交",
+412 -34
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
from datetime import datetime
from types import SimpleNamespace
from typing import Any
from fastapi import APIRouter, Body, Depends, File, HTTPException, Path, Query, UploadFile
from sqlalchemy import inspect as sa_inspect
@@ -13,6 +14,12 @@ from app.dependencies import get_current_user, get_db
from app.models.user import User
from app.enums.common import ModuleEventTypeEnum
from app.enums.generation_task import GenerationOwnerType
from app.enums.credit_record import (
CreditRecordBillingScene,
CreditRecordChargeKind,
CreditRecordOwnerType,
)
from app.enums.llm_billing import LlmBillingConfigKey
from app.enums.shot_replicate import (
ModuleCodeEnum,
ShotAnalysisStatusEnum,
@@ -65,6 +72,7 @@ from app.services.shot_replicate_flow_service import (
update_shot_replicate_video_prompt_schema,
)
from app.services.shot_replicate_taskset_service import (
build_task_set_analysis_billing_context,
create_custom_segment,
create_segments_by_ai,
create_task_set,
@@ -72,6 +80,9 @@ from app.services.shot_replicate_taskset_service import (
delete_task_set,
list_segments,
list_task_sets,
mark_custom_segment_split_dispatch_failed,
mark_segment_analysis_dispatch_failed,
mark_task_set_analysis_dispatch_failed,
prepare_reanalyze_segment,
prepare_reanalyze_task_set,
prepare_retry_split_segment,
@@ -79,10 +90,20 @@ from app.services.shot_replicate_taskset_service import (
task_set_detail,
)
from app.services.module_generation_log_service import log_module_error, log_module_event_file
from app.services.llm_billing import (
LlmBillingContext,
log_celery_dispatch_compensated,
log_celery_dispatch_failure,
log_celery_dispatch_start,
log_celery_dispatch_success,
)
from app.services.module_async_recovery_service import (
OBJECT_MODULE_STEP,
TASK_SHOT_IMAGE_PROMPT,
TASK_SHOT_VIDEO_PROMPT,
has_live_object_lock,
register_module_step_task,
remove_active_task,
)
from app.tasks.celery_app import celery_app
from app.enums.upload_resource import UploadResourceEventEnum, UploadResourceModuleEnum, UploadResourceSourceModelEnum, UploadResourceTypeEnum
@@ -187,6 +208,83 @@ def _ensure_celery_enabled(*, current_user: User | None = None, project_id: str
)
raise HTTPException(status_code=503, detail=message)
def _prompt_dispatch_billing_context(
*,
user_id: str,
project_id: str,
step_id: str,
step_code: str,
attempt_no: int,
celery_task_id: str,
) -> LlmBillingContext:
is_image = step_code == ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value
return LlmBillingContext(
user_id=user_id,
owner_type=CreditRecordOwnerType.MODULE_GENERATION_STEP.value,
owner_id=step_id,
attempt_no=attempt_no,
charge_kind=CreditRecordChargeKind.TEXT_PROMPT.value,
billing_scene=(
CreditRecordBillingScene.SHOT_IMAGE_PROMPT_OPTIMIZE.value
if is_image
else CreditRecordBillingScene.SHOT_VIDEO_PROMPT_OPTIMIZE.value
),
source_module=MODULE,
source_project_id=project_id,
source_step_id=step_id,
source_step_code=step_code,
related_id=step_id,
hold_config_key=(
LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value
if is_image
else LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value
),
description_prefix=(
"拆镜复刻图片AI提词优化" if is_image else "拆镜复刻视频提词优化"
),
trace_id=f"shot-replicate-prompt:{step_id}:attempt:{attempt_no}",
celery_task_id=celery_task_id,
)
def _analysis_dispatch_billing_context(
*,
user_id: str,
owner_id: str,
attempt_no: int,
task_set_id: str,
is_segment: bool,
celery_task_id: str,
) -> LlmBillingContext:
return LlmBillingContext(
user_id=user_id,
owner_type=(
CreditRecordOwnerType.SHOT_REPLICATE_SEGMENT.value
if is_segment
else CreditRecordOwnerType.SHOT_REPLICATE_TASK_SET.value
),
owner_id=owner_id,
attempt_no=attempt_no,
charge_kind=CreditRecordChargeKind.VIDEO_ANALYSIS.value,
billing_scene=(
CreditRecordBillingScene.SHOT_SEGMENT_VIDEO_ANALYSIS.value
if is_segment
else CreditRecordBillingScene.SHOT_ORIGINAL_VIDEO_ANALYSIS.value
),
source_module=MODULE,
source_project_id=task_set_id,
source_step_id=owner_id,
source_step_code=ShotReplicateStepCodeEnum.VIDEO_ANALYSIS.value,
related_id=owner_id,
hold_config_key=LlmBillingConfigKey.HOLD_SHOT_VIDEO_ANALYSIS.value,
description_prefix=(
"拆镜复刻片段视频AI分析" if is_segment else "拆镜复刻原视频AI分析"
),
trace_id=f"shot-analysis:{owner_id}:attempt:{attempt_no}",
celery_task_id=celery_task_id,
)
async def _reload_project_detail(db: AsyncSession, current_user: User, project_id: str) -> ShotReplicateTaskDetailOut:
project = await _get_project_for_user(
db,
@@ -205,9 +303,25 @@ async def _mark_dispatch_failed_and_raise(
project_id: str,
step_id: str | None,
message: str,
billing_context: LlmBillingContext | None = None,
) -> None:
if billing_context is not None:
log_celery_dispatch_failure(billing_context, error=message)
compensated = False
if step_id:
try:
if await has_live_object_lock(object_type=OBJECT_MODULE_STEP, object_id=step_id):
log_module_error(
module=MODULE,
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
project_id=project_id,
step_id=step_id,
user_id=_safe_user_id(current_user),
message="Celery 投递返回异常,但 worker 已领取任务,跳过失败补偿",
detail={"reason": "uncertain_dispatch_worker_started", "dispatch_error": message},
error=message,
)
raise HTTPException(status_code=503, detail=f"{message};任务可能已被 worker 接收,请勿重复提交")
await mark_shot_replicate_step_dispatch_failed(
db,
current_user=_user_context(current_user),
@@ -216,6 +330,23 @@ async def _mark_dispatch_failed_and_raise(
error_message=message,
)
await db.commit()
compensated = True
if billing_context is not None:
log_celery_dispatch_compensated(billing_context, error=message)
try:
await remove_active_task(object_type=OBJECT_MODULE_STEP, object_id=step_id)
except Exception as cleanup_exc:
_log_api_error(
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_MARK_FAILED.value,
current_user=current_user,
project_id=project_id,
step_id=step_id,
message="Celery 投递补偿完成,但清理 active registry 失败",
detail={"dispatch_error": message},
exc=cleanup_exc,
)
except HTTPException:
raise
except Exception as exc:
await db.rollback()
_log_api_error(
@@ -234,12 +365,89 @@ async def _mark_dispatch_failed_and_raise(
step_id=step_id,
user_id=_safe_user_id(current_user),
message=message,
detail={"reason": "celery_dispatch_failed"},
detail={"reason": "celery_dispatch_failed", "compensated": compensated},
error=message,
)
raise HTTPException(status_code=503, detail=message)
async def _dispatch_prompt_task(
db: AsyncSession,
*,
current_user: User,
project_id: str,
step_id: str,
step_code: str,
task_name: str,
celery_task: Any,
celery_task_id: str,
billing_context: LlmBillingContext,
error_prefix: str,
) -> None:
"""Redis 注册与 Celery 直投任一成功即视为可恢复投递。"""
registry_error: Exception | None = None
try:
await register_module_step_task(
module=MODULE,
project_id=project_id,
step_id=step_id,
step_code=step_code,
task_name=task_name,
)
except Exception as exc:
registry_error = exc
log_module_error(
module=MODULE,
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
project_id=project_id,
step_id=step_id,
user_id=_safe_user_id(current_user),
message="提词任务 Redis 活跃注册失败,将继续尝试 Celery 直投",
detail={"channel": "active_registry"},
exc=exc,
)
celery_error: Exception | None = None
try:
celery_task.apply_async(
args=[project_id, step_id],
queue=CeleryQueue.GEN_CHATAPI_CREATE.value,
countdown=0,
task_id=celery_task_id,
)
except Exception as exc:
celery_error = exc
if celery_error is None:
log_celery_dispatch_success(billing_context)
return
if registry_error is None:
log_celery_dispatch_failure(
billing_context,
error=f"Celery 直投失败,已保留 active registry 等待恢复:{celery_error}",
)
log_module_event_file(
module=MODULE,
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
project_id=project_id,
step_id=step_id,
user_id=_safe_user_id(current_user),
message="Celery 直投失败,任务将由 active registry 恢复投递",
detail={"recoverable": True, "celery_task_id": celery_task_id},
error=str(celery_error),
)
return
await _mark_dispatch_failed_and_raise(
db,
current_user=current_user,
project_id=project_id,
step_id=step_id,
message=f"{error_prefix}: Redis 注册失败({registry_error});Celery 投递失败({celery_error}",
billing_context=billing_context,
)
@router.get(
"/spec",
response_model=ShotReplicateSpecOut,
@@ -327,8 +535,16 @@ async def create_shot_task_set(
):
_ensure_celery_enabled(current_user=current_user, project_id=locals().get("project_id") or locals().get("task_set_id"))
try:
task_set = await create_task_set(db, current_user=current_user, req=req)
task_set_id = task_set.id
task_set, created_new = await create_task_set(db, current_user=current_user, req=req)
task_set_id = str(task_set.id)
if not created_new:
# 幂等重复请求不重复预扣和投递;已有 pending 任务由原投递或恢复任务继续处理。
await db.rollback()
return await task_set_detail(db, current_user=_user_context(current_user), task_set_id=task_set_id)
analysis_attempt_no = max(1, int(task_set.analysis_attempt_no or 1))
celery_task_id = f"shot-analysis:task-set:{task_set_id}:attempt:{analysis_attempt_no}"
billing_context = build_task_set_analysis_billing_context(task_set)
billing_context.celery_task_id = celery_task_id
await bind_upload_resources(
db,
user_id=current_user.id,
@@ -348,11 +564,19 @@ async def create_shot_task_set(
_log_api_exception_from_locals(exc, locals(), f"创建拆镜总任务集失败: {exc}")
raise HTTPException(status_code=500, detail=f"创建拆镜总任务集失败: {exc}")
log_celery_dispatch_start(billing_context)
try:
from app.tasks.shot_replicate_tasks import analyze_original_video
analyze_original_video.apply_async(args=[task_set_id], queue=CeleryQueue.GEN_SHOT_ANALYSIS.value, countdown=0)
analyze_original_video.apply_async(
args=[task_set_id],
queue=CeleryQueue.GEN_SHOT_ANALYSIS.value,
countdown=0,
task_id=celery_task_id,
)
log_celery_dispatch_success(billing_context)
except Exception as exc:
log_celery_dispatch_failure(billing_context, error=str(exc))
_log_api_error(
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
current_user=current_user,
@@ -361,6 +585,26 @@ async def create_shot_task_set(
detail={"task_set_id": task_set_id, "task": "analyze_original_video"},
exc=exc,
)
try:
compensated = await mark_task_set_analysis_dispatch_failed(
db,
current_user=_user_context(current_user),
task_set_id=task_set_id,
error_message=f"拆镜分析任务投递失败: {exc}",
)
await db.commit()
if compensated:
log_celery_dispatch_compensated(billing_context, error=str(exc))
except Exception as mark_exc:
await db.rollback()
_log_api_error(
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_MARK_FAILED.value,
current_user=current_user,
project_id=task_set_id,
message="拆镜分析任务投递失败后补偿失败",
detail={"task_set_id": task_set_id, "task": "analyze_original_video"},
exc=mark_exc,
)
raise HTTPException(status_code=503, detail=f"拆镜分析任务投递失败: {exc}")
return await task_set_detail(db, current_user=_user_context(current_user), task_set_id=task_set_id)
@@ -445,6 +689,16 @@ async def reanalyze_task_set(
force=req.force,
reason=req.reason,
)
analysis_attempt_no = int(out.analysis_attempt_no)
celery_task_id = f"shot-analysis:task-set:{task_set_id}:attempt:{analysis_attempt_no}"
billing_context = _analysis_dispatch_billing_context(
user_id=str(current_user.id),
owner_id=task_set_id,
attempt_no=analysis_attempt_no,
task_set_id=task_set_id,
is_segment=False,
celery_task_id=celery_task_id,
)
await db.commit()
except HTTPException as exc:
await db.rollback()
@@ -470,10 +724,17 @@ async def reanalyze_task_set(
)
raise HTTPException(status_code=500, detail=f"原视频再次分析状态重置失败: {exc}")
log_celery_dispatch_start(billing_context)
try:
from app.tasks.shot_replicate_tasks import analyze_original_video
analyze_original_video.apply_async(args=[task_set_id], queue=CeleryQueue.GEN_SHOT_ANALYSIS.value, countdown=0)
analyze_original_video.apply_async(
args=[task_set_id],
queue=CeleryQueue.GEN_SHOT_ANALYSIS.value,
countdown=0,
task_id=celery_task_id,
)
log_celery_dispatch_success(billing_context)
log_module_event_file(
module=MODULE,
event_type=ShotReplicateLogEventEnum.TASK_SET_REANALYZE_SUBMITTED.value,
@@ -483,6 +744,7 @@ async def reanalyze_task_set(
detail={"task_set_id": task_set_id, "task": "analyze_original_video", "request": req.model_dump()},
)
except Exception as exc:
log_celery_dispatch_failure(billing_context, error=str(exc))
_log_api_error(
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
current_user=current_user,
@@ -491,6 +753,26 @@ async def reanalyze_task_set(
detail={"task_set_id": task_set_id, "task": "analyze_original_video"},
exc=exc,
)
try:
compensated = await mark_task_set_analysis_dispatch_failed(
db,
current_user=_user_context(current_user),
task_set_id=task_set_id,
error_message=f"原视频再次分析任务投递失败: {exc}",
)
await db.commit()
if compensated:
log_celery_dispatch_compensated(billing_context, error=str(exc))
except Exception as mark_exc:
await db.rollback()
_log_api_error(
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_MARK_FAILED.value,
current_user=current_user,
project_id=task_set_id,
message="原视频再次分析任务投递失败后补偿失败",
detail={"task_set_id": task_set_id, "task": "analyze_original_video"},
exc=mark_exc,
)
raise HTTPException(status_code=503, detail=f"原视频再次分析任务投递失败: {exc}")
out.message = "原视频再次分析任务已提交"
return out
@@ -558,7 +840,38 @@ async def split_custom(
from app.tasks.shot_replicate_tasks import split_one_segment
split_one_segment.apply_async(args=[segment_id], queue=CeleryQueue.GEN_SHOT_SPLIT.value, countdown=0)
try:
split_one_segment.apply_async(args=[segment_id], queue=CeleryQueue.GEN_SHOT_SPLIT.value, countdown=0)
except Exception as exc:
_log_api_error(
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
current_user=current_user,
project_id=task_set_id,
step_id=segment_id,
message=f"自定义拆镜切片任务投递失败: {exc}",
detail={"segment_id": segment_id, "task_set_id": task_set_id, "task": "split_one_segment"},
exc=exc,
)
try:
await mark_custom_segment_split_dispatch_failed(
db,
current_user=_user_context(current_user),
segment_id=segment_id,
error_message=f"自定义拆镜切片任务投递失败: {exc}",
)
await db.commit()
except Exception as mark_exc:
await db.rollback()
_log_api_error(
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_MARK_FAILED.value,
current_user=current_user,
project_id=task_set_id,
step_id=segment_id,
message="自定义拆镜切片投递失败后补偿失败",
detail={"segment_id": segment_id, "task_set_id": task_set_id},
exc=mark_exc,
)
raise HTTPException(status_code=503, detail=f"自定义拆镜切片任务投递失败: {exc}")
return out
@@ -627,7 +940,17 @@ async def reanalyze_segment(
force=req.force,
reason=req.reason,
)
task_set_id = out.task_set_id
task_set_id = str(out.task_set_id)
analysis_attempt_no = int(out.analysis_attempt_no)
celery_task_id = f"shot-analysis:segment:{segment_id}:attempt:{analysis_attempt_no}"
billing_context = _analysis_dispatch_billing_context(
user_id=str(current_user.id),
owner_id=segment_id,
attempt_no=analysis_attempt_no,
task_set_id=task_set_id,
is_segment=True,
celery_task_id=celery_task_id,
)
await db.commit()
except HTTPException as exc:
await db.rollback()
@@ -653,10 +976,17 @@ async def reanalyze_segment(
)
raise HTTPException(status_code=500, detail=f"切片视频再次分析状态重置失败: {exc}")
log_celery_dispatch_start(billing_context)
try:
from app.tasks.shot_replicate_tasks import analyze_custom_segment_video
analyze_custom_segment_video.apply_async(args=[segment_id], queue=CeleryQueue.GEN_SHOT_ANALYSIS.value, countdown=0)
analyze_custom_segment_video.apply_async(
args=[segment_id],
queue=CeleryQueue.GEN_SHOT_ANALYSIS.value,
countdown=0,
task_id=celery_task_id,
)
log_celery_dispatch_success(billing_context)
log_module_event_file(
module=MODULE,
event_type=ShotReplicateLogEventEnum.SEGMENT_REANALYZE_SUBMITTED.value,
@@ -667,6 +997,7 @@ async def reanalyze_segment(
detail={"segment_id": segment_id, "task_set_id": task_set_id, "task": "analyze_custom_segment_video", "request": req.model_dump()},
)
except Exception as exc:
log_celery_dispatch_failure(billing_context, error=str(exc))
_log_api_error(
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
current_user=current_user,
@@ -676,6 +1007,27 @@ async def reanalyze_segment(
detail={"segment_id": segment_id, "task_set_id": task_set_id, "task": "analyze_custom_segment_video"},
exc=exc,
)
try:
compensated = await mark_segment_analysis_dispatch_failed(
db,
current_user=_user_context(current_user),
segment_id=segment_id,
error_message=f"切片视频再次分析任务投递失败: {exc}",
)
await db.commit()
if compensated:
log_celery_dispatch_compensated(billing_context, error=str(exc))
except Exception as mark_exc:
await db.rollback()
_log_api_error(
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_MARK_FAILED.value,
current_user=current_user,
project_id=task_set_id,
step_id=segment_id,
message="切片视频再次分析任务投递失败后补偿失败",
detail={"segment_id": segment_id, "task_set_id": task_set_id, "task": "analyze_custom_segment_video"},
exc=mark_exc,
)
raise HTTPException(status_code=503, detail=f"切片视频再次分析任务投递失败: {exc}")
out.message = "切片视频再次分析任务已提交"
return out
@@ -985,7 +1337,18 @@ async def generate_image_prompt(
_ensure_celery_enabled(current_user=current_user, project_id=project_id, step_id=step_id)
try:
project, step = await submit_image_prompt_optimize(db, current_user=current_user, project_id=project_id, material_step_id=step_id, req=req)
project_id_value, step_id_value = project.id, step.id
project_id_value, step_id_value = str(project.id), str(step.id)
user_id_value = str(project.user_id)
attempt_no_value = int(step.version or 1)
celery_task_id = f"shot-replicate:image-prompt:{step_id_value}"
billing_context = _prompt_dispatch_billing_context(
user_id=user_id_value,
project_id=project_id_value,
step_id=step_id_value,
step_code=ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value,
attempt_no=attempt_no_value,
celery_task_id=celery_task_id,
)
await db.commit()
except HTTPException:
await db.rollback()
@@ -995,19 +1358,21 @@ async def generate_image_prompt(
_log_api_exception_from_locals(exc, locals(), f"提交图片 AI 提词失败: {exc}")
raise HTTPException(status_code=500, detail=f"提交图片 AI 提词失败: {exc}")
try:
from app.tasks.shot_replicate_flow_tasks import start_image_prompt_optimize
from app.tasks.shot_replicate_flow_tasks import start_image_prompt_optimize
await register_module_step_task(
module=MODULE,
project_id=project_id_value,
step_id=step_id_value,
step_code=ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value,
task_name=TASK_SHOT_IMAGE_PROMPT,
)
start_image_prompt_optimize.apply_async(args=[project_id_value, step_id_value], queue=CeleryQueue.GEN_CHATAPI_CREATE.value, countdown=0)
except Exception as exc:
await _mark_dispatch_failed_and_raise(db, current_user=current_user, project_id=project_id_value, step_id=step_id_value, message=f"图片 AI 提词任务投递失败: {exc}")
log_celery_dispatch_start(billing_context)
await _dispatch_prompt_task(
db,
current_user=current_user,
project_id=project_id_value,
step_id=step_id_value,
step_code=ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value,
task_name=TASK_SHOT_IMAGE_PROMPT,
celery_task=start_image_prompt_optimize,
celery_task_id=celery_task_id,
billing_context=billing_context,
error_prefix="图片 AI 提词任务投递失败",
)
return ShotReplicateActionOut(message="图片 AI 提词任务已提交", project_id=project_id_value, step_id=step_id_value, detail=await _reload_project_detail(db, current_user, project_id_value))
@@ -1085,7 +1450,18 @@ async def generate_video_prompt(
_ensure_celery_enabled(current_user=current_user, project_id=project_id, step_id=step_id)
try:
project, step = await submit_video_prompt_optimize(db, current_user=current_user, project_id=project_id, image_step_id=step_id, req=req)
project_id_value, step_id_value = project.id, step.id
project_id_value, step_id_value = str(project.id), str(step.id)
user_id_value = str(project.user_id)
attempt_no_value = int(step.version or 1)
celery_task_id = f"shot-replicate:video-prompt:{step_id_value}"
billing_context = _prompt_dispatch_billing_context(
user_id=user_id_value,
project_id=project_id_value,
step_id=step_id_value,
step_code=ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value,
attempt_no=attempt_no_value,
celery_task_id=celery_task_id,
)
await db.commit()
except HTTPException:
await db.rollback()
@@ -1095,19 +1471,21 @@ async def generate_video_prompt(
_log_api_exception_from_locals(exc, locals(), f"提交视频 AI 提词失败: {exc}")
raise HTTPException(status_code=500, detail=f"提交视频 AI 提词失败: {exc}")
try:
from app.tasks.shot_replicate_flow_tasks import start_video_prompt_optimize
from app.tasks.shot_replicate_flow_tasks import start_video_prompt_optimize
await register_module_step_task(
module=MODULE,
project_id=project_id_value,
step_id=step_id_value,
step_code=ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value,
task_name=TASK_SHOT_VIDEO_PROMPT,
)
start_video_prompt_optimize.apply_async(args=[project_id_value, step_id_value], queue=CeleryQueue.GEN_CHATAPI_CREATE.value, countdown=0)
except Exception as exc:
await _mark_dispatch_failed_and_raise(db, current_user=current_user, project_id=project_id_value, step_id=step_id_value, message=f"视频 AI 提词任务投递失败: {exc}")
log_celery_dispatch_start(billing_context)
await _dispatch_prompt_task(
db,
current_user=current_user,
project_id=project_id_value,
step_id=step_id_value,
step_code=ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value,
task_name=TASK_SHOT_VIDEO_PROMPT,
celery_task=start_video_prompt_optimize,
celery_task_id=celery_task_id,
billing_context=billing_context,
error_prefix="视频 AI 提词任务投递失败",
)
return ShotReplicateActionOut(message="视频 AI 提词任务已提交", project_id=project_id_value, step_id=step_id_value, detail=await _reload_project_detail(db, current_user, project_id_value))
@@ -15,12 +15,15 @@ from app.schemas.module_generation_v2 import (
)
from app.services.generation.pipeline.enqueue_service import enqueue_generation_create
from app.services.hot_opening_replicate_service import project_to_detail_out
from app.services.llm_billing import LlmBillingContext, log_celery_dispatch_compensated
from app.services.module_async_recovery_service import OBJECT_MODULE_STEP, has_live_object_lock
from app.services.module_generation_v2.config import HOT_OPENING_V2
from app.services.module_generation_v2.dispatch_service import (
dispatch_video_prompt_v2,
ensure_v2_celery_enabled,
)
from app.services.module_generation_v2.flow_service import (
build_v2_video_prompt_billing_context,
create_hot_opening_project_v2,
delete_project_v2,
generate_video_from_prompt_v2,
@@ -35,6 +38,19 @@ from app.services.upload_resource import cleanup_upload_resource_files_after_com
router = APIRouter(prefix="/hot-opening-replications", tags=["hot-opening-replications-v2"])
def _dispatch_context(*, user_id: str, project_id: str, step_id: str, step_version: int) -> LlmBillingContext:
context = build_v2_video_prompt_billing_context(
user_id=user_id,
project_id=project_id,
step_id=step_id,
step_version=step_version,
module=HOT_OPENING_V2.module,
display_name=HOT_OPENING_V2.display_name,
)
context.celery_task_id = f"module-v2-video-prompt:{step_id}"
return context
async def _detail(db: AsyncSession, current_user: User, project_id: str) -> HotOpeningTaskDetailOut:
project = await get_v2_project_for_user(
db,
@@ -50,14 +66,19 @@ async def _dispatch_or_mark_failed(
*,
project_id: str,
step_id: str,
billing_context: LlmBillingContext,
) -> None:
dispatch = await dispatch_video_prompt_v2(
config=HOT_OPENING_V2,
project_id=project_id,
step_id=step_id,
billing_context=billing_context,
)
if dispatch.recoverable:
return
if await has_live_object_lock(object_type=OBJECT_MODULE_STEP, object_id=step_id):
# apply_async 可能已送达但客户端收到异常;worker 已领取时不能释放冻结。
return
error_message = "视频提词任务的 Redis 注册和 Celery 投递均失败,请重新执行步骤2"
await mark_video_prompt_dispatch_failed_v2(
db,
@@ -66,6 +87,7 @@ async def _dispatch_or_mark_failed(
step_id=step_id,
error_message=error_message,
)
log_celery_dispatch_compensated(billing_context, error=error_message)
raise HTTPException(status_code=503, detail=error_message)
@@ -81,6 +103,12 @@ async def create_task_v2(
project_id = str(result.project.id)
step_id = str(result.prompt_step.id)
created_new = bool(result.created_new)
billing_context = _dispatch_context(
user_id=str(result.project.user_id),
project_id=project_id,
step_id=step_id,
step_version=int(result.prompt_step.version or 1),
)
await db.commit()
except IntegrityError as exc:
await db.rollback()
@@ -91,6 +119,12 @@ async def create_task_v2(
project_id = str(result.project.id)
step_id = str(result.prompt_step.id)
created_new = bool(result.created_new)
billing_context = _dispatch_context(
user_id=str(result.project.user_id),
project_id=project_id,
step_id=step_id,
step_version=int(result.prompt_step.version or 1),
)
await db.commit()
except HTTPException:
await db.rollback()
@@ -100,7 +134,9 @@ async def create_task_v2(
raise HTTPException(status_code=500, detail="创建爆款复刻 V2 项目失败") from exc
if created_new:
await _dispatch_or_mark_failed(db, project_id=project_id, step_id=step_id)
await _dispatch_or_mark_failed(
db, project_id=project_id, step_id=step_id, billing_context=billing_context
)
return await _detail(db, current_user, project_id)
@@ -136,11 +172,22 @@ async def retry_video_prompt_v2(
)
project_id_value = str(project.id)
step_id_value = str(new_step.id)
billing_context = _dispatch_context(
user_id=str(project.user_id),
project_id=project_id_value,
step_id=step_id_value,
step_version=int(new_step.version or 1),
)
await db.commit()
except HTTPException:
await db.rollback()
raise
await _dispatch_or_mark_failed(db, project_id=project_id_value, step_id=step_id_value)
await _dispatch_or_mark_failed(
db,
project_id=project_id_value,
step_id=step_id_value,
billing_context=billing_context,
)
return HotOpeningActionOut(
message="视频提词已重新提交",
project_id=project_id_value,
+49 -2
View File
@@ -14,12 +14,15 @@ from app.schemas.module_generation_v2 import (
)
from app.schemas.shot_replicate import ShotReplicateActionOut, ShotReplicateDeleteOut, ShotReplicateTaskDetailOut
from app.services.generation.pipeline.enqueue_service import enqueue_generation_create
from app.services.llm_billing import LlmBillingContext, log_celery_dispatch_compensated
from app.services.module_async_recovery_service import OBJECT_MODULE_STEP, has_live_object_lock
from app.services.module_generation_v2.config import SHOT_REPLICATE_V2
from app.services.module_generation_v2.dispatch_service import (
dispatch_video_prompt_v2,
ensure_v2_celery_enabled,
)
from app.services.module_generation_v2.flow_service import (
build_v2_video_prompt_billing_context,
create_shot_replicate_project_v2,
delete_project_v2,
generate_video_from_prompt_v2,
@@ -36,6 +39,19 @@ from app.services.upload_resource import cleanup_upload_resource_files_after_com
router = APIRouter(prefix="/shot-replications", tags=["shot-replications-v2"])
def _dispatch_context(*, user_id: str, project_id: str, step_id: str, step_version: int) -> LlmBillingContext:
context = build_v2_video_prompt_billing_context(
user_id=user_id,
project_id=project_id,
step_id=step_id,
step_version=step_version,
module=SHOT_REPLICATE_V2.module,
display_name=SHOT_REPLICATE_V2.display_name,
)
context.celery_task_id = f"module-v2-video-prompt:{step_id}"
return context
async def _detail(db: AsyncSession, current_user: User, project_id: str) -> ShotReplicateTaskDetailOut:
project = await get_v2_project_for_user(
db,
@@ -51,14 +67,19 @@ async def _dispatch_or_mark_failed(
*,
project_id: str,
step_id: str,
billing_context: LlmBillingContext,
) -> None:
dispatch = await dispatch_video_prompt_v2(
config=SHOT_REPLICATE_V2,
project_id=project_id,
step_id=step_id,
billing_context=billing_context,
)
if dispatch.recoverable:
return
if await has_live_object_lock(object_type=OBJECT_MODULE_STEP, object_id=step_id):
# apply_async 可能已送达但客户端收到异常;worker 已领取时不能释放冻结。
return
error_message = "视频提词任务的 Redis 注册和 Celery 投递均失败,请重新执行步骤2"
await mark_video_prompt_dispatch_failed_v2(
db,
@@ -67,6 +88,7 @@ async def _dispatch_or_mark_failed(
step_id=step_id,
error_message=error_message,
)
log_celery_dispatch_compensated(billing_context, error=error_message)
raise HTTPException(status_code=503, detail=error_message)
@@ -91,6 +113,12 @@ async def create_project_v2(
project_id = str(result.project.id)
step_id = str(result.prompt_step.id)
created_new = bool(result.created_new)
billing_context = _dispatch_context(
user_id=str(result.project.user_id),
project_id=project_id,
step_id=step_id,
step_version=int(result.prompt_step.version or 1),
)
await db.commit()
except IntegrityError as exc:
await db.rollback()
@@ -105,6 +133,12 @@ async def create_project_v2(
project_id = str(result.project.id)
step_id = str(result.prompt_step.id)
created_new = bool(result.created_new)
billing_context = _dispatch_context(
user_id=str(result.project.user_id),
project_id=project_id,
step_id=step_id,
step_version=int(result.prompt_step.version or 1),
)
await db.commit()
except HTTPException:
await db.rollback()
@@ -114,7 +148,9 @@ async def create_project_v2(
raise HTTPException(status_code=500, detail="创建拆镜复刻 V2 项目失败") from exc
if created_new:
await _dispatch_or_mark_failed(db, project_id=project_id, step_id=step_id)
await _dispatch_or_mark_failed(
db, project_id=project_id, step_id=step_id, billing_context=billing_context
)
return ShotReplicateActionOut(
message="V2 项目已创建,视频提词已自动提交" if created_new else "已返回现有幂等项目",
project_id=project_id,
@@ -155,11 +191,22 @@ async def retry_video_prompt_v2(
)
project_id_value = str(project.id)
step_id_value = str(new_step.id)
billing_context = _dispatch_context(
user_id=str(project.user_id),
project_id=project_id_value,
step_id=step_id_value,
step_version=int(new_step.version or 1),
)
await db.commit()
except HTTPException:
await db.rollback()
raise
await _dispatch_or_mark_failed(db, project_id=project_id_value, step_id=step_id_value)
await _dispatch_or_mark_failed(
db,
project_id=project_id_value,
step_id=step_id_value,
billing_context=billing_context,
)
return ShotReplicateActionOut(
message="视频提词已重新提交",
project_id=project_id_value,