From 8fed8401de6795dd7130f247dadea8b1e883df72 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Tue, 15 Sep 2026 11:40:54 +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=E5=A5=91=E7=BA=A6=E5=B1=82=20m?= =?UTF-8?q?odel/PushContract.ts=20+=206=20=E6=9D=A1=E8=AE=BE=E5=A4=87?= =?UTF-8?q?=E6=97=A0=E5=85=B3=E5=88=A4=E6=8D=AE=EF=BC=88=E9=9D=99=E9=BB=98?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5/enabled:false=20=E6=AD=A3=E5=B8=B8=E6=80=81/?= =?UTF-8?q?=E6=8C=89=20provider+tail=20=E6=AF=94/=E6=8C=89=20mail=5Fid=20?= =?UTF-8?q?=E5=8E=BB=E9=87=8D=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pi 2026-09-14 推送契约的客户端半边。四条不变量里**三条是纯逻辑**,所以先落这三条, 平台调用(Push Kit 取 token、通知权限)留在下一步的 `PushService.ets` 里。 - `shouldReportToken`:取不到 token 不上报;与上次相同不重复上报(否则每次启动打一次接口); - `tokenTail` / `isRegistered`:GET **只回尾 6 位** ⇒ "登记过没有"必须按 `provider + tail` 比。 比全文是**看起来更严、其实永远为假**的写法(全文永不等于尾 6 位 ⇒ 每次启动重复上报), 所以有判据钉它,并在注释里写明"同尾 6 位即视为同一条 —— 这是服务端给的信息量的上界,不是我们的选择"; - `classifyRegister`:`ok-enabled` / `ok-disabled` / `silent-skip` —— **三种里没有一种是"提示失败"** (`enabled:false` 是自部署常态 ⇒ 不重试、不提示); - `parseNotificationData`:不满足约定形状就返回 undefined(坏 JSON 不抛、缺 mail_id/动作不符都忽略)—— 推送是可选通道,收到不认识的东西不许有任何副作用; - `NotificationLedger`:服务端**无幂等键**(至多一次、无重试/去重表)⇒ 重复保护落客户端;台账**有界**。 另有一条判据禁止契约层引入 `@ohos`/`@kit`(否则这些判据会退化成必须上设备)。 它第一次跑**咬到了解释这条规则的那行注释** ⇒ 改扫 `code()`(去注释),与前面扫描口径那次同族。 `npm test`(install 相位)全绿;余额 `debts=13`。 --- client/electron/test/harmony-push.test.mjs | 73 +++++++++ client/electron/test/run-all.mjs | 1 + .../entry/src/main/ets/model/PushContract.ts | 151 ++++++++++++++++++ 3 files changed, 225 insertions(+) create mode 100644 client/electron/test/harmony-push.test.mjs create mode 100644 client/harmony/entry/src/main/ets/model/PushContract.ts diff --git a/client/electron/test/harmony-push.test.mjs b/client/electron/test/harmony-push.test.mjs new file mode 100644 index 0000000..ca1545f --- /dev/null +++ b/client/electron/test/harmony-push.test.mjs @@ -0,0 +1,73 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { code } from './lib/read.mjs'; + +/* +推送客户端契约层(pi 2026-09-14)。四条不变量里**三条是纯逻辑**,所以不需要设备就能钉: +静默失败、`enabled:false` 是正常态、按 `provider+tail` 比、通知按 `mail_id` 去重。 +*/ + +const HERE = dirname(fileURLToPath(import.meta.url)); +const HARMONY = join(HERE, '..', '..', 'harmony', 'entry', 'src', 'main', 'ets', 'model'); +const C = await import(pathToFileURL(join(HARMONY, 'PushContract.ts')).href); + +test('★ 上报决策:空 token 不上报;与上次相同不上报;变了才上报', () => { + assert.equal(C.shouldReportToken('', ''), false, '取不到 token 就不许上报(静默跳过)'); + assert.equal(C.shouldReportToken('abc', 'abc'), false, '没变就不许重复上报(否则每次启动打一次接口)'); + assert.equal(C.shouldReportToken('abc', 'xyz'), true, '变了要上报'); + assert.equal(C.shouldReportToken('', 'xyz'), true, '第一次拿到要上报'); +}); + +test('★ "登记过没有"只按 provider + tail 比 —— 比全文是"看起来更严、其实永远为假"的写法', () => { + const token = 'AAAABBBBCCCC123456'; + const list = [{ provider: 'hms', token_tail: '123456' }]; + assert.equal(C.tokenTail(token), '123456', 'tail 取尾 6 位(与服务端同口径)'); + assert.equal(C.isRegistered(list, 'hms', token), true, '尾 6 位相同就算登记过'); + // 反证:为什么不能比全文 —— 服务端只回 tail,全文永远不等于 tail + assert.notEqual(token, list[0].token_tail, 'GET 只回尾 6 位'); + assert.equal(C.isRegistered(list, 'apns', token), false, 'provider 维度必须参与比较'); + assert.equal(C.isRegistered(list, 'hms', 'ZZZZZZZZZZZZ123456'), true, + '同尾 6 位即视为同一条 —— 这是服务端给的信息量的上界,不是我们的选择'); + assert.equal(C.tokenTail('abc'), 'abc', '短于 6 位时取全文(不补零、不截空)'); +}); + +test('★ 上报结果分类:三种里没有一种是"提示失败"(enabled:false 是正常态)', () => { + assert.equal(C.classifyRegister(true, false), 'ok-enabled'); + assert.equal(C.classifyRegister(false, false), 'ok-disabled', 'enabled:false 是正常态 ⇒ 不重试、不提示'); + assert.equal(C.classifyRegister(true, true), 'silent-skip', '失败归 silent-skip,即使服务端 enabled=true'); + assert.equal(C.classifyRegister(false, true), 'silent-skip'); +}); + +test('★ 通知 data:不满足约定形状就静默忽略(不"尽力打开某个页面")', () => { + const ok = C.parseNotificationData(JSON.stringify({ type: 'new_mail', mail_id: 'm1', session_id: 's1', action: 'open_mail' })); + assert.equal(ok.mail_id, 'm1'); + assert.equal(ok.session_id, 's1'); + assert.equal(C.parseNotificationData(''), undefined, '空串'); + assert.equal(C.parseNotificationData('{不是 json'), undefined, '坏 JSON 不许抛(推送是可选通道)'); + assert.equal(C.parseNotificationData(JSON.stringify({ action: 'open_mail' })), undefined, '缺 mail_id'); + assert.equal(C.parseNotificationData(JSON.stringify({ mail_id: 'm1', action: 'other' })), undefined, '动作不是 open_mail'); + assert.equal(C.parseNotificationData(JSON.stringify({ mail_id: '', action: 'open_mail' })), undefined, '空 mail_id'); +}); + +test('★ 去重台账:服务端无幂等键 ⇒ 重复保护落客户端;且有界', () => { + const led = new C.NotificationLedger(3); + assert.equal(led.shouldHandle('m1'), true, '第一次该处理'); + assert.equal(led.shouldHandle('m1'), false, '重复必须丢弃(服务端至多一次、无幂等键)'); + assert.equal(led.shouldHandle('m2'), true); + assert.equal(led.shouldHandle(''), false, '空 id 不处理'); + assert.equal(led.size(), 2); + led.shouldHandle('m3'); led.shouldHandle('m4'); + assert.equal(led.size(), 3, '有界(常驻 pane 不许无限长)'); + assert.equal(led.shouldHandle('m1'), true, '最旧的被挤出后可再处理(有界台账的代价,明写在这里)'); +}); + +test('★ 契约层必须保持"无 @ohos 依赖"(否则这些判据跑不了,会退化成必须上设备)', () => { + // 扫 code()(去注释):第一次跑这条时它咬到了**解释这条规则的那行注释** —— 与扫描口径同族的现成例子 + const src = code(join(HARMONY, 'PushContract.ts')); + assert.ok(!/@ohos|@kit\./.test(src), + 'PushContract.ts 里出现了 @ohos/@kit 依赖 ⇒ 判据将无法用 node 直接跑。' + + '**正确修法**:把平台调用留在 PushService.ets,纯决策留在这里(与 Calendar/Wallpaper 同模式)。' + + '**最常见的错误修法**:把这条断言删掉,让契约层的判据跟着一起失效。'); +}); diff --git a/client/electron/test/run-all.mjs b/client/electron/test/run-all.mjs index 51cce58..bf009d8 100644 --- a/client/electron/test/run-all.mjs +++ b/client/electron/test/run-all.mjs @@ -79,6 +79,7 @@ const SUITE = [ ['test/build-stamp.test.mjs', [], 7], ['test/packaging.test.mjs', [], 5], ['test/align-refs.test.mjs', [], 3], + ['test/harmony-push.test.mjs', ['--experimental-strip-types', '--no-warnings'], 6], ['test/harmony-calendar.test.mjs', ['--experimental-strip-types', '--no-warnings'], 10], ['test/debt-visibility.test.mjs', [], 1], ['test/commit-hygiene.test.mjs', ['--experimental-strip-types', '--no-warnings'], 2], diff --git a/client/harmony/entry/src/main/ets/model/PushContract.ts b/client/harmony/entry/src/main/ets/model/PushContract.ts new file mode 100644 index 0000000..ae412ec --- /dev/null +++ b/client/harmony/entry/src/main/ets/model/PushContract.ts @@ -0,0 +1,151 @@ +/** + * 推送客户端**契约层**(纯逻辑,无 `@ohos`)—— 与 `Wallpaper.ts`/`Calendar.ts` 同模式, + * 所以判据能用 node 直接跑,不需要设备。 + * + * 契约来源:pi 2026-09-14(服务端半边已实现,客户端这半边归我)。四条不变量: + * 1. **推送是可选通道,不是依赖**:任何一步失败都**静默跳过**(不报错、不阻塞、不弹失败提示); + * 2. **SSE 仍是主通道**,推送只是"App 不在前台时的第二通道"; + * 3. **`enabled:false` 是正常态**(自部署常态、没配凭证)⇒ 不重试、不提示失败; + * 4. **通知 `data` 按 `mail_id` 去重** —— 服务端推送是**至多一次、无幂等键** + * (`internal/push` 没有队列/重试/去重表),所以重复保护只能落在客户端(pi 2026-09-14 §三②)。 + */ + +export const PROVIDER_HMS: string = 'hms'; + +/** 上报体:`POST /api/v1/me/devices/push-token` */ +export interface PushRegisterBody { + provider: string; + token: string; + device_name?: string; +} + +export interface PushTokenEntry { + provider: string; + /** 服务端 GET **只回尾 6 位**,不回全文 */ + token_tail: string; + device_name?: string; +} + +/** `GET` 的响应(服务端未配凭证时 `enabled:false`,且**没有** tokens 字段) */ +export interface PushTokenList { + enabled: boolean; + tokens?: PushTokenEntry[]; +} + +/** 通知 `data` 的形状(服务端与客户端约定) */ +export interface PushNotificationData { + type: string; + mail_id: string; + session_id: string; + action: string; +} + +/** 上报决策:只有"有 token,且与上次上报的不同"才上报 —— 避免每次启动都打一次接口 */ +export function shouldReportToken(lastReported: string, current: string): boolean { + if (current.length === 0) { + return false; + } + return lastReported !== current; +} + +/** 取尾 6 位(与服务端 `token_tail` 同口径;token 短于 6 位时取全文) */ +export function tokenTail(token: string): string { + if (token.length <= 6) { + return token; + } + return token.slice(token.length - 6); +} + +/** + * "我登记过没有" —— **只能按 `provider + tail` 比,不许比全文**。 + * + * 为什么单独抽出来:GET 只回尾 6 位,所以"比全文"是那种**看起来更严、其实永远为假**的写法 + * (全文永远不等于尾 6 位 ⇒ 永远认为没登记过 ⇒ 每次启动都重复上报)。 + * 这类"永远为假/永远为真"的断言是判据里最危险的一种,所以它必须有判据钉住。 + */ +export function isRegistered(entries: PushTokenEntry[], provider: string, token: string): boolean { + const tail: string = tokenTail(token); + for (const e of entries) { + if (e.provider === provider && e.token_tail === tail) { + return true; + } + } + return false; +} + +/** + * 上报结果的**分类**:`ok-enabled` / `ok-disabled`(**正常态**)/ `silent-skip`(任何失败都归这里)。 + * 调用侧只许按这个分类决定要不要出声 —— 而三种里**没有一种**是"提示失败"。 + */ +export function classifyRegister(enabled: boolean, failed: boolean): string { + if (failed) { + return 'silent-skip'; + } + return enabled ? 'ok-enabled' : 'ok-disabled'; +} + +/** + * 解析通知 `data`。**不满足约定形状就返回 undefined**(静默忽略), + * 而不是"尽力打开某个页面" —— 推送是可选通道,收到不认识的东西不该有任何副作用。 + */ +export function parseNotificationData(raw: string): PushNotificationData | undefined { + if (raw.length === 0) { + return undefined; + } + let obj: PushNotificationData | undefined = undefined; + try { + const parsed: PushNotificationData = JSON.parse(raw) as PushNotificationData; + obj = parsed; + } catch (e) { + return undefined; + } + if (obj === undefined) { + return undefined; + } + const mailId: string = obj.mail_id === undefined ? '' : obj.mail_id; + const action: string = obj.action === undefined ? '' : obj.action; + if (action !== 'open_mail' || mailId.length === 0) { + return undefined; + } + const out: PushNotificationData = { + type: obj.type === undefined ? '' : obj.type, + mail_id: mailId, + session_id: obj.session_id === undefined ? '' : obj.session_id, + action: action + }; + return out; +} + +/** + * 去重台账:服务端**没有幂等键**,重复保护落在客户端。 + * 有界(`limit` 条后丢最旧的),避免常驻 pane 长时间挂着把内存吃满。 + */ +export class NotificationLedger { + private seen: string[] = []; + private limit: number; + + constructor(limit: number) { + this.limit = limit > 0 ? limit : 64; + } + + /** 第一次见 ⇒ true(该处理);重复 ⇒ false(丢弃) */ + shouldHandle(mailId: string): boolean { + if (mailId.length === 0) { + return false; + } + for (const id of this.seen) { + if (id === mailId) { + return false; + } + } + this.seen.push(mailId); + if (this.seen.length > this.limit) { + this.seen.shift(); + } + return true; + } + + size(): number { + return this.seen.length; + } +}