Browse Source

用户认证/运营看板/API开放平台

dev-mcp
wanghanlin 4 weeks ago
parent
commit
dec3d19f3c
  1. 25
      pom.xml
  2. 4
      src/main/java/com/wok/supportbot/SupportBotApplication.java
  3. 38
      src/main/java/com/wok/supportbot/app/AssistantApp.java
  4. 162
      src/main/java/com/wok/supportbot/auth/AuthController.java
  5. 350
      src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java
  6. 8
      src/main/java/com/wok/supportbot/controller/AiModelConfigController.java
  7. 183
      src/main/java/com/wok/supportbot/controller/ApiKeyController.java
  8. 36
      src/main/java/com/wok/supportbot/controller/ConversationController.java
  9. 6
      src/main/java/com/wok/supportbot/controller/CustomerAccountController.java
  10. 7
      src/main/java/com/wok/supportbot/controller/CustomerServiceRoleController.java
  11. 101
      src/main/java/com/wok/supportbot/controller/DashboardController.java
  12. 22
      src/main/java/com/wok/supportbot/controller/DocumentController.java
  13. 14
      src/main/java/com/wok/supportbot/controller/FaqController.java
  14. 4
      src/main/java/com/wok/supportbot/controller/MessageFeedbackController.java
  15. 149
      src/main/java/com/wok/supportbot/controller/OpenApiController.java
  16. 7
      src/main/java/com/wok/supportbot/controller/SensitiveWordController.java
  17. 85
      src/main/java/com/wok/supportbot/controller/SysRoleController.java
  18. 197
      src/main/java/com/wok/supportbot/controller/SysUserController.java
  19. 190
      src/main/java/com/wok/supportbot/controller/WebhookController.java
  20. 12
      src/main/java/com/wok/supportbot/dao/ApiKeyMapper.java
  21. 12
      src/main/java/com/wok/supportbot/dao/DashboardSnapshotMapper.java
  22. 12
      src/main/java/com/wok/supportbot/dao/RagHitLogMapper.java
  23. 12
      src/main/java/com/wok/supportbot/dao/SysPermissionMapper.java
  24. 12
      src/main/java/com/wok/supportbot/dao/SysRoleMapper.java
  25. 12
      src/main/java/com/wok/supportbot/dao/SysUserMapper.java
  26. 12
      src/main/java/com/wok/supportbot/dao/SysUserRoleMapper.java
  27. 12
      src/main/java/com/wok/supportbot/dao/WebhookConfigMapper.java
  28. 83
      src/main/java/com/wok/supportbot/entity/ApiKey.java
  29. 9
      src/main/java/com/wok/supportbot/entity/ChatMessage.java
  30. 92
      src/main/java/com/wok/supportbot/entity/DashboardSnapshot.java
  31. 9
      src/main/java/com/wok/supportbot/entity/KnowledgeFaq.java
  32. 66
      src/main/java/com/wok/supportbot/entity/RagHitLog.java
  33. 54
      src/main/java/com/wok/supportbot/entity/SysPermission.java
  34. 62
      src/main/java/com/wok/supportbot/entity/SysRole.java
  35. 83
      src/main/java/com/wok/supportbot/entity/SysUser.java
  36. 45
      src/main/java/com/wok/supportbot/entity/SysUserRole.java
  37. 73
      src/main/java/com/wok/supportbot/entity/WebhookConfig.java
  38. 113
      src/main/java/com/wok/supportbot/openapi/ApiKeyAuthFilter.java
  39. 106
      src/main/java/com/wok/supportbot/openapi/SlidingWindowRateLimiter.java
  40. 65
      src/main/java/com/wok/supportbot/security/JwtAuthFilter.java
  41. 108
      src/main/java/com/wok/supportbot/security/JwtTokenProvider.java
  42. 115
      src/main/java/com/wok/supportbot/security/SecurityConfig.java
  43. 170
      src/main/java/com/wok/supportbot/service/ApiKeyService.java
  44. 16
      src/main/java/com/wok/supportbot/service/ConversationService.java
  45. 352
      src/main/java/com/wok/supportbot/service/DashboardService.java
  46. 4
      src/main/java/com/wok/supportbot/service/FaqMatchEngine.java
  47. 11
      src/main/java/com/wok/supportbot/service/FaqService.java
  48. 84
      src/main/java/com/wok/supportbot/service/RagHitLogService.java
  49. 73
      src/main/java/com/wok/supportbot/service/SysRoleService.java
  50. 270
      src/main/java/com/wok/supportbot/service/SysUserService.java
  51. 191
      src/main/java/com/wok/supportbot/service/WebhookService.java
  52. 13
      src/main/resources/application.yml
  53. 250
      src/main/resources/static/components/ApiKeyManager.js
  54. 340
      src/main/resources/static/components/DashboardPanel.js
  55. 79
      src/main/resources/static/components/FaqManager.js
  56. 71
      src/main/resources/static/components/LoginPage.js
  57. 315
      src/main/resources/static/components/UserManager.js
  58. 216
      src/main/resources/static/components/WebhookManager.js
  59. 83
      src/main/resources/static/css/main.css
  60. 3
      src/main/resources/static/index.html
  61. 261
      src/main/resources/static/js/api.js
  62. 361
      src/main/resources/static/js/app.js
  63. 43
      src/main/resources/static/js/store.js
  64. 55
      src/main/resources/static/js/utils.js

25
pom.xml

@ -150,6 +150,31 @@
<groupId>org.springframework.ai</groupId> <groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-tika-document-reader</artifactId> <artifactId>spring-ai-tika-document-reader</artifactId>
</dependency> </dependency>
<!-- Spring Security 认证授权框架 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- JWT(JJWT 库):用于无状态 Token 认证 -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<!-- Spring AI ToolCallingAutoConfiguration 依赖 spring-security-oauth2-client,补充该依赖避免 ClassNotFoundException --> <!-- Spring AI ToolCallingAutoConfiguration 依赖 spring-security-oauth2-client,补充该依赖避免 ClassNotFoundException -->
<dependency> <dependency>
<groupId>org.springframework.security</groupId> <groupId>org.springframework.security</groupId>

4
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.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; 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 * 确保 ToolCallingAutoConfiguration 不会抛出 ClassNotFoundException
*/ */
@SpringBootApplication @SpringBootApplication
@EnableScheduling
@EnableAsync
public class SupportBotApplication { public class SupportBotApplication {
public static void main(String[] args) { public static void main(String[] args) {

38
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.MultiQueryExpanderRewriter;
import com.wok.supportbot.rag.preretrieval.RewriteQueryRewriter; import com.wok.supportbot.rag.preretrieval.RewriteQueryRewriter;
import com.wok.supportbot.rag.preretrieval.TranslationQueryRewriter; import com.wok.supportbot.rag.preretrieval.TranslationQueryRewriter;
import com.wok.supportbot.service.FaqMatchEngine;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.client.ChatClient;
@ -38,6 +39,7 @@ import java.util.Collections;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@ -60,6 +62,9 @@ public class AssistantApp {
@Resource @Resource
private ContentSafetyAdvisor contentSafetyAdvisor; private ContentSafetyAdvisor contentSafetyAdvisor;
@Resource
private FaqMatchEngine faqMatchEngine;
private final ChatModelFactory chatModelFactory; private final ChatModelFactory chatModelFactory;
private final DatabaseChatMemory chatMemory; private final DatabaseChatMemory chatMemory;
@ -170,6 +175,25 @@ public class AssistantApp {
return spec.stream().content(); return spec.stream().content();
} }
// ==================== FAQ 优先匹配 ====================
/**
* 尝试 FAQ 三级匹配精确关键词语义命中则返回标准答案
* RAG 对话入口处优先调用避免不必要的知识库检索开销
*
* @param message 用户消息
* @return FAQ 标准答案未命中时返回 empty
*/
private Optional<String> 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 恋爱知识库问答功能 // AI 恋爱知识库问答功能
@Resource @Resource
RewriteQueryRewriter rewriteQueryRewriter; RewriteQueryRewriter rewriteQueryRewriter;
@ -226,6 +250,13 @@ public class AssistantApp {
} }
public String doChatWithRagStrategy(String message, String chatId, String strategy, List<Long> categoryIds, String systemPrompt) { public String doChatWithRagStrategy(String message, String chatId, String strategy, List<Long> categoryIds, String systemPrompt) {
// FAQ 优先匹配三级匹配精确关键词语义命中则直接返回标准答案
Optional<String> faqAnswer = tryFaqMatch(message);
if (faqAnswer.isPresent()) {
log.info("FAQ 命中,直接返回标准答案: chatId={}", chatId);
return faqAnswer.get();
}
// 对于 MULTI_QUERY 策略需要使用特殊的处理方式 // 对于 MULTI_QUERY 策略需要使用特殊的处理方式
if ("MULTI_QUERY".equalsIgnoreCase(strategy)) { if ("MULTI_QUERY".equalsIgnoreCase(strategy)) {
return doChatWithMultiQueryRag(message, chatId, categoryIds, systemPrompt); return doChatWithMultiQueryRag(message, chatId, categoryIds, systemPrompt);
@ -282,6 +313,13 @@ public class AssistantApp {
*/ */
public Flux<String> doChatWithRagStrategyByStream(String message, String chatId, String strategy, public Flux<String> doChatWithRagStrategyByStream(String message, String chatId, String strategy,
List<Long> categoryIds, String systemPrompt) { List<Long> categoryIds, String systemPrompt) {
// FAQ 优先匹配命中则直接以流式形式返回标准答案
Optional<String> faqAnswer = tryFaqMatch(message);
if (faqAnswer.isPresent()) {
log.info("FAQ 命中(流式),直接返回标准答案: chatId={}", chatId);
return Flux.just(faqAnswer.get());
}
// 对于 MULTI_QUERY 策略需要先手动检索合并再流式生成 // 对于 MULTI_QUERY 策略需要先手动检索合并再流式生成
if ("MULTI_QUERY".equalsIgnoreCase(strategy)) { if ("MULTI_QUERY".equalsIgnoreCase(strategy)) {
return doChatWithMultiQueryRagByStream(message, chatId, categoryIds, systemPrompt); return doChatWithMultiQueryRagByStream(message, chatId, categoryIds, systemPrompt);

162
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<Map<String, Object>> login(@RequestBody Map<String, String> 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<SysRole> roles = user.getRoles();
List<String> roleKeys = roles.stream().map(SysRole::getRoleKey).toList();
// 生成 Token
String accessToken = jwtTokenProvider.generateToken(username, roleKeys);
String refreshToken = jwtTokenProvider.generateRefreshToken(username);
// 更新最后登录时间
sysUserService.updateLastLoginTime(user.getId());
// 构建用户信息
Map<String, Object> 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<Map<String, Object>> refresh(@RequestBody Map<String, String> 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<String> 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<Map<String, Object>> 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<String> roleKeys = user.getRoles().stream().map(SysRole::getRoleKey).toList();
Map<String, Object> 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));
}
}

350
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.Autowired;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
/** /**
@ -18,6 +19,9 @@ public class DatabaseInitConfig {
@Autowired @Autowired
private JdbcTemplate jdbcTemplate; private JdbcTemplate jdbcTemplate;
@Autowired(required = false)
private PasswordEncoder passwordEncoder;
@Value("${knowledge.vector.dimension:1024}") @Value("${knowledge.vector.dimension:1024}")
private int vectorDimension; private int vectorDimension;
@ -101,12 +105,60 @@ public class DatabaseInitConfig {
if (!checkTableExists("knowledge_faq")) { if (!checkTableExists("knowledge_faq")) {
log.info("创建 FAQ 知识库表 knowledge_faq"); log.info("创建 FAQ 知识库表 knowledge_faq");
createKnowledgeFaqTable(); createKnowledgeFaqTable();
} else {
// 自动添加 category_id 引用文档管理分类
addFaqCategoryIdColumn();
} }
if (!checkTableExists("faq_embedding")) { if (!checkTableExists("faq_embedding")) {
log.info("创建 FAQ 向量索引表 faq_embedding"); log.info("创建 FAQ 向量索引表 faq_embedding");
createFaqEmbeddingTable(); 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 添加全文检索列 // P0-001: 混合检索 - vector_store 添加全文检索列
initVectorStoreFullTextSearch(); initVectorStoreFullTextSearch();
@ -457,6 +509,7 @@ public class DatabaseInitConfig {
answer TEXT NOT NULL, answer TEXT NOT NULL,
similar_questions TEXT DEFAULT '[]' NOT NULL, similar_questions TEXT DEFAULT '[]' NOT NULL,
category VARCHAR(128), category VARCHAR(128),
category_id BIGINT,
status VARCHAR(20) NOT NULL DEFAULT 'ENABLED', status VARCHAR(20) NOT NULL DEFAULT 'ENABLED',
priority INTEGER NOT NULL DEFAULT 0, priority INTEGER NOT NULL DEFAULT 0,
hit_count BIGINT NOT NULL DEFAULT 0, hit_count BIGINT NOT NULL DEFAULT 0,
@ -469,6 +522,7 @@ public class DatabaseInitConfig {
jdbcTemplate.execute(sql); 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_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 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)"); 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)"); 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: 混合检索 - 全文检索 ==================== // ==================== 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 * 为所有自动创建的表添加注释COMMENT ON
* 所有语句均为幂等操作可安全重复执行 * 所有语句均为幂等操作可安全重复执行
@ -669,7 +969,8 @@ public class DatabaseInitConfig {
executeComment("COLUMN knowledge_faq.question", "标准问题"); executeComment("COLUMN knowledge_faq.question", "标准问题");
executeComment("COLUMN knowledge_faq.answer", "标准答案"); executeComment("COLUMN knowledge_faq.answer", "标准答案");
executeComment("COLUMN knowledge_faq.similar_questions", "相似问题列表(JSON 字符串数组)"); 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.status", "状态: ENABLED(启用) / DISABLED(禁用)");
executeComment("COLUMN knowledge_faq.priority", "优先级(数值越大越优先匹配)"); executeComment("COLUMN knowledge_faq.priority", "优先级(数值越大越优先匹配)");
executeComment("COLUMN knowledge_faq.hit_count", "命中次数统计"); executeComment("COLUMN knowledge_faq.hit_count", "命中次数统计");
@ -686,6 +987,53 @@ public class DatabaseInitConfig {
executeComment("COLUMN faq_embedding.model_name", "使用的 Embedding 模型名称"); executeComment("COLUMN faq_embedding.model_name", "使用的 Embedding 模型名称");
executeComment("COLUMN faq_embedding.create_time", "创建时间"); 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("数据库表注释已应用"); log.info("数据库表注释已应用");
} catch (Exception e) { } catch (Exception e) {
log.warn("应用数据库表注释时出错", e); log.warn("应用数据库表注释时出错", e);

8
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 com.wok.supportbot.service.AiModelConfigService;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.Map; import java.util.Map;
@ -41,6 +42,7 @@ public class AiModelConfigController {
* @return 分页配置列表 * @return 分页配置列表
*/ */
@GetMapping("/model-config/list") @GetMapping("/model-config/list")
@PreAuthorize("hasRole('admin')")
public ResponseEntity<Map<String, Object>> listConfigs( public ResponseEntity<Map<String, Object>> listConfigs(
@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size, @RequestParam(defaultValue = "10") int size,
@ -72,6 +74,7 @@ public class AiModelConfigController {
* @return 配置详情 * @return 配置详情
*/ */
@GetMapping("/model-config/{id}") @GetMapping("/model-config/{id}")
@PreAuthorize("hasRole('admin')")
public ResponseEntity<Map<String, Object>> getConfigDetail(@PathVariable("id") Long id) { public ResponseEntity<Map<String, Object>> getConfigDetail(@PathVariable("id") Long id) {
try { try {
AiModelConfig config = aiModelConfigService.getConfigDetail(id); AiModelConfig config = aiModelConfigService.getConfigDetail(id);
@ -102,6 +105,7 @@ public class AiModelConfigController {
* @return 活跃配置 * @return 活跃配置
*/ */
@GetMapping("/model-config/active/{appType}") @GetMapping("/model-config/active/{appType}")
@PreAuthorize("hasRole('admin')")
public ResponseEntity<Map<String, Object>> getActiveConfig(@PathVariable("appType") String appType) { public ResponseEntity<Map<String, Object>> getActiveConfig(@PathVariable("appType") String appType) {
try { try {
AiModelConfig config = aiModelConfigService.getActiveConfig(appType); AiModelConfig config = aiModelConfigService.getActiveConfig(appType);
@ -132,6 +136,7 @@ public class AiModelConfigController {
* @return 创建结果 * @return 创建结果
*/ */
@PostMapping("/model-config") @PostMapping("/model-config")
@PreAuthorize("hasRole('admin')")
public ResponseEntity<Map<String, Object>> createConfig(@RequestBody AiModelConfig config) { public ResponseEntity<Map<String, Object>> createConfig(@RequestBody AiModelConfig config) {
try { try {
// 参数校验 // 参数校验
@ -185,6 +190,7 @@ public class AiModelConfigController {
* @return 更新结果 * @return 更新结果
*/ */
@PutMapping("/model-config/{id}") @PutMapping("/model-config/{id}")
@PreAuthorize("hasRole('admin')")
public ResponseEntity<Map<String, Object>> updateConfig( public ResponseEntity<Map<String, Object>> updateConfig(
@PathVariable("id") Long id, @PathVariable("id") Long id,
@RequestBody AiModelConfig config) { @RequestBody AiModelConfig config) {
@ -218,6 +224,7 @@ public class AiModelConfigController {
* @return 激活结果 * @return 激活结果
*/ */
@PutMapping("/model-config/{id}/activate") @PutMapping("/model-config/{id}/activate")
@PreAuthorize("hasRole('admin')")
public ResponseEntity<Map<String, Object>> activateConfig(@PathVariable("id") Long id) { public ResponseEntity<Map<String, Object>> activateConfig(@PathVariable("id") Long id) {
try { try {
aiModelConfigService.activateConfig(id); aiModelConfigService.activateConfig(id);
@ -248,6 +255,7 @@ public class AiModelConfigController {
* @return 删除结果 * @return 删除结果
*/ */
@DeleteMapping("/model-config/{id}") @DeleteMapping("/model-config/{id}")
@PreAuthorize("hasRole('admin')")
public ResponseEntity<Map<String, Object>> deleteConfig(@PathVariable("id") Long id) { public ResponseEntity<Map<String, Object>> deleteConfig(@PathVariable("id") Long id) {
try { try {
aiModelConfigService.deleteConfig(id); aiModelConfigService.deleteConfig(id);

183
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<Map<String, Object>> list(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
try {
Map<String, Object> result = apiKeyService.listKeys(page, size);
Map<String, Object> 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<Map<String, Object>> create(@RequestBody Map<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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()
));
}
}
}

36
src/main/java/com/wok/supportbot/controller/ConversationController.java

@ -1,9 +1,13 @@
package com.wok.supportbot.controller; package com.wok.supportbot.controller;
import com.wok.supportbot.service.ConversationService; import com.wok.supportbot.service.ConversationService;
import com.wok.supportbot.service.SysUserService;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; 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 org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.List;
@ -19,6 +23,9 @@ public class ConversationController {
@Autowired @Autowired
private ConversationService conversationService; private ConversationService conversationService;
@Autowired(required = false)
private SysUserService sysUserService;
// ==================== 会话列表 ==================== // ==================== 会话列表 ====================
/** /**
@ -30,6 +37,7 @@ public class ConversationController {
* @return 分页会话列表 * @return 分页会话列表
*/ */
@GetMapping("/conversation/list") @GetMapping("/conversation/list")
@PreAuthorize("hasAnyRole('admin','cs_agent','viewer','kb_operator')")
public ResponseEntity<Map<String, Object>> listConversations( public ResponseEntity<Map<String, Object>> listConversations(
@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size, @RequestParam(defaultValue = "10") int size,
@ -37,7 +45,9 @@ public class ConversationController {
@RequestParam(required = false) String accountId, @RequestParam(required = false) String accountId,
@RequestParam(required = false) Long roleId) { @RequestParam(required = false) Long roleId) {
try { try {
Map<String, Object> result = conversationService.listConversations(page, size, keyword, accountId, roleId);
// 获取当前登录用户ID用于数据隔离SDK调用时为null
Long userId = getCurrentUserId();
Map<String, Object> result = conversationService.listConversations(page, size, keyword, accountId, roleId, userId);
Map<String, Object> data = new java.util.HashMap<>(); Map<String, Object> data = new java.util.HashMap<>();
data.put("success", true); data.put("success", true);
data.put("data", result.get("records")); data.put("data", result.get("records"));
@ -64,6 +74,7 @@ public class ConversationController {
* @return 逻辑删除的消息条数 * @return 逻辑删除的消息条数
*/ */
@PostMapping("/conversation/{id}/truncate") @PostMapping("/conversation/{id}/truncate")
@PreAuthorize("hasAnyRole('admin','cs_agent')")
public ResponseEntity<Map<String, Object>> truncateConversation( public ResponseEntity<Map<String, Object>> truncateConversation(
@PathVariable("id") String conversationId, @PathVariable("id") String conversationId,
@RequestBody Map<String, Object> body) { @RequestBody Map<String, Object> body) {
@ -90,6 +101,7 @@ public class ConversationController {
* @return 会话详情 * @return 会话详情
*/ */
@GetMapping("/conversation/{id}") @GetMapping("/conversation/{id}")
@PreAuthorize("hasAnyRole('admin','cs_agent','viewer','kb_operator')")
public ResponseEntity<Map<String, Object>> getConversationDetail(@PathVariable("id") String conversationId) { public ResponseEntity<Map<String, Object>> getConversationDetail(@PathVariable("id") String conversationId) {
try { try {
Map<String, Object> detail = conversationService.getConversationDetail(conversationId); Map<String, Object> detail = conversationService.getConversationDetail(conversationId);
@ -118,6 +130,7 @@ public class ConversationController {
* @return 消息列表 * @return 消息列表
*/ */
@GetMapping("/conversation/{id}/messages") @GetMapping("/conversation/{id}/messages")
@PreAuthorize("hasAnyRole('admin','cs_agent','viewer','kb_operator')")
public ResponseEntity<Map<String, Object>> getConversationMessages(@PathVariable("id") String conversationId) { public ResponseEntity<Map<String, Object>> getConversationMessages(@PathVariable("id") String conversationId) {
try { try {
List<Map<String, Object>> messages = conversationService.getConversationMessages(conversationId); List<Map<String, Object>> messages = conversationService.getConversationMessages(conversationId);
@ -143,6 +156,7 @@ public class ConversationController {
* @return 删除结果 * @return 删除结果
*/ */
@DeleteMapping("/conversation/{id}") @DeleteMapping("/conversation/{id}")
@PreAuthorize("hasAnyRole('admin','cs_agent')")
public ResponseEntity<Map<String, Object>> deleteConversation(@PathVariable("id") String conversationId) { public ResponseEntity<Map<String, Object>> deleteConversation(@PathVariable("id") String conversationId) {
try { try {
int count = conversationService.deleteConversation(conversationId); int count = conversationService.deleteConversation(conversationId);
@ -168,6 +182,7 @@ public class ConversationController {
* @return 格式化后的会话文本 * @return 格式化后的会话文本
*/ */
@GetMapping(value = "/conversation/{id}/export", produces = MediaType.TEXT_PLAIN_VALUE + ";charset=UTF-8") @GetMapping(value = "/conversation/{id}/export", produces = MediaType.TEXT_PLAIN_VALUE + ";charset=UTF-8")
@PreAuthorize("hasAnyRole('admin','cs_agent','viewer','kb_operator')")
public ResponseEntity<String> exportConversation(@PathVariable("id") String conversationId) { public ResponseEntity<String> exportConversation(@PathVariable("id") String conversationId) {
try { try {
String content = conversationService.exportConversation(conversationId); String content = conversationService.exportConversation(conversationId);
@ -187,6 +202,7 @@ public class ConversationController {
* @return 统计信息 * @return 统计信息
*/ */
@GetMapping("/conversation/stats") @GetMapping("/conversation/stats")
@PreAuthorize("hasAnyRole('admin','cs_agent','viewer','kb_operator')")
public ResponseEntity<Map<String, Object>> getConversationStats() { public ResponseEntity<Map<String, Object>> getConversationStats() {
try { try {
Map<String, Object> stats = conversationService.getConversationStats(); Map<String, Object> stats = conversationService.getConversationStats();
@ -202,4 +218,22 @@ public class ConversationController {
} }
} }
/**
* SecurityContext 获取当前登录用户的系统用户ID
* 未登录时返回 nullSDK 调用场景
*/
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;
}
} }

6
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 com.wok.supportbot.service.CustomerAccountService;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity; 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.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
@ -20,6 +21,7 @@ public class CustomerAccountController {
private CustomerAccountService customerAccountService; private CustomerAccountService customerAccountService;
@GetMapping("/account/list") @GetMapping("/account/list")
@PreAuthorize("hasRole('admin')")
public ResponseEntity<Map<String, Object>> listAccounts() { public ResponseEntity<Map<String, Object>> listAccounts() {
return ResponseEntity.ok(Map.of( return ResponseEntity.ok(Map.of(
"success", true, "success", true,
@ -28,6 +30,7 @@ public class CustomerAccountController {
} }
@GetMapping("/account/all") @GetMapping("/account/all")
@PreAuthorize("hasRole('admin')")
public ResponseEntity<Map<String, Object>> listAllAccounts() { public ResponseEntity<Map<String, Object>> listAllAccounts() {
return ResponseEntity.ok(Map.of( return ResponseEntity.ok(Map.of(
"success", true, "success", true,
@ -36,6 +39,7 @@ public class CustomerAccountController {
} }
@PostMapping("/account") @PostMapping("/account")
@PreAuthorize("hasRole('admin')")
public ResponseEntity<Map<String, Object>> createAccount(@RequestBody Map<String, Object> body) { public ResponseEntity<Map<String, Object>> createAccount(@RequestBody Map<String, Object> body) {
try { try {
customerAccountService.createAccount( customerAccountService.createAccount(
@ -48,6 +52,7 @@ public class CustomerAccountController {
} }
@PutMapping("/account/{id}") @PutMapping("/account/{id}")
@PreAuthorize("hasRole('admin')")
public ResponseEntity<Map<String, Object>> updateAccount(@PathVariable("id") Long accountId, @RequestBody Map<String, Object> body) { public ResponseEntity<Map<String, Object>> updateAccount(@PathVariable("id") Long accountId, @RequestBody Map<String, Object> body) {
try { try {
customerAccountService.updateAccount(accountId, customerAccountService.updateAccount(accountId,
@ -60,6 +65,7 @@ public class CustomerAccountController {
} }
@DeleteMapping("/account/{id}") @DeleteMapping("/account/{id}")
@PreAuthorize("hasRole('admin')")
public ResponseEntity<Map<String, Object>> deleteAccount(@PathVariable("id") Long accountId) { public ResponseEntity<Map<String, Object>> deleteAccount(@PathVariable("id") Long accountId) {
try { try {
customerAccountService.deleteAccount(accountId); customerAccountService.deleteAccount(accountId);

7
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 com.wok.supportbot.service.CustomerServiceRoleService;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity; 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.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
@ -22,6 +23,7 @@ public class CustomerServiceRoleController {
private CustomerServiceRoleService customerServiceRoleService; private CustomerServiceRoleService customerServiceRoleService;
@GetMapping("/role/list") @GetMapping("/role/list")
@PreAuthorize("hasRole('admin')")
public ResponseEntity<Map<String, Object>> listRoles() { public ResponseEntity<Map<String, Object>> listRoles() {
return ResponseEntity.ok(Map.of( return ResponseEntity.ok(Map.of(
"success", true, "success", true,
@ -33,6 +35,7 @@ public class CustomerServiceRoleController {
* 管理用列出全部角色含已停用 * 管理用列出全部角色含已停用
*/ */
@GetMapping("/role/all") @GetMapping("/role/all")
@PreAuthorize("hasRole('admin')")
public ResponseEntity<Map<String, Object>> listAllRoles() { public ResponseEntity<Map<String, Object>> listAllRoles() {
return ResponseEntity.ok(Map.of( return ResponseEntity.ok(Map.of(
"success", true, "success", true,
@ -41,6 +44,7 @@ public class CustomerServiceRoleController {
} }
@PostMapping("/role") @PostMapping("/role")
@PreAuthorize("hasRole('admin')")
public ResponseEntity<Map<String, Object>> createRole(@RequestBody Map<String, Object> body) { public ResponseEntity<Map<String, Object>> createRole(@RequestBody Map<String, Object> body) {
try { try {
customerServiceRoleService.createRole( customerServiceRoleService.createRole(
@ -53,6 +57,7 @@ public class CustomerServiceRoleController {
} }
@PutMapping("/role/{id}") @PutMapping("/role/{id}")
@PreAuthorize("hasRole('admin')")
public ResponseEntity<Map<String, Object>> updateRole(@PathVariable("id") Long roleId, @RequestBody Map<String, Object> body) { public ResponseEntity<Map<String, Object>> updateRole(@PathVariable("id") Long roleId, @RequestBody Map<String, Object> body) {
try { try {
customerServiceRoleService.updateRole(roleId, customerServiceRoleService.updateRole(roleId,
@ -65,6 +70,7 @@ public class CustomerServiceRoleController {
} }
@DeleteMapping("/role/{id}") @DeleteMapping("/role/{id}")
@PreAuthorize("hasRole('admin')")
public ResponseEntity<Map<String, Object>> deleteRole(@PathVariable("id") Long roleId) { public ResponseEntity<Map<String, Object>> deleteRole(@PathVariable("id") Long roleId) {
try { try {
customerServiceRoleService.deleteRole(roleId); customerServiceRoleService.deleteRole(roleId);
@ -75,6 +81,7 @@ public class CustomerServiceRoleController {
} }
@PutMapping("/role/{id}/categories") @PutMapping("/role/{id}/categories")
@PreAuthorize("hasRole('admin')")
public ResponseEntity<Map<String, Object>> updateRoleCategories( public ResponseEntity<Map<String, Object>> updateRoleCategories(
@PathVariable("id") Long roleId, @PathVariable("id") Long roleId,
@RequestBody Map<String, Object> body) { @RequestBody Map<String, Object> body) {

101
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<Map<String, Object>> getOverview() {
try {
Map<String, Object> 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<Map<String, Object>> getTrend(
@RequestParam(defaultValue = "7") int days) {
try {
List<DashboardSnapshot> 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<Map<String, Object>> getKnowledgeAnalysis() {
try {
Map<String, Object> 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<Map<String, Object>> getCustomRange(
@RequestParam String startDate,
@RequestParam String endDate) {
try {
List<DashboardSnapshot> 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()
));
}
}
}

22
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 com.wok.supportbot.service.DocumentService;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
@ -74,6 +75,7 @@ public class DocumentController {
* @return 上传结果 * @return 上传结果
*/ */
@PostMapping("/upload/file") @PostMapping("/upload/file")
@PreAuthorize("hasAnyRole('admin','kb_operator')")
public ResponseEntity<Map<String, Object>> uploadFile( public ResponseEntity<Map<String, Object>> uploadFile(
@RequestParam("file") MultipartFile file, @RequestParam("file") MultipartFile file,
@RequestParam(required = false) String title, @RequestParam(required = false) String title,
@ -105,6 +107,7 @@ public class DocumentController {
* @return 上传结果 * @return 上传结果
*/ */
@PostMapping("/upload/string") @PostMapping("/upload/string")
@PreAuthorize("hasAnyRole('admin','kb_operator')")
public ResponseEntity<Map<String, Object>> uploadString( public ResponseEntity<Map<String, Object>> uploadString(
@RequestBody String content, @RequestBody String content,
@RequestParam String title, @RequestParam String title,
@ -129,6 +132,7 @@ public class DocumentController {
* 上传 Markdown 文件 * 上传 Markdown 文件
*/ */
@PostMapping("/upload/markdown") @PostMapping("/upload/markdown")
@PreAuthorize("hasAnyRole('admin','kb_operator')")
public ResponseEntity<Map<String, Object>> uploadMarkdown( public ResponseEntity<Map<String, Object>> uploadMarkdown(
@RequestParam("file") MultipartFile file, @RequestParam("file") MultipartFile file,
@RequestParam(required = false) String title, @RequestParam(required = false) String title,
@ -154,6 +158,7 @@ public class DocumentController {
* 上传 JSON 文件基本方式 * 上传 JSON 文件基本方式
*/ */
@PostMapping("/upload/json/basic") @PostMapping("/upload/json/basic")
@PreAuthorize("hasAnyRole('admin','kb_operator')")
public ResponseEntity<Map<String, Object>> uploadJsonBasic( public ResponseEntity<Map<String, Object>> uploadJsonBasic(
@RequestParam("file") MultipartFile file, @RequestParam("file") MultipartFile file,
@RequestParam(required = false) String title, @RequestParam(required = false) String title,
@ -179,6 +184,7 @@ public class DocumentController {
* 上传 JSON 文件按字段提取 * 上传 JSON 文件按字段提取
*/ */
@PostMapping("/upload/json/fields") @PostMapping("/upload/json/fields")
@PreAuthorize("hasAnyRole('admin','kb_operator')")
public ResponseEntity<Map<String, Object>> uploadJsonWithFields( public ResponseEntity<Map<String, Object>> uploadJsonWithFields(
@RequestParam("file") MultipartFile file, @RequestParam("file") MultipartFile file,
@RequestParam("fields") List<String> fields, @RequestParam("fields") List<String> fields,
@ -206,6 +212,7 @@ public class DocumentController {
* 上传 JSON 文件按指针拆分 * 上传 JSON 文件按指针拆分
*/ */
@PostMapping("/upload/json/pointer") @PostMapping("/upload/json/pointer")
@PreAuthorize("hasAnyRole('admin','kb_operator')")
public ResponseEntity<Map<String, Object>> uploadJsonWithPointer( public ResponseEntity<Map<String, Object>> uploadJsonWithPointer(
@RequestParam("file") MultipartFile file, @RequestParam("file") MultipartFile file,
@RequestParam("pointer") String pointer, @RequestParam("pointer") String pointer,
@ -241,6 +248,7 @@ public class DocumentController {
* @return 分页文档列表 * @return 分页文档列表
*/ */
@GetMapping("/document/list") @GetMapping("/document/list")
@PreAuthorize("hasAnyRole('admin','kb_operator')")
public ResponseEntity<Map<String, Object>> listDocuments( public ResponseEntity<Map<String, Object>> listDocuments(
@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size, @RequestParam(defaultValue = "10") int size,
@ -268,6 +276,7 @@ public class DocumentController {
* 获取文档详情 * 获取文档详情
*/ */
@GetMapping("/document/{id}") @GetMapping("/document/{id}")
@PreAuthorize("hasAnyRole('admin','kb_operator')")
public ResponseEntity<Map<String, Object>> getDocumentDetail(@PathVariable Long id) { public ResponseEntity<Map<String, Object>> getDocumentDetail(@PathVariable Long id) {
try { try {
KnowledgeDocument doc = documentService.getDocumentDetail(id); KnowledgeDocument doc = documentService.getDocumentDetail(id);
@ -293,6 +302,7 @@ public class DocumentController {
* 获取文档的所有分块 * 获取文档的所有分块
*/ */
@GetMapping("/document/{id}/chunks") @GetMapping("/document/{id}/chunks")
@PreAuthorize("hasAnyRole('admin','kb_operator')")
public ResponseEntity<Map<String, Object>> getDocumentChunks(@PathVariable Long id) { public ResponseEntity<Map<String, Object>> getDocumentChunks(@PathVariable Long id) {
try { try {
List<Map<String, Object>> chunks = documentService.getDocumentChunks(id); List<Map<String, Object>> chunks = documentService.getDocumentChunks(id);
@ -313,6 +323,7 @@ public class DocumentController {
* 删除文档逻辑删除 + 级联删除向量 * 删除文档逻辑删除 + 级联删除向量
*/ */
@DeleteMapping("/document/{id}") @DeleteMapping("/document/{id}")
@PreAuthorize("hasAnyRole('admin','kb_operator')")
public ResponseEntity<Map<String, Object>> deleteDocument(@PathVariable Long id) { public ResponseEntity<Map<String, Object>> deleteDocument(@PathVariable Long id) {
try { try {
int vectorCount = documentService.deleteDocument(id); int vectorCount = documentService.deleteDocument(id);
@ -333,6 +344,7 @@ public class DocumentController {
* 批量删除文档 * 批量删除文档
*/ */
@PostMapping("/document/batch/delete") @PostMapping("/document/batch/delete")
@PreAuthorize("hasAnyRole('admin','kb_operator')")
public ResponseEntity<Map<String, Object>> batchDeleteDocuments(@RequestBody Map<String, Object> body) { public ResponseEntity<Map<String, Object>> batchDeleteDocuments(@RequestBody Map<String, Object> body) {
try { try {
List<Long> ids = extractIds(body); List<Long> ids = extractIds(body);
@ -361,6 +373,7 @@ public class DocumentController {
* 批量重新处理文档 * 批量重新处理文档
*/ */
@PostMapping("/document/batch/reprocess") @PostMapping("/document/batch/reprocess")
@PreAuthorize("hasAnyRole('admin','kb_operator')")
public ResponseEntity<Map<String, Object>> batchReprocessDocuments(@RequestBody Map<String, Object> body) { public ResponseEntity<Map<String, Object>> batchReprocessDocuments(@RequestBody Map<String, Object> body) {
try { try {
List<Long> ids = extractIds(body); List<Long> ids = extractIds(body);
@ -409,6 +422,7 @@ public class DocumentController {
* 更新文档元信息 * 更新文档元信息
*/ */
@PutMapping("/document/{id}") @PutMapping("/document/{id}")
@PreAuthorize("hasAnyRole('admin','kb_operator')")
public ResponseEntity<Map<String, Object>> updateDocument( public ResponseEntity<Map<String, Object>> updateDocument(
@PathVariable Long id, @PathVariable Long id,
@RequestParam(required = false) String title, @RequestParam(required = false) String title,
@ -432,6 +446,7 @@ public class DocumentController {
* 重新处理文档重新分块 + 向量化 * 重新处理文档重新分块 + 向量化
*/ */
@PutMapping("/document/{id}/reprocess") @PutMapping("/document/{id}/reprocess")
@PreAuthorize("hasAnyRole('admin','kb_operator')")
public ResponseEntity<Map<String, Object>> reprocessDocument(@PathVariable Long id) { public ResponseEntity<Map<String, Object>> reprocessDocument(@PathVariable Long id) {
try { try {
KnowledgeDocument doc = documentService.reprocessDocument(id); KnowledgeDocument doc = documentService.reprocessDocument(id);
@ -457,6 +472,7 @@ public class DocumentController {
* @return 搜索结果 * @return 搜索结果
*/ */
@PostMapping("/document/search") @PostMapping("/document/search")
@PreAuthorize("hasAnyRole('admin','kb_operator')")
public ResponseEntity<Map<String, Object>> searchDocuments(@RequestBody Map<String, Object> body) { public ResponseEntity<Map<String, Object>> searchDocuments(@RequestBody Map<String, Object> body) {
try { try {
String query = (String) body.get("query"); String query = (String) body.get("query");
@ -520,6 +536,7 @@ public class DocumentController {
* 知识库统计面板 * 知识库统计面板
*/ */
@GetMapping("/document/stats") @GetMapping("/document/stats")
@PreAuthorize("hasAnyRole('admin','kb_operator')")
public ResponseEntity<Map<String, Object>> getStats() { public ResponseEntity<Map<String, Object>> getStats() {
try { try {
Map<String, Object> stats = documentService.getStats(); Map<String, Object> stats = documentService.getStats();
@ -541,6 +558,7 @@ public class DocumentController {
* 获取分类树 * 获取分类树
*/ */
@GetMapping("/category/tree") @GetMapping("/category/tree")
@PreAuthorize("hasAnyRole('admin','kb_operator')")
public ResponseEntity<Map<String, Object>> getCategoryTree() { public ResponseEntity<Map<String, Object>> getCategoryTree() {
try { try {
List<CategoryNode> tree = documentService.getCategoryTree(); List<CategoryNode> tree = documentService.getCategoryTree();
@ -560,6 +578,7 @@ public class DocumentController {
* 获取分类列表 * 获取分类列表
*/ */
@GetMapping("/category/list") @GetMapping("/category/list")
@PreAuthorize("hasAnyRole('admin','kb_operator')")
public ResponseEntity<Map<String, Object>> listCategories() { public ResponseEntity<Map<String, Object>> listCategories() {
try { try {
List<KnowledgeCategory> list = documentService.listCategories(); List<KnowledgeCategory> list = documentService.listCategories();
@ -579,6 +598,7 @@ public class DocumentController {
* 创建分类 * 创建分类
*/ */
@PostMapping("/category") @PostMapping("/category")
@PreAuthorize("hasAnyRole('admin','kb_operator')")
public ResponseEntity<Map<String, Object>> createCategory(@RequestBody Map<String, Object> body) { public ResponseEntity<Map<String, Object>> createCategory(@RequestBody Map<String, Object> body) {
try { try {
String name = (String) body.get("name"); String name = (String) body.get("name");
@ -604,6 +624,7 @@ public class DocumentController {
* 更新分类 * 更新分类
*/ */
@PutMapping("/category/{id}") @PutMapping("/category/{id}")
@PreAuthorize("hasAnyRole('admin','kb_operator')")
public ResponseEntity<Map<String, Object>> updateCategory( public ResponseEntity<Map<String, Object>> updateCategory(
@PathVariable Long id, @PathVariable Long id,
@RequestBody Map<String, Object> body) { @RequestBody Map<String, Object> body) {
@ -629,6 +650,7 @@ public class DocumentController {
* 删除分类 * 删除分类
*/ */
@DeleteMapping("/category/{id}") @DeleteMapping("/category/{id}")
@PreAuthorize("hasAnyRole('admin','kb_operator')")
public ResponseEntity<Map<String, Object>> deleteCategory(@PathVariable Long id) { public ResponseEntity<Map<String, Object>> deleteCategory(@PathVariable Long id) {
try { try {
documentService.deleteCategory(id); documentService.deleteCategory(id);

14
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 lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.List;
@ -28,14 +29,15 @@ public class FaqController {
* 分页查询 FAQ 列表 * 分页查询 FAQ 列表
*/ */
@GetMapping("/list") @GetMapping("/list")
@PreAuthorize("hasAnyRole('admin','kb_operator')")
public ResponseEntity<Map<String, Object>> list( public ResponseEntity<Map<String, Object>> list(
@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size, @RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) String keyword, @RequestParam(required = false) String keyword,
@RequestParam(required = false) String category,
@RequestParam(required = false) Long categoryId,
@RequestParam(required = false) String status) { @RequestParam(required = false) String status) {
try { try {
Map<String, Object> data = faqService.list(page, size, keyword, category, status);
Map<String, Object> data = faqService.list(page, size, keyword, categoryId, status);
return ResponseEntity.ok(Map.of( return ResponseEntity.ok(Map.of(
"success", true, "success", true,
"message", "查询成功", "message", "查询成功",
@ -56,6 +58,7 @@ public class FaqController {
* 新增 FAQ * 新增 FAQ
*/ */
@PostMapping @PostMapping
@PreAuthorize("hasAnyRole('admin','kb_operator')")
public ResponseEntity<Map<String, Object>> create(@RequestBody KnowledgeFaq faq) { public ResponseEntity<Map<String, Object>> create(@RequestBody KnowledgeFaq faq) {
try { try {
if (faq.getQuestion() == null || faq.getQuestion().isBlank()) { if (faq.getQuestion() == null || faq.getQuestion().isBlank()) {
@ -92,6 +95,7 @@ public class FaqController {
* 修改 FAQ * 修改 FAQ
*/ */
@PutMapping("/{id}") @PutMapping("/{id}")
@PreAuthorize("hasAnyRole('admin','kb_operator')")
public ResponseEntity<Map<String, Object>> update(@PathVariable Long id, @RequestBody KnowledgeFaq faq) { public ResponseEntity<Map<String, Object>> update(@PathVariable Long id, @RequestBody KnowledgeFaq faq) {
try { try {
KnowledgeFaq updated = faqService.update(id, faq); KnowledgeFaq updated = faqService.update(id, faq);
@ -120,6 +124,7 @@ public class FaqController {
* 删除 FAQ逻辑删除 * 删除 FAQ逻辑删除
*/ */
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
@PreAuthorize("hasAnyRole('admin','kb_operator')")
public ResponseEntity<Map<String, Object>> delete(@PathVariable Long id) { public ResponseEntity<Map<String, Object>> delete(@PathVariable Long id) {
try { try {
faqService.delete(id); faqService.delete(id);
@ -147,6 +152,7 @@ public class FaqController {
* 切换 FAQ 启用/禁用状态 * 切换 FAQ 启用/禁用状态
*/ */
@PutMapping("/{id}/toggle") @PutMapping("/{id}/toggle")
@PreAuthorize("hasAnyRole('admin','kb_operator')")
public ResponseEntity<Map<String, Object>> toggleStatus( public ResponseEntity<Map<String, Object>> toggleStatus(
@PathVariable Long id, @PathVariable Long id,
@RequestParam String status) { @RequestParam String status) {
@ -177,6 +183,7 @@ public class FaqController {
* 请求体格式: {"faqs": [{"question":"...", "answer":"...", "similarQuestions":"[...]", "category":"...", "priority":0}]} * 请求体格式: {"faqs": [{"question":"...", "answer":"...", "similarQuestions":"[...]", "category":"...", "priority":0}]}
*/ */
@PostMapping("/batch-import") @PostMapping("/batch-import")
@PreAuthorize("hasAnyRole('admin','kb_operator')")
public ResponseEntity<Map<String, Object>> batchImport(@RequestBody Map<String, List<KnowledgeFaq>> body) { public ResponseEntity<Map<String, Object>> batchImport(@RequestBody Map<String, List<KnowledgeFaq>> body) {
try { try {
List<KnowledgeFaq> faqs = body.get("faqs"); List<KnowledgeFaq> faqs = body.get("faqs");
@ -208,6 +215,7 @@ public class FaqController {
* 导出所有启用的 FAQ返回 JSON 列表后续可扩展为 Excel * 导出所有启用的 FAQ返回 JSON 列表后续可扩展为 Excel
*/ */
@GetMapping("/export") @GetMapping("/export")
@PreAuthorize("hasAnyRole('admin','kb_operator')")
public ResponseEntity<Map<String, Object>> exportAll() { public ResponseEntity<Map<String, Object>> exportAll() {
try { try {
List<KnowledgeFaq> faqs = faqService.exportAll(); List<KnowledgeFaq> faqs = faqService.exportAll();
@ -231,6 +239,7 @@ public class FaqController {
* 获取 FAQ 匹配统计信息 * 获取 FAQ 匹配统计信息
*/ */
@GetMapping("/stats") @GetMapping("/stats")
@PreAuthorize("hasAnyRole('admin','kb_operator')")
public ResponseEntity<Map<String, Object>> getStats() { public ResponseEntity<Map<String, Object>> getStats() {
try { try {
Map<String, Object> stats = faqService.getStats(); Map<String, Object> stats = faqService.getStats();
@ -254,6 +263,7 @@ public class FaqController {
* 手动重新计算某条 FAQ 的向量嵌入 * 手动重新计算某条 FAQ 的向量嵌入
*/ */
@PostMapping("/{id}/recompute-embedding") @PostMapping("/{id}/recompute-embedding")
@PreAuthorize("hasAnyRole('admin','kb_operator')")
public ResponseEntity<Map<String, Object>> recomputeEmbedding(@PathVariable Long id) { public ResponseEntity<Map<String, Object>> recomputeEmbedding(@PathVariable Long id) {
try { try {
faqService.recomputeEmbedding(id); faqService.recomputeEmbedding(id);

4
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 lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.Arrays; import java.util.Arrays;
@ -74,6 +75,7 @@ public class MessageFeedbackController {
* @return 统计结果 * @return 统计结果
*/ */
@GetMapping("/feedback/stats") @GetMapping("/feedback/stats")
@PreAuthorize("hasAnyRole('admin','cs_agent','viewer')")
public ResponseEntity<Map<String, Object>> getStats( public ResponseEntity<Map<String, Object>> getStats(
@RequestParam(required = false) String startDate, @RequestParam(required = false) String startDate,
@RequestParam(required = false) String endDate) { @RequestParam(required = false) String endDate) {
@ -98,6 +100,7 @@ public class MessageFeedbackController {
* @return 反馈列表 * @return 反馈列表
*/ */
@GetMapping("/feedback/by-conversation/{conversationId}") @GetMapping("/feedback/by-conversation/{conversationId}")
@PreAuthorize("hasAnyRole('admin','cs_agent','viewer')")
public ResponseEntity<Map<String, Object>> getByConversation( public ResponseEntity<Map<String, Object>> getByConversation(
@PathVariable String conversationId) { @PathVariable String conversationId) {
try { try {
@ -121,6 +124,7 @@ public class MessageFeedbackController {
* @return 反馈列表 * @return 反馈列表
*/ */
@GetMapping("/feedback/batch") @GetMapping("/feedback/batch")
@PreAuthorize("hasAnyRole('admin','cs_agent','viewer')")
public ResponseEntity<Map<String, Object>> getBatchByMessageIds( public ResponseEntity<Map<String, Object>> getBatchByMessageIds(
@RequestParam String messageIds) { @RequestParam String messageIds) {
try { try {

149
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<Map<String, Object>> 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<String, Object> 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<String> 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<Map<String, Object>> 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<SearchResult> results = hybridSearchService.search(
query, mode, topK, 0.0, Collections.emptyList());
Map<String, Object> 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 鉴权信息缺失");
}
}

7
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.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.HashMap; import java.util.HashMap;
@ -32,6 +33,7 @@ public class SensitiveWordController {
* 分页查询敏感词列表 * 分页查询敏感词列表
*/ */
@GetMapping("/list") @GetMapping("/list")
@PreAuthorize("hasRole('admin')")
public ResponseEntity<Map<String, Object>> list( public ResponseEntity<Map<String, Object>> list(
@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size, @RequestParam(defaultValue = "10") int size,
@ -61,6 +63,7 @@ public class SensitiveWordController {
* 新增敏感词 * 新增敏感词
*/ */
@PostMapping @PostMapping
@PreAuthorize("hasRole('admin')")
public ResponseEntity<Map<String, Object>> create(@RequestBody SensitiveWord word) { public ResponseEntity<Map<String, Object>> create(@RequestBody SensitiveWord word) {
try { try {
SensitiveWord created = sensitiveWordService.create(word); SensitiveWord created = sensitiveWordService.create(word);
@ -87,6 +90,7 @@ public class SensitiveWordController {
* 修改敏感词 * 修改敏感词
*/ */
@PutMapping("/{id}") @PutMapping("/{id}")
@PreAuthorize("hasRole('admin')")
public ResponseEntity<Map<String, Object>> update(@PathVariable Long id, @RequestBody SensitiveWord word) { public ResponseEntity<Map<String, Object>> update(@PathVariable Long id, @RequestBody SensitiveWord word) {
try { try {
SensitiveWord updated = sensitiveWordService.update(id, word); SensitiveWord updated = sensitiveWordService.update(id, word);
@ -113,6 +117,7 @@ public class SensitiveWordController {
* 删除敏感词逻辑删除 * 删除敏感词逻辑删除
*/ */
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
@PreAuthorize("hasRole('admin')")
public ResponseEntity<Map<String, Object>> delete(@PathVariable Long id) { public ResponseEntity<Map<String, Object>> delete(@PathVariable Long id) {
try { try {
sensitiveWordService.delete(id); sensitiveWordService.delete(id);
@ -141,6 +146,7 @@ public class SensitiveWordController {
*/ */
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
@PostMapping("/batch-import") @PostMapping("/batch-import")
@PreAuthorize("hasRole('admin')")
public ResponseEntity<Map<String, Object>> batchImport(@RequestBody Map<String, Object> body) { public ResponseEntity<Map<String, Object>> batchImport(@RequestBody Map<String, Object> body) {
try { try {
List<String> words = (List<String>) body.get("words"); List<String> words = (List<String>) body.get("words");
@ -191,6 +197,7 @@ public class SensitiveWordController {
* 分页查询内容审计日志使用 JdbcTemplate 直接查询因为审计日志表无逻辑删除字段 * 分页查询内容审计日志使用 JdbcTemplate 直接查询因为审计日志表无逻辑删除字段
*/ */
@GetMapping("/audit-log") @GetMapping("/audit-log")
@PreAuthorize("hasRole('admin')")
public ResponseEntity<Map<String, Object>> auditLog( public ResponseEntity<Map<String, Object>> auditLog(
@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size, @RequestParam(defaultValue = "10") int size,

85
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<Map<String, Object>> 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<Map<String, Object>> create(@RequestBody Map<String, String> 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<Map<String, Object>> update(@PathVariable Long id, @RequestBody Map<String, Object> 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()
));
}
}
}

197
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<Map<String, Object>> list(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) Boolean enabled) {
try {
Map<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> create(@RequestBody Map<String, Object> 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<Long> roleIds = body.get("roleIds") != null
? ((List<Number>) 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<Map<String, Object>> update(@PathVariable Long id, @RequestBody Map<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> assignRoles(@PathVariable Long id, @RequestBody Map<String, Object> body) {
try {
@SuppressWarnings("unchecked")
List<Long> roleIds = body.get("roleIds") != null
? ((List<Number>) 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<Map<String, Object>> changePassword(@PathVariable Long id, @RequestBody Map<String, String> 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<Map<String, Object>> 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()
));
}
}
}

190
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<Map<String, Object>> list(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
try {
Map<String, Object> result = webhookService.listWebhooks(page, size);
Map<String, Object> 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<Map<String, Object>> create(@RequestBody Map<String, Object> 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<String> events = body.get("events") instanceof List<?>
? ((List<String>) 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<Map<String, Object>> update(@PathVariable Long id,
@RequestBody Map<String, Object> body) {
try {
String name = body.containsKey("name") ? (String) body.get("name") : null;
String url = body.containsKey("url") ? (String) body.get("url") : null;
List<String> events = body.get("events") instanceof List<?>
? ((List<String>) 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<Map<String, Object>> 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<Map<String, Object>> test(@PathVariable Long id) {
try {
Map<String, Object> 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()
));
}
}
}

12
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<ApiKey> {
}

12
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<DashboardSnapshot> {
}

12
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<RagHitLog> {
}

12
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<SysPermission> {
}

12
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<SysRole> {
}

12
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<SysUser> {
}

12
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<SysUserRole> {
}

12
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<WebhookConfig> {
}

83
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;
}

9
src/main/java/com/wok/supportbot/entity/ChatMessage.java

@ -1,6 +1,8 @@
package com.wok.supportbot.entity; package com.wok.supportbot.entity;
import com.baomidou.mybatisplus.annotation.*; 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 com.wok.supportbot.handler.PostgresJsonTypeHandler;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
@ -39,6 +41,13 @@ public class ChatMessage implements Serializable {
@TableField("message_type") @TableField("message_type")
private MessageType messageType; private MessageType messageType;
/**
* 所属系统用户ID数据隔离SDK调用时为null
*/
@TableField("user_id")
@JsonSerialize(using = ToStringSerializer.class)
private Long userId;
/** /**
* 消息内容 * 消息内容
*/ */

92
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<String, Object> topQuestions;
/** 命中文档 TOP 列表(JSONB) */
@TableField(value = "top_hit_documents", typeHandler = PostgresJsonTypeHandler.class)
private Map<String, Object> topHitDocuments;
/** 未命中问题列表(JSONB) */
@TableField(value = "miss_questions", typeHandler = PostgresJsonTypeHandler.class)
private Map<String, Object> missQuestions;
/** 创建时间 */
@TableField(value = "create_time", fill = FieldFill.INSERT)
private Date createTime;
/** 更新时间 */
@TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE)
private Date updateTime;
}

9
src/main/java/com/wok/supportbot/entity/KnowledgeFaq.java

@ -52,11 +52,18 @@ public class KnowledgeFaq implements Serializable {
private String similarQuestions; private String similarQuestions;
/** /**
* FAQ 分类"退货政策"
* FAQ 分类已废弃保留向后兼容改用 categoryId 引用 knowledge_category
*/ */
@TableField("category") @TableField("category")
private String category; private String category;
/**
* 分类 ID引用 knowledge_category.id文档管理分类体系
*/
@TableField("category_id")
@JsonSerialize(using = ToStringSerializer.class)
private Long categoryId;
/** /**
* 状态: ENABLED / DISABLED * 状态: ENABLED / DISABLED
*/ */

66
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;
}

54
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;
}

62
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;
}

83
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<SysRole> roles;
}

45
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;
}

73
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<String, Object> 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;
}

113
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<String, Object> body = Map.of(
"success", false,
"message", message,
"code", status
);
response.getWriter().write(objectMapper.writeValueAsString(body));
}
}

106
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<String, LinkedList<Long>> 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<Long> timestamps = requestMap.computeIfAbsent(key, k -> new LinkedList<>());
synchronized (timestamps) {
// 移除窗口外的过期时间戳
Iterator<Long> 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<Map.Entry<String, LinkedList<Long>>> it = requestMap.entrySet().iterator();
while (it.hasNext()) {
Map.Entry<String, LinkedList<Long>> entry = it.next();
LinkedList<Long> timestamps = entry.getValue();
synchronized (timestamps) {
if (timestamps.isEmpty() || timestamps.getLast() < cutoff) {
it.remove();
removed++;
}
}
}
if (removed > 0) {
log.debug("限流器清理过期 key: {} 个,当前剩余 {} 个", removed, requestMap.size());
}
}
}
}

65
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<String> roles = jwtTokenProvider.getRolesFromToken(token);
// 构建 Spring Security 权限列表ROLE_ 前缀
List<SimpleGrantedAuthority> 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;
}
}

108
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<String> 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<String> 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();
}
}

115
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 BeanAuthController 登录验证需要
*/
@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;
}
}

170
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<String, Object> listKeys(int page, int size) {
QueryWrapper<ApiKey> countWrapper = new QueryWrapper<>();
Long total = apiKeyMapper.selectCount(countWrapper);
QueryWrapper<ApiKey> listWrapper = new QueryWrapper<>();
listWrapper.orderByDesc("create_time");
listWrapper.last("LIMIT " + size + " OFFSET " + (long) (page - 1) * size);
List<ApiKey> records = apiKeyMapper.selectList(listWrapper);
Map<String, Object> 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<ApiKey> 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);
}
}
}

16
src/main/java/com/wok/supportbot/service/ConversationService.java

@ -39,10 +39,19 @@ public class ConversationService {
* @return 分页会话列表 * @return 分页会话列表
*/ */
public Map<String, Object> listConversations(int page, int size, String keyword) { public Map<String, Object> listConversations(int page, int size, String keyword) {
return listConversations(page, size, keyword, null, null);
return listConversations(page, size, keyword, null, null, null);
} }
public Map<String, Object> listConversations(int page, int size, String keyword, String accountId, Long roleId) { public Map<String, Object> listConversations(int page, int size, String keyword, String accountId, Long roleId) {
return listConversations(page, size, keyword, accountId, roleId, null);
}
/**
* 获取会话列表分页支持用户数据隔离
*
* @param userId 系统用户IDnull 表示不过滤SDK 调用场景
*/
public Map<String, Object> listConversations(int page, int size, String keyword, String accountId, Long roleId, Long userId) {
// 构建基础 SQL 条件 // 构建基础 SQL 条件
StringBuilder whereClause = new StringBuilder("WHERE cm1.is_delete = false "); StringBuilder whereClause = new StringBuilder("WHERE cm1.is_delete = false ");
List<Object> params = new ArrayList<>(); List<Object> params = new ArrayList<>();
@ -78,6 +87,11 @@ public class ConversationService {
whereClause.append(" AND cs.role_id = ? "); whereClause.append(" AND cs.role_id = ? ");
params.add(roleId); params.add(roleId);
} }
// 用户数据隔离只返回属于当前用户的会话
if (userId != null) {
whereClause.append(" AND cm1.user_id = ? ");
params.add(userId);
}
// 查询总会话数 // 查询总会话数
String countSql = """ String countSql = """

352
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<String, Object> getOverview() {
Map<String, Object> 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<DashboardSnapshot> 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<String, Object> getKnowledgeAnalysis() {
Map<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> missQuestions = jdbcTemplate.queryForList(missSql);
result.put("missQuestions", missQuestions);
return result;
}
/**
* 获取自定义时间范围的快照数据
*
* @param startDate 开始日期yyyy-MM-dd
* @param endDate 结束日期yyyy-MM-dd
* @return 区间内的快照列表
*/
public List<DashboardSnapshot> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map> 序列化为 JSON 字符串
*/
private String toJson(List<Map<String, Object>> 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<String, Object> 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<String, Object> parseJsonb(Object value) {
if (value == null) return Collections.emptyMap();
if (value instanceof Map) return (Map<String, Object>) 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();
}
}

4
src/main/java/com/wok/supportbot/service/FaqMatchEngine.java

@ -200,7 +200,7 @@ public class FaqMatchEngine {
// 余弦距离查询<=> 运算符返回余弦距离相似度 = 1 - distance // 余弦距离查询<=> 运算符返回余弦距离相似度 = 1 - distance
List<Map<String, Object>> results = jdbcTemplate.queryForList( List<Map<String, Object>> results = jdbcTemplate.queryForList(
"SELECT fe.faq_id, fe.embedding <=> ?::vector AS distance, " + "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 " + "kf.status, kf.priority, kf.hit_count, kf.source, kf.create_time, kf.update_time, kf.is_delete " +
"FROM faq_embedding fe " + "FROM faq_embedding fe " +
"JOIN knowledge_faq kf ON fe.faq_id = kf.id " + "JOIN knowledge_faq kf ON fe.faq_id = kf.id " +
@ -331,6 +331,7 @@ public class FaqMatchEngine {
faq.setAnswer(rs.getString("answer")); faq.setAnswer(rs.getString("answer"));
faq.setSimilarQuestions(rs.getString("similar_questions")); faq.setSimilarQuestions(rs.getString("similar_questions"));
faq.setCategory(rs.getString("category")); faq.setCategory(rs.getString("category"));
faq.setCategoryId(rs.getObject("category_id") != null ? rs.getLong("category_id") : null);
faq.setStatus(rs.getString("status")); faq.setStatus(rs.getString("status"));
faq.setPriority(rs.getInt("priority")); faq.setPriority(rs.getInt("priority"));
faq.setHitCount(rs.getLong("hit_count")); faq.setHitCount(rs.getLong("hit_count"));
@ -351,6 +352,7 @@ public class FaqMatchEngine {
faq.setAnswer((String) result.get("answer")); faq.setAnswer((String) result.get("answer"));
faq.setSimilarQuestions((String) result.get("similar_questions")); faq.setSimilarQuestions((String) result.get("similar_questions"));
faq.setCategory((String) result.get("category")); 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.setStatus((String) result.get("status"));
faq.setPriority(((Number) result.get("priority")).intValue()); faq.setPriority(((Number) result.get("priority")).intValue());
faq.setHitCount(((Number) result.get("hit_count")).longValue()); faq.setHitCount(((Number) result.get("hit_count")).longValue());

11
src/main/java/com/wok/supportbot/service/FaqService.java

@ -44,14 +44,14 @@ public class FaqService {
* @param status 状态过滤可选 * @param status 状态过滤可选
* @return 分页结果 Map {total, records} * @return 分页结果 Map {total, records}
*/ */
public Map<String, Object> list(int page, int size, String keyword, String category, String status) {
public Map<String, Object> list(int page, int size, String keyword, Long categoryId, String status) {
QueryWrapper<KnowledgeFaq> wrapper = new QueryWrapper<>(); QueryWrapper<KnowledgeFaq> wrapper = new QueryWrapper<>();
if (keyword != null && !keyword.isBlank()) { if (keyword != null && !keyword.isBlank()) {
wrapper.like("question", keyword.trim()); 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()) { if (status != null && !status.isBlank()) {
wrapper.eq("status", status.trim()); wrapper.eq("status", status.trim());
@ -129,6 +129,9 @@ public class FaqService {
if (faq.getCategory() != null) { if (faq.getCategory() != null) {
existing.setCategory(faq.getCategory()); existing.setCategory(faq.getCategory());
} }
if (faq.getCategoryId() != null) {
existing.setCategoryId(faq.getCategoryId());
}
if (faq.getStatus() != null) { if (faq.getStatus() != null) {
existing.setStatus(faq.getStatus()); existing.setStatus(faq.getStatus());
} }
@ -274,7 +277,7 @@ public class FaqService {
// Top10 热门 FAQ // Top10 热门 FAQ
List<Map<String, Object>> topFaqs = jdbcTemplate.queryForList( List<Map<String, Object>> 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' " + "WHERE is_delete = false AND status = 'ENABLED' " +
"ORDER BY hit_count DESC LIMIT 10" "ORDER BY hit_count DESC LIMIT 10"
); );

84
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;
}
}
}

73
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<SysRole> listAll() {
return roleMapper.selectList(
new LambdaQueryWrapper<SysRole>()
.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<SysRole>().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);
}
}

270
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<SysUser>()
.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<SysRole> roles = getUserRoles(user.getId());
List<SimpleGrantedAuthority> 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<SysUser>()
.eq(SysUser::getUsername, username)
.eq(SysUser::getIsDelete, false)
);
if (user != null) {
user.setRoles(getUserRoles(user.getId()));
}
return user;
}
/**
* 获取用户的角色列表
*/
public List<SysRole> getUserRoles(Long userId) {
List<SysUserRole> userRoles = userRoleMapper.selectList(
new LambdaQueryWrapper<SysUserRole>().eq(SysUserRole::getUserId, userId)
);
if (userRoles.isEmpty()) return List.of();
List<Long> roleIds = userRoles.stream().map(SysUserRole::getRoleId).toList();
return roleMapper.selectBatchIds(roleIds);
}
/**
* 分页查询用户列表
*/
public Map<String, Object> listUsers(int page, int size, String keyword, Boolean enabled) {
StringBuilder where = new StringBuilder("WHERE u.is_delete = FALSE");
List<Object> 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<Object> dataParams = new ArrayList<>(params);
dataParams.add(size);
dataParams.add(offset);
List<Map<String, Object>> users = jdbcTemplate.queryForList(dataSql, dataParams.toArray());
Map<String, Object> 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<Long> roleIds) {
// 检查用户名是否已存在
Long exists = userMapper.selectCount(
new LambdaQueryWrapper<SysUser>().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<Long> roleIds) {
// 删除旧关联
userRoleMapper.delete(
new LambdaQueryWrapper<SysUserRole>().eq(SysUserRole::getUserId, userId)
);
// 插入新关联
for (Long roleId : roleIds) {
SysUserRole userRole = SysUserRole.builder()
.userId(userId)
.roleId(roleId)
.build();
userRoleMapper.insert(userRole);
}
}
/**
* 获取所有角色列表用于分配角色时的下拉选择
*/
public List<SysRole> getAllRoles() {
return roleMapper.selectList(
new LambdaQueryWrapper<SysRole>().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<String> getRoleKeysByUserId(Long userId) {
return getUserRoles(userId).stream().map(SysRole::getRoleKey).toList();
}
}

191
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<String, Object> listWebhooks(int page, int size) {
QueryWrapper<WebhookConfig> countWrapper = new QueryWrapper<>();
Long total = webhookConfigMapper.selectCount(countWrapper);
QueryWrapper<WebhookConfig> listWrapper = new QueryWrapper<>();
listWrapper.orderByDesc("create_time");
listWrapper.last("LIMIT " + size + " OFFSET " + (long) (page - 1) * size);
List<WebhookConfig> records = webhookConfigMapper.selectList(listWrapper);
Map<String, Object> 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<String> events) {
// 将事件列表包装为 Map 存入 JSONB
Map<String, Object> 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 回调 URLnull 则不更新
* @param events 订阅事件列表null 则不更新
* @param enabled 是否启用null 则不更新
* @return 更新后的 WebhookConfig
*/
public WebhookConfig updateWebhook(Long id, String name, String url, List<String> 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<String, Object> 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<String, Object> payload) {
// 查询所有已启用的 Webhook
QueryWrapper<WebhookConfig> wrapper = new QueryWrapper<>();
wrapper.eq("enabled", true);
List<WebhookConfig> allWebhooks = webhookConfigMapper.selectList(wrapper);
// 过滤出订阅了该事件的 Webhook
List<WebhookConfig> matched = allWebhooks.stream()
.filter(wh -> isEventSubscribed(wh, event))
.toList();
if (matched.isEmpty()) {
return;
}
// 构建推送 payload
Map<String, Object> 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<String, Object> 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;
}
}

13
src/main/resources/application.yml

@ -78,6 +78,10 @@ springdoc:
- group: default - group: default
paths-to-match: /** paths-to-match: /**
packages-to-scan: com.wok.supportbot.controller 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: knife4j:
setting: setting:
@ -89,3 +93,12 @@ knife4j:
logging: logging:
pattern: pattern:
console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n" 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

250
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: `
<div class="card" style="margin-top:16px;">
<h3 style="margin-bottom:12px;">🔑 API Key 管理</h3>
<!-- 操作栏 -->
<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap;align-items:center;">
<button @click="openCreateDialog" style="padding:6px 14px;background:var(--primary);color:#fff;border:none;border-radius:6px;cursor:pointer;"> 新建 API Key</button>
<span style="margin-left:auto;font-size:13px;color:#666;"> {{ total }} Key</span>
</div>
<!-- API Key 表格 -->
<table style="width:100%;border-collapse:collapse;">
<thead>
<tr style="border-bottom:2px solid var(--border);">
<th style="text-align:left;padding:8px;">名称</th>
<th style="text-align:left;padding:8px;">Key</th>
<th style="text-align:center;padding:8px;">频率限制</th>
<th style="text-align:center;padding:8px;">调用次数</th>
<th style="text-align:center;padding:8px;">过期时间</th>
<th style="text-align:center;padding:8px;">状态</th>
<th style="text-align:right;padding:8px;">操作</th>
</tr>
</thead>
<tbody>
<tr v-if="loading"><td colspan="7" style="text-align:center;padding:20px;">加载中...</td></tr>
<tr v-else-if="keys.length === 0"><td colspan="7" style="text-align:center;padding:20px;color:#999;">暂无 API Key</td></tr>
<tr v-for="k in keys" :key="k.id" style="border-bottom:1px solid var(--border);">
<td style="padding:8px;">
<div style="font-weight:500;">{{ k.name || '未命名' }}</div>
<div v-if="k.description" style="font-size:12px;color:#999;margin-top:2px;">{{ k.description }}</div>
</td>
<td style="padding:8px;">
<div style="display:flex;align-items:center;gap:6px;">
<code style="font-size:12px;background:var(--bg);padding:2px 6px;border-radius:4px;">{{ maskKey(k.keyValue) }}</code>
<button @click="copyKey(k.keyValue)" title="复制完整 Key" style="padding:2px 6px;background:none;border:1px solid var(--border);border-radius:4px;cursor:pointer;font-size:12px;">📋</button>
</div>
</td>
<td style="text-align:center;padding:8px;">{{ k.rateLimit || 60 }} /分钟</td>
<td style="text-align:center;padding:8px;">
{{ k.currentCalls || 0 }}
<span v-if="k.maxCalls" style="color:#999;">/ {{ k.maxCalls }}</span>
<span v-else style="color:#999;">不限</span>
</td>
<td style="text-align:center;padding:8px;font-size:12px;">
{{ k.expireTime ? formatDate(k.expireTime) : '永不过期' }}
</td>
<td style="text-align:center;padding:8px;">
<span :style="{color: k.enabled ? '#28a745' : '#dc3545', cursor:'pointer'}">
{{ k.enabled ? '✅ 有效' : '❌ 已吊销' }}
</span>
</td>
<td style="text-align:right;padding:8px;">
<button v-if="k.enabled" @click="doRevoke(k.id)" style="padding:3px 8px;background:none;border:1px solid #dc3545;color:#dc3545;border-radius:4px;cursor:pointer;margin-right:4px;font-size:12px;">吊销</button>
<button v-else @click="doEnable(k.id)" style="padding:3px 8px;background:none;border:1px solid #28a745;color:#28a745;border-radius:4px;cursor:pointer;margin-right:4px;font-size:12px;">启用</button>
<button @click="doDelete(k.id)" style="padding:3px 8px;background:none;border:1px solid #dc3545;color:#dc3545;border-radius:4px;cursor:pointer;font-size:12px;">删除</button>
</td>
</tr>
</tbody>
</table>
<!-- 分页 -->
<div v-if="total > pageSize" style="display:flex;justify-content:center;gap:8px;margin-top:12px;">
<button @click="page > 1 && (page--, loadList())" :disabled="page <= 1" style="padding:4px 10px;">上一页</button>
<span style="line-height:32px;"> {{ page }} / {{ Math.ceil(total / pageSize) }} {{ total }} </span>
<button @click="page < Math.ceil(total / pageSize) && (page++, loadList())" :disabled="page >= Math.ceil(total / pageSize)" style="padding:4px 10px;">下一页</button>
</div>
<!-- 新建弹窗 -->
<div v-if="showCreateDialog" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:1000;">
<div style="background:var(--card);border-radius:12px;padding:24px;width:520px;max-width:90vw;max-height:90vh;overflow-y:auto;">
<h4 style="margin-bottom:16px;">新建 API Key</h4>
<div style="margin-bottom:12px;">
<label style="display:block;margin-bottom:4px;font-size:13px;">名称 *</label>
<input v-model="form.name" placeholder="如:生产环境 Key" style="width:100%;padding:8px;border:1px solid var(--border);border-radius:6px;box-sizing:border-box;" />
</div>
<div style="margin-bottom:12px;">
<label style="display:block;margin-bottom:4px;font-size:13px;">描述</label>
<input v-model="form.description" placeholder="用途说明(可选)" style="width:100%;padding:8px;border:1px solid var(--border);border-radius:6px;box-sizing:border-box;" />
</div>
<div style="display:flex;gap:8px;margin-bottom:12px;">
<div style="flex:1;">
<label style="display:block;margin-bottom:4px;font-size:13px;">频率限制/分钟</label>
<input v-model.number="form.rateLimit" type="number" min="1" placeholder="60" style="width:100%;padding:8px;border:1px solid var(--border);border-radius:6px;box-sizing:border-box;" />
</div>
<div style="flex:1;">
<label style="display:block;margin-bottom:4px;font-size:13px;">最大调用次数</label>
<input v-model.number="form.maxCalls" type="number" min="1" placeholder="不限" style="width:100%;padding:8px;border:1px solid var(--border);border-radius:6px;box-sizing:border-box;" />
</div>
</div>
<div style="margin-bottom:12px;">
<label style="display:block;margin-bottom:4px;font-size:13px;">过期时间可选</label>
<input v-model="form.expireTime" type="datetime-local" style="width:100%;padding:8px;border:1px solid var(--border);border-radius:6px;box-sizing:border-box;" />
</div>
<div style="display:flex;justify-content:flex-end;gap:8px;">
<button @click="showCreateDialog = false" style="padding:8px 16px;background:none;border:1px solid var(--border);border-radius:6px;cursor:pointer;">取消</button>
<button @click="doCreate" :disabled="!form.name" style="padding:8px 16px;background:var(--primary);color:#fff;border:none;border-radius:6px;cursor:pointer;">创建</button>
</div>
</div>
</div>
<!-- 创建成功提示弹窗显示完整 Key -->
<div v-if="showKeyReveal" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:1001;">
<div style="background:var(--card);border-radius:12px;padding:24px;width:520px;max-width:90vw;">
<h4 style="margin-bottom:8px;"> API Key 已创建</h4>
<p style="color:#dc3545;font-size:13px;margin-bottom:12px;"> 请立即复制保存关闭后将无法再次查看完整 Key</p>
<div style="background:var(--bg);padding:12px;border-radius:6px;font-family:monospace;word-break:break-all;font-size:13px;margin-bottom:12px;">
{{ revealedKey }}
</div>
<div style="display:flex;justify-content:flex-end;gap:8px;">
<button @click="copyKey(revealedKey)" style="padding:8px 16px;background:var(--primary);color:#fff;border:none;border-radius:6px;cursor:pointer;">📋 复制 Key</button>
<button @click="showKeyReveal = false" style="padding:8px 16px;background:none;border:1px solid var(--border);border-radius:6px;cursor:pointer;">关闭</button>
</div>
</div>
</div>
</div>
`,
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
}
}
}

340
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: `
<div class="dashboard-panel">
<!-- 时间范围选择器 -->
<div class="card">
<div style="display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:12px;">
<h2 style="margin:0;">📈 运营数据看板</h2>
<div style="display:flex; align-items:center; gap:8px;">
<button class="btn btn-sm" :class="rangeDays === 7 ? 'btn-primary' : 'btn-outline'" @click="setRange(7)">近7天</button>
<button class="btn btn-sm" :class="rangeDays === 30 ? 'btn-primary' : 'btn-outline'" @click="setRange(30)">近30天</button>
<span style="color:var(--muted); font-size:13px;">|</span>
<input type="date" class="input" style="width:140px; flex:none; padding:6px 10px; font-size:13px;" v-model="customStart" />
<span style="color:var(--muted); font-size:13px;"></span>
<input type="date" class="input" style="width:140px; flex:none; padding:6px 10px; font-size:13px;" v-model="customEnd" />
<button class="btn btn-sm btn-purple" @click="loadCustomRange" :disabled="!customStart || !customEnd">查询</button>
</div>
</div>
</div>
<!-- 4个指标卡片 -->
<div class="stat-grid">
<div class="stat-card">
<div class="number">{{ overview.conversationCount ?? 0 }}</div>
<div class="label">今日对话数</div>
</div>
<div class="stat-card">
<div class="number" :style="{ color: satisfactionColor }">{{ formatPercent(overview.satisfactionRate) }}%</div>
<div class="label">满意率</div>
</div>
<div class="stat-card">
<div class="number" style="color: var(--success);">{{ formatPercent(overview.ragHitRate) }}%</div>
<div class="label">知识库命中率</div>
</div>
<div class="stat-card">
<div class="number">{{ overview.avgResponseTime ?? 0 }}<span style="font-size:14px; color:var(--muted);">ms</span></div>
<div class="label">平均响应时间</div>
</div>
</div>
<!-- 图表区域 -->
<div style="display:grid; grid-template-columns:1fr 1fr; gap:16px; margin-bottom:16px;">
<!-- 对话量趋势折线图 -->
<div class="card">
<h3 style="font-size:14px; font-weight:600; color:var(--text); margin-bottom:12px;">📊 对话量趋势</h3>
<div style="position:relative; height:260px;">
<canvas ref="conversationChartRef"></canvas>
</div>
</div>
<!-- 满意度趋势折线图 -->
<div class="card">
<h3 style="font-size:14px; font-weight:600; color:var(--text); margin-bottom:12px;">📊 满意度趋势</h3>
<div style="position:relative; height:260px;">
<canvas ref="satisfactionChartRef"></canvas>
</div>
</div>
</div>
<!-- 知识库分析 -->
<div style="display:grid; grid-template-columns:1fr 1fr; gap:16px;">
<!-- 文档命中排行 TOP-10 -->
<div class="card">
<h3 style="font-size:14px; font-weight:600; color:var(--text); margin-bottom:12px;">📚 文档命中排行 TOP-10</h3>
<div v-if="topHitDocuments.length === 0" class="empty" style="padding:30px;">暂无数据</div>
<table v-else style="width:100%; font-size:13px; border-collapse:collapse;">
<thead>
<tr style="border-bottom:1px solid var(--border);">
<th style="text-align:left; padding:8px 4px; color:var(--sub); font-weight:600;">排名</th>
<th style="text-align:left; padding:8px 4px; color:var(--sub); font-weight:600;">文档标题</th>
<th style="text-align:right; padding:8px 4px; color:var(--sub); font-weight:600;">命中次数</th>
<th style="text-align:right; padding:8px 4px; color:var(--sub); font-weight:600;">平均得分</th>
</tr>
</thead>
<tbody>
<tr v-for="(doc, idx) in topHitDocuments" :key="idx" style="border-bottom:1px solid var(--border);">
<td style="padding:8px 4px; color:var(--muted);">{{ idx + 1 }}</td>
<td style="padding:8px 4px; max-width:200px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;" :title="doc.document_title">{{ doc.document_title || '-' }}</td>
<td style="text-align:right; padding:8px 4px; font-weight:600; color:var(--primary);">{{ doc.hit_count ?? doc.cnt ?? 0 }}</td>
<td style="text-align:right; padding:8px 4px; color:var(--sub);">{{ doc.avg_score != null ? Number(doc.avg_score).toFixed(3) : '-' }}</td>
</tr>
</tbody>
</table>
</div>
<!-- 未命中问题收集 -->
<div class="card">
<h3 style="font-size:14px; font-weight:600; color:var(--text); margin-bottom:12px;"> 未命中问题收集</h3>
<div v-if="missQuestions.length === 0" class="empty" style="padding:30px;">暂无数据</div>
<div v-else style="max-height:400px; overflow-y:auto;">
<div v-for="(item, idx) in missQuestions" :key="idx"
style="padding:8px 12px; border-bottom:1px solid var(--border); font-size:13px; display:flex; align-items:center; gap:8px;">
<span style="color:var(--muted); flex:none; min-width:20px;">{{ idx + 1 }}.</span>
<span style="flex:1; word-break:break-all;">{{ item.user_query }}</span>
<span v-if="item.last_time" style="color:var(--muted); font-size:11px; flex:none;">{{ formatDateShort(item.last_time) }}</span>
</div>
</div>
</div>
</div>
<!-- 加载状态 -->
<div v-if="loading" style="text-align:center; padding:20px; color:var(--muted);">加载中...</div>
</div>
`,
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
}
}
}

79
src/main/resources/static/components/FaqManager.js

@ -1,10 +1,27 @@
/** /**
* FAQ 管理组件 * FAQ 管理组件
* 支持 CRUD + 批量导入 + 导出 + 启用/禁用 * 支持 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' 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 { export default {
template: ` template: `
<div class="card" style="margin-top:16px;"> <div class="card" style="margin-top:16px;">
@ -16,9 +33,9 @@ export default {
<button @click="showImportDialog = true" style="padding:6px 14px;background:#6c757d;color:#fff;border:none;border-radius:6px;cursor:pointer;">📥 批量导入</button> <button @click="showImportDialog = true" style="padding:6px 14px;background:#6c757d;color:#fff;border:none;border-radius:6px;cursor:pointer;">📥 批量导入</button>
<button @click="doExport" style="padding:6px 14px;background:#28a745;color:#fff;border:none;border-radius:6px;cursor:pointer;">📤 导出</button> <button @click="doExport" style="padding:6px 14px;background:#28a745;color:#fff;border:none;border-radius:6px;cursor:pointer;">📤 导出</button>
<input v-model="searchKeyword" @input="debouncedSearch" placeholder="搜索问题..." style="margin-left:auto;padding:6px 10px;border:1px solid var(--border);border-radius:6px;width:200px;" /> <input v-model="searchKeyword" @input="debouncedSearch" placeholder="搜索问题..." style="margin-left:auto;padding:6px 10px;border:1px solid var(--border);border-radius:6px;width:200px;" />
<select v-model="filterCategory" @change="loadList" style="padding:6px 10px;border:1px solid var(--border);border-radius:6px;">
<select v-model="filterCategoryId" @change="loadList" style="padding:6px 10px;border:1px solid var(--border);border-radius:6px;">
<option value="">全部分类</option> <option value="">全部分类</option>
<option v-for="c in categories" :key="c" :value="c">{{ c }}</option>
<option v-for="c in flatCategories" :key="c.id" :value="c.id">{{ c.path }}</option>
</select> </select>
<select v-model="filterStatus" @change="loadList" style="padding:6px 10px;border:1px solid var(--border);border-radius:6px;"> <select v-model="filterStatus" @change="loadList" style="padding:6px 10px;border:1px solid var(--border);border-radius:6px;">
<option value="">全部状态</option> <option value="">全部状态</option>
@ -31,7 +48,7 @@ export default {
<div v-if="stats" style="display:flex;gap:16px;margin-bottom:12px;font-size:13px;color:#666;"> <div v-if="stats" style="display:flex;gap:16px;margin-bottom:12px;font-size:13px;color:#666;">
<span>总计: {{ stats.totalCount || 0 }}</span> <span>总计: {{ stats.totalCount || 0 }}</span>
<span>启用: {{ stats.enabledCount || 0 }}</span> <span>启用: {{ stats.enabledCount || 0 }}</span>
<span>总命中: {{ stats.totalHitCount || 0 }}</span>
<span>总命中: {{ stats.totalHitCount || stats.totalHits || 0 }}</span>
</div> </div>
<!-- FAQ 表格 --> <!-- FAQ 表格 -->
@ -53,7 +70,7 @@ export default {
<tr v-for="f in faqs" :key="f.id" style="border-bottom:1px solid var(--border);"> <tr v-for="f in faqs" :key="f.id" style="border-bottom:1px solid var(--border);">
<td style="padding:8px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" :title="f.question">{{ f.question }}</td> <td style="padding:8px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" :title="f.question">{{ f.question }}</td>
<td style="padding:8px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#666;" :title="f.answer">{{ f.answer }}</td> <td style="padding:8px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#666;" :title="f.answer">{{ f.answer }}</td>
<td style="text-align:center;padding:8px;font-size:12px;">{{ f.category || '-' }}</td>
<td style="text-align:center;padding:8px;font-size:12px;">{{ getCategoryName(f.categoryId) || f.category || '-' }}</td>
<td style="text-align:center;padding:8px;">{{ f.priority }}</td> <td style="text-align:center;padding:8px;">{{ f.priority }}</td>
<td style="text-align:center;padding:8px;">{{ f.hitCount }}</td> <td style="text-align:center;padding:8px;">{{ f.hitCount }}</td>
<td style="text-align:center;padding:8px;"> <td style="text-align:center;padding:8px;">
@ -94,8 +111,11 @@ export default {
</div> </div>
<div style="display:flex;gap:8px;margin-bottom:12px;"> <div style="display:flex;gap:8px;margin-bottom:12px;">
<div style="flex:1;"> <div style="flex:1;">
<label style="display:block;margin-bottom:4px;font-size:13px;">分类</label>
<input v-model="form.category" placeholder="如: 退货政策" style="width:100%;padding:8px;border:1px solid var(--border);border-radius:6px;box-sizing:border-box;" />
<label style="display:block;margin-bottom:4px;font-size:13px;">分类引用文档管理分类</label>
<select v-model="form.categoryId" style="width:100%;padding:8px;border:1px solid var(--border);border-radius:6px;box-sizing:border-box;">
<option :value="null">不分类</option>
<option v-for="c in flatCategories" :key="c.id" :value="c.id">{{ c.path }}</option>
</select>
</div> </div>
<div style="width:100px;"> <div style="width:100px;">
<label style="display:block;margin-bottom:4px;font-size:13px;">优先级</label> <label style="display:block;margin-bottom:4px;font-size:13px;">优先级</label>
@ -113,8 +133,8 @@ export default {
<div v-if="showImportDialog" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:1000;"> <div v-if="showImportDialog" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:1000;">
<div style="background:var(--card);border-radius:12px;padding:24px;width:660px;max-width:90vw;"> <div style="background:var(--card);border-radius:12px;padding:24px;width:660px;max-width:90vw;">
<h4 style="margin-bottom:16px;">批量导入 FAQ</h4> <h4 style="margin-bottom:16px;">批量导入 FAQ</h4>
<p style="font-size:13px;color:#666;margin-bottom:12px;">使用 JSON 格式批量导入每条包含 questionanswersimilarQuestions可选数组category可选</p>
<textarea v-model="importJson" rows="10" placeholder='[&#10; {"question":"退货流程","answer":"请先在订单页面...","similarQuestions":["怎么退货","退货步骤"],"category":"退货政策"},&#10; {"question":"运费谁出","answer":"7天内退货运费..."}&#10;]' style="width:100%;padding:8px;border:1px solid var(--border);border-radius:6px;box-sizing:border-box;font-family:monospace;font-size:12px;"></textarea>
<p style="font-size:13px;color:#666;margin-bottom:12px;">使用 JSON 格式批量导入每条包含 questionanswersimilarQuestions可选数组categoryId可选分类ID</p>
<textarea v-model="importJson" rows="10" placeholder='[&#10; {"question":"退货流程","answer":"请先在订单页面...","similarQuestions":["怎么退货","退货步骤"],"categoryId":123456},&#10; {"question":"运费谁出","answer":"7天内退货运费..."}&#10;]' style="width:100%;padding:8px;border:1px solid var(--border);border-radius:6px;box-sizing:border-box;font-family:monospace;font-size:12px;"></textarea>
<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:12px;"> <div style="display:flex;justify-content:flex-end;gap:8px;margin-top:12px;">
<button @click="showImportDialog = false" style="padding:8px 16px;background:none;border:1px solid var(--border);border-radius:6px;cursor:pointer;">取消</button> <button @click="showImportDialog = false" style="padding:8px 16px;background:none;border:1px solid var(--border);border-radius:6px;cursor:pointer;">取消</button>
<button @click="doImport" :disabled="!importJson.trim()" style="padding:8px 16px;background:var(--primary);color:#fff;border:none;border-radius:6px;cursor:pointer;">导入</button> <button @click="doImport" :disabled="!importJson.trim()" style="padding:8px 16px;background:var(--primary);color:#fff;border:none;border-radius:6px;cursor:pointer;">导入</button>
@ -132,35 +152,56 @@ export default {
pageSize: 20, pageSize: 20,
total: 0, total: 0,
searchKeyword: '', searchKeyword: '',
filterCategory: '',
filterCategoryId: '',
filterStatus: '', filterStatus: '',
categories: [],
flatCategories: [],
categoryMap: {},
stats: null, stats: null,
showFormDialog: false, showFormDialog: false,
showImportDialog: false, showImportDialog: false,
editingFaq: null, editingFaq: null,
form: { question: '', answer: '', similarQuestionsText: '', category: '', priority: 0 },
form: { question: '', answer: '', similarQuestionsText: '', categoryId: null, priority: 0 },
importJson: '', importJson: '',
searchTimer: null searchTimer: null
} }
}, },
mounted() { mounted() {
this.loadCategories()
this.loadList() this.loadList()
this.loadStats() this.loadStats()
}, },
methods: { 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() { async loadList() {
this.loading = true this.loading = true
try { 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) { if (res.success) {
this.faqs = res.data?.records || res.data || [] this.faqs = res.data?.records || res.data || []
this.total = res.data?.total || res.total || 0 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) { } catch (e) {
toast('加载失败: ' + e.message, 'error') toast('加载失败: ' + e.message, 'error')
@ -182,7 +223,7 @@ export default {
openAddDialog() { openAddDialog() {
this.editingFaq = null this.editingFaq = null
this.form = { question: '', answer: '', similarQuestionsText: '', category: '', priority: 0 }
this.form = { question: '', answer: '', similarQuestionsText: '', categoryId: null, priority: 0 }
this.showFormDialog = true this.showFormDialog = true
}, },
@ -193,7 +234,7 @@ export default {
const arr = typeof f.similarQuestions === 'string' ? JSON.parse(f.similarQuestions) : f.similarQuestions const arr = typeof f.similarQuestions === 'string' ? JSON.parse(f.similarQuestions) : f.similarQuestions
similarText = Array.isArray(arr) ? arr.join('\n') : '' similarText = Array.isArray(arr) ? arr.join('\n') : ''
} catch { similarText = f.similarQuestions || '' } } 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 this.showFormDialog = true
}, },
@ -203,7 +244,7 @@ export default {
question: this.form.question, question: this.form.question,
answer: this.form.answer, answer: this.form.answer,
similarQuestions: JSON.stringify(similarQuestions), similarQuestions: JSON.stringify(similarQuestions),
category: this.form.category || null,
categoryId: this.form.categoryId || null,
priority: this.form.priority || 0 priority: this.form.priority || 0
} }
try { try {

71
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: `
<div class="login-page">
<div class="login-card">
<div class="login-header">
<div class="login-logo">🤖</div>
<h1>AI 智能客服系统</h1>
<p class="login-subtitle">Support Bot Management Console</p>
</div>
<form class="login-form" @submit.prevent="handleLogin">
<div class="form-group">
<label>用户名</label>
<input v-model="username" type="text" placeholder="请输入用户名" autocomplete="username" autofocus />
</div>
<div class="form-group">
<label>密码</label>
<input v-model="password" type="password" placeholder="请输入密码" autocomplete="current-password" @keyup.enter="handleLogin" />
</div>
<button type="submit" class="btn-login" :disabled="loading">
{{ loading ? '登录中...' : '登 录' }}
</button>
<p v-if="errorMsg" class="login-error">{{ errorMsg }}</p>
</form>
<div class="login-footer">
<span>默认账号: admin / admin123</span>
</div>
</div>
</div>
`,
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 }
}
}

315
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: `
<div class="card">
<div class="card-header">
<h2>👥 用户管理</h2>
<button class="btn btn-primary" @click="openCreate">+ 新建用户</button>
</div>
<!-- 搜索栏 -->
<div class="toolbar">
<input v-model="keyword" placeholder="搜索用户名/昵称/邮箱" @keyup.enter="loadData" class="search-input" />
<select v-model="filterEnabled" @change="loadData" class="search-select">
<option :value="null">全部状态</option>
<option :value="true">已启用</option>
<option :value="false">已禁用</option>
</select>
<button class="btn" @click="loadData">搜索</button>
</div>
<!-- 用户列表 -->
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>用户名</th>
<th>昵称</th>
<th>邮箱</th>
<th>角色</th>
<th>状态</th>
<th>最后登录</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="u in users" :key="u.id">
<td><strong>{{ u.username }}</strong></td>
<td>{{ u.nickname || '-' }}</td>
<td>{{ u.email || '-' }}</td>
<td>
<span v-for="r in (u.role_names || '').split(',')" :key="r" class="tag" v-if="r">{{ r }}</span>
<span v-if="!u.role_names" class="tag tag-muted">未分配</span>
</td>
<td>
<span :class="u.enabled ? 'status-ready' : 'status-failed'">
{{ u.enabled ? '启用' : '禁用' }}
</span>
</td>
<td>{{ u.last_login_time ? formatDate(u.last_login_time) : '从未登录' }}</td>
<td>{{ formatDate(u.create_time) }}</td>
<td class="actions">
<button class="btn btn-sm" @click="openEdit(u)">编辑</button>
<button class="btn btn-sm" @click="openRoleAssign(u)">角色</button>
<button class="btn btn-sm" @click="openPasswordChange(u)">改密</button>
<button class="btn btn-sm" :class="u.enabled ? 'btn-warn' : 'btn-success'" @click="handleToggle(u)">
{{ u.enabled ? '禁用' : '启用' }}
</button>
</td>
</tr>
<tr v-if="!users.length">
<td colspan="8" class="empty">暂无用户数据</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页 -->
<div class="pagination" v-if="total > pageSize">
<button class="btn btn-sm" :disabled="page <= 1" @click="page--; loadData()">上一页</button>
<span>{{ page }} / {{ Math.ceil(total / pageSize) }} ( {{ total }} )</span>
<button class="btn btn-sm" :disabled="page >= Math.ceil(total / pageSize)" @click="page++; loadData()">下一页</button>
</div>
<!-- 新建/编辑弹窗 -->
<div v-if="modal.visible" class="modal-overlay" @click.self="modal.visible = false">
<div class="modal-card">
<h3>{{ modal.isEdit ? '编辑用户' : '新建用户' }}</h3>
<div class="form-group">
<label>用户名</label>
<input v-model="modal.username" :disabled="modal.isEdit" placeholder="用户名(登录账号)" />
</div>
<div v-if="!modal.isEdit" class="form-group">
<label>密码</label>
<input v-model="modal.password" type="password" placeholder="初始密码" />
</div>
<div class="form-group">
<label>昵称</label>
<input v-model="modal.nickname" placeholder="显示昵称" />
</div>
<div class="form-group">
<label>邮箱</label>
<input v-model="modal.email" placeholder="邮箱地址" />
</div>
<div class="form-group">
<label>手机</label>
<input v-model="modal.phone" placeholder="手机号" />
</div>
<div class="modal-actions">
<button class="btn" @click="modal.visible = false">取消</button>
<button class="btn btn-primary" @click="handleSave" :disabled="saving">
{{ saving ? '保存中...' : '保存' }}
</button>
</div>
</div>
</div>
<!-- 角色分配弹窗 -->
<div v-if="roleModal.visible" class="modal-overlay" @click.self="roleModal.visible = false">
<div class="modal-card">
<h3>分配角色 {{ roleModal.username }}</h3>
<div class="form-group">
<label v-for="r in allRoles" :key="r.id" class="checkbox-label">
<input type="checkbox" :value="r.id.toString()" v-model="roleModal.selectedRoleIds" />
{{ r.name }} ({{ r.roleKey }})
</label>
</div>
<div class="modal-actions">
<button class="btn" @click="roleModal.visible = false">取消</button>
<button class="btn btn-primary" @click="handleRoleAssign">保存</button>
</div>
</div>
</div>
<!-- 改密弹窗 -->
<div v-if="pwdModal.visible" class="modal-overlay" @click.self="pwdModal.visible = false">
<div class="modal-card">
<h3>修改密码 {{ pwdModal.username }}</h3>
<div class="form-group">
<label>新密码</label>
<input v-model="pwdModal.newPassword" type="password" placeholder="请输入新密码" />
</div>
<div class="modal-actions">
<button class="btn" @click="pwdModal.visible = false">取消</button>
<button class="btn btn-primary" @click="handlePasswordChange">确认修改</button>
</div>
</div>
</div>
</div>
`,
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
}
}
}

216
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: `
<div class="card" style="margin-top:16px;">
<h3 style="margin-bottom:12px;">🔔 Webhook 管理</h3>
<!-- 操作栏 -->
<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap;align-items:center;">
<button @click="openCreateDialog" style="padding:6px 14px;background:var(--primary);color:#fff;border:none;border-radius:6px;cursor:pointer;"> 新建 Webhook</button>
<span style="margin-left:auto;font-size:13px;color:#666;"> {{ total }} </span>
</div>
<!-- Webhook 表格 -->
<table style="width:100%;border-collapse:collapse;">
<thead>
<tr style="border-bottom:2px solid var(--border);">
<th style="text-align:left;padding:8px;">名称</th>
<th style="text-align:left;padding:8px;">URL</th>
<th style="text-align:center;padding:8px;">订阅事件</th>
<th style="text-align:center;padding:8px;">状态</th>
<th style="text-align:right;padding:8px;">操作</th>
</tr>
</thead>
<tbody>
<tr v-if="loading"><td colspan="5" style="text-align:center;padding:20px;">加载中...</td></tr>
<tr v-else-if="webhooks.length === 0"><td colspan="5" style="text-align:center;padding:20px;color:#999;">暂无 Webhook</td></tr>
<tr v-for="w in webhooks" :key="w.id" style="border-bottom:1px solid var(--border);">
<td style="padding:8px;font-weight:500;">{{ w.name || '未命名' }}</td>
<td style="padding:8px;">
<code style="font-size:12px;background:var(--bg);padding:2px 6px;border-radius:4px;word-break:break-all;">{{ w.url }}</code>
</td>
<td style="text-align:center;padding:8px;">
<div style="display:flex;flex-wrap:wrap;gap:4px;justify-content:center;">
<span v-for="ev in getEvents(w)" :key="ev" style="font-size:11px;background:var(--bg);padding:2px 8px;border-radius:10px;border:1px solid var(--border);">{{ ev }}</span>
</div>
</td>
<td style="text-align:center;padding:8px;">
<span :style="{color: w.enabled ? '#28a745' : '#dc3545'}">
{{ w.enabled ? '✅ 启用' : '❌ 禁用' }}
</span>
</td>
<td style="text-align:right;padding:8px;">
<button @click="openEditDialog(w)" style="padding:3px 8px;background:none;border:1px solid var(--border);border-radius:4px;cursor:pointer;margin-right:4px;font-size:12px;">编辑</button>
<button @click="doTest(w.id)" style="padding:3px 8px;background:none;border:1px solid #17a2b8;color:#17a2b8;border-radius:4px;cursor:pointer;margin-right:4px;font-size:12px;">测试</button>
<button @click="doDelete(w.id)" style="padding:3px 8px;background:none;border:1px solid #dc3545;color:#dc3545;border-radius:4px;cursor:pointer;font-size:12px;">删除</button>
</td>
</tr>
</tbody>
</table>
<!-- 分页 -->
<div v-if="total > pageSize" style="display:flex;justify-content:center;gap:8px;margin-top:12px;">
<button @click="page > 1 && (page--, loadList())" :disabled="page <= 1" style="padding:4px 10px;">上一页</button>
<span style="line-height:32px;"> {{ page }} / {{ Math.ceil(total / pageSize) }} {{ total }} </span>
<button @click="page < Math.ceil(total / pageSize) && (page++, loadList())" :disabled="page >= Math.ceil(total / pageSize)" style="padding:4px 10px;">下一页</button>
</div>
<!-- 新建/编辑弹窗 -->
<div v-if="showFormDialog" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:1000;">
<div style="background:var(--card);border-radius:12px;padding:24px;width:520px;max-width:90vw;max-height:90vh;overflow-y:auto;">
<h4 style="margin-bottom:16px;">{{ editingId ? '编辑 Webhook' : '新建 Webhook' }}</h4>
<div style="margin-bottom:12px;">
<label style="display:block;margin-bottom:4px;font-size:13px;">名称 *</label>
<input v-model="form.name" placeholder="如:文档处理通知" style="width:100%;padding:8px;border:1px solid var(--border);border-radius:6px;box-sizing:border-box;" />
</div>
<div style="margin-bottom:12px;">
<label style="display:block;margin-bottom:4px;font-size:13px;">回调 URL *</label>
<input v-model="form.url" placeholder="https://example.com/webhook" style="width:100%;padding:8px;border:1px solid var(--border);border-radius:6px;box-sizing:border-box;" />
</div>
<div style="margin-bottom:12px;">
<label style="display:block;margin-bottom:8px;font-size:13px;">订阅事件</label>
<div style="display:flex;flex-direction:column;gap:8px;">
<label v-for="ev in availableEvents" :key="ev.value" style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;">
<input type="checkbox" :value="ev.value" v-model="form.events" style="cursor:pointer;" />
<span style="font-weight:500;">{{ ev.value }}</span>
<span style="color:#999;font-size:12px;"> {{ ev.label }}</span>
</label>
</div>
</div>
<div style="margin-bottom:12px;">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;">
<input type="checkbox" v-model="form.enabled" style="cursor:pointer;" />
<span>启用</span>
</label>
</div>
<div style="display:flex;justify-content:flex-end;gap:8px;">
<button @click="showFormDialog = false" style="padding:8px 16px;background:none;border:1px solid var(--border);border-radius:6px;cursor:pointer;">取消</button>
<button @click="doSave" :disabled="!form.name || !form.url" style="padding:8px 16px;background:var(--primary);color:#fff;border:none;border-radius:6px;cursor:pointer;">保存</button>
</div>
</div>
</div>
</div>
`,
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
}
}
}

83
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 { 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.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; } .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; } .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; } * { scrollbar-width:thin; scrollbar-color:#cbd0d8 transparent; }
::-webkit-scrollbar { width:10px; height:10px; } ::-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-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-rel { display:inline-block; font-size:11px; color:var(--muted); margin-right:6px; font-weight:600; }
.source-snippet { color:var(--sub); } .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; }
}

3
src/main/resources/static/index.html

@ -10,7 +10,8 @@
"imports": { "imports": {
"vue": "https://unpkg.com/vue@3/dist/vue.esm-browser.prod.js", "vue": "https://unpkg.com/vue@3/dist/vue.esm-browser.prod.js",
"marked": "https://cdn.jsdelivr.net/npm/marked@12/+esm", "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"
} }
} }
</script> </script>

261
src/main/resources/static/js/api.js

@ -1,16 +1,27 @@
/** /**
* 统一 API 请求层 * 统一 API 请求层
* 封装所有后端接口调用组件只需调用函数处理业务逻辑 * 封装所有后端接口调用组件只需调用函数处理业务逻辑
* P1: 所有请求自动附加 Bearer Token401 时触发未登录事件
*/ */
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 * GET 请求返回 JSON
*/ */
async function getJSON(path) { 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() return res.json()
} }
@ -20,9 +31,11 @@ async function getJSON(path) {
async function postJSON(path, body) { async function postJSON(path, body) {
const res = await fetch(API_BASE + path, { const res = await fetch(API_BASE + path, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify(body) body: JSON.stringify(body)
}) })
if (res.status === 401) { handleUnauthorized(); throw new Error('401') }
if (res.status === 403) throw new Error('403')
return res.json() return res.json()
} }
@ -32,9 +45,10 @@ async function postJSON(path, body) {
async function deleteJSONWithBody(path, body) { async function deleteJSONWithBody(path, body) {
const res = await fetch(API_BASE + path, { const res = await fetch(API_BASE + path, {
method: 'DELETE', method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify(body) body: JSON.stringify(body)
}) })
if (res.status === 401) { handleUnauthorized(); throw new Error('401') }
return res.json() return res.json()
} }
@ -44,9 +58,10 @@ async function deleteJSONWithBody(path, body) {
async function putJSONWithBody(path, body) { async function putJSONWithBody(path, body) {
const res = await fetch(API_BASE + path, { const res = await fetch(API_BASE + path, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify(body) body: JSON.stringify(body)
}) })
if (res.status === 401) { handleUnauthorized(); throw new Error('401') }
return res.json() return res.json()
} }
@ -54,7 +69,8 @@ async function putJSONWithBody(path, body) {
* DELETE 请求返回 JSON body * DELETE 请求返回 JSON body
*/ */
async function deleteJSON(path) { 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() return res.json()
} }
@ -70,7 +86,8 @@ async function putJSON(path, params) {
.join('&') .join('&')
if (qs) url += (url.includes('?') ? '&' : '?') + qs 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() return res.json()
} }
@ -80,8 +97,10 @@ async function putJSON(path, params) {
async function postForm(path, formData) { async function postForm(path, formData) {
const res = await fetch(API_BASE + path, { const res = await fetch(API_BASE + path, {
method: 'POST', method: 'POST',
headers: authHeaders(),
body: formData body: formData
}) })
if (res.status === 401) { handleUnauthorized(); throw new Error('401') }
return res.json() return res.json()
} }
@ -269,8 +288,6 @@ export function batchReprocessDocuments(ids) {
return postJSON('/document/batch/reprocess', { ids }) return postJSON('/document/batch/reprocess', { ids })
} }
/**
/** /**
* 语义搜索 * 语义搜索
*/ */
@ -307,6 +324,10 @@ function postFormWithProgress(path, formData, onProgress) {
const xhr = new XMLHttpRequest() const xhr = new XMLHttpRequest()
xhr.open('POST', API_BASE + path) xhr.open('POST', API_BASE + path)
// 附加认证头
const token = localStorage.getItem('sb_token')
if (token) xhr.setRequestHeader('Authorization', 'Bearer ' + token)
// 上传进度监听 // 上传进度监听
xhr.upload.addEventListener('progress', (e) => { xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable && onProgress) { if (e.lengthComputable && onProgress) {
@ -315,6 +336,11 @@ function postFormWithProgress(path, formData, onProgress) {
}) })
xhr.addEventListener('load', () => { xhr.addEventListener('load', () => {
if (xhr.status === 401) {
handleUnauthorized()
reject(new Error('401'))
return
}
try { try {
resolve(JSON.parse(xhr.responseText)) resolve(JSON.parse(xhr.responseText))
} catch (e) { } catch (e) {
@ -351,7 +377,7 @@ export function uploadString(content, title, categoryId, tags) {
if (tags) url += `&tags=${encodeURIComponent(tags)}` if (tags) url += `&tags=${encodeURIComponent(tags)}`
return fetch(API_BASE + url, { return fetch(API_BASE + url, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'text/plain' },
headers: { 'Content-Type': 'text/plain', ...authHeaders() },
body: content body: content
}).then(res => res.json()) }).then(res => res.json())
} }
@ -459,7 +485,7 @@ export function deleteConversation(conversationId) {
* 导出会话记录 * 导出会话记录
*/ */
export function exportConversation(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()) .then(res => res.text())
} }
@ -599,10 +625,10 @@ export function listAuditLogs(page = 1, size = 20, sessionId) {
/** /**
* FAQ 分页列表 * 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}` let path = `/faq/list?page=${page}&size=${size}`
if (keyword) path += `&keyword=${encodeURIComponent(keyword)}` if (keyword) path += `&keyword=${encodeURIComponent(keyword)}`
if (category) path += `&category=${encodeURIComponent(category)}`
if (categoryId) path += `&categoryId=${categoryId}`
if (status) path += `&status=${encodeURIComponent(status)}` if (status) path += `&status=${encodeURIComponent(status)}`
return getJSON(path) return getJSON(path)
} }
@ -655,3 +681,212 @@ export function exportFaqs() {
export function getFaqStats() { export function getFaqStats() {
return getJSON('/faq/stats') 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`, {})
}

361
src/main/resources/static/js/app.js

@ -1,9 +1,12 @@
/** /**
* Vue 应用入口 * Vue 应用入口
* 侧边栏导航 + 内容区布局数据驱动菜单渲染 * 侧边栏导航 + 内容区布局数据驱动菜单渲染
* P1: 登录守卫 + 用户信息展示 + 新页面注册
*/ */
import { createApp } from 'vue' import { createApp } from 'vue'
import { store, MENU_ITEMS } from './store.js' 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' import ChatPanel from '../components/ChatPanel.js'
@ -20,6 +23,11 @@ import ModelConfigManager from '../components/ModelConfigManager.js'
import SensitiveWordManager from '../components/SensitiveWordManager.js' import SensitiveWordManager from '../components/SensitiveWordManager.js'
import FaqManager from '../components/FaqManager.js' import FaqManager from '../components/FaqManager.js'
import AuditLogManager from '../components/AuditLogManager.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({ const app = createApp({
setup() { setup() {
@ -31,148 +39,256 @@ const app = createApp({
return parentItem.children.some(c => c.id === store.activePage) 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: ` template: `
<!-- 移动端遮罩层 -->
<div class="sidebar-overlay" v-if="store.mobileMenuOpen" @click="store.mobileMenuOpen = false"></div>
<!-- 顶部导航 -->
<div class="topbar">
<button class="menu-toggle" @click="store.mobileMenuOpen = !store.mobileMenuOpen"></button>
<span class="logo">🤖 Support Bot</span><span class="ver">AI </span>
<span class="links">
<a href="/sdk/test.html" target="_blank">🧪 SDK 测试面板</a>
<a href="/doc.html" target="_blank">📖 API 文档</a>
</span>
</div>
<!-- 布局主体侧边栏 + 内容区 -->
<div class="layout-body">
<!-- 左侧边栏 -->
<aside class="sidebar" :class="{ collapsed: store.sidebarCollapsed && !store.mobileMenuOpen, open: store.mobileMenuOpen }">
<div class="sidebar-inner">
<nav class="sidebar-nav">
<template v-for="item in menuItems" :key="item.id">
<!-- 有子菜单的父级菜单项 -->
<div v-if="item.children" :class="['menu-group', store.expandedMenus.includes(item.id) ? 'open' : '']">
<!-- 未登录显示登录页 -->
<login-page v-if="!store.isLoggedIn" @login-success="onLoginSuccess"></login-page>
<!-- 已登录显示管理后台 -->
<template v-else>
<!-- 移动端遮罩层 -->
<div class="sidebar-overlay" v-if="store.mobileMenuOpen" @click="store.mobileMenuOpen = false"></div>
<!-- 顶部导航 -->
<div class="topbar">
<button class="menu-toggle" @click="store.mobileMenuOpen = !store.mobileMenuOpen"></button>
<span class="logo">🤖 Support Bot</span><span class="ver">AI </span>
<span class="links">
<a href="/sdk/test.html" target="_blank">🧪 SDK 测试面板</a>
<a href="/doc.html" target="_blank">📖 API 文档</a>
</span>
<!-- 用户信息 + 退出 -->
<div class="user-bar">
<span class="user-info">
<span class="user-avatar">{{ (store.currentUser.nickname || store.currentUser.username || 'U').charAt(0) }}</span>
<span class="user-name">{{ store.currentUser.nickname || store.currentUser.username || '' }}</span>
<span class="user-role-tag" v-for="r in (store.currentUser.roles || [])" :key="r">{{ r }}</span>
</span>
<button class="btn btn-sm btn-logout" @click="handleLogout" title="退出登录">退出</button>
</div>
</div>
<!-- 布局主体侧边栏 + 内容区 -->
<div class="layout-body">
<!-- 左侧边栏 -->
<aside class="sidebar" :class="{ collapsed: store.sidebarCollapsed && !store.mobileMenuOpen, open: store.mobileMenuOpen }">
<div class="sidebar-inner">
<nav class="sidebar-nav">
<template v-for="item in menuItems" :key="item.id">
<!-- 有子菜单的父级菜单项 -->
<div v-if="item.children" :class="['menu-group', store.expandedMenus.includes(item.id) ? 'open' : '']">
<button
class="menu-item menu-parent"
:class="{ active: isMenuActive(item) }"
@click="store.toggleMenu(item.id)"
:title="store.sidebarCollapsed ? item.label : ''"
>
<span class="menu-icon">{{ item.icon }}</span>
<span class="menu-label">{{ item.label }}</span>
<span class="menu-arrow" :class="{ expanded: store.expandedMenus.includes(item.id) }"></span>
</button>
<!-- 子菜单项按角色过滤 -->
<div class="submenu" v-show="store.expandedMenus.includes(item.id) && !store.sidebarCollapsed">
<button
v-for="child in item.children.filter(c => isMenuVisible(c))"
:key="child.id"
class="menu-item menu-child"
:class="{ active: store.activePage === child.id }"
@click="store.navigateTo(child.id)"
>
<span class="menu-icon child-icon">{{ child.icon }}</span>
<span class="menu-label">{{ child.label }}</span>
</button>
</div>
</div>
<!-- 无子菜单的直接导航项 -->
<button <button
class="menu-item menu-parent"
:class="{ active: isMenuActive(item) }"
@click="store.toggleMenu(item.id)"
v-else
class="menu-item"
:class="{ active: store.activePage === item.id }"
@click="store.navigateTo(item.id)"
:title="store.sidebarCollapsed ? item.label : ''" :title="store.sidebarCollapsed ? item.label : ''"
> >
<span class="menu-icon">{{ item.icon }}</span> <span class="menu-icon">{{ item.icon }}</span>
<span class="menu-label">{{ item.label }}</span> <span class="menu-label">{{ item.label }}</span>
<span class="menu-arrow" :class="{ expanded: store.expandedMenus.includes(item.id) }"></span>
</button> </button>
<!-- 子菜单项 -->
<div class="submenu" v-show="store.expandedMenus.includes(item.id) && !store.sidebarCollapsed">
<button
v-for="child in item.children"
:key="child.id"
class="menu-item menu-child"
:class="{ active: store.activePage === child.id }"
@click="store.navigateTo(child.id)"
>
<span class="menu-icon child-icon">{{ child.icon }}</span>
<span class="menu-label">{{ child.label }}</span>
</button>
</div>
</div>
<!-- 无子菜单的直接导航项 -->
<button
v-else
class="menu-item"
:class="{ active: store.activePage === item.id }"
@click="store.navigateTo(item.id)"
:title="store.sidebarCollapsed ? item.label : ''"
>
<span class="menu-icon">{{ item.icon }}</span>
<span class="menu-label">{{ item.label }}</span>
</button>
</template>
</nav>
<!-- 侧边栏折叠按钮 -->
<button class="sidebar-collapse-btn" @click="store.toggleSidebar()">
<span :style="{ transform: store.sidebarCollapsed ? 'rotate(180deg)' : '' }"></span>
</button>
</div>
</aside>
<!-- 右侧内容区 -->
<main class="main" :class="{ 'main-chat': store.activePage === 'chat' }">
</template>
</nav>
<!-- 智能客服对话 -->
<div v-if="store.activePage === 'chat'" class="page-pane" style="animation: fadeIn .3s ease;">
<chat-panel></chat-panel>
</div>
<!-- 侧边栏折叠按钮 -->
<button class="sidebar-collapse-btn" @click="store.toggleSidebar()">
<span :style="{ transform: store.sidebarCollapsed ? 'rotate(180deg)' : '' }"></span>
</button>
</div>
</aside>
<!-- 知识库 - 统计概览 -->
<div v-if="store.activePage === 'stats'" class="page-pane" style="animation: fadeIn .3s ease;">
<doc-stats></doc-stats>
</div>
<!-- 右侧内容区 -->
<main class="main" :class="{ 'main-chat': store.activePage === 'chat' }">
<!-- 知识库 - 文档管理 -->
<div v-if="store.activePage === 'doc-manage'" class="page-pane" style="animation: fadeIn .3s ease;">
<doc-list></doc-list>
<doc-upload></doc-upload>
</div>
<!-- 智能客服对话 -->
<div v-if="store.activePage === 'chat'" class="page-pane" style="animation: fadeIn .3s ease;">
<chat-panel></chat-panel>
</div>
<!-- 知识库 - 分类管理 -->
<div v-if="store.activePage === 'category'" class="page-pane" style="animation: fadeIn .3s ease;">
<category-manager></category-manager>
</div>
<!-- 知识库 - 统计概览 -->
<div v-if="store.activePage === 'stats'" class="page-pane" style="animation: fadeIn .3s ease;">
<doc-stats></doc-stats>
</div>
<!-- 知识库 - 搜索测试 -->
<div v-if="store.activePage === 'search-test'" class="page-pane" style="animation: fadeIn .3s ease;">
<doc-search></doc-search>
</div>
<!-- 知识库 - 文档管理 -->
<div v-if="store.activePage === 'doc-manage'" class="page-pane" style="animation: fadeIn .3s ease;">
<doc-list></doc-list>
<doc-upload></doc-upload>
</div>
<!-- 知识库 - FAQ 管理 -->
<div v-if="store.activePage === 'faq'" class="page-pane" style="animation: fadeIn .3s ease;">
<faq-manager></faq-manager>
</div>
<!-- 知识库 - 分类管理 -->
<div v-if="store.activePage === 'category'" class="page-pane" style="animation: fadeIn .3s ease;">
<category-manager></category-manager>
</div>
<!-- 会话管理 -->
<div v-if="store.activePage === 'conversation'" class="page-pane" style="animation: fadeIn .3s ease;">
<conversation-manager></conversation-manager>
</div>
<!-- 知识库 - 搜索测试 -->
<div v-if="store.activePage === 'search-test'" class="page-pane" style="animation: fadeIn .3s ease;">
<doc-search></doc-search>
</div>
<!-- 系统设置 - 账号管理 -->
<div v-if="store.activePage === 'account'" class="page-pane" style="animation: fadeIn .3s ease;">
<account-manager></account-manager>
</div>
<!-- 知识库 - FAQ 管理 -->
<div v-if="store.activePage === 'faq'" class="page-pane" style="animation: fadeIn .3s ease;">
<faq-manager></faq-manager>
</div>
<!-- 系统设置 - 角色管理 -->
<div v-if="store.activePage === 'role'" class="page-pane" style="animation: fadeIn .3s ease;">
<role-manager></role-manager>
</div>
<!-- 会话管理 -->
<div v-if="store.activePage === 'conversation'" class="page-pane" style="animation: fadeIn .3s ease;">
<conversation-manager></conversation-manager>
</div>
<!-- 系统设置 - 模型配置 -->
<div v-if="store.activePage === 'model-config'" class="page-pane" style="animation: fadeIn .3s ease;">
<model-config-manager></model-config-manager>
</div>
<!-- 运营看板 -->
<div v-if="store.activePage === 'dashboard'" class="page-pane" style="animation: fadeIn .3s ease;">
<dashboard-panel></dashboard-panel>
</div>
<!-- 系统设置 - 敏感词管理 -->
<div v-if="store.activePage === 'sensitive'" class="page-pane" style="animation: fadeIn .3s ease;">
<sensitive-word-manager></sensitive-word-manager>
</div>
<!-- 系统设置 - 客服账号管理 -->
<div v-if="store.activePage === 'account'" class="page-pane" style="animation: fadeIn .3s ease;">
<account-manager></account-manager>
</div>
<!-- 系统设置 - 审计日志 -->
<div v-if="store.activePage === 'audit-log'" class="page-pane" style="animation: fadeIn .3s ease;">
<audit-log-manager></audit-log-manager>
</div>
<!-- 系统设置 - 客服角色管理 -->
<div v-if="store.activePage === 'role'" class="page-pane" style="animation: fadeIn .3s ease;">
<role-manager></role-manager>
</div>
<!-- 系统设置 - 模型配置 -->
<div v-if="store.activePage === 'model-config'" class="page-pane" style="animation: fadeIn .3s ease;">
<model-config-manager></model-config-manager>
</div>
<!-- 系统设置 - 敏感词管理 -->
<div v-if="store.activePage === 'sensitive'" class="page-pane" style="animation: fadeIn .3s ease;">
<sensitive-word-manager></sensitive-word-manager>
</div>
<!-- 系统设置 - 审计日志 -->
<div v-if="store.activePage === 'audit-log'" class="page-pane" style="animation: fadeIn .3s ease;">
<audit-log-manager></audit-log-manager>
</div>
<!-- 系统设置 - 用户管理 -->
<div v-if="store.activePage === 'user-manage'" class="page-pane" style="animation: fadeIn .3s ease;">
<user-manager></user-manager>
</div>
<!-- 系统设置 - API Key 管理 -->
<div v-if="store.activePage === 'api-key'" class="page-pane" style="animation: fadeIn .3s ease;">
<api-key-manager></api-key-manager>
</div>
<!-- 系统设置 - Webhook 管理 -->
<div v-if="store.activePage === 'webhook'" class="page-pane" style="animation: fadeIn .3s ease;">
<webhook-manager></webhook-manager>
</div>
<!-- 文档详情弹窗 -->
<doc-detail></doc-detail>
</main>
</div>
<!-- 文档详情弹窗 -->
<doc-detail></doc-detail>
</main>
</div>
</template>
<!-- Toast 容器 --> <!-- Toast 容器 -->
<div class="toast-container" id="toasts"></div> <div class="toast-container" id="toasts"></div>
@ -194,5 +310,10 @@ app.component('model-config-manager', ModelConfigManager)
app.component('sensitive-word-manager', SensitiveWordManager) app.component('sensitive-word-manager', SensitiveWordManager)
app.component('faq-manager', FaqManager) app.component('faq-manager', FaqManager)
app.component('audit-log-manager', AuditLogManager) app.component('audit-log-manager', AuditLogManager)
app.component('login-page', LoginPage)
app.component('user-manager', UserManager)
app.component('dashboard-panel', DashboardPanel)
app.component('api-key-manager', ApiKeyManager)
app.component('webhook-manager', WebhookManager)
app.mount('#app') app.mount('#app')

43
src/main/resources/static/js/store.js

@ -4,6 +4,7 @@
*/ */
import { reactive } from 'vue' import { reactive } from 'vue'
import * as api from './api.js' import * as api from './api.js'
import { getUserInfo, isLoggedIn } from './utils.js'
// ==================== 菜单配置 ==================== // ==================== 菜单配置 ====================
// 数据驱动菜单渲染,未来增删菜单只改此数组 // 数据驱动菜单渲染,未来增删菜单只改此数组
@ -30,21 +31,47 @@ export const MENU_ITEMS = [
label: '会话管理', label: '会话管理',
icon: '💬' icon: '💬'
}, },
{
id: 'dashboard',
label: '运营看板',
icon: '📈'
},
{ {
id: 'settings', id: 'settings',
label: '系统设置', label: '系统设置',
icon: '⚙️', icon: '⚙️',
children: [ children: [
{ id: 'account', label: '账号管理', icon: '👤' },
{ id: 'role', label: '角色管理', icon: '🎭' },
{ id: 'model-config', label: '模型配置', icon: '🤖' },
{ id: 'sensitive', label: '敏感词管理', icon: '🛡️' },
{ id: 'audit-log', label: '审计日志', icon: '📋' }
{ id: 'account', label: '客服账号管理', icon: '👤' },
{ id: 'role', label: '客服角色管理', icon: '🎭' },
{ id: 'model-config', label: '模型配置', icon: '🤖' },
{ id: 'sensitive', label: '敏感词管理', icon: '🛡️' },
{ id: 'audit-log', label: '审计日志', icon: '📋' },
{ id: 'user-manage', label: '用户管理', icon: '👥', roles: ['admin'] },
{ id: 'api-key', label: 'API Key 管理', icon: '🔑', roles: ['admin'] },
{ id: 'webhook', label: 'Webhook 管理', icon: '🔔', roles: ['admin'] }
] ]
} }
] ]
export const store = reactive({ export const store = reactive({
// ==================== 当前登录用户 ====================
currentUser: getUserInfo(),
isLoggedIn: isLoggedIn(),
/**
* 判断当前用户是否包含指定角色
*/
hasRole(role) {
return (this.currentUser.roles || []).includes(role)
},
/**
* 判断当前用户是否为管理员
*/
get isAdmin() {
return this.hasRole('admin')
},
// ==================== 分类数据 ==================== // ==================== 分类数据 ====================
categories: [], categories: [],
categoryMap: {}, categoryMap: {},
@ -193,6 +220,12 @@ export const store = reactive({
case 'role': case 'role':
this.loadCategories() this.loadCategories()
break break
case 'dashboard':
case 'user-manage':
case 'api-key':
case 'webhook':
// 新页面无额外数据预加载
break
} }
} }
}) })

55
src/main/resources/static/js/utils.js

@ -1,6 +1,6 @@
/** /**
* 工具函数模块 * 工具函数模块
* 提供 Toast 提示格式化SSE 流式读取Markdown 渲染等通用能力
* 提供 Toast 提示格式化SSE 流式读取Markdown 渲染Token 管理等通用能力
*/ */
import { marked } from 'marked' import { marked } from 'marked'
import DOMPurify from 'dompurify' import DOMPurify from 'dompurify'
@ -11,6 +11,54 @@ marked.setOptions({ breaks: true, gfm: true })
// API 基址:空字符串 = 同源部署,由 Spring Boot 直接服务前端 // API 基址:空字符串 = 同源部署,由 Spring Boot 直接服务前端
export const API_BASE = '' export const API_BASE = ''
// ==================== Token 管理 ====================
export function getToken() {
return localStorage.getItem('sb_token') || ''
}
export function setToken(token) {
localStorage.setItem('sb_token', token)
}
export function getRefreshToken() {
return localStorage.getItem('sb_refresh_token') || ''
}
export function setRefreshToken(token) {
localStorage.setItem('sb_refresh_token', token)
}
export function clearTokens() {
localStorage.removeItem('sb_token')
localStorage.removeItem('sb_refresh_token')
localStorage.removeItem('sb_user')
}
export function getUserInfo() {
try {
return JSON.parse(localStorage.getItem('sb_user') || '{}')
} catch { return {} }
}
export function setUserInfo(info) {
localStorage.setItem('sb_user', JSON.stringify(info))
}
export function isLoggedIn() {
return !!getToken()
}
/**
* 获取含认证信息的请求头
*/
export function authHeaders() {
const token = getToken()
const headers = {}
if (token) headers['Authorization'] = 'Bearer ' + token
return headers
}
// ==================== Toast 提示 ==================== // ==================== Toast 提示 ====================
let _toastContainer = null let _toastContainer = null
@ -77,9 +125,10 @@ export function formatDate(d) {
* @param {string} url 请求地址 * @param {string} url 请求地址
* @param {function(string): void} onChunk 每收到一段文本的回调 * @param {function(string): void} onChunk 每收到一段文本的回调
* @param {function(): void} onDone 流结束的回调 * @param {function(): void} onDone 流结束的回调
* @param {object} [headers] 额外请求头
*/ */
export async function readSSEStream(url, onChunk, onDone) {
const res = await fetch(url)
export async function readSSEStream(url, onChunk, onDone, headers) {
const res = await fetch(url, { headers: headers || {} })
if (!res.ok) throw new Error('HTTP ' + res.status) if (!res.ok) throw new Error('HTTP ' + res.status)
const reader = res.body.getReader() const reader = res.body.getReader()
const decoder = new TextDecoder() const decoder = new TextDecoder()

Loading…
Cancel
Save