Files
hi-server/internal/model/lottery/grant_ledger.go
T
shanshanzhong147 117dc0d6a7 修复(#3): 抽奖 GrantLedger.Payload 空字符串违反 MySQL JSON 校验
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 三次挖坑。
2026-07-09 09:27:14 -07:00

79 lines
3.3 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")
}
// Payload 是 JSON 列,MySQL 拒绝空字符串(error 3140)——
// handler 在成功 dispatch 后会 UpdateColumn 覆盖真实 payloadReserve 阶
// 段的空 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
}