Merge pull request 'fix: page query' (#9) from backend-dev into main

Reviewed-on: #9
This commit was merged in pull request #9.
This commit is contained in:
2026-08-18 16:05:41 +08:00
7 changed files with 126 additions and 146 deletions
+1
View File
@@ -23,6 +23,7 @@ repositories {
dependencies { dependencies {
implementation("com.auth0:java-jwt:4.4.0") implementation("com.auth0:java-jwt:4.4.0")
implementation("com.baomidou:mybatis-plus-spring-boot4-starter:3.5.15") implementation("com.baomidou:mybatis-plus-spring-boot4-starter:3.5.15")
implementation("com.baomidou:mybatis-plus-jsqlparser:3.5.15")
implementation("org.springframework.boot:spring-boot-starter-amqp") implementation("org.springframework.boot:spring-boot-starter-amqp")
implementation("org.springframework.boot:spring-boot-starter-data-redis-reactive") implementation("org.springframework.boot:spring-boot-starter-data-redis-reactive")
implementation("org.springframework.boot:spring-boot-starter-json") implementation("org.springframework.boot:spring-boot-starter-json")
@@ -0,0 +1,18 @@
package club.itstudio.itmanager.config
import com.baomidou.mybatisplus.annotation.DbType
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
@Configuration
class MybatisConfig {
@Bean
fun mybatisPlusInterceptor(): MybatisPlusInterceptor {
val interceptor = MybatisPlusInterceptor()
interceptor.addInnerInterceptor(PaginationInnerInterceptor(DbType.MARIADB))
return interceptor
}
}
@@ -0,0 +1,61 @@
package club.itstudio.itmanager.service
import club.itstudio.itmanager.utils.Const
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.http.codec.multipart.FilePart
import org.springframework.stereotype.Service
import reactor.core.publisher.Mono
import reactor.core.scheduler.Schedulers
import java.io.IOException
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import java.util.*
@Service
class FileStorageService(
@Value($$"${app.file.data-path}")
private val dataPath: String
) {
data class StoredFile(
val path: Path,
val size: Long
)
private val logger = LoggerFactory.getLogger(this::class.java)
fun saveImage(file: FilePart, subDir: String, entityId: Long): Mono<StoredFile> {
val directory = Paths.get(dataPath, Const.FILE_DIR_IMAGE, subDir, entityId.toString())
val savedName = generateSafeFileName(file.filename())
val target = directory.resolve(savedName)
return Mono.fromCallable<Void> { Files.createDirectories(directory); null }
.then(file.transferTo(target))
.then(Mono.fromCallable { StoredFile(target, Files.size(target)) })
.subscribeOn(Schedulers.boundedElastic())
}
fun deleteFile(path: String?): Mono<Void> {
if (path.isNullOrBlank()) return Mono.empty()
return Mono.fromRunnable<Void> {
try {
Files.deleteIfExists(Paths.get(path))
} catch (e: IOException) {
logger.warn("Failed to delete file: {}", path, e)
}
}.subscribeOn(Schedulers.boundedElastic())
}
fun generateSafeFileName(original: String?): String {
val extension = original
?.substringAfterLast('.')
?.lowercase()
?.takeIf {
it.isNotBlank() &&
it.length <= 10 &&
it.all { ch -> ch.isLetterOrDigit() }
}
return if (extension != null) "${UUID.randomUUID()}.$extension" else UUID.randomUUID().toString()
}
}
@@ -3,6 +3,7 @@ package club.itstudio.itmanager.service.impl
import club.itstudio.itmanager.entity.dto.BorrowRecord import club.itstudio.itmanager.entity.dto.BorrowRecord
import club.itstudio.itmanager.mapper.BorrowRecordMapper import club.itstudio.itmanager.mapper.BorrowRecordMapper
import club.itstudio.itmanager.service.BorrowRecordService import club.itstudio.itmanager.service.BorrowRecordService
import com.baomidou.mybatisplus.extension.kotlin.KtQueryWrapper
import com.baomidou.mybatisplus.extension.plugins.pagination.Page import com.baomidou.mybatisplus.extension.plugins.pagination.Page
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
@@ -32,7 +33,7 @@ class BorrowRecordServiceImpl : ServiceImpl<BorrowRecordMapper, BorrowRecord>(),
): Mono<Page<BorrowRecord>> = ): Mono<Page<BorrowRecord>> =
blockingCall { blockingCall {
logger.info("Paging borrow records") logger.info("Paging borrow records")
val wrapper = ktQuery() val wrapper = KtQueryWrapper(BorrowRecord())
if (!keyword.isNullOrBlank()) { if (!keyword.isNullOrBlank()) {
wrapper.like(BorrowRecord::borrower, keyword) wrapper.like(BorrowRecord::borrower, keyword)
} }
@@ -9,22 +9,18 @@ import club.itstudio.itmanager.service.AccountService
import club.itstudio.itmanager.service.AttachmentService import club.itstudio.itmanager.service.AttachmentService
import club.itstudio.itmanager.service.CategoryService import club.itstudio.itmanager.service.CategoryService
import club.itstudio.itmanager.service.FinanceRecordService import club.itstudio.itmanager.service.FinanceRecordService
import club.itstudio.itmanager.service.FileStorageService
import club.itstudio.itmanager.utils.Const import club.itstudio.itmanager.utils.Const
import club.itstudio.itmanager.utils.DateUtils.getCurrentDateTime import club.itstudio.itmanager.utils.DateUtils.getCurrentDateTime
import com.baomidou.mybatisplus.extension.plugins.pagination.Page import com.baomidou.mybatisplus.extension.plugins.pagination.Page
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.amqp.core.AmqpTemplate import org.springframework.amqp.core.AmqpTemplate
import org.springframework.beans.factory.annotation.Value
import org.springframework.http.codec.multipart.FilePart import org.springframework.http.codec.multipart.FilePart
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import reactor.core.publisher.Flux import reactor.core.publisher.Flux
import reactor.core.publisher.Mono import reactor.core.publisher.Mono
import reactor.core.scheduler.Schedulers import reactor.core.scheduler.Schedulers
import java.io.IOException
import java.nio.file.Files
import java.nio.file.Paths
import java.util.NoSuchElementException import java.util.NoSuchElementException
import java.util.UUID
@Service @Service
class FinanceApiService( class FinanceApiService(
@@ -33,9 +29,7 @@ class FinanceApiService(
private val attachmentService: AttachmentService, private val attachmentService: AttachmentService,
private val accountService: AccountService, private val accountService: AccountService,
private val amqpTemplate: AmqpTemplate, private val amqpTemplate: AmqpTemplate,
private val fileStorageService: FileStorageService
@Value($$"${app.file.data-path}")
private val dataPath: String
) { ) {
private val logger = LoggerFactory.getLogger(this::class.java) private val logger = LoggerFactory.getLogger(this::class.java)
@@ -171,41 +165,25 @@ class FinanceApiService(
} }
private fun saveFinanceImage(financeRecordId: Long, file: FilePart): Mono<Attachment> { private fun saveFinanceImage(financeRecordId: Long, file: FilePart): Mono<Attachment> {
val directory = Paths.get(dataPath, Const.FILE_DIR_IMAGE, "finance", financeRecordId.toString()) return fileStorageService.saveImage(file, "finance", financeRecordId)
val savedName = generateSafeFileName(file.filename()) .map { stored ->
val target = directory.resolve(savedName) Attachment(
return Mono.fromCallable { relatedTable = "finance_records",
Files.createDirectories(directory) relatedId = financeRecordId,
file.transferTo(target) fileName = stored.path.fileName.toString(),
Attachment( filePath = stored.path.toString(),
relatedTable = "finance_records", fileSize = stored.size,
relatedId = financeRecordId, contentType = file.headers().contentType?.toString(),
fileName = savedName, uploadedAt = getCurrentDateTime()
filePath = target.toString(), )
fileSize = Files.size(target),
contentType = file.headers().contentType?.toString(),
uploadedAt = getCurrentDateTime()
)
}.subscribeOn(Schedulers.boundedElastic())
}
private fun generateSafeFileName(original: String?): String {
val extension = original
?.substringAfterLast('.')
?.lowercase()
?.takeIf {
it.isNotBlank() &&
it.length <= 10 &&
it.all { ch -> ch.isLetterOrDigit() }
} }
return if (extension != null) "${UUID.randomUUID()}.$extension" else UUID.randomUUID().toString()
} }
private fun cleanupFinanceAttachments(financeRecordId: Long): Mono<Boolean> { private fun cleanupFinanceAttachments(financeRecordId: Long): Mono<Boolean> {
return attachmentService.listByRelated("finance_records", financeRecordId) return attachmentService.listByRelated("finance_records", financeRecordId)
.flatMap { attachments -> .flatMap { attachments ->
Flux.fromIterable(attachments) Flux.fromIterable(attachments)
.flatMap { deletePhysicalFile(it.filePath) } .flatMap { fileStorageService.deleteFile(it.filePath) }
.then(attachmentService.deleteByRelated("finance_records", financeRecordId)) .then(attachmentService.deleteByRelated("finance_records", financeRecordId))
} }
.onErrorResume { error -> .onErrorResume { error ->
@@ -236,15 +214,4 @@ class FinanceApiService(
} }
} }
} }
private fun deletePhysicalFile(path: String?): Mono<Void> {
if (path.isNullOrBlank()) return Mono.empty()
return Mono.fromRunnable<Void> {
try {
Files.deleteIfExists(Paths.get(path))
} catch (e: IOException) {
logger.warn("Failed to delete file: {}", path, e)
}
}.subscribeOn(Schedulers.boundedElastic())
}
} }
@@ -7,23 +7,18 @@ import club.itstudio.itmanager.entity.dto.ItemTransaction
import club.itstudio.itmanager.entity.enums.TransactionType import club.itstudio.itmanager.entity.enums.TransactionType
import club.itstudio.itmanager.service.AttachmentService import club.itstudio.itmanager.service.AttachmentService
import club.itstudio.itmanager.service.CategoryService import club.itstudio.itmanager.service.CategoryService
import club.itstudio.itmanager.service.FileStorageService
import club.itstudio.itmanager.service.ItemService import club.itstudio.itmanager.service.ItemService
import club.itstudio.itmanager.service.ItemTransactionService import club.itstudio.itmanager.service.ItemTransactionService
import club.itstudio.itmanager.utils.Const import club.itstudio.itmanager.utils.Const
import club.itstudio.itmanager.utils.DateUtils.getCurrentDateTime import club.itstudio.itmanager.utils.DateUtils.getCurrentDateTime
import com.baomidou.mybatisplus.extension.plugins.pagination.Page import com.baomidou.mybatisplus.extension.plugins.pagination.Page
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.http.codec.multipart.FilePart import org.springframework.http.codec.multipart.FilePart
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import reactor.core.publisher.Flux import reactor.core.publisher.Flux
import reactor.core.publisher.Mono import reactor.core.publisher.Mono
import reactor.core.scheduler.Schedulers
import java.io.IOException
import java.nio.file.Files
import java.nio.file.Paths
import java.util.NoSuchElementException import java.util.NoSuchElementException
import java.util.UUID
@Service @Service
class ItemsApiService( class ItemsApiService(
@@ -31,9 +26,7 @@ class ItemsApiService(
private val categoryService: CategoryService, private val categoryService: CategoryService,
private val attachmentService: AttachmentService, private val attachmentService: AttachmentService,
private val transactionService: ItemTransactionService, private val transactionService: ItemTransactionService,
private val fileStorageService: FileStorageService
@Value($$"${app.file.data-path}")
private val dataPath: String
) { ) {
private val logger = LoggerFactory.getLogger(this::class.java) private val logger = LoggerFactory.getLogger(this::class.java)
@@ -276,7 +269,7 @@ class ItemsApiService(
.flatMap { attachment -> .flatMap { attachment ->
attachmentService.deleteAttachment(id).flatMap { success -> attachmentService.deleteAttachment(id).flatMap { success ->
if (success) { if (success) {
deletePhysicalFile(attachment.filePath).thenReturn("") fileStorageService.deleteFile(attachment.filePath).thenReturn("")
} else { } else {
Mono.just("item image delete failed.") Mono.just("item image delete failed.")
} }
@@ -296,41 +289,25 @@ class ItemsApiService(
} }
private fun saveItemImage(itemId: Long, file: FilePart): Mono<Attachment> { private fun saveItemImage(itemId: Long, file: FilePart): Mono<Attachment> {
val directory = Paths.get(dataPath, Const.FILE_DIR_IMAGE, "items", itemId.toString()) return fileStorageService.saveImage(file, "items", itemId)
val savedName = generateSafeFileName(file.filename()) .map { stored ->
val target = directory.resolve(savedName) Attachment(
return Mono.fromCallable { relatedTable = "items",
Files.createDirectories(directory) relatedId = itemId,
file.transferTo(target) fileName = stored.path.fileName.toString(),
Attachment( filePath = stored.path.toString(),
relatedTable = "items", fileSize = stored.size,
relatedId = itemId, contentType = file.headers().contentType?.toString(),
fileName = savedName, uploadedAt = getCurrentDateTime()
filePath = target.toString(), )
fileSize = Files.size(target),
contentType = file.headers().contentType?.toString(),
uploadedAt = getCurrentDateTime()
)
}.subscribeOn(Schedulers.boundedElastic())
}
private fun generateSafeFileName(original: String?): String {
val extension = original
?.substringAfterLast('.')
?.lowercase()
?.takeIf {
it.isNotBlank() &&
it.length <= 10 &&
it.all { ch -> ch.isLetterOrDigit() }
} }
return if (extension != null) "${UUID.randomUUID()}.$extension" else UUID.randomUUID().toString()
} }
private fun cleanupItemAttachments(itemId: Long): Mono<String> { private fun cleanupItemAttachments(itemId: Long): Mono<String> {
return attachmentService.listByRelated("items", itemId) return attachmentService.listByRelated("items", itemId)
.flatMap { attachments -> .flatMap { attachments ->
Flux.fromIterable(attachments) Flux.fromIterable(attachments)
.flatMap { deletePhysicalFile(it.filePath) } .flatMap { fileStorageService.deleteFile(it.filePath) }
.then(attachmentService.deleteByRelated("items", itemId)) .then(attachmentService.deleteByRelated("items", itemId))
.thenReturn("") .thenReturn("")
} }
@@ -339,15 +316,4 @@ class ItemsApiService(
Mono.just("") Mono.just("")
} }
} }
private fun deletePhysicalFile(path: String?): Mono<Void> {
if (path.isNullOrBlank()) return Mono.empty()
return Mono.fromRunnable<Void> {
try {
Files.deleteIfExists(Paths.get(path))
} catch (e: IOException) {
logger.warn("Failed to delete file: {}", path, e)
}
}.subscribeOn(Schedulers.boundedElastic())
}
} }
@@ -4,29 +4,22 @@ import club.itstudio.itmanager.entity.dto.Attachment
import club.itstudio.itmanager.entity.dto.BorrowRecord import club.itstudio.itmanager.entity.dto.BorrowRecord
import club.itstudio.itmanager.service.AttachmentService import club.itstudio.itmanager.service.AttachmentService
import club.itstudio.itmanager.service.BorrowRecordService import club.itstudio.itmanager.service.BorrowRecordService
import club.itstudio.itmanager.service.FileStorageService
import club.itstudio.itmanager.utils.Const import club.itstudio.itmanager.utils.Const
import club.itstudio.itmanager.utils.DateUtils.getCurrentDateTime import club.itstudio.itmanager.utils.DateUtils.getCurrentDateTime
import com.baomidou.mybatisplus.extension.plugins.pagination.Page import com.baomidou.mybatisplus.extension.plugins.pagination.Page
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.http.codec.multipart.FilePart import org.springframework.http.codec.multipart.FilePart
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import reactor.core.publisher.Flux import reactor.core.publisher.Flux
import reactor.core.publisher.Mono import reactor.core.publisher.Mono
import reactor.core.scheduler.Schedulers
import java.io.IOException
import java.nio.file.Files
import java.nio.file.Paths
import java.util.NoSuchElementException import java.util.NoSuchElementException
import java.util.UUID
@Service @Service
class UsagesApiService( class UsagesApiService(
private val borrowRecordService: BorrowRecordService, private val borrowRecordService: BorrowRecordService,
private val attachmentService: AttachmentService, private val attachmentService: AttachmentService,
private val fileStorageService: FileStorageService
@Value($$"${app.file.data-path}")
private val dataPath: String
) { ) {
private val logger = LoggerFactory.getLogger(this::class.java) private val logger = LoggerFactory.getLogger(this::class.java)
@@ -149,7 +142,7 @@ class UsagesApiService(
.flatMap { attachment -> .flatMap { attachment ->
attachmentService.deleteAttachment(id).flatMap { success -> attachmentService.deleteAttachment(id).flatMap { success ->
if (success) { if (success) {
deletePhysicalFile(attachment.filePath).thenReturn("") fileStorageService.deleteFile(attachment.filePath).thenReturn("")
} else { } else {
Mono.just("usage image delete failed.") Mono.just("usage image delete failed.")
} }
@@ -160,41 +153,25 @@ class UsagesApiService(
} }
private fun saveUsageImage(usageId: Long, file: FilePart): Mono<Attachment> { private fun saveUsageImage(usageId: Long, file: FilePart): Mono<Attachment> {
val directory = Paths.get(dataPath, Const.FILE_DIR_IMAGE, "borrow_records", usageId.toString()) return fileStorageService.saveImage(file, "borrow_records", usageId)
val savedName = generateSafeFileName(file.filename()) .map { stored ->
val target = directory.resolve(savedName) Attachment(
return Mono.fromCallable { relatedTable = RELATED_TABLE,
Files.createDirectories(directory) relatedId = usageId,
file.transferTo(target) fileName = stored.path.fileName.toString(),
Attachment( filePath = stored.path.toString(),
relatedTable = RELATED_TABLE, fileSize = stored.size,
relatedId = usageId, contentType = file.headers().contentType?.toString(),
fileName = savedName, uploadedAt = getCurrentDateTime()
filePath = target.toString(), )
fileSize = Files.size(target),
contentType = file.headers().contentType?.toString(),
uploadedAt = getCurrentDateTime()
)
}.subscribeOn(Schedulers.boundedElastic())
}
private fun generateSafeFileName(original: String?): String {
val extension = original
?.substringAfterLast('.')
?.lowercase()
?.takeIf {
it.isNotBlank() &&
it.length <= 10 &&
it.all { ch -> ch.isLetterOrDigit() }
} }
return if (extension != null) "${UUID.randomUUID()}.$extension" else UUID.randomUUID().toString()
} }
private fun cleanupUsageAttachments(usageId: Long): Mono<String> { private fun cleanupUsageAttachments(usageId: Long): Mono<String> {
return attachmentService.listByRelated(RELATED_TABLE, usageId) return attachmentService.listByRelated(RELATED_TABLE, usageId)
.flatMap { attachments -> .flatMap { attachments ->
Flux.fromIterable(attachments) Flux.fromIterable(attachments)
.flatMap { deletePhysicalFile(it.filePath) } .flatMap { fileStorageService.deleteFile(it.filePath) }
.then(attachmentService.deleteByRelated(RELATED_TABLE, usageId)) .then(attachmentService.deleteByRelated(RELATED_TABLE, usageId))
.thenReturn("") .thenReturn("")
} }
@@ -204,17 +181,6 @@ class UsagesApiService(
} }
} }
private fun deletePhysicalFile(path: String?): Mono<Void> {
if (path.isNullOrBlank()) return Mono.empty()
return Mono.fromRunnable<Void> {
try {
Files.deleteIfExists(Paths.get(path))
} catch (e: IOException) {
logger.warn("Failed to delete file: {}", path, e)
}
}.subscribeOn(Schedulers.boundedElastic())
}
companion object { companion object {
private const val RELATED_TABLE = "borrow_records" private const val RELATED_TABLE = "borrow_records"
private const val STATUS_BORROWED = "BORROWED" private const val STATUS_BORROWED = "BORROWED"