fix(gateway): 일시정지와 서버 중지 상태 계약 분리

This commit is contained in:
2026-08-15 17:12:13 +00:00
parent dc27766dd6
commit 4f841ad82f
16 changed files with 320 additions and 41 deletions
+45 -6
View File
@@ -1,5 +1,11 @@
<script setup lang="ts">
import { formatServerDateTime, serverDateTimeInputToIso, toServerDateTimeInputValue } from '@sammo-ts/common';
import {
formatServerDateTime,
gatewayProfileCapabilities,
serverDateTimeInputToIso,
toServerDateTimeInputValue,
type GatewayProfileStatus,
} from '@sammo-ts/common';
import { computed, onMounted, ref, watch } from 'vue';
import ServerProfileTabs from '../components/ServerProfileTabs.vue';
import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue';
@@ -180,7 +186,7 @@ type AdminProfile = {
currentScenario: string | null;
/** @deprecated Rollback-compatible mirror of currentScenario. */
scenario: string;
status: string;
status: GatewayProfileStatus;
apiPort: number;
runtime: {
apiRunning: boolean;
@@ -423,6 +429,22 @@ const runtimeActionPending = (profile: AdminProfile): boolean => {
return profile.runtimeActions.some((action) => action.status === 'REQUESTED' || action.status === 'PARTIAL');
};
const profileLifecycleText = (profile: AdminProfile): string => {
if (profile.currentScenario === null) return 'DB 초기화 전 · 게임 접근 불가';
if (profile.status === 'PAUSED') return '턴 일시정지 · 게임 조회와 예약턴 입력 가능 · 운영자 재개 가능';
if (profile.status === 'STOPPED') return '서버 프로세스 중지 · 게임 접근 불가 · 운영자 서버 재개 가능';
if (profile.status === 'RUNNING') return '서버 운영 및 턴 진행 중';
if (profile.status === 'PREOPEN') return '서버 접근 가능 · 개장 전 턴 정지';
if (profile.status === 'COMPLETED') return '종료 기수 조회 가능 · 턴 정지';
if (profile.status === 'DISABLED') return '비활성 · 게임 접근 불가';
return '준비 중 · 게임 접근 불가';
};
const canResumeProfile = (profile: AdminProfile): boolean =>
profile.currentScenario !== null && gatewayProfileCapabilities(profile.status).operatorResumable;
const canPauseProfile = (profile: AdminProfile): boolean => profile.status === 'RUNNING';
const canStopProfile = (profile: AdminProfile): boolean => gatewayProfileCapabilities(profile.status).runtimeExpected;
const validDuration = (profileName: string): boolean => {
const value = Number(profileActions.value[profileName]?.durationMinutes);
return Number.isInteger(value) && value >= 1 && value <= 1440;
@@ -2069,6 +2091,12 @@ onMounted(() => {
<div class="text-xs text-zinc-500">
현재 시나리오: {{ profile.currentScenario ?? '미설정' }}
</div>
<div
class="mt-1 text-xs text-amber-200"
data-testid="profile-lifecycle-description"
>
{{ profileLifecycleText(profile) }}
</div>
</div>
<div class="text-xs text-zinc-400">
상태: {{ profile.status }} / API: {{ profile.runtime.apiRunning ? 'ON' : 'OFF' }} /
@@ -2359,19 +2387,30 @@ onMounted(() => {
</div>
<div class="grid grid-cols-2 gap-2 pt-2">
<button
class="bg-blue-700 hover:bg-blue-600 text-white font-semibold px-3 py-2 rounded"
class="bg-blue-700 hover:bg-blue-600 text-white font-semibold px-3 py-2 rounded disabled:opacity-40 disabled:cursor-not-allowed"
:disabled="
profileActionSubmitting[profile.profileName] ||
!canResumeProfile(profile)
"
@click="requestProfileAction(profile.profileName, 'RESUME')"
>
재개
{{ profile.status === 'PAUSED' ? '턴 재개' : '서버 재개' }}
</button>
<button
class="bg-zinc-700 hover:bg-zinc-600 text-white font-semibold px-3 py-2 rounded"
class="bg-zinc-700 hover:bg-zinc-600 text-white font-semibold px-3 py-2 rounded disabled:opacity-40 disabled:cursor-not-allowed"
:disabled="
profileActionSubmitting[profile.profileName] ||
!canPauseProfile(profile)
"
@click="requestProfileAction(profile.profileName, 'PAUSE')"
>
일시정지
</button>
<button
class="bg-red-700 hover:bg-red-600 text-white font-semibold px-3 py-2 rounded"
class="bg-red-700 hover:bg-red-600 text-white font-semibold px-3 py-2 rounded disabled:opacity-40 disabled:cursor-not-allowed"
:disabled="
profileActionSubmitting[profile.profileName] || !canStopProfile(profile)
"
@click="requestProfileAction(profile.profileName, 'STOP')"
>
중지
+6 -3
View File
@@ -3,6 +3,7 @@ import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import type { inferRouterOutputs } from '@trpc/server';
import type { AppRouter } from '@sammo-ts/gateway-api';
import { gatewayProfileCapabilities } from '@sammo-ts/common';
import MapPreview from '../components/MapPreview.vue';
import KakaoOtpDialog from '../components/KakaoOtpDialog.vue';
@@ -53,9 +54,11 @@ const loadPublicStatus = async (): Promise<void> => {
try {
const profiles = await trpc.lobby.profiles.query();
profile.value =
PROFILE_PUBLIC_STATUS_ORDER.map((status) => profiles.find((entry) => entry.status === status)).find(
(entry) => entry !== undefined
) ?? null;
PROFILE_PUBLIC_STATUS_ORDER.map((status) =>
profiles.find(
(entry) => entry.status === status && gatewayProfileCapabilities(entry.status).userAccessible
)
).find((entry) => entry !== undefined) ?? null;
if (!profile.value) {
statusError.value = '현재 공개 중인 서버가 없습니다.';
return;
+6 -9
View File
@@ -33,8 +33,6 @@ type ProfileLoadState = {
const PROFILE_REQUEST_TIMEOUT_MS = 10_000;
const PROFILE_RETRY_DELAYS_MS = [1_000, 2_000, 3_000, 5_000, 8_000, 15_000] as const;
const PROFILE_RUNTIME_STATUSES = new Set<LobbyProfile['status']>(['RUNNING', 'PREOPEN', 'PAUSED', 'COMPLETED']);
const router = useRouter();
const me = ref<MeOutput>(null);
const notice = ref('');
@@ -67,9 +65,7 @@ const needsKakaoVerification = computed(
);
const userIconBaseUrl = configuredUserIconPublicUrl();
const sharedIconBaseUrl = configuredSharedIconPublicUrl();
const publicMapProfiles = computed(() =>
profiles.value.filter((profile) => PROFILE_RUNTIME_STATUSES.has(profile.status))
);
const publicMapProfiles = computed(() => profiles.value.filter((profile) => profile.lifecycle.userAccessible));
const selectedMapProfile = computed(
() => publicMapProfiles.value.find((profile) => profile.profileName === selectedMapProfileName.value) ?? null
);
@@ -110,11 +106,12 @@ const handleMapTabKeydown = (event: KeyboardEvent, profileName: string): void =>
const formatGraceEndsAt = (value: string | null | undefined): string => formatServerDateTime(value);
const serverSeasonStatus = (info: LobbyInfo) => resolveServerSeasonStatus(info);
const isProfileRuntimeAvailable = (profile: LobbyProfile): boolean => PROFILE_RUNTIME_STATUSES.has(profile.status);
const isProfileRuntimeAvailable = (profile: LobbyProfile): boolean => profile.lifecycle.userAccessible;
const unavailableProfileText = (profile: LobbyProfile): string => {
if (profile.status === 'RESERVED') return '- 준 비 중 -';
if (!profile.lifecycle.dataInitialized) return '- DB 초기화 전 · 접근 불가 -';
if (profile.status === 'RESERVED') return '- 준 비 중 · 접근 불가 -';
if (profile.status === 'DISABLED') return '- 비 활 성 -';
return '- 폐 쇄 중 -';
return '- 서버 중지 · 접근 불가 -';
};
const profileLoadState = (profileName: string): ProfileLoadState | undefined => profileLoadStates.value[profileName];
const setProfileLoadState = (profileName: string, state: ProfileLoadState): void => {
@@ -455,7 +452,7 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
class="mt-1 whitespace-nowrap text-xs text-amber-300"
data-testid="profile-paused-status"
>
진행 일시정지
일시정지 · 조회/예약턴 가능
</div>
<div
v-if="profile.localAccountPolicy?.specialAccess"