diff --git a/app/game-api/src/router/playAudit/index.ts b/app/game-api/src/router/playAudit/index.ts index e8220708..feb54795 100644 --- a/app/game-api/src/router/playAudit/index.ts +++ b/app/game-api/src/router/playAudit/index.ts @@ -1,3 +1,4 @@ +import { requestState } from './requests.js'; import { decisionHistory, decisionDetail } from './decisions.js'; import { diplomacyHistory, diplomacyEvent } from './diplomacy.js'; import { nationSeries, zAuditNation } from './nationSeries.js'; @@ -26,6 +27,7 @@ import { } from './projection.js'; export const playAuditRouter = router({ + requestState, decisionHistory, decisionDetail, diplomacyHistory, diff --git a/app/game-api/src/router/playAudit/requests.ts b/app/game-api/src/router/playAudit/requests.ts new file mode 100644 index 00000000..9ff82cdb --- /dev/null +++ b/app/game-api/src/router/playAudit/requests.ts @@ -0,0 +1,72 @@ +import { TRPCError } from '@trpc/server'; +import { z } from 'zod'; +import { auditProcedure, readAudit, readAuditWorld } from './shared.js'; + +interface RequestStateRow { + sequence: bigint; + requestId: string; + target: 'API' | 'ENGINE'; + eventType: string; + status: 'PENDING' | 'PROCESSING' | 'SUCCEEDED' | 'FAILED'; + attempts: number; + acceptedGameTick: bigint | null; + processingGameTick: bigint | null; + acceptedClockRevision: bigint | null; + processingClockRevision: bigint | null; + createdAt: Date; + processingAt: Date | null; + completedAt: Date | null; + resultRecorded: boolean; + errorRecorded: boolean; +} + +/** 현재 기수의 불변 사건이 참조한 요청만 읽는다. 임의 요청 ID 검색이나 원문 조회는 제공하지 않는다. */ +export const requestState = auditProcedure + .input(z.object({ kind: z.enum(['POLICY', 'DIPLOMACY']), id: z.string().regex(/^[a-f0-9]{64}$/) }).strict()) + .query(({ ctx, input }) => + readAudit(ctx, async (tx) => { + const world = await readAuditWorld(tx); + const reference = world.serverId + ? await (input.kind === 'POLICY' + ? tx.playAuditPolicy.findFirst({ + where: { id: input.id, serverId: world.serverId }, + select: { requestId: true, inputSequence: true }, + }) + : tx.playAuditDiplomacyEvent.findFirst({ + where: { id: input.id, serverId: world.serverId }, + select: { requestId: true, inputSequence: true }, + })) + : null; + if (!reference) + throw new TRPCError({ code: 'NOT_FOUND', message: '현재 기수의 감사 기록을 찾을 수 없습니다.' }); + const base = { ...world, coverage: 'CURRENT_JOURNAL_STATE' as const }; + if (!reference.requestId) return { ...base, status: 'NOT_LINKED' as const, request: null }; + if (reference.inputSequence === null) + return { ...base, status: 'INCOMPLETE_REFERENCE' as const, request: null }; + // unique request_id로 한 행만 읽고 원문 payload/result/error/계정/lease owner는 materialize하지 않는다. + const rows = await tx.$queryRaw` + SELECT sequence, request_id AS "requestId", target::text AS target, event_type AS "eventType", status::text AS status, attempts, + accepted_game_tick AS "acceptedGameTick", processing_game_tick AS "processingGameTick", + accepted_clock_revision AS "acceptedClockRevision", processing_clock_revision AS "processingClockRevision", + created_at AS "createdAt", processing_at AS "processingAt", completed_at AS "completedAt", + result IS NOT NULL AS "resultRecorded", error IS NOT NULL AS "errorRecorded" + FROM input_event WHERE request_id = ${reference.requestId} LIMIT 1 + `; + const row = rows[0]; + if (!row) return { ...base, status: 'MISSING_REQUEST' as const, request: null }; + if (row.sequence !== reference.inputSequence) + return { ...base, status: 'REFERENCE_MISMATCH' as const, request: null }; + return { + ...base, + status: 'AVAILABLE' as const, + request: { + ...row, + sequence: row.sequence.toString(), + acceptedGameTick: row.acceptedGameTick?.toString() ?? null, + processingGameTick: row.processingGameTick?.toString() ?? null, + acceptedClockRevision: row.acceptedClockRevision?.toString() ?? null, + processingClockRevision: row.processingClockRevision?.toString() ?? null, + }, + }; + }) + ); diff --git a/app/game-api/test/securityTransport.integration.test.ts b/app/game-api/test/securityTransport.integration.test.ts index 1c1d67a6..7a5b3fb5 100644 --- a/app/game-api/test/securityTransport.integration.test.ts +++ b/app/game-api/test/securityTransport.integration.test.ts @@ -2242,6 +2242,27 @@ integration('game API security over HTTP transport', () => { }, }); const admin = await token([`admin.playAudit.read:${profileName}`]); + await db.inputEvent.create({ + data: { + sequence: 9007199254740993n, + requestId: 'audit-policy-request', + target: 'ENGINE', + eventType: 'setNpcPolicy', + status: 'SUCCEEDED', + attempts: 2, + payload: { secret: 'request-secret' }, + result: { secret: 'request-secret' }, + error: 'request-secret', + actorUserId: 'request-secret', + lockedBy: 'request-secret', + acceptedGameTick: 4320000000n, + processingGameTick: 4356000000n, + acceptedClockRevision: 3n, + processingClockRevision: 4n, + processingAt: new Date('2026-09-16T00:00:01Z'), + completedAt: new Date('2026-09-16T00:00:02Z'), + }, + }); const beforeInputs = await db.inputEvent.count(); const decisionIds = [policyId(501), policyId(502)].sort().reverse(); const decisionGeneral = 99129; // live general 없이 보존 이력을 읽는다. @@ -2585,6 +2606,59 @@ integration('game API security over HTTP transport', () => { hash: 'fixture', })), }); + const requestInput = { kind: 'POLICY', id: policyId(2) }; + expect((await get('requestState', undefined, requestInput)).status).toBe(401); + for (const roles of [['admin'], ['admin.playAudit.read:other:default']]) + expect((await get('requestState', await token(roles), requestInput)).status).toBe(403); + const requestState = await get('requestState', admin, requestInput); + expect(requestState.status).toBe(200); + expect(requestState.body).toMatchObject({ + result: { + data: { + status: 'AVAILABLE', + coverage: 'CURRENT_JOURNAL_STATE', + request: { + sequence: '9007199254740993', + requestId: 'audit-policy-request', + status: 'SUCCEEDED', + attempts: 2, + acceptedGameTick: '4320000000', + processingGameTick: '4356000000', + acceptedClockRevision: '3', + processingClockRevision: '4', + resultRecorded: true, + errorRecorded: true, + }, + }, + }, + }); + for (const excluded of ['request-secret', 'payload', 'actorUserId', 'lockedBy']) + expect(JSON.stringify(requestState.body)).not.toContain(excluded); + expect((await get('requestState', admin, { kind: 'POLICY', id: policyId(1) })).body).toMatchObject({ + result: { data: { status: 'NOT_LINKED', request: null } }, + }); + expect((await get('requestState', admin, { kind: 'POLICY', id: policyId(99) })).status).toBe(404); + expect((await get('requestState', admin, { ...requestInput, requestId: 'arbitrary' })).status).toBe(400); + expect((await get('requestState', admin, { ...requestInput, id: '../bad' })).status).toBe(400); + await db.playAuditPolicy.update({ where: { id: policyId(2) }, data: { inputSequence: null } }); + expect((await get('requestState', admin, requestInput)).body).toMatchObject({ + result: { data: { status: 'INCOMPLETE_REFERENCE', request: null } }, + }); + await db.playAuditPolicy.update({ where: { id: policyId(2) }, data: { inputSequence: 9007199254740994n } }); + expect((await get('requestState', admin, requestInput)).body).toMatchObject({ + result: { data: { status: 'REFERENCE_MISMATCH', request: null } }, + }); + await db.playAuditPolicy.update({ where: { id: policyId(2) }, data: { inputSequence: 9007199254740993n } }); + expect((await get('requestState', admin, { kind: 'DIPLOMACY', id: policyId(101) })).body).toMatchObject({ + result: { data: { status: 'MISSING_REQUEST', request: null } }, + }); + await db.playAuditDiplomacyEvent.update({ + where: { id: policyId(101) }, + data: { requestId: 'audit-policy-request' }, + }); + expect((await get('requestState', admin, { kind: 'DIPLOMACY', id: policyId(101) })).body).toMatchObject({ + result: { data: { status: 'AVAILABLE', request: { sequence: '9007199254740993' } } }, + }); const policyPage = await get('policyHistory', admin, { ...policyInput, limit: 1 }); expect(policyPage.status).toBe(200); expect(policyPage.body).toMatchObject({ @@ -3169,6 +3243,7 @@ integration('game API security over HTTP transport', () => { result: { data: { items: [] } }, }); expect((await get('policyVersion', admin, { id: policyId(3) })).status).toBe(404); + expect((await get('requestState', admin, requestInput)).status).toBe(404); expect((await get('diplomacyHistory', admin, diplomacyInput)).body).toMatchObject({ result: { data: { items: [] } }, }); @@ -3190,6 +3265,7 @@ integration('game API security over HTTP transport', () => { } finally { await db.playAuditDecisionChunk.deleteMany({ where: { decision: { serverId: seasonId } } }); await db.playAuditDecision.deleteMany({ where: { serverId: seasonId } }); + await db.inputEvent.deleteMany({ where: { requestId: 'audit-policy-request' } }); await db.playAuditPolicy.deleteMany({ where: { serverId: seasonId } }); await db.playAuditDiplomacyEvent.deleteMany({ where: { serverId: seasonId } }); await db.diplomacyLetter.deleteMany({ where: { srcNationId: 99121, destNationId: 99122 } }); diff --git a/app/game-frontend/e2e/playAudit.spec.ts b/app/game-frontend/e2e/playAudit.spec.ts index a7b4fcd5..e52ce5db 100644 --- a/app/game-frontend/e2e/playAudit.spec.ts +++ b/app/game-frontend/e2e/playAudit.spec.ts @@ -107,6 +107,32 @@ const install = async ( }, } : result({ profileName: gameProfile, read: true, accounts: false }); + case 'playAudit.requestState': + return result({ + ...world, + coverage: 'CURRENT_JOURNAL_STATE', + status: input.kind === 'POLICY' && input.id === 'a'.repeat(64) ? 'NOT_LINKED' : 'AVAILABLE', + request: + input.kind === 'POLICY' && input.id === 'a'.repeat(64) + ? null + : { + sequence: '9007199254740993', + requestId: 'fixture-request', + target: 'ENGINE', + eventType: 'setNpcPolicy', + status: 'SUCCEEDED', + attempts: 2, + acceptedGameTick: null, + processingGameTick: '4320000000', + acceptedClockRevision: null, + processingClockRevision: '3', + createdAt: world.asOf, + processingAt: world.asOf, + completedAt: world.asOf, + resultRecorded: true, + errorRecorded: false, + }, + }); case 'playAudit.decisionHistory': return result({ ...world, @@ -1228,3 +1254,65 @@ for (const [status, label] of [ await expect(region).not.toContainText('실행 실패'); }); } + +test('policy request state is explicit, retries independently and resets on version change', async ({ page }) => { + const requests = await install(page); + let fail = true; + await page.route(gameTrpcRoute, async (route) => { + if (fail && route.request().url().includes('playAudit.requestState')) { + fail = false; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + error: { + message: '요청 조회 일시 오류', + code: -32603, + data: { code: 'INTERNAL_SERVER_ERROR', httpStatus: 500 }, + }, + }, + ]), + }); + } else await route.fallback(); + }); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto( + gamePath('/play-audit?tab=policies&nation=2&policyArea=DEFENCE&fromYear=190&fromMonth=1&year=190&month=6') + ); + await page.getByRole('button', { name: '버전 2', exact: true }).click(); + await expect(page.getByRole('button', { name: '요청 처리 조회', exact: true })).toBeVisible(); + expect(requests.some((r) => r.operation === 'playAudit.requestState')).toBe(false); + const before = requests.length; + await page.getByRole('button', { name: '요청 처리 조회', exact: true }).click(); + await expect(page.getByRole('alert')).toContainText('요청 조회 일시 오류'); + await page.getByRole('button', { name: '요청 처리 다시 조회', exact: true }).click(); + const region = page.getByRole('region', { name: '요청 처리 기록', exact: true }); + await expect(region).toContainText('9007199254740993'); + await expect(region).toContainText('처리 성공'); + await expect(region).toContainText('시도별 전체 이력이나 실제 변경 횟수는 아닙니다'); + expect(requests.slice(before).map((r) => r.operation)).toEqual(['playAudit.requestState']); + expect(requests.at(-1)?.input).toEqual({ kind: 'POLICY', id: 'b'.repeat(64) }); + await capture(page, 'mobile-policy-request'); + const count = requests.filter((r) => r.operation === 'playAudit.requestState').length; + await page.getByRole('button', { name: '이전 정책 버전', exact: true }).click(); + await expect(region).not.toContainText('9007199254740993'); + expect(requests.filter((r) => r.operation === 'playAudit.requestState')).toHaveLength(count); + await page.getByRole('button', { name: '요청 처리 조회', exact: true }).click(); + await expect(region).toContainText('연결된 요청이 없습니다'); +}); + +test('diplomacy request state does not reload documents or lists', async ({ page }) => { + const requests = await install(page); + await page.goto( + gamePath('/play-audit?tab=diplomacy&nation=2&otherNation=3&fromYear=190&fromMonth=1&year=190&month=6') + ); + await page.getByRole('button', { name: '문서 승인', exact: true }).click(); + await expect(page.getByRole('button', { name: '요청 처리 조회', exact: true })).toBeVisible(); + const before = requests.length; + await page.getByRole('button', { name: '요청 처리 조회', exact: true }).click(); + await expect(page.getByRole('region', { name: '요청 처리 기록', exact: true })).toContainText('처리 성공'); + expect(requests.slice(before).map((r) => r.operation)).toEqual(['playAudit.requestState']); + expect(requests.at(-1)?.input.kind).toBe('DIPLOMACY'); + await capture(page, 'desktop-diplomacy-request'); +}); diff --git a/app/game-frontend/src/components/playAudit/AuditDiplomacyHistory.vue b/app/game-frontend/src/components/playAudit/AuditDiplomacyHistory.vue index 70f4cd28..a7f134c6 100644 --- a/app/game-frontend/src/components/playAudit/AuditDiplomacyHistory.vue +++ b/app/game-frontend/src/components/playAudit/AuditDiplomacyHistory.vue @@ -2,6 +2,7 @@ import { computed, ref, watch } from 'vue'; import { useRoute, useRouter } from 'vue-router'; import { trpc } from '../../utils/trpc'; +import AuditRequestState from './AuditRequestState.vue'; const props = defineProps<{ nationId: number; otherNationId: number; @@ -261,6 +262,7 @@ watch( {{ detail.event.inputSequence ?? '해당 없음' }}

+ diff --git a/app/game-frontend/src/components/playAudit/AuditPolicyVersion.vue b/app/game-frontend/src/components/playAudit/AuditPolicyVersion.vue index e1c4fd26..dab7881f 100644 --- a/app/game-frontend/src/components/playAudit/AuditPolicyVersion.vue +++ b/app/game-frontend/src/components/playAudit/AuditPolicyVersion.vue @@ -1,6 +1,7 @@ + + diff --git a/docs/design/play-audit-implementation.md b/docs/design/play-audit-implementation.md index bb03bfaa..41db9452 100644 --- a/docs/design/play-audit-implementation.md +++ b/docs/design/play-audit-implementation.md @@ -9,6 +9,21 @@ NPC 결정 trace와 조사 A~F의 완성, 전체 종료 경계 및 COST gate는 ## 현재 구현 +### 정책·외교 사건의 요청 처리 상태 + +`playAudit.requestState`는 현재 기수의 정책 버전 또는 외교 사건 ID만 받는다. +참조된 requestId와 inputSequence가 모두 실제 input_event와 일치할 때 현재 상태, +처리 시도 횟수·접수/처리 tick·시계 버전·시각과 결과/오류 존재 여부를 반환한다. +기준 기록의 연결 없음, 불완전 참조, 삭제된 요청과 순번 불일치를 구분한다. +임의 requestId 검색, payload/result/error 원문·계정·lease owner 조회는 제공하지 않는다. + +권한 검사 뒤 읽기 transaction 안에서 world, 사건 한 행, unique request_id 한 행을 +최대3회 읽는다. SQL은 필요한 scalar와 존재 여부만 투영하며 쓰기·COUNT·전역 검색이 없다. +정책·외교 상세의 공통 component에서 버튼을 눌러 조회하며 재시도는 이 요청만 반복한다. +다른 버전으로 바꾸면 결과를 지우고 진행 중 응답을 무시한다. 자동 조회/polling은 없다. +R7 E의 연결 기반 일부이며 전체 실패·시도별 이력, 실제 mutation 횟수와 replay가 아니다. +F의 알려진 버그 조사 preset은 아직 구현하지 않았다. schema migration58은 그대로다. + ### NPC 결정 조회 API와 화면 `playAudit.decisionHistory/decisionDetail`은 같은 프로필 감사 권한·현재 기수 경계를 따른다. diff --git a/docs/play-audit-operations.md b/docs/play-audit-operations.md index abfbb327..7707fa4e 100644 --- a/docs/play-audit-operations.md +++ b/docs/play-audit-operations.md @@ -24,6 +24,11 @@ 장수 이름은 선택 시점의 이름으로 부분 검색하며 영문 대소문자를 구분한다. 검색어는 64자까지이고 `%`·`_`도 문자 그대로 찾는다. 장수 번호 정렬은 오름차순/내림차순을 제공한다. +정책 버전과 외교 사건 상세의 **요청 처리 조회**는 연결된 요청의 현재 상태와 처리 시도 +횟수, 접수·처리 시각/tick, 결과·오류 기록 존재 여부를 보여준다. 조회 시각 기준 정보이며 +시도별 전체 이력이나 실제 변경 횟수는 아니다. 요청이 없거나 참조 순번이 맞지 않으면 +그 사유를 표시한다. 버튼을 누를 때만 한 건을 조회하고 원문 요청/오류 내용은 제공하지 않는다. + ## 진입과 권한 1. Gateway의 사용자 관리에서 대상 관리자에게 정확한 프로필 scope를 부여한다.