跨端: feat(推送客户端): 契约层 model/PushContract.ts + 6 条设备无关判据(静默失败/enabled:false 正常态/按 provider+tail 比/按 mail_id 去重)

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`。
This commit is contained in:
2026-09-15 11:40:54 +08:00
parent 351be9dc5e
commit 8fed8401de
3 changed files with 225 additions and 0 deletions

View File

@ -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;
}
}