초기 관제 시스템 뼈대 구성

This commit is contained in:
2026-06-06 14:58:54 +09:00
parent d982777718
commit cab7394b1f
29 changed files with 3221 additions and 1 deletions

View File

@@ -0,0 +1 @@

14
apps/server/package.json Normal file
View 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
View 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";
}