feat: Ref 호환 접속 제한과 벌점 초기화 구현
실행 중인 프로필에서만 접속 벌점을 누적하고 제한 임계값과 대상 경로를 Ref 순서에 맞춘다. 자기 턴 명령 성공 시 순간 점수를 같은 flush에서 초기화하며 월간 누적 감쇠는 유지한다. 제한 중 메인 자동 갱신과 실시간 구독을 중지하고 수동 갱신 성공 시 복구한다.
This commit is contained in:
@@ -3,6 +3,13 @@ import { resolve } from 'node:path';
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
|
||||
const response = (data: unknown) => ({ result: { data } });
|
||||
const errorResponse = (path: string, message: string) => ({
|
||||
error: {
|
||||
message,
|
||||
code: -32029,
|
||||
data: { code: 'TOO_MANY_REQUESTS', httpStatus: 429, path },
|
||||
},
|
||||
});
|
||||
const artifactRoot = process.env.MAIN_NAVIGATION_ARTIFACT_DIR;
|
||||
const autoRefreshArtifactRoot = process.env.AUTO_REFRESH_ARTIFACT_DIR;
|
||||
const productionBundle = process.env.PLAYWRIGHT_FRONTEND_MODE === 'production';
|
||||
@@ -31,6 +38,7 @@ type NavigationFixture = {
|
||||
commandBlockedCount?: number;
|
||||
forceSnapshotCalls?: number;
|
||||
refreshDelayMs?: number;
|
||||
accessLimitAfterCalls?: number;
|
||||
largeCommandTable?: boolean;
|
||||
refCommandCategories?: boolean;
|
||||
currentYear?: number;
|
||||
@@ -368,6 +376,14 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
|
||||
}
|
||||
if (operation === 'dashboard.getContextBundleDelta') {
|
||||
state.generalMeCalls += 1;
|
||||
if (state.accessLimitAfterCalls !== undefined && state.generalMeCalls > state.accessLimitAfterCalls) {
|
||||
return errorResponse(
|
||||
operation,
|
||||
'접속 제한중입니다. 1턴 이내에 너무 많은 갱신을 하셨습니다. ' +
|
||||
'(다음 접속 가능 시각: 2026-08-15 12:34:56) ' +
|
||||
'자신의 턴이 되면 다시 접속 가능합니다. 잠시 쉬어보세요.'
|
||||
);
|
||||
}
|
||||
const input = operationInput(route, index);
|
||||
const include = input.include ?? {};
|
||||
const forceSnapshot = input.forceSnapshot === true;
|
||||
@@ -499,7 +515,7 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
|
||||
operations.forEach((operation, index) => {
|
||||
if (operation !== 'dashboard.getContextBundleDelta') return;
|
||||
const item = results[index];
|
||||
if (!item) return;
|
||||
if (!item || !('result' in item)) return;
|
||||
const data = item.result.data as {
|
||||
context?: { kind: string };
|
||||
commandTable?: { kind: string };
|
||||
@@ -2041,7 +2057,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
|
||||
});
|
||||
await expect
|
||||
.poll(() => state.operations.slice(operationsBeforeSurvey), { timeout: 3_000 })
|
||||
.toEqual(['general.getFrontStatus']);
|
||||
.toEqual(['dashboard.getContextBundleDelta', 'general.getFrontStatus']);
|
||||
|
||||
const profile = await page.evaluate(() => {
|
||||
const probe = (
|
||||
@@ -2199,6 +2215,55 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
|
||||
expect(state.generalMeCalls).toBe(callsAfterLeavingMain);
|
||||
});
|
||||
|
||||
test('access limit stops automatic main refresh and closes realtime until a manual retry can pass', async ({
|
||||
page,
|
||||
}) => {
|
||||
const state: NavigationFixture = {
|
||||
officerLevel: 5,
|
||||
permission: 2,
|
||||
nationLevel: 3,
|
||||
stage: 0,
|
||||
npcMode: 1,
|
||||
generalMeCalls: 0,
|
||||
operations: [],
|
||||
accessLimitAfterCalls: 1,
|
||||
};
|
||||
await installRealtimeHarness(page);
|
||||
await installFixture(page, state);
|
||||
await page.setViewportSize({ width: 1200, height: 900 });
|
||||
await waitForMain(page);
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime())
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
const operationsBeforeLimit = state.operations.length;
|
||||
await emitReadModelInvalidation(page, readModelInvalidation({ records: true, map: true }));
|
||||
|
||||
await expect(page.getByRole('alert')).toContainText('접속 제한중입니다.');
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime())
|
||||
)
|
||||
.toBe(false);
|
||||
expect(state.operations.slice(operationsBeforeLimit)).toEqual(['dashboard.getContextBundleDelta']);
|
||||
|
||||
const operationsAfterLimit = state.operations.length;
|
||||
await emitReadModelInvalidation(page, readModelInvalidation({ context: true, commands: true }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
expect(state.operations).toHaveLength(operationsAfterLimit);
|
||||
|
||||
state.accessLimitAfterCalls = undefined;
|
||||
await page.getByRole('button', { name: '갱 신' }).click();
|
||||
await expect(page.getByRole('alert')).toHaveCount(0);
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime())
|
||||
)
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
test('global activity, world history, and a month boundary refresh their visible main slices', async ({ page }) => {
|
||||
const state: NavigationFixture = {
|
||||
officerLevel: 5,
|
||||
@@ -2226,7 +2291,10 @@ test('global activity, world history, and a month boundary refresh their visible
|
||||
const operationsBeforeGlobal = state.operations.length;
|
||||
await emitReadModelInvalidation(page, readModelInvalidation({ records: true }));
|
||||
await expect(page.locator('[data-main-target="global-records"]')).toContainText('자동 갱신된 장수 동향');
|
||||
expect(state.operations.slice(operationsBeforeGlobal)).toEqual(['general.getRecentRecords']);
|
||||
expect(state.operations.slice(operationsBeforeGlobal)).toEqual([
|
||||
'dashboard.getContextBundleDelta',
|
||||
'general.getRecentRecords',
|
||||
]);
|
||||
|
||||
state.worldHistory = [
|
||||
{ id: 5, text: '자동 갱신된 중원 정세' },
|
||||
@@ -2235,7 +2303,10 @@ test('global activity, world history, and a month boundary refresh their visible
|
||||
const operationsBeforeHistory = state.operations.length;
|
||||
await emitReadModelInvalidation(page, readModelInvalidation({ records: true }));
|
||||
await expect(page.locator('[data-main-target="world-history"]')).toContainText('자동 갱신된 중원 정세');
|
||||
expect(state.operations.slice(operationsBeforeHistory)).toEqual(['general.getRecentRecords']);
|
||||
expect(state.operations.slice(operationsBeforeHistory)).toEqual([
|
||||
'dashboard.getContextBundleDelta',
|
||||
'general.getRecentRecords',
|
||||
]);
|
||||
|
||||
state.currentMonth = 2;
|
||||
const operationsBeforeMonth = state.operations.length;
|
||||
|
||||
@@ -82,6 +82,16 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
const realtimeEnabled = ref(true);
|
||||
const realtimeStatus = ref<'idle' | 'connected' | 'paused'>('idle');
|
||||
const realtimeActive = ref(false);
|
||||
const accessLimited = ref(false);
|
||||
|
||||
const handleDashboardError = (value: unknown) => {
|
||||
const message = resolveErrorMessage(value);
|
||||
error.value = message;
|
||||
if (message.startsWith('접속 제한중입니다.')) {
|
||||
accessLimited.value = true;
|
||||
realtimeStatus.value = 'paused';
|
||||
}
|
||||
};
|
||||
|
||||
const general = ref<PresentGeneralContext['general'] | null>(null);
|
||||
const city = ref<PresentGeneralContext['city'] | null>(null);
|
||||
@@ -508,6 +518,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
{ context: true, commandTable: true, boardAccess: true },
|
||||
true
|
||||
);
|
||||
accessLimited.value = false;
|
||||
applyDashboardPatch(contextPatch);
|
||||
const context = contextSnapshot;
|
||||
|
||||
@@ -573,7 +584,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
}
|
||||
initialized = true;
|
||||
} catch (err) {
|
||||
error.value = resolveErrorMessage(err);
|
||||
handleDashboardError(err);
|
||||
} finally {
|
||||
if (isInitialLoad) {
|
||||
loading.value = false;
|
||||
@@ -626,14 +637,14 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
if (plan.records) recordsError.value = null;
|
||||
if (plan.frontStatus) frontStatusError.value = null;
|
||||
try {
|
||||
const contextBundlePromise =
|
||||
plan.context || plan.commands || plan.boardAccess
|
||||
? fetchContextBundlePatch({
|
||||
context: plan.context,
|
||||
commandTable: plan.commands,
|
||||
boardAccess: plan.boardAccess,
|
||||
})
|
||||
: Promise.resolve(undefined);
|
||||
const contextPatch = await fetchContextBundlePatch({
|
||||
// Every automatic refresh crosses this access-limit gate. The
|
||||
// context delta is usually unchanged and therefore stays small.
|
||||
context: true,
|
||||
commandTable: plan.commands,
|
||||
boardAccess: plan.boardAccess,
|
||||
});
|
||||
accessLimited.value = false;
|
||||
const lobbyPromise = plan.lobby ? trpc.lobby.info.query() : Promise.resolve(undefined);
|
||||
const mapPromise = plan.map
|
||||
? trpc.world.getMap.query({ generalId: id, showMe: true, useCache: true })
|
||||
@@ -659,8 +670,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
})
|
||||
: Promise.resolve(undefined);
|
||||
|
||||
const [contextPatch, lobby, map, contacts, generalTurns, records, nextFrontStatus] = await Promise.all([
|
||||
contextBundlePromise,
|
||||
const [lobby, map, contacts, generalTurns, records, nextFrontStatus] = await Promise.all([
|
||||
lobbyPromise,
|
||||
mapPromise,
|
||||
contactsPromise,
|
||||
@@ -669,7 +679,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
frontPromise,
|
||||
]);
|
||||
|
||||
const patch: DashboardReadModelPatch = contextPatch ? { ...contextPatch } : {};
|
||||
const patch: DashboardReadModelPatch = { ...contextPatch };
|
||||
if (lobby !== undefined) patch.lobbyInfo = lobby;
|
||||
if (map !== undefined) patch.worldMap = map;
|
||||
if (contacts !== undefined) patch.messageContacts = contacts;
|
||||
@@ -690,7 +700,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
applyDashboardPatch(patch);
|
||||
publishDashboardPatch(patch);
|
||||
} catch (err) {
|
||||
error.value = resolveErrorMessage(err);
|
||||
handleDashboardError(err);
|
||||
} finally {
|
||||
refreshing.value = false;
|
||||
}
|
||||
@@ -709,7 +719,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
applyDashboardPatch(patch);
|
||||
publishDashboardPatch(patch);
|
||||
} catch (err) {
|
||||
error.value = resolveErrorMessage(err);
|
||||
handleDashboardError(err);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -970,6 +980,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
realtimeActive.value &&
|
||||
document.visibilityState !== 'hidden' &&
|
||||
realtimeEnabled.value &&
|
||||
!accessLimited.value &&
|
||||
session.isReady &&
|
||||
session.hasGeneral &&
|
||||
generalId.value !== null;
|
||||
@@ -1158,11 +1169,12 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
session.profile,
|
||||
session.user?.id,
|
||||
generalId.value,
|
||||
accessLimited.value,
|
||||
],
|
||||
([active, enabled, ready, hasGeneral]) => {
|
||||
realtimeStatus.value = !enabled ? 'paused' : realtimeStatus.value;
|
||||
([active, enabled, ready, hasGeneral, , , , , limited]) => {
|
||||
realtimeStatus.value = !enabled || limited ? 'paused' : realtimeStatus.value;
|
||||
if (!active || !ready || !hasGeneral) {
|
||||
realtimeStatus.value = enabled ? 'idle' : 'paused';
|
||||
realtimeStatus.value = enabled && !limited ? 'idle' : 'paused';
|
||||
}
|
||||
reconcileRealtimeCoordinator();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user