fix license check

This commit is contained in:
2026-08-03 15:18:28 +08:00
parent 2090597858
commit bae71f3253
11 changed files with 469 additions and 20 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>
+79 -1
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");
@@ -688,6 +688,56 @@ 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
});
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) => { document.querySelector("#license-form").addEventListener("submit", async (event) => {
event.preventDefault(); event.preventDefault();
const company = selectedCompany(); const company = selectedCompany();
@@ -712,6 +762,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,6 +783,33 @@ elements.licenseList.addEventListener("click", async (event) => {
} }
return; 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) { if (revokeId) {
const reason = await requestModelName({ const reason = await requestModelName({
title: "确认撤销许可证", title: "确认撤销许可证",
+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
+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)
+20
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`
+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);
}); });