Files
xk-ai-agent/internal/handler/kb_admin_handler.go

398 lines
13 KiB
Go
Raw Permalink Normal View History

2026-08-14 21:50:48 +08:00
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——
// 大文件(>32MBmultipart 会落磁盘临时文件,单次 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
}