feat: 감사 사건에서 요청 처리 상태를 조회한다
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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<RequestStateRow[]>`
|
||||
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,
|
||||
},
|
||||
};
|
||||
})
|
||||
);
|
||||
@@ -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 } });
|
||||
|
||||
Reference in New Issue
Block a user