Files
ark-stronghold-protocol/server/skland/service.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

286 lines
13 KiB
JavaScript

// server/skland/service.js — per-player Arknights account bindings on top of the client (client.js) and the
// (i18n-ignore-file: the Chinese strings are error/diagnostic text (an upstream failure's message, a login that did
// not finish) returned as `{ ok: false, error }` — docs/I18N.md's "Error messages are not player text"; the panel
// supplies the wording a player reads)
// store (store.js). One instance per server. The scan sessions live in memory (a QR code is transient); everything
// durable lives in the store.
//
// HTTP uses transport-scoped pending rows, then JWT-owned account rows after provider confirmation.
// Diagnostic ids require an administrator key and cannot access either reserved namespace (docs/skland.md §5).
import { createSklandClient, SklandError, SklandCaptchaError, SklandNeedDeviceError } from './client.js';
import { makeDeviceId } from './crypto.js';
/** Extracts a readable profile name from the binding answer (an arknights channel first, then any). */
function nicknameFromBinding(binding) {
const lists = binding?.list || [];
const ordered = [...lists.filter((l) => l?.appCode === 'arknights'), ...lists.filter((l) => l?.appCode !== 'arknights')];
for (const list of ordered) {
for (const b of list?.bindingList || []) {
if (b.nickName) return b.nickName;
}
}
return null;
}
/** The arknights characters of the binding answer, as a stable shape (every field always present, so JSON keeps it).
* `uid` is the in-game account id, `channelName` the channel, `isDefault` its flag, `gameId` the game's own id. */
function arknightsRoles(binding) {
const roles = [];
for (const list of binding?.list || []) {
if (list?.appCode !== 'arknights') continue;
for (const b of list?.bindingList || []) {
roles.push({
uid: b.uid == null ? null : String(b.uid),
nickName: b.nickName ?? null,
channelName: b.channelName ?? null,
isDefault: b.is_default === true || b.isDefault === true,
gameId: b.gameId ?? null,
});
}
}
return roles;
}
/**
* @param {{
* store: ReturnType<typeof import('./store.js').createSklandStore>,
* client?: ReturnType<typeof import('./client.js').createSklandClient>,
* fetchImpl?: typeof fetch,
* scanTimeoutMs?: number,
* }} p
*/
export function createSklandService({ store, client, fetchImpl, scanTimeoutMs = 130_000 }) {
const skland = client || createSklandClient({ fetchImpl });
/** @type {Map<string, { scanId: string, scanUrl: string, createdAt: number, attempt: object, polling: boolean }>} in-memory scan sessions by player */
const scans = new Map();
// Resume the same grant after its captcha; never cache passwords or repeat a successful password exchange.
const pendingPasswordGrants = new Map();
const attempts = new Map();
function begin(playerId) {
const attempt = {};
attempts.set(playerId, attempt); scans.delete(playerId);
return attempt;
}
const current = (playerId, attempt) => attempts.get(playerId) === attempt;
function checkCurrent(playerId, attempt) {
if (!current(playerId, attempt)) {
const error = new Error('登录已取消,请重试');
throw error;
}
}
function retire(playerId, attempt) { if (current(playerId, attempt)) attempts.delete(playerId); }
function now() { return Date.now(); }
/** The player's row, created when missing. */
function row(playerId) {
store.ensure(playerId);
return store.get(playerId);
}
/** GET /skland/status body. */
function status(playerId) {
const r = row(playerId);
const roles = r.binding ? arknightsRoles(r.binding) : [];
return {
playerId,
status: r.status,
bound: r.status === 'bound',
nickname: r.nickname,
hgId: r.hg_id,
roles,
error: r.error || undefined,
};
}
/** Starts (or restarts) a QR login session for the player. The QR code encodes `scanUrl`. */
async function startScan(playerId) {
const attempt = begin(playerId);
pendingPasswordGrants.delete(playerId);
try {
const { scanId, scanUrl } = await skland.genScan();
checkCurrent(playerId, attempt);
scans.set(playerId, { scanId, scanUrl, createdAt: now(), attempt, polling: false });
store.update(playerId, { status: 'scanning', error: null });
return { scanId, scanUrl, expiresInMs: scanTimeoutMs };
} catch (error) { retire(playerId, attempt); throw error; }
}
/**
* One poll of the player's scan session. When the phone confirmed, the rest of the chain runs and the player is
* bound — the same call that reports `confirmed` also completes the login.
* @returns {Promise<{ state: 'none' | 'waiting' | 'confirmed' | 'expired', scanUrl?: string, error?: string,
* awaitingConfirmation?: boolean, status?: string, nickname?: string | null }>}
*/
async function pollScan(playerId) {
const session = scans.get(playerId);
if (!session) return { state: 'none' };
if (now() - session.createdAt > scanTimeoutMs) {
scans.delete(playerId);
retire(playerId, session.attempt);
store.update(playerId, { status: 'unbound', error: '扫码超时' });
return { state: 'expired', error: '扫码超时,请重新获取二维码' };
}
if (session.polling) return { state: 'waiting', scanUrl: session.scanUrl };
session.polling = true;
let confirmed = false;
try {
const { state, scanCode, awaitingConfirmation } = await skland.checkScan(session.scanId);
checkCurrent(playerId, session.attempt);
if (state === 'waiting') return { state, scanUrl: session.scanUrl, awaitingConfirmation: !!awaitingConfirmation };
confirmed = true;
const auth = await skland.tokenByScanCode(scanCode);
await finishLogin(playerId, auth, { attempt: session.attempt });
const r = row(playerId);
scans.delete(playerId); retire(playerId, session.attempt);
return { state: 'confirmed', status: r.status, nickname: r.nickname };
} catch (e) {
if (!current(playerId, session.attempt)) return { state: 'none' };
scans.delete(playerId); retire(playerId, session.attempt);
store.update(playerId, { status: 'error', error: e.message });
if (!confirmed) throw e;
return { state: 'confirmed', error: e.message };
} finally { session.polling = false; }
}
/**
* Shared tail of every login path: grant → cred → binding, persisted per player.
* @param {string} playerId
* @param {{ token: string, hgId?: string | null, deviceToken?: string | null }} auth
* @param {{ dId?: string, attempt?: object }} [o]
*/
async function finishLogin(playerId, auth, o = {}) {
const existing = row(playerId);
const dId = existing.d_id || o.dId || makeDeviceId();
const grantCode = await skland.getGrantCode(auth.token, auth.deviceToken);
const { cred, credToken } = await skland.getCred(grantCode);
const { binding, cred: cred2, credToken: credToken2 } = await skland.getBinding({
cred, credToken, dId, accessToken: auth.token, deviceToken: auth.deviceToken,
});
if (o.attempt) checkCurrent(playerId, o.attempt);
store.update(playerId, {
access_token: auth.token, device_token: auth.deviceToken || existing.device_token || null,
hg_id: auth.hgId || existing.hg_id || null,
cred: cred2, cred_token: credToken2, d_id: dId, binding, binding_source: 'skland',
nickname: nicknameFromBinding(binding), status: 'bound', error: null,
});
return row(playerId);
}
/** 发送短信验证码. `captcha` = the solved geetest fields when the first attempt asked for one. */
async function sendPhoneCode(playerId, phone, captcha) {
try {
await skland.sendPhoneCode(phone, captcha);
store.update(playerId, { error: null });
return { ok: true };
} catch (e) {
if (e instanceof SklandCaptchaError) return { ok: false, needCaptcha: true, captcha: e.captcha };
if (e instanceof SklandError) return { ok: false, error: e.message };
throw e;
}
}
/** 验证码登录: phone + code → bind. Uses the step methods so `finishLogin` is the only grant→cred→binding place. */
async function loginByPhoneCode(playerId, phone, code) {
const attempt = begin(playerId);
pendingPasswordGrants.delete(playerId);
try {
const auth = await skland.tokenByPhoneCode(phone, code);
const r = await finishLogin(playerId, auth, { attempt });
return { ok: true, nickname: r.nickname, roles: arknightsRoles(r.binding) };
} catch (e) {
if (!current(playerId, attempt)) return { ok: false, error: '登录已取消,请重试' };
const message = e instanceof SklandError ? e.message : '登录失败';
store.update(playerId, { status: 'error', error: message });
return { ok: false, error: message };
} finally { retire(playerId, attempt); }
}
/**
* Password login queries the official HG account binding service directly, without acquiring a Skland cred.
* A previous QR/SMS login and its deviceToken are not needed for this flow.
*/
async function loginByPhonePassword(playerId, phone, password, captcha, captchaStep = 'password') {
const attempt = begin(playerId);
let auth;
try {
if (captchaStep === 'accountGrant' && captcha) {
const pending = pendingPasswordGrants.get(playerId);
pendingPasswordGrants.delete(playerId);
if (!pending || pending.phone !== phone || now() - pending.createdAt > 120_000) {
throw new SklandError('验证已过期,请重新点击登录', 'captcha_expired', 'accountGrant');
}
auth = { token: pending.token };
} else {
pendingPasswordGrants.delete(playerId);
auth = await skland.tokenByPhonePassword(phone, password, captcha);
}
const { token } = auth;
const binding = await skland.getAccountBinding(token, captchaStep === 'accountGrant' ? captcha : undefined);
checkCurrent(playerId, attempt);
store.update(playerId, {
access_token: token, device_token: null, hg_id: null, cred: null, cred_token: null,
user_id: null, d_id: null, binding, binding_source: 'hypergryph',
nickname: nicknameFromBinding(binding), status: 'bound', error: null,
});
const r = row(playerId);
return { ok: true, nickname: r.nickname, roles: arknightsRoles(r.binding) };
} catch (e) {
if (!current(playerId, attempt)) return { ok: false, error: '登录已取消,请重试' };
if (e instanceof SklandCaptchaError) {
if (e.step === 'accountGrant' && auth) pendingPasswordGrants.set(playerId, { token: auth.token, phone, createdAt: now() });
return { ok: false, needCaptcha: true, captcha: e.captcha, captchaStep: e.step };
}
if (e instanceof SklandNeedDeviceError) {
store.update(playerId, { status: 'error', error: e.message });
return { ok: false, error: e.message, needDevice: true };
}
const message = e instanceof SklandError ? e.message : '登录失败';
store.update(playerId, { status: 'error', error: message });
return { ok: false, error: message };
} finally { retire(playerId, attempt); }
}
/** 解绑: forget every credential, including the deviceToken; later logins are checked by the upstream again. */
function unbind(playerId) {
store.unbind(playerId);
scans.delete(playerId);
pendingPasswordGrants.delete(playerId);
attempts.delete(playerId);
return { ok: true };
}
/** The cached arknights roles (a fresh /player/binding round-trip when `refresh`). */
async function roles(playerId, refresh = false) {
const r = row(playerId);
if (!refresh || r.status !== 'bound') return arknightsRoles(r.binding);
const attempt = begin(playerId);
try {
if (r.binding_source === 'hypergryph') {
const binding = await skland.getAccountBinding(r.access_token);
checkCurrent(playerId, attempt);
store.update(playerId, { binding, nickname: nicknameFromBinding(binding), error: null });
return arknightsRoles(binding);
}
const { binding, cred, credToken } = await skland.getBinding({
cred: r.cred, credToken: r.cred_token, dId: r.d_id, accessToken: r.access_token, deviceToken: r.device_token,
});
checkCurrent(playerId, attempt);
store.update(playerId, { binding, cred, credToken, nickname: nicknameFromBinding(binding) });
return arknightsRoles(binding);
} catch (e) {
if (!current(playerId, attempt)) throw e;
const message = e instanceof SklandError ? e.message : '查询失败';
store.update(playerId, { status: 'error', error: message });
throw e;
} finally { retire(playerId, attempt); }
}
return {
status, startScan, pollScan, sendPhoneCode, loginByPhoneCode, loginByPhonePassword, unbind, roles,
/** test hooks */
_store: store, _client: skland, _scans: scans,
};
}