Skip to main content

futu_qot/
page_bounds.rs

1//! 共享分页 / 边界 validator (v1.4.106 codex 0635 整体重构, ζ36)
2//!
3//! 历史背景: codex 0635 audit (5 P2) 揭示 QOT 分析 / 参考类查询的分页 / 边界
4//! 校验四处不一致:
5//! - F1 [P2]: warrant MCP/CLI wrapper 没暴露 begin (硬编码 0), 用户拿不到分页
6//! - F2 [P2]: GetWarrantHandler 直透 begin/num 不校验, REST/raw caller 可发非法值
7//! - F3 [P2]: warrant + stock-filter MCP/CLI wrapper 静默 clamp num (1, 200)
8//! - F4 [P2]: stock-filter num=0 跨 surface 行为不一致 (MCP clamp 1 / REST silent empty)
9//! - F5 [P2]: history-kline schema 写 default 1000 实际不限制
10//!
11//! audit 自己建议抽 `PageBounds` / `validate_begin_num` / `validate_optional_max_count`
12//! 共享分页契约. 本模块实装该契约, 落点:
13//! - MCP wrapper: tool 入参先校验, 错走 `Err`
14//! - CLI wrapper: 越界返 `Err`, 不静默改写
15//! - gateway handler: 最终边界再校验, 防 REST/raw/gRPC/WS 直 proto caller
16//! - schema/help: 只写真实运行契约
17//!
18//! C++ 对照: 各 handler 校验在
19//! `NNBiz/Src/Qot/StockScreener/NNBiz_Qot_StockScreener.cpp` (CMD 9010 begin/num
20//! validation) 及 `NNBiz_Qot_Warrant.cpp` (CMD 6513 data_from / data_max_count
21//! validation). C++ backend 拒非法值 (loud reject), Rust daemon 提前到入口拦截
22//! 减少 backend 往返 + 给用户清晰中文错误.
23
24use std::fmt;
25
26/// 分页参数校验结果. 越界 / 非法返 `PageBoundsError`, 校验通过返 `PageBounds`.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub struct PageBounds {
29    pub begin: i32,
30    pub num: i32,
31}
32
33/// 校验失败原因.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct PageBoundsError {
36    /// 触发校验的 endpoint 名 (用于错误 message debug, 例如 "warrant" / "stock_filter")
37    pub endpoint: String,
38    /// 错误原因
39    pub reason: PageBoundsErrorReason,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum PageBoundsErrorReason {
44    BeginNegative {
45        begin: i32,
46    },
47    NumOutOfRange {
48        num: i32,
49        min: i32,
50        max: i32,
51    },
52    /// max_count 负数 (None / 0 视为合法 = 不限制; 仅正数受范围约束)
53    MaxCountNegative {
54        max_count: i32,
55    },
56    /// max_count 正数超上限
57    MaxCountTooLarge {
58        max_count: i32,
59        max_allowed: i32,
60    },
61}
62
63impl fmt::Display for PageBoundsError {
64    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65        match &self.reason {
66            PageBoundsErrorReason::BeginNegative { begin } => write!(
67                f,
68                "{}: begin={} 非法 (必须 >= 0)。begin 是分页起始 index, 0 表示第一页",
69                self.endpoint, begin
70            ),
71            PageBoundsErrorReason::NumOutOfRange { num, min, max } => write!(
72                f,
73                "{}: num={} 非法 (合法范围 [{}, {}])。num 必须显式在范围内, \
74                 daemon 不再静默 clamp。如需更大 batch 请分页发多次。",
75                self.endpoint, num, min, max
76            ),
77            PageBoundsErrorReason::MaxCountNegative { max_count } => write!(
78                f,
79                "{}: max_count={} 负数非法。省略 (None) 或 0 = 不限制; \
80                 否则必须正整数。",
81                self.endpoint, max_count
82            ),
83            PageBoundsErrorReason::MaxCountTooLarge {
84                max_count,
85                max_allowed,
86            } => write!(
87                f,
88                "{}: max_count={} 超上限 (合法上限 {})。\
89                 更大 range 请分段查询或省略以使用默认值。",
90                self.endpoint, max_count, max_allowed
91            ),
92        }
93    }
94}
95
96impl std::error::Error for PageBoundsError {}
97
98/// 校验 (begin, num) 分页参数. C++ 对应做法是 backend 收到非法值返
99/// `result_code != 0`, daemon 提前 loud reject 减少 backend 往返.
100///
101/// `endpoint` 用于错误 message (例如 "warrant" / "stock_filter")
102///
103/// 严格规则 (与 C++ backend 一致):
104/// - begin 必须 >= 0
105/// - num 必须 >= 1 且 <= max_num (不静默 clamp 0 → 1, 越界 loud reject)
106///
107/// `max_num` 由调用方按 endpoint 传:
108/// - warrant: 200 (C++ NNBiz_Qot_Warrant.cpp 上限)
109/// - stock_filter: 200 (C++ NNBiz_Qot_StockScreener.cpp CMD 9010 上限)
110pub fn validate_begin_num(
111    begin: i32,
112    num: i32,
113    max_num: i32,
114    endpoint: &str,
115) -> Result<PageBounds, PageBoundsError> {
116    if begin < 0 {
117        return Err(PageBoundsError {
118            endpoint: endpoint.to_string(),
119            reason: PageBoundsErrorReason::BeginNegative { begin },
120        });
121    }
122    if num < 1 || num > max_num {
123        return Err(PageBoundsError {
124            endpoint: endpoint.to_string(),
125            reason: PageBoundsErrorReason::NumOutOfRange {
126                num,
127                min: 1,
128                max: max_num,
129            },
130        });
131    }
132    Ok(PageBounds { begin, num })
133}
134
135/// 校验 `Option<i32>` max_count (history-kline 类) 参数.
136///
137/// 语义 (与 C++ backend 一致):
138/// - None: 不限制 (backend 用默认上限)
139/// - Some(0): 不限制 (历史兼容, 与 None 等价)
140/// - Some(>0): 必须 <= max_allowed (越界 loud reject)
141/// - Some(<0): 非法 (loud reject)
142///
143/// 返回校验后的 `Option<i32>`, None / Some(0) 都 normalize 为 None.
144pub fn validate_optional_max_count(
145    max_count: Option<i32>,
146    max_allowed: i32,
147    endpoint: &str,
148) -> Result<Option<i32>, PageBoundsError> {
149    match max_count {
150        None | Some(0) => Ok(None),
151        Some(n) if n < 0 => Err(PageBoundsError {
152            endpoint: endpoint.to_string(),
153            reason: PageBoundsErrorReason::MaxCountNegative { max_count: n },
154        }),
155        Some(n) if n > max_allowed => Err(PageBoundsError {
156            endpoint: endpoint.to_string(),
157            reason: PageBoundsErrorReason::MaxCountTooLarge {
158                max_count: n,
159                max_allowed,
160            },
161        }),
162        Some(n) => Ok(Some(n)),
163    }
164}
165
166#[cfg(test)]
167mod tests;