export type AddrItem = { zipNo: string; roadAddr: string; jibunAddr: string; ctpvNm: string; sggNm: string; }; export interface ZipSearchParams { roadNm?: string; bldgNoMno?: number | null; bldgNoSno?: number | null; page?: number; size?: number; } export interface Zip { zoneNo: string; ctpvNm: string; sggNm: string; lotno: string; zipAddr: string; } interface ZipListResponse { content?: Zip[]; totalElements?: number; } export type PostcodeSearchParams = { roadNm: string; bldgNoMno: number; bldgNoSno: number | null; page: number; }; export type ParsePostcodeInputResult = | { ok: true; value: PostcodeSearchParams; keyword: string } | { ok: false; message: string }; export type PostcodeSearchResult = { totalCount: number; items: AddrItem[]; }; type SuggestionSeed = { roadNm: string; bldgNoMno: number; }; async function findAddress(searchQuery: ZipSearchParams): Promise { if (!searchQuery) return {}; const params = new URLSearchParams(); if (searchQuery.roadNm) params.append("roadNm", searchQuery.roadNm); if (searchQuery.bldgNoMno != null) { params.append("bldgNoMno", searchQuery.bldgNoMno.toString()); } if (searchQuery.bldgNoSno != null) { params.append("bldgNoSno", searchQuery.bldgNoSno.toString()); } if (searchQuery.page != null) params.append("page", searchQuery.page.toString()); if (searchQuery.size != null) params.append("size", searchQuery.size.toString()); const url = `/cms-service/api/v1/zips?${params.toString()}`; const response = await fetch(url); if (!response.ok) throw new Error("Network response was not ok"); return (await response.json()) as ZipListResponse; } function extractSuggestionSeed(item: Zip): SuggestionSeed | null { const ext = item as Zip & { roadNm?: string; bldgNoMno?: number | string; }; const mnoByField = Number(ext.bldgNoMno); const roadNmByField = (ext.roadNm ?? "").trim(); if (roadNmByField && Number.isInteger(mnoByField) && mnoByField > 0) { return { roadNm: roadNmByField, bldgNoMno: mnoByField }; } const firstSegment = (item.zipAddr ?? "").split(",")[0]?.trim() ?? ""; if (!firstSegment) return null; const parts = firstSegment.split(/\s+/); const bldgToken = parts.at(-1) ?? ""; const matched = bldgToken.match(/^(\d+)(?:-\d+)?$/); if (!matched) return null; const bldgNoMno = Number(matched[1]); const roadNm = parts.slice(0, -1).join(" ").trim(); if (!roadNm || !Number.isInteger(bldgNoMno) || bldgNoMno <= 0) { return null; } return { roadNm, bldgNoMno }; } function searchFilter(search: string): boolean { if (search.length === 0) return true; const expText = /[%=><]/; if (expText.test(search)) { alert("특수문자를 입력 할수 없습니다."); return false; } const sqlArray = [ "OR", "SELECT", "INSERT", "DELETE", "UPDATE", "CREATE", "DROP", "EXEC", "UNION", "FETCH", "DECLARE", "TRUNCATE", ]; for (const sqlWord of sqlArray) { const regex = new RegExp(sqlWord, "gi"); if (regex.test(search)) { alert(`"${sqlWord}"와(과) 같은 특정문자로 검색할 수 없습니다.`); return false; } } return true; } export function parsePostcodeInput( input: string, page = 1, ): ParsePostcodeInputResult { const normalized = input.replace(/\s+/g, " ").trim(); if (!normalized) { return { ok: false, message: "도로명과 건물번호를 입력해 주세요. 예) 테헤란로 152-1", }; } const parts = normalized.split(" "); if (parts.length < 2) { if (/^\d+(?:-\d+)?$/.test(normalized)) { return { ok: false, message: "도로명을 입력해 주세요" }; } if (/[가-힣]/.test(normalized) && !/\d/.test(normalized)) { return { ok: false, message: "건물번호를 입력해 주세요" }; } return { ok: false, message: "도로명과 건물번호를 공백으로 구분해 주세요. 예) 테헤란로 152-1", }; } const bldgToken = parts.at(-1) ?? ""; const roadNm = parts.slice(0, -1).join(" ").trim(); if (!roadNm || !/[가-힣]/.test(roadNm)) { return { ok: false, message: "도로명을 입력해 주세요", }; } if (!/\d/.test(bldgToken)) { return { ok: false, message: "건물번호를 입력해 주세요" }; } if (!/^\d+(?:-\d+)?$/.test(bldgToken)) { return { ok: false, message: "건물번호는 숫자 또는 숫자-숫자 형식으로 입력해 주세요. 예) 152 또는 152-1", }; } const [mnoStr, snoStr] = bldgToken.split("-"); const bldgNoMno = Number(mnoStr); const bldgNoSno = snoStr ? Number(snoStr) : null; if (!Number.isInteger(bldgNoMno) || bldgNoMno <= 0) { return { ok: false, message: "건물본번은 1 이상의 숫자로 입력해 주세요." }; } if (bldgNoSno !== null && (!Number.isInteger(bldgNoSno) || bldgNoSno <= 0)) { return { ok: false, message: "건물부번은 1 이상의 숫자로 입력해 주세요." }; } return { ok: true, value: { roadNm, bldgNoMno, bldgNoSno, page, }, keyword: `${roadNm} ${bldgNoMno}${bldgNoSno ? `-${bldgNoSno}` : ""}`, }; } export function extractSearchInputFromRoadAddr(roadAddr: string): string { const firstSegment = roadAddr.split(",")[0]?.trim() ?? ""; if (!firstSegment) return ""; const parts = firstSegment.split(/\s+/); const bldgToken = parts.at(-1) ?? ""; if (!/^\d+(?:-\d+)?$/.test(bldgToken)) return ""; return `${parts.slice(0, -1).join(" ")} ${bldgToken}`.trim(); } export async function fetchPostcodeSuggestions(input: string): Promise { const normalized = input.replace(/\s+/g, " ").trim(); const parts = normalized.split(" "); const tail = parts.at(-1) ?? ""; const roadNm = /^\d+(?:-\d+)?$/.test(tail) && parts.length > 1 ? parts.slice(0, -1).join(" ").trim() : normalized; if (!roadNm) return []; if (!searchFilter(roadNm)) return []; const data = await findAddress({ roadNm, page: 0, size: 50, }); const list = Array.isArray(data?.content) ? (data.content as Zip[]) : []; const suggestions: string[] = []; const seen = new Set(); for (const item of list) { const seed = extractSuggestionSeed(item); if (!seed) continue; const label = `${seed.roadNm} ${seed.bldgNoMno}`; if (seen.has(label)) continue; seen.add(label); suggestions.push(label); if (suggestions.length >= 5) break; } return suggestions; } export async function fetchPostcode( params: PostcodeSearchParams, ): Promise { const keywordForCheck = `${params.roadNm} ${params.bldgNoMno}${ params.bldgNoSno ? `-${params.bldgNoSno}` : "" }`; if (!searchFilter(keywordForCheck)) { return { totalCount: 0, items: [], }; } const query: ZipSearchParams = { roadNm: params.roadNm, bldgNoMno: params.bldgNoMno, bldgNoSno: params.bldgNoSno, page: Math.max(params.page - 1, 0), size: 20, }; const data = await findAddress(query); const list = Array.isArray(data?.content) ? (data.content as Zip[]) : []; const items: AddrItem[] = list.map((item) => ({ zipNo: item.zoneNo ?? "", roadAddr: item.zipAddr ?? "", jibunAddr: item.lotno ?? item.zipAddr ?? "", ctpvNm: item.ctpvNm ?? "", sggNm: item.sggNm ?? "", })); return { totalCount: Number(data?.totalElements ?? 0), items, }; }