package handler import ( "io" "log" "net/http" "strconv" "tcm-agent/internal/kb" "github.com/gin-gonic/gin" ) // ======================================================================== // KBAdminHandler —— 本地知识库后台管理 API // ======================================================================== // 暴露 9 个端点(前缀 /api/v1/kb/admin): // // 库管理: // GET /libraries 列出所有库(含禁用的) // GET /libraries/:id 取单个库详情 // POST /libraries 创建库 // DELETE /libraries/:id 软删除库(连带文档+分段) // // 文档管理: // GET /libraries/:id/docs 列出某库下所有文档 // POST /docs/import 导入文档(multipart 上传文件) // GET /docs/:id 取文档详情 // DELETE /docs/:id 软删除文档(连带分段) // // 分段管理: // GET /docs/:id/chunks 列出某文档的分段 // // 工具: // POST /search 检索测试(前端"试一试"按钮用) // POST /embed 【V2 预留】触发批量向量化,V1 返回"未启用" // ======================================================================== // KBAdminHandler 后台管理处理器 type KBAdminHandler struct { libSvc *kb.LibraryService searcher *kb.Searcher } // NewKBAdminHandler 构造 func NewKBAdminHandler(libSvc *kb.LibraryService, searcher *kb.Searcher) *KBAdminHandler { return &KBAdminHandler{libSvc: libSvc, searcher: searcher} } // ---------------------------- 库管理 ---------------------------- // ListLibraries 列出所有库 // GET /api/v1/kb/admin/libraries func (h *KBAdminHandler) ListLibraries(c *gin.Context) { rows, err := h.libSvc.ListLibraries(c.Request.Context()) if err != nil { // 关键:把错误打到日志,方便后端排查(DB 未连/表不存在/SQL 语法错都会在这里暴露) log.Printf("[KB] ListLibraries 失败: %v", err) c.JSON(http.StatusOK, gin.H{"code": 500, "message": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"code": 200, "data": rows}) } // GetLibrary 取单个库详情 // GET /api/v1/kb/admin/libraries/:id func (h *KBAdminHandler) GetLibrary(c *gin.Context) { id, err := parseUintParam(c, "id") if err != nil { c.JSON(http.StatusOK, gin.H{"code": 400, "message": err.Error()}) return } row, err := h.libSvc.GetLibrary(c.Request.Context(), id) if err != nil { c.JSON(http.StatusOK, gin.H{"code": 404, "message": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"code": 200, "data": row}) } // CreateLibrary 创建库 // POST /api/v1/kb/admin/libraries // // Body: kb.CreateLibraryInput func (h *KBAdminHandler) CreateLibrary(c *gin.Context) { var in kb.CreateLibraryInput if err := c.ShouldBindJSON(&in); err != nil { c.JSON(http.StatusOK, gin.H{"code": 400, "message": "参数错误: " + err.Error()}) return } row, err := h.libSvc.CreateLibrary(c.Request.Context(), in) if err != nil { c.JSON(http.StatusOK, gin.H{"code": 500, "message": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"code": 200, "data": row}) } // DeleteLibrary 软删除库(连带文档+分段) // DELETE /api/v1/kb/admin/libraries/:id func (h *KBAdminHandler) DeleteLibrary(c *gin.Context) { id, err := parseUintParam(c, "id") if err != nil { c.JSON(http.StatusOK, gin.H{"code": 400, "message": err.Error()}) return } if err := h.libSvc.DeleteLibrary(c.Request.Context(), id); err != nil { c.JSON(http.StatusOK, gin.H{"code": 500, "message": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"code": 200, "message": "删除成功"}) } // ---------------------------- 文档管理 ---------------------------- // ListDocs 列出某库下的文档 // GET /api/v1/kb/admin/libraries/:id/docs func (h *KBAdminHandler) ListDocs(c *gin.Context) { id, err := parseUintParam(c, "id") if err != nil { c.JSON(http.StatusOK, gin.H{"code": 400, "message": err.Error()}) return } rows, err := h.libSvc.ListDocs(c.Request.Context(), id) if err != nil { c.JSON(http.StatusOK, gin.H{"code": 500, "message": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"code": 200, "data": rows}) } // ImportDocument 导入文档 // POST /api/v1/kb/admin/docs/import // // 表单字段: // library_id (必填, form 字段) // title (可选, form 字段,自定义文档标题) // file (必填, multipart 文件) // max_len (可选, form 字段,自定义分段最大长度 100~2000,缺省 500) // overlap (可选, form 字段,自定义分段重叠 0~500,缺省 50) // // 支持 .xlsx / .xls / .csv / .md / .txt / .pdf / .docx / .html, // 具体解析与自动分段规则见 kb.ParseFileFromBytes func (h *KBAdminHandler) ImportDocument(c *gin.Context) { libraryIDStr := c.PostForm("library_id") libraryID, err := strconv.ParseUint(libraryIDStr, 10, 64) if err != nil || libraryID == 0 { c.JSON(http.StatusOK, gin.H{"code": 400, "message": "library_id 不能为空"}) return } title := c.PostForm("title") // 自定义分段参数:解析失败或未传时用哨兵值(max_len=0 / overlap=-1 表示"用默认") // overlap 不能用 0 当哨兵——0 是合法值(不重叠),语义与"没传"不同 maxLen := 0 if v := c.PostForm("max_len"); v != "" { if n, e := strconv.Atoi(v); e == nil { maxLen = n } } overlap := -1 if v := c.PostForm("overlap"); v != "" { if n, e := strconv.Atoi(v); e == nil { overlap = n } } fileHeader, err := c.FormFile("file") if err != nil { c.JSON(http.StatusOK, gin.H{"code": 400, "message": "请上传文件: " + err.Error()}) return } // 限制文件大小 50MB(中医知识库单文件不会超过这个) if fileHeader.Size > 50*1024*1024 { c.JSON(http.StatusOK, gin.H{"code": 400, "message": "文件过大(最大 50MB)"}) return } file, err := fileHeader.Open() if err != nil { c.JSON(http.StatusOK, gin.H{"code": 500, "message": "打开上传文件失败: " + err.Error()}) return } defer file.Close() // 读全部字节:必须用 io.ReadFull 而不是单次 file.Read—— // 大文件(>32MB)multipart 会落磁盘临时文件,单次 Read 不保证读满缓冲区, // 读不满会导致导入的内容被截断 buf := make([]byte, fileHeader.Size) if _, err := io.ReadFull(file, buf); err != nil { c.JSON(http.StatusOK, gin.H{"code": 500, "message": "读取上传文件失败: " + err.Error()}) return } result, err := h.libSvc.ImportDocument(c.Request.Context(), kb.ImportDocInput{ LibraryID: uint(libraryID), Filename: fileHeader.Filename, Content: buf, Title: title, MaxLen: maxLen, Overlap: overlap, }) if err != nil { c.JSON(http.StatusOK, gin.H{"code": 500, "message": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"code": 200, "data": result}) } // GetDoc 取文档详情 // GET /api/v1/kb/admin/docs/:id func (h *KBAdminHandler) GetDoc(c *gin.Context) { id, err := parseUintParam(c, "id") if err != nil { c.JSON(http.StatusOK, gin.H{"code": 400, "message": err.Error()}) return } row, err := h.libSvc.GetDoc(c.Request.Context(), id) if err != nil { c.JSON(http.StatusOK, gin.H{"code": 404, "message": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"code": 200, "data": row}) } // DeleteDoc 软删除文档(连带分段) // DELETE /api/v1/kb/admin/docs/:id func (h *KBAdminHandler) DeleteDoc(c *gin.Context) { id, err := parseUintParam(c, "id") if err != nil { c.JSON(http.StatusOK, gin.H{"code": 400, "message": err.Error()}) return } if err := h.libSvc.DeleteDoc(c.Request.Context(), id); err != nil { c.JSON(http.StatusOK, gin.H{"code": 500, "message": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"code": 200, "message": "删除成功"}) } // ---------------------------- 分段管理 ---------------------------- // ListChunks 列出文档的分段 // GET /api/v1/kb/admin/docs/:id/chunks func (h *KBAdminHandler) ListChunks(c *gin.Context) { id, err := parseUintParam(c, "id") if err != nil { c.JSON(http.StatusOK, gin.H{"code": 400, "message": err.Error()}) return } rows, err := h.libSvc.ListChunks(c.Request.Context(), id) if err != nil { log.Printf("[KB] ListChunks 失败: %v", err) c.JSON(http.StatusOK, gin.H{"code": 500, "message": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"code": 200, "data": rows}) } // UpdateChunk 编辑分段 // PUT /api/v1/kb/admin/chunks/:id // // Body: kb.UpdateChunkInput // { "title": "...", "content": "...", "related_questions": ["问题1","问题2"] } // // 三个字段都可选,传啥改啥;related_questions 传空数组表示清空关联问题 func (h *KBAdminHandler) UpdateChunk(c *gin.Context) { id, err := parseUintParam(c, "id") if err != nil { c.JSON(http.StatusOK, gin.H{"code": 400, "message": err.Error()}) return } var in kb.UpdateChunkInput if err := c.ShouldBindJSON(&in); err != nil { c.JSON(http.StatusOK, gin.H{"code": 400, "message": "参数错误: " + err.Error()}) return } // 内容不能为空(标题可空);例外:只带 is_active 的启停开关调用不改内容,放行 if in.Content == "" && in.IsActive == nil { c.JSON(http.StatusOK, gin.H{"code": 400, "message": "content 不能为空"}) return } updated, err := h.libSvc.UpdateChunk(c.Request.Context(), id, in) if err != nil { log.Printf("[KB] UpdateChunk 失败 id=%d: %v", id, err) c.JSON(http.StatusOK, gin.H{"code": 500, "message": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"code": 200, "data": updated, "message": "已保存"}) } // BatchUpdateChunks 批量启用/禁用/删除分段 // PUT /api/v1/kb/admin/chunks/batch // // Body: { "ids": [1,2,3], "action": "enable|disable|delete" } // // 管理前端的分段多选批量操作入口;事务原子性由 DAO 保证 func (h *KBAdminHandler) BatchUpdateChunks(c *gin.Context) { var in kb.BatchChunkInput if err := c.ShouldBindJSON(&in); err != nil { c.JSON(http.StatusOK, gin.H{"code": 400, "message": "参数错误: " + err.Error()}) return } affected, err := h.libSvc.BatchUpdateChunks(c.Request.Context(), in) if err != nil { log.Printf("[KB] BatchUpdateChunks 失败 action=%s ids=%d: %v", in.Action, len(in.IDs), err) c.JSON(http.StatusOK, gin.H{"code": 500, "message": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"affected": affected}, "message": "操作成功"}) } // RechunkDocument 用新参数对文档重新分段 // POST /api/v1/kb/admin/docs/:id/rechunk // // Body: { "max_len": 500, "overlap": 50 }(0/-1 表示用默认值) // // 用 xk_kb_doc.content 存的原文重切,旧分段软删、新分段插入(事务)。 // 注意:人工编辑过的分段内容会被覆盖,前端调用前必须二次确认 func (h *KBAdminHandler) RechunkDocument(c *gin.Context) { id, err := parseUintParam(c, "id") if err != nil { c.JSON(http.StatusOK, gin.H{"code": 400, "message": err.Error()}) return } var in kb.RechunkInput // body 可以整个不传(全用默认参数),绑定失败不视为错误 if err := c.ShouldBindJSON(&in); err != nil { in = kb.RechunkInput{MaxLen: 0, Overlap: -1} } result, err := h.libSvc.RechunkDocument(c.Request.Context(), id, in) if err != nil { log.Printf("[KB] RechunkDocument 失败 id=%d: %v", id, err) c.JSON(http.StatusOK, gin.H{"code": 500, "message": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"code": 200, "data": result, "message": "重新分段完成"}) } // ---------------------------- 工具 ---------------------------- // KBSearchRequest 检索测试入参 type KBSearchRequest struct { LibraryID uint `json:"library_id" binding:"required"` Query string `json:"query" binding:"required"` TopK int `json:"top_k"` Mode string `json:"mode"` } // Search 检索测试 // POST /api/v1/kb/admin/search // // V1 返回 FULLTEXT 得分;V2 接入向量后支持 mode=vector/blend func (h *KBAdminHandler) Search(c *gin.Context) { var req KBSearchRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusOK, gin.H{"code": 400, "message": "参数错误: " + err.Error()}) return } results, err := h.searcher.Search(c.Request.Context(), kb.SearchOptions{ LibraryID: req.LibraryID, Query: req.Query, TopK: req.TopK, Mode: req.Mode, }) if err != nil { c.JSON(http.StatusOK, gin.H{"code": 500, "message": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"code": 200, "data": results}) } // EmbedRequest 触发向量化入参(V2 用) type EmbedRequest struct { LibraryID uint `json:"library_id" binding:"required"` } // Embed 【V2 预留】触发批量向量化 // POST /api/v1/kb/admin/embed // // V1 始终返回"未启用",前端展示对应提示 func (h *KBAdminHandler) Embed(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "code": 501, "message": "向量化功能 V2 才支持(当前 NoopEmbedder 未启用)。请保持 search_mode=fulltext。", }) } // ---------------------------- 工具函数 ---------------------------- // parseUintParam 解析路径参数 :id 为 uint func parseUintParam(c *gin.Context, key string) (uint, error) { v, err := strconv.ParseUint(c.Param(key), 10, 64) if err != nil || v == 0 { return 0, errInvalidID } return uint(v), nil }