From a3b7d3d87618b4a686ebf720b841b75351884886 Mon Sep 17 00:00:00 2001 From: Epifnne Date: Mon, 3 Aug 2026 17:27:23 +0800 Subject: [PATCH 1/3] banned cross device check --- .../scripts/test-cross-device-notification.js | 118 ++++++++++++++++++ server/src/app.js | 2 +- server/test/server.test.js | 5 +- 3 files changed, 121 insertions(+), 4 deletions(-) create mode 100644 server/scripts/test-cross-device-notification.js diff --git a/server/scripts/test-cross-device-notification.js b/server/scripts/test-cross-device-notification.js new file mode 100644 index 0000000..630a983 --- /dev/null +++ b/server/scripts/test-cross-device-notification.js @@ -0,0 +1,118 @@ +"use strict"; + +function parseArgs(argv) { + const result = {}; + for (let index = 2; index < argv.length; index += 1) { + const arg = argv[index]; + if (!arg.startsWith("--")) continue; + const [key, ...rest] = arg.slice(2).split("="); + if (!key) continue; + const value = rest.length ? rest.join("=") : ""; + result[key] = value; + } + return result; +} + +const args = parseArgs(process.argv); +const BASE_URL = (args.baseUrl || process.env.BASE_URL || "http://127.0.0.1:3000").replace(/\/$/, ""); +const ADMIN_TOKEN = args.adminToken || process.env.ADMIN_TOKEN || ""; +const DEVICE_A = args.deviceA || process.env.DEVICE_A || "develop-test2/test1"; +const DEVICE_B = args.deviceB || process.env.DEVICE_B || "develop-test/test1"; + +if (!ADMIN_TOKEN) { + console.error("[ERROR] 缺少 ADMIN_TOKEN 环境变量。"); + console.error("示例1: node scripts/test-cross-device-notification.js --adminToken=your-token"); + console.error("示例2: node scripts/test-cross-device-notification.js --adminToken=your-token --baseUrl=http://127.0.0.1:3000 --deviceA=develop-test2/test1 --deviceB=develop-test/test1"); + process.exit(1); +} + +async function postJson(path, body) { + const response = await fetch(`${BASE_URL}${path}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body) + }); + + let data; + try { + data = await response.json(); + } catch (_error) { + data = { success: false, errMsg: "响应不是 JSON" }; + } + + return { status: response.status, data }; +} + +function assert(condition, message, details) { + if (condition) return; + const error = new Error(message); + error.details = details; + throw error; +} + +async function main() { + console.log(`[INFO] BASE_URL=${BASE_URL}`); + console.log(`[INFO] DEVICE_A=${DEVICE_A}`); + console.log(`[INFO] DEVICE_B=${DEVICE_B}`); + + console.log("\n[1/5] 为 DEVICE_A 生成一条通知(createVolumeConfigRequest)..."); + const createReq = await postJson("/", { + type: "createVolumeConfigRequest", + deviceId: DEVICE_A + }); + assert(createReq.data.success === true, "createVolumeConfigRequest 失败", createReq); + + console.log("[2/5] 读取 DEVICE_A 的 pending notification..."); + const pendingA = await postJson("/", { + type: "getPendingPanelNotification", + adminToken: ADMIN_TOKEN, + deviceId: DEVICE_A + }); + assert(pendingA.data.success === true, "getPendingPanelNotification(DEVICE_A) 调用失败", pendingA); + assert(pendingA.data.pending === true, "DEVICE_A 没有待处理通知", pendingA); + const notificationId = pendingA.data.notification?.notificationId; + assert(Boolean(notificationId), "未拿到 notificationId", pendingA); + console.log(`[INFO] notificationId=${notificationId}`); + + console.log("[3/5] 使用 DEVICE_B 尝试确认 DEVICE_A 的 notificationId(预期失败)..."); + const crossAck = await postJson("/", { + type: "ackPanelNotification", + adminToken: ADMIN_TOKEN, + deviceId: DEVICE_B, + notificationId + }); + assert(crossAck.data.success === false, "跨 deviceId 确认意外成功(存在越权风险)", crossAck); + assert(crossAck.data.errMsg === "Panel 通知不存在", "跨 deviceId 失败文案非预期", crossAck); + + console.log("[4/5] 使用 DEVICE_A 正常确认同一 notificationId(预期成功)..."); + const ownerAck = await postJson("/", { + type: "ackPanelNotification", + adminToken: ADMIN_TOKEN, + deviceId: DEVICE_A, + notificationId + }); + assert(ownerAck.data.success === true, "DEVICE_A 确认自身通知失败", ownerAck); + + console.log("[5/5] 再次读取 DEVICE_A pending,确认已被消费..."); + const pendingAfter = await postJson("/", { + type: "getPendingPanelNotification", + adminToken: ADMIN_TOKEN, + deviceId: DEVICE_A + }); + assert(pendingAfter.data.success === true, "二次查询 pending 失败", pendingAfter); + + console.log("\n[PASS] 测试通过:notificationId 不能被其他 deviceId 确认。\n"); + console.log("关键结果:"); + console.log(`- crossAck.success = ${crossAck.data.success}`); + console.log(`- crossAck.errMsg = ${crossAck.data.errMsg}`); + console.log(`- ownerAck.success = ${ownerAck.data.success}`); +} + +main().catch((error) => { + console.error("\n[FAIL]", error.message); + if (error.details) { + console.error("details="); + console.error(JSON.stringify(error.details, null, 2)); + } + process.exit(1); +}); diff --git a/server/src/app.js b/server/src/app.js index df7ab4a..290bfee 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -937,7 +937,7 @@ function createApp({ item.deviceId === deviceId && item.notificationId === notificationId ); if (!exists) { - return { success: true, notificationId, deleted: 0, idempotent: true }; + return { success: false, errMsg: "Panel 通知不存在" }; } database.panelNotifications = database.panelNotifications.filter((item) => !(item.deviceId === deviceId && item.notificationId === notificationId) diff --git a/server/test/server.test.js b/server/test/server.test.js index 019ca3f..6d1fb74 100644 --- a/server/test/server.test.js +++ b/server/test/server.test.js @@ -550,9 +550,8 @@ test("panel notifications and volume configuration stay isolated by device", asy type: "ackPanelNotification", deviceId, notificationId: notification.notification.notificationId, adminToken: "test-token" }); - assert.equal(duplicatedAck.success, true); - assert.equal(duplicatedAck.idempotent, true); - assert.equal(duplicatedAck.deleted, 0); + assert.equal(duplicatedAck.success, false); + assert.equal(duplicatedAck.errMsg, "Panel 通知不存在"); async function uploadResult(folderName, fileName, content) { const result = await post({ type: "uploadDataFile", fileName, folder: `${deviceId}/${folderName}` }); From 8c3df90a877e9af6798bf4697b4e9765c3f43b5e Mon Sep 17 00:00:00 2001 From: Epifnne Date: Mon, 3 Aug 2026 17:54:36 +0800 Subject: [PATCH 2/3] fix runid --- ControlPanel/electron-main.js | 4 ++- ControlPanel/electron-ui/index.html | 2 +- ControlPanel/electron-ui/renderer.js | 19 ++++++++++-- ControlPanel/electron-ui/styles.css | 8 +++++ server/src/app.js | 45 ++++++++++++++++++++++++---- server/test/server.test.js | 3 ++ 6 files changed, 71 insertions(+), 10 deletions(-) diff --git a/ControlPanel/electron-main.js b/ControlPanel/electron-main.js index f05884e..d3d9524 100644 --- a/ControlPanel/electron-main.js +++ b/ControlPanel/electron-main.js @@ -86,6 +86,7 @@ function startPanelInboxPoller(window) { filePath: imagePath, dataUrl: toDataUrl(imagePath), fileName: pending.fileName, + runId: pending.runId || pending.fileName, mediaType: pending.mediaType, uploadTime: pending.uploadTime, deviceId: requestContext.deviceId, @@ -95,7 +96,7 @@ function startPanelInboxPoller(window) { pendingReview = { deviceId: requestContext.deviceId, fileID: pending.fileID, - runId: pending.fileName, + runId: pending.runId || pending.fileName, credentials: requestContext }; } else { @@ -378,6 +379,7 @@ function registerHandlers() { filePath: imagePath, dataUrl: toDataUrl(imagePath), fileName: download.fileName || request.fileName, + runId: download.runId || request.runId || request.fileName, mediaType, uploadTime: download.uploadTime || request.uploadTime, deviceId: request.deviceId diff --git a/ControlPanel/electron-ui/index.html b/ControlPanel/electron-ui/index.html index ce61a74..682ef1c 100644 --- a/ControlPanel/electron-ui/index.html +++ b/ControlPanel/electron-ui/index.html @@ -111,7 +111,7 @@
-
上传时间文件名类型大小状态操作
+
上传时间文件名runId类型大小状态操作

选择公司和产线后刷新辨识数据

diff --git a/ControlPanel/electron-ui/renderer.js b/ControlPanel/electron-ui/renderer.js index 4245554..5481690 100644 --- a/ControlPanel/electron-ui/renderer.js +++ b/ControlPanel/electron-ui/renderer.js @@ -305,6 +305,7 @@ async function refreshIdentificationFiles() { state.identificationFiles = result.files || result.fileList || []; elements.identificationFileList.innerHTML = state.identificationFiles.map((file) => ` ${escapeHtml(file.uploadTime || "-")}${escapeHtml(file.fileName)} + ${escapeHtml(file.runId || "-")} ${file.mediaType === "json" ? "行程 JSON" : "辨识 CSV"}${formatSize(file.size)} ${file.status === "processed" ? "已处理" : "待处理"} @@ -457,8 +458,9 @@ function showImage(result) { plot.showImage.disabled = false; plot.openImage.disabled = false; if (mediaType === "csv" && result.reviewable) { - state.review = { runId: result.fileName, deviceId: result.deviceId }; - elements.reviewTarget.textContent = result.fileName; + const runId = result.runId || result.fileName; + state.review = { runId, deviceId: result.deviceId }; + elements.reviewTarget.textContent = `${result.fileName} (runId: ${runId})`; elements.approveReview.disabled = false; elements.rejectReview.disabled = false; } @@ -954,6 +956,12 @@ elements.identificationFileList.addEventListener("click", async (event) => { if (result) { showImage(result); activateTab("plot-panel"); + if ((file.mediaType !== "json") && file.runId) { + state.review = { runId: file.runId, deviceId: elements.deviceId.value }; + elements.reviewTarget.textContent = `${result.fileName} (runId: ${file.runId})`; + elements.approveReview.disabled = false; + elements.rejectReview.disabled = false; + } } } else if (event.target.dataset.identificationDownload) { const result = await runBusy("正在保存辨识原始数据", () => window.reinloop.downloadIdentificationFile({ @@ -1146,7 +1154,12 @@ function renderNotifications() { ? "查看结果" : "查看绘图"; const secondaryAction = notification.type === "identification_result_ready" ? "查看辨识数据" : ""; - return `
${escapeHtml(notification.title)}${escapeHtml(notification.message)}
${secondaryAction ? `` : ""}
`; + const traces = [ + notification.runId ? `runId: ${notification.runId}` : "", + notification.fileID ? `fileID: ${notification.fileID}` : "", + notification.requestId ? `requestId: ${notification.requestId}` : "" + ].filter(Boolean).join(" | "); + return `
${escapeHtml(notification.title)}${escapeHtml(notification.message)}${traces ? `${escapeHtml(traces)}` : ""}
${secondaryAction ? `` : ""}
`; }).join(""); } diff --git a/ControlPanel/electron-ui/styles.css b/ControlPanel/electron-ui/styles.css index dfcc0d5..764d78f 100644 --- a/ControlPanel/electron-ui/styles.css +++ b/ControlPanel/electron-ui/styles.css @@ -197,6 +197,14 @@ input:focus, select:focus, textarea:focus { border-color: var(--green); box-shad .notification-copy { display: grid; gap: 3px; min-width: 0; } .notification-copy strong { font-size: 13px; } .notification-copy span { color: var(--muted); font-size: 12px; overflow-wrap: anywhere; } +.notification-copy .notification-trace { + display: block; + margin-top: 4px; + color: var(--text-muted); + font-size: 12px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; + word-break: break-all; +} .notification-actions { display: flex; gap: 8px; } .editor-layout { display: grid; grid-template-columns: minmax(0, 1fr) 270px; gap: 18px; } .editor-column, .publish-aside { background: var(--surface); border: 1px solid var(--line); } diff --git a/server/src/app.js b/server/src/app.js index 290bfee..c58b88b 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -496,6 +496,39 @@ function createApp({ 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); @@ -901,16 +934,16 @@ function createApp({ 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 - ); + 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) @@ -923,7 +956,7 @@ function createApp({ const database = await store.read(); const notification = database.panelNotifications.find((item) => item.deviceId === deviceId); return notification - ? { success: true, pending: true, notification } + ? { success: true, pending: true, notification: enrichNotification(database, notification) } : { success: true, pending: false }; } case "ackPanelNotification": { @@ -988,7 +1021,7 @@ function createApp({ const offset = (page - 1) * pageSize; return { success: true, - files: files.slice(offset, offset + pageSize), + files: files.slice(offset, offset + pageSize).map((record) => enrichIdentificationFile(database, record)), total: files.length, page, pageSize @@ -1065,10 +1098,12 @@ function createApp({ 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) }; diff --git a/server/test/server.test.js b/server/test/server.test.js index 6d1fb74..c7eaa8f 100644 --- a/server/test/server.test.js +++ b/server/test/server.test.js @@ -294,6 +294,7 @@ test("panel consumes uploaded device files from an inbox without scanning folder }); assert.equal(pending.pending, true); assert.equal(pending.fileName, "result_20260724_120000.csv"); + assert.equal(pending.runId, "result_20260724_120000.csv"); assert.equal(await (await fetch(pending.url)).text(), "t,u,p\n0,10,20\n"); assert.equal((await post({ @@ -328,6 +329,7 @@ test("panel consumes uploaded device files from an inbox without scanning folder }); assert.equal(history.total, 2); const csvHistory = history.files.find((file) => file.fileID === pending.fileID); + assert.equal(csvHistory.runId, "result_20260724_120000.csv"); assert.equal(csvHistory.status, "processed"); assert.ok(csvHistory.processedAt); assert.ok(csvHistory.expiresAt); @@ -575,6 +577,7 @@ test("panel notifications and volume configuration stay isolated by device", asy type: "getPendingPanelNotification", deviceId, adminToken: "test-token" }); assert.equal(identificationResult.notification.type, "identification_result_ready"); + assert.equal(identificationResult.notification.runId, "identification_result.json"); }); test("admin manages companies and production lines with a stable device id", async () => { From 1f9c096d05b0ac5db8c3c68d57f0bfadea24e29b Mon Sep 17 00:00:00 2001 From: Epifnne Date: Mon, 3 Aug 2026 18:20:23 +0800 Subject: [PATCH 3/3] fix notice display --- server/src/app.js | 26 +++++++++++++++++++------- server/test/server.test.js | 17 +++++++++++++++-- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/server/src/app.js b/server/src/app.js index c58b88b..fd875d2 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -966,16 +966,28 @@ function createApp({ const notificationId = String(event.notificationId || ""); if (!notificationId) return { success: false, errMsg: "缺少 notificationId" }; return store.update((database) => { - const exists = database.panelNotifications.some((item) => + const exactMatch = database.panelNotifications.find((item) => item.deviceId === deviceId && item.notificationId === notificationId ); - if (!exists) { - return { success: false, errMsg: "Panel 通知不存在" }; + if (exactMatch) { + database.panelNotifications = database.panelNotifications.filter((item) => item !== exactMatch); + return { success: true, notificationId, deleted: 1 }; } - database.panelNotifications = database.panelNotifications.filter((item) => - !(item.deviceId === deviceId && item.notificationId === notificationId) - ); - 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": { diff --git a/server/test/server.test.js b/server/test/server.test.js index c7eaa8f..2ca013f 100644 --- a/server/test/server.test.js +++ b/server/test/server.test.js @@ -552,8 +552,12 @@ test("panel notifications and volume configuration stay isolated by device", asy type: "ackPanelNotification", deviceId, notificationId: notification.notification.notificationId, adminToken: "test-token" }); - assert.equal(duplicatedAck.success, false); - assert.equal(duplicatedAck.errMsg, "Panel 通知不存在"); + assert.equal(duplicatedAck.success, true); + assert.equal(duplicatedAck.idempotent, true); + assert.equal(duplicatedAck.deleted, 0); + assert.equal((await post({ + type: "getPendingPanelNotification", deviceId, adminToken: "test-token" + })).pending, false); async function uploadResult(folderName, fileName, content) { const result = await post({ type: "uploadDataFile", fileName, folder: `${deviceId}/${folderName}` }); @@ -567,6 +571,15 @@ test("panel notifications and volume configuration stay isolated by device", asy }); assert.equal(volumeResult.notification.type, "volume_result_ready"); assert.ok(volumeResult.notification.fileID); + const fallbackAck = await post({ + type: "ackPanelNotification", + deviceId: otherDeviceId, + notificationId: volumeResult.notification.notificationId, + adminToken: "test-token" + }); + assert.equal(fallbackAck.success, true); + assert.equal(fallbackAck.idempotent, true); + assert.equal(fallbackAck.deleted, 1); await post({ type: "ackPanelNotification", deviceId, notificationId: volumeResult.notification.notificationId, adminToken: "test-token"