feat: delivery-tracker 셀프호스팅 + API 라우트 구현

- delivery-tracker Docker 이미지 빌드 및 컨테이너 추가
- GraphQL 클라이언트 플러그인 (tracker.js)
- parcels CRUD API (등록/조회/수정/삭제/새로고침)
- carriers 목록 API
- 택배사 ID에서 kr. 접두사 제거, logo_url 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-03-24 18:57:42 +09:00
parent d02bc5f738
commit d9ba70de16
8 changed files with 370 additions and 34 deletions

View file

@ -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' });

View file

@ -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('기본 택배사 데이터 초기화 완료');

View file

@ -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' });

View file

@ -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;
});
}

View file

@ -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' });
}

View file

@ -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)]
);
}
}

View file

@ -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

View file

@ -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로 데이터 페칭 + 자동 리페칭