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;