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

@@ -14,8 +14,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 { PricingData, ItemInfo } from './types';
// API 응답 타입
@@ -160,319 +159,80 @@ function transformFrontendToApi(data: PricingData): Record<string, unknown> {
};
}
/**
* 단가 상세 조회
*/
const API_URL = process.env.NEXT_PUBLIC_API_URL;
export async function getPricingById(id: string): Promise<PricingData | null> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/pricing/${id}`,
{
method: 'GET',
cache: 'no-store',
}
);
if (error) {
console.error('[PricingActions] GET pricing error:', error.message);
return null;
}
if (!response) {
console.error('[PricingActions] GET pricing: 응답이 없습니다.');
return null;
}
if (!response.ok) {
console.error('[PricingActions] GET pricing error:', response.status);
return null;
}
const result: ApiResponse<PriceApiData> = await response.json();
console.log('[PricingActions] GET pricing response:', result);
if (!result.success || !result.data) {
return null;
}
return transformApiToFrontend(result.data);
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[PricingActions] getPricingById error:', error);
return null;
}
const result = await executeServerAction({
url: `${API_URL}/api/v1/pricing/${id}`,
transform: (data: PriceApiData) => transformApiToFrontend(data),
errorMessage: '단가 조회에 실패했습니다.',
});
return result.success ? result.data || null : null;
}
/**
* 품목 정보 조회 (통합 품목 API)
*
* GET /api/v1/items/{id}
*/
export async function getItemInfo(itemId: string): Promise<ItemInfo | null> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/items/${itemId}`,
{
method: 'GET',
cache: 'no-store',
}
);
if (error) {
console.error('[PricingActions] getItemInfo error:', error.message);
return null;
}
if (!response) {
console.error('[PricingActions] getItemInfo: 응답이 없습니다.');
return null;
}
if (!response.ok) {
console.error('[PricingActions] Item not found:', itemId);
return null;
}
const result = await response.json();
if (!result.success || !result.data) {
return null;
}
const item = result.data;
return {
id: String(item.id),
itemCode: item.code,
itemName: item.name,
itemType: item.item_type || 'PT',
specification: item.specification || undefined,
unit: item.unit || 'EA',
};
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[PricingActions] getItemInfo error:', error);
return null;
}
interface ItemApiItem { id: number; code: string; name: string; item_type: string; specification?: string; unit?: string }
const result = await executeServerAction<ItemApiItem>({
url: `${API_URL}/api/v1/items/${itemId}`,
errorMessage: '품목 조회에 실패했습니다.',
});
if (!result.success || !result.data) return null;
const item = result.data;
return {
id: String(item.id), itemCode: item.code, itemName: item.name,
itemType: item.item_type || 'PT', specification: item.specification || undefined, unit: item.unit || 'EA',
};
}
/**
* 단가 등록
* item_type_code는 data.itemType에서 자동으로 가져옴
*/
export async function createPricing(
data: PricingData
): Promise<{ success: boolean; data?: PricingData; error?: string; __authError?: boolean }> {
try {
const apiData = transformFrontendToApi(data);
console.log('[PricingActions] POST pricing request:', apiData);
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/pricing`,
{
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('[PricingActions] POST pricing response:', result);
if (!response.ok || !result.success) {
return {
success: false,
error: result.message || '단가 등록에 실패했습니다.',
};
}
return {
success: true,
data: transformApiToFrontend(result.data),
};
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[PricingActions] createPricing error:', error);
return {
success: false,
error: '서버 오류가 발생했습니다.',
};
}
const apiData = transformFrontendToApi(data);
const result = await executeServerAction({
url: `${API_URL}/api/v1/pricing`,
method: 'POST',
body: apiData,
transform: (d: PriceApiData) => transformApiToFrontend(d),
errorMessage: '단가 등록에 실패했습니다.',
});
if (result.__authError) return { success: false, __authError: true };
return { success: result.success, data: result.data, error: result.error };
}
/**
* 단가 수정
*/
export async function updatePricing(
id: string,
data: PricingData,
changeReason?: string
id: string, data: PricingData, changeReason?: string
): Promise<{ success: boolean; data?: PricingData; error?: string; __authError?: boolean }> {
try {
const apiData = {
...transformFrontendToApi(data),
change_reason: changeReason || null,
} as Record<string, unknown>;
console.log('[PricingActions] PUT pricing request:', apiData);
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/pricing/${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('[PricingActions] PUT pricing response:', result);
if (!response.ok || !result.success) {
return {
success: false,
error: result.message || '단가 수정에 실패했습니다.',
};
}
return {
success: true,
data: transformApiToFrontend(result.data),
};
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[PricingActions] updatePricing error:', error);
return {
success: false,
error: '서버 오류가 발생했습니다.',
};
}
const apiData = { ...transformFrontendToApi(data), change_reason: changeReason || null };
const result = await executeServerAction({
url: `${API_URL}/api/v1/pricing/${id}`,
method: 'PUT',
body: apiData,
transform: (d: PriceApiData) => transformApiToFrontend(d),
errorMessage: '단가 수정에 실패했습니다.',
});
if (result.__authError) return { success: false, __authError: true };
return { success: result.success, data: result.data, error: result.error };
}
/**
* 단가 삭제
*/
export async function deletePricing(id: string): Promise<{ success: boolean; error?: string; __authError?: boolean }> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/pricing/${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('[PricingActions] DELETE pricing 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('[PricingActions] deletePricing error:', error);
return {
success: false,
error: '서버 오류가 발생했습니다.',
};
}
const result = await executeServerAction({
url: `${API_URL}/api/v1/pricing/${id}`,
method: 'DELETE',
errorMessage: '단가 삭제에 실패했습니다.',
});
if (result.__authError) return { success: false, __authError: true };
return { success: result.success, error: result.error };
}
/**
* 단가 확정
*/
export async function finalizePricing(id: string): Promise<{ success: boolean; data?: PricingData; error?: string; __authError?: boolean }> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/pricing/${id}/finalize`,
{
method: 'POST',
}
);
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('[PricingActions] POST finalize response:', result);
if (!response.ok || !result.success) {
return {
success: false,
error: result.message || '단가 확정에 실패했습니다.',
};
}
return {
success: true,
data: transformApiToFrontend(result.data),
};
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[PricingActions] finalizePricing error:', error);
return {
success: false,
error: '서버 오류가 발생했습니다.',
};
}
const result = await executeServerAction({
url: `${API_URL}/api/v1/pricing/${id}/finalize`,
method: 'POST',
transform: (d: PriceApiData) => transformApiToFrontend(d),
errorMessage: '단가 확정에 실패했습니다.',
});
if (result.__authError) return { success: false, __authError: true };
return { success: result.success, data: result.data, error: result.error };
}
// ============================================
@@ -565,94 +325,57 @@ function mapStatusForList(apiStatus: string, isFinal: boolean): 'draft' | 'activ
* 단가 목록 데이터 조회 (품목 + 단가 병합)
*/
export async function getPricingListData(): Promise<PricingListItem[]> {
try {
// 품목 목록 조회
const { response: itemsResponse, error: itemsError } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/items?group_id=1&size=100`,
{ method: 'GET' }
);
interface ItemsPaginatedResponse { data: ItemApiData[]; current_page: number; last_page: number; per_page: number; total: number }
interface PricingPaginatedResponse { data: PriceApiListItem[]; current_page: number; last_page: number; per_page: number; total: number }
if (itemsError || !itemsResponse) {
console.error('[PricingActions] Items fetch error:', itemsError?.message);
return [];
}
const [itemsResult, pricingResult] = await Promise.all([
executeServerAction<ItemsPaginatedResponse>({
url: `${API_URL}/api/v1/items?group_id=1&size=100`,
errorMessage: '품목 목록 조회에 실패했습니다.',
}),
executeServerAction<PricingPaginatedResponse>({
url: `${API_URL}/api/v1/pricing?size=100`,
errorMessage: '단가 목록 조회에 실패했습니다.',
}),
]);
const itemsResult = await itemsResponse.json();
const items: ItemApiData[] = itemsResult.success ? (itemsResult.data?.data || []) : [];
const items: ItemApiData[] = itemsResult.success ? (itemsResult.data?.data || []) : [];
const pricings: PriceApiListItem[] = pricingResult.success ? (pricingResult.data?.data || []) : [];
// 단가 목록 조회
const { response: pricingResponse, error: pricingError } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/pricing?size=100`,
{ method: 'GET' }
);
if (items.length === 0) return [];
if (pricingError || !pricingResponse) {
console.error('[PricingActions] Pricing fetch error:', pricingError?.message);
return [];
}
const pricingResult = await pricingResponse.json();
const pricings: PriceApiListItem[] = pricingResult.success ? (pricingResult.data?.data || []) : [];
// 단가 정보를 빠르게 찾기 위한 Map 생성
const pricingMap = new Map<string, PriceApiListItem>();
for (const pricing of pricings) {
const key = `${pricing.item_type_code}_${pricing.item_id}`;
if (!pricingMap.has(key)) {
pricingMap.set(key, pricing);
}
}
// 품목 목록을 기준으로 병합
return items.map((item) => {
const key = `${item.item_type}_${item.id}`;
const pricing = pricingMap.get(key);
if (pricing) {
return {
id: String(pricing.id),
itemId: String(item.id),
itemCode: item.code,
itemName: item.name,
itemType: mapItemTypeForList(item.item_type),
specification: undefined,
unit: item.unit || 'EA',
purchasePrice: pricing.purchase_price ? parseFloat(pricing.purchase_price) : undefined,
processingCost: pricing.processing_cost ? parseFloat(pricing.processing_cost) : undefined,
salesPrice: pricing.sales_price ? parseFloat(pricing.sales_price) : undefined,
marginRate: pricing.margin_rate ? parseFloat(pricing.margin_rate) : undefined,
effectiveDate: pricing.effective_from,
status: mapStatusForList(pricing.status, pricing.is_final),
currentRevision: 0,
isFinal: pricing.is_final,
itemTypeCode: item.item_type,
};
} else {
return {
id: `item_${item.id}`,
itemId: String(item.id),
itemCode: item.code,
itemName: item.name,
itemType: mapItemTypeForList(item.item_type),
specification: undefined,
unit: item.unit || 'EA',
purchasePrice: undefined,
processingCost: undefined,
salesPrice: undefined,
marginRate: undefined,
effectiveDate: undefined,
status: 'not_registered' as const,
currentRevision: 0,
isFinal: false,
itemTypeCode: item.item_type,
};
}
});
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[PricingActions] getPricingListData error:', error);
return [];
const pricingMap = new Map<string, PriceApiListItem>();
for (const pricing of pricings) {
const key = `${pricing.item_type_code}_${pricing.item_id}`;
if (!pricingMap.has(key)) pricingMap.set(key, pricing);
}
return items.map((item) => {
const key = `${item.item_type}_${item.id}`;
const pricing = pricingMap.get(key);
if (pricing) {
return {
id: String(pricing.id), itemId: String(item.id), itemCode: item.code, itemName: item.name,
itemType: mapItemTypeForList(item.item_type), specification: undefined, unit: item.unit || 'EA',
purchasePrice: pricing.purchase_price ? parseFloat(pricing.purchase_price) : undefined,
processingCost: pricing.processing_cost ? parseFloat(pricing.processing_cost) : undefined,
salesPrice: pricing.sales_price ? parseFloat(pricing.sales_price) : undefined,
marginRate: pricing.margin_rate ? parseFloat(pricing.margin_rate) : undefined,
effectiveDate: pricing.effective_from,
status: mapStatusForList(pricing.status, pricing.is_final),
currentRevision: 0, isFinal: pricing.is_final, itemTypeCode: item.item_type,
};
} else {
return {
id: `item_${item.id}`, itemId: String(item.id), itemCode: item.code, itemName: item.name,
itemType: mapItemTypeForList(item.item_type), specification: undefined, unit: item.unit || 'EA',
purchasePrice: undefined, processingCost: undefined, salesPrice: undefined, marginRate: undefined,
effectiveDate: undefined, status: 'not_registered' as const,
currentRevision: 0, isFinal: false, itemTypeCode: item.item_type,
};
}
});
}
/**
@@ -661,77 +384,35 @@ export async function getPricingListData(): Promise<PricingListItem[]> {
export async function getPricingRevisions(priceId: string): Promise<{
success: boolean;
data?: Array<{
revisionNumber: number;
revisionDate: string;
revisionBy: string;
revisionReason?: string;
beforeSnapshot: Record<string, unknown> | null;
afterSnapshot: Record<string, unknown>;
revisionNumber: number; revisionDate: string; revisionBy: string; revisionReason?: string;
beforeSnapshot: Record<string, unknown> | null; afterSnapshot: Record<string, unknown>;
}>;
error?: string;
__authError?: boolean;
error?: string; __authError?: boolean;
}> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/pricing/${priceId}/revisions`,
{
method: 'GET',
cache: 'no-store',
}
);
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('[PricingActions] GET revisions response:', result);
if (!response.ok || !result.success) {
return {
success: false,
error: result.message || '이력 조회에 실패했습니다.',
};
}
const revisions = result.data.data?.map((rev: {
revision_number: number;
changed_at: string;
changed_by: number;
change_reason: string | null;
before_snapshot: Record<string, unknown> | null;
after_snapshot: Record<string, unknown>;
changed_by_user?: { name: string };
}) => ({
revisionNumber: rev.revision_number,
revisionDate: rev.changed_at,
revisionBy: rev.changed_by_user?.name || `User ${rev.changed_by}`,
revisionReason: rev.change_reason || undefined,
beforeSnapshot: rev.before_snapshot,
afterSnapshot: rev.after_snapshot,
})) || [];
return {
success: true,
data: revisions,
};
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[PricingActions] getPricingRevisions error:', error);
return {
success: false,
error: '서버 오류가 발생했습니다.',
};
interface RevisionApiData {
data?: Array<{
revision_number: number; changed_at: string; changed_by: number;
change_reason: string | null; before_snapshot: Record<string, unknown> | null;
after_snapshot: Record<string, unknown>; changed_by_user?: { name: string };
}>;
}
const result = await executeServerAction<RevisionApiData>({
url: `${API_URL}/api/v1/pricing/${priceId}/revisions`,
errorMessage: '이력 조회에 실패했습니다.',
});
if (result.__authError) return { success: false, __authError: true };
if (!result.success || !result.data) return { success: false, error: result.error };
const revisions = (result.data.data || []).map((rev) => ({
revisionNumber: rev.revision_number,
revisionDate: rev.changed_at,
revisionBy: rev.changed_by_user?.name || `User ${rev.changed_by}`,
revisionReason: rev.change_reason || undefined,
beforeSnapshot: rev.before_snapshot,
afterSnapshot: rev.after_snapshot,
}));
return { success: true, data: revisions };
}