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 清理),
不碰语法层。
This commit is contained in:
2026-09-28 08:27:49 +08:00
parent 4556886e04
commit 2530229180
6 changed files with 1681 additions and 0 deletions

View File

@ -0,0 +1,373 @@
# 鸿蒙客户端代码审查报告
审查对象:`client/harmony/entry/src/main/ets/`(65 个源文件,约 23,600 行)
审查方式:加载 `arkts-grammar-standards` 技能后逐文件通读 + 交叉核对服务端契约
基线:`node test/run-all.mjs` → **577 判据 / 565 通过 / 3 红 / 9 跳过**
---
## 一、总体评价
这份客户端的**工程素养显著高于常见水平**,审查中需要特别说明:
- 大量注释带日期 + 现象 + 根因 + 判据,可追溯到具体 commit
- 已系统性修过 ArkUI 生命周期、SSE 解码、UTF-8 分片、主题应用、键盘避让等问题
- 已接入 34 个判据文件、577 条判据的自动化回归网
**ArkTS 语言规范层面零违规**:无 `any`/`unknown`、无解构、无正则字面量、无 `Object.assign`、
无 `for...in`、无 `@ts-ignore`、无 V1/V2 装饰器混用、无伪造的 ArkUI 修饰符、无属性名与
ArkUI 通用属性冲突。本文所有问题都是**逻辑缺陷**,不是语法问题。
因此下面列出的不是"代码很烂",而是**一处安全边界失效 + 一处数据隔离失效 + 一批边界态缺陷**。
---
## 二、必须修(会出事)
### 1. `AdminUsersPage.ets:319` — 管理员门禁**失败开放**(fail-open)
```ts
if (this.roleKnown && !this.isAdmin) { // 只有"确定不是管理员"才拦
```
`loadRole()` 在**任何**失败路径上都把 `roleKnown` 置 false(`:113-116` —— 网络抖动、
token 过期、500、DNS 失败全都一样)。于是:
- `aboutToAppear` 里 `loadRole()` 与 `load()` **都未 await 且并行** ⇒ 首帧 `roleKnown=false`
⇒ 完整管理控制台**在角色校验返回之前就已挂载**
- 此后每次网络失败都会再次 fail-open
**讽刺的是该文件自己的头注释(`:100-103`)写的就是正确规则**:
> 「不能把"读不到"当成"是管理员"(那会让一次网络抖动对所有人显示管理入口)」
代码做的正是这条注释禁止的事。
**影响边界**:服务端 `middleware/user.go:70-77` 的 `AdminOnly` 仍在,所以**不是越权**。
但非管理员会看到完整用户列表、建号表单、改密/重置入口,并向管理端点发起请求 —— 属于
客户端信息泄露 + 无意义的失败请求风暴。
**修法**:
```ts
if (!this.roleKnown) { /* 读不到:显示"正在确认身份",不要渲染管理台 */ }
else if (!this.isAdmin) { /* 现有那面墙 */ }
else { /* 管理台 */ }
```
---
### 2. `MailStore.ets:446` / `:593` — 收件箱与发件箱**共用同一份快照**
```ts
async loadInbox(...) { const snap = this.snapshot; ... snap.mails = merged; ... }
async loadSent (...) { const snap = this.snapshot; ... snap.mails = merged; ... }
```
`loadSent` 会把 `snap.unread` 置 0、用发件箱分组重建 `groups`。
`MainPage.ets:1947` 的 `navPathStack.clear()` 只保证了"同一时刻只挂载一个页签",
**没有串行化在途请求**:SSE 事件在 `loadSent` await 期间落地
→ `notifyRemoteChange()` → `InboxTab.onMailRevChanged()` → `loadInbox()`,
两个 promise 同时在飞,**后落地的覆盖先落地的**。
**修法**:给 `MailStore` 加 generation 计数器,`await` 之后校验自己仍是最新一代,
否则丢弃结果;或者按视图拆成 `inboxSnapshot` / `sentSnapshot` 两个对象。
---
### 3. `MailStore.ets:702` — `clear()` **零调用方**,登出不清内存
```ts
/** 退出登录/换账号时清空 —— 否则下一个账号会看到上一个人的邮件(哪怕只有一帧)。 */
clear(): void { this.snapshot = new MailSnapshot(); this.bump(); }
```
全仓检索确认:**除了定义处,没有任何调用点**。
`api/Logout.ets:34-101` 的 `performLogout()` 与 `SettingsPage` 的 `removeAccount()` 都没调。
⇒ 上一个账号的邮件、`AppearanceStore.wallpaper`、`TopbarStore` 内容全部留在内存里。
新账号走 `LoginPage.aboutToAppear` 快速路径时,会在请求返回前**先渲染出上一个账号的邮件**。
这正是 `clear()` 注释自己声称要防的那件事。
**修法**:`performLogout()` 与 `removeAccount()` 里补 `MailStore.getInstance().clear()`。
---
### 4. `MailDetailPage.ets:1939` — 回车键守卫比较了一个**不存在的值**
```ts
if (this.mailType === 'permission' && this.permissionResult.length === 0) {
```
服务端只发 `'normal'`、`'permission_request'`(`server/internal/handler/permission.go:286`、
`repo/repo.go:442`)。同文件 `:1100` 与 `:1388` 都正确写的是 `'permission_request'`,
**只有这一处是 `'permission'`** ⇒ 该守卫恒为假,是死代码。
后果:在一封**尚未决策**的权限请求上按回车,会走进"打开回复框"的分支
(`MainPage.ets:4263-4274` → `ReplyIntent.request()`),而正确行为是进入决策面板。
**修法**:改为 `'permission_request'`。
---
## 三、应修(生产环境会撞上)
### 5. `MainPage.ets:2759` — 顶栏轮播的内层 `setTimeout` 句柄被丢弃
```ts
this.topTimer = setInterval(() => {
...
setTimeout(() => { this.topIndex = ...; ui.animateTo(...) }, TOPBAR_FADE_MS);
}, TOPBAR_ROTATE_MS);
```
`stopTopbarRotation()`(`:2768-2773`)**只 `clearInterval(topTimer)`**。
那个 `setTimeout` 的句柄被丢弃 ⇒ 页面销毁后回调仍会执行,在正在销毁的组件上写
`topIndex`/`topOpacity`,并对已失效的 `UIContext` 调 `animateTo`。
同一块还**缺重入保护**:`TOPBAR_ROTATE_MS`(5500) 远大于 `TOPBAR_FADE_MS`(260),
但一次超过一个周期的卡顿(大收件箱 `JSON.parse`、切后台)会让下一 tick 再排一个淡入,
`topIndex` 前进两次而只显示一次淡入 ⇒ 静默跳过一条。
**修法**:把 timeout 句柄与 `topTimer` 一起保存/一起清;加一个"淡入中"标志。
---
### 6. `MainPage.ets:2154` — `closeDetail()` 是死代码,系统返回键绕过了它
```ts
private closeDetail(): void {
this.currentMailId = '';
AppStorage.setOrCreate<string>(KEY_OPEN_MAIL_ID, '');
}
```
全仓**仅此一处定义、零调用**。`KEY_OPEN_MAIL_ID` 由 `openMail` 写、
由根部按键分发器(`:4263-4274`)读;唯一的清除点是 `NavDestinations.ets:54-57` 里
应用内返回箭头的 `onBack` 回调。
**两个 `Navigation` 都没有注册 `onPop`**(已核对 `MainPage.ets` / `ContactsTab.ets`,
都只调了 `.navDestination(...)`)⇒ 硬件返回键与侧滑返回直接弹栈,绕过那个回调。
后果:用系统返回键退出详情页后,`KEY_OPEN_MAIL_ID` 仍是旧值 ⇒ 在收件箱按回车
会打开**一封你已不在看的邮件的回复框**。`currentMailId` 同样不复位 ⇒ 已读行高亮残留、
`groupHasActive()` 继续给已离开的组描边。
---
### 7. `MainPage.ets:2081` — `KEY_COMM_STACK_DEPTH` 只在按 Esc 时递减
`publishStackDepth()` 的调用点里,**唯一能减的地方是 `PopIntent` 监听器(`:1708`),
而它只在按 Esc 时才跑**。
推入详情 → 用系统返回键弹出 ⇒ 真实栈已空但发布出去的深度仍是 1。
下一次 Esc 读到 `depth > 0` → 触发 `PopIntent.request()` → `navPathStack.pop()`
在空栈上是空操作 → handler `return true`(`:4239`)**吞掉了这次按键**。
⇒ 此后 Esc 永远无法透传给系统,**用户无法用键盘退出应用**。
**修法**:深度必须由真实的 pop 观察驱动(`NavDestination.onHidden`,或任何栈变化时重发),
而不是只由 Esc 路径驱动。
---
### 8. `ContactsTab.ets:202` — `openSession()` 没有请求令牌
```ts
this.openSessionId = c.session_id;
this.sessionTitle = ...;
this.sessionMails = [];
this.sessionLoading = true;
try { this.sessionMails = await m.sessionMails(c.session_id); } ...
```
连点 A 再点 B,两个请求同时在飞。若 A 的响应后到(账号慢/冷连接),
赋值会**用 A 的列表覆盖 B 的**,而标题仍显示 B ⇒ 面板上方是 B 的名字和主题、
下方是 A 的邮件列表;`openSessionId`(`confirmArchive` 用)指向 B,内容却是 A 的。
`sessionLoading` 也被先完成者清掉,另一个还在飞却已无转圈。
**修法**:加序号,`await` 之后校验仍是最新一次请求。
---
### 9. `MailStore.ets:499` — `cc_list.length` 少了 `?? []` 兜底
```ts
mail.cc_count = mail.cc_list.length; // ← 无兜底
mail.attach_count = (mail.attachments ?? []).length; // ← 两行之下就有兜底
```
注释里详细记录了 `attachments` 因为服务端 `omitempty` 导致 94/96 缺失、
`.length` 当场崩掉的全过程 —— 紧接着的下一行**又犯了同形状的错**(只是 `cc_list`
目前恰好 96/96 都有)。
聚合模式下单个账号的 `try/catch`(`:529`)会把这个 TypeError 吞成
`failed.push(...)` ⇒ **静默丢掉一整个账号的邮件,并污染未读总数**。
---
### 10. `AppearanceStore.ets:188-197` — 壁纸 PixelMap 泄漏 + 全尺寸解码
```ts
const bytes: ArrayBuffer = await api.fetchImageBytes();
const src: image.ImageSource = image.createImageSource(bytes);
this.wallpaper = await src.createPixelMap(); // 无 desiredSize,且不 release
```
三个问题叠在一起:
- 旧 `PixelMap` **从不 `release()`**,`ImageSource` 也不释放 —— 而同仓
`BackgroundPicker.ets:167-229` 对释放是极其严谨的,这里的标准不一致
- `createPixelMap()` **不带 `desiredSize`** ⇒ 2560px 壁纸按全尺寸解码(约 26 MB ARGB),
并在整个会话里由静态单例持有 ⇒ **低内存设备 OOM 风险**
- 全尺寸解码**跑在 UI 线程**上,且设置页会 `await` 它才应用主题 ⇒ 每次同步都有
数百毫秒卡顿
---
### 11. `MainPage.ets:2917` — 每条 SSE 事件都触发一次全量多账号重拉
```ts
MailStore.getInstance().notifyRemoteChange(); // → 所有已挂载页签 loadData()
```
`loadInbox` 会**逐个账号**各发一次 `GET /me/mail/inbox`(`:477-487`),且
**没有 debounce、没有在途抑制、没有 "正在加载" 早退** ⇒ 事件会排队而不是合并。
一分钟 20 条 `session_update`、3 个账号 = 60 次冗余请求。
这是"接收邮件不正常/卡顿"最可能的来源。
**修法**:修订号处理侧加短 debounce + `loadInbox` 入口加在途早退。
---
### 12. `CalendarPage.ets:1312` vs `:1344` — 同一事件按两个时间基准放置与标注
```ts
if (new Date(e.event_time).getHours() === hour) { ... } // 设备本地时
Text(hhmmAtOffset(e.event_time, this.offsetMinutes)) // 日历配置时区
```
日历时区与设备时区不一致的用户,**每个事件被画在这一行、却被标注成另一时间**。
`nowHour()` 同样用设备本地 `getHours()`,所以"当前时间"参考线也对不上所有标签。
---
### 13. `ApiClient.ets:119` — `persistBase()` 绕过了归一化闸口
```ts
async persistBase(base: string): Promise<void> {
this.apiBase = base; // ← 直接赋值,未经 normalizeApiBase
pref.putSync(PREF_KEY_API_BASE, base);
```
`setBase()`(`:95-97`)的注释明确说它是"归一化的唯一闸口",
但 `persistBase` 绕过了它。`LoginPage.ets:216-217` 虽然先调了 `setBase`,
可传入 `persistBase` 的是**未经归一化的原始输入** `this.serverAddr`。
后果:用户填 `https://x.com/` ⇒ 内存里是对的,**存进 preferences 的是不带 `/api/v1` 的**,
下次冷启 `init()` 虽会再归一化一次,但这条路径本身就是设计上的漏洞。
另外该方法还会**丢弃调用方已算好的 `check.base`**。
---
### 14. `SseService.ets` — 重连不销毁旧连接,且不支持事件回放
- **`doConnect` 没有 `destroy()` 旧 `httpRequest`**(`:201-202` 直接新建并覆盖
`conn.httpRequest`)⇒ 每次重连泄漏一个 `HttpRequest`,旧实例的回调也不会被解除。
`disconnectAccount` 里的 `destroy()`(`:127`)只对**当前**那个生效。
- **完全不支持 `Last-Event-ID`**(全仓零命中)。服务端 `server/internal/sse/manager.go:184-193`
专门实现了环形缓冲回放,而 WebUI 靠 `EventSource` 自动带这个头。
⇒ 鸿蒙端**断线期间的事件永久丢失**,这正是服务端那段回放代码想解决的问题。
- 重连固定 3 秒、无指数退避(`:305-308`),WebUI 侧有 backoff。
---
### 15. `MailStore.ets:690` — `dropSession` 忽略账号,而分组键带账号
```ts
if (m.session_id !== sessionId) { kept.push(m); }
```
而 `MailGrouping.ts:193` 的 `sessionKey` 明确要求键必须是 `account + '/' + session_id`
("同一个 `session_id` 出现在两个账号里是两件事")。
后果:聚合模式下归档 A 账号的某条会话,会把**其他账号里同 id 的会话一并删掉**,
且要等下次重拉才恢复。
---
### 16. `MailStore.ets:375-419` — 缓存读路径没有归一化
`paintFromCache` 走 `JSON.parse(...) as MailLike[]` 后**直接使用**,
不像 `MailApi.inbox()` 那样过 `MailSummary.normalize()`。
旧版本构建留下的缓存里,`session_alias` / `permission_result` / `attachments`
都会是 `undefined` ⇒ **复现 `Models.ets:139-163` 记录过的那次白屏崩溃**
(`Cannot read property length of undefined`)。
---
## 四、可选清理
| 位置 | 问题 |
|---|---|
| `Index.ets:1-38` | DevEco "Hello World" 模板页仍注册在 `main_pages.json` 里,可被路由到并显示模板文案 |
| `InboxPage.ets` | 既不在 `main_pages.json`,也无任何 `pushUrl` 指向 —— 完全死代码,~262 行,且自带一套与 `MainPage.InboxTab` 重复的取数与未读计数 |
| `SessionsPage.ets` | 仅被上面那个死页面引用 |
| `MainPage.ets:38` | `import { MailDetailView }` 从未使用(细节渲染走 `MailDetailDestination`)—— 半途重构的痕迹 |
| `MainPage.ets:455` | `InboxTab.openCompose()` 仍是迁移前的 `pushUrl` 全页路径(当前不可达,但离回归只差一个调用点) |
| `WideSidebar.ets:97` | 注释声称"订阅 SSE 状态变化",实际只在 `aboutToAppear` 读一次 ⇒ 连接指示灯永远冻结在挂载时的状态 |
| `MailDetailPage.ets:113` | `autoReadMailId` 带着 7 行去重注释但从未被读或写,真正的去重没实现(`status` 在 await 之后才置 `'read'`,不构成重入锁) |
| `MailDetailPage.ets:2008` | `const sessionId: string \| null = this.sessionId;` 而 `@State sessionId: string = ''` 永不为 null ⇒ 那个 `=== null` 守卫恒假,空串会漏过去 |
| `SessionApi.ets:48` | `proposal: RenameProposal \| null` 用了 `null`(本仓约定偏向 `undefined`) |
| `ContactsTab.ets:416` | `ForEach` 用数组下标做 key(其余列表都用稳定标识),归档后行组件会错位复用 |
| `CalendarPage.ets:499/993/1036/1060` | `loading = false` 写在 `catch` 之后而非 `finally`(同仓 `PermissionTab`/`AdminUsersPage` 是对的) |
---
## 五、审查过程中**排除**的疑点
避免这些被当成 bug 去"修":
- **ICS 生成**:客户端**根本不生成** ICS,`IcsFile.ets` 只负责选/读/写文本;
生成在服务端 `handler/calendar.go`,CRLF 与 UTC `DTSTART` 都正确。
- **`EntryAbility.onCreate` 里登录前就 `reportToken`**:已知且**已修**(`LoginPage:179`
的 `reportPushToken` 补报,注释记录了完整因果)。
- **顶栏 `setInterval`**:外层 interval 在 `aboutToDisappear` 确实清了(内层 timeout 漏了,见第 5 条)。
- **SSE 监听器生命周期**:`addListener`/`removeListener` 在 `MainPage:2868/2876` 正确配对。
- **`bump()` 与 `publishChange()` 共用 revision 计数器**:曾被怀疑会导致 AppStorage 值永久漂移,
实际不会 —— `@Watch` 关心的是"值有没有变",两者同步自增反而是必需的。
- **`CalendarPage.ets:335` 的 `Date.UTC(year, month-2, 1)`**:1 月时索引为 -1 是**故意的**溢出算术。
- **Markdown 渲染远端邮件正文**:全仓无 WebView / `loadUrl` / `innerHTML` 路径,
走原生 `@luvi/lv-markdown-in` 组件 ⇒ **不存在 XSS 执行路径**。
- **`.onClick` 签名**:16 个页面里全部零参,无 `ClickEvent`/`GestureEvent` 混用。
- **ARKTS 语法层**:无 `any`/`unknown`、无解构、无正则字面量、无 `Object.assign`、
无 `for...in`、无 `@ts-ignore`、无 V1/V2 混用、无伪造修饰符、无 `Record` 字面量未加引号的键。
---
## 六、建议的修复顺序
1. **第 1 条**(管理员门禁 fail-open)—— 唯一的"安全边界失效",改动量最小
2. **第 3 条**(`clear()` 零调用)—— 一行调用,堵住跨账号数据泄露
3. **第 4 条**(`'permission'` 拼写)—— 一个字符串,修掉一处死守卫
4. **第 6、7 条**(系统返回键相关的两处状态未复位)—— 一起改,都需要真实 pop 观察
5. **第 2、11 条**(快照争用 + 无 debounce 重拉)—— 一起做,加 generation 与 debounce
6. 第 14 条(SSE 重连销毁 / `Last-Event-ID`)—— 影响实时性,可排后
7. 其余按需
---
## 附:测试基线
`node test/run-all.mjs` 本身是**红的**,3 条失败均与鸿蒙客户端代码无关:
- `build-stamp` —— 产物是在旧提交 `d9e71a4` 上构建的,当前 HEAD 是 `3b46126`(需重构建)
- `commit-hygiene` —— **两个 `.hap` 产物被 git 跟踪**,内含 AGC 信封密钥/校验码/api_key。
已确认**尚未推送到 `origin/main`**(`git cat-file -e origin/main:<path>` 均失败),
提交于 `f51c9c8` ⇒ **赶在下一次 push 前**执行
`git rm --cached AgentMail-v1.2.1-pushdiag.hap AgentMail-v1.2.1-pushlog2.hap` 即可;
但既然是客户端签名凭证,仍建议**轮换**
- `criteria-hygiene` —— 判据自身的探针路径在本地历史里查不到(探针自身有问题)
9 条"跳过"全部是**设备相关**判据(hdc 服务/模拟器不在),
按本仓自己的约定「跳过不是通过」⇒ 上述运行时行为类问题仍缺设备侧验证。