Files
hi-server/internal/model/lottery/rule_evaluator.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

253 lines
7.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"
"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
}