Files
hi-server/internal/model/lottery/grant_ledger.go
T
shanshanzhong147 a46fb83054 新功能(#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 返回空。
2026-07-08 21:19:28 -07:00

72 lines
3.0 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 (
"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
}