chore: sync backend and deployment changes

This commit is contained in:
2026-05-25 17:25:52 +09:00
parent d38d022872
commit fb5ec649cd
58 changed files with 17575 additions and 378 deletions

View File

@@ -1,5 +1,7 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { env } from '../config/env.js';
import { getSharedResourceTokenDetailBySharePath } from '../services/shared-resource-token-service.js';
import {
getChatContextSettingsConfig,
getAppConfigSnapshot,
@@ -14,6 +16,124 @@ import {
upsertAutomationContextsConfig,
} from '../services/automation-context-config-service.js';
import { getAutomationTypesConfig, upsertAutomationTypesConfig } from '../services/automation-type-config-service.js';
import {
getTokenSettingsConfig,
getTokenSettingById,
upsertTokenSettingsConfig,
type TokenSettingRecord,
} from '../services/token-setting-config-service.js';
const CHAT_SHARE_PATH_PREFIX = '/chat/share/';
function getRequestAccessToken(request: { headers: Record<string, string | string[] | undefined> }) {
const tokenHeader = request.headers['x-access-token'];
return Array.isArray(tokenHeader) ? tokenHeader[0]?.trim() ?? '' : String(tokenHeader ?? '').trim();
}
function getRequestChatShareToken(request: { headers: Record<string, string | string[] | undefined> }) {
const tokenHeader = request.headers['x-chat-share-token'];
return Array.isArray(tokenHeader) ? tokenHeader[0]?.trim() ?? '' : String(tokenHeader ?? '').trim();
}
function resolveChatSharePath(token: string) {
return `${CHAT_SHARE_PATH_PREFIX}${encodeURIComponent(token)}`;
}
type TokenSettingsAccessContext =
| { scope: 'full' }
| { scope: 'shared'; tokenSetting: TokenSettingRecord };
type AppConfigAccessContext =
| { scope: 'full' }
| { scope: 'shared' };
async function resolveTokenSettingsAccessContext(
request: { headers: Record<string, string | string[] | undefined> },
) {
if (getRequestAccessToken(request) === env.SERVER_COMMAND_ACCESS_TOKEN) {
return { scope: 'full' } satisfies TokenSettingsAccessContext;
}
const shareToken = getRequestChatShareToken(request);
if (!shareToken) {
return null;
}
const managedResource = await getSharedResourceTokenDetailBySharePath(resolveChatSharePath(shareToken));
if (!managedResource || managedResource.token.enabled === false || managedResource.token.revokedAt) {
return null;
}
if (managedResource.token.resourceType === 'chat-share') {
return null;
}
const allowedAppIds = managedResource.token.allowedAppIds ?? [];
const normalizedAllowedAppIds = new Set(allowedAppIds.map((item) => item.trim().toLowerCase()).filter(Boolean));
const hasManagePermission = managedResource.token.permissions.includes('manage');
const canOpenTokenSetting = normalizedAllowedAppIds.has('token-setting');
const tokenSettingId = managedResource.token.tokenSettingId?.trim() ?? '';
if (!hasManagePermission || !canOpenTokenSetting || !tokenSettingId) {
return null;
}
const tokenSetting = await getTokenSettingById(tokenSettingId);
if (!tokenSetting) {
return null;
}
return { scope: 'shared', tokenSetting } satisfies TokenSettingsAccessContext;
}
async function resolveAppConfigAccessContext(
request: { headers: Record<string, string | string[] | undefined> },
) {
if (getRequestAccessToken(request) === env.SERVER_COMMAND_ACCESS_TOKEN) {
return { scope: 'full' } satisfies AppConfigAccessContext;
}
const shareToken = getRequestChatShareToken(request);
if (!shareToken) {
return null;
}
const managedResource = await getSharedResourceTokenDetailBySharePath(resolveChatSharePath(shareToken));
if (!managedResource || managedResource.token.enabled === false || managedResource.token.revokedAt) {
return null;
}
if (managedResource.token.resourceType === 'chat-share') {
return null;
}
const allowedAppIds = managedResource.token.allowedAppIds ?? [];
const normalizedAllowedAppIds = new Set(allowedAppIds.map((item) => item.trim().toLowerCase()).filter(Boolean));
const hasManagePermission = managedResource.token.permissions.includes('manage');
const canOpenAppSettings = normalizedAllowedAppIds.has('app-settings');
if (!hasManagePermission || !canOpenAppSettings) {
return null;
}
return { scope: 'shared' } satisfies AppConfigAccessContext;
}
function sendTokenSettingsAccessDenied(
reply: { code: (statusCode: number) => { send: (payload: { message: string }) => unknown } },
) {
reply.code(403).send({
message: '권한 토큰 또는 토큰관리 관리 권한이 있는 공유 링크에서만 토큰 설정을 사용할 수 있습니다.',
});
}
function sendAppConfigAccessDenied(
reply: { code: (statusCode: number) => { send: (payload: { message: string }) => unknown } },
) {
reply.code(403).send({
message: '권한 토큰 또는 앱 설정 관리 권한이 있는 공유 링크에서만 앱 설정을 사용할 수 있습니다.',
});
}
function getRequestAppOrigin(request: { headers: Record<string, string | string[] | undefined> }) {
const rawAppOrigin = request.headers['x-app-origin'];
@@ -50,7 +170,15 @@ function getRequestAppDomain(request: { headers: Record<string, string | string[
}
export async function registerAppConfigRoutes(app: FastifyInstance) {
app.get('/api/app-config', async (request) => {
app.get('/api/app-config', async (request, reply) => {
const accessContext = await resolveAppConfigAccessContext(request);
const hasShareToken = Boolean(getRequestChatShareToken(request));
if (hasShareToken && !accessContext) {
sendAppConfigAccessDenied(reply);
return;
}
const appOrigin = getRequestAppOrigin(request);
const config = await getAppConfigSnapshot(appOrigin);
@@ -96,6 +224,22 @@ export async function registerAppConfigRoutes(app: FastifyInstance) {
};
});
app.get('/api/token-settings', async (request, reply) => {
const accessContext = await resolveTokenSettingsAccessContext(request);
if (!accessContext) {
sendTokenSettingsAccessDenied(reply);
return;
}
const tokenSettings =
accessContext.scope === 'full' ? await getTokenSettingsConfig() : [accessContext.tokenSetting];
return {
ok: true,
tokenSettings,
};
});
app.put('/api/chat-types', async (request, reply) => {
try {
let payload: unknown = request.body ?? {};
@@ -219,7 +363,67 @@ export async function registerAppConfigRoutes(app: FastifyInstance) {
}
});
app.put('/api/token-settings', async (request, reply) => {
const accessContext = await resolveTokenSettingsAccessContext(request);
if (!accessContext) {
sendTokenSettingsAccessDenied(reply);
return;
}
try {
let payload: unknown = request.body ?? {};
if (typeof payload === 'string') {
try {
payload = JSON.parse(payload);
} catch {
payload = {};
}
}
const parsed = z.object({
tokenSettings: z.array(z.unknown()),
}).parse(payload ?? {});
const nextTokenSettingsInput = parsed.tokenSettings as Partial<TokenSettingRecord>[];
const savedTokenSettings =
accessContext.scope === 'full'
? await upsertTokenSettingsConfig(nextTokenSettingsInput)
: await (async () => {
const authorizedSettingId = accessContext.tokenSetting.id;
const requestedSetting = nextTokenSettingsInput.find(
(item) => typeof item?.id === 'string' && item.id.trim().toLowerCase() === authorizedSettingId,
);
if (!requestedSetting) {
throw new Error('공유 링크에서는 현재 연결된 토큰 설정만 저장할 수 있습니다.');
}
const currentTokenSettings = await getTokenSettingsConfig();
const nextTokenSettings = currentTokenSettings.map((item) =>
item.id === authorizedSettingId ? { ...requestedSetting, id: authorizedSettingId } : item,
);
return upsertTokenSettingsConfig(nextTokenSettings);
})();
return {
ok: true,
tokenSettings: accessContext.scope === 'full' ? savedTokenSettings : savedTokenSettings.filter((item) => item.id === accessContext.tokenSetting.id),
};
} catch (error) {
return reply.code(409).send({
message: error instanceof Error ? error.message : '토큰 설정 저장에 실패했습니다.',
});
}
});
app.put('/api/app-config', async (request, reply) => {
const accessContext = await resolveAppConfigAccessContext(request);
if (!accessContext) {
sendAppConfigAccessDenied(reply);
return;
}
try {
let payload: unknown = request.body ?? {};

View File

@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { resolveStaticContentType } from './chat.js';
import { resolveStaticContentType, shouldAutoCompleteShareReplyParentVerification } from './chat.js';
test('resolveStaticContentType returns html content type for chat resource html files', () => {
assert.equal(resolveStaticContentType('/tmp/sample.html'), 'text/html; charset=utf-8');
@@ -11,3 +11,41 @@ test('resolveStaticContentType keeps plain text content type for code resources'
assert.equal(resolveStaticContentType('/tmp/sample.ts'), 'text/plain; charset=utf-8');
assert.equal(resolveStaticContentType('/tmp/sample.diff'), 'text/plain; charset=utf-8');
});
test('shouldAutoCompleteShareReplyParentVerification only completes answered requests that are not already verified', () => {
assert.equal(
shouldAutoCompleteShareReplyParentVerification({
responseMessageId: 101,
responseText: '',
manualVerificationCompletedAt: null,
}),
true,
);
assert.equal(
shouldAutoCompleteShareReplyParentVerification({
responseMessageId: null,
responseText: '답변 본문',
manualVerificationCompletedAt: null,
}),
true,
);
assert.equal(
shouldAutoCompleteShareReplyParentVerification({
responseMessageId: null,
responseText: '',
manualVerificationCompletedAt: null,
}),
false,
);
assert.equal(
shouldAutoCompleteShareReplyParentVerification({
responseMessageId: 102,
responseText: '답변 본문',
manualVerificationCompletedAt: '2026-05-24T06:00:00.000Z',
}),
false,
);
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,57 @@
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
import test from 'node:test';
import Fastify from 'fastify';
import { env } from '../config/env.js';
import { registerResourceManagerRoutes, resolveSingleRange } from './resource-manager.js';
const fallbackResourceRoot = path.resolve(process.cwd(), '../../../resource');
test('resolveSingleRange parses open-ended and suffix byte ranges', () => {
assert.deepEqual(resolveSingleRange('bytes=5-', 20), {
isValid: true,
start: 5,
end: 19,
});
assert.deepEqual(resolveSingleRange('bytes=-4', 20), {
isValid: true,
start: 16,
end: 19,
});
});
test('resolveSingleRange rejects malformed or out-of-bounds values', () => {
assert.deepEqual(resolveSingleRange('bytes=', 20), { isValid: false });
assert.deepEqual(resolveSingleRange('bytes=25-30', 20), { isValid: false });
assert.deepEqual(resolveSingleRange('bytes=4-3', 20), { isValid: false });
});
test('resource manager preview serves 206 partial content for byte ranges', async () => {
const app = Fastify();
await registerResourceManagerRoutes(app);
const relativePath = `range-test-${Date.now()}.wav`;
const absolutePath = path.join(fallbackResourceRoot, relativePath);
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
await fs.writeFile(absolutePath, Buffer.from('0123456789', 'utf8'));
try {
const response = await app.inject({
method: 'GET',
url: `/api/resource-manager/preview/${encodeURIComponent(relativePath)}?token=${encodeURIComponent(env.SERVER_COMMAND_ACCESS_TOKEN)}`,
headers: {
range: 'bytes=2-5',
},
});
assert.equal(response.statusCode, 206);
assert.equal(response.headers['accept-ranges'], 'bytes');
assert.equal(response.headers['content-range'], 'bytes 2-5/10');
assert.equal(response.headers['content-length'], '4');
assert.equal(response.body, '2345');
} finally {
await fs.rm(absolutePath, { force: true });
await app.close();
}
});

View File

@@ -48,6 +48,51 @@ const copyMoveBodySchema = z.object({
nextName: z.string().trim().max(255).optional().nullable(),
});
export function resolveSingleRange(rangeHeader: string | undefined, fileSize: number) {
const rangeValue = String(rangeHeader ?? '').trim();
if (!rangeValue) {
return null;
}
const match = /^bytes=(\d*)-(\d*)$/u.exec(rangeValue);
if (!match) {
return { isValid: false } as const;
}
const [, startRaw, endRaw] = match;
if (!startRaw && !endRaw) {
return { isValid: false } as const;
}
if (!startRaw) {
const suffixLength = Number(endRaw);
if (!Number.isInteger(suffixLength) || suffixLength <= 0) {
return { isValid: false } as const;
}
const start = Math.max(fileSize - suffixLength, 0);
const end = fileSize - 1;
return start <= end ? { isValid: true, start, end } as const : { isValid: false } as const;
}
const start = Number(startRaw);
const end = endRaw ? Number(endRaw) : fileSize - 1;
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || start >= fileSize) {
return { isValid: false } as const;
}
return {
isValid: true,
start,
end: Math.min(end, fileSize - 1),
} as const;
}
function getRequestAccessToken(request: FastifyRequest) {
const tokenHeader = request.headers['x-access-token'];
return Array.isArray(tokenHeader) ? tokenHeader[0]?.trim() ?? '' : String(tokenHeader ?? '').trim();
@@ -123,10 +168,29 @@ export async function registerResourceManagerRoutes(app: FastifyInstance) {
const wildcard = String((request.params as { '*': string | undefined })['*'] ?? '').trim();
const preview = await openResourceManagerPreviewStream(resolveRepoRootPath(), decodeURIComponent(wildcard));
const rangeHeader = Array.isArray(request.headers.range) ? request.headers.range[0] : request.headers.range;
const range = resolveSingleRange(rangeHeader, preview.size);
reply.header('Cache-Control', 'no-store');
reply.header('Accept-Ranges', 'bytes');
reply.type(preview.contentType);
return reply.send(preview.stream);
if (range) {
if (!range.isValid) {
reply.status(416);
reply.header('Content-Range', `bytes */${preview.size}`);
return reply.send();
}
const contentLength = range.end - range.start + 1;
reply.status(206);
reply.header('Content-Range', `bytes ${range.start}-${range.end}/${preview.size}`);
reply.header('Content-Length', String(contentLength));
return reply.send(preview.createStream({ start: range.start, end: range.end }));
}
reply.header('Content-Length', String(preview.size));
return reply.send(preview.createStream());
});
app.post('/api/resource-manager/directories', async (request, reply) => {

View File

@@ -39,10 +39,16 @@ function getImmediateRestartBlockInfo(
return null;
}
if (key === 'work-server' && automationPendingCount > 0) {
if (key === 'work-server') {
const pendingCount = codexPendingCount + automationPendingCount;
if (pendingCount === 0) {
return null;
}
return {
pendingCount: automationPendingCount,
message: `진행 중인 자동화 작업 ${automationPendingCount}건이 있어 즉시 재기동할 수 없습니다. 재기동 예약을 사용해 주세요.`,
pendingCount,
message: `진행 중인 Codex Live/자동화 작업 ${pendingCount}건이 있어 즉시 재기동할 수 없습니다. 재기동 예약을 사용해 주세요.`,
};
}

View File

@@ -0,0 +1,337 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { env } from '../config/env.js';
import {
deleteSharedResourceTokens,
deleteSharedResourceToken,
getSharedResourceTokenDetail,
getSharedResourceTokenDetailBySharePath,
listSharedResourceTokens,
recordSharedResourceTokenUsage,
restoreSharedResourceToken,
revokeSharedResourceToken,
revokeSharedResourceTokens,
sharedResourceTokenSchema,
upsertSharedResourceToken,
} from '../services/shared-resource-token-service.js';
function getRequestAccessToken(request: { headers: Record<string, string | string[] | undefined> }) {
const tokenHeader = request.headers['x-access-token'];
return Array.isArray(tokenHeader) ? tokenHeader[0]?.trim() ?? '' : String(tokenHeader ?? '').trim();
}
function getRequestChatShareToken(request: { headers: Record<string, string | string[] | undefined> }) {
const tokenHeader = request.headers['x-chat-share-token'];
return Array.isArray(tokenHeader) ? tokenHeader[0]?.trim() ?? '' : String(tokenHeader ?? '').trim();
}
function resolveChatSharePath(token: string) {
return `/chat/share/${encodeURIComponent(token)}`;
}
type SharedResourceTokenAccessContext =
| { scope: 'full' }
| { scope: 'shared'; tokenId: string };
async function resolveSharedResourceTokenAccessContext(
request: { headers: Record<string, string | string[] | undefined> },
) {
if (getRequestAccessToken(request) === env.SERVER_COMMAND_ACCESS_TOKEN) {
return { scope: 'full' } satisfies SharedResourceTokenAccessContext;
}
const shareToken = getRequestChatShareToken(request);
if (!shareToken) {
return null;
}
const managedResource = await getSharedResourceTokenDetailBySharePath(resolveChatSharePath(shareToken));
if (!managedResource || managedResource.token.enabled === false || managedResource.token.revokedAt) {
return null;
}
const allowedAppIds = managedResource.token.allowedAppIds ?? [];
const normalizedAllowedAppIds = new Set(allowedAppIds.map((item) => item.trim().toLowerCase()).filter(Boolean));
if (!managedResource.token.permissions.includes('manage') || !normalizedAllowedAppIds.has('shared-resource')) {
return null;
}
return {
scope: 'shared',
tokenId: managedResource.token.id,
} satisfies SharedResourceTokenAccessContext;
}
function sendAccessDenied(
reply: { code: (statusCode: number) => { send: (payload: { message: string }) => unknown } },
) {
reply.code(403).send({
message: '권한 토큰 또는 shared-resource 관리 권한이 있는 공유 링크에서만 공유 리소스 관리를 사용할 수 있습니다.',
});
}
function isAllowedSharedTokenTarget(accessContext: SharedResourceTokenAccessContext, tokenId: string) {
return accessContext.scope === 'full' || accessContext.tokenId === tokenId;
}
export async function registerSharedResourceTokenRoutes(app: FastifyInstance) {
app.get('/api/shared-resource-tokens', async (request, reply) => {
const accessContext = await resolveSharedResourceTokenAccessContext(request);
if (!accessContext) {
sendAccessDenied(reply);
return;
}
const sharedTokenDetail =
accessContext.scope === 'shared' ? await getSharedResourceTokenDetail(accessContext.tokenId) : null;
const items =
accessContext.scope === 'full'
? await listSharedResourceTokens()
: sharedTokenDetail?.token
? [sharedTokenDetail.token]
: [];
return {
ok: true,
items,
};
});
app.get('/api/shared-resource-tokens/:tokenId', async (request, reply) => {
const accessContext = await resolveSharedResourceTokenAccessContext(request);
if (!accessContext) {
sendAccessDenied(reply);
return;
}
const tokenId = z.string().trim().min(1).parse((request.params as { tokenId: string }).tokenId);
if (!isAllowedSharedTokenTarget(accessContext, tokenId)) {
return reply.code(403).send({
message: '현재 공유 링크로는 이 공유 토큰 상세를 열 수 없습니다.',
});
}
const item = await getSharedResourceTokenDetail(tokenId);
if (!item) {
return reply.code(404).send({
message: '공유 리소스 토큰을 찾을 수 없습니다.',
});
}
return {
ok: true,
...item,
};
});
app.put('/api/shared-resource-tokens', async (request, reply) => {
const accessContext = await resolveSharedResourceTokenAccessContext(request);
if (!accessContext) {
sendAccessDenied(reply);
return;
}
try {
const payload = sharedResourceTokenSchema.parse(request.body ?? {});
if (accessContext.scope === 'shared' && payload.id !== accessContext.tokenId) {
return reply.code(403).send({
message: '현재 공유 링크에서는 연결된 공유 토큰 상세만 수정할 수 있습니다.',
});
}
const saved = await upsertSharedResourceToken(payload);
return {
ok: true,
...saved,
};
} catch (error) {
return reply.code(409).send({
message: error instanceof Error ? error.message : '공유 리소스 토큰 저장에 실패했습니다.',
});
}
});
app.post('/api/shared-resource-tokens/bulk-revoke', async (request, reply) => {
const accessContext = await resolveSharedResourceTokenAccessContext(request);
if (!accessContext) {
sendAccessDenied(reply);
return;
}
const payload = z
.object({
tokenIds: z.array(z.string().trim().min(1)).min(1).max(500),
reason: z.string().trim().max(500).optional().nullable(),
})
.parse(request.body ?? {});
if (accessContext.scope === 'shared' && payload.tokenIds.some((tokenId) => tokenId !== accessContext.tokenId)) {
return reply.code(403).send({
message: '현재 공유 링크에서는 연결된 공유 토큰만 회수할 수 있습니다.',
});
}
const result = await revokeSharedResourceTokens(payload.tokenIds, payload.reason);
return {
ok: true,
...result,
};
});
app.post('/api/shared-resource-tokens/:tokenId/revoke', async (request, reply) => {
const accessContext = await resolveSharedResourceTokenAccessContext(request);
if (!accessContext) {
sendAccessDenied(reply);
return;
}
const tokenId = z.string().trim().min(1).parse((request.params as { tokenId: string }).tokenId);
if (!isAllowedSharedTokenTarget(accessContext, tokenId)) {
return reply.code(403).send({
message: '현재 공유 링크에서는 연결된 공유 토큰만 회수할 수 있습니다.',
});
}
const payload = z
.object({
reason: z.string().trim().max(500).optional().nullable(),
})
.parse(request.body ?? {});
const saved = await revokeSharedResourceToken(tokenId, payload.reason);
if (!saved) {
return reply.code(404).send({
message: '회수할 공유 리소스 토큰을 찾을 수 없습니다.',
});
}
return {
ok: true,
...saved,
};
});
app.post('/api/shared-resource-tokens/:tokenId/restore', async (request, reply) => {
const accessContext = await resolveSharedResourceTokenAccessContext(request);
if (!accessContext) {
sendAccessDenied(reply);
return;
}
const tokenId = z.string().trim().min(1).parse((request.params as { tokenId: string }).tokenId);
if (!isAllowedSharedTokenTarget(accessContext, tokenId)) {
return reply.code(403).send({
message: '현재 공유 링크에서는 연결된 공유 토큰만 복원할 수 있습니다.',
});
}
const saved = await restoreSharedResourceToken(tokenId);
if (!saved) {
return reply.code(404).send({
message: '복원할 공유 리소스 토큰을 찾을 수 없습니다.',
});
}
return {
ok: true,
...saved,
};
});
app.post('/api/shared-resource-tokens/:tokenId/usage', async (request, reply) => {
const accessContext = await resolveSharedResourceTokenAccessContext(request);
if (!accessContext) {
sendAccessDenied(reply);
return;
}
const tokenId = z.string().trim().min(1).parse((request.params as { tokenId: string }).tokenId);
if (!isAllowedSharedTokenTarget(accessContext, tokenId)) {
return reply.code(403).send({
message: '현재 공유 링크에서는 연결된 공유 토큰만 기록할 수 있습니다.',
});
}
const payload = z
.object({
actorLabel: z.string().trim().max(120).optional().nullable(),
summary: z.string().trim().max(400).optional().nullable(),
detail: z.string().trim().max(2000).optional().nullable(),
usageDelta: z.number().int().min(1).max(1_000_000).optional().nullable(),
})
.parse(request.body ?? {});
const saved = await recordSharedResourceTokenUsage(tokenId, payload);
if (!saved) {
return reply.code(404).send({
message: '사용량을 기록할 공유 리소스 토큰을 찾을 수 없습니다.',
});
}
return {
ok: true,
...saved,
};
});
app.post('/api/shared-resource-tokens/bulk-delete', async (request, reply) => {
const accessContext = await resolveSharedResourceTokenAccessContext(request);
if (!accessContext) {
sendAccessDenied(reply);
return;
}
const payload = z
.object({
tokenIds: z.array(z.string().trim().min(1)).min(1).max(500),
})
.parse(request.body ?? {});
if (accessContext.scope === 'shared' && payload.tokenIds.some((tokenId) => tokenId !== accessContext.tokenId)) {
return reply.code(403).send({
message: '현재 공유 링크에서는 연결된 공유 토큰만 삭제할 수 있습니다.',
});
}
const result = await deleteSharedResourceTokens(payload.tokenIds);
return {
ok: true,
...result,
};
});
app.delete('/api/shared-resource-tokens/:tokenId', async (request, reply) => {
const accessContext = await resolveSharedResourceTokenAccessContext(request);
if (!accessContext) {
sendAccessDenied(reply);
return;
}
const tokenId = z.string().trim().min(1).parse((request.params as { tokenId: string }).tokenId);
if (!isAllowedSharedTokenTarget(accessContext, tokenId)) {
return reply.code(403).send({
message: '현재 공유 링크에서는 연결된 공유 토큰만 삭제할 수 있습니다.',
});
}
const deleted = await deleteSharedResourceToken(tokenId);
if (!deleted) {
return reply.code(404).send({
message: '삭제할 공유 리소스 토큰을 찾을 수 없습니다.',
});
}
return {
ok: true,
deleted: true,
tokenId,
};
});
}

View File

@@ -0,0 +1,357 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { db } from '../db/client.js';
const TEST_APP_TABLE = 'test_app_maintenance_requests';
const TEST_APP_SEED_COUNT = 7000;
const PRIORITY_VALUES = ['긴급', '높음', '보통', '낮음'] as const;
const STATUS_VALUES = ['접수', '배정완료', '조치중', '부품대기', '완료'] as const;
const ISSUE_TYPES = ['센서 오차', '진동 이상', '누유 감지', '온도 상승', '부품 마모', '통신 장애'] as const;
const REQUESTERS = ['김민재', '박서윤', '이도윤', '최하린', '정서준', '한지민'] as const;
const ASSIGNEES = ['윤태호', '장우진', '서가은', '임현수', '강다온', '문시우'] as const;
const LINE_EQUIPMENT_MAP = {
PKG: ['실링기 1호', '실링기 2호', '포장로봇 1호', '라벨러 2호'],
MFG: ['혼합기 A', '혼합기 B', '압출기 3호', '컨베이어 7호'],
UTL: ['냉각펌프 1호', '공조기 2호', '콤프레서 1호', '보일러 1호'],
QC: ['비전검사기 1호', '중량선별기 2호', '샘플러 1호', '검사컨베이어 1호'],
} as const;
const LINE_CODES = Object.keys(LINE_EQUIPMENT_MAP) as Array<keyof typeof LINE_EQUIPMENT_MAP>;
const listQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(200).default(100),
keyword: z.string().trim().default(''),
lineCode: z.string().trim().optional(),
priority: z.string().trim().optional(),
status: z.string().trim().optional(),
requestedFrom: z.string().trim().optional(),
requestedTo: z.string().trim().optional(),
});
const updateItemSchema = z.object({
id: z.coerce.number().int().positive(),
priority: z.enum(PRIORITY_VALUES),
status: z.enum(STATUS_VALUES),
assigneeName: z.string().trim().max(80).optional(),
});
const saveBodySchema = z.object({
items: z.array(updateItemSchema).min(1).max(200),
});
const deleteParamsSchema = z.object({
id: z.coerce.number().int().positive(),
});
type TestAppRow = {
id: number;
request_no: string;
line_code: string;
equipment_name: string;
issue_type: (typeof ISSUE_TYPES)[number];
priority: (typeof PRIORITY_VALUES)[number];
requester_name: string;
assignee_name: string | null;
status: (typeof STATUS_VALUES)[number];
requested_at: Date | string;
last_action_at: Date | string;
};
function pad2(value: number) {
return String(value).padStart(2, '0');
}
function formatDateTime(value: Date | string) {
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`;
}
function toResponseRow(row: TestAppRow) {
return {
id: row.id,
requestNo: row.request_no,
lineCode: row.line_code,
equipmentName: row.equipment_name,
issueType: row.issue_type,
priority: row.priority,
requesterName: row.requester_name,
assigneeName: row.assignee_name ?? '',
status: row.status,
requestedAt: formatDateTime(row.requested_at),
lastActionAt: formatDateTime(row.last_action_at),
};
}
function pickPriority(index: number) {
const ratio = index % 100;
if (ratio < 6) {
return '긴급' as const;
}
if (ratio < 24) {
return '높음' as const;
}
if (ratio < 72) {
return '보통' as const;
}
return '낮음' as const;
}
function pickStatus(index: number, priority: (typeof PRIORITY_VALUES)[number]) {
const ratio = index % 100;
if (priority === '긴급') {
if (ratio < 28) {
return '접수' as const;
}
if (ratio < 54) {
return '배정완료' as const;
}
if (ratio < 86) {
return '조치중' as const;
}
if (ratio < 93) {
return '부품대기' as const;
}
return '완료' as const;
}
if (ratio < 22) {
return '접수' as const;
}
if (ratio < 45) {
return '배정완료' as const;
}
if (ratio < 70) {
return '조치중' as const;
}
if (ratio < 79) {
return '부품대기' as const;
}
return '완료' as const;
}
function buildSeedRow(index: number) {
const lineCode = LINE_CODES[index % LINE_CODES.length];
const equipmentList = LINE_EQUIPMENT_MAP[lineCode];
const equipmentName = equipmentList[Math.floor(index / LINE_CODES.length) % equipmentList.length];
const priority = pickPriority(index);
const status = pickStatus(index, priority);
const issueType = ISSUE_TYPES[(index * 3 + Math.floor(index / 7)) % ISSUE_TYPES.length];
const requesterName = REQUESTERS[(index * 5 + 1) % REQUESTERS.length];
const assigneeName = status === '접수' ? null : ASSIGNEES[(index * 7 + 2) % ASSIGNEES.length];
const requestedAt = new Date(Date.now() - ((index % (45 * 48)) * 30 + (index % 3) * 10) * 60_000);
const lastActionAt =
status === '접수'
? requestedAt
: new Date(requestedAt.getTime() + (((index % 9) + 1) * 45 + (priority === '긴급' ? 20 : 0)) * 60_000);
const requestNo = `JR-${requestedAt.getFullYear()}${pad2(requestedAt.getMonth() + 1)}${pad2(requestedAt.getDate())}-${String(1000 + index).padStart(4, '0')}`;
return {
request_no: requestNo,
line_code: lineCode,
equipment_name: equipmentName,
issue_type: issueType,
priority,
requester_name: requesterName,
assignee_name: assigneeName,
status,
requested_at: requestedAt,
last_action_at: lastActionAt,
};
}
async function ensureTestAppTable() {
const tableExists = await db.schema.hasTable(TEST_APP_TABLE);
if (!tableExists) {
await db.schema.createTable(TEST_APP_TABLE, (table) => {
table.increments('id').primary();
table.string('request_no', 40).notNullable().unique();
table.string('line_code', 20).notNullable();
table.string('equipment_name', 120).notNullable();
table.string('issue_type', 80).notNullable();
table.string('priority', 20).notNullable();
table.string('requester_name', 80).notNullable();
table.string('assignee_name', 80).nullable();
table.string('status', 40).notNullable();
table.timestamp('requested_at').notNullable().defaultTo(db.fn.now());
table.timestamp('last_action_at').notNullable().defaultTo(db.fn.now());
table.index(['requested_at', 'id'], 'test_app_requests_requested_at_idx');
table.index(['line_code', 'priority', 'status'], 'test_app_requests_filter_idx');
});
}
const countResult = await db(TEST_APP_TABLE).count<{ count: string }[]>({ count: '*' }).first();
const currentCount = Number(countResult?.count ?? 0);
if (currentCount >= TEST_APP_SEED_COUNT) {
return;
}
const chunkSize = 500;
const rowsToInsert = Array.from({ length: TEST_APP_SEED_COUNT - currentCount }, (_, offset) =>
buildSeedRow(currentCount + offset),
);
for (let index = 0; index < rowsToInsert.length; index += chunkSize) {
await db(TEST_APP_TABLE).insert(rowsToInsert.slice(index, index + chunkSize));
}
}
function applyListFilters(baseQuery: ReturnType<typeof db>, query: z.infer<typeof listQuerySchema>) {
if (query.keyword) {
baseQuery.where((builder) => {
builder
.whereILike('request_no', `%${query.keyword}%`)
.orWhereILike('equipment_name', `%${query.keyword}%`)
.orWhereILike('requester_name', `%${query.keyword}%`);
});
}
if (query.lineCode && query.lineCode !== '전체') {
baseQuery.where('line_code', query.lineCode);
}
if (query.priority && query.priority !== '전체') {
baseQuery.where('priority', query.priority);
}
if (query.status && query.status !== '전체') {
baseQuery.where('status', query.status);
}
if (query.requestedFrom) {
baseQuery.where('requested_at', '>=', new Date(`${query.requestedFrom}T00:00:00+09:00`));
}
if (query.requestedTo) {
baseQuery.where('requested_at', '<=', new Date(`${query.requestedTo}T23:59:59+09:00`));
}
}
async function handleListRequests(request: { query?: unknown }) {
await ensureTestAppTable();
const query = listQuerySchema.parse(request.query ?? {});
const offset = (query.page - 1) * query.pageSize;
const baseQuery = db(TEST_APP_TABLE);
applyListFilters(baseQuery, query);
const [countResult, rows] = await Promise.all([
baseQuery.clone().count<{ count: string }[]>({ count: '*' }).first(),
baseQuery
.clone()
.select<TestAppRow[]>('*')
.orderBy('requested_at', 'desc')
.orderBy('id', 'desc')
.limit(query.pageSize)
.offset(offset),
]);
const total = Number(countResult?.count ?? 0);
return {
ok: true,
items: rows.map(toResponseRow),
page: query.page,
pageSize: query.pageSize,
total,
hasNext: offset + rows.length < total,
filters: {
keyword: query.keyword,
lineCode: query.lineCode ?? '전체',
priority: query.priority ?? '전체',
status: query.status ?? '전체',
requestedFrom: query.requestedFrom ?? null,
requestedTo: query.requestedTo ?? null,
},
};
}
async function handleSaveRequests(request: { body?: unknown }) {
await ensureTestAppTable();
const payload = saveBodySchema.parse(request.body ?? {});
const ids = payload.items.map((item) => item.id);
const existingRows = await db(TEST_APP_TABLE).select<TestAppRow[]>('*').whereIn('id', ids);
const existingIdSet = new Set(existingRows.map((row) => row.id));
const missingIds = ids.filter((id) => !existingIdSet.has(id));
if (missingIds.length) {
const error = new Error(`저장할 요청을 찾을 수 없습니다: ${missingIds.join(', ')}`) as Error & { statusCode?: number };
error.statusCode = 404;
throw error;
}
const updatedRows: TestAppRow[] = [];
for (const item of payload.items) {
const [updatedRow] = await db(TEST_APP_TABLE)
.where({ id: item.id })
.update(
{
priority: item.priority,
status: item.status,
assignee_name: item.assigneeName?.trim() || null,
last_action_at: db.fn.now(),
},
'*',
);
if (updatedRow) {
updatedRows.push(updatedRow as TestAppRow);
}
}
return {
ok: true,
count: updatedRows.length,
items: updatedRows.map(toResponseRow),
};
}
async function handleDeleteRequest(request: { params?: unknown }) {
await ensureTestAppTable();
const { id } = deleteParamsSchema.parse(request.params ?? {});
const [deletedRow] = await db(TEST_APP_TABLE).where({ id }).delete('*');
if (!deletedRow) {
const error = new Error(`삭제할 요청을 찾을 수 없습니다: ${id}`) as Error & { statusCode?: number };
error.statusCode = 404;
throw error;
}
return {
ok: true,
deletedId: id,
item: toResponseRow(deletedRow as TestAppRow),
};
}
export async function registerTestAppRoutes(app: FastifyInstance) {
app.get('/api/test-app/maintenance-requests', handleListRequests);
app.get('/api/test-app/measurements', handleListRequests);
app.put('/api/test-app/maintenance-requests', handleSaveRequests);
app.put('/api/test-app/measurements', handleSaveRequests);
app.delete('/api/test-app/maintenance-requests/:id', handleDeleteRequest);
}