feat: 프로필별 플레이 감사 조회와 권한 검사 연결
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
import { z } from 'zod';
|
||||
import { canReadPlayAuditAccounts } from '@sammo-ts/common';
|
||||
import { router } from '../../trpc.js';
|
||||
import {
|
||||
auditProcedure,
|
||||
findAuditMonth,
|
||||
pageResult,
|
||||
readAudit,
|
||||
readAuditWorld,
|
||||
zAuditPage,
|
||||
zAuditMonth,
|
||||
} from './shared.js';
|
||||
import {
|
||||
citySelect,
|
||||
generalSelect,
|
||||
projectCurrentCity,
|
||||
projectCurrentGeneral,
|
||||
zAuditCityData,
|
||||
zAuditGeneralData,
|
||||
} from './projection.js';
|
||||
|
||||
export const playAuditRouter = router({
|
||||
capabilities: auditProcedure.query(({ ctx }) => ({
|
||||
profileName: ctx.profile.name,
|
||||
read: true,
|
||||
accounts: canReadPlayAuditAccounts(ctx.auth!.user.roles, ctx.profile.name),
|
||||
})),
|
||||
coverage: auditProcedure
|
||||
.input(
|
||||
z
|
||||
.object({ cursor: zAuditMonth.optional(), limit: z.number().int().min(1).max(200).default(50) })
|
||||
.strict()
|
||||
.default({ limit: 50 })
|
||||
)
|
||||
.query(({ ctx, input }) =>
|
||||
readAudit(ctx, async (tx) => {
|
||||
const world = await readAuditWorld(tx);
|
||||
const samples = world.serverId
|
||||
? await tx.playAuditMonth.findMany({
|
||||
where: {
|
||||
serverId: world.serverId,
|
||||
...(input.cursor
|
||||
? {
|
||||
OR: [
|
||||
{ year: { gt: input.cursor.year } },
|
||||
{ year: input.cursor.year, month: { gt: input.cursor.month } },
|
||||
{
|
||||
year: input.cursor.year,
|
||||
month: input.cursor.month,
|
||||
kind: { gt: input.cursor.kind },
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
take: input.limit + 1,
|
||||
orderBy: [{ year: 'asc' }, { month: 'asc' }, { kind: 'asc' }],
|
||||
select: { year: true, month: true, kind: true, settlementsComplete: true, createdAt: true },
|
||||
})
|
||||
: [];
|
||||
return {
|
||||
...world,
|
||||
status: !world.serverId
|
||||
? ('IDENTITY_MISSING' as const)
|
||||
: samples.length
|
||||
? ('COLLECTED' as const)
|
||||
: input.cursor
|
||||
? ('PAGE_EMPTY' as const)
|
||||
: ('NOT_COLLECTED' as const),
|
||||
samples: samples.slice(0, input.limit),
|
||||
nextCursor:
|
||||
samples.length > input.limit
|
||||
? {
|
||||
year: samples[input.limit - 1]!.year,
|
||||
month: samples[input.limit - 1]!.month,
|
||||
kind: z.enum(['MONTH_END', 'FINAL']).parse(samples[input.limit - 1]!.kind),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
})
|
||||
),
|
||||
generals: auditProcedure
|
||||
.input(
|
||||
zAuditPage.extend({
|
||||
cityId: z.number().int().nonnegative().optional(),
|
||||
population: z.enum(['human', 'npc', 'troopNpc']).optional(),
|
||||
})
|
||||
)
|
||||
.query(({ ctx, input }) =>
|
||||
readAudit(ctx, async (tx) => {
|
||||
const world = await readAuditWorld(tx);
|
||||
const npcState =
|
||||
input.population === 'human'
|
||||
? { lt: 2 }
|
||||
: input.population === 'npc'
|
||||
? { gte: 2, not: 5 }
|
||||
: input.population === 'troopNpc'
|
||||
? 5
|
||||
: undefined;
|
||||
const filter = { nationId: input.nationId, cityId: input.cityId, npcState };
|
||||
if (input.at) {
|
||||
const sample = await findAuditMonth(tx, world, input.at);
|
||||
const rows = sample
|
||||
? await tx.playAuditGeneral.findMany({
|
||||
where: {
|
||||
sampleId: sample.id,
|
||||
...filter,
|
||||
generalId: input.cursor === undefined ? undefined : { gt: input.cursor },
|
||||
},
|
||||
orderBy: { generalId: 'asc' },
|
||||
take: input.limit + 1,
|
||||
select: { data: true },
|
||||
})
|
||||
: [];
|
||||
return {
|
||||
...world,
|
||||
sample,
|
||||
collected: Boolean(sample),
|
||||
...pageResult(
|
||||
rows.map((row) => zAuditGeneralData.parse(row.data)),
|
||||
input.limit,
|
||||
(row) => row.id
|
||||
),
|
||||
};
|
||||
}
|
||||
const rows = await tx.general.findMany({
|
||||
where: { ...filter, id: input.cursor === undefined ? undefined : { gt: input.cursor } },
|
||||
orderBy: { id: 'asc' },
|
||||
take: input.limit + 1,
|
||||
select: generalSelect,
|
||||
});
|
||||
return {
|
||||
...world,
|
||||
sample: null,
|
||||
collected: true,
|
||||
...pageResult(rows.map(projectCurrentGeneral), input.limit, (row) => row.id),
|
||||
};
|
||||
})
|
||||
),
|
||||
cities: auditProcedure.input(zAuditPage).query(({ ctx, input }) =>
|
||||
readAudit(ctx, async (tx) => {
|
||||
const world = await readAuditWorld(tx);
|
||||
if (input.at) {
|
||||
const sample = await findAuditMonth(tx, world, input.at);
|
||||
const rows = sample
|
||||
? await tx.playAuditCity.findMany({
|
||||
where: {
|
||||
sampleId: sample.id,
|
||||
nationId: input.nationId,
|
||||
cityId: input.cursor === undefined ? undefined : { gt: input.cursor },
|
||||
},
|
||||
orderBy: { cityId: 'asc' },
|
||||
take: input.limit + 1,
|
||||
select: { data: true },
|
||||
})
|
||||
: [];
|
||||
return {
|
||||
...world,
|
||||
sample,
|
||||
collected: Boolean(sample),
|
||||
...pageResult(
|
||||
rows.map((row) => zAuditCityData.parse(row.data)),
|
||||
input.limit,
|
||||
(row) => row.id
|
||||
),
|
||||
};
|
||||
}
|
||||
const rows = await tx.city.findMany({
|
||||
where: { nationId: input.nationId, id: input.cursor === undefined ? undefined : { gt: input.cursor } },
|
||||
orderBy: { id: 'asc' },
|
||||
take: input.limit + 1,
|
||||
select: citySelect,
|
||||
});
|
||||
return {
|
||||
...world,
|
||||
sample: null,
|
||||
collected: true,
|
||||
...pageResult(rows.map(projectCurrentCity), input.limit, (row) => row.id),
|
||||
};
|
||||
})
|
||||
),
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import { z } from 'zod';
|
||||
import { asNumber, asRecord } from '@sammo-ts/common';
|
||||
import type { GamePrisma } from '@sammo-ts/infra';
|
||||
|
||||
const dex = z.object({ dex1: z.number(), dex2: z.number(), dex3: z.number(), dex4: z.number(), dex5: z.number() });
|
||||
export const zAuditGeneralData = z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
userId: z.string().nullable(),
|
||||
nationId: z.number(),
|
||||
cityId: z.number(),
|
||||
troopId: z.number(),
|
||||
npcState: z.number(),
|
||||
gold: z.number(),
|
||||
rice: z.number(),
|
||||
stats: z.object({ leadership: z.number(), strength: z.number(), intelligence: z.number() }),
|
||||
experience: z.number(),
|
||||
dedication: z.number(),
|
||||
officerLevel: z.number(),
|
||||
injury: z.number(),
|
||||
age: z.number(),
|
||||
crew: z.number(),
|
||||
crewTypeId: z.number(),
|
||||
train: z.number(),
|
||||
atmos: z.number(),
|
||||
dex,
|
||||
role: z.object({
|
||||
personality: z.string().nullable(),
|
||||
specialDomestic: z.string().nullable(),
|
||||
specialWar: z.string().nullable(),
|
||||
items: z.object({
|
||||
horse: z.string().nullable(),
|
||||
weapon: z.string().nullable(),
|
||||
book: z.string().nullable(),
|
||||
item: z.string().nullable(),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
export const zAuditCityData = z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
nationId: z.number(),
|
||||
level: z.number(),
|
||||
state: z.number(),
|
||||
population: z.number(),
|
||||
populationMax: z.number(),
|
||||
agriculture: z.number(),
|
||||
agricultureMax: z.number(),
|
||||
commerce: z.number(),
|
||||
commerceMax: z.number(),
|
||||
security: z.number(),
|
||||
securityMax: z.number(),
|
||||
wall: z.number(),
|
||||
wallMax: z.number(),
|
||||
defence: z.number(),
|
||||
defenceMax: z.number(),
|
||||
supplyState: z.number(),
|
||||
frontState: z.number(),
|
||||
trust: z.number(),
|
||||
});
|
||||
|
||||
export const generalSelect = {
|
||||
id: true,
|
||||
name: true,
|
||||
userId: true,
|
||||
nationId: true,
|
||||
cityId: true,
|
||||
troopId: true,
|
||||
npcState: true,
|
||||
gold: true,
|
||||
rice: true,
|
||||
leadership: true,
|
||||
strength: true,
|
||||
intel: true,
|
||||
experience: true,
|
||||
dedication: true,
|
||||
officerLevel: true,
|
||||
injury: true,
|
||||
age: true,
|
||||
crew: true,
|
||||
crewTypeId: true,
|
||||
train: true,
|
||||
atmos: true,
|
||||
personalCode: true,
|
||||
specialCode: true,
|
||||
special2Code: true,
|
||||
horseCode: true,
|
||||
weaponCode: true,
|
||||
bookCode: true,
|
||||
itemCode: true,
|
||||
meta: true,
|
||||
} satisfies GamePrisma.GeneralSelect;
|
||||
export const citySelect = {
|
||||
id: true,
|
||||
name: true,
|
||||
nationId: true,
|
||||
level: true,
|
||||
population: true,
|
||||
populationMax: true,
|
||||
agriculture: true,
|
||||
agricultureMax: true,
|
||||
commerce: true,
|
||||
commerceMax: true,
|
||||
security: true,
|
||||
securityMax: true,
|
||||
wall: true,
|
||||
wallMax: true,
|
||||
defence: true,
|
||||
defenceMax: true,
|
||||
supplyState: true,
|
||||
frontState: true,
|
||||
trust: true,
|
||||
meta: true,
|
||||
} satisfies GamePrisma.CitySelect;
|
||||
const code = (value: string): string | null => (value === 'None' ? null : value);
|
||||
export const projectCurrentGeneral = (
|
||||
row: GamePrisma.GeneralGetPayload<{ select: typeof generalSelect }>
|
||||
): z.infer<typeof zAuditGeneralData> => {
|
||||
const meta = asRecord(row.meta);
|
||||
return zAuditGeneralData.parse({
|
||||
...row,
|
||||
stats: { leadership: row.leadership, strength: row.strength, intelligence: row.intel },
|
||||
dex: Object.fromEntries(['dex1', 'dex2', 'dex3', 'dex4', 'dex5'].map((key) => [key, asNumber(meta[key], 0)])),
|
||||
role: {
|
||||
personality: code(row.personalCode),
|
||||
specialDomestic: code(row.specialCode),
|
||||
specialWar: code(row.special2Code),
|
||||
items: {
|
||||
horse: code(row.horseCode),
|
||||
weapon: code(row.weaponCode),
|
||||
book: code(row.bookCode),
|
||||
item: code(row.itemCode),
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
export const projectCurrentCity = (
|
||||
row: GamePrisma.CityGetPayload<{ select: typeof citySelect }>
|
||||
): z.infer<typeof zAuditCityData> =>
|
||||
zAuditCityData.parse({ ...row, state: Math.floor(asNumber(asRecord(row.meta).state, 0)) });
|
||||
@@ -0,0 +1,124 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import { asRecord, canReadPlayAudit } from '@sammo-ts/common';
|
||||
import type { GamePrisma } from '@sammo-ts/infra';
|
||||
import type { GameApiContext } from '../../context.js';
|
||||
import { readOnlyAuthedProcedure } from '../../trpc.js';
|
||||
|
||||
export const auditProcedure = readOnlyAuthedProcedure.use(({ ctx, next }) => {
|
||||
if (
|
||||
!ctx.auth ||
|
||||
ctx.auth.profile !== ctx.profile.name ||
|
||||
!canReadPlayAudit(ctx.auth.user.roles, ctx.profile.name)
|
||||
) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: '이 프로필의 플레이 감사 권한이 필요합니다.' });
|
||||
}
|
||||
return next();
|
||||
});
|
||||
|
||||
export const zAuditMonth = z
|
||||
.object({
|
||||
year: z.number().int().min(0).max(9999),
|
||||
month: z.number().int().min(1).max(12),
|
||||
kind: z.enum(['MONTH_END', 'FINAL']).default('MONTH_END'),
|
||||
})
|
||||
.strict();
|
||||
export const zAuditPage = z
|
||||
.object({
|
||||
at: zAuditMonth.optional(),
|
||||
nationId: z.number().int().nonnegative().optional(),
|
||||
cursor: z.number().int().nonnegative().optional(),
|
||||
limit: z.number().int().min(1).max(200).default(50),
|
||||
})
|
||||
.strict();
|
||||
export const monthOrdinal = (year: number, month: number): number => year * 12 + month - 1;
|
||||
|
||||
export const readAudit = async <T>(
|
||||
ctx: GameApiContext,
|
||||
read: (tx: GamePrisma.TransactionClient) => Promise<T>
|
||||
): Promise<T> => {
|
||||
if (!ctx.db.$transaction)
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: '감사 조회 transaction을 사용할 수 없습니다.' });
|
||||
try {
|
||||
return await ctx.db.$transaction(read, { isolationLevel: 'RepeatableRead', maxWait: 2000, timeout: 5000 });
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) throw error;
|
||||
if (error && typeof error === 'object' && 'code' in error && ['P2028', 'P2034'].includes(String(error.code))) {
|
||||
throw new TRPCError({ code: 'TIMEOUT', message: '조회가 지연되었습니다. 기간을 줄여 다시 조회해 주세요.' });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const readAuditWorld = async (tx: GamePrisma.TransactionClient) => {
|
||||
const world = await tx.worldState.findFirst({
|
||||
orderBy: { id: 'asc' },
|
||||
select: {
|
||||
currentYear: true,
|
||||
currentMonth: true,
|
||||
lastTurnTick: true,
|
||||
meta: true,
|
||||
config: true,
|
||||
},
|
||||
});
|
||||
if (!world) throw new TRPCError({ code: 'NOT_FOUND', message: '게임 상태가 없습니다.' });
|
||||
const meta = asRecord(world.meta);
|
||||
const serverId = typeof meta.serverId === 'string' && meta.serverId.trim() ? meta.serverId : null;
|
||||
const scenario = asRecord(meta.scenarioMeta);
|
||||
const config = asRecord(world.config);
|
||||
const startYear =
|
||||
typeof scenario.startYear === 'number'
|
||||
? scenario.startYear
|
||||
: typeof asRecord(config.scenarioMeta).startYear === 'number'
|
||||
? Number(asRecord(config.scenarioMeta).startYear)
|
||||
: world.currentYear;
|
||||
return {
|
||||
serverId,
|
||||
year: world.currentYear,
|
||||
month: world.currentMonth,
|
||||
startYear,
|
||||
tick: world.lastTurnTick?.toString() ?? null,
|
||||
asOf: new Date().toISOString(),
|
||||
};
|
||||
};
|
||||
export type AuditWorld = Awaited<ReturnType<typeof readAuditWorld>>;
|
||||
|
||||
export const findAuditMonth = async (
|
||||
tx: GamePrisma.TransactionClient,
|
||||
world: AuditWorld,
|
||||
at: z.infer<typeof zAuditMonth>
|
||||
) => {
|
||||
const ordinal = monthOrdinal(at.year, at.month);
|
||||
if (at.year < world.startYear || ordinal > monthOrdinal(world.year, world.month)) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '현재 기수의 게임 연월을 선택해 주세요.' });
|
||||
}
|
||||
if (!world.serverId) return null;
|
||||
return tx.playAuditMonth.findUnique({
|
||||
where: {
|
||||
serverId_year_month_kind: {
|
||||
serverId: world.serverId,
|
||||
year: at.year,
|
||||
month: at.month,
|
||||
kind: at.kind,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
year: true,
|
||||
month: true,
|
||||
kind: true,
|
||||
tick: true,
|
||||
settlementsComplete: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const pageResult = <T>(
|
||||
rows: T[],
|
||||
limit: number,
|
||||
getId: (row: T) => number
|
||||
): { items: T[]; nextCursor: number | null } => {
|
||||
const items = rows.slice(0, limit);
|
||||
return { items, nextCursor: rows.length > limit ? getId(items[items.length - 1]!) : null };
|
||||
};
|
||||
Reference in New Issue
Block a user