update server

This commit is contained in:
2026-07-30 15:06:38 +08:00
parent dee13d69d5
commit 692d8dd18a
9 changed files with 189 additions and 479 deletions
-125
View File
@@ -1,125 +0,0 @@
# ReinLoop Express Server
该服务将原微信云函数中的文件中转、配置发布、辨识反馈和容积配置请求迁移到服务器。
请求体继续使用原来的 `type` 字段,因此 ReinLoop 和 ControlPanel 只需更换服务 URL。
## 本地运行
要求 Node.js 20 或更高版本。
```powershell
cd server
npm install
$env:B_ADMIN_TOKEN="your-admin-token"
npm start
```
默认监听:
- 业务接口:`http://127.0.0.1:3000`(同时兼容原有 `/api` 路径)
- 健康检查:`http://127.0.0.1:3000/health`
ControlPanel 本地联调:
```powershell
$env:REINLOOP_API_URL="http://127.0.0.1:3000"
$env:B_ADMIN_TOKEN="your-admin-token"
$env:REINLOOP_DEVICE_ID="local-test-device"
cd ControlPanel
npm run gui
```
ReinLoop 无 GUI 核心联调:
```powershell
$env:REINLOOP_SERVER_URL="http://127.0.0.1:3000"
$env:REINLOOP_API_URL="http://127.0.0.1:3000"
$env:REINLOOP_DEVICE_ID="local-test-device"
```
如果 ReinLoop 或 ControlPanel 运行在其他设备上,不可使用 `127.0.0.1`
应改为服务器的局域网 IP 或 HTTPS 域名。
业务请求可直接发送到域名根路径,也继续兼容 `/api`。反向代理需要将根路径完整转发到
Node 服务,例如 Nginx
```nginx
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
```
外部访问返回 `502 Bad Gateway` 表示请求尚未到达 Express,通常是 Node 服务未运行、
代理的端口不一致或代理无法连接上游。先在服务器执行
`curl http://127.0.0.1:3000/health`,确认返回 `success: true`,再检查代理配置和服务日志。
## 数据与上传
- 开发和单元测试时,元数据保存在 `data/database.json`。全新生产部署必须设置
`NODE_ENV=production``DATABASE_URL`;已有 `database.json` 的旧生产实例可继续启动,
但会输出迁移警告。PostgreSQL 启动时会执行可重复的规范化表迁移。
- 模型文件保存在 `data/models/<公司编码>/<产线编码>/`
- 模型上传可通过 `modelName` 重命名;数据库同时保存 `originalFileName`,供 Panel
显示和识别本地来源名称。未传 `modelName` 时保持原名。
- 其他上传文件保存在 `data/files/ReinLoop_GUI/`
- ReinLoop 上传到 `<设备 ID>/ind_data` 的 CSV/JSON 会进入 Panel 消息队列;
Panel 处理并确认后,server 将其标记为已处理并保留,默认 30 天后自动清理。
- B 端可通过 `listIdentificationFiles` 查看暂存历史,通过
`getIdentificationFileDownload` 获取短期签名 URL 下载原始文件,也可通过
`deleteIdentificationFile` 显式删除。
- 除模型外,`/files/:fileID` 必须携带服务端签发且绑定文件与过期时间的下载 token;
直接拼接文件地址会返回 `403`
- `uploadDataFile` 仍返回 `uploadMetadata`,现有 Python 与 ControlPanel 的 multipart
两步上传代码可以继续使用。
- 可通过 `DATA_DIR` 将数据目录放到独立磁盘。
- 单文件默认上限为 100 MB。
## API 参考
完整的业务功能、接口字段、权限边界、上传协议和流程说明见
[features.md](features.md)。
## PostgreSQL 与密钥
生产环境需要以下变量:
- `DATABASE_URL`PostgreSQL 连接串。
- `B_ADMIN_TOKEN`:高熵管理令牌。
- `LICENSE_PUBLIC_KEY_PATH`:只读 RSA 公钥 PEM 路径,用于验证 Panel 已签名许可证。
- `PUBLIC_BASE_URL`:外部 HTTPS 根地址。
- `DATA_DIR`:文件存储目录;文件二进制仍保存在该目录的 `files/` 下。
- `IDENTIFICATION_RETENTION_MS`:已处理辨识 CSV/JSON 的保留时长,默认 30 天。
- `IDENTIFICATION_PURGE_INTERVAL_MS`:过期清理周期,默认 1 小时。
- `DOWNLOAD_TOKEN_TTL_MS`:非模型文件短期下载 URL 有效期,默认 5 分钟。
- `HOST``PORT`:监听地址和端口。
迁移可单独执行,且可重复运行:
```sh
DATABASE_URL=postgres://... npm run migrate
```
数据库保存文件元数据,文件本体目前需要共享卷或单实例部署;多实例部署前应改为对象存储。
不得上传、保存或提交 RSA 私钥。通过 HTTPS 部署,定期备份 PostgreSQL 与 `DATA_DIR`
密钥轮换时先部署新公钥并验证新许可证,再废止旧签发私钥;恢复时先恢复数据库,再恢复同一
时间点的文件卷。
## 生产部署注意事项
1. 设置强随机 `B_ADMIN_TOKEN`,不要使用默认开发令牌。
2. 设置 `HOST=0.0.0.0` 并通过 Nginx/Caddy 提供 HTTPS,或由容器平台映射端口。
3. 设置 `PUBLIC_BASE_URL` 为外部 HTTPS 根地址,否则下载和上传 URL 会按请求 Host 生成。
4. 微信小程序后台需要把 HTTPS 域名加入 request、uploadFile 和 downloadFile 合法域名。
5. 定期备份 PostgreSQL 与整个 `DATA_DIR`;生产环境不可回退到 JSON 存储。
## 验证
```powershell
npm run check
npm test
```
测试会在随机本地端口验证健康检查、multipart 上传、文件列表、下载、辨识反馈和容积请求流程。
+1 -1
View File
@@ -39,7 +39,7 @@ Panel 应每 10 秒调用 `listOrganizations` 刷新在线状态,不应自行
| `createLicense` | Admin | `licenseId``companyId``productionLineId``customer``issued``expiry``features``license` | 创建已签名许可证。服务端以 RSA-PSS 公钥验签,校验签名载荷、组织关系和设备 ID。`issued`/`expiry` 使用 `YYYY-MM-DD HH:MM`,按 `Asia/Shanghai` 解析并存为 UTC。相同 ID 和内容幂等成功,不同内容冲突。 |
| `listLicenses` | Admin | 无 | 返回许可证摘要列表,不返回原始 `license`。 |
| `getLicense` | Admin | `licenseId` | 返回完整许可证详情,可包含原始 `license`。 |
| `revokeLicense` | Admin | `licenseId``reason` | 撤销许可证,保留历史、撤销时间和原因。 |
| `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`。每次撤销会记录不含令牌的结构化审计日志。 |
| `validateLicense` | 无 | `licenseId``deviceId` | 返回 `valid``status``licenseId`。状态为 `active``revoked``expired``not_found``device_mismatch`;不泄露客户信息和许可证原文。 |
许可证格式为 `payloadBase64|signatureBase64`。服务端只读取 `LICENSE_PUBLIC_KEY_PATH` 的公钥,绝不接收或保存 RSA 私钥。
+46 -5
View File
@@ -174,6 +174,32 @@ function createApp({
return event.adminToken === adminToken ? null : "B端管理令牌无效";
}
function logRevokeAction({ event, licenseId, success, errCode = null }) {
console.info("[license] revoke", {
time: new Date().toISOString(),
requestId: event.requestId || null,
type: "revokeLicense",
licenseId: licenseId || null,
result: success ? "success" : "failed",
errCode
});
}
function normalizeEventCompat(event) {
if (!event || typeof event !== "object" || Array.isArray(event)) return event;
const type = String(event.type || event.action || "").trim();
const legacyRevokeTypes = new Set(["revoke_license", "licenseRevoke", "revoke"]);
if (!legacyRevokeTypes.has(type) && !(event.action === "revokeLicense" && !event.type)) return event;
console.warn("[license] legacy revoke request mapped", { type: type || null, requestId: event.requestId || null });
return {
...event,
type: "revokeLicense",
licenseId: event.licenseId ?? event.license_id ?? event.id,
reason: event.reason ?? event.revocationReason,
adminToken: event.adminToken ?? event.admin_token ?? event.token
};
}
function signDownload(fileID, expiresAtMs) {
return createHmac("sha256", adminToken).update(`${fileID}\n${expiresAtMs}`).digest("hex");
}
@@ -469,15 +495,30 @@ function createApp({
}
case "revokeLicense": {
const authError = requireAdmin(event);
if (authError) return { success: false, errMsg: authError };
return store.update((database) => {
const record = database.licenses.find((item) => item.licenseId === event.licenseId);
if (!record) return { success: false, errMsg: "许可证不存在" };
const licenseId = String(event.licenseId || "").trim();
if (authError) {
const errCode = authError === "B端管理令牌无效"
? "ADMIN_TOKEN_INVALID"
: "ADMIN_TOKEN_NOT_CONFIGURED";
const result = { success: false, errMsg: authError, errCode };
logRevokeAction({ event, licenseId, success: false, errCode: result.errCode });
return result;
}
if (!licenseId) {
const result = { success: false, errMsg: "licenseId 不能为空", errCode: "LICENSE_ID_REQUIRED" };
logRevokeAction({ event, licenseId, success: false, errCode: result.errCode });
return result;
}
const result = await store.update((database) => {
const record = database.licenses.find((item) => item.licenseId === licenseId);
if (!record) return { success: false, errMsg: "许可证不存在", errCode: "LICENSE_NOT_FOUND" };
record.status = "revoked";
record.revokedAt = new Date().toISOString();
record.revocationReason = String(event.reason || "管理员撤销").trim();
return { success: true, license: publicLicense(record) };
});
logRevokeAction({ event, licenseId, success: result.success, errCode: result.errCode });
return result;
}
case "validateLicense": {
const database = await store.read();
@@ -978,7 +1019,7 @@ function createApp({
const apiHandler = async (req, res) => {
try {
res.json(await dispatch(req.body || {}, req));
res.json(await dispatch(normalizeEventCompat(req.body || {}), req));
} catch (error) {
res.status(400).json({ success: false, errMsg: error.message });
}
+28
View File
@@ -619,6 +619,34 @@ test("issued licenses can be listed, validated, and revoked", async () => {
assert.equal(rejected.status, "revoked");
});
test("revokeLicense returns actionable errors and accepts legacy request fields", async () => {
const invalidToken = await post({
type: "revokeLicense", licenseId: crypto.randomUUID(), adminToken: "wrong-token"
});
assert.deepEqual(invalidToken, {
success: false, errMsg: "B端管理令牌无效", errCode: "ADMIN_TOKEN_INVALID"
});
const missingLicenseId = await post({ type: "revokeLicense", licenseId: " ", adminToken: "test-token" });
assert.deepEqual(missingLicenseId, {
success: false, errMsg: "licenseId 不能为空", errCode: "LICENSE_ID_REQUIRED"
});
const notFound = await post({
type: "revokeLicense", licenseId: crypto.randomUUID(), adminToken: "test-token"
});
assert.deepEqual(notFound, {
success: false, errMsg: "许可证不存在", errCode: "LICENSE_NOT_FOUND"
});
const legacy = await post({
type: "revoke_license", license_id: " ", admin_token: "test-token"
});
assert.deepEqual(legacy, {
success: false, errMsg: "licenseId 不能为空", errCode: "LICENSE_ID_REQUIRED"
});
});
test("license creation verifies the signed payload and validates expiry in real time", async () => {
const company = await post({
type: "createCompany", name: "安全校验公司", code: "security-co", adminToken: "test-token"