新功能(#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:
2026-07-08 20:05:05 -07:00
committed by GitHub
parent d2710d356f
commit 9933d34bdd
12 changed files with 1495 additions and 0 deletions
+252
View File
@@ -0,0 +1,252 @@
package lottery
import (
"context"
"encoding/json"
"fmt"
"strings"
)
// 门槛规则类型常量。前端展示时可依据这些 key 组装本地化提示。
const (
RuleTypeHasSubscription = "has_subscription" // 需要有活跃订阅(可带 min_days_remaining
RuleTypeSubscriptionType = "subscription_type" // 订阅套餐必须在 plan_ids 中
RuleTypeInviteCount = "invite_count" // 邀请人数 ≥ min(可带 window_days,由 ContextBuilder 预算)
RuleTypeTotalRecharge = "total_recharge" // 累计充值 ≥ min_usdt
RuleTypeRegisterDays = "register_days" // 注册天数 ≥ min
RuleTypeUserTag = "user_tag" // 用户标签命中 tags[] 中任一
)
const (
OpAND = "AND"
OpOR = "OR"
)
// defaultEvaluator 是 RuleEvaluator 的开箱实现。
type defaultEvaluator struct{}
// NewRuleEvaluator 返回默认门槛评估器。
func NewRuleEvaluator() RuleEvaluator { return &defaultEvaluator{} }
func (e *defaultEvaluator) Evaluate(_ context.Context, tree *EligibilityRule, rc RuleContext) (bool, []UnmetReason, error) {
if tree == nil {
return true, nil, nil
}
unmet := make([]UnmetReason, 0, 4)
passed, err := e.evalNode(tree, rc, &unmet)
if err != nil {
return false, nil, err
}
return passed, unmet, nil
}
func (e *defaultEvaluator) evalNode(node *EligibilityRule, rc RuleContext, unmet *[]UnmetReason) (bool, error) {
if node == nil {
return true, nil
}
// 聚合节点
if node.Op != "" {
return e.evalGroup(node, rc, unmet)
}
// 叶子节点
if node.Type == "" {
return true, nil
}
return e.evalLeaf(node, rc, unmet)
}
func (e *defaultEvaluator) evalGroup(node *EligibilityRule, rc RuleContext, unmet *[]UnmetReason) (bool, error) {
op := strings.ToUpper(node.Op)
if len(node.Children) == 0 {
return true, nil
}
switch op {
case OpAND:
allPassed := true
for _, child := range node.Children {
ok, err := e.evalNode(child, rc, unmet)
if err != nil {
return false, err
}
if !ok {
allPassed = false
}
}
return allPassed, nil
case OpOR:
// OR 只收集内部未通过项到一个临时篮子;若整体通过则不冒泡出去。
anyPassed := false
local := make([]UnmetReason, 0, len(node.Children))
for _, child := range node.Children {
ok, err := e.evalNode(child, rc, &local)
if err != nil {
return false, err
}
if ok {
anyPassed = true
}
}
if !anyPassed {
*unmet = append(*unmet, local...)
}
return anyPassed, nil
default:
return false, fmt.Errorf("unknown group op %q", node.Op)
}
}
func (e *defaultEvaluator) evalLeaf(node *EligibilityRule, rc RuleContext, unmet *[]UnmetReason) (bool, error) {
switch node.Type {
case RuleTypeHasSubscription:
return e.evalHasSubscription(node, rc, unmet)
case RuleTypeSubscriptionType:
return e.evalSubscriptionType(node, rc, unmet)
case RuleTypeInviteCount:
return e.evalInviteCount(node, rc, unmet)
case RuleTypeTotalRecharge:
return e.evalTotalRecharge(node, rc, unmet)
case RuleTypeRegisterDays:
return e.evalRegisterDays(node, rc, unmet)
case RuleTypeUserTag:
return e.evalUserTag(node, rc, unmet)
default:
return false, fmt.Errorf("unknown leaf rule type %q", node.Type)
}
}
// ---- 单条规则 -------------------------------------------------------------
func (e *defaultEvaluator) evalHasSubscription(node *EligibilityRule, rc RuleContext, unmet *[]UnmetReason) (bool, error) {
var params struct {
MinDaysRemaining int64 `json:"min_days_remaining"`
}
if len(node.Params) > 0 {
if err := json.Unmarshal(node.Params, &params); err != nil {
return false, fmt.Errorf("has_subscription params: %w", err)
}
}
if !rc.HasActiveSubscription {
*unmet = append(*unmet, UnmetReason{
Rule: RuleTypeHasSubscription,
Hint: "需要有活跃订阅",
})
return false, nil
}
if params.MinDaysRemaining > 0 {
got := rc.SubscriptionExpiresIn / 86400
if got < params.MinDaysRemaining {
*unmet = append(*unmet, UnmetReason{
Rule: RuleTypeHasSubscription,
Hint: fmt.Sprintf("订阅剩余天数不足,还差 %d 天", params.MinDaysRemaining-got),
Current: got,
Required: params.MinDaysRemaining,
})
return false, nil
}
}
return true, nil
}
func (e *defaultEvaluator) evalSubscriptionType(node *EligibilityRule, rc RuleContext, unmet *[]UnmetReason) (bool, error) {
var params struct {
PlanIds []int64 `json:"plan_ids"`
}
if err := json.Unmarshal(node.Params, &params); err != nil {
return false, fmt.Errorf("subscription_type params: %w", err)
}
want := make(map[int64]struct{}, len(params.PlanIds))
for _, id := range params.PlanIds {
want[id] = struct{}{}
}
for _, id := range rc.SubscriptionPlanIds {
if _, ok := want[id]; ok {
return true, nil
}
}
*unmet = append(*unmet, UnmetReason{
Rule: RuleTypeSubscriptionType,
Hint: "订阅类型不符合活动要求",
})
return false, nil
}
func (e *defaultEvaluator) evalInviteCount(node *EligibilityRule, rc RuleContext, unmet *[]UnmetReason) (bool, error) {
var params struct {
Min int64 `json:"min"`
WindowDays int64 `json:"window_days,omitempty"`
}
if err := json.Unmarshal(node.Params, &params); err != nil {
return false, fmt.Errorf("invite_count params: %w", err)
}
if rc.InviteCount >= params.Min {
return true, nil
}
*unmet = append(*unmet, UnmetReason{
Rule: RuleTypeInviteCount,
Hint: fmt.Sprintf("还需邀请 %d 人(%d/%d", params.Min-rc.InviteCount, rc.InviteCount, params.Min),
Current: rc.InviteCount,
Required: params.Min,
})
return false, nil
}
func (e *defaultEvaluator) evalTotalRecharge(node *EligibilityRule, rc RuleContext, unmet *[]UnmetReason) (bool, error) {
var params struct {
MinUSDT int64 `json:"min_usdt"`
}
if err := json.Unmarshal(node.Params, &params); err != nil {
return false, fmt.Errorf("total_recharge params: %w", err)
}
if rc.TotalRechargeUSDT >= params.MinUSDT {
return true, nil
}
*unmet = append(*unmet, UnmetReason{
Rule: RuleTypeTotalRecharge,
Hint: fmt.Sprintf("累计充值不足,还差 %d USDT", params.MinUSDT-rc.TotalRechargeUSDT),
Current: rc.TotalRechargeUSDT,
Required: params.MinUSDT,
})
return false, nil
}
func (e *defaultEvaluator) evalRegisterDays(node *EligibilityRule, rc RuleContext, unmet *[]UnmetReason) (bool, error) {
var params struct {
Min int64 `json:"min"`
}
if err := json.Unmarshal(node.Params, &params); err != nil {
return false, fmt.Errorf("register_days params: %w", err)
}
if rc.RegisterDays >= params.Min {
return true, nil
}
*unmet = append(*unmet, UnmetReason{
Rule: RuleTypeRegisterDays,
Hint: fmt.Sprintf("注册天数不足,还差 %d 天", params.Min-rc.RegisterDays),
Current: rc.RegisterDays,
Required: params.Min,
})
return false, nil
}
func (e *defaultEvaluator) evalUserTag(node *EligibilityRule, rc RuleContext, unmet *[]UnmetReason) (bool, error) {
var params struct {
Tags []string `json:"tags"`
}
if err := json.Unmarshal(node.Params, &params); err != nil {
return false, fmt.Errorf("user_tag params: %w", err)
}
want := make(map[string]struct{}, len(params.Tags))
for _, t := range params.Tags {
want[t] = struct{}{}
}
for _, t := range rc.UserTags {
if _, ok := want[t]; ok {
return true, nil
}
}
*unmet = append(*unmet, UnmetReason{
Rule: RuleTypeUserTag,
Hint: "用户标签不符合活动要求",
})
return false, nil
}