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
286 lines
13 KiB
JavaScript
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,
|
|
};
|
|
}
|