docs(core): enhance KDoc documentation across controllers, services, and entities
Add comprehensive API documentation to controller classes with planned endpoint lists and path prefixes. Document security measures in auth service implementation. Add field-level comments to Book entity.
This commit is contained in:
@@ -4,7 +4,8 @@ import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||
import org.springframework.boot.runApplication
|
||||
|
||||
/*
|
||||
* Spring Boot 启动类
|
||||
* Spring Boot 应用入口
|
||||
* @SpringBootApplication 等价于 @Configuration + @EnableAutoConfiguration + @ComponentScan
|
||||
*/
|
||||
@SpringBootApplication
|
||||
class BookMgrApplication
|
||||
|
||||
@@ -4,11 +4,23 @@ import jakarta.servlet.http.HttpServletRequest
|
||||
import org.springframework.stereotype.Component
|
||||
|
||||
/*
|
||||
* 从请求头中提取真实客户端 IP(处理反向代理)
|
||||
* 从 HTTP 请求中提取客户端真实 IP
|
||||
* 按优先级依次检查代理头(X-Forwarded-For 等),取第一个非 unknown 的值
|
||||
* 若所有代理头均不可用,则回退到 request.remoteAddr
|
||||
*
|
||||
* 处理的代理头优先级:
|
||||
* 1. X-Forwarded-For — 最常用,nginx/HAProxy 等标准头
|
||||
* 2. Proxy-Client-IP — Apache 代理
|
||||
* 3. WL-Proxy-Client-IP — WebLogic 代理
|
||||
* 4. HTTP_X_FORWARDED_FOR — 非标准变体
|
||||
* 5. X-Real-IP — nginx 单跳真实 IP
|
||||
*
|
||||
* X-Forwarded-For 可能包含多个 IP(逗号分隔的代理链),只取第一个(最靠近客户端的 IP)
|
||||
*/
|
||||
@Component
|
||||
class IpExtractor {
|
||||
fun getRealIp(request: HttpServletRequest): String {
|
||||
// 按优先级排列的代理头列表
|
||||
val headers = listOf(
|
||||
"X-Forwarded-For",
|
||||
"Proxy-Client-IP",
|
||||
@@ -19,9 +31,9 @@ class IpExtractor {
|
||||
return headers
|
||||
.mapNotNull { request.getHeader(it) }
|
||||
.firstOrNull { it.isNotBlank() && !it.equals("unknown", ignoreCase = true) }
|
||||
?.split(",")
|
||||
?.first()
|
||||
?.split(",") // X-Forwarded-For 格式:client, proxy1, proxy2
|
||||
?.first() // 取第一个(最靠近客户端)
|
||||
?.trim()
|
||||
?: request.remoteAddr
|
||||
?: request.remoteAddr // 所有代理头都不可用时回退到直连 IP
|
||||
}
|
||||
}
|
||||
@@ -13,22 +13,33 @@ import java.util.*
|
||||
import javax.crypto.SecretKey
|
||||
|
||||
/*
|
||||
* JWT 令牌工具:签发、解析
|
||||
* 密钥来源:环境变量 JWT_SECRET,未配置时自动生成(重启失效)
|
||||
* JWT 令牌工具
|
||||
* 负责 JWT 的签发和解析,密钥从配置读取或自动生成
|
||||
*
|
||||
* 密钥来源:
|
||||
* - 优先使用环境变量 JWT_SECRET(通过 SHA-256 哈希成 256bit 固定长度密钥)
|
||||
* - 未配置时随机生成 64 字节密钥(重启后失效,所有已签发 token 将无法验证)
|
||||
*
|
||||
* 配置项(application.yaml 中 jwt 段):
|
||||
* jwt.secret — 密钥原文,推荐 32 字符以上
|
||||
* jwt.expiration — token 有效期,毫秒,默认 86400000(24 小时)
|
||||
*/
|
||||
@Component
|
||||
class JwtUtils(
|
||||
@Value("\${jwt.secret}") private val configuredSecret: String,
|
||||
@Value("\${jwt.expiration}") private val expiration: Long
|
||||
) {
|
||||
// HMAC-SHA 签名密钥,在 init 块中初始化
|
||||
private val secretKey: SecretKey
|
||||
|
||||
// 初始化密钥:有配置时用 SHA-256 哈希,无配置时随机生成
|
||||
// 初始化密钥:有配置时用 SHA-256 哈希,无配置时随机生成并打印警告
|
||||
init {
|
||||
val keyBytes = if (configuredSecret.isNotBlank()) {
|
||||
// 把用户提供的secret,用SHA-256哈希成256字节固定长度
|
||||
val md = MessageDigest.getInstance("SHA-256")
|
||||
md.digest(configuredSecret.toByteArray())
|
||||
} else {
|
||||
// 随机生成 64 字节密钥,仅本次启动有效
|
||||
val bytes = ByteArray(64)
|
||||
SecureRandom().nextBytes(bytes)
|
||||
val generated = Base64.getEncoder().encodeToString(bytes)
|
||||
@@ -39,7 +50,17 @@ class JwtUtils(
|
||||
secretKey = Keys.hmacShaKeyFor(keyBytes)
|
||||
}
|
||||
|
||||
// 签发 token,payload 包含 role
|
||||
/*
|
||||
* 签发 JWT token
|
||||
* @param username 用户名,写入 sub(subject)字段
|
||||
* @param role 角色,写入自定义 claims 的 role 字段
|
||||
* @return 签发的 JWT 字符串
|
||||
*
|
||||
* token 结构(由 jjwt 库生成):
|
||||
* Header: { "alg": "HS256" }
|
||||
* Payload: { "sub": "admin", "role": "admin", "iat": ..., "exp": ... }
|
||||
* Signature: HMAC-SHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secretKey)
|
||||
*/
|
||||
fun generateToken(username: String, role: String): String {
|
||||
val claims = Jwts.claims().add("role", role).build()
|
||||
val token = Jwts.builder().claims(claims).subject(username)
|
||||
@@ -52,14 +73,27 @@ class JwtUtils(
|
||||
return token
|
||||
}
|
||||
|
||||
// 解析 token,校验失败时记日志并返回 null
|
||||
/*
|
||||
* 解析并验证 JWT token
|
||||
* @param token JWT 字符串
|
||||
* @return 解析成功返回 Claims(包含 subject 和 role),失败返回 null
|
||||
*
|
||||
* 失败场景:
|
||||
* - token 过期(ExpiredJwtException)
|
||||
* - 签名不匹配(SignatureException)
|
||||
* - token 格式错误(MalformedJwtException)
|
||||
* 以上均由 JwtException 统一捕获
|
||||
* - token 为空白字符串 / 参数异常 → IllegalArgumentException
|
||||
*/
|
||||
fun parseToken(token: String): Claims? {
|
||||
try {
|
||||
val claims = Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token)
|
||||
return claims.payload
|
||||
} catch (e: JwtException) {
|
||||
// 包含 ExpiredJwtException / SignatureException / MalformedJwtException 等所有 jjwt 异常
|
||||
log.error("[JWT] parse failed: {}", e.message)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
// token 为空或格式异常
|
||||
log.error("[JWT] parse failed: illegal argument")
|
||||
}
|
||||
return null
|
||||
|
||||
@@ -8,43 +8,66 @@ import org.aspectj.lang.annotation.Pointcut
|
||||
import org.springframework.stereotype.Component
|
||||
|
||||
/*
|
||||
* AOP 日志切面:自动记录方法入口/出口/耗时
|
||||
* - controller/service/runner/jwt:DEBUG 级别,超 500ms 时 WARN
|
||||
* - mapper:TRACE 级别,日常不输出
|
||||
* AOP 日志切面
|
||||
* 通过 @Around 环绕通知,自动为每个方法记录入口参数、出口结果、执行耗时
|
||||
*
|
||||
* 日志级别:
|
||||
* - controller / service / runner / JwtUtils → DEBUG 级别,超 500ms 时 WARN
|
||||
* - mapper(MyBatis 数据库操作) → TRACE 级别,日常不输出
|
||||
*
|
||||
* 输出格式:
|
||||
* => ClassName.methodName(args) — 方法入口
|
||||
* <= ClassName.methodName = result (耗时 ms) — 方法出口
|
||||
* ClassName.methodName took XXX ms — 慢方法警告(耗时 > 500ms)
|
||||
*
|
||||
* 注意:Spring AOP 基于代理,类内部方法间的 self-invocation 不会触发切面
|
||||
*/
|
||||
@Aspect
|
||||
@Component
|
||||
class LoggingAspect {
|
||||
|
||||
// 匹配 controller 包及子包下所有类的所有方法
|
||||
@Pointcut("within(com.msksbr.bookmgr.controller..*)")
|
||||
fun controller() {}
|
||||
|
||||
// 匹配 service 包及子包下所有类的所有方法
|
||||
@Pointcut("within(com.msksbr.bookmgr.service..*)")
|
||||
fun service() {}
|
||||
|
||||
// 匹配 mapper 包及子包下所有类的所有方法(数据库操作)
|
||||
@Pointcut("within(com.msksbr.bookmgr.mapper..*)")
|
||||
fun mapper() {}
|
||||
|
||||
// 匹配 runner 包及子包下所有类的所有方法(启动任务)
|
||||
@Pointcut("within(com.msksbr.bookmgr.runner..*)")
|
||||
fun runner() {}
|
||||
|
||||
// 仅匹配 JwtUtils 类(不包含同包其他类)
|
||||
@Pointcut("within(com.msksbr.bookmgr.config.JwtUtils)")
|
||||
fun jwt() {}
|
||||
|
||||
/*
|
||||
* 业务层日志环绕通知
|
||||
* 覆盖范围:controller、service、runner、JwtUtils
|
||||
* 日志级别:DEBUG,慢方法(>500ms)升到 WARN
|
||||
*/
|
||||
@Around("controller() || service() || runner() || jwt()")
|
||||
fun logMethod(joinPoint: ProceedingJoinPoint): Any? {
|
||||
val className = joinPoint.target::class.simpleName
|
||||
val methodName = joinPoint.signature.name
|
||||
|
||||
// 入口日志
|
||||
log.debug("=> {}.{}({})", className, methodName, joinPoint.args.joinToString())
|
||||
|
||||
val start = System.currentTimeMillis()
|
||||
try {
|
||||
val result = joinPoint.proceed()
|
||||
val elapsed = System.currentTimeMillis() - start
|
||||
// 耗时超过 500 毫秒时以 WARN 级别告警
|
||||
if (elapsed > 500) {
|
||||
log.warn("{}.{} took {} ms", className, methodName, elapsed)
|
||||
}
|
||||
// 出口日志
|
||||
log.debug("<= {}.{} = {} ({} ms)", className, methodName, result, elapsed)
|
||||
return result
|
||||
} catch (e: Throwable) {
|
||||
@@ -54,6 +77,11 @@ class LoggingAspect {
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* 数据层日志环绕通知
|
||||
* 覆盖范围:mapper(MyBatis-Plus BaseMapper 的所有数据库操作)
|
||||
* 日志级别:TRACE(仅在开发调试时开启,生成环境关闭)
|
||||
*/
|
||||
@Around("mapper()")
|
||||
fun logMapper(joinPoint: ProceedingJoinPoint): Any? {
|
||||
val className = joinPoint.target::class.simpleName
|
||||
|
||||
@@ -6,11 +6,18 @@ import org.springframework.security.crypto.argon2.Argon2PasswordEncoder
|
||||
import org.springframework.security.crypto.password.PasswordEncoder
|
||||
|
||||
/*
|
||||
* Argon2 密码编码器配置
|
||||
* 密码编码器配置
|
||||
* 将 Argon2 注册为 Spring Bean,供整个应用的密码加密和验证使用
|
||||
*
|
||||
* Argon2 是 2015 年密码哈希竞赛的胜出算法,相对 bcrypt/scrypt 提供了更好的抗 GPU/ASIC 暴力破解能力
|
||||
*/
|
||||
// 将Argon2的加盐哈希方法注册成Bean
|
||||
@Configuration
|
||||
class PasswordConfig {
|
||||
/*
|
||||
* 返回 Argon2PasswordEncoder 实例(Spring Security 5.8 默认参数)
|
||||
* 参数:salt 长度 16 字节、hash 长度 32 字节、parallelism=1、memory=1<<12(4MB)、iterations=3
|
||||
*/
|
||||
// 将 Argon2 的加盐哈希方法注册成 Bean
|
||||
@Bean
|
||||
fun passwordEncoder(): PasswordEncoder {
|
||||
return Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8()
|
||||
|
||||
@@ -5,14 +5,25 @@ import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
|
||||
|
||||
/*
|
||||
* Spring MVC 配置 — 注册 JWT 鉴权拦截器
|
||||
*
|
||||
* 拦截规则:
|
||||
* /api/ — 所有 /api/ 下的请求都需要校验 JWT
|
||||
* /api/auth/login — 排除,登录不需要 token(此时还没有 token)
|
||||
* /api/auth/logout — 排除,登出不校验 token(客户端自行删除即可)
|
||||
*/
|
||||
@Configuration
|
||||
class WebConfig(
|
||||
private val jwtAuthInterceptor: JwtAuthInterceptor
|
||||
) : WebMvcConfigurer {
|
||||
|
||||
override fun addInterceptors(registry: InterceptorRegistry) {
|
||||
registry.addInterceptor(jwtAuthInterceptor)
|
||||
.addPathPatterns("/api/**")
|
||||
.excludePathPatterns("/api/auth/login")
|
||||
.excludePathPatterns("/api/auth/logout")
|
||||
.excludePathPatterns(
|
||||
"/api/auth/login",
|
||||
"/api/auth/logout"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,14 @@ package com.msksbr.bookmgr.controller
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
/*
|
||||
* 管理端图书接口
|
||||
* 图书管理接口(面向管理员)
|
||||
* 路径前缀(待定):/api/admin/books
|
||||
*
|
||||
* 计划接口:
|
||||
* - 新增图书
|
||||
* - 修改图书信息
|
||||
* - 删除图书
|
||||
* - 调整库存
|
||||
*/
|
||||
@RestController
|
||||
class AdminBookController {
|
||||
|
||||
@@ -3,7 +3,13 @@ package com.msksbr.bookmgr.controller
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
/*
|
||||
* 管理端借阅接口
|
||||
* 借阅管理接口(面向管理员)
|
||||
* 路径前缀(待定):/api/admin/borrows
|
||||
*
|
||||
* 计划接口:
|
||||
* - 全量借阅记录查询
|
||||
* - 催还 / 标记逾期
|
||||
* - 手动归还
|
||||
*/
|
||||
@RestController
|
||||
class AdminBorrowController {
|
||||
|
||||
@@ -14,7 +14,10 @@ import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
/*
|
||||
* 认证接口:登录 / 登出
|
||||
* 认证接口
|
||||
* 提供登录(签发 JWT)和登出功能,无需鉴权即可访问
|
||||
*
|
||||
* 路径前缀:/api/auth
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/auth")
|
||||
@@ -23,6 +26,19 @@ class AuthController(
|
||||
val ipExtractor: IpExtractor,
|
||||
private val jwtUtils: JwtUtils
|
||||
) {
|
||||
/*
|
||||
* POST /api/auth/login
|
||||
* 验证用户名密码,成功后签发 JWT 令牌并返回用户信息
|
||||
*
|
||||
* 请求体 JSON:
|
||||
* { "username": "admin", "password": "admin" }
|
||||
*
|
||||
* 成功响应(code=200, message="success"):
|
||||
* { "code": 200, "message": "success", "data": { "token": "...", "username": "admin", "role": "admin" } }
|
||||
*
|
||||
* 失败响应(code=500, message="Incorrect username or password"):
|
||||
* { "code": 500, "message": "Incorrect username or password", "data": null }
|
||||
*/
|
||||
@PostMapping("/login")
|
||||
fun login(
|
||||
@Valid
|
||||
@@ -50,6 +66,13 @@ class AuthController(
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* POST /api/auth/logout
|
||||
* 退出登录。由于 JWT 无状态,服务端不做额外处理,客户端自行删除 token 即可
|
||||
*
|
||||
* 成功响应(code=200, message="success"):
|
||||
* { "code": 200, "message": "success", "data": "logout successfully" }
|
||||
*/
|
||||
@PostMapping("/logout")
|
||||
fun logout(
|
||||
request: HttpServletRequest
|
||||
|
||||
@@ -3,7 +3,12 @@ package com.msksbr.bookmgr.controller
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
/*
|
||||
* 图书接口
|
||||
* 图书接口(面向普通用户)
|
||||
* 路径前缀(待定):/api/books
|
||||
*
|
||||
* 计划接口:
|
||||
* - 图书列表查询(支持分页、搜索)
|
||||
* - 单本图书详情
|
||||
*/
|
||||
@RestController
|
||||
class BookController {
|
||||
|
||||
@@ -3,7 +3,13 @@ package com.msksbr.bookmgr.controller
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
/*
|
||||
* 借阅接口
|
||||
* 借阅接口(面向普通用户)
|
||||
* 路径前缀(待定):/api/borrows
|
||||
*
|
||||
* 计划接口:
|
||||
* - 我的借阅记录列表
|
||||
* - 借书(提交借阅请求)
|
||||
* - 还书
|
||||
*/
|
||||
@RestController
|
||||
class BorrowController {
|
||||
|
||||
@@ -4,6 +4,10 @@ import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
/*
|
||||
* 仪表盘接口
|
||||
* 路径前缀(待定):/api/dashboard
|
||||
*
|
||||
* 计划接口:
|
||||
* - 图书总数、借出数量、逾期数量等统计数据
|
||||
*/
|
||||
@RestController
|
||||
class DashBoardController {
|
||||
|
||||
@@ -3,7 +3,13 @@ package com.msksbr.bookmgr.dto
|
||||
import jakarta.validation.constraints.NotBlank
|
||||
|
||||
/*
|
||||
* 登录请求体
|
||||
* 登录请求体 DTO
|
||||
*
|
||||
* 字段:
|
||||
* username - 用户名,不能为空(由 @NotBlank 校验,失败时返回 "username is required")
|
||||
* password - 密码,不能为空(由 @NotBlank 校验,失败时返回 "password is required")
|
||||
*
|
||||
* 字段类型为可空 String?,但运行时被 @Valid + @NotBlank 保证非空
|
||||
*/
|
||||
data class UserLoginDTO(
|
||||
@NotBlank(message = "username is required")
|
||||
|
||||
@@ -5,7 +5,13 @@ import com.baomidou.mybatisplus.annotation.TableId
|
||||
import com.baomidou.mybatisplus.annotation.TableName
|
||||
|
||||
/*
|
||||
* 图书实体,映射 book 表
|
||||
* 图书实体,映射数据库 book 表
|
||||
*
|
||||
* 字段:
|
||||
* id - 自增主键,插入时为 null 由数据库自动生成
|
||||
* name - 书名
|
||||
* author - 作者
|
||||
* stock - 库存余量(可借出数量)
|
||||
*/
|
||||
@TableName("book")
|
||||
data class Book(
|
||||
|
||||
@@ -6,7 +6,15 @@ import com.baomidou.mybatisplus.annotation.TableName
|
||||
import java.util.Date
|
||||
|
||||
/*
|
||||
* 借阅记录实体,映射 book_record 表
|
||||
* 借阅记录实体,映射数据库 book_record 表
|
||||
*
|
||||
* 字段:
|
||||
* id - 自增主键,插入时为 null 由数据库自动生成
|
||||
* userId - 借阅者用户 ID,关联 user 表
|
||||
* bookId - 被借图书 ID,关联 book 表
|
||||
* borrowTime - 借出时间
|
||||
* returnTime - 归还时间,null 表示尚未归还
|
||||
* status - 记录状态:borrowed(借出中)/ returned(已归还)/ overdue(逾期)
|
||||
*/
|
||||
@TableName("book_record")
|
||||
data class BorrowRecord(
|
||||
|
||||
@@ -5,7 +5,13 @@ import com.baomidou.mybatisplus.annotation.TableId
|
||||
import com.baomidou.mybatisplus.annotation.TableName
|
||||
|
||||
/*
|
||||
* 用户实体,映射 user 表
|
||||
* 用户实体,映射数据库 user 表
|
||||
*
|
||||
* 字段:
|
||||
* id - 自增主键,插入时为 null 由数据库自动生成
|
||||
* username - 用户名(唯一,用于登录)
|
||||
* password - 密码(Argon2 哈希值,由 PasswordConfig 指定的编码器处理)
|
||||
* role - 角色:admin(管理员)或 user(普通用户)
|
||||
*/
|
||||
@TableName("user")
|
||||
data class User(
|
||||
|
||||
@@ -1,50 +1,66 @@
|
||||
package com.msksbr.bookmgr.interceptor
|
||||
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.msksbr.bookmgr.config.JwtUtils
|
||||
import com.msksbr.bookmgr.script.log
|
||||
import com.msksbr.bookmgr.template.Result
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import jakarta.servlet.http.HttpServletResponse
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.web.servlet.HandlerInterceptor
|
||||
import tools.jackson.databind.ObjectMapper
|
||||
|
||||
/*
|
||||
* JWT 鉴权拦截器
|
||||
* 在每个受保护的 API 请求到达 Controller 之前执行,从 Authorization 头提取并校验 JWT
|
||||
*
|
||||
* 校验失败时直接返回 401 JSON 响应(使用 Result.unauthorized),请求不会到达 Controller
|
||||
* 校验成功后从 token 中提取 username 和 role,写入 request attribute,后续可通过 @RequestAttribute 获取
|
||||
*
|
||||
* 返回体格式(与 Result.unauthorized 一致):
|
||||
* {"code":401,"message":"Missing Authorization header"}
|
||||
* {"code":401,"message":"Invalid token format"}
|
||||
* {"code":401,"message":"Token invalid or expired"}
|
||||
*/
|
||||
@Component
|
||||
class JwtAuthInterceptor(
|
||||
private val objectMapper: ObjectMapper,
|
||||
private val jwtUtils: JwtUtils
|
||||
) : HandlerInterceptor {
|
||||
override fun preHandle(request: HttpServletRequest, response: HttpServletResponse, handler: Any): Boolean {
|
||||
|
||||
override fun preHandle(
|
||||
request: HttpServletRequest,
|
||||
response: HttpServletResponse,
|
||||
handler: Any
|
||||
): Boolean {
|
||||
// 1. 检查 Authorization 头是否存在
|
||||
val authHeader = request.getHeader("Authorization") ?: run {
|
||||
writeJson(
|
||||
response, HttpServletResponse.SC_UNAUTHORIZED,
|
||||
Result.unauthorized("Missing Authorization header")
|
||||
)
|
||||
writeJson(response, Result.unauthorized("Missing Authorization header"))
|
||||
return false
|
||||
}
|
||||
// 2. 检查前缀是否为 "Bearer "
|
||||
if (!authHeader.startsWith("Bearer ")) {
|
||||
writeJson(
|
||||
response, HttpServletResponse.SC_FORBIDDEN,
|
||||
Result.unauthorized("Invalid token format")
|
||||
)
|
||||
writeJson(response, Result.unauthorized("Invalid token format"))
|
||||
return false
|
||||
}
|
||||
// 3. 解析并验证 token
|
||||
val token = authHeader.removePrefix("Bearer ")
|
||||
val claims = jwtUtils.parseToken(token)
|
||||
if (claims == null) {
|
||||
writeJson(
|
||||
response, HttpServletResponse.SC_UNAUTHORIZED,
|
||||
Result.unauthorized("Token invalid or expired")
|
||||
)
|
||||
writeJson(response, Result.unauthorized("Token invalid or expired"))
|
||||
return false
|
||||
}
|
||||
// 4. 校验通过,用户信息写入 request attribute
|
||||
request.setAttribute("username", claims.subject)
|
||||
request.setAttribute("role", claims.get("role", String::class.java))
|
||||
log.debug("[JWT] authenticated: user={}, role={}", claims.subject, claims.get("role", String::class.java))
|
||||
return true
|
||||
}
|
||||
|
||||
private fun writeJson(response: HttpServletResponse, status: Int, result: Result<*>) {
|
||||
response.status = status
|
||||
/*
|
||||
* 写入 401 响应,使用 ObjectMapper 序列化保证与 Controller 一致的 JSON 格式(snake_case / non_null 等)
|
||||
*/
|
||||
private fun writeJson(response: HttpServletResponse, result: Result<*>) {
|
||||
response.status = HttpServletResponse.SC_UNAUTHORIZED
|
||||
response.contentType = "application/json;charset=UTF-8"
|
||||
objectMapper.writeValue(response.writer, result)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,17 @@ import com.msksbr.bookmgr.entity.Book
|
||||
import org.apache.ibatis.annotations.Mapper
|
||||
|
||||
/*
|
||||
* 图书 Mapper,继承 MyBatis-Plus BaseMapper 获得通用 CRUD
|
||||
* 图书数据访问层,继承 MyBatis-Plus BaseMapper<Book> 获得通用 CRUD 方法
|
||||
*
|
||||
* 内置方法(无需手写 SQL):
|
||||
* - insert(book) — 插入单条记录
|
||||
* - deleteById(id) — 根据主键删除
|
||||
* - updateById(book) — 根据主键更新
|
||||
* - selectById(id) — 根据主键查询
|
||||
* - selectList(queryWrapper) — 条件查询列表
|
||||
* - selectPage(page, wrapper) — 分页查询
|
||||
*
|
||||
* 复杂查询可通过接口方法 + @Select 注解或 XML 映射文件扩展
|
||||
*/
|
||||
@Mapper
|
||||
interface BookMapper : BaseMapper<Book>
|
||||
@@ -5,7 +5,15 @@ import com.msksbr.bookmgr.entity.BorrowRecord
|
||||
import org.apache.ibatis.annotations.Mapper
|
||||
|
||||
/*
|
||||
* 借阅记录 Mapper,继承 MyBatis-Plus BaseMapper 获得通用 CRUD
|
||||
* 借阅记录数据访问层,继承 MyBatis-Plus BaseMapper<BorrowRecord> 获得通用 CRUD 方法
|
||||
*
|
||||
* 内置方法:
|
||||
* - insert(record) — 新建借阅记录
|
||||
* - deleteById(id) — 根据主键删除
|
||||
* - updateById(record) — 根据主键更新(如更新 status / returnTime)
|
||||
* - selectById(id) — 根据主键查询
|
||||
* - selectList(queryWrapper) — 条件查询列表(可按 userId、bookId、status 筛选)
|
||||
* - selectPage(page, wrapper) — 分页查询
|
||||
*/
|
||||
@Mapper
|
||||
interface BorrowRecordMapper : BaseMapper<BorrowRecord>
|
||||
@@ -5,7 +5,15 @@ import com.msksbr.bookmgr.entity.User
|
||||
import org.apache.ibatis.annotations.Mapper
|
||||
|
||||
/*
|
||||
* 用户 Mapper,继承 MyBatis-Plus BaseMapper 获得通用 CRUD
|
||||
* 用户数据访问层,继承 MyBatis-Plus BaseMapper<User> 获得通用 CRUD 方法
|
||||
*
|
||||
* 内置方法:
|
||||
* - insert(user) — 插入单条记录
|
||||
* - deleteById(id) — 根据主键删除
|
||||
* - updateById(user) — 根据主键更新
|
||||
* - selectById(id) — 根据主键查询
|
||||
* - selectOne(queryWrapper) — 按条件查询单条(常用于 username 唯一查询)
|
||||
* - selectList(queryWrapper) — 条件查询列表
|
||||
*/
|
||||
@Mapper
|
||||
interface UserMapper : BaseMapper<User>
|
||||
@@ -11,17 +11,30 @@ import org.springframework.stereotype.Component
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
/*
|
||||
* 应用启动时初始化默认用户(admin / user01 / user02)
|
||||
* 已存在的用户会跳过,因此可安全重复执行
|
||||
* 启动初始化任务 — 保证默认用户存在
|
||||
* 在应用启动时自动执行(通过 ApplicationRunner 接口)
|
||||
*
|
||||
* 创建的默认用户:
|
||||
* admin — 密码 admin,角色 admin(管理员)
|
||||
* user01 — 密码 user01,角色 user(普通用户)
|
||||
* user02 — 密码 user02,角色 user(普通用户)
|
||||
*
|
||||
* 幂等性:启动前会先查数据库判断用户是否已存在,已存在则跳过
|
||||
* 事务保护:整个 run 方法在 @Transactional 中执行,任意插入失败会整体回滚
|
||||
*/
|
||||
@Component
|
||||
class InitUserRunner(
|
||||
val passwordEncoder: PasswordEncoder,
|
||||
val userMapper: UserMapper,
|
||||
) : ApplicationRunner {
|
||||
/*
|
||||
* ApplicationRunner 入口,Spring Boot 启动完成后自动调用
|
||||
* @param args 命令行参数,此处不使用
|
||||
*/
|
||||
@Transactional
|
||||
override fun run(args: ApplicationArguments) {
|
||||
log.info("[InitUser] starting")
|
||||
// 检查并创建 admin
|
||||
val existsAdmin = userMapper.selectOne(
|
||||
QueryWrapper<User>()
|
||||
.eq("username", "admin")
|
||||
@@ -30,6 +43,7 @@ class InitUserRunner(
|
||||
log.info("[InitUser] admin not found, creating")
|
||||
insertAdmin()
|
||||
}
|
||||
// 检查并创建 user01
|
||||
val existsUser01 = userMapper.selectOne(
|
||||
QueryWrapper<User>()
|
||||
.eq("username", "user01")
|
||||
@@ -38,6 +52,7 @@ class InitUserRunner(
|
||||
log.info("[InitUser] user01 not found, creating")
|
||||
insertUser01()
|
||||
}
|
||||
// 检查并创建 user02
|
||||
val existsUser02 = userMapper.selectOne(
|
||||
QueryWrapper<User>()
|
||||
.eq("username", "user02")
|
||||
@@ -49,6 +64,10 @@ class InitUserRunner(
|
||||
log.info("[InitUser] completed")
|
||||
}
|
||||
|
||||
/*
|
||||
* 插入管理员用户(admin / admin / admin)
|
||||
* 密码通过 Argon2 编码后存入数据库
|
||||
*/
|
||||
fun insertAdmin() {
|
||||
val user = User(
|
||||
id = null,
|
||||
@@ -59,6 +78,9 @@ class InitUserRunner(
|
||||
userMapper.insert(user)
|
||||
}
|
||||
|
||||
/*
|
||||
* 插入普通用户 user01(user01 / user01 / user)
|
||||
*/
|
||||
fun insertUser01() {
|
||||
val user = User(
|
||||
id = null,
|
||||
@@ -69,6 +91,9 @@ class InitUserRunner(
|
||||
userMapper.insert(user)
|
||||
}
|
||||
|
||||
/*
|
||||
* 插入普通用户 user02(user02 / user02 / user)
|
||||
*/
|
||||
fun insertUser02() {
|
||||
val user = User(
|
||||
id = null,
|
||||
|
||||
@@ -4,8 +4,15 @@ import org.slf4j.Logger
|
||||
import org.slf4j.LoggerFactory
|
||||
|
||||
/*
|
||||
* SLF4J 日志扩展属性,通过 T.log 在任意类获取 Logger
|
||||
* SLF4J 日志扩展属性
|
||||
* 用法:任意类中直接使用 log.info("message"),Logger 名称自动取自当前类的全限定名
|
||||
*
|
||||
* 示例:
|
||||
* class MyService {
|
||||
* fun doSomething() {
|
||||
* log.debug("doing something") // Logger 名为 com.xxx.MyService
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
|
||||
val <T : Any> T.log: Logger
|
||||
get() = LoggerFactory.getLogger(this::class.java)
|
||||
@@ -4,8 +4,14 @@ import com.msksbr.bookmgr.dto.UserLoginDTO
|
||||
import com.msksbr.bookmgr.entity.User
|
||||
|
||||
/*
|
||||
* 认证服务
|
||||
* 认证服务接口
|
||||
* 定义用户登录逻辑的契约
|
||||
*/
|
||||
interface AuthService {
|
||||
/*
|
||||
* 验证登录凭据
|
||||
* @param loginDTO 包含 username 和 password 的请求体
|
||||
* @return 验证成功返回对应的 User 实体,失败返回 null
|
||||
*/
|
||||
fun login(loginDTO: UserLoginDTO): User?
|
||||
}
|
||||
@@ -9,10 +9,26 @@ import org.springframework.security.crypto.password.PasswordEncoder
|
||||
import org.springframework.stereotype.Service
|
||||
|
||||
/*
|
||||
* 认证服务实现,含时序攻击防护
|
||||
* 认证服务实现
|
||||
*
|
||||
* 安全措施:
|
||||
* - 密码哈希使用 Argon2(PasswordConfig 注册的编码器),即使数据库泄露也无法直接还原密码
|
||||
* - 用户不存在时仍执行一次 hash 操作(dummyHash),对齐响应时间,防止基于响应延时的用户枚举攻击
|
||||
*/
|
||||
@Service
|
||||
class AuthServiceImpl(private val userMapper: UserMapper, private val passwordEncoder: PasswordEncoder) : AuthService {
|
||||
class AuthServiceImpl(
|
||||
private val userMapper: UserMapper,
|
||||
private val passwordEncoder: PasswordEncoder
|
||||
) : AuthService {
|
||||
/*
|
||||
* 验证登录凭据
|
||||
* 1. 按 username 查询用户
|
||||
* 2. 若不存在:跑一次 Argon2 hash 消耗时间以对齐响应,然后返回 null
|
||||
* 3. 若存在:用 Argon2.matches() 比对密码,匹配则返回 User,否则返回 null
|
||||
*
|
||||
* @param loginDTO 包含 username 和 password
|
||||
* @return 验证成功返回 User,失败返回 null
|
||||
*/
|
||||
override fun login(loginDTO: UserLoginDTO): User? {
|
||||
val user = userMapper.selectOne(
|
||||
QueryWrapper<User>()
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
package com.msksbr.bookmgr.template
|
||||
|
||||
/*
|
||||
* 统一 API 响应格式
|
||||
* 统一 API 响应格式,所有 Controller 接口的返回值均使用此类包装
|
||||
*
|
||||
* JSON 序列化行为(由 application.yaml 中 jackson 配置控制):
|
||||
* - 属性名自动转为 snake_case(如 errorMessage → error_message)
|
||||
* - data 为 null 时不输出 data 字段(default-property-inclusion: non_null)
|
||||
* - 日期按 application.yaml 格式序列化
|
||||
*
|
||||
* 字段:
|
||||
* code - HTTP 状态码语义:200 = 成功,401 = 未登录,500 = 服务器错误
|
||||
* message - 人类可读的提示信息,前端可直接展示
|
||||
* data - 响应数据体,可为 null
|
||||
*/
|
||||
data class Result<T>(
|
||||
var code: Int,
|
||||
@@ -9,7 +19,12 @@ data class Result<T>(
|
||||
var data: T?
|
||||
) {
|
||||
companion object {
|
||||
// 成功
|
||||
/*
|
||||
* 成功响应 — 返回 200 + "success"
|
||||
* @param data 业务数据,类型由调用方推断
|
||||
*
|
||||
* JSON 输出示例:{ "code": 200, "message": "success", "data": {...} }
|
||||
*/
|
||||
fun <T> success(data: T): Result<T> {
|
||||
return Result(
|
||||
code = 200,
|
||||
@@ -17,7 +32,13 @@ data class Result<T>(
|
||||
data = data
|
||||
)
|
||||
}
|
||||
// 失败
|
||||
|
||||
/*
|
||||
* 失败响应 — 返回 500 + 自定义错误消息
|
||||
* @param message 错误描述,如 "Incorrect username or password"
|
||||
*
|
||||
* JSON 输出示例:{ "code": 500, "message": "xxx", "data": null }
|
||||
*/
|
||||
fun error(message: String): Result<Any?> {
|
||||
return Result(
|
||||
code = 500,
|
||||
@@ -25,7 +46,13 @@ data class Result<T>(
|
||||
data = null
|
||||
)
|
||||
}
|
||||
// 未登录
|
||||
|
||||
/*
|
||||
* 未登录响应 — 返回 401 + 自定义提示
|
||||
* @param message 提示信息,如 "Missing Authorization header"
|
||||
*
|
||||
* JSON 输出示例:{ "code": 401, "message": "xxx" }
|
||||
*/
|
||||
fun unauthorized(message: String): Result<Any?> {
|
||||
return Result(
|
||||
code = 401,
|
||||
|
||||
Reference in New Issue
Block a user