feat(config): add scoped local visitor accounts
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
# Task 2 report: visitor account and storage sources
|
||||
|
||||
## Outcome
|
||||
|
||||
Implemented the local visitor account source and its scoped storage key. The provider validates the local startup context at construction, reuses only exact schema-version-1 records, freezes returned identities, generates from the ten legacy avatar URLs, and replaces storage only after generation and validation succeed.
|
||||
|
||||
Concurrency is explicit: simultaneous `getAccount()` calls share one in-flight operation and identity; `resetAccount()` rejects while that operation is pending or while a runtime session is active. No in-memory account remains after an operation, so a successful reset cannot be followed by a stale cached return.
|
||||
|
||||
## TDD evidence
|
||||
|
||||
- RED: `node --import tsx --test framework-tests/adapters/local/visitor-account.test.ts` failed with `ERR_MODULE_NOT_FOUND` for `adapters/local/local-storage.ts` before production files existed.
|
||||
- GREEN: the focused suite passes all 8 tests.
|
||||
- A typecheck then caught ES2020-incompatible `Object.hasOwn`; the implementation was changed to `Object.prototype.hasOwnProperty.call`, followed by a clean rerun.
|
||||
|
||||
## Coverage
|
||||
|
||||
- concurrent generation, one write, and frozen identity reuse
|
||||
- lower/upper random boundaries and NaN, Infinity, negative, and 1 rejection
|
||||
- exact cache schema, complete field validation, forbidden legacy/unknown fields, and sanitized errors
|
||||
- dependency error causes and preservation of existing storage when generation fails
|
||||
- scope separation by value type, version exclusion, and server inclusion
|
||||
- active-session and pending-read reset refusal; isolation from legacy and machine keys
|
||||
|
||||
## Verification
|
||||
|
||||
- `node --import tsx --test framework-tests/adapters/local/visitor-account.test.ts` — 8 passed, 0 failed
|
||||
- `.\\node_modules\\.bin\\tsc.cmd -p tsconfig.framework.json --noEmit` — passed
|
||||
- `node scripts/check-import-boundaries.mjs` — `[import-boundaries] OK`
|
||||
- `git diff --check` — passed
|
||||
|
||||
## Self-review
|
||||
|
||||
The change is limited to the two requested sources, their focused test, and this report. It does not touch serialized Cocos assets, engine code, networking, or legacy runtime files. Cache errors include the key and field path, preserve the original cause where one exists, and never include the cached payload.
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { BUILD_IDENTITY } from '../../../YouleNexus/assets/framework/config/sources/defaults.ts';
|
||||
import { PROFILES } from '../../../YouleNexus/assets/framework/config/profiles.ts';
|
||||
import type { LocalStartupContext } from '../../../YouleNexus/assets/framework/config/local-startup.ts';
|
||||
import { LOCAL_MACHINE_KEY, localAccountKey, type LocalStoragePort } from '../../../YouleNexus/assets/framework/adapters/local/local-storage.ts';
|
||||
import { createLocalVisitorProvider } from '../../../YouleNexus/assets/framework/adapters/local/visitor-account.ts';
|
||||
|
||||
class MapStorage implements LocalStoragePort {
|
||||
readonly values = new Map<string, string>();
|
||||
readonly writes: Array<[string, string]> = [];
|
||||
getItem(key: string): string | null { return this.values.get(key) ?? null; }
|
||||
setItem(key: string, value: string): void { this.writes.push([key, value]); this.values.set(key, value); }
|
||||
}
|
||||
|
||||
function context(overrides: Partial<typeof BUILD_IDENTITY> = {}): LocalStartupContext {
|
||||
return { hostKind: 'h5', profile: 'local', config: {
|
||||
mode: 'debug', isDebugger: true, source: 'direct', gameserver: null, rawConfig: null,
|
||||
servers: PROFILES.local!.servers!, identity: { ...BUILD_IDENTITY, ...overrides },
|
||||
} };
|
||||
}
|
||||
|
||||
test('concurrent account reads share one generated frozen identity and one write', async () => {
|
||||
const storage = new MapStorage();
|
||||
let randomCalls = 0;
|
||||
const provider = createLocalVisitorProvider({ context: context(), storage,
|
||||
random: () => { randomCalls += 1; return 0.5; }, isSessionActive: () => false });
|
||||
const [first, second] = await Promise.all([provider.getAccount(), provider.getAccount()]);
|
||||
assert.strictEqual(first, second);
|
||||
assert.deepEqual(first, { openid: 'testopenid_50000', unionid: 'ylgame50000', nickname: '50000_游客',
|
||||
avatar: 'https://img2.baidu.com/it/u=3081347414,2363887197&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500',
|
||||
sex: 2, province: 'jiangxi', city: 'nanchang' });
|
||||
assert.equal(Object.isFrozen(first), true);
|
||||
assert.equal(randomCalls, 3);
|
||||
assert.equal(storage.writes.length, 1);
|
||||
});
|
||||
|
||||
test('random boundaries select first and last valid values', async () => {
|
||||
for (const [samples, expected] of [
|
||||
[[0, 0, 0], ['testopenid_0', 1, 'u=1823726962,2692568839']],
|
||||
[[0.999999, 0.999999, 0.999999], ['testopenid_99999', 2, 'u=1801052125,1596537112']],
|
||||
] as const) {
|
||||
const queue = [...samples];
|
||||
const account = await createLocalVisitorProvider({ context: context(), storage: new MapStorage(),
|
||||
random: () => queue.shift()!, isSessionActive: () => false }).getAccount();
|
||||
assert.equal(account.openid, expected[0]);
|
||||
assert.equal(account.sex, expected[1]);
|
||||
assert.match(account.avatar, new RegExp(expected[2]));
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects every invalid random sample without writing', async () => {
|
||||
for (const invalid of [Number.NaN, Number.POSITIVE_INFINITY, -0.01, 1]) {
|
||||
const storage = new MapStorage();
|
||||
await assert.rejects(createLocalVisitorProvider({ context: context(), storage,
|
||||
random: () => invalid, isSessionActive: () => false }).getAccount(), /random.*finite|random.*range/i);
|
||||
assert.equal(storage.writes.length, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('reuses only an exact schema record and never randomizes it', async () => {
|
||||
const storage = new MapStorage();
|
||||
const account = { openid: 'cached', nickname: '', avatar: 'avatar', sex: 1, province: '', city: '', unionid: 'u' };
|
||||
storage.values.set(localAccountKey(context()), JSON.stringify({ schemaVersion: 1, account }));
|
||||
const loaded = await createLocalVisitorProvider({ context: context(), storage,
|
||||
random: () => { throw new Error('random forbidden'); }, isSessionActive: () => false }).getAccount();
|
||||
assert.deepEqual(loaded, account);
|
||||
assert.equal(Object.isFrozen(loaded), true);
|
||||
assert.equal(storage.writes.length, 0);
|
||||
});
|
||||
|
||||
test('rejects malformed cached records with key and field path without rewriting or leaking text', async () => {
|
||||
const cases: Array<[string, string]> = [
|
||||
['{secret-account-text', '$'],
|
||||
[JSON.stringify({ schemaVersion: 2, account: {} }), 'schemaVersion'],
|
||||
[JSON.stringify({ schemaVersion: 1, account: { openid: 'x' } }), 'account.nickname'],
|
||||
[JSON.stringify({ schemaVersion: 1, account: { openid: 'x', nickname: '', avatar: '', sex: 1, province: '', city: '', unionid: 'u' } }), 'nickname'],
|
||||
[JSON.stringify({ schemaVersion: 1, account: { openid: 'x', nickname: 'n', avatar: '', sex: 1, province: '', city: '', unionid: 'u', headimgurl: 'old' } }), 'headimgurl'],
|
||||
[JSON.stringify({ schemaVersion: 1, account: { openid: 'x', nickname: 'n', avatar: '', sex: 1, province: '', city: '', unionid: 'u', Province: 'old' } }), 'Province'],
|
||||
];
|
||||
for (const [raw, path] of cases) {
|
||||
const storage = new MapStorage();
|
||||
const key = localAccountKey(context());
|
||||
storage.values.set(key, raw);
|
||||
await assert.rejects(createLocalVisitorProvider({ context: context(), storage,
|
||||
random: () => 0, isSessionActive: () => false }).getAccount(), (error: unknown) => {
|
||||
assert.ok(error instanceof Error);
|
||||
assert.match(error.message, new RegExp(path.replace('.', '\\.')));
|
||||
assert.match(error.message, /youle\.local-test\.account\.v1:/);
|
||||
assert.doesNotMatch(error.message, /secret-account-text/);
|
||||
return true;
|
||||
});
|
||||
assert.equal(storage.writes.length, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('preserves dependency failures as causes and keeps old storage on generation failure', async () => {
|
||||
const readFailure = new Error('read broke');
|
||||
await assert.rejects(createLocalVisitorProvider({ context: context(),
|
||||
storage: { getItem() { throw readFailure; }, setItem() {} }, random: () => 0, isSessionActive: () => false,
|
||||
}).getAccount(), (error: unknown) => error instanceof Error && (error as Error & { cause?: unknown }).cause === readFailure);
|
||||
const storage = new MapStorage();
|
||||
const key = localAccountKey(context());
|
||||
storage.values.set(key, 'old-value');
|
||||
const failure = new Error('generation broke');
|
||||
await assert.rejects(createLocalVisitorProvider({ context: context(), storage,
|
||||
random: () => { throw failure; }, isSessionActive: () => false }).resetAccount(),
|
||||
(error: unknown) => error instanceof Error && (error as Error & { cause?: unknown }).cause === failure);
|
||||
assert.equal(storage.values.get(key), 'old-value');
|
||||
|
||||
const writeFailure = new Error('write broke');
|
||||
await assert.rejects(createLocalVisitorProvider({ context: context(),
|
||||
storage: { getItem() { return null; }, setItem() { throw writeFailure; } },
|
||||
random: () => 0, isSessionActive: () => false,
|
||||
}).getAccount(), (error: unknown) => error instanceof Error && (error as Error & { cause?: unknown }).cause === writeFailure);
|
||||
});
|
||||
|
||||
test('scope isolates identity types, ignores version, and serializes the server configuration', () => {
|
||||
const base = context({ marketid: 7, version: 1 });
|
||||
assert.notEqual(localAccountKey(base), localAccountKey(context({ marketid: '7', version: 1 })));
|
||||
assert.equal(localAccountKey(base), localAccountKey(context({ marketid: 7, version: 999 })));
|
||||
assert.equal(localAccountKey(base),
|
||||
'youle.local-test.account.v1:["local",["ws://127.0.0.1:3088"],"veRa0qrBf0df2K1G4de2tgfmVxB2jxpv","J9r4022t80pxlb01zOK76DN1WD4QewFl","FtJf073aa0d6rI1xD8J1Y42fINTm0ziK",7]');
|
||||
});
|
||||
|
||||
test('reset is isolated, refuses active sessions, and refuses overlap with getAccount', async () => {
|
||||
const storage = new MapStorage();
|
||||
storage.values.set('visitorinfo', 'legacy');
|
||||
storage.values.set(LOCAL_MACHINE_KEY, 'machine');
|
||||
await assert.rejects(createLocalVisitorProvider({ context: context(), storage,
|
||||
random: () => 0, isSessionActive: () => true }).resetAccount(), /active session/i);
|
||||
assert.equal(storage.writes.length, 0);
|
||||
const provider = createLocalVisitorProvider({ context: context(), storage: new MapStorage(),
|
||||
random: () => 0, isSessionActive: () => false });
|
||||
const pending = provider.getAccount();
|
||||
await assert.rejects(provider.resetAccount(), /pending|getAccount/i);
|
||||
await pending;
|
||||
const reset = await createLocalVisitorProvider({ context: context(), storage,
|
||||
random: () => 0, isSessionActive: () => false }).resetAccount();
|
||||
assert.equal(reset.openid, 'testopenid_0');
|
||||
assert.deepEqual(storage.writes.map(([key]) => key), [localAccountKey(context())]);
|
||||
assert.equal(storage.values.get('visitorinfo'), 'legacy');
|
||||
assert.equal(storage.values.get(LOCAL_MACHINE_KEY), 'machine');
|
||||
});
|
||||
Reference in New Issue
Block a user