Files
hi-server/internal/model/lottery/chance_service.go
T
shanshanzhong147 9933d34bdd 新功能(#3): 抽奖活动 Stage 1 骨架(DB + 规则引擎 + Handler 抽象)
Closes HIF-3

Stage 1 骨架:7 张表迁移 + 门槛规则引擎 + 加权选奖 + 次数入账/消耗(幂等)+ 发奖 handler 抽象与注册表。

- 单测覆盖 75.5%(未覆盖行 = stub handler ErrNotImplemented,合理)
- CI 全绿(构建/Vet/测试 + golangci-lint)
- 骨架不接入真实业务,vpn_duration/commission handler 在 Dispatch 中返回 ErrNotImplemented;合并后线上零变更

架构师 review 通过,4 项决策已在 issue 上给出:
1. 邀请转化语义 = 首次付款激活
2. 佣金日志类型 = 新增 CommissionTypeLottery=339
3. 家庭组归属 = 穿透到 owner
4. 管理端 IP 白名单 = 不做(推到 nginx/ingress 层)

后续 PR B/C 补真实业务对接 + 用户 API + 后台 CRUD + 集成/并发/概率测试。
2026-07-08 20:05:05 -07:00

117 lines
3.7 KiB
Go

package lottery
import (
"context"
"errors"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// chanceService 是 ChanceService 的默认实现。
type chanceService struct {
db *gorm.DB
}
// NewChanceService 用注入的 *gorm.DB 构造一个 ChanceService。
func NewChanceService(db *gorm.DB) ChanceService { return &chanceService{db: db} }
// Grant 记录一次次数入账,幂等键 = (activity_id, source, source_ref)。
// 幂等策略:
// 1. INSERT lottery_chance_grant,靠 UNIQUE(activity_id, source, source_ref) 触发冲突
// 2. 冲突视为"已发过",直接返回 nil 不重复发放
// 3. 未冲突 → UPSERT lottery_chance_balance 累加 remaining
//
// 关键正确性:两步必须在同一事务内。这样第 (1) 成功即证明是首次入账,
// 才走第 (2);第 (1) 冲突则直接跳过 (2),balance 不会双加。
func (s *chanceService) Grant(ctx context.Context, userId, activityId int64, source, sourceRef string, amount int) error {
if amount <= 0 {
return nil
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
grant := ChanceGrant{
UserId: userId,
ActivityId: activityId,
Source: source,
SourceRef: sourceRef,
Amount: amount,
}
// OnConflict DoNothing 依赖 UNIQUE(activity_id, source, source_ref)。
res := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&grant)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
// 幂等命中:已经发过,balance 不动。
return nil
}
// 未冲突 → 累加余额(upsert balance 行)。
balance := ChanceBalance{
UserId: userId,
ActivityId: activityId,
Remaining: int64(amount),
TotalEarned: int64(amount),
}
return tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}, {Name: "activity_id"}},
DoUpdates: clause.Assignments(map[string]interface{}{
"remaining": gorm.Expr("`lottery_chance_balance`.`remaining` + ?", amount),
"total_earned": gorm.Expr("`lottery_chance_balance`.`total_earned` + ?", amount),
}),
}).Create(&balance).Error
})
}
// Consume 在事务内以 SELECT ... FOR UPDATE 锁住 chance_balance 行后 -1。
// 剩余为 0 时返回 ErrNoChances,调用方直接回滚事务,不写 draw。
func (s *chanceService) Consume(ctx context.Context, tx *gorm.DB, userId, activityId int64) (int64, error) {
if tx == nil {
return 0, errors.New("Consume requires a transaction handle")
}
var balance ChanceBalance
err := tx.WithContext(ctx).
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("user_id = ? AND activity_id = ?", userId, activityId).
First(&balance).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, ErrNoChances
}
return 0, err
}
if balance.Remaining <= 0 {
return 0, ErrNoChances
}
// 累加 spent,扣减 remaining,一条 SQL 完成。
updateErr := tx.WithContext(ctx).
Model(&ChanceBalance{}).
Where("id = ? AND remaining > 0", balance.Id).
Updates(map[string]interface{}{
"remaining": gorm.Expr("`remaining` - 1"),
"total_spent": gorm.Expr("`total_spent` + 1"),
}).Error
if updateErr != nil {
return 0, updateErr
}
return balance.Remaining - 1, nil
}
// Query 只读,返回用户在活动下的剩余次数。未初始化过 balance 行时返回 0。
func (s *chanceService) Query(ctx context.Context, userId, activityId int64) (int64, error) {
var balance ChanceBalance
err := s.db.WithContext(ctx).
Where("user_id = ? AND activity_id = ?", userId, activityId).
First(&balance).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, nil
}
return 0, err
}
if balance.Remaining < 0 {
return 0, nil
}
return balance.Remaining, nil
}