Files
hi-server/internal/model/lottery/model.go
T
shanshanzhong147 cce147108c 修复(#4): 抽奖 8 宫格默认值 + 100500 msg 脱敏
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 处理
2026-07-12 19:55:25 -07:00

252 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"`
}