上一提交只写进了一格判据,pi 随后把它的建议补上 —— 报告里现在是两格: · `TestHMSAccessTokenFailureDoesNotBurnQuota` 钉内部计数器 dayCount==0 · `TestHMSQuotaSurvivesTokenFailureWithLimitOne` 钉用户看得见的行为 两层都要钉的理由:**计数器对而行为错是可能的** —— 运维会收到「达到每日推送上限」这种**误导性文案**, 而真实原因只是上一次网络抖动。这正是它建议单独加一格的原因。
19 KiB
系统推送链 与 GUI 审查报告
审查对象:
- 推送链(端到端):
client/harmony推送侧(PushService.ets/PushContract.ts/EntryAbility.ets) +server/internal/push/*(push.go/hms.go/config.go)+handler/push.go+repo/push_tokens.go+notify/mail.go - GUI(Electron/React):
client/electron/src/(约 14,100 行,10 个 store + 30 个组件)
基线:node test/run-all.mjs → 577 判据 / 565 通过 / 3 红(与本报告无关,见上份报告)
Go 侧未能执行 go test(本机无 GOMODCACHE/GOPATH),推送逻辑以读码 + 对照既有测试验证。
第一部分:系统推送
一、先说结论:推送链整体是健康的
设计上有几处明确比一般实现高明的地方,先列出来避免后续误改:
- 不写死华为:
Notifier接口 +Factory注册表 +push.json配置式接入,RegisterType一个函数就能加厂商。 - 单项配错不拖垮服务:
config.go:133-159逐项continue并打明确日志, 网关照常启动 —— 推送是锦上添花,不能变成单点故障。 - 推送是可选旁路且零开销:
shouldDispatch抽成纯函数(push.go:115-117), 且注释诚实记录了变异验证曾经失败的原因(goroutine 竞态让错误理由也能绿)。 - 所有失败静默:客户端
PushService每一步 catch 都不打扰用户、不阻塞登录。 - 有效 token 自愈:华为回
80300007/illegal_tokens时自动删表(hms.go:244-254), 因为测试额度是项目级的,死 token 会白吃额度。 - 动态
IN (...)的方言安全性我已验证:repo/push_tokens.go手拼$1,$2…占位符, 而db.go:7明确「SQLite 也支持 $1/$2,无需改写」⇒ 两种方言都安全,不是 SQL 注入 (占位符位置由i+1生成,值一律走参数绑定)。
二、推送链的真实缺陷
1.【HIGH】每日额度按 token 数扣,且在实际投递之前扣
hms.go:192-194:
if !h.reserveDaily(len(tokens)) {
return fmt.Errorf("达到每日推送上限 %d 条(HMS_DAILY_LIMIT)", h.DailyLimit)
}
tok, err := h.accessToken(ctx) // ← 额度已经扣了
...
// 网络请求在下面,失败也只记日志
两个问题叠在一起:
- 计数单位错:
reserveDaily(len(tokens))记的是 token 个数,而注释与变量名 (dayCount、达到每日推送上限 N 条)都说是「条」。华为的测试消息额度是 按条消息限的(一次messages:send一条消息,无论带几个 token)。 ⇒ 3 个设备 1 封邮件就吃掉 3 条额度,实际发出去 1 条。 多设备自部署用户会以约 1/设备数 的速度提前耗尽 1000 条/天。 - 扣在投递前:
accessToken失败、HTTP 失败、华为返回非成功码 —— 这些一条都没发出去, 但额度已经扣掉了。而失败只log.Printf(push.go:174-176),用户侧完全无感。 ⇒ 一次网络抖动会静默烧掉配额。
修法:reserveDaily(1)(按批次计),并把预留挪到确认拿到 access_token 之后、
或失败时归还。
修复状态(2026-09-28):① 已修(
044a664,reserveDaily(1),有判据TestHMSDailyLimitCountsMessagesNotTokens)。 ② 曾被声称已修但没落地 ——044a664的 commit message 承诺「挪到确认能发之后」, diff 里却只改了传参,调用位置仍在accessToken之前,注释与代码自相矛盾。 之所以没被当场发现,是因为当时那批判据造不出「accessToken 失败」这条路。 现已真正落地(预留移到accessToken成功之后、发请求之前), 并补上两格判据:
TestHMSAccessTokenFailureDoesNotBurnQuota—— 钉内部计数器(dayCount == 0)TestHMSQuotaSurvivesTokenFailureWithLimitOne—— 钉用户看得见的行为 (DailyLimit=1时一次accessToken失败后第二次仍然要能发出去)两层都要钉:计数器对而行为错是可能的 —— 那会让运维收到「达到每日推送上限」这种 误导性文案,真实原因却是上一次网络抖动。 变异验证:把预留挪回
accessToken之前,两格同时红。 抓住这处不一致的是 pi。
2.【HIGH】HMS 通知载荷缺 click_action,锁屏点击可能带不出 data
hms.go:206-217:
"message": map[string]any{
"token": tokens,
"notification": map[string]any{ "title": ..., "body": ... },
"data": string(data), // ← data 带了,但没有任何 click 行为声明
}
客户端完全依赖 data 里的 mail_id 做跳转:
EntryAbility.onCreate / onNewWant → PushService.routeFromWant → routeFromWant(params, ...)
读 parameters['data'](EntryAbility.ets:84-86、120-122)。
而载荷里没有 click_action、没有 intent、没有 backgroundMode。
HMS v1 在「普通通知」形态下,点击行为由 click_action 决定(1=打开应用、2=打开页面、
3=触发 Action);缺省时不同形态/系统版本对 data 是否透传到 want.parameters 的行为并不一致。
项目里 PushContract.ts:9-10 自己写着「服务端推送是至多一次、无幂等键」,
EntryAbility.ets:78-82 也记录了 2026-09-15 为此专门加过冷启处理 ——
说明这条链此前已经因「跳转目标丢失」返工过一次,而根因(载荷形态)没有变。
这正是「App 弹到前台、停在列表」的另一种表现形态。
修法:显式声明点击行为(HMS v1 click_action 或按 Action 形态补 intent),
并用真机 token 实测一次确认 data 能进 want.parameters ——
hms.go:50 自己写着「我无法在本机验证成功路径(需要一台真机产出的 token)」,
这个未知项至今没有关闭。
3.【MEDIUM】通知标题把完整邮件主题写进锁屏
push.go:36-38 的设计声明:
「字段刻意少:推送只负责『通知栏那一行 + 点进去看哪封信』。 正文不进通知,否则锁屏上就会露出邮件内容。」
但 hms.go:211 实际是:
"title": "新邮件:" + truncate(n.Subject, 40), // ← 主题全文前 40 字
"body": n.From,
Subject 是邮件主题本身,标题里就带「报销 3 万走哪个账户」这类内容。
声明说"正文不进通知",实现却把主题放进去了 —— 这在锁屏上是可见的。
(truncate 限了 40 字,但 40 字足够泄漏一个机密项目名。)
修法:要么按声明改成固定文案(如「新邮件」+ 发件人),
要么把 push.go 的注释改成与实现一致 —— 现状是注释在骗人,这比任一选择都糟。
4.【MEDIUM】客户端写死 provider: 'hms',与"配置式多厂商"自相矛盾
PushContract.ts:13 + PushService.ets:348,381 无条件用 PROVIDER_HMS。
服务端明确支持一实例多厂商、且 name 可覆盖(config.go:44-47)。
但客户端把 hms 硬编码,PushService.ts:360-361 又只是把 resp.providers 打进日志
就丢掉,从不据此选择。⇒ 服务端配了小米通道,鸿蒙客户端永远不会往它登记。
讽刺的是 PushContract.ts:203-207 的注释正在讲"不要硬编码只有 hms 合法"这个原则,
而调用处恰恰硬编码了。
5.【MEDIUM】NotificationLedger 只在 want 层去重,SSE 侧无跨通道去重
PushContract.ts:9-10 说「重复保护只能落在客户端」,EntryAbility 用
NotificationLedger(50) 按 mail_id 去重(EntryAbility.ets:42)。
但这只覆盖「点通知」这一条路径。用户实际会看到两次:
- App 在前台 → SSE
new_mail→MainPage.ets:2911showToast('新邮件:…') - HMS 通知同时下发 → 通知栏一条
两条路径各自独立、无共享去重台账 ⇒ 前台每次都"toast + 通知栏"双份。
main.go 端也没有按在线状态抑制推送的逻辑(shouldDispatch 只看 Enabled() 和收件人非空)。
6.【MEDIUM】ledger 是 EntryAbility 实例字段,50 条就会开始漏
EntryAbility.ets:42 private ledger: NotificationLedger = new NotificationLedger(50)。
台账按 mail_id 去重且有界(50)。高流量场景(Agent 批量发信)下,
第 51 封之后旧 mail_id 被挤出 ⇒ 同一封邮件若被系统重放 want(HMS 在网络重试时确实会)
会再次触发跳转。属于可接受但需知情的取舍,limit=50 是否够值得确认。
7.【LOW】client 侧 PushService 在 aboutToAppear 之前就上报(已知,非新问题)
EntryAbility.onCreate:106 用 ApiClient.getInstance(...) 上报,而该实例尚未 init()
(init() 只在 LoginPage.ets:101 与 MainPage.ets:3128 调)⇒ 此刻无 token,服务端回 401。
LoginPage.ets:168-192 的 reportPushToken 已在登录后补报,已修复。
仅记录:EntryAbility.ets:101 那句注释仍写「PushService.isEnabled,默认关」,
而 PushContract.DEFAULT_PUSH_ENABLED = true —— 注释与实现相反,会误导后续维护。
8.【LOW】SessionApi 同类问题:null 违反本仓约定
api/SessionApi.ets:48 proposal: RenameProposal | null = null。
本仓 ArkTS 约定偏向 undefined。服务端用 null 表示"没有待处理建议",
客户端需要原样承载 —— 属于契约强制,但与 PushContract.ts 的 | undefined 风格不一致。
第二部分:GUI(Electron/React)
一、先说结论:GUI 的 XSS 面是干净的
我独立复核并确认:
- 全仓零
dangerouslySetInnerHTML/innerHTML/insertAdjacentHTML - 三个 Markdown 渲染点(
MailView.tsx:146、:690、ComposePage.tsx:305)统一用<Markdown remarkPlugins={[remarkGfm]}>,没有rehype-raw⇒ 原始 HTML 被转义 - 唯一的动态
href是Attachments.tsx:22的api.attachmentURL(a.attachment_id)(服务端签发的 id +encodeURIComponent过的 token),唯一的动态src是本地生成的 JPEG data URL electron/main.cjs:47-48:contextIsolation: true/nodeIntegration: false✔main.cjs:47-48有preload,未见openExternal/will-navigate缺口
这条链上 test/markdown-xss.test.mjs 的不变量没有被绕过。
二、GUI 的真实缺陷
1.【CRITICAL】切账号不清 store ⇒ A 账号的邮件显示在 B 账号下
AccountSwitcher.tsx:56-62:
const pick = async (id: string) => {
setOpen(false);
await setActive(id);
await fetchInbox('all'); // ← 只重取了收件箱
};
setActive(accountStore.ts:186-197)会 syncAuth(next, id),把 api/config 单例的
API_BASE 与 bearer 翻到新账号。此后:
useMailStore.sent、currentMailuseSessionStore.sessions、currentSession、currentSessionMailsuseContactStore.contacts/archivedContacts
一个都没复位。而后续单账号请求(api.getSent()、api.getMail()、api.archiveContact())
会带上 B 的凭证。
后果:A 账号下打开发件箱 → 切到 B → 发件箱仍是 A 的邮件;
MailView.tsx:67 仍按 A 的会话渲染全文;从这些陈旧视图点转发/归档/批准,改的是 B。
grep 确认:只有 MailList.pick / ContactPanel.open / PermissionList.pick 会调
clearSession() / clearCurrentMail(),账号切换路径一个都没有。
修法:setActive 之后除 fetchInbox 外,还要
clearCurrentMail() / clearSession() / 清 sent / 清 contacts。
这与鸿蒙侧
MailStore.clear()零调用(上份报告第 3 条)是同一个根因的两端: 两个客户端都在"切换身份"时只刷新了主列表,没清其余状态。
2.【CRITICAL】lastUploadedImage 是模块级单例、不按账号分 ⇒ 第二个账号的壁纸永不上传
appearanceSync.ts:40:
let lastUploadedImage = ''; // 模块级,不带账号维度
pull() 第 87 行写它(从服务端图回填时),push() 第 115 行用它做跳过判据:
if (ok && bg.kind === 'image' && bg.imageDataUrl && bg.imageDataUrl !== lastUploadedImage) {
路径:A 账号把壁纸设为 X(上传成功 ⇒ 标记 = X)→ 切到 B 账号,B 的服务端记录是
saved:false ⇒ pull() 走 appearanceSync.ts:69-76 的"把本地这份推上去"分支
(:74 调 push())⇒ 但跳过判据被 A 留下的标记满足 ⇒
B 的壁纸永远不传,而 :120 仍把 status 置为 'synced'。
⇒ B 的壁纸在其其它所有设备上都缺失,而界面上显示"已同步"。
讽刺的是该文件头明确把 localStorage 键按账号做了隔离,
却漏了这个变量 —— 它同样需要 accountId 维度。
修法:lastUploadedImage 改成 Map<accountId, string>,或直接存进按账号分区的
localStorage(与现有 key 策略一致)。
3.【HIGH】权限审批吞掉错误 ⇒ 失败的"同意"看起来像成功了
MailView.tsx:746-761:
} catch (err) {
console.error(err); // ← 只进控制台
} finally { setBusy(false); }
setDecided(...)(:753)在 await api.decidePermission 之后。
⇒ 请求失败时 decided 仍是 '',界面回到可点状态,用户看不到任何提示,
只会以为"点了没反应"而反复点,而 Agent 那边一直阻塞。
同文件其它提交路径(ForwardBar.submit:401、ReplyBar.send:1046、BudgetEditor.commit:252)
都正确地 setError 了 —— 只有这一处是例外。
修法:catch 里 setError(...) 并把错误渲染出来。
4.【HIGH】转发有双发窗口,且"关窗"依赖可能失败的刷新
MailView.tsx:390-408:
if (!to.trim() || busy) return;
setBusy(true);
...
await api.forwardMail(...);
await Promise.all([fetchInbox('all'), fetchSent(), fetchSessions(), fetchContacts()]);
onClose();
busy守卫要等 React 提交setBusy(true)之后才生效 ⇒ 快速双击会发出两个forwardMail(真转发,不是本地乐观更新)- 四个刷新里任一 reject ⇒
onClose()被跳过,尽管转发已经成功 ⇒ 用户看到报错、于是再转一次
修法:setBusy 之前用同步 ref 置位;onClose() 挪进 finally 或改成
Promise.allSettled。
5.【HIGH】ICS 导出的 object URL 同步 revoke ⇒ 文件可能 0 字节
CalendarView.tsx:262-267:
const url = URL.createObjectURL(new Blob([ics], ...));
const a = document.createElement('a');
a.href = url; a.download = `...ics`;
a.click();
URL.revokeObjectURL(url); // ← 同一同步块内立即撤销
a 也从未插入 document。Firefox / WebKit 的下载是异步取 blob 的 ⇒
同步撤销会导致静默失败或 0 字节 .ics(Chromium 通常容忍,故容易漏测)。
修法:setTimeout(() => URL.revokeObjectURL(url), 0),并把 a 挂到
document 后再 remove()。
6.【MEDIUM】日历加载无请求代次守卫 ⇒ 快速翻月时"标题与内容错配"
CalendarView.tsx:101-106:
useEffect(() => { setLoading(true); load(); }, [range.from, range.to]);
load() 里无条件 setEvents(r.events ?? [])。
快速点「下一页/上一页」⇒ 两个请求同时在飞,后落地的覆盖先落地的 ⇒
标题显示 11 月而网格是 10 月的事件。
对比:ThreadView.tsx:66-85 用 gen.current / myGen 做对了,
CalendarView 没有对应实现。
7.【MEDIUM】三个 setTimeout 无卸载清理
ComposePage.tsx:160-163(900ms 后清提示并关窗)、AdminUsersPage.tsx:93-96、
KeyPanel.tsx:37-40。ComposePage 那条可达(清空按钮在 sending 期间未禁用)。
8.【MEDIUM】预设缩略图全部渲染同一张自定义图
BackgroundPicker.tsx:120-122:
className={`bg-preset-${p.id} absolute inset-0 block`}
style={{ backgroundImage: 'var(--bg-image)', backgroundSize: 'cover' }}
当 kind==='image' 时 --bg-image 有值 ⇒ 六个预设格子都显示同一张照片,
而用户正在这一步里挑选预设。
9.【LOW】api.attachmentURL 把 token 放进 query
api/client.ts:385-387 + :118-122。服务端有意支持
(main.go:299 用 middleware.UserAuthAllowQueryToken,handler/events.go:17 注释说明
这是浏览器 EventSource 专用回退)⇒ 不是缺陷。
但代价是 token 会进浏览器历史/服务端访问日志,是已知的取舍,记录备查。
(鸿蒙侧 ApiClient.getBytes 的注释明确写了"不用 ?token=,那会把密钥写进日志" ——
两端做法不一致,值得统一口径。)
三、本轮明确排除的疑点
- 推送 SQL 注入:
repo/push_tokens.go的IN (...)占位符由序号生成、值全走绑定, 且两种方言都支持$N⇒ 安全。 - Go 推送测试:23 个测试覆盖了形状、分批、限额、无效 token 清理、配置容错 ——
覆盖面是好的;
TestHMSDailyLimitStopsSending用例也说明"按 token 计"是已固化的行为, 但没人质疑这个计数单位(见缺陷 1)。 resetUI()登出不清数据 store:单独看不可达(无账号时AccountSwitcher渲染null), 真正的泄露面是切账号路径(缺陷 1)。不算独立缺陷。- Electron
MonthGrid key={i}:父级key={${anchor.getTime()}-${scale}}使其 每次导航都重挂载,索引不会跨数据变化 ⇒ 安全。 ContactPanel双按钮:非嵌套可交互元素,无点击吞没。- GUI 监听器/定时器清理:
LoginPage.tsx:43-55、AddressInput.tsx:195-199({capture:true}成对)、backgroundStore.ts:379-393(两条路径都 revoke)⇒ 正确。
四、修复顺序建议
| 序 | 项 | 位置 | 理由 |
|---|---|---|---|
| 1 | 切账号清 store | AccountSwitcher.tsx:56-62 |
唯一的跨账号数据泄露,改动最小 |
| 2 | 壁纸按账号分 | appearanceSync.ts:40 |
静默丢数据 + 谎报"已同步" |
| 3 | 审批错误提示 | MailView.tsx:756-757 |
权限决策失败却无反馈,Agent 一直阻塞 |
| 4 | 每日额度计数单位 | hms.go:192 |
用户可感知的功能失效(提前耗尽配额) |
| 5 | HMS click_action + 真机实测 |
hms.go:206-217 |
需真机 token 才能关闭这个未知项 |
| 6 | ICS revoke 时机 | CalendarView.tsx:267 |
导出功能在部分内核上失效 |
| 7 | 转发双发 / 关窗 | MailView.tsx:390-408 |
数据重复 |
| 8 | 锁屏文案 | hms.go:211 + push.go:36-38 |
注释与实现不一致(二选一,别留现状) |
| 9 | 日历代次守卫 | CalendarView.tsx:101 |
照抄 ThreadView.tsx:66-85 即可 |
| 10 | provider 硬编码 | PushContract.ts:13 |
影响"配置式多厂商"这个卖点 |