新功能(#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:
@@ -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"`
|
||||
}
|
||||
Reference in New Issue
Block a user