新功能(#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,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, ¶ms); 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, ¶ms); 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, ¶ms); 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, ¶ms); 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, ¶ms); 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, ¶ms); 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
|
||||
}
|
||||
Reference in New Issue
Block a user