diff --git a/src/beckend/src/main/kotlin/club/itstudio/itmanager/controller/v1/ItemsController.kt b/src/beckend/src/main/kotlin/club/itstudio/itmanager/controller/v1/ItemsController.kt index af08c15..5261c29 100644 --- a/src/beckend/src/main/kotlin/club/itstudio/itmanager/controller/v1/ItemsController.kt +++ b/src/beckend/src/main/kotlin/club/itstudio/itmanager/controller/v1/ItemsController.kt @@ -1,75 +1,178 @@ package club.itstudio.itmanager.controller.v1 +import club.itstudio.itmanager.entity.ApiResponse +import club.itstudio.itmanager.entity.dto.Attachment +import club.itstudio.itmanager.entity.dto.Category +import club.itstudio.itmanager.entity.dto.Item +import club.itstudio.itmanager.service.v1.ItemsApiService import club.itstudio.itmanager.utils.Const +import com.baomidou.mybatisplus.extension.plugins.pagination.Page +import jakarta.validation.Valid +import org.slf4j.LoggerFactory +import org.springframework.http.codec.multipart.FilePart import org.springframework.validation.annotation.Validated import org.springframework.web.bind.annotation.* +import reactor.core.publisher.Flux import reactor.core.publisher.Mono @Validated @RestController @RequestMapping("/api/v1/items") -class ItemsController { +class ItemsController( + private val service: ItemsApiService +) { + private val logger = LoggerFactory.getLogger(this::class.java) // items/ GET/POST/PUT/DELETE 物品 CRUD 操作 @GetMapping("", "/") - fun getItems(): Mono = Const.INTERNAL_ERROR_MONO + fun getItems( + @RequestParam(defaultValue = "1") pageNum: Long, + @RequestParam(defaultValue = "10") pageSize: Long, + @RequestParam(required = false) keyword: String?, + @RequestParam(required = false) status: String? + ): Mono> { + logger.info("Trying to list items") + val page = Page(pageNum, pageSize) + return service.getItems(page, keyword, status) + .map { ApiResponse.success(it) } + } @PostMapping("", "/") - fun createItem(): Mono = Const.INTERNAL_ERROR_MONO + fun createItem(@RequestBody @Valid item: Item): Mono> { + logger.info("Trying to create item") + return service.createItem(item) + .map(Const::messageHandler) + } @PutMapping("", "/") - fun updateItem(): Mono = Const.INTERNAL_ERROR_MONO + fun updateItem(@RequestBody @Valid item: Item): Mono> { + logger.info("Trying to update item") + return service.updateItem(item) + .map(Const::messageHandler) + } @DeleteMapping("", "/") - fun deleteItem(@RequestParam(required = false) id: String?): Mono = Const.INTERNAL_ERROR_MONO + fun deleteItem(@RequestParam(required = false) id: Long?): Mono> { + logger.info("Trying to delete item") + return service.deleteItem(id) + .map(Const::messageHandler) + } // items/{id}/upload_images/ POST 上传物品图片 @PostMapping("/{id}/upload_images", "/{id}/upload_images/") - fun uploadImages(@PathVariable("id") id: String): Mono = Const.INTERNAL_ERROR_MONO + fun uploadImages( + @PathVariable("id") id: Long, + @RequestPart("files") files: Flux + ): Mono> { + logger.info("Trying to upload images for item {}", id) + return service.uploadImages(id, files) + .map(Const::messageHandler) + } // items/{id}/set_primary_image/ POST 设置主图片 @PostMapping("/{id}/set_primary_image", "/{id}/set_primary_image/") - fun setPrimaryImage(@PathVariable("id") id: String): Mono = Const.INTERNAL_ERROR_MONO + fun setPrimaryImage( + @PathVariable("id") id: Long, + @RequestParam imageId: Long + ): Mono> { + logger.info("Trying to set primary image for item {}", id) + return service.setPrimaryImage(id, imageId) + .map(Const::messageHandler) + } // items/{id}/borrow/ POST 借用物品 @PostMapping("/{id}/borrow", "/{id}/borrow/") - fun borrowItem(@PathVariable("id") id: String): Mono = Const.INTERNAL_ERROR_MONO + fun borrowItem( + @PathVariable("id") id: Long, + @RequestParam(defaultValue = "1") quantity: Int + ): Mono> { + logger.info("Trying to borrow item {}", id) + return service.borrowItem(id, quantity) + .map(Const::messageHandler) + } // items/{id}/return_item/ POST 归还物品 @PostMapping("/{id}/return_item", "/{id}/return_item/") - fun returnItem(@PathVariable("id") id: String): Mono = Const.INTERNAL_ERROR_MONO + fun returnItem( + @PathVariable("id") id: Long, + @RequestParam(defaultValue = "1") quantity: Int + ): Mono> { + logger.info("Trying to return item {}", id) + return service.returnItem(id, quantity) + .map(Const::messageHandler) + } // items/available/ GET 获取可用物品列表 @GetMapping("/available", "/available/") - fun getAvailableItems(): Mono = Const.INTERNAL_ERROR_MONO + fun getAvailableItems(): Mono> { + logger.info("Trying to list available items") + return service.getAvailableItems() + .map { ApiResponse.success(it) } + } // items/in_use/ GET 获取使用中的物品列表 @GetMapping("/in_use", "/in_use/") - fun getInUseItems(): Mono = Const.INTERNAL_ERROR_MONO + fun getInUseItems(): Mono> { + logger.info("Trying to list in-use items") + return service.getInUseItems() + .map { ApiResponse.success(it) } + } // items/categories/ GET/POST/PUT/DELETE 物品类别 CRUD 操作 @GetMapping("/categories", "/categories/") - fun getItemCategories(): Mono = Const.INTERNAL_ERROR_MONO + fun getItemCategories(): Mono> { + logger.info("Trying to list item categories") + return service.getItemCategories() + .map { ApiResponse.success(it) } + } @PostMapping("/categories", "/categories/") - fun createItemCategory(): Mono = Const.INTERNAL_ERROR_MONO + fun createItemCategory(@RequestBody @Valid category: Category): Mono> { + logger.info("Trying to create item category") + return service.createItemCategory(category) + .map(Const::messageHandler) + } @PutMapping("/categories", "/categories/") - fun updateItemCategory(): Mono = Const.INTERNAL_ERROR_MONO + fun updateItemCategory(@RequestBody @Valid category: Category): Mono> { + logger.info("Trying to update item category") + return service.updateItemCategory(category) + .map(Const::messageHandler) + } @DeleteMapping("/categories", "/categories/") - fun deleteItemCategory(@RequestParam(required = false) id: String?): Mono = Const.INTERNAL_ERROR_MONO + fun deleteItemCategory(@RequestParam(required = false) id: Long?): Mono> { + logger.info("Trying to delete item category") + return service.deleteItemCategory(id) + .map(Const::messageHandler) + } // items/images/ GET/POST/PUT/DELETE 物品图片 CRUD 操作 @GetMapping("/images", "/images/") - fun getItemImages(): Mono = Const.INTERNAL_ERROR_MONO + fun getItemImages(): Mono> { + logger.info("Trying to list item images") + return service.getItemImages() + .map { ApiResponse.success(it) } + } @PostMapping("/images", "/images/") - fun createItemImage(): Mono = Const.INTERNAL_ERROR_MONO + fun createItemImage(@RequestBody @Valid attachment: Attachment): Mono> { + logger.info("Trying to create item image") + return service.createItemImage(attachment) + .map(Const::messageHandler) + } @PutMapping("/images", "/images/") - fun updateItemImage(): Mono = Const.INTERNAL_ERROR_MONO + fun updateItemImage(@RequestBody @Valid attachment: Attachment): Mono> { + logger.info("Trying to update item image") + return service.updateItemImage(attachment) + .map(Const::messageHandler) + } @DeleteMapping("/images", "/images/") - fun deleteItemImage(@RequestParam(required = false) id: String?): Mono = Const.INTERNAL_ERROR_MONO + fun deleteItemImage(@RequestParam(required = false) id: Long?): Mono> { + logger.info("Trying to delete item image") + return service.deleteItemImage(id) + .map(Const::messageHandler) + } } diff --git a/src/beckend/src/main/kotlin/club/itstudio/itmanager/entity/dto/ItemTransaction.kt b/src/beckend/src/main/kotlin/club/itstudio/itmanager/entity/dto/ItemTransaction.kt index 4fc78ec..b618ab6 100644 --- a/src/beckend/src/main/kotlin/club/itstudio/itmanager/entity/dto/ItemTransaction.kt +++ b/src/beckend/src/main/kotlin/club/itstudio/itmanager/entity/dto/ItemTransaction.kt @@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.annotation.IdType import com.baomidou.mybatisplus.annotation.TableId import com.baomidou.mybatisplus.annotation.TableName import club.itstudio.itmanager.entity.DataCopy +import club.itstudio.itmanager.entity.enums.TransactionType import kotlinx.datetime.LocalDateTime import kotlinx.serialization.Serializable @@ -13,7 +14,7 @@ data class ItemTransaction( @TableId(type = IdType.AUTO) val id: Long? = null, val itemId: Long = 0, - val transType: String = "", // IN / OUT / ADJUST / TRANSFER + val transType: TransactionType = TransactionType.IN, val quantity: Int = 0, val fromLocation: String? = null, val toLocation: String? = null, diff --git a/src/beckend/src/main/kotlin/club/itstudio/itmanager/entity/enums/TransactionType.kt b/src/beckend/src/main/kotlin/club/itstudio/itmanager/entity/enums/TransactionType.kt new file mode 100644 index 0000000..1a775e2 --- /dev/null +++ b/src/beckend/src/main/kotlin/club/itstudio/itmanager/entity/enums/TransactionType.kt @@ -0,0 +1,19 @@ +package club.itstudio.itmanager.entity.enums + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +enum class TransactionType { + @SerialName("IN") + IN, + + @SerialName("OUT") + OUT, + + @SerialName("ADJUST") + ADJUST, + + @SerialName("TRANSFER") + TRANSFER, +} diff --git a/src/beckend/src/main/kotlin/club/itstudio/itmanager/service/v1/ItemsApiService.kt b/src/beckend/src/main/kotlin/club/itstudio/itmanager/service/v1/ItemsApiService.kt index 127d375..6d3bdd4 100644 --- a/src/beckend/src/main/kotlin/club/itstudio/itmanager/service/v1/ItemsApiService.kt +++ b/src/beckend/src/main/kotlin/club/itstudio/itmanager/service/v1/ItemsApiService.kt @@ -4,6 +4,7 @@ import club.itstudio.itmanager.entity.dto.Attachment import club.itstudio.itmanager.entity.dto.Category import club.itstudio.itmanager.entity.dto.Item import club.itstudio.itmanager.entity.dto.ItemTransaction +import club.itstudio.itmanager.entity.enums.TransactionType import club.itstudio.itmanager.service.AttachmentService import club.itstudio.itmanager.service.CategoryService import club.itstudio.itmanager.service.ItemService @@ -15,12 +16,339 @@ 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.Flux 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.UUID @Service -class ItemsApiService \ No newline at end of file +class ItemsApiService( + private val itemService: ItemService, + private val categoryService: CategoryService, + private val attachmentService: AttachmentService, + private val transactionService: ItemTransactionService, + + @Value($$"${app.file.data-path}") + private val dataPath: String +) { + private val logger = LoggerFactory.getLogger(this::class.java) + + fun getItems(page: Page, keyword: String?, status: String?): Mono> { + logger.info("Listing items with keyword and status") + return itemService.pageItems(page, keyword, status) + } + + fun createItem(item: Item): Mono { + logger.info("Creating item") + val rightNow = getCurrentDateTime() + return itemService.createItem( + item.copy( + id = null, + createdAt = rightNow, + updatedAt = rightNow + ) + ).flatMap { success -> + if (success) Mono.just("") + else Mono.just("item create failed.") + } + } + + fun updateItem(item: Item): Mono { + logger.info("Updating item") + if (item.id == null) return Mono.just("item id is required.") + return itemService.updateItem( + item.copy(updatedAt = getCurrentDateTime()) + ).flatMap { success -> + if (success) Mono.just("") + else Mono.just("item update failed.") + } + } + + fun deleteItem(id: Long?): Mono { + logger.info("Deleting item with id {}", id) + if (id == null) return Mono.just("item id is required.") + return itemService.removeItemById(id).flatMap { success -> + if (success) { + cleanupItemAttachments(id) + } else { + Mono.just("item delete failed.") + } + } + } + + fun uploadImages(id: Long, files: Flux): Mono { + logger.info("Uploading images for item {}", id) + return itemService.getItemById(id) + .flatMap { item -> + files.flatMap { file -> saveItemImage(id, file) } + .collectList() + .flatMap { attachments -> + if (attachments.isEmpty()) { + Mono.just("no image files provided.") + } else { + Flux.fromIterable(attachments) + .flatMap { attachmentService.createAttachment(it) } + .collectList() + .flatMap { results -> + if (results.any { !it }) { + Mono.just("item image upload failed.") + } else { + setPrimaryImageIfBlank(item, attachments.first(), id) + } + } + } + } + } + .onErrorResume(NoSuchElementException::class.java) { Mono.just("item not found.") } + .onErrorResume { Mono.just(Const.INTERNAL_ERROR_STR) } + } + + fun setPrimaryImage(id: Long, imageId: Long): Mono { + logger.info("Setting primary image for item {}", id) + return itemService.getItemById(id) + .flatMap { _ -> + attachmentService.getAttachmentById(imageId) + .flatMap { attachment -> + if (attachment.relatedTable == "items" && attachment.relatedId == id) { + val path = attachment.filePath + if (path.isNullOrBlank()) { + Mono.just("image file path is empty.") + } else { + itemService.setPrimaryImage(id, path).flatMap { success -> + if (success) Mono.just("") + else Mono.just("set primary image failed.") + } + } + } else { + Mono.just("image does not belong to this item.") + } + } + .onErrorResume(NoSuchElementException::class.java) { _ -> Mono.just("image not found.") } + } + .onErrorResume(NoSuchElementException::class.java) { _ -> Mono.just("item not found.") } + .onErrorResume { _ -> Mono.just(Const.INTERNAL_ERROR_STR) } + } + + fun borrowItem(id: Long, quantity: Int): Mono { + logger.info("Borrowing item {} with quantity {}", id, quantity) + if (quantity <= 0) return Mono.just("quantity must be positive.") + return itemService.getItemById(id) + .flatMap { item -> + itemService.borrowItem(id, quantity) + .flatMap { success -> + if (success) { + transactionService.createTransaction( + ItemTransaction( + itemId = id, + transType = TransactionType.OUT, + quantity = quantity, + fromLocation = item.location, + createdAt = getCurrentDateTime() + ) + ).onErrorResume { error -> + logger.warn("Failed to record OUT transaction for item {}: {}", id, error.message) + Mono.just(false) + }.thenReturn("") + } else { + Mono.just("item available quantity is not enough.") + } + } + } + .onErrorResume(NoSuchElementException::class.java) { Mono.just("item not found.") } + .onErrorResume { Mono.just(Const.INTERNAL_ERROR_STR) } + } + + fun returnItem(id: Long, quantity: Int): Mono { + logger.info("Returning item {} with quantity {}", id, quantity) + if (quantity <= 0) return Mono.just("quantity must be positive.") + return itemService.getItemById(id) + .flatMap { item -> + itemService.returnItem(id, quantity) + .flatMap { success -> + if (success) { + transactionService.createTransaction( + ItemTransaction( + itemId = id, + transType = TransactionType.IN, + quantity = quantity, + toLocation = item.location, + createdAt = getCurrentDateTime() + ) + ).onErrorResume { error -> + logger.warn("Failed to record IN transaction for item {}: {}", id, error.message) + Mono.just(false) + }.thenReturn("") + } else { + Mono.just("item cannot be returned with the given quantity.") + } + } + } + .onErrorResume(NoSuchElementException::class.java) { Mono.just("item not found.") } + .onErrorResume { Mono.just(Const.INTERNAL_ERROR_STR) } + } + + fun getAvailableItems(): Mono> { + logger.info("Listing available items") + return itemService.listAvailableItems() + } + + fun getInUseItems(): Mono> { + logger.info("Listing in-use items") + return itemService.listInUseItems() + } + + fun getItemCategories(): Mono> { + logger.info("Listing item categories") + return categoryService.listAllCategories() + } + + fun createItemCategory(category: Category): Mono { + logger.info("Creating item category") + return categoryService.createCategory( + category.copy( + id = null, + createdAt = getCurrentDateTime(), + updatedAt = getCurrentDateTime() + ) + ).flatMap { success -> + if (success) Mono.just("") + else Mono.just("category create failed.") + } + } + + fun updateItemCategory(category: Category): Mono { + logger.info("Updating item category") + if (category.id == null) return Mono.just("category id is required.") + return categoryService.updateCategory( + category.copy(updatedAt = getCurrentDateTime()) + ).flatMap { success -> + if (success) Mono.just("") + else Mono.just("category update failed.") + } + } + + fun deleteItemCategory(id: Long?): Mono { + logger.info("Deleting item category with id {}", id) + if (id == null) return Mono.just("category id is required.") + return categoryService.deleteCategory(id).flatMap { success -> + if (success) Mono.just("") + else Mono.just("category delete failed.") + } + } + + fun getItemImages(): Mono> { + logger.info("Listing item images") + return attachmentService.exportAllAttachments() + .map { list -> list.filter { it.relatedTable == "items" } } + } + + fun createItemImage(attachment: Attachment): Mono { + logger.info("Creating item image") + return attachmentService.createAttachment( + attachment.copy( + id = null, + uploadedAt = getCurrentDateTime() + ) + ).flatMap { success -> + if (success) Mono.just("") + else Mono.just("item image create failed.") + } + } + + fun updateItemImage(attachment: Attachment): Mono { + logger.info("Updating item image") + if (attachment.id == null) return Mono.just("item image id is required.") + return Mono.fromCallable { attachmentService.updateById(attachment) } + .subscribeOn(Schedulers.boundedElastic()) + .flatMap { success -> + if (success) Mono.just("") + else Mono.just("item image update failed.") + } + } + + fun deleteItemImage(id: Long?): Mono { + logger.info("Deleting item image with id {}", id) + if (id == null) return Mono.just("item image id is required.") + return attachmentService.getAttachmentById(id) + .flatMap { attachment -> + attachmentService.deleteAttachment(id).flatMap { success -> + if (success) { + deletePhysicalFile(attachment.filePath).thenReturn("") + } else { + Mono.just("item image delete failed.") + } + } + } + .onErrorResume(NoSuchElementException::class.java) { Mono.just("item image not found.") } + .onErrorResume { Mono.just(Const.INTERNAL_ERROR_STR) } + } + + private fun setPrimaryImageIfBlank(item: Item, attachment: Attachment, id: Long): Mono { + if (!item.imagePath.isNullOrBlank()) return Mono.just("") + val path = attachment.filePath ?: return Mono.just("") + return itemService.setPrimaryImage(id, path).flatMap { success -> + if (success) Mono.just("") + else Mono.just("set primary image failed.") + } + } + + private fun saveItemImage(itemId: Long, file: FilePart): Mono { + val directory = Paths.get(dataPath, Const.FILE_DIR_IMAGE, "items", itemId.toString()) + val savedName = generateSafeFileName(file.filename()) + val target = directory.resolve(savedName) + return Mono.fromCallable { + Files.createDirectories(directory) + file.transferTo(target) + Attachment( + relatedTable = "items", + relatedId = itemId, + fileName = savedName, + 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 { + return attachmentService.listByRelated("items", itemId) + .flatMap { attachments -> + Flux.fromIterable(attachments) + .flatMap { deletePhysicalFile(it.filePath) } + .then(attachmentService.deleteByRelated("items", itemId)) + .thenReturn("") + } + .onErrorResume { error -> + logger.warn("Failed to clean up attachments for item {}: {}", itemId, error.message) + Mono.just("") + } + } + + private fun deletePhysicalFile(path: String?): Mono { + if (path.isNullOrBlank()) return Mono.empty() + return Mono.fromRunnable { + try { + Files.deleteIfExists(Paths.get(path)) + } catch (e: IOException) { + logger.warn("Failed to delete file: {}", path, e) + } + }.subscribeOn(Schedulers.boundedElastic()) + } +}