diff --git a/CHANGELOG.md b/CHANGELOG.md index 79c2796..e37abea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,29 @@ All notable changes are recorded here. Versions follow [SemVer](https://semver.org/). +## v0.7.25 (2026-09-15) + +自 v0.7.24 起累计 18 个 PR(#242–#259)。⚠️ **数据库 schema 14 → 15**:新增两条覆盖索引,迁移在启动时执行。本版两条主线:dev 上 NFS 的**查询/写入性能**,以及前端一批「**显示口径必须等于筛选口径、一个事实只有一个真源**」的缺陷。**无 config 变更**(`config.toml` 无需同步改动)。 + +- **NFS 上不再映射数据库、不再每请求真写(#259 a28ad3e)** — dev 上 `/api/transactions` 1.7–2.3s、`/api/ops/runtime` 2.1s,根因是**两个独立的 NFS 机制**叠加。① `PRAGMA mmap_size=64MB` 在这台 NFS 上是**慢路径**(映射页进不了客户端缓存 ⇒ 每次查询都真读文件):真库同一条覆盖索引 `COUNT(*)`,mmap=64MB **每次 1.7–3.1s、进程实读 250 MiB / 5 次**,mmap=0 **~10.5ms、实读 80 KiB**(同副本放本地盘两者都 7ms ⇒ 不是存储慢);尺寸扫描 0→13ms / 16MB→334–407ms / 64MB→1.7–2.2s / 128MB→2.2–2.8s ⇒ **任何非零值都更差**,故取 0。② **每一次真写都会打掉 NFS 客户端对该文件的缓存** ⇒ 只改 ① 只能 1.9s→~1.1s,到不了目标;而**每个已认证请求**都经过的 `dao::touch_api_key` 会写 `last_used`,加 60 秒时间守卫后(命中 0 行 ⇒ SQLite 不落页、不打掉缓存;实测提交 7ms vs 真写 34ms)把「每请求一次真写」降为「每分钟至多一次」。同 PR 更正了 `db.rs` 里被实测证伪的 v12 mmap 注释(原注释称「整库常驻进程内存、远端只首读一次」)。prod(本地盘、2.26MB)实测中性:200 次 COUNT 11ms(64MB)vs 9ms(0)。 +- **应急索引进迁移 + 条件 JOIN 门禁(#242 5d4e6f4)** — v0.7.24 的按需 JOIN 与两条应急索引此前只存在于 dev/prod 的**手工操作**里,新部署仍会踩 22s 慢查询。新增 **v15 迁移**建两条覆盖索引(`transactions(user_id,time,type,pts,tokens,…)` 与 `transactions(key_id,type,pts)`,后者此前在该查询上零索引),并补**计划级验收**:`EXPLAIN QUERY PLAN` 必须报 `COVERING INDEX`(名字断言 ≠ 覆盖性证明),以及第二条 `perf_gate` 不变量(`tx_joins()` 的调用点只在包装器内,且包装器必须保留无 JOIN 分支)。 +- **共享页收益改为批量聚合(#243 199566c8)** — `sharing_row` 曾对每行跑一次 `SELECT SUM(pts) … WHERE key_id=?` ⇒ 共享页 N 行 = N 次额外语句;改为一条 13 列 `SELECT` + `LEFT JOIN` 一次性聚合(同文件 `list`/`patch` 两份逐字重复的 SELECT 也随之合并为一处)。实测(20 万行)8 次子查询 1.54ms → 一次批聚合 0.02ms;门禁 `perf_gate::the_sharing_row_builder_runs_no_sql`(行构造器体内零 SQL)。 +- **交易页缓存单一写者(#250 a81839b)** — `Live.transactions` 曾有两个写者:`loadTransactions`(`page_size=10`+筛选,并记录 `txTable.loaded*` 作为缓存有效性证据)与 `loadDashboard`(只为「交易笔数」卡拉 `page_size=1`);后者的载荷借守卫通过 ⇒ 再入交易页时**表只 1 行、汇总卡挂「当前筛选」却显示全时段数字、趋势卡谎报「加载失败」**。改为仪表盘用**自己的槽** `Live.tradeCount`;门禁钉 `writes(Live.transactions) == {loadTransactions} == writers(txTable.loaded*)`。 +- **会话边界清空缓存 + 钱包视图补 loader(#251 0e4acc2)** — `Live` 缓存活得比填它的身份更久 ⇒ 换账号后侧栏「永久点数」显示**上一个人的钱**且永不自愈。`resetSessionCaches()` 改为**槽名派生自 `Live` 字面量**(不维护名册),并在登出/401 与 boot/登录两个边界各调一次;同时给钱包视图补上此前缺失的 loader。门禁:身份边界必须清空每个槽、`renderView` 每个分支必须既渲染又装载。 +- **钱包页月度净变动改用共享写者(#253 f83df40)** — `#month-changes`(钱包)与 `#dash-month-changes`(仪表盘)由同一 `renderMonthChanges()` 画、都读 `Live.dashboard`,而该槽唯一写者是**仪表盘的** `loadDashboard` ⇒ **会话在钱包视图上建立**时(含在钱包页登出再登入)净变化恒印 `0` + 「本月暂无变动」,永不自愈(带 token 刷新看不到,因为 boot 的无条件 `renderView("dashboard")` 顺手装好了槽)。新增 `refreshDashboard()` 作该槽唯一写者,两个 loader 各调一次;门禁升级为**传递闭包**版(渲染闭包读的槽,其 loader 闭包必须写它)。 +- **boot 只装载当前目的地视图(#254 6be548a)** — `DOMContentLoaded` 无条件 `renderView("dashboard")` 且跑在 `restoreSession()` **之前** ⇒ 带 token 时仪表盘那套查询在会话还不存在时就发出(`log[0]` 是 `/api/wallet`),随后被 `resetSessionCaches()` 作废、再装一遍;落在 `#/transactions` 也照拉仪表盘那套 5 次;过期 token 时同一条「Session expired」toast 发 6 次(用户看到 3 条)。删掉那句渲染,视图只由 `switchView` 按**当前目的地**装载;门禁 `the_boot_handler_touches_no_view`。 +- **模型行身份 = 模型而非下标(#255 e64ae6c)** — `modelsToView()` 曾用**数组下标** `id: i` 当身份,还被持久化进 `localStorage` ⇒ 身份跨渲染/跨会话/跨数组(游客表 7 行、id 从 **1** 起;后端按 `provider, model` 排)⇒「最近使用」芯片指向**另一个模型**、或整条消失、或目录一变改写别的模型,点开的对话也一样。改为 `modelKey(m)=provider/model` 作唯一身份(三处 `data-*` 载体与点击侧全改用它,旧下标一次性丢弃);门禁 `the_model_row_identity_is_the_model_not_its_position`。 +- **市场数据源由会话决定(#256 afc9c6f)** — 市场面写成 `Live.models ? 活目录 : 游客表`(按「数据到没到」而非「会话在不在」分支)⇒ 登录态 `/api/models` 失败/超时时:厂商下拉列出 **10** 个厂商(5 个本部署没有)、「最近使用」芯片画游客市场模型(点开只提示加载失败)、徽标显示 **13**(活目录 6、游客市场 7 ⇒ 13 是第三个数)。改为 `marketRows()`/`marketProviders()` 一处真源,判别式为 `loggedIn()`;门禁 `market_tables_follow_the_session_not_the_data`(零豁免清单)。 +- **侧栏只 advertise 能按响的键位(#257 9982063)** — 每个导航项印数字角标 + 「快捷键 N」提示,而键盘上只有**一个**数字键处理器;两半是同一契约、必须取自同一数组 `NAV_ORDER`。`renderNav()` 的**游客**分支手搓了一个同形字面量(不属于登记表)⇒ `indexOf` 恒 **-1** ⇒ 角标印 **`0`**(死键)、提示写「Shortcut 0 · Marketplace」,而真正能打开市场的键是 **`2`**(游客永远看不到)。改为游客组从登记表里筛;门禁 `the_sidebar_advertises_only_digits_that_work`(四规则各有独立的牙)。 +- **管理员「总余额」卡折进赠送额(#258 4af7cb7)** — 该卡**副标题**写「余额 + 赠送」、正下方「可用」列也是 `balance + gift_balance`,而取值只加永久余额 ⇒ **少掉全部赠送额**。赠送是**可花会过期**的真钱(`gift.rs` 真划走),产品把可用余额定义为 `available = balance + gift_balance`(用户自己看到的「余额」取的就是它)⇒ 方向由产品钉死(「把副标题改窄」不是修法);修法一行=合计折进 `gift_balance`;门禁 `the_admin_total_balance_card_sums_what_its_caption_names`。 +- **后端错误全量登记进词表(#249 5183e41)** — `api.js` 把后端 `error` 整串交给 `ERR_MAP`,未登记即**原样返回** ⇒ en 界面显示中文而 CI 全绿(45 条里 20 条泄漏,`删除部门` 409 由可达控件触发)。20 条逐字派生登记 + 名册改为**派生**(`BACKEND_ERROR_SOURCES` 必须覆盖每个产出 `"error"` 字面量的文件)。同 PR 修掉一个**静默三轮**的失真:剥注释函数用 `byte as char` 搬 UTF-8 ⇒ 中文词表被撕成 Latin-1 乱码、**永远比不中**(旧消费者只问 `is_ascii()`,乱码同样非 ASCII),改为 `Vec` + `String::from_utf8` 并加保真断言。 +- **后端不再自造中文展示标签(#252 91a2da1)** — 两处把**自己编的中文标签**放进响应**数据**字段、前端原样渲染(`admin.rs` 的 `'(未分配)'` 部门桶名 → `#usage-dept`;`gateway.rs` 的 plan 兜底名 `'API(按量)'` → 上架表单/`#sf-plan`,config 的 `[[plans]]` 全不写 `name` ⇒ 恒触发),而 `ERR_MAP` 只扫 `"error"` 字面量 ⇒ **这一类零门禁**。同屏/同 payload 早有本地化机制(`T("common.unassigned")`、`showPlanHint`)⇒ 是漏用而非缺功能;改为后端只发**语言中性**标记、前端本地化,并加门禁(中文**数据**字面量集合须恰等于一份**日落清单**)。 +- **凭据 401 不再当作会话过期(#245 301ca4d)** — `api.js` 曾把一切 401 当「会话过期」(清 token + 登出 + toast)⇒ 登录失败时会发一条**误报**的「会话已过期」并把用户登出。改为 `opts.on401` 逃生口(401 语义由调用方声明,默认仍是会话失效 ⇒ 业务端点零改动),登录调用点声明为凭据错误;门禁 `credential_401_is_not_a_session_expired`。 +- **boot 非 401 失败不再演成「被登出」(#244 3199731)** — boot 的 `catch` 对任何**非 401** 失败都停在登录页(token 仍在、hash 仍指向上次视图)⇒ 与「被踢出」视觉不可区分。改为三档:401 回登录页(不重试);网络错误/5xx 重试一次;其余 4xx 不重试但**仍进 app**、由各视图的降级态(加载失败 + 重试)承接 —— 「加载失败」交各视图,「未登录」只由 401 承担。 +- **时间戳不再被手工切片(#246 6e022e1)** — 三个消费者自己切线上时间串:管理员加额行 `.slice(5,16)` 把 ISO 的 `T` 泄进 UI(屏幕上是 `09-13T16:30`、且是 UTC)、设置页 Key「创建时间」`.slice(0,10)` 取 **UTC 日**(东八区 08:00 前显示「昨天」)、交易行 `.replace("T"," ").slice(0,16)` 在渲染器**之前**把秒抹掉 ⇒ 屏幕上的秒永远是伪造的 `00`。改为整串交给现成 helper(`fmtPrecise`/`timeCell`/`timeAgo`);门禁 `wire_timestamps_reach_the_renderer_unsliced`。 +- **行内卡片 Enter 从每个字段提交(#247 ecc654c)** — Enter 提交是逐字段手工登记的,模型表单 10 个可输入控件里 **8 个**按 Enter 没反应(另三张卡片是 2/2、1/1、1/1,`#share-form` 本身是真 `
`+`type=submit`)。改为 `wireEnterSubmit(卡片, 确认按钮)` 委托到容器(当前与以后的文本控件自动生效;焦点在「取消」上按 Enter 仍只取消;处理体只 `btn.click()` ⇒ 忙碌态/校验/请求仍只有一处);门禁 `enter_submit_is_delegated_to_the_card`(不变量**从 markup 派生**而非人写名册)。 +- **市场可用性标签取自该行自身(#248 818ed88)** — `availPill()` 只认 `keys` 计数,而游客兜底表按「虚构数据已移除」**不携带**该字段 ⇒ 游客 **7/7** 行的绿点/按钮说「可用」而这一格写「无 key」(自相矛盾)。改为有计数时按计数分档(≥2 / ==1 / 0)、无计数时落到该行的 `avail` 布尔;门禁同时钉**消费侧**(须出现 `m.avail`)与**数据侧**(`MARKET` 不得携带手写 `keys`,否则等于把虚构运营数据摆给游客)。 + ## v0.7.24 (2026-09-14) - **交易页慢查询导致「刷新跳回登录页」(#240 95d69ba)** — 宿主实测:dev 上登录后进 `#/sharing` 刷新会**停在登录页**(URL 仍 `#/sharing`、token 仍在 sessionStorage)。根因是 `/api/transactions` 的 summary / COUNT / trend 三条语句**无条件**拼上 keys/users/api_keys 三个 LEFT JOIN,而这几个 JOIN 一出现 SQLite 就**弃用覆盖索引**、退回逐行回表:同一条聚合语句、同一个库(18.6 万行,NAS/NFS),**不带 JOIN 0.21s、带 JOIN 22.6s(约 100×)**。实际只有 `user_name` / `key_name` 两个筛选会引用 JOIN 表(`model` / `status` / pts 区间全在 `transactions` 自身),故改为**按需 JOIN**(新增单一判定谓词 `needs_joins()`,避免 JOIN 与 `tx_where` 的引用再次分叉);列表查询保留 JOIN(要渲染 user_name / key_label / key_name,且有 `LIMIT` 兜底)。故障链:慢查询长时间占住共享 DB 锁 → `/api/me`、`/api/models` 被排队(实测单次 `/api/me` 被卡 35s、并发下出现网关 504)→ 前端 boot 串行等 4 个请求(其中 `/api/models` 非必需)才 `enterApp()` ⇒ 任一卡住则登录页无限期停留(实测 3 次刷新 2 次卡住)。 diff --git a/Cargo.lock b/Cargo.lock index 0441698..01002ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -51,7 +51,7 @@ dependencies = [ [[package]] name = "aitokenpool" -version = "0.7.24" +version = "0.7.25" dependencies = [ "aes-gcm", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 5549b91..3858f86 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "aitokenpool" -version = "0.7.24" +version = "0.7.25" edition = "2021" description = "AI Token 共享池 — 企业 key 池 + 公共共享市场" license = "MIT"