Skip to main content

futu_rest/routes/trd/
flow_summary.rs

1//! REST `/api/flow-summary` 路由与 date-range fanout。
2//!
3//! 这是 Trd_FlowSummary 的 endpoint-specific 聚合逻辑:单日请求直接转发,
4//! date-range 请求在 REST 层按日 fanout 后聚合,避免把业务 fanout 放进通用 adapter。
5
6use std::sync::Arc;
7
8use axum::Json;
9use axum::extract::{Extension, State};
10use axum::http::StatusCode;
11use serde_json::Value;
12
13use futu_auth::KeyRecord;
14use futu_core::proto_id;
15use futu_proto::trd_flow_summary;
16
17use crate::adapter::{self, RestState};
18
19use super::ApiResult;
20use super::card_num::normalize_and_resolve_card_num_for_route;
21use super::validation::{
22    read_handler_acc_id_check, validate_header_trd_env_present, validate_header_trd_market,
23};
24
25/// POST /api/flow-summary — 账户资金流水(v1.4.30 P2)
26///
27/// **v1.4.93 Jackie feedback Problem 1 (date-range support)**:
28/// proto `Trd_FlowSummary` 一次只接一天 (`clearing_date`). 之前 CLI v1.4.32
29/// `--date-range` 已实装客户端循环, REST 层之前未暴露 → jackie 报告需 10 次
30/// 单日 call. 本版加 REST adapter 层客户端循环, 接受:
31///
32/// ```json
33/// // v1 单日 (向后兼容)
34/// {"c2s":{"header":{...},"clearing_date":"2026-04-22"}}
35///
36/// // v2 范围 (v1.4.93 新加, 等价 CLI --date-range)
37/// {"c2s":{"header":{...},"begin_date":"2026-04-15","end_date":"2026-04-22"}}
38/// ```
39///
40/// 范围模式: 跳周末, 31 天上限 (防 bot 脚本无意打几百天被服务端 rate-limit),
41/// 循环 fetch 单日 → 聚合 `flow_summary_info_list` 返一个 response.
42/// `direction` (`cash_flow_direction`) 顶层 c2s 字段透传到每天调用.
43///
44/// 防 backend 无 native range 接口 — 这是 client-side fanout, 与 proto 一致.
45pub async fn get_flow_summary(
46    State(state): State<RestState>,
47    rec: Option<Extension<Arc<KeyRecord>>>,
48    Json(mut body): Json<Value>,
49) -> ApiResult {
50    normalize_and_resolve_card_num_for_route(&state, &rec, &mut body, "/api/flow-summary")?;
51    // v1.4.102 codex 32 F2 (P2) fix: flow-summary 也加 trd_env / market 预校验.
52    // 之前漏挂 → fanout 把缺 trd_env 包成 partial/all-failed silent. 与 BUG-005 +
53    // 26 F1 read endpoint 同步.
54    validate_header_trd_market(&body, "/api/flow-summary")?;
55    validate_header_trd_env_present(&body, "/api/flow-summary")?;
56    read_handler_acc_id_check(
57        &state,
58        rec.as_deref().map(|r| r.as_ref()),
59        &body,
60        "/api/flow-summary",
61    )?;
62    // v1.4.93 Jackie #1: 检测 date_range 模式 (begin_date + end_date)
63    if let Some(range_resp) = maybe_handle_flow_summary_date_range(&state, &body).await? {
64        return Ok(range_resp);
65    }
66    adapter::proto_request::<trd_flow_summary::Request, trd_flow_summary::Response>(
67        &state,
68        proto_id::TRD_FLOW_SUMMARY,
69        Some(body),
70    )
71    .await
72}
73
74/// v1.4.93 Jackie #1: 客户端循环实装 `/api/flow-summary` 的 date-range 模式.
75///
76/// 检测 body 含 `begin_date` + `end_date` (顶层或 `c2s` 内, 兼容 normalize 前后)
77/// → 跳周末 / 31 天上限 / 每日单调 fetch / 聚合返聚合 response.
78///
79/// 返 `Ok(Some(resp))` = range mode 处理完成; `Ok(None)` = 单日模式 (调用方继续
80/// 走原 adapter 路径); `Err((status, body))` = 输入错误 (begin>end / 跨度过大).
81///
82/// 为什么不放 adapter 层: adapter 是通用 proto 转发, 客户端 fanout (聚合多次
83/// proto call) 是 endpoint-specific 业务逻辑, 应在 endpoint handler.
84async fn maybe_handle_flow_summary_date_range(
85    state: &RestState,
86    body: &Value,
87) -> Result<Option<Json<Value>>, (StatusCode, Json<Value>)> {
88    // 抽 begin_date / end_date — 兼容顶层 (flat) 和 c2s 内
89    let pick = |key: &str| -> Option<String> {
90        body.pointer(&format!("/c2s/{key}"))
91            .or_else(|| body.pointer(&format!("/{key}")))
92            .and_then(|v| v.as_str())
93            .map(|s| s.to_string())
94    };
95    let (Some(begin), Some(end)) = (pick("begin_date"), pick("end_date")) else {
96        return Ok(None); // 单日模式
97    };
98    let date_from = chrono::NaiveDate::parse_from_str(&begin, "%Y-%m-%d").map_err(|e| {
99        (
100            StatusCode::BAD_REQUEST,
101            Json(serde_json::json!({
102                "error": format!("begin_date 格式错: {e} (需 YYYY-MM-DD)")
103            })),
104        )
105    })?;
106    let date_to = chrono::NaiveDate::parse_from_str(&end, "%Y-%m-%d").map_err(|e| {
107        (
108            StatusCode::BAD_REQUEST,
109            Json(serde_json::json!({
110                "error": format!("end_date 格式错: {e} (需 YYYY-MM-DD)")
111            })),
112        )
113    })?;
114    if date_to < date_from {
115        return Err((
116            StatusCode::BAD_REQUEST,
117            Json(serde_json::json!({
118                "error": format!("end_date {date_to} 不能早于 begin_date {date_from}"),
119            })),
120        ));
121    }
122    let span_days = (date_to - date_from).num_days();
123    if span_days > 31 {
124        return Err((
125            StatusCode::BAD_REQUEST,
126            Json(serde_json::json!({
127                "error": format!(
128                    "date 范围跨度 {span_days} 天超过 31 天上限 (防 bot 误调用被服务端 \
129                     rate-limit. 对齐 CLI --date-range). 请拆多次或用脚本循环."
130                ),
131            })),
132        ));
133    }
134    // 提取 header + direction 备每日重用。
135    //
136    // v1.4.107: range fanout 必须接受 REST adapter 支持的 4 种 body shape:
137    // - {"c2s":{"header":{...}}}
138    // - {"header":{...}}
139    // - {"c2s":{"acc_id":...,"trd_env":...,"trd_market":...}}
140    // - {"acc_id":...,"trd_env":...,"trd_market":...}
141    //
142    // 旧实现只复制前两种;flat body 已通过 validate/read_handler_acc_id_check,
143    // 但 fanout 下游收到 header:null → daily fetch 全失败。
144    let header = flow_summary_range_header_from_body(body);
145    let direction = body
146        .pointer("/c2s/cash_flow_direction")
147        .or_else(|| body.pointer("/cash_flow_direction"))
148        .or_else(|| body.pointer("/c2s/direction"))
149        .or_else(|| body.pointer("/direction"))
150        .cloned();
151
152    use chrono::Datelike;
153    let mut day = date_from;
154    let mut all_entries: Vec<Value> = Vec::new();
155    let mut s2c_header: Value = Value::Null;
156    let mut failed_days: Vec<String> = Vec::new();
157    let mut success_days = 0usize; // v1.4.102 codex 45 F1: 成功但空 ≠ 失败
158    while day <= date_to {
159        if matches!(day.weekday(), chrono::Weekday::Sat | chrono::Weekday::Sun) {
160            if !flow_summary_advance_day(&mut day, date_to).map_err(|e| {
161                (
162                    StatusCode::BAD_REQUEST,
163                    Json(serde_json::json!({ "error": e })),
164                )
165            })? {
166                break;
167            }
168            continue;
169        }
170        let date_str = day.format("%Y-%m-%d").to_string();
171        let mut single_body = serde_json::json!({
172            "c2s": {
173                "header": header.clone(),
174                "clearing_date": date_str.clone(),
175            }
176        });
177        if let Some(dir) = direction.clone() {
178            single_body["c2s"]["cash_flow_direction"] = dir;
179        }
180        match adapter::proto_request::<trd_flow_summary::Request, trd_flow_summary::Response>(
181            state,
182            proto_id::TRD_FLOW_SUMMARY,
183            Some(single_body),
184        )
185        .await
186        {
187            Ok(Json(resp)) => {
188                let ret = resp.get("ret_type").and_then(|v| v.as_i64()).unwrap_or(-1);
189                if ret == 0 {
190                    // v1.4.102 codex 45 F1: 成功的 daily fetch 也可能合法返
191                    // 空 flow_summary_info_list (假日 / 真无流水), 必须算
192                    // success_day 不能用 entries 数推断成败.
193                    success_days += 1;
194                    if s2c_header == Value::Null
195                        && let Some(h) = resp.pointer("/s2c/header")
196                    {
197                        s2c_header = h.clone();
198                    }
199                    if let Some(arr) = resp
200                        .pointer("/s2c/flow_summary_info_list")
201                        .and_then(|v| v.as_array())
202                    {
203                        all_entries.extend(arr.iter().cloned());
204                    }
205                } else {
206                    failed_days.push(date_str.clone());
207                }
208            }
209            Err(_) => {
210                failed_days.push(date_str.clone());
211            }
212        }
213        if !flow_summary_advance_day(&mut day, date_to).map_err(|e| {
214            (
215                StatusCode::BAD_REQUEST,
216                Json(serde_json::json!({ "error": e })),
217            )
218        })? {
219            break;
220        }
221    }
222
223    // v1.4.102 codex 32 F1 + v1.4.102 codex 45 F1 (P2) fix:
224    // 全失败时不再 silent ret_type=0 + 不再用 entries 数推断成败.
225    // silent-success anti-pattern (CLAUDE.md 反模式 D / 坑 #45).
226    //
227    // 真正 all-failed: success_days == 0 + failed_days 非空 (没有任何一天 succeed).
228    //   - 之前 codex 32 F1 用 `failed_days 非空 && all_entries.is_empty()`
229    //     → 部分成功但成功日恰好无流水 (假日 / 无活动) → 误判 all-failed.
230    //   - codex 45 F1 修: 用 success_days 真实计数; 0 success = 真 all-failed.
231    let all_failed = success_days == 0 && !failed_days.is_empty();
232    let (ret_type, ret_msg): (i32, Option<String>) = if all_failed {
233        (
234            -1,
235            Some(format!(
236                "flow-summary: 所有 {} 个 daily fetch 都失败 (failed_days: {:?}). \
237                 检查 trd_env / acc_id / trd_market / backend 连接. \
238                 v1.4.102 codex 32 F1 fix: 不再 silent ret_type=0.",
239                failed_days.len(),
240                failed_days
241            )),
242        )
243    } else if !failed_days.is_empty() {
244        (
245            0,
246            Some(format!(
247                "flow-summary: partial success ({} entries from successful days), \
248                 但有 {} 天失败 (见 s2c.date_range.failed_days). v1.4.102 codex 32 F1.",
249                all_entries.len(),
250                failed_days.len()
251            )),
252        )
253    } else {
254        (0, None)
255    };
256    let resp = serde_json::json!({
257        "ret_type": ret_type,
258        "ret_msg": ret_msg,
259        "err_code": null,
260        "s2c": {
261            "header": s2c_header,
262            "flow_summary_info_list": all_entries,
263            "date_range": {
264                "begin_date": begin,
265                "end_date": end,
266                "failed_days": failed_days,
267            },
268        },
269    });
270    Ok(Some(Json(resp)))
271}
272
273fn flow_summary_range_header_from_body(body: &Value) -> Value {
274    if let Some(header) = body
275        .pointer("/c2s/header")
276        .or_else(|| body.pointer("/header"))
277    {
278        return header.clone();
279    }
280
281    let mut header = serde_json::Map::new();
282    for key in ["acc_id", "trd_env", "trd_market", "jp_acc_type"] {
283        if let Some(v) = body
284            .pointer(&format!("/c2s/{key}"))
285            .or_else(|| body.pointer(&format!("/{key}")))
286        {
287            header.insert(key.to_string(), v.clone());
288        }
289    }
290    Value::Object(header)
291}
292
293fn flow_summary_advance_day(
294    day: &mut chrono::NaiveDate,
295    date_to: chrono::NaiveDate,
296) -> Result<bool, String> {
297    if *day >= date_to {
298        return Ok(false);
299    }
300    let next = day
301        .succ_opt()
302        .ok_or_else(|| format!("flow-summary date range cannot advance beyond {day}"))?;
303    *day = next;
304    Ok(true)
305}
306
307/// v1.4.93 Jackie #1 unit tests — date-range mode for `/api/flow-summary`.
308///
309/// 测 `maybe_handle_flow_summary_date_range` validation 路径 (输入错误检测).
310/// HTTP fanout 路径 (实际跨日 fetch + 聚合) 走真机 verify (essentials/2026-04-26
311/// jackie feedback report).
312#[cfg(test)]
313mod tests_v1_4_93_jackie_1_flow_summary_range;
314
315// ============================================================================