CI / test (ubuntu-latest, node 22) (push) Failing after 33m10s
CI / test (ubuntu-latest, node 24) (push) Failing after 34m41s
CI / tooling (lint, imports, typecheck) (push) Failing after 36m11s
CI / test (windows-latest, node 22) (push) Canceled after 0s
CI / test (windows-latest, node 24) (push) Canceled after 0s
334 lines
17 KiB
JavaScript
334 lines
17 KiB
JavaScript
// server/skland/client.js — HG account bindings and the legacy Skland login/signed API client. Every call uses an
|
|
// (i18n-ignore-file: what this file holds is not UI text — the Chinese strings are the internal chain-step labels of
|
|
// errors and logs, or the message of an upstream failure the HTTP layer turns into `{ ok: false, error }`, exactly the
|
|
// "Error messages are not player text" case of docs/I18N.md; the panel owns the wording a player reads)
|
|
// injected `fetchImpl` (default: globalThis.fetch), so tests run the whole chain against a stub and nothing here
|
|
// touches the network unless the server does. Account bindings follow the official account SDK; the legacy Skland
|
|
// endpoints/signing also follow community documentation (3mua.cn and TNXG/skland-api). Signing lives in ./crypto.js.
|
|
//
|
|
// The existing QR/SMS paths use Skland; password lookup uses the official HG account binding service:
|
|
// 扫码 genScan → pollScan → tokenByScanCode → (token + deviceToken) → grant → cred
|
|
// 验证码 sendPhoneCode → tokenByPhoneCode → (token + hgId + deviceToken) → grant → cred
|
|
// 密码 tokenByPhonePassword → token → binding-service grant (type 1) → Arknights binding_list
|
|
//
|
|
// A deviceToken is optional in the official account SDK's grant request. Password login must attempt the grant
|
|
// even on a fresh local player; only upstream status 109 (NEED_DEVICE_VERIFY) requires extra verification.
|
|
// Source: https://web.hycdn.cn/hg_account_web_sdk/lib/hg-account-web-sdk.min.js (checked 2026-10-14).
|
|
//
|
|
// SMS/password/account grants may ask for Geetest (status 1). The browser displays the interactive widget and
|
|
// returns its proof to the challenged step; the server never solves the challenge or exposes account tokens.
|
|
//
|
|
// Cred refresh: code 10000 → cred_token expired → POST zonai /auth/refresh, retry once; code 10002 → cred expired →
|
|
// re-run grant → cred from the stored access_token (+ deviceToken), retry once; otherwise the player re-binds.
|
|
|
|
import { SKLAND_APP_CODE, SKLAND_UA, signedHeaders, deviceHeaders } from './crypto.js';
|
|
|
|
const AS_BASE = 'https://as.hypergryph.com';
|
|
const ZONAI_BASE = 'https://zonai.skland.com';
|
|
const BINDING_BASE = 'https://binding-api-account-prod.hypergryph.com';
|
|
// Official account SDK: Account.API.grantApp('binding'), app registry entry (checked 2026-10-14).
|
|
const BINDING_APP_CODE = 'be36d44aa36bfb5b';
|
|
const POLL_INTERVAL_MS = 2000;
|
|
const POLL_TIMEOUT_MS = 120_000;
|
|
|
|
/** Error carrying the upstream's message; `code` is the upstream status/code field, `step` the chain step. */
|
|
export class SklandError extends Error {
|
|
/**
|
|
* @param {string} message @param {number | string} [code] @param {string} [step]
|
|
*/
|
|
constructor(message, code, step) {
|
|
super(message);
|
|
this.name = 'SklandError';
|
|
this.code = code;
|
|
this.step = step;
|
|
}
|
|
}
|
|
|
|
/** The upstream requested an interactive Geetest challenge; the browser completes it and retries that step. */
|
|
export class SklandCaptchaError extends SklandError {
|
|
/**
|
|
* @param {{ gt?: string, challenge?: string, new_captcha?: boolean, geetestId?: string, riskType?: string }} captcha
|
|
*/
|
|
constructor(captcha, step = 'sendPhoneCode') {
|
|
super('需要人机验证,请完成验证后重试', 'captcha', step);
|
|
this.name = 'SklandCaptchaError';
|
|
this.captcha = captcha;
|
|
}
|
|
}
|
|
|
|
/** The upstream requested device verification (109); absence of a local deviceToken alone is not an error. */
|
|
export class SklandNeedDeviceError extends SklandError {
|
|
constructor(message = '当前设备需要额外验证', step = 'getGrantCode') {
|
|
super(`${message},请使用验证码或扫码完成登录`, 109, step);
|
|
this.name = 'SklandNeedDeviceError';
|
|
}
|
|
}
|
|
|
|
async function readJson(res, step) {
|
|
try { return await res.json(); } catch {
|
|
throw new SklandError(`${step}:响应不是 JSON (HTTP ${res.status})`, res.status, step);
|
|
}
|
|
}
|
|
|
|
/** as.hypergryph.com answers { status: 0, msg?, data? }; returns `data`. */
|
|
function assertAs(json, step, captchaStep = step) {
|
|
if (json.status === 1 && json.data?.captcha) throw new SklandCaptchaError(json.data.captcha, captchaStep);
|
|
if (json.status === 109) throw new SklandNeedDeviceError(json.msg || undefined, step);
|
|
if (json.status !== 0) throw new SklandError(json.msg || `${step}失败`, json.status, step);
|
|
return json.data || {};
|
|
}
|
|
|
|
/** zonai.skland.com answers { code: 0, message?, data }; returns `data`. */
|
|
function assertZonai(json, step) {
|
|
if (json.code !== 0) throw new SklandError(json.message || json.msg || `${step}失败`, json.code, step);
|
|
return json.data || {};
|
|
}
|
|
|
|
/**
|
|
* @param {{
|
|
* fetchImpl?: typeof fetch,
|
|
* baseHeaders?: Record<string, string>,
|
|
* pollIntervalMs?: number,
|
|
* pollTimeoutMs?: number,
|
|
* sleep?: (ms: number) => Promise<void>,
|
|
* }} [opts]
|
|
*/
|
|
export function createSklandClient(opts = {}) {
|
|
const fetchImpl = opts.fetchImpl || globalThis.fetch;
|
|
const pollIntervalMs = opts.pollIntervalMs ?? POLL_INTERVAL_MS;
|
|
const pollTimeoutMs = opts.pollTimeoutMs ?? POLL_TIMEOUT_MS;
|
|
const sleep = opts.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
const baseHeaders = { 'User-Agent': SKLAND_UA, Accept: 'application/json', ...(opts.baseHeaders || {}) };
|
|
|
|
async function post(url, body, extraHeaders) {
|
|
return fetchImpl(url, {
|
|
method: 'POST',
|
|
headers: { ...baseHeaders, 'Content-Type': 'application/json', ...(extraHeaders || {}) },
|
|
body: JSON.stringify(body ?? {}),
|
|
});
|
|
}
|
|
|
|
// ── 扫码登录 ──────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Step 1: request a QR scan session. The QR code must encode `scanUrl` (a `hypergryph://scan_login?…` deep link),
|
|
* not the scanId — the 森空岛 app's scanner reads the deep link.
|
|
* @returns {Promise<{ scanId: string, scanUrl: string }>}
|
|
*/
|
|
async function genScan() {
|
|
const res = await post(`${AS_BASE}/general/v1/gen_scan/login`, { appCode: SKLAND_APP_CODE });
|
|
const json = await readJson(res, '获取扫码会话');
|
|
const data = assertAs(json, '获取扫码会话');
|
|
return { scanId: data.scanId, scanUrl: data.scanUrl || `hypergryph://scan_login?scanId=${data.scanId}` };
|
|
}
|
|
|
|
/**
|
|
* Step 2: one status check (the HTTP route polls per request; `pollScan` below loops it).
|
|
* The official account-center login keeps polling for 100 (unscanned) and 101 (awaiting phone confirmation).
|
|
* Source: https://web.hycdn.cn/user-center/index.fa9aa713.js (general/v1/scan_status, checked 2026-10-14).
|
|
* Only status 0 + data.scanCode completes confirmation; 102 and other failures remain errors.
|
|
* @returns {Promise<{ state: 'waiting' | 'confirmed', scanCode?: string, awaitingConfirmation?: boolean }>}
|
|
*/
|
|
async function checkScan(scanId) {
|
|
const res = await fetchImpl(`${AS_BASE}/general/v1/scan_status?scanId=${encodeURIComponent(scanId)}`, { headers: baseHeaders });
|
|
const json = await readJson(res, '查询扫码状态');
|
|
if (json.data && json.data.scanCode) return { state: 'confirmed', scanCode: json.data.scanCode };
|
|
if (json.status === 100 || json.status === 101) return { state: 'waiting', awaitingConfirmation: json.status === 101 };
|
|
throw new SklandError(json.msg || '查询扫码状态失败', json.status, 'checkScan');
|
|
}
|
|
|
|
/**
|
|
* Step 2 (looping): poll until the phone confirms, or the timeout passes.
|
|
* @param {string} scanId
|
|
* @param {{ onWait?: (state: string) => void }} [o]
|
|
* @returns {Promise<string>} the scanCode
|
|
*/
|
|
async function pollScan(scanId, o = {}) {
|
|
const deadline = Date.now() + pollTimeoutMs;
|
|
for (;;) {
|
|
const { state, scanCode } = await checkScan(scanId);
|
|
if (state === 'confirmed') return scanCode;
|
|
if (Date.now() > deadline) throw new SklandError('扫码超时,请重试', 'timeout', 'pollScan');
|
|
o.onWait?.(state);
|
|
await sleep(pollIntervalMs);
|
|
}
|
|
}
|
|
|
|
/** Step 3: scanCode → { token, hgId?, deviceToken? }. */
|
|
async function tokenByScanCode(scanCode) {
|
|
const res = await post(`${AS_BASE}/user/auth/v1/token_by_scan_code`,
|
|
{ appCode: SKLAND_APP_CODE, from: 0, scanCode }, deviceHeaders());
|
|
const json = await readJson(res, '扫码换取 token');
|
|
const data = assertAs(json, '扫码换取 token');
|
|
return { token: data.token, hgId: data.hgId || null, deviceToken: data.deviceToken || null };
|
|
}
|
|
|
|
// ── 验证码登录 ────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Send a login/register SMS (type 2, LOGIN_OR_REGISTER), matching the official account center:
|
|
* https://web.hycdn.cn/user-center/index.fa9aa713.js (checked 2026-10-14).
|
|
* Rate limited (status 102) and 人机验证 (status 1 + geetest) surface as errors;
|
|
* retry the latter with the solved `captcha` fields.
|
|
* @param {string} phone
|
|
* @param {Record<string, string>} [captcha] Geetest v3/v4 proof supplied by the browser
|
|
* @returns {Promise<{ ok: true }>}
|
|
*/
|
|
async function sendPhoneCode(phone, captcha) {
|
|
const body = { phone, type: 2 };
|
|
if (captcha) body.captcha = captcha;
|
|
const res = await post(`${AS_BASE}/general/v1/send_phone_code`, body);
|
|
const json = await readJson(res, '发送验证码');
|
|
if (json.status === 1 && json.data && json.data.captcha) throw new SklandCaptchaError(json.data.captcha);
|
|
if (json.status === 102) throw new SklandError(json.msg || '发送频率太高,请稍后再试', 102, 'sendPhoneCode');
|
|
assertAs(json, '发送验证码');
|
|
return { ok: true };
|
|
}
|
|
|
|
/** 验证码 → { token, hgId, deviceToken } (this endpoint always returns the deviceToken). */
|
|
async function tokenByPhoneCode(phone, code) {
|
|
const res = await post(`${AS_BASE}/user/auth/v2/token_by_phone_code`,
|
|
{ appCode: SKLAND_APP_CODE, code, phone }, deviceHeaders());
|
|
const json = await readJson(res, '验证码换取 token');
|
|
const data = assertAs(json, '验证码换取 token');
|
|
return { token: data.token, hgId: data.hgId || null, deviceToken: data.deviceToken || null };
|
|
}
|
|
|
|
// ── 密码登录 ──────────────────────────────────────────────────────────────────────────────
|
|
|
|
/** 密码 → { token }; a deviceToken is not required locally to try the following grant. */
|
|
async function tokenByPhonePassword(phone, password, captcha) {
|
|
const body = { phone, password };
|
|
if (captcha) body.captcha = captcha;
|
|
const res = await post(`${AS_BASE}/user/auth/v1/token_by_phone_password`, body, deviceHeaders());
|
|
const json = await readJson(res, '密码换取 token');
|
|
const data = assertAs(json, '密码换取 token', 'password');
|
|
if (!data.token) throw new SklandError('密码登录未返回 token', 'no_token', 'tokenByPhonePassword');
|
|
return { token: data.token };
|
|
}
|
|
|
|
/**
|
|
* Query Arknights account bindings without a Skland account or cred.
|
|
* Official SDK: Account.API.grantApp('binding') → Role.API.getServerRoleList('ak').
|
|
* Source: https://web.hycdn.cn/hg_account_web_sdk/lib/hg-account-web-sdk.min.js (2026-10-14).
|
|
*/
|
|
async function getAccountBinding(accessToken, captcha) {
|
|
const body = { token: accessToken, appCode: BINDING_APP_CODE, type: 1 };
|
|
if (captcha) body.captcha = captcha;
|
|
const res = await post(`${AS_BASE}/user/oauth2/v2/grant`,
|
|
body, deviceHeaders());
|
|
const grant = assertAs(await readJson(res, '授权查询账号绑定'), '授权查询账号绑定', 'accountGrant');
|
|
if (!grant.token) throw new SklandError('账号绑定授权未返回 token', 'no_token', 'getAccountBinding');
|
|
const query = new URLSearchParams({ token: grant.token, appCode: 'arknights' });
|
|
const response = await fetchImpl(`${BINDING_BASE}/account/binding/v1/binding_list?${query}`, { headers: baseHeaders });
|
|
const binding = assertAs(await readJson(response, '查询明日方舟账号绑定'), '查询明日方舟账号绑定');
|
|
if (!Array.isArray(binding.list)) throw new SklandError('账号绑定响应缺少角色列表', 'bad_binding', 'getAccountBinding');
|
|
return binding;
|
|
}
|
|
|
|
// ── token → 授权码 → cred → 绑定 ──────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* access_token (+ deviceToken when the login path provided one) → grant code.
|
|
* @param {string} accessToken
|
|
* @param {string | null} [deviceToken]
|
|
* @returns {Promise<string>} the grant code
|
|
*/
|
|
async function getGrantCode(accessToken, deviceToken = null) {
|
|
const body = { appCode: SKLAND_APP_CODE, token: accessToken, type: 0 };
|
|
if (deviceToken) body.deviceToken = deviceToken;
|
|
const res = await post(`${AS_BASE}/user/oauth2/v2/grant`, body);
|
|
const json = await readJson(res, '换取授权码');
|
|
return assertAs(json, '换取授权码').code;
|
|
}
|
|
|
|
/** grant code → { cred, credToken } (this endpoint takes no signature). */
|
|
async function getCred(code) {
|
|
const res = await post(`${ZONAI_BASE}/api/v1/user/auth/generate_cred_by_code`, { code, kind: 1 });
|
|
const json = await readJson(res, '换取 cred');
|
|
const data = assertZonai(json, '换取 cred');
|
|
return { cred: data.cred, credToken: data.token };
|
|
}
|
|
|
|
/** A signed GET on zonai (the signature lives in crypto.js). */
|
|
async function signedGet(path, query, cred, credToken, dId) {
|
|
const { headers } = signedHeaders({ cred, credToken, dId, method: 'GET', path, query });
|
|
const res = await fetchImpl(`${ZONAI_BASE}${path}${query ? `?${query}` : ''}`, { headers: { ...baseHeaders, ...headers } });
|
|
return readJson(res, path);
|
|
}
|
|
|
|
/** A signed POST on zonai (attendance etc.). */
|
|
async function signedPost(path, body, cred, credToken, dId) {
|
|
const { headers } = signedHeaders({ cred, credToken, dId, method: 'POST', path, body });
|
|
const res = await post(`${ZONAI_BASE}${path}`, body, headers);
|
|
return readJson(res, path);
|
|
}
|
|
|
|
/**
|
|
* The account's bound games / roles, refreshing the credential once when the upstream says it expired.
|
|
* @param {{ cred: string, credToken: string, dId: string, accessToken?: string | null, deviceToken?: string | null }} c
|
|
* @returns {Promise<{ binding: object, cred: string, credToken: string }>} the (possibly refreshed) credentials
|
|
*/
|
|
async function getBinding(c) {
|
|
let { cred, credToken } = c;
|
|
const dId = c.dId;
|
|
let json = await signedGet('/api/v1/game/player/binding', '', cred, credToken, dId);
|
|
if (json.code === 10000) {
|
|
const res = await post(`${ZONAI_BASE}/api/v1/auth/refresh`, {}, { cred });
|
|
const data = assertZonai(await readJson(res, '刷新 cred_token'), '刷新 cred_token');
|
|
credToken = data.token;
|
|
json = await signedGet('/api/v1/game/player/binding', '', cred, credToken, dId);
|
|
}
|
|
if (json.code === 10002 && (c.accessToken || c.deviceToken)) {
|
|
const grantCode = await getGrantCode(c.accessToken, c.deviceToken);
|
|
const fresh = await getCred(grantCode);
|
|
cred = fresh.cred;
|
|
credToken = fresh.credToken;
|
|
json = await signedGet('/api/v1/game/player/binding', '', cred, credToken, dId);
|
|
}
|
|
if (json.code !== 0) {
|
|
const message = json.message || json.msg || '';
|
|
if (/未登录/.test(message)) throw new SklandError('登录已过期,请重新绑定', json.code, 'getBinding');
|
|
throw new SklandError(message || '查询绑定失败', json.code, 'getBinding');
|
|
}
|
|
return { binding: json.data, cred, credToken };
|
|
}
|
|
|
|
// ── 整链快捷方式(service.js 用它们组装每个玩家的登录) ──────────────────────────────────
|
|
|
|
/** 扫码整链: scanUrl → scanCode → token (+deviceToken) → grant → cred. */
|
|
async function loginByScan({ onWait } = {}) {
|
|
const { scanId, scanUrl } = await genScan();
|
|
const scanCode = await pollScan(scanId, { onWait });
|
|
const auth = await tokenByScanCode(scanCode);
|
|
const grantCode = await getGrantCode(auth.token, auth.deviceToken);
|
|
const { cred, credToken } = await getCred(grantCode);
|
|
return { scanId, scanUrl, ...auth, cred, credToken };
|
|
}
|
|
|
|
/** 验证码整链: phone + code → token (+hgId+deviceToken) → grant → cred. */
|
|
async function loginByPhoneCode(phone, code) {
|
|
const auth = await tokenByPhoneCode(phone, code);
|
|
const grantCode = await getGrantCode(auth.token, auth.deviceToken);
|
|
const { cred, credToken } = await getCred(grantCode);
|
|
return { ...auth, cred, credToken };
|
|
}
|
|
|
|
/**
|
|
* Password → HG account token → binding-service token (type 1) → Arknights roles.
|
|
* This flow does not acquire a Skland cred or require a stored deviceToken.
|
|
*/
|
|
async function loginByPhonePassword(phone, password) {
|
|
const { token } = await tokenByPhonePassword(phone, password);
|
|
const binding = await getAccountBinding(token);
|
|
return { token, binding };
|
|
}
|
|
|
|
return {
|
|
genScan, checkScan, pollScan, tokenByScanCode,
|
|
sendPhoneCode, tokenByPhoneCode, tokenByPhonePassword,
|
|
getGrantCode, getCred, getBinding, getAccountBinding, signedGet, signedPost,
|
|
loginByScan, loginByPhoneCode, loginByPhonePassword,
|
|
};
|
|
}
|