上一笔 `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`)。
⇒ 结论不变但理由更准:**这个字段存在的唯一目的就是"点通知回到那条会话",
而它存的值是错的**;"不会立刻炸"正是它该先修的原因。
写这条注释时我把"投递不读它"顺手写成了"没人读它"——**"不参与这条路径"与"没有读取方"
不是同一件事**,与这两天反复出现的形状同族(把"我没看到"读成"不存在")。
342 lines
15 KiB
Plaintext
342 lines
15 KiB
Plaintext
/*
|
||
* 推送客户端(平台那半)—— 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;
|
||
}
|
||
}
|