跨端: feat(推送客户端): 按线上形状补契约层(DELETE 也带 body / 未知字段 400 / provider 无白名单 / 错误是 {"error"})

pi 2026-09-14 给的线上形状(从 handler/push.go 读的,不是猜的)逐条落成可判的:

- **请求体只放已知键**:`PUSH_BODY_KEYS` 登记四个键,**未知字段服务端直接 400**(不是静默忽略)
  ⇒ 拼错会立刻可见;可选字段为空就不放(空串虽合法,但不放更不容易踩校验)。
- **provider 只做形状校验、没有白名单**(`^[a-z0-9_-]{1,32}$`):判据断言 `apns`/`fcm` 也合法 ——
  客户端**不许**硬编码"只有 hms 合法"去先拦一道(服务端没实现的通道不该变成客户端的 400)。
  这正是"校验的范围必须等于它真正知道的事"的又一落点。
- **token**:空非法、512 上限;形状不合法 ⇒ `buildTokenBody` 返回 undefined,调用侧**静默跳过**,
  不去打一次注定 400 的请求。
- **错误体是 `{"error"}` 不是 `{"message"}`**:判据专门断言 `{"message":"x"}` 解析出 undefined
  (用错键会把"没有消息"当有消息)。
- **注销:`deleted:false`(本来没登记)不是失败** ⇒ 它**不改变分类**,所以**不再是入参**
  (一个不影响结果的入参只会让人误以为它影响结果),判据断 `classifyUnregister.length === 2` 钉住这点。
- **`ApiClient.del` 补可选 JSON body**:DELETE 端点是 JSON body 形状(不是 query、不是 path 参数),
  原来只有 `path` ⇒ 注销会无效。不传 body 时行为与旧版完全一致(向后兼容)。
EOF
This commit is contained in:
2026-09-15 11:43:22 +08:00
parent 8fed8401de
commit ed0ad2508e
4 changed files with 155 additions and 3 deletions

View File

@ -242,11 +242,18 @@ export class ApiClient {
return this.request<T>(opts);
}
/** DELETE 便捷 */
async del<T>(path: string): Promise<T> {
/**
* DELETE 便捷。**带可选 JSON body** —— 服务端 `/me/devices/push-token` 的 DELETE
* 就是"JSON body"形状(不是 query、不是 path 参数,pi 2026-09-14 从 handler 读的形状)。
* 不传 body 时与旧行为完全一致(向后兼容)。
*/
async del<T>(path: string, bodyObj?: Object): Promise<T> {
const opts = new RequestOptions();
opts.method = 'DELETE';
opts.path = path;
if (bodyObj !== undefined) {
opts.body = JSON.stringify(bodyObj);
}
return this.request<T>(opts);
}

View File

@ -149,3 +149,92 @@ export class NotificationLedger {
return this.seen.length;
}
}
// ─────────────────────────────────────────────────────────────────────────────
// 线上形状(pi 2026-09-14,从 handler/push.go + cmd/server/main.go 读的,不是猜的)
//
// 三条端点(POST / DELETE / GET)**都挂在 `/me/*` 组下**,同一套 Bearer 鉴权;
// **DELETE 也是 JSON body**(不是 query、不是 path 参数);
// 错误一律 `{"error":"…"}`(不是 `{"message":…}`);
// **严格 JSON:未知字段直接 400**(不是静默忽略)。
// ─────────────────────────────────────────────────────────────────────────────
/** 三个端点共用的请求体(`GET` 不用)。字段名**必须逐字一致** —— 拼错会 400。 */
export interface PushTokenBody {
provider: string;
token: string;
device_name?: string;
session_id?: string;
}
/** 允许出现在请求体里的键(未知字段服务端直接 400) */
export const PUSH_BODY_KEYS: string[] = ['provider', 'token', 'device_name', 'session_id'];
/**
* provider 只做**形状**校验:`^[a-z0-9_-]{1,32}$`,**没有白名单**。
*
* ⇒ 客户端**不许**硬编码"只有 `hms` 合法"去先拦一道:服务端没实现的通道
* 也不该变成客户端的 400(`hms` 只是我要发的那个值,不是唯一合法的值)。
* 这条正是"判据/校验的范围必须等于它真正知道的事"的又一落点。
*/
export function isValidProvider(provider: string): boolean {
return new RegExp('^[a-z0-9_-]{1,32}$').test(provider);
}
/** token:不能为空、最长 512 */
export function isValidToken(token: string): boolean {
if (token.length === 0) {
return false;
}
return token.length <= 512;
}
/**
* 组请求体:**只放已知键**,可选字段为空则**不放**(空串虽合法,但不放更不容易踩校验)。
* provider/token 形状不合法时返回 undefined ⇒ 调用侧**静默跳过**,不必去打一次注定 400 的请求。
*/
export function buildTokenBody(provider: string, token: string,
deviceName: string, sessionId: string): PushTokenBody | undefined {
if (!isValidProvider(provider) || !isValidToken(token)) {
return undefined;
}
const body: PushTokenBody = { provider: provider, token: token };
if (deviceName.length > 0) {
body.device_name = deviceName;
}
if (sessionId.length > 0) {
body.session_id = sessionId;
}
return body;
}
/** 错误体是 `{"error":"…"}` —— 不是 `{"message":…}`;解析不出就返回 undefined */
export function parseErrorBody(raw: string): string | undefined {
if (raw.length === 0) {
return undefined;
}
let msg: string | undefined = undefined;
try {
const obj: Record<string, string> = JSON.parse(raw) as Record<string, string>;
const v: string = obj.error;
if (v !== undefined && v.length > 0) {
msg = v;
}
} catch (e) {
return undefined;
}
return msg;
}
/**
* 注销结果的分类:`ok-enabled` / `ok-disabled` / `silent-skip`。
*
* **`deleted:false`(本来就没登记)不是失败** ⇒ 它**不改变分类**,所以它**不是这个函数的参数** ——
* 一个不影响结果的入参只会让人误以为它影响结果。(响应里仍带 `deleted`,供日志/调试用。)
*/
export function classifyUnregister(enabled: boolean, failed: boolean): string {
if (failed) {
return 'silent-skip';
}
return enabled ? 'ok-enabled' : 'ok-disabled';
}