Files
MailUI4Agents/client/harmony/entry/src/main/ets/api/PushService.ets
JianFeeeee 0bced9fcff 跨端: fix(推送客户端) 更正我上一笔的注释:session_id 不是"只写不读",GET 会回给客户端
上一笔 `9404f98` 的代码是对的,但注释里我写了一句**过度概括**:

    "服务端对这个字段不做格式校验、而且当前**只写不读**"

后半句错了。`GET /api/v1/me/devices/push-token` 把这个字段**原样回给客户端**:

    server/internal/handler/push.go:148    "session_id":  t.SessionID,

更正为准确的三条(各自都能复核):

1. 服务端不做格式校验(只 `TrimSpace`、允许为空)⇒ 所以不 400、不影响收信、不进日志;
2. 但 GET 会回给客户端 ⇒ 假值**是可见的**,且 `isRegistered` 的比对口径
   (只按 `provider + token_tail`,见 PushContract.ts 那段说明)恰好不看它 ——
   两件事合起来意味着:**没有任何机制会因为这个字段错了而报警**;
3. 投递暂不受影响:发通知用的是**邮件自己的** `session_id`
   (`notify/mail.go:266` 构造 `push.NewMail{SessionID: m.SessionID}`),
   `dispatch` 只用 token 的 `Provider`/`Token`(`push.go:155`)。

⇒ 结论不变但理由更准:**这个字段存在的唯一目的就是"点通知回到那条会话",
而它存的值是错的**;"不会立刻炸"正是它该先修的原因。

写这条注释时我把"投递不读它"顺手写成了"没人读它"——**"不参与这条路径"与"没有读取方"
不是同一件事**,与这两天反复出现的形状同族(把"我没看到"读成"不存在")。
2026-09-17 19:40:00 +08:00

342 lines
15 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* 推送客户端(平台那半)—— 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,
DEFAULT_PUSH_ENABLED,
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';
const SETTING_KEY: string = 'push_enabled';
/*
* ★ 2026-09-15:接入系统通知是**可配置项**(用户强调多次)。
* 默认 **关** —— 自部署的用户没有服务端推送凭证时,App 不取 token、不请求权限、不上报、不打网关。
* 开了以后才走 getToken → requestEnableNotification → 上报这一整条。
* 这条开关存在 PushService 里(而不是设置页 @State),是因为 onCreate(ability 阶段)要读它。
*/
/** 点击跳转的目标(由通知 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;
/*
* ★ 2026-09-17 真机实测补的**第二半**:光有"待处理格子"只够**冷启**。
*
* 实测(模拟器,`aa start --ps data '{…open_mail…}'`):冷启能跳(页面刚挂载,
* `aboutToAppear` 会读 pendingRoute);但**应用已在运行时点通知**(`onNewWant`)
* 只把格子写上了,**没有任何东西会再读它** —— 那几个页面早就挂载完了、
* `aboutToAppear` 不会重跑,用户看到的是"点了通知,App 弹到前台,停在列表"。
*
* 为什么不用页面生命周期兜(`onPageShow` 之类):这里要的是"**事件发生的那一刻**"
* 而不是"页面每次可见时"—— 后者会在用户手动返回列表时反复触发跳转。
* 所以:注册一个回调,`onNewWant` 里直接叫醒正在监听的页面。
*/
private static routeListener: ((route: PushRoute) => void) | undefined = undefined;
/** 页面注册"点通知要跳转"的回调(同一时刻只留一个:栈只有一处)。 */
static setRouteListener(fn: (route: PushRoute) => void): void {
PushService.routeListener = fn;
}
/** 页面退出时摘掉回调(否则会叫醒一个已销毁的页面)。 */
static clearRouteListener(): void {
PushService.routeListener = undefined;
}
/**
* 通知目标落地:**有人监听就立刻交出去,没人监听就留在格子里等页面来读**。
*
* 两条路都要留着,因为两种启动方式各走一条:
* · 冷启(进程不在):页面还没挂载 ⇒ 没人监听 ⇒ 留在格子里,`aboutToAppear` 取走;
* · 热启(进程在、页面已挂载):有人监听 ⇒ 直接调,用户立刻看到那封信。
*/
static deliverRoute(route: PushRoute): void {
const fn: ((route: PushRoute) => void) | undefined = PushService.routeListener;
if (fn !== undefined) {
fn(route);
return;
}
PushService.pendingRoute = route;
}
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;
}
/**
* 读「接入系统通知」开关(**默认开** —— 见 PushContract.DEFAULT_PUSH_ENABLED)。
* 读不到(首次/异常)都返回开:推送是用户要能默认收到的功能,
* 降级发生在服务端回 enabled:false 时(通道未配),不是由客户端默认关掉。
*/
static isEnabled(context: common.Context): boolean {
try {
const store = preferences.getPreferencesSync(context, { name: STORE });
const v = store.getSync(SETTING_KEY, DEFAULT_PUSH_ENABLED);
return v === true;
} catch (e) {
return DEFAULT_PUSH_ENABLED;
}
}
/** 写开关(设置页 Toggle 回调;写入失败静默 —— 关着总比误开着安全,下次再试不迟) */
static setEnabled(context: common.Context, enabled: boolean): void {
try {
const store = preferences.getPreferencesSync(context, { name: STORE });
store.putSync(SETTING_KEY, enabled);
store.flush();
} catch (e) {
hilog.info(DOMAIN, TAG, '推送开关写不进去(静默):%{public}s', (e as BusinessError).message);
}
}
/**
* 取 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> {
/*
* ★ 开关 gate(2026-09-15):"接入系统通知"是**可配置项**,默认关。
* 关 ⇒ 不取 token(不碰 Push Kit)、不申请权限(不弹系统弹窗)、不上报、不打网关 ——
* 自部署用户没有服务端推送凭证时,这一整条都是无谓的开销。
* 这条 gate 在**最前**:getToken/requestEnableNotification 都在它后面。
*/
if (!PushService.isEnabled(this.context)) {
return;
}
// ★ 先申请通知权限再取 token:部分设备上 Push Kit getToken 会因通知权限未开而
// 返回空 / 报 1600004,先 requestEnableNotification 把权限前置到位。
await this.requestEnableNotification();
const token: string = await this.getToken();
if (token.length === 0) {
return; // 没有 token 就没什么可报的(这不是错误)
}
const accountKey: string = this.account.getActiveId();
const lastMarker: string = await this.readMarker();
/*
* ★ 换账号必须重报:服务端里同一 token 换账号是**转移**,
* 所以"我报过没有"这个问题的答案**随账号变**(pi `1ce5b03a` 确认的行为)。
*/
if (!shouldReportToken(lastMarker, accountKey, token)) {
return;
}
/*
* ★ `session_id` 传**空**(不传这个字段),不是"随手塞一个值"。
*
* 服务端对这个字段的语义是"客户端**当前所在的会话**:点通知要回到那条会话里的那封信",
* 并且**明确允许为空**("客户端还没进任何会话,此时通知只带 mail_id")。
*
* 而这一步的时机决定它必然为空:`reportToken` 只在**登录成功**与**换账号**时跑,
* 那时用户还没打开任何一条会话 —— 客户端也**根本没有"当前会话"这个状态**
* 可以取(`MainPage` 不记它)。所以这里如实传空,而不是编一个。
*
* ★ 这里原本传的是 `getActiveAccount()!.server` —— 那是**服务器地址**
* (`AccountInfo.server`,值长这样:`https://mail.jianfgit.xyz/api/v1`),
* 不是会话 id。服务端对这个字段**不做格式校验**(只 `TrimSpace`、且允许为空),
* 所以它不会 400、不影响收信、也不进任何日志 ——
* 只会在 `push_tokens` 里存一条**假的**会话 id,而 `GET /me/devices/push-token`
* 还会把它**原样回给客户端**(`handler/push.go` 的 items 里带 `session_id`)。
* 投递本身暂不受影响:发通知用的是**邮件自己的** `session_id`
* (`notify/mail.go` 构造 `push.NewMail{SessionID: m.SessionID}`),
* 而 `dispatch` 只用 token 的 `Provider`/`Token`。
* ⇒ **这个字段存在的唯一目的就是"点通知回到那条会话",而它存的值是错的。**
* "不会立刻炸"正是这种错最值得先修的原因:它不报错,只是让数据开始说谎。
*
* 等客户端真的有了"当前会话"(打开某条会话时),再在这里补报一次真实 id ——
* 那时 `session_id` 才有值可传。**现在传空是准确的,传 URL 是错的。**
*/
const sessionId: string = '';
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;
}
}