feat: Spring Boot JWT应用模板

This commit is contained in:
AiKrai 2025-04-13 21:08:50 +08:00
parent 189e34b7a2
commit 29f5e1b75f
8 changed files with 425 additions and 2 deletions

4
.gitignore vendored
View File

@ -5,8 +5,8 @@ build/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/test/**/build/
logs/
config/
/logs
/config
### STS ###
.apt_generated

View File

@ -0,0 +1,15 @@
package com.app.config
import cn.hutool.core.lang.Snowflake
import cn.hutool.core.util.IdUtil
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
@Configuration
class HuToolConfig {
@Bean
fun snowflake(): Snowflake {
return IdUtil.getSnowflake()
}
}

View File

@ -0,0 +1,54 @@
package com.app.config
import com.app.data.RespBean
import com.fasterxml.jackson.databind.ObjectMapper
import org.springframework.beans.factory.annotation.Value
import org.springframework.core.MethodParameter
import org.springframework.http.MediaType
import org.springframework.http.converter.HttpMessageConverter
import org.springframework.http.server.ServerHttpRequest
import org.springframework.http.server.ServerHttpResponse
import org.springframework.web.bind.annotation.ControllerAdvice
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice
@ControllerAdvice
class ResponseAdvisor(
private val objectMapper: ObjectMapper
) : ResponseBodyAdvice<Any> {
@Value("#{'\${ignore.response.ignoreUris:/swagger,/actuator,/api-docs,/v3/api-docs,/doc.html}'.split(',')}")
private lateinit var ignoreUris: Array<String>
override fun supports(returnType: MethodParameter, converterType: Class<out HttpMessageConverter<*>>): Boolean {
return true
}
override fun beforeBodyWrite(
body: Any?,
returnType: MethodParameter,
selectedContentType: MediaType,
selectedConverterType: Class<out HttpMessageConverter<*>>,
request: ServerHttpRequest,
response: ServerHttpResponse
): Any? {
if (body is RespBean<*>) {
return body
}
val requestUri = request.uri.toString()
if (ignoreUris.any { requestUri.contains(it) }) {
return body
}
if (body is String) {
response.headers.contentType = MediaType.APPLICATION_JSON
return objectMapper.writeValueAsString(RespBean.success(body))
}
if (body == null) {
return RespBean.success<Any>(null)
}
return RespBean.success(body)
}
}

View File

@ -0,0 +1,107 @@
package com.app.config.exception
import com.app.data.RespBean
import jakarta.servlet.http.HttpServletRequest
import org.slf4j.LoggerFactory
import org.springframework.http.HttpStatus
import org.springframework.http.converter.HttpMessageNotReadableException
import org.springframework.stereotype.Component
import org.springframework.validation.BindException
import org.springframework.validation.FieldError
import org.springframework.web.bind.MethodArgumentNotValidException
import java.io.ByteArrayOutputStream
import java.io.PrintStream
import java.security.SignatureException
@Component
class ExceptionAdviceHandel {
companion object {
private val logger = LoggerFactory.getLogger(ExceptionAdviceHandel::class.java)
}
fun getDefaultExceptionResponse(req: HttpServletRequest, error: Throwable): RespBean<*> {
return when (error) {
is SignatureException -> signatureException(error)
is MethodArgumentNotValidException -> methodArgumentExceptionAdvice(error)
is BindException -> bindException(error)
is HttpMessageNotReadableException -> httpMessageNotReadableExceptionAdvice(error)
else -> throwableAdvice(error)
}
}
/**
* 处理通用异常
*/
private fun throwableAdvice(error: Throwable): RespBean<Void> {
logger.error("系统发生未处理异常", error)
// 获取详细错误信息
val printStackTrace = ByteArrayOutputStream()
error.printStackTrace(PrintStream(printStackTrace))
// 查找第一个应用包下的堆栈信息,用于定位错误
val stackElement = error.stackTrace.firstOrNull {
it.className.startsWith("com.app")
}
// 格式化错误信息
val errorMsg = if (stackElement != null) {
"系统异常: ${error.message ?: "Unknown error"} (位置: ${stackElement.className}:${stackElement.lineNumber})"
} else {
"系统异常: ${error.message ?: "Unknown error"}"
}
return RespBean.failure(HttpStatus.INTERNAL_SERVER_ERROR.value(), errorMsg)
}
/**
* 处理方法参数验证异常
*/
private fun methodArgumentExceptionAdvice(error: MethodArgumentNotValidException): RespBean<Void> {
logger.error("数据格式不正确:{}", error.message)
val sb = StringBuilder()
error.bindingResult.fieldErrors.forEach { errorInfo ->
appendExceptionInfo(sb, errorInfo)
}
return RespBean.failure(HttpStatus.BAD_REQUEST.value(), sb.toString())
}
/**
* 处理绑定异常
*/
private fun bindException(error: BindException): RespBean<Void> {
logger.error("数据格式不正确:{}", error.message)
val sb = StringBuilder()
error.bindingResult.fieldErrors.forEach { fieldError ->
appendExceptionInfo(sb, fieldError)
}
return RespBean.failure(HttpStatus.BAD_REQUEST.value(), sb.toString())
}
/**
* 添加异常信息到StringBuilder
*/
private fun appendExceptionInfo(sb: StringBuilder, errorInfo: FieldError) {
if (sb.isNotEmpty()) {
sb.append("; ")
}
sb.append(errorInfo.field).append(": ").append(errorInfo.defaultMessage)
}
/**
* 处理HTTP消息不可读异常
*/
private fun httpMessageNotReadableExceptionAdvice(error: HttpMessageNotReadableException): RespBean<Void> {
logger.error("参数数据类型不匹配:{}", error.message)
return RespBean.failure(HttpStatus.BAD_REQUEST.value(), "参数数据类型不匹配: ${error.message}")
}
/**
* 处理签名异常
*/
private fun signatureException(error: SignatureException): RespBean<Void> {
logger.info("签名异常", error)
return RespBean.failure(420, error.message ?: "签名异常")
}
}

View File

@ -0,0 +1,55 @@
package com.app.config.exception
import com.app.data.RespBean
import jakarta.servlet.http.HttpServletRequest
import jakarta.validation.ValidationException
import org.slf4j.LoggerFactory
import org.springframework.http.HttpStatus
import org.springframework.http.converter.HttpMessageNotReadableException
import org.springframework.validation.BindException
import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.ControllerAdvice
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.ResponseBody
/**
* 全局异常统一处理
*/
@ControllerAdvice
@ResponseBody
class GlobalException(
private val exceptionAdviceHandel: ExceptionAdviceHandel
) {
// 所有未明确处理的异常将通过exceptionAdviceHandel统一处理
@ExceptionHandler(Throwable::class)
fun handleAllExceptions(req: HttpServletRequest, error: Throwable): RespBean<*> {
log.error("全局异常处理", error)
return exceptionAdviceHandel.getDefaultExceptionResponse(req, error)
}
// 验证相关异常处理
@ExceptionHandler(ValidationException::class)
fun validateError(exception: ValidationException): RespBean<Void> {
log.warn("验证不通过: [{}: {}]", exception.javaClass.name, exception.message)
return RespBean.failure(HttpStatus.BAD_REQUEST.value(), "请求参数有误: ${exception.message}")
}
// 处理参数校验异常
@ExceptionHandler(MethodArgumentNotValidException::class, BindException::class)
fun handleBindingErrors(req: HttpServletRequest, error: Exception): RespBean<*> {
log.warn("参数校验异常", error)
return exceptionAdviceHandel.getDefaultExceptionResponse(req, error)
}
// 处理HTTP消息转换异常
@ExceptionHandler(HttpMessageNotReadableException::class)
fun handleHttpMessageNotReadableException(req: HttpServletRequest, error: HttpMessageNotReadableException): RespBean<*> {
log.warn("HTTP消息转换异常", error)
return exceptionAdviceHandel.getDefaultExceptionResponse(req, error)
}
companion object {
private val log = LoggerFactory.getLogger(GlobalException::class.java)
}
}

View File

@ -0,0 +1,88 @@
package com.app.config.satoken
import cn.dev33.satoken.context.SaHolder
import cn.dev33.satoken.filter.SaServletFilter
import cn.dev33.satoken.interceptor.SaInterceptor
import cn.dev33.satoken.jwt.StpLogicJwtForSimple
import cn.dev33.satoken.stp.StpLogic
import cn.dev33.satoken.util.SaResult
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.web.servlet.config.annotation.InterceptorRegistry
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
/**
* [Sa-Token 权限认证] 配置类
* @author click33
*/
@Configuration
class SaTokenConfigure : WebMvcConfigurer {
/**
* 注册 Sa-Token 拦截器打开注解鉴权功能
*/
override fun addInterceptors(registry: InterceptorRegistry) {
// 注册 Sa-Token 拦截器打开注解鉴权功能
registry.addInterceptor(SaInterceptor())
.addPathPatterns("/**")
.excludePathPatterns(
"/api/auth/login",
"/api/auth/register",
"/api/sms",
"/api/mobLogin",
"/swagger/**",
"/swagger-ui.html",
"/swagger-resources/**",
"/doc.html",
"/v2/**",
"/v3/**",
"/v2/api-docs/**",
"/v2/api-docs-ext/**",
"/v3/api-docs/**",
"/favicon.ico",
"/error"
)
}
/**
* Sa-Token 整合 jwt
*/
@Bean
fun getStpLogicJwt(): StpLogic {
return StpLogicJwtForSimple()
}
/**
* 注册 [Sa-Token 全局过滤器]
*/
@Bean
fun getSaServletFilter(): SaServletFilter {
return SaServletFilter()
// 指定 [拦截路由] 与 [放行路由]
.addInclude("/**") // .addExclude("/favicon.ico")
// 认证函数: 每次请求执行
.setAuth {
// println("---------- sa全局认证 ${SaHolder.getRequest().requestPath}")
}
// 异常处理函数:每次认证函数发生异常时执行此函数
.setError { e ->
println("---------- sa全局异常 ")
e.printStackTrace()
SaResult.error(e.message)
}
// 前置函数:在每次认证函数之前执行
.setBeforeAuth {
// ---------- 设置一些安全响应头 ----------
SaHolder.getResponse()
// 服务器名称
.setServer("sa-server")
// 是否可以在iframe显示视图 DENY=不可以 | SAMEORIGIN=同域下可以 | ALLOW-FROM uri=指定域名下可以
.setHeader("X-Frame-Options", "SAMEORIGIN")
// 是否启用浏览器默认XSS防护 0=禁用 | 1=启用 | 1; mode=block 启用, 并在检查到XSS攻击时停止渲染页面
.setHeader("X-XSS-Protection", "1; mode=block")
// 禁用浏览器内容嗅探
.setHeader("X-Content-Type-Options", "nosniff")
}
}
}

View File

@ -0,0 +1,43 @@
package com.app.config.satoken
import cn.dev33.satoken.stp.StpInterface
import com.app.service.UserService
import org.springframework.stereotype.Component
/**
* 自定义权限验证接口扩展
*/
@Component // 打开此注解保证此类被springboot扫描即可完成sa-token的自定义权限验证扩展
class StpInterfaceImpl(
private val userService: UserService
) : StpInterface {
/**
* 返回一个账号所拥有的权限码集合
*/
override fun getPermissionList(loginId: Any, loginType: String): List<String> {
// 本list仅做模拟实际项目中要根据具体业务逻辑来查询权限
// userService.selectById(loginId as Long)
return listOf(
"101",
"user-add",
"user-delete",
"user-update",
"user-get",
"article-get"
)
}
/**
* 返回一个账号所拥有的角色标识集合
*/
override fun getRoleList(loginId: Any, loginType: String): List<String> {
// 本list仅做模拟实际项目中要根据具体业务逻辑来查询角色
return listOf("admin", "super-admin")
// 实际业务代码示例:
// val id = loginId as Long
// val userEntity = userMapper.selectById(id)
// return listOf(userEntity.role)
}
}

View File

@ -0,0 +1,61 @@
package com.app.config.swagger
import cn.hutool.core.util.RandomUtil
import io.swagger.v3.oas.models.OpenAPI
import io.swagger.v3.oas.models.info.Info
import io.swagger.v3.oas.models.info.License
import org.springdoc.core.customizers.GlobalOpenApiCustomizer
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
@Configuration
class SwaggerConfig {
@Bean
fun orderGlobalOpenApiCustomizer(): GlobalOpenApiCustomizer {
return GlobalOpenApiCustomizer { openApi ->
openApi.tags?.forEach { tag ->
val map = HashMap<String, Any>()
map["x-order"] = RandomUtil.randomInt(0, 100)
tag.extensions = map
}
if (openApi.paths != null) {
openApi.addExtension("x-test123", "333")
openApi.paths.addExtension("x-abb", RandomUtil.randomInt(1, 100))
}
}
}
@Bean
fun customOpenAPI(): OpenAPI {
return OpenAPI()
.info(
Info()
.title("XXX用户系统API")
.version("1.0")
.description("Knife4j集成springdoc-openapi示例")
.termsOfService("http://doc.xiaominfo.com")
.license(
License().name("Apache 2.0")
.url("http://doc.xiaominfo.com")
)
)
}
// @Bean
// fun csrsApi(): GroupedOpenApi {
// return GroupedOpenApi.builder()
// .group("csrs系统模块")
// .packagesToScan("com.csrs.web.controller.csrs")
// .build()
// }
//
// @Bean
// fun aiApi(): GroupedOpenApi {
// return GroupedOpenApi.builder()
// .group("ai模块")
// .packagesToScan("com.csrs.web.controller.ai")
// .build()
// }
}