fix: 실제 행동 기준으로 접속 중 국가를 집계

This commit is contained in:
2026-08-19 14:14:12 +00:00
parent daf98722de
commit 830833ebc6
11 changed files with 295 additions and 69 deletions
+3 -2
View File
@@ -810,7 +810,7 @@ export const generalRouter = router({
const [onlineAccess, ownNation, latestVote] = await Promise.all([
ctx.db.generalAccessLog.findMany({
where: {
lastRefresh: {
lastActionAt: {
gte: scoreStartedAt,
},
},
@@ -868,8 +868,9 @@ export const generalRouter = router({
onlineByNation.set(general.nationId, bucket);
}
const onlineNations = [...onlineByNation.entries()]
.filter(([nationId]) => nationId > 0)
.sort((left, right) => right[1].length - left[1].length || left[0] - right[0])
.map(([nationId]) => `${nationId === 0 ? '재야' : (nationNames.get(nationId) ?? `세력 ${nationId}`)}`)
.map(([nationId]) => `${nationNames.get(nationId) ?? `세력 ${nationId}`}`)
.join(', ');
const myOnlineGenerals = onlineGenerals
.filter((general) => general.nationId === me.nationId)
@@ -0,0 +1,44 @@
import { GamePrisma } from '@sammo-ts/infra';
import type { GameApiContext } from '../context.js';
const adminRoles = new Set(['superuser', 'admin', 'admin.superuser']);
/**
* Records a completed, authenticated user mutation without changing the Ref
* refresh counters. Page loads and read-model refreshes never call this path.
*/
export const recordGeneralActivity = async (
ctx: Pick<GameApiContext, 'auth' | 'db'>,
now = new Date()
): Promise<boolean> => {
const user = ctx.auth?.user;
if (!user || user.roles.some((role) => adminRoles.has(role))) {
return false;
}
const written = await ctx.db.$executeRaw(
GamePrisma.sql`
INSERT INTO general_access_log (
general_id,
user_id,
last_action_at
)
SELECT
id,
${user.id},
${now}
FROM "general"
WHERE user_id = ${user.id}
ORDER BY id ASC
LIMIT 1
ON CONFLICT (general_id) DO UPDATE SET
user_id = EXCLUDED.user_id,
last_action_at = GREATEST(
general_access_log.last_action_at,
EXCLUDED.last_action_at
)
`
);
return written > 0;
};
+54 -10
View File
@@ -16,6 +16,7 @@ import {
type GeneralAccessEndpoint,
} from './services/generalAccess.js';
import { getDeferredGeneralAccessLimit } from './services/deferredGeneralAccess.js';
import { recordGeneralActivity } from './services/generalActivity.js';
const t = initTRPC.context<GameApiContext>().create();
@@ -41,6 +42,21 @@ const requireAuthMiddleware = t.middleware(({ ctx, next }) => {
});
});
const generalActivityMiddleware = t.middleware(async ({ ctx, type, next }) => {
const result = await next();
if (type !== 'mutation' || !result.ok || ctx.generalAccessTracking !== true) {
return result;
}
try {
await recordGeneralActivity(ctx);
} catch {
// 활동 표시는 업무 transaction보다 약한 보조 기록이다. 배포 중 schema
// 전환이나 일시 DB 오류가 이미 완료된 사용자 mutation을 실패시키지 않는다.
}
return result;
});
const inputEventMiddleware = t.middleware(async ({ ctx, type, path, next }) => {
if (type !== 'mutation' || !ctx.db.$transaction) {
return next();
@@ -146,7 +162,10 @@ const deferredGeneralAccessLimitMiddleware = t.middleware(async ({ ctx, next })
export const router = t.router;
export const procedure = t.procedure.use(inputEventMiddleware);
export const authedProcedure: typeof procedure = procedure.use(requireAuthMiddleware);
export const authedProcedure: typeof procedure = t.procedure
.use(requireAuthMiddleware)
.use(generalActivityMiddleware)
.use(inputEventMiddleware);
// Ref의 increaseRefresh()는 로그인/제재 확인 뒤, 업무 validation과 mutation
// transaction보다 먼저 별도 저장된다. access middleware를 input-event보다
@@ -154,16 +173,20 @@ export const authedProcedure: typeof procedure = procedure.use(requireAuthMiddle
export const accessAuthedProcedure: typeof procedure = t.procedure
.use(requireAuthMiddleware)
.use(generalAccessEndpointMiddleware)
.use(generalActivityMiddleware)
.use(inputEventMiddleware);
// 턴 데몬이 ENGINE input_event와 world/DB 변경을 자체 transaction으로
// 커밋하는 mutation에 사용한다. API input-event transaction으로 한 번 더
// 감싸면 daemon이 아직 commit되지 않은 command를 볼 수 없어 교착된다.
export const engineAuthedProcedure: typeof procedure = t.procedure.use(requireAuthMiddleware);
export const engineAuthedProcedure: typeof procedure = t.procedure
.use(requireAuthMiddleware)
.use(generalActivityMiddleware);
export const engineProcedure: typeof procedure = t.procedure;
export const accessEngineAuthedProcedure: typeof procedure = t.procedure
.use(requireAuthMiddleware)
.use(generalAccessEndpointMiddleware);
.use(generalAccessEndpointMiddleware)
.use(generalActivityMiddleware);
// 페이지 조회 계측처럼 game state/input-event 원장과 무관한 세션 보조
// mutation에 사용한다. gameplay state 변경에는 사용하지 않는다.
@@ -171,22 +194,43 @@ export const sessionActivityProcedure = t.procedure;
// 시뮬레이터처럼 게임 상태를 변경하지 않는 계산은 input-event transaction과
// 이벤트 원장을 만들지 않는다. 인증은 유지하되 lifecycle DB 경계 밖에서 실행한다.
export const readOnlyAuthedProcedure: typeof procedure = t.procedure.use(requireAuthMiddleware);
export const readOnlyAuthedProcedure: typeof procedure = t.procedure
.use(requireAuthMiddleware)
.use(generalActivityMiddleware);
export const accessLimitAuthedProcedure: typeof procedure = t.procedure
.use(requireAuthMiddleware)
.use(generalAccessLimitMiddleware);
.use(generalAccessLimitMiddleware)
.use(generalActivityMiddleware);
export const deferredAccessLimitAuthedProcedure: typeof procedure = t.procedure
.use(requireAuthMiddleware)
.use(deferredGeneralAccessLimitMiddleware);
.use(deferredGeneralAccessLimitMiddleware)
.use(generalActivityMiddleware);
// 입력이 있는 Ref handler는 request parsing을 마친 뒤 increaseRefresh()를
// 호출한다. 이 factory들은 parser를 access/input-event middleware 앞에 둔다.
export const accessInputProcedure: typeof procedure.input = (input) =>
t.procedure.input(input).use(generalAccessEndpointMiddleware).use(inputEventMiddleware);
export const accessAuthedInputProcedure: typeof procedure.input = (input) =>
t.procedure.use(requireAuthMiddleware).input(input).use(generalAccessEndpointMiddleware).use(inputEventMiddleware);
t.procedure
.use(requireAuthMiddleware)
.input(input)
.use(generalAccessEndpointMiddleware)
.use(generalActivityMiddleware)
.use(inputEventMiddleware);
export const accessEngineAuthedInputProcedure: typeof procedure.input = (input) =>
t.procedure.use(requireAuthMiddleware).input(input).use(generalAccessEndpointMiddleware);
t.procedure
.use(requireAuthMiddleware)
.input(input)
.use(generalAccessEndpointMiddleware)
.use(generalActivityMiddleware);
export const accessReadOnlyAuthedInputProcedure: typeof procedure.input = (input) =>
t.procedure.use(requireAuthMiddleware).input(input).use(generalAccessEndpointMiddleware);
t.procedure
.use(requireAuthMiddleware)
.input(input)
.use(generalAccessEndpointMiddleware)
.use(generalActivityMiddleware);
export const accessLimitAuthedInputProcedure: typeof procedure.input = (input) =>
t.procedure.use(requireAuthMiddleware).input(input).use(generalAccessLimitMiddleware);
t.procedure
.use(requireAuthMiddleware)
.input(input)
.use(generalAccessLimitMiddleware)
.use(generalActivityMiddleware);
@@ -427,9 +427,7 @@ integration('general access tracking persistence', () => {
if (!initialContext?.sourceRevision) {
throw new Error('dashboard snapshot did not include its source revision');
}
await expect(
db.generalAccessLog.findUnique({ where: { generalId: endpointGeneralId } })
).resolves.toBeNull();
await expect(db.generalAccessLog.findUnique({ where: { generalId: endpointGeneralId } })).resolves.toBeNull();
expect(redisEval).toHaveBeenCalledTimes(1);
await flushDeferredGeneralAccessBatch(db, deferredBatchId, [
@@ -482,15 +480,22 @@ integration('general access tracking persistence', () => {
).resolves.toMatchObject({
refresh: 2,
refreshTotal: 2,
lastActionAt: null,
});
await expect(boundaryCaller.general.setMySetting({ accepted: true })).resolves.toEqual({ ok: true });
await expect(
db.generalAccessLog.findUniqueOrThrow({ where: { generalId: endpointGeneralId } })
).resolves.toMatchObject({
const completedActionAccess = await db.generalAccessLog.findUniqueOrThrow({
where: { generalId: endpointGeneralId },
});
expect(completedActionAccess).toMatchObject({
refresh: 2,
refreshTotal: 2,
});
expect(completedActionAccess.lastActionAt).toBeInstanceOf(Date);
await expect(dashboardCaller.general.getFrontStatus()).resolves.toMatchObject({
onlineNations: expect.not.stringContaining('재야'),
onlineGenerals: expect.stringContaining('접속경계'),
});
await expect(boundaryCaller.board.writeArticle({ accepted: true })).rejects.toMatchObject({
code: 'BAD_REQUEST',
@@ -501,6 +506,7 @@ integration('general access tracking persistence', () => {
).resolves.toMatchObject({
refresh: 3,
refreshTotal: 3,
lastActionAt: completedActionAccess.lastActionAt,
});
const adminCaller = endpointBoundaryRouter.createCaller({
@@ -0,0 +1,71 @@
import { TRPCError } from '@trpc/server';
import { describe, expect, it, vi } from 'vitest';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import type { GameApiContext } from '../src/context.js';
import { authedProcedure, router, sessionActivityProcedure } from '../src/trpc.js';
const auth = (roles: string[] = ['user']): GameSessionTokenPayload => ({
version: 1,
profile: 'che:default',
issuedAt: '2026-08-19T00:00:00.000Z',
expiresAt: '2026-08-20T00:00:00.000Z',
sessionId: 'activity-session',
user: {
id: 'activity-user',
username: 'activity-user',
displayName: '활동 사용자',
roles,
},
sanctions: {},
});
const buildContext = (executeRaw = vi.fn(async (_query: unknown) => 1), token = auth()) =>
({
auth: token,
db: { $executeRaw: executeRaw },
generalAccessTracking: true,
profile: { id: 'che:default', name: 'che', scenario: 'default' },
}) as unknown as GameApiContext;
const activityRouter = router({
read: authedProcedure.query(() => 'read'),
act: authedProcedure.mutation(() => 'acted'),
rejected: authedProcedure.mutation(() => {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'rejected' });
}),
pageRefresh: sessionActivityProcedure.mutation(() => 'refreshed'),
});
describe('general action tracking', () => {
it('records only a completed authenticated mutation, not reads, page refreshes, or rejected actions', async () => {
const executeRaw = vi.fn(async (_query: unknown) => 1);
const caller = activityRouter.createCaller(buildContext(executeRaw));
await expect(caller.read()).resolves.toBe('read');
await expect(caller.pageRefresh()).resolves.toBe('refreshed');
await expect(caller.rejected()).rejects.toMatchObject({ code: 'BAD_REQUEST' });
expect(executeRaw).not.toHaveBeenCalled();
await expect(caller.act()).resolves.toBe('acted');
expect(executeRaw).toHaveBeenCalledTimes(1);
const statement = executeRaw.mock.calls[0]![0] as { sql: string; values: unknown[] };
expect(statement.sql).toContain('INSERT INTO general_access_log');
expect(statement.sql).toContain('last_action_at');
expect(statement.sql).toContain('FROM "general"');
expect(statement.values).toContain('activity-user');
});
it('does not mark admin mutations and never overturns a completed action when presence persistence fails', async () => {
const adminWrite = vi.fn(async (_query: unknown) => 1);
await expect(activityRouter.createCaller(buildContext(adminWrite, auth(['admin']))).act()).resolves.toBe(
'acted'
);
expect(adminWrite).not.toHaveBeenCalled();
const failedWrite = vi.fn(async (_query: unknown) => Promise.reject(new Error('presence unavailable')));
await expect(activityRouter.createCaller(buildContext(failedWrite)).act()).resolves.toBe('acted');
expect(failedWrite).toHaveBeenCalledTimes(1);
});
});
@@ -34,6 +34,7 @@ const buildContext = (options: { auth?: GameSessionTokenPayload | null; hasVoted
{ id: 7, name: '유비', nationId: 2 },
{ id: 8, name: '관우', nationId: 2 },
{ id: 9, name: '조조', nationId: 3 },
{ id: 10, name: '재야장수', nationId: 0 },
]),
},
worldState: {
@@ -46,7 +47,7 @@ const buildContext = (options: { auth?: GameSessionTokenPayload | null; hasVoted
})),
},
generalAccessLog: {
findMany: vi.fn(async () => [{ generalId: 7 }, { generalId: 8 }, { generalId: 9 }]),
findMany: vi.fn(async () => [{ generalId: 7 }, { generalId: 8 }, { generalId: 9 }, { generalId: 10 }]),
},
nation: {
findUnique: vi.fn(async () => ({
@@ -81,7 +82,7 @@ describe('general.getFrontStatus', () => {
vi.useRealTimers();
});
it('returns ref-compatible current-turn online, nation notice, and new vote data', async () => {
it('returns action-based current-turn online data without listing the free nation', async () => {
const context = buildContext();
const caller = appRouter.createCaller(context);
@@ -89,7 +90,7 @@ describe('general.getFrontStatus', () => {
expect(result).toEqual({
serverId: 'che_260819_front',
onlineUserCount: 3,
onlineUserCount: 4,
onlineNations: '【촉】, 【위】',
onlineGenerals: '유비, 관우',
nationNotice: '<p>북벌 준비</p>',
@@ -102,7 +103,7 @@ describe('general.getFrontStatus', () => {
});
expect(context.db.generalAccessLog.findMany).toHaveBeenCalledWith({
where: {
lastRefresh: {
lastActionAt: {
gte: new Date('2026-07-26T10:00:00.000Z'),
},
},