futu_rest/routes/trd/
flow_summary.rs1use 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
25pub 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 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 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
74async fn maybe_handle_flow_summary_date_range(
85 state: &RestState,
86 body: &Value,
87) -> Result<Option<Json<Value>>, (StatusCode, Json<Value>)> {
88 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); };
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 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; 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 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 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#[cfg(test)]
313mod tests_v1_4_93_jackie_1_flow_summary_range;
314
315