From edc3a2c3d753ad514faa6f4a6e9f3cbafcd231be Mon Sep 17 00:00:00 2001 From: caadiq Date: Tue, 24 Mar 2026 19:48:36 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9E=90=EB=8F=99=EA=B0=B1=EC=8B=A0=20?= =?UTF-8?q?cron=20+=20react-router=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - node-cron 30분 간격 배송 자동 조회 (DELIVERED 제외) - 상태 변경 시 로그 기록 - react-router-dom 제거 (다이얼로그 전환으로 불필요) Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/src/server.js | 3 ++ backend/src/services/cron.js | 101 +++++++++++++++++++++++++++++++++++ docs/plan.md | 7 ++- frontend/package.json | 2 +- frontend/src/main.jsx | 5 +- 5 files changed, 109 insertions(+), 9 deletions(-) create mode 100644 backend/src/services/cron.js diff --git a/backend/src/server.js b/backend/src/server.js index fef8d7b..cbca407 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -1,5 +1,6 @@ import { buildApp } from './app.js'; import config from './config/index.js'; +import { startCronJobs } from './services/cron.js'; async function start() { const app = await buildApp(); @@ -10,6 +11,8 @@ async function start() { host: config.server.host, }); + startCronJobs(app); + app.log.info(`서버 시작: http://${config.server.host}:${config.server.port}`); } catch (err) { app.log.error(err); diff --git a/backend/src/services/cron.js b/backend/src/services/cron.js new file mode 100644 index 0000000..3314604 --- /dev/null +++ b/backend/src/services/cron.js @@ -0,0 +1,101 @@ +import cron from 'node-cron'; + +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', ' '); +} + +export function startCronJobs(fastify) { + // 30분 간격으로 배송중인 택배 자동 조회 + cron.schedule('*/30 * * * *', async () => { + try { + const [parcels] = await fastify.db.execute( + "SELECT * FROM parcels WHERE status != 'DELIVERED'" + ); + + if (parcels.length === 0) return; + + fastify.log.info(`[cron] 배송중 택배 ${parcels.length}건 자동 조회 시작`); + + for (const parcel of parcels) { + try { + 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 = ?', + [parcel.id] + ); + continue; + } + + const status = result.lastEvent?.status || 'UNKNOWN'; + const lastDetail = result.lastEvent?.description || ''; + const deliveredAt = + status === 'DELIVERED' + ? toMysqlDatetime(result.lastEvent?.time) + : null; + const prevStatus = parcel.status; + + await fastify.db.execute( + `UPDATE parcels SET status = ?, last_detail = ?, last_checked_at = NOW(), + delivered_at = COALESCE(?, delivered_at), + sender_name = COALESCE(?, sender_name), + recipient_name = COALESCE(?, recipient_name) + WHERE id = ?`, + [ + status, + lastDetail, + deliveredAt, + result.senderName, + result.recipientName, + parcel.id, + ] + ); + + // 이벤트 갱신 + await fastify.db.execute( + 'DELETE FROM tracking_events WHERE parcel_id = ?', + [parcel.id] + ); + + for (const event of result.events) { + await fastify.db.execute( + `INSERT INTO tracking_events (parcel_id, status, status_name, description, location, event_time) + VALUES (?, ?, ?, ?, ?, ?)`, + [ + parcel.id, + event.status, + event.statusName, + event.description, + event.location, + toMysqlDatetime(event.time), + ] + ); + } + + if (prevStatus !== status) { + fastify.log.info( + `[cron] ${parcel.carrier_name} ${parcel.tracking_number}: ${prevStatus} → ${status}` + ); + } + } catch (err) { + fastify.log.warn( + `[cron] 조회 실패 (${parcel.tracking_number}): ${err.message}` + ); + } + } + + fastify.log.info(`[cron] 자동 조회 완료`); + } catch (err) { + fastify.log.error(`[cron] 오류: ${err.message}`); + } + }); + + fastify.log.info('[cron] 자동 조회 스케줄러 시작 (30분 간격)'); +} diff --git a/docs/plan.md b/docs/plan.md index 960bc2c..a50e101 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -171,10 +171,9 @@ CREATE TABLE tracking_events ( - React Query로 데이터 페칭 (택배 목록, 상세, 택배사 목록) - 등록/수정/삭제/새로고침 mutation + 캐시 무효화 - 택배사 로고를 RustFS URL에서 로드 -- [ ] 자동갱신 cron 서비스 (node-cron) - - 배송 중인 택배: 30분 간격 자동 조회 - - 배송 완료된 택배: 조회 중단 - - 상태 변경 감지 시 알림 트리거 +- [x] 자동갱신 cron 서비스 (node-cron, 30분 간격) + - 배송 완료되지 않은 택배만 자동 조회 (DELIVERED 제외) + - 상태 변경 시 로그 기록 - [ ] 알림 서비스 - Discord Webhook 또는 Telegram Bot API - 설정 가능한 Webhook URL (.env) diff --git a/frontend/package.json b/frontend/package.json index 8f1d76f..d14aa91 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,7 +16,7 @@ "lucide-react": "^0.344.0", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-router-dom": "^6.22.3", + "zustand": "^5.0.9" }, "devDependencies": { diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index c506b7f..68f6663 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -1,6 +1,5 @@ import React from "react"; import ReactDOM from "react-dom/client"; -import { BrowserRouter } from "react-router-dom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import App from "./App"; import "./index.css"; @@ -18,9 +17,7 @@ const queryClient = new QueryClient({ ReactDOM.createRoot(document.getElementById("root")).render( - - - + );