// 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, * client?: ReturnType, * fetchImpl?: typeof fetch, * scanTimeoutMs?: number, * }} p */ export function createSklandService({ store, client, fetchImpl, scanTimeoutMs = 130_000 }) { const skland = client || createSklandClient({ fetchImpl }); /** @type {Map} 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, }; }