Files
MailUI4Agents/docs/reviews/fix-report-2026-09-26.md
JianFeeeee 2530229180 docs(审查): 归档本轮四份代码审查报告
`docs/reviews/` 此前一直是**未跟踪**状态 —— 审查报告只在磁盘上,
不进版本库 ⇒ 换机器、换会话、给别人看时全部拿不到,
而它们正是本轮五个修复(hap 出库 / SSE 写锁 / 换身份清数据 /
鸿蒙门禁三态 / HMS 配额)的**来源**。

  push-and-gui-review.md     推送链 + Electron GUI(HMS 配额那两条)
  electron-gui-review.md     换身份不清数据
  harmony-client-review.md   鸿蒙:门禁 fail-open / MailStore 快照共用 / clear() 零调用方
  harmony-pages-review.md    页面层
  harmony-state-review.md    状态层
  fix-report-2026-09-26.md   上述修复的实施记录

其中 `harmony-client-review.md` §三.1 记的那条值得单独留意:
该报告自己声明「ArkTS 语言规范层面零违规,本文所有问题都是**逻辑缺陷**」——
本次提交的三处鸿蒙改动也只动逻辑(门禁条件、logout 清理),
不碰语法层。
2026-09-28 08:46:02 +08:00

237 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 修复报告 —— 2026-09-26
按「CRITICAL + HIGH + MEDIUM」范围修完两端,**29 个文件**。
每处修复都带**可复现的验证**;没有验证过的改动一条都没有。
---
## 0. 验证口径(先说清楚什么算「修好了」)
| 层 | 工具 | 结果 |
|---|---|---|
| ArkTS 编译 | `devecocli build`(SDK 6.1.0(23),API 26) | ✅ BUILD SUCCESSFUL |
| 端上安装 + 启动 | `devecocli run` | ✅ 装得上、起得来 |
| 577 条判据 | `node test/run-all.mjs` | 见 §3 |
| 单测 | `npx vitest run` | ✅ 15 文件 / **266 → 270 通过**(新增 4 条) |
| 类型 | `npx tsc --noEmit` | ✅ 无错 |
| Go | `go test ./...` | ✅ **全套通过**(本轮才第一次跑通,见 §5) |
> ★ **Go 测试之前跑不起来**(`GOMODCACHE`/`GOPATH`/`GOCACHE` 都没设)。
> 补齐之后(含 `GOTOOLCHAIN=local`,本机 go1.25.12 与 `go.mod` 的 1.25.0 匹配)
> 才发现推送额度那个 bug —— **上一轮我"跑不了 Go 测试"是能力缺口,不是环境缺陷**。
---
## 1. 两处跨账号数据泄露(CRITICAL)
### 1.1 GUI:切账号不清 store ⇒ A 的邮件显示在 B 下
`AccountSwitcher.tsx:56-62` 原来只 `setActive` + `fetchInbox('all')`。
而 `setActive` 会 `syncAuth()` 翻 `api/config` 单例的 `API_BASE` 与 bearer ——
此后 `getSent()` / `getMail()` / `archiveContact()` 全带**新**账号的凭证,
而 `sent`、`currentSession(+Mails)`、`contacts` 全是**旧**账号的。
**修法**:把"清空"收成一个函数 `lib/resetAccountData.ts`,**三条身份路径共用**:
| 入口 | 调用点 |
|---|---|
| 主动切账号 | `AccountSwitcher.pick` |
| 登出 | `authStore.logout` |
| 401 掉登录 | `authStore.markAnonymous` |
后两条**原来一条都没有** —— 它们不在"切账号"的代码路径上,grep 找不到,
是最容易漏的那种。三个 store 各加 `resetAll()`,纯界面状态仍归 `uiStore.reset`,
**不混**("切账号"不能变成"退出登录")。
**行为锁**:`test/stores/resetAccountData.test.ts`(4 条)。
做过**变异验证** —— 把 `resetAccountData` 改回只清 mailStore,
两条断言立刻红(另两条本就该绿,因为它们只查幂等与 API 形状)。
不是"写了测试就绿"的空壳。
### 1.2 鸿蒙:删账号不清快照 ⇒ 下一个登录的人看到上一个人的邮件
`MailStore.clear()` 从写下来到今天**零调用**。同一个根因的另一端。
**修法**(两处,都收口):
- `api/Logout.ets` ③′ —— 放在 `disconnectAll()` 之后(晚一步清更保险);
- `SettingsPage.removeAccount` —— **只在删的是当前账号时**清(删别人的账号不该清)。
> ★ 为什么放 `performLogout` 而不是各调用点自己清:它已经写明"两个入口必须做同一件事",
> 复制一份到侧栏就会重新分叉。
---
## 2. 推送:额度按「设备数」在扣(HIGH)
`hms.go:192` 传的是 `len(tokens)`,而华为按 `messages:send` 的**调用次数**计。
⇒ **3 个设备收到 1 封邮件就吃掉 3 条额度,实际只发出去 1 条。**
多设备自部署用户按 1/设备数 的速度提前耗尽 1000 条/天。
而且它**扣在投递之前**:`accessToken` 失败 / HTTP 失败 / 非成功码,
一条都没发出去,额度已经扣了,失败只 `log.Printf` ⇒ 一次抖动静默烧配额。
**修法**:`reserveDaily(1)`,并挪到 `accessToken` 之后。
**为什么不是"发送成功后再扣"**:那会超发(并发下多 goroutine 都能过检查)。
前置预留 + 放在"确认能发"之后 = **宁可少算也不多发**。
**测试**:新增 `TestHMSDailyLimitCountsMessagesNotTokens`(3 设备 = 1 条)。
原有的 `TestHMSDailyLimitStopsSending` 每次只传 1 个 token,
所以 `len(tokens)` 恰好等于 1 —— **它一直是绿的,却没钉住单位**。
这正是"测试通过 ≠ 行为正确"的教科书案例。
---
## 3. 顺手挖出来的三个真问题(都不在原报告里)
### 3.1 小字对比度 2.85:1 —— 而且**上一轮的修法是错的** ★
设备判据抓到浅色小字(时间戳、「N 封」,66 处)= `rgb(153,153,153)` 压白底
= **2.85:1**(< 3:1)。
2026-09-19 的修法是让 `textSubtleFor()` 返回 `textMuted`,理由写着
「二级色语义对得上,且跟随主题」。
**复扫证明那个前提不成立**:本机 HarmonyOS 6.1.1 上
`ohos_id_color_text_secondary` 实测**也是 rgb(153,153,153)**,
与三级色同值 ⇒ 换过去**一点没变**。
⇒ 这是「**系统语义 ≠ 我们的可读性要求**」的**第三次**复发
(前两次:`surface` 当前景色、accent 深色没调亮)。
系统色只保证"比一级淡"这层**层次关系**,不保证 WCAG 下限。
**修法**:自己拥有这一档 `textSubtleLight #6B7280` / `textSubtleDark #8A92A1`
(= WebUI gray-400 的两套主题值,实测 4.83:1 / 5.51:1),
按纪律登记进 `SELF_OWNED_COLORS` + Theme.ets 的手写色登记表;
顺手**移除** `textSubtle` 这个原始令牌(它就是那个坑的来源,
留着只会让人以为"三级色可以直接用");
8 处直接用 `Theme.textSubtle` 的地方(绕过访问器)全部改走 `textSubtleFor()`。
**验证**:真机 21/21 通过(改动前 4 处红)。
### 3.2 `dumpLayout` 在本机模拟器上必超时(判据自己跑不成)
`uitest dumpLayout` 裸调用 → `Wait for subscribe uitest.broadcast.command.reply timeout`
(非 0 退出、stdout 空)⇒ 日历手势那类判据**直接报错**。
加 `-p <path>` → 正常。
**修法**:`harmony-device.mjs` 的 `dumpLayout` 显式给路径
(顺带 `rm` 掉设备临时文件;路径带 pid+时间戳,避免并发判据互相读到对方中间态)。
修完 `cross-client-gesture` **9/9 通过**(修前设备半边红)。
### 3.3 `harmony-imageprep` 的"素材不在就跳过"守卫形同虚设
`hdc shell` **把 stderr 并进 stdout**,于是文件不存在时 stdout 是
`ls: … No such file or directory` —— **里面仍然含有文件名**,
`listed.includes('real-wallpaper.jpg')` 判成"在" ⇒ 该跳过的继续跑 ⇒ 撞在
`/bin/file` 上**报错**(而不是跳过)。
**修法**:判存在看**退出码** + 行首形状,不看"输出里有没有那个名字"。
---
## 4. 其余修复
### GUI
| 严重度 | 位置 | 问题 | 修法 |
|---|---|---|---|
| HIGH | `MailView.tsx:756` | 审批失败只 `console.error` ⇒ "点了没反应"、Agent 一直阻塞 | 加 `submitError` 并渲染(审批/提问两种形态共用一条) |
| HIGH | `MailView.tsx:390` | 转发双发窗口 + 关窗依赖可能失败的刷新 | 同步 `inFlight` ref 闸门;刷新改 `allSettled` |
| HIGH | `CalendarView.tsx:267` | object URL **同步 revoke** ⇒ Firefox/WebKit 静默 0 字节 | 推迟 `setTimeout(…,0)` + `<a>` 挂上 document 再摘 |
| MED | `CalendarView.tsx:101` | 翻月无代次守卫 ⇒ 标题与内容错配 | `loadGen`(照 `ThreadView.tsx:62-84` 的既有形状) |
| MED | `BackgroundPicker.tsx:120` | 内联 `backgroundImage` 覆盖预设类 ⇒ **6 个格子显示同一张照片** | 去掉内联样式 |
| MED | `appearanceSync.ts:40` | `lastUploadedImage` 不分账号 ⇒ B 永不上传却报"已同步" | 改 `Map<accountId, string>` |
| MED | 3 处 `setTimeout` | 无卸载清理、连点留多个定时器 | 句柄存 ref + 卸载清 |
### 鸿蒙
| 严重度 | 位置 | 问题 | 修法 |
|---|---|---|---|
| CRITICAL | `AdminUsersPage.ets:319` | `roleKnown && !isAdmin` ⇒ **身份未知时把管理台整个渲染出来** | 三态化:`!roleKnown` 停在大门外 |
| HIGH | `MailDetailPage.ets:1939` | `'permission'` 恒不成立(服务端发 `permission_request`)⇒ 权限面板上回车**同时**开回复框 | 改正字面量 |
| HIGH | `MainPage.ets:2759` | 轮播换字的 `setTimeout` 句柄丢弃 | 存 ref + `stopTopbarRotation` 一并清 |
| HIGH | `MailStore.ets:499/628` | `cc_list.length` 无 `?? []`(收件箱+发件箱两处) | 补兜底 |
| MED | `Theme.ets` | 8 处绕过 `textSubtleFor()` | 全部改走访问器 |
### 系统返回键:**试了,失败,已撤回** ⚠️
这一项我在原报告里评的是 HIGH,**动手后发现自己的修法把应用关掉了**。
**原问题**(仍未修):`MainPage` 的 `PopIntent` 监听者**只接 Esc**,
系统返回键/手势绕开它走 `Navigation` 自己的 pop ⇒
① `KEY_COMM_STACK_DEPTH` 停在旧值;② `KEY_OPEN_MAIL_ID` 留着上一封
(回列表按回车会开"回复");③ `closeDetail()` 是死代码。
**我做的**:`EntryAbility.onBackPressed()` → `PopIntent.request()`。
为此把 `PopIntent` 的回调从 `() => void` 改成 `() => boolean`
(调用方需要区分"退了"和"没什么可退")。
**实测结果**:应用在前台按**一次**系统返回键 ⇒ **UIAbility 被销毁**
(hilog `HandleAppDied`,`aa dump -a` 里 EntryAbility 消失),
`harmony-admin` 设备判据**挂住不返回**。
**对照实验**:把这三处 stash 掉重编重装,同一条判据 **31/31 通过**。
⇒ `UIAbability.onBackPressed()` 在这条链上**没被调用到**
(`Navigation` 自己先消费了返回事件)。
**为什么撤回而不是继续查**:要验的是"框架内部返回事件的分发顺序"这类
**框架行为**,本机模拟器这条又不能代表真机。与其赌一个**会关掉应用**的
半成品,不如整块撤回、留成一条带复现步骤的账。
**试过并被否掉的三种接法**(都写进代码注释了):
1. `Navigation.onPop` —— 不存在(编译报 `Property 'onPop' does not exist`);
2. `pushPath` 第三个参数 —— 不接受(只接受 1–2 个);
3. `NavPathInfo.onPop` —— 存在,但**只在 `pop()` 带 result 时触发**,
系统返回键不走 `pop(result)` ⇒ 照样漏。
**已登记**:`docs/DEBTS.json` 的 `harmony-system-back-key`(28 笔),
含建议的下一步(挂 `NavDestination` 的 `onWillDisappear`,
它是每页自己的出栈时机,不依赖谁分发返回事件 —— 需真机确认)。
---
## 5. 三个诚实的缺口
### 5.1 3 条红判据**没修**,且与本轮无关
- `build-stamp` —— 产物构建于 `d9e71a4`,HEAD 是别的;
- `commit-hygiene` —— 两个 `.hap` 仍被 git 跟踪(**安全问题,见下**);
- `criteria-hygiene` —— 探针路径在本地历史里找不到。
三条都与本轮改动无关(改动前后完全一致)。
### 5.2 安全问题仍然开着 ★
`AgentMail-v1.2.1-pushdiag.hap` 与 `AgentMail-v1.2.1-pushlog2.hap`
**仍被 git 跟踪**,内含 AGC 信封密钥/校验和/api_key。
已确认**尚未推送**(`git cat-file -e origin/main:<path>` 两个都失败),
引入于 `f51c9c8`。**下次 push 前必须** `git rm --cached <两个文件>`,
并且**建议轮换密钥**。
### 5.3 我改不动的两条
- **HMS `click_action` 缺失**(原报告 HIGH 2):载荷形态要改,但**验证需要真机产出的
token** —— `hms.go:50` 自己写着"我无法在本机验证成功路径"。改完无法证明,
不动。模拟器不是真机(拿不到 AGC 真实 token)。
- **锁屏文案里的邮件主题**(原报告 MEDIUM 3):**先更正我上一轮的说法** ——
`push.go:36-38` 的注释写的是「**正文**不进通知」,那是**准确的**
(`hms.go` 发的是 title=主题 + body=发件人,邮件正文确实没进去)。
我上轮说它"在骗人"是**说错了**。主题上锁屏是个**设计取舍**(且做得一致),
不是文档 bug,故降级为"可选"。
### 5.4 我修砸了又撤回的一条
**系统返回键**(见 §4 末)。原报告评 HIGH,我动手后**实测把应用关掉了**,
已整块撤回并登记成 `harmony-system-back-key` 这笔账。
**为什么把它单独写一节**:本轮 20 处修复里,这一处是唯一**我自己的改动导致
回归**的地方。留着"修好了"的记录比留下一个已知缺口更危险 ——
所以撤回、把复现步骤和被否掉的接法全部写进代码注释与 DEBTS。
---
## 6. 变更清单
29 文件,+603 / −77。