feat(billing): 计费商业化重构(积分制)+ 团队差异化调价
统一计价引擎 apps/billing/pricing.py:1积分=¥0.1,平台成本(¥)与用户价(积分)双记账, 视频按火山真实 usage.total_tokens 结算(true-up,终结¥1/段倒贴)+ 首发×1.5毛利系数。 Team.price_multiplier 差异化调价(jimeng同款):挂牌价×系数两步HALF_UP取整,视频类 按下单时的价格/汇率快照结算(中途改配不影响在途任务)。 BillingConfig 单例(汇率/毛利系数/预留buffer,admin可调即刻生效)。存量数据 ×10 rescale 迁移(RunPython+atomic,MySQL 迁移不可中断重跑)。开户赠送归零(DEFAULT_TRIAL_CREDITS=0, 商业决策)。audit_billing 加 I9(卖亏审计)。271 条测试 + tsc/build 全绿。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -190,8 +190,10 @@ class RechargePermissionTests(TestCase):
|
||||
client.force_authenticate(self.owner)
|
||||
response = client.post("/api/billing/recharge/", {"amount": "100"}, format="json")
|
||||
self.assertEqual(response.status_code, 201)
|
||||
# 积分制:支付 ¥100 × 10 = 到账 1000 积分;响应带换算快照
|
||||
self.assertEqual(response.json()["credited_points"], "1000")
|
||||
account = CreditAccount.objects.get(team=self.team)
|
||||
self.assertEqual(account.balance, Decimal("100.0000"))
|
||||
self.assertEqual(account.balance, Decimal("1000.0000"))
|
||||
|
||||
def test_member_cannot_recharge(self):
|
||||
client = APIClient()
|
||||
@@ -201,3 +203,287 @@ class RechargePermissionTests(TestCase):
|
||||
account = CreditAccount.objects.get(team=self.team)
|
||||
self.assertEqual(account.balance, Decimal("0.0000")) # 余额未变,越权被拦
|
||||
|
||||
|
||||
|
||||
class PricingEngineTests(TestCase):
|
||||
"""计价引擎(apps/billing/pricing):flat 回落 / 配音阶梯 / 视频毛利与取整 / 最低 1 积分。"""
|
||||
|
||||
def setUp(self):
|
||||
from apps.billing.pricing import invalidate_billing_config_cache
|
||||
|
||||
invalidate_billing_config_cache()
|
||||
provider, _ = ModelProvider.objects.get_or_create(name="volcengine", defaults={"display_name": "V"})
|
||||
self.text_model = ModelConfig.objects.create(
|
||||
provider=provider, name="pe-text", display_name="T", capability=ModelConfig.Capability.TEXT,
|
||||
unit_price=Decimal("10"), metadata={"pricing": {"mode": "flat", "base_cost_yuan": 0.1}},
|
||||
)
|
||||
self.audio_model = ModelConfig.objects.create(
|
||||
provider=provider, name="pe-audio", display_name="A", capability=ModelConfig.Capability.AUDIO,
|
||||
metadata={"pricing": {"mode": "per_chars", "chars_per_unit": 500, "points_per_unit": 10, "min_units": 1, "base_cost_yuan_per_unit": 0.25}},
|
||||
)
|
||||
|
||||
def test_flat_uses_unit_price_as_points(self):
|
||||
from apps.billing.pricing import quote_flat
|
||||
|
||||
quote = quote_flat(self.text_model)
|
||||
self.assertEqual(quote.points, Decimal("10"))
|
||||
self.assertEqual(quote.base_cost_yuan, Decimal("0.1"))
|
||||
# 多单位(单图批量按张)
|
||||
self.assertEqual(quote_flat(self.text_model, units=3).points, Decimal("30"))
|
||||
|
||||
def test_flat_fallback_when_unit_price_unset(self):
|
||||
from apps.billing.pricing import FLAT_FALLBACK_POINTS, quote_flat
|
||||
|
||||
self.text_model.unit_price = Decimal("0")
|
||||
quote = quote_flat(self.text_model)
|
||||
self.assertEqual(quote.points, FLAT_FALLBACK_POINTS)
|
||||
|
||||
def test_voiceover_char_tiers(self):
|
||||
from apps.billing.pricing import quote_voiceover
|
||||
|
||||
cases = {0: 1, 1: 1, 499: 1, 500: 1, 501: 2, 1500: 3, 1501: 4}
|
||||
for chars, units in cases.items():
|
||||
quote = quote_voiceover(self.audio_model, char_count=chars)
|
||||
self.assertEqual(quote.points, Decimal(units * 10), f"chars={chars}")
|
||||
self.assertEqual(quote.base_cost_yuan, Decimal("0.25") * units, f"chars={chars}")
|
||||
|
||||
def test_video_margin_and_rounding(self):
|
||||
from apps.billing.pricing import get_billing_config, quote_video_from_cost
|
||||
|
||||
cfg = get_billing_config()
|
||||
self.assertEqual(cfg.video_margin_multiplier, Decimal("1.50")) # 首发 ×1.5(商业决策)
|
||||
# ¥1.85 成本 × 1.5 × 10 = 27.75 → HALF_UP → 28 积分;base_cost 保留 ¥ 原值
|
||||
quote = quote_video_from_cost(Decimal("1.85"))
|
||||
self.assertEqual(quote.points, Decimal("28"))
|
||||
self.assertEqual(quote.base_cost_yuan, Decimal("1.85"))
|
||||
|
||||
def test_minimum_one_point(self):
|
||||
from apps.billing.pricing import quote_video_from_cost, yuan_to_points
|
||||
|
||||
self.assertEqual(quote_video_from_cost(Decimal("0.001")).points, Decimal("1"))
|
||||
self.assertEqual(yuan_to_points(Decimal("0")), Decimal("0")) # 0 成本不强收
|
||||
|
||||
def test_video_reserve_buffer(self):
|
||||
from apps.billing.pricing import video_reserve_amount
|
||||
|
||||
# 28 × 1.10 = 30.8 → 31 积分
|
||||
self.assertEqual(video_reserve_amount(Decimal("28")), Decimal("31"))
|
||||
|
||||
|
||||
class TeamMonthlyLimitTests(TestCase):
|
||||
"""Team.monthly_credit_limit 真管控:None/-1/0 均不管控,仅正数=硬上限(含在途预留)。"""
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username="tml-owner", password="p")
|
||||
self.team = Team.objects.create(name="TML", owner=self.user)
|
||||
TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER)
|
||||
CreditAccount.objects.create(team=self.team, balance=Decimal("1000.0000"))
|
||||
provider, _ = ModelProvider.objects.get_or_create(name="volcengine", defaults={"display_name": "V"})
|
||||
self.model = ModelConfig.objects.create(
|
||||
provider=provider, name="tml-m", display_name="M", capability=ModelConfig.Capability.TEXT,
|
||||
)
|
||||
|
||||
def _task(self, key):
|
||||
return AITask.objects.create(
|
||||
team=self.team, created_by=self.user, task_type=AITask.Type.SCRIPT_GENERATION,
|
||||
model_config=self.model, idempotency_key=f"tml-{key}",
|
||||
)
|
||||
|
||||
def _reserve(self, key, amount):
|
||||
return reserve_credit(team=self.team, user=self.user, task=self._task(key), amount=Decimal(str(amount)))
|
||||
|
||||
def test_none_means_no_enforcement(self):
|
||||
self.team.monthly_credit_limit = None
|
||||
self.team.save(update_fields=["monthly_credit_limit"])
|
||||
self._reserve("a", 500) # 不抛
|
||||
|
||||
def test_minus_one_means_unlimited(self):
|
||||
self.team.monthly_credit_limit = Decimal("-1")
|
||||
self.team.save(update_fields=["monthly_credit_limit"])
|
||||
self._reserve("b", 500) # 不抛
|
||||
|
||||
def test_zero_means_no_enforcement(self):
|
||||
# 0 必须不管控:前端把 null 映射成 0、成员限额 0=不限,存量 0 行若被解释成「冻结」
|
||||
# 会在部署当天静默冻结整个团队(review 确认)。冻结消费用 QuotaPolicy(monthly=0)。
|
||||
self.team.monthly_credit_limit = Decimal("0")
|
||||
self.team.save(update_fields=["monthly_credit_limit"])
|
||||
self._reserve("c", 500) # 不抛
|
||||
|
||||
def test_positive_limit_counts_active_reservations(self):
|
||||
self.team.monthly_credit_limit = Decimal("100")
|
||||
self.team.save(update_fields=["monthly_credit_limit"])
|
||||
reservation = self._reserve("d", 60) # 在途 60
|
||||
with self.assertRaisesMessage(ValueError, "团队本月额度不足"):
|
||||
self._reserve("e", 50) # 60+50 > 100
|
||||
# 结算 40(在途转已扣 40,当月已用 40):40+50 <= 100 → 放行
|
||||
charge_reserved_credit(reservation=reservation, actual_amount=Decimal("40"))
|
||||
self._reserve("f", 50)
|
||||
|
||||
|
||||
class RechargePointsTests(TestCase):
|
||||
"""充值积分语义:¥ × points_per_yuan + bonus_points;metadata 快照支付金额与汇率。"""
|
||||
|
||||
def setUp(self):
|
||||
self.owner = User.objects.create_user(username="rp-owner", password="p")
|
||||
self.team = Team.objects.create(name="RP", owner=self.owner)
|
||||
TeamMember.objects.create(team=self.team, user=self.owner, role=TeamMember.Role.OWNER)
|
||||
CreditAccount.objects.create(team=self.team, balance=Decimal("0"))
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(self.owner)
|
||||
|
||||
def test_recharge_converts_yuan_to_points_with_bonus(self):
|
||||
resp = self.client.post("/api/billing/recharge/", {"amount": "500", "bonus_points": "300"}, format="json")
|
||||
self.assertEqual(resp.status_code, 201)
|
||||
body = resp.json()
|
||||
self.assertEqual(body["credited_points"], "5300")
|
||||
self.assertEqual(body["points_per_yuan"], "10.00")
|
||||
account = CreditAccount.objects.get(team=self.team)
|
||||
self.assertEqual(account.balance, Decimal("5300"))
|
||||
ledger = CreditLedger.objects.get(team=self.team, ledger_type=CreditLedger.Type.RECHARGE)
|
||||
# 真实支付金额只能从 metadata 读(老流水不可用 amount÷rate 反推)
|
||||
self.assertEqual(ledger.metadata["paid_amount"], "500")
|
||||
self.assertEqual(ledger.metadata["bonus_points"], "300")
|
||||
self.assertEqual(ledger.metadata["points_per_yuan"], "10.00")
|
||||
|
||||
def test_legacy_bonus_yuan_converted(self):
|
||||
resp = self.client.post("/api/billing/recharge/", {"amount": "100", "bonus": "20"}, format="json")
|
||||
self.assertEqual(resp.status_code, 201)
|
||||
self.assertEqual(resp.json()["credited_points"], "1200") # 100×10 + 20×10
|
||||
|
||||
|
||||
class BillingConfigEndpointTests(TestCase):
|
||||
"""公共 GET /api/billing/config/(鉴权)+ admin PATCH(权限/生效/审计)。"""
|
||||
|
||||
def setUp(self):
|
||||
from apps.billing.pricing import invalidate_billing_config_cache
|
||||
|
||||
invalidate_billing_config_cache()
|
||||
self.user = User.objects.create_user(username="bc-user", password="p")
|
||||
self.team = Team.objects.create(name="BC", owner=self.user)
|
||||
TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER)
|
||||
self.admin = User.objects.create_user(username="bc-admin", password="p", is_platform_admin=True)
|
||||
|
||||
def test_public_config_requires_auth(self):
|
||||
self.assertEqual(APIClient().get("/api/billing/config/").status_code, 401)
|
||||
client = APIClient()
|
||||
client.force_authenticate(self.user)
|
||||
body = client.get("/api/billing/config/").json()
|
||||
self.assertEqual(body["currency_unit"], "points")
|
||||
self.assertEqual(body["points_per_yuan"], "10.00")
|
||||
self.assertEqual(body["video_margin_multiplier"], "1.50")
|
||||
|
||||
def test_admin_patch_updates_and_takes_effect(self):
|
||||
from apps.billing.pricing import get_billing_config, invalidate_billing_config_cache
|
||||
|
||||
client = APIClient()
|
||||
client.force_authenticate(self.admin)
|
||||
resp = client.patch("/api/admin/billing-config/", {"video_margin_multiplier": "2.5"}, format="json")
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
invalidate_billing_config_cache()
|
||||
self.assertEqual(get_billing_config().video_margin_multiplier, Decimal("2.5"))
|
||||
# 非超管 PATCH 拒绝
|
||||
stranger = APIClient()
|
||||
stranger.force_authenticate(self.user)
|
||||
self.assertEqual(stranger.patch("/api/admin/billing-config/", {"points_per_yuan": "1"}, format="json").status_code, 403)
|
||||
|
||||
|
||||
class TeamPriceMultiplierTests(TestCase):
|
||||
"""团队差异化调价:全类型统一系数、两步取整、视频结算用下单快照(中途改价不影响在途)。"""
|
||||
|
||||
def setUp(self):
|
||||
from apps.billing.pricing import invalidate_billing_config_cache
|
||||
|
||||
invalidate_billing_config_cache()
|
||||
self.user = User.objects.create_user(username="tpm-owner", password="p")
|
||||
self.team = Team.objects.create(name="TPM", owner=self.user, price_multiplier=Decimal("0.80"))
|
||||
TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER)
|
||||
CreditAccount.objects.create(team=self.team, balance=Decimal("10000"))
|
||||
provider, _ = ModelProvider.objects.get_or_create(name="volcengine", defaults={"display_name": "V"})
|
||||
self.image_model = ModelConfig.objects.create(
|
||||
provider=provider, name="tpm-img", display_name="I", capability=ModelConfig.Capability.IMAGE,
|
||||
unit_price=Decimal("20"),
|
||||
)
|
||||
|
||||
def test_flat_applies_multiplier(self):
|
||||
from apps.billing.pricing import quote_flat
|
||||
|
||||
# 20 × 0.8 = 16;不传 team = 标准价(零回归)
|
||||
quote = quote_flat(self.image_model, team=self.team)
|
||||
self.assertEqual(quote.points, Decimal("16"))
|
||||
self.assertEqual(quote_flat(self.image_model).points, Decimal("20"))
|
||||
# meta.rate 是汇率快照契约:create_ai_task 落 payload.points_per_yuan_snapshot,毛利报表用它防汇率漂移
|
||||
self.assertEqual(Decimal(quote.meta["rate"]), Decimal("10"))
|
||||
|
||||
def test_two_step_rounding(self):
|
||||
from apps.billing.pricing import quote_video_from_cost
|
||||
|
||||
# 挂牌:¥1.85×1.5×10=27.75→28;再 ×0.8=22.4→22(两步取整,与前端镜像逐字一致)
|
||||
quote = quote_video_from_cost(Decimal("1.85"), multiplier=Decimal("0.80"))
|
||||
self.assertEqual(quote.points, Decimal("22"))
|
||||
self.assertEqual(quote.meta["price_multiplier"], "0.80")
|
||||
|
||||
def test_minimum_one_point_after_discount(self):
|
||||
from apps.billing.pricing import apply_team_price
|
||||
|
||||
self.assertEqual(apply_team_price(Decimal("1"), Decimal("0.10")), Decimal("1"))
|
||||
|
||||
def test_invalid_multiplier_falls_back_to_one(self):
|
||||
from apps.billing.pricing import team_price_multiplier
|
||||
|
||||
self.team.price_multiplier = Decimal("0")
|
||||
self.assertEqual(team_price_multiplier(self.team), Decimal("1"))
|
||||
self.assertEqual(team_price_multiplier(None), Decimal("1"))
|
||||
|
||||
def test_video_settle_uses_snapshot_not_current(self):
|
||||
"""下单时 0.8 → 中途管理员改成 2.0 → 结算仍按 0.8 快照(jimeng 同款纪律)。"""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from apps.ai.free_video import finalize_free_video, submit_free_video
|
||||
from apps.ai.models import AITask
|
||||
|
||||
provider = MagicMock()
|
||||
provider.create_video_task.return_value = {"id": "ark-tpm", "status": "queued"}
|
||||
provider.extract_first_media_url.return_value = "http://x/v.mp4"
|
||||
patch("apps.ai.services.build_provider", return_value=provider).start()
|
||||
patch("apps.ai.tasks.poll_free_video_task.apply_async").start()
|
||||
patch("apps.ai.free_video._store_free_video_media").start()
|
||||
self.addCleanup(patch.stopall)
|
||||
|
||||
task = submit_free_video(team=self.team, user=self.user, params={
|
||||
"prompt": "测试", "mode": "universal", "model": "doubao-seedance-2-0-260128",
|
||||
"aspect_ratio": "16:9", "resolution": "480p", "duration": 4, "references": [],
|
||||
})
|
||||
self.assertEqual(task.request_payload["price_multiplier"], "0.80")
|
||||
# 挂牌 28 × 0.8 = 22.4 → 22 积分
|
||||
self.assertEqual(task.estimated_cost, Decimal("22"))
|
||||
# 中途涨价到 2.0(在途任务不受影响)
|
||||
self.team.price_multiplier = Decimal("2.00")
|
||||
self.team.save(update_fields=["price_multiplier"])
|
||||
provider.poll_video_task.return_value = {"status": "succeeded", "usage": {"total_tokens": 30000}}
|
||||
task = finalize_free_video(task=task)
|
||||
# 真实 30000 tokens:挂牌 1.38×1.5×10=20.7→21;×快照0.8=16.8→17(而非 ×2.0=42)
|
||||
self.assertEqual(task.status, AITask.Status.SUCCEEDED)
|
||||
self.assertEqual(task.actual_cost, Decimal("17"))
|
||||
|
||||
def test_admin_pricing_endpoint(self):
|
||||
admin = User.objects.create_user(username="tpm-admin", password="p", is_platform_admin=True)
|
||||
client = APIClient()
|
||||
client.force_authenticate(admin)
|
||||
resp = client.post(f"/api/admin/teams/{self.team.id}/pricing/", {"price_multiplier": "0.9"}, format="json")
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(resp.json()["price_multiplier"], "0.90")
|
||||
# 三位小数走 HALF_UP 而非银行家舍入:0.125 → 0.13(quantize 默认会给 0.12)
|
||||
resp = client.post(f"/api/admin/teams/{self.team.id}/pricing/", {"price_multiplier": "0.125"}, format="json")
|
||||
self.assertEqual(resp.json()["price_multiplier"], "0.13")
|
||||
# 边界与非法值
|
||||
self.assertEqual(client.post(f"/api/admin/teams/{self.team.id}/pricing/", {"price_multiplier": "0.05"}, format="json").status_code, 400)
|
||||
self.assertEqual(client.post(f"/api/admin/teams/{self.team.id}/pricing/", {"price_multiplier": "NaN"}, format="json").status_code, 400)
|
||||
# 非超管 403
|
||||
stranger = APIClient()
|
||||
stranger.force_authenticate(self.user)
|
||||
self.assertEqual(stranger.post(f"/api/admin/teams/{self.team.id}/pricing/", {"price_multiplier": "0.5"}, format="json").status_code, 403)
|
||||
|
||||
def test_public_config_returns_team_multiplier(self):
|
||||
client = APIClient()
|
||||
client.force_authenticate(self.user)
|
||||
self.assertEqual(client.get("/api/billing/config/").json()["team_price_multiplier"], "0.80")
|
||||
|
||||
Reference in New Issue
Block a user