update server
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
HOST=0.0.0.0
|
||||
PORT=3000
|
||||
B_ADMIN_TOKEN=replace-with-a-long-random-token
|
||||
# 对外部署时填写可被客户端访问的 HTTPS 根地址,例如 https://api.example.com
|
||||
# PUBLIC_BASE_URL=https://api.example.com
|
||||
# DATA_DIR=D:\ReinLoopData
|
||||
# VOLUME_CONFIG_FOLDER=volume_config
|
||||
# VOLUME_REQUEST_TTL_MS=300000
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
data/
|
||||
.env
|
||||
coverage/
|
||||
@@ -0,0 +1,125 @@
|
||||
# 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 上传、文件列表、下载、辨识反馈和容积请求流程。
|
||||
@@ -0,0 +1,103 @@
|
||||
# ReinLoop Server 功能与接口
|
||||
|
||||
## 通用约定
|
||||
|
||||
业务接口为 `POST /api`。请求与响应均为 JSON,响应包含 `success`。
|
||||
|
||||
标注为 Admin 的接口需要附加:
|
||||
|
||||
```json
|
||||
{"adminToken":"<B_ADMIN_TOKEN>"}
|
||||
```
|
||||
|
||||
`deviceId` 统一为两段格式:`<company-code>/<line-code>`。
|
||||
|
||||
## 服务入口
|
||||
|
||||
| 方法 | 路径 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| `POST` | `/api` | 主业务 API |
|
||||
| `POST` | `/upload/:token` | 根据上传凭证接收 multipart 文件 |
|
||||
| `GET` | `/files/:fileID` | 下载已存储文件 |
|
||||
| `GET` | `/health` | 服务存活检查 |
|
||||
|
||||
## 设备心跳与组织
|
||||
|
||||
| type | 鉴权 | 请求字段 | 功能与响应要点 |
|
||||
| --- | --- | --- | --- |
|
||||
| `deviceHeartbeat` | 无 | `deviceId` | 已登记设备每 10 秒上报。返回 `deviceId`、服务器记录的 `lastSeenAt`;未登记设备失败。 |
|
||||
| `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>`。 |
|
||||
|
||||
Panel 应每 10 秒调用 `listOrganizations` 刷新在线状态,不应自行推测设备状态。
|
||||
|
||||
## 许可证
|
||||
|
||||
| type | 鉴权 | 请求字段 | 功能与响应要点 |
|
||||
| --- | --- | --- | --- |
|
||||
| `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` | 撤销许可证,保留历史、撤销时间和原因。 |
|
||||
| `validateLicense` | 无 | `licenseId`、`deviceId` | 返回 `valid`、`status`、`licenseId`。状态为 `active`、`revoked`、`expired`、`not_found` 或 `device_mismatch`;不泄露客户信息和许可证原文。 |
|
||||
|
||||
许可证格式为 `payloadBase64|signatureBase64`。服务端只读取 `LICENSE_PUBLIC_KEY_PATH` 的公钥,绝不接收或保存 RSA 私钥。
|
||||
|
||||
## 文件与模型
|
||||
|
||||
| type | 鉴权 | 请求字段 | 功能与响应要点 |
|
||||
| --- | --- | --- | --- |
|
||||
| `uploadDataFile` | 视目录而定 | `fileName`、`folder` | 签发通用两步上传凭证。目录为 `*/model_config` 时必须 Admin;其他既有 ReinLoop 数据上传保持兼容。 |
|
||||
| `issueModelUpload` | Admin | `deviceId`、`fileName`、可选 `modelName`、`overwrite` | `fileName` 是本地原始文件名;传入 `modelName` 时以该名称存储和识别模型,并保留 `originalFileName`。同名模型已存在时返回 `conflict: true`;仅 `overwrite: true` 可签发覆盖凭证。 |
|
||||
| `listModels` | 无 | `folder` | 返回 `files` 当前模型名数组和 `fileList` 元数据数组,最多 100 条;每条同时包含 `fileName` 和 `originalFileName`。 |
|
||||
| `downloadModel` | 视文件而定 | `fileID` | 模型保持兼容;非模型文件要求 Admin 并返回短期签名下载 URL。 |
|
||||
| `deleteFile` | Admin | `fileID`,或 `folder` 与 `fileName` | 删除文件及元数据;同名文件不唯一时必须使用 `fileID`。 |
|
||||
| `deleteModel` | Admin | 同 `deleteFile` | 模型删除的明确管理端别名。 |
|
||||
|
||||
上传分两步:先调用 `uploadDataFile` 或 `issueModelUpload`,再将文件作为 `multipart/form-data` 的 `file` 字段提交到响应中的 `uploadMetadata.url`。上传成功返回 HTTP `204`;响应中的 `fileID` 可用于下载和删除。
|
||||
模型重命名不会修改文件格式,因此 `modelName` 与原始 `fileName` 的扩展名必须一致。未传 `modelName` 时两者相同,旧客户端行为不变。
|
||||
|
||||
上传到 `<deviceId>/ind_data` 的 `.csv`、`.json` 会自动进入 Panel inbox。
|
||||
|
||||
## 配置发布与读取
|
||||
|
||||
| type | 鉴权 | 请求字段 | 功能与响应要点 |
|
||||
| --- | --- | --- | --- |
|
||||
| `publishIdentificationConfig` | Admin | `deviceId`、`parameters` | 校验辨识参数并为 `<deviceId>/identification_config/identification_config.csv` 签发上传凭证。 |
|
||||
| `getIdentificationConfig` | 无 | `deviceId` | 返回该设备辨识 CSV 的 `fileID` 和 `url`。 |
|
||||
| `publishVolumeConfig` | Admin | `parameters` | 校验容积参数并签发 `volume_config.json` 上传凭证。上传完成后更新功能参数记录。 |
|
||||
| `getVolumeConfigFile` | 无 | 无 | 返回已发布容积 JSON 的 `fileID`、`cloudPath`、`url`。 |
|
||||
| `getFunctionConfig` | 无 | `configType: "volume"` | 返回已发布容积参数的 `parameters`、`version`、`updateTime`。 |
|
||||
|
||||
发布接口仅签发上传凭证;客户端完成二步上传后,读取接口才会返回新文件或参数。
|
||||
|
||||
## 辨识结果与 Panel 收件箱
|
||||
|
||||
| type | 鉴权 | 请求字段 | 功能与响应要点 |
|
||||
| --- | --- | --- | --- |
|
||||
| `registerIdentificationResult` | 无 | `deviceId`、`runId`、可选 `fileName` | ReinLoop 登记待审核辨识结果。 |
|
||||
| `getIdentificationFeedback` | 无 | `deviceId`、`runId` | 未就绪时 `ready: false`;就绪时返回 `ready: true` 和 `result`(`0` 或 `1`)。 |
|
||||
| `setIdentificationFeedback` | Admin | `deviceId`、可选 `runId`、`result` | Panel 提交辨识审核结果,`result` 必须为数字 `0` 或 `1`。 |
|
||||
| `ackIdentificationFeedback` | 无 | `deviceId`、`runId` | ReinLoop 消费后清理反馈。 |
|
||||
| `getPendingPanelFile` | Admin | `deviceId` | 获取指定设备下一条待处理 CSV/JSON;无数据时 `pending: false`,有数据时返回文件信息和 `url`。 |
|
||||
| `ackPanelFile` | Admin | `deviceId`、`fileID` | Panel 处理完成后确认,移除 inbox 项并标记历史记录为 `processed`,不立即删除文件。 |
|
||||
| `listIdentificationFiles` | Admin | `deviceId`、可选 `mediaType`、`status`、`page`、`pageSize` | 分页返回设备的辨识 CSV/JSON 暂存历史。 |
|
||||
| `getIdentificationFileDownload` | Admin | `fileID` | 返回原始辨识文件的短期签名下载 URL。 |
|
||||
| `deleteIdentificationFile` | Admin | `fileID` | 显式删除辨识文件、历史记录及待处理消息。 |
|
||||
|
||||
CSV 辨识文件须先以同名 `runId` 调用 `registerIdentificationResult`,才会出现在 `getPendingPanelFile`;初始行程 JSON 可直接获取。
|
||||
已处理文件默认保留 30 天,从 `processedAt` 开始计算;待处理文件不会被 TTL 清理。
|
||||
保留时长和清理周期分别由 `IDENTIFICATION_RETENTION_MS`、`IDENTIFICATION_PURGE_INTERVAL_MS` 配置。
|
||||
|
||||
## 容积配置请求
|
||||
|
||||
| type | 鉴权 | 请求字段 | 功能与响应要点 |
|
||||
| --- | --- | --- | --- |
|
||||
| `createVolumeConfigRequest` | 无 | `deviceId` | ReinLoop 创建一次性上传请求,返回 `requestId`、`createdAtMs`、`expiresAtMs`。同设备旧请求会被替换。 |
|
||||
| `getPendingVolumeConfigRequest` | 无 | `deviceId` | 查询是否存在待上传请求,返回 `pending` 和请求时间信息。 |
|
||||
| `submitVolumeConfigFile` | 无 | `deviceId`、`requestId`、`fileID`、可选 `fileName` | 将已上传到 `<deviceId>/volume_config_requests/<requestId>/` 的文件绑定至请求。 |
|
||||
| `getVolumeConfigRequest` | 无 | `deviceId`、`requestId` | 轮询配置是否就绪,返回 `ready`、`expired`;就绪时包含下载 `url`。 |
|
||||
| `ackVolumeConfigRequest` | 无 | `deviceId`、`requestId` | ReinLoop 下载完成后确认,清理请求及关联文件。 |
|
||||
|
||||
请求有效期由 `VOLUME_REQUEST_TTL_MS` 控制,默认 300000 毫秒(5 分钟)。
|
||||
@@ -0,0 +1,105 @@
|
||||
CREATE TABLE IF NOT EXISTS companies (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
code TEXT NOT NULL UNIQUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS production_lines (
|
||||
id TEXT PRIMARY KEY,
|
||||
company_id TEXT NOT NULL REFERENCES companies(id),
|
||||
name TEXT NOT NULL,
|
||||
code TEXT NOT NULL,
|
||||
device_id TEXT NOT NULL UNIQUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_seen_at TIMESTAMPTZ,
|
||||
UNIQUE (company_id, code)
|
||||
);
|
||||
|
||||
ALTER TABLE production_lines ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMPTZ;
|
||||
CREATE INDEX IF NOT EXISTS production_lines_last_seen_at_idx ON production_lines (last_seen_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS licenses (
|
||||
license_id UUID PRIMARY KEY,
|
||||
company_id TEXT NOT NULL REFERENCES companies(id),
|
||||
production_line_id TEXT NOT NULL REFERENCES production_lines(id),
|
||||
device_id TEXT NOT NULL,
|
||||
customer TEXT NOT NULL,
|
||||
issued_at TIMESTAMPTZ NOT NULL,
|
||||
expiry_at TIMESTAMPTZ NOT NULL,
|
||||
features TEXT NOT NULL,
|
||||
license TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('active', 'revoked')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
revoked_at TIMESTAMPTZ,
|
||||
revocation_reason TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS file_records (
|
||||
id TEXT PRIMARY KEY,
|
||||
file_name TEXT NOT NULL,
|
||||
original_file_name TEXT,
|
||||
folder TEXT NOT NULL,
|
||||
cloud_path TEXT NOT NULL UNIQUE,
|
||||
file_id TEXT NOT NULL UNIQUE,
|
||||
upload_time TIMESTAMPTZ NOT NULL,
|
||||
size_bytes BIGINT NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE file_records ADD COLUMN IF NOT EXISTS original_file_name TEXT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS function_configs (
|
||||
config_type TEXT PRIMARY KEY,
|
||||
parameters JSONB NOT NULL,
|
||||
version BIGINT NOT NULL,
|
||||
update_time TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS panel_inbox (
|
||||
file_id TEXT PRIMARY KEY,
|
||||
device_id TEXT NOT NULL,
|
||||
file_name TEXT NOT NULL,
|
||||
media_type TEXT NOT NULL,
|
||||
upload_time TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS identification_files (
|
||||
file_id TEXT PRIMARY KEY,
|
||||
device_id TEXT NOT NULL,
|
||||
file_name TEXT NOT NULL,
|
||||
media_type TEXT NOT NULL,
|
||||
upload_time TIMESTAMPTZ NOT NULL,
|
||||
size_bytes BIGINT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('pending', 'processed')),
|
||||
processed_at TIMESTAMPTZ,
|
||||
expires_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS identification_feedback (
|
||||
device_id TEXT NOT NULL,
|
||||
run_id TEXT NOT NULL,
|
||||
file_name TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
result SMALLINT,
|
||||
update_time TIMESTAMPTZ NOT NULL,
|
||||
PRIMARY KEY (device_id, run_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS volume_config_requests (
|
||||
device_id TEXT NOT NULL,
|
||||
request_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
expires_at_ms BIGINT NOT NULL,
|
||||
config_file_id TEXT,
|
||||
config_file_name TEXT,
|
||||
uploaded_at_ms BIGINT,
|
||||
update_time TIMESTAMPTZ NOT NULL,
|
||||
PRIMARY KEY (device_id, request_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS licenses_status_device_expiry_idx ON licenses (status, device_id, expiry_at);
|
||||
CREATE INDEX IF NOT EXISTS file_records_folder_idx ON file_records (folder);
|
||||
CREATE INDEX IF NOT EXISTS identification_files_device_upload_idx ON identification_files (device_id, upload_time DESC);
|
||||
CREATE INDEX IF NOT EXISTS identification_files_expires_idx ON identification_files (expires_at) WHERE expires_at IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS identification_feedback_device_idx ON identification_feedback (device_id);
|
||||
Generated
+1175
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "reinloop-server",
|
||||
"version": "1.0.0",
|
||||
"description": "ReinLoop 本地业务与文件服务",
|
||||
"private": true,
|
||||
"main": "src/server.js",
|
||||
"scripts": {
|
||||
"start": "node src/server.js",
|
||||
"dev": "node --watch src/server.js",
|
||||
"check": "node --check src/server.js && node --check src/app.js && node --check src/store.js && node --check src/postgres-store.js",
|
||||
"migrate": "node -e \"const fs=require('node:fs'); const {Pool}=require('pg'); if(!process.env.DATABASE_URL) throw new Error('DATABASE_URL is required'); (async()=>{const p=new Pool({connectionString:process.env.DATABASE_URL}); await p.query(fs.readFileSync('migrations/001_normalized_schema.sql','utf8')); await p.end();})().catch(error=>{console.error(error.message);process.exitCode=1})\"",
|
||||
"test": "node --test"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^5.1.0",
|
||||
"multer": "^2.0.2",
|
||||
"pg": "^8.22.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
[Unit]
|
||||
Description=ReinLoop Node.js server
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=Epifnne
|
||||
Group=Epifnne
|
||||
WorkingDirectory=/ReinLoop/server
|
||||
Environment=NODE_ENV=production
|
||||
Environment=HOST=127.0.0.1
|
||||
Environment=PORT=3000
|
||||
Environment=PUBLIC_BASE_URL=https://ReinLoop.dominatedconvergence.com
|
||||
EnvironmentFile=/ReinLoop/server/.env.production
|
||||
ExecStart=/usr/bin/node /ReinLoop/server/src/server.js
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,997 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { randomUUID, constants, createHmac, timingSafeEqual, verify } = require("node:crypto");
|
||||
const express = require("express");
|
||||
const multer = require("multer");
|
||||
|
||||
const BASE_FOLDER = "ReinLoop_GUI";
|
||||
const VOLUME_REQUEST_TTL_MS = Number(process.env.VOLUME_REQUEST_TTL_MS || 300000);
|
||||
const DOWNLOAD_TOKEN_TTL_MS = Number(process.env.DOWNLOAD_TOKEN_TTL_MS || 300000);
|
||||
const IDENTIFICATION_RETENTION_MS = Number(process.env.IDENTIFICATION_RETENTION_MS || 30 * 24 * 60 * 60 * 1000);
|
||||
const DEVICE_HEARTBEAT_TTL_MS = 30_000;
|
||||
const CONFIG_SCHEMAS = {
|
||||
volume: {
|
||||
q_in_val: "number", dt: "number", xa_full: "number", p_max: "number",
|
||||
fit_low: "number", fit_high: "number", T_delta: "number", num_runs: "integer"
|
||||
},
|
||||
identification: {
|
||||
q_in_val: "number", dt: "number", n_order: "integer", t_c: "number",
|
||||
levels: "array", dead_area: "number", xa_full: "number", V_val: "number",
|
||||
repeat: "integer"
|
||||
}
|
||||
};
|
||||
|
||||
function normalizeRelativePath(value, fallback = "") {
|
||||
const normalized = String(value || fallback).trim().replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
|
||||
if (!normalized || normalized.split("/").some((part) => !part || part === "." || part === "..")) {
|
||||
throw new Error("目录格式无效");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeFileName(value) {
|
||||
const fileName = path.basename(String(value || "").trim());
|
||||
if (!fileName || fileName === "." || fileName === "..") throw new Error("缺少有效的 fileName");
|
||||
return fileName;
|
||||
}
|
||||
|
||||
function normalizeDeviceId(value) {
|
||||
if (typeof value !== "string") throw new Error("缺少有效的 deviceId");
|
||||
const deviceId = value.trim().replace(/\\/g, "/");
|
||||
const parts = deviceId.split("/");
|
||||
if (!deviceId || parts.length !== 2 || parts.some((part) => !/^[a-z0-9][a-z0-9_-]{1,63}$/.test(part))) {
|
||||
throw new Error("deviceId 格式无效");
|
||||
}
|
||||
return deviceId;
|
||||
}
|
||||
|
||||
function controlDataDeviceId(folder) {
|
||||
const parts = String(folder || "").split("/");
|
||||
if (parts.length < 3 || parts[2] !== "data_record") return null;
|
||||
return normalizeDeviceId(parts.slice(0, 2).join("/"));
|
||||
}
|
||||
|
||||
function isControlDataRecord(record, deviceId) {
|
||||
if (!record || typeof record.folder !== "string") return false;
|
||||
const prefix = `${deviceId}/data_record`;
|
||||
return record.folder === prefix || record.folder.startsWith(`${prefix}/`);
|
||||
}
|
||||
|
||||
function parseLicenseTimestamp(value, fieldName) {
|
||||
const text = String(value || "").trim();
|
||||
const matched = /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2})$/.exec(text);
|
||||
if (!matched) throw new Error(`${fieldName} 必须是 YYYY-MM-DD HH:MM 格式`);
|
||||
const [, year, month, day, hour, minute] = matched.map(Number);
|
||||
const wallClock = new Date(Date.UTC(year, month - 1, day, hour, minute));
|
||||
if (
|
||||
wallClock.getUTCFullYear() !== year || wallClock.getUTCMonth() !== month - 1 ||
|
||||
wallClock.getUTCDate() !== day || wallClock.getUTCHours() !== hour ||
|
||||
wallClock.getUTCMinutes() !== minute
|
||||
) {
|
||||
throw new Error(`${fieldName} 无效`);
|
||||
}
|
||||
const utcMs = Date.UTC(year, month - 1, day, hour - 8, minute);
|
||||
return new Date(utcMs);
|
||||
}
|
||||
|
||||
function parseSignedLicense(content, publicKeyPath) {
|
||||
if (!publicKeyPath) throw new Error("服务器未配置 LICENSE_PUBLIC_KEY_PATH");
|
||||
const parts = String(content || "").split("|");
|
||||
if (parts.length !== 2 || !parts.every(Boolean)) throw new Error("许可证格式无效");
|
||||
const [payloadBase64, signatureBase64] = parts;
|
||||
let payload;
|
||||
let signature;
|
||||
try {
|
||||
payload = JSON.parse(Buffer.from(payloadBase64, "base64").toString("utf8"));
|
||||
signature = Buffer.from(signatureBase64, "base64");
|
||||
} catch {
|
||||
throw new Error("许可证内容解析失败");
|
||||
}
|
||||
const publicKey = fs.readFileSync(publicKeyPath);
|
||||
const valid = verify("sha256", Buffer.from(payloadBase64), {
|
||||
key: publicKey,
|
||||
padding: constants.RSA_PKCS1_PSS_PADDING,
|
||||
saltLength: constants.RSA_PSS_SALTLEN_AUTO
|
||||
}, signature);
|
||||
if (!valid) throw new Error("许可证签名验证失败");
|
||||
return payload;
|
||||
}
|
||||
|
||||
function licenseContentsMatch(record, event) {
|
||||
return ["companyId", "productionLineId", "customer", "issued", "expiry", "features", "license"]
|
||||
.every((field) => record[field] === event[field]);
|
||||
}
|
||||
|
||||
function normalizeCode(value, fieldName) {
|
||||
const code = String(value || "").trim().toLowerCase();
|
||||
if (!/^[a-z0-9][a-z0-9_-]{1,63}$/.test(code)) {
|
||||
throw new Error(`${fieldName}只能包含 2-64 位小写字母、数字、下划线或连字符`);
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
function publicLicense(record, includeContent = false) {
|
||||
const result = { ...record };
|
||||
if (!includeContent) delete result.license;
|
||||
return result;
|
||||
}
|
||||
|
||||
function validateConfigParameters(configType, parameters) {
|
||||
const schema = CONFIG_SCHEMAS[configType];
|
||||
if (!schema) return `未知 configType: ${configType}`;
|
||||
if (!parameters || typeof parameters !== "object" || Array.isArray(parameters)) return "parameters 必须是对象";
|
||||
const expected = Object.keys(schema);
|
||||
const actual = Object.keys(parameters);
|
||||
const missing = expected.filter((field) => !actual.includes(field));
|
||||
const extra = actual.filter((field) => !expected.includes(field));
|
||||
if (missing.length) return `缺少字段: ${missing.join(", ")}`;
|
||||
if (extra.length) return `包含不允许的字段: ${extra.join(", ")}`;
|
||||
for (const [field, type] of Object.entries(schema)) {
|
||||
const value = parameters[field];
|
||||
if (type === "array" && (!Array.isArray(value) || !value.length)) return `${field} 必须是非空数组`;
|
||||
if (type === "number" && !Number.isFinite(value)) return `${field} 必须是数字`;
|
||||
if (type === "integer" && !Number.isInteger(value)) return `${field} 必须是整数`;
|
||||
}
|
||||
if (configType === "identification") {
|
||||
const { levels } = parameters;
|
||||
if (levels.length < 2 || (levels.length & (levels.length - 1)) !== 0) {
|
||||
return "levels 长度必须是大于等于 2 的 2 的整数次幂";
|
||||
}
|
||||
if (levels.some((value) => !Number.isFinite(value) || value < 0 || value > 100)) {
|
||||
return "levels 中的开度必须是 0 到 100 的有限数字";
|
||||
}
|
||||
if (parameters.q_in_val < 0) return "q_in_val 不能小于 0";
|
||||
if (parameters.dt <= 0 || parameters.t_c < parameters.dt) return "必须满足 0 < dt <= t_c";
|
||||
if (parameters.n_order < 2) return "n_order 必须大于等于 2";
|
||||
if (parameters.repeat <= 0) return "repeat 必须是正整数";
|
||||
if (parameters.dead_area < 0 || parameters.xa_full <= parameters.dead_area) {
|
||||
return "必须满足 0 <= dead_area < xa_full";
|
||||
}
|
||||
if (parameters.xa_full < 1000) return "xa_full 不能小于 1000";
|
||||
if (parameters.V_val <= 0) return "V_val 必须大于 0";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function createApp({
|
||||
store,
|
||||
adminToken = process.env.B_ADMIN_TOKEN || "dev-admin-token",
|
||||
licensePublicKeyPath = process.env.LICENSE_PUBLIC_KEY_PATH
|
||||
}) {
|
||||
const app = express();
|
||||
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 100 * 1024 * 1024 } });
|
||||
const pendingUploads = new Map();
|
||||
|
||||
app.disable("x-powered-by");
|
||||
app.use(express.json({ limit: "2mb" }));
|
||||
|
||||
function publicBaseUrl(req) {
|
||||
return (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get("host")}`).replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function requireAdmin(event) {
|
||||
if (!adminToken) return "服务器未配置 B_ADMIN_TOKEN";
|
||||
return event.adminToken === adminToken ? null : "B端管理令牌无效";
|
||||
}
|
||||
|
||||
function signDownload(fileID, expiresAtMs) {
|
||||
return createHmac("sha256", adminToken).update(`${fileID}\n${expiresAtMs}`).digest("hex");
|
||||
}
|
||||
|
||||
function downloadUrl(req, fileID) {
|
||||
const baseUrl = `${publicBaseUrl(req)}/files/${encodeURIComponent(fileID)}`;
|
||||
if (fileID.startsWith("model://")) return baseUrl;
|
||||
const expires = Date.now() + DOWNLOAD_TOKEN_TTL_MS;
|
||||
return `${baseUrl}?expires=${expires}&token=${signDownload(fileID, expires)}`;
|
||||
}
|
||||
|
||||
function hasValidDownloadToken(req, fileID) {
|
||||
const expires = Number(req.query.expires);
|
||||
const token = String(req.query.token || "");
|
||||
if (!Number.isSafeInteger(expires) || expires < Date.now() || !/^[0-9a-f]{64}$/.test(token)) return false;
|
||||
const expected = signDownload(fileID, expires);
|
||||
return timingSafeEqual(Buffer.from(token, "hex"), Buffer.from(expected, "hex"));
|
||||
}
|
||||
|
||||
//config test-----------------------------------
|
||||
function logReceivedConfig({ configType, deviceId, requestId, record, config }) {
|
||||
console.info("[config] received", {
|
||||
configType,
|
||||
deviceId,
|
||||
requestId: requestId || null,
|
||||
fileID: record.fileID,
|
||||
cloudPath: record.cloudPath,
|
||||
storagePath: store.resolveStoredFile(record.fileID),
|
||||
config
|
||||
});
|
||||
}
|
||||
|
||||
function logConfigRead({ configType, deviceId, requestId, record }) {
|
||||
console.info("[config] read", {
|
||||
configType,
|
||||
deviceId,
|
||||
requestId: requestId || null,
|
||||
fileID: record.fileID,
|
||||
cloudPath: record.cloudPath,
|
||||
storagePath: store.resolveStoredFile(record.fileID)
|
||||
});
|
||||
}
|
||||
//end config test------------------------------------
|
||||
|
||||
async function issueUpload(event, req) {
|
||||
const originalFileName = normalizeFileName(event.fileName);
|
||||
const folder = normalizeRelativePath(event.folder, "data_record");
|
||||
const folderParts = folder.split("/");
|
||||
if (folderParts.includes("data_record")) {
|
||||
const deviceId = controlDataDeviceId(folder);
|
||||
if (!deviceId) {
|
||||
return { success: false, errMsg: "控制数据目录必须为 <deviceId>/data_record/ 的子目录" };
|
||||
}
|
||||
}
|
||||
const isModel = folderParts.at(-1) === "model_config";
|
||||
const fileName = isModel && event.modelName
|
||||
? normalizeFileName(event.modelName)
|
||||
: originalFileName;
|
||||
if (isModel && path.extname(fileName).toLowerCase() !== path.extname(originalFileName).toLowerCase()) {
|
||||
return { success: false, errMsg: "重命名后的模型扩展名必须与原文件一致" };
|
||||
}
|
||||
const modelDeviceId = isModel
|
||||
? normalizeDeviceId(folderParts.slice(0, -1).join("/"))
|
||||
: null;
|
||||
const cloudPath = isModel
|
||||
? `${modelDeviceId}/${fileName}`
|
||||
: `${BASE_FOLDER}/${folder}/${fileName}`;
|
||||
const fileID = `${isModel ? "model" : "local"}://${cloudPath}`;
|
||||
if (isModel && event.overwrite !== true) {
|
||||
const database = await store.read();
|
||||
const existing = database.fileRecords.find((record) => record.cloudPath === cloudPath);
|
||||
if (existing) {
|
||||
return {
|
||||
success: false,
|
||||
conflict: true,
|
||||
errMsg: "同名模型已存在",
|
||||
existing: {
|
||||
...existing,
|
||||
originalFileName: existing.originalFileName || existing.fileName
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
const token = randomUUID();
|
||||
pendingUploads.set(token, {
|
||||
fileName,
|
||||
originalFileName: isModel ? originalFileName : undefined,
|
||||
folder,
|
||||
cloudPath,
|
||||
fileID,
|
||||
configType: event.configType,
|
||||
parameters: event.parameters,
|
||||
expiresAt: Date.now() + 10 * 60 * 1000
|
||||
});
|
||||
return {
|
||||
code: 200,
|
||||
success: true,
|
||||
uploadMetadata: {
|
||||
url: `${publicBaseUrl(req)}/upload/${token}`,
|
||||
token,
|
||||
authorization: token,
|
||||
cosFileId: cloudPath,
|
||||
fileId: fileID
|
||||
},
|
||||
fileID,
|
||||
cloudPath,
|
||||
fileName,
|
||||
originalFileName: isModel ? originalFileName : undefined
|
||||
};
|
||||
}
|
||||
|
||||
async function removeFile(database, fileID) {
|
||||
const filePath = store.resolveStoredFile(fileID);
|
||||
if (filePath) await fs.promises.rm(filePath, { force: true });
|
||||
const before = database.fileRecords.length;
|
||||
database.fileRecords = database.fileRecords.filter((record) => record.fileID !== fileID);
|
||||
database.panelInbox = database.panelInbox.filter((record) => record.fileID !== fileID);
|
||||
database.identificationFiles = database.identificationFiles.filter((record) => record.fileID !== fileID);
|
||||
return before - database.fileRecords.length;
|
||||
}
|
||||
|
||||
function backfillIdentificationFiles(database) {
|
||||
for (const record of database.fileRecords) {
|
||||
if (!record.folder.endsWith("/ind_data") || ![".csv", ".json"].includes(path.extname(record.fileName).toLowerCase())) continue;
|
||||
if (database.identificationFiles.some((item) => item.fileID === record.fileID)) continue;
|
||||
const deviceId = normalizeDeviceId(record.folder.slice(0, -"/ind_data".length));
|
||||
const pending = database.panelInbox.some((item) => item.fileID === record.fileID);
|
||||
database.identificationFiles.push({
|
||||
fileID: record.fileID,
|
||||
deviceId,
|
||||
fileName: record.fileName,
|
||||
mediaType: path.extname(record.fileName).slice(1).toLowerCase(),
|
||||
uploadTime: record.uploadTime,
|
||||
size: record.size,
|
||||
status: pending ? "pending" : "processed",
|
||||
processedAt: pending ? null : record.uploadTime,
|
||||
expiresAt: pending ? null : new Date(Date.parse(record.uploadTime) + IDENTIFICATION_RETENTION_MS).toISOString()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function purgeExpiredIdentificationFiles() {
|
||||
return store.update(async (database) => {
|
||||
backfillIdentificationFiles(database);
|
||||
const now = Date.now();
|
||||
const expired = database.identificationFiles.filter((record) =>
|
||||
record.status === "processed" && record.expiresAt && Date.parse(record.expiresAt) <= now
|
||||
);
|
||||
for (const record of expired) await removeFile(database, record.fileID);
|
||||
return expired.length;
|
||||
});
|
||||
}
|
||||
|
||||
app.locals.purgeExpiredIdentificationFiles = purgeExpiredIdentificationFiles;
|
||||
|
||||
async function dispatch(event, req) {
|
||||
switch (event.type) {
|
||||
case "listOrganizations": {
|
||||
const authError = requireAdmin(event);
|
||||
if (authError) return { success: false, errMsg: authError };
|
||||
const database = await store.read();
|
||||
const now = Date.now();
|
||||
const companies = database.companies.map((company) => ({
|
||||
...company,
|
||||
productionLines: database.productionLines
|
||||
.filter((line) => line.companyId === company.id)
|
||||
.map((line) => ({
|
||||
...line,
|
||||
online: Boolean(line.lastSeenAt && now - Date.parse(line.lastSeenAt) <= DEVICE_HEARTBEAT_TTL_MS),
|
||||
lastSeenAt: line.lastSeenAt || null
|
||||
}))
|
||||
}));
|
||||
return { success: true, companies };
|
||||
}
|
||||
case "deviceHeartbeat": {
|
||||
const deviceId = normalizeDeviceId(event.deviceId);
|
||||
return store.update((database) => {
|
||||
const line = database.productionLines.find((item) => item.deviceId === deviceId);
|
||||
if (!line) return { success: false, errMsg: "设备未注册" };
|
||||
line.lastSeenAt = new Date().toISOString();
|
||||
return { success: true, deviceId, lastSeenAt: line.lastSeenAt };
|
||||
});
|
||||
}
|
||||
case "createCompany": {
|
||||
const authError = requireAdmin(event);
|
||||
if (authError) return { success: false, errMsg: authError };
|
||||
const name = String(event.name || "").trim();
|
||||
if (!name) return { success: false, errMsg: "公司名称不能为空" };
|
||||
const code = normalizeCode(event.code, "公司编码");
|
||||
return store.update((database) => {
|
||||
if (database.companies.some((item) => item.code === code)) {
|
||||
return { success: false, errMsg: `公司编码 ${code} 已存在` };
|
||||
}
|
||||
const company = {
|
||||
id: store.createId("company"), name, code,
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
database.companies.push(company);
|
||||
return { success: true, company };
|
||||
});
|
||||
}
|
||||
case "createProductionLine": {
|
||||
const authError = requireAdmin(event);
|
||||
if (authError) return { success: false, errMsg: authError };
|
||||
const name = String(event.name || "").trim();
|
||||
if (!name) return { success: false, errMsg: "产线名称不能为空" };
|
||||
const code = normalizeCode(event.code, "产线编码");
|
||||
return store.update((database) => {
|
||||
const company = database.companies.find((item) => item.id === event.companyId);
|
||||
if (!company) return { success: false, errMsg: "公司不存在" };
|
||||
if (database.productionLines.some((item) => item.companyId === company.id && item.code === code)) {
|
||||
return { success: false, errMsg: `该公司下产线编码 ${code} 已存在` };
|
||||
}
|
||||
const productionLine = {
|
||||
id: store.createId("line"), companyId: company.id, name, code,
|
||||
deviceId: `${company.code}/${code}`,
|
||||
createdAt: new Date().toISOString(), lastSeenAt: null
|
||||
};
|
||||
database.productionLines.push(productionLine);
|
||||
return { success: true, productionLine };
|
||||
});
|
||||
}
|
||||
case "createLicense": {
|
||||
const authError = requireAdmin(event);
|
||||
if (authError) return { success: false, errMsg: authError };
|
||||
const required = ["licenseId", "companyId", "productionLineId", "customer", "issued", "expiry", "license"];
|
||||
const missing = required.filter((field) => !String(event[field] || "").trim());
|
||||
if (missing.length) return { success: false, errMsg: `缺少必填字段: ${missing.join(", ")}` };
|
||||
const licenseId = String(event.licenseId).trim();
|
||||
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(licenseId)) {
|
||||
return { success: false, errMsg: "licenseId 必须是 UUID" };
|
||||
}
|
||||
const issuedAt = parseLicenseTimestamp(event.issued, "issued");
|
||||
const expiryAt = parseLicenseTimestamp(event.expiry, "expiry");
|
||||
if (expiryAt <= issuedAt) return { success: false, errMsg: "expiry 必须晚于 issued" };
|
||||
const payload = parseSignedLicense(event.license, licensePublicKeyPath);
|
||||
const signedFields = {
|
||||
license_id: licenseId,
|
||||
company_id: String(event.companyId),
|
||||
production_line_id: String(event.productionLineId),
|
||||
customer: String(event.customer),
|
||||
issued: String(event.issued),
|
||||
expiry: String(event.expiry),
|
||||
features: String(event.features || "*")
|
||||
};
|
||||
if (Object.entries(signedFields).some(([field, value]) => payload[field] !== value)) {
|
||||
return { success: false, errMsg: "许可证载荷与请求字段不一致" };
|
||||
}
|
||||
return store.update((database) => {
|
||||
const company = database.companies.find((item) => item.id === event.companyId);
|
||||
const line = database.productionLines.find((item) => item.id === event.productionLineId && item.companyId === event.companyId);
|
||||
if (!company || !line) return { success: false, errMsg: "公司或产线不存在" };
|
||||
if (payload.device_id !== line.deviceId) return { success: false, errMsg: "许可证 device_id 与产线不匹配" };
|
||||
const existing = database.licenses.find((item) => item.licenseId === licenseId);
|
||||
if (existing) {
|
||||
return licenseContentsMatch(existing, { ...event, features: event.features || "*" })
|
||||
? { success: true, license: publicLicense(existing), idempotent: true }
|
||||
: { success: false, conflict: true, errMsg: "licenseId 已存在且内容不同" };
|
||||
}
|
||||
const record = {
|
||||
licenseId, companyId: company.id,
|
||||
productionLineId: line.id, companyName: company.name,
|
||||
productionLineName: line.name, deviceId: line.deviceId,
|
||||
customer: String(event.customer), issued: String(event.issued),
|
||||
expiry: String(event.expiry), issuedAt: issuedAt.toISOString(),
|
||||
expiryAt: expiryAt.toISOString(), features: event.features || "*",
|
||||
license: String(event.license), status: "active",
|
||||
createdAt: new Date().toISOString(), revokedAt: null,
|
||||
revocationReason: null
|
||||
};
|
||||
database.licenses.push(record);
|
||||
return { success: true, license: publicLicense(record) };
|
||||
});
|
||||
}
|
||||
case "listLicenses": {
|
||||
const authError = requireAdmin(event);
|
||||
if (authError) return { success: false, errMsg: authError };
|
||||
const database = await store.read();
|
||||
return {
|
||||
success: true,
|
||||
licenses: database.licenses.map((record) => publicLicense(record))
|
||||
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))
|
||||
};
|
||||
}
|
||||
case "getLicense": {
|
||||
const authError = requireAdmin(event);
|
||||
if (authError) return { success: false, errMsg: authError };
|
||||
const database = await store.read();
|
||||
const record = database.licenses.find((item) => item.licenseId === event.licenseId);
|
||||
return record
|
||||
? { success: true, license: publicLicense(record, true) }
|
||||
: { success: false, errMsg: "许可证不存在" };
|
||||
}
|
||||
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: "许可证不存在" };
|
||||
record.status = "revoked";
|
||||
record.revokedAt = new Date().toISOString();
|
||||
record.revocationReason = String(event.reason || "管理员撤销").trim();
|
||||
return { success: true, license: publicLicense(record) };
|
||||
});
|
||||
}
|
||||
case "validateLicense": {
|
||||
const database = await store.read();
|
||||
const record = database.licenses.find((item) => item.licenseId === event.licenseId);
|
||||
if (!record) return { success: true, valid: false, status: "not_found" };
|
||||
if (event.deviceId && normalizeDeviceId(event.deviceId) !== record.deviceId) {
|
||||
return { success: true, valid: false, status: "device_mismatch", licenseId: record.licenseId };
|
||||
}
|
||||
if (new Date(record.expiryAt || parseLicenseTimestamp(record.expiry, "expiry")) <= new Date()) {
|
||||
return { success: true, valid: false, status: "expired", licenseId: record.licenseId };
|
||||
}
|
||||
return {
|
||||
success: true, valid: record.status === "active",
|
||||
status: record.status, licenseId: record.licenseId
|
||||
};
|
||||
}
|
||||
case "uploadDataFile":
|
||||
if (normalizeRelativePath(event.folder, "data_record").endsWith("/model_config")) {
|
||||
const authError = requireAdmin(event);
|
||||
if (authError) return { success: false, errMsg: authError };
|
||||
}
|
||||
return issueUpload(event, req);
|
||||
case "issueModelUpload": {
|
||||
const authError = requireAdmin(event);
|
||||
if (authError) return { success: false, errMsg: authError };
|
||||
const deviceId = normalizeDeviceId(event.deviceId);
|
||||
return issueUpload({ ...event, folder: `${deviceId}/model_config` }, req);
|
||||
}
|
||||
case "listModels": {
|
||||
const folder = normalizeRelativePath(event.folder, "model_config");
|
||||
const database = await store.read();
|
||||
const fileList = database.fileRecords
|
||||
.filter((record) => record.folder === folder)
|
||||
.sort((left, right) => right.uploadTime.localeCompare(left.uploadTime))
|
||||
.slice(0, 100);
|
||||
return {
|
||||
success: true,
|
||||
files: fileList.map((record) => record.fileName),
|
||||
fileList: fileList.map((record) => ({
|
||||
...record,
|
||||
originalFileName: record.originalFileName || record.fileName
|
||||
}))
|
||||
};
|
||||
}
|
||||
case "downloadModel": {
|
||||
const database = await store.read();
|
||||
const record = database.fileRecords.find((item) => item.fileID === event.fileID);
|
||||
if (!record || !store.resolveStoredFile(record.fileID)) return { success: false, errMsg: "文件不存在" };
|
||||
if (!record.fileID.startsWith("model://")) {
|
||||
const authError = requireAdmin(event);
|
||||
if (authError) return { success: false, errMsg: authError };
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
url: downloadUrl(req, record.fileID),
|
||||
fileName: record.fileName,
|
||||
originalFileName: record.originalFileName || record.fileName
|
||||
};
|
||||
}
|
||||
case "deleteFile":
|
||||
case "deleteModel": {
|
||||
const authError = requireAdmin(event);
|
||||
if (authError) return { success: false, errMsg: authError };
|
||||
return store.update(async (database) => {
|
||||
let records = event.fileID
|
||||
? database.fileRecords.filter((item) => item.fileID === event.fileID)
|
||||
: database.fileRecords.filter((item) =>
|
||||
item.folder === normalizeRelativePath(event.folder, "model_config") &&
|
||||
item.fileName === normalizeFileName(event.fileName)
|
||||
);
|
||||
if (!event.fileID && records.length > 1) {
|
||||
return { success: false, ambiguous: true, errMsg: `发现 ${records.length} 个同名文件,请用 fileID 精确指定`, candidates: records };
|
||||
}
|
||||
if (!records.length) return { success: false, errMsg: "数据库中未找到对应记录" };
|
||||
let deletedCount = 0;
|
||||
for (const record of records) deletedCount += await removeFile(database, record.fileID);
|
||||
return { success: true, deletedFileID: records.map((record) => record.fileID).join(", "), deletedCount };
|
||||
});
|
||||
}
|
||||
case "publishIdentificationConfig": {
|
||||
const authError = requireAdmin(event);
|
||||
if (authError) return { success: false, errMsg: authError };
|
||||
const deviceId = normalizeDeviceId(event.deviceId);
|
||||
const validationError = validateConfigParameters("identification", event.parameters);
|
||||
if (validationError) return { success: false, errMsg: validationError };
|
||||
return issueUpload({
|
||||
fileName: "identification_config.csv",
|
||||
folder: `${deviceId}/identification_config`
|
||||
}, req);
|
||||
}
|
||||
case "publishVolumeConfig": {
|
||||
const authError = requireAdmin(event);
|
||||
if (authError) return { success: false, errMsg: authError };
|
||||
const validationError = validateConfigParameters("volume", event.parameters);
|
||||
if (validationError) return { success: false, errMsg: validationError };
|
||||
const result = await issueUpload({
|
||||
fileName: "volume_config.json",
|
||||
folder: process.env.VOLUME_CONFIG_FOLDER || "volume_config",
|
||||
configType: "volume",
|
||||
parameters: event.parameters
|
||||
}, req);
|
||||
return { ...result, parameters: event.parameters };
|
||||
}
|
||||
case "getVolumeConfigFile": {
|
||||
const folder = process.env.VOLUME_CONFIG_FOLDER || "volume_config";
|
||||
const fileID = `local://${BASE_FOLDER}/${folder}/volume_config.json`;
|
||||
const database = await store.read();
|
||||
if (!database.fileRecords.some((record) => record.fileID === fileID)) {
|
||||
return { success: false, notFound: true, errMsg: "容积配置文件尚未发布" };
|
||||
}
|
||||
return { success: true, fileID, cloudPath: `${BASE_FOLDER}/${folder}/volume_config.json`, url: downloadUrl(req, fileID) };
|
||||
}
|
||||
case "getFunctionConfig": {
|
||||
if (event.configType !== "volume") return { success: false, errMsg: `未知 configType: ${event.configType}` };
|
||||
const database = await store.read();
|
||||
const config = database.functionConfigs.find((item) => item.configType === event.configType);
|
||||
return config ? { success: true, ...config } : { success: false, notFound: true, errMsg: "参数配置尚未发布" };
|
||||
}
|
||||
case "getIdentificationConfig": {
|
||||
const deviceId = normalizeDeviceId(event.deviceId);
|
||||
const database = await store.read();
|
||||
const record = database.fileRecords.find((item) => item.folder === `${deviceId}/identification_config` && item.fileName === "identification_config.csv");
|
||||
if (!record) return { success: false, errMsg: "服务器尚未配置辨识参数" };
|
||||
logConfigRead({ configType: "identification", deviceId, record });
|
||||
return { success: true, fileName: record.fileName, fileID: record.fileID, cloudPath: record.cloudPath, url: downloadUrl(req, record.fileID) };
|
||||
}
|
||||
case "getPendingPanelFile": {
|
||||
const authError = requireAdmin(event);
|
||||
if (authError) return { success: false, errMsg: authError };
|
||||
const deviceId = normalizeDeviceId(event.deviceId);
|
||||
const database = await store.read();
|
||||
const message = database.panelInbox.find((item) => {
|
||||
if (item.deviceId !== deviceId) return false;
|
||||
if (item.mediaType !== "csv") return true;
|
||||
return database.identificationFeedback.some((feedback) =>
|
||||
feedback.deviceId === deviceId && feedback.runId === item.fileName
|
||||
);
|
||||
});
|
||||
if (!message) return { success: true, pending: false };
|
||||
return {
|
||||
success: true,
|
||||
pending: true,
|
||||
fileID: message.fileID,
|
||||
fileName: message.fileName,
|
||||
mediaType: message.mediaType,
|
||||
uploadTime: message.uploadTime,
|
||||
url: downloadUrl(req, message.fileID)
|
||||
};
|
||||
}
|
||||
case "ackPanelFile": {
|
||||
const authError = requireAdmin(event);
|
||||
if (authError) return { success: false, errMsg: authError };
|
||||
const deviceId = normalizeDeviceId(event.deviceId);
|
||||
if (!event.fileID) return { success: false, errMsg: "缺少 fileID" };
|
||||
return store.update((database) => {
|
||||
backfillIdentificationFiles(database);
|
||||
const messages = database.panelInbox.filter((item) =>
|
||||
item.deviceId === deviceId && item.fileID === String(event.fileID)
|
||||
);
|
||||
const historyRecord = database.identificationFiles.find((item) =>
|
||||
item.deviceId === deviceId && item.fileID === String(event.fileID)
|
||||
);
|
||||
if (!messages.length && !historyRecord) return { success: false, errMsg: "辨识文件不存在" };
|
||||
const processedAt = new Date().toISOString();
|
||||
if (historyRecord) {
|
||||
historyRecord.status = "processed";
|
||||
historyRecord.processedAt = processedAt;
|
||||
historyRecord.expiresAt = new Date(Date.now() + IDENTIFICATION_RETENTION_MS).toISOString();
|
||||
}
|
||||
const before = database.panelInbox.length;
|
||||
database.panelInbox = database.panelInbox.filter((item) =>
|
||||
!(item.deviceId === deviceId && item.fileID === String(event.fileID))
|
||||
);
|
||||
return { success: true, processed: true, deleted: before - database.panelInbox.length, processedAt };
|
||||
});
|
||||
}
|
||||
case "listIdentificationFiles": {
|
||||
const authError = requireAdmin(event);
|
||||
if (authError) return { success: false, errMsg: authError };
|
||||
const deviceId = normalizeDeviceId(event.deviceId);
|
||||
await purgeExpiredIdentificationFiles();
|
||||
const database = await store.read();
|
||||
const page = Math.max(1, Number.parseInt(event.page, 10) || 1);
|
||||
const pageSize = Math.min(100, Math.max(1, Number.parseInt(event.pageSize, 10) || 20));
|
||||
const files = database.identificationFiles
|
||||
.filter((record) => record.deviceId === deviceId)
|
||||
.filter((record) => !event.mediaType || record.mediaType === event.mediaType)
|
||||
.filter((record) => !event.status || record.status === event.status)
|
||||
.sort((left, right) => right.uploadTime.localeCompare(left.uploadTime));
|
||||
const offset = (page - 1) * pageSize;
|
||||
return {
|
||||
success: true,
|
||||
files: files.slice(offset, offset + pageSize),
|
||||
total: files.length,
|
||||
page,
|
||||
pageSize
|
||||
};
|
||||
}
|
||||
case "listControlFiles": {
|
||||
const authError = requireAdmin(event);
|
||||
if (authError) return { success: false, errMsg: authError };
|
||||
const deviceId = normalizeDeviceId(event.deviceId);
|
||||
const page = Math.max(1, Number.parseInt(event.page, 10) || 1);
|
||||
const pageSize = Math.min(100, Math.max(1, Number.parseInt(event.pageSize, 10) || 20));
|
||||
const database = await store.read();
|
||||
const files = database.fileRecords
|
||||
.filter((record) => isControlDataRecord(record, deviceId))
|
||||
.sort((left, right) => right.uploadTime.localeCompare(left.uploadTime));
|
||||
const offset = (page - 1) * pageSize;
|
||||
return {
|
||||
success: true,
|
||||
files: files.slice(offset, offset + pageSize).map((record) => ({
|
||||
fileID: record.fileID,
|
||||
fileName: record.fileName,
|
||||
uploadTime: record.uploadTime,
|
||||
size: record.size
|
||||
})),
|
||||
total: files.length,
|
||||
page,
|
||||
pageSize
|
||||
};
|
||||
}
|
||||
case "getControlFileDownload": {
|
||||
const authError = requireAdmin(event);
|
||||
if (authError) return { success: false, errMsg: authError };
|
||||
if (!event.fileID) return { success: false, errMsg: "缺少 fileID" };
|
||||
const database = await store.read();
|
||||
const record = database.fileRecords.find((item) => item.fileID === String(event.fileID));
|
||||
if (!record || !controlDataDeviceId(record.folder) || !store.resolveStoredFile(record.fileID)) {
|
||||
return { success: false, errMsg: "控制数据文件不存在或不属于控制数据目录" };
|
||||
}
|
||||
try {
|
||||
await fs.promises.access(store.resolveStoredFile(record.fileID));
|
||||
} catch {
|
||||
return { success: false, errMsg: "控制数据文件不存在" };
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
fileID: record.fileID,
|
||||
fileName: record.fileName,
|
||||
uploadTime: record.uploadTime,
|
||||
size: record.size,
|
||||
url: downloadUrl(req, record.fileID)
|
||||
};
|
||||
}
|
||||
case "deleteControlFile": {
|
||||
const authError = requireAdmin(event);
|
||||
if (authError) return { success: false, errMsg: authError };
|
||||
if (!event.fileID) return { success: false, errMsg: "缺少 fileID" };
|
||||
return store.update(async (database) => {
|
||||
const record = database.fileRecords.find((item) => item.fileID === String(event.fileID));
|
||||
if (!record || !controlDataDeviceId(record.folder)) {
|
||||
return { success: false, errMsg: "控制数据文件不存在或不属于控制数据目录" };
|
||||
}
|
||||
const deletedCount = await removeFile(database, record.fileID);
|
||||
return deletedCount
|
||||
? { success: true, deletedCount }
|
||||
: { success: false, errMsg: "控制数据文件不存在" };
|
||||
});
|
||||
}
|
||||
case "getIdentificationFileDownload": {
|
||||
const authError = requireAdmin(event);
|
||||
if (authError) return { success: false, errMsg: authError };
|
||||
const database = await store.read();
|
||||
const historyRecord = database.identificationFiles.find((item) => item.fileID === event.fileID);
|
||||
const fileRecord = database.fileRecords.find((item) => item.fileID === event.fileID);
|
||||
if (!historyRecord || !fileRecord || !store.resolveStoredFile(fileRecord.fileID)) {
|
||||
return { success: false, errMsg: "辨识文件不存在" };
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
fileID: fileRecord.fileID,
|
||||
fileName: fileRecord.fileName,
|
||||
mediaType: historyRecord.mediaType,
|
||||
url: downloadUrl(req, fileRecord.fileID)
|
||||
};
|
||||
}
|
||||
case "deleteIdentificationFile": {
|
||||
const authError = requireAdmin(event);
|
||||
if (authError) return { success: false, errMsg: authError };
|
||||
if (!event.fileID) return { success: false, errMsg: "缺少 fileID" };
|
||||
return store.update(async (database) => {
|
||||
if (!database.identificationFiles.some((item) => item.fileID === event.fileID)) {
|
||||
return { success: false, errMsg: "辨识文件不存在" };
|
||||
}
|
||||
const deletedCount = await removeFile(database, event.fileID);
|
||||
return { success: true, deletedCount };
|
||||
});
|
||||
}
|
||||
case "registerIdentificationResult": {
|
||||
const deviceId = normalizeDeviceId(event.deviceId);
|
||||
if (!event.runId) return { success: false, errMsg: "缺少 deviceId 或 runId" };
|
||||
return store.update((database) => {
|
||||
const record = { deviceId, runId: String(event.runId), fileName: event.fileName || String(event.runId), status: "waiting_feedback", result: null, updateTime: new Date().toISOString() };
|
||||
database.identificationFeedback = database.identificationFeedback.filter((item) => item.deviceId !== deviceId);
|
||||
database.identificationFeedback.push(record);
|
||||
return { success: true, runId: record.runId, status: record.status };
|
||||
});
|
||||
}
|
||||
case "getIdentificationFeedback": {
|
||||
const deviceId = normalizeDeviceId(event.deviceId);
|
||||
if (!event.runId) return { success: false, errMsg: "缺少 deviceId 或 runId" };
|
||||
const database = await store.read();
|
||||
const record = database.identificationFeedback.find((item) => item.deviceId === deviceId && item.runId === String(event.runId));
|
||||
if (!record || record.status !== "feedback_ready") return { success: true, ready: false };
|
||||
return { success: true, ready: true, result: record.result, runId: record.runId };
|
||||
}
|
||||
case "setIdentificationFeedback": {
|
||||
const authError = requireAdmin(event);
|
||||
if (authError) return { success: false, errMsg: authError };
|
||||
const deviceId = normalizeDeviceId(event.deviceId);
|
||||
if (event.result !== 0 && event.result !== 1) return { success: false, errMsg: "result 必须是数字 0/1" };
|
||||
return store.update((database) => {
|
||||
const record = database.identificationFeedback.find((item) => item.deviceId === deviceId);
|
||||
if (!record) return { success: false, errMsg: "当前没有待审核的辨识结果" };
|
||||
if (event.runId && String(event.runId) !== record.runId) return { success: false, errMsg: "runId 与当前待审核结果不一致" };
|
||||
record.status = "feedback_ready";
|
||||
record.result = event.result;
|
||||
record.updateTime = new Date().toISOString();
|
||||
return { success: true, runId: record.runId, fileName: record.fileName, result: record.result };
|
||||
});
|
||||
}
|
||||
case "ackIdentificationFeedback": {
|
||||
const deviceId = normalizeDeviceId(event.deviceId);
|
||||
if (!event.runId) return { success: false, errMsg: "缺少 deviceId 或 runId" };
|
||||
return store.update((database) => {
|
||||
const before = database.identificationFeedback.length;
|
||||
database.identificationFeedback = database.identificationFeedback.filter((item) => !(item.deviceId === deviceId && item.runId === String(event.runId)));
|
||||
return { success: true, deleted: before - database.identificationFeedback.length };
|
||||
});
|
||||
}
|
||||
case "createVolumeConfigRequest": {
|
||||
const deviceId = normalizeDeviceId(event.deviceId);
|
||||
return store.update(async (database) => {
|
||||
const previous = database.volumeConfigRequests.filter((item) => item.deviceId === deviceId);
|
||||
for (const record of previous) if (record.configFileID) await removeFile(database, record.configFileID);
|
||||
database.volumeConfigRequests = database.volumeConfigRequests.filter((item) => item.deviceId !== deviceId);
|
||||
const createdAtMs = Date.now();
|
||||
const record = { deviceId, requestId: `${createdAtMs}-${randomUUID().slice(0, 10)}`, status: "waiting_upload", createdAtMs, expiresAtMs: createdAtMs + VOLUME_REQUEST_TTL_MS, configFileID: null, configFileName: null, uploadedAtMs: null, updateTime: new Date().toISOString() };
|
||||
database.volumeConfigRequests.push(record);
|
||||
return { success: true, requestId: record.requestId, createdAtMs, expiresAtMs: record.expiresAtMs };
|
||||
});
|
||||
}
|
||||
case "getPendingVolumeConfigRequest": {
|
||||
const deviceId = normalizeDeviceId(event.deviceId);
|
||||
return store.update(async (database) => {
|
||||
const record = database.volumeConfigRequests.find((item) => item.deviceId === deviceId && item.status === "waiting_upload");
|
||||
if (!record) return { success: true, pending: false };
|
||||
if (Date.now() > record.expiresAtMs) {
|
||||
database.volumeConfigRequests = database.volumeConfigRequests.filter((item) => item !== record);
|
||||
return { success: true, pending: false, expired: true };
|
||||
}
|
||||
return { success: true, pending: true, requestId: record.requestId, createdAtMs: record.createdAtMs, expiresAtMs: record.expiresAtMs };
|
||||
});
|
||||
}
|
||||
case "submitVolumeConfigFile": {
|
||||
const deviceId = normalizeDeviceId(event.deviceId);
|
||||
if (!event.requestId || !event.fileID) return { success: false, errMsg: "缺少 deviceId、requestId 或 fileID" };
|
||||
return store.update((database) => {
|
||||
const record = database.volumeConfigRequests.find((item) => item.deviceId === deviceId && item.requestId === String(event.requestId));
|
||||
if (!record) return { success: false, errMsg: "容积参数请求不存在" };
|
||||
if (record.status !== "waiting_upload" || Date.now() > record.expiresAtMs) return { success: false, errMsg: "容积参数请求已失效" };
|
||||
const fileName = event.fileName || "volume_measurement.json";
|
||||
const expectedFolder = `${deviceId}/volume_config_requests/${record.requestId}`;
|
||||
const fileRecord = database.fileRecords.find((item) => item.fileID === String(event.fileID) && item.folder === expectedFolder && item.fileName === fileName);
|
||||
if (!fileRecord) return { success: false, errMsg: "上传文件不属于本次容积参数请求" };
|
||||
record.status = "ready";
|
||||
record.configFileID = fileRecord.fileID;
|
||||
record.configFileName = fileName;
|
||||
record.uploadedAtMs = Date.now();
|
||||
record.updateTime = new Date().toISOString();
|
||||
return { success: true, requestId: record.requestId, uploadedAtMs: record.uploadedAtMs };
|
||||
});
|
||||
}
|
||||
case "getVolumeConfigRequest": {
|
||||
const deviceId = normalizeDeviceId(event.deviceId);
|
||||
if (!event.requestId) return { success: false, errMsg: "缺少 deviceId 或 requestId" };
|
||||
const database = await store.read();
|
||||
const record = database.volumeConfigRequests.find((item) => item.deviceId === deviceId && item.requestId === String(event.requestId));
|
||||
if (!record || Date.now() > record.expiresAtMs) return { success: true, ready: false, expired: true };
|
||||
if (record.status !== "ready") return { success: true, ready: false, expired: false };
|
||||
const fileRecord = database.fileRecords.find((item) => item.fileID === record.configFileID);
|
||||
if (!fileRecord) return { success: false, errMsg: "容积配置文件记录不存在" };
|
||||
logConfigRead({ configType: "volume", deviceId, requestId: record.requestId, record: fileRecord });
|
||||
return { success: true, ready: true, expired: false, requestId: record.requestId, fileName: record.configFileName, fileID: fileRecord.fileID, cloudPath: fileRecord.cloudPath, uploadedAtMs: record.uploadedAtMs, url: downloadUrl(req, record.configFileID) };
|
||||
}
|
||||
case "ackVolumeConfigRequest": {
|
||||
const deviceId = normalizeDeviceId(event.deviceId);
|
||||
if (!event.requestId) return { success: false, errMsg: "缺少 deviceId 或 requestId" };
|
||||
return store.update(async (database) => {
|
||||
const records = database.volumeConfigRequests.filter((item) => item.deviceId === deviceId && item.requestId === String(event.requestId));
|
||||
for (const record of records) if (record.configFileID) await removeFile(database, record.configFileID);
|
||||
database.volumeConfigRequests = database.volumeConfigRequests.filter((item) => !records.includes(item));
|
||||
return { success: true, deleted: records.length };
|
||||
});
|
||||
}
|
||||
default:
|
||||
return { success: false, errMsg: "无效的 type 字段" };
|
||||
}
|
||||
}
|
||||
|
||||
app.get("/health", (req, res) => res.json({ success: true, service: "reinloop-server" }));
|
||||
|
||||
app.post("/upload/:token", upload.single("file"), async (req, res, next) => {
|
||||
try {
|
||||
const pending = pendingUploads.get(req.params.token);
|
||||
if (!pending || pending.expiresAt < Date.now()) return res.status(403).json({ success: false, errMsg: "上传凭证无效或已过期" });
|
||||
if (!req.file) return res.status(400).json({ success: false, errMsg: "缺少 file 表单字段" });
|
||||
const destination = store.resolveStoredFile(pending.fileID);
|
||||
await fs.promises.mkdir(path.dirname(destination), { recursive: true });
|
||||
await fs.promises.writeFile(destination, req.file.buffer);
|
||||
await store.update((database) => {
|
||||
const record = { _id: store.createId("file"), fileName: pending.fileName, originalFileName: pending.originalFileName, folder: pending.folder, cloudPath: pending.cloudPath, fileID: pending.fileID, uploadTime: new Date().toISOString(), size: req.file.size };
|
||||
database.fileRecords = database.fileRecords.filter((item) => item.cloudPath !== pending.cloudPath);
|
||||
database.fileRecords.push(record);
|
||||
const folderParts = pending.folder.split("/");
|
||||
const extension = path.extname(pending.fileName).toLowerCase();
|
||||
if (folderParts.at(-1) === "ind_data" && [".csv", ".json"].includes(extension)) {
|
||||
const deviceId = normalizeDeviceId(folderParts.slice(0, -1).join("/"));
|
||||
const message = {
|
||||
deviceId,
|
||||
fileID: pending.fileID,
|
||||
fileName: pending.fileName,
|
||||
mediaType: extension.slice(1),
|
||||
uploadTime: record.uploadTime
|
||||
};
|
||||
database.panelInbox = database.panelInbox.filter((item) => item.fileID !== pending.fileID);
|
||||
database.panelInbox.push(message);
|
||||
database.identificationFiles = database.identificationFiles.filter((item) => item.fileID !== pending.fileID);
|
||||
database.identificationFiles.push({
|
||||
...message,
|
||||
size: record.size,
|
||||
status: "pending",
|
||||
processedAt: null,
|
||||
expiresAt: null
|
||||
});
|
||||
}
|
||||
if (pending.configType && pending.parameters) {
|
||||
const config = {
|
||||
configType: pending.configType,
|
||||
parameters: pending.parameters,
|
||||
version: Date.now(),
|
||||
updateTime: new Date().toISOString()
|
||||
};
|
||||
database.functionConfigs = database.functionConfigs.filter(
|
||||
(item) => item.configType !== pending.configType
|
||||
);
|
||||
database.functionConfigs.push(config);
|
||||
}
|
||||
});
|
||||
const folderParts = pending.folder.split("/");
|
||||
if (folderParts.at(-1) === "identification_config") {
|
||||
logReceivedConfig({
|
||||
configType: "identification",
|
||||
deviceId: folderParts.slice(0, -1).join("/"),
|
||||
record: { fileID: pending.fileID, cloudPath: pending.cloudPath },
|
||||
config: req.file.buffer.toString("utf8").trim()
|
||||
});
|
||||
} else if (folderParts.at(-2) === "volume_config_requests") {
|
||||
let config;
|
||||
try {
|
||||
config = JSON.parse(req.file.buffer.toString("utf8"));
|
||||
} catch {
|
||||
config = "<invalid JSON>";
|
||||
}
|
||||
logReceivedConfig({
|
||||
configType: "volume",
|
||||
deviceId: folderParts.slice(0, -2).join("/"),
|
||||
requestId: folderParts.at(-1),
|
||||
record: { fileID: pending.fileID, cloudPath: pending.cloudPath },
|
||||
config
|
||||
});
|
||||
}
|
||||
pendingUploads.delete(req.params.token);
|
||||
res.status(204).end();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/files/:fileID", async (req, res, next) => {
|
||||
try {
|
||||
const fileID = decodeURIComponent(req.params.fileID);
|
||||
if (!fileID.startsWith("model://") && !hasValidDownloadToken(req, fileID)) {
|
||||
return res.status(403).json({ success: false, errMsg: "下载凭证无效或已过期" });
|
||||
}
|
||||
const database = await store.read();
|
||||
const record = database.fileRecords.find((item) => item.fileID === fileID);
|
||||
const filePath = record && store.resolveStoredFile(fileID);
|
||||
if (!record || !filePath) return res.status(404).json({ success: false, errMsg: "文件不存在" });
|
||||
await fs.promises.access(filePath);
|
||||
res.download(filePath, record.fileName);
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") return res.status(404).json({ success: false, errMsg: "文件不存在" });
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
const apiHandler = async (req, res) => {
|
||||
try {
|
||||
res.json(await dispatch(req.body || {}, req));
|
||||
} catch (error) {
|
||||
res.status(400).json({ success: false, errMsg: error.message });
|
||||
}
|
||||
};
|
||||
app.post("/", apiHandler);
|
||||
app.post("/api", apiHandler);
|
||||
|
||||
app.use((error, req, res, next) => {
|
||||
console.error(error);
|
||||
res.status(500).json({ success: false, errMsg: "服务器内部错误" });
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
module.exports = { createApp, validateConfigParameters };
|
||||
@@ -0,0 +1,110 @@
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const { randomUUID } = require("node:crypto");
|
||||
const { Pool } = require("pg");
|
||||
const { EMPTY_DATABASE } = require("./store");
|
||||
|
||||
function asIso(value) {
|
||||
return value instanceof Date ? value.toISOString() : value;
|
||||
}
|
||||
|
||||
function formatShanghaiTimestamp(value) {
|
||||
return new Intl.DateTimeFormat("sv-SE", {
|
||||
timeZone: "Asia/Shanghai", year: "numeric", month: "2-digit", day: "2-digit",
|
||||
hour: "2-digit", minute: "2-digit", hourCycle: "h23"
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
class PostgresStore {
|
||||
constructor(connectionString, filesDirectory) {
|
||||
this.pool = new Pool({ connectionString });
|
||||
this.filesDirectory = filesDirectory;
|
||||
this.modelsDirectory = path.join(path.dirname(filesDirectory), "models");
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
const migrationPath = path.join(__dirname, "..", "migrations", "001_normalized_schema.sql");
|
||||
await this.pool.query(await fs.promises.readFile(migrationPath, "utf8"));
|
||||
}
|
||||
|
||||
async read() {
|
||||
return this.readDatabase(this.pool);
|
||||
}
|
||||
|
||||
async readDatabase(queryable) {
|
||||
const companies = await queryable.query("SELECT id, name, code, created_at FROM companies");
|
||||
const productionLines = await queryable.query("SELECT id, company_id, name, code, device_id, created_at, last_seen_at FROM production_lines");
|
||||
const licenses = await queryable.query("SELECT licenses.license_id, licenses.company_id, licenses.production_line_id, licenses.device_id, licenses.customer, licenses.issued_at, licenses.expiry_at, licenses.features, licenses.license, licenses.status, licenses.created_at, licenses.revoked_at, licenses.revocation_reason, companies.name AS company_name, production_lines.name AS production_line_name FROM licenses JOIN companies ON companies.id = licenses.company_id JOIN production_lines ON production_lines.id = licenses.production_line_id");
|
||||
const fileRecords = await queryable.query("SELECT id, file_name, original_file_name, folder, cloud_path, file_id, upload_time, size_bytes FROM file_records");
|
||||
const functionConfigs = await queryable.query("SELECT config_type, parameters, version, update_time FROM function_configs");
|
||||
const panelInbox = await queryable.query("SELECT file_id, device_id, file_name, media_type, upload_time FROM panel_inbox");
|
||||
const identificationFiles = await queryable.query("SELECT file_id, device_id, file_name, media_type, upload_time, size_bytes, status, processed_at, expires_at FROM identification_files");
|
||||
const feedback = await queryable.query("SELECT device_id, run_id, file_name, status, result, update_time FROM identification_feedback");
|
||||
const requests = await queryable.query("SELECT device_id, request_id, status, created_at_ms, expires_at_ms, config_file_id, config_file_name, uploaded_at_ms, update_time FROM volume_config_requests");
|
||||
return {
|
||||
...structuredClone(EMPTY_DATABASE),
|
||||
companies: companies.rows.map((row) => ({ id: row.id, name: row.name, code: row.code, createdAt: asIso(row.created_at) })),
|
||||
productionLines: productionLines.rows.map((row) => ({ id: row.id, companyId: row.company_id, name: row.name, code: row.code, deviceId: row.device_id, createdAt: asIso(row.created_at), lastSeenAt: row.last_seen_at && asIso(row.last_seen_at) })),
|
||||
licenses: licenses.rows.map((row) => ({ licenseId: row.license_id, companyId: row.company_id, productionLineId: row.production_line_id, companyName: row.company_name, productionLineName: row.production_line_name, deviceId: row.device_id, customer: row.customer, issued: formatShanghaiTimestamp(row.issued_at), expiry: formatShanghaiTimestamp(row.expiry_at), issuedAt: asIso(row.issued_at), expiryAt: asIso(row.expiry_at), features: row.features, license: row.license, status: row.status, createdAt: asIso(row.created_at), revokedAt: row.revoked_at && asIso(row.revoked_at), revocationReason: row.revocation_reason })),
|
||||
fileRecords: fileRecords.rows.map((row) => ({ _id: row.id, fileName: row.file_name, originalFileName: row.original_file_name || undefined, folder: row.folder, cloudPath: row.cloud_path, fileID: row.file_id, uploadTime: asIso(row.upload_time), size: Number(row.size_bytes) })),
|
||||
functionConfigs: functionConfigs.rows.map((row) => ({ configType: row.config_type, parameters: row.parameters, version: Number(row.version), updateTime: asIso(row.update_time) })),
|
||||
panelInbox: panelInbox.rows.map((row) => ({ fileID: row.file_id, deviceId: row.device_id, fileName: row.file_name, mediaType: row.media_type, uploadTime: asIso(row.upload_time) })),
|
||||
identificationFiles: identificationFiles.rows.map((row) => ({ fileID: row.file_id, deviceId: row.device_id, fileName: row.file_name, mediaType: row.media_type, uploadTime: asIso(row.upload_time), size: Number(row.size_bytes), status: row.status, processedAt: row.processed_at && asIso(row.processed_at), expiresAt: row.expires_at && asIso(row.expires_at) })),
|
||||
identificationFeedback: feedback.rows.map((row) => ({ deviceId: row.device_id, runId: row.run_id, fileName: row.file_name, status: row.status, result: row.result, updateTime: asIso(row.update_time) })),
|
||||
volumeConfigRequests: requests.rows.map((row) => ({ deviceId: row.device_id, requestId: row.request_id, status: row.status, createdAtMs: Number(row.created_at_ms), expiresAtMs: Number(row.expires_at_ms), configFileID: row.config_file_id, configFileName: row.config_file_name, uploadedAtMs: row.uploaded_at_ms && Number(row.uploaded_at_ms), updateTime: asIso(row.update_time) }))
|
||||
};
|
||||
}
|
||||
|
||||
async update(mutator) {
|
||||
const client = await this.pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
await client.query("SELECT pg_advisory_xact_lock(81720260725)");
|
||||
const database = await this.readDatabase(client);
|
||||
const response = await mutator(database);
|
||||
await this.writeDatabase(client, database);
|
||||
await client.query("COMMIT");
|
||||
return response;
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK");
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async writeDatabase(client, database) {
|
||||
await client.query("DELETE FROM volume_config_requests; DELETE FROM identification_feedback; DELETE FROM panel_inbox; DELETE FROM identification_files; DELETE FROM function_configs; DELETE FROM file_records; DELETE FROM licenses; DELETE FROM production_lines; DELETE FROM companies;");
|
||||
for (const item of database.companies) await client.query("INSERT INTO companies (id, name, code, created_at) VALUES ($1, $2, $3, $4)", [item.id, item.name, item.code, item.createdAt]);
|
||||
for (const item of database.productionLines) await client.query("INSERT INTO production_lines (id, company_id, name, code, device_id, created_at, last_seen_at) VALUES ($1, $2, $3, $4, $5, $6, $7)", [item.id, item.companyId, item.name, item.code, item.deviceId, item.createdAt, item.lastSeenAt]);
|
||||
for (const item of database.licenses) await client.query("INSERT INTO licenses (license_id, company_id, production_line_id, device_id, customer, issued_at, expiry_at, features, license, status, created_at, revoked_at, revocation_reason) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)", [item.licenseId, item.companyId, item.productionLineId, item.deviceId, item.customer, item.issuedAt, item.expiryAt, item.features, item.license, item.status, item.createdAt, item.revokedAt, item.revocationReason]);
|
||||
for (const item of database.fileRecords) await client.query("INSERT INTO file_records (id, file_name, original_file_name, folder, cloud_path, file_id, upload_time, size_bytes) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)", [item._id, item.fileName, item.originalFileName || null, item.folder, item.cloudPath, item.fileID, item.uploadTime, item.size]);
|
||||
for (const item of database.functionConfigs) await client.query("INSERT INTO function_configs (config_type, parameters, version, update_time) VALUES ($1,$2,$3,$4)", [item.configType, item.parameters, item.version, item.updateTime]);
|
||||
for (const item of database.panelInbox) await client.query("INSERT INTO panel_inbox (file_id, device_id, file_name, media_type, upload_time) VALUES ($1,$2,$3,$4,$5)", [item.fileID, item.deviceId, item.fileName, item.mediaType, item.uploadTime]);
|
||||
for (const item of database.identificationFiles) await client.query("INSERT INTO identification_files (file_id, device_id, file_name, media_type, upload_time, size_bytes, status, processed_at, expires_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)", [item.fileID, item.deviceId, item.fileName, item.mediaType, item.uploadTime, item.size, item.status, item.processedAt, item.expiresAt]);
|
||||
for (const item of database.identificationFeedback) await client.query("INSERT INTO identification_feedback (device_id, run_id, file_name, status, result, update_time) VALUES ($1,$2,$3,$4,$5,$6)", [item.deviceId, item.runId, item.fileName, item.status, item.result, item.updateTime]);
|
||||
for (const item of database.volumeConfigRequests) await client.query("INSERT INTO volume_config_requests (device_id, request_id, status, created_at_ms, expires_at_ms, config_file_id, config_file_name, uploaded_at_ms, update_time) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)", [item.deviceId, item.requestId, item.status, item.createdAtMs, item.expiresAtMs, item.configFileID, item.configFileName, item.uploadedAtMs, item.updateTime]);
|
||||
}
|
||||
|
||||
createId(prefix) {
|
||||
return `${prefix}_${randomUUID()}`;
|
||||
}
|
||||
|
||||
resolveStoredFile(fileID) {
|
||||
if (typeof fileID !== "string") return null;
|
||||
const isModel = fileID.startsWith("model://");
|
||||
if (!isModel && !fileID.startsWith("local://")) return null;
|
||||
const rootDirectory = isModel ? this.modelsDirectory : this.filesDirectory;
|
||||
const relativePath = fileID.slice(isModel ? "model://".length : "local://".length).replace(/\\/g, "/");
|
||||
const absolutePath = path.resolve(rootDirectory, relativePath);
|
||||
const relativeToRoot = path.relative(rootDirectory, absolutePath);
|
||||
if (relativeToRoot.startsWith("..") || path.isAbsolute(relativeToRoot)) return null;
|
||||
return absolutePath;
|
||||
}
|
||||
|
||||
async close() {
|
||||
await this.pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { PostgresStore };
|
||||
@@ -0,0 +1,51 @@
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const { createApp } = require("./app");
|
||||
const { JsonStore } = require("./store");
|
||||
const { PostgresStore } = require("./postgres-store");
|
||||
|
||||
const host = process.env.HOST || "127.0.0.1";
|
||||
const port = Number(process.env.PORT || 3000);
|
||||
const identificationPurgeIntervalMs = Number(process.env.IDENTIFICATION_PURGE_INTERVAL_MS || 60 * 60 * 1000);
|
||||
const dataDirectory = path.resolve(process.env.DATA_DIR || path.join(__dirname, "..", "data"));
|
||||
|
||||
async function main() {
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("PORT 必须是有效端口号");
|
||||
const jsonDatabasePath = path.join(dataDirectory, "database.json");
|
||||
const hasExistingJsonStore = await fs.promises.access(jsonDatabasePath).then(() => true, () => false);
|
||||
if (process.env.NODE_ENV === "production" && !process.env.DATABASE_URL && !hasExistingJsonStore) {
|
||||
throw new Error("生产环境必须配置 DATABASE_URL");
|
||||
}
|
||||
if (process.env.NODE_ENV === "production" && !process.env.DATABASE_URL) {
|
||||
console.warn("警告: 正在使用既有 JSON 数据库;请尽快迁移到 PostgreSQL");
|
||||
}
|
||||
const filesDirectory = path.join(dataDirectory, "files");
|
||||
await fs.promises.mkdir(filesDirectory, { recursive: true });
|
||||
const store = process.env.DATABASE_URL
|
||||
? new PostgresStore(process.env.DATABASE_URL, filesDirectory)
|
||||
: new JsonStore(dataDirectory);
|
||||
await store.initialize();
|
||||
const app = createApp({ store });
|
||||
const purgeIdentificationFiles = async () => {
|
||||
try {
|
||||
const purged = await app.locals.purgeExpiredIdentificationFiles();
|
||||
if (purged) console.info(`[retention] purged ${purged} expired identification file(s)`);
|
||||
} catch (error) {
|
||||
console.error("[retention] identification file purge failed", error);
|
||||
}
|
||||
};
|
||||
await purgeIdentificationFiles();
|
||||
const purgeTimer = setInterval(purgeIdentificationFiles, identificationPurgeIntervalMs);
|
||||
purgeTimer.unref();
|
||||
app.listen(port, host, () => {
|
||||
console.log(`ReinLoop server listening on http://${host}:${port}`);
|
||||
console.log(`API endpoints: http://${host}:${port} and http://${host}:${port}/api`);
|
||||
console.log(`Data directory: ${dataDirectory}`);
|
||||
console.log(`Metadata store: ${process.env.DATABASE_URL ? "PostgreSQL" : "local JSON"}`);
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { randomUUID } = require("node:crypto");
|
||||
|
||||
const EMPTY_DATABASE = {
|
||||
fileRecords: [],
|
||||
functionConfigs: [],
|
||||
panelInbox: [],
|
||||
identificationFiles: [],
|
||||
identificationFeedback: [],
|
||||
volumeConfigRequests: [],
|
||||
userInfo: [],
|
||||
companies: [],
|
||||
productionLines: [],
|
||||
licenses: []
|
||||
};
|
||||
|
||||
class JsonStore {
|
||||
constructor(dataDirectory) {
|
||||
this.dataDirectory = dataDirectory;
|
||||
this.filesDirectory = path.join(dataDirectory, "files");
|
||||
this.modelsDirectory = path.join(dataDirectory, "models");
|
||||
this.databasePath = path.join(dataDirectory, "database.json");
|
||||
this.writeQueue = Promise.resolve();
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
await fs.promises.mkdir(this.filesDirectory, { recursive: true });
|
||||
try {
|
||||
await fs.promises.access(this.databasePath);
|
||||
} catch {
|
||||
await this.writeDatabase(structuredClone(EMPTY_DATABASE));
|
||||
}
|
||||
}
|
||||
|
||||
async read() {
|
||||
const content = await fs.promises.readFile(this.databasePath, "utf8");
|
||||
return { ...structuredClone(EMPTY_DATABASE), ...JSON.parse(content) };
|
||||
}
|
||||
|
||||
async update(mutator) {
|
||||
const operation = this.writeQueue.then(async () => {
|
||||
const database = await this.read();
|
||||
const result = await mutator(database);
|
||||
await this.writeDatabase(database);
|
||||
return result;
|
||||
});
|
||||
this.writeQueue = operation.catch(() => undefined);
|
||||
return operation;
|
||||
}
|
||||
|
||||
async writeDatabase(database) {
|
||||
await fs.promises.mkdir(this.dataDirectory, { recursive: true });
|
||||
const temporaryPath = `${this.databasePath}.${process.pid}.tmp`;
|
||||
await fs.promises.writeFile(
|
||||
temporaryPath,
|
||||
`${JSON.stringify(database, null, 2)}\n`,
|
||||
"utf8"
|
||||
);
|
||||
await fs.promises.rename(temporaryPath, this.databasePath);
|
||||
}
|
||||
|
||||
createId(prefix) {
|
||||
return `${prefix}_${randomUUID()}`;
|
||||
}
|
||||
|
||||
resolveStoredFile(fileID) {
|
||||
if (typeof fileID !== "string") return null;
|
||||
const isModel = fileID.startsWith("model://");
|
||||
if (!isModel && !fileID.startsWith("local://")) return null;
|
||||
const rootDirectory = isModel ? this.modelsDirectory : this.filesDirectory;
|
||||
const relativePath = fileID.slice(isModel ? "model://".length : "local://".length).replace(/\\/g, "/");
|
||||
const absolutePath = path.resolve(rootDirectory, relativePath);
|
||||
const relativeToRoot = path.relative(rootDirectory, absolutePath);
|
||||
if (relativeToRoot.startsWith("..") || path.isAbsolute(relativeToRoot)) return null;
|
||||
return absolutePath;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { EMPTY_DATABASE, JsonStore };
|
||||
@@ -0,0 +1,665 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { after, before, test } = require("node:test");
|
||||
|
||||
const { createApp } = require("../src/app");
|
||||
const { JsonStore } = require("../src/store");
|
||||
|
||||
let baseUrl;
|
||||
let dataDirectory;
|
||||
let server;
|
||||
let licensePrivateKey;
|
||||
let licensePublicKeyPath;
|
||||
|
||||
async function post(payload) {
|
||||
const response = await fetch(`${baseUrl}/api`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function signLicense(payload) {
|
||||
const payloadBase64 = Buffer.from(JSON.stringify(payload)).toString("base64");
|
||||
const signature = crypto.sign("sha256", Buffer.from(payloadBase64), {
|
||||
key: licensePrivateKey,
|
||||
padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
|
||||
saltLength: crypto.constants.RSA_PSS_SALTLEN_MAX
|
||||
});
|
||||
return `${payloadBase64}|${signature.toString("base64")}`;
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
dataDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), "reinloop-server-"));
|
||||
const keyPair = crypto.generateKeyPairSync("rsa", { modulusLength: 2048 });
|
||||
licensePrivateKey = keyPair.privateKey;
|
||||
licensePublicKeyPath = path.join(dataDirectory, "license-public.pem");
|
||||
await fs.promises.writeFile(licensePublicKeyPath, keyPair.publicKey.export({ type: "spki", format: "pem" }));
|
||||
const store = new JsonStore(dataDirectory);
|
||||
await store.initialize();
|
||||
const app = createApp({ store, adminToken: "test-token", licensePublicKeyPath });
|
||||
await new Promise((resolve) => {
|
||||
server = app.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
baseUrl = `http://127.0.0.1:${server.address().port}`;
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
||||
await fs.promises.rm(dataDirectory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("health endpoint reports ready", async () => {
|
||||
const response = await fetch(`${baseUrl}/health`);
|
||||
assert.deepEqual(await response.json(), { success: true, service: "reinloop-server" });
|
||||
});
|
||||
|
||||
test("root endpoint accepts API requests without the /api suffix", async () => {
|
||||
const response = await fetch(baseUrl, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ type: "listOrganizations", adminToken: "test-token" })
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(await response.json(), { success: true, companies: [] });
|
||||
});
|
||||
|
||||
test("legacy data_record endpoint and uploadUserInfo type are unavailable", async () => {
|
||||
const legacyRoute = await fetch(`${baseUrl}/data_record`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ type: "listOrganizations", adminToken: "test-token" })
|
||||
});
|
||||
assert.equal(legacyRoute.status, 404);
|
||||
|
||||
const legacyType = await post({
|
||||
type: "uploadUserInfo", _id: "legacy", username: "legacy",
|
||||
issued: "2026-07-25 12:00", expiry: "2027-07-25 12:00", license: "legacy"
|
||||
});
|
||||
assert.equal(legacyType.success, false);
|
||||
assert.match(legacyType.errMsg, /无效/);
|
||||
});
|
||||
|
||||
test("existing two-step client flow uploads, lists, and downloads a file", async () => {
|
||||
const issued = await post({ type: "uploadDataFile", fileName: "result.csv", folder: "customer/line-1/ind_data" });
|
||||
assert.equal(issued.success, true);
|
||||
assert.ok(issued.uploadMetadata.authorization);
|
||||
|
||||
const form = new FormData();
|
||||
form.append("key", issued.uploadMetadata.cosFileId);
|
||||
form.append("Signature", issued.uploadMetadata.authorization);
|
||||
form.append("x-cos-security-token", issued.uploadMetadata.token);
|
||||
form.append("x-cos-meta-fileid", issued.uploadMetadata.fileId);
|
||||
form.append("file", new Blob(["time,pressure\n0,10\n"], { type: "text/csv" }), "result.csv");
|
||||
const uploaded = await fetch(issued.uploadMetadata.url, { method: "POST", body: form });
|
||||
assert.equal(uploaded.status, 204);
|
||||
|
||||
const listed = await post({ type: "listModels", folder: "customer/line-1/ind_data" });
|
||||
assert.deepEqual(listed.files, ["result.csv"]);
|
||||
assert.equal(listed.fileList[0].fileID, issued.fileID);
|
||||
|
||||
const directDownload = await fetch(`${baseUrl}/files/${encodeURIComponent(issued.fileID)}`);
|
||||
assert.equal(directDownload.status, 403);
|
||||
const rejectedDownload = await post({ type: "downloadModel", fileID: issued.fileID });
|
||||
assert.equal(rejectedDownload.success, false);
|
||||
const download = await post({
|
||||
type: "downloadModel", fileID: issued.fileID, adminToken: "test-token"
|
||||
});
|
||||
const downloaded = await fetch(download.url);
|
||||
assert.equal(await downloaded.text(), "time,pressure\n0,10\n");
|
||||
});
|
||||
|
||||
test("admin lists, downloads, and deletes only the selected device control data", async () => {
|
||||
async function uploadControlData(deviceId, fileName, content) {
|
||||
const issued = await post({
|
||||
type: "uploadDataFile", fileName, folder: `${deviceId}/data_record/run-1`
|
||||
});
|
||||
assert.equal(issued.success, true);
|
||||
const form = new FormData();
|
||||
form.append("file", new Blob([content]), fileName);
|
||||
assert.equal((await fetch(issued.uploadMetadata.url, { method: "POST", body: form })).status, 204);
|
||||
return issued;
|
||||
}
|
||||
|
||||
const deviceId = "control-co/line-1";
|
||||
const first = await uploadControlData(deviceId, "episode_part1.pkl", "part-1");
|
||||
const second = await uploadControlData(deviceId, "episode_manifest.json", '{"parts":1}');
|
||||
await uploadControlData("other-co/line-2", "other.pkl", "other");
|
||||
|
||||
const rejectedList = await post({ type: "listControlFiles", deviceId });
|
||||
assert.equal(rejectedList.success, false);
|
||||
const listed = await post({
|
||||
type: "listControlFiles", deviceId, page: 1, pageSize: 500, adminToken: "test-token"
|
||||
});
|
||||
assert.equal(listed.total, 2);
|
||||
assert.equal(listed.pageSize, 100);
|
||||
assert.deepEqual(new Set(listed.files.map((item) => item.fileID)), new Set([first.fileID, second.fileID]));
|
||||
|
||||
const forbiddenDownload = await post({
|
||||
type: "getControlFileDownload", fileID: "local://ReinLoop_GUI/other-co/line-2/ind_data/result.csv",
|
||||
adminToken: "test-token"
|
||||
});
|
||||
assert.equal(forbiddenDownload.success, false);
|
||||
const download = await post({
|
||||
type: "getControlFileDownload", fileID: second.fileID, adminToken: "test-token"
|
||||
});
|
||||
assert.equal(download.success, true);
|
||||
assert.equal(download.size, Buffer.byteLength('{"parts":1}'));
|
||||
assert.match(download.url, /expires=.*token=/);
|
||||
assert.equal(await (await fetch(download.url)).text(), '{"parts":1}');
|
||||
|
||||
const forbiddenDelete = await post({
|
||||
type: "deleteControlFile", fileID: "model://control-co/line-1/controller.bin", adminToken: "test-token"
|
||||
});
|
||||
assert.equal(forbiddenDelete.success, false);
|
||||
const deleted = await post({ type: "deleteControlFile", fileID: first.fileID, adminToken: "test-token" });
|
||||
assert.deepEqual(deleted, { success: true, deletedCount: 1 });
|
||||
assert.equal((await post({
|
||||
type: "deleteControlFile", fileID: first.fileID, adminToken: "test-token"
|
||||
})).success, false);
|
||||
});
|
||||
|
||||
test("model uploads are stored under data/models/company/line", async () => {
|
||||
const issued = await post({
|
||||
type: "issueModelUpload", deviceId: "company-a/line-1",
|
||||
fileName: "controller.bin", adminToken: "test-token"
|
||||
});
|
||||
assert.equal(issued.fileID, "model://company-a/line-1/controller.bin");
|
||||
|
||||
const form = new FormData();
|
||||
form.append("file", new Blob(["model-content"]), "controller.bin");
|
||||
assert.equal((await fetch(issued.uploadMetadata.url, { method: "POST", body: form })).status, 204);
|
||||
assert.equal(
|
||||
await fs.promises.readFile(path.join(dataDirectory, "models", "company-a", "line-1", "controller.bin"), "utf8"),
|
||||
"model-content"
|
||||
);
|
||||
|
||||
const listed = await post({ type: "listModels", folder: "company-a/line-1/model_config" });
|
||||
assert.equal(listed.fileList[0].fileID, issued.fileID);
|
||||
});
|
||||
|
||||
test("model upload supports renaming while preserving the original file name", async () => {
|
||||
const issued = await post({
|
||||
type: "issueModelUpload", deviceId: "company-a/rename-line",
|
||||
fileName: "controller-original.bin", modelName: "pressure-controller-v2.bin",
|
||||
adminToken: "test-token"
|
||||
});
|
||||
assert.equal(issued.success, true);
|
||||
assert.equal(issued.fileName, "pressure-controller-v2.bin");
|
||||
assert.equal(issued.originalFileName, "controller-original.bin");
|
||||
assert.equal(issued.fileID, "model://company-a/rename-line/pressure-controller-v2.bin");
|
||||
|
||||
const form = new FormData();
|
||||
form.append("file", new Blob(["renamed-model"]), "controller-original.bin");
|
||||
assert.equal((await fetch(issued.uploadMetadata.url, { method: "POST", body: form })).status, 204);
|
||||
|
||||
const listed = await post({
|
||||
type: "listModels", folder: "company-a/rename-line/model_config"
|
||||
});
|
||||
assert.deepEqual(listed.files, ["pressure-controller-v2.bin"]);
|
||||
assert.equal(listed.fileList[0].fileName, "pressure-controller-v2.bin");
|
||||
assert.equal(listed.fileList[0].originalFileName, "controller-original.bin");
|
||||
assert.equal(
|
||||
await fs.promises.readFile(path.join(dataDirectory, "models", "company-a", "rename-line", "pressure-controller-v2.bin"), "utf8"),
|
||||
"renamed-model"
|
||||
);
|
||||
|
||||
const invalid = await post({
|
||||
type: "issueModelUpload", deviceId: "company-a/rename-line",
|
||||
fileName: "controller.bin", modelName: "controller.zip",
|
||||
adminToken: "test-token"
|
||||
});
|
||||
assert.equal(invalid.success, false);
|
||||
assert.match(invalid.errMsg, /扩展名/);
|
||||
});
|
||||
|
||||
test("model upload requires explicit overwrite for an existing file", async () => {
|
||||
const request = {
|
||||
type: "issueModelUpload", deviceId: "company-a/overwrite-line",
|
||||
fileName: "controller.bin", adminToken: "test-token"
|
||||
};
|
||||
const issued = await post(request);
|
||||
const firstForm = new FormData();
|
||||
firstForm.append("file", new Blob(["first-version"]), "controller.bin");
|
||||
assert.equal((await fetch(issued.uploadMetadata.url, { method: "POST", body: firstForm })).status, 204);
|
||||
|
||||
const conflict = await post(request);
|
||||
assert.equal(conflict.success, false);
|
||||
assert.equal(conflict.conflict, true);
|
||||
assert.equal(conflict.existing.fileID, issued.fileID);
|
||||
|
||||
const replacement = await post({ ...request, overwrite: true });
|
||||
assert.equal(replacement.success, true);
|
||||
const replacementForm = new FormData();
|
||||
replacementForm.append("file", new Blob(["second-version"]), "controller.bin");
|
||||
assert.equal((await fetch(replacement.uploadMetadata.url, {
|
||||
method: "POST", body: replacementForm
|
||||
})).status, 204);
|
||||
assert.equal(
|
||||
await fs.promises.readFile(path.join(dataDirectory, "models", "company-a", "overwrite-line", "controller.bin"), "utf8"),
|
||||
"second-version"
|
||||
);
|
||||
});
|
||||
|
||||
test("panel consumes uploaded device files from an inbox without scanning folders", async () => {
|
||||
const deviceId = "panel-company/panel-line";
|
||||
const issued = await post({
|
||||
type: "uploadDataFile", fileName: "result_20260724_120000.csv",
|
||||
folder: `${deviceId}/ind_data`
|
||||
});
|
||||
const form = new FormData();
|
||||
form.append("file", new Blob(["t,u,p\n0,10,20\n"], { type: "text/csv" }), issued.fileID);
|
||||
assert.equal((await fetch(issued.uploadMetadata.url, { method: "POST", body: form })).status, 204);
|
||||
|
||||
const beforeRegistration = await post({
|
||||
type: "getPendingPanelFile", deviceId, adminToken: "test-token"
|
||||
});
|
||||
assert.equal(beforeRegistration.pending, false);
|
||||
|
||||
await post({
|
||||
type: "registerIdentificationResult", deviceId,
|
||||
runId: "result_20260724_120000.csv"
|
||||
});
|
||||
const pending = await post({
|
||||
type: "getPendingPanelFile", deviceId, adminToken: "test-token"
|
||||
});
|
||||
assert.equal(pending.pending, true);
|
||||
assert.equal(pending.fileName, "result_20260724_120000.csv");
|
||||
assert.equal(await (await fetch(pending.url)).text(), "t,u,p\n0,10,20\n");
|
||||
|
||||
assert.equal((await post({
|
||||
type: "ackPanelFile", deviceId, fileID: pending.fileID,
|
||||
adminToken: "test-token"
|
||||
})).deleted, 1);
|
||||
assert.equal((await post({
|
||||
type: "getPendingPanelFile", deviceId, adminToken: "test-token"
|
||||
})).pending, false);
|
||||
|
||||
const jsonIssued = await post({
|
||||
type: "uploadDataFile", fileName: "travel_stability_pressures_20260724_120000.json",
|
||||
folder: `${deviceId}/ind_data`
|
||||
});
|
||||
const jsonForm = new FormData();
|
||||
jsonForm.append("file", new Blob(['{"stable_pressures":[]}'], {
|
||||
type: "application/json"
|
||||
}), jsonIssued.fileID);
|
||||
assert.equal((await fetch(jsonIssued.uploadMetadata.url, {
|
||||
method: "POST", body: jsonForm
|
||||
})).status, 204);
|
||||
const jsonPending = await post({
|
||||
type: "getPendingPanelFile", deviceId, adminToken: "test-token"
|
||||
});
|
||||
assert.equal(jsonPending.pending, true);
|
||||
assert.equal(jsonPending.mediaType, "json");
|
||||
|
||||
const rejectedHistory = await post({ type: "listIdentificationFiles", deviceId });
|
||||
assert.equal(rejectedHistory.success, false);
|
||||
const history = await post({
|
||||
type: "listIdentificationFiles", deviceId, adminToken: "test-token"
|
||||
});
|
||||
assert.equal(history.total, 2);
|
||||
const csvHistory = history.files.find((file) => file.fileID === pending.fileID);
|
||||
assert.equal(csvHistory.status, "processed");
|
||||
assert.ok(csvHistory.processedAt);
|
||||
assert.ok(csvHistory.expiresAt);
|
||||
|
||||
const rejectedHistoryDownload = await post({
|
||||
type: "getIdentificationFileDownload", fileID: pending.fileID
|
||||
});
|
||||
assert.equal(rejectedHistoryDownload.success, false);
|
||||
const historyDownload = await post({
|
||||
type: "getIdentificationFileDownload", fileID: pending.fileID,
|
||||
adminToken: "test-token"
|
||||
});
|
||||
assert.equal(await (await fetch(historyDownload.url)).text(), "t,u,p\n0,10,20\n");
|
||||
|
||||
const rejectedDelete = await post({
|
||||
type: "deleteIdentificationFile", fileID: jsonPending.fileID
|
||||
});
|
||||
assert.equal(rejectedDelete.success, false);
|
||||
const deleted = await post({
|
||||
type: "deleteIdentificationFile", fileID: jsonPending.fileID,
|
||||
adminToken: "test-token"
|
||||
});
|
||||
assert.equal(deleted.deletedCount, 1);
|
||||
assert.equal((await fetch(jsonPending.url)).status, 404);
|
||||
});
|
||||
|
||||
test("expired processed identification files are purged without affecting pending files", async () => {
|
||||
const deviceId = "retention-company/retention-line";
|
||||
const processedIssued = await post({
|
||||
type: "uploadDataFile", fileName: "processed.csv", folder: `${deviceId}/ind_data`
|
||||
});
|
||||
const processedForm = new FormData();
|
||||
processedForm.append("file", new Blob(["processed"]), "processed.csv");
|
||||
assert.equal((await fetch(processedIssued.uploadMetadata.url, {
|
||||
method: "POST", body: processedForm
|
||||
})).status, 204);
|
||||
await post({
|
||||
type: "ackPanelFile", deviceId, fileID: processedIssued.fileID,
|
||||
adminToken: "test-token"
|
||||
});
|
||||
|
||||
const pendingIssued = await post({
|
||||
type: "uploadDataFile", fileName: "pending.json", folder: `${deviceId}/ind_data`
|
||||
});
|
||||
const pendingForm = new FormData();
|
||||
pendingForm.append("file", new Blob(["{}"]), "pending.json");
|
||||
assert.equal((await fetch(pendingIssued.uploadMetadata.url, {
|
||||
method: "POST", body: pendingForm
|
||||
})).status, 204);
|
||||
|
||||
const store = new JsonStore(dataDirectory);
|
||||
await store.update((database) => {
|
||||
const record = database.identificationFiles.find((item) => item.fileID === processedIssued.fileID);
|
||||
record.expiresAt = new Date(Date.now() - 1000).toISOString();
|
||||
});
|
||||
const history = await post({
|
||||
type: "listIdentificationFiles", deviceId, adminToken: "test-token"
|
||||
});
|
||||
assert.deepEqual(history.files.map((file) => file.fileID), [pendingIssued.fileID]);
|
||||
assert.equal(history.files[0].status, "pending");
|
||||
assert.equal(await fs.promises.access(
|
||||
path.join(dataDirectory, "files", "ReinLoop_GUI", deviceId, "ind_data", "processed.csv")
|
||||
).then(() => true, () => false), false);
|
||||
});
|
||||
|
||||
test("identification feedback supports register, publish, poll, and acknowledge", async () => {
|
||||
assert.equal((await post({
|
||||
type: "registerIdentificationResult", deviceId: "customer-a/line-a", runId: "run-1"
|
||||
})).success, true);
|
||||
assert.deepEqual(await post({
|
||||
type: "getIdentificationFeedback", deviceId: "customer-a/line-a", runId: "run-1"
|
||||
}), { success: true, ready: false });
|
||||
|
||||
const published = await post({
|
||||
type: "setIdentificationFeedback", deviceId: "customer-a/line-a", runId: "run-1",
|
||||
result: 1, adminToken: "test-token"
|
||||
});
|
||||
assert.equal(published.result, 1);
|
||||
assert.equal((await post({
|
||||
type: "getIdentificationFeedback", deviceId: "customer-a/line-a", runId: "run-1"
|
||||
})).result, 1);
|
||||
assert.equal((await post({
|
||||
type: "ackIdentificationFeedback", deviceId: "customer-a/line-a", runId: "run-1"
|
||||
})).deleted, 1);
|
||||
});
|
||||
|
||||
test("control panel publishes the device identification CSV consumed by ReinLoop", async () => {
|
||||
const parameters = {
|
||||
q_in_val: 91, dt: 0.1, n_order: 8, t_c: 2.5,
|
||||
levels: [10, 20, 30, 40, 50, 60, 70, 80],
|
||||
dead_area: 0, xa_full: 1000, V_val: 1, repeat: 2
|
||||
};
|
||||
const issued = await post({
|
||||
type: "publishIdentificationConfig", deviceId: "customer-a/line-a",
|
||||
parameters, adminToken: "test-token"
|
||||
});
|
||||
assert.equal(issued.success, true);
|
||||
|
||||
const invalid = await post({
|
||||
type: "publishIdentificationConfig", deviceId: "customer-a/line-a",
|
||||
parameters: { ...parameters, levels: [1000, 900, 800] },
|
||||
adminToken: "test-token"
|
||||
});
|
||||
assert.equal(invalid.success, false);
|
||||
assert.match(invalid.errMsg, /levels/);
|
||||
|
||||
const csv = [
|
||||
"parameter,value",
|
||||
"q_in_val,91",
|
||||
"dt,0.1",
|
||||
"n_order,8",
|
||||
"t_c,2.5",
|
||||
'levels,"10,20,30,40,50,60,70,80"',
|
||||
"dead_area,0",
|
||||
"xa_full,1000",
|
||||
"V_val,1",
|
||||
"repeat,2",
|
||||
""
|
||||
].join("\n");
|
||||
const form = new FormData();
|
||||
form.append("file", new Blob([csv], { type: "text/csv" }), "identification_config.csv");
|
||||
assert.equal((await fetch(issued.uploadMetadata.url, {
|
||||
method: "POST", body: form
|
||||
})).status, 204);
|
||||
|
||||
const available = await post({
|
||||
type: "getIdentificationConfig", deviceId: "customer-a/line-a"
|
||||
});
|
||||
assert.equal(available.success, true);
|
||||
assert.equal(
|
||||
available.cloudPath,
|
||||
"ReinLoop_GUI/customer-a/line-a/identification_config/identification_config.csv"
|
||||
);
|
||||
assert.equal(await (await fetch(available.url)).text(), csv);
|
||||
});
|
||||
|
||||
test("volume publishing becomes readable only after the file upload completes", async () => {
|
||||
const parameters = {
|
||||
q_in_val: 91, dt: 0.1, xa_full: 1000, p_max: 200,
|
||||
fit_low: 50, fit_high: 200, T_delta: 30, num_runs: 6
|
||||
};
|
||||
const issued = await post({
|
||||
type: "publishVolumeConfig", parameters, adminToken: "test-token"
|
||||
});
|
||||
const beforeUpload = await post({ type: "getFunctionConfig", configType: "volume" });
|
||||
assert.equal(beforeUpload.notFound, true);
|
||||
|
||||
const form = new FormData();
|
||||
form.append("file", new Blob([JSON.stringify(parameters)], {
|
||||
type: "application/json"
|
||||
}), "volume_config.json");
|
||||
assert.equal((await fetch(issued.uploadMetadata.url, {
|
||||
method: "POST", body: form
|
||||
})).status, 204);
|
||||
|
||||
const published = await post({ type: "getFunctionConfig", configType: "volume" });
|
||||
assert.equal(published.success, true);
|
||||
assert.deepEqual(published.parameters, parameters);
|
||||
});
|
||||
|
||||
test("volume request accepts only the file uploaded for that request", async () => {
|
||||
const request = await post({ type: "createVolumeConfigRequest", deviceId: "customer-a/line-a" });
|
||||
const folder = `customer-a/line-a/volume_config_requests/${request.requestId}`;
|
||||
const issued = await post({ type: "uploadDataFile", fileName: "volume_measurement.json", folder });
|
||||
const form = new FormData();
|
||||
form.append("file", new Blob(["{\"num_runs\":2}"], { type: "application/json" }), "volume_measurement.json");
|
||||
assert.equal((await fetch(issued.uploadMetadata.url, { method: "POST", body: form })).status, 204);
|
||||
|
||||
const submitted = await post({
|
||||
type: "submitVolumeConfigFile", deviceId: "customer-a/line-a", requestId: request.requestId,
|
||||
fileID: issued.fileID, fileName: "volume_measurement.json"
|
||||
});
|
||||
assert.equal(submitted.success, true);
|
||||
const ready = await post({
|
||||
type: "getVolumeConfigRequest", deviceId: "customer-a/line-a", requestId: request.requestId
|
||||
});
|
||||
assert.equal(ready.ready, true);
|
||||
assert.equal(
|
||||
ready.cloudPath,
|
||||
`ReinLoop_GUI/customer-a/line-a/volume_config_requests/${request.requestId}/volume_measurement.json`
|
||||
);
|
||||
assert.deepEqual(await (await fetch(ready.url)).json(), { num_runs: 2 });
|
||||
});
|
||||
|
||||
test("admin manages companies and production lines with a stable device id", async () => {
|
||||
const unauthorized = await post({
|
||||
type: "createCompany", name: "未授权公司", code: "blocked"
|
||||
});
|
||||
assert.equal(unauthorized.success, false);
|
||||
|
||||
const company = await post({
|
||||
type: "createCompany", name: "示例公司", code: "sample-co",
|
||||
adminToken: "test-token"
|
||||
});
|
||||
assert.equal(company.success, true);
|
||||
assert.equal(company.company.code, "sample-co");
|
||||
|
||||
const line = await post({
|
||||
type: "createProductionLine", companyId: company.company.id,
|
||||
name: "一号产线", code: "line-1", adminToken: "test-token"
|
||||
});
|
||||
assert.equal(line.success, true);
|
||||
assert.equal(line.productionLine.deviceId, "sample-co/line-1");
|
||||
|
||||
const organizations = await post({
|
||||
type: "listOrganizations", adminToken: "test-token"
|
||||
});
|
||||
assert.equal(organizations.companies.length, 1);
|
||||
const listedLine = organizations.companies[0].productionLines[0];
|
||||
assert.equal(listedLine.id, line.productionLine.id);
|
||||
assert.equal(listedLine.online, false);
|
||||
assert.equal(listedLine.lastSeenAt, null);
|
||||
|
||||
const heartbeat = await post({ type: "deviceHeartbeat", deviceId: line.productionLine.deviceId });
|
||||
assert.equal(heartbeat.success, true);
|
||||
assert.equal(heartbeat.deviceId, line.productionLine.deviceId);
|
||||
assert.ok(heartbeat.lastSeenAt);
|
||||
|
||||
const onlineOrganizations = await post({ type: "listOrganizations", adminToken: "test-token" });
|
||||
const onlineLine = onlineOrganizations.companies[0].productionLines[0];
|
||||
assert.equal(onlineLine.online, true);
|
||||
assert.equal(onlineLine.lastSeenAt, heartbeat.lastSeenAt);
|
||||
|
||||
const store = new JsonStore(dataDirectory);
|
||||
await store.update((database) => {
|
||||
const storedLine = database.productionLines.find((item) => item.id === line.productionLine.id);
|
||||
storedLine.lastSeenAt = new Date(Date.now() - 30_001).toISOString();
|
||||
});
|
||||
const offlineOrganizations = await post({ type: "listOrganizations", adminToken: "test-token" });
|
||||
assert.equal(offlineOrganizations.companies[0].productionLines[0].online, false);
|
||||
|
||||
const unknownDevice = await post({ type: "deviceHeartbeat", deviceId: "unknown-co/unknown-line" });
|
||||
assert.equal(unknownDevice.success, false);
|
||||
});
|
||||
|
||||
test("model write operations require an admin token", async () => {
|
||||
const rejectedUpload = await post({
|
||||
type: "uploadDataFile", fileName: "model.bin", folder: "company-a/line-a/model_config"
|
||||
});
|
||||
assert.equal(rejectedUpload.success, false);
|
||||
|
||||
const issued = await post({
|
||||
type: "issueModelUpload", deviceId: "company-a/line-a", fileName: "model.bin",
|
||||
adminToken: "test-token"
|
||||
});
|
||||
assert.equal(issued.success, true);
|
||||
const form = new FormData();
|
||||
form.append("file", new Blob(["model"], { type: "application/octet-stream" }), "model.bin");
|
||||
assert.equal((await fetch(issued.uploadMetadata.url, { method: "POST", body: form })).status, 204);
|
||||
|
||||
const rejectedDelete = await post({ type: "deleteFile", fileID: issued.fileID });
|
||||
assert.equal(rejectedDelete.success, false);
|
||||
const deleted = await post({ type: "deleteModel", fileID: issued.fileID, adminToken: "test-token" });
|
||||
assert.equal(deleted.deletedCount, 1);
|
||||
});
|
||||
|
||||
test("issued licenses can be listed, validated, and revoked", async () => {
|
||||
const company = await post({
|
||||
type: "createCompany", name: "许可证公司", code: "licensed-co",
|
||||
adminToken: "test-token"
|
||||
});
|
||||
const line = await post({
|
||||
type: "createProductionLine", companyId: company.company.id,
|
||||
name: "测试线", code: "test-line", adminToken: "test-token"
|
||||
});
|
||||
const licenseId = crypto.randomUUID();
|
||||
const licensePayload = {
|
||||
license_id: licenseId,
|
||||
company_id: company.company.id,
|
||||
production_line_id: line.productionLine.id,
|
||||
customer: "许可证公司",
|
||||
device_id: "licensed-co/test-line",
|
||||
issued: "2026-07-25 12:00",
|
||||
expiry: "2027-07-25 12:00",
|
||||
features: "*"
|
||||
};
|
||||
const issued = await post({
|
||||
type: "createLicense", licenseId,
|
||||
companyId: company.company.id, productionLineId: line.productionLine.id,
|
||||
customer: "许可证公司", issued: "2026-07-25 12:00",
|
||||
expiry: "2027-07-25 12:00", features: "*",
|
||||
license: signLicense(licensePayload), adminToken: "test-token"
|
||||
});
|
||||
assert.equal(issued.success, true);
|
||||
assert.equal(issued.license.status, "active");
|
||||
assert.equal(issued.license.deviceId, "licensed-co/test-line");
|
||||
assert.equal("license" in issued.license, false);
|
||||
|
||||
const validation = await post({
|
||||
type: "validateLicense", licenseId,
|
||||
deviceId: "licensed-co/test-line"
|
||||
});
|
||||
assert.deepEqual(validation, {
|
||||
success: true, valid: true, status: "active", licenseId
|
||||
});
|
||||
|
||||
const listed = await post({ type: "listLicenses", adminToken: "test-token" });
|
||||
assert.equal(listed.licenses.length, 1);
|
||||
assert.equal(listed.licenses[0].customer, "许可证公司");
|
||||
|
||||
const revoked = await post({
|
||||
type: "revokeLicense", licenseId,
|
||||
reason: "合同终止", adminToken: "test-token"
|
||||
});
|
||||
assert.equal(revoked.license.status, "revoked");
|
||||
assert.equal(revoked.license.revocationReason, "合同终止");
|
||||
|
||||
const rejected = await post({
|
||||
type: "validateLicense", licenseId,
|
||||
deviceId: "licensed-co/test-line"
|
||||
});
|
||||
assert.equal(rejected.valid, false);
|
||||
assert.equal(rejected.status, "revoked");
|
||||
});
|
||||
|
||||
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"
|
||||
});
|
||||
const line = await post({
|
||||
type: "createProductionLine", companyId: company.company.id, name: "安全线", code: "secure-line",
|
||||
adminToken: "test-token"
|
||||
});
|
||||
const licenseId = crypto.randomUUID();
|
||||
const payload = {
|
||||
license_id: licenseId, company_id: company.company.id, production_line_id: line.productionLine.id,
|
||||
customer: "安全校验公司", device_id: line.productionLine.deviceId,
|
||||
issued: "2026-07-25 01:00", expiry: "2027-07-25 01:00", features: "*"
|
||||
};
|
||||
const request = {
|
||||
type: "createLicense", licenseId, companyId: company.company.id, productionLineId: line.productionLine.id,
|
||||
customer: payload.customer, issued: payload.issued, expiry: payload.expiry, features: "*",
|
||||
license: signLicense(payload), adminToken: "test-token"
|
||||
};
|
||||
assert.equal((await post(request)).success, true);
|
||||
assert.equal((await post(request)).idempotent, true);
|
||||
|
||||
const changed = await post({ ...request, customer: "已篡改客户" });
|
||||
assert.equal(changed.success, false);
|
||||
assert.match(changed.errMsg, /不一致/);
|
||||
|
||||
const invalidSignature = await post({
|
||||
...request, licenseId: crypto.randomUUID(), license: `${request.license}x`
|
||||
});
|
||||
assert.equal(invalidSignature.success, false);
|
||||
|
||||
const expiredId = crypto.randomUUID();
|
||||
const expiredPayload = {
|
||||
...payload, license_id: expiredId, issued: "2020-01-01 00:00", expiry: "2020-01-02 00:00"
|
||||
};
|
||||
const expired = await post({
|
||||
...request, licenseId: expiredId, issued: expiredPayload.issued, expiry: expiredPayload.expiry,
|
||||
license: signLicense(expiredPayload)
|
||||
});
|
||||
assert.equal(expired.success, true);
|
||||
assert.deepEqual(await post({
|
||||
type: "validateLicense", licenseId: expiredId, deviceId: line.productionLine.deviceId
|
||||
}), { success: true, valid: false, status: "expired", licenseId: expiredId });
|
||||
});
|
||||
Reference in New Issue
Block a user