diff --git a/.gitignore b/.gitignore index e319b73..0544e39 100644 --- a/.gitignore +++ b/.gitignore @@ -5,8 +5,8 @@ build/ !gradle/wrapper/gradle-wrapper.jar !**/src/main/**/build/ !**/src/test/**/build/ -logs/ -config/ +/logs +/config ### STS ### .apt_generated diff --git a/src/main/kotlin/com/app/config/HuToolConfig.kt b/src/main/kotlin/com/app/config/HuToolConfig.kt new file mode 100644 index 0000000..1658d07 --- /dev/null +++ b/src/main/kotlin/com/app/config/HuToolConfig.kt @@ -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() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/app/config/ResponseAdvisor.kt b/src/main/kotlin/com/app/config/ResponseAdvisor.kt new file mode 100644 index 0000000..f4996a8 --- /dev/null +++ b/src/main/kotlin/com/app/config/ResponseAdvisor.kt @@ -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 { + + @Value("#{'\${ignore.response.ignoreUris:/swagger,/actuator,/api-docs,/v3/api-docs,/doc.html}'.split(',')}") + private lateinit var ignoreUris: Array + + override fun supports(returnType: MethodParameter, converterType: Class>): Boolean { + return true + } + + override fun beforeBodyWrite( + body: Any?, + returnType: MethodParameter, + selectedContentType: MediaType, + selectedConverterType: Class>, + 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(null) + } + + return RespBean.success(body) + } +} diff --git a/src/main/kotlin/com/app/config/exception/ExceptionAdviceHandel.kt b/src/main/kotlin/com/app/config/exception/ExceptionAdviceHandel.kt new file mode 100644 index 0000000..d43e67b --- /dev/null +++ b/src/main/kotlin/com/app/config/exception/ExceptionAdviceHandel.kt @@ -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 { + 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 { + 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 { + 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 { + logger.error("参数数据类型不匹配:{}", error.message) + return RespBean.failure(HttpStatus.BAD_REQUEST.value(), "参数数据类型不匹配: ${error.message}") + } + + /** + * 处理签名异常 + */ + private fun signatureException(error: SignatureException): RespBean { + logger.info("签名异常", error) + return RespBean.failure(420, error.message ?: "签名异常") + } +} diff --git a/src/main/kotlin/com/app/config/exception/GlobalException.kt b/src/main/kotlin/com/app/config/exception/GlobalException.kt new file mode 100644 index 0000000..bec07e0 --- /dev/null +++ b/src/main/kotlin/com/app/config/exception/GlobalException.kt @@ -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 { + 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) + } +} diff --git a/src/main/kotlin/com/app/config/satoken/SaTokenConfigure.kt b/src/main/kotlin/com/app/config/satoken/SaTokenConfigure.kt new file mode 100644 index 0000000..3c790e0 --- /dev/null +++ b/src/main/kotlin/com/app/config/satoken/SaTokenConfigure.kt @@ -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") + } + } +} diff --git a/src/main/kotlin/com/app/config/satoken/StpInterfaceImpl.kt b/src/main/kotlin/com/app/config/satoken/StpInterfaceImpl.kt new file mode 100644 index 0000000..65fba5b --- /dev/null +++ b/src/main/kotlin/com/app/config/satoken/StpInterfaceImpl.kt @@ -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 { + // 本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 { + // 本list仅做模拟,实际项目中要根据具体业务逻辑来查询角色 + return listOf("admin", "super-admin") + + // 实际业务代码示例: + // val id = loginId as Long + // val userEntity = userMapper.selectById(id) + // return listOf(userEntity.role) + } +} diff --git a/src/main/kotlin/com/app/config/swagger/SwaggerConfig.kt b/src/main/kotlin/com/app/config/swagger/SwaggerConfig.kt new file mode 100644 index 0000000..fab20e2 --- /dev/null +++ b/src/main/kotlin/com/app/config/swagger/SwaggerConfig.kt @@ -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() + 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() +// } + +} \ No newline at end of file