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 必须 >= 0 且 <= max_num (不静默 clamp 0 → 1; 0 对齐 C++ 为空页请求)
106///
107/// `max_num` 由调用方按 endpoint 传:
108/// - warrant: 200 (C++ APIServer_Qot_Warrant.cpp 只拒 num<0 / num>200;
109/// NNBiz_Qot_WarrantFilter.cpp 将 page_count 原样下发)
110/// - stock_filter: 200 (C++ APIServer_Qot_StockFilter.cpp 只拒 num<0 / num>200;
111/// NNBiz_Qot_StockFilter.cpp 对 0 计算空页)
112pub fn validate_begin_num(
113 begin: i32,
114 num: i32,
115 max_num: i32,
116 endpoint: &str,
117) -> Result<PageBounds, PageBoundsError> {
118 if begin < 0 {
119 return Err(PageBoundsError {
120 endpoint: endpoint.to_string(),
121 reason: PageBoundsErrorReason::BeginNegative { begin },
122 });
123 }
124 if num < 0 || num > max_num {
125 return Err(PageBoundsError {
126 endpoint: endpoint.to_string(),
127 reason: PageBoundsErrorReason::NumOutOfRange {
128 num,
129 min: 0,
130 max: max_num,
131 },
132 });
133 }
134 Ok(PageBounds { begin, num })
135}
136
137/// 校验 `Option<i32>` max_count (history-kline 类) 参数.
138///
139/// 语义 (与 C++ backend 一致):
140/// - None: 不限制 (backend 用默认上限)
141/// - Some(0): 不限制 (历史兼容, 与 None 等价)
142/// - Some(>0): 必须 <= max_allowed (越界 loud reject)
143/// - Some(<0): 非法 (loud reject)
144///
145/// 返回校验后的 `Option<i32>`, None / Some(0) 都 normalize 为 None.
146pub fn validate_optional_max_count(
147 max_count: Option<i32>,
148 max_allowed: i32,
149 endpoint: &str,
150) -> Result<Option<i32>, PageBoundsError> {
151 match max_count {
152 None | Some(0) => Ok(None),
153 Some(n) if n < 0 => Err(PageBoundsError {
154 endpoint: endpoint.to_string(),
155 reason: PageBoundsErrorReason::MaxCountNegative { max_count: n },
156 }),
157 Some(n) if n > max_allowed => Err(PageBoundsError {
158 endpoint: endpoint.to_string(),
159 reason: PageBoundsErrorReason::MaxCountTooLarge {
160 max_count: n,
161 max_allowed,
162 },
163 }),
164 Some(n) => Ok(Some(n)),
165 }
166}
167
168#[cfg(test)]
169mod tests;