chore: test deploy snapshot

This commit is contained in:
2026-05-29 17:42:33 +09:00
parent 262ce4b627
commit 5b3e70910c
6 changed files with 3701 additions and 170 deletions

View File

@@ -1,5 +1,6 @@
import type { FastifyInstance } from 'fastify';
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import { env } from '../config/env.js';
import { db } from '../db/client.js';
type PlayAppEnvironment = 'preview' | 'test' | 'prod';
@@ -18,6 +19,23 @@ type PlayAppSeedEntry = {
};
const PLAY_APP_TABLE = 'play_apps';
function getRequestAccessToken(request: FastifyRequest) {
const tokenHeader = request.headers['x-access-token'];
return Array.isArray(tokenHeader) ? tokenHeader[0]?.trim() ?? '' : String(tokenHeader ?? '').trim();
}
function ensurePlayAppWriteAuthorized(request: FastifyRequest, reply: FastifyReply) {
if (getRequestAccessToken(request) === env.SERVER_COMMAND_ACCESS_TOKEN) {
return true;
}
reply.code(403).send({
message: '권한 토큰이 필요합니다.',
});
return false;
}
const DEFAULT_ENTRIES: PlayAppSeedEntry[] = [
{
id: 'baseball-ticket-bay',
@@ -546,15 +564,27 @@ export async function registerPlayAppRoutes(app: FastifyInstance) {
};
});
app.post('/api/play-apps', async (request) => {
app.post('/api/play-apps', async (request, reply) => {
if (!ensurePlayAppWriteAuthorized(request, reply)) {
return;
}
return createPlayAppEntry(request.body);
});
app.put('/api/play-apps/:id', async (request) => {
app.put('/api/play-apps/:id', async (request, reply) => {
if (!ensurePlayAppWriteAuthorized(request, reply)) {
return;
}
return updatePlayAppEntry(request.params, request.body);
});
app.delete('/api/play-apps/:id', async (request) => {
app.delete('/api/play-apps/:id', async (request, reply) => {
if (!ensurePlayAppWriteAuthorized(request, reply)) {
return;
}
return deletePlayAppEntry(request.params);
});
}

View File

@@ -1,7 +1,10 @@
import { DeleteOutlined, LinkOutlined, PlusOutlined, QrcodeOutlined, ReloadOutlined, SaveOutlined, StopOutlined, UnorderedListOutlined } from '@ant-design/icons';
import { Alert, App, Button, Card, Checkbox, Drawer, Empty, Flex, Form, Input, InputNumber, Modal, QRCode, Select, Space, Table, Tabs, Tag, Typography } from 'antd';
import { useEffect, useLayoutEffect, useMemo, useState, type Key, type MouseEvent as ReactMouseEvent } from 'react';
import { getReadyPlayAppEntries } from '../../views/play/apps/apps/appsRegistry';
import { useCallback, useEffect, useLayoutEffect, useMemo, useState, type Key, type MouseEvent as ReactMouseEvent } from 'react';
import {
getReadyPlayAppEntries,
loadPlayAppEntriesFromServer,
} from '../../views/play/apps/apps/appsRegistry';
import { copyTextToClipboard } from '../../utils/clipboard';
import { openExternalLinkInNewWindow } from './mainChatPanel/linkNavigation';
import { resolveChatPathForSession } from './chatSessionRouting';
@@ -56,18 +59,33 @@ const MANAGEMENT_APP_OPTIONS = [
{ value: 'error-log', label: '에러 로그', description: '앱 로그와 장애 이력 조회', category: '관리' },
] as const;
const PLAY_APP_OPTIONS = getReadyPlayAppEntries().map((entry) => ({
value: entry.id,
label: entry.name,
description: entry.searchDescription ?? `${entry.name} 앱 실행`,
category: 'Play' as const,
}));
const APP_OPTIONS = [...MANAGEMENT_APP_OPTIONS, ...PLAY_APP_OPTIONS];
const APP_OPTION_LABEL_MAP = new Map(APP_OPTIONS.map((option) => [option.value, option.label] as const));
const ADMIN_PERMISSION_PRESET: SharedResourcePermission[] = ['view', 'download', 'comment', 'upload', 'manage'];
const ADMIN_ALLOWED_APP_PRESET = MANAGEMENT_APP_OPTIONS.map((option) => option.value);
type AppOption = {
value: string;
label: string;
description: string;
category: '관리' | 'Play';
};
function buildPlayAppOptionsFromCache() {
return getReadyPlayAppEntries().map((entry) => ({
value: entry.id,
label: entry.name,
description: entry.searchDescription ?? `${entry.name} 앱 실행`,
category: 'Play' as const,
}));
}
function buildAppOptions() {
return [...MANAGEMENT_APP_OPTIONS, ...buildPlayAppOptionsFromCache()];
}
function buildAppOptionLabelMap(appOptions: AppOption[]) {
return new Map(appOptions.map((option) => [option.value, option.label] as const));
}
type SharedResourceManagementSharedPreview = {
managedResourceTokenId?: string | null;
sharePath?: string | null;
@@ -188,10 +206,6 @@ function formatPermissionLabel(permission: SharedResourcePermission) {
return PERMISSION_OPTIONS.find((option) => option.value === permission)?.label ?? permission;
}
function formatAppLabel(appId: string) {
return APP_OPTION_LABEL_MAP.get(appId) ?? appId;
}
function formatDurationMinutesLabel(totalMinutes: number | null | undefined) {
const normalizedMinutes = Number(totalMinutes ?? 0);
@@ -513,12 +527,34 @@ export function SharedResourceManagementPage({
const [detailData, setDetailData] = useState<{ token: SharedResourceTokenRecord; activities: SharedResourceTokenActivityRecord[] } | null>(null);
const [nowMs, setNowMs] = useState(() => Date.now());
const [activeDetailTab, setActiveDetailTab] = useState<'basic' | 'settings' | 'history'>('basic');
const [appOptions, setAppOptions] = useState<AppOption[]>(buildAppOptions);
const [selectedRowKeys, setSelectedRowKeys] = useState<Key[]>([]);
const [qrPreviewTokenId, setQrPreviewTokenId] = useState<string | null>(null);
const [conversationDrawer, setConversationDrawer] = useState<ConversationDrawerState | null>(null);
const [conversationDrawerKey, setConversationDrawerKey] = useState(0);
const [form] = Form.useForm<SharedResourceTokenFormValue>();
const [modalApi, modalContextHolder] = Modal.useModal();
const appOptionLabelMap = useMemo(() => buildAppOptionLabelMap(appOptions), [appOptions]);
const formatAppLabel = useCallback(
(appId: string) => appOptionLabelMap.get(appId) ?? appId,
[appOptionLabelMap],
);
useEffect(() => {
let active = true;
void loadPlayAppEntriesFromServer().then(() => {
if (!active) {
return;
}
setAppOptions(buildAppOptions());
});
return () => {
active = false;
};
}, []);
useLayoutEffect(() => {
if (disableInstallMetadata || typeof window === 'undefined') {
@@ -1604,13 +1640,13 @@ export function SharedResourceManagementPage({
</div>
</div>
<Form.Item
label={`앱 권한 ${APP_OPTIONS.length}`}
label={`앱 권한 ${appOptions.length}`}
name="allowedAppIds"
extra="공유 토큰 상세에서 최종 허용 앱 목록을 직접 저장합니다."
>
<Checkbox.Group style={{ width: '100%' }}>
<div className="shared-resource-management-page__permission-grid">
{APP_OPTIONS.map((option) => (
{appOptions.map((option) => (
<label key={option.value} className="shared-resource-management-page__permission-card">
<div className="shared-resource-management-page__permission-card-header">
<div className="shared-resource-management-page__permission-card-copy">

View File

@@ -1,7 +1,10 @@
import { DeleteOutlined, EditOutlined, PlusOutlined, SaveOutlined, UnorderedListOutlined } from '@ant-design/icons';
import { Alert, App, Button, Card, Checkbox, Descriptions, Empty, Form, Input, InputNumber, List, Modal, Space, Switch, Table, Tabs, Tag, Typography } from 'antd';
import { useEffect, useMemo, useRef, useState } from 'react';
import { getReadyPlayAppEntries } from '../../views/play/apps/apps/appsRegistry';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
getReadyPlayAppEntries,
loadPlayAppEntriesFromServer,
} from '../../views/play/apps/apps/appsRegistry';
import { confirmWithKeyboard } from './modalKeyboard';
import {
deleteTokenSetting,
@@ -66,16 +69,6 @@ const MANAGEMENT_APP_OPTIONS: AppOption[] = [
{ value: 'error-log', label: '에러 로그', description: '앱 로그와 장애 이력 조회', category: '관리' },
];
const PLAY_APP_OPTIONS: AppOption[] = getReadyPlayAppEntries().map((entry) => ({
value: entry.id,
label: entry.name,
description: entry.searchDescription ?? `${entry.name} 앱 실행`,
category: 'Play',
}));
const APP_OPTIONS: AppOption[] = [...MANAGEMENT_APP_OPTIONS, ...PLAY_APP_OPTIONS];
const APP_OPTION_LABEL_MAP = new Map(APP_OPTIONS.map((item) => [item.value, item.label] as const));
const EMPTY_FORM_VALUE: TokenSettingFormValue = {
id: '',
name: '',
@@ -175,8 +168,21 @@ function formatQuotaSummary(setting: TokenSettingRecord) {
return [`7일 ${formatTokenLimit(setting.maxTokensPer7Days)}`, `5시간 ${formatTokenLimit(setting.maxTokensPer5Hours)}`].join(' / ');
}
function resolveAppLabels(appIds: string[]) {
return appIds.map((item) => APP_OPTION_LABEL_MAP.get(item) ?? item);
function buildPlayAppOptionsFromCache() {
return getReadyPlayAppEntries().map((entry) => ({
value: entry.id,
label: entry.name,
description: entry.searchDescription ?? `${entry.name} 앱 실행`,
category: 'Play',
}));
}
function buildAppOptions() {
return [...MANAGEMENT_APP_OPTIONS, ...buildPlayAppOptionsFromCache()];
}
function buildAppOptionLabelMap(appOptions: AppOption[]) {
return new Map(appOptions.map((item) => [item.value, item.label] as const));
}
export function TokenSettingManagementPage({
@@ -223,11 +229,33 @@ export function TokenSettingManagementPage({
const [saveErrorMessage, setSaveErrorMessage] = useState('');
const [saveSuccessMessage, setSaveSuccessMessage] = useState('');
const [activeDetailTab, setActiveDetailTab] = useState<'basic' | 'quota' | 'apps' | 'history'>('basic');
const [appOptions, setAppOptions] = useState<AppOption[]>(buildAppOptions);
const [activities, setActivities] = useState<TokenSettingActivityRecord[]>([]);
const [isActivityLoading, setIsActivityLoading] = useState(false);
const [form] = Form.useForm<TokenSettingFormValue>();
const [modalApi, modalContextHolder] = Modal.useModal();
const lastHydratedFormKeyRef = useRef('');
const appOptionLabelMap = useMemo(() => buildAppOptionLabelMap(appOptions), [appOptions]);
const resolveAppLabelsMemo = useCallback(
(appIds: string[]) => appIds.map((item) => appOptionLabelMap.get(item) ?? item),
[appOptionLabelMap],
);
useEffect(() => {
let active = true;
void loadPlayAppEntriesFromServer().then(() => {
if (!active) {
return;
}
setAppOptions(buildAppOptions());
});
return () => {
active = false;
};
}, []);
const selectedTokenSetting = useMemo(
() => tokenSettings.find((item) => item.id === selectedTokenSettingId) ?? null,
@@ -491,9 +519,9 @@ export function TokenSettingManagementPage({
{item.description || '설명 없음'}
</div>
<Space size={[6, 6]} wrap>
{resolveAppLabels(item.allowedAppIds).map((label) => (
<Tag key={`${item.id}-${label}`}>{label}</Tag>
))}
{resolveAppLabelsMemo(item.allowedAppIds).map((label) => (
<Tag key={`${item.id}-${label}`}>{label}</Tag>
))}
</Space>
</div>
</List.Item>
@@ -723,7 +751,7 @@ export function TokenSettingManagementPage({
},
{
key: 'apps',
label: `앱 권한 ${APP_OPTIONS.length}`,
label: `앱 권한 ${appOptions.length}`,
children: (
<div className="token-setting-management-page__section-scroll">
<Form.Item
@@ -743,7 +771,7 @@ export function TokenSettingManagementPage({
>
<Checkbox.Group style={{ width: '100%' }}>
<div className="token-setting-management-page__app-grid">
{APP_OPTIONS.map((option) => (
{appOptions.map((option) => (
<div key={option.value} className="token-setting-management-page__app-card">
<div className="token-setting-management-page__app-card-header">
<div className="token-setting-management-page__app-card-title">

View File

@@ -120,8 +120,9 @@ const SHARE_ROOM_SWITCH_CACHE_MESSAGE_LIMIT = 32;
const SHARE_IMMEDIATE_SEND_TOGGLE_HOLD_MS = 1000;
const SHARE_EDGE_NAVIGATION_HOTZONE_PX = 38;
const SHARE_EDGE_GESTURE_MIN_HORIZONTAL_PX = 16;
const SHARE_EDGE_GESTURE_OPEN_APPS_PX = 100;
const SHARE_EDGE_GESTURE_OPEN_APPS_PX = 72;
const SHARE_EDGE_APPS_GESTURE_HOTZONE_PX = 110;
const SHARE_EDGE_GESTURE_OPEN_APPS_MAX_VERTICAL_DEVIATION_PX = 34;
const SHARE_MINIMIZED_ACTION_TAP_TOLERANCE_PX = 8;
const SHARE_HISTORY_PAGE_SIZE = 40;
const SHARE_ACCESS_PIN_PROMPT_TTL_OPTIONS = [
@@ -2053,6 +2054,27 @@ function isMobileTouchViewport() {
&& (window.matchMedia('(pointer: coarse)').matches || window.matchMedia('(hover: none)').matches);
}
function getShareViewportWidth() {
if (typeof window === 'undefined') {
return 0;
}
return Math.floor(
Math.min(
window.innerWidth,
window.visualViewport?.width ?? window.innerWidth,
document.documentElement?.clientWidth ?? window.innerWidth,
),
);
}
function isShareAppOpenGestureSwipe(touch: Touch, startX: number, startY: number) {
const movedLeft = startX - touch.clientX;
const movedUpDown = Math.abs(touch.clientY - startY);
return movedLeft > SHARE_EDGE_GESTURE_OPEN_APPS_PX && movedUpDown <= SHARE_EDGE_GESTURE_OPEN_APPS_MAX_VERTICAL_DEVIATION_PX;
}
function isShareEdgeGestureIgnoredTarget(target: EventTarget | null) {
if (isTypingTarget(target)) {
return true;
@@ -9763,9 +9785,7 @@ export function ChatSharePage() {
return;
}
if (
touch.clientX >= window.innerWidth - SHARE_EDGE_APPS_GESTURE_HOTZONE_PX
) {
if (touch.clientX >= Math.max(0, getShareViewportWidth() - SHARE_EDGE_APPS_GESTURE_HOTZONE_PX)) {
const now = performance.now();
event.preventDefault();
tracking = {
@@ -9805,7 +9825,9 @@ export function ChatSharePage() {
const deltaX = touch.clientX - tracking.startX;
if (tracking.direction === 'apps') {
if (deltaX <= SHARE_EDGE_GESTURE_OPEN_APPS_PX * -1) {
const isSwipeLeftEnough = isShareAppOpenGestureSwipe(touch, tracking.startX, tracking.startY);
if (isSwipeLeftEnough) {
if (deltaX <= SHARE_EDGE_GESTURE_MIN_HORIZONTAL_PX * -1) {
event.preventDefault();
}
@@ -9837,7 +9859,11 @@ export function ChatSharePage() {
return;
}
if (tracking.direction === 'apps' && !tracking.opened) {
if (
tracking.direction === 'apps'
&& !tracking.opened
&& isShareAppOpenGestureSwipe(touch, tracking.startX, tracking.startY)
) {
const deltaX = touch.clientX - tracking.startX;
if (deltaX <= SHARE_EDGE_GESTURE_OPEN_APPS_PX * -1) {
@@ -9868,6 +9894,7 @@ export function ChatSharePage() {
};
}, [
activeProcessInspectorRequestId,
activeShareRoomSessionId,
isCreateRoomOpen,
isOriginReplyModalOpen,
isRoomSettingsOpen,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff