diff --git a/app/game-api/src/router/public/index.ts b/app/game-api/src/router/public/index.ts index f4ef4d8..cfde31a 100644 --- a/app/game-api/src/router/public/index.ts +++ b/app/game-api/src/router/public/index.ts @@ -1,13 +1,14 @@ import { TRPCError } from '@trpc/server'; import { asRecord } from '@sammo-ts/common'; +import { z } from 'zod'; import type { GameApiContext } from '../../context.js'; import { zWorldStateConfig, zWorldStateMeta } from '../../context.js'; import { loadMapLayout } from '../../maps/mapLayout.js'; import { loadPublicMap } from '../../maps/worldMap.js'; -import { procedure, router } from '../../trpc.js'; +import { accessPages, recordGeneralAccess } from '../../services/generalAccess.js'; +import { procedure, router, sessionActivityProcedure } from '../../trpc.js'; import { loadTraitNames } from '../nation/shared.js'; -import { z } from 'zod'; type WorldTrendSnapshot = { year: number; @@ -231,6 +232,11 @@ const sortNpcList = ({ + recorded: await recordGeneralAccess(ctx, input.page), + })), getMapLayout: procedure.query(async ({ ctx }) => { return loadMapLayout(ctx.profile.scenario); }), diff --git a/app/game-api/src/services/generalAccess.ts b/app/game-api/src/services/generalAccess.ts new file mode 100644 index 0000000..ecf03f7 --- /dev/null +++ b/app/game-api/src/services/generalAccess.ts @@ -0,0 +1,184 @@ +import { asRecord } from '@sammo-ts/common'; +import { GamePrisma } from '@sammo-ts/infra'; + +import type { GameApiContext } from '../context.js'; + +export const accessPages = [ + 'front-info', + 'nation-info', + 'nation-cities', + 'global-info', + 'current-city', + 'diplomacy', + 'nation-generals', + 'nation-personnel', + 'nation-finance', + 'battle-center', + 'board', + 'best-general', + 'hall-of-fame', + 'dynasty', + 'yearbook', + 'nation-betting', + 'traffic', + 'npc-list', + 'my-page', + 'npc-control', + 'tournament', + 'betting', +] as const; + +export type AccessPage = (typeof accessPages)[number]; + +export const accessPageWeights: Record = { + 'front-info': 1, + 'nation-info': 1, + 'nation-cities': 1, + 'global-info': 1, + 'current-city': 1, + diplomacy: 1, + 'nation-generals': 1, + 'nation-personnel': 1, + 'nation-finance': 1, + 'battle-center': 1, + board: 1, + 'best-general': 1, + 'hall-of-fame': 1, + dynasty: 1, + yearbook: 1, + 'nation-betting': 1, + traffic: 1, + 'npc-list': 2, + 'my-page': 1, + 'npc-control': 1, + tournament: 1, + betting: 1, +}; + +const adminRoles = new Set(['superuser', 'admin', 'admin.superuser']); + +const readFiniteNumber = (value: unknown): number | null => + typeof value === 'number' && Number.isFinite(value) ? value : null; + +const readDate = (value: unknown): Date | null => { + if (typeof value !== 'string' && !(value instanceof Date)) { + return null; + } + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed; +}; + +export const resolveAccessWindows = ( + now: Date, + tickSeconds: number, + worldMeta: unknown +): { dayStartedAt: Date; scoreStartedAt: Date } => { + const dayStartedAt = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); + const meta = asRecord(worldMeta); + const tickStartedAt = readDate(meta.lastTurnTime) ?? readDate(meta.turntime); + const fallbackTickMs = Math.max(1, Math.floor(tickSeconds)) * 1_000; + const scoreStartedAt = + tickStartedAt && tickStartedAt.getTime() <= now.getTime() + ? tickStartedAt + : new Date(now.getTime() - fallbackTickMs); + return { dayStartedAt, scoreStartedAt }; +}; + +export const upsertGeneralAccess = async ( + db: Pick, + input: { + generalId: number; + userId: string; + weight: number; + now: Date; + dayStartedAt: Date; + scoreStartedAt: Date; + } +): Promise => { + await db.$executeRaw( + GamePrisma.sql` + INSERT INTO general_access_log ( + general_id, + user_id, + last_refresh, + refresh, + refresh_total, + refresh_score, + refresh_score_total + ) + VALUES ( + ${input.generalId}, + ${input.userId}, + ${input.now}, + ${input.weight}, + ${input.weight}, + ${input.weight}, + ${input.weight} + ) + ON CONFLICT (general_id) DO UPDATE SET + user_id = EXCLUDED.user_id, + last_refresh = EXCLUDED.last_refresh, + refresh = CASE + WHEN general_access_log.last_refresh IS NULL + OR general_access_log.last_refresh < ${input.dayStartedAt} + THEN EXCLUDED.refresh + ELSE general_access_log.refresh + EXCLUDED.refresh + END, + refresh_total = general_access_log.refresh_total + EXCLUDED.refresh_total, + refresh_score = CASE + WHEN general_access_log.last_refresh IS NULL + OR general_access_log.last_refresh < ${input.scoreStartedAt} + THEN EXCLUDED.refresh_score + ELSE general_access_log.refresh_score + EXCLUDED.refresh_score + END, + refresh_score_total = + general_access_log.refresh_score_total + EXCLUDED.refresh_score_total + ` + ); +}; + +export const recordGeneralAccess = async ( + ctx: Pick, + page: AccessPage, + now = new Date() +): Promise => { + const user = ctx.auth?.user; + if (!user || user.roles.some((role) => adminRoles.has(role))) { + return false; + } + + const [general, worldState] = await Promise.all([ + ctx.db.general.findFirst({ + where: { userId: user.id }, + orderBy: { id: 'asc' }, + select: { id: true, userId: true }, + }), + ctx.db.worldState.findFirst({ + orderBy: { id: 'asc' }, + select: { tickSeconds: true, meta: true }, + }), + ]); + if (!general || !worldState) { + return false; + } + + const meta = asRecord(worldState.meta); + const isUnited = readFiniteNumber(meta.isUnited) ?? readFiniteNumber(meta.isunited) ?? 0; + const openTime = readDate(meta.opentime); + if (isUnited === 2 || (openTime && openTime.getTime() > now.getTime())) { + return false; + } + + const weight = accessPageWeights[page]; + const { dayStartedAt, scoreStartedAt } = resolveAccessWindows(now, worldState.tickSeconds, meta); + + await upsertGeneralAccess(ctx.db, { + generalId: general.id, + userId: user.id, + weight, + now, + dayStartedAt, + scoreStartedAt, + }); + return true; +}; diff --git a/app/game-api/src/trpc.ts b/app/game-api/src/trpc.ts index 4a3e8dc..880fa42 100644 --- a/app/game-api/src/trpc.ts +++ b/app/game-api/src/trpc.ts @@ -63,6 +63,10 @@ export const router = t.router; export const procedure = t.procedure.use(inputEventMiddleware); export const authedProcedure: typeof procedure = procedure.use(requireAuthMiddleware); +// 페이지 조회 계측처럼 game state/input-event 원장과 무관한 세션 보조 +// mutation에 사용한다. gameplay state 변경에는 사용하지 않는다. +export const sessionActivityProcedure = t.procedure; + // 시뮬레이터처럼 게임 상태를 변경하지 않는 계산은 input-event transaction과 // 이벤트 원장을 만들지 않는다. 인증은 유지하되 lifecycle DB 경계 밖에서 실행한다. export const readOnlyAuthedProcedure: typeof procedure = t.procedure.use(requireAuthMiddleware); diff --git a/app/game-api/test/generalAccessTracking.integration.test.ts b/app/game-api/test/generalAccessTracking.integration.test.ts new file mode 100644 index 0000000..989792f --- /dev/null +++ b/app/game-api/test/generalAccessTracking.integration.test.ts @@ -0,0 +1,71 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; + +import { upsertGeneralAccess } from '../src/services/generalAccess.js'; + +const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; +const integration = describe.skipIf(!databaseUrl); +const generalId = 9_980_071; + +integration('general access tracking persistence', () => { + let db: GamePrismaClient; + let closeDb: (() => Promise) | undefined; + + beforeAll(async () => { + const connector = createGamePostgresConnector({ url: databaseUrl! }); + await connector.connect(); + db = connector.prisma; + closeDb = () => connector.disconnect(); + await db.generalAccessLog.deleteMany({ where: { generalId } }); + }); + + afterAll(async () => { + await db.generalAccessLog.deleteMany({ where: { generalId } }); + await closeDb?.(); + }); + + it('atomically increments concurrent requests and resets only windowed counters', async () => { + const firstWindow = { + generalId, + userId: 'access-user-a', + now: new Date('2026-07-26T03:05:00.000Z'), + dayStartedAt: new Date('2026-07-26T00:00:00.000Z'), + scoreStartedAt: new Date('2026-07-26T03:00:00.000Z'), + }; + await upsertGeneralAccess(db, { ...firstWindow, weight: 2 }); + await Promise.all( + Array.from({ length: 20 }, (_, index) => + upsertGeneralAccess(db, { + ...firstWindow, + now: new Date(firstWindow.now.getTime() + index + 1), + weight: 1, + }) + ) + ); + + expect(await db.generalAccessLog.findUniqueOrThrow({ where: { generalId } })).toMatchObject({ + userId: 'access-user-a', + refresh: 22, + refreshTotal: 22, + refreshScore: 22, + refreshScoreTotal: 22, + }); + + await upsertGeneralAccess(db, { + generalId, + userId: 'access-user-b', + now: new Date('2026-07-27T00:05:00.000Z'), + dayStartedAt: new Date('2026-07-27T00:00:00.000Z'), + scoreStartedAt: new Date('2026-07-27T00:00:00.000Z'), + weight: 1, + }); + + expect(await db.generalAccessLog.findUniqueOrThrow({ where: { generalId } })).toMatchObject({ + userId: 'access-user-b', + refresh: 1, + refreshTotal: 23, + refreshScore: 1, + refreshScoreTotal: 23, + }); + }); +}); diff --git a/app/game-api/test/generalAccessTracking.test.ts b/app/game-api/test/generalAccessTracking.test.ts new file mode 100644 index 0000000..e0c32ae --- /dev/null +++ b/app/game-api/test/generalAccessTracking.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; +import type { DatabaseClient } from '../src/context.js'; + +import { recordGeneralAccess, resolveAccessWindows } from '../src/services/generalAccess.js'; + +const auth = (roles = ['user']): GameSessionTokenPayload => ({ + version: 1, + profile: 'che:default', + issuedAt: '2026-07-26T00:00:00.000Z', + expiresAt: '2026-07-27T00:00:00.000Z', + sessionId: 'access-session', + user: { + id: 'user-7', + username: 'user7', + displayName: '사용자7', + roles, + }, + sanctions: {}, +}); + +const buildDb = (meta: Record = {}) => { + const executeRaw = vi.fn(async (_query: unknown) => 1); + const findGeneral = vi.fn(async () => ({ id: 7, userId: 'user-7' })); + const findWorld = vi.fn(async () => ({ + tickSeconds: 600, + meta: { + opentime: '2026-07-25T00:00:00.000Z', + lastTurnTime: '2026-07-26T03:00:00.000Z', + ...meta, + }, + })); + const db = { + $executeRaw: executeRaw, + general: { findFirst: findGeneral }, + worldState: { findFirst: findWorld }, + } as unknown as DatabaseClient; + return { db, executeRaw, findGeneral, findWorld }; +}; + +describe('general access tracking', () => { + it('resolves the UTC day and latest processed turn windows', () => { + expect( + resolveAccessWindows(new Date('2026-07-26T03:14:15.000Z'), 600, { + lastTurnTime: '2026-07-26T03:10:00.000Z', + }) + ).toEqual({ + dayStartedAt: new Date('2026-07-26T00:00:00.000Z'), + scoreStartedAt: new Date('2026-07-26T03:10:00.000Z'), + }); + }); + + it('uses the session user actor and the legacy page weight in one atomic upsert', async () => { + const { db, executeRaw, findGeneral } = buildDb(); + const now = new Date('2026-07-26T03:05:00.000Z'); + + await expect(recordGeneralAccess({ auth: auth(), db }, 'npc-list', now)).resolves.toBe(true); + expect(findGeneral).toHaveBeenCalledWith({ + where: { userId: 'user-7' }, + orderBy: { id: 'asc' }, + select: { id: true, userId: true }, + }); + expect(executeRaw).toHaveBeenCalledTimes(1); + + const statement = executeRaw.mock.calls[0]![0] as { sql: string; values: unknown[] }; + expect(statement.sql).toContain('ON CONFLICT (general_id) DO UPDATE'); + expect(statement.sql).toContain('general_access_log.refresh + EXCLUDED.refresh'); + expect(statement.values).toEqual([ + 7, + 'user-7', + now, + 2, + 2, + 2, + 2, + new Date('2026-07-26T00:00:00.000Z'), + new Date('2026-07-26T03:00:00.000Z'), + ]); + }); + + it('does not write for anonymous/admin users, a future opening, or a finished world', async () => { + const anonymous = buildDb(); + await expect(recordGeneralAccess({ auth: null, db: anonymous.db }, 'traffic')).resolves.toBe(false); + expect(anonymous.findGeneral).not.toHaveBeenCalled(); + + const admin = buildDb(); + await expect(recordGeneralAccess({ auth: auth(['admin']), db: admin.db }, 'traffic')).resolves.toBe(false); + expect(admin.findGeneral).not.toHaveBeenCalled(); + + const future = buildDb({ opentime: '2026-07-27T00:00:00.000Z' }); + await expect( + recordGeneralAccess({ auth: auth(), db: future.db }, 'traffic', new Date('2026-07-26T03:05:00.000Z')) + ).resolves.toBe(false); + expect(future.executeRaw).not.toHaveBeenCalled(); + + const united = buildDb({ isUnited: 2 }); + await expect(recordGeneralAccess({ auth: auth(), db: united.db }, 'traffic')).resolves.toBe(false); + expect(united.executeRaw).not.toHaveBeenCalled(); + }); +}); diff --git a/app/game-frontend/e2e/inGameMenus.spec.ts b/app/game-frontend/e2e/inGameMenus.spec.ts index 8d7a3f2..6d76291 100644 --- a/app/game-frontend/e2e/inGameMenus.spec.ts +++ b/app/game-frontend/e2e/inGameMenus.spec.ts @@ -23,6 +23,12 @@ type FixtureState = { permission: 'head' | 'member'; myset: number; settingMutations: Array>; + accessPages: string[]; +}; + +type TrpcRequestPayload = { + json?: Record; + input?: { json?: Record }; }; const myGeneral = (state: FixtureState) => ({ @@ -113,7 +119,7 @@ const battleCenter = (state: FixtureState) => ({ const install = async (page: Page, state: FixtureState) => { await page.addInitScript(() => { - localStorage.setItem('sammo-game-token', 'menu-token'); + localStorage.setItem('sammo-game-token', 'ga_menu-token'); localStorage.setItem('sammo-game-profile', 'che:default'); }); await page.route('**/image/game/**', async (route) => { @@ -130,7 +136,16 @@ const install = async (page: Page, state: FixtureState) => { }); await page.route('**/che/api/trpc/**', async (route) => { const operations = operationNames(route); - const results = operations.map((operation) => { + const rawRequestBody: unknown = route.request().postData() ? route.request().postDataJSON() : {}; + const requestBody = + rawRequestBody && typeof rawRequestBody === 'object' ? (rawRequestBody as Record) : {}; + const results = operations.map((operation, operationIndex) => { + const rawPayload = + requestBody[String(operationIndex)] ?? (operations.length === 1 ? requestBody : undefined); + const payload = + rawPayload && typeof rawPayload === 'object' ? (rawPayload as TrpcRequestPayload) : undefined; + const jsonInput = + payload?.json ?? payload?.input?.json ?? (payload as Record | undefined) ?? {}; if (operation === 'lobby.info') return response({ myGeneral: { id: 7, name: '검증장수' } }); if (operation === 'join.getConfig') return response({}); if (operation === 'general.me') return response(myGeneral(state)); @@ -162,11 +177,15 @@ const install = async (page: Page, state: FixtureState) => { if (operation === 'general.getMyLog') return response({ type: 'generalAction', logs: [{ id: 1, text: '기록' }] }); if (operation === 'general.setMySetting') { - const raw = route.request().postDataJSON() as { input?: { json?: Record } }; - state.settingMutations.push(raw.input?.json ?? {}); + state.settingMutations.push(jsonInput); state.myset = Math.max(0, state.myset - 1); return response({ ok: true }); } + if (operation === 'public.recordAccess') { + const pageName = typeof jsonInput.page === 'string' ? jsonInput.page : null; + if (pageName) state.accessPages.push(pageName); + return response({ recorded: true }); + } if (operation === 'nation.getBattleCenter') { if (state.permission === 'member') { return { @@ -196,11 +215,12 @@ const install = async (page: Page, state: FixtureState) => { }; test('접속량정보 keeps the legacy public 1016px chart geometry', async ({ page }) => { - const state: FixtureState = { permission: 'member', myset: 0, settingMutations: [] }; + const state: FixtureState = { permission: 'member', myset: 0, settingMutations: [], accessPages: [] }; await install(page, state); await page.setViewportSize({ width: 1200, height: 900 }); await page.goto('traffic'); await expect(page.locator('.chart-title').first()).toHaveText('접 속 량'); + await expect.poll(() => state.accessPages).toContain('traffic'); const geometry = await page.locator('#traffic-container').evaluate((element) => { const rect = element.getBoundingClientRect(); @@ -244,12 +264,13 @@ test('접속량정보 keeps the legacy public 1016px chart geometry', async ({ p }); test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in place', async ({ page }) => { - const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [] }; + const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] }; await install(page, state); await page.setViewportSize({ width: 1200, height: 900 }); await page.goto('my-page'); await expect(page.locator('.title-row')).toContainText('내 정 보'); await expect(page.locator('#set_my_setting')).toBeVisible(); + await expect.poll(() => state.accessPages).toContain('my-page'); const desktop = await page.locator('#container').evaluate((element) => { const rect = element.getBoundingClientRect(); @@ -322,7 +343,7 @@ test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in plac }); test('감찰부 keeps the selector interaction and shows the permission error path', async ({ page }) => { - const head: FixtureState = { permission: 'head', myset: 3, settingMutations: [] }; + const head: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] }; await install(page, head); await page.setViewportSize({ width: 1000, height: 900 }); await page.goto('battle-center'); @@ -368,7 +389,7 @@ test('감찰부 keeps the selector interaction and shows the permission error pa await persistParityArtifact(page, 'core-battle-center-mobile', mobileGeometry); await page.unrouteAll({ behavior: 'wait' }); - const member: FixtureState = { permission: 'member', myset: 3, settingMutations: [] }; + const member: FixtureState = { permission: 'member', myset: 3, settingMutations: [], accessPages: [] }; await install(page, member); await page.reload(); await expect(page.locator('.error')).toContainText('권한이 부족합니다.'); diff --git a/app/game-frontend/src/router/index.ts b/app/game-frontend/src/router/index.ts index c4dd81d..f7694a4 100644 --- a/app/game-frontend/src/router/index.ts +++ b/app/game-frontend/src/router/index.ts @@ -34,6 +34,34 @@ import NationBettingView from '../views/NationBettingView.vue'; import NpcListView from '../views/NpcListView.vue'; import TrafficView from '../views/TrafficView.vue'; import { useSessionStore } from '../stores/session'; +import { trpc } from '../utils/trpc'; + +const accessPageByRouteName = { + home: 'front-info', + 'nation-info': 'nation-info', + 'nation-cities': 'nation-cities', + 'global-info': 'global-info', + 'current-city': 'current-city', + diplomacy: 'diplomacy', + 'nation-generals': 'nation-generals', + 'nation-personnel': 'nation-personnel', + 'nation-finance': 'nation-finance', + 'battle-center': 'battle-center', + board: 'board', + 'board-secret': 'board', + 'best-general': 'best-general', + 'hall-of-fame': 'hall-of-fame', + 'dynasty-list': 'dynasty', + 'dynasty-detail': 'dynasty', + yearbook: 'yearbook', + 'nation-betting': 'nation-betting', + traffic: 'traffic', + 'npc-list': 'npc-list', + 'my-page': 'my-page', + 'npc-control': 'npc-control', + tournament: 'tournament', + betting: 'betting', +} as const; const routes = [ { @@ -370,4 +398,14 @@ router.beforeEach(async (to) => { return true; }); +router.afterEach((to) => { + const session = useSessionStore(); + const routeName = typeof to.name === 'string' ? to.name : ''; + const page = accessPageByRouteName[routeName as keyof typeof accessPageByRouteName]; + if (!page || !session.hasGeneral) { + return; + } + void trpc.public.recordAccess.mutate({ page }).catch(() => undefined); +}); + export default router;