398 lines
13 KiB
Go
398 lines
13 KiB
Go
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
|
||
}
|