feat: 정식 사용자의 오픈 요청과 동의 기반 예약을 영속화한다

This commit is contained in:
2026-10-23 00:18:45 +09:00
parent 856187b315
commit 236f1bc4ed
24 changed files with 2333 additions and 142 deletions
+2
View File
@@ -131,6 +131,8 @@ export const getAdminCapability = (permission: string): AdminCapabilityDefinitio
CAPABILITY_BY_PERMISSION.get(permission);
export const resolveAdminActionCapability = (path: string, rawInput?: unknown): string | undefined => {
if (path.endsWith('.openRequests.decide')) return 'admin.scenarios.reset';
if (path.endsWith('.openRequests.updatePolicy')) return 'admin.profiles.settings';
if (path.endsWith('.users.createLocal')) return 'admin.users.create';
if (path.includes('.users.')) return 'admin.users.manage';
if (path.includes('.system.')) return 'admin.notice.manage';
+48 -64
View File
@@ -7,6 +7,16 @@ import { gatewayProfileCapabilities } from '@sammo-ts/common';
import type { GatewayPrisma } from '@sammo-ts/infra';
import { procedure, router } from './trpc.js';
import { zOpenRequestPolicy } from './openRequests/policy.js';
import { actOnOpenRequest, updateOpenRequestPolicy } from './openRequests/service.js';
import {
isAllowedTurnTerm,
zInstallOptions,
zOperationInstallOptions,
zProfileResetDefaults,
buildResetOtherTextInfo,
SYSTEM_PROFILE_RESET_DEFAULTS,
} from './resetOptions.js';
import {
listScenarioPreviews,
resolveGitBranchCommitSha,
@@ -51,7 +61,6 @@ const zSpecialAccessProfile = z
.min(1)
.max(64)
.regex(/^[a-z0-9_-]+(?::[a-zA-Z0-9._-]+)?$/);
const zJoinMode = z.enum(['full', 'onlyRandom']);
const zServerAction = z.enum([
'RESUME',
'PAUSE',
@@ -441,15 +450,6 @@ const zLocalAccountInput = z.object({
displayName: z.string().min(2).max(40).optional(),
});
const zInstallAutorun = z.object({
limitMinutes: z.number().int().min(0).max(43200),
options: z.array(z.enum(AUTORUN_USER_OPTIONS)),
});
// Ref aligns a 12-month game year inside a 24-hour wall-clock day. That makes
// every positive divisor of 120 valid, including 3 and 8 minutes.
const isAllowedTurnTerm = (value: number): boolean => Number.isInteger(value) && value > 0 && 120 % value === 0;
const zRuntimeSettings = z
.object({
turnTermMinutes: z
@@ -479,60 +479,6 @@ const isUniqueConstraintError = (error: unknown): boolean =>
const isProfileOperationConflictError = (error: unknown): boolean =>
error instanceof GatewayProfileOperationConflictError || isUniqueConstraintError(error);
const zInstallOptions = z.object({
scenarioId: z.number().int().min(0),
turnTermMinutes: z
.number()
.int()
.refine((value) => isAllowedTurnTerm(value), {
message: '턴 시간은 120의 약수여야 합니다.',
}),
sync: z.boolean(),
fiction: z.number().int().min(0).max(1),
extend: z.boolean(),
blockGeneralCreate: z.number().int().min(0).max(2),
npcMode: z.number().int().min(0).max(2),
showImgLevel: z.number().int().min(0).max(3),
tournamentTrig: z.boolean(),
joinMode: zJoinMode,
autorunUser: zInstallAutorun.nullable().optional(),
openAt: z.string().datetime().optional(),
preopenAt: z.string().datetime().optional(),
gitRef: z.string().min(1).max(128).optional(),
});
const zOperationInstallOptions = zInstallOptions.omit({ gitRef: true });
const zProfileResetDefaults = zInstallOptions.omit({
scenarioId: true,
openAt: true,
preopenAt: true,
gitRef: true,
});
const SYSTEM_PROFILE_RESET_DEFAULTS: z.infer<typeof zProfileResetDefaults> = {
turnTermMinutes: 60,
sync: true,
fiction: 1,
extend: true,
blockGeneralCreate: 0,
npcMode: 0,
showImgLevel: 3,
tournamentTrig: true,
joinMode: 'full',
autorunUser: null,
};
const buildResetOtherTextInfo = (install: z.infer<typeof zOperationInstallOptions>): string => {
const settings: string[] = [];
if (!install.sync) settings.push('시간동기화 없음');
if (!install.extend) settings.push('확장 NPC 미포함');
if (install.blockGeneralCreate === 1) settings.push('장수 생성 불가');
if (install.blockGeneralCreate === 2) settings.push('장수명 무작위');
if (install.joinMode === 'onlyRandom') settings.push('랜덤 임관');
if (install.showImgLevel !== SYSTEM_PROFILE_RESET_DEFAULTS.showImgLevel) {
settings.push(['이미지 표시 안함', '전콘 표시', '전콘/병종 표시'][install.showImgLevel] ?? '이미지 표시');
}
if (!install.tournamentTrig) settings.push('토너먼트 수동 시작');
return settings.join(', ');
};
const zSourceMode = z.enum(['BRANCH', 'COMMIT']);
const zResetSourceMode = z.enum(['CURRENT', 'BRANCH', 'COMMIT']);
@@ -631,6 +577,44 @@ const applyMetaPatch = (
};
export const adminRouter = router({
openRequests: router({
permissions: adminProcedure
.input(z.object({ profileName: z.string().min(1).max(64) }))
.query(({ ctx, input }) => ({
canAccept: hasScopedPermission(requireAdminAuth(ctx), ROLE_ADMIN_SCENARIO_RESET, input.profileName),
canManagePolicy: hasScopedPermission(
requireAdminAuth(ctx),
ROLE_ADMIN_PROFILE_SETTINGS,
input.profileName
),
})),
decide: adminProcedure
.input(z.object({ id: z.string().uuid(), action: z.enum(['ACCEPT', 'REJECT']) }).strict())
.mutation(async ({ ctx, input }) => {
const request = await ctx.prisma.gatewayOpenRequest.findUnique({
where: { id: input.id },
select: { profileName: true },
});
if (!request) throw new TRPCError({ code: 'NOT_FOUND', message: '오픈 요청을 찾을 수 없습니다.' });
const auth = requireAdminAuth(ctx);
assertPermission(auth, ROLE_ADMIN_SCENARIO_RESET, request.profileName);
return actOnOpenRequest(ctx, auth.user, input.id, input.action);
}),
updatePolicy: adminProcedure
.input(
z
.object({
profileName: z.string().min(1).max(64),
policy: zOpenRequestPolicy,
reason: z.string().trim().min(3).max(200),
})
.strict()
)
.mutation(async ({ ctx, input }) => {
assertPermission(requireAdminAuth(ctx), ROLE_ADMIN_PROFILE_SETTINGS, input.profileName);
return updateOpenRequestPolicy(ctx, input.profileName, input.policy, requireAdminAuth(ctx).user.id);
}),
}),
capabilities: router({
list: adminProcedure.query(({ ctx }) => {
const adminAuth = requireAdminAuth(ctx);
@@ -0,0 +1,38 @@
import { z } from 'zod';
export const zOpenRequestPolicy = z
.object({
enabled: z.boolean().default(true),
lifetimeDays: z.number().int().min(1).max(365).default(7),
buildLeadMinutes: z.number().int().min(1).max(10080).default(10),
// null means opener approval only, including CHE by default.
endorsementThreshold: z.number().int().min(1).max(10000).nullable().default(null),
})
.strict();
export interface OpenRequestPolicy {
enabled: boolean;
lifetimeDays: number;
buildLeadMinutes: number;
endorsementThreshold: number | null;
}
export const readOpenRequestPolicy = (meta: Record<string, unknown>): OpenRequestPolicy =>
zOpenRequestPolicy.parse(meta.openRequestPolicy ?? {});
export const canRequestOpening = (status: string): boolean => ['COMPLETED', 'STOPPED', 'CANCELLED'].includes(status);
export const openingSeasonKey = (profile: { currentScenario: string | null; openAt?: string | Date | null }): string =>
JSON.stringify([profile.currentScenario, profile.openAt ? new Date(profile.openAt).toISOString() : null]);
export const canExecuteOpeningReservation = (
profile: { status: string; currentScenario: string | null; openAt?: string | Date | null },
seasonKey: unknown,
openAt: unknown,
now: Date
): boolean =>
canRequestOpening(profile.status) &&
openingSeasonKey(profile) === seasonKey &&
typeof openAt === 'string' &&
Number.isFinite(Date.parse(openAt)) &&
Date.parse(openAt) > now.getTime();
@@ -0,0 +1,48 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { procedure, router } from '../trpc.js';
import { isGatewaySessionCurrent } from '../auth/sessionValidity.js';
import type { GatewayApiContext } from '../context.js';
import { actOnOpenRequest, createOpenRequest, listOpenRequests, zOpeningInstall } from './service.js';
const memberProcedure = procedure.use(async ({ ctx, next }) => {
const header = ctx.requestHeaders['x-session-token'];
const token = Array.isArray(header) ? header[0] : header;
const session = token ? await ctx.sessions.getSession(token) : null;
const user = session ? await ctx.users.findById(session.userId) : null;
if (!session || !user || !isGatewaySessionCurrent(session, user)) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: '로그인이 필요합니다.' });
}
return next({ ctx: { ...ctx, openingUser: user } });
});
const visibleProfile = async (ctx: GatewayApiContext, userId: string, profileName: string): Promise<void> => {
const profiles = await ctx.profileStatus.listLobbyProfiles({ userId });
if (!profiles.some((profile) => profile.profileName === profileName))
throw new TRPCError({ code: 'NOT_FOUND', message: '서버를 찾을 수 없습니다.' });
};
export const openRequestsRouter = router({
list: memberProcedure
.input(z.object({ profileName: z.string().min(1).max(64) }).strict())
.query(async ({ ctx, input }) => {
await visibleProfile(ctx, ctx.openingUser.id, input.profileName);
return listOpenRequests(ctx, ctx.openingUser, input.profileName);
}),
create: memberProcedure
.input(z.object({ profileName: z.string().min(1).max(64), install: zOpeningInstall }).strict())
.mutation(async ({ ctx, input }) => {
await visibleProfile(ctx, ctx.openingUser.id, input.profileName);
return createOpenRequest(ctx, ctx.openingUser, input.profileName, input.install);
}),
act: memberProcedure
.input(z.object({ id: z.string().uuid(), action: z.enum(['ENDORSE', 'WITHDRAW']) }).strict())
.mutation(async ({ ctx, input }) => {
const request = await ctx.prisma.gatewayOpenRequest.findUnique({
where: { id: input.id },
select: { profileName: true },
});
if (!request) throw new TRPCError({ code: 'NOT_FOUND', message: '요청을 찾을 수 없습니다.' });
await visibleProfile(ctx, ctx.openingUser.id, request.profileName);
return actOnOpenRequest(ctx, ctx.openingUser, input.id, input.action);
}),
});
+392
View File
@@ -0,0 +1,392 @@
import { isDeepStrictEqual } from 'node:util';
import { TRPCError } from '@trpc/server';
import { isGameAccessBlocked } from '@sammo-ts/common/auth/sanctions';
import type { GatewayPrisma, GatewayPrismaClient } from '@sammo-ts/infra';
import type { GatewayApiContext } from '../context.js';
import type { UserRecord } from '../auth/userRepository.js';
import { resolveLocalAccountProfilePolicy } from '../auth/localAccountPolicy.js';
import { CONTROL_PLANE_OPERATION_CLAIM_LOCK } from '../orchestrator/profileRepository.js';
import { readProfileReleaseSource } from '../orchestrator/profileReleaseSource.js';
import { buildResetOtherTextInfo, zOperationInstallOptions } from '../resetOptions.js';
import {
listScenarioPreviews,
resolveGitBranchCommitSha,
resolveGitCommitSha,
supportsProfileReleaseBuild,
} from '../scenario/scenarioCatalog.js';
import { canRequestOpening, openingSeasonKey, readOpenRequestPolicy, type OpenRequestPolicy } from './policy.js';
import { z } from 'zod';
export const zOpeningInstall = zOperationInstallOptions.required({ preopenAt: true, openAt: true }).strict();
export type OpeningInstall = z.infer<typeof zOpeningInstall>;
export interface OpenRequestResult {
id: string;
status: string;
}
export interface OpenRequestList {
policy: OpenRequestPolicy;
canRequest: boolean;
requests: Array<{
id: string;
status: string;
requesterName: string;
isMine: boolean;
announcement: { scenarioTitle: string; defaultStatTotal: number };
install: OpeningInstall;
expiresAt: string;
createdAt: string;
endorsementCount: number;
endorsed: boolean;
canEndorse: boolean;
operationStatus: string | null;
}>;
}
type Profile = NonNullable<Awaited<ReturnType<GatewayPrismaClient['gatewayProfile']['findUnique']>>>;
type Request = NonNullable<Awaited<ReturnType<GatewayPrismaClient['gatewayOpenRequest']['findUnique']>>>;
const fail = (message: string, code: 'BAD_REQUEST' | 'CONFLICT' | 'FORBIDDEN' | 'NOT_FOUND' = 'BAD_REQUEST'): never => {
throw new TRPCError({ code, message });
};
// Authentication grace is deliberately not full membership. Special grants must apply to this profile.
export const isOpeningMember = async (
ctx: GatewayApiContext,
user: UserRecord,
profile: Profile,
now: Date
): Promise<boolean> => {
if (user.deleteAfter || isGameAccessBlocked(user.sanctions, [profile.profile, profile.profileName], now))
return false;
if (user.oauthType === 'KAKAO' && user.oauthId?.trim() && user.kakaoVerifiedAt) return true;
const policy = resolveLocalAccountProfilePolicy({
profile: profile.profile,
profileName: profile.profileName,
profileMeta: profile.meta as Record<string, unknown>,
defaultGraceDays: ctx.localAccountGraceDays,
user,
specialAccessGrants: await ctx.users.listSpecialAccessGrants(user.id),
now,
});
return policy.kakaoVerified || policy.specialAccess !== null;
};
const assertMember = async (ctx: GatewayApiContext, user: UserRecord, profile: Profile, now: Date): Promise<void> => {
const currentUser = await ctx.users.findById(user.id);
if (!currentUser || !(await isOpeningMember(ctx, currentUser, profile, now)))
fail('카카오 인증 또는 이 서버의 정식 접근 허용이 필요합니다.', 'FORBIDDEN');
};
const buildAt = (preopenAt: Date, lead: number): Date => new Date(preopenAt.getTime() - lead * 60_000);
const activeStatus = (request: Request, profile: Profile, now: Date): string => {
if (request.status !== 'PENDING') return request.status;
if (!canRequestOpening(profile.status) || request.seasonKey !== openingSeasonKey(profile)) return 'SUPERSEDED';
const policy = readOpenRequestPolicy(profile.meta as Record<string, unknown>);
if (request.expiresAt <= now || buildAt(request.preopenAt, policy.buildLeadMinutes) <= now) return 'EXPIRED';
return request.status;
};
export const listOpenRequests = async (
ctx: GatewayApiContext,
user: UserRecord,
profileName: string
): Promise<OpenRequestList> => {
const profile = await ctx.prisma.gatewayProfile.findUnique({ where: { profileName } });
if (!profile) return fail('서버를 찾을 수 없습니다.', 'NOT_FOUND');
const now = new Date();
const policy = readOpenRequestPolicy(profile.meta as Record<string, unknown>);
const member = await isOpeningMember(ctx, user, profile, now);
const requests = await ctx.prisma.gatewayOpenRequest.findMany({
where: {
profileName,
OR: [
{ status: 'PENDING', expiresAt: { gt: now } },
{ status: 'RESERVED', openAt: { gt: now } },
],
},
orderBy: [{ createdAt: 'desc' }, { id: 'asc' }],
take: 100,
include: {
requester: { select: { displayName: true } },
endorsements: { select: { userId: true } },
operation: { select: { status: true } },
},
});
const voters = await ctx.users.findByIds([
...new Set(requests.flatMap((request) => request.endorsements.map((vote) => vote.userId))),
]);
const qualifiedVoters = new Set<string>();
for (const voter of voters) {
if (await isOpeningMember(ctx, voter, profile, now)) qualifiedVoters.add(voter.id);
}
const activeOperation = await ctx.prisma.gatewayOperation.findFirst({
where: { profileName, status: { in: ['QUEUED', 'RUNNING'] } },
select: { id: true },
});
return {
policy,
canRequest: member && policy.enabled && canRequestOpening(profile.status) && !activeOperation,
requests: requests
.filter((request) => ['PENDING', 'RESERVED'].includes(activeStatus(request, profile, now)))
.map((request) => ({
id: request.id,
status: request.status,
requesterName: request.requester.displayName,
isMine: request.requestedBy === user.id,
announcement: z
.object({ scenarioTitle: z.string(), defaultStatTotal: z.number() })
.parse(request.announcement),
install: zOpeningInstall.parse(request.install),
expiresAt: request.expiresAt.toISOString(),
createdAt: request.createdAt.toISOString(),
endorsementCount: request.endorsements.filter((vote) => qualifiedVoters.has(vote.userId)).length,
endorsed: request.endorsements.some((vote) => vote.userId === user.id),
canEndorse:
member &&
policy.enabled &&
policy.endorsementThreshold !== null &&
request.requestedBy !== user.id &&
request.status === 'PENDING',
operationStatus: request.operation?.status ?? null,
})),
};
};
const inProfileTransaction = async <T>(
ctx: GatewayApiContext,
profileName: string,
run: (tx: GatewayPrisma.TransactionClient, profile: Profile, now: Date) => Promise<T>
): Promise<T> =>
ctx.prisma.$transaction(
async (tx) => {
// Same ordering as RESET admission/claim. This serializes consensus with operator operations.
await tx.$queryRaw`SELECT pg_advisory_xact_lock(hashtextextended(${CONTROL_PLANE_OPERATION_CLAIM_LOCK}, 0))::text`;
await tx.$queryRaw`SELECT "profile_name" FROM "gateway_profile" WHERE "profile_name" = ${profileName} FOR UPDATE`;
const profile = await tx.gatewayProfile.findUnique({ where: { profileName } });
if (!profile) return fail('서버를 찾을 수 없습니다.', 'NOT_FOUND');
const [{ now }] = await tx.$queryRaw<Array<{ now: Date }>>`SELECT clock_timestamp() AS now`;
return run(tx, profile, now!);
},
{ timeout: 60_000 }
);
export const createOpenRequest = async (
ctx: GatewayApiContext,
user: UserRecord,
profileName: string,
install: OpeningInstall
): Promise<OpenRequestResult> =>
inProfileTransaction(ctx, profileName, async (tx, profile, now) => {
await assertMember(ctx, user, profile, now);
const policy = readOpenRequestPolicy(profile.meta as Record<string, unknown>);
if (!policy.enabled || !canRequestOpening(profile.status))
return fail('통일되었거나 닫힌 서버에만 오픈 요청을 할 수 있습니다.', 'CONFLICT');
const preopenAt = new Date(install.preopenAt),
openAt = new Date(install.openAt);
const deadline = buildAt(preopenAt, policy.buildLeadMinutes);
if (deadline <= now || preopenAt >= openAt)
return fail('가오픈은 빌드 선행 시간 이후, 정식 오픈은 가오픈 이후여야 합니다.');
if (install.autorunUser && install.autorunUser.limitMinutes > 0 !== install.autorunUser.options.length > 0)
return fail('자율행동 시간과 항목을 함께 지정해 주세요.');
if (await tx.gatewayOperation.findFirst({ where: { profileName, status: { in: ['QUEUED', 'RUNNING'] } } }))
return fail('진행 중이거나 예약된 서버 작업이 있습니다.', 'CONFLICT');
const old = await tx.gatewayOpenRequest.findMany({ where: { profileName, status: 'PENDING' } });
for (const request of old) {
const status = activeStatus(request, profile, now);
if (status !== 'PENDING')
await tx.gatewayOpenRequest.update({ where: { id: request.id }, data: { status } });
else if (request.requestedBy === user.id) {
if (isDeepStrictEqual(request.install, install)) return { id: request.id, status: request.status };
return fail('기존 요청을 철회한 뒤 새 요청을 등록해 주세요.', 'CONFLICT');
}
}
if (old.filter((request) => activeStatus(request, profile, now) === 'PENDING').length >= 100)
return fail('이 서버의 대기 요청이 너무 많습니다.', 'CONFLICT');
if (!profile.buildCommitSha) return fail('서버의 시나리오 버전이 준비되지 않았습니다.');
const scenario = (await listScenarioPreviews({ gitRef: profile.buildCommitSha })).find(
(entry) => entry.id === install.scenarioId
);
if (!scenario) return fail('현재 서버에 없는 시나리오입니다.');
const expiresAt = new Date(Math.min(now.getTime() + policy.lifetimeDays * 86_400_000, deadline.getTime()));
const request = await tx.gatewayOpenRequest.create({
data: {
profileName,
requestedBy: user.id,
install,
preopenAt,
openAt,
expiresAt,
seasonKey: openingSeasonKey(profile),
announcement: { scenarioTitle: scenario.title, defaultStatTotal: scenario.defaultStatTotal },
},
});
return { id: request.id, status: request.status };
});
// Called only while holding the shared admission lock. All mutations, including the winning
// endorsement, sibling supersession, operation, and log either commit together or roll back.
const reserve = async (
ctx: GatewayApiContext,
tx: GatewayPrisma.TransactionClient,
profile: Profile,
request: Request,
actorId: string,
now: Date
) => {
const requester = await ctx.users.findById(request.requestedBy);
if (!requester || !(await isOpeningMember(ctx, requester, profile, now)))
return fail('요청자의 정식 참여 자격을 확인해 주세요.', 'CONFLICT');
if (
await tx.gatewayOperation.findFirst({
where: { profileName: profile.profileName, status: { in: ['QUEUED', 'RUNNING'] } },
})
)
return fail('진행 중이거나 예약된 서버 작업이 있습니다.', 'CONFLICT');
const policy = readOpenRequestPolicy(profile.meta as Record<string, unknown>);
const install = zOpeningInstall.parse(request.install);
const source = readProfileReleaseSource({
meta: profile.meta as GatewayPrisma.JsonObject,
buildCommitSha: profile.buildCommitSha ?? undefined,
});
if (!source) return fail('서버 패치 정책이 준비되지 않았습니다.');
const commit =
source.mode === 'BRANCH' ? await resolveGitBranchCommitSha(source.ref) : await resolveGitCommitSha(source.ref);
const scenario = (await listScenarioPreviews({ gitRef: commit })).find((entry) => entry.id === install.scenarioId);
if (!scenario || !(await supportsProfileReleaseBuild(commit)))
return fail('패치 정책의 버전에서 시나리오 또는 배포 빌드를 사용할 수 없습니다.');
const scheduledAt = buildAt(request.preopenAt, policy.buildLeadMinutes);
const [{ now: admissionNow }] = await tx.$queryRaw<Array<{ now: Date }>>`SELECT clock_timestamp() AS now`;
if (scheduledAt <= admissionNow! || request.expiresAt <= admissionNow!)
return fail('빌드 시작 시각이 지나 요청을 수락할 수 없습니다.', 'CONFLICT');
const operation = await tx.gatewayOperation.create({
data: {
profileName: profile.profileName,
type: 'RESET',
sourceMode: source.mode,
sourceRef: source.mode === 'COMMIT' ? commit : source.ref,
requestedBy: actorId,
scheduledAt,
payload: {
install,
requestedSource: 'CURRENT',
releaseSource: { ...source },
openRequestId: request.id,
openRequestSeasonKey: request.seasonKey,
publicAnnouncement: {
enabled: true,
scenarioId: scenario.id,
scenarioTitle: scenario.title,
defaultStatTotal: scenario.defaultStatTotal,
scheduledAt: scheduledAt.toISOString(),
preopenAt: install.preopenAt,
openAt: install.openAt,
turnTermMinutes: install.turnTermMinutes,
fictionMode: install.fiction === 1 ? '가상' : '사실',
npcMode: install.npcMode,
otherTextInfo: buildResetOtherTextInfo(install),
autorunUser: install.autorunUser ?? null,
},
},
reason: '사용자 오픈 요청 수락',
},
});
await tx.gatewayOperationLog.create({
data: {
operationId: operation.id,
level: 'INFO',
phase: 'queue',
message: '오픈 요청이 서버 패치 정책을 따르는 초기화 예약으로 전환되었습니다.',
},
});
await tx.gatewayOpenRequest.update({
where: { id: request.id },
data: { status: 'RESERVED', operationId: operation.id, decidedBy: actorId, decidedAt: now },
});
await tx.gatewayOpenRequest.updateMany({
where: { profileName: profile.profileName, status: 'PENDING', id: { not: request.id } },
data: { status: 'SUPERSEDED', decidedAt: now },
});
return { id: request.id, status: 'RESERVED' };
};
export const actOnOpenRequest = async (
ctx: GatewayApiContext,
user: UserRecord,
id: string,
action: 'ENDORSE' | 'WITHDRAW' | 'ACCEPT' | 'REJECT'
): Promise<OpenRequestResult> => {
const initial = await ctx.prisma.gatewayOpenRequest.findUnique({ where: { id } });
if (!initial) return fail('오픈 요청을 찾을 수 없습니다.', 'NOT_FOUND');
return inProfileTransaction(ctx, initial.profileName, async (tx, profile, now) => {
const request = await tx.gatewayOpenRequest.findUniqueOrThrow({ where: { id } });
if (action === 'ENDORSE') await assertMember(ctx, user, profile, now);
if (action === 'WITHDRAW' && request.requestedBy !== user.id)
return fail('자신의 요청만 철회할 수 있습니다.', 'FORBIDDEN');
if (request.status === 'RESERVED' && ['ACCEPT', 'ENDORSE'].includes(action))
return { id, status: request.status };
if (action === 'WITHDRAW' && request.status === 'WITHDRAWN') return { id, status: request.status };
if (activeStatus(request, profile, now) !== 'PENDING')
return fail('만료되었거나 이미 처리된 요청입니다.', 'CONFLICT');
if (action === 'WITHDRAW' || action === 'REJECT') {
const status = action === 'WITHDRAW' ? 'WITHDRAWN' : 'REJECTED';
await tx.gatewayOpenRequest.update({ where: { id }, data: { status, decidedBy: user.id, decidedAt: now } });
return { id, status };
}
const policy = readOpenRequestPolicy(profile.meta as Record<string, unknown>);
if (!policy.enabled) return fail('이 서버는 오픈 요청을 받지 않습니다.', 'CONFLICT');
if (action === 'ACCEPT') return reserve(ctx, tx, profile, request, user.id, now);
if (request.requestedBy === user.id) return fail('요청자는 자신의 요청에 동의할 수 없습니다.');
if (policy.endorsementThreshold === null) return fail('이 서버는 오프너 수락으로만 예약할 수 있습니다.');
await tx.gatewayOpenEndorsement.upsert({
where: { requestId_userId: { requestId: id, userId: user.id } },
create: { requestId: id, userId: user.id },
update: {},
});
const votes = await tx.gatewayOpenEndorsement.findMany({ where: { requestId: id } });
if (votes.length >= policy.endorsementThreshold) {
// Revoked, banned, deleted or unverified voters no longer contribute to automatic approval.
let valid = 0;
for (const voter of await ctx.users.findByIds(votes.map((vote) => vote.userId))) {
if (await isOpeningMember(ctx, voter, profile, now)) valid++;
}
if (valid >= policy.endorsementThreshold) return reserve(ctx, tx, profile, request, user.id, now);
}
return { id, status: 'PENDING' };
});
};
// Lowering the threshold also evaluates accumulated agreements immediately. Older eligible
// proposals win deterministically; the same admission lock prevents a parallel endorsement winner.
export const updateOpenRequestPolicy = async (
ctx: GatewayApiContext,
profileName: string,
policy: OpenRequestPolicy,
actorId: string
): Promise<OpenRequestPolicy> =>
inProfileTransaction(ctx, profileName, async (tx, profile, now) => {
const updated = await tx.gatewayProfile.update({
where: { profileName },
data: { meta: { ...(profile.meta as GatewayPrisma.JsonObject), openRequestPolicy: { ...policy } } },
});
if (policy.enabled && policy.endorsementThreshold !== null && canRequestOpening(updated.status)) {
const pending = await tx.gatewayOpenRequest.findMany({
where: { profileName, status: 'PENDING' },
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
include: { endorsements: { select: { userId: true } } },
});
for (const request of pending) {
if (
activeStatus(request, updated, now) !== 'PENDING' ||
request.endorsements.length < policy.endorsementThreshold
)
continue;
let valid = 0;
for (const voter of await ctx.users.findByIds(request.endorsements.map((vote) => vote.userId))) {
if (await isOpeningMember(ctx, voter, updated, now)) valid++;
}
if (valid >= policy.endorsementThreshold) {
await reserve(ctx, tx, updated, request, actorId, now);
break;
}
}
}
return policy;
});
@@ -1,3 +1,4 @@
import { canExecuteOpeningReservation } from '../openRequests/policy.js';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
@@ -1854,6 +1855,22 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
return;
}
if (operation.type === 'RESET' && typeof operation.payload.openRequestId === 'string') {
const install = isRecord(operation.payload.install) ? operation.payload.install : {};
if (
!canExecuteOpeningReservation(
profile,
operation.payload.openRequestSeasonKey,
install.openAt,
this.now()
)
) {
throw new Error(
'오픈 요청 이후 서버가 다시 열렸거나 기수가 변경되었거나 오픈 시각이 지나 예약을 실행할 수 없습니다.'
);
}
}
if (!operation.sourceMode || !operation.sourceRef) {
throw new Error('Operation source mode and ref are required.');
}
@@ -1958,7 +1975,10 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
commitSha,
assertLease,
operation.id,
readOperationReleaseSource(operation)
readOperationReleaseSource(operation),
typeof payload.openRequestId === 'string'
? { seasonKey: payload.openRequestSeasonKey, openAt: install.openAt }
: undefined
);
if (result.status === 'REQUESTED') {
const retryAt = new Date(this.now().getTime() + this.adminActionIntervalMs).toISOString();
@@ -2413,7 +2433,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
commitShaOverride?: string,
assertLease?: () => Promise<void>,
operationId?: string,
releaseSource?: ProfileReleaseSource
releaseSource?: ProfileReleaseSource,
communityReservation?: { seasonKey: unknown; openAt: unknown }
): Promise<GatewayAdminActionResult> {
const appendLog = async (
phase: string,
@@ -2525,6 +2546,24 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
);
return { status: 'FAILED', detail: 'selected workspace build failed' };
}
const assertCommunityReservation = async (): Promise<void> => {
if (!communityReservation) return;
const current = await this.repository.getProfile(profile.profileName);
if (
!current ||
!canExecuteOpeningReservation(
current,
communityReservation.seasonKey,
communityReservation.openAt,
this.now()
)
) {
throw new Error(
'오픈 요청 이후 서버 상태·기수가 변경되었거나 오픈 시각이 지나 초기화를 중단했습니다.'
);
}
};
await assertCommunityReservation();
await appendLog('seed', '선택 버전의 profile seed CLI를 확인합니다.');
await this.assertProfileSeedCli(workspace.root);
// A newly provisioned profile schema has no world_state row (or table) yet.
@@ -2568,6 +2607,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
const firstGameIdx = resolveProfileFirstGameIdx(profileMeta);
const baseSeason = readMetaNumber(normalizeMeta(seedInfo.meta), 'season');
const season = nextSeasonIdx ?? baseSeason ?? 1;
await assertCommunityReservation();
await updateClaimedProfile({ status: 'STOPPED' }, () =>
this.repository.updateStatus(profile.profileName, 'STOPPED')
);
@@ -13,7 +13,9 @@ export interface ProfileReleaseSource {
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === 'object' && !Array.isArray(value));
export const readProfileReleaseSource = (profile: GatewayProfileRecord): ProfileReleaseSource | null => {
export const readProfileReleaseSource = (
profile: Pick<GatewayProfileRecord, 'meta' | 'buildCommitSha'>
): ProfileReleaseSource | null => {
const stored = isRecord(profile.meta) && isRecord(profile.meta.releaseSource) ? profile.meta.releaseSource : null;
const mode = stored?.mode;
const ref = typeof stored?.ref === 'string' ? stored.ref.trim() : '';
+68
View File
@@ -0,0 +1,68 @@
import { z } from 'zod';
const AUTORUN_USER_OPTIONS = ['develop', 'warp', 'recruit', 'recruit_high', 'train', 'battle', 'chief'] as const;
const zJoinMode = z.enum(['full', 'onlyRandom']);
const zInstallAutorun = z.object({
limitMinutes: z.number().int().min(0).max(43200),
options: z.array(z.enum(AUTORUN_USER_OPTIONS)),
});
// Ref aligns a 12-month game year inside a 24-hour wall-clock day. That makes
// every positive divisor of 120 valid, including 3 and 8 minutes.
export const isAllowedTurnTerm = (value: number): boolean => Number.isInteger(value) && value > 0 && 120 % value === 0;
export const zInstallOptions = z.object({
scenarioId: z.number().int().min(0),
turnTermMinutes: z
.number()
.int()
.refine((value) => isAllowedTurnTerm(value), {
message: '턴 시간은 120의 약수여야 합니다.',
}),
sync: z.boolean(),
fiction: z.number().int().min(0).max(1),
extend: z.boolean(),
blockGeneralCreate: z.number().int().min(0).max(2),
npcMode: z.number().int().min(0).max(2),
showImgLevel: z.number().int().min(0).max(3),
tournamentTrig: z.boolean(),
joinMode: zJoinMode,
autorunUser: zInstallAutorun.nullable().optional(),
openAt: z.string().datetime().optional(),
preopenAt: z.string().datetime().optional(),
gitRef: z.string().min(1).max(128).optional(),
});
export const zOperationInstallOptions = zInstallOptions.omit({ gitRef: true });
export const zProfileResetDefaults = zInstallOptions.omit({
scenarioId: true,
openAt: true,
preopenAt: true,
gitRef: true,
});
export const SYSTEM_PROFILE_RESET_DEFAULTS: z.infer<typeof zProfileResetDefaults> = {
turnTermMinutes: 60,
sync: true,
fiction: 1,
extend: true,
blockGeneralCreate: 0,
npcMode: 0,
showImgLevel: 3,
tournamentTrig: true,
joinMode: 'full',
autorunUser: null,
};
export const buildResetOtherTextInfo = (install: z.infer<typeof zOperationInstallOptions>): string => {
const settings: string[] = [];
if (!install.sync) settings.push('시간동기화 없음');
if (!install.extend) settings.push('확장 NPC 미포함');
if (install.blockGeneralCreate === 1) settings.push('장수 생성 불가');
if (install.blockGeneralCreate === 2) settings.push('장수명 무작위');
if (install.joinMode === 'onlyRandom') settings.push('랜덤 임관');
if (install.showImgLevel !== SYSTEM_PROFILE_RESET_DEFAULTS.showImgLevel) {
settings.push(['이미지 표시 안함', '전콘 표시', '전콘/병종 표시'][install.showImgLevel] ?? '이미지 표시');
}
if (!install.tournamentTrig) settings.push('토너먼트 수동 시작');
return settings.join(', ');
};
+2
View File
@@ -11,6 +11,7 @@ import { procedure, router } from './trpc.js';
import { toPublicUser } from './auth/userRepository.js';
import type { UserOAuthInfo, UserRecord } from './auth/userRepository.js';
import { adminRouter } from './adminRouter.js';
import { openRequestsRouter } from './openRequests/router.js';
import { accountRouter } from './account/router.js';
import {
hasActiveSpecialAccountGrant,
@@ -130,6 +131,7 @@ const finishKakaoLoginOrRequestPasswordSetup = async <T extends 'login' | 'verif
};
export const appRouter = router({
openRequests: openRequestsRouter,
navigation: router({
get: procedure.query(({ ctx }) => ctx.navigationConfig.get()),
}),