From 6f89d8f0b266de4a65cb1348f4f93f751065086b Mon Sep 17 00:00:00 2001 From: caadiq Date: Tue, 24 Mar 2026 20:14:42 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=AC=BC=ED=92=88=EB=AA=85=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EC=9E=85=EB=A0=A5=20+=20=EC=9A=B4=EC=86=A1?= =?UTF-8?q?=EC=9E=A5=20=ED=8C=A8=ED=84=B4=20=EC=B6=94=EC=B2=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CJ대한통운, 한진택배 물품명 자동 파싱 (goods_name) - 별칭 미입력 시 물품명을 label로 자동 설정 - carriers 테이블에 tracking_pattern 컬럼 추가 - 운송장 번호 패턴 기반 택배사 추천 (DB 기반, 하드코딩 제거) - 보내는분/받는분 헤더 텍스트 필터링 Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/src/plugins/db.js | 35 +++++++----- backend/src/plugins/tracker.js | 9 ++- backend/src/routes/carriers.js | 2 +- backend/src/routes/parcels.js | 14 ++++- backend/src/services/goods-name.js | 62 ++++++++++++++++++++ docs/plan.md | 4 +- frontend/src/components/CarrierSelect.jsx | 70 ++++++++++++++++------- frontend/src/components/ParcelDialog.jsx | 2 +- frontend/src/components/ParcelForm.jsx | 6 +- frontend/src/utils/carrier-pattern.js | 70 +++++++++++++++++++++++ 10 files changed, 227 insertions(+), 47 deletions(-) create mode 100644 backend/src/services/goods-name.js create mode 100644 frontend/src/utils/carrier-pattern.js diff --git a/backend/src/plugins/db.js b/backend/src/plugins/db.js index 427f81f..43ad4d4 100644 --- a/backend/src/plugins/db.js +++ b/backend/src/plugins/db.js @@ -8,6 +8,7 @@ CREATE TABLE IF NOT EXISTS carriers ( short_name VARCHAR(20) NOT NULL, color VARCHAR(7) NOT NULL, logo_url VARCHAR(500), + tracking_pattern VARCHAR(100), created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); @@ -17,6 +18,7 @@ CREATE TABLE IF NOT EXISTS parcels ( carrier_name VARCHAR(100) NOT NULL, tracking_number VARCHAR(100) NOT NULL, label VARCHAR(200), + goods_name VARCHAR(300), sender_name VARCHAR(100), recipient_name VARCHAR(100), status VARCHAR(50) DEFAULT 'PENDING', @@ -44,11 +46,11 @@ CREATE TABLE IF NOT EXISTS tracking_events ( const S3_BASE = 'https://s3.caadiq.co.kr/traeon/logo'; const DEFAULT_CARRIERS = [ - ['cjlogistics', 'CJ대한통운', 'CJ', '#E4002B', `${S3_BASE}/cjlogistics.svg`], - ['hanjin', '한진택배', '한진', '#1B3A6B', `${S3_BASE}/hanjin.svg`], - ['lotte', '롯데택배', '롯데', '#ED1C24', `${S3_BASE}/lotte.svg`], - ['epost', '우체국택배', '우체국', '#003DA5', `${S3_BASE}/epost.svg`], - ['logen', '로젠택배', '로젠', '#F5A623', `${S3_BASE}/logen.svg`], + ['cjlogistics', 'CJ대한통운', 'CJ', '#E4002B', `${S3_BASE}/cjlogistics.svg`, '10-12'], + ['hanjin', '한진택배', '한진', '#1B3A6B', `${S3_BASE}/hanjin.svg`, '10,12'], + ['lotte', '롯데택배', '롯데', '#ED1C24', `${S3_BASE}/lotte.svg`, '12'], + ['epost', '우체국택배', '우체국', '#003DA5', `${S3_BASE}/epost.svg`, '13'], + ['logen', '로젠택배', '로젠', '#F5A623', `${S3_BASE}/logen.svg`, '11'], ]; async function dbPlugin(fastify) { @@ -71,19 +73,22 @@ async function dbPlugin(fastify) { } fastify.log.info('테이블 초기화 완료'); - // 마이그레이션: sender_name, recipient_name 컬럼 추가 - try { - await pool.execute('ALTER TABLE parcels ADD COLUMN sender_name VARCHAR(100) AFTER label'); - } catch (e) { /* 이미 존재 */ } - try { - await pool.execute('ALTER TABLE parcels ADD COLUMN recipient_name VARCHAR(100) AFTER sender_name'); - } catch (e) { /* 이미 존재 */ } + // 마이그레이션 + const migrations = [ + 'ALTER TABLE parcels ADD COLUMN sender_name VARCHAR(100) AFTER label', + 'ALTER TABLE parcels ADD COLUMN recipient_name VARCHAR(100) AFTER sender_name', + 'ALTER TABLE parcels ADD COLUMN goods_name VARCHAR(300) AFTER label', + 'ALTER TABLE carriers ADD COLUMN tracking_pattern VARCHAR(100) AFTER logo_url', + ]; + for (const sql of migrations) { + try { await pool.execute(sql); } catch (e) { /* 이미 존재 */ } + } // 기본 택배사 데이터 삽입 - for (const [id, name, shortName, color, logoUrl] of DEFAULT_CARRIERS) { + for (const [id, name, shortName, color, logoUrl, pattern] of DEFAULT_CARRIERS) { await pool.execute( - 'INSERT IGNORE INTO carriers (id, name, short_name, color, logo_url) VALUES (?, ?, ?, ?, ?)', - [id, name, shortName, color, logoUrl] + 'INSERT INTO carriers (id, name, short_name, color, logo_url, tracking_pattern) VALUES (?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE tracking_pattern = VALUES(tracking_pattern)', + [id, name, shortName, color, logoUrl, pattern] ); } fastify.log.info('기본 택배사 데이터 초기화 완료'); diff --git a/backend/src/plugins/tracker.js b/backend/src/plugins/tracker.js index 92dfe6d..71e61d0 100644 --- a/backend/src/plugins/tracker.js +++ b/backend/src/plugins/tracker.js @@ -89,10 +89,15 @@ async function trackerPlugin(fastify) { } : null; + // 헤더 텍스트가 그대로 나오는 경우 필터링 + const invalidNames = ['보내는 분', '보내는분', '받는 분', '받는분']; + const senderName = trackInfo.sender?.name || null; + const recipientName = trackInfo.recipient?.name || null; + return { trackingNumber: trackInfo.trackingNumber, - senderName: trackInfo.sender?.name || null, - recipientName: trackInfo.recipient?.name || null, + senderName: invalidNames.includes(senderName) ? null : senderName, + recipientName: invalidNames.includes(recipientName) ? null : recipientName, lastEvent, events, }; diff --git a/backend/src/routes/carriers.js b/backend/src/routes/carriers.js index 8aaf49e..45f83af 100644 --- a/backend/src/routes/carriers.js +++ b/backend/src/routes/carriers.js @@ -1,7 +1,7 @@ export default async function carrierRoutes(fastify) { fastify.get('/', async () => { const [rows] = await fastify.db.execute( - 'SELECT id, name, short_name, color, logo_url FROM carriers ORDER BY name' + 'SELECT id, name, short_name, color, logo_url, tracking_pattern FROM carriers ORDER BY name' ); return rows; }); diff --git a/backend/src/routes/parcels.js b/backend/src/routes/parcels.js index 35f0abe..8fb2983 100644 --- a/backend/src/routes/parcels.js +++ b/backend/src/routes/parcels.js @@ -1,3 +1,5 @@ +import { fetchGoodsName } from '../services/goods-name.js'; + export default async function parcelRoutes(fastify) { // 전체 택배 목록 (페이징) fastify.get('/', async (request) => { @@ -61,10 +63,18 @@ export default async function parcelRoutes(fastify) { return reply.code(409).send({ error: '이미 등록된 운송장입니다' }); } + // 물품명 자동 조회 (CJ대한통운, 한진택배) + let goodsName = null; + try { + goodsName = await fetchGoodsName(carrierId, trackingNumber); + } catch (err) { + fastify.log.warn(`물품명 조회 실패: ${err.message}`); + } + // 등록 const [result] = await fastify.db.execute( - 'INSERT INTO parcels (carrier_id, carrier_name, tracking_number, label) VALUES (?, ?, ?, ?)', - [carrierId, carrier.name, trackingNumber, label || null] + 'INSERT INTO parcels (carrier_id, carrier_name, tracking_number, label, goods_name) VALUES (?, ?, ?, ?, ?)', + [carrierId, carrier.name, trackingNumber, label || goodsName || null, goodsName] ); const parcelId = result.insertId; diff --git a/backend/src/services/goods-name.js b/backend/src/services/goods-name.js new file mode 100644 index 0000000..787f80a --- /dev/null +++ b/backend/src/services/goods-name.js @@ -0,0 +1,62 @@ +/** + * 택배사 웹사이트에서 물품명을 직접 파싱 + * 지원: CJ대한통운, 한진택배 + */ + +export async function fetchGoodsName(carrierId, trackingNumber) { + try { + switch (carrierId) { + case 'cjlogistics': + return await fetchCjGoodsName(trackingNumber); + case 'hanjin': + return await fetchHanjinGoodsName(trackingNumber); + default: + return null; + } + } catch (err) { + return null; + } +} + +async function fetchCjGoodsName(trackingNumber) { + const res = await fetch( + 'https://trace.cjlogistics.com/next/rest/selectTrackingWaybil.do', + { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: `wblNo=${trackingNumber}`, + } + ); + + const json = await res.json(); + const goodsName = json?.data?.repGoodsNm; + return goodsName?.trim() || null; +} + +async function fetchHanjinGoodsName(trackingNumber) { + const res = await fetch( + 'https://www.hanjin.com/kor/CMS/DeliveryMgr/WaybillResult.do', + { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + wblnum: trackingNumber, + mCode: 'MN038', + schLang: 'KR', + }).toString(), + } + ); + + const html = await res.text(); + + // 첫 번째 테이블의 td[4]가 물품명 + const tdMatches = html.match(/]*>([\s\S]*?)<\/td>/g); + if (!tdMatches || tdMatches.length < 5) return null; + + const goodsTd = tdMatches[4]; + const goodsName = goodsTd + .replace(/<[^>]+>/g, '') + .trim(); + + return goodsName || null; +} diff --git a/docs/plan.md b/docs/plan.md index a50e101..e5dc63d 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -174,9 +174,7 @@ CREATE TABLE tracking_events ( - [x] 자동갱신 cron 서비스 (node-cron, 30분 간격) - 배송 완료되지 않은 택배만 자동 조회 (DELIVERED 제외) - 상태 변경 시 로그 기록 -- [ ] 알림 서비스 - - Discord Webhook 또는 Telegram Bot API - - 설정 가능한 Webhook URL (.env) +- [ ] 알림 서비스 (보류 — 추후 앱 버전에서 푸시 알림으로 구현 예정) ### 4단계: Docker 배포 diff --git a/frontend/src/components/CarrierSelect.jsx b/frontend/src/components/CarrierSelect.jsx index be5743f..0c212aa 100644 --- a/frontend/src/components/CarrierSelect.jsx +++ b/frontend/src/components/CarrierSelect.jsx @@ -1,10 +1,11 @@ -import { useState, useRef, useEffect } from "react"; +import { useState, useRef, useEffect, useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; import { AnimatePresence, motion } from "framer-motion"; -import { ChevronDown } from "lucide-react"; +import { ChevronDown, Sparkles } from "lucide-react"; import { fetchCarriers } from "@/api/parcels"; +import { getMatchingCarriers } from "@/utils/carrier-pattern"; -function CarrierSelect({ value, onChange }) { +function CarrierSelect({ value, onChange, trackingNumber }) { const [open, setOpen] = useState(false); const ref = useRef(null); @@ -16,6 +17,23 @@ function CarrierSelect({ value, onChange }) { const selected = carriers.find((c) => c.id === value); + const matchingIds = useMemo( + () => getMatchingCarriers(trackingNumber, carriers), + [trackingNumber, carriers] + ); + + const sortedCarriers = useMemo(() => { + if (matchingIds.length === 0) return carriers; + return [...carriers].sort((a, b) => { + const aMatch = matchingIds.indexOf(a.id); + const bMatch = matchingIds.indexOf(b.id); + if (aMatch !== -1 && bMatch !== -1) return aMatch - bMatch; + if (aMatch !== -1) return -1; + if (bMatch !== -1) return 1; + return 0; + }); + }, [carriers, matchingIds]); + useEffect(() => { function handleClickOutside(e) { if (ref.current && !ref.current.contains(e.target)) { @@ -36,7 +54,7 @@ function CarrierSelect({ value, onChange }) { - - ))} + {sortedCarriers.map((carrier) => { + const isMatch = matchingIds.includes(carrier.id); + const isSelected = value === carrier.id; + return ( +
  • + +
  • + ); + })} )} diff --git a/frontend/src/components/ParcelDialog.jsx b/frontend/src/components/ParcelDialog.jsx index 0052ee6..180617c 100644 --- a/frontend/src/components/ParcelDialog.jsx +++ b/frontend/src/components/ParcelDialog.jsx @@ -155,7 +155,7 @@ function ParcelDialog({ parcelId, onClose }) { {(parcel.sender_name || parcel.recipient_name) && ( -
    +
    {parcel.sender_name && ( 보내는 분: {parcel.sender_name} )} diff --git a/frontend/src/components/ParcelForm.jsx b/frontend/src/components/ParcelForm.jsx index aed36a3..87e9bef 100644 --- a/frontend/src/components/ParcelForm.jsx +++ b/frontend/src/components/ParcelForm.jsx @@ -52,7 +52,7 @@ function ParcelForm({ onClose }) { - +
    @@ -63,7 +63,7 @@ function ParcelForm({ onClose }) { type="text" value={trackingNumber} onChange={(e) => setTrackingNumber(e.target.value)} - placeholder="운송장 번호를 입력하세요" + placeholder="운송장 번호 입력" className="w-full border border-gray-300 rounded-lg px-3 lg:px-4 py-2 lg:py-2.5 text-sm lg:text-base focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary" required /> @@ -77,7 +77,7 @@ function ParcelForm({ onClose }) { type="text" value={label} onChange={(e) => setLabel(e.target.value)} - placeholder="예: 쿠팡 - 키보드" + placeholder="상품명 입력" className="w-full border border-gray-300 rounded-lg px-3 lg:px-4 py-2 lg:py-2.5 text-sm lg:text-base focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary" />
    diff --git a/frontend/src/utils/carrier-pattern.js b/frontend/src/utils/carrier-pattern.js new file mode 100644 index 0000000..a16d81e --- /dev/null +++ b/frontend/src/utils/carrier-pattern.js @@ -0,0 +1,70 @@ +/** + * tracking_pattern 형식: + * - "13" → 13자리만 매칭 + * - "10,12" → 10자리 또는 12자리 매칭 + * - "10-12" → 10~12자리 범위 매칭 + */ + +function parsePattern(pattern) { + if (!pattern) return null; + + if (pattern.includes("-")) { + const [min, max] = pattern.split("-").map(Number); + return { type: "range", min, max }; + } + + if (pattern.includes(",")) { + const lengths = pattern.split(",").map(Number); + return { type: "list", lengths }; + } + + return { type: "exact", length: Number(pattern) }; +} + +function matchesPattern(parsed, numLength) { + if (!parsed) return false; + + switch (parsed.type) { + case "range": + return numLength >= parsed.min && numLength <= parsed.max; + case "list": + return parsed.lengths.includes(numLength); + case "exact": + return numLength === parsed.length; + default: + return false; + } +} + +function patternSpecificity(parsed, numLength) { + if (!parsed) return 0; + + switch (parsed.type) { + case "exact": + return 5; + case "list": + return 3; + case "range": + return 1; + default: + return 0; + } +} + +export function getMatchingCarriers(trackingNumber, carriers) { + if (!trackingNumber || !carriers?.length) return []; + + const num = trackingNumber.replace(/\s/g, ""); + if (num.length < 10 || !/^\d+$/.test(num)) return []; + + return carriers + .map((carrier) => { + const parsed = parsePattern(carrier.tracking_pattern); + const matches = matchesPattern(parsed, num.length); + const specificity = patternSpecificity(parsed, num.length); + return { id: carrier.id, matches, specificity }; + }) + .filter((c) => c.matches) + .sort((a, b) => b.specificity - a.specificity) + .map((c) => c.id); +}