From d8b939a3ff6be62cfe10a7dd6f5b40e5e59e21cf Mon Sep 17 00:00:00 2001 From: Epifnne Date: Mon, 3 Aug 2026 14:05:27 +0800 Subject: [PATCH 1/4] fix reinloop api --- ReinLoop/core/model_manager.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ReinLoop/core/model_manager.py b/ReinLoop/core/model_manager.py index abcf23b..7dbfb7c 100644 --- a/ReinLoop/core/model_manager.py +++ b/ReinLoop/core/model_manager.py @@ -6,6 +6,7 @@ import threading import io +import requests import torch from stable_baselines3 import SAC From 2090597858c8b81be5bf5c3e7bcaa3f5e77393c3 Mon Sep 17 00:00:00 2001 From: Epifnne Date: Mon, 3 Aug 2026 14:26:24 +0800 Subject: [PATCH 2/4] fix license del --- ControlPanel/electron-ui/renderer.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/ControlPanel/electron-ui/renderer.js b/ControlPanel/electron-ui/renderer.js index 80ee4c7..51ae3e1 100644 --- a/ControlPanel/electron-ui/renderer.js +++ b/ControlPanel/electron-ui/renderer.js @@ -733,10 +733,15 @@ elements.licenseList.addEventListener("click", async (event) => { return; } if (revokeId) { - const reason = window.prompt("请输入撤销原因", "管理员撤销"); + const reason = await requestModelName({ + title: "确认撤销许可证", + message: "请输入撤销原因。确认后将立即撤销该许可证。", + value: "管理员撤销", + confirmLabel: "确认撤销", + danger: true + }); if (reason === null) return; const trimmedReason = reason.trim() || "管理员撤销"; - if (!window.confirm(`确认撤销此许可证?\n原因:${trimmedReason}`)) return; button.disabled = true; try { const result = await runBusy("正在撤销许可证", () => window.reinloop.revokeLicense({ From bae71f32532de3bd67a38ff7f6a8e78c4b7a1a81 Mon Sep 17 00:00:00 2001 From: Epifnne Date: Mon, 3 Aug 2026 15:18:28 +0800 Subject: [PATCH 3/4] fix license check --- ControlPanel/electron-main.js | 13 ++ ControlPanel/electron-preload.js | 3 + ControlPanel/electron-ui/index.html | 2 + ControlPanel/electron-ui/renderer.js | 80 ++++++++++++- ReinLoop/core/__init__.py | 2 +- ReinLoop/license_utils.py | 53 ++++++--- ReinLoop/tests/test_license_protocol.py | 39 ++++++ changelog.md | 20 ++++ server/features.md | 3 + server/src/app.js | 123 +++++++++++++++++++ server/test/server.test.js | 151 ++++++++++++++++++++++++ 11 files changed, 469 insertions(+), 20 deletions(-) diff --git a/ControlPanel/electron-main.js b/ControlPanel/electron-main.js index c2404c1..f05884e 100644 --- a/ControlPanel/electron-main.js +++ b/ControlPanel/electron-main.js @@ -246,6 +246,14 @@ function registerHandlers() { callServer({ type: "createCompany", name: request.name, code: request.code }, request.credentials)); ipcMain.handle("organization:create-line", (_event, request) => callServer({ type: "createProductionLine", companyId: request.companyId, name: request.name, code: request.code }, request.credentials)); + ipcMain.handle("organization:delete-company", (_event, request) => + callServer({ type: "deleteCompany", companyId: request.companyId }, request.credentials)); + ipcMain.handle("organization:delete-line", (_event, request) => + callServer({ + type: "deleteProductionLine", + companyId: request.companyId, + productionLineId: request.productionLineId + }, request.credentials)); ipcMain.handle("license:issue", async (_event, request) => { const keySelection = await dialog.showOpenDialog({ @@ -295,6 +303,11 @@ function registerHandlers() { { type: "revokeLicense", licenseId: request.licenseId, reason: request.reason }, resolveCredentials(request.credentials) )); + ipcMain.handle("license:delete", (_event, request) => + callServer( + { type: "deleteLicense", licenseId: request.licenseId }, + resolveCredentials(request.credentials) + )); ipcMain.handle("license:download", async (_event, request) => { const resolvedCredentials = resolveCredentials(request.credentials); const result = await callServer( diff --git a/ControlPanel/electron-preload.js b/ControlPanel/electron-preload.js index af6982b..3d21197 100644 --- a/ControlPanel/electron-preload.js +++ b/ControlPanel/electron-preload.js @@ -15,7 +15,10 @@ contextBridge.exposeInMainWorld("reinloop", { listLicenses: (credentials) => ipcRenderer.invoke("license:list", credentials), getLicense: (request) => ipcRenderer.invoke("license:get", request), revokeLicense: (request) => ipcRenderer.invoke("license:revoke", request), + deleteLicense: (request) => ipcRenderer.invoke("license:delete", request), downloadLicense: (request) => ipcRenderer.invoke("license:download", request), + deleteCompany: (request) => ipcRenderer.invoke("organization:delete-company", request), + deleteProductionLine: (request) => ipcRenderer.invoke("organization:delete-line", request), submitReview: (request) => ipcRenderer.invoke("review:submit", request), listModels: (request) => ipcRenderer.invoke("model:list", request), chooseModelUploadFile: () => ipcRenderer.invoke("model:choose-upload-file"), diff --git a/ControlPanel/electron-ui/index.html b/ControlPanel/electron-ui/index.html index 856d879..ce61a74 100644 --- a/ControlPanel/electron-ui/index.html +++ b/ControlPanel/electron-ui/index.html @@ -211,6 +211,7 @@ +

添加产线

@@ -218,6 +219,7 @@ +
diff --git a/ControlPanel/electron-ui/renderer.js b/ControlPanel/electron-ui/renderer.js index 51ae3e1..e875543 100644 --- a/ControlPanel/electron-ui/renderer.js +++ b/ControlPanel/electron-ui/renderer.js @@ -275,7 +275,7 @@ async function refreshLicenses() { elements.licenseList.innerHTML = result.licenses.map((license) => ` ${escapeHtml(license.companyName)} / ${escapeHtml(license.productionLineName)} ${escapeHtml(license.expiry)}${license.status === "active" ? "有效" : "已撤销"} - ${license.status === "active" ? `` : ""} + ${license.status === "active" ? `` : ``} `).join(""); elements.licenseEmpty.hidden = result.licenses.length > 0; setStatus("许可证已刷新", "success"); @@ -688,6 +688,56 @@ document.querySelector("#line-form").addEventListener("submit", async (event) => await refreshOrganizations(); }); +document.querySelector("#delete-line").addEventListener("click", async () => { + const company = selectedCompany(); + const line = selectedLine(); + if (!company || !line) return showError(new Error("请先选择公司和产线")); + const confirmation = await requestModelName({ + title: "确认删除产线", + message: `删除产线 ${line.name} 后将清理关联业务数据。请输入完整 deviceId 以确认。`, + value: "", + confirmLabel: "删除产线", + danger: true, + expectedValue: line.deviceId + }); + if (confirmation === null) return; + const result = await runBusy("正在删除产线", () => window.reinloop.deleteProductionLine({ + companyId: company.id, + productionLineId: line.id, + credentials: credentials() + })); + if (!result) return; + elements.licenseDetail.hidden = true; + elements.licenseDetail.textContent = ""; + await refreshOrganizations(); + await refreshLicenses(); + setStatus("产线已删除", "success"); +}); + +document.querySelector("#delete-company").addEventListener("click", async () => { + const company = selectedCompany(); + if (!company) return showError(new Error("请先选择公司")); + const confirmation = await requestModelName({ + title: "确认删除公司", + message: `删除公司 ${company.name} 前必须先删除其产线与许可证。请输入公司编码以确认。`, + value: "", + confirmLabel: "删除公司", + danger: true, + expectedValue: company.code + }); + if (confirmation === null) return; + const result = await runBusy("正在删除公司", () => window.reinloop.deleteCompany({ + companyId: company.id, + credentials: credentials() + })); + if (!result) return; + elements.licenseDetail.hidden = true; + elements.licenseDetail.textContent = ""; + await refreshOrganizations(); + await refreshLicenses(); + setStatus("公司已删除", "success"); +}); + document.querySelector("#license-form").addEventListener("submit", async (event) => { event.preventDefault(); const company = selectedCompany(); @@ -712,6 +762,7 @@ elements.licenseList.addEventListener("click", async (event) => { const detailId = button.dataset.licenseDetail; const downloadId = button.dataset.licenseDownload; const revokeId = button.dataset.licenseRevoke; + const deleteId = button.dataset.licenseDelete; if (detailId) { const result = await runBusy("正在读取许可证详情", () => window.reinloop.getLicense({ licenseId: detailId, credentials: credentials() })); if (result) { @@ -732,6 +783,33 @@ elements.licenseList.addEventListener("click", async (event) => { } return; } + if (deleteId) { + const confirmation = await requestModelName({ + title: "确认删除许可证", + message: "已撤销许可证才能删除。请输入许可证 ID 以确认永久删除。", + value: "", + confirmLabel: "永久删除", + danger: true, + expectedValue: deleteId + }); + if (confirmation === null) return; + button.disabled = true; + try { + const result = await runBusy("正在删除许可证", () => window.reinloop.deleteLicense({ + licenseId: deleteId, + credentials: credentials() + })); + if (result) { + await refreshLicenses(); + elements.licenseDetail.hidden = true; + elements.licenseDetail.textContent = ""; + setStatus("许可证已删除", "success"); + } + } finally { + button.disabled = false; + } + return; + } if (revokeId) { const reason = await requestModelName({ title: "确认撤销许可证", diff --git a/ReinLoop/core/__init__.py b/ReinLoop/core/__init__.py index a820005..2bcb583 100644 --- a/ReinLoop/core/__init__.py +++ b/ReinLoop/core/__init__.py @@ -30,7 +30,7 @@ def _init_license(): # 生产环境(exe 打包)→ 严格执行验签 from license_utils import check_license - check_license() # 验签并启动唯一的后台巡检线程,失败直接退出 + check_license() # 仅启动时验签,失败直接退出 _LICENSE_CHECKED = True diff --git a/ReinLoop/license_utils.py b/ReinLoop/license_utils.py index db92b2a..cdb58f1 100644 --- a/ReinLoop/license_utils.py +++ b/ReinLoop/license_utils.py @@ -41,8 +41,8 @@ Ue6JWRU4j3Wg37WDPbwkO3tQba2jbUQsLYomLGuohfkVAgMBAAE= -----END PUBLIC KEY-----""" # {{LICENSE_PUBLIC_KEY_END}} -# 许可证文件相对路径 -LICENSE_FILE = "license.lic" +# 许可证文件检索模式(默认在程序目录中匹配) +LICENSE_GLOB = "*license.lic" # 巡检间隔(分钟) DEFAULT_CHECK_INTERVAL = 5 @@ -164,11 +164,38 @@ def _validate_payload(payload): return has_new_format +def _default_license_dir(): + """返回默认许可证搜索目录。""" + # PyInstaller 打包后 sys.executable 是 exe 路径 + return Path(sys.executable).parent if getattr(sys, 'frozen', False) else Path.cwd() + + +def _resolve_license_path(lic_path=None): + """解析许可证路径。 + + - 显式传入 ``lic_path`` 时直接使用该路径。 + - 未传入时,在默认目录按 ``*license.lic`` 匹配,优先选择最近修改的文件。 + """ + if lic_path is not None: + return Path(lic_path) + + search_dir = _default_license_dir() + matches = [path for path in search_dir.glob(LICENSE_GLOB) if path.is_file()] + if not matches: + raise FileNotFoundError( + f"未找到许可证文件(模式: {LICENSE_GLOB},目录: {search_dir})" + ) + + # 多个候选时优先取最新文件;同修改时间再按文件名稳定排序。 + matches.sort(key=lambda path: (path.stat().st_mtime, path.name), reverse=True) + return matches[0] + + def verify_license(lic_path=None): """验证许可证签名 + 有效期。 Args: - lic_path: 许可证文件路径,默认 exe 同级目录下的 license.lic + lic_path: 许可证文件路径,默认在程序目录按 *license.lic 自动匹配 Returns: dict: 许可证 payload(customer, expiry, issued 等) @@ -179,10 +206,7 @@ def verify_license(lic_path=None): RuntimeError: 许可证已过期 ValueError: 许可证格式错误 """ - if lic_path is None: - # PyInstaller 打包后 sys.executable 是 exe 路径 - exe_dir = Path(sys.executable).parent if getattr(sys, 'frozen', False) else Path.cwd() - lic_path = exe_dir / LICENSE_FILE + lic_path = _resolve_license_path(lic_path) if not os.path.exists(lic_path): raise FileNotFoundError(f"许可证文件不存在: {lic_path}") @@ -531,7 +555,7 @@ def check_license(lic_path=None): """启动时调用:验证许可证,通过则返回 payload。 在 main.py 的 main() 函数开头调用一次即可。 - 内部会自动启动后台巡检线程。 + 仅启动时执行校验,不自动启动后台巡检线程。 Returns: dict: 许可证载荷 @@ -549,7 +573,6 @@ def check_license(lic_path=None): with _verified_license_lock: global _verified_license _verified_license = dict(payload) - start_license_watchdog() expiry = payload.get("expiry", "未知") customer = payload.get("customer", "未知") _log(f"✅ 许可证有效 | 客户: {customer} | 到期: {expiry}") @@ -559,7 +582,7 @@ def check_license(lic_path=None): _log(f"❌ {e}") _show_error_and_exit( "未找到许可证文件", - "请将 license.lic 放到软件根目录,然后重新启动程序。\n\n" + "请将许可证文件放到软件根目录(文件名需匹配 *license.lic),然后重新启动程序。\n\n" "如有疑问,请联系厂商获取有效的许可证文件。" ) @@ -568,7 +591,7 @@ def check_license(lic_path=None): _show_error_and_exit( "许可证验证失败", "许可证签名校验不通过,文件可能已被篡改。\n\n" - "请使用原始签发的 license.lic 文件,\n" + "请使用原始签发且文件名匹配 *license.lic 的许可证文件,\n" "或联系厂商重新签发。" ) @@ -626,13 +649,7 @@ def get_license_info(lic_path=None): dict | None: 许可证信息,文件不存在则返回 None """ try: - if lic_path is None: - exe_dir = ( - Path(sys.executable).parent - if getattr(sys, 'frozen', False) - else Path.cwd() - ) - lic_path = exe_dir / LICENSE_FILE + lic_path = _resolve_license_path(lic_path) if not os.path.exists(lic_path): return None diff --git a/ReinLoop/tests/test_license_protocol.py b/ReinLoop/tests/test_license_protocol.py index e2b9784..978f615 100644 --- a/ReinLoop/tests/test_license_protocol.py +++ b/ReinLoop/tests/test_license_protocol.py @@ -7,6 +7,7 @@ import os from pathlib import Path import sys import tempfile +import time import types import unittest from unittest.mock import patch @@ -107,6 +108,44 @@ class LicenseProtocolTests(unittest.TestCase): side_effect=LICENSE.requests.ConnectionError("offline")): LICENSE.validate_license_online(NEW_LICENSE) + def test_verify_license_uses_default_glob_pattern_when_path_missing(self): + with tempfile.TemporaryDirectory() as tmp_dir: + payload_b64 = base64.b64encode(json.dumps(NEW_LICENSE).encode()).decode() + license_path = Path(tmp_dir) / "customer-license.lic" + license_path.write_text( + f"{payload_b64}|{base64.b64encode(b'signature').decode()}", + encoding="utf-8", + ) + with patch.object(LICENSE, "_load_public_key", return_value=FakePublicKey()), \ + patch.object(LICENSE, "_default_license_dir", return_value=Path(tmp_dir)): + payload = LICENSE.verify_license() + + self.assertEqual(payload["license_id"], NEW_LICENSE["license_id"]) + + def test_verify_license_prefers_latest_matching_file(self): + with tempfile.TemporaryDirectory() as tmp_dir: + old_payload = dict(NEW_LICENSE, license_id="old-license") + new_payload = dict(NEW_LICENSE, license_id="new-license") + + def write_license(path, payload): + payload_b64 = base64.b64encode(json.dumps(payload).encode()).decode() + path.write_text( + f"{payload_b64}|{base64.b64encode(b'signature').decode()}", + encoding="utf-8", + ) + + old_path = Path(tmp_dir) / "a-license.lic" + new_path = Path(tmp_dir) / "z-license.lic" + write_license(old_path, old_payload) + time.sleep(0.01) + write_license(new_path, new_payload) + + with patch.object(LICENSE, "_load_public_key", return_value=FakePublicKey()), \ + patch.object(LICENSE, "_default_license_dir", return_value=Path(tmp_dir)): + payload = LICENSE.verify_license() + + self.assertEqual(payload["license_id"], "new-license") + def test_api_rejects_environment_device_id_mismatch(self): fake_license_utils = types.ModuleType("license_utils") fake_license_utils.get_verified_license = lambda: dict(NEW_LICENSE) diff --git a/changelog.md b/changelog.md index cc4296a..5b56bb3 100644 --- a/changelog.md +++ b/changelog.md @@ -171,6 +171,26 @@ - ReinLoop 客户端核心模块已切换为 `/device` 访问链路:设备心跳、模型列表与下载、辨识配置下载、容积请求、辨识反馈、控制与辨识结果上传申请。 - ReinLoop 离线宽限默认调整为 `100` 小时(`REINLOOP_LICENSE_OFFLINE_HOURS` 默认值)。 +### ReinLoop + Panel:离线持续运行与组织清理增强 + +- ReinLoop 调整为“仅启动时执行许可证校验”,不再自动启动后台巡检线程,满足离线持续运行需求。 +- Server 新增许可证删除接口 `deleteLicense`(仅允许删除已撤销许可证)。 +- Server 新增组织删除接口 `deleteProductionLine`、`deleteCompany`,并增加前置约束与关联数据清理。 +- Panel 新增操作入口: + - 已撤销许可证支持“删除”; + - 组织管理页支持删除当前公司与当前产线。 + +涉及文件: + +- `ReinLoop/license_utils.py` +- `ReinLoop/core/__init__.py` +- `server/src/app.js` +- `server/features.md` +- `ControlPanel/electron-main.js` +- `ControlPanel/electron-preload.js` +- `ControlPanel/electron-ui/index.html` +- `ControlPanel/electron-ui/renderer.js` + 涉及文件: - `server/src/app.js` diff --git a/server/features.md b/server/features.md index 861acaa..4f2f675 100644 --- a/server/features.md +++ b/server/features.md @@ -57,6 +57,8 @@ | `listOrganizations` | Admin | 无 | 返回 `companies`,每家公司包含 `productionLines`。产线包含 `id`、`companyId`、`name`、`code`、`deviceId`、`lastSeenAt`、`online`。最近 30 秒有心跳时 `online` 为 `true`。 | | `createCompany` | Admin | `name`、`code` | 创建公司。`code` 全局唯一,只允许 2-64 位小写字母、数字、`_`、`-`。 | | `createProductionLine` | Admin | `companyId`、`name`、`code` | 创建产线。产线编码在公司内唯一;服务端固定生成 `/`。 | +| `deleteProductionLine` | Admin | `companyId`、`productionLineId` | 删除产线及关联业务数据。若该产线仍存在有效许可证会拒绝,需先撤销。 | +| `deleteCompany` | Admin | `companyId` | 删除公司。若仍有关联产线或许可证会拒绝。 | Panel 应每 10 秒调用 `listOrganizations` 刷新在线状态,不应自行推测设备状态。 @@ -68,6 +70,7 @@ Panel 应每 10 秒调用 `listOrganizations` 刷新在线状态,不应自行 | `listLicenses` | Admin | 无 | 返回许可证摘要列表,不返回原始 `license`。 | | `getLicense` | Admin | `licenseId` | 返回完整许可证详情,可包含原始 `license`。 | | `revokeLicense` | Admin | `licenseId`、`reason` | 撤销许可证,保留历史、撤销时间和原因。`licenseId` 会去除首尾空白。失败时返回 `errCode`:`ADMIN_TOKEN_INVALID`、`ADMIN_TOKEN_NOT_CONFIGURED`、`LICENSE_ID_REQUIRED` 或 `LICENSE_NOT_FOUND`。兼容旧类型 `revoke_license`、`licenseRevoke`、`revoke`,以及旧字段 `license_id`、`admin_token`。每次撤销会记录不含令牌的结构化审计日志。 | +| `deleteLicense` | Admin | `licenseId` | 永久删除许可证记录。仅允许删除已撤销许可证,`active` 状态会返回 `LICENSE_ACTIVE`。 | | `validateLicense` | 无 | `licenseId`、`deviceId` | 返回 `valid`、`status`、`licenseId`。状态为 `active`、`revoked`、`expired`、`not_found` 或 `device_mismatch`;不泄露客户信息和许可证原文。 | 许可证格式为 `payloadBase64|signatureBase64`。服务端只读取 `LICENSE_PUBLIC_KEY_PATH` 的公钥,绝不接收或保存 RSA 私钥。 diff --git a/server/src/app.js b/server/src/app.js index 4d48a93..8289722 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -487,6 +487,110 @@ function createApp({ 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: "公司不存在" }; + 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 (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 revokedLicenses = database.licenses.filter((item) => + item.companyId === companyId && item.productionLineId === productionLineId + ).length; + database.licenses = database.licenses.filter((item) => + !(item.companyId === companyId && item.productionLineId === productionLineId) + ); + + database.productionLines = database.productionLines.filter((item) => item.id !== productionLineId); + + return { + success: true, + deletedProductionLineId: productionLineId, + deletedFiles: deletedFileCount, + deletedLicenses: revokedLicenses, + deletedFeedback, + deletedVolumeRequests: deletedRequests, + deletedVolumeConfigs: deletedConfigs, + deletedNotifications + }; + }); + } + 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((database) => { + const company = database.companies.find((item) => item.id === companyId); + if (!company) return { success: false, errMsg: "公司不存在" }; + const lines = database.productionLines.filter((item) => item.companyId === companyId); + if (lines.length) { + return { + success: false, + errMsg: `请先删除该公司的 ${lines.length} 条产线后再删除公司`, + errCode: "PRODUCTION_LINES_PRESENT" + }; + } + const licenses = database.licenses.filter((item) => item.companyId === companyId); + if (licenses.length) { + return { + success: false, + errMsg: `请先删除该公司的 ${licenses.length} 个许可证后再删除公司`, + errCode: "LICENSES_PRESENT" + }; + } + database.companies = database.companies.filter((item) => item.id !== companyId); + return { success: true, deletedCompanyId: companyId }; + }); + } case "createLicense": { const authError = requireAdmin(event); if (authError) return { success: false, errMsg: authError }; @@ -585,6 +689,25 @@ function createApp({ 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); diff --git a/server/test/server.test.js b/server/test/server.test.js index 16a33fc..0a1dfdd 100644 --- a/server/test/server.test.js +++ b/server/test/server.test.js @@ -773,4 +773,155 @@ test("license creation verifies the signed payload and validates expiry in real assert.deepEqual(await post({ type: "validateLicense", licenseId: expiredId, deviceId: line.productionLine.deviceId }), { success: true, valid: false, status: "expired", licenseId: expiredId }); +}); + +test("deleteLicense requires prior revocation", async () => { + const suffix = crypto.randomUUID().slice(0, 8); + const company = await post({ + type: "createCompany", name: `删除许可证公司-${suffix}`, code: `del-lic-${suffix}`, + adminToken: "test-token" + }); + const line = await post({ + type: "createProductionLine", companyId: company.company.id, + name: "许可证线", code: "line-1", adminToken: "test-token" + }); + const licenseId = crypto.randomUUID(); + const payload = { + license_id: licenseId, + company_id: company.company.id, + production_line_id: line.productionLine.id, + customer: company.company.name, + device_id: line.productionLine.deviceId, + issued: "2026-08-01 10:00", + expiry: "2028-08-01 10:00", + features: "*" + }; + const created = await post({ + type: "createLicense", licenseId, + companyId: company.company.id, + productionLineId: line.productionLine.id, + customer: payload.customer, + issued: payload.issued, + expiry: payload.expiry, + features: payload.features, + license: signLicense(payload), + adminToken: "test-token" + }); + assert.equal(created.success, true); + + const activeDelete = await post({ type: "deleteLicense", licenseId, adminToken: "test-token" }); + assert.equal(activeDelete.success, false); + assert.equal(activeDelete.errCode, "LICENSE_ACTIVE"); + + const revoked = await post({ + type: "revokeLicense", licenseId, reason: "测试删除", adminToken: "test-token" + }); + assert.equal(revoked.success, true); + + const deleted = await post({ type: "deleteLicense", licenseId, adminToken: "test-token" }); + assert.deepEqual(deleted, { success: true, deletedLicenseId: licenseId }); + + const missing = await post({ type: "getLicense", licenseId, adminToken: "test-token" }); + assert.equal(missing.success, false); +}); + +test("deleteProductionLine blocks active licenses and removes revoked data", async () => { + const suffix = crypto.randomUUID().slice(0, 8); + const company = await post({ + type: "createCompany", name: `删除产线公司-${suffix}`, code: `del-line-${suffix}`, + adminToken: "test-token" + }); + const line = await post({ + type: "createProductionLine", companyId: company.company.id, + name: "待删产线", code: "line-1", adminToken: "test-token" + }); + + const modelIssued = await post({ + type: "issueModelUpload", deviceId: line.productionLine.deviceId, + fileName: "controller.bin", adminToken: "test-token" + }); + const modelForm = new FormData(); + modelForm.append("file", new Blob(["model-bytes"]), "controller.bin"); + assert.equal((await fetch(modelIssued.uploadMetadata.url, { method: "POST", body: modelForm })).status, 204); + + const licenseId = crypto.randomUUID(); + const payload = { + license_id: licenseId, + company_id: company.company.id, + production_line_id: line.productionLine.id, + customer: company.company.name, + device_id: line.productionLine.deviceId, + issued: "2026-08-01 10:00", + expiry: "2028-08-01 10:00", + features: "*" + }; + await post({ + type: "createLicense", licenseId, + companyId: company.company.id, + productionLineId: line.productionLine.id, + customer: payload.customer, + issued: payload.issued, + expiry: payload.expiry, + features: payload.features, + license: signLicense(payload), + adminToken: "test-token" + }); + + const blocked = await post({ + type: "deleteProductionLine", + companyId: company.company.id, + productionLineId: line.productionLine.id, + adminToken: "test-token" + }); + assert.equal(blocked.success, false); + assert.equal(blocked.errCode, "ACTIVE_LICENSES_PRESENT"); + + await post({ type: "revokeLicense", licenseId, reason: "产线删除", adminToken: "test-token" }); + const deleted = await post({ + type: "deleteProductionLine", + companyId: company.company.id, + productionLineId: line.productionLine.id, + adminToken: "test-token" + }); + assert.equal(deleted.success, true); + assert.ok(deleted.deletedFiles >= 1); + assert.ok(deleted.deletedLicenses >= 1); + + const organizations = await post({ type: "listOrganizations", adminToken: "test-token" }); + const listedCompany = organizations.companies.find((item) => item.id === company.company.id); + assert.ok(listedCompany); + assert.equal(listedCompany.productionLines.some((item) => item.id === line.productionLine.id), false); + + const models = await post({ type: "listModels", folder: `${line.productionLine.deviceId}/model_config` }); + assert.deepEqual(models.fileList, []); +}); + +test("deleteCompany requires no child lines and no remaining licenses", async () => { + const suffix = crypto.randomUUID().slice(0, 8); + const company = await post({ + type: "createCompany", name: `删除公司-${suffix}`, code: `del-co-${suffix}`, + adminToken: "test-token" + }); + const line = await post({ + type: "createProductionLine", companyId: company.company.id, + name: "子产线", code: "line-1", adminToken: "test-token" + }); + + const blocked = await post({ type: "deleteCompany", companyId: company.company.id, adminToken: "test-token" }); + assert.equal(blocked.success, false); + assert.equal(blocked.errCode, "PRODUCTION_LINES_PRESENT"); + + const lineDeleted = await post({ + type: "deleteProductionLine", + companyId: company.company.id, + productionLineId: line.productionLine.id, + adminToken: "test-token" + }); + assert.equal(lineDeleted.success, true); + + const deleted = await post({ type: "deleteCompany", companyId: company.company.id, adminToken: "test-token" }); + assert.deepEqual(deleted, { success: true, deletedCompanyId: company.company.id }); + + const organizations = await post({ type: "listOrganizations", adminToken: "test-token" }); + assert.equal(organizations.companies.some((item) => item.id === company.company.id), false); }); \ No newline at end of file From 5edb0b2d69ddbcc035beb2ce7d35321dd7eaabc7 Mon Sep 17 00:00:00 2001 From: Epifnne Date: Mon, 3 Aug 2026 15:28:37 +0800 Subject: [PATCH 4/4] fix del api --- ControlPanel/electron-ui/renderer.js | 26 +++++++++++++++++++------- changelog.md | 21 +++++++++++++++++++++ 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/ControlPanel/electron-ui/renderer.js b/ControlPanel/electron-ui/renderer.js index e875543..f9fbb64 100644 --- a/ControlPanel/electron-ui/renderer.js +++ b/ControlPanel/electron-ui/renderer.js @@ -367,7 +367,15 @@ function closeActionDialog(value) { resolve(value); } -function requestModelName({ title, message, value = "", confirmLabel = "确认", danger = false, expectedValue = null }) { +function requestModelName({ + title, + message, + value = "", + confirmLabel = "确认", + danger = false, + expectedValue = null, + expectedLabel = "输入内容" +}) { if (actionDialogState) closeActionDialog(null); elements.actionDialogTitle.textContent = title; elements.actionDialogMessage.textContent = message; @@ -383,7 +391,7 @@ function requestModelName({ title, message, value = "", confirmLabel = "确认", elements.actionDialogInput.select(); }); return new Promise((resolve) => { - actionDialogState = { resolve, previousFocus, expectedValue }; + actionDialogState = { resolve, previousFocus, expectedValue, expectedLabel }; }); } @@ -391,14 +399,15 @@ elements.actionDialogForm.addEventListener("submit", (event) => { event.preventDefault(); const value = elements.actionDialogInput.value.trim(); const expectedValue = actionDialogState?.expectedValue; + const expectedLabel = actionDialogState?.expectedLabel || "输入内容"; if (!value) { - elements.actionDialogError.textContent = "文件名不能为空"; + elements.actionDialogError.textContent = `${expectedLabel}不能为空`; elements.actionDialogError.hidden = false; elements.actionDialogInput.focus(); return; } if (expectedValue !== null && value !== expectedValue) { - elements.actionDialogError.textContent = "文件名不匹配,请输入完整文件名"; + elements.actionDialogError.textContent = `${expectedLabel}不匹配,请按提示完整输入`; elements.actionDialogError.hidden = false; elements.actionDialogInput.focus(); return; @@ -698,7 +707,8 @@ document.querySelector("#delete-line").addEventListener("click", async () => { value: "", confirmLabel: "删除产线", danger: true, - expectedValue: line.deviceId + expectedValue: line.deviceId, + expectedLabel: "deviceId" }); if (confirmation === null) return; const result = await runBusy("正在删除产线", () => window.reinloop.deleteProductionLine({ @@ -723,7 +733,8 @@ document.querySelector("#delete-company").addEventListener("click", async () => value: "", confirmLabel: "删除公司", danger: true, - expectedValue: company.code + expectedValue: company.code, + expectedLabel: "公司编码" }); if (confirmation === null) return; const result = await runBusy("正在删除公司", () => window.reinloop.deleteCompany({ @@ -790,7 +801,8 @@ elements.licenseList.addEventListener("click", async (event) => { value: "", confirmLabel: "永久删除", danger: true, - expectedValue: deleteId + expectedValue: deleteId, + expectedLabel: "许可证ID" }); if (confirmation === null) return; button.disabled = true; diff --git a/changelog.md b/changelog.md index 5b56bb3..e785def 100644 --- a/changelog.md +++ b/changelog.md @@ -204,3 +204,24 @@ - `ReinLoop/core/device_heartbeat.py` - `ReinLoop/core/data_collector.py` - `ReinLoop/core/identification.py` + +### ReinLoop:许可证文件改为通配检索 + +- 许可证启动校验与信息读取不再固定使用 `license.lic`,改为在程序目录检索匹配 `*license.lic` 的文件。 +- 当存在多个匹配文件时,按“最近修改时间优先”选择目标文件,降低人工改名或多版本并存时的启动失败概率。 +- 同步更新提示文案:明确要求许可证文件名需匹配 `*license.lic`。 +- 新增协议测试覆盖:默认通配匹配与多文件择优选择。 + +涉及文件: + +- `ReinLoop/license_utils.py` +- `ReinLoop/tests/test_license_protocol.py` + +### ControlPanel:删除确认提示文案修正 + +- 删除产线、删除公司、删除许可证的二次确认弹窗改为按业务字段显示错误提示,不再统一显示“文件名不匹配”。 +- 现分别提示 `deviceId`、公司编码、许可证 ID 的必填和匹配错误,减少误解与误操作。 + +涉及文件: + +- `ControlPanel/electron-ui/renderer.js`