feat: 감사 사건에서 요청 처리 상태를 조회한다
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import { requestState } from './requests.js';
|
||||||
import { decisionHistory, decisionDetail } from './decisions.js';
|
import { decisionHistory, decisionDetail } from './decisions.js';
|
||||||
import { diplomacyHistory, diplomacyEvent } from './diplomacy.js';
|
import { diplomacyHistory, diplomacyEvent } from './diplomacy.js';
|
||||||
import { nationSeries, zAuditNation } from './nationSeries.js';
|
import { nationSeries, zAuditNation } from './nationSeries.js';
|
||||||
@@ -26,6 +27,7 @@ import {
|
|||||||
} from './projection.js';
|
} from './projection.js';
|
||||||
|
|
||||||
export const playAuditRouter = router({
|
export const playAuditRouter = router({
|
||||||
|
requestState,
|
||||||
decisionHistory,
|
decisionHistory,
|
||||||
decisionDetail,
|
decisionDetail,
|
||||||
diplomacyHistory,
|
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}`]);
|
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 beforeInputs = await db.inputEvent.count();
|
||||||
const decisionIds = [policyId(501), policyId(502)].sort().reverse();
|
const decisionIds = [policyId(501), policyId(502)].sort().reverse();
|
||||||
const decisionGeneral = 99129; // live general 없이 보존 이력을 읽는다.
|
const decisionGeneral = 99129; // live general 없이 보존 이력을 읽는다.
|
||||||
@@ -2585,6 +2606,59 @@ integration('game API security over HTTP transport', () => {
|
|||||||
hash: 'fixture',
|
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 });
|
const policyPage = await get('policyHistory', admin, { ...policyInput, limit: 1 });
|
||||||
expect(policyPage.status).toBe(200);
|
expect(policyPage.status).toBe(200);
|
||||||
expect(policyPage.body).toMatchObject({
|
expect(policyPage.body).toMatchObject({
|
||||||
@@ -3169,6 +3243,7 @@ integration('game API security over HTTP transport', () => {
|
|||||||
result: { data: { items: [] } },
|
result: { data: { items: [] } },
|
||||||
});
|
});
|
||||||
expect((await get('policyVersion', admin, { id: policyId(3) })).status).toBe(404);
|
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({
|
expect((await get('diplomacyHistory', admin, diplomacyInput)).body).toMatchObject({
|
||||||
result: { data: { items: [] } },
|
result: { data: { items: [] } },
|
||||||
});
|
});
|
||||||
@@ -3190,6 +3265,7 @@ integration('game API security over HTTP transport', () => {
|
|||||||
} finally {
|
} finally {
|
||||||
await db.playAuditDecisionChunk.deleteMany({ where: { decision: { serverId: seasonId } } });
|
await db.playAuditDecisionChunk.deleteMany({ where: { decision: { serverId: seasonId } } });
|
||||||
await db.playAuditDecision.deleteMany({ where: { 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.playAuditPolicy.deleteMany({ where: { serverId: seasonId } });
|
||||||
await db.playAuditDiplomacyEvent.deleteMany({ where: { serverId: seasonId } });
|
await db.playAuditDiplomacyEvent.deleteMany({ where: { serverId: seasonId } });
|
||||||
await db.diplomacyLetter.deleteMany({ where: { srcNationId: 99121, destNationId: 99122 } });
|
await db.diplomacyLetter.deleteMany({ where: { srcNationId: 99121, destNationId: 99122 } });
|
||||||
|
|||||||
@@ -107,6 +107,32 @@ const install = async (
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
: result({ profileName: gameProfile, read: true, accounts: false });
|
: 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':
|
case 'playAudit.decisionHistory':
|
||||||
return result({
|
return result({
|
||||||
...world,
|
...world,
|
||||||
@@ -1228,3 +1254,65 @@ for (const [status, label] of [
|
|||||||
await expect(region).not.toContainText('실행 실패');
|
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');
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed, ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { trpc } from '../../utils/trpc';
|
import { trpc } from '../../utils/trpc';
|
||||||
|
import AuditRequestState from './AuditRequestState.vue';
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
nationId: number;
|
nationId: number;
|
||||||
otherNationId: number;
|
otherNationId: number;
|
||||||
@@ -261,6 +262,7 @@ watch(
|
|||||||
{{ detail.event.inputSequence ?? '해당 없음' }}
|
{{ detail.event.inputSequence ?? '해당 없음' }}
|
||||||
</p>
|
</p>
|
||||||
</details>
|
</details>
|
||||||
|
<AuditRequestState :id="detail.event.id" kind="DIPLOMACY" />
|
||||||
</template>
|
</template>
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch } from 'vue';
|
import { ref, watch } from 'vue';
|
||||||
import { trpc } from '../../utils/trpc';
|
import { trpc } from '../../utils/trpc';
|
||||||
|
import AuditRequestState from './AuditRequestState.vue';
|
||||||
const props = withDefaults(defineProps<{ id: string; allowPrevious?: boolean }>(), { allowPrevious: true });
|
const props = withDefaults(defineProps<{ id: string; allowPrevious?: boolean }>(), { allowPrevious: true });
|
||||||
const emit = defineEmits<{ select: [id: string | null] }>();
|
const emit = defineEmits<{ select: [id: string | null] }>();
|
||||||
type Detail = Awaited<ReturnType<typeof trpc.playAudit.policyVersion.query>>;
|
type Detail = Awaited<ReturnType<typeof trpc.playAudit.policyVersion.query>>;
|
||||||
@@ -128,6 +129,7 @@ watch(
|
|||||||
<p>요청 {{ detail.version.requestId ?? '해당 없음' }}</p>
|
<p>요청 {{ detail.version.requestId ?? '해당 없음' }}</p>
|
||||||
<p>입력 순번 {{ detail.version.inputSequence ?? '해당 없음' }}</p>
|
<p>입력 순번 {{ detail.version.inputSequence ?? '해당 없음' }}</p>
|
||||||
</details>
|
</details>
|
||||||
|
<AuditRequestState :id="detail.version.id" kind="POLICY" />
|
||||||
</template>
|
</template>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
import { trpc } from '../../utils/trpc';
|
||||||
|
const props = defineProps<{ kind: 'POLICY' | 'DIPLOMACY'; id: string }>();
|
||||||
|
type Data = Awaited<ReturnType<typeof trpc.playAudit.requestState.query>>;
|
||||||
|
const data = ref<Data | null>(null);
|
||||||
|
const opened = ref(false);
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref('');
|
||||||
|
let generation = 0;
|
||||||
|
const labels = { PENDING: '처리 대기', PROCESSING: '처리 중', SUCCEEDED: '처리 성공', FAILED: '처리 실패' };
|
||||||
|
const missing = {
|
||||||
|
NOT_LINKED: '이 사건에는 연결된 요청이 없습니다. 자동 처리나 최초 관측일 수 있습니다.',
|
||||||
|
INCOMPLETE_REFERENCE: '요청 순번이 없어 현재 요청 기록과의 일치를 확인할 수 없습니다.',
|
||||||
|
MISSING_REQUEST: '연결된 요청 기록이 보존되어 있지 않습니다. 원인을 이 자료만으로 판단할 수 없습니다.',
|
||||||
|
REFERENCE_MISMATCH: '요청 ID와 저장된 입력 순번이 일치하지 않아 내용을 표시하지 않습니다.',
|
||||||
|
};
|
||||||
|
const load = async () => {
|
||||||
|
if (loading.value) return;
|
||||||
|
opened.value = true;
|
||||||
|
loading.value = true;
|
||||||
|
error.value = '';
|
||||||
|
const request = ++generation;
|
||||||
|
try {
|
||||||
|
const response = await trpc.playAudit.requestState.query({ kind: props.kind, id: props.id });
|
||||||
|
if (request === generation) data.value = response;
|
||||||
|
} catch (cause) {
|
||||||
|
if (request === generation)
|
||||||
|
error.value = cause instanceof Error ? cause.message : '요청 기록을 조회하지 못했습니다.';
|
||||||
|
} finally {
|
||||||
|
if (request === generation) loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
watch([() => props.kind, () => props.id], () => {
|
||||||
|
generation++;
|
||||||
|
data.value = null;
|
||||||
|
opened.value = false;
|
||||||
|
loading.value = false;
|
||||||
|
error.value = '';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<section class="audit-request" aria-label="요청 처리 기록">
|
||||||
|
<button class="legacy-button" :disabled="loading" @click="load">
|
||||||
|
{{ opened ? '요청 처리 다시 조회' : '요청 처리 조회' }}
|
||||||
|
</button>
|
||||||
|
<p v-if="loading" role="status">요청 처리 기록 조회 중…</p>
|
||||||
|
<p v-if="error" role="alert">{{ error }}</p>
|
||||||
|
<template v-if="data">
|
||||||
|
<p v-if="data.status !== 'AVAILABLE'">{{ missing[data.status] }}</p>
|
||||||
|
<template v-else-if="data.request">
|
||||||
|
<p>조회 시각 {{ data.asOf }}</p>
|
||||||
|
<p>조회 시점에 보존된 요청 상태입니다. 처리 시도별 전체 이력이나 실제 변경 횟수는 아닙니다.</p>
|
||||||
|
<dl>
|
||||||
|
<dt>요청</dt>
|
||||||
|
<dd>{{ data.request.requestId }}</dd>
|
||||||
|
<dt>입력 순번</dt>
|
||||||
|
<dd>{{ data.request.sequence }}</dd>
|
||||||
|
<dt>처리 대상 · 종류</dt>
|
||||||
|
<dd>{{ data.request.target }} · {{ data.request.eventType }}</dd>
|
||||||
|
<dt>현재 상태</dt>
|
||||||
|
<dd>{{ labels[data.request.status] }}</dd>
|
||||||
|
<dt>처리 시도 횟수</dt>
|
||||||
|
<dd>{{ data.request.attempts }}</dd>
|
||||||
|
<dt>접수</dt>
|
||||||
|
<dd>
|
||||||
|
{{ data.request.createdAt }} · tick {{ data.request.acceptedGameTick ?? '미관측' }} · 시계 버전
|
||||||
|
{{ data.request.acceptedClockRevision ?? '미관측' }}
|
||||||
|
</dd>
|
||||||
|
<dt>마지막 처리 시작</dt>
|
||||||
|
<dd>
|
||||||
|
{{ data.request.processingAt ?? '미관측' }} · tick
|
||||||
|
{{ data.request.processingGameTick ?? '미관측' }} · 시계 버전
|
||||||
|
{{ data.request.processingClockRevision ?? '미관측' }}
|
||||||
|
</dd>
|
||||||
|
<dt>완료 기록 시각</dt>
|
||||||
|
<dd>{{ data.request.completedAt ?? '미관측' }}</dd>
|
||||||
|
<dt>결과 · 오류 기록</dt>
|
||||||
|
<dd>
|
||||||
|
{{ data.request.resultRecorded ? '결과 있음' : '결과 없음' }} ·
|
||||||
|
{{ data.request.errorRecorded ? '오류 있음' : '오류 없음' }}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
.audit-request {
|
||||||
|
margin-top: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
dl {
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
dt {
|
||||||
|
font-weight: bold;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
dd {
|
||||||
|
margin-left: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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와 화면
|
### NPC 결정 조회 API와 화면
|
||||||
|
|
||||||
`playAudit.decisionHistory/decisionDetail`은 같은 프로필 감사 권한·현재 기수 경계를 따른다.
|
`playAudit.decisionHistory/decisionDetail`은 같은 프로필 감사 권한·현재 기수 경계를 따른다.
|
||||||
|
|||||||
@@ -24,6 +24,11 @@
|
|||||||
장수 이름은 선택 시점의 이름으로 부분 검색하며 영문 대소문자를 구분한다. 검색어는
|
장수 이름은 선택 시점의 이름으로 부분 검색하며 영문 대소문자를 구분한다. 검색어는
|
||||||
64자까지이고 `%`·`_`도 문자 그대로 찾는다. 장수 번호 정렬은 오름차순/내림차순을 제공한다.
|
64자까지이고 `%`·`_`도 문자 그대로 찾는다. 장수 번호 정렬은 오름차순/내림차순을 제공한다.
|
||||||
|
|
||||||
|
정책 버전과 외교 사건 상세의 **요청 처리 조회**는 연결된 요청의 현재 상태와 처리 시도
|
||||||
|
횟수, 접수·처리 시각/tick, 결과·오류 기록 존재 여부를 보여준다. 조회 시각 기준 정보이며
|
||||||
|
시도별 전체 이력이나 실제 변경 횟수는 아니다. 요청이 없거나 참조 순번이 맞지 않으면
|
||||||
|
그 사유를 표시한다. 버튼을 누를 때만 한 건을 조회하고 원문 요청/오류 내용은 제공하지 않는다.
|
||||||
|
|
||||||
## 진입과 권한
|
## 진입과 권한
|
||||||
|
|
||||||
1. Gateway의 사용자 관리에서 대상 관리자에게 정확한 프로필 scope를 부여한다.
|
1. Gateway의 사용자 관리에서 대상 관리자에게 정확한 프로필 scope를 부여한다.
|
||||||
|
|||||||
Reference in New Issue
Block a user