'use server'; import { revalidatePath } from 'next/cache'; import { redirect, unstable_rethrow } from 'next/navigation'; import { verifySession } from '@/lib/auth/dal'; import { BackendRequestError } from '@/lib/http/backend-fetch'; import { fetchCommonCodes } from '@/lib/data/repositories/common-code-repository'; import { buildFileImageUrl, uploadAttachment, uploadAttachments, } from '@/lib/data/repositories/file-repository'; import { createMerchantDetail, deleteMerchantDetail, fetchMerchantDetail, findMerchantByName, updateMerchantDetail, } from '@/lib/data/repositories/merchant-detail-repository'; import { createMerchant, updateMerchant } from '@/lib/data/repositories/merchant-repository'; import { createMerchantProduct, deleteMerchantProduct, fetchMerchantProduct, updateMerchantProduct, } from '@/lib/data/repositories/merchant-product-repository'; import { CODE_GROUP } from '@/lib/domain/common-code'; import { FILE_MODULE, MAX_ATTACHMENT_BYTES, allowedExtensions, isAllowedFileExtension, } from '@/lib/domain/file-module'; import { MAX_EXTRA_IMAGES, MAX_IMAGE_BYTES } from '@/lib/domain/merchant-detail'; import { validateMerchantDetail, type MerchantDetailFormState, type MerchantDetailMerchantValues, type MerchantDetailValues, } from '@/lib/domain/merchant-detail-form'; import { DEFAULT_MERCHANT_DIVISION_CODE } from '@/lib/domain/merchant'; import type { MerchantValues } from '@/lib/domain/merchant-form'; import { MERCHANT_DETAILS_PATH, merchantDetailPath, } from '@/lib/domain/merchant-detail-query'; import type { MerchantProductBadge } from '@/lib/domain/merchant-product'; import { validateMerchantProduct, type MerchantProductFormState, } from '@/lib/domain/merchant-product-form'; /** * 가맹몰 상세 Server Action — 상세(가맹점당 1건)와 메뉴(여러 건) 둘 다 여기서 다룬다. * * 노출 토글은 단건을 읽어 전 컬럼을 그대로 다시 보낸다 — 백엔드 `PUT`이 모든 컬럼을 덮어쓰기 때문이다. * 단건이 대표이미지·카테고리를 안 주는 응답이면(구 버전) 값이 지워지므로 토글을 거부한다. */ const SAVE_FAILED_MESSAGE = '저장하지 못했습니다. 잠시 후 다시 시도해 주세요.'; const DELETE_FAILED_MESSAGE = '삭제하지 못했습니다. 잠시 후 다시 시도해 주세요.'; const INVALID_REQUEST_MESSAGE = '요청이 올바르지 않습니다.'; const DETAIL_NOT_LOADED_MESSAGE = '백엔드가 상세 값을 돌려주지 않아 목록에서는 바꿀 수 없습니다. 수정 화면에서 바꿔 주세요.'; // 백엔드가 삭제된 상세도 "있는 것"으로 보는 결함(보고함) — 409는 대개 이 경우다. const DETAIL_CONFLICT_MESSAGE = '이 가맹점에는 이미 상세가 있습니다. 삭제했던 상세라면 백엔드에서 정리해야 다시 등록할 수 있습니다.'; const MERCHANT_NOT_FOUND_AFTER_CREATE_MESSAGE = '업체는 등록됐지만 코드를 받지 못해 상세를 붙이지 못했습니다. 목록에 업체가 나타나면 [수정]으로 상세를 등록해 주세요.'; const IMAGE_OVERSIZE_MESSAGE = `이미지는 장당 ${Math.floor(MAX_IMAGE_BYTES / (1024 * 1024))}MB까지 올릴 수 있습니다.`; const IMAGES_TOO_MANY_MESSAGE = `추가 이미지는 최대 ${MAX_EXTRA_IMAGES}장까지입니다.`; const REQUEST_OVERSIZE_MESSAGE = `한 번에 올리는 이미지의 합이 ${Math.floor(MAX_ATTACHMENT_BYTES / (1024 * 1024))}MB를 넘습니다. 나눠서 저장해 주세요.`; class InputError extends Error { constructor(message: string) { super(message); this.name = 'InputError'; } } export type MerchantDetailActionResult = | { status: 'success' } | { status: 'error'; message: string }; function readString(formData: FormData, key: string): string { const value = formData.get(key); return typeof value === 'string' ? value : ''; } function readOptionalInteger(formData: FormData, key: string): number | null { const raw = readString(formData, key).trim(); if (raw === '') return null; const parsed = Number(raw); return Number.isFinite(parsed) ? parsed : Number.NaN; } function readFiles(formData: FormData, key: string): Array { return formData .getAll(key) .map((entry) => (entry instanceof File && entry.size > 0 ? entry : null)); } function assertImage(file: File): void { if (file.size > MAX_IMAGE_BYTES) { throw new InputError(IMAGE_OVERSIZE_MESSAGE); } if (!isAllowedFileExtension(FILE_MODULE.merchantImage, file.name)) { throw new InputError( `이미지는 ${allowedExtensions(FILE_MODULE.merchantImage).join(', ')} 파일만 올릴 수 있습니다.` ); } } function failureMessage(error: unknown, fallback: string): string { const showable = error instanceof InputError || error instanceof BackendRequestError; return showable && error.message ? error.message : fallback; } type BatchFailure = { failedCount: number; error: unknown }; /** * 여러 건을 동시에 보내고 실패 수와 첫 오류를 돌려준다. 백엔드 관리자 API에는 트랜잭션이 없어 * 한 건이 실패해도 나머지는 그대로 반영된다 — 차례로 보내며 멈추는 것보다 동시에 보내고 결과를 * 세어 알리는 편이 빠르고 정확하다. */ async function runForEach( targets: readonly string[], run: (frcsNo: string) => Promise ): Promise { const results = await Promise.allSettled(targets.map((frcsNo) => run(frcsNo))); const rejected = results.filter( (result): result is PromiseRejectedResult => result.status === 'rejected' ); if (rejected.length === 0) return null; unstable_rethrow(rejected[0].reason); return { failedCount: rejected.length, error: rejected[0].reason }; } function batchFailureMessage(failure: BatchFailure, total: number, fallback: string): string { const reason = failureMessage(failure.error, fallback); return total > 1 ? `${total}건 중 ${failure.failedCount}건 실패 — ${reason}` : reason; } // ── 상세 ────────────────────────────────────────────────────────────────────── async function resolveThumbnailId(formData: FormData): Promise { const file = readFiles(formData, 'thumbnailFile')[0] ?? null; if (!file) { return readString(formData, 'thumbnailFileId'); } assertImage(file); return uploadAttachment(file, FILE_MODULE.merchantImage.id); } /** 저장된 이미지 한 장을 다시 파일로 — 묶음을 새 순서로 다시 올릴 때 쓴다. */ async function fetchSavedImage(fileId: string, fileSn: number): Promise { const url = buildFileImageUrl(fileId, fileSn); if (!url) { throw new InputError(INVALID_REQUEST_MESSAGE); } const response = await fetch(url, { cache: 'no-store' }); const blob = response.ok ? await response.blob() : null; // 파일이 없어도 백엔드가 200에 빈 본문으로 답한다(보고함) — 빈 파일을 다시 올리면 500이 난다. if (!blob || blob.size === 0) { throw new InputError( `저장된 추가 이미지 ${fileSn}번을 다시 읽지 못했습니다. 이 서버에 파일이 없을 수 있으니 이미지를 다시 올려 주세요.` ); } const extension = blob.type.split('/')[1] || 'jpg'; return new File([blob], `image-${fileSn}.${extension}`, { type: blob.type }); } /** * 추가 이미지 묶음. 백엔드가 한 `atchFileId` 아래 `fileSn` 1..n으로 채번한다. * - 저장된 장이 그대로 앞에 있고 새 장이 뒤에만 붙었으면 기존 묶음에 덧붙인다(백엔드 40f85c4) — * 저장된 장을 다시 읽지 않아 빠르고, 파일이 다른 서버에 있어도 된다. * - 순서를 바꾸거나 저장된 장을 지웠으면 묶음을 통째로 다시 올린다 — 저장된 장은 백엔드에서 다시 읽는다. * * 폼: 칸마다 `imageSlot`(`saved:` | `new`)과 `imageFile`(같은 순서, 안 고른 칸은 빈 파일). */ async function resolveImageBundleId(formData: FormData): Promise { const retainedId = readString(formData, 'imageFileId'); const slots = formData .getAll('imageSlot') .map((entry) => (typeof entry === 'string' ? entry : '')); const files = readFiles(formData, 'imageFile'); if (slots.length > MAX_EXTRA_IMAGES) { throw new InputError(IMAGES_TOO_MANY_MESSAGE); } const savedSns = slots .map((slot, index) => files[index] === null && slot.startsWith('saved:') ? Number(slot.slice(6)) : null ) .filter((sn): sn is number => sn !== null); const hasNewFile = files.some((file) => file !== null); const untouched = !hasNewFile && savedSns.length === slots.length && savedSns.every((sn, index) => sn === index + 1); if (slots.length === 0) { return ''; } if (untouched) { return retainedId; } const appendOnly = retainedId !== '' && savedSns.length > 0 && savedSns.every((sn, index) => sn === index + 1) && slots.every((slot, index) => index < savedSns.length ? files[index] === null && slot === `saved:${index + 1}` : files[index] !== null ); if (appendOnly) { const appended = files.slice(savedSns.length).filter((file): file is File => file !== null); appended.forEach(assertImage); const appendedBytes = appended.reduce((total, file) => total + file.size, 0); if (appendedBytes > MAX_ATTACHMENT_BYTES) { throw new InputError(REQUEST_OVERSIZE_MESSAGE); } return uploadAttachments(appended, FILE_MODULE.merchantImage.id, retainedId); } let newBytes = 0; const bundle: File[] = []; for (const [index, slot] of slots.entries()) { const picked = files[index]; if (picked) { assertImage(picked); newBytes += picked.size; bundle.push(picked); } else if (slot.startsWith('saved:') && retainedId) { bundle.push(await fetchSavedImage(retainedId, Number(slot.slice(6)))); } } if (newBytes > MAX_ATTACHMENT_BYTES) { throw new InputError(REQUEST_OVERSIZE_MESSAGE); } if (bundle.length === 0) { return ''; } return uploadAttachments(bundle, FILE_MODULE.merchantImage.id); } function readMerchantValues(formData: FormData, prefix = ''): MerchantDetailMerchantValues { return { name: readString(formData, `${prefix}name`), phoneNumber: readString(formData, `${prefix}phoneNumber`), postalCode: readString(formData, `${prefix}postalCode`), address: readString(formData, `${prefix}address`), addressDetail: readString(formData, `${prefix}addressDetail`), }; } function isSameMerchant(a: MerchantDetailMerchantValues, b: MerchantDetailMerchantValues): boolean { return ( a.name.trim() === b.name.trim() && a.phoneNumber.trim() === b.phoneNumber.trim() && a.postalCode.trim() === b.postalCode.trim() && a.address.trim() === b.address.trim() && a.addressDetail.trim() === b.addressDetail.trim() ); } /** 상세 화면이 받는 업체 항목을 가맹점 등록·수정 요청 값으로 — 나머지는 이 화면에 없어 비운다. */ function toMerchantValues(frcsNo: string, values: MerchantDetailMerchantValues): MerchantValues { return { id: frcsNo, brandName: '', memo: '', name: values.name, categoryCode: '', businessNumber: '', ownerName: '', phoneNumber: values.phoneNumber, address: values.address, addressDetail: values.addressDetail, postalCode: values.postalCode, latitude: '', longitude: '', bankHolderName: '', bankCode: '', accountNumber: '', businessStatusCode: '', divisionCode: DEFAULT_MERCHANT_DIVISION_CODE, isVisible: true, }; } /** * 업체(가맹점)를 만들거나 고치고 코드를 돌려준다. * - 코드가 없으면 새로 만든다 — 응답에 코드가 없으면(BizPlay 위임 응답) 목록에서 이름으로 찾는다. * - 코드가 있으면 값이 바뀌었을 때만 고친다 — BizPlay 위임 상태에선 불필요한 외부 호출을 피한다. */ async function saveMerchant( frcsNo: string, values: MerchantDetailMerchantValues, original: MerchantDetailMerchantValues ): Promise { if (frcsNo) { if (!isSameMerchant(values, original)) { await updateMerchant(frcsNo, toMerchantValues(frcsNo, values)); } return frcsNo; } const created = await createMerchant(toMerchantValues('', values)); if (created) return created; const found = await findMerchantByName(values.name, values.phoneNumber); if (!found) { throw new InputError(MERCHANT_NOT_FOUND_AFTER_CREATE_MESSAGE); } return found.id; } function readDetailValues( formData: FormData, thumbnailFileId: string, imageFileId: string ): MerchantDetailValues { return { ...readMerchantValues(formData), frcsNo: readString(formData, 'frcsNo'), categoryCode: readString(formData, 'categoryCode'), description: readString(formData, 'description'), openTime: readString(formData, 'openTime'), closeTime: readString(formData, 'closeTime'), closedDays: readString(formData, 'closedDays'), isVisible: readString(formData, 'isVisible') !== 'N', isVoucherUsable: readString(formData, 'isVoucherUsable') !== 'N', sortOrder: readOptionalInteger(formData, 'sortOrder'), thumbnailFileId, imageFileId, }; } export async function saveMerchantDetailAction( _prevState: MerchantDetailFormState, formData: FormData ): Promise { await verifySession(); const isEdit = readString(formData, 'mode') === 'edit'; let frcsNo = readString(formData, 'frcsNo'); let failed: MerchantDetailFormState | null = null; try { const [thumbnailFileId, imageFileId, categories] = await Promise.all([ resolveThumbnailId(formData), resolveImageBundleId(formData), fetchCommonCodes(CODE_GROUP.merchantType), ]); const validation = validateMerchantDetail( readDetailValues(formData, thumbnailFileId, imageFileId), categories ); if (!validation.ok) { return { status: 'error', errors: validation.errors }; } // 업체 먼저(만들거나 고치고), 그 코드로 상세를 붙인다. frcsNo = await saveMerchant(frcsNo, validation.values, readMerchantValues(formData, 'original')); const values = { ...validation.values, frcsNo }; if (isEdit) { await updateMerchantDetail(values); } else { await createMerchantDetail(values); } } catch (error) { unstable_rethrow(error); console.error('[merchant-details] 저장 실패', error); const isConflict = !isEdit && error instanceof BackendRequestError && error.code === 409; failed = { status: 'error', message: isConflict ? DETAIL_CONFLICT_MESSAGE : failureMessage(error, SAVE_FAILED_MESSAGE), }; } if (failed) { return failed; } revalidatePath(MERCHANT_DETAILS_PATH); revalidatePath(merchantDetailPath(frcsNo)); // 등록·수정이 한 화면이라 여기서 옮기지 않는다 — 화면이 결과를 보고 머물거나 주소만 바꾼다. return { status: 'success', frcsNo, created: !isEdit }; } export async function deleteMerchantDetailAction( frcsNo: string ): Promise { await verifySession(); if (!frcsNo) { return { status: 'error', message: INVALID_REQUEST_MESSAGE }; } try { await deleteMerchantDetail(frcsNo); } catch (error) { unstable_rethrow(error); console.error('[merchant-details] 삭제 실패', error); return { status: 'error', message: failureMessage(error, DELETE_FAILED_MESSAGE) }; } revalidatePath(MERCHANT_DETAILS_PATH); return { status: 'success' }; } export async function deleteMerchantDetailsAction( frcsNos: string[] ): Promise { await verifySession(); const targets = frcsNos.filter((value) => typeof value === 'string' && value !== ''); if (targets.length === 0) { return { status: 'error', message: INVALID_REQUEST_MESSAGE }; } const failure = await runForEach(targets, deleteMerchantDetail); revalidatePath(MERCHANT_DETAILS_PATH); if (failure) { console.error('[merchant-details] 일괄 삭제 실패', failure.error); return { status: 'error', message: batchFailureMessage(failure, targets.length, DELETE_FAILED_MESSAGE) }; } return { status: 'success' }; } async function toggleDetailVisibility(frcsNo: string, isVisible: boolean): Promise { const detail = await fetchMerchantDetail(frcsNo, { withImages: false }); // 대표이미지는 필수라 상세가 있으면 반드시 온다 — 없으면 백엔드가 상세를 돌려주지 않은 것이다. if (!detail || !detail.hasDetail || !detail.thumbnailFileId || !detail.categoryCode) { throw new InputError(DETAIL_NOT_LOADED_MESSAGE); } await updateMerchantDetail({ frcsNo, name: detail.name, phoneNumber: detail.phoneNumber ?? '', postalCode: detail.postalCode ?? '', address: detail.address ?? '', addressDetail: detail.addressDetail ?? '', categoryCode: detail.categoryCode, description: detail.description ?? '', openTime: detail.openTime ?? '', closeTime: detail.closeTime ?? '', closedDays: detail.closedDays ?? '', isVisible, isVoucherUsable: true, sortOrder: detail.sortOrder, thumbnailFileId: detail.thumbnailFileId, imageFileId: detail.imageFileId ?? '', }); } export async function updateMerchantDetailVisibilityAction( frcsNo: string, isVisible: boolean ): Promise { await verifySession(); if (!frcsNo) { return { status: 'error', message: INVALID_REQUEST_MESSAGE }; } try { await toggleDetailVisibility(frcsNo, isVisible); } catch (error) { unstable_rethrow(error); console.error('[merchant-details] 노출 변경 실패', error); return { status: 'error', message: failureMessage(error, SAVE_FAILED_MESSAGE) }; } revalidatePath(MERCHANT_DETAILS_PATH); revalidatePath(merchantDetailPath(frcsNo)); return { status: 'success' }; } export async function deactivateMerchantDetailsAction( frcsNos: string[] ): Promise { await verifySession(); const targets = frcsNos.filter((value) => typeof value === 'string' && value !== ''); if (targets.length === 0) { return { status: 'error', message: INVALID_REQUEST_MESSAGE }; } const failure = await runForEach(targets, (frcsNo) => toggleDetailVisibility(frcsNo, false)); revalidatePath(MERCHANT_DETAILS_PATH); if (failure) { console.error('[merchant-details] 일괄 비활성 실패', failure.error); return { status: 'error', message: batchFailureMessage(failure, targets.length, SAVE_FAILED_MESSAGE) }; } return { status: 'success' }; } // ── 메뉴 ────────────────────────────────────────────────────────────────────── function readBadges(formData: FormData): MerchantProductBadge[] { return formData .getAll('badges') .map((entry) => (typeof entry === 'string' ? entry.trim() : '')) .filter((value) => value !== ''); } async function resolveProductImageId(formData: FormData): Promise { const file = readFiles(formData, 'imageFile')[0] ?? null; if (!file) { return readString(formData, 'imageFileId'); } assertImage(file); return uploadAttachment(file, FILE_MODULE.merchantImage.id); } export async function saveMerchantProductAction( _prevState: MerchantProductFormState, formData: FormData ): Promise { await verifySession(); const frcsNo = readString(formData, 'frcsNo'); const productSn = readString(formData, 'productSn'); if (!frcsNo) { return { status: 'error', message: INVALID_REQUEST_MESSAGE }; } let failed: MerchantProductFormState | null = null; try { const [imageFileId, badgeCodes] = await Promise.all([ resolveProductImageId(formData), fetchCommonCodes(CODE_GROUP.merchantProductBadge), ]); const validation = validateMerchantProduct( { frcsNo, name: readString(formData, 'name'), price: readOptionalInteger(formData, 'price'), description: readString(formData, 'description'), badges: readBadges(formData), isVisible: readString(formData, 'isVisible') !== 'N', sortOrder: readOptionalInteger(formData, 'sortOrder'), imageFileId, }, badgeCodes ); if (!validation.ok) { return { status: 'error', errors: validation.errors }; } if (productSn) { await updateMerchantProduct(productSn, validation.values); } else { await createMerchantProduct(validation.values); } } catch (error) { unstable_rethrow(error); console.error('[merchant-products] 저장 실패', error); failed = { status: 'error', message: failureMessage(error, SAVE_FAILED_MESSAGE) }; } if (failed) { return failed; } revalidatePath(merchantDetailPath(frcsNo)); redirect(merchantDetailPath(frcsNo)); } export async function deleteMerchantProductAction( frcsNo: string, productSn: string ): Promise { await verifySession(); if (!frcsNo || !productSn) { return { status: 'error', message: INVALID_REQUEST_MESSAGE }; } try { await deleteMerchantProduct(productSn); } catch (error) { unstable_rethrow(error); console.error('[merchant-products] 삭제 실패', error); return { status: 'error', message: failureMessage(error, DELETE_FAILED_MESSAGE) }; } revalidatePath(merchantDetailPath(frcsNo)); return { status: 'success' }; } export async function updateMerchantProductVisibilityAction( frcsNo: string, productSn: string, isVisible: boolean ): Promise { await verifySession(); if (!frcsNo || !productSn) { return { status: 'error', message: INVALID_REQUEST_MESSAGE }; } try { const product = await fetchMerchantProduct(productSn); if (!product) { return { status: 'error', message: INVALID_REQUEST_MESSAGE }; } await updateMerchantProduct(productSn, { frcsNo: product.frcsNo, name: product.name, price: product.price, description: product.description ?? '', badges: product.badges, isVisible, sortOrder: product.sortOrder, imageFileId: product.imageFileId ?? '', }); } catch (error) { unstable_rethrow(error); console.error('[merchant-products] 노출 변경 실패', error); return { status: 'error', message: failureMessage(error, SAVE_FAILED_MESSAGE) }; } revalidatePath(merchantDetailPath(frcsNo)); return { status: 'success' }; }