feat: 프로필 감사 화면에서 정책 버전과 전후 값을 조회

This commit is contained in:
2026-09-16 05:02:32 +00:00
parent d549cbf887
commit 3d60e6122d
8 changed files with 845 additions and 11 deletions
@@ -1,6 +1,7 @@
import { nationSeries, zAuditNation } from './nationSeries.js';
import { cityDetail, generalDetail, generalTurns } from './details.js';
import { generalLogs } from './logs.js';
import { policyHistory, policyVersion } from './policies.js';
import { z } from 'zod';
import { canReadPlayAuditAccounts } from '@sammo-ts/common';
import { router } from '../../trpc.js';
@@ -23,6 +24,8 @@ import {
} from './projection.js';
export const playAuditRouter = router({
policyHistory,
policyVersion,
generalLogs,
cityDetail,
generalDetail,
@@ -0,0 +1,159 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { asRecord } from '@sammo-ts/common';
import type { GamePrisma } from '@sammo-ts/infra';
import { auditProcedure, monthOrdinal, readAudit, readAuditWorld, zAuditMonth } from './shared.js';
const zArea = z.enum(['NPC_VALUES', 'NPC_NATION_PRIORITY', 'NPC_GENERAL_PRIORITY', 'DEFENCE']);
const zMonth = zAuditMonth.omit({ kind: true });
const zActor = z
.object({
generalId: z.number().int(),
name: z.string(),
nationId: z.number().int(),
officerLevel: z.number().int(),
npcState: z.number().int(),
})
.nullable();
const summarySelect = {
id: true,
schemaVersion: true,
nationId: true,
area: true,
revision: true,
previousId: true,
source: true,
year: true,
month: true,
actor: true,
createdAt: true,
} satisfies GamePrisma.PlayAuditPolicySelect;
type Summary = GamePrisma.PlayAuditPolicyGetPayload<{ select: typeof summarySelect }>;
/** 향후 자국 권한 API의 공개 정보 경계. 실제 자국 인가는 호출부에서 별도로 수행한다. */
export const projectPolicySummary = (row: Summary) => {
if (row.schemaVersion !== 1)
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: '지원하지 않는 정책 기록 버전입니다.' });
return {
id: row.id,
nationId: row.nationId,
area: zArea.parse(row.area),
revision: row.revision,
previousId: row.previousId,
source: z.enum(['BASELINE', 'CHANGE', 'OBSERVED_GAP']).parse(row.source),
year: row.year,
month: row.month,
actor: zActor.parse(row.actor),
createdAt: row.createdAt,
};
};
/** 계정·요청·내부 진단 값을 제외한 정책 전후 projection이다. */
export const projectPolicyConfiguration = (
row: Summary & { before: GamePrisma.JsonValue; after: GamePrisma.JsonValue }
) => {
const before = row.before === null ? null : asRecord(row.before);
const after = asRecord(row.after);
return {
...projectPolicySummary(row),
fields: [...new Set([...Object.keys(before ?? {}), ...Object.keys(after)])].sort().map((key) => ({
key,
beforeJson: before === null ? null : JSON.stringify(before[key] ?? null),
afterJson: JSON.stringify(after[key] ?? null),
changed: before !== null && JSON.stringify(before[key] ?? null) !== JSON.stringify(after[key] ?? null),
})),
};
};
export const policyHistory = auditProcedure
.input(
z
.object({
nationId: z.number().int().nonnegative(),
area: zArea,
from: zMonth,
to: zMonth,
cursor: z.number().int().positive().optional(),
limit: z.number().int().min(1).max(200).default(50),
})
.strict()
)
.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.playAuditPolicy.findMany({
where: {
serverId: world.serverId,
nationId: input.nationId,
area: input.area,
revision: input.cursor === undefined ? undefined : { lt: 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 } },
],
},
],
},
orderBy: { revision: 'desc' },
take: input.limit + 1,
select: summarySelect,
})
: [];
return {
...world,
coverage: world.serverId ? ('RECORDED_VERSIONS_ONLY' as const) : ('IDENTITY_MISSING' as const),
items: rows.slice(0, input.limit).map(projectPolicySummary),
nextCursor: rows.length > input.limit ? rows[input.limit - 1]!.revision : null,
};
})
);
export const policyVersion = auditProcedure
.input(z.object({ id: z.string().regex(/^[a-f0-9]{64}$/) }).strict())
.query(({ ctx, input }) =>
readAudit(ctx, async (tx) => {
const world = await readAuditWorld(tx);
const row = world.serverId
? await tx.playAuditPolicy.findFirst({
where: { id: input.id, serverId: world.serverId },
select: {
...summarySelect,
tick: true,
ordinal: true,
before: true,
after: true,
requestId: true,
inputSequence: true,
},
})
: null;
if (!row) throw new TRPCError({ code: 'NOT_FOUND', message: '현재 기수의 정책 버전을 찾을 수 없습니다.' });
return {
...world,
version: {
...projectPolicyConfiguration(row),
tick: row.tick?.toString() ?? null,
ordinal: row.ordinal,
requestId: row.requestId,
inputSequence: row.inputSequence?.toString() ?? null,
},
};
})
);
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest';
import { projectPolicyConfiguration } from '../src/router/playAudit/policies.js';
const row = {
id: 'policy',
schemaVersion: 1,
nationId: 2,
area: 'DEFENCE',
revision: 2,
previousId: 'baseline',
source: 'CHANGE',
year: 190,
month: 2,
tick: 99,
ordinal: 18,
actor: {
userId: 'private-account',
generalId: 3,
name: '당시군주',
nationId: 2,
officerLevel: 12,
npcState: 0,
permission: 4,
debug: 'internal',
},
createdAt: new Date('2026-09-16T00:00:00Z'),
before: { scout: 0 },
after: { scout: 1 },
requestId: 'internal-request',
inputSequence: 123n,
};
describe('policy public configuration boundary', () => {
it('retains historical office and values while excluding account and admin diagnostics', () => {
const projection = projectPolicyConfiguration(row);
expect(projection).toMatchObject({
nationId: 2,
revision: 2,
actor: { generalId: 3, officerLevel: 12 },
fields: [{ key: 'scout', beforeJson: '0', afterJson: '1', changed: true }],
});
const serialized = JSON.stringify(projection);
for (const excluded of [
'private-account',
'permission',
'debug',
'internal-request',
'inputSequence',
'tick',
'ordinal',
])
expect(serialized).not.toContain(excluded);
});
it('refuses an unsupported record schema rather than guessing its meaning', () => {
expect(() => projectPolicyConfiguration({ ...row, schemaVersion: 2 })).toThrow('지원하지 않는 정책 기록 버전');
});
it('distinguishes unobserved baseline from a recorded inherited setting', () => {
expect(
projectPolicyConfiguration({
...row,
source: 'BASELINE',
actor: null,
before: null,
after: { scout: null },
}).fields
).toEqual([{ key: 'scout', beforeJson: null, afterJson: 'null', changed: false }]);
});
});
@@ -1,3 +1,4 @@
import { createHash } from 'node:crypto';
import { projectCurrentGeneral } from '../src/router/playAudit/projection.js';
import { asRecord } from '@sammo-ts/common';
import fs from 'node:fs/promises';
@@ -2154,6 +2155,8 @@ integration('game API security over HTTP transport', () => {
const auditUserId = `audit-http-${process.pid}`;
const seasonId = `audit-season-${process.pid}`;
const sampleId = `${seasonId}:190:1`;
const policyId = (revision: number) =>
createHash('sha256').update(`${seasonId}:policy:${revision}`).digest('hex');
const originalWorld = await db.worldState.findUniqueOrThrow({ where: { id: fixtureWorldId } });
const token = async (roles: string[], sanctions: GameSessionTokenPayload['sanctions'] = {}) => {
const payload = buildPayload(`audit-${roles.join('-')}`, sanctions, auditUserId);
@@ -2238,6 +2241,100 @@ integration('game API security over HTTP transport', () => {
});
const admin = await token([`admin.playAudit.read:${profileName}`]);
const beforeInputs = await db.inputEvent.count();
const policyInput = {
nationId: 99128,
area: 'DEFENCE',
from: { year: 190, month: 1 },
to: { year: 190, month: 2 },
};
await db.playAuditPolicy.createMany({
data: [1, 2, 3].map((revision) => ({
id: policyId(revision),
serverId: seasonId,
nationId: 99128,
area: 'DEFENCE',
revision,
previousId: revision > 1 ? policyId(revision - 1) : null,
source: revision === 1 ? 'BASELINE' : 'CHANGE',
year: 190,
month: revision === 3 ? 2 : 1,
ordinal: revision,
tick: 12,
requestId: revision > 1 ? 'audit-policy-request' : null,
inputSequence: revision > 1 ? 9007199254740993n : null,
actor:
revision > 1
? {
userId: 'hidden-account',
generalId,
name: '당시군주',
nationId: 99128,
officerLevel: 12,
npcState: 0,
permission: 3,
extra: 'hidden-extra',
}
: GamePrisma.DbNull,
before: revision === 1 ? GamePrisma.DbNull : { scout: revision - 1 },
after: { scout: revision },
hash: 'fixture',
})),
});
const policyPage = await get('policyHistory', admin, { ...policyInput, limit: 1 });
expect(policyPage.status).toBe(200);
expect(policyPage.body).toMatchObject({
result: {
data: {
nextCursor: 3,
items: [{ revision: 3, source: 'CHANGE', actor: { name: '당시군주', officerLevel: 12 } }],
},
},
});
for (const excluded of ['hidden-account', 'hidden-extra', 'before', 'after', 'inputSequence', 'requestId'])
expect(JSON.stringify(policyPage.body)).not.toContain(excluded);
expect((await get('policyHistory', admin, { ...policyInput, cursor: 3 })).body).toMatchObject({
result: { data: { nextCursor: null, items: [{ revision: 2 }, { revision: 1, actor: null }] } },
});
expect(
(await get('policyHistory', admin, { ...policyInput, to: { year: 190, month: 1 } })).body
).toMatchObject({ result: { data: { items: [{ revision: 2 }, { revision: 1 }] } } });
const policyDetail = await get('policyVersion', admin, { id: policyId(3) });
expect(policyDetail.status).toBe(200);
expect(policyDetail.body).toMatchObject({
result: {
data: {
version: {
previousId: policyId(2),
inputSequence: '9007199254740993',
fields: [{ key: 'scout', beforeJson: '2', afterJson: '3', changed: true }],
},
},
},
});
expect(JSON.stringify(policyDetail.body)).not.toContain('hidden-account');
expect((await get('policyVersion', admin, { id: policyId(1) })).body).toMatchObject({
result: { data: { version: { fields: [{ beforeJson: null, changed: false }] } } },
});
expect((await get('policyVersion', admin, { id: policyId(99) })).status).toBe(404);
for (const patch of [
{ limit: 201 },
{ cursor: 0 },
{ area: 'ANY' },
{ from: { year: 189, month: 12 } },
{ to: { year: 191, month: 1 } },
{ from: { year: 190, month: 2 }, to: { year: 190, month: 1 } },
])
expect((await get('policyHistory', admin, { ...policyInput, ...patch })).status).toBe(400);
expect((await get('policyVersion', admin, { id: '../bad' })).status).toBe(400);
for (const [operation, input] of [
['policyHistory', policyInput],
['policyVersion', { id: policyId(3) }],
] 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 logGeneralId = 99129; // No live general: death must not hide retained records.
const ownLogs = await Promise.all(
['HISTORY', 'ACTION', 'BATTLE_BRIEF', 'BATTLE_DETAIL'].map((category) =>
@@ -2398,6 +2495,8 @@ integration('game API security over HTTP transport', () => {
serverRestrictions: { [profileName]: { blockedFeatures: ['gameplay'] } },
});
expect((await get('capabilities', blocked)).status).toBe(403);
expect((await get('policyHistory', blocked, policyInput)).status).toBe(403);
expect((await get('policyVersion', blocked, { id: policyId(3) })).status).toBe(403);
const population = {
count: 0,
gold: 0,
@@ -2647,6 +2746,10 @@ integration('game API security over HTTP transport', () => {
expect((await get('generals', admin, { at: { year: 190, month: 1 } })).body).toMatchObject({
result: { data: { collected: false, items: [] } },
});
expect((await get('policyHistory', admin, policyInput)).body).toMatchObject({
result: { data: { items: [] } },
});
expect((await get('policyVersion', admin, { id: policyId(3) })).status).toBe(404);
expect(await db.inputEvent.count()).toBe(beforeInputs);
await redis!.client.publish(
`${redisPrefix}:flush`,
@@ -2658,6 +2761,7 @@ integration('game API security over HTTP transport', () => {
);
await expect.poll(async () => (await get('capabilities', admin)).status).toBe(401);
} finally {
await db.playAuditPolicy.deleteMany({ where: { serverId: seasonId } });
await db.logEntry.deleteMany({ where: { text: { startsWith: `${seasonId}:` } } });
await db.playAuditMonth.deleteMany({ where: { serverId: seasonId } });
await db.generalTurn.deleteMany({ where: { generalId, turnIdx: { in: [9001, 9002] } } });