实现账号绑定、持久登录与跨设备对局恢复
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
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
This commit is contained in:
1 parent
488db49f11
commit
7c25c89992
62 files changed
+6855
-1005
No files matched your search
@@ -0,0 +1,285 @@
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user