a46fb83054
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 返回空。
72 lines
3.0 KiB
Go
72 lines
3.0 KiB
Go
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
|
||
}
|