feat(menu): PHP 기준 공통 메뉴를 런타임 설정으로 이관

Gateway와 게임 공통 메뉴를 영속 JSON에서 읽고 다음 페이지 로드에 반영한다. 운영 PHP의 항목과 순서, desktop/mobile geometry 및 정보 동작을 보존하고 검증·복구 문서를 추가한다.
This commit is contained in:
2026-08-19 11:13:57 +00:00
parent b43d7601e7
commit a14282b34d
25 changed files with 744 additions and 173 deletions
+7 -3
View File
@@ -34,6 +34,8 @@ export interface GatewayApiConfig {
orchestratorAdminIntervalMs: number;
workspaceRootHint: string;
worktreeRoot: string;
navigationConfigFile: string | null;
defaultNavigationConfigFile: string;
}
export interface GatewayOrchestratorConfig {
@@ -70,6 +72,7 @@ export const resolveGatewayApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.
const publicBaseUrl = env.GATEWAY_PUBLIC_URL ?? kakaoRedirectUri;
const redisKeyPrefix = env.GATEWAY_REDIS_PREFIX ?? 'sammo:gateway';
const port = parseNumberWithFallback(env.GATEWAY_API_PORT, 13000, 'GATEWAY_API_PORT');
const workspaceRootHint = env.GATEWAY_WORKSPACE_ROOT ?? process.cwd();
return {
host: env.GATEWAY_API_HOST ?? '0.0.0.0',
port,
@@ -129,9 +132,10 @@ export const resolveGatewayApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.
5000,
'GATEWAY_ORCHESTRATOR_ADMIN_MS'
),
workspaceRootHint: env.GATEWAY_WORKSPACE_ROOT ?? process.cwd(),
worktreeRoot:
env.GATEWAY_WORKTREE_ROOT ?? path.resolve(env.GATEWAY_WORKSPACE_ROOT ?? process.cwd(), '.worktrees'),
workspaceRootHint,
worktreeRoot: env.GATEWAY_WORKTREE_ROOT ?? path.resolve(workspaceRootHint, '.worktrees'),
navigationConfigFile: env.CORE_NAVIGATION_CONFIG_FILE?.trim() || '/srv/data/navigation.json',
defaultNavigationConfigFile: path.resolve(workspaceRootHint, 'resources/navigation.json'),
};
};
+7
View File
@@ -15,6 +15,8 @@ import type { AdminAuthContext } from './adminAuth.js';
import type { PasswordEnvelopeService } from './auth/passwordEnvelope.js';
import { createAdminAuditStore, type AdminAuditStore } from './adminAudit.js';
import type { UserIconUploadStore } from './account/remoteUserIconStore.js';
import path from 'node:path';
import { RuntimeNavigationConfigStore } from './navigation/runtimeNavigationConfig.js';
export interface GatewayApiContext {
users: UserRepository;
@@ -41,6 +43,7 @@ export interface GatewayApiContext {
prisma: GatewayPrismaClient;
adminAudit: AdminAuditStore;
adminAuth?: AdminAuthContext;
navigationConfig: RuntimeNavigationConfigStore;
}
export const createGatewayApiContext = (options: {
@@ -67,6 +70,7 @@ export const createGatewayApiContext = (options: {
requestHeaders?: Record<string, string | string[] | undefined>;
prisma: GatewayPrismaClient;
adminAudit?: AdminAuditStore;
navigationConfig?: RuntimeNavigationConfigStore;
}): GatewayApiContext => ({
users: options.users,
sessions: options.sessions,
@@ -91,4 +95,7 @@ export const createGatewayApiContext = (options: {
requestHeaders: options.requestHeaders ?? {},
prisma: options.prisma,
adminAudit: options.adminAudit ?? createAdminAuditStore(options.prisma),
navigationConfig:
options.navigationConfig ??
new RuntimeNavigationConfigStore(null, path.resolve(process.cwd(), 'resources/navigation.json')),
});
@@ -0,0 +1,126 @@
import fs from 'node:fs/promises';
import type { RuntimeNavigationConfig } from '@sammo-ts/common/navigation/menuConfig';
import { z } from 'zod';
const zId = z.string().min(1).max(80).regex(/^[a-z0-9][a-z0-9-]*$/u);
const zLabel = z.string().min(1).max(80);
const zInternalPath = z
.string()
.min(1)
.max(500)
.refine((value) => value.startsWith('/') && !value.startsWith('//'), '내부 경로는 /로 시작해야 합니다.');
const zExternalHref = z
.string()
.min(1)
.max(1000)
.refine(
(value) =>
(value.startsWith('/') && !value.startsWith('///')) ||
value.startsWith('https://') ||
value.startsWith('http://'),
'링크는 /, //, https:// 또는 http://로 시작해야 합니다.'
);
const zNavigationLink = z
.object({
kind: z.literal('link'),
id: zId,
label: zLabel,
to: zInternalPath.optional(),
href: zExternalHref.optional(),
action: z.literal('show-version').optional(),
newTab: z.boolean().optional(),
showWhen: z.enum(['always', 'npc-enabled']).optional(),
highlightWhen: z.enum(['nation-betting', 'vote']).optional(),
})
.strict()
.superRefine((value, context) => {
const destinations = [value.to, value.href, value.action].filter(Boolean);
if (destinations.length !== 1) {
context.addIssue({
code: 'custom',
message: '메뉴 링크에는 to, href, action 중 하나만 필요합니다.',
});
}
});
const zNavigationDivider = z.object({ kind: z.literal('divider'), id: zId }).strict();
const zNavigationChild = z.union([zNavigationLink, zNavigationDivider]);
const zNavigationEntry = z.union([
zNavigationLink,
z
.object({
kind: z.literal('group'),
id: zId,
label: zLabel,
items: z.array(zNavigationChild).min(1).max(30),
})
.strict(),
z
.object({
kind: z.literal('split'),
id: zId,
main: zNavigationLink,
items: z.array(zNavigationChild).min(1).max(30),
})
.strict(),
]);
export const zRuntimeNavigationConfig: z.ZodType<RuntimeNavigationConfig> = z
.object({
version: z.literal(1),
gateway: z
.object({
brand: z.object({ label: zLabel, to: zInternalPath }).strict(),
items: z
.array(
z
.object({
id: zId,
label: zLabel,
href: zExternalHref,
newTab: z.boolean().optional(),
})
.strict()
)
.max(30),
})
.strict(),
game: z.object({ items: z.array(zNavigationEntry).min(1).max(20) }).strict(),
})
.strict();
export class RuntimeNavigationConfigStore {
constructor(
private readonly overridePath: string | null,
private readonly defaultPath: string
) {}
async get(): Promise<RuntimeNavigationConfig> {
const configPath = await this.resolveConfigPath();
let raw: unknown;
try {
raw = JSON.parse(await fs.readFile(configPath, 'utf8')) as unknown;
} catch (error) {
throw new Error(`메뉴 설정 파일을 읽지 못했습니다: ${configPath}`, { cause: error });
}
const parsed = zRuntimeNavigationConfig.safeParse(raw);
if (!parsed.success) {
throw new Error(`메뉴 설정 파일이 올바르지 않습니다: ${configPath}: ${parsed.error.message}`);
}
return parsed.data;
}
private async resolveConfigPath(): Promise<string> {
if (!this.overridePath) return this.defaultPath;
try {
await fs.access(this.overridePath);
return this.overridePath;
} catch (error) {
const code = error instanceof Error && 'code' in error ? error.code : undefined;
if (code === 'ENOENT') return this.defaultPath;
throw error;
}
}
}
+3
View File
@@ -128,6 +128,9 @@ const finishKakaoLoginOrRequestPasswordSetup = async <T extends 'login' | 'verif
};
export const appRouter = router({
navigation: router({
get: procedure.query(({ ctx }) => ctx.navigationConfig.get()),
}),
health: router({
ping: procedure.query(() => ({
ok: true,
+10
View File
@@ -31,6 +31,7 @@ import { registerProfileStatusInternalRoute } from './lobby/profileStatusInterna
import { installGatewayShutdownController } from './lifecycle/shutdownController.js';
import { RemoteUserIconStore } from './account/remoteUserIconStore.js';
import { gatewayFastifyRouterOptions } from './fastifyOptions.js';
import { RuntimeNavigationConfigStore } from './navigation/runtimeNavigationConfig.js';
export const createGatewayApiServer = async () => {
const config = resolveGatewayApiConfigFromEnv();
@@ -80,6 +81,10 @@ export const createGatewayApiServer = async () => {
);
const releases = createGatewayReleaseRepository(postgres.prisma as GatewayPrismaClient);
const profileStatus = new RepositoryProfileStatusService(profiles, orchestrator);
const navigationConfig = new RuntimeNavigationConfigStore(
config.navigationConfigFile,
config.defaultNavigationConfigFile
);
const app = fastify({
logger: true,
@@ -104,6 +109,10 @@ export const createGatewayApiServer = async () => {
profiles,
secret: config.gameTokenSecret,
});
app.get(config.trpcPath.replace(/\/trpc\/?$/u, '/navigation'), async (_request, reply) => {
void reply.header('Cache-Control', 'no-store');
return navigationConfig.get();
});
await app.register(fastifyTRPCPlugin, {
prefix: config.trpcPath,
@@ -134,6 +143,7 @@ export const createGatewayApiServer = async () => {
profileStatus,
requestHeaders: req.headers,
prisma: postgres.prisma as GatewayPrismaClient,
navigationConfig,
}),
},
});
@@ -0,0 +1,86 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { RuntimeNavigationConfigStore } from '../src/navigation/runtimeNavigationConfig.js';
const temporaryDirectories: string[] = [];
const createTemporaryDirectory = async (): Promise<string> => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-navigation-'));
temporaryDirectories.push(directory);
return directory;
};
afterEach(async () => {
await Promise.all(temporaryDirectories.splice(0).map((directory) => fs.rm(directory, { recursive: true })));
});
describe('RuntimeNavigationConfigStore', () => {
it('운영 override가 없으면 저장소 기본 메뉴를 읽는다', async () => {
const store = new RuntimeNavigationConfigStore(
'/definitely-missing/navigation.json',
path.resolve(import.meta.dirname, '../../../resources/navigation.json')
);
const config = await store.get();
expect(config.gateway.items.map((item) => item.label)).toEqual([
'공지사항',
'커뮤니티',
'건의/제안/개발',
'신고/문의',
'자주 묻는 질문',
'패치 내역',
'Git Repo.',
'위키',
'공식 오픈 톡',
'잡담 오픈 톡',
]);
expect(config.game.items.map((item) => (item.kind === 'split' ? item.main.label : item.label))).toEqual([
'천통국 베팅',
'세력일람',
'장수일람',
'명장일람',
'연감',
'게임 정보',
'커뮤니티',
'설문조사',
]);
});
it('프로세스를 재시작하지 않아도 운영 JSON 수정이 다음 조회에 반영된다', async () => {
const directory = await createTemporaryDirectory();
const overridePath = path.join(directory, 'navigation.json');
const defaultPath = path.resolve(import.meta.dirname, '../../../resources/navigation.json');
const raw = JSON.parse(await fs.readFile(defaultPath, 'utf8')) as {
gateway: { items: Array<{ label: string }> };
};
await fs.writeFile(overridePath, JSON.stringify(raw));
const store = new RuntimeNavigationConfigStore(overridePath, defaultPath);
expect((await store.get()).gateway.items[0]?.label).toBe('공지사항');
raw.gateway.items[0]!.label = '운영 공지';
await fs.writeFile(overridePath, JSON.stringify(raw));
expect((await store.get()).gateway.items[0]?.label).toBe('운영 공지');
});
it('실행 가능한 스크립트 URL과 목적지가 없는 링크를 거부한다', async () => {
const directory = await createTemporaryDirectory();
const overridePath = path.join(directory, 'navigation.json');
const invalid = {
version: 1,
gateway: {
brand: { label: '삼국지 모의전투 HiDCHe', to: '/' },
items: [{ id: 'unsafe', label: '위험', href: 'javascript:alert(1)' }],
},
game: { items: [{ kind: 'link', id: 'empty', label: '빈 링크' }] },
};
await fs.writeFile(overridePath, JSON.stringify(invalid));
const store = new RuntimeNavigationConfigStore(overridePath, overridePath);
await expect(store.get()).rejects.toThrow('메뉴 설정 파일이 올바르지 않습니다');
});
});