feat: grant signup bonus points
This commit is contained in:
+17
-1
@@ -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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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}"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user