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

18 KiB
Raw Blame History

鸿蒙客户端代码审查报告

审查对象: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)

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 仍在,所以不是越权。 但非管理员会看到完整用户列表、建号表单、改密/重置入口,并向管理端点发起请求 —— 属于 客户端信息泄露 + 无意义的失败请求风暴。

修法:

if (!this.roleKnown) { /* 读不到:显示"正在确认身份",不要渲染管理台 */ }
else if (!this.isAdmin) { /* 现有那面墙 */ }
else { /* 管理台 */ }

2. MailStore.ets:446 / :593 — 收件箱与发件箱共用同一份快照

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() 零调用方,登出不清内存

/** 退出登录/换账号时清空 —— 否则下一个账号会看到上一个人的邮件(哪怕只有一帧)。 */
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 — 回车键守卫比较了一个不存在的值

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 句柄被丢弃

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() 是死代码,系统返回键绕过了它

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() 没有请求令牌

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 少了 ?? [] 兜底

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 泄漏 + 全尺寸解码

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 事件都触发一次全量多账号重拉

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 — 同一事件按两个时间基准放置与标注

if (new Date(e.event_time).getHours() === hour) { ... }              // 设备本地时
Text(hhmmAtOffset(e.event_time, this.offsetMinutes))                  // 日历配置时区

日历时区与设备时区不一致的用户,每个事件被画在这一行、却被标注成另一时间。 nowHour() 同样用设备本地 getHours(),所以"当前时间"参考线也对不上所有标签。


13. ApiClient.ets:119 — persistBase() 绕过了归一化闸口

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 忽略账号,而分组键带账号

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 服务/模拟器不在), 按本仓自己的约定「跳过不是通过」⇒ 上述运行时行为类问题仍缺设备侧验证。