feat: Ref 호환 접속 제한과 벌점 초기화 구현

실행 중인 프로필에서만 접속 벌점을 누적하고 제한 임계값과 대상 경로를 Ref 순서에 맞춘다. 자기 턴 명령 성공 시 순간 점수를 같은 flush에서 초기화하며 월간 누적 감쇠는 유지한다. 제한 중 메인 자동 갱신과 실시간 구독을 중지하고 수동 갱신 성공 시 복구한다.
This commit is contained in:
2026-08-15 18:49:41 +00:00
parent a99f8166eb
commit 48d94e5ff2
29 changed files with 982 additions and 232 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { authedProcedure, router } from '../../trpc.js';
import { accessLimitAuthedProcedure, router } from '../../trpc.js';
import { createReadModelDelta } from '../../services/readModelDeltaCache.js';
import { getBoardAccess } from '../board/index.js';
import { getGeneralContext } from '../general/index.js';
@@ -31,7 +31,7 @@ const zContextBundleInput = z
});
export const dashboardRouter = router({
getContextBundleDelta: authedProcedure.input(zContextBundleInput).query(async ({ ctx, input }) => {
getContextBundleDelta: accessLimitAuthedProcedure.input(zContextBundleInput).query(async ({ ctx, input }) => {
const viewerId = ctx.auth?.user.id;
if (!viewerId) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
+2 -1
View File
@@ -10,6 +10,7 @@ import {
accessAuthedInputProcedure,
accessEngineAuthedProcedure,
accessEngineAuthedInputProcedure,
accessLimitAuthedProcedure,
authedProcedure,
engineAuthedProcedure,
router,
@@ -735,7 +736,7 @@ export const generalRouter = router({
})),
};
}),
getRecentRecords: authedProcedure
getRecentRecords: accessLimitAuthedProcedure
.input(
z.object({
lastGeneralRecordId: z.number().int().nonnegative().default(0),
+102 -104
View File
@@ -4,7 +4,7 @@ import { asRecord } from '@sammo-ts/common';
import type { UserSanctions } from '@sammo-ts/common/auth/gameToken';
import { isMessageAccessBlocked } from '@sammo-ts/common/auth/sanctions';
import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.js';
import { accessAuthedInputProcedure, accessLimitAuthedInputProcedure, authedProcedure, router } from '../../trpc.js';
import {
MESSAGE_MAILBOX_NATIONAL_BASE,
MESSAGE_MAILBOX_PUBLIC,
@@ -73,116 +73,114 @@ const hasPenalty = (penalty: unknown, key: string): boolean => {
};
export const messagesRouter = router({
getRecent: authedProcedure
.input(
z.object({
generalId: z.number().int().positive(),
sequence: z.number().int().optional(),
})
)
.query(async ({ ctx, input }) => {
const general = await getOwnedGeneral(ctx, input.generalId);
getRecent: accessLimitAuthedInputProcedure(
z.object({
generalId: z.number().int().positive(),
sequence: z.number().int().optional(),
})
).query(async ({ ctx, input }) => {
const general = await getOwnedGeneral(ctx, input.generalId);
const sequence = input.sequence ?? -1;
const nationId = general.nationId;
const mailboxes = {
private: general.id,
public: MESSAGE_MAILBOX_PUBLIC,
national: MESSAGE_MAILBOX_NATIONAL_BASE + nationId,
diplomacy: MESSAGE_MAILBOX_NATIONAL_BASE + nationId,
} satisfies Record<MessageType, number>;
const sequence = input.sequence ?? -1;
const nationId = general.nationId;
const mailboxes = {
private: general.id,
public: MESSAGE_MAILBOX_PUBLIC,
national: MESSAGE_MAILBOX_NATIONAL_BASE + nationId,
diplomacy: MESSAGE_MAILBOX_NATIONAL_BASE + nationId,
} satisfies Record<MessageType, number>;
const [privateMessages, publicMessages, nationalMessages, diplomacyMessages, readState, nation] =
await Promise.all([
fetchMessagesFromMailbox({
db: ctx.db,
mailbox: mailboxes.private,
msgType: 'private',
limit: 15,
fromSeq: sequence,
}),
fetchMessagesFromMailbox({
db: ctx.db,
mailbox: mailboxes.public,
msgType: 'public',
limit: 15,
fromSeq: sequence,
}),
fetchMessagesFromMailbox({
db: ctx.db,
mailbox: mailboxes.national,
msgType: 'national',
limit: 15,
fromSeq: sequence,
}),
fetchMessagesFromMailbox({
db: ctx.db,
mailbox: mailboxes.diplomacy,
msgType: 'diplomacy',
limit: 15,
fromSeq: sequence,
}),
ctx.db.messageReadState.findUnique({ where: { generalId: general.id } }),
nationId > 0
? ctx.db.nation.findUnique({
where: { id: nationId },
select: { meta: true },
})
: null,
]);
const [privateMessages, publicMessages, nationalMessages, diplomacyMessages, readState, nation] =
await Promise.all([
fetchMessagesFromMailbox({
db: ctx.db,
mailbox: mailboxes.private,
msgType: 'private',
limit: 15,
fromSeq: sequence,
}),
fetchMessagesFromMailbox({
db: ctx.db,
mailbox: mailboxes.public,
msgType: 'public',
limit: 15,
fromSeq: sequence,
}),
fetchMessagesFromMailbox({
db: ctx.db,
mailbox: mailboxes.national,
msgType: 'national',
limit: 15,
fromSeq: sequence,
}),
fetchMessagesFromMailbox({
db: ctx.db,
mailbox: mailboxes.diplomacy,
msgType: 'diplomacy',
limit: 15,
fromSeq: sequence,
}),
ctx.db.messageReadState.findUnique({ where: { generalId: general.id } }),
nationId > 0
? ctx.db.nation.findUnique({
where: { id: nationId },
select: { meta: true },
})
: null,
]);
const permission = nationId > 0 && nation ? resolveNationPermission(general, nation.meta, false) : -1;
const messageBuckets: Record<MessageType, MessageView[]> = {
private: privateMessages,
public: publicMessages,
national: nationalMessages,
diplomacy: redactDiplomacyMessages(diplomacyMessages, permission),
};
const permission = nationId > 0 && nation ? resolveNationPermission(general, nation.meta, false) : -1;
const messageBuckets: Record<MessageType, MessageView[]> = {
private: privateMessages,
public: publicMessages,
national: nationalMessages,
diplomacy: redactDiplomacyMessages(diplomacyMessages, permission),
};
let nextSequence = sequence;
let minSequence = sequence;
let lastType: MessageType | null = null;
const updateSequence = (type: MessageType, messages: Array<{ id: number }>) => {
for (const message of messages) {
if (message.id > nextSequence) {
nextSequence = message.id;
}
if (message.id <= minSequence) {
minSequence = message.id;
lastType = type;
}
let nextSequence = sequence;
let minSequence = sequence;
let lastType: MessageType | null = null;
const updateSequence = (type: MessageType, messages: Array<{ id: number }>) => {
for (const message of messages) {
if (message.id > nextSequence) {
nextSequence = message.id;
}
if (message.id <= minSequence) {
minSequence = message.id;
lastType = type;
}
};
updateSequence('private', privateMessages);
updateSequence('public', publicMessages);
updateSequence('national', nationalMessages);
updateSequence('diplomacy', diplomacyMessages);
if (lastType === 'private' && messageBuckets.private.length > 0) {
messageBuckets.private.pop();
} else if (lastType === 'public' && messageBuckets.public.length > 0) {
messageBuckets.public.pop();
} else if (lastType === 'national' && messageBuckets.national.length > 0) {
messageBuckets.national.pop();
} else if (lastType === 'diplomacy' && messageBuckets.diplomacy.length > 0) {
messageBuckets.diplomacy.pop();
}
};
return {
result: true,
...messageBuckets,
sequence: nextSequence,
nationId: nationId,
generalName: general.name,
permission,
canRespondDiplomacy: permission >= 4 && general.officerLevel > 4,
latestRead: {
diplomacy: readState?.latestDiplomacyMessage ?? 0,
private: readState?.latestPrivateMessage ?? 0,
},
};
}),
updateSequence('private', privateMessages);
updateSequence('public', publicMessages);
updateSequence('national', nationalMessages);
updateSequence('diplomacy', diplomacyMessages);
if (lastType === 'private' && messageBuckets.private.length > 0) {
messageBuckets.private.pop();
} else if (lastType === 'public' && messageBuckets.public.length > 0) {
messageBuckets.public.pop();
} else if (lastType === 'national' && messageBuckets.national.length > 0) {
messageBuckets.national.pop();
} else if (lastType === 'diplomacy' && messageBuckets.diplomacy.length > 0) {
messageBuckets.diplomacy.pop();
}
return {
result: true,
...messageBuckets,
sequence: nextSequence,
nationId: nationId,
generalName: general.name,
permission,
canRespondDiplomacy: permission >= 4 && general.officerLevel > 4,
latestRead: {
diplomacy: readState?.latestDiplomacyMessage ?? 0,
private: readState?.latestPrivateMessage ?? 0,
},
};
}),
getContacts: authedProcedure
.input(z.object({ generalId: z.number().int().positive() }))
.query(async ({ ctx, input }) => {
@@ -3,7 +3,7 @@ import { z } from 'zod';
import { LogCategory, LogScope } from '@sammo-ts/logic';
import { authedProcedure } from '../../../trpc.js';
import { accessLimitAuthedInputProcedure } from '../../../trpc.js';
import { getMyGeneral } from '../../shared/general.js';
import {
assertNationAccess,
@@ -13,77 +13,75 @@ import {
type GeneralLogType,
} from '../shared.js';
export const getGeneralLog = authedProcedure
.input(
z.object({
generalId: z.number().int().positive(),
type: zGeneralLogType,
beforeId: z.number().int().positive().optional(),
})
)
.query(async ({ ctx, input }) => {
const me = await getMyGeneral(ctx);
assertNationAccess(me);
export const getGeneralLog = accessLimitAuthedInputProcedure(
z.object({
generalId: z.number().int().positive(),
type: zGeneralLogType,
beforeId: z.number().int().positive().optional(),
})
).query(async ({ ctx, input }) => {
const me = await getMyGeneral(ctx);
assertNationAccess(me);
const [nation, target] = await Promise.all([
ctx.db.nation.findUnique({
where: { id: me.nationId },
select: { meta: true },
}),
ctx.db.general.findUnique({
where: { id: input.generalId },
select: { id: true, nationId: true, npcState: true },
}),
]);
const [nation, target] = await Promise.all([
ctx.db.nation.findUnique({
where: { id: me.nationId },
select: { meta: true },
}),
ctx.db.general.findUnique({
where: { id: input.generalId },
select: { id: true, nationId: true, npcState: true },
}),
]);
if (!nation) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' });
}
if (!target) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'General not found' });
}
if (!nation) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' });
}
if (!target) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'General not found' });
}
const permissionLevel = resolveNationPermission(me, nation.meta, true);
if (permissionLevel < 1) {
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
}
if (target.nationId !== me.nationId) {
throw new TRPCError({ code: 'FORBIDDEN', message: '같은 나라의 장수가 아닙니다.' });
}
if (input.type === 'generalAction' && target.npcState < 2 && target.id !== me.id && permissionLevel < 2) {
throw new TRPCError({
code: 'FORBIDDEN',
message: '권한이 부족합니다. 유저 장수의 개인 기록은 수뇌만 열람 가능합니다.',
});
}
const categoryMap: Record<GeneralLogType, LogCategory> = {
generalHistory: LogCategory.HISTORY,
generalAction: LogCategory.ACTION,
battleResult: LogCategory.BATTLE_BRIEF,
battleDetail: LogCategory.BATTLE_DETAIL,
};
const logs = await ctx.db.logEntry.findMany({
where: {
generalId: target.id,
scope: LogScope.GENERAL,
category: categoryMap[input.type],
...(input.type !== 'generalHistory' && input.beforeId ? { id: { lt: input.beforeId } } : {}),
},
orderBy: { id: 'desc' },
...(input.type === 'generalHistory' ? {} : { take: 30 }),
const permissionLevel = resolveNationPermission(me, nation.meta, true);
if (permissionLevel < 1) {
throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' });
}
if (target.nationId !== me.nationId) {
throw new TRPCError({ code: 'FORBIDDEN', message: '같은 나라의 장수가 아닙니다.' });
}
if (input.type === 'generalAction' && target.npcState < 2 && target.id !== me.id && permissionLevel < 2) {
throw new TRPCError({
code: 'FORBIDDEN',
message: '권한이 부족합니다. 유저 장수의 개인 기록은 수뇌만 열람 가능합니다.',
});
}
return {
type: input.type,
const categoryMap: Record<GeneralLogType, LogCategory> = {
generalHistory: LogCategory.HISTORY,
generalAction: LogCategory.ACTION,
battleResult: LogCategory.BATTLE_BRIEF,
battleDetail: LogCategory.BATTLE_DETAIL,
};
const logs = await ctx.db.logEntry.findMany({
where: {
generalId: target.id,
logs: logs.map((entry) => ({
id: entry.id,
text: entry.text,
year: entry.year,
month: entry.month,
createdAt: formatDateTime(entry.createdAt),
})),
};
scope: LogScope.GENERAL,
category: categoryMap[input.type],
...(input.type !== 'generalHistory' && input.beforeId ? { id: { lt: input.beforeId } } : {}),
},
orderBy: { id: 'desc' },
...(input.type === 'generalHistory' ? {} : { take: 30 }),
});
return {
type: input.type,
generalId: target.id,
logs: logs.map((entry) => ({
id: entry.id,
text: entry.text,
year: entry.year,
month: entry.month,
createdAt: formatDateTime(entry.createdAt),
})),
};
});
+20 -4
View File
@@ -7,7 +7,13 @@ import type { GameApiContext } from '../../context.js';
import { zWorldStateConfig, zWorldStateMeta } from '../../context.js';
import { loadMapLayout } from '../../maps/mapLayout.js';
import { loadPublicMap } from '../../maps/worldMap.js';
import { accessPages, recordGeneralAccess } from '../../services/generalAccess.js';
import {
accessPages,
formatGeneralAccessLimitMessage,
generalAccessLimitPages,
getGeneralAccessState,
recordGeneralAccess,
} from '../../services/generalAccess.js';
import { sanitizeInternalDisplayCode } from '../../services/gameDisplayNames.js';
import { accessInputProcedure, procedure, router, sessionActivityProcedure } from '../../trpc.js';
import { loadTraitNames } from '../nation/shared.js';
@@ -248,9 +254,19 @@ const sortNpcList = <
export const publicRouter = router({
recordAccess: sessionActivityProcedure
.input(z.object({ page: z.enum(accessPages) }))
.mutation(async ({ ctx, input }) => ({
recorded: await recordGeneralAccess(ctx, input.page),
})),
.mutation(async ({ ctx, input }) => {
const recorded = await recordGeneralAccess(ctx, input.page);
if (ctx.generalAccessTracking === true && generalAccessLimitPages.has(input.page)) {
const state = await getGeneralAccessState(ctx);
if (state?.level === 2) {
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: formatGeneralAccessLimitMessage(state),
});
}
}
return { recorded };
}),
getMapLayout: procedure.query(async ({ ctx }) => {
return loadMapLayout(ctx.profile.scenario);
}),
+6 -8
View File
@@ -3,7 +3,7 @@ import { z } from 'zod';
import { loadActionModuleBundle } from '@sammo-ts/logic';
import { asRecord } from '@sammo-ts/common';
import { authedProcedure, router } from '../../trpc.js';
import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.js';
import { buildBattleSimEnvironment } from '../../battleSim/environment.js';
import { loadBattleSimTraitOptions } from '../../battleSim/simulatorOptions.js';
import {
@@ -283,13 +283,11 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
};
export const turnsRouter = router({
getCommandTable: authedProcedure
.input(
z.object({
generalId: z.number().int().positive(),
})
)
.query(({ ctx, input }) => getTurnCommandTable(ctx, input.generalId)),
getCommandTable: accessAuthedInputProcedure(
z.object({
generalId: z.number().int().positive(),
})
).query(({ ctx, input }) => getTurnCommandTable(ctx, input.generalId)),
reserved: router({
getGeneral: authedProcedure
.input(
+10 -1
View File
@@ -7,7 +7,12 @@ import { LogCategory, LogScope } from '@sammo-ts/logic';
import type { GameApiContext } from '../../context.js';
import { loadPublicMap, type BaseMapResult } from '../../maps/worldMap.js';
import { generalAccessEndpointWeights, recordGeneralAccessWeight } from '../../services/generalAccess.js';
import {
formatGeneralAccessLimitMessage,
generalAccessEndpointWeights,
getGeneralAccessState,
recordGeneralAccessWeight,
} from '../../services/generalAccess.js';
import { authedProcedure, router } from '../../trpc.js';
import { getMyGeneral } from '../shared/general.js';
@@ -31,6 +36,10 @@ const recordHistoryAccess = async (ctx: GameApiContext): Promise<void> => {
return;
}
await recordGeneralAccessWeight(ctx, generalAccessEndpointWeights['yearbook.getHistory']);
const state = ctx.generalAccessTracking === true ? await getGeneralAccessState(ctx) : null;
if (state?.level === 2) {
throw new TRPCError({ code: 'TOO_MANY_REQUESTS', message: formatGeneralAccessLimitMessage(state) });
}
};
const parseTextArray = (value: unknown): string[] =>