feat(config): add scoped local visitor accounts

This commit is contained in:
2026-09-05 14:43:11 +08:00
parent c6f1103b32
commit eb5fb44d97
4 changed files with 317 additions and 0 deletions
@@ -0,0 +1,22 @@
import { assertLocalStartupContext, type LocalStartupContext } from '../../config/local-startup.ts';
export interface LocalStoragePort {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
}
export const LOCAL_MACHINE_KEY = 'youle.local-test.machine.v1';
export function localAccountKey(context: LocalStartupContext): string {
assertLocalStartupContext(context);
const { identity } = context.config;
const scope = JSON.stringify([
context.profile,
context.config.servers,
identity.agentid,
identity.gameid,
identity.channelid,
identity.marketid,
]);
return `youle.local-test.account.v1:${scope}`;
}
@@ -0,0 +1,118 @@
import type { LoginAccountIdentity } from '../../protocol/contracts/index.ts';
import { assertLocalStartupContext, type LocalStartupContext } from '../../config/local-startup.ts';
import { localAccountKey, type LocalStoragePort } from './local-storage.ts';
const AVATARS = Object.freeze([
'https://img2.baidu.com/it/u=1823726962,2692568839&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500',
'https://img0.baidu.com/it/u=320875415,2231067789&fm=253&fmt=auto&app=120&f=JPEG?w=500&h=500',
'https://img1.baidu.com/it/u=3407774307,2859661062&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500',
'https://img2.baidu.com/it/u=1161398577,3592656327&fm=253&fmt=auto&app=120&f=JPEG?w=500&h=500',
'https://img1.baidu.com/it/u=2061657615,4194426181&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=501',
'https://img2.baidu.com/it/u=3081347414,2363887197&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500',
'https://img1.baidu.com/it/u=1364384963,3982203144&fm=253&fmt=auto&app=120&f=JPEG?w=500&h=500',
'https://img1.baidu.com/it/u=3041194995,2386060237&fm=253&fmt=auto&app=120&f=JPEG?w=500&h=500',
'https://img2.baidu.com/it/u=2904976445,2884058010&fm=253&fmt=auto&app=120&f=JPEG?w=529&h=500',
'https://img0.baidu.com/it/u=1801052125,1596537112&fm=253&fmt=auto&app=120&f=JPEG?w=529&h=500',
] as const);
export interface LocalVisitorProvider {
getAccount(): Promise<LoginAccountIdentity>;
resetAccount(): Promise<LoginAccountIdentity>;
}
interface Options {
readonly context: LocalStartupContext;
readonly storage: LocalStoragePort;
readonly random: () => number;
readonly isSessionActive: () => boolean;
}
const ACCOUNT_FIELDS = Object.freeze(['openid', 'nickname', 'avatar', 'sex', 'province', 'city', 'unionid'] as const);
function wrap(key: string, path: string, reason: string, cause?: unknown): Error {
const error = new Error(`Invalid local account cache ${key} at ${path}: ${reason}`) as Error & { cause?: unknown };
if (cause !== undefined) error.cause = cause;
return error;
}
function exactRecord(value: unknown, key: string, path: string, fields: readonly string[]): Record<string, unknown> {
if (value === null || typeof value !== 'object' || Array.isArray(value)) throw wrap(key, path, 'expected object');
const record = value as Record<string, unknown>;
for (const field of fields) {
if (!Object.prototype.hasOwnProperty.call(record, field)) throw wrap(key, `${path}.${field}`, 'missing field');
}
for (const field of Object.keys(record)) if (!fields.includes(field)) throw wrap(key, `${path}.${field}`, 'unknown field');
return record;
}
function validateAccount(value: unknown, key: string): LoginAccountIdentity {
const account = exactRecord(value, key, 'account', ACCOUNT_FIELDS);
for (const field of ['openid', 'nickname', 'avatar', 'province', 'city', 'unionid'] as const) {
if (typeof account[field] !== 'string') throw wrap(key, `account.${field}`, 'expected string');
}
if (account.openid === '') throw wrap(key, 'account.openid', 'must not be empty');
if (account.unionid === '') throw wrap(key, 'account.unionid', 'must not be empty');
if (!Number.isInteger(account.sex)) throw wrap(key, 'account.sex', 'expected integer');
if (account.sex !== 1 && account.sex !== 2) throw wrap(key, 'account.sex', 'expected 1 or 2');
if (account.nickname === '' && account.avatar === '') throw wrap(key, 'account.nickname/avatar', 'must not both be empty');
return Object.freeze(account as unknown as LoginAccountIdentity);
}
function parseCached(raw: string, key: string): LoginAccountIdentity {
let value: unknown;
try { value = JSON.parse(raw); } catch (cause) { throw wrap(key, '$', 'invalid JSON', cause); }
const record = exactRecord(value, key, '$', ['schemaVersion', 'account']);
if (record.schemaVersion !== 1) throw wrap(key, 'schemaVersion', 'expected 1');
return validateAccount(record.account, key);
}
function sample(random: () => number, key: string): number {
let value: number;
try { value = random(); } catch (cause) { throw wrap(key, 'random', 'source failed', cause); }
if (!Number.isFinite(value)) throw wrap(key, 'random', 'expected finite number');
if (value < 0 || value >= 1) throw wrap(key, 'random', 'expected range [0, 1)');
return value;
}
function generate(random: () => number, key: string): LoginAccountIdentity {
const id = Math.floor(sample(random, key) * 100000);
const avatar = AVATARS[Math.floor(sample(random, key) * AVATARS.length)]!;
const sex = Math.floor(sample(random, key) * 2) + 1;
return validateAccount({ openid: `testopenid_${id}`, unionid: `ylgame${id}`,
nickname: `${id}_游客`, avatar, sex, province: 'jiangxi', city: 'nanchang' }, key);
}
function storageFailure(key: string, operation: 'read' | 'write', cause: unknown): Error {
return wrap(key, '$', `storage ${operation} failed`, cause);
}
export function createLocalVisitorProvider(options: Options): LocalVisitorProvider {
assertLocalStartupContext(options.context);
const key = localAccountKey(options.context);
let pending: Promise<LoginAccountIdentity> | null = null;
async function getAccount(): Promise<LoginAccountIdentity> {
if (pending) return pending;
pending = (async () => {
let raw: string | null;
try { raw = options.storage.getItem(key); } catch (cause) { throw storageFailure(key, 'read', cause); }
if (raw !== null) return parseCached(raw, key);
const account = generate(options.random, key);
try { options.storage.setItem(key, JSON.stringify({ schemaVersion: 1, account })); }
catch (cause) { throw storageFailure(key, 'write', cause); }
return account;
})();
try { return await pending; } finally { pending = null; }
}
async function resetAccount(): Promise<LoginAccountIdentity> {
if (options.isSessionActive()) throw new Error('Cannot reset local visitor account during an active session');
if (pending) throw new Error('Cannot reset local visitor account while getAccount is pending');
const account = generate(options.random, key);
try { options.storage.setItem(key, JSON.stringify({ schemaVersion: 1, account })); }
catch (cause) { throw storageFailure(key, 'write', cause); }
return account;
}
return Object.freeze({ getAccount, resetAccount });
}