From d9ba70de16a0c2792ed92613f43abae7fa5083be Mon Sep 17 00:00:00 2001 From: caadiq Date: Tue, 24 Mar 2026 18:57:42 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20delivery-tracker=20=EC=85=80=ED=94=84?= =?UTF-8?q?=ED=98=B8=EC=8A=A4=ED=8C=85=20+=20API=20=EB=9D=BC=EC=9A=B0?= =?UTF-8?q?=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - delivery-tracker Docker 이미지 빌드 및 컨테이너 추가 - GraphQL 클라이언트 플러그인 (tracker.js) - parcels CRUD API (등록/조회/수정/삭제/새로고침) - carriers 목록 API - 택배사 ID에서 kr. 접두사 제거, logo_url 추가 Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/src/app.js | 2 + backend/src/plugins/db.js | 21 ++-- backend/src/plugins/tracker.js | 101 +++++++++++++++++ backend/src/routes/carriers.js | 8 ++ backend/src/routes/index.js | 6 +- backend/src/routes/parcels.js | 197 +++++++++++++++++++++++++++++++++ docker-compose.yml | 11 ++ docs/plan.md | 58 ++++++---- 8 files changed, 370 insertions(+), 34 deletions(-) create mode 100644 backend/src/plugins/tracker.js create mode 100644 backend/src/routes/carriers.js create mode 100644 backend/src/routes/parcels.js diff --git a/backend/src/app.js b/backend/src/app.js index 4479e03..ee1c1d6 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -2,6 +2,7 @@ import Fastify from 'fastify'; import fastifyCors from '@fastify/cors'; import config from './config/index.js'; import dbPlugin from './plugins/db.js'; +import trackerPlugin from './plugins/tracker.js'; import routes from './routes/index.js'; export async function buildApp(opts = {}) { @@ -22,6 +23,7 @@ export async function buildApp(opts = {}) { // 플러그인 await fastify.register(dbPlugin); + await fastify.register(trackerPlugin); // 라우트 await fastify.register(routes, { prefix: '/api' }); diff --git a/backend/src/plugins/db.js b/backend/src/plugins/db.js index 5f483e4..236f661 100644 --- a/backend/src/plugins/db.js +++ b/backend/src/plugins/db.js @@ -39,15 +39,14 @@ CREATE TABLE IF NOT EXISTS tracking_events ( ); `; +const S3_BASE = 'https://s3.caadiq.co.kr/traeon/logo'; + const DEFAULT_CARRIERS = [ - ['kr.cjlogistics', 'CJ대한통운', 'CJ', '#E4002B'], - ['kr.hanjin', '한진택배', '한진', '#1B3A6B'], - ['kr.lotte', '롯데택배', '롯데', '#ED1C24'], - ['kr.epost', '우체국택배', '우체국', '#003DA5'], - ['kr.logen', '로젠택배', '로젠', '#F5A623'], - ['kr.kdexp', '경동택배', '경동', '#0066B3'], - ['kr.cupost', 'CU편의점택배', 'CU', '#652D90'], - ['kr.daesin', '대신택배', '대신', '#00A651'], + ['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`], ]; async function dbPlugin(fastify) { @@ -71,10 +70,10 @@ async function dbPlugin(fastify) { fastify.log.info('테이블 초기화 완료'); // 기본 택배사 데이터 삽입 - for (const [id, name, shortName, color] of DEFAULT_CARRIERS) { + for (const [id, name, shortName, color, logoUrl] of DEFAULT_CARRIERS) { await pool.execute( - 'INSERT IGNORE INTO carriers (id, name, short_name, color) VALUES (?, ?, ?, ?)', - [id, name, shortName, color] + 'INSERT IGNORE INTO carriers (id, name, short_name, color, logo_url) VALUES (?, ?, ?, ?, ?)', + [id, name, shortName, color, logoUrl] ); } fastify.log.info('기본 택배사 데이터 초기화 완료'); diff --git a/backend/src/plugins/tracker.js b/backend/src/plugins/tracker.js new file mode 100644 index 0000000..bcc4782 --- /dev/null +++ b/backend/src/plugins/tracker.js @@ -0,0 +1,101 @@ +import fp from 'fastify-plugin'; + +const TRACKER_URL = process.env.TRACKER_URL || 'http://delivery-tracker:4000/'; + +// delivery-tracker의 carrier ID는 kr. 접두사 필요 +const CARRIER_ID_MAP = { + cjlogistics: 'kr.cjlogistics', + hanjin: 'kr.hanjin', + lotte: 'kr.lotte', + epost: 'kr.epost', + logen: 'kr.logen', +}; + +const TRACK_QUERY = ` + query Track($carrierId: ID!, $trackingNumber: String!) { + track(carrierId: $carrierId, trackingNumber: $trackingNumber) { + trackingNumber + lastEvent { + status { code name } + time + location { name } + description + } + events(last: 100) { + edges { + node { + status { code name } + time + location { name } + description + } + } + } + } + } +`; + +async function trackerPlugin(fastify) { + const tracker = { + async track(carrierId, trackingNumber) { + const graphqlCarrierId = CARRIER_ID_MAP[carrierId] || `kr.${carrierId}`; + + const res = await fetch(TRACKER_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: TRACK_QUERY, + variables: { + carrierId: graphqlCarrierId, + trackingNumber, + }, + }), + }); + + const json = await res.json(); + + if (json.errors) { + const msg = json.errors[0]?.message || 'tracking failed'; + throw new Error(msg); + } + + const trackInfo = json.data?.track; + if (!trackInfo) { + return null; + } + + // 이벤트 정리 + const events = (trackInfo.events?.edges || []).map((edge) => { + const node = edge.node; + return { + status: node.status?.code || 'UNKNOWN', + statusName: node.status?.name || '', + description: node.description || '', + location: node.location?.name || '', + time: node.time || null, + }; + }); + + // 마지막 이벤트 + const lastEvent = trackInfo.lastEvent + ? { + status: trackInfo.lastEvent.status?.code || 'UNKNOWN', + statusName: trackInfo.lastEvent.status?.name || '', + description: trackInfo.lastEvent.description || '', + location: trackInfo.lastEvent.location?.name || '', + time: trackInfo.lastEvent.time || null, + } + : null; + + return { + trackingNumber: trackInfo.trackingNumber, + lastEvent, + events, + }; + }, + }; + + fastify.decorate('tracker', tracker); +} + +export default fp(trackerPlugin, { name: 'tracker' }); diff --git a/backend/src/routes/carriers.js b/backend/src/routes/carriers.js new file mode 100644 index 0000000..8aaf49e --- /dev/null +++ b/backend/src/routes/carriers.js @@ -0,0 +1,8 @@ +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' + ); + return rows; + }); +} diff --git a/backend/src/routes/index.js b/backend/src/routes/index.js index 20df283..ef46c48 100644 --- a/backend/src/routes/index.js +++ b/backend/src/routes/index.js @@ -1,3 +1,7 @@ +import parcelRoutes from './parcels.js'; +import carrierRoutes from './carriers.js'; + export default async function routes(fastify) { - // TODO: 라우트 추가 예정 + await fastify.register(parcelRoutes, { prefix: '/parcels' }); + await fastify.register(carrierRoutes, { prefix: '/carriers' }); } diff --git a/backend/src/routes/parcels.js b/backend/src/routes/parcels.js new file mode 100644 index 0000000..8c9133c --- /dev/null +++ b/backend/src/routes/parcels.js @@ -0,0 +1,197 @@ +export default async function parcelRoutes(fastify) { + // 전체 택배 목록 + fastify.get('/', async (request) => { + const { status } = request.query; + let sql = 'SELECT * FROM parcels ORDER BY created_at DESC'; + const params = []; + + if (status === 'active') { + sql = 'SELECT * FROM parcels WHERE status != ? ORDER BY created_at DESC'; + params.push('DELIVERED'); + } else if (status === 'delivered') { + sql = 'SELECT * FROM parcels WHERE status = ? ORDER BY created_at DESC'; + params.push('DELIVERED'); + } + + const [rows] = await fastify.db.execute(sql, params); + return rows; + }); + + // 운송장 등록 + fastify.post('/', async (request, reply) => { + const { carrierId, trackingNumber, label } = request.body; + + // 택배사 조회 + const [carriers] = await fastify.db.execute( + 'SELECT id, name FROM carriers WHERE id = ?', + [carrierId] + ); + if (carriers.length === 0) { + return reply.code(400).send({ error: '지원하지 않는 택배사입니다' }); + } + + const carrier = carriers[0]; + + // 중복 확인 + const [existing] = await fastify.db.execute( + 'SELECT id FROM parcels WHERE carrier_id = ? AND tracking_number = ?', + [carrierId, trackingNumber] + ); + if (existing.length > 0) { + return reply.code(409).send({ error: '이미 등록된 운송장입니다' }); + } + + // 등록 + const [result] = await fastify.db.execute( + 'INSERT INTO parcels (carrier_id, carrier_name, tracking_number, label) VALUES (?, ?, ?, ?)', + [carrierId, carrier.name, trackingNumber, label || null] + ); + + const parcelId = result.insertId; + + // 즉시 배송 조회 + try { + await refreshParcel(fastify, parcelId); + } catch (err) { + fastify.log.warn(`등록 후 조회 실패: ${err.message}`); + } + + const [parcels] = await fastify.db.execute( + 'SELECT * FROM parcels WHERE id = ?', + [parcelId] + ); + + return reply.code(201).send(parcels[0]); + }); + + // 개별 택배 상세 + 추적 이벤트 + fastify.get('/:id', async (request, reply) => { + const { id } = request.params; + + const [parcels] = await fastify.db.execute( + 'SELECT * FROM parcels WHERE id = ?', + [id] + ); + if (parcels.length === 0) { + return reply.code(404).send({ error: '택배를 찾을 수 없습니다' }); + } + + const [events] = await fastify.db.execute( + 'SELECT * FROM tracking_events WHERE parcel_id = ? ORDER BY event_time ASC', + [id] + ); + + return { ...parcels[0], events }; + }); + + // 별칭 수정 + fastify.put('/:id', async (request, reply) => { + const { id } = request.params; + const { label } = request.body; + + const [result] = await fastify.db.execute( + 'UPDATE parcels SET label = ? WHERE id = ?', + [label, id] + ); + + if (result.affectedRows === 0) { + return reply.code(404).send({ error: '택배를 찾을 수 없습니다' }); + } + + const [parcels] = await fastify.db.execute( + 'SELECT * FROM parcels WHERE id = ?', + [id] + ); + return parcels[0]; + }); + + // 운송장 삭제 + fastify.delete('/:id', async (request, reply) => { + const { id } = request.params; + + const [result] = await fastify.db.execute( + 'DELETE FROM parcels WHERE id = ?', + [id] + ); + + if (result.affectedRows === 0) { + return reply.code(404).send({ error: '택배를 찾을 수 없습니다' }); + } + + return { success: true }; + }); + + // 수동 새로고침 + fastify.post('/:id/refresh', async (request, reply) => { + const { id } = request.params; + + const [parcels] = await fastify.db.execute( + 'SELECT * FROM parcels WHERE id = ?', + [id] + ); + if (parcels.length === 0) { + return reply.code(404).send({ error: '택배를 찾을 수 없습니다' }); + } + + await refreshParcel(fastify, id); + + const [updated] = await fastify.db.execute( + 'SELECT * FROM parcels WHERE id = ?', + [id] + ); + const [events] = await fastify.db.execute( + 'SELECT * FROM tracking_events WHERE parcel_id = ? ORDER BY event_time ASC', + [id] + ); + + return { ...updated[0], events }; + }); +} + +function toMysqlDatetime(isoString) { + if (!isoString) return null; + const d = new Date(isoString); + if (isNaN(d.getTime())) return null; + return d.toISOString().slice(0, 19).replace('T', ' '); +} + +async function refreshParcel(fastify, parcelId) { + const [parcels] = await fastify.db.execute( + 'SELECT * FROM parcels WHERE id = ?', + [parcelId] + ); + if (parcels.length === 0) return; + + const parcel = parcels[0]; + const result = await fastify.tracker.track(parcel.carrier_id, parcel.tracking_number); + + if (!result) { + await fastify.db.execute( + 'UPDATE parcels SET last_checked_at = NOW() WHERE id = ?', + [parcelId] + ); + return; + } + + // 상태 업데이트 + const status = result.lastEvent?.status || 'UNKNOWN'; + const lastDetail = result.lastEvent?.description || ''; + const deliveredAt = status === 'DELIVERED' ? toMysqlDatetime(result.lastEvent?.time) : null; + + await fastify.db.execute( + `UPDATE parcels SET status = ?, last_detail = ?, last_checked_at = NOW(), + delivered_at = COALESCE(?, delivered_at) WHERE id = ?`, + [status, lastDetail, deliveredAt, parcelId] + ); + + // 기존 이벤트 삭제 후 새로 삽입 + await fastify.db.execute('DELETE FROM tracking_events WHERE parcel_id = ?', [parcelId]); + + for (const event of result.events) { + await fastify.db.execute( + `INSERT INTO tracking_events (parcel_id, status, status_name, description, location, event_time) + VALUES (?, ?, ?, ?, ?, ?)`, + [parcelId, event.status, event.statusName, event.description, event.location, toMysqlDatetime(event.time)] + ); + } +} diff --git a/docker-compose.yml b/docker-compose.yml index c571933..42e3e5e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,11 +21,22 @@ services: - .env volumes: - ./backend:/app + depends_on: + - delivery-tracker networks: - app - db restart: unless-stopped + delivery-tracker: + image: delivery-tracker:latest + container_name: delivery-tracker + labels: + - "com.centurylinklabs.watchtower.enable=false" + networks: + - app + restart: unless-stopped + networks: app: external: true diff --git a/docs/plan.md b/docs/plan.md index 7ba0730..cd74c9a 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -86,7 +86,7 @@ CREATE TABLE parcels ( ); CREATE TABLE carriers ( - id VARCHAR(50) PRIMARY KEY, -- 택배사 코드 (예: kr.cjlogistics) + id VARCHAR(50) PRIMARY KEY, -- 택배사 코드 (예: cjlogistics) name VARCHAR(100) NOT NULL, -- 택배사 이름 short_name VARCHAR(20) NOT NULL, -- 약칭 (로고 없을 때 이니셜용) color VARCHAR(7) NOT NULL, -- 브랜드 컬러 (#hex) @@ -94,6 +94,15 @@ CREATE TABLE carriers ( created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); +-- 기본 택배사 데이터 (5개) +-- | id | name | logo_url | +-- |--------------|------------|------------------------------------------------| +-- | cjlogistics | CJ대한통운 | https://s3.caadiq.co.kr/traeon/logo/cjlogistics.svg | +-- | hanjin | 한진택배 | https://s3.caadiq.co.kr/traeon/logo/hanjin.svg | +-- | lotte | 롯데택배 | https://s3.caadiq.co.kr/traeon/logo/lotte.svg | +-- | epost | 우체국택배 | https://s3.caadiq.co.kr/traeon/logo/epost.svg | +-- | logen | 로젠택배 | https://s3.caadiq.co.kr/traeon/logo/logen.svg | + CREATE TABLE tracking_events ( id INT AUTO_INCREMENT PRIMARY KEY, parcel_id INT NOT NULL, @@ -120,38 +129,43 @@ CREATE TABLE tracking_events ( ## 구현 단계 -### 1단계: 프로젝트 초기 세팅 +### 1단계: 프로젝트 초기 세팅 ✅ -- [ ] `docker-compose.yml` 작성 +- [x] `docker-compose.yml` 작성 - `traeon-frontend`: node:20-alpine, 포트 80, 네트워크 `app` - `traeon-backend`: node:20-alpine, 포트 80, 네트워크 `app` + `db` - `delivery-tracker`: 셀프호스팅 컨테이너, 네트워크 `app` - Watchtower 제외 레이블 -- [ ] `.env` 파일 작성 (DB 접속정보 등) -- [ ] Caddy에 `traeon.caadiq.co.kr` 도메인 추가 +- [x] `.env` 파일 작성 (DB 접속정보 등) +- [x] Caddy에 `traeon.caadiq.co.kr` 도메인 추가 -### 2단계: Frontend 구현 (더미 데이터) +### 2단계: Frontend 구현 (더미 데이터) ✅ -- [ ] Vite + React + Tailwind 초기 세팅 -- [ ] 더미 데이터 준비 (택배사 목록, 배송 중/완료 샘플 데이터) -- [ ] 메인 페이지 - - 운송장 등록 폼 (택배사 선택 + 운송장 번호 + 별칭) - - 택배 목록 (카드형, 상태별 그룹핑: 배송중 / 배송완료) - - 각 카드에 현재 상태, 마지막 위치, 경과 시간 표시 -- [ ] 상세 페이지 +- [x] Vite + React + Tailwind 초기 세팅 +- [x] 더미 데이터 준비 (택배사 목록, 배송 중/완료 샘플 데이터) +- [x] 메인 페이지 + - 운송장 등록 폼 (커스텀 드롭다운 + 택배사 로고 + 운송장 번호 + 별칭) + - 택배 목록 (카드형, 필터 탭으로 배송중/완료 구분) + - 각 카드에 택배사 로고, 현재 상태, 등록일 표시 +- [x] 상세 페이지 - 배송 추적 타임라인 (세로 타임라인 UI) - - 수동 새로고침 버튼 - - 삭제/수정 기능 -- [ ] Zustand로 UI 상태 관리 (필터, 정렬) -- [ ] 디자인 확인 후 피드백 반영 + - 수동 새로고침 버튼 (상단 배치) + - 삭제/수정 기능 (제목 옆 아이콘) +- [x] Zustand로 UI 상태 관리 (필터, 정렬) +- [x] framer-motion 애니메이션 적용 +- [x] PC/모바일 반응형 대응 (lg 브레이크포인트) +- [x] SVG 파비콘 로고 추가 +- [x] Pretendard 폰트 적용 ### 3단계: Backend 구현 + 연동 -- [ ] Fastify 앱 세팅 (app.js, server.js) — fromis_9 패턴 -- [ ] DB 플러그인 + 테이블 자동 생성 (parcels, tracking_events, carriers) -- [ ] 택배사 로고 이미지를 RustFS에 업로드하고 carriers 테이블에 logo_url 저장 -- [ ] delivery-tracker GraphQL 클라이언트 플러그인 -- [ ] API 라우트 구현 (위 API 설계 참고) +- [x] Fastify 앱 세팅 (app.js, server.js) — fromis_9 패턴 +- [x] DB 플러그인 + 테이블 자동 생성 (parcels, tracking_events, carriers) +- [x] 기본 택배사 5개 데이터 자동 삽입 (CJ대한통운, 한진, 롯데, 우체국, 로젠) +- [x] 택배사 로고 SVG를 RustFS에 업로드하고 carriers 테이블에 logo_url 저장 +- [x] delivery-tracker 셀프호스팅 (Docker 이미지 빌드, Apollo Server 포트 4000) +- [x] delivery-tracker GraphQL 클라이언트 플러그인 (tracker.js) +- [x] API 라우트 구현 (parcels CRUD + carriers 목록 + 수동 새로고침) - [ ] Frontend를 더미 데이터에서 실제 API로 전환 - Vite 프록시 설정 (`/api/` → backend) - React Query로 데이터 페칭 + 자동 리페칭