feat: 프로필별 외교 감사 목록과 원문 검증 상세 조회 추가
This commit is contained in:
@@ -0,0 +1,213 @@
|
|||||||
|
import { TRPCError } from '@trpc/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { hashAuditDiplomacyDocument, type GamePrisma } from '@sammo-ts/infra';
|
||||||
|
import { auditProcedure, monthOrdinal, readAudit, readAuditWorld, zAuditMonth } from './shared.js';
|
||||||
|
|
||||||
|
const zActor = z
|
||||||
|
.object({
|
||||||
|
generalId: z.number().int(),
|
||||||
|
name: z.string(),
|
||||||
|
nationId: z.number().int(),
|
||||||
|
officerLevel: z.number().int(),
|
||||||
|
npcState: z.number().int(),
|
||||||
|
actionKey: z.string().optional(),
|
||||||
|
kind: z.enum(['nation', 'general']).optional(),
|
||||||
|
actionOrdinal: z.number().int().positive().optional(),
|
||||||
|
messageId: z.number().int().positive().optional(),
|
||||||
|
})
|
||||||
|
.nullable();
|
||||||
|
const zState = z
|
||||||
|
.object({
|
||||||
|
state: z.union([z.number().int(), z.enum(['PROPOSED', 'ACTIVATED', 'REPLACED', 'CANCELLED'])]),
|
||||||
|
term: z.number().int().optional(),
|
||||||
|
dead: z.number().optional(),
|
||||||
|
isDead: z.boolean().optional(),
|
||||||
|
isShowing: z.boolean().optional(),
|
||||||
|
srcSignerId: z.number().int().nullable().optional(),
|
||||||
|
destSignerId: z.number().int().nullable().optional(),
|
||||||
|
srcNationName: z.string().nullable().optional(),
|
||||||
|
destNationName: z.string().nullable().optional(),
|
||||||
|
srcSignerName: z.string().nullable().optional(),
|
||||||
|
destSignerName: z.string().nullable().optional(),
|
||||||
|
stateOption: z.string().nullable().optional(),
|
||||||
|
reason: z.string().nullable().optional(),
|
||||||
|
reasonAction: z.string().nullable().optional(),
|
||||||
|
reasonActorId: z.number().int().nullable().optional(),
|
||||||
|
})
|
||||||
|
.nullable();
|
||||||
|
const summarySelect = {
|
||||||
|
id: true,
|
||||||
|
sequence: true,
|
||||||
|
schemaVersion: true,
|
||||||
|
srcNationId: true,
|
||||||
|
destNationId: true,
|
||||||
|
category: true,
|
||||||
|
source: true,
|
||||||
|
eventType: true,
|
||||||
|
documentId: true,
|
||||||
|
previousDocumentId: true,
|
||||||
|
year: true,
|
||||||
|
month: true,
|
||||||
|
actor: true,
|
||||||
|
createdAt: true,
|
||||||
|
} satisfies GamePrisma.PlayAuditDiplomacyEventSelect;
|
||||||
|
type Summary = GamePrisma.PlayAuditDiplomacyEventGetPayload<{ select: typeof summarySelect }>;
|
||||||
|
export const projectDiplomacySummary = (row: Summary) => {
|
||||||
|
if (row.schemaVersion !== 1)
|
||||||
|
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: '지원하지 않는 외교 기록 버전입니다.' });
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
sequence: row.sequence.toString(),
|
||||||
|
srcNationId: row.srcNationId,
|
||||||
|
destNationId: row.destNationId,
|
||||||
|
category: z.enum(['DOCUMENT', 'RELATION']).parse(row.category),
|
||||||
|
source: z.enum(['API', 'ENGINE', 'BASELINE']).parse(row.source),
|
||||||
|
eventType: row.eventType,
|
||||||
|
documentId: row.documentId,
|
||||||
|
previousDocumentId: row.previousDocumentId,
|
||||||
|
year: row.year,
|
||||||
|
month: row.month,
|
||||||
|
actor: zActor.parse(row.actor),
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const zSequence = z
|
||||||
|
.string()
|
||||||
|
.regex(/^[1-9][0-9]{0,18}$/)
|
||||||
|
.refine((value) => /^[1-9][0-9]{0,18}$/.test(value) && BigInt(value) <= 9223372036854775807n);
|
||||||
|
const zId = z.string().regex(/^[a-f0-9]{64}$/);
|
||||||
|
|
||||||
|
export const diplomacyHistory = auditProcedure
|
||||||
|
.input(
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
nationId: z.number().int().positive(),
|
||||||
|
otherNationId: z.number().int().positive(),
|
||||||
|
from: zAuditMonth.omit({ kind: true }),
|
||||||
|
to: zAuditMonth.omit({ kind: true }),
|
||||||
|
category: z.enum(['DOCUMENT', 'RELATION']).optional(),
|
||||||
|
cursor: zSequence.optional(),
|
||||||
|
limit: z.number().int().min(1).max(200).default(50),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.refine((input) => input.nationId !== input.otherNationId, '서로 다른 국가를 선택해 주세요.')
|
||||||
|
)
|
||||||
|
.query(({ ctx, input }) =>
|
||||||
|
readAudit(ctx, async (tx) => {
|
||||||
|
const world = await readAuditWorld(tx);
|
||||||
|
const from = monthOrdinal(input.from.year, input.from.month);
|
||||||
|
const to = monthOrdinal(input.to.year, input.to.month);
|
||||||
|
if (
|
||||||
|
from > to ||
|
||||||
|
from < monthOrdinal(world.startYear, world.startMonth) ||
|
||||||
|
to > monthOrdinal(world.year, world.month)
|
||||||
|
)
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '현재 기수 안의 외교 조회 기간을 선택해 주세요.' });
|
||||||
|
const rows = world.serverId
|
||||||
|
? await tx.playAuditDiplomacyEvent.findMany({
|
||||||
|
where: {
|
||||||
|
serverId: world.serverId,
|
||||||
|
nationA: Math.min(input.nationId, input.otherNationId),
|
||||||
|
nationB: Math.max(input.nationId, input.otherNationId),
|
||||||
|
category: input.category,
|
||||||
|
sequence: input.cursor === undefined ? undefined : { lt: BigInt(input.cursor) },
|
||||||
|
AND: [
|
||||||
|
{
|
||||||
|
OR: [
|
||||||
|
{ year: { gt: input.from.year } },
|
||||||
|
{ year: input.from.year, month: { gte: input.from.month } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
OR: [
|
||||||
|
{ year: { lt: input.to.year } },
|
||||||
|
{ year: input.to.year, month: { lte: input.to.month } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: summarySelect,
|
||||||
|
orderBy: { sequence: 'desc' },
|
||||||
|
take: input.limit + 1,
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
return {
|
||||||
|
...world,
|
||||||
|
coverage: world.serverId ? ('RECORDED_EVENTS_ONLY' as const) : ('IDENTITY_MISSING' as const),
|
||||||
|
items: rows.slice(0, input.limit).map(projectDiplomacySummary),
|
||||||
|
nextCursor: rows.length > input.limit ? rows[input.limit - 1]!.sequence.toString() : null,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export const diplomacyEvent = auditProcedure.input(z.object({ id: zId }).strict()).query(({ ctx, input }) =>
|
||||||
|
readAudit(ctx, async (tx) => {
|
||||||
|
const world = await readAuditWorld(tx);
|
||||||
|
const row = world.serverId
|
||||||
|
? await tx.playAuditDiplomacyEvent.findFirst({
|
||||||
|
where: { id: input.id, serverId: world.serverId },
|
||||||
|
select: {
|
||||||
|
...summarySelect,
|
||||||
|
before: true,
|
||||||
|
after: true,
|
||||||
|
tick: true,
|
||||||
|
clockRevision: true,
|
||||||
|
executionId: true,
|
||||||
|
ordinal: true,
|
||||||
|
requestId: true,
|
||||||
|
inputSequence: true,
|
||||||
|
documentHash: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
if (!row) throw new TRPCError({ code: 'NOT_FOUND', message: '현재 기수의 외교 기록을 찾을 수 없습니다.' });
|
||||||
|
const document =
|
||||||
|
row.documentId === null
|
||||||
|
? null
|
||||||
|
: await tx.diplomacyLetter.findUnique({
|
||||||
|
where: { id: row.documentId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
srcNationId: true,
|
||||||
|
destNationId: true,
|
||||||
|
prevId: true,
|
||||||
|
textBrief: true,
|
||||||
|
textDetail: true,
|
||||||
|
srcSignerId: true,
|
||||||
|
date: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const documentStatus =
|
||||||
|
row.documentId === null
|
||||||
|
? ('NOT_APPLICABLE' as const)
|
||||||
|
: !document
|
||||||
|
? ('MISSING_REFERENCE' as const)
|
||||||
|
: hashAuditDiplomacyDocument(document) !== row.documentHash
|
||||||
|
? ('HASH_MISMATCH' as const)
|
||||||
|
: ('AVAILABLE' as const);
|
||||||
|
return {
|
||||||
|
...world,
|
||||||
|
event: {
|
||||||
|
...projectDiplomacySummary(row),
|
||||||
|
before: zState.parse(row.before),
|
||||||
|
after: zState.parse(row.after),
|
||||||
|
tick: row.tick?.toString() ?? null,
|
||||||
|
clockRevision: row.clockRevision?.toString() ?? null,
|
||||||
|
executionId: row.executionId,
|
||||||
|
ordinal: row.ordinal,
|
||||||
|
requestId: row.requestId,
|
||||||
|
inputSequence: row.inputSequence?.toString() ?? null,
|
||||||
|
documentStatus,
|
||||||
|
document:
|
||||||
|
documentStatus === 'AVAILABLE' && document
|
||||||
|
? {
|
||||||
|
id: document.id,
|
||||||
|
brief: document.textBrief,
|
||||||
|
detail: document.textDetail,
|
||||||
|
writtenAt: document.date,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { diplomacyHistory, diplomacyEvent } from './diplomacy.js';
|
||||||
import { nationSeries, zAuditNation } from './nationSeries.js';
|
import { nationSeries, zAuditNation } from './nationSeries.js';
|
||||||
import { cityDetail, generalDetail, generalTurns } from './details.js';
|
import { cityDetail, generalDetail, generalTurns } from './details.js';
|
||||||
import { generalLogs } from './logs.js';
|
import { generalLogs } from './logs.js';
|
||||||
@@ -24,6 +25,8 @@ import {
|
|||||||
} from './projection.js';
|
} from './projection.js';
|
||||||
|
|
||||||
export const playAuditRouter = router({
|
export const playAuditRouter = router({
|
||||||
|
diplomacyHistory,
|
||||||
|
diplomacyEvent,
|
||||||
policyHistory,
|
policyHistory,
|
||||||
policyVersion,
|
policyVersion,
|
||||||
generalLogs,
|
generalLogs,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { encryptGameSessionToken, type GameSessionTokenPayload } from '@sammo-ts
|
|||||||
import {
|
import {
|
||||||
createGamePostgresConnector,
|
createGamePostgresConnector,
|
||||||
GamePrisma,
|
GamePrisma,
|
||||||
|
hashAuditDiplomacyDocument,
|
||||||
createRedisConnector,
|
createRedisConnector,
|
||||||
enqueueWebPushOutboxEvents,
|
enqueueWebPushOutboxEvents,
|
||||||
resolveRedisConfigFromEnv,
|
resolveRedisConfigFromEnv,
|
||||||
@@ -2241,6 +2242,135 @@ integration('game API security over HTTP transport', () => {
|
|||||||
});
|
});
|
||||||
const admin = await token([`admin.playAudit.read:${profileName}`]);
|
const admin = await token([`admin.playAudit.read:${profileName}`]);
|
||||||
const beforeInputs = await db.inputEvent.count();
|
const beforeInputs = await db.inputEvent.count();
|
||||||
|
const diplomacyInput = {
|
||||||
|
nationId: 99121,
|
||||||
|
otherNationId: 99122,
|
||||||
|
from: { year: 190, month: 1 },
|
||||||
|
to: { year: 190, month: 2 },
|
||||||
|
};
|
||||||
|
const letter = await db.diplomacyLetter.create({
|
||||||
|
data: {
|
||||||
|
srcNationId: 99121,
|
||||||
|
destNationId: 99122,
|
||||||
|
srcSignerId: generalId,
|
||||||
|
state: 'CANCELLED',
|
||||||
|
textBrief: '당시 외교 문서',
|
||||||
|
textDetail: '<p>역사 본문</p>',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await db.playAuditDiplomacyEvent.createMany({
|
||||||
|
data: [1, 2, 3].map((ordinal) => ({
|
||||||
|
id: policyId(100 + ordinal),
|
||||||
|
sequence: 9007199254741000n + BigInt(ordinal),
|
||||||
|
schemaVersion: 1,
|
||||||
|
serverId: seasonId,
|
||||||
|
nationA: 99121,
|
||||||
|
nationB: 99122,
|
||||||
|
srcNationId: 99121,
|
||||||
|
destNationId: 99122,
|
||||||
|
category: ordinal === 2 ? 'RELATION' : 'DOCUMENT',
|
||||||
|
source: 'API',
|
||||||
|
eventType: 'LETTER_ACCEPTED',
|
||||||
|
documentId: ordinal === 2 ? null : letter.id,
|
||||||
|
documentHash: ordinal === 3 ? 'wrong-hash' : hashAuditDiplomacyDocument(letter),
|
||||||
|
year: 190,
|
||||||
|
month: ordinal === 3 ? 2 : 1,
|
||||||
|
executionId: 'http-fixture',
|
||||||
|
ordinal,
|
||||||
|
requestId: 'hidden-request-in-list',
|
||||||
|
inputSequence: 9007199254740993n,
|
||||||
|
actor: {
|
||||||
|
userId: 'hidden-account',
|
||||||
|
generalId,
|
||||||
|
name: '당시군주',
|
||||||
|
nationId: 99121,
|
||||||
|
officerLevel: 12,
|
||||||
|
npcState: 0,
|
||||||
|
debug: 'hidden-debug',
|
||||||
|
},
|
||||||
|
before: { state: 'PROPOSED', debug: 'hidden-before' },
|
||||||
|
after: { state: 'ACTIVATED', debug: 'hidden-after' },
|
||||||
|
hash: 'fixture',
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
const diplomacyPage = await get('diplomacyHistory', admin, { ...diplomacyInput, limit: 1 });
|
||||||
|
expect(diplomacyPage.status).toBe(200);
|
||||||
|
expect(diplomacyPage.body).toMatchObject({
|
||||||
|
result: {
|
||||||
|
data: {
|
||||||
|
nextCursor: '9007199254741003',
|
||||||
|
coverage: 'RECORDED_EVENTS_ONLY',
|
||||||
|
items: [{ id: policyId(103), sequence: '9007199254741003' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const hidden of [
|
||||||
|
'hidden-account',
|
||||||
|
'hidden-debug',
|
||||||
|
'hidden-request-in-list',
|
||||||
|
'before',
|
||||||
|
'after',
|
||||||
|
'역사 본문',
|
||||||
|
])
|
||||||
|
expect(JSON.stringify(diplomacyPage.body)).not.toContain(hidden);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await get('diplomacyHistory', admin, {
|
||||||
|
...diplomacyInput,
|
||||||
|
nationId: 99122,
|
||||||
|
otherNationId: 99121,
|
||||||
|
cursor: '9007199254741003',
|
||||||
|
})
|
||||||
|
).body
|
||||||
|
).toMatchObject({
|
||||||
|
result: { data: { nextCursor: null, items: [{ id: policyId(102) }, { id: policyId(101) }] } },
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
(await get('diplomacyHistory', admin, { ...diplomacyInput, category: 'RELATION' })).body
|
||||||
|
).toMatchObject({ result: { data: { items: [{ id: policyId(102) }] } } });
|
||||||
|
const eventDetail = await get('diplomacyEvent', admin, { id: policyId(101) });
|
||||||
|
expect(eventDetail.status).toBe(200);
|
||||||
|
expect(eventDetail.body).toMatchObject({
|
||||||
|
result: {
|
||||||
|
data: {
|
||||||
|
event: {
|
||||||
|
before: { state: 'PROPOSED' },
|
||||||
|
after: { state: 'ACTIVATED' },
|
||||||
|
inputSequence: '9007199254740993',
|
||||||
|
documentStatus: 'AVAILABLE',
|
||||||
|
document: { detail: '<p>역사 본문</p>' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const hidden of ['hidden-account', 'hidden-debug', 'hidden-before', 'hidden-after'])
|
||||||
|
expect(JSON.stringify(eventDetail.body)).not.toContain(hidden);
|
||||||
|
expect((await get('diplomacyEvent', admin, { id: policyId(103) })).body).toMatchObject({
|
||||||
|
result: { data: { event: { documentStatus: 'HASH_MISMATCH', document: null } } },
|
||||||
|
});
|
||||||
|
await db.diplomacyLetter.delete({ where: { id: letter.id } });
|
||||||
|
expect((await get('diplomacyEvent', admin, { id: policyId(101) })).body).toMatchObject({
|
||||||
|
result: { data: { event: { documentStatus: 'MISSING_REFERENCE', document: null } } },
|
||||||
|
});
|
||||||
|
for (const patch of [
|
||||||
|
{ cursor: 'x' },
|
||||||
|
{ cursor: '9223372036854775808' },
|
||||||
|
{ limit: 201 },
|
||||||
|
{ otherNationId: 99121 },
|
||||||
|
{ from: { year: 189, month: 12 } },
|
||||||
|
{ to: { year: 191, month: 1 } },
|
||||||
|
])
|
||||||
|
expect((await get('diplomacyHistory', admin, { ...diplomacyInput, ...patch })).status).toBe(400);
|
||||||
|
expect((await get('diplomacyEvent', admin, { id: '../bad' })).status).toBe(400);
|
||||||
|
for (const [operation, input] of [
|
||||||
|
['diplomacyHistory', diplomacyInput],
|
||||||
|
['diplomacyEvent', { id: policyId(101) }],
|
||||||
|
] as const) {
|
||||||
|
expect((await get(operation, undefined, input)).status).toBe(401);
|
||||||
|
for (const roles of [['admin'], ['admin.playAudit.read:other:default']])
|
||||||
|
expect((await get(operation, await token(roles), input)).status).toBe(403);
|
||||||
|
}
|
||||||
|
|
||||||
const policyInput = {
|
const policyInput = {
|
||||||
nationId: 99128,
|
nationId: 99128,
|
||||||
area: 'DEFENCE',
|
area: 'DEFENCE',
|
||||||
@@ -2496,6 +2626,8 @@ integration('game API security over HTTP transport', () => {
|
|||||||
});
|
});
|
||||||
expect((await get('capabilities', blocked)).status).toBe(403);
|
expect((await get('capabilities', blocked)).status).toBe(403);
|
||||||
expect((await get('policyHistory', blocked, policyInput)).status).toBe(403);
|
expect((await get('policyHistory', blocked, policyInput)).status).toBe(403);
|
||||||
|
expect((await get('diplomacyHistory', blocked, diplomacyInput)).status).toBe(403);
|
||||||
|
expect((await get('diplomacyEvent', blocked, { id: policyId(101) })).status).toBe(403);
|
||||||
expect((await get('policyVersion', blocked, { id: policyId(3) })).status).toBe(403);
|
expect((await get('policyVersion', blocked, { id: policyId(3) })).status).toBe(403);
|
||||||
const population = {
|
const population = {
|
||||||
count: 0,
|
count: 0,
|
||||||
@@ -2819,6 +2951,10 @@ 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('diplomacyHistory', admin, diplomacyInput)).body).toMatchObject({
|
||||||
|
result: { data: { items: [] } },
|
||||||
|
});
|
||||||
|
expect((await get('diplomacyEvent', admin, { id: policyId(101) })).status).toBe(404);
|
||||||
expect(await db.inputEvent.count()).toBe(beforeInputs);
|
expect(await db.inputEvent.count()).toBe(beforeInputs);
|
||||||
await redis!.client.publish(
|
await redis!.client.publish(
|
||||||
`${redisPrefix}:flush`,
|
`${redisPrefix}:flush`,
|
||||||
@@ -2831,6 +2967,8 @@ integration('game API security over HTTP transport', () => {
|
|||||||
await expect.poll(async () => (await get('capabilities', admin)).status).toBe(401);
|
await expect.poll(async () => (await get('capabilities', admin)).status).toBe(401);
|
||||||
} finally {
|
} finally {
|
||||||
await db.playAuditPolicy.deleteMany({ where: { serverId: seasonId } });
|
await db.playAuditPolicy.deleteMany({ where: { serverId: seasonId } });
|
||||||
|
await db.playAuditDiplomacyEvent.deleteMany({ where: { serverId: seasonId } });
|
||||||
|
await db.diplomacyLetter.deleteMany({ where: { srcNationId: 99121, destNationId: 99122 } });
|
||||||
await db.logEntry.deleteMany({ where: { text: { startsWith: `${seasonId}:` } } });
|
await db.logEntry.deleteMany({ where: { text: { startsWith: `${seasonId}:` } } });
|
||||||
await db.playAuditMonth.deleteMany({ where: { serverId: seasonId } });
|
await db.playAuditMonth.deleteMany({ where: { serverId: seasonId } });
|
||||||
await db.generalTurn.deleteMany({ where: { generalId, turnIdx: { in: [9001, 9002] } } });
|
await db.generalTurn.deleteMany({ where: { generalId, turnIdx: { in: [9001, 9002] } } });
|
||||||
|
|||||||
@@ -166,6 +166,25 @@ sequence를 예약 턴의 실행 ID로 오인하지 않는다. 메모리 Map을
|
|||||||
실제 DB 재로드와 당시 NPC 정책/결정 trace 연결은 후속 gate다. 즉시 명령 executor와
|
실제 DB 재로드와 당시 NPC 정책/결정 trace 연결은 후속 gate다. 즉시 명령 executor와
|
||||||
특수 상태 변경을 포함한 최종 mutation inventory 및 초기 기준/조회 화면도 남았다.
|
특수 상태 변경을 포함한 최종 mutation inventory 및 초기 기준/조회 화면도 남았다.
|
||||||
|
|
||||||
|
### 외교 이력 조회 API
|
||||||
|
|
||||||
|
프로필 game-api의 `diplomacyHistory/diplomacyEvent`는 공통 감사 권한과 read-only
|
||||||
|
transaction을 재사용한다. 목록은 서로 다른 국가 두 개와 현재 기수 안의 기간을
|
||||||
|
필수로 받아 sequence 역순 기본50/최대200개만 반환한다. 국가쌍의 입력 순서는 무관하지만
|
||||||
|
각 사건의 방향은 유지한다. sequence/cursor/tick은 정밀도를 잃지 않는 문자열이다.
|
||||||
|
|
||||||
|
목록에는 본문·전후 값·요청 원장 정보를 싣지 않는다. 상세는 해당 기수 event 1개와
|
||||||
|
필요한 문서 1개만 읽고 원문 hash를 검증한다. AVAILABLE/NOT_APPLICABLE/
|
||||||
|
MISSING_REFERENCE/HASH_MISMATCH를 구분하고 원문 부재·불일치에서는 본문을 반환하지
|
||||||
|
않는다. 기록의 before/after를 보여 주며 현재 문서 상태로 덮어쓰지 않는다. actor와
|
||||||
|
상태는 allowlist로 투영하여 계정 ID·debug 등 내부 값을 제외한다.
|
||||||
|
|
||||||
|
coverage는 RECORDED_EVENTS_ONLY이며 최초 수집 전의 상태 완전성을 주장하지 않는다.
|
||||||
|
국가쌍/sequence index를 사용 가능한 형태지만 실제 큰 기수의 기간별 scan/EXPLAIN 비용
|
||||||
|
검증은 후속 gate다. 실제 HTTP+PG/Redis에서 권한·sanction·잘못된 범위/cursor,
|
||||||
|
본문 지연 조회/hash 상태·기수 전환의 접근 차단과 input_event 무증가를 검증했다.
|
||||||
|
외교 화면과 초기 기준 수집은 아직 남아 있다.
|
||||||
|
|
||||||
## NPC·국방 정책 버전 저장 기반
|
## NPC·국방 정책 버전 저장 기반
|
||||||
|
|
||||||
`PlayAuditPolicy`는 현재 기수/국가/영역별 불변 revision과 이전 버전 ID를 보존한다.
|
`PlayAuditPolicy`는 현재 기수/국가/영역별 불변 revision과 이전 버전 ID를 보존한다.
|
||||||
|
|||||||
Reference in New Issue
Block a user