This commit is contained in:
Yvan
2026-09-22 20:20:23 +08:00
parent bf33e87d72
commit ceaea78b0f
42 changed files with 2845 additions and 154 deletions
+7 -6
View File
@@ -4,11 +4,11 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"gorm.io/gorm"
"gorm.io/gorm/clause"
bizgift "kra/internal/biz/gift"
"kra/internal/data/ledger"
"kra/internal/utils/serial"
"strconv"
"strings"
"time"
@@ -26,7 +26,7 @@ type giftPO struct {
Description string `gorm:"size:255"`
Tiers string `gorm:"type:text"`
Sort int `gorm:"default:0;index"`
Enabled bool `gorm:"default:true;index"`
Enabled bool `gorm:"index"`
}
func (giftPO) TableName() string { return "live_gifts" }
@@ -244,7 +244,11 @@ func (r *repo) Send(ctx context.Context, userID, roomID, giftID uint, quantity i
}
}
roomIDCopy, giftIDCopy := room.ID, gift.ID
row := ledger.GiftSendPO{SerialNo: newNumber("DS"), UserID: userID, RoomID: &roomIDCopy, RoomName: room.Name, AnchorUserID: room.AnchorUserID, AnchorName: room.AnchorName, GiftID: &giftIDCopy, GiftName: gift.Name, GiftIcon: gift.Icon, GiftPrice: gift.Price, Quantity: quantity, DiamondTotal: total, RateCents: rate, EquivalentCents: total * rate}
serialNo, err := serial.New("DS")
if err != nil {
return err
}
row := ledger.GiftSendPO{SerialNo: serialNo, UserID: userID, RoomID: &roomIDCopy, RoomName: room.Name, AnchorUserID: room.AnchorUserID, AnchorName: room.AnchorName, GiftID: &giftIDCopy, GiftName: gift.Name, GiftIcon: gift.Icon, GiftPrice: gift.Price, Quantity: quantity, DiamondTotal: total, RateCents: rate, EquivalentCents: total * rate}
if err := tx.Create(&row).Error; err != nil {
return err
}
@@ -295,6 +299,3 @@ func sendOrder(v string) string {
}
return "live_gift_sends.created_at DESC,live_gift_sends.id DESC"
}
func newNumber(prefix string) string {
return fmt.Sprintf("%s%s%06d", prefix, time.Now().Format("20060102150405"), time.Now().UnixNano()%1000000)
}
+182
View File
@@ -3,6 +3,7 @@ package gift
import (
"context"
"errors"
"regexp"
"testing"
bizgift "kra/internal/biz/gift"
@@ -116,3 +117,184 @@ func TestSeedDefaultCatalogAddsMissingGiftsWithoutOverwriting(t *testing.T) {
t.Fatalf("existing gift was overwritten: %+v", rose)
}
}
func TestGiftRepoInsufficientBalanceDoesNotWriteLedger(t *testing.T) {
db := openGiftTestDB(t)
repo := NewRepo(testProvider{db: db})
if err := db.Exec(`INSERT INTO sys_users(id,username,nick_name) VALUES (1,'viewer','Viewer'),(2,'anchor','Anchor')`).Error; err != nil {
t.Fatal(err)
}
if err := db.Exec(`INSERT INTO live_rooms(id,name,anchor_user_id,enabled) VALUES (7,'Room',2,1)`).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&ledger.WalletPO{UserID: 1, DiamondBalance: 11}).Error; err != nil {
t.Fatal(err)
}
item, err := repo.Save(context.Background(), &bizgift.Gift{Name: "Star", Icon: "star", Price: 7, Tiers: []int{1, 10}, Enabled: true})
if err != nil {
t.Fatal(err)
}
_, err = repo.Send(context.Background(), 1, 7, item.ID, 3)
var insufficient *bizgift.InsufficientBalanceError
if !errors.Is(err, bizgift.ErrInsufficientBalance) || !errors.As(err, &insufficient) {
t.Fatalf("insufficient balance error = %v", err)
}
if insufficient.Required != 21 || insufficient.Balance != 11 || insufficient.Missing != 10 {
t.Fatalf("incorrect recharge shortfall: %+v", insufficient)
}
var wallet ledger.WalletPO
if err := db.Where("user_id = ?", 1).First(&wallet).Error; err != nil {
t.Fatal(err)
}
var sends int64
if err := db.Model(&ledger.GiftSendPO{}).Count(&sends).Error; err != nil {
t.Fatal(err)
}
if wallet.DiamondBalance != 11 || wallet.TotalSpentDiamonds != 0 || sends != 0 {
t.Fatalf("failed gift changed ledger: wallet=%+v sends=%d", wallet, sends)
}
}
func TestGiftRepoWalletFailureRollsBackGiftReceipt(t *testing.T) {
db := openGiftTestDB(t)
repo := NewRepo(testProvider{db: db})
if err := db.Exec(`INSERT INTO sys_users(id,username,nick_name) VALUES (1,'viewer','Viewer'),(2,'anchor','Anchor')`).Error; err != nil {
t.Fatal(err)
}
if err := db.Exec(`INSERT INTO live_rooms(id,name,anchor_user_id,enabled) VALUES (7,'Room',2,1)`).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&ledger.WalletPO{UserID: 1, DiamondBalance: 100}).Error; err != nil {
t.Fatal(err)
}
item, err := repo.Save(context.Background(), &bizgift.Gift{Name: "Star", Icon: "star", Price: 7, Tiers: []int{1, 10}, Enabled: true})
if err != nil {
t.Fatal(err)
}
if err := db.Exec(`CREATE TRIGGER reject_wallet_update BEFORE UPDATE ON live_wallets BEGIN SELECT RAISE(ABORT, 'forced wallet failure'); END`).Error; err != nil {
t.Fatal(err)
}
if _, err := repo.Send(context.Background(), 1, 7, item.ID, 3); err == nil {
t.Fatal("forced wallet update failure was ignored")
}
var wallet ledger.WalletPO
if err := db.Where("user_id = ?", 1).First(&wallet).Error; err != nil {
t.Fatal(err)
}
var sends int64
if err := db.Model(&ledger.GiftSendPO{}).Count(&sends).Error; err != nil {
t.Fatal(err)
}
if wallet.DiamondBalance != 100 || wallet.TotalSpentDiamonds != 0 || sends != 0 {
t.Fatalf("wallet failure was not rolled back: wallet=%+v sends=%d", wallet, sends)
}
}
func TestGiftRepoCreateDisabledGiftCannotExposeOrDebitIt(t *testing.T) {
db := openGiftTestDB(t)
if err := db.Table("live_gifts").AutoMigrate(&struct {
Enabled bool `gorm:"default:true"`
}{}); err != nil {
t.Fatal(err)
}
repo := NewRepo(testProvider{db: db})
ctx := context.Background()
active, err := repo.Save(ctx, &bizgift.Gift{Name: "Active", Icon: "active.png", Price: 1, Tiers: []int{1}, Enabled: true})
if err != nil {
t.Fatal(err)
}
disabled, err := repo.Save(ctx, &bizgift.Gift{Name: "Disabled", Icon: "disabled.png", Price: 5, Tiers: []int{1}, Enabled: false})
if err != nil {
t.Fatal(err)
}
if disabled.Enabled {
t.Fatal("a newly created disabled gift was enabled")
}
reloaded, err := repo.Get(ctx, disabled.ID)
if err != nil || reloaded.Enabled {
t.Fatalf("reloaded disabled gift=%+v error=%v", reloaded, err)
}
enabled := true
items, total, err := repo.List(ctx, bizgift.Filter{Enabled: &enabled}, bizgift.ListOptions{Limit: 10})
if err != nil {
t.Fatal(err)
}
if total != 1 || len(items) != 1 || items[0].ID != active.ID {
t.Fatalf("public gifts include a disabled gift: total=%d items=%+v", total, items)
}
if err = repo.SetEnabled(ctx, active.ID, false); !errors.Is(err, bizgift.ErrLastEnabled) {
t.Fatalf("disabled gift counted as an enabled alternative: error=%v", err)
}
if err = db.Exec(`INSERT INTO sys_users(id,username,nick_name) VALUES (1,'viewer','Viewer'),(2,'anchor','Anchor')`).Error; err != nil {
t.Fatal(err)
}
if err = db.Exec(`INSERT INTO live_rooms(id,name,anchor_user_id,enabled) VALUES (7,'Room',2,1)`).Error; err != nil {
t.Fatal(err)
}
if err = db.Create(&ledger.WalletPO{UserID: 1, DiamondBalance: 100}).Error; err != nil {
t.Fatal(err)
}
if _, err = repo.Send(ctx, 1, 7, disabled.ID, 1); !errors.Is(err, bizgift.ErrSendNotAllowed) {
t.Fatalf("disabled gift could be sent: error=%v", err)
}
var wallet ledger.WalletPO
if err = db.Where("user_id = ?", 1).First(&wallet).Error; err != nil {
t.Fatal(err)
}
var sends int64
if err = db.Model(&ledger.GiftSendPO{}).Count(&sends).Error; err != nil {
t.Fatal(err)
}
if wallet.DiamondBalance != 100 || wallet.TotalSpentDiamonds != 0 || sends != 0 {
t.Fatalf("disabled gift changed the ledger: wallet=%+v sends=%d", wallet, sends)
}
}
func TestGiftRepoConsecutiveSendsHaveUniqueShortSerialNumbers(t *testing.T) {
db := openGiftTestDB(t)
ctx := context.Background()
repo := NewRepo(testProvider{db: db})
if err := db.Exec(`INSERT INTO sys_users(id,username,nick_name) VALUES (1,'viewer','Viewer'),(2,'anchor','Anchor')`).Error; err != nil {
t.Fatal(err)
}
if err := db.Exec(`INSERT INTO live_rooms(id,name,anchor_user_id,enabled) VALUES (7,'Room',2,1)`).Error; err != nil {
t.Fatal(err)
}
const count = 100
if err := db.Create(&ledger.WalletPO{UserID: 1, DiamondBalance: count}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&ledger.SettingPO{Key: "diamond_rate_cents", Value: "10"}).Error; err != nil {
t.Fatal(err)
}
gift, err := repo.Save(ctx, &bizgift.Gift{Name: "Gift", Icon: "gift.png", Price: 1, Tiers: []int{1}, Enabled: true})
if err != nil {
t.Fatal(err)
}
pattern := regexp.MustCompile(`^DS[A-Z2-7]{26}$`)
seen := make(map[string]struct{}, count)
for index := 0; index < count; index++ {
sent, err := repo.Send(ctx, 1, 7, gift.ID, 1)
if err != nil {
t.Fatalf("gift %d failed: %v", index, err)
}
if !pattern.MatchString(sent.SerialNo) || len(sent.SerialNo) > 32 {
t.Fatalf("invalid gift serial: %q", sent.SerialNo)
}
if _, exists := seen[sent.SerialNo]; exists {
t.Fatalf("duplicate gift serial: %q", sent.SerialNo)
}
seen[sent.SerialNo] = struct{}{}
}
var receipts int64
if err := db.Model(&ledger.GiftSendPO{}).Count(&receipts).Error; err != nil {
t.Fatal(err)
}
var wallet ledger.WalletPO
if err := db.Where("user_id = ?", 1).First(&wallet).Error; err != nil {
t.Fatal(err)
}
if receipts != count || wallet.DiamondBalance != 0 || wallet.TotalSpentDiamonds != count {
t.Fatalf("consecutive gifts did not reconcile: receipts=%d wallet=%+v", receipts, wallet)
}
}