diff --git a/src/main/kotlin/com/msksbr/bookmgr/BookMgrApplication.kt b/src/main/kotlin/com/msksbr/bookmgr/BookMgrApplication.kt index 63f2373..c101b93 100644 --- a/src/main/kotlin/com/msksbr/bookmgr/BookMgrApplication.kt +++ b/src/main/kotlin/com/msksbr/bookmgr/BookMgrApplication.kt @@ -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 diff --git a/src/main/kotlin/com/msksbr/bookmgr/config/IpExtractor.kt b/src/main/kotlin/com/msksbr/bookmgr/config/IpExtractor.kt index d912585..12c00ca 100644 --- a/src/main/kotlin/com/msksbr/bookmgr/config/IpExtractor.kt +++ b/src/main/kotlin/com/msksbr/bookmgr/config/IpExtractor.kt @@ -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 } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/msksbr/bookmgr/config/JwtUtils.kt b/src/main/kotlin/com/msksbr/bookmgr/config/JwtUtils.kt index 677b23b..5d9badc 100644 --- a/src/main/kotlin/com/msksbr/bookmgr/config/JwtUtils.kt +++ b/src/main/kotlin/com/msksbr/bookmgr/config/JwtUtils.kt @@ -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 diff --git a/src/main/kotlin/com/msksbr/bookmgr/config/LoggingAspect.kt b/src/main/kotlin/com/msksbr/bookmgr/config/LoggingAspect.kt index c223132..d10f40e 100644 --- a/src/main/kotlin/com/msksbr/bookmgr/config/LoggingAspect.kt +++ b/src/main/kotlin/com/msksbr/bookmgr/config/LoggingAspect.kt @@ -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 diff --git a/src/main/kotlin/com/msksbr/bookmgr/config/PasswordConfig.kt b/src/main/kotlin/com/msksbr/bookmgr/config/PasswordConfig.kt index a5ccece..fe65bdd 100644 --- a/src/main/kotlin/com/msksbr/bookmgr/config/PasswordConfig.kt +++ b/src/main/kotlin/com/msksbr/bookmgr/config/PasswordConfig.kt @@ -6,13 +6,20 @@ 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() } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/msksbr/bookmgr/config/WebConfig.kt b/src/main/kotlin/com/msksbr/bookmgr/config/WebConfig.kt index ae51112..37fab3f 100644 --- a/src/main/kotlin/com/msksbr/bookmgr/config/WebConfig.kt +++ b/src/main/kotlin/com/msksbr/bookmgr/config/WebConfig.kt @@ -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 { +) : 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" + ) } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/msksbr/bookmgr/controller/AdminBookController.kt b/src/main/kotlin/com/msksbr/bookmgr/controller/AdminBookController.kt index f7c07ad..f8bce3c 100644 --- a/src/main/kotlin/com/msksbr/bookmgr/controller/AdminBookController.kt +++ b/src/main/kotlin/com/msksbr/bookmgr/controller/AdminBookController.kt @@ -3,8 +3,15 @@ package com.msksbr.bookmgr.controller import org.springframework.web.bind.annotation.RestController /* -* 管理端图书接口 +* 图书管理接口(面向管理员) +* 路径前缀(待定):/api/admin/books +* +* 计划接口: +* - 新增图书 +* - 修改图书信息 +* - 删除图书 +* - 调整库存 */ @RestController class AdminBookController { -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/msksbr/bookmgr/controller/AdminBorrowController.kt b/src/main/kotlin/com/msksbr/bookmgr/controller/AdminBorrowController.kt index ceada83..5139936 100644 --- a/src/main/kotlin/com/msksbr/bookmgr/controller/AdminBorrowController.kt +++ b/src/main/kotlin/com/msksbr/bookmgr/controller/AdminBorrowController.kt @@ -3,8 +3,14 @@ package com.msksbr.bookmgr.controller import org.springframework.web.bind.annotation.RestController /* -* 管理端借阅接口 +* 借阅管理接口(面向管理员) +* 路径前缀(待定):/api/admin/borrows +* +* 计划接口: +* - 全量借阅记录查询 +* - 催还 / 标记逾期 +* - 手动归还 */ @RestController class AdminBorrowController { -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/msksbr/bookmgr/controller/AuthController.kt b/src/main/kotlin/com/msksbr/bookmgr/controller/AuthController.kt index 98a49d6..964dae7 100644 --- a/src/main/kotlin/com/msksbr/bookmgr/controller/AuthController.kt +++ b/src/main/kotlin/com/msksbr/bookmgr/controller/AuthController.kt @@ -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 diff --git a/src/main/kotlin/com/msksbr/bookmgr/controller/BookController.kt b/src/main/kotlin/com/msksbr/bookmgr/controller/BookController.kt index 42fb6c5..357fd27 100644 --- a/src/main/kotlin/com/msksbr/bookmgr/controller/BookController.kt +++ b/src/main/kotlin/com/msksbr/bookmgr/controller/BookController.kt @@ -3,8 +3,13 @@ package com.msksbr.bookmgr.controller import org.springframework.web.bind.annotation.RestController /* -* 图书接口 +* 图书接口(面向普通用户) +* 路径前缀(待定):/api/books +* +* 计划接口: +* - 图书列表查询(支持分页、搜索) +* - 单本图书详情 */ @RestController class BookController { -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/msksbr/bookmgr/controller/BorrowController.kt b/src/main/kotlin/com/msksbr/bookmgr/controller/BorrowController.kt index f706e54..2723fe2 100644 --- a/src/main/kotlin/com/msksbr/bookmgr/controller/BorrowController.kt +++ b/src/main/kotlin/com/msksbr/bookmgr/controller/BorrowController.kt @@ -3,8 +3,14 @@ package com.msksbr.bookmgr.controller import org.springframework.web.bind.annotation.RestController /* -* 借阅接口 +* 借阅接口(面向普通用户) +* 路径前缀(待定):/api/borrows +* +* 计划接口: +* - 我的借阅记录列表 +* - 借书(提交借阅请求) +* - 还书 */ @RestController class BorrowController { -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/msksbr/bookmgr/controller/DashBoardController.kt b/src/main/kotlin/com/msksbr/bookmgr/controller/DashBoardController.kt index bcdf8af..9e8039a 100644 --- a/src/main/kotlin/com/msksbr/bookmgr/controller/DashBoardController.kt +++ b/src/main/kotlin/com/msksbr/bookmgr/controller/DashBoardController.kt @@ -4,7 +4,11 @@ import org.springframework.web.bind.annotation.RestController /* * 仪表盘接口 +* 路径前缀(待定):/api/dashboard +* +* 计划接口: +* - 图书总数、借出数量、逾期数量等统计数据 */ @RestController class DashBoardController { -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/msksbr/bookmgr/dto/UserLoginDTO.kt b/src/main/kotlin/com/msksbr/bookmgr/dto/UserLoginDTO.kt index c784c10..a6d862b 100644 --- a/src/main/kotlin/com/msksbr/bookmgr/dto/UserLoginDTO.kt +++ b/src/main/kotlin/com/msksbr/bookmgr/dto/UserLoginDTO.kt @@ -3,11 +3,17 @@ 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") val username: String?, @NotBlank(message = "password is required") val password: String? -) \ No newline at end of file +) diff --git a/src/main/kotlin/com/msksbr/bookmgr/entity/Book.kt b/src/main/kotlin/com/msksbr/bookmgr/entity/Book.kt index d73ea23..b2fc056 100644 --- a/src/main/kotlin/com/msksbr/bookmgr/entity/Book.kt +++ b/src/main/kotlin/com/msksbr/bookmgr/entity/Book.kt @@ -5,13 +5,19 @@ import com.baomidou.mybatisplus.annotation.TableId import com.baomidou.mybatisplus.annotation.TableName /* -* 图书实体,映射 book 表 +* 图书实体,映射数据库 book 表 +* +* 字段: +* id - 自增主键,插入时为 null 由数据库自动生成 +* name - 书名 +* author - 作者 +* stock - 库存余量(可借出数量) */ @TableName("book") data class Book( @TableId(type = IdType.AUTO) - val id: Long?=null, + val id: Long? = null, var name: String, var author: String, - var stock:Int -) \ No newline at end of file + var stock: Int +) diff --git a/src/main/kotlin/com/msksbr/bookmgr/entity/BorrowRecord.kt b/src/main/kotlin/com/msksbr/bookmgr/entity/BorrowRecord.kt index 7b91493..e3b1c67 100644 --- a/src/main/kotlin/com/msksbr/bookmgr/entity/BorrowRecord.kt +++ b/src/main/kotlin/com/msksbr/bookmgr/entity/BorrowRecord.kt @@ -6,15 +6,23 @@ 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( @TableId(type = IdType.AUTO) - val id:Long?=null, - var userId:Long, - var bookId:Long, + val id: Long? = null, + var userId: Long, + var bookId: Long, var borrowTime: Date, - var returnTime:Date?=null, - var status:String -) \ No newline at end of file + var returnTime: Date? = null, + var status: String +) diff --git a/src/main/kotlin/com/msksbr/bookmgr/entity/User.kt b/src/main/kotlin/com/msksbr/bookmgr/entity/User.kt index 1321a14..a153708 100644 --- a/src/main/kotlin/com/msksbr/bookmgr/entity/User.kt +++ b/src/main/kotlin/com/msksbr/bookmgr/entity/User.kt @@ -5,13 +5,19 @@ 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( @TableId(type = IdType.AUTO) // 设置自增主键 - val id: Long?=null, + val id: Long? = null, var username: String, var password: String, var role: String, -) \ No newline at end of file +) diff --git a/src/main/kotlin/com/msksbr/bookmgr/interceptor/JwtAuthInterceptor.kt b/src/main/kotlin/com/msksbr/bookmgr/interceptor/JwtAuthInterceptor.kt index 5894a03..62d081d 100644 --- a/src/main/kotlin/com/msksbr/bookmgr/interceptor/JwtAuthInterceptor.kt +++ b/src/main/kotlin/com/msksbr/bookmgr/interceptor/JwtAuthInterceptor.kt @@ -1,51 +1,67 @@ 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 } - request.setAttribute("username",claims.subject) - request.setAttribute("role",claims.get("role", String::class.java)) + // 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) } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/msksbr/bookmgr/mapper/BookMapper.kt b/src/main/kotlin/com/msksbr/bookmgr/mapper/BookMapper.kt index d231b91..8a5adcb 100644 --- a/src/main/kotlin/com/msksbr/bookmgr/mapper/BookMapper.kt +++ b/src/main/kotlin/com/msksbr/bookmgr/mapper/BookMapper.kt @@ -5,7 +5,17 @@ import com.msksbr.bookmgr.entity.Book import org.apache.ibatis.annotations.Mapper /* -* 图书 Mapper,继承 MyBatis-Plus BaseMapper 获得通用 CRUD +* 图书数据访问层,继承 MyBatis-Plus BaseMapper 获得通用 CRUD 方法 +* +* 内置方法(无需手写 SQL): +* - insert(book) — 插入单条记录 +* - deleteById(id) — 根据主键删除 +* - updateById(book) — 根据主键更新 +* - selectById(id) — 根据主键查询 +* - selectList(queryWrapper) — 条件查询列表 +* - selectPage(page, wrapper) — 分页查询 +* +* 复杂查询可通过接口方法 + @Select 注解或 XML 映射文件扩展 */ @Mapper -interface BookMapper: BaseMapper \ No newline at end of file +interface BookMapper : BaseMapper diff --git a/src/main/kotlin/com/msksbr/bookmgr/mapper/BorrowRecordMapper.kt b/src/main/kotlin/com/msksbr/bookmgr/mapper/BorrowRecordMapper.kt index 183c2c9..693146e 100644 --- a/src/main/kotlin/com/msksbr/bookmgr/mapper/BorrowRecordMapper.kt +++ b/src/main/kotlin/com/msksbr/bookmgr/mapper/BorrowRecordMapper.kt @@ -5,7 +5,15 @@ import com.msksbr.bookmgr.entity.BorrowRecord import org.apache.ibatis.annotations.Mapper /* -* 借阅记录 Mapper,继承 MyBatis-Plus BaseMapper 获得通用 CRUD +* 借阅记录数据访问层,继承 MyBatis-Plus BaseMapper 获得通用 CRUD 方法 +* +* 内置方法: +* - insert(record) — 新建借阅记录 +* - deleteById(id) — 根据主键删除 +* - updateById(record) — 根据主键更新(如更新 status / returnTime) +* - selectById(id) — 根据主键查询 +* - selectList(queryWrapper) — 条件查询列表(可按 userId、bookId、status 筛选) +* - selectPage(page, wrapper) — 分页查询 */ @Mapper -interface BorrowRecordMapper: BaseMapper \ No newline at end of file +interface BorrowRecordMapper : BaseMapper diff --git a/src/main/kotlin/com/msksbr/bookmgr/mapper/UserMapper.kt b/src/main/kotlin/com/msksbr/bookmgr/mapper/UserMapper.kt index dee0bbd..6fa9745 100644 --- a/src/main/kotlin/com/msksbr/bookmgr/mapper/UserMapper.kt +++ b/src/main/kotlin/com/msksbr/bookmgr/mapper/UserMapper.kt @@ -5,7 +5,15 @@ import com.msksbr.bookmgr.entity.User import org.apache.ibatis.annotations.Mapper /* -* 用户 Mapper,继承 MyBatis-Plus BaseMapper 获得通用 CRUD +* 用户数据访问层,继承 MyBatis-Plus BaseMapper 获得通用 CRUD 方法 +* +* 内置方法: +* - insert(user) — 插入单条记录 +* - deleteById(id) — 根据主键删除 +* - updateById(user) — 根据主键更新 +* - selectById(id) — 根据主键查询 +* - selectOne(queryWrapper) — 按条件查询单条(常用于 username 唯一查询) +* - selectList(queryWrapper) — 条件查询列表 */ @Mapper -interface UserMapper: BaseMapper \ No newline at end of file +interface UserMapper : BaseMapper diff --git a/src/main/kotlin/com/msksbr/bookmgr/runner/InitUserRunner.kt b/src/main/kotlin/com/msksbr/bookmgr/runner/InitUserRunner.kt index 9bd5fb6..05722f6 100644 --- a/src/main/kotlin/com/msksbr/bookmgr/runner/InitUserRunner.kt +++ b/src/main/kotlin/com/msksbr/bookmgr/runner/InitUserRunner.kt @@ -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() .eq("username", "admin") @@ -30,6 +43,7 @@ class InitUserRunner( log.info("[InitUser] admin not found, creating") insertAdmin() } + // 检查并创建 user01 val existsUser01 = userMapper.selectOne( QueryWrapper() .eq("username", "user01") @@ -38,6 +52,7 @@ class InitUserRunner( log.info("[InitUser] user01 not found, creating") insertUser01() } + // 检查并创建 user02 val existsUser02 = userMapper.selectOne( QueryWrapper() .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, diff --git a/src/main/kotlin/com/msksbr/bookmgr/script/Log.kt b/src/main/kotlin/com/msksbr/bookmgr/script/Log.kt index 7a24263..b890d0f 100644 --- a/src/main/kotlin/com/msksbr/bookmgr/script/Log.kt +++ b/src/main/kotlin/com/msksbr/bookmgr/script/Log.kt @@ -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.log: Logger - get() = LoggerFactory.getLogger(this::class.java) \ No newline at end of file +val T.log: Logger + get() = LoggerFactory.getLogger(this::class.java) diff --git a/src/main/kotlin/com/msksbr/bookmgr/service/AuthService.kt b/src/main/kotlin/com/msksbr/bookmgr/service/AuthService.kt index 51b9a9a..a64a574 100644 --- a/src/main/kotlin/com/msksbr/bookmgr/service/AuthService.kt +++ b/src/main/kotlin/com/msksbr/bookmgr/service/AuthService.kt @@ -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? -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/msksbr/bookmgr/service/impl/AuthServiceImpl.kt b/src/main/kotlin/com/msksbr/bookmgr/service/impl/AuthServiceImpl.kt index 405841b..f8ee38f 100644 --- a/src/main/kotlin/com/msksbr/bookmgr/service/impl/AuthServiceImpl.kt +++ b/src/main/kotlin/com/msksbr/bookmgr/service/impl/AuthServiceImpl.kt @@ -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() @@ -24,7 +40,7 @@ class AuthServiceImpl(private val userMapper: UserMapper, private val passwordEn return null } // 比对密码 - return if(passwordEncoder.matches(loginDTO.password, user.password)) { + return if (passwordEncoder.matches(loginDTO.password, user.password)) { user } else { null diff --git a/src/main/kotlin/com/msksbr/bookmgr/template/Result.kt b/src/main/kotlin/com/msksbr/bookmgr/template/Result.kt index 723100e..e056224 100644 --- a/src/main/kotlin/com/msksbr/bookmgr/template/Result.kt +++ b/src/main/kotlin/com/msksbr/bookmgr/template/Result.kt @@ -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( var code: Int, @@ -9,7 +19,12 @@ data class Result( var data: T? ) { companion object { - // 成功 + /* + * 成功响应 — 返回 200 + "success" + * @param data 业务数据,类型由调用方推断 + * + * JSON 输出示例:{ "code": 200, "message": "success", "data": {...} } + */ fun success(data: T): Result { return Result( code = 200, @@ -17,7 +32,13 @@ data class Result( data = data ) } - // 失败 + + /* + * 失败响应 — 返回 500 + 自定义错误消息 + * @param message 错误描述,如 "Incorrect username or password" + * + * JSON 输出示例:{ "code": 500, "message": "xxx", "data": null } + */ fun error(message: String): Result { return Result( code = 500, @@ -25,7 +46,13 @@ data class Result( data = null ) } - // 未登录 + + /* + * 未登录响应 — 返回 401 + 自定义提示 + * @param message 提示信息,如 "Missing Authorization header" + * + * JSON 输出示例:{ "code": 401, "message": "xxx" } + */ fun unauthorized(message: String): Result { return Result( code = 401, @@ -34,4 +61,4 @@ data class Result( ) } } -} \ No newline at end of file +}