update server

This commit is contained in:
2026-07-30 11:12:31 +08:00
commit 4312cb878c
99 changed files with 24034 additions and 0 deletions
+997
View File
@@ -0,0 +1,997 @@
const fs = require("node:fs");
const path = require("node:path");
const { randomUUID, constants, createHmac, timingSafeEqual, verify } = require("node:crypto");
const express = require("express");
const multer = require("multer");
const BASE_FOLDER = "ReinLoop_GUI";
const VOLUME_REQUEST_TTL_MS = Number(process.env.VOLUME_REQUEST_TTL_MS || 300000);
const DOWNLOAD_TOKEN_TTL_MS = Number(process.env.DOWNLOAD_TOKEN_TTL_MS || 300000);
const IDENTIFICATION_RETENTION_MS = Number(process.env.IDENTIFICATION_RETENTION_MS || 30 * 24 * 60 * 60 * 1000);
const DEVICE_HEARTBEAT_TTL_MS = 30_000;
const CONFIG_SCHEMAS = {
volume: {
q_in_val: "number", dt: "number", xa_full: "number", p_max: "number",
fit_low: "number", fit_high: "number", T_delta: "number", num_runs: "integer"
},
identification: {
q_in_val: "number", dt: "number", n_order: "integer", t_c: "number",
levels: "array", dead_area: "number", xa_full: "number", V_val: "number",
repeat: "integer"
}
};
function normalizeRelativePath(value, fallback = "") {
const normalized = String(value || fallback).trim().replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
if (!normalized || normalized.split("/").some((part) => !part || part === "." || part === "..")) {
throw new Error("目录格式无效");
}
return normalized;
}
function normalizeFileName(value) {
const fileName = path.basename(String(value || "").trim());
if (!fileName || fileName === "." || fileName === "..") throw new Error("缺少有效的 fileName");
return fileName;
}
function normalizeDeviceId(value) {
if (typeof value !== "string") throw new Error("缺少有效的 deviceId");
const deviceId = value.trim().replace(/\\/g, "/");
const parts = deviceId.split("/");
if (!deviceId || parts.length !== 2 || parts.some((part) => !/^[a-z0-9][a-z0-9_-]{1,63}$/.test(part))) {
throw new Error("deviceId 格式无效");
}
return deviceId;
}
function controlDataDeviceId(folder) {
const parts = String(folder || "").split("/");
if (parts.length < 3 || parts[2] !== "data_record") return null;
return normalizeDeviceId(parts.slice(0, 2).join("/"));
}
function isControlDataRecord(record, deviceId) {
if (!record || typeof record.folder !== "string") return false;
const prefix = `${deviceId}/data_record`;
return record.folder === prefix || record.folder.startsWith(`${prefix}/`);
}
function parseLicenseTimestamp(value, fieldName) {
const text = String(value || "").trim();
const matched = /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2})$/.exec(text);
if (!matched) throw new Error(`${fieldName} 必须是 YYYY-MM-DD HH:MM 格式`);
const [, year, month, day, hour, minute] = matched.map(Number);
const wallClock = new Date(Date.UTC(year, month - 1, day, hour, minute));
if (
wallClock.getUTCFullYear() !== year || wallClock.getUTCMonth() !== month - 1 ||
wallClock.getUTCDate() !== day || wallClock.getUTCHours() !== hour ||
wallClock.getUTCMinutes() !== minute
) {
throw new Error(`${fieldName} 无效`);
}
const utcMs = Date.UTC(year, month - 1, day, hour - 8, minute);
return new Date(utcMs);
}
function parseSignedLicense(content, publicKeyPath) {
if (!publicKeyPath) throw new Error("服务器未配置 LICENSE_PUBLIC_KEY_PATH");
const parts = String(content || "").split("|");
if (parts.length !== 2 || !parts.every(Boolean)) throw new Error("许可证格式无效");
const [payloadBase64, signatureBase64] = parts;
let payload;
let signature;
try {
payload = JSON.parse(Buffer.from(payloadBase64, "base64").toString("utf8"));
signature = Buffer.from(signatureBase64, "base64");
} catch {
throw new Error("许可证内容解析失败");
}
const publicKey = fs.readFileSync(publicKeyPath);
const valid = verify("sha256", Buffer.from(payloadBase64), {
key: publicKey,
padding: constants.RSA_PKCS1_PSS_PADDING,
saltLength: constants.RSA_PSS_SALTLEN_AUTO
}, signature);
if (!valid) throw new Error("许可证签名验证失败");
return payload;
}
function licenseContentsMatch(record, event) {
return ["companyId", "productionLineId", "customer", "issued", "expiry", "features", "license"]
.every((field) => record[field] === event[field]);
}
function normalizeCode(value, fieldName) {
const code = String(value || "").trim().toLowerCase();
if (!/^[a-z0-9][a-z0-9_-]{1,63}$/.test(code)) {
throw new Error(`${fieldName}只能包含 2-64 位小写字母、数字、下划线或连字符`);
}
return code;
}
function publicLicense(record, includeContent = false) {
const result = { ...record };
if (!includeContent) delete result.license;
return result;
}
function validateConfigParameters(configType, parameters) {
const schema = CONFIG_SCHEMAS[configType];
if (!schema) return `未知 configType: ${configType}`;
if (!parameters || typeof parameters !== "object" || Array.isArray(parameters)) return "parameters 必须是对象";
const expected = Object.keys(schema);
const actual = Object.keys(parameters);
const missing = expected.filter((field) => !actual.includes(field));
const extra = actual.filter((field) => !expected.includes(field));
if (missing.length) return `缺少字段: ${missing.join(", ")}`;
if (extra.length) return `包含不允许的字段: ${extra.join(", ")}`;
for (const [field, type] of Object.entries(schema)) {
const value = parameters[field];
if (type === "array" && (!Array.isArray(value) || !value.length)) return `${field} 必须是非空数组`;
if (type === "number" && !Number.isFinite(value)) return `${field} 必须是数字`;
if (type === "integer" && !Number.isInteger(value)) return `${field} 必须是整数`;
}
if (configType === "identification") {
const { levels } = parameters;
if (levels.length < 2 || (levels.length & (levels.length - 1)) !== 0) {
return "levels 长度必须是大于等于 2 的 2 的整数次幂";
}
if (levels.some((value) => !Number.isFinite(value) || value < 0 || value > 100)) {
return "levels 中的开度必须是 0 到 100 的有限数字";
}
if (parameters.q_in_val < 0) return "q_in_val 不能小于 0";
if (parameters.dt <= 0 || parameters.t_c < parameters.dt) return "必须满足 0 < dt <= t_c";
if (parameters.n_order < 2) return "n_order 必须大于等于 2";
if (parameters.repeat <= 0) return "repeat 必须是正整数";
if (parameters.dead_area < 0 || parameters.xa_full <= parameters.dead_area) {
return "必须满足 0 <= dead_area < xa_full";
}
if (parameters.xa_full < 1000) return "xa_full 不能小于 1000";
if (parameters.V_val <= 0) return "V_val 必须大于 0";
}
return null;
}
function createApp({
store,
adminToken = process.env.B_ADMIN_TOKEN || "dev-admin-token",
licensePublicKeyPath = process.env.LICENSE_PUBLIC_KEY_PATH
}) {
const app = express();
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 100 * 1024 * 1024 } });
const pendingUploads = new Map();
app.disable("x-powered-by");
app.use(express.json({ limit: "2mb" }));
function publicBaseUrl(req) {
return (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get("host")}`).replace(/\/$/, "");
}
function requireAdmin(event) {
if (!adminToken) return "服务器未配置 B_ADMIN_TOKEN";
return event.adminToken === adminToken ? null : "B端管理令牌无效";
}
function signDownload(fileID, expiresAtMs) {
return createHmac("sha256", adminToken).update(`${fileID}\n${expiresAtMs}`).digest("hex");
}
function downloadUrl(req, fileID) {
const baseUrl = `${publicBaseUrl(req)}/files/${encodeURIComponent(fileID)}`;
if (fileID.startsWith("model://")) return baseUrl;
const expires = Date.now() + DOWNLOAD_TOKEN_TTL_MS;
return `${baseUrl}?expires=${expires}&token=${signDownload(fileID, expires)}`;
}
function hasValidDownloadToken(req, fileID) {
const expires = Number(req.query.expires);
const token = String(req.query.token || "");
if (!Number.isSafeInteger(expires) || expires < Date.now() || !/^[0-9a-f]{64}$/.test(token)) return false;
const expected = signDownload(fileID, expires);
return timingSafeEqual(Buffer.from(token, "hex"), Buffer.from(expected, "hex"));
}
//config test-----------------------------------
function logReceivedConfig({ configType, deviceId, requestId, record, config }) {
console.info("[config] received", {
configType,
deviceId,
requestId: requestId || null,
fileID: record.fileID,
cloudPath: record.cloudPath,
storagePath: store.resolveStoredFile(record.fileID),
config
});
}
function logConfigRead({ configType, deviceId, requestId, record }) {
console.info("[config] read", {
configType,
deviceId,
requestId: requestId || null,
fileID: record.fileID,
cloudPath: record.cloudPath,
storagePath: store.resolveStoredFile(record.fileID)
});
}
//end config test------------------------------------
async function issueUpload(event, req) {
const originalFileName = normalizeFileName(event.fileName);
const folder = normalizeRelativePath(event.folder, "data_record");
const folderParts = folder.split("/");
if (folderParts.includes("data_record")) {
const deviceId = controlDataDeviceId(folder);
if (!deviceId) {
return { success: false, errMsg: "控制数据目录必须为 <deviceId>/data_record/ 的子目录" };
}
}
const isModel = folderParts.at(-1) === "model_config";
const fileName = isModel && event.modelName
? normalizeFileName(event.modelName)
: originalFileName;
if (isModel && path.extname(fileName).toLowerCase() !== path.extname(originalFileName).toLowerCase()) {
return { success: false, errMsg: "重命名后的模型扩展名必须与原文件一致" };
}
const modelDeviceId = isModel
? normalizeDeviceId(folderParts.slice(0, -1).join("/"))
: null;
const cloudPath = isModel
? `${modelDeviceId}/${fileName}`
: `${BASE_FOLDER}/${folder}/${fileName}`;
const fileID = `${isModel ? "model" : "local"}://${cloudPath}`;
if (isModel && event.overwrite !== true) {
const database = await store.read();
const existing = database.fileRecords.find((record) => record.cloudPath === cloudPath);
if (existing) {
return {
success: false,
conflict: true,
errMsg: "同名模型已存在",
existing: {
...existing,
originalFileName: existing.originalFileName || existing.fileName
}
};
}
}
const token = randomUUID();
pendingUploads.set(token, {
fileName,
originalFileName: isModel ? originalFileName : undefined,
folder,
cloudPath,
fileID,
configType: event.configType,
parameters: event.parameters,
expiresAt: Date.now() + 10 * 60 * 1000
});
return {
code: 200,
success: true,
uploadMetadata: {
url: `${publicBaseUrl(req)}/upload/${token}`,
token,
authorization: token,
cosFileId: cloudPath,
fileId: fileID
},
fileID,
cloudPath,
fileName,
originalFileName: isModel ? originalFileName : undefined
};
}
async function removeFile(database, fileID) {
const filePath = store.resolveStoredFile(fileID);
if (filePath) await fs.promises.rm(filePath, { force: true });
const before = database.fileRecords.length;
database.fileRecords = database.fileRecords.filter((record) => record.fileID !== fileID);
database.panelInbox = database.panelInbox.filter((record) => record.fileID !== fileID);
database.identificationFiles = database.identificationFiles.filter((record) => record.fileID !== fileID);
return before - database.fileRecords.length;
}
function backfillIdentificationFiles(database) {
for (const record of database.fileRecords) {
if (!record.folder.endsWith("/ind_data") || ![".csv", ".json"].includes(path.extname(record.fileName).toLowerCase())) continue;
if (database.identificationFiles.some((item) => item.fileID === record.fileID)) continue;
const deviceId = normalizeDeviceId(record.folder.slice(0, -"/ind_data".length));
const pending = database.panelInbox.some((item) => item.fileID === record.fileID);
database.identificationFiles.push({
fileID: record.fileID,
deviceId,
fileName: record.fileName,
mediaType: path.extname(record.fileName).slice(1).toLowerCase(),
uploadTime: record.uploadTime,
size: record.size,
status: pending ? "pending" : "processed",
processedAt: pending ? null : record.uploadTime,
expiresAt: pending ? null : new Date(Date.parse(record.uploadTime) + IDENTIFICATION_RETENTION_MS).toISOString()
});
}
}
async function purgeExpiredIdentificationFiles() {
return store.update(async (database) => {
backfillIdentificationFiles(database);
const now = Date.now();
const expired = database.identificationFiles.filter((record) =>
record.status === "processed" && record.expiresAt && Date.parse(record.expiresAt) <= now
);
for (const record of expired) await removeFile(database, record.fileID);
return expired.length;
});
}
app.locals.purgeExpiredIdentificationFiles = purgeExpiredIdentificationFiles;
async function dispatch(event, req) {
switch (event.type) {
case "listOrganizations": {
const authError = requireAdmin(event);
if (authError) return { success: false, errMsg: authError };
const database = await store.read();
const now = Date.now();
const companies = database.companies.map((company) => ({
...company,
productionLines: database.productionLines
.filter((line) => line.companyId === company.id)
.map((line) => ({
...line,
online: Boolean(line.lastSeenAt && now - Date.parse(line.lastSeenAt) <= DEVICE_HEARTBEAT_TTL_MS),
lastSeenAt: line.lastSeenAt || null
}))
}));
return { success: true, companies };
}
case "deviceHeartbeat": {
const deviceId = normalizeDeviceId(event.deviceId);
return store.update((database) => {
const line = database.productionLines.find((item) => item.deviceId === deviceId);
if (!line) return { success: false, errMsg: "设备未注册" };
line.lastSeenAt = new Date().toISOString();
return { success: true, deviceId, lastSeenAt: line.lastSeenAt };
});
}
case "createCompany": {
const authError = requireAdmin(event);
if (authError) return { success: false, errMsg: authError };
const name = String(event.name || "").trim();
if (!name) return { success: false, errMsg: "公司名称不能为空" };
const code = normalizeCode(event.code, "公司编码");
return store.update((database) => {
if (database.companies.some((item) => item.code === code)) {
return { success: false, errMsg: `公司编码 ${code} 已存在` };
}
const company = {
id: store.createId("company"), name, code,
createdAt: new Date().toISOString()
};
database.companies.push(company);
return { success: true, company };
});
}
case "createProductionLine": {
const authError = requireAdmin(event);
if (authError) return { success: false, errMsg: authError };
const name = String(event.name || "").trim();
if (!name) return { success: false, errMsg: "产线名称不能为空" };
const code = normalizeCode(event.code, "产线编码");
return store.update((database) => {
const company = database.companies.find((item) => item.id === event.companyId);
if (!company) return { success: false, errMsg: "公司不存在" };
if (database.productionLines.some((item) => item.companyId === company.id && item.code === code)) {
return { success: false, errMsg: `该公司下产线编码 ${code} 已存在` };
}
const productionLine = {
id: store.createId("line"), companyId: company.id, name, code,
deviceId: `${company.code}/${code}`,
createdAt: new Date().toISOString(), lastSeenAt: null
};
database.productionLines.push(productionLine);
return { success: true, productionLine };
});
}
case "createLicense": {
const authError = requireAdmin(event);
if (authError) return { success: false, errMsg: authError };
const required = ["licenseId", "companyId", "productionLineId", "customer", "issued", "expiry", "license"];
const missing = required.filter((field) => !String(event[field] || "").trim());
if (missing.length) return { success: false, errMsg: `缺少必填字段: ${missing.join(", ")}` };
const licenseId = String(event.licenseId).trim();
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(licenseId)) {
return { success: false, errMsg: "licenseId 必须是 UUID" };
}
const issuedAt = parseLicenseTimestamp(event.issued, "issued");
const expiryAt = parseLicenseTimestamp(event.expiry, "expiry");
if (expiryAt <= issuedAt) return { success: false, errMsg: "expiry 必须晚于 issued" };
const payload = parseSignedLicense(event.license, licensePublicKeyPath);
const signedFields = {
license_id: licenseId,
company_id: String(event.companyId),
production_line_id: String(event.productionLineId),
customer: String(event.customer),
issued: String(event.issued),
expiry: String(event.expiry),
features: String(event.features || "*")
};
if (Object.entries(signedFields).some(([field, value]) => payload[field] !== value)) {
return { success: false, errMsg: "许可证载荷与请求字段不一致" };
}
return store.update((database) => {
const company = database.companies.find((item) => item.id === event.companyId);
const line = database.productionLines.find((item) => item.id === event.productionLineId && item.companyId === event.companyId);
if (!company || !line) return { success: false, errMsg: "公司或产线不存在" };
if (payload.device_id !== line.deviceId) return { success: false, errMsg: "许可证 device_id 与产线不匹配" };
const existing = database.licenses.find((item) => item.licenseId === licenseId);
if (existing) {
return licenseContentsMatch(existing, { ...event, features: event.features || "*" })
? { success: true, license: publicLicense(existing), idempotent: true }
: { success: false, conflict: true, errMsg: "licenseId 已存在且内容不同" };
}
const record = {
licenseId, companyId: company.id,
productionLineId: line.id, companyName: company.name,
productionLineName: line.name, deviceId: line.deviceId,
customer: String(event.customer), issued: String(event.issued),
expiry: String(event.expiry), issuedAt: issuedAt.toISOString(),
expiryAt: expiryAt.toISOString(), features: event.features || "*",
license: String(event.license), status: "active",
createdAt: new Date().toISOString(), revokedAt: null,
revocationReason: null
};
database.licenses.push(record);
return { success: true, license: publicLicense(record) };
});
}
case "listLicenses": {
const authError = requireAdmin(event);
if (authError) return { success: false, errMsg: authError };
const database = await store.read();
return {
success: true,
licenses: database.licenses.map((record) => publicLicense(record))
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))
};
}
case "getLicense": {
const authError = requireAdmin(event);
if (authError) return { success: false, errMsg: authError };
const database = await store.read();
const record = database.licenses.find((item) => item.licenseId === event.licenseId);
return record
? { success: true, license: publicLicense(record, true) }
: { success: false, errMsg: "许可证不存在" };
}
case "revokeLicense": {
const authError = requireAdmin(event);
if (authError) return { success: false, errMsg: authError };
return store.update((database) => {
const record = database.licenses.find((item) => item.licenseId === event.licenseId);
if (!record) return { success: false, errMsg: "许可证不存在" };
record.status = "revoked";
record.revokedAt = new Date().toISOString();
record.revocationReason = String(event.reason || "管理员撤销").trim();
return { success: true, license: publicLicense(record) };
});
}
case "validateLicense": {
const database = await store.read();
const record = database.licenses.find((item) => item.licenseId === event.licenseId);
if (!record) return { success: true, valid: false, status: "not_found" };
if (event.deviceId && normalizeDeviceId(event.deviceId) !== record.deviceId) {
return { success: true, valid: false, status: "device_mismatch", licenseId: record.licenseId };
}
if (new Date(record.expiryAt || parseLicenseTimestamp(record.expiry, "expiry")) <= new Date()) {
return { success: true, valid: false, status: "expired", licenseId: record.licenseId };
}
return {
success: true, valid: record.status === "active",
status: record.status, licenseId: record.licenseId
};
}
case "uploadDataFile":
if (normalizeRelativePath(event.folder, "data_record").endsWith("/model_config")) {
const authError = requireAdmin(event);
if (authError) return { success: false, errMsg: authError };
}
return issueUpload(event, req);
case "issueModelUpload": {
const authError = requireAdmin(event);
if (authError) return { success: false, errMsg: authError };
const deviceId = normalizeDeviceId(event.deviceId);
return issueUpload({ ...event, folder: `${deviceId}/model_config` }, req);
}
case "listModels": {
const folder = normalizeRelativePath(event.folder, "model_config");
const database = await store.read();
const fileList = database.fileRecords
.filter((record) => record.folder === folder)
.sort((left, right) => right.uploadTime.localeCompare(left.uploadTime))
.slice(0, 100);
return {
success: true,
files: fileList.map((record) => record.fileName),
fileList: fileList.map((record) => ({
...record,
originalFileName: record.originalFileName || record.fileName
}))
};
}
case "downloadModel": {
const database = await store.read();
const record = database.fileRecords.find((item) => item.fileID === event.fileID);
if (!record || !store.resolveStoredFile(record.fileID)) return { success: false, errMsg: "文件不存在" };
if (!record.fileID.startsWith("model://")) {
const authError = requireAdmin(event);
if (authError) return { success: false, errMsg: authError };
}
return {
success: true,
url: downloadUrl(req, record.fileID),
fileName: record.fileName,
originalFileName: record.originalFileName || record.fileName
};
}
case "deleteFile":
case "deleteModel": {
const authError = requireAdmin(event);
if (authError) return { success: false, errMsg: authError };
return store.update(async (database) => {
let records = event.fileID
? database.fileRecords.filter((item) => item.fileID === event.fileID)
: database.fileRecords.filter((item) =>
item.folder === normalizeRelativePath(event.folder, "model_config") &&
item.fileName === normalizeFileName(event.fileName)
);
if (!event.fileID && records.length > 1) {
return { success: false, ambiguous: true, errMsg: `发现 ${records.length} 个同名文件,请用 fileID 精确指定`, candidates: records };
}
if (!records.length) return { success: false, errMsg: "数据库中未找到对应记录" };
let deletedCount = 0;
for (const record of records) deletedCount += await removeFile(database, record.fileID);
return { success: true, deletedFileID: records.map((record) => record.fileID).join(", "), deletedCount };
});
}
case "publishIdentificationConfig": {
const authError = requireAdmin(event);
if (authError) return { success: false, errMsg: authError };
const deviceId = normalizeDeviceId(event.deviceId);
const validationError = validateConfigParameters("identification", event.parameters);
if (validationError) return { success: false, errMsg: validationError };
return issueUpload({
fileName: "identification_config.csv",
folder: `${deviceId}/identification_config`
}, req);
}
case "publishVolumeConfig": {
const authError = requireAdmin(event);
if (authError) return { success: false, errMsg: authError };
const validationError = validateConfigParameters("volume", event.parameters);
if (validationError) return { success: false, errMsg: validationError };
const result = await issueUpload({
fileName: "volume_config.json",
folder: process.env.VOLUME_CONFIG_FOLDER || "volume_config",
configType: "volume",
parameters: event.parameters
}, req);
return { ...result, parameters: event.parameters };
}
case "getVolumeConfigFile": {
const folder = process.env.VOLUME_CONFIG_FOLDER || "volume_config";
const fileID = `local://${BASE_FOLDER}/${folder}/volume_config.json`;
const database = await store.read();
if (!database.fileRecords.some((record) => record.fileID === fileID)) {
return { success: false, notFound: true, errMsg: "容积配置文件尚未发布" };
}
return { success: true, fileID, cloudPath: `${BASE_FOLDER}/${folder}/volume_config.json`, url: downloadUrl(req, fileID) };
}
case "getFunctionConfig": {
if (event.configType !== "volume") return { success: false, errMsg: `未知 configType: ${event.configType}` };
const database = await store.read();
const config = database.functionConfigs.find((item) => item.configType === event.configType);
return config ? { success: true, ...config } : { success: false, notFound: true, errMsg: "参数配置尚未发布" };
}
case "getIdentificationConfig": {
const deviceId = normalizeDeviceId(event.deviceId);
const database = await store.read();
const record = database.fileRecords.find((item) => item.folder === `${deviceId}/identification_config` && item.fileName === "identification_config.csv");
if (!record) return { success: false, errMsg: "服务器尚未配置辨识参数" };
logConfigRead({ configType: "identification", deviceId, record });
return { success: true, fileName: record.fileName, fileID: record.fileID, cloudPath: record.cloudPath, url: downloadUrl(req, record.fileID) };
}
case "getPendingPanelFile": {
const authError = requireAdmin(event);
if (authError) return { success: false, errMsg: authError };
const deviceId = normalizeDeviceId(event.deviceId);
const database = await store.read();
const message = database.panelInbox.find((item) => {
if (item.deviceId !== deviceId) return false;
if (item.mediaType !== "csv") return true;
return database.identificationFeedback.some((feedback) =>
feedback.deviceId === deviceId && feedback.runId === item.fileName
);
});
if (!message) return { success: true, pending: false };
return {
success: true,
pending: true,
fileID: message.fileID,
fileName: message.fileName,
mediaType: message.mediaType,
uploadTime: message.uploadTime,
url: downloadUrl(req, message.fileID)
};
}
case "ackPanelFile": {
const authError = requireAdmin(event);
if (authError) return { success: false, errMsg: authError };
const deviceId = normalizeDeviceId(event.deviceId);
if (!event.fileID) return { success: false, errMsg: "缺少 fileID" };
return store.update((database) => {
backfillIdentificationFiles(database);
const messages = database.panelInbox.filter((item) =>
item.deviceId === deviceId && item.fileID === String(event.fileID)
);
const historyRecord = database.identificationFiles.find((item) =>
item.deviceId === deviceId && item.fileID === String(event.fileID)
);
if (!messages.length && !historyRecord) return { success: false, errMsg: "辨识文件不存在" };
const processedAt = new Date().toISOString();
if (historyRecord) {
historyRecord.status = "processed";
historyRecord.processedAt = processedAt;
historyRecord.expiresAt = new Date(Date.now() + IDENTIFICATION_RETENTION_MS).toISOString();
}
const before = database.panelInbox.length;
database.panelInbox = database.panelInbox.filter((item) =>
!(item.deviceId === deviceId && item.fileID === String(event.fileID))
);
return { success: true, processed: true, deleted: before - database.panelInbox.length, processedAt };
});
}
case "listIdentificationFiles": {
const authError = requireAdmin(event);
if (authError) return { success: false, errMsg: authError };
const deviceId = normalizeDeviceId(event.deviceId);
await purgeExpiredIdentificationFiles();
const database = await store.read();
const page = Math.max(1, Number.parseInt(event.page, 10) || 1);
const pageSize = Math.min(100, Math.max(1, Number.parseInt(event.pageSize, 10) || 20));
const files = database.identificationFiles
.filter((record) => record.deviceId === deviceId)
.filter((record) => !event.mediaType || record.mediaType === event.mediaType)
.filter((record) => !event.status || record.status === event.status)
.sort((left, right) => right.uploadTime.localeCompare(left.uploadTime));
const offset = (page - 1) * pageSize;
return {
success: true,
files: files.slice(offset, offset + pageSize),
total: files.length,
page,
pageSize
};
}
case "listControlFiles": {
const authError = requireAdmin(event);
if (authError) return { success: false, errMsg: authError };
const deviceId = normalizeDeviceId(event.deviceId);
const page = Math.max(1, Number.parseInt(event.page, 10) || 1);
const pageSize = Math.min(100, Math.max(1, Number.parseInt(event.pageSize, 10) || 20));
const database = await store.read();
const files = database.fileRecords
.filter((record) => isControlDataRecord(record, deviceId))
.sort((left, right) => right.uploadTime.localeCompare(left.uploadTime));
const offset = (page - 1) * pageSize;
return {
success: true,
files: files.slice(offset, offset + pageSize).map((record) => ({
fileID: record.fileID,
fileName: record.fileName,
uploadTime: record.uploadTime,
size: record.size
})),
total: files.length,
page,
pageSize
};
}
case "getControlFileDownload": {
const authError = requireAdmin(event);
if (authError) return { success: false, errMsg: authError };
if (!event.fileID) return { success: false, errMsg: "缺少 fileID" };
const database = await store.read();
const record = database.fileRecords.find((item) => item.fileID === String(event.fileID));
if (!record || !controlDataDeviceId(record.folder) || !store.resolveStoredFile(record.fileID)) {
return { success: false, errMsg: "控制数据文件不存在或不属于控制数据目录" };
}
try {
await fs.promises.access(store.resolveStoredFile(record.fileID));
} catch {
return { success: false, errMsg: "控制数据文件不存在" };
}
return {
success: true,
fileID: record.fileID,
fileName: record.fileName,
uploadTime: record.uploadTime,
size: record.size,
url: downloadUrl(req, record.fileID)
};
}
case "deleteControlFile": {
const authError = requireAdmin(event);
if (authError) return { success: false, errMsg: authError };
if (!event.fileID) return { success: false, errMsg: "缺少 fileID" };
return store.update(async (database) => {
const record = database.fileRecords.find((item) => item.fileID === String(event.fileID));
if (!record || !controlDataDeviceId(record.folder)) {
return { success: false, errMsg: "控制数据文件不存在或不属于控制数据目录" };
}
const deletedCount = await removeFile(database, record.fileID);
return deletedCount
? { success: true, deletedCount }
: { success: false, errMsg: "控制数据文件不存在" };
});
}
case "getIdentificationFileDownload": {
const authError = requireAdmin(event);
if (authError) return { success: false, errMsg: authError };
const database = await store.read();
const historyRecord = database.identificationFiles.find((item) => item.fileID === event.fileID);
const fileRecord = database.fileRecords.find((item) => item.fileID === event.fileID);
if (!historyRecord || !fileRecord || !store.resolveStoredFile(fileRecord.fileID)) {
return { success: false, errMsg: "辨识文件不存在" };
}
return {
success: true,
fileID: fileRecord.fileID,
fileName: fileRecord.fileName,
mediaType: historyRecord.mediaType,
url: downloadUrl(req, fileRecord.fileID)
};
}
case "deleteIdentificationFile": {
const authError = requireAdmin(event);
if (authError) return { success: false, errMsg: authError };
if (!event.fileID) return { success: false, errMsg: "缺少 fileID" };
return store.update(async (database) => {
if (!database.identificationFiles.some((item) => item.fileID === event.fileID)) {
return { success: false, errMsg: "辨识文件不存在" };
}
const deletedCount = await removeFile(database, event.fileID);
return { success: true, deletedCount };
});
}
case "registerIdentificationResult": {
const deviceId = normalizeDeviceId(event.deviceId);
if (!event.runId) return { success: false, errMsg: "缺少 deviceId 或 runId" };
return store.update((database) => {
const record = { deviceId, runId: String(event.runId), fileName: event.fileName || String(event.runId), status: "waiting_feedback", result: null, updateTime: new Date().toISOString() };
database.identificationFeedback = database.identificationFeedback.filter((item) => item.deviceId !== deviceId);
database.identificationFeedback.push(record);
return { success: true, runId: record.runId, status: record.status };
});
}
case "getIdentificationFeedback": {
const deviceId = normalizeDeviceId(event.deviceId);
if (!event.runId) return { success: false, errMsg: "缺少 deviceId 或 runId" };
const database = await store.read();
const record = database.identificationFeedback.find((item) => item.deviceId === deviceId && item.runId === String(event.runId));
if (!record || record.status !== "feedback_ready") return { success: true, ready: false };
return { success: true, ready: true, result: record.result, runId: record.runId };
}
case "setIdentificationFeedback": {
const authError = requireAdmin(event);
if (authError) return { success: false, errMsg: authError };
const deviceId = normalizeDeviceId(event.deviceId);
if (event.result !== 0 && event.result !== 1) return { success: false, errMsg: "result 必须是数字 0/1" };
return store.update((database) => {
const record = database.identificationFeedback.find((item) => item.deviceId === deviceId);
if (!record) return { success: false, errMsg: "当前没有待审核的辨识结果" };
if (event.runId && String(event.runId) !== record.runId) return { success: false, errMsg: "runId 与当前待审核结果不一致" };
record.status = "feedback_ready";
record.result = event.result;
record.updateTime = new Date().toISOString();
return { success: true, runId: record.runId, fileName: record.fileName, result: record.result };
});
}
case "ackIdentificationFeedback": {
const deviceId = normalizeDeviceId(event.deviceId);
if (!event.runId) return { success: false, errMsg: "缺少 deviceId 或 runId" };
return store.update((database) => {
const before = database.identificationFeedback.length;
database.identificationFeedback = database.identificationFeedback.filter((item) => !(item.deviceId === deviceId && item.runId === String(event.runId)));
return { success: true, deleted: before - database.identificationFeedback.length };
});
}
case "createVolumeConfigRequest": {
const deviceId = normalizeDeviceId(event.deviceId);
return store.update(async (database) => {
const previous = database.volumeConfigRequests.filter((item) => item.deviceId === deviceId);
for (const record of previous) if (record.configFileID) await removeFile(database, record.configFileID);
database.volumeConfigRequests = database.volumeConfigRequests.filter((item) => item.deviceId !== deviceId);
const createdAtMs = Date.now();
const record = { deviceId, requestId: `${createdAtMs}-${randomUUID().slice(0, 10)}`, status: "waiting_upload", createdAtMs, expiresAtMs: createdAtMs + VOLUME_REQUEST_TTL_MS, configFileID: null, configFileName: null, uploadedAtMs: null, updateTime: new Date().toISOString() };
database.volumeConfigRequests.push(record);
return { success: true, requestId: record.requestId, createdAtMs, expiresAtMs: record.expiresAtMs };
});
}
case "getPendingVolumeConfigRequest": {
const deviceId = normalizeDeviceId(event.deviceId);
return store.update(async (database) => {
const record = database.volumeConfigRequests.find((item) => item.deviceId === deviceId && item.status === "waiting_upload");
if (!record) return { success: true, pending: false };
if (Date.now() > record.expiresAtMs) {
database.volumeConfigRequests = database.volumeConfigRequests.filter((item) => item !== record);
return { success: true, pending: false, expired: true };
}
return { success: true, pending: true, requestId: record.requestId, createdAtMs: record.createdAtMs, expiresAtMs: record.expiresAtMs };
});
}
case "submitVolumeConfigFile": {
const deviceId = normalizeDeviceId(event.deviceId);
if (!event.requestId || !event.fileID) return { success: false, errMsg: "缺少 deviceId、requestId 或 fileID" };
return store.update((database) => {
const record = database.volumeConfigRequests.find((item) => item.deviceId === deviceId && item.requestId === String(event.requestId));
if (!record) return { success: false, errMsg: "容积参数请求不存在" };
if (record.status !== "waiting_upload" || Date.now() > record.expiresAtMs) return { success: false, errMsg: "容积参数请求已失效" };
const fileName = event.fileName || "volume_measurement.json";
const expectedFolder = `${deviceId}/volume_config_requests/${record.requestId}`;
const fileRecord = database.fileRecords.find((item) => item.fileID === String(event.fileID) && item.folder === expectedFolder && item.fileName === fileName);
if (!fileRecord) return { success: false, errMsg: "上传文件不属于本次容积参数请求" };
record.status = "ready";
record.configFileID = fileRecord.fileID;
record.configFileName = fileName;
record.uploadedAtMs = Date.now();
record.updateTime = new Date().toISOString();
return { success: true, requestId: record.requestId, uploadedAtMs: record.uploadedAtMs };
});
}
case "getVolumeConfigRequest": {
const deviceId = normalizeDeviceId(event.deviceId);
if (!event.requestId) return { success: false, errMsg: "缺少 deviceId 或 requestId" };
const database = await store.read();
const record = database.volumeConfigRequests.find((item) => item.deviceId === deviceId && item.requestId === String(event.requestId));
if (!record || Date.now() > record.expiresAtMs) return { success: true, ready: false, expired: true };
if (record.status !== "ready") return { success: true, ready: false, expired: false };
const fileRecord = database.fileRecords.find((item) => item.fileID === record.configFileID);
if (!fileRecord) return { success: false, errMsg: "容积配置文件记录不存在" };
logConfigRead({ configType: "volume", deviceId, requestId: record.requestId, record: fileRecord });
return { success: true, ready: true, expired: false, requestId: record.requestId, fileName: record.configFileName, fileID: fileRecord.fileID, cloudPath: fileRecord.cloudPath, uploadedAtMs: record.uploadedAtMs, url: downloadUrl(req, record.configFileID) };
}
case "ackVolumeConfigRequest": {
const deviceId = normalizeDeviceId(event.deviceId);
if (!event.requestId) return { success: false, errMsg: "缺少 deviceId 或 requestId" };
return store.update(async (database) => {
const records = database.volumeConfigRequests.filter((item) => item.deviceId === deviceId && item.requestId === String(event.requestId));
for (const record of records) if (record.configFileID) await removeFile(database, record.configFileID);
database.volumeConfigRequests = database.volumeConfigRequests.filter((item) => !records.includes(item));
return { success: true, deleted: records.length };
});
}
default:
return { success: false, errMsg: "无效的 type 字段" };
}
}
app.get("/health", (req, res) => res.json({ success: true, service: "reinloop-server" }));
app.post("/upload/:token", upload.single("file"), async (req, res, next) => {
try {
const pending = pendingUploads.get(req.params.token);
if (!pending || pending.expiresAt < Date.now()) return res.status(403).json({ success: false, errMsg: "上传凭证无效或已过期" });
if (!req.file) return res.status(400).json({ success: false, errMsg: "缺少 file 表单字段" });
const destination = store.resolveStoredFile(pending.fileID);
await fs.promises.mkdir(path.dirname(destination), { recursive: true });
await fs.promises.writeFile(destination, req.file.buffer);
await store.update((database) => {
const record = { _id: store.createId("file"), fileName: pending.fileName, originalFileName: pending.originalFileName, folder: pending.folder, cloudPath: pending.cloudPath, fileID: pending.fileID, uploadTime: new Date().toISOString(), size: req.file.size };
database.fileRecords = database.fileRecords.filter((item) => item.cloudPath !== pending.cloudPath);
database.fileRecords.push(record);
const folderParts = pending.folder.split("/");
const extension = path.extname(pending.fileName).toLowerCase();
if (folderParts.at(-1) === "ind_data" && [".csv", ".json"].includes(extension)) {
const deviceId = normalizeDeviceId(folderParts.slice(0, -1).join("/"));
const message = {
deviceId,
fileID: pending.fileID,
fileName: pending.fileName,
mediaType: extension.slice(1),
uploadTime: record.uploadTime
};
database.panelInbox = database.panelInbox.filter((item) => item.fileID !== pending.fileID);
database.panelInbox.push(message);
database.identificationFiles = database.identificationFiles.filter((item) => item.fileID !== pending.fileID);
database.identificationFiles.push({
...message,
size: record.size,
status: "pending",
processedAt: null,
expiresAt: null
});
}
if (pending.configType && pending.parameters) {
const config = {
configType: pending.configType,
parameters: pending.parameters,
version: Date.now(),
updateTime: new Date().toISOString()
};
database.functionConfigs = database.functionConfigs.filter(
(item) => item.configType !== pending.configType
);
database.functionConfigs.push(config);
}
});
const folderParts = pending.folder.split("/");
if (folderParts.at(-1) === "identification_config") {
logReceivedConfig({
configType: "identification",
deviceId: folderParts.slice(0, -1).join("/"),
record: { fileID: pending.fileID, cloudPath: pending.cloudPath },
config: req.file.buffer.toString("utf8").trim()
});
} else if (folderParts.at(-2) === "volume_config_requests") {
let config;
try {
config = JSON.parse(req.file.buffer.toString("utf8"));
} catch {
config = "<invalid JSON>";
}
logReceivedConfig({
configType: "volume",
deviceId: folderParts.slice(0, -2).join("/"),
requestId: folderParts.at(-1),
record: { fileID: pending.fileID, cloudPath: pending.cloudPath },
config
});
}
pendingUploads.delete(req.params.token);
res.status(204).end();
} catch (error) {
next(error);
}
});
app.get("/files/:fileID", async (req, res, next) => {
try {
const fileID = decodeURIComponent(req.params.fileID);
if (!fileID.startsWith("model://") && !hasValidDownloadToken(req, fileID)) {
return res.status(403).json({ success: false, errMsg: "下载凭证无效或已过期" });
}
const database = await store.read();
const record = database.fileRecords.find((item) => item.fileID === fileID);
const filePath = record && store.resolveStoredFile(fileID);
if (!record || !filePath) return res.status(404).json({ success: false, errMsg: "文件不存在" });
await fs.promises.access(filePath);
res.download(filePath, record.fileName);
} catch (error) {
if (error.code === "ENOENT") return res.status(404).json({ success: false, errMsg: "文件不存在" });
next(error);
}
});
const apiHandler = async (req, res) => {
try {
res.json(await dispatch(req.body || {}, req));
} catch (error) {
res.status(400).json({ success: false, errMsg: error.message });
}
};
app.post("/", apiHandler);
app.post("/api", apiHandler);
app.use((error, req, res, next) => {
console.error(error);
res.status(500).json({ success: false, errMsg: "服务器内部错误" });
});
return app;
}
module.exports = { createApp, validateConfigParameters };
+110
View File
@@ -0,0 +1,110 @@
const path = require("node:path");
const fs = require("node:fs");
const { randomUUID } = require("node:crypto");
const { Pool } = require("pg");
const { EMPTY_DATABASE } = require("./store");
function asIso(value) {
return value instanceof Date ? value.toISOString() : value;
}
function formatShanghaiTimestamp(value) {
return new Intl.DateTimeFormat("sv-SE", {
timeZone: "Asia/Shanghai", year: "numeric", month: "2-digit", day: "2-digit",
hour: "2-digit", minute: "2-digit", hourCycle: "h23"
}).format(new Date(value));
}
class PostgresStore {
constructor(connectionString, filesDirectory) {
this.pool = new Pool({ connectionString });
this.filesDirectory = filesDirectory;
this.modelsDirectory = path.join(path.dirname(filesDirectory), "models");
}
async initialize() {
const migrationPath = path.join(__dirname, "..", "migrations", "001_normalized_schema.sql");
await this.pool.query(await fs.promises.readFile(migrationPath, "utf8"));
}
async read() {
return this.readDatabase(this.pool);
}
async readDatabase(queryable) {
const companies = await queryable.query("SELECT id, name, code, created_at FROM companies");
const productionLines = await queryable.query("SELECT id, company_id, name, code, device_id, created_at, last_seen_at FROM production_lines");
const licenses = await queryable.query("SELECT licenses.license_id, licenses.company_id, licenses.production_line_id, licenses.device_id, licenses.customer, licenses.issued_at, licenses.expiry_at, licenses.features, licenses.license, licenses.status, licenses.created_at, licenses.revoked_at, licenses.revocation_reason, companies.name AS company_name, production_lines.name AS production_line_name FROM licenses JOIN companies ON companies.id = licenses.company_id JOIN production_lines ON production_lines.id = licenses.production_line_id");
const fileRecords = await queryable.query("SELECT id, file_name, original_file_name, folder, cloud_path, file_id, upload_time, size_bytes FROM file_records");
const functionConfigs = await queryable.query("SELECT config_type, parameters, version, update_time FROM function_configs");
const panelInbox = await queryable.query("SELECT file_id, device_id, file_name, media_type, upload_time FROM panel_inbox");
const identificationFiles = await queryable.query("SELECT file_id, device_id, file_name, media_type, upload_time, size_bytes, status, processed_at, expires_at FROM identification_files");
const feedback = await queryable.query("SELECT device_id, run_id, file_name, status, result, update_time FROM identification_feedback");
const requests = await queryable.query("SELECT device_id, request_id, status, created_at_ms, expires_at_ms, config_file_id, config_file_name, uploaded_at_ms, update_time FROM volume_config_requests");
return {
...structuredClone(EMPTY_DATABASE),
companies: companies.rows.map((row) => ({ id: row.id, name: row.name, code: row.code, createdAt: asIso(row.created_at) })),
productionLines: productionLines.rows.map((row) => ({ id: row.id, companyId: row.company_id, name: row.name, code: row.code, deviceId: row.device_id, createdAt: asIso(row.created_at), lastSeenAt: row.last_seen_at && asIso(row.last_seen_at) })),
licenses: licenses.rows.map((row) => ({ licenseId: row.license_id, companyId: row.company_id, productionLineId: row.production_line_id, companyName: row.company_name, productionLineName: row.production_line_name, deviceId: row.device_id, customer: row.customer, issued: formatShanghaiTimestamp(row.issued_at), expiry: formatShanghaiTimestamp(row.expiry_at), issuedAt: asIso(row.issued_at), expiryAt: asIso(row.expiry_at), features: row.features, license: row.license, status: row.status, createdAt: asIso(row.created_at), revokedAt: row.revoked_at && asIso(row.revoked_at), revocationReason: row.revocation_reason })),
fileRecords: fileRecords.rows.map((row) => ({ _id: row.id, fileName: row.file_name, originalFileName: row.original_file_name || undefined, folder: row.folder, cloudPath: row.cloud_path, fileID: row.file_id, uploadTime: asIso(row.upload_time), size: Number(row.size_bytes) })),
functionConfigs: functionConfigs.rows.map((row) => ({ configType: row.config_type, parameters: row.parameters, version: Number(row.version), updateTime: asIso(row.update_time) })),
panelInbox: panelInbox.rows.map((row) => ({ fileID: row.file_id, deviceId: row.device_id, fileName: row.file_name, mediaType: row.media_type, uploadTime: asIso(row.upload_time) })),
identificationFiles: identificationFiles.rows.map((row) => ({ fileID: row.file_id, deviceId: row.device_id, fileName: row.file_name, mediaType: row.media_type, uploadTime: asIso(row.upload_time), size: Number(row.size_bytes), status: row.status, processedAt: row.processed_at && asIso(row.processed_at), expiresAt: row.expires_at && asIso(row.expires_at) })),
identificationFeedback: feedback.rows.map((row) => ({ deviceId: row.device_id, runId: row.run_id, fileName: row.file_name, status: row.status, result: row.result, updateTime: asIso(row.update_time) })),
volumeConfigRequests: requests.rows.map((row) => ({ deviceId: row.device_id, requestId: row.request_id, status: row.status, createdAtMs: Number(row.created_at_ms), expiresAtMs: Number(row.expires_at_ms), configFileID: row.config_file_id, configFileName: row.config_file_name, uploadedAtMs: row.uploaded_at_ms && Number(row.uploaded_at_ms), updateTime: asIso(row.update_time) }))
};
}
async update(mutator) {
const client = await this.pool.connect();
try {
await client.query("BEGIN");
await client.query("SELECT pg_advisory_xact_lock(81720260725)");
const database = await this.readDatabase(client);
const response = await mutator(database);
await this.writeDatabase(client, database);
await client.query("COMMIT");
return response;
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
async writeDatabase(client, database) {
await client.query("DELETE FROM volume_config_requests; DELETE FROM identification_feedback; DELETE FROM panel_inbox; DELETE FROM identification_files; DELETE FROM function_configs; DELETE FROM file_records; DELETE FROM licenses; DELETE FROM production_lines; DELETE FROM companies;");
for (const item of database.companies) await client.query("INSERT INTO companies (id, name, code, created_at) VALUES ($1, $2, $3, $4)", [item.id, item.name, item.code, item.createdAt]);
for (const item of database.productionLines) await client.query("INSERT INTO production_lines (id, company_id, name, code, device_id, created_at, last_seen_at) VALUES ($1, $2, $3, $4, $5, $6, $7)", [item.id, item.companyId, item.name, item.code, item.deviceId, item.createdAt, item.lastSeenAt]);
for (const item of database.licenses) await client.query("INSERT INTO licenses (license_id, company_id, production_line_id, device_id, customer, issued_at, expiry_at, features, license, status, created_at, revoked_at, revocation_reason) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)", [item.licenseId, item.companyId, item.productionLineId, item.deviceId, item.customer, item.issuedAt, item.expiryAt, item.features, item.license, item.status, item.createdAt, item.revokedAt, item.revocationReason]);
for (const item of database.fileRecords) await client.query("INSERT INTO file_records (id, file_name, original_file_name, folder, cloud_path, file_id, upload_time, size_bytes) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)", [item._id, item.fileName, item.originalFileName || null, item.folder, item.cloudPath, item.fileID, item.uploadTime, item.size]);
for (const item of database.functionConfigs) await client.query("INSERT INTO function_configs (config_type, parameters, version, update_time) VALUES ($1,$2,$3,$4)", [item.configType, item.parameters, item.version, item.updateTime]);
for (const item of database.panelInbox) await client.query("INSERT INTO panel_inbox (file_id, device_id, file_name, media_type, upload_time) VALUES ($1,$2,$3,$4,$5)", [item.fileID, item.deviceId, item.fileName, item.mediaType, item.uploadTime]);
for (const item of database.identificationFiles) await client.query("INSERT INTO identification_files (file_id, device_id, file_name, media_type, upload_time, size_bytes, status, processed_at, expires_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)", [item.fileID, item.deviceId, item.fileName, item.mediaType, item.uploadTime, item.size, item.status, item.processedAt, item.expiresAt]);
for (const item of database.identificationFeedback) await client.query("INSERT INTO identification_feedback (device_id, run_id, file_name, status, result, update_time) VALUES ($1,$2,$3,$4,$5,$6)", [item.deviceId, item.runId, item.fileName, item.status, item.result, item.updateTime]);
for (const item of database.volumeConfigRequests) await client.query("INSERT INTO volume_config_requests (device_id, request_id, status, created_at_ms, expires_at_ms, config_file_id, config_file_name, uploaded_at_ms, update_time) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)", [item.deviceId, item.requestId, item.status, item.createdAtMs, item.expiresAtMs, item.configFileID, item.configFileName, item.uploadedAtMs, item.updateTime]);
}
createId(prefix) {
return `${prefix}_${randomUUID()}`;
}
resolveStoredFile(fileID) {
if (typeof fileID !== "string") return null;
const isModel = fileID.startsWith("model://");
if (!isModel && !fileID.startsWith("local://")) return null;
const rootDirectory = isModel ? this.modelsDirectory : this.filesDirectory;
const relativePath = fileID.slice(isModel ? "model://".length : "local://".length).replace(/\\/g, "/");
const absolutePath = path.resolve(rootDirectory, relativePath);
const relativeToRoot = path.relative(rootDirectory, absolutePath);
if (relativeToRoot.startsWith("..") || path.isAbsolute(relativeToRoot)) return null;
return absolutePath;
}
async close() {
await this.pool.end();
}
}
module.exports = { PostgresStore };
+51
View File
@@ -0,0 +1,51 @@
const path = require("node:path");
const fs = require("node:fs");
const { createApp } = require("./app");
const { JsonStore } = require("./store");
const { PostgresStore } = require("./postgres-store");
const host = process.env.HOST || "127.0.0.1";
const port = Number(process.env.PORT || 3000);
const identificationPurgeIntervalMs = Number(process.env.IDENTIFICATION_PURGE_INTERVAL_MS || 60 * 60 * 1000);
const dataDirectory = path.resolve(process.env.DATA_DIR || path.join(__dirname, "..", "data"));
async function main() {
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("PORT 必须是有效端口号");
const jsonDatabasePath = path.join(dataDirectory, "database.json");
const hasExistingJsonStore = await fs.promises.access(jsonDatabasePath).then(() => true, () => false);
if (process.env.NODE_ENV === "production" && !process.env.DATABASE_URL && !hasExistingJsonStore) {
throw new Error("生产环境必须配置 DATABASE_URL");
}
if (process.env.NODE_ENV === "production" && !process.env.DATABASE_URL) {
console.warn("警告: 正在使用既有 JSON 数据库;请尽快迁移到 PostgreSQL");
}
const filesDirectory = path.join(dataDirectory, "files");
await fs.promises.mkdir(filesDirectory, { recursive: true });
const store = process.env.DATABASE_URL
? new PostgresStore(process.env.DATABASE_URL, filesDirectory)
: new JsonStore(dataDirectory);
await store.initialize();
const app = createApp({ store });
const purgeIdentificationFiles = async () => {
try {
const purged = await app.locals.purgeExpiredIdentificationFiles();
if (purged) console.info(`[retention] purged ${purged} expired identification file(s)`);
} catch (error) {
console.error("[retention] identification file purge failed", error);
}
};
await purgeIdentificationFiles();
const purgeTimer = setInterval(purgeIdentificationFiles, identificationPurgeIntervalMs);
purgeTimer.unref();
app.listen(port, host, () => {
console.log(`ReinLoop server listening on http://${host}:${port}`);
console.log(`API endpoints: http://${host}:${port} and http://${host}:${port}/api`);
console.log(`Data directory: ${dataDirectory}`);
console.log(`Metadata store: ${process.env.DATABASE_URL ? "PostgreSQL" : "local JSON"}`);
});
}
main().catch((error) => {
console.error(error.message);
process.exitCode = 1;
});
+80
View File
@@ -0,0 +1,80 @@
const fs = require("node:fs");
const path = require("node:path");
const { randomUUID } = require("node:crypto");
const EMPTY_DATABASE = {
fileRecords: [],
functionConfigs: [],
panelInbox: [],
identificationFiles: [],
identificationFeedback: [],
volumeConfigRequests: [],
userInfo: [],
companies: [],
productionLines: [],
licenses: []
};
class JsonStore {
constructor(dataDirectory) {
this.dataDirectory = dataDirectory;
this.filesDirectory = path.join(dataDirectory, "files");
this.modelsDirectory = path.join(dataDirectory, "models");
this.databasePath = path.join(dataDirectory, "database.json");
this.writeQueue = Promise.resolve();
}
async initialize() {
await fs.promises.mkdir(this.filesDirectory, { recursive: true });
try {
await fs.promises.access(this.databasePath);
} catch {
await this.writeDatabase(structuredClone(EMPTY_DATABASE));
}
}
async read() {
const content = await fs.promises.readFile(this.databasePath, "utf8");
return { ...structuredClone(EMPTY_DATABASE), ...JSON.parse(content) };
}
async update(mutator) {
const operation = this.writeQueue.then(async () => {
const database = await this.read();
const result = await mutator(database);
await this.writeDatabase(database);
return result;
});
this.writeQueue = operation.catch(() => undefined);
return operation;
}
async writeDatabase(database) {
await fs.promises.mkdir(this.dataDirectory, { recursive: true });
const temporaryPath = `${this.databasePath}.${process.pid}.tmp`;
await fs.promises.writeFile(
temporaryPath,
`${JSON.stringify(database, null, 2)}\n`,
"utf8"
);
await fs.promises.rename(temporaryPath, this.databasePath);
}
createId(prefix) {
return `${prefix}_${randomUUID()}`;
}
resolveStoredFile(fileID) {
if (typeof fileID !== "string") return null;
const isModel = fileID.startsWith("model://");
if (!isModel && !fileID.startsWith("local://")) return null;
const rootDirectory = isModel ? this.modelsDirectory : this.filesDirectory;
const relativePath = fileID.slice(isModel ? "model://".length : "local://".length).replace(/\\/g, "/");
const absolutePath = path.resolve(rootDirectory, relativePath);
const relativeToRoot = path.relative(rootDirectory, absolutePath);
if (relativeToRoot.startsWith("..") || path.isAbsolute(relativeToRoot)) return null;
return absolutePath;
}
}
module.exports = { EMPTY_DATABASE, JsonStore };