Browse Source

feat(security): JWT token_version 服务端撤销与登录安全加固

- 新增 token_version 服务端撤销机制:改密/禁用/角色变更/登出时自增,使已签发 token 立即失效
- refresh token 从 localStorage 迁移到 httpOnly Cookie,access token 有效期缩短至 15 分钟
- 新增登录失败锁定(LoginAttemptService)与密码强度校验(PasswordValidator)
- SDK JWT 密钥支持环境变量注入,使用默认密钥时告警

修复:
- assignRoles 角色未变化仍自增 token_version,导致用户新建用户时 token 被误失效而跳转登录
- 角色 ID 解析兼容前端字符串传输,避免雪花 ID 精度丢失导致 ClassCastException
- updateFill 改用 setFieldValByName,修复 update_time 未自动填充的问题
Spring-AI-1.1.2
wanghanlin 3 weeks ago
parent
commit
9338fcbd5f
  1. 30
      frontend/src/App.vue
  2. 16
      frontend/src/api/auth.ts
  3. 66
      frontend/src/api/request.ts
  4. 4
      frontend/src/layouts/MainLayout.vue
  5. 91
      frontend/src/layouts/Topbar.vue
  6. 18
      frontend/src/stores/auth.ts
  7. 6
      frontend/src/types/models.ts
  8. 4
      frontend/src/utils/sse.ts
  9. 21
      frontend/src/utils/token.ts
  10. 5
      frontend/src/views/LoginPage.vue
  11. 201
      src/main/java/com/wok/supportbot/auth/AuthController.java
  12. 21
      src/main/java/com/wok/supportbot/config/CorsConfig.java
  13. 51
      src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java
  14. 32
      src/main/java/com/wok/supportbot/controller/SysUserController.java
  15. 4
      src/main/java/com/wok/supportbot/entity/SysUser.java
  16. 5
      src/main/java/com/wok/supportbot/handler/MyMetaObjectHandler.java
  17. 43
      src/main/java/com/wok/supportbot/security/JwtAuthFilter.java
  18. 81
      src/main/java/com/wok/supportbot/security/JwtTokenProvider.java
  19. 95
      src/main/java/com/wok/supportbot/security/LoginAttemptService.java
  20. 48
      src/main/java/com/wok/supportbot/security/PasswordValidator.java
  21. 39
      src/main/java/com/wok/supportbot/security/SdkAuthFilter.java
  22. 18
      src/main/java/com/wok/supportbot/security/SdkJwtTokenProvider.java
  23. 17
      src/main/java/com/wok/supportbot/security/SecurityConfig.java
  24. 63
      src/main/java/com/wok/supportbot/service/SysUserService.java
  25. 15
      src/main/resources/application-dev.yml
  26. 16
      src/main/resources/application-prod.yml
  27. 28
      src/main/resources/application.yml
  28. 210
      src/main/resources/init-database.sql
  29. 8
      src/main/resources/static/sdk/test.html
  30. 16
      src/main/resources/support-bot.sql

30
frontend/src/App.vue

@ -6,11 +6,11 @@
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { onMounted, onUnmounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import LoginPage from '@/views/LoginPage.vue'
import MainLayout from '@/layouts/MainLayout.vue'
import { isLoggedIn, clearTokens } from '@/utils/token'
import { isLoggedIn, cleanupLegacyTokens } from '@/utils/token'
import { toast } from '@/utils/toast'
const auth = useAuthStore()
@ -19,7 +19,16 @@ function onLoginSuccess(user: any) {
auth.onLoginSuccess(user)
}
// 401
async function handleUnauthorized() {
await auth.doLogout()
toast('登录已过期,请重新登录', 'error')
}
onMounted(async () => {
// refresh token
cleanupLegacyTokens()
//
if (isLoggedIn()) {
const ok = await auth.fetchUser()
@ -29,20 +38,15 @@ onMounted(async () => {
if (refreshed) {
await auth.fetchUser().catch(() => {})
} else {
auth.logout()
await auth.doLogout()
}
}
}
// 401
window.addEventListener('auth:unauthorized', async () => {
const refreshed = await auth.tryRefreshToken()
if (refreshed) {
await auth.fetchUser().catch(() => {})
} else {
auth.logout()
toast('登录已过期,请重新登录', 'error')
}
})
window.addEventListener('auth:unauthorized', handleUnauthorized)
})
onUnmounted(() => {
window.removeEventListener('auth:unauthorized', handleUnauthorized)
})
</script>

16
frontend/src/api/auth.ts

@ -6,9 +6,19 @@ export function login(username: string, password: string): Promise<ApiResponse<T
return request.post('/auth/login', { username, password }).then(r => r.data)
}
/** 刷新 Token */
export function refreshToken(refreshToken: string): Promise<ApiResponse<TokenResponse>> {
return request.post('/auth/refresh', { refreshToken }).then(r => r.data)
/** 刷新 Token(refresh token 通过 httpOnly Cookie 自动携带) */
export function refreshToken(): Promise<ApiResponse<TokenResponse>> {
return request.post('/auth/refresh').then(r => r.data)
}
/** 退出登录(清除服务端 refresh token cookie) */
export function logout(): Promise<ApiResponse<void>> {
return request.post('/auth/logout').then(r => r.data)
}
/** 当前登录用户自助修改密码 */
export function changePassword(oldPassword: string, newPassword: string): Promise<ApiResponse<void>> {
return request.put('/auth/password', { oldPassword, newPassword }).then(r => r.data)
}
/** 获取当前登录用户信息 */

66
frontend/src/api/request.ts

@ -3,13 +3,15 @@
* api.js fetch
*/
import axios from 'axios'
import { getToken } from '@/utils/token'
import { getToken, setToken, clearTokens } from '@/utils/token'
/** 同源部署,API 基址为空 */
const request = axios.create({
baseURL: '',
timeout: 30000,
headers: { 'Content-Type': 'application/json' },
// 允许跨域携带 Cookie(refresh token 使用 httpOnly Cookie)
withCredentials: true,
})
/** 请求拦截器:自动附加 Bearer Token */
@ -22,18 +24,64 @@ request.interceptors.request.use((config) => {
})
/**
* 401 auth:unauthorized
* app.js Token
* Token
* - 401 /auth/refresh token
* -
*/
let isRefreshing = false
let pendingQueue: Array<(token: string) => void> = []
/**
* 401 access token
* /auth/login /auth/refresh 401
*/
request.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('sb_token')
localStorage.removeItem('sb_user')
window.dispatchEvent(new Event('auth:unauthorized'))
async (error) => {
const { config, response } = error
const url: string = config?.url || ''
if (response?.status === 401 && !url.includes('/auth/login') && !url.includes('/auth/refresh')) {
// 已有刷新在进行中,挂起当前请求,等待新 token 后重试
if (isRefreshing) {
return new Promise((resolve, reject) => {
pendingQueue.push((token: string) => {
if (token) {
resolve(request(config))
} else {
reject(new Error(response?.data?.message || error.message || '网络错误'))
}
})
})
}
// 发起刷新(request 的 url 为 /auth/refresh,其 401 会被上方判断跳过,不会递归)
isRefreshing = true
try {
const res = await request.post('/auth/refresh')
const newToken = res.data?.data?.accessToken
if (newToken) {
setToken(newToken)
// 放行所有挂起请求(请求拦截器会自动附加新 token)
pendingQueue.forEach((cb) => cb(newToken))
pendingQueue = []
// 重试当前请求
return request(config)
}
throw new Error('刷新令牌失败')
} catch (refreshErr) {
// 刷新失败:清除本地凭证并触发全局登出
clearTokens()
pendingQueue.forEach((cb) => cb(''))
pendingQueue = []
window.dispatchEvent(new Event('auth:unauthorized'))
return Promise.reject(new Error(response?.data?.message || error.message || '网络错误'))
} finally {
isRefreshing = false
}
}
const message = error.response?.data?.message || error.message || '网络错误'
const message = response?.data?.message || error.message || '网络错误'
return Promise.reject(new Error(message))
}
)

4
frontend/src/layouts/MainLayout.vue

@ -50,8 +50,8 @@ const mobileMenuOpen = ref(false)
//
provide('openDocDetail', (docId: string) => dialog.openDetail(docId))
function handleLogout() {
auth.logout()
async function handleLogout() {
await auth.doLogout()
toast('已退出登录', 'info')
router.push('/chat')
}

91
frontend/src/layouts/Topbar.vue

@ -13,21 +13,108 @@
<span class="user-name">{{ displayName }}</span>
<t-tag v-for="r in roles" :key="r" size="small" variant="light" theme="primary">{{ r }}</t-tag>
</span>
<t-button size="small" variant="outline" theme="default" @click="openPasswordDialog">改密</t-button>
<t-button size="small" variant="outline" theme="default" @click="$emit('logout')">退出</t-button>
</div>
<t-dialog
v-model:visible="passwordVisible"
header="修改密码"
width="400px"
:confirm-btn="{ content: '确认修改', loading: passwordLoading }"
:on-confirm="submitPassword"
@close="resetPasswordForm"
>
<t-form :data="passwordForm" :rules="passwordRules" label-width="80px">
<t-form-item label="旧密码" name="oldPassword">
<t-input v-model="passwordForm.oldPassword" type="password" placeholder="请输入旧密码" />
</t-form-item>
<t-form-item label="新密码" name="newPassword">
<t-input v-model="passwordForm.newPassword" type="password" placeholder="8-64 位,含字母和数字" />
</t-form-item>
<t-form-item label="确认密码" name="confirmPassword">
<t-input v-model="passwordForm.confirmPassword" type="password" placeholder="请再次输入新密码" />
</t-form-item>
</t-form>
</t-dialog>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, reactive, ref } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { changePassword } from '@/api/auth'
import { toast } from '@/utils/toast'
defineEmits<{ toggleMobileMenu: []; logout: [] }>()
const emit = defineEmits<{ toggleMobileMenu: []; logout: [] }>()
const auth = useAuthStore()
const displayName = computed(() => auth.currentUser.nickname || auth.currentUser.username || '')
const avatarChar = computed(() => (auth.currentUser.nickname || auth.currentUser.username || 'U').charAt(0))
const roles = computed(() => auth.currentUser.roles || [])
//
const passwordVisible = ref(false)
const passwordLoading = ref(false)
const passwordForm = reactive({
oldPassword: '',
newPassword: '',
confirmPassword: '',
})
const passwordRules = {
oldPassword: [{ required: true, message: '请输入旧密码' }],
newPassword: [{ required: true, message: '请输入新密码' }],
confirmPassword: [{ required: true, message: '请确认新密码' }],
}
function openPasswordDialog() {
resetPasswordForm()
passwordVisible.value = true
}
function resetPasswordForm() {
passwordForm.oldPassword = ''
passwordForm.newPassword = ''
passwordForm.confirmPassword = ''
}
async function submitPassword() {
if (!passwordForm.oldPassword || !passwordForm.newPassword || !passwordForm.confirmPassword) {
toast('请填写完整密码信息', 'error')
return
}
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
toast('两次输入的新密码不一致', 'error')
return
}
if (passwordForm.newPassword.length < 8 || passwordForm.newPassword.length > 64) {
toast('新密码长度需在 8-64 位之间', 'error')
return
}
const hasLetter = /[a-zA-Z]/.test(passwordForm.newPassword)
const hasDigit = /\d/.test(passwordForm.newPassword)
if (!hasLetter || !hasDigit) {
toast('新密码需同时包含字母和数字', 'error')
return
}
passwordLoading.value = true
try {
const res = await changePassword(passwordForm.oldPassword, passwordForm.newPassword)
if (res.success) {
toast(res.message || '密码修改成功,请重新登录', 'success')
passwordVisible.value = false
// token 退
emit('logout')
} else {
toast(res.message || '修改失败', 'error')
}
} catch (e: any) {
toast(e.message || '修改失败', 'error')
}
passwordLoading.value = false
}
</script>
<style scoped>

18
frontend/src/stores/auth.ts

@ -3,8 +3,8 @@
*/
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { getToken, isLoggedIn, clearTokens, getUserInfo, setUserInfo, setToken, setRefreshToken } from '@/utils/token'
import { getCurrentUser, refreshToken } from '@/api/auth'
import { getToken, isLoggedIn, clearTokens, getUserInfo, setUserInfo, setToken, cleanupLegacyTokens } from '@/utils/token'
import { getCurrentUser, refreshToken, logout } from '@/api/auth'
export const useAuthStore = defineStore('auth', () => {
const currentUser = ref<Record<string, any>>(getUserInfo())
@ -30,20 +30,20 @@ export const useAuthStore = defineStore('auth', () => {
}
async function tryRefreshToken(): Promise<boolean> {
const rt = localStorage.getItem('sb_refresh_token')
if (!rt) return false
try {
const res = await refreshToken(rt)
const res = await refreshToken()
if (res.success && res.data) {
setToken(res.data.accessToken)
setRefreshToken(res.data.refreshToken)
return true
}
} catch { /* ignore */ }
return false
}
function logout(): void {
async function doLogout(): Promise<void> {
try {
await logout()
} catch { /* ignore */ }
clearTokens()
loggedIn.value = false
currentUser.value = {}
@ -53,7 +53,9 @@ export const useAuthStore = defineStore('auth', () => {
currentUser.value = user || getUserInfo()
setUserInfo(user || {})
loggedIn.value = true
// 清理历史遗留的 localStorage refresh token
cleanupLegacyTokens()
}
return { currentUser, loggedIn, isAdmin, hasRole, fetchUser, tryRefreshToken, logout, onLoginSuccess }
return { currentUser, loggedIn, isAdmin, hasRole, fetchUser, tryRefreshToken, doLogout, onLoginSuccess }
})

6
frontend/src/types/models.ts

@ -15,10 +15,12 @@ export interface LoginRequest {
password: string
}
/** Token 响应 */
/** Token
* refreshToken httpOnly Cookie body
*/
export interface TokenResponse {
accessToken: string
refreshToken: string
refreshToken?: string
}
// ==================== 知识库文档 ====================

4
frontend/src/utils/sse.ts

@ -49,7 +49,7 @@ export async function readSSEStream(
headers?: Record<string, string>,
signal?: AbortSignal
): Promise<void> {
const res = await fetch(url, { headers: authHeaders(headers), signal })
const res = await fetch(url, { headers: authHeaders(headers), signal, credentials: 'include' })
if (!res.ok) throw new Error('HTTP ' + res.status)
const reader = res.body!.getReader()
const decoder = new TextDecoder()
@ -130,7 +130,7 @@ export async function readSSEStreamWithEvents(
signal?: AbortSignal
): Promise<void> {
const { onMessage, onToolCallStart, onToolCallResult, onError, onDone } = handlers
const res = await fetch(url, { headers: authHeaders(headers), signal })
const res = await fetch(url, { headers: authHeaders(headers), signal, credentials: 'include' })
if (!res.ok) throw new Error('HTTP ' + res.status)
const reader = res.body!.getReader()
const decoder = new TextDecoder()

21
frontend/src/utils/token.ts

@ -1,10 +1,9 @@
/**
* Token
* localStorage JWT Token
* Access Token
* localStorage access token refresh token httpOnly Cookie
*/
const TOKEN_KEY = 'sb_token'
const REFRESH_TOKEN_KEY = 'sb_refresh_token'
const USER_KEY = 'sb_user'
export function getToken(): string {
@ -15,17 +14,8 @@ export function setToken(token: string): void {
localStorage.setItem(TOKEN_KEY, token)
}
export function getRefreshToken(): string {
return localStorage.getItem(REFRESH_TOKEN_KEY) || ''
}
export function setRefreshToken(token: string): void {
localStorage.setItem(REFRESH_TOKEN_KEY, token)
}
export function clearTokens(): void {
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(REFRESH_TOKEN_KEY)
localStorage.removeItem(USER_KEY)
}
@ -44,3 +34,10 @@ export function setUserInfo(info: Record<string, any>): void {
export function isLoggedIn(): boolean {
return !!getToken()
}
/**
* refresh token
*/
export function cleanupLegacyTokens(): void {
localStorage.removeItem('sb_refresh_token')
}

5
frontend/src/views/LoginPage.vue

@ -23,7 +23,7 @@
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { login } from '@/api/auth'
import { setToken, setRefreshToken, setUserInfo } from '@/utils/token'
import { setToken, setUserInfo, cleanupLegacyTokens } from '@/utils/token'
import { getCurrentUser } from '@/api/auth'
const emit = defineEmits<{ loginSuccess: [user: any] }>()
@ -43,7 +43,7 @@ async function handleLogin() {
const res = await login(form.username, form.password)
if (res.success && res.data) {
setToken(res.data.accessToken)
setRefreshToken(res.data.refreshToken)
cleanupLegacyTokens()
//
const userRes = await getCurrentUser()
if (userRes.success && userRes.data) {
@ -56,6 +56,7 @@ async function handleLogin() {
errorMsg.value = res.message || '登录失败'
}
} catch (e: any) {
// Error message
errorMsg.value = e.message || '登录失败'
}
loading.value = false

201
src/main/java/com/wok/supportbot/auth/AuthController.java

@ -3,14 +3,22 @@ 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.security.LoginAttemptService;
import com.wok.supportbot.security.PasswordValidator;
import com.wok.supportbot.service.SysUserService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseCookie;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
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.AuthenticationException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;
import java.util.LinkedHashMap;
@ -19,7 +27,7 @@ import java.util.Map;
/**
* 认证控制器
* 处理登录Token 刷新当前用户信息查询
* 处理登录Token 刷新当前用户信息查询自助改密
*/
@Slf4j
@RestController
@ -29,28 +37,51 @@ public class AuthController {
private final AuthenticationManager authenticationManager;
private final JwtTokenProvider jwtTokenProvider;
private final SysUserService sysUserService;
private final PasswordEncoder passwordEncoder;
private final LoginAttemptService loginAttemptService;
private final PasswordValidator passwordValidator;
private final boolean refreshCookieSecure;
public AuthController(AuthenticationManager authenticationManager,
JwtTokenProvider jwtTokenProvider,
SysUserService sysUserService) {
SysUserService sysUserService,
PasswordEncoder passwordEncoder,
LoginAttemptService loginAttemptService,
PasswordValidator passwordValidator,
@Value("${jwt.refresh-cookie-secure:false}") boolean refreshCookieSecure) {
this.authenticationManager = authenticationManager;
this.jwtTokenProvider = jwtTokenProvider;
this.sysUserService = sysUserService;
this.passwordEncoder = passwordEncoder;
this.loginAttemptService = loginAttemptService;
this.passwordValidator = passwordValidator;
this.refreshCookieSecure = refreshCookieSecure;
}
/**
* 用户登录
* 登录成功后通过 httpOnly Cookie 返回 refresh tokenbody 中只返回 access token
*/
@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) {
if (username == null || password == null || username.isBlank() || password.isBlank()) {
return ResponseEntity.badRequest().body(Map.of(
"success", false, "message", "用户名和密码不能为空"
));
}
// 检查账号是否因暴力破解被锁定
int lockedSeconds = loginAttemptService.getLockedSeconds(username);
if (lockedSeconds > 0) {
int minutes = (lockedSeconds + 59) / 60;
return ResponseEntity.status(423).body(Map.of(
"success", false,
"message", "账号已锁定,请 " + minutes + " 分钟后重试"
));
}
try {
Authentication auth = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(username, password)
@ -62,33 +93,37 @@ public class AuthController {
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);
int tokenVersion = user.getTokenVersion() == null ? 0 : user.getTokenVersion();
String accessToken = jwtTokenProvider.generateToken(username, roleKeys, tokenVersion);
String refreshToken = jwtTokenProvider.generateRefreshToken(username, tokenVersion);
// 更新最后登录时间
// 更新最后登录时间并清除失败记录
sysUserService.updateLastLoginTime(user.getId());
loginAttemptService.recordSuccess(username);
// 构建用户信息
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());
Map<String, Object> userInfo = buildUserInfo(user, roleKeys);
ResponseCookie refreshCookie = buildRefreshCookie(refreshToken, 7 * 24 * 60 * 60L);
log.info("用户登录成功: {}", username);
return ResponseEntity.ok(Map.of(
"success", true,
"data", Map.of(
"accessToken", accessToken,
"refreshToken", refreshToken,
"user", userInfo
)
));
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, refreshCookie.toString())
.body(Map.of(
"success", true,
"data", Map.of(
"accessToken", accessToken,
"user", userInfo
)
));
} catch (BadCredentialsException e) {
return ResponseEntity.ok(Map.of(
// 密码错误等认证失败场景记录失败次数
loginAttemptService.recordFailure(username);
return ResponseEntity.status(401).body(Map.of(
"success", false, "message", "用户名或密码错误"
));
} catch (AuthenticationException e) {
// 用户不存在账号禁用等也统一返回 401避免泄露账号状态
loginAttemptService.recordFailure(username);
return ResponseEntity.status(401).body(Map.of(
"success", false, "message", "用户名或密码错误"
));
}
@ -96,34 +131,114 @@ public class AuthController {
/**
* 刷新 Token
* httpOnly Cookie 读取 refresh token换发新的 access token refresh cookie
*/
@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(
public ResponseEntity<Map<String, Object>> refresh(
@CookieValue(name = "refresh_token", required = false) String refreshToken) {
if (refreshToken == null || !jwtTokenProvider.validateRefreshToken(refreshToken)) {
return ResponseEntity.status(401).body(Map.of(
"success", false, "message", "刷新令牌无效或已过期"
));
}
String username = jwtTokenProvider.getUsernameFromToken(refreshToken);
int tokenVer = jwtTokenProvider.getTokenVersionFromToken(refreshToken);
SysUser user = sysUserService.getUserByUsername(username);
if (user == null || Boolean.FALSE.equals(user.getEnabled())) {
return ResponseEntity.ok(Map.of(
return ResponseEntity.status(401).body(Map.of(
"success", false, "message", "用户不存在或已被禁用"
));
}
int currentVer = user.getTokenVersion() == null ? 0 : user.getTokenVersion();
if (currentVer != tokenVer) {
return ResponseEntity.status(401).body(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);
String newAccessToken = jwtTokenProvider.generateToken(username, roleKeys, currentVer);
String newRefreshToken = jwtTokenProvider.generateRefreshToken(username, currentVer);
ResponseCookie refreshCookie = buildRefreshCookie(newRefreshToken, 7 * 24 * 60 * 60L);
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, refreshCookie.toString())
.body(Map.of(
"success", true,
"data", Map.of("accessToken", newAccessToken)
));
}
/**
* 退出登录清除 refresh token cookie并使当前 access token 立即失效
*/
@PostMapping("/logout")
public ResponseEntity<Map<String, Object>> logout() {
// 递增 token_version使已签发的 access token 立即失效剩余有效期内的令牌不再可用
// 注意改密/禁用后 token 已失效SecurityContext 可能为空此时仅清除 cookie 即可
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.isAuthenticated() && !"anonymousUser".equals(auth.getPrincipal())) {
SysUser user = sysUserService.getUserByUsername(auth.getName());
if (user != null) {
sysUserService.incrementTokenVersion(user.getId());
}
}
ResponseCookie clearCookie = ResponseCookie.from("refresh_token", "")
.httpOnly(true)
.secure(refreshCookieSecure)
.sameSite("Strict")
.path("/auth/refresh")
.maxAge(0)
.build();
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, clearCookie.toString())
.body(Map.of("success", true, "message", "已退出登录"));
}
/**
* 当前登录用户自助修改密码必须校验旧密码
*/
@PutMapping("/password")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<Map<String, Object>> changePassword(@RequestBody Map<String, String> body) {
String oldPassword = body.get("oldPassword");
String newPassword = body.get("newPassword");
if (oldPassword == null || oldPassword.isBlank() || newPassword == null || newPassword.isBlank()) {
return ResponseEntity.badRequest().body(Map.of(
"success", false, "message", "旧密码和新密码不能为空"
));
}
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String username = auth.getName();
SysUser user = sysUserService.getUserByUsername(username);
if (user == null) {
return ResponseEntity.status(401).body(Map.of(
"success", false, "message", "用户不存在"
));
}
if (!passwordEncoder.matches(oldPassword, user.getPassword())) {
return ResponseEntity.status(401).body(Map.of(
"success", false, "message", "旧密码错误"
));
}
try {
passwordValidator.validate(newPassword);
sysUserService.changePassword(user.getId(), newPassword);
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of(
"success", false, "message", e.getMessage()
));
}
return ResponseEntity.ok(Map.of(
"success", true,
"data", Map.of(
"accessToken", newAccessToken,
"refreshToken", newRefreshToken
)
"success", true, "message", "密码已修改,请重新登录"
));
}
@ -148,7 +263,10 @@ public class AuthController {
}
List<String> roleKeys = user.getRoles().stream().map(SysRole::getRoleKey).toList();
return ResponseEntity.ok(Map.of("success", true, "data", buildUserInfo(user, roleKeys)));
}
private Map<String, Object> buildUserInfo(SysUser user, List<String> roleKeys) {
Map<String, Object> userInfo = new LinkedHashMap<>();
userInfo.put("id", user.getId().toString());
userInfo.put("username", user.getUsername());
@ -156,7 +274,16 @@ public class AuthController {
userInfo.put("email", user.getEmail());
userInfo.put("roles", roleKeys);
userInfo.put("avatar", user.getAvatar());
return userInfo;
}
return ResponseEntity.ok(Map.of("success", true, "data", userInfo));
private ResponseCookie buildRefreshCookie(String refreshToken, long maxAgeSeconds) {
return ResponseCookie.from("refresh_token", refreshToken)
.httpOnly(true)
.secure(refreshCookieSecure)
.sameSite("Strict")
.path("/auth/refresh")
.maxAge(maxAgeSeconds)
.build();
}
}

21
src/main/java/com/wok/supportbot/config/CorsConfig.java

@ -1,23 +1,38 @@
package com.wok.supportbot.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.util.Arrays;
import java.util.List;
/**
* 全局跨域配置
* SecurityConfig 中的 CORS 配置保持一致的来源白名单
*/
@Configuration
public class CorsConfig implements WebMvcConfigurer {
private final List<String> allowedOrigins;
public CorsConfig(
@Value("${app.cors.allowed-origins:http://localhost:5173,http://localhost:5174,http://localhost:9090}") String allowedOrigins) {
this.allowedOrigins = Arrays.stream(allowedOrigins.split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.toList();
}
@Override
public void addCorsMappings(CorsRegistry registry) {
// 覆盖所有请求
registry.addMapping("/**")
// 允许发送 Cookie
// 允许发送 Cookierefresh token 需要
.allowCredentials(true)
// 放行哪些域名必须用 patterns否则 * 会和 allowCredentials 冲突
.allowedOriginPatterns("*")
// 放行白名单域名禁止 * + allowCredentials 组合
.allowedOriginPatterns(allowedOrigins.toArray(new String[0]))
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.exposedHeaders("*");

51
src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java

@ -155,6 +155,7 @@ public class DatabaseInitConfig {
createSysUserTable();
}
});
safeInit("迁移 sys_user.token_version 列", this::addTokenVersionColumn);
safeInit("创建系统角色表 sys_role", () -> {
if (!checkTableExists("sys_role")) {
createSysRoleTable();
@ -234,6 +235,13 @@ public class DatabaseInitConfig {
});
safeInit("同步默认系统配置", this::syncDefaultSystemConfigs);
// ==================== 登录安全 ====================
safeInit("创建登录失败记录表 login_attempt", () -> {
if (!checkTableExists("login_attempt")) {
createLoginAttemptTable();
}
});
// 为所有表添加注释幂等可重复执行
safeInit("应用数据库表注释", this::applyTableComments);
@ -269,7 +277,8 @@ public class DatabaseInitConfig {
"api_key", "webhook_config",
"mcp_server_config",
"system_config",
"llm_call_trace"
"llm_call_trace",
"login_attempt"
};
java.util.List<String> missingTables = new java.util.ArrayList<>();
@ -953,6 +962,7 @@ public class DatabaseInitConfig {
phone VARCHAR(20),
avatar VARCHAR(512),
enabled BOOLEAN DEFAULT TRUE NOT NULL,
token_version INT DEFAULT 0 NOT NULL,
last_login_time TIMESTAMP,
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
@ -1009,6 +1019,45 @@ public class DatabaseInitConfig {
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_sys_user_role_role ON sys_user_role (role_id)");
}
/**
* 迁移 sys_user.token_version JWT 服务端撤销机制幂等
*/
private void addTokenVersionColumn() {
try {
String checkSql = """
SELECT COUNT(*) FROM information_schema.columns
WHERE table_name = 'sys_user' AND column_name = 'token_version'
""";
Integer count = jdbcTemplate.queryForObject(checkSql, Integer.class);
if (count != null && count == 0) {
jdbcTemplate.execute("ALTER TABLE sys_user ADD COLUMN token_version INT DEFAULT 0 NOT NULL");
log.info("已迁移 sys_user.token_version 列");
}
} catch (Exception e) {
log.error("迁移 sys_user.token_version 列失败: {}", e.getMessage());
throw e;
}
}
/**
* 创建登录失败记录表暴力破解防护
*/
private void createLoginAttemptTable() {
String sql = """
CREATE TABLE IF NOT EXISTS login_attempt (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(64) NOT NULL UNIQUE,
failed_count INT DEFAULT 0 NOT NULL,
last_failed_time TIMESTAMP,
locked_until TIMESTAMP,
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
)
""";
jdbcTemplate.execute(sql);
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_login_attempt_locked ON login_attempt (locked_until)");
}
/**
* chat_message 表增加 user_id 数据隔离迁移幂等
*/

32
src/main/java/com/wok/supportbot/controller/SysUserController.java

@ -76,10 +76,7 @@ public class SysUserController {
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();
List<Long> roleIds = parseRoleIds(body.get("roleIds"));
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())));
@ -141,10 +138,7 @@ public class SysUserController {
@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();
List<Long> roleIds = parseRoleIds(body.get("roleIds"));
sysUserService.assignRoles(id, roleIds);
return ResponseEntity.ok(Map.of("success", true, "message", "角色分配成功"));
} catch (Exception e) {
@ -194,4 +188,26 @@ public class SysUserController {
));
}
}
/**
* 解析请求体中的 roleIds 字段为 Long 列表
* 兼容前端传数字 ID 或字符串 ID 两种情况雪花 ID 超过 JS 精度时前端通常传字符串
*/
private List<Long> parseRoleIds(Object roleIdsObj) {
if (roleIdsObj == null) {
return List.of();
}
if (!(roleIdsObj instanceof List<?> list)) {
return List.of();
}
return list.stream()
.filter(java.util.Objects::nonNull)
.map(item -> {
if (item instanceof Number number) {
return number.longValue();
}
return Long.parseLong(String.valueOf(item));
})
.toList();
}
}

4
src/main/java/com/wok/supportbot/entity/SysUser.java

@ -60,6 +60,10 @@ public class SysUser implements Serializable {
@TableField("enabled")
private Boolean enabled;
/** JWT 令牌版本号,改密/禁用/角色变更时自增,用于服务端撤销已签发 token */
@TableField("token_version")
private Integer tokenVersion;
/** 最后登录时间 */
@TableField("last_login_time")
private Date lastLoginTime;

5
src/main/java/com/wok/supportbot/handler/MyMetaObjectHandler.java

@ -20,6 +20,9 @@ public class MyMetaObjectHandler implements MetaObjectHandler {
@Override
public void updateFill(MetaObject metaObject) {
this.strictUpdateFill(metaObject, "updateTime", Date.class, new Date());
// 使用 setFieldValByName 而非 strictUpdateFill
// strictUpdateFill 仅匹配 FieldFill.UPDATE update_time FieldFill.INSERT_UPDATE
// 导致 updateById update_time 不会被自动填充实测 token_version 自增但 update_time 不更新
this.setFieldValByName("updateTime", new Date(), metaObject);
}
}

43
src/main/java/com/wok/supportbot/security/JwtAuthFilter.java

@ -5,6 +5,7 @@ import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
@ -24,9 +25,11 @@ import java.util.List;
public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
private final JdbcTemplate jdbcTemplate;
public JwtAuthFilter(JwtTokenProvider jwtTokenProvider) {
public JwtAuthFilter(JwtTokenProvider jwtTokenProvider, JdbcTemplate jdbcTemplate) {
this.jwtTokenProvider = jwtTokenProvider;
this.jdbcTemplate = jdbcTemplate;
}
@Override
@ -37,23 +40,35 @@ public class JwtAuthFilter extends OncePerRequestFilter {
String token = extractToken(request);
if (StringUtils.hasText(token)) {
boolean valid = jwtTokenProvider.validateToken(token);
boolean valid = jwtTokenProvider.validateAccessToken(token);
if (valid) {
String username = jwtTokenProvider.getUsernameFromToken(token);
List<String> roles = jwtTokenProvider.getRolesFromToken(token);
int tokenVer = jwtTokenProvider.getTokenVersionFromToken(token);
// 构建 Spring Security 权限列表ROLE_ 前缀
List<SimpleGrantedAuthority> authorities = roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.toList();
// 服务端撤销校验token_version 必须一致且账号处于启用状态
Integer currentVer = jdbcTemplate.query(
"SELECT token_version FROM sys_user WHERE username = ? AND is_delete = false AND enabled = true",
rs -> rs.next() ? rs.getInt("token_version") : null,
username
);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(username, null, authorities);
SecurityContextHolder.getContext().setAuthentication(authentication);
log.debug("JWT 认证成功: path={}, user={}, roles={}", path, username, roles);
if (currentVer == null || !currentVer.equals(tokenVer)) {
log.warn("JWT Token 版本已失效或账号被禁用: path={}, user={}", path, username);
} else {
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);
log.debug("JWT 认证成功: path={}, user={}, roles={}", path, username, roles);
}
} else {
log.warn("JWT Token 验证失败: path={}, tokenPrefix={}",
path, token.length() > 10 ? token.substring(0, 10) + "..." : token);
log.warn("JWT Access Token 验证失败: path={}", path);
}
} else {
log.debug("JWT 未找到 Token: path={}", path);
@ -84,7 +99,7 @@ public class JwtAuthFilter extends OncePerRequestFilter {
private String extractToken(HttpServletRequest request) {
String bearer = request.getHeader("Authorization");
if (StringUtils.hasText(bearer) && bearer.startsWith("Bearer ")) {
return bearer.substring(7);
return bearer.substring(7).trim();
}
return null;
}

81
src/main/java/com/wok/supportbot/security/JwtTokenProvider.java

@ -4,6 +4,7 @@ import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@ -16,35 +17,51 @@ import java.util.List;
* JWT 令牌提供者
* 负责 JWT 的生成验证和解析
*/
@Slf4j
@Component
public class JwtTokenProvider {
/** 默认密钥(禁止使用),必须与 application.yml 中 jwt.secret 的默认值保持一致 */
public static final String DEFAULT_SECRET = "support-bot-jwt-secret-key-2026-please-change-in-production";
private final SecretKey key;
private final long expiration;
private final long refreshExpiration;
private final boolean failIfDefaultSecret;
public JwtTokenProvider(
@Value("${jwt.secret}") String secret,
@Value("${jwt.expiration:86400000}") long expiration,
@Value("${jwt.refresh-expiration:604800000}") long refreshExpiration) {
// 直接使用 UTF-8 字节作为 HMAC 密钥密钥长度需 >= 32 字节
@Value("${jwt.expiration:900000}") long expiration,
@Value("${jwt.refresh-expiration:604800000}") long refreshExpiration,
@Value("${jwt.fail-if-default-secret:false}") boolean failIfDefaultSecret) {
if (DEFAULT_SECRET.equals(secret)) {
String message = "⚠️ 管理后台 JWT 使用了默认密钥,存在安全风险!请在 application.yml 中配置 jwt.secret 为强随机字符串(建议通过环境变量 JWT_SECRET 注入)";
// if (failIfDefaultSecret) {
// throw new IllegalStateException(message);
// }
log.warn(message);
}
byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8);
this.key = Keys.hmacShaKeyFor(keyBytes);
this.expiration = expiration;
this.refreshExpiration = refreshExpiration;
this.failIfDefaultSecret = failIfDefaultSecret;
}
/**
* 生成访问令牌
* @param username 用户名
* @param roles 角色列表
* @param tokenVersion 用户当前令牌版本号用于服务端撤销
* @return JWT 令牌
*/
public String generateToken(String username, List<String> roles) {
public String generateToken(String username, List<String> roles, int tokenVersion) {
Date now = new Date();
return Jwts.builder()
.subject(username)
.claim("roles", roles)
.claim("type", "access")
.claim("ver", tokenVersion)
.issuedAt(now)
.expiration(new Date(now.getTime() + expiration))
.signWith(key)
@ -54,10 +71,12 @@ public class JwtTokenProvider {
/**
* 生成刷新令牌有效期更长不含角色信息
*/
public String generateRefreshToken(String username) {
public String generateRefreshToken(String username, int tokenVersion) {
Date now = new Date();
return Jwts.builder()
.subject(username)
.claim("type", "refresh")
.claim("ver", tokenVersion)
.issuedAt(now)
.expiration(new Date(now.getTime() + refreshExpiration))
.signWith(key)
@ -65,18 +84,19 @@ public class JwtTokenProvider {
}
/**
* 验证令牌有效性
* 验证 access token 有效性校验签名过期时间 type 必须为 access
*/
public boolean validateToken(String token) {
try {
parseClaims(token);
return true;
} catch (ExpiredJwtException e) {
// Token 已过期不打印堆栈
return false;
} catch (Exception e) {
return false;
}
public boolean validateAccessToken(String token) {
Claims claims = parseClaims(token);
return claims != null && "access".equals(claims.get("type"));
}
/**
* 验证 refresh token 有效性
*/
public boolean validateRefreshToken(String token) {
Claims claims = parseClaims(token);
return claims != null && "refresh".equals(claims.get("type"));
}
/**
@ -99,14 +119,33 @@ public class JwtTokenProvider {
return List.of();
}
/**
* 从令牌中提取 tokenVersion
*/
public int getTokenVersionFromToken(String token) {
Claims claims = parseClaims(token);
Object ver = claims.get("ver");
if (ver instanceof Number number) {
return number.intValue();
}
return 0;
}
/**
* 解析并验证 Claims
*/
private Claims parseClaims(String token) {
return Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload();
try {
return Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload();
} catch (ExpiredJwtException e) {
// Token 已过期不打印堆栈
return null;
} catch (Exception e) {
return null;
}
}
}

95
src/main/java/com/wok/supportbot/security/LoginAttemptService.java

@ -0,0 +1,95 @@
package com.wok.supportbot.security;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.sql.Timestamp;
/**
* 登录失败尝试记录服务
* 用于防范暴力破解同一账号连续失败达到阈值后锁定一段时间
*/
@Slf4j
@Service
public class LoginAttemptService {
private final JdbcTemplate jdbcTemplate;
private final int maxAttempts;
private final int lockMinutes;
public LoginAttemptService(JdbcTemplate jdbcTemplate,
@Value("${security.login.max-attempts:5}") int maxAttempts,
@Value("${security.login.lock-minutes:15}") int lockMinutes) {
this.jdbcTemplate = jdbcTemplate;
this.maxAttempts = maxAttempts;
this.lockMinutes = lockMinutes;
}
/**
* 返回账号锁定的剩余秒数未锁定返回 0
*/
public int getLockedSeconds(String username) {
if (username == null || username.isBlank()) {
return 0;
}
Timestamp lockedUntil = jdbcTemplate.query(
"SELECT locked_until FROM login_attempt WHERE username = ?",
rs -> rs.next() ? rs.getTimestamp("locked_until") : null,
username.toLowerCase()
);
if (lockedUntil == null) {
return 0;
}
long remaining = lockedUntil.getTime() - System.currentTimeMillis();
return remaining > 0 ? (int) (remaining / 1000) : 0;
}
/**
* 记录一次登录失败达到阈值时锁定账号
*/
@Transactional(rollbackFor = Exception.class)
public void recordFailure(String username) {
if (username == null || username.isBlank()) {
return;
}
// 边界保护login_attempt.username VARCHAR(64)超长用户名截断避免 DataIntegrityViolationException
String name = username.toLowerCase();
if (name.length() > 64) {
name = name.substring(0, 64);
}
jdbcTemplate.update("""
INSERT INTO login_attempt (username, failed_count, last_failed_time, locked_until)
VALUES (?, 1, CURRENT_TIMESTAMP, NULL)
ON CONFLICT (username) DO UPDATE SET
failed_count = CASE
WHEN login_attempt.locked_until IS NOT NULL AND login_attempt.locked_until <= CURRENT_TIMESTAMP THEN 1
ELSE LEAST(login_attempt.failed_count + 1, ?)
END,
last_failed_time = CURRENT_TIMESTAMP,
locked_until = CASE
WHEN login_attempt.locked_until > CURRENT_TIMESTAMP THEN login_attempt.locked_until
WHEN login_attempt.locked_until IS NOT NULL AND login_attempt.locked_until <= CURRENT_TIMESTAMP THEN NULL
WHEN login_attempt.failed_count + 1 >= ? THEN CURRENT_TIMESTAMP + (? * INTERVAL '1 minute')
ELSE NULL
END,
update_time = CURRENT_TIMESTAMP
""", name, Integer.MAX_VALUE, maxAttempts, lockMinutes);
}
/**
* 登录成功后清除失败记录
*/
@Transactional(rollbackFor = Exception.class)
public void recordSuccess(String username) {
if (username == null || username.isBlank()) {
return;
}
int rows = jdbcTemplate.update("DELETE FROM login_attempt WHERE username = ?", username.toLowerCase());
if (rows > 0) {
log.info("登录成功,清除失败记录: {}", username);
}
}
}

48
src/main/java/com/wok/supportbot/security/PasswordValidator.java

@ -0,0 +1,48 @@
package com.wok.supportbot.security;
import org.springframework.stereotype.Component;
/**
* 密码强度校验器
* 统一校验创建用户修改密码自助改密等场景的密码复杂度
*/
@Component
public class PasswordValidator {
private static final int MIN_LENGTH = 8;
private static final int MAX_LENGTH = 64;
/**
* 校验密码强度不通过则抛出 IllegalArgumentException
*
* 规则
* - 长度 8-64
* - 同时包含字母和数字
*/
public void validate(String password) {
if (password == null || password.length() < MIN_LENGTH || password.length() > MAX_LENGTH) {
throw new IllegalArgumentException("密码长度需在 8-64 位之间");
}
if (!containsLetter(password) || !containsDigit(password)) {
throw new IllegalArgumentException("密码需同时包含字母和数字");
}
}
private boolean containsLetter(String s) {
for (char c : s.toCharArray()) {
if (Character.isLetter(c)) {
return true;
}
}
return false;
}
private boolean containsDigit(String s) {
for (char c : s.toCharArray()) {
if (Character.isDigit(c)) {
return true;
}
}
return false;
}
}

39
src/main/java/com/wok/supportbot/security/SdkAuthFilter.java

@ -10,8 +10,8 @@ import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.MDC;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.Ordered;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.core.annotation.Order;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
@ -46,11 +46,17 @@ import java.util.UUID;
@Order(Ordered.HIGHEST_PRECEDENCE + 1)
public class SdkAuthFilter extends OncePerRequestFilter {
@Autowired
private SdkJwtTokenProvider sdkJwtTokenProvider;
private final SdkJwtTokenProvider sdkJwtTokenProvider;
private final JwtTokenProvider jwtTokenProvider;
private final JdbcTemplate jdbcTemplate;
@Autowired
private JwtTokenProvider jwtTokenProvider;
public SdkAuthFilter(SdkJwtTokenProvider sdkJwtTokenProvider,
JwtTokenProvider jwtTokenProvider,
JdbcTemplate jdbcTemplate) {
this.sdkJwtTokenProvider = sdkJwtTokenProvider;
this.jwtTokenProvider = jwtTokenProvider;
this.jdbcTemplate = jdbcTemplate;
}
private static final ObjectMapper objectMapper = new ObjectMapper();
@ -61,6 +67,10 @@ public class SdkAuthFilter extends OncePerRequestFilter {
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
return true;
}
// /ai/system-config/** 是公开接口 SDK 匿名拉取声明内容SecurityConfig 已声明 permitAll此处保持一致
if (path.startsWith("/ai/system-config/")) {
return true;
}
// 拦截 /ai/ 路径和 /feedback 端点排除静态资源和 swagger
return !path.startsWith("/ai/") && !path.equals("/feedback");
}
@ -106,9 +116,24 @@ public class SdkAuthFilter extends OncePerRequestFilter {
// 通道 B管理后台 JWT 验证仅当 SDK 验证失败时尝试
if (claims == null) {
try {
if (jwtTokenProvider.validateToken(token)) {
// 管理后台 JWT 验证成功设置 SecurityContext
if (jwtTokenProvider.validateAccessToken(token)) {
// 管理后台 JWT 验证成功先校验服务端撤销状态token_version + enabled
String username = jwtTokenProvider.getUsernameFromToken(token);
int tokenVer = jwtTokenProvider.getTokenVersionFromToken(token);
// JwtAuthFilter 保持一致token_version 必须匹配且账号处于启用状态
Integer currentVer = jdbcTemplate.query(
"SELECT token_version FROM sys_user WHERE username = ? AND is_delete = false AND enabled = true",
rs -> rs.next() ? rs.getInt("token_version") : null,
username
);
if (currentVer == null || !currentVer.equals(tokenVer)) {
log.warn("管理后台 JWT 已失效或账号被禁用: path={}, user={}",
request.getServletPath(), username);
writeError(response, 401, "无效的认证令牌");
return;
}
List<String> roles = jwtTokenProvider.getRolesFromToken(token);
List<SimpleGrantedAuthority> authorities = roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))

18
src/main/java/com/wok/supportbot/security/SdkJwtTokenProvider.java

@ -8,6 +8,7 @@ import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
@ -37,14 +38,23 @@ public class SdkJwtTokenProvider {
/** 5 分钟下限 */
private static final long MIN_EXPIRATION = 300000L;
/** 默认密钥标识(禁止使用),长度 ≥64 字符以兼容 HS256/HS384/HS512 */
private static final String DEFAULT_SECRET = "support-bot-sdk-jwt-secret-2026-please-change-to-a-strong-random-value";
/** 默认密钥标识(禁止使用),长度 ≥64 字符以兼容 HS256/HS384/HS512,且必须与 application.yml 默认值一致 */
private static final String DEFAULT_SECRET = "support-bot-sdk-jwt-secret-2026-please-change";
public SdkJwtTokenProvider(
@Value("${jwt.sdk-secret:support-bot-sdk-jwt-secret-2026-please-change}") String sdkSecret,
@Value("${jwt.sdk-expiration:7200000}") long defaultExpiration) {
@Value("${jwt.sdk-expiration:7200000}") long defaultExpiration,
@Value("${jwt.fail-if-default-sdk-secret:false}") boolean failIfDefaultSdkSecret) {
// 空值校验密钥为空/空白时启动失败并给出明确提示避免 Keys.hmacShaKeyFor 抛出晦涩异常
if (!StringUtils.hasText(sdkSecret)) {
throw new IllegalArgumentException("jwt.sdk-secret 不能为空,请通过环境变量 JWT_SDK_SECRET 配置强随机密钥");
}
if (DEFAULT_SECRET.equals(sdkSecret)) {
log.warn("⚠️ SDK JWT 使用了默认密钥,存在安全风险!请在 application.yml 中配置 jwt.sdk-secret 为强随机字符串");
String message = "⚠️ SDK JWT 使用了默认密钥,存在安全风险!请在 application.yml 中配置 jwt.sdk-secret 为强随机字符串(建议通过环境变量 JWT_SDK_SECRET 注入)";
// if (failIfDefaultSdkSecret) {
// throw new IllegalStateException(message);
// }
log.warn(message);
}
this.key = Keys.hmacShaKeyFor(sdkSecret.getBytes(StandardCharsets.UTF_8));
this.defaultExpiration = defaultExpiration;

17
src/main/java/com/wok/supportbot/security/SecurityConfig.java

@ -1,5 +1,6 @@
package com.wok.supportbot.security;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
@ -19,6 +20,7 @@ import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
import java.util.List;
/**
@ -31,9 +33,15 @@ import java.util.List;
public class SecurityConfig {
private final JwtAuthFilter jwtAuthFilter;
private final List<String> allowedOrigins;
public SecurityConfig(JwtAuthFilter jwtAuthFilter) {
public SecurityConfig(JwtAuthFilter jwtAuthFilter,
@Value("${app.cors.allowed-origins:http://localhost:5173,http://localhost:5174,http://localhost:9090}") String allowedOrigins) {
this.jwtAuthFilter = jwtAuthFilter;
this.allowedOrigins = Arrays.stream(allowedOrigins.split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.toList();
}
@Bean
@ -54,8 +62,9 @@ public class SecurityConfig {
.requestMatchers("/ai/**").permitAll()
// 开放 API ApiKeyAuthFilter 独立鉴权
.requestMatchers("/open-api/**").permitAll()
// 登录/刷新 Token
.requestMatchers("/auth/login", "/auth/refresh").permitAll()
// 登录/刷新 Token/退出登录
// logout 设为 permitAll改密或禁用后 token 已失效仍需能清除 refresh cookie
.requestMatchers("/auth/login", "/auth/refresh", "/auth/logout").permitAll()
// SDK 需要的接口feedback分类会话查询等
.requestMatchers("/feedback").permitAll()
.requestMatchers("/category/tree", "/category/list").permitAll()
@ -118,7 +127,7 @@ public class SecurityConfig {
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOriginPatterns(List.of("*"));
config.setAllowedOriginPatterns(allowedOrigins);
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setExposedHeaders(List.of("*"));

63
src/main/java/com/wok/supportbot/service/SysUserService.java

@ -7,6 +7,7 @@ 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 com.wok.supportbot.security.PasswordValidator;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
@ -33,15 +34,17 @@ public class SysUserService implements UserDetailsService {
private final SysRoleMapper roleMapper;
private final SysUserRoleMapper userRoleMapper;
private final PasswordEncoder passwordEncoder;
private final PasswordValidator passwordValidator;
private final JdbcTemplate jdbcTemplate;
public SysUserService(SysUserMapper userMapper, SysRoleMapper roleMapper,
SysUserRoleMapper userRoleMapper, PasswordEncoder passwordEncoder,
JdbcTemplate jdbcTemplate) {
PasswordValidator passwordValidator, JdbcTemplate jdbcTemplate) {
this.userMapper = userMapper;
this.roleMapper = roleMapper;
this.userRoleMapper = userRoleMapper;
this.passwordEncoder = passwordEncoder;
this.passwordValidator = passwordValidator;
this.jdbcTemplate = jdbcTemplate;
}
@ -86,7 +89,7 @@ public class SysUserService implements UserDetailsService {
}
/**
* 获取用户的角色列表
* 获取用户的角色列表排除已软删除的角色
*/
public List<SysRole> getUserRoles(Long userId) {
List<SysUserRole> userRoles = userRoleMapper.selectList(
@ -94,7 +97,12 @@ public class SysUserService implements UserDetailsService {
);
if (userRoles.isEmpty()) return List.of();
List<Long> roleIds = userRoles.stream().map(SysUserRole::getRoleId).toList();
return roleMapper.selectBatchIds(roleIds);
// 显式过滤已删除角色避免已删除角色继续授予失效权限
return roleMapper.selectList(
new LambdaQueryWrapper<SysRole>()
.in(SysRole::getId, roleIds)
.eq(SysRole::getIsDelete, false)
);
}
/**
@ -165,6 +173,9 @@ public class SysUserService implements UserDetailsService {
throw new IllegalArgumentException("用户名已存在: " + username);
}
// 校验密码强度
passwordValidator.validate(password);
SysUser user = SysUser.builder()
.username(username)
.password(passwordEncoder.encode(password))
@ -172,6 +183,7 @@ public class SysUserService implements UserDetailsService {
.email(email)
.phone(phone)
.enabled(true)
.tokenVersion(0)
.build();
userMapper.insert(user);
@ -198,30 +210,60 @@ public class SysUserService implements UserDetailsService {
}
/**
* 修改密码
* 修改密码会触发 token_version 自增使已签发 token 失效
*/
@Transactional(rollbackFor = Exception.class)
public void changePassword(Long id, String newPassword) {
SysUser user = userMapper.selectById(id);
if (user == null) throw new IllegalArgumentException("用户不存在");
// 校验密码强度
passwordValidator.validate(newPassword);
user.setPassword(passwordEncoder.encode(newPassword));
user.setTokenVersion((user.getTokenVersion() == null ? 0 : user.getTokenVersion()) + 1);
userMapper.updateById(user);
}
/**
* 启用/禁用用户
* 递增用户 token_version使已签发的 access/refresh token 立即失效登出改密禁用角色变更时调用
*/
@Transactional(rollbackFor = Exception.class)
public void incrementTokenVersion(Long userId) {
SysUser user = userMapper.selectById(userId);
if (user == null) return;
user.setTokenVersion((user.getTokenVersion() == null ? 0 : user.getTokenVersion()) + 1);
userMapper.updateById(user);
}
/**
* 启用/禁用用户禁用会使已签发 token 失效
*/
@Transactional(rollbackFor = Exception.class)
public void toggleUser(Long id, boolean enabled) {
SysUser user = userMapper.selectById(id);
if (user == null) throw new IllegalArgumentException("用户不存在");
user.setEnabled(enabled);
if (!enabled) {
user.setTokenVersion((user.getTokenVersion() == null ? 0 : user.getTokenVersion()) + 1);
}
userMapper.updateById(user);
}
/**
* 分配角色先删后插
* 分配角色先删后插角色变更会使已签发 token 中的角色信息失效
*/
@Transactional(rollbackFor = Exception.class)
public void assignRoles(Long userId, List<Long> roleIds) {
// 查询旧角色集合用于判断是否真正发生变更
List<Long> oldRoleIds = userRoleMapper.selectList(
new LambdaQueryWrapper<SysUserRole>().eq(SysUserRole::getUserId, userId)
).stream()
.map(SysUserRole::getRoleId)
.sorted()
.toList();
List<Long> newRoleIds = roleIds.stream().sorted().toList();
// 删除旧关联
userRoleMapper.delete(
new LambdaQueryWrapper<SysUserRole>().eq(SysUserRole::getUserId, userId)
@ -234,6 +276,15 @@ public class SysUserService implements UserDetailsService {
.build();
userRoleMapper.insert(userRole);
}
// 仅当角色集合真正变化时才递增 token_version避免重复分配相同角色导致已签发 token 被误失效
if (!oldRoleIds.equals(newRoleIds)) {
SysUser user = userMapper.selectById(userId);
if (user != null) {
user.setTokenVersion((user.getTokenVersion() == null ? 0 : user.getTokenVersion()) + 1);
userMapper.updateById(user);
}
}
}
/**

15
src/main/resources/application-dev.yml

@ -13,16 +13,21 @@ spring:
url: jdbc:postgresql://192.168.1.49:5432/support_bot
username: postgres
password: supportbot123
sql:
init:
mode: never
schema-locations: classpath:support-bot.sql
continue-on-error: true
# ==================== Knife4j 开关 ====================
knife4j:
enable: true
# ==================== JWT 开发配置 ====================
jwt:
refresh-cookie-secure: false
fail-if-default-secret: false
# ==================== CORS 开发配置 ====================
app:
cors:
allowed-origins: http://localhost:5173,http://localhost:5174,http://localhost:9090
# ==================== 文件存储路径 ====================
knowledge:
storage:

16
src/main/resources/application-prod.yml

@ -21,17 +21,23 @@ spring:
url: jdbc:postgresql://192.168.1.220:5432/support_bot
username: admin
password: cdw147258369
sql:
init:
mode: never
schema-locations: classpath:support-bot.sql
continue-on-error: true
# ==================== Knife4j 开关 ====================
# 生产环境建议关闭 API 文档入口
knife4j:
enable: false
# ==================== JWT 安全配置(生产必须覆盖密钥并启用 Secure Cookie) ====================
jwt:
refresh-cookie-secure: true
fail-if-default-secret: true
fail-if-default-sdk-secret: true
# ==================== CORS 白名单(生产替换为真实域名) ====================
app:
cors:
allowed-origins: https://docfilepath.dgscdw.com:8804
# ==================== 文件存储路径 ====================
knowledge:
storage:

28
src/main/resources/application.yml

@ -122,13 +122,33 @@ logging:
# ==================== JWT 认证配置 ====================
jwt:
# 签名密钥(生产环境务必通过环境变量 JWT_SECRET 覆盖)
secret: support-bot-jwt-secret-key-2026-please-change-in-production
# 访问令牌有效期:24小时(毫秒)
expiration: 86400000
secret: ${JWT_SECRET:support-bot-jwt-secret-key-2026-please-change-in-production}
# 访问令牌有效期:15分钟(毫秒),降低 XSS 窃取后的利用窗口
expiration: 900000
# 刷新令牌有效期:7天(毫秒)
refresh-expiration: 604800000
# refresh token cookie 是否标记 Secure(生产必须 true,本地开发通过 application-dev.yml 覆盖为 false)
refresh-cookie-secure: true
# 使用默认密钥时是否直接启动失败(生产建议 true)
fail-if-default-secret: false
# ==================== SDK 专用 JWT 配置 ====================
# SDK Token 签名密钥(独立于管理后台 JWT 密钥,生产环境务必覆盖)
sdk-secret: support-bot-sdk-jwt-secret-2026-please-change
sdk-secret: ${JWT_SDK_SECRET:support-bot-sdk-jwt-secret-2026-please-change}
# 使用默认 SDK 密钥时是否直接启动失败(生产建议 true)
fail-if-default-sdk-secret: false
# SDK Token 默认有效期:2小时(毫秒)
sdk-expiration: 7200000
# ==================== 登录安全配置 ====================
security:
login:
# 连续登录失败锁定阈值
max-attempts: 5
# 锁定持续时长(分钟)
lock-minutes: 15
# ==================== CORS 配置 ====================
app:
cors:
# 允许携带 Cookie 的跨域来源(逗号分隔),生产替换为实际域名
allowed-origins: http://localhost:5173,http://localhost:5174,http://localhost:9090

210
src/main/resources/init-database.sql

@ -1,7 +1,8 @@
-- ============================================================
-- AI 智能客服系统 - 数据库初始化脚本(完整版)
-- 适用环境: PostgreSQL 12+ 且已安装 pgvector 扩展
-- 表数量: 14 张(基础 8 张 + P0 阶段新增 6 张)
-- 表数量: 27 张(含 vector_store;其余与 DatabaseInitConfig 的 26 张 expectedTables 对齐)
-- 注: vector_store 由 Spring AI 自动建表,此处手动建表作为备份方案
--
-- ⚠️ 开发规范:任何涉及建表、增删改列、初始数据的变更,
-- 必须同时更新本脚本和 DatabaseInitConfig.java(自动初始化),
@ -33,21 +34,21 @@ CREATE EXTENSION IF NOT EXISTS vector;
-- 表 1: chat_message — 聊天消息表
-- ============================================================
CREATE TABLE IF NOT EXISTS chat_message (
id BIGINT PRIMARY KEY,
id BIGSERIAL PRIMARY KEY,
conversation_id VARCHAR(64) NOT NULL,
message_type VARCHAR(20) NOT NULL,
content TEXT NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}',
user_id BIGINT,
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
is_delete BOOLEAN NOT NULL DEFAULT FALSE,
CONSTRAINT chk_message_type CHECK (message_type IN ('USER', 'ASSISTANT', 'SYSTEM', 'TOOL'))
is_delete BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE INDEX IF NOT EXISTS idx_chat_message_conversation_id ON chat_message (conversation_id);
CREATE INDEX IF NOT EXISTS idx_chat_message_create_time ON chat_message (create_time DESC);
CREATE INDEX IF NOT EXISTS idx_chat_message_type ON chat_message (message_type);
CREATE INDEX IF NOT EXISTS idx_chat_message_not_deleted ON chat_message (conversation_id) WHERE is_delete = FALSE;
CREATE INDEX IF NOT EXISTS idx_chat_message_user_id ON chat_message (user_id);
COMMENT ON TABLE chat_message IS '聊天消息表';
COMMENT ON COLUMN chat_message.id IS '主键(雪花算法生成)';
@ -55,6 +56,7 @@ COMMENT ON COLUMN chat_message.conversation_id IS '会话ID';
COMMENT ON COLUMN chat_message.message_type IS '消息类型: USER / ASSISTANT / SYSTEM';
COMMENT ON COLUMN chat_message.content IS '消息内容';
COMMENT ON COLUMN chat_message.metadata IS '元数据(JSON)';
COMMENT ON COLUMN chat_message.user_id IS '所属系统用户ID(数据隔离,SDK调用时为null)';
COMMENT ON COLUMN chat_message.create_time IS '创建时间';
COMMENT ON COLUMN chat_message.update_time IS '更新时间';
COMMENT ON COLUMN chat_message.is_delete IS '逻辑删除: FALSE=正常 TRUE=已删除';
@ -165,19 +167,30 @@ COMMENT ON COLUMN customer_service_role.update_time IS '更新时间';
COMMENT ON COLUMN customer_service_role.is_delete IS '逻辑删除';
COMMENT ON COLUMN customer_service_role.allowed_mcp_tools IS '允许使用的 MCP 工具列表(JSONB 数组,空=不允许,["*"]=全部允许)';
-- 默认角色种子数据
INSERT INTO customer_service_role (role_key, name, description, enabled)
VALUES ('general', '客服', '通用客服角色', TRUE)
-- 默认角色种子数据(与 DatabaseInitConfig.syncDefaultCustomerServiceRoles 保持一致,含 prompt)
INSERT INTO customer_service_role (role_key, name, description, prompt, enabled)
VALUES ('general', '客服', '用户咨询、业务办理、常见问题、问题受理与进度说明',
'职责:回答用户关于业务办理、服务流程、常见问题、问题受理和进度说明的问题。
', TRUE)
ON CONFLICT (role_key) DO NOTHING;
INSERT INTO customer_service_role (role_key, name, description, enabled)
VALUES ('finance', '财务', '财务相关客服角色', TRUE)
INSERT INTO customer_service_role (role_key, name, description, prompt, enabled)
VALUES ('finance', '财务', '付款、退款、发票、对账、报销、结算与费用规则',
'职责:回答付款、退款、发票、对账、报销、结算、费用规则相关问题。
', TRUE)
ON CONFLICT (role_key) DO NOTHING;
INSERT INTO customer_service_role (role_key, name, description, enabled)
VALUES ('administration', '行政', '行政相关客服角色', TRUE)
INSERT INTO customer_service_role (role_key, name, description, prompt, enabled)
VALUES ('administration', '行政', '办公制度、行政流程、资产、会议、考勤、用章、采购与后勤',
'职责:回答办公制度、行政流程、资产、会议、考勤、入职、用章、采购、后勤等问题。
', TRUE)
ON CONFLICT (role_key) DO NOTHING;
-- 废弃旧角色(与 DatabaseInitConfig.retireObsoleteDefaultRoles 保持一致)
UPDATE customer_service_role
SET is_delete = true, enabled = false, update_time = CURRENT_TIMESTAMP
WHERE role_key IN ('after_sale', 'logistics', 'product');
-- ============================================================
-- 表 5: customer_service_role_category — 角色知识库关联表
-- ============================================================
@ -308,8 +321,8 @@ COMMENT ON COLUMN mcp_server_config.is_delete IS '逻辑删除: FALSE=正常 TR
-- 表 8: vector_store — 向量存储表
-- 注意: Spring AI PgVectorStore 配置了 initializeSchema(true),
-- 应用启动时会自动建表。此处手动建表作为备份方案。
-- 向量维度默认 1024,如使用不同维度的 Embedding 模型
-- 需修改下面的 VECTOR(1024) 并重建
-- ⚠️ 向量维度必须与 application.yml 的 knowledge.vector.dimension 保持一致(默认 1024)。
-- 若修改了该配置(如 1536/2048),需同步修改下面的 VECTOR(1024) 与 faq_embedding 的 vector(1024),并重建表
-- ============================================================
CREATE TABLE IF NOT EXISTS vector_store (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
@ -583,6 +596,56 @@ COMMENT ON COLUMN faq_embedding.embedding IS '向量嵌入(维度由 knowled
COMMENT ON COLUMN faq_embedding.model_name IS '使用的 Embedding 模型名称(用于检测模型变更后重新计算)';
COMMENT ON COLUMN faq_embedding.create_time IS '创建时间';
-- ============================================================
-- 表 15: api_key — API Key 表(P1-003 开放平台)
-- ============================================================
CREATE TABLE IF NOT EXISTS api_key (
id BIGSERIAL PRIMARY KEY,
key_value VARCHAR(128) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
description TEXT,
user_id BIGINT,
rate_limit INTEGER DEFAULT 60 NOT NULL,
max_calls BIGINT,
current_calls BIGINT DEFAULT 0 NOT NULL,
expire_time TIMESTAMP,
enabled BOOLEAN DEFAULT TRUE NOT NULL,
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
is_delete BOOLEAN DEFAULT FALSE NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_api_key_user ON api_key (user_id);
CREATE INDEX IF NOT EXISTS idx_api_key_enabled ON api_key (enabled) WHERE is_delete = false;
COMMENT ON TABLE api_key IS 'API Key 表(第三方系统接入鉴权)';
COMMENT ON COLUMN api_key.key_value IS 'API Key 值(唯一,用于换取 SDK JWT)';
COMMENT ON COLUMN api_key.user_id IS '所属系统用户 ID';
COMMENT ON COLUMN api_key.rate_limit IS '速率限制(每分钟调用次数)';
-- ============================================================
-- 表 16: webhook_config — Webhook 配置表(P1-003 开放平台)
-- ============================================================
CREATE TABLE IF NOT EXISTS webhook_config (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT,
name VARCHAR(100) NOT NULL,
url VARCHAR(512) NOT NULL,
events JSONB DEFAULT '[]' NOT NULL,
enabled BOOLEAN DEFAULT TRUE NOT NULL,
secret VARCHAR(128),
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
is_delete BOOLEAN DEFAULT FALSE NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_webhook_user ON webhook_config (user_id);
CREATE INDEX IF NOT EXISTS idx_webhook_enabled ON webhook_config (enabled) WHERE is_delete = false;
COMMENT ON TABLE webhook_config IS 'Webhook 回调配置表';
COMMENT ON COLUMN webhook_config.url IS '回调地址';
COMMENT ON COLUMN webhook_config.events IS '订阅的事件类型(JSONB 数组)';
-- ============================================================
-- 清理租户相关数据(移除多租户架构)
-- ============================================================
@ -758,9 +821,122 @@ INSERT INTO system_config (config_key, config_value, description)
VALUES ('llm_trace_retention_days', '30', 'LLM 调用追踪记录保留天数(自动清理,默认 30 天)')
ON CONFLICT (config_key) DO NOTHING;
-- ============================================================
-- 表 17-21: 用户认证与登录安全(与 DatabaseInitConfig 同步)
-- ============================================================
CREATE TABLE IF NOT EXISTS sys_user (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(64) NOT NULL UNIQUE,
password VARCHAR(256) NOT NULL,
nickname VARCHAR(100),
email VARCHAR(128),
phone VARCHAR(20),
avatar VARCHAR(512),
enabled BOOLEAN DEFAULT TRUE NOT NULL,
token_version INT DEFAULT 0 NOT NULL,
last_login_time TIMESTAMP,
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
is_delete BOOLEAN DEFAULT FALSE NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sys_user_username ON sys_user (username) WHERE is_delete = false;
CREATE INDEX IF NOT EXISTS idx_sys_user_enabled ON sys_user (enabled) WHERE is_delete = false;
COMMENT ON COLUMN sys_user.token_version IS 'JWT 令牌版本号,改密/禁用/角色变更时自增,用于服务端撤销已签发 token';
CREATE TABLE IF NOT EXISTS sys_role (
id BIGSERIAL PRIMARY KEY,
role_key VARCHAR(64) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
description TEXT,
enabled BOOLEAN DEFAULT TRUE NOT NULL,
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
is_delete BOOLEAN DEFAULT FALSE NOT NULL
);
CREATE TABLE IF NOT EXISTS sys_permission (
id BIGSERIAL PRIMARY KEY,
permission_key VARCHAR(128) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
description TEXT,
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
is_delete BOOLEAN DEFAULT FALSE NOT NULL
);
CREATE TABLE IF NOT EXISTS sys_user_role (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
role_id BIGINT NOT NULL,
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
UNIQUE (user_id, role_id)
);
CREATE INDEX IF NOT EXISTS idx_sys_user_role_user ON sys_user_role (user_id);
CREATE INDEX IF NOT EXISTS idx_sys_user_role_role ON sys_user_role (role_id);
CREATE TABLE IF NOT EXISTS login_attempt (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(64) NOT NULL UNIQUE,
failed_count INT DEFAULT 0 NOT NULL,
last_failed_time TIMESTAMP,
locked_until TIMESTAMP,
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_login_attempt_locked ON login_attempt (locked_until);
-- ============================================================
-- 表 26: rag_hit_log — RAG 命中记录表(P1-002 运营数据分析看板)
-- ============================================================
CREATE TABLE IF NOT EXISTS rag_hit_log (
id BIGSERIAL PRIMARY KEY,
conversation_id VARCHAR(64),
user_query TEXT NOT NULL,
document_id BIGINT,
document_title VARCHAR(500),
chunk_id VARCHAR(128),
score DOUBLE PRECISION,
search_mode VARCHAR(20),
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_rag_hit_log_created ON rag_hit_log (create_time DESC);
CREATE INDEX IF NOT EXISTS idx_rag_hit_log_document ON rag_hit_log (document_id);
COMMENT ON TABLE rag_hit_log IS 'RAG 命中记录表(记录每次知识库检索的命中/未命中情况)';
COMMENT ON COLUMN rag_hit_log.document_id IS '命中文档 ID(未命中时为 null)';
COMMENT ON COLUMN rag_hit_log.score IS '相似度分数';
COMMENT ON COLUMN rag_hit_log.search_mode IS '检索模式: VECTOR / KEYWORD / HYBRID';
-- ============================================================
-- 表 27: dashboard_snapshot — 运营看板每日汇总快照(P1-002)
-- ============================================================
CREATE TABLE IF NOT EXISTS dashboard_snapshot (
id BIGSERIAL PRIMARY KEY,
snapshot_date DATE NOT NULL UNIQUE,
conversation_count INTEGER DEFAULT 0 NOT NULL,
message_count INTEGER DEFAULT 0 NOT NULL,
satisfaction_rate DOUBLE PRECISION DEFAULT 0,
thumbs_up_count INTEGER DEFAULT 0 NOT NULL,
thumbs_down_count INTEGER DEFAULT 0 NOT NULL,
rag_hit_count INTEGER DEFAULT 0 NOT NULL,
rag_miss_count INTEGER DEFAULT 0 NOT NULL,
avg_response_time DOUBLE PRECISION DEFAULT 0,
top_questions JSONB DEFAULT '[]' NOT NULL,
top_hit_documents JSONB DEFAULT '[]' NOT NULL,
miss_questions JSONB DEFAULT '[]' NOT NULL,
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_snapshot_date ON dashboard_snapshot (snapshot_date DESC);
COMMENT ON TABLE dashboard_snapshot IS '运营看板每日汇总快照';
COMMENT ON COLUMN dashboard_snapshot.snapshot_date IS '快照日期(唯一,每天一条)';
COMMENT ON COLUMN dashboard_snapshot.conversation_count IS '当日会话数';
COMMENT ON COLUMN dashboard_snapshot.satisfaction_rate IS '满意度(0~1)';
-- ============================================================
-- 完成!
-- 注:本脚本与 DatabaseInitConfig 为双文件同步源;部分表(如 rag_hit_log、
-- dashboard_snapshot、sys_user 等)历史上仅随 DatabaseInitConfig 演进,
-- 属既有技术债,以 DatabaseInitConfig 为准。
-- 注:本脚本与 DatabaseInitConfig 为双文件同步源,以 DatabaseInitConfig 为准。
-- ============================================================

8
src/main/resources/static/sdk/test.html

@ -4,15 +4,15 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ChatbotSDK 测试面板</title>
<script type="module" crossorigin src="/assets/sdk-test-BV9sVPfh.js"></script>
<script type="module" crossorigin src="/assets/sdk-test-BM4QMGvl.js"></script>
<link rel="modulepreload" crossorigin href="/assets/tdesign-BEIBDEDo.js">
<link rel="modulepreload" crossorigin href="/assets/tdesign-web-components-BtnmJ2z8.js">
<link rel="modulepreload" crossorigin href="/assets/tdesign-chat-CvvLoh5G.js">
<link rel="modulepreload" crossorigin href="/assets/markdown-Dyjv1-p0.js">
<link rel="modulepreload" crossorigin href="/assets/chatAdapter-DFdkvd4y.js">
<link rel="modulepreload" crossorigin href="/assets/markdown-pxFwF6hE.js">
<link rel="modulepreload" crossorigin href="/assets/chatAdapter-DJkCuBX0.js">
<link rel="stylesheet" crossorigin href="/assets/tdesign-CY0HVqZ3.css">
<link rel="stylesheet" crossorigin href="/assets/tdesign-chat-Dj1Q23QO.css">
<link rel="stylesheet" crossorigin href="/assets/tdesign-web-components-B-ycfzW_.css">
<link rel="stylesheet" crossorigin href="/assets/tdesign-chat-Dj1Q23QO.css">
<link rel="stylesheet" crossorigin href="/assets/sdk-test-wRid8JYa.css">
</head>
<body>

16
src/main/resources/support-bot.sql

@ -1,7 +1,9 @@
-- ================================================================
-- Support Bot 数据库初始化脚本
-- 版本: 1.0
-- 说明: 创建智能客服系统所需的数据库表和索引
-- Support Bot 数据库初始化脚本(已废弃)
-- ⚠️ 本脚本为早期版本,仅含 chat_message / vector_store 两张表,
-- 已被 init-database.sql + DatabaseInitConfig.java 取代,请勿使用。
-- 数据库初始化以 DatabaseInitConfig.java 自动执行为准,
-- 手动初始化请使用 init-database.sql。
-- ================================================================
-- 检查并创建必要的扩展
@ -11,9 +13,7 @@ CREATE EXTENSION IF NOT EXISTS vector;
-- ================================================================
-- 聊天消息表 - 存储用户与AI的对话记录
-- ================================================================
DROP TABLE IF EXISTS chat_message CASCADE;
CREATE TABLE chat_message (
CREATE TABLE IF NOT EXISTS chat_message (
id BIGSERIAL PRIMARY KEY,
conversation_id VARCHAR(64) NOT NULL,
message_type VARCHAR(20) NOT NULL,
@ -50,9 +50,7 @@ ALTER TABLE chat_message ADD CONSTRAINT chk_message_type
-- ================================================================
-- 向量存储表 - 存储文档向量和元数据用于RAG检索
-- ================================================================
DROP TABLE IF EXISTS vector_store CASCADE;
CREATE TABLE vector_store (
CREATE TABLE IF NOT EXISTS vector_store (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
content TEXT NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}',

Loading…
Cancel
Save