feat: grant signup bonus points

This commit is contained in:
QiuSW
2026-07-08 15:13:23 +08:00
parent 02aa12e8b9
commit ac8bec7b9c
29 changed files with 399 additions and 61 deletions
+1
View File
@@ -13,6 +13,7 @@ DJANGO_SECURE_HSTS_SECONDS=0
DJANGO_SECURE_HSTS_INCLUDE_SUBDOMAINS=false
DJANGO_SECURE_HSTS_PRELOAD=false
DJANGO_SECURE_PROXY_SSL_HEADER=false
ACCOUNT_SIGNUP_RATE_LIMIT=20/m/ip
# Static and shared cache
STATIC_URL=/static/
+1 -1
View File
@@ -25,7 +25,7 @@ Python 3.12 / Django 5.2 LTS + DRF / django-admin / 用户端 Django 模板 SSR
## 当前状态
Phase 2 计费核心已完成,Phase 3 对外 API 与充值已完成到 T-306,Phase 4 用户端已完成 T-501~T-505,Phase 5 已完成 T-401 运营后台完善、T-402 MVP 完整验收与 T-403 部署 / 运行文档,Phase 6 已完成 T-601 可用别名发现、T-602/T-603 django-admin 中文化、T-604 中文敏感词本地过滤、T-605 免邮箱验证策略落地、T-606 公开首页 + 客户端下载入口与 T-607 桌面端最新版本检查接口。用户可通过公开首页进入注册、登录和下载入口;登录后可扫码充值并轮询到账,生成 / 删除(吊销)API Key,查看余额、充值总额、分页充值记录、分页消费记录与可用模型;桌面端可匿名请求最新客户端版本 JSON;运营可在 django-admin 检索用户、钱包、API Key、计费规则、汇率、充值订单、点数流水、调用记录和客户端发布版本,并通过计费层带原因手工调点;生产部署按 `docs/deployment.md` 执行。2026-07-08 新增需求已登记为 T-608:新用户注册成功赠送 100 点试用点数,当前代码待按任务实现。生产侧仍需补真实支付回调到账闭环、配置并发布客户端下载包和图片真实耗时验证;邮件服务仅用于后续密码找回/通知等邮件能力,不阻塞注册登录。详见 [`docs/current-state.md`](docs/current-state.md)。
Phase 2 计费核心已完成,Phase 3 对外 API 与充值已完成到 T-306,Phase 4 用户端已完成 T-501~T-505,Phase 5 已完成 T-401 运营后台完善、T-402 MVP 完整验收与 T-403 部署 / 运行文档,Phase 6 已完成 T-601 可用别名发现、T-602/T-603 django-admin 中文化、T-604 中文敏感词本地过滤、T-605 免邮箱验证策略落地、T-606 公开首页 + 客户端下载入口、T-607 桌面端最新版本检查接口与 T-608 新用户注册赠送 100 点试用点数。用户可通过公开首页进入注册、登录和下载入口;新用户注册后经计费层自动获得 100 点并写注册赠点流水;登录后可扫码充值并轮询到账,生成 / 删除(吊销)API Key,查看余额、充值总额、分页充值记录、分页点数记录与可用模型;桌面端可匿名请求最新客户端版本 JSON;运营可在 django-admin 检索用户、钱包、API Key、计费规则、汇率、充值订单、点数流水、注册赠点记录、调用记录和客户端发布版本,并通过计费层带原因手工调点;生产部署按 `docs/deployment.md` 执行。生产侧仍需补真实支付回调到账闭环、配置并发布客户端下载包和图片真实耗时验证;邮件服务仅用于后续密码找回/通知等邮件能力,不阻塞注册登录。详见 [`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) 第四节计费时序。
+17 -1
View File
@@ -1,6 +1,13 @@
from django.contrib import admin
from .models import CallRecord, ExchangeRate, PointsLedger, PricingRule, RechargeOrder
from .models import (
CallRecord,
ExchangeRate,
PointsLedger,
PricingRule,
RechargeOrder,
SignupBonusGrant,
)
class ReadOnlyLedgerAdmin(admin.ModelAdmin):
@@ -123,3 +130,12 @@ class CallRecordAdmin(ReadOnlyLedgerAdmin):
)
ordering = ("-created_at", "-id")
list_select_related = ("user", "api_key")
@admin.register(SignupBonusGrant)
class SignupBonusGrantAdmin(ReadOnlyLedgerAdmin):
list_display = ("created_at", "user", "points_granted")
list_filter = ("created_at",)
search_fields = ("user__username", "user__email")
ordering = ("-created_at", "-id")
list_select_related = ("user",)
@@ -0,0 +1,38 @@
# Generated by Django 5.2.15 on 2026-07-08 06:28
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('billing', '0006_alter_callrecord_alias_alter_callrecord_api_key_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='pointsledger',
name='change_type',
field=models.CharField(choices=[('recharge', '充值'), ('consume', '消费'), ('adjust', '调整'), ('refund', '退款'), ('signup_bonus', '注册赠点')], max_length=20, verbose_name='变动类型'),
),
migrations.CreateModel(
name='SignupBonusGrant',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('points_granted', models.BigIntegerField(default=100, verbose_name='赠送点数')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='signup_bonus_grant', to=settings.AUTH_USER_MODEL, verbose_name='用户')),
],
options={
'verbose_name': '注册赠点记录',
'verbose_name_plural': '注册赠点记录',
'db_table': 'signup_bonus_grant',
'ordering': ('-created_at', '-id'),
'indexes': [models.Index(fields=['user', 'created_at'], name='signup_bonu_user_id_f5c054_idx')],
'constraints': [models.CheckConstraint(condition=models.Q(('points_granted__gt', 0)), name='signup_bonus_grant_points_positive')],
},
),
]
+30
View File
@@ -248,6 +248,7 @@ class PointsLedger(models.Model):
CONSUME = "consume", "消费"
ADJUST = "adjust", "调整"
REFUND = "refund", "退款"
SIGNUP_BONUS = "signup_bonus", "注册赠点"
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
@@ -307,3 +308,32 @@ class PointsLedger(models.Model):
super().clean()
if self.change_type == self.ChangeType.ADJUST and not (self.reason or "").strip():
raise ValidationError({"reason": "Adjust ledger entries require a reason."})
class SignupBonusGrant(models.Model):
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
verbose_name="用户",
on_delete=models.PROTECT,
related_name="signup_bonus_grant",
)
points_granted = models.BigIntegerField("赠送点数", default=100)
created_at = models.DateTimeField("创建时间", auto_now_add=True)
class Meta:
db_table = "signup_bonus_grant"
verbose_name = "注册赠点记录"
verbose_name_plural = "注册赠点记录"
ordering = ("-created_at", "-id")
constraints = [
models.CheckConstraint(
condition=Q(points_granted__gt=0),
name="signup_bonus_grant_points_positive",
),
]
indexes = [
models.Index(fields=("user", "created_at")),
]
def __str__(self) -> str:
return f"{self.user} signup bonus: {self.points_granted}"
+71 -1
View File
@@ -11,7 +11,13 @@ from django.utils import timezone
from apps.users.models import UserWallet
from .models import CallRecord, PointsLedger, RechargeOrder, normalize_resolution
from .models import (
CallRecord,
PointsLedger,
RechargeOrder,
SignupBonusGrant,
normalize_resolution,
)
from .pricing import quote_recharge_points
@@ -147,6 +153,16 @@ class WalletAdjustment:
balance_after: int
@dataclass(frozen=True)
class SignupBonusGrantResult:
wallet: UserWallet
grant: SignupBonusGrant
ledger_entry: PointsLedger | None
points_granted: int
balance_after: int
granted: bool
def _validate_positive_points(points: int) -> int:
if isinstance(points, bool) or not isinstance(points, int) or points <= 0:
raise ValueError("points must be a positive integer")
@@ -333,6 +349,60 @@ def adjust_wallet_points(
)
def grant_signup_bonus(
*,
user,
points: int = 100,
reason: str = "new_user_registration",
) -> SignupBonusGrantResult:
points = _validate_positive_points(points)
ledger_reason = str(reason or "new_user_registration").strip()
with transaction.atomic():
wallet = _locked_wallet_for_user(user)
grant, created = SignupBonusGrant.objects.get_or_create(
user=user,
defaults={"points_granted": points},
)
if not created:
existing_ledger = (
PointsLedger.objects.filter(
user=user,
change_type=PointsLedger.ChangeType.SIGNUP_BONUS,
)
.order_by("id")
.first()
)
return SignupBonusGrantResult(
wallet=wallet,
grant=grant,
ledger_entry=existing_ledger,
points_granted=0,
balance_after=wallet.points_balance,
granted=False,
)
wallet.points_balance += points
wallet.save(update_fields=("points_balance", "updated_at"))
ledger_entry = PointsLedger.objects.create(
user=user,
change_type=PointsLedger.ChangeType.SIGNUP_BONUS,
points_delta=points,
balance_after=wallet.points_balance,
reason=ledger_reason,
)
return SignupBonusGrantResult(
wallet=wallet,
grant=grant,
ledger_entry=ledger_entry,
points_granted=points,
balance_after=ledger_entry.balance_after,
granted=True,
)
def apply_recharge_payment(payment: RechargePayment) -> RechargeResult:
order_no = str(payment.order_no or "").strip()
pay_method = _normalize_pay_method(payment.pay_method)
+113
View File
@@ -24,6 +24,7 @@ from apps.billing.models import (
PointsLedger,
PricingRule,
RechargeOrder,
SignupBonusGrant,
)
from apps.billing.pricing import (
NoPricingRuleError,
@@ -45,6 +46,7 @@ from apps.billing.services import (
adjust_wallet_points,
apply_recharge_payment,
create_recharge_order,
grant_signup_bonus,
mark_call_success,
precharge_call,
query_and_apply_recharge_payment,
@@ -273,6 +275,7 @@ class BillingCoreModelTests(TestCase):
self.assertIn(PointsLedger, admin.site._registry)
self.assertIn(CallRecord, admin.site._registry)
self.assertIn(RechargeOrder, admin.site._registry)
self.assertIn(SignupBonusGrant, admin.site._registry)
@override_settings(AI_KEY_ENCRYPTION_KEY=TEST_ENCRYPTION_KEY)
@@ -484,6 +487,50 @@ class BillingServiceTests(TestCase):
self.assertEqual(order.exchange_rate, Decimal("12.5000"))
self.assertEqual(order.points_granted, 111)
def test_grant_signup_bonus_creates_wallet_ledger_and_is_idempotent(self):
suffix = uuid.uuid4().hex[:8]
user = get_user_model().objects.create_user(
username=f"signup-bonus-{suffix}",
email=f"signup-bonus-{suffix}@example.com",
password="password",
)
first = grant_signup_bonus(user=user)
second = grant_signup_bonus(user=user)
wallet = UserWallet.objects.get(user=user)
self.assertTrue(first.granted)
self.assertFalse(second.granted)
self.assertEqual(first.points_granted, 100)
self.assertEqual(second.points_granted, 0)
self.assertEqual(first.balance_after, 100)
self.assertEqual(second.balance_after, 100)
self.assertEqual(wallet.points_balance, 100)
self.assertEqual(SignupBonusGrant.objects.filter(user=user).count(), 1)
ledger = PointsLedger.objects.get(
user=user,
change_type=PointsLedger.ChangeType.SIGNUP_BONUS,
)
self.assertEqual(ledger.points_delta, 100)
self.assertEqual(ledger.balance_after, 100)
self.assertEqual(ledger.reason, "new_user_registration")
def test_grant_signup_bonus_adds_to_existing_wallet_balance(self):
suffix = uuid.uuid4().hex[:8]
user = get_user_model().objects.create_user(
username=f"signup-existing-wallet-{suffix}",
email=f"signup-existing-wallet-{suffix}@example.com",
password="password",
)
UserWallet.objects.create(user=user, points_balance=30)
result = grant_signup_bonus(user=user)
wallet = UserWallet.objects.get(user=user)
self.assertTrue(result.granted)
self.assertEqual(wallet.points_balance, 130)
self.assertEqual(result.balance_after, 130)
def test_create_recharge_order_marks_order_failed_when_gateway_fails(self):
self.create_exchange_rate(points_per_unit="10.0000")
@@ -957,3 +1004,69 @@ class ConcurrentDebitTests(TransactionTestCase):
balance_after__lt=0,
).exists()
)
class ConcurrentSignupBonusTests(TransactionTestCase):
def setUp(self):
self.addCleanup(connections.close_all)
suffix = uuid.uuid4().hex[:8]
self.user = get_user_model().objects.create_user(
username=f"concurrent-signup-{suffix}",
email=f"concurrent-signup-{suffix}@example.com",
password="password",
)
UserWallet.objects.create(user=self.user, points_balance=0)
def test_concurrent_signup_bonus_is_granted_once(self):
worker_count = 2
barrier = threading.Barrier(worker_count)
results = []
errors = []
results_lock = threading.Lock()
def worker():
connections.close_all()
try:
for attempt in range(3):
try:
connections["default"].ensure_connection()
break
except OperationalError as exc:
connections.close_all()
if attempt == 2:
with results_lock:
errors.append(repr(exc))
barrier.abort()
return
time.sleep(0.5)
barrier.wait(timeout=15)
result = grant_signup_bonus(user=self.user)
with results_lock:
results.append((result.granted, result.balance_after))
except Exception as exc: # pragma: no cover - surfaced through assertion below.
with results_lock:
errors.append(repr(exc))
finally:
connections.close_all()
threads = [threading.Thread(target=worker) for _index in range(worker_count)]
for thread in threads:
thread.start()
for thread in threads:
thread.join(timeout=30)
self.assertFalse(any(thread.is_alive() for thread in threads), "worker thread timed out")
self.assertEqual(errors, [])
self.assertEqual(sum(1 for granted, _balance in results if granted), 1)
self.assertEqual(sum(1 for granted, _balance in results if not granted), 1)
wallet = UserWallet.objects.get(user=self.user)
self.assertEqual(wallet.points_balance, 100)
self.assertEqual(SignupBonusGrant.objects.filter(user=self.user).count(), 1)
self.assertEqual(
PointsLedger.objects.filter(
user=self.user,
change_type=PointsLedger.ChangeType.SIGNUP_BONUS,
).count(),
1,
)
+2 -5
View File
@@ -4,7 +4,7 @@ from django.db import transaction
from allauth.account.adapter import DefaultAccountAdapter
from apps.users.models import UserWallet
from apps.billing.services import grant_signup_bonus
class CmhubAccountAdapter(DefaultAccountAdapter):
@@ -12,8 +12,5 @@ class CmhubAccountAdapter(DefaultAccountAdapter):
with transaction.atomic():
saved_user = super().save_user(request, user, form, commit=commit)
if commit:
UserWallet.objects.get_or_create(
user=saved_user,
defaults={"points_balance": 0},
)
grant_signup_bonus(user=saved_user)
return saved_user
@@ -5,6 +5,9 @@
{% block content %}
<section class="cmhub-panel">
<h1 class="h4 mb-3">注册</h1>
<div class="alert alert-info">
注册成功后自动赠送 100 点试用点数,系统会写入注册赠点流水。
</div>
<form method="post" action="{% url 'portal-signup' %}">
{% csrf_token %}
{{ form.non_field_errors }}
+1 -1
View File
@@ -20,7 +20,7 @@
<a class="btn btn-sm {% if current_url == 'portal-apikeys' %}btn-primary{% else %}btn-outline-secondary{% endif %}"{% if current_url == 'portal-apikeys' %} aria-current="page"{% endif %} href="{% url 'portal-apikeys' %}">API Key</a>
<a class="btn btn-sm {% if current_url == 'portal-models' %}btn-primary{% else %}btn-outline-secondary{% endif %}"{% if current_url == 'portal-models' %} aria-current="page"{% endif %} href="{% url 'portal-models' %}">可用模型</a>
<a class="btn btn-sm {% if current_url == 'portal-recharge-records' %}btn-primary{% else %}btn-outline-secondary{% endif %}"{% if current_url == 'portal-recharge-records' %} aria-current="page"{% endif %} href="{% url 'portal-recharge-records' %}">充值记录</a>
<a class="btn btn-sm {% if current_url == 'portal-usage-records' %}btn-primary{% else %}btn-outline-secondary{% endif %}"{% if current_url == 'portal-usage-records' %} aria-current="page"{% endif %} href="{% url 'portal-usage-records' %}">消费记录</a>
<a class="btn btn-sm {% if current_url == 'portal-usage-records' %}btn-primary{% else %}btn-outline-secondary{% endif %}"{% if current_url == 'portal-usage-records' %} aria-current="page"{% endif %} href="{% url 'portal-usage-records' %}">点数记录</a>
<form method="post" action="{% url 'portal-logout' %}">
{% csrf_token %}
<button class="btn btn-sm btn-outline-secondary" type="submit">退出</button>
+7 -7
View File
@@ -28,8 +28,8 @@
</div>
<div class="col-md-3">
<div class="metric">
<div class="text-secondary small">入账点数</div>
<div class="display-6 fw-semibold">{{ recharge_points_total }}</div>
<div class="text-secondary small">获得点数</div>
<div class="display-6 fw-semibold">{{ credited_points_total }}</div>
</div>
</div>
<div class="col-md-3">
@@ -62,8 +62,8 @@
<div class="col-md-4">
<div class="metric d-flex flex-column gap-3">
<div>
<div class="text-secondary small">点数使用</div>
<div class="h5 mb-0">消费记录</div>
<div class="text-secondary small">点数账本</div>
<div class="h5 mb-0">点数记录</div>
</div>
<a class="btn btn-primary align-self-start" href="{% url 'portal-usage-records' %}">查看</a>
</div>
@@ -107,7 +107,7 @@
<section class="cmhub-surface">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="h5 mb-0">最近消费</h2>
<h2 class="h5 mb-0">最近点数变动</h2>
<a class="btn btn-sm btn-outline-secondary" href="{% url 'portal-usage-records' %}">全部</a>
</div>
{% if recent_usage_entries %}
@@ -126,7 +126,7 @@
{% for entry in recent_usage_entries %}
<tr>
<td>{{ entry.created_at|date:"Y-m-d H:i" }}</td>
<td>{{ entry.change_type }}</td>
<td>{{ entry.get_change_type_display }}</td>
<td>{{ entry.ref_call.alias|default:"-" }}</td>
<td>{{ entry.points_delta }}</td>
<td>{{ entry.balance_after }}</td>
@@ -136,7 +136,7 @@
</table>
</div>
{% else %}
<div class="text-secondary">暂无消费记录</div>
<div class="text-secondary">暂无点数记录</div>
{% endif %}
</section>
</div>
+3 -3
View File
@@ -18,7 +18,7 @@
{% if user.is_authenticated %}
<a class="btn btn-primary btn-lg" href="{% url 'portal-dashboard' %}">进入控制台</a>
{% else %}
<a class="btn btn-primary btn-lg" href="{% url 'portal-signup' %}">免费注册</a>
<a class="btn btn-primary btn-lg" href="{% url 'portal-signup' %}">注册领 100 点</a>
<a class="btn btn-outline-secondary btn-lg" href="{% url 'portal-login' %}">登录</a>
{% endif %}
{% if current_release and current_release.has_download %}
@@ -28,7 +28,7 @@
{% endif %}
</div>
<div class="home-proof" aria-label="服务要点">
<span class="home-chip">注册即可用</span>
<span class="home-chip">注册送 100 点</span>
<span class="home-chip">按点数扣点</span>
<span class="home-chip">API Key 接入</span>
</div>
@@ -70,7 +70,7 @@
<div class="home-step">
<div class="home-step-num">01</div>
<h3>注册账号</h3>
<p>邮箱注册后直接登录,系统创建 0 点钱包,不赠点、不写虚假流水。</p>
<p>邮箱注册后直接登录,系统通过点数账本发放 100 点试用额度并写注册赠点流水。</p>
</div>
<div class="home-step">
<div class="home-step-num">02</div>
@@ -1,11 +1,11 @@
{% extends "portal/base.html" %}
{% block title %}消费记录 - cmhub{% endblock %}
{% block title %}点数记录 - cmhub{% endblock %}
{% block content %}
<div class="d-flex flex-column gap-4">
<div>
<h1 class="h3 mb-1">消费记录</h1>
<h1 class="h3 mb-1">点数记录</h1>
<div class="text-secondary">{{ user.email }}</div>
</div>
@@ -31,7 +31,7 @@
</div>
<section class="cmhub-surface">
<h2 class="h5 mb-3">点数使用流水</h2>
<h2 class="h5 mb-3">点数流水</h2>
{% if usage_entries %}
<div class="table-responsive">
<table class="table align-middle mb-0">
@@ -51,7 +51,7 @@
{% for entry in usage_entries %}
<tr>
<td>{{ entry.created_at|date:"Y-m-d H:i" }}</td>
<td>{{ entry.change_type }}</td>
<td>{{ entry.get_change_type_display }}</td>
<td>
{% if entry.ref_call %}
{{ entry.ref_call.operation_type }}
@@ -89,7 +89,7 @@
</div>
{% include "portal/includes/pagination.html" %}
{% else %}
<div class="text-secondary">暂无消费记录</div>
<div class="text-secondary">暂无点数记录</div>
{% endif %}
</section>
</div>
+34 -6
View File
@@ -2,6 +2,7 @@ import uuid
from decimal import Decimal
from allauth.account.models import EmailAddress
from django.conf import settings
from django.contrib.auth import get_user, get_user_model
from django.contrib.staticfiles import finders
from django.core import mail
@@ -17,6 +18,7 @@ from apps.billing.models import (
PointsLedger,
PricingRule,
RechargeOrder,
SignupBonusGrant,
)
from apps.portal.models import DownloadRelease
from apps.users.models import ApiKey, UserWallet
@@ -144,7 +146,10 @@ class PortalAccountFlowTests(TestCase):
html,
)
def test_signup_creates_user_wallet_with_zero_points_no_ledger_and_can_login(self):
def test_signup_rate_limit_is_configured(self):
self.assertEqual(settings.ACCOUNT_RATE_LIMITS["signup"], "20/m/ip")
def test_signup_grants_100_points_writes_signup_bonus_ledger_and_can_login(self):
suffix = uuid.uuid4().hex[:8]
email = f"signup-{suffix}@example.com"
@@ -164,11 +169,23 @@ class PortalAccountFlowTests(TestCase):
wallet = UserWallet.objects.get(user=user)
self.assertFalse(email_address.verified)
self.assertTrue(email_address.primary)
self.assertEqual(wallet.points_balance, 0)
self.assertFalse(PointsLedger.objects.filter(user=user).exists())
self.assertEqual(wallet.points_balance, 100)
self.assertEqual(SignupBonusGrant.objects.filter(user=user).count(), 1)
ledger = PointsLedger.objects.get(
user=user,
change_type=PointsLedger.ChangeType.SIGNUP_BONUS,
)
self.assertEqual(ledger.points_delta, 100)
self.assertEqual(ledger.balance_after, 100)
self.assertEqual(len(mail.outbox), 0)
self.assertTrue(get_user(self.client).is_authenticated)
dashboard_response = self.client.get("/dashboard")
self.assertEqual(dashboard_response.status_code, 200)
self.assertEqual(dashboard_response.context["balance"].points_balance, 100)
self.assertEqual(dashboard_response.context["balance"].ledger_balance, 100)
self.assertContains(dashboard_response, "100")
def test_user_can_login_without_verified_email(self):
suffix = uuid.uuid4().hex[:8]
user = get_user_model().objects.create_user(
@@ -237,7 +254,8 @@ class PortalAccountFlowTests(TestCase):
self.assertEqual(response.status_code, 200)
self.assertEqual(response.resolver_match.url_name, "portal-home")
self.assertContains(response, "cmhub AI 电商生成台")
self.assertContains(response, "免费注册")
self.assertContains(response, "注册领 100 点")
self.assertContains(response, "注册送 100 点")
self.assertContains(response, "登录")
self.assertContains(response, "客户端暂未发布")
self.assertContains(response, "暂未发布")
@@ -269,7 +287,7 @@ class PortalAccountFlowTests(TestCase):
self.assertEqual(response.status_code, 200)
self.assertContains(response, "进入控制台")
self.assertNotContains(response, "免费注册")
self.assertNotContains(response, "注册领 100 点")
def test_download_release_only_keeps_one_current_per_platform(self):
old_release = self.create_download_release(version="1.0.0", sha256="c" * 64)
@@ -349,7 +367,7 @@ class PortalAccountFlowTests(TestCase):
("/apikeys", "/apikeys", "API Key"),
("/models", "/models", "可用模型"),
("/records/recharge", "/records/recharge", "充值记录"),
("/records/usage", "/records/usage", "消费记录"),
("/records/usage", "/records/usage", "点数记录"),
)
for path, href, label in cases:
@@ -526,6 +544,7 @@ class PortalAccountFlowTests(TestCase):
self.assertEqual(response.context["balance"].ledger_balance, 160)
self.assertEqual(response.context["recharge_total_amount"], Decimal("20.00"))
self.assertEqual(response.context["recharge_points_total"], 200)
self.assertEqual(response.context["credited_points_total"], 200)
self.assertEqual(response.context["consumed_points_total"], 50)
self.assertEqual(response.context["refunded_points_total"], 10)
self.assertEqual(response.context["net_used_points"], 40)
@@ -634,6 +653,13 @@ class PortalAccountFlowTests(TestCase):
alias="other-alias",
points_cost=90,
)
PointsLedger.objects.create(
user=user,
change_type=PointsLedger.ChangeType.SIGNUP_BONUS,
points_delta=100,
balance_after=100,
reason="new_user_registration",
)
PointsLedger.objects.create(
user=user,
change_type=PointsLedger.ChangeType.CONSUME,
@@ -667,6 +693,8 @@ class PortalAccountFlowTests(TestCase):
self.assertEqual(response.context["consumed_points_total"], 40)
self.assertEqual(response.context["refunded_points_total"], 15)
self.assertEqual(response.context["net_used_points"], 25)
self.assertContains(response, "注册赠点")
self.assertContains(response, "100")
self.assertContains(response, "title-standard")
self.assertContains(response, api_key.key_prefix)
self.assertContains(response, "-40")
+10 -1
View File
@@ -47,11 +47,19 @@ def get_portal_account_summary(user) -> dict:
user=user,
change_type=PointsLedger.ChangeType.RECHARGE,
)
signup_bonus_entries = PointsLedger.objects.filter(
user=user,
change_type=PointsLedger.ChangeType.SIGNUP_BONUS,
)
consumed_points = abs(int(_sum_or_zero(consume_entries, "points_delta")))
refunded_points = int(_sum_or_zero(refund_entries, "points_delta"))
recharge_points = int(_sum_or_zero(recharge_entries, "points_delta"))
signup_bonus_points = int(_sum_or_zero(signup_bonus_entries, "points_delta"))
return {
"recharge_total_amount": _sum_or_zero(paid_orders, "amount_money"),
"recharge_points_total": int(_sum_or_zero(recharge_entries, "points_delta")),
"recharge_points_total": recharge_points,
"signup_bonus_points_total": signup_bonus_points,
"credited_points_total": recharge_points + signup_bonus_points,
"consumed_points_total": consumed_points,
"refunded_points_total": refunded_points,
"net_used_points": consumed_points - refunded_points,
@@ -70,6 +78,7 @@ def get_usage_ledger_entries_for_user(user):
change_type__in=(
PointsLedger.ChangeType.CONSUME,
PointsLedger.ChangeType.REFUND,
PointsLedger.ChangeType.SIGNUP_BONUS,
),
)
.select_related("ref_call", "ref_call__api_key")
+3
View File
@@ -204,6 +204,9 @@ ACCOUNT_EMAIL_VERIFICATION = "none"
ACCOUNT_UNIQUE_EMAIL = True
ACCOUNT_LOGIN_METHODS = {"email", "username"}
ACCOUNT_SIGNUP_FIELDS = ["username*", "email*", "password1*", "password2*"]
ACCOUNT_RATE_LIMITS = {
"signup": os.environ.get("ACCOUNT_SIGNUP_RATE_LIMIT", "20/m/ip"),
}
ACCOUNT_LOGIN_ON_EMAIL_CONFIRMATION = False
ACCOUNT_EMAIL_CONFIRMATION_ANONYMOUS_REDIRECT_URL = "/login"
ACCOUNT_EMAIL_CONFIRMATION_AUTHENTICATED_REDIRECT_URL = "/dashboard"
+2 -2
View File
@@ -38,7 +38,7 @@
## 当前阶段
当前项目处于:**Phase 6 增强任务推进期**。Phase 2 计费核心已完成到 T-204;Phase 3 已完成 T-301 API Key 鉴权、T-302 生成标题 / 图片接口、T-303 余额查询接口、T-304 充值回调、T-305 扫码充值下单 + 轮询与 T-306 对外 API 安全加固;Phase 4 已完成 T-501 注册 / 登录(allauth)、T-502 API Key 自助管理页、T-503 个人中心 / 记录页、T-504 充值页与 T-505 用户端审核优化;Phase 5 已完成 T-401 运营后台完善、T-402 MVP 完整验收与 T-403 部署 / 运行文档;Phase 6 已完成 T-601「可用别名发现」、T-602「django-admin 中文化第 1-3 层」、T-603「django-admin 字段级中文化」、T-604「中文敏感词本地过滤」、T-605「免邮箱验证策略落地」、T-606「公开首页 + 客户端下载入口」与 T-607「桌面端最新版本检查接口」;T-608「新用户注册赠送 100 点试用点数」已登记为下一项 TODO。生产侧仍需补真实支付回调到账闭环、配置并发布客户端下载包和图片生成真实耗时验证;邮件服务仅用于后续密码找回/通知等邮件能力,不阻塞注册登录。
当前项目处于:**Phase 6 增强任务推进期**。Phase 2 计费核心已完成到 T-204;Phase 3 已完成 T-301 API Key 鉴权、T-302 生成标题 / 图片接口、T-303 余额查询接口、T-304 充值回调、T-305 扫码充值下单 + 轮询与 T-306 对外 API 安全加固;Phase 4 已完成 T-501 注册 / 登录(allauth)、T-502 API Key 自助管理页、T-503 个人中心 / 记录页、T-504 充值页与 T-505 用户端审核优化;Phase 5 已完成 T-401 运营后台完善、T-402 MVP 完整验收与 T-403 部署 / 运行文档;Phase 6 已完成 T-601「可用别名发现」、T-602「django-admin 中文化第 1-3 层」、T-603「django-admin 字段级中文化」、T-604「中文敏感词本地过滤」、T-605「免邮箱验证策略落地」、T-606「公开首页 + 客户端下载入口」、T-607「桌面端最新版本检查接口」与 T-608「新用户注册赠送 100 点试用点数」。生产侧仍需补真实支付回调到账闭环、配置并发布客户端下载包和图片生成真实耗时验证;邮件服务仅用于后续密码找回/通知等邮件能力,不阻塞注册登录。
优先路径:
@@ -48,7 +48,7 @@
4. Phase 3:对外 API 与充值 —— T-301 Key 鉴权、T-302 生成接口、T-303 余额查询、T-304 充值回调、T-305 扫码下单与轮询、T-306 安全加固已完成。
5. Phase 4:用户端(Django 模板 SSR)—— T-501 注册登录、T-502 API Key 管理、T-503 个人中心 / 记录页、T-504 充值页与 T-505 用户端审核优化已完成。
6. Phase 5:后台与发布 —— T-401 运营后台完善、T-402 完整验收 MVP、T-403 部署 / 运行文档已完成;计划内 MVP 任务已收尾。
7. Phase 6:增强(MVP 后)—— T-601 可用别名发现已完成,实现 `/api/v1/models` 与 portal 只读「可用模型」页;T-602 已完成 django-admin 分组/表名中文化;T-603 已完成字段级中文标签代码与 no-op 迁移并人工确认 admin 字段中文化;T-604 已完成中文敏感词本地过滤;T-605 已完成免邮箱验证策略落地;T-606 已完成公开首页 + 客户端下载入口;T-607 已完成桌面端最新版本检查接口;T-608 已拆为「新用户注册赠送 100 点试用点数」,下一步可领取实现。
7. Phase 6:增强(MVP 后)—— T-601 可用别名发现已完成,实现 `/api/v1/models` 与 portal 只读「可用模型」页;T-602 已完成 django-admin 分组/表名中文化;T-603 已完成字段级中文标签代码与 no-op 迁移并人工确认 admin 字段中文化;T-604 已完成中文敏感词本地过滤;T-605 已完成免邮箱验证策略落地;T-606 已完成公开首页 + 客户端下载入口;T-607 已完成桌面端最新版本检查接口;T-608 已完成新用户注册赠送 100 点试用点数。
## 领取任务规则
+3 -3
View File
@@ -11,7 +11,7 @@
| Web 框架 | Django 5.2 LTS | 已定 | 自带 ORM、迁移、admin,适合「API + 运营后台」;选 LTS 维护到 2028,安全更新窗口最长。**禁用已 EOL 的 4.0/4.1**(无安全补丁,资金服务不可用) |
| API 框架 | Django REST Framework (DRF) | 已定 | 鉴权、序列化、参数校验、限流现成 |
| 运营后台 | django-admin | 已定 | 近零代码即得用户/点数/记录的增删改查与检索,省 80% 后台工作量 |
| 用户端 | Django 模板 SSR + Bootstrap 5 + django-allauth | 已定 | T-501 已落地自助注册/登录/登出与最小 dashboard;T-605 起注册策略固定为**免邮箱验证、注册即可用,邮箱仍必填且唯一**;T-502 已落地 API Key 自助生成 / 删除页;T-503 已落地个人中心、充值记录和消费记录页;T-504 已落地充值页(创建订单、展示二维码票据、轮询到账后刷新);T-505 已把 Bootstrap 与 qrcode.js 改为本地 static 自托管,并给记录页加分页;MVP 先用 Django form + Bootstrap 模板,不为简单表单引入 crispy-forms |
| 用户端 | Django 模板 SSR + Bootstrap 5 + django-allauth | 已定 | T-501 已落地自助注册/登录/登出与最小 dashboard;T-605 起注册策略固定为**免邮箱验证、注册即可用,邮箱仍必填且唯一**;T-608 已落地新用户注册成功经计费层赠送 100 点并写注册赠点流水;T-502 已落地 API Key 自助生成 / 删除页;T-503 已落地个人中心、充值记录和点数记录页;T-504 已落地充值页(创建订单、展示二维码票据、轮询到账后刷新);T-505 已把 Bootstrap 与 qrcode.js 改为本地 static 自托管,并给记录页加分页;MVP 先用 Django form + Bootstrap 模板,不为简单表单引入 crispy-forms |
| 后台美化 | django-unfold 或 simpleui | 待定 | 仅外观,MVP 可先用原生 admin,后期按需引入 |
| AI 上游对接 | **Provider 适配器层**(按 `api_type` 注册)+ **能力别名** 映射 + `requests` HTTP 客户端 | 已定 | 对外只暴露 `generate text/image` 两接口与别名;换供应商改后台映射,不动对外契约。移植 `cmbot` 的调用逻辑到各适配器。当前 3 模型机制不同:文本 chat、`nano-banana2` chat 多模态返图、`gpt-image-2` images/edits 改图(详见 `04` 3.1) |
| 供应商密钥存储 | 应用层 Fernet 加密(`cryptography`) | 已定 | `AiModel.api_key_encrypted` 加密入库、admin 写入型字段不回显;加密主密钥 `AI_KEY_ENCRYPTION_KEY` 走环境变量,配置清单见 `env.md` |
@@ -20,7 +20,7 @@
| 数据库 | MySQL 8.4 LTS(cmhub 专用独立实例) | 已定 | 满足 Django 5.2 的 MySQL ≥8.0.11;引擎 InnoDB + 字符集 utf8mb4;行锁 `select_for_update` / 条件更新保并发扣点。**不复用 VPS 已有的 MySQL 5.7**(跑不了 Django 5.2、无 CHECK 约束)。开发亦用 MySQL,勿用 SQLite(不支持 `select_for_update`) |
| MySQL 驱动 | PyMySQL + cryptography | 已定 | PyMySQL 负责 Django 连接 MySQL;MySQL 8 默认 `caching_sha2_password` 认证需要 `cryptography` 支持;客户端连接/读/写超时通过 `MYSQL_CONNECT_TIMEOUT` / `MYSQL_READ_TIMEOUT` / `MYSQL_WRITE_TIMEOUT` 配置 |
| 对外鉴权 | API Key(DRF 自定义 Authentication,哈希存储比对) | 已定 | 用户自助生成 Key;**API 只认 Key、不挂 SessionAuthentication**,防浏览器 cookie 绕过计费 |
| 用户端鉴权 | Django Session(+ allauth 注册登录,免邮箱验证) | 已定 | T-501 已落地 `/signup` `/login` `/logout` 与 `/dashboard`;T-605 固定 `ACCOUNT_EMAIL_VERIFICATION="none"`,新用户注册后可直接使用;T-502 已落地 `/apikeys`;T-503 已落地 `/records/recharge` 与 `/records/usage`;T-504 已落地 `/recharge`;用户端页面与 `recharge/create` 走 session + CSRF;后台账号也用 Session 登录 |
| 用户端鉴权 | Django Session(+ allauth 注册登录,免邮箱验证) | 已定 | T-501 已落地 `/signup` `/login` `/logout` 与 `/dashboard`;T-605 固定 `ACCOUNT_EMAIL_VERIFICATION="none"`,新用户注册后可直接使用;T-608 显式配置注册限流 `ACCOUNT_SIGNUP_RATE_LIMIT`(默认 `20/m/ip`,由 allauth signup rate limit 执行);T-502 已落地 `/apikeys`;T-503 已落地 `/records/recharge` 与 `/records/usage`;T-504 已落地 `/recharge`;用户端页面与 `recharge/create` 走 session + CSRF;后台账号也用 Session 登录 |
| 充值对接 | 自助扫码:微信 V3 native + 支付宝当面付;下单取二维码 + 服务端回调(验签 + 幂等) | 已定 | T-304/T-305 已落地回调、扫码下单、状态轮询、HMAC mock 联调与 SDK 模式入口;生产需安装并配置 `wechatpayv3` / `python-alipay-sdk` 与真实商户密钥/证书 |
| 生成返回方式 | 同步 HTTP(无任务队列) | 已定 | MVP 简化;图片接口需调大网关/服务超时 |
| 任务队列 | 暂不引入(Celery/RQ) | 待定 | V2 异步化时再评估 |
@@ -41,7 +41,7 @@
- **同步可用的前提是「超时链路 + worker 容量」配对**,否则会「小量正常、上量假死」:① 每模型 `timeout_seconds` 设有限值(`ai_models.json` 现为 `0`,迁入须改);② Gunicorn `--timeout` 与网关 `proxy_read_timeout` 按最慢图片放大(别用默认 30s / 60s);③ worker/线程数按**峰值总并发**预留。详见 [架构设计](04-architecture.md) 第五节结论。
- **何时转 V2 异步**:worker 被长连接占满拖慢快接口/后台、接入方总并发明显上涨、或需要「关窗重连/任务持久化」体验——在此之前保持同步。
- **数据库:MySQL 8.4 LTS,cmhub 专用独立实例**:① 部署环境的 VPS 已装 MySQL 5.7 供其他服务用,但 5.7 跑不了 Django 5.2(需 ≥8.0.11)、已 EOL、且不支持 CHECK 约束,故**不复用**它;② 机器内存宽裕(`available` 7.4G),给 cmhub **单开一个 MySQL 8.4 LTS 实例**(独立端口/容器),与已有 5.7 完全隔离、互不影响;③ 选 8.4 LTS 取长维护窗口 + 完整 CHECK 约束(CHECK 需 MySQL ≥8.0.16 才真正生效);④ 强制 InnoDB + utf8mb4(5.7/老配置默认非 utf8mb4,prompt 的 emoji/生僻字会写失败);⑤ MySQL 默认隔离级别 REPEATABLE READ(不同于 PostgreSQL 的 READ COMMITTED),`select_for_update` 扣点仍安全,但计费实现按此语义验证;⑥ 开发环境同用 MySQL,不要用 SQLite——SQLite 会静默忽略 `FOR UPDATE`,并发扣点逻辑测不出来;⑦ 不在代码里写死只适配某一种库的 SQL。
- **用户端用 Django 模板 SSR 单体,不引前端框架**:需求含终端用户自助(注册/充值/API Key/记录),选 Django 模板 + Bootstrap + allauth 与后端同工程单体部署,复用 Django auth/session,开发部署最快、最契合单机 MVP;代价是交互不如 SPA,可后续加 HTMX。T-501 已接入 django-allauth 65.18.0,使用 session 与 CSRF;T-605 起注册策略固定为 `ACCOUNT_EMAIL_VERIFICATION="none"`,免邮箱验证、注册即可用,邮箱仍必填且唯一;T-608 已登记把注册成功后的初始化改为经 `apps.billing` 发放 100 点试用点数并写 `signup_bonus` 流水。T-502 已用 Django Form + Bootstrap 模板落地 `/apikeys`,生成 Key 后明文只显示一次,删除写为 `revoked`。T-503 已用只读 Django TemplateView 落地 dashboard 汇总、充值记录和消费记录。T-504 已用 Django Form + Bootstrap 模板落地 `/recharge`:页面 POST 创建 pending 充值订单,展示支付二维码票据,并用浏览器轮询 `/api/v1/recharge/status`,订单 paid 后刷新余额。T-505 已把 Bootstrap 5 CSS 与 qrcode.js vendoring 到 `apps/portal/static/portal/vendor/`,页面不再依赖 jsdelivr,充值/消费记录页改为 Django `Paginator` 分页。放弃 Vue/React 前后端分离(两套项目/部署,与单体 MVP 调性冲突)。用户模型:`User`(auth) 持登录态、`UserWallet` 持点数(扣点锁 wallet、与 auth 解耦)、`ApiKey`(User 1:N,哈希存储)。新注册用户一次性赠送 100 点,必须走计费层和点数流水。
- **用户端用 Django 模板 SSR 单体,不引前端框架**:需求含终端用户自助(注册/充值/API Key/记录),选 Django 模板 + Bootstrap + allauth 与后端同工程单体部署,复用 Django auth/session,开发部署最快、最契合单机 MVP;代价是交互不如 SPA,可后续加 HTMX。T-501 已接入 django-allauth 65.18.0,使用 session 与 CSRF;T-605 起注册策略固定为 `ACCOUNT_EMAIL_VERIFICATION="none"`,免邮箱验证、注册即可用,邮箱仍必填且唯一;T-608 已把注册成功后的初始化改为经 `apps.billing` 发放 100 点试用点数并写 `signup_bonus` 流水,且显式配置 `ACCOUNT_SIGNUP_RATE_LIMIT` 做基础注册限流。T-502 已用 Django Form + Bootstrap 模板落地 `/apikeys`,生成 Key 后明文只显示一次,删除写为 `revoked`。T-503 已用只读 Django TemplateView 落地 dashboard 汇总、充值记录和点数记录。T-504 已用 Django Form + Bootstrap 模板落地 `/recharge`:页面 POST 创建 pending 充值订单,展示支付二维码票据,并用浏览器轮询 `/api/v1/recharge/status`,订单 paid 后刷新余额。T-505 已把 Bootstrap 5 CSS 与 qrcode.js vendoring 到 `apps/portal/static/portal/vendor/`,页面不再依赖 jsdelivr,充值/点数记录页改为 Django `Paginator` 分页。放弃 Vue/React 前后端分离(两套项目/部署,与单体 MVP 调性冲突)。用户模型:`User`(auth) 持登录态、`UserWallet` 持点数(扣点锁 wallet、与 auth 解耦)、`ApiKey`(User 1:N,哈希存储)。新注册用户一次性赠送 100 点,必须走计费层和点数流水。
## 三、构建与运行命令
+5 -5
View File
@@ -18,7 +18,7 @@
组件落位:
- **用户端层(Django 模板 SSR)**:公开首页、注册/登录(Django auth / allauth)、个人中心(余额/充值总额/充值记录/消费记录)、API Key 自助管理、发起扫码充值、模型目录与客户端下载入口。入口 `apps/portal/`,公开首页匿名可访问,其他自助页面用 session 鉴权。T-501 已落地 `/signup`、`/login`、`/logout` 与最小 `/dashboard`;T-608 需把注册成功后的钱包初始化改为经计费层一次性发放 100 点试用点数,并写 `signup_bonus` 点数流水。T-502 已落地 `/apikeys`,用户可自助生成和删除(吊销)自己的 API Key,明文只显示一次,列表只显示 prefix。T-503 已扩展 `/dashboard` 并新增 `/records/recharge`、`/records/usage`,只读展示当前用户余额、充值订单与消费流水。T-504 已落地 `/recharge`,用户可创建 pending 充值订单、查看二维码票据,并轮询订单状态;到账仍以服务端回调或主动查单入账后的本地订单状态为准。T-505 已把 Bootstrap/qrcode.js 改成本地 static 自托管,并把充值/消费记录页从固定切片改为分页。T-606 已把 `/` 改为公开首页,并新增 `DownloadRelease` 下载版本配置用于展示 Windows 客户端版本、下载地址、SHA256 与发布说明;T-607 已新增公开 JSON 版本检查接口给桌面端自动更新使用。
- **用户端层(Django 模板 SSR)**:公开首页、注册/登录(Django auth / allauth)、个人中心(余额/充值总额/充值记录/点数记录)、API Key 自助管理、发起扫码充值、模型目录与客户端下载入口。入口 `apps/portal/`,公开首页匿名可访问,其他自助页面用 session 鉴权。T-501/T-608 已落地 `/signup`、`/login`、`/logout` 与 `/dashboard`;注册成功后经计费层一次性发放 100 点试用点数,并写 `signup_bonus` 点数流水。T-502 已落地 `/apikeys`,用户可自助生成和删除(吊销)自己的 API Key,明文只显示一次,列表只显示 prefix。T-503/T-608 已扩展 `/dashboard` 并新增 `/records/recharge`、`/records/usage`,只读展示当前用户余额、充值订单、注册赠点与消费/退款流水。T-504 已落地 `/recharge`,用户可创建 pending 充值订单、查看二维码票据,并轮询订单状态;到账仍以服务端回调或主动查单入账后的本地订单状态为准。T-505 已把 Bootstrap/qrcode.js 改成本地 static 自托管,并把充值/点数记录页从固定切片改为分页。T-606 已把 `/` 改为公开首页,并新增 `DownloadRelease` 下载版本配置用于展示 Windows 客户端版本、下载地址、SHA256 与发布说明;T-607 已新增公开 JSON 版本检查接口给桌面端自动更新使用。
- **用户与账号层**:注册用户 `User`、点数钱包 `UserWallet`、`ApiKey`(一用户多把、哈希存储)。入口 `apps/users/`。
- **API 层(DRF)**:对外生成接口、余额查询、支付回调接收、扫码下单、公开版本检查接口。入口 `apps/api/`。生成/余额/模型目录这类对外业务 API **只认 API Key,不接受 Web session**;充值下单/状态查询属于用户端流程,走 Web session + CSRF;支付回调走平台验签;T-607 的客户端下载版本检查接口为公开只读例外,不需要 API Key,不读取用户、不扣点。
- **计费层**:点数计算、原子扣减(锁 `UserWallet` 行)、退点、充值入账、流水记账。入口 `apps/billing/`。
@@ -58,7 +58,7 @@ T-306 已实现对外 API 安全加固:`download_image_input()` 在请求前
- 充值入账:接收已验签的支付回调数据,按汇率换算点数,幂等入账,写流水。
- 注册赠点:新用户注册成功后一次性发放 100 点试用点数,必须幂等、防并发重复,并写注册赠点流水。
- 手工调点:运营后台只收集点数变动和原因,必须调用计费层 `adjust_wallet_points()`;服务在事务内锁 `UserWallet`,拒绝扣成负数,并写 `PointsLedger(change_type=adjust)`。
- 点数流水记账:所有点数变动(充值/消费/调整/冲正)都生成一条流水。
- 点数流水记账:所有点数变动(充值/消费/调整/冲正/注册赠点)都生成一条流水。
- 是点数余额的唯一写入方。
**AI 调用层(`apps/ai`)**
@@ -310,7 +310,7 @@ CREATE TABLE call_record (
- `payment_user_id`、`payment_txn_no` 为对账预留,字段先建。
- `recharge_order.exchange_rate` 与 `points_granted` 在下单时写入,状态为 `pending` 时也必须有值;支付回调金额必须与订单金额一致,入账时不得按新的汇率重算。
- `call_record.status` 状态机为 `pending -> success / failed`。上游失败退点后仍保持 `failed`,退款流水通过 `points_ledger(change_type=refund, ref_call_id=call_record.id)` 关联,不单独增加 `refunded` 状态,避免调用结果与账务动作混在一个字段里。
- T-201 已落地 `UserWallet` / `ApiKey` 于 `apps.users`,`PointsLedger` / `CallRecord` 于 `apps.billing`;T-203 已落地扣点/退点服务;T-304 已落地 `RechargeOrder`、回调幂等入账服务和 `points_ledger(ref_order_id, change_type)` 复合唯一约束,`ref_order_id` 当前仍为数值引用 `RechargeOrder.id`;T-305 已落地 `create_recharge_order()`,负责创建 pending 订单、锁定汇率/点数并回填二维码票据;T-401 已落地 `adjust_wallet_points()`,手工调整点数必须带原因并写 `adjust` 流水,后台钱包余额字段只读;T-608 需新增 `signup_bonus` 流水类型和 MySQL 兼容的注册赠点幂等标记(如 `SignupBonusGrant(user UNIQUE)`),并把 allauth 自助注册路径改为调用 `grant_signup_bonus()` 发放 100 点;T-502 已把 API Key 自助管理接到 `ApiKey.create_for_user()`,删除动作写为 `revoked` 状态而非物理删除;T-503 已把个人中心和记录页接到只读查询,余额用 `get_balance_snapshot()`,充值总额按 paid `RechargeOrder` 汇总,入账 / 消费 / 退款 / 注册赠点按 `PointsLedger` 汇总;T-504 已把 `/recharge` 页面接到 `create_recharge_order()` 与 `/api/v1/recharge/status`。
- T-201 已落地 `UserWallet` / `ApiKey` 于 `apps.users`,`PointsLedger` / `CallRecord` 于 `apps.billing`;T-203 已落地扣点/退点服务;T-304 已落地 `RechargeOrder`、回调幂等入账服务和 `points_ledger(ref_order_id, change_type)` 复合唯一约束,`ref_order_id` 当前仍为数值引用 `RechargeOrder.id`;T-305 已落地 `create_recharge_order()`,负责创建 pending 订单、锁定汇率/点数并回填二维码票据;T-401 已落地 `adjust_wallet_points()`,手工调整点数必须带原因并写 `adjust` 流水,后台钱包余额字段只读;T-608 已新增 `signup_bonus` 流水类型和 MySQL 兼容的注册赠点幂等标记 `SignupBonusGrant(user UNIQUE)`,并把 allauth 自助注册路径改为调用 `grant_signup_bonus()` 发放 100 点;T-502 已把 API Key 自助管理接到 `ApiKey.create_for_user()`,删除动作写为 `revoked` 状态而非物理删除;T-503/T-608 已把个人中心和记录页接到只读查询,余额用 `get_balance_snapshot()`,充值总额按 paid `RechargeOrder` 汇总,充值 / 注册赠点 / 消费 / 退款按 `PointsLedger` 汇总;T-504 已把 `/recharge` 页面接到 `create_recharge_order()` 与 `/api/v1/recharge/status`。
## 四、计费时序(核心,务必照此实现)
@@ -384,7 +384,7 @@ CREATE TABLE call_record (
- 不允许在 adapter / view / signal 中直接 `points_balance=100`;所有余额变动必须走计费层。
- 幂等要有数据库级兜底。MySQL 不支持条件唯一约束,不要用 partial unique;也不要用 `PointsLedger(user, change_type)` 这类会误伤其他流水类型的全局唯一约束。推荐独立 `SignupBonusGrant(user UNIQUE)` 标记表。
- 只对 T-608 上线后的新注册用户自动发放。历史用户是否补发不在本流程内,需单独审批、单独任务、单独批处理流水。
- 由于当前注册免邮箱验证,注册送点会提高刷号动机。T-608 实施时至少要有基础注册限流;图形验证码 / 人机验证可作为上线前风控项继续增强。
- 由于当前注册免邮箱验证,注册送点会提高刷号动机。T-608 已显式配置 `ACCOUNT_SIGNUP_RATE_LIMIT`(默认 `20/m/ip`)并复用 allauth signup rate limit;图形验证码 / 人机验证可作为上线前风控项继续增强。
## 五、关键技术难点
@@ -401,7 +401,7 @@ CREATE TABLE call_record (
| 配置热生效 | 后台改模型/密钥后运行时仍用旧值 | 不在进程内长缓存;每次查库或保存时失效缓存 |
| 配置变更审计 | 改密钥/模型/别名映射无痕 | `AiConfigAuditLog` 自动记录后台 create/update/delete;密钥只记录 empty/set 变化,日志只读 |
| API Key 泄露 | 明文存库一旦泄露全泄 | Key **哈希存储**(sha256),明文只在创建时显示一次,库内存 `key_hash`+`key_prefix`,鉴权做哈希比对 |
| 注册滥用 | 自助注册被批量刷以获取 100 点试用额度 | **免邮箱验证**(`ACCOUNT_EMAIL_VERIFICATION="none"`)下必须补注册限流 / 图形验证码 / 人机验证等防刷;注册赠点走 `SignupBonusGrant(user UNIQUE)` 幂等标记 + `PointsLedger(signup_bonus)` 留痕,防重复发放 |
| 注册滥用 | 自助注册被批量刷以获取 100 点试用额度 | **免邮箱验证**(`ACCOUNT_EMAIL_VERIFICATION="none"`)下已显式配置 `ACCOUNT_SIGNUP_RATE_LIMIT` 基础注册限流;图形验证码 / 人机验证仍是上线前风控增强项;注册赠点走 `SignupBonusGrant(user UNIQUE)` 幂等标记 + `PointsLedger(signup_bonus)` 留痕,防重复发放 |
| Web/API 抢 worker | 图片同步长请求占满 worker、拖慢用户端页面 | 单体下按路径把 `/api/v1/generate/*` 与用户端页面**分流到不同 gunicorn/worker 池**(见 5.1) |
| 充错账户 | 扫码订单未绑定发起用户 | 订单创建即绑定 `user`;回调按 `order_no` 定位订单→其 user 入账 |
| SSRF / 任意 URL 下载 | `image_url` 让服务端请求调用方指定地址 | 仅允许公网 http/https;请求前解析 IP 并拒绝内网/回环/链路本地/保留地址;重定向逐跳校验;流式读取并限制大小 |
+1 -1
View File
@@ -86,7 +86,7 @@
| T-604 | 中文敏感词本地过滤(本地 keyword provider) | T-302, T-401 | 按 [`moderation.md`](moderation.md) 实施。**范围收紧**:T-604 只做输入 prompt 的本地敏感词快筛,不做云内容安全、不做输出审核、不做图片审核。**关键时序**:serializer 后先审 prompt,命中即 `400 content_blocked`;不得先下载 `image_url`,不得预扣点,不写 `CallRecord` / `PointsLedger`,不调上游。**核心实现**:新增 `apps.moderation`、`SensitiveWord` 模型/admin/迁移、keyword provider、归一化管线、Aho-Corasick matcher;`ahocorapy` 作为候选依赖,编码前必须验证 PyPI 可用性和 API 形状。**缓存**:matcher 进程内缓存,词库变更用共享 cache 版本号失效,不能只靠 `post_save` signal;生产依赖共享 cache。**配置**:`MODERATION_ENABLED=false` 默认 no-op;启用时 `MODERATION_PROVIDER=keyword`;MVP `SensitiveWord.action` 只支持 `block`。**验收**:`check`/`test` 全绿,覆盖 no-op、命中拦截且不扣点/不建记录/不调上游/不下载图片、归一化防绕过、词库变更后 matcher 重建;真实词库数据不进仓库,`__pycache__` 不进 Git | DONE |
| T-606 | 公开首页 + 客户端下载入口 | T-501 | 给网站补「前门」并提供桌面端下载。**路由改造**:`/` 从「重定向到 `/dashboard`」改为**公开首页**(匿名可访问、不跳登录);已登录用户显示「进入控制台」,匿名显示「注册/登录 + 下载客户端」。**首页内容**(SSR 模板):项目一句话介绍(生成标题/图片、按点数计费)+ 三步上手(注册→充值→建 API Key→桌面端填 Key)+ 下载入口 + 文档链接。**`DownloadRelease` 模型**:`platform`(如 windows)、`version`、`file`(FileField,**前期存 `MEDIA_ROOT`、服务器托管**) + `external_url`(URLField 可选,**后续切对象存储/CDN 用,有则优先**)、`sha256`、`is_current`(每平台仅一个当前版本)、`release_notes`、时间戳;admin 可上传安装包并标记当前版本;迁移。**下载区块**:展示当前 release 的版本、下载按钮、**SHA256 校验值**、可选 release notes;无 current release 时优雅提示「暂未发布」。**托管策略**:**前期安装包放本服务器**(生产由 **Nginx 直接服务 media/下载文件、不走 Django**,与 T-403 static/media serving 一致,大文件不占 gunicorn worker);`external_url` 预留,后续切对象存储只改后台链接不改代码。**安全**:下载走 HTTPS,页面展示 SHA256 供校验;**代码签名**作为决策登记——未签名 Windows 安装包会被 SmartScreen 拦「未知发布者」、macOS 被 Gatekeeper 拦,首页先给「如何忽略警告」说明,正式签名后续补(挂 Backlog / deployment)。**含测试**:`/` 匿名 200 不跳登录、下载区展示当前 release、无 current release 优雅处理、已登录用户显示「进入控制台」。**视觉原型(已定 v1)**:按 `prototypes/cmhub-homepage-v1.svg` 落地——「生成台」方向:靛蓝=生成 / 琥珀=点数;Hero 为「商品图 + 一句话 → 吸睛标题 + 生成主图 + 点数计量」转化图;四步上手 01–04、两张能力卡、深色计费 band、下载区(版本/SHA256/未签名提示);配色、间距、结构照此原型转成 Django 模板(Bootstrap + 本地 static)。**视觉一致性(brand token)**:按 [`brand.md`](brand.md) 抽出共享 CSS 变量(如 `apps/portal/static/portal/brand.css`),**首页与现有 portal 页面(dashboard/记录/充值/API Key)一起套用同一套 token**(把配色/字体变量灌进现有 Bootstrap,各页只引用变量不散写 hex),确保落地页与登录后控制台风格一致——**不是只做漂亮首页**。验收 `check`/`test`/`init` 全绿并在 `../progress.md` 留证据 | DONE |
| T-607 | 桌面端最新版本检查接口 | T-606 | 给桌面端自动检查更新提供只读 JSON 合约。**新增路由**:`GET /api/v1/client/releases/latest?platform=windows`,公开匿名可访问,**不需要 API Key、不读取用户、不扣点、不占用生成接口限流**;只返回发布元数据,不返回本地文件系统路径、后台 ID、内部状态或任何用户数据。**请求参数**:`platform` 支持 `windows` / `macos` / `linux`,缺省按 `windows`;非法平台返回 `400 bad_request`。**响应结构**:有当前版本时返回 `{platform, release:{version, download_url, sha256, release_notes, published_at}}`;无当前版本或当前版本没有下载地址时返回 `{platform, release:null, message:"暂未发布"}` 且 HTTP 200,方便客户端安静处理。**数据来源**:复用 `DownloadRelease`,只查 `platform + is_current=True`;`download_url` 继续按 `external_url` 优先,否则由 `file.url` 生成绝对 HTTPS URL;`published_at` 可先使用 `updated_at`。**缓存与安全**:可加短 TTL 公共缓存(如 60 秒);生产下载仍走 HTTPS + SHA256 校验;不得把 `MEDIA_ROOT` 或服务器路径暴露给客户端。**含测试**:匿名无 Key 可访问;当前 release 返回完整结构和绝对下载 URL;无 current release 返回 `release:null`;非法 platform 返回 400;`external_url` 优先于 `file`;响应不含本地路径、模型/密钥/用户字段;`check`/目标测试通过并在 `../progress.md` 留证据 | DONE |
| T-608 | 新用户注册赠送 100 点试用点数 | T-501, T-203, T-401 | 把 2026-07-08 的产品口径落地为账本安全实现。**业务口径**:只对 T-608 上线后的新注册用户自动发放 100 点;历史用户是否补发不在本任务内,需单独审批和批处理任务。**数据与服务**:新增 `PointsLedger.ChangeType.SIGNUP_BONUS`(展示为注册赠点)和 MySQL 兼容的数据库级幂等兜底(推荐 `SignupBonusGrant(user UNIQUE)`,不要用 partial unique / 条件唯一约束);新增 `apps.billing.services.grant_signup_bonus(user, points=100)`,在事务内锁/创建钱包、发放点数、写 `PointsLedger(signup_bonus,+100,balance_after,reason)`,重复调用或并发触发不得重复发放。**接入点**:allauth adapter 注册成功后调用 billing 服务,不得在 portal 直接写 `points_balance`;失败要清晰暴露并保持注册 / 账务一致性。**页面与文案**:公开首页、注册页、dashboard/记录页等用户端文案同步「注册送 100 点」;消费记录页能展示注册赠点正向流水;admin 可检索该流水。**防刷**:在免邮箱验证策略下,至少补基础注册限流或明确依赖 allauth/Django 限流配置;图形验证码 / 人机验证若暂不做,必须在文档登记为上线前风控风险。**测试**:注册后余额为 100 且有一条 `signup_bonus` 流水;重复调用服务不重复加点;并发触发不重复发放;现有用户不会自动补发;余额 API / dashboard / 使用记录显示一致;`check`/迁移/目标测试通过并在 `../progress.md` 留证据 | TODO |
| T-608 | 新用户注册赠送 100 点试用点数 | T-501, T-203, T-401 | 把 2026-07-08 的产品口径落地为账本安全实现。**业务口径**:只对 T-608 上线后的新注册用户自动发放 100 点;历史用户是否补发不在本任务内,需单独审批和批处理任务。**数据与服务**:新增 `PointsLedger.ChangeType.SIGNUP_BONUS`(展示为注册赠点)和 MySQL 兼容的数据库级幂等兜底 `SignupBonusGrant(user UNIQUE)`,不要用 partial unique / 条件唯一约束;新增 `apps.billing.services.grant_signup_bonus(user, points=100)`,在事务内锁/创建钱包、发放点数、写 `PointsLedger(signup_bonus,+100,balance_after,reason)`,重复调用或并发触发不得重复发放。**接入点**:allauth adapter 注册成功后调用 billing 服务,不得在 portal 直接写 `points_balance`;失败要清晰暴露并保持注册 / 账务一致性。**页面与文案**:公开首页、注册页、dashboard/记录页等用户端文案同步「注册送 100 点」;点数记录页能展示注册赠点正向流水;admin 可检索该流水和注册赠点记录。**防刷**:免邮箱验证策略下显式配置 `ACCOUNT_SIGNUP_RATE_LIMIT`(默认 `20/m/ip`),由 allauth signup rate limit 执行;图形验证码 / 人机验证仍作为上线前风控增强项。**测试**:注册后余额为 100 且有一条 `signup_bonus` 流水;重复调用服务不重复加点;并发触发不重复发放;现有用户不会自动补发;dashboard / 点数记录显示一致;`check`/迁移/目标测试通过并在 `../progress.md` 留证据 | DONE |
## 里程碑
+2 -2
View File
@@ -35,11 +35,11 @@ T-306 已实现对外 API 安全加固:`image_url` 下载只允许 `http` / `h
T-604 目标口径:生成接口在 serializer 基础校验后先执行 prompt 本地敏感词检查;命中返回 `400 content_blocked`,且不得下载 `image_url`、不得预扣点、不得写 `CallRecord` / `PointsLedger`、不得调用上游。T-604 不启用输出审核和图片审核;详细规则见 [`moderation.md`](moderation.md)。
T-501 已实现用户端注册 / 登录基线:`/signup` `/login` `/logout` 走 django-allauth + Django session + CSRF;**免邮箱验证、注册即可用(`ACCOUNT_EMAIL_VERIFICATION="none"`,邮箱仍必填且唯一)**。T-608 需把注册成功后的 0 点钱包初始化改为一次性发放 100 点注册试用额度,并创建 `signup_bonus` 流水。对外 API 仍只认 API Key,不接受 Web session。(免验证策略见 T-605 与 2026-07-06 决策)
T-501/T-608 已实现用户端注册 / 登录基线:`/signup` `/login` `/logout` 走 django-allauth + Django session + CSRF;**免邮箱验证、注册即可用(`ACCOUNT_EMAIL_VERIFICATION="none"`,邮箱仍必填且唯一)**。注册成功后 allauth adapter 调用 `grant_signup_bonus()` 一次性发放 100 点注册试用额度,并创建 `signup_bonus` 流水;注册限流由 allauth signup rate limit 执行,项目显式配置 `ACCOUNT_SIGNUP_RATE_LIMIT`(默认 `20/m/ip`)。对外 API 仍只认 API Key,不接受 Web session。(免验证策略见 T-605 与 2026-07-06 决策)
T-502 已实现用户端 API Key 自助管理基线:`/apikeys` 走 Django session + CSRF;登录用户可生成和删除自己的 Key,生成后的明文只在重定向后的首个页面显示一次,库内只保存 `key_hash` 与 `key_prefix`。用户端“删除”落库为 `revoked`,保留历史记录关联;吊销后的 Key 调用生成 / 余额接口返回 `403 account_disabled`,缺失、无效或不存在的 Key 仍返回 `401 unauthorized`。
T-503/T-505 已实现用户端个人中心 / 记录页基线:`/dashboard` 走 Django session,展示当前用户剩余点数、充值总额、入账点数、净消耗点数和最近记录;`/records/recharge` 分页展示当前登录用户的充值订单;`/records/usage` 分页展示当前登录用户的 `consume` / `refund` 点数流水并关联调用信息。页面只读,不写 `UserWallet.points_balance`。
T-503/T-505/T-608 已实现用户端个人中心 / 记录页基线:`/dashboard` 走 Django session,展示当前用户剩余点数、充值总额、获得点数、净消耗点数和最近记录;`/records/recharge` 分页展示当前登录用户的充值订单;`/records/usage` 分页展示当前登录用户的 `signup_bonus` / `consume` / `refund` 点数流水并关联调用信息。页面只读,不写 `UserWallet.points_balance`。
T-504/T-505 已实现用户端充值页基线:`/recharge` 走 Django session + CSRF,GET 展示余额、充值表单、当前订单和最近充值;POST 创建 pending 充值订单并用本地 static 自托管的 qrcode.js 展示支付二维码票据;浏览器轮询 `GET /api/v1/recharge/status`,订单 paid 后刷新页面重新读取余额。页面不直接加点,到账仍以支付回调或主动查单入账后的本地订单状态为准。若当前 `PAYMENT_CALLBACK_MODE=mock`,页面必须明确提示二维码为测试票据、不能用微信/支付宝真实支付,避免用户把 mock 二维码当成生产收款码。当前因支付宝可信 IP 未配置,用户端 `/recharge` 表单暂只开放微信支付,底层 JSON API / 回调 / SDK 路径仍保留支付宝。
+11 -10
View File
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -96,6 +96,7 @@ DJANGO_CSRF_COOKIE_SECURE=true
DJANGO_SECURE_PROXY_SSL_HEADER=true
DJANGO_SECURE_SSL_REDIRECT=false
DJANGO_SECURE_HSTS_SECONDS=0
ACCOUNT_SIGNUP_RATE_LIMIT=20/m/ip
MYSQL_HOST=127.0.0.1
MYSQL_PORT=3306
@@ -131,7 +132,7 @@ python3.12 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key
注意:
- `AI_KEY_ENCRYPTION_KEY` 生产不可随意更换;更换会导致已加密的 `AiModel.api_key_encrypted` 无法解密。
- 当前注册策略为免邮箱验证,注册登录不依赖邮件服务;若后续启用密码找回、通知或恢复邮箱验证,再把 `DJANGO_EMAIL_BACKEND` 改为真实 SMTP / 邮件服务并配置 `DJANGO_DEFAULT_FROM_EMAIL`。
- 当前注册策略为免邮箱验证,注册登录不依赖邮件服务;T-608 后注册成功会赠送 100 点,生产必须保留或收紧 `ACCOUNT_SIGNUP_RATE_LIMIT`,并使用共享 Django cache 承载限流计数;若后续启用密码找回、通知或恢复邮箱验证,再把 `DJANGO_EMAIL_BACKEND` 改为真实 SMTP / 邮件服务并配置 `DJANGO_DEFAULT_FROM_EMAIL`。
- `PAYMENT_CALLBACK_MODE=sdk` 必须配齐微信 / 支付宝商户配置;未配齐时先保持 `mock`。
- 宝塔 / Nginx 已强制 HTTPS 时,`DJANGO_SECURE_SSL_REDIRECT=false` 即可;全站 HTTPS 稳定后再把 `DJANGO_SECURE_HSTS_SECONDS` 调大,避免 HSTS 误锁域名。
+2 -1
View File
@@ -28,6 +28,7 @@
| `DJANGO_SECURE_HSTS_INCLUDE_SUBDOMAINS` | 否 | `false` | 是否把 HSTS 应用于子域名 |
| `DJANGO_SECURE_HSTS_PRELOAD` | 否 | `false` | 是否声明 HSTS preload;提交 preload 前必须确认全部子域长期 HTTPS |
| `DJANGO_SECURE_PROXY_SSL_HEADER` | 反代 HTTPS 是 | `true` | Nginx 反代并传 `X-Forwarded-Proto https` 时开启,避免 Django 误判当前请求为 HTTP |
| `ACCOUNT_SIGNUP_RATE_LIMIT` | 否 | `20/m/ip` | allauth 注册限流配置;T-608 后注册会赠送可消费点数,生产必须保留或收紧该限制 |
## 三、数据库配置
@@ -94,7 +95,7 @@ T-604 只做 prompt 本地敏感词快筛。命中时返回 `content_blocked`,
| `DJANGO_CACHE_BACKEND` | 生产是 | `django.core.cache.backends.db.DatabaseCache` | Django cache 后端;开发可用 locmem,生产多 Gunicorn worker 必须用共享后端 |
| `DJANGO_CACHE_LOCATION` | 生产是 | `cmhub_cache` | cache 位置;DatabaseCache 时为表名,Redis/Memcached 时为连接地址 |
生产限流依赖 Django cache。默认 `LocMemCache` 只适合单进程本地开发;多 worker 部署时每个进程各算一份限流,会放大实际请求速率。MVP 可先用 MySQL 的 `DatabaseCache`,部署时执行 `python3.12 manage.py createcachetable cmhub_cache`;高并发后再换 Redis / Memcached 等共享 cache,并同步安装对应 backend 依赖。
生产限流依赖 Django cache。默认 `LocMemCache` 只适合单进程本地开发;多 worker 部署时每个进程各算一份限流,会放大实际请求速率。MVP 可先用 MySQL 的 `DatabaseCache`,部署时执行 `python3.12 manage.py createcachetable cmhub_cache`;高并发后再换 Redis / Memcached 等共享 cache,并同步安装对应 backend 依赖。`ACCOUNT_SIGNUP_RATE_LIMIT`、生成接口限流和认证失败限流都依赖这套 cache。
## 八、支付配置
+2 -2
View File
@@ -8,10 +8,10 @@
| P0 验收项 | 结论 | 主要证据 |
| --- | --- | --- |
| 用户注册 / 登录 | PASS | T-402 历史验收口径为 `apps.portal` 注册创建 0 点钱包且不写赠点流水;T-605 后注册为免邮箱验证、注册后可直接登录,邮箱仍必填且唯一。2026-07-08 新需求已登记为 T-608,后续需改为注册赠送 100 点并写 `signup_bonus` 流水 |
| 用户注册 / 登录 | PASS | T-402 历史验收口径为 `apps.portal` 注册创建 0 点钱包且不写赠点流水;T-605 后注册为免邮箱验证、注册后可直接登录,邮箱仍必填且唯一。T-608 已按新需求改为注册赠送 100 点并写 `signup_bonus` 流水,且通过 `SignupBonusGrant(user UNIQUE)` 防重复发放 |
| API Key 自助管理 | PASS | `apps.portal`:Key 明文只显示一次、列表仅显示 prefix、删除为吊销、不可删除他人 Key;`apps.api`:缺失/无效 Key 为 401,吊销 Key 为 403 |
| 扫码充值 | PASS | `apps.portal` / `apps.api`:创建 pending 订单不直接加点、不写流水;二维码票据展示;状态轮询只允许本人订单;主动查单可补入账 |
| 个人中心 / 记录 | PASS | `apps.portal`:余额、充值总额、入账点数、消费/退款记录与实际订单/流水一致;充值记录和消费记录分页且仅见本人 |
| 个人中心 / 记录 | PASS | `apps.portal`:余额、充值总额、获得点数、注册赠点/消费/退款记录与实际订单/流水一致;充值记录和点数记录分页且仅见本人 |
| 生成标题 API | PASS | `apps.api`:有效 API Key + 合法 prompt 返回标题、扣点并写调用记录;缺失/无效 Key 返回 401 |
| 生成图片 API | PASS | `apps.api`:有效 API Key 返回图片 URL,图片结果不把 provider raw/base64 写入调用记录;上游错误和能力错误会退点 |
| 点数计费与扣减 | PASS | `apps.billing` / `apps.api`:按别名+分辨率计费,余额不足返回 402 且不调上游;并发扣点不超扣、不为负 |
+1 -1
View File
@@ -108,7 +108,7 @@
- Phase 5 已完成 T-401:运营后台可管理/检索用户、钱包、API Key(脱敏)、计费规则、汇率、充值订单、点数流水和调用记录;手工调点必须填写原因,并经计费层锁钱包、写 `adjust` 流水。
- Phase 5 已完成 T-402:MVP P0 验收通过,注册/充值/API Key/调用/余额/记录/后台/别名映射均有测试证据,详见 `mvp-acceptance.md`。
- Phase 5 已完成 T-403:已补部署 / 运行文档,明确宝塔/Nginx/Gunicorn、生产静态与媒体文件、共享缓存限流、图片同步超时、真实商户配置和上线检查;当前免邮箱验证,邮件服务仅作为后续密码找回/通知等邮件能力配置项,详见 `deployment.md`。
- Phase 6 已完成 T-601 可用别名发现、T-602 后台表名/分组中文化、T-603 字段级中文化、T-604 中文敏感词本地过滤、T-605 免邮箱验证策略落地、T-606 公开首页 + 客户端下载入口与 T-607 桌面端最新版本检查接口;线上真实标题生成已恢复并验证扣点。
- Phase 6 已完成 T-601 可用别名发现、T-602 后台表名/分组中文化、T-603 字段级中文化、T-604 中文敏感词本地过滤、T-605 免邮箱验证策略落地、T-606 公开首页 + 客户端下载入口、T-607 桌面端最新版本检查接口与 T-608 新用户注册赠送 100 点试用点数;线上真实标题生成已恢复并验证扣点。
- 下一步可继续补跑真实支付回调到账闭环、配置并发布客户端下载包和真实图片耗时验证。
---
+1 -1
View File
@@ -40,7 +40,7 @@
## 进度
M1 骨架已完成;M2 已通过录制生成 smoke 验证 AI 调用链路;M3 计费、对外 API、充值闭环与上线前安全加固已完成到 T-306;M4 用户端已完成注册 / 登录、API Key 管理、个人中心 / 记录页、充值页和审核优化;M5 已完成 T-401 运营后台完善、T-402 MVP 完整验收与 T-403 部署 / 运行文档。下一步是在 VPS 上按部署文档接真实邮件、支付、AI 模型并补真实图片耗时验证。里程碑:M1 骨架可跑 · M2 跑通生成 · M3 计费充值闭环 · M4 用户端可用 · M5 验收上线。
M1 骨架已完成;M2 已通过录制生成 smoke 验证 AI 调用链路;M3 计费、对外 API、充值闭环与上线前安全加固已完成到 T-306;M4 用户端已完成注册 / 登录、API Key 管理、个人中心 / 记录页、充值页和审核优化;M5 已完成 T-401 运营后台完善、T-402 MVP 完整验收与 T-403 部署 / 运行文档;M7 注册试用额度已完成,注册后经账本发放 100 点并留流水。下一步是在 VPS 上按部署文档接真实邮件、支付、AI 模型并补真实图片耗时验证。里程碑:M1 骨架可跑 · M2 跑通生成 · M3 计费充值闭环 · M4 用户端可用 · M5 验收上线 · M7 试用额度安全落地。
---
*详见 `project-brief.md`(完整介绍)。*
+1 -1
View File
@@ -16,7 +16,7 @@
| `/apikeys` | GET/POST | API Key 管理:列表 / 生成 / 删除(删除即吊销,明文只显示一次) | session |
| `/models` | GET | 可用模型:只读展示可调用能力别名、能力、是否需要原图和点数单价 | session |
T-501 已落地 `/signup`、`/login`、`/logout` 与最小 `/dashboard`;T-608 需把注册后 0 点初始化改为经计费层一次性发放 100 点试用点数并写流水。T-502 已落地 `/apikeys`:登录用户只能管理自己的 Key,生成后明文只显示一次,列表只显示 prefix,删除为吊销 `revoked`。T-503/T-505 已扩展 `/dashboard` 为个人中心汇总,并落地 `/records/recharge` 与 `/records/usage`:充值总额按已支付订单统计,入账 / 消费 / 退款点数按 `PointsLedger` 统计,记录页只查询当前登录用户数据并分页展示。T-504/T-505 已落地 `/recharge`:登录用户可选择金额和支付方式创建 pending 充值订单,页面用本地 static 自托管 qrcode.js 展示二维码票据并轮询 `/api/v1/recharge/status`,到账后刷新余额。
T-501/T-608 已落地 `/signup`、`/login`、`/logout` 与 `/dashboard`:注册成功后经计费层一次性发放 100 点试用点数并写 `signup_bonus` 流水,注册限流由 allauth signup rate limit 执行。T-502 已落地 `/apikeys`:登录用户只能管理自己的 Key,生成后明文只显示一次,列表只显示 prefix,删除为吊销 `revoked`。T-503/T-505/T-608 已扩展 `/dashboard` 为个人中心汇总,并落地 `/records/recharge` 与 `/records/usage`:充值总额按已支付订单统计,注册赠点 / 入账 / 消费 / 退款点数按 `PointsLedger` 统计,记录页只查询当前登录用户数据并分页展示。T-504/T-505 已落地 `/recharge`:登录用户可选择金额和支付方式创建 pending 充值订单,页面用本地 static 自托管 qrcode.js 展示二维码票据并轮询 `/api/v1/recharge/status`,到账后刷新余额。
T-601 已落地 `/models`:登录用户可查看当前公开可调用别名、能力、是否需要原图和点数单价;页面不展示底层 SKU、模型 URL、provider key、`api_key_encrypted` 或 `extra_body`。
T-606 已落地 `/` 公开首页:匿名访问返回 200,不再重定向到 `/dashboard`;匿名用户看到注册 / 登录 / 下载入口,登录用户看到「进入控制台」。首页下载区读取 `DownloadRelease(platform=windows, is_current=True)`,优先使用 `external_url`,否则使用后台上传文件的 `file.url`;无当前版本时显示「暂未发布」。
+27
View File
@@ -1476,3 +1476,30 @@
- `docs/mvp-acceptance.md`、`docs/phase-4-review.md`、`docs/README.md`:标注 T-501/T-402/Phase 4 审核中的“注册不送点”属于历史口径,已被 T-608 新需求变更。
- 验证:纯文档修改;后续执行 T-608 时需补迁移、目标测试、`check` 和 `git diff --check` 证据。
- 下一步:领取 T-608,先实现 billing 层注册赠点服务和幂等数据结构,再接 allauth adapter、页面文案和测试。
## 2026-07-08 T-608 新用户注册赠送 100 点试用点数落地
- 状态:DONE。
- 代码变更:
- `apps/billing/models.py`:新增 `PointsLedger.ChangeType.SIGNUP_BONUS` 和 `SignupBonusGrant(user UNIQUE)` 幂等标记模型,迁移为 `billing.0007_alter_pointsledger_change_type_signupbonusgrant`。
- `apps/billing/services.py`:新增 `grant_signup_bonus(user, points=100)`,事务内锁/创建钱包、创建幂等记录、加点、写 `PointsLedger(signup_bonus,+100,balance_after,reason="new_user_registration")`;重复调用返回未发放结果,不重复加点。
- `apps/portal/adapters.py`:allauth 注册保存成功后只调用 billing 服务发放注册赠点,不在 portal 直接写 `points_balance`。
- `apps/portal/views.py` 与模板:dashboard 统计充值点数 + 注册赠点为“获得点数”;`/records/usage` 改为点数记录,展示 `signup_bonus` / `consume` / `refund` 流水;公开首页和注册页同步「注册送 100 点」文案。
- `config/settings.py`:新增 `ACCOUNT_RATE_LIMITS["signup"]`,从 `ACCOUNT_SIGNUP_RATE_LIMIT` 读取,默认 `20/m/ip`,由 allauth signup rate limit 执行。
- `apps/billing/admin.py`:新增 `SignupBonusGrant` 只读 admin,便于运营检索注册赠点记录。
- 文档变更:
- `docs/06-tasks.md`:T-608 状态改为 DONE,防刷口径改为显式配置 `ACCOUNT_SIGNUP_RATE_LIMIT`,图形验证码 / 人机验证保留为上线前增强项。
- `README.md`、`docs/00-ai-start-here.md`、`docs/03-tech-stack.md`、`docs/04-architecture.md`、`docs/api.md`、`docs/routes.md`、`docs/current-state.md`、`docs/env.md`、`docs/deployment.md`、`docs/mvp-acceptance.md`、`docs/project-brief.md`、`docs/project-onepager.md`:同步注册赠点已落地、点数记录口径、注册限流配置和当前状态。
- 验证:
- `py -3.12 -m py_compile config\settings.py apps\billing\models.py apps\billing\services.py apps\billing\admin.py apps\portal\adapters.py apps\portal\views.py apps\billing\tests.py apps\portal\tests.py`:通过。
- `py -3.12 manage.py check`:通过,0 issues。
- `py -3.12 manage.py makemigrations --check --dry-run`:通过,No changes detected。
- `py -3.12 manage.py migrate billing --noinput`:通过,No migrations to apply。
- `py -3.12 manage.py test apps.billing.tests.BillingServiceTests.test_grant_signup_bonus_creates_wallet_ledger_and_is_idempotent apps.billing.tests.BillingServiceTests.test_grant_signup_bonus_adds_to_existing_wallet_balance apps.billing.tests.ConcurrentSignupBonusTests --keepdb --noinput --verbosity 2`:3 tests OK。
- `py -3.12 manage.py test apps.portal.tests.PortalAccountFlowTests.test_signup_rate_limit_is_configured --keepdb --noinput --verbosity 2`:1 test OK。
- `py -3.12 manage.py test apps.portal.tests.PortalAccountFlowTests.test_signup_grants_100_points_writes_signup_bonus_ledger_and_can_login apps.portal.tests.PortalAccountFlowTests.test_homepage_is_public_and_shows_anonymous_onboarding_without_release apps.portal.tests.PortalAccountFlowTests.test_homepage_shows_dashboard_entry_for_authenticated_user apps.portal.tests.PortalAccountFlowTests.test_authenticated_nav_highlights_current_page_only apps.portal.tests.PortalAccountFlowTests.test_dashboard_shows_account_summary_recent_records_for_current_user_only apps.portal.tests.PortalAccountFlowTests.test_usage_records_require_login_and_only_show_current_user_usage --keepdb --noinput --verbosity 2`:6 tests OK。
- `py -3.12 manage.py test apps.billing.tests.BillingCoreModelTests.test_billing_models_are_registered_in_admin --keepdb --noinput --verbosity 2`:1 test OK。
- `.\init.ps1`:收尾验证通过(Python 3.12.3,依赖已满足,`manage.py check` 0 issues,打印启动命令)。
- `git diff --check`:通过,仅 Windows CRLF 提示。
- 已知测试环境现象:一次 portal 目标测试启动阶段因远程 MySQL `43.128.3.240` 连接超时中断;随后 `Test-NetConnection 43.128.3.240 -Port 3306` 显示端口可达,拆分重跑全部目标用例通过。测试期仍保留 allauth `account.EmailAddress` 条件唯一约束在 MySQL 上不可创建的既有 `models.W036` 警告。
- 下一步:当前任务看板暂无新的编号 TODO;建议按业务优先级拆真实支付回调到账闭环、客户端下载包发布、真实图片耗时验证,或从 Backlog 创建新任务。