perf: 실시간 접속 확인에서 불필요한 context 조회를 제거

This commit is contained in:
2026-08-16 17:53:25 +00:00
parent dd2ae20167
commit aa51f1b7d9
8 changed files with 148 additions and 78 deletions
+2
View File
@@ -85,6 +85,8 @@ export type DatabaseClient = InfraDatabaseClient;
export interface GameApiContext {
requestId?: string;
generalAccessTracking?: boolean;
/** Request-local identity already resolved by the realtime access gate. */
realtimeAccessGeneralId?: number;
db: DatabaseClient;
redis: RedisConnector['client'];
turnDaemon: TurnDaemonTransport;
+29 -22
View File
@@ -9,26 +9,21 @@ import { getTurnCommandTable } from '../turns/index.js';
const zRevision = z.string().regex(/^[A-Za-z0-9_-]{22}$/u);
const zContextBundleInput = z
.object({
include: z.object({
context: z.boolean(),
commandTable: z.boolean(),
boardAccess: z.boolean(),
}),
known: z
.object({
context: zRevision.optional(),
commandTable: zRevision.optional(),
boardAccess: zRevision.optional(),
})
.optional(),
forceSnapshot: z.boolean().optional(),
})
.refine((input) => Object.values(input.include).some(Boolean), {
message: 'At least one dashboard context slice must be requested.',
path: ['include'],
});
const zContextBundleInput = z.object({
include: z.object({
context: z.boolean(),
commandTable: z.boolean(),
boardAccess: z.boolean(),
}),
known: z
.object({
context: zRevision.optional(),
commandTable: zRevision.optional(),
boardAccess: zRevision.optional(),
})
.optional(),
forceSnapshot: z.boolean().optional(),
});
export const dashboardRouter = router({
getContextBundleDelta: accessLimitAuthedProcedure.input(zContextBundleInput).query(async ({ ctx, input }) => {
@@ -37,8 +32,20 @@ export const dashboardRouter = router({
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
const currentContext = await getGeneralContext(ctx);
const generalId = currentContext?.general.id ?? null;
const includesProjection = Object.values(input.include).some(Boolean);
const currentContext = input.include.context ? await getGeneralContext(ctx) : undefined;
const generalId =
currentContext?.general.id ??
ctx.realtimeAccessGeneralId ??
(includesProjection
? (
await ctx.db.general.findFirst({
where: { userId: viewerId },
orderBy: { id: 'asc' },
select: { id: true },
})
)?.id ?? null
: null);
const [commandTable, boardAccess] = await Promise.all([
input.include.commandTable && generalId ? getTurnCommandTable(ctx, generalId) : Promise.resolve(undefined),
input.include.boardAccess && generalId ? getBoardAccess(ctx) : Promise.resolve(undefined),
@@ -94,6 +94,7 @@ export const generalAccessLimitEndpoints = new Set<GeneralAccessEndpoint>([
export const generalAccessLimitBeforeRecordEndpoints = new Set<GeneralAccessEndpoint>(['general.getFrontStatus']);
export type GeneralAccessState = {
generalId: number;
refreshScore: number;
refreshLimit: number;
level: AccessLimitLevel;
@@ -194,6 +195,7 @@ export const getGeneralAccessState = async (
: (access?.refreshScore ?? 0);
const refreshLimit = resolveAccessRefreshLimit(worldState.tickSeconds, asRecord(worldState.meta).refreshLimit);
return {
generalId: general.id,
refreshScore,
refreshLimit,
level: resolveAccessLimitLevel(refreshScore, refreshLimit),
+6 -1
View File
@@ -126,7 +126,12 @@ const generalAccessLimitMiddleware = t.middleware(async ({ ctx, next }) => {
message: formatGeneralAccessLimitMessage(state),
});
}
return next();
return next({
ctx: {
...ctx,
...(state ? { realtimeAccessGeneralId: state.generalId } : {}),
},
});
});
export const router = t.router;
+57 -40
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { applyReadModelDelta } from '@sammo-ts/common';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
@@ -21,12 +21,47 @@ const auth: GameSessionTokenPayload = {
sanctions: {},
};
const buildContext = (authenticated: boolean) => {
const buildContext = (authenticated: boolean, generalAccessTracking = false) => {
let generalName = '초기 장수';
const redisValues = new Map<string, string>();
const findGeneral = vi.fn(async () => ({
id: 7,
name: generalName,
npcState: 0,
nationId: 0,
cityId: 0,
troopId: 0,
picture: null,
imageServer: 0,
leadership: 70,
strength: 60,
intel: 50,
officerLevel: 0,
gold: 1_000,
rice: 2_000,
crew: 300,
train: 80,
atmos: 90,
injury: 0,
experience: 100,
dedication: 200,
age: 20,
turnTime: new Date('2026-08-11T00:10:00.000Z'),
crewTypeId: 0,
personalCode: 'None',
specialCode: 'None',
special2Code: 'None',
weaponCode: 'None',
horseCode: 'None',
bookCode: 'None',
itemCode: 'None',
meta: {},
penalty: {},
}));
const context = {
auth: authenticated ? auth : null,
profile: { id: 'hwe', scenario: 'default', name: 'hwe:default' },
generalAccessTracking,
redis: {
get: async (key: string) => redisValues.get(key) ?? null,
set: async (key: string, value: string) => {
@@ -36,45 +71,20 @@ const buildContext = (authenticated: boolean) => {
},
db: {
general: {
findFirst: async () => ({
id: 7,
name: generalName,
npcState: 0,
nationId: 0,
cityId: 0,
troopId: 0,
picture: null,
imageServer: 0,
leadership: 70,
strength: 60,
intel: 50,
officerLevel: 0,
gold: 1_000,
rice: 2_000,
crew: 300,
train: 80,
atmos: 90,
injury: 0,
experience: 100,
dedication: 200,
age: 20,
turnTime: new Date('2026-08-11T00:00:00.000Z'),
crewTypeId: 0,
personalCode: 'None',
specialCode: 'None',
special2Code: 'None',
weaponCode: 'None',
horseCode: 'None',
bookCode: 'None',
itemCode: 'None',
meta: {},
penalty: {},
}),
findFirst: findGeneral,
},
city: { findUnique: async () => null },
nation: { findUnique: async () => null },
generalAccessLog: { findUnique: async () => null },
worldState: { findFirst: async () => ({ config: { const: {} } }) },
worldState: {
findFirst: async () => ({
currentYear: 185,
currentMonth: 1,
tickSeconds: 600,
config: { const: {} },
meta: { lastTurnTime: '2026-08-11T00:00:00.000Z' },
}),
},
},
} as unknown as GameApiContext;
@@ -83,6 +93,7 @@ const buildContext = (authenticated: boolean) => {
rename: (name: string) => {
generalName = name;
},
findGeneral,
};
};
@@ -128,12 +139,18 @@ describe('dashboardRouter.getContextBundleDelta', () => {
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
});
it('rejects an empty bundle request', async () => {
const fixture = buildContext(true);
it('uses an all-false bundle as an access-only gate without projecting dashboard context', async () => {
const fixture = buildContext(true, true);
await expect(
dashboardRouter.createCaller(fixture.context).getContextBundleDelta({
include: { context: false, commandTable: false, boardAccess: false },
})
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
).resolves.toEqual({ context: undefined, commandTable: undefined, boardAccess: undefined });
expect(fixture.findGeneral).toHaveBeenCalledTimes(1);
expect(fixture.findGeneral).toHaveBeenCalledWith({
where: { userId: auth.user.id },
orderBy: { id: 'asc' },
select: { id: true, turnTime: true },
});
});
});