feat: 프로필 감사 화면에서 정책 버전과 전후 값을 조회
This commit is contained in:
@@ -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] } } });
|
||||
|
||||
Reference in New Issue
Block a user