解耦前的最后一个准备,可恢复版本

This commit is contained in:
sladro 2026-01-16 17:54:10 +08:00
parent 19a37a3de3
commit 0a5a879418
12 changed files with 411 additions and 40 deletions

View File

@ -236,6 +236,38 @@ Response 200`{"ok":true}`
失败500 + `{"error":"..."}`
### 5.3 `PUT /v1/media-server/configs/{name}`
用途:上传 media-server 配置文件到 `agent.media_server_process.configs_dir`
**Auth**必须401
Headers
- `Content-Type: application/json`
- `X-RK-Token: ...`
Path params
- `name`: string仅允许 `[A-Za-z0-9._-]`,禁止 `/`、`\\`、`..`;若无 `.json` 后缀则自动追加)
Bodymedia-server 配置 JSON
Response 200
```json
{
"ok": true,
"name": "cam1.json",
"path": "/opt/rk3588sys/configs/cam1.json",
"size": 1234,
"mtime_ms": 1730000000000
}
```
失败:
- 400name 非法 / Content-Type 非 application/json / JSON 无效 / 空 body
- 401unauthorized
- 413超过 `max_upload_mb`
- 501`configs_dir` 未配置
- 500写盘失败
## 6. 主程序进程控制agent 对外)
> 说明:该能力用于“启动/重启/关闭主程序media-server并选择加载哪个配置文件”。

View File

@ -0,0 +1,145 @@
# PRD_06 多硬件解耦计划RK3588/Atlas/Jetson
## 1. 背景与目标
当前工程深度绑定 RK3588RKNN/RGA/MPP/DMA-BUF限制了 Atlas、Jetson 等平台的接入。目标是在**不破坏现有功能**的前提下,通过接口解耦与默认实现保留现有行为,实现多硬件可插拔支持。
### 目标
- 以接口层抽象 **推理、图像处理、编解码、缓冲区** 四个核心模块。
- 默认实现保持 RK3588 行为与性能路径(零拷贝/DMA-BUF
- 逐步迁移现有节点,避免一次性大改。
### 非目标
- 不改变业务逻辑(检测/识别流程、RTSP/HLS 业务)。
- 不在本阶段引入新 UI/协议或跨平台发布流程。
## 2. 现状摘要(关键路径)
- 推理:`include/ai_scheduler.h`, `src/ai_scheduler.cpp`RKNN + DMA-BUF 输入)
- 图像处理:`plugins/preprocess/preprocess_node.cpp`RGA 或 swscale
- 编解码:`plugins/input_rtsp/*`, `plugins/input_file/*`, `plugins/publish/*`MPP + FFmpeg 混用)
- 缓冲:`include/frame/frame.h`dma_fd/planes/data_owner
## 3. 总体方案
引入四类接口与默认实现:
- **IInferBackend**:推理后端抽象(默认 RKNN
- **IImageProcessor**:图像预处理抽象(默认 RGA + swscale 兜底)
- **IDecoder / IEncoder**:编解码抽象(默认 MPP必要时 FFmpeg
- **FrameBuffer**:统一缓冲区语义与同步
所有接口通过工厂/配置注入,保持现有 JSON 配置兼容。
## 4. 实施步骤、里程碑与单元测试
### Step 1建立基础抽象与工厂
**实施内容**
- 新建 `include/hw/` 下接口定义:`i_infer_backend.h`, `i_image_processor.h`, `i_decoder.h`, `i_encoder.h`, `frame_buffer.h`
- 定义最小能力集:
- IInferBackend: `LoadModel`, `Infer`, `InferBorrowed`
- IImageProcessor: `Resize`, `CvtColor`, `Normalize`
- IDecoder/IEncoder: `Open`, `Send`, `Receive`, `Close`
- FrameBuffer: `Planes()`, `DmaFd()`, `SyncStart/End()`
- 新建工厂:`hw_factory.h/cpp`,根据配置返回默认实现
**关键里程碑**
- 接口头文件编译通过;工程无行为变化
- 工厂默认返回 RK3588 实现(空实现也可先用占位)
**单元测试GTest**
- `HwFactory_Defaults_ReturnsRk3588Impls`
- `FrameBuffer_Metadata_Preserved`dma_fd/planes 赋值一致性)
---
### Step 2推理模块解耦RKNN → IInferBackend
**实施内容**
- 将 `AiScheduler` 包装为 `RknnInferBackend` 实现
- `ai_*` 节点依赖 `IInferBackend` 接口注入(保留默认行为)
- 保留 `InferBorrowed` 以支持零拷贝输入
**关键里程碑**
- 现有模型推理链路无回归,性能基准一致(同配置)
- RKNN 仍可多上下文并发
**单元测试GTest**
- `InferBackend_LoadModel_Smoke`(加载模型返回成功)
- `InferBackend_BorrowedInput_UsesDmaFd`(检查传入 dma_fd 路径被调用)
---
### Step 3图像预处理解耦RGA/CPU → IImageProcessor
**实施内容**
- 抽取 RGA 路径为 `RgaImageProcessor`
- 抽取 swscale 路径为 `SwscaleImageProcessor`
- `preprocess_node` 仅面向接口调用
**关键里程碑**
- `use_rga=true/false` 行为完全一致
- RGA 限流逻辑RgaGate保留
**单元测试GTest**
- `ImageProcessor_RgaVsSwscale_OutputShape`(输出尺寸一致)
- `ImageProcessor_ColorConversion_Nv12ToRgb`(像素格式转换)
---
### Step 4编解码解耦MPP/FFmpeg → IDecoder/IEncoder
**实施内容**
- `MppDecoder`, `FfmpegDecoder` 实现 `IDecoder`
- `MppEncoder`, `FfmpegEncoder` 实现 `IEncoder`
- `input_rtsp/input_file/publish/storage` 节点仅面向接口
**关键里程碑**
- RTSP 输入与 HLS 输出链路不变
- MPP 仍为默认路径FFmpeg 作为兜底/平台适配
**单元测试GTest**
- `Decoder_Open_Close_Smoke`
- `Encoder_Open_Close_Smoke`
- `Codec_Pipeline_EncodeDecode_OneFrame`(小尺寸样例帧)
---
### Step 5缓冲区抽象Frame → FrameBuffer
**实施内容**
- 新增 `FrameBuffer`,替代直接使用 `Frame` 的 dma_fd/data_owner
- `Frame` 保留为业务结构,内部持有 `FrameBuffer`
- 统一 DMA 同步接口以便多硬件适配
**关键里程碑**
- DMA-BUF 与内存缓冲区均可通过统一接口访问
- `Frame` 兼容旧字段,最小侵入替换完成
**单元测试GTest**
- `FrameBuffer_Sync_NoCrash`
- `FrameBuffer_PlaneAccess_Consistent`
---
### Step 6多硬件适配接入Atlas/Jetson
**实施内容**
- 新增 `AtlasInferBackend/AtlasImageProcessor/AtlasCodec` 实现(占位/实验性)
- 新增 `JetsonInferBackend/JetsonImageProcessor/JetsonCodec` 实现
- 通过配置切换平台实现
**关键里程碑**
- 不影响 RK3588 默认路径
- 新平台可在单机完成 smoke 测试
**单元测试GTest**
- `HwFactory_SelectsBackend_ByConfig`
- `PlatformImpls_Smoke_Construct`(构造/释放)
## 5. 风险与缓解
- **接口过宽导致迁移成本增加** → 控制最小接口集,逐步扩展
- **性能回退** → 保留 RK3588 默认实现与 DMA-BUF 快路径
- **迁移破坏现有节点** → 节点逐个替换,保持旧路径可回退
## 6. 验证与回滚
### 验证命令
```
scripts/build_host.sh
ctest --test-dir build/host --output-on-failure
```
### 回滚策略
- 以编译开关/配置切换回旧路径
- 保留 RK3588 实现作为默认后端

Binary file not shown.

View File

@ -10,6 +10,7 @@ import (
"mime"
"net/http"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
@ -28,6 +29,7 @@ type Server struct {
ms *mediaserver.Client
store *modelstore.Store
proc *procctl.Controller
baseDir string
deviceID string
hostname string
agentPort int
@ -60,6 +62,7 @@ func New(agentCfg config.AgentConfig, baseDir string, ms *mediaserver.Client, st
ms: ms,
store: store,
proc: pc,
baseDir: baseDir,
deviceID: deviceID,
hostname: sysinfo.Hostname(),
agentPort: agentPort,
@ -85,6 +88,7 @@ func New(agentCfg config.AgentConfig, baseDir string, ms *mediaserver.Client, st
mux.HandleFunc("/v1/media-server/restart", s.handleMediaRestart)
mux.HandleFunc("/v1/media-server/stop", s.handleMediaStop)
mux.HandleFunc("/v1/media-server/status", s.handleMediaStatus)
mux.HandleFunc("/v1/media-server/configs/", s.handleMediaConfigUpload)
mux.HandleFunc("/v1/graphs", s.handleGraphs)
mux.HandleFunc("/v1/graphs/", s.handleGraphDetail)
mux.HandleFunc("/v1/logs/recent", s.handleLogsRecent)
@ -205,6 +209,7 @@ func (s *Server) applyRootConfigBytes(ctx context.Context, body []byte) error {
}
var modelNameRE = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
var configNameRE = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
func (s *Server) handleModelUpload(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
@ -285,6 +290,74 @@ func (s *Server) handleModelsList(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, m)
}
func (s *Server) handleMediaConfigUpload(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
errorJSON(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
if !s.authorize(r, true) {
errorJSON(w, http.StatusUnauthorized, "unauthorized")
return
}
if mt, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")); err != nil || mt != "application/json" {
errorJSON(w, http.StatusBadRequest, "validation failed: Content-Type must be application/json")
return
}
name := strings.TrimPrefix(r.URL.Path, "/v1/media-server/configs/")
name = strings.TrimSpace(name)
finalName, err := normalizeConfigName(name)
if err != nil {
errorJSON(w, http.StatusBadRequest, "validation failed: invalid name")
return
}
configsDir, err := s.resolveConfigsDir()
if err != nil {
errorJSON(w, http.StatusNotImplemented, "not supported")
return
}
maxBytes := int64(s.agentCfg.MaxUploadMB) * 1024 * 1024
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
body, err := io.ReadAll(r.Body)
if err != nil {
if strings.Contains(err.Error(), "request body too large") {
errorJSON(w, http.StatusRequestEntityTooLarge, "payload too large")
return
}
errorJSON(w, http.StatusBadRequest, "invalid json: "+err.Error())
return
}
if len(body) == 0 {
errorJSON(w, http.StatusBadRequest, "validation failed: empty body")
return
}
var tmp any
if err := json.Unmarshal(body, &tmp); err != nil {
errorJSON(w, http.StatusBadRequest, "invalid json: "+err.Error())
return
}
dst := filepath.Join(configsDir, finalName)
if err := files.WriteFileAtomic(dst, append(body, '\n'), 0o644); err != nil {
errorJSON(w, http.StatusInternalServerError, "internal error: "+err.Error())
return
}
st, err := os.Stat(dst)
if err != nil {
errorJSON(w, http.StatusInternalServerError, "internal error: "+err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"name": finalName,
"path": filepath.ToSlash(dst),
"size": st.Size(),
"mtime_ms": st.ModTime().UnixMilli(),
})
}
func (s *Server) handleMediaReload(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
errorJSON(w, http.StatusMethodNotAllowed, "method not allowed")
@ -447,6 +520,33 @@ func (s *Server) handleMediaStatus(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "running": st.Running, "pid": st.Pid, "config_path": st.ConfigPath})
}
func normalizeConfigName(name string) (string, error) {
if name == "" || strings.Contains(name, "/") || strings.Contains(name, "\\") || strings.Contains(name, "..") {
return "", errors.New("invalid name")
}
if !configNameRE.MatchString(name) {
return "", errors.New("invalid name")
}
if !strings.HasSuffix(strings.ToLower(name), ".json") {
name += ".json"
}
return name, nil
}
func (s *Server) resolveConfigsDir() (string, error) {
base := strings.TrimSpace(s.agentCfg.MediaServerProcess.ConfigsDir)
if base == "" {
return "", errors.New("configs_dir is empty")
}
if filepath.IsAbs(base) {
return base, nil
}
if s.baseDir == "" {
return filepath.Clean(base), nil
}
return filepath.Join(s.baseDir, base), nil
}
func readOptionalJSON[T any](w http.ResponseWriter, r *http.Request, maxBytes int64) (T, error) {
var zero T
if r.Body == nil {

29
agent/orangepi@10.0.0.81 Normal file
View File

@ -0,0 +1,29 @@
{
"agent": {
"listen": "0.0.0.0:9100",
"token": "CHANGE_ME",
"require_token_for_read": false,
"discovery_enable": true,
"discovery_port": 35688,
"device_name": "cam1_strict_minio_alarm",
"device_id_path": "./device_id",
"models_dir": "./models",
"max_upload_mb": 200,
"config_path": "./configs/test_cam1_strict_minio_alarm_rtsp_server.json",
"media_server_process": {
"enable": true,
"exec_path": "../build/media-server",
"work_dir": "..",
"configs_dir": "../configs",
"pid_file": "./rk3588sys-media-server.pid",
"graceful_timeout_ms": 5000
},
"media_server_base_url": "http://127.0.0.1:9000",
"media_server_timeout_ms": 3000,
"media_server_retry": { "max_attempts": 3, "backoff_ms": [200, 500] }
}
}

View File

@ -2,7 +2,7 @@
"queue": { "size": 8, "strategy": "drop_oldest" },
"graphs": [
{
"name": "cam1_native_face_dual_output",
"name": "cam1_native_main",
"nodes": [
{
"id": "in_cam1",
@ -30,7 +30,7 @@
"dst_format": "rgb",
"dst_packed": true,
"keep_ratio": false,
"rga_gate": "cam1_native_face_dual_output",
"rga_gate": "cam1_native_main",
"use_rga": true
},
{
@ -53,7 +53,7 @@
"enable": true,
"mode": "bytetrack_lite",
"per_class": true,
"state_key": "cam1_native_face_dual_output",
"state_key": "cam1_native_main",
"track_classes": [0],
"ignore_classes": [],
"allowed_models": ["yolov5", "yolov8"],
@ -86,7 +86,7 @@
"dst_h": 720,
"dst_format": "nv12",
"keep_ratio": false,
"rga_gate": "cam1_native_face_dual_output",
"rga_gate": "cam1_native_main",
"use_rga": true
},
{
@ -109,7 +109,7 @@
"id": "alarm_cam1",
"type": "alarm",
"role": "sink",
"enable": true,
"enable": false,
"eval_fps": 10,
"labels": [],
"rules": [
@ -125,7 +125,7 @@
"actions": {
"log": { "enable": true, "level": "info" },
"snapshot": {
"enable": true,
"enable": false,
"format": "jpg",
"quality": 85,
"upload": {
@ -138,7 +138,7 @@
}
},
"clip": {
"enable": true,
"enable": false,
"pre_sec": 5,
"post_sec": 10,
"format": "mp4",
@ -160,19 +160,47 @@
"method": "POST"
}
}
},
}
],
"edges": [
["in_cam1", "pre_cam1"],
["pre_cam1", "yolo_cam1"],
["yolo_cam1", "trk_cam1"],
["trk_cam1", "osd_cam1"],
["osd_cam1", "post_cam1"],
["post_cam1", "pub_cam1"],
["pub_cam1", "alarm_cam1"]
]
},
{
"name": "cam1_face_recog_rtsp",
"nodes": [
{
"id": "pre_face_cam1",
"id": "in_cam1",
"type": "input_rtsp",
"role": "source",
"enable": true,
"url": "rtsp://10.0.0.5:8554/cam",
"fps": 30,
"width": 1280,
"height": 720,
"use_mpp": true,
"use_ffmpeg": false,
"force_tcp": true,
"reconnect_sec": 5,
"reconnect_backoff_max_sec": 30
},
{
"id": "pre_cam1",
"type": "preprocess",
"role": "filter",
"enable": true,
"dst_w": 0,
"dst_h": 0,
"dst_w": 1280,
"dst_h": 720,
"dst_format": "rgb",
"dst_packed": true,
"keep_ratio": false,
"rga_gate": "cam1_native_face_dual_output",
"rga_gate": "cam1_face_recog_rtsp",
"use_rga": true
},
{
@ -225,11 +253,11 @@
"type": "preprocess",
"role": "filter",
"enable": true,
"dst_w": 0,
"dst_h": 0,
"dst_w": 1280,
"dst_h": 720,
"dst_format": "nv12",
"keep_ratio": false,
"rga_gate": "cam1_native_face_dual_output",
"rga_gate": "cam1_face_recog_rtsp",
"use_rga": true
},
{
@ -251,16 +279,8 @@
],
"edges": [
["in_cam1", "pre_cam1"],
["pre_cam1", "yolo_cam1"],
["yolo_cam1", "trk_cam1"],
["trk_cam1", "osd_cam1"],
["osd_cam1", "post_cam1"],
["post_cam1", "pub_cam1"],
["pub_cam1", "alarm_cam1"],
["in_cam1", "pre_face_cam1", {"queue": {"size": 1, "strategy": "drop_oldest"}}],
["pre_face_cam1", "face_det_cam1", {"queue": {"size": 1, "strategy": "drop_oldest"}}],
["face_det_cam1", "face_recog_cam1", {"queue": {"size": 1, "strategy": "drop_oldest"}}],
["pre_cam1", "face_det_cam1"],
["face_det_cam1", "face_recog_cam1"],
["face_recog_cam1", "osd_face_cam1"],
["osd_face_cam1", "post_face_cam1"],
["post_face_cam1", "pub_face_cam1"]

View File

@ -1,6 +1,7 @@
#pragma once
#include <string>
#include <unordered_set>
#include "utils/simple_json.h"
@ -185,12 +186,19 @@ inline bool ValidateExpandedRootConfig(const SimpleJson& root, std::string& err)
err = "graph.edges must be array";
return false;
}
std::unordered_set<std::string> node_ids;
for (const auto& nv : nodes->AsArray()) {
std::string nerr;
if (!ValidateNodeCfg(nv, nerr)) {
err = "graph.nodes invalid: " + nerr;
return false;
}
const SimpleJson* id = nv.Find("id");
const std::string id_value = id ? id->AsString("") : "";
if (!id_value.empty() && !node_ids.insert(id_value).second) {
err = "graph.nodes duplicate id: " + id_value;
return false;
}
}
for (const auto& ev : edges->AsArray()) {
std::string eerr;
@ -198,6 +206,30 @@ inline bool ValidateExpandedRootConfig(const SimpleJson& root, std::string& err)
err = "graph.edges invalid: " + eerr;
return false;
}
std::string from;
std::string to;
if (ev.IsArray()) {
const auto& a = ev.AsArray();
if (a.size() >= 2) {
from = a[0].AsString("");
to = a[1].AsString("");
}
} else if (ev.IsObject()) {
if (const SimpleJson* v = ev.Find("from")) {
from = v->AsString("");
}
if (const SimpleJson* v = ev.Find("to")) {
to = v->AsString("");
}
}
if (!from.empty() && node_ids.find(from) == node_ids.end()) {
err = "graph.edges unknown node: " + from;
return false;
}
if (!to.empty() && node_ids.find(to) == node_ids.end()) {
err = "graph.edges unknown node: " + to;
return false;
}
}
}
return true;

View File

@ -100,9 +100,13 @@ if(RK3588_ENABLE_RKNN AND RK_RKNN_LIB)
endif()
set_target_properties(ai_scheduler PROPERTIES
OUTPUT_NAME "ai_scheduler"
ARCHIVE_OUTPUT_DIRECTORY ${RK_PLUGIN_OUTPUT_DIR}
LIBRARY_OUTPUT_DIRECTORY ${RK_PLUGIN_OUTPUT_DIR}
RUNTIME_OUTPUT_DIRECTORY ${RK_PLUGIN_OUTPUT_DIR}
)
if(WIN32)
set_target_properties(ai_scheduler PROPERTIES WINDOWS_EXPORT_ALL_SYMBOLS ON)
endif()
add_library(input_rtsp SHARED input_rtsp/input_rtsp_node.cpp)
target_include_directories(input_rtsp PRIVATE ${CMAKE_SOURCE_DIR}/include ${CMAKE_SOURCE_DIR}/third_party)

View File

@ -13,6 +13,9 @@
#include <thread>
#if defined(_WIN32)
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <windows.h>
#elif defined(__unix__) || defined(__APPLE__)
#include <unistd.h>

View File

@ -74,7 +74,7 @@ TEST(SimpleJsonTest, ParseString) {
TEST(SimpleJsonTest, ParseStringEscapes) {
SimpleJson v;
std::string err;
EXPECT_TRUE(ParseSimpleJson(R"("line1\nline2\ttab\\slash\"quote")", v, err));
EXPECT_TRUE(ParseSimpleJson("\"line1\\nline2\\ttab\\\\slash\\\"quote\"", v, err));
EXPECT_TRUE(v.IsString());
EXPECT_EQ(v.AsString(), "line1\nline2\ttab\\slash\"quote");
}

View File

@ -237,18 +237,18 @@ TEST(SpscQueueTest, OnDataAvailableCallback) {
}
TEST(SpscQueueTest, ConcurrentPushPop) {
SpscQueue<int> q(100, QueueDropStrategy::DropOldest);
constexpr int kNumItems = 10000;
SpscQueue<int> q(kNumItems, QueueDropStrategy::Block);
std::atomic<int> sum{0};
std::thread producer([&q]() {
std::thread producer([&q, kNumItems]() {
for (int i = 1; i <= kNumItems; ++i) {
q.Push(i);
}
});
std::thread consumer([&q, &sum]() {
std::thread consumer([&q, &sum, kNumItems]() {
int received = 0;
while (received < kNumItems) {
int v;

View File

@ -6,20 +6,22 @@
ffmpeg -f dshow -video_size 1280x720 -vcodec mjpeg -i video="1080P USB Camera" -c:v libx264 -preset ultrafast -pix_fmt yuv420p -f rtsp rtsp://localhost:8554/cam
cmake -S . -B build \
-DCMAKE_BUILD_TYPE=Release \
-DRK3588_ENABLE_FFMPEG=ON \
-DRK3588_ENABLE_MPP=ON \
-DRK3588_ENABLE_RGA=ON \
-DRK3588_ENABLE_ZLMEDIAKIT=ON \
-DRK3588_ENABLE_RKNN=ON \
-DRK_ZLMK_API_LIB_PATH=$PWD/third_party/rknpu2/examples/3rdparty/zlmediakit/aarch64/libmk_api.so \
-DRK_ZLMEDIAKIT_INCLUDE_DIR=$PWD/third_party/rknpu2/examples/3rdparty/zlmediakit/include
-DCMAKE_BUILD_TYPE=Release \
-DBUILD_TESTS=OFF \
-DBUILD_SAMPLES=ON \
-DRK3588_ENABLE_FFMPEG=ON \
-DRK3588_ENABLE_MPP=ON \
-DRK3588_ENABLE_RGA=ON \
-DRK3588_ENABLE_ZLMEDIAKIT=ON \
-DRK3588_ENABLE_RKNN=ON \
-DRK_ZLMK_API_LIB_PATH=$PWD/third_party/rknpu2/examples/3rdparty/zlmediakit/aarch64/libmk_api.so \
-DRK_ZLMEDIAKIT_INCLUDE_DIR=$PWD/third_party/rknpu2/examples/3rdparty/zlmediakit/include
cmake --build build -j$(nproc)
//退出
pidof media-server
@ -112,4 +114,8 @@ ls -l ./rk3588-agent_linux_arm64
sudo systemctl restart rk3588-agent
这样运行后SSH 断开不会影响进程(由 systemd 托管)。如果你的 agent.config.json 里有相对路径(如 models 目录),记得写成绝对路径,或放到
/opt/rk3588-agent/ 并按配置调整。
/opt/rk3588-agent/ 并按配置调整。
## 后端启动命令
go run .\cmd\managerd\main.go .\managerd.json