Files
ark-stronghold-protocol/server/skland/client.js
T
herryyun 7c25c89992
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
实现账号绑定、持久登录与跨设备对局恢复
2026-10-14 23:22:50 +08:00

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,
};
}