交易流水对账明细导出完成

This commit is contained in:
2026-06-25 16:53:04 +08:00
parent 8aafd7de32
commit 30db7d3bf5
34 changed files with 2491 additions and 327 deletions
@@ -4,23 +4,36 @@ import re
from dataclasses import asdict, dataclass
from typing import Any, Mapping
from sqlalchemy import func, select
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.credit_record import CreditRecordBillingScene, CreditRecordChargeKind, CreditRecordOwnerType, CreditRecordSourceModule
from app.models.credit_record import CreditRecord
from app.models.generation_record import GenerationRecord
from app.models.module_generation_step import ModuleGenerationStep
from app.models.token_usage import TokenUsage
from app.models.system_config import SystemConfig
from app.services.credit_record_meta_service import (
CreditRecordMeta,
build_generation_media_meta,
build_generation_record_prompt_meta,
build_module_step_prompt_meta,
build_shot_video_analysis_meta,
)
from app.services.credits import calc_image_credits, calc_text_credits, calc_video_credits, deduct_credits
CHARGE_TEXT_PROMPT = "text_prompt"
CHARGE_FILE_PARSE = "file_parse"
CHARGE_VISION_INPUT = "vision_input"
CHARGE_MEDIA = "media"
CHARGE_TEXT_PROMPT = CreditRecordChargeKind.TEXT_PROMPT.value
CHARGE_FILE_PARSE = CreditRecordChargeKind.FILE_PARSE.value
CHARGE_VISION_INPUT = CreditRecordChargeKind.VISION_INPUT.value
CHARGE_MEDIA = CreditRecordChargeKind.MEDIA.value
CHARGE_VIDEO_ANALYSIS = CreditRecordChargeKind.VIDEO_ANALYSIS.value
OWNER_GENERATION_RECORD = "generation_record"
OWNER_CHAT_GENERATION_TASK = "chat_generation_task"
OWNER_MODULE_GENERATION_STEP = "module_generation_step"
OWNER_GENERATION_RECORD = CreditRecordOwnerType.GENERATION_RECORD.value
OWNER_CHAT_GENERATION_TASK = CreditRecordOwnerType.CHAT_GENERATION_TASK.value
OWNER_MODULE_GENERATION_STEP = CreditRecordOwnerType.MODULE_GENERATION_STEP.value
OWNER_SHOT_REPLICATE_TASK_SET = CreditRecordOwnerType.SHOT_REPLICATE_TASK_SET.value
OWNER_SHOT_REPLICATE_SEGMENT = CreditRecordOwnerType.SHOT_REPLICATE_SEGMENT.value
_BIZ_KEY_PATTERN = re.compile(
r"^(?P<owner_type>[^:]+):(?P<owner_id>[^:]+):attempt:(?P<attempt_no>\d+):(?P<charge_kind>[^:]+):(?P<action>charge|refund)$"
@@ -169,10 +182,12 @@ async def deduct_credits_locked_once(
charge_key: str,
biz_key: str | None = None,
attempt_no: int | None = None,
record_meta: CreditRecordMeta | dict | None = None,
) -> BillingItem:
"""按 biz_key 做幂等扣费。
charge_key 只保留为业务分类;正式幂等以 biz_key 为准。
record_meta 负责把业务归属、模块、步骤、token、模型快照写入 CreditRecord。
"""
amount = _round2(amount)
if amount <= 0:
@@ -197,6 +212,7 @@ async def deduct_credits_locked_once(
description=description,
related_id=related_id,
biz_key=biz_key,
record_meta=record_meta,
)
return BillingItem(charge_key=charge_key, amount=amount, charged=True, biz_key=biz_key, attempt_no=attempt_no)
@@ -208,7 +224,7 @@ async def charge_chatapi_prompt_usage(
usage: Mapping[str, Any],
project_name: str | None = None,
) -> BillingSummary:
"""提示词整理扣费仍按记录维度一次性幂等,不参与生成失败媒体退款。"""
"""项目记录提示词整理扣费不参与生成失败媒体退款。"""
project_name = project_name or "AI生成任务"
items: list[BillingItem] = []
@@ -219,12 +235,19 @@ async def charge_chatapi_prompt_usage(
input_tokens = _safe_int(usage.get("input_tokens"))
output_tokens = _safe_int(usage.get("output_tokens"))
text_credits = await calc_text_credits(db, input_tokens, output_tokens)
text_meta = await build_generation_record_prompt_meta(
db,
record_id=record.id,
attempt_no=attempt_no,
charge_kind=CHARGE_TEXT_PROMPT,
usage=usage,
)
items.append(
await deduct_credits_locked_once(
db,
user_id=record.user_id,
amount=text_credits,
description=f"ChatAPI提示词整理",
description=f"提示词优化 - {project_name}",
related_id=record.id,
charge_key=CHARGE_TEXT_PROMPT,
biz_key=build_credit_biz_key(
@@ -235,17 +258,25 @@ async def charge_chatapi_prompt_usage(
action="charge",
),
attempt_no=attempt_no,
record_meta=text_meta,
)
)
file_tokens = usage.get("file_parse_tokens") or usage.get("file_tokens") or usage.get("document_tokens") or 0
file_parse_credits = await _calc_optional_token_credits(db, _safe_int(file_tokens), "file_parse_credits_per_1000_tokens")
file_meta = await build_generation_record_prompt_meta(
db,
record_id=record.id,
attempt_no=attempt_no,
charge_kind=CHARGE_FILE_PARSE,
usage={**dict(usage), "total_tokens": _safe_int(file_tokens), "input_tokens": _safe_int(file_tokens), "output_tokens": 0},
)
items.append(
await deduct_credits_locked_once(
db,
user_id=record.user_id,
amount=file_parse_credits,
description=f"文件解析Token",
description="文件解析Token",
related_id=record.id,
charge_key=CHARGE_FILE_PARSE,
biz_key=build_credit_biz_key(
@@ -256,17 +287,25 @@ async def charge_chatapi_prompt_usage(
action="charge",
),
attempt_no=attempt_no,
record_meta=file_meta,
)
)
vision_tokens = usage.get("vision_input_tokens") or usage.get("image_input_tokens") or usage.get("image_tokens") or 0
vision_input_credits = await _calc_optional_token_credits(db, _safe_int(vision_tokens), "vision_input_credits_per_1000_tokens")
vision_meta = await build_generation_record_prompt_meta(
db,
record_id=record.id,
attempt_no=attempt_no,
charge_kind=CHARGE_VISION_INPUT,
usage={**dict(usage), "total_tokens": _safe_int(vision_tokens), "input_tokens": _safe_int(vision_tokens), "output_tokens": 0},
)
items.append(
await deduct_credits_locked_once(
db,
user_id=record.user_id,
amount=vision_input_credits,
description=f"图片理解Token",
description="图片理解Token",
related_id=record.id,
charge_key=CHARGE_VISION_INPUT,
biz_key=build_credit_biz_key(
@@ -277,6 +316,7 @@ async def charge_chatapi_prompt_usage(
action="charge",
),
attempt_no=attempt_no,
record_meta=vision_meta,
)
)
@@ -297,7 +337,7 @@ async def charge_module_prompt_usage(
description: str,
attempt_no: int = 1,
) -> BillingSummary:
"""爆款开头复刻模块图片/视频 AI 提词扣文本积分。
"""爆款开头复刻/拆镜复刻模块图片/视频 AI 提词扣文本积分。
文本提词属于已经发生的 LLM 消费:
- 调用成功后按 input_tokens + output_tokens 扣费。
@@ -314,6 +354,7 @@ async def charge_module_prompt_usage(
charge_kind=CHARGE_TEXT_PROMPT,
action="charge",
)
record_meta = await build_module_step_prompt_meta(db, step_id=step_id, attempt_no=attempt_no, usage=usage)
item = await deduct_credits_locked_once(
db,
user_id=user_id,
@@ -323,10 +364,92 @@ async def charge_module_prompt_usage(
charge_key=CHARGE_TEXT_PROMPT,
biz_key=biz_key,
attempt_no=attempt_no,
record_meta=record_meta,
)
result = await db.execute(select(ModuleGenerationStep).where(ModuleGenerationStep.id == step_id).limit(1))
step = result.scalar_one_or_none()
if step:
step.token_usage_id = record_meta.token_usage_id
step.model_config_id = usage.get("model_config_id")
step.input_tokens = record_meta.input_tokens
step.output_tokens = record_meta.output_tokens
step.total_tokens = record_meta.total_tokens
step.text_credits_cost = text_credits
return BillingSummary(record_id=step_id, user_id=user_id, items=[item])
async def charge_shot_video_analysis_usage(
db: AsyncSession,
*,
user_id: str,
owner_type: str,
owner_id: str,
usage: Mapping[str, Any],
description: str,
billing_scene: str,
source_project_id: str | None = None,
source_step_id: str | None = None,
attempt_no: int | None = None,
) -> BillingSummary:
"""拆镜复刻视频分析扣分析积分。
视频分析属于“文字提示词 + 视频素材”的模型调用类消费,
按 input_tokens + output_tokens 参考文本积分规则计费,
但账务归类为 analysis/video_analysis,避免混入提词优化统计。
"""
attempt_no = attempt_no or await get_next_credit_attempt_no(
db,
owner_type=owner_type,
owner_id=owner_id,
charge_kind=CHARGE_VIDEO_ANALYSIS,
)
input_tokens = _safe_int(usage.get("input_tokens"))
output_tokens = _safe_int(usage.get("output_tokens"))
amount = await calc_text_credits(db, input_tokens, output_tokens)
biz_key = build_credit_biz_key(
owner_type=owner_type,
owner_id=owner_id,
attempt_no=attempt_no,
charge_kind=CHARGE_VIDEO_ANALYSIS,
action="charge",
)
record_meta = await build_shot_video_analysis_meta(
db,
owner_type=owner_type,
owner_id=owner_id,
attempt_no=attempt_no,
usage=usage,
billing_scene=billing_scene,
source_project_id=source_project_id,
source_step_id=source_step_id,
)
item = await deduct_credits_locked_once(
db,
user_id=user_id,
amount=amount,
description=description,
related_id=owner_id,
charge_key=CHARGE_VIDEO_ANALYSIS,
biz_key=biz_key,
attempt_no=attempt_no,
record_meta=record_meta,
)
if record_meta.token_usage_id:
result = await db.execute(select(TokenUsage).where(TokenUsage.id == record_meta.token_usage_id).limit(1))
token_usage = result.scalar_one_or_none()
if token_usage:
token_usage.owner_type = token_usage.owner_type or owner_type
token_usage.owner_id = token_usage.owner_id or owner_id
token_usage.biz_key = token_usage.biz_key or biz_key
token_usage.source_module = token_usage.source_module or CreditRecordSourceModule.SHOT_REPLICATE.value
token_usage.source_step_code = token_usage.source_step_code or "video_analysis"
return BillingSummary(record_id=owner_id, user_id=user_id, items=[item])
async def charge_generation_media_by_params(
db: AsyncSession,
*,
@@ -341,6 +464,11 @@ async def charge_generation_media_by_params(
description_prefix: str = "AI创作-",
owner_type: str = OWNER_CHAT_GENERATION_TASK,
attempt_no: int | None = None,
source_module: str | None = None,
source_project_id: str | None = None,
source_step_id: str | None = None,
source_step_code: str | None = None,
billing_scene: str | None = None,
) -> BillingSummary:
"""图片/视频媒体生成扣费。
@@ -363,6 +491,19 @@ async def charge_generation_media_by_params(
action="charge",
)
items: list[BillingItem] = []
record_meta = await build_generation_media_meta(
db,
owner_type=owner_type,
owner_id=record_id,
attempt_no=attempt_no,
gen_type=gen_type,
engine_id=engine_id,
source_module=source_module,
source_project_id=source_project_id,
source_step_id=source_step_id,
source_step_code=source_step_code,
billing_scene=billing_scene,
)
if gen_type == "image":
size = image_size or "2K"
@@ -377,6 +518,7 @@ async def charge_generation_media_by_params(
charge_key=CHARGE_MEDIA,
biz_key=biz_key,
attempt_no=attempt_no,
record_meta=record_meta,
)
)
elif gen_type == "video":
@@ -391,6 +533,7 @@ async def charge_generation_media_by_params(
charge_key=CHARGE_MEDIA,
biz_key=biz_key,
attempt_no=attempt_no,
record_meta=record_meta,
)
)
else:
@@ -419,4 +562,5 @@ async def charge_generation_media_for_record(
description_prefix=description_prefix,
owner_type=OWNER_GENERATION_RECORD,
attempt_no=attempt_no,
source_module=CreditRecordSourceModule.GENERATION_RECORD.value,
)