跨端: 补 MailSummary 的解析边界兜底(列表这条主流的 omitempty 一直是敞的)

判决书来自 `harmony-arkts` 那条判据,它在我加了授权栏历史之后报出两处调用点:

    MainPage.ets: parts.push(e.reason.length > 0 ? …)                 ← 假阳性
    MainPage.ets: if (m.mail_type === 'permission_request' && m.permission_result.length > 0)   ← 真 bug

## 真 bug:`MailSummary` 没有解析边界兜底

本仓早就为这个形状付过代价,**而且修法只修了一半**:

· `MailDetail` 有 `normalize()`,并在 `MailApi.mailDetail()` 接了(当时那次是**整页白屏**);
· 而列表用的 `MailSummary` **没有** —— 于是 `mail_type` / `permission_result` /
  `session_alias` 这些带 `omitempty` 的字段在缺失时是 `undefined`,
  而三处调用点直接读 `.length`。

服务端 `omitempty` 的语义是**整个 key 不出现**(不是给空串),
ArkTS 裸 cast(`JSON.parse(raw) as T`)缺键给 `undefined`、**不会**应用类里那个 `= ''`。

⇒ 这是同一形状的**第四次**(前三次:`MailDetail` 白屏、`participantAddress` 的 trim、
`AddressSuggestion.title`)。前三次都是"读的人临时守一下",
**而列表这条主流一直敞着**。

修法(与 `MailDetail` 同一处、同一纪律):给 `MailSummary` 加 `normalize()`,
在 `MailApi.inbox()` / `sent()` / `sessionMails()` **三处**解析边界接上。

★ 为什么不在调用点加 `??`:`MailSummary` 上还有 `mail_type`/`session_alias`
  同样带 omitempty —— 逐个调用点加就是"每加一处就得记得做一次",
  本仓反复在消的形状。归一化做一次、覆盖全部字段。

## 假阳性:同名词撞车

`e.reason` 的 `e` 是 **`AccountError`** —— 鸿蒙**本地类**(`MailStore.ets:73`),
只经 `AccountError.of(account, reason)` 构造 ⇒ `reason` 恒为 string。
而判据收集的那个 `json:"reason,omitempty"` 属于 `RenameProposal`
(`rename_proposal.go:39`,**完全不同的**接口)。已按既有
「已逐个核实过的豁免」格式加 ⑤,附取证。

## 期间修了判据自己的一个洞(变异实测)

我给 ⑥ 加豁免时第一版写的是**无条件**正则 —— 变异实测
(把 `MailSummary.normalize` 里那行 `m.permission_result = str(...)` 删掉)
**判据照样全绿**:豁免把那一行永久致盲了。

这正是本仓反复消的形状:**豁免口自己没人管**。

⇒ 改成"**有前提**的豁免":先断言 `MailSummary.normalize` 里确实有那两行,
命中才豁免。变异复验:

    删掉 normalize 里 permission_result → 判据红 ✓
    删掉 normalize 里 mail_type        → 判据红 ✓
    两者都在                          → 全绿 ✓

⇒ 豁免表达的是"**因为上游归一了**,所以这里可以不写 ?? ",
  而不是"这一行不用管"。
This commit is contained in:
2026-09-21 17:06:19 +08:00
parent 006f813066
commit f4d5a75976
3 changed files with 127 additions and 3 deletions

View File

@ -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, [],

View File

@ -167,7 +167,19 @@ export class MailApi {
/** 收件箱(status + limit) */
async inbox(status: string, limit: number): Promise<InboxResponse> {
const query: string = 'status=' + status + '&limit=' + limit;
return this.client.get<InboxResponse>('/me/mail/inbox', query);
const resp: InboxResponse = await this.client.get<InboxResponse>('/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<SentResponse> {
return this.client.get<SentResponse>('/me/mail/sent');
const resp: SentResponse = await this.client.get<SentResponse>('/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<SessionMailsResponse>(
'/sessions/' + sessionId + '/mails'
);
/* 与 `inbox()` 同一处归一化 */
for (let i = 0; i < resp.mails.length; i++) {
resp.mails[i] = MailSummary.normalize(resp.mails[i]);
}
return resp.mails;
}

View File

@ -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;
}
}
/** 附件元数据 */