Files

417 lines
15 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package billing
import (
"context"
"os"
"strconv"
"sync"
"testing"
"github.com/alicebob/miniredis/v2"
"github.com/gogf/gf/v2/database/gredis"
"github.com/shopspring/decimal"
// 注册 GoFrame redis 适配器(生产代码在 main.go 空导入;单测需在此显式注册)
_ "github.com/gogf/gf/contrib/nosql/redis/v2"
)
// miniredis 全局实例:g.Redis() 经 gredis.SetConfig 指向内存 Redis,
// 验证钱包权威化架构下的全部 Lua 脚本(预扣/结算认领/解冻/加款/扣款)。
// miniredis 单线程串行执行命令,脚本内 check-then-act 等价原子,可验证资金守恒。
var mr *miniredis.Miniredis
func TestMain(m *testing.M) {
var err error
mr, err = miniredis.Run()
if err != nil {
panic(err)
}
if err := gredis.SetConfigByMap(map[string]any{
"address": mr.Addr(),
"db": 0,
}); err != nil {
panic(err)
}
code := m.Run()
mr.Close()
os.Exit(code)
}
func testCtx() context.Context { return context.Background() }
// readWallet 读取租户钱包余额/冻结(micro → decimal)
func readWallet(t *testing.T, tenantID int64) (balance, frozen decimal.Decimal) {
t.Helper()
b, f, _, _, exists, err := readWalletHash(testCtx(), tenantID)
if err != nil {
t.Fatalf("read wallet hash: %v", err)
}
if !exists {
t.Fatalf("wallet hash missing for tenant %d", tenantID)
}
return FromMicro(b), FromMicro(f)
}
// readWalletConsumed 读取租户累计消费计数(micro → decimal;hash 缺字段按 0)
func readWalletConsumed(t *testing.T, tenantID int64) decimal.Decimal {
t.Helper()
_, _, c, hasConsumed, exists, err := readWalletHash(testCtx(), tenantID)
if err != nil {
t.Fatalf("read wallet hash: %v", err)
}
if !exists {
t.Fatalf("wallet hash missing for tenant %d", tenantID)
}
if !hasConsumed {
return decimal.Zero
}
return FromMicro(c)
}
// seedWallet 用加款创建指定余额的钱包 hash(顺带校验 CreditWalletRedis 自身)
func seedWallet(t *testing.T, tenantID int64, balance decimal.Decimal) {
t.Helper()
ba, _, err := CreditWalletRedis(testCtx(), tenantID, balance)
if err != nil {
t.Fatalf("seed wallet: %v", err)
}
if !ba.Equal(balance) {
t.Fatalf("seed wallet balance=%s want %s", ba.String(), balance.String())
}
}
// assertDecimalEqual 断言两个 decimal 相等(金额精度 6 位)
func assertDecimalEqual(t *testing.T, got, want decimal.Decimal, msg string) {
t.Helper()
if !got.Equal(want) {
t.Fatalf("%s: got=%s want=%s", msg, got.String(), want.String())
}
}
func TestPreDeduct_Basic(t *testing.T) {
tenant := int64(1)
seedWallet(t, tenant, NewFromFloat(10))
ok, err := PreDeduct(testCtx(), tenant, 4, "req-p1", "gpt-4o")
if err != nil || !ok {
t.Fatalf("pre-deduct failed: ok=%v err=%v", ok, err)
}
bal, frozen := readWallet(t, tenant)
assertDecimalEqual(t, bal, NewFromFloat(10), "balance unchanged by freeze")
assertDecimalEqual(t, frozen, NewFromFloat(4), "frozen after prededuct")
// 幂等:同一 request_id 重复预扣 → 成功且不重复冻结
ok, err = PreDeduct(testCtx(), tenant, 4, "req-p1", "gpt-4o")
if err != nil || !ok {
t.Fatalf("idempotent pre-deduct failed: ok=%v err=%v", ok, err)
}
_, frozen = readWallet(t, tenant)
assertDecimalEqual(t, frozen, NewFromFloat(4), "no double freeze on idempotent")
// 余额不足:可用 6(10-4)< 7 → 拒绝且不冻结
ok, err = PreDeduct(testCtx(), tenant, 7, "req-p2", "gpt-4o")
if ok || err == nil {
t.Fatalf("insufficient pre-deduct should fail: ok=%v err=%v", ok, err)
}
_, frozen = readWallet(t, tenant)
assertDecimalEqual(t, frozen, NewFromFloat(4), "no freeze on insufficient")
}
func TestSettleClaim_Normal(t *testing.T) {
tenant := int64(2)
seedWallet(t, tenant, NewFromFloat(10))
if ok, _ := PreDeduct(testCtx(), tenant, 4, "req-s1", "gpt-4o"); !ok {
t.Fatal("prededuct failed")
}
claimed, bal, frozen, err := SettleClaim(testCtx(), tenant, NewFromFloat(1.5), []string{"req-s1"})
if err != nil {
t.Fatalf("settle claim: %v", err)
}
assertDecimalEqual(t, claimed, NewFromFloat(4), "claimed == prededucted")
assertDecimalEqual(t, bal, NewFromFloat(8.5), "balance after deduct")
assertDecimalEqual(t, frozen, decimal.Zero, "frozen fully released")
}
func TestSettleClaim_Duplicate(t *testing.T) {
tenant := int64(3)
seedWallet(t, tenant, NewFromFloat(10))
if ok, _ := PreDeduct(testCtx(), tenant, 4, "req-s2", "gpt-4o"); !ok {
t.Fatal("prededuct failed")
}
if _, _, _, err := SettleClaim(testCtx(), tenant, NewFromFloat(1.5), []string{"req-s2"}); err != nil {
t.Fatalf("first settle: %v", err)
}
// 重复结算:预扣 hash 已随认领删除 → claimed=0,只按补扣继续扣 balance
claimed, bal, frozen, err := SettleClaim(testCtx(), tenant, NewFromFloat(0.5), []string{"req-s2"})
if err != nil {
t.Fatalf("duplicate settle claim: %v", err)
}
assertDecimalEqual(t, claimed, decimal.Zero, "duplicate claims nothing")
assertDecimalEqual(t, bal, NewFromFloat(8), "only supplement deducted")
assertDecimalEqual(t, frozen, decimal.Zero, "frozen untouched")
}
func TestSettleClaim_MultiKeyAdjust(t *testing.T) {
tenant := int64(4)
seedWallet(t, tenant, NewFromFloat(10))
if ok, _ := PreDeduct(testCtx(), tenant, 3, "req-t1", "midjourney"); !ok {
t.Fatal("prededuct failed")
}
if ok, _ := PreDeduct(testCtx(), tenant, 1, "req-t1_adjust", "midjourney"); !ok {
t.Fatal("adjust prededuct failed")
}
claimed, bal, frozen, err := SettleClaim(testCtx(), tenant, NewFromFloat(2), []string{"req-t1", "req-t1_adjust"})
if err != nil {
t.Fatalf("multi-key settle claim: %v", err)
}
assertDecimalEqual(t, claimed, NewFromFloat(4), "sum of both prededucts claimed")
assertDecimalEqual(t, bal, NewFromFloat(8), "balance after cost")
assertDecimalEqual(t, frozen, decimal.Zero, "all frozen released")
}
func TestUnfreeze_Idempotent(t *testing.T) {
tenant := int64(5)
seedWallet(t, tenant, NewFromFloat(10))
if ok, _ := PreDeduct(testCtx(), tenant, 2, "req-u1", "gpt-4o"); !ok {
t.Fatal("prededuct failed")
}
amt, err := UnfreezePreDeduct(testCtx(), tenant, "req-u1")
if err != nil {
t.Fatalf("unfreeze: %v", err)
}
assertDecimalEqual(t, amt, NewFromFloat(2), "unfreeze releases prededucted amount")
_, frozen := readWallet(t, tenant)
assertDecimalEqual(t, frozen, decimal.Zero, "frozen released")
// 幂等:第二次认领不到 → 0,不二次释放
amt2, err := UnfreezePreDeduct(testCtx(), tenant, "req-u1")
if err != nil {
t.Fatalf("second unfreeze: %v", err)
}
assertDecimalEqual(t, amt2, decimal.Zero, "idempotent second unfreeze")
// 不存在的预扣 → 0
amt3, err := UnfreezePreDeduct(testCtx(), tenant, "req-missing")
if err != nil {
t.Fatalf("missing unfreeze: %v", err)
}
assertDecimalEqual(t, amt3, decimal.Zero, "missing prededuct no-op")
}
func TestCreditDebitWallet(t *testing.T) {
tenant := int64(6)
seedWallet(t, tenant, NewFromFloat(10))
// 扣款门槛:可用 10 < 12 → 拒绝且不变更
_, _, ok, err := DebitWalletRedis(testCtx(), tenant, NewFromFloat(12))
if err != nil {
t.Fatalf("debit err: %v", err)
}
if ok {
t.Fatal("debit beyond available must fail")
}
bal, _ := readWallet(t, tenant)
assertDecimalEqual(t, bal, NewFromFloat(10), "no change on failed debit")
// 正常扣款
after, _, ok, err := DebitWalletRedis(testCtx(), tenant, NewFromFloat(3))
if err != nil || !ok {
t.Fatalf("debit failed: ok=%v err=%v", ok, err)
}
assertDecimalEqual(t, after, NewFromFloat(7), "debit reduces balance")
// 加款(负数=扣回补偿)
after, _, err = CreditWalletRedis(testCtx(), tenant, NewFromFloat(-3))
if err != nil {
t.Fatalf("credit: %v", err)
}
assertDecimalEqual(t, after, NewFromFloat(4), "negative credit reduces balance")
}
// TestWalletConcurrency 并发守恒:N 并发预扣+结算后,frozen 归零、balance 精确扣减
func TestWalletConcurrency(t *testing.T) {
tenant := int64(10)
seedWallet(t, tenant, NewFromFloat(100))
const n = 50
var wg sync.WaitGroup
errCh := make(chan error, n)
for i := 0; i < n; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
ctx := testCtx()
rid := "req-c" + strconv.Itoa(idx)
ok, err := PreDeduct(ctx, tenant, 1, rid, "gpt-4o")
if err != nil || !ok {
errCh <- err
return
}
if _, _, _, err := SettleClaim(ctx, tenant, NewFromFloat(0.5), []string{rid}); err != nil {
errCh <- err
}
}(i)
}
wg.Wait()
close(errCh)
for err := range errCh {
t.Fatalf("concurrent wallet op failed: %v", err)
}
bal, frozen := readWallet(t, tenant)
assertDecimalEqual(t, bal, NewFromFloat(100).Sub(NewFromFloat(0.5).Mul(NewFromFloat(n))), "balance conserved (100 - n*0.5)")
assertDecimalEqual(t, frozen, decimal.Zero, "frozen converges to zero")
assertDecimalEqual(t, readWalletConsumed(t, tenant), NewFromFloat(0.5).Mul(NewFromFloat(n)), "total_consumed conserved (n*0.5)")
}
// TestPredeductSweep_RecomputesFrozen 孤儿清扫重算:某预扣 hash 丢失(TTL 过期/崩溃残留)后,
// frozen 仍含其金额 → sweep 重算为幸存 hash 之和并清理活跃集合死亡成员。
func TestPredeductSweep_RecomputesFrozen(t *testing.T) {
tenant := int64(8)
seedWallet(t, tenant, NewFromFloat(10))
if ok, _ := PreDeduct(testCtx(), tenant, 2, "req-sw1", "gpt-4o"); !ok {
t.Fatal("prededuct 1 failed")
}
if ok, _ := PreDeduct(testCtx(), tenant, 3, "req-sw2", "gpt-4o"); !ok {
t.Fatal("prededuct 2 failed")
}
_, frozen := readWallet(t, tenant)
assertDecimalEqual(t, frozen, NewFromFloat(5), "frozen after two prededucts")
// 模拟孤儿:直接删除 req-sw2 的预扣 hash(不释放冻结)
if !mr.Del(PreDeductRedisKeyPrefix + "req-sw2") {
t.Fatal("simulate orphan: prededuct hash not found")
}
PredeductSweep(testCtx())
_, frozen = readWallet(t, tenant)
assertDecimalEqual(t, frozen, NewFromFloat(2), "frozen recomputed to surviving prededucts")
// 孤儿 request 移出活跃集合
activeSetKey := "prededuct_active:8"
members, err := mr.SMembers(activeSetKey)
if err != nil {
t.Fatalf("read active set: %v", err)
}
if len(members) != 1 || members[0] != "req-sw1" {
t.Fatalf("active set after sweep = %v, want [req-sw1]", members)
}
}
func TestCleanupPreDeduct_RemovesHash(t *testing.T) {
tenant := int64(7)
seedWallet(t, tenant, NewFromFloat(10))
if ok, _ := PreDeduct(testCtx(), tenant, 1, "req-cl1", "gpt-4o"); !ok {
t.Fatal("prededuct failed")
}
CleanupPreDeduct(testCtx(), tenant, "req-cl1")
if _, exists := GetPreDeductAmount(testCtx(), "req-cl1"); exists {
t.Fatal("prededuct hash should be cleaned")
}
}
// TestSettleClaim_IncrementsTotalConsumed 累计消费随结算扣款同一原子递增;
// 重复结算(预扣已认领、claimed=0 只补扣)时计数仍按实际扣款额累计。
func TestSettleClaim_IncrementsTotalConsumed(t *testing.T) {
tenant := int64(11)
seedWallet(t, tenant, NewFromFloat(10))
if ok, _ := PreDeduct(testCtx(), tenant, 4, "req-tc1", "gpt-4o"); !ok {
t.Fatal("prededuct failed")
}
// 首次结算:新 hash 无 total_consumed 字段,HINCRBY 从 0 起算(正常路径由三层补种保证含基线)
if _, _, _, err := SettleClaim(testCtx(), tenant, NewFromFloat(1.5), []string{"req-tc1"}); err != nil {
t.Fatalf("settle claim: %v", err)
}
assertDecimalEqual(t, readWalletConsumed(t, tenant), NewFromFloat(1.5), "total_consumed after first settle")
// 重复结算走补扣路径:计数继续累计实际扣款
if _, _, _, err := SettleClaim(testCtx(), tenant, NewFromFloat(0.5), []string{"req-tc1"}); err != nil {
t.Fatalf("second settle claim: %v", err)
}
assertDecimalEqual(t, readWalletConsumed(t, tenant), NewFromFloat(2), "total_consumed accumulates supplement")
}
// TestReverseConsumeRedis_ReversesBalanceAndCount 结算补偿逆转:余额与累计消费同一原子回退
func TestReverseConsumeRedis_ReversesBalanceAndCount(t *testing.T) {
tenant := int64(12)
seedWallet(t, tenant, NewFromFloat(10))
if ok, _ := PreDeduct(testCtx(), tenant, 4, "req-tc2", "gpt-4o"); !ok {
t.Fatal("prededuct failed")
}
if _, _, _, err := SettleClaim(testCtx(), tenant, NewFromFloat(3), []string{"req-tc2"}); err != nil {
t.Fatalf("settle claim: %v", err)
}
assertDecimalEqual(t, readWalletConsumed(t, tenant), NewFromFloat(3), "total_consumed after settle")
// 模拟 executeSettlement 第 c 步失败补偿:本单免费,计数与余额同步回退
if err := ReverseConsumeRedis(testCtx(), tenant, NewFromFloat(3)); err != nil {
t.Fatalf("reverse consume: %v", err)
}
bal, _ := readWallet(t, tenant)
assertDecimalEqual(t, bal, NewFromFloat(10), "balance restored after reverse")
assertDecimalEqual(t, readWalletConsumed(t, tenant), decimal.Zero, "total_consumed reversed with balance")
}
// TestNonConsumeOps_DoNotTouchTotalConsumed 回归保护:
// 预扣/解冻/扣款(退款调账)/加款(充值)均不得触碰累计消费计数。
func TestNonConsumeOps_DoNotTouchTotalConsumed(t *testing.T) {
tenant := int64(13)
seedWallet(t, tenant, NewFromFloat(10))
if ok, _ := PreDeduct(testCtx(), tenant, 2, "req-nc1", "gpt-4o"); !ok {
t.Fatal("prededuct failed")
}
assertDecimalEqual(t, readWalletConsumed(t, tenant), decimal.Zero, "prededuct must not touch total_consumed")
if _, err := UnfreezePreDeduct(testCtx(), tenant, "req-nc1"); err != nil {
t.Fatalf("unfreeze: %v", err)
}
assertDecimalEqual(t, readWalletConsumed(t, tenant), decimal.Zero, "unfreeze must not touch total_consumed")
if _, _, ok, err := DebitWalletRedis(testCtx(), tenant, NewFromFloat(3)); err != nil || !ok {
t.Fatalf("debit failed: ok=%v err=%v", ok, err)
}
assertDecimalEqual(t, readWalletConsumed(t, tenant), decimal.Zero, "debit (refund/adjust) must not touch total_consumed")
if _, _, err := CreditWalletRedis(testCtx(), tenant, NewFromFloat(5)); err != nil {
t.Fatalf("credit: %v", err)
}
assertDecimalEqual(t, readWalletConsumed(t, tenant), decimal.Zero, "credit (recharge) must not touch total_consumed")
}
// TestSeedTotalConsumedIfMissing 三层补种之①③层共用 Lua 的行为:
// hash 存在且缺字段 → 写入基线;字段已存在 → 不覆盖(幂等);hash 不存在 → no-op。
func TestSeedTotalConsumedIfMissing(t *testing.T) {
tenant := int64(14)
seedWallet(t, tenant, NewFromFloat(10)) // creditLua 创建的 hash 不含 total_consumed 字段
if err := seedTotalConsumedIfMissing(testCtx(), tenant, ToMicro(NewFromFloat(7.25))); err != nil {
t.Fatalf("seed total_consumed: %v", err)
}
assertDecimalEqual(t, readWalletConsumed(t, tenant), NewFromFloat(7.25), "seeded from db baseline")
// 字段已存在后发生结算 → 重复种子不得回退结算增量
if _, _, _, err := SettleClaim(testCtx(), tenant, NewFromFloat(1), nil); err != nil {
t.Fatalf("settle claim: %v", err)
}
if err := seedTotalConsumedIfMissing(testCtx(), tenant, ToMicro(NewFromFloat(7.25))); err != nil {
t.Fatalf("re-seed: %v", err)
}
assertDecimalEqual(t, readWalletConsumed(t, tenant), NewFromFloat(8.25), "re-seed must not overwrite post-settle value")
// hash 不存在的租户 → no-op(不得凭空创建 hash,由 rebuild 流程负责)
missing := int64(15)
if err := seedTotalConsumedIfMissing(testCtx(), missing, ToMicro(NewFromFloat(9))); err != nil {
t.Fatalf("seed on missing hash: %v", err)
}
if _, _, _, hasConsumed, exists, err := readWalletHash(testCtx(), missing); err != nil || exists || hasConsumed {
t.Fatalf("seed must not create hash: exists=%v hasConsumed=%v err=%v", exists, hasConsumed, err)
}
}