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

@@ -1,54 +1,54 @@
'use server';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { serverFetch } from '@/lib/api/fetch-wrapper';
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
import type { WithdrawalRecord, WithdrawalType } from './types';
const API_URL = process.env.NEXT_PUBLIC_API_URL;
// ===== API 응답 타입 =====
interface WithdrawalApiData {
id: number;
tenant_id: number;
withdrawal_date: string;
used_at: string | null;
amount: number | string; // API 실제 필드명
client_id: number | null; // API 실제 필드명
client_name: string | null; // API 실제 필드명
merchant_name: string | null; // API 실제 필드명 (가맹점명)
amount: number | string;
client_id: number | null;
client_name: string | null;
merchant_name: string | null;
bank_account_id: number | null;
card_id: number | null;
payment_method: string | null; // API 실제 필드명 (결제수단)
account_code: string | null; // API 실제 필드명 (계정과목)
description: string | null; // API 실제 필드명
payment_method: string | null;
account_code: string | null;
description: string | null;
reference_type: string | null;
reference_id: number | null;
created_at: string;
updated_at: string;
// 관계 데이터
client?: { id: number; name: string } | null;
bank_account?: { id: number; bank_name: string; account_name: string } | null;
card?: { id: number; card_name: string } | null;
}
interface PaginationMeta {
interface WithdrawalPaginatedResponse {
data: WithdrawalApiData[];
current_page: number;
last_page: number;
per_page: number;
total: number;
}
interface FrontendPagination { currentPage: number; lastPage: number; perPage: number; total: number }
const DEFAULT_PAGINATION: FrontendPagination = { currentPage: 1, lastPage: 1, perPage: 20, total: 0 };
// ===== API → Frontend 변환 =====
function transformApiToFrontend(apiData: WithdrawalApiData): WithdrawalRecord {
return {
id: String(apiData.id),
withdrawalDate: apiData.withdrawal_date,
withdrawalAmount: typeof apiData.amount === 'string'
? parseFloat(apiData.amount)
: (apiData.amount ?? 0),
withdrawalAmount: typeof apiData.amount === 'string' ? parseFloat(apiData.amount) : (apiData.amount ?? 0),
bankAccountId: apiData.bank_account_id ? String(apiData.bank_account_id) : '',
accountName: apiData.bank_account
? `${apiData.bank_account.bank_name} ${apiData.bank_account.account_name}`
: '',
accountName: apiData.bank_account ? `${apiData.bank_account.bank_name} ${apiData.bank_account.account_name}` : '',
recipientName: apiData.merchant_name || apiData.client_name || apiData.client?.name || '',
note: apiData.description || '',
withdrawalType: (apiData.account_code || 'unset') as WithdrawalType,
@@ -62,318 +62,133 @@ function transformApiToFrontend(apiData: WithdrawalApiData): WithdrawalRecord {
// ===== Frontend → API 변환 =====
function transformFrontendToApi(data: Partial<WithdrawalRecord>): Record<string, unknown> {
const result: Record<string, unknown> = {};
if (data.withdrawalDate !== undefined) result.withdrawal_date = data.withdrawalDate;
if (data.withdrawalAmount !== undefined) result.amount = data.withdrawalAmount;
if (data.recipientName !== undefined) result.client_name = data.recipientName;
if (data.note !== undefined) result.description = data.note || null;
// 'unset'은 미설정 상태이므로 API에 null로 전송
if (data.withdrawalType !== undefined) {
result.account_code = data.withdrawalType === 'unset' ? null : data.withdrawalType;
}
if (data.vendorId !== undefined) result.client_id = data.vendorId ? parseInt(data.vendorId, 10) : null;
// 계좌 ID
if (data.bankAccountId !== undefined) {
result.bank_account_id = data.bankAccountId ? parseInt(data.bankAccountId, 10) : null;
}
// payment_method는 API 필수 필드 - 기본값 'transfer'(계좌이체)
// 유효값: cash, transfer, card, check
result.payment_method = 'transfer';
return result;
}
// ===== 출금 내역 조회 =====
export async function getWithdrawals(params?: {
page?: number;
perPage?: number;
startDate?: string;
endDate?: string;
withdrawalType?: string;
vendor?: string;
search?: string;
}): Promise<{
success: boolean;
data: WithdrawalRecord[];
pagination: {
currentPage: number;
lastPage: number;
perPage: number;
total: number;
};
error?: string;
}> {
page?: number; perPage?: number; startDate?: string; endDate?: string;
withdrawalType?: string; vendor?: string; search?: string;
}): Promise<{ success: boolean; data: WithdrawalRecord[]; pagination: FrontendPagination; error?: string }> {
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?.startDate) searchParams.set('start_date', params.startDate);
if (params?.endDate) searchParams.set('end_date', params.endDate);
if (params?.withdrawalType && params.withdrawalType !== 'all') {
searchParams.set('withdrawal_type', params.withdrawalType);
}
if (params?.vendor && params.vendor !== 'all') {
searchParams.set('vendor', params.vendor);
}
if (params?.withdrawalType && params.withdrawalType !== 'all') searchParams.set('withdrawal_type', params.withdrawalType);
if (params?.vendor && params.vendor !== 'all') searchParams.set('vendor', params.vendor);
if (params?.search) searchParams.set('search', params.search);
const queryString = searchParams.toString();
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/withdrawals${queryString ? `?${queryString}` : ''}`;
const { response, error } = await serverFetch(url, { method: 'GET' });
if (error) {
return {
success: false,
data: [],
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 },
error: error.message,
};
}
if (!response?.ok) {
console.warn('[WithdrawalActions] GET withdrawals 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 || '출금 내역 조회에 실패했습니다.',
};
}
// API 응답 구조 처리: { data: { data: [...], current_page: ... } } 또는 { data: [...], meta: {...} }
const isPaginatedResponse = result.data && typeof result.data === 'object' && 'data' in result.data && Array.isArray(result.data.data);
const rawData = isPaginatedResponse ? result.data.data : (Array.isArray(result.data) ? result.data : []);
const withdrawals = rawData.map(transformApiToFrontend);
const meta: PaginationMeta = isPaginatedResponse
? {
current_page: result.data.current_page || 1,
last_page: result.data.last_page || 1,
per_page: result.data.per_page || 20,
total: result.data.total || withdrawals.length,
}
: result.meta || {
current_page: 1,
last_page: 1,
per_page: 20,
total: withdrawals.length,
const result = await executeServerAction({
url: `${API_URL}/api/v1/withdrawals${queryString ? `?${queryString}` : ''}`,
transform: (data: WithdrawalPaginatedResponse | WithdrawalApiData[]) => {
const isPaginated = !Array.isArray(data) && data && 'data' in data;
const rawData = isPaginated ? (data as WithdrawalPaginatedResponse).data : (Array.isArray(data) ? data : []);
const items = rawData.map(transformApiToFrontend);
const meta = isPaginated
? (data as WithdrawalPaginatedResponse)
: { current_page: 1, last_page: 1, per_page: 20, total: items.length };
return {
items,
pagination: { currentPage: meta.current_page || 1, lastPage: meta.last_page || 1, perPage: meta.per_page || 20, total: meta.total || items.length },
};
return {
success: true,
data: withdrawals,
pagination: {
currentPage: meta.current_page,
lastPage: meta.last_page,
perPage: meta.per_page,
total: meta.total,
},
};
errorMessage: '출금 내역 조회에 실패했습니다.',
});
return { success: result.success, data: result.data?.items || [], pagination: result.data?.pagination || DEFAULT_PAGINATION, error: result.error };
}
// ===== 출금 내역 삭제 =====
export async function deleteWithdrawal(id: string): Promise<{ success: boolean; error?: string }> {
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/withdrawals/${id}`;
const { response, error } = await serverFetch(url, { method: 'DELETE' });
if (error) {
return { success: false, error: error.message };
}
const result = await response?.json();
if (!response?.ok || !result.success) {
return { success: false, error: result?.message || '출금 내역 삭제에 실패했습니다.' };
}
return { success: true };
export async function deleteWithdrawal(id: string): Promise<ActionResult> {
return executeServerAction({
url: `${API_URL}/api/v1/withdrawals/${id}`,
method: 'DELETE',
errorMessage: '출금 내역 삭제에 실패했습니다.',
});
}
// ===== 계정과목명 일괄 저장 =====
export async function updateWithdrawalTypes(
ids: string[],
withdrawalType: string
): Promise<{ success: boolean; error?: string }> {
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/withdrawals/bulk-update-type`;
const { response, error } = await serverFetch(url, {
export async function updateWithdrawalTypes(ids: string[], withdrawalType: string): Promise<ActionResult> {
return executeServerAction({
url: `${API_URL}/api/v1/withdrawals/bulk-update-type`,
method: 'PUT',
body: JSON.stringify({
ids: ids.map(id => parseInt(id, 10)),
withdrawal_type: withdrawalType,
}),
body: { ids: ids.map(id => parseInt(id, 10)), withdrawal_type: withdrawalType },
errorMessage: '계정과목명 저장에 실패했습니다.',
});
if (error) {
return { success: false, error: error.message };
}
const result = await response?.json();
if (!response?.ok || !result.success) {
return { success: false, error: result?.message || '계정과목명 저장에 실패했습니다.' };
}
return { success: true };
}
// ===== 출금 상세 조회 =====
export async function getWithdrawalById(id: string): Promise<{
success: boolean;
data?: WithdrawalRecord;
error?: string;
}> {
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/withdrawals/${id}`;
const { response, error } = await serverFetch(url, { method: 'GET' });
if (error) {
return { success: false, error: error.message };
}
if (!response?.ok) {
console.error('[WithdrawalActions] GET withdrawal 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: transformApiToFrontend(result.data) };
export async function getWithdrawalById(id: string): Promise<ActionResult<WithdrawalRecord>> {
return executeServerAction({
url: `${API_URL}/api/v1/withdrawals/${id}`,
transform: (data: WithdrawalApiData) => transformApiToFrontend(data),
errorMessage: '출금 내역 조회에 실패했습니다.',
});
}
// ===== 출금 등록 =====
export async function createWithdrawal(
data: Partial<WithdrawalRecord>
): Promise<{ success: boolean; data?: WithdrawalRecord; error?: string }> {
const apiData = transformFrontendToApi(data);
console.log('[WithdrawalActions] POST withdrawal request:', apiData);
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/withdrawals`;
const { response, error } = await serverFetch(url, {
export async function createWithdrawal(data: Partial<WithdrawalRecord>): Promise<ActionResult<WithdrawalRecord>> {
return executeServerAction({
url: `${API_URL}/api/v1/withdrawals`,
method: 'POST',
body: JSON.stringify(apiData),
body: transformFrontendToApi(data),
transform: (data: WithdrawalApiData) => transformApiToFrontend(data),
errorMessage: '출금 등록에 실패했습니다.',
});
if (error) {
return { success: false, error: error.message };
}
const result = await response?.json();
console.log('[WithdrawalActions] POST withdrawal response:', result);
if (!response?.ok || !result.success) {
return { success: false, error: result?.message || '출금 등록에 실패했습니다.' };
}
return { success: true, data: transformApiToFrontend(result.data) };
}
// ===== 출금 수정 =====
export async function updateWithdrawal(
id: string,
data: Partial<WithdrawalRecord>
): Promise<{ success: boolean; data?: WithdrawalRecord; error?: string }> {
const apiData = transformFrontendToApi(data);
console.log('[WithdrawalActions] PUT withdrawal request:', apiData);
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/withdrawals/${id}`;
const { response, error } = await serverFetch(url, {
export async function updateWithdrawal(id: string, data: Partial<WithdrawalRecord>): Promise<ActionResult<WithdrawalRecord>> {
return executeServerAction({
url: `${API_URL}/api/v1/withdrawals/${id}`,
method: 'PUT',
body: JSON.stringify(apiData),
body: transformFrontendToApi(data),
transform: (data: WithdrawalApiData) => transformApiToFrontend(data),
errorMessage: '출금 수정에 실패했습니다.',
});
if (error) {
return { success: false, error: error.message };
}
const result = await response?.json();
console.log('[WithdrawalActions] PUT withdrawal response:', result);
if (!response?.ok || !result.success) {
return { success: false, error: result?.message || '출금 수정에 실패했습니다.' };
}
return { success: true, data: transformApiToFrontend(result.data) };
}
// ===== 거래처 목록 조회 =====
export async function getVendors(): Promise<{
success: boolean;
data: { id: string; name: string }[];
error?: string;
success: boolean; data: { id: string; name: string }[]; error?: string;
}> {
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/clients?per_page=100`;
const { response, error } = await serverFetch(url, { method: 'GET' });
if (error) {
return { success: false, data: [], error: error.message };
}
if (!response?.ok) {
return { success: false, data: [], error: `API 오류: ${response?.status}` };
}
const result = await response.json();
if (!result.success) {
return { success: false, data: [], error: result.message };
}
const clients = result.data?.data || result.data || [];
return {
success: true,
data: clients.map((c: { id: number; name: string }) => ({
id: String(c.id),
name: c.name,
})),
};
const result = await executeServerAction({
url: `${API_URL}/api/v1/clients?per_page=100`,
transform: (data: { data?: { id: number; name: string }[] } | { id: number; name: string }[]) => {
type ClientApi = { id: number; name: string };
const clients: ClientApi[] = Array.isArray(data) ? data : (data as { data?: ClientApi[] })?.data || [];
return clients.map(c => ({ id: String(c.id), name: c.name }));
},
errorMessage: '거래처 조회에 실패했습니다.',
});
return { success: result.success, data: result.data || [], error: result.error };
}
// ===== 계좌 목록 조회 =====
export async function getBankAccounts(): Promise<{
success: boolean;
data: { id: string; name: string }[];
error?: string;
success: boolean; data: { id: string; name: string }[]; error?: string;
}> {
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/bank-accounts?per_page=100`;
const { response, error } = await serverFetch(url, { method: 'GET' });
if (error) {
return { success: false, data: [], error: error.message };
}
if (!response?.ok) {
return { success: false, data: [], error: `API 오류: ${response?.status}` };
}
const result = await response.json();
if (!result.success) {
return { success: false, data: [], error: result.message };
}
const accounts = result.data?.data || result.data || [];
return {
success: true,
data: accounts.map((a: { id: number; account_name: string; bank_name: string }) => ({
id: String(a.id),
name: `${a.bank_name} ${a.account_name}`,
})),
};
const result = await executeServerAction({
url: `${API_URL}/api/v1/bank-accounts?per_page=100`,
transform: (data: { data?: { id: number; account_name: string; bank_name: string }[] } | { id: number; account_name: string; bank_name: string }[]) => {
type AccountApi = { id: number; account_name: string; bank_name: string };
const accounts: AccountApi[] = Array.isArray(data) ? data : (data as { data?: AccountApi[] })?.data || [];
return accounts.map(a => ({ id: String(a.id), name: `${a.bank_name} ${a.account_name}` }));
},
errorMessage: '계좌 조회에 실패했습니다.',
});
return { success: result.success, data: result.data || [], error: result.error };
}