From 423ff9fbb2459e168c17463f6983c2f5e52c3b90 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Thu, 17 Sep 2026 19:03:14 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B7=A8=E7=AB=AF:=20feat(=E6=8E=A8=E9=80=81?= =?UTF-8?q?=E5=AE=A2=E6=88=B7=E7=AB=AF)=20=E6=94=B6=E5=B0=BE=E4=B8=A4?= =?UTF-8?q?=E5=A4=84=E6=8E=A5=E7=BA=BF=EF=BC=88pi=20=E9=82=AE=E4=BB=B6=206?= =?UTF-8?q?6bbd929=20=E5=88=86=E5=B7=A5=EF=BC=89+=20=E6=8D=A2=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E8=A1=A5=E6=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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)。 --- client/electron/test/harmony-push.test.mjs | 63 +++++++++++++++++++ client/electron/test/run-all.mjs | 5 +- .../entry/src/main/ets/pages/LoginPage.ets | 38 +++++++++++ .../entry/src/main/ets/pages/MainPage.ets | 41 ++++++++++++ .../entry/src/main/ets/pages/SettingsPage.ets | 16 +++++ 5 files changed, 162 insertions(+), 1 deletion(-) diff --git a/client/electron/test/harmony-push.test.mjs b/client/electron/test/harmony-push.test.mjs index 68ad779..142dfc9 100644 --- a/client/electron/test/harmony-push.test.mjs +++ b/client/electron/test/harmony-push.test.mjs @@ -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 压详情路由)'); +}); + diff --git a/client/electron/test/run-all.mjs b/client/electron/test/run-all.mjs index 309bdec..2ae392b 100644 --- a/client/electron/test/run-all.mjs +++ b/client/electron/test/run-all.mjs @@ -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` 那几条是**静态**接线判据(无设备)。 diff --git a/client/harmony/entry/src/main/ets/pages/LoginPage.ets b/client/harmony/entry/src/main/ets/pages/LoginPage.ets index db17f97..6bf8888 100644 --- a/client/harmony/entry/src/main/ets/pages/LoginPage.ets +++ b/client/harmony/entry/src/main/ets/pages/LoginPage.ets @@ -11,6 +11,8 @@ import { SseService } from '../api/SseService'; import { Me } from '../model/Models'; import { DEFAULT_API_BASE, EMULATOR_HOST_BASE } from '../common/Config'; import { ApiBaseCheck, validateApiBase } from '../model/ApiBase'; +import { PushService } from '../api/PushService'; +import { common } from '@kit.AbilityKit'; import { hilog } from '@kit.PerformanceAnalysisKit'; @Entry @@ -47,6 +49,12 @@ struct LoginPage { this.serverAddr = active.server; // 为所有账号建立 SSE 连接 SseService.getInstance().connectAll(acctMgr); + /* + * ★ 这条快速路径也是"一次登录",所以同样要补报: + * 它**不经** doLogin/tryRestore,是"已有账号直接进主界面"。 + * 漏了这里,老用户(最需要推送的那批)恰好永远不补报。 + */ + this.reportPushToken(client); this.getUIContext().getRouter().pushUrl({ url: 'pages/MainPage' }); return; } @@ -73,6 +81,7 @@ struct LoginPage { const user = await new AuthApi(c).loginWithKey(token); this.me = user; this.loggedIn = true; + this.reportPushToken(c); } catch (e) { const c2: ApiClient | null = this.client; if (c2 !== null) { @@ -83,6 +92,33 @@ struct LoginPage { } } + /** + * 登录成功后的**补报**(收尾项,pi 邮件 `66bbd929` §三)。 + * + * 为什么必须有这一处:`EntryAbility.onCreate` 里那次上报发生在**登录之前** —— + * 那时 `ApiClient` 还没有 token,服务端回 401,而 `reportToken` 的失败是**静默**的 + * (设计如此),所以"启动时报过一次"并不等于"这个账号登记过"。 + * 少了这一处,链路的症状是:App 能登录、能 GET,但**永远不 POST** token。 + * + * 三条性质沿用 `PushService.reportToken` 自己的:开关 gate 在最前(关着就零开销)、 + * 失败全静默(不弹、不阻塞登录跳转)、且**幂等**(已报过且账号没换就不打网络)。 + * 所以这里不 await、不 catch 提示 —— 登录跳转不该被一个可选通道拖住。 + */ + private reportPushToken(c: ApiClient): void { + const ctx: Context | undefined = this.getUIContext().getHostContext(); + if (ctx === undefined) { + return; + } + try { + const push: PushService = PushService.getInstance(ctx as common.UIAbilityContext); + push.reportToken(c).catch((err: Object) => { + hilog.info(0x0001, 'LoginPage', '登录后补报失败(静默):%{public}s', JSON.stringify(err)); + }); + } catch (e) { + hilog.info(0x0001, 'LoginPage', '登录后补报跳过(静默):%{public}s', JSON.stringify(e)); + } + } + async doLogin(): Promise { if (this.loading) { return; @@ -131,6 +167,8 @@ struct LoginPage { // 为该账号建立 SSE 连接 SseService.getInstance().connectForAccount(acct.id, acct.server, acct.token); } + // ★ 登录成功 ⇒ 补报一次推送 token(见 reportPushToken 的说明:启动那次是在登录之前) + this.reportPushToken(c); // 跳转主界面(含 Tab 导航) await this.getUIContext().getRouter().pushUrl({ url: 'pages/MainPage' }); } catch (e) { diff --git a/client/harmony/entry/src/main/ets/pages/MainPage.ets b/client/harmony/entry/src/main/ets/pages/MainPage.ets index c241af0..270a1a0 100644 --- a/client/harmony/entry/src/main/ets/pages/MainPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MainPage.ets @@ -52,6 +52,7 @@ import { emptyHint } from '../model/CommTabs'; import { MailDetailParams, ComposeParams } from '../model/RouteParams'; +import { PushService, PushRoute } from '../api/PushService'; /* * ⚠️ **`import` 必须在这一行的位置**:ArkTS 要求所有 import 都在**任何其它语句之前** * (`arkts-no-misplaced-imports`),与它们之间隔的是常量、表还是别的语句无关。 @@ -1142,6 +1143,46 @@ struct CommPage { aboutToAppear(): void { this.refreshCounts(); + this.consumePendingRoute(); + } + + /** + * 消费「点通知进来」的待处理跳转(收尾项,pi 邮件 `66bbd929` §三)。 + * + * 断链在哪:`EntryAbility` 已经把通知的 `data` 解析成 `PushService.pendingRoute` + * (冷启在 `onCreate`、热启在 `onNewWant`),**但没有任何东西读它** —— + * 于是"点通知打开那封信"这三件的最后一件是空的:通知会拉起 App, + * 然后停在列表页,用户还得自己找那封信。 + * + * 为什么在这里消费:跳转的落点是**邮件详情**,而详情页是这个 + * `Navigation` 的 `navPathStack` 上的路由 —— 这个栈只有 `CommPage` 持有 + * (`openMail` 就是往它上面 push)。在别处消费就得跨组件去够这个栈。 + * + * 三条边界: + * ① 没有待处理目标(正常启动)⇒ 什么都不做,**不清栈、不跳转**; + * ② 解析不出目标的 want 在 `EntryAbility` 那层就已经被丢掉了(静默), + * 所以到这里一定有 mail_id; + * ③ 消费后**立刻清空**那个静态格子:它是"待处理"而不是"当前页" —— + * 不清的话,用户返回列表再进这一页时会被再跳一次(永远回不到列表)。 + */ + private consumePendingRoute(): void { + const route: PushRoute | undefined = PushService.pendingRoute; + if (route === undefined) { + return; + } + PushService.pendingRoute = undefined; + /* + * 账号:通知的 `data` 里只有 mail_id/session_id(服务端契约如此),没有 account_id。 + * 所以用**当前活跃账号** —— 点通知的人就是本机正在用的那个人。 + */ + const ctx = this.getUIContext().getHostContext(); + let accountId: string = ''; + if (ctx !== undefined) { + accountId = AccountManager.getInstance(ctx).getActiveId(); + } + // 通知落的就是收件箱里那封信 ⇒ 先把栏切回收件箱,再压详情页 + this.commTab = 'inbox'; + this.openMail(route.mailId, accountId); } /** 徽标数字:未读(收件箱里**要读的**那些)+ 待决策(授权栏) */ diff --git a/client/harmony/entry/src/main/ets/pages/SettingsPage.ets b/client/harmony/entry/src/main/ets/pages/SettingsPage.ets index 55fd10f..6da420f 100644 --- a/client/harmony/entry/src/main/ets/pages/SettingsPage.ets +++ b/client/harmony/entry/src/main/ets/pages/SettingsPage.ets @@ -467,6 +467,22 @@ struct SettingsPage { client.setBase(account.server); client.setToken(account.token); this.getUIContext().getPromptAction().showToast({ message: '默认发信账号已设为 ' + account.displayName }); + /* + * ★ 换账号必须给**新账号**补报一次 token。 + * + * 服务端语义(pi 邮件 `2518e1a3`):同一个 token 换账号登录是**转移**,不是并存 —— + * 也就是"上一件事的登记已被转走",新账号其实**没登记过**。 + * `reportToken` 的幂等标记按 `accountKey|token` 存(`PushContract.reportMarker`), + * 所以这里调一次必然真的重发,不会被"我报过"挡掉。 + * 不补这一处,链路的症状是:切完账号,通知仍然推给**上一个**账号。 + */ + const ctx: Context | undefined = this.getUIContext().getHostContext(); + if (ctx !== undefined) { + const push: PushService = PushService.getInstance(ctx as common.UIAbilityContext); + push.reportToken(client).catch((err: Object) => { + hilog.info(0x0001, 'SettingsPage', '切账号后补报失败(静默):%{public}s', JSON.stringify(err)); + }); + } } this.refreshList(); }