From 270783adfe2d3ddc1077a32cb64c73170bd894f2 Mon Sep 17 00:00:00 2001 From: shark Date: Thu, 21 May 2026 20:10:35 +0800 Subject: [PATCH] feat(robot-types): add config template API --- docs/designs/robot-type-config-templates.html | 416 +++++++++++++++++ .../handlers/robot_type_config_template.go | 437 ++++++++++++++++++ .../robot_type_config_template_test.go | 336 ++++++++++++++ internal/server/server.go | 3 + ...00003_robot_type_config_templates.down.sql | 5 + .../000003_robot_type_config_templates.up.sql | 20 + 6 files changed, 1217 insertions(+) create mode 100644 docs/designs/robot-type-config-templates.html create mode 100644 internal/api/handlers/robot_type_config_template.go create mode 100644 internal/api/handlers/robot_type_config_template_test.go create mode 100644 internal/storage/database/migrations/000003_robot_type_config_templates.down.sql create mode 100644 internal/storage/database/migrations/000003_robot_type_config_templates.up.sql diff --git a/docs/designs/robot-type-config-templates.html b/docs/designs/robot-type-config-templates.html new file mode 100644 index 0000000..03d8e8a --- /dev/null +++ b/docs/designs/robot-type-config-templates.html @@ -0,0 +1,416 @@ + + + + + + + Robot Type Config Templates API Design + + + +
+
+

Robot Type Config Templates API Design

+

+ 本文定义 Keystone 中按机器人类型管理 Axon 运行配置模板的第一版方案。模板由 Synapse 管理, + Keystone 提供公开读取接口给未来 Axon 注册/刷新流程使用,Axon 在本地使用注册结果渲染最终配置文件。 +

+
+ +
+

目标

+
    +
  • 为每个 robot_type_id 保存当前生效的 recorder.yamltransfer.yaml 模板。
  • +
  • 提供公开 API 返回原始 YAML 模板文本,不要求 JWT。
  • +
  • 提供 admin API 给 Synapse 上传和管理模板。
  • +
  • Synapse 第一版只支持上传模板文件和查看存在状态,不展示模板正文。
  • +
  • Axon 本地负责模板渲染,Keystone 不替换 {{ robot_id }} 等占位符。
  • +
+
+ +
+

非目标

+
    +
  • 不实现模板版本历史、发布审批或回滚。
  • +
  • 不实现工厂级或机器人级模板覆盖。
  • +
  • 不校验 YAML 语法或模板 placeholder 是否被 Axon 支持。
  • +
  • 不实现旧接口 /configs/<factory>/<robot_type>/...
  • +
  • 本阶段不改 Axon 现有代码。
  • +
+
+ +
+

核心边界

+
+

+ Keystone 只返回原始模板内容。模板中的占位符原样保留,由 Axon 使用本地 + device.json 中的注册结果渲染最终配置。 +

+
+
device_id: "{{ device_id }}"
+factory_id: "{{ factory_id }}"
+robot_type_id: "{{ robot_type_id }}"
+robot_id: "{{ robot_id }}"
+transfer_ws_url: "{{ transfer_ws_url }}"
+
+ +
+

数据模型

+

第一版只保存当前 active 模板。删除采用软删除,同一 robot_type_id + filename 只能有一条 active 记录。

+
CREATE TABLE IF NOT EXISTS robot_type_config_templates (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    robot_type_id BIGINT NOT NULL,
+    filename VARCHAR(128) NOT NULL,
+    content MEDIUMTEXT NOT NULL,
+    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    deleted_at TIMESTAMP NULL,
+
+    _active_unique VARCHAR(300) GENERATED ALWAYS AS (
+        IF(deleted_at IS NULL, CONCAT(robot_type_id, '|', filename), NULL)
+    ) STORED,
+
+    UNIQUE INDEX idx_robot_type_config_templates_active (_active_unique),
+    INDEX idx_robot_type_config_templates_robot_type (robot_type_id),
+    INDEX idx_robot_type_config_templates_filename (filename),
+    INDEX idx_robot_type_config_templates_deleted (deleted_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+

字段说明

+ + + + + + + + + + + + + + + + + + + + + + + + + +
字段说明
robot_type_id关联 robot_types.id。接口层必须确认 robot type 存在且未软删除。
filename模板文件名。第一版只允许 recorder.yamltransfer.yaml
content原始模板文本,最大 256 KiB,不能为空或全空白。
_active_uniqueMySQL generated column,用于保证 active 模板唯一,同时允许保留多条软删除历史记录。
+
+ +
+

公开读取接口

+

该接口供未来 Axon 注册或刷新配置时读取模板。它不要求 JWT。

+
GET /api/v1/robot_types/{robot_type_id}/configs/{filename}
+

成功响应

+
HTTP/1.1 200 OK
+Content-Type: text/yaml; charset=utf-8
+
+rpc:
+  mode: ws_client
+  ws_client:
+    url: "{{ recorder_rpc_url }}"
+

错误响应

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
条件状态码响应
robot_type_id 不是正整数400{"error":"invalid robot_type_id"}
filename 不在白名单400{"error":"invalid config filename"}
robot type 不存在或已软删除404{"error":"robot_type not found"}
模板不存在404{"error":"config template not found"}
+
+ +
+

Admin 管理接口

+

以下接口供 Synapse 管理模板,必须要求 admin JWT。

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
方法路径用途
GET/api/v1/robot_types/{robot_type_id}/config_templates列出固定两个模板槽位和上传状态。
GET/api/v1/robot_types/{robot_type_id}/config_templates/{filename}获取单个模板正文和元数据,第一版前端可不用。
PUT/api/v1/robot_types/{robot_type_id}/config_templates/{filename}上传或更新模板。
DELETE/api/v1/robot_types/{robot_type_id}/config_templates/{filename}软删除模板。前端第一版可不暴露删除按钮。
+ +

列表响应

+
{
+  "templates": [
+    {
+      "filename": "recorder.yaml",
+      "exists": true,
+      "updated_at": "2026-05-21T10:00:00Z"
+    },
+    {
+      "filename": "transfer.yaml",
+      "exists": false,
+      "updated_at": null
+    }
+  ]
+}
+ +

上传请求

+
PUT /api/v1/robot_types/3/config_templates/recorder.yaml
+Content-Type: application/json
+
+{
+  "content": "rpc:\n  mode: ws_client\n"
+}
+ +

上传响应

+
{
+  "filename": "recorder.yaml",
+  "exists": true,
+  "created_at": "2026-05-21T10:00:00Z",
+  "updated_at": "2026-05-21T10:00:00Z"
+}
+
+ +
+

校验规则

+
    +
  • robot_type_id 必须是正整数。
  • +
  • robot type 必须存在且 deleted_at IS NULL
  • +
  • filename 只允许 recorder.yamltransfer.yaml
  • +
  • PUT content 不能为空或全空白。
  • +
  • PUT content 大小不能超过 256 KiB。
  • +
  • 第一版不做 YAML 语法校验,也不校验 placeholder 白名单。
  • +
  • DELETE 幂等。模板不存在时也返回 204 No Content
  • +
+
+ +
+

Synapse 第一版范围

+

前端放在 robot type 页面中,不做独立复杂模板管理模块。

+
    +
  • 页面加载时调用列表接口,展示 recorder.yamltransfer.yaml 两个槽位。
  • +
  • 每个槽位显示是否已上传和最后更新时间。
  • +
  • 每个槽位提供文件选择和上传按钮。
  • +
  • 浏览器读取本地 YAML 文件为文本,然后通过 JSON PUT 提交 content
  • +
  • 第一版不展示模板正文,不提供在线编辑器,不提供预览渲染。
  • +
  • 上传成功后重新拉列表或本地更新该槽位状态。
  • +
+
+ +
+

Axon 集成说明

+
+

+ 本阶段不改 Axon。Keystone 只实现新的 canonical API。当前 Axon 分支仍使用旧 + /configs/<factory>/<robot_type>/... URL 时,不会自动命中新接口。 +

+
+

未来 Axon 切换时应使用注册响应中的 robot_type_id 调用:

+
GET /api/v1/robot_types/{robot_type_id}/configs/recorder.yaml
+GET /api/v1/robot_types/{robot_type_id}/configs/transfer.yaml
+
+ +
+

测试范围

+
    +
  • Public GET 成功返回 raw YAML,Content-Type 为 text/yaml; charset=utf-8
  • +
  • Public GET 非法 id、非法 filename、robot type 不存在、模板不存在。
  • +
  • Admin list 固定返回两个槽位。
  • +
  • Admin PUT 新建模板、更新模板、拒绝空内容、拒绝超大内容。
  • +
  • Admin DELETE 软删除模板,并保持幂等。
  • +
  • Admin 接口需要 admin JWT。
  • +
  • Swagger/OpenAPI 文档需要重新生成。
  • +
+
+ +
+

实现清单

+
    +
  1. 新增数据库 migration:robot_type_config_templates
  2. +
  3. 新增或扩展 Keystone handler:public raw template GET。
  4. +
  5. 新增 admin template CRUD handler。
  6. +
  7. 在 server route 中区分 public route 和 admin JWT route。
  8. +
  9. 补 handler 单元测试。
  10. +
  11. 更新 Synapse robot type 页面,增加两个上传槽位。
  12. +
  13. 重新生成 Swagger 文档。
  14. +
+
+
+ + diff --git a/internal/api/handlers/robot_type_config_template.go b/internal/api/handlers/robot_type_config_template.go new file mode 100644 index 0000000..9a07cc9 --- /dev/null +++ b/internal/api/handlers/robot_type_config_template.go @@ -0,0 +1,437 @@ +// SPDX-FileCopyrightText: 2026 ArcheBase +// +// SPDX-License-Identifier: MulanPSL-2.0 + +package handlers + +import ( + "database/sql" + "net/http" + "strconv" + "strings" + "time" + + "archebase.com/keystone-edge/internal/logger" + "github.com/gin-gonic/gin" +) + +const maxRobotTypeConfigTemplateContentBytes = 256 * 1024 + +var robotTypeConfigTemplateFilenames = []string{"recorder.yaml", "transfer.yaml"} + +// RobotTypeConfigTemplateSummary represents one managed config template slot. +type RobotTypeConfigTemplateSummary struct { + Filename string `json:"filename"` + Exists bool `json:"exists"` + UpdatedAt *string `json:"updated_at"` +} + +// RobotTypeConfigTemplateListResponse represents the admin config template list response. +type RobotTypeConfigTemplateListResponse struct { + Templates []RobotTypeConfigTemplateSummary `json:"templates"` +} + +// RobotTypeConfigTemplateResponse represents a stored config template. +type RobotTypeConfigTemplateResponse struct { + Filename string `json:"filename"` + Content string `json:"content"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// RobotTypeConfigTemplateStatusResponse represents a saved template without returning its content. +type RobotTypeConfigTemplateStatusResponse struct { + Filename string `json:"filename"` + Exists bool `json:"exists"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// UpsertRobotTypeConfigTemplateRequest is the admin payload for creating/updating a template. +type UpsertRobotTypeConfigTemplateRequest struct { + Content string `json:"content"` +} + +type robotTypeConfigTemplateRow struct { + ID int64 `db:"id"` + Filename string `db:"filename"` + Content string `db:"content"` + CreatedAt sql.NullTime `db:"created_at"` + UpdatedAt sql.NullTime `db:"updated_at"` +} + +type robotTypeConfigTemplateSummaryRow struct { + Filename string `db:"filename"` + UpdatedAt sql.NullTime `db:"updated_at"` +} + +// RegisterConfigTemplatePublicRoutes registers robot type config template routes that do not require auth. +func (h *RobotTypeHandler) RegisterConfigTemplatePublicRoutes(apiV1 *gin.RouterGroup) { + apiV1.GET("/robot_types/:id/configs/:filename", h.GetRobotTypeConfig) +} + +// RegisterConfigTemplateAdminRoutes registers admin-only robot type config template routes. +func (h *RobotTypeHandler) RegisterConfigTemplateAdminRoutes(apiV1 *gin.RouterGroup) { + apiV1.GET("/robot_types/:id/config_templates", h.ListRobotTypeConfigTemplates) + apiV1.GET("/robot_types/:id/config_templates/:filename", h.GetRobotTypeConfigTemplate) + apiV1.PUT("/robot_types/:id/config_templates/:filename", h.UpsertRobotTypeConfigTemplate) + apiV1.DELETE("/robot_types/:id/config_templates/:filename", h.DeleteRobotTypeConfigTemplate) +} + +// GetRobotTypeConfig returns the raw template content for Axon/Synapse consumers. +// +// @Summary Get robot type config +// @Description Returns an unrendered robot type config template by robot type and filename. +// @Tags robot_type_config_templates +// @Produce plain +// @Param robot_type_id path string true "Robot Type ID" +// @Param filename path string true "Config filename" +// @Success 200 {string} string +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /robot_types/{robot_type_id}/configs/{filename} [get] +func (h *RobotTypeHandler) GetRobotTypeConfig(c *gin.Context) { + robotTypeID, filename, ok := parseRobotTypeConfigTemplatePath(c) + if !ok { + return + } + + if ok := h.ensureRobotTypeExists(c, robotTypeID); !ok { + return + } + + row, err := h.getActiveRobotTypeConfigTemplate(robotTypeID, filename) + if err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "config template not found"}) + return + } + logger.Printf("[ROBOT] Failed to query robot type config template: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get config template"}) + return + } + + c.Data(http.StatusOK, "text/yaml; charset=utf-8", []byte(row.Content)) +} + +// ListRobotTypeConfigTemplates lists the fixed config template slots for a robot type. +// +// @Summary List robot type config templates +// @Description Lists recorder.yaml and transfer.yaml upload status for a robot type. +// @Tags robot_type_config_templates +// @Accept json +// @Produce json +// @Param robot_type_id path string true "Robot Type ID" +// @Success 200 {object} RobotTypeConfigTemplateListResponse +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /robot_types/{robot_type_id}/config_templates [get] +func (h *RobotTypeHandler) ListRobotTypeConfigTemplates(c *gin.Context) { + robotTypeID, ok := parseRobotTypeConfigTemplateID(c) + if !ok { + return + } + + if ok := h.ensureRobotTypeExists(c, robotTypeID); !ok { + return + } + + rows := []robotTypeConfigTemplateSummaryRow{} + if err := h.db.Select(&rows, ` + SELECT filename, updated_at + FROM robot_type_config_templates + WHERE robot_type_id = ? AND deleted_at IS NULL + `, robotTypeID); err != nil { + logger.Printf("[ROBOT] Failed to list robot type config templates: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list config templates"}) + return + } + + byFilename := make(map[string]robotTypeConfigTemplateSummaryRow, len(rows)) + for _, row := range rows { + if isAllowedRobotTypeConfigFilename(row.Filename) { + byFilename[row.Filename] = row + } + } + + templates := make([]RobotTypeConfigTemplateSummary, 0, len(robotTypeConfigTemplateFilenames)) + for _, filename := range robotTypeConfigTemplateFilenames { + summary := RobotTypeConfigTemplateSummary{Filename: filename, Exists: false} + if row, ok := byFilename[filename]; ok { + summary.Exists = true + summary.UpdatedAt = formatRobotTypeConfigTemplateTimePtr(row.UpdatedAt) + } + templates = append(templates, summary) + } + + c.JSON(http.StatusOK, RobotTypeConfigTemplateListResponse{Templates: templates}) +} + +// GetRobotTypeConfigTemplate returns one stored template as JSON for admin management. +// +// @Summary Get robot type config template +// @Description Gets a robot type config template as JSON for admin management. +// @Tags robot_type_config_templates +// @Accept json +// @Produce json +// @Param robot_type_id path string true "Robot Type ID" +// @Param filename path string true "Config filename" +// @Success 200 {object} RobotTypeConfigTemplateResponse +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /robot_types/{robot_type_id}/config_templates/{filename} [get] +func (h *RobotTypeHandler) GetRobotTypeConfigTemplate(c *gin.Context) { + robotTypeID, filename, ok := parseRobotTypeConfigTemplatePath(c) + if !ok { + return + } + + if ok := h.ensureRobotTypeExists(c, robotTypeID); !ok { + return + } + + row, err := h.getActiveRobotTypeConfigTemplate(robotTypeID, filename) + if err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "config template not found"}) + return + } + logger.Printf("[ROBOT] Failed to query robot type config template: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get config template"}) + return + } + + c.JSON(http.StatusOK, robotTypeConfigTemplateRowToResponse(row)) +} + +// UpsertRobotTypeConfigTemplate creates or replaces the current active config template. +// +// @Summary Upsert robot type config template +// @Description Creates or replaces the current active config template for a robot type. +// @Tags robot_type_config_templates +// @Accept json +// @Produce json +// @Param robot_type_id path string true "Robot Type ID" +// @Param filename path string true "Config filename" +// @Param body body UpsertRobotTypeConfigTemplateRequest true "Config template payload" +// @Success 200 {object} RobotTypeConfigTemplateStatusResponse +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /robot_types/{robot_type_id}/config_templates/{filename} [put] +func (h *RobotTypeHandler) UpsertRobotTypeConfigTemplate(c *gin.Context) { + robotTypeID, filename, ok := parseRobotTypeConfigTemplatePath(c) + if !ok { + return + } + + var req UpsertRobotTypeConfigTemplateRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + if strings.TrimSpace(req.Content) == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "content is required"}) + return + } + if len(req.Content) > maxRobotTypeConfigTemplateContentBytes { + c.JSON(http.StatusBadRequest, gin.H{"error": "content too large"}) + return + } + + if ok := h.ensureRobotTypeExists(c, robotTypeID); !ok { + return + } + + tx, err := h.db.Begin() + if err != nil { + logger.Printf("[ROBOT] Failed to begin robot type config template transaction: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save config template"}) + return + } + defer tx.Rollback() //nolint:errcheck + + now := time.Now().UTC() + var existingID int64 + err = tx.QueryRow(` + SELECT id + FROM robot_type_config_templates + WHERE robot_type_id = ? AND filename = ? AND deleted_at IS NULL + LIMIT 1 + `, robotTypeID, filename).Scan(&existingID) + if err != nil && err != sql.ErrNoRows { + logger.Printf("[ROBOT] Failed to query robot type config template for update: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save config template"}) + return + } + + if err == sql.ErrNoRows { + _, err = tx.Exec(` + INSERT INTO robot_type_config_templates ( + robot_type_id, + filename, + content, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?) + `, robotTypeID, filename, req.Content, now, now) + } else { + _, err = tx.Exec(` + UPDATE robot_type_config_templates + SET content = ?, updated_at = ? + WHERE id = ? AND deleted_at IS NULL + `, req.Content, now, existingID) + } + if err != nil { + logger.Printf("[ROBOT] Failed to save robot type config template: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save config template"}) + return + } + + if err := tx.Commit(); err != nil { + logger.Printf("[ROBOT] Failed to commit robot type config template transaction: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save config template"}) + return + } + + row, err := h.getActiveRobotTypeConfigTemplate(robotTypeID, filename) + if err != nil { + logger.Printf("[ROBOT] Failed to fetch saved robot type config template: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save config template"}) + return + } + + c.JSON(http.StatusOK, robotTypeConfigTemplateRowToStatusResponse(row)) +} + +// DeleteRobotTypeConfigTemplate soft deletes the current active config template. +// +// @Summary Delete robot type config template +// @Description Deletes the current active config template for a robot type. +// @Tags robot_type_config_templates +// @Accept json +// @Produce json +// @Param robot_type_id path string true "Robot Type ID" +// @Param filename path string true "Config filename" +// @Success 204 +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /robot_types/{robot_type_id}/config_templates/{filename} [delete] +func (h *RobotTypeHandler) DeleteRobotTypeConfigTemplate(c *gin.Context) { + robotTypeID, filename, ok := parseRobotTypeConfigTemplatePath(c) + if !ok { + return + } + + if ok := h.ensureRobotTypeExists(c, robotTypeID); !ok { + return + } + + now := time.Now().UTC() + if _, err := h.db.Exec(` + UPDATE robot_type_config_templates + SET deleted_at = ?, updated_at = ? + WHERE robot_type_id = ? AND filename = ? AND deleted_at IS NULL + `, now, now, robotTypeID, filename); err != nil { + logger.Printf("[ROBOT] Failed to delete robot type config template: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete config template"}) + return + } + + c.Status(http.StatusNoContent) +} + +func parseRobotTypeConfigTemplateID(c *gin.Context) (int64, bool) { + id, err := strconv.ParseInt(strings.TrimSpace(c.Param("id")), 10, 64) + if err != nil || id <= 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid robot_type_id"}) + return 0, false + } + return id, true +} + +func parseRobotTypeConfigTemplatePath(c *gin.Context) (int64, string, bool) { + id, ok := parseRobotTypeConfigTemplateID(c) + if !ok { + return 0, "", false + } + + filename := strings.TrimSpace(c.Param("filename")) + if !isAllowedRobotTypeConfigFilename(filename) { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid config filename"}) + return 0, "", false + } + + return id, filename, true +} + +func isAllowedRobotTypeConfigFilename(filename string) bool { + for _, allowed := range robotTypeConfigTemplateFilenames { + if filename == allowed { + return true + } + } + return false +} + +func (h *RobotTypeHandler) ensureRobotTypeExists(c *gin.Context, id int64) bool { + var exists bool + if err := h.db.Get(&exists, "SELECT EXISTS(SELECT 1 FROM robot_types WHERE id = ? AND deleted_at IS NULL)", id); err != nil { + logger.Printf("[ROBOT] Failed to check robot type existence: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to check robot_type"}) + return false + } + if !exists { + c.JSON(http.StatusNotFound, gin.H{"error": "robot_type not found"}) + return false + } + return true +} + +func (h *RobotTypeHandler) getActiveRobotTypeConfigTemplate(robotTypeID int64, filename string) (robotTypeConfigTemplateRow, error) { + var row robotTypeConfigTemplateRow + err := h.db.Get(&row, ` + SELECT id, filename, content, created_at, updated_at + FROM robot_type_config_templates + WHERE robot_type_id = ? AND filename = ? AND deleted_at IS NULL + LIMIT 1 + `, robotTypeID, filename) + return row, err +} + +func robotTypeConfigTemplateRowToResponse(row robotTypeConfigTemplateRow) RobotTypeConfigTemplateResponse { + return RobotTypeConfigTemplateResponse{ + Filename: row.Filename, + Content: row.Content, + CreatedAt: formatRobotTypeConfigTemplateTime(row.CreatedAt), + UpdatedAt: formatRobotTypeConfigTemplateTime(row.UpdatedAt), + } +} + +func robotTypeConfigTemplateRowToStatusResponse(row robotTypeConfigTemplateRow) RobotTypeConfigTemplateStatusResponse { + return RobotTypeConfigTemplateStatusResponse{ + Filename: row.Filename, + Exists: true, + CreatedAt: formatRobotTypeConfigTemplateTime(row.CreatedAt), + UpdatedAt: formatRobotTypeConfigTemplateTime(row.UpdatedAt), + } +} + +func formatRobotTypeConfigTemplateTime(t sql.NullTime) string { + if !t.Valid { + return "" + } + return t.Time.UTC().Format(time.RFC3339) +} + +func formatRobotTypeConfigTemplateTimePtr(t sql.NullTime) *string { + if !t.Valid { + return nil + } + formatted := t.Time.UTC().Format(time.RFC3339) + return &formatted +} diff --git a/internal/api/handlers/robot_type_config_template_test.go b/internal/api/handlers/robot_type_config_template_test.go new file mode 100644 index 0000000..a96cbff --- /dev/null +++ b/internal/api/handlers/robot_type_config_template_test.go @@ -0,0 +1,336 @@ +// SPDX-FileCopyrightText: 2026 ArcheBase +// +// SPDX-License-Identifier: MulanPSL-2.0 + +package handlers + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/jmoiron/sqlx" + _ "modernc.org/sqlite" +) + +func TestRobotTypeConfigTemplatePublicGetSuccess(t *testing.T) { + db := newTestRobotTypeConfigTemplateDB(t) + defer db.Close() + seedRobotTypeConfigTemplateFixtures(t, db) + + now := time.Now().UTC() + if _, err := db.Exec(` + INSERT INTO robot_type_config_templates ( + robot_type_id, + filename, + content, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?) + `, 12, "recorder.yaml", "record:\n enabled: true\n", now, now); err != nil { + t.Fatalf("seed template fixture: %v", err) + } + + router := newTestRobotTypeConfigTemplateRouter(t, db) + req := httptest.NewRequest(http.MethodGet, "/api/v1/robot_types/12/configs/recorder.yaml", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status=%d want=%d body=%s", w.Code, http.StatusOK, w.Body.String()) + } + if got := w.Header().Get("Content-Type"); !strings.Contains(got, "text/yaml") { + t.Fatalf("content-type=%q want text/yaml", got) + } + if got := w.Body.String(); got != "record:\n enabled: true\n" { + t.Fatalf("body=%q", got) + } +} + +func TestRobotTypeConfigTemplatePathErrors(t *testing.T) { + db := newTestRobotTypeConfigTemplateDB(t) + defer db.Close() + seedRobotTypeConfigTemplateFixtures(t, db) + + router := newTestRobotTypeConfigTemplateRouter(t, db) + tests := []struct { + name string + path string + wantStatus int + wantError string + }{ + { + name: "invalid robot type id", + path: "/api/v1/robot_types/0/configs/recorder.yaml", + wantStatus: http.StatusBadRequest, + wantError: "invalid robot_type_id", + }, + { + name: "invalid filename", + path: "/api/v1/robot_types/12/configs/other.yaml", + wantStatus: http.StatusBadRequest, + wantError: "invalid config filename", + }, + { + name: "missing robot type", + path: "/api/v1/robot_types/99/configs/recorder.yaml", + wantStatus: http.StatusNotFound, + wantError: "robot_type not found", + }, + { + name: "missing template", + path: "/api/v1/robot_types/12/configs/transfer.yaml", + wantStatus: http.StatusNotFound, + wantError: "config template not found", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, tt.path, nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != tt.wantStatus { + t.Fatalf("status=%d want=%d body=%s", w.Code, tt.wantStatus, w.Body.String()) + } + if !strings.Contains(w.Body.String(), tt.wantError) { + t.Fatalf("body=%q want error %q", w.Body.String(), tt.wantError) + } + }) + } +} + +func TestRobotTypeConfigTemplateListReturnsFixedSlots(t *testing.T) { + db := newTestRobotTypeConfigTemplateDB(t) + defer db.Close() + seedRobotTypeConfigTemplateFixtures(t, db) + + now := time.Now().UTC() + if _, err := db.Exec(` + INSERT INTO robot_type_config_templates ( + robot_type_id, + filename, + content, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?) + `, 12, "recorder.yaml", "record: true\n", now, now); err != nil { + t.Fatalf("seed template fixture: %v", err) + } + + router := newTestRobotTypeConfigTemplateRouter(t, db) + req := httptest.NewRequest(http.MethodGet, "/api/v1/robot_types/12/config_templates", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status=%d want=%d body=%s", w.Code, http.StatusOK, w.Body.String()) + } + + var resp RobotTypeConfigTemplateListResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal response: %v body=%s", err, w.Body.String()) + } + if len(resp.Templates) != 2 { + t.Fatalf("templates=%#v want two fixed slots", resp.Templates) + } + if resp.Templates[0].Filename != "recorder.yaml" || !resp.Templates[0].Exists || resp.Templates[0].UpdatedAt == nil { + t.Fatalf("unexpected recorder slot: %#v", resp.Templates[0]) + } + if resp.Templates[1].Filename != "transfer.yaml" || resp.Templates[1].Exists || resp.Templates[1].UpdatedAt != nil { + t.Fatalf("unexpected transfer slot: %#v", resp.Templates[1]) + } +} + +func TestRobotTypeConfigTemplateUpsertValidation(t *testing.T) { + db := newTestRobotTypeConfigTemplateDB(t) + defer db.Close() + seedRobotTypeConfigTemplateFixtures(t, db) + + router := newTestRobotTypeConfigTemplateRouter(t, db) + tests := []struct { + name string + body []byte + wantStatus int + wantError string + }{ + { + name: "empty content", + body: []byte(`{"content":" "}`), + wantStatus: http.StatusBadRequest, + wantError: "content is required", + }, + { + name: "too large", + body: mustJSON(t, UpsertRobotTypeConfigTemplateRequest{Content: strings.Repeat("a", maxRobotTypeConfigTemplateContentBytes+1)}), + wantStatus: http.StatusBadRequest, + wantError: "content too large", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPut, "/api/v1/robot_types/12/config_templates/recorder.yaml", bytes.NewReader(tt.body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != tt.wantStatus { + t.Fatalf("status=%d want=%d body=%s", w.Code, tt.wantStatus, w.Body.String()) + } + if !strings.Contains(w.Body.String(), tt.wantError) { + t.Fatalf("body=%q want error %q", w.Body.String(), tt.wantError) + } + }) + } +} + +func TestRobotTypeConfigTemplateUpsertAndDelete(t *testing.T) { + db := newTestRobotTypeConfigTemplateDB(t) + defer db.Close() + seedRobotTypeConfigTemplateFixtures(t, db) + + router := newTestRobotTypeConfigTemplateRouter(t, db) + putTemplate(t, router, "recorder: first\n") + putTemplate(t, router, "recorder: second\n") + + var activeCount int + if err := db.Get(&activeCount, ` + SELECT COUNT(*) + FROM robot_type_config_templates + WHERE robot_type_id = 12 AND filename = 'recorder.yaml' AND deleted_at IS NULL + `); err != nil { + t.Fatalf("count active templates: %v", err) + } + if activeCount != 1 { + t.Fatalf("activeCount=%d want=1", activeCount) + } + + req := httptest.NewRequest(http.MethodGet, "/api/v1/robot_types/12/configs/recorder.yaml", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusOK || w.Body.String() != "recorder: second\n" { + t.Fatalf("public get status=%d body=%q", w.Code, w.Body.String()) + } + + req = httptest.NewRequest(http.MethodDelete, "/api/v1/robot_types/12/config_templates/recorder.yaml", nil) + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusNoContent { + t.Fatalf("delete status=%d want=%d body=%s", w.Code, http.StatusNoContent, w.Body.String()) + } + + req = httptest.NewRequest(http.MethodGet, "/api/v1/robot_types/12/configs/recorder.yaml", nil) + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusNotFound { + t.Fatalf("public get after delete status=%d want=%d body=%s", w.Code, http.StatusNotFound, w.Body.String()) + } +} + +func TestRobotTypeConfigTemplateRoutesDoNotConflictWithRobotTypeRoutes(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + v1 := router.Group("/api/v1") + handler := NewRobotTypeHandler(nil) + + handler.RegisterRoutes(v1) + handler.RegisterConfigTemplatePublicRoutes(v1) + handler.RegisterConfigTemplateAdminRoutes(v1) +} + +func putTemplate(t *testing.T, router *gin.Engine, content string) { + t.Helper() + body := mustJSON(t, UpsertRobotTypeConfigTemplateRequest{Content: content}) + req := httptest.NewRequest(http.MethodPut, "/api/v1/robot_types/12/config_templates/recorder.yaml", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("put status=%d want=%d body=%s", w.Code, http.StatusOK, w.Body.String()) + } + + var resp RobotTypeConfigTemplateStatusResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal put response: %v body=%s", err, w.Body.String()) + } + if resp.Filename != "recorder.yaml" || !resp.Exists || resp.UpdatedAt == "" { + t.Fatalf("unexpected put response: %#v", resp) + } +} + +func newTestRobotTypeConfigTemplateRouter(t *testing.T, db *sqlx.DB) *gin.Engine { + t.Helper() + gin.SetMode(gin.TestMode) + router := gin.New() + + handler := NewRobotTypeHandler(db) + v1 := router.Group("/api/v1") + handler.RegisterConfigTemplatePublicRoutes(v1) + handler.RegisterConfigTemplateAdminRoutes(v1) + + return router +} + +func newTestRobotTypeConfigTemplateDB(t *testing.T) *sqlx.DB { + t.Helper() + db, err := sqlx.Open("sqlite", ":memory:") + if err != nil { + t.Fatalf("open sqlite db: %v", err) + } + + schema := []string{ + `CREATE TABLE robot_types ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + model TEXT NOT NULL, + deleted_at TIMESTAMP NULL + )`, + `CREATE TABLE robot_type_config_templates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + robot_type_id INTEGER NOT NULL, + filename TEXT NOT NULL, + content TEXT NOT NULL, + created_at TIMESTAMP, + updated_at TIMESTAMP, + deleted_at TIMESTAMP NULL + )`, + } + + for _, stmt := range schema { + if _, err := db.Exec(stmt); err != nil { + t.Fatalf("create schema failed: %v", err) + } + } + + return db +} + +func seedRobotTypeConfigTemplateFixtures(t *testing.T, db *sqlx.DB) { + t.Helper() + stmts := []string{ + `INSERT INTO robot_types (id, name, model) VALUES (12, '搬运机器人', 'mover-v1')`, + `INSERT INTO robot_types (id, name, model, deleted_at) VALUES (13, '已删除类型', 'deleted-v1', '2026-01-01T00:00:00Z')`, + } + for _, stmt := range stmts { + if _, err := db.Exec(stmt); err != nil { + t.Fatalf("seed fixture failed: %v", err) + } + } +} + +func mustJSON(t *testing.T, v any) []byte { + t.Helper() + data, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal json: %v", err) + } + return data +} diff --git a/internal/server/server.go b/internal/server/server.go index d863e93..45e0a09 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -256,6 +256,9 @@ func (s *Server) buildRoutes() http.Handler { } if s.robotType != nil { s.robotType.RegisterRoutes(v1Tasks) + s.robotType.RegisterConfigTemplatePublicRoutes(v1Tasks) + adminRobotTypes := v1Routes.Group("", middleware.JWTAuth(&s.cfg.Auth), middleware.RequireRole("admin")) + s.robotType.RegisterConfigTemplateAdminRoutes(adminRobotTypes) } if s.robot != nil { s.robot.RegisterRoutes(v1Tasks) diff --git a/internal/storage/database/migrations/000003_robot_type_config_templates.down.sql b/internal/storage/database/migrations/000003_robot_type_config_templates.down.sql new file mode 100644 index 0000000..7feb0f7 --- /dev/null +++ b/internal/storage/database/migrations/000003_robot_type_config_templates.down.sql @@ -0,0 +1,5 @@ +-- SPDX-FileCopyrightText: 2026 ArcheBase +-- +-- SPDX-License-Identifier: MulanPSL-2.0 + +DROP TABLE IF EXISTS robot_type_config_templates; diff --git a/internal/storage/database/migrations/000003_robot_type_config_templates.up.sql b/internal/storage/database/migrations/000003_robot_type_config_templates.up.sql new file mode 100644 index 0000000..277d614 --- /dev/null +++ b/internal/storage/database/migrations/000003_robot_type_config_templates.up.sql @@ -0,0 +1,20 @@ +-- SPDX-FileCopyrightText: 2026 ArcheBase +-- +-- SPDX-License-Identifier: MulanPSL-2.0 + +CREATE TABLE IF NOT EXISTS robot_type_config_templates ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + robot_type_id BIGINT NOT NULL, + filename VARCHAR(128) NOT NULL, + content MEDIUMTEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at TIMESTAMP NULL, + _active_unique VARCHAR(300) GENERATED ALWAYS AS ( + IF(deleted_at IS NULL, CONCAT(robot_type_id, '|', filename), NULL) + ) STORED, + UNIQUE INDEX idx_robot_type_config_templates_active (_active_unique), + INDEX idx_robot_type_config_templates_robot_type (robot_type_id), + INDEX idx_robot_type_config_templates_filename (filename), + INDEX idx_robot_type_config_templates_deleted (deleted_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;