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
+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")