新功能(#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
+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"`
}