新功能(#3): 抽奖活动 Stage 1 骨架(DB + 规则引擎 + Handler 抽象)

Closes HIF-3

Stage 1 骨架:7 张表迁移 + 门槛规则引擎 + 加权选奖 + 次数入账/消耗(幂等)+ 发奖 handler 抽象与注册表。

- 单测覆盖 75.5%(未覆盖行 = stub handler ErrNotImplemented,合理)
- CI 全绿(构建/Vet/测试 + golangci-lint)
- 骨架不接入真实业务,vpn_duration/commission handler 在 Dispatch 中返回 ErrNotImplemented;合并后线上零变更

架构师 review 通过,4 项决策已在 issue 上给出:
1. 邀请转化语义 = 首次付款激活
2. 佣金日志类型 = 新增 CommissionTypeLottery=339
3. 家庭组归属 = 穿透到 owner
4. 管理端 IP 白名单 = 不做(推到 nginx/ingress 层)

后续 PR B/C 补真实业务对接 + 用户 API + 后台 CRUD + 集成/并发/概率测试。
This commit is contained in:
2026-07-08 20:05:05 -07:00
committed by GitHub
parent d2710d356f
commit 9933d34bdd
12 changed files with 1495 additions and 0 deletions
@@ -0,0 +1,197 @@
package lottery
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
func newLotteryTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
t.Helper()
sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expected, actual string) error {
if strings.Contains(actual, expected) {
return nil
}
return fmt.Errorf("actual sql %q does not contain %q", actual, expected)
})))
if err != nil {
t.Fatalf("create sqlmock: %v", err)
}
db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{})
if err != nil {
_ = sqlDB.Close()
t.Fatalf("open gorm db: %v", err)
}
return db, mock, func() { _ = sqlDB.Close() }
}
func TestChanceService_Grant_ZeroAmountShortCircuits(t *testing.T) {
db, mock, cleanup := newLotteryTestDB(t)
defer cleanup()
svc := NewChanceService(db)
if err := svc.Grant(context.Background(), 1, 100, "manual_grant", "ref-1", 0); err != nil {
t.Fatalf("Grant(amount=0) unexpected err: %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("no queries expected, got: %v", err)
}
}
func TestChanceService_Grant_FirstTimeInsertsGrantAndBalance(t *testing.T) {
db, mock, cleanup := newLotteryTestDB(t)
defer cleanup()
mock.ExpectBegin()
// INSERT lottery_chance_grant,未冲突返回 1 行
mock.ExpectExec("INSERT INTO `lottery_chance_grant`").
WillReturnResult(sqlmock.NewResult(1, 1))
// UPSERT lottery_chance_balance
mock.ExpectExec("INSERT INTO `lottery_chance_balance`").
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
svc := NewChanceService(db)
if err := svc.Grant(context.Background(), 42, 100, "invite_success", "order-xyz", 3); err != nil {
t.Fatalf("Grant: %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("expectations: %v", err)
}
}
func TestChanceService_Grant_IdempotentOnDuplicateSourceRef(t *testing.T) {
db, mock, cleanup := newLotteryTestDB(t)
defer cleanup()
mock.ExpectBegin()
// INSERT lottery_chance_grantUNIQUE 冲突 → 0 行影响
mock.ExpectExec("INSERT INTO `lottery_chance_grant`").
WillReturnResult(sqlmock.NewResult(0, 0))
// balance 不应被触发
mock.ExpectCommit()
svc := NewChanceService(db)
if err := svc.Grant(context.Background(), 42, 100, "invite_success", "order-xyz", 3); err != nil {
t.Fatalf("Grant: %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("expectations: %v", err)
}
}
func TestChanceService_Consume_LocksAndDecrements(t *testing.T) {
db, mock, cleanup := newLotteryTestDB(t)
defer cleanup()
mock.ExpectBegin()
mock.ExpectQuery("FROM `lottery_chance_balance`").
WithArgs(int64(42), int64(100), 1).
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "activity_id", "remaining", "total_earned", "total_spent"}).
AddRow(int64(9), int64(42), int64(100), int64(2), int64(3), int64(1)))
mock.ExpectExec("UPDATE `lottery_chance_balance`").
WithArgs(sqlmock.AnyArg(), int64(9)).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
svc := NewChanceService(db)
var remaining int64
err := db.Transaction(func(tx *gorm.DB) error {
var e error
remaining, e = svc.Consume(context.Background(), tx, 42, 100)
return e
})
if err != nil {
t.Fatalf("Consume: %v", err)
}
if remaining != 1 {
t.Fatalf("expected remaining=1, got %d", remaining)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("expectations: %v", err)
}
}
func TestChanceService_Consume_NoRowReturnsErrNoChances(t *testing.T) {
db, mock, cleanup := newLotteryTestDB(t)
defer cleanup()
mock.ExpectBegin()
mock.ExpectQuery("FROM `lottery_chance_balance`").
WithArgs(int64(42), int64(100), 1).
WillReturnError(gorm.ErrRecordNotFound)
mock.ExpectRollback()
svc := NewChanceService(db)
err := db.Transaction(func(tx *gorm.DB) error {
_, e := svc.Consume(context.Background(), tx, 42, 100)
return e
})
if !errors.Is(err, ErrNoChances) {
t.Fatalf("expected ErrNoChances, got %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("expectations: %v", err)
}
}
func TestChanceService_Consume_ZeroRemainingReturnsErrNoChances(t *testing.T) {
db, mock, cleanup := newLotteryTestDB(t)
defer cleanup()
mock.ExpectBegin()
mock.ExpectQuery("FROM `lottery_chance_balance`").
WithArgs(int64(42), int64(100), 1).
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "activity_id", "remaining", "total_earned", "total_spent"}).
AddRow(int64(9), int64(42), int64(100), int64(0), int64(3), int64(3)))
mock.ExpectRollback()
svc := NewChanceService(db)
err := db.Transaction(func(tx *gorm.DB) error {
_, e := svc.Consume(context.Background(), tx, 42, 100)
return e
})
if !errors.Is(err, ErrNoChances) {
t.Fatalf("expected ErrNoChances when remaining=0, got %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("expectations: %v", err)
}
}
func TestChanceService_Consume_RequiresTx(t *testing.T) {
db, _, cleanup := newLotteryTestDB(t)
defer cleanup()
svc := NewChanceService(db)
if _, err := svc.Consume(context.Background(), nil, 1, 1); err == nil {
t.Fatalf("expected error when tx is nil")
}
}
func TestChanceService_Query_NotFoundReturnsZero(t *testing.T) {
db, mock, cleanup := newLotteryTestDB(t)
defer cleanup()
mock.ExpectQuery("FROM `lottery_chance_balance`").
WithArgs(int64(42), int64(100), 1).
WillReturnError(gorm.ErrRecordNotFound)
svc := NewChanceService(db)
got, err := svc.Query(context.Background(), 42, 100)
if err != nil {
t.Fatalf("Query: %v", err)
}
if got != 0 {
t.Fatalf("expected 0 when no row, got %d", got)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("expectations: %v", err)
}
}