cce147108c
Closes part of HIF-4 (Stage 2 前端集成反馈 F8 + F10) F8 (P1): Activity.GridSize 默认值 9 → 8 - migration 02160 ALTER DEFAULT 8(幂等) - 02156 up.sql 注释同步更新 - admin/lottery.go 兜底值 9 → 8 - 布局 A:3x3 挖中心,中心是抽奖按钮不是奖品 F10 (P1): wrapInternal msg 脱敏 - 内部错误 err.Error() 只写日志,不外传 - 用户端 msg 只带通用文案 (xerr.MapErrMsg lookup) - 回归护栏:TestWrapInternal_ScrubsErrorDetailsFromMsg + TestWrapInternal_PreservesCodeErrors CI 全绿;无 API 契约变更;F9 独立在 PR #45 处理
252 lines
12 KiB
Go
252 lines
12 KiB
Go
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 已实装:vpn_duration / commission / none。
|
||
// Stage 2 新增人工奖:crypto / physical / manual_other。
|
||
PrizeTypeVPNDuration = "vpn_duration"
|
||
PrizeTypeCommission = "commission"
|
||
PrizeTypeNone = "none"
|
||
// Stage 2 人工奖类型(HIF-4)。
|
||
PrizeTypeCrypto = "crypto"
|
||
PrizeTypePhysical = "physical"
|
||
PrizeTypeManualOther = "manual_other"
|
||
// Stage 3 预留。
|
||
PrizeTypeBalance = "balance"
|
||
PrizeTypeGiftAmount = "gift_amount"
|
||
PrizeTypeCoupon = "coupon"
|
||
PrizeTypePoints = "points"
|
||
|
||
// 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"
|
||
|
||
// ClaimStatus* 是 lottery_claim.status 的取值(Stage 2)。
|
||
// pending_claim: 已入库,等用户填领奖信息。
|
||
// reviewing: 用户已提交,等运营审核。
|
||
// paying: 运营 approve,等运营线下打款/发货 + mark-paid。
|
||
// paid: 运营已录入 tx_hash / delivery_ref。终态。
|
||
// rejected: 运营 reject(可为 reviewing → rejected 或 paying → rejected);用户可再次提交。
|
||
// expired: pending_claim 超时未提交(业务规则:过期不补次数)。终态。
|
||
ClaimStatusPendingClaim = "pending_claim"
|
||
ClaimStatusReviewing = "reviewing"
|
||
ClaimStatusPaying = "paying"
|
||
ClaimStatusPaid = "paid"
|
||
ClaimStatusRejected = "rejected"
|
||
ClaimStatusExpired = "expired"
|
||
|
||
// DefaultClaimTTLHours 是 Stage 2 spec 里"默认 7 天"的实际编码:
|
||
// 每个奖品可通过 config.claim_ttl_hours 覆盖单个奖品的过期窗口。
|
||
DefaultClaimTTLHours = 24 * 7
|
||
)
|
||
|
||
// ---- 实体 -----------------------------------------------------------------
|
||
|
||
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:8;comment:九宫格数量(HIF-4 F8:布局 A 3×3 挖中心 → 8 个奖品格)"`
|
||
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" }
|
||
|
||
// Claim 是 Stage 2 的人工奖领奖工单。lottery_draw ↔ lottery_claim 一对一
|
||
// (由 lottery_claim.draw_id UNIQUE 保证)。
|
||
//
|
||
// 生命周期:抽奖事务命中人工类奖品 → 同事务插入一行 status=pending_claim;
|
||
// 用户 POST /claim → 转 reviewing;运营 approve → paying → mark-paid → paid。
|
||
// 详细状态机见 02159_lottery_claim.up.sql 的注释。
|
||
type Claim 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"`
|
||
PrizeType string `gorm:"type:varchar(32);not null;comment:奖品类型(冗余便于后台过滤)"`
|
||
ClaimData string `gorm:"type:json;comment:用户提交的领奖表单(结构随 prize_type 变化)"`
|
||
Status string `gorm:"type:varchar(32);not null;default:'pending_claim';comment:状态"`
|
||
SubmittedAt *time.Time `gorm:"default:null;comment:用户提交领奖信息时间"`
|
||
ExpiresAt time.Time `gorm:"not null;comment:领奖窗口截止时间"`
|
||
ReviewedBy int64 `gorm:"type:bigint unsigned;default:0;comment:最近一次审核操作者"`
|
||
ReviewedAt *time.Time `gorm:"default:null;comment:最近一次审核时间"`
|
||
RejectReason string `gorm:"type:varchar(512);not null;default:'';comment:拒绝原因"`
|
||
TxHash string `gorm:"type:varchar(128);not null;default:'';comment:链上交易哈希"`
|
||
DeliveryRef string `gorm:"type:varchar(128);not null;default:'';comment:快递单号 / 发货单据编号"`
|
||
PaidAt *time.Time `gorm:"default:null;comment:运营标记打款/发货完成时间"`
|
||
CreatedAt time.Time `gorm:"<-:create;comment:Create Time"`
|
||
UpdatedAt time.Time `gorm:"comment:Update Time"`
|
||
}
|
||
|
||
// TableName 对齐 02159 migration。
|
||
func (Claim) TableName() string { return "lottery_claim" }
|
||
|
||
// IsClaimStatusResubmittable 判断当前 claim 状态是否允许用户再次提交领奖数据。
|
||
// pending_claim(还没提交过)与 rejected(被运营拒绝后允许重填)算入。
|
||
// 其他状态(reviewing / paying / paid / expired)都禁止再提交。
|
||
func IsClaimStatusResubmittable(status string) bool {
|
||
return status == ClaimStatusPendingClaim || status == ClaimStatusRejected
|
||
}
|
||
|
||
// IsPrizeTypeManualClaim 判断某奖品类型是否属于"人工领奖"类别,
|
||
// 即 draw 时需要挂 lottery_claim 而不是自动发放。
|
||
func IsPrizeTypeManualClaim(prizeType string) bool {
|
||
switch prizeType {
|
||
case PrizeTypeCrypto, PrizeTypePhysical, PrizeTypeManualOther:
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// ---- 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"`
|
||
}
|