64 changed files with 5854 additions and 174 deletions
-
25pom.xml
-
4src/main/java/com/wok/supportbot/SupportBotApplication.java
-
38src/main/java/com/wok/supportbot/app/AssistantApp.java
-
162src/main/java/com/wok/supportbot/auth/AuthController.java
-
350src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java
-
8src/main/java/com/wok/supportbot/controller/AiModelConfigController.java
-
183src/main/java/com/wok/supportbot/controller/ApiKeyController.java
-
36src/main/java/com/wok/supportbot/controller/ConversationController.java
-
6src/main/java/com/wok/supportbot/controller/CustomerAccountController.java
-
7src/main/java/com/wok/supportbot/controller/CustomerServiceRoleController.java
-
101src/main/java/com/wok/supportbot/controller/DashboardController.java
-
22src/main/java/com/wok/supportbot/controller/DocumentController.java
-
14src/main/java/com/wok/supportbot/controller/FaqController.java
-
4src/main/java/com/wok/supportbot/controller/MessageFeedbackController.java
-
149src/main/java/com/wok/supportbot/controller/OpenApiController.java
-
7src/main/java/com/wok/supportbot/controller/SensitiveWordController.java
-
85src/main/java/com/wok/supportbot/controller/SysRoleController.java
-
197src/main/java/com/wok/supportbot/controller/SysUserController.java
-
190src/main/java/com/wok/supportbot/controller/WebhookController.java
-
12src/main/java/com/wok/supportbot/dao/ApiKeyMapper.java
-
12src/main/java/com/wok/supportbot/dao/DashboardSnapshotMapper.java
-
12src/main/java/com/wok/supportbot/dao/RagHitLogMapper.java
-
12src/main/java/com/wok/supportbot/dao/SysPermissionMapper.java
-
12src/main/java/com/wok/supportbot/dao/SysRoleMapper.java
-
12src/main/java/com/wok/supportbot/dao/SysUserMapper.java
-
12src/main/java/com/wok/supportbot/dao/SysUserRoleMapper.java
-
12src/main/java/com/wok/supportbot/dao/WebhookConfigMapper.java
-
83src/main/java/com/wok/supportbot/entity/ApiKey.java
-
9src/main/java/com/wok/supportbot/entity/ChatMessage.java
-
92src/main/java/com/wok/supportbot/entity/DashboardSnapshot.java
-
9src/main/java/com/wok/supportbot/entity/KnowledgeFaq.java
-
66src/main/java/com/wok/supportbot/entity/RagHitLog.java
-
54src/main/java/com/wok/supportbot/entity/SysPermission.java
-
62src/main/java/com/wok/supportbot/entity/SysRole.java
-
83src/main/java/com/wok/supportbot/entity/SysUser.java
-
45src/main/java/com/wok/supportbot/entity/SysUserRole.java
-
73src/main/java/com/wok/supportbot/entity/WebhookConfig.java
-
113src/main/java/com/wok/supportbot/openapi/ApiKeyAuthFilter.java
-
106src/main/java/com/wok/supportbot/openapi/SlidingWindowRateLimiter.java
-
65src/main/java/com/wok/supportbot/security/JwtAuthFilter.java
-
108src/main/java/com/wok/supportbot/security/JwtTokenProvider.java
-
115src/main/java/com/wok/supportbot/security/SecurityConfig.java
-
170src/main/java/com/wok/supportbot/service/ApiKeyService.java
-
16src/main/java/com/wok/supportbot/service/ConversationService.java
-
352src/main/java/com/wok/supportbot/service/DashboardService.java
-
4src/main/java/com/wok/supportbot/service/FaqMatchEngine.java
-
11src/main/java/com/wok/supportbot/service/FaqService.java
-
84src/main/java/com/wok/supportbot/service/RagHitLogService.java
-
73src/main/java/com/wok/supportbot/service/SysRoleService.java
-
270src/main/java/com/wok/supportbot/service/SysUserService.java
-
191src/main/java/com/wok/supportbot/service/WebhookService.java
-
13src/main/resources/application.yml
-
250src/main/resources/static/components/ApiKeyManager.js
-
340src/main/resources/static/components/DashboardPanel.js
-
79src/main/resources/static/components/FaqManager.js
-
71src/main/resources/static/components/LoginPage.js
-
315src/main/resources/static/components/UserManager.js
-
216src/main/resources/static/components/WebhookManager.js
-
83src/main/resources/static/css/main.css
-
3src/main/resources/static/index.html
-
261src/main/resources/static/js/api.js
-
131src/main/resources/static/js/app.js
-
39src/main/resources/static/js/store.js
-
55src/main/resources/static/js/utils.js
@ -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)); |
|||
} |
|||
} |
|||
@ -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() |
|||
)); |
|||
} |
|||
} |
|||
} |
|||
@ -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() |
|||
)); |
|||
} |
|||
} |
|||
} |
|||
@ -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 鉴权信息缺失"); |
|||
} |
|||
} |
|||
@ -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() |
|||
)); |
|||
} |
|||
} |
|||
} |
|||
@ -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() |
|||
)); |
|||
} |
|||
} |
|||
} |
|||
@ -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() |
|||
)); |
|||
} |
|||
} |
|||
} |
|||
@ -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> { |
|||
} |
|||
@ -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> { |
|||
} |
|||
@ -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> { |
|||
} |
|||
@ -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> { |
|||
} |
|||
@ -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> { |
|||
} |
|||
@ -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> { |
|||
} |
|||
@ -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> { |
|||
} |
|||
@ -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> { |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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)); |
|||
} |
|||
} |
|||
@ -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()); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -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; |
|||
} |
|||
} |
|||
@ -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(); |
|||
} |
|||
} |
|||
@ -0,0 +1,115 @@ |
|||
package com.wok.supportbot.security; |
|||
|
|||
import org.springframework.context.annotation.Bean; |
|||
import org.springframework.context.annotation.Configuration; |
|||
import org.springframework.http.HttpMethod; |
|||
import org.springframework.security.authentication.AuthenticationManager; |
|||
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; |
|||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; |
|||
import org.springframework.security.config.annotation.web.builders.HttpSecurity; |
|||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; |
|||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; |
|||
import org.springframework.security.config.http.SessionCreationPolicy; |
|||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; |
|||
import org.springframework.security.crypto.password.PasswordEncoder; |
|||
import org.springframework.security.web.SecurityFilterChain; |
|||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; |
|||
import org.springframework.web.cors.CorsConfiguration; |
|||
import org.springframework.web.cors.CorsConfigurationSource; |
|||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource; |
|||
|
|||
import java.util.List; |
|||
|
|||
/** |
|||
* Spring Security 安全配置 |
|||
* 无状态 JWT 认证,白名单策略 |
|||
*/ |
|||
@Configuration |
|||
@EnableWebSecurity |
|||
@EnableMethodSecurity |
|||
public class SecurityConfig { |
|||
|
|||
private final JwtAuthFilter jwtAuthFilter; |
|||
|
|||
public SecurityConfig(JwtAuthFilter jwtAuthFilter) { |
|||
this.jwtAuthFilter = jwtAuthFilter; |
|||
} |
|||
|
|||
@Bean |
|||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { |
|||
http |
|||
.csrf(AbstractHttpConfigurer::disable) |
|||
.cors(cors -> cors.configurationSource(corsConfigurationSource())) |
|||
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) |
|||
.authorizeHttpRequests(auth -> auth |
|||
// ========== 公开接口(无需认证) ========== |
|||
// SDK 对话接口(方案A:不认证,SDK 直接调用) |
|||
.requestMatchers("/ai/**").permitAll() |
|||
// 开放 API(由 ApiKeyAuthFilter 独立鉴权) |
|||
.requestMatchers("/open-api/**").permitAll() |
|||
// 登录/刷新 Token |
|||
.requestMatchers("/auth/login", "/auth/refresh").permitAll() |
|||
// SDK 需要的接口(feedback、分类、会话查询等) |
|||
.requestMatchers("/feedback").permitAll() |
|||
.requestMatchers("/category/tree", "/category/list").permitAll() |
|||
.requestMatchers("/conversation/list").permitAll() |
|||
.requestMatchers(HttpMethod.GET, "/conversation/*/messages").permitAll() |
|||
.requestMatchers(HttpMethod.DELETE, "/conversation/*").permitAll() |
|||
.requestMatchers(HttpMethod.GET, "/conversation/*/export").permitAll() |
|||
.requestMatchers(HttpMethod.POST, "/conversation/*/truncate").permitAll() |
|||
.requestMatchers("/conversation/stats").permitAll() |
|||
// Knife4j / Swagger |
|||
.requestMatchers("/doc.html", "/swagger-ui/**", "/v3/api-docs/**", "/webjars/**").permitAll() |
|||
// 静态资源 |
|||
.requestMatchers("/sdk/**", "/css/**", "/js/**", "/components/**").permitAll() |
|||
.requestMatchers("/index.html", "/", "/favicon.ico").permitAll() |
|||
|
|||
// ========== 管理接口(需要认证) ========== |
|||
.anyRequest().authenticated() |
|||
) |
|||
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) |
|||
.exceptionHandling(ex -> ex |
|||
.authenticationEntryPoint((req, res, authEx) -> { |
|||
res.setContentType("application/json;charset=UTF-8"); |
|||
res.setStatus(401); |
|||
res.getWriter().write("{\"success\":false,\"code\":401,\"message\":\"未登录或 Token 已过期\"}"); |
|||
}) |
|||
.accessDeniedHandler((req, res, accessEx) -> { |
|||
res.setContentType("application/json;charset=UTF-8"); |
|||
res.setStatus(403); |
|||
res.getWriter().write("{\"success\":false,\"code\":403,\"message\":\"权限不足\"}"); |
|||
}) |
|||
); |
|||
return http.build(); |
|||
} |
|||
|
|||
@Bean |
|||
public PasswordEncoder passwordEncoder() { |
|||
return new BCryptPasswordEncoder(); |
|||
} |
|||
|
|||
/** |
|||
* 暴露 AuthenticationManager Bean(AuthController 登录验证需要) |
|||
*/ |
|||
@Bean |
|||
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { |
|||
return config.getAuthenticationManager(); |
|||
} |
|||
|
|||
/** |
|||
* CORS 配置(与原 CorsConfig 保持一致) |
|||
* Spring Security 会接管 CORS 处理,原 CorsConfig 中的 WebMvcConfigurer 配置需保留以支持非 Security 管理的路径 |
|||
*/ |
|||
@Bean |
|||
public CorsConfigurationSource corsConfigurationSource() { |
|||
CorsConfiguration config = new CorsConfiguration(); |
|||
config.setAllowedOriginPatterns(List.of("*")); |
|||
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); |
|||
config.setAllowedHeaders(List.of("*")); |
|||
config.setExposedHeaders(List.of("*")); |
|||
config.setAllowCredentials(true); |
|||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); |
|||
source.registerCorsConfiguration("/**", config); |
|||
return source; |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
} |
|||
@ -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(); |
|||
} |
|||
} |
|||
@ -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; |
|||
} |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
@ -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(); |
|||
} |
|||
} |
|||
@ -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 回调 URL(null 则不更新) |
|||
* @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; |
|||
} |
|||
} |
|||
@ -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 |
|||
} |
|||
} |
|||
} |
|||
@ -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 |
|||
} |
|||
} |
|||
} |
|||
@ -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 } |
|||
} |
|||
} |
|||
@ -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 |
|||
} |
|||
} |
|||
} |
|||
@ -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 |
|||
} |
|||
} |
|||
} |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue