feat(config): add scoped local visitor accounts
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
Reference in New Issue
Block a user