From ff82a36ae119cff9cc55fbd569777e4488f46901 Mon Sep 17 00:00:00 2001 From: argszero Date: Sat, 12 Sep 2026 11:12:55 +0800 Subject: [PATCH] fix(ui): restore the trades card on the operator overview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `GET /api/ops/runtime` has returned `total_txs` (a global `COUNT(*) FROM transactions`) since 85982e8 (PR #80), but the live operator view never rendered it. The mock branch did: stat(T("ops.stats.trades"), T("cnt.trades", { n: txs.length }), T("ops.stats.trades.sub")) and 89963f3 ("zero mock data in logged-in views", v1.22) deleted that line without re-pointing the live branch at `rt.total_txs`, which the same response object already carried. So this is a regression being repaired, not a new feature — and not prototype parity: the prototype has four ops cards and no trades card at all, while the live view has nine by design. The card's two i18n keys were already present in both packs, which is why the change adds no keys. The field had no assertion in either direction, which is exactly why it could sit unused for 23 commits: `grep total_txs src/routes/ops.rs` found only the compute and the serialize lines. The new route test pins both that it is returned and that its scope is global and all-types (rows span two users, so a per-user or consume-only regression is falsifiable). No production backend change: the field already ships. --- src/i18n_pack.rs | 4 +- src/routes/mod.rs | 95 +++++++++++++++++++++++++++++++++++++++++++++++ ui/README.md | 1 + ui/index.html | 10 ++--- ui/js/app.js | 4 ++ 5 files changed, 107 insertions(+), 7 deletions(-) diff --git a/src/i18n_pack.rs b/src/i18n_pack.rs index a226ae9..3d74865 100644 --- a/src/i18n_pack.rs +++ b/src/i18n_pack.rs @@ -47,8 +47,8 @@ const ZH_KEY_COUNT: usize = 786; const EN_KEY_COUNT: usize = 786; const STATIC_ATTR_COUNT: usize = 330; const STATIC_ATTR_DISTINCT: usize = 305; -const T_LITERAL_COUNT: usize = 535; -const T_LITERAL_DISTINCT: usize = 429; +const T_LITERAL_COUNT: usize = 538; +const T_LITERAL_DISTINCT: usize = 431; /// 切出语言包区段(起点标记 → 终点标记,含起点)。 fn pack_region<'a>(src: &'a str, start_mark: &str, end_mark: &str) -> &'a str { diff --git a/src/routes/mod.rs b/src/routes/mod.rs index e0b2adf..e2a7a6a 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -1904,6 +1904,101 @@ mod tests { assert_eq!(s, StatusCode::BAD_REQUEST); } + /// `total_txs` —— 运营概览「交易量」卡的数据源。 + /// + /// 这个字段自 `85982e8`(PR #80)起就在算、就在返回(`ops.rs:101`/`:175`), + /// 但**两侧都没有任何断言**:v1.22 的零 mock 重构(`89963f3`)删掉 mock 分支的 + /// 那张卡后,前端漏了重接,而这个字段照旧返回,于是谁都发现不了。 + /// 本测试把「返回了」与「口径是全库 / 累计全部类型」同时钉住。 + #[tokio::test] + async fn ops_runtime_total_txs_is_global_and_all_types() { + let st = test_state("opstxs"); + let ops_bearer = login_bearer(&st, "ops@aitokenpool.local", "ops1234").await; + // 空库 → 0(先证有值,再证口径,避免「恒为 0 也算过」) + let (s, body) = get(st.clone(), "/api/ops/runtime", Some(&ops_bearer)).await; + assert_eq!(s, StatusCode::OK, "ops runtime 应 200: {body}"); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(v["total_txs"], 0, "空库应为 0: {body}"); + + // 造 3 条、**跨两个用户**:admin 的 topup(走真实充值接口)+ demo 的 consume / earn。 + // 跨用户是刻意的:这样「全局 3」既不同于 demo 自己的 2,也不同于 consume-only 的 1, + // 两种退化的口径都能被这条断言抓住。 + let uid = |email: &str| -> i64 { + st.db + .lock() + .unwrap() + .query_row("SELECT id FROM users WHERE email = ?1", [email], |r| { + r.get(0) + }) + .unwrap() + }; + let demo_id = uid("demo@aitokenpool.local"); + let admin_id = uid("admin@aitokenpool.local"); + let (s2, body2) = post( + st.clone(), + "/api/ops/credits", + &format!(r#"{{"user_id":{admin_id},"amount":77}}"#), + Some(&ops_bearer), + ) + .await; + assert_eq!(s2, StatusCode::OK, "充值应 200: {body2}"); + { + let conn = st.db.lock().unwrap(); + conn.execute( + "INSERT INTO transactions (user_id, counterpart, key_id, model, tokens, pts, type, status) \ + VALUES (?1, 'x', 1, 'm', 10, -2.0, 'consume', '成功')", + [demo_id], + ) + .unwrap(); + conn.execute( + "INSERT INTO transactions (user_id, counterpart, key_id, model, tokens, pts, type, status) \ + VALUES (?1, 'x', 1, 'm', 10, 1.0, 'earn', '成功')", + [demo_id], + ) + .unwrap(); + } + let (_, body) = get(st.clone(), "/api/ops/runtime", Some(&ops_bearer)).await; + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!( + v["total_txs"], 3, + "全库 3 条(admin 的 topup + demo 的 consume/earn)——'累计全部类型': {body}" + ); + // 反面一:按 type 过滤 → 只有 1 条 + let consume_only: i64 = st + .db + .lock() + .unwrap() + .query_row( + "SELECT COUNT(*) FROM transactions WHERE type = 'consume'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(consume_only, 1, "前置条件:consume 仅 1 条"); + assert_ne!( + v["total_txs"].as_i64().unwrap(), + consume_only, + "total_txs 不能退化成按 type 过滤: {body}" + ); + // 反面二:按用户过滤 → 最多 2 条(demo) + let max_per_user: i64 = st + .db + .lock() + .unwrap() + .query_row( + "SELECT COALESCE(MAX(c), 0) FROM (SELECT COUNT(*) AS c FROM transactions GROUP BY user_id)", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(max_per_user, 2, "前置条件:单用户最多 2 条"); + assert_ne!( + v["total_txs"].as_i64().unwrap(), + max_per_user, + "total_txs 不能退化成按用户过滤: {body}" + ); + } + #[tokio::test] async fn usage_three_group_aggregation() { let st = test_state("usage3"); diff --git a/ui/README.md b/ui/README.md index c6af3bb..8a7a625 100644 --- a/ui/README.md +++ b/ui/README.md @@ -346,4 +346,5 @@ ui/ - **page-head crumb**:8 个视图统一 `.crumb`(`/ 设置` `/ 管理视图` `/ 运营视图`),与仪表盘/市场/共享/钱包/交易一致; - **管理视图**(4 tab 不变):成员管理 pane 增加 `#emp-search` 成员搜索(成员名 / 邮箱 / 部门,`hl()` 高亮)+ 表格「角色 / 部门 / 永久点数 / 赠送点数 / 可用」列;用量报表用 `.card-grid-3` 三栏;组织管理与模型管理的工具栏为「搜索 + `.grow` + 主按钮(`btn-sm`)」,与原型一致; - **运营视图**(2 tab 不变):运行概览新增四张卡片——**服务版本**(`/api/ops/runtime` 的 `version`,取自 `env!("CARGO_PKG_VERSION")`,与 `/healthz` 同源;前端**不得**写死版本号)与**运行时长**(`uptime_secs` / `uptime_days` / `uptime_hours` / `uptime_minutes` / `uptime_secs_rest`,进位在后端 `split_uptime` 完成,前端 `fmtUptime` 只挑「最高两个非零位」并取 `ops.uptime.{days,hours,minutes,seconds}` 单位)、**今日调用量(按小时)** `.bar-list`(`today_hours`,服务端 0-23 全量补零;GROUP BY 会省略无调用的小时,不补零会让柱子整体左移,与交易页 `txTrendDays` 同款坑)与**上游 key 健康** `.mini-list`(`key_health` 按厂商聚合 total/on/off,三态 pill:健康 / N 个异常 / 全部失败);成员充值的搜索框移到卡片标题行右侧(原型 `.spread`); +- **交易量卡(第 9 张,回归修复)**:`total_txs`(`ops.stats.trades` + `cnt.trades` + `ops.stats.trades.sub`)自 `85982e8`(PR #80)起就在 `/api/ops/runtime` 返回(全库 `COUNT(*)`,累计**全部类型**),但 v1.22 零 mock 重构 `89963f3` 删掉 mock 分支那张卡时漏了重接。**这不是原型对齐**——原型没有这张卡(原型 4 张,实现 9 张,多出的卡是刻意的)。值一律取自响应,**不取** `D.TRANSACTIONS`; - **零 mock 不破**:以上数据全部来自真实端点,加载失败仍走空态 + 重试(`.mini-item` / `.bar-row` 只在有真实数据时才渲染)。 diff --git a/ui/index.html b/ui/index.html index 614d512..7ef28bb 100644 --- a/ui/index.html +++ b/ui/index.html @@ -5,7 +5,7 @@ AITokenPool - + @@ -842,9 +842,9 @@

使用模型

- - - - + + + + diff --git a/ui/js/app.js b/ui/js/app.js index 816c049..2e5a91b 100644 --- a/ui/js/app.js +++ b/ui/js/app.js @@ -2561,6 +2561,10 @@ stat(T("ops.stats.uptime"), esc(fmtUptime(rt)), T("ops.stats.uptime.sub")), stat(T("ops.stats.users"), T("cnt.people", { n: rt.users }), T("ops.stats.users.sub")), stat(T("ops.stats.keys"), T("cnt.keys", { n: rt.active_keys }), T("ops.stats.keys.sub.on")), + // 交易量:total_txs 自 85982e8(PR #80)起就一直在算、在返回(全库 COUNT(*)), + // 但 v1.22 零 mock 重构(89963f3)删掉 mock 分支那一行时漏了重接——这是**回归**, + // 不是新功能,也不是原型对齐(原型没有这张卡)。零 mock 不破:值取自响应,不取 D.TRANSACTIONS。 + stat(T("ops.stats.trades"), T("cnt.trades", { n: rt.total_txs }), T("ops.stats.trades.sub")), stat(T("ops.stats.calls"), T("cnt.calls", { n: rt.month_calls }), T("ops.stats.calls.sub")), stat(T("ops.stats.in"), "+" + D.fmt(rt.month_in) + " " + T("common.points"), T("ops.stats.in.sub")), stat(T("ops.stats.out"), "-" + D.fmt(rt.month_out) + " " + T("common.points"), T("ops.stats.out.sub")),