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