초기 관제 시스템 뼈대 구성
This commit is contained in:
1
apps/server/data/.gitkeep
Normal file
1
apps/server/data/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
14
apps/server/package.json
Normal file
14
apps/server/package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@cargoradar/server",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "CargoRadar MVP API and static control web server.",
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
"start": "node src/server.js",
|
||||
"dev": "node src/server.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
573
apps/server/src/server.js
Normal file
573
apps/server/src/server.js
Normal file
@@ -0,0 +1,573 @@
|
||||
const fs = require("node:fs");
|
||||
const http = require("node:http");
|
||||
const path = require("node:path");
|
||||
const { randomUUID } = require("node:crypto");
|
||||
const { URL } = require("node:url");
|
||||
|
||||
const HOST = process.env.HOST || "0.0.0.0";
|
||||
const PORT = Number(process.env.PORT || 8080);
|
||||
const DATA_DIR = process.env.DATA_DIR || path.resolve(__dirname, "..", "data");
|
||||
const WEB_ROOT = process.env.WEB_ROOT || path.resolve(__dirname, "..", "..", "web", "public");
|
||||
const ADMIN_TOKEN = process.env.ADMIN_TOKEN || "";
|
||||
const DEFAULT_KAKAO_JAVASCRIPT_KEY = "ea26ed8ef3cf2960e9c36f1c161dadb5";
|
||||
|
||||
const DRIVERS_FILE = path.join(DATA_DIR, "drivers.json");
|
||||
const LATEST_FILE = path.join(DATA_DIR, "latest-locations.json");
|
||||
const HISTORY_FILE = path.join(DATA_DIR, "locations.jsonl");
|
||||
|
||||
const sseClients = new Set();
|
||||
|
||||
ensureDataFiles();
|
||||
|
||||
let drivers = loadDrivers();
|
||||
let latestLocations = loadLatestLocations();
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const requestUrl = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
|
||||
|
||||
try {
|
||||
addCorsHeaders(res);
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "GET" && requestUrl.pathname === "/health") {
|
||||
sendJson(res, 200, {
|
||||
status: "ok",
|
||||
service: "cargoradar-server",
|
||||
time: new Date().toISOString()
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "GET" && requestUrl.pathname === "/config.js") {
|
||||
sendJavaScript(res, 200, `window.CARGORADAR_CONFIG = ${JSON.stringify({
|
||||
kakaoJavaScriptKey: process.env.KAKAO_JAVASCRIPT_KEY || DEFAULT_KAKAO_JAVASCRIPT_KEY
|
||||
})};\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "GET" && requestUrl.pathname === "/api/v1/drivers") {
|
||||
if (!authorizeAdmin(req, res)) return;
|
||||
sendJson(res, 200, {
|
||||
drivers: drivers.map(toDriverResponse).sort(compareDrivers)
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && requestUrl.pathname === "/api/v1/drivers") {
|
||||
if (!authorizeAdmin(req, res)) return;
|
||||
const body = await readJsonBody(req);
|
||||
const driver = createDriver(body);
|
||||
drivers.push(driver);
|
||||
writeJsonAtomic(DRIVERS_FILE, drivers);
|
||||
sendJson(res, 201, { driver: toDriverResponse(driver) });
|
||||
return;
|
||||
}
|
||||
|
||||
const driverMatch = requestUrl.pathname.match(/^\/api\/v1\/drivers\/([^/]+)$/);
|
||||
if (driverMatch && req.method === "PATCH") {
|
||||
if (!authorizeAdmin(req, res)) return;
|
||||
const driverId = decodeURIComponent(driverMatch[1]);
|
||||
const body = await readJsonBody(req);
|
||||
const driver = updateDriver(driverId, body);
|
||||
writeJsonAtomic(DRIVERS_FILE, drivers);
|
||||
sendJson(res, 200, { driver: toDriverResponse(driver) });
|
||||
return;
|
||||
}
|
||||
|
||||
if (driverMatch && req.method === "DELETE") {
|
||||
if (!authorizeAdmin(req, res)) return;
|
||||
const driverId = decodeURIComponent(driverMatch[1]);
|
||||
const driverIndex = drivers.findIndex((driver) => driver.driverId === driverId);
|
||||
|
||||
if (driverIndex < 0) {
|
||||
sendJson(res, 404, { error: "driver_not_found" });
|
||||
return;
|
||||
}
|
||||
|
||||
drivers.splice(driverIndex, 1);
|
||||
delete latestLocations[driverId];
|
||||
writeJsonAtomic(DRIVERS_FILE, drivers);
|
||||
writeJsonAtomic(LATEST_FILE, latestLocations);
|
||||
broadcastEvent("drivers.updated", { drivers: drivers.map(toDriverResponse).sort(compareDrivers) });
|
||||
sendJson(res, 200, { ok: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "GET" && requestUrl.pathname === "/api/v1/locations/latest") {
|
||||
if (!authorizeAdmin(req, res)) return;
|
||||
sendJson(res, 200, {
|
||||
locations: Object.values(latestLocations).sort(compareLatestLocations)
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "GET" && requestUrl.pathname === "/api/v1/locations/history") {
|
||||
if (!authorizeAdmin(req, res)) return;
|
||||
const driverId = requestUrl.searchParams.get("driverId");
|
||||
const limit = clampNumber(Number(requestUrl.searchParams.get("limit") || 200), 1, 1000);
|
||||
sendJson(res, 200, {
|
||||
locations: readHistory({ driverId, limit })
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "GET" && requestUrl.pathname === "/api/v1/events") {
|
||||
if (!authorizeAdmin(req, res)) return;
|
||||
openEventStream(req, res);
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && requestUrl.pathname === "/api/v1/locations") {
|
||||
const body = await readJsonBody(req);
|
||||
const driver = authorizeDevice(req, body.driverId);
|
||||
|
||||
if (!driver) {
|
||||
sendJson(res, 401, { error: "invalid_device_token" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (body.driverId && body.driverId !== driver.driverId) {
|
||||
sendJson(res, 403, { error: "driver_token_mismatch" });
|
||||
return;
|
||||
}
|
||||
|
||||
const location = createLocationRecord(body, driver);
|
||||
latestLocations[location.driverId] = location;
|
||||
|
||||
appendJsonLine(HISTORY_FILE, location);
|
||||
writeJsonAtomic(LATEST_FILE, latestLocations);
|
||||
broadcastEvent("location.updated", location);
|
||||
|
||||
sendJson(res, 201, { location });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "GET" || req.method === "HEAD") {
|
||||
if (serveStaticFile(req, res, requestUrl.pathname)) return;
|
||||
}
|
||||
|
||||
sendJson(res, 404, { error: "not_found" });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
sendJson(res, error.statusCode || 500, {
|
||||
error: error.publicMessage || "internal_server_error"
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(PORT, HOST, () => {
|
||||
console.log(`CargoRadar server listening on http://${HOST}:${PORT}`);
|
||||
console.log(`Serving web files from ${WEB_ROOT}`);
|
||||
});
|
||||
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
|
||||
function shutdown() {
|
||||
for (const client of sseClients) {
|
||||
client.end();
|
||||
}
|
||||
server.close(() => process.exit(0));
|
||||
}
|
||||
|
||||
function ensureDataFiles() {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
|
||||
if (!fs.existsSync(DRIVERS_FILE)) {
|
||||
writeJsonAtomic(DRIVERS_FILE, [
|
||||
{
|
||||
driverId: "demo-driver",
|
||||
name: "Demo Driver",
|
||||
vehicleNo: "SEOUL-12-3456",
|
||||
phone: "",
|
||||
token: "demo-token",
|
||||
enabled: true
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(LATEST_FILE)) {
|
||||
writeJsonAtomic(LATEST_FILE, {});
|
||||
}
|
||||
|
||||
if (!fs.existsSync(HISTORY_FILE)) {
|
||||
fs.writeFileSync(HISTORY_FILE, "", "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
function loadDrivers() {
|
||||
const parsed = readJsonFile(DRIVERS_FILE, []);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
}
|
||||
|
||||
function createDriver(body) {
|
||||
const driverId = normalizeDriverId(body.driverId) || generateDriverId();
|
||||
|
||||
if (drivers.some((driver) => driver.driverId === driverId)) {
|
||||
validationError("driver_id_exists");
|
||||
}
|
||||
|
||||
const name = String(body.name || "").trim();
|
||||
const vehicleNo = String(body.vehicleNo || "").trim();
|
||||
|
||||
if (!name) validationError("driver_name_required");
|
||||
if (!vehicleNo) validationError("vehicle_no_required");
|
||||
|
||||
const driver = {
|
||||
driverId,
|
||||
name,
|
||||
vehicleNo,
|
||||
phone: String(body.phone || "").trim(),
|
||||
token: String(body.token || "").trim() || generateDeviceToken(),
|
||||
enabled: body.enabled === undefined ? true : Boolean(body.enabled),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
broadcastEvent("drivers.updated", { drivers: [...drivers, driver].map(toDriverResponse).sort(compareDrivers) });
|
||||
return driver;
|
||||
}
|
||||
|
||||
function updateDriver(driverId, body) {
|
||||
const driver = drivers.find((candidate) => candidate.driverId === driverId);
|
||||
if (!driver) notFoundError("driver_not_found");
|
||||
|
||||
if (body.name !== undefined) {
|
||||
const name = String(body.name || "").trim();
|
||||
if (!name) validationError("driver_name_required");
|
||||
driver.name = name;
|
||||
}
|
||||
|
||||
if (body.vehicleNo !== undefined) {
|
||||
const vehicleNo = String(body.vehicleNo || "").trim();
|
||||
if (!vehicleNo) validationError("vehicle_no_required");
|
||||
driver.vehicleNo = vehicleNo;
|
||||
}
|
||||
|
||||
if (body.phone !== undefined) {
|
||||
driver.phone = String(body.phone || "").trim();
|
||||
}
|
||||
|
||||
if (body.enabled !== undefined) {
|
||||
driver.enabled = Boolean(body.enabled);
|
||||
}
|
||||
|
||||
if (body.regenerateToken) {
|
||||
driver.token = generateDeviceToken();
|
||||
} else if (body.token !== undefined) {
|
||||
const token = String(body.token || "").trim();
|
||||
if (!token) validationError("driver_token_required");
|
||||
driver.token = token;
|
||||
}
|
||||
|
||||
driver.updatedAt = new Date().toISOString();
|
||||
broadcastEvent("drivers.updated", { drivers: drivers.map(toDriverResponse).sort(compareDrivers) });
|
||||
return driver;
|
||||
}
|
||||
|
||||
function loadLatestLocations() {
|
||||
const parsed = readJsonFile(LATEST_FILE, {});
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
||||
}
|
||||
|
||||
function readJsonFile(filePath, fallback) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJsonAtomic(filePath, value) {
|
||||
const tempPath = `${filePath}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||
fs.renameSync(tempPath, filePath);
|
||||
}
|
||||
|
||||
function appendJsonLine(filePath, value) {
|
||||
fs.appendFileSync(filePath, `${JSON.stringify(value)}\n`, "utf8");
|
||||
}
|
||||
|
||||
function sendJson(res, statusCode, payload) {
|
||||
const body = JSON.stringify(payload, null, 2);
|
||||
res.writeHead(statusCode, {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Content-Length": Buffer.byteLength(body)
|
||||
});
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
function sendJavaScript(res, statusCode, body) {
|
||||
res.writeHead(statusCode, {
|
||||
"Content-Type": "application/javascript; charset=utf-8",
|
||||
"Content-Length": Buffer.byteLength(body)
|
||||
});
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
function addCorsHeaders(res) {
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET,POST,PATCH,DELETE,OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type,Authorization,X-Admin-Token,X-Device-Token");
|
||||
}
|
||||
|
||||
function authorizeAdmin(req, res) {
|
||||
if (!ADMIN_TOKEN) return true;
|
||||
|
||||
const token = extractToken(req, "x-admin-token");
|
||||
const requestUrl = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
|
||||
const queryToken = requestUrl.searchParams.get("adminToken") || "";
|
||||
|
||||
if (token === ADMIN_TOKEN || queryToken === ADMIN_TOKEN) return true;
|
||||
|
||||
sendJson(res, 401, { error: "invalid_admin_token" });
|
||||
return false;
|
||||
}
|
||||
|
||||
function authorizeDevice(req, requestedDriverId) {
|
||||
const token = extractToken(req, "x-device-token");
|
||||
if (!token) return null;
|
||||
|
||||
return drivers.find((driver) => {
|
||||
if (!driver.enabled) return false;
|
||||
if (driver.token !== token) return false;
|
||||
if (requestedDriverId && driver.driverId !== requestedDriverId) return false;
|
||||
return true;
|
||||
}) || null;
|
||||
}
|
||||
|
||||
function extractToken(req, headerName) {
|
||||
const directToken = req.headers[headerName];
|
||||
if (typeof directToken === "string" && directToken.trim()) {
|
||||
return directToken.trim();
|
||||
}
|
||||
|
||||
const authorization = req.headers.authorization || "";
|
||||
const match = authorization.match(/^Bearer\s+(.+)$/i);
|
||||
return match ? match[1].trim() : "";
|
||||
}
|
||||
|
||||
async function readJsonBody(req) {
|
||||
const chunks = [];
|
||||
let totalBytes = 0;
|
||||
|
||||
for await (const chunk of req) {
|
||||
totalBytes += chunk.length;
|
||||
if (totalBytes > 1024 * 1024) {
|
||||
const error = new Error("Request body too large.");
|
||||
error.statusCode = 413;
|
||||
error.publicMessage = "body_too_large";
|
||||
throw error;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
|
||||
} catch {
|
||||
const error = new Error("Invalid JSON request body.");
|
||||
error.statusCode = 400;
|
||||
error.publicMessage = "invalid_json";
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function createLocationRecord(body, driver) {
|
||||
const latitude = Number(body.latitude);
|
||||
const longitude = Number(body.longitude);
|
||||
|
||||
if (!Number.isFinite(latitude) || latitude < -90 || latitude > 90) {
|
||||
validationError("invalid_latitude");
|
||||
}
|
||||
|
||||
if (!Number.isFinite(longitude) || longitude < -180 || longitude > 180) {
|
||||
validationError("invalid_longitude");
|
||||
}
|
||||
|
||||
return {
|
||||
id: randomUUID(),
|
||||
driverId: driver.driverId,
|
||||
driverName: driver.name || driver.driverId,
|
||||
vehicleNo: String(body.vehicleNo || driver.vehicleNo || ""),
|
||||
latitude,
|
||||
longitude,
|
||||
accuracy: optionalNumber(body.accuracy),
|
||||
speed: optionalNumber(body.speed),
|
||||
heading: optionalNumber(body.heading),
|
||||
batteryLevel: optionalNumber(body.batteryLevel),
|
||||
provider: body.provider ? String(body.provider) : "",
|
||||
recordedAt: normalizeDate(body.recordedAt),
|
||||
receivedAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
function validationError(publicMessage) {
|
||||
const error = new Error(publicMessage);
|
||||
error.statusCode = 400;
|
||||
error.publicMessage = publicMessage;
|
||||
throw error;
|
||||
}
|
||||
|
||||
function notFoundError(publicMessage) {
|
||||
const error = new Error(publicMessage);
|
||||
error.statusCode = 404;
|
||||
error.publicMessage = publicMessage;
|
||||
throw error;
|
||||
}
|
||||
|
||||
function optionalNumber(value) {
|
||||
if (value === undefined || value === null || value === "") return null;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function normalizeDate(value) {
|
||||
if (!value) return new Date().toISOString();
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? new Date().toISOString() : parsed.toISOString();
|
||||
}
|
||||
|
||||
function readHistory({ driverId, limit }) {
|
||||
if (!fs.existsSync(HISTORY_FILE)) return [];
|
||||
|
||||
const lines = fs.readFileSync(HISTORY_FILE, "utf8").trim().split("\n").filter(Boolean);
|
||||
const records = [];
|
||||
|
||||
for (let index = lines.length - 1; index >= 0 && records.length < limit; index -= 1) {
|
||||
try {
|
||||
const record = JSON.parse(lines[index]);
|
||||
if (!driverId || record.driverId === driverId) {
|
||||
records.push(record);
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return records.reverse();
|
||||
}
|
||||
|
||||
function openEventStream(req, res) {
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/event-stream; charset=utf-8",
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive"
|
||||
});
|
||||
|
||||
res.write(`event: snapshot\n`);
|
||||
res.write(`data: ${JSON.stringify({ locations: Object.values(latestLocations) })}\n\n`);
|
||||
sseClients.add(res);
|
||||
|
||||
const heartbeat = setInterval(() => {
|
||||
res.write(`event: heartbeat\n`);
|
||||
res.write(`data: ${JSON.stringify({ time: new Date().toISOString() })}\n\n`);
|
||||
}, 15000);
|
||||
|
||||
req.on("close", () => {
|
||||
clearInterval(heartbeat);
|
||||
sseClients.delete(res);
|
||||
});
|
||||
}
|
||||
|
||||
function broadcastEvent(type, data) {
|
||||
const payload = `event: ${type}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||
for (const client of sseClients) {
|
||||
client.write(payload);
|
||||
}
|
||||
}
|
||||
|
||||
function compareLatestLocations(left, right) {
|
||||
return new Date(right.receivedAt).getTime() - new Date(left.receivedAt).getTime();
|
||||
}
|
||||
|
||||
function compareDrivers(left, right) {
|
||||
return String(left.vehicleNo || left.driverId).localeCompare(String(right.vehicleNo || right.driverId), "ko");
|
||||
}
|
||||
|
||||
function toDriverResponse(driver) {
|
||||
return {
|
||||
driverId: driver.driverId,
|
||||
name: driver.name || driver.driverId,
|
||||
vehicleNo: driver.vehicleNo || "",
|
||||
phone: driver.phone || "",
|
||||
token: driver.token || "",
|
||||
enabled: driver.enabled !== false,
|
||||
createdAt: driver.createdAt || null,
|
||||
updatedAt: driver.updatedAt || null
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDriverId(value) {
|
||||
const rawValue = String(value || "").trim();
|
||||
if (!rawValue) return "";
|
||||
|
||||
const normalized = rawValue
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 64);
|
||||
|
||||
if (!normalized) validationError("invalid_driver_id");
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function generateDriverId() {
|
||||
return `driver-${Date.now().toString(36)}`;
|
||||
}
|
||||
|
||||
function generateDeviceToken() {
|
||||
return randomUUID().replace(/-/g, "");
|
||||
}
|
||||
|
||||
function clampNumber(value, min, max) {
|
||||
if (!Number.isFinite(value)) return min;
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function serveStaticFile(req, res, pathname) {
|
||||
const normalizedPath = pathname === "/" ? "/index.html" : pathname;
|
||||
const decodedPath = decodeURIComponent(normalizedPath);
|
||||
const filePath = path.resolve(WEB_ROOT, `.${decodedPath}`);
|
||||
const relativePath = path.relative(WEB_ROOT, filePath);
|
||||
|
||||
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
|
||||
sendJson(res, 403, { error: "forbidden" });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const contentType = getContentType(filePath);
|
||||
res.writeHead(200, { "Content-Type": contentType });
|
||||
|
||||
if (req.method === "HEAD") {
|
||||
res.end();
|
||||
return true;
|
||||
}
|
||||
|
||||
fs.createReadStream(filePath).pipe(res);
|
||||
return true;
|
||||
}
|
||||
|
||||
function getContentType(filePath) {
|
||||
const extension = path.extname(filePath).toLowerCase();
|
||||
const types = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".js": "application/javascript; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".svg": "image/svg+xml"
|
||||
};
|
||||
|
||||
return types[extension] || "application/octet-stream";
|
||||
}
|
||||
Reference in New Issue
Block a user