feat: 외교 문서 변경을 입력 원장과 함께 감사 이력으로 저장
This commit is contained in:
@@ -1166,3 +1166,38 @@ model PlayAuditPolicy {
|
||||
@@index([serverId, nationId, year, month, ordinal])
|
||||
@@map("play_audit_policy")
|
||||
}
|
||||
|
||||
model PlayAuditDiplomacyEvent {
|
||||
id String @id
|
||||
sequence BigInt @unique @default(autoincrement())
|
||||
schemaVersion Int @default(1) @map("schema_version")
|
||||
serverId String @map("server_id")
|
||||
nationA Int @map("nation_a")
|
||||
nationB Int @map("nation_b")
|
||||
srcNationId Int @map("src_nation_id")
|
||||
destNationId Int @map("dest_nation_id")
|
||||
category String
|
||||
source String
|
||||
eventType String @map("event_type")
|
||||
documentId Int? @map("document_id")
|
||||
documentHash String? @map("document_hash")
|
||||
previousDocumentId Int? @map("previous_document_id")
|
||||
year Int
|
||||
month Int
|
||||
tick BigInt?
|
||||
clockRevision BigInt? @map("clock_revision")
|
||||
executionId String @map("execution_id")
|
||||
ordinal Int
|
||||
requestId String? @map("request_id")
|
||||
inputSequence BigInt? @map("input_sequence")
|
||||
actor Json?
|
||||
before Json?
|
||||
after Json?
|
||||
hash String
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
|
||||
@@unique([serverId, executionId, ordinal])
|
||||
@@index([serverId, nationA, nationB, sequence], map: "audit_diplomacy_pair_sequence_idx")
|
||||
@@index([serverId, documentId, sequence])
|
||||
@@map("play_audit_diplomacy_event")
|
||||
}
|
||||
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
CREATE TABLE "play_audit_diplomacy_event" (
|
||||
"id" TEXT PRIMARY KEY,
|
||||
"sequence" BIGSERIAL NOT NULL UNIQUE,
|
||||
"schema_version" INTEGER NOT NULL DEFAULT 1 CHECK ("schema_version" > 0),
|
||||
"server_id" TEXT NOT NULL,
|
||||
"nation_a" INTEGER NOT NULL,
|
||||
"nation_b" INTEGER NOT NULL,
|
||||
"src_nation_id" INTEGER NOT NULL,
|
||||
"dest_nation_id" INTEGER NOT NULL,
|
||||
"category" TEXT NOT NULL CHECK ("category" IN ('DOCUMENT', 'RELATION')),
|
||||
"source" TEXT NOT NULL CHECK ("source" IN ('API', 'ENGINE', 'BASELINE')),
|
||||
"event_type" TEXT NOT NULL,
|
||||
"document_id" INTEGER,
|
||||
"document_hash" TEXT,
|
||||
"previous_document_id" INTEGER,
|
||||
"year" INTEGER NOT NULL,
|
||||
"month" INTEGER NOT NULL CHECK ("month" BETWEEN 1 AND 12),
|
||||
"tick" BIGINT,
|
||||
"clock_revision" BIGINT,
|
||||
"execution_id" TEXT NOT NULL,
|
||||
"ordinal" INTEGER NOT NULL CHECK ("ordinal" > 0),
|
||||
"request_id" TEXT,
|
||||
"input_sequence" BIGINT,
|
||||
"actor" JSONB,
|
||||
"before" JSONB,
|
||||
"after" JSONB,
|
||||
"hash" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CHECK ("nation_a" <= "nation_b")
|
||||
);
|
||||
CREATE UNIQUE INDEX "play_audit_diplomacy_event_server_id_execution_id_ordinal_key"
|
||||
ON "play_audit_diplomacy_event" ("server_id", "execution_id", "ordinal");
|
||||
CREATE INDEX "audit_diplomacy_pair_sequence_idx"
|
||||
ON "play_audit_diplomacy_event" ("server_id", "nation_a", "nation_b", "sequence");
|
||||
CREATE INDEX "play_audit_diplomacy_event_server_id_document_id_sequence_idx"
|
||||
ON "play_audit_diplomacy_event" ("server_id", "document_id", "sequence");
|
||||
|
||||
-- 수정은 새 문서(prev_id)로 만든다. 원문을 한 번만 참조할 수 있도록 기존 작성 내용을 고정한다.
|
||||
CREATE FUNCTION preserve_diplomacy_document_content() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF ROW(NEW.id, NEW.src_nation_id, NEW.dest_nation_id, NEW.prev_id, NEW.text_brief, NEW.text_detail, NEW.src_signer, NEW.date)
|
||||
IS DISTINCT FROM
|
||||
ROW(OLD.id, OLD.src_nation_id, OLD.dest_nation_id, OLD.prev_id, OLD.text_brief, OLD.text_detail, OLD.src_signer, OLD.date)
|
||||
THEN
|
||||
RAISE EXCEPTION 'Diplomacy document content is immutable; create a replacement document';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
CREATE TRIGGER preserve_diplomacy_document_content BEFORE UPDATE ON "diplomacy_letter"
|
||||
FOR EACH ROW EXECUTE FUNCTION preserve_diplomacy_document_content();
|
||||
@@ -23,6 +23,7 @@ export interface DatabaseClient {
|
||||
playAuditNation: GamePrisma.PlayAuditNationDelegate;
|
||||
playAuditCity: GamePrisma.PlayAuditCityDelegate;
|
||||
playAuditGeneral: GamePrisma.PlayAuditGeneralDelegate;
|
||||
playAuditDiplomacyEvent: GamePrisma.PlayAuditDiplomacyEventDelegate;
|
||||
rankData: GamePrisma.RankDataDelegate;
|
||||
hallOfFame: GamePrisma.HallOfFameDelegate;
|
||||
gameHistory: GamePrisma.GameHistoryDelegate;
|
||||
|
||||
@@ -11,5 +11,6 @@ export * from './readModelOutboxDispatcher.js';
|
||||
export * from './readModelCoverageActivation.js';
|
||||
export * from './gameSchemaAdvisoryLock.js';
|
||||
export * from './inputEventClock.js';
|
||||
export * from './playAuditDiplomacy.js';
|
||||
export * from './messageEnvelope.js';
|
||||
export * from './webPushOutbox.js';
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { GamePrisma, type GamePrismaClient } from './gamePrisma.js';
|
||||
|
||||
export interface AuditDiplomacyEventDraft {
|
||||
schemaVersion: 1;
|
||||
serverId: string;
|
||||
srcNationId: number;
|
||||
destNationId: number;
|
||||
category: 'DOCUMENT' | 'RELATION';
|
||||
source: 'API' | 'ENGINE' | 'BASELINE';
|
||||
eventType: string;
|
||||
documentId: number | null;
|
||||
documentHash: string | null;
|
||||
previousDocumentId: number | null;
|
||||
year: number;
|
||||
month: number;
|
||||
tick: bigint | null;
|
||||
clockRevision: bigint | null;
|
||||
executionId: string;
|
||||
ordinal: number;
|
||||
requestId: string | null;
|
||||
inputSequence: bigint | null;
|
||||
actor: Record<string, unknown> | null;
|
||||
before: Record<string, unknown> | null;
|
||||
after: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export const hashAuditDiplomacy = (value: unknown): string =>
|
||||
createHash('sha256')
|
||||
.update(
|
||||
JSON.stringify(value, (_key, item: unknown) => {
|
||||
if (typeof item === 'bigint') return item.toString();
|
||||
if (item && typeof item === 'object' && !Array.isArray(item))
|
||||
return Object.fromEntries(
|
||||
Object.entries(item).sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
||||
);
|
||||
return item;
|
||||
})
|
||||
)
|
||||
.digest('hex');
|
||||
|
||||
/** 본문은 불변 문서 행을 참조한다. event별로 HTML을 복제하지 않는다. */
|
||||
export const hashAuditDiplomacyDocument = (letter: {
|
||||
id: number;
|
||||
srcNationId: number;
|
||||
destNationId: number;
|
||||
prevId: number | null;
|
||||
textBrief: string;
|
||||
textDetail: string;
|
||||
srcSignerId: number;
|
||||
date: Date;
|
||||
}): string =>
|
||||
hashAuditDiplomacy({
|
||||
id: letter.id,
|
||||
srcNationId: letter.srcNationId,
|
||||
destNationId: letter.destNationId,
|
||||
prevId: letter.prevId,
|
||||
textBrief: letter.textBrief,
|
||||
textDetail: letter.textDetail,
|
||||
srcSignerId: letter.srcSignerId,
|
||||
date: letter.date.toISOString(),
|
||||
});
|
||||
|
||||
export const persistAuditDiplomacyEvents = async (
|
||||
db: Pick<GamePrismaClient, 'playAuditDiplomacyEvent'>,
|
||||
events: readonly AuditDiplomacyEventDraft[]
|
||||
): Promise<void> => {
|
||||
const json = (value: Record<string, unknown> | null) =>
|
||||
value === null ? GamePrisma.DbNull : (JSON.parse(JSON.stringify(value)) as GamePrisma.InputJsonValue);
|
||||
for (let offset = 0; offset < events.length; offset += 200) {
|
||||
const batch = events.slice(offset, offset + 200).map((event) => ({
|
||||
...event,
|
||||
id: hashAuditDiplomacy([event.serverId, event.executionId, event.ordinal]),
|
||||
nationA: Math.min(event.srcNationId, event.destNationId),
|
||||
nationB: Math.max(event.srcNationId, event.destNationId),
|
||||
actor: json(event.actor),
|
||||
before: json(event.before),
|
||||
after: json(event.after),
|
||||
hash: hashAuditDiplomacy(event),
|
||||
}));
|
||||
await db.playAuditDiplomacyEvent.createMany({ data: batch, skipDuplicates: true });
|
||||
const saved = await db.playAuditDiplomacyEvent.findMany({
|
||||
where: { id: { in: batch.map((event) => event.id) } },
|
||||
select: { id: true, hash: true },
|
||||
});
|
||||
const hashes = new Map(saved.map((row) => [row.id, row.hash]));
|
||||
if (batch.some((event) => hashes.get(event.id) !== event.hash))
|
||||
throw new Error('Play audit diplomacy replay payload conflict');
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user