feat(borrow): implement borrow module for regular users

- Add list, search, detail, borrow, and return endpoints to BorrowController
- Implement service methods with MyBatis Plus queries and validation
- Introduce MyBorrowVo response wrapper for borrow records
- Add JwtPopulateAspect for populating userId from JWT claims
This commit is contained in:
2026-05-23 20:05:55 +08:00
parent 105aa9579a
commit b79fd24ed5
3 changed files with 294 additions and 15 deletions
@@ -1,16 +1,113 @@
package com.msksbr.bookmgr.controller
import org.springframework.web.bind.annotation.RestController
import com.msksbr.bookmgr.annotation.RequireRole
import com.msksbr.bookmgr.config.IpExtractor
import com.msksbr.bookmgr.script.log
import com.msksbr.bookmgr.service.BorrowService
import com.msksbr.bookmgr.template.Result
import jakarta.servlet.http.HttpServletRequest
import org.springframework.web.bind.annotation.*
/*
* 借阅接口(面向普通用户)
* 路径前缀(待定)/api/borrows
* 路径前缀:/api/borrows
*
* 计划接口:
* 接口:
* - 我的借阅记录列表
* - 借书(提交借阅请求)
* - 搜索我的借阅记录
* - 单条借阅详情
* - 借书
* - 还书
*
* 全部接口需 user 角色,由 @RequireRole 切面校验
*/
@RestController
class BorrowController {
@RequestMapping("/api/borrows")
class BorrowController(
private val borrowService: BorrowService,
private val ipExtractor: IpExtractor
) {
/*
* GET /api/borrows/getall
* 查询当前用户的所有借阅记录
*/
@RequireRole("user")
@GetMapping("/getall")
fun getAllMyBorrows(
@RequestAttribute(required = false) username: String?,
@RequestAttribute(required = false) userId: Long?,
request: HttpServletRequest
): Result<Any?> {
log.info("[Borrow] getAll: user={}, userId={}", username ?: "guest", userId)
log.info("[Borrow] user agent: {}, ip={}", request.getHeader("User-Agent"), ipExtractor.getRealIp(request))
return borrowService.getAllMyBorrows(userId!!)
}
/*
* GET /api/borrows/search?query=xxx
* 搜索当前用户的借阅记录,按书名或作者模糊匹配
*/
@RequireRole("user")
@GetMapping("/search")
fun searchMyBorrows(
@RequestAttribute(required = false) username: String?,
query: String,
@RequestAttribute(required = false) userId: Long?,
request: HttpServletRequest
): Result<Any?> {
log.info("[Borrow] search: user={}, userId={}, query={}", username ?: "guest", userId, query)
log.info("[Borrow] user agent: {}, ip={}", request.getHeader("User-Agent"), ipExtractor.getRealIp(request))
return borrowService.searchMyBorrows(query, userId!!)
}
/*
* GET /api/borrows/getone?id=xxx
* 查询单条借阅记录详情(仅限本人的记录)
*/
@RequireRole("user")
@GetMapping("/getone")
fun getOneMyBorrow(
@RequestAttribute(required = false) username: String?,
borrowId: Long,
@RequestAttribute(required = false) userId: Long?,
request: HttpServletRequest
): Result<Any?> {
log.info("[Borrow] getOne: user={}, userId={}, borrowId={}", username ?: "guest", userId, borrowId)
log.info("[Borrow] user agent: {}, ip={}", request.getHeader("User-Agent"), ipExtractor.getRealIp(request))
return borrowService.getOneMyBorrow(borrowId, userId!!)
}
/*
* POST /api/borrows/borrowbook?bookId=xxx
* 借书
*/
@RequireRole("user")
@PostMapping("/borrowbook")
fun borrowBookForMe(
@RequestAttribute(required = false) username: String?,
bookId: Long,
@RequestAttribute(required = false) userId: Long?,
request: HttpServletRequest
): Result<Any?> {
log.info("[Borrow] borrow: user={}, userId={}, bookId={}", username ?: "guest", userId, bookId)
log.info("[Borrow] user agent: {}, ip={}", request.getHeader("User-Agent"), ipExtractor.getRealIp(request))
return borrowService.borrowBookForMe(bookId, userId!!)
}
/*
* POST /api/borrows/returnbook?borrowId=xxx
* 还书(仅限本人的记录)
*/
@RequireRole("user")
@PostMapping("/returnbook")
fun returnBookForMe(
@RequestAttribute(required = false) username: String?,
borrowId: Long,
@RequestAttribute(required = false) userId: Long?,
request: HttpServletRequest
): Result<Any?> {
log.info("[Borrow] return: user={}, userId={}, borrowId={}", username ?: "guest", userId, borrowId)
log.info("[Borrow] user agent: {}, ip={}", request.getHeader("User-Agent"), ipExtractor.getRealIp(request))
return borrowService.returnBookForMe(borrowId, userId!!)
}
}
@@ -1,19 +1,45 @@
package com.msksbr.bookmgr.service.impl
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper
import com.msksbr.bookmgr.entity.Book
import com.msksbr.bookmgr.entity.BorrowRecord
import com.msksbr.bookmgr.mapper.BookMapper
import com.msksbr.bookmgr.mapper.BorrowRecordMapper
import com.msksbr.bookmgr.script.log
import com.msksbr.bookmgr.service.BorrowService
import org.springframework.stereotype.Service
import com.msksbr.bookmgr.template.Result
import com.msksbr.bookmgr.vo.MyBorrowVo
import com.msksbr.bookmgr.vo.borrow.BookBorrowVo
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.runBlocking
import org.springframework.stereotype.Service
import java.util.*
/*
* 借阅服务实现(面向普通用户)
*/
@Service
class BorrowServiceImpl: BorrowService {
class BorrowServiceImpl(
private val borrowRecordMapper: BorrowRecordMapper,
private val bookMapper: BookMapper
) : BorrowService {
/*
* 查询当前用户的所有借阅记录
*/
override fun getAllMyBorrows(userId: Long): Result<Any?> {
TODO("Not yet implemented")
val borrows = borrowRecordMapper.selectList(
QueryWrapper<BorrowRecord>().eq("user_id", userId).orderByDesc("borrow_time")
)
if (borrows.isEmpty()) {
log.info("[Borrow] getAll: no records for userId={}", userId)
return Result.notFound("No borrow records found")
}
val bookIds = borrows.map { it.bookId }.distinct()
val result = runBlocking { buildMyBorrowVos(borrows, bookIds) }
log.info("[Borrow] getAll: found {} records for userId={}", result.size, userId)
return Result.success(result)
}
/*
@@ -23,7 +49,34 @@ class BorrowServiceImpl: BorrowService {
query: String,
userId: Long
): Result<Any?> {
TODO("Not yet implemented")
if (query.isBlank()) {
log.warn("[Borrow] search: query is blank")
return Result.error("Search query cannot be empty")
}
val matchedBookIds = bookMapper.selectList(
QueryWrapper<Book>()
.like("name", query)
.or()
.like("author", query)
).map { it.id }
if (matchedBookIds.isEmpty()) {
log.info("[Borrow] search: no results for {}", query)
return Result.notFound("No matching borrow records found")
}
val borrows = borrowRecordMapper.selectList(
QueryWrapper<BorrowRecord>()
.eq("user_id", userId)
.`in`("book_id", matchedBookIds)
.orderByDesc("borrow_time")
)
if (borrows.isEmpty()) {
log.info("[Borrow] search: no borrows match for userId={}, query={}", userId, query)
return Result.notFound("No matching borrow records found")
}
val bookIds = borrows.map { it.bookId }.distinct()
val result = runBlocking { buildMyBorrowVos(borrows, bookIds) }
log.info("[Borrow] search: found {} records for userId={}, query={}", result.size, userId, query)
return Result.success(result)
}
/*
@@ -33,26 +86,139 @@ class BorrowServiceImpl: BorrowService {
borrowId: Long,
userId: Long
): Result<Any?> {
TODO("Not yet implemented")
val borrow = borrowRecordMapper.selectById(borrowId)
if (borrow == null) {
log.info("[Borrow] getOne: record not found, borrowId={}", borrowId)
return Result.notFound("Borrow record not found")
}
if (borrow.userId != userId) {
log.warn(
"[Borrow] getOne: OWNERSHIP MISMATCH — userId={} attempted to access borrowId={} belonging to userId={}, bookId={}, status={}",
userId, borrowId, borrow.userId, borrow.bookId, borrow.status
)
return Result.notFound("Borrow record not found")
}
val book = bookMapper.selectById(borrow.bookId)
val result = MyBorrowVo(
id = borrow.id!!,
borrowTime = borrow.borrowTime,
returnTime = borrow.returnTime,
status = borrow.status,
bookBorrowVo = book?.let {
BookBorrowVo(id = it.id!!, name = it.name, author = it.author)
} ?: BookBorrowVo()
)
log.info("[Borrow] getOne: found record borrowId={}, userId={}, book={}", borrowId, userId, book?.name)
return Result.success(result)
}
/*
* 借书
* 借书(普通用户)
*/
override fun borrowBookForMe(
bookId: Long,
userId: Long
): Result<Any?> {
TODO("Not yet implemented")
val matchedBook = bookMapper.selectById(bookId)
if (matchedBook == null) {
log.warn("[Borrow] borrow: book not found, bookId={}", bookId)
return Result.error("Book does not exist")
}
if (matchedBook.stock < 1) {
log.warn("[Borrow] borrow: book out of stock, bookId={}, stock={}", bookId, matchedBook.stock)
return Result.conflict("Book is out of stock")
}
val book = Book(
id = matchedBook.id,
name = matchedBook.name,
author = matchedBook.author,
stock = matchedBook.stock - 1,
)
val borrow = BorrowRecord(
id = null,
userId = userId,
bookId = bookId,
borrowTime = Date(),
returnTime = null,
status = "BORROWED"
)
bookMapper.updateById(book)
borrowRecordMapper.insert(borrow)
log.info("[Borrow] borrow: success, userId={}, bookId={}, book={}", userId, bookId, matchedBook.name)
return Result.success("Book borrowed successfully")
}
/*
* 还书
* 还书(普通用户)
*/
override fun returnBookForMe(
borrowId: Long,
userId: Long
): Result<Any?> {
TODO("Not yet implemented")
val matchedBorrow = borrowRecordMapper.selectById(borrowId)
if (matchedBorrow == null) {
log.warn("[Borrow] return: record not found, borrowId={}", borrowId)
return Result.error("Borrow record does not exist")
}
if (matchedBorrow.userId != userId) {
log.warn(
"[Borrow] return: OWNERSHIP MISMATCH — userId={} attempted to return borrowId={} belonging to userId={}, bookId={}, status={}",
userId, borrowId, matchedBorrow.userId, matchedBorrow.bookId, matchedBorrow.status
)
return Result.error("Borrow record does not exist")
}
if (matchedBorrow.status == "RETURNED") {
log.warn("[Borrow] return: already returned, borrowId={}", borrowId)
return Result.conflict("Book has already been returned")
}
val borrow = BorrowRecord(
id = matchedBorrow.id,
bookId = matchedBorrow.bookId,
userId = matchedBorrow.userId,
borrowTime = matchedBorrow.borrowTime,
returnTime = Date(),
status = "RETURNED"
)
val matchedBook = bookMapper.selectById(matchedBorrow.bookId)
if (matchedBook == null) {
log.warn("[Borrow] return: book not found, bookId={}", matchedBorrow.bookId)
return Result.error("Book does not exist")
}
val book = Book(
id = matchedBook.id,
name = matchedBook.name,
author = matchedBook.author,
stock = matchedBook.stock + 1,
)
bookMapper.updateById(book)
borrowRecordMapper.updateById(borrow)
log.info("[Borrow] return: success, borrowId={}, userId={}, book={}", borrowId, userId, matchedBook.name)
return Result.success("Book returned successfully")
}
}
// 并发查询图书信息,组装为 MyBorrowVo 列表
private suspend fun buildMyBorrowVos(
borrows: List<BorrowRecord>,
bookIds: List<Long>,
): List<MyBorrowVo> = coroutineScope {
val bookMap = async(Dispatchers.IO) {
bookMapper.selectByIds(bookIds).associateBy { it.id }
}.await()
borrows.map { borrow ->
MyBorrowVo(
id = borrow.id!!,
borrowTime = borrow.borrowTime,
returnTime = borrow.returnTime,
status = borrow.status,
bookBorrowVo = bookMap[borrow.bookId]?.let { book ->
BookBorrowVo(
id = book.id!!,
name = book.name,
author = book.author,
)
} ?: BookBorrowVo(),
)
}
}
}
@@ -0,0 +1,16 @@
package com.msksbr.bookmgr.vo
import com.msksbr.bookmgr.vo.borrow.BookBorrowVo
import java.util.*
/*
* 用户借阅记录视图(面向普通用户)
* 与 BorrowInfoVo 一致,但不包含其他用户的信息
*/
data class MyBorrowVo(
val id: Long = 0,
val bookBorrowVo: BookBorrowVo = BookBorrowVo(),
val borrowTime: Date = Date(),
val returnTime: Date? = null,
val status: String = ""
)