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 }) {
|