feat: improve operations admin

This commit is contained in:
QiuSW
2026-07-03 16:36:06 +08:00
parent 547196f39a
commit 79605449ef
15 changed files with 520 additions and 31 deletions
+11 -1
View File
@@ -29,8 +29,10 @@ class PointsLedgerAdmin(ReadOnlyLedgerAdmin):
"ref_call",
)
list_filter = ("change_type", "created_at")
search_fields = ("user__username", "user__email", "reason")
search_fields = ("user__username", "user__email", "=ref_order_id", "reason")
ordering = ("-created_at", "-id")
date_hierarchy = "created_at"
list_select_related = ("user", "ref_call")
@admin.register(PricingRule)
@@ -47,6 +49,7 @@ class PricingRuleAdmin(admin.ModelAdmin):
search_fields = ("alias", "resolution")
ordering = ("operation_type", "alias", "resolution")
readonly_fields = ("created_at", "updated_at")
list_editable = ("points_cost", "is_active")
@admin.display(description="resolution")
def resolution_display(self, obj):
@@ -66,6 +69,8 @@ class ExchangeRateAdmin(admin.ModelAdmin):
search_fields = ("currency", "note")
ordering = ("-effective_from", "-id")
readonly_fields = ("created_at", "updated_at")
list_editable = ("is_active",)
date_hierarchy = "effective_from"
@admin.register(RechargeOrder)
@@ -90,6 +95,8 @@ class RechargeOrderAdmin(ReadOnlyLedgerAdmin):
"payment_txn_no",
)
ordering = ("-created_at", "-id")
date_hierarchy = "created_at"
list_select_related = ("user",)
@admin.register(CallRecord)
@@ -112,8 +119,11 @@ class CallRecordAdmin(ReadOnlyLedgerAdmin):
"api_key__key_prefix",
"alias",
"model_used",
"prompt",
"error_message",
"result_ref",
"result_summary",
)
ordering = ("-created_at", "-id")
date_hierarchy = "created_at"
list_select_related = ("user", "api_key")
+99
View File
@@ -85,6 +85,19 @@ class InvalidRechargePayMethodError(RechargeOrderCreateError):
super().__init__("Unsupported recharge payment method.")
class WalletAdjustmentError(BillingOperationError):
code = "invalid_wallet_adjustment"
class WalletAdjustmentWouldOverdrawError(WalletAdjustmentError):
code = "adjustment_would_overdraw"
def __init__(self, *, points_delta: int, available_points: int):
self.points_delta = points_delta
self.available_points = available_points
super().__init__("Wallet adjustment would make the balance negative.")
@dataclass(frozen=True)
class CallCharge:
call_record: CallRecord
@@ -126,12 +139,56 @@ class RechargeResult:
applied: bool
@dataclass(frozen=True)
class WalletAdjustment:
wallet: UserWallet
ledger_entry: PointsLedger
points_delta: int
balance_after: int
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")
return points
def _validate_adjustment_delta(points_delta: int) -> int:
if (
isinstance(points_delta, bool)
or not isinstance(points_delta, int)
or points_delta == 0
):
raise WalletAdjustmentError("points_delta must be a non-zero integer.")
return points_delta
def _normalize_adjustment_reason(reason: str) -> str:
normalized_reason = str(reason or "").strip()
if not normalized_reason:
raise WalletAdjustmentError("Adjustment reason is required.")
return normalized_reason
def _format_operator(actor) -> str:
if actor is None:
return ""
username_getter = getattr(actor, "get_username", None)
username = (
username_getter()
if callable(username_getter)
else getattr(actor, "username", "")
)
actor_pk = getattr(actor, "pk", None)
if username and actor_pk:
return f"{username}#{actor_pk}"
if username:
return str(username)
if actor_pk:
return f"id#{actor_pk}"
return str(actor)
def _locked_wallet_for_user(user) -> UserWallet:
wallet, _created = UserWallet.objects.select_for_update().get_or_create(user=user)
return wallet
@@ -234,6 +291,48 @@ def get_balance_snapshot(user) -> BalanceSnapshot:
)
def adjust_wallet_points(
*,
user,
points_delta: int,
reason: str,
actor=None,
) -> WalletAdjustment:
points_delta = _validate_adjustment_delta(points_delta)
normalized_reason = _normalize_adjustment_reason(reason)
operator = _format_operator(actor)
ledger_reason = (
f"{normalized_reason}\n\noperator={operator}" if operator else normalized_reason
)
with transaction.atomic():
wallet = _locked_wallet_for_user(user)
balance_after = wallet.points_balance + points_delta
if balance_after < 0:
raise WalletAdjustmentWouldOverdrawError(
points_delta=points_delta,
available_points=wallet.points_balance,
)
wallet.points_balance = balance_after
wallet.save(update_fields=("points_balance", "updated_at"))
ledger_entry = PointsLedger.objects.create(
user=user,
change_type=PointsLedger.ChangeType.ADJUST,
points_delta=points_delta,
balance_after=wallet.points_balance,
reason=ledger_reason,
)
return WalletAdjustment(
wallet=wallet,
ledger_entry=ledger_entry,
points_delta=points_delta,
balance_after=ledger_entry.balance_after,
)
def apply_recharge_payment(payment: RechargePayment) -> RechargeResult:
order_no = str(payment.order_no or "").strip()
pay_method = _normalize_pay_method(payment.pay_method)
+69
View File
@@ -34,6 +34,9 @@ from apps.billing.services import (
RechargeAmountMismatchError,
RechargeOrderCreateError,
RechargePayment,
WalletAdjustmentError,
WalletAdjustmentWouldOverdrawError,
adjust_wallet_points,
apply_recharge_payment,
create_recharge_order,
mark_call_success,
@@ -683,6 +686,72 @@ class BillingServiceTests(TestCase):
self.assertEqual(order.status, RechargeOrder.Status.PAID)
self.assertEqual(order.payment_txn_no, "queried-txn-001")
def test_adjust_wallet_points_credits_wallet_and_writes_adjust_ledger(self):
result = adjust_wallet_points(
user=self.user,
points_delta=25,
reason="运营补偿",
)
self.wallet.refresh_from_db()
self.assertEqual(result.points_delta, 25)
self.assertEqual(result.balance_after, 125)
self.assertEqual(self.wallet.points_balance, 125)
ledger = PointsLedger.objects.get(
user=self.user,
change_type=PointsLedger.ChangeType.ADJUST,
)
self.assertEqual(ledger.points_delta, 25)
self.assertEqual(ledger.balance_after, 125)
self.assertEqual(ledger.reason, "运营补偿")
def test_adjust_wallet_points_debits_wallet_without_overdraft(self):
result = adjust_wallet_points(
user=self.user,
points_delta=-40,
reason="纠正误充值",
)
self.wallet.refresh_from_db()
self.assertEqual(result.balance_after, 60)
self.assertEqual(self.wallet.points_balance, 60)
self.assertEqual(result.ledger_entry.points_delta, -40)
self.assertEqual(result.ledger_entry.reason, "纠正误充值")
def test_adjust_wallet_points_requires_non_zero_delta_and_reason(self):
with self.assertRaises(WalletAdjustmentError):
adjust_wallet_points(user=self.user, points_delta=0, reason="无变化")
with self.assertRaises(WalletAdjustmentError):
adjust_wallet_points(user=self.user, points_delta=10, reason=" ")
self.wallet.refresh_from_db()
self.assertEqual(self.wallet.points_balance, 100)
self.assertFalse(
PointsLedger.objects.filter(
user=self.user,
change_type=PointsLedger.ChangeType.ADJUST,
).exists()
)
def test_adjust_wallet_points_rejects_overdraft_without_writing_ledger(self):
with self.assertRaises(WalletAdjustmentWouldOverdrawError) as context:
adjust_wallet_points(
user=self.user,
points_delta=-101,
reason="扣回异常点数",
)
self.assertEqual(context.exception.available_points, 100)
self.wallet.refresh_from_db()
self.assertEqual(self.wallet.points_balance, 100)
self.assertFalse(
PointsLedger.objects.filter(
user=self.user,
change_type=PointsLedger.ChangeType.ADJUST,
).exists()
)
class ConcurrentDebitTests(TransactionTestCase):
def setUp(self):