From 826f5f685b118c00def7ff22831ffe91a58910fe Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Thu, 2 Jul 2026 15:28:19 +0800 Subject: [PATCH] feat: add billing pricing rules --- README.md | 4 +- apps/billing/admin.py | 37 +++- .../0002_exchangerate_pricingrule.py | 51 ++++++ apps/billing/models.py | 88 ++++++++++ apps/billing/pricing.py | 122 ++++++++++++++ apps/billing/tests.py | 159 +++++++++++++++++- docs/00-ai-start-here.md | 4 +- docs/04-architecture.md | 35 ++-- docs/06-tasks.md | 2 +- docs/api.md | 18 ++ docs/current-state.md | 20 +-- docs/project-brief.md | 9 +- docs/project-onepager.md | 4 +- progress.md | 29 ++++ 14 files changed, 548 insertions(+), 34 deletions(-) create mode 100644 apps/billing/migrations/0002_exchangerate_pricingrule.py create mode 100644 apps/billing/pricing.py diff --git a/README.md b/README.md index 4a0e48f..07e545d 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ - 用户端(自助):注册/登录、扫码充值、查看充值记录/剩余点数/消费记录、生成与删除 API Key(注册不送免费点数)。 - 对外提供 HTTP API:生成标题、生成图片(同步返回)、查询点数余额。 -- 按「操作类型 + 模型 + 分辨率」计费,调用消耗不同点数;余额不足返回「点数不足,请先充值」。 +- 按「操作类型 + 能力别名(+ 可选分辨率)」计费,调用消耗不同点数;余额不足返回「点数不足,请先充值」。 - **预付费点数模型**:用户在外部支付系统充值,付款成功由支付系统回调本服务,按汇率把金额转成点数存在本地;之后调用直接扣本地点数,扣费与上游解耦、低延迟。 - django-admin 运营后台:管理注册用户、点数余额、计费规则、充值订单、点数流水、调用记录。 @@ -25,7 +25,7 @@ Python 3.12 / Django 5.2 LTS + DRF / django-admin / 用户端 Django 模板 SSR ## 当前状态 -Phase 2 已完成 T-201:用户钱包、API Key、点数流水与调用记录模型已落地并注册 admin。下一步是 T-202 计费规则 / 汇率模型与计费计算。详见 [`docs/current-state.md`](docs/current-state.md)。 +Phase 2 已完成 T-202:用户钱包、API Key、点数流水、调用记录、计费规则、汇率模型与计费计算已落地。下一步是 T-203 并发安全扣点 / 退点。详见 [`docs/current-state.md`](docs/current-state.md)。 > ⚠️ 涉及资金/点数。改动充值、扣费、退款、对账相关代码前,先读 [`docs/05-coding-rules.md`](docs/05-coding-rules.md) 第 8 节与 [`docs/04-architecture.md`](docs/04-architecture.md) 第四节计费时序。 diff --git a/apps/billing/admin.py b/apps/billing/admin.py index 474e491..114a271 100644 --- a/apps/billing/admin.py +++ b/apps/billing/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin -from .models import CallRecord, PointsLedger +from .models import CallRecord, ExchangeRate, PointsLedger, PricingRule class ReadOnlyLedgerAdmin(admin.ModelAdmin): @@ -33,6 +33,41 @@ class PointsLedgerAdmin(ReadOnlyLedgerAdmin): ordering = ("-created_at", "-id") +@admin.register(PricingRule) +class PricingRuleAdmin(admin.ModelAdmin): + list_display = ( + "operation_type", + "alias", + "resolution_display", + "points_cost", + "is_active", + "updated_at", + ) + list_filter = ("operation_type", "is_active") + search_fields = ("alias", "resolution") + ordering = ("operation_type", "alias", "resolution") + readonly_fields = ("created_at", "updated_at") + + @admin.display(description="resolution") + def resolution_display(self, obj): + return obj.resolution or "*" + + +@admin.register(ExchangeRate) +class ExchangeRateAdmin(admin.ModelAdmin): + list_display = ( + "currency", + "points_per_unit", + "is_active", + "effective_from", + "updated_at", + ) + list_filter = ("currency", "is_active") + search_fields = ("currency", "note") + ordering = ("-effective_from", "-id") + readonly_fields = ("created_at", "updated_at") + + @admin.register(CallRecord) class CallRecordAdmin(ReadOnlyLedgerAdmin): list_display = ( diff --git a/apps/billing/migrations/0002_exchangerate_pricingrule.py b/apps/billing/migrations/0002_exchangerate_pricingrule.py new file mode 100644 index 0000000..367ed40 --- /dev/null +++ b/apps/billing/migrations/0002_exchangerate_pricingrule.py @@ -0,0 +1,51 @@ +# Generated by Django 5.2.15 on 2026-07-02 07:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('billing', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='ExchangeRate', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('currency', models.CharField(default='CNY', max_length=10)), + ('points_per_unit', models.DecimalField(decimal_places=4, max_digits=12)), + ('is_active', models.BooleanField(default=True)), + ('effective_from', models.DateTimeField()), + ('note', models.CharField(blank=True, max_length=255)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'db_table': 'exchange_rate', + 'ordering': ('-effective_from', '-id'), + 'indexes': [models.Index(fields=['currency', 'is_active', 'effective_from'], name='exchange_ra_currenc_d69abe_idx')], + 'constraints': [models.CheckConstraint(condition=models.Q(('points_per_unit__gt', 0)), name='exchange_rate_points_per_unit_positive')], + }, + ), + migrations.CreateModel( + name='PricingRule', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('operation_type', models.CharField(choices=[('title', 'Generate title'), ('image', 'Generate image')], max_length=32)), + ('alias', models.CharField(max_length=64)), + ('resolution', models.CharField(blank=True, default='', help_text='Leave blank to apply to every resolution for this alias.', max_length=32)), + ('points_cost', models.BigIntegerField()), + ('is_active', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'db_table': 'pricing_rule', + 'ordering': ('operation_type', 'alias', 'resolution'), + 'indexes': [models.Index(fields=['operation_type', 'alias', 'is_active'], name='pricing_rul_operati_580547_idx'), models.Index(fields=['operation_type', 'alias', 'resolution', 'is_active'], name='pricing_rul_operati_52be66_idx')], + 'constraints': [models.UniqueConstraint(fields=('operation_type', 'alias', 'resolution'), name='unique_pricing_rule_per_alias_resolution'), models.CheckConstraint(condition=models.Q(('points_cost__gt', 0)), name='pricing_rule_points_cost_positive')], + }, + ), + ] diff --git a/apps/billing/models.py b/apps/billing/models.py index ba6cda2..08e1059 100644 --- a/apps/billing/models.py +++ b/apps/billing/models.py @@ -6,6 +6,10 @@ from django.db.models import Q from apps.users.models import ApiKey +def normalize_resolution(value: str | None) -> str: + return "" if value is None else str(value).strip().upper() + + class CallRecord(models.Model): class OperationType(models.TextChoices): TITLE = "title", "Generate title" @@ -66,6 +70,90 @@ class CallRecord(models.Model): return f"{self.operation_type}:{self.alias or ''} {self.status}" +class PricingRule(models.Model): + operation_type = models.CharField(max_length=32, choices=CallRecord.OperationType.choices) + alias = models.CharField(max_length=64) + resolution = models.CharField( + max_length=32, + blank=True, + default="", + help_text="Leave blank to apply to every resolution for this alias.", + ) + points_cost = models.BigIntegerField() + is_active = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "pricing_rule" + ordering = ("operation_type", "alias", "resolution") + constraints = [ + models.UniqueConstraint( + fields=("operation_type", "alias", "resolution"), + name="unique_pricing_rule_per_alias_resolution", + ), + models.CheckConstraint( + condition=Q(points_cost__gt=0), + name="pricing_rule_points_cost_positive", + ), + ] + indexes = [ + models.Index(fields=("operation_type", "alias", "is_active")), + models.Index(fields=("operation_type", "alias", "resolution", "is_active")), + ] + + def __str__(self) -> str: + resolution = self.resolution or "*" + return f"{self.operation_type}:{self.alias}:{resolution} = {self.points_cost}" + + def clean(self) -> None: + super().clean() + self.alias = str(self.alias or "").strip() + self.resolution = normalize_resolution(self.resolution) + if not self.alias: + raise ValidationError({"alias": "Alias is required."}) + + def save(self, *args, **kwargs) -> None: + self.full_clean() + super().save(*args, **kwargs) + + +class ExchangeRate(models.Model): + currency = models.CharField(max_length=10, default="CNY") + points_per_unit = models.DecimalField(max_digits=12, decimal_places=4) + is_active = models.BooleanField(default=True) + effective_from = models.DateTimeField() + note = models.CharField(max_length=255, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "exchange_rate" + ordering = ("-effective_from", "-id") + constraints = [ + models.CheckConstraint( + condition=Q(points_per_unit__gt=0), + name="exchange_rate_points_per_unit_positive", + ), + ] + indexes = [ + models.Index(fields=("currency", "is_active", "effective_from")), + ] + + def __str__(self) -> str: + return f"{self.currency} 1 = {self.points_per_unit} points" + + def clean(self) -> None: + super().clean() + self.currency = str(self.currency or "").strip().upper() + if not self.currency: + raise ValidationError({"currency": "Currency is required."}) + + def save(self, *args, **kwargs) -> None: + self.full_clean() + super().save(*args, **kwargs) + + class PointsLedger(models.Model): class ChangeType(models.TextChoices): RECHARGE = "recharge", "Recharge" diff --git a/apps/billing/pricing.py b/apps/billing/pricing.py new file mode 100644 index 0000000..d69515d --- /dev/null +++ b/apps/billing/pricing.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +from dataclasses import dataclass +from decimal import Decimal, ROUND_FLOOR + +from django.utils import timezone + +from .models import ExchangeRate, PricingRule, normalize_resolution + + +class BillingConfigError(RuntimeError): + code = "billing_config_error" + + +class NoPricingRuleError(BillingConfigError): + code = "no_pricing_rule" + + def __init__(self, operation_type: str, alias: str, resolution: str | None = None): + normalized_resolution = normalize_resolution(resolution) or "*" + super().__init__( + "No pricing rule configured for " + f"operation={operation_type}, alias={alias}, resolution={normalized_resolution}." + ) + + +class NoExchangeRateError(BillingConfigError): + code = "no_exchange_rate" + + def __init__(self, currency: str): + super().__init__(f"No active exchange rate configured for currency={currency}.") + + +@dataclass(frozen=True) +class RechargeQuote: + amount: Decimal + currency: str + points_per_unit: Decimal + points_granted: int + exchange_rate: ExchangeRate + + +def get_pricing_rule( + operation_type: str, + alias: str, + resolution: str | None = None, +) -> PricingRule: + normalized_alias = str(alias or "").strip() + normalized_resolution = normalize_resolution(resolution) + rules = PricingRule.objects.filter( + operation_type=operation_type, + alias=normalized_alias, + is_active=True, + ) + if normalized_resolution: + exact_rule = rules.filter(resolution=normalized_resolution).first() + if exact_rule is not None: + return exact_rule + + default_rule = rules.filter(resolution="").first() + if default_rule is not None: + return default_rule + + raise NoPricingRuleError(operation_type, normalized_alias, normalized_resolution) + + +def calculate_points_cost( + operation_type: str, + alias: str, + resolution: str | None = None, +) -> int: + return get_pricing_rule(operation_type, alias, resolution).points_cost + + +def get_current_exchange_rate( + *, + currency: str = "CNY", + at=None, +) -> ExchangeRate: + normalized_currency = str(currency or "").strip().upper() + effective_at = at or timezone.now() + exchange_rate = ( + ExchangeRate.objects.filter( + currency=normalized_currency, + is_active=True, + effective_from__lte=effective_at, + ) + .order_by("-effective_from", "-id") + .first() + ) + if exchange_rate is None: + raise NoExchangeRateError(normalized_currency) + return exchange_rate + + +def quote_recharge_points( + amount, + *, + currency: str = "CNY", + at=None, +) -> RechargeQuote: + decimal_amount = Decimal(str(amount)) + if decimal_amount <= 0: + raise ValueError("amount must be greater than 0") + exchange_rate = get_current_exchange_rate(currency=currency, at=at) + points_decimal = decimal_amount * exchange_rate.points_per_unit + points_granted = int(points_decimal.to_integral_value(rounding=ROUND_FLOOR)) + return RechargeQuote( + amount=decimal_amount, + currency=exchange_rate.currency, + points_per_unit=exchange_rate.points_per_unit, + points_granted=points_granted, + exchange_rate=exchange_rate, + ) + + +def calculate_points_granted( + amount, + *, + currency: str = "CNY", + at=None, +) -> int: + return quote_recharge_points(amount, currency=currency, at=at).points_granted diff --git a/apps/billing/tests.py b/apps/billing/tests.py index 34f066c..5c7e9cd 100644 --- a/apps/billing/tests.py +++ b/apps/billing/tests.py @@ -1,12 +1,27 @@ +from datetime import timedelta +from decimal import Decimal + +from cryptography.fernet import Fernet from django.contrib import admin from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError from django.db import IntegrityError, transaction -from django.test import TestCase +from django.test import TestCase, override_settings +from django.utils import timezone -from apps.billing.models import CallRecord, PointsLedger +from apps.ai.models import AiModel, ModelAlias +from apps.billing.models import CallRecord, ExchangeRate, PointsLedger, PricingRule +from apps.billing.pricing import ( + NoPricingRuleError, + calculate_points_cost, + calculate_points_granted, + get_pricing_rule, + quote_recharge_points, +) from apps.users.models import ApiKey, UserWallet +TEST_ENCRYPTION_KEY = Fernet.generate_key().decode("ascii") + class BillingCoreModelTests(TestCase): def setUp(self): @@ -122,3 +137,143 @@ class BillingCoreModelTests(TestCase): self.assertIn(ApiKey, admin.site._registry) self.assertIn(PointsLedger, admin.site._registry) self.assertIn(CallRecord, admin.site._registry) + + +@override_settings(AI_KEY_ENCRYPTION_KEY=TEST_ENCRYPTION_KEY) +class PricingCalculationTests(TestCase): + def setUp(self): + self.text_model = self.create_ai_model( + name="Text model A", + model="gpt-5.5-a", + capabilities=["text"], + ) + self.replacement_text_model = self.create_ai_model( + name="Text model B", + model="gpt-5.5-b", + capabilities=["text"], + ) + self.alias = ModelAlias.objects.create( + operation_type=ModelAlias.OperationType.TITLE, + alias="title-standard", + ai_model=self.text_model, + ) + + def create_ai_model(self, *, name, model, capabilities): + ai_model = AiModel( + name=name, + url="https://api.vectorengine.ai/v1", + model=model, + api_type=AiModel.ApiType.CHAT, + capabilities=capabilities, + ) + ai_model.set_api_key("sk-test-secret") + ai_model.save() + return ai_model + + def test_exact_resolution_rule_overrides_alias_default(self): + PricingRule.objects.create( + operation_type=CallRecord.OperationType.IMAGE, + alias="image-hd", + resolution="", + points_cost=10, + ) + PricingRule.objects.create( + operation_type=CallRecord.OperationType.IMAGE, + alias="image-hd", + resolution="1k", + points_cost=15, + ) + + self.assertEqual( + calculate_points_cost( + CallRecord.OperationType.IMAGE, + "image-hd", + "1K", + ), + 15, + ) + self.assertEqual( + calculate_points_cost( + CallRecord.OperationType.IMAGE, + "image-hd", + "2K", + ), + 10, + ) + + exact_rule = get_pricing_rule( + CallRecord.OperationType.IMAGE, + "image-hd", + "1k", + ) + self.assertEqual(exact_rule.resolution, "1K") + + def test_pricing_is_bound_to_alias_not_underlying_model(self): + PricingRule.objects.create( + operation_type=CallRecord.OperationType.TITLE, + alias="title-standard", + points_cost=2, + ) + + self.assertEqual( + calculate_points_cost( + CallRecord.OperationType.TITLE, + "title-standard", + "1K", + ), + 2, + ) + + self.alias.ai_model = self.replacement_text_model + self.alias.save() + + self.assertEqual( + calculate_points_cost( + CallRecord.OperationType.TITLE, + "title-standard", + "1K", + ), + 2, + ) + + def test_missing_pricing_rule_raises_no_pricing_rule_code(self): + with self.assertRaises(NoPricingRuleError) as context: + calculate_points_cost( + CallRecord.OperationType.TITLE, + "missing-alias", + "1K", + ) + + self.assertEqual(context.exception.code, "no_pricing_rule") + + def test_exchange_rate_uses_latest_active_effective_rate_and_floors_points(self): + now = timezone.now() + ExchangeRate.objects.create( + currency="cny", + points_per_unit=Decimal("10.0000"), + effective_from=now - timedelta(days=2), + ) + current = ExchangeRate.objects.create( + currency="CNY", + points_per_unit=Decimal("12.5000"), + effective_from=now - timedelta(days=1), + ) + ExchangeRate.objects.create( + currency="CNY", + points_per_unit=Decimal("20.0000"), + effective_from=now, + is_active=False, + ) + ExchangeRate.objects.create( + currency="CNY", + points_per_unit=Decimal("99.0000"), + effective_from=now + timedelta(days=1), + ) + + quote = quote_recharge_points(Decimal("8.88"), currency="cny", at=now) + + self.assertEqual(quote.exchange_rate, current) + self.assertEqual(quote.currency, "CNY") + self.assertEqual(quote.points_per_unit, Decimal("12.5000")) + self.assertEqual(quote.points_granted, 111) + self.assertEqual(calculate_points_granted("8.88", currency="CNY", at=now), 111) diff --git a/docs/00-ai-start-here.md b/docs/00-ai-start-here.md index 02b8d5b..cae78ee 100644 --- a/docs/00-ai-start-here.md +++ b/docs/00-ai-start-here.md @@ -38,13 +38,13 @@ ## 当前阶段 -当前项目处于:**Phase 2 计费核心**。T-201 用户钱包 / API Key / 点数流水 / 调用记录模型已完成,下一步进入 T-202 PricingRule / ExchangeRate 模型与计费计算。 +当前项目处于:**Phase 2 计费核心**。T-202 PricingRule / ExchangeRate 模型与计费计算已完成,下一步进入 T-203 并发安全扣点 / 退点。 优先路径: 1. Phase 0:Django 骨架可运行、**自定义 User 模型在首次迁移前定好**、django-admin 可登录;T-004 审核修补项已完成。 2. Phase 1:最高风险功能原型 —— T-101/T-102/T-103/T-104/T-105 已完成 provider 层、模型配置表、别名解析、配置审计、录制标题/图片 smoke 与审核修补;真实图片同步耗时待配置 Fernet 主密钥、AiModel/ModelAlias 与真实上游后在 T-302/T-403 前补测。 -3. Phase 2:计费核心 —— T-201 已完成 UserWallet/ApiKey/PointsLedger/CallRecord;下一步 T-202 计费规则与汇率,随后 T-203 并发安全扣点。 +3. Phase 2:计费核心 —— T-201 已完成 UserWallet/ApiKey/PointsLedger/CallRecord;T-202 已完成计费规则、汇率与计算函数;下一步 T-203 并发安全扣点。 4. Phase 3:对外 API 与充值 —— Key 鉴权、生成接口、余额查询、充值回调、扫码下单与轮询。 5. Phase 4:用户端(Django 模板 SSR)—— 注册登录、API Key 管理、个人中心/记录页、充值页。 6. Phase 5:后台与发布 —— 运营后台完善、完整验收、部署 / 运行文档。 diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 767031a..81a408b 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -70,8 +70,8 @@ | AiModel | 迁移自 `cmbot/config/ai_models.json` | name、url、model、`api_type`、`capabilities`、`timeout_seconds`、`connect_timeout_seconds`、`extra_body`、`is_active`、**api_key_encrypted(Fernet 加密存储)** | | ModelAlias | 运营配置 | `operation_type + alias` → 映射到一个具体 AiModel;支持每个操作一个默认别名;调用方只见别名,不见 SKU | | AiConfigAuditLog | 后台自动写入 | AiModel / ModelAlias / api_key 变更审计;记录 actor、action、target、changed_fields、changes、created_at;只读 | -| PricingRule | 运营配置 | 操作类型 × **能力别名**(+ 可选分辨率)→ 点数单价 | -| ExchangeRate | 运营配置 | 金额 → 点数 的汇率(如 1 元 = N 点),可带生效时间 | +| PricingRule | 运营配置 | 操作类型 × **能力别名字符串**(+ 可选分辨率)→ 点数单价;不外键到具体 AiModel | +| ExchangeRate | 运营配置 | 金额 → 点数 的汇率(如 1 元 = N 点),按 `currency + effective_from` 取当前有效值 | 关键事实: @@ -136,16 +136,31 @@ CREATE TABLE ai_config_audit_log ( -- 计费规则(按别名定价,换底层模型不影响计费) CREATE TABLE pricing_rule ( - id INTEGER PRIMARY KEY, - operation_type TEXT NOT NULL, -- title / image - alias TEXT NOT NULL REFERENCES model_alias(alias), - resolution TEXT, -- 可空:按档位区分价格时使用 - points_cost BIGINT NOT NULL, - UNIQUE(alias, resolution) + id BIGINT PRIMARY KEY, + operation_type VARCHAR(32) NOT NULL, -- title / image + alias VARCHAR(64) NOT NULL, -- 能力别名字符串,不绑具体模型 + resolution VARCHAR(32) NOT NULL DEFAULT '', -- 空字符串表示该别名默认价 + points_cost BIGINT NOT NULL, -- > 0 + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + UNIQUE(operation_type, alias, resolution) +); + +-- 金额换点数汇率(充值下单时锁定) +CREATE TABLE exchange_rate ( + id BIGINT PRIMARY KEY, + currency VARCHAR(10) NOT NULL DEFAULT 'CNY', + points_per_unit DECIMAL(12,4) NOT NULL, -- 1 currency unit = N points + is_active BOOLEAN NOT NULL DEFAULT TRUE, + effective_from DATETIME(6) NOT NULL, + note VARCHAR(255) NOT NULL DEFAULT '', + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL ); ``` -T-102 已实现 `AiModel` / `ModelAlias` 的 Django models、admin、迁移与别名解析。T-103 已补 `AiConfigAuditLog`,admin 里保存/删除模型配置或能力别名时自动写审计日志。默认别名唯一性由 model validation、admin 与导入器保证;MySQL 不支持通用 partial unique index,后续若要强制数据库层约束可在 T-401 评估触发器或约束表。 +T-102 已实现 `AiModel` / `ModelAlias` 的 Django models、admin、迁移与别名解析。T-103 已补 `AiConfigAuditLog`,admin 里保存/删除模型配置或能力别名时自动写审计日志。T-202 已实现 `PricingRule` / `ExchangeRate` 与 `apps.billing.pricing` 计算函数:定价按 `operation_type + alias + resolution` 查 active 规则,优先 exact resolution,再回退到空 resolution 默认价;缺规则抛 `NoPricingRuleError(code="no_pricing_rule")`。默认别名唯一性由 model validation、admin 与导入器保证;MySQL 不支持通用 partial unique index,后续若要强制数据库层约束可在 T-401 评估触发器或约束表。 > 可选增强(接口预留、MVP 不实现):`account_alias_permission`(按账号授权可用别名,防止调用方点用未授权/昂贵模型);别名按比例分流到多个模型(灰度/AB/故障转移)。适配器接口需为此留口子。 @@ -238,7 +253,7 @@ CREATE TABLE call_record ( 需要说明: -- 主键自增;`api_key.key_hash`、`recharge_order.order_no`、`user.username` 唯一。 +- 主键自增;`api_key.key_hash`、`pricing_rule(operation_type, alias, resolution)`、`recharge_order.order_no`、`user.username` 唯一。 - 重要索引:`call_record(user_id, created_at)`、`points_ledger(user_id, created_at)`、`recharge_order(order_no)`、`api_key(key_hash)`。 - 不软删除业务流水;用户/账号可标记 `disabled` 而非物理删;API Key 用 `revoked` 状态而非物理删。 - 服务端生成字段:`api_key.key_hash`/`key_prefix`、`points_balance`、`balance_after`、各 `created_at` / `updated_at`。 diff --git a/docs/06-tasks.md b/docs/06-tasks.md index 9ed2baf..78daef4 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -42,7 +42,7 @@ | ID | 任务 | 依赖 | 验收要点 | 状态 | | --- | --- | --- | --- | --- | | T-201 | User / UserWallet / ApiKey / PointsLedger / CallRecord 模型 | T-002 | 表结构符合 `04-architecture.md`;`UserWallet.points_balance>=0` 约束;ApiKey **哈希存储**(key_hash+key_prefix,明文只创建时返回);CallRecord 含 `user`/`api_key`/`alias`/`model_used`;调用结果只存 `result_ref`/摘要,不 dump provider `raw`、base64 图片或敏感上游字段;admin 注册 | DONE | -| T-202 | PricingRule / ExchangeRate 模型 + 计费计算 | T-201, T-102 | **按「操作 + 能力别名(+ 可选分辨率)」定价**;换底层模型不影响计费;缺规则返回 `no_pricing_rule` | TODO | +| T-202 | PricingRule / ExchangeRate 模型 + 计费计算 | T-201, T-102 | **按「操作 + 能力别名(+ 可选分辨率)」定价**;换底层模型不影响计费;缺规则返回 `no_pricing_rule` | DONE | | T-203 | 并发安全扣点 / 退点(billing 层) | T-201 | 锁 `UserWallet` 行或 F() 原子扣减;并发测试不超扣、不为负;失败退点写流水;含测试 | TODO | ## Phase 3 · 对外 API 与充值 diff --git a/docs/api.md b/docs/api.md index 4b9e015..7e8a1d8 100644 --- a/docs/api.md +++ b/docs/api.md @@ -116,6 +116,24 @@ } ``` +## 计费模块合约(`apps/billing.pricing`) + +T-202 后,计费计算已有独立模块,供后续扣点、生成接口和充值下单调用: + +```python +calculate_points_cost(operation_type: str, alias: str, resolution: str | None = None) -> int +quote_recharge_points(amount, currency: str = "CNY", at=None) -> RechargeQuote +calculate_points_granted(amount, currency: str = "CNY", at=None) -> int +``` + +要点: + +- `PricingRule` 按 `operation_type + alias + resolution` 查 active 规则;`resolution` 先做大小写归一,优先匹配精确分辨率,再回退到空 `resolution` 的默认价。 +- 定价绑定**能力别名字符串**,不绑定 `AiModel` 或上游 SKU;后台切换 `ModelAlias` 指向的底层模型,不改变该别名的价格。 +- 缺计费规则抛 `NoPricingRuleError(code="no_pricing_rule")`,API 层应翻译为上方同名错误码。 +- `ExchangeRate` 按 `currency + effective_from` 取当前 active 汇率;充值下单时应锁定当时的 `exchange_rate` / `points_granted` 到订单,回调入账不得按新汇率重算。 +- 金额换点数采用 `floor(amount * points_per_unit)`,点数为整数。 + ## 支付充值(自助扫码:微信 V3 native + 支付宝当面付) > 协议对齐同支付系统的既有 PHP 实现(微信库 `wechatpayv3` / 支付宝库 `python-alipay-sdk`)。二维码是平台不透明票据、**不含业务数据**,靠 `out_trade_no`(=本地 `order_no`) 在回调关联,付款人由平台识别。**协议已明确,仅商户密钥/证书为真实值待提供。** diff --git a/docs/current-state.md b/docs/current-state.md index a0148b4..6c578ce 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -12,16 +12,16 @@ ## 当前快照 - 日期:2026-07-02 -- 阶段:Phase 2 计费核心已完成 T-201;下一步进入 T-202 PricingRule / ExchangeRate 模型与计费计算 +- 阶段:Phase 2 计费核心已完成 T-202;下一步进入 T-203 并发安全扣点 / 退点 - 技术栈:系统 Python 3.12.3 + Django 5.2.15 + DRF 3.16.1 + PyMySQL 1.1.3 + cryptography 46.0.7 + requests 2.34.2 + django-admin;MySQL 8.4 已接入 settings;用户端(模板 SSR/Bootstrap/allauth) 后续任务落地;详见 `03-tech-stack.md` -- 生产代码:已有最小 Django 工程骨架:`manage.py`、`config/`;T-002 已创建 `apps/users|portal|billing|ai|api`;T-003 已把自定义 `User` 注册进 django-admin;T-004 已完成 email 唯一性、init 版本断言、app 顺序、`.env.example` 与 `pyproject.toml`;T-101 已新增 `apps/ai/providers/`(Provider 接口、注册表、chat/gemini/images/images_edits 适配器);T-102 已新增 `AiModel` / `ModelAlias`、Fernet 加密密钥存储、别名解析、admin 配置页、`import_ai_models` 导入命令;T-103 已新增 `AiConfigAuditLog` 审计表、admin 只读页面和后台保存/删除审计 hook;T-104/T-105 已完成录制 title/image smoke 与审核修补;T-201 已新增 `UserWallet` / `ApiKey`、`PointsLedger` / `CallRecord`、对应 admin 与迁移 -- 测试:`manage.py test --noinput --keepdb` 通过(34 tests);`manage.py test apps.users apps.billing --noinput --keepdb` 通过(8 tests);`manage.py migrate` 已应用 `users.0003_apikey_userwallet` / `billing.0001_initial`;`showmigrations users billing` 均为 `[X]`;`manage.py check` 通过;`makemigrations --check` 通过;`compileall apps` 通过;`git diff --check` 仅 Windows CRLF 提示;`./init.ps1` 通过。 +- 生产代码:已有最小 Django 工程骨架:`manage.py`、`config/`;T-002 已创建 `apps/users|portal|billing|ai|api`;T-003 已把自定义 `User` 注册进 django-admin;T-004 已完成 email 唯一性、init 版本断言、app 顺序、`.env.example` 与 `pyproject.toml`;T-101 已新增 `apps/ai/providers/`(Provider 接口、注册表、chat/gemini/images/images_edits 适配器);T-102 已新增 `AiModel` / `ModelAlias`、Fernet 加密密钥存储、别名解析、admin 配置页、`import_ai_models` 导入命令;T-103 已新增 `AiConfigAuditLog` 审计表、admin 只读页面和后台保存/删除审计 hook;T-104/T-105 已完成录制 title/image smoke 与审核修补;T-201 已新增 `UserWallet` / `ApiKey`、`PointsLedger` / `CallRecord`、对应 admin 与迁移;T-202 已新增 `PricingRule` / `ExchangeRate`、`apps.billing.pricing` 计费计算函数、admin 配置页与迁移 +- 测试:T-202 范围验证通过:`py_compile`、`manage.py check`、`makemigrations --check`、`migrate`、`showmigrations billing`、`manage.py test apps.billing --noinput --keepdb`(10 tests)、`manage.py test apps.users --noinput --keepdb`(2 tests)、`compileall apps`、`git diff --check`(仅 Windows CRLF 提示)、`./init.ps1`。全量 `manage.py test --noinput --keepdb` 当前发现 38 tests,但两次在远程 MySQL `43.128.3.240:3306` 连接超时/重置处失败;`apps.ai` 单独测试也在远程连接重建处超时,已跑过测试无断言失败。 - 数据:AI 上游调用与模型配置参考 `D:\chengma\cmbot`(`src/services/ai_text_service.py`、`ai_image_service.py`、`config/ai_models.json`);真实 `ai_models.json` 不提交,需通过 `import_ai_models` 命令加密导入 - 标准启动路径:Windows 用 `./init.ps1`;Unix/WSL 用 `./init.sh` - 标准验证路径:Windows 用 `py -3.12 manage.py check` / `py -3.12 manage.py test` - 设计基线:**自助用户端 + 对外 API + 运营后台**三合一单体;用户模型 `User`(auth)/`UserWallet`(点数,锁 wallet 扣点)/`ApiKey`(1:N,哈希存储);对外两接口 + **能力别名 + Provider 适配器**(可插拔供应商);自助扫码充值;注册不送点数。详见 `04-architecture.md` 与 2026-06-29 / 2026-07-01 的 `progress.md` 决策 - 配置基线:运行环境变量集中见 `docs/env.md`;真实密钥/支付凭证不得写入代码或文档样例。充值订单在创建时锁定汇率与预计点数,回调入账使用订单值,不按新汇率重算 -- 当前 blocker:无。支付商户密钥/证书仍缺真实值,但不阻塞当前 Phase 2;真实 AI 上游 smoke 需要先配置 `AI_KEY_ENCRYPTION_KEY` 并导入 AiModel/ModelAlias。图片同步真实耗时风险仍未退,已登记到 T-302/T-403。 +- 当前 blocker:无代码 blocker。支付商户密钥/证书仍缺真实值,但不阻塞当前 Phase 2;真实 AI 上游 smoke 需要先配置 `AI_KEY_ENCRYPTION_KEY` 并导入 AiModel/ModelAlias。图片同步真实耗时风险仍未退,已登记到 T-302/T-403。远程 MySQL 对全量测试存在间歇连接超时/重置,必要时重试或分 app 验证。 ## 当前目录要点 @@ -33,7 +33,7 @@ | `init.sh` / `init.ps1` | 已有 | 启动验证入口,已固定系统 Python 3.12 命令,并校验解释器版本 `>=3.12,<3.14` | | `requirements.txt` / `pyproject.toml` | 已有 | `requirements.txt` 管运行依赖;`pyproject.toml` 落地 `requires-python`;T-101 新增 `requests`;T-102 使用既有 `cryptography` 做 Fernet 加密 | | `config/`(Django 工程) | 已有 | T-001 创建,含 settings / urls / wsgi / asgi | -| `apps/`(users/portal/billing/ai/api) | 已有 | T-002 创建;`apps/users` 已定义自定义 `User`;T-003 已注册 admin 与 admin smoke test;T-004 已给 `User.email` 加唯一约束;T-101 已新增 `apps/ai/providers`;T-102 已新增 `apps/ai/security.py`、`aliases.py`、`importers.py`、management command 与 `ai.0001_initial` 迁移;T-103 已新增 `apps/ai/audit.py` 与 `ai.0002_aiconfigauditlog` 迁移;T-104/T-105 已新增 `smoke_ai_generation` 录制 title/image smoke 命令;T-201 已在 users 落 `UserWallet` / `ApiKey`,在 billing 落 `PointsLedger` / `CallRecord` | +| `apps/`(users/portal/billing/ai/api) | 已有 | T-002 创建;`apps/users` 已定义自定义 `User`;T-003 已注册 admin 与 admin smoke test;T-004 已给 `User.email` 加唯一约束;T-101 已新增 `apps/ai/providers`;T-102 已新增 `apps/ai/security.py`、`aliases.py`、`importers.py`、management command 与 `ai.0001_initial` 迁移;T-103 已新增 `apps/ai/audit.py` 与 `ai.0002_aiconfigauditlog` 迁移;T-104/T-105 已新增 `smoke_ai_generation` 录制 title/image smoke 命令;T-201 已在 users 落 `UserWallet` / `ApiKey`,在 billing 落 `PointsLedger` / `CallRecord`;T-202 已在 billing 落 `PricingRule` / `ExchangeRate` 与 `pricing.py` | | `manage.py` | 已有 | T-001 创建 | | `tests/` | 待建 | 随各任务补充 | @@ -41,10 +41,10 @@ 任务状态以 [`06-tasks.md`](06-tasks.md) 为准,历史执行记录见 [`../progress.md`](../progress.md)。 -- 已完成:T-001 初始化 Django + DRF 项目骨架;T-002 建立 apps 目录、自定义 User 与配置;T-003 接通 django-admin 与最小测试;T-004 Phase 0 骨架审核修补;T-101 Provider 适配器层 + 移植 cmbot 调用;T-102 AiModel + ModelAlias 模型 + 别名解析;T-103 配置变更审计;T-104 跑通一次录制标题生成;T-105 Phase 1 AI 层审核修补;T-201 User / UserWallet / ApiKey / PointsLedger / CallRecord 模型。 +- 已完成:T-001 初始化 Django + DRF 项目骨架;T-002 建立 apps 目录、自定义 User 与配置;T-003 接通 django-admin 与最小测试;T-004 Phase 0 骨架审核修补;T-101 Provider 适配器层 + 移植 cmbot 调用;T-102 AiModel + ModelAlias 模型 + 别名解析;T-103 配置变更审计;T-104 跑通一次录制标题生成;T-105 Phase 1 AI 层审核修补;T-201 User / UserWallet / ApiKey / PointsLedger / CallRecord 模型;T-202 PricingRule / ExchangeRate 模型 + 计费计算。 - 正在进行:无。 -- 当前 blocker:无。 -- 下一个可领取任务:**T-202 PricingRule / ExchangeRate 模型 + 计费计算**。 +- 当前 blocker:无代码 blocker;远程 MySQL 全量测试偶发连接超时/重置。 +- 下一个可领取任务:**T-203 并发安全扣点 / 退点(billing 层)**。 ## 当前可运行内容 @@ -68,14 +68,14 @@ python3.12 manage.py smoke_ai_generation title --recorded python3.12 manage.py smoke_ai_generation image --recorded ``` -当前骨架可运行。T-002 已在首次迁移前创建自定义 User,并按 `env.md` 接入 MySQL 8.4 / utf8mb4;远程 MySQL 已完成 Django 初始迁移。T-003 已接通 django-admin,测试可创建/销毁 `test_cmhub` 测试库;当前远程 MySQL 对频繁建库/销库存在间歇超时,必要时用 `--keepdb` 验证。T-004 已应用 `users.0002_alter_user_email`,`user.email` 已有唯一索引。T-101 的 AI provider 层只做 HTTP 调用与响应解析;T-102 已把 provider 运行配置接到数据库 `AiModel` / `ModelAlias`,`resolve_alias()` 每次查当前 active 配置并按 `text` / `image` 能力校验。T-103 已补 `AiConfigAuditLog`,admin 保存/删除 `AiModel` / `ModelAlias` 时记录 actor、action、target、changed_fields、changes、created_at,密钥只记录 empty/set 状态。T-104/T-105 已用临时回滚配置跑通录制标题和录制图片生成。T-201 已落地钱包、API Key、点数流水和调用记录:API Key 明文只在创建 helper 返回,库内只存 hash/prefix;CallRecord 只存 `result_ref`/`result_summary`,没有 provider raw 字段。真实上游生成未执行,原因是当前环境未配置 `AI_KEY_ENCRYPTION_KEY` 且数据库没有 AiModel/ModelAlias;后续配置后可用 `import_ai_models` 导入,再用同一 smoke 命令去掉 `--recorded` 跑真实标题/图片。 +当前骨架可运行。T-002 已在首次迁移前创建自定义 User,并按 `env.md` 接入 MySQL 8.4 / utf8mb4;远程 MySQL 已完成 Django 初始迁移。T-003 已接通 django-admin,测试可创建/销毁 `test_cmhub` 测试库;当前远程 MySQL 对频繁建库/销库存在间歇超时,必要时用 `--keepdb` 验证。T-004 已应用 `users.0002_alter_user_email`,`user.email` 已有唯一索引。T-101 的 AI provider 层只做 HTTP 调用与响应解析;T-102 已把 provider 运行配置接到数据库 `AiModel` / `ModelAlias`,`resolve_alias()` 每次查当前 active 配置并按 `text` / `image` 能力校验。T-103 已补 `AiConfigAuditLog`,admin 保存/删除 `AiModel` / `ModelAlias` 时记录 actor、action、target、changed_fields、changes、created_at,密钥只记录 empty/set 状态。T-104/T-105 已用临时回滚配置跑通录制标题和录制图片生成。T-201 已落地钱包、API Key、点数流水和调用记录:API Key 明文只在创建 helper 返回,库内只存 hash/prefix;CallRecord 只存 `result_ref`/`result_summary`,没有 provider raw 字段。T-202 已落地 `PricingRule` / `ExchangeRate`:计费按 `operation_type + alias + resolution` 查 active 规则,优先精确分辨率,再回退默认价;缺规则抛 `NoPricingRuleError(code="no_pricing_rule")`;金额换点数按当前 active 汇率向下取整。真实上游生成未执行,原因是当前环境未配置 `AI_KEY_ENCRYPTION_KEY` 且数据库没有 AiModel/ModelAlias;后续配置后可用 `import_ai_models` 导入,再用同一 smoke 命令去掉 `--recorded` 跑真实标题/图片。 ## 开始编码前检查 1. 读仓库级 `AGENTS.md` / `CLAUDE.md`。 2. 读 `docs/00-ai-start-here.md`。 3. 读 `docs/05-coding-rules.md`(尤其第 8 节资金安全)。 -4. 在 `docs/06-tasks.md` 取第一个 `TODO` 且依赖均 `DONE` 的任务(当前为 T-202)。 +4. 在 `docs/06-tasks.md` 取第一个 `TODO` 且依赖均 `DONE` 的任务(当前为 T-203)。 5. 将该任务状态改为 `DOING`。 ## 维护规则 diff --git a/docs/project-brief.md b/docs/project-brief.md index 0b96909..e793b2b 100644 --- a/docs/project-brief.md +++ b/docs/project-brief.md @@ -1,7 +1,7 @@ # cmhub 项目介绍(给管理层) > 面向决策与汇报的项目概览。技术细节见同目录架构与需求文档。 -> 日期:2026-07-02 | 阶段:Phase 2 起步(计费核心) +> 日期:2026-07-02 | 阶段:Phase 2 计费核心(T-202 已完成) ## 一句话概括 @@ -19,7 +19,7 @@ - 用户可自助注册、扫码充值、查看点数和记录、管理 API Key。 - 对外只提供两个稳定接口:**生成标题**、**生成图片**。 -- 谁能调、调多少,用**点数**计费:不同操作、不同模型消耗不同点数。 +- 谁能调、调多少,用**点数**计费:不同操作、不同能力别名/分辨率消耗不同点数。 - 点数不够时直接提示「点数不足,请先充值」。 - 配一个**运营后台**,运营人员可视化管理用户、点数、计费规则和所有调用记录。 @@ -53,7 +53,7 @@ - **预付费点数**:先充值、后消费,类似话费/云服务的预付费。 - 充值金额按**可配置汇率**换算成点数(如 1 元 = N 点,由运营在后台设定)。 -- 不同接口、不同模型、不同清晰度可设**不同点数单价**,灵活定价。 +- 不同接口、不同能力别名、不同清晰度可设**不同点数单价**,灵活定价。 - 付款在公司**已有支付系统**完成,本项目不碰收银,只接收付款结果——合规、风险低。 ## 六、第一版范围(MVP) @@ -102,7 +102,8 @@ - M1 骨架已完成:Django + admin + 自定义 User + MySQL 8.4 已跑通。 - Phase 1 已完成 T-101~T-104:AI Provider 适配器、AiModel/ModelAlias、Fernet 加密密钥存储、别名解析、配置审计与录制标题生成 smoke 已落地。 -- 下一步是 T-202:建立 PricingRule / ExchangeRate,并实现按操作、能力别名和可选分辨率计算点数成本。 +- T-202 已完成:PricingRule / ExchangeRate 与计费计算已落地,按操作、能力别名和可选分辨率计算点数成本。 +- 下一步是 T-203:实现并发安全扣点 / 失败退点,保证余额不超扣、不为负。 --- *更多细节:愿景 `01-vision.md` | 需求与验收 `02-requirements.md` | 架构 `04-architecture.md` | 任务计划 `06-tasks.md`。* diff --git a/docs/project-onepager.md b/docs/project-onepager.md index 3c4e232..0490ff9 100644 --- a/docs/project-onepager.md +++ b/docs/project-onepager.md @@ -1,6 +1,6 @@ # cmhub · 一页汇报版 -> 自助用户端 + 计费型 AI 能力网关 + 运营后台 | 2026-07-02 | Phase 2 起步 +> 自助用户端 + 计费型 AI 能力网关 + 运营后台 | 2026-07-02 | Phase 2 计费核心 ## 电梯陈述(30 秒) @@ -40,7 +40,7 @@ ## 进度 -M1 骨架已完成;M2 已通过录制标题生成 smoke 验证 AI 调用链路。下一步进入计费核心模型。里程碑:M1 骨架可跑 · M2 跑通生成 · M3 计费充值闭环 · M4 用户端可用 · M5 验收上线。 +M1 骨架已完成;M2 已通过录制生成 smoke 验证 AI 调用链路;T-202 已落地计费规则、汇率和计算函数。下一步做并发安全扣点 / 退点。里程碑:M1 骨架可跑 · M2 跑通生成 · M3 计费充值闭环 · M4 用户端可用 · M5 验收上线。 --- *详见 `project-brief.md`(完整介绍)。* diff --git a/progress.md b/progress.md index 0154431..8d7958f 100644 --- a/progress.md +++ b/progress.md @@ -430,3 +430,32 @@ - `UserWallet` / `ApiKey` 放在 `apps.users`,`PointsLedger` / `CallRecord` 放在 `apps.billing`;T-201 只建数据结构,不实现扣点服务、计费规则、充值订单或 API 鉴权。 - `PointsLedger.ref_order_id` 在 RechargeOrder 模型落地前先保留为索引化数值引用,充值任务落地时再正式关联或补迁移。 - 下一步:领取 T-202 PricingRule / ExchangeRate 模型 + 计费计算。 + +## 2026-07-02 T-202 PricingRule / ExchangeRate 模型 + 计费计算 + +- 状态:DONE +- 变更: + - `apps/billing/models.py`:新增 `PricingRule`(按 `operation_type + alias + resolution` 唯一,`points_cost > 0`,active 开关)与 `ExchangeRate`(`currency + effective_from` 当前汇率,`points_per_unit > 0`),并归一化分辨率/币种。 + - `apps/billing/pricing.py`:新增计费计算服务;定价优先精确分辨率,再回退空 `resolution` 默认价;缺规则抛 `NoPricingRuleError(code="no_pricing_rule")`;充值点数按当前 active 汇率 `floor(amount * points_per_unit)` 计算。 + - `apps/billing/admin.py`:注册 `PricingRule` / `ExchangeRate`,运营可在 admin 配规则与汇率。 + - `apps/billing/tests.py`:覆盖分辨率精确价优先、默认价回退、换底层 `ModelAlias.ai_model` 不影响别名价格、缺规则错误码、当前汇率选择与点数向下取整。 + - 新增迁移 `billing.0002_exchangerate_pricingrule`,已应用到当前 MySQL。 + - 同步更新 `README.md`、`docs/00-ai-start-here.md`、`docs/04-architecture.md`、`docs/api.md`、`docs/06-tasks.md`、`docs/current-state.md`、`docs/project-brief.md`、`docs/project-onepager.md`。 +- 验证: + - `py -3.12 -m py_compile apps\billing\models.py apps\billing\pricing.py apps\billing\admin.py apps\billing\tests.py`:通过。 + - `py -3.12 manage.py check`:通过,0 issues。 + - `py -3.12 manage.py makemigrations --check`:通过,No changes detected。 + - `py -3.12 manage.py migrate`:通过,应用 `billing.0002_exchangerate_pricingrule`。 + - `py -3.12 manage.py showmigrations billing`:通过,`billing.0001_initial` / `billing.0002_exchangerate_pricingrule` 均为 `[X]`。 + - `Test-NetConnection 43.128.3.240 -Port 3306`:通过,`TcpTestSucceeded=True`。 + - `py -3.12 manage.py test apps.billing --noinput --keepdb`:通过,10 tests OK。 + - `py -3.12 manage.py test apps.users --noinput --keepdb`:通过,2 tests OK。 + - `py -3.12 -m compileall apps`:通过。 + - `git diff --check`:通过,仅 Windows CRLF 提示。 + - `./init.ps1`:通过,依赖同步与基础检查正常。 + - `py -3.12 manage.py test --noinput --keepdb`:两次未作为绿灯;一次跑到 31 tests 后在 `apps.ai` 测试类建事务时远程 MySQL 连接超时,一次在测试库连接阶段被远程主机重置。`py -3.12 manage.py test apps.ai --noinput --keepdb` 也在远程连接重建时超时;已跑过测试无断言失败。失败点均为远程 MySQL `43.128.3.240:3306` 连接问题。 +- 阻塞:无代码阻塞。远程 MySQL 对全量测试仍有间歇连接超时/重置;T-203 做并发扣点时需要继续使用 `--keepdb` 并必要时分 app 重试。 +- 决策: + - 定价规则绑定能力别名字符串,不外键到具体 `AiModel`;后台切换 `ModelAlias` 指向不改变价格。 + - T-202 不实现扣点、退点、充值订单或 API 编排;这些留给 T-203/T-304/T-305/T-302。 +- 下一步:领取 T-203 并发安全扣点 / 退点(billing 层)。