跨端: feat(推送客户端): PushService.ets(取 token → 上报 → 点击跳转)+ 接进 EntryAbility ⇒ **文件真的被编译了**

pi 三件里的前两件 + 第三件的"目标解析"这半。

- `pushService.getToken()` → `POST /me/devices/push-token`(`provider/token/device_name/session_id`,省略空项);
- 上报标记按 **`accountKey|token`**(服务端同一 token 换账号是**转移**不是并存 ⇒ 标记必须按账号维度,否则新账号永远不报、旧账号的登记已被转走);
- 注销:`DELETE` **带 body**(服务端就是这么定的),`deleted:false` 不当错误;
- 点击跳转:`onNewWant` → `routeFromWant`(`parseNotificationData` + 有界去重 ledger)→ `PushService.pendingRoute` 等页面来取;
- **所有失败静默**(没 token / 401 / 400 / 500 / 权限拒绝 ⇒ 只写 hilog,不提示、不重试、不阻塞启动);
- 通知权限**只在拿到 token 后**才申请(没 token 就弹窗是打扰)。

★ 一条我差点当成"验过了"的证据:`assembleHap` 在**没接入**时也报 `BUILD SUCCESSFUL` ——
因为 ArkTS 只编译**可达**模块,没人 import 的文件根本不参与编译。我植入类型错误后仍是 SUCCESSFUL,
才确认"编译通过"当时**什么都没证明**。接入 EntryAbility 后再植入同样的错误 ⇒ `Failed :entry:default@CompileArkTS` 且指名 `PushService.ets:102` ✓
—— 现在这句"编译通过"是有内容的。

**未完成(不掩盖)**:① 登录成功后主动补报一次(现在只在冷启 `onCreate` 报,冷启时未登录 ⇒ 401 静默,等下次冷启才补上);
② `pendingRoute` 的**消费端**(页面读它并跳转)—— 这两处都要动 `LoginPage/MainPage`,而那两个文件正被别人在飞编辑,我没抢着改。
This commit is contained in:
2026-09-15 14:20:00 +08:00
parent 3c8f18d4de
commit 1e3aac2f7d
2 changed files with 269 additions and 0 deletions

View File

@ -0,0 +1,233 @@
/*
* 推送客户端(平台那半)—— Push Kit 取 token → 上报给网关 → 点通知跳转的后半段。
*
* 设计原则(pi `1ce5b03a` 已实测的服务端事实):
* · POST/GET/DELETE 都在 `/me/*`,同一套 Bearer 鉴权;DELETE **也读 body**(不是 query、不是 path 参数);
* · `provider` 只做**形状**校验(`^[a-z0-9_-]{1,32}$`),**不做白名单** —— 所以这里不硬编码"只有 hms";
* · 同一 token 换账号是**转移**(不是并存)⇒ 上报标记必须按 `accountKey|token`,否则新账号永远不报、旧账号的登记已被转走;
* · `enabled:false`(服务端没配通道)**不是错误**:静默跳过,别弹任何东西;
* · 单批最多 10 个 token(服务端批量)—— 客户端一次只报自己一个,不涉及。
*
* ★ 最重要的一条:**所有失败都静默**。
* 推送是"便利",不是主链 —— SSE 才是主通道。任何一步取不到、报不上、没权限,
* 都只写日志,绝不打扰用户、绝不阻塞启动。这条写在这里是因为它**很容易被"顺手加个提示"破坏**,
* 而那样做的后果是:一个本来只在后台失败的可选功能,变成用户每次启动都看到的报错。
*/
import { pushService } from '@kit.PushKit';
import { notificationManager } from '@kit.NotificationKit';
import { preferences } from '@kit.ArkData';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { BusinessError, deviceInfo } from '@kit.BasicServicesKit';
import { common } from '@kit.AbilityKit';
import { ApiClient } from './ApiClient';
import { AccountManager } from './AccountManager';
import {
PROVIDER_HMS,
PushRegisterBody,
PushNotificationData,
buildTokenBody,
parseNotificationData,
reportMarker,
shouldReportToken,
NotificationLedger,
} from '../model/PushContract';
const DOMAIN: number = 0x0001;
const TAG: string = 'PushService';
const STORE: string = 'push_store';
const MARKER_KEY: string = 'reported_marker';
/** 点击跳转的目标(由通知 data 解析而来;只有两样东西要路由) */
export interface PushRoute {
sessionId: string;
mailId: string;
}
/** 上报的返回(服务端同时给 enabled/providers,所以**不需要**再 GET 一次 —— pi `1ce5b03a`) */
interface PushRegisterResponse {
enabled?: boolean;
providers?: string[];
}
export class PushService {
private static instance: PushService | null = null;
/*
* 点通知进来的**待处理目标**:ability 收到 want 时写这里,页面起来后读它并清空。
* 用一个静态格子而不是 AppStorage:ability 阶段拿不到页面状态,而静态格子两边都够得着。
*/
static pendingRoute: PushRoute | undefined = undefined;
private context: common.UIAbilityContext;
private account: AccountManager;
private constructor(context: common.UIAbilityContext) {
this.context = context;
this.account = AccountManager.getInstance(context);
}
static getInstance(context: common.UIAbilityContext): PushService {
if (PushService.instance === null) {
PushService.instance = new PushService(context);
}
return PushService.instance;
}
/**
* 取 Push Kit 的 token。**取不到就返回空串**(没装 HMS Core、没登录华为账号、没权限……都是正常情况)。
*/
private async getToken(): Promise<string> {
try {
const token: string = await pushService.getToken();
return token === undefined || token === null ? '' : token;
} catch (e) {
const err = e as BusinessError;
hilog.info(DOMAIN, TAG, 'push token 取不到(静默,属正常):%{public}s', err.message);
return '';
}
}
/** 申请通知权限(用户拒绝也不影响主链) */
async requestEnableNotification(): Promise<void> {
try {
await notificationManager.requestEnableNotification(this.context);
} catch (e) {
const err = e as BusinessError;
hilog.info(DOMAIN, TAG, '通知权限未开(静默):%{public}s', err.message);
}
}
private async readMarker(): Promise<string> {
try {
const store = await preferences.getPreferences(this.context, STORE);
const v = await store.get(MARKER_KEY, '');
return typeof v === 'string' ? v : '';
} catch (e) {
return '';
}
}
private async writeMarker(marker: string): Promise<void> {
try {
const store = await preferences.getPreferences(this.context, STORE);
await store.put(MARKER_KEY, marker);
await store.flush();
} catch (e) {
hilog.info(DOMAIN, TAG, '标记写不进去(静默):下次会重复上报一次,服务端是幂等的登记');
}
}
/** 设备名:取不到就给空串,body 里会省略这一项(不要造一个假名字) */
private deviceName(): string {
try {
const m: string = deviceInfo.productModel;
return m === undefined || m === null ? '' : m;
} catch (e) {
return '';
}
}
/**
* 上报本机 token。**幂等**:已经报过、且账号没换 ⇒ 直接返回(不打网络)。
*
* 放在启动后调用(登录成功之后、或已有账号时),调用方**不需要** catch —— 它自己内部全静默。
*/
async reportToken(api: ApiClient): Promise<void> {
const token: string = await this.getToken();
if (token.length === 0) {
return; // 没有 token 就没什么可报的(这不是错误)
}
// 有 token 才申请通知权限:没 token(没 HMS Core / 没登录华为账号)时**不该弹窗打扰用户**
await this.requestEnableNotification();
const accountKey: string = this.account.getActiveId();
const lastMarker: string = await this.readMarker();
/*
* ★ 换账号必须重报:服务端里同一 token 换账号是**转移**,
* 所以"我报过没有"这个问题的答案**随账号变**(pi `1ce5b03a` 确认的行为)。
*/
if (!shouldReportToken(lastMarker, accountKey, token)) {
return;
}
const sessionId: string = this.account.getActiveAccount() === null
? ''
: this.account.getActiveAccount()!.server;
const body: PushRegisterBody | undefined =
buildTokenBody(PROVIDER_HMS, token, this.deviceName(), sessionId);
if (body === undefined) {
hilog.info(DOMAIN, TAG, 'body 形状不合法(静默,不发)');
return;
}
try {
await api.post<PushRegisterResponse>('/me/devices/push-token', body);
await this.writeMarker(reportMarker(accountKey, token));
} catch (e) {
const err = e as BusinessError;
/*
* 静默:401(没登录)/400(形状)/500(服务端)都不重试、不提示。
* 不重试的理由:这是"登记便利通道",不是数据;失败了下次启动会再试一次(标记没写成功)。
*/
hilog.info(DOMAIN, TAG, '上报失败(静默):%{public}s', err.message);
}
}
/**
* 注销本机 token(退出登录时调用)。DELETE **带 body** —— 服务端就是这么定的。
* 返回的 `deleted:false`(本来就没登记)**不是错误**。
*/
async unregister(api: ApiClient): Promise<void> {
const token: string = await this.getToken();
if (token.length === 0) {
return;
}
const body: PushRegisterBody | undefined = buildTokenBody(PROVIDER_HMS, token, '', '');
if (body === undefined) {
return;
}
try {
await api.del<PushRegisterResponse>('/me/devices/push-token', body);
} catch (e) {
const err = e as BusinessError;
hilog.info(DOMAIN, TAG, '注销失败(静默):%{public}s', err.message);
}
await this.writeMarker('');
}
/**
* 点通知后的跳转目标。
*
* `raw` 是通知 `data` 里的那个 JSON 字符串(`{type,mail_id,session_id,action:'open_mail'}`)。
* 解析不出来、或 action 不是 `open_mail` ⇒ 返回 undefined(**不做任何跳转**,也不报错:
* 一条格式不认识的通知,最坏结果应当是"没反应",而不是"跳到一个空页面")。
*/
static routeOf(raw: string): PushRoute | undefined {
const data: PushNotificationData | undefined = parseNotificationData(raw);
if (data === undefined) {
return undefined;
}
return { sessionId: data.session_id, mailId: data.mail_id };
}
/**
* 从 ability 收到的 `want` 里取跳转目标(**带去重**)。
*
* 为什么要去重:同一封邮件点两次、或系统重放 want,会让页面重复压栈/重复请求;
* `NotificationLedger` 是有界的(不会无限长大)。
*/
static routeFromWant(parameters: Record<string, Object>, ledger: NotificationLedger): PushRoute | undefined {
const raw = parameters['data'];
if (raw === undefined || raw === null) {
return undefined;
}
const text: string = typeof raw === 'string' ? raw : '';
if (text.length === 0) {
return undefined;
}
const route: PushRoute | undefined = PushService.routeOf(text);
if (route === undefined) {
return undefined;
}
if (!ledger.shouldHandle(route.mailId)) {
return undefined;
}
return route;
}
}

View File

@ -16,10 +16,16 @@
import { AbilityConstant, ConfigurationConstant, UIAbility, Want } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { window } from '@kit.ArkUI';
import { ApiClient } from '../api/ApiClient';
import { PushService } from '../api/PushService';
import { NotificationLedger } from '../model/PushContract';
const DOMAIN = 0x0000;
export default class EntryAbility extends UIAbility {
/** 通知点击去重(有界) */
private ledger: NotificationLedger = new NotificationLedger(50);
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
try {
this.context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET);
@ -27,6 +33,36 @@ export default class EntryAbility extends UIAbility {
hilog.error(DOMAIN, 'testTag', 'Failed to set colorMode. Cause: %{public}s', JSON.stringify(err));
}
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onCreate');
/*
* 推送上报:**不 await、失败全静默** —— 启动不能被网络/权限阻塞,也不能因为推送不可用而报错。
* 冷启时还没登录 ⇒ POST 会 401,静默忽略;标记没写成功 ⇒ 下次启动会再试一次。
*/
try {
const push: PushService = PushService.getInstance(this.context);
push.reportToken(ApiClient.getInstance(this.context)).catch((err: Object) => {
hilog.info(DOMAIN, 'testTag', 'push 上报异常(静默):%{public}s', JSON.stringify(err));
});
} catch (err) {
hilog.info(DOMAIN, 'testTag', 'push 初始化跳过(静默):%{public}s', JSON.stringify(err));
}
}
/**
* 点通知拉起应用时走这里(应用已在运行时)。
* 解析不出来就**什么都不做** —— 一条格式不认识的通知,最坏结果应当是"没反应",不是"跳到空页面"。
*/
onNewWant(want: Want, launchParam: AbilityConstant.LaunchParam): void {
try {
const params: Record<string, Object> = want.parameters === undefined
? {} as Record<string, Object>
: want.parameters as Record<string, Object>;
const route = PushService.routeFromWant(params, this.ledger);
if (route !== undefined) {
PushService.pendingRoute = route; // 页面起来后读它
}
} catch (err) {
hilog.info(DOMAIN, 'testTag', '通知跳转解析失败(静默):%{public}s', JSON.stringify(err));
}
}
onDestroy(): void {