campus
通过宿主访问已登录校园服务所用的类型(CampusService、CampusRequest、CampusResponse、multipart 上传、用户选取的文件),以及登录状态和手机令牌验证码。
插件需要以用户身份访问校园网站时,例如查询校园卡余额或在树洞发帖,就通过 services.campus.request 让宿主代为发送请求。这个模块定义了请求、响应和相关状态的类型。使用这些接口的插件需要声明 campus-session 能力。
源码:packages/sdk/src/campus.ts。概念介绍见校园会话。
插件能看到什么
校园账号和密码由宿主保存在设备的密钥库里。宿主自己登录各个服务,包括 IAAA(北大统一身份认证)、门户的单点登录(SSO)和 ITS(校园网账号系统),然后把服务的响应交给插件。插件既拿不到密码,也拿不到原始的会话 token。
上传文件(multipart 请求)时,宿主直接从本地 URI 读取文件并发送,文件内容同样不经过插件。
类型
/**
* - `card`:bdcard.pku.edu.cn(宿主自动加上 synjones JWT)
* - `its`:its.pku.edu.cn(网络账号会话)
* - `treehole`:treehole.pku.edu.cn `/chapi/…`(宿主自动加上 Bearer JWT 和设备 uuid)。树洞自己的
* 二次验证仍会返回给插件:40002 = 短信验证,40077 = 课程数据令牌。
* - `blackboard`:course.pku.edu.cn(教学网,HTML 页面;SSO 会话,自动跟随重定向)
* - 其余服务由宿主的适配器注册(bdkj、cwfw、cwpay、elective、library、library-space)
*/
type CampusService = 'card' | 'its' | 'treehole' | 'blackboard' | (string & {});
type CampusRequest = {
/** 绝对 URL,或该服务主机上的路径(例如 "/berserker-app/…")。 */
url: string;
method?: 'GET' | 'POST';
headers?: Record<string, string>;
body?: string;
contentType?: string;
responseType?: 'text' | 'base64';
/** 表单字段,以 url-encoded 格式发送(代替 `body`)。 */
form?: Record<string, string>;
/**
* 会话凭据放在哪里,默认放在请求头。'form' 表示同时放进 `form`,用于从请求体读取 token 并对其签名的
* 接口(校园卡的下单接口)。
*/
authIn?: 'headers' | 'form';
/** 发送 `form` 前的最后一步:加上签名。在宿主内运行;仅限内置插件。 */
sign?: (fields: Record<string, string>) => Record<string, string> | Promise<Record<string, string>>;
/** 是否跟随 3xx 重定向(默认由服务决定)。false 表示自己读取 `location`。 */
followRedirects?: boolean;
/** multipart/form-data 请求体(上传文件),代替 `body` / `form`。方法默认为 POST。 */
multipart?: CampusMultipart;
/** 请求超时,单位为毫秒(默认 20 秒;multipart 为 120 秒)。 */
timeoutMs?: number;
};
type CampusMultipart = {
/** 文本字段:用 [name, value] 数组可以保持顺序并允许重名。文本字段写在文件之前。 */
fields: Record<string, string> | [string, string][];
files: CampusFilePart[];
};
type CampusFilePart = {
/** 这一部分的表单字段名,例如 "newFile_LocalFile0"。 */
field: string;
/** 发送给服务器的文件名。 */
name: string;
/** MIME 类型,例如 "application/pdf"。 */
mime: string;
/** 来自 `services.files.pick` 的本地 URI。 */
uri: string;
};
type PickedFile = {
name: string;
mime: string;
/** 字节数,系统提供时才有。 */
size: number | null;
/** 本地 URI,在本次应用会话内有效。 */
uri: string;
};
type CampusResponse = { status: number; url: string; headers: Record<string, string>; body: string };
type CampusStatus = {
signedIn: boolean;
username: string | null;
/** 持卡人姓名,已知时才有(来自校园卡)。 */
name: string | null;
};
/** 当前的手机令牌(IAAA TOTP)验证码;密钥始终留在宿主内。 */
type OtpCode = { code: string; /** 距离验证码更新还有几秒(1–30)。 */ remainingSeconds: number };
/** 宿主的手机令牌绑定状态(不含密钥)。`pending` 表示已发送绑定短信,正在等待验证码。 */
type OtpStatus = { bound: boolean; userId: string | null; userName: string | null; boundAt: number | null; pending: boolean };示例
// 用校园卡会话调用 JSON 接口
const res = await ctx.services.campus.request('card', { url: '/berserker-app/ykt/tsm/queryCard?synAccessSource=h5' });
if (res.status !== 200) throw new Error(`校园卡服务返回 HTTP ${res.status}`);
const json = JSON.parse(res.body);
// 向树洞发送 JSON POST 请求
await ctx.services.campus.request('treehole', {
url: '/chapi/api/jwt_send_msg',
method: 'POST',
body: JSON.stringify({}),
contentType: 'application/json',
});
// 会话 token 放在表单里并签名,自己读取重定向
// (节选自 plugins/wallet/src/card/recharge.ts,移植自 pkucli campuscard api.rs)
const order = await ctx.services.campus.request('card', {
url: '/charge/order/thirdOrder',
method: 'POST',
followRedirects: false,
authIn: 'form', // 宿主在签名前把校园卡会话加进表单
form: { feeitemid: '401', tranamt: '50', synAccessSource: 'h5' /* … */ },
sign: (fields) => signFields(fields, ctx.services.crypto.sha256), // 对最终字段签名
});
const cashier = order.headers.location; // 用 services.browser.open 打开,不要返回给模型