Files
MailUI4Agents/docs/reviews/harmony-client-review.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

374 lines
18 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.

# 鸿蒙客户端代码审查报告
审查对象:`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 服务/模拟器不在),
按本仓自己的约定「跳过不是通过」⇒ 上述运行时行为类问题仍缺设备侧验证。