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
+8
View File
@@ -66,6 +66,13 @@ export const ADMIN_CAPABILITIES: readonly AdminCapabilityDefinition[] = [
risk: 'CRITICAL',
scope: 'PROFILE',
},
{
permission: 'admin.games.cancel',
label: '진행 게임 취소',
description: '지정 profile의 진행 중 게임을 취소하고 기록과 유산 포인트를 정산합니다.',
risk: 'CRITICAL',
scope: 'PROFILE',
},
{
permission: 'admin.reset.schedule',
label: 'Profile 초기화 예약',
@@ -123,6 +130,7 @@ export const resolveAdminActionCapability = (path: string, rawInput?: unknown):
}
if (path.endsWith('.operations.requestDeploy')) return 'admin.profiles.deploy';
if (path.endsWith('.operations.requestReset')) return 'admin.scenarios.reset';
if (path.endsWith('.operations.requestGameCancellation')) return 'admin.games.cancel';
if (path.endsWith('.operations.requestRuntime')) return 'admin.profiles.runtime';
if (path.endsWith('.profiles.upsert') || path.endsWith('.profiles.updateMeta')) return 'admin.profiles.settings';
if (path.endsWith('.profiles.setStatus') || path.endsWith('.profiles.reconcileNow')) {
+82 -6
View File
@@ -60,6 +60,7 @@ const ROLE_ADMIN_PROFILE_RUNTIME = 'admin.profiles.runtime';
const ROLE_ADMIN_PROFILE_SETTINGS = 'admin.profiles.settings';
const ROLE_ADMIN_PROFILE_DEPLOY = 'admin.profiles.deploy';
const ROLE_ADMIN_SCENARIO_RESET = 'admin.scenarios.reset';
const ROLE_ADMIN_GAME_CANCEL = 'admin.games.cancel';
const ROLE_ADMIN_RELEASES = 'admin.releases.manage';
const ROLE_ADMIN_NOTICE = 'admin.notice.manage';
const ROLE_ADMIN_AUDIT = 'admin.audit.read';
@@ -1268,6 +1269,65 @@ export const adminRouter = router({
});
}
}),
requestGameCancellation: adminProcedure
.input(
z.object({
profileName: z.string().min(1),
historyMode: z.enum(['RETAIN_ABANDONED', 'DELETE']),
generalMode: z.enum(['RETAIN', 'DELETE']),
earnedPointRetentionPercent: z.number().int().min(0).max(100),
reason: z.string().trim().min(5).max(500),
})
)
.mutation(async ({ ctx, input }) => {
const adminAuth = requireAdminAuth(ctx);
assertPermission(adminAuth, ROLE_ADMIN_GAME_CANCEL, input.profileName);
const profile = await ctx.profiles.getProfile(input.profileName);
if (!profile) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' });
}
if (!['PREOPEN', 'RUNNING', 'PAUSED'].includes(profile.status)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Only a PREOPEN, RUNNING, or PAUSED game can be cancelled.',
});
}
const releaseState = await ctx.releases.getState();
const sourceRef = releaseState.activeCommitSha?.trim();
if (!sourceRef) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'The Gateway has no active release commit for the cancellation migration boundary.',
});
}
try {
const resolvedCommitSha = await resolveGitCommitSha(sourceRef);
return await ctx.profiles.createOperation({
profileName: input.profileName,
type: 'CANCEL_GAME',
sourceMode: 'COMMIT',
sourceRef: resolvedCommitSha,
payload: {
historyMode: input.historyMode,
generalMode: input.generalMode,
earnedPointRetentionPercent: input.earnedPointRetentionPercent,
} as GatewayPrisma.JsonObject,
reason: input.reason,
requestedBy: adminAuth.user.id,
});
} catch (error) {
if (!isUniqueConstraintError(error)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'The active Gateway release commit cannot be resolved for game cancellation.',
});
}
throw new TRPCError({
code: 'CONFLICT',
message: 'This profile already has a queued or running operation.',
});
}
}),
requestDeploy: adminProcedure
.input(
z.object({
@@ -1340,6 +1400,18 @@ export const adminRouter = router({
if (!profile) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' });
}
if (input.action === 'START' && !gatewayProfileCapabilities(profile.status).operatorResumable) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'This profile must be reset before it can be started.',
});
}
if (input.action === 'STOP' && !gatewayProfileCapabilities(profile.status).runtimeExpected) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Only a running profile can be stopped.',
});
}
try {
const operation = await ctx.profiles.createOperation({
profileName: input.profileName,
@@ -1367,9 +1439,11 @@ export const adminRouter = router({
const permission =
previous.type === 'RESET'
? ROLE_ADMIN_SCENARIO_RESET
: previous.type === 'DEPLOY'
? ROLE_ADMIN_PROFILE_DEPLOY
: ROLE_ADMIN_PROFILE_RUNTIME;
: previous.type === 'CANCEL_GAME'
? ROLE_ADMIN_GAME_CANCEL
: previous.type === 'DEPLOY'
? ROLE_ADMIN_PROFILE_DEPLOY
: ROLE_ADMIN_PROFILE_RUNTIME;
assertPermission(adminAuth, permission, previous.profileName);
const cancelled = await ctx.profiles.cancelOperation(input.id);
if (!cancelled) {
@@ -1389,9 +1463,11 @@ export const adminRouter = router({
const permission =
previous.type === 'RESET'
? ROLE_ADMIN_SCENARIO_RESET
: previous.type === 'DEPLOY'
? ROLE_ADMIN_PROFILE_DEPLOY
: ROLE_ADMIN_PROFILE_RUNTIME;
: previous.type === 'CANCEL_GAME'
? ROLE_ADMIN_GAME_CANCEL
: previous.type === 'DEPLOY'
? ROLE_ADMIN_PROFILE_DEPLOY
: ROLE_ADMIN_PROFILE_RUNTIME;
assertPermission(adminAuth, permission, previous.profileName);
if (previous.type === 'RESET') {
const payload = readMetaObject(previous.payload);
@@ -5,6 +5,14 @@ import { createHash, randomBytes, randomUUID } from 'node:crypto';
import { stripVTControlCharacters } from 'node:util';
import type { ScenarioInstallOptions } from '@sammo-ts/game-engine/scenario/scenarioSeeder.js';
import {
cancelGame as defaultCancelGame,
GAME_CANCELLATION_GENERAL_MODES,
GAME_CANCELLATION_HISTORY_MODES,
type GameCancellationGeneralMode,
type GameCancellationHistoryMode,
type GameCancellationResult,
} from '@sammo-ts/game-engine/scenario/gameCancellation.js';
import { gatewayProfileCapabilities } from '@sammo-ts/common';
import {
createGamePostgresConnector,
@@ -56,6 +64,7 @@ export interface GatewayOrchestratorOptions {
now?: () => Date;
fetchImpl?: typeof fetch;
clearTournamentRuntimeState?: (profileName: string) => Promise<void>;
cancelGame?: typeof defaultCancelGame;
}
export interface ProfileRuntimeState {
@@ -596,6 +605,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
private readonly now: () => Date;
private readonly fetchImpl: typeof fetch;
private readonly clearTournamentRuntimeState: (profileName: string) => Promise<void>;
private readonly cancelGame: typeof defaultCancelGame;
private reconcileTimer?: NodeJS.Timeout;
private scheduleTimer?: NodeJS.Timeout;
private buildTimer?: NodeJS.Timeout;
@@ -627,6 +637,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
this.clearTournamentRuntimeState =
options.clearTournamentRuntimeState ??
((profileName) => this.clearTournamentRuntimeStateFromRedis(profileName));
this.cancelGame = options.cancelGame ?? defaultCancelGame;
}
private sanitizeOperationLogMessage(message: string): string {
@@ -1029,6 +1040,9 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
let resolvedCommitSha: string | undefined;
try {
if (operation.type === 'START') {
if (!gatewayProfileCapabilities(profile.status).operatorResumable) {
throw new Error(`Profile status ${profile.status} cannot be started by an operator.`);
}
await this.appendOperationLog(operation.id, 'runtime', '프로필 process를 시작합니다.');
const updated = await updateOperationProfile(
{
@@ -1066,6 +1080,9 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
return;
}
if (operation.type === 'STOP') {
if (!gatewayProfileCapabilities(profile.status).runtimeExpected && profile.status !== 'STOPPED') {
throw new Error(`Profile status ${profile.status} cannot be stopped by an operator.`);
}
await this.appendOperationLog(operation.id, 'runtime', '프로필 process를 정지합니다.');
await updateOperationProfile({ status: 'STOPPED' }, () =>
this.repository.updateStatus(profile.profileName, 'STOPPED')
@@ -1082,7 +1099,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
}
if (!operation.sourceMode || !operation.sourceRef) {
throw new Error('Reset source mode and ref are required.');
throw new Error('Operation source mode and ref are required.');
}
await this.appendOperationLog(
operation.id,
@@ -1105,6 +1122,48 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
}
await this.appendOperationLog(operation.id, 'resolve', `대상 커밋을 ${commitSha}로 고정했습니다.`);
await assertLease();
if (operation.type === 'CANCEL_GAME') {
const payload = normalizeMeta(operation.payload);
const historyMode = payload.historyMode;
const generalMode = payload.generalMode;
const retention = payload.earnedPointRetentionPercent;
if (
typeof historyMode !== 'string' ||
!GAME_CANCELLATION_HISTORY_MODES.includes(historyMode as GameCancellationHistoryMode) ||
typeof generalMode !== 'string' ||
!GAME_CANCELLATION_GENERAL_MODES.includes(generalMode as GameCancellationGeneralMode) ||
typeof retention !== 'number' ||
!Number.isInteger(retention) ||
retention < 0 ||
retention > 100 ||
!operation.reason
) {
throw new Error('Game cancellation payload is invalid.');
}
const result = await this.handleGameCancellation(
profile,
operation,
commitSha,
{
historyMode: historyMode as GameCancellationHistoryMode,
generalMode: generalMode as GameCancellationGeneralMode,
earnedPointRetentionPercent: retention,
},
assertLease
);
await this.appendOperationLog(
operation.id,
'complete',
`게임 취소가 완료되었습니다. 참여자 ${result.participantCount}명, 보존 장수 ${result.preservedGeneralCount}명.`
);
await this.repository.completeOperation(
operation.id,
'SUCCEEDED',
{ resolvedCommitSha: commitSha, error: null },
this.operationLeaseOwner
);
return;
}
if (operation.type === 'DEPLOY') {
const result = await this.handleProfileDeploy(profile, commitSha, assertLease, operation.id);
if (!result.ok) {
@@ -1186,6 +1245,115 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
}
}
private async handleGameCancellation(
profile: GatewayProfileRecord,
operation: GatewayOperationRecord,
commitSha: string,
options: {
historyMode: GameCancellationHistoryMode;
generalMode: GameCancellationGeneralMode;
earnedPointRetentionPercent: number;
},
assertLease: () => Promise<void>
): Promise<GameCancellationResult> {
if (!['PREOPEN', 'RUNNING', 'PAUSED', 'STOPPED'].includes(profile.status)) {
throw new Error(`Profile status ${profile.status} cannot be cancelled.`);
}
if (this.buildInFlight) throw new Error('build already in progress');
this.buildInFlight = true;
let runtimeStopped = profile.status === 'STOPPED';
let cancellationCommitted = false;
const updateClaimedProfile = async (patch: GatewayClaimedProfileUpdate): Promise<GatewayProfileRecord> => {
if (!this.repository.updateProfileForOperation) {
throw new Error('Game cancellation requires lease-fenced profile updates.');
}
const updated = await this.repository.updateProfileForOperation(
operation.id,
this.operationLeaseOwner,
profile.profileName,
patch
);
if (!updated) {
throw new OperationLeaseLostError(`Operation lease lost while cancelling game: ${operation.id}`);
}
return updated;
};
try {
await this.appendOperationLog(
operation.id,
'build',
'현재 profile 커밋의 취소 도구와 migration을 준비합니다.'
);
const { result: buildResult, workspace } = await this.runBuildCommands(commitSha, profile, operation.id);
await assertLease();
if (!buildResult.ok) throw new Error(buildResult.output.slice(-4000) || 'profile build failed');
const databaseUrl = this.resolveProfileDatabaseUrl(profile);
await this.appendOperationLog(operation.id, 'migration', '게임 취소 schema migration을 적용합니다.');
const migration = await this.runProfileMigration(
workspace.root,
databaseUrl,
this.buildProgress(operation.id, 'migration')
);
await assertLease();
if (!migration.ok) throw new Error(migration.output.slice(-4000) || 'profile database migration failed');
if (!runtimeStopped) {
await updateClaimedProfile({ status: 'STOPPED' });
await this.appendOperationLog(
operation.id,
'runtime',
'쓰기 차단을 위해 profile process를 정지합니다.'
);
await this.stopProfile(profile, assertLease);
runtimeStopped = true;
}
await assertLease();
await this.appendOperationLog(
operation.id,
'settlement',
'기수·장수 기록과 유산 포인트를 원자적으로 정산합니다.'
);
const result = await this.cancelGame({
cancellationId: operation.id,
databaseUrl,
cancelledBy: operation.requestedBy,
reason: operation.reason ?? '',
...options,
cancelledAt: this.now(),
});
cancellationCommitted = true;
await assertLease();
await updateClaimedProfile({
status: 'CANCELLED',
preopenAt: null,
openAt: null,
scheduledStartAt: null,
lastError: null,
});
await this.appendOperationLog(
operation.id,
'publish',
`profile을 재개할 수 없는 CANCELLED 상태로 전환했습니다. 취소 ID: ${result.cancellationId}`
);
return result;
} catch (error) {
if (error instanceof OperationLeaseLostError) throw error;
if (runtimeStopped && !cancellationCommitted && profile.status !== 'STOPPED') {
try {
await updateClaimedProfile({ status: profile.status, lastError: null });
await this.startProfile(profile, assertLease);
runtimeStopped = false;
} catch {
// The original cancellation failure remains authoritative.
}
}
throw error;
} finally {
this.buildInFlight = false;
}
}
private async handleProfileDeploy(
profile: GatewayProfileRecord,
commitSha: string,
@@ -8,7 +8,7 @@ export { GATEWAY_PROFILE_STATUSES, type GatewayProfileStatus };
export const GATEWAY_BUILD_STATUSES = ['IDLE', 'QUEUED', 'RUNNING', 'FAILED', 'SUCCEEDED'] as const;
export type GatewayBuildStatus = (typeof GATEWAY_BUILD_STATUSES)[number];
export type GatewayOperationType = 'RESET' | 'DEPLOY' | 'START' | 'STOP';
export type GatewayOperationType = 'RESET' | 'DEPLOY' | 'CANCEL_GAME' | 'START' | 'STOP';
export type GatewayOperationStatus = 'QUEUED' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'CANCELLED';