291 lines
7.4 KiB
TypeScript
291 lines
7.4 KiB
TypeScript
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<ZipListResponse> {
|
|
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<string[]> {
|
|
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<string>();
|
|
|
|
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<PostcodeSearchResult> {
|
|
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,
|
|
};
|
|
}
|