9933d34bdd
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 + 集成/并发/概率测试。
117 lines
3.7 KiB
Go
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
|
|
}
|