1492 lines
69 KiB
JavaScript
1492 lines
69 KiB
JavaScript
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
const { randomUUID, constants, 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_URL_TTL_MS = Number(process.env.DOWNLOAD_URL_TTL_MS || process.env.DOWNLOAD_TOKEN_TTL_MS || 300000);
|
|
const DEVICE_TOKEN_TTL_MS = Number(process.env.DEVICE_TOKEN_TTL_MS || 15 * 60 * 1000);
|
|
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();
|
|
const pendingDownloads = new Map();
|
|
const pendingDeviceTokens = 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 logRevokeAction({ event, licenseId, success, errCode = null }) {
|
|
console.info("[license] revoke", {
|
|
time: new Date().toISOString(),
|
|
requestId: event.requestId || null,
|
|
type: "revokeLicense",
|
|
licenseId: licenseId || null,
|
|
result: success ? "success" : "failed",
|
|
errCode
|
|
});
|
|
}
|
|
|
|
function normalizeEventCompat(event) {
|
|
if (!event || typeof event !== "object" || Array.isArray(event)) return event;
|
|
const type = String(event.type || event.action || "").trim();
|
|
const legacyRevokeTypes = new Set(["revoke_license", "licenseRevoke", "revoke"]);
|
|
if (!legacyRevokeTypes.has(type) && !(event.action === "revokeLicense" && !event.type)) return event;
|
|
console.warn("[license] legacy revoke request mapped", { type: type || null, requestId: event.requestId || null });
|
|
return {
|
|
...event,
|
|
type: "revokeLicense",
|
|
licenseId: event.licenseId ?? event.license_id ?? event.id,
|
|
reason: event.reason ?? event.revocationReason,
|
|
adminToken: event.adminToken ?? event.admin_token ?? event.token
|
|
};
|
|
}
|
|
|
|
function requestIp(req) {
|
|
return String(req.ip || req.socket?.remoteAddress || "").trim();
|
|
}
|
|
|
|
function issueDownloadUrl(req, fileID) {
|
|
const ticket = randomUUID().replace(/-/g, "");
|
|
pendingDownloads.set(ticket, {
|
|
fileID,
|
|
requestIp: requestIp(req),
|
|
expiresAtMs: Date.now() + DOWNLOAD_URL_TTL_MS
|
|
});
|
|
return `${publicBaseUrl(req)}/downloads/${ticket}`;
|
|
}
|
|
|
|
function consumeDownloadTicket(req, ticket) {
|
|
const request = pendingDownloads.get(ticket);
|
|
if (!request) return { ok: false, errMsg: "下载链接无效或已失效" };
|
|
pendingDownloads.delete(ticket);
|
|
if (request.expiresAtMs < Date.now()) return { ok: false, errMsg: "下载链接已过期" };
|
|
if (!request.requestIp || request.requestIp !== requestIp(req)) {
|
|
return { ok: false, errMsg: "下载请求来源IP不匹配" };
|
|
}
|
|
return { ok: true, fileID: request.fileID };
|
|
}
|
|
|
|
function verifyLicenseRecord(record, deviceId) {
|
|
if (!record) return { success: false, valid: false, status: "not_found" };
|
|
if (deviceId && 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
|
|
};
|
|
}
|
|
|
|
function issueDeviceToken({ deviceId, licenseId }) {
|
|
const token = randomUUID().replace(/-/g, "");
|
|
const expiresAtMs = Date.now() + DEVICE_TOKEN_TTL_MS;
|
|
pendingDeviceTokens.set(token, { deviceId, licenseId, expiresAtMs });
|
|
return { token, expiresAtMs };
|
|
}
|
|
|
|
function consumeDeviceContext(event) {
|
|
const deviceToken = String(event.deviceToken || "").trim();
|
|
if (!deviceToken) {
|
|
return { ok: false, errCode: "DEVICE_TOKEN_REQUIRED", errMsg: "缺少 deviceToken" };
|
|
}
|
|
const session = pendingDeviceTokens.get(deviceToken);
|
|
if (!session) {
|
|
return { ok: false, errCode: "DEVICE_TOKEN_INVALID", errMsg: "deviceToken 无效" };
|
|
}
|
|
if (session.expiresAtMs <= Date.now()) {
|
|
pendingDeviceTokens.delete(deviceToken);
|
|
return { ok: false, errCode: "DEVICE_TOKEN_EXPIRED", errMsg: "deviceToken 已过期" };
|
|
}
|
|
return { ok: true, ...session };
|
|
}
|
|
|
|
//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);
|
|
database.volumeConfigs = database.volumeConfigs.filter((record) => record.fileID !== fileID);
|
|
database.panelNotifications = database.panelNotifications.filter((record) => record.fileID !== fileID);
|
|
return before - database.fileRecords.length;
|
|
}
|
|
|
|
async function removeDeviceDirectories(deviceId) {
|
|
const segments = String(deviceId || "").split("/");
|
|
if (segments.length !== 2 || !segments[0] || !segments[1]) return 0;
|
|
const [companyCode, lineCode] = segments;
|
|
const targets = [
|
|
path.join(store.modelsDirectory, companyCode, lineCode),
|
|
path.join(store.filesDirectory, "ReinLoop_GUI", companyCode, lineCode)
|
|
];
|
|
let removed = 0;
|
|
for (const directory of targets) {
|
|
try {
|
|
const stat = await fs.promises.stat(directory);
|
|
if (!stat.isDirectory()) continue;
|
|
} catch (_error) {
|
|
continue;
|
|
}
|
|
await fs.promises.rm(directory, { recursive: true, force: true });
|
|
removed += 1;
|
|
}
|
|
return removed;
|
|
}
|
|
|
|
async function removeProductionLineData(database, { companyId, productionLineId, failOnActiveLicense = true }) {
|
|
const line = database.productionLines.find((item) => item.id === productionLineId && item.companyId === companyId);
|
|
if (!line) return { success: false, errMsg: "产线不存在" };
|
|
|
|
const activeLicenses = database.licenses.filter((item) =>
|
|
item.companyId === companyId && item.productionLineId === productionLineId && item.status === "active"
|
|
);
|
|
if (failOnActiveLicense && activeLicenses.length) {
|
|
return {
|
|
success: false,
|
|
errMsg: `请先撤销该产线的 ${activeLicenses.length} 个有效许可证后再删除产线`,
|
|
errCode: "ACTIVE_LICENSES_PRESENT"
|
|
};
|
|
}
|
|
|
|
const deviceId = line.deviceId;
|
|
const relatedFileIDs = database.fileRecords
|
|
.filter((record) => {
|
|
if (record.fileID.startsWith(`model://${deviceId}/`)) return true;
|
|
const prefix = `${deviceId}/`;
|
|
return record.folder === deviceId || record.folder.startsWith(prefix);
|
|
})
|
|
.map((record) => record.fileID);
|
|
|
|
let deletedFileCount = 0;
|
|
for (const fileID of new Set(relatedFileIDs)) {
|
|
deletedFileCount += await removeFile(database, fileID);
|
|
}
|
|
|
|
const beforeFeedback = database.identificationFeedback.length;
|
|
database.identificationFeedback = database.identificationFeedback.filter((item) => item.deviceId !== deviceId);
|
|
const deletedFeedback = beforeFeedback - database.identificationFeedback.length;
|
|
|
|
const beforeRequests = database.volumeConfigRequests.length;
|
|
database.volumeConfigRequests = database.volumeConfigRequests.filter((item) => item.deviceId !== deviceId);
|
|
const deletedRequests = beforeRequests - database.volumeConfigRequests.length;
|
|
|
|
const beforeConfigs = database.volumeConfigs.length;
|
|
database.volumeConfigs = database.volumeConfigs.filter((item) => item.deviceId !== deviceId);
|
|
const deletedConfigs = beforeConfigs - database.volumeConfigs.length;
|
|
|
|
const beforeNotifications = database.panelNotifications.length;
|
|
database.panelNotifications = database.panelNotifications.filter((item) => item.deviceId !== deviceId);
|
|
const deletedNotifications = beforeNotifications - database.panelNotifications.length;
|
|
|
|
const deletedLicenses = database.licenses.filter((item) =>
|
|
item.companyId === companyId && item.productionLineId === productionLineId
|
|
).length;
|
|
database.licenses = database.licenses.filter((item) =>
|
|
!(item.companyId === companyId && item.productionLineId === productionLineId)
|
|
);
|
|
|
|
const deletedDirectories = await removeDeviceDirectories(deviceId);
|
|
database.productionLines = database.productionLines.filter((item) => item.id !== productionLineId);
|
|
|
|
return {
|
|
success: true,
|
|
deletedProductionLineId: productionLineId,
|
|
deletedFiles: deletedFileCount,
|
|
deletedDirectories,
|
|
deletedLicenses,
|
|
deletedFeedback,
|
|
deletedVolumeRequests: deletedRequests,
|
|
deletedVolumeConfigs: deletedConfigs,
|
|
deletedNotifications
|
|
};
|
|
}
|
|
|
|
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()
|
|
});
|
|
}
|
|
}
|
|
|
|
function addPanelNotification(database, { deviceId, type, title, message, requestId = null, fileID = null }) {
|
|
const notification = {
|
|
notificationId: store.createId("pn"),
|
|
deviceId,
|
|
type,
|
|
title,
|
|
message,
|
|
requestId,
|
|
fileID,
|
|
createdAtMs: Date.now()
|
|
};
|
|
database.panelNotifications.push(notification);
|
|
return notification;
|
|
}
|
|
|
|
function findIdentificationFeedbackRecord(database, deviceId, fileName) {
|
|
if (!deviceId || !fileName) return null;
|
|
const normalizedFileName = String(fileName);
|
|
return database.identificationFeedback.find((item) =>
|
|
item.deviceId === deviceId
|
|
&& (item.fileName === normalizedFileName || item.runId === normalizedFileName)
|
|
) || null;
|
|
}
|
|
|
|
function resolveIdentificationRunId(database, record) {
|
|
if (!record) return null;
|
|
if (record.runId) return String(record.runId);
|
|
const feedback = findIdentificationFeedbackRecord(database, record.deviceId, record.fileName);
|
|
if (feedback) return String(feedback.runId);
|
|
if (record.fileName) return String(record.fileName);
|
|
return null;
|
|
}
|
|
|
|
function enrichIdentificationFile(database, record) {
|
|
const runId = resolveIdentificationRunId(database, record);
|
|
return runId ? { ...record, runId } : { ...record };
|
|
}
|
|
|
|
function enrichNotification(database, notification) {
|
|
if (!notification || notification.type !== "identification_result_ready" || !notification.fileID) {
|
|
return notification;
|
|
}
|
|
const related = database.identificationFiles.find((item) => item.fileID === notification.fileID)
|
|
|| database.panelInbox.find((item) => item.fileID === notification.fileID);
|
|
const runId = resolveIdentificationRunId(database, related);
|
|
return runId ? { ...notification, runId } : notification;
|
|
}
|
|
|
|
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 "deleteProductionLine": {
|
|
const authError = requireAdmin(event);
|
|
if (authError) return { success: false, errMsg: authError };
|
|
const companyId = String(event.companyId || "").trim();
|
|
const productionLineId = String(event.productionLineId || "").trim();
|
|
if (!companyId || !productionLineId) {
|
|
return { success: false, errMsg: "缺少 companyId 或 productionLineId" };
|
|
}
|
|
return store.update(async (database) => {
|
|
const company = database.companies.find((item) => item.id === companyId);
|
|
if (!company) return { success: false, errMsg: "公司不存在" };
|
|
return removeProductionLineData(database, {
|
|
companyId,
|
|
productionLineId,
|
|
failOnActiveLicense: true
|
|
});
|
|
});
|
|
}
|
|
case "deleteCompany": {
|
|
const authError = requireAdmin(event);
|
|
if (authError) return { success: false, errMsg: authError };
|
|
const companyId = String(event.companyId || "").trim();
|
|
if (!companyId) return { success: false, errMsg: "缺少 companyId" };
|
|
return store.update(async (database) => {
|
|
const company = database.companies.find((item) => item.id === companyId);
|
|
if (!company) return { success: false, errMsg: "公司不存在" };
|
|
|
|
const activeLicenses = database.licenses.filter((item) =>
|
|
item.companyId === companyId && item.status === "active"
|
|
);
|
|
if (activeLicenses.length) {
|
|
return {
|
|
success: false,
|
|
errMsg: `请先撤销该公司的 ${activeLicenses.length} 个有效许可证后再删除公司`,
|
|
errCode: "ACTIVE_LICENSES_PRESENT"
|
|
};
|
|
}
|
|
|
|
const lines = database.productionLines.filter((item) => item.companyId === companyId);
|
|
|
|
const summary = {
|
|
deletedProductionLines: 0,
|
|
deletedFiles: 0,
|
|
deletedDirectories: 0,
|
|
deletedLicenses: 0,
|
|
deletedFeedback: 0,
|
|
deletedVolumeRequests: 0,
|
|
deletedVolumeConfigs: 0,
|
|
deletedNotifications: 0
|
|
};
|
|
|
|
for (const line of lines) {
|
|
const lineDeleted = await removeProductionLineData(database, {
|
|
companyId,
|
|
productionLineId: line.id,
|
|
failOnActiveLicense: false
|
|
});
|
|
if (!lineDeleted.success) return lineDeleted;
|
|
summary.deletedProductionLines += 1;
|
|
summary.deletedFiles += lineDeleted.deletedFiles;
|
|
summary.deletedDirectories += lineDeleted.deletedDirectories;
|
|
summary.deletedLicenses += lineDeleted.deletedLicenses;
|
|
summary.deletedFeedback += lineDeleted.deletedFeedback;
|
|
summary.deletedVolumeRequests += lineDeleted.deletedVolumeRequests;
|
|
summary.deletedVolumeConfigs += lineDeleted.deletedVolumeConfigs;
|
|
summary.deletedNotifications += lineDeleted.deletedNotifications;
|
|
}
|
|
|
|
const orphanCompanyLicenses = database.licenses.filter((item) => item.companyId === companyId).length;
|
|
if (orphanCompanyLicenses > 0) {
|
|
summary.deletedLicenses += orphanCompanyLicenses;
|
|
database.licenses = database.licenses.filter((item) => item.companyId !== companyId);
|
|
}
|
|
|
|
database.companies = database.companies.filter((item) => item.id !== companyId);
|
|
return { success: true, deletedCompanyId: companyId, ...summary };
|
|
});
|
|
}
|
|
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);
|
|
const licenseId = String(event.licenseId || "").trim();
|
|
if (authError) {
|
|
const errCode = authError === "B端管理令牌无效"
|
|
? "ADMIN_TOKEN_INVALID"
|
|
: "ADMIN_TOKEN_NOT_CONFIGURED";
|
|
const result = { success: false, errMsg: authError, errCode };
|
|
logRevokeAction({ event, licenseId, success: false, errCode: result.errCode });
|
|
return result;
|
|
}
|
|
if (!licenseId) {
|
|
const result = { success: false, errMsg: "licenseId 不能为空", errCode: "LICENSE_ID_REQUIRED" };
|
|
logRevokeAction({ event, licenseId, success: false, errCode: result.errCode });
|
|
return result;
|
|
}
|
|
const result = await store.update((database) => {
|
|
const record = database.licenses.find((item) => item.licenseId === licenseId);
|
|
if (!record) return { success: false, errMsg: "许可证不存在", errCode: "LICENSE_NOT_FOUND" };
|
|
record.status = "revoked";
|
|
record.revokedAt = new Date().toISOString();
|
|
record.revocationReason = String(event.reason || "管理员撤销").trim();
|
|
return { success: true, license: publicLicense(record) };
|
|
});
|
|
logRevokeAction({ event, licenseId, success: result.success, errCode: result.errCode });
|
|
return result;
|
|
}
|
|
case "deleteLicense": {
|
|
const authError = requireAdmin(event);
|
|
if (authError) return { success: false, errMsg: authError };
|
|
const licenseId = String(event.licenseId || "").trim();
|
|
if (!licenseId) return { success: false, errMsg: "licenseId 不能为空", errCode: "LICENSE_ID_REQUIRED" };
|
|
return store.update((database) => {
|
|
const record = database.licenses.find((item) => item.licenseId === licenseId);
|
|
if (!record) return { success: false, errMsg: "许可证不存在", errCode: "LICENSE_NOT_FOUND" };
|
|
if (record.status === "active") {
|
|
return {
|
|
success: false,
|
|
errMsg: "请先撤销许可证后再删除",
|
|
errCode: "LICENSE_ACTIVE"
|
|
};
|
|
}
|
|
database.licenses = database.licenses.filter((item) => item.licenseId !== licenseId);
|
|
return { success: true, deletedLicenseId: licenseId };
|
|
});
|
|
}
|
|
case "validateLicense": {
|
|
const database = await store.read();
|
|
const record = database.licenses.find((item) => item.licenseId === event.licenseId);
|
|
const deviceId = event.deviceId ? normalizeDeviceId(event.deviceId) : null;
|
|
return verifyLicenseRecord(record, deviceId);
|
|
}
|
|
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: "文件不存在" };
|
|
const authError = requireAdmin(event);
|
|
if (authError) return { success: false, errMsg: authError };
|
|
return {
|
|
success: true,
|
|
url: issueDownloadUrl(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;
|
|
const fileIDs = [...new Set(records.map((record) => record.fileID))];
|
|
for (const fileID of fileIDs) deletedCount += await removeFile(database, fileID);
|
|
return { success: true, deletedFileID: fileIDs.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 authError = requireAdmin(event);
|
|
if (authError) return { success: false, errMsg: authError };
|
|
const deviceId = normalizeDeviceId(event.deviceId);
|
|
const database = await store.read();
|
|
const config = database.volumeConfigs.find((item) => item.deviceId === deviceId);
|
|
if (!config) return { success: true, found: false, deviceId };
|
|
const record = database.fileRecords.find((item) => item.fileID === config.fileID);
|
|
if (!record) return { success: true, found: false, deviceId };
|
|
return {
|
|
success: true, found: true, deviceId, fileID: record.fileID,
|
|
fileName: record.fileName, url: issueDownloadUrl(req, record.fileID),
|
|
updatedAtMs: config.updatedAtMs, requestId: config.requestId
|
|
};
|
|
}
|
|
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: issueDownloadUrl(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 Boolean(findIdentificationFeedbackRecord(database, deviceId, item.fileName));
|
|
});
|
|
if (!message) return { success: true, pending: false };
|
|
const runId = resolveIdentificationRunId(database, message);
|
|
return {
|
|
success: true,
|
|
pending: true,
|
|
fileID: message.fileID,
|
|
fileName: message.fileName,
|
|
runId,
|
|
mediaType: message.mediaType,
|
|
uploadTime: message.uploadTime,
|
|
url: issueDownloadUrl(req, message.fileID)
|
|
};
|
|
}
|
|
case "getPendingPanelNotification": {
|
|
const authError = requireAdmin(event);
|
|
if (authError) return { success: false, errMsg: authError };
|
|
const deviceId = normalizeDeviceId(event.deviceId);
|
|
const database = await store.read();
|
|
const notification = database.panelNotifications.find((item) => item.deviceId === deviceId);
|
|
return notification
|
|
? { success: true, pending: true, notification: enrichNotification(database, notification) }
|
|
: { success: true, pending: false };
|
|
}
|
|
case "ackPanelNotification": {
|
|
const authError = requireAdmin(event);
|
|
if (authError) return { success: false, errMsg: authError };
|
|
const deviceId = normalizeDeviceId(event.deviceId);
|
|
const notificationId = String(event.notificationId || "");
|
|
if (!notificationId) return { success: false, errMsg: "缺少 notificationId" };
|
|
return store.update((database) => {
|
|
const exactMatch = database.panelNotifications.find((item) =>
|
|
item.deviceId === deviceId && item.notificationId === notificationId
|
|
);
|
|
if (exactMatch) {
|
|
database.panelNotifications = database.panelNotifications.filter((item) => item !== exactMatch);
|
|
return { success: true, notificationId, deleted: 1 };
|
|
}
|
|
|
|
// 兼容:客户端 deviceId 切换或重连后,允许仅凭 notificationId 完成幂等关闭。
|
|
const fallbackMatch = database.panelNotifications.find((item) => item.notificationId === notificationId);
|
|
if (fallbackMatch) {
|
|
database.panelNotifications = database.panelNotifications.filter((item) => item !== fallbackMatch);
|
|
return {
|
|
success: true,
|
|
notificationId,
|
|
deleted: 1,
|
|
idempotent: true,
|
|
reassignedDeviceId: fallbackMatch.deviceId
|
|
};
|
|
}
|
|
|
|
return { success: true, notificationId, deleted: 0, idempotent: true };
|
|
});
|
|
}
|
|
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).map((record) => enrichIdentificationFile(database, record)),
|
|
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: issueDownloadUrl(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: "辨识文件不存在" };
|
|
}
|
|
const runId = resolveIdentificationRunId(database, historyRecord);
|
|
return {
|
|
success: true,
|
|
fileID: fileRecord.fileID,
|
|
fileName: fileRecord.fileName,
|
|
runId,
|
|
mediaType: historyRecord.mediaType,
|
|
url: issueDownloadUrl(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);
|
|
addPanelNotification(database, {
|
|
deviceId,
|
|
type: "volume_request_started",
|
|
title: "产线请求容积测试配置",
|
|
message: "请完成容积测试配置并提交。",
|
|
requestId: record.requestId
|
|
});
|
|
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();
|
|
database.volumeConfigs = database.volumeConfigs.filter((item) => item.deviceId !== deviceId);
|
|
database.volumeConfigs.push({
|
|
deviceId,
|
|
fileID: fileRecord.fileID,
|
|
fileName: fileRecord.fileName,
|
|
requestId: record.requestId,
|
|
updatedAtMs: record.uploadedAtMs
|
|
});
|
|
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: issueDownloadUrl(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) {
|
|
const isLatest = database.volumeConfigs.some((item) => item.fileID === record.configFileID);
|
|
if (record.configFileID && !isLatest) 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 字段" };
|
|
}
|
|
}
|
|
|
|
async function dispatchDevice(event, req) {
|
|
if (event.type === "deviceAuth") {
|
|
const licenseId = String(event.licenseId || "").trim();
|
|
if (!licenseId) return { success: false, errCode: "LICENSE_ID_REQUIRED", errMsg: "缺少 licenseId" };
|
|
const deviceId = normalizeDeviceId(event.deviceId);
|
|
const database = await store.read();
|
|
const record = database.licenses.find((item) => item.licenseId === licenseId);
|
|
const validation = verifyLicenseRecord(record, deviceId);
|
|
if (!validation.valid) {
|
|
const errCodeMap = {
|
|
not_found: "LICENSE_NOT_FOUND",
|
|
device_mismatch: "DEVICE_ID_MISMATCH",
|
|
expired: "LICENSE_EXPIRED",
|
|
revoked: "LICENSE_REVOKED"
|
|
};
|
|
return {
|
|
success: false,
|
|
errCode: errCodeMap[validation.status] || "LICENSE_INVALID",
|
|
errMsg: `许可证不可用: ${validation.status}`
|
|
};
|
|
}
|
|
const issued = issueDeviceToken({ deviceId, licenseId });
|
|
return {
|
|
success: true,
|
|
deviceId,
|
|
licenseId,
|
|
deviceToken: issued.token,
|
|
expiresAtMs: issued.expiresAtMs
|
|
};
|
|
}
|
|
|
|
const context = consumeDeviceContext(event);
|
|
if (!context.ok) return { success: false, errCode: context.errCode, errMsg: context.errMsg };
|
|
const deviceId = context.deviceId;
|
|
|
|
switch (event.type) {
|
|
case "deviceHeartbeat":
|
|
case "registerIdentificationResult":
|
|
case "getIdentificationFeedback":
|
|
case "ackIdentificationFeedback":
|
|
case "createVolumeConfigRequest":
|
|
case "getPendingVolumeConfigRequest":
|
|
case "submitVolumeConfigFile":
|
|
case "getVolumeConfigRequest":
|
|
case "ackVolumeConfigRequest":
|
|
case "getIdentificationConfig":
|
|
return dispatch({ ...event, deviceId }, req);
|
|
case "uploadDataFile": {
|
|
const folder = normalizeRelativePath(event.folder, "data_record");
|
|
const devicePrefix = `${deviceId}/`;
|
|
if (!folder.startsWith(devicePrefix)) {
|
|
return { success: false, errMsg: "设备接口仅允许访问当前 deviceId 目录" };
|
|
}
|
|
if (folder.endsWith("/model_config")) {
|
|
return { success: false, errMsg: "设备接口不允许上传模型" };
|
|
}
|
|
return dispatch({ ...event, folder }, req);
|
|
}
|
|
case "listModels": {
|
|
const folder = `${deviceId}/model_config`;
|
|
return dispatch({ ...event, folder }, req);
|
|
}
|
|
case "downloadModel": {
|
|
if (!event.fileID) return { success: false, errMsg: "缺少 fileID" };
|
|
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.folder !== `${deviceId}/model_config`) {
|
|
return { success: false, errMsg: "设备接口无权下载该模型" };
|
|
}
|
|
return {
|
|
success: true,
|
|
url: issueDownloadUrl(req, record.fileID),
|
|
fileName: record.fileName,
|
|
originalFileName: record.originalFileName || record.fileName
|
|
};
|
|
}
|
|
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
|
|
});
|
|
addPanelNotification(database, {
|
|
deviceId,
|
|
type: "identification_result_ready",
|
|
title: "系统辨识结果已返回",
|
|
message: "新的系统辨识 CSV 或 JSON 数据已进入暂存。",
|
|
fileID: pending.fileID
|
|
});
|
|
}
|
|
if (folderParts.at(-1) === "V_config" && extension === ".json") {
|
|
const deviceId = normalizeDeviceId(folderParts.slice(0, -1).join("/"));
|
|
addPanelNotification(database, {
|
|
deviceId,
|
|
type: "volume_result_ready",
|
|
title: "容积测试结果已返回",
|
|
message: "容积测试结果 JSON 已上传,可打开查看。",
|
|
fileID: pending.fileID
|
|
});
|
|
}
|
|
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("/downloads/:ticket", async (req, res, next) => {
|
|
try {
|
|
const ticket = String(req.params.ticket || "").trim();
|
|
if (!ticket) return res.status(403).json({ success: false, errMsg: "下载链接无效或已失效" });
|
|
const consumed = consumeDownloadTicket(req, ticket);
|
|
if (!consumed.ok) return res.status(403).json({ success: false, errMsg: consumed.errMsg });
|
|
const fileID = consumed.fileID;
|
|
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);
|
|
}
|
|
});
|
|
|
|
app.get("/files/:fileID", (req, res) => {
|
|
res.status(403).json({ success: false, errMsg: "旧下载链接已停用,请先通过 API 获取临时下载链接" });
|
|
});
|
|
|
|
const apiHandler = async (req, res) => {
|
|
try {
|
|
res.json(await dispatch(normalizeEventCompat(req.body || {}), req));
|
|
} catch (error) {
|
|
res.status(400).json({ success: false, errMsg: error.message });
|
|
}
|
|
};
|
|
app.post("/", apiHandler);
|
|
|
|
const deviceApiHandler = async (req, res) => {
|
|
try {
|
|
res.json(await dispatchDevice(normalizeEventCompat(req.body || {}), req));
|
|
} catch (error) {
|
|
res.status(400).json({ success: false, errMsg: error.message });
|
|
}
|
|
};
|
|
app.post("/device", deviceApiHandler);
|
|
|
|
app.use((error, req, res, next) => {
|
|
console.error(error);
|
|
res.status(500).json({ success: false, errMsg: "服务器内部错误" });
|
|
});
|
|
|
|
return app;
|
|
}
|
|
|
|
module.exports = { createApp, validateConfigParameters }; |