大量优化修改扣积分规则

This commit is contained in:
Azmat@qq.com
2026-09-04 17:20:22 +08:00
parent 3e904479d9
commit 15718a60bf
41 changed files with 1590 additions and 429 deletions
+150 -14
View File
@@ -10,8 +10,9 @@
平台成本配在 metadata.pricing.base_cost_yuan(未配=0,毛利报表按未知处理)。
· 配音:metadata.pricing = {"mode":"per_chars","chars_per_unit":500,"points_per_unit":10,
"min_units":1,"base_cost_yuan_per_unit":...},按字符数阶梯。
· 视频:metadata.pricing 是火山**成本价表**(元/百万tokens,见 apps/ai/video_pricing.py),
用户价 = ¥成本 × video_margin_multiplier → 积分。按真实 usage.total_tokens 结算
· 视频:优先 metadata.points_pricing 挂牌(分辨率积分/秒 × 时长;可配含视频参考价);
未挂牌才走 metadata.pricing 火山成本 × 毛利 → 积分,并按 usage.total_tokens true-up
挂牌任务预留=精确积分、结算认下单快照;token 任务预留仍 × video_reserve_buffer。
取整(前后端必须逐字一致,前端镜像在 frontend/src/components/free-create/constants.ts):
¥成本先 quantize 到 0.01(video_pricing.calculate_cost 现状)→ ×毛利 ×汇率 → ROUND_HALF_UP
@@ -100,19 +101,38 @@ def _pricing_meta(model_config) -> dict:
def quote_flat(model_config, *, units: int = 1, team=None) -> Quote:
"""文本(次)/ 图像(张)。unit_price = 积分/单位;≤0 回落 10 积分。team 传入则乘团队价格系数。"""
unit_points = Decimal(str(getattr(model_config, "unit_price", 0) or 0))
"""文本(次)/ 图像(张)。优先 metadata.points_pricing 挂牌;否则 unit_price;≤0 回落默认积分。team 传入则乘团队价格系数。"""
from apps.billing.points_rules import image_points_per_unit, text_points_per_call
capability = str(getattr(model_config, "capability", "") or "")
listed = None
if capability == "image":
listed = image_points_per_unit(model_config)
elif capability in {"text", "vision"}:
listed = text_points_per_call(model_config)
listed_from_hang = listed is not None and listed > 0
if listed_from_hang:
unit_points = Decimal(listed)
else:
unit_points = Decimal(str(getattr(model_config, "unit_price", 0) or 0))
if unit_points <= 0:
is_image = str(getattr(model_config, "capability", "")) == "image"
is_image = capability == "image"
unit_points = FLAT_FALLBACK_POINTS_IMAGE if is_image else FLAT_FALLBACK_POINTS
unit_points = unit_points.quantize(Decimal("1"), rounding=ROUND_HALF_UP)
base_per_unit = Decimal(str(_pricing_meta(model_config).get("base_cost_yuan") or 0))
multiplier = team_price_multiplier(team)
points = apply_team_price(max(unit_points * units, Decimal("1")), multiplier)
# 挂牌:image=per_image / text=per_call;未挂牌仍记 flat(unit_price 或默认)
if listed_from_hang and capability == "image":
rule = "per_image"
elif listed_from_hang and capability in {"text", "vision"}:
rule = "per_call"
else:
rule = "flat"
return Quote(
points=points,
base_cost_yuan=base_per_unit * units,
meta={"rule": "flat", "units": units, "unit_points": str(unit_points), "price_multiplier": str(multiplier), "rate": str(get_billing_config().points_per_yuan)},
meta={"rule": rule, "units": units, "unit_points": str(unit_points), "price_multiplier": str(multiplier), "rate": str(get_billing_config().points_per_yuan)},
)
@@ -176,25 +196,141 @@ def quote_video_from_cost(cost_yuan: Decimal, *, tokens: int = 0, multiplier: De
def quote_video_estimate(model_config, *, aspect_ratio: str, resolution: str, duration: int, references: list, team=None) -> tuple[int, Quote]:
"""预估:apps/ai/video_pricing 的 ¥成本 × 毛利 → 积分 → ×团队当前系数。返回 (tokens, Quote)。"""
from apps.ai.video_pricing import estimate_video_cost
"""预估视频积分。优先老板挂牌(分辨率×秒);未配置则回落 token×成本×毛利。返回 (tokens占位, Quote)。"""
from apps.ai.video_pricing import estimate_video_cost, has_video_reference
from apps.billing.points_rules import quote_points_per_second, video_points_per_second
with_ref = has_video_reference(references)
pps = video_points_per_second(model_config, resolution=resolution, with_ref_video=with_ref)
multiplier = team_price_multiplier(team)
if pps is not None:
list_points = quote_points_per_second(points_per_second=pps, duration=duration)
points = apply_team_price(list_points, multiplier)
return 0, Quote(
points=points,
base_cost_yuan=Decimal("0"),
meta={
"rule": "points_per_second",
"resolution": resolution,
"duration": duration,
"with_ref_video": with_ref,
"points_per_second": pps,
"price_multiplier": str(multiplier),
"rate": str(get_billing_config().points_per_yuan),
},
)
tokens, cost_yuan = estimate_video_cost(
model_config, aspect_ratio=aspect_ratio, resolution=resolution, duration=duration, references=references
)
return tokens, quote_video_from_cost(cost_yuan, tokens=tokens, multiplier=team_price_multiplier(team))
return tokens, quote_video_from_cost(cost_yuan, tokens=tokens, multiplier=multiplier)
def quote_video_actual(model_config, *, tokens: int, with_video_ref: bool, resolution: str, multiplier: Decimal | None = None) -> Quote:
"""按真实 usage.total_tokens 结算(与预估同一张成本表+同一毛利)。
multiplier 必须传**下单时的快照系数**(request_payload.price_multiplier),不读团队当前值。"""
def quote_video_actual(
model_config,
*,
tokens: int,
with_video_ref: bool,
resolution: str,
multiplier: Decimal | None = None,
duration: int | None = None,
listed_points_per_second: int | None = None,
) -> Quote:
"""结算视频积分。优先老板挂牌秒价(与预估同口径);未挂牌才按 tokens×成本表。
multiplier / listed_points_per_second 必须用**下单快照**,中途改价不影响在途任务。"""
from apps.ai.video_pricing import tokens_to_cost
from apps.billing.points_rules import quote_points_per_second, video_points_per_second
multiplier = multiplier if multiplier is not None else Decimal("1")
pps = listed_points_per_second
if pps is None:
pps = video_points_per_second(model_config, resolution=resolution, with_ref_video=with_video_ref)
if pps is not None and duration is not None and int(duration) > 0:
list_points = quote_points_per_second(points_per_second=int(pps), duration=int(duration))
points = apply_team_price(list_points, multiplier)
return Quote(
points=points,
base_cost_yuan=Decimal("0"),
meta={
"rule": "points_per_second",
"resolution": resolution,
"duration": int(duration),
"with_ref_video": with_video_ref,
"points_per_second": int(pps),
"price_multiplier": str(multiplier),
"rate": str(get_billing_config().points_per_yuan),
},
)
cost_yuan = tokens_to_cost(model_config, tokens, with_video_ref=with_video_ref, resolution=resolution)
return quote_video_from_cost(cost_yuan, tokens=tokens, multiplier=multiplier)
def video_reserve_amount(points: Decimal) -> Decimal:
"""视频预留额 = 预估积分 × buffer(真实 tokens 可能略超预估;ledger 禁超预留扣费)。"""
def video_reserve_amount(points: Decimal, *, rule: str | None = None) -> Decimal:
"""视频预留额。挂牌秒价=精确积分(无 buffer);token 估价才 × buffer(真实 tokens 可能略超预估)。"""
points = Decimal(str(points)).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
if rule == "points_per_second":
return max(points, Decimal("1")) if points > 0 else Decimal("0")
buffer = get_billing_config().video_reserve_buffer
return (points * buffer).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
def video_quote_payload(quote: Quote) -> dict:
"""下单时写入 request_payload 的计价快照,结算只认这些字段。"""
meta = quote.meta or {}
out = {
"pricing_rule": meta.get("rule") or "",
"price_multiplier": meta.get("price_multiplier", "1"),
"points_per_yuan_snapshot": meta.get("rate", ""),
}
if meta.get("rule") == "points_per_second":
out["listed_points_per_second"] = int(meta.get("points_per_second") or 0)
out["with_ref_video"] = bool(meta.get("with_ref_video"))
if meta.get("duration") is not None:
out["billing_duration"] = int(meta["duration"])
return out
def settle_video_from_payload(model_config, *, payload: dict, tokens: int = 0) -> Quote:
"""统一视频结算:挂牌任务按快照秒价×时长扣;token 任务按 usage true-up。
挂牌不依赖 tokens——用量缺失也能按后台填的积分扣到位。"""
payload = payload or {}
multiplier = Decimal(str(payload.get("price_multiplier") or "1"))
resolution = str(payload.get("resolution") or "720p")
duration = payload.get("billing_duration")
if duration in (None, ""):
duration = payload.get("duration")
try:
duration_i = int(duration or 0) or None
except (TypeError, ValueError):
duration_i = None
with_ref = payload.get("with_ref_video")
if with_ref is None:
with_ref = any((r or {}).get("type") == "video" for r in (payload.get("references") or []))
listed = payload.get("listed_points_per_second")
try:
listed_i = int(listed) if listed not in (None, "") else None
except (TypeError, ValueError):
listed_i = None
rule = str(payload.get("pricing_rule") or "")
# 挂牌快照在:即使 usage.tokens=0 也按秒价结算(与预估同数)
if rule == "points_per_second" or listed_i is not None:
return quote_video_actual(
model_config,
tokens=int(tokens or 0),
with_video_ref=bool(with_ref),
resolution=resolution,
multiplier=multiplier,
duration=duration_i,
listed_points_per_second=listed_i,
)
if int(tokens or 0) <= 0:
# 无 usage 且无挂牌:由调用方回落 estimated_cost
return Quote(points=Decimal("0"), base_cost_yuan=Decimal("0"), meta={"rule": "missing_usage"})
# 无挂牌快照 → 纯 token 结算(即便模型后来配了挂牌也不改在途口径)
from apps.ai.video_pricing import tokens_to_cost
cost_yuan = tokens_to_cost(
model_config, int(tokens), with_video_ref=bool(with_ref), resolution=resolution,
)
return quote_video_from_cost(cost_yuan, tokens=int(tokens), multiplier=multiplier)