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 @@