This commit is contained in:
rangang
2026-08-03 15:50:43 +08:00
12 changed files with 514 additions and 26 deletions
+13
View File
@@ -246,6 +246,14 @@ function registerHandlers() {
callServer({ type: "createCompany", name: request.name, code: request.code }, request.credentials)); callServer({ type: "createCompany", name: request.name, code: request.code }, request.credentials));
ipcMain.handle("organization:create-line", (_event, request) => ipcMain.handle("organization:create-line", (_event, request) =>
callServer({ type: "createProductionLine", companyId: request.companyId, name: request.name, code: request.code }, request.credentials)); 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) => { ipcMain.handle("license:issue", async (_event, request) => {
const keySelection = await dialog.showOpenDialog({ const keySelection = await dialog.showOpenDialog({
@@ -295,6 +303,11 @@ function registerHandlers() {
{ type: "revokeLicense", licenseId: request.licenseId, reason: request.reason }, { type: "revokeLicense", licenseId: request.licenseId, reason: request.reason },
resolveCredentials(request.credentials) 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) => { ipcMain.handle("license:download", async (_event, request) => {
const resolvedCredentials = resolveCredentials(request.credentials); const resolvedCredentials = resolveCredentials(request.credentials);
const result = await callServer( const result = await callServer(
+3
View File
@@ -15,7 +15,10 @@ contextBridge.exposeInMainWorld("reinloop", {
listLicenses: (credentials) => ipcRenderer.invoke("license:list", credentials), listLicenses: (credentials) => ipcRenderer.invoke("license:list", credentials),
getLicense: (request) => ipcRenderer.invoke("license:get", request), getLicense: (request) => ipcRenderer.invoke("license:get", request),
revokeLicense: (request) => ipcRenderer.invoke("license:revoke", request), revokeLicense: (request) => ipcRenderer.invoke("license:revoke", request),
deleteLicense: (request) => ipcRenderer.invoke("license:delete", request),
downloadLicense: (request) => ipcRenderer.invoke("license:download", 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), submitReview: (request) => ipcRenderer.invoke("review:submit", request),
listModels: (request) => ipcRenderer.invoke("model:list", request), listModels: (request) => ipcRenderer.invoke("model:list", request),
chooseModelUploadFile: () => ipcRenderer.invoke("model:choose-upload-file"), chooseModelUploadFile: () => ipcRenderer.invoke("model:choose-upload-file"),
+2
View File
@@ -211,6 +211,7 @@
<label><span>公司名称</span><input id="company-name" required></label> <label><span>公司名称</span><input id="company-name" required></label>
<label><span>公司编码</span><input id="company-code" pattern="[a-z0-9][a-z0-9_-]{1,63}" required></label> <label><span>公司编码</span><input id="company-code" pattern="[a-z0-9][a-z0-9_-]{1,63}" required></label>
<button class="button primary" type="submit">添加公司</button> <button class="button primary" type="submit">添加公司</button>
<button id="delete-company" class="button danger" type="button">删除当前公司</button>
</form> </form>
<form id="line-form" class="form-surface"> <form id="line-form" class="form-surface">
<h3>添加产线</h3> <h3>添加产线</h3>
@@ -218,6 +219,7 @@
<label><span>产线名称</span><input id="line-name" required></label> <label><span>产线名称</span><input id="line-name" required></label>
<label><span>产线编码</span><input id="line-code" pattern="[a-z0-9][a-z0-9_-]{1,63}" required></label> <label><span>产线编码</span><input id="line-code" pattern="[a-z0-9][a-z0-9_-]{1,63}" required></label>
<button class="button primary" type="submit">添加产线</button> <button class="button primary" type="submit">添加产线</button>
<button id="delete-line" class="button danger" type="button">删除当前产线</button>
</form> </form>
</div> </div>
</section> </section>
+102 -7
View File
@@ -275,7 +275,7 @@ async function refreshLicenses() {
elements.licenseList.innerHTML = result.licenses.map((license) => ` elements.licenseList.innerHTML = result.licenses.map((license) => `
<tr><td>${escapeHtml(license.companyName)} / ${escapeHtml(license.productionLineName)}</td> <tr><td>${escapeHtml(license.companyName)} / ${escapeHtml(license.productionLineName)}</td>
<td>${escapeHtml(license.expiry)}</td><td>${license.status === "active" ? "有效" : "已撤销"}</td> <td>${escapeHtml(license.expiry)}</td><td>${license.status === "active" ? "有效" : "已撤销"}</td>
<td><button class="table-action" data-license-detail="${escapeHtml(license.licenseId)}">详情</button><button class="table-action" data-license-download="${escapeHtml(license.licenseId)}">下载</button>${license.status === "active" ? `<button class="table-action danger" data-license-revoke="${escapeHtml(license.licenseId)}">撤销</button>` : ""}</td></tr> <td><button class="table-action" data-license-detail="${escapeHtml(license.licenseId)}">详情</button><button class="table-action" data-license-download="${escapeHtml(license.licenseId)}">下载</button>${license.status === "active" ? `<button class="table-action danger" data-license-revoke="${escapeHtml(license.licenseId)}">撤销</button>` : `<button class="table-action danger" data-license-delete="${escapeHtml(license.licenseId)}">删除</button>`}</td></tr>
`).join(""); `).join("");
elements.licenseEmpty.hidden = result.licenses.length > 0; elements.licenseEmpty.hidden = result.licenses.length > 0;
setStatus("许可证已刷新", "success"); setStatus("许可证已刷新", "success");
@@ -367,7 +367,15 @@ function closeActionDialog(value) {
resolve(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); if (actionDialogState) closeActionDialog(null);
elements.actionDialogTitle.textContent = title; elements.actionDialogTitle.textContent = title;
elements.actionDialogMessage.textContent = message; elements.actionDialogMessage.textContent = message;
@@ -383,7 +391,7 @@ function requestModelName({ title, message, value = "", confirmLabel = "确认",
elements.actionDialogInput.select(); elements.actionDialogInput.select();
}); });
return new Promise((resolve) => { return new Promise((resolve) => {
actionDialogState = { resolve, previousFocus, expectedValue }; actionDialogState = { resolve, previousFocus, expectedValue, expectedLabel };
}); });
} }
@@ -391,14 +399,15 @@ elements.actionDialogForm.addEventListener("submit", (event) => {
event.preventDefault(); event.preventDefault();
const value = elements.actionDialogInput.value.trim(); const value = elements.actionDialogInput.value.trim();
const expectedValue = actionDialogState?.expectedValue; const expectedValue = actionDialogState?.expectedValue;
const expectedLabel = actionDialogState?.expectedLabel || "输入内容";
if (!value) { if (!value) {
elements.actionDialogError.textContent = "文件名不能为空"; elements.actionDialogError.textContent = `${expectedLabel}不能为空`;
elements.actionDialogError.hidden = false; elements.actionDialogError.hidden = false;
elements.actionDialogInput.focus(); elements.actionDialogInput.focus();
return; return;
} }
if (expectedValue !== null && value !== expectedValue) { if (expectedValue !== null && value !== expectedValue) {
elements.actionDialogError.textContent = "文件名不匹配,请输入完整文件名"; elements.actionDialogError.textContent = `${expectedLabel}不匹配,请按提示完整输入`;
elements.actionDialogError.hidden = false; elements.actionDialogError.hidden = false;
elements.actionDialogInput.focus(); elements.actionDialogInput.focus();
return; return;
@@ -688,6 +697,58 @@ document.querySelector("#line-form").addEventListener("submit", async (event) =>
await refreshOrganizations(); 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,
expectedLabel: "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,
expectedLabel: "公司编码"
});
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) => { document.querySelector("#license-form").addEventListener("submit", async (event) => {
event.preventDefault(); event.preventDefault();
const company = selectedCompany(); const company = selectedCompany();
@@ -712,6 +773,7 @@ elements.licenseList.addEventListener("click", async (event) => {
const detailId = button.dataset.licenseDetail; const detailId = button.dataset.licenseDetail;
const downloadId = button.dataset.licenseDownload; const downloadId = button.dataset.licenseDownload;
const revokeId = button.dataset.licenseRevoke; const revokeId = button.dataset.licenseRevoke;
const deleteId = button.dataset.licenseDelete;
if (detailId) { if (detailId) {
const result = await runBusy("正在读取许可证详情", () => window.reinloop.getLicense({ licenseId: detailId, credentials: credentials() })); const result = await runBusy("正在读取许可证详情", () => window.reinloop.getLicense({ licenseId: detailId, credentials: credentials() }));
if (result) { if (result) {
@@ -732,11 +794,44 @@ elements.licenseList.addEventListener("click", async (event) => {
} }
return; return;
} }
if (deleteId) {
const confirmation = await requestModelName({
title: "确认删除许可证",
message: "已撤销许可证才能删除。请输入许可证 ID 以确认永久删除。",
value: "",
confirmLabel: "永久删除",
danger: true,
expectedValue: deleteId,
expectedLabel: "许可证ID"
});
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) { if (revokeId) {
const reason = window.prompt("请输入撤销原因", "管理员撤销"); const reason = await requestModelName({
title: "确认撤销许可证",
message: "请输入撤销原因。确认后将立即撤销该许可证。",
value: "管理员撤销",
confirmLabel: "确认撤销",
danger: true
});
if (reason === null) return; if (reason === null) return;
const trimmedReason = reason.trim() || "管理员撤销"; const trimmedReason = reason.trim() || "管理员撤销";
if (!window.confirm(`确认撤销此许可证?\n原因:${trimmedReason}`)) return;
button.disabled = true; button.disabled = true;
try { try {
const result = await runBusy("正在撤销许可证", () => window.reinloop.revokeLicense({ const result = await runBusy("正在撤销许可证", () => window.reinloop.revokeLicense({
+1 -1
View File
@@ -30,7 +30,7 @@ def _init_license():
# 生产环境(exe 打包)→ 严格执行验签 # 生产环境(exe 打包)→ 严格执行验签
from license_utils import check_license from license_utils import check_license
check_license() # 验签并启动唯一的后台巡检线程,失败直接退出 check_license() # 仅启动时验签,失败直接退出
_LICENSE_CHECKED = True _LICENSE_CHECKED = True
+1
View File
@@ -6,6 +6,7 @@
import threading import threading
import io import io
import requests
import torch import torch
from stable_baselines3 import SAC from stable_baselines3 import SAC
+35 -18
View File
@@ -41,8 +41,8 @@ Ue6JWRU4j3Wg37WDPbwkO3tQba2jbUQsLYomLGuohfkVAgMBAAE=
-----END PUBLIC KEY-----""" -----END PUBLIC KEY-----"""
# {{LICENSE_PUBLIC_KEY_END}} # {{LICENSE_PUBLIC_KEY_END}}
# 许可证文件相对路径 # 许可证文件检索模式(默认在程序目录中匹配)
LICENSE_FILE = "license.lic" LICENSE_GLOB = "*license.lic"
# 巡检间隔(分钟) # 巡检间隔(分钟)
DEFAULT_CHECK_INTERVAL = 5 DEFAULT_CHECK_INTERVAL = 5
@@ -164,11 +164,38 @@ def _validate_payload(payload):
return has_new_format 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): def verify_license(lic_path=None):
"""验证许可证签名 + 有效期。 """验证许可证签名 + 有效期。
Args: Args:
lic_path: 许可证文件路径,默认 exe 同级目录下的 license.lic lic_path: 许可证文件路径,默认在程序目录按 *license.lic 自动匹配
Returns: Returns:
dict: 许可证 payloadcustomer, expiry, issued 等) dict: 许可证 payloadcustomer, expiry, issued 等)
@@ -179,10 +206,7 @@ def verify_license(lic_path=None):
RuntimeError: 许可证已过期 RuntimeError: 许可证已过期
ValueError: 许可证格式错误 ValueError: 许可证格式错误
""" """
if lic_path is None: lic_path = _resolve_license_path(lic_path)
# PyInstaller 打包后 sys.executable 是 exe 路径
exe_dir = Path(sys.executable).parent if getattr(sys, 'frozen', False) else Path.cwd()
lic_path = exe_dir / LICENSE_FILE
if not os.path.exists(lic_path): if not os.path.exists(lic_path):
raise FileNotFoundError(f"许可证文件不存在: {lic_path}") raise FileNotFoundError(f"许可证文件不存在: {lic_path}")
@@ -531,7 +555,7 @@ def check_license(lic_path=None):
"""启动时调用:验证许可证,通过则返回 payload。 """启动时调用:验证许可证,通过则返回 payload。
在 main.py 的 main() 函数开头调用一次即可。 在 main.py 的 main() 函数开头调用一次即可。
内部会自动启动后台巡检线程。 仅启动时执行校验,不自动启动后台巡检线程。
Returns: Returns:
dict: 许可证载荷 dict: 许可证载荷
@@ -549,7 +573,6 @@ def check_license(lic_path=None):
with _verified_license_lock: with _verified_license_lock:
global _verified_license global _verified_license
_verified_license = dict(payload) _verified_license = dict(payload)
start_license_watchdog()
expiry = payload.get("expiry", "未知") expiry = payload.get("expiry", "未知")
customer = payload.get("customer", "未知") customer = payload.get("customer", "未知")
_log(f"✅ 许可证有效 | 客户: {customer} | 到期: {expiry}") _log(f"✅ 许可证有效 | 客户: {customer} | 到期: {expiry}")
@@ -559,7 +582,7 @@ def check_license(lic_path=None):
_log(f"{e}") _log(f"{e}")
_show_error_and_exit( _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( _show_error_and_exit(
"许可证验证失败", "许可证验证失败",
"许可证签名校验不通过,文件可能已被篡改。\n\n" "许可证签名校验不通过,文件可能已被篡改。\n\n"
"请使用原始签发license.lic 文件,\n" "请使用原始签发且文件名匹配 *license.lic 的许可证文件,\n"
"或联系厂商重新签发。" "或联系厂商重新签发。"
) )
@@ -626,13 +649,7 @@ def get_license_info(lic_path=None):
dict | None: 许可证信息,文件不存在则返回 None dict | None: 许可证信息,文件不存在则返回 None
""" """
try: try:
if lic_path is None: lic_path = _resolve_license_path(lic_path)
exe_dir = (
Path(sys.executable).parent
if getattr(sys, 'frozen', False)
else Path.cwd()
)
lic_path = exe_dir / LICENSE_FILE
if not os.path.exists(lic_path): if not os.path.exists(lic_path):
return None return None
+39
View File
@@ -7,6 +7,7 @@ import os
from pathlib import Path from pathlib import Path
import sys import sys
import tempfile import tempfile
import time
import types import types
import unittest import unittest
from unittest.mock import patch from unittest.mock import patch
@@ -107,6 +108,44 @@ class LicenseProtocolTests(unittest.TestCase):
side_effect=LICENSE.requests.ConnectionError("offline")): side_effect=LICENSE.requests.ConnectionError("offline")):
LICENSE.validate_license_online(NEW_LICENSE) 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): def test_api_rejects_environment_device_id_mismatch(self):
fake_license_utils = types.ModuleType("license_utils") fake_license_utils = types.ModuleType("license_utils")
fake_license_utils.get_verified_license = lambda: dict(NEW_LICENSE) fake_license_utils.get_verified_license = lambda: dict(NEW_LICENSE)
+41
View File
@@ -171,6 +171,26 @@
- ReinLoop 客户端核心模块已切换为 `/device` 访问链路:设备心跳、模型列表与下载、辨识配置下载、容积请求、辨识反馈、控制与辨识结果上传申请。 - ReinLoop 客户端核心模块已切换为 `/device` 访问链路:设备心跳、模型列表与下载、辨识配置下载、容积请求、辨识反馈、控制与辨识结果上传申请。
- ReinLoop 离线宽限默认调整为 `100` 小时(`REINLOOP_LICENSE_OFFLINE_HOURS` 默认值)。 - 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` - `server/src/app.js`
@@ -184,3 +204,24 @@
- `ReinLoop/core/device_heartbeat.py` - `ReinLoop/core/device_heartbeat.py`
- `ReinLoop/core/data_collector.py` - `ReinLoop/core/data_collector.py`
- `ReinLoop/core/identification.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`
+3
View File
@@ -57,6 +57,8 @@
| `listOrganizations` | Admin | 无 | 返回 `companies`,每家公司包含 `productionLines`。产线包含 `id``companyId``name``code``deviceId``lastSeenAt``online`。最近 30 秒有心跳时 `online``true`。 | | `listOrganizations` | Admin | 无 | 返回 `companies`,每家公司包含 `productionLines`。产线包含 `id``companyId``name``code``deviceId``lastSeenAt``online`。最近 30 秒有心跳时 `online``true`。 |
| `createCompany` | Admin | `name``code` | 创建公司。`code` 全局唯一,只允许 2-64 位小写字母、数字、`_``-`。 | | `createCompany` | Admin | `name``code` | 创建公司。`code` 全局唯一,只允许 2-64 位小写字母、数字、`_``-`。 |
| `createProductionLine` | Admin | `companyId``name``code` | 创建产线。产线编码在公司内唯一;服务端固定生成 `<company.code>/<line.code>`。 | | `createProductionLine` | Admin | `companyId``name``code` | 创建产线。产线编码在公司内唯一;服务端固定生成 `<company.code>/<line.code>`。 |
| `deleteProductionLine` | Admin | `companyId``productionLineId` | 删除产线及关联业务数据。若该产线仍存在有效许可证会拒绝,需先撤销。 |
| `deleteCompany` | Admin | `companyId` | 删除公司。若仍有关联产线或许可证会拒绝。 |
Panel 应每 10 秒调用 `listOrganizations` 刷新在线状态,不应自行推测设备状态。 Panel 应每 10 秒调用 `listOrganizations` 刷新在线状态,不应自行推测设备状态。
@@ -68,6 +70,7 @@ Panel 应每 10 秒调用 `listOrganizations` 刷新在线状态,不应自行
| `listLicenses` | Admin | 无 | 返回许可证摘要列表,不返回原始 `license`。 | | `listLicenses` | Admin | 无 | 返回许可证摘要列表,不返回原始 `license`。 |
| `getLicense` | Admin | `licenseId` | 返回完整许可证详情,可包含原始 `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`。每次撤销会记录不含令牌的结构化审计日志。 | | `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`;不泄露客户信息和许可证原文。 | | `validateLicense` | 无 | `licenseId``deviceId` | 返回 `valid``status``licenseId`。状态为 `active``revoked``expired``not_found``device_mismatch`;不泄露客户信息和许可证原文。 |
许可证格式为 `payloadBase64|signatureBase64`。服务端只读取 `LICENSE_PUBLIC_KEY_PATH` 的公钥,绝不接收或保存 RSA 私钥。 许可证格式为 `payloadBase64|signatureBase64`。服务端只读取 `LICENSE_PUBLIC_KEY_PATH` 的公钥,绝不接收或保存 RSA 私钥。
+123
View File
@@ -487,6 +487,110 @@ function createApp({
return { success: true, productionLine }; return { success: true, productionLine };
}); });
} }
case "deleteProductionLine": {
const authError = requireAdmin(event);
if (authError) return { success: false, errMsg: authError };
const companyId = String(event.companyId || "").trim();
const productionLineId = String(event.productionLineId || "").trim();
if (!companyId || !productionLineId) {
return { success: false, errMsg: "缺少 companyId 或 productionLineId" };
}
return store.update(async (database) => {
const company = database.companies.find((item) => item.id === companyId);
if (!company) return { success: false, errMsg: "公司不存在" };
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": { case "createLicense": {
const authError = requireAdmin(event); const authError = requireAdmin(event);
if (authError) return { success: false, errMsg: authError }; if (authError) return { success: false, errMsg: authError };
@@ -585,6 +689,25 @@ function createApp({
logRevokeAction({ event, licenseId, success: result.success, errCode: result.errCode }); logRevokeAction({ event, licenseId, success: result.success, errCode: result.errCode });
return result; 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": { case "validateLicense": {
const database = await store.read(); const database = await store.read();
const record = database.licenses.find((item) => item.licenseId === event.licenseId); const record = database.licenses.find((item) => item.licenseId === event.licenseId);
+151
View File
@@ -773,4 +773,155 @@ test("license creation verifies the signed payload and validates expiry in real
assert.deepEqual(await post({ assert.deepEqual(await post({
type: "validateLicense", licenseId: expiredId, deviceId: line.productionLine.deviceId type: "validateLicense", licenseId: expiredId, deviceId: line.productionLine.deviceId
}), { success: true, valid: false, status: "expired", licenseId: expiredId }); }), { 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);
}); });