import 'server-only'; import { request as httpRequest } from 'node:http'; import { request as httpsRequest } from 'node:https'; import { getSessionAccessToken } from '@/lib/auth/dal'; import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch'; import { buildFileImageUrl } from '@/lib/data/repositories/file-repository'; import { MAX_EXTRA_IMAGES, type MerchantDetail, type MerchantDetailSummary, } from '@/lib/domain/merchant-detail'; import type { MerchantDetailValues } from '@/lib/domain/merchant-detail-form'; import type { MerchantDetailQuery } from '@/lib/domain/merchant-detail-query'; /** * 가맹몰 상세 Repository — edupay-backend origin/feature/voc b009025(2026-09-17) 기준. * * ``` * GET /api/v1/mngr/voc/detail/pagination 상세가 있는 가맹점만(TB_COM_VOUCHER ⋈ TB_COM_VOUCHER_DTL ⋈ 코드) * GET /api/v1/mngr/voc/detail/{frcsNo} 상세가 없으면 NOT_FOUND 봉투 * POST /api/v1/mngr/voc/detail @RequestBody · 상세가 있으면 409, 가맹점이 없으면 404 * PUT /api/v1/mngr/voc/detail/{frcsNo} @RequestBody · 전 컬럼 덮어씀 * DELETE /api/v1/mngr/voc/{frcsNo} 논리 삭제(DEL_YN) * GET /api/v1/mngr/voc/pagination 가맹점 전체 — 목록 합치기·등록 선택지·상세 없는 단건에 쓴다 * ``` * * 목록·단건이 소개·카테고리 코드·이름(`frcsTypeCdNm`)·영업시간·휴무일·추가이미지·순서·우편번호· * 등록자(`frstRgtrNmStr`)·등록일(`frstRegDtStr`)·수정일(`lastMdfcnDtStr`)을 돌려준다. 업종·업태 이름 * (`frcsTpbizCdNm`·`frcsBzstatCdNm`·`frcsCdNm`)도 오지만 화면(시안)에 자리가 없어 읽지 않는다. * * ⚠️ 백엔드 결함(보고함, 미수정): * 1. 응답 VO(`MngrVocDetailVo`)에 `frcsNm`이 없어 SELECT의 업체명이 버려진다 — 그동안 업체명 자리에 * 가맹점 코드를 보인다. * 2. 건수 쿼리(`selectPaginationCount`)만 LEFT JOIN이라 상세 없는 가맹점까지 세어 페이지 수가 부풀 수 있다. * 3. `selectOne`이 DEL_YN을 거르지 않아 삭제한 상세가 있는 가맹점은 다시 등록하면 409가 난다. * 4. `searchStartDt/EndDt`를 VO만 받고 WHERE에 쓰지 않는다. * 5. 「바우처 사용」을 담을 필드가 없다 — `voucherUseYn`으로 보내되 버려진다. */ const DETAIL_PATH = '/api/v1/mngr/voc/detail'; const VOC_PATH = '/api/v1/mngr/voc'; // 등록 선택지·상세 보유 목록을 한 번에 훑는 크기. 가맹점은 많아야 수백 건이다. const SCAN_PAGE_SIZE = 500; const MAX_SCAN_PAGES = 20; function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object'; } function readString(source: Record, key: string): string | null { const value = source[key]; if (typeof value === 'string' && value !== '') return value; if (typeof value === 'number') return String(value); return null; } function readNumber(source: Record, key: string): number | null { const value = source[key]; if (typeof value === 'number' && Number.isFinite(value)) return value; if (typeof value === 'string' && value.trim() !== '') { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : null; } return null; } function readYesNo(source: Record, key: string): boolean | null { const value = readString(source, key); return value === null ? null : value.toUpperCase() === 'Y'; } /** * 등록일은 `d.FRST_REG_DT`가 별칭 없이 오므로 `frstRegDt`(Date → ISO 문자열 또는 epoch)로 * 받는다. 다른 화면의 `frstRegDtStr`가 있으면 그것을 우선한다. */ function readDate(source: Record, key: string): string | null { const formatted = readString(source, `${key}Str`); if (formatted) return formatted.slice(0, 10); const raw = source[key]; const date = typeof raw === 'number' ? new Date(raw) : typeof raw === 'string' && raw !== '' ? new Date(raw) : null; if (date === null || Number.isNaN(date.getTime())) return null; return date.toISOString().slice(0, 10); } /** 상세 목록·단건의 한 행. 가맹점 API의 행은 `toCandidate`가 따로 맡는다. */ function toSummary(raw: unknown): MerchantDetailSummary[] { if (!isRecord(raw)) return []; const id = readString(raw, 'frcsNo'); if (!id) return []; const registeredAt = readDate(raw, 'frstRegDt'); return [ { id, // 결함 1 — 업체명이 안 오면 코드라도 보인다. name: readString(raw, 'frcsNm') ?? id, phoneNumber: readString(raw, 'rprsvTelno'), address: readString(raw, 'frcsPostAddr'), addressDetail: readString(raw, 'frcsDaddr'), postalCode: readString(raw, 'frcsPostZip'), thumbnailFileId: readString(raw, 'thumbAtchFileId'), thumbnailUrl: buildFileImageUrl(readString(raw, 'thumbAtchFileId')), categoryCode: readString(raw, 'frcsTypeCd'), categoryLabel: readString(raw, 'frcsTypeCdNm') ?? readString(raw, 'cdNm'), isVisible: readYesNo(raw, 'useYn'), registeredAt, hasDetail: registeredAt !== null || readString(raw, 'thumbAtchFileId') !== null || readString(raw, 'frcsTypeCd') !== null, }, ]; } /** 가맹점 API(`/mngr/voc/pagination`)의 한 행 — 아직 상세가 없는 가맹점의 선택지. */ function toCandidate(raw: unknown): MerchantDetailSummary[] { if (!isRecord(raw)) return []; const id = readString(raw, 'frcsNo'); const name = readString(raw, 'frcsNm'); if (!id || !name) return []; return [ { id, name, phoneNumber: readString(raw, 'rprsvTelno'), address: readString(raw, 'frcsPostAddr'), addressDetail: readString(raw, 'frcsDaddr'), postalCode: readString(raw, 'frcsPostZip'), thumbnailFileId: null, thumbnailUrl: null, categoryCode: null, categoryLabel: null, isVisible: null, registeredAt: null, hasDetail: false, }, ]; } const PROBE_TIMEOUT_MS = 5_000; /** * 이미지 한 장이 있는지 — 응답 머리만 보고 바로 끊는다. * * `fetch`가 아니라 `node:http`를 쓴다. 백엔드 인증 예외가 GET에만 열려 HEAD는 401이고, Next가 * 감싼 `fetch`는 본문을 `cancel()`해도 끝나지 않아(확인함) 화면이 영영 뜨지 않는다. */ function probeImageUrl(url: string): Promise { return new Promise((resolve) => { const target = new URL(url); const request = (target.protocol === 'https:' ? httpsRequest : httpRequest)( target, { method: 'GET', timeout: PROBE_TIMEOUT_MS }, (response) => { response.destroy(); resolve((response.statusCode ?? 500) < 400); } ); request.on('timeout', () => { request.destroy(); resolve(false); }); request.on('error', () => resolve(false)); request.end(); }); } /** * 묶음 안의 이미지 수를 알려 주는 API가 없다(보고함). fileSn이 1부터 빈틈없이 붙으므로 최대 장수를 * 한꺼번에 두드려 이어지는 앞부분만 취한다 — 차례로 두드리면 장수만큼 왕복이 늘어난다. * 없는 번호는 백엔드가 500으로 답한다. */ async function probeImageUrls(fileId: string | null): Promise { if (!fileId) return []; const candidates = Array.from({ length: MAX_EXTRA_IMAGES }, (_, index) => buildFileImageUrl(fileId, index + 1) ).filter((url): url is string => url !== null); const exists = await Promise.all(candidates.map(probeImageUrl)); const missingAt = exists.indexOf(false); return missingAt === -1 ? candidates : candidates.slice(0, missingAt); } async function toDetail(raw: unknown, withImages: boolean): Promise { const [summary] = toSummary(raw); if (!summary || !isRecord(raw)) return null; const imageFileId = readString(raw, 'atchFileId'); return { ...summary, imageUrls: withImages ? await probeImageUrls(imageFileId) : [], description: readString(raw, 'frcsCn'), openTime: readString(raw, 'startHour'), closeTime: readString(raw, 'endHour'), closedDays: readString(raw, 'offDayCn'), imageFileId, sortOrder: readNumber(raw, 'sortOrdr'), registeredBy: readString(raw, 'frstRgtrNmStr') ?? readString(raw, 'frstRgtrNm'), modifiedAt: readDate(raw, 'lastMdfcnDt'), // 수정자는 `lastMdfrNm`이 JSON에서 빠지고 *Str 별칭도 없어 아직 안 온다. modifiedBy: readString(raw, 'lastMdfrNm'), }; } /** * 목록 — 가맹점 전체에 상세를 덧입혀 상세가 없는 가맹점도 세운다(사용자 확정). 백엔드 목록은 상세가 * 있는 것만 주므로 가맹점 목록과 상세 목록을 모두 받아 합치고, 검색·기간·페이지는 여기서 처리한다. * 부수 효과로 백엔드가 쓰지 않던 등록일 기간 조건과 응답에 빠진 업체명(결함 1)이 여기서 채워진다. */ export async function fetchMerchantDetails( query: MerchantDetailQuery ): Promise<{ items: MerchantDetailSummary[]; totalCount: number }> { const merged = await fetchMergedSummaries(); const keyword = query.keyword.trim().toLowerCase(); const filtered = merged.filter((row) => { if (keyword && !row.name.toLowerCase().includes(keyword)) return false; if (query.category && row.categoryCode !== query.category) return false; if (query.visibility && row.isVisible !== (query.visibility === 'Y')) return false; if (query.startDate && (row.registeredAt === null || row.registeredAt < query.startDate)) return false; if (query.endDate && (row.registeredAt === null || row.registeredAt > query.endDate)) return false; return true; }); const start = (query.page - 1) * query.pageSize; return { items: filtered.slice(start, start + query.pageSize), totalCount: filtered.length, }; } /** 상세 있는 가맹점을 등록일 내림차순으로 먼저, 나머지 가맹점을 코드 내림차순으로 뒤에 둔다. */ function compareSummaryOrder(a: MerchantDetailSummary, b: MerchantDetailSummary): number { if (a.hasDetail !== b.hasDetail) return a.hasDetail ? -1 : 1; const dateA = a.registeredAt ?? ''; const dateB = b.registeredAt ?? ''; if (dateA !== dateB) return dateA < dateB ? 1 : -1; return b.id.localeCompare(a.id, undefined, { numeric: true }); } async function fetchMergedSummaries(): Promise { const [merchants, details] = await Promise.all([ fetchAllPages(`${VOC_PATH}/pagination`, toCandidate), fetchAllPages(`${DETAIL_PATH}/pagination`, toSummary), ]); const byId = new Map(merchants.map((row) => [row.id, row])); for (const detail of details) { // develop 백엔드(LEFT JOIN)는 상세 없는 가맹점도 섞어 준다 — 상세 있는 행만 덧입힌다. if (!detail.hasDetail) continue; const merchant = byId.get(detail.id); byId.set(detail.id, { ...detail, // 결함 1 — 상세 응답에 업체명이 없으면 가맹점 목록의 이름을 쓴다. name: detail.name === detail.id && merchant ? merchant.name : detail.name, }); } return [...byId.values()].sort(compareSummaryOrder); } /** * 단건. `withImages`를 끄면 추가 이미지 URL 더듬기(HEAD 여러 번)를 건너뛴다 — 노출 토글처럼 * 값만 다시 보내는 곳은 URL이 필요 없다. */ export async function fetchMerchantDetail( frcsNo: string, { withImages = true }: { withImages?: boolean } = {} ): Promise { const accessToken = await getSessionAccessToken(); const result = await backendFetch( `${DETAIL_PATH}/${encodeURIComponent(frcsNo)}`, { method: 'GET', accessToken: accessToken ?? undefined, cache: 'no-store', canHaveNullData: true, } ); if (!result.ok) { // 상세가 없는 가맹점은 NOT_FOUND 봉투로 온다 — 화면이 notFound()로 넘기게 null을 준다. if (result.code === 404) return null; throw new BackendRequestError(result); } return toDetail(result.data, withImages); } async function fetchPage( path: string, pageIndex: number, pageSize: number, toRow: (raw: unknown) => T[] ): Promise<{ rows: T[]; rowCount: number; totalCount: number }> { const accessToken = await getSessionAccessToken(); const result = await backendFetch(path, { method: 'GET', query: { pageIndex, recordCountPerPage: pageSize }, accessToken: accessToken ?? undefined, cache: 'no-store', canHaveNullData: true, }); if (!result.ok) { throw new BackendRequestError(result); } const data = result.data; if (!isRecord(data) || !Array.isArray(data.list)) { return { rows: [], rowCount: 0, totalCount: 0 }; } return { rows: data.list.flatMap(toRow), rowCount: data.list.length, totalCount: typeof data.totalCount === 'number' ? data.totalCount : data.list.length, }; } async function fetchAllPages(path: string, toRow: (raw: unknown) => T[]): Promise { const rows: T[] = []; let seen = 0; for (let pageIndex = 1; pageIndex <= MAX_SCAN_PAGES; pageIndex += 1) { const page = await fetchPage(path, pageIndex, SCAN_PAGE_SIZE, toRow); rows.push(...page.rows); seen += page.rowCount; if (page.rowCount < SCAN_PAGE_SIZE || seen >= page.totalCount) break; } return rows; } /** 등록 화면의 업체 선택지 — 아직 상세가 없는 가맹점 전부. */ export async function fetchMerchantDetailCandidates(): Promise { return (await fetchMergedSummaries()).filter((row) => !row.hasDetail); } /** 가맹점 한 건(상세 없이) — 가맹점 단건 API가 없어 목록에서 찾는다. */ export async function fetchMerchantSummary( frcsNo: string ): Promise { const merchants = await fetchAllPages(`${VOC_PATH}/pagination`, toCandidate); return merchants.find((row) => row.id === frcsNo) ?? null; } function toWritePayload(values: MerchantDetailValues): Record { return { frcsNo: values.frcsNo, frcsTypeCd: values.categoryCode, frcsCn: values.description, startHour: values.openTime, endHour: values.closeTime, offDayCn: values.closedDays, thumbAtchFileId: values.thumbnailFileId, atchFileId: values.imageFileId || null, sortOrdr: values.sortOrder, useYn: values.isVisible ? 'Y' : 'N', // 요청 VO에 없어 버려진다(⚠️ 3). 자리가 생기면 그대로 저장된다. voucherUseYn: values.isVoucherUsable ? 'Y' : 'N', }; } async function sendWrite( path: string, method: 'POST' | 'PUT' | 'DELETE', body?: unknown ): Promise { const accessToken = await getSessionAccessToken(); const result = await backendFetch(path, { method, ...(body === undefined ? {} : { body }), accessToken: accessToken ?? undefined, cache: 'no-store', canHaveNullData: true, canHaveEmptyBody: true, }); if (!result.ok) { throw new BackendRequestError(result); } } export async function createMerchantDetail( values: MerchantDetailValues ): Promise { await sendWrite(DETAIL_PATH, 'POST', toWritePayload(values)); } export async function updateMerchantDetail( values: MerchantDetailValues ): Promise { await sendWrite( `${DETAIL_PATH}/${encodeURIComponent(values.frcsNo)}`, 'PUT', toWritePayload(values) ); } export async function deleteMerchantDetail(frcsNo: string): Promise { await sendWrite(`${VOC_PATH}/${encodeURIComponent(frcsNo)}`, 'DELETE'); }