feat: 오픈 게임 취소와 유산 정산 경로 추가

별도 최고위험 권한으로 실행되는 원자적 취소 작업을 추가한다. 버려진 게임과 장수 기록의 보존·삭제 옵션, 오픈 원금 전액 환급 및 획득 포인트 보전율, 취소 상태와 관리자·과거기록 UI를 함께 반영한다.
This commit is contained in:
2026-08-18 13:31:49 +00:00
parent a7b11811de
commit 383d173790
36 changed files with 1967 additions and 83 deletions
@@ -5,7 +5,7 @@ type OperationStatus = 'QUEUED' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'CANCELLE
type Operation = {
id: string;
profileName: string;
type: 'RESET' | 'DEPLOY' | 'START' | 'STOP';
type: 'RESET' | 'DEPLOY' | 'START' | 'STOP' | 'CANCEL_GAME';
status: OperationStatus;
sourceMode?: 'BRANCH' | 'COMMIT';
sourceRef?: string;
@@ -389,6 +389,22 @@ const installFixture = async (page: Page, state: FixtureState) => {
state.operations = [operation];
return response(operation);
}
if (name === 'admin.operations.requestGameCancellation') {
const operation: Operation = {
id: '88888888-8888-4888-8888-888888888888',
profileName: 'che:default',
type: 'CANCEL_GAME',
status: 'QUEUED',
sourceMode: 'COMMIT',
sourceRef: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
payload: {},
requestedBy: 'admin',
createdAt: '2026-08-18T01:00:00.000Z',
updatedAt: '2026-08-18T01:00:00.000Z',
};
state.operations = [operation];
return response(operation);
}
if (name === 'admin.releases.requestGatewayDeploy' || name === 'admin.releases.requestGatewayRollback') {
const releaseOperation = {
id: '77777777-7777-4777-8777-777777777777',
@@ -700,6 +716,93 @@ test('separates DB-preserving profile deployment from DB reset', async ({ page }
expect(state.requestBodies.some((entry) => entry.operation === 'admin.operations.requestReset')).toBe(false);
});
test('submits a separately authorized destructive game cancellation on desktop and mobile', async ({
page,
}, testInfo) => {
const state: FixtureState = {
operations: [],
gatewayOperations: [],
runtimeRunning: true,
requestBodies: [],
profileLogProgress: true,
capabilities: [{ permission: 'admin.games.cancel', scope: 'PROFILE', scopes: ['che:default'] }],
};
await installFixture(page, state);
const confirmations: string[] = [];
page.on('dialog', async (dialog) => {
confirmations.push(dialog.message());
await dialog.accept();
});
await page.goto('admin/servers/che%3Adefault/cancel');
await expect(page).toHaveURL(/\/gateway\/admin\/servers\/che%3Adefault\/cancel$/);
await expect(page.getByRole('heading', { name: 'che:default 게임 취소' })).toBeVisible();
await expect(page.getByRole('link', { name: '게임 취소', exact: true })).toHaveAttribute('aria-current', 'page');
await expect(page.getByRole('link', { name: '시나리오 초기화', exact: true })).toHaveCount(0);
await expect(page.getByTestId('request-game-cancellation')).toBeDisabled();
await page.getByTestId('cancellation-history-mode').selectOption('DELETE');
await page.getByTestId('cancellation-general-mode').selectOption('RETAIN');
await page.getByTestId('cancellation-retention-percent').fill('35');
await page.getByTestId('cancellation-reason').fill('잘못된 시나리오로 개장함');
await page.getByTestId('cancellation-confirmation').fill('che:default');
const cancelButton = page.getByTestId('request-game-cancellation');
await expect(cancelButton).toBeEnabled();
await cancelButton.hover();
const desktopMetrics = await page.getByTestId('game-cancellation-form').evaluate((form) => {
const rect = form.getBoundingClientRect();
const style = getComputedStyle(form);
const controls = Array.from(form.querySelectorAll('select, input:not([type="range"]), textarea, button')).map(
(control) => {
const controlRect = control.getBoundingClientRect();
return { width: controlRect.width, height: controlRect.height };
}
);
return {
x: rect.x,
width: rect.width,
borderColor: style.borderColor,
backgroundColor: style.backgroundColor,
minimumControlHeight: Math.min(...controls.map((control) => control.height)),
};
});
expect(desktopMetrics.width).toBeGreaterThan(800);
expect(desktopMetrics.minimumControlHeight).toBeGreaterThanOrEqual(21);
await page.screenshot({ path: testInfo.outputPath('game-cancellation-desktop.png'), fullPage: true });
await cancelButton.click();
await expect(page.getByText('게임 취소 작업을 등록했습니다.').first()).toBeVisible();
await expect(page.getByTestId('operations-table')).toContainText('CANCEL_GAME');
expect(confirmations).toHaveLength(1);
expect(confirmations[0]).toContain('기수 행 물리 삭제');
expect(confirmations[0]).toContain('장수 기록 보존');
expect(confirmations[0]).toContain('유산 획득분 35% 보전');
const request = state.requestBodies.find((entry) => entry.operation === 'admin.operations.requestGameCancellation');
expect(JSON.stringify(request?.body)).toContain('"historyMode":"DELETE"');
expect(JSON.stringify(request?.body)).toContain('"generalMode":"RETAIN"');
expect(JSON.stringify(request?.body)).toContain('"earnedPointRetentionPercent":35');
expect(JSON.stringify(request?.body)).toContain('잘못된 시나리오로 개장함');
await page.setViewportSize({ width: 390, height: 844 });
const mobileMetrics = await page.getByTestId('game-cancellation-form').evaluate((form) => {
const rect = form.getBoundingClientRect();
return {
x: rect.x,
width: rect.width,
viewportWidth: document.documentElement.clientWidth,
documentScrollWidth: document.documentElement.scrollWidth,
};
});
expect(mobileMetrics.x).toBeGreaterThanOrEqual(0);
expect(mobileMetrics.x + mobileMetrics.width).toBeLessThanOrEqual(mobileMetrics.viewportWidth);
expect(mobileMetrics.documentScrollWidth).toBeLessThanOrEqual(mobileMetrics.viewportWidth);
await writeFile(
testInfo.outputPath('game-cancellation-metrics.json'),
JSON.stringify({ desktopMetrics, mobileMetrics }, null, 2)
);
await page.screenshot({ path: testInfo.outputPath('game-cancellation-mobile.png'), fullPage: true });
});
for (const viewportSize of [
{ name: 'desktop', width: 1280, height: 720 },
{ name: 'mobile', width: 390, height: 844 },
@@ -1,13 +1,14 @@
<script setup lang="ts">
import { computed } from 'vue';
type ServerProfileTab = 'status' | 'version' | 'scenario';
type ServerProfileTab = 'status' | 'version' | 'scenario' | 'cancel';
const props = defineProps<{
profileName: string;
activeTab: ServerProfileTab;
canDeploy: boolean;
canReset: boolean;
canCancel: boolean;
}>();
const tabs = computed(() =>
@@ -30,6 +31,12 @@ const tabs = computed(() =>
to: `/admin/servers/${encodeURIComponent(props.profileName)}/scenario`,
visible: props.canReset,
},
{
id: 'cancel' as const,
label: '게임 취소',
to: `/admin/servers/${encodeURIComponent(props.profileName)}/cancel`,
visible: props.canCancel,
},
].filter((tab) => tab.visible)
);
</script>
+6
View File
@@ -61,6 +61,12 @@ const router = createRouter({
component: ServerOperationsView,
props: (route) => ({ mode: 'scenario', profileName: route.params.profileName }),
},
{
path: '/admin/servers/:profileName/cancel',
name: 'admin-server-cancel',
component: ServerOperationsView,
props: (route) => ({ mode: 'cancel', profileName: route.params.profileName }),
},
{
path: '/admin/system',
name: 'admin-system',
@@ -457,6 +457,7 @@ const profileLifecycleText = (profile: AdminProfile): string => {
if (profile.status === 'RUNNING') return '서버 운영 및 턴 진행 중';
if (profile.status === 'PREOPEN') return '서버 접근 가능 · 개장 전 턴 정지';
if (profile.status === 'COMPLETED') return '종료 기수 조회 가능 · 턴 정지';
if (profile.status === 'CANCELLED') return '취소 게임 · 접근 및 재개 불가 · 새 시나리오 초기화 필요';
if (profile.status === 'DISABLED') return '비활성 · 게임 접근 불가';
return '준비 중 · 게임 접근 불가';
};
@@ -2162,6 +2163,7 @@ onMounted(() => {
active-tab="status"
:can-deploy="hasCapability('admin.profiles.deploy', profile.profileName)"
:can-reset="hasCapability('admin.scenarios.reset', profile.profileName)"
:can-cancel="hasCapability('admin.games.cancel', profile.profileName)"
/>
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2">
@@ -12,7 +12,7 @@ import {
} from '../utils/resetDefaults';
import { directTrpc, trpc } from '../utils/trpc';
type OperationPageMode = 'version' | 'scenario' | 'gateway';
type OperationPageMode = 'version' | 'scenario' | 'cancel' | 'gateway';
const props = defineProps<{
mode: OperationPageMode;
@@ -33,7 +33,7 @@ type Scenario = {
type Operation = {
id: string;
profileName: string;
type: 'RESET' | 'DEPLOY' | 'START' | 'STOP';
type: 'RESET' | 'DEPLOY' | 'CANCEL_GAME' | 'START' | 'STOP';
status: 'QUEUED' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'CANCELLED';
sourceMode?: 'BRANCH' | 'COMMIT';
sourceRef?: string;
@@ -152,6 +152,13 @@ const gatewayForm = reactive({
sourceRef: 'main',
reason: '',
});
const cancellationForm = reactive({
historyMode: 'RETAIN_ABANDONED' as 'RETAIN_ABANDONED' | 'DELETE',
generalMode: 'RETAIN' as 'RETAIN' | 'DELETE',
earnedPointRetentionPercent: 0,
reason: '',
confirmation: '',
});
const selectedGatewayOperation = computed(
() => gatewayReleaseOperations.value.find((operation) => operation.id === selectedGatewayOperationId.value) ?? null
@@ -190,12 +197,16 @@ const hasCapability = (permission: string): boolean =>
const pageTitle = computed(() => {
if (props.mode === 'gateway') return 'Gateway 릴리스';
if (props.mode === 'cancel') return `${props.profileName ?? ''} 게임 취소`;
if (props.mode === 'scenario') return `${props.profileName ?? ''} 시나리오 초기화`;
return `${props.profileName ?? ''} 버전 업데이트`;
});
const pageDescription = computed(() => {
if (props.mode === 'gateway') return 'Gateway control plane 배포와 rollback을 별도 권한으로 관리합니다.';
if (props.mode === 'cancel') {
return '진행 중 게임을 닫고 정식 기수에서 제외하며 장수 기록과 유산 포인트 보전 범위를 선택합니다.';
}
if (props.mode === 'scenario') {
return '현재 배포 버전으로 시나리오만 초기화하거나, 배포 권한이 있을 때 새 버전과 함께 초기화합니다.';
}
@@ -445,8 +456,7 @@ const selectGatewayReleaseOperation = (operationId: string) => {
};
const toggleGatewayReleaseError = (operationId: string) => {
expandedGatewayErrorOperationId.value =
expandedGatewayErrorOperationId.value === operationId ? '' : operationId;
expandedGatewayErrorOperationId.value = expandedGatewayErrorOperationId.value === operationId ? '' : operationId;
};
const requestDeploy = async () => {
@@ -627,6 +637,48 @@ const requestReset = async () => {
}
};
const requestGameCancellation = async () => {
clearStatus();
const profileName = selectedProfileName.value;
if (!profileName || activeOperation.value) return;
if (cancellationForm.reason.trim().length < 5) {
errorMessage.value = '취소 사유를 5자 이상 입력해주세요.';
return;
}
if (cancellationForm.confirmation.trim() !== profileName) {
errorMessage.value = `확인란에 ${profileName}을 정확히 입력해주세요.`;
return;
}
const historyText =
cancellationForm.historyMode === 'RETAIN_ABANDONED' ? '취소 게임으로 보존' : '기수 행 물리 삭제';
const generalText = cancellationForm.generalMode === 'RETAIN' ? '장수 기록 보존' : '장수 기록 삭제';
if (
!window.confirm(
`${profileName}의 진행 중 게임을 취소합니다.\n${historyText}\n${generalText}\n유산 획득분 ${cancellationForm.earnedPointRetentionPercent}% 보전\n취소 후 시나리오 초기화 전에는 재개할 수 없습니다.`
)
) {
return;
}
submitting.value = true;
try {
const operation = await adminClient.operations.requestGameCancellation.mutate({
profileName,
historyMode: cancellationForm.historyMode,
generalMode: cancellationForm.generalMode,
earnedPointRetentionPercent: cancellationForm.earnedPointRetentionPercent,
reason: cancellationForm.reason.trim(),
});
selectedProfileOperationId.value = operation.id;
cancellationForm.confirmation = '';
message.value = '게임 취소 작업을 등록했습니다.';
await loadState(true);
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '게임 취소 요청에 실패했습니다.';
} finally {
submitting.value = false;
}
};
const cancelOperation = async (operation: Operation) => {
clearStatus();
if (!window.confirm('대기 중인 작업을 취소하시겠습니까?')) {
@@ -725,9 +777,10 @@ onBeforeUnmount(() => {
<ServerProfileTabs
v-if="mode !== 'gateway' && profileName"
:profile-name="profileName"
:active-tab="mode === 'scenario' ? 'scenario' : 'version'"
:active-tab="mode === 'scenario' ? 'scenario' : mode === 'cancel' ? 'cancel' : 'version'"
:can-deploy="hasCapability('admin.profiles.deploy')"
:can-reset="hasCapability('admin.scenarios.reset')"
:can-cancel="hasCapability('admin.games.cancel')"
/>
<div v-if="errorMessage" class="rounded border border-red-800 bg-red-950/50 px-4 py-3 text-sm text-red-200">
@@ -740,7 +793,105 @@ onBeforeUnmount(() => {
{{ message }}
</div>
<section v-if="mode !== 'gateway'">
<section v-if="mode === 'cancel'">
<form
class="space-y-5 rounded-lg border border-red-900/80 bg-zinc-900 p-5"
data-testid="game-cancellation-form"
@submit.prevent="requestGameCancellation"
>
<div class="rounded border border-red-800 bg-red-950/50 px-4 py-3 text-sm text-red-100">
작업은 게임을 즉시 닫고 profile을 <strong>CANCELLED</strong> 상태로 만듭니다. 버전
rollback이나 단순 서버 정지가 아니며, 다시 열려면 시나리오 초기화가 필요합니다.
</div>
<fieldset class="grid gap-4 md:grid-cols-2">
<label class="text-sm text-zinc-300">
기수 데이터
<select
v-model="cancellationForm.historyMode"
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2"
data-testid="cancellation-history-mode"
>
<option value="RETAIN_ABANDONED">취소 게임으로 DB에 보존</option>
<option value="DELETE">기수 물리 삭제</option>
</select>
</label>
<label class="text-sm text-zinc-300">
플레이 장수 기록
<select
v-model="cancellationForm.generalMode"
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2"
data-testid="cancellation-general-mode"
>
<option value="RETAIN"> 지난 플레이에 취소 게임 기록 보존</option>
<option value="DELETE">장수 과거 기록 삭제</option>
</select>
</label>
</fieldset>
<label class="block text-sm text-zinc-300">
당기 획득 유산 포인트 보전율
<div class="mt-1 flex items-center gap-3">
<input
v-model.number="cancellationForm.earnedPointRetentionPercent"
type="range"
min="0"
max="100"
step="1"
class="w-full"
data-testid="cancellation-retention-range"
/>
<input
v-model.number="cancellationForm.earnedPointRetentionPercent"
type="number"
min="0"
max="100"
step="1"
class="w-24 rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-right"
data-testid="cancellation-retention-percent"
/>
<span>%</span>
</div>
<span class="mt-1 block text-xs text-zinc-500">
개장 보유 원금은 사용 여부와 관계없이 전액 복구되고, 비율은 이번 게임에서 획득한
몫에만 적용됩니다.
</span>
</label>
<label class="block text-sm text-zinc-300">
취소 사유
<textarea
v-model="cancellationForm.reason"
rows="3"
maxlength="500"
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2"
placeholder="잘못된 기수/시나리오 설정 등 감사 기록에 남길 사유"
data-testid="cancellation-reason"
></textarea>
</label>
<label class="block text-sm text-zinc-300">
확인을 위해 <strong>{{ selectedProfileName }}</strong> 입력
<input
v-model="cancellationForm.confirmation"
class="mt-1 w-full rounded border border-red-800 bg-zinc-950 px-3 py-2 font-mono"
:placeholder="selectedProfileName"
data-testid="cancellation-confirmation"
/>
</label>
<button
type="submit"
class="w-full rounded bg-red-700 px-4 py-3 font-bold text-white hover:bg-red-600 disabled:cursor-not-allowed disabled:opacity-40"
:disabled="
submitting ||
Boolean(activeOperation) ||
cancellationForm.reason.trim().length < 5 ||
cancellationForm.confirmation.trim() !== selectedProfileName
"
data-testid="request-game-cancellation"
>
진행 게임 취소
</button>
</form>
</section>
<section v-if="mode !== 'gateway' && mode !== 'cancel'">
<form
class="rounded-lg border border-zinc-800 bg-zinc-900 p-5 space-y-5"
@submit.prevent="mode === 'scenario' ? requestReset() : requestDeploy()"
@@ -1178,7 +1329,9 @@ onBeforeUnmount(() => {
</td>
<td class="p-2 font-semibold">{{ operation.status }}</td>
<td class="hidden p-2 font-mono sm:table-cell">
<div class="truncate" :title="operation.sourceRef">{{ operation.sourceRef }}</div>
<div class="truncate" :title="operation.sourceRef">
{{ operation.sourceRef }}
</div>
</td>
<td class="hidden p-2 font-mono sm:table-cell">
{{ shortSha(operation.resolvedCommitSha) }}
@@ -1206,7 +1359,11 @@ onBeforeUnmount(() => {
data-testid="gateway-release-error-toggle"
@click="toggleGatewayReleaseError(operation.id)"
>
{{ expandedGatewayErrorOperationId === operation.id ? '오류 닫기' : '오류 보기' }}
{{
expandedGatewayErrorOperationId === operation.id
? '오류 닫기'
: '오류 보기'
}}
</button>
</div>
</td>
@@ -1227,7 +1384,7 @@ onBeforeUnmount(() => {
<div class="mb-2 text-xs font-semibold text-red-300">오류 상세</div>
<pre
class="whitespace-pre-wrap break-all font-mono text-xs leading-5 text-red-200"
>{{ operation.error }}</pre>
>{{ operation.error }}</pre>
</div>
</td>
</tr>