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 fastifyCors from '@fastify/cors';
|
||||||
import config from './config/index.js';
|
import config from './config/index.js';
|
||||||
import dbPlugin from './plugins/db.js';
|
import dbPlugin from './plugins/db.js';
|
||||||
|
import trackerPlugin from './plugins/tracker.js';
|
||||||
import routes from './routes/index.js';
|
import routes from './routes/index.js';
|
||||||
|
|
||||||
export async function buildApp(opts = {}) {
|
export async function buildApp(opts = {}) {
|
||||||
|
|
@ -22,6 +23,7 @@ export async function buildApp(opts = {}) {
|
||||||
|
|
||||||
// 플러그인
|
// 플러그인
|
||||||
await fastify.register(dbPlugin);
|
await fastify.register(dbPlugin);
|
||||||
|
await fastify.register(trackerPlugin);
|
||||||
|
|
||||||
// 라우트
|
// 라우트
|
||||||
await fastify.register(routes, { prefix: '/api' });
|
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 = [
|
const DEFAULT_CARRIERS = [
|
||||||
['kr.cjlogistics', 'CJ대한통운', 'CJ', '#E4002B'],
|
['cjlogistics', 'CJ대한통운', 'CJ', '#E4002B', `${S3_BASE}/cjlogistics.svg`],
|
||||||
['kr.hanjin', '한진택배', '한진', '#1B3A6B'],
|
['hanjin', '한진택배', '한진', '#1B3A6B', `${S3_BASE}/hanjin.svg`],
|
||||||
['kr.lotte', '롯데택배', '롯데', '#ED1C24'],
|
['lotte', '롯데택배', '롯데', '#ED1C24', `${S3_BASE}/lotte.svg`],
|
||||||
['kr.epost', '우체국택배', '우체국', '#003DA5'],
|
['epost', '우체국택배', '우체국', '#003DA5', `${S3_BASE}/epost.svg`],
|
||||||
['kr.logen', '로젠택배', '로젠', '#F5A623'],
|
['logen', '로젠택배', '로젠', '#F5A623', `${S3_BASE}/logen.svg`],
|
||||||
['kr.kdexp', '경동택배', '경동', '#0066B3'],
|
|
||||||
['kr.cupost', 'CU편의점택배', 'CU', '#652D90'],
|
|
||||||
['kr.daesin', '대신택배', '대신', '#00A651'],
|
|
||||||
];
|
];
|
||||||
|
|
||||||
async function dbPlugin(fastify) {
|
async function dbPlugin(fastify) {
|
||||||
|
|
@ -71,10 +70,10 @@ async function dbPlugin(fastify) {
|
||||||
fastify.log.info('테이블 초기화 완료');
|
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(
|
await pool.execute(
|
||||||
'INSERT IGNORE INTO carriers (id, name, short_name, color) VALUES (?, ?, ?, ?)',
|
'INSERT IGNORE INTO carriers (id, name, short_name, color, logo_url) VALUES (?, ?, ?, ?, ?)',
|
||||||
[id, name, shortName, color]
|
[id, name, shortName, color, logoUrl]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
fastify.log.info('기본 택배사 데이터 초기화 완료');
|
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) {
|
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
|
- .env
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
|
depends_on:
|
||||||
|
- delivery-tracker
|
||||||
networks:
|
networks:
|
||||||
- app
|
- app
|
||||||
- db
|
- db
|
||||||
restart: unless-stopped
|
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:
|
networks:
|
||||||
app:
|
app:
|
||||||
external: true
|
external: true
|
||||||
|
|
|
||||||
58
docs/plan.md
58
docs/plan.md
|
|
@ -86,7 +86,7 @@ CREATE TABLE parcels (
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE carriers (
|
CREATE TABLE carriers (
|
||||||
id VARCHAR(50) PRIMARY KEY, -- 택배사 코드 (예: kr.cjlogistics)
|
id VARCHAR(50) PRIMARY KEY, -- 택배사 코드 (예: cjlogistics)
|
||||||
name VARCHAR(100) NOT NULL, -- 택배사 이름
|
name VARCHAR(100) NOT NULL, -- 택배사 이름
|
||||||
short_name VARCHAR(20) NOT NULL, -- 약칭 (로고 없을 때 이니셜용)
|
short_name VARCHAR(20) NOT NULL, -- 약칭 (로고 없을 때 이니셜용)
|
||||||
color VARCHAR(7) NOT NULL, -- 브랜드 컬러 (#hex)
|
color VARCHAR(7) NOT NULL, -- 브랜드 컬러 (#hex)
|
||||||
|
|
@ -94,6 +94,15 @@ CREATE TABLE carriers (
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
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 (
|
CREATE TABLE tracking_events (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
parcel_id INT NOT NULL,
|
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-frontend`: node:20-alpine, 포트 80, 네트워크 `app`
|
||||||
- `traeon-backend`: node:20-alpine, 포트 80, 네트워크 `app` + `db`
|
- `traeon-backend`: node:20-alpine, 포트 80, 네트워크 `app` + `db`
|
||||||
- `delivery-tracker`: 셀프호스팅 컨테이너, 네트워크 `app`
|
- `delivery-tracker`: 셀프호스팅 컨테이너, 네트워크 `app`
|
||||||
- Watchtower 제외 레이블
|
- Watchtower 제외 레이블
|
||||||
- [ ] `.env` 파일 작성 (DB 접속정보 등)
|
- [x] `.env` 파일 작성 (DB 접속정보 등)
|
||||||
- [ ] Caddy에 `traeon.caadiq.co.kr` 도메인 추가
|
- [x] Caddy에 `traeon.caadiq.co.kr` 도메인 추가
|
||||||
|
|
||||||
### 2단계: Frontend 구현 (더미 데이터)
|
### 2단계: Frontend 구현 (더미 데이터) ✅
|
||||||
|
|
||||||
- [ ] Vite + React + Tailwind 초기 세팅
|
- [x] Vite + React + Tailwind 초기 세팅
|
||||||
- [ ] 더미 데이터 준비 (택배사 목록, 배송 중/완료 샘플 데이터)
|
- [x] 더미 데이터 준비 (택배사 목록, 배송 중/완료 샘플 데이터)
|
||||||
- [ ] 메인 페이지
|
- [x] 메인 페이지
|
||||||
- 운송장 등록 폼 (택배사 선택 + 운송장 번호 + 별칭)
|
- 운송장 등록 폼 (커스텀 드롭다운 + 택배사 로고 + 운송장 번호 + 별칭)
|
||||||
- 택배 목록 (카드형, 상태별 그룹핑: 배송중 / 배송완료)
|
- 택배 목록 (카드형, 필터 탭으로 배송중/완료 구분)
|
||||||
- 각 카드에 현재 상태, 마지막 위치, 경과 시간 표시
|
- 각 카드에 택배사 로고, 현재 상태, 등록일 표시
|
||||||
- [ ] 상세 페이지
|
- [x] 상세 페이지
|
||||||
- 배송 추적 타임라인 (세로 타임라인 UI)
|
- 배송 추적 타임라인 (세로 타임라인 UI)
|
||||||
- 수동 새로고침 버튼
|
- 수동 새로고침 버튼 (상단 배치)
|
||||||
- 삭제/수정 기능
|
- 삭제/수정 기능 (제목 옆 아이콘)
|
||||||
- [ ] Zustand로 UI 상태 관리 (필터, 정렬)
|
- [x] Zustand로 UI 상태 관리 (필터, 정렬)
|
||||||
- [ ] 디자인 확인 후 피드백 반영
|
- [x] framer-motion 애니메이션 적용
|
||||||
|
- [x] PC/모바일 반응형 대응 (lg 브레이크포인트)
|
||||||
|
- [x] SVG 파비콘 로고 추가
|
||||||
|
- [x] Pretendard 폰트 적용
|
||||||
|
|
||||||
### 3단계: Backend 구현 + 연동
|
### 3단계: Backend 구현 + 연동
|
||||||
|
|
||||||
- [ ] Fastify 앱 세팅 (app.js, server.js) — fromis_9 패턴
|
- [x] Fastify 앱 세팅 (app.js, server.js) — fromis_9 패턴
|
||||||
- [ ] DB 플러그인 + 테이블 자동 생성 (parcels, tracking_events, carriers)
|
- [x] DB 플러그인 + 테이블 자동 생성 (parcels, tracking_events, carriers)
|
||||||
- [ ] 택배사 로고 이미지를 RustFS에 업로드하고 carriers 테이블에 logo_url 저장
|
- [x] 기본 택배사 5개 데이터 자동 삽입 (CJ대한통운, 한진, 롯데, 우체국, 로젠)
|
||||||
- [ ] delivery-tracker GraphQL 클라이언트 플러그인
|
- [x] 택배사 로고 SVG를 RustFS에 업로드하고 carriers 테이블에 logo_url 저장
|
||||||
- [ ] API 라우트 구현 (위 API 설계 참고)
|
- [x] delivery-tracker 셀프호스팅 (Docker 이미지 빌드, Apollo Server 포트 4000)
|
||||||
|
- [x] delivery-tracker GraphQL 클라이언트 플러그인 (tracker.js)
|
||||||
|
- [x] API 라우트 구현 (parcels CRUD + carriers 목록 + 수동 새로고침)
|
||||||
- [ ] Frontend를 더미 데이터에서 실제 API로 전환
|
- [ ] Frontend를 더미 데이터에서 실제 API로 전환
|
||||||
- Vite 프록시 설정 (`/api/` → backend)
|
- Vite 프록시 설정 (`/api/` → backend)
|
||||||
- React Query로 데이터 페칭 + 자동 리페칭
|
- React Query로 데이터 페칭 + 자동 리페칭
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue