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_id 保存当前生效的 recorder.yaml 和 transfer.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.yaml 和 transfer.yaml。 |
+
+
+ content |
+ 原始模板文本,最大 256 KiB,不能为空或全空白。 |
+
+
+ _active_unique |
+ MySQL 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.yaml 和 transfer.yaml。
+ PUT content 不能为空或全空白。
+ PUT content 大小不能超过 256 KiB。
+ - 第一版不做 YAML 语法校验,也不校验 placeholder 白名单。
+ DELETE 幂等。模板不存在时也返回 204 No Content。
+
+
+
+
+ Synapse 第一版范围
+ 前端放在 robot type 页面中,不做独立复杂模板管理模块。
+
+ - 页面加载时调用列表接口,展示
recorder.yaml 和 transfer.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 文档需要重新生成。
+
+
+
+
+ 实现清单
+
+ - 新增数据库 migration:
robot_type_config_templates。
+ - 新增或扩展 Keystone handler:public raw template GET。
+ - 新增 admin template CRUD handler。
+ - 在 server route 中区分 public route 和 admin JWT route。
+ - 补 handler 单元测试。
+ - 更新 Synapse robot type 页面,增加两个上传槽位。
+ - 重新生成 Swagger 文档。
+
+
+
+
+
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;