大量优化修改扣积分规则

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
+207
View File
@@ -0,0 +1,207 @@
"""老板挂牌积分规则(不谈供应商成本)。
存在 ModelConfig.metadata["points_pricing"]:
· image → mode=per_image, points_per_image
· text → mode=per_call, points_per_call
· video → mode=per_second, tiers=[{resolution, points_per_second, points_per_second_with_ref?}]
能力(可选分辨率/时长)在 metadata["capabilities"]。
未配置 points_pricing 时由 pricing.quote_* 回落旧口径(unit_price / token 价表)。
"""
from __future__ import annotations
from decimal import Decimal, ROUND_HALF_UP
from typing import Any
def _dict(value: Any) -> dict:
return value if isinstance(value, dict) else {}
def _int_points(value: Any) -> int | None:
if value is None or value is False:
return None
try:
n = int(Decimal(str(value)).quantize(Decimal("1"), rounding=ROUND_HALF_UP))
except Exception: # noqa: BLE001
return None
return n if n >= 0 else None
def points_pricing_of(model_config) -> dict:
return _dict(_dict(getattr(model_config, "metadata", None)).get("points_pricing"))
def capabilities_of(model_config) -> dict:
return _dict(_dict(getattr(model_config, "metadata", None)).get("capabilities"))
def has_points_pricing(model_config) -> bool:
pricing = points_pricing_of(model_config)
mode = str(pricing.get("mode") or "").strip()
if mode == "per_image":
return _int_points(pricing.get("points_per_image")) is not None
if mode == "per_call":
return _int_points(pricing.get("points_per_call")) is not None
if mode == "per_second":
tiers = pricing.get("tiers")
return isinstance(tiers, list) and any(isinstance(t, dict) for t in tiers)
# 兼容:只填了数字字段
return (
_int_points(pricing.get("points_per_image")) is not None
or _int_points(pricing.get("points_per_call")) is not None
)
def image_points_per_unit(model_config) -> int | None:
pricing = points_pricing_of(model_config)
n = _int_points(pricing.get("points_per_image"))
if n is not None:
return n
# 回落 unit_price(积分/张)
return _int_points(getattr(model_config, "unit_price", None))
def text_points_per_call(model_config) -> int | None:
pricing = points_pricing_of(model_config)
n = _int_points(pricing.get("points_per_call"))
if n is not None:
return n
return _int_points(getattr(model_config, "unit_price", None))
def video_tier(model_config, *, resolution: str, with_ref_video: bool = False) -> dict | None:
pricing = points_pricing_of(model_config)
tiers = pricing.get("tiers")
if not isinstance(tiers, list):
return None
resolution = str(resolution or "").strip().lower()
hit = None
for raw in tiers:
if not isinstance(raw, dict):
continue
if str(raw.get("resolution") or "").strip().lower() == resolution:
hit = raw
break
return hit
def video_points_per_second(model_config, *, resolution: str, with_ref_video: bool = False) -> int | None:
tier = video_tier(model_config, resolution=resolution, with_ref_video=with_ref_video)
if not tier:
return None
if with_ref_video:
n = _int_points(tier.get("points_per_second_with_ref"))
if n is not None:
return n
return _int_points(tier.get("points_per_second"))
def quote_points_per_second(*, points_per_second: int, duration: int) -> Decimal:
seconds = max(int(duration or 0), 0)
pts = max(int(points_per_second) * seconds, 1) if seconds > 0 else 0
return Decimal(pts)
def quote_points_units(*, points_per_unit: int, units: int) -> Decimal:
count = max(int(units or 0), 0)
if count <= 0:
return Decimal("0")
return Decimal(max(int(points_per_unit) * count, 1))
def normalize_points_pricing_for_save(capability: str, metadata: dict | None) -> dict:
"""整理 metadata:补齐 points_pricing.mode,并让 unit_price 与挂牌积分对齐(兼容旧读法)。"""
root = dict(metadata or {})
pricing = dict(_dict(root.get("points_pricing")))
caps = dict(_dict(root.get("capabilities")))
capability = str(capability or "")
if capability == "image":
pts = _int_points(pricing.get("points_per_image"))
if pts is not None:
pricing["mode"] = "per_image"
pricing["points_per_image"] = pts
root["points_pricing"] = pricing
return root
if capability in {"text", "vision"}:
pts = _int_points(pricing.get("points_per_call"))
if pts is not None:
pricing["mode"] = "per_call"
pricing["points_per_call"] = pts
root["points_pricing"] = pricing
return root
if capability == "video":
tiers_in = pricing.get("tiers") if isinstance(pricing.get("tiers"), list) else []
tiers: list[dict] = []
resolutions: list[str] = []
for raw in tiers_in:
if not isinstance(raw, dict):
continue
res = str(raw.get("resolution") or "").strip()
pps = _int_points(raw.get("points_per_second"))
if not res or pps is None:
continue
row = {"resolution": res, "points_per_second": pps}
with_ref = _int_points(raw.get("points_per_second_with_ref"))
if with_ref is not None:
row["points_per_second_with_ref"] = with_ref
tiers.append(row)
if res not in resolutions:
resolutions.append(res)
if tiers:
pricing["mode"] = "per_second"
pricing["tiers"] = tiers
# 能力分辨率与价表对齐,前端下拉只出有价的档
if not caps.get("resolutions"):
caps["resolutions"] = resolutions
else:
# 保留已配能力,但确保价表分辨率都在里面
existing = [str(x) for x in (caps.get("resolutions") or [])]
for res in resolutions:
if res not in existing:
existing.append(res)
caps["resolutions"] = existing
root["points_pricing"] = pricing
root["capabilities"] = caps
return root
root["points_pricing"] = pricing
return root
def points_pricing_errors(capability: str, metadata: Any) -> tuple[str, ...]:
"""可选校验:填了 points_pricing 就要完整。"""
root = _dict(metadata)
pricing = _dict(root.get("points_pricing"))
if not pricing:
return ()
errors: list[str] = []
mode = str(pricing.get("mode") or "").strip()
if capability == "image":
if _int_points(pricing.get("points_per_image")) is None and mode in {"", "per_image"}:
if "points_per_image" in pricing:
errors.append("points_pricing.points_per_image 必须是 ≥0 的整数积分")
elif capability in {"text", "vision"}:
if "points_per_call" in pricing and _int_points(pricing.get("points_per_call")) is None:
errors.append("points_pricing.points_per_call 必须是 ≥0 的整数积分")
elif capability == "video":
tiers = pricing.get("tiers")
if tiers is None:
return tuple(errors)
if not isinstance(tiers, list) or not tiers:
errors.append("视频 points_pricing.tiers 至少配置一档分辨率积分")
else:
for i, raw in enumerate(tiers):
if not isinstance(raw, dict):
errors.append(f"tiers[{i}] 必须是对象")
continue
if not str(raw.get("resolution") or "").strip():
errors.append(f"tiers[{i}].resolution 不能为空")
if _int_points(raw.get("points_per_second")) is None:
errors.append(f"tiers[{i}].points_per_second 必须是 ≥0 的整数积分")
if "points_per_second_with_ref" in raw and _int_points(raw.get("points_per_second_with_ref")) is None:
errors.append(f"tiers[{i}].points_per_second_with_ref 必须是 ≥0 的整数积分")
return tuple(errors)
+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)
+93
View File
@@ -487,3 +487,96 @@ class TeamPriceMultiplierTests(TestCase):
client = APIClient()
client.force_authenticate(self.user)
self.assertEqual(client.get("/api/billing/config/").json()["team_price_multiplier"], "0.80")
class ListedPointsPricingTests(TestCase):
"""老板挂牌秒价:预留精确、结算认快照,不跟 tokens/中途改价跑偏。"""
def setUp(self):
from apps.billing.pricing import invalidate_billing_config_cache
invalidate_billing_config_cache()
self.user = User.objects.create_user(username="listed-pts", password="p")
self.team = Team.objects.create(name="listed", owner=self.user, price_multiplier=Decimal("1.00"))
TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER)
CreditAccount.objects.create(team=self.team, balance=Decimal("10000"), reserved_balance=Decimal("0"))
provider, _ = ModelProvider.objects.get_or_create(name="volcengine", defaults={"display_name": "V"})
self.model = ModelConfig.objects.create(
provider=provider,
name="doubao-seedance-listed",
display_name="Listed Mini",
capability=ModelConfig.Capability.VIDEO,
unit_price=Decimal("0"),
status=ModelConfig.Status.ACTIVE,
metadata={
"capabilities": {"resolutions": ["480p", "720p"], "durations": [4, 5]},
"points_pricing": {
"mode": "per_second",
"tiers": [
{"resolution": "720p", "points_per_second": 10, "points_per_second_with_ref": 15},
{"resolution": "480p", "points_per_second": 6},
],
},
},
)
def test_estimate_and_exact_reserve(self):
from apps.billing.pricing import quote_video_estimate, video_reserve_amount
_tokens, quote = quote_video_estimate(
self.model, aspect_ratio="16:9", resolution="720p", duration=5, references=[], team=self.team,
)
self.assertEqual(quote.meta["rule"], "points_per_second")
self.assertEqual(quote.points, Decimal("50")) # 10 * 5
self.assertEqual(video_reserve_amount(quote.points, rule=quote.meta["rule"]), Decimal("50"))
# token 路径仍乘 buffer(默认 1.10)
self.assertEqual(video_reserve_amount(Decimal("50"), rule="video_tokens"), Decimal("55"))
def test_settle_snapshot_ignores_model_price_change_and_tokens(self):
from apps.billing.pricing import quote_video_estimate, settle_video_from_payload, video_quote_payload
_tokens, quote = quote_video_estimate(
self.model,
aspect_ratio="16:9",
resolution="720p",
duration=5,
references=[{"type": "video", "url": "http://x"}],
team=self.team,
)
# 含视频参考 → 15 * 5 = 75
self.assertEqual(quote.points, Decimal("75"))
payload = {
"resolution": "720p",
"duration": 5,
**video_quote_payload(quote),
"references": [{"type": "video"}],
}
# 中途把挂牌改成天价
meta = dict(self.model.metadata)
meta["points_pricing"] = {
"mode": "per_second",
"tiers": [{"resolution": "720p", "points_per_second": 999, "points_per_second_with_ref": 999}],
}
self.model.metadata = meta
self.model.save(update_fields=["metadata"])
settle = settle_video_from_payload(self.model, payload=payload, tokens=9_999_999)
self.assertEqual(settle.points, Decimal("75"))
self.assertEqual(settle.meta["rule"], "points_per_second")
settle0 = settle_video_from_payload(self.model, payload=payload, tokens=0)
self.assertEqual(settle0.points, Decimal("75"))
def test_flat_image_uses_points_per_image(self):
from apps.billing.pricing import quote_flat
provider, _ = ModelProvider.objects.get_or_create(name="volcengine", defaults={"display_name": "V"})
image = ModelConfig.objects.create(
provider=provider,
name="seedream-listed",
capability=ModelConfig.Capability.IMAGE,
unit_price=Decimal("0"),
status=ModelConfig.Status.ACTIVE,
metadata={"points_pricing": {"mode": "per_image", "points_per_image": 33}},
)
quote = quote_flat(image, units=2, team=self.team)
self.assertEqual(quote.points, Decimal("66"))
self.assertEqual(quote.meta.get("rule"), "per_image")