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:
parent
d02bc5f738
commit
d9ba70de16
8 changed files with 370 additions and 34 deletions
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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('기본 택배사 데이터 초기화 완료');
|
||||
|
|
|
|||
101
backend/src/plugins/tracker.js
Normal file
101
backend/src/plugins/tracker.js
Normal 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' });
|
||||
8
backend/src/routes/carriers.js
Normal file
8
backend/src/routes/carriers.js
Normal 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;
|
||||
});
|
||||
}
|
||||
|
|
@ -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' });
|
||||
}
|
||||
|
|
|
|||
197
backend/src/routes/parcels.js
Normal file
197
backend/src/routes/parcels.js
Normal 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)]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
58
docs/plan.md
58
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로 데이터 페칭 + 자동 리페칭
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue