117dc0d6a7
Closes HIF-3 (Stage 1 P0 from QA smoke - third and final of the same class)
F6 (P0): GrantLedger.Payload=\"\" → MySQL error 3140 (Invalid JSON text: The document is empty)
- 修法:方式 A 对称守卫 Payload=\"{}\" (与 PR E UnmetReasons=\"[]\", PR C PrizeSnapshot.Config=\"{}\" 三处守卫模式统一)
- QA 已 sweep 全部 6 个 lottery JSON 列,这是最后一处漏守
- handler 成功 dispatch 后会 UpdateColumn 覆盖真实 payload;Reserve 阶段用 {} 兜底
回归护栏:TestReserve_EmptyPayloadDefaultsToEmptyJSONObject 用 payloadNotEmptyString per-arg matcher
CI 全绿;2 files, +83/-0
架构师复盘:三次同一 pattern 漏检查(F2 audit ctx keys / F4 UnmetReasons / F6 Payload)。Stage 2 起硬性规则:grep -RIn 'type:json' internal/model/ 全量清单逐列 sweep + 每列至少一条 per-arg matcher 单测。感谢 QA 三次挖坑。
79 lines
3.3 KiB
Go
79 lines
3.3 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")
|
||
}
|
||
// 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
|
||
}
|