merge: 최종 main을 Profile 포괄 권한 제거에 통합
This commit is contained in:
@@ -117,6 +117,7 @@ export const resolveAdminActionCapability = (path: string, rawInput?: unknown):
|
||||
const action = (rawInput as { action?: unknown }).action;
|
||||
if (action === 'RESET_SCHEDULED') return 'admin.reset.schedule';
|
||||
if (action === 'RESUME') return 'admin.resume.when-stopped';
|
||||
if (action === 'UPDATE_RUNTIME_SETTINGS') return 'admin.profiles.runtime';
|
||||
if (action === 'OPEN_SURVEY') return 'admin.survey.open';
|
||||
return 'admin.profiles.runtime';
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ const zServerAction = z.enum([
|
||||
'STOP',
|
||||
'ACCELERATE',
|
||||
'DELAY',
|
||||
'UPDATE_RUNTIME_SETTINGS',
|
||||
'RESET_NOW',
|
||||
'RESET_SCHEDULED',
|
||||
'OPEN_SURVEY',
|
||||
@@ -428,6 +429,30 @@ const zInstallAutorun = z.object({
|
||||
});
|
||||
|
||||
const isAllowedTurnTerm = (value: number): boolean => TURN_TERM_MINUTES.some((term) => term === value);
|
||||
|
||||
const zRuntimeSettings = z
|
||||
.object({
|
||||
turnTermMinutes: z
|
||||
.number()
|
||||
.int()
|
||||
.refine((value) => isAllowedTurnTerm(value), {
|
||||
message: 'turnTermMinutes must divide 120.',
|
||||
})
|
||||
.optional(),
|
||||
blockGeneralCreate: z.union([z.literal(0), z.literal(1), z.literal(2)]).optional(),
|
||||
autorunUser: z
|
||||
.object({
|
||||
limitMinutes: z.number().int().min(1).max(43200),
|
||||
options: z.array(z.enum(AUTORUN_USER_OPTIONS)).min(1),
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
})
|
||||
.strict()
|
||||
.refine((settings) => Object.values(settings).some((value) => value !== undefined), {
|
||||
message: 'At least one runtime setting is required.',
|
||||
});
|
||||
|
||||
const isUniqueConstraintError = (error: unknown): boolean =>
|
||||
Boolean(error && typeof error === 'object' && 'code' in error && error.code === 'P2002');
|
||||
|
||||
@@ -1580,7 +1605,7 @@ export const adminRouter = router({
|
||||
canReadProfile(adminAuth, profile.profileName)
|
||||
);
|
||||
const profileNames = profiles.map((profile) => profile.profileName);
|
||||
const [runtimeActions, activeOperations] = await Promise.all([
|
||||
const [runtimeActions, activeOperations, runtimeSettings] = await Promise.all([
|
||||
ctx.prisma.gatewayRuntimeAction.findMany({
|
||||
where: { profileName: { in: profileNames } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
@@ -1592,6 +1617,7 @@ export const adminRouter = router({
|
||||
},
|
||||
select: { id: true, profileName: true, status: true },
|
||||
}),
|
||||
ctx.orchestrator.listRuntimeSettings?.(profileNames) ?? Promise.resolve([]),
|
||||
]);
|
||||
const activeOperationByProfile = new Map(
|
||||
activeOperations.map((operation) => [operation.profileName, operation])
|
||||
@@ -1608,9 +1634,11 @@ export const adminRouter = router({
|
||||
profiles.map((profile) => profile.profileName)
|
||||
);
|
||||
const runtimeMap = new Map(runtimeStates.map((state) => [state.profileName, state]));
|
||||
const runtimeSettingsMap = new Map(runtimeSettings.map((settings) => [settings.profileName, settings]));
|
||||
return profiles.map((profile) => ({
|
||||
...profile,
|
||||
runtimeActions: runtimeActionsByProfile.get(profile.profileName) ?? [],
|
||||
runtimeSettings: runtimeSettingsMap.get(profile.profileName) ?? null,
|
||||
activeOperation: activeOperationByProfile.get(profile.profileName) ?? null,
|
||||
runtime: runtimeMap.get(profile.profileName) ?? {
|
||||
profileName: profile.profileName,
|
||||
@@ -2006,6 +2034,7 @@ export const adminRouter = router({
|
||||
profileName: z.string().min(1),
|
||||
action: zServerAction,
|
||||
durationMinutes: z.number().int().min(1).max(1440).optional(),
|
||||
runtimeSettings: zRuntimeSettings.optional(),
|
||||
scheduledAt: z.string().datetime().optional(),
|
||||
reason: z.string().max(200).optional(),
|
||||
})
|
||||
@@ -2024,6 +2053,19 @@ export const adminRouter = router({
|
||||
message: 'durationMinutes is required for acceleration or delay.',
|
||||
});
|
||||
}
|
||||
if (input.action === 'UPDATE_RUNTIME_SETTINGS') {
|
||||
if (!input.runtimeSettings) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'runtimeSettings is required.' });
|
||||
}
|
||||
if (!input.reason || input.reason.trim().length < 3) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '변경 사유를 입력해 주세요.' });
|
||||
}
|
||||
} else if (input.runtimeSettings) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'runtimeSettings is not valid for this action.',
|
||||
});
|
||||
}
|
||||
if (input.scheduledAt) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
@@ -2084,6 +2126,19 @@ export const adminRouter = router({
|
||||
message: 'Survey permission is required.',
|
||||
});
|
||||
}
|
||||
} else if (input.action === 'UPDATE_RUNTIME_SETTINGS') {
|
||||
if (!gatewayProfileCapabilities(profile.status).runtimeExpected) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: '실행 중인 프로필에서만 현재 기수 설정을 바꿀 수 있습니다.',
|
||||
});
|
||||
}
|
||||
if (!canManageProfiles) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Profile management permission is required.',
|
||||
});
|
||||
}
|
||||
} else if (!canManageProfiles) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
@@ -2098,12 +2153,17 @@ export const adminRouter = router({
|
||||
});
|
||||
}
|
||||
|
||||
if (input.action === 'ACCELERATE' || input.action === 'DELAY') {
|
||||
if (
|
||||
input.action === 'ACCELERATE' ||
|
||||
input.action === 'DELAY' ||
|
||||
input.action === 'UPDATE_RUNTIME_SETTINGS'
|
||||
) {
|
||||
try {
|
||||
const runtimeAction = await ctx.prisma.gatewayRuntimeAction.create({
|
||||
data: {
|
||||
profileName: input.profileName,
|
||||
action: input.action,
|
||||
payload: input.runtimeSettings ? { settings: input.runtimeSettings } : {},
|
||||
durationMinutes: input.durationMinutes,
|
||||
reason: input.reason,
|
||||
requestedBy: adminAuth.user.id,
|
||||
@@ -2116,7 +2176,7 @@ export const adminRouter = router({
|
||||
}
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: '이 프로필의 이전 시간 조정 요청이 아직 처리 중입니다.',
|
||||
message: '이 프로필의 이전 런타임 변경 요청이 아직 처리 중입니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,23 @@ export interface ProfileRuntimeSnapshot extends ProfileRuntimeState {
|
||||
profileName: string;
|
||||
}
|
||||
|
||||
export interface ProfileRuntimeSettingsSnapshot {
|
||||
profileName: string;
|
||||
turnTermMinutes: number;
|
||||
blockGeneralCreate: 0 | 1 | 2;
|
||||
autorunUser: {
|
||||
limitMinutes: number;
|
||||
options: Array<'develop' | 'warp' | 'recruit' | 'recruit_high' | 'train' | 'battle' | 'chief'>;
|
||||
} | null;
|
||||
}
|
||||
|
||||
type RuntimeAutorunOption =
|
||||
NonNullable<ProfileRuntimeSettingsSnapshot['autorunUser']> extends {
|
||||
options: Array<infer Option>;
|
||||
}
|
||||
? Option
|
||||
: never;
|
||||
|
||||
export interface GatewayOrchestratorHandle {
|
||||
start(): void;
|
||||
stop(): Promise<void>;
|
||||
@@ -82,6 +99,7 @@ export interface GatewayOrchestratorHandle {
|
||||
skipped: string[];
|
||||
}>;
|
||||
listRuntimeStates(profileNames: string[]): Promise<ProfileRuntimeSnapshot[]>;
|
||||
listRuntimeSettings?(profileNames: string[]): Promise<ProfileRuntimeSettingsSnapshot[]>;
|
||||
}
|
||||
|
||||
const SENSITIVE_ENV_NAME = /(SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE_KEY|CLIENT_SECRET|DATABASE_URL|REDIS_URL)/iu;
|
||||
@@ -706,6 +724,67 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
|
||||
return mapRuntimeStates(profileNames, processStates);
|
||||
}
|
||||
|
||||
async listRuntimeSettings(profileNames: string[]): Promise<ProfileRuntimeSettingsSnapshot[]> {
|
||||
const allowedAutorunOptions = new Set<RuntimeAutorunOption>([
|
||||
'develop',
|
||||
'warp',
|
||||
'recruit',
|
||||
'recruit_high',
|
||||
'train',
|
||||
'battle',
|
||||
'chief',
|
||||
] as const);
|
||||
const snapshots = await Promise.all(
|
||||
profileNames.map(async (profileName): Promise<ProfileRuntimeSettingsSnapshot | null> => {
|
||||
const profile = await this.repository.getProfile(profileName);
|
||||
if (!profile || profile.currentScenario === null) return null;
|
||||
const connector = createGamePostgresConnector({ url: this.resolveProfileDatabaseUrl(profile) });
|
||||
try {
|
||||
await connector.connect();
|
||||
const row = await connector.prisma.worldState.findFirst({
|
||||
select: { tickSeconds: true, config: true, meta: true },
|
||||
});
|
||||
if (!row) return null;
|
||||
const config = isRecord(row.config) ? row.config : {};
|
||||
const meta = isRecord(row.meta) ? row.meta : {};
|
||||
const rawBlock = Number(config.blockGeneralCreate ?? 0);
|
||||
const blockGeneralCreate = ([0, 1, 2].includes(rawBlock) ? rawBlock : 0) as 0 | 1 | 2;
|
||||
const rawAutorun = isRecord(meta.autorun_user) ? meta.autorun_user : null;
|
||||
const limitMinutes = rawAutorun ? Number(rawAutorun.limit_minutes ?? 0) : 0;
|
||||
const rawOptions = rawAutorun
|
||||
? Array.isArray(rawAutorun.options)
|
||||
? rawAutorun.options
|
||||
: isRecord(rawAutorun.options)
|
||||
? Object.entries(rawAutorun.options)
|
||||
.filter(([, enabled]) => enabled === true)
|
||||
.map(([option]) => option)
|
||||
: []
|
||||
: [];
|
||||
const autorunOptions = rawOptions.filter(
|
||||
(
|
||||
option
|
||||
): option is 'develop' | 'warp' | 'recruit' | 'recruit_high' | 'train' | 'battle' | 'chief' =>
|
||||
typeof option === 'string' && allowedAutorunOptions.has(option as RuntimeAutorunOption)
|
||||
);
|
||||
return {
|
||||
profileName,
|
||||
turnTermMinutes: Math.max(1, Math.round(row.tickSeconds / 60)),
|
||||
blockGeneralCreate,
|
||||
autorunUser:
|
||||
Number.isInteger(limitMinutes) && limitMinutes > 0 && autorunOptions.length > 0
|
||||
? { limitMinutes, options: autorunOptions }
|
||||
: null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
await connector.disconnect().catch(() => undefined);
|
||||
}
|
||||
})
|
||||
);
|
||||
return snapshots.filter((snapshot): snapshot is ProfileRuntimeSettingsSnapshot => snapshot !== null);
|
||||
}
|
||||
|
||||
async reconcileNow(): Promise<void> {
|
||||
if (this.stopping || this.reconcileInFlight) {
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user