fix license check
This commit is contained in:
@@ -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(
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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: "确认撤销许可证",
|
||||||
|
|||||||
@@ -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
@@ -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: 许可证 payload(customer, expiry, issued 等)
|
dict: 许可证 payload(customer, 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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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 私钥。
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -774,3 +774,154 @@ test("license creation verifies the signed payload and validates expiry in real
|
|||||||
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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user