fix(i18n): stop the backend from inventing Chinese display labels in response data fields - #252
Merged
Merged
Conversation
…response data fields 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.
…response data fields 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).
argszero
added a commit
that referenced
this pull request
Sep 15, 2026
Ships the 18 PRs merged since v0.7.24 (#242-#259). Schema 14 -> 15 (two covering indexes, applied at startup). No config change, so no deployment-side config.toml edit is needed. Two themes: Perf on the NFS dev database - #259: stop mapping the db (PRAGMA mmap_size 64MB -> 0) and stop a real write per request (dao::touch_api_key gains a 60s guard). Measured on the live dev db: mmap=64MB 1.7-3.1s per COUNT / 250 MiB read vs mmap=0 ~10.5ms / 80 KiB; mmap=0 alone still leaves ~1.2s behind any write, so the pair is required. - #242: codify the two emergency indexes in a v15 migration and gate the conditional joins at the plan level. - #243: read the sharing page's earn total from one batched aggregate. Frontend: display must equal what it filters on, and one fact, one source - #250 one writer for the transaction cache; #251 clear every session slot at the identity boundary and give the wallet view a loader; #253 one shared writer for the wallet/dashboard month-changes; #254 boot loads only the destination view; #255 a model row's identity is the model, not its index; #256 the marketplace source follows the session, not whether data arrived; #257 the sidebar advertises only digits that work; #258 the admin total-balance card sums the gift amount its caption names. i18n - #249 every backend error reaches the wordlist, and the comment stripper stops mangling UTF-8; #252 the backend stops inventing Chinese display labels in response data fields. Forms and robustness - #244 a non-auth boot failure no longer looks like being logged out; #245 a credential 401 is no longer read as a session expiry; #246 wire timestamps reach the renderer unsliced; #247 inline cards submit from every field; #248 a market row's availability label comes from that row. - Cargo.toml / Cargo.lock: 0.7.24 -> 0.7.25. - CHANGELOG.md: v0.7.25 entry. - ui/index.html: cache-bust left as-is; the UI PRs in this release already advanced it past the value deployed with v0.7.24 (app.js 20260915-13, i18n.js 20260915-3). cargo test 288 passed; cargo fmt --check clean; clippy unchanged.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The client translates the backend's
errorfield (ui/js/api.jshands it toI18n.mapErr(), wordlist inui/js/i18n.js) — #249 covered that path and its gate. Response data fields have no such path: the UI renders them raw. So a backend that invents a Chinese display label and puts it in a data field prints Chinese on theeninterface, andcargo teststays green. This PR removes the two reachable cases and adds one contract per direction.Reachable leaks (both measured, not read off the code):
src/routes/admin.rs— department bucketCOALESCE(d.name, '(未分配)')barRow(d.name, …)→#usage-deptdept_id IS NULL+ usage this monthsrc/gateway.rs— plan name derived fromtype(API(按量)…)#sf-planoptions / share toast / share-table cell[[plans]]inconfig.example.tomlsetnameBoth fire next to a localization mechanism that already exists on the same screen/payload — the member table on that very admin page uses
T("common.unassigned"), andshowPlanHintalready mapspl.typethroughshare.plan.*— so this is a missed call site, not a missing feature.Related Issue
None — found by scanning, not reported.
Changes
src/routes/admin.rs: the no-department bucket returns'', the same shape as the neighbouringusers[].dept_name(COALESCE(d.name, '')).src/gateway.rs:/api/plansreturnsp.nameverbatim (empty when config leaves it unset); docstring rewritten; existing test updated.ui/js/app.js:planLabel(pl)— config's own name when set, else a wordlist key pertype(share.planName.paygo|token|coding, 3 new keys, packs now 809). Used by the dropdown, the share toast and the share-table cell. The department bucket rendersd.name || T("common.unassigned")(existing key, zero new ones).ui/js/app.js: the Plan dropdown is rebuilt when its data source changes (dataset.plansSrc) instead of once (dataset.init), and the cascading fills read the source at call time. The view renders beforeloadSharing()resolves, so the fallback table always built the dropdown first and the one-shot guard kept it for good — the backend half was invisible on that control.ui/index.htmlcache-bust;ui/README.mddocuments the rule and a same-axis item deliberately left out.Tests
cargo test— 275 passed / 0 failedcargo fmt --check— clean (clippy: only the pre-existing falsecollapsible_matchatsrc/protocol.rs:662)i18n_pack::backend_data_fields_are_language_neutral— scans everysrc/**/*.rs, extracts Chinese literals delivered through ajson!data field (key ≠error), including through a localletbinding (the plan-name fallback was written that way — without that layer the gate is green on the pre-change tree) and skipping nestedjson!and#[cfg(test)]modules. The result must be exactly the adjudicated exemption list — a new invented label turns it red, a stale exemption turns it red too. The roster carries a deliberate 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.i18n_pack::backend_neutral_data_labels_are_localized_in_the_client— both render sites must have the localized fallback (a neutral backend without one 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 adept_id IS NULLuser who has usage this month, the bucket name carries no CJK, and a real department name still passes through verbatim.Scanner geometry helpers (
enclosing_fn_span,local_binding_rhs,cjk_literals,statement_containing) have their own self-proof test that injects labels in several literal forms and asserts the extractor's output contains the marker — an extractor that silently finds nothing is the failure mode this guards.A/B
Rust, full
cargo testper leg, mutated in place and restored againstHEAD(md5 +git diff --stat+diffchecks):L1_gateway(plan-name fallback restored){backend_data_fields_are_language_neutral}L2_admin(SQL bucket restored){usage_department_bucket_without_a_department_is_language_neutral}L3_client(client fallback removed){backend_neutral_data_labels_are_localized_in_the_client}+ 2 key-count gates (collateral)L4_roster_escape(competing fix: keep the invented name and register it in the roster){backend_data_fields_are_language_neutral}{}JS (
tmp/c2133_probe.js; jsdom, realindex.html+ the 4 real scripts, onlyfetchstubbed,atp_lang=enforced; each check compared against its expectation, so the pre-change leg's demonstration checks are not mistaken for acceptance failures; every leg generated by string substitution from the working tree):{}— 17/17origin/main){B2b,D1,D2,E2,F1,F2}pl.nameraw{A2b,D2}{F1,F2}{B2b,D2}{D1,E2}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.Checklist
fix/…fix(i18n): …,test(i18n): …)Deliberately not bundled
ui/js/data.jscarries its ownPLANS[].namedisplay labels (API(按量),Kimi Code 会员…), which the client shows when/api/plansfails. Different producer (a client data table, not a response field), and it needs a brand-vs-generic ruling first.provLabel()right beside it is the pattern to copy (I18n.lang === "zh"gates the label table,engets the id). Recorded inui/README.mdand in the project ledger.