新功能(#3): 抽奖 Stage 1 handler 真实业务对接 + 邀请钩子
Closes HIF-3 (阶段 PR B) PR B:handler 真实业务对接 + 邀请钩子(迭代含 R1 修复) - 迁移 02157_lottery_grant_ledger:external_ref UNIQUE 作为发奖幂等键 - log.CommissionTypeLottery=339(架构师批准的新常量) - DispatchRequest.IdempotencyKey(架构师 review 建议第 2 条) - GrantLedger + LedgerService.Reserve:INSERT ON CONFLICT DO NOTHING 幂等 upsert - VPNDurationHandler:ResolveEffectiveUser 归位家庭 owner + UpdateSubscribe,ExpireTime 三分支对齐 grantGiftDays - CommissionHandler:UpdateCommission + WriteCommissionLog(339),发给中奖者本人,不做家庭组归位 - Handler 从 model 层迁到 logic 层(避免 model → logic 反向依赖);noop 保留在 model 层 - InviteHook:fire-and-forget 独立 goroutine + 10s timeout,扫 running 活动的 invite_success 源 - ServiceContext 新增 LotteryChance / LotteryLedger / LotteryInviteHook - activateOrderLogic.handleCommission 两条分支通过 invokeInviteHookIfEligible 助手触发,助手内统一 gate IsNew(架构师 R1 打回后的修复) 架构师 R1 打回:branch B 未按"首次付款激活"gate,续费也会给 referer 发抽奖机会 → 已通过助手函数集中收拢,避免 branch A/B 判断漂移。 测试覆盖率:model 76.5% / handler 73.2% / hook 91.7%;补 4 个回归测试覆盖 IsNew 门槛(含关键的 DoesNotFireOnRenewal)。 线上仍零可见变更:无对外路由,钩子仅在活动 status=running 时生效,Stage 1 全流程无活动记录时 loadRunningActivities 返回空。
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
package lottery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// GrantLedger 是发奖账本一行。UNIQUE(external_ref) 是幂等键的载体:
|
||||
// 每次 PrizeHandler.Dispatch 用 DispatchRequest.IdempotencyKey 作 external_ref,
|
||||
// INSERT 冲突即"已发过",直接返回持久化的原结果。
|
||||
type GrantLedger struct {
|
||||
Id int64 `gorm:"primaryKey"`
|
||||
ExternalRef string `gorm:"type:varchar(128);not null;uniqueIndex:uk_external_ref;comment:幂等键"`
|
||||
HandlerType string `gorm:"type:varchar(32);not null;comment:handler 类型"`
|
||||
UserId int64 `gorm:"type:bigint unsigned;not null;comment:发放对象用户 ID"`
|
||||
ActivityId int64 `gorm:"type:bigint unsigned;not null;comment:活动 ID"`
|
||||
DrawId int64 `gorm:"type:bigint unsigned;not null;comment:抽奖记录 ID"`
|
||||
Amount int64 `gorm:"type:bigint;not null;default:0;comment:发放数量"`
|
||||
Payload string `gorm:"type:json;comment:发放后的关键结果快照"`
|
||||
GrantedAt time.Time `gorm:"<-:create;default:CURRENT_TIMESTAMP;comment:发放完成时间"`
|
||||
}
|
||||
|
||||
// TableName 对齐 02157 migration。
|
||||
func (GrantLedger) TableName() string { return "lottery_grant_ledger" }
|
||||
|
||||
// LedgerService 处理发奖账本的幂等 upsert。所有 handler 的第一步都是它。
|
||||
type LedgerService interface {
|
||||
// Reserve 尝试为 external_ref 抢占一行账本。
|
||||
// - 未冲突 → 返回新建行,caller 继续调用下游业务;提交事务时账本一起落。
|
||||
// - 冲突 → 返回已存在的账本行,caller 视为幂等命中直接返回。
|
||||
// 传入 tx 必须是 caller 的事务句柄,保证账本行随抽奖事务一起提交。
|
||||
Reserve(ctx context.Context, tx *gorm.DB, entry GrantLedger) (row *GrantLedger, alreadyExisted bool, err error)
|
||||
}
|
||||
|
||||
type ledgerService struct{}
|
||||
|
||||
// NewLedgerService 返回默认账本服务。
|
||||
func NewLedgerService() LedgerService { return &ledgerService{} }
|
||||
|
||||
// Reserve 用 INSERT ... ON CONFLICT DO NOTHING 抢占 external_ref。
|
||||
// 未命中时再走一次 SELECT 拿到实际持久化的行(不管是新插的还是旧的),
|
||||
// 目的是让 caller 拿到统一的 GrantLedger 结构,方便回写 draw 状态。
|
||||
func (s *ledgerService) Reserve(ctx context.Context, tx *gorm.DB, entry GrantLedger) (*GrantLedger, bool, error) {
|
||||
if tx == nil {
|
||||
return nil, false, errors.New("Reserve requires a transaction handle")
|
||||
}
|
||||
if entry.ExternalRef == "" {
|
||||
return nil, false, errors.New("Reserve requires a non-empty ExternalRef")
|
||||
}
|
||||
|
||||
insertRes := tx.WithContext(ctx).
|
||||
Clauses(clause.OnConflict{DoNothing: true}).
|
||||
Create(&entry)
|
||||
if insertRes.Error != nil {
|
||||
return nil, false, insertRes.Error
|
||||
}
|
||||
alreadyExisted := insertRes.RowsAffected == 0
|
||||
|
||||
// 读回持久化的行,避免依赖 gorm 的 AutoIncrement 回填在冲突分支不确定的行为。
|
||||
var stored GrantLedger
|
||||
if err := tx.WithContext(ctx).
|
||||
Where("external_ref = ?", entry.ExternalRef).
|
||||
First(&stored).Error; err != nil {
|
||||
return nil, alreadyExisted, err
|
||||
}
|
||||
return &stored, alreadyExisted, nil
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package lottery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestLedgerService_Reserve_FirstInsertNotExisted(t *testing.T) {
|
||||
db, mock, cleanup := newLotteryTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
svc := NewLedgerService()
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("INSERT INTO `lottery_grant_ledger`").
|
||||
WillReturnResult(sqlmock.NewResult(7, 1))
|
||||
mock.ExpectQuery("FROM `lottery_grant_ledger`").
|
||||
WithArgs("lottery:100:200", 1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "external_ref", "handler_type", "user_id", "activity_id", "draw_id", "amount"}).
|
||||
AddRow(int64(7), "lottery:100:200", "vpn_duration", int64(42), int64(100), int64(200), int64(3)))
|
||||
mock.ExpectCommit()
|
||||
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
row, existed, e := svc.Reserve(context.Background(), tx, GrantLedger{
|
||||
ExternalRef: "lottery:100:200",
|
||||
HandlerType: "vpn_duration",
|
||||
UserId: 42,
|
||||
ActivityId: 100,
|
||||
DrawId: 200,
|
||||
Amount: 3,
|
||||
})
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if existed {
|
||||
t.Fatalf("expected not existed")
|
||||
}
|
||||
if row.Id != 7 {
|
||||
t.Fatalf("expected reloaded id=7, got %d", row.Id)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("tx: %v", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLedgerService_Reserve_DuplicateExisted(t *testing.T) {
|
||||
db, mock, cleanup := newLotteryTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
svc := NewLedgerService()
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("INSERT INTO `lottery_grant_ledger`").
|
||||
WillReturnResult(sqlmock.NewResult(0, 0)) // conflict, 0 rows affected
|
||||
mock.ExpectQuery("FROM `lottery_grant_ledger`").
|
||||
WithArgs("lottery:100:200", 1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "external_ref", "handler_type", "user_id", "activity_id", "draw_id", "amount"}).
|
||||
AddRow(int64(9), "lottery:100:200", "vpn_duration", int64(42), int64(100), int64(200), int64(3)))
|
||||
mock.ExpectCommit()
|
||||
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
row, existed, e := svc.Reserve(context.Background(), tx, GrantLedger{
|
||||
ExternalRef: "lottery:100:200",
|
||||
HandlerType: "vpn_duration",
|
||||
})
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if !existed {
|
||||
t.Fatalf("expected existed=true when INSERT returns 0 rows affected")
|
||||
}
|
||||
if row.Id != 9 {
|
||||
t.Fatalf("expected stored id=9, got %d", row.Id)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("tx: %v", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLedgerService_Reserve_RequiresTx(t *testing.T) {
|
||||
svc := NewLedgerService()
|
||||
if _, _, err := svc.Reserve(context.Background(), nil, GrantLedger{ExternalRef: "x"}); err == nil {
|
||||
t.Fatalf("expected error when tx is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLedgerService_Reserve_RequiresExternalRef(t *testing.T) {
|
||||
db, _, cleanup := newLotteryTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
svc := NewLedgerService()
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
_, _, e := svc.Reserve(context.Background(), tx, GrantLedger{})
|
||||
return e
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error on empty ExternalRef")
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ type defaultRegistry struct {
|
||||
}
|
||||
|
||||
// NewRegistry 返回空的默认注册表。使用 Register 挂接实现。
|
||||
// 实际业务 handler(vpn_duration / commission)在 internal/logic/lottery/handler 里,
|
||||
// 由 initialize 阶段注入(避免 model 层反向依赖 logic 层)。
|
||||
func NewRegistry() *defaultRegistry {
|
||||
return &defaultRegistry{handlers: make(map[string]PrizeHandler, 8)}
|
||||
}
|
||||
@@ -39,11 +41,7 @@ func (r *defaultRegistry) MustGet(prizeType string) (PrizeHandler, error) {
|
||||
return nil, ErrHandlerNotRegistered
|
||||
}
|
||||
|
||||
// ---- Stage 1 骨架 handler --------------------------------------------------
|
||||
// 真实业务对接在架构师 review 骨架 PR 之后 Day 2-3 补齐。此时命中真实
|
||||
// handler 会返回 ErrNotImplemented,让抽奖流程整笔事务回滚,避免误发。
|
||||
|
||||
// noopHandler 是"谢谢参与"。写记录、不发放,不需要真实业务对接。
|
||||
// noopHandler 是"谢谢参与",永远归 model 层:不依赖任何业务,最小占位。
|
||||
type noopHandler struct{}
|
||||
|
||||
// NewNoopHandler 返回 PrizeTypeNone 的 handler。
|
||||
@@ -55,40 +53,3 @@ func (noopHandler) Dispatch(_ context.Context, _ *gorm.DB, _ DispatchRequest) (D
|
||||
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 }
|
||||
|
||||
@@ -21,17 +21,13 @@ func TestRegistry_GetMissing(t *testing.T) {
|
||||
func TestRegistry_RegisterAndGet(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
r.Register(NewNoopHandler())
|
||||
r.Register(NewVPNDurationStubHandler())
|
||||
r.Register(NewCommissionStubHandler())
|
||||
|
||||
for _, want := range []string{PrizeTypeNone, PrizeTypeVPNDuration, PrizeTypeCommission} {
|
||||
h, err := r.MustGet(want)
|
||||
if err != nil {
|
||||
t.Fatalf("MustGet(%q): %v", want, err)
|
||||
}
|
||||
if h.Type() != want {
|
||||
t.Fatalf("Type mismatch: want %q got %q", want, h.Type())
|
||||
}
|
||||
h, err := r.MustGet(PrizeTypeNone)
|
||||
if err != nil {
|
||||
t.Fatalf("MustGet(%q): %v", PrizeTypeNone, err)
|
||||
}
|
||||
if h.Type() != PrizeTypeNone {
|
||||
t.Fatalf("Type mismatch: want %q got %q", PrizeTypeNone, h.Type())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,12 +44,3 @@ func TestNoopHandler_AlwaysAutoClaimed(t *testing.T) {
|
||||
t.Fatalf("noop should dispatch as auto_claimed, got %q", res.State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStubHandlers_ReturnNotImplemented(t *testing.T) {
|
||||
for _, h := range []PrizeHandler{NewVPNDurationStubHandler(), NewCommissionStubHandler()} {
|
||||
_, err := h.Dispatch(context.Background(), (*gorm.DB)(nil), DispatchRequest{})
|
||||
if !errors.Is(err, ErrNotImplemented) {
|
||||
t.Fatalf("%s: expected ErrNotImplemented, got %v", h.Type(), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,10 @@ type DispatchRequest struct {
|
||||
DrawId int64
|
||||
Prize Prize // 当前奖品(含 Config JSON)
|
||||
Snapshot PrizeSnapshot // 抽奖时刻快照
|
||||
// IdempotencyKey 是 lottery_grant_ledger.external_ref 的最终值。
|
||||
// 调用方(抽奖服务)负责生成,惯例 = fmt.Sprintf("lottery:%d:%d", ActivityId, DrawId)。
|
||||
// 每次 Dispatch 用同一 IdempotencyKey 重试 → handler 命中 ledger UNIQUE 返回原结果。
|
||||
IdempotencyKey string
|
||||
}
|
||||
|
||||
// DispatchResult 是发奖结果。
|
||||
|
||||
Reference in New Issue
Block a user