大量优化修改扣积分规则
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user