Bladeren bron

feat(kb): 统一知识库管理端维护接口 + 页面

- UnifiedKnowledgeBaseService 新增 updateModule/resetModuleFromClasspath/getModuleOverviewDetailed
- 新增 AdminUnifiedKnowledgeController (/api/admin/knowledge-base/unified/*, 权限 admin/operator/finance/knowledge_admin/butler)
- cfc-web 新增统一知识库管理页(模块列表+JSON编辑/格式化/保存/从种子重置, 路由/菜单挂知识库分组)
- 写库后 load() 热重载内存索引, 无需重启
Xiaogang Liao 1 week geleden
bovenliggende
commit
299a31f882

+ 133 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminUnifiedKnowledgeController.java

@@ -0,0 +1,133 @@
+package com.etotem.cfc.controller.admin;
+
+import com.etotem.cfc.common.AdminRoles;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.service.UnifiedKnowledgeBaseService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import javax.servlet.http.HttpServletRequest;
+import java.util.*;
+
+/**
+ * 统一知识库管理 Controller
+ * 管理端在线编辑 knowledge_base_section 各模块 JSON,写库后热重载(无需重启)。
+ * 权限:平台角色(admin/operator/finance) + 知识管理员 + 管家。
+ */
+@Slf4j
+@Tag(name = "统一知识库管理")
+@RestController
+@RequestMapping("/api/admin/knowledge-base/unified")
+public class AdminUnifiedKnowledgeController {
+
+    @Resource
+    private UnifiedKnowledgeBaseService unifiedKnowledgeBaseService;
+
+    /** 允许维护统一知识库的角色(knowledge_admin 门户中知识库归其管理;admin/operator 平台角色放行) */
+    private static final Set<String> ALLOWED_ROLES = new HashSet<>(Arrays.asList(
+            "admin", "operator", "finance", "knowledge_admin", "butler"));
+
+    @SuppressWarnings("unchecked")
+    private List<String> roles(HttpServletRequest request) {
+        Object rolesAttr = request.getAttribute("roles");
+        if (rolesAttr instanceof List) {
+            return (List<String>) rolesAttr;
+        }
+        Object roleAttr = request.getAttribute("role");
+        if (roleAttr != null) {
+            return Collections.singletonList(roleAttr.toString());
+        }
+        return Collections.emptyList();
+    }
+
+    private boolean allowed(HttpServletRequest request) {
+        List<String> r = roles(request);
+        if (r == null || r.isEmpty()) {
+            return false;
+        }
+        for (String raw : r) {
+            if (ALLOWED_ROLES.contains(AdminRoles.normalize(raw))) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    @Operation(summary = "统一知识库-模块概览(管理端:键/名称/大小/版本/更新时间/加载状态)")
+    @PostMapping("/overview")
+    public Result<List<Map<String, Object>>> overview(HttpServletRequest request) {
+        if (!allowed(request)) {
+            return Result.error("无权限维护统一知识库");
+        }
+        return Result.success(unifiedKnowledgeBaseService.getModuleOverviewDetailed());
+    }
+
+    @Operation(summary = "统一知识库-按模块获取原始内容")
+    @PostMapping("/section")
+    public Result<Map<String, Object>> section(@RequestBody Map<String, String> params, HttpServletRequest request) {
+        if (!allowed(request)) {
+            return Result.error("无权限维护统一知识库");
+        }
+        String moduleKey = params.get("moduleKey");
+        if (moduleKey == null || moduleKey.isEmpty()) {
+            return Result.error("moduleKey不能为空");
+        }
+        Map<String, Object> data = unifiedKnowledgeBaseService.getSection(moduleKey);
+        if (data == null) {
+            return Result.error("模块不存在或内容为空: " + moduleKey);
+        }
+        return Result.success(data);
+    }
+
+    @Operation(summary = "统一知识库-更新模块内容(写库+热重载)")
+    @PostMapping("/update")
+    public Result<Map<String, Object>> update(@RequestBody Map<String, Object> params, HttpServletRequest request) {
+        if (!allowed(request)) {
+            return Result.error("无权限维护统一知识库");
+        }
+        String moduleKey = params.get("moduleKey") != null ? params.get("moduleKey").toString() : null;
+        String moduleName = params.get("moduleName") != null ? params.get("moduleName").toString() : null;
+        Object content = params.get("content");
+        if (moduleKey == null || moduleKey.isEmpty()) {
+            return Result.error("moduleKey不能为空");
+        }
+        if (content == null) {
+            return Result.error("content不能为空");
+        }
+        try {
+            Map<String, Object> r = unifiedKnowledgeBaseService.updateModule(moduleKey, moduleName, content);
+            log.info("统一知识库模块更新: {} 成功, size={}", moduleKey, r.get("size"));
+            return Result.success("模块已更新并热加载生效", r);
+        } catch (IllegalArgumentException e) {
+            return Result.error(e.getMessage());
+        } catch (Exception e) {
+            log.error("统一知识库模块更新异常: " + moduleKey, e);
+            return Result.error("更新失败: " + e.getMessage());
+        }
+    }
+
+    @Operation(summary = "统一知识库-从classpath种子重置模块(写库+热重载)")
+    @PostMapping("/reset")
+    public Result<Map<String, Object>> reset(@RequestBody Map<String, String> params, HttpServletRequest request) {
+        if (!allowed(request)) {
+            return Result.error("无权限维护统一知识库");
+        }
+        String moduleKey = params.get("moduleKey");
+        if (moduleKey == null || moduleKey.isEmpty()) {
+            return Result.error("moduleKey不能为空");
+        }
+        try {
+            Map<String, Object> r = unifiedKnowledgeBaseService.resetModuleFromClasspath(moduleKey);
+            log.info("统一知识库模块重置: {} 成功, size={}", moduleKey, r.get("size"));
+            return Result.success("模块已从种子重置并热加载生效", r);
+        } catch (IllegalArgumentException | IllegalStateException e) {
+            return Result.error(e.getMessage());
+        } catch (Exception e) {
+            log.error("统一知识库模块重置异常: " + moduleKey, e);
+            return Result.error("重置失败: " + e.getMessage());
+        }
+    }
+}

+ 112 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/UnifiedKnowledgeBaseService.java

@@ -458,6 +458,118 @@ public class UnifiedKnowledgeBaseService {
         return overview;
     }
 
+    /**
+     * 获取模块详情概览(管理端使用):键 + 名称 + 内容字符数 + 版本 + 更新时间
+     */
+    public List<Map<String, Object>> getModuleOverviewDetailed() {
+        List<Map<String, Object>> list = new ArrayList<>();
+        Map<String, KnowledgeBaseSection> dbRows = new LinkedHashMap<>();
+        try {
+            List<KnowledgeBaseSection> rows = knowledgeBaseSectionMapper.selectList(null);
+            if (rows != null) {
+                for (KnowledgeBaseSection r : rows) {
+                    dbRows.put(r.getModuleKey(), r);
+                }
+            }
+        } catch (Exception e) {
+            log.warn("读取knowledge_base_section表失败: {}", e.getMessage());
+        }
+        for (String key : MODULE_KEYS) {
+            Map<String, Object> item = new LinkedHashMap<>();
+            item.put("moduleKey", key);
+            Map<String, Object> content = sectionMap.get(key);
+            KnowledgeBaseSection row = dbRows.get(key);
+            item.put("moduleName", row != null && row.getModuleName() != null ? row.getModuleName() : key);
+            int size = 0;
+            try {
+                size = row != null && row.getContent() != null ? row.getContent().length() : 0;
+            } catch (Exception ignore) {
+            }
+            item.put("size", size);
+            item.put("version", row != null ? row.getVersion() : null);
+            item.put("updatedAt", row != null && row.getUpdatedAt() != null ? row.getUpdatedAt() : null);
+            item.put("loaded", content != null);
+            item.put("count", content != null ? content.size() : 0);
+            list.add(item);
+        }
+        return list;
+    }
+
+    /**
+     * 更新模块内容(写DB + 热重载)。moduleKey 必须是已注册模块。
+     */
+    public synchronized Map<String, Object> updateModule(String moduleKey, String moduleName, Object content) {
+        if (moduleKey == null || moduleKey.isEmpty()) {
+            throw new IllegalArgumentException("moduleKey不能为空");
+        }
+        if (!MODULE_KEYS.contains(moduleKey)) {
+            throw new IllegalArgumentException("未注册模块: " + moduleKey);
+        }
+        String json;
+        try {
+            json = objectMapper.writeValueAsString(content);
+        } catch (Exception e) {
+            throw new IllegalArgumentException("content不是合法JSON: " + e.getMessage());
+        }
+        try {
+            KnowledgeBaseSection row = knowledgeBaseSectionMapper.selectOne(
+                    new LambdaQueryWrapper<KnowledgeBaseSection>().eq(KnowledgeBaseSection::getModuleKey, moduleKey));
+            Date now = new Date();
+            if (row == null) {
+                row = new KnowledgeBaseSection();
+                row.setModuleKey(moduleKey);
+                row.setModuleName(moduleName != null ? moduleName : moduleKey);
+                row.setContent(json);
+                row.setVersion("manual-" + new java.text.SimpleDateFormat("yyyyMMddHHmmss").format(now));
+                row.setCreatedAt(now);
+                row.setUpdatedAt(now);
+                knowledgeBaseSectionMapper.insert(row);
+            } else {
+                if (moduleName != null && !moduleName.isEmpty()) {
+                    row.setModuleName(moduleName);
+                }
+                row.setContent(json);
+                row.setUpdatedAt(now);
+                knowledgeBaseSectionMapper.updateById(row);
+            }
+            load(); // 热重载内存索引
+            Map<String, Object> result = new LinkedHashMap<>();
+            result.put("moduleKey", moduleKey);
+            result.put("size", json.length());
+            result.put("updatedAt", now);
+            return result;
+        } catch (Exception e) {
+            log.error("更新模块{}失败: {}", moduleKey, e.getMessage(), e);
+            throw e;
+        }
+    }
+
+    /**
+     * 从 classpath 种子恢复单个模块(写DB + 热重载)
+     */
+    public synchronized Map<String, Object> resetModuleFromClasspath(String moduleKey) {
+        if (moduleKey == null || moduleKey.isEmpty()) {
+            throw new IllegalArgumentException("moduleKey不能为空");
+        }
+        if (!MODULE_KEYS.contains(moduleKey)) {
+            throw new IllegalArgumentException("未注册模块: " + moduleKey);
+        }
+        try (InputStream is = getClass().getResourceAsStream(SEED_CLASSPATH)) {
+            if (is == null) {
+                throw new IllegalStateException("classpath种子不存在: " + SEED_CLASSPATH);
+            }
+            Map<String, Object> seed = objectMapper.readValue(is, new TypeReference<Map<String, Object>>() {});
+            Object moduleContent = seed.get(moduleKey);
+            if (moduleContent == null) {
+                throw new IllegalStateException("种子中无模块: " + moduleKey);
+            }
+            return updateModule(moduleKey, moduleKey, moduleContent);
+        } catch (java.io.IOException e) {
+            log.error("重置模块{}失败: {}", moduleKey, e.getMessage(), e);
+            throw new IllegalStateException("重置失败: " + e.getMessage(), e);
+        }
+    }
+
     // ==================== 食物查询 ====================
 
     /**

+ 38 - 0
cfc-web/src/api/unifiedKnowledge.js

@@ -0,0 +1,38 @@
+import request from '@/utils/request'
+
+// 统一知识库 API(knowledge_base_section 各模块概览 / 查看 / 保存 / 重置)
+
+// 模块概览:返回 [{ moduleKey, moduleName, size, updatedAt, version }]
+export function getUnifiedOverview() {
+  return request({
+    url: '/api/admin/knowledge-base/unified/overview',
+    method: 'post'
+  })
+}
+
+// 查看单个模块原始 JSON 内容
+export function getUnifiedSection(moduleKey) {
+  return request({
+    url: '/api/admin/knowledge-base/unified/section',
+    method: 'post',
+    data: { moduleKey }
+  })
+}
+
+// 保存模块内容并热重载
+export function updateUnifiedModule(data) {
+  return request({
+    url: '/api/admin/knowledge-base/unified/update',
+    method: 'post',
+    data
+  })
+}
+
+// 从 classpath 种子重置模块
+export function resetUnifiedModule(moduleKey) {
+  return request({
+    url: '/api/admin/knowledge-base/unified/reset',
+    method: 'post',
+    data: { moduleKey }
+  })
+}

+ 6 - 0
cfc-web/src/router/index.js

@@ -769,6 +769,12 @@ const routes = [
         component: () => import('@/views/admin/health-knowledge'),
         meta: { title: '健康知识库管理', perm: 'knowledge:base' }
       },
+      {
+        path: 'knowledge-base/unified',
+        name: 'UnifiedKnowledgeBase',
+        component: () => import('@/views/admin/unified-knowledge.vue'),
+        meta: { title: '统一知识库管理', perm: 'knowledge:base' }
+      },
       // ========== 健康维度配置 ==========
       {
         path: 'health-norm-config',

+ 1 - 0
cfc-web/src/views/Layout.vue

@@ -232,6 +232,7 @@ export default {
             { path: '/comment-review', label: '评论审核', icon: 'el-icon-chat-dot-round', perm: 'articles:manage' },
             { path: '/knowledge-tags', label: '知识标签', icon: 'el-icon-price-tag', perm: 'articles:categories' },
             { path: '/knowledge-base', label: '知识库', icon: 'el-icon-reading', perm: 'knowledge:base' },
+            { path: '/knowledge-base/unified', label: '统一知识库', icon: 'el-icon-notebook-2', perm: 'knowledge:base' },
             { path: '/health-knowledge', label: '健康知识库', icon: 'el-icon-first-aid-kit', perm: 'knowledge:base' },
             { path: '/vector-knowledge', label: '向量知识库', icon: 'el-icon-data-line', perm: 'knowledge:base' },
           ]},

+ 259 - 0
cfc-web/src/views/admin/unified-knowledge.vue

@@ -0,0 +1,259 @@
+<template>
+  <div class="unified-kb admin-page">
+    <el-card>
+      <div slot="header" class="admin-page-header">
+        <span class="admin-page-title">统一知识库管理</span>
+        <div class="admin-page-actions">
+          <el-tag type="info" size="small">共 {{ overview.length }} 个模块</el-tag>
+          <el-button
+            type="primary"
+            size="small"
+            icon="el-icon-refresh"
+            :loading="loading"
+            @click="fetchOverview"
+          >刷新</el-button>
+        </div>
+      </div>
+
+      <el-alert
+        title="模块内容存储于数据库表 knowledge_base_section,保存后热加载生效(无需重启服务)。"
+        type="info"
+        :closable="false"
+        show-icon
+        style="margin-bottom: 16px"
+      />
+
+      <div class="adm-table-wrap">
+        <el-table
+          height="100%"
+          :data="overview"
+          v-loading="loading"
+          border
+          stripe
+          empty-text="暂无数据"
+        >
+          <el-table-column label="模块" min-width="220">
+            <template slot-scope="{ row }">
+              <div class="module-name">{{ row.moduleName || row.moduleKey }}</div>
+              <div class="module-key">{{ row.moduleKey }}</div>
+            </template>
+          </el-table-column>
+          <el-table-column label="内容大小" width="120">
+            <template slot-scope="{ row }">
+              {{ formatSize(row.size) }}
+            </template>
+          </el-table-column>
+          <el-table-column prop="version" label="版本" width="100">
+            <template slot-scope="{ row }">
+              <span>{{ row.version || '-' }}</span>
+            </template>
+          </el-table-column>
+          <el-table-column prop="updatedAt" label="更新时间" width="200">
+            <template slot-scope="{ row }">
+              <span>{{ row.updatedAt || '-' }}</span>
+            </template>
+          </el-table-column>
+          <el-table-column label="操作" width="160" fixed="right">
+            <template slot-scope="{ row }">
+              <el-button type="text" size="mini" @click="openEdit(row)">编辑</el-button>
+              <el-button type="text" size="mini" class="danger-text" @click="handleReset(row)">从种子重置</el-button>
+            </template>
+          </el-table-column>
+        </el-table>
+      </div>
+    </el-card>
+
+    <!-- 编辑模块弹窗 -->
+    <el-dialog
+      :visible.sync="editVisible"
+      title="编辑知识库模块"
+      width="760px"
+      :close-on-click-modal="false"
+    >
+      <el-form label-width="90px" size="small" v-loading="sectionLoading">
+        <el-form-item label="模块键">
+          <el-input :value="editForm.moduleKey" readonly />
+        </el-form-item>
+        <el-form-item label="模块名">
+          <el-input :value="editForm.moduleName" readonly />
+        </el-form-item>
+        <el-form-item label="JSON 内容">
+          <div class="json-toolbar">
+            <el-button size="mini" icon="el-icon-magic-stick" @click="formatJson">格式化 JSON</el-button>
+            <span class="json-hint">保存后将热加载生效</span>
+          </div>
+          <el-input
+            class="json-editor"
+            type="textarea"
+            :rows="16"
+            v-model="editForm.content"
+            placeholder="模块 JSON 内容"
+          />
+        </el-form-item>
+      </el-form>
+      <span slot="footer">
+        <el-button size="small" @click="editVisible = false">取消</el-button>
+        <el-button type="primary" size="small" :loading="saving" @click="submitEdit">保存</el-button>
+      </span>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import {
+  getUnifiedOverview,
+  getUnifiedSection,
+  updateUnifiedModule,
+  resetUnifiedModule
+} from '@/api/unifiedKnowledge'
+
+export default {
+  name: 'UnifiedKnowledgeBase',
+  data() {
+    return {
+      loading: false,
+      overview: [],
+      editVisible: false,
+      sectionLoading: false,
+      saving: false,
+      editForm: { moduleKey: '', moduleName: '', content: '' }
+    }
+  },
+  created() {
+    this.fetchOverview()
+  },
+  methods: {
+    formatSize(size) {
+      if (size === null || size === undefined || size === '') return '-'
+      if (typeof size !== 'number') return size
+      if (size < 1024) return size + ' B'
+      if (size < 1024 * 1024) return (size / 1024).toFixed(1) + ' KB'
+      return (size / 1024 / 1024).toFixed(2) + ' MB'
+    },
+    async fetchOverview() {
+      this.loading = true
+      try {
+        const res = await getUnifiedOverview()
+        this.overview = res.data || []
+      } catch (e) {
+        this.$message.error(e.message || '加载模块概览失败')
+      } finally {
+        this.loading = false
+      }
+    },
+    async openEdit(row) {
+      this.editForm = {
+        moduleKey: row.moduleKey,
+        moduleName: row.moduleName || '',
+        content: ''
+      }
+      this.editVisible = true
+      this.sectionLoading = true
+      try {
+        const res = await getUnifiedSection(row.moduleKey)
+        const data = res.data
+        if (data === null || data === undefined) {
+          this.editForm.content = ''
+        } else if (typeof data === 'string') {
+          this.editForm.content = data
+        } else {
+          this.editForm.content = JSON.stringify(data, null, 2)
+        }
+      } catch (e) {
+        this.editVisible = false
+        this.$message.error(e.message || '加载模块内容失败')
+      } finally {
+        this.sectionLoading = false
+      }
+    },
+    formatJson() {
+      const raw = (this.editForm.content || '').trim()
+      if (!raw) {
+        this.$message.warning('内容为空,无需格式化')
+        return
+      }
+      try {
+        this.editForm.content = JSON.stringify(JSON.parse(raw), null, 2)
+      } catch (e) {
+        this.$message.error('JSON 解析失败:' + (e.message || '格式不正确'))
+      }
+    },
+    async submitEdit() {
+      const raw = (this.editForm.content || '').trim()
+      if (!raw) {
+        this.$message.warning('内容不能为空')
+        return
+      }
+      try {
+        JSON.parse(raw)
+      } catch (e) {
+        this.$message.error('JSON 格式不正确,请先修正(可点击「格式化 JSON」检查)')
+        return
+      }
+      this.saving = true
+      try {
+        const res = await updateUnifiedModule({
+          moduleKey: this.editForm.moduleKey,
+          moduleName: this.editForm.moduleName,
+          content: raw
+        })
+        this.$message.success((res && res.message) || '保存成功,已热加载生效')
+        this.editVisible = false
+        this.fetchOverview()
+      } catch (e) {
+        this.$message.error(e.message || '操作失败')
+      } finally {
+        this.saving = false
+      }
+    },
+    handleReset(row) {
+      this.$confirm(
+        `确认将模块「${row.moduleName || row.moduleKey}」从 classpath 种子重置?当前自定义内容将被覆盖!`,
+        '确认重置',
+        { confirmButtonText: '重置', cancelButtonText: '取消', type: 'warning' }
+      ).then(async () => {
+        try {
+          const res = await resetUnifiedModule(row.moduleKey)
+          this.$message.success((res && res.message) || '重置成功,已热加载生效')
+          this.fetchOverview()
+        } catch (e) {
+          this.$message.error(e.message || '操作失败')
+        }
+      }).catch(() => {})
+    }
+  }
+}
+</script>
+
+<style scoped>
+.unified-kb {
+  padding: 12px;
+}
+.module-name {
+  font-weight: 500;
+  color: #303133;
+}
+.module-key {
+  font-size: 12px;
+  color: #909399;
+  margin-top: 2px;
+}
+.danger-text {
+  color: #f56c6c;
+}
+.json-toolbar {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-bottom: 8px;
+}
+.json-hint {
+  font-size: 12px;
+  color: #909399;
+}
+.json-editor /deep/ .el-textarea__inner {
+  font-family: Menlo, Consolas, 'Courier New', monospace;
+  font-size: 13px;
+  line-height: 1.5;
+}
+</style>