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
+3
View File
@@ -57,6 +57,8 @@
| `listOrganizations` | Admin | 无 | 返回 `companies`,每家公司包含 `productionLines`。产线包含 `id``companyId``name``code``deviceId``lastSeenAt``online`。最近 30 秒有心跳时 `online``true`。 |
| `createCompany` | Admin | `name``code` | 创建公司。`code` 全局唯一,只允许 2-64 位小写字母、数字、`_``-`。 |
| `createProductionLine` | Admin | `companyId``name``code` | 创建产线。产线编码在公司内唯一;服务端固定生成 `<company.code>/<line.code>`。 |
| `deleteProductionLine` | Admin | `companyId``productionLineId` | 删除产线及关联业务数据。若该产线仍存在有效许可证会拒绝,需先撤销。 |
| `deleteCompany` | Admin | `companyId` | 删除公司。若仍有关联产线或许可证会拒绝。 |
Panel 应每 10 秒调用 `listOrganizations` 刷新在线状态,不应自行推测设备状态。
@@ -68,6 +70,7 @@ Panel 应每 10 秒调用 `listOrganizations` 刷新在线状态,不应自行
| `listLicenses` | Admin | 无 | 返回许可证摘要列表,不返回原始 `license`。 |
| `getLicense` | Admin | `licenseId` | 返回完整许可证详情,可包含原始 `license`。 |
| `revokeLicense` | Admin | `licenseId``reason` | 撤销许可证,保留历史、撤销时间和原因。`licenseId` 会去除首尾空白。失败时返回 `errCode``ADMIN_TOKEN_INVALID``ADMIN_TOKEN_NOT_CONFIGURED``LICENSE_ID_REQUIRED``LICENSE_NOT_FOUND`。兼容旧类型 `revoke_license``licenseRevoke``revoke`,以及旧字段 `license_id``admin_token`。每次撤销会记录不含令牌的结构化审计日志。 |
| `deleteLicense` | Admin | `licenseId` | 永久删除许可证记录。仅允许删除已撤销许可证,`active` 状态会返回 `LICENSE_ACTIVE`。 |
| `validateLicense` | 无 | `licenseId``deviceId` | 返回 `valid``status``licenseId`。状态为 `active``revoked``expired``not_found``device_mismatch`;不泄露客户信息和许可证原文。 |
许可证格式为 `payloadBase64|signatureBase64`。服务端只读取 `LICENSE_PUBLIC_KEY_PATH` 的公钥,绝不接收或保存 RSA 私钥。
+123
View File
@@ -487,6 +487,110 @@ function createApp({
return { success: true, productionLine };
});
}
case "deleteProductionLine": {
const authError = requireAdmin(event);
if (authError) return { success: false, errMsg: authError };
const companyId = String(event.companyId || "").trim();
const productionLineId = String(event.productionLineId || "").trim();
if (!companyId || !productionLineId) {
return { success: false, errMsg: "缺少 companyId 或 productionLineId" };
}
return store.update(async (database) => {
const company = database.companies.find((item) => item.id === companyId);
if (!company) return { success: false, errMsg: "公司不存在" };
const line = database.productionLines.find((item) => item.id === productionLineId && item.companyId === companyId);
if (!line) return { success: false, errMsg: "产线不存在" };
const activeLicenses = database.licenses.filter((item) =>
item.companyId === companyId && item.productionLineId === productionLineId && item.status === "active"
);
if (activeLicenses.length) {
return {
success: false,
errMsg: `请先撤销该产线的 ${activeLicenses.length} 个有效许可证后再删除产线`,
errCode: "ACTIVE_LICENSES_PRESENT"
};
}
const deviceId = line.deviceId;
const relatedFileIDs = database.fileRecords
.filter((record) => {
if (record.fileID.startsWith(`model://${deviceId}/`)) return true;
const prefix = `${deviceId}/`;
return record.folder === deviceId || record.folder.startsWith(prefix);
})
.map((record) => record.fileID);
let deletedFileCount = 0;
for (const fileID of new Set(relatedFileIDs)) {
deletedFileCount += await removeFile(database, fileID);
}
const beforeFeedback = database.identificationFeedback.length;
database.identificationFeedback = database.identificationFeedback.filter((item) => item.deviceId !== deviceId);
const deletedFeedback = beforeFeedback - database.identificationFeedback.length;
const beforeRequests = database.volumeConfigRequests.length;
database.volumeConfigRequests = database.volumeConfigRequests.filter((item) => item.deviceId !== deviceId);
const deletedRequests = beforeRequests - database.volumeConfigRequests.length;
const beforeConfigs = database.volumeConfigs.length;
database.volumeConfigs = database.volumeConfigs.filter((item) => item.deviceId !== deviceId);
const deletedConfigs = beforeConfigs - database.volumeConfigs.length;
const beforeNotifications = database.panelNotifications.length;
database.panelNotifications = database.panelNotifications.filter((item) => item.deviceId !== deviceId);
const deletedNotifications = beforeNotifications - database.panelNotifications.length;
const revokedLicenses = database.licenses.filter((item) =>
item.companyId === companyId && item.productionLineId === productionLineId
).length;
database.licenses = database.licenses.filter((item) =>
!(item.companyId === companyId && item.productionLineId === productionLineId)
);
database.productionLines = database.productionLines.filter((item) => item.id !== productionLineId);
return {
success: true,
deletedProductionLineId: productionLineId,
deletedFiles: deletedFileCount,
deletedLicenses: revokedLicenses,
deletedFeedback,
deletedVolumeRequests: deletedRequests,
deletedVolumeConfigs: deletedConfigs,
deletedNotifications
};
});
}
case "deleteCompany": {
const authError = requireAdmin(event);
if (authError) return { success: false, errMsg: authError };
const companyId = String(event.companyId || "").trim();
if (!companyId) return { success: false, errMsg: "缺少 companyId" };
return store.update((database) => {
const company = database.companies.find((item) => item.id === companyId);
if (!company) return { success: false, errMsg: "公司不存在" };
const lines = database.productionLines.filter((item) => item.companyId === companyId);
if (lines.length) {
return {
success: false,
errMsg: `请先删除该公司的 ${lines.length} 条产线后再删除公司`,
errCode: "PRODUCTION_LINES_PRESENT"
};
}
const licenses = database.licenses.filter((item) => item.companyId === companyId);
if (licenses.length) {
return {
success: false,
errMsg: `请先删除该公司的 ${licenses.length} 个许可证后再删除公司`,
errCode: "LICENSES_PRESENT"
};
}
database.companies = database.companies.filter((item) => item.id !== companyId);
return { success: true, deletedCompanyId: companyId };
});
}
case "createLicense": {
const authError = requireAdmin(event);
if (authError) return { success: false, errMsg: authError };
@@ -585,6 +689,25 @@ function createApp({
logRevokeAction({ event, licenseId, success: result.success, errCode: result.errCode });
return result;
}
case "deleteLicense": {
const authError = requireAdmin(event);
if (authError) return { success: false, errMsg: authError };
const licenseId = String(event.licenseId || "").trim();
if (!licenseId) return { success: false, errMsg: "licenseId 不能为空", errCode: "LICENSE_ID_REQUIRED" };
return store.update((database) => {
const record = database.licenses.find((item) => item.licenseId === licenseId);
if (!record) return { success: false, errMsg: "许可证不存在", errCode: "LICENSE_NOT_FOUND" };
if (record.status === "active") {
return {
success: false,
errMsg: "请先撤销许可证后再删除",
errCode: "LICENSE_ACTIVE"
};
}
database.licenses = database.licenses.filter((item) => item.licenseId !== licenseId);
return { success: true, deletedLicenseId: licenseId };
});
}
case "validateLicense": {
const database = await store.read();
const record = database.licenses.find((item) => item.licenseId === event.licenseId);
+151
View File
@@ -773,4 +773,155 @@ test("license creation verifies the signed payload and validates expiry in real
assert.deepEqual(await post({
type: "validateLicense", licenseId: expiredId, deviceId: line.productionLine.deviceId
}), { success: true, valid: false, status: "expired", licenseId: expiredId });
});
test("deleteLicense requires prior revocation", async () => {
const suffix = crypto.randomUUID().slice(0, 8);
const company = await post({
type: "createCompany", name: `删除许可证公司-${suffix}`, code: `del-lic-${suffix}`,
adminToken: "test-token"
});
const line = await post({
type: "createProductionLine", companyId: company.company.id,
name: "许可证线", code: "line-1", adminToken: "test-token"
});
const licenseId = crypto.randomUUID();
const payload = {
license_id: licenseId,
company_id: company.company.id,
production_line_id: line.productionLine.id,
customer: company.company.name,
device_id: line.productionLine.deviceId,
issued: "2026-08-01 10:00",
expiry: "2028-08-01 10:00",
features: "*"
};
const created = await post({
type: "createLicense", licenseId,
companyId: company.company.id,
productionLineId: line.productionLine.id,
customer: payload.customer,
issued: payload.issued,
expiry: payload.expiry,
features: payload.features,
license: signLicense(payload),
adminToken: "test-token"
});
assert.equal(created.success, true);
const activeDelete = await post({ type: "deleteLicense", licenseId, adminToken: "test-token" });
assert.equal(activeDelete.success, false);
assert.equal(activeDelete.errCode, "LICENSE_ACTIVE");
const revoked = await post({
type: "revokeLicense", licenseId, reason: "测试删除", adminToken: "test-token"
});
assert.equal(revoked.success, true);
const deleted = await post({ type: "deleteLicense", licenseId, adminToken: "test-token" });
assert.deepEqual(deleted, { success: true, deletedLicenseId: licenseId });
const missing = await post({ type: "getLicense", licenseId, adminToken: "test-token" });
assert.equal(missing.success, false);
});
test("deleteProductionLine blocks active licenses and removes revoked data", async () => {
const suffix = crypto.randomUUID().slice(0, 8);
const company = await post({
type: "createCompany", name: `删除产线公司-${suffix}`, code: `del-line-${suffix}`,
adminToken: "test-token"
});
const line = await post({
type: "createProductionLine", companyId: company.company.id,
name: "待删产线", code: "line-1", adminToken: "test-token"
});
const modelIssued = await post({
type: "issueModelUpload", deviceId: line.productionLine.deviceId,
fileName: "controller.bin", adminToken: "test-token"
});
const modelForm = new FormData();
modelForm.append("file", new Blob(["model-bytes"]), "controller.bin");
assert.equal((await fetch(modelIssued.uploadMetadata.url, { method: "POST", body: modelForm })).status, 204);
const licenseId = crypto.randomUUID();
const payload = {
license_id: licenseId,
company_id: company.company.id,
production_line_id: line.productionLine.id,
customer: company.company.name,
device_id: line.productionLine.deviceId,
issued: "2026-08-01 10:00",
expiry: "2028-08-01 10:00",
features: "*"
};
await post({
type: "createLicense", licenseId,
companyId: company.company.id,
productionLineId: line.productionLine.id,
customer: payload.customer,
issued: payload.issued,
expiry: payload.expiry,
features: payload.features,
license: signLicense(payload),
adminToken: "test-token"
});
const blocked = await post({
type: "deleteProductionLine",
companyId: company.company.id,
productionLineId: line.productionLine.id,
adminToken: "test-token"
});
assert.equal(blocked.success, false);
assert.equal(blocked.errCode, "ACTIVE_LICENSES_PRESENT");
await post({ type: "revokeLicense", licenseId, reason: "产线删除", adminToken: "test-token" });
const deleted = await post({
type: "deleteProductionLine",
companyId: company.company.id,
productionLineId: line.productionLine.id,
adminToken: "test-token"
});
assert.equal(deleted.success, true);
assert.ok(deleted.deletedFiles >= 1);
assert.ok(deleted.deletedLicenses >= 1);
const organizations = await post({ type: "listOrganizations", adminToken: "test-token" });
const listedCompany = organizations.companies.find((item) => item.id === company.company.id);
assert.ok(listedCompany);
assert.equal(listedCompany.productionLines.some((item) => item.id === line.productionLine.id), false);
const models = await post({ type: "listModels", folder: `${line.productionLine.deviceId}/model_config` });
assert.deepEqual(models.fileList, []);
});
test("deleteCompany requires no child lines and no remaining licenses", async () => {
const suffix = crypto.randomUUID().slice(0, 8);
const company = await post({
type: "createCompany", name: `删除公司-${suffix}`, code: `del-co-${suffix}`,
adminToken: "test-token"
});
const line = await post({
type: "createProductionLine", companyId: company.company.id,
name: "子产线", code: "line-1", adminToken: "test-token"
});
const blocked = await post({ type: "deleteCompany", companyId: company.company.id, adminToken: "test-token" });
assert.equal(blocked.success, false);
assert.equal(blocked.errCode, "PRODUCTION_LINES_PRESENT");
const lineDeleted = await post({
type: "deleteProductionLine",
companyId: company.company.id,
productionLineId: line.productionLine.id,
adminToken: "test-token"
});
assert.equal(lineDeleted.success, true);
const deleted = await post({ type: "deleteCompany", companyId: company.company.id, adminToken: "test-token" });
assert.deepEqual(deleted, { success: true, deletedCompanyId: company.company.id });
const organizations = await post({ type: "listOrganizations", adminToken: "test-token" });
assert.equal(organizations.companies.some((item) => item.id === company.company.id), false);
});