File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
'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,
fetchAttachmentFiles,
uploadAttachment,
uploadAttachments,
type AttachmentFile,
} 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<File | null> {
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<void>
): Promise<BatchFailure | null> {
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<string> {
const file = readFiles(formData, 'thumbnailFile')[0] ?? null;
if (!file) {
return readString(formData, 'thumbnailFileId');
}
assertImage(file);
return uploadAttachment(file, FILE_MODULE.merchantImage.id);
}
/**
* 저장된 이미지 한 장을 다시 파일로 — 묶음을 새 순서로 다시 올릴 때 쓴다.
*
* 파일 이름은 원본 이름(묶음 파일 목록)을 쓴다 — 백엔드가 이름의 확장자로 허용 여부를 가르기 때문이다.
* 목록에 없으면 MIME에서 만들되 `;charset=…` 같은 매개변수는 뗀다(안 떼면 확장자가
* `jpeg;charset=utf-8`이 되어 거절당한다 — 실제로 났던 500).
*/
async function fetchSavedImage(
fileId: string,
fileSn: number,
saved: AttachmentFile | undefined
): Promise<File> {
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 mimeType = blob.type.split(';')[0].trim();
const extension = saved?.extension || mimeType.split('/')[1] || 'jpg';
const name = saved?.name && /\.[A-Za-z0-9]+$/.test(saved.name) ? saved.name : `image-${fileSn}.${extension}`;
return new File([blob], name, { type: mimeType || `image/${extension}` });
}
/**
* 추가 이미지 묶음. 백엔드가 한 `atchFileId` 아래 `fileSn` 1..n으로 채번한다.
* - 저장된 장이 그대로 앞에 있고 새 장이 뒤에만 붙었으면 기존 묶음에 덧붙인다(백엔드 40f85c4) —
* 저장된 장을 다시 읽지 않아 빠르고, 파일이 다른 서버에 있어도 된다.
* - 순서를 바꾸거나 저장된 장을 지웠으면 묶음을 통째로 다시 올린다 — 저장된 장은 백엔드에서 다시 읽는다.
*
* 폼: 칸마다 `imageSlot`(`saved:<fileSn>` | `new`)과 `imageFile`(같은 순서, 안 고른 칸은 빈 파일).
*/
async function resolveImageBundleId(formData: FormData): Promise<string> {
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[] = [];
const savedFiles = retainedId && savedSns.length > 0 ? await fetchAttachmentFiles(retainedId) : [];
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) {
const fileSn = Number(slot.slice(6));
bundle.push(
await fetchSavedImage(retainedId, fileSn, savedFiles.find((file) => file.fileSn === fileSn))
);
}
}
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<string> {
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<MerchantDetailFormState> {
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<MerchantDetailActionResult> {
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<MerchantDetailActionResult> {
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<void> {
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<MerchantDetailActionResult> {
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<MerchantDetailActionResult> {
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<string> {
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<MerchantProductFormState> {
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<MerchantDetailActionResult> {
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<MerchantDetailActionResult> {
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' };
}