From 8262b3469fcffbb6becda53a0e7a75c3bb9b0af1 Mon Sep 17 00:00:00 2001 From: SOR <3272194354@qq.com> Date: Sun, 9 Aug 2026 20:51:28 +0800 Subject: [PATCH] feat: first version of backend API --- API.md | 353 +++++++++++++++++- README.md | 74 +--- .../controller/v1/EmailController.kt | 66 ++-- .../controller/v1/FinanceController.kt | 157 +++++--- .../controller/v1/ProofImagesController.kt | 86 +++-- .../controller/v1/UsagesController.kt | 165 +++++--- .../itmanager/entity/dto/FinanceRecord.kt | 3 +- .../itmanager/entity/enums/EmailType.kt | 3 + .../entity/enums/FinanceRecordType.kt | 22 ++ .../itmanager/listener/MailQueueListener.kt | 6 + .../itmanager/service/v1/EmailApiService.kt | 56 +++ .../itmanager/service/v1/FinanceApiService.kt | 288 ++++++++++++++ .../service/v1/ProofImagesApiService.kt | 84 +++++ .../itmanager/service/v1/UsagesApiService.kt | 234 ++++++++++++ 14 files changed, 1380 insertions(+), 217 deletions(-) create mode 100644 src/backend/src/main/kotlin/club/itstudio/itmanager/entity/enums/FinanceRecordType.kt create mode 100644 src/backend/src/main/kotlin/club/itstudio/itmanager/service/v1/EmailApiService.kt create mode 100644 src/backend/src/main/kotlin/club/itstudio/itmanager/service/v1/FinanceApiService.kt create mode 100644 src/backend/src/main/kotlin/club/itstudio/itmanager/service/v1/ProofImagesApiService.kt create mode 100644 src/backend/src/main/kotlin/club/itstudio/itmanager/service/v1/UsagesApiService.kt diff --git a/API.md b/API.md index cc1ad0d..313cfb9 100644 --- a/API.md +++ b/API.md @@ -22,11 +22,15 @@ class Response { ## 接口总览 -| 分组 | 路径前缀 | 说明 | -|----------|-------------|-------------------------------------------| -| 测试 | `/test` | 连通性测试 | -| 认证 | `/v1/auth` | 登录 / 注册 / 验证码 / Token 管理 | -| 物品管理 | `/v1/items` | 物品 / 类别 / 图片 CRUD、借还、上下架查询 | +| 分组 | 路径前缀 | 说明 | +|--------------|--------------------|-------------------------------------------| +| 测试 | `/test` | 连通性测试 | +| 认证 | `/v1/auth` | 登录 / 注册 / 验证码 / Token 管理 | +| 物品管理 | `/v1/items` | 物品 / 类别 / 图片 CRUD、借还、上下架查询 | +| 使用记录 | `/v1/usages` | 使用记录 CRUD、图片、当前/按用户查询 | +| 财务管理 | `/v1/finance` | 财务记录 / 分类 CRUD、凭证图片上传 | +| 财务凭证图片 | `/v1/proof-images` | 财务凭证图片 CRUD | +| 邮件通知 | `/v1/email` | 通知设置、收件邮箱启用状态 | --- @@ -34,7 +38,7 @@ class Response { ### GET /hello -公开接口。 +需要认证。 **响应** @@ -57,6 +61,8 @@ class Response { ### POST /login +以 `application/x-www-form-urlencoded` 表单提交。 + **请求参数** | 字段 | 类型 | 说明 | @@ -113,7 +119,7 @@ class data { ```typescript class data { "token": string; - "expire": time; + "expire": Date; } ``` @@ -344,6 +350,339 @@ class Page { --- +## 分组:使用记录 `/v1/usages` + +| 方法 | 路径 | 认证 | 说明 | +|--------|------------------------------|------|--------------------------------------| +| GET | `/usages` | 需要 | 分页查询使用记录 | +| POST | `/usages` | 需要 | 创建使用记录 | +| PUT | `/usages` | 需要 | 更新使用记录 | +| DELETE | `/usages` | 需要 | 删除使用记录 | +| POST | `/usages/{id}/upload_images` | 需要 | 上传使用记录图片(multipart) | +| GET | `/usages/current` | 需要 | 获取当前使用中的记录 | +| GET | `/usages/by_user` | 需要 | 根据用户姓名获取使用记录 | +| GET | `/usages/images` | 需要 | 查询使用记录图片列表 | +| POST | `/usages/images` | 需要 | 创建使用记录图片 | +| PUT | `/usages/images` | 需要 | 更新使用记录图片 | +| DELETE | `/usages/images` | 需要 | 删除使用记录图片(同时删除物理文件) | + +### 实体定义 + +```typescript +class BorrowRecord { + "id": number | null; + "itemId": number; + "borrower": string; + "quantity": number; + "borrowDate": string | null; + "dueDate": string | null; + "returnDate": string | null; + "status": string; // BORROWED / RETURNED / OVERDUE / CANCELED + "approver": string | null; + "note": string | null; + "createdAt": string | null; + "updatedAt": string | null; +} +``` + +### GET /usages + +**请求参数** + +| 字段 | 类型 | 说明 | +|----------|---------|---------------------| +| pageNum | number | 页码,默认 `1` | +| pageSize | number | 每页条数,默认 `10` | +| keyword | string? | 按借入人模糊搜索 | +| status | string? | 按状态过滤 | + +**响应 data**:`Page` + +### POST /usages + +**请求体**:`BorrowRecord`(`id` / `createdAt` / `updatedAt` 由服务端填充) + +**响应 data**:`null` + +### PUT /usages + +**请求体**:`BorrowRecord`(须包含 `id`) + +**响应 data**:`null` + +### DELETE /usages + +**请求参数** + +| 字段 | 类型 | 说明 | +|------|--------|---------| +| id | number | 记录 ID | + +**响应 data**:`null` + +### POST /usages/{id}/upload_images + +**请求**:`multipart/form-data`,字段名 `files`,可传多个图片文件 + +**响应 data**:`null` + +### GET /usages/current + +**请求参数**:无 + +**响应 data**:`BorrowRecord[]`(当前 `BORROWED` 与 `OVERDUE` 记录) + +### GET /usages/by_user + +**请求参数** + +| 字段 | 类型 | 说明 | +|------|---------|------------| +| name | string? | 借入人姓名 | + +**响应 data**:`BorrowRecord[]` + +### GET /usages/images + +**请求参数**:无 + +**响应 data**:`Attachment[]`(`relatedTable` 为 `"borrow_records"` 的图片) + +### POST /usages/images + +**请求体**:`Attachment`(`id` / `uploadedAt` 由服务端填充) + +**响应 data**:`null` + +### PUT /usages/images + +**请求体**:`Attachment`(须包含 `id`) + +**响应 data**:`null` + +### DELETE /usages/images + +**请求参数** + +| 字段 | 类型 | 说明 | +|------|--------|----------| +| id | number | 图片 ID | + +**响应 data**:`null` + +--- + +## 分组:财务管理 `/v1/finance` + +| 方法 | 路径 | 认证 | 说明 | +|--------|-------------------------------|------|---------------------------------------| +| GET | `/finance` | 需要 | 分页查询财务记录 | +| POST | `/finance` | 需要 | 创建财务记录 | +| PUT | `/finance` | 需要 | 更新财务记录 | +| DELETE | `/finance` | 需要 | 删除财务记录 | +| POST | `/finance/{id}/upload_images` | 需要 | 上传财务凭证图片(multipart) | +| DELETE | `/finance/{id}` | 需要 | 删除财务记录(发送邮件通知) | +| GET | `/finance/categories` | 需要 | 查询财务分类列表 | +| POST | `/finance/categories` | 需要 | 创建财务分类 | +| PUT | `/finance/categories` | 需要 | 更新财务分类 | +| DELETE | `/finance/categories` | 需要 | 删除财务分类 | + +### 实体定义 + +```typescript +class FinanceRecord { + "id": number | null; + "recordNo": string | null; + "type": string; // PURCHASE / REFUND / REPAIR / DEPRECIATION / OTHER + "relatedItemId": number | null; + "amount": number; // BigDecimal + "currency": string; // 默认 "CNY" + "vendor": string | null; + "occurredAt": string | null; + "recordedBy": number | null; + "note": string | null; + "createdAt": string | null; +} +``` + +### GET /finance + +**请求参数** + +| 字段 | 类型 | 说明 | +|----------|---------|--------------------------------| +| pageNum | number | 页码,默认 `1` | +| pageSize | number | 每页条数,默认 `10` | +| keyword | string? | 按单号 / 供应商 / 备注模糊搜索 | +| type | string? | 按类型过滤 | +| start | string? | 发生时间起点 | +| end | string? | 发生时间终点 | +| vendor | string? | 按供应商过滤 | +| itemId | number? | 按关联物品 ID 过滤 | + +**响应 data**:`Page` + +### POST /finance + +**请求体**:`FinanceRecord`(`id` / `createdAt` 由服务端填充) + +**响应 data**:`null` + +### PUT /finance + +**请求体**:`FinanceRecord`(须包含 `id`) + +**响应 data**:`null` + +### DELETE /finance + +**请求参数** + +| 字段 | 类型 | 说明 | +|------|--------|---------| +| id | number | 记录 ID | + +**响应 data**:`null` + +### POST /finance/{id}/upload_images + +**请求**:`multipart/form-data`,字段名 `files`,可传多个图片文件 + +**响应 data**:`null` + +### DELETE /finance/{id} + +删除财务记录,并向 `recordedBy` 对应账号的邮箱发送删除通知。 + +**响应 data**:`null` + +### GET /finance/categories + +**请求参数**:无 + +**响应 data**:`Category[]` + +### POST /finance/categories + +**请求体**:`Category`(`id` / `createdAt` / `updatedAt` 由服务端填充) + +**响应 data**:`null` + +### PUT /finance/categories + +**请求体**:`Category`(须包含 `id`) + +**响应 data**:`null` + +### DELETE /finance/categories + +**请求参数** + +| 字段 | 类型 | 说明 | +|------|--------|----------| +| id | number | 分类 ID | + +**响应 data**:`null` + +--- + +## 分组:财务凭证图片 `/v1/proof-images` + +| 方法 | 路径 | 认证 | 说明 | +|--------|------------------------|------|-----------------------------------| +| GET | `/proof-images` | 需要 | 查询财务凭证图片列表 | +| POST | `/proof-images` | 需要 | 创建财务凭证图片 | +| PUT | `/proof-images` | 需要 | 更新财务凭证图片 | +| DELETE | `/proof-images` | 需要 | 删除财务凭证图片 | +| DELETE | `/proof-images/{id}` | 需要 | 删除凭证图片(同时删除物理文件) | + +凭证图片为 `relatedTable` 为 `"finance_records"` 的 `Attachment`,与 `/items/images` 共用 `Attachment` 实体。 + +### GET /proof-images + +**请求参数**:无 + +**响应 data**:`Attachment[]` + +### POST /proof-images + +**请求体**:`Attachment`(`id` / `uploadedAt` 由服务端填充) + +**响应 data**:`null` + +### PUT /proof-images + +**请求体**:`Attachment`(须包含 `id`) + +**响应 data**:`null` + +### DELETE /proof-images + +**请求参数** + +| 字段 | 类型 | 说明 | +|------|--------|----------| +| id | number | 图片 ID | + +**响应 data**:`null` + +### DELETE /proof-images/{id} + +删除图片并清理物理文件。 + +**响应 data**:`null` + +--- + +## 分组:邮件通知 `/v1/email` + +| 方法 | 路径 | 认证 | 说明 | +|--------|--------------------------|------|-------------------------| +| GET | `/notification-settings` | 需要 | 获取通知设置和邮箱列表 | +| POST | `/notification-settings` | 需要 | 创建 / 更新通知设置 | +| POST | `/toggle-email-status` | 需要 | 切换邮箱启用 / 禁用状态 | + +### 实体定义 + +```typescript +class EmailNotice { + "id": number | null; + "name": string | null; + "targetEmails": string | null; + "subject": string | null; + "body": string | null; + "enabled": boolean; // 默认 true + "scheduleCron": string | null; + "lastSentAt": string | null; + "createdAt": string | null; + "updatedAt": string | null; +} +``` + +### GET /notification-settings + +**请求参数**:无 + +**响应 data**:`EmailNotice[]` + +### POST /notification-settings + +**请求体**:`EmailNotice`。`id` 为 `null` 时创建新记录,否则更新已有记录。 + +**响应 data**:`null` + +### POST /toggle-email-status + +**请求参数** + +| 字段 | 类型 | 说明 | +|------|---------|----------| +| id | number? | 通知 ID | + +**响应 data**:`null` + +--- + ## 其他 ### WebSocket diff --git a/README.md b/README.md index bfa3b73..2434baa 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,13 @@ - /register - /reset - /email + - /notification-settings + - /toggle-email-status - /finance + - / + - /{id}/upload_images + - /{id} + - /categories - /items - / - /{id}/upload_images @@ -28,68 +34,14 @@ - /categories - /images - /proof-images + - / + - /{id} - /usages - -## 预计完成的 API 列表 - -### 1. **物品管理 (Items)** - -| 端点 | 方法 | 功能说明 | -|----------------------------------------------|---------------------|--------------------------| -| `items/` | GET/POST/PUT/DELETE | 物品 CRUD 操作 | -| `items/{id}/upload_images/` | POST | 上传物品图片 | -| `items/{id}/set_primary_image/` | POST | 设置主图片 | -| `items/{id}/borrow/` | POST | 借用物品 | -| `items/{id}/return_item/` | POST | 归还物品 | -| `items/available/` | GET | 获取可用物品列表 | -| `items/in_use/` | GET | 获取使用中的物品列表 | -| `usages/` | GET/POST/PUT/DELETE | 物品使用记录 CRUD 操作 | -| `usages/{id}/upload_images/` | POST | 上传使用记录图片 | -| `usages/current/` | GET | 获取当前使用中的记录 | -| `usages/by_user/` | GET | 根据用户姓名获取使用记录 | -| `items/categories/`(原 `item_categories/`) | GET/POST/PUT/DELETE | 物品类别 CRUD 操作 | -| `users/` | GET | 获取用户列表(只读) | -| `items/images/`(原 `item-images/`) | GET/POST/PUT/DELETE | 物品图片 CRUD 操作 | -| `usages/images/`(原 `usage-images/`) | GET/POST/PUT/DELETE | 使用记录图片 CRUD 操作 | - ---- - -### 3. **财务管理 (Finance)** - -| 端点 | 方法 | 功能说明 | -|---------------------------------------------------|---------------------|----------------------------------| -| `finance/` | GET/POST/PUT/DELETE | 财务记录 CRUD 操作 | -| `finance/{id}/upload_images/` | POST | 上传财务凭证图片 | -| `finance/{id}/` | DELETE | 删除财务记录(发送邮件通知) | -| `finance/categories/`(原 `finance_categories/`) | GET/POST/PUT/DELETE | 财务分类 CRUD 操作 | -| `proof-images/` | GET/POST/PUT/DELETE | 财务凭证图片 CRUD 操作 | -| `proof-images/{id}/` | DELETE | 删除凭证图片(自动删除物理文件) | - ---- - -### 4. **邮件通知 (Email Notice)** - -| 端点 | 方法 | 功能说明 | -|--------------------------|----------|-----------------------------| -| `notification-settings/` | GET/POST | 获取/更新通知设置和邮箱列表 | -| `toggle-email-status/` | POST | 切换邮箱启用/禁用状态 | - -**功能**: 管理邮件通知的全局开关和收件人列表 - ---- - -### 5. **认证相关** - -这是原先的 Django REST Framework JWT 认证端点,主要用于获取和刷新 JWT 令牌。 -现在改用 `/api/v1/auth/` 端点来处理登录、注册、重置密码等操作。 - -| 端点 | 方法 | 功能说明 | -|----------------------|----------|-----------------| -| `api/token/` | POST | JWT 令牌获取 | -| `api/token/refresh/` | POST | JWT 令牌刷新 | -| `admin/` | GET/POST | Django 管理后台 | - ---- + - / + - /{id}/upload_images + - /current + - /by_user + - /images #### 认证方式 diff --git a/src/backend/src/main/kotlin/club/itstudio/itmanager/controller/v1/EmailController.kt b/src/backend/src/main/kotlin/club/itstudio/itmanager/controller/v1/EmailController.kt index 47374a0..f3318d2 100644 --- a/src/backend/src/main/kotlin/club/itstudio/itmanager/controller/v1/EmailController.kt +++ b/src/backend/src/main/kotlin/club/itstudio/itmanager/controller/v1/EmailController.kt @@ -1,23 +1,43 @@ -package club.itstudio.itmanager.controller.v1 - -import club.itstudio.itmanager.utils.Const -import org.springframework.validation.annotation.Validated -import org.springframework.web.bind.annotation.* -import reactor.core.publisher.Mono - -@Validated -@RestController -@RequestMapping("/api/v1/email") -class EmailController { - - // notification-settings/ GET/POST 获取/更新通知设置和邮箱列表 - @GetMapping("/notification-settings", "/notification-settings/") - fun getNotificationSettings(): Mono = Const.INTERNAL_ERROR_MONO - - @PostMapping("/notification-settings", "/notification-settings/") - fun updateNotificationSettings(): Mono = Const.INTERNAL_ERROR_MONO - - // toggle-email-status/ POST 切换邮箱启用/禁用状态 - @PostMapping("/toggle-email-status", "/toggle-email-status/") - fun toggleEmailStatus(): Mono = Const.INTERNAL_ERROR_MONO -} \ No newline at end of file +package club.itstudio.itmanager.controller.v1 + +import club.itstudio.itmanager.entity.ApiResponse +import club.itstudio.itmanager.entity.dto.EmailNotice +import club.itstudio.itmanager.service.v1.EmailApiService +import club.itstudio.itmanager.utils.Const +import jakarta.validation.Valid +import org.slf4j.LoggerFactory +import org.springframework.validation.annotation.Validated +import org.springframework.web.bind.annotation.* +import reactor.core.publisher.Mono + +@Validated +@RestController +@RequestMapping("/api/v1/email") +class EmailController( + private val service: EmailApiService +) { + private val logger = LoggerFactory.getLogger(this::class.java) + + // notification-settings/ GET/POST 获取/更新通知设置和邮箱列表 + @GetMapping("/notification-settings", "/notification-settings/") + fun getNotificationSettings(): Mono> { + logger.info("Trying to list notification settings") + return service.getNotificationSettings() + .map { ApiResponse.success(it) } + } + + @PostMapping("/notification-settings", "/notification-settings/") + fun updateNotificationSettings(@RequestBody @Valid notice: EmailNotice): Mono> { + logger.info("Trying to update notification settings") + return service.updateNotificationSettings(notice) + .map(Const::messageHandler) + } + + // toggle-email-status/ POST 切换邮箱启用/禁用状态 + @PostMapping("/toggle-email-status", "/toggle-email-status/") + fun toggleEmailStatus(@RequestParam(required = false) id: Long?): Mono> { + logger.info("Trying to toggle email status") + return service.toggleEmailStatus(id) + .map(Const::messageHandler) + } +} diff --git a/src/backend/src/main/kotlin/club/itstudio/itmanager/controller/v1/FinanceController.kt b/src/backend/src/main/kotlin/club/itstudio/itmanager/controller/v1/FinanceController.kt index 58c3d00..eb09c7f 100644 --- a/src/backend/src/main/kotlin/club/itstudio/itmanager/controller/v1/FinanceController.kt +++ b/src/backend/src/main/kotlin/club/itstudio/itmanager/controller/v1/FinanceController.kt @@ -1,46 +1,111 @@ -package club.itstudio.itmanager.controller.v1 - -import club.itstudio.itmanager.utils.Const -import org.springframework.validation.annotation.Validated -import org.springframework.web.bind.annotation.* -import reactor.core.publisher.Mono - -@Validated -@RestController -@RequestMapping("/api/v1/finance") -class FinanceController { - - // finance/ GET/POST/PUT/DELETE 财务记录 CRUD 操作 - @GetMapping("", "/") - fun getFinanceRecords(): Mono = Const.INTERNAL_ERROR_MONO - - @PostMapping("", "/") - fun createFinanceRecord(): Mono = Const.INTERNAL_ERROR_MONO - - @PutMapping("", "/") - fun updateFinanceRecord(): Mono = Const.INTERNAL_ERROR_MONO - - @DeleteMapping("", "/") - fun deleteFinanceRecord(@RequestParam(required = false) id: String?): Mono = Const.INTERNAL_ERROR_MONO - - // finance/{id}/upload_images/ POST 上传财务凭证图片 - @PostMapping("/{id}/upload_images", "/{id}/upload_images/") - fun uploadImages(@PathVariable("id") id: String): Mono = Const.INTERNAL_ERROR_MONO - - // finance/{id}/ DELETE 删除财务记录(发送邮件通知) - @DeleteMapping("/{id}", "/{id}/") - fun deleteById(@PathVariable("id") id: String): Mono = Const.INTERNAL_ERROR_MONO - - // finance/categories/ GET/POST/PUT/DELETE 财务分类 CRUD 操作 - @GetMapping("/categories", "/categories/") - fun getFinanceCategories(): Mono = Const.INTERNAL_ERROR_MONO - - @PostMapping("/categories", "/categories/") - fun createFinanceCategory(): Mono = Const.INTERNAL_ERROR_MONO - - @PutMapping("/categories", "/categories/") - fun updateFinanceCategory(): Mono = Const.INTERNAL_ERROR_MONO - - @DeleteMapping("/categories", "/categories/") - fun deleteFinanceCategory(@RequestParam(required = false) id: String?): Mono = Const.INTERNAL_ERROR_MONO -} \ No newline at end of file +package club.itstudio.itmanager.controller.v1 + +import club.itstudio.itmanager.entity.ApiResponse +import club.itstudio.itmanager.entity.dto.Category +import club.itstudio.itmanager.entity.dto.FinanceRecord +import club.itstudio.itmanager.service.v1.FinanceApiService +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/finance") +class FinanceController( + private val service: FinanceApiService +) { + private val logger = LoggerFactory.getLogger(this::class.java) + + // finance/ GET/POST/PUT/DELETE 财务记录 CRUD 操作 + @GetMapping("", "/") + fun getFinanceRecords( + @RequestParam(defaultValue = "1") pageNum: Long, + @RequestParam(defaultValue = "10") pageSize: Long, + @RequestParam(required = false) keyword: String?, + @RequestParam(required = false) type: String?, + @RequestParam(required = false) start: String?, + @RequestParam(required = false) end: String?, + @RequestParam(required = false) vendor: String?, + @RequestParam(required = false) itemId: Long? + ): Mono> { + logger.info("Trying to list finance records") + val page = Page(pageNum, pageSize) + return service.getFinanceRecords(page, keyword, type, start, end, vendor, itemId) + .map { ApiResponse.success(it) } + } + + @PostMapping("", "/") + fun createFinanceRecord(@RequestBody @Valid record: FinanceRecord): Mono> { + logger.info("Trying to create finance record") + return service.createFinanceRecord(record) + .map(Const::messageHandler) + } + + @PutMapping("", "/") + fun updateFinanceRecord(@RequestBody @Valid record: FinanceRecord): Mono> { + logger.info("Trying to update finance record") + return service.updateFinanceRecord(record) + .map(Const::messageHandler) + } + + @DeleteMapping("", "/") + fun deleteFinanceRecord(@RequestParam(required = false) id: Long?): Mono> { + logger.info("Trying to delete finance record") + return service.deleteFinanceRecord(id) + .map(Const::messageHandler) + } + + // finance/{id}/upload_images/ POST 上传财务凭证图片 + @PostMapping("/{id}/upload_images", "/{id}/upload_images/") + fun uploadImages( + @PathVariable("id") id: Long, + @RequestPart("files") files: Flux + ): Mono> { + logger.info("Trying to upload images for finance record {}", id) + return service.uploadImages(id, files) + .map(Const::messageHandler) + } + + // finance/{id}/ DELETE 删除财务记录(发送邮件通知) + @DeleteMapping("/{id}", "/{id}/") + fun deleteById(@PathVariable("id") id: Long): Mono> { + logger.info("Trying to delete finance record {} with email notification", id) + return service.deleteById(id) + .map(Const::messageHandler) + } + + // finance/categories/ GET/POST/PUT/DELETE 财务分类 CRUD 操作 + @GetMapping("/categories", "/categories/") + fun getFinanceCategories(): Mono> { + logger.info("Trying to list finance categories") + return service.getFinanceCategories() + .map { ApiResponse.success(it) } + } + + @PostMapping("/categories", "/categories/") + fun createFinanceCategory(@RequestBody @Valid category: Category): Mono> { + logger.info("Trying to create finance category") + return service.createFinanceCategory(category) + .map(Const::messageHandler) + } + + @PutMapping("/categories", "/categories/") + fun updateFinanceCategory(@RequestBody @Valid category: Category): Mono> { + logger.info("Trying to update finance category") + return service.updateFinanceCategory(category) + .map(Const::messageHandler) + } + + @DeleteMapping("/categories", "/categories/") + fun deleteFinanceCategory(@RequestParam(required = false) id: Long?): Mono> { + logger.info("Trying to delete finance category") + return service.deleteFinanceCategory(id) + .map(Const::messageHandler) + } +} \ No newline at end of file diff --git a/src/backend/src/main/kotlin/club/itstudio/itmanager/controller/v1/ProofImagesController.kt b/src/backend/src/main/kotlin/club/itstudio/itmanager/controller/v1/ProofImagesController.kt index a2afb2c..fc4aff7 100644 --- a/src/backend/src/main/kotlin/club/itstudio/itmanager/controller/v1/ProofImagesController.kt +++ b/src/backend/src/main/kotlin/club/itstudio/itmanager/controller/v1/ProofImagesController.kt @@ -1,29 +1,57 @@ -package club.itstudio.itmanager.controller.v1 - -import club.itstudio.itmanager.utils.Const -import org.springframework.validation.annotation.Validated -import org.springframework.web.bind.annotation.* -import reactor.core.publisher.Mono - -@Validated -@RestController -@RequestMapping("/api/v1/proof-images") -class ProofImagesController { - - // proof-images/ GET/POST/PUT/DELETE 财务凭证图片 CRUD 操作 - @GetMapping("", "/") - fun getProofImages(): Mono = Const.INTERNAL_ERROR_MONO - - @PostMapping("", "/") - fun createProofImage(): Mono = Const.INTERNAL_ERROR_MONO - - @PutMapping("", "/") - fun updateProofImage(): Mono = Const.INTERNAL_ERROR_MONO - - @DeleteMapping("", "/") - fun deleteProofImage(@RequestParam(required = false) id: String?): Mono = Const.INTERNAL_ERROR_MONO - - // proof-images/{id}/ DELETE 删除凭证图片(自动删除物理文件) - @DeleteMapping("/{id}", "/{id}/") - fun deleteById(@PathVariable("id") id: String): Mono = Const.INTERNAL_ERROR_MONO -} \ No newline at end of file +package club.itstudio.itmanager.controller.v1 + +import club.itstudio.itmanager.entity.ApiResponse +import club.itstudio.itmanager.entity.dto.Attachment +import club.itstudio.itmanager.service.v1.ProofImagesApiService +import club.itstudio.itmanager.utils.Const +import jakarta.validation.Valid +import org.slf4j.LoggerFactory +import org.springframework.validation.annotation.Validated +import org.springframework.web.bind.annotation.* +import reactor.core.publisher.Mono + +@Validated +@RestController +@RequestMapping("/api/v1/proof-images") +class ProofImagesController( + private val service: ProofImagesApiService +) { + private val logger = LoggerFactory.getLogger(this::class.java) + + // proof-images/ GET/POST/PUT/DELETE 财务凭证图片 CRUD 操作 + @GetMapping("", "/") + fun getProofImages(): Mono> { + logger.info("Trying to list proof images") + return service.getProofImages() + .map { ApiResponse.success(it) } + } + + @PostMapping("", "/") + fun createProofImage(@RequestBody @Valid attachment: Attachment): Mono> { + logger.info("Trying to create proof image") + return service.createProofImage(attachment) + .map(Const::messageHandler) + } + + @PutMapping("", "/") + fun updateProofImage(@RequestBody @Valid attachment: Attachment): Mono> { + logger.info("Trying to update proof image") + return service.updateProofImage(attachment) + .map(Const::messageHandler) + } + + @DeleteMapping("", "/") + fun deleteProofImage(@RequestParam(required = false) id: Long?): Mono> { + logger.info("Trying to delete proof image") + return service.deleteProofImage(id) + .map(Const::messageHandler) + } + + // proof-images/{id}/ DELETE 删除凭证图片(自动删除物理文件) + @DeleteMapping("/{id}", "/{id}/") + fun deleteById(@PathVariable("id") id: Long): Mono> { + logger.info("Trying to delete proof image {}", id) + return service.deleteProofImage(id) + .map(Const::messageHandler) + } +} \ No newline at end of file diff --git a/src/backend/src/main/kotlin/club/itstudio/itmanager/controller/v1/UsagesController.kt b/src/backend/src/main/kotlin/club/itstudio/itmanager/controller/v1/UsagesController.kt index 6e1692a..4d40213 100644 --- a/src/backend/src/main/kotlin/club/itstudio/itmanager/controller/v1/UsagesController.kt +++ b/src/backend/src/main/kotlin/club/itstudio/itmanager/controller/v1/UsagesController.kt @@ -1,50 +1,115 @@ -package club.itstudio.itmanager.controller.v1 - -import club.itstudio.itmanager.utils.Const -import org.springframework.validation.annotation.Validated -import org.springframework.web.bind.annotation.* -import reactor.core.publisher.Mono - -@Validated -@RestController -@RequestMapping("/api/v1/usages") -class UsagesController { - - // usages/ GET/POST/PUT/DELETE 物品使用记录 CRUD 操作 - @GetMapping("", "/") - fun getUsages(): Mono = Const.INTERNAL_ERROR_MONO - - @PostMapping("", "/") - fun createUsage(): Mono = Const.INTERNAL_ERROR_MONO - - @PutMapping("", "/") - fun updateUsage(): Mono = Const.INTERNAL_ERROR_MONO - - @DeleteMapping("", "/") - fun deleteUsage(@RequestParam(required = false) id: String?): Mono = Const.INTERNAL_ERROR_MONO - - // usages/{id}/upload_images/ POST 上传使用记录图片 - @PostMapping("/{id}/upload_images", "/{id}/upload_images/") - fun uploadImages(@PathVariable("id") id: String): Mono = Const.INTERNAL_ERROR_MONO - - // usages/current/ GET 获取当前使用中的记录 - @GetMapping("/current", "/current/") - fun getCurrentUsages(): Mono = Const.INTERNAL_ERROR_MONO - - // usages/by_user/ GET 根据用户姓名获取使用记录 - @GetMapping("/by_user", "/by_user/") - fun getUsagesByUser(@RequestParam(required = false) name: String?): Mono = Const.INTERNAL_ERROR_MONO - - // usages/images/ GET/POST/PUT/DELETE 使用记录图片 CRUD 操作 - @GetMapping("/images", "/images/") - fun getUsageImages(): Mono = Const.INTERNAL_ERROR_MONO - - @PostMapping("/images", "/images/") - fun createUsageImage(): Mono = Const.INTERNAL_ERROR_MONO - - @PutMapping("/images", "/images/") - fun updateUsageImage(): Mono = Const.INTERNAL_ERROR_MONO - - @DeleteMapping("/images", "/images/") - fun deleteUsageImage(@RequestParam(required = false) id: String?): Mono = Const.INTERNAL_ERROR_MONO -} \ No newline at end of file +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.BorrowRecord +import club.itstudio.itmanager.service.v1.UsagesApiService +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/usages") +class UsagesController( + private val service: UsagesApiService +) { + private val logger = LoggerFactory.getLogger(this::class.java) + + // usages/ GET/POST/PUT/DELETE 物品使用记录 CRUD 操作 + @GetMapping("", "/") + fun getUsages( + @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 usages") + val page = Page(pageNum, pageSize) + return service.getUsages(page, keyword, status) + .map { ApiResponse.success(it) } + } + + @PostMapping("", "/") + fun createUsage(@RequestBody @Valid record: BorrowRecord): Mono> { + logger.info("Trying to create usage") + return service.createUsage(record) + .map(Const::messageHandler) + } + + @PutMapping("", "/") + fun updateUsage(@RequestBody @Valid record: BorrowRecord): Mono> { + logger.info("Trying to update usage") + return service.updateUsage(record) + .map(Const::messageHandler) + } + + @DeleteMapping("", "/") + fun deleteUsage(@RequestParam(required = false) id: Long?): Mono> { + logger.info("Trying to delete usage") + return service.deleteUsage(id) + .map(Const::messageHandler) + } + + // usages/{id}/upload_images/ POST 上传使用记录图片 + @PostMapping("/{id}/upload_images", "/{id}/upload_images/") + fun uploadImages( + @PathVariable("id") id: Long, + @RequestPart("files") files: Flux + ): Mono> { + logger.info("Trying to upload images for usage {}", id) + return service.uploadImages(id, files) + .map(Const::messageHandler) + } + + // usages/current/ GET 获取当前使用中的记录 + @GetMapping("/current", "/current/") + fun getCurrentUsages(): Mono> { + logger.info("Trying to list current usages") + return service.getCurrentUsages() + .map { ApiResponse.success(it) } + } + + // usages/by_user/ GET 根据用户姓名获取使用记录 + @GetMapping("/by_user", "/by_user/") + fun getUsagesByUser(@RequestParam(required = false) name: String?): Mono> { + logger.info("Trying to list usages by user") + return service.getUsagesByUser(name) + .map { ApiResponse.success(it) } + } + + // usages/images/ GET/POST/PUT/DELETE 使用记录图片 CRUD 操作 + @GetMapping("/images", "/images/") + fun getUsageImages(): Mono> { + logger.info("Trying to list usage images") + return service.getUsageImages() + .map { ApiResponse.success(it) } + } + + @PostMapping("/images", "/images/") + fun createUsageImage(@RequestBody @Valid attachment: Attachment): Mono> { + logger.info("Trying to create usage image") + return service.createUsageImage(attachment) + .map(Const::messageHandler) + } + + @PutMapping("/images", "/images/") + fun updateUsageImage(@RequestBody @Valid attachment: Attachment): Mono> { + logger.info("Trying to update usage image") + return service.updateUsageImage(attachment) + .map(Const::messageHandler) + } + + @DeleteMapping("/images", "/images/") + fun deleteUsageImage(@RequestParam(required = false) id: Long?): Mono> { + logger.info("Trying to delete usage image") + return service.deleteUsageImage(id) + .map(Const::messageHandler) + } +} diff --git a/src/backend/src/main/kotlin/club/itstudio/itmanager/entity/dto/FinanceRecord.kt b/src/backend/src/main/kotlin/club/itstudio/itmanager/entity/dto/FinanceRecord.kt index bede005..28d9524 100644 --- a/src/backend/src/main/kotlin/club/itstudio/itmanager/entity/dto/FinanceRecord.kt +++ b/src/backend/src/main/kotlin/club/itstudio/itmanager/entity/dto/FinanceRecord.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.FinanceRecordType import kotlinx.datetime.LocalDateTime import kotlinx.serialization.Serializable import java.math.BigDecimal @@ -14,7 +15,7 @@ data class FinanceRecord( @TableId(type = IdType.AUTO) val id: Long? = null, val recordNo: String? = null, - val type: String = "", // PURCHASE / REFUND / REPAIR / DEPRECIATION / OTHER + val type: FinanceRecordType = FinanceRecordType.OTHER, val relatedItemId: Long? = null, val amount: BigDecimal = BigDecimal.ZERO, val currency: String = "CNY", diff --git a/src/backend/src/main/kotlin/club/itstudio/itmanager/entity/enums/EmailType.kt b/src/backend/src/main/kotlin/club/itstudio/itmanager/entity/enums/EmailType.kt index db95edf..20d5382 100644 --- a/src/backend/src/main/kotlin/club/itstudio/itmanager/entity/enums/EmailType.kt +++ b/src/backend/src/main/kotlin/club/itstudio/itmanager/entity/enums/EmailType.kt @@ -10,4 +10,7 @@ enum class EmailType { @SerialName("reset") RESET, + + @SerialName("finance-delete") + FINANCE_DELETE, } \ No newline at end of file diff --git a/src/backend/src/main/kotlin/club/itstudio/itmanager/entity/enums/FinanceRecordType.kt b/src/backend/src/main/kotlin/club/itstudio/itmanager/entity/enums/FinanceRecordType.kt new file mode 100644 index 0000000..9c7825e --- /dev/null +++ b/src/backend/src/main/kotlin/club/itstudio/itmanager/entity/enums/FinanceRecordType.kt @@ -0,0 +1,22 @@ +package club.itstudio.itmanager.entity.enums + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +enum class FinanceRecordType { + @SerialName("PURCHASE") + PURCHASE, + + @SerialName("REFUND") + REFUND, + + @SerialName("REPAIR") + REPAIR, + + @SerialName("DEPRECIATION") + DEPRECIATION, + + @SerialName("OTHER") + OTHER, +} \ No newline at end of file diff --git a/src/backend/src/main/kotlin/club/itstudio/itmanager/listener/MailQueueListener.kt b/src/backend/src/main/kotlin/club/itstudio/itmanager/listener/MailQueueListener.kt index 1a9b526..d406370 100644 --- a/src/backend/src/main/kotlin/club/itstudio/itmanager/listener/MailQueueListener.kt +++ b/src/backend/src/main/kotlin/club/itstudio/itmanager/listener/MailQueueListener.kt @@ -42,6 +42,12 @@ class MailQueueListener( + "\nThis code is valid for 3 minutes.", email ) + + EmailType.FINANCE_DELETE -> createMailMessage( + "Finance Record Deleted", + "A finance record has been deleted. Record No: $code", + email + ) } sender.send(message) logger.info("Email sent to $email.") diff --git a/src/backend/src/main/kotlin/club/itstudio/itmanager/service/v1/EmailApiService.kt b/src/backend/src/main/kotlin/club/itstudio/itmanager/service/v1/EmailApiService.kt new file mode 100644 index 0000000..b340822 --- /dev/null +++ b/src/backend/src/main/kotlin/club/itstudio/itmanager/service/v1/EmailApiService.kt @@ -0,0 +1,56 @@ +package club.itstudio.itmanager.service.v1 + +import club.itstudio.itmanager.entity.dto.EmailNotice +import club.itstudio.itmanager.service.EmailNoticeService +import club.itstudio.itmanager.utils.Const +import club.itstudio.itmanager.utils.DateUtils.getCurrentDateTime +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import reactor.core.publisher.Mono +import java.util.NoSuchElementException + +@Service +class EmailApiService( + private val emailNoticeService: EmailNoticeService +) { + private val logger = LoggerFactory.getLogger(this::class.java) + + fun getNotificationSettings(): Mono> { + logger.info("Listing notification settings and email list") + return emailNoticeService.listAllNotices() + } + + fun updateNotificationSettings(notice: EmailNotice): Mono { + logger.info("Updating notification settings") + if (notice.id == null) { + return emailNoticeService.createEmailNotice( + notice.copy( + id = null, + createdAt = getCurrentDateTime(), + updatedAt = getCurrentDateTime() + ) + ).flatMap { success -> + if (success) Mono.just("") + else Mono.just("notification settings create failed.") + } + } + return emailNoticeService.updateEmailNotice( + notice.copy(updatedAt = getCurrentDateTime()) + ).flatMap { success -> + if (success) Mono.just("") + else Mono.just("notification settings update failed.") + } + } + + fun toggleEmailStatus(id: Long?): Mono { + logger.info("Toggling email status for id {}", id) + if (id == null) return Mono.just("email notice id is required.") + return emailNoticeService.toggleEnabled(id) + .flatMap { success -> + if (success) Mono.just("") + else Mono.just("email notice not found or toggle failed.") + } + .onErrorResume(NoSuchElementException::class.java) { Mono.just("email notice not found.") } + .onErrorResume { Mono.just(Const.INTERNAL_ERROR_STR) } + } +} diff --git a/src/backend/src/main/kotlin/club/itstudio/itmanager/service/v1/FinanceApiService.kt b/src/backend/src/main/kotlin/club/itstudio/itmanager/service/v1/FinanceApiService.kt new file mode 100644 index 0000000..d297d2d --- /dev/null +++ b/src/backend/src/main/kotlin/club/itstudio/itmanager/service/v1/FinanceApiService.kt @@ -0,0 +1,288 @@ +package club.itstudio.itmanager.service.v1 + +import club.itstudio.itmanager.entity.dto.Attachment +import club.itstudio.itmanager.entity.dto.Category +import club.itstudio.itmanager.entity.dto.EmailVerificationMsg +import club.itstudio.itmanager.entity.dto.FinanceRecord +import club.itstudio.itmanager.entity.enums.EmailType +import club.itstudio.itmanager.service.AccountService +import club.itstudio.itmanager.service.AttachmentService +import club.itstudio.itmanager.service.CategoryService +import club.itstudio.itmanager.service.FinanceRecordService +import club.itstudio.itmanager.utils.Const +import club.itstudio.itmanager.utils.DateUtils.getCurrentDateTime +import com.baomidou.mybatisplus.extension.plugins.pagination.Page +import org.slf4j.LoggerFactory +import org.springframework.amqp.core.AmqpTemplate +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 FinanceApiService( + private val financeRecordService: FinanceRecordService, + private val categoryService: CategoryService, + private val attachmentService: AttachmentService, + private val accountService: AccountService, + private val amqpTemplate: AmqpTemplate, + + @Value($$"${app.file.data-path}") + private val dataPath: String +) { + private val logger = LoggerFactory.getLogger(this::class.java) + + fun getFinanceRecords( + page: Page, + keyword: String?, + type: String?, + start: String?, + end: String?, + vendor: String?, + itemId: Long? + ): Mono> { + logger.info("Listing finance records") + return pageFinanceRecords(page, keyword, type, start, end, vendor, itemId) + } + + private fun pageFinanceRecords( + page: Page, + keyword: String?, + type: String?, + start: String?, + end: String?, + vendor: String?, + itemId: Long? + ): Mono> = + Mono.fromCallable { + val wrapper = financeRecordService.ktQuery() + if (!keyword.isNullOrBlank()) { + wrapper.and { + it.like(FinanceRecord::recordNo, keyword) + .or().like(FinanceRecord::vendor, keyword) + .or().like(FinanceRecord::note, keyword) + } + } + if (!type.isNullOrBlank()) { + wrapper.eq(FinanceRecord::type, type) + } + if (!start.isNullOrBlank() && !end.isNullOrBlank()) { + wrapper.apply("occurred_at BETWEEN {0} AND {1}", start, end) + } else if (!start.isNullOrBlank()) { + wrapper.ge(FinanceRecord::occurredAt, start) + } else if (!end.isNullOrBlank()) { + wrapper.le(FinanceRecord::occurredAt, end) + } + if (!vendor.isNullOrBlank()) { + wrapper.eq(FinanceRecord::vendor, vendor) + } + if (itemId != null) { + wrapper.eq(FinanceRecord::relatedItemId, itemId) + } + financeRecordService.page(page, wrapper) + }.subscribeOn(Schedulers.boundedElastic()) + + fun createFinanceRecord(record: FinanceRecord): Mono { + logger.info("Creating finance record") + val rightNow = getCurrentDateTime() + return financeRecordService.createFinanceRecord( + record.copy( + id = null, + createdAt = rightNow + ) + ).flatMap { success -> + if (success) Mono.just("") + else Mono.just("finance record create failed.") + } + } + + fun updateFinanceRecord(record: FinanceRecord): Mono { + logger.info("Updating finance record") + if (record.id == null) return Mono.just("finance record id is required.") + return financeRecordService.updateFinanceRecord(record).flatMap { success -> + if (success) Mono.just("") + else Mono.just("finance record update failed.") + } + } + + fun deleteFinanceRecord(id: Long?): Mono { + logger.info("Deleting finance record with id {}", id) + if (id == null) return Mono.just("finance record id is required.") + return financeRecordService.deleteFinanceRecord(id).flatMap { success -> + if (success) cleanupFinanceAttachments(id).thenReturn("") + else Mono.just("finance record delete failed.") + } + } + + fun uploadImages(id: Long, files: Flux): Mono { + logger.info("Uploading images for finance record {}", id) + return financeRecordService.getFinanceRecordById(id) + .flatMap { _ -> + files.flatMap { file -> saveFinanceImage(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("finance image upload failed.") + } else { + Mono.just("") + } + } + } + } + } + .onErrorResume(NoSuchElementException::class.java) { Mono.just("finance record not found.") } + .onErrorResume { Mono.just(Const.INTERNAL_ERROR_STR) } + } + + fun deleteById(id: Long?): Mono { + logger.info("Deleting finance record {} and notifying by email", id) + if (id == null) return Mono.just("finance record id is required.") + return financeRecordService.getFinanceRecordById(id) + .flatMap { record -> + financeRecordService.deleteFinanceRecord(id) + .flatMap { success -> + if (success) { + cleanupFinanceAttachments(id) + .then(sendFinanceDeleteNotification(record)) + .thenReturn("") + } else { + Mono.just("finance record delete failed.") + } + } + } + .onErrorResume(NoSuchElementException::class.java) { Mono.just("finance record not found.") } + .onErrorResume { Mono.just(Const.INTERNAL_ERROR_STR) } + } + + fun getFinanceCategories(): Mono> { + logger.info("Listing finance categories") + return categoryService.listAllCategories() + } + + fun createFinanceCategory(category: Category): Mono { + logger.info("Creating finance 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 updateFinanceCategory(category: Category): Mono { + logger.info("Updating finance 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 deleteFinanceCategory(id: Long?): Mono { + logger.info("Deleting finance 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.") + } + } + + private fun saveFinanceImage(financeRecordId: Long, file: FilePart): Mono { + val directory = Paths.get(dataPath, Const.FILE_DIR_IMAGE, "finance", financeRecordId.toString()) + val savedName = generateSafeFileName(file.filename()) + val target = directory.resolve(savedName) + return Mono.fromCallable { + Files.createDirectories(directory) + file.transferTo(target) + Attachment( + relatedTable = "finance_records", + relatedId = financeRecordId, + 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 cleanupFinanceAttachments(financeRecordId: Long): Mono { + return attachmentService.listByRelated("finance_records", financeRecordId) + .flatMap { attachments -> + Flux.fromIterable(attachments) + .flatMap { deletePhysicalFile(it.filePath) } + .then(attachmentService.deleteByRelated("finance_records", financeRecordId)) + } + .onErrorResume { error -> + logger.warn("Failed to clean up attachments for finance record {}: {}", financeRecordId, error.message) + Mono.just(false) + } + } + + private fun sendFinanceDeleteNotification(record: FinanceRecord): Mono { + val recordedBy = record.recordedBy ?: return Mono.empty() + return Mono.fromCallable { accountService.getById(recordedBy) } + .subscribeOn(Schedulers.boundedElastic()) + .flatMap { account -> + val email = account.email + if (email.isNullOrBlank()) { + logger.warn("Skipping finance delete notification for record {}: no email for account {}", record.id, recordedBy) + Mono.empty() + } else { + Mono.fromRunnable { + amqpTemplate.convertAndSend( + "mail", + EmailVerificationMsg( + email = email, + type = EmailType.FINANCE_DELETE, + code = record.recordNo ?: record.id?.toString() ?: "" + ) + ) + }.subscribeOn(Schedulers.boundedElastic()) + } + } + } + + 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()) + } +} \ No newline at end of file diff --git a/src/backend/src/main/kotlin/club/itstudio/itmanager/service/v1/ProofImagesApiService.kt b/src/backend/src/main/kotlin/club/itstudio/itmanager/service/v1/ProofImagesApiService.kt new file mode 100644 index 0000000..32235d1 --- /dev/null +++ b/src/backend/src/main/kotlin/club/itstudio/itmanager/service/v1/ProofImagesApiService.kt @@ -0,0 +1,84 @@ +package club.itstudio.itmanager.service.v1 + +import club.itstudio.itmanager.entity.dto.Attachment +import club.itstudio.itmanager.service.AttachmentService +import club.itstudio.itmanager.utils.Const +import club.itstudio.itmanager.utils.DateUtils.getCurrentDateTime +import org.slf4j.LoggerFactory +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.Paths +import java.util.NoSuchElementException + +@Service +class ProofImagesApiService( + private val attachmentService: AttachmentService +) { + private val logger = LoggerFactory.getLogger(this::class.java) + + fun getProofImages(): Mono> { + logger.info("Listing proof images") + return attachmentService.exportAllAttachments() + .map { list -> list.filter { it.relatedTable == RELATED_TABLE } } + } + + fun createProofImage(attachment: Attachment): Mono { + logger.info("Creating proof image") + return attachmentService.createAttachment( + attachment.copy( + id = null, + uploadedAt = getCurrentDateTime() + ) + ).flatMap { success -> + if (success) Mono.just("") + else Mono.just("proof image create failed.") + }.onErrorResume { Mono.just(Const.INTERNAL_ERROR_STR) } + } + + fun updateProofImage(attachment: Attachment): Mono { + logger.info("Updating proof image") + if (attachment.id == null) return Mono.just("proof image id is required.") + return Mono.fromCallable { attachmentService.updateById(attachment) } + .subscribeOn(Schedulers.boundedElastic()) + .flatMap { success -> + if (success) Mono.just("") + else Mono.just("proof image update failed.") + } + .onErrorResume { Mono.just(Const.INTERNAL_ERROR_STR) } + } + + fun deleteProofImage(id: Long?): Mono { + logger.info("Deleting proof image with id {}", id) + if (id == null) return Mono.just("proof image id is required.") + return attachmentService.getAttachmentById(id) + .flatMap { attachment -> + attachmentService.deleteAttachment(id).flatMap { success -> + if (success) { + deletePhysicalFile(attachment.filePath).thenReturn("") + } else { + Mono.just("proof image delete failed.") + } + } + } + .onErrorResume(NoSuchElementException::class.java) { Mono.just("proof image not found.") } + .onErrorResume { Mono.just(Const.INTERNAL_ERROR_STR) } + } + + 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()) + } + + companion object { + private const val RELATED_TABLE = "finance_records" + } +} \ No newline at end of file diff --git a/src/backend/src/main/kotlin/club/itstudio/itmanager/service/v1/UsagesApiService.kt b/src/backend/src/main/kotlin/club/itstudio/itmanager/service/v1/UsagesApiService.kt new file mode 100644 index 0000000..817f0a9 --- /dev/null +++ b/src/backend/src/main/kotlin/club/itstudio/itmanager/service/v1/UsagesApiService.kt @@ -0,0 +1,234 @@ +package club.itstudio.itmanager.service.v1 + +import club.itstudio.itmanager.entity.dto.Attachment +import club.itstudio.itmanager.entity.dto.BorrowRecord +import club.itstudio.itmanager.service.AttachmentService +import club.itstudio.itmanager.service.BorrowRecordService +import club.itstudio.itmanager.utils.Const +import club.itstudio.itmanager.utils.DateUtils.getCurrentDateTime +import com.baomidou.mybatisplus.core.toolkit.Wrappers +import com.baomidou.mybatisplus.extension.plugins.pagination.Page +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 UsagesApiService( + private val borrowRecordService: BorrowRecordService, + private val attachmentService: AttachmentService, + + @Value($$"${app.file.data-path}") + private val dataPath: String +) { + private val logger = LoggerFactory.getLogger(this::class.java) + + fun getUsages(page: Page, keyword: String?, status: String?): Mono> { + logger.info("Paging usages with keyword and status") + return Mono.fromCallable { + val wrapper = Wrappers.lambdaQuery() + if (!keyword.isNullOrBlank()) { + wrapper.like(BorrowRecord::borrower, keyword) + } + if (!status.isNullOrBlank()) { + wrapper.eq(BorrowRecord::status, status) + } + wrapper.orderByDesc(BorrowRecord::createdAt) + borrowRecordService.page(page, wrapper) + }.subscribeOn(Schedulers.boundedElastic()) + } + + fun createUsage(record: BorrowRecord): Mono { + logger.info("Creating usage") + val rightNow = getCurrentDateTime() + return borrowRecordService.createBorrowRecord( + record.copy( + id = null, + createdAt = rightNow, + updatedAt = rightNow + ) + ).flatMap { success -> + if (success) Mono.just("") + else Mono.just("usage create failed.") + }.onErrorResume { Mono.just(Const.INTERNAL_ERROR_STR) } + } + + fun updateUsage(record: BorrowRecord): Mono { + logger.info("Updating usage") + if (record.id == null) return Mono.just("usage id is required.") + return borrowRecordService.updateBorrowRecord( + record.copy(updatedAt = getCurrentDateTime()) + ).flatMap { success -> + if (success) Mono.just("") + else Mono.just("usage update failed.") + }.onErrorResume { Mono.just(Const.INTERNAL_ERROR_STR) } + } + + fun deleteUsage(id: Long?): Mono { + logger.info("Deleting usage with id {}", id) + if (id == null) return Mono.just("usage id is required.") + return borrowRecordService.deleteBorrowRecord(id).flatMap { success -> + if (success) { + cleanupUsageAttachments(id) + } else { + Mono.just("usage delete failed.") + } + }.onErrorResume { Mono.just(Const.INTERNAL_ERROR_STR) } + } + + fun uploadImages(id: Long, files: Flux): Mono { + logger.info("Uploading images for usage {}", id) + return borrowRecordService.getBorrowRecordById(id) + .flatMap { _ -> + files.flatMap { file -> saveUsageImage(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("usage image upload failed.") + } else { + Mono.just("") + } + } + } + } + } + .onErrorResume(NoSuchElementException::class.java) { Mono.just("usage not found.") } + .onErrorResume { Mono.just(Const.INTERNAL_ERROR_STR) } + } + + fun getCurrentUsages(): Mono> { + logger.info("Listing current usages") + return borrowRecordService.listByStatus(STATUS_BORROWED) + .zipWith(borrowRecordService.listOverdueRecords()) { borrowed, overdue -> borrowed + overdue } + } + + fun getUsagesByUser(name: String?): Mono> { + logger.info("Listing usages by user {}", name) + if (name.isNullOrBlank()) return Mono.just(emptyList()) + return borrowRecordService.listByBorrower(name) + } + + fun getUsageImages(): Mono> { + logger.info("Listing usage images") + return attachmentService.exportAllAttachments() + .map { list -> list.filter { it.relatedTable == RELATED_TABLE } } + } + + fun createUsageImage(attachment: Attachment): Mono { + logger.info("Creating usage image") + return attachmentService.createAttachment( + attachment.copy( + id = null, + uploadedAt = getCurrentDateTime() + ) + ).flatMap { success -> + if (success) Mono.just("") + else Mono.just("usage image create failed.") + }.onErrorResume { Mono.just(Const.INTERNAL_ERROR_STR) } + } + + fun updateUsageImage(attachment: Attachment): Mono { + logger.info("Updating usage image") + if (attachment.id == null) return Mono.just("usage image id is required.") + return Mono.fromCallable { attachmentService.updateById(attachment) } + .subscribeOn(Schedulers.boundedElastic()) + .flatMap { success -> + if (success) Mono.just("") + else Mono.just("usage image update failed.") + } + .onErrorResume { Mono.just(Const.INTERNAL_ERROR_STR) } + } + + fun deleteUsageImage(id: Long?): Mono { + logger.info("Deleting usage image with id {}", id) + if (id == null) return Mono.just("usage image id is required.") + return attachmentService.getAttachmentById(id) + .flatMap { attachment -> + attachmentService.deleteAttachment(id).flatMap { success -> + if (success) { + deletePhysicalFile(attachment.filePath).thenReturn("") + } else { + Mono.just("usage image delete failed.") + } + } + } + .onErrorResume(NoSuchElementException::class.java) { Mono.just("usage image not found.") } + .onErrorResume { Mono.just(Const.INTERNAL_ERROR_STR) } + } + + private fun saveUsageImage(usageId: Long, file: FilePart): Mono { + val directory = Paths.get(dataPath, Const.FILE_DIR_IMAGE, "borrow_records", usageId.toString()) + val savedName = generateSafeFileName(file.filename()) + val target = directory.resolve(savedName) + return Mono.fromCallable { + Files.createDirectories(directory) + file.transferTo(target) + Attachment( + relatedTable = RELATED_TABLE, + relatedId = usageId, + 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 cleanupUsageAttachments(usageId: Long): Mono { + return attachmentService.listByRelated(RELATED_TABLE, usageId) + .flatMap { attachments -> + Flux.fromIterable(attachments) + .flatMap { deletePhysicalFile(it.filePath) } + .then(attachmentService.deleteByRelated(RELATED_TABLE, usageId)) + .thenReturn("") + } + .onErrorResume { error -> + logger.warn("Failed to clean up attachments for usage {}: {}", usageId, 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()) + } + + companion object { + private const val RELATED_TABLE = "borrow_records" + private const val STATUS_BORROWED = "BORROWED" + } +}