diff --git a/client/electron/test/harmony-arkts.test.mjs b/client/electron/test/harmony-arkts.test.mjs index 2e8ace3..50a6e5c 100644 --- a/client/electron/test/harmony-arkts.test.mjs +++ b/client/electron/test/harmony-arkts.test.mjs @@ -504,13 +504,70 @@ test('★ 服务端「只以 omitempty 形式出现」的字段:客户端读 * 那里写的是 `this.profile.last_login !== undefined && …` —— * **已经显式判过 undefined**(只是用了 !== undefined 而不是 ??, * 所以上面那个正则没认出来)。等价且更明确。 + * + * ⑤ `reason`(`MainPage.ets` 的 `e.reason`,2026-09-21 加): + * 这里命中纯属**同名词撞车**。判据是把服务端所有 `json:"reason,omitempty"` + * 的字段名收集起来,再回 `.ets` 里按名字扫 —— 而那个 omitempty 声明属于 + * `RenameProposal`(`rename_proposal.go:39`,一个**完全不同的**接口)。 + * `e` 是 **`AccountError`**,那是鸿蒙**本地类**(`MailStore.ets:73`), + * 实例只经 `AccountError.of(account, reason)` 构造 ⇒ `reason` 恒为 string。 + * ⇒ 与 `Mail` 的 omitempty **毫无关系**,不存在 undefined。 + * + * ⑥ `permission_result`(`MainPage.ets` 的 `m.permission_result`,2026-09-21 加): + * ★ 这一处**原先是真的会抛**,而修法不是在这里加 `??` —— + * 而是**在解析边界归一化**(本仓那条纪律:`MailDetail` 就是那么修的)。 + * 已给 `MailSummary` 补了 `normalize()`(`Models.ets`), + * 并在 `MailApi.inbox()` / `sent()` / `sessionMails()` **三处**接上 + * ⇒ 能走到这一行的 `m` 一定是归一过的,`permission_result` 必为 string。 + * ⇒ 判据看不见"上游归一过了",所以这里必须显式豁免,并把理由写清。 + * + * ★ 为什么不在这一行加 `??`:那会把**根因**(列表这条主流没有解析边界 + * 兜底)继续盖住,而 `MailSummary` 上还有 `mail_type` / `session_alias` + * 同样带 omitempty —— 逐个调用点加 `??` 就是本仓反复在消的形状 + * ("每加一处就得记得做一次")。归一化只做一次、且覆盖全部字段。 */ + /* + * 豁免 ⑥ 的**前提**:`MailSummary.normalize()` 真的把两个 omitempty 字段补了。 + * + * 为什么要有这一步:无条件豁免 = 把那一行永久致盲(变异实测过,见 ⑥ 的注释)。 + * 判据要表达的是"**因为上游归一了**,所以这里可以不写 `??`", + * 而不是"这一行不用管"。 + */ + const modelsSrc = prose(join(ETS_ROOT, 'model/Models.ets')); + const summaryCls = (() => { + const at = modelsSrc.indexOf('export class MailSummary'); + if (at < 0) return ''; + const end = modelsSrc.indexOf('\nexport class ', at + 10); + return modelsSrc.slice(at, end < 0 ? undefined : end); + })(); + const normalizeCoversOmitemptyFields = + /static normalize\(m: MailSummary\)/.test(summaryCls) && + /m\.permission_result = str\(m\.permission_result\)/.test(summaryCls) && + /m\.mail_type = str\(m\.mail_type\)/.test(summaryCls); + 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/ + /^SettingsPage\.ets: \(this\.profile\.last_login !== undefined/, + /* ⑤ 同名词撞车:`AccountError.reason` 是本地类(见上面 ⑤ 的取证) */ + /^MainPage\.ets: parts\.push\(e\.reason\.length/, + /* + * ⑥ 已在解析边界归一化(见上面 ⑥)。 + * + * ★★ 这条豁免**不能无条件** —— 我第一版就是无条件正则,变异实测 + * (把 `MailSummary.normalize` 里那行 `m.permission_result = str(...)` 删掉) + * **判据照样全绿**:豁免把这一行永久致盲了。 + * 那正是本仓反复消的形状 —— **豁免口自己没人管**。 + * + * ⇒ 豁免必须**以"上游真的归一了"为前提**:先断言 + * `MailSummary.normalize` 里确实有这两行,命中才豁免。 + * 归一被删 ⇒ 前提不成立 ⇒ 豁免失效 ⇒ 这一行重新报警。 + */ + ...(normalizeCoversOmitemptyFields + ? [/^MainPage\.ets: if \(m\.mail_type === 'permission_request' && m\.permission_result\.length/] + : []) ]; const real = hits.filter(h => !EXEMPT.some(re => re.test(h))); assert.deepEqual(real, [], diff --git a/client/harmony/entry/src/main/ets/api/MailApi.ets b/client/harmony/entry/src/main/ets/api/MailApi.ets index bd5f725..c37eeb9 100644 --- a/client/harmony/entry/src/main/ets/api/MailApi.ets +++ b/client/harmony/entry/src/main/ets/api/MailApi.ets @@ -167,7 +167,19 @@ export class MailApi { /** 收件箱(status + limit) */ async inbox(status: string, limit: number): Promise { const query: string = 'status=' + status + '&limit=' + limit; - return this.client.get('/me/mail/inbox', query); + const resp: InboxResponse = await this.client.get('/me/mail/inbox', query); + /* + * ★★ 2026-09-21 补归一化 —— 与 `mailDetail()` 同一处、同一理由: + * 服务端 `mail_type` / `permission_result` / `session_alias` 带 `omitempty`, + * 零值时**整个 key 不出现**,而这里是裸 `JSON.parse as T` ⇒ 缺失字段是 + * `undefined`,下游 `.length` 当场抛。 + * 此前只给 `MailDetail` 归了,**列表这条主流一直敞着** + * (判据 `harmony-arkts` 报的就是它)。 + */ + for (let i = 0; i < resp.mails.length; i++) { + resp.mails[i] = MailSummary.normalize(resp.mails[i]); + } + return resp; } /** @@ -175,7 +187,12 @@ export class MailApi { * (收件箱的 `total` 是未读数,见 docs/API.md 的「同名不同义」),这里也不读它。 */ async sent(): Promise { - return this.client.get('/me/mail/sent'); + const resp: SentResponse = await this.client.get('/me/mail/sent'); + /* 与 `inbox()` 同一处归一化(发件箱回包同样是裸 cast) */ + for (let i = 0; i < resp.mails.length; i++) { + resp.mails[i] = MailSummary.normalize(resp.mails[i]); + } + return resp; } /** @@ -262,6 +279,10 @@ export class MailApi { const resp: SessionMailsResponse = await this.client.get( '/sessions/' + sessionId + '/mails' ); + /* 与 `inbox()` 同一处归一化 */ + for (let i = 0; i < resp.mails.length; i++) { + resp.mails[i] = MailSummary.normalize(resp.mails[i]); + } return resp.mails; } diff --git a/client/harmony/entry/src/main/ets/model/Models.ets b/client/harmony/entry/src/main/ets/model/Models.ets index 317e3c6..48d7a72 100644 --- a/client/harmony/entry/src/main/ets/model/Models.ets +++ b/client/harmony/entry/src/main/ets/model/Models.ets @@ -111,6 +111,52 @@ export class MailSummary implements MailLike { /** 客户端聚合字段:服务端不返回,由收件箱按来源账号填充。 */ source_account_id: string = ''; source_account_name: string = ''; + + /** + * **列表行**的解析边界兜底(与 `MailDetail.normalize` 同一职责、同一个理由)。 + * + * ★★ 2026-09-21 补。此前**只有 `MailDetail` 有 normalize**,而列表用的 + * `MailSummary` 没有 —— 于是带 `omitempty` 的字段在缺失时是 `undefined`, + * 而调用点直接写了 `.length`: + * + * MainPage.ets m.permission_result.length > 0 + * MailGrouping.ts !m.permission_result + * MainPage.ets c.session_alias.length > 0 + * + * 服务端 `omitempty` 的语义是**整个 key 不出现**(不是给空串), + * 而 ArkTS 的裸 cast(`JSON.parse(raw) as T`)缺键时给 `undefined`、 + * **不会**应用类里那个 `= ''` ⇒ 读 `.length` 抛 + * `Cannot read property length of undefined`。 + * + * ⇒ 这是本仓**第四次**同一形状(前三次:`MailDetail` 白屏、 + * `participantAddress` 的 trim、`AddressSuggestion.title`)。 + * 前三次都是"读的人临时守一下",而**列表这条主流一直是敞着的**。 + * + * ★ 钉住它的判据:`harmony-arkts.test.mjs` 的 + * 「服务端『只以 omitempty 形式出现』的字段:客户端读它时必须有 `??` 兜底」 + * —— 正是它把这个漏报出来的(它报的是两处调用点,根因是这里缺 normalize)。 + * + * 调用点:`MailApi.inbox()` / `MailApi.sessionMails()`(解析边界)。 + */ + static normalize(m: MailSummary): MailSummary { + m.mail_id = str(m.mail_id); + m.session_id = str(m.session_id); + m.session_alias = str(m.session_alias); + m.from_name = str(m.from_name); + m.to_name = str(m.to_name); + m.subject = str(m.subject); + m.body_preview = str(m.body_preview); + m.created_at = str(m.created_at); + m.status = str(m.status); + m.is_read = boolOr(m.is_read, true); + m.permission_mode = str(m.permission_mode); + /* 两个 `omitempty` 字段(缺失时是 undefined)—— 判据命中的就是它们 */ + m.mail_type = str(m.mail_type); + m.permission_result = str(m.permission_result); + m.attachments = m.attachments === undefined || m.attachments === null ? [] : m.attachments; + m.cc_list = m.cc_list === undefined || m.cc_list === null ? [] : m.cc_list; + return m; + } } /** 附件元数据 */