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") } // Payload 是 JSON 列,MySQL 拒绝空字符串(error 3140)—— // handler 在成功 dispatch 后会 UpdateColumn 覆盖真实 payload;Reserve 阶 // 段的空 payload 用 "{}" 兜底,与 PrizeSnapshot.Config、 // EligibilitySnapshot.UnmetReasons 的守卫对称。 if entry.Payload == "" { entry.Payload = "{}" } 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 }