Compare commits

...

27 Commits

Author SHA1 Message Date
server_main 1bacf534b9 refactor: better SerializersModule structure 2026-08-18 10:40:18 +00:00
server_main 77cf75a2be V1.0.0
All backend function is available
2026-08-18 09:20:39 +00:00
SOR dd660d416a Merge branch 'backend-dev'
# Conflicts:
#	src/backend/src/main/kotlin/club/itstudio/itmanager/service/impl/FinanceRecordServiceImpl.kt
#	src/backend/src/main/kotlin/club/itstudio/itmanager/service/impl/ItemServiceImpl.kt
2026-08-18 17:05:13 +08:00
SOR 5d3a727b73 fix: page query wrapper 2026-08-18 16:58:32 +08:00
SOR2171 4f640b134a Merge pull request 'fix: page query' (#9) from backend-dev into main
Reviewed-on: #9
2026-08-18 16:05:41 +08:00
SOR 36d1b94fd8 fix: page query 2026-08-18 16:05:17 +08:00
server_main a1bcde0a37 fix: mybatis plus and kotlinx LocalDateTime 2026-08-18 06:41:50 +00:00
SOR2171 bb3df45b9f Merge pull request 'refactor: wrong position of query functions' (#8) from backend-dev into main
Reviewed-on: #8
2026-08-18 14:08:21 +08:00
SOR 92ffc47169 refactor: wrong position of query functions 2026-08-18 14:06:49 +08:00
server_main 774c7f8cc2 fix: CopyData for val 2026-08-18 05:54:57 +00:00
server_main 3c280c5353 refactor: better jwt convert 2026-08-17 13:00:01 +00:00
server_main 0af6fd35ab feat: upgrade gradle 2026-08-17 12:47:52 +00:00
server_main 53855df24a fix: about kotlinx.serialize 2026-08-17 12:45:36 +00:00
server_main 0536ffd632 fix: pass test, v0.1.0 2026-08-10 05:15:12 +00:00
SOR2171 b4c78c1f01 Merge pull request 'feat: first version of backend API' (#7) from backend-dev into main
Reviewed-on: #7
2026-08-09 20:53:17 +08:00
SOR 8262b3469f feat: first version of backend API 2026-08-09 20:51:28 +08:00
SOR 905f1d897d fix: relogin time 2026-08-09 20:25:58 +08:00
SOR 2912d83b5e refactor: 'fun set(key: String, value: String, timeout: Long, unit: TimeUnit): Unit' is deprecated. Deprecated in Java. 2026-08-09 17:55:13 +08:00
SOR 091684357d refactor: delete remain of department and users 2026-08-09 16:43:38 +08:00
SOR d6e8390ed6 refactor: wrong dir name 2026-08-09 13:42:03 +08:00
server_main 2a678d9093 feat: test class print encoded password 2026-08-09 05:24:46 +00:00
server_main 7afef64ed8 feat: database name 2026-08-09 05:15:46 +00:00
SOR2171 aacf474b5c Merge pull request 'feat: secret application.yaml' (#6) from backend-dev into main
Reviewed-on: #6
2026-08-06 17:52:58 +08:00
SOR2171 39a7d89dad Merge pull request 'feat: ready for ItemApi' (#4) from backend-dev into main
Reviewed-on: #4
2026-08-01 11:49:03 +08:00
SOR2171 d8b60339a5 Merge pull request 'feat: DB interface' (#3) from backend-dev into main
Reviewed-on: #3
2026-07-27 15:58:22 +08:00
SOR2171 6328f97153 Merge pull request 'doc: sql in README' (#2) from backend-dev into main
Reviewed-on: #2
2026-07-25 14:41:41 +08:00
SOR2171 fe85d1fe77 Merge pull request 'feat: All api url' (#1) from backend-dev into main
Reviewed-on: #1
2026-07-22 21:15:24 +08:00
106 changed files with 1743 additions and 545 deletions
+2 -2
View File
@@ -2,12 +2,12 @@
<project version="4"> <project version="4">
<component name="CompilerConfiguration"> <component name="CompilerConfiguration">
<bytecodeTargetLevel> <bytecodeTargetLevel>
<module name="club.itstudio.itmanager.main" target="21" /> <module name="itmanager" target="25" />
<module name="club.itstudio.itmanager.test" target="21" />
</bytecodeTargetLevel> </bytecodeTargetLevel>
</component> </component>
<component name="JavacSettings"> <component name="JavacSettings">
<option name="ADDITIONAL_OPTIONS_OVERRIDE"> <option name="ADDITIONAL_OPTIONS_OVERRIDE">
<module name="club.itstudio.itmanager" options="-parameters" />
<module name="club.itstudio.itmanager.main" options="-parameters" /> <module name="club.itstudio.itmanager.main" options="-parameters" />
<module name="club.itstudio.itmanager.test" options="-parameters" /> <module name="club.itstudio.itmanager.test" options="-parameters" />
<module name="itmanager" options="-parameters" /> <module name="itmanager" options="-parameters" />
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourcePerFileMappings">
<file url="file://$PROJECT_DIR$/schema.sql" value="c13cb29f-8398-41b6-a24b-5cf0642c9759" />
</component>
</project>
+3 -3
View File
@@ -4,11 +4,11 @@
<component name="GradleSettings"> <component name="GradleSettings">
<option name="linkedExternalProjectsSettings"> <option name="linkedExternalProjectsSettings">
<GradleProjectSettings> <GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/src/beckend" /> <option name="externalProjectPath" value="$PROJECT_DIR$/src/backend" />
<option name="gradleJvm" value="25" /> <option name="gradleJvm" value="ms-25" />
<option name="modules"> <option name="modules">
<set> <set>
<option value="$PROJECT_DIR$/src/beckend" /> <option value="$PROJECT_DIR$/src/backend" />
</set> </set>
</option> </option>
</GradleProjectSettings> </GradleProjectSettings>
+3
View File
@@ -13,5 +13,8 @@
</list> </list>
</option> </option>
</inspection_tool> </inspection_tool>
<inspection_tool class="ShellCheck" enabled="true" level="ERROR" enabled_by_default="true">
<shellcheck_settings value="SC2317" />
</inspection_tool>
</profile> </profile>
</component> </component>
+2 -2
View File
@@ -3,8 +3,8 @@
<component name="NewModuleRootManager" inherit-compiler-output="true"> <component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output /> <exclude-output />
<content url="file://$MODULE_DIR$"> <content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/src/beckend/.kotlin" /> <excludeFolder url="file://$MODULE_DIR$/src/backend/.kotlin" />
<excludeFolder url="file://$MODULE_DIR$/src/beckend/build" /> <excludeFolder url="file://$MODULE_DIR$/src/backend/build" />
<excludeFolder url="file://$MODULE_DIR$/.idea/dataSources" /> <excludeFolder url="file://$MODULE_DIR$/.idea/dataSources" />
</content> </content>
<orderEntry type="inheritedJdk" /> <orderEntry type="inheritedJdk" />
+1 -5
View File
@@ -6,12 +6,8 @@
<component name="Kotlin2JvmCompilerArguments"> <component name="Kotlin2JvmCompilerArguments">
<option name="jvmTarget" value="21" /> <option name="jvmTarget" value="21" />
</component> </component>
<component name="KotlinCommonCompilerArguments">
<option name="apiVersion" value="2.3" />
<option name="languageVersion" value="2.3" />
</component>
<component name="KotlinJpsPluginSettings"> <component name="KotlinJpsPluginSettings">
<option name="externalSystemId" value="Gradle" /> <option name="externalSystemId" value="Gradle" />
<option name="version" value="2.3.21" /> <option name="version" value="2.4.20-RC" />
</component> </component>
</project> </project>
+1 -1
View File
@@ -2,7 +2,7 @@
<project version="4"> <project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" /> <component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="FrameworkDetectionExcludesConfiguration"> <component name="FrameworkDetectionExcludesConfiguration">
<file type="web" url="file://$PROJECT_DIR$/src/beckend" /> <file type="web" url="file://$PROJECT_DIR$/src/backend" />
</component> </component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_25" project-jdk-name="ms-25" project-jdk-type="JavaSDK"> <component name="ProjectRootManager" version="2" languageLevel="JDK_25" project-jdk-name="ms-25" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" /> <output url="file://$PROJECT_DIR$/out" />
+346 -7
View File
@@ -22,11 +22,15 @@ class Response<T> {
## 接口总览 ## 接口总览
| 分组 | 路径前缀 | 说明 | | 分组 | 路径前缀 | 说明 |
|----------|-------------|-------------------------------------------| |--------------|--------------------|-------------------------------------------|
| 测试 | `/test` | 连通性测试 | | 测试 | `/test` | 连通性测试 |
| 认证 | `/v1/auth` | 登录 / 注册 / 验证码 / Token 管理 | | 认证 | `/v1/auth` | 登录 / 注册 / 验证码 / Token 管理 |
| 物品管理 | `/v1/items` | 物品 / 类别 / 图片 CRUD、借还、上下架查询 | | 物品管理 | `/v1/items` | 物品 / 类别 / 图片 CRUD、借还、上下架查询 |
| 使用记录 | `/v1/usages` | 使用记录 CRUD、图片、当前/按用户查询 |
| 财务管理 | `/v1/finance` | 财务记录 / 分类 CRUD、凭证图片上传 |
| 财务凭证图片 | `/v1/proof-images` | 财务凭证图片 CRUD |
| 邮件通知 | `/v1/email` | 通知设置、收件邮箱启用状态 |
--- ---
@@ -34,7 +38,7 @@ class Response<T> {
### GET /hello ### GET /hello
公开接口。 需要认证。
**响应** **响应**
@@ -57,6 +61,8 @@ class Response<T> {
### POST /login ### POST /login
以 `application/x-www-form-urlencoded` 表单提交。
**请求参数** **请求参数**
| 字段 | 类型 | 说明 | | 字段 | 类型 | 说明 |
@@ -113,7 +119,7 @@ class data {
```typescript ```typescript
class data { class data {
"token": string; "token": string;
"expire": time; "expire": Date;
} }
``` ```
@@ -344,6 +350,339 @@ class Page<T> {
--- ---
## 分组:使用记录 `/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<BorrowRecord>`
### 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<FinanceRecord>`
### 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 ### WebSocket
+22 -66
View File
@@ -15,75 +15,33 @@
- /ask-code - /ask-code
- /register - /register
- /reset - /reset
- /departments
- /email - /email
- /notification-settings
- /toggle-email-status
- /finance - /finance
- /
- /{id}/upload_images
- /{id}
- /categories
- /items - /items
- /
- /{id}/upload_images
- /{id}/set_primary_image
- /{id}/borrow
- /{id}/return_item
- /available
- /in_use
- /categories
- /images
- /proof-images - /proof-images
- /
- /{id}
- /usages - /usages
- /
## 预计完成的 API 列表 - /{id}/upload_images
- /current
### 1. **物品管理 (Items)** - /by_user
- /images
| 端点 | 方法 | 功能说明 |
|----------------------------------------------|---------------------|--------------------------|
| `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 | 删除财务记录(发送邮件通知) |
| `departments/` | GET/POST/PUT/DELETE | 部门 CRUD 操作 |
| `departments/{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 管理后台 |
---
#### 认证方式 #### 认证方式
@@ -98,8 +56,6 @@ Authorization: Bearer <token>
[schema.sql](./schema.sql) [schema.sql](./schema.sql)
- users/account (系统用户/认证信息) - users/account (系统用户/认证信息)
- departments (组织/部门)
- parent_id -> departments.id (自关联父级)
- categories (物品类别) - categories (物品类别)
- parent_id -> categories.id (自关联父级) - parent_id -> categories.id (自关联父级)
- items (物品基本信息) - items (物品基本信息)
-15
View File
@@ -15,21 +15,6 @@ CREATE TABLE account
DEFAULT CHARSET = utf8mb4 DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_unicode_ci COMMENT ='系统用户/认证信息'; COLLATE = utf8mb4_unicode_ci COMMENT ='系统用户/认证信息';
-- 部门表
DROP TABLE IF EXISTS departments;
CREATE TABLE departments
(
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
code VARCHAR(50),
parent_id BIGINT UNSIGNED,
description TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_departments_parent FOREIGN KEY (parent_id) REFERENCES departments (id) ON DELETE SET NULL
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4 COMMENT ='组织/部门';
-- 物品类别 -- 物品类别
DROP TABLE IF EXISTS categories; DROP TABLE IF EXISTS categories;
CREATE TABLE categories CREATE TABLE categories
@@ -1,12 +1,13 @@
plugins { plugins {
kotlin("jvm") version "2.3.21" kotlin("jvm") version "2.3.21"
kotlin("plugin.spring") version "2.3.21" kotlin("plugin.spring") version "2.3.21"
id("org.jetbrains.kotlin.plugin.serialization") version "2.4.20-RC"
id("org.springframework.boot") version "4.1.0" id("org.springframework.boot") version "4.1.0"
id("io.spring.dependency-management") version "1.1.7" id("io.spring.dependency-management") version "1.1.7"
} }
group = "club.itstudio" group = "club.itstudio"
version = "0.0.1-SNAPSHOT" version = "1.0.0"
description = "itmanager" description = "itmanager"
java { java {
@@ -22,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")
+6
View File
@@ -0,0 +1,6 @@
#Mon Aug 17 12:30:11 UTC 2026
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-9.7.0-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
View File
@@ -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
}
}
@@ -1,6 +1,9 @@
package club.itstudio.itmanager.config package club.itstudio.itmanager.config
import club.itstudio.itmanager.utils.BigDecimalSerializer
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.contextual
import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration
@@ -9,6 +12,9 @@ class SerializationConfig {
@Bean @Bean
fun kotlinxSerializationJson(): Json = Json { fun kotlinxSerializationJson(): Json = Json {
serializersModule = SerializersModule {
contextual(BigDecimalSerializer)
}
ignoreUnknownKeys = true ignoreUnknownKeys = true
isLenient = true isLenient = true
encodeDefaults = false encodeDefaults = false
@@ -0,0 +1,43 @@
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<ApiResponse<Any?>> {
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<ApiResponse<Any?>> {
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<ApiResponse<Any?>> {
logger.info("Trying to toggle email status")
return service.toggleEmailStatus(id)
.map(Const::messageHandler)
}
}
@@ -0,0 +1,111 @@
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<ApiResponse<Any?>> {
logger.info("Trying to list finance records")
val page = Page<FinanceRecord>(pageNum, pageSize)
return service.getFinanceRecords(page, keyword, type, start, end, vendor, itemId)
.map { ApiResponse.success(it) }
}
@PostMapping("", "/")
fun createFinanceRecord(@RequestBody @Valid record: FinanceRecord): Mono<ApiResponse<Any?>> {
logger.info("Trying to create finance record")
return service.createFinanceRecord(record)
.map(Const::messageHandler)
}
@PutMapping("", "/")
fun updateFinanceRecord(@RequestBody @Valid record: FinanceRecord): Mono<ApiResponse<Any?>> {
logger.info("Trying to update finance record")
return service.updateFinanceRecord(record)
.map(Const::messageHandler)
}
@DeleteMapping("", "/")
fun deleteFinanceRecord(@RequestParam(required = false) id: Long?): Mono<ApiResponse<Any?>> {
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<FilePart>
): Mono<ApiResponse<Any?>> {
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<ApiResponse<Any?>> {
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<ApiResponse<Any?>> {
logger.info("Trying to list finance categories")
return service.getFinanceCategories()
.map { ApiResponse.success(it) }
}
@PostMapping("/categories", "/categories/")
fun createFinanceCategory(@RequestBody @Valid category: Category): Mono<ApiResponse<Any?>> {
logger.info("Trying to create finance category")
return service.createFinanceCategory(category)
.map(Const::messageHandler)
}
@PutMapping("/categories", "/categories/")
fun updateFinanceCategory(@RequestBody @Valid category: Category): Mono<ApiResponse<Any?>> {
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<ApiResponse<Any?>> {
logger.info("Trying to delete finance category")
return service.deleteFinanceCategory(id)
.map(Const::messageHandler)
}
}
@@ -0,0 +1,57 @@
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<ApiResponse<Any?>> {
logger.info("Trying to list proof images")
return service.getProofImages()
.map { ApiResponse.success(it) }
}
@PostMapping("", "/")
fun createProofImage(@RequestBody @Valid attachment: Attachment): Mono<ApiResponse<Any?>> {
logger.info("Trying to create proof image")
return service.createProofImage(attachment)
.map(Const::messageHandler)
}
@PutMapping("", "/")
fun updateProofImage(@RequestBody @Valid attachment: Attachment): Mono<ApiResponse<Any?>> {
logger.info("Trying to update proof image")
return service.updateProofImage(attachment)
.map(Const::messageHandler)
}
@DeleteMapping("", "/")
fun deleteProofImage(@RequestParam(required = false) id: Long?): Mono<ApiResponse<Any?>> {
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<ApiResponse<Any?>> {
logger.info("Trying to delete proof image {}", id)
return service.deleteProofImage(id)
.map(Const::messageHandler)
}
}
@@ -0,0 +1,115 @@
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<ApiResponse<Any?>> {
logger.info("Trying to list usages")
val page = Page<BorrowRecord>(pageNum, pageSize)
return service.getUsages(page, keyword, status)
.map { ApiResponse.success(it) }
}
@PostMapping("", "/")
fun createUsage(@RequestBody @Valid record: BorrowRecord): Mono<ApiResponse<Any?>> {
logger.info("Trying to create usage")
return service.createUsage(record)
.map(Const::messageHandler)
}
@PutMapping("", "/")
fun updateUsage(@RequestBody @Valid record: BorrowRecord): Mono<ApiResponse<Any?>> {
logger.info("Trying to update usage")
return service.updateUsage(record)
.map(Const::messageHandler)
}
@DeleteMapping("", "/")
fun deleteUsage(@RequestParam(required = false) id: Long?): Mono<ApiResponse<Any?>> {
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<FilePart>
): Mono<ApiResponse<Any?>> {
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<ApiResponse<Any?>> {
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<ApiResponse<Any?>> {
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<ApiResponse<Any?>> {
logger.info("Trying to list usage images")
return service.getUsageImages()
.map { ApiResponse.success(it) }
}
@PostMapping("/images", "/images/")
fun createUsageImage(@RequestBody @Valid attachment: Attachment): Mono<ApiResponse<Any?>> {
logger.info("Trying to create usage image")
return service.createUsageImage(attachment)
.map(Const::messageHandler)
}
@PutMapping("/images", "/images/")
fun updateUsageImage(@RequestBody @Valid attachment: Attachment): Mono<ApiResponse<Any?>> {
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<ApiResponse<Any?>> {
logger.info("Trying to delete usage image")
return service.deleteUsageImage(id)
.map(Const::messageHandler)
}
}
@@ -7,6 +7,7 @@ import kotlin.reflect.full.createInstance
import kotlin.reflect.full.declaredMemberProperties import kotlin.reflect.full.declaredMemberProperties
import kotlin.reflect.full.memberProperties import kotlin.reflect.full.memberProperties
import kotlin.reflect.jvm.isAccessible import kotlin.reflect.jvm.isAccessible
import kotlin.reflect.jvm.javaField
interface DataCopy { interface DataCopy {
fun <T : Any> toAnotherObject( fun <T : Any> toAnotherObject(
@@ -19,15 +20,21 @@ interface DataCopy {
return try { return try {
val vo = toClass.createInstance() val vo = toClass.createInstance()
for (prop in toClass.declaredMemberProperties) { for (prop in toClass.declaredMemberProperties) {
@Suppress("UNCHECKED_CAST")
val mutableProp = prop as? KMutableProperty1<Any, Any?> ?: continue
val value = when { val value = when {
sourceProps.containsKey(prop.name) -> sourceProps[prop.name] sourceProps.containsKey(prop.name) -> sourceProps[prop.name]
otherProperties.containsKey(prop.name) -> otherProperties[prop.name] otherProperties.containsKey(prop.name) -> otherProperties[prop.name]
else -> continue else -> continue
} }
mutableProp.isAccessible = true @Suppress("UNCHECKED_CAST")
mutableProp.set(vo, value) val mutableProp = prop as? KMutableProperty1<Any, Any?>
if (mutableProp != null) {
mutableProp.isAccessible = true
mutableProp.set(vo, value)
} else {
val field = prop.javaField ?: continue
field.isAccessible = true
field.set(vo, value)
}
} }
vo vo
} catch (e: ReflectiveOperationException) { } catch (e: ReflectiveOperationException) {
@@ -4,7 +4,9 @@ import com.baomidou.mybatisplus.annotation.IdType
import com.baomidou.mybatisplus.annotation.TableId import com.baomidou.mybatisplus.annotation.TableId
import com.baomidou.mybatisplus.annotation.TableName import com.baomidou.mybatisplus.annotation.TableName
import club.itstudio.itmanager.entity.DataCopy import club.itstudio.itmanager.entity.DataCopy
import club.itstudio.itmanager.entity.enums.FinanceRecordType
import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalDateTime
import kotlinx.serialization.Contextual
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import java.math.BigDecimal import java.math.BigDecimal
@@ -14,9 +16,9 @@ data class FinanceRecord(
@TableId(type = IdType.AUTO) @TableId(type = IdType.AUTO)
val id: Long? = null, val id: Long? = null,
val recordNo: String? = null, val recordNo: String? = null,
val type: String = "", // PURCHASE / REFUND / REPAIR / DEPRECIATION / OTHER val type: FinanceRecordType = FinanceRecordType.OTHER,
val relatedItemId: Long? = null, val relatedItemId: Long? = null,
val amount: BigDecimal = BigDecimal.ZERO, val amount: @Contextual BigDecimal = BigDecimal.ZERO,
val currency: String = "CNY", val currency: String = "CNY",
val vendor: String? = null, val vendor: String? = null,
val occurredAt: LocalDateTime? = null, val occurredAt: LocalDateTime? = null,
@@ -6,6 +6,7 @@ import com.baomidou.mybatisplus.annotation.TableName
import club.itstudio.itmanager.entity.DataCopy import club.itstudio.itmanager.entity.DataCopy
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalDateTime
import kotlinx.serialization.Contextual
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import java.math.BigDecimal import java.math.BigDecimal
@@ -23,7 +24,7 @@ data class Item(
val totalQuantity: Int = 0, val totalQuantity: Int = 0,
val availableQuantity: Int = 0, val availableQuantity: Int = 0,
val unit: String = "件", val unit: String = "件",
val price: BigDecimal? = null, val price: @Contextual BigDecimal? = null,
val purchaseDate: LocalDate? = null, val purchaseDate: LocalDate? = null,
val supplier: String? = null, val supplier: String? = null,
val status: String = "normal", val status: String = "normal",
@@ -10,4 +10,7 @@ enum class EmailType {
@SerialName("reset") @SerialName("reset")
RESET, RESET,
@SerialName("finance-delete")
FINANCE_DELETE,
} }
@@ -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,
}
@@ -1,9 +1,11 @@
package club.itstudio.itmanager.entity.vo.response package club.itstudio.itmanager.entity.vo.response
import club.itstudio.itmanager.utils.DateUtils.getCurrentDateTime
import kotlinx.datetime.LocalDateTime
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
data class ReloginVO( data class ReloginVO(
val token: String = "", val token: String = "",
val expire: String = "", val expire: LocalDateTime = getCurrentDateTime(),
) )
@@ -42,6 +42,12 @@ class MailQueueListener(
+ "\nThis code is valid for 3 minutes.", + "\nThis code is valid for 3 minutes.",
email email
) )
EmailType.FINANCE_DELETE -> createMailMessage(
"Finance Record Deleted",
"A finance record has been deleted. Record No: $code",
email
)
} }
sender.send(message) sender.send(message)
logger.info("Email sent to $email.") logger.info("Email sent to $email.")
@@ -0,0 +1,49 @@
package club.itstudio.itmanager.mapper.typehandler
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.toJavaLocalDateTime
import kotlinx.datetime.toKotlinLocalDateTime
import org.apache.ibatis.type.BaseTypeHandler
import org.apache.ibatis.type.JdbcType
import org.apache.ibatis.type.MappedJdbcTypes
import org.apache.ibatis.type.MappedTypes
import java.sql.CallableStatement
import java.sql.PreparedStatement
import java.sql.ResultSet
@MappedTypes(LocalDateTime::class)
@MappedJdbcTypes(JdbcType.TIMESTAMP)
class TimeTypeHandler : BaseTypeHandler<LocalDateTime>() {
override fun setNonNullParameter(
ps: PreparedStatement,
i: Int,
parameter: LocalDateTime,
jdbcType: JdbcType?
) {
ps.setObject(i, parameter.toJavaLocalDateTime())
}
override fun getNullableResult(
rs: ResultSet,
columnName: String
): LocalDateTime? =
rs.getTimestamp(columnName)
?.toLocalDateTime()
?.toKotlinLocalDateTime()
override fun getNullableResult(
rs: ResultSet,
columnIndex: Int
): LocalDateTime? =
rs.getTimestamp(columnIndex)
?.toLocalDateTime()
?.toKotlinLocalDateTime()
override fun getNullableResult(
cs: CallableStatement,
columnIndex: Int
): LocalDateTime? =
cs.getTimestamp(columnIndex)
?.toLocalDateTime()
?.toKotlinLocalDateTime()
}
@@ -1,20 +1,19 @@
package club.itstudio.itmanager.service package club.itstudio.itmanager.service
import com.baomidou.mybatisplus.extension.service.IService
import club.itstudio.itmanager.entity.dto.Account import club.itstudio.itmanager.entity.dto.Account
import club.itstudio.itmanager.entity.vo.request.EmailRegisterVO import com.baomidou.mybatisplus.extension.service.IService
import club.itstudio.itmanager.entity.vo.request.PasswordResetVO
import club.itstudio.itmanager.entity.vo.response.ReloginVO
import org.springframework.security.core.userdetails.ReactiveUserDetailsService import org.springframework.security.core.userdetails.ReactiveUserDetailsService
import reactor.core.publisher.Mono import reactor.core.publisher.Mono
interface AccountService : IService<Account>, ReactiveUserDetailsService { interface AccountService : IService<Account>, ReactiveUserDetailsService {
fun findAccountById(id: Long): Mono<Account>
fun findAccountByNameOrEmail(text: String): Mono<Account> fun findAccountByNameOrEmail(text: String): Mono<Account>
fun findAccountByName(username: String): Mono<Account> fun findAccountByName(username: String): Mono<Account>
fun findAccountByEmail(email: String): Mono<Account> fun findAccountByEmail(email: String): Mono<Account>
fun existAccountByName(username: String): Mono<Boolean> fun existAccountByName(username: String): Mono<Boolean>
fun existAccountByEmail(email: String): Mono<Boolean> fun existAccountByEmail(email: String): Mono<Boolean>
fun resetPasswordByEmail(email: String, encodedPassword: String): Mono<Void> fun resetPasswordByEmail(email: String, encodedPassword: String): Mono<Void>
fun createAccount(account: Account): Mono<Boolean>
// fun batchInsert(accounts: List<Account>): Mono<Boolean> // fun batchInsert(accounts: List<Account>): Mono<Boolean>
// fun replaceAll(accounts: List<Account>): Mono<Boolean> // fun replaceAll(accounts: List<Account>): Mono<Boolean>
@@ -9,6 +9,7 @@ interface AttachmentService : IService<Attachment> {
fun listByRelated(relatedTable: String, relatedId: Long): Mono<List<Attachment>> fun listByRelated(relatedTable: String, relatedId: Long): Mono<List<Attachment>>
fun listByUploader(uploadedBy: Long): Mono<List<Attachment>> fun listByUploader(uploadedBy: Long): Mono<List<Attachment>>
fun createAttachment(attachment: Attachment): Mono<Boolean> fun createAttachment(attachment: Attachment): Mono<Boolean>
fun updateAttachment(attachment: Attachment): Mono<Boolean>
fun deleteAttachment(id: Long): Mono<Boolean> fun deleteAttachment(id: Long): Mono<Boolean>
fun deleteByRelated(relatedTable: String, relatedId: Long): Mono<Boolean> fun deleteByRelated(relatedTable: String, relatedId: Long): Mono<Boolean>
fun exportAllAttachments(): Mono<List<Attachment>> fun exportAllAttachments(): Mono<List<Attachment>>
@@ -2,10 +2,12 @@ package club.itstudio.itmanager.service
import com.baomidou.mybatisplus.extension.service.IService import com.baomidou.mybatisplus.extension.service.IService
import club.itstudio.itmanager.entity.dto.BorrowRecord import club.itstudio.itmanager.entity.dto.BorrowRecord
import com.baomidou.mybatisplus.extension.plugins.pagination.Page
import reactor.core.publisher.Mono import reactor.core.publisher.Mono
interface BorrowRecordService : IService<BorrowRecord> { interface BorrowRecordService : IService<BorrowRecord> {
fun getBorrowRecordById(id: Long): Mono<BorrowRecord> fun getBorrowRecordById(id: Long): Mono<BorrowRecord>
fun pageUsages(page: Page<BorrowRecord>, keyword: String?, status: String?): Mono<Page<BorrowRecord>>
fun listByItem(itemId: Long): Mono<List<BorrowRecord>> fun listByItem(itemId: Long): Mono<List<BorrowRecord>>
fun listByBorrower(borrower: String): Mono<List<BorrowRecord>> fun listByBorrower(borrower: String): Mono<List<BorrowRecord>>
fun listByStatus(status: String): Mono<List<BorrowRecord>> fun listByStatus(status: String): Mono<List<BorrowRecord>>
@@ -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()
}
}
@@ -2,10 +2,20 @@ package club.itstudio.itmanager.service
import com.baomidou.mybatisplus.extension.service.IService import com.baomidou.mybatisplus.extension.service.IService
import club.itstudio.itmanager.entity.dto.FinanceRecord import club.itstudio.itmanager.entity.dto.FinanceRecord
import com.baomidou.mybatisplus.extension.plugins.pagination.Page
import reactor.core.publisher.Mono import reactor.core.publisher.Mono
interface FinanceRecordService : IService<FinanceRecord> { interface FinanceRecordService : IService<FinanceRecord> {
fun getFinanceRecordById(id: Long): Mono<FinanceRecord> fun getFinanceRecordById(id: Long): Mono<FinanceRecord>
fun pageFinanceRecords(
page: Page<FinanceRecord>,
keyword: String?,
type: String?,
start: String?,
end: String?,
vendor: String?,
itemId: Long?
): Mono<Page<FinanceRecord>>
fun listByType(type: String): Mono<List<FinanceRecord>> fun listByType(type: String): Mono<List<FinanceRecord>>
fun listByDateRange(start: String, end: String): Mono<List<FinanceRecord>> fun listByDateRange(start: String, end: String): Mono<List<FinanceRecord>>
fun listByVendor(vendor: String): Mono<List<FinanceRecord>> fun listByVendor(vendor: String): Mono<List<FinanceRecord>>
@@ -47,6 +47,14 @@ class AccountServiceImpl : ServiceImpl<AccountMapper, Account>(), AccountService
.one() .one()
} }
override fun findAccountById(id: Long): Mono<Account> =
blockingCall {
logger.info("Searching account by id")
ktQuery()
.eq(Account::id, id)
.one()
}
override fun findAccountByEmail(email: String): Mono<Account> = override fun findAccountByEmail(email: String): Mono<Account> =
findAccount("email") { findAccount("email") {
ktQuery() ktQuery()
@@ -141,4 +149,10 @@ class AccountServiceImpl : ServiceImpl<AccountMapper, Account>(), AccountService
} }
} }
} }
override fun createAccount(account: Account): Mono<Boolean> =
blockingCall {
logger.info("Creating account")
save(account)
}
} }
@@ -8,7 +8,6 @@ import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import reactor.core.publisher.Mono import reactor.core.publisher.Mono
import reactor.core.scheduler.Schedulers import reactor.core.scheduler.Schedulers
import java.util.NoSuchElementException
@Service @Service
class AttachmentServiceImpl : ServiceImpl<AttachmentMapper, Attachment>(), AttachmentService { class AttachmentServiceImpl : ServiceImpl<AttachmentMapper, Attachment>(), AttachmentService {
@@ -21,19 +20,26 @@ class AttachmentServiceImpl : ServiceImpl<AttachmentMapper, Attachment>(), Attac
override fun getAttachmentById(id: Long): Mono<Attachment> = override fun getAttachmentById(id: Long): Mono<Attachment> =
blockingCall { blockingCall {
logger.info("Searching attachment by id") logger.info("Searching attachment by id")
ktQuery().eq(Attachment::id, id).one() ktQuery()
.eq(Attachment::id, id)
.one()
}.switchIfEmpty(Mono.error(NoSuchElementException("Attachment not found"))) }.switchIfEmpty(Mono.error(NoSuchElementException("Attachment not found")))
override fun listByRelated(relatedTable: String, relatedId: Long): Mono<List<Attachment>> = override fun listByRelated(relatedTable: String, relatedId: Long): Mono<List<Attachment>> =
blockingCall { blockingCall {
logger.info("Searching attachments by related table and id") logger.info("Searching attachments by related table and id")
ktQuery().eq(Attachment::relatedTable, relatedTable).eq(Attachment::relatedId, relatedId).list() ktQuery()
.eq(Attachment::relatedTable, relatedTable)
.eq(Attachment::relatedId, relatedId)
.list()
} }
override fun listByUploader(uploadedBy: Long): Mono<List<Attachment>> = override fun listByUploader(uploadedBy: Long): Mono<List<Attachment>> =
blockingCall { blockingCall {
logger.info("Searching attachments by uploader") logger.info("Searching attachments by uploader")
ktQuery().eq(Attachment::uploadedBy, uploadedBy).list() ktQuery()
.eq(Attachment::uploadedBy, uploadedBy)
.list()
} }
override fun createAttachment(attachment: Attachment): Mono<Boolean> = override fun createAttachment(attachment: Attachment): Mono<Boolean> =
@@ -42,6 +48,12 @@ class AttachmentServiceImpl : ServiceImpl<AttachmentMapper, Attachment>(), Attac
save(attachment) save(attachment)
} }
override fun updateAttachment(attachment: Attachment): Mono<Boolean> =
blockingCall {
logger.info("Updating attachment")
updateById(attachment)
}
override fun deleteAttachment(id: Long): Mono<Boolean> = override fun deleteAttachment(id: Long): Mono<Boolean> =
blockingCall { blockingCall {
logger.info("Deleting attachment by id") logger.info("Deleting attachment by id")
@@ -51,7 +63,11 @@ class AttachmentServiceImpl : ServiceImpl<AttachmentMapper, Attachment>(), Attac
override fun deleteByRelated(relatedTable: String, relatedId: Long): Mono<Boolean> = override fun deleteByRelated(relatedTable: String, relatedId: Long): Mono<Boolean> =
blockingCall { blockingCall {
logger.info("Deleting attachments by related table and id") logger.info("Deleting attachments by related table and id")
remove(ktQuery().eq(Attachment::relatedTable, relatedTable).eq(Attachment::relatedId, relatedId)) remove(
ktQuery()
.eq(Attachment::relatedTable, relatedTable)
.eq(Attachment::relatedId, relatedId)
)
} }
override fun exportAllAttachments(): Mono<List<Attachment>> = override fun exportAllAttachments(): Mono<List<Attachment>> =
@@ -3,12 +3,14 @@ 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.core.conditions.query.QueryWrapper
import com.baomidou.mybatisplus.core.metadata.OrderItem
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
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import reactor.core.publisher.Mono import reactor.core.publisher.Mono
import reactor.core.scheduler.Schedulers import reactor.core.scheduler.Schedulers
import java.util.NoSuchElementException
@Service @Service
class BorrowRecordServiceImpl : ServiceImpl<BorrowRecordMapper, BorrowRecord>(), BorrowRecordService { class BorrowRecordServiceImpl : ServiceImpl<BorrowRecordMapper, BorrowRecord>(), BorrowRecordService {
@@ -21,31 +23,64 @@ class BorrowRecordServiceImpl : ServiceImpl<BorrowRecordMapper, BorrowRecord>(),
override fun getBorrowRecordById(id: Long): Mono<BorrowRecord> = override fun getBorrowRecordById(id: Long): Mono<BorrowRecord> =
blockingCall { blockingCall {
logger.info("Searching borrow record by id") logger.info("Searching borrow record by id")
ktQuery().eq(BorrowRecord::id, id).one() ktQuery()
.eq(BorrowRecord::id, id)
.one()
}.switchIfEmpty(Mono.error(NoSuchElementException("BorrowRecord not found"))) }.switchIfEmpty(Mono.error(NoSuchElementException("BorrowRecord not found")))
override fun pageUsages(
page: Page<BorrowRecord>,
keyword: String?,
status: String?
): Mono<Page<BorrowRecord>> =
blockingCall {
logger.info("Paging borrow records")
if (keyword.isNullOrBlank() && status.isNullOrBlank()) {
page.addOrder(OrderItem.desc("created_at"))
page(page)
} else {
val wrapper = QueryWrapper<BorrowRecord>()
if (!keyword.isNullOrBlank()) {
wrapper.like("borrower", keyword)
}
if (!status.isNullOrBlank()) {
wrapper.eq("status", status)
}
wrapper.orderByDesc("created_at")
page(page, wrapper)
}
}
override fun listByItem(itemId: Long): Mono<List<BorrowRecord>> = override fun listByItem(itemId: Long): Mono<List<BorrowRecord>> =
blockingCall { blockingCall {
logger.info("Searching borrow records by item id") logger.info("Searching borrow records by item id")
ktQuery().eq(BorrowRecord::itemId, itemId).list() ktQuery()
.eq(BorrowRecord::itemId, itemId)
.list()
} }
override fun listByBorrower(borrower: String): Mono<List<BorrowRecord>> = override fun listByBorrower(borrower: String): Mono<List<BorrowRecord>> =
blockingCall { blockingCall {
logger.info("Searching borrow records by borrower") logger.info("Searching borrow records by borrower")
ktQuery().eq(BorrowRecord::borrower, borrower).list() ktQuery()
.eq(BorrowRecord::borrower, borrower)
.list()
} }
override fun listByStatus(status: String): Mono<List<BorrowRecord>> = override fun listByStatus(status: String): Mono<List<BorrowRecord>> =
blockingCall { blockingCall {
logger.info("Searching borrow records by status") logger.info("Searching borrow records by status")
ktQuery().eq(BorrowRecord::status, status).list() ktQuery()
.eq(BorrowRecord::status, status)
.list()
} }
override fun listOverdueRecords(): Mono<List<BorrowRecord>> = override fun listOverdueRecords(): Mono<List<BorrowRecord>> =
blockingCall { blockingCall {
logger.info("Searching overdue borrow records") logger.info("Searching overdue borrow records")
ktQuery().eq(BorrowRecord::status, "OVERDUE").list() ktQuery()
.eq(BorrowRecord::status, "OVERDUE")
.list()
} }
override fun createBorrowRecord(record: BorrowRecord): Mono<Boolean> = override fun createBorrowRecord(record: BorrowRecord): Mono<Boolean> =
@@ -8,7 +8,6 @@ import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import reactor.core.publisher.Mono import reactor.core.publisher.Mono
import reactor.core.scheduler.Schedulers import reactor.core.scheduler.Schedulers
import java.util.NoSuchElementException
@Service @Service
class CategoryServiceImpl : ServiceImpl<CategoryMapper, Category>(), CategoryService { class CategoryServiceImpl : ServiceImpl<CategoryMapper, Category>(), CategoryService {
@@ -21,7 +20,9 @@ class CategoryServiceImpl : ServiceImpl<CategoryMapper, Category>(), CategorySer
override fun getCategoryById(id: Long): Mono<Category> = override fun getCategoryById(id: Long): Mono<Category> =
blockingCall { blockingCall {
logger.info("Searching category by id") logger.info("Searching category by id")
ktQuery().eq(Category::id, id).one() ktQuery()
.eq(Category::id, id)
.one()
}.switchIfEmpty(Mono.error(NoSuchElementException("Category not found"))) }.switchIfEmpty(Mono.error(NoSuchElementException("Category not found")))
override fun listAllCategories(): Mono<List<Category>> = override fun listAllCategories(): Mono<List<Category>> =
@@ -33,7 +34,9 @@ class CategoryServiceImpl : ServiceImpl<CategoryMapper, Category>(), CategorySer
override fun listByParentId(parentId: Long): Mono<List<Category>> = override fun listByParentId(parentId: Long): Mono<List<Category>> =
blockingCall { blockingCall {
logger.info("Searching categories by parent id") logger.info("Searching categories by parent id")
ktQuery().eq(Category::parentId, parentId).list() ktQuery()
.eq(Category::parentId, parentId)
.list()
} }
override fun createCategory(category: Category): Mono<Boolean> = override fun createCategory(category: Category): Mono<Boolean> =
@@ -8,7 +8,6 @@ import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import reactor.core.publisher.Mono import reactor.core.publisher.Mono
import reactor.core.scheduler.Schedulers import reactor.core.scheduler.Schedulers
import java.util.NoSuchElementException
@Service @Service
class EmailNoticeServiceImpl : ServiceImpl<EmailNoticeMapper, EmailNotice>(), EmailNoticeService { class EmailNoticeServiceImpl : ServiceImpl<EmailNoticeMapper, EmailNotice>(), EmailNoticeService {
@@ -21,7 +20,9 @@ class EmailNoticeServiceImpl : ServiceImpl<EmailNoticeMapper, EmailNotice>(), Em
override fun getEmailNoticeById(id: Long): Mono<EmailNotice> = override fun getEmailNoticeById(id: Long): Mono<EmailNotice> =
blockingCall { blockingCall {
logger.info("Searching email notice by id") logger.info("Searching email notice by id")
ktQuery().eq(EmailNotice::id, id).one() ktQuery()
.eq(EmailNotice::id, id)
.one()
}.switchIfEmpty(Mono.error(NoSuchElementException("EmailNotice not found"))) }.switchIfEmpty(Mono.error(NoSuchElementException("EmailNotice not found")))
override fun listAllNotices(): Mono<List<EmailNotice>> = override fun listAllNotices(): Mono<List<EmailNotice>> =
@@ -33,7 +34,9 @@ class EmailNoticeServiceImpl : ServiceImpl<EmailNoticeMapper, EmailNotice>(), Em
override fun listEnabledNotices(): Mono<List<EmailNotice>> = override fun listEnabledNotices(): Mono<List<EmailNotice>> =
blockingCall { blockingCall {
logger.info("Listing enabled email notices") logger.info("Listing enabled email notices")
ktQuery().eq(EmailNotice::enabled, true).list() ktQuery()
.eq(EmailNotice::enabled, true)
.list()
} }
override fun createEmailNotice(notice: EmailNotice): Mono<Boolean> = override fun createEmailNotice(notice: EmailNotice): Mono<Boolean> =
@@ -57,11 +60,8 @@ class EmailNoticeServiceImpl : ServiceImpl<EmailNoticeMapper, EmailNotice>(), Em
override fun toggleEnabled(id: Long): Mono<Boolean> = override fun toggleEnabled(id: Long): Mono<Boolean> =
blockingCall { blockingCall {
val notice = ktQuery().eq(EmailNotice::id, id).one() val notice = ktQuery().eq(EmailNotice::id, id).one()
if (notice != null) { notice != null
updateById(notice.copy(enabled = !notice.enabled)) && updateById(notice.copy(enabled = !notice.enabled))
} else {
false
}
} }
override fun exportAllEmailNotices(): Mono<List<EmailNotice>> = override fun exportAllEmailNotices(): Mono<List<EmailNotice>> =
@@ -3,12 +3,13 @@ package club.itstudio.itmanager.service.impl
import club.itstudio.itmanager.entity.dto.FinanceRecord import club.itstudio.itmanager.entity.dto.FinanceRecord
import club.itstudio.itmanager.mapper.FinanceRecordMapper import club.itstudio.itmanager.mapper.FinanceRecordMapper
import club.itstudio.itmanager.service.FinanceRecordService import club.itstudio.itmanager.service.FinanceRecordService
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper
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
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import reactor.core.publisher.Mono import reactor.core.publisher.Mono
import reactor.core.scheduler.Schedulers import reactor.core.scheduler.Schedulers
import java.util.NoSuchElementException
@Service @Service
class FinanceRecordServiceImpl : ServiceImpl<FinanceRecordMapper, FinanceRecord>(), FinanceRecordService { class FinanceRecordServiceImpl : ServiceImpl<FinanceRecordMapper, FinanceRecord>(), FinanceRecordService {
@@ -21,31 +22,85 @@ class FinanceRecordServiceImpl : ServiceImpl<FinanceRecordMapper, FinanceRecord>
override fun getFinanceRecordById(id: Long): Mono<FinanceRecord> = override fun getFinanceRecordById(id: Long): Mono<FinanceRecord> =
blockingCall { blockingCall {
logger.info("Searching finance record by id") logger.info("Searching finance record by id")
ktQuery().eq(FinanceRecord::id, id).one() ktQuery()
.eq(FinanceRecord::id, id)
.one()
}.switchIfEmpty(Mono.error(NoSuchElementException("FinanceRecord not found"))) }.switchIfEmpty(Mono.error(NoSuchElementException("FinanceRecord not found")))
override fun pageFinanceRecords(
page: Page<FinanceRecord>,
keyword: String?,
type: String?,
start: String?,
end: String?,
vendor: String?,
itemId: Long?
): Mono<Page<FinanceRecord>> =
blockingCall {
logger.info("Paging finance records")
if (keyword.isNullOrBlank() && type.isNullOrBlank() && start.isNullOrBlank() &&
end.isNullOrBlank() && vendor.isNullOrBlank() && itemId == null
) {
page(page)
} else {
val wrapper = QueryWrapper<FinanceRecord>()
if (!keyword.isNullOrBlank()) {
wrapper.and {
it.like("record_no", keyword)
.or().like("vendor", keyword)
.or().like("note", keyword)
}
}
if (!type.isNullOrBlank()) {
wrapper.eq("type", type)
}
if (!start.isNullOrBlank() && !end.isNullOrBlank()) {
wrapper.apply("occurred_at BETWEEN {0} AND {1}", start, end)
} else if (!start.isNullOrBlank()) {
wrapper.ge("occurred_at", start)
} else if (!end.isNullOrBlank()) {
wrapper.le("occurred_at", end)
}
if (!vendor.isNullOrBlank()) {
wrapper.eq("vendor", vendor)
}
if (itemId != null) {
wrapper.eq("related_item_id", itemId)
}
page(page, wrapper)
}
}
override fun listByType(type: String): Mono<List<FinanceRecord>> = override fun listByType(type: String): Mono<List<FinanceRecord>> =
blockingCall { blockingCall {
logger.info("Searching finance records by type") logger.info("Searching finance records by type")
ktQuery().eq(FinanceRecord::type, type).list() ktQuery()
.eq(FinanceRecord::type, type)
.list()
} }
override fun listByDateRange(start: String, end: String): Mono<List<FinanceRecord>> = override fun listByDateRange(start: String, end: String): Mono<List<FinanceRecord>> =
blockingCall { blockingCall {
logger.info("Searching finance records by date range") logger.info("Searching finance records by date range")
ktQuery().apply("occurred_at BETWEEN {0} AND {1}", start, end).list() ktQuery()
.apply("occurred_at BETWEEN {0} AND {1}", start, end)
.list()
} }
override fun listByVendor(vendor: String): Mono<List<FinanceRecord>> = override fun listByVendor(vendor: String): Mono<List<FinanceRecord>> =
blockingCall { blockingCall {
logger.info("Searching finance records by vendor") logger.info("Searching finance records by vendor")
ktQuery().eq(FinanceRecord::vendor, vendor).list() ktQuery()
.eq(FinanceRecord::vendor, vendor)
.list()
} }
override fun listByRelatedItem(itemId: Long): Mono<List<FinanceRecord>> = override fun listByRelatedItem(itemId: Long): Mono<List<FinanceRecord>> =
blockingCall { blockingCall {
logger.info("Searching finance records by related item id") logger.info("Searching finance records by related item id")
ktQuery().eq(FinanceRecord::relatedItemId, itemId).list() ktQuery()
.eq(FinanceRecord::relatedItemId, itemId)
.list()
} }
override fun createFinanceRecord(record: FinanceRecord): Mono<Boolean> = override fun createFinanceRecord(record: FinanceRecord): Mono<Boolean> =
@@ -3,13 +3,14 @@ package club.itstudio.itmanager.service.impl
import club.itstudio.itmanager.entity.dto.Item import club.itstudio.itmanager.entity.dto.Item
import club.itstudio.itmanager.mapper.ItemMapper import club.itstudio.itmanager.mapper.ItemMapper
import club.itstudio.itmanager.service.ItemService import club.itstudio.itmanager.service.ItemService
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper
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
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import reactor.core.publisher.Mono import reactor.core.publisher.Mono
import reactor.core.scheduler.Schedulers import reactor.core.scheduler.Schedulers
import java.util.NoSuchElementException
@Service @Service
class ItemServiceImpl : ServiceImpl<ItemMapper, Item>(), ItemService { class ItemServiceImpl : ServiceImpl<ItemMapper, Item>(), ItemService {
@@ -22,40 +23,54 @@ class ItemServiceImpl : ServiceImpl<ItemMapper, Item>(), ItemService {
override fun pageItems(page: Page<Item>, keyword: String?, status: String?): Mono<Page<Item>> = override fun pageItems(page: Page<Item>, keyword: String?, status: String?): Mono<Page<Item>> =
blockingCall { blockingCall {
logger.info("Paging items with keyword and status") logger.info("Paging items with keyword and status")
val wrapper = ktQuery() if (keyword.isNullOrBlank() && status.isNullOrBlank()) {
if (!keyword.isNullOrBlank()) { page(page)
wrapper.and { } else {
it.like(Item::name, keyword).or().like(Item::code, keyword) val wrapper = QueryWrapper<Item>()
if (!keyword.isNullOrBlank()) {
wrapper.and {
it.like("name", keyword)
.or().like("code", keyword)
}
} }
if (!status.isNullOrBlank()) {
wrapper.eq("status", status)
}
page(page, wrapper)
} }
if (!status.isNullOrBlank()) {
wrapper.eq(Item::status, status)
}
page(page, wrapper)
} }
override fun getItemById(id: Long): Mono<Item> = override fun getItemById(id: Long): Mono<Item> =
blockingCall { blockingCall {
logger.info("Searching item by id") logger.info("Searching item by id")
ktQuery().eq(Item::id, id).one() ktQuery()
.eq(Item::id, id)
.one()
}.switchIfEmpty(Mono.error(NoSuchElementException("Item not found"))) }.switchIfEmpty(Mono.error(NoSuchElementException("Item not found")))
override fun listAvailableItems(): Mono<List<Item>> = override fun listAvailableItems(): Mono<List<Item>> =
blockingCall { blockingCall {
logger.info("Listing available items") logger.info("Listing available items")
ktQuery().gt(Item::availableQuantity, 0).eq(Item::status, "normal").list() ktQuery()
.gt(Item::availableQuantity, 0)
.eq(Item::status, "normal")
.list()
} }
override fun listInUseItems(): Mono<List<Item>> = override fun listInUseItems(): Mono<List<Item>> =
blockingCall { blockingCall {
logger.info("Listing in-use items") logger.info("Listing in-use items")
ktQuery().apply("total_quantity - available_quantity > 0").list() ktQuery()
.apply("total_quantity - available_quantity > 0")
.list()
} }
override fun listItemsByCategory(categoryId: Long): Mono<List<Item>> = override fun listItemsByCategory(categoryId: Long): Mono<List<Item>> =
blockingCall { blockingCall {
logger.info("Listing items by category") logger.info("Listing items by category")
ktQuery().eq(Item::categoryId, categoryId).list() ktQuery()
.eq(Item::categoryId, categoryId)
.list()
} }
override fun createItem(item: Item): Mono<Boolean> = override fun createItem(item: Item): Mono<Boolean> =
@@ -79,35 +94,42 @@ class ItemServiceImpl : ServiceImpl<ItemMapper, Item>(), ItemService {
override fun setPrimaryImage(id: Long, imagePath: String): Mono<Boolean> = override fun setPrimaryImage(id: Long, imagePath: String): Mono<Boolean> =
blockingCall { blockingCall {
logger.info("Setting primary image for item") logger.info("Setting primary image for item")
ktUpdate().eq(Item::id, id).set(Item::imagePath, imagePath).update() ktUpdate()
.eq(Item::id, id)
.set(Item::imagePath, imagePath)
.update()
} }
override fun borrowItem(id: Long, quantity: Int): Mono<Boolean> = override fun borrowItem(id: Long, quantity: Int): Mono<Boolean> =
blockingCall { blockingCall {
val item = ktQuery().eq(Item::id, id).one() val item = ktQuery().eq(Item::id, id).one()
if (item != null && item.availableQuantity >= quantity) { item != null
ktUpdate().eq(Item::id, id).set(Item::availableQuantity, item.availableQuantity - quantity).update() && item.availableQuantity >= quantity
} else { && ktUpdate()
false .eq(Item::id, id)
} .set(Item::availableQuantity, item.availableQuantity - quantity)
.update()
} }
override fun returnItem(id: Long, quantity: Int): Mono<Boolean> = override fun returnItem(id: Long, quantity: Int): Mono<Boolean> =
blockingCall { blockingCall {
val item = ktQuery().eq(Item::id, id).one() val item = ktQuery().eq(Item::id, id).one()
if (item != null && item.totalQuantity >= item.availableQuantity + quantity) { item != null
ktUpdate().eq(Item::id, id).set(Item::availableQuantity, item.availableQuantity + quantity).update() && item.totalQuantity >= item.availableQuantity + quantity
} else { && ktUpdate()
false .eq(Item::id, id)
} .set(Item::availableQuantity, item.availableQuantity + quantity)
.update()
} }
@Transactional
override fun batchInsertItems(items: List<Item>): Mono<Boolean> = override fun batchInsertItems(items: List<Item>): Mono<Boolean> =
blockingCall { blockingCall {
logger.info("Batch inserting items") logger.info("Batch inserting items")
saveBatch(items) saveBatch(items)
} }
@Transactional
override fun replaceAllItems(items: List<Item>): Mono<Boolean> = override fun replaceAllItems(items: List<Item>): Mono<Boolean> =
blockingCall { blockingCall {
logger.info("Replacing all items") logger.info("Replacing all items")
@@ -8,7 +8,6 @@ import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import reactor.core.publisher.Mono import reactor.core.publisher.Mono
import reactor.core.scheduler.Schedulers import reactor.core.scheduler.Schedulers
import java.util.NoSuchElementException
@Service @Service
class ItemTransactionServiceImpl : ServiceImpl<ItemTransactionMapper, ItemTransaction>(), ItemTransactionService { class ItemTransactionServiceImpl : ServiceImpl<ItemTransactionMapper, ItemTransaction>(), ItemTransactionService {
@@ -21,25 +20,33 @@ class ItemTransactionServiceImpl : ServiceImpl<ItemTransactionMapper, ItemTransa
override fun getTransactionById(id: Long): Mono<ItemTransaction> = override fun getTransactionById(id: Long): Mono<ItemTransaction> =
blockingCall { blockingCall {
logger.info("Searching transaction by id") logger.info("Searching transaction by id")
ktQuery().eq(ItemTransaction::id, id).one() ktQuery()
.eq(ItemTransaction::id, id)
.one()
}.switchIfEmpty(Mono.error(NoSuchElementException("ItemTransaction not found"))) }.switchIfEmpty(Mono.error(NoSuchElementException("ItemTransaction not found")))
override fun listByItem(itemId: Long): Mono<List<ItemTransaction>> = override fun listByItem(itemId: Long): Mono<List<ItemTransaction>> =
blockingCall { blockingCall {
logger.info("Searching transactions by item id") logger.info("Searching transactions by item id")
ktQuery().eq(ItemTransaction::itemId, itemId).list() ktQuery()
.eq(ItemTransaction::itemId, itemId)
.list()
} }
override fun listByType(transType: String): Mono<List<ItemTransaction>> = override fun listByType(transType: String): Mono<List<ItemTransaction>> =
blockingCall { blockingCall {
logger.info("Searching transactions by type") logger.info("Searching transactions by type")
ktQuery().eq(ItemTransaction::transType, transType).list() ktQuery()
.eq(ItemTransaction::transType, transType)
.list()
} }
override fun listByOperator(operatorId: Long): Mono<List<ItemTransaction>> = override fun listByOperator(operatorId: Long): Mono<List<ItemTransaction>> =
blockingCall { blockingCall {
logger.info("Searching transactions by operator id") logger.info("Searching transactions by operator id")
ktQuery().eq(ItemTransaction::operatorId, operatorId).list() ktQuery()
.eq(ItemTransaction::operatorId, operatorId)
.list()
} }
override fun createTransaction(transaction: ItemTransaction): Mono<Boolean> = override fun createTransaction(transaction: ItemTransaction): Mono<Boolean> =
@@ -8,6 +8,7 @@ import club.itstudio.itmanager.entity.vo.request.PasswordResetVO
import club.itstudio.itmanager.entity.vo.response.ReloginVO import club.itstudio.itmanager.entity.vo.response.ReloginVO
import club.itstudio.itmanager.service.AccountService import club.itstudio.itmanager.service.AccountService
import club.itstudio.itmanager.utils.Const import club.itstudio.itmanager.utils.Const
import club.itstudio.itmanager.utils.DateUtils
import club.itstudio.itmanager.utils.DateUtils.getCurrentDateTime import club.itstudio.itmanager.utils.DateUtils.getCurrentDateTime
import club.itstudio.itmanager.utils.FlowUtils import club.itstudio.itmanager.utils.FlowUtils
import club.itstudio.itmanager.utils.JwtUtils import club.itstudio.itmanager.utils.JwtUtils
@@ -76,7 +77,7 @@ class AuthorizationService(
isStaff = false, isStaff = false,
createdAt = getCurrentDateTime() createdAt = getCurrentDateTime()
) )
Mono.fromCallable { accountService.save(account) } accountService.createAccount(account)
.flatMap { success -> .flatMap { success ->
if (success) { if (success) {
stringRedisTemplate.delete(Const.VERIFY_EMAIL_DATA + email) stringRedisTemplate.delete(Const.VERIFY_EMAIL_DATA + email)
@@ -120,7 +121,7 @@ class AuthorizationService(
val voMono = accountMono.map { val voMono = accountMono.map {
ReloginVO( ReloginVO(
jwtUtils.createJwt(user, it.id!!, it.username), jwtUtils.createJwt(user, it.id!!, it.username),
jwtUtils.expiresTime().toInstant().toString() DateUtils.dateToLocalDateTime(jwtUtils.expiresTime())
) )
} }
return voMono return voMono
@@ -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<List<EmailNotice>> {
logger.info("Listing notification settings and email list")
return emailNoticeService.listAllNotices()
}
fun updateNotificationSettings(notice: EmailNotice): Mono<String> {
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<String> {
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) }
}
}
@@ -0,0 +1,217 @@
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.service.FileStorageService
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.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.util.NoSuchElementException
@Service
class FinanceApiService(
private val financeRecordService: FinanceRecordService,
private val categoryService: CategoryService,
private val attachmentService: AttachmentService,
private val accountService: AccountService,
private val amqpTemplate: AmqpTemplate,
private val fileStorageService: FileStorageService
) {
private val logger = LoggerFactory.getLogger(this::class.java)
fun getFinanceRecords(
page: Page<FinanceRecord>,
keyword: String?,
type: String?,
start: String?,
end: String?,
vendor: String?,
itemId: Long?
): Mono<Page<FinanceRecord>> {
logger.info("Listing finance records")
return financeRecordService.pageFinanceRecords(page, keyword, type, start, end, vendor, itemId)
}
fun createFinanceRecord(record: FinanceRecord): Mono<String> {
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<String> {
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<String> {
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<FilePart>): Mono<String> {
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<String> {
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<List<Category>> {
logger.info("Listing finance categories")
return categoryService.listAllCategories()
}
fun createFinanceCategory(category: Category): Mono<String> {
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<String> {
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<String> {
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<Attachment> {
return fileStorageService.saveImage(file, "finance", financeRecordId)
.map { stored ->
Attachment(
relatedTable = "finance_records",
relatedId = financeRecordId,
fileName = stored.path.fileName.toString(),
filePath = stored.path.toString(),
fileSize = stored.size,
contentType = file.headers().contentType?.toString(),
uploadedAt = getCurrentDateTime()
)
}
}
private fun cleanupFinanceAttachments(financeRecordId: Long): Mono<Boolean> {
return attachmentService.listByRelated("finance_records", financeRecordId)
.flatMap { attachments ->
Flux.fromIterable(attachments)
.flatMap { fileStorageService.deleteFile(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<Void> {
val recordedBy = record.recordedBy ?: return Mono.empty()
return accountService.findAccountById(recordedBy)
.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<Void> {
amqpTemplate.convertAndSend(
"mail",
EmailVerificationMsg(
email = email,
type = EmailType.FINANCE_DELETE,
code = record.recordNo ?: record.id?.toString() ?: ""
)
)
}.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)
@@ -262,8 +255,7 @@ class ItemsApiService(
fun updateItemImage(attachment: Attachment): Mono<String> { fun updateItemImage(attachment: Attachment): Mono<String> {
logger.info("Updating item image") logger.info("Updating item image")
if (attachment.id == null) return Mono.just("item image id is required.") if (attachment.id == null) return Mono.just("item image id is required.")
return Mono.fromCallable { attachmentService.updateById(attachment) } return attachmentService.updateAttachment(attachment)
.subscribeOn(Schedulers.boundedElastic())
.flatMap { success -> .flatMap { success ->
if (success) Mono.just("") if (success) Mono.just("")
else Mono.just("item image update failed.") else Mono.just("item image update failed.")
@@ -277,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.")
} }
@@ -297,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("")
} }
@@ -340,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())
}
} }
@@ -0,0 +1,83 @@
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<List<Attachment>> {
logger.info("Listing proof images")
return attachmentService.exportAllAttachments()
.map { list -> list.filter { it.relatedTable == RELATED_TABLE } }
}
fun createProofImage(attachment: Attachment): Mono<String> {
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<String> {
logger.info("Updating proof image")
if (attachment.id == null) return Mono.just("proof image id is required.")
return attachmentService.updateAttachment(attachment)
.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<String> {
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<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 {
private const val RELATED_TABLE = "finance_records"
}
}
@@ -0,0 +1,188 @@
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.service.FileStorageService
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.http.codec.multipart.FilePart
import org.springframework.stereotype.Service
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import java.util.NoSuchElementException
@Service
class UsagesApiService(
private val borrowRecordService: BorrowRecordService,
private val attachmentService: AttachmentService,
private val fileStorageService: FileStorageService
) {
private val logger = LoggerFactory.getLogger(this::class.java)
fun getUsages(page: Page<BorrowRecord>, keyword: String?, status: String?): Mono<Page<BorrowRecord>> {
logger.info("Paging usages with keyword and status")
return borrowRecordService.pageUsages(page, keyword, status)
}
fun createUsage(record: BorrowRecord): Mono<String> {
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<String> {
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<String> {
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<FilePart>): Mono<String> {
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<List<BorrowRecord>> {
logger.info("Listing current usages")
return borrowRecordService.listByStatus(STATUS_BORROWED)
.zipWith(borrowRecordService.listOverdueRecords()) { borrowed, overdue -> borrowed + overdue }
}
fun getUsagesByUser(name: String?): Mono<List<BorrowRecord>> {
logger.info("Listing usages by user {}", name)
if (name.isNullOrBlank()) return Mono.just(emptyList())
return borrowRecordService.listByBorrower(name)
}
fun getUsageImages(): Mono<List<Attachment>> {
logger.info("Listing usage images")
return attachmentService.exportAllAttachments()
.map { list -> list.filter { it.relatedTable == RELATED_TABLE } }
}
fun createUsageImage(attachment: Attachment): Mono<String> {
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<String> {
logger.info("Updating usage image")
if (attachment.id == null) return Mono.just("usage image id is required.")
return attachmentService.updateAttachment(attachment)
.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<String> {
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) {
fileStorageService.deleteFile(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<Attachment> {
return fileStorageService.saveImage(file, "borrow_records", usageId)
.map { stored ->
Attachment(
relatedTable = RELATED_TABLE,
relatedId = usageId,
fileName = stored.path.fileName.toString(),
filePath = stored.path.toString(),
fileSize = stored.size,
contentType = file.headers().contentType?.toString(),
uploadedAt = getCurrentDateTime()
)
}
}
private fun cleanupUsageAttachments(usageId: Long): Mono<String> {
return attachmentService.listByRelated(RELATED_TABLE, usageId)
.flatMap { attachments ->
Flux.fromIterable(attachments)
.flatMap { fileStorageService.deleteFile(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("")
}
}
companion object {
private const val RELATED_TABLE = "borrow_records"
private const val STATUS_BORROWED = "BORROWED"
}
}
@@ -0,0 +1,29 @@
package club.itstudio.itmanager.utils
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerializationException
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonPrimitive
import java.math.BigDecimal
object BigDecimalSerializer : KSerializer<BigDecimal> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("BigDecimal", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: BigDecimal) {
encoder.encodeString(value.toPlainString())
}
override fun deserialize(decoder: Decoder): BigDecimal {
val element = (decoder as JsonDecoder).decodeJsonElement()
if (element !is JsonPrimitive) {
throw SerializationException("Expected JSON primitive, got $element")
}
return element.content.toBigDecimal()
}
}
@@ -10,8 +10,8 @@ import org.springframework.data.redis.core.StringRedisTemplate
import org.springframework.security.core.userdetails.User import org.springframework.security.core.userdetails.User
import org.springframework.security.core.userdetails.UserDetails import org.springframework.security.core.userdetails.UserDetails
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import java.time.Duration
import java.util.* import java.util.*
import java.util.concurrent.TimeUnit
@Component @Component
class JwtUtils( class JwtUtils(
@@ -25,7 +25,7 @@ class JwtUtils(
private val algorithm: Algorithm = Algorithm.HMAC256(key) private val algorithm: Algorithm = Algorithm.HMAC256(key)
private val jwtVerifier: JWTVerifier = JWT.require(algorithm).build() private val jwtVerifier: JWTVerifier = JWT.require(algorithm).build()
private val logger = LoggerFactory.getLogger(this::class.java) private val logger = LoggerFactory.getLogger(this::class.java)
fun invalidateJwt(headerToken: String?): Boolean { fun invalidateJwt(headerToken: String?): Boolean {
val token = this.convertToToken(headerToken) ?: return false val token = this.convertToToken(headerToken) ?: return false
try { try {
@@ -44,8 +44,7 @@ class JwtUtils(
template.opsForValue().set( template.opsForValue().set(
Const.JWT_BLACK_LIST + uuid, Const.JWT_BLACK_LIST + uuid,
"deleted", "deleted",
expire, Duration.ofMillis(expire)
TimeUnit.MILLISECONDS
) )
return true return true
} }
@@ -59,10 +58,10 @@ class JwtUtils(
val decodedJWT = jwtVerifier.verify(token) val decodedJWT = jwtVerifier.verify(token)
if (this.isInvalidToken(decodedJWT.id)) return null if (this.isInvalidToken(decodedJWT.id)) return null
val expiresAt = decodedJWT.expiresAt val expiresAt = decodedJWT.expiresAt
return if (Date().after(expiresAt)) null return if (Date().after(expiresAt)) null
else decodedJWT else decodedJWT
} catch (e: Exception) { } catch (e: Exception) {
logger.error("Error occurred while resolving JWT", e) logger.error("Error occurred while resolving JWT", e)
return null return null
@@ -104,6 +103,8 @@ class JwtUtils(
return cal.time return cal.time
} }
fun convertToToken(headerToken: String?): String? = fun convertToToken(headerToken: String?): String? {
headerToken?.replace("Bearer ", "") val token = headerToken?.replace("Bearer ", "")?.trim()
return token?.takeIf { it.isNotEmpty() }
}
} }
@@ -1,4 +1,7 @@
spring: spring:
profiles:
active: private
webflux: webflux:
base-path: / base-path: /
@@ -17,8 +20,8 @@ spring:
host: localhost host: localhost
port: 5672 port: 5672
virtual-host: / virtual-host: /
username: admin #username: admin
password: 123456 #password: 123456
application: application:
name: itmanager name: itmanager
@@ -29,11 +32,14 @@ spring:
expire-hours: 168 expire-hours: 168
datasource: datasource:
url: jdbc:mariadb://localhost:3306/kotlin_springboot url: jdbc:mariadb://localhost:3306/itstudio_manager
driver-class-name: org.mariadb.jdbc.Driver driver-class-name: org.mariadb.jdbc.Driver
#username: root #username: root
#password: 123456 #password: 123456
mybatis-plus:
type-handlers-package: club.itstudio.itmanager.mapper.typehandler
app: app:
flow-limit: flow-limit:
count: 1000 count: 1000
@@ -2,12 +2,14 @@ package club.itstudio.itmanager
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.context.SpringBootTest
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
@SpringBootTest @SpringBootTest
class ItmanagerApplicationTests { class ItmanagerApplicationTests {
@Test @Test
fun contextLoads() { fun contextLoads() {
println(BCryptPasswordEncoder().encode("123456"))
} }
} }
-9
View File
@@ -1,9 +0,0 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
@@ -1,29 +0,0 @@
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/departments")
class DepartmentsController {
// departments/ GET/POST/PUT/DELETE 部门 CRUD 操作
@GetMapping("", "/")
fun getDepartments(): Mono<String> = Const.INTERNAL_ERROR_MONO
@PostMapping("", "/")
fun createDepartment(): Mono<String> = Const.INTERNAL_ERROR_MONO
@PutMapping("", "/")
fun updateDepartment(): Mono<String> = Const.INTERNAL_ERROR_MONO
@DeleteMapping("", "/")
fun deleteDepartment(@RequestParam(required = false) id: String?): Mono<String> = Const.INTERNAL_ERROR_MONO
// departments/{id}/ DELETE 删除部门(发送邮件通知)
@DeleteMapping("/{id}", "/{id}/")
fun deleteById(@PathVariable("id") id: String): Mono<String> = Const.INTERNAL_ERROR_MONO
}
@@ -1,23 +0,0 @@
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<String> = Const.INTERNAL_ERROR_MONO
@PostMapping("/notification-settings", "/notification-settings/")
fun updateNotificationSettings(): Mono<String> = Const.INTERNAL_ERROR_MONO
// toggle-email-status/ POST 切换邮箱启用/禁用状态
@PostMapping("/toggle-email-status", "/toggle-email-status/")
fun toggleEmailStatus(): Mono<String> = Const.INTERNAL_ERROR_MONO
}
@@ -1,46 +0,0 @@
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<String> = Const.INTERNAL_ERROR_MONO
@PostMapping("", "/")
fun createFinanceRecord(): Mono<String> = Const.INTERNAL_ERROR_MONO
@PutMapping("", "/")
fun updateFinanceRecord(): Mono<String> = Const.INTERNAL_ERROR_MONO
@DeleteMapping("", "/")
fun deleteFinanceRecord(@RequestParam(required = false) id: String?): Mono<String> = Const.INTERNAL_ERROR_MONO
// finance/{id}/upload_images/ POST 上传财务凭证图片
@PostMapping("/{id}/upload_images", "/{id}/upload_images/")
fun uploadImages(@PathVariable("id") id: String): Mono<String> = Const.INTERNAL_ERROR_MONO
// finance/{id}/ DELETE 删除财务记录(发送邮件通知)
@DeleteMapping("/{id}", "/{id}/")
fun deleteById(@PathVariable("id") id: String): Mono<String> = Const.INTERNAL_ERROR_MONO
// finance/categories/ GET/POST/PUT/DELETE 财务分类 CRUD 操作
@GetMapping("/categories", "/categories/")
fun getFinanceCategories(): Mono<String> = Const.INTERNAL_ERROR_MONO
@PostMapping("/categories", "/categories/")
fun createFinanceCategory(): Mono<String> = Const.INTERNAL_ERROR_MONO
@PutMapping("/categories", "/categories/")
fun updateFinanceCategory(): Mono<String> = Const.INTERNAL_ERROR_MONO
@DeleteMapping("/categories", "/categories/")
fun deleteFinanceCategory(@RequestParam(required = false) id: String?): Mono<String> = Const.INTERNAL_ERROR_MONO
}
@@ -1,29 +0,0 @@
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<String> = Const.INTERNAL_ERROR_MONO
@PostMapping("", "/")
fun createProofImage(): Mono<String> = Const.INTERNAL_ERROR_MONO
@PutMapping("", "/")
fun updateProofImage(): Mono<String> = Const.INTERNAL_ERROR_MONO
@DeleteMapping("", "/")
fun deleteProofImage(@RequestParam(required = false) id: String?): Mono<String> = Const.INTERNAL_ERROR_MONO
// proof-images/{id}/ DELETE 删除凭证图片(自动删除物理文件)
@DeleteMapping("/{id}", "/{id}/")
fun deleteById(@PathVariable("id") id: String): Mono<String> = Const.INTERNAL_ERROR_MONO
}

Some files were not shown because too many files have changed in this diff Show More