积分冻结释放

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
+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)