add auth check
This commit is contained in:
@@ -39,21 +39,13 @@ async function callServer(payload, options = {}) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function downloadHeaders(options = {}) {
|
|
||||||
if (!options.adminToken) return {};
|
|
||||||
return {
|
|
||||||
authorization: `Bearer ${options.adminToken}`,
|
|
||||||
"x-admin-token": options.adminToken
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function downloadFromUrl(url, fileName, options = {}) {
|
async function downloadFromUrl(url, fileName, options = {}) {
|
||||||
await fs.promises.mkdir(DOWNLOAD_DIR, { recursive: true });
|
await fs.promises.mkdir(DOWNLOAD_DIR, { recursive: true });
|
||||||
return downloadToPath(url, path.join(DOWNLOAD_DIR, path.basename(fileName)), options);
|
return downloadToPath(url, path.join(DOWNLOAD_DIR, path.basename(fileName)), options);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadToPath(url, destination, options = {}) {
|
async function downloadToPath(url, destination, options = {}) {
|
||||||
const response = await fetch(url, { headers: downloadHeaders(options) });
|
const response = await fetch(url);
|
||||||
if (!response.ok || !response.body) {
|
if (!response.ok || !response.body) {
|
||||||
throw new Error(`下载失败: HTTP ${response.status}`);
|
throw new Error(`下载失败: HTTP ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|||||||
+66
-1
@@ -1,6 +1,10 @@
|
|||||||
"""ReinLoop cloud-server endpoint configuration shared by core modules."""
|
"""ReinLoop cloud-server endpoint configuration shared by core modules."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
from license_utils import get_verified_license
|
from license_utils import get_verified_license
|
||||||
|
|
||||||
@@ -16,6 +20,7 @@ server_api_url = os.environ.get(
|
|||||||
# Compatibility alias used by existing modules. It points to the ReinLoop
|
# Compatibility alias used by existing modules. It points to the ReinLoop
|
||||||
# Express server API, not a cloud-function endpoint.
|
# Express server API, not a cloud-function endpoint.
|
||||||
data_record_url = server_api_url
|
data_record_url = server_api_url
|
||||||
|
device_api_url = f"{server_api_url.rstrip('/')}/device"
|
||||||
_license = get_verified_license()
|
_license = get_verified_license()
|
||||||
_license_device_id = (_license or {}).get("device_id", "").strip()
|
_license_device_id = (_license or {}).get("device_id", "").strip()
|
||||||
_environment_device_id = os.environ.get("REINLOOP_DEVICE_ID", "").strip()
|
_environment_device_id = os.environ.get("REINLOOP_DEVICE_ID", "").strip()
|
||||||
@@ -26,4 +31,64 @@ if _license_device_id and _environment_device_id and _license_device_id != _envi
|
|||||||
the_folder = _license_device_id or _environment_device_id or "local-test-device"
|
the_folder = _license_device_id or _environment_device_id or "local-test-device"
|
||||||
|
|
||||||
if not the_folder:
|
if not the_folder:
|
||||||
raise RuntimeError("设备 ID 不能为空")
|
raise RuntimeError("设备 ID 不能为空")
|
||||||
|
|
||||||
|
_DEVICE_TOKEN = None
|
||||||
|
_DEVICE_TOKEN_EXPIRES_AT_MS = 0
|
||||||
|
_DEVICE_TOKEN_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _current_license_context():
|
||||||
|
payload = get_verified_license() or {}
|
||||||
|
license_id = str(payload.get("license_id") or "").strip()
|
||||||
|
device_id = str(payload.get("device_id") or _environment_device_id or the_folder).strip()
|
||||||
|
return license_id, device_id
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_device_token(timeout=10):
|
||||||
|
global _DEVICE_TOKEN, _DEVICE_TOKEN_EXPIRES_AT_MS
|
||||||
|
with _DEVICE_TOKEN_LOCK:
|
||||||
|
now_ms = int(time.time() * 1000)
|
||||||
|
if _DEVICE_TOKEN and _DEVICE_TOKEN_EXPIRES_AT_MS - now_ms > 30_000:
|
||||||
|
return _DEVICE_TOKEN
|
||||||
|
|
||||||
|
license_id, device_id = _current_license_context()
|
||||||
|
if not license_id:
|
||||||
|
raise RuntimeError("许可证未就绪,无法获取 deviceToken")
|
||||||
|
|
||||||
|
response = requests.post(device_api_url, json={
|
||||||
|
"type": "deviceAuth",
|
||||||
|
"licenseId": license_id,
|
||||||
|
"deviceId": device_id,
|
||||||
|
}, timeout=timeout)
|
||||||
|
response.raise_for_status()
|
||||||
|
result = response.json()
|
||||||
|
if not result.get("success"):
|
||||||
|
raise RuntimeError(result.get("errMsg") or "设备鉴权失败")
|
||||||
|
|
||||||
|
_DEVICE_TOKEN = str(result.get("deviceToken") or "").strip()
|
||||||
|
_DEVICE_TOKEN_EXPIRES_AT_MS = int(result.get("expiresAtMs") or 0)
|
||||||
|
if not _DEVICE_TOKEN or _DEVICE_TOKEN_EXPIRES_AT_MS <= now_ms:
|
||||||
|
raise RuntimeError("设备鉴权返回了无效 deviceToken")
|
||||||
|
return _DEVICE_TOKEN
|
||||||
|
|
||||||
|
|
||||||
|
def device_post(payload, timeout=10):
|
||||||
|
"""Call the device-scoped API route with an auto-renewed device token."""
|
||||||
|
token = _ensure_device_token(timeout=timeout)
|
||||||
|
request_payload = {**payload, "deviceToken": token}
|
||||||
|
response = requests.post(device_api_url, json=request_payload, timeout=timeout)
|
||||||
|
response.raise_for_status()
|
||||||
|
result = response.json()
|
||||||
|
if result.get("success"):
|
||||||
|
return result
|
||||||
|
if result.get("errCode") in {"DEVICE_TOKEN_EXPIRED", "DEVICE_TOKEN_INVALID"}:
|
||||||
|
with _DEVICE_TOKEN_LOCK:
|
||||||
|
global _DEVICE_TOKEN, _DEVICE_TOKEN_EXPIRES_AT_MS
|
||||||
|
_DEVICE_TOKEN = None
|
||||||
|
_DEVICE_TOKEN_EXPIRES_AT_MS = 0
|
||||||
|
token = _ensure_device_token(timeout=timeout)
|
||||||
|
response = requests.post(device_api_url, json={**payload, "deviceToken": token}, timeout=timeout)
|
||||||
|
response.raise_for_status()
|
||||||
|
result = response.json()
|
||||||
|
return result
|
||||||
@@ -11,7 +11,7 @@ import datetime
|
|||||||
import threading
|
import threading
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from api import base_url, data_record_url, the_folder
|
from api import device_post, the_folder
|
||||||
|
|
||||||
|
|
||||||
class DataCollector:
|
class DataCollector:
|
||||||
@@ -47,12 +47,11 @@ class DataCollector:
|
|||||||
def _upload_to_server(self, data_bytes: bytes, filename: str, folder: str) -> bool:
|
def _upload_to_server(self, data_bytes: bytes, filename: str, folder: str) -> bool:
|
||||||
"""向 ReinLoop 云服务器申请上传地址并上传控制数据。"""
|
"""向 ReinLoop 云服务器申请上传地址并上传控制数据。"""
|
||||||
try:
|
try:
|
||||||
resp = requests.post(data_record_url, json={
|
result = device_post({
|
||||||
"type": "uploadDataFile",
|
"type": "uploadDataFile",
|
||||||
"fileName": filename,
|
"fileName": filename,
|
||||||
"folder": folder,
|
"folder": folder,
|
||||||
}, timeout=30)
|
}, timeout=30)
|
||||||
result = resp.json()
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.log(f"向云服务器申请上传地址异常: {e}")
|
self.log(f"向云服务器申请上传地址异常: {e}")
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -3,16 +3,12 @@
|
|||||||
|
|
||||||
def heartbeat_device(timeout=5):
|
def heartbeat_device(timeout=5):
|
||||||
"""Refresh the current device's Server heartbeat and return its timestamp."""
|
"""Refresh the current device's Server heartbeat and return its timestamp."""
|
||||||
import requests
|
from api import device_post
|
||||||
from api import data_record_url, the_folder
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.post(data_record_url, json={
|
result = device_post({
|
||||||
"type": "deviceHeartbeat",
|
"type": "deviceHeartbeat",
|
||||||
"deviceId": the_folder,
|
|
||||||
}, timeout=timeout)
|
}, timeout=timeout)
|
||||||
response.raise_for_status()
|
|
||||||
result = response.json()
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise ValueError(f"设备心跳请求失败: {exc}") from exc
|
raise ValueError(f"设备心跳请求失败: {exc}") from exc
|
||||||
if not result.get("success"):
|
if not result.get("success"):
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from collections import deque
|
|||||||
from get_V import measure_volume
|
from get_V import measure_volume
|
||||||
from ind_collector import collect_data_with_prbs
|
from ind_collector import collect_data_with_prbs
|
||||||
|
|
||||||
from api import base_url, data_record_url, the_folder
|
from api import device_post, the_folder
|
||||||
|
|
||||||
|
|
||||||
class IdentificationManager:
|
class IdentificationManager:
|
||||||
@@ -66,12 +66,11 @@ class IdentificationManager:
|
|||||||
"""
|
"""
|
||||||
# Step 1: 向业务服务器申请一次性上传地址(不传文件内容)
|
# Step 1: 向业务服务器申请一次性上传地址(不传文件内容)
|
||||||
try:
|
try:
|
||||||
resp = requests.post(data_record_url, json={
|
result = device_post({
|
||||||
"type": "uploadDataFile",
|
"type": "uploadDataFile",
|
||||||
"fileName": filename,
|
"fileName": filename,
|
||||||
"folder": folder,
|
"folder": folder,
|
||||||
}, timeout=30)
|
}, timeout=30)
|
||||||
result = resp.json()
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.log(f"向云服务器申请上传地址异常: {e}")
|
self.log(f"向云服务器申请上传地址异常: {e}")
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -130,15 +130,12 @@ def parse_identification_config_csv(csv_text: str) -> dict:
|
|||||||
def download_identification_config(timeout=20) -> dict:
|
def download_identification_config(timeout=20) -> dict:
|
||||||
"""Download the current customer's CSV config through the cloud server."""
|
"""Download the current customer's CSV config through the cloud server."""
|
||||||
import requests
|
import requests
|
||||||
from api import data_record_url, the_folder
|
from api import device_post
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.post(data_record_url, json={
|
result = device_post({
|
||||||
"type": "getIdentificationConfig",
|
"type": "getIdentificationConfig",
|
||||||
"deviceId": the_folder,
|
|
||||||
}, timeout=timeout)
|
}, timeout=timeout)
|
||||||
response.raise_for_status()
|
|
||||||
result = response.json()
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise ValueError(f"连接云服务器辨识配置服务失败: {exc}") from exc
|
raise ValueError(f"连接云服务器辨识配置服务失败: {exc}") from exc
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,10 @@
|
|||||||
|
|
||||||
|
|
||||||
def _post(payload, timeout=10):
|
def _post(payload, timeout=10):
|
||||||
import requests
|
from api import device_post
|
||||||
from api import data_record_url
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.post(data_record_url, json=payload, timeout=timeout)
|
result = device_post(payload, timeout=timeout)
|
||||||
response.raise_for_status()
|
|
||||||
result = response.json()
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise ValueError(f"辨识反馈服务请求失败: {exc}") from exc
|
raise ValueError(f"辨识反馈服务请求失败: {exc}") from exc
|
||||||
if not result.get("success"):
|
if not result.get("success"):
|
||||||
@@ -18,13 +15,10 @@ def _post(payload, timeout=10):
|
|||||||
|
|
||||||
def register_identification_result(run_id: str, timeout=10) -> None:
|
def register_identification_result(run_id: str, timeout=10) -> None:
|
||||||
"""Register one uploaded CSV as the customer's current review target."""
|
"""Register one uploaded CSV as the customer's current review target."""
|
||||||
from api import the_folder
|
|
||||||
|
|
||||||
if not run_id:
|
if not run_id:
|
||||||
raise ValueError("辨识结果缺少 run_id")
|
raise ValueError("辨识结果缺少 run_id")
|
||||||
_post({
|
_post({
|
||||||
"type": "registerIdentificationResult",
|
"type": "registerIdentificationResult",
|
||||||
"deviceId": the_folder,
|
|
||||||
"runId": run_id,
|
"runId": run_id,
|
||||||
"fileName": run_id,
|
"fileName": run_id,
|
||||||
}, timeout=timeout)
|
}, timeout=timeout)
|
||||||
@@ -32,11 +26,8 @@ def register_identification_result(run_id: str, timeout=10) -> None:
|
|||||||
|
|
||||||
def get_identification_feedback(run_id: str, timeout=10):
|
def get_identification_feedback(run_id: str, timeout=10):
|
||||||
"""Return None while pending, otherwise return the integer 0 or 1."""
|
"""Return None while pending, otherwise return the integer 0 or 1."""
|
||||||
from api import the_folder
|
|
||||||
|
|
||||||
result = _post({
|
result = _post({
|
||||||
"type": "getIdentificationFeedback",
|
"type": "getIdentificationFeedback",
|
||||||
"deviceId": the_folder,
|
|
||||||
"runId": run_id,
|
"runId": run_id,
|
||||||
}, timeout=timeout)
|
}, timeout=timeout)
|
||||||
if not result.get("ready"):
|
if not result.get("ready"):
|
||||||
@@ -49,10 +40,7 @@ def get_identification_feedback(run_id: str, timeout=10):
|
|||||||
|
|
||||||
def acknowledge_identification_feedback(run_id: str, timeout=10) -> None:
|
def acknowledge_identification_feedback(run_id: str, timeout=10) -> None:
|
||||||
"""Delete the consumed review record so stale feedback cannot be reused."""
|
"""Delete the consumed review record so stale feedback cannot be reused."""
|
||||||
from api import the_folder
|
|
||||||
|
|
||||||
_post({
|
_post({
|
||||||
"type": "ackIdentificationFeedback",
|
"type": "ackIdentificationFeedback",
|
||||||
"deviceId": the_folder,
|
|
||||||
"runId": run_id,
|
"runId": run_id,
|
||||||
}, timeout=timeout)
|
}, timeout=timeout)
|
||||||
|
|||||||
@@ -6,14 +6,13 @@
|
|||||||
|
|
||||||
import threading
|
import threading
|
||||||
import io
|
import io
|
||||||
import requests
|
|
||||||
import torch
|
import torch
|
||||||
from stable_baselines3 import SAC
|
from stable_baselines3 import SAC
|
||||||
|
|
||||||
# 关键:禁用 PyTorch 内部多线程,防止在 PyInstaller daemon 线程中 segfault
|
# 关键:禁用 PyTorch 内部多线程,防止在 PyInstaller daemon 线程中 segfault
|
||||||
torch.set_num_threads(1)
|
torch.set_num_threads(1)
|
||||||
|
|
||||||
from api import base_url, data_record_url, the_folder
|
from api import device_post, the_folder
|
||||||
|
|
||||||
|
|
||||||
class ModelManager:
|
class ModelManager:
|
||||||
@@ -50,8 +49,7 @@ class ModelManager:
|
|||||||
def fetch_models():
|
def fetch_models():
|
||||||
try:
|
try:
|
||||||
payload = {"type": "listModels", "folder": f"{the_folder}/model_config"}
|
payload = {"type": "listModels", "folder": f"{the_folder}/model_config"}
|
||||||
resp = requests.post(data_record_url, json=payload, timeout=10)
|
result = device_post(payload, timeout=10)
|
||||||
result = resp.json()
|
|
||||||
|
|
||||||
if result.get("success"):
|
if result.get("success"):
|
||||||
files = result.get("files", [])
|
files = result.get("files", [])
|
||||||
@@ -98,8 +96,7 @@ class ModelManager:
|
|||||||
|
|
||||||
# 获取临时下载 URL
|
# 获取临时下载 URL
|
||||||
payload = {"type": "downloadModel", "fileID": file_id}
|
payload = {"type": "downloadModel", "fileID": file_id}
|
||||||
resp = requests.post(data_record_url, json=payload, timeout=15)
|
result = device_post(payload, timeout=15)
|
||||||
result = resp.json()
|
|
||||||
|
|
||||||
if not result.get("success"):
|
if not result.get("success"):
|
||||||
err = result.get('errMsg', '未知错误')
|
err = result.get('errMsg', '未知错误')
|
||||||
|
|||||||
@@ -84,13 +84,10 @@ def validate_volume_config(config) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def _post_volume_request(payload, timeout=10):
|
def _post_volume_request(payload, timeout=10):
|
||||||
import requests
|
from api import device_post
|
||||||
from api import data_record_url
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.post(data_record_url, json=payload, timeout=timeout)
|
result = device_post(payload, timeout=timeout)
|
||||||
response.raise_for_status()
|
|
||||||
result = response.json()
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise ValueError(f"连接云端容积参数服务失败: {exc}") from exc
|
raise ValueError(f"连接云端容积参数服务失败: {exc}") from exc
|
||||||
|
|
||||||
@@ -101,11 +98,8 @@ def _post_volume_request(payload, timeout=10):
|
|||||||
|
|
||||||
def create_volume_config_request(timeout=10) -> dict:
|
def create_volume_config_request(timeout=10) -> dict:
|
||||||
"""Create exactly one cloud request after the customer clicks Test."""
|
"""Create exactly one cloud request after the customer clicks Test."""
|
||||||
from api import the_folder
|
|
||||||
|
|
||||||
result = _post_volume_request({
|
result = _post_volume_request({
|
||||||
"type": "createVolumeConfigRequest",
|
"type": "createVolumeConfigRequest",
|
||||||
"deviceId": the_folder,
|
|
||||||
}, timeout=timeout)
|
}, timeout=timeout)
|
||||||
if not result.get("requestId") or not result.get("expiresAtMs"):
|
if not result.get("requestId") or not result.get("expiresAtMs"):
|
||||||
raise ValueError("云端未返回有效的容积参数请求编号")
|
raise ValueError("云端未返回有效的容积参数请求编号")
|
||||||
@@ -118,11 +112,9 @@ def create_volume_config_request(timeout=10) -> dict:
|
|||||||
def poll_volume_config_request(request_id: str, timeout=10) -> dict:
|
def poll_volume_config_request(request_id: str, timeout=10) -> dict:
|
||||||
"""Poll one request; download and validate JSON only when it is ready."""
|
"""Poll one request; download and validate JSON only when it is ready."""
|
||||||
import requests
|
import requests
|
||||||
from api import the_folder
|
|
||||||
|
|
||||||
result = _post_volume_request({
|
result = _post_volume_request({
|
||||||
"type": "getVolumeConfigRequest",
|
"type": "getVolumeConfigRequest",
|
||||||
"deviceId": the_folder,
|
|
||||||
"requestId": request_id,
|
"requestId": request_id,
|
||||||
}, timeout=timeout)
|
}, timeout=timeout)
|
||||||
if result.get("expired"):
|
if result.get("expired"):
|
||||||
@@ -145,10 +137,7 @@ def poll_volume_config_request(request_id: str, timeout=10) -> dict:
|
|||||||
|
|
||||||
def acknowledge_volume_config_request(request_id: str, timeout=10) -> None:
|
def acknowledge_volume_config_request(request_id: str, timeout=10) -> None:
|
||||||
"""Delete the consumed/abandoned request and its temporary JSON file."""
|
"""Delete the consumed/abandoned request and its temporary JSON file."""
|
||||||
from api import the_folder
|
|
||||||
|
|
||||||
_post_volume_request({
|
_post_volume_request({
|
||||||
"type": "ackVolumeConfigRequest",
|
"type": "ackVolumeConfigRequest",
|
||||||
"deviceId": the_folder,
|
|
||||||
"requestId": request_id,
|
"requestId": request_id,
|
||||||
}, timeout=timeout)
|
}, timeout=timeout)
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ LICENSE_FILE = "license.lic"
|
|||||||
# 巡检间隔(分钟)
|
# 巡检间隔(分钟)
|
||||||
DEFAULT_CHECK_INTERVAL = 5
|
DEFAULT_CHECK_INTERVAL = 5
|
||||||
ONLINE_CHECK_TIMEOUT_SECONDS = 5
|
ONLINE_CHECK_TIMEOUT_SECONDS = 5
|
||||||
DEFAULT_OFFLINE_HOURS = 72
|
DEFAULT_OFFLINE_HOURS = 100
|
||||||
|
|
||||||
# 过期后宽限期(小时),给用户保存工作的时间
|
# 过期后宽限期(小时),给用户保存工作的时间
|
||||||
GRACE_PERIOD_HOURS = 2
|
GRACE_PERIOD_HOURS = 2
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ def load_data_collector_module():
|
|||||||
api = types.ModuleType("api")
|
api = types.ModuleType("api")
|
||||||
api.base_url = "https://cloud.example"
|
api.base_url = "https://cloud.example"
|
||||||
api.data_record_url = "https://cloud.example"
|
api.data_record_url = "https://cloud.example"
|
||||||
|
api.device_post = lambda payload, timeout=10: {
|
||||||
|
"success": True,
|
||||||
|
"uploadMetadata": {"url": "https://upload.example"}
|
||||||
|
}
|
||||||
api.the_folder = "customer-a/line-1"
|
api.the_folder = "customer-a/line-1"
|
||||||
requests = types.ModuleType("requests")
|
requests = types.ModuleType("requests")
|
||||||
|
|
||||||
|
|||||||
@@ -16,26 +16,12 @@ class DeviceHeartbeatTests(unittest.TestCase):
|
|||||||
def test_sends_current_device_id_to_server(self):
|
def test_sends_current_device_id_to_server(self):
|
||||||
calls = []
|
calls = []
|
||||||
requests_module = types.ModuleType("requests")
|
requests_module = types.ModuleType("requests")
|
||||||
|
|
||||||
class Response:
|
|
||||||
def raise_for_status(self):
|
|
||||||
return None
|
|
||||||
|
|
||||||
def json(self):
|
|
||||||
return {"success": True, "lastSeenAt": "2026-07-28T00:00:00.000Z"}
|
|
||||||
|
|
||||||
def post(url, json, timeout):
|
|
||||||
calls.append((url, json, timeout))
|
|
||||||
return Response()
|
|
||||||
|
|
||||||
requests_module.post = post
|
|
||||||
api_module = types.ModuleType("api")
|
api_module = types.ModuleType("api")
|
||||||
api_module.data_record_url = "https://server.example"
|
api_module.device_post = lambda payload, timeout=5: (
|
||||||
api_module.the_folder = "company/line"
|
calls.append((payload, timeout)) or {"success": True, "lastSeenAt": "2026-07-28T00:00:00.000Z"}
|
||||||
|
)
|
||||||
with patch.dict(sys.modules, {"requests": requests_module, "api": api_module}):
|
with patch.dict(sys.modules, {"requests": requests_module, "api": api_module}):
|
||||||
timestamp = HEARTBEAT.heartbeat_device(timeout=7)
|
timestamp = HEARTBEAT.heartbeat_device(timeout=7)
|
||||||
|
|
||||||
self.assertEqual(timestamp, "2026-07-28T00:00:00.000Z")
|
self.assertEqual(timestamp, "2026-07-28T00:00:00.000Z")
|
||||||
self.assertEqual(calls, [("https://server.example", {
|
self.assertEqual(calls, [({"type": "deviceHeartbeat"}, 7)])
|
||||||
"type": "deviceHeartbeat", "deviceId": "company/line"
|
|
||||||
}, 7)])
|
|
||||||
@@ -87,33 +87,23 @@ class IdentificationConfigTests(unittest.TestCase):
|
|||||||
def test_download_requests_customer_config_and_validates_it(self):
|
def test_download_requests_customer_config_and_validates_it(self):
|
||||||
calls = []
|
calls = []
|
||||||
|
|
||||||
class FakeResponse:
|
|
||||||
def __init__(self, body=None, text=None):
|
|
||||||
self.body = body
|
|
||||||
self.text = text
|
|
||||||
|
|
||||||
def raise_for_status(self):
|
|
||||||
return None
|
|
||||||
|
|
||||||
def json(self):
|
|
||||||
return self.body
|
|
||||||
|
|
||||||
requests_module = types.ModuleType("requests")
|
requests_module = types.ModuleType("requests")
|
||||||
requests_module.RequestException = Exception
|
requests_module.RequestException = Exception
|
||||||
|
|
||||||
def post(url, json, timeout):
|
def device_post(payload, timeout):
|
||||||
calls.append(("post", url, json, timeout))
|
calls.append(("device_post", payload, timeout))
|
||||||
return FakeResponse({"success": True, "url": "https://temp/config"})
|
return {"success": True, "url": "https://temp/config"}
|
||||||
|
|
||||||
def get(url, timeout):
|
def get(url, timeout):
|
||||||
calls.append(("get", url, timeout))
|
calls.append(("get", url, timeout))
|
||||||
return FakeResponse(text=config_csv(VALID_CONFIG))
|
return types.SimpleNamespace(
|
||||||
|
text=config_csv(VALID_CONFIG),
|
||||||
|
raise_for_status=lambda: None
|
||||||
|
)
|
||||||
|
|
||||||
requests_module.post = post
|
|
||||||
requests_module.get = get
|
requests_module.get = get
|
||||||
api_module = types.ModuleType("api")
|
api_module = types.ModuleType("api")
|
||||||
api_module.data_record_url = "https://cloud/data_record"
|
api_module.device_post = device_post
|
||||||
api_module.the_folder = "客户A"
|
|
||||||
|
|
||||||
with patch.dict(sys.modules, {
|
with patch.dict(sys.modules, {
|
||||||
"requests": requests_module,
|
"requests": requests_module,
|
||||||
@@ -122,28 +112,14 @@ class IdentificationConfigTests(unittest.TestCase):
|
|||||||
result = download_identification_config(timeout=7)
|
result = download_identification_config(timeout=7)
|
||||||
|
|
||||||
self.assertEqual(result["repeat"], 2)
|
self.assertEqual(result["repeat"], 2)
|
||||||
self.assertEqual(calls[0], (
|
self.assertEqual(calls[0], ("device_post", {"type": "getIdentificationConfig"}, 7))
|
||||||
"post",
|
|
||||||
"https://cloud/data_record",
|
|
||||||
{"type": "getIdentificationConfig", "deviceId": "客户A"},
|
|
||||||
7,
|
|
||||||
))
|
|
||||||
self.assertEqual(calls[1], ("get", "https://temp/config", 7))
|
self.assertEqual(calls[1], ("get", "https://temp/config", 7))
|
||||||
|
|
||||||
def test_download_reports_cloud_rejection(self):
|
def test_download_reports_cloud_rejection(self):
|
||||||
class FakeResponse:
|
|
||||||
def raise_for_status(self):
|
|
||||||
return None
|
|
||||||
|
|
||||||
def json(self):
|
|
||||||
return {"success": False, "errMsg": "配置不存在"}
|
|
||||||
|
|
||||||
requests_module = types.ModuleType("requests")
|
requests_module = types.ModuleType("requests")
|
||||||
requests_module.RequestException = Exception
|
requests_module.RequestException = Exception
|
||||||
requests_module.post = lambda *args, **kwargs: FakeResponse()
|
|
||||||
api_module = types.ModuleType("api")
|
api_module = types.ModuleType("api")
|
||||||
api_module.data_record_url = "https://cloud/data_record"
|
api_module.device_post = lambda *args, **kwargs: {"success": False, "errMsg": "配置不存在"}
|
||||||
api_module.the_folder = "客户A"
|
|
||||||
|
|
||||||
with patch.dict(sys.modules, {
|
with patch.dict(sys.modules, {
|
||||||
"requests": requests_module,
|
"requests": requests_module,
|
||||||
|
|||||||
@@ -57,28 +57,13 @@ class VolumeConfigTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_customer_creates_exactly_one_request_instruction(self):
|
def test_customer_creates_exactly_one_request_instruction(self):
|
||||||
calls = []
|
calls = []
|
||||||
|
|
||||||
class FakeResponse:
|
|
||||||
def raise_for_status(self):
|
|
||||||
return None
|
|
||||||
|
|
||||||
def json(self):
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"requestId": "request-1",
|
|
||||||
"expiresAtMs": 123456,
|
|
||||||
}
|
|
||||||
|
|
||||||
requests_module = types.ModuleType("requests")
|
requests_module = types.ModuleType("requests")
|
||||||
|
|
||||||
def post(url, json, timeout):
|
|
||||||
calls.append((url, json, timeout))
|
|
||||||
return FakeResponse()
|
|
||||||
|
|
||||||
requests_module.post = post
|
|
||||||
api_module = types.ModuleType("api")
|
api_module = types.ModuleType("api")
|
||||||
api_module.data_record_url = "https://cloud/data_record"
|
api_module.device_post = lambda payload, timeout=10: (calls.append((payload, timeout)) or {
|
||||||
api_module.the_folder = "客户A"
|
"success": True,
|
||||||
|
"requestId": "request-1",
|
||||||
|
"expiresAtMs": 123456,
|
||||||
|
})
|
||||||
|
|
||||||
with patch.dict(sys.modules, {
|
with patch.dict(sys.modules, {
|
||||||
"requests": requests_module,
|
"requests": requests_module,
|
||||||
@@ -90,30 +75,16 @@ class VolumeConfigTests(unittest.TestCase):
|
|||||||
"request_id": "request-1",
|
"request_id": "request-1",
|
||||||
"expires_at_ms": 123456,
|
"expires_at_ms": 123456,
|
||||||
})
|
})
|
||||||
self.assertEqual(calls, [(
|
self.assertEqual(calls, [({"type": "createVolumeConfigRequest"}, 7)])
|
||||||
"https://cloud/data_record",
|
|
||||||
{"type": "createVolumeConfigRequest", "deviceId": "客户A"},
|
|
||||||
7,
|
|
||||||
)])
|
|
||||||
|
|
||||||
def test_pending_request_does_not_download_a_file(self):
|
def test_pending_request_does_not_download_a_file(self):
|
||||||
calls = []
|
calls = []
|
||||||
|
|
||||||
class FakeResponse:
|
|
||||||
def raise_for_status(self):
|
|
||||||
return None
|
|
||||||
|
|
||||||
def json(self):
|
|
||||||
return {"success": True, "ready": False, "expired": False}
|
|
||||||
|
|
||||||
requests_module = types.ModuleType("requests")
|
requests_module = types.ModuleType("requests")
|
||||||
requests_module.post = lambda *args, **kwargs: (
|
|
||||||
calls.append(("post", kwargs["json"])) or FakeResponse()
|
|
||||||
)
|
|
||||||
requests_module.get = lambda *args, **kwargs: calls.append(("get", args[0]))
|
requests_module.get = lambda *args, **kwargs: calls.append(("get", args[0]))
|
||||||
api_module = types.ModuleType("api")
|
api_module = types.ModuleType("api")
|
||||||
api_module.data_record_url = "https://cloud/data_record"
|
api_module.device_post = lambda payload, timeout=10: (
|
||||||
api_module.the_folder = "客户A"
|
calls.append(("device_post", payload)) or {"success": True, "ready": False, "expired": False}
|
||||||
|
)
|
||||||
|
|
||||||
with patch.dict(sys.modules, {
|
with patch.dict(sys.modules, {
|
||||||
"requests": requests_module,
|
"requests": requests_module,
|
||||||
@@ -122,36 +93,25 @@ class VolumeConfigTests(unittest.TestCase):
|
|||||||
result = poll_volume_config_request("request-1")
|
result = poll_volume_config_request("request-1")
|
||||||
|
|
||||||
self.assertEqual(result, {"ready": False, "expired": False})
|
self.assertEqual(result, {"ready": False, "expired": False})
|
||||||
self.assertEqual(calls, [("post", {
|
self.assertEqual(calls, [("device_post", {
|
||||||
"type": "getVolumeConfigRequest",
|
"type": "getVolumeConfigRequest",
|
||||||
"deviceId": "客户A",
|
|
||||||
"requestId": "request-1",
|
"requestId": "request-1",
|
||||||
})])
|
})])
|
||||||
|
|
||||||
def test_ready_request_downloads_and_validates_json(self):
|
def test_ready_request_downloads_and_validates_json(self):
|
||||||
class FakeResponse:
|
|
||||||
def __init__(self, body):
|
|
||||||
self.body = body
|
|
||||||
|
|
||||||
def raise_for_status(self):
|
|
||||||
return None
|
|
||||||
|
|
||||||
def json(self):
|
|
||||||
return self.body
|
|
||||||
|
|
||||||
requests_module = types.ModuleType("requests")
|
requests_module = types.ModuleType("requests")
|
||||||
requests_module.post = lambda *args, **kwargs: FakeResponse({
|
requests_module.post = lambda *args, **kwargs: types.SimpleNamespace()
|
||||||
|
api_module = types.ModuleType("api")
|
||||||
|
api_module.device_post = lambda payload, timeout=10: {
|
||||||
"success": True,
|
"success": True,
|
||||||
"ready": True,
|
"ready": True,
|
||||||
"expired": False,
|
"expired": False,
|
||||||
"url": "https://temp/volume.json",
|
"url": "https://temp/volume.json",
|
||||||
})
|
}
|
||||||
requests_module.get = lambda *args, **kwargs: FakeResponse(
|
requests_module.get = lambda *args, **kwargs: types.SimpleNamespace(
|
||||||
dict(VALID_CONFIG)
|
raise_for_status=lambda: None,
|
||||||
|
json=lambda: dict(VALID_CONFIG)
|
||||||
)
|
)
|
||||||
api_module = types.ModuleType("api")
|
|
||||||
api_module.data_record_url = "https://cloud/data_record"
|
|
||||||
api_module.the_folder = "客户A"
|
|
||||||
|
|
||||||
with patch.dict(sys.modules, {
|
with patch.dict(sys.modules, {
|
||||||
"requests": requests_module,
|
"requests": requests_module,
|
||||||
@@ -163,18 +123,9 @@ class VolumeConfigTests(unittest.TestCase):
|
|||||||
self.assertEqual(result["config"], VALID_CONFIG)
|
self.assertEqual(result["config"], VALID_CONFIG)
|
||||||
|
|
||||||
def test_create_request_reports_server_rejection(self):
|
def test_create_request_reports_server_rejection(self):
|
||||||
class FakeResponse:
|
|
||||||
def raise_for_status(self):
|
|
||||||
return None
|
|
||||||
|
|
||||||
def json(self):
|
|
||||||
return {"success": False, "errMsg": "尚未配置"}
|
|
||||||
|
|
||||||
requests_module = types.ModuleType("requests")
|
requests_module = types.ModuleType("requests")
|
||||||
requests_module.post = lambda *args, **kwargs: FakeResponse()
|
|
||||||
api_module = types.ModuleType("api")
|
api_module = types.ModuleType("api")
|
||||||
api_module.data_record_url = "https://cloud/data_record"
|
api_module.device_post = lambda *args, **kwargs: {"success": False, "errMsg": "尚未配置"}
|
||||||
api_module.the_folder = "客户A"
|
|
||||||
|
|
||||||
with patch.dict(sys.modules, {
|
with patch.dict(sys.modules, {
|
||||||
"requests": requests_module,
|
"requests": requests_module,
|
||||||
@@ -185,21 +136,9 @@ class VolumeConfigTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_acknowledges_the_same_request_for_cleanup(self):
|
def test_acknowledges_the_same_request_for_cleanup(self):
|
||||||
calls = []
|
calls = []
|
||||||
|
|
||||||
class FakeResponse:
|
|
||||||
def raise_for_status(self):
|
|
||||||
return None
|
|
||||||
|
|
||||||
def json(self):
|
|
||||||
return {"success": True, "deleted": 1}
|
|
||||||
|
|
||||||
requests_module = types.ModuleType("requests")
|
requests_module = types.ModuleType("requests")
|
||||||
requests_module.post = lambda *args, **kwargs: (
|
|
||||||
calls.append(kwargs["json"]) or FakeResponse()
|
|
||||||
)
|
|
||||||
api_module = types.ModuleType("api")
|
api_module = types.ModuleType("api")
|
||||||
api_module.data_record_url = "https://cloud/data_record"
|
api_module.device_post = lambda payload, timeout=10: (calls.append(payload) or {"success": True, "deleted": 1})
|
||||||
api_module.the_folder = "客户A"
|
|
||||||
|
|
||||||
with patch.dict(sys.modules, {
|
with patch.dict(sys.modules, {
|
||||||
"requests": requests_module,
|
"requests": requests_module,
|
||||||
@@ -209,7 +148,6 @@ class VolumeConfigTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(calls, [{
|
self.assertEqual(calls, [{
|
||||||
"type": "ackVolumeConfigRequest",
|
"type": "ackVolumeConfigRequest",
|
||||||
"deviceId": "客户A",
|
|
||||||
"requestId": "request-1",
|
"requestId": "request-1",
|
||||||
}])
|
}])
|
||||||
|
|
||||||
|
|||||||
@@ -140,3 +140,47 @@
|
|||||||
### 验证
|
### 验证
|
||||||
|
|
||||||
- 已完成修改文件的 Python、JavaScript 语法检查,未发现语法错误。
|
- 已完成修改文件的 Python、JavaScript 语法检查,未发现语法错误。
|
||||||
|
|
||||||
|
## 2026-08-03
|
||||||
|
|
||||||
|
### Server:下载接口改造为一次性临时 URL 并绑定来源 IP
|
||||||
|
|
||||||
|
- 文件下载流程由“业务接口返回可复用直链”改为“业务接口签发一次性临时 URL + 下载后立即失效”。
|
||||||
|
- 新增下载入口 `GET /downloads/:ticket`,票据校验包含有效期、一次性消费和请求来源 IP 一致性。
|
||||||
|
- 旧入口 `GET /files/:fileID` 已停用并固定返回 `403`,避免 fileID 直链被转发复用。
|
||||||
|
- `downloadModel` 统一要求 `adminToken`,下载能力与管理权限保持一致。
|
||||||
|
- 返回下载 URL 的业务接口已统一改为临时票据地址:`downloadModel`、`getPendingPanelFile`、`getIdentificationFileDownload`、`getControlFileDownload`、`getIdentificationConfig`、`getVolumeConfigFile`、`getVolumeConfigRequest`。
|
||||||
|
|
||||||
|
涉及文件:
|
||||||
|
|
||||||
|
- `server/src/app.js`
|
||||||
|
- `server/test/server.test.js`
|
||||||
|
- `ControlPanel/server-client.js`
|
||||||
|
- `server/features.md`
|
||||||
|
|
||||||
|
### 验证
|
||||||
|
|
||||||
|
- 已完成 `server` 全量测试(21 项)通过。
|
||||||
|
- 已完成 ControlPanel 无 GUI 内核联调测试通过(`model-handlers` 与 `license-manager` 相关用例)。
|
||||||
|
- 已重启 `reinloop-server.service` 并验证:同一临时下载 URL 首次下载成功,二次访问返回 `403`。
|
||||||
|
|
||||||
|
### Server + ReinLoop:设备分路由与最小权限访问
|
||||||
|
|
||||||
|
- 新增 `POST /device` 设备侧路由,ReinLoop 通过 `deviceAuth`(`licenseId` + `deviceId`)换取短期 `deviceToken` 后访问设备接口。
|
||||||
|
- 设备侧接口采用白名单权限,不再使用 `adminToken`,并强制按 `deviceId` 隔离目录和模型访问范围。
|
||||||
|
- ReinLoop 客户端核心模块已切换为 `/device` 访问链路:设备心跳、模型列表与下载、辨识配置下载、容积请求、辨识反馈、控制与辨识结果上传申请。
|
||||||
|
- ReinLoop 离线宽限默认调整为 `100` 小时(`REINLOOP_LICENSE_OFFLINE_HOURS` 默认值)。
|
||||||
|
|
||||||
|
涉及文件:
|
||||||
|
|
||||||
|
- `server/src/app.js`
|
||||||
|
- `server/features.md`
|
||||||
|
- `ReinLoop/api.py`
|
||||||
|
- `ReinLoop/license_utils.py`
|
||||||
|
- `ReinLoop/core/model_manager.py`
|
||||||
|
- `ReinLoop/core/identification_config.py`
|
||||||
|
- `ReinLoop/core/volume_config.py`
|
||||||
|
- `ReinLoop/core/identification_feedback.py`
|
||||||
|
- `ReinLoop/core/device_heartbeat.py`
|
||||||
|
- `ReinLoop/core/data_collector.py`
|
||||||
|
- `ReinLoop/core/identification.py`
|
||||||
|
|||||||
+39
-5
@@ -17,10 +17,38 @@
|
|||||||
| 方法 | 路径 | 用途 |
|
| 方法 | 路径 | 用途 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `POST` | `/` | 主业务 API |
|
| `POST` | `/` | 主业务 API |
|
||||||
|
| `POST` | `/device` | ReinLoop 设备侧 API(license -> deviceToken) |
|
||||||
| `POST` | `/upload/:token` | 根据上传凭证接收 multipart 文件 |
|
| `POST` | `/upload/:token` | 根据上传凭证接收 multipart 文件 |
|
||||||
| `GET` | `/files/:fileID` | 下载已存储文件 |
|
| `GET` | `/downloads/:ticket` | 使用一次性临时下载票据获取文件 |
|
||||||
|
| `GET` | `/files/:fileID` | 旧直链下载入口(已停用,固定返回 403) |
|
||||||
| `GET` | `/health` | 服务存活检查 |
|
| `GET` | `/health` | 服务存活检查 |
|
||||||
|
|
||||||
|
## 设备侧鉴权与分路由
|
||||||
|
|
||||||
|
`/device` 仅用于 ReinLoop 客户端,禁止使用 `adminToken`。
|
||||||
|
|
||||||
|
调用方式:
|
||||||
|
|
||||||
|
1. 先 `POST /device`,`type=deviceAuth`,字段 `licenseId`、`deviceId`。
|
||||||
|
2. 服务端校验许可证状态(存在、未过期、未撤销、deviceId 匹配)后签发 `deviceToken`。
|
||||||
|
3. ReinLoop 后续调用 `/device` 白名单接口时携带 `deviceToken`。
|
||||||
|
|
||||||
|
`deviceToken` 默认有效期由 `DEVICE_TOKEN_TTL_MS` 控制(默认 15 分钟)。
|
||||||
|
|
||||||
|
离线宽限由 ReinLoop 客户端控制,当前默认 `REINLOOP_LICENSE_OFFLINE_HOURS=100`(100 小时)。
|
||||||
|
|
||||||
|
## 下载安全调用链
|
||||||
|
|
||||||
|
统一链路为:业务 `POST /`(鉴权) -> 返回临时 URL -> `GET /downloads/:ticket`(一次性消费)。
|
||||||
|
|
||||||
|
安全校验点:
|
||||||
|
|
||||||
|
- `POST /`:按业务类型执行权限校验。
|
||||||
|
- `GET /downloads/:ticket`:
|
||||||
|
- 票据存在且未过期(`DOWNLOAD_URL_TTL_MS`,兼容旧环境变量 `DOWNLOAD_TOKEN_TTL_MS`)。
|
||||||
|
- 票据仅可消费一次,成功下载或校验失败后均失效。
|
||||||
|
- 下载请求来源 IP 必须与签发票据的 `POST` 请求来源 IP 一致。
|
||||||
|
|
||||||
## 设备心跳与组织
|
## 设备心跳与组织
|
||||||
|
|
||||||
| type | 鉴权 | 请求字段 | 功能与响应要点 |
|
| type | 鉴权 | 请求字段 | 功能与响应要点 |
|
||||||
@@ -51,13 +79,19 @@ Panel 应每 10 秒调用 `listOrganizations` 刷新在线状态,不应自行
|
|||||||
| `uploadDataFile` | 视目录而定 | `fileName`、`folder` | 签发通用两步上传凭证。目录为 `*/model_config` 时必须 Admin;其他既有 ReinLoop 数据上传保持兼容。 |
|
| `uploadDataFile` | 视目录而定 | `fileName`、`folder` | 签发通用两步上传凭证。目录为 `*/model_config` 时必须 Admin;其他既有 ReinLoop 数据上传保持兼容。 |
|
||||||
| `issueModelUpload` | Admin | `deviceId`、`fileName`、可选 `modelName`、`overwrite` | `fileName` 是本地原始文件名;传入 `modelName` 时以该名称存储和识别模型,并保留 `originalFileName`。同名模型已存在时返回 `conflict: true`;仅 `overwrite: true` 可签发覆盖凭证。 |
|
| `issueModelUpload` | Admin | `deviceId`、`fileName`、可选 `modelName`、`overwrite` | `fileName` 是本地原始文件名;传入 `modelName` 时以该名称存储和识别模型,并保留 `originalFileName`。同名模型已存在时返回 `conflict: true`;仅 `overwrite: true` 可签发覆盖凭证。 |
|
||||||
| `listModels` | 无 | `folder` | 返回 `files` 当前模型名数组和 `fileList` 元数据数组,最多 100 条;每条同时包含 `fileName` 和 `originalFileName`。 |
|
| `listModels` | 无 | `folder` | 返回 `files` 当前模型名数组和 `fileList` 元数据数组,最多 100 条;每条同时包含 `fileName` 和 `originalFileName`。 |
|
||||||
| `downloadModel` | 视文件而定 | `fileID` | 模型保持兼容;非模型文件要求 Admin 并返回短期签名下载 URL。 |
|
| `downloadModel` | Admin | `fileID` | 返回一次性临时下载 URL(`/downloads/:ticket`)。 |
|
||||||
| `deleteFile` | Admin | `fileID`,或 `folder` 与 `fileName` | 删除文件及元数据;同名文件不唯一时必须使用 `fileID`。 |
|
| `deleteFile` | Admin | `fileID`,或 `folder` 与 `fileName` | 删除文件及元数据;同名文件不唯一时必须使用 `fileID`。 |
|
||||||
| `deleteModel` | Admin | 同 `deleteFile` | 模型删除的明确管理端别名。 |
|
| `deleteModel` | Admin | 同 `deleteFile` | 模型删除的明确管理端别名。 |
|
||||||
|
|
||||||
上传分两步:先调用 `uploadDataFile` 或 `issueModelUpload`,再将文件作为 `multipart/form-data` 的 `file` 字段提交到响应中的 `uploadMetadata.url`。上传成功返回 HTTP `204`;响应中的 `fileID` 可用于下载和删除。
|
上传分两步:先调用 `uploadDataFile` 或 `issueModelUpload`,再将文件作为 `multipart/form-data` 的 `file` 字段提交到响应中的 `uploadMetadata.url`。上传成功返回 HTTP `204`;响应中的 `fileID` 可用于下载和删除。
|
||||||
模型重命名不会修改文件格式,因此 `modelName` 与原始 `fileName` 的扩展名必须一致。未传 `modelName` 时两者相同,旧客户端行为不变。
|
模型重命名不会修改文件格式,因此 `modelName` 与原始 `fileName` 的扩展名必须一致。未传 `modelName` 时两者相同,旧客户端行为不变。
|
||||||
|
|
||||||
|
设备侧(`/device`)模型访问约束:
|
||||||
|
|
||||||
|
- `listModels` 固定返回当前 `deviceId/model_config` 目录。
|
||||||
|
- `downloadModel` 仅允许下载当前 `deviceId/model_config` 下文件。
|
||||||
|
- 设备侧不允许模型上传与删除。
|
||||||
|
|
||||||
上传到 `<deviceId>/ind_data` 的 `.csv`、`.json` 会自动进入 Panel inbox。
|
上传到 `<deviceId>/ind_data` 的 `.csv`、`.json` 会自动进入 Panel inbox。
|
||||||
|
|
||||||
## 配置发布与读取
|
## 配置发布与读取
|
||||||
@@ -67,7 +101,7 @@ Panel 应每 10 秒调用 `listOrganizations` 刷新在线状态,不应自行
|
|||||||
| `publishIdentificationConfig` | Admin | `deviceId`、`parameters` | 校验辨识参数并为 `<deviceId>/identification_config/identification_config.csv` 签发上传凭证。 |
|
| `publishIdentificationConfig` | Admin | `deviceId`、`parameters` | 校验辨识参数并为 `<deviceId>/identification_config/identification_config.csv` 签发上传凭证。 |
|
||||||
| `getIdentificationConfig` | 无 | `deviceId` | 返回该设备辨识 CSV 的 `fileID` 和 `url`。 |
|
| `getIdentificationConfig` | 无 | `deviceId` | 返回该设备辨识 CSV 的 `fileID` 和 `url`。 |
|
||||||
| `publishVolumeConfig` | Admin | `parameters` | 校验容积参数并签发 `volume_config.json` 上传凭证。上传完成后更新功能参数记录。 |
|
| `publishVolumeConfig` | Admin | `parameters` | 校验容积参数并签发 `volume_config.json` 上传凭证。上传完成后更新功能参数记录。 |
|
||||||
| `getVolumeConfigFile` | 无 | 无 | 返回已发布容积 JSON 的 `fileID`、`cloudPath`、`url`。 |
|
| `getVolumeConfigFile` | Admin | `deviceId` | 返回指定设备已发布容积 JSON 的 `fileID`、`cloudPath`、`url`。 |
|
||||||
| `getFunctionConfig` | 无 | `configType: "volume"` | 返回已发布容积参数的 `parameters`、`version`、`updateTime`。 |
|
| `getFunctionConfig` | 无 | `configType: "volume"` | 返回已发布容积参数的 `parameters`、`version`、`updateTime`。 |
|
||||||
|
|
||||||
发布接口仅签发上传凭证;客户端完成二步上传后,读取接口才会返回新文件或参数。
|
发布接口仅签发上传凭证;客户端完成二步上传后,读取接口才会返回新文件或参数。
|
||||||
@@ -83,7 +117,7 @@ Panel 应每 10 秒调用 `listOrganizations` 刷新在线状态,不应自行
|
|||||||
| `getPendingPanelFile` | Admin | `deviceId` | 获取指定设备下一条待处理 CSV/JSON;无数据时 `pending: false`,有数据时返回文件信息和 `url`。 |
|
| `getPendingPanelFile` | Admin | `deviceId` | 获取指定设备下一条待处理 CSV/JSON;无数据时 `pending: false`,有数据时返回文件信息和 `url`。 |
|
||||||
| `ackPanelFile` | Admin | `deviceId`、`fileID` | Panel 处理完成后确认,移除 inbox 项并标记历史记录为 `processed`,不立即删除文件。 |
|
| `ackPanelFile` | Admin | `deviceId`、`fileID` | Panel 处理完成后确认,移除 inbox 项并标记历史记录为 `processed`,不立即删除文件。 |
|
||||||
| `listIdentificationFiles` | Admin | `deviceId`、可选 `mediaType`、`status`、`page`、`pageSize` | 分页返回设备的辨识 CSV/JSON 暂存历史。 |
|
| `listIdentificationFiles` | Admin | `deviceId`、可选 `mediaType`、`status`、`page`、`pageSize` | 分页返回设备的辨识 CSV/JSON 暂存历史。 |
|
||||||
| `getIdentificationFileDownload` | Admin | `fileID` | 返回原始辨识文件的短期签名下载 URL。 |
|
| `getIdentificationFileDownload` | Admin | `fileID` | 返回原始辨识文件的一次性临时下载 URL。 |
|
||||||
| `deleteIdentificationFile` | Admin | `fileID` | 显式删除辨识文件、历史记录及待处理消息。 |
|
| `deleteIdentificationFile` | Admin | `fileID` | 显式删除辨识文件、历史记录及待处理消息。 |
|
||||||
|
|
||||||
CSV 辨识文件须先以同名 `runId` 调用 `registerIdentificationResult`,才会出现在 `getPendingPanelFile`;初始行程 JSON 可直接获取。
|
CSV 辨识文件须先以同名 `runId` 调用 `registerIdentificationResult`,才会出现在 `getPendingPanelFile`;初始行程 JSON 可直接获取。
|
||||||
@@ -97,7 +131,7 @@ CSV 辨识文件须先以同名 `runId` 调用 `registerIdentificationResult`,
|
|||||||
| `createVolumeConfigRequest` | 无 | `deviceId` | ReinLoop 创建一次性上传请求,返回 `requestId`、`createdAtMs`、`expiresAtMs`。同设备旧请求会被替换。 |
|
| `createVolumeConfigRequest` | 无 | `deviceId` | ReinLoop 创建一次性上传请求,返回 `requestId`、`createdAtMs`、`expiresAtMs`。同设备旧请求会被替换。 |
|
||||||
| `getPendingVolumeConfigRequest` | 无 | `deviceId` | 查询是否存在待上传请求,返回 `pending` 和请求时间信息。 |
|
| `getPendingVolumeConfigRequest` | 无 | `deviceId` | 查询是否存在待上传请求,返回 `pending` 和请求时间信息。 |
|
||||||
| `submitVolumeConfigFile` | 无 | `deviceId`、`requestId`、`fileID`、可选 `fileName` | 将已上传到 `<deviceId>/volume_config_requests/<requestId>/` 的文件绑定至请求。 |
|
| `submitVolumeConfigFile` | 无 | `deviceId`、`requestId`、`fileID`、可选 `fileName` | 将已上传到 `<deviceId>/volume_config_requests/<requestId>/` 的文件绑定至请求。 |
|
||||||
| `getVolumeConfigRequest` | 无 | `deviceId`、`requestId` | 轮询配置是否就绪,返回 `ready`、`expired`;就绪时包含下载 `url`。 |
|
| `getVolumeConfigRequest` | 无 | `deviceId`、`requestId` | 轮询配置是否就绪,返回 `ready`、`expired`;就绪时包含一次性临时下载 `url`。 |
|
||||||
| `ackVolumeConfigRequest` | 无 | `deviceId`、`requestId` | ReinLoop 下载完成后确认,清理请求及关联文件。 |
|
| `ackVolumeConfigRequest` | 无 | `deviceId`、`requestId` | ReinLoop 下载完成后确认,清理请求及关联文件。 |
|
||||||
|
|
||||||
请求有效期由 `VOLUME_REQUEST_TTL_MS` 控制,默认 300000 毫秒(5 分钟)。
|
请求有效期由 `VOLUME_REQUEST_TTL_MS` 控制,默认 300000 毫秒(5 分钟)。
|
||||||
|
|||||||
+175
-42
@@ -1,12 +1,13 @@
|
|||||||
const fs = require("node:fs");
|
const fs = require("node:fs");
|
||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
const { randomUUID, constants, createHmac, timingSafeEqual, verify } = require("node:crypto");
|
const { randomUUID, constants, verify } = require("node:crypto");
|
||||||
const express = require("express");
|
const express = require("express");
|
||||||
const multer = require("multer");
|
const multer = require("multer");
|
||||||
|
|
||||||
const BASE_FOLDER = "ReinLoop_GUI";
|
const BASE_FOLDER = "ReinLoop_GUI";
|
||||||
const VOLUME_REQUEST_TTL_MS = Number(process.env.VOLUME_REQUEST_TTL_MS || 300000);
|
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 DOWNLOAD_URL_TTL_MS = Number(process.env.DOWNLOAD_URL_TTL_MS || process.env.DOWNLOAD_TOKEN_TTL_MS || 300000);
|
||||||
|
const DEVICE_TOKEN_TTL_MS = Number(process.env.DEVICE_TOKEN_TTL_MS || 15 * 60 * 1000);
|
||||||
const IDENTIFICATION_RETENTION_MS = Number(process.env.IDENTIFICATION_RETENTION_MS || 30 * 24 * 60 * 60 * 1000);
|
const IDENTIFICATION_RETENTION_MS = Number(process.env.IDENTIFICATION_RETENTION_MS || 30 * 24 * 60 * 60 * 1000);
|
||||||
const DEVICE_HEARTBEAT_TTL_MS = 30_000;
|
const DEVICE_HEARTBEAT_TTL_MS = 30_000;
|
||||||
const CONFIG_SCHEMAS = {
|
const CONFIG_SCHEMAS = {
|
||||||
@@ -161,6 +162,8 @@ function createApp({
|
|||||||
const app = express();
|
const app = express();
|
||||||
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 100 * 1024 * 1024 } });
|
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 100 * 1024 * 1024 } });
|
||||||
const pendingUploads = new Map();
|
const pendingUploads = new Map();
|
||||||
|
const pendingDownloads = new Map();
|
||||||
|
const pendingDeviceTokens = new Map();
|
||||||
|
|
||||||
app.disable("x-powered-by");
|
app.disable("x-powered-by");
|
||||||
app.use(express.json({ limit: "2mb" }));
|
app.use(express.json({ limit: "2mb" }));
|
||||||
@@ -200,23 +203,68 @@ function createApp({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function signDownload(fileID, expiresAtMs) {
|
function requestIp(req) {
|
||||||
return createHmac("sha256", adminToken).update(`${fileID}\n${expiresAtMs}`).digest("hex");
|
return String(req.ip || req.socket?.remoteAddress || "").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function downloadUrl(req, fileID) {
|
function issueDownloadUrl(req, fileID) {
|
||||||
const baseUrl = `${publicBaseUrl(req)}/files/${encodeURIComponent(fileID)}`;
|
const ticket = randomUUID().replace(/-/g, "");
|
||||||
if (fileID.startsWith("model://")) return baseUrl;
|
pendingDownloads.set(ticket, {
|
||||||
const expires = Date.now() + DOWNLOAD_TOKEN_TTL_MS;
|
fileID,
|
||||||
return `${baseUrl}?expires=${expires}&token=${signDownload(fileID, expires)}`;
|
requestIp: requestIp(req),
|
||||||
|
expiresAtMs: Date.now() + DOWNLOAD_URL_TTL_MS
|
||||||
|
});
|
||||||
|
return `${publicBaseUrl(req)}/downloads/${ticket}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasValidDownloadToken(req, fileID) {
|
function consumeDownloadTicket(req, ticket) {
|
||||||
const expires = Number(req.query.expires);
|
const request = pendingDownloads.get(ticket);
|
||||||
const token = String(req.query.token || "");
|
if (!request) return { ok: false, errMsg: "下载链接无效或已失效" };
|
||||||
if (!Number.isSafeInteger(expires) || expires < Date.now() || !/^[0-9a-f]{64}$/.test(token)) return false;
|
pendingDownloads.delete(ticket);
|
||||||
const expected = signDownload(fileID, expires);
|
if (request.expiresAtMs < Date.now()) return { ok: false, errMsg: "下载链接已过期" };
|
||||||
return timingSafeEqual(Buffer.from(token, "hex"), Buffer.from(expected, "hex"));
|
if (!request.requestIp || request.requestIp !== requestIp(req)) {
|
||||||
|
return { ok: false, errMsg: "下载请求来源IP不匹配" };
|
||||||
|
}
|
||||||
|
return { ok: true, fileID: request.fileID };
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyLicenseRecord(record, deviceId) {
|
||||||
|
if (!record) return { success: false, valid: false, status: "not_found" };
|
||||||
|
if (deviceId && 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function issueDeviceToken({ deviceId, licenseId }) {
|
||||||
|
const token = randomUUID().replace(/-/g, "");
|
||||||
|
const expiresAtMs = Date.now() + DEVICE_TOKEN_TTL_MS;
|
||||||
|
pendingDeviceTokens.set(token, { deviceId, licenseId, expiresAtMs });
|
||||||
|
return { token, expiresAtMs };
|
||||||
|
}
|
||||||
|
|
||||||
|
function consumeDeviceContext(event) {
|
||||||
|
const deviceToken = String(event.deviceToken || "").trim();
|
||||||
|
if (!deviceToken) {
|
||||||
|
return { ok: false, errCode: "DEVICE_TOKEN_REQUIRED", errMsg: "缺少 deviceToken" };
|
||||||
|
}
|
||||||
|
const session = pendingDeviceTokens.get(deviceToken);
|
||||||
|
if (!session) {
|
||||||
|
return { ok: false, errCode: "DEVICE_TOKEN_INVALID", errMsg: "deviceToken 无效" };
|
||||||
|
}
|
||||||
|
if (session.expiresAtMs <= Date.now()) {
|
||||||
|
pendingDeviceTokens.delete(deviceToken);
|
||||||
|
return { ok: false, errCode: "DEVICE_TOKEN_EXPIRED", errMsg: "deviceToken 已过期" };
|
||||||
|
}
|
||||||
|
return { ok: true, ...session };
|
||||||
}
|
}
|
||||||
|
|
||||||
//config test-----------------------------------
|
//config test-----------------------------------
|
||||||
@@ -540,17 +588,8 @@ function createApp({
|
|||||||
case "validateLicense": {
|
case "validateLicense": {
|
||||||
const database = await store.read();
|
const database = await store.read();
|
||||||
const record = database.licenses.find((item) => item.licenseId === event.licenseId);
|
const record = database.licenses.find((item) => item.licenseId === event.licenseId);
|
||||||
if (!record) return { success: true, valid: false, status: "not_found" };
|
const deviceId = event.deviceId ? normalizeDeviceId(event.deviceId) : null;
|
||||||
if (event.deviceId && normalizeDeviceId(event.deviceId) !== record.deviceId) {
|
return verifyLicenseRecord(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":
|
case "uploadDataFile":
|
||||||
if (normalizeRelativePath(event.folder, "data_record").endsWith("/model_config")) {
|
if (normalizeRelativePath(event.folder, "data_record").endsWith("/model_config")) {
|
||||||
@@ -584,13 +623,11 @@ function createApp({
|
|||||||
const database = await store.read();
|
const database = await store.read();
|
||||||
const record = database.fileRecords.find((item) => item.fileID === event.fileID);
|
const record = database.fileRecords.find((item) => item.fileID === event.fileID);
|
||||||
if (!record || !store.resolveStoredFile(record.fileID)) return { success: false, errMsg: "文件不存在" };
|
if (!record || !store.resolveStoredFile(record.fileID)) return { success: false, errMsg: "文件不存在" };
|
||||||
if (!record.fileID.startsWith("model://")) {
|
const authError = requireAdmin(event);
|
||||||
const authError = requireAdmin(event);
|
if (authError) return { success: false, errMsg: authError };
|
||||||
if (authError) return { success: false, errMsg: authError };
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
url: downloadUrl(req, record.fileID),
|
url: issueDownloadUrl(req, record.fileID),
|
||||||
fileName: record.fileName,
|
fileName: record.fileName,
|
||||||
originalFileName: record.originalFileName || record.fileName
|
originalFileName: record.originalFileName || record.fileName
|
||||||
};
|
};
|
||||||
@@ -651,7 +688,7 @@ function createApp({
|
|||||||
if (!record) return { success: true, found: false, deviceId };
|
if (!record) return { success: true, found: false, deviceId };
|
||||||
return {
|
return {
|
||||||
success: true, found: true, deviceId, fileID: record.fileID,
|
success: true, found: true, deviceId, fileID: record.fileID,
|
||||||
fileName: record.fileName, url: downloadUrl(req, record.fileID),
|
fileName: record.fileName, url: issueDownloadUrl(req, record.fileID),
|
||||||
updatedAtMs: config.updatedAtMs, requestId: config.requestId
|
updatedAtMs: config.updatedAtMs, requestId: config.requestId
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -667,7 +704,7 @@ function createApp({
|
|||||||
const record = database.fileRecords.find((item) => item.folder === `${deviceId}/identification_config` && item.fileName === "identification_config.csv");
|
const record = database.fileRecords.find((item) => item.folder === `${deviceId}/identification_config` && item.fileName === "identification_config.csv");
|
||||||
if (!record) return { success: false, errMsg: "服务器尚未配置辨识参数" };
|
if (!record) return { success: false, errMsg: "服务器尚未配置辨识参数" };
|
||||||
logConfigRead({ configType: "identification", deviceId, record });
|
logConfigRead({ configType: "identification", deviceId, record });
|
||||||
return { success: true, fileName: record.fileName, fileID: record.fileID, cloudPath: record.cloudPath, url: downloadUrl(req, record.fileID) };
|
return { success: true, fileName: record.fileName, fileID: record.fileID, cloudPath: record.cloudPath, url: issueDownloadUrl(req, record.fileID) };
|
||||||
}
|
}
|
||||||
case "getPendingPanelFile": {
|
case "getPendingPanelFile": {
|
||||||
const authError = requireAdmin(event);
|
const authError = requireAdmin(event);
|
||||||
@@ -689,7 +726,7 @@ function createApp({
|
|||||||
fileName: message.fileName,
|
fileName: message.fileName,
|
||||||
mediaType: message.mediaType,
|
mediaType: message.mediaType,
|
||||||
uploadTime: message.uploadTime,
|
uploadTime: message.uploadTime,
|
||||||
url: downloadUrl(req, message.fileID)
|
url: issueDownloadUrl(req, message.fileID)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case "getPendingPanelNotification": {
|
case "getPendingPanelNotification": {
|
||||||
@@ -812,7 +849,7 @@ function createApp({
|
|||||||
fileName: record.fileName,
|
fileName: record.fileName,
|
||||||
uploadTime: record.uploadTime,
|
uploadTime: record.uploadTime,
|
||||||
size: record.size,
|
size: record.size,
|
||||||
url: downloadUrl(req, record.fileID)
|
url: issueDownloadUrl(req, record.fileID)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case "deleteControlFile": {
|
case "deleteControlFile": {
|
||||||
@@ -844,7 +881,7 @@ function createApp({
|
|||||||
fileID: fileRecord.fileID,
|
fileID: fileRecord.fileID,
|
||||||
fileName: fileRecord.fileName,
|
fileName: fileRecord.fileName,
|
||||||
mediaType: historyRecord.mediaType,
|
mediaType: historyRecord.mediaType,
|
||||||
url: downloadUrl(req, fileRecord.fileID)
|
url: issueDownloadUrl(req, fileRecord.fileID)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case "deleteIdentificationFile": {
|
case "deleteIdentificationFile": {
|
||||||
@@ -969,7 +1006,7 @@ function createApp({
|
|||||||
const fileRecord = database.fileRecords.find((item) => item.fileID === record.configFileID);
|
const fileRecord = database.fileRecords.find((item) => item.fileID === record.configFileID);
|
||||||
if (!fileRecord) return { success: false, errMsg: "容积配置文件记录不存在" };
|
if (!fileRecord) return { success: false, errMsg: "容积配置文件记录不存在" };
|
||||||
logConfigRead({ configType: "volume", deviceId, requestId: record.requestId, record: fileRecord });
|
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) };
|
return { success: true, ready: true, expired: false, requestId: record.requestId, fileName: record.configFileName, fileID: fileRecord.fileID, cloudPath: fileRecord.cloudPath, uploadedAtMs: record.uploadedAtMs, url: issueDownloadUrl(req, record.configFileID) };
|
||||||
}
|
}
|
||||||
case "ackVolumeConfigRequest": {
|
case "ackVolumeConfigRequest": {
|
||||||
const deviceId = normalizeDeviceId(event.deviceId);
|
const deviceId = normalizeDeviceId(event.deviceId);
|
||||||
@@ -989,6 +1026,88 @@ function createApp({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function dispatchDevice(event, req) {
|
||||||
|
if (event.type === "deviceAuth") {
|
||||||
|
const licenseId = String(event.licenseId || "").trim();
|
||||||
|
if (!licenseId) return { success: false, errCode: "LICENSE_ID_REQUIRED", errMsg: "缺少 licenseId" };
|
||||||
|
const deviceId = normalizeDeviceId(event.deviceId);
|
||||||
|
const database = await store.read();
|
||||||
|
const record = database.licenses.find((item) => item.licenseId === licenseId);
|
||||||
|
const validation = verifyLicenseRecord(record, deviceId);
|
||||||
|
if (!validation.valid) {
|
||||||
|
const errCodeMap = {
|
||||||
|
not_found: "LICENSE_NOT_FOUND",
|
||||||
|
device_mismatch: "DEVICE_ID_MISMATCH",
|
||||||
|
expired: "LICENSE_EXPIRED",
|
||||||
|
revoked: "LICENSE_REVOKED"
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errCode: errCodeMap[validation.status] || "LICENSE_INVALID",
|
||||||
|
errMsg: `许可证不可用: ${validation.status}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const issued = issueDeviceToken({ deviceId, licenseId });
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
deviceId,
|
||||||
|
licenseId,
|
||||||
|
deviceToken: issued.token,
|
||||||
|
expiresAtMs: issued.expiresAtMs
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = consumeDeviceContext(event);
|
||||||
|
if (!context.ok) return { success: false, errCode: context.errCode, errMsg: context.errMsg };
|
||||||
|
const deviceId = context.deviceId;
|
||||||
|
|
||||||
|
switch (event.type) {
|
||||||
|
case "deviceHeartbeat":
|
||||||
|
case "registerIdentificationResult":
|
||||||
|
case "getIdentificationFeedback":
|
||||||
|
case "ackIdentificationFeedback":
|
||||||
|
case "createVolumeConfigRequest":
|
||||||
|
case "getPendingVolumeConfigRequest":
|
||||||
|
case "submitVolumeConfigFile":
|
||||||
|
case "getVolumeConfigRequest":
|
||||||
|
case "ackVolumeConfigRequest":
|
||||||
|
case "getIdentificationConfig":
|
||||||
|
return dispatch({ ...event, deviceId }, req);
|
||||||
|
case "uploadDataFile": {
|
||||||
|
const folder = normalizeRelativePath(event.folder, "data_record");
|
||||||
|
const devicePrefix = `${deviceId}/`;
|
||||||
|
if (!folder.startsWith(devicePrefix)) {
|
||||||
|
return { success: false, errMsg: "设备接口仅允许访问当前 deviceId 目录" };
|
||||||
|
}
|
||||||
|
if (folder.endsWith("/model_config")) {
|
||||||
|
return { success: false, errMsg: "设备接口不允许上传模型" };
|
||||||
|
}
|
||||||
|
return dispatch({ ...event, folder }, req);
|
||||||
|
}
|
||||||
|
case "listModels": {
|
||||||
|
const folder = `${deviceId}/model_config`;
|
||||||
|
return dispatch({ ...event, folder }, req);
|
||||||
|
}
|
||||||
|
case "downloadModel": {
|
||||||
|
if (!event.fileID) return { success: false, errMsg: "缺少 fileID" };
|
||||||
|
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.folder !== `${deviceId}/model_config`) {
|
||||||
|
return { success: false, errMsg: "设备接口无权下载该模型" };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
url: issueDownloadUrl(req, record.fileID),
|
||||||
|
fileName: record.fileName,
|
||||||
|
originalFileName: record.originalFileName || record.fileName
|
||||||
|
};
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return { success: false, errMsg: "无效的 type 字段" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
app.get("/health", (req, res) => res.json({ success: true, service: "reinloop-server" }));
|
app.get("/health", (req, res) => res.json({ success: true, service: "reinloop-server" }));
|
||||||
|
|
||||||
app.post("/upload/:token", upload.single("file"), async (req, res, next) => {
|
app.post("/upload/:token", upload.single("file"), async (req, res, next) => {
|
||||||
@@ -1085,12 +1204,13 @@ function createApp({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/files/:fileID", async (req, res, next) => {
|
app.get("/downloads/:ticket", async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const fileID = decodeURIComponent(req.params.fileID);
|
const ticket = String(req.params.ticket || "").trim();
|
||||||
if (!fileID.startsWith("model://") && !hasValidDownloadToken(req, fileID)) {
|
if (!ticket) return res.status(403).json({ success: false, errMsg: "下载链接无效或已失效" });
|
||||||
return res.status(403).json({ success: false, errMsg: "下载凭证无效或已过期" });
|
const consumed = consumeDownloadTicket(req, ticket);
|
||||||
}
|
if (!consumed.ok) return res.status(403).json({ success: false, errMsg: consumed.errMsg });
|
||||||
|
const fileID = consumed.fileID;
|
||||||
const database = await store.read();
|
const database = await store.read();
|
||||||
const record = database.fileRecords.find((item) => item.fileID === fileID);
|
const record = database.fileRecords.find((item) => item.fileID === fileID);
|
||||||
const filePath = record && store.resolveStoredFile(fileID);
|
const filePath = record && store.resolveStoredFile(fileID);
|
||||||
@@ -1103,6 +1223,10 @@ function createApp({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get("/files/:fileID", (req, res) => {
|
||||||
|
res.status(403).json({ success: false, errMsg: "旧下载链接已停用,请先通过 API 获取临时下载链接" });
|
||||||
|
});
|
||||||
|
|
||||||
const apiHandler = async (req, res) => {
|
const apiHandler = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
res.json(await dispatch(normalizeEventCompat(req.body || {}), req));
|
res.json(await dispatch(normalizeEventCompat(req.body || {}), req));
|
||||||
@@ -1112,6 +1236,15 @@ function createApp({
|
|||||||
};
|
};
|
||||||
app.post("/", apiHandler);
|
app.post("/", apiHandler);
|
||||||
|
|
||||||
|
const deviceApiHandler = async (req, res) => {
|
||||||
|
try {
|
||||||
|
res.json(await dispatchDevice(normalizeEventCompat(req.body || {}), req));
|
||||||
|
} catch (error) {
|
||||||
|
res.status(400).json({ success: false, errMsg: error.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
app.post("/device", deviceApiHandler);
|
||||||
|
|
||||||
app.use((error, req, res, next) => {
|
app.use((error, req, res, next) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
res.status(500).json({ success: false, errMsg: "服务器内部错误" });
|
res.status(500).json({ success: false, errMsg: "服务器内部错误" });
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ test("existing two-step client flow uploads, lists, and downloads a file", async
|
|||||||
});
|
});
|
||||||
const downloaded = await fetch(download.url);
|
const downloaded = await fetch(download.url);
|
||||||
assert.equal(await downloaded.text(), "time,pressure\n0,10\n");
|
assert.equal(await downloaded.text(), "time,pressure\n0,10\n");
|
||||||
|
assert.equal((await fetch(download.url)).status, 403);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("admin lists, downloads, and deletes only the selected device control data", async () => {
|
test("admin lists, downloads, and deletes only the selected device control data", async () => {
|
||||||
@@ -159,8 +160,9 @@ test("admin lists, downloads, and deletes only the selected device control data"
|
|||||||
});
|
});
|
||||||
assert.equal(download.success, true);
|
assert.equal(download.success, true);
|
||||||
assert.equal(download.size, Buffer.byteLength('{"parts":1}'));
|
assert.equal(download.size, Buffer.byteLength('{"parts":1}'));
|
||||||
assert.match(download.url, /expires=.*token=/);
|
assert.match(download.url, /\/downloads\//);
|
||||||
assert.equal(await (await fetch(download.url)).text(), '{"parts":1}');
|
assert.equal(await (await fetch(download.url)).text(), '{"parts":1}');
|
||||||
|
assert.equal((await fetch(download.url)).status, 403);
|
||||||
|
|
||||||
const forbiddenDelete = await post({
|
const forbiddenDelete = await post({
|
||||||
type: "deleteControlFile", fileID: "model://control-co/line-1/controller.bin", adminToken: "test-token"
|
type: "deleteControlFile", fileID: "model://control-co/line-1/controller.bin", adminToken: "test-token"
|
||||||
|
|||||||
Reference in New Issue
Block a user