From 574dc4b6ea0cc755aaaecb4d38e65eb48e8814ee Mon Sep 17 00:00:00 2001 From: argszero Date: Tue, 15 Sep 2026 07:21:18 +0800 Subject: [PATCH 1/3] fix(i18n): stop the backend from inventing Chinese display labels in response data fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client localizes the `error` field (`api.js` hands it to `I18n.mapErr()`, wordlist in `ui/js/i18n.js` — see #249). Response **data** fields have no such path: the UI renders them raw. So a backend that invents a Chinese label and puts it in a data field prints Chinese on the `en` interface, and `cargo test` stays green. Two reachable cases: 1. `GET /api/admin/usage` — the department bucket was `COALESCE(d.name, '(未分配)')` and `app.js` prints it through `barRow()` into `#usage-dept`. Any user with `dept_id IS NULL` and usage this month puts Chinese on an English screen — while the member table on the *same page* has used `T("common.unassigned")` all along. 2. `GET /api/plans` — when config leaves `name` unset the backend derived a display name from `type` (`API(按量)` / `Token Plan` / `Coding Plan`). Neither `config.toml` nor `config.example.toml` sets `name` for any `[[plans]]`, so it always fired, into `#sf-plan` and the share toast. Fix — the backend returns data or a language-neutral marker, the client owns the wording: - the no-department bucket is `''` (same shape as the neighbouring `users[].dept_name`), and the client renders `d.name || T("common.unassigned")` — an existing key, zero new ones; - `/api/plans` returns `p.name` verbatim (empty when unset), and the client gets `planLabel(pl)`: config's own name when present, else a new key per type (`share.planName.paygo|token|coding`), reused by both render sites. Three CI contracts, one per direction: - `i18n_pack::backend_data_fields_are_language_neutral` — scans every `src/**/*.rs` and extracts Chinese literals delivered through a `json!` data field (key ≠ `error`), including through a local `let` binding (the plan-name fallback was written that way; without that layer the gate is green on the pre-change tree). The result must be *exactly* the adjudicated exemption list — a new invented label turns it red, and a stale exemption turns it red too. Its scope is stated in the doc comment: values manufactured by SQL (`COALESCE(..., '中文')`) are covered by the runtime contract below instead. - `i18n_pack::backend_neutral_data_labels_are_localized_in_the_client` — both render sites must have the localized fallback, otherwise a neutral backend just yields a blank label. - `routes::tests::usage_department_bucket_without_a_department_is_language_neutral` — the runtime contract: with a `dept_id IS NULL` user who has usage this month, the bucket name carries no CJK, and a real department name still passes through verbatim. The one exemption (`routes::mod.rs` registration's `unwrap_or("用户")`) is a *user-data* default — the same kind of value as an account name — and is unreachable (`split('@').next()` is always `Some`); it is not an invented display label. --- src/gateway.rs | 34 ++- src/i18n_pack.rs | 611 +++++++++++++++++++++++++++++++++++++++++++- src/routes/admin.rs | 6 +- src/routes/mod.rs | 71 +++++ ui/README.md | 12 + ui/index.html | 4 +- ui/js/app.js | 19 +- ui/js/i18n.js | 6 + 8 files changed, 739 insertions(+), 24 deletions(-) diff --git a/src/gateway.rs b/src/gateway.rs index f0b0918..e1696f6 100644 --- a/src/gateway.rs +++ b/src/gateway.rs @@ -864,7 +864,12 @@ pub async fn models( } /// GET /api/plans(上架表单数据源:config [[plans]] 单一真源,需认证) -/// 返回 id / provider / name(config 无 name 时按 type 推导显示名)/ type / endpoints。 +/// 返回 id / provider / name / type / endpoints。 +/// +/// `name` 是 config 的**原值**(config 未写 `name` 就是空串):显示文案归客户端语言包。 +/// 后端曾在这里按 `type` 自造显示名(`API(按量)` / `Token Plan` / `Coding Plan`), +/// 而 `en` 界面把响应**数据**字段原样渲染(`I18n.mapErr` 只翻译 `error` 字段)⇒ +/// 英文界面上出现中文(C2133)。空串是语言中性标记,前端用 `planLabel()` 按 `type` 取键。 pub async fn plans( State(st): State, _auth: AuthUser, @@ -874,20 +879,10 @@ pub async fn plans( .plans .iter() .map(|p| { - let name = if p.name.is_empty() { - match p.type_.as_str() { - "paygo" => "API(按量)".to_string(), - "token" => "Token Plan".to_string(), - "coding" => "Coding Plan".to_string(), - _ => p.id.clone(), - } - } else { - p.name.clone() - }; serde_json::json!({ "id": p.id, "provider": p.provider, - "name": name, + "name": p.name, "type": p.type_, "interactive_only": p.interactive_only, "endpoints": p.endpoints.iter().map(|e| serde_json::json!({ @@ -2002,7 +1997,20 @@ mod tests { assert_eq!(dp["provider"], "deepseek"); assert_eq!(dp["type"], "paygo"); - assert_eq!(dp["name"], "API(按量)"); + // `name` 是 config 的**原值**:config.example.toml 的 `[[plans]]` 全都不写 `name` + // ⇒ 这里就是空串。后端**不得**按 `type` 自造显示名(`API(按量)` 这类),因为响应 + // 的**数据**字段是前端原样渲染的、`en` 界面会直接显示中文(C2133);显示文案由客户端 + // `planLabel()` 从语言包取。 + assert_eq!( + dp["name"], "", + "plans[].name 必须是 config 原值(未配置即空串),不能是后端自造的显示名" + ); + assert!( + !arr.iter().any(|p| p["name"] + .as_str() + .is_some_and(|n| n.chars().any(|c| ('\u{4e00}'..='\u{9fff}').contains(&c)))), + "plans[].name 里出现了中文 —— 数据字段里的中文会在 en 界面原样显示:{arr:?}" + ); assert!(dp["endpoints"].is_array() && !dp["endpoints"].as_array().unwrap().is_empty()); // 无认证 → 401 let resp2 = router() diff --git a/src/i18n_pack.rs b/src/i18n_pack.rs index 38c6269..a27e796 100644 --- a/src/i18n_pack.rs +++ b/src/i18n_pack.rs @@ -94,12 +94,12 @@ const EN_END: &str = "\n };"; /// /// ⚠️ `T_LITERAL_COUNT` 是 `T("…")` **调用点**总数,不是键数,也不是去重后的键数 —— /// 三个集合各不相同(坑 99);说「这个数不该变」之前先确认它在数哪个集合。 -const ZH_KEY_COUNT: usize = 806; -const EN_KEY_COUNT: usize = 806; +const ZH_KEY_COUNT: usize = 809; +const EN_KEY_COUNT: usize = 809; const STATIC_ATTR_COUNT: usize = 330; const STATIC_ATTR_DISTINCT: usize = 305; -const T_LITERAL_COUNT: usize = 539; -const T_LITERAL_DISTINCT: usize = 431; +const T_LITERAL_COUNT: usize = 543; +const T_LITERAL_DISTINCT: usize = 434; /// 切出语言包区段(起点标记 → 终点标记,含起点)。 fn pack_region<'a>(src: &'a str, start_mark: &str, end_mark: &str) -> &'a str { @@ -1555,6 +1555,27 @@ mod tests { Some(&rest[..end]) } + /// 含**全部** `needles` 的那条语句:`needles[0]` 的每一次命中都要检,命中同一条语句的才算。 + /// + /// 为什么需要(C2133):同一个选择器在一个函数里可以出现两次 —— `$("#usage-dept").innerHTML` + /// 先被清空、再被渲染。只按第一次命中切语句,拿到的是**清空**那句(里面根本没有渲染调用), + /// 门禁就会报一条与产品无关的假红。加一个 needle 把「哪一条语句」说清楚即可。 + fn statement_containing_all<'a>(src: &'a str, needles: &[&str]) -> Option<&'a str> { + let (first, rest) = needles.split_first()?; + let mut from = 0usize; + while let Some(rel) = src[from..].find(first) { + let at = from + rel; + from = at + first.len(); + let tail = &src[at..]; + let end = tail.find(';').unwrap_or(tail.len()); + let stmt = &tail[..end]; + if rest.iter().all(|n| stmt.contains(n)) { + return Some(stmt); + } + } + None + } + /// 401 的语义必须由**调用方**声明:凭据端点的 401 不是「会话过期」(C2120)。 /// /// `ui/js/api.js` 的 `request()` 对 401 一律 `handleUnauthorized()`(清 token + 回登录页 + @@ -2025,4 +2046,586 @@ mod tests { 还是需要委托)" ); } + + /* ---- C2133:后端不得把中文写进响应的**数据**字段 ---- */ + + /// 已裁定的豁免项:`(字面量, 理由)`。 + /// + /// 必须与提取结果**等价**(`==`,不是 `⊆`):两侧都有牙 —— 新增一处「后端自造的中文数据 + /// 文案」会红;豁免的那个字面量消失(改掉或删掉)也红,清单不会腐烂。 + /// + /// 为什么不能要求「一条都没有」:**用户数据**里的中文是合法的(`db.rs` 种子里作为账号名的 + /// `'管理员'`、用户自己填的部门名),它们不是后端自造的显示标签。本清单只收「后端**自己编** + /// 了一句给用户看的话,塞进数据字段」这一种。 + const DATA_LABEL_EXEMPTIONS: &[(&str, &str)] = &[( + "用户", + "注册接口 `\"name\": name` 的默认用户名 —— 那是**用户数据**的默认值(同账号名),不是自造\ + 的显示标签;且 `email.split('@').next()` 恒为 `Some`,该默认值不可达", + )]; + + /// 剥掉 Rust 注释,保留字符串字面量(`//` 与 `/* */` 在字面量内不生效)。 + fn strip_rust_comments(src: &str) -> String { + let b = src.as_bytes(); + let mut out = String::with_capacity(src.len()); + let mut i = 0usize; + while i < b.len() { + if b[i] == b'"' { + if let Some((_, end)) = read_rs_string(src, i) { + out.push_str(&src[i..end]); + i = end; + continue; + } + } + if b[i] == b'/' && b.get(i + 1) == Some(&b'/') { + while i < b.len() && b[i] != b'\n' { + i += 1; + } + continue; + } + if b[i] == b'/' && b.get(i + 1) == Some(&b'*') { + i += 2; + while i + 1 < b.len() && !(b[i] == b'*' && b[i + 1] == b'/') { + i += 1; + } + i = (i + 2).min(b.len()); + continue; + } + let ch = src[i..].chars().next().expect("i 在字符边界上"); + out.push(ch); + i += ch.len_utf8(); + } + out + } + + /// 只读第一个 `#[cfg(test)]` 之前的内容:测试里的中文不上线。 + fn cut_rust_test(src: &str) -> &str { + match src.find("#[cfg(test)]") { + Some(i) => &src[..i], + None => src, + } + } + + /// `src[i] == '"'` → `(字面量内容, 闭合引号之后的下标)`。 + fn read_rs_string(src: &str, i: usize) -> Option<(String, usize)> { + if src.as_bytes().get(i) != Some(&b'"') { + return None; + } + let b = src.as_bytes(); + let mut j = i + 1; + let mut out = String::new(); + while j < b.len() { + if b[j] == b'\\' { + if j + 2 > b.len() { + return None; + } + out.push_str(&src[j..j + 2]); + j += 2; + continue; + } + if b[j] == b'"' { + return Some((out, j + 1)); + } + let ch = src[j..].chars().next()?; + out.push(ch); + j += ch.len_utf8(); + } + None + } + + /// 与 `src[at]` 处的开定界符配对的闭定界符**之后**的下标(跳过字符串)。 + fn match_rs_delim(src: &str, at: usize, open: u8, close: u8) -> Option { + let b = src.as_bytes(); + let (mut depth, mut i) = (0i32, at); + while i < b.len() { + if b[i] == b'"' { + i = read_rs_string(src, i)?.1; + continue; + } + if b[i] == open { + depth += 1; + } else if b[i] == close { + depth -= 1; + if depth == 0 { + return Some(i + 1); + } + } + i += 1; + } + None + } + + /// 包含 `at` 的**最内层** `fn` 体(绝对区间)。 + /// + /// 作用域是必须的:`mod.rs` 的 `me()` 用元组解构拿 `name`(真数据),而 `register()` 里 + /// 另有一个 `let name = … "用户" …`。没有作用域限制,`me()` 就会被误判成泄漏。 + fn enclosing_fn_span(src: &str, at: usize) -> Option<(usize, usize)> { + let b = src.as_bytes(); + let mut best: Option<(usize, usize)> = None; + let mut from = 0usize; + while let Some(rel) = src[from..].find("fn ") { + let start = from + rel; + if start >= at { + break; + } + from = start + 3; + let mut k = start + 3; + while k < b.len() && (b[k].is_ascii_alphanumeric() || b[k] == b'_') { + k += 1; + } + if k == start + 3 { + continue; // `fn(` 是函数指针类型,没有名字 + } + while k < b.len() && b[k].is_ascii_whitespace() { + k += 1; + } + if b.get(k) != Some(&b'(') && b.get(k) != Some(&b'<') { + continue; + } + let Some(brace) = src[k..].find('{').map(|o| o + k) else { + continue; + }; + if brace > at { + continue; + } + if let Some(end) = match_rs_delim(src, brace, b'{', b'}') { + if end > at { + best = Some((start, end)); + } + } + } + best + } + + /// 跳过嵌套 `json!(...)`:那些区域由外层循环自己扫,别在绑定 RHS 里重复计入 + ///(否则 `let user_id = { … json!({ "error": "…" }) … }` 会把**错误文案**算成数据文案)。 + fn strip_json_macros(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut i = 0usize; + while i < text.len() { + if text[i..].starts_with("json!") { + let after = i + "json!".len(); + if let Some(open) = text[after..].find('(').map(|o| o + after) { + if let Some(end) = match_rs_delim(text, open, b'(', b')') { + i = end; + continue; + } + } + } + let ch = text[i..].chars().next().expect("i 在字符边界上"); + out.push(ch); + i += ch.len_utf8(); + } + out + } + + /// 同函数内 `at` 之前**最近**的 `let = ` 的右值(已剥掉嵌套 `json!`)。 + fn local_binding_rhs(src: &str, ident: &str, at: usize) -> Option { + let (fstart, fend) = enclosing_fn_span(src, at)?; + let b = src.as_bytes(); + let mut chosen: Option = None; + let mut from = fstart; + while let Some(rel) = src[from..fend].find("let ") { + let abs = from + rel; + from = abs + 4; + let mut k = abs + 4; + let id_start = k; + while k < fend && (b[k].is_ascii_alphanumeric() || b[k] == b'_') { + k += 1; + } + if &src[id_start..k] != ident { + continue; + } + while k < fend && b[k].is_ascii_whitespace() { + k += 1; + } + if b.get(k) == Some(&b':') && b.get(k + 1) == Some(&b'=') { + k += 2; + } else if b.get(k) == Some(&b'=') { + k += 1; + } else { + continue; + } + if abs >= at { + break; + } + chosen = Some(k); + } + let start = chosen?; + let (mut depth, mut i) = (0i32, start); + while i < b.len() { + if b[i] == b'"' { + i = read_rs_string(src, i)?.1; + continue; + } + match b[i] { + b'(' | b'[' | b'{' => depth += 1, + b')' | b']' | b'}' => { + if depth == 0 { + break; + } + depth -= 1; + } + b';' if depth == 0 => break, + _ => {} + } + i += 1; + } + Some(strip_json_macros(&src[start..i])) + } + + /// 文本里全部含 CJK 的字面量。 + fn cjk_literals(text: &str) -> Vec { + let mut out = Vec::new(); + let mut i = 0usize; + while i < text.len() { + if text.as_bytes()[i] == b'"' { + if let Some((lit, end)) = read_rs_string(text, i) { + if lit.chars().any(is_cjk) { + out.push(lit); + } + i = end; + continue; + } + } + i += 1; + } + out + } + + /// `j` 处的值表达式原文(到顶层 `,` / `}` 为止);字符串原样保留(含引号)。 + fn json_value_expression(region: &str, j: usize) -> String { + let b = region.as_bytes(); + let (mut depth, mut i) = (0i32, j); + let mut out = String::new(); + while i < b.len() { + if b[i] == b'"' { + match read_rs_string(region, i) { + Some((_, end)) => { + out.push_str(®ion[i..end]); + i = end; + continue; + } + None => break, + } + } + match b[i] { + b'(' | b'[' | b'{' => depth += 1, + b')' | b']' | b'}' => { + if depth == 0 { + break; + } + depth -= 1; + } + b',' if depth == 0 => break, + _ => {} + } + let ch = region[i..].chars().next().expect("i 在字符边界上"); + out.push(ch); + i += ch.len_utf8(); + } + out + } + + /// 以 `json!` **数据**字段(key ≠ `error`)交付的中文字面量,返回 `(字面量, 位置)`。 + /// + /// 三条形态规则,缺一条就会漏掉本轴的一半: + /// 1. 直接写字面量:`json!({ "name": "中文" })`; + /// 2. 表达式里的字面量:`json!({ "name": format!("中文 {x}") })`; + /// 3. **穿透本地绑定**:`let name = … "中文" …; json!({ "name": name })` —— + /// 计划名的自造标签正是这种写法(`match p.type_ { "paygo" => "API(按量)" }`), + /// 不穿透就看不见它,门禁会在**改前就是绿的**。 + fn data_field_cjk_literals(file: &str, src: &str) -> Vec<(String, String)> { + let stripped = strip_rust_comments(src); + let src = cut_rust_test(&stripped); + let mut out = Vec::new(); + let mut from = 0usize; + while let Some(rel) = src[from..].find("json!") { + let at = from + rel; + from = at + "json!".len(); + let Some(open) = src[from..].find('(').map(|o| o + from) else { + continue; + }; + let Some(end) = match_rs_delim(src, open, b'(', b')') else { + continue; + }; + let region = &src[open + 1..end]; + let mut i = 0usize; + while i < region.len() { + if region.as_bytes()[i] != b'"' { + i += 1; + continue; + } + let Some((key, after)) = read_rs_string(region, i) else { + break; + }; + let mut j = after; + while j < region.len() && region.as_bytes()[j].is_ascii_whitespace() { + j += 1; + } + if region.as_bytes().get(j) != Some(&b':') { + i = after; + continue; + } + j += 1; // 跳过冒号本身 —— 漏掉这一步会把**键**当成值表达式,整条扫描静默失效 + while j < region.len() && region.as_bytes()[j].is_ascii_whitespace() { + j += 1; + } + let line = src[..open + 1 + i].matches('\n').count() + 1; + i = after; + if key == "error" { + continue; + } + if region.as_bytes().get(j) == Some(&b'"') { + if let Some((lit, _)) = read_rs_string(region, j) { + if lit.chars().any(is_cjk) { + out.push((lit, format!("{file}:{line} 字段 `{key}`"))); + } + } + continue; + } + let ident_end = { + let mut k = j; + while k < region.len() + && (region.as_bytes()[k].is_ascii_alphanumeric() + || region.as_bytes()[k] == b'_') + { + k += 1; + } + k + }; + let next = region.as_bytes().get(ident_end).copied(); + let is_binding = ident_end > j + && !region.as_bytes()[j].is_ascii_digit() + && next != Some(b'(') + && next != Some(b'!'); + if is_binding { + let ident = ®ion[j..ident_end]; + if let Some(rhs) = local_binding_rhs(src, ident, open + 1 + j) { + for lit in cjk_literals(&rhs) { + out.push(( + lit, + format!("{file}:{line} 字段 `{key}`(经由 `let {ident}`)"), + )); + } + continue; + } + } + for lit in cjk_literals(&json_value_expression(region, j)) { + out.push((lit, format!("{file}:{line} 字段 `{key}`"))); + } + } + } + out + } + + /// `src/` 下的全部 `.rs`(递归):名册由文件系统派生,新增子目录不会静默逃逸。 + fn rust_sources_under_src(root: &str) -> Vec { + let mut stack = vec!["src".to_string()]; + let mut out = Vec::new(); + while let Some(dir) = stack.pop() { + let entries = std::fs::read_dir(format!("{root}/{dir}")) + .unwrap_or_else(|_| panic!("应能读取 {dir}/")); + for e in entries.filter_map(|e| e.ok()) { + let rel = format!("{dir}/{}", e.file_name().to_string_lossy()); + if e.path().is_dir() { + stack.push(rel); + } else if rel.ends_with(".rs") { + out.push(rel); + } + } + } + out.sort(); + out + } + + /// 不变量:**后端不得自造中文显示文案塞进响应的数据字段**(C2133)。 + /// + /// 咽喉 `api.js` 只把 `error` 字段交给 `mapErr`(词表见 `every_backend_error_message_ + /// reaches_the_wordlist`),数据字段是前端**原样渲染**的 ⇒ 后端在数据字段里放一句中文, + /// `en` 界面上就是中文,而 `cargo test` 全绿。修前实测两处可达(`en` 语言包下,jsdom 启真 + /// `index.html` + 四脚本):① `GET /api/admin/usage` 的部门桶名 `(未分配)` → `#usage-dept`; + /// ② `GET /api/plans` 的 plan 兜底名 `API(按量)`(config 的 `[[plans]]` 全都不写 `name` + /// ⇒ 恒触发)→ `#sf-plan` 与上架 toast。 + /// + /// 断言形态是**名册等价**而不是「一条都没有」:用户数据里的中文是合法的(账号名、用户自己 + /// 填的部门名)。判据是「后端**自己编**了一句给用户看的话」—— 那种话归语言包。 + #[test] + fn backend_data_fields_are_language_neutral() { + let root = env!("CARGO_MANIFEST_DIR"); + let mut flagged: BTreeMap = BTreeMap::new(); + for rel in rust_sources_under_src(root) { + let src = std::fs::read_to_string(format!("{root}/{rel}")) + .unwrap_or_else(|_| panic!("应能读取 {rel}")); + for (lit, site) in data_field_cjk_literals(&rel, &src) { + flagged.insert(lit, site); + } + } + + let got: Vec = flagged.keys().cloned().collect(); + let mut want: Vec = DATA_LABEL_EXEMPTIONS + .iter() + .map(|(l, _)| l.to_string()) + .collect(); + want.sort(); + want.dedup(); + + assert_eq!( + got, want, + "响应数据字段里的中文字面量与已裁定清单不一致 ——\n\ + 左=实际提取到的(提取器与后端源码无关,它会随源码变),右=裁定清单。\n\ + 多出来的:这不是错误文案(`error` 字段有 ERR_MAP 兜底),`en` 界面会原样显示中文;\ + 改法是让后端回传 config / 库里的**原值**或语言中性标记,把显示文案搬到客户端语言包。\n\ + 少掉的:清单在腐烂 —— 删掉那个字面量时请一并删掉它的豁免条目。\n\ + 实测:{flagged:#?}\n\ + 豁免清单:{DATA_LABEL_EXEMPTIONS:#?}" + ); + + // 提取器自证:不能是靠「什么都没扫到」通过的(清单非空 ⇒ 这一条同时是阳性对照) + assert!( + !DATA_LABEL_EXEMPTIONS.is_empty(), + "豁免清单为空时上面的等号会退化成「后端一条中文数据文案都没有」,\ + 请确认那是有意为之,而不是提取器失真" + ); + } + + /// 阴性/阳性对照:把「漏」与「误收」两种失真都注入合成输入,证明上面那条断言有牙齿。 + #[test] + fn data_field_scanner_detects_injected_labels() { + let sample = r#" +fn f(cfg: &Plan) { + json!({ "name": "中文甲" }); + json!({ "error": "中文乙" }); + json!({ "nested": { "label": "中文丙" } }); + let name = if cfg.name.is_empty() { "中文丁".to_string() } else { cfg.name.clone() }; + json!({ "name": name }); + let user_id = { if bad() { return Err(json!({ "error": "中文戊" })); } 5 }; + json!({ "id": user_id }); + json!({ "note": format!("中文己 {x}") }); + let decoy = "中文庚"; +} +fn g(row: (String, String, String)) { + let (email, name, role) = row; + json!({ "name": name }); +} +#[cfg(test)] +mod tests { fn t() { json!({ "x": "测试中文" }) } } +"#; + let got: Vec = data_field_cjk_literals("sample.rs", sample) + .into_iter() + .map(|(lit, _)| lit) + .collect(); + + // 注意 `中文己 {x}` 比对的是字面量的**原文**(含占位符):提取器交出来的就是源码里的 + // 那个串,门禁的豁免清单也按原文记账 —— 换成「已格式化的样子」两侧就永远对不上。 + for want in ["中文甲", "中文丙", "中文丁", "中文己 {x}"] { + assert!( + got.iter().any(|g| g == want), + "提取器漏掉 {want:?} —— 漏掉一种形态就等于把那一半的类放行,实得 {got:?}" + ); + } + for unwanted in [ + "中文乙", // `error` 字段:由 ERR_MAP 那条门禁负责 + "中文戊", // 绑定 RHS 里**嵌套** json! 的错误文案,不得算作数据文案 + "中文庚", // 与 json! 无关的局部变量 + "测试中文", // `#[cfg(test)]` 之后 + ] { + assert!( + !got.iter().any(|g| g == unwanted), + "{unwanted:?} 不该被算作响应数据字段文案,实得 {got:?}" + ); + } + // 元组解构绑定的是**真数据**(用户/部门的实际名字):必须按**函数作用域**解析绑定, + // 否则 `g()` 里那个 `name` 会解析到 `f()` 里更早的 `let name`,把 中文丁 数第二遍。 + assert_eq!( + got.iter().filter(|g| *g == "中文丁").count(), + 1, + "绑定解析必须限定在**同一个函数**内,实得 {got:?}" + ); + } + + /// 另一半(C2133):后端只回传语言中性标记之后,标签必须由客户端补上。 + /// + /// 只钉生产者(后端无中文)会漏掉「前端把空串直接渲染成空白标签」这条半修; + /// 只钉消费者则会漏掉「后端继续自造中文」。两半各钉一个方向。 + #[test] + fn backend_neutral_data_labels_are_localized_in_the_client() { + let app = strip_js_comments(APP_JS); + + // ① 用量卡片:无部门桶(后端回空串)必须有语言包兜底 + // 注意:`#usage-dept` 在同一个函数里出现**两次**(先清空、后渲染)。按第一次命中切语句 + // 会拿到那句清空(里面根本没有 `barRow`)⇒ 门禁报出一条与产品无关的假红(坑 #286 家族)。 + let stmt = statement_containing_all(&app, &["$(\"#usage-dept\").innerHTML", "barRow("]) + .expect("找不到 #usage-dept 经 barRow 渲染的那条语句"); + let arg = first_call_arg(stmt, "barRow(").expect("部门条应经 barRow 渲染"); + assert!( + arg.contains("d.name"), + "部门条的首参应是该行的部门名,实得 {arg:?}" + ); + assert!( + arg.contains("T("), + "无部门桶的标签必须由语言包提供(`d.name || T(\"common.unassigned\")`)——\ + 后端已经不再自造它了,前端不兜底就只剩一个空标签:{arg:?}" + ); + + // ② Plan 显示名:一个函数、两个渲染点 + let body = js_function_body(&app, "function planLabel(") + .expect("应有 planLabel(config 没写 name 时按 type 取语言包)"); + assert!( + body.contains("pl.name") && body.contains("T(\"share.planName."), + "planLabel 必须先看 config 原名、再按 type 取语言包,实得 {body:?}" + ); + for (site, needle) in [ + ("上架表单的 Plan 下拉", "selPlan.innerHTML"), + ("上架成功的 toast", "const label = provLabel(plan.provider)"), + ] { + let stmt = statement_containing(&app, needle) + .unwrap_or_else(|| panic!("找不到 {site} 的渲染语句(`{needle}`)")); + assert!( + stmt.contains("planLabel("), + "{site} 必须经 planLabel 渲染 —— 直接读 `plan.name` 会让 config 未配置时显示空标签:{stmt:?}" + ); + } + } + + /// `src` 中 `needle` 之后那个调用的**第一个实参**(括号配平,到顶层 `,` 为止)。 + fn first_call_arg<'a>(src: &'a str, needle: &str) -> Option<&'a str> { + let at = src.find(needle)? + needle.len(); + let b = src.as_bytes(); + let (mut depth, mut i, mut end) = (0i32, at, None); + while i < b.len() { + if b[i] == b'"' || b[i] == b'\'' || b[i] == b'`' { + let q = b[i]; + i += 1; + while i < b.len() { + if b[i] == b'\\' { + i += 2; + continue; + } + if b[i] == q { + i += 1; + break; + } + i += 1; + } + continue; + } + match b[i] { + b'(' | b'[' | b'{' => depth += 1, + b')' | b']' | b'}' => { + if depth == 0 { + end = Some(i); + break; + } + depth -= 1; + } + b',' if depth == 0 => { + end = Some(i); + break; + } + _ => {} + } + i += 1; + } + Some(&src[at..end?]) + } } diff --git a/src/routes/admin.rs b/src/routes/admin.rs index 38b2163..aaff5bb 100644 --- a/src/routes/admin.rs +++ b/src/routes/admin.rs @@ -262,9 +262,13 @@ pub async fn usage( } } // 按部门 + // 无部门(`d.name IS NULL`)用空串这个**语言中性**标记,显示文案归前端语言包 + //(C2133:响应**数据**字段里由后端自造的中文会被 `en` 界面原样渲染 —— + // `I18n.mapErr` 只看 `error` 字段;前端用 `T("common.unassigned")` 兜底, + // 与上面 `users[].dept_name` 的 `COALESCE(d.name, '')` 同一口径) let mut stmt = conn .prepare( - "SELECT COALESCE(d.name, '(未分配)'), COALESCE(SUM(ur.tokens), 0), COALESCE(SUM(ur.cost), 0), COUNT(ur.id) \ + "SELECT COALESCE(d.name, ''), COALESCE(SUM(ur.tokens), 0), COALESCE(SUM(ur.cost), 0), COUNT(ur.id) \ FROM usage_records ur JOIN users u ON u.id = ur.user_id \ LEFT JOIN departments d ON d.id = u.dept_id \ WHERE ur.time >= date('now', 'start of month') \ diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 5c21210..ab33f98 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -2866,6 +2866,77 @@ mod tests { assert_eq!(d["cost"], 2.5); } + /// C2133:没有部门的那个桶,名字必须是**语言中性**的 —— 后端不得自造显示文案。 + /// + /// 修前 SQL 是 `COALESCE(d.name, '(未分配)')`,而前端对 `departments[].name` 只过 `esc()` + /// (`app.js` 的 `barRow(d.name, …)` → `#usage-dept`)⇒ 只要有一个未分配部门且本月有用量的 + /// 用户,`en` 界面的用量卡片上就出现中文。现在后端回空串,标签由前端 + /// `T("common.unassigned")` 提供(同一个键、同一张页面的成员表早就在用)。 + #[tokio::test] + async fn usage_department_bucket_without_a_department_is_language_neutral() { + fn is_cjk(c: char) -> bool { + matches!(c, '\u{3400}'..='\u{4dbf}' | '\u{4e00}'..='\u{9fff}' | '\u{f900}'..='\u{faff}') + } + + let st = test_state("usagedeptneutral"); + let admin = login_bearer(&st, "admin@aitokenpool.local", "admin1234").await; + // demo 未分配部门 ⇒ 它的用量落进「无部门」那个桶 + { + let conn = st.db.lock().unwrap(); + conn.execute( + "INSERT INTO usage_records (user_id, model, tokens, cost) VALUES (1, 'gpt-test', 1000, 2.5)", + [], + ) + .unwrap(); + } + let (s, body) = get(st.clone(), "/api/admin/usage", Some(&admin)).await; + assert_eq!(s, StatusCode::OK, "usage 应 200: {body}"); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + + // ① 不变量:桶名语言中性(显示文案归前端语言包) + let depts = v["departments"].as_array().unwrap(); + assert_eq!( + depts.len(), + 1, + "只有 demo 有用量且它未分配部门 ⇒ 恰好一个桶: {body}" + ); + let name = depts[0]["name"].as_str().expect("桶名是字符串"); + assert!( + !name.chars().any(is_cjk), + "无部门桶名必须语言中性(en 界面会原样渲染它),实测 {name:?}" + ); + assert_eq!(depts[0]["cost"], 2.5, "桶仍带着聚合值: {body}"); + + // ② 阳性对照:真实部门名(用户数据)原样透传 —— 本不变量只约束**自造标签** + let (_, body) = post( + st.clone(), + "/api/admin/departments", + r#"{"name":"研发","quota":100000}"#, + Some(&admin), + ) + .await; + let dept_id = serde_json::from_str::(&body).unwrap()["id"] + .as_i64() + .unwrap(); + let (_, _) = patch( + st.clone(), + "/api/admin/users/1", + &format!(r#"{{"dept_id":{dept_id}}}"#), + Some(&admin), + ) + .await; + let (s, body) = get(st.clone(), "/api/admin/usage", Some(&admin)).await; + assert_eq!(s, StatusCode::OK, "usage 应 200: {body}"); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + let d = v["departments"] + .as_array() + .unwrap() + .iter() + .find(|x| x["name"] == "研发") + .expect("真实部门名原样透传"); + assert_eq!(d["cost"], 2.5, "换了部门仍是同一条聚合: {body}"); + } + /* ---- C2089:口令 KDF(argon2)必须在共享 DB 互斥量**之外**运行 ---- */ /// 观测「共享 DB 互斥量是否被某个请求长时间攥住」的计数器:另起一条线程反复 `try_lock`, diff --git a/ui/README.md b/ui/README.md index 82bcc88..10dbdec 100644 --- a/ui/README.md +++ b/ui/README.md @@ -433,3 +433,15 @@ ui/ - **CI 覆盖**:`src/state_gate.rs` 再加两条 —— `the_identity_boundaries_drop_every_session_cache`(清空必须含 `Object.keys(Live)`;体内**不得**出现逐个槽的赋值,否则就是第二份名册;`loadSession` 与 `exitGuest` 都必须调用它)与 `the_view_router_renders_and_loads_in_every_branch`(`renderView` 每个分支行都既含 `render` 又含 `load`)。两条都带**提取器自证**与**合成输入**(手抄名册 / 缺 loader 的分支必须变红)。 - **冒烟测试注意**:① 夹具必须让两个账号的钱包**可区分**,且 **`available ≠ balance`**(有当日赠送)—— 否则「拿到了自己的永久余额」与「回落到 available」不可区分;② 冻结点要**精确**:`loadSession` 自己的 `/api/wallet` 必须放行(否则会话建立就卡住),只扣住**仪表盘刷新**那一次(按序放行第 1 个、冻结第 2 个);③ 隔离瞬态脸时,乙的**落点**必须是仪表盘 —— 若乙落在钱包,钱包的新 loader 会在登录过程中就把共享的 `Live.wallet` 刷新掉,瞬态脸**看不见**(这正是「只加 loader 不清缓存」这条竞争修法能让探针全绿的原因);④ 断言按**读到的 DOM 文本**,不要读 `Live` 内部(探针可临时注入 `window.__Live = Live` 仅用于**诊断**)。 + +## 后端自造的显示文案:分界线在「数据字段 / 文案字段」(C2133) + +- **咽喉只覆盖文案字段**:`api.js` 把后端 `error` 字段整串交给 `I18n.mapErr()`(词表 `ERR_MAP`),所以**错误文案**有兜底(C2129)。但**响应数据字段**是前端原样渲染的 —— 后端在数据字段里自造一句中文,`en` 界面上就是中文,而 `cargo test` 全绿。 +- **不变量**:**显示文案归语言包,后端只回传数据或语言中性标记**。数据字段里的值要么来自 config / 库 / 用户输入(原值透传),要么是语言中性的机器值(空串 / `null` / 枚举名)。中文标签只允许出现在 `error` 字段(有词表)和邮件正文(无 locale 机制、中英双语)里。 +- **修前两处可达**(都在 `en` 界面直接显示中文): + 1. `src/routes/admin.rs` 的按部门聚合:`COALESCE(d.name, '(未分配)')` → `app.js` 的 `#usage-dept` 只过 `esc()`。任何 `dept_id IS NULL` 且本月有用量的用户都会让这行出现 —— 而**同一张页面**的成员表早就在用 `T("common.unassigned")`(同一个键、零新增)。 + 2. `src/gateway.rs` 的 `/api/plans`:config 未写 `name` 时后端按 `type` 自造 `API(按量)` / `Token Plan` / `Coding Plan`;而 `config.example.toml` 与 `config.toml` 的 `[[plans]]` **全都**不写 `name` ⇒ 恒触发,上架表单的 Plan 下拉与上架成功的 toast 都读它。 +- **改法**:① 无部门的桶改用**空串**(与同一个 handler 里 `users[].dept_name` 的 `COALESCE(d.name, '')` 同口径),前端 `d.name || T("common.unassigned")` 兜底;② `/api/plans` 的 `name` 改为 config **原值**(未配置即空串),前端新增 `planLabel(pl)`:有 `name` 用原文,否则按 `type` 取新键 `share.planName.paygo|token|coding`(下拉与 toast 共用这一个函数)。 +- **CI 覆盖**:`src/i18n_pack.rs` 三条 —— ① `backend_data_fields_are_language_neutral`:扫全部 `src/**/*.rs`,提取「以 `json!` **数据**字段(key ≠ `error`)交付的中文字面量」,其集合必须**恰好等于**已裁定豁免清单(两侧都有牙:新增一处变红、删掉豁免项也变红)。提取器必须能穿透 `let name = … "中文" …; json!({ "name": name })` 这层间接(计划名就是这种写法;不穿透就漏掉一半的类),并跳过嵌套 `json!`;② 这两个渲染点必须**有**本地化兜底(`#usage-dept` 的 `barRow` 首参含 `T(`、`planLabel` 体内含 `pl.name` 与 `T("share.planName.…")`);③ `GET /api/admin/usage` 的运行期契约:无部门用户有用量时,桶名不得含 CJK(`src/routes/mod.rs` 的 router 测试)。 +- **豁免清单为什么存在**:`src/routes/mod.rs` 注册接口的 `"name": name` 里那个默认用户名(`email.split('@').next().unwrap_or(...)`)是**用户数据**的默认值(同 `db.rs` 种子里的 `'管理员'`),不是后端自造的显示标签 —— 且 `split().next()` 恒 `Some`,该默认值不可达。豁免项带**理由**、且与提取结果**等价**(`==`,不是 `⊆`),所以它不会腐烂。 +- **冒烟测试注意**:`en` 语言包下断言 `#usage-dept` / `#sf-plan` 的**渲染后文本**不含 CJK(改前红)。夹具要让 `departments` 桶真的出现(`dept_id IS NULL` + 本月 `usage_records`),并**独立构造**期望值(`T("common.unassigned")` / `T("share.planName.paygo")` 现取,不要抄后端回传的串);阴性对照腿用**配了 `name` 的 plan**(此时必须原样显示 config 的名字)。 diff --git a/ui/index.html b/ui/index.html index 6fd2aab..3bfdb53 100644 --- a/ui/index.html +++ b/ui/index.html @@ -844,7 +844,7 @@

使用模型

- - + + diff --git a/ui/js/app.js b/ui/js/app.js index 15c4f20..1e1e011 100644 --- a/ui/js/app.js +++ b/ui/js/app.js @@ -436,6 +436,17 @@ el.textContent = pl.type === "paygo" ? T("share.plan.paygo") : T("share.plan.sub"); } + // Plan 显示名(C2133):config 写了 name 就用它的原文,否则按 type 取语言包。 + // 后端只回传 config 原值(未配置 = 空串,语言中性)——显示文案归语言包,否则 + // `en` 界面会把响应数据字段里的后端自造中文原样印出来(mapErr 只认 `error` 字段)。 + function planLabel(pl) { + if (pl.name) return pl.name; + if (pl.type === "paygo") return T("share.planName.paygo"); + if (pl.type === "token") return T("share.planName.token"); + if (pl.type === "coding") return T("share.planName.coding"); + return pl.id; + } + /* ---------------- 导航 ---------------- */ // 统一内联 SVG 图标(线性风格、同尺寸、currentColor,替代 emoji;rant 15:50:05 A.2) @@ -990,7 +1001,7 @@ const fillPlans = () => { const p = selP.value; selPlan.innerHTML = '" + plans.filter((pl) => pl.provider === p) - .map((pl) => '").join(""); + .map((pl) => '").join(""); showPlanHint(""); fillModels(); }; @@ -2424,10 +2435,10 @@ '
' + T("cnt.calls", { n: x.month_calls || 0 }) + "
" + T("admin.usage.emp.calls") + "
" ).join("") + barRow(T("admin.usage.total"), users.reduce((a, x) => a + (x.month_tokens || 0), 0), maxUT, T("admin.usage.unit.tokens")) : '
' + EMPTY_ICON + "

" + T("admin.usage.empty.emp") + "

"; - // 按部门(barRow 用 cost 归一) + // 按部门(barRow 用 cost 归一);无部门的桶后端回传空串(语言中性)⇒ 本地取语言包 const maxDC = Math.max(1, ...depts.map((d) => d.cost || 0)); $("#usage-dept").innerHTML = depts.length - ? depts.map((d) => barRow(d.name, d.cost, maxDC, T("admin.usage.unit.yuan"))).join("") + ? depts.map((d) => barRow(d.name || T("common.unassigned"), d.cost, maxDC, T("admin.usage.unit.yuan"))).join("") : '
' + EMPTY_ICON + "

" + T("admin.usage.empty.dept") + "

"; } else if (tab === "org") { renderOrg(); @@ -3779,7 +3790,7 @@ const p = $("#sf-provider"); p.value = ""; p.dispatchEvent(new Event("change")); $("#sf-quota").value = 5000; hideShareForm(); - const label = provLabel(plan.provider) + " · " + plan.name; + const label = provLabel(plan.provider) + " · " + planLabel(plan); toast(T("share.list.ok", { label: label, model: model, price: D.fmt(price) }), "success"); }; if (!loggedIn()) { diff --git a/ui/js/i18n.js b/ui/js/i18n.js index 33d1f1f..3c8284d 100644 --- a/ui/js/i18n.js +++ b/ui/js/i18n.js @@ -308,6 +308,9 @@ "share.select.model": "选择模型", "share.plan.paygo": "按量计价的 key", "share.plan.sub": "订阅 Plan", + "share.planName.paygo": "API(按量)", + "share.planName.token": "Token Plan", + "share.planName.coding": "Coding Plan", "share.price.auto": "{n} 点 / 1M 输出(自动)", "share.price.default": "按默认价:{n} 点 / 1M 输出(自动)", "share.priceUnit": "点/1M", @@ -1144,6 +1147,9 @@ "share.select.model": "Select model", "share.plan.paygo": "Pay-as-you-go key", "share.plan.sub": "Subscription Plan", + "share.planName.paygo": "API (pay-as-you-go)", + "share.planName.token": "Token Plan", + "share.planName.coding": "Coding Plan", "share.price.auto": "{n} pts / 1M output (auto)", "share.price.default": "Default price: {n} pts / 1M output (auto)", "share.priceUnit": "pts/1M", From 0b3b99ea6824ddaa2f58c240096c88631ba71e3b Mon Sep 17 00:00:00 2001 From: argszero Date: Tue, 15 Sep 2026 07:24:09 +0800 Subject: [PATCH 2/3] test(i18n): give the data-label exemption roster a deliberate budget --- src/i18n_pack.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/i18n_pack.rs b/src/i18n_pack.rs index a27e796..6765847 100644 --- a/src/i18n_pack.rs +++ b/src/i18n_pack.rs @@ -2486,6 +2486,15 @@ mod tests { "豁免清单为空时上面的等号会退化成「后端一条中文数据文案都没有」,\ 请确认那是有意为之,而不是提取器失真" ); + // 名册是**日落清单**,不是注册表:这个类里正确的修法是「后端回传语言中性标记、显示文案 + // 归客户端语言包」,**不是**「把自造的中文登记进来」。所以名额刻意只有 1 条,扩容必须 + // 显式改这个数 —— 加之前先回答:这句话能不能由语言包说?能,就别加。 + //(竞争修法腿就是这么被抓的:保留后端自造名 + 往清单里塞一条,等号那一半会放行,这一半不会。) + assert_eq!( + DATA_LABEL_EXEMPTIONS.len(), + 1, + "豁免清单只收**用户数据的默认值**(不是自造标签);确需扩容请同时改这个数 —— 故意的减速带" + ); } /// 阴性/阳性对照:把「漏」与「误收」两种失真都注入合成输入,证明上面那条断言有牙齿。 From 3d74b07295f44f35167e2093b81b8465a8de9ee9 Mon Sep 17 00:00:00 2001 From: argszero Date: Tue, 15 Sep 2026 07:36:11 +0800 Subject: [PATCH 3/3] fix(i18n): stop the backend from inventing Chinese display labels in response data fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client localizes the `error` field (`api.js` hands it to `I18n.mapErr()`, wordlist in `ui/js/i18n.js` — see #249). Response **data** fields have no such path: the UI renders them raw. So a backend that invents a Chinese label and puts it in a data field prints Chinese on the `en` interface, and `cargo test` stays green. Two reachable cases: 1. `GET /api/admin/usage` — the department bucket was `COALESCE(d.name, '(未分配)')` and `app.js` prints it through `barRow()` into `#usage-dept`. Any user with `dept_id IS NULL` and usage this month puts Chinese on an English screen — while the member table on the *same page* has used `T("common.unassigned")` all along. 2. `GET /api/plans` — when config leaves `name` unset the backend derived a display name from `type` (`API(按量)` / `Token Plan` / `Coding Plan`). Neither `config.toml` nor `config.example.toml` sets `name` for any of the `[[plans]]`, so it always fired, into `#sf-plan` and the share toast. Fix — the backend returns data or a language-neutral marker, the client owns the wording: - the no-department bucket is `''` (same shape as the neighbouring `users[].dept_name`), and the client renders `d.name || T("common.unassigned")` — an existing key, zero new ones; - `/api/plans` returns `p.name` verbatim (empty when unset), and the client gets `planLabel(pl)`: config's own name when present, else a new key per type (`share.planName.paygo|token|coding`), reused by both render sites; - the plan dropdown is now rebuilt when its **data source** changes (`dataset.plansSrc`) instead of once (`dataset.init`). The view renders before `loadSharing()` resolves, so the fallback table `D.PLANS` always built the dropdown first and the one-shot guard then kept it forever — the backend fix was invisible on that control. The cascading fills read the source at call time, so the once-registered listeners cannot pin the old table either. Three CI contracts, one per direction: - `i18n_pack::backend_data_fields_are_language_neutral` — scans every `src/**/*.rs` and extracts Chinese literals delivered through a `json!` data field (key ≠ `error`), including through a local `let` binding (the plan-name fallback was written that way; without that layer the gate is green on the pre-change tree). The result must be *exactly* the adjudicated exemption list — a new invented label turns it red, a stale exemption turns it red too, and the list has a budget of one, because it is a sunset list rather than a registry (the tempting "keep inventing, register the label" fix has to edit a second place to pass). Scope is stated in its doc comment: values manufactured by SQL (`COALESCE(..., '中文')`) are covered by the runtime contract instead. - `i18n_pack::backend_neutral_data_labels_are_localized_in_the_client` — both render sites must have the localized fallback (otherwise a neutral backend just yields a blank label), and the dropdown must be rebuilt from source evidence rather than a one-shot flag. - `routes::tests::usage_department_bucket_without_a_department_is_language_neutral` — the runtime contract: with a `dept_id IS NULL` user who has usage this month, the bucket name carries no CJK, and a real department name still passes through verbatim. The one exemption (`routes::mod.rs` registration's `unwrap_or("用户")`) is a *user-data* default — the same kind of value as an account name — and is unreachable (`split('@').next()` is always `Some`); it is not an invented display label. A/B — the JS probe (`tmp/c2133_probe.js`; jsdom, real `index.html` + 4 real scripts, only `fetch` stubbed, `atp_lang=en` forced, every check compared against its **expectation** so the pre-change leg's demonstration checks are not confused with acceptance failures): | leg | mismatched checks | |-----|-------------------| | working tree | `{}` — 17/17 | | pre-change tree (client + payloads from `origin/main`) | `{B2b,D1,D2,E2,F1,F2}` | | render sites read `pl.name` raw (backend neutral, no client label) | `{A2b,D2}` | | dropdown keeps the one-shot guard (no rebuild) | `{F1,F2}` | | dropdown reads a source snapshot taken at first render | `{B2b,D2}` | | no-department bucket loses its localized fallback | `{D1,E2}` | Each leg is generated by string substitution from the working tree, so the red sets are reproducible; `F1`/`F2` (the provider list must follow the live plan source) are what make the rebuild half observable at all — without them that leg reads green, which is how the one-shot guard survived the first pass. Rust side, `cargo test` per leg (mutated in place, restored against `HEAD` with md5 + `git diff --stat` + `diff` checks) — see the PR body for the recorded sets. Same axis, deliberately not bundled (recorded in `ui/README.md`): `ui/js/data.js` carries its own `PLANS[].name` display labels (`API(按量)`, `Kimi Code 会员`…), which the client shows when `/api/plans` fails. Different producer (a client data table, not a response field) and it needs a brand-vs-generic ruling first — `provLabel()` right next to it is the pattern to copy (`I18n.lang === "zh"` gates the label table; `en` gets the id). --- src/i18n_pack.rs | 13 +++++++++++ ui/README.md | 7 +++--- ui/index.html | 2 +- ui/js/app.js | 60 ++++++++++++++++++++++++++++-------------------- 4 files changed, 53 insertions(+), 29 deletions(-) diff --git a/src/i18n_pack.rs b/src/i18n_pack.rs index 6765847..db6b641 100644 --- a/src/i18n_pack.rs +++ b/src/i18n_pack.rs @@ -2594,6 +2594,19 @@ mod tests { fn t() { json!({ "x": "测试中文" }) } } "{site} 必须经 planLabel 渲染 —— 直接读 `plan.name` 会让 config 未配置时显示空标签:{stmt:?}" ); } + + // ③ 兜底表不得赢过真实清单(C2133 实测的那条路):Plan 下拉框的重建判据必须是**数据源**, + // 而不是「建过没有」。一次性守卫在登录后首次渲染时就把兜底表 `D.PLANS` 定了型 + // (`/api/plans` 那次请求还在路上),它回来后下拉框再也不重建 ⇒ `planLabel` 永远没机会 + // 生效,`en` 界面上显示的就是兜底表里的中文名 —— 光加上 planLabel 是**半修**。 + assert!( + app.contains("selP.dataset.plansSrc"), + "Plan 下拉框丢了「数据源变了才重建」的判据(应比对数据源快照,而不是一次性标志)" + ); + assert!( + !app.contains("selP.dataset.init"), + "Plan 下拉框又回到「一次性初始化」守卫:兜底表会赢到底,真实清单回来后不再重建" + ); } /// `src` 中 `needle` 之后那个调用的**第一个实参**(括号配平,到顶层 `,` 为止)。 diff --git a/ui/README.md b/ui/README.md index 10dbdec..1abad6e 100644 --- a/ui/README.md +++ b/ui/README.md @@ -441,7 +441,8 @@ ui/ - **修前两处可达**(都在 `en` 界面直接显示中文): 1. `src/routes/admin.rs` 的按部门聚合:`COALESCE(d.name, '(未分配)')` → `app.js` 的 `#usage-dept` 只过 `esc()`。任何 `dept_id IS NULL` 且本月有用量的用户都会让这行出现 —— 而**同一张页面**的成员表早就在用 `T("common.unassigned")`(同一个键、零新增)。 2. `src/gateway.rs` 的 `/api/plans`:config 未写 `name` 时后端按 `type` 自造 `API(按量)` / `Token Plan` / `Coding Plan`;而 `config.example.toml` 与 `config.toml` 的 `[[plans]]` **全都**不写 `name` ⇒ 恒触发,上架表单的 Plan 下拉与上架成功的 toast 都读它。 -- **改法**:① 无部门的桶改用**空串**(与同一个 handler 里 `users[].dept_name` 的 `COALESCE(d.name, '')` 同口径),前端 `d.name || T("common.unassigned")` 兜底;② `/api/plans` 的 `name` 改为 config **原值**(未配置即空串),前端新增 `planLabel(pl)`:有 `name` 用原文,否则按 `type` 取新键 `share.planName.paygo|token|coding`(下拉与 toast 共用这一个函数)。 -- **CI 覆盖**:`src/i18n_pack.rs` 三条 —— ① `backend_data_fields_are_language_neutral`:扫全部 `src/**/*.rs`,提取「以 `json!` **数据**字段(key ≠ `error`)交付的中文字面量」,其集合必须**恰好等于**已裁定豁免清单(两侧都有牙:新增一处变红、删掉豁免项也变红)。提取器必须能穿透 `let name = … "中文" …; json!({ "name": name })` 这层间接(计划名就是这种写法;不穿透就漏掉一半的类),并跳过嵌套 `json!`;② 这两个渲染点必须**有**本地化兜底(`#usage-dept` 的 `barRow` 首参含 `T(`、`planLabel` 体内含 `pl.name` 与 `T("share.planName.…")`);③ `GET /api/admin/usage` 的运行期契约:无部门用户有用量时,桶名不得含 CJK(`src/routes/mod.rs` 的 router 测试)。 +- **改法**:① 无部门的桶改用**空串**(与同一个 handler 里 `users[].dept_name` 的 `COALESCE(d.name, '')` 同口径),前端 `d.name || T("common.unassigned")` 兜底;② `/api/plans` 的 `name` 改为 config **原值**(未配置即空串),前端新增 `planLabel(pl)`:有 `name` 用原文,否则按 `type` 取新键 `share.planName.paygo|token|coding`(下拉与 toast 共用这一个函数);③ Plan 下拉框的重建判据从「建过没有」(一次性 `dataset.init`)改成**数据源**(`dataset.plansSrc`)——登录后首次渲染时 `/api/plans` 还在路上,兜底表会先建一次,一次性守卫会让它**赢到底**(实测:真实清单回来后下拉框不再重建,`en` 界面上仍是兜底表里的中文名);级联填充函数也改成**调用时**读数据源,否则只登记一次的监听器会永远指着那份兜底表。 +- **CI 覆盖**:`src/i18n_pack.rs` 三条 —— ① `backend_data_fields_are_language_neutral`:扫全部 `src/**/*.rs`,提取「以 `json!` **数据**字段(key ≠ `error`)交付的中文字面量」,其集合必须**恰好等于**已裁定豁免清单(两侧都有牙:新增一处变红、删掉豁免项也变红;清单只给 1 个名额 —— 它是**日落清单**,不是注册表,扩容要显式改那个数)。提取器必须能穿透 `let name = … "中文" …; json!({ "name": name })` 这层间接(计划名就是这种写法;不穿透就漏掉一半的类),并跳过嵌套 `json!`、跳过 `#[cfg(test)]`;② `backend_neutral_data_labels_are_localized_in_the_client`:两个渲染点必须**有**本地化兜底(`#usage-dept` 的 `barRow` 首参含 `T(`、`planLabel` 体内含 `pl.name` 与 `T("share.planName.…")`),且下拉框必须按**数据源**重建(不得退回一次性守卫);③ `GET /api/admin/usage` 的运行期契约:无部门用户有用量时,桶名不得含 CJK(`src/routes/mod.rs` 的 router 测试)。 - **豁免清单为什么存在**:`src/routes/mod.rs` 注册接口的 `"name": name` 里那个默认用户名(`email.split('@').next().unwrap_or(...)`)是**用户数据**的默认值(同 `db.rs` 种子里的 `'管理员'`),不是后端自造的显示标签 —— 且 `split().next()` 恒 `Some`,该默认值不可达。豁免项带**理由**、且与提取结果**等价**(`==`,不是 `⊆`),所以它不会腐烂。 -- **冒烟测试注意**:`en` 语言包下断言 `#usage-dept` / `#sf-plan` 的**渲染后文本**不含 CJK(改前红)。夹具要让 `departments` 桶真的出现(`dept_id IS NULL` + 本月 `usage_records`),并**独立构造**期望值(`T("common.unassigned")` / `T("share.planName.paygo")` 现取,不要抄后端回传的串);阴性对照腿用**配了 `name` 的 plan**(此时必须原样显示 config 的名字)。 +- **冒烟测试(`tmp/c2133_probe.js`,jsdom 真 `index.html` + 四脚本、只 stub `fetch`、强制 `atp_lang=en`)**:腿 PRE(`origin/main` 的前端 + 老后端那两份载荷)红 `{B1,B2}`;腿 POST(工作树 + 语言中性载荷)10/10 绿;两条**竞争修法**腿各红在**不相交**的一半 —— 「只加 `planLabel`、保留一次性守卫」红 `{B2b,D2}`(下拉框里仍是兜底表的中文名)、「改前前端 + 老载荷」红 `{B1,B2,D1,E2}`(部门半张脸)。控制腿:真实部门名 `研发` 与 config 里配了 `name` 的 plan **原样透传**(本轴只约束**自造**标签)。 +- **同轴未修(记录,勿顺手带上)**:`ui/js/data.js` 的 `PLANS[].name` 是**客户端**自带的一份显示标签(`API(按量)`、`Kimi Code 会员`…)。`/api/plans` 拉取**失败**时前端落回这张表,`en` 界面上就会显示它的中文名(成功路径已由 ③ 的重建判据修好)。它与本 PR 的**生产者**不同(客户端数据表 vs 后端响应字段),且需要先裁定「品牌名放行 / 通用名取语言包」的边界 —— 对照组:`provLabel()` 是**正确**写法(`I18n.lang === "zh"` 才用 `PROVIDER_LABELS`,`en` 直接回 id),`data.js` 的 PLANS 名可以照抄这个形状。 diff --git a/ui/index.html b/ui/index.html index 3bfdb53..e7bee3d 100644 --- a/ui/index.html +++ b/ui/index.html @@ -845,6 +845,6 @@

使用模型

- + diff --git a/ui/js/app.js b/ui/js/app.js index 1e1e011..99f75bc 100644 --- a/ui/js/app.js +++ b/ui/js/app.js @@ -981,34 +981,44 @@ // 表单下拉(厂商 → Plan → 模型 三级联动;Plan 中「API」= 按量计价的 key) const selP = $("#sf-provider"); - if (!selP.dataset.init) { - // Bug 1 修复:优先用 /api/plans(后端 config [[plans]]),未登录/失败降级 data.js 对齐清单 - const plans = Live.plans || D.PLANS; - const planProviders = [...new Set(plans.map((pl) => pl.provider))]; - selP.innerHTML = '" + planProviders - .map((p) => '").join(""); - const selPlan = $("#sf-plan"); - const selM = $("#sf-model"); - const fillModels = () => { - const plan = plans.find((pl) => pl.id === selPlan.value); - const p = plan ? plan.provider : selP.value; - // 零 mock(rant 15:54:06):模型下拉登录态用 /api/models(Live.models),游客/兜底 data.js - const modelSrc = Live.models ? Live.models : D.MODELS; - selM.innerHTML = '" + modelSrc.filter((m) => !p || m.provider === p) - .map((m) => '").join(""); - showPriceHint(selM.value); - }; - const fillPlans = () => { - const p = selP.value; - selPlan.innerHTML = '" + plans.filter((pl) => pl.provider === p) - .map((pl) => '").join(""); - showPlanHint(""); - fillModels(); - }; + const selPlan = $("#sf-plan"); + const selM = $("#sf-model"); + // 当前清单:优先 /api/plans(后端 config [[plans]] 单一真源),未登录/拉取失败降级 data.js。 + // 每次读(不是捕获一份副本):登录后首次渲染时 `Live.plans` 还没回来,随后会被真实清单替换, + // 而监听器只在第一次渲染时登记一次 —— 捕获副本的写法会让监听器永远指着那份兜底表。 + const plansSrc = () => Live.plans || D.PLANS; + const fillModels = () => { + const plan = plansSrc().find((pl) => pl.id === selPlan.value); + const p = plan ? plan.provider : selP.value; + // 零 mock(rant 15:54:06):模型下拉登录态用 /api/models(Live.models),游客/兜底 data.js + const modelSrc = Live.models ? Live.models : D.MODELS; + selM.innerHTML = '" + modelSrc.filter((m) => !p || m.provider === p) + .map((m) => '").join(""); + showPriceHint(selM.value); + }; + const fillPlans = () => { + const p = selP.value; + selPlan.innerHTML = '" + plansSrc().filter((pl) => pl.provider === p) + .map((pl) => '").join(""); + showPlanHint(""); + fillModels(); + }; + // 监听器只登记一次(重建下拉框不该重复登记,否则一次 change 会级联跑两遍) + if (!selP.dataset.wired) { selP.addEventListener("change", fillPlans); selPlan.addEventListener("change", () => { showPlanHint(selPlan.value); fillModels(); }); selM.addEventListener("change", () => showPriceHint(selM.value)); - selP.dataset.init = "1"; + selP.dataset.wired = "1"; + } + // 重建的判据是**数据源**,不是「建过没有」(C2133):登录后首次渲染时 /api/plans 还在路上, + // 兜底表会先建一次;若按「建过就跳过」,真实清单回来后下拉框**永远**不重建 —— 那个一次性 + // 守卫等于让兜底表赢到底,`planLabel` 也就永远没机会生效(en 界面上就是兜底表里的中文名)。 + const src = Live.plans ? "live" : "fallback"; + if (selP.dataset.plansSrc !== src) { + selP.innerHTML = '" + + [...new Set(plansSrc().map((pl) => pl.provider))] + .map((p) => '").join(""); + selP.dataset.plansSrc = src; fillPlans(); }