Files
xk-ai-agent/internal/handler/kb_admin_handler.go
2026-08-14 21:50:48 +08:00

398 lines
13 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}