refactor(WEB): Server Action 공통화 및 보안 강화

- executeServerAction 공통 유틸 도입으로 actions.ts 대폭 간소화 (50+개 파일)
- sanitize 유틸 추가 (XSS 방지)
- middleware CSP 헤더 추가 및 Open Redirect 방지
- 프록시 라우트 로깅 개발환경 한정으로 변경
- 프로덕션 불필요 console.log 제거

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-02-09 16:14:06 +09:00
parent d014227e9c
commit 55e0791e16
85 changed files with 7211 additions and 17638 deletions

View File

@@ -18,8 +18,7 @@
'use server';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { serverFetch } from '@/lib/api/fetch-wrapper';
import { executeServerAction } from '@/lib/api/execute-server-action';
import type {
ShipmentItem,
ShipmentDetail,
@@ -308,523 +307,177 @@ interface PaginationMeta {
total: number;
}
const API_URL = process.env.NEXT_PUBLIC_API_URL;
// ===== 출고 목록 조회 =====
export async function getShipments(params?: {
page?: number;
perPage?: number;
search?: string;
status?: string;
priority?: string;
deliveryMethod?: string;
scheduledFrom?: string;
scheduledTo?: string;
canShip?: boolean;
depositConfirmed?: boolean;
sortBy?: string;
sortDir?: string;
}): Promise<{
success: boolean;
data: ShipmentItem[];
pagination: PaginationMeta;
error?: string;
__authError?: boolean;
}> {
try {
const searchParams = new URLSearchParams();
page?: number; perPage?: number; search?: string; status?: string;
priority?: string; deliveryMethod?: string; scheduledFrom?: string; scheduledTo?: string;
canShip?: boolean; depositConfirmed?: boolean; sortBy?: string; sortDir?: string;
}): Promise<{ success: boolean; data: ShipmentItem[]; pagination: PaginationMeta; error?: string; __authError?: boolean }> {
const emptyPagination = { currentPage: 1, lastPage: 1, perPage: 20, total: 0 };
const searchParams = new URLSearchParams();
if (params?.page) searchParams.set('page', String(params.page));
if (params?.perPage) searchParams.set('per_page', String(params.perPage));
if (params?.search) searchParams.set('search', params.search);
if (params?.status && params.status !== 'all') searchParams.set('status', params.status);
if (params?.priority && params.priority !== 'all') searchParams.set('priority', params.priority);
if (params?.deliveryMethod && params.deliveryMethod !== 'all') searchParams.set('delivery_method', params.deliveryMethod);
if (params?.scheduledFrom) searchParams.set('scheduled_from', params.scheduledFrom);
if (params?.scheduledTo) searchParams.set('scheduled_to', params.scheduledTo);
if (params?.canShip !== undefined) searchParams.set('can_ship', String(params.canShip));
if (params?.depositConfirmed !== undefined) searchParams.set('deposit_confirmed', String(params.depositConfirmed));
if (params?.sortBy) searchParams.set('sort_by', params.sortBy);
if (params?.sortDir) searchParams.set('sort_dir', params.sortDir);
if (params?.page) searchParams.set('page', String(params.page));
if (params?.perPage) searchParams.set('per_page', String(params.perPage));
if (params?.search) searchParams.set('search', params.search);
if (params?.status && params.status !== 'all') {
searchParams.set('status', params.status);
}
if (params?.priority && params.priority !== 'all') {
searchParams.set('priority', params.priority);
}
if (params?.deliveryMethod && params.deliveryMethod !== 'all') {
searchParams.set('delivery_method', params.deliveryMethod);
}
if (params?.scheduledFrom) searchParams.set('scheduled_from', params.scheduledFrom);
if (params?.scheduledTo) searchParams.set('scheduled_to', params.scheduledTo);
if (params?.canShip !== undefined) searchParams.set('can_ship', String(params.canShip));
if (params?.depositConfirmed !== undefined) {
searchParams.set('deposit_confirmed', String(params.depositConfirmed));
}
if (params?.sortBy) searchParams.set('sort_by', params.sortBy);
if (params?.sortDir) searchParams.set('sort_dir', params.sortDir);
const queryString = searchParams.toString();
const result = await executeServerAction<ShipmentApiPaginatedResponse>({
url: `${API_URL}/api/v1/shipments${queryString ? `?${queryString}` : ''}`,
errorMessage: '출고 목록 조회에 실패했습니다.',
});
const queryString = searchParams.toString();
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/shipments${queryString ? `?${queryString}` : ''}`;
if (result.__authError) return { success: false, data: [], pagination: emptyPagination, __authError: true };
if (!result.success || !result.data) return { success: false, data: [], pagination: emptyPagination, error: result.error };
console.log('[ShipmentActions] GET shipments:', url);
const { response, error } = await serverFetch(url, {
method: 'GET',
cache: 'no-store',
});
if (error) {
return {
success: false,
data: [],
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 },
error: error.message,
__authError: error.code === 'UNAUTHORIZED',
};
}
if (!response || !response.ok) {
console.warn('[ShipmentActions] GET shipments error:', response?.status);
return {
success: false,
data: [],
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 },
error: `API 오류: ${response?.status}`,
};
}
const result = await response.json();
if (!result.success) {
return {
success: false,
data: [],
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 },
error: result.message || '출고 목록 조회에 실패했습니다.',
};
}
const paginatedData: ShipmentApiPaginatedResponse = result.data || {
data: [],
current_page: 1,
last_page: 1,
per_page: 20,
total: 0,
};
const shipments = (paginatedData.data || []).map(transformApiToListItem);
return {
success: true,
data: shipments,
pagination: {
currentPage: paginatedData.current_page,
lastPage: paginatedData.last_page,
perPage: paginatedData.per_page,
total: paginatedData.total,
},
};
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[ShipmentActions] getShipments error:', error);
return {
success: false,
data: [],
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 },
error: '서버 오류가 발생했습니다.',
};
}
return {
success: true,
data: (result.data.data || []).map(transformApiToListItem),
pagination: {
currentPage: result.data.current_page, lastPage: result.data.last_page,
perPage: result.data.per_page, total: result.data.total,
},
};
}
// ===== 출고 통계 조회 =====
export async function getShipmentStats(): Promise<{
success: boolean;
data?: ShipmentStats;
error?: string;
__authError?: boolean;
}> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/shipments/stats`,
{
method: 'GET',
cache: 'no-store',
}
);
if (error) {
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
}
if (!response || !response.ok) {
console.warn('[ShipmentActions] GET stats error:', response?.status);
return { success: false, error: `API 오류: ${response?.status}` };
}
const result = await response.json();
if (!result.success || !result.data) {
return { success: false, error: result.message || '출고 통계 조회에 실패했습니다.' };
}
return { success: true, data: transformApiToStats(result.data) };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[ShipmentActions] getShipmentStats error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
export async function getShipmentStats(): Promise<{ success: boolean; data?: ShipmentStats; error?: string; __authError?: boolean }> {
const result = await executeServerAction({
url: `${API_URL}/api/v1/shipments/stats`,
transform: (data: ShipmentApiStatsResponse & { total_count?: number }) => transformApiToStats(data),
errorMessage: '출고 통계 조회에 실패했습니다.',
});
if (result.__authError) return { success: false, __authError: true };
return { success: result.success, data: result.data, error: result.error };
}
// ===== 상태별 통계 조회 (탭용) =====
export async function getShipmentStatsByStatus(): Promise<{
success: boolean;
data?: ShipmentStatusStats;
error?: string;
__authError?: boolean;
}> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/shipments/stats-by-status`,
{
method: 'GET',
cache: 'no-store',
}
);
if (error) {
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
}
if (!response || !response.ok) {
console.warn('[ShipmentActions] GET stats-by-status error:', response?.status);
return { success: false, error: `API 오류: ${response?.status}` };
}
const result = await response.json();
if (!result.success || !result.data) {
return { success: false, error: result.message || '상태별 통계 조회에 실패했습니다.' };
}
return { success: true, data: transformApiToStatsByStatus(result.data) };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[ShipmentActions] getShipmentStatsByStatus error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
export async function getShipmentStatsByStatus(): Promise<{ success: boolean; data?: ShipmentStatusStats; error?: string; __authError?: boolean }> {
const result = await executeServerAction({
url: `${API_URL}/api/v1/shipments/stats-by-status`,
transform: (data: ShipmentApiStatsByStatusResponse) => transformApiToStatsByStatus(data),
errorMessage: '상태별 통계 조회에 실패했습니다.',
});
if (result.__authError) return { success: false, __authError: true };
return { success: result.success, data: result.data, error: result.error };
}
// ===== 출고 상세 조회 =====
export async function getShipmentById(id: string): Promise<{
success: boolean;
data?: ShipmentDetail;
error?: string;
__authError?: boolean;
}> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/shipments/${id}`,
{
method: 'GET',
cache: 'no-store',
}
);
if (error) {
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
}
if (!response || !response.ok) {
console.error('[ShipmentActions] GET shipment error:', response?.status);
return { success: false, error: `API 오류: ${response?.status}` };
}
const result = await response.json();
if (!result.success || !result.data) {
return { success: false, error: result.message || '출고 조회에 실패했습니다.' };
}
return { success: true, data: transformApiToDetail(result.data) };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[ShipmentActions] getShipmentById error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
export async function getShipmentById(id: string): Promise<{ success: boolean; data?: ShipmentDetail; error?: string; __authError?: boolean }> {
const result = await executeServerAction({
url: `${API_URL}/api/v1/shipments/${id}`,
transform: (data: ShipmentApiData) => transformApiToDetail(data),
errorMessage: '출고 조회에 실패했습니다.',
});
if (result.__authError) return { success: false, __authError: true };
return { success: result.success, data: result.data, error: result.error };
}
// ===== 출고 등록 =====
export async function createShipment(
data: ShipmentCreateFormData
): Promise<{ success: boolean; data?: ShipmentDetail; error?: string; __authError?: boolean }> {
try {
const apiData = transformCreateFormToApi(data);
console.log('[ShipmentActions] POST shipment request:', apiData);
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/shipments`,
{
method: 'POST',
body: JSON.stringify(apiData),
}
);
if (error) {
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
}
if (!response) {
return { success: false, error: '출고 등록에 실패했습니다.' };
}
const result = await response.json();
console.log('[ShipmentActions] POST shipment response:', result);
if (!response.ok || !result.success) {
return { success: false, error: result.message || '출고 등록에 실패했습니다.' };
}
return { success: true, data: transformApiToDetail(result.data) };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[ShipmentActions] createShipment error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
const apiData = transformCreateFormToApi(data);
const result = await executeServerAction({
url: `${API_URL}/api/v1/shipments`,
method: 'POST',
body: apiData,
transform: (d: ShipmentApiData) => transformApiToDetail(d),
errorMessage: '출고 등록에 실패했습니다.',
});
if (result.__authError) return { success: false, __authError: true };
return { success: result.success, data: result.data, error: result.error };
}
// ===== 출고 수정 =====
export async function updateShipment(
id: string,
data: Partial<ShipmentEditFormData>
id: string, data: Partial<ShipmentEditFormData>
): Promise<{ success: boolean; data?: ShipmentDetail; error?: string; __authError?: boolean }> {
try {
const apiData = transformEditFormToApi(data);
console.log('[ShipmentActions] PUT shipment request:', apiData);
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/shipments/${id}`,
{
method: 'PUT',
body: JSON.stringify(apiData),
}
);
if (error) {
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
}
if (!response) {
return { success: false, error: '출고 수정에 실패했습니다.' };
}
const result = await response.json();
console.log('[ShipmentActions] PUT shipment response:', result);
if (!response.ok || !result.success) {
return { success: false, error: result.message || '출고 수정에 실패했습니다.' };
}
return { success: true, data: transformApiToDetail(result.data) };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[ShipmentActions] updateShipment error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
const apiData = transformEditFormToApi(data);
const result = await executeServerAction({
url: `${API_URL}/api/v1/shipments/${id}`,
method: 'PUT',
body: apiData,
transform: (d: ShipmentApiData) => transformApiToDetail(d),
errorMessage: '출고 수정에 실패했습니다.',
});
if (result.__authError) return { success: false, __authError: true };
return { success: result.success, data: result.data, error: result.error };
}
// ===== 출고 상태 변경 =====
export async function updateShipmentStatus(
id: string,
status: ShipmentStatus,
id: string, status: ShipmentStatus,
additionalData?: {
loadingTime?: string;
loadingCompletedAt?: string;
vehicleNo?: string;
driverName?: string;
driverContact?: string;
confirmedArrival?: string;
loadingTime?: string; loadingCompletedAt?: string; vehicleNo?: string;
driverName?: string; driverContact?: string; confirmedArrival?: string;
}
): Promise<{ success: boolean; data?: ShipmentDetail; error?: string; __authError?: boolean }> {
try {
const apiData: Record<string, unknown> = { status };
if (additionalData?.loadingTime) apiData.loading_time = additionalData.loadingTime;
if (additionalData?.loadingCompletedAt) apiData.loading_completed_at = additionalData.loadingCompletedAt;
if (additionalData?.vehicleNo) apiData.vehicle_no = additionalData.vehicleNo;
if (additionalData?.driverName) apiData.driver_name = additionalData.driverName;
if (additionalData?.driverContact) apiData.driver_contact = additionalData.driverContact;
if (additionalData?.confirmedArrival) apiData.confirmed_arrival = additionalData.confirmedArrival;
const apiData: Record<string, unknown> = { status };
if (additionalData?.loadingTime) apiData.loading_time = additionalData.loadingTime;
if (additionalData?.loadingCompletedAt) apiData.loading_completed_at = additionalData.loadingCompletedAt;
if (additionalData?.vehicleNo) apiData.vehicle_no = additionalData.vehicleNo;
if (additionalData?.driverName) apiData.driver_name = additionalData.driverName;
if (additionalData?.driverContact) apiData.driver_contact = additionalData.driverContact;
if (additionalData?.confirmedArrival) apiData.confirmed_arrival = additionalData.confirmedArrival;
console.log('[ShipmentActions] PATCH status request:', apiData);
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/shipments/${id}/status`,
{
method: 'PATCH',
body: JSON.stringify(apiData),
}
);
if (error) {
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
}
if (!response) {
return { success: false, error: '상태 변경에 실패했습니다.' };
}
const result = await response.json();
console.log('[ShipmentActions] PATCH status response:', result);
if (!response.ok || !result.success) {
return { success: false, error: result.message || '상태 변경에 실패했습니다.' };
}
return { success: true, data: transformApiToDetail(result.data) };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[ShipmentActions] updateShipmentStatus error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
const result = await executeServerAction({
url: `${API_URL}/api/v1/shipments/${id}/status`,
method: 'PATCH',
body: apiData,
transform: (d: ShipmentApiData) => transformApiToDetail(d),
errorMessage: '상태 변경에 실패했습니다.',
});
if (result.__authError) return { success: false, __authError: true };
return { success: result.success, data: result.data, error: result.error };
}
// ===== 출고 삭제 =====
export async function deleteShipment(
id: string
): Promise<{ success: boolean; error?: string; __authError?: boolean }> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/shipments/${id}`,
{
method: 'DELETE',
}
);
if (error) {
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
}
if (!response) {
return { success: false, error: '출고 삭제에 실패했습니다.' };
}
const result = await response.json();
console.log('[ShipmentActions] DELETE shipment response:', result);
if (!response.ok || !result.success) {
return { success: false, error: result.message || '출고 삭제에 실패했습니다.' };
}
return { success: true };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[ShipmentActions] deleteShipment error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
export async function deleteShipment(id: string): Promise<{ success: boolean; error?: string; __authError?: boolean }> {
const result = await executeServerAction({
url: `${API_URL}/api/v1/shipments/${id}`,
method: 'DELETE',
errorMessage: '출고 삭제에 실패했습니다.',
});
if (result.__authError) return { success: false, __authError: true };
return { success: result.success, error: result.error };
}
// ===== LOT 옵션 조회 =====
export async function getLotOptions(): Promise<{
success: boolean;
data: LotOption[];
error?: string;
__authError?: boolean;
}> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/shipments/options/lots`,
{
method: 'GET',
cache: 'no-store',
}
);
if (error) {
return { success: false, data: [], error: error.message, __authError: error.code === 'UNAUTHORIZED' };
}
if (!response || !response.ok) {
console.warn('[ShipmentActions] GET lot options error:', response?.status);
return { success: false, data: [], error: `API 오류: ${response?.status}` };
}
const result = await response.json();
if (!result.success) {
return { success: false, data: [], error: result.message || 'LOT 옵션 조회에 실패했습니다.' };
}
return { success: true, data: result.data || [] };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[ShipmentActions] getLotOptions error:', error);
return { success: false, data: [], error: '서버 오류가 발생했습니다.' };
}
export async function getLotOptions(): Promise<{ success: boolean; data: LotOption[]; error?: string; __authError?: boolean }> {
const result = await executeServerAction<LotOption[]>({
url: `${API_URL}/api/v1/shipments/options/lots`,
errorMessage: 'LOT 옵션 조회에 실패했습니다.',
});
if (result.__authError) return { success: false, data: [], __authError: true };
return { success: result.success, data: result.data || [], error: result.error };
}
// ===== 물류사 옵션 조회 =====
export async function getLogisticsOptions(): Promise<{
success: boolean;
data: LogisticsOption[];
error?: string;
__authError?: boolean;
}> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/shipments/options/logistics`,
{
method: 'GET',
cache: 'no-store',
}
);
if (error) {
return { success: false, data: [], error: error.message, __authError: error.code === 'UNAUTHORIZED' };
}
if (!response || !response.ok) {
console.warn('[ShipmentActions] GET logistics options error:', response?.status);
return { success: false, data: [], error: `API 오류: ${response?.status}` };
}
const result = await response.json();
if (!result.success) {
return { success: false, data: [], error: result.message || '물류사 옵션 조회에 실패했습니다.' };
}
return { success: true, data: result.data || [] };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[ShipmentActions] getLogisticsOptions error:', error);
return { success: false, data: [], error: '서버 오류가 발생했습니다.' };
}
export async function getLogisticsOptions(): Promise<{ success: boolean; data: LogisticsOption[]; error?: string; __authError?: boolean }> {
const result = await executeServerAction<LogisticsOption[]>({
url: `${API_URL}/api/v1/shipments/options/logistics`,
errorMessage: '물류사 옵션 조회에 실패했습니다.',
});
if (result.__authError) return { success: false, data: [], __authError: true };
return { success: result.success, data: result.data || [], error: result.error };
}
// ===== 차량 톤수 옵션 조회 =====
export async function getVehicleTonnageOptions(): Promise<{
success: boolean;
data: VehicleTonnageOption[];
error?: string;
__authError?: boolean;
}> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/shipments/options/vehicle-tonnage`,
{
method: 'GET',
cache: 'no-store',
}
);
if (error) {
return { success: false, data: [], error: error.message, __authError: error.code === 'UNAUTHORIZED' };
}
if (!response || !response.ok) {
console.warn('[ShipmentActions] GET vehicle tonnage options error:', response?.status);
return { success: false, data: [], error: `API 오류: ${response?.status}` };
}
const result = await response.json();
if (!result.success) {
return { success: false, data: [], error: result.message || '차량 톤수 옵션 조회에 실패했습니다.' };
}
return { success: true, data: result.data || [] };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[ShipmentActions] getVehicleTonnageOptions error:', error);
return { success: false, data: [], error: '서버 오류가 발생했습니다.' };
}
export async function getVehicleTonnageOptions(): Promise<{ success: boolean; data: VehicleTonnageOption[]; error?: string; __authError?: boolean }> {
const result = await executeServerAction<VehicleTonnageOption[]>({
url: `${API_URL}/api/v1/shipments/options/vehicle-tonnage`,
errorMessage: '차량 톤수 옵션 조회에 실패했습니다.',
});
if (result.__authError) return { success: false, data: [], __authError: true };
return { success: result.success, data: result.data || [], error: result.error };
}