修复冻结积分BUG | 拆镜状态异常BUG

This commit is contained in:
2026-07-24 14:00:37 +08:00
parent 357657d7cd
commit 4685b475af
22 changed files with 2159 additions and 733 deletions
+15 -387
View File
@@ -26,7 +26,6 @@ from app.services.generation.pipeline.db_lock_service import (
DatabaseRowLockBusy,
execute_with_lock_timeout,
)
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
from app.services.resource_signed_url_service import build_resource_signed_url
@@ -43,21 +42,6 @@ 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,
@@ -70,12 +54,9 @@ from app.services.generation.billing_service import (
from app.services.generation.ai.engine_service import (
get_image_engine,
get_video_engine,
image_supported_sizes,
parse_json_list,
)
from app.services.generation.pipeline.generation_record_config_service import (
ensure_generation_record_config_frozen,
freeze_generation_record_config_with_log,
frozen_generation_record_engine_view,
generation_record_config_fallback_hint,
generation_record_engine_snapshot,
@@ -87,12 +68,12 @@ from app.services.generation.media_reference_service import (
calculate_media_reference_usage,
validate_media_reference_usage_for_engine,
)
from app.services.generation.prompt_optimize_service import optimize_generation_prompt
from app.enums.audio_reference import (
AUDIO_ALLOWED_EXTENSIONS,
AUDIO_ALLOWED_MIME_TYPES,
AUDIO_MAX_FILE_SIZE_MB,
)
from app.utils.id_gen import generate_id
from app.utils.exceptions import RecordNotFoundError, InvalidStatusError
router = APIRouter(prefix="/generation-records", tags=["generation"])
@@ -107,43 +88,12 @@ def _record_config_complete(record: GenerationRecord) -> bool:
return is_generation_record_config_complete(record)
def _canonical_references(value: object) -> str:
return json.dumps(value or [], ensure_ascii=False, sort_keys=True, separators=(",", ":"))
def _idempotency_config_matches(record: GenerationRecord, req: OptimizeParams) -> bool:
try:
existing_references = json.loads(record.media_references) if record.media_references else []
except (TypeError, json.JSONDecodeError):
return False
if (
str(record.project_id) != str(req.project_id)
or record.original_prompt != req.prompt
or record.gen_type != req.gen_type.value
or str(record.engine_id or "") != str(req.engine_id)
or bool(record.include_media_references) != bool(req.include_media_references)
or _canonical_references(existing_references) != _canonical_references(req.references)
):
return False
if req.gen_type == GenerationType.video:
return (
record.duration == req.duration
and record.aspect_ratio == req.aspect_ratio
and record.resolution == req.resolution
)
return (
record.image_size == req.image_size
and record.image_proportion == req.image_proportion
and record.image_px == req.image_px
)
def _record_status_view(record: GenerationRecord) -> dict[str, object]:
config_complete = _record_config_complete(record)
config_recoverable = is_generation_record_config_recoverable(record)
prompt_failure = record.status == "failed" and record.resource_generation_started_at is None
resource_failure = record.status == "failed" and record.resource_generation_started_at is not None
if record.status in {"pending", "optimizing"}:
if record.status in {"pending", "optimizing", "settlement_pending"}:
client_status = "prompt_processing"
operation_phase = "prompt"
elif record.status == "prompt_optimized":
@@ -164,7 +114,7 @@ def _record_status_view(record: GenerationRecord) -> dict[str, object]:
"config_fallback_hint": generation_record_config_fallback_hint(record),
"can_generate": record.status == "prompt_optimized" and (config_complete or config_recoverable),
"can_retry": resource_failure and config_complete and record.pipeline_stage != GenerationRecordPipelineStage.UPSCALE_FAILED.value,
"should_poll": record.status in {"optimizing", "generating"},
"should_poll": record.status in {"optimizing", "settlement_pending", "generating"},
"client_status": client_status,
"operation_phase": operation_phase,
}
@@ -174,26 +124,6 @@ def _frozen_engine_view(record: GenerationRecord) -> SimpleNamespace:
return frozen_generation_record_engine_view(record)
def _validate_video_engine_selection(engine, *, aspect_ratio: str, resolution: str, duration: int) -> None:
ratios = [str(item) for item in parse_json_list(engine.supported_ratios, [])]
resolutions = [str(item) for item in parse_json_list(engine.supported_resolutions, [])]
durations = [int(item) for item in parse_json_list(engine.supported_durations, []) if str(item).isdigit()]
if ratios and aspect_ratio not in ratios:
raise HTTPException(status_code=400, detail="当前视频引擎不支持所选画面比例")
if resolutions and resolution not in resolutions:
raise HTTPException(status_code=400, detail="当前视频引擎不支持所选分辨率")
if durations and duration not in durations:
raise HTTPException(status_code=400, detail="当前视频引擎不支持所选时长")
if int(engine.max_duration or 0) > 0 and duration > int(engine.max_duration):
raise HTTPException(status_code=400, detail="生成时长超过当前视频引擎上限")
def _validate_image_engine_selection(engine, *, image_size: str) -> None:
sizes = image_supported_sizes(engine)
if sizes and image_size not in sizes:
raise HTTPException(status_code=400, detail="当前图片引擎不支持所选画面分辨率")
def _record_to_out(record: GenerationRecord, project_name: str, refs_override: list[dict] | None = None) -> GenerationRecordOut:
refs = refs_override
if refs is None and record.media_references:
@@ -286,6 +216,8 @@ async def list_records(
db: AsyncSession = Depends(get_db),
):
allowed_statuses = {
"optimizing",
"settlement_pending",
"prompt_optimized",
"generating",
"failed",
@@ -295,7 +227,7 @@ async def list_records(
if status and status not in allowed_statuses:
raise HTTPException(
status_code=400,
detail="状态参数错误,仅支持:prompt_optimized、generating、failed、completed",
detail="状态参数错误,仅支持:optimizing、settlement_pending、prompt_optimized、generating、failed、completed",
)
offset = (page - 1) * page_size
@@ -356,324 +288,20 @@ async def optimize(
db: AsyncSession = Depends(get_db),
):
user_id_snapshot = str(current_user.id)
# Validate project, engine and the complete generation configuration before
# charging prompt credits or invoking the LLM.
proj_result = await db.execute(
select(Project).where(
Project.id == req.project_id,
Project.user_id == user_id_snapshot,
Project.deleted_at.is_(None),
).limit(1)
)
project = proj_result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
log_generation_record_config_event(
event_type=GenerationRecordEventTypeEnum.PROMPT_CONFIG_VALIDATE_START,
event_status=LogEventStatusEnum.STARTED,
source=GenerationRecordConfigSourceEnum.PROMPT_OPTIMIZE,
record=GenerationRecord(
id=req.idempotency_key or "pending",
user_id=user_id_snapshot,
project_id=req.project_id,
original_prompt=req.prompt,
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,
include_media_references=bool(req.include_media_references),
media_references=json.dumps(req.references, ensure_ascii=False) if req.references else None,
engine_id=req.engine_id,
),
detail={
"project_id": req.project_id,
"gen_type": req.gen_type.value,
"engine_id": req.engine_id,
"include_media_references": bool(req.include_media_references),
"reference_count": len(req.references or []),
"source": GenerationRecordConfigSourceEnum.PROMPT_OPTIMIZE.value,
},
)
# Idempotency must be checked before the temporary prompt-credit hold. The
# key is bound to one immutable generation configuration; reusing it with a
# different engine, parameter set or attachment selection is rejected.
if req.idempotency_key:
existing = await db.execute(
select(GenerationRecord, Project.name)
.join(Project, GenerationRecord.project_id == Project.id)
.where(
GenerationRecord.user_id == user_id_snapshot,
GenerationRecord.deleted_at.is_(None),
Project.deleted_at.is_(None),
GenerationRecord.idempotency_key == req.idempotency_key,
)
.order_by(GenerationRecord.created_at.desc())
.limit(1)
)
row = existing.first()
if row:
existing_record, project_name = row
if not _idempotency_config_matches(existing_record, req):
raise HTTPException(
status_code=409,
detail="幂等键已绑定其他生成配置,请重新提交",
)
if not existing_record.optimized_prompt or not _record_config_complete(existing_record):
raise HTTPException(
status_code=409,
detail="幂等记录配置不完整,请使用新的幂等键重新提交",
)
refs = await resolve_private_portrait_reference_display_urls(
db,
json.loads(existing_record.media_references) if existing_record.media_references else None,
user_id=user_id_snapshot,
)
return OptimizeResult(
optimized_prompt=existing_record.optimized_prompt,
text_credits_cost=existing_record.text_credits_cost or 0.0,
text_tokens_used=existing_record.text_tokens_used or 0,
record=_record_to_out(existing_record, project_name, refs_override=refs),
)
if req.gen_type == GenerationType.video:
if req.duration not in DURATIONS:
raise HTTPException(status_code=400, detail=f"视频时长必须为{DURATIONS}秒之一")
if req.aspect_ratio not in ASPECT_RATIOS:
raise HTTPException(status_code=400, detail="不支持的画面比例")
if req.resolution not in RESOLUTIONS:
raise HTTPException(status_code=400, detail="不支持的分辨率")
engine = await get_video_engine(db, req.engine_id)
_validate_video_engine_selection(
engine,
aspect_ratio=req.aspect_ratio,
resolution=req.resolution,
duration=int(req.duration),
)
else:
if req.image_size not in IMAGE_SIZES:
raise HTTPException(status_code=400, detail=f"图片分辨率必须为{IMAGE_SIZES}之一")
if not req.image_proportion or not req.image_px:
raise HTTPException(status_code=400, detail="图片生成需要指定比例和像素尺寸")
engine = await get_image_engine(db, req.engine_id)
_validate_image_engine_selection(engine, image_size=req.image_size)
project_name_snapshot = str(project.name)
project_industry_snapshot = str(project.industry or "")
engine_snapshot_source = SimpleNamespace(
**{
key: value
for key, value in vars(engine).items()
if key != "_sa_instance_state"
}
)
reference_usage = calculate_media_reference_usage(
json.dumps(req.references, ensure_ascii=False) if req.references else None,
include=bool(req.include_media_references),
)
validate_media_reference_usage_for_engine(
reference_usage,
gen_type=req.gen_type.value,
engine=engine,
)
log_generation_record_config_event(
event_type=GenerationRecordEventTypeEnum.PROMPT_CONFIG_VALIDATE_SUCCESS,
event_status=LogEventStatusEnum.SUCCESS,
source=GenerationRecordConfigSourceEnum.PROMPT_OPTIMIZE,
record=GenerationRecord(
id=req.idempotency_key or "pending",
user_id=user_id_snapshot,
project_id=req.project_id,
original_prompt=req.prompt,
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,
include_media_references=bool(req.include_media_references),
media_references=json.dumps(req.references, ensure_ascii=False) if req.references else None,
engine_id=req.engine_id,
),
detail={
"project_id": req.project_id,
"gen_type": req.gen_type.value,
"engine_id": req.engine_id,
"include_media_references": bool(req.include_media_references),
"reference_count": len(req.references or []),
"media_reference_usage": getattr(reference_usage, "__dict__", None) or str(reference_usage),
"source": GenerationRecordConfigSourceEnum.PROMPT_OPTIMIZE.value,
},
)
record_id_value = generate_id()
prompt_attempt_no = 1
llm_billing_context = LlmBillingContext(
service_result = await optimize_generation_prompt(
db,
req=req,
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,
req.prompt,
user_id=user_id_snapshot,
industry_key=project_industry_snapshot,
duration=req.duration 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,
references=req.references,
gen_type=req.gen_type.value,
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()
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
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
provider_resolution, upscale_enabled, upscale_snapshot_json = await build_video_upscale_snapshot(
db,
target_resolution=req.resolution,
aspect_ratio=req.aspect_ratio,
supported_provider_resolutions=parse_json_list(
engine_snapshot_source.supported_resolutions,
[],
),
)
record.provider_generation_resolution = provider_resolution
record.video_upscale_enabled_snapshot = upscale_enabled
record.video_upscale_snapshot_json = upscale_snapshot_json
else:
record.provider_generation_resolution = None
record.video_upscale_enabled_snapshot = False
record.video_upscale_snapshot_json = None
freeze_generation_record_config_with_log(
record,
engine=engine_snapshot_source,
source=GenerationRecordConfigSourceEnum.PROMPT_OPTIMIZE,
)
await db.flush()
billing = await settle_success(
db,
llm_billing_context,
usage=token_usage,
description=f"提示词优化 - {project_name_snapshot}",
)
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()
return record_id_snapshot
try:
record_id_snapshot = await _persist_optimized_result()
except Exception as first_exc:
await db.rollback()
logger.exception(
"prompt optimize local persistence/settlement failed after provider success; retry once: record_id=%s",
record_id_value,
)
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)
.join(Project, GenerationRecord.project_id == Project.id)
.where(GenerationRecord.id == record_id_snapshot)
.where(
GenerationRecord.id == service_result.record_id,
GenerationRecord.user_id == user_id_snapshot,
GenerationRecord.deleted_at.is_(None),
Project.deleted_at.is_(None),
)
.limit(1)
)
refreshed_row = refreshed.first()