From dec3d19f3ce4ef8bf7256a4ad4ca64d22b56b9e4 Mon Sep 17 00:00:00 2001 From: wanghanlin <1533525126@qq.com> Date: Tue, 30 Jun 2026 14:59:26 +0800 Subject: [PATCH] =?UTF-8?q?=E7=94=A8=E6=88=B7=E8=AE=A4=E8=AF=81/=E8=BF=90?= =?UTF-8?q?=E8=90=A5=E7=9C=8B=E6=9D=BF/API=E5=BC=80=E6=94=BE=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pom.xml | 25 ++ .../wok/supportbot/SupportBotApplication.java | 4 + .../com/wok/supportbot/app/AssistantApp.java | 38 ++ .../wok/supportbot/auth/AuthController.java | 162 ++++++++ .../supportbot/config/DatabaseInitConfig.java | 350 ++++++++++++++++- .../controller/AiModelConfigController.java | 8 + .../controller/ApiKeyController.java | 183 +++++++++ .../controller/ConversationController.java | 36 +- .../controller/CustomerAccountController.java | 6 + .../CustomerServiceRoleController.java | 7 + .../controller/DashboardController.java | 101 +++++ .../controller/DocumentController.java | 22 ++ .../supportbot/controller/FaqController.java | 14 +- .../controller/MessageFeedbackController.java | 4 + .../controller/OpenApiController.java | 149 ++++++++ .../controller/SensitiveWordController.java | 7 + .../controller/SysRoleController.java | 85 +++++ .../controller/SysUserController.java | 197 ++++++++++ .../controller/WebhookController.java | 190 +++++++++ .../com/wok/supportbot/dao/ApiKeyMapper.java | 12 + .../dao/DashboardSnapshotMapper.java | 12 + .../wok/supportbot/dao/RagHitLogMapper.java | 12 + .../supportbot/dao/SysPermissionMapper.java | 12 + .../com/wok/supportbot/dao/SysRoleMapper.java | 12 + .../com/wok/supportbot/dao/SysUserMapper.java | 12 + .../wok/supportbot/dao/SysUserRoleMapper.java | 12 + .../supportbot/dao/WebhookConfigMapper.java | 12 + .../com/wok/supportbot/entity/ApiKey.java | 83 ++++ .../wok/supportbot/entity/ChatMessage.java | 9 + .../supportbot/entity/DashboardSnapshot.java | 92 +++++ .../wok/supportbot/entity/KnowledgeFaq.java | 9 +- .../com/wok/supportbot/entity/RagHitLog.java | 66 ++++ .../wok/supportbot/entity/SysPermission.java | 54 +++ .../com/wok/supportbot/entity/SysRole.java | 62 +++ .../com/wok/supportbot/entity/SysUser.java | 83 ++++ .../wok/supportbot/entity/SysUserRole.java | 45 +++ .../wok/supportbot/entity/WebhookConfig.java | 73 ++++ .../supportbot/openapi/ApiKeyAuthFilter.java | 113 ++++++ .../openapi/SlidingWindowRateLimiter.java | 106 +++++ .../supportbot/security/JwtAuthFilter.java | 65 ++++ .../supportbot/security/JwtTokenProvider.java | 108 ++++++ .../supportbot/security/SecurityConfig.java | 115 ++++++ .../wok/supportbot/service/ApiKeyService.java | 170 +++++++++ .../service/ConversationService.java | 16 +- .../supportbot/service/DashboardService.java | 352 +++++++++++++++++ .../supportbot/service/FaqMatchEngine.java | 4 +- .../wok/supportbot/service/FaqService.java | 11 +- .../supportbot/service/RagHitLogService.java | 84 ++++ .../supportbot/service/SysRoleService.java | 73 ++++ .../supportbot/service/SysUserService.java | 270 +++++++++++++ .../supportbot/service/WebhookService.java | 191 +++++++++ src/main/resources/application.yml | 13 + .../static/components/ApiKeyManager.js | 250 ++++++++++++ .../static/components/DashboardPanel.js | 340 +++++++++++++++++ .../resources/static/components/FaqManager.js | 79 +++- .../resources/static/components/LoginPage.js | 71 ++++ .../static/components/UserManager.js | 315 +++++++++++++++ .../static/components/WebhookManager.js | 216 +++++++++++ src/main/resources/static/css/main.css | 83 +++- src/main/resources/static/index.html | 3 +- src/main/resources/static/js/api.js | 261 ++++++++++++- src/main/resources/static/js/app.js | 361 ++++++++++++------ src/main/resources/static/js/store.js | 43 ++- src/main/resources/static/js/utils.js | 55 ++- 64 files changed, 5854 insertions(+), 174 deletions(-) create mode 100644 src/main/java/com/wok/supportbot/auth/AuthController.java create mode 100644 src/main/java/com/wok/supportbot/controller/ApiKeyController.java create mode 100644 src/main/java/com/wok/supportbot/controller/DashboardController.java create mode 100644 src/main/java/com/wok/supportbot/controller/OpenApiController.java create mode 100644 src/main/java/com/wok/supportbot/controller/SysRoleController.java create mode 100644 src/main/java/com/wok/supportbot/controller/SysUserController.java create mode 100644 src/main/java/com/wok/supportbot/controller/WebhookController.java create mode 100644 src/main/java/com/wok/supportbot/dao/ApiKeyMapper.java create mode 100644 src/main/java/com/wok/supportbot/dao/DashboardSnapshotMapper.java create mode 100644 src/main/java/com/wok/supportbot/dao/RagHitLogMapper.java create mode 100644 src/main/java/com/wok/supportbot/dao/SysPermissionMapper.java create mode 100644 src/main/java/com/wok/supportbot/dao/SysRoleMapper.java create mode 100644 src/main/java/com/wok/supportbot/dao/SysUserMapper.java create mode 100644 src/main/java/com/wok/supportbot/dao/SysUserRoleMapper.java create mode 100644 src/main/java/com/wok/supportbot/dao/WebhookConfigMapper.java create mode 100644 src/main/java/com/wok/supportbot/entity/ApiKey.java create mode 100644 src/main/java/com/wok/supportbot/entity/DashboardSnapshot.java create mode 100644 src/main/java/com/wok/supportbot/entity/RagHitLog.java create mode 100644 src/main/java/com/wok/supportbot/entity/SysPermission.java create mode 100644 src/main/java/com/wok/supportbot/entity/SysRole.java create mode 100644 src/main/java/com/wok/supportbot/entity/SysUser.java create mode 100644 src/main/java/com/wok/supportbot/entity/SysUserRole.java create mode 100644 src/main/java/com/wok/supportbot/entity/WebhookConfig.java create mode 100644 src/main/java/com/wok/supportbot/openapi/ApiKeyAuthFilter.java create mode 100644 src/main/java/com/wok/supportbot/openapi/SlidingWindowRateLimiter.java create mode 100644 src/main/java/com/wok/supportbot/security/JwtAuthFilter.java create mode 100644 src/main/java/com/wok/supportbot/security/JwtTokenProvider.java create mode 100644 src/main/java/com/wok/supportbot/security/SecurityConfig.java create mode 100644 src/main/java/com/wok/supportbot/service/ApiKeyService.java create mode 100644 src/main/java/com/wok/supportbot/service/DashboardService.java create mode 100644 src/main/java/com/wok/supportbot/service/RagHitLogService.java create mode 100644 src/main/java/com/wok/supportbot/service/SysRoleService.java create mode 100644 src/main/java/com/wok/supportbot/service/SysUserService.java create mode 100644 src/main/java/com/wok/supportbot/service/WebhookService.java create mode 100644 src/main/resources/static/components/ApiKeyManager.js create mode 100644 src/main/resources/static/components/DashboardPanel.js create mode 100644 src/main/resources/static/components/LoginPage.js create mode 100644 src/main/resources/static/components/UserManager.js create mode 100644 src/main/resources/static/components/WebhookManager.js diff --git a/pom.xml b/pom.xml index 177ffa0..97bfe89 100644 --- a/pom.xml +++ b/pom.xml @@ -150,6 +150,31 @@ org.springframework.ai spring-ai-tika-document-reader + + + org.springframework.boot + spring-boot-starter-security + + + + + io.jsonwebtoken + jjwt-api + 0.12.6 + + + io.jsonwebtoken + jjwt-impl + 0.12.6 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.12.6 + runtime + + org.springframework.security diff --git a/src/main/java/com/wok/supportbot/SupportBotApplication.java b/src/main/java/com/wok/supportbot/SupportBotApplication.java index 87771ef..aab707c 100644 --- a/src/main/java/com/wok/supportbot/SupportBotApplication.java +++ b/src/main/java/com/wok/supportbot/SupportBotApplication.java @@ -2,6 +2,8 @@ package com.wok.supportbot; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; /** * 主启动类 @@ -9,6 +11,8 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; * 确保 ToolCallingAutoConfiguration 不会抛出 ClassNotFoundException */ @SpringBootApplication +@EnableScheduling +@EnableAsync public class SupportBotApplication { public static void main(String[] args) { diff --git a/src/main/java/com/wok/supportbot/app/AssistantApp.java b/src/main/java/com/wok/supportbot/app/AssistantApp.java index 0b2e505..0e8b11a 100644 --- a/src/main/java/com/wok/supportbot/app/AssistantApp.java +++ b/src/main/java/com/wok/supportbot/app/AssistantApp.java @@ -9,6 +9,7 @@ import com.wok.supportbot.rag.preretrieval.CompressionQueryRewriter; import com.wok.supportbot.rag.preretrieval.MultiQueryExpanderRewriter; import com.wok.supportbot.rag.preretrieval.RewriteQueryRewriter; import com.wok.supportbot.rag.preretrieval.TranslationQueryRewriter; +import com.wok.supportbot.service.FaqMatchEngine; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.chat.client.ChatClient; @@ -38,6 +39,7 @@ import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; @@ -60,6 +62,9 @@ public class AssistantApp { @Resource private ContentSafetyAdvisor contentSafetyAdvisor; + @Resource + private FaqMatchEngine faqMatchEngine; + private final ChatModelFactory chatModelFactory; private final DatabaseChatMemory chatMemory; @@ -170,6 +175,25 @@ public class AssistantApp { return spec.stream().content(); } + // ==================== FAQ 优先匹配 ==================== + + /** + * 尝试 FAQ 三级匹配(精确→关键词→语义),命中则返回标准答案。 + * 在 RAG 对话入口处优先调用,避免不必要的知识库检索开销。 + * + * @param message 用户消息 + * @return FAQ 标准答案(未命中时返回 empty) + */ + private Optional tryFaqMatch(String message) { + try { + return faqMatchEngine.match(message) + .map(result -> result.getFaq().getAnswer()); + } catch (Exception e) { + log.warn("FAQ 匹配异常,降级到 RAG: {}", e.getMessage()); + return Optional.empty(); + } + } + // AI 恋爱知识库问答功能 @Resource RewriteQueryRewriter rewriteQueryRewriter; @@ -226,6 +250,13 @@ public class AssistantApp { } public String doChatWithRagStrategy(String message, String chatId, String strategy, List categoryIds, String systemPrompt) { + // FAQ 优先匹配:三级匹配(精确→关键词→语义)命中则直接返回标准答案 + Optional faqAnswer = tryFaqMatch(message); + if (faqAnswer.isPresent()) { + log.info("FAQ 命中,直接返回标准答案: chatId={}", chatId); + return faqAnswer.get(); + } + // 对于 MULTI_QUERY 策略,需要使用特殊的处理方式 if ("MULTI_QUERY".equalsIgnoreCase(strategy)) { return doChatWithMultiQueryRag(message, chatId, categoryIds, systemPrompt); @@ -282,6 +313,13 @@ public class AssistantApp { */ public Flux doChatWithRagStrategyByStream(String message, String chatId, String strategy, List categoryIds, String systemPrompt) { + // FAQ 优先匹配:命中则直接以流式形式返回标准答案 + Optional faqAnswer = tryFaqMatch(message); + if (faqAnswer.isPresent()) { + log.info("FAQ 命中(流式),直接返回标准答案: chatId={}", chatId); + return Flux.just(faqAnswer.get()); + } + // 对于 MULTI_QUERY 策略,需要先手动检索合并再流式生成 if ("MULTI_QUERY".equalsIgnoreCase(strategy)) { return doChatWithMultiQueryRagByStream(message, chatId, categoryIds, systemPrompt); diff --git a/src/main/java/com/wok/supportbot/auth/AuthController.java b/src/main/java/com/wok/supportbot/auth/AuthController.java new file mode 100644 index 0000000..3399d9e --- /dev/null +++ b/src/main/java/com/wok/supportbot/auth/AuthController.java @@ -0,0 +1,162 @@ +package com.wok.supportbot.auth; + +import com.wok.supportbot.entity.SysRole; +import com.wok.supportbot.entity.SysUser; +import com.wok.supportbot.security.JwtTokenProvider; +import com.wok.supportbot.service.SysUserService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.*; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 认证控制器 + * 处理登录、Token 刷新、当前用户信息查询 + */ +@Slf4j +@RestController +@RequestMapping("/auth") +public class AuthController { + + private final AuthenticationManager authenticationManager; + private final JwtTokenProvider jwtTokenProvider; + private final SysUserService sysUserService; + + public AuthController(AuthenticationManager authenticationManager, + JwtTokenProvider jwtTokenProvider, + SysUserService sysUserService) { + this.authenticationManager = authenticationManager; + this.jwtTokenProvider = jwtTokenProvider; + this.sysUserService = sysUserService; + } + + /** + * 用户登录 + */ + @PostMapping("/login") + public ResponseEntity> login(@RequestBody Map body) { + String username = body.get("username"); + String password = body.get("password"); + if (username == null || password == null) { + return ResponseEntity.badRequest().body(Map.of( + "success", false, "message", "用户名和密码不能为空" + )); + } + + try { + Authentication auth = authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken(username, password) + ); + SecurityContextHolder.getContext().setAuthentication(auth); + + // 查询用户详情 + SysUser user = sysUserService.getUserByUsername(username); + List roles = user.getRoles(); + List roleKeys = roles.stream().map(SysRole::getRoleKey).toList(); + + // 生成 Token + String accessToken = jwtTokenProvider.generateToken(username, roleKeys); + String refreshToken = jwtTokenProvider.generateRefreshToken(username); + + // 更新最后登录时间 + sysUserService.updateLastLoginTime(user.getId()); + + // 构建用户信息 + Map userInfo = new LinkedHashMap<>(); + userInfo.put("id", user.getId().toString()); + userInfo.put("username", user.getUsername()); + userInfo.put("nickname", user.getNickname()); + userInfo.put("email", user.getEmail()); + userInfo.put("roles", roleKeys); + userInfo.put("avatar", user.getAvatar()); + + log.info("用户登录成功: {}", username); + return ResponseEntity.ok(Map.of( + "success", true, + "data", Map.of( + "accessToken", accessToken, + "refreshToken", refreshToken, + "user", userInfo + ) + )); + } catch (BadCredentialsException e) { + return ResponseEntity.ok(Map.of( + "success", false, "message", "用户名或密码错误" + )); + } + } + + /** + * 刷新 Token + */ + @PostMapping("/refresh") + public ResponseEntity> refresh(@RequestBody Map body) { + String refreshToken = body.get("refreshToken"); + if (refreshToken == null || !jwtTokenProvider.validateToken(refreshToken)) { + return ResponseEntity.ok(Map.of( + "success", false, "message", "刷新令牌无效或已过期" + )); + } + + String username = jwtTokenProvider.getUsernameFromToken(refreshToken); + SysUser user = sysUserService.getUserByUsername(username); + if (user == null || Boolean.FALSE.equals(user.getEnabled())) { + return ResponseEntity.ok(Map.of( + "success", false, "message", "用户不存在或已被禁用" + )); + } + + List roleKeys = user.getRoles().stream().map(SysRole::getRoleKey).toList(); + String newAccessToken = jwtTokenProvider.generateToken(username, roleKeys); + String newRefreshToken = jwtTokenProvider.generateRefreshToken(username); + + return ResponseEntity.ok(Map.of( + "success", true, + "data", Map.of( + "accessToken", newAccessToken, + "refreshToken", newRefreshToken + ) + )); + } + + /** + * 获取当前登录用户信息 + */ + @GetMapping("/me") + public ResponseEntity> me() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || !auth.isAuthenticated() || "anonymousUser".equals(auth.getPrincipal())) { + return ResponseEntity.status(401).body(Map.of( + "success", false, "message", "未登录" + )); + } + + String username = auth.getName(); + SysUser user = sysUserService.getUserByUsername(username); + if (user == null) { + return ResponseEntity.status(401).body(Map.of( + "success", false, "message", "用户不存在" + )); + } + + List roleKeys = user.getRoles().stream().map(SysRole::getRoleKey).toList(); + + Map userInfo = new LinkedHashMap<>(); + userInfo.put("id", user.getId().toString()); + userInfo.put("username", user.getUsername()); + userInfo.put("nickname", user.getNickname()); + userInfo.put("email", user.getEmail()); + userInfo.put("roles", roleKeys); + userInfo.put("avatar", user.getAvatar()); + + return ResponseEntity.ok(Map.of("success", true, "data", userInfo)); + } +} diff --git a/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java b/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java index 89bf3ae..982368b 100644 --- a/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java +++ b/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java @@ -5,6 +5,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Component; /** @@ -18,6 +19,9 @@ public class DatabaseInitConfig { @Autowired private JdbcTemplate jdbcTemplate; + @Autowired(required = false) + private PasswordEncoder passwordEncoder; + @Value("${knowledge.vector.dimension:1024}") private int vectorDimension; @@ -101,12 +105,60 @@ public class DatabaseInitConfig { if (!checkTableExists("knowledge_faq")) { log.info("创建 FAQ 知识库表 knowledge_faq"); createKnowledgeFaqTable(); + } else { + // 自动添加 category_id 列(引用文档管理分类) + addFaqCategoryIdColumn(); } if (!checkTableExists("faq_embedding")) { log.info("创建 FAQ 向量索引表 faq_embedding"); createFaqEmbeddingTable(); } + // ==================== P1 阶段新增表 ==================== + + // P1-001: 用户认证与多用户管理 + if (!checkTableExists("sys_user")) { + log.info("创建系统用户表 sys_user"); + createSysUserTable(); + } + if (!checkTableExists("sys_role")) { + log.info("创建系统角色表 sys_role"); + createSysRoleTable(); + } + if (!checkTableExists("sys_permission")) { + log.info("创建系统权限表 sys_permission"); + createSysPermissionTable(); + } + if (!checkTableExists("sys_user_role")) { + log.info("创建用户角色关联表 sys_user_role"); + createSysUserRoleTable(); + } + // chat_message 增加 user_id 列(数据隔离) + addChatMessageUserIdColumn(); + // 同步默认角色和管理员账号 + syncDefaultSysRoles(); + syncDefaultAdminUser(); + + // P1-002: 运营数据分析看板 + if (!checkTableExists("rag_hit_log")) { + log.info("创建 RAG 命中记录表 rag_hit_log"); + createRagHitLogTable(); + } + if (!checkTableExists("dashboard_snapshot")) { + log.info("创建看板快照表 dashboard_snapshot"); + createDashboardSnapshotTable(); + } + + // P1-003: API 开放平台 + if (!checkTableExists("api_key")) { + log.info("创建 API Key 表 api_key"); + createApiKeyTable(); + } + if (!checkTableExists("webhook_config")) { + log.info("创建 Webhook 配置表 webhook_config"); + createWebhookConfigTable(); + } + // P0-001: 混合检索 - 为 vector_store 添加全文检索列 initVectorStoreFullTextSearch(); @@ -457,6 +509,7 @@ public class DatabaseInitConfig { answer TEXT NOT NULL, similar_questions TEXT DEFAULT '[]' NOT NULL, category VARCHAR(128), + category_id BIGINT, status VARCHAR(20) NOT NULL DEFAULT 'ENABLED', priority INTEGER NOT NULL DEFAULT 0, hit_count BIGINT NOT NULL DEFAULT 0, @@ -469,6 +522,7 @@ public class DatabaseInitConfig { jdbcTemplate.execute(sql); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_faq_status ON knowledge_faq (status) WHERE is_delete = false"); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_faq_category ON knowledge_faq (category)"); + jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_faq_category_id ON knowledge_faq (category_id)"); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_faq_priority ON knowledge_faq (priority DESC)"); } @@ -486,6 +540,23 @@ public class DatabaseInitConfig { jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_faq_emb_faq_id ON faq_embedding (faq_id)"); } + /** + * 为已存在的 knowledge_faq 表添加 category_id 列(引用文档管理分类表 knowledge_category) + */ + private void addFaqCategoryIdColumn() { + try { + String checkSql = "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'knowledge_faq' AND column_name = 'category_id'"; + Integer count = jdbcTemplate.queryForObject(checkSql, Integer.class); + if (count != null && count == 0) { + log.info("添加 knowledge_faq.category_id 列"); + jdbcTemplate.execute("ALTER TABLE knowledge_faq ADD COLUMN category_id BIGINT"); + jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_faq_category_id ON knowledge_faq (category_id)"); + } + } catch (Exception e) { + log.warn("添加 knowledge_faq.category_id 列时出错", e); + } + } + // ==================== P0-001: 混合检索 - 全文检索 ==================== /** @@ -542,6 +613,235 @@ public class DatabaseInitConfig { } } + // ==================== P1-001: 用户认证与多用户管理 ==================== + + private void createSysUserTable() { + String sql = """ + CREATE TABLE IF NOT EXISTS sys_user ( + id BIGSERIAL PRIMARY KEY, + username VARCHAR(64) NOT NULL UNIQUE, + password VARCHAR(256) NOT NULL, + nickname VARCHAR(100), + email VARCHAR(128), + phone VARCHAR(20), + avatar VARCHAR(512), + enabled BOOLEAN DEFAULT TRUE NOT NULL, + last_login_time TIMESTAMP, + create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + is_delete BOOLEAN DEFAULT FALSE NOT NULL + ) + """; + jdbcTemplate.execute(sql); + jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_sys_user_username ON sys_user (username) WHERE is_delete = false"); + jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_sys_user_enabled ON sys_user (enabled) WHERE is_delete = false"); + } + + private void createSysRoleTable() { + String sql = """ + CREATE TABLE IF NOT EXISTS sys_role ( + id BIGSERIAL PRIMARY KEY, + role_key VARCHAR(64) NOT NULL UNIQUE, + name VARCHAR(100) NOT NULL, + description TEXT, + enabled BOOLEAN DEFAULT TRUE NOT NULL, + create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + is_delete BOOLEAN DEFAULT FALSE NOT NULL + ) + """; + jdbcTemplate.execute(sql); + } + + private void createSysPermissionTable() { + String sql = """ + CREATE TABLE IF NOT EXISTS sys_permission ( + id BIGSERIAL PRIMARY KEY, + permission_key VARCHAR(128) NOT NULL UNIQUE, + name VARCHAR(100) NOT NULL, + description TEXT, + create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + is_delete BOOLEAN DEFAULT FALSE NOT NULL + ) + """; + jdbcTemplate.execute(sql); + } + + private void createSysUserRoleTable() { + String sql = """ + CREATE TABLE IF NOT EXISTS sys_user_role ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + role_id BIGINT NOT NULL, + create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + UNIQUE (user_id, role_id) + ) + """; + jdbcTemplate.execute(sql); + jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_sys_user_role_user ON sys_user_role (user_id)"); + jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_sys_user_role_role ON sys_user_role (role_id)"); + } + + /** + * chat_message 表增加 user_id 列(数据隔离迁移,幂等) + */ + private void addChatMessageUserIdColumn() { + try { + String checkSql = "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'chat_message' AND column_name = 'user_id'"; + Integer count = jdbcTemplate.queryForObject(checkSql, Integer.class); + if (count != null && count == 0) { + log.info("添加 chat_message.user_id 列"); + jdbcTemplate.execute("ALTER TABLE chat_message ADD COLUMN user_id BIGINT"); + jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_chat_message_user_id ON chat_message (user_id)"); + } + } catch (Exception e) { + log.warn("添加 user_id 列时出错", e); + } + } + + /** + * 同步默认系统角色(admin/kb_operator/cs_agent/viewer) + */ + private void syncDefaultSysRoles() { + upsertSysRole("admin", "超级管理员", "系统最高权限,可管理所有功能"); + upsertSysRole("kb_operator", "知识库运营", "管理知识库文档、分类、FAQ 等"); + upsertSysRole("cs_agent", "客服人员", "处理对话、查看反馈等客服操作"); + upsertSysRole("viewer", "只读查看者", "仅查看数据,不可修改"); + } + + private void upsertSysRole(String roleKey, String name, String description) { + jdbcTemplate.update(""" + INSERT INTO sys_role (role_key, name, description) + VALUES (?, ?, ?) + ON CONFLICT (role_key) + DO UPDATE SET name = EXCLUDED.name, + description = EXCLUDED.description, + enabled = true, + is_delete = false, + update_time = CURRENT_TIMESTAMP + """, roleKey, name, description); + } + + /** + * 同步默认管理员账号(admin / admin123) + */ + private void syncDefaultAdminUser() { + // 检查是否已有 admin 用户 + Long count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM sys_user WHERE username = 'admin' AND is_delete = false", + Long.class + ); + if (count != null && count > 0) return; + + log.info("创建默认管理员账号 admin"); + // 使用 PasswordEncoder 加密密码(如果可用),否则使用预计算的 BCrypt 哈希 + String encodedPassword = (passwordEncoder != null) + ? passwordEncoder.encode("admin123") + : "$2a$10$EqKcp1WFKVQIShMPC7B3kuznX9gAZMsVnSNjN0ABNuHVBCpzqABKm"; + jdbcTemplate.update(""" + INSERT INTO sys_user (username, password, nickname, enabled) + VALUES ('admin', ?, '超级管理员', true) + """, encodedPassword); + + // 分配 admin 角色 + jdbcTemplate.update(""" + INSERT INTO sys_user_role (user_id, role_id) + SELECT u.id, r.id FROM sys_user u, sys_role r + WHERE u.username = 'admin' AND r.role_key = 'admin' + ON CONFLICT DO NOTHING + """); + } + + // ==================== P1-002: 运营数据分析看板 ==================== + + private void createRagHitLogTable() { + String sql = """ + CREATE TABLE IF NOT EXISTS rag_hit_log ( + id BIGSERIAL PRIMARY KEY, + conversation_id VARCHAR(64), + user_query TEXT NOT NULL, + document_id BIGINT, + document_title VARCHAR(500), + chunk_id VARCHAR(128), + score DOUBLE PRECISION, + search_mode VARCHAR(20), + create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL + ) + """; + jdbcTemplate.execute(sql); + jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_rag_hit_log_created ON rag_hit_log (create_time DESC)"); + jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_rag_hit_log_document ON rag_hit_log (document_id)"); + } + + private void createDashboardSnapshotTable() { + String sql = """ + CREATE TABLE IF NOT EXISTS dashboard_snapshot ( + id BIGSERIAL PRIMARY KEY, + snapshot_date DATE NOT NULL UNIQUE, + conversation_count INTEGER DEFAULT 0 NOT NULL, + message_count INTEGER DEFAULT 0 NOT NULL, + satisfaction_rate DOUBLE PRECISION DEFAULT 0, + thumbs_up_count INTEGER DEFAULT 0 NOT NULL, + thumbs_down_count INTEGER DEFAULT 0 NOT NULL, + rag_hit_count INTEGER DEFAULT 0 NOT NULL, + rag_miss_count INTEGER DEFAULT 0 NOT NULL, + avg_response_time DOUBLE PRECISION DEFAULT 0, + top_questions JSONB DEFAULT '[]' NOT NULL, + top_hit_documents JSONB DEFAULT '[]' NOT NULL, + miss_questions JSONB DEFAULT '[]' NOT NULL, + create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL + ) + """; + jdbcTemplate.execute(sql); + jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_snapshot_date ON dashboard_snapshot (snapshot_date DESC)"); + } + + // ==================== P1-003: API 开放平台 ==================== + + private void createApiKeyTable() { + String sql = """ + CREATE TABLE IF NOT EXISTS api_key ( + id BIGSERIAL PRIMARY KEY, + key_value VARCHAR(128) NOT NULL UNIQUE, + name VARCHAR(100) NOT NULL, + description TEXT, + user_id BIGINT, + rate_limit INTEGER DEFAULT 60 NOT NULL, + max_calls BIGINT, + current_calls BIGINT DEFAULT 0 NOT NULL, + expire_time TIMESTAMP, + enabled BOOLEAN DEFAULT TRUE NOT NULL, + create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + is_delete BOOLEAN DEFAULT FALSE NOT NULL + ) + """; + jdbcTemplate.execute(sql); + jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_api_key_user ON api_key (user_id)"); + jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_api_key_enabled ON api_key (enabled) WHERE is_delete = false"); + } + + private void createWebhookConfigTable() { + String sql = """ + CREATE TABLE IF NOT EXISTS webhook_config ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT, + name VARCHAR(100) NOT NULL, + url VARCHAR(512) NOT NULL, + events JSONB DEFAULT '[]' NOT NULL, + enabled BOOLEAN DEFAULT TRUE NOT NULL, + secret VARCHAR(128), + create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + is_delete BOOLEAN DEFAULT FALSE NOT NULL + ) + """; + jdbcTemplate.execute(sql); + jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_webhook_user ON webhook_config (user_id)"); + jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_webhook_enabled ON webhook_config (enabled) WHERE is_delete = false"); + } + /** * 为所有自动创建的表添加注释(COMMENT ON)。 * 所有语句均为幂等操作,可安全重复执行。 @@ -669,7 +969,8 @@ public class DatabaseInitConfig { executeComment("COLUMN knowledge_faq.question", "标准问题"); executeComment("COLUMN knowledge_faq.answer", "标准答案"); executeComment("COLUMN knowledge_faq.similar_questions", "相似问题列表(JSON 字符串数组)"); - executeComment("COLUMN knowledge_faq.category", "FAQ 分类"); + executeComment("COLUMN knowledge_faq.category", "FAQ 分类(已废弃,改用 category_id)"); + executeComment("COLUMN knowledge_faq.category_id", "分类 ID(引用 knowledge_category.id)"); executeComment("COLUMN knowledge_faq.status", "状态: ENABLED(启用) / DISABLED(禁用)"); executeComment("COLUMN knowledge_faq.priority", "优先级(数值越大越优先匹配)"); executeComment("COLUMN knowledge_faq.hit_count", "命中次数统计"); @@ -686,6 +987,53 @@ public class DatabaseInitConfig { executeComment("COLUMN faq_embedding.model_name", "使用的 Embedding 模型名称"); executeComment("COLUMN faq_embedding.create_time", "创建时间"); + // ===== sys_user ===== + executeComment("TABLE sys_user", "系统用户表(管理后台登录用户)"); + executeComment("COLUMN sys_user.id", "主键(雪花算法生成)"); + executeComment("COLUMN sys_user.username", "用户名(唯一)"); + executeComment("COLUMN sys_user.password", "密码(BCrypt 加密)"); + executeComment("COLUMN sys_user.nickname", "昵称"); + executeComment("COLUMN sys_user.email", "邮箱"); + executeComment("COLUMN sys_user.phone", "手机号"); + executeComment("COLUMN sys_user.avatar", "头像URL"); + executeComment("COLUMN sys_user.enabled", "是否启用"); + executeComment("COLUMN sys_user.last_login_time", "最后登录时间"); + executeComment("COLUMN sys_user.create_time", "创建时间"); + executeComment("COLUMN sys_user.update_time", "更新时间"); + executeComment("COLUMN sys_user.is_delete", "逻辑删除"); + + // ===== sys_role ===== + executeComment("TABLE sys_role", "系统角色表(RBAC 角色定义)"); + executeComment("COLUMN sys_role.id", "主键"); + executeComment("COLUMN sys_role.role_key", "角色标识符(唯一)"); + executeComment("COLUMN sys_role.name", "角色名称"); + executeComment("COLUMN sys_role.description", "角色描述"); + executeComment("COLUMN sys_role.enabled", "是否启用"); + executeComment("COLUMN sys_role.create_time", "创建时间"); + executeComment("COLUMN sys_role.update_time", "更新时间"); + executeComment("COLUMN sys_role.is_delete", "逻辑删除"); + + // ===== sys_permission ===== + executeComment("TABLE sys_permission", "系统权限表(细粒度权限定义)"); + + // ===== sys_user_role ===== + executeComment("TABLE sys_user_role", "用户-角色关联表(多对多)"); + + // ===== rag_hit_log ===== + executeComment("TABLE rag_hit_log", "RAG 命中记录表(记录每次知识库检索的命中/未命中情况)"); + + // ===== dashboard_snapshot ===== + executeComment("TABLE dashboard_snapshot", "运营看板每日汇总快照"); + + // ===== api_key ===== + executeComment("TABLE api_key", "API Key 表(开放平台认证令牌)"); + + // ===== webhook_config ===== + executeComment("TABLE webhook_config", "Webhook 配置表(事件推送订阅)"); + + // ===== chat_message.user_id ===== + executeComment("COLUMN chat_message.user_id", "所属系统用户ID(数据隔离,SDK调用时为null)"); + log.info("数据库表注释已应用"); } catch (Exception e) { log.warn("应用数据库表注释时出错", e); diff --git a/src/main/java/com/wok/supportbot/controller/AiModelConfigController.java b/src/main/java/com/wok/supportbot/controller/AiModelConfigController.java index 20c53a5..c83bb7f 100644 --- a/src/main/java/com/wok/supportbot/controller/AiModelConfigController.java +++ b/src/main/java/com/wok/supportbot/controller/AiModelConfigController.java @@ -7,6 +7,7 @@ import com.wok.supportbot.entity.AiModelConfig; import com.wok.supportbot.service.AiModelConfigService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import java.util.Map; @@ -41,6 +42,7 @@ public class AiModelConfigController { * @return 分页配置列表 */ @GetMapping("/model-config/list") + @PreAuthorize("hasRole('admin')") public ResponseEntity> listConfigs( @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "10") int size, @@ -72,6 +74,7 @@ public class AiModelConfigController { * @return 配置详情 */ @GetMapping("/model-config/{id}") + @PreAuthorize("hasRole('admin')") public ResponseEntity> getConfigDetail(@PathVariable("id") Long id) { try { AiModelConfig config = aiModelConfigService.getConfigDetail(id); @@ -102,6 +105,7 @@ public class AiModelConfigController { * @return 活跃配置 */ @GetMapping("/model-config/active/{appType}") + @PreAuthorize("hasRole('admin')") public ResponseEntity> getActiveConfig(@PathVariable("appType") String appType) { try { AiModelConfig config = aiModelConfigService.getActiveConfig(appType); @@ -132,6 +136,7 @@ public class AiModelConfigController { * @return 创建结果 */ @PostMapping("/model-config") + @PreAuthorize("hasRole('admin')") public ResponseEntity> createConfig(@RequestBody AiModelConfig config) { try { // 参数校验 @@ -185,6 +190,7 @@ public class AiModelConfigController { * @return 更新结果 */ @PutMapping("/model-config/{id}") + @PreAuthorize("hasRole('admin')") public ResponseEntity> updateConfig( @PathVariable("id") Long id, @RequestBody AiModelConfig config) { @@ -218,6 +224,7 @@ public class AiModelConfigController { * @return 激活结果 */ @PutMapping("/model-config/{id}/activate") + @PreAuthorize("hasRole('admin')") public ResponseEntity> activateConfig(@PathVariable("id") Long id) { try { aiModelConfigService.activateConfig(id); @@ -248,6 +255,7 @@ public class AiModelConfigController { * @return 删除结果 */ @DeleteMapping("/model-config/{id}") + @PreAuthorize("hasRole('admin')") public ResponseEntity> deleteConfig(@PathVariable("id") Long id) { try { aiModelConfigService.deleteConfig(id); diff --git a/src/main/java/com/wok/supportbot/controller/ApiKeyController.java b/src/main/java/com/wok/supportbot/controller/ApiKeyController.java new file mode 100644 index 0000000..b0d6373 --- /dev/null +++ b/src/main/java/com/wok/supportbot/controller/ApiKeyController.java @@ -0,0 +1,183 @@ +package com.wok.supportbot.controller; + +import com.wok.supportbot.entity.ApiKey; +import com.wok.supportbot.service.ApiKeyService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.Date; +import java.util.Map; + +/** + * API Key 管理接口 + * 提供 API Key 的创建、吊销、启用、删除、分页查询等操作。 + */ +@Slf4j +@RestController +@RequestMapping("/api-key") +public class ApiKeyController { + + @Autowired + private ApiKeyService apiKeyService; + + /** + * 分页查询 API Key 列表 + */ + @GetMapping("/list") + @PreAuthorize("hasRole('admin')") + public ResponseEntity> list( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + try { + Map result = apiKeyService.listKeys(page, size); + + Map data = new java.util.HashMap<>(); + data.put("success", true); + data.put("data", result.get("records")); + data.put("total", result.get("total")); + data.put("page", result.get("page")); + data.put("size", result.get("size")); + data.put("pages", result.get("pages")); + return ResponseEntity.ok(data); + } catch (Exception e) { + log.error("查询 API Key 列表失败", e); + return ResponseEntity.status(500).body(Map.of( + "success", false, + "message", "查询失败:" + e.getMessage() + )); + } + } + + /** + * 创建 API Key + * + * @param body 请求体,包含 name, description, rateLimit, maxCalls, expireTime + */ + @PostMapping + @PreAuthorize("hasRole('admin')") + public ResponseEntity> create(@RequestBody Map body) { + try { + String name = (String) body.get("name"); + if (name == null || name.isBlank()) { + return ResponseEntity.badRequest().body(Map.of( + "success", false, + "message", "名称不能为空" + )); + } + + String description = (String) body.getOrDefault("description", ""); + Integer rateLimit = body.containsKey("rateLimit") && body.get("rateLimit") != null + ? ((Number) body.get("rateLimit")).intValue() : null; + Long maxCalls = body.containsKey("maxCalls") && body.get("maxCalls") != null + ? ((Number) body.get("maxCalls")).longValue() : null; + + Date expireTime = null; + if (body.get("expireTime") != null) { + if (body.get("expireTime") instanceof Number num) { + expireTime = new Date(num.longValue()); + } else if (body.get("expireTime") instanceof String str && !str.isBlank()) { + expireTime = new java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm").parse(str); + } + } + + ApiKey apiKey = apiKeyService.generateKey(null, name, description, rateLimit, maxCalls, expireTime); + return ResponseEntity.ok(Map.of( + "success", true, + "message", "创建成功", + "data", apiKey + )); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(Map.of( + "success", false, + "message", e.getMessage() + )); + } catch (Exception e) { + log.error("创建 API Key 失败", e); + return ResponseEntity.status(500).body(Map.of( + "success", false, + "message", "创建失败:" + e.getMessage() + )); + } + } + + /** + * 吊销 API Key + */ + @PutMapping("/{id}/revoke") + @PreAuthorize("hasRole('admin')") + public ResponseEntity> revoke(@PathVariable Long id) { + try { + apiKeyService.revokeKey(id); + return ResponseEntity.ok(Map.of( + "success", true, + "message", "已吊销" + )); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(Map.of( + "success", false, + "message", e.getMessage() + )); + } catch (Exception e) { + log.error("吊销 API Key 失败", e); + return ResponseEntity.status(500).body(Map.of( + "success", false, + "message", "吊销失败:" + e.getMessage() + )); + } + } + + /** + * 启用 API Key + */ + @PutMapping("/{id}/enable") + @PreAuthorize("hasRole('admin')") + public ResponseEntity> enable(@PathVariable Long id) { + try { + apiKeyService.enableKey(id); + return ResponseEntity.ok(Map.of( + "success", true, + "message", "已启用" + )); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(Map.of( + "success", false, + "message", e.getMessage() + )); + } catch (Exception e) { + log.error("启用 API Key 失败", e); + return ResponseEntity.status(500).body(Map.of( + "success", false, + "message", "启用失败:" + e.getMessage() + )); + } + } + + /** + * 删除 API Key + */ + @DeleteMapping("/{id}") + @PreAuthorize("hasRole('admin')") + public ResponseEntity> delete(@PathVariable Long id) { + try { + apiKeyService.deleteKey(id); + return ResponseEntity.ok(Map.of( + "success", true, + "message", "删除成功" + )); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(Map.of( + "success", false, + "message", e.getMessage() + )); + } catch (Exception e) { + log.error("删除 API Key 失败", e); + return ResponseEntity.status(500).body(Map.of( + "success", false, + "message", "删除失败:" + e.getMessage() + )); + } + } +} diff --git a/src/main/java/com/wok/supportbot/controller/ConversationController.java b/src/main/java/com/wok/supportbot/controller/ConversationController.java index df23bc6..a6af966 100644 --- a/src/main/java/com/wok/supportbot/controller/ConversationController.java +++ b/src/main/java/com/wok/supportbot/controller/ConversationController.java @@ -1,9 +1,13 @@ package com.wok.supportbot.controller; import com.wok.supportbot.service.ConversationService; +import com.wok.supportbot.service.SysUserService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -19,6 +23,9 @@ public class ConversationController { @Autowired private ConversationService conversationService; + @Autowired(required = false) + private SysUserService sysUserService; + // ==================== 会话列表 ==================== /** @@ -30,6 +37,7 @@ public class ConversationController { * @return 分页会话列表 */ @GetMapping("/conversation/list") + @PreAuthorize("hasAnyRole('admin','cs_agent','viewer','kb_operator')") public ResponseEntity> listConversations( @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "10") int size, @@ -37,7 +45,9 @@ public class ConversationController { @RequestParam(required = false) String accountId, @RequestParam(required = false) Long roleId) { try { - Map result = conversationService.listConversations(page, size, keyword, accountId, roleId); + // 获取当前登录用户ID(用于数据隔离,SDK调用时为null) + Long userId = getCurrentUserId(); + Map result = conversationService.listConversations(page, size, keyword, accountId, roleId, userId); Map data = new java.util.HashMap<>(); data.put("success", true); data.put("data", result.get("records")); @@ -64,6 +74,7 @@ public class ConversationController { * @return 逻辑删除的消息条数 */ @PostMapping("/conversation/{id}/truncate") + @PreAuthorize("hasAnyRole('admin','cs_agent')") public ResponseEntity> truncateConversation( @PathVariable("id") String conversationId, @RequestBody Map body) { @@ -90,6 +101,7 @@ public class ConversationController { * @return 会话详情 */ @GetMapping("/conversation/{id}") + @PreAuthorize("hasAnyRole('admin','cs_agent','viewer','kb_operator')") public ResponseEntity> getConversationDetail(@PathVariable("id") String conversationId) { try { Map detail = conversationService.getConversationDetail(conversationId); @@ -118,6 +130,7 @@ public class ConversationController { * @return 消息列表 */ @GetMapping("/conversation/{id}/messages") + @PreAuthorize("hasAnyRole('admin','cs_agent','viewer','kb_operator')") public ResponseEntity> getConversationMessages(@PathVariable("id") String conversationId) { try { List> messages = conversationService.getConversationMessages(conversationId); @@ -143,6 +156,7 @@ public class ConversationController { * @return 删除结果 */ @DeleteMapping("/conversation/{id}") + @PreAuthorize("hasAnyRole('admin','cs_agent')") public ResponseEntity> deleteConversation(@PathVariable("id") String conversationId) { try { int count = conversationService.deleteConversation(conversationId); @@ -168,6 +182,7 @@ public class ConversationController { * @return 格式化后的会话文本 */ @GetMapping(value = "/conversation/{id}/export", produces = MediaType.TEXT_PLAIN_VALUE + ";charset=UTF-8") + @PreAuthorize("hasAnyRole('admin','cs_agent','viewer','kb_operator')") public ResponseEntity exportConversation(@PathVariable("id") String conversationId) { try { String content = conversationService.exportConversation(conversationId); @@ -187,6 +202,7 @@ public class ConversationController { * @return 统计信息 */ @GetMapping("/conversation/stats") + @PreAuthorize("hasAnyRole('admin','cs_agent','viewer','kb_operator')") public ResponseEntity> getConversationStats() { try { Map stats = conversationService.getConversationStats(); @@ -202,4 +218,22 @@ public class ConversationController { } } + /** + * 从 SecurityContext 获取当前登录用户的系统用户ID + * 未登录时返回 null(SDK 调用场景) + */ + private Long getCurrentUserId() { + try { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth != null && auth.isAuthenticated() && !"anonymousUser".equals(auth.getPrincipal())) { + String username = auth.getName(); + if (sysUserService != null && username != null) { + var user = sysUserService.getUserByUsername(username); + if (user != null) return user.getId(); + } + } + } catch (Exception ignored) {} + return null; + } + } diff --git a/src/main/java/com/wok/supportbot/controller/CustomerAccountController.java b/src/main/java/com/wok/supportbot/controller/CustomerAccountController.java index 9e7620d..b9a21fa 100644 --- a/src/main/java/com/wok/supportbot/controller/CustomerAccountController.java +++ b/src/main/java/com/wok/supportbot/controller/CustomerAccountController.java @@ -3,6 +3,7 @@ package com.wok.supportbot.controller; import com.wok.supportbot.service.CustomerAccountService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -20,6 +21,7 @@ public class CustomerAccountController { private CustomerAccountService customerAccountService; @GetMapping("/account/list") + @PreAuthorize("hasRole('admin')") public ResponseEntity> listAccounts() { return ResponseEntity.ok(Map.of( "success", true, @@ -28,6 +30,7 @@ public class CustomerAccountController { } @GetMapping("/account/all") + @PreAuthorize("hasRole('admin')") public ResponseEntity> listAllAccounts() { return ResponseEntity.ok(Map.of( "success", true, @@ -36,6 +39,7 @@ public class CustomerAccountController { } @PostMapping("/account") + @PreAuthorize("hasRole('admin')") public ResponseEntity> createAccount(@RequestBody Map body) { try { customerAccountService.createAccount( @@ -48,6 +52,7 @@ public class CustomerAccountController { } @PutMapping("/account/{id}") + @PreAuthorize("hasRole('admin')") public ResponseEntity> updateAccount(@PathVariable("id") Long accountId, @RequestBody Map body) { try { customerAccountService.updateAccount(accountId, @@ -60,6 +65,7 @@ public class CustomerAccountController { } @DeleteMapping("/account/{id}") + @PreAuthorize("hasRole('admin')") public ResponseEntity> deleteAccount(@PathVariable("id") Long accountId) { try { customerAccountService.deleteAccount(accountId); diff --git a/src/main/java/com/wok/supportbot/controller/CustomerServiceRoleController.java b/src/main/java/com/wok/supportbot/controller/CustomerServiceRoleController.java index 6845f01..abc8439 100644 --- a/src/main/java/com/wok/supportbot/controller/CustomerServiceRoleController.java +++ b/src/main/java/com/wok/supportbot/controller/CustomerServiceRoleController.java @@ -3,6 +3,7 @@ package com.wok.supportbot.controller; import com.wok.supportbot.service.CustomerServiceRoleService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -22,6 +23,7 @@ public class CustomerServiceRoleController { private CustomerServiceRoleService customerServiceRoleService; @GetMapping("/role/list") + @PreAuthorize("hasRole('admin')") public ResponseEntity> listRoles() { return ResponseEntity.ok(Map.of( "success", true, @@ -33,6 +35,7 @@ public class CustomerServiceRoleController { * 管理用:列出全部角色(含已停用)。 */ @GetMapping("/role/all") + @PreAuthorize("hasRole('admin')") public ResponseEntity> listAllRoles() { return ResponseEntity.ok(Map.of( "success", true, @@ -41,6 +44,7 @@ public class CustomerServiceRoleController { } @PostMapping("/role") + @PreAuthorize("hasRole('admin')") public ResponseEntity> createRole(@RequestBody Map body) { try { customerServiceRoleService.createRole( @@ -53,6 +57,7 @@ public class CustomerServiceRoleController { } @PutMapping("/role/{id}") + @PreAuthorize("hasRole('admin')") public ResponseEntity> updateRole(@PathVariable("id") Long roleId, @RequestBody Map body) { try { customerServiceRoleService.updateRole(roleId, @@ -65,6 +70,7 @@ public class CustomerServiceRoleController { } @DeleteMapping("/role/{id}") + @PreAuthorize("hasRole('admin')") public ResponseEntity> deleteRole(@PathVariable("id") Long roleId) { try { customerServiceRoleService.deleteRole(roleId); @@ -75,6 +81,7 @@ public class CustomerServiceRoleController { } @PutMapping("/role/{id}/categories") + @PreAuthorize("hasRole('admin')") public ResponseEntity> updateRoleCategories( @PathVariable("id") Long roleId, @RequestBody Map body) { diff --git a/src/main/java/com/wok/supportbot/controller/DashboardController.java b/src/main/java/com/wok/supportbot/controller/DashboardController.java new file mode 100644 index 0000000..9018079 --- /dev/null +++ b/src/main/java/com/wok/supportbot/controller/DashboardController.java @@ -0,0 +1,101 @@ +package com.wok.supportbot.controller; + +import com.wok.supportbot.entity.DashboardSnapshot; +import com.wok.supportbot.service.DashboardService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** + * 运营看板控制器 + * 提供运营指标查询、趋势分析、知识库分析等 API + */ +@RestController +@RequestMapping("/dashboard") +public class DashboardController { + + @Autowired + private DashboardService dashboardService; + + /** + * 获取今日实时概览指标 + * 包含对话数、满意率、RAG命中率、平均响应时间等 + */ + @GetMapping("/overview") + @PreAuthorize("hasAnyRole('admin','kb_operator','cs_agent','viewer')") + public ResponseEntity> getOverview() { + try { + Map data = dashboardService.getOverview(); + return ResponseEntity.ok(Map.of("success", true, "data", data)); + } catch (Exception e) { + return ResponseEntity.status(500).body(Map.of( + "success", false, + "message", "查询概览数据失败:" + e.getMessage() + )); + } + } + + /** + * 获取最近 N 天的趋势数据 + * + * @param days 天数,默认 7 + */ + @GetMapping("/trend") + @PreAuthorize("hasAnyRole('admin','kb_operator','cs_agent','viewer')") + public ResponseEntity> getTrend( + @RequestParam(defaultValue = "7") int days) { + try { + List data = dashboardService.getTrend(days); + return ResponseEntity.ok(Map.of("success", true, "data", data)); + } catch (Exception e) { + return ResponseEntity.status(500).body(Map.of( + "success", false, + "message", "查询趋势数据失败:" + e.getMessage() + )); + } + } + + /** + * 获取知识库分析数据 + * 包含命中文档 TOP-10 和未命中问题列表 + */ + @GetMapping("/knowledge-analysis") + @PreAuthorize("hasAnyRole('admin','kb_operator','cs_agent','viewer')") + public ResponseEntity> getKnowledgeAnalysis() { + try { + Map data = dashboardService.getKnowledgeAnalysis(); + return ResponseEntity.ok(Map.of("success", true, "data", data)); + } catch (Exception e) { + return ResponseEntity.status(500).body(Map.of( + "success", false, + "message", "查询知识库分析失败:" + e.getMessage() + )); + } + } + + /** + * 获取自定义时间范围的快照数据 + * + * @param startDate 开始日期(yyyy-MM-dd) + * @param endDate 结束日期(yyyy-MM-dd) + */ + @GetMapping("/custom") + @PreAuthorize("hasAnyRole('admin','kb_operator','cs_agent','viewer')") + public ResponseEntity> getCustomRange( + @RequestParam String startDate, + @RequestParam String endDate) { + try { + List data = dashboardService.getCustomRange(startDate, endDate); + return ResponseEntity.ok(Map.of("success", true, "data", data)); + } catch (Exception e) { + return ResponseEntity.status(500).body(Map.of( + "success", false, + "message", "查询自定义范围数据失败:" + e.getMessage() + )); + } + } +} diff --git a/src/main/java/com/wok/supportbot/controller/DocumentController.java b/src/main/java/com/wok/supportbot/controller/DocumentController.java index d8a8da9..6594638 100644 --- a/src/main/java/com/wok/supportbot/controller/DocumentController.java +++ b/src/main/java/com/wok/supportbot/controller/DocumentController.java @@ -7,6 +7,7 @@ import com.wok.supportbot.entity.SearchResult; import com.wok.supportbot.service.DocumentService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -74,6 +75,7 @@ public class DocumentController { * @return 上传结果 */ @PostMapping("/upload/file") + @PreAuthorize("hasAnyRole('admin','kb_operator')") public ResponseEntity> uploadFile( @RequestParam("file") MultipartFile file, @RequestParam(required = false) String title, @@ -105,6 +107,7 @@ public class DocumentController { * @return 上传结果 */ @PostMapping("/upload/string") + @PreAuthorize("hasAnyRole('admin','kb_operator')") public ResponseEntity> uploadString( @RequestBody String content, @RequestParam String title, @@ -129,6 +132,7 @@ public class DocumentController { * 上传 Markdown 文件 */ @PostMapping("/upload/markdown") + @PreAuthorize("hasAnyRole('admin','kb_operator')") public ResponseEntity> uploadMarkdown( @RequestParam("file") MultipartFile file, @RequestParam(required = false) String title, @@ -154,6 +158,7 @@ public class DocumentController { * 上传 JSON 文件(基本方式) */ @PostMapping("/upload/json/basic") + @PreAuthorize("hasAnyRole('admin','kb_operator')") public ResponseEntity> uploadJsonBasic( @RequestParam("file") MultipartFile file, @RequestParam(required = false) String title, @@ -179,6 +184,7 @@ public class DocumentController { * 上传 JSON 文件(按字段提取) */ @PostMapping("/upload/json/fields") + @PreAuthorize("hasAnyRole('admin','kb_operator')") public ResponseEntity> uploadJsonWithFields( @RequestParam("file") MultipartFile file, @RequestParam("fields") List fields, @@ -206,6 +212,7 @@ public class DocumentController { * 上传 JSON 文件(按指针拆分) */ @PostMapping("/upload/json/pointer") + @PreAuthorize("hasAnyRole('admin','kb_operator')") public ResponseEntity> uploadJsonWithPointer( @RequestParam("file") MultipartFile file, @RequestParam("pointer") String pointer, @@ -241,6 +248,7 @@ public class DocumentController { * @return 分页文档列表 */ @GetMapping("/document/list") + @PreAuthorize("hasAnyRole('admin','kb_operator')") public ResponseEntity> listDocuments( @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "10") int size, @@ -268,6 +276,7 @@ public class DocumentController { * 获取文档详情 */ @GetMapping("/document/{id}") + @PreAuthorize("hasAnyRole('admin','kb_operator')") public ResponseEntity> getDocumentDetail(@PathVariable Long id) { try { KnowledgeDocument doc = documentService.getDocumentDetail(id); @@ -293,6 +302,7 @@ public class DocumentController { * 获取文档的所有分块 */ @GetMapping("/document/{id}/chunks") + @PreAuthorize("hasAnyRole('admin','kb_operator')") public ResponseEntity> getDocumentChunks(@PathVariable Long id) { try { List> chunks = documentService.getDocumentChunks(id); @@ -313,6 +323,7 @@ public class DocumentController { * 删除文档(逻辑删除 + 级联删除向量) */ @DeleteMapping("/document/{id}") + @PreAuthorize("hasAnyRole('admin','kb_operator')") public ResponseEntity> deleteDocument(@PathVariable Long id) { try { int vectorCount = documentService.deleteDocument(id); @@ -333,6 +344,7 @@ public class DocumentController { * 批量删除文档 */ @PostMapping("/document/batch/delete") + @PreAuthorize("hasAnyRole('admin','kb_operator')") public ResponseEntity> batchDeleteDocuments(@RequestBody Map body) { try { List ids = extractIds(body); @@ -361,6 +373,7 @@ public class DocumentController { * 批量重新处理文档 */ @PostMapping("/document/batch/reprocess") + @PreAuthorize("hasAnyRole('admin','kb_operator')") public ResponseEntity> batchReprocessDocuments(@RequestBody Map body) { try { List ids = extractIds(body); @@ -409,6 +422,7 @@ public class DocumentController { * 更新文档元信息 */ @PutMapping("/document/{id}") + @PreAuthorize("hasAnyRole('admin','kb_operator')") public ResponseEntity> updateDocument( @PathVariable Long id, @RequestParam(required = false) String title, @@ -432,6 +446,7 @@ public class DocumentController { * 重新处理文档(重新分块 + 向量化) */ @PutMapping("/document/{id}/reprocess") + @PreAuthorize("hasAnyRole('admin','kb_operator')") public ResponseEntity> reprocessDocument(@PathVariable Long id) { try { KnowledgeDocument doc = documentService.reprocessDocument(id); @@ -457,6 +472,7 @@ public class DocumentController { * @return 搜索结果 */ @PostMapping("/document/search") + @PreAuthorize("hasAnyRole('admin','kb_operator')") public ResponseEntity> searchDocuments(@RequestBody Map body) { try { String query = (String) body.get("query"); @@ -520,6 +536,7 @@ public class DocumentController { * 知识库统计面板 */ @GetMapping("/document/stats") + @PreAuthorize("hasAnyRole('admin','kb_operator')") public ResponseEntity> getStats() { try { Map stats = documentService.getStats(); @@ -541,6 +558,7 @@ public class DocumentController { * 获取分类树 */ @GetMapping("/category/tree") + @PreAuthorize("hasAnyRole('admin','kb_operator')") public ResponseEntity> getCategoryTree() { try { List tree = documentService.getCategoryTree(); @@ -560,6 +578,7 @@ public class DocumentController { * 获取分类列表 */ @GetMapping("/category/list") + @PreAuthorize("hasAnyRole('admin','kb_operator')") public ResponseEntity> listCategories() { try { List list = documentService.listCategories(); @@ -579,6 +598,7 @@ public class DocumentController { * 创建分类 */ @PostMapping("/category") + @PreAuthorize("hasAnyRole('admin','kb_operator')") public ResponseEntity> createCategory(@RequestBody Map body) { try { String name = (String) body.get("name"); @@ -604,6 +624,7 @@ public class DocumentController { * 更新分类 */ @PutMapping("/category/{id}") + @PreAuthorize("hasAnyRole('admin','kb_operator')") public ResponseEntity> updateCategory( @PathVariable Long id, @RequestBody Map body) { @@ -629,6 +650,7 @@ public class DocumentController { * 删除分类 */ @DeleteMapping("/category/{id}") + @PreAuthorize("hasAnyRole('admin','kb_operator')") public ResponseEntity> deleteCategory(@PathVariable Long id) { try { documentService.deleteCategory(id); diff --git a/src/main/java/com/wok/supportbot/controller/FaqController.java b/src/main/java/com/wok/supportbot/controller/FaqController.java index c8a6a91..0acba72 100644 --- a/src/main/java/com/wok/supportbot/controller/FaqController.java +++ b/src/main/java/com/wok/supportbot/controller/FaqController.java @@ -5,6 +5,7 @@ import com.wok.supportbot.service.FaqService; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -28,14 +29,15 @@ public class FaqController { * 分页查询 FAQ 列表 */ @GetMapping("/list") + @PreAuthorize("hasAnyRole('admin','kb_operator')") public ResponseEntity> list( @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size, @RequestParam(required = false) String keyword, - @RequestParam(required = false) String category, + @RequestParam(required = false) Long categoryId, @RequestParam(required = false) String status) { try { - Map data = faqService.list(page, size, keyword, category, status); + Map data = faqService.list(page, size, keyword, categoryId, status); return ResponseEntity.ok(Map.of( "success", true, "message", "查询成功", @@ -56,6 +58,7 @@ public class FaqController { * 新增 FAQ */ @PostMapping + @PreAuthorize("hasAnyRole('admin','kb_operator')") public ResponseEntity> create(@RequestBody KnowledgeFaq faq) { try { if (faq.getQuestion() == null || faq.getQuestion().isBlank()) { @@ -92,6 +95,7 @@ public class FaqController { * 修改 FAQ */ @PutMapping("/{id}") + @PreAuthorize("hasAnyRole('admin','kb_operator')") public ResponseEntity> update(@PathVariable Long id, @RequestBody KnowledgeFaq faq) { try { KnowledgeFaq updated = faqService.update(id, faq); @@ -120,6 +124,7 @@ public class FaqController { * 删除 FAQ(逻辑删除) */ @DeleteMapping("/{id}") + @PreAuthorize("hasAnyRole('admin','kb_operator')") public ResponseEntity> delete(@PathVariable Long id) { try { faqService.delete(id); @@ -147,6 +152,7 @@ public class FaqController { * 切换 FAQ 启用/禁用状态 */ @PutMapping("/{id}/toggle") + @PreAuthorize("hasAnyRole('admin','kb_operator')") public ResponseEntity> toggleStatus( @PathVariable Long id, @RequestParam String status) { @@ -177,6 +183,7 @@ public class FaqController { * 请求体格式: {"faqs": [{"question":"...", "answer":"...", "similarQuestions":"[...]", "category":"...", "priority":0}]} */ @PostMapping("/batch-import") + @PreAuthorize("hasAnyRole('admin','kb_operator')") public ResponseEntity> batchImport(@RequestBody Map> body) { try { List faqs = body.get("faqs"); @@ -208,6 +215,7 @@ public class FaqController { * 导出所有启用的 FAQ(返回 JSON 列表,后续可扩展为 Excel) */ @GetMapping("/export") + @PreAuthorize("hasAnyRole('admin','kb_operator')") public ResponseEntity> exportAll() { try { List faqs = faqService.exportAll(); @@ -231,6 +239,7 @@ public class FaqController { * 获取 FAQ 匹配统计信息 */ @GetMapping("/stats") + @PreAuthorize("hasAnyRole('admin','kb_operator')") public ResponseEntity> getStats() { try { Map stats = faqService.getStats(); @@ -254,6 +263,7 @@ public class FaqController { * 手动重新计算某条 FAQ 的向量嵌入 */ @PostMapping("/{id}/recompute-embedding") + @PreAuthorize("hasAnyRole('admin','kb_operator')") public ResponseEntity> recomputeEmbedding(@PathVariable Long id) { try { faqService.recomputeEmbedding(id); diff --git a/src/main/java/com/wok/supportbot/controller/MessageFeedbackController.java b/src/main/java/com/wok/supportbot/controller/MessageFeedbackController.java index f6618dc..f4fc871 100644 --- a/src/main/java/com/wok/supportbot/controller/MessageFeedbackController.java +++ b/src/main/java/com/wok/supportbot/controller/MessageFeedbackController.java @@ -5,6 +5,7 @@ import com.wok.supportbot.service.MessageFeedbackService; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import java.util.Arrays; @@ -74,6 +75,7 @@ public class MessageFeedbackController { * @return 统计结果 */ @GetMapping("/feedback/stats") + @PreAuthorize("hasAnyRole('admin','cs_agent','viewer')") public ResponseEntity> getStats( @RequestParam(required = false) String startDate, @RequestParam(required = false) String endDate) { @@ -98,6 +100,7 @@ public class MessageFeedbackController { * @return 反馈列表 */ @GetMapping("/feedback/by-conversation/{conversationId}") + @PreAuthorize("hasAnyRole('admin','cs_agent','viewer')") public ResponseEntity> getByConversation( @PathVariable String conversationId) { try { @@ -121,6 +124,7 @@ public class MessageFeedbackController { * @return 反馈列表 */ @GetMapping("/feedback/batch") + @PreAuthorize("hasAnyRole('admin','cs_agent','viewer')") public ResponseEntity> getBatchByMessageIds( @RequestParam String messageIds) { try { diff --git a/src/main/java/com/wok/supportbot/controller/OpenApiController.java b/src/main/java/com/wok/supportbot/controller/OpenApiController.java new file mode 100644 index 0000000..524a421 --- /dev/null +++ b/src/main/java/com/wok/supportbot/controller/OpenApiController.java @@ -0,0 +1,149 @@ +package com.wok.supportbot.controller; + +import com.wok.supportbot.app.AssistantApp; +import com.wok.supportbot.entity.ApiKey; +import com.wok.supportbot.entity.SearchResult; +import com.wok.supportbot.rag.HybridSearchService; +import com.wok.supportbot.rag.SearchMode; +import com.wok.supportbot.service.ApiKeyService; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import reactor.core.publisher.Flux; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 开放 API 接口 + * 提供第三方系统调用的对话和检索能力,鉴权由 ApiKeyAuthFilter 处理。 + */ +@Slf4j +@RestController +@RequestMapping("/open-api") +public class OpenApiController { + + @Autowired + private AssistantApp assistantApp; + + @Autowired + private HybridSearchService hybridSearchService; + + @Autowired + private ApiKeyService apiKeyService; + + /** + * 同步对话接口 + * + * @param message 用户消息 + * @param roleId 客服角色ID(可选) + * @param chatId 会话ID(可选,不传则自动生成) + * @param request HTTP 请求(含已鉴权的 API Key 信息) + * @return AI 回答 + */ + @PostMapping("/chat") + public ResponseEntity> chat( + @RequestParam String message, + @RequestParam(required = false) String roleId, + @RequestParam(required = false) String chatId, + HttpServletRequest request) { + try { + ApiKey apiKey = getApiKeyFromRequest(request); + String resolvedChatId = (chatId != null && !chatId.isBlank()) + ? chatId + : "openapi-" + apiKey.getId() + "-" + System.currentTimeMillis(); + + String reply = assistantApp.doChat(message, resolvedChatId); + + Map result = new HashMap<>(); + result.put("success", true); + result.put("data", Map.of( + "reply", reply, + "chatId", resolvedChatId + )); + return ResponseEntity.ok(result); + } catch (Exception e) { + log.error("开放 API 对话失败", e); + return ResponseEntity.status(500).body(Map.of( + "success", false, + "message", "对话失败:" + e.getMessage() + )); + } + } + + /** + * SSE 流式对话接口 + * + * @param message 用户消息 + * @param chatId 会话ID(可选) + * @param request HTTP 请求 + * @return SSE 流式回答 + */ + @GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public Flux chatStream( + @RequestParam String message, + @RequestParam(required = false) String roleId, + @RequestParam(required = false) String chatId, + HttpServletRequest request) { + ApiKey apiKey = getApiKeyFromRequest(request); + String resolvedChatId = (chatId != null && !chatId.isBlank()) + ? chatId + : "openapi-stream-" + apiKey.getId() + "-" + System.currentTimeMillis(); + + return assistantApp.doChatByStream(message, resolvedChatId); + } + + /** + * 知识库检索接口 + * + * @param query 查询文本 + * @param topK 返回条数(默认 5) + * @param searchMode 检索模式:VECTOR / KEYWORD / HYBRID(默认 VECTOR) + * @return 检索结果列表 + */ + @PostMapping("/rag/search") + public ResponseEntity> ragSearch( + @RequestParam String query, + @RequestParam(defaultValue = "5") int topK, + @RequestParam(defaultValue = "VECTOR") String searchMode) { + try { + SearchMode mode; + try { + mode = SearchMode.valueOf(searchMode.toUpperCase()); + } catch (IllegalArgumentException e) { + mode = SearchMode.VECTOR; + } + + List results = hybridSearchService.search( + query, mode, topK, 0.0, Collections.emptyList()); + + Map result = new HashMap<>(); + result.put("success", true); + result.put("data", results); + result.put("total", results.size()); + return ResponseEntity.ok(result); + } catch (Exception e) { + log.error("开放 API 检索失败", e); + return ResponseEntity.status(500).body(Map.of( + "success", false, + "message", "检索失败:" + e.getMessage() + )); + } + } + + /** + * 从 request attribute 获取已鉴权的 API Key 信息 + */ + private ApiKey getApiKeyFromRequest(HttpServletRequest request) { + Object attr = request.getAttribute("apiKey"); + if (attr instanceof ApiKey apiKey) { + return apiKey; + } + throw new IllegalStateException("API Key 鉴权信息缺失"); + } +} diff --git a/src/main/java/com/wok/supportbot/controller/SensitiveWordController.java b/src/main/java/com/wok/supportbot/controller/SensitiveWordController.java index d182b56..739f65a 100644 --- a/src/main/java/com/wok/supportbot/controller/SensitiveWordController.java +++ b/src/main/java/com/wok/supportbot/controller/SensitiveWordController.java @@ -6,6 +6,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import java.util.HashMap; @@ -32,6 +33,7 @@ public class SensitiveWordController { * 分页查询敏感词列表 */ @GetMapping("/list") + @PreAuthorize("hasRole('admin')") public ResponseEntity> list( @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "10") int size, @@ -61,6 +63,7 @@ public class SensitiveWordController { * 新增敏感词 */ @PostMapping + @PreAuthorize("hasRole('admin')") public ResponseEntity> create(@RequestBody SensitiveWord word) { try { SensitiveWord created = sensitiveWordService.create(word); @@ -87,6 +90,7 @@ public class SensitiveWordController { * 修改敏感词 */ @PutMapping("/{id}") + @PreAuthorize("hasRole('admin')") public ResponseEntity> update(@PathVariable Long id, @RequestBody SensitiveWord word) { try { SensitiveWord updated = sensitiveWordService.update(id, word); @@ -113,6 +117,7 @@ public class SensitiveWordController { * 删除敏感词(逻辑删除) */ @DeleteMapping("/{id}") + @PreAuthorize("hasRole('admin')") public ResponseEntity> delete(@PathVariable Long id) { try { sensitiveWordService.delete(id); @@ -141,6 +146,7 @@ public class SensitiveWordController { */ @SuppressWarnings("unchecked") @PostMapping("/batch-import") + @PreAuthorize("hasRole('admin')") public ResponseEntity> batchImport(@RequestBody Map body) { try { List words = (List) body.get("words"); @@ -191,6 +197,7 @@ public class SensitiveWordController { * 分页查询内容审计日志(使用 JdbcTemplate 直接查询,因为审计日志表无逻辑删除字段) */ @GetMapping("/audit-log") + @PreAuthorize("hasRole('admin')") public ResponseEntity> auditLog( @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "10") int size, diff --git a/src/main/java/com/wok/supportbot/controller/SysRoleController.java b/src/main/java/com/wok/supportbot/controller/SysRoleController.java new file mode 100644 index 0000000..6116546 --- /dev/null +++ b/src/main/java/com/wok/supportbot/controller/SysRoleController.java @@ -0,0 +1,85 @@ +package com.wok.supportbot.controller; + +import com.wok.supportbot.service.SysRoleService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +/** + * 系统角色管理控制器 + */ +@Slf4j +@RestController +@RequestMapping("/sys-role") +public class SysRoleController { + + private final SysRoleService sysRoleService; + + public SysRoleController(SysRoleService sysRoleService) { + this.sysRoleService = sysRoleService; + } + + /** + * 获取所有角色列表 + */ + @GetMapping("/list") + @PreAuthorize("hasRole('admin')") + public ResponseEntity> list() { + try { + var roles = sysRoleService.listAll(); + return ResponseEntity.ok(Map.of("success", true, "data", roles)); + } catch (Exception e) { + log.error("查询角色列表失败", e); + return ResponseEntity.internalServerError().body(Map.of( + "success", false, "message", "查询失败: " + e.getMessage() + )); + } + } + + /** + * 创建角色 + */ + @PostMapping + @PreAuthorize("hasRole('admin')") + public ResponseEntity> create(@RequestBody Map body) { + try { + String roleKey = body.get("roleKey"); + String name = body.get("name"); + String description = body.get("description"); + sysRoleService.createRole(roleKey, name, description); + return ResponseEntity.ok(Map.of("success", true, "message", "创建成功")); + } catch (IllegalArgumentException e) { + return ResponseEntity.ok(Map.of("success", false, "message", e.getMessage())); + } catch (Exception e) { + log.error("创建角色失败", e); + return ResponseEntity.internalServerError().body(Map.of( + "success", false, "message", "创建失败: " + e.getMessage() + )); + } + } + + /** + * 更新角色 + */ + @PutMapping("/{id}") + @PreAuthorize("hasRole('admin')") + public ResponseEntity> update(@PathVariable Long id, @RequestBody Map body) { + try { + String name = (String) body.get("name"); + String description = (String) body.get("description"); + Boolean enabled = body.get("enabled") != null ? (Boolean) body.get("enabled") : null; + sysRoleService.updateRole(id, name, description, enabled); + return ResponseEntity.ok(Map.of("success", true, "message", "更新成功")); + } catch (IllegalArgumentException e) { + return ResponseEntity.ok(Map.of("success", false, "message", e.getMessage())); + } catch (Exception e) { + log.error("更新角色失败", e); + return ResponseEntity.internalServerError().body(Map.of( + "success", false, "message", "更新失败: " + e.getMessage() + )); + } + } +} diff --git a/src/main/java/com/wok/supportbot/controller/SysUserController.java b/src/main/java/com/wok/supportbot/controller/SysUserController.java new file mode 100644 index 0000000..dc1b983 --- /dev/null +++ b/src/main/java/com/wok/supportbot/controller/SysUserController.java @@ -0,0 +1,197 @@ +package com.wok.supportbot.controller; + +import com.wok.supportbot.service.SysUserService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** + * 系统用户管理控制器 + */ +@Slf4j +@RestController +@RequestMapping("/sys-user") +public class SysUserController { + + private final SysUserService sysUserService; + + public SysUserController(SysUserService sysUserService) { + this.sysUserService = sysUserService; + } + + /** + * 分页查询用户列表 + */ + @GetMapping("/list") + @PreAuthorize("hasRole('admin')") + public ResponseEntity> list( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) Boolean enabled) { + try { + Map data = sysUserService.listUsers(page, size, keyword, enabled); + return ResponseEntity.ok(Map.of("success", true, "data", data)); + } catch (Exception e) { + log.error("查询用户列表失败", e); + return ResponseEntity.internalServerError().body(Map.of( + "success", false, "message", "查询失败: " + e.getMessage() + )); + } + } + + /** + * 获取用户详情 + */ + @GetMapping("/{id}") + @PreAuthorize("hasRole('admin')") + public ResponseEntity> detail(@PathVariable Long id) { + try { + var user = sysUserService.getUserById(id); + if (user == null) { + return ResponseEntity.ok(Map.of("success", false, "message", "用户不存在")); + } + return ResponseEntity.ok(Map.of("success", true, "data", user)); + } catch (Exception e) { + log.error("查询用户详情失败", e); + return ResponseEntity.internalServerError().body(Map.of( + "success", false, "message", "查询失败: " + e.getMessage() + )); + } + } + + /** + * 创建用户 + */ + @PostMapping + @PreAuthorize("hasRole('admin')") + public ResponseEntity> create(@RequestBody Map body) { + try { + String username = (String) body.get("username"); + String password = (String) body.get("password"); + String nickname = (String) body.get("nickname"); + String email = (String) body.get("email"); + String phone = (String) body.get("phone"); + @SuppressWarnings("unchecked") + List roleIds = body.get("roleIds") != null + ? ((List) body.get("roleIds")).stream().map(Number::longValue).toList() + : List.of(); + + var user = sysUserService.createUser(username, password, nickname, email, phone, roleIds); + return ResponseEntity.ok(Map.of("success", true, "message", "创建成功", "data", Map.of("id", user.getId().toString()))); + } catch (IllegalArgumentException e) { + return ResponseEntity.ok(Map.of("success", false, "message", e.getMessage())); + } catch (Exception e) { + log.error("创建用户失败", e); + return ResponseEntity.internalServerError().body(Map.of( + "success", false, "message", "创建失败: " + e.getMessage() + )); + } + } + + /** + * 更新用户信息 + */ + @PutMapping("/{id}") + @PreAuthorize("hasRole('admin')") + public ResponseEntity> update(@PathVariable Long id, @RequestBody Map body) { + try { + String nickname = (String) body.get("nickname"); + String email = (String) body.get("email"); + String phone = (String) body.get("phone"); + sysUserService.updateUser(id, nickname, email, phone); + return ResponseEntity.ok(Map.of("success", true, "message", "更新成功")); + } catch (IllegalArgumentException e) { + return ResponseEntity.ok(Map.of("success", false, "message", e.getMessage())); + } catch (Exception e) { + log.error("更新用户失败", e); + return ResponseEntity.internalServerError().body(Map.of( + "success", false, "message", "更新失败: " + e.getMessage() + )); + } + } + + /** + * 启用/禁用用户 + */ + @PutMapping("/{id}/toggle") + @PreAuthorize("hasRole('admin')") + public ResponseEntity> toggle(@PathVariable Long id, @RequestParam boolean enabled) { + try { + sysUserService.toggleUser(id, enabled); + return ResponseEntity.ok(Map.of("success", true, "message", enabled ? "已启用" : "已禁用")); + } catch (IllegalArgumentException e) { + return ResponseEntity.ok(Map.of("success", false, "message", e.getMessage())); + } catch (Exception e) { + log.error("切换用户状态失败", e); + return ResponseEntity.internalServerError().body(Map.of( + "success", false, "message", "操作失败: " + e.getMessage() + )); + } + } + + /** + * 分配角色 + */ + @PutMapping("/{id}/roles") + @PreAuthorize("hasRole('admin')") + public ResponseEntity> assignRoles(@PathVariable Long id, @RequestBody Map body) { + try { + @SuppressWarnings("unchecked") + List roleIds = body.get("roleIds") != null + ? ((List) body.get("roleIds")).stream().map(Number::longValue).toList() + : List.of(); + sysUserService.assignRoles(id, roleIds); + return ResponseEntity.ok(Map.of("success", true, "message", "角色分配成功")); + } catch (Exception e) { + log.error("分配角色失败", e); + return ResponseEntity.internalServerError().body(Map.of( + "success", false, "message", "分配失败: " + e.getMessage() + )); + } + } + + /** + * 修改密码 + */ + @PutMapping("/{id}/password") + @PreAuthorize("hasRole('admin')") + public ResponseEntity> changePassword(@PathVariable Long id, @RequestBody Map body) { + try { + String newPassword = body.get("newPassword"); + if (newPassword == null || newPassword.isBlank()) { + return ResponseEntity.ok(Map.of("success", false, "message", "新密码不能为空")); + } + sysUserService.changePassword(id, newPassword); + return ResponseEntity.ok(Map.of("success", true, "message", "密码修改成功")); + } catch (IllegalArgumentException e) { + return ResponseEntity.ok(Map.of("success", false, "message", e.getMessage())); + } catch (Exception e) { + log.error("修改密码失败", e); + return ResponseEntity.internalServerError().body(Map.of( + "success", false, "message", "修改失败: " + e.getMessage() + )); + } + } + + /** + * 获取所有角色(用于分配角色时的下拉选择) + */ + @GetMapping("/roles") + @PreAuthorize("hasRole('admin')") + public ResponseEntity> allRoles() { + try { + var roles = sysUserService.getAllRoles(); + return ResponseEntity.ok(Map.of("success", true, "data", roles)); + } catch (Exception e) { + log.error("查询角色列表失败", e); + return ResponseEntity.internalServerError().body(Map.of( + "success", false, "message", "查询失败: " + e.getMessage() + )); + } + } +} diff --git a/src/main/java/com/wok/supportbot/controller/WebhookController.java b/src/main/java/com/wok/supportbot/controller/WebhookController.java new file mode 100644 index 0000000..d8b2c06 --- /dev/null +++ b/src/main/java/com/wok/supportbot/controller/WebhookController.java @@ -0,0 +1,190 @@ +package com.wok.supportbot.controller; + +import com.wok.supportbot.entity.WebhookConfig; +import com.wok.supportbot.service.WebhookService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.*; + +/** + * Webhook 管理接口 + * 提供 Webhook 的创建、更新、删除、分页查询、测试推送等操作。 + */ +@Slf4j +@RestController +@RequestMapping("/webhook") +public class WebhookController { + + @Autowired + private WebhookService webhookService; + + /** + * 分页查询 Webhook 列表 + */ + @GetMapping("/list") + @PreAuthorize("hasRole('admin')") + public ResponseEntity> list( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + try { + Map result = webhookService.listWebhooks(page, size); + + Map data = new HashMap<>(); + data.put("success", true); + data.put("data", result.get("records")); + data.put("total", result.get("total")); + data.put("page", result.get("page")); + data.put("size", result.get("size")); + data.put("pages", result.get("pages")); + return ResponseEntity.ok(data); + } catch (Exception e) { + log.error("查询 Webhook 列表失败", e); + return ResponseEntity.status(500).body(Map.of( + "success", false, + "message", "查询失败:" + e.getMessage() + )); + } + } + + /** + * 创建 Webhook + * + * @param body 请求体,包含 name, url, events(事件列表) + */ + @SuppressWarnings("unchecked") + @PostMapping + @PreAuthorize("hasRole('admin')") + public ResponseEntity> create(@RequestBody Map body) { + try { + String name = (String) body.get("name"); + String url = (String) body.get("url"); + if (name == null || name.isBlank()) { + return ResponseEntity.badRequest().body(Map.of( + "success", false, + "message", "名称不能为空" + )); + } + if (url == null || url.isBlank()) { + return ResponseEntity.badRequest().body(Map.of( + "success", false, + "message", "URL 不能为空" + )); + } + + List events = body.get("events") instanceof List + ? ((List) body.get("events")) + : new ArrayList<>(); + + WebhookConfig config = webhookService.createWebhook(null, name, url, events); + return ResponseEntity.ok(Map.of( + "success", true, + "message", "创建成功", + "data", config + )); + } catch (Exception e) { + log.error("创建 Webhook 失败", e); + return ResponseEntity.status(500).body(Map.of( + "success", false, + "message", "创建失败:" + e.getMessage() + )); + } + } + + /** + * 更新 Webhook + * + * @param id Webhook ID + * @param body 请求体,包含 name, url, events, enabled(均可选) + */ + @SuppressWarnings("unchecked") + @PutMapping("/{id}") + @PreAuthorize("hasRole('admin')") + public ResponseEntity> update(@PathVariable Long id, + @RequestBody Map body) { + try { + String name = body.containsKey("name") ? (String) body.get("name") : null; + String url = body.containsKey("url") ? (String) body.get("url") : null; + List events = body.get("events") instanceof List + ? ((List) body.get("events")) : null; + Boolean enabled = body.containsKey("enabled") ? (Boolean) body.get("enabled") : null; + + WebhookConfig config = webhookService.updateWebhook(id, name, url, events, enabled); + return ResponseEntity.ok(Map.of( + "success", true, + "message", "更新成功", + "data", config + )); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(Map.of( + "success", false, + "message", e.getMessage() + )); + } catch (Exception e) { + log.error("更新 Webhook 失败", e); + return ResponseEntity.status(500).body(Map.of( + "success", false, + "message", "更新失败:" + e.getMessage() + )); + } + } + + /** + * 删除 Webhook + */ + @DeleteMapping("/{id}") + @PreAuthorize("hasRole('admin')") + public ResponseEntity> delete(@PathVariable Long id) { + try { + webhookService.deleteWebhook(id); + return ResponseEntity.ok(Map.of( + "success", true, + "message", "删除成功" + )); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(Map.of( + "success", false, + "message", e.getMessage() + )); + } catch (Exception e) { + log.error("删除 Webhook 失败", e); + return ResponseEntity.status(500).body(Map.of( + "success", false, + "message", "删除失败:" + e.getMessage() + )); + } + } + + /** + * 测试 Webhook 推送 + * 向指定 Webhook 发送一条测试事件,验证连通性。 + * + * @param id Webhook ID + */ + @PostMapping("/{id}/test") + @PreAuthorize("hasRole('admin')") + public ResponseEntity> test(@PathVariable Long id) { + try { + Map testPayload = Map.of( + "message", "这是一条 Webhook 测试推送", + "webhookId", id.toString() + ); + // 直接触发测试事件(使用同步方式以便立即返回结果) + // triggerEvent 是异步的,这里手动构建测试请求 + webhookService.triggerEvent("test.ping", testPayload); + return ResponseEntity.ok(Map.of( + "success", true, + "message", "测试推送已发送,请检查目标 URL 是否收到请求" + )); + } catch (Exception e) { + log.error("Webhook 测试推送失败", e); + return ResponseEntity.status(500).body(Map.of( + "success", false, + "message", "测试推送失败:" + e.getMessage() + )); + } + } +} diff --git a/src/main/java/com/wok/supportbot/dao/ApiKeyMapper.java b/src/main/java/com/wok/supportbot/dao/ApiKeyMapper.java new file mode 100644 index 0000000..b05cf05 --- /dev/null +++ b/src/main/java/com/wok/supportbot/dao/ApiKeyMapper.java @@ -0,0 +1,12 @@ +package com.wok.supportbot.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.wok.supportbot.entity.ApiKey; +import org.apache.ibatis.annotations.Mapper; + +/** + * API Key Mapper + */ +@Mapper +public interface ApiKeyMapper extends BaseMapper { +} diff --git a/src/main/java/com/wok/supportbot/dao/DashboardSnapshotMapper.java b/src/main/java/com/wok/supportbot/dao/DashboardSnapshotMapper.java new file mode 100644 index 0000000..5b2a7f4 --- /dev/null +++ b/src/main/java/com/wok/supportbot/dao/DashboardSnapshotMapper.java @@ -0,0 +1,12 @@ +package com.wok.supportbot.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.wok.supportbot.entity.DashboardSnapshot; +import org.apache.ibatis.annotations.Mapper; + +/** + * 看板快照 Mapper + */ +@Mapper +public interface DashboardSnapshotMapper extends BaseMapper { +} diff --git a/src/main/java/com/wok/supportbot/dao/RagHitLogMapper.java b/src/main/java/com/wok/supportbot/dao/RagHitLogMapper.java new file mode 100644 index 0000000..1b28f7d --- /dev/null +++ b/src/main/java/com/wok/supportbot/dao/RagHitLogMapper.java @@ -0,0 +1,12 @@ +package com.wok.supportbot.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.wok.supportbot.entity.RagHitLog; +import org.apache.ibatis.annotations.Mapper; + +/** + * RAG 命中日志 Mapper + */ +@Mapper +public interface RagHitLogMapper extends BaseMapper { +} diff --git a/src/main/java/com/wok/supportbot/dao/SysPermissionMapper.java b/src/main/java/com/wok/supportbot/dao/SysPermissionMapper.java new file mode 100644 index 0000000..4e98f37 --- /dev/null +++ b/src/main/java/com/wok/supportbot/dao/SysPermissionMapper.java @@ -0,0 +1,12 @@ +package com.wok.supportbot.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.wok.supportbot.entity.SysPermission; +import org.apache.ibatis.annotations.Mapper; + +/** + * 系统权限 Mapper + */ +@Mapper +public interface SysPermissionMapper extends BaseMapper { +} diff --git a/src/main/java/com/wok/supportbot/dao/SysRoleMapper.java b/src/main/java/com/wok/supportbot/dao/SysRoleMapper.java new file mode 100644 index 0000000..80d4c46 --- /dev/null +++ b/src/main/java/com/wok/supportbot/dao/SysRoleMapper.java @@ -0,0 +1,12 @@ +package com.wok.supportbot.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.wok.supportbot.entity.SysRole; +import org.apache.ibatis.annotations.Mapper; + +/** + * 系统角色 Mapper + */ +@Mapper +public interface SysRoleMapper extends BaseMapper { +} diff --git a/src/main/java/com/wok/supportbot/dao/SysUserMapper.java b/src/main/java/com/wok/supportbot/dao/SysUserMapper.java new file mode 100644 index 0000000..11f0ddc --- /dev/null +++ b/src/main/java/com/wok/supportbot/dao/SysUserMapper.java @@ -0,0 +1,12 @@ +package com.wok.supportbot.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.wok.supportbot.entity.SysUser; +import org.apache.ibatis.annotations.Mapper; + +/** + * 系统用户 Mapper + */ +@Mapper +public interface SysUserMapper extends BaseMapper { +} diff --git a/src/main/java/com/wok/supportbot/dao/SysUserRoleMapper.java b/src/main/java/com/wok/supportbot/dao/SysUserRoleMapper.java new file mode 100644 index 0000000..fefe5bd --- /dev/null +++ b/src/main/java/com/wok/supportbot/dao/SysUserRoleMapper.java @@ -0,0 +1,12 @@ +package com.wok.supportbot.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.wok.supportbot.entity.SysUserRole; +import org.apache.ibatis.annotations.Mapper; + +/** + * 用户-角色关联 Mapper + */ +@Mapper +public interface SysUserRoleMapper extends BaseMapper { +} diff --git a/src/main/java/com/wok/supportbot/dao/WebhookConfigMapper.java b/src/main/java/com/wok/supportbot/dao/WebhookConfigMapper.java new file mode 100644 index 0000000..3e7aedd --- /dev/null +++ b/src/main/java/com/wok/supportbot/dao/WebhookConfigMapper.java @@ -0,0 +1,12 @@ +package com.wok.supportbot.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.wok.supportbot.entity.WebhookConfig; +import org.apache.ibatis.annotations.Mapper; + +/** + * Webhook 配置 Mapper + */ +@Mapper +public interface WebhookConfigMapper extends BaseMapper { +} diff --git a/src/main/java/com/wok/supportbot/entity/ApiKey.java b/src/main/java/com/wok/supportbot/entity/ApiKey.java new file mode 100644 index 0000000..e97f9b4 --- /dev/null +++ b/src/main/java/com/wok/supportbot/entity/ApiKey.java @@ -0,0 +1,83 @@ +package com.wok.supportbot.entity; + +import com.baomidou.mybatisplus.annotation.*; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * API Key 实体 + */ +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +@TableName("api_key") +public class ApiKey implements Serializable { + + @Serial + @TableField(exist = false) + private static final long serialVersionUID = 1L; + + /** 主键ID(雪花算法) */ + @TableId(value = "id", type = IdType.ASSIGN_ID) + @JsonSerialize(using = ToStringSerializer.class) + private Long id; + + /** API Key 值(sk_ 前缀 + UUID) */ + @TableField("key_value") + private String keyValue; + + /** Key 名称 */ + @TableField("name") + private String name; + + /** 描述 */ + @TableField("description") + private String description; + + /** 所属用户ID */ + @TableField("user_id") + @JsonSerialize(using = ToStringSerializer.class) + private Long userId; + + /** 每分钟频率限制(默认60) */ + @TableField("rate_limit") + private Integer rateLimit; + + /** 最大调用次数(null 表示不限制) */ + @TableField("max_calls") + private Long maxCalls; + + /** 已调用次数 */ + @TableField("current_calls") + private Long currentCalls; + + /** 过期时间(null 表示永不过期) */ + @TableField("expire_time") + private Date expireTime; + + /** 是否启用 */ + @TableField("enabled") + private Boolean enabled; + + /** 创建时间 */ + @TableField(value = "create_time", fill = FieldFill.INSERT) + private Date createTime; + + /** 更新时间 */ + @TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE) + private Date updateTime; + + /** 逻辑删除标识 */ + @TableField("is_delete") + @TableLogic + private boolean isDelete; +} diff --git a/src/main/java/com/wok/supportbot/entity/ChatMessage.java b/src/main/java/com/wok/supportbot/entity/ChatMessage.java index 37ad2e0..bfb5308 100644 --- a/src/main/java/com/wok/supportbot/entity/ChatMessage.java +++ b/src/main/java/com/wok/supportbot/entity/ChatMessage.java @@ -1,6 +1,8 @@ package com.wok.supportbot.entity; import com.baomidou.mybatisplus.annotation.*; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; import com.wok.supportbot.handler.PostgresJsonTypeHandler; import lombok.AllArgsConstructor; import lombok.Builder; @@ -39,6 +41,13 @@ public class ChatMessage implements Serializable { @TableField("message_type") private MessageType messageType; + /** + * 所属系统用户ID(数据隔离,SDK调用时为null) + */ + @TableField("user_id") + @JsonSerialize(using = ToStringSerializer.class) + private Long userId; + /** * 消息内容 */ diff --git a/src/main/java/com/wok/supportbot/entity/DashboardSnapshot.java b/src/main/java/com/wok/supportbot/entity/DashboardSnapshot.java new file mode 100644 index 0000000..4c80bf4 --- /dev/null +++ b/src/main/java/com/wok/supportbot/entity/DashboardSnapshot.java @@ -0,0 +1,92 @@ +package com.wok.supportbot.entity; + +import com.baomidou.mybatisplus.annotation.*; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import com.wok.supportbot.handler.PostgresJsonTypeHandler; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; +import java.util.Map; + +/** + * 看板快照实体 + * 每日凌晨汇总前一天的运营指标数据 + */ +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +@TableName(value = "dashboard_snapshot", autoResultMap = true) +public class DashboardSnapshot implements Serializable { + + @Serial + @TableField(exist = false) + private static final long serialVersionUID = 1L; + + /** 主键ID(雪花算法) */ + @TableId(value = "id", type = IdType.ASSIGN_ID) + @JsonSerialize(using = ToStringSerializer.class) + private Long id; + + /** 快照日期(唯一) */ + @TableField("snapshot_date") + private Date snapshotDate; + + /** 对话数 */ + @TableField("conversation_count") + private Integer conversationCount; + + /** 消息数 */ + @TableField("message_count") + private Integer messageCount; + + /** 满意率(0~1) */ + @TableField("satisfaction_rate") + private Double satisfactionRate; + + /** 点赞数 */ + @TableField("thumbs_up_count") + private Integer thumbsUpCount; + + /** 点踩数 */ + @TableField("thumbs_down_count") + private Integer thumbsDownCount; + + /** RAG 命中数 */ + @TableField("rag_hit_count") + private Integer ragHitCount; + + /** RAG 未命中数 */ + @TableField("rag_miss_count") + private Integer ragMissCount; + + /** 平均响应时间(毫秒) */ + @TableField("avg_response_time") + private Double avgResponseTime; + + /** 热门问题 TOP 列表(JSONB) */ + @TableField(value = "top_questions", typeHandler = PostgresJsonTypeHandler.class) + private Map topQuestions; + + /** 命中文档 TOP 列表(JSONB) */ + @TableField(value = "top_hit_documents", typeHandler = PostgresJsonTypeHandler.class) + private Map topHitDocuments; + + /** 未命中问题列表(JSONB) */ + @TableField(value = "miss_questions", typeHandler = PostgresJsonTypeHandler.class) + private Map missQuestions; + + /** 创建时间 */ + @TableField(value = "create_time", fill = FieldFill.INSERT) + private Date createTime; + + /** 更新时间 */ + @TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE) + private Date updateTime; +} diff --git a/src/main/java/com/wok/supportbot/entity/KnowledgeFaq.java b/src/main/java/com/wok/supportbot/entity/KnowledgeFaq.java index 32e8b7b..91c7788 100644 --- a/src/main/java/com/wok/supportbot/entity/KnowledgeFaq.java +++ b/src/main/java/com/wok/supportbot/entity/KnowledgeFaq.java @@ -52,11 +52,18 @@ public class KnowledgeFaq implements Serializable { private String similarQuestions; /** - * FAQ 分类,如"退货政策" + * FAQ 分类(已废弃,保留向后兼容),改用 categoryId 引用 knowledge_category 表 */ @TableField("category") private String category; + /** + * 分类 ID,引用 knowledge_category.id(文档管理分类体系) + */ + @TableField("category_id") + @JsonSerialize(using = ToStringSerializer.class) + private Long categoryId; + /** * 状态: ENABLED / DISABLED */ diff --git a/src/main/java/com/wok/supportbot/entity/RagHitLog.java b/src/main/java/com/wok/supportbot/entity/RagHitLog.java new file mode 100644 index 0000000..03f66c9 --- /dev/null +++ b/src/main/java/com/wok/supportbot/entity/RagHitLog.java @@ -0,0 +1,66 @@ +package com.wok.supportbot.entity; + +import com.baomidou.mybatisplus.annotation.*; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * RAG 命中日志实体 + * 记录每次知识库检索的命中/未命中情况(append-only,无逻辑删除) + */ +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +@TableName("rag_hit_log") +public class RagHitLog implements Serializable { + + @Serial + @TableField(exist = false) + private static final long serialVersionUID = 1L; + + /** 主键ID(雪花算法) */ + @TableId(value = "id", type = IdType.ASSIGN_ID) + @JsonSerialize(using = ToStringSerializer.class) + private Long id; + + /** 会话ID */ + @TableField("conversation_id") + private String conversationId; + + /** 用户查询文本 */ + @TableField("user_query") + private String userQuery; + + /** 命中的文档ID(未命中时为 null) */ + @TableField("document_id") + private Long documentId; + + /** 命中的文档标题 */ + @TableField("document_title") + private String documentTitle; + + /** 命中的分块ID */ + @TableField("chunk_id") + private String chunkId; + + /** 匹配得分 */ + @TableField("score") + private Double score; + + /** 检索模式:VECTOR / KEYWORD / HYBRID */ + @TableField("search_mode") + private String searchMode; + + /** 创建时间 */ + @TableField(value = "create_time", fill = FieldFill.INSERT) + private Date createTime; +} diff --git a/src/main/java/com/wok/supportbot/entity/SysPermission.java b/src/main/java/com/wok/supportbot/entity/SysPermission.java new file mode 100644 index 0000000..8abcb18 --- /dev/null +++ b/src/main/java/com/wok/supportbot/entity/SysPermission.java @@ -0,0 +1,54 @@ +package com.wok.supportbot.entity; + +import com.baomidou.mybatisplus.annotation.*; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * 系统权限实体 + */ +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +@TableName("sys_permission") +public class SysPermission implements Serializable { + + @Serial + @TableField(exist = false) + private static final long serialVersionUID = 1L; + + /** 主键ID(雪花算法) */ + @TableId(value = "id", type = IdType.ASSIGN_ID) + @JsonSerialize(using = ToStringSerializer.class) + private Long id; + + /** 权限标识符(如 document:upload、faq:manage) */ + @TableField("permission_key") + private String permissionKey; + + /** 权限名称 */ + @TableField("name") + private String name; + + /** 权限描述 */ + @TableField("description") + private String description; + + /** 创建时间 */ + @TableField(value = "create_time", fill = FieldFill.INSERT) + private Date createTime; + + /** 逻辑删除标识 */ + @TableField("is_delete") + @TableLogic + private Boolean isDelete; +} diff --git a/src/main/java/com/wok/supportbot/entity/SysRole.java b/src/main/java/com/wok/supportbot/entity/SysRole.java new file mode 100644 index 0000000..dc1f0b3 --- /dev/null +++ b/src/main/java/com/wok/supportbot/entity/SysRole.java @@ -0,0 +1,62 @@ +package com.wok.supportbot.entity; + +import com.baomidou.mybatisplus.annotation.*; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * 系统角色实体 + */ +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +@TableName("sys_role") +public class SysRole implements Serializable { + + @Serial + @TableField(exist = false) + private static final long serialVersionUID = 1L; + + /** 主键ID(雪花算法) */ + @TableId(value = "id", type = IdType.ASSIGN_ID) + @JsonSerialize(using = ToStringSerializer.class) + private Long id; + + /** 角色标识符(唯一,如 admin/kb_operator/cs_agent/viewer) */ + @TableField("role_key") + private String roleKey; + + /** 角色名称 */ + @TableField("name") + private String name; + + /** 角色描述 */ + @TableField("description") + private String description; + + /** 是否启用 */ + @TableField("enabled") + private Boolean enabled; + + /** 创建时间 */ + @TableField(value = "create_time", fill = FieldFill.INSERT) + private Date createTime; + + /** 更新时间 */ + @TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE) + private Date updateTime; + + /** 逻辑删除标识 */ + @TableField("is_delete") + @TableLogic + private Boolean isDelete; +} diff --git a/src/main/java/com/wok/supportbot/entity/SysUser.java b/src/main/java/com/wok/supportbot/entity/SysUser.java new file mode 100644 index 0000000..4e3ad41 --- /dev/null +++ b/src/main/java/com/wok/supportbot/entity/SysUser.java @@ -0,0 +1,83 @@ +package com.wok.supportbot.entity; + +import com.baomidou.mybatisplus.annotation.*; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; +import java.util.List; + +/** + * 系统用户实体 + */ +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +@TableName("sys_user") +public class SysUser implements Serializable { + + @Serial + @TableField(exist = false) + private static final long serialVersionUID = 1L; + + /** 主键ID(雪花算法) */ + @TableId(value = "id", type = IdType.ASSIGN_ID) + @JsonSerialize(using = ToStringSerializer.class) + private Long id; + + /** 用户名 */ + @TableField("username") + private String username; + + /** 密码(BCrypt 加密) */ + @TableField("password") + private String password; + + /** 昵称 */ + @TableField("nickname") + private String nickname; + + /** 邮箱 */ + @TableField("email") + private String email; + + /** 手机号 */ + @TableField("phone") + private String phone; + + /** 头像URL */ + @TableField("avatar") + private String avatar; + + /** 是否启用 */ + @TableField("enabled") + private Boolean enabled; + + /** 最后登录时间 */ + @TableField("last_login_time") + private Date lastLoginTime; + + /** 创建时间 */ + @TableField(value = "create_time", fill = FieldFill.INSERT) + private Date createTime; + + /** 更新时间 */ + @TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE) + private Date updateTime; + + /** 逻辑删除标识 */ + @TableField("is_delete") + @TableLogic + private Boolean isDelete; + + /** 用户角色列表(非持久化,查询时填充) */ + @TableField(exist = false) + private List roles; +} diff --git a/src/main/java/com/wok/supportbot/entity/SysUserRole.java b/src/main/java/com/wok/supportbot/entity/SysUserRole.java new file mode 100644 index 0000000..ba2b63b --- /dev/null +++ b/src/main/java/com/wok/supportbot/entity/SysUserRole.java @@ -0,0 +1,45 @@ +package com.wok.supportbot.entity; + +import com.baomidou.mybatisplus.annotation.*; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; + +/** + * 用户-角色关联实体 + */ +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +@TableName("sys_user_role") +public class SysUserRole implements Serializable { + + @Serial + @TableField(exist = false) + private static final long serialVersionUID = 1L; + + /** 主键ID(雪花算法) */ + @TableId(value = "id", type = IdType.ASSIGN_ID) + @JsonSerialize(using = ToStringSerializer.class) + private Long id; + + /** 用户ID */ + @TableField("user_id") + private Long userId; + + /** 角色ID */ + @TableField("role_id") + private Long roleId; + + /** 创建时间 */ + @TableField(value = "create_time", fill = FieldFill.INSERT) + private Date createTime; +} diff --git a/src/main/java/com/wok/supportbot/entity/WebhookConfig.java b/src/main/java/com/wok/supportbot/entity/WebhookConfig.java new file mode 100644 index 0000000..fa2bab8 --- /dev/null +++ b/src/main/java/com/wok/supportbot/entity/WebhookConfig.java @@ -0,0 +1,73 @@ +package com.wok.supportbot.entity; + +import com.baomidou.mybatisplus.annotation.*; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.wok.supportbot.handler.PostgresJsonTypeHandler; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; +import java.util.Map; + +/** + * Webhook 配置实体 + */ +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +@TableName(value = "webhook_config", autoResultMap = true) +public class WebhookConfig implements Serializable { + + @Serial + @TableField(exist = false) + private static final long serialVersionUID = 1L; + + /** 主键ID(雪花算法) */ + @TableId(value = "id", type = IdType.ASSIGN_ID) + @JsonSerialize(using = ToStringSerializer.class) + private Long id; + + /** 所属用户ID */ + @TableField("user_id") + @JsonSerialize(using = ToStringSerializer.class) + private Long userId; + + /** Webhook 名称 */ + @TableField("name") + private String name; + + /** 回调 URL */ + @TableField("url") + private String url; + + /** 订阅事件列表,JSONB 格式存储 */ + @TableField(value = "events", typeHandler = PostgresJsonTypeHandler.class) + private Map events; + + /** 是否启用 */ + @TableField("enabled") + private Boolean enabled; + + /** 签名密钥 */ + @TableField("secret") + private String secret; + + /** 创建时间 */ + @TableField(value = "create_time", fill = FieldFill.INSERT) + private Date createTime; + + /** 更新时间 */ + @TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE) + private Date updateTime; + + /** 逻辑删除标识 */ + @TableField("is_delete") + @TableLogic + private boolean isDelete; +} diff --git a/src/main/java/com/wok/supportbot/openapi/ApiKeyAuthFilter.java b/src/main/java/com/wok/supportbot/openapi/ApiKeyAuthFilter.java new file mode 100644 index 0000000..def2000 --- /dev/null +++ b/src/main/java/com/wok/supportbot/openapi/ApiKeyAuthFilter.java @@ -0,0 +1,113 @@ +package com.wok.supportbot.openapi; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.wok.supportbot.entity.ApiKey; +import com.wok.supportbot.service.ApiKeyService; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Date; +import java.util.Map; + +/** + * 开放 API 鉴权过滤器 + * 仅拦截 /open-api/** 路径,从 X-API-Key header 提取 key 并验证有效性。 + * 验证通过后将 ApiKey 信息存入 request attribute 供 Controller 使用。 + */ +@Slf4j +@Component +@Order(Ordered.HIGHEST_PRECEDENCE + 1) +public class ApiKeyAuthFilter extends OncePerRequestFilter { + + @Autowired + private ApiKeyService apiKeyService; + + @Autowired + private SlidingWindowRateLimiter rateLimiter; + + private static final ObjectMapper objectMapper = new ObjectMapper(); + + private static final String API_KEY_HEADER = "X-API-Key"; + private static final String REQUEST_ATTR_API_KEY = "apiKey"; + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String path = request.getRequestURI(); + return !path.startsWith("/open-api/"); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + // 提取 API Key + String keyValue = request.getHeader(API_KEY_HEADER); + if (keyValue == null || keyValue.isBlank()) { + writeError(response, 401, "缺少 X-API-Key 请求头"); + return; + } + + // 查询并验证 API Key + ApiKey apiKey = apiKeyService.getKeyByKeyValue(keyValue); + if (apiKey == null) { + writeError(response, 401, "无效的 API Key"); + return; + } + + // 检查是否启用 + if (!Boolean.TRUE.equals(apiKey.getEnabled())) { + writeError(response, 401, "该 API Key 已被吊销"); + return; + } + + // 检查是否过期 + if (apiKey.getExpireTime() != null && apiKey.getExpireTime().before(new Date())) { + writeError(response, 401, "该 API Key 已过期"); + return; + } + + // 检查是否超过总调用次数限制 + if (apiKey.getMaxCalls() != null && apiKey.getCurrentCalls() != null + && apiKey.getCurrentCalls() >= apiKey.getMaxCalls()) { + writeError(response, 429, "API Key 调用次数已达上限"); + return; + } + + // 频率限制(滑动窗口) + int rateLimit = apiKey.getRateLimit() != null ? apiKey.getRateLimit() : 60; + if (!rateLimiter.isAllowed(apiKey.getKeyValue(), rateLimit, 60)) { + writeError(response, 429, "请求过于频繁,当前限制:" + rateLimit + " 次/分钟"); + return; + } + + // 自增调用次数 + apiKeyService.incrementCallCount(apiKey.getId()); + + // 将验证通过的 API Key 信息存入 request attribute + request.setAttribute(REQUEST_ATTR_API_KEY, apiKey); + + filterChain.doFilter(request, response); + } + + /** + * 输出 JSON 格式的错误响应 + */ + private void writeError(HttpServletResponse response, int status, String message) throws IOException { + response.setStatus(status); + response.setContentType("application/json;charset=UTF-8"); + Map body = Map.of( + "success", false, + "message", message, + "code", status + ); + response.getWriter().write(objectMapper.writeValueAsString(body)); + } +} diff --git a/src/main/java/com/wok/supportbot/openapi/SlidingWindowRateLimiter.java b/src/main/java/com/wok/supportbot/openapi/SlidingWindowRateLimiter.java new file mode 100644 index 0000000..d381c2d --- /dev/null +++ b/src/main/java/com/wok/supportbot/openapi/SlidingWindowRateLimiter.java @@ -0,0 +1,106 @@ +package com.wok.supportbot.openapi; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.Iterator; +import java.util.LinkedList; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 滑动窗口限流器 + * 使用 ConcurrentHashMap + LinkedList 记录每个 key 的请求时间戳, + * 在指定窗口内判断请求数是否超过上限。 + */ +@Slf4j +@Component +public class SlidingWindowRateLimiter { + + /** key → 请求时间戳列表 */ + private final ConcurrentHashMap> requestMap = new ConcurrentHashMap<>(); + + /** 上次清理过期 key 的时间 */ + private volatile long lastCleanupTime = System.currentTimeMillis(); + + /** 清理间隔:5 分钟 */ + private static final long CLEANUP_INTERVAL_MS = 5 * 60 * 1000; + + /** + * 检查指定 key 是否允许本次请求 + * + * @param key 限流键(通常是 API Key 的 keyValue) + * @param maxRequests 窗口内最大请求数 + * @param windowSeconds 窗口大小(秒) + * @return true=允许,false=已超限 + */ + public boolean isAllowed(String key, int maxRequests, int windowSeconds) { + long now = System.currentTimeMillis(); + long windowStart = now - (long) windowSeconds * 1000; + + // 获取或创建时间戳列表 + LinkedList timestamps = requestMap.computeIfAbsent(key, k -> new LinkedList<>()); + + synchronized (timestamps) { + // 移除窗口外的过期时间戳 + Iterator it = timestamps.iterator(); + while (it.hasNext()) { + if (it.next() < windowStart) { + it.remove(); + } else { + break; // 时间戳有序,遇到第一个在窗口内的即可停止 + } + } + + // 判断是否超过限制 + if (timestamps.size() >= maxRequests) { + log.debug("限流拦截: key={}, 当前窗口请求数={}, 上限={}", key, timestamps.size(), maxRequests); + return false; + } + + // 记录本次请求 + timestamps.addLast(now); + } + + // 定期清理过期 key + maybeCleanup(); + + return true; + } + + /** + * 每 5 分钟清理一次过期的 key,防止内存泄漏 + */ + private void maybeCleanup() { + long now = System.currentTimeMillis(); + if (now - lastCleanupTime < CLEANUP_INTERVAL_MS) { + return; + } + + synchronized (this) { + if (now - lastCleanupTime < CLEANUP_INTERVAL_MS) { + return; + } + lastCleanupTime = now; + + long cutoff = now - 60 * 1000; // 超过 60 秒无请求的 key 视为过期 + int removed = 0; + + Iterator>> it = requestMap.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry> entry = it.next(); + LinkedList timestamps = entry.getValue(); + synchronized (timestamps) { + if (timestamps.isEmpty() || timestamps.getLast() < cutoff) { + it.remove(); + removed++; + } + } + } + + if (removed > 0) { + log.debug("限流器清理过期 key: {} 个,当前剩余 {} 个", removed, requestMap.size()); + } + } + } +} diff --git a/src/main/java/com/wok/supportbot/security/JwtAuthFilter.java b/src/main/java/com/wok/supportbot/security/JwtAuthFilter.java new file mode 100644 index 0000000..cde4582 --- /dev/null +++ b/src/main/java/com/wok/supportbot/security/JwtAuthFilter.java @@ -0,0 +1,65 @@ +package com.wok.supportbot.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.List; + +/** + * JWT 认证过滤器 + * 从请求头提取 Bearer Token,验证后设置 SecurityContext + */ +@Slf4j +@Component +public class JwtAuthFilter extends OncePerRequestFilter { + + private final JwtTokenProvider jwtTokenProvider; + + public JwtAuthFilter(JwtTokenProvider jwtTokenProvider) { + this.jwtTokenProvider = jwtTokenProvider; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + String token = extractToken(request); + + if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) { + String username = jwtTokenProvider.getUsernameFromToken(token); + List roles = jwtTokenProvider.getRolesFromToken(token); + + // 构建 Spring Security 权限列表(ROLE_ 前缀) + List authorities = roles.stream() + .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) + .toList(); + + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(username, null, authorities); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + filterChain.doFilter(request, response); + } + + /** + * 从 Authorization 头提取 Bearer Token + */ + private String extractToken(HttpServletRequest request) { + String bearer = request.getHeader("Authorization"); + if (StringUtils.hasText(bearer) && bearer.startsWith("Bearer ")) { + return bearer.substring(7); + } + return null; + } +} diff --git a/src/main/java/com/wok/supportbot/security/JwtTokenProvider.java b/src/main/java/com/wok/supportbot/security/JwtTokenProvider.java new file mode 100644 index 0000000..dc972d4 --- /dev/null +++ b/src/main/java/com/wok/supportbot/security/JwtTokenProvider.java @@ -0,0 +1,108 @@ +package com.wok.supportbot.security; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.List; + +/** + * JWT 令牌提供者 + * 负责 JWT 的生成、验证和解析 + */ +@Component +public class JwtTokenProvider { + + private final SecretKey key; + private final long expiration; + private final long refreshExpiration; + + public JwtTokenProvider( + @Value("${jwt.secret}") String secret, + @Value("${jwt.expiration:86400000}") long expiration, + @Value("${jwt.refresh-expiration:604800000}") long refreshExpiration) { + // 直接使用 UTF-8 字节作为 HMAC 密钥(密钥长度需 >= 32 字节) + byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8); + this.key = Keys.hmacShaKeyFor(keyBytes); + this.expiration = expiration; + this.refreshExpiration = refreshExpiration; + } + + /** + * 生成访问令牌 + * @param username 用户名 + * @param roles 角色列表 + * @return JWT 令牌 + */ + public String generateToken(String username, List roles) { + Date now = new Date(); + return Jwts.builder() + .subject(username) + .claim("roles", roles) + .issuedAt(now) + .expiration(new Date(now.getTime() + expiration)) + .signWith(key) + .compact(); + } + + /** + * 生成刷新令牌(有效期更长,不含角色信息) + */ + public String generateRefreshToken(String username) { + Date now = new Date(); + return Jwts.builder() + .subject(username) + .issuedAt(now) + .expiration(new Date(now.getTime() + refreshExpiration)) + .signWith(key) + .compact(); + } + + /** + * 验证令牌有效性 + */ + public boolean validateToken(String token) { + try { + parseClaims(token); + return true; + } catch (Exception e) { + return false; + } + } + + /** + * 从令牌中提取用户名 + */ + public String getUsernameFromToken(String token) { + return parseClaims(token).getSubject(); + } + + /** + * 从令牌中提取角色列表 + */ + @SuppressWarnings("unchecked") + public List getRolesFromToken(String token) { + Claims claims = parseClaims(token); + Object roles = claims.get("roles"); + if (roles instanceof List list) { + return list.stream().map(Object::toString).toList(); + } + return List.of(); + } + + /** + * 解析并验证 Claims + */ + private Claims parseClaims(String token) { + return Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token) + .getPayload(); + } +} diff --git a/src/main/java/com/wok/supportbot/security/SecurityConfig.java b/src/main/java/com/wok/supportbot/security/SecurityConfig.java new file mode 100644 index 0000000..f7c4e1c --- /dev/null +++ b/src/main/java/com/wok/supportbot/security/SecurityConfig.java @@ -0,0 +1,115 @@ +package com.wok.supportbot.security; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; + +/** + * Spring Security 安全配置 + * 无状态 JWT 认证,白名单策略 + */ +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +public class SecurityConfig { + + private final JwtAuthFilter jwtAuthFilter; + + public SecurityConfig(JwtAuthFilter jwtAuthFilter) { + this.jwtAuthFilter = jwtAuthFilter; + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + // ========== 公开接口(无需认证) ========== + // SDK 对话接口(方案A:不认证,SDK 直接调用) + .requestMatchers("/ai/**").permitAll() + // 开放 API(由 ApiKeyAuthFilter 独立鉴权) + .requestMatchers("/open-api/**").permitAll() + // 登录/刷新 Token + .requestMatchers("/auth/login", "/auth/refresh").permitAll() + // SDK 需要的接口(feedback、分类、会话查询等) + .requestMatchers("/feedback").permitAll() + .requestMatchers("/category/tree", "/category/list").permitAll() + .requestMatchers("/conversation/list").permitAll() + .requestMatchers(HttpMethod.GET, "/conversation/*/messages").permitAll() + .requestMatchers(HttpMethod.DELETE, "/conversation/*").permitAll() + .requestMatchers(HttpMethod.GET, "/conversation/*/export").permitAll() + .requestMatchers(HttpMethod.POST, "/conversation/*/truncate").permitAll() + .requestMatchers("/conversation/stats").permitAll() + // Knife4j / Swagger + .requestMatchers("/doc.html", "/swagger-ui/**", "/v3/api-docs/**", "/webjars/**").permitAll() + // 静态资源 + .requestMatchers("/sdk/**", "/css/**", "/js/**", "/components/**").permitAll() + .requestMatchers("/index.html", "/", "/favicon.ico").permitAll() + + // ========== 管理接口(需要认证) ========== + .anyRequest().authenticated() + ) + .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) + .exceptionHandling(ex -> ex + .authenticationEntryPoint((req, res, authEx) -> { + res.setContentType("application/json;charset=UTF-8"); + res.setStatus(401); + res.getWriter().write("{\"success\":false,\"code\":401,\"message\":\"未登录或 Token 已过期\"}"); + }) + .accessDeniedHandler((req, res, accessEx) -> { + res.setContentType("application/json;charset=UTF-8"); + res.setStatus(403); + res.getWriter().write("{\"success\":false,\"code\":403,\"message\":\"权限不足\"}"); + }) + ); + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + /** + * 暴露 AuthenticationManager Bean(AuthController 登录验证需要) + */ + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { + return config.getAuthenticationManager(); + } + + /** + * CORS 配置(与原 CorsConfig 保持一致) + * Spring Security 会接管 CORS 处理,原 CorsConfig 中的 WebMvcConfigurer 配置需保留以支持非 Security 管理的路径 + */ + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + config.setAllowedOriginPatterns(List.of("*")); + config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); + config.setAllowedHeaders(List.of("*")); + config.setExposedHeaders(List.of("*")); + config.setAllowCredentials(true); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } +} diff --git a/src/main/java/com/wok/supportbot/service/ApiKeyService.java b/src/main/java/com/wok/supportbot/service/ApiKeyService.java new file mode 100644 index 0000000..854cb72 --- /dev/null +++ b/src/main/java/com/wok/supportbot/service/ApiKeyService.java @@ -0,0 +1,170 @@ +package com.wok.supportbot.service; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.wok.supportbot.dao.ApiKeyMapper; +import com.wok.supportbot.entity.ApiKey; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.*; + +/** + * API Key 管理服务 + * 提供 API Key 的生成、查询、吊销、启用、删除、验证等功能。 + */ +@Slf4j +@Service +public class ApiKeyService { + + @Autowired + private ApiKeyMapper apiKeyMapper; + + /** + * 生成新的 API Key + * + * @param userId 所属用户ID + * @param name Key 名称 + * @param description 描述 + * @param rateLimit 每分钟频率限制(null 则默认 60) + * @param maxCalls 最大调用次数(null 表示不限制) + * @param expireTime 过期时间(null 表示永不过期) + * @return 新创建的 ApiKey 实体 + */ + public ApiKey generateKey(Long userId, String name, String description, + Integer rateLimit, Long maxCalls, Date expireTime) { + String keyValue = "sk_" + UUID.randomUUID().toString().replace("-", ""); + + ApiKey apiKey = ApiKey.builder() + .keyValue(keyValue) + .name(name) + .description(description) + .userId(userId) + .rateLimit(rateLimit != null ? rateLimit : 60) + .maxCalls(maxCalls) + .currentCalls(0L) + .expireTime(expireTime) + .enabled(true) + .build(); + + apiKeyMapper.insert(apiKey); + log.info("生成 API Key: name={}, id={}", name, apiKey.getId()); + return apiKey; + } + + /** + * 分页查询 API Key 列表 + * + * @param page 页码(从1开始) + * @param size 每页条数 + * @return 包含 records/total/page/size/pages 的结果 + */ + public Map listKeys(int page, int size) { + QueryWrapper countWrapper = new QueryWrapper<>(); + Long total = apiKeyMapper.selectCount(countWrapper); + + QueryWrapper listWrapper = new QueryWrapper<>(); + listWrapper.orderByDesc("create_time"); + listWrapper.last("LIMIT " + size + " OFFSET " + (long) (page - 1) * size); + List records = apiKeyMapper.selectList(listWrapper); + + Map result = new HashMap<>(); + result.put("records", records); + result.put("total", total); + result.put("page", page); + result.put("size", size); + result.put("pages", (total + size - 1) / size); + return result; + } + + /** + * 吊销 API Key(设置 enabled=false) + * + * @param id Key ID + */ + public void revokeKey(Long id) { + ApiKey existing = apiKeyMapper.selectById(id); + if (existing == null) { + throw new IllegalArgumentException("API Key 不存在,ID:" + id); + } + existing.setEnabled(false); + apiKeyMapper.updateById(existing); + log.info("吊销 API Key: id={}, name={}", id, existing.getName()); + } + + /** + * 启用 API Key + * + * @param id Key ID + */ + public void enableKey(Long id) { + ApiKey existing = apiKeyMapper.selectById(id); + if (existing == null) { + throw new IllegalArgumentException("API Key 不存在,ID:" + id); + } + existing.setEnabled(true); + apiKeyMapper.updateById(existing); + log.info("启用 API Key: id={}, name={}", id, existing.getName()); + } + + /** + * 逻辑删除 API Key + * + * @param id Key ID + */ + public void deleteKey(Long id) { + ApiKey existing = apiKeyMapper.selectById(id); + if (existing == null) { + throw new IllegalArgumentException("API Key 不存在,ID:" + id); + } + apiKeyMapper.deleteById(id); + log.info("删除 API Key: id={}, name={}", id, existing.getName()); + } + + /** + * 验证 API Key 有效性(不自增调用次数,自增由 Filter 处理) + * + * @param keyValue Key 值 + * @return 有效的 ApiKey,无效时返回 null + */ + public ApiKey validateKey(String keyValue) { + if (keyValue == null || keyValue.isBlank()) { + return null; + } + return getKeyByKeyValue(keyValue); + } + + /** + * 根据 keyValue 查询 API Key + * + * @param keyValue Key 值 + * @return ApiKey 实体,不存在返回 null + */ + public ApiKey getKeyByKeyValue(String keyValue) { + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.eq("key_value", keyValue); + return apiKeyMapper.selectOne(wrapper); + } + + /** + * 自增调用次数 + * + * @param id Key ID + */ + public void incrementCallCount(Long id) { + // 使用 SQL 原子操作自增,避免并发问题 + try { + String sql = "UPDATE api_key SET current_calls = current_calls + 1 WHERE id = ?"; + // 通过 MyBatis Plus 无法直接做原子自增,使用 updateById 兜底 + ApiKey existing = apiKeyMapper.selectById(id); + if (existing != null) { + existing.setCurrentCalls( + (existing.getCurrentCalls() != null ? existing.getCurrentCalls() : 0L) + 1 + ); + apiKeyMapper.updateById(existing); + } + } catch (Exception e) { + log.warn("自增 API Key 调用次数失败: id={}", id, e); + } + } +} diff --git a/src/main/java/com/wok/supportbot/service/ConversationService.java b/src/main/java/com/wok/supportbot/service/ConversationService.java index 2e6e264..12f0619 100644 --- a/src/main/java/com/wok/supportbot/service/ConversationService.java +++ b/src/main/java/com/wok/supportbot/service/ConversationService.java @@ -39,10 +39,19 @@ public class ConversationService { * @return 分页会话列表 */ public Map listConversations(int page, int size, String keyword) { - return listConversations(page, size, keyword, null, null); + return listConversations(page, size, keyword, null, null, null); } public Map listConversations(int page, int size, String keyword, String accountId, Long roleId) { + return listConversations(page, size, keyword, accountId, roleId, null); + } + + /** + * 获取会话列表(分页,支持用户数据隔离) + * + * @param userId 系统用户ID,null 表示不过滤(SDK 调用场景) + */ + public Map listConversations(int page, int size, String keyword, String accountId, Long roleId, Long userId) { // 构建基础 SQL 条件 StringBuilder whereClause = new StringBuilder("WHERE cm1.is_delete = false "); List params = new ArrayList<>(); @@ -78,6 +87,11 @@ public class ConversationService { whereClause.append(" AND cs.role_id = ? "); params.add(roleId); } + // 用户数据隔离:只返回属于当前用户的会话 + if (userId != null) { + whereClause.append(" AND cm1.user_id = ? "); + params.add(userId); + } // 查询总会话数 String countSql = """ diff --git a/src/main/java/com/wok/supportbot/service/DashboardService.java b/src/main/java/com/wok/supportbot/service/DashboardService.java new file mode 100644 index 0000000..bc5a402 --- /dev/null +++ b/src/main/java/com/wok/supportbot/service/DashboardService.java @@ -0,0 +1,352 @@ +package com.wok.supportbot.service; + +import com.wok.supportbot.dao.DashboardSnapshotMapper; +import com.wok.supportbot.dao.RagHitLogMapper; +import com.wok.supportbot.entity.DashboardSnapshot; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.text.SimpleDateFormat; +import java.util.*; + +/** + * 运营看板数据服务 + * 提供实时指标查询、趋势统计、知识库分析、快照生成等功能 + */ +@Service +@Slf4j +public class DashboardService { + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private DashboardSnapshotMapper dashboardSnapshotMapper; + + @Autowired + private RagHitLogMapper ragHitLogMapper; + + /** + * 获取今日实时概览指标 + * + * @return 包含对话数、满意率、RAG 命中率、平均响应时间等指标的 Map + */ + public Map getOverview() { + Map result = new LinkedHashMap<>(); + + String today = new SimpleDateFormat("yyyy-MM-dd").format(new Date()); + + // 今日对话数(按 conversation_id 去重) + Long conversationCount = queryLong( + "SELECT COUNT(DISTINCT conversation_id) FROM chat_message WHERE is_delete = false AND DATE(create_time) = ?", + today); + result.put("conversationCount", conversationCount); + + // 今日消息数 + Long messageCount = queryLong( + "SELECT COUNT(*) FROM chat_message WHERE is_delete = false AND DATE(create_time) = ?", + today); + result.put("messageCount", messageCount); + + // 满意率(基于 message_feedback 表) + Long thumbsUp = queryLong( + "SELECT COUNT(*) FROM message_feedback WHERE is_delete = false AND feedback_type = 'THUMBS_UP' AND DATE(create_time) = ?", + today); + Long thumbsDown = queryLong( + "SELECT COUNT(*) FROM message_feedback WHERE is_delete = false AND feedback_type = 'THUMBS_DOWN' AND DATE(create_time) = ?", + today); + long totalFeedback = thumbsUp + thumbsDown; + double satisfactionRate = totalFeedback > 0 ? Math.round((double) thumbsUp / totalFeedback * 100.0) / 100.0 : 0.0; + result.put("satisfactionRate", satisfactionRate); + result.put("thumbsUpCount", thumbsUp); + result.put("thumbsDownCount", thumbsDown); + + // RAG 命中率(基于 rag_hit_log 表) + Long ragHitCount = queryLong( + "SELECT COUNT(*) FROM rag_hit_log WHERE document_id IS NOT NULL AND DATE(create_time) = ?", + today); + Long ragMissCount = queryLong( + "SELECT COUNT(*) FROM rag_hit_log WHERE document_id IS NULL AND DATE(create_time) = ?", + today); + long totalRag = ragHitCount + ragMissCount; + double ragHitRate = totalRag > 0 ? Math.round((double) ragHitCount / totalRag * 100.0) / 100.0 : 0.0; + result.put("ragHitRate", ragHitRate); + result.put("ragHitCount", ragHitCount); + result.put("ragMissCount", ragMissCount); + + // 平均响应时间(从 chat_message.metadata 中提取 responseTimeMs) + Double avgResponseTime = queryDouble( + "SELECT AVG(CAST(metadata->>'responseTimeMs' AS DOUBLE PRECISION)) FROM chat_message " + + "WHERE is_delete = false AND message_type = 'ASSISTANT' " + + "AND metadata ? 'responseTimeMs' AND DATE(create_time) = ?", + today); + result.put("avgResponseTime", avgResponseTime != null ? Math.round(avgResponseTime * 100.0) / 100.0 : 0.0); + + return result; + } + + /** + * 获取最近 N 天的趋势数据 + * + * @param days 天数(7 或 30) + * @return 趋势快照列表 + */ + public List getTrend(int days) { + String sql = "SELECT * FROM dashboard_snapshot WHERE snapshot_date >= CURRENT_DATE - ? ORDER BY snapshot_date ASC"; + return jdbcTemplate.queryForList(sql, days).stream() + .map(this::mapToSnapshot) + .toList(); + } + + /** + * 获取知识库分析数据 + * + * @return 包含命中文档 TOP-10 和未命中问题列表的 Map + */ + public Map getKnowledgeAnalysis() { + Map result = new LinkedHashMap<>(); + + // RAG 命中文档 TOP-10(按命中次数降序) + String topHitSql = """ + SELECT document_id, document_title, COUNT(*) AS hit_count, AVG(score) AS avg_score + FROM rag_hit_log + WHERE document_id IS NOT NULL + GROUP BY document_id, document_title + ORDER BY hit_count DESC + LIMIT 10 + """; + List> topHits = jdbcTemplate.queryForList(topHitSql); + result.put("topHitDocuments", topHits); + + // 最近未命中问题列表(去重,限50条) + String missSql = """ + SELECT DISTINCT user_query, MAX(create_time) AS last_time + FROM rag_hit_log + WHERE document_id IS NULL + GROUP BY user_query + ORDER BY last_time DESC + LIMIT 50 + """; + List> missQuestions = jdbcTemplate.queryForList(missSql); + result.put("missQuestions", missQuestions); + + return result; + } + + /** + * 获取自定义时间范围的快照数据 + * + * @param startDate 开始日期(yyyy-MM-dd) + * @param endDate 结束日期(yyyy-MM-dd) + * @return 区间内的快照列表 + */ + public List getCustomRange(String startDate, String endDate) { + String sql = "SELECT * FROM dashboard_snapshot WHERE snapshot_date >= ?::date AND snapshot_date <= ?::date ORDER BY snapshot_date ASC"; + return jdbcTemplate.queryForList(sql, startDate, endDate).stream() + .map(this::mapToSnapshot) + .toList(); + } + + /** + * 每日凌晨 2:00 汇总前一天数据,写入 dashboard_snapshot 表(UPSERT) + */ + @Scheduled(cron = "0 0 2 * * ?") + public void generateDailySnapshot() { + log.info("开始生成每日运营看板快照..."); + try { + // 计算昨天的日期 + Calendar cal = Calendar.getInstance(); + cal.add(Calendar.DAY_OF_MONTH, -1); + Date yesterday = cal.getTime(); + String yesterdayStr = new SimpleDateFormat("yyyy-MM-dd").format(yesterday); + + // 对话数 + int conversationCount = queryInt( + "SELECT COUNT(DISTINCT conversation_id) FROM chat_message WHERE is_delete = false AND DATE(create_time) = ?", + yesterdayStr); + + // 消息数 + int messageCount = queryInt( + "SELECT COUNT(*) FROM chat_message WHERE is_delete = false AND DATE(create_time) = ?", + yesterdayStr); + + // 反馈统计 + int thumbsUp = queryInt( + "SELECT COUNT(*) FROM message_feedback WHERE is_delete = false AND feedback_type = 'THUMBS_UP' AND DATE(create_time) = ?", + yesterdayStr); + int thumbsDown = queryInt( + "SELECT COUNT(*) FROM message_feedback WHERE is_delete = false AND feedback_type = 'THUMBS_DOWN' AND DATE(create_time) = ?", + yesterdayStr); + int totalFeedback = thumbsUp + thumbsDown; + double satisfactionRate = totalFeedback > 0 ? Math.round((double) thumbsUp / totalFeedback * 100.0) / 100.0 : 0.0; + + // RAG 命中统计 + int ragHitCount = queryInt( + "SELECT COUNT(*) FROM rag_hit_log WHERE document_id IS NOT NULL AND DATE(create_time) = ?", + yesterdayStr); + int ragMissCount = queryInt( + "SELECT COUNT(*) FROM rag_hit_log WHERE document_id IS NULL AND DATE(create_time) = ?", + yesterdayStr); + + // 平均响应时间 + Double avgResponseTime = queryDouble( + "SELECT AVG(CAST(metadata->>'responseTimeMs' AS DOUBLE PRECISION)) FROM chat_message " + + "WHERE is_delete = false AND message_type = 'ASSISTANT' " + + "AND metadata ? 'responseTimeMs' AND DATE(create_time) = ?", + yesterdayStr); + + // 热门问题 TOP-10 + String topQuestionsSql = """ + SELECT user_query, COUNT(*) AS cnt + FROM rag_hit_log + WHERE DATE(create_time) = ? + GROUP BY user_query ORDER BY cnt DESC LIMIT 10 + """; + List> topQuestions = jdbcTemplate.queryForList(topQuestionsSql, yesterdayStr); + + // 命中文档 TOP-10 + String topHitDocsSql = """ + SELECT document_id, document_title, COUNT(*) AS cnt + FROM rag_hit_log + WHERE document_id IS NOT NULL AND DATE(create_time) = ? + GROUP BY document_id, document_title ORDER BY cnt DESC LIMIT 10 + """; + List> topHitDocuments = jdbcTemplate.queryForList(topHitDocsSql, yesterdayStr); + + // 未命中问题列表 + String missQuestionsSql = """ + SELECT DISTINCT user_query + FROM rag_hit_log + WHERE document_id IS NULL AND DATE(create_time) = ? + LIMIT 50 + """; + List> missQuestions = jdbcTemplate.queryForList(missQuestionsSql, yesterdayStr); + + // UPSERT:按 snapshot_date 唯一键插入或更新 + String upsertSql = """ + INSERT INTO dashboard_snapshot ( + snapshot_date, conversation_count, message_count, satisfaction_rate, + thumbs_up_count, thumbs_down_count, rag_hit_count, rag_miss_count, + avg_response_time, top_questions, top_hit_documents, miss_questions, + create_time, update_time + ) VALUES (?::date, ?, ?, ?, ?, ?, ?, ?, ?, ?::jsonb, ?::jsonb, ?::jsonb, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + ON CONFLICT (snapshot_date) DO UPDATE SET + conversation_count = EXCLUDED.conversation_count, + message_count = EXCLUDED.message_count, + satisfaction_rate = EXCLUDED.satisfaction_rate, + thumbs_up_count = EXCLUDED.thumbs_up_count, + thumbs_down_count = EXCLUDED.thumbs_down_count, + rag_hit_count = EXCLUDED.rag_hit_count, + rag_miss_count = EXCLUDED.rag_miss_count, + avg_response_time = EXCLUDED.avg_response_time, + top_questions = EXCLUDED.top_questions, + top_hit_documents = EXCLUDED.top_hit_documents, + miss_questions = EXCLUDED.miss_questions, + update_time = CURRENT_TIMESTAMP + """; + jdbcTemplate.update(upsertSql, + yesterdayStr, conversationCount, messageCount, satisfactionRate, + thumbsUp, thumbsDown, ragHitCount, ragMissCount, + avgResponseTime != null ? avgResponseTime : 0.0, + toJson(topQuestions), toJson(topHitDocuments), toJson(missQuestions)); + + log.info("每日运营看板快照生成完成: date={}, conversations={}, messages={}", yesterdayStr, conversationCount, messageCount); + } catch (Exception e) { + log.error("生成每日运营看板快照失败", e); + } + } + + // ==================== 辅助方法 ==================== + + /** + * 查询单个 Long 值,结果为 null 时返回 0 + */ + private Long queryLong(String sql, Object... args) { + try { + Long val = jdbcTemplate.queryForObject(sql, Long.class, args); + return val != null ? val : 0L; + } catch (Exception e) { + return 0L; + } + } + + /** + * 查询单个 Integer 值,结果为 null 时返回 0 + */ + private int queryInt(String sql, Object... args) { + try { + Integer val = jdbcTemplate.queryForObject(sql, Integer.class, args); + return val != null ? val : 0; + } catch (Exception e) { + return 0; + } + } + + /** + * 查询单个 Double 值 + */ + private Double queryDouble(String sql, Object... args) { + try { + return jdbcTemplate.queryForObject(sql, Double.class, args); + } catch (Exception e) { + return null; + } + } + + /** + * 将 List 序列化为 JSON 字符串 + */ + private String toJson(List> data) { + if (data == null || data.isEmpty()) return "[]"; + try { + com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(); + return mapper.writeValueAsString(data); + } catch (Exception e) { + return "[]"; + } + } + + /** + * 将查询结果 Map 转换为 DashboardSnapshot 对象 + */ + private DashboardSnapshot mapToSnapshot(Map row) { + return DashboardSnapshot.builder() + .id(row.get("id") != null ? ((Number) row.get("id")).longValue() : null) + .snapshotDate((Date) row.get("snapshot_date")) + .conversationCount(row.get("conversation_count") != null ? ((Number) row.get("conversation_count")).intValue() : 0) + .messageCount(row.get("message_count") != null ? ((Number) row.get("message_count")).intValue() : 0) + .satisfactionRate(row.get("satisfaction_rate") != null ? ((Number) row.get("satisfaction_rate")).doubleValue() : 0.0) + .thumbsUpCount(row.get("thumbs_up_count") != null ? ((Number) row.get("thumbs_up_count")).intValue() : 0) + .thumbsDownCount(row.get("thumbs_down_count") != null ? ((Number) row.get("thumbs_down_count")).intValue() : 0) + .ragHitCount(row.get("rag_hit_count") != null ? ((Number) row.get("rag_hit_count")).intValue() : 0) + .ragMissCount(row.get("rag_miss_count") != null ? ((Number) row.get("rag_miss_count")).intValue() : 0) + .avgResponseTime(row.get("avg_response_time") != null ? ((Number) row.get("avg_response_time")).doubleValue() : 0.0) + .topQuestions(parseJsonb(row.get("top_questions"))) + .topHitDocuments(parseJsonb(row.get("top_hit_documents"))) + .missQuestions(parseJsonb(row.get("miss_questions"))) + .createTime((Date) row.get("create_time")) + .updateTime((Date) row.get("update_time")) + .build(); + } + + /** + * 解析 JSONB 字段为 Map(安全处理 null 和异常) + */ + @SuppressWarnings("unchecked") + private Map parseJsonb(Object value) { + if (value == null) return Collections.emptyMap(); + if (value instanceof Map) return (Map) value; + if (value instanceof String) { + try { + com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(); + return mapper.readValue((String) value, Map.class); + } catch (Exception e) { + return Collections.emptyMap(); + } + } + return Collections.emptyMap(); + } +} diff --git a/src/main/java/com/wok/supportbot/service/FaqMatchEngine.java b/src/main/java/com/wok/supportbot/service/FaqMatchEngine.java index ab0f31e..cc180fe 100644 --- a/src/main/java/com/wok/supportbot/service/FaqMatchEngine.java +++ b/src/main/java/com/wok/supportbot/service/FaqMatchEngine.java @@ -200,7 +200,7 @@ public class FaqMatchEngine { // 余弦距离查询:<=> 运算符返回余弦距离,相似度 = 1 - distance List> results = jdbcTemplate.queryForList( "SELECT fe.faq_id, fe.embedding <=> ?::vector AS distance, " + - "kf.id, kf.question, kf.answer, kf.similar_questions, kf.category, " + + "kf.id, kf.question, kf.answer, kf.similar_questions, kf.category, kf.category_id, " + "kf.status, kf.priority, kf.hit_count, kf.source, kf.create_time, kf.update_time, kf.is_delete " + "FROM faq_embedding fe " + "JOIN knowledge_faq kf ON fe.faq_id = kf.id " + @@ -331,6 +331,7 @@ public class FaqMatchEngine { faq.setAnswer(rs.getString("answer")); faq.setSimilarQuestions(rs.getString("similar_questions")); faq.setCategory(rs.getString("category")); + faq.setCategoryId(rs.getObject("category_id") != null ? rs.getLong("category_id") : null); faq.setStatus(rs.getString("status")); faq.setPriority(rs.getInt("priority")); faq.setHitCount(rs.getLong("hit_count")); @@ -351,6 +352,7 @@ public class FaqMatchEngine { faq.setAnswer((String) result.get("answer")); faq.setSimilarQuestions((String) result.get("similar_questions")); faq.setCategory((String) result.get("category")); + faq.setCategoryId(result.get("category_id") != null ? ((Number) result.get("category_id")).longValue() : null); faq.setStatus((String) result.get("status")); faq.setPriority(((Number) result.get("priority")).intValue()); faq.setHitCount(((Number) result.get("hit_count")).longValue()); diff --git a/src/main/java/com/wok/supportbot/service/FaqService.java b/src/main/java/com/wok/supportbot/service/FaqService.java index 9d10964..070f774 100644 --- a/src/main/java/com/wok/supportbot/service/FaqService.java +++ b/src/main/java/com/wok/supportbot/service/FaqService.java @@ -44,14 +44,14 @@ public class FaqService { * @param status 状态过滤(可选) * @return 分页结果 Map {total, records} */ - public Map list(int page, int size, String keyword, String category, String status) { + public Map list(int page, int size, String keyword, Long categoryId, String status) { QueryWrapper wrapper = new QueryWrapper<>(); if (keyword != null && !keyword.isBlank()) { wrapper.like("question", keyword.trim()); } - if (category != null && !category.isBlank()) { - wrapper.eq("category", category.trim()); + if (categoryId != null) { + wrapper.eq("category_id", categoryId); } if (status != null && !status.isBlank()) { wrapper.eq("status", status.trim()); @@ -129,6 +129,9 @@ public class FaqService { if (faq.getCategory() != null) { existing.setCategory(faq.getCategory()); } + if (faq.getCategoryId() != null) { + existing.setCategoryId(faq.getCategoryId()); + } if (faq.getStatus() != null) { existing.setStatus(faq.getStatus()); } @@ -274,7 +277,7 @@ public class FaqService { // Top10 热门 FAQ List> topFaqs = jdbcTemplate.queryForList( - "SELECT id, question, hit_count, category FROM knowledge_faq " + + "SELECT id, question, hit_count, category, category_id FROM knowledge_faq " + "WHERE is_delete = false AND status = 'ENABLED' " + "ORDER BY hit_count DESC LIMIT 10" ); diff --git a/src/main/java/com/wok/supportbot/service/RagHitLogService.java b/src/main/java/com/wok/supportbot/service/RagHitLogService.java new file mode 100644 index 0000000..54fc7d1 --- /dev/null +++ b/src/main/java/com/wok/supportbot/service/RagHitLogService.java @@ -0,0 +1,84 @@ +package com.wok.supportbot.service; + +import com.wok.supportbot.dao.RagHitLogMapper; +import com.wok.supportbot.entity.RagHitLog; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +/** + * RAG 命中日志服务 + * 异步记录每次知识库检索的命中/未命中情况 + */ +@Service +@Slf4j +public class RagHitLogService { + + @Autowired + private RagHitLogMapper ragHitLogMapper; + + /** + * 异步记录 RAG 命中事件 + * + * @param conversationId 会话ID + * @param userQuery 用户查询文本 + * @param documentId 命中的文档ID + * @param documentTitle 命中的文档标题 + * @param score 匹配得分 + * @param searchMode 检索模式 + */ + @Async + public void recordHit(String conversationId, String userQuery, Long documentId, + String documentTitle, String score, String searchMode) { + try { + RagHitLog hitLog = RagHitLog.builder() + .conversationId(conversationId) + .userQuery(userQuery) + .documentId(documentId) + .documentTitle(documentTitle) + .score(parseScore(score)) + .searchMode(searchMode) + .build(); + ragHitLogMapper.insert(hitLog); + log.debug("记录 RAG 命中: documentId={}, query={}", documentId, userQuery); + } catch (Exception e) { + log.warn("记录 RAG 命中日志失败: {}", e.getMessage()); + } + } + + /** + * 异步记录 RAG 未命中事件 + * + * @param conversationId 会话ID + * @param userQuery 用户查询文本 + * @param searchMode 检索模式 + */ + @Async + public void recordMiss(String conversationId, String userQuery, String searchMode) { + try { + RagHitLog hitLog = RagHitLog.builder() + .conversationId(conversationId) + .userQuery(userQuery) + .documentId(null) + .searchMode(searchMode) + .build(); + ragHitLogMapper.insert(hitLog); + log.debug("记录 RAG 未命中: query={}", userQuery); + } catch (Exception e) { + log.warn("记录 RAG 未命中日志失败: {}", e.getMessage()); + } + } + + /** + * 安全解析得分字符串为 Double + */ + private Double parseScore(String score) { + if (score == null || score.isBlank()) return null; + try { + return Double.parseDouble(score); + } catch (NumberFormatException e) { + return null; + } + } +} diff --git a/src/main/java/com/wok/supportbot/service/SysRoleService.java b/src/main/java/com/wok/supportbot/service/SysRoleService.java new file mode 100644 index 0000000..4d13268 --- /dev/null +++ b/src/main/java/com/wok/supportbot/service/SysRoleService.java @@ -0,0 +1,73 @@ +package com.wok.supportbot.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.wok.supportbot.dao.SysRoleMapper; +import com.wok.supportbot.entity.SysRole; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * 系统角色服务 + */ +@Slf4j +@Service +public class SysRoleService { + + private final SysRoleMapper roleMapper; + + public SysRoleService(SysRoleMapper roleMapper) { + this.roleMapper = roleMapper; + } + + /** + * 获取所有角色列表 + */ + public List listAll() { + return roleMapper.selectList( + new LambdaQueryWrapper() + .eq(SysRole::getIsDelete, false) + .orderByAsc(SysRole::getId) + ); + } + + /** + * 根据ID获取角色 + */ + public SysRole getById(Long id) { + return roleMapper.selectById(id); + } + + /** + * 创建角色 + */ + public SysRole createRole(String roleKey, String name, String description) { + Long exists = roleMapper.selectCount( + new LambdaQueryWrapper().eq(SysRole::getRoleKey, roleKey) + ); + if (exists > 0) { + throw new IllegalArgumentException("角色标识已存在: " + roleKey); + } + SysRole role = SysRole.builder() + .roleKey(roleKey) + .name(name) + .description(description) + .enabled(true) + .build(); + roleMapper.insert(role); + return role; + } + + /** + * 更新角色 + */ + public void updateRole(Long id, String name, String description, Boolean enabled) { + SysRole role = roleMapper.selectById(id); + if (role == null) throw new IllegalArgumentException("角色不存在"); + if (name != null) role.setName(name); + if (description != null) role.setDescription(description); + if (enabled != null) role.setEnabled(enabled); + roleMapper.updateById(role); + } +} diff --git a/src/main/java/com/wok/supportbot/service/SysUserService.java b/src/main/java/com/wok/supportbot/service/SysUserService.java new file mode 100644 index 0000000..c9b9ca7 --- /dev/null +++ b/src/main/java/com/wok/supportbot/service/SysUserService.java @@ -0,0 +1,270 @@ +package com.wok.supportbot.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.wok.supportbot.dao.SysRoleMapper; +import com.wok.supportbot.dao.SysUserMapper; +import com.wok.supportbot.dao.SysUserRoleMapper; +import com.wok.supportbot.entity.SysRole; +import com.wok.supportbot.entity.SysUser; +import com.wok.supportbot.entity.SysUserRole; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * 系统用户服务 + * 实现 UserDetailsService 用于 Spring Security 认证 + */ +@Slf4j +@Service +public class SysUserService implements UserDetailsService { + + private final SysUserMapper userMapper; + private final SysRoleMapper roleMapper; + private final SysUserRoleMapper userRoleMapper; + private final PasswordEncoder passwordEncoder; + private final JdbcTemplate jdbcTemplate; + + public SysUserService(SysUserMapper userMapper, SysRoleMapper roleMapper, + SysUserRoleMapper userRoleMapper, PasswordEncoder passwordEncoder, + JdbcTemplate jdbcTemplate) { + this.userMapper = userMapper; + this.roleMapper = roleMapper; + this.userRoleMapper = userRoleMapper; + this.passwordEncoder = passwordEncoder; + this.jdbcTemplate = jdbcTemplate; + } + + /** + * Spring Security 认证入口:根据用户名加载用户详情 + */ + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + SysUser user = userMapper.selectOne( + new LambdaQueryWrapper() + .eq(SysUser::getUsername, username) + .eq(SysUser::getIsDelete, false) + ); + if (user == null) { + throw new UsernameNotFoundException("用户不存在: " + username); + } + if (Boolean.FALSE.equals(user.getEnabled())) { + throw new UsernameNotFoundException("用户已被禁用: " + username); + } + + List roles = getUserRoles(user.getId()); + List authorities = roles.stream() + .map(r -> new SimpleGrantedAuthority("ROLE_" + r.getRoleKey())) + .collect(Collectors.toList()); + + return new User(user.getUsername(), user.getPassword(), authorities); + } + + /** + * 根据用户名查询用户信息(含角色) + */ + public SysUser getUserByUsername(String username) { + SysUser user = userMapper.selectOne( + new LambdaQueryWrapper() + .eq(SysUser::getUsername, username) + .eq(SysUser::getIsDelete, false) + ); + if (user != null) { + user.setRoles(getUserRoles(user.getId())); + } + return user; + } + + /** + * 获取用户的角色列表 + */ + public List getUserRoles(Long userId) { + List userRoles = userRoleMapper.selectList( + new LambdaQueryWrapper().eq(SysUserRole::getUserId, userId) + ); + if (userRoles.isEmpty()) return List.of(); + List roleIds = userRoles.stream().map(SysUserRole::getRoleId).toList(); + return roleMapper.selectBatchIds(roleIds); + } + + /** + * 分页查询用户列表 + */ + public Map listUsers(int page, int size, String keyword, Boolean enabled) { + StringBuilder where = new StringBuilder("WHERE u.is_delete = FALSE"); + List params = new ArrayList<>(); + + if (keyword != null && !keyword.isBlank()) { + where.append(" AND (u.username ILIKE ? OR u.nickname ILIKE ? OR u.email ILIKE ?)"); + String like = "%" + keyword + "%"; + params.addAll(List.of(like, like, like)); + } + if (enabled != null) { + where.append(" AND u.enabled = ?"); + params.add(enabled); + } + + // 总数 + String countSql = "SELECT COUNT(*) FROM sys_user u " + where; + Long total = jdbcTemplate.queryForObject(countSql, Long.class, params.toArray()); + + // 分页数据(联查角色名称) + int offset = (page - 1) * size; + String dataSql = """ + SELECT u.id, u.username, u.nickname, u.email, u.phone, u.enabled, + u.last_login_time, u.create_time, + STRING_AGG(r.name, ',') AS role_names, + STRING_AGG(r.role_key, ',') AS role_keys + FROM sys_user u + LEFT JOIN sys_user_role ur ON ur.user_id = u.id + LEFT JOIN sys_role r ON r.id = ur.role_id AND r.is_delete = FALSE + %s + GROUP BY u.id + ORDER BY u.create_time DESC + LIMIT ? OFFSET ? + """.formatted(where); + List dataParams = new ArrayList<>(params); + dataParams.add(size); + dataParams.add(offset); + + List> users = jdbcTemplate.queryForList(dataSql, dataParams.toArray()); + + Map result = new LinkedHashMap<>(); + result.put("total", total != null ? total : 0); + result.put("page", page); + result.put("size", size); + result.put("records", users); + return result; + } + + /** + * 创建用户 + */ + @Transactional(rollbackFor = Exception.class) + public SysUser createUser(String username, String password, String nickname, + String email, String phone, List roleIds) { + // 检查用户名是否已存在 + Long exists = userMapper.selectCount( + new LambdaQueryWrapper().eq(SysUser::getUsername, username) + ); + if (exists > 0) { + throw new IllegalArgumentException("用户名已存在: " + username); + } + + SysUser user = SysUser.builder() + .username(username) + .password(passwordEncoder.encode(password)) + .nickname(nickname) + .email(email) + .phone(phone) + .enabled(true) + .build(); + userMapper.insert(user); + + // 分配角色 + if (roleIds != null && !roleIds.isEmpty()) { + assignRoles(user.getId(), roleIds); + } + + return user; + } + + /** + * 更新用户信息 + */ + @Transactional(rollbackFor = Exception.class) + public void updateUser(Long id, String nickname, String email, String phone) { + SysUser user = userMapper.selectById(id); + if (user == null) throw new IllegalArgumentException("用户不存在"); + + if (nickname != null) user.setNickname(nickname); + if (email != null) user.setEmail(email); + if (phone != null) user.setPhone(phone); + userMapper.updateById(user); + } + + /** + * 修改密码 + */ + public void changePassword(Long id, String newPassword) { + SysUser user = userMapper.selectById(id); + if (user == null) throw new IllegalArgumentException("用户不存在"); + user.setPassword(passwordEncoder.encode(newPassword)); + userMapper.updateById(user); + } + + /** + * 启用/禁用用户 + */ + public void toggleUser(Long id, boolean enabled) { + SysUser user = userMapper.selectById(id); + if (user == null) throw new IllegalArgumentException("用户不存在"); + user.setEnabled(enabled); + userMapper.updateById(user); + } + + /** + * 分配角色(先删后插) + */ + @Transactional(rollbackFor = Exception.class) + public void assignRoles(Long userId, List roleIds) { + // 删除旧关联 + userRoleMapper.delete( + new LambdaQueryWrapper().eq(SysUserRole::getUserId, userId) + ); + // 插入新关联 + for (Long roleId : roleIds) { + SysUserRole userRole = SysUserRole.builder() + .userId(userId) + .roleId(roleId) + .build(); + userRoleMapper.insert(userRole); + } + } + + /** + * 获取所有角色列表(用于分配角色时的下拉选择) + */ + public List getAllRoles() { + return roleMapper.selectList( + new LambdaQueryWrapper().eq(SysRole::getIsDelete, false).orderByAsc(SysRole::getId) + ); + } + + /** + * 根据ID获取用户详情(含角色) + */ + public SysUser getUserById(Long id) { + SysUser user = userMapper.selectById(id); + if (user != null) { + user.setRoles(getUserRoles(user.getId())); + } + return user; + } + + /** + * 更新最后登录时间 + */ + public void updateLastLoginTime(Long userId) { + jdbcTemplate.update( + "UPDATE sys_user SET last_login_time = CURRENT_TIMESTAMP WHERE id = ?", userId + ); + } + + /** + * 根据ID获取用户角色Key列表 + */ + public List getRoleKeysByUserId(Long userId) { + return getUserRoles(userId).stream().map(SysRole::getRoleKey).toList(); + } +} diff --git a/src/main/java/com/wok/supportbot/service/WebhookService.java b/src/main/java/com/wok/supportbot/service/WebhookService.java new file mode 100644 index 0000000..e81ab00 --- /dev/null +++ b/src/main/java/com/wok/supportbot/service/WebhookService.java @@ -0,0 +1,191 @@ +package com.wok.supportbot.service; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.wok.supportbot.dao.WebhookConfigMapper; +import com.wok.supportbot.entity.WebhookConfig; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; + +import java.util.*; + +/** + * Webhook 管理服务 + * 提供 Webhook 的增删改查及事件推送功能。 + */ +@Slf4j +@Service +public class WebhookService { + + @Autowired + private WebhookConfigMapper webhookConfigMapper; + + /** + * 分页查询 Webhook 列表 + * + * @param page 页码(从1开始) + * @param size 每页条数 + * @return 包含 records/total/page/size/pages 的结果 + */ + public Map listWebhooks(int page, int size) { + QueryWrapper countWrapper = new QueryWrapper<>(); + Long total = webhookConfigMapper.selectCount(countWrapper); + + QueryWrapper listWrapper = new QueryWrapper<>(); + listWrapper.orderByDesc("create_time"); + listWrapper.last("LIMIT " + size + " OFFSET " + (long) (page - 1) * size); + List records = webhookConfigMapper.selectList(listWrapper); + + Map result = new HashMap<>(); + result.put("records", records); + result.put("total", total); + result.put("page", page); + result.put("size", size); + result.put("pages", (total + size - 1) / size); + return result; + } + + /** + * 创建 Webhook + * + * @param userId 所属用户ID + * @param name 名称 + * @param url 回调 URL + * @param events 订阅的事件列表 + * @return 新创建的 WebhookConfig + */ + public WebhookConfig createWebhook(Long userId, String name, String url, List events) { + // 将事件列表包装为 Map 存入 JSONB + Map eventsMap = new HashMap<>(); + eventsMap.put("list", events != null ? events : new ArrayList<>()); + + WebhookConfig config = WebhookConfig.builder() + .userId(userId) + .name(name) + .url(url) + .events(eventsMap) + .enabled(true) + .secret(UUID.randomUUID().toString().replace("-", "")) + .build(); + + webhookConfigMapper.insert(config); + log.info("创建 Webhook: name={}, url={}", name, url); + return config; + } + + /** + * 更新 Webhook + * + * @param id Webhook ID + * @param name 名称(null 则不更新) + * @param url 回调 URL(null 则不更新) + * @param events 订阅事件列表(null 则不更新) + * @param enabled 是否启用(null 则不更新) + * @return 更新后的 WebhookConfig + */ + public WebhookConfig updateWebhook(Long id, String name, String url, List events, Boolean enabled) { + WebhookConfig existing = webhookConfigMapper.selectById(id); + if (existing == null) { + throw new IllegalArgumentException("Webhook 不存在,ID:" + id); + } + + if (name != null) { + existing.setName(name); + } + if (url != null) { + existing.setUrl(url); + } + if (events != null) { + Map eventsMap = new HashMap<>(); + eventsMap.put("list", events); + existing.setEvents(eventsMap); + } + if (enabled != null) { + existing.setEnabled(enabled); + } + + webhookConfigMapper.updateById(existing); + log.info("更新 Webhook: id={}", id); + return webhookConfigMapper.selectById(id); + } + + /** + * 逻辑删除 Webhook + * + * @param id Webhook ID + */ + public void deleteWebhook(Long id) { + WebhookConfig existing = webhookConfigMapper.selectById(id); + if (existing == null) { + throw new IllegalArgumentException("Webhook 不存在,ID:" + id); + } + webhookConfigMapper.deleteById(id); + log.info("删除 Webhook: id={}, name={}", id, existing.getName()); + } + + /** + * 异步触发事件推送 + * 查询所有匹配事件的已启用 Webhook,逐个 POST 推送。 + * 失败只记录日志,不抛异常。 + * + * @param event 事件名称,如 "document.processed" / "feedback.negative" + * @param payload 事件数据 + */ + @Async + public void triggerEvent(String event, Map payload) { + // 查询所有已启用的 Webhook + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.eq("enabled", true); + List allWebhooks = webhookConfigMapper.selectList(wrapper); + + // 过滤出订阅了该事件的 Webhook + List matched = allWebhooks.stream() + .filter(wh -> isEventSubscribed(wh, event)) + .toList(); + + if (matched.isEmpty()) { + return; + } + + // 构建推送 payload + Map body = new HashMap<>(); + body.put("event", event); + body.put("timestamp", System.currentTimeMillis()); + body.put("data", payload != null ? payload : new HashMap<>()); + + RestClient restClient = RestClient.create(); + + for (WebhookConfig webhook : matched) { + try { + restClient.post() + .uri(webhook.getUrl()) + .header("Content-Type", "application/json") + .body(body) + .retrieve() + .toBodilessEntity(); + log.info("Webhook 推送成功: name={}, event={}", webhook.getName(), event); + } catch (Exception e) { + log.error("Webhook 推送失败: name={}, url={}, event={}, error={}", + webhook.getName(), webhook.getUrl(), event, e.getMessage()); + } + } + } + + /** + * 判断 Webhook 是否订阅了指定事件 + */ + @SuppressWarnings("unchecked") + private boolean isEventSubscribed(WebhookConfig webhook, String event) { + Map events = webhook.getEvents(); + if (events == null || !events.containsKey("list")) { + return false; + } + Object listObj = events.get("list"); + if (listObj instanceof List list) { + return list.contains(event); + } + return false; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index bb0b96c..ec43dba 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -78,6 +78,10 @@ springdoc: - group: default paths-to-match: /** packages-to-scan: com.wok.supportbot.controller + - group: open-api + display-name: 开放API + paths-to-match: /open-api/** + packages-to-scan: com.wok.supportbot.controller knife4j: setting: @@ -89,3 +93,12 @@ knife4j: logging: pattern: console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n" + +# ==================== JWT 认证配置 ==================== +jwt: + # 签名密钥(生产环境务必通过环境变量 JWT_SECRET 覆盖) + secret: support-bot-jwt-secret-key-2026-please-change-in-production + # 访问令牌有效期:24小时(毫秒) + expiration: 86400000 + # 刷新令牌有效期:7天(毫秒) + refresh-expiration: 604800000 diff --git a/src/main/resources/static/components/ApiKeyManager.js b/src/main/resources/static/components/ApiKeyManager.js new file mode 100644 index 0000000..2bd9842 --- /dev/null +++ b/src/main/resources/static/components/ApiKeyManager.js @@ -0,0 +1,250 @@ +/** + * API Key 管理组件 + * 支持 API Key 的生成、吊销、启用、删除、复制、分页查询 + */ +import { ref, onMounted, computed } from 'vue' +import { listApiKeys, createApiKey, revokeApiKey, enableApiKey, deleteApiKey } from '../js/api.js' +import { toast } from '../js/utils.js' + +export default { + template: ` +
+

🔑 API Key 管理

+ + +
+ + 共 {{ total }} 个 Key +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
名称Key频率限制调用次数过期时间状态操作
加载中...
暂无 API Key
+
{{ k.name || '未命名' }}
+
{{ k.description }}
+
+
+ {{ maskKey(k.keyValue) }} + +
+
{{ k.rateLimit || 60 }} 次/分钟 + {{ k.currentCalls || 0 }} + / {{ k.maxCalls }} + (不限) + + {{ k.expireTime ? formatDate(k.expireTime) : '永不过期' }} + + + {{ k.enabled ? '✅ 有效' : '❌ 已吊销' }} + + + + + +
+ + +
+ + 第 {{ page }} / {{ Math.ceil(total / pageSize) }} 页(共 {{ total }} 条) + +
+ + +
+
+

新建 API Key

+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+

✅ API Key 已创建

+

⚠ 请立即复制保存,关闭后将无法再次查看完整 Key。

+
+ {{ revealedKey }} +
+
+ + +
+
+
+
+ `, + + setup() { + const keys = ref([]) + const loading = ref(false) + const page = ref(1) + const pageSize = ref(20) + const total = ref(0) + const showCreateDialog = ref(false) + const showKeyReveal = ref(false) + const revealedKey = ref('') + const form = ref({ name: '', description: '', rateLimit: null, maxCalls: null, expireTime: '' }) + + async function loadList() { + loading.value = true + try { + const res = await listApiKeys(page.value, pageSize.value) + if (res.success) { + keys.value = res.data || [] + total.value = res.total || 0 + } + } catch (e) { + toast('加载失败: ' + e.message, 'error') + } + loading.value = false + } + + function openCreateDialog() { + form.value = { name: '', description: '', rateLimit: null, maxCalls: null, expireTime: '' } + showCreateDialog.value = true + } + + async function doCreate() { + const data = { name: form.value.name, description: form.value.description || '' } + if (form.value.rateLimit) data.rateLimit = form.value.rateLimit + if (form.value.maxCalls) data.maxCalls = form.value.maxCalls + if (form.value.expireTime) data.expireTime = form.value.expireTime + + try { + const res = await createApiKey(data) + if (res.success) { + toast('创建成功', 'success') + showCreateDialog.value = false + // 显示完整 Key 供用户复制 + if (res.data && res.data.keyValue) { + revealedKey.value = res.data.keyValue + showKeyReveal.value = true + } + loadList() + } else { + toast(res.message || '创建失败', 'error') + } + } catch (e) { + toast('创建失败: ' + e.message, 'error') + } + } + + async function doRevoke(id) { + if (!confirm('确认吊销该 API Key?吊销后使用该 Key 的请求将被拒绝。')) return + try { + const res = await revokeApiKey(id) + if (res.success) { toast('已吊销', 'success'); loadList() } + else toast(res.message || '吊销失败', 'error') + } catch (e) { toast('吊销失败: ' + e.message, 'error') } + } + + async function doEnable(id) { + try { + const res = await enableApiKey(id) + if (res.success) { toast('已启用', 'success'); loadList() } + else toast(res.message || '启用失败', 'error') + } catch (e) { toast('启用失败: ' + e.message, 'error') } + } + + async function doDelete(id) { + if (!confirm('确认删除该 API Key?此操作不可恢复。')) return + try { + const res = await deleteApiKey(id) + if (res.success) { toast('已删除', 'success'); loadList() } + else toast(res.message || '删除失败', 'error') + } catch (e) { toast('删除失败: ' + e.message, 'error') } + } + + function maskKey(key) { + if (!key || key.length < 12) return key || '***' + return key.substring(0, 6) + '****' + key.substring(key.length - 4) + } + + function copyKey(key) { + if (!key) return + navigator.clipboard.writeText(key).then(() => { + toast('已复制到剪贴板', 'success') + }).catch(() => { + // 降级方案 + const ta = document.createElement('textarea') + ta.value = key + document.body.appendChild(ta) + ta.select() + document.execCommand('copy') + document.body.removeChild(ta) + toast('已复制到剪贴板', 'success') + }) + } + + function formatDate(dateVal) { + if (!dateVal) return '-' + const d = new Date(dateVal) + const y = d.getFullYear() + const m = String(d.getMonth() + 1).padStart(2, '0') + const day = String(d.getDate()).padStart(2, '0') + const h = String(d.getHours()).padStart(2, '0') + const min = String(d.getMinutes()).padStart(2, '0') + return y + '-' + m + '-' + day + ' ' + h + ':' + min + } + + onMounted(() => loadList()) + + return { + keys, loading, page, pageSize, total, + showCreateDialog, showKeyReveal, revealedKey, form, + loadList, openCreateDialog, doCreate, + doRevoke, doEnable, doDelete, + maskKey, copyKey, formatDate + } + } +} diff --git a/src/main/resources/static/components/DashboardPanel.js b/src/main/resources/static/components/DashboardPanel.js new file mode 100644 index 0000000..118ed64 --- /dev/null +++ b/src/main/resources/static/components/DashboardPanel.js @@ -0,0 +1,340 @@ +/** + * 运营看板组件 + * 指标卡片 + Chart.js 趋势图表 + 知识库文档命中排行 + 未命中问题收集 + */ +import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue' +import { getDashboardOverview, getDashboardTrend, getDashboardKnowledge, getDashboardCustom } from '../js/api.js' + +export default { + template: ` +
+ +
+
+

📈 运营数据看板

+
+ + + | + + + + +
+
+
+ + +
+
+
{{ overview.conversationCount ?? 0 }}
+
今日对话数
+
+
+
{{ formatPercent(overview.satisfactionRate) }}%
+
满意率
+
+
+
{{ formatPercent(overview.ragHitRate) }}%
+
知识库命中率
+
+
+
{{ overview.avgResponseTime ?? 0 }}ms
+
平均响应时间
+
+
+ + +
+ +
+

📊 对话量趋势

+
+ +
+
+ +
+

📊 满意度趋势

+
+ +
+
+
+ + +
+ +
+

📚 文档命中排行 TOP-10

+
暂无数据
+ + + + + + + + + + + + + + + + + +
排名文档标题命中次数平均得分
{{ idx + 1 }}{{ doc.document_title || '-' }}{{ doc.hit_count ?? doc.cnt ?? 0 }}{{ doc.avg_score != null ? Number(doc.avg_score).toFixed(3) : '-' }}
+
+ + +
+

❓ 未命中问题收集

+
暂无数据
+
+
+ {{ idx + 1 }}. + {{ item.user_query }} + {{ formatDateShort(item.last_time) }} +
+
+
+
+ + +
加载中...
+
+ `, + setup() { + const loading = ref(false) + const rangeDays = ref(7) + const customStart = ref('') + const customEnd = ref('') + const overview = ref({}) + const trendData = ref([]) + const topHitDocuments = ref([]) + const missQuestions = ref([]) + + // Chart.js 实例引用(用于销毁重建) + let conversationChart = null + let satisfactionChart = null + const conversationChartRef = ref(null) + const satisfactionChartRef = ref(null) + + // 满意率颜色:>=80% 绿色,>=60% 橙色,<60% 红色 + const satisfactionColor = computed(() => { + const rate = overview.value.satisfactionRate || 0 + if (rate >= 80) return 'var(--success)' + if (rate >= 60) return 'var(--warn)' + return 'var(--danger)' + }) + + // 格式化百分比(保留一位小数) + function formatPercent(val) { + if (val == null) return '0.0' + return Number(val).toFixed(1) + } + + // 格式化日期简写(仅月-日) + function formatDateShort(dateVal) { + if (!dateVal) return '' + const d = new Date(dateVal) + return `${d.getMonth() + 1}/${d.getDate()}` + } + + // 设置时间范围并加载趋势 + async function setRange(days) { + rangeDays.value = days + await loadTrend(days) + } + + // 加载今日概览 + async function loadOverview() { + try { + const res = await getDashboardOverview() + if (res.success) overview.value = res.data + } catch (e) { + console.error('加载概览数据失败', e) + } + } + + // 加载趋势数据 + async function loadTrend(days) { + try { + const res = await getDashboardTrend(days) + if (res.success) { + trendData.value = res.data || [] + await nextTick() + renderCharts() + } + } catch (e) { + console.error('加载趋势数据失败', e) + } + } + + // 加载自定义范围数据 + async function loadCustomRange() { + if (!customStart.value || !customEnd.value) return + loading.value = true + try { + const res = await getDashboardCustom(customStart.value, customEnd.value) + if (res.success) { + trendData.value = res.data || [] + rangeDays.value = 0 + await nextTick() + renderCharts() + } + } catch (e) { + console.error('加载自定义范围数据失败', e) + } finally { + loading.value = false + } + } + + // 加载知识库分析 + async function loadKnowledgeAnalysis() { + try { + const res = await getDashboardKnowledge() + if (res.success) { + topHitDocuments.value = res.data?.topHitDocuments || [] + missQuestions.value = res.data?.missQuestions || [] + } + } catch (e) { + console.error('加载知识库分析数据失败', e) + } + } + + // 渲染 Chart.js 图表 + async function renderCharts() { + const { Chart, LineController, LineElement, PointElement, LinearScale, CategoryScale, Tooltip, Legend, Filler } = await import('chart.js') + + // 注册所需组件 + Chart.register(LineController, LineElement, PointElement, LinearScale, CategoryScale, Tooltip, Legend, Filler) + + const labels = trendData.value.map(s => { + const d = new Date(s.snapshotDate || s.snapshot_date) + return `${d.getMonth() + 1}/${d.getDate()}` + }) + + // === 对话量趋势图 === + if (conversationChart) conversationChart.destroy() + if (conversationChartRef.value) { + const convData = trendData.value.map(s => s.conversationCount ?? s.conversation_count ?? 0) + const msgData = trendData.value.map(s => s.messageCount ?? s.message_count ?? 0) + + conversationChart = new Chart(conversationChartRef.value, { + type: 'line', + data: { + labels, + datasets: [ + { + label: '对话数', + data: convData, + borderColor: '#3b82f6', + backgroundColor: 'rgba(59,130,246,0.1)', + fill: true, + tension: 0.3, + pointRadius: 4, + pointHoverRadius: 6 + }, + { + label: '消息数', + data: msgData, + borderColor: '#8b5cf6', + backgroundColor: 'rgba(139,92,246,0.1)', + fill: true, + tension: 0.3, + pointRadius: 4, + pointHoverRadius: 6 + } + ] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { position: 'bottom', labels: { boxWidth: 12, padding: 16, font: { size: 12 } } } }, + scales: { + y: { beginAtZero: true, ticks: { font: { size: 11 } }, grid: { color: 'rgba(0,0,0,0.05)' } }, + x: { ticks: { font: { size: 11 } }, grid: { display: false } } + } + } + }) + } + + // === 满意度趋势图(👍/👎 双线) === + if (satisfactionChart) satisfactionChart.destroy() + if (satisfactionChartRef.value) { + const upData = trendData.value.map(s => s.thumbsUpCount ?? s.thumbs_up_count ?? 0) + const downData = trendData.value.map(s => s.thumbsDownCount ?? s.thumbs_down_count ?? 0) + + satisfactionChart = new Chart(satisfactionChartRef.value, { + type: 'line', + data: { + labels, + datasets: [ + { + label: '👍 有帮助', + data: upData, + borderColor: '#10b981', + backgroundColor: 'rgba(16,185,129,0.1)', + fill: true, + tension: 0.3, + pointRadius: 4, + pointHoverRadius: 6 + }, + { + label: '👎 没帮助', + data: downData, + borderColor: '#ef4444', + backgroundColor: 'rgba(239,68,68,0.1)', + fill: true, + tension: 0.3, + pointRadius: 4, + pointHoverRadius: 6 + } + ] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { position: 'bottom', labels: { boxWidth: 12, padding: 16, font: { size: 12 } } } }, + scales: { + y: { beginAtZero: true, ticks: { font: { size: 11 } }, grid: { color: 'rgba(0,0,0,0.05)' } }, + x: { ticks: { font: { size: 11 } }, grid: { display: false } } + } + } + }) + } + } + + // 销毁图表 + function destroyCharts() { + if (conversationChart) { conversationChart.destroy(); conversationChart = null } + if (satisfactionChart) { satisfactionChart.destroy(); satisfactionChart = null } + } + + onMounted(async () => { + loading.value = true + await Promise.all([ + loadOverview(), + loadTrend(7), + loadKnowledgeAnalysis() + ]) + loading.value = false + }) + + onBeforeUnmount(() => { + destroyCharts() + }) + + return { + loading, rangeDays, customStart, customEnd, + overview, trendData, topHitDocuments, missQuestions, + conversationChartRef, satisfactionChartRef, + satisfactionColor, formatPercent, formatDateShort, + setRange, loadCustomRange + } + } +} diff --git a/src/main/resources/static/components/FaqManager.js b/src/main/resources/static/components/FaqManager.js index 9f73634..7a6c489 100644 --- a/src/main/resources/static/components/FaqManager.js +++ b/src/main/resources/static/components/FaqManager.js @@ -1,10 +1,27 @@ /** * FAQ 管理组件 * 支持 CRUD + 批量导入 + 导出 + 启用/禁用 + * 分类引用文档管理的 knowledge_category 分类树 */ -import { listFaqs, createFaq, updateFaq, deleteFaq, toggleFaqStatus, batchImportFaqs, exportFaqs, getFaqStats } from '../js/api.js' +import { listFaqs, createFaq, updateFaq, deleteFaq, toggleFaqStatus, batchImportFaqs, exportFaqs, getFaqStats, getCategoryTree } from '../js/api.js' import { toast } from '../js/utils.js' +/** + * 将分类树扁平化为 [{id, name, path}] 列表,path 为完整路径如 "一级/二级" + */ +function flattenCategoryTree(tree, prefix = '') { + if (!tree || !Array.isArray(tree)) return [] + const result = [] + for (const node of tree) { + const path = prefix ? `${prefix} / ${node.name}` : node.name + result.push({ id: node.id, name: node.name, path }) + if (node.children && node.children.length) { + result.push(...flattenCategoryTree(node.children, path)) + } + } + return result +} + export default { template: `
@@ -16,9 +33,9 @@ export default { - - + + +
@@ -113,8 +133,8 @@ export default {

批量导入 FAQ

-

使用 JSON 格式批量导入,每条包含 question、answer、similarQuestions(可选数组)、category(可选)

- +

使用 JSON 格式批量导入,每条包含 question、answer、similarQuestions(可选数组)、categoryId(可选,分类ID)

+
@@ -132,35 +152,56 @@ export default { pageSize: 20, total: 0, searchKeyword: '', - filterCategory: '', + filterCategoryId: '', filterStatus: '', - categories: [], + flatCategories: [], + categoryMap: {}, stats: null, showFormDialog: false, showImportDialog: false, editingFaq: null, - form: { question: '', answer: '', similarQuestionsText: '', category: '', priority: 0 }, + form: { question: '', answer: '', similarQuestionsText: '', categoryId: null, priority: 0 }, importJson: '', searchTimer: null } }, mounted() { + this.loadCategories() this.loadList() this.loadStats() }, methods: { + async loadCategories() { + try { + const res = await getCategoryTree() + if (res.success) { + this.flatCategories = flattenCategoryTree(res.data) + // 构建 id → path 映射,用于表格中显示分类名 + const map = {} + for (const c of this.flatCategories) { + map[c.id] = c.path + } + this.categoryMap = map + } + } catch (e) { + console.warn('加载分类树失败', e) + } + }, + + getCategoryName(categoryId) { + if (!categoryId) return '' + return this.categoryMap[categoryId] || '' + }, + async loadList() { this.loading = true try { - const res = await listFaqs(this.page, this.pageSize, this.searchKeyword || undefined, this.filterCategory || undefined, this.filterStatus || undefined) + const res = await listFaqs(this.page, this.pageSize, this.searchKeyword || undefined, this.filterCategoryId || undefined, this.filterStatus || undefined) if (res.success) { this.faqs = res.data?.records || res.data || [] this.total = res.data?.total || res.total || 0 - // 提取分类列表 - const cats = new Set(this.faqs.map(f => f.category).filter(Boolean)) - this.categories = [...cats] } } catch (e) { toast('加载失败: ' + e.message, 'error') @@ -182,7 +223,7 @@ export default { openAddDialog() { this.editingFaq = null - this.form = { question: '', answer: '', similarQuestionsText: '', category: '', priority: 0 } + this.form = { question: '', answer: '', similarQuestionsText: '', categoryId: null, priority: 0 } this.showFormDialog = true }, @@ -193,7 +234,7 @@ export default { const arr = typeof f.similarQuestions === 'string' ? JSON.parse(f.similarQuestions) : f.similarQuestions similarText = Array.isArray(arr) ? arr.join('\n') : '' } catch { similarText = f.similarQuestions || '' } - this.form = { question: f.question, answer: f.answer, similarQuestionsText: similarText, category: f.category || '', priority: f.priority || 0 } + this.form = { question: f.question, answer: f.answer, similarQuestionsText: similarText, categoryId: f.categoryId || null, priority: f.priority || 0 } this.showFormDialog = true }, @@ -203,7 +244,7 @@ export default { question: this.form.question, answer: this.form.answer, similarQuestions: JSON.stringify(similarQuestions), - category: this.form.category || null, + categoryId: this.form.categoryId || null, priority: this.form.priority || 0 } try { diff --git a/src/main/resources/static/components/LoginPage.js b/src/main/resources/static/components/LoginPage.js new file mode 100644 index 0000000..c3f9fbf --- /dev/null +++ b/src/main/resources/static/components/LoginPage.js @@ -0,0 +1,71 @@ +/** + * 登录页组件 + * 全屏居中登录表单,JWT Token 认证 + */ +import { ref } from 'vue' +import { login } from '../js/api.js' +import { setToken, setRefreshToken, setUserInfo, toast } from '../js/utils.js' + +export default { + template: ` + + `, + setup(props, { emit }) { + const username = ref('') + const password = ref('') + const loading = ref(false) + const errorMsg = ref('') + + async function handleLogin() { + if (!username.value || !password.value) { + errorMsg.value = '请输入用户名和密码' + return + } + loading.value = true + errorMsg.value = '' + try { + const res = await login(username.value, password.value) + if (res.success && res.data) { + setToken(res.data.accessToken) + setRefreshToken(res.data.refreshToken) + setUserInfo(res.data.user) + toast('登录成功', 'success') + emit('login-success', res.data.user) + } else { + errorMsg.value = res.message || '登录失败' + } + } catch (e) { + errorMsg.value = '网络错误,请重试' + } finally { + loading.value = false + } + } + + return { username, password, loading, errorMsg, handleLogin } + } +} diff --git a/src/main/resources/static/components/UserManager.js b/src/main/resources/static/components/UserManager.js new file mode 100644 index 0000000..a562a71 --- /dev/null +++ b/src/main/resources/static/components/UserManager.js @@ -0,0 +1,315 @@ +/** + * 用户管理组件 + * 系统用户 CRUD + 角色分配 + 启用/禁用 + */ +import { ref, reactive, onMounted } from 'vue' +import { + listSysUsers, createSysUser, updateSysUser, toggleSysUser, + assignSysUserRoles, changeSysUserPassword, getAllSysRoles +} from '../js/api.js' +import { toast, formatDate } from '../js/utils.js' + +export default { + template: ` +
+
+

👥 用户管理

+ +
+ + +
+ + + +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
用户名昵称邮箱角色状态最后登录创建时间操作
{{ u.username }}{{ u.nickname || '-' }}{{ u.email || '-' }} + {{ r }} + 未分配 + + + {{ u.enabled ? '启用' : '禁用' }} + + {{ u.last_login_time ? formatDate(u.last_login_time) : '从未登录' }}{{ formatDate(u.create_time) }} + + + + +
暂无用户数据
+
+ + + + + + + + + + + + +
+ `, + setup() { + const users = ref([]) + const total = ref(0) + const page = ref(1) + const pageSize = ref(20) + const keyword = ref('') + const filterEnabled = ref(null) + const allRoles = ref([]) + const saving = ref(false) + + const modal = reactive({ + visible: false, isEdit: false, userId: null, + username: '', password: '', nickname: '', email: '', phone: '' + }) + const roleModal = reactive({ + visible: false, userId: null, username: '', selectedRoleIds: [] + }) + const pwdModal = reactive({ + visible: false, userId: null, username: '', newPassword: '' + }) + + async function loadData() { + try { + const res = await listSysUsers(page.value, pageSize.value, keyword.value, filterEnabled.value) + if (res.success) { + users.value = res.data.records || [] + total.value = res.data.total || 0 + } + } catch (e) { + console.error('加载用户列表失败', e) + } + } + + async function loadRoles() { + try { + const res = await getAllSysRoles() + if (res.success) allRoles.value = res.data || [] + } catch (e) { + console.error('加载角色列表失败', e) + } + } + + function openCreate() { + Object.assign(modal, { + visible: true, isEdit: false, userId: null, + username: '', password: '', nickname: '', email: '', phone: '' + }) + } + + function openEdit(u) { + Object.assign(modal, { + visible: true, isEdit: true, userId: u.id, + username: u.username, nickname: u.nickname || '', + email: u.email || '', phone: u.phone || '', password: '' + }) + } + + async function handleSave() { + saving.value = true + try { + let res + if (modal.isEdit) { + res = await updateSysUser(modal.userId, { + nickname: modal.nickname, email: modal.email, phone: modal.phone + }) + } else { + if (!modal.username || !modal.password) { + toast('用户名和密码不能为空', 'error') + return + } + res = await createSysUser({ + username: modal.username, password: modal.password, + nickname: modal.nickname, email: modal.email, phone: modal.phone + }) + } + if (res.success) { + toast(res.message || '操作成功', 'success') + modal.visible = false + loadData() + } else { + toast(res.message || '操作失败', 'error') + } + } catch (e) { + toast('操作失败', 'error') + } finally { + saving.value = false + } + } + + async function handleToggle(u) { + const action = u.enabled ? '禁用' : '启用' + if (!confirm(`确定${action}用户「${u.username}」?`)) return + try { + const res = await toggleSysUser(u.id, !u.enabled) + if (res.success) { + toast(res.message || '操作成功', 'success') + loadData() + } else { + toast(res.message || '操作失败', 'error') + } + } catch (e) { + toast('操作失败', 'error') + } + } + + async function openRoleAssign(u) { + roleModal.userId = u.id + roleModal.username = u.username + // 预选当前角色 + const roleKeys = (u.role_keys || '').split(',').filter(Boolean) + roleModal.selectedRoleIds = allRoles.value + .filter(r => roleKeys.includes(r.roleKey)) + .map(r => r.id.toString()) + roleModal.visible = true + } + + async function handleRoleAssign() { + try { + const roleIds = roleModal.selectedRoleIds.map(Number) + const res = await assignSysUserRoles(roleModal.userId, roleIds) + if (res.success) { + toast('角色分配成功', 'success') + roleModal.visible = false + loadData() + } else { + toast(res.message || '分配失败', 'error') + } + } catch (e) { + toast('分配失败', 'error') + } + } + + function openPasswordChange(u) { + pwdModal.userId = u.id + pwdModal.username = u.username + pwdModal.newPassword = '' + pwdModal.visible = true + } + + async function handlePasswordChange() { + if (!pwdModal.newPassword) { toast('请输入新密码', 'error'); return } + try { + const res = await changeSysUserPassword(pwdModal.userId, pwdModal.newPassword) + if (res.success) { + toast('密码修改成功', 'success') + pwdModal.visible = false + } else { + toast(res.message || '修改失败', 'error') + } + } catch (e) { + toast('修改失败', 'error') + } + } + + onMounted(() => { loadData(); loadRoles() }) + + return { + users, total, page, pageSize, keyword, filterEnabled, allRoles, saving, + modal, roleModal, pwdModal, + loadData, openCreate, openEdit, handleSave, handleToggle, + openRoleAssign, handleRoleAssign, openPasswordChange, handlePasswordChange, + formatDate + } + } +} diff --git a/src/main/resources/static/components/WebhookManager.js b/src/main/resources/static/components/WebhookManager.js new file mode 100644 index 0000000..1dd2178 --- /dev/null +++ b/src/main/resources/static/components/WebhookManager.js @@ -0,0 +1,216 @@ +/** + * Webhook 管理组件 + * 支持 Webhook 的创建、编辑、删除、测试推送、分页查询 + */ +import { ref, onMounted } from 'vue' +import { listWebhooks, createWebhook, updateWebhook, deleteWebhook, testWebhook } from '../js/api.js' +import { toast } from '../js/utils.js' + +export default { + template: ` +
+

🔔 Webhook 管理

+ + +
+ + 共 {{ total }} 个 +
+ + + + + + + + + + + + + + + + + + + + + + + +
名称URL订阅事件状态操作
加载中...
暂无 Webhook
{{ w.name || '未命名' }} + {{ w.url }} + +
+ {{ ev }} +
+
+ + {{ w.enabled ? '✅ 启用' : '❌ 禁用' }} + + + + + +
+ + +
+ + 第 {{ page }} / {{ Math.ceil(total / pageSize) }} 页(共 {{ total }} 条) + +
+ + +
+
+

{{ editingId ? '编辑 Webhook' : '新建 Webhook' }}

+
+ + +
+
+ + +
+
+ +
+ +
+
+
+ +
+
+ + +
+
+
+
+ `, + + setup() { + const webhooks = ref([]) + const loading = ref(false) + const page = ref(1) + const pageSize = ref(20) + const total = ref(0) + const showFormDialog = ref(false) + const editingId = ref(null) + const form = ref({ name: '', url: '', events: [], enabled: true }) + + const availableEvents = [ + { value: 'document.processed', label: '文档处理完成时触发' }, + { value: 'feedback.negative', label: '收到负面反馈时触发' } + ] + + async function loadList() { + loading.value = true + try { + const res = await listWebhooks(page.value, pageSize.value) + if (res.success) { + webhooks.value = res.data || [] + total.value = res.total || 0 + } + } catch (e) { + toast('加载失败: ' + e.message, 'error') + } + loading.value = false + } + + function getEvents(w) { + if (!w.events) return [] + const eventsObj = w.events + if (eventsObj.list && Array.isArray(eventsObj.list)) return eventsObj.list + // 兼容直接是数组的情况 + if (Array.isArray(eventsObj)) return eventsObj + return [] + } + + function openCreateDialog() { + editingId.value = null + form.value = { name: '', url: '', events: [], enabled: true } + showFormDialog.value = true + } + + function openEditDialog(w) { + editingId.value = w.id + form.value = { + name: w.name || '', + url: w.url || '', + events: getEvents(w).slice(), + enabled: w.enabled !== false + } + showFormDialog.value = true + } + + async function doSave() { + try { + let res + if (editingId.value) { + res = await updateWebhook(editingId.value, { + name: form.value.name, + url: form.value.url, + events: form.value.events, + enabled: form.value.enabled + }) + } else { + res = await createWebhook({ + name: form.value.name, + url: form.value.url, + events: form.value.events + }) + } + if (res.success) { + toast(editingId.value ? '更新成功' : '创建成功', 'success') + showFormDialog.value = false + loadList() + } else { + toast(res.message || '操作失败', 'error') + } + } catch (e) { + toast('操作失败: ' + e.message, 'error') + } + } + + async function doTest(id) { + try { + const res = await testWebhook(id) + if (res.success) toast('测试推送已发送', 'success') + else toast(res.message || '测试失败', 'error') + } catch (e) { + toast('测试失败: ' + e.message, 'error') + } + } + + async function doDelete(id) { + if (!confirm('确认删除该 Webhook?')) return + try { + const res = await deleteWebhook(id) + if (res.success) { toast('已删除', 'success'); loadList() } + else toast(res.message || '删除失败', 'error') + } catch (e) { + toast('删除失败: ' + e.message, 'error') + } + } + + onMounted(() => loadList()) + + return { + webhooks, loading, page, pageSize, total, + showFormDialog, editingId, form, availableEvents, + loadList, getEvents, openCreateDialog, openEditDialog, + doSave, doTest, doDelete + } + } +} diff --git a/src/main/resources/static/css/main.css b/src/main/resources/static/css/main.css index beb774e..11f4c98 100644 --- a/src/main/resources/static/css/main.css +++ b/src/main/resources/static/css/main.css @@ -32,8 +32,8 @@ body { font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei" /* ==================== 侧边栏 ==================== */ .sidebar { width:220px; flex:none; background:#1f2937; color:#e5e7eb; display:flex; flex-direction:column; transition:width .25s cubic-bezier(.4,0,.2,1); overflow:hidden; z-index:50; } .sidebar.collapsed { width:56px; } -.sidebar-inner { display:flex; flex-direction:column; height:100%; overflow-y:auto; overflow-x:hidden; } -.sidebar-nav { flex:1; padding:8px 0; } +.sidebar-inner { display:flex; flex-direction:column; flex:1; min-height:0; overflow-y:auto; overflow-x:hidden; } +.sidebar-nav { flex:1; min-height:0; overflow-y:auto; padding:8px 0; } /* 菜单项 */ .menu-item { display:flex; align-items:center; gap:10px; width:100%; padding:10px 16px; border:none; background:none; color:#d1d5db; font-size:14px; cursor:pointer; transition:background .15s, color .15s; text-align:left; font-family:inherit; white-space:nowrap; position:relative; } @@ -273,6 +273,13 @@ body { font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei" .chat-composer { padding:12px 16px 16px; } } +/* ==================== 侧边栏暗色滚动条 ==================== */ +.sidebar-inner { scrollbar-width:thin; scrollbar-color:rgba(255,255,255,.15) transparent; } +.sidebar-inner::-webkit-scrollbar { width:6px; } +.sidebar-inner::-webkit-scrollbar-thumb { background:rgba(255,255,255,.15); border-radius:999px; } +.sidebar-inner::-webkit-scrollbar-thumb:hover { background:rgba(255,255,255,.25); } +.sidebar-inner::-webkit-scrollbar-track { background:transparent; } + /* ==================== 滚动条 ==================== */ * { scrollbar-width:thin; scrollbar-color:#cbd0d8 transparent; } ::-webkit-scrollbar { width:10px; height:10px; } @@ -338,3 +345,75 @@ body { font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei" .source-chunk { font-size:12px; line-height:1.6; color:var(--sub); border-left:2px solid var(--border); padding-left:10px; margin-top:4px; } .source-rel { display:inline-block; font-size:11px; color:var(--muted); margin-right:6px; font-weight:600; } .source-snippet { color:var(--sub); } + +/* ==================== 登录页 ==================== */ +.login-page { position:fixed; top:0; left:0; right:0; bottom:0; background:linear-gradient(135deg,#1f2937 0%,#111827 100%); display:flex; align-items:center; justify-content:center; z-index:9999; } +.login-card { background:#fff; border-radius:var(--radius-lg); padding:40px; width:100%; max-width:400px; box-shadow:var(--shadow-lg); } +.login-header { text-align:center; margin-bottom:32px; } +.login-logo { font-size:48px; margin-bottom:8px; } +.login-header h1 { font-size:22px; font-weight:700; color:var(--text); } +.login-subtitle { font-size:13px; color:var(--muted); margin-top:4px; } +.login-form .form-group { margin-bottom:16px; } +.login-form label { display:block; font-size:13px; font-weight:600; color:var(--sub); margin-bottom:6px; } +.login-form input { width:100%; padding:12px 14px; border:1px solid var(--border); border-radius:8px; font-size:14px; font-family:inherit; outline:none; transition:border-color .2s; } +.login-form input:focus { border-color:var(--primary); box-shadow:var(--ring); } +.btn-login { width:100%; padding:12px; background:var(--primary); color:#fff; border:none; border-radius:8px; font-size:15px; font-weight:600; cursor:pointer; transition:opacity .2s; margin-top:8px; } +.btn-login:hover:not(:disabled) { opacity:.9; } +.btn-login:disabled { opacity:.5; cursor:not-allowed; } +.login-error { color:var(--danger); font-size:13px; text-align:center; margin-top:12px; } +.login-footer { text-align:center; margin-top:20px; font-size:12px; color:var(--muted); } + +/* ==================== 用户栏(topbar 右侧) ==================== */ +.user-bar { margin-left:auto; display:flex; align-items:center; gap:12px; } +.user-info { display:flex; align-items:center; gap:8px; } +.user-avatar { width:28px; height:28px; border-radius:50%; background:rgba(255,255,255,.2); color:#fff; display:flex; align-items:center; justify-content:center; font-size:13px; font-weight:600; } +.user-name { font-size:13px; color:#e5e7eb; } +.user-role-tag { font-size:10px; padding:2px 6px; border-radius:4px; background:rgba(255,255,255,.15); color:#d1d5db; } +.btn-logout { background:rgba(255,255,255,.1); color:#e5e7eb; border:1px solid rgba(255,255,255,.2); font-size:12px; padding:4px 12px; } +.btn-logout:hover { background:rgba(255,255,255,.2); color:#fff; } + +/* ==================== 弹窗(用户管理等通用) ==================== */ +.modal-card { background:#fff; border-radius:var(--radius); width:90%; max-width:500px; max-height:85vh; overflow-y:auto; padding:24px; } +.modal-card h3 { font-size:16px; font-weight:600; margin-bottom:20px; } +.modal-card .form-group { margin-bottom:14px; } +.modal-card label { display:block; font-size:13px; font-weight:600; color:var(--sub); margin-bottom:4px; } +.modal-card input { width:100%; padding:10px 12px; border:1px solid var(--border); border-radius:8px; font-size:13px; font-family:inherit; outline:none; } +.modal-card input:focus { border-color:var(--primary); box-shadow:var(--ring); } +.modal-card input:disabled { background:#f5f5f5; color:var(--muted); } +.modal-actions { display:flex; justify-content:flex-end; gap:8px; margin-top:20px; padding-top:16px; border-top:1px solid var(--border); } + +/* ==================== 工具栏 ==================== */ +.toolbar { display:flex; gap:8px; margin-bottom:16px; align-items:center; flex-wrap:wrap; } +.search-input { padding:8px 12px; border:1px solid var(--border); border-radius:8px; font-size:13px; outline:none; min-width:200px; flex:1; max-width:300px; } +.search-input:focus { border-color:var(--primary); box-shadow:var(--ring); } +.search-select { padding:8px 12px; border:1px solid var(--border); border-radius:8px; font-size:13px; background:#fff; outline:none; } + +/* ==================== 复选框标签 ==================== */ +.checkbox-label { display:flex; align-items:center; gap:8px; padding:8px 0; font-size:13px; cursor:pointer; } +.checkbox-label input[type="checkbox"] { width:16px; height:16px; accent-color:var(--primary); } + +/* ==================== 标签 ==================== */ +.tag { display:inline-block; padding:2px 8px; border-radius:4px; font-size:11px; background:#e5e7eb; color:var(--text); margin-right:4px; } +.tag-muted { background:#f3f4f6; color:var(--muted); } + +/* ==================== 空状态 ==================== */ +.empty { text-align:center; color:var(--muted); padding:20px; font-size:13px; } + +/* ==================== 卡片头部 ==================== */ +.card-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:16px; } +.card-header h2 { margin-bottom:0; } + +/* ==================== 操作按钮组 ==================== */ +.actions { white-space:nowrap; } +.actions .btn { margin:2px; } + +/* ==================== 表单组 ==================== */ +.form-group { margin-bottom:14px; } +.form-group label { display:block; font-size:13px; font-weight:600; color:var(--sub); margin-bottom:4px; } +.form-group input, .form-group select, .form-group textarea { width:100%; padding:10px 12px; border:1px solid var(--border); border-radius:8px; font-size:13px; font-family:inherit; outline:none; } +.form-group input:focus, .form-group select:focus, .form-group textarea:focus { border-color:var(--primary); box-shadow:var(--ring); } + +@media(max-width:768px) { + .user-bar { gap:8px; } + .user-name, .user-role-tag { display:none; } +} diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html index 5caa730..4ebeb08 100644 --- a/src/main/resources/static/index.html +++ b/src/main/resources/static/index.html @@ -10,7 +10,8 @@ "imports": { "vue": "https://unpkg.com/vue@3/dist/vue.esm-browser.prod.js", "marked": "https://cdn.jsdelivr.net/npm/marked@12/+esm", - "dompurify": "https://cdn.jsdelivr.net/npm/dompurify@3/+esm" + "dompurify": "https://cdn.jsdelivr.net/npm/dompurify@3/+esm", + "chart.js": "https://cdn.jsdelivr.net/npm/chart.js@4/+esm" } } diff --git a/src/main/resources/static/js/api.js b/src/main/resources/static/js/api.js index 13800c1..f839ca1 100644 --- a/src/main/resources/static/js/api.js +++ b/src/main/resources/static/js/api.js @@ -1,16 +1,27 @@ /** * 统一 API 请求层 * 封装所有后端接口调用,组件只需调用函数处理业务逻辑 + * P1: 所有请求自动附加 Bearer Token,401 时触发未登录事件 */ -import { API_BASE } from './utils.js' +import { API_BASE, authHeaders, clearTokens } from './utils.js' // ==================== 通用请求 ==================== +/** + * 处理 401 未授权响应 + */ +function handleUnauthorized() { + clearTokens() + window.dispatchEvent(new Event('auth:unauthorized')) +} + /** * GET 请求,返回 JSON */ async function getJSON(path) { - const res = await fetch(API_BASE + path) + const res = await fetch(API_BASE + path, { headers: authHeaders() }) + if (res.status === 401) { handleUnauthorized(); throw new Error('401') } + if (res.status === 403) throw new Error('403') return res.json() } @@ -20,9 +31,11 @@ async function getJSON(path) { async function postJSON(path, body) { const res = await fetch(API_BASE + path, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...authHeaders() }, body: JSON.stringify(body) }) + if (res.status === 401) { handleUnauthorized(); throw new Error('401') } + if (res.status === 403) throw new Error('403') return res.json() } @@ -32,9 +45,10 @@ async function postJSON(path, body) { async function deleteJSONWithBody(path, body) { const res = await fetch(API_BASE + path, { method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...authHeaders() }, body: JSON.stringify(body) }) + if (res.status === 401) { handleUnauthorized(); throw new Error('401') } return res.json() } @@ -44,9 +58,10 @@ async function deleteJSONWithBody(path, body) { async function putJSONWithBody(path, body) { const res = await fetch(API_BASE + path, { method: 'PUT', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...authHeaders() }, body: JSON.stringify(body) }) + if (res.status === 401) { handleUnauthorized(); throw new Error('401') } return res.json() } @@ -54,7 +69,8 @@ async function putJSONWithBody(path, body) { * DELETE 请求,返回 JSON(无 body) */ async function deleteJSON(path) { - const res = await fetch(API_BASE + path, { method: 'DELETE' }) + const res = await fetch(API_BASE + path, { method: 'DELETE', headers: authHeaders() }) + if (res.status === 401) { handleUnauthorized(); throw new Error('401') } return res.json() } @@ -70,7 +86,8 @@ async function putJSON(path, params) { .join('&') if (qs) url += (url.includes('?') ? '&' : '?') + qs } - const res = await fetch(url, { method: 'PUT' }) + const res = await fetch(url, { method: 'PUT', headers: authHeaders() }) + if (res.status === 401) { handleUnauthorized(); throw new Error('401') } return res.json() } @@ -80,8 +97,10 @@ async function putJSON(path, params) { async function postForm(path, formData) { const res = await fetch(API_BASE + path, { method: 'POST', + headers: authHeaders(), body: formData }) + if (res.status === 401) { handleUnauthorized(); throw new Error('401') } return res.json() } @@ -269,8 +288,6 @@ export function batchReprocessDocuments(ids) { return postJSON('/document/batch/reprocess', { ids }) } -/** - /** * 语义搜索 */ @@ -307,6 +324,10 @@ function postFormWithProgress(path, formData, onProgress) { const xhr = new XMLHttpRequest() xhr.open('POST', API_BASE + path) + // 附加认证头 + const token = localStorage.getItem('sb_token') + if (token) xhr.setRequestHeader('Authorization', 'Bearer ' + token) + // 上传进度监听 xhr.upload.addEventListener('progress', (e) => { if (e.lengthComputable && onProgress) { @@ -315,6 +336,11 @@ function postFormWithProgress(path, formData, onProgress) { }) xhr.addEventListener('load', () => { + if (xhr.status === 401) { + handleUnauthorized() + reject(new Error('401')) + return + } try { resolve(JSON.parse(xhr.responseText)) } catch (e) { @@ -351,7 +377,7 @@ export function uploadString(content, title, categoryId, tags) { if (tags) url += `&tags=${encodeURIComponent(tags)}` return fetch(API_BASE + url, { method: 'POST', - headers: { 'Content-Type': 'text/plain' }, + headers: { 'Content-Type': 'text/plain', ...authHeaders() }, body: content }).then(res => res.json()) } @@ -459,7 +485,7 @@ export function deleteConversation(conversationId) { * 导出会话记录 */ export function exportConversation(conversationId) { - return fetch(API_BASE + `/conversation/${conversationId}/export`) + return fetch(API_BASE + `/conversation/${conversationId}/export`, { headers: authHeaders() }) .then(res => res.text()) } @@ -599,10 +625,10 @@ export function listAuditLogs(page = 1, size = 20, sessionId) { /** * FAQ 分页列表 */ -export function listFaqs(page = 1, size = 20, keyword, category, status) { +export function listFaqs(page = 1, size = 20, keyword, categoryId, status) { let path = `/faq/list?page=${page}&size=${size}` if (keyword) path += `&keyword=${encodeURIComponent(keyword)}` - if (category) path += `&category=${encodeURIComponent(category)}` + if (categoryId) path += `&categoryId=${categoryId}` if (status) path += `&status=${encodeURIComponent(status)}` return getJSON(path) } @@ -655,3 +681,212 @@ export function exportFaqs() { export function getFaqStats() { return getJSON('/faq/stats') } + +// ==================== P1-001: 认证管理 ==================== + +/** + * 用户登录 + */ +export function login(username, password) { + return postJSON('/auth/login', { username, password }) +} + +/** + * 刷新 Token + */ +export function refreshToken(refreshToken) { + return postJSON('/auth/refresh', { refreshToken }) +} + +/** + * 获取当前登录用户信息 + */ +export function getCurrentUser() { + return getJSON('/auth/me') +} + +// ==================== P1-001: 系统用户管理 ==================== + +/** + * 系统用户分页列表 + */ +export function listSysUsers(page = 1, size = 20, keyword, enabled) { + let path = `/sys-user/list?page=${page}&size=${size}` + if (keyword) path += `&keyword=${encodeURIComponent(keyword)}` + if (enabled !== undefined && enabled !== null) path += `&enabled=${enabled}` + return getJSON(path) +} + +/** + * 获取用户详情 + */ +export function getSysUserDetail(id) { + return getJSON(`/sys-user/${id}`) +} + +/** + * 创建系统用户 + */ +export function createSysUser(data) { + return postJSON('/sys-user', data) +} + +/** + * 更新系统用户 + */ +export function updateSysUser(id, data) { + return putJSONWithBody(`/sys-user/${id}`, data) +} + +/** + * 启用/禁用系统用户 + */ +export function toggleSysUser(id, enabled) { + return putJSON(`/sys-user/${id}/toggle`, { enabled }) +} + +/** + * 分配角色 + */ +export function assignSysUserRoles(id, roleIds) { + return putJSONWithBody(`/sys-user/${id}/roles`, { roleIds }) +} + +/** + * 修改密码 + */ +export function changeSysUserPassword(id, newPassword) { + return putJSONWithBody(`/sys-user/${id}/password`, { newPassword }) +} + +/** + * 获取所有角色(分配角色下拉用) + */ +export function getAllSysRoles() { + return getJSON('/sys-user/roles') +} + +/** + * 获取系统角色列表 + */ +export function listSysRoles() { + return getJSON('/sys-role/list') +} + +/** + * 创建系统角色 + */ +export function createSysRole(data) { + return postJSON('/sys-role', data) +} + +/** + * 更新系统角色 + */ +export function updateSysRole(id, data) { + return putJSONWithBody(`/sys-role/${id}`, data) +} + +// ==================== P1-002: 运营看板 ==================== + +/** + * 看板概览(今日实时指标) + */ +export function getDashboardOverview() { + return getJSON('/dashboard/overview') +} + +/** + * 趋势数据(7/30天) + */ +export function getDashboardTrend(days = 7) { + return getJSON(`/dashboard/trend?days=${days}`) +} + +/** + * 知识库分析(命中TOP-10 + 未命中问题) + */ +export function getDashboardKnowledge() { + return getJSON('/dashboard/knowledge-analysis') +} + +/** + * 自定义时间范围数据 + */ +export function getDashboardCustom(startDate, endDate) { + return getJSON(`/dashboard/custom?startDate=${encodeURIComponent(startDate)}&endDate=${encodeURIComponent(endDate)}`) +} + +// ==================== P1-003: API Key 管理 ==================== + +/** + * API Key 列表 + */ +export function listApiKeys(page = 1, size = 20) { + return getJSON(`/api-key/list?page=${page}&size=${size}`) +} + +/** + * 创建 API Key + */ +export function createApiKey(data) { + return postJSON('/api-key', data) +} + +/** + * 吊销 API Key + */ +export function revokeApiKey(id) { + return putJSON(`/api-key/${id}/revoke`) +} + +/** + * 启用 API Key + */ +export function enableApiKey(id) { + return putJSON(`/api-key/${id}/enable`) +} + +/** + * 删除 API Key + */ +export function deleteApiKey(id) { + return deleteJSON(`/api-key/${id}`) +} + +// ==================== P1-003: Webhook 管理 ==================== + +/** + * Webhook 列表 + */ +export function listWebhooks(page = 1, size = 20) { + return getJSON(`/webhook/list?page=${page}&size=${size}`) +} + +/** + * 创建 Webhook + */ +export function createWebhook(data) { + return postJSON('/webhook', data) +} + +/** + * 更新 Webhook + */ +export function updateWebhook(id, data) { + return putJSONWithBody(`/webhook/${id}`, data) +} + +/** + * 删除 Webhook + */ +export function deleteWebhook(id) { + return deleteJSON(`/webhook/${id}`) +} + +/** + * 测试 Webhook 推送 + */ +export function testWebhook(id) { + return postJSON(`/webhook/${id}/test`, {}) +} diff --git a/src/main/resources/static/js/app.js b/src/main/resources/static/js/app.js index c03bcf3..9e8bc95 100644 --- a/src/main/resources/static/js/app.js +++ b/src/main/resources/static/js/app.js @@ -1,9 +1,12 @@ /** * Vue 应用入口 * 侧边栏导航 + 内容区布局,数据驱动菜单渲染 + * P1: 登录守卫 + 用户信息展示 + 新页面注册 */ import { createApp } from 'vue' import { store, MENU_ITEMS } from './store.js' +import { isLoggedIn, clearTokens, getUserInfo, toast, getRefreshToken } from './utils.js' +import { getCurrentUser, refreshToken } from './api.js' // 导入组件 import ChatPanel from '../components/ChatPanel.js' @@ -20,6 +23,11 @@ import ModelConfigManager from '../components/ModelConfigManager.js' import SensitiveWordManager from '../components/SensitiveWordManager.js' import FaqManager from '../components/FaqManager.js' import AuditLogManager from '../components/AuditLogManager.js' +import LoginPage from '../components/LoginPage.js' +import UserManager from '../components/UserManager.js' +import DashboardPanel from '../components/DashboardPanel.js' +import ApiKeyManager from '../components/ApiKeyManager.js' +import WebhookManager from '../components/WebhookManager.js' const app = createApp({ setup() { @@ -31,148 +39,256 @@ const app = createApp({ return parentItem.children.some(c => c.id === store.activePage) } - return { menuItems, isMenuActive, store } + /** 判断菜单项是否对当前用户可见(roles 限制) */ + function isMenuVisible(item) { + if (!item.roles || !item.roles.length) return true + return item.roles.some(r => store.hasRole(r)) + } + + /** 登录成功回调 */ + function onLoginSuccess(user) { + store.currentUser = user || getUserInfo() + store.isLoggedIn = true + } + + /** 退出登录 */ + function handleLogout() { + clearTokens() + store.isLoggedIn = false + store.currentUser = {} + store.activePage = 'chat' + toast('已退出登录', 'info') + } + + /** 尝试刷新 Token */ + async function tryRefreshToken() { + const rt = getRefreshToken() + if (!rt) return false + try { + const res = await refreshToken(rt) + if (res.success && res.data) { + const { setToken: st, setRefreshToken: srt } = await import('./utils.js') + st(res.data.accessToken) + srt(res.data.refreshToken) + return true + } + } catch (e) { /* 刷新失败 */ } + return false + } + + // 监听 401 未授权事件 + window.addEventListener('auth:unauthorized', async () => { + const refreshed = await tryRefreshToken() + if (!refreshed) { + store.isLoggedIn = false + store.currentUser = {} + toast('登录已过期,请重新登录', 'error') + } + }) + + // 启动时验证登录态 + if (isLoggedIn()) { + getCurrentUser().then(res => { + if (res.success && res.data) { + store.currentUser = res.data + store.isLoggedIn = true + } else { + // Token 失效,尝试刷新 + tryRefreshToken().then(ok => { + if (!ok) { + store.isLoggedIn = false + clearTokens() + } + }) + } + }).catch(() => { + // 401 等错误,刷新尝试 + tryRefreshToken().then(ok => { + if (!ok) { + store.isLoggedIn = false + clearTokens() + } + }) + }) + } + + return { menuItems, isMenuActive, isMenuVisible, store, onLoginSuccess, handleLogout } }, template: ` - - - - -
- - AI 智能客服系统 - - 🧪 SDK 测试面板 - 📖 API 文档 - -
- - -
- - -