新功能(#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 + 集成/并发/概率测试。
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user