update server
This commit is contained in:
@@ -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 };
|
||||
Reference in New Issue
Block a user