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
+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);