跨端: feat(推送客户端) 收尾两处接线(pi 邮件 66bbd929 分工)+ 换账号补报

`PushService.ets` 与 `pendingRoute` 都早就写好了,但**没人调用/没人读** ——
而"写好了"与"接上了"是两件事:ArkTS 只编译可达模块,往一个无人 import 的
文件里放必然报错的类型错误,`assembleHap` 照样 BUILD SUCCESSFUL
(pi 与我各复现过一个方向)。这次补的是**边**,不是点。

① 登录成功补报(LoginPage):`EntryAbility.onCreate` 那次在**登录之前**跑,
   那时 `ApiClient` 还没有 token ⇒ 服务端 401,而 `reportToken` 的失败是静默的
   ⇒ "启动时报过"不等于"这个账号登记过"。补三条登录路径,一条都不能漏:
   快速路径(已有账号直接进主界面,老用户走这条)/ tryRestore / doLogin。

② 换账号补报(SettingsPage.switchTo,**我加的第三处**):服务端语义是同一
   token 换账号**转移**而非并存 ⇒ 新账号其实没登记过。幂等标记按
   `accountKey|token` 存,所以这里调一次必然重发,不会被"我报过"挡掉。
   不补的症状:切完账号,通知仍推给上一个账号。

③ pendingRoute 消费端(MainPage.CommPage):`EntryAbility` 已把通知 data
   解析成 `pendingRoute`(冷启 onCreate / 热启 onNewWant 都写了),
   **但没有任何东西读它** ⇒ 点通知只拉起 App、停在列表页。
   在 CommPage 消费(详情页是这个 Navigation 的 navPathStack 上的路由,
   栈只有它持有),消费后**立刻清空**(它是"待处理"不是"当前页",
   不清则返回列表再进来会被反复跳走)。

判据:`harmony-push.test.mjs` 15 → 18 条,三条新判据钉的就是上面这三条边,
**三条都做了变异验证**(各自删掉对应接线即红、恢复即绿)。
`LoginPage` 那条数**恰好 3 处**调用:少了快速路径=老用户永远不补报,
多了要问清是哪条路径。

编译验证(不是"命令成功",是"它真读了这个文件"):三处改动各植入一次必然
类型错误 ⇒ BUILD FAILED 且 `At File:` 指到**新代码那一行**(MainPage:1173 /
LoginPage:108 / SettingsPage:456),恢复后 BUILD SUCCESSFUL。
产物侧证:`modules.abc` 里能查到 `consumePendingRoute`(2) / `reportPushToken`(2)。
This commit is contained in:
2026-09-17 19:03:14 +08:00
parent a47b42b36e
commit 423ff9fbb2
5 changed files with 162 additions and 1 deletions

View File

@ -187,3 +187,66 @@ test('★ 取 token 前提②:reportToken 里 requestEnableNotification 在 ge
assert.ok(permAt < tokenAt,
'通知权限申请必须在 getToken **之前** —— 部分设备上权限未开时 getToken 返回空 / 报 1600004(2026-09-17 实测)');
});
/*
* ★★ 三条**接线**判据(2026-09-17 收尾,pi 邮件 `66bbd929` §三 分工给我那两处 + 我加的第三处)。
*
* 为什么要有它们:`PushService.reportToken` 与 `PushService.pendingRoute` 都**写好了**,
* 但"写好了"和"有人调用/有人读"是两件事 —— 而**编译通过不覆盖后者**:
* ArkTS 只编译**可达**模块,一个没人 import 的新文件里放必然报错的类型错误,
* `assembleHap` 照样 BUILD SUCCESSFUL(pi 与我各自复现过这个反向对照)。
*
* 所以这三条钉的是**边**,不是**点**:谁调用 `reportToken`、谁消费 `pendingRoute`。
* 它们都是**静态**判据(读形状),因为"点通知真的跳过去"只有设备能判;
* 设备那半的到期前提记在 `docs/DEBTS.json` 的 static-criteria 里。
*/
test('★ 接线①:登录成功必须补报(启动那次在登录之前 ⇒ 没 token ⇒ 静默 401,覆盖不到登录)', () => {
const login = code(join(HERE, '..', '..', 'harmony', 'entry', 'src', 'main', 'ets', 'pages', 'LoginPage.ets'));
/*
* 三条"登录"路径,**每条都要补报**:
* ① aboutToAppear 的快速路径(已有账号直接进主界面)—— 老用户走这条;
* ② tryRestore(旧 token 恢复);
* ③ doLogin(手填账号密码 / 密钥)。
* 只覆盖 doLogin 是那种"看起来做了、漏掉最常走的那条"的写法,所以这里数**调用点个数**。
*/
const calls = (login.match(/this\.reportPushToken\(/g) || []).length;
assert.equal(calls, 3,
`LoginPage 里补报调用应恰好 3 处(快速路径 / tryRestore / doLogin 各一处),实得 ${calls} 处。` +
' 少了快速路径 = 老用户永远不补报(最需要推送的那批);多了则要问清楚是哪条路径。');
assert.match(login, /PushService\.getInstance\(ctx as common\.UIAbilityContext\)/,
'补报要走 PushService 单例(与 SettingsPage 同一形状)');
assert.match(login, /\.catch\(\(err: Object\)/,
'补报必须**吞掉失败** —— 推送是可选通道,不能因它弹错或拖住登录跳转');
assert.match(login, /push\.reportToken\(c\)|push\.reportToken\(client\)/,
'要真的把 ApiClient 传进 reportToken(带上刚登录的凭证)');
});
test('★ 接线②:换账号后必须补报(同一 token 换账号是"转移"⇒ 新账号其实没登记过)', () => {
const settings = code(join(HERE, '..', '..', 'harmony', 'entry', 'src', 'main', 'ets', 'pages', 'SettingsPage.ets'));
const lines = settings.split('\n');
const start = lines.findIndex(l => /async switchTo\(/.test(l));
assert.ok(start > 0, '要能找到 SettingsPage.switchTo');
const body = lines.slice(start, start + 45).join('\n');
assert.match(body, /reportToken\(/,
'switchTo 里必须补报一次:服务端里同一 token 换账号是**转移**(不是并存),' +
'不补报的症状是"切完账号,通知仍推给上一个账号"。');
assert.match(body, /PushService\.getInstance/,
'switchTo 里要走 PushService 单例');
});
test('★ 接线③:pendingRoute 必须有人**消费**(EntryAbility 只写不读 ⇒ 点通知停列表页)', () => {
const main = code(join(HERE, '..', '..', 'harmony', 'entry', 'src', 'main', 'ets', 'pages', 'MainPage.ets'));
const ent = code(join(HERE, '..', '..', 'harmony', 'entry', 'src', 'main', 'ets', 'entryability', 'EntryAbility.ets'));
// 写侧:ability 解析 want(冷启 onCreate + 热启 onNewWant)
const writes = (ent.match(/PushService\.pendingRoute = route/g) || []).length;
assert.equal(writes, 2, 'EntryAbility 的 onCreate 与 onNewWant 都要写 pendingRoute(冷启漏了就只有热启能跳)');
// 读侧:必须有页面真的读它,并**清空**它
assert.match(main, /PushService\.pendingRoute/,
'MainPage 必须读 pendingRoute —— 只有 EntryAbility 写、没人读,等于"点通知只拉起 App"');
assert.match(main, /PushService\.pendingRoute = undefined/,
'消费后必须清空:它是"待处理"不是"当前页",不清的话返回列表再进这页会被反复跳走');
// 消费端要真的落到邮件详情(而不是只把变量读出来丢掉)
assert.match(main, /this\.openMail\(route\.mailId/,
'消费 pendingRoute 要落到邮件详情(openMail 往 navPathStack 压详情路由)');
});

View File

@ -80,7 +80,10 @@ const SUITE = [
['test/packaging.test.mjs', [], 5],
['test/align-refs.test.mjs', [], 3],
['test/harmony-deviceprobe.test.mjs', ['--experimental-strip-types', '--no-warnings'], 8],
['test/harmony-push.test.mjs', ['--experimental-strip-types', '--no-warnings'], 13],
// 契约/形状 15 条 + ★接线 3 条(2026-09-17 收尾:登录后补报 / 换账号补报 / pendingRoute 消费端)。
// 前 15 条钉的是"点"(逻辑对不对),后 3 条钉的是"边"(谁调用、谁消费)——
// 而"写好了但没人调用/没人读"编译一样通过(ArkTS 只编译可达模块,反向对照已复现过)。
['test/harmony-push.test.mjs', ['--experimental-strip-types', '--no-warnings'], 18],
// 服务器地址(apiBase):补 /api/v1 / 去尾斜杠不吃协议 // / 校验自带修法 /
// 明文只对公网告警 / 404 说清“少了 /api/v1” / 网络错误码分类 / 归一化只有一份实现。
// 值判据跑真逻辑(model/ApiBase.ts);`.ets` 那几条是**静态**接线判据(无设备)。