Skip to content

客户端 SDK 集成手册 ​

本页面向希望编写脚本、桌面客户端、机器人或第三方网页的开发者。示例只使用公开接口和占位凭据,不包含任何隐藏地址、内部实现或未公开操作。

目标与边界 ​

一个可靠的客户端至少要处理:

  • 会话获取、验证、过期和退出;
  • 统一的 HTTP/业务错误;
  • 只读请求与写入请求的不同重试策略;
  • 分页和空数据;
  • JSON 与二进制响应;
  • 用户确认、删除和恢复;
  • Token、访问码和个人数据脱敏;
  • 新字段和新状态的向后兼容。

客户端不应尝试隐藏入口、猜测未列出的字段、绕过权限或把页面上未出现的目标机台写入请求。

建议的模块结构 ​

text
config          基础地址、超时、客户端版本
transport       fetch/requests、Content-Type、HTTP 状态
session         登录、验证、退出和本地会话
resources       缓存、相片、记录、成绩和统计
mutations       配方、评论、上传、删除和恢复
pagination      范围读取、页码和游标
redaction       日志与截图脱敏
presentation    加载、空状态、错误和确认弹窗

不要把所有功能写进一个“万能请求函数”后直接暴露给 UI。把高风险写入隔离出来,可以更容易统一二次确认和重试规则。

TypeScript 基础客户端 ​

ts
export type ApiEnvelope = {
  success?: boolean;
  valid?: boolean;
  msg?: string;
  [key: string]: unknown;
};

export type ClientConfig = {
  baseUrl: string;
  timeoutMs: number;
  userAgent?: string;
};

export class ZundaClientError extends Error {
  constructor(
    message: string,
    public readonly httpStatus?: number,
    public readonly body?: unknown,
    public readonly retryable = false,
  ) {
    super(message);
    this.name = 'ZundaClientError';
  }
}

export class ZundaClient {
  constructor(private readonly config: ClientConfig) {}

  async request<T extends ApiEnvelope>(
    path: string,
    body: Record<string, unknown>,
    options: { signal?: AbortSignal; retryableRead?: boolean } = {},
  ): Promise<T> {
    const controller = new AbortController();
    const timer = setTimeout(
      () => controller.abort(),
      this.config.timeoutMs,
    );

    try {
      const response = await fetch(`${this.config.baseUrl}${path}`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          ...(this.config.userAgent
            ? { 'X-Client-Version': this.config.userAgent }
            : {}),
        },
        body: JSON.stringify(body),
        signal: options.signal ?? controller.signal,
      });

      const data = (await response.json()) as T;
      const businessFailed = data.success === false || data.valid === false;
      if (!response.ok || businessFailed) {
        throw new ZundaClientError(
          data.msg || `HTTP ${response.status}`,
          response.status,
          data,
          Boolean(options.retryableRead && response.status >= 500),
        );
      }
      return data;
    } catch (error) {
      if (error instanceof ZundaClientError) throw error;
      throw new ZundaClientError(
        error instanceof Error ? error.message : 'network error',
        undefined,
        undefined,
        Boolean(options.retryableRead),
      );
    } finally {
      clearTimeout(timer);
    }
  }
}

生产实现可以把重试放在 transport 层,但必须让调用方标记“只读”。不要对所有请求默认重试。

Python 基础客户端 ​

python
from dataclasses import dataclass
from typing import Any
import requests


@dataclass
class ClientConfig:
    base_url: str
    timeout_seconds: float = 20.0


class ApiError(RuntimeError):
    def __init__(self, message: str, status: int | None = None,
                 body: Any = None, retryable: bool = False):
        super().__init__(message)
        self.status = status
        self.body = body
        self.retryable = retryable


class ZundaClient:
    def __init__(self, config: ClientConfig):
        self.config = config

    def post(self, path: str, payload: dict[str, Any], *, read_only=False):
        try:
            response = requests.post(
                self.config.base_url + path,
                json=payload,
                timeout=self.config.timeout_seconds,
            )
        except requests.RequestException as exc:
            raise ApiError(str(exc), retryable=read_only) from exc

        try:
            data = response.json()
        except ValueError as exc:
            raise ApiError("response is not JSON", response.status_code) from exc

        if response.status_code >= 500 or data.get("success") is False:
            raise ApiError(
                data.get("msg", f"HTTP {response.status_code}"),
                response.status_code,
                data,
                retryable=read_only and response.status_code >= 500,
            )
        return data

Token 状态机 ​

推荐将会话建模为显式状态,而不是用“是否有字符串”判断:

状态可以做什么应停止什么
signed_out显示登录所有账号请求
checking验证 Token写入、删除、后台轮询
active读取账号可见数据无
expired提示重新登录后台轮询和写入
permission_denied显示权限说明猜测字段或目标
signing_out清理本地状态新请求

收到 valid: false、明确过期消息或多个连续未授权响应时,先停止轮询,再回到登录页面。不要在后台无限调用验证接口。

登录和退出封装 ​

ts
type Session = {
  token: string;
  isMachine: boolean;
  expiresAt?: string;
};

async function loginByQr(client: ZundaClient, qrcode: string): Promise<Session> {
  const result = await client.request<{
    success: boolean;
    msg: string;
    token: string;
    isMachine: boolean;
  }>('/token/get', { qrcode });

  return {
    token: result.token,
    isMachine: result.isMachine,
  };
}

async function validate(client: ZundaClient, token: string) {
  return client.request<{
    valid: boolean;
    token?: string;
    expires_at?: string;
    expired?: boolean;
  }>('/token/validate', { token }, { retryableRead: true });
}

async function logout(client: ZundaClient, token: string): Promise<void> {
  try {
    await client.request('/token/logout', { token });
  } finally {
    // 无论网络结果如何,都清理内存会话。
  }
}

登录成功后不要把完整响应直接写入日志。用户本地的显示名称可以单独保存,但不能用它代替 Token。

读取配方的安全封装 ​

ts
type RuleValue = { enable: boolean; value: string };
type RuleMap = Record<string, RuleValue>;

async function readRules(client: ZundaClient, token: string): Promise<RuleMap> {
  const result = await client.request<{ forwardData: RuleMap }>(
    '/forward/get',
    { token },
    { retryableRead: true },
  );
  return result.forwardData || {};
}

async function updateVisibleRule(
  client: ZundaClient,
  token: string,
  name: string,
  value: RuleValue,
) {
  return client.request('/forward/add', {
    token,
    rule: name,
    enable: value.enable,
    value: value.value,
  });
}

UI 应只允许从 readRules 的响应中选择配置名。不要让用户在普通客户端输入任意未知规则名,也不要把机台凭据放在 URL。

分页读取游玩记录 ​

ts
export async function getAllPlaylogs(
  client: ZundaClient,
  token: string,
  pageSize = 50,
) {
  const result: unknown[] = [];
  let start = 0;

  while (true) {
    const page = await client.request<{
      total: number;
      playlogList: unknown[];
    }>('/playlog/get', { token, start, end: start + pageSize }, {
      retryableRead: true,
    });

    const rows = page.playlogList || [];
    result.push(...rows);
    if (rows.length === 0 || result.length >= page.total) break;
    start += rows.length;
  }
  return result;
}

推进游标时使用实际返回长度。若服务返回重复或不增长的结果,客户端应检测到异常并停止,而不是无限循环。

二进制文件处理 ​

相片读取成功时可能是图片,失败时可能是 JSON。浏览器客户端应先检查响应头:

ts
async function readPhoto(response: Response) {
  const contentType = response.headers.get('content-type') || '';
  if (contentType.includes('application/json')) {
    return { kind: 'error', data: await response.json() };
  }
  return { kind: 'file', data: await response.blob() };
}

临时 Object URL 使用完后调用 URL.revokeObjectURL。不要把大图片转换成 base64 后长期保存到 localStorage。

上传缓存工作流 ​

推荐让上传流程成为显式状态机:

text
idle
  → loading_index
  → ready
  → downloading_snapshot
  → downloaded
  → waiting_confirmation
  → uploading
  → uploaded / failed

关键规则:

  • indexName 来自列表;
  • 上传前先下载留档;
  • 访问码只存在于内存或受保护输入框;
  • 上传按钮在请求期间禁用;
  • 超时后先读取列表和只读数据;
  • 不要因为响应丢失就重复上传;
  • 删除前二次确认并检查本地文件。

相片删除工作流 ​

删除单张和删除全部必须使用不同的确认文案:

ts
type DeleteTarget =
  | { kind: 'one'; fileName: string }
  | { kind: 'all' };

function confirmationText(target: DeleteTarget) {
  return target.kind === 'all'
    ? '将删除当前账号的全部相片,且不可恢复。'
    : `将删除相片 ${target.fileName},且不可恢复。`;
}

删除请求超时后不要自动重试。重新获取列表:文件消失可以说明删除已生效,文件仍在则让用户决定是否重试。

评论和反馈工作流 ​

评论、回复、点赞、点踩和反馈都是用户可见操作。客户端应:

  1. 显示目标曲目和回复对象;
  2. 检查内容是否为空;
  3. 避免将 Token、二维码和私人信息写进内容;
  4. 请求期间禁用对应按钮;
  5. 成功后使用服务返回的数据更新页面;
  6. 响应丢失时先重新读取,而不是盲目再发。

审核按钮只能在账号明确具有审核权限并且页面显示审核区域时出现。普通用户看到的是内容反馈,不是审核决策。

多机台轮询 ​

机台查卡和健康数据适合使用可取消的轮询器:

ts
class Poller {
  private timer: ReturnType<typeof setTimeout> | undefined;
  private stopped = false;

  start(task: () => Promise<void>, intervalMs: number) {
    this.stopped = false;
    const run = async () => {
      if (this.stopped) return;
      try {
        await task();
      } finally {
        if (!this.stopped) {
          this.timer = setTimeout(run, intervalMs);
        }
      }
    };
    void run();
  }

  stop() {
    this.stopped = true;
    if (this.timer) clearTimeout(this.timer);
  }
}

轮询应在以下情况下停止:

  • 页面离开;
  • 用户切换账号;
  • Token 失效;
  • 连续出现权限错误;
  • 用户关闭自动刷新。

多机台结果必须携带 clientId 和生成时间,不能把上一轮数据冒充实时数据。

超时、取消和幂等 ​

“取消请求”通常只取消浏览器等待,不一定撤回服务已经收到的动作。UI 应把状态拆成:

  • 未发送;
  • 已发送、等待响应;
  • 响应成功;
  • 响应失败;
  • 结果未知。

结果未知时,优先读取状态接口。只有确认写入未生效,才允许用户重新提交。

日志脱敏 ​

推荐使用结构化日志,但先做字段过滤:

ts
const secretKeys = new Set([
  'token', 'password', 'old_password', 'new_password',
  'qrcode', 'accessCode', 'SGWCMAID', 'clientPwd',
]);

function redact(value: unknown): unknown {
  if (Array.isArray(value)) return value.map(redact);
  if (!value || typeof value !== 'object') return value;
  return Object.fromEntries(
    Object.entries(value as Record<string, unknown>).map(([key, item]) => [
      key,
      secretKeys.has(key) ? '<REDACTED>' : redact(item),
    ]),
  );
}

还应遮盖机台标识、地点、对手名称、原始备份和网络文本。错误消息可以保留,但应检查它是否包含回显的凭据。

本地缓存隔离 ​

缓存键必须包含本地账号和资源类型,但不能包含完整 Token:

ts
function cacheKey(accountAlias: string, resource: string) {
  return `zundalink:${accountAlias}:${resource}`;
}

切换账号时清理敏感资源;退出时停止轮询;缓存数据必须带读取时间和“缓存/实时”标记,不能把旧数据显示成实时结果。

测试清单 ​

第三方客户端发布前至少测试:

  • 有效 Token、过期 Token、权限不足;
  • 空数组、data: null、缺少可选字段;
  • 新增未知字段和未知状态字符串;
  • HTTP 4xx、5xx、连接超时和响应丢失;
  • 删除/恢复/上传的二次确认;
  • 二进制成功和 JSON 错误;
  • 分页返回少于请求大小;
  • 页面离开时停止轮询;
  • 日志中没有 Token、密码、访问码或完整响应。

发布前兼容检查 ​

  1. 先调用 Token 验证;
  2. 再调用一个只读接口;
  3. 读取 success/valid 和 msg;
  4. 记录客户端版本和 API 基础地址;
  5. 最后才显示写入、删除和恢复按钮。

接口增加字段时不应破坏旧客户端;接口删除或改变结构时,应先更新公开接口总参考和字段目录。

Released under the CC BY-NC-SA 4.0 License.