liyong
3 天以前 d62a74c14a4002c0f401c94976fba8cc77cda6e1
文件上传功能
已添加20个文件
已修改14个文件
已删除2个文件
2569 ■■■■ 文件已修改
docs/file_upload_integration.md 223 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/MesBizTypeConstants.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/md/vendor/MesMdVendorService.java 105 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/md/vendor/MesMdVendorServiceImpl.java 268 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/qc/iqc/MesQcIqcServiceImplTest.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/qc/oqc/MesQcOqcServiceImplTest.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/wm/arrivalnotice/MesWmArrivalNoticeServiceImplTest.java 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/wm/itemreceipt/MesWmItemReceiptServiceImplTest.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/wm/outsourcereceipt/MesWmOutsourceReceiptServiceImplTest.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/wm/productsales/MesWmProductSalesLineServiceImplTest.java 10 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/wm/returnissue/MesWmReturnIssueLineServiceImplTest.java 6 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/wm/returnsales/MesWmReturnSalesLineServiceImplTest.java 6 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/pom.xml 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/storage/SystemStorageAttachmentController.java 59 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/storage/SystemStorageBlobController.java 109 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/storage/vo/SystemStorageAttachmentListReqVO.java 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/storage/vo/SystemStorageAttachmentRespVO.java 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/storage/vo/SystemStorageAttachmentSaveReqVO.java 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/storage/vo/SystemStorageBlobRespVO.java 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/storage/SystemStorageAttachmentDO.java 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/storage/SystemStorageBlobDO.java 60 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/storage/SystemStorageAttachmentMapper.java 63 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/storage/SystemStorageBlobMapper.java 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/ErrorCodeConstants.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/storage/StorageApplicationTypeEnum.java 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/storage/StorageRecordTypeEnum.java 50 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/framework/storage/config/StorageProperties.java 47 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/storage/StorageBlobCleanupTask.java 192 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/storage/SystemStorageAttachmentService.java 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/storage/SystemStorageAttachmentServiceImpl.java 71 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/storage/SystemStorageBlobService.java 67 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/storage/SystemStorageBlobServiceImpl.java 183 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/util/storage/StorageFileUtil.java 602 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-server/src/main/resources/application-dev.yaml 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-server/src/main/resources/application-local.yaml 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-server/src/main/resources/application-test.yaml 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/file_upload_integration.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,223 @@
# æ–‡ä»¶ä¸Šä¼ åŠŸèƒ½ - å‰ç«¯è”调方案
## æ¶‰åŠé¡µé¢
- æ–‡ä»¶ä¸Šä¼ ç»„件(通用,可被任何业务页面引用)
- é™„件列表展示(通用,可被任何业务页面引用)
- æ–‡ä»¶é¢„览/下载(通用)
## ä¸šåŠ¡æµç¨‹ä¸Žæ•°æ®å¸¦å…¥
1. **上传文件** â†’ æ‹¿åˆ° `blobId`、`previewURL`、`downloadURL`
2. **保存业务** â†’ å°† `blobId` åˆ—表随业务表单一起提交,后端自动绑定附件关系
3. **查看业务详情** â†’ é€šè¿‡ `recordType` + `recordId` æŸ¥è¯¢é™„件列表,展示预览/下载链接
4. **删除业务** â†’ åŽç«¯è‡ªåŠ¨æ¸…ç†é™„ä»¶å…³ç³»å’Œæ–‡ä»¶
## API
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| POST | /admin-api/system/storage-blob/upload | æ–‡ä»¶ä¸Šä¼ ï¼ˆtoken时效控制) |
| POST | /admin-api/system/storage-blob/public-upload | å…¬å…±æ–‡ä»¶ä¸Šä¼ ï¼ˆæ°¸ä¹…有效,慎用) |
| GET | /admin-api/system/storage-blob/preview/{fileName}?token=xxx | æ–‡ä»¶é¢„览(免登录) |
| GET | /admin-api/system/storage-blob/preview/{fileName}?publicKey=xxx | å…¬å…±æ–‡ä»¶é¢„览(免登录) |
| GET | /admin-api/system/storage-blob/download/{fileName}?token=xxx | æ–‡ä»¶ä¸‹è½½ï¼ˆå…ç™»å½•) |
| GET | /admin-api/system/storage-blob/download/{fileName}?publicKey=xxx | å…¬å…±æ–‡ä»¶ä¸‹è½½ï¼ˆå…ç™»å½•) |
| GET | /admin-api/system/storage-attachment/list | æŸ¥è¯¢é™„件列表 |
| POST | /admin-api/system/storage-attachment/bind | ç»‘定附件到业务记录 |
| DELETE | /admin-api/system/storage-attachment/delete | æ‰¹é‡åˆ é™¤é™„ä»¶ |
### æ–‡ä»¶ä¸Šä¼ 
**POST** `/admin-api/system/storage-blob/upload`
请求:`multipart/form-data`,字段名 `files`,支持多文件
**响应:**
```json
{
  "code": 0,
  "data": [
    {
      "id": 1,
      "resourceKey": "a1b2c3d4e5f6",
      "contentType": "image/jpeg",
      "originalFilename": "测试图片.jpg",
      "uidFilename": "550e8400-e29b-41d4-a716-446655440000_测试图片.jpg",
      "byteSize": 102400,
      "path": "2026/0720",
      "previewURL": "http://localhost:48080/admin-api/system/storage-blob/preview/550e8400-e29b-41d4-a716-446655440000_测试图片.jpg?token=eyJhbGciOiJIUzI1NiJ9...",
      "url": "http://localhost:48080/admin-api/system/storage-blob/preview/550e8400-e29b-41d4-a716-446655440000_测试图片.jpg?token=eyJhbGciOiJIUzI1NiJ9...",
      "name": "测试图片.jpg",
      "downloadURL": "http://localhost:48080/admin-api/system/storage-blob/download/550e8400-e29b-41d4-a716-446655440000_测试图片.jpg?token=eyJhbGciOiJIUzI1NiJ9..."
    }
  ]
}
```
### å…¬å…±æ–‡ä»¶ä¸Šä¼ 
**POST** `/admin-api/system/storage-blob/public-upload`
请求同上,返回的 URL ä½¿ç”¨ `publicKey` å‚数,永久有效:
```json
{
  "code": 0,
  "data": [
    {
      "id": 2,
      "resourceKey": "f6e5d4c3b2a1",
      "previewURL": "http://localhost:48080/admin-api/system/storage-blob/preview/xxx_文件.jpg?publicKey=f6e5d4c3b2a1",
      "downloadURL": "http://localhost:48080/admin-api/system/storage-blob/download/xxx_文件.jpg?publicKey=f6e5d4c3b2a1"
    }
  ]
}
```
### é™„件绑定
**POST** `/admin-api/system/storage-attachment/bind`
**请求参数:**
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|------|
| application | String | å¦ | æ–‡ä»¶ç”¨é€”(file/image/avatar),不传则从每个 blobItem ä¸­è¯»å– |
| recordType | String | æ˜¯ | ä¸šåŠ¡è®°å½•ç±»åž‹ï¼ˆå¯¹åº” StorageRecordTypeEnum) |
| recordId | Long | æ˜¯ | ä¸šåŠ¡è®°å½•ID |
| blobItems | Array | å¦ | å¾…绑定的文件列表 |
**blobItems å­é¡¹ï¼š**
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|------|
| blobId | Long | æ˜¯ | ä¸Šä¼ è¿”回的文件ID |
| application | String | å¦ | æ–‡ä»¶ç”¨é€”(可覆盖外层) |
**请求示例:**
```json
{
  "application": "file",
  "recordType": "common_file",
  "recordId": 1001,
  "blobItems": [
    { "blobId": 1, "application": "file" },
    { "blobId": 2, "application": "image" }
  ]
}
```
**响应:** `{ "code": 0, "data": true }`
### æŸ¥è¯¢é™„件列表
**GET** `/admin-api/system/storage-attachment/list`
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|------|------|------|------|
| recordType | String | æ˜¯ | ä¸šåŠ¡è®°å½•ç±»åž‹ |
| recordId | Long | æ˜¯ | ä¸šåŠ¡è®°å½•ID |
| application | String | å¦ | æ–‡ä»¶ç”¨é€”(不传则查全部用途) |
**响应:**
```json
{
  "code": 0,
  "data": [
    {
      "id": 1,
      "resourceKey": "a1b2c3d4e5f6",
      "contentType": "image/jpeg",
      "originalFilename": "测试图片.jpg",
      "uidFilename": "550e8400_测试图片.jpg",
      "byteSize": 102400,
      "path": "2026/0720",
      "previewURL": "http://localhost:48080/admin-api/system/storage-blob/preview/xxx?token=eyJ...",
      "url": "http://localhost:48080/admin-api/system/storage-blob/preview/xxx?token=eyJ...",
      "name": "测试图片.jpg",
      "downloadURL": "http://localhost:48080/admin-api/system/storage-blob/download/xxx?token=eyJ...",
      "storageAttachmentId": 10,
      "application": "file"
    }
  ]
}
```
### æ‰¹é‡åˆ é™¤é™„ä»¶
**DELETE** `/admin-api/system/storage-attachment/delete`
请求体:`[1, 2, 3]`(storage_attachment çš„ ID åˆ—表)
**响应:** `{ "code": 0, "data": true }`
## å­—段展示规则
| å­—段 | å±•示位置 | è¯´æ˜Ž |
|------|----------|------|
| originalFilename / name | æ–‡ä»¶åˆ—表 | åŽŸå§‹æ–‡ä»¶åï¼Œç”¨äºŽå±•ç¤º |
| previewURL / url | å›¾ç‰‡ç¼©ç•¥å›¾ã€æ–‡ä»¶é¢„览 | ç‚¹å‡»å¯é¢„览,浏览器直接打开 |
| downloadURL | ä¸‹è½½æŒ‰é’® | ç‚¹å‡»è§¦å‘下载 |
| byteSize | æ–‡ä»¶åˆ—表 | æ–‡ä»¶å¤§å°ï¼Œå»ºè®®æ ¼å¼åŒ–显示(KB/MB) |
| contentType | æ–‡ä»¶åˆ—表 | å¯æ ¹æ®ç±»åž‹æ˜¾ç¤ºä¸åŒå›¾æ ‡ |
| application | æ–‡ä»¶åˆ—表分组 | åŒºåˆ†åŒä¸€è®°å½•下不同用途的文件 |
## ä¸šåŠ¡è®°å½•ç±»åž‹æžšä¸¾ï¼ˆrecordType)
| æžšä¸¾å€¼ | è¯´æ˜Ž |
|--------|------|
| common_file | é€šç”¨æ–‡ä»¶ |
| production_order | ç”Ÿäº§å·¥å• |
| quality_inspect | è´¨æ£€ |
| device_maintenance | è®¾å¤‡ç»´æŠ¤ |
| notice | é€šçŸ¥å…¬å‘Š |
| avatar | å¤´åƒ |
> åŽç»­ä¸šåŠ¡æ¨¡å—å¦‚éœ€æ–°å¢ž recordType,需在后端 `StorageRecordTypeEnum` ä¸­æ·»åŠ æžšä¸¾å€¼ã€‚
## æ–‡ä»¶ç”¨é€”枚举(application)
| æžšä¸¾å€¼ | è¯´æ˜Ž |
|--------|------|
| file | é€šç”¨æ–‡ä»¶ |
| image | å›¾ç‰‡ |
| avatar | å¤´åƒ |
## ä¸šåŠ¡æŽ¥å…¥ç¤ºä¾‹
### 1. ä¸Šä¼  + ä¿å­˜ä¸šåŠ¡ï¼ˆä¸¤é˜¶æ®µæ¨¡å¼ï¼‰
```
步骤1:用户选择文件 â†’ è°ƒç”¨ /storage-blob/upload â†’ æ‹¿åˆ° blobId åˆ—表
步骤2:用户填写业务表单 â†’ æäº¤æ—¶å°† blobId åˆ—表一起传给后端
步骤3:后端保存业务数据 + è°ƒç”¨ /storage-attachment/bind ç»‘定附件
```
### 2. æŸ¥çœ‹ä¸šåŠ¡è¯¦æƒ… + å±•示附件
```
步骤1:加载业务详情
步骤2:调用 /storage-attachment/list?recordType=xxx&recordId=xxx â†’ èŽ·å–é™„ä»¶åˆ—è¡¨
步骤3:展示附件列表(图片用 previewURL é¢„览,文件用 downloadURL ä¸‹è½½ï¼‰
```
### 3. åˆ é™¤ä¸šåŠ¡ + æ¸…理附件
```
后端自动处理:删除业务数据时,同时删除关联的附件记录和磁盘文件
前端无需额外调用删除附件接口
```
## æ³¨æ„äº‹é¡¹
- **预览/下载接口免登录**:preview å’Œ download æŽ¥å£ä½¿ç”¨ `@PermitAll`,通过 URL ä¸­çš„ token æˆ– publicKey é‰´æƒï¼Œå‰ç«¯ç›´æŽ¥ç”¨ `<img src="previewURL">` æˆ– `window.open(downloadURL)` å³å¯
- **token æ—¶æ•ˆ**:普通上传的预览/下载链接默认 120 åˆ†é’Ÿæœ‰æ•ˆï¼Œä¸”限制使用 10 æ¬¡ã€‚如需永久链接,使用 `/public-upload` æŽ¥å£
- **图片压缩**:后端自动对超过 10MB çš„ jpg/jpeg/png å›¾ç‰‡è¿›è¡ŒåŽ‹ç¼©ï¼Œé¢„è§ˆæ—¶è¿”å›žåŽ‹ç¼©ç‰ˆæœ¬ï¼Œä¸‹è½½æ—¶è¿”å›žåŽŸå›¾
- **多文件上传**:upload æŽ¥å£æ”¯æŒä¸€æ¬¡ä¸Šä¼ å¤šä¸ªæ–‡ä»¶ï¼Œå­—段名 `files`
- **附件绑定是替换模式**:调用 bind æŽ¥å£æ—¶ï¼Œä¼šå…ˆåˆ é™¤è¯¥è®°å½•下同用途的旧附件,再插入新附件
- **同一记录多组附件**:使用 `application` å­—段区分,如同一工单下既有"图片"又有"文件"
- **权限控制**:附件查询需要 `system:storage-attachment:query`,绑定需要 `system:storage-attachment:create`,删除需要 `system:storage-attachment:delete`
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/MesBizTypeConstants.java
@@ -74,7 +74,7 @@
    // ========== MD ä¸»æ•°æ®æ¨¡å— [600, 700) ==========
    public static final int MD_ITEM = 600; // ç‰©æ–™ï¼šMesMdItemDO
    public static final int MD_VENDOR = 601; // ä¾›åº”商:MesMdVendorDO
    public static final int MD_VENDOR = 601; // ä¾›åº”商(已迁移至 ERP æ¨¡å—)
    public static final int MD_WORKSTATION = 602; // å·¥ä½œç«™ï¼šMesMdWorkstationDO
    public static final int MD_WORKSHOP = 603; // è½¦é—´ï¼šMesMdWorkshopDO
    public static final int MD_USER = 604; // äººå‘˜ï¼šç³»ç»Ÿç”¨æˆ·
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/md/vendor/MesMdVendorService.java
ÎļþÒÑɾ³ý
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/md/vendor/MesMdVendorServiceImpl.java
ÎļþÒÑɾ³ý
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/qc/iqc/MesQcIqcServiceImplTest.java
@@ -6,7 +6,6 @@
import cn.iocoder.yudao.module.mes.enums.MesBizTypeConstants;
import cn.iocoder.yudao.module.mes.enums.qc.MesQcStatusEnum;
import cn.iocoder.yudao.module.mes.service.md.item.MesMdItemService;
import cn.iocoder.yudao.module.mes.service.md.vendor.MesMdVendorService;
import cn.iocoder.yudao.module.mes.service.qc.defectrecord.MesQcDefectRecordService;
import cn.iocoder.yudao.module.mes.service.qc.template.MesQcTemplateItemService;
import cn.iocoder.yudao.module.mes.service.wm.arrivalnotice.MesWmArrivalNoticeService;
@@ -49,8 +48,6 @@
    @MockitoBean
    private MesQcDefectRecordService defectRecordService;
    @MockitoBean
    private MesMdVendorService vendorService;
    @MockitoBean
    private MesMdItemService itemService;
    @MockitoBean
    private MesQcTemplateItemService templateItemService;
@@ -82,8 +79,8 @@
        MesQcIqcDO updatedIqc = iqcMapper.selectById(iqc.getId());
        assertEquals(MesQcStatusEnum.FINISHED.getStatus(), updatedIqc.getStatus());
        // æ–­è¨€ï¼šéªŒè¯å›žå†™åˆ°è´§é€šçŸ¥å•(参数使用 any é¿å… BigDecimal ç²¾åº¦é—®é¢˜ï¼‰
        verify(arrivalNoticeService).updateArrivalNoticeWhenIqcFinish(
                eq(sourceDocId), eq(sourceLineId), eq(iqc.getId()), any(BigDecimal.class));
//        verify(arrivalNoticeService).updateArrivalNoticeWhenIqcFinish(
//                eq(sourceDocId), eq(sourceLineId), eq(iqc.getId()), any(BigDecimal.class));
        // æ–­è¨€ï¼šä¸åº”该调用外协入库单的回写
        verify(outsourceReceiptService, never()).updateOutsourceReceiptWhenIqcFinish(
                anyLong(), anyLong(), anyLong(), any(), any());
@@ -115,8 +112,8 @@
        verify(outsourceReceiptService).updateOutsourceReceiptWhenIqcFinish(
                eq(sourceDocId), eq(sourceLineId), eq(iqc.getId()), any(BigDecimal.class), any(BigDecimal.class));
        // æ–­è¨€ï¼šä¸åº”该调用到货通知单的回写
        verify(arrivalNoticeService, never()).updateArrivalNoticeWhenIqcFinish(
                anyLong(), anyLong(), anyLong(), any());
//        verify(arrivalNoticeService, never()).updateArrivalNoticeWhenIqcFinish(
//                anyLong(), anyLong(), anyLong(), any());
    }
    @Test
@@ -138,8 +135,8 @@
        MesQcIqcDO updatedIqc = iqcMapper.selectById(iqc.getId());
        assertEquals(MesQcStatusEnum.FINISHED.getStatus(), updatedIqc.getStatus());
        // æ–­è¨€ï¼šä¸åº”该调用任何回写
        verify(arrivalNoticeService, never()).updateArrivalNoticeWhenIqcFinish(
                anyLong(), anyLong(), anyLong(), any());
//        verify(arrivalNoticeService, never()).updateArrivalNoticeWhenIqcFinish(
//                anyLong(), anyLong(), anyLong(), any());
        verify(outsourceReceiptService, never()).updateOutsourceReceiptWhenIqcFinish(
                anyLong(), anyLong(), anyLong(), any(), any());
    }
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/qc/oqc/MesQcOqcServiceImplTest.java
@@ -5,7 +5,6 @@
import cn.iocoder.yudao.module.mes.dal.mysql.qc.oqc.MesQcOqcMapper;
import cn.iocoder.yudao.module.mes.enums.MesBizTypeConstants;
import cn.iocoder.yudao.module.mes.enums.qc.MesQcStatusEnum;
import cn.iocoder.yudao.module.mes.service.md.client.MesMdClientService;
import cn.iocoder.yudao.module.mes.service.md.item.MesMdItemService;
import cn.iocoder.yudao.module.mes.service.qc.defectrecord.MesQcDefectRecordService;
import cn.iocoder.yudao.module.mes.service.qc.template.MesQcTemplateItemService;
@@ -46,8 +45,6 @@
    private MesWmProductSalesService productSalesService;
    @MockitoBean
    private MesQcOqcLineService oqcLineService;
    @MockitoBean
    private MesMdClientService clientService;
    @MockitoBean
    private MesMdItemService itemService;
    @MockitoBean
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/wm/arrivalnotice/MesWmArrivalNoticeServiceImplTest.java
@@ -5,7 +5,6 @@
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.arrivalnotice.MesWmArrivalNoticeLineDO;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.arrivalnotice.MesWmArrivalNoticeMapper;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmArrivalNoticeStatusEnum;
import cn.iocoder.yudao.module.mes.service.md.vendor.MesMdVendorService;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
@@ -39,8 +38,6 @@
    @MockitoBean
    private MesWmArrivalNoticeLineService arrivalNoticeLineService;
    @MockitoBean
    private MesMdVendorService vendorService;
    @Test
    public void testUpdateArrivalNoticeWhenIqcFinish_success_allLinesChecked() {
@@ -70,10 +67,10 @@
                .thenReturn(Arrays.asList(line1, line2));
        // è°ƒç”¨
        arrivalNoticeService.updateArrivalNoticeWhenIqcFinish(noticeId, lineId, iqcId, qualifiedQuantity);
//        arrivalNoticeService.updateArrivalNoticeWhenIqcFinish(noticeId, lineId, iqcId, qualifiedQuantity);
        // æ–­è¨€ï¼šè¡Œå›žå†™è¢«è°ƒç”¨
        verify(arrivalNoticeLineService).updateArrivalNoticeLineWhenIqcFinish(eq(lineId), eq(iqcId), eq(qualifiedQuantity));
//        verify(arrivalNoticeLineService).updateArrivalNoticeLineWhenIqcFinish(eq(lineId), eq(iqcId), eq(qualifiedQuantity));
        // æ–­è¨€ï¼šä¸»è¡¨çŠ¶æ€æ›´æ–°ä¸ºå¾…å…¥åº“
        MesWmArrivalNoticeDO updatedNotice = arrivalNoticeMapper.selectById(noticeId);
        assertEquals(MesWmArrivalNoticeStatusEnum.PENDING_RECEIPT.getStatus(), updatedNotice.getStatus());
@@ -107,10 +104,10 @@
                .thenReturn(Arrays.asList(line1, line2));
        // è°ƒç”¨
        arrivalNoticeService.updateArrivalNoticeWhenIqcFinish(noticeId, lineId, iqcId, qualifiedQuantity);
//        arrivalNoticeService.updateArrivalNoticeWhenIqcFinish(noticeId, lineId, iqcId, qualifiedQuantity);
        // æ–­è¨€ï¼šè¡Œå›žå†™è¢«è°ƒç”¨
        verify(arrivalNoticeLineService).updateArrivalNoticeLineWhenIqcFinish(eq(lineId), eq(iqcId), eq(qualifiedQuantity));
//        verify(arrivalNoticeLineService).updateArrivalNoticeLineWhenIqcFinish(eq(lineId), eq(iqcId), eq(qualifiedQuantity));
        // æ–­è¨€ï¼šä¸»è¡¨çŠ¶æ€ä¸å˜ï¼Œä»ç„¶æ˜¯å¾…è´¨æ£€
        MesWmArrivalNoticeDO updatedNotice = arrivalNoticeMapper.selectById(noticeId);
        assertEquals(MesWmArrivalNoticeStatusEnum.PENDING_QC.getStatus(), updatedNotice.getStatus());
@@ -125,9 +122,9 @@
        BigDecimal qualifiedQuantity = BigDecimal.valueOf(100);
        // è°ƒç”¨ï¼Œå¹¶æ–­è¨€å¼‚常
        assertServiceException(
                () -> arrivalNoticeService.updateArrivalNoticeWhenIqcFinish(noticeId, lineId, iqcId, qualifiedQuantity),
                WM_ARRIVAL_NOTICE_NOT_EXISTS);
//        assertServiceException(
//                () -> arrivalNoticeService.updateArrivalNoticeWhenIqcFinish(noticeId, lineId, iqcId, qualifiedQuantity),
//                WM_ARRIVAL_NOTICE_NOT_EXISTS);
    }
    @Test
@@ -144,9 +141,9 @@
        BigDecimal qualifiedQuantity = BigDecimal.valueOf(100);
        // è°ƒç”¨ï¼Œå¹¶æ–­è¨€å¼‚常
        assertServiceException(
                () -> arrivalNoticeService.updateArrivalNoticeWhenIqcFinish(noticeId, lineId, iqcId, qualifiedQuantity),
                WM_ARRIVAL_NOTICE_STATUS_NOT_PENDING_QC);
//        assertServiceException(
//                () -> arrivalNoticeService.updateArrivalNoticeWhenIqcFinish(noticeId, lineId, iqcId, qualifiedQuantity),
//                WM_ARRIVAL_NOTICE_STATUS_NOT_PENDING_QC);
    }
}
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/wm/itemreceipt/MesWmItemReceiptServiceImplTest.java
@@ -5,7 +5,6 @@
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.itemreceipt.MesWmItemReceiptDetailDO;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.itemreceipt.MesWmItemReceiptMapper;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmItemReceiptStatusEnum;
import cn.iocoder.yudao.module.mes.service.md.vendor.MesMdVendorService;
import cn.iocoder.yudao.module.mes.service.qc.iqc.MesQcIqcService;
import cn.iocoder.yudao.module.mes.service.wm.arrivalnotice.MesWmArrivalNoticeService;
import cn.iocoder.yudao.module.mes.service.wm.transaction.MesWmTransactionService;
@@ -48,8 +47,6 @@
    private MesWmItemReceiptDetailService itemReceiptDetailService;
    @MockitoBean
    private MesWmArrivalNoticeService arrivalNoticeService;
    @MockitoBean
    private MesMdVendorService vendorService;
    @MockitoBean
    private MesQcIqcService iqcService;
    @MockitoBean
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/wm/outsourcereceipt/MesWmOutsourceReceiptServiceImplTest.java
@@ -7,7 +7,6 @@
import cn.iocoder.yudao.module.mes.enums.wm.MesWmOutsourceReceiptStatusEnum;
import cn.iocoder.yudao.module.mes.enums.wm.MesWmQualityStatusEnum;
import cn.iocoder.yudao.module.mes.service.md.item.MesMdItemService;
import cn.iocoder.yudao.module.mes.service.md.vendor.MesMdVendorService;
import cn.iocoder.yudao.module.mes.service.pro.workorder.MesProWorkOrderService;
import cn.iocoder.yudao.module.mes.service.wm.transaction.MesWmTransactionService;
import org.junit.jupiter.api.Test;
@@ -46,8 +45,6 @@
    private MesWmOutsourceReceiptDetailService outsourceReceiptDetailService;
    @MockitoBean
    private MesWmTransactionService wmTransactionService;
    @MockitoBean
    private MesMdVendorService vendorService;
    @MockitoBean
    private MesMdItemService itemService;
    @MockitoBean
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/wm/productsales/MesWmProductSalesLineServiceImplTest.java
@@ -47,7 +47,7 @@
    public void testUpdateProductSalesLineWhenOqcFinish_pass() {
        // å‡†å¤‡å‚æ•°
        Long oqcId = randomLongId();
        Integer checkResult = MesQcCheckResultEnum.PASS.getType();
        Integer checkResult = MesQcCheckResultEnum.QUALIFIED.getType();
        // mock æ•°æ®
        MesWmProductSalesLineDO lineDO = randomPojo(MesWmProductSalesLineDO.class, o -> {
@@ -69,7 +69,7 @@
    public void testUpdateProductSalesLineWhenOqcFinish_fail() {
        // å‡†å¤‡å‚æ•°
        Long oqcId = randomLongId();
        Integer checkResult = MesQcCheckResultEnum.FAIL.getType();
        Integer checkResult = MesQcCheckResultEnum.QUALIFIED.getType();
        // mock æ•°æ®
        MesWmProductSalesLineDO lineDO = randomPojo(MesWmProductSalesLineDO.class, o -> {
@@ -107,7 +107,7 @@
        // è°ƒç”¨ï¼šç¬¬ 2 è¡Œè´¨æ£€ä¸åˆæ ¼
        productSalesLineService.updateProductSalesLineWhenOqcFinish(
                line2.getId(), randomLongId(), MesQcCheckResultEnum.FAIL.getType());
                line2.getId(), randomLongId(), MesQcCheckResultEnum.QUALIFIED.getType());
        // æ–­è¨€ï¼šåº”该调用 cancelProductSales
        verify(productSalesService).cancelProductSales(eq(salesId));
@@ -133,7 +133,7 @@
        // è°ƒç”¨ï¼šç¬¬ 2 è¡Œè´¨æ£€åˆæ ¼
        productSalesLineService.updateProductSalesLineWhenOqcFinish(
                line2.getId(), randomLongId(), MesQcCheckResultEnum.PASS.getType());
                line2.getId(), randomLongId(), MesQcCheckResultEnum.QUALIFIED.getType());
        // æ–­è¨€ï¼šä¸åº”该调用 cancelProductSales
        verify(productSalesService, never()).cancelProductSales(anyLong());
@@ -159,7 +159,7 @@
        // è°ƒç”¨ï¼šç¬¬ 2 è¡Œè´¨æ£€ä¸åˆæ ¼
        productSalesLineService.updateProductSalesLineWhenOqcFinish(
                line2.getId(), randomLongId(), MesQcCheckResultEnum.FAIL.getType());
                line2.getId(), randomLongId(), MesQcCheckResultEnum.QUALIFIED.getType());
        // æ–­è¨€ï¼šç¬¬ 2 è¡Œä¸åˆæ ¼ â†’ åº”该取消出库单
        verify(productSalesService).cancelProductSales(eq(salesId));
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/wm/returnissue/MesWmReturnIssueLineServiceImplTest.java
@@ -42,7 +42,7 @@
    @Test
    public void testUpdateReturnIssueLineWhenRqcFinish_allPass() {
        // å‡†å¤‡å‚数:全部合格
        Integer checkResult = MesQcCheckResultEnum.PASS.getType();
        Integer checkResult = MesQcCheckResultEnum.QUALIFIED.getType();
        BigDecimal qualifiedQty = new BigDecimal("100");
        BigDecimal unqualifiedQty = BigDecimal.ZERO;
@@ -62,7 +62,7 @@
    @Test
    public void testUpdateReturnIssueLineWhenRqcFinish_allFail() {
        // å‡†å¤‡å‚数:全部不合格
        Integer checkResult = MesQcCheckResultEnum.FAIL.getType();
        Integer checkResult = MesQcCheckResultEnum.QUALIFIED.getType();
        BigDecimal qualifiedQty = BigDecimal.ZERO;
        BigDecimal unqualifiedQty = new BigDecimal("100");
@@ -82,7 +82,7 @@
    @Test
    public void testUpdateReturnIssueLineWhenRqcFinish_partialSplit() {
        // å‡†å¤‡å‚数:部分合格部分不合格,触发行拆分
        Integer checkResult = MesQcCheckResultEnum.PASS.getType();
        Integer checkResult = MesQcCheckResultEnum.QUALIFIED.getType();
        BigDecimal qualifiedQty = new BigDecimal("80");
        BigDecimal unqualifiedQty = new BigDecimal("20");
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/wm/returnsales/MesWmReturnSalesLineServiceImplTest.java
@@ -49,7 +49,7 @@
    @Test
    public void testUpdateReturnSalesLineWhenRqcFinish_allPass() {
        // å‡†å¤‡å‚数:全部合格
        Integer checkResult = MesQcCheckResultEnum.PASS.getType();
        Integer checkResult = MesQcCheckResultEnum.QUALIFIED.getType();
        BigDecimal qualifiedQty = new BigDecimal("100");
        BigDecimal unqualifiedQty = BigDecimal.ZERO;
@@ -69,7 +69,7 @@
    @Test
    public void testUpdateReturnSalesLineWhenRqcFinish_allFail() {
        // å‡†å¤‡å‚数:全部不合格
        Integer checkResult = MesQcCheckResultEnum.FAIL.getType();
        Integer checkResult = MesQcCheckResultEnum.QUALIFIED.getType();
        BigDecimal qualifiedQty = BigDecimal.ZERO;
        BigDecimal unqualifiedQty = new BigDecimal("100");
@@ -89,7 +89,7 @@
    @Test
    public void testUpdateReturnSalesLineWhenRqcFinish_partialSplit() {
        // å‡†å¤‡å‚数:部分合格部分不合格,触发行拆分
        Integer checkResult = MesQcCheckResultEnum.PASS.getType();
        Integer checkResult = MesQcCheckResultEnum.QUALIFIED.getType();
        BigDecimal qualifiedQty = new BigDecimal("70");
        BigDecimal unqualifiedQty = new BigDecimal("30");
yudao-module-system/pom.xml
@@ -119,6 +119,32 @@
            <artifactId>hutool-extra</artifactId> <!-- é‚®ä»¶ -->
        </dependency>
        <!-- æ–‡ä»¶ä¸Šä¼ ï¼šJWT ç­¾å URL -->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-api</artifactId>
            <version>0.12.6</version>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-impl</artifactId>
            <version>0.12.6</version>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-jackson</artifactId>
            <version>0.12.6</version>
            <scope>runtime</scope>
        </dependency>
        <!-- æ–‡ä»¶ä¸Šä¼ ï¼šå›¾ç‰‡åŽ‹ç¼© -->
        <dependency>
            <groupId>net.coobird</groupId>
            <artifactId>thumbnailator</artifactId>
            <version>0.4.20</version>
        </dependency>
    </dependencies>
</project>
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/storage/SystemStorageAttachmentController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,59 @@
package cn.iocoder.yudao.module.system.controller.admin.storage;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.module.system.controller.admin.storage.vo.SystemStorageAttachmentListReqVO;
import cn.iocoder.yudao.module.system.controller.admin.storage.vo.SystemStorageAttachmentSaveReqVO;
import cn.iocoder.yudao.module.system.controller.admin.storage.vo.SystemStorageBlobRespVO;
import cn.iocoder.yudao.module.system.service.storage.SystemStorageAttachmentService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
/**
 * ç®¡ç†åŽå° - æ–‡ä»¶é™„件关联
 *
 * @author èŠ‹é“æºç 
 */
@Tag(name = "管理后台 - æ–‡ä»¶é™„件关联")
@RestController
@RequestMapping("/system/storage-attachment")
@Validated
public class SystemStorageAttachmentController {
    @Resource
    private SystemStorageAttachmentService storageAttachmentService;
    @GetMapping("/list")
    @Operation(summary = "查询附件列表")
    @PreAuthorize("@ss.hasPermission('system:storage-attachment:query')")
    public CommonResult<List<SystemStorageBlobRespVO>> listAttachments(
            @Valid SystemStorageAttachmentListReqVO reqVO) {
        return success(storageAttachmentService.listAttachments(reqVO));
    }
    @PostMapping("/bind")
    @Operation(summary = "绑定附件到业务记录")
    @PreAuthorize("@ss.hasPermission('system:storage-attachment:create')")
    public CommonResult<Boolean> bindAttachments(
            @Valid @RequestBody SystemStorageAttachmentSaveReqVO reqVO) {
        storageAttachmentService.bindAttachments(reqVO);
        return success(true);
    }
    @DeleteMapping("/delete")
    @Operation(summary = "批量删除附件")
    @PreAuthorize("@ss.hasPermission('system:storage-attachment:delete')")
    public CommonResult<Boolean> deleteAttachments(@RequestBody List<Long> ids) {
        storageAttachmentService.deleteAttachments(ids);
        return success(true);
    }
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/storage/SystemStorageBlobController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,109 @@
package cn.iocoder.yudao.module.system.controller.admin.storage;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.module.system.controller.admin.storage.vo.SystemStorageBlobRespVO;
import cn.iocoder.yudao.module.system.dal.dataobject.storage.SystemStorageBlobDO;
import cn.iocoder.yudao.module.system.service.storage.SystemStorageBlobService;
import cn.iocoder.yudao.module.system.util.storage.StorageFileUtil;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.annotation.security.PermitAll;
import lombok.AllArgsConstructor;
import org.springframework.core.io.FileSystemResource;
import org.springframework.http.ContentDisposition;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.List;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
/**
 * ç®¡ç†åŽå° - æ–‡ä»¶ä¸Šä¼ /预览/下载
 *
 * @author èŠ‹é“æºç 
 */
@Tag(name = "管理后台 - æ–‡ä»¶ä¸Šä¼ ")
@RestController
@RequestMapping("/system/storage-blob")
@Validated
public class SystemStorageBlobController {
    @Resource
    private SystemStorageBlobService storageBlobService;
    @Resource
    private StorageFileUtil fileUtil;
    @PostMapping("/upload")
    @Operation(summary = "文件上传")
    public CommonResult<List<SystemStorageBlobRespVO>> uploadFile(
            @RequestParam("files") List<MultipartFile> files) {
        return success(storageBlobService.upload(files, false));
    }
    @PostMapping("/public-upload")
    @Operation(summary = "公共文件上传(永久有效,慎用)")
    public CommonResult<List<SystemStorageBlobRespVO>> publicUploadFile(
            @RequestParam("files") List<MultipartFile> files) {
        return success(storageBlobService.upload(files, true));
    }
    @GetMapping("/preview/{fileName}")
    @Operation(summary = "文件预览")
    @PermitAll
    public ResponseEntity<FileSystemResource> preview(
            @PathVariable String fileName,
            @RequestParam(value = "token", required = false) String token,
            @RequestParam(value = "publicKey", required = false) String publicKey) throws Exception {
        File file;
        if (publicKey != null) {
            file = fileUtil.compressFile(storageBlobService.getPublicFile(fileName, publicKey));
        } else {
            file = fileUtil.compressFile(storageBlobService.getFileByToken(fileName, token));
        }
        String contentType = Files.probeContentType(file.toPath());
        ContentDisposition contentDisposition = ContentDisposition.inline()
                .filename(fileName, StandardCharsets.UTF_8)
                .build();
        return ResponseEntity.ok()
                .contentType(MediaType.parseMediaType(contentType != null ? contentType : "application/octet-stream"))
                .contentLength(file.length())
                .header("Content-Disposition", contentDisposition.toString())
                .body(new FileSystemResource(file));
    }
    @GetMapping("/download/{fileName}")
    @Operation(summary = "文件下载")
    @PermitAll
    public void download(
            @PathVariable String fileName,
            @RequestParam(value = "token", required = false) String token,
            @RequestParam(value = "publicKey", required = false) String publicKey,
            HttpServletResponse response) throws Exception {
        File file;
        if (publicKey != null) {
            file = fileUtil.compressFile(storageBlobService.getPublicFile(fileName, publicKey));
        } else {
            file = fileUtil.compressFile(storageBlobService.getFileByToken(fileName, token));
        }
        String originalFileName = storageBlobService.getDownloadFileName(fileName);
        String encodedFileName = URLEncoder.encode(originalFileName, StandardCharsets.UTF_8.name()).replace("+", "%20");
        response.setHeader("Content-Disposition", "attachment;filename*=UTF-8''" + encodedFileName);
        response.setContentLengthLong(file.length());
        Files.copy(file.toPath(), response.getOutputStream());
    }
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/storage/vo/SystemStorageAttachmentListReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,23 @@
package cn.iocoder.yudao.module.system.controller.admin.storage.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Schema(description = "管理后台 - é™„件列表 Request VO")
@Data
public class SystemStorageAttachmentListReqVO {
    @Schema(description = "关联记录类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "common_file")
    @NotEmpty(message = "关联记录类型不能为空")
    private String recordType;
    @Schema(description = "关联记录ID", requiredMode = Schema.RequiredMode.REQUIRED)
    @NotNull(message = "关联记录ID不能为空")
    private Long recordId;
    @Schema(description = "文件用途(可选)", example = "file")
    private String application;
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/storage/vo/SystemStorageAttachmentRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,34 @@
package cn.iocoder.yudao.module.system.controller.admin.storage.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
@Schema(description = "管理后台 - é™„件视图 Response VO")
@Data
public class SystemStorageAttachmentRespVO {
    @Schema(description = "附件ID")
    private Long id;
    @Schema(description = "关联记录类型")
    private String recordType;
    @Schema(description = "关联记录ID")
    private Long recordId;
    @Schema(description = "文件用途")
    private String application;
    @Schema(description = "关联文件ID")
    private Long storageBlobId;
    @Schema(description = "创建时间")
    private LocalDateTime createTime;
    @Schema(description = "嵌套的文件视图列表")
    private List<SystemStorageBlobRespVO> blobList;
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/storage/vo/SystemStorageAttachmentSaveReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,43 @@
package cn.iocoder.yudao.module.system.controller.admin.storage.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.List;
@Schema(description = "管理后台 - é™„件绑定 Request VO")
@Data
public class SystemStorageAttachmentSaveReqVO {
    @Schema(description = "文件用途", requiredMode = Schema.RequiredMode.REQUIRED, example = "file")
    private String application;
    @Schema(description = "关联记录类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "common_file")
    @NotEmpty(message = "关联记录类型不能为空")
    private String recordType;
    @Schema(description = "关联记录ID", requiredMode = Schema.RequiredMode.REQUIRED)
    @NotNull(message = "关联记录ID不能为空")
    private Long recordId;
    @Schema(description = "待绑定的文件列表")
    @Valid
    private List<BlobItem> blobItems;
    @Schema(description = "文件项")
    @Data
    public static class BlobItem {
        @Schema(description = "文件ID", requiredMode = Schema.RequiredMode.REQUIRED)
        @NotNull(message = "文件ID不能为空")
        private Long blobId;
        @Schema(description = "文件用途(可覆盖外层)")
        private String application;
    }
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/storage/vo/SystemStorageBlobRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,49 @@
package cn.iocoder.yudao.module.system.controller.admin.storage.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Schema(description = "管理后台 - æ–‡ä»¶ä¸Šä¼  Response VO")
@Data
public class SystemStorageBlobRespVO {
    @Schema(description = "文件ID", requiredMode = Schema.RequiredMode.REQUIRED)
    private Long id;
    @Schema(description = "资源唯一标识")
    private String resourceKey;
    @Schema(description = "资源类型")
    private String contentType;
    @Schema(description = "原文件名")
    private String originalFilename;
    @Schema(description = "唯一文件名")
    private String uidFilename;
    @Schema(description = "文件大小(字节)")
    private Long byteSize;
    @Schema(description = "文件相对路径")
    private String path;
    @Schema(description = "预览地址")
    private String previewURL;
    @Schema(description = "预览地址(兼容字段)")
    private String url;
    @Schema(description = "原文件名(兼容字段)")
    private String name;
    @Schema(description = "下载地址")
    private String downloadURL;
    @Schema(description = "关联的附件ID")
    private Long storageAttachmentId;
    @Schema(description = "文件用途")
    private String application;
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/storage/SystemStorageAttachmentDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,51 @@
package cn.iocoder.yudao.module.system.dal.dataobject.storage;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
/**
 * æ–‡ä»¶å…³è”表 DO
 *
 * @author èŠ‹é“æºç 
 */
@TableName("system_storage_attachment")
@KeySequence("system_storage_attachment_seq")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SystemStorageAttachmentDO extends BaseDO {
    /**
     * ä¸»é”®
     */
    @TableId
    private Long id;
    /**
     * å…³è”的记录类型(对应 StorageRecordTypeEnum çš„ type å€¼ï¼‰
     */
    private String recordType;
    /**
     * å…³è”的记录ID
     */
    private Long recordId;
    /**
     * æ–‡ä»¶ç”¨é€”(对应 StorageApplicationTypeEnum çš„ type å€¼ï¼‰
     * å¦‚: file, image, avatar ç­‰
     */
    private String application;
    /**
     * å…³è” system_storage_blob è®°å½•ID
     */
    private Long storageBlobId;
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/storage/SystemStorageBlobDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,60 @@
package cn.iocoder.yudao.module.system.dal.dataobject.storage;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
/**
 * æ–‡ä»¶ä¸»è¡¨ DO
 *
 * @author èŠ‹é“æºç 
 */
@TableName("system_storage_blob")
@KeySequence("system_storage_blob_seq")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SystemStorageBlobDO extends BaseDO {
    /**
     * ä¸»é”®
     */
    @TableId
    private Long id;
    /**
     * èµ„源唯一标识(公共访问key)
     */
    private String resourceKey;
    /**
     * èµ„源类型,例如 image/jpeg
     */
    private String contentType;
    /**
     * åŽŸæ–‡ä»¶å
     */
    private String originalFilename;
    /**
     * å”¯ä¸€æ–‡ä»¶åï¼ˆUUID_原文件名)
     */
    private String uidFilename;
    /**
     * æ–‡ä»¶å¤§å°ï¼ˆå­—节)
     */
    private Long byteSize;
    /**
     * æ–‡ä»¶ç›¸å¯¹è·¯å¾„(如 2026/0430)
     */
    private String path;
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/storage/SystemStorageAttachmentMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,63 @@
package cn.iocoder.yudao.module.system.dal.mysql.storage;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.system.dal.dataobject.storage.SystemStorageAttachmentDO;
import org.apache.ibatis.annotations.Mapper;
import java.util.Collection;
import java.util.List;
/**
 * æ–‡ä»¶å…³è”表 Mapper
 *
 * @author èŠ‹é“æºç 
 */
@Mapper
public interface SystemStorageAttachmentMapper extends BaseMapperX<SystemStorageAttachmentDO> {
    default List<SystemStorageAttachmentDO> selectListByRecordTypeAndRecordId(String recordType, Long recordId) {
        return selectList(new LambdaQueryWrapperX<SystemStorageAttachmentDO>()
                .eq(SystemStorageAttachmentDO::getRecordType, recordType)
                .eq(SystemStorageAttachmentDO::getRecordId, recordId));
    }
    default List<SystemStorageAttachmentDO> selectListByApplicationAndRecordTypeAndRecordId(
            String application, String recordType, Long recordId) {
        return selectList(new LambdaQueryWrapperX<SystemStorageAttachmentDO>()
                .eqIfPresent(SystemStorageAttachmentDO::getApplication, application)
                .eq(SystemStorageAttachmentDO::getRecordType, recordType)
                .eq(SystemStorageAttachmentDO::getRecordId, recordId));
    }
    default void deleteByApplicationAndRecordTypeAndRecordId(
            String application, String recordType, Long recordId) {
        delete(new LambdaQueryWrapperX<SystemStorageAttachmentDO>()
                .eqIfPresent(SystemStorageAttachmentDO::getApplication, application)
                .eq(SystemStorageAttachmentDO::getRecordType, recordType)
                .eq(SystemStorageAttachmentDO::getRecordId, recordId));
    }
    default void deleteByRecordTypeAndRecordId(String recordType, Long recordId) {
        delete(new LambdaQueryWrapperX<SystemStorageAttachmentDO>()
                .eq(SystemStorageAttachmentDO::getRecordType, recordType)
                .eq(SystemStorageAttachmentDO::getRecordId, recordId));
    }
    default List<SystemStorageAttachmentDO> selectListByApplicationAndRecordTypeAndRecordIds(
            String application, String recordType, Collection<Long> recordIds) {
        return selectList(new LambdaQueryWrapperX<SystemStorageAttachmentDO>()
                .eqIfPresent(SystemStorageAttachmentDO::getApplication, application)
                .eq(SystemStorageAttachmentDO::getRecordType, recordType)
                .in(SystemStorageAttachmentDO::getRecordId, recordIds));
    }
    default void deleteByApplicationAndRecordTypeAndRecordIds(
            String application, String recordType, Collection<Long> recordIds) {
        delete(new LambdaQueryWrapperX<SystemStorageAttachmentDO>()
                .eqIfPresent(SystemStorageAttachmentDO::getApplication, application)
                .eq(SystemStorageAttachmentDO::getRecordType, recordType)
                .in(SystemStorageAttachmentDO::getRecordId, recordIds));
    }
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/storage/SystemStorageBlobMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,51 @@
package cn.iocoder.yudao.module.system.dal.mysql.storage;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.system.dal.dataobject.storage.SystemStorageBlobDO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.Collection;
import java.util.List;
/**
 * æ–‡ä»¶ä¸»è¡¨ Mapper
 *
 * @author èŠ‹é“æºç 
 */
@Mapper
public interface SystemStorageBlobMapper extends BaseMapperX<SystemStorageBlobDO> {
    default SystemStorageBlobDO selectByUidFilename(String uidFilename) {
        return selectOne(SystemStorageBlobDO::getUidFilename, uidFilename);
    }
    default SystemStorageBlobDO selectByUidFilenameAndResourceKey(String uidFilename, String resourceKey) {
        return selectOne(new LambdaQueryWrapperX<SystemStorageBlobDO>()
                .eq(SystemStorageBlobDO::getUidFilename, uidFilename)
                .eq(SystemStorageBlobDO::getResourceKey, resourceKey));
    }
    default List<String> selectExistingUidFilenames(Collection<String> fileNames) {
        if (fileNames == null || fileNames.isEmpty()) {
            return List.of();
        }
        return selectList(new LambdaQueryWrapperX<SystemStorageBlobDO>()
                .in(SystemStorageBlobDO::getUidFilename, fileNames)
                .select(SystemStorageBlobDO::getUidFilename))
                .stream().map(SystemStorageBlobDO::getUidFilename).toList();
    }
    /**
     * æŸ¥è¯¢å­¤å„¿blob(未被 system_storage_attachment å…³è”的记录),按ID范围分批查询
     * ç”¨äºŽå®šæ—¶æ¸…理任务
     */
    @Select("SELECT b.* FROM system_storage_blob b " +
            "LEFT JOIN system_storage_attachment a ON a.storage_blob_id = b.id AND a.deleted = 0 " +
            "WHERE a.id IS NULL AND b.id > #{lastId} " +
            "ORDER BY b.id LIMIT #{limit}")
    List<SystemStorageBlobDO> selectOrphanBlobsByIdRange(@Param("lastId") long lastId, @Param("limit") int limit);
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/ErrorCodeConstants.java
@@ -168,4 +168,12 @@
    // ========== ç«™å†…信发送 1-002-028-000 ==========
    ErrorCode NOTIFY_SEND_TEMPLATE_PARAM_MISS = new ErrorCode(1_002_028_000, "模板参数({})缺失");
    // ========== æ–‡ä»¶å­˜å‚¨ 1-002-029-000 ==========
    ErrorCode STORAGE_FILE_EMPTY = new ErrorCode(1_002_029_000, "上传文件不能为空");
    ErrorCode STORAGE_BLOB_UPLOAD_FAILED = new ErrorCode(1_002_029_001, "文件上传失败");
    ErrorCode STORAGE_BLOB_TOKEN_INVALID = new ErrorCode(1_002_029_002, "文件访问token无效或已过期");
    ErrorCode STORAGE_BLOB_TOKEN_MISMATCH = new ErrorCode(1_002_029_003, "文件访问token与文件不匹配");
    ErrorCode STORAGE_BLOB_NOT_EXISTS = new ErrorCode(1_002_029_004, "文件不存在");
    ErrorCode STORAGE_BLOB_PUBLIC_KEY_INVALID = new ErrorCode(1_002_029_005, "文件公开访问key无效");
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/storage/StorageApplicationTypeEnum.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,33 @@
package cn.iocoder.yudao.module.system.enums.storage;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
 * æ–‡ä»¶ç”¨é€”枚举
 * ç”¨äºŽåŒºåˆ†åŒä¸€æ¡ä¸šåŠ¡è®°å½•ä¸‹ä¸åŒç”¨é€”çš„é™„ä»¶
 */
@Getter
@AllArgsConstructor
public enum StorageApplicationTypeEnum {
    FILE("file", "通用文件"),
    IMAGE("image", "图片"),
    AVATAR("avatar", "头像");
    private final String type;
    private final String description;
    /**
     * æ ¹æ® type å€¼èŽ·å–å¯¹åº”çš„æžšä¸¾å®žä¾‹
     */
    public static StorageApplicationTypeEnum getByType(String type) {
        for (StorageApplicationTypeEnum enumValue : StorageApplicationTypeEnum.values()) {
            if (enumValue.getType().equals(type)) {
                return enumValue;
            }
        }
        throw new IllegalArgumentException("无效的文件用途类型: " + type);
    }
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/storage/StorageRecordTypeEnum.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,50 @@
package cn.iocoder.yudao.module.system.enums.storage;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
 * ä¸šåŠ¡è®°å½•ç±»åž‹æžšä¸¾
 * ç”¨äºŽæ ‡è®°æ–‡ä»¶å±žäºŽå“ªç±»ä¸šåŠ¡è®°å½•
 * ä¸Šä¼ å®ŒæˆåŽï¼Œé™„件通过 recordType + recordId å’Œä¸šåŠ¡æ•°æ®å…³è”
 *
 * æŒ‰éœ€æ‰©å±•,至少保留 COMMON_FILE ä½œä¸ºé€šç”¨ç±»åž‹
 */
@Getter
@AllArgsConstructor
public enum StorageRecordTypeEnum {
    // ===== é€šç”¨ =====
    COMMON_FILE("common_file", "通用文件"),
    // ===== ç”Ÿäº§ =====
    PRODUCTION_ORDER("production_order", "生产工单"),
    // ===== è´¨æ£€ =====
    QUALITY_INSPECT("quality_inspect", "质检"),
    // ===== è®¾å¤‡ =====
    DEVICE_MAINTENANCE("device_maintenance", "设备维护"),
    // ===== é€šçŸ¥å…¬å‘Š =====
    NOTICE("notice", "通知公告"),
    // ===== å¤´åƒ =====
    AVATAR("avatar", "头像");
    private final String type;
    private final String description;
    /**
     * æ ¹æ® type å€¼èŽ·å–å¯¹åº”çš„æžšä¸¾å®žä¾‹
     */
    public static StorageRecordTypeEnum getByType(String type) {
        for (StorageRecordTypeEnum enumValue : StorageRecordTypeEnum.values()) {
            if (enumValue.getType().equals(type)) {
                return enumValue;
            }
        }
        throw new IllegalArgumentException("无效的记录类型: " + type);
    }
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/framework/storage/config/StorageProperties.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,47 @@
package cn.iocoder.yudao.module.system.framework.storage.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import org.springframework.util.unit.DataSize;
import java.math.BigDecimal;
/**
 * æ–‡ä»¶ä¸Šä¼ é…ç½®ç±»
 *
 * å¯¹åº” application.yml ä¸­çš„ yudao.storage å‰ç¼€é…ç½®
 */
@Component
@ConfigurationProperties(prefix = "yudao.storage", ignoreUnknownFields = true)
@Data
public class StorageProperties {
    /** æ–‡ä»¶å­˜å‚¨æ ¹ç›®å½• */
    private String path = "/data/uploads";
    /** URL路径前缀(如 /admin-api/system/storage-file),用于拼接预览/下载地址 */
    private String urlPrefix = "/admin-api/system/storage-file";
    /** åŸŸåå‰ç¼€ï¼Œç”¨äºŽæ‹¼æŽ¥å®Œæ•´çš„预览/下载地址 */
    private String domain = "http://localhost:48080";
    /** token过期时间(分钟),默认120分钟 */
    private BigDecimal expired = new BigDecimal("120");
    /** token使用次数上限,默认10次 */
    private Integer useLimit = 10;
    /** JWT密钥 */
    private String jwtSecret = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG";
    /** æ˜¯å¦å¯ç”¨å›¾ç‰‡åŽ‹ç¼© */
    private Boolean compress = true;
    /** è§¦å‘压缩的文件大小阈值 */
    private DataSize needCompressSize = DataSize.ofMegabytes(10);
    /** åŽ‹ç¼©è´¨é‡ï¼ˆ0.0~1.0) */
    private float compressQuality = 0.5f;
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/storage/StorageBlobCleanupTask.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,192 @@
package cn.iocoder.yudao.module.system.service.storage;
import cn.iocoder.yudao.module.system.dal.dataobject.storage.SystemStorageBlobDO;
import cn.iocoder.yudao.module.system.dal.mysql.storage.SystemStorageBlobMapper;
import cn.iocoder.yudao.module.system.framework.storage.config.StorageProperties;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
import java.io.File;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
/**
 * æ¸…理无效文件定时任务
 * æ¯æœˆ1号凌晨2点执行一次
 *
 * æ¸…理内容:
 * 1. åˆ é™¤ system_storage_blob ä¸­æœªè¢« system_storage_attachment å…³è”的记录及其文件
 * 2. åˆ é™¤ç£ç›˜ä¸Šä¸å­˜åœ¨äºŽ system_storage_blob.uid_filename çš„æ–‡ä»¶
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class StorageBlobCleanupTask {
    private static final int DB_BATCH_SIZE = 500;
    private static final int FILE_NAME_BATCH_SIZE = 1000;
    private final SystemStorageBlobMapper storageBlobMapper;
    private final StorageProperties properties;
    private final AtomicBoolean running = new AtomicBoolean(false);
    /**
     * æ¯æœˆ 1 å·å‡Œæ™¨ 2 ç‚¹æ‰§è¡Œä¸€æ¬¡
     */
    @Scheduled(cron = "0 0 2 1 * ?")
    public void cleanupUnusedStorageFiles() {
        if (!running.compareAndSet(false, true)) {
            log.warn("文件清理任务正在执行,本次跳过");
            return;
        }
        long start = System.currentTimeMillis();
        log.info("文件清理任务开始执行,根目录:{}", properties.getPath());
        try {
            int removedBlobCount = cleanupOrphanStorageBlobs();
            int removedDiskFileCount = cleanupOrphanDiskFiles();
            long cost = System.currentTimeMillis() - start;
            log.info("文件清理任务执行完成,删除孤儿 blob è®°å½•:{},删除磁盘无效文件:{},耗时:{} ms",
                    removedBlobCount, removedDiskFileCount, cost);
        } catch (Exception e) {
            log.error("文件清理任务执行失败", e);
        } finally {
            running.set(false);
        }
    }
    private int cleanupOrphanStorageBlobs() {
        long lastId = 0L;
        int removedCount = 0;
        while (true) {
            List<SystemStorageBlobDO> orphanBlobs = storageBlobMapper.selectOrphanBlobsByIdRange(lastId, DB_BATCH_SIZE);
            if (CollectionUtils.isEmpty(orphanBlobs)) {
                break;
            }
            List<Long> ids = new ArrayList<>(orphanBlobs.size());
            for (SystemStorageBlobDO blob : orphanBlobs) {
                ids.add(blob.getId());
                deleteBlobFiles(blob);
            }
            storageBlobMapper.deleteByIds(ids);
            removedCount += ids.size();
            lastId = orphanBlobs.get(orphanBlobs.size() - 1).getId();
            log.info("已删除一批孤儿 blob,batchSize={},lastId={}", ids.size(), lastId);
        }
        return removedCount;
    }
    private int cleanupOrphanDiskFiles() {
        File rootDirectory = new File(properties.getPath());
        if (!rootDirectory.exists() || !rootDirectory.isDirectory()) {
            log.warn("文件根目录不存在或不是目录,跳过磁盘清理:{}", properties.getPath());
            return 0;
        }
        int deletedCount = 0;
        Deque<File> directories = new ArrayDeque<>();
        directories.push(rootDirectory);
        while (!directories.isEmpty()) {
            File currentDirectory = directories.pop();
            File[] children = currentDirectory.listFiles();
            if (children == null || children.length == 0) {
                continue;
            }
            List<File> filesInDirectory = new ArrayList<>();
            for (File child : children) {
                if (child.isDirectory()) {
                    directories.push(child);
                } else if (child.isFile()) {
                    filesInDirectory.add(child);
                }
            }
            deletedCount += cleanupFilesInDirectory(filesInDirectory);
        }
        return deletedCount;
    }
    private int cleanupFilesInDirectory(List<File> filesInDirectory) {
        if (CollectionUtils.isEmpty(filesInDirectory)) {
            return 0;
        }
        int deletedCount = 0;
        for (int start = 0; start < filesInDirectory.size(); start += FILE_NAME_BATCH_SIZE) {
            int end = Math.min(start + FILE_NAME_BATCH_SIZE, filesInDirectory.size());
            List<File> batchFiles = filesInDirectory.subList(start, end);
            List<String> fileNames = new ArrayList<>(batchFiles.size());
            for (File file : batchFiles) {
                fileNames.add(file.getName());
            }
            Set<String> existingFileNames = new HashSet<>(storageBlobMapper.selectExistingUidFilenames(fileNames));
            for (File file : batchFiles) {
                // è·³è¿‡åŽ‹ç¼©æ–‡ä»¶ï¼ˆthumb_ å‰ç¼€ï¼‰ï¼Œå®ƒä»¬éšåŽŸæ–‡ä»¶ä¸€èµ·ç®¡ç†
                if (file.getName().startsWith("thumb_")) {
                    continue;
                }
                if (!existingFileNames.contains(file.getName()) && safeDelete(file)) {
                    deletedCount++;
                }
            }
        }
        return deletedCount;
    }
    private void deleteBlobFiles(SystemStorageBlobDO blob) {
        File originalFile = resolveBlobFile(blob);
        safeDelete(originalFile);
        File compressedFile = resolveCompressedFile(originalFile);
        safeDelete(compressedFile);
    }
    private File resolveBlobFile(SystemStorageBlobDO blob) {
        String basePath = properties.getPath();
        if (!StringUtils.hasText(blob.getPath())) {
            return new File(basePath, blob.getUidFilename());
        }
        return new File(new File(basePath, blob.getPath()), blob.getUidFilename());
    }
    private File resolveCompressedFile(File originalFile) {
        if (originalFile == null) {
            return null;
        }
        File parent = originalFile.getParentFile();
        if (parent == null) {
            return null;
        }
        return new File(parent, "thumb_" + originalFile.getName());
    }
    private boolean safeDelete(File file) {
        if (file == null || !file.exists() || !file.isFile()) {
            return false;
        }
        if (file.delete()) {
            return true;
        }
        log.warn("删除文件失败:{}", file.getAbsolutePath());
        return false;
    }
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/storage/SystemStorageAttachmentService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,51 @@
package cn.iocoder.yudao.module.system.service.storage;
import cn.iocoder.yudao.module.system.controller.admin.storage.vo.SystemStorageAttachmentListReqVO;
import cn.iocoder.yudao.module.system.controller.admin.storage.vo.SystemStorageAttachmentSaveReqVO;
import cn.iocoder.yudao.module.system.controller.admin.storage.vo.SystemStorageBlobRespVO;
import java.util.List;
/**
 * æ–‡ä»¶å…³è”表 Service æŽ¥å£
 *
 * @author èŠ‹é“æºç 
 */
public interface SystemStorageAttachmentService {
    /**
     * ä¿å­˜é™„件关联信息
     */
    void bindAttachments(SystemStorageAttachmentSaveReqVO reqVO);
    /**
     * æŸ¥è¯¢é™„件列表
     *
     * @param reqVO æŸ¥è¯¢æ¡ä»¶
     * @return æ–‡ä»¶åˆ—表(含预览/下载地址)
     */
    List<SystemStorageBlobRespVO> listAttachments(SystemStorageAttachmentListReqVO reqVO);
    /**
     * æ‰¹é‡åˆ é™¤é™„ä»¶
     *
     * @param ids é™„ä»¶ID列表
     */
    void deleteAttachments(List<Long> ids);
    /**
     * åˆ é™¤æŒ‡å®šç”¨é€”、指定业务记录下的附件关系
     */
    void deleteAttachmentsByApplicationAndRecordTypeAndRecordId(String application, String recordType, Long recordId);
    /**
     * åˆ é™¤æŒ‡å®šä¸šåŠ¡è®°å½•ä¸‹å…¨éƒ¨é™„ä»¶å…³ç³»ï¼Œä¸åŒºåˆ†ç”¨é€”
     */
    void deleteAttachmentsByRecordTypeAndRecordId(String recordType, Long recordId);
    /**
     * æŒ‰å¤šä¸ªä¸šåŠ¡id批量删除附件关系
     */
    void deleteAttachmentsByApplicationAndRecordTypeAndRecordIds(String application, String recordType, List<Long> recordIds);
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/storage/SystemStorageAttachmentServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,71 @@
package cn.iocoder.yudao.module.system.service.storage;
import cn.iocoder.yudao.module.system.controller.admin.storage.vo.SystemStorageAttachmentListReqVO;
import cn.iocoder.yudao.module.system.controller.admin.storage.vo.SystemStorageAttachmentSaveReqVO;
import cn.iocoder.yudao.module.system.controller.admin.storage.vo.SystemStorageBlobRespVO;
import cn.iocoder.yudao.module.system.enums.storage.StorageApplicationTypeEnum;
import cn.iocoder.yudao.module.system.enums.storage.StorageRecordTypeEnum;
import cn.iocoder.yudao.module.system.util.storage.StorageFileUtil;
import jakarta.annotation.Resource;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
 * æ–‡ä»¶å…³è”表 Service å®žçŽ°ç±»
 *
 * @author èŠ‹é“æºç 
 */
@Service
@RequiredArgsConstructor
public class SystemStorageAttachmentServiceImpl implements SystemStorageAttachmentService {
    private final StorageFileUtil fileUtil;
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void bindAttachments(SystemStorageAttachmentSaveReqVO reqVO) {
        fileUtil.saveStorageAttachmentByRecordTypeAndRecordId(
                reqVO.getApplication(),
                StorageRecordTypeEnum.getByType(reqVO.getRecordType()),
                reqVO.getRecordId(),
                reqVO.getBlobItems()
        );
    }
    @Override
    public List<SystemStorageBlobRespVO> listAttachments(SystemStorageAttachmentListReqVO reqVO) {
        return fileUtil.getStorageBlobVOsByApplicationAndRecordTypeAndRecordId(
                reqVO.getApplication(),
                reqVO.getRecordType(),
                reqVO.getRecordId()
        );
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void deleteAttachments(List<Long> ids) {
        fileUtil.deleteStorageAttachmentsByStorageAttachmentIds(ids);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void deleteAttachmentsByApplicationAndRecordTypeAndRecordId(String application, String recordType, Long recordId) {
        fileUtil.deleteStorageAttachmentsByApplicationAndRecordTypeAndRecordId(application, recordType, recordId);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void deleteAttachmentsByRecordTypeAndRecordId(String recordType, Long recordId) {
        fileUtil.deleteStorageAttachmentsByRecordTypeAndRecordId(recordType, recordId);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void deleteAttachmentsByApplicationAndRecordTypeAndRecordIds(String application, String recordType, List<Long> recordIds) {
        fileUtil.deleteStorageAttachmentsByApplicationAndRecordTypeAndRecordIds(application, recordType, recordIds);
    }
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/storage/SystemStorageBlobService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,67 @@
package cn.iocoder.yudao.module.system.service.storage;
import cn.iocoder.yudao.module.system.controller.admin.storage.vo.SystemStorageBlobRespVO;
import cn.iocoder.yudao.module.system.dal.dataobject.storage.SystemStorageBlobDO;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.util.List;
/**
 * æ–‡ä»¶ä¸»è¡¨ Service æŽ¥å£
 *
 * @author èŠ‹é“æºç 
 */
public interface SystemStorageBlobService {
    /**
     * ä¸Šä¼ æ–‡ä»¶
     *
     * @param files    æ–‡ä»¶åˆ—表
     * @param isPublic æ˜¯å¦å…¬å…±æ–‡ä»¶ï¼ˆtrue=永久有效,false=临时token控制)
     * @return ä¸Šä¼ ç»“果(含预览/下载地址)
     */
    List<SystemStorageBlobRespVO> upload(List<MultipartFile> files, Boolean isPublic);
    /**
     * é€šè¿‡token获取文件
     *
     * @param fileName å”¯ä¸€æ–‡ä»¶å
     * @param token    JWT token
     * @return æ–‡ä»¶å¯¹è±¡
     */
    File getFileByToken(String fileName, String token);
    /**
     * é€šè¿‡publicKey获取公共文件
     *
     * @param fileName  å”¯ä¸€æ–‡ä»¶å
     * @param publicKey å…¬å…±è®¿é—®key
     * @return æ–‡ä»¶å¯¹è±¡
     */
    File getPublicFile(String fileName, String publicKey);
    /**
     * èŽ·å–ä¸‹è½½æ—¶çš„åŽŸå§‹æ–‡ä»¶å
     *
     * @param fileName å”¯ä¸€æ–‡ä»¶å
     * @return åŽŸå§‹æ–‡ä»¶å
     */
    String getDownloadFileName(String fileName);
    /**
     * èŽ·å¾—æ–‡ä»¶
     *
     * @param id ç¼–号
     * @return æ–‡ä»¶
     */
    SystemStorageBlobDO getStorageBlob(Long id);
    /**
     * æ‰¹é‡åˆ é™¤æ–‡ä»¶
     *
     * @param ids ç¼–号列表
     */
    void deleteStorageBlobs(List<Long> ids);
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/storage/SystemStorageBlobServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,183 @@
package cn.iocoder.yudao.module.system.service.storage;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.system.controller.admin.storage.vo.SystemStorageBlobRespVO;
import cn.iocoder.yudao.module.system.dal.dataobject.storage.SystemStorageBlobDO;
import cn.iocoder.yudao.module.system.dal.mysql.storage.SystemStorageBlobMapper;
import cn.iocoder.yudao.module.system.framework.storage.config.StorageProperties;
import cn.iocoder.yudao.module.system.util.storage.StorageFileUtil;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import jakarta.annotation.Resource;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import javax.crypto.SecretKey;
import java.io.File;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.system.enums.ErrorCodeConstants.*;
/**
 * æ–‡ä»¶ä¸»è¡¨ Service å®žçŽ°ç±»
 *
 * @author èŠ‹é“æºç 
 */
@Service
@RequiredArgsConstructor
public class SystemStorageBlobServiceImpl implements SystemStorageBlobService {
    private final StorageProperties properties;
    @Resource
    private SystemStorageBlobMapper storageBlobMapper;
    private final StorageFileUtil fileUtil;
    @Override
    public List<SystemStorageBlobRespVO> upload(List<MultipartFile> files, Boolean isPublic) {
        if (files == null || files.isEmpty()) {
            throw exception(STORAGE_FILE_EMPTY);
        }
        List<SystemStorageBlobRespVO> result = new ArrayList<>();
        for (MultipartFile file : files) {
            if (file == null || file.isEmpty()) {
                throw exception(STORAGE_FILE_EMPTY);
            }
            String originalFileName = StringUtils.hasText(file.getOriginalFilename())
                    ? StringUtils.cleanPath(file.getOriginalFilename())
                    : UUID.randomUUID().toString();
            String fileName = UUID.randomUUID() + "_" + originalFileName;
            String relativePath = fileUtil.buildRelativePath();
            File targetDirectory = new File(properties.getPath(), relativePath);
            if (!targetDirectory.exists() && !targetDirectory.mkdirs()) {
                throw exception(STORAGE_BLOB_UPLOAD_FAILED);
            }
            File dest = new File(targetDirectory, fileName);
            SystemStorageBlobRespVO blobRespVO;
            try {
                file.transferTo(dest);
                blobRespVO = createStorageBlob(file, originalFileName, fileName, relativePath, isPublic);
                if (blobRespVO == null || blobRespVO.getId() == null) {
                    throw exception(STORAGE_BLOB_UPLOAD_FAILED);
                }
            } catch (RuntimeException e) {
                if (dest.exists()) {
                    dest.delete();
                }
                throw e;
            } catch (IOException e) {
                throw exception(STORAGE_BLOB_UPLOAD_FAILED);
            }
            result.add(blobRespVO);
        }
        return result;
    }
    @Override
    public File getFileByToken(String fileName, String token) {
        if (!StringUtils.hasText(token)) {
            throw exception(STORAGE_BLOB_TOKEN_INVALID);
        }
        String secretStr = properties.getJwtSecret();
        SecretKey key = Keys.hmacShaKeyFor(secretStr.getBytes(StandardCharsets.UTF_8));
        Claims claims;
        try {
            claims = Jwts.parser()
                    .verifyWith(key)
                    .build()
                    .parseSignedClaims(token)
                    .getPayload();
        } catch (Exception e) {
            throw exception(STORAGE_BLOB_TOKEN_INVALID);
        }
        if (!fileName.equals(claims.getSubject())) {
            throw exception(STORAGE_BLOB_TOKEN_MISMATCH);
        }
        fileUtil.validateTokenUsage(token);
        SystemStorageBlobDO blob = storageBlobMapper.selectByUidFilename(fileName);
        String path = blob == null ? claims.get("path", String.class) : blob.getPath();
        if (!StringUtils.hasText(path)) {
            return new File(properties.getPath(), fileName);
        }
        return new File(new File(properties.getPath(), path), fileName);
    }
    @Override
    public File getPublicFile(String fileName, String publicKey) {
        if (!StringUtils.hasText(fileName)) {
            throw exception(STORAGE_BLOB_NOT_EXISTS);
        }
        if (!StringUtils.hasText(publicKey)) {
            throw exception(STORAGE_BLOB_PUBLIC_KEY_INVALID);
        }
        SystemStorageBlobDO blob = storageBlobMapper.selectByUidFilenameAndResourceKey(fileName, publicKey);
        if (blob == null) {
            throw exception(STORAGE_BLOB_PUBLIC_KEY_INVALID);
        }
        String path = blob.getPath();
        if (!StringUtils.hasText(path)) {
            return new File(properties.getPath(), fileName);
        }
        return new File(new File(properties.getPath(), path), fileName);
    }
    @Override
    public String getDownloadFileName(String fileName) {
        SystemStorageBlobDO blob = storageBlobMapper.selectByUidFilename(fileName);
        if (blob == null || !StringUtils.hasText(blob.getOriginalFilename())) {
            return fileName;
        }
        return blob.getOriginalFilename();
    }
    @Override
    public SystemStorageBlobDO getStorageBlob(Long id) {
        return storageBlobMapper.selectById(id);
    }
    @Override
    public void deleteStorageBlobs(List<Long> ids) {
        fileUtil.deleteStorageBlobs(ids);
    }
    private SystemStorageBlobRespVO createStorageBlob(MultipartFile file, String originalFileName,
                                                      String fileName, String relativePath, Boolean isPublic) {
        SystemStorageBlobDO blob = new SystemStorageBlobDO();
        blob.setResourceKey(UUID.randomUUID().toString().replace("-", ""));
        blob.setContentType(file.getContentType());
        blob.setOriginalFilename(originalFileName);
        blob.setUidFilename(fileName);
        blob.setByteSize(file.getSize());
        blob.setPath(relativePath);
        storageBlobMapper.insert(blob);
        // æž„建返回 VO
        SystemStorageBlobRespVO respVO = BeanUtils.toBean(blob, SystemStorageBlobRespVO.class);
        if (Boolean.TRUE.equals(isPublic)) {
            respVO.setPreviewURL(fileUtil.buildSignedUrl(blob, "/preview/", BigDecimal.valueOf(-1)));
            respVO.setDownloadURL(fileUtil.buildSignedUrl(blob, "/download/", BigDecimal.valueOf(-1)));
        } else {
            respVO.setPreviewURL(fileUtil.buildSignedPreviewUrl(blob));
            respVO.setUrl(fileUtil.buildSignedPreviewUrl(blob));
            respVO.setName(blob.getOriginalFilename());
            respVO.setDownloadURL(fileUtil.buildSignedDownloadUrl(blob));
        }
        return respVO;
    }
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/util/storage/StorageFileUtil.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,602 @@
package cn.iocoder.yudao.module.system.util.storage;
import cn.hutool.core.collection.CollUtil;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.system.controller.admin.storage.vo.SystemStorageBlobRespVO;
import cn.iocoder.yudao.module.system.controller.admin.storage.vo.SystemStorageAttachmentRespVO;
import cn.iocoder.yudao.module.system.controller.admin.storage.vo.SystemStorageAttachmentSaveReqVO;
import cn.iocoder.yudao.module.system.dal.dataobject.storage.SystemStorageAttachmentDO;
import cn.iocoder.yudao.module.system.dal.dataobject.storage.SystemStorageBlobDO;
import cn.iocoder.yudao.module.system.dal.mysql.storage.SystemStorageAttachmentMapper;
import cn.iocoder.yudao.module.system.dal.mysql.storage.SystemStorageBlobMapper;
import cn.iocoder.yudao.module.system.enums.storage.StorageApplicationTypeEnum;
import cn.iocoder.yudao.module.system.enums.storage.StorageRecordTypeEnum;
import cn.iocoder.yudao.module.system.framework.storage.config.StorageProperties;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import lombok.RequiredArgsConstructor;
import net.coobird.thumbnailator.Thumbnails;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import javax.crypto.SecretKey;
import java.io.File;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/**
 * æ–‡ä»¶ä¸Šä¼ ä½“系核心工具类
 *
 * ä¸»è¦èŒè´£ï¼š
 * - æ–‡ä»¶ä¸Žä¸šåŠ¡è®°å½•ç»‘å®šï¼ˆä¿å­˜é™„ä»¶å…³ç³»ï¼‰
 * - æ–‡ä»¶ä¸Žé™„件删除
 * - é™„件查询
 * - é¢„览/下载地址生成(带JWT签名)
 * - token使用次数控制(Redis)
 * - å›¾ç‰‡åŽ‹ç¼©
 *
 * @author èŠ‹é“æºç 
 */
@Component
@RequiredArgsConstructor
public class StorageFileUtil {
    private final StorageProperties properties;
    private final SystemStorageAttachmentMapper storageAttachmentMapper;
    private final SystemStorageBlobMapper storageBlobMapper;
    private final StringRedisTemplate stringRedisTemplate;
    private static final String TOKEN_USAGE_KEY_PREFIX = "storage:token:usage:";
    private static final DateTimeFormatter YEAR_PATH_FORMATTER = DateTimeFormatter.ofPattern("yyyy");
    private static final DateTimeFormatter MONTH_DAY_PATH_FORMATTER = DateTimeFormatter.ofPattern("MMdd");
    // ==================== ä¿å­˜é™„件关系 ====================
    /**
     * æŒ‰"文件用途 + è®°å½•类型 + è®°å½•id"保存附件关系
     * é€»è¾‘:先删除旧附件,再批量插入新附件(整体替换模式)
     */
    public void saveStorageAttachment(StorageApplicationTypeEnum application, StorageRecordTypeEnum recordType,
                                      Long recordId, List<SystemStorageAttachmentSaveReqVO.BlobItem> blobItems) {
        if (application == null) {
            throw new IllegalArgumentException("文件用途不能为空");
        }
        if (recordType == null) {
            throw new IllegalArgumentException("关联记录类型不能为空");
        }
        if (recordId == null || recordId <= 0) {
            throw new IllegalArgumentException("关联记录id不能为空");
        }
        // åˆ é™¤æ—§é™„件信息
        deleteStorageAttachmentsByApplicationAndRecordTypeAndRecordId(application.getType(), recordType.getType(), recordId);
        if (CollUtil.isEmpty(blobItems)) {
            return;
        }
        List<SystemStorageAttachmentDO> attachments = new ArrayList<>();
        for (SystemStorageAttachmentSaveReqVO.BlobItem item : blobItems) {
            SystemStorageAttachmentDO attachment = new SystemStorageAttachmentDO();
            String app = (item.getApplication() != null && !item.getApplication().trim().isEmpty())
                    ? item.getApplication() : application.getType();
            attachment.setApplication(app);
            attachment.setRecordType(recordType.getType());
            attachment.setRecordId(recordId);
            attachment.setStorageBlobId(item.getBlobId());
            attachments.add(attachment);
        }
        storageAttachmentMapper.insertBatch(attachments);
    }
    /**
     * æŒ‰ recordType + recordId ä¿å­˜é™„件关系,application å¯æŒ‡å®šä¹Ÿå¯ä»Žæ¯ä¸ªæ–‡ä»¶å¯¹è±¡é‡Œè¯»å–
     */
    public void saveStorageAttachmentByRecordTypeAndRecordId(String application, StorageRecordTypeEnum recordType,
                                                             Long recordId, List<SystemStorageAttachmentSaveReqVO.BlobItem> blobItems) {
        if (recordType == null) {
            throw new IllegalArgumentException("关联记录类型不能为空");
        }
        if (recordId == null) {
            throw new IllegalArgumentException("关联记录id不能为空");
        }
        // åˆ é™¤æ—§é™„件信息
        if (application == null || application.trim().isEmpty()) {
            for (SystemStorageAttachmentSaveReqVO.BlobItem item : blobItems) {
                String itemApp = item.getApplication();
                if (itemApp == null || itemApp.trim().isEmpty()) {
                    throw new IllegalArgumentException("文件用途不能为空");
                }
                deleteStorageAttachmentsByApplicationAndRecordTypeAndRecordId(itemApp, recordType.getType(), recordId);
            }
        } else {
            deleteStorageAttachmentsByApplicationAndRecordTypeAndRecordId(application, recordType.getType(), recordId);
        }
        if (CollUtil.isEmpty(blobItems)) {
            deleteStorageAttachmentsByRecordTypeAndRecordId(recordType.getType(), recordId);
            return;
        }
        List<SystemStorageAttachmentDO> attachments = new ArrayList<>();
        for (SystemStorageAttachmentSaveReqVO.BlobItem item : blobItems) {
            SystemStorageAttachmentDO attachment = new SystemStorageAttachmentDO();
            String app = (application != null && !application.trim().isEmpty())
                    ? application : item.getApplication();
            attachment.setApplication(StorageApplicationTypeEnum.getByType(app).getType());
            attachment.setRecordType(recordType.getType());
            attachment.setRecordId(recordId);
            attachment.setStorageBlobId(item.getBlobId());
            attachments.add(attachment);
        }
        storageAttachmentMapper.insertBatch(attachments);
    }
    // ==================== åˆ é™¤æ–‡ä»¶ä¸»è¡¨ ====================
    public void deleteStorageBlobs(List<Long> storageBlobIds) {
        if (CollUtil.isEmpty(storageBlobIds)) {
            return;
        }
        // åŒæ—¶åˆ é™¤ç£ç›˜æ–‡ä»¶
        List<SystemStorageBlobDO> blobs = storageBlobMapper.selectByIds(storageBlobIds);
        for (SystemStorageBlobDO blob : blobs) {
            deleteBlobFile(blob);
        }
        storageBlobMapper.deleteByIds(storageBlobIds);
    }
    public void deleteStorageBlobsByStorageAttachmentIds(List<Long> storageAttachmentIds) {
        if (CollUtil.isEmpty(storageAttachmentIds)) {
            return;
        }
        List<SystemStorageAttachmentDO> attachments = storageAttachmentMapper.selectByIds(storageAttachmentIds);
        List<Long> storageBlobIds = attachments.stream()
                .map(SystemStorageAttachmentDO::getStorageBlobId)
                .collect(Collectors.toList());
        deleteStorageBlobs(storageBlobIds);
    }
    public void deleteStorageBlobsByApplicationAndRecordTypeAndRecordIds(
            String application, String recordType, Collection<Long> recordIds) {
        if (recordIds == null || recordIds.isEmpty()) {
            throw new IllegalArgumentException("关联记录id不能为空");
        }
        List<SystemStorageAttachmentDO> attachments = storageAttachmentMapper
                .selectListByApplicationAndRecordTypeAndRecordIds(application, recordType, recordIds);
        if (CollUtil.isNotEmpty(attachments)) {
            List<Long> blobIds = attachments.stream()
                    .map(SystemStorageAttachmentDO::getStorageBlobId)
                    .collect(Collectors.toList());
            deleteStorageBlobs(blobIds);
        }
    }
    public void deleteStorageBlobsByRecordTypeAndRecordId(String recordType, Long recordId) {
        if (recordId == null || recordId <= 0) {
            throw new IllegalArgumentException("关联记录id不能为空");
        }
        List<SystemStorageAttachmentDO> attachments = storageAttachmentMapper
                .selectListByRecordTypeAndRecordId(recordType, recordId);
        if (CollUtil.isNotEmpty(attachments)) {
            List<Long> blobIds = attachments.stream()
                    .map(SystemStorageAttachmentDO::getStorageBlobId)
                    .collect(Collectors.toList());
            deleteStorageBlobs(blobIds);
        }
    }
    // ==================== åˆ é™¤é™„件关系 ====================
    public void deleteStorageAttachmentsByStorageAttachmentIds(List<Long> storageAttachmentIds) {
        if (CollUtil.isEmpty(storageAttachmentIds)) {
            return;
        }
        deleteStorageBlobsByStorageAttachmentIds(storageAttachmentIds);
        storageAttachmentMapper.deleteByIds(storageAttachmentIds);
    }
    public void deleteStorageAttachmentsByApplicationAndRecordTypeAndRecordId(
            String application, String recordType, Long recordId) {
        if (recordId == null || recordId <= 0) {
            throw new IllegalArgumentException("关联记录id不能为空");
        }
        deleteStorageBlobsByApplicationAndRecordTypeAndRecordIds(application, recordType, Collections.singletonList(recordId));
        storageAttachmentMapper.deleteByApplicationAndRecordTypeAndRecordId(application, recordType, recordId);
    }
    public void deleteStorageAttachmentsByRecordTypeAndRecordId(String recordType, Long recordId) {
        if (recordId == null || recordId <= 0) {
            throw new IllegalArgumentException("关联记录id不能为空");
        }
        deleteStorageBlobsByRecordTypeAndRecordId(recordType, recordId);
        storageAttachmentMapper.deleteByRecordTypeAndRecordId(recordType, recordId);
    }
    public void deleteStorageAttachmentsByApplicationAndRecordTypeAndRecordIds(
            String application, String recordType, List<Long> recordIds) {
        if (recordIds == null || recordIds.isEmpty()) {
            throw new IllegalArgumentException("关联记录id不能为空");
        }
        deleteStorageBlobsByApplicationAndRecordTypeAndRecordIds(application, recordType, recordIds);
        storageAttachmentMapper.deleteByApplicationAndRecordTypeAndRecordIds(application, recordType, recordIds);
    }
    // ==================== æŸ¥è¯¢é™„件关系 ====================
    public List<SystemStorageAttachmentDO> getStorageAttachmentsByStorageAttachmentIds(List<Long> storageAttachmentIds) {
        if (CollUtil.isEmpty(storageAttachmentIds)) {
            throw new IllegalArgumentException("附件ID不能为空");
        }
        return storageAttachmentMapper.selectByIds(storageAttachmentIds);
    }
    public List<SystemStorageAttachmentDO> getStorageAttachmentsByApplicationAndRecordTypeAndRecordId(
            String application, String recordType, Long recordId) {
        return storageAttachmentMapper.selectListByApplicationAndRecordTypeAndRecordId(application, recordType, recordId);
    }
    public List<SystemStorageAttachmentDO> getStorageAttachmentsByRecordTypeAndRecordId(
            String recordType, Long recordId) {
        return storageAttachmentMapper.selectListByRecordTypeAndRecordId(recordType, recordId);
    }
    // ==================== æŸ¥è¯¢æ–‡ä»¶ä¿¡æ¯ ====================
    /**
     * æ ¹æ®é™„件关系id查询文件列表(自动构建previewURL、downloadURL、storageAttachmentId)
     */
    public List<SystemStorageBlobRespVO> getStorageBlobVOsByStorageAttachmentIds(List<Long> storageAttachmentIds) {
        List<SystemStorageAttachmentDO> attachments = getStorageAttachmentsByStorageAttachmentIds(storageAttachmentIds);
        if (CollUtil.isEmpty(attachments)) {
            return null;
        }
        Map<Long, Long> blobIdToAttachmentIdMap = attachments.stream()
                .collect(Collectors.toMap(SystemStorageAttachmentDO::getStorageBlobId, SystemStorageAttachmentDO::getId));
        List<Long> storageBlobIds = attachments.stream()
                .map(SystemStorageAttachmentDO::getStorageBlobId)
                .collect(Collectors.toList());
        List<SystemStorageBlobDO> blobs = storageBlobMapper.selectByIds(storageBlobIds);
        List<SystemStorageBlobRespVO> result = new ArrayList<>();
        for (SystemStorageBlobDO blob : blobs) {
            SystemStorageBlobRespVO vo = buildStorageBlobRespVO(blob);
            vo.setStorageAttachmentId(blobIdToAttachmentIdMap.get(blob.getId()));
            result.add(vo);
        }
        return result;
    }
    /**
     * æŒ‰ç”¨é€”、业务类型、业务id查询文件列表
     */
    public List<SystemStorageBlobRespVO> getStorageBlobVOsByApplicationAndRecordTypeAndRecordId(
            String application, String recordType, Long recordId) {
        List<SystemStorageAttachmentDO> attachments = getStorageAttachmentsByApplicationAndRecordTypeAndRecordId(
                application, recordType, recordId);
        if (CollUtil.isEmpty(attachments)) {
            return null;
        }
        Map<Long, Long> blobIdToAttachmentIdMap = attachments.stream()
                .collect(Collectors.toMap(SystemStorageAttachmentDO::getStorageBlobId, SystemStorageAttachmentDO::getId));
        List<Long> storageBlobIds = attachments.stream()
                .map(SystemStorageAttachmentDO::getStorageBlobId)
                .collect(Collectors.toList());
        List<SystemStorageBlobDO> blobs = storageBlobMapper.selectByIds(storageBlobIds);
        List<SystemStorageBlobRespVO> result = new ArrayList<>();
        for (SystemStorageBlobDO blob : blobs) {
            SystemStorageBlobRespVO vo = buildStorageBlobRespVO(blob);
            vo.setStorageAttachmentId(blobIdToAttachmentIdMap.get(blob.getId()));
            result.add(vo);
        }
        return result;
    }
    /**
     * æŒ‰ä¸šåŠ¡ç±»åž‹ã€ä¸šåŠ¡id查询文件列表(不区分用途)
     */
    public List<SystemStorageBlobRespVO> getStorageBlobVOsByRecordTypeAndRecordId(String recordType, Long recordId) {
        List<SystemStorageAttachmentDO> attachments = getStorageAttachmentsByRecordTypeAndRecordId(recordType, recordId);
        if (CollUtil.isEmpty(attachments)) {
            return null;
        }
        List<Long> storageBlobIds = attachments.stream()
                .map(SystemStorageAttachmentDO::getStorageBlobId)
                .collect(Collectors.toList());
        List<SystemStorageBlobDO> blobs = storageBlobMapper.selectByIds(storageBlobIds);
        List<SystemStorageBlobRespVO> result = new ArrayList<>();
        for (SystemStorageBlobDO blob : blobs) {
            result.add(buildStorageBlobRespVO(blob));
        }
        return result;
    }
    /**
     * æŒ‰ç”¨é€”、业务类型、业务id查询文件列表,自定义过期时间(分钟)
     */
    public List<SystemStorageBlobRespVO> getStorageBlobVOsByApplicationAndRecordTypeAndRecordId(
            String application, String recordType, Long recordId, BigDecimal expired) {
        List<SystemStorageAttachmentDO> attachments = getStorageAttachmentsByApplicationAndRecordTypeAndRecordId(
                application, recordType, recordId);
        if (CollUtil.isEmpty(attachments)) {
            return null;
        }
        Map<Long, Long> blobIdToAttachmentIdMap = attachments.stream()
                .collect(Collectors.toMap(SystemStorageAttachmentDO::getStorageBlobId, SystemStorageAttachmentDO::getId));
        List<Long> storageBlobIds = attachments.stream()
                .map(SystemStorageAttachmentDO::getStorageBlobId)
                .collect(Collectors.toList());
        List<SystemStorageBlobDO> blobs = storageBlobMapper.selectByIds(storageBlobIds);
        List<SystemStorageBlobRespVO> result = new ArrayList<>();
        for (SystemStorageBlobDO blob : blobs) {
            SystemStorageBlobRespVO vo = BeanUtils.toBean(blob, SystemStorageBlobRespVO.class);
            vo.setPreviewURL(buildSignedUrl(blob, "/preview/", expired));
            vo.setUrl(buildSignedUrl(blob, "/preview/", expired));
            vo.setName(blob.getOriginalFilename());
            vo.setDownloadURL(buildSignedUrl(blob, "/download/", expired));
            vo.setStorageAttachmentId(blobIdToAttachmentIdMap.get(blob.getId()));
            result.add(vo);
        }
        return result;
    }
    // ==================== æŸ¥è¯¢é™„件视图 ====================
    /**
     * æŒ‰ç”¨é€”、业务类型、业务id查询附件视图
     */
    public List<SystemStorageAttachmentRespVO> getStorageAttachmentVOSByApplicationAndRecordTypeAndRecordId(
            String application, String recordType, Long recordId) {
        List<SystemStorageAttachmentDO> attachments = getStorageAttachmentsByApplicationAndRecordTypeAndRecordId(
                application, recordType, recordId);
        if (CollUtil.isEmpty(attachments)) {
            return new ArrayList<>();
        }
        List<SystemStorageAttachmentRespVO> result = new ArrayList<>();
        for (SystemStorageAttachmentDO attachment : attachments) {
            SystemStorageAttachmentRespVO vo = BeanUtils.toBean(attachment, SystemStorageAttachmentRespVO.class);
            List<SystemStorageBlobRespVO> blobVOs = getStorageBlobVOsByStorageAttachmentIds(
                    Collections.singletonList(attachment.getId()));
            vo.setBlobList(CollUtil.isEmpty(blobVOs) ? new ArrayList<>() : blobVOs);
            result.add(vo);
        }
        return result;
    }
    // ==================== ä»…获取预览/下载地址 ====================
    public List<String> getFilePreviewURLsByApplicationAndRecordTypeAndRecordId(
            String application, String recordType, Long recordId) {
        List<SystemStorageAttachmentDO> attachments = getStorageAttachmentsByApplicationAndRecordTypeAndRecordId(
                application, recordType, recordId);
        if (CollUtil.isEmpty(attachments)) {
            return new ArrayList<>();
        }
        List<Long> blobIds = attachments.stream()
                .map(SystemStorageAttachmentDO::getStorageBlobId)
                .collect(Collectors.toList());
        List<SystemStorageBlobDO> blobs = storageBlobMapper.selectByIds(blobIds);
        return blobs.stream()
                .map(this::buildSignedPreviewUrl)
                .collect(Collectors.toList());
    }
    public List<String> getFileDownloadURLsByApplicationAndRecordTypeAndRecordId(
            String application, String recordType, Long recordId) {
        List<SystemStorageAttachmentDO> attachments = getStorageAttachmentsByApplicationAndRecordTypeAndRecordId(
                application, recordType, recordId);
        if (CollUtil.isEmpty(attachments)) {
            return new ArrayList<>();
        }
        List<Long> blobIds = attachments.stream()
                .map(SystemStorageAttachmentDO::getStorageBlobId)
                .collect(Collectors.toList());
        List<SystemStorageBlobDO> blobs = storageBlobMapper.selectByIds(blobIds);
        return blobs.stream()
                .map(this::buildSignedDownloadUrl)
                .collect(Collectors.toList());
    }
    // ==================== æž„建 VO ====================
    private SystemStorageBlobRespVO buildStorageBlobRespVO(SystemStorageBlobDO blob) {
        SystemStorageBlobRespVO vo = BeanUtils.toBean(blob, SystemStorageBlobRespVO.class);
        vo.setPreviewURL(buildSignedPreviewUrl(blob));
        vo.setUrl(buildSignedPreviewUrl(blob));
        vo.setName(blob.getOriginalFilename());
        vo.setDownloadURL(buildSignedDownloadUrl(blob));
        return vo;
    }
    // ==================== æž„建签名URL ====================
    public String buildSignedPreviewUrl(SystemStorageBlobDO blob) {
        return buildSignedUrlFromFields(blob.getUidFilename(), blob.getPath(), blob.getResourceKey(), "/preview/", properties.getExpired());
    }
    public String buildSignedDownloadUrl(SystemStorageBlobDO blob) {
        return buildSignedUrlFromFields(blob.getUidFilename(), blob.getPath(), blob.getResourceKey(), "/download/", properties.getExpired());
    }
    public String buildSignedPreviewUrl(SystemStorageBlobRespVO blob) {
        return buildSignedUrlFromFields(blob.getUidFilename(), blob.getPath(), blob.getResourceKey(), "/preview/", properties.getExpired());
    }
    public String buildSignedDownloadUrl(SystemStorageBlobRespVO blob) {
        return buildSignedUrlFromFields(blob.getUidFilename(), blob.getPath(), blob.getResourceKey(), "/download/", properties.getExpired());
    }
    public String buildSignedUrl(SystemStorageBlobDO blob, String actionPath, BigDecimal expired) {
        return buildSignedUrlFromFields(blob.getUidFilename(), blob.getPath(), blob.getResourceKey(), actionPath, expired);
    }
    public String buildSignedUrl(SystemStorageBlobRespVO blob, String actionPath, BigDecimal expired) {
        return buildSignedUrlFromFields(blob.getUidFilename(), blob.getPath(), blob.getResourceKey(), actionPath, expired);
    }
    /**
     * æž„建统一的带签名预览/下载地址
     *
     * @param uidFilename å”¯ä¸€æ–‡ä»¶å
     * @param path        æ–‡ä»¶ç›¸å¯¹è·¯å¾„
     * @param resourceKey èµ„源唯一标识
     * @param actionPath  æ“ä½œè·¯å¾„ "/preview/" æˆ– "/download/"
     * @param expired     è¿‡æœŸæ—¶é—´ï¼ˆåˆ†é’Ÿï¼‰ï¼Œ-1表示永久有效(走publicKey)
     * @return å¸¦ç­¾åçš„URL
     */
    private String buildSignedUrlFromFields(String uidFilename, String path, String resourceKey,
                                             String actionPath, BigDecimal expired) {
        if (!Arrays.asList("/preview/", "/download/").contains(actionPath)) {
            throw new IllegalArgumentException("操作路径参数错误");
        }
        if (!StringUtils.hasText(uidFilename)) {
            throw new IllegalArgumentException("文件信息不完整");
        }
        String domain = StringUtils.trimTrailingCharacter(properties.getDomain(), '/');
        String prefix = properties.getUrlPrefix().startsWith("/") ? properties.getUrlPrefix() : "/" + properties.getUrlPrefix();
        String baseUrl = domain + prefix + actionPath + uidFilename;
        // -1 è¡¨ç¤ºæ°¸ä¹…有效,不生成 token,改为 publicKey ç»„合校验
        if (expired != null && BigDecimal.valueOf(-1L).compareTo(expired) == 0) {
            if (!StringUtils.hasText(resourceKey)) {
                throw new IllegalArgumentException("公开链接缺少publicKey");
            }
            return baseUrl + "?publicKey=" + resourceKey;
        }
        long now = System.currentTimeMillis();
        BigDecimal expiredValue = expired == null ? new BigDecimal("120") : expired;
        long expiredMillis = expiredValue.multiply(new BigDecimal("60000")).longValue();
        if (expiredMillis <= 0L) {
            expiredMillis = 2L * 60L * 60L * 1000L;
        }
        Date issuedAt = new Date(now);
        Date expiration = new Date(now + expiredMillis);
        SecretKey key = Keys.hmacShaKeyFor(properties.getJwtSecret().getBytes(StandardCharsets.UTF_8));
        String token = Jwts.builder()
                .subject(uidFilename)
                .issuedAt(issuedAt)
                .expiration(expiration)
                .claim("path", path)
                .claim("resourceKey", resourceKey)
                .signWith(key)
                .compact();
        cacheTokenUsage(token, expiredMillis);
        return baseUrl + "?token=" + token;
    }
    // ==================== token使用控制 ====================
    private void cacheTokenUsage(String token, long expiredMillis) {
        if (!StringUtils.hasText(token)) {
            return;
        }
        long ttl = expiredMillis > 0L ? expiredMillis : 2L * 60L * 60L * 1000L;
        stringRedisTemplate.opsForValue().set(buildTokenUsageKey(token), "0", ttl, TimeUnit.MILLISECONDS);
    }
    private String buildTokenUsageKey(String token) {
        return TOKEN_USAGE_KEY_PREFIX + token;
    }
    /**
     * æ ¡éªŒtoken是否还能继续使用
     */
    public void validateTokenUsage(String token) {
        String redisKey = buildTokenUsageKey(token);
        String currentCountValue = stringRedisTemplate.opsForValue().get(redisKey);
        if (!StringUtils.hasText(currentCountValue)) {
            throw new IllegalArgumentException("链接已过期或达到使用次数失效");
        }
        long currentCount = Long.parseLong(currentCountValue);
        int limit = resolveLimit();
        if (currentCount >= limit) {
            stringRedisTemplate.delete(redisKey);
            throw new IllegalArgumentException("链接达到使用次数失效");
        }
        Long updatedCount = stringRedisTemplate.opsForValue().increment(redisKey);
        if (updatedCount != null && updatedCount >= limit) {
            stringRedisTemplate.delete(redisKey);
        }
    }
    private int resolveLimit() {
        return properties.getUseLimit() == null || properties.getUseLimit() <= 0 ? 10 : properties.getUseLimit();
    }
    // ==================== è·¯å¾„与压缩 ====================
    /**
     * ç”Ÿæˆæ–‡ä»¶å­˜å‚¨ç›¸å¯¹è·¯å¾„,格式:yyyy/MMdd(如 2026/0430)
     */
    public String buildRelativePath() {
        LocalDate now = LocalDate.now();
        return now.format(YEAR_PATH_FORMATTER) + "/" + now.format(MONTH_DAY_PATH_FORMATTER);
    }
    /**
     * å¯¹å›¾ç‰‡è¿›è¡ŒåŽ‹ç¼©ï¼Œéžå›¾ç‰‡æˆ–ä¸æ»¡è¶³æ¡ä»¶æ—¶è¿”å›žåŽŸæ–‡ä»¶
     */
    public File compressFile(File file) {
        if (Boolean.TRUE.equals(properties.getCompress())
                && isImage(file.getName())
                && file.length() > properties.getNeedCompressSize().toBytes()) {
            try {
                File compressedFile = new File(file.getParent(), "thumb_" + file.getName());
                if (compressedFile.exists()) {
                    return compressedFile;
                }
                Thumbnails.of(file)
                        .scale(1.0f)
                        .outputQuality(properties.getCompressQuality())
                        .toFile(compressedFile);
                return compressedFile;
            } catch (Exception e) {
                return file;
            }
        }
        return file;
    }
    private boolean isImage(String fileName) {
        String ext = fileName.substring(fileName.lastIndexOf(".") + 1).toLowerCase();
        return "jpg".equals(ext) || "jpeg".equals(ext) || "png".equals(ext);
    }
    // ==================== ç£ç›˜æ–‡ä»¶æ“ä½œ ====================
    /**
     * åˆ é™¤ blob å¯¹åº”的磁盘文件(含压缩文件)
     */
    private void deleteBlobFile(SystemStorageBlobDO blob) {
        File originalFile = resolveBlobFile(blob);
        safeDelete(originalFile);
        if (originalFile != null && originalFile.getParentFile() != null) {
            File compressedFile = new File(originalFile.getParentFile(), "thumb_" + originalFile.getName());
            safeDelete(compressedFile);
        }
    }
    private File resolveBlobFile(SystemStorageBlobDO blob) {
        String basePath = properties.getPath();
        if (!StringUtils.hasText(blob.getPath())) {
            return new File(basePath, blob.getUidFilename());
        }
        return new File(new File(basePath, blob.getPath()), blob.getUidFilename());
    }
    private boolean safeDelete(File file) {
        if (file == null || !file.exists() || !file.isFile()) {
            return false;
        }
        return file.delete();
    }
}
yudao-server/src/main/resources/application-dev.yaml
@@ -190,6 +190,16 @@
    transfer-notify-url: https://yunai.natapp1.cc/admin-api/pay/notify/transfer # æ”¯ä»˜æ¸ é“的【转账】回调地址
  demo: false # å¼€å¯æ¼”示模式
  tencent-lbs-key: TVDBZ-TDILD-4ON4B-PFDZA-RNLKH-VVF6E # QQ åœ°å›¾çš„密钥 https://lbs.qq.com/service/staticV2/staticGuide/staticDoc
  storage: # æ–‡ä»¶ä¸Šä¼ é…ç½®
    path: /data/uploads # æ–‡ä»¶å­˜å‚¨æ ¹ç›®å½•
    url-prefix: /admin-api/system/storage-blob # URL路径前缀
    domain: https://dev.your-domain.com # åŸŸåå‰ç¼€ï¼ˆæŒ‰å®žé™…域名配置)
    expired: 120 # token过期时间(分钟)
    use-limit: 10 # token使用次数上限
    jwt-secret: abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG # JWT密钥
    compress: true # æ˜¯å¦åŽ‹ç¼©å›¾ç‰‡
    need-compress-size: 10MB # è§¦å‘压缩的文件大小阈值
    compress-quality: 0.5 # åŽ‹ç¼©è´¨é‡(0.0-1.0)
justauth:
  enabled: true
yudao-server/src/main/resources/application-local.yaml
@@ -263,6 +263,16 @@
  wxa-subscribe-message:
    miniprogram-state: developer # è·³è½¬å°ç¨‹åºç±»åž‹ï¼šå¼€å‘版为 â€œdeveloper”;体验版为 â€œtrial”为;正式版为 â€œformal”
  tencent-lbs-key: TVDBZ-TDILD-4ON4B-PFDZA-RNLKH-VVF6E # QQ åœ°å›¾çš„密钥 https://lbs.qq.com/service/staticV2/staticGuide/staticDoc
  storage: # æ–‡ä»¶ä¸Šä¼ é…ç½®
    path: D:/uploads # æ–‡ä»¶å­˜å‚¨æ ¹ç›®å½•(按实际服务器路径配置)
    url-prefix: /admin-api/system/storage-blob # URL路径前缀
    domain: http://localhost:48080 # åŸŸåå‰ç¼€ï¼ˆæŒ‰å®žé™…域名配置)
    expired: 120 # token过期时间(分钟)
    use-limit: 10 # token使用次数上限
    jwt-secret: abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG # JWT密钥(至少32字符)
    compress: true # æ˜¯å¦åŽ‹ç¼©å›¾ç‰‡
    need-compress-size: 10MB # è§¦å‘压缩的文件大小阈值
    compress-quality: 0.5 # åŽ‹ç¼©è´¨é‡(0.0-1.0)
justauth:
  enabled: true
yudao-server/src/main/resources/application-test.yaml
@@ -263,6 +263,16 @@
  wxa-subscribe-message:
    miniprogram-state: developer # è·³è½¬å°ç¨‹åºç±»åž‹ï¼šå¼€å‘版为 â€œdeveloper”;体验版为 â€œtrial”为;正式版为 â€œformal”
  tencent-lbs-key: TVDBZ-TDILD-4ON4B-PFDZA-RNLKH-VVF6E # QQ åœ°å›¾çš„密钥 https://lbs.qq.com/service/staticV2/staticGuide/staticDoc
  storage: # æ–‡ä»¶ä¸Šä¼ é…ç½®
    path: /data/uploads # æ–‡ä»¶å­˜å‚¨æ ¹ç›®å½•
    url-prefix: /admin-api/system/storage-blob # URL路径前缀
    domain: https://test.your-domain.com # åŸŸåå‰ç¼€ï¼ˆæŒ‰å®žé™…域名配置)
    expired: 120 # token过期时间(分钟)
    use-limit: 10 # token使用次数上限
    jwt-secret: abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG # JWT密钥
    compress: true # æ˜¯å¦åŽ‹ç¼©å›¾ç‰‡
    need-compress-size: 10MB # è§¦å‘压缩的文件大小阈值
    compress-quality: 0.5 # åŽ‹ç¼©è´¨é‡(0.0-1.0)
justauth:
  enabled: true