新功能(#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,11 @@
-- 02156 抽奖活动 Stage 1 回滚
-- 反向删除 7 张表。因存在业务耦合数据(用户次数、抽奖记录、快照)在生产回滚前
-- 必须先备份,回滚只删表结构。执行顺序按外键依赖反向:先删依赖别人的,再删被依赖的。
DROP TABLE IF EXISTS `lottery_eligibility_snapshot`;
DROP TABLE IF EXISTS `lottery_prize_snapshot`;
DROP TABLE IF EXISTS `lottery_draw`;
DROP TABLE IF EXISTS `lottery_chance_grant`;
DROP TABLE IF EXISTS `lottery_chance_balance`;
DROP TABLE IF EXISTS `lottery_prize`;
DROP TABLE IF EXISTS `lottery_activity`;
@@ -0,0 +1,125 @@
-- 02156 抽奖活动 Stage 1(后端核心闭环)
--
-- 新建 7 张表 + 全部索引 + 幂等约束。
-- 幂等设计:全部 `CREATE TABLE IF NOT EXISTS`;索引通过 INFORMATION_SCHEMA 预检
-- 后再补齐。可重复执行不报错,符合 `doc/development-workflow-zh.md` 迁移规范。
--
-- 关键唯一索引(都是并发/幂等正确性的核心,切勿删):
-- 1) lottery_prize (activity_id, slot) — 一个活动一个位置只能挂一个奖品
-- 2) lottery_draw (user_id, client_nonce) — 用户端幂等键,重放同一 nonce 返回同一 draw
-- 3) lottery_chance_balance (user_id, activity_id) — 每人每活动一个次数余额行
-- 4) lottery_chance_grant (activity_id, source, source_ref) — 次数入账幂等键(避免同订单发两次机会)
-- 5) lottery_prize_snapshot (draw_id) — 抽奖时刻的奖品快照,1:1
-- 6) lottery_eligibility_snapshot (draw_id) — 抽奖时刻的门槛评估快照,1:1
CREATE TABLE IF NOT EXISTS `lottery_activity` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`title` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '活动标题',
`description` TEXT COMMENT '活动描述(Markdown',
`start_at` DATETIME NOT NULL COMMENT '开始时间',
`end_at` DATETIME NOT NULL COMMENT '结束时间',
`status` VARCHAR(16) NOT NULL DEFAULT 'draft' COMMENT '状态:draft / running / paused / ended',
`grid_size` TINYINT NOT NULL DEFAULT 9 COMMENT '前端九宫格数量(3/6/8/9/12',
`eligibility` JSON NOT NULL COMMENT '参与门槛(AND/OR 嵌套规则)',
`chance_sources` JSON NOT NULL COMMENT '次数来源列表(daily_signin / new_subscription / invite_success / manual_grant',
`unmet_action` VARCHAR(32) NOT NULL DEFAULT 'block' COMMENT '未达门槛策略:block / show_reason',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`deleted_at` DATETIME DEFAULT NULL COMMENT '软删除时间',
PRIMARY KEY (`id`),
KEY `idx_status_time` (`status`, `start_at`, `end_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抽奖活动';
CREATE TABLE IF NOT EXISTS `lottery_prize` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`activity_id` BIGINT UNSIGNED NOT NULL COMMENT '所属活动 ID',
`slot` TINYINT NOT NULL COMMENT '九宫格位置(0-based',
`type` VARCHAR(32) NOT NULL COMMENT '奖品类型:vpn_duration / commission / balance / gift_amount / coupon / points / encrypted / physical / manual_other / none',
`name` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '奖品名称',
`icon_url` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '奖品图标 URL',
`config` JSON NOT NULL COMMENT '类型专属配置(如 {"duration_days":3}',
`weight` INT NOT NULL DEFAULT 0 COMMENT '加权随机权重(0 表示不参与随机)',
`total_stock` BIGINT COMMENT '总库存(NULL 表示无限)',
`remaining_stock` BIGINT COMMENT '剩余库存(NULL 表示无限,与 total_stock 同 NULL',
`is_fallback` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否为保底奖(1: 是,抽中限量奖降级到此;weight 被忽略)',
`version` BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_activity_slot` (`activity_id`, `slot`),
KEY `idx_activity_fallback` (`activity_id`, `is_fallback`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抽奖奖品定义';
CREATE TABLE IF NOT EXISTS `lottery_chance_balance` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户 ID',
`activity_id` BIGINT UNSIGNED NOT NULL COMMENT '活动 ID',
`remaining` BIGINT NOT NULL DEFAULT 0 COMMENT '剩余次数(下一次抽奖要读这里并 -1',
`total_earned` BIGINT NOT NULL DEFAULT 0 COMMENT '累计入账次数(审计用)',
`total_spent` BIGINT NOT NULL DEFAULT 0 COMMENT '累计消耗次数(审计用)',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_activity` (`user_id`, `activity_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抽奖次数余额';
CREATE TABLE IF NOT EXISTS `lottery_chance_grant` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`user_id` BIGINT UNSIGNED NOT NULL COMMENT '发放对象用户 ID',
`activity_id` BIGINT UNSIGNED NOT NULL COMMENT '活动 ID',
`source` VARCHAR(32) NOT NULL COMMENT '触发源:daily_signin / new_subscription / invite_success / manual_grant',
`source_ref` VARCHAR(128) NOT NULL COMMENT '外部业务幂等键(如 order_no、"signin:{yyyymmdd}"、"manual:{admin_id}:{ts}"',
`amount` INT NOT NULL DEFAULT 0 COMMENT '本次发放次数',
`expires_at` DATETIME DEFAULT NULL COMMENT '本次入账的到期时间(NULL 表示不过期)',
`granted_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_activity_source_ref` (`activity_id`, `source`, `source_ref`),
KEY `idx_user_activity_expires` (`user_id`, `activity_id`, `expires_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抽奖次数入账流水(幂等键 = activity_id+source+source_ref';
CREATE TABLE IF NOT EXISTS `lottery_draw` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户 ID',
`activity_id` BIGINT UNSIGNED NOT NULL COMMENT '活动 ID',
`client_nonce` VARCHAR(64) NOT NULL COMMENT '前端幂等键(UUID',
`prize_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '中奖奖品 ID(未中奖为 NULL',
`is_win` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否中奖(未中奖=谢谢参与,也会写 draw)',
`dispatch_state` VARCHAR(16) NOT NULL DEFAULT 'none' COMMENT '发放状态:none(无需发) / auto_claimed(自动已发) / pending_claim(等待人工领) / paid(人工发完) / expired(超时未领) / failed',
`dispatch_error` TEXT COMMENT '发放失败的错误信息(仅失败时写)',
`dispatched_at` DATETIME DEFAULT NULL COMMENT '发放完成时间(自动类=事务提交时;人工类=运营录入后)',
`drawn_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '抽奖时间',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_nonce` (`user_id`, `client_nonce`),
KEY `idx_user_time` (`user_id`, `drawn_at`),
KEY `idx_activity_win_time` (`activity_id`, `is_win`, `drawn_at`),
KEY `idx_activity_prize` (`activity_id`, `prize_id`),
KEY `idx_dispatch_state` (`dispatch_state`, `drawn_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抽奖记录';
CREATE TABLE IF NOT EXISTS `lottery_prize_snapshot` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`draw_id` BIGINT UNSIGNED NOT NULL COMMENT '抽奖记录 ID',
`prize_id` BIGINT UNSIGNED NOT NULL COMMENT '奖品 ID(快照当时的 id',
`slot` TINYINT NOT NULL COMMENT '九宫格位置(快照)',
`type` VARCHAR(32) NOT NULL COMMENT '奖品类型(快照)',
`name` VARCHAR(128) NOT NULL COMMENT '奖品名称(快照)',
`config` JSON NOT NULL COMMENT '类型专属配置(快照)',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_draw_id` (`draw_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抽奖时刻的奖品快照(对账/纠纷用)';
CREATE TABLE IF NOT EXISTS `lottery_eligibility_snapshot` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`draw_id` BIGINT UNSIGNED NOT NULL COMMENT '抽奖记录 ID',
`user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户 ID',
`activity_id` BIGINT UNSIGNED NOT NULL COMMENT '活动 ID',
`passed` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否通过门槛(未通过=拒绝抽奖或前端提示)',
`unmet_reasons` JSON COMMENT '未通过项(rule/hint/current/required',
`evaluated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_draw_id` (`draw_id`),
KEY `idx_user_activity_time` (`user_id`, `activity_id`, `evaluated_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抽奖时刻的门槛评估快照(对账/申诉用)';
+116
View File
@@ -0,0 +1,116 @@
package lottery
import (
"context"
"errors"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// chanceService 是 ChanceService 的默认实现。
type chanceService struct {
db *gorm.DB
}
// NewChanceService 用注入的 *gorm.DB 构造一个 ChanceService。
func NewChanceService(db *gorm.DB) ChanceService { return &chanceService{db: db} }
// Grant 记录一次次数入账,幂等键 = (activity_id, source, source_ref)。
// 幂等策略:
// 1. INSERT lottery_chance_grant,靠 UNIQUE(activity_id, source, source_ref) 触发冲突
// 2. 冲突视为"已发过",直接返回 nil 不重复发放
// 3. 未冲突 → UPSERT lottery_chance_balance 累加 remaining
//
// 关键正确性:两步必须在同一事务内。这样第 (1) 成功即证明是首次入账,
// 才走第 (2);第 (1) 冲突则直接跳过 (2),balance 不会双加。
func (s *chanceService) Grant(ctx context.Context, userId, activityId int64, source, sourceRef string, amount int) error {
if amount <= 0 {
return nil
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
grant := ChanceGrant{
UserId: userId,
ActivityId: activityId,
Source: source,
SourceRef: sourceRef,
Amount: amount,
}
// OnConflict DoNothing 依赖 UNIQUE(activity_id, source, source_ref)。
res := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&grant)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
// 幂等命中:已经发过,balance 不动。
return nil
}
// 未冲突 → 累加余额(upsert balance 行)。
balance := ChanceBalance{
UserId: userId,
ActivityId: activityId,
Remaining: int64(amount),
TotalEarned: int64(amount),
}
return tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}, {Name: "activity_id"}},
DoUpdates: clause.Assignments(map[string]interface{}{
"remaining": gorm.Expr("`lottery_chance_balance`.`remaining` + ?", amount),
"total_earned": gorm.Expr("`lottery_chance_balance`.`total_earned` + ?", amount),
}),
}).Create(&balance).Error
})
}
// Consume 在事务内以 SELECT ... FOR UPDATE 锁住 chance_balance 行后 -1。
// 剩余为 0 时返回 ErrNoChances,调用方直接回滚事务,不写 draw。
func (s *chanceService) Consume(ctx context.Context, tx *gorm.DB, userId, activityId int64) (int64, error) {
if tx == nil {
return 0, errors.New("Consume requires a transaction handle")
}
var balance ChanceBalance
err := tx.WithContext(ctx).
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("user_id = ? AND activity_id = ?", userId, activityId).
First(&balance).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, ErrNoChances
}
return 0, err
}
if balance.Remaining <= 0 {
return 0, ErrNoChances
}
// 累加 spent,扣减 remaining,一条 SQL 完成。
updateErr := tx.WithContext(ctx).
Model(&ChanceBalance{}).
Where("id = ? AND remaining > 0", balance.Id).
Updates(map[string]interface{}{
"remaining": gorm.Expr("`remaining` - 1"),
"total_spent": gorm.Expr("`total_spent` + 1"),
}).Error
if updateErr != nil {
return 0, updateErr
}
return balance.Remaining - 1, nil
}
// Query 只读,返回用户在活动下的剩余次数。未初始化过 balance 行时返回 0。
func (s *chanceService) Query(ctx context.Context, userId, activityId int64) (int64, error) {
var balance ChanceBalance
err := s.db.WithContext(ctx).
Where("user_id = ? AND activity_id = ?", userId, activityId).
First(&balance).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, nil
}
return 0, err
}
if balance.Remaining < 0 {
return 0, nil
}
return balance.Remaining, nil
}
@@ -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)
}
}
+184
View File
@@ -0,0 +1,184 @@
package lottery
import (
"database/sql"
"encoding/json"
"time"
"gorm.io/gorm"
)
// ---- 状态与类型常量 ---------------------------------------------------------
const (
ActivityStatusDraft = "draft"
ActivityStatusRunning = "running"
ActivityStatusPaused = "paused"
ActivityStatusEnded = "ended"
UnmetActionBlock = "block"
UnmetActionShowReason = "show_reason"
// PrizeType* 是发奖 handler 注册表的键。Stage 1 只实现前三种。
PrizeTypeVPNDuration = "vpn_duration"
PrizeTypeCommission = "commission"
PrizeTypeNone = "none"
// Stage 2/3 预留。
PrizeTypeBalance = "balance"
PrizeTypeGiftAmount = "gift_amount"
PrizeTypeCoupon = "coupon"
PrizeTypePoints = "points"
PrizeTypeEncrypted = "encrypted"
PrizeTypePhysical = "physical"
PrizeTypeManualOther = "manual_other"
// ChanceSource* 是次数入账触发源。
ChanceSourceDailySignin = "daily_signin"
ChanceSourceNewSubscription = "new_subscription"
ChanceSourceInviteSuccess = "invite_success"
ChanceSourceManualGrant = "manual_grant"
// DispatchState* 是 lottery_draw.dispatch_state 的取值。
DispatchStateNone = "none"
DispatchStateAutoClaimed = "auto_claimed"
DispatchStatePendingClaim = "pending_claim"
DispatchStatePaid = "paid"
DispatchStateExpired = "expired"
DispatchStateFailed = "failed"
)
// ---- 实体 -----------------------------------------------------------------
type Activity struct {
Id int64 `gorm:"primaryKey"`
Title string `gorm:"type:varchar(128);not null;default:'';comment:活动标题"`
Description string `gorm:"type:text;comment:活动描述"`
StartAt time.Time `gorm:"not null;comment:开始时间"`
EndAt time.Time `gorm:"not null;comment:结束时间"`
Status string `gorm:"type:varchar(16);not null;default:'draft';comment:状态"`
GridSize int `gorm:"type:tinyint;not null;default:9;comment:九宫格数量"`
Eligibility string `gorm:"type:json;not null;comment:参与门槛(AND/OR 嵌套规则)"`
ChanceSources string `gorm:"type:json;not null;comment:次数来源列表"`
UnmetAction string `gorm:"type:varchar(32);not null;default:'block';comment:未达门槛策略"`
CreatedAt time.Time `gorm:"<-:create;comment:Create Time"`
UpdatedAt time.Time `gorm:"comment:Update Time"`
DeletedAt gorm.DeletedAt `gorm:"index;comment:Delete Time"`
}
func (Activity) TableName() string { return "lottery_activity" }
type Prize struct {
Id int64 `gorm:"primaryKey"`
ActivityId int64 `gorm:"type:bigint unsigned;not null;comment:所属活动"`
Slot int `gorm:"type:tinyint;not null;comment:九宫格位置"`
Type string `gorm:"type:varchar(32);not null;comment:奖品类型"`
Name string `gorm:"type:varchar(128);not null;default:'';comment:名称"`
IconURL string `gorm:"type:varchar(512);not null;default:'';comment:图标 URL"`
Config string `gorm:"type:json;not null;comment:类型专属配置"`
Weight int `gorm:"type:int;not null;default:0;comment:加权随机权重"`
TotalStock sql.NullInt64 `gorm:"type:bigint;comment:总库存(NULL=无限)"`
RemainingStock sql.NullInt64 `gorm:"type:bigint;comment:剩余库存(NULL=无限)"`
IsFallback bool `gorm:"type:tinyint(1);not null;default:0;comment:是否为保底奖"`
Version int64 `gorm:"type:bigint;not null;default:0;comment:乐观锁"`
CreatedAt time.Time `gorm:"<-:create;comment:Create Time"`
UpdatedAt time.Time `gorm:"comment:Update Time"`
}
func (Prize) TableName() string { return "lottery_prize" }
type ChanceBalance struct {
Id int64 `gorm:"primaryKey"`
UserId int64 `gorm:"type:bigint unsigned;not null;comment:用户 ID"`
ActivityId int64 `gorm:"type:bigint unsigned;not null;comment:活动 ID"`
Remaining int64 `gorm:"type:bigint;not null;default:0;comment:剩余次数"`
TotalEarned int64 `gorm:"type:bigint;not null;default:0;comment:累计入账"`
TotalSpent int64 `gorm:"type:bigint;not null;default:0;comment:累计消耗"`
CreatedAt time.Time `gorm:"<-:create;comment:Create Time"`
UpdatedAt time.Time `gorm:"comment:Update Time"`
}
func (ChanceBalance) TableName() string { return "lottery_chance_balance" }
type ChanceGrant struct {
Id int64 `gorm:"primaryKey"`
UserId int64 `gorm:"type:bigint unsigned;not null;comment:用户 ID"`
ActivityId int64 `gorm:"type:bigint unsigned;not null;comment:活动 ID"`
Source string `gorm:"type:varchar(32);not null;comment:触发源"`
SourceRef string `gorm:"type:varchar(128);not null;comment:外部业务幂等键"`
Amount int `gorm:"type:int;not null;default:0;comment:本次发放次数"`
ExpiresAt *time.Time `gorm:"default:null;comment:到期时间"`
GrantedAt time.Time `gorm:"<-:create;default:CURRENT_TIMESTAMP;comment:入账时间"`
}
func (ChanceGrant) TableName() string { return "lottery_chance_grant" }
type Draw struct {
Id int64 `gorm:"primaryKey"`
UserId int64 `gorm:"type:bigint unsigned;not null;comment:用户 ID"`
ActivityId int64 `gorm:"type:bigint unsigned;not null;comment:活动 ID"`
ClientNonce string `gorm:"type:varchar(64);not null;comment:前端幂等键"`
PrizeId sql.NullInt64 `gorm:"type:bigint unsigned;comment:中奖奖品 ID(谢谢参与=NULL"`
IsWin bool `gorm:"type:tinyint(1);not null;default:0;comment:是否中奖"`
DispatchState string `gorm:"type:varchar(16);not null;default:'none';comment:发放状态"`
DispatchError string `gorm:"type:text;comment:发放失败原因"`
DispatchedAt *time.Time `gorm:"default:null;comment:发放完成时间"`
DrawnAt time.Time `gorm:"not null;default:CURRENT_TIMESTAMP;comment:抽奖时间"`
CreatedAt time.Time `gorm:"<-:create;comment:Create Time"`
UpdatedAt time.Time `gorm:"comment:Update Time"`
}
func (Draw) TableName() string { return "lottery_draw" }
type PrizeSnapshot struct {
Id int64 `gorm:"primaryKey"`
DrawId int64 `gorm:"type:bigint unsigned;not null;uniqueIndex:uk_draw_id;comment:抽奖记录 ID"`
PrizeId int64 `gorm:"type:bigint unsigned;not null;comment:奖品 ID"`
Slot int `gorm:"type:tinyint;not null;comment:九宫格位置"`
Type string `gorm:"type:varchar(32);not null;comment:奖品类型"`
Name string `gorm:"type:varchar(128);not null;comment:奖品名称"`
Config string `gorm:"type:json;not null;comment:类型专属配置"`
CreatedAt time.Time `gorm:"<-:create;comment:Create Time"`
}
func (PrizeSnapshot) TableName() string { return "lottery_prize_snapshot" }
type EligibilitySnapshot struct {
Id int64 `gorm:"primaryKey"`
DrawId int64 `gorm:"type:bigint unsigned;not null;uniqueIndex:uk_draw_id;comment:抽奖记录 ID"`
UserId int64 `gorm:"type:bigint unsigned;not null;comment:用户 ID"`
ActivityId int64 `gorm:"type:bigint unsigned;not null;comment:活动 ID"`
Passed bool `gorm:"type:tinyint(1);not null;default:0;comment:是否通过门槛"`
UnmetReasons string `gorm:"type:json;comment:未通过项"`
EvaluatedAt time.Time `gorm:"not null;default:CURRENT_TIMESTAMP;comment:评估时间"`
}
func (EligibilitySnapshot) TableName() string { return "lottery_eligibility_snapshot" }
// ---- JSON 结构体辅助 -------------------------------------------------------
// EligibilityRule 是持久化在 lottery_activity.eligibility 字段里的门槛规则树。
// 每个节点要么是"叶子"(Type 非空),要么是"聚合"Op 为 AND/OR + Children)。
type EligibilityRule struct {
Op string `json:"op,omitempty"`
Children []*EligibilityRule `json:"children,omitempty"`
Type string `json:"type,omitempty"`
Params json.RawMessage `json:"params,omitempty"`
}
// ChanceSource 描述次数来源的一条配置,持久化在 lottery_activity.chance_sources。
type ChanceSource struct {
Source string `json:"source"`
Amount int `json:"amount"`
Params json.RawMessage `json:"params,omitempty"`
DailyLimit int `json:"daily_limit,omitempty"`
Unlimited bool `json:"unlimited,omitempty"`
}
// UnmetReason 是门槛评估结果,作为 GET /config 的 unmet_reasons 元素返回。
type UnmetReason struct {
Rule string `json:"rule"`
Hint string `json:"hint"`
Current int64 `json:"current,omitempty"`
Required int64 `json:"required,omitempty"`
}
+94
View File
@@ -0,0 +1,94 @@
package lottery
import (
"context"
"sync"
"gorm.io/gorm"
)
// defaultRegistry 是并发安全的默认 Registry。启动时一次性注册,运行时只读。
type defaultRegistry struct {
mu sync.RWMutex
handlers map[string]PrizeHandler
}
// NewRegistry 返回空的默认注册表。使用 Register 挂接实现。
func NewRegistry() *defaultRegistry {
return &defaultRegistry{handlers: make(map[string]PrizeHandler, 8)}
}
// Register 挂接一个 handler;重复注册会覆盖(初始化阶段可控,不额外拦截)。
func (r *defaultRegistry) Register(h PrizeHandler) {
r.mu.Lock()
defer r.mu.Unlock()
r.handlers[h.Type()] = h
}
func (r *defaultRegistry) Get(prizeType string) (PrizeHandler, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
h, ok := r.handlers[prizeType]
return h, ok
}
func (r *defaultRegistry) MustGet(prizeType string) (PrizeHandler, error) {
if h, ok := r.Get(prizeType); ok {
return h, nil
}
return nil, ErrHandlerNotRegistered
}
// ---- Stage 1 骨架 handler --------------------------------------------------
// 真实业务对接在架构师 review 骨架 PR 之后 Day 2-3 补齐。此时命中真实
// handler 会返回 ErrNotImplemented,让抽奖流程整笔事务回滚,避免误发。
// noopHandler 是"谢谢参与"。写记录、不发放,不需要真实业务对接。
type noopHandler struct{}
// NewNoopHandler 返回 PrizeTypeNone 的 handler。
func NewNoopHandler() PrizeHandler { return &noopHandler{} }
func (noopHandler) Type() string { return PrizeTypeNone }
func (noopHandler) IsAuto() bool { return true }
func (noopHandler) Dispatch(_ context.Context, _ *gorm.DB, _ DispatchRequest) (DispatchResult, error) {
return DispatchResult{State: DispatchStateAutoClaimed, Message: "谢谢参与"}, nil
}
func (noopHandler) ValidateClaim(_ []byte) error { return nil }
// vpnDurationStubHandler 是订阅时长发放的占位 handler。
// Day 2-3 需替换为真实实现:
// 1. tx 内 SELECT ... FOR UPDATE 锁 lottery_grant_ledger UNIQUE(external_ref)
// 2. 命中 → 幂等返回;未命中 → INSERT ledger + 调用 UserModel.UpdateSubscribe/InsertSubscribe
// 3. external_ref 用 fmt.Sprintf("lottery:%d:%d", req.ActivityId, req.DrawId)
// 4. 家庭组场景通过 commonLogic.ResolveEntitlementUser 归位到 owner
// 5. 成功后 best-effort 调 NodeModel.ClearServerAllCache + 用户组重算
type vpnDurationStubHandler struct{}
// NewVPNDurationStubHandler 返回 PrizeTypeVPNDuration 的骨架 handler。
func NewVPNDurationStubHandler() PrizeHandler { return &vpnDurationStubHandler{} }
func (vpnDurationStubHandler) Type() string { return PrizeTypeVPNDuration }
func (vpnDurationStubHandler) IsAuto() bool { return true }
func (vpnDurationStubHandler) Dispatch(_ context.Context, _ *gorm.DB, _ DispatchRequest) (DispatchResult, error) {
return DispatchResult{}, ErrNotImplemented
}
func (vpnDurationStubHandler) ValidateClaim(_ []byte) error { return nil }
// commissionStubHandler 是佣金入账的占位 handler。
// Day 2-3 需替换为真实实现:
// 1. tx 内 lottery_grant_ledger 幂等检查
// 2. 调 UserModel.UpdateCommission + common.WriteCommissionLog(同 tx
// 3. 建议新增 log.CommissionTypeLottery 常量(数值待架构师批),
// 避免把抽奖入账混进 Purchase/Renewal 类型统计
type commissionStubHandler struct{}
// NewCommissionStubHandler 返回 PrizeTypeCommission 的骨架 handler。
func NewCommissionStubHandler() PrizeHandler { return &commissionStubHandler{} }
func (commissionStubHandler) Type() string { return PrizeTypeCommission }
func (commissionStubHandler) IsAuto() bool { return true }
func (commissionStubHandler) Dispatch(_ context.Context, _ *gorm.DB, _ DispatchRequest) (DispatchResult, error) {
return DispatchResult{}, ErrNotImplemented
}
func (commissionStubHandler) ValidateClaim(_ []byte) error { return nil }
+59
View File
@@ -0,0 +1,59 @@
package lottery
import (
"context"
"errors"
"testing"
"gorm.io/gorm"
)
func TestRegistry_GetMissing(t *testing.T) {
r := NewRegistry()
if _, ok := r.Get("nope"); ok {
t.Fatalf("empty registry should not return handler")
}
if _, err := r.MustGet("nope"); !errors.Is(err, ErrHandlerNotRegistered) {
t.Fatalf("MustGet on missing type should return ErrHandlerNotRegistered, got %v", err)
}
}
func TestRegistry_RegisterAndGet(t *testing.T) {
r := NewRegistry()
r.Register(NewNoopHandler())
r.Register(NewVPNDurationStubHandler())
r.Register(NewCommissionStubHandler())
for _, want := range []string{PrizeTypeNone, PrizeTypeVPNDuration, PrizeTypeCommission} {
h, err := r.MustGet(want)
if err != nil {
t.Fatalf("MustGet(%q): %v", want, err)
}
if h.Type() != want {
t.Fatalf("Type mismatch: want %q got %q", want, h.Type())
}
}
}
func TestNoopHandler_AlwaysAutoClaimed(t *testing.T) {
h := NewNoopHandler()
if !h.IsAuto() {
t.Fatal("noop must be auto")
}
res, err := h.Dispatch(context.Background(), (*gorm.DB)(nil), DispatchRequest{})
if err != nil {
t.Fatalf("noop dispatch err: %v", err)
}
if res.State != DispatchStateAutoClaimed {
t.Fatalf("noop should dispatch as auto_claimed, got %q", res.State)
}
}
func TestStubHandlers_ReturnNotImplemented(t *testing.T) {
for _, h := range []PrizeHandler{NewVPNDurationStubHandler(), NewCommissionStubHandler()} {
_, err := h.Dispatch(context.Background(), (*gorm.DB)(nil), DispatchRequest{})
if !errors.Is(err, ErrNotImplemented) {
t.Fatalf("%s: expected ErrNotImplemented, got %v", h.Type(), err)
}
}
}
+252
View File
@@ -0,0 +1,252 @@
package lottery
import (
"context"
"encoding/json"
"fmt"
"strings"
)
// 门槛规则类型常量。前端展示时可依据这些 key 组装本地化提示。
const (
RuleTypeHasSubscription = "has_subscription" // 需要有活跃订阅(可带 min_days_remaining
RuleTypeSubscriptionType = "subscription_type" // 订阅套餐必须在 plan_ids 中
RuleTypeInviteCount = "invite_count" // 邀请人数 ≥ min(可带 window_days,由 ContextBuilder 预算)
RuleTypeTotalRecharge = "total_recharge" // 累计充值 ≥ min_usdt
RuleTypeRegisterDays = "register_days" // 注册天数 ≥ min
RuleTypeUserTag = "user_tag" // 用户标签命中 tags[] 中任一
)
const (
OpAND = "AND"
OpOR = "OR"
)
// defaultEvaluator 是 RuleEvaluator 的开箱实现。
type defaultEvaluator struct{}
// NewRuleEvaluator 返回默认门槛评估器。
func NewRuleEvaluator() RuleEvaluator { return &defaultEvaluator{} }
func (e *defaultEvaluator) Evaluate(_ context.Context, tree *EligibilityRule, rc RuleContext) (bool, []UnmetReason, error) {
if tree == nil {
return true, nil, nil
}
unmet := make([]UnmetReason, 0, 4)
passed, err := e.evalNode(tree, rc, &unmet)
if err != nil {
return false, nil, err
}
return passed, unmet, nil
}
func (e *defaultEvaluator) evalNode(node *EligibilityRule, rc RuleContext, unmet *[]UnmetReason) (bool, error) {
if node == nil {
return true, nil
}
// 聚合节点
if node.Op != "" {
return e.evalGroup(node, rc, unmet)
}
// 叶子节点
if node.Type == "" {
return true, nil
}
return e.evalLeaf(node, rc, unmet)
}
func (e *defaultEvaluator) evalGroup(node *EligibilityRule, rc RuleContext, unmet *[]UnmetReason) (bool, error) {
op := strings.ToUpper(node.Op)
if len(node.Children) == 0 {
return true, nil
}
switch op {
case OpAND:
allPassed := true
for _, child := range node.Children {
ok, err := e.evalNode(child, rc, unmet)
if err != nil {
return false, err
}
if !ok {
allPassed = false
}
}
return allPassed, nil
case OpOR:
// OR 只收集内部未通过项到一个临时篮子;若整体通过则不冒泡出去。
anyPassed := false
local := make([]UnmetReason, 0, len(node.Children))
for _, child := range node.Children {
ok, err := e.evalNode(child, rc, &local)
if err != nil {
return false, err
}
if ok {
anyPassed = true
}
}
if !anyPassed {
*unmet = append(*unmet, local...)
}
return anyPassed, nil
default:
return false, fmt.Errorf("unknown group op %q", node.Op)
}
}
func (e *defaultEvaluator) evalLeaf(node *EligibilityRule, rc RuleContext, unmet *[]UnmetReason) (bool, error) {
switch node.Type {
case RuleTypeHasSubscription:
return e.evalHasSubscription(node, rc, unmet)
case RuleTypeSubscriptionType:
return e.evalSubscriptionType(node, rc, unmet)
case RuleTypeInviteCount:
return e.evalInviteCount(node, rc, unmet)
case RuleTypeTotalRecharge:
return e.evalTotalRecharge(node, rc, unmet)
case RuleTypeRegisterDays:
return e.evalRegisterDays(node, rc, unmet)
case RuleTypeUserTag:
return e.evalUserTag(node, rc, unmet)
default:
return false, fmt.Errorf("unknown leaf rule type %q", node.Type)
}
}
// ---- 单条规则 -------------------------------------------------------------
func (e *defaultEvaluator) evalHasSubscription(node *EligibilityRule, rc RuleContext, unmet *[]UnmetReason) (bool, error) {
var params struct {
MinDaysRemaining int64 `json:"min_days_remaining"`
}
if len(node.Params) > 0 {
if err := json.Unmarshal(node.Params, &params); err != nil {
return false, fmt.Errorf("has_subscription params: %w", err)
}
}
if !rc.HasActiveSubscription {
*unmet = append(*unmet, UnmetReason{
Rule: RuleTypeHasSubscription,
Hint: "需要有活跃订阅",
})
return false, nil
}
if params.MinDaysRemaining > 0 {
got := rc.SubscriptionExpiresIn / 86400
if got < params.MinDaysRemaining {
*unmet = append(*unmet, UnmetReason{
Rule: RuleTypeHasSubscription,
Hint: fmt.Sprintf("订阅剩余天数不足,还差 %d 天", params.MinDaysRemaining-got),
Current: got,
Required: params.MinDaysRemaining,
})
return false, nil
}
}
return true, nil
}
func (e *defaultEvaluator) evalSubscriptionType(node *EligibilityRule, rc RuleContext, unmet *[]UnmetReason) (bool, error) {
var params struct {
PlanIds []int64 `json:"plan_ids"`
}
if err := json.Unmarshal(node.Params, &params); err != nil {
return false, fmt.Errorf("subscription_type params: %w", err)
}
want := make(map[int64]struct{}, len(params.PlanIds))
for _, id := range params.PlanIds {
want[id] = struct{}{}
}
for _, id := range rc.SubscriptionPlanIds {
if _, ok := want[id]; ok {
return true, nil
}
}
*unmet = append(*unmet, UnmetReason{
Rule: RuleTypeSubscriptionType,
Hint: "订阅类型不符合活动要求",
})
return false, nil
}
func (e *defaultEvaluator) evalInviteCount(node *EligibilityRule, rc RuleContext, unmet *[]UnmetReason) (bool, error) {
var params struct {
Min int64 `json:"min"`
WindowDays int64 `json:"window_days,omitempty"`
}
if err := json.Unmarshal(node.Params, &params); err != nil {
return false, fmt.Errorf("invite_count params: %w", err)
}
if rc.InviteCount >= params.Min {
return true, nil
}
*unmet = append(*unmet, UnmetReason{
Rule: RuleTypeInviteCount,
Hint: fmt.Sprintf("还需邀请 %d 人(%d/%d", params.Min-rc.InviteCount, rc.InviteCount, params.Min),
Current: rc.InviteCount,
Required: params.Min,
})
return false, nil
}
func (e *defaultEvaluator) evalTotalRecharge(node *EligibilityRule, rc RuleContext, unmet *[]UnmetReason) (bool, error) {
var params struct {
MinUSDT int64 `json:"min_usdt"`
}
if err := json.Unmarshal(node.Params, &params); err != nil {
return false, fmt.Errorf("total_recharge params: %w", err)
}
if rc.TotalRechargeUSDT >= params.MinUSDT {
return true, nil
}
*unmet = append(*unmet, UnmetReason{
Rule: RuleTypeTotalRecharge,
Hint: fmt.Sprintf("累计充值不足,还差 %d USDT", params.MinUSDT-rc.TotalRechargeUSDT),
Current: rc.TotalRechargeUSDT,
Required: params.MinUSDT,
})
return false, nil
}
func (e *defaultEvaluator) evalRegisterDays(node *EligibilityRule, rc RuleContext, unmet *[]UnmetReason) (bool, error) {
var params struct {
Min int64 `json:"min"`
}
if err := json.Unmarshal(node.Params, &params); err != nil {
return false, fmt.Errorf("register_days params: %w", err)
}
if rc.RegisterDays >= params.Min {
return true, nil
}
*unmet = append(*unmet, UnmetReason{
Rule: RuleTypeRegisterDays,
Hint: fmt.Sprintf("注册天数不足,还差 %d 天", params.Min-rc.RegisterDays),
Current: rc.RegisterDays,
Required: params.Min,
})
return false, nil
}
func (e *defaultEvaluator) evalUserTag(node *EligibilityRule, rc RuleContext, unmet *[]UnmetReason) (bool, error) {
var params struct {
Tags []string `json:"tags"`
}
if err := json.Unmarshal(node.Params, &params); err != nil {
return false, fmt.Errorf("user_tag params: %w", err)
}
want := make(map[string]struct{}, len(params.Tags))
for _, t := range params.Tags {
want[t] = struct{}{}
}
for _, t := range rc.UserTags {
if _, ok := want[t]; ok {
return true, nil
}
}
*unmet = append(*unmet, UnmetReason{
Rule: RuleTypeUserTag,
Hint: "用户标签不符合活动要求",
})
return false, nil
}
@@ -0,0 +1,193 @@
package lottery
import (
"context"
"encoding/json"
"testing"
)
func mustParams(t *testing.T, v any) json.RawMessage {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatalf("marshal params: %v", err)
}
return b
}
func TestEvaluator_HasSubscription(t *testing.T) {
ev := NewRuleEvaluator()
tree := &EligibilityRule{
Type: RuleTypeHasSubscription,
Params: mustParams(t, map[string]int64{"min_days_remaining": 7}),
}
// 没订阅
ok, unmet, err := ev.Evaluate(context.Background(), tree, RuleContext{})
if err != nil {
t.Fatalf("eval err: %v", err)
}
if ok || len(unmet) != 1 || unmet[0].Rule != RuleTypeHasSubscription {
t.Fatalf("expected fail on no sub, got ok=%v unmet=%+v", ok, unmet)
}
// 有订阅但剩余不足
ok, unmet, err = ev.Evaluate(context.Background(), tree, RuleContext{
HasActiveSubscription: true,
SubscriptionExpiresIn: 3 * 86400,
})
if err != nil {
t.Fatalf("eval err: %v", err)
}
if ok {
t.Fatalf("expected fail on 3 days < 7 required, got pass")
}
if len(unmet) != 1 || unmet[0].Required != 7 || unmet[0].Current != 3 {
t.Fatalf("unmet mismatch: %+v", unmet)
}
// 有订阅剩余足够
ok, _, err = ev.Evaluate(context.Background(), tree, RuleContext{
HasActiveSubscription: true,
SubscriptionExpiresIn: 30 * 86400,
})
if err != nil || !ok {
t.Fatalf("expected pass on 30 days, got ok=%v err=%v", ok, err)
}
}
func TestEvaluator_InviteCount(t *testing.T) {
ev := NewRuleEvaluator()
tree := &EligibilityRule{
Type: RuleTypeInviteCount,
Params: mustParams(t, map[string]int64{"min": 3}),
}
ok, unmet, err := ev.Evaluate(context.Background(), tree, RuleContext{InviteCount: 1})
if err != nil || ok {
t.Fatalf("expected fail, got ok=%v err=%v", ok, err)
}
if unmet[0].Current != 1 || unmet[0].Required != 3 {
t.Fatalf("expected current=1 required=3, got %+v", unmet[0])
}
ok, _, _ = ev.Evaluate(context.Background(), tree, RuleContext{InviteCount: 3})
if !ok {
t.Fatalf("expected pass with InviteCount=3")
}
}
func TestEvaluator_ANDShort(t *testing.T) {
ev := NewRuleEvaluator()
tree := &EligibilityRule{
Op: OpAND,
Children: []*EligibilityRule{
{Type: RuleTypeHasSubscription},
{Type: RuleTypeInviteCount, Params: mustParams(t, map[string]int64{"min": 5})},
},
}
ok, unmet, _ := ev.Evaluate(context.Background(), tree, RuleContext{
HasActiveSubscription: true,
InviteCount: 1,
})
if ok {
t.Fatalf("expected AND to fail")
}
// AND 需要收集全部未通过项(这里只有 1 条)
if len(unmet) != 1 || unmet[0].Rule != RuleTypeInviteCount {
t.Fatalf("expected 1 unmet (invite_count), got %+v", unmet)
}
}
func TestEvaluator_ORPassIgnoresChildUnmet(t *testing.T) {
ev := NewRuleEvaluator()
tree := &EligibilityRule{
Op: OpOR,
Children: []*EligibilityRule{
{Type: RuleTypeInviteCount, Params: mustParams(t, map[string]int64{"min": 100})},
{Type: RuleTypeHasSubscription}, // 会通过
},
}
ok, unmet, _ := ev.Evaluate(context.Background(), tree, RuleContext{
HasActiveSubscription: true,
InviteCount: 0,
})
if !ok {
t.Fatalf("expected OR to pass because one child passes")
}
if len(unmet) != 0 {
t.Fatalf("OR pass should hide child failures, got %+v", unmet)
}
}
func TestEvaluator_ORFailBubblesAllChildren(t *testing.T) {
ev := NewRuleEvaluator()
tree := &EligibilityRule{
Op: OpOR,
Children: []*EligibilityRule{
{Type: RuleTypeInviteCount, Params: mustParams(t, map[string]int64{"min": 5})},
{Type: RuleTypeHasSubscription},
},
}
ok, unmet, _ := ev.Evaluate(context.Background(), tree, RuleContext{
InviteCount: 1,
})
if ok {
t.Fatalf("expected OR to fail")
}
if len(unmet) != 2 {
t.Fatalf("expected 2 unmet reasons on OR fail, got %+v", unmet)
}
}
func TestEvaluator_SubscriptionType(t *testing.T) {
ev := NewRuleEvaluator()
tree := &EligibilityRule{
Type: RuleTypeSubscriptionType,
Params: mustParams(t, map[string][]int64{"plan_ids": {10, 20}}),
}
ok, _, _ := ev.Evaluate(context.Background(), tree, RuleContext{
SubscriptionPlanIds: []int64{20},
})
if !ok {
t.Fatalf("expected pass when user plan matches")
}
ok, _, _ = ev.Evaluate(context.Background(), tree, RuleContext{
SubscriptionPlanIds: []int64{99},
})
if ok {
t.Fatalf("expected fail when user plan not in whitelist")
}
}
func TestEvaluator_UserTag(t *testing.T) {
ev := NewRuleEvaluator()
tree := &EligibilityRule{
Type: RuleTypeUserTag,
Params: mustParams(t, map[string][]string{"tags": {"vip", "beta"}}),
}
ok, _, _ := ev.Evaluate(context.Background(), tree, RuleContext{UserTags: []string{"beta"}})
if !ok {
t.Fatalf("expected pass when any tag matches")
}
ok, _, _ = ev.Evaluate(context.Background(), tree, RuleContext{UserTags: []string{"foo"}})
if ok {
t.Fatalf("expected fail when no tags match")
}
}
func TestEvaluator_NilTreeAlwaysPasses(t *testing.T) {
ev := NewRuleEvaluator()
ok, unmet, err := ev.Evaluate(context.Background(), nil, RuleContext{})
if err != nil || !ok || len(unmet) != 0 {
t.Fatalf("nil tree should always pass; got ok=%v unmet=%+v err=%v", ok, unmet, err)
}
}
func TestEvaluator_UnknownRuleType(t *testing.T) {
ev := NewRuleEvaluator()
tree := &EligibilityRule{Type: "no_such_rule"}
_, _, err := ev.Evaluate(context.Background(), tree, RuleContext{})
if err == nil {
t.Fatalf("expected error on unknown rule type")
}
}
+122
View File
@@ -0,0 +1,122 @@
// Package lottery 提供抽奖 Stage 1 后端核心闭环:门槛规则引擎、次数入账、
// 加权随机选奖、发奖 handler 抽象。
//
// Stage 1 只保证接口稳定 + 骨架编译通过;具体业务对接(订阅时长发放、佣金入账、
// 邀请钩子)留到架构师 review 骨架 PR 之后再补。
package lottery
import (
"context"
"errors"
"gorm.io/gorm"
)
// ---- 门槛规则引擎 ----------------------------------------------------------
// RuleContext 是评估门槛规则时可见的用户上下文。构造时应做一次批量
// 读,避免每条子规则各自查库。
type RuleContext struct {
UserId int64
Now int64 // Unix seconds
// 下列字段由 RuleContextBuilder 填充。规则实现只读,不写。
HasActiveSubscription bool
SubscriptionExpiresIn int64 // seconds until expiry; 0 if no active sub
SubscriptionPlanIds []int64
InviteCount int64 // 若规则限定 window_days,则调用方需自行按窗口预计算
TotalRechargeUSDT int64 // 已充值总额,单位与业务侧一致
RegisterDays int64
UserTags []string
}
// RuleEvaluator 评估一棵门槛规则树,返回是否通过 + 未通过项。
// 实现是纯计算,不做任何 DB 写。
type RuleEvaluator interface {
Evaluate(ctx context.Context, tree *EligibilityRule, rc RuleContext) (passed bool, unmet []UnmetReason, err error)
}
// ---- 加权随机选奖 ----------------------------------------------------------
// WeightedPicker 从奖池中按 weight 加权抽一次。实现应使用注入的 rand 源,
// 便于测试;weight 为 0 的奖品视作不参与随机(可用于挂出但不发放)。
type WeightedPicker interface {
// Pick 从 candidates 中返回一个索引 i;若累计权重为 0 返回 ErrEmptyPool。
// 调用方在事务外先做快照,事务内再对返回的 candidates[i] 做库存乐观扣减。
Pick(candidates []Prize) (int, error)
}
// ErrEmptyPool 表示奖池累计权重为 0,无法完成一次随机。
var ErrEmptyPool = errors.New("lottery: empty prize pool")
// ---- 次数入账 / 消耗 -------------------------------------------------------
// ChanceService 处理"用户抽奖次数账户",为触发源提供幂等入账、为抽奖流程提供
// 事务内原子扣减。
type ChanceService interface {
// Grant 记录一次次数入账;幂等键 = (activityId, source, sourceRef)。
// 已存在的 sourceRef 视为幂等命中,返回 nil 不重复发放。
Grant(ctx context.Context, userId, activityId int64, source, sourceRef string, amount int) error
// Consume 在事务内扣减一次次数(SELECT ... FOR UPDATE 锁 chance_balance),
// 返回扣减后剩余次数。剩余为 0 时返回 ErrNoChances。
Consume(ctx context.Context, tx *gorm.DB, userId, activityId int64) (remaining int64, err error)
// Query 返回用户当前剩余次数(非事务,用于 GET /config 展示)。
Query(ctx context.Context, userId, activityId int64) (remaining int64, err error)
}
// ErrNoChances 表示用户在该活动下已无剩余抽奖次数。
var ErrNoChances = errors.New("lottery: no chances remaining")
// ---- 发奖 Handler ---------------------------------------------------------
// DispatchRequest 是 PrizeHandler.Dispatch 的输入。事务由调用方开启并传入,
// handler 在同 tx 内完成外部账本写入 + 业务侧发放。
type DispatchRequest struct {
UserId int64
ActivityId int64
DrawId int64
Prize Prize // 当前奖品(含 Config JSON
Snapshot PrizeSnapshot // 抽奖时刻快照
}
// DispatchResult 是发奖结果。
type DispatchResult struct {
// State 会写入 lottery_draw.dispatch_state。
State string
// Message 供前端展示("已加到订阅"/"佣金到账 3 USDT" 等)。
Message string
}
// PrizeHandler 是一种奖品类型的发奖策略。Type 是注册键;IsAuto=true 表示
// 抽奖事务内立刻发放,false 表示挂 pending_claim 等人工发(Stage 1 只实现
// IsAuto=true 的三种)。
//
// 幂等:所有实现必须以 lottery_draw.id 为外部 ref 做 check-before-write
// 避免重试重复发放。见 doc/lottery-stage1-plan.md 的"发奖账本"章节。
type PrizeHandler interface {
Type() string
IsAuto() bool
// Dispatch 在调用方的事务内执行;返回结果或错误。
// 错误会导致抽奖事务回滚(次数不扣、draw 不落库),由用户侧重新发起。
Dispatch(ctx context.Context, tx *gorm.DB, req DispatchRequest) (DispatchResult, error)
// ValidateClaim 是人工领奖时校验用户输入(Stage 2 才用);Stage 1 的
// auto handler 直接返回 nil 即可。
ValidateClaim(raw []byte) error
}
// ErrNotImplemented 是 Stage 1 骨架里 handler 的占位错误:抽奖流程接入前
// 若不慎命中真实 handler 会立即失败,避免误发。
var ErrNotImplemented = errors.New("lottery: handler not yet wired to real business")
// Registry 是 type → PrizeHandler 的路由表。抽奖服务只依赖此接口,
// 具体 handler 由 initialize 阶段注入。
type Registry interface {
Get(prizeType string) (PrizeHandler, bool)
// MustGet 在类型未注册时返回 ErrHandlerNotRegistered。
MustGet(prizeType string) (PrizeHandler, error)
}
// ErrHandlerNotRegistered 表示奖品类型没有对应 handler。
var ErrHandlerNotRegistered = errors.New("lottery: prize handler not registered")
+66
View File
@@ -0,0 +1,66 @@
package lottery
import (
"math/rand"
"sync"
"time"
)
// weightedPicker 是 WeightedPicker 的默认实现。使用累计权重 O(log n) 二分选取。
// 注入的 rand.Source 允许测试固定种子。
type weightedPicker struct {
mu sync.Mutex
rng *rand.Rand
}
// NewWeightedPicker 返回默认加权选取器。传 seed=0 使用当前时间纳秒。
func NewWeightedPicker(seed int64) WeightedPicker {
if seed == 0 {
seed = time.Now().UnixNano()
}
return &weightedPicker{
rng: rand.New(rand.NewSource(seed)),
}
}
// Pick 从 candidates 中返回一个索引。权重为 0 的奖品不参与随机;累计权重
// 为 0(如所有奖品 weight 都是 0)返回 ErrEmptyPool。
//
// 算法:累计权重线性扫描一次,取 [0, total) 随机数落到哪个区间。稳定、
// 不需要预分配,并对小池(<20 项)足够快。
func (p *weightedPicker) Pick(candidates []Prize) (int, error) {
if len(candidates) == 0 {
return 0, ErrEmptyPool
}
var total int64
for _, c := range candidates {
if c.Weight > 0 {
total += int64(c.Weight)
}
}
if total == 0 {
return 0, ErrEmptyPool
}
p.mu.Lock()
roll := p.rng.Int63n(total)
p.mu.Unlock()
var cum int64
for i, c := range candidates {
if c.Weight <= 0 {
continue
}
cum += int64(c.Weight)
if roll < cum {
return i, nil
}
}
// 走到这里说明浮点/累加异常,回退到最后一个非 0 权重项。
for i := len(candidates) - 1; i >= 0; i-- {
if candidates[i].Weight > 0 {
return i, nil
}
}
return 0, ErrEmptyPool
}
@@ -0,0 +1,76 @@
package lottery
import (
"testing"
)
func TestWeightedPicker_EmptyPool(t *testing.T) {
p := NewWeightedPicker(42)
if _, err := p.Pick(nil); err != ErrEmptyPool {
t.Fatalf("expected ErrEmptyPool for nil pool, got %v", err)
}
if _, err := p.Pick([]Prize{{Weight: 0}, {Weight: 0}}); err != ErrEmptyPool {
t.Fatalf("expected ErrEmptyPool when all weights are 0, got %v", err)
}
}
func TestWeightedPicker_SkipsZeroWeight(t *testing.T) {
p := NewWeightedPicker(1)
pool := []Prize{
{Id: 1, Weight: 0}, // 不参与
{Id: 2, Weight: 100}, // 独占权重
{Id: 3, Weight: 0}, // 不参与
}
for i := 0; i < 200; i++ {
idx, err := p.Pick(pool)
if err != nil {
t.Fatalf("Pick err: %v", err)
}
if idx != 1 {
t.Fatalf("expected idx 1 (only positive weight), got %d", idx)
}
}
}
func TestWeightedPicker_DistributionCloseToWeights(t *testing.T) {
p := NewWeightedPicker(2026)
pool := []Prize{
{Id: 10, Weight: 10}, // 10/60 ≈ 16.67%
{Id: 20, Weight: 20}, // 20/60 ≈ 33.33%
{Id: 30, Weight: 30}, // 30/60 = 50%
}
const trials = 60000
counts := make(map[int]int)
for i := 0; i < trials; i++ {
idx, err := p.Pick(pool)
if err != nil {
t.Fatalf("Pick err: %v", err)
}
counts[idx]++
}
// 允许 ±2% 偏差
expect := []float64{10.0 / 60, 20.0 / 60, 30.0 / 60}
for i, e := range expect {
got := float64(counts[i]) / float64(trials)
if got < e-0.02 || got > e+0.02 {
t.Fatalf("prize %d: expected %.4f (±0.02), got %.4f", i, e, got)
}
}
}
func TestWeightedPicker_DeterministicWithFixedSeed(t *testing.T) {
pool := []Prize{
{Id: 1, Weight: 1},
{Id: 2, Weight: 1},
{Id: 3, Weight: 1},
}
a := NewWeightedPicker(7)
b := NewWeightedPicker(7)
for i := 0; i < 20; i++ {
ai, _ := a.Pick(pool)
bi, _ := b.Pick(pool)
if ai != bi {
t.Fatalf("iter %d: seed 7 diverged: a=%d b=%d", i, ai, bi)
}
}
}