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