跨端: 补附件区(两端一直都有这个功能,我上次误判成"死代码")+ 修两个真 bug

══ ① 更正我 2026-09-19 的一个**错误结论**(已写进 docs/DEBTS.json 留档)

那天我审计后写下:`GET /me/mail/inbox` 的回包**既没有 `attachments` 也没有
`has_attachments`** ⇒ `MailList.tsx:261` 的 `mail.attachments?.length ?? 0`
恒为 0、WebUI 那个 📎 是**死代码**。并据此在鸿蒙侧**有意不抄**这个标记。

**这个结论是错的**,错在取证方法:我**只看了一封没有附件的邮件**,
看到 key 不在,就断言服务端从不返回它。事实:
· 服务端 `GetInbox`(`mail.go:586`)**明确调了 `fillAttachments`**,注释还写着
  理由:「Agent 靠收件箱列表得知有哪些附件可下载,否则它不知道该调 attachment_id」
· `Mail.Attachments` 的 tag 带 **`omitempty`** ⇒ **没有附件的邮件根本不输出这个 key**

实测 `limit=200`(96 封):带 `attachments` 的 **2 封**,正是真有附件那两封。

★ 教训:**`omitempty` 字段的"缺失"不等于"服务端不返回"**。
  判「某字段有没有」必须拿**确实有值的那条**去验,而不是拿一条恰好为空的数据。
  这与同一天那个白屏崩溃(`session_workspace` 缺失 → `undefined` → 抛)
  是**同一个坑的两面** —— 那天是"缺失 → 客户端崩",今天是"缺失 → 我误判成不返回"。

══ ② 补附件区(鸿蒙原来完全没有)

· `model/Attachment.ts`(新)—— `formatSize` / `attachmentLabel`,纯逻辑无 SDK 依赖,
  逐字对齐 WebUI `api/client.ts:390`(三档 + 保留一位小数)。
· `MailApi.downloadAttachment` —— 走 `getBytes`(不是 `get<T>`:后者假定 JSON,
  取二进制会炸;壁纸当初踩过)。
· `IcsFile.saveBinaryFile` —— 与既有 `saveIcsText` 同一套流程,只是写 `ArrayBuffer`。
· `MailDetailPage` 正文之后渲染附件清单(回形针 + 文件名 + 大小 + 下载),
  位置/形态对齐 WebUI `Attachments.tsx`(无附件时**整块不渲染**)。
· 列表行的 📎 + 数字(`attach_count`,与 `cc_count` 同形状派生)。

══ ③ 顺带撞出并修掉两个**真 bug**

**bug A(差一点就是 94/96 必崩)**:我第一版写 `mail.attachments.length` ——
而 `Attachments` 带 `omitempty`,96 封里只有 2 封有这个 key ⇒ 其余 94 封是
`undefined` ⇒ `.length` 抛。**与当天早些时候那个白屏崩溃是同一个坑,
我刚修过、还在 `Models.ets` 里写了一大段注释,然后加新字段时照踩。**
⇒ 说明"记住别这么写"不管用,要在每个真正读的地方把 `?? []` 写出来。

**bug B(潜在白屏)**:`PermissionTab` 读 `req.session_alias` ——
而服务端 `PermissionRequest` struct **根本没有这个字段**(`models.go:239-255`),
`ListPendingPermissionsFor` 的 SELECT 也没查它,WebUI 的类型里同样没有。
它是我照"授权卡总得显示会话名"的直觉加出来的。⇒ 恒 `undefined`,
一旦有待办就抛。**一直没暴露只因为当前待办数一直是 0**(实测 `{"requests":[]}`)。
⇒ 改成服务端确实有的 `agent_name`,并**删掉那个字段声明**:
让误用变成**编译错**,而不是运行时白屏。

同样是 `body_preview`(`omitempty`,值是 `Body` 的截断)——
空正文 ⇒ 空串 ⇒ 服务端省略 key ⇒ `undefined.length` 抛。那批 96 封恰好都有正文,
所以"看起来没问题"——那正是这个坑的形态。已加 `?? ''`。

══ ④ 新判据:`omitempty` 字段的读法(形状,不是实例)

从 `server/internal/models` **算出**"只以 omitempty 形式出现过"的字段名
(不在判据里手抄名单),再扫鸿蒙侧对它们的裸成员调用。
★ 关键:**不能按字段名一刀切** —— 我第一版就是这么写的,报了 6 处、4 处误报:
  `session_alias` 在服务端有**两个**声明(`Mail` 上带 omitempty、`repo.Contact` 上不带),
  鸿蒙那 4 处读的全是 `Contact` ⇒ 恒有值、不是 bug。
  ⇒ 只扫"从未不带 omitempty 出现过"的名字,那 4 处自动排除。
已逐个核实 4 处豁免(每条都写了取证理由,不是"看着像就放过")。
变异验证:把 `?? []` 去掉 → 判据转红,且**正是**报 `MailStore.ets: mail.attach_count = mail.attachments.length`。

══ ⑤ 数据路径已实测(模拟器)

临时把 `INBOX_PAGE_SIZE` 提到 200(因为有附件那两封在下标 51/52,
默认 limit=50 **根本取不到** —— 这也解释了为什么之前一直没发现),
加临时 hilog 后拿到:
    AttProbe: mail=531a1629-… attach=1
    AttProbe: mail=b68cbbe8-… attach=1
正好是那两封。验完已撤掉探针、`INBOX_PAGE_SIZE` 恢复 50。

══ ⚠️ 本轮**未能**完成设备端视觉验收

模拟器已卡死(`hdc` 能连上但 `shell` 超时;进程 152% CPU、已跑 32 小时),
导致套件里的设备判据各跑 836 秒后失败("要能拉起应用")。
主机可用内存只剩 ~3.7GB。附件区的**渲染**(清单外观、下载落盘)
尚未在设备上看过 —— 待模拟器恢复后补。
This commit is contained in:
2026-09-20 21:14:20 +08:00
parent f1db99ef41
commit 25e7d8f3bf
13 changed files with 583 additions and 24 deletions

View File

@ -26,7 +26,7 @@ import assert from 'node:assert/strict';
import { readdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { code } from './lib/read.mjs';
import { code, prose } from './lib/read.mjs';
// 与其它鸿蒙判据同口径(见 `harmony-admin.test.mjs` 的文件头)
/*
@ -346,3 +346,153 @@ test('★ 时间戳不许把原始 ISO 直接印到界面上(`Text(mail.create
assert.equal(probe("Text(compactMailTime(mail.created_at))"), 0, '不许误伤传参给格式化函数的写法');
assert.equal(probe("Text(mail.createdAt)"), 1, '驼峰写法同样要抓');
});
test('★ 服务端「只以 omitempty 形式出现」的字段:客户端读它时必须有 `??` 兜底', () => {
/*
* ★★ 2026-09-20 加。这条是**同一天踩了两次**之后加的判据。
*
* 第一次(真崩溃,白屏 + 应用重启):
* `session_workspace` 带 `omitempty` ⇒ 服务端**不输出这个 key**
* ⇒ `JSON.parse as T` 之后是 `undefined`
* ⇒ `participantAddress` 里 `workspace.trim()` 抛 TypeError。
* 第二次(差一点就是 94/96 必崩):
* 给列表行加"附件数"时写 `mail.attachments.length` ——
* 而 `Attachments` 也带 `omitempty`,实测 96 封里**只有 2 封**带这个 key。
*
* ★ 为什么"记住别这么写"不管用、要写成判据:
* 两次之间我**刚**在 `Models.ets` 头注释里写了一大段讲这个坑,
* 然后加新字段时照踩 —— 记忆不跨"写下一行代码"这个边界。判据能跨。
*
* ── 判据的关键:**不能按字段名一刀切** ──
*
* 第一版我就是按"名字在 omitempty 名单里"扫的,结果报了 6 处,
* 其中 4 处是**误报**:`session_alias` 在服务端有**两个**声明 ——
* · `models.Mail.SessionAlias` `json:"session_alias,omitempty"` ★会缺失
* · `repo.Contact.SessionAlias` `json:"session_alias"` 恒有
* 而鸿蒙那 4 处读的全是 `Contact`(联系人卡片)⇒ 按名字判根本分不出来。
*
* 正确判法:只扫**"只以 omitempty 形式出现过"的字段名** ——
* 即全仓所有 struct 里,该 json 名**从来没有**不带 omitempty 的声明。
* 那样 `session_alias`/`display_name`/`title`/`mail_count`/`key_token`
* 这几个"两种形式都有"的自动被排除(名单从服务端**算出来**,不手抄)。
*/
const GO_FILES = [];
const walkGo = (dir) => {
for (const e of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, e.name);
if (e.isDirectory()) { walkGo(full); continue; }
if (e.name.endsWith('.go')) GO_FILES.push(full);
}
};
walkGo(join(ROOT, 'server/internal'));
const omitOnly = new Map(); // json 名 → 类型集合
const alsoPlain = new Set(); // 同时存在"不带 omitempty"声明的名字
for (const f of GO_FILES) {
/*
* 读**原文**(`prose`)而不是剥注释后的 `code()`:
* struct tag 里的 `json:"...,omitempty"` 虽然不是注释,但这几行 Go 源码
* 附近有大量解释性注释,而**判据要的是字段声明的字面形状** ——
* 走 `prose` 语义最直白("我在读这一段字面文本"),也符合本仓
* 「不许裸用 readFileSync」那条(改用它就要说清是 code 还是 prose)。
*/
const goSrc = prose(f);
for (const m of goSrc.matchAll(/^\s+(\w+)\s+(\[\]\w+|\*\w+|\w+)\s+`json:"([a-z_]+)(,omitempty)?"/gm)) {
const jsonName = m[3];
if (m[4]) {
if (!omitOnly.has(jsonName)) omitOnly.set(jsonName, new Set());
omitOnly.get(jsonName).add(m[2]);
} else {
alsoPlain.add(jsonName);
}
}
}
/* 只留下"从未不带 omitempty 出现过"的名字 —— 那些读起来一定要有兜底 */
const risky = [...omitOnly.entries()]
.filter(([name, types]) => {
if (alsoPlain.has(name)) return false;
/* 只有"成员调用会抛"的类型才算:字符串与切片。
数值/bool 缺失时比较不抛(`undefined > 0` 是 false),不在此列。 */
return [...types].some(t => t === 'string' || t.startsWith('[]'));
})
.map(([name]) => name);
assert.ok(risky.length >= 5, `应当算出一批"只在 omitempty 里出现"的字段,实际 ${risky.length}`);
assert.ok(risky.includes('attachments'), '`attachments` 应当在名单里(这是第二次踩的那个)');
assert.ok(risky.includes('session_workspace'), '`session_workspace` 应当在名单里(这是崩溃那个)');
assert.ok(!risky.includes('session_alias'),
'`session_alias` 不该在名单里 —— 它在 `repo.Contact` 上是不带 omitempty 的(恒有)');
const hits = [];
const walkEts = (dir) => {
for (const e of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, e.name);
if (e.isDirectory()) { walkEts(full); continue; }
if (!/\.(ets|ts)$/.test(e.name)) continue;
const src = code(full);
for (const f of risky) {
const re = new RegExp(`\\.${f}\\s*\\.\\s*(length|trim|slice|substring|toUpperCase|toLowerCase|replace|split|startsWith|endsWith|indexOf)\\b`, 'g');
for (const mm of src.matchAll(re)) {
const lineStart = src.lastIndexOf('\n', mm.index) + 1;
const lineEnd = src.indexOf('\n', mm.index);
const line = src.slice(lineStart, lineEnd === -1 ? undefined : lineEnd);
/* 同一行有兜底就不算(`??` 或 `||`) */
if (/\?\?|\|\|/.test(line)) continue;
hits.push(`${e.name}: ${line.trim().slice(0, 96)}`);
}
}
}
};
for (const sub of ['pages', 'common', 'model']) walkEts(join(ETS_ROOT, sub));
/*
* ── 已逐个核实过的**豁免**(不是"看着像就放过")──
*
* 判据按**跨语言字段名**匹配,所以同一个名字在不同接口上含义不同时会有假阳性。
* 下面每条都写了"为什么它不是那个会缺失的字段",改这里必须先重新取证:
*
* ① `attachments` 在 `this.` 上(`MailDetailPage`):
* `this.attachments` 是 `@State attachments: AttachmentInfo[] = []` ——
* 本地状态,初值是**空数组**、且 `MailDetail.normalize()` 在赋值前
* 已经把服务端的缺失兜成 `[]`(`Models.ets:202`)。不是服务端裸值。
*
* ② `attachments` 在 `mail.` 上(`InboxPage`):
* 那个文件是**死代码** —— 不在 `resources/base/profile/main_pages.json`
* 的页面表里、全仓没有任何 `pushUrl('pages/InboxPage')`。
* (它靠"编译器仍会编译"活着,所以判据还是能扫到它。)
*
* ③ `reason`(`MailDetailPage` 的 `this.renameProposal.reason`):
* 这个不是 `models.Mail.RenameReason`,是
* `GET /sessions/{id}/rename-proposal` 的**那条** proposal。
* 服务端(`sessions.go:236`)返回的是 `map[string]string{"alias":…, "reason":…}`
* —— **Go 的 map 里的空串照样输出**(实测:
* `json.Marshal(map[string]string{"reason":""})` → `{"reason":""}`,
* 而 struct 带 omitempty 才会省略)⇒ 这个 key 恒在。
*
* ④ `last_login`(`SettingsPage`):
* 那里写的是 `this.profile.last_login !== undefined && …` ——
* **已经显式判过 undefined**(只是用了 !== undefined 而不是 ??,
* 所以上面那个正则没认出来)。等价且更明确。
*/
const EXEMPT = [
/^MailDetailPage\.ets: if \(this\.attachments\.length/,
/^MailDetailPage\.ets: Text\('附件 ' \+ this\.attachments\.length\)/,
/^InboxPage\.ets: if \(mail\.attachments\.length/,
/^MailDetailPage\.ets: if \(this\.renameProposal\.reason\.length/,
/^SettingsPage\.ets: \(this\.profile\.last_login !== undefined/
];
const real = hits.filter(h => !EXEMPT.some(re => re.test(h)));
assert.deepEqual(real, [],
`这些地方直接读了服务端带 omitempty 的字段(缺失时是 undefined,会抛):\n ${real.join('\n ')}\n` +
'加 `?? []` / `?? \'\'` 兜底(见 Models.ets 顶部那段教训)');
// 自检:探测器要认得错误形状,也要认得兜底后的正确形状
const probe = (t, f) => {
const re = new RegExp(`\\.${f}\\s*\\.\\s*(length|trim)\\b`, 'g');
return [...t.matchAll(re)].filter(mm => {
const line = t.slice(t.lastIndexOf('\n', mm.index) + 1, t.indexOf('\n', mm.index));
return !/\?\?|\|\|/.test(line);
}).length;
};
assert.equal(probe('const n = mail.attachments.length;', 'attachments'), 1, '要抓得住裸读');
assert.equal(probe('const n = (mail.attachments ?? []).length;', 'attachments'), 0, '不许误伤兜底写法');
});

View File

@ -33,6 +33,12 @@ const COMM_TS = join(HARMONY_ETS, 'model/CommTabs.ts');
/** 被测对象:鸿蒙客户端真正引用的那份逻辑(不是复制品) */
const H = await import(pathToFileURL(MODULE_TS).href);
/*
* 附件格式化(`model/Attachment.ts`,同样是纯逻辑、无 SDK 依赖)。
* ★ 直接执行鸿蒙那一份代码 —— 与 `H`/`C` 同一手法,验的是**行为**。
*/
const ATT_TS = join(HARMONY_ETS, 'model/Attachment.ts');
const ATT = await import(pathToFileURL(ATT_TS).href);
const C = await import(pathToFileURL(COMM_TS).href);
const page = code(join(HARMONY_ETS, 'pages/MainPage.ets'));
@ -871,3 +877,44 @@ test('★ 详情页折叠头部的展开区要有足够高度(14px 的横条
assert.ok(Number(h[1]) >= 32,
`★ 展开行的高度应 ≥32vp(实测 14px 太薄、点不中;修成 36 后实测可点区 43px)。实际 ${h[1]}`);
});
test('★ 附件区:字节数格式化与 WebUI 逐字一致(三档 + 保留一位小数)', () => {
/*
* ★★ 2026-09-20 加。鸿蒙原来**完全没有附件区** ——
* `MailDetail.attachments` 一直在模型里、服务端也在返回,界面一个都没画。
* WebUI `MailView.tsx:148/692` 两处都调 `AttachmentList`。
*
* 这里验的是格式化:期望值照 WebUI `api/client.ts:390 formatSize` **逐字**写,
* 不是"大概像就行" —— 两端同一个文件名旁边显示 `512 B` 与 `512.0 B`
* 会显得是两个不同的软件。
*
* ★ 边界各取一个,而不是只测"正常值":
* · `0` —— 0 字节文件真实存在(空文件),不能显示成空串
* · `1023` —— 最后一个 B 档(`< 1024`,不加小数)
* · `1024` —— 第一个 KB 档(**跨越点**,最容易差一位)
* · `1048575`—— 最后一个 KB 档
* · `1048576`—— 第一个 MB 档(第二个跨越点)
*/
assert.equal(ATT.formatSize(0), '0 B', '0 字节要显示成 "0 B",不能是空串');
assert.equal(ATT.formatSize(512), '512 B', 'B 档是整数、不加小数(与 WebUI 同)');
assert.equal(ATT.formatSize(1023), '1023 B', '1023 是最后一个 B 档');
assert.equal(ATT.formatSize(1024), '1.0 KB', '1024 跨到 KB 档,且保留一位小数');
assert.equal(ATT.formatSize(1536), '1.5 KB', 'KB 档保留一位小数');
assert.equal(ATT.formatSize(1048575), '1024.0 KB', '最后一个 KB 档(WebUI 也是 1024.0 KB,不提前进位)');
assert.equal(ATT.formatSize(1048576), '1.0 MB', '1048576 跨到 MB 档');
assert.equal(ATT.formatSize(3 * 1024 * 1024), '3.0 MB', 'MB 档保留一位小数');
});
test('★ 附件区:空文件名要显式占位,不能留一条看起来坏掉的空行', () => {
/*
* 服务端理论上不会给空文件名,但真出现时界面上会是一条**看不出来是错的**
* 空行(比显式占位更难诊断)。占位符不是文案偏好,是**可诊断性**。
*/
const ok = ATT.attachmentLabel('report.pdf', 2048);
assert.equal(ok.filename, 'report.pdf');
assert.equal(ok.size, '2.0 KB');
const empty = ATT.attachmentLabel('', 100);
assert.notEqual(empty.filename, '', '空文件名要有占位符,不能是空串');
assert.equal(empty.size, '100 B', '空文件名不影响大小显示');
});

View File

@ -78,7 +78,7 @@ const SUITE = [
// 预设的**行为**判据:每一档都真的画得出来(能真跑,不需要设备 ⇒ 不进 static 欠账)。
// 与 appearance-defaults 那条「清单 id/顺序相等」配对:值判据管清单,行为判据管渲染器。
['test/harmony-presets.test.mjs', ['--experimental-strip-types', '--no-warnings'], 6],
['test/harmony-logic.test.mjs', ['--experimental-strip-types', '--no-warnings'], 32],
['test/harmony-logic.test.mjs', ['--experimental-strip-types', '--no-warnings'], 34],
['test/harmony-system-api.test.mjs', [], 5],
// P4 外观同步:跑 model/Appearance.ts(纯逻辑),所以也要 strip-types
['test/harmony-appearance.test.mjs', ['--experimental-strip-types', '--no-warnings'], 27],
@ -121,7 +121,7 @@ const SUITE = [
['test/harmony-imageprep.test.mjs', ['--experimental-strip-types', '--no-warnings'], 31],
// ArkTS **编译期**硬规则(纯文本可判、不需要设备)。这一条是构建撞出来的:
// 我把常量表插在了既有 import 之前 ⇒ arkts-no-misplaced-imports,而当时没有任何判据会跑它。
['test/harmony-arkts.test.mjs', [], 5],
['test/harmony-arkts.test.mjs', [], 7],
['test/harmony-contacts.test.mjs', [], 5],
// ★★ 下面三条是**补接线**,不是新写的判据(2026-09-17)。
//