From 189e34b7a27722c309700c08babbfe619454dd79 Mon Sep 17 00:00:00 2001 From: AiKrai Date: Sun, 13 Apr 2025 20:59:49 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Spring=20Boot=20JWT=E5=BA=94=E7=94=A8?= =?UTF-8?q?=E6=A8=A1=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 43 +++++++ build.gradle.kts | 78 +++++++++++++ settings.gradle.kts | 1 + src/main/kotlin/com/app/Application.kt | 11 ++ .../com/app/controller/AuthorizeController.kt | 43 +++++++ .../com/app/controller/HelloController.kt | 18 +++ .../com/app/controller/UserController.kt | 29 +++++ .../exception/ErrorPageController.kt | 60 ++++++++++ src/main/kotlin/com/app/data/RespBean.kt | 60 ++++++++++ .../kotlin/com/app/data/dto/SignInRequest.kt | 10 ++ src/main/kotlin/com/app/data/model/User.kt | 36 ++++++ src/main/kotlin/com/app/filter/CorsFilter.kt | 70 ++++++++++++ .../com/app/filter/FlowLimitingFilter.kt | 89 +++++++++++++++ .../kotlin/com/app/filter/RequestLogFilter.kt | 107 ++++++++++++++++++ .../com/app/repository/UserRepository.kt | 30 +++++ .../kotlin/com/app/service/UserService.kt | 13 +++ .../com/app/service/impl/UserServiceImpl.kt | 37 ++++++ src/main/kotlin/com/app/utlis/Const.kt | 31 +++++ src/main/kotlin/com/app/utlis/FlowUtils.kt | 92 +++++++++++++++ src/main/resources/application-knife4j.yml | 17 +++ src/main/resources/application-satoken.yml | 26 +++++ src/main/resources/application.yml | 42 +++++++ src/main/resources/db/sys_user.sql | 68 +++++++++++ src/main/resources/logback.xml | 93 +++++++++++++++ src/test/kotlin/com/app/ApplicationTests.kt | 13 +++ 25 files changed, 1117 insertions(+) create mode 100644 .gitignore create mode 100644 build.gradle.kts create mode 100644 settings.gradle.kts create mode 100644 src/main/kotlin/com/app/Application.kt create mode 100644 src/main/kotlin/com/app/controller/AuthorizeController.kt create mode 100644 src/main/kotlin/com/app/controller/HelloController.kt create mode 100644 src/main/kotlin/com/app/controller/UserController.kt create mode 100644 src/main/kotlin/com/app/controller/exception/ErrorPageController.kt create mode 100644 src/main/kotlin/com/app/data/RespBean.kt create mode 100644 src/main/kotlin/com/app/data/dto/SignInRequest.kt create mode 100644 src/main/kotlin/com/app/data/model/User.kt create mode 100644 src/main/kotlin/com/app/filter/CorsFilter.kt create mode 100644 src/main/kotlin/com/app/filter/FlowLimitingFilter.kt create mode 100644 src/main/kotlin/com/app/filter/RequestLogFilter.kt create mode 100644 src/main/kotlin/com/app/repository/UserRepository.kt create mode 100644 src/main/kotlin/com/app/service/UserService.kt create mode 100644 src/main/kotlin/com/app/service/impl/UserServiceImpl.kt create mode 100644 src/main/kotlin/com/app/utlis/Const.kt create mode 100644 src/main/kotlin/com/app/utlis/FlowUtils.kt create mode 100644 src/main/resources/application-knife4j.yml create mode 100644 src/main/resources/application-satoken.yml create mode 100644 src/main/resources/application.yml create mode 100644 src/main/resources/db/sys_user.sql create mode 100644 src/main/resources/logback.xml create mode 100644 src/test/kotlin/com/app/ApplicationTests.kt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e319b73 --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +HELP.md +.gradle +gradle/ +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ +logs/ +config/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +### Kotlin ### +.kotlin diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..24423c9 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,78 @@ +plugins { + kotlin("jvm") version "1.9.25" + kotlin("plugin.spring") version "1.9.25" + id("org.springframework.boot") version "3.4.4" + id("io.spring.dependency-management") version "1.1.7" + id("com.google.devtools.ksp") version "1.9.25-1.0.20" + id("tech.argonariod.gradle-plugin-jimmer") version "1.2.0" +} + +group = "com.app" +version = "0.0.1-SNAPSHOT" +val jimmerVersion = "0.9.73" +val knife4jVersion = "4.4.0" + + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +repositories { + mavenCentral() +} + +kotlin { + compilerOptions { + freeCompilerArgs.addAll("-Xjsr305=strict") + } +} + +tasks.withType { + useJUnitPlatform() +} + +jimmer { + version = jimmerVersion +} + +dependencies { + implementation(kotlin("reflect")) + implementation(kotlin("stdlib-jdk8")) + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor:1.8.1") + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + + // redis + implementation("org.springframework.boot:spring-boot-starter-data-redis") +// implementation("com.fasterxml.jackson.core:jackson-databind") + + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") + // springdoc + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.6") + + implementation("org.babyfish.jimmer:jimmer-spring-boot-starter:${jimmerVersion}") + + // db + implementation("org.postgresql:postgresql") +// implementation("com.ongres.scram:client:2.1") + + // lombok + compileOnly("org.projectlombok:lombok") + + // hutool + implementation("cn.hutool:hutool-all:5.8.24") + // swagger + implementation("com.github.xiaoymin:knife4j-openapi3-jakarta-spring-boot-starter:${knife4jVersion}") + + // sa-token + implementation("cn.dev33:sa-token-spring-boot3-starter:1.42.0") + implementation("cn.dev33:sa-token-jwt:1.42.0") + + +// implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.18.3") +} diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..060f02b --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "springboot-template-jwt-api" diff --git a/src/main/kotlin/com/app/Application.kt b/src/main/kotlin/com/app/Application.kt new file mode 100644 index 0000000..045eeba --- /dev/null +++ b/src/main/kotlin/com/app/Application.kt @@ -0,0 +1,11 @@ +package com.app + +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.runApplication + +@SpringBootApplication +class SpringbootTemplateJwtApiApplication + +fun main(args: Array) { + runApplication(*args) +} diff --git a/src/main/kotlin/com/app/controller/AuthorizeController.kt b/src/main/kotlin/com/app/controller/AuthorizeController.kt new file mode 100644 index 0000000..6459873 --- /dev/null +++ b/src/main/kotlin/com/app/controller/AuthorizeController.kt @@ -0,0 +1,43 @@ +package com.app.controller; + +import cn.dev33.satoken.stp.StpUtil +import com.app.data.dto.SignInRequest +import com.app.service.UserService +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.validation.annotation.Validated +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@Validated +@RestController +@RequestMapping("/api/auth") +@Tag(name = "登录校验相关", description = "包括用户登录、注册、验证码请求等操作。") +class AuthorizeController( + private val userService: UserService +) { + @Operation(summary = "注册") + @PostMapping("/register") + fun register( + @RequestBody signInRequest: SignInRequest + ): String { + return userService.signIn(signInRequest.username, signInRequest.password) + } + + @Operation(summary = "登录") + @PostMapping("/login") + fun login( + @RequestBody signInRequest: SignInRequest + ): String { + return userService.signIn(signInRequest.username, signInRequest.password) + } + + @Operation(summary = "退出登录") + @PostMapping("/logout") + fun logout(): String { + StpUtil.logout() + return "退出登录成功" + } +} diff --git a/src/main/kotlin/com/app/controller/HelloController.kt b/src/main/kotlin/com/app/controller/HelloController.kt new file mode 100644 index 0000000..a5a24c9 --- /dev/null +++ b/src/main/kotlin/com/app/controller/HelloController.kt @@ -0,0 +1,18 @@ +package com.app.controller + + +import cn.dev33.satoken.annotation.SaIgnore +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/") +class HelloController { + + @SaIgnore + @GetMapping("/") + fun hello(): String { + return "Hello, World!" + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/app/controller/UserController.kt b/src/main/kotlin/com/app/controller/UserController.kt new file mode 100644 index 0000000..8862e05 --- /dev/null +++ b/src/main/kotlin/com/app/controller/UserController.kt @@ -0,0 +1,29 @@ +package com.app.controller + +import cn.dev33.satoken.annotation.SaCheckRole +import com.app.data.model.User +import com.app.service.UserService +import io.swagger.v3.oas.annotations.tags.Tag +import org.babyfish.jimmer.Page +import org.springframework.validation.annotation.Validated +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@Validated +@RestController +@RequestMapping("/api/user") +@Tag(name = "用户", description = "") +class UserController( + private val userService: UserService +) { + + @SaCheckRole("admin") + @PostMapping("/list") + fun list( + pageNum: Int? = 1, + pageSize: Int? = 10 + ): Page { + return userService.list(pageNum!!, pageSize!!) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/app/controller/exception/ErrorPageController.kt b/src/main/kotlin/com/app/controller/exception/ErrorPageController.kt new file mode 100644 index 0000000..75d8f38 --- /dev/null +++ b/src/main/kotlin/com/app/controller/exception/ErrorPageController.kt @@ -0,0 +1,60 @@ +package com.example.controller.exception + +import com.app.data.RespBean +import jakarta.servlet.http.HttpServletRequest +import org.springframework.boot.autoconfigure.web.servlet.error.AbstractErrorController +import org.springframework.boot.web.error.ErrorAttributeOptions +import org.springframework.boot.web.servlet.error.ErrorAttributes +import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import java.util.* + +/** + * 专用用于处理错误页面的Controller + */ +@RestController +@RequestMapping("\${server.error.path:\${error.path:/error}}") +class ErrorPageController(errorAttributes: ErrorAttributes) : AbstractErrorController(errorAttributes) { + + /** + * 所有错误在这里统一处理,自动解析状态码和原因 + * @param request 请求 + * @return 失败响应 + */ + @RequestMapping + fun error(request: HttpServletRequest): RespBean { + val status = getStatus(request) + val errorAttributes = getErrorAttributes(request, attributeOptions) + val message = convertErrorMessage(status) + .orElse(errorAttributes["message"].toString()) + return RespBean.failure(status.value(), message) + } + + /** + * 对于一些特殊的状态码,错误信息转换 + * @param status 状态码 + * @return 错误信息 + */ + private fun convertErrorMessage(status: HttpStatus): Optional { + val value = when (status.value()) { + 400 -> "请求参数有误" + 404 -> "请求的接口不存在" + 405 -> "请求方法错误" + 500 -> "内部错误,请联系管理员" + else -> null + } + return Optional.ofNullable(value) + } + + /** + * 错误属性获取选项,这里额外添加了错误消息和异常类型 + * @return 选项 + */ + private val attributeOptions: ErrorAttributeOptions + get() = ErrorAttributeOptions.defaults() + .including( + ErrorAttributeOptions.Include.MESSAGE, + ErrorAttributeOptions.Include.EXCEPTION + ) +} diff --git a/src/main/kotlin/com/app/data/RespBean.kt b/src/main/kotlin/com/app/data/RespBean.kt new file mode 100644 index 0000000..76e460e --- /dev/null +++ b/src/main/kotlin/com/app/data/RespBean.kt @@ -0,0 +1,60 @@ +package com.app.data + +import cn.hutool.json.JSONUtil +import org.slf4j.MDC +import java.util.* + +/** + * 响应实体类封装,Rest风格 + * @param id 请求ID + * @param code 状态码 + * @param data 响应数据 + * @param message 其他消息 + * @param T 响应数据类型 + */ +data class RespBean( + val id: Long, + val code: Int, + val data: T?, + val message: String +) { + companion object { + fun success(data: T?): RespBean { + return RespBean(requestId(), 200, data, "请求成功") + } + + fun success(): RespBean { + return success(null) + } + + fun forbidden(message: String): RespBean { + return failure(403, message) + } + + fun unauthorized(message: String): RespBean { + return failure(401, message) + } + + fun failure(code: Int, message: String): RespBean { + return RespBean(requestId(), code, null, message) + } + + /** + * 获取当前请求ID方便快速定位错误 + * @return ID + */ + private fun requestId(): Long { + val requestId = Optional.ofNullable(MDC.get("reqId")).orElse("0") + return requestId.toLong() + } + } + + /** + * 快速将当前实体转换为JSON字符串格式 + * @return JSON字符串 + */ + fun asJsonString(): String { + return JSONUtil.toJsonStr(this) +// return JSONObject.toJSONString(this, JSONWriter.Feature.WriteNulls) + } +} diff --git a/src/main/kotlin/com/app/data/dto/SignInRequest.kt b/src/main/kotlin/com/app/data/dto/SignInRequest.kt new file mode 100644 index 0000000..24abbfa --- /dev/null +++ b/src/main/kotlin/com/app/data/dto/SignInRequest.kt @@ -0,0 +1,10 @@ +package com.app.data.dto + +import io.swagger.v3.oas.annotations.media.Schema + +data class SignInRequest ( + @Schema(title = "username", description = "账号名称", defaultValue = "") + var username: String, + @Schema(title = "password", description = "账号密码", defaultValue = "") + var password: String +) \ No newline at end of file diff --git a/src/main/kotlin/com/app/data/model/User.kt b/src/main/kotlin/com/app/data/model/User.kt new file mode 100644 index 0000000..9952f13 --- /dev/null +++ b/src/main/kotlin/com/app/data/model/User.kt @@ -0,0 +1,36 @@ +package com.app.data.model + +import org.babyfish.jimmer.sql.* +import java.sql.Timestamp + +@Entity +@Table(name = "sys_user") +interface User { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val userId: Long + + val deptId: Long? + + @Column(name = "user_name") + val userName: String + + val nickName: String? + + val userType: String? + + val email: String? + val phone: String? + val sex: String? + val avatar: String? + val password: String? + val status: String? + val delFlag: String? + val loginIp: String? + val loginDate: Timestamp? + val createBy: String? + val createTime: Timestamp? + val updateBy: String? + val updateTime: Timestamp? + val remark: String? +} \ No newline at end of file diff --git a/src/main/kotlin/com/app/filter/CorsFilter.kt b/src/main/kotlin/com/app/filter/CorsFilter.kt new file mode 100644 index 0000000..b15e9a0 --- /dev/null +++ b/src/main/kotlin/com/app/filter/CorsFilter.kt @@ -0,0 +1,70 @@ +package com.app.filter + +import com.app.utlis.Const +import jakarta.servlet.FilterChain +import jakarta.servlet.ServletException +import jakarta.servlet.http.HttpFilter +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.beans.factory.annotation.Value +import org.springframework.core.annotation.Order +import org.springframework.stereotype.Component +import java.io.IOException + +/** + * 跨域配置过滤器,仅处理跨域,添加跨域响应头 + */ +@Component +@Order(Const.ORDER_CORS) +class CorsFilter : HttpFilter() { + + @Value("\${spring.web.cors.origin}") + private lateinit var origin: String + + @Value("\${spring.web.cors.credentials}") + private var credentials: Boolean = false + + @Value("\${spring.web.cors.methods}") + private lateinit var methods: String + + @Throws(IOException::class, ServletException::class) + override fun doFilter(request: HttpServletRequest, response: HttpServletResponse, chain: FilterChain) { + addCorsHeader(request, response) + chain.doFilter(request, response) + } + + /** + * 添加所有跨域相关响应头 + * @param request 请求 + * @param response 响应 + */ + private fun addCorsHeader(request: HttpServletRequest, response: HttpServletResponse) { + response.addHeader("Access-Control-Allow-Origin", resolveOrigin(request)) + response.addHeader("Access-Control-Allow-Methods", resolveMethod()) + response.addHeader("Access-Control-Allow-Headers", "Authorization, Content-Type") + if (credentials) { + response.addHeader("Access-Control-Allow-Credentials", "true") + } + } + + /** + * 解析配置文件中的请求方法 + * @return 解析得到的请求头值 + */ + private fun resolveMethod(): String { + return if (methods == "*") "GET, HEAD, POST, PUT, DELETE, OPTIONS, TRACE, PATCH" else methods + } + + /** + * 解析配置文件中的请求原始站点 + * @param request 请求 + * @return 解析得到的请求头值 + */ + private fun resolveOrigin(request: HttpServletRequest): String { + return if (origin == "*") { + request.getHeader("Origin") ?: "*" + } else { + origin + } + } +} diff --git a/src/main/kotlin/com/app/filter/FlowLimitingFilter.kt b/src/main/kotlin/com/app/filter/FlowLimitingFilter.kt new file mode 100644 index 0000000..6e24296 --- /dev/null +++ b/src/main/kotlin/com/app/filter/FlowLimitingFilter.kt @@ -0,0 +1,89 @@ +package com.app.filter + +import com.app.data.RespBean +import com.app.utlis.Const +import com.app.utlis.FlowUtils +import jakarta.annotation.Resource +import jakarta.servlet.FilterChain +import jakarta.servlet.ServletException +import jakarta.servlet.http.HttpFilter +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import lombok.extern.slf4j.Slf4j +import org.springframework.beans.factory.annotation.Value +import org.springframework.core.annotation.Order +import org.springframework.data.redis.core.StringRedisTemplate +import org.springframework.stereotype.Component +import java.io.IOException +import java.io.PrintWriter + +/** + * 限流控制过滤器 + * 防止用户高频请求接口,借助Redis进行限流 + */ +@Slf4j +@Component +@Order(Const.ORDER_FLOW_LIMIT) +class FlowLimitingFilter : HttpFilter() { + + @Resource + private lateinit var template: StringRedisTemplate + + // 指定时间内最大请求次数限制 + @Value("\${spring.web.flow.limit}") + private var limit: Int = 0 + + // 计数时间周期 + @Value("\${spring.web.flow.period}") + private var period: Int = 0 + + // 超出请求限制封禁时间 + @Value("\${spring.web.flow.block}") + private var block: Int = 0 + + @Resource + private lateinit var utils: FlowUtils + + @Throws(IOException::class, ServletException::class) + override fun doFilter(request: HttpServletRequest, response: HttpServletResponse, chain: FilterChain) { + val address = request.remoteAddr + if ("OPTIONS" != request.method && !tryCount(address)) { + writeBlockMessage(response) + } else { + chain.doFilter(request, response) + } + } + + /** + * 尝试对指定IP地址请求计数,如果被限制则无法继续访问 + * @param address 请求IP地址 + * @return 是否操作成功 + */ + private fun tryCount(address: String): Boolean { + synchronized(address.intern()) { + if (template.hasKey(Const.FLOW_LIMIT_BLOCK + address) == true) { + return false + } + val counterKey = Const.FLOW_LIMIT_COUNTER + address + val blockKey = Const.FLOW_LIMIT_BLOCK + address + return utils.limitPeriodCheck(counterKey, blockKey, block, limit, period) + } + } + + /** + * 为响应编写拦截内容,提示用户操作频繁 + * @param response 响应 + * @throws IOException 可能的异常 + */ + @Throws(IOException::class) + private fun writeBlockMessage(response: HttpServletResponse) { + response.status = 429 + response.contentType = "application/json;charset=utf-8" + val writer: PrintWriter = response.writer + writer.write(RespBean.failure(429, "请求频率过快,请稍后再试").asJsonString()) + } + + companion object { + private val log = org.slf4j.LoggerFactory.getLogger(FlowLimitingFilter::class.java) + } +} diff --git a/src/main/kotlin/com/app/filter/RequestLogFilter.kt b/src/main/kotlin/com/app/filter/RequestLogFilter.kt new file mode 100644 index 0000000..6ef90fb --- /dev/null +++ b/src/main/kotlin/com/app/filter/RequestLogFilter.kt @@ -0,0 +1,107 @@ +package com.app.filter + +import cn.dev33.satoken.stp.StpUtil +import cn.hutool.core.lang.Snowflake +import cn.hutool.json.JSONObject +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import lombok.extern.slf4j.Slf4j +import org.slf4j.MDC +import org.springframework.stereotype.Component +import org.springframework.web.filter.OncePerRequestFilter +import org.springframework.web.util.ContentCachingResponseWrapper + +@Slf4j +@Component +class RequestLogFilter( + private val snowflake: Snowflake +) : OncePerRequestFilter() { + + private val ignores = setOf("/chatjava", "/ai", "/doc.html", "/swagger-ui", "/v3/api-docs") + + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain + ) { + if (isIgnoreUrl(request.servletPath)) { + filterChain.doFilter(request, response) + } else { + val startTime = System.currentTimeMillis() + logRequestStart(request) + val wrapper = ContentCachingResponseWrapper(response) + filterChain.doFilter(request, wrapper) + logRequestEnd(wrapper, startTime) + wrapper.copyBodyToResponse() + } + } + + /** + * 判定当前请求url是否不需要日志打印 + * + * @param url 路径 + * @return 是否忽略 + */ + private fun isIgnoreUrl(url: String): Boolean { + return ignores.any { url.startsWith(it) } + } + + /** + * 请求结束时的日志打印,包含处理耗时以及响应结果 + * + * @param wrapper 用于读取响应结果的包装类 + * @param startTime 起始时间 + */ + fun logRequestEnd(wrapper: ContentCachingResponseWrapper, startTime: Long) { + val time = System.currentTimeMillis() - startTime + val status = wrapper.status + val content = if (status != 200) "$status 错误" + else String(wrapper.contentAsByteArray) + + log.info("\n>>>>>请求处理耗时:[{}ms] 响应结果:{}", time, content) + } + + /** + * 请求开始时的日志打印,包含请求全部信息,以及对应用户角色 + * + * @param request 请求 + */ + fun logRequestStart(request: HttpServletRequest) { + val reqId = snowflake.nextId() + MDC.put("reqId", reqId.toString()) + + val params = JSONObject().apply { + request.parameterMap.forEach { (k, v) -> + put(k, if (v.isNotEmpty()) v[0] else null) + } + } + + if (StpUtil.isLogin()) { + val id = StpUtil.getLoginId() + log.info(""" + + >>>>>请求ID:[${reqId}] + >>>>>请求URL:["${request.servletPath}"](${request.method}) + >>>>>远程IP:[${request.remoteAddr}] + >>>>>用户名:[username] + >>>>>用户ID:$id + >>>>>角色:${StpUtil.getRoleList()} + >>>>>请求参数列表: [$params] + """.trimIndent()) + } else { + log.info(""" + + >>>>>请求ID:[${reqId}] + >>>>>请求URL:["${request.servletPath}"](${request.method}) + >>>>>远程IP地址:[${request.remoteAddr}] + >>>>>身份:未验证 + >>>>>请求参数列表: [$params] + """.trimIndent()) + } + } + + companion object { + private val log = org.slf4j.LoggerFactory.getLogger(RequestLogFilter::class.java) + } +} diff --git a/src/main/kotlin/com/app/repository/UserRepository.kt b/src/main/kotlin/com/app/repository/UserRepository.kt new file mode 100644 index 0000000..bc4d4ab --- /dev/null +++ b/src/main/kotlin/com/app/repository/UserRepository.kt @@ -0,0 +1,30 @@ +package com.app.repository + +import com.app.data.model.User +import com.app.data.model.userId +import com.app.data.model.userName +import org.babyfish.jimmer.Page +import org.babyfish.jimmer.spring.repository.KRepository +import org.babyfish.jimmer.sql.kt.ast.expression.eq +import org.babyfish.jimmer.sql.kt.ast.expression.`eq?` + + +interface UserRepository : KRepository { + fun findUser( + pageIndex: Int = 0, + pageSize: Int = 10, + id: Long? = null, + name: String? = null, + email: String? = null, + phone: String? = null, + ): Page = + sql.createQuery(User::class) { + where(table.userId `eq?` id) + select(table) + }.fetchPage(pageIndex, pageSize) + + fun findByUserName(username: String): User? = sql.createQuery(User::class) { + where(table.userName eq username) + select(table) + }.fetchOne() +} \ No newline at end of file diff --git a/src/main/kotlin/com/app/service/UserService.kt b/src/main/kotlin/com/app/service/UserService.kt new file mode 100644 index 0000000..d30ecf9 --- /dev/null +++ b/src/main/kotlin/com/app/service/UserService.kt @@ -0,0 +1,13 @@ +package com.app.service + +import com.app.data.model.User +import org.babyfish.jimmer.Page + +interface UserService { + fun signIn(username: String, password: String): String + + fun list( + pageNum: Int = 0, + pageSize: Int = 10 + ): Page +} \ No newline at end of file diff --git a/src/main/kotlin/com/app/service/impl/UserServiceImpl.kt b/src/main/kotlin/com/app/service/impl/UserServiceImpl.kt new file mode 100644 index 0000000..3a362c4 --- /dev/null +++ b/src/main/kotlin/com/app/service/impl/UserServiceImpl.kt @@ -0,0 +1,37 @@ +package com.app.service.impl + +import cn.dev33.satoken.stp.StpUtil +import cn.dev33.satoken.stp.parameter.SaLoginParameter +import cn.hutool.crypto.SecureUtil +import com.app.data.model.User +import com.app.repository.UserRepository +import com.app.service.UserService +import org.babyfish.jimmer.Page +import org.springframework.stereotype.Service + +@Service +class UserServiceImpl( + private val userRepository: UserRepository +): UserService { + override fun signIn(username: String, password: String): String { +// withContext(Dispatchers.IO) { +// +// } + val user = userRepository.findByUserName(username) ?: throw Exception("User not found") + if (user.password != SecureUtil.sha1(password)) { + throw Exception("Invalid password") + } + val parameter = SaLoginParameter.create() + .setExtraData(mapOf("deptId" to user.deptId)) + StpUtil.login(user.userId.toString(), parameter) + val token = StpUtil.getTokenInfo() + return token.tokenValue; + } + + override fun list( + pageNum: Int, + pageSize: Int + ): Page { + return userRepository.findUser(pageNum, pageSize) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/app/utlis/Const.kt b/src/main/kotlin/com/app/utlis/Const.kt new file mode 100644 index 0000000..b07b36b --- /dev/null +++ b/src/main/kotlin/com/app/utlis/Const.kt @@ -0,0 +1,31 @@ +package com.app.utlis + +/** + * 一些常量字符串整合 + */ +object Const { + // JWT令牌 + const val JWT_BLACK_LIST = "jwt:blacklist:" + const val JWT_FREQUENCY = "jwt:frequency:" + + // 请求频率限制 + const val FLOW_LIMIT_COUNTER = "flow:counter:" + const val FLOW_LIMIT_BLOCK = "flow:block:" + + // 邮件验证码 + const val VERIFY_EMAIL_LIMIT = "verify:email:limit:" + const val VERIFY_EMAIL_DATA = "verify:email:data:" + + // 过滤器优先级 + const val ORDER_FLOW_LIMIT = -101 + const val ORDER_CORS = -102 + + // 请求自定义属性 + const val ATTR_USER_ID = "userId" + + // 消息队列 + const val MQ_MAIL = "mail" + + // 用户角色 + const val ROLE_DEFAULT = "user" +} diff --git a/src/main/kotlin/com/app/utlis/FlowUtils.kt b/src/main/kotlin/com/app/utlis/FlowUtils.kt new file mode 100644 index 0000000..50d7150 --- /dev/null +++ b/src/main/kotlin/com/app/utlis/FlowUtils.kt @@ -0,0 +1,92 @@ +package com.app.utlis + +import jakarta.annotation.Resource +import lombok.extern.slf4j.Slf4j +import org.springframework.data.redis.core.StringRedisTemplate +import org.springframework.stereotype.Component +import java.util.concurrent.TimeUnit + +/** + * 限流通用工具 + * 针对于不同的情况进行限流操作,支持限流升级 + */ +@Slf4j +@Component +class FlowUtils { + + @Resource + private lateinit var template: StringRedisTemplate + + /** + * 针对于单次频率限制,请求成功后,在冷却时间内不得再次进行请求,如3秒内不能再次发起请求 + * @param key 键 + * @param blockTime 限制时间 + * @return 是否通过限流检查 + */ + fun limitOnceCheck(key: String, blockTime: Int): Boolean { + return internalCheck(key, 1, blockTime) { false } + } + + /** + * 针对于单次频率限制,请求成功后,在冷却时间内不得再次进行请求 + * 如3秒内不能再次发起请求,如果不听劝阻继续发起请求,将限制更长时间 + * @param key 键 + * @param frequency 请求频率 + * @param baseTime 基础限制时间 + * @param upgradeTime 升级限制时间 + * @return 是否通过限流检查 + */ + fun limitOnceUpgradeCheck(key: String, frequency: Int, baseTime: Int, upgradeTime: Int): Boolean { + return internalCheck(key, frequency, baseTime) { overclock -> + if (overclock) { + template.opsForValue().set(key, "1", upgradeTime.toLong(), TimeUnit.SECONDS) + } + false + } + } + + /** + * 针对于在时间段内多次请求限制,如3秒内限制请求20次,超出频率则封禁一段时间 + * @param counterKey 计数键 + * @param blockKey 封禁键 + * @param blockTime 封禁时间 + * @param frequency 请求频率 + * @param period 计数周期 + * @return 是否通过限流检查 + */ + fun limitPeriodCheck(counterKey: String, blockKey: String, blockTime: Int, frequency: Int, period: Int): Boolean { + return internalCheck(counterKey, frequency, period) { overclock -> + if (overclock) { + template.opsForValue().set(blockKey, "", blockTime.toLong(), TimeUnit.SECONDS) + } + !overclock + } + } + + /** + * 内部使用请求限制主要逻辑 + * @param key 计数键 + * @param frequency 请求频率 + * @param period 计数周期 + * @param action 限制行为与策略 + * @return 是否通过限流检查 + */ + private fun internalCheck(key: String, frequency: Int, period: Int, action: (Boolean) -> Boolean): Boolean { + val count = template.opsForValue().get(key) + return if (count != null) { + val value = template.opsForValue().increment(key) ?: 0L + val c = count.toInt() + if (value != c + 1L) { + template.expire(key, period.toLong(), TimeUnit.SECONDS) + } + action(value > frequency) + } else { + template.opsForValue().set(key, "1", period.toLong(), TimeUnit.SECONDS) + true + } + } + + companion object { + private val log = org.slf4j.LoggerFactory.getLogger(FlowUtils::class.java) + } +} diff --git a/src/main/resources/application-knife4j.yml b/src/main/resources/application-knife4j.yml new file mode 100644 index 0000000..6b551f9 --- /dev/null +++ b/src/main/resources/application-knife4j.yml @@ -0,0 +1,17 @@ +# springdoc-openapi项目配置 +springdoc: + swagger-ui: + path: /swagger-ui.html + tags-sorter: alpha + operations-sorter: alpha + api-docs: + path: /v3/api-docs + group-configs: + - group: 'default' + paths-to-match: '/**' + packages-to-scan: com.app +# knife4j的增强配置,不需要增强可以不配 +knife4j: + enable: true + setting: + language: zh_cn \ No newline at end of file diff --git a/src/main/resources/application-satoken.yml b/src/main/resources/application-satoken.yml new file mode 100644 index 0000000..6013acc --- /dev/null +++ b/src/main/resources/application-satoken.yml @@ -0,0 +1,26 @@ +# sa-token 配置 +sa-token: + # 是否在初始化配置时打印版本字符画 + is-print: false + # token 名称 (同时也是 cookie 名称) + token-name: Authorization + # token前缀,例如填写 Bearer 实际传参 satoken: Bearer xxxx-xxxx-xxxx-xxxx + token-prefix: Bearer + # token 有效期(单位:秒) 默认30天,-1 代表永久有效 + timeout: 2592000000000 + # token 最低活跃频率(单位:秒),如果 token 超过此时间没有访问系统就会被冻结,默认-1 代表不限制,永不冻结 + active-timeout: -1 + # 是否允许同一账号多地同时登录 (为 true 时允许一起登录, 为 false 时新登录挤掉旧登录) + is-concurrent: true + # 在多人登录同一账号时,是否共用一个 token (为 true 时所有登录共用一个 token, 为 false 时每次登录新建一个 token) + is-share: true + # token 风格(默认可取值:uuid、simple-uuid、random-32、random-64、random-128、tik) + token-style: random-128 + # 是否输出操作日志 + is-log: false + # jwt秘钥 + jwt-secret-key: sadfjldjfaklfjkekslkrjke + # 是否尝试从 header 里读取 Token + is-read-header: true + # 是否打开自动续签 (如果此值为true, 框架会在每次直接或间接调用 getLoginId() 时进行一次过期检查与续签操作) + auto-renew: true \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..8872aed --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,42 @@ +server: + port: 18080 + servlet: + context-path: / +spring: + application: + name: springboot-template-jwt-api + datasource: + driver-class-name: org.postgresql.Driver + url: jdbc:postgresql://xxx:5432/template + username: xxx + password: xxx + mvc: + pathmatch: + matching-strategy: ANT_PATH_MATCHER + profiles: + active: knife4j,satoken + web: + verify: + mail-limit: 60 + flow: + limit: 50 + period: 3 + block: 30 + cors: + origin: '*' + credentials: false + methods: '*' + + +# jimmer配置参考https://babyfish-ct.github.io/jimmer-doc/zh/docs/spring/appendix +jimmer: + # Kotlin项目必须配置 默认java + language: kotlin + # Jimmer方言类名 + dialect: org.babyfish.jimmer.sql.dialect.PostgresDialect + # 是否打印SQL日志 默认false + show-sql: true + # 是否格式化SQL日志 默认false + pretty-sql: true + # 如果非NONE,验证数据库结构和代码实体类型结构的一致性,如果不一致,WARNING导致日志告警,ERROR导致报错 + database-validation-mode: ERROR \ No newline at end of file diff --git a/src/main/resources/db/sys_user.sql b/src/main/resources/db/sys_user.sql new file mode 100644 index 0000000..2fc608e --- /dev/null +++ b/src/main/resources/db/sys_user.sql @@ -0,0 +1,68 @@ +/* + Navicat Premium Dump SQL + + Source Server : aliyuncs + Source Server Type : PostgreSQL + Source Server Version : 170004 (170004) + Source Catalog : template + Source Schema : public + + Target Server Type : PostgreSQL + Target Server Version : 170004 (170004) + File Encoding : 65001 + + Date: 13/04/2025 20:57:33 +*/ + + +-- ---------------------------- +-- Table structure for sys_user +-- ---------------------------- +DROP TABLE IF EXISTS "public"."sys_user"; +CREATE TABLE "public"."sys_user" ( + "user_id" int8 NOT NULL, + "dept_id" int8, + "user_name" varchar(30) COLLATE "pg_catalog"."default" NOT NULL, + "nick_name" varchar(30) COLLATE "pg_catalog"."default", + "user_type" varchar(2) COLLATE "pg_catalog"."default", + "email" varchar(50) COLLATE "pg_catalog"."default", + "phone" varchar(11) COLLATE "pg_catalog"."default", + "sex" char(1) COLLATE "pg_catalog"."default", + "avatar" varchar(100) COLLATE "pg_catalog"."default", + "password" varchar(100) COLLATE "pg_catalog"."default", + "status" char(1) COLLATE "pg_catalog"."default", + "del_flag" char(1) COLLATE "pg_catalog"."default", + "login_ip" varchar(128) COLLATE "pg_catalog"."default", + "login_date" timestamp(6), + "create_by" varchar(64) COLLATE "pg_catalog"."default", + "create_time" timestamp(6), + "update_by" varchar(64) COLLATE "pg_catalog"."default", + "update_time" timestamp(6), + "remark" varchar(500) COLLATE "pg_catalog"."default" +) +; +COMMENT ON COLUMN "public"."sys_user"."user_id" IS '用户ID'; +COMMENT ON COLUMN "public"."sys_user"."dept_id" IS '部门ID'; +COMMENT ON COLUMN "public"."sys_user"."user_name" IS '用户账号'; +COMMENT ON COLUMN "public"."sys_user"."nick_name" IS '用户昵称'; +COMMENT ON COLUMN "public"."sys_user"."user_type" IS '用户类型(00系统用户)'; +COMMENT ON COLUMN "public"."sys_user"."email" IS '用户邮箱'; +COMMENT ON COLUMN "public"."sys_user"."phone" IS '手机号码'; +COMMENT ON COLUMN "public"."sys_user"."sex" IS '用户性别(0男 1女 2未知)'; +COMMENT ON COLUMN "public"."sys_user"."avatar" IS '头像地址'; +COMMENT ON COLUMN "public"."sys_user"."password" IS '密码'; +COMMENT ON COLUMN "public"."sys_user"."status" IS '帐号状态(0正常 1停用)'; +COMMENT ON COLUMN "public"."sys_user"."del_flag" IS '删除标志(0代表存在 2代表删除)'; +COMMENT ON COLUMN "public"."sys_user"."login_ip" IS '最后登录IP'; +COMMENT ON COLUMN "public"."sys_user"."login_date" IS '最后登录时间'; +COMMENT ON COLUMN "public"."sys_user"."create_by" IS '创建者'; +COMMENT ON COLUMN "public"."sys_user"."create_time" IS '创建时间'; +COMMENT ON COLUMN "public"."sys_user"."update_by" IS '更新者'; +COMMENT ON COLUMN "public"."sys_user"."update_time" IS '更新时间'; +COMMENT ON COLUMN "public"."sys_user"."remark" IS '备注'; +COMMENT ON TABLE "public"."sys_user" IS '用户信息表'; + +-- ---------------------------- +-- Primary Key structure for table sys_user +-- ---------------------------- +ALTER TABLE "public"."sys_user" ADD CONSTRAINT "sys_user_pkey" PRIMARY KEY ("user_id"); diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml new file mode 100644 index 0000000..5b0e953 --- /dev/null +++ b/src/main/resources/logback.xml @@ -0,0 +1,93 @@ + + + + + + + + + + + ${log.pattern} + + + + + + ${log.path}/sys-info.log + + + + ${log.path}/sys-info.%d{yyyy-MM-dd}.log + + 60 + + + ${log.pattern} + + + + INFO + + ACCEPT + + DENY + + + + + ${log.path}/sys-error.log + + + + ${log.path}/sys-error.%d{yyyy-MM-dd}.log + + 60 + + + ${log.pattern} + + + + ERROR + + ACCEPT + + DENY + + + + + + ${log.path}/sys-user.log + + + ${log.path}/sys-user.%d{yyyy-MM-dd}.log + + 60 + + + ${log.pattern} + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/test/kotlin/com/app/ApplicationTests.kt b/src/test/kotlin/com/app/ApplicationTests.kt new file mode 100644 index 0000000..979c7ea --- /dev/null +++ b/src/test/kotlin/com/app/ApplicationTests.kt @@ -0,0 +1,13 @@ +package com.app + +import org.junit.jupiter.api.Test +import org.springframework.boot.test.context.SpringBootTest + +@SpringBootTest +class ApplicationTests { + + @Test + fun contextLoads() { + } + +}