积分冻结释放
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user