package rulecaps import ( "encoding/json" "errors" "fmt" "github.com/perfect-panel/server/internal/model/lottery" ) // 抽奖门槛规则树的固定上限。恶意 admin 或误配可以让 PUT rules 的 JSON 递归 // 爆炸,评估时爆栈;这些常量给"合理配置"预留了充足空间,同时挡住 blob。 const ( // MaxDepth 是嵌套 AND/OR 允许的最大深度(根算 1 层)。 MaxDepth = 8 // MaxNodes 是整树里叶子 + 聚合节点总数上限。 MaxNodes = 64 // MaxBytes 是原始 JSON 字节数上限(8KB)。 MaxBytes = 8 * 1024 ) // ErrRuleTreeTooDeep 表示 AND/OR 嵌套超过 MaxDepth。 var ErrRuleTreeTooDeep = errors.New("rule tree exceeds max depth") // ErrRuleTreeTooManyNodes 表示节点总数超过 MaxNodes。 var ErrRuleTreeTooManyNodes = errors.New("rule tree exceeds max node count") // ErrRuleTreeTooLarge 表示 JSON payload 超过 MaxBytes。 var ErrRuleTreeTooLarge = errors.New("rule tree JSON exceeds max byte size") // ValidateEligibilityJSON 是 PUT /activities/{id}/rules 收到 eligibility JSON // 时的准入闸门。三个上限任一超限 → 返回带上下文的错误,caller 直接 400。 // 空 JSON、"{}"、`null` 都视为合法(表示"无门槛")。 func ValidateEligibilityJSON(raw []byte) error { if len(raw) > MaxBytes { return fmt.Errorf("%w: %d bytes > %d limit", ErrRuleTreeTooLarge, len(raw), MaxBytes) } if len(raw) == 0 { return nil } // 允许 null / "{}" 表示无门槛。 trimmed := trimJSONWhitespace(raw) if len(trimmed) == 0 || string(trimmed) == "null" || string(trimmed) == "{}" { return nil } var tree lottery.EligibilityRule if err := json.Unmarshal(raw, &tree); err != nil { return fmt.Errorf("invalid eligibility JSON: %w", err) } return validateRule(&tree, 1) } // validateRule 递归检查一棵规则树;depth 是当前节点所在层(根 = 1)。 // 用共享计数器(返回值)而不是外部 counter 是为了让递归签名保持无副作用。 func validateRule(node *lottery.EligibilityRule, depth int) error { if node == nil { return nil } if depth > MaxDepth { return fmt.Errorf("%w: got depth %d, max %d", ErrRuleTreeTooDeep, depth, MaxDepth) } count, err := countAndValidate(node, depth) if err != nil { return err } if count > MaxNodes { return fmt.Errorf("%w: got %d nodes, max %d", ErrRuleTreeTooManyNodes, count, MaxNodes) } return nil } // countAndValidate 深度优先遍历,一次递归同时统计节点数并做深度检查。 // 返回 count 是子树总节点数(含当前节点);err 表明遍历中已经超限。 func countAndValidate(node *lottery.EligibilityRule, depth int) (int, error) { if node == nil { return 0, nil } if depth > MaxDepth { return 0, fmt.Errorf("%w: got depth %d, max %d", ErrRuleTreeTooDeep, depth, MaxDepth) } total := 1 for _, child := range node.Children { sub, err := countAndValidate(child, depth+1) if err != nil { return 0, err } total += sub // 提前退出:命中节点数上限就不要继续 walk 剩余分支。 if total > MaxNodes { return 0, fmt.Errorf("%w: got at least %d nodes, max %d", ErrRuleTreeTooManyNodes, total, MaxNodes) } } return total, nil } // trimJSONWhitespace 剥掉前后 JSON 空白,用于识别"实质空"的 payload。 func trimJSONWhitespace(raw []byte) []byte { i, j := 0, len(raw) for i < j && isJSONWhitespace(raw[i]) { i++ } for j > i && isJSONWhitespace(raw[j-1]) { j-- } return raw[i:j] } func isJSONWhitespace(b byte) bool { return b == ' ' || b == '\t' || b == '\n' || b == '\r' }