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

180 lines
7.9 KiB
JavaScript

// server/skland/store.js — the per-player Skland credential store (docs/PACKS.md keeps packs data-only; this is
// server code, not a pack). A local, high-performance embedded database: node:sqlite (bundled with Node ≥ 22) in
// WAL mode — no native build step, no extra dependency, survives restarts, and the Docker image mounts one file.
//
// One row per pending transport, JWT account subject or administrator diagnostic id, holding the login chain state:
// access_token the HG account token (refreshes official bindings or rebuilds a Skland cred)
// cred the long-term API credential
// cred_token the HMAC signing key (never leaves the server)
// user_id the Skland internal user id
// d_id the device fingerprint the credentials are pinned to
// nickname / binding what the account reports (cache; refreshed on demand)
// binding_source selects official HG account lookup or the legacy Skland refresh path
//
// The store is synchronous (SQLite is), so callers never await; it is created per server instance (tests pass their
// own file, `:memory:` for pure in-memory). Reserved keys and account ownership are enforced by accountHttp.js.
import { DatabaseSync } from 'node:sqlite';
import { mkdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
const SCHEMA = `
CREATE TABLE IF NOT EXISTS skland_players (
player_id TEXT PRIMARY KEY,
access_token TEXT,
device_token TEXT,
hg_id TEXT,
cred TEXT,
cred_token TEXT,
user_id TEXT,
d_id TEXT,
nickname TEXT,
binding TEXT, -- the raw binding list from the selected provider (cache)
binding_source TEXT NOT NULL DEFAULT 'skland', -- skland | hypergryph
status TEXT NOT NULL DEFAULT 'unbound', -- unbound | scanning | bound | error
error TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_skland_status ON skland_players(status);
`;
/**
* Node ≥ 22.5 has node:sqlite; an older 22.x has not. The store falls back to an in-memory map so the server still
* boots (the credentials then do not survive a restart — logged once).
* @param {{ file?: string, log?: object }} [opts] file: the database path (default data/skland.db; ':memory:' for
* tests). The parent directory is created when missing.
*/
export function createSklandStore(opts = {}) {
const file = opts.file || join(process.cwd(), 'data', 'skland.db');
const log = opts.log || { warn: () => {} };
if (typeof DatabaseSync !== 'function') {
log.warn?.('[skland] node:sqlite unavailable on this Node — using an in-memory store (credentials lost on restart)');
return createMemoryStore(file);
}
if (file !== ':memory:') mkdirSync(dirname(file), { recursive: true });
const db = new DatabaseSync(file);
db.exec('PRAGMA journal_mode = WAL;');
db.exec('PRAGMA synchronous = NORMAL;');
db.exec('PRAGMA foreign_keys = ON;');
db.exec(SCHEMA);
// Preserve existing QR/SMS bindings when opening a database created before official account lookup was added.
if (!db.prepare('PRAGMA table_info(skland_players)').all().some((column) => column.name === 'binding_source')) {
db.exec("ALTER TABLE skland_players ADD COLUMN binding_source TEXT NOT NULL DEFAULT 'skland'");
}
const now = () => Date.now();
/** @returns {Record<string, any> | null} the row as an object, or null */
function rowToObject(row) {
if (!row) return null;
return { ...row, binding: row.binding ? JSON.parse(row.binding) : null };
}
return {
/** The database file (or ':memory:'). */
file,
/** The SQLite journal mode actually in force ('wal' on a file-backed store; 'memory' for the fallback). */
journalMode() {
try { return String(db.prepare('PRAGMA journal_mode').get().journal_mode).toLowerCase(); } catch { return 'unknown'; }
},
close() { db.close(); },
/** Ensure a row exists for the player (idempotent). */
ensure(playerId) {
db.prepare(
'INSERT INTO skland_players (player_id, status, created_at, updated_at) VALUES (?, ?, ?, ?) ' +
'ON CONFLICT(player_id) DO NOTHING',
).run(playerId, 'unbound', now(), now());
},
get(playerId) {
const row = db.prepare('SELECT * FROM skland_players WHERE player_id = ?').get(playerId);
return rowToObject(row);
},
/** All rows (admin/debug views). */
list() {
return db.prepare('SELECT * FROM skland_players ORDER BY updated_at DESC').all().map(rowToObject);
},
/**
* Merge fields into a player's row (created when missing). `binding` may be an object — stored as JSON.
* @param {string} playerId
* @param {Partial<{access_token: string, device_token: string, hg_id: string, cred: string, cred_token: string,
* user_id: string, d_id: string, nickname: string, binding: object | null, binding_source: string, status: string,
* error: string | null}>} fields
*/
update(playerId, fields) {
this.ensure(playerId);
const allowed = ['access_token', 'device_token', 'hg_id', 'cred', 'cred_token', 'user_id', 'd_id', 'nickname', 'binding_source', 'status', 'error'];
const sets = [];
const values = [];
for (const key of allowed) {
if (key in fields) { sets.push(`${key} = ?`); values.push(fields[key] ?? null); }
}
if ('binding' in fields) { sets.push('binding = ?'); values.push(fields.binding == null ? null : JSON.stringify(fields.binding)); }
if (!sets.length) return;
sets.push('updated_at = ?');
values.push(now(), playerId);
db.prepare(`UPDATE skland_players SET ${sets.join(', ')} WHERE player_id = ?`).run(...values);
},
/** Remove a player's credentials (解绑). The row stays, marked unbound. */
unbind(playerId) {
this.update(playerId, {
access_token: null, device_token: null, hg_id: null, cred: null, cred_token: null,
user_id: null, d_id: null, nickname: null, binding: null, binding_source: 'skland', status: 'unbound', error: null,
});
},
/** Delete a player's row entirely (e.g. session cleanup). */
remove(playerId) {
db.prepare('DELETE FROM skland_players WHERE player_id = ?').run(playerId);
},
/** For tests: raw row count. */
count() {
return db.prepare('SELECT COUNT(*) AS n FROM skland_players').get().n;
},
};
}
/** The in-memory fallback (same API; a pre-22.5 Node). Credentials do not survive a restart. */
function createMemoryStore(file) {
/** @type {Map<string, Record<string, any>>} */
const rows = new Map();
const now = () => Date.now();
const blank = () => ({
player_id: null, access_token: null, device_token: null, hg_id: null, cred: null, cred_token: null,
user_id: null, d_id: null, nickname: null, binding: null, binding_source: 'skland', status: 'unbound', error: null,
created_at: 0, updated_at: 0,
});
return {
file,
journalMode() { return 'memory'; },
close() { rows.clear(); },
ensure(playerId) {
if (!rows.has(playerId)) rows.set(playerId, { ...blank(), player_id: playerId, created_at: now(), updated_at: now() });
},
get(playerId) { const row = rows.get(playerId); return row ? { ...row } : null; },
list() { return [...rows.values()].map((r) => ({ ...r })); },
update(playerId, fields) {
this.ensure(playerId);
const row = rows.get(playerId);
for (const [key, value] of Object.entries(fields)) {
row[key] = key === 'binding' && value != null ? JSON.parse(JSON.stringify(value)) : (value ?? null);
}
row.updated_at = now();
},
unbind(playerId) {
this.update(playerId, {
access_token: null, device_token: null, hg_id: null, cred: null, cred_token: null,
user_id: null, d_id: null, nickname: null, binding: null, binding_source: 'skland', status: 'unbound', error: null,
});
},
remove(playerId) { rows.delete(playerId); },
count() { return rows.size; },
};
}