diff --git a/docs/dev/JAVA规范.md b/docs/dev/JAVA规范.md index afd8658..7744ee7 100644 --- a/docs/dev/JAVA规范.md +++ b/docs/dev/JAVA规范.md @@ -143,7 +143,7 @@ 7. 【强制】NPE 是程序员最常遇到的异常,必须进行预防性检查。 ​ 说明:当方法返回值为集合或对象时,先判空再操作。 -8. 【推荐】尽量使用 try-with-resources 语句关闭资源。 +8. 【推荐】尽量使用 try-with-fileStorages 语句关闭资源。 ​ 正例: ```java try (InputStream is = new FileInputStream(path)) { diff --git a/docs/gbs/OSS配置汇总.md b/docs/gbs/OSS配置汇总.md new file mode 100644 index 0000000..8cf024f --- /dev/null +++ b/docs/gbs/OSS配置汇总.md @@ -0,0 +1,169 @@ +# GBS 平台文件存储(OSS)配置汇总 + +> **生成时间**:2026-06-30 10:05 +> **数据来源**:Nacos(jjb-dragon 命名空间)+ MySQL `jjb-saas-attachment` 库 +> **结论**:平台文件存储**全部使用阿里云 OSS**,无 MinIO 在用 + +--- + +## 一、核心结论 + +| 项目 | 值 | +|------|-----| +| **存储后端** | 阿里云 OSS(Aliyun Object Storage Service) | +| **支持的存储类型** | `ALIYUN_OSS`(普通文件 70 个渠道)、`ALIYUN_VIDEO_OSS`(视频点播 28 个渠道) | +| **存储 Region** | 华东1(杭州)`oss-cn-hangzhou` | +| **Bucket** | `test-dragon-yf-pub`(所有租户共用) | +| **总容量** | 平台级 4 TB(4398046511104 字节),单租户默认 100 GB | +| **已用容量** | 平台级 3.09 TB(3096225104031 字节 / ≈ 2.81 TiB) | +| **Nacos 默认存储策略** | `ALIYUN_OSS` | +| **MinIO 状态** | 容器在运行,但**仅作历史残留**,无任何业务使用 | + +--- + +## 二、Nacos 默认存储策略 + +Nacos 命名空间 `jjb-dragon` → 配置 `jjb-saas-attachment.yml`: + +```yaml +attachment: + # 默认存储空间 支持MINIO、ALIYUN_OSS + storage-policies: ALIYUN_OSS # ← 当前使用阿里云 OSS + file-view-url: ${common.gateway.network.http.external}/fileview/onlinePreview + expiration: 3600 + default-init-capacity: 102400 # 默认 100 GB + +minio: + inter: + replace-enum: true + url: http://192.168.1.117:9001 # ← 旧地址,无效(已弃用) +``` + +> ⚠️ 注释里仍写着"支持 MINIO、ALIYUN_OSS",但 `minio.inter.url` 指向已不存在的旧环境 `192.168.1.117:9001`。当前业务完全走 OSS,不影响上传。 + +--- + +## 三、阿里云 OSS 凭证(2 套 — 文件/视频) + +| 用途 | AccessKeyId | AccessKeySecret | Bucket | Region | 内/外网 Endpoint | +|------|-------------|-----------------|--------|--------|------------------| +| **ALIYUN_OSS**(文件) | `LTAI5tFerUgBCLEbXtinDD6d` | `XyGVNV2RvW8U3ceoJDiy9A9oLvCGYp` | `test-dragon-yf-pub` | oss-cn-hangzhou | `https://test-dragon-yf-pub.oss-cn-hangzhou.aliyuncs.com` | +| **ALIYUN_VIDEO_OSS**(视频) | `LTAI5tDAMFkin5tavp4thfaG` | `XoBYTtIgeHOiz3udiP5gm0rcdRWp25` | `test-dragon-yf-pub` | oss-cn-shanghai(regionId) | 同上 | + +**URL 模板**(来自 `attachment_tenant_channel.channel_config` JSON): + +```json +{ + "rootDir": "jjb", + "endpoint": "oss-cn-hangzhou.aliyuncs.com", + "bucketName": "test-dragon-yf-pub", + "expiration": null, + "accessKeyId": "LTAI5tFerUgBCLEbXtinDD6d", + "accessKeySecret": "XyGVNV2RvW8U3ceoJDiy9A9oLvCGYp", + "innerNetBaseUrl": "https://test-dragon-yf-pub.oss-cn-hangzhou.aliyuncs.com", + "outerNetBaseUrl": "https://test-dragon-yf-pub.oss-cn-hangzhou.aliyuncs.com", + "innerFileViewBaseUrl": "https://test-dragon-yf-pub.oss-cn-hangzhou.aliyuncs.com", + "outerFileViewBaseUrl": "https://test-dragon-yf-pub.oss-cn-hangzhou.aliyuncs.com" +} +``` + +> ⚠️ 视频 OSS 凭证中 `regionId: cn-shanghai` 与 `endpoint: oss-cn-hangzhou` 不一致(**疑似配置错配**),但实际请求走 endpoint,杭州 bucket 可正常访问。如切换视频点播需注意。 + +--- + +## 四、数据库表结构 + +### 4.1 `attachment_tenant_channel`(租户存储渠道绑定表) +| 字段 | 说明 | +|------|------| +| `id` | 主键 | +| `channel_owner_enum` | 渠道所属(PLATFORM / 自定义) | +| `channel_type_enum` | 渠道类型(ALIYUN_OSS / ALIYUN_VIDEO_OSS / MinIO 等) | +| `channel_name` | 渠道名称 | +| `channel_config` | 渠道配置 JSON(含 endpoint/bucket/accessKey 等) | +| `total_storage_size` | 总容量(字节) | +| `default_enum` | 是否默认渠道 | +| `tenant_id` / `org_id` | 租户/机构 | + +**数据规模**:98 条记录(70 个 ALIYUN_OSS + 28 个 ALIYUN_VIDEO_OSS) + +### 4.2 `oss_region_endpoint`(OSS 区域映射表) +预置 16 个阿里云 OSS Region(杭州/上海/北京/深圳/香港/美国等),用于 endpoint 路由。 + +| 关键 Region | 外网 endpoint | 内网 endpoint | +|-------------|---------------|---------------| +| 华东1(杭州) | oss-cn-hangzhou.aliyuncs.com | oss-cn-hangzhou-internal.aliyuncs.com | +| 华东2(上海) | oss-cn-shanghai.aliyuncs.com | oss-cn-shanghai-internal.aliyuncs.com | +| 华北2(北京) | oss-cn-beijing.aliyuncs.com | oss-cn-beijing-internal.aliyuncs.com | +| 华南1(深圳) | oss-cn-shenzhen.aliyuncs.com | oss-cn-shenzhen-internal.aliyuncs.com | +| 中国香港 | oss-cn-hongkong.aliyuncs.com | oss-cn-hongkong-internal.aliyuncs.com | + +### 4.3 `attachment_file`(文件元数据表) +| 关键字段 | 说明 | +|----------|------| +| `storage_type_enum` | 存储类型(本地/阿里OSS/本地minio等) | +| `bucket_name` | bucket 名(如 `test-dragon-yf-pub`) | +| `url` | 上传文件 URL | +| `preview_url` / `download_url` | 预览/下载地址 | +| `status_enum` / `upload_status_enum` | 状态/上传状态 | +| `file_size` / `name` / `ext` | 文件属性 | +| `channel_id` | 渠道 id(关联 `attachment_tenant_channel`) | + +### 4.4 `attachment_storage`(存储用量表) +| 字段 | 说明 | +|------|------| +| `tenant_id` | 租户 | +| `total_storage_size` | 总配额(字节,默认 107374182400 = 100 GB) | +| `occupied_storage_size` | 已用容量(字节) | + +**平台总和**:4 TB 配额,3.09 TB 已用(**73% 使用率**) + +--- + +## 五、最近文件上传样本 + +``` +id: 2065350515959717890 +name: 48afad885c233d3cf82fa3b779002554 +storage_type_enum: NULL +bucket_name: test-dragon-yf-pub +file_size: 410715 +create_time: 2026-06-12 16:28:30 +``` + +> 注意:`storage_type_enum` 字段为 NULL(schema 里没存具体值),但 `bucket_name` = `test-dragon-yf-pub` 表明所有文件都上传到了阿里云 OSS。 + +--- + +## 六、MinIO 容器状态(仅作残留) + +``` +容器名: minio +状态: Up 13 days +端口: 0.0.0.0:9000-9001 (API/Console) +数据目录: /data +内容: .minio.sys/ jjb-attachment/ (jjb-attachment 桶内只有 test.txt) +``` + +**结论**:MinIO 容器仍在运行(端口 9000/9001 暴露在 master 节点),但桶内无业务数据,**仅作早期本地化部署残留**。Nacos 配置文件 `minio.inter.url` 指向的 `192.168.1.117:9001` 早已不可达(应为 192.168.20.100:9000),但因为默认策略是 ALIYUN_OSS,所以**未影响实际业务**。 + +--- + +## 七、问题与改进建议 + +1. **MinIO 配置应清理**:`jjb-saas-attachment.yml` 的 `minio.inter.url` 指向旧地址 `192.168.1.117:9001`,与现状不符,应改为 `192.168.20.100:9000` 或直接移除(既然不使用)。 +2. **视频 OSS region 错配**:`ALIYUN_VIDEO_OSS` 的 `regionId: cn-shanghai` 与 `endpoint: oss-cn-hangzhou` 不一致,存在历史误配风险。 +3. **`storage_type_enum` 字段为空**:`attachment_file.storage_type_enum` 全部为 NULL,无法直接通过 SQL 区分文件走的是 OSS 还是 MinIO。判断需通过 `bucket_name` 字段。 +4. **平台总容量 73% 已用**(3.09 TB / 4 TB),建议监控告警。 +5. **凭证无主从**:OSS AK/SK 直接以明文存储在 `attachment_tenant_channel.channel_config` JSON 字段,建议改用 K8s Secret + Nacos 占位符注入。 + +--- + +## 八、相关服务连通性入口 + +- **OSS 外网地址**:https://test-dragon-yf-pub.oss-cn-hangzhou.aliyuncs.com +- **OSS 内网地址**:https://test-dragon-yf-pub.oss-cn-hangzhou-internal.aliyuncs.com +- **MinIO Console**(master 节点):http://192.168.20.100:9001(admin / Zcloud@zcloud2026,无业务数据) +- **MinIO API**:http://192.168.20.100:9000 +- **attachment 服务内部访问路径**:`lb://jjb-saas-attachment/attachment/**` +- **文件预览地址模板**:`${common.gateway.network.http.external}/fileview/onlinePreview?xxx` diff --git a/docs/gbs/Resource.java b/docs/gbs/Resource.java new file mode 100644 index 0000000..c893e15 --- /dev/null +++ b/docs/gbs/Resource.java @@ -0,0 +1,167 @@ +package com.qiyoule.resource.domain.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import java.io.Serializable; +import java.util.Date; +import lombok.Data; +import lombok.experimental.Accessors; + +/** + * + * @TableName filestorage + */ +@TableName(value ="filestorage") +@Data +@Accessors(chain = true) +public class Resource implements Serializable { + /** + * + */ + @TableId(type = IdType.AUTO) + private Integer id; + + /** + * + */ + private String url; + + /** + * + */ + private Integer size; + + /** + * + */ + private String filename; + + /** + * + */ + private String originalFilename; + + /** + * + */ + private String basePath; + + /** + * + */ + private String path; + + /** + * + */ + private String ext; + + /** + * + */ + private String contentType; + + /** + * + */ + private String platform; + + /** + * + */ + private String md5; + + /** + * + */ + private String creatorName; + + /** + * + */ + private String creatorUid; + + /** + * + */ + private Date createdTime; + + @TableField(exist = false) + private static final long serialVersionUID = 1L; + + @Override + public boolean equals(Object that) { + if (this == that) { + return true; + } + if (that == null) { + return false; + } + if (getClass() != that.getClass()) { + return false; + } + Resource other = (Resource) that; + return (this.getId() == null ? other.getId() == null : this.getId().equals(other.getId())) + && (this.getUrl() == null ? other.getUrl() == null : this.getUrl().equals(other.getUrl())) + && (this.getSize() == null ? other.getSize() == null : this.getSize().equals(other.getSize())) + && (this.getFilename() == null ? other.getFilename() == null : this.getFilename().equals(other.getFilename())) + && (this.getOriginalFilename() == null ? other.getOriginalFilename() == null : this.getOriginalFilename().equals(other.getOriginalFilename())) + && (this.getBasePath() == null ? other.getBasePath() == null : this.getBasePath().equals(other.getBasePath())) + && (this.getPath() == null ? other.getPath() == null : this.getPath().equals(other.getPath())) + && (this.getExt() == null ? other.getExt() == null : this.getExt().equals(other.getExt())) + && (this.getContentType() == null ? other.getContentType() == null : this.getContentType().equals(other.getContentType())) + && (this.getPlatform() == null ? other.getPlatform() == null : this.getPlatform().equals(other.getPlatform())) + && (this.getMd5() == null ? other.getMd5() == null : this.getMd5().equals(other.getMd5())) + && (this.getCreatorName() == null ? other.getCreatorName() == null : this.getCreatorName().equals(other.getCreatorName())) + && (this.getCreatorUid() == null ? other.getCreatorUid() == null : this.getCreatorUid().equals(other.getCreatorUid())) + && (this.getCreatedTime() == null ? other.getCreatedTime() == null : this.getCreatedTime().equals(other.getCreatedTime())); + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + ((getId() == null) ? 0 : getId().hashCode()); + result = prime * result + ((getUrl() == null) ? 0 : getUrl().hashCode()); + result = prime * result + ((getSize() == null) ? 0 : getSize().hashCode()); + result = prime * result + ((getFilename() == null) ? 0 : getFilename().hashCode()); + result = prime * result + ((getOriginalFilename() == null) ? 0 : getOriginalFilename().hashCode()); + result = prime * result + ((getBasePath() == null) ? 0 : getBasePath().hashCode()); + result = prime * result + ((getPath() == null) ? 0 : getPath().hashCode()); + result = prime * result + ((getExt() == null) ? 0 : getExt().hashCode()); + result = prime * result + ((getContentType() == null) ? 0 : getContentType().hashCode()); + result = prime * result + ((getPlatform() == null) ? 0 : getPlatform().hashCode()); + result = prime * result + ((getMd5() == null) ? 0 : getMd5().hashCode()); + result = prime * result + ((getCreatorName() == null) ? 0 : getCreatorName().hashCode()); + result = prime * result + ((getCreatorUid() == null) ? 0 : getCreatorUid().hashCode()); + result = prime * result + ((getCreatedTime() == null) ? 0 : getCreatedTime().hashCode()); + return result; + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append(getClass().getSimpleName()); + sb.append(" ["); + sb.append("Hash = ").append(hashCode()); + sb.append(", id=").append(id); + sb.append(", url=").append(url); + sb.append(", size=").append(size); + sb.append(", filename=").append(filename); + sb.append(", originalFilename=").append(originalFilename); + sb.append(", basePath=").append(basePath); + sb.append(", path=").append(path); + sb.append(", ext=").append(ext); + sb.append(", contentType=").append(contentType); + sb.append(", platform=").append(platform); + sb.append(", md5=").append(md5); + sb.append(", creatorName=").append(creatorName); + sb.append(", creatorUid=").append(creatorUid); + sb.append(", createdTime=").append(createdTime); + sb.append(", serialVersionUID=").append(serialVersionUID); + sb.append("]"); + return sb.toString(); + } +} \ No newline at end of file diff --git a/docs/gbs/init-filestorage.sql b/docs/gbs/init-filestorage.sql new file mode 100644 index 0000000..b5c4c74 --- /dev/null +++ b/docs/gbs/init-filestorage.sql @@ -0,0 +1,27 @@ +-- ============================================= +-- 文件存储本地管理表 filestorage +-- 数据库: jjb_saas_safety_eval +-- 说明: 用于记录上传到阿里云OSS的文件元信息, +-- 在存储/删除文件时进行文件的维护管理 +-- ============================================= + +CREATE TABLE IF NOT EXISTS `filestorage` ( + `id` INT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', + `url` VARCHAR(1024) NOT NULL COMMENT 'OSS文件完整访问URL', + `base_path` VARCHAR(512) DEFAULT NULL COMMENT 'OSS存储根路径 (如 jjb)', + `path` VARCHAR(512) DEFAULT NULL COMMENT 'OSS存储相对路径 (不含basePath)', + `filename` VARCHAR(512) DEFAULT NULL COMMENT 'OSS存储文件名 (uuid生成)', + `original_filename` VARCHAR(512) DEFAULT NULL COMMENT '文件原始名称', + `size` BIGINT DEFAULT 0 COMMENT '文件大小(字节)', + `ext` VARCHAR(64) DEFAULT NULL COMMENT '文件扩展名(不含.)', + `content_type` VARCHAR(128) DEFAULT NULL COMMENT '文件MIME类型', + `platform` VARCHAR(64) DEFAULT NULL COMMENT 'OSS平台标识 (如 aliyun-oss)', + `md5` VARCHAR(64) DEFAULT NULL COMMENT 'Content-MD5或文件MD5值', + `creator_name` VARCHAR(128) DEFAULT NULL COMMENT '上传者姓名', + `creator_uid` VARCHAR(128) DEFAULT NULL COMMENT '上传者UID', + `created_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '上传时间', + INDEX `idx_platform` (`platform`), + INDEX `idx_creator_uid` (`creator_uid`), + INDEX `idx_filename` (`filename`), + INDEX `idx_created_time` (`created_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='文件存储本地管理表'; diff --git a/pom.xml b/pom.xml index 2131a9d..9a37e61 100644 --- a/pom.xml +++ b/pom.xml @@ -48,6 +48,12 @@ 1.18.30 1.7.1.1-SNAPSHOT + + + 2.2.0 + + + 3.17.4 @@ -201,6 +207,20 @@ lombok ${lombok.version} + + + + org.dromara.x-file-storage + x-file-storage-spring + ${x-file-storage.version} + + + + + com.aliyun.oss + aliyun-sdk-oss + ${aliyun-oss.version} + diff --git a/safety-eval-adapter/src/main/java/org/qinan/safetyeval/adapter/web/FileStorageController.java b/safety-eval-adapter/src/main/java/org/qinan/safetyeval/adapter/web/FileStorageController.java new file mode 100644 index 0000000..f900b9e --- /dev/null +++ b/safety-eval-adapter/src/main/java/org/qinan/safetyeval/adapter/web/FileStorageController.java @@ -0,0 +1,118 @@ +package org.qinan.safetyeval.adapter.web; + +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiParam; +import org.qinan.safetyeval.client.api.FileStorageApi; +import org.qinan.safetyeval.client.co.FileInfoCO; +import org.qinan.safetyeval.client.dto.SingleResponse; +import org.springframework.web.bind.annotation.*; + +import javax.annotation.Resource; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.io.OutputStream; +import java.net.URL; +import java.util.List; + +/** + * 文件存储管理适配层(Controller) + *

+ * 提供 filestorage 表的下载、删除、获取地址等 REST 接口。 + * 文件新增由 {@link FileUploadController} 在上传时同步完成。 + *

+ * + * @author safety-eval + */ +@Api(tags = "文件存储管理") +@RestController +@RequestMapping("/file-storage") +public class FileStorageController { + + @Resource + private FileStorageApi fileStorageApi; + + // ==================== 下载 ==================== + + @ApiOperation("下载文件") + @GetMapping("/download/{fileId}") + public void download( + @ApiParam("文件ID") @PathVariable Integer fileId, + HttpServletResponse response) { + try { + SingleResponse result = fileStorageApi.getPresignedUrl(fileId); + if (!result.isSuccess() || result.getData() == null) { + response.setStatus(HttpServletResponse.SC_NOT_FOUND); + return; + } + FileInfoCO fileInfo = result.getData(); + + response.setContentType(fileInfo.getContentType() != null ? fileInfo.getContentType() : "application/octet-stream"); + response.setHeader("Content-Disposition", + "attachment; filename=\"" + java.net.URLEncoder.encode(fileInfo.getOriginalFilename(), "UTF-8") + "\""); + if (fileInfo.getSize() != null) { + response.setContentLengthLong(fileInfo.getSize()); + } + + URL url = new URL(fileInfo.getPresignedUrl()); + try (java.io.InputStream in = url.openStream(); + OutputStream out = response.getOutputStream()) { + byte[] buffer = new byte[8192]; + int bytesRead; + while ((bytesRead = in.read(buffer)) != -1) { + out.write(buffer, 0, bytesRead); + } + out.flush(); + } + } catch (IOException e) { + response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + } + } + + // ==================== 获取地址/信息 ==================== + + @ApiOperation("获取文件签名URL(临时访问链接)") + @GetMapping("/presigned-url/{fileId}") + public SingleResponse getPresignedUrl( + @ApiParam("文件ID") @PathVariable Integer fileId) { + return fileStorageApi.getPresignedUrl(fileId); + } + + @ApiOperation("获取文件信息") + @GetMapping("/info/{fileId}") + public SingleResponse getInfo( + @ApiParam("文件ID") @PathVariable Integer fileId) { + return fileStorageApi.getInfo(fileId); + } + + // ==================== 删除 ==================== + + @ApiOperation("删除文件") + @DeleteMapping("/delete/{fileId}") + public SingleResponse delete( + @ApiParam("文件ID") @PathVariable Integer fileId) { + return fileStorageApi.delete(fileId); + } + + @ApiOperation("批量删除文件") + @DeleteMapping("/delete-batch") + public SingleResponse deleteBatch( + @ApiParam("文件ID列表") @RequestBody List fileIds) { + return fileStorageApi.deleteBatch(fileIds); + } + + // ==================== 列表查询 ==================== + + @ApiOperation("获取所有文件列表") + @GetMapping("/list-all") + public SingleResponse> listAll() { + return fileStorageApi.listAll(); + } + + @ApiOperation("根据上传者获取文件列表") + @GetMapping("/list-by-creator") + public SingleResponse> listByCreator( + @ApiParam("上传者UID") @RequestParam("creatorUid") String creatorUid) { + return fileStorageApi.listByCreator(creatorUid); + } +} diff --git a/safety-eval-adapter/src/main/java/org/qinan/safetyeval/adapter/web/FileUploadController.java b/safety-eval-adapter/src/main/java/org/qinan/safetyeval/adapter/web/FileUploadController.java new file mode 100644 index 0000000..72d6792 --- /dev/null +++ b/safety-eval-adapter/src/main/java/org/qinan/safetyeval/adapter/web/FileUploadController.java @@ -0,0 +1,44 @@ +package org.qinan.safetyeval.adapter.web; + +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiParam; +import org.qinan.safetyeval.client.api.FileUploadApi; +import org.qinan.safetyeval.client.co.FileInfoCO; +import org.qinan.safetyeval.client.dto.SingleResponse; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import javax.annotation.Resource; +import java.util.List; + +/** + * 文件上传适配层(Controller) + *

+ * 区分公开上传(创建人默认 system)与内部上传(创建人为当前登录用户)。 + * 上传完成后自动同步元信息到 filestorage 表。 + *

+ * + * @author safety-eval + */ +@Api(tags = "文件上传") +@RestController +@RequestMapping("/file") +public class FileUploadController { + + @Resource + private FileUploadApi fileUploadApi; + @ApiOperation("内部上传单个文件(创建人为当前用户)") + @PostMapping("/upload") + public SingleResponse uploadInternal( + @ApiParam("上传文件") @RequestParam("file") MultipartFile file) { + return fileUploadApi.uploadInternal(file); + } + + @ApiOperation("内部批量上传文件(创建人为当前用户)") + @PostMapping("/upload/batch") + public SingleResponse> uploadInternalBatch( + @ApiParam("上传文件列表") @RequestParam("files") List files) { + return fileUploadApi.uploadInternalBatch(files); + } +} diff --git a/safety-eval-adapter/src/main/java/org/qinan/safetyeval/adapter/web/FileUploadPublicController.java b/safety-eval-adapter/src/main/java/org/qinan/safetyeval/adapter/web/FileUploadPublicController.java new file mode 100644 index 0000000..c64e33f --- /dev/null +++ b/safety-eval-adapter/src/main/java/org/qinan/safetyeval/adapter/web/FileUploadPublicController.java @@ -0,0 +1,50 @@ +package org.qinan.safetyeval.adapter.web; + +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiParam; +import org.qinan.safetyeval.client.api.FileUploadApi; +import org.qinan.safetyeval.client.co.FileInfoCO; +import org.qinan.safetyeval.client.dto.SingleResponse; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import javax.annotation.Resource; +import java.util.List; + +/** + * 文件上传适配层(Controller) + *

+ * 区分公开上传(创建人默认 system)与内部上传(创建人为当前登录用户)。 + * 上传完成后自动同步元信息到 filestorage 表。 + *

+ * + * @author safety-eval + */ +@Api(tags = "文件上传") +@RestController +@RequestMapping("/test/file") +public class FileUploadPublicController { + + @Resource + private FileUploadApi fileUploadApi; + + // ==================== 公开上传 ==================== + + @ApiOperation("公开上传单个文件(创建人默认 system)") + @PostMapping("/upload") + public SingleResponse uploadPublic( + @ApiParam("上传文件") @RequestParam("file") MultipartFile file) { + return fileUploadApi.uploadPublic(file); + } + + @ApiOperation("公开批量上传文件(创建人默认 system)") + @PostMapping("/upload/batch") + public SingleResponse> uploadPublicBatch( + @ApiParam("上传文件列表") @RequestParam("files") List files) { + return fileUploadApi.uploadPublicBatch(files); + } +} diff --git a/safety-eval-app/src/main/java/org/qinan/safetyeval/app/executor/FileStorageExecutor.java b/safety-eval-app/src/main/java/org/qinan/safetyeval/app/executor/FileStorageExecutor.java new file mode 100644 index 0000000..bc37086 --- /dev/null +++ b/safety-eval-app/src/main/java/org/qinan/safetyeval/app/executor/FileStorageExecutor.java @@ -0,0 +1,125 @@ +package org.qinan.safetyeval.app.executor; + +import org.dromara.x.file.storage.core.FileInfo; +import org.dromara.x.file.storage.core.FileStorageService; +import org.qinan.safetyeval.client.api.FileStorageApi; +import org.qinan.safetyeval.client.co.FileInfoCO; +import org.qinan.safetyeval.client.dto.SingleResponse; +import org.qinan.safetyeval.domain.entity.FileStorage; +import org.qinan.safetyeval.domain.exception.BizException; +import org.qinan.safetyeval.domain.exception.ErrorCode; +import org.qinan.safetyeval.domain.service.FileStorageDomainService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import javax.annotation.Resource; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 文件存储管理执行器(App层) + *

+ * 负责 filestorage 表的查询、删除、获取签名URL等管理操作。 + * 文件新增由 {@link FileUploadExecutor} 在上传时同步完成,本执行器不提供上传能力。 + *

+ * + * @author safety-eval + */ +@Service +public class FileStorageExecutor implements FileStorageApi { + + private static final Logger log = LoggerFactory.getLogger(FileStorageExecutor.class); + + @Resource + private FileStorageService fileStorageService; + + @Resource + private FileStorageDomainService fileStorageDomainService; + + @Override + public SingleResponse getPresignedUrl(Integer fileId) { + try { + FileStorage fileStorage = fileStorageDomainService.getResource(fileId); + FileInfo ossFileInfo = FileStorageHelper.buildOssFileInfo(fileStorage); + FileStorageHelper.checkOssExists(fileStorageService, ossFileInfo); + + String presignedUrl = FileStorageHelper.generatePresignedUrl(fileStorageService, ossFileInfo); + return SingleResponse.success(FileStorageHelper.toCO(fileStorage, presignedUrl)); + } catch (BizException e) { + throw e; + } catch (Exception e) { + log.error("Generate presigned URL failed: fileId={}", fileId, e); + throw new BizException(ErrorCode.FILE_URL_GENERATE_FAILED, e); + } + } + + @Override + public SingleResponse getInfo(Integer fileId) { + FileStorage fileStorage = fileStorageDomainService.getResource(fileId); + String presignedUrl = null; + try { + FileInfo ossFileInfo = FileStorageHelper.buildOssFileInfo(fileStorage); + if (fileStorageService.exists(ossFileInfo)) { + presignedUrl = FileStorageHelper.generatePresignedUrl(fileStorageService, ossFileInfo); + } + } catch (Exception e) { + log.warn("Failed to generate presigned URL for info query: fileId={}", fileId, e); + } + return SingleResponse.success(FileStorageHelper.toCO(fileStorage, presignedUrl)); + } + + @Override + public SingleResponse delete(Integer fileId) { + try { + FileStorage fileStorage = fileStorageDomainService.getResource(fileId); + + FileInfo ossFileInfo = FileStorageHelper.buildOssFileInfo(fileStorage); + if (!fileStorageService.delete(ossFileInfo)) { + log.warn("OSS delete returned false for fileId={}, url={}", fileId, fileStorage.getUrl()); + } + + fileStorageDomainService.deleteResource(fileId); + log.info("File deleted successfully: fileId={}, filename={}", fileId, fileStorage.getFilename()); + return SingleResponse.success(); + } catch (BizException e) { + throw e; + } catch (Exception e) { + log.error("File delete failed: fileId={}", fileId, e); + throw new BizException(ErrorCode.FILE_DELETE_FAILED, e); + } + } + + @Override + public SingleResponse deleteBatch(List fileIds) { + if (fileIds == null || fileIds.isEmpty()) { + return SingleResponse.success(); + } + for (Integer fileId : fileIds) { + try { + delete(fileId); + } catch (Exception e) { + log.warn("Batch delete: failed for fileId={}, continue with next", fileId, e); + } + } + return SingleResponse.success(); + } + + @Override + public SingleResponse> listAll() { + List fileStorages = fileStorageDomainService.listAllResources(); + List result = fileStorages.stream() + .map(r -> FileStorageHelper.toCO(r, null)) + .collect(Collectors.toList()); + return SingleResponse.success(result); + } + + @Override + public SingleResponse> listByCreator(String creatorUid) { + List fileStorages = fileStorageDomainService.listResourcesByCreator(creatorUid); + List result = fileStorages.stream() + .map(r -> FileStorageHelper.toCO(r, null)) + .collect(Collectors.toList()); + return SingleResponse.success(result); + } +} diff --git a/safety-eval-app/src/main/java/org/qinan/safetyeval/app/executor/FileStorageHelper.java b/safety-eval-app/src/main/java/org/qinan/safetyeval/app/executor/FileStorageHelper.java new file mode 100644 index 0000000..534ac53 --- /dev/null +++ b/safety-eval-app/src/main/java/org/qinan/safetyeval/app/executor/FileStorageHelper.java @@ -0,0 +1,114 @@ +package org.qinan.safetyeval.app.executor; + +import org.dromara.x.file.storage.core.FileInfo; +import org.dromara.x.file.storage.core.FileStorageService; +import org.qinan.safetyeval.client.co.FileInfoCO; +import org.qinan.safetyeval.domain.entity.FileStorage; +import org.qinan.safetyeval.domain.exception.BizException; +import org.qinan.safetyeval.domain.exception.ErrorCode; +import org.qinan.safetyeval.infrastructure.adapter.auth.AuthUserContextAdapter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Date; + +/** + * 文件操作公共工具类(App层内部共享) + *

+ * 提供实体转换、OSS FileInfo 构建、创建人信息填充等通用逻辑。 + *

+ * + * @author safety-eval + */ +final class FileStorageHelper { + + private static final Logger log = LoggerFactory.getLogger(FileStorageHelper.class); + + /** 签名URL默认过期时间:1小时(毫秒) */ + static final long PRESIGNED_URL_EXPIRE_MILLIS = Duration.ofHours(1).toMillis(); + + private FileStorageHelper() { + } + + /** + * 根据 OSS FileInfo 构建 FileStorage 实体 + * + * @param ossFileInfo OSS 文件信息 + * @param originalFilename 原始文件名 + * @param isInternal true-内部(取当前用户),false-公开(creator=system) + */ + static FileStorage buildResource(FileInfo ossFileInfo, String originalFilename, boolean isInternal) { + FileStorage fileStorage = new FileStorage(); + fileStorage.setUrl(ossFileInfo.getUrl()); + fileStorage.setSize(ossFileInfo.getSize()); + fileStorage.setFilename(ossFileInfo.getFilename()); + fileStorage.setOriginalFilename(originalFilename); + fileStorage.setBasePath(ossFileInfo.getBasePath()); + fileStorage.setPath(ossFileInfo.getPath()); + fileStorage.setExt(ossFileInfo.getExt()); + fileStorage.setContentType(ossFileInfo.getContentType()); + fileStorage.setPlatform(ossFileInfo.getPlatform()); + fileStorage.setCreatedTime(LocalDateTime.now()); + + if (isInternal) { + fileStorage.setCreatorName(AuthUserContextAdapter.getCurrentUserName()); + Long userId = AuthUserContextAdapter.getCurrentUserId(); + fileStorage.setCreatorUid(userId != null ? String.valueOf(userId) : "0"); + } else { + fileStorage.setCreatorName("system"); + fileStorage.setCreatorUid("0"); + } + return fileStorage; + } + + /** + * 根据 FileStorage 实体构建 OSS FileInfo(用于存在性检查、删除、生成签名URL等OOS操作) + */ + static FileInfo buildOssFileInfo(FileStorage fileStorage) { + FileInfo ossFileInfo = new FileInfo(); + ossFileInfo.setUrl(fileStorage.getUrl()); + ossFileInfo.setBasePath(fileStorage.getBasePath()); + ossFileInfo.setFilename(fileStorage.getFilename()); + ossFileInfo.setPlatform(fileStorage.getPlatform()); + return ossFileInfo; + } + + /** + * 生成签名URL + */ + static String generatePresignedUrl(FileStorageService fileStorageService, FileInfo ossFileInfo) { + Date expiration = new Date(System.currentTimeMillis() + PRESIGNED_URL_EXPIRE_MILLIS); + return fileStorageService.generatePresignedUrl(ossFileInfo, expiration).toString(); + } + + /** + * 检查文件在 OSS 是否存在 + */ + static void checkOssExists(FileStorageService fileStorageService, FileInfo ossFileInfo) { + if (!fileStorageService.exists(ossFileInfo)) { + throw new BizException(ErrorCode.FILE_NOT_FOUND); + } + } + + /** + * FileStorage 转 FileInfoCO + */ + static FileInfoCO toCO(FileStorage fileStorage, String presignedUrl) { + if (fileStorage == null) { + return null; + } + FileInfoCO co = new FileInfoCO(); + co.setId(fileStorage.getId()); + co.setUrl(fileStorage.getUrl()); + co.setPresignedUrl(presignedUrl != null ? presignedUrl : fileStorage.getUrl()); + co.setSize(fileStorage.getSize()); + co.setOriginalFilename(fileStorage.getOriginalFilename()); + co.setExt(fileStorage.getExt()); + co.setContentType(fileStorage.getContentType()); + co.setCreatorName(fileStorage.getCreatorName()); + co.setCreatedTime(fileStorage.getCreatedTime()); + return co; + } +} diff --git a/safety-eval-app/src/main/java/org/qinan/safetyeval/app/executor/FileUploadExecutor.java b/safety-eval-app/src/main/java/org/qinan/safetyeval/app/executor/FileUploadExecutor.java new file mode 100644 index 0000000..56f42cf --- /dev/null +++ b/safety-eval-app/src/main/java/org/qinan/safetyeval/app/executor/FileUploadExecutor.java @@ -0,0 +1,115 @@ +package org.qinan.safetyeval.app.executor; + +import org.dromara.x.file.storage.core.FileInfo; +import org.dromara.x.file.storage.core.FileStorageService; +import org.qinan.safetyeval.client.api.FileUploadApi; +import org.qinan.safetyeval.client.co.FileInfoCO; +import org.qinan.safetyeval.client.dto.SingleResponse; +import org.qinan.safetyeval.domain.entity.FileStorage; +import org.qinan.safetyeval.domain.exception.BizException; +import org.qinan.safetyeval.domain.exception.ErrorCode; +import org.qinan.safetyeval.domain.service.FileStorageDomainService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import javax.annotation.Resource; +import java.util.ArrayList; +import java.util.List; + +/** + * 文件上传执行器(App层) + *

+ * 负责文件上传至阿里云 OSS,并在上传完成后将元信息同步写入 filestorage 表。 + * 区分公开上传(创建人=system)与内部上传(创建人=当前登录用户)。 + *

+ * + * @author safety-eval + */ +@Service +public class FileUploadExecutor implements FileUploadApi { + + private static final Logger log = LoggerFactory.getLogger(FileUploadExecutor.class); + + @Resource + private FileStorageService fileStorageService; + + @Resource + private FileStorageDomainService fileStorageDomainService; + + // ==================== 公开上传(创建人默认 system) ==================== + + @Override + public SingleResponse uploadPublic(MultipartFile file) { + return doUpload(file, false); + } + + @Override + public SingleResponse> uploadPublicBatch(List files) { + return doUploadBatch(files, false); + } + + // ==================== 内部上传(创建人为当前用户) ==================== + + @Override + public SingleResponse uploadInternal(MultipartFile file) { + return doUpload(file, true); + } + + @Override + public SingleResponse> uploadInternalBatch(List files) { + return doUploadBatch(files, true); + } + + // ==================== 私有方法 ==================== + + /** + * 执行单个文件上传并同步写入 filestorage 表 + * + * @param file 上传的文件 + * @param isInternal true-内部(取当前用户),false-公开(creator=system) + */ + private SingleResponse doUpload(MultipartFile file, boolean isInternal) { + if (file == null || file.isEmpty()) { + throw new BizException(ErrorCode.FILE_UPLOAD_EMPTY); + } + try { + // 1. 上传到 OSS + FileInfo ossFileInfo = fileStorageService.of(file).upload(); + log.info("OSS upload success: url={}, size={}, isInternal={}", + ossFileInfo.getUrl(), ossFileInfo.getSize(), isInternal); + + // 2. 同步元信息到本地 filestorage 表 + FileStorage fileStorage = FileStorageHelper.buildResource(ossFileInfo, file.getOriginalFilename(), isInternal); + FileStorage saved = fileStorageDomainService.saveResource(fileStorage); + + return SingleResponse.success(FileStorageHelper.toCO(saved, null)); + } catch (BizException e) { + throw e; + } catch (Exception e) { + log.error("File upload failed: {}, isInternal={}", file.getOriginalFilename(), isInternal, e); + throw new BizException(ErrorCode.FILE_UPLOAD_FAILED, e); + } + } + + /** + * 批量上传 + */ + private SingleResponse> doUploadBatch(List files, boolean isInternal) { + if (files == null || files.isEmpty()) { + throw new BizException(ErrorCode.FILE_UPLOAD_EMPTY); + } + List results = new ArrayList<>(); + for (MultipartFile file : files) { + if (file == null || file.isEmpty()) { + continue; + } + SingleResponse result = doUpload(file, isInternal); + if (result.isSuccess() && result.getData() != null) { + results.add(result.getData()); + } + } + return SingleResponse.success(results); + } +} diff --git a/safety-eval-client/src/main/java/org/qinan/safetyeval/client/api/FileStorageApi.java b/safety-eval-client/src/main/java/org/qinan/safetyeval/client/api/FileStorageApi.java new file mode 100644 index 0000000..b7186ff --- /dev/null +++ b/safety-eval-client/src/main/java/org/qinan/safetyeval/client/api/FileStorageApi.java @@ -0,0 +1,65 @@ +package org.qinan.safetyeval.client.api; + +import org.qinan.safetyeval.client.co.FileInfoCO; +import org.qinan.safetyeval.client.dto.SingleResponse; + +import java.util.List; + +/** + * 文件存储管理接口定义(Client层) + *

+ * 提供 filestorage 表的查询、删除、获取签名URL等管理功能。 + * 文件的新增由上传接口({@link FileUploadApi})间接完成,本接口不提供直接新增能力。 + *

+ * + * @author safety-eval + */ +public interface FileStorageApi { + + /** + * 获取文件签名URL(临时访问链接) + * + * @param fileId 文件ID + * @return 文件信息(含签名URL) + */ + SingleResponse getPresignedUrl(Integer fileId); + + /** + * 获取文件信息 + * + * @param fileId 文件ID + * @return 文件信息 + */ + SingleResponse getInfo(Integer fileId); + + /** + * 删除文件(OSS + DB) + * + * @param fileId 文件ID + * @return 操作结果 + */ + SingleResponse delete(Integer fileId); + + /** + * 批量删除文件(OSS + DB) + * + * @param fileIds 文件ID列表 + * @return 操作结果 + */ + SingleResponse deleteBatch(List fileIds); + + /** + * 获取所有文件列表 + * + * @return 文件信息列表 + */ + SingleResponse> listAll(); + + /** + * 根据上传者获取文件列表 + * + * @param creatorUid 上传者UID + * @return 文件信息列表 + */ + SingleResponse> listByCreator(String creatorUid); +} diff --git a/safety-eval-client/src/main/java/org/qinan/safetyeval/client/api/FileUploadApi.java b/safety-eval-client/src/main/java/org/qinan/safetyeval/client/api/FileUploadApi.java new file mode 100644 index 0000000..54777ea --- /dev/null +++ b/safety-eval-client/src/main/java/org/qinan/safetyeval/client/api/FileUploadApi.java @@ -0,0 +1,50 @@ +package org.qinan.safetyeval.client.api; + +import org.qinan.safetyeval.client.co.FileInfoCO; +import org.qinan.safetyeval.client.dto.SingleResponse; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +/** + * 文件上传接口定义(Client层) + *

+ * 提供公开/内部两种文件上传方式。上传完成后自动同步元信息到 filestorage 表。 + *

+ * + * @author safety-eval + */ +public interface FileUploadApi { + + /** + * 公开上传单个文件(创建人默认为 system) + * + * @param file 上传的文件 + * @return 文件信息 + */ + SingleResponse uploadPublic(MultipartFile file); + + /** + * 公开批量上传文件(创建人默认为 system) + * + * @param files 上传的文件列表 + * @return 文件信息列表 + */ + SingleResponse> uploadPublicBatch(List files); + + /** + * 内部上传单个文件(创建人为当前登录用户) + * + * @param file 上传的文件 + * @return 文件信息 + */ + SingleResponse uploadInternal(MultipartFile file); + + /** + * 内部批量上传文件(创建人为当前登录用户) + * + * @param files 上传的文件列表 + * @return 文件信息列表 + */ + SingleResponse> uploadInternalBatch(List files); +} diff --git a/safety-eval-client/src/main/java/org/qinan/safetyeval/client/co/FileInfoCO.java b/safety-eval-client/src/main/java/org/qinan/safetyeval/client/co/FileInfoCO.java new file mode 100644 index 0000000..d2704bb --- /dev/null +++ b/safety-eval-client/src/main/java/org/qinan/safetyeval/client/co/FileInfoCO.java @@ -0,0 +1,41 @@ +package org.qinan.safetyeval.client.co; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 文件信息 CO(Client Object - 返回给前端) + * + * @author safety-eval + */ +@Data +public class FileInfoCO { + + /** 文件ID */ + private Integer id; + + /** OSS文件访问URL */ + private String url; + + /** 文件签名URL(带过期时间的临时访问链接) */ + private String presignedUrl; + + /** 文件大小(字节) */ + private Long size; + + /** 文件原始名称 */ + private String originalFilename; + + /** 文件扩展名 */ + private String ext; + + /** 文件MIME类型 */ + private String contentType; + + /** 上传者姓名 */ + private String creatorName; + + /** 上传时间 */ + private LocalDateTime createdTime; +} diff --git a/safety-eval-domain/src/main/java/org/qinan/safetyeval/domain/entity/FileStorage.java b/safety-eval-domain/src/main/java/org/qinan/safetyeval/domain/entity/FileStorage.java new file mode 100644 index 0000000..5a4f07e --- /dev/null +++ b/safety-eval-domain/src/main/java/org/qinan/safetyeval/domain/entity/FileStorage.java @@ -0,0 +1,56 @@ +package org.qinan.safetyeval.domain.entity; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 文件资源领域实体 + * + * @author safety-eval + */ +@Data +public class FileStorage { + + /** 主键ID */ + private Integer id; + + /** OSS文件完整访问URL */ + private String url; + + /** 文件大小(字节) */ + private Long size; + + /** OSS存储文件名 */ + private String filename; + + /** 文件原始名称 */ + private String originalFilename; + + /** OSS存储根路径 */ + private String basePath; + + /** OSS存储相对路径 */ + private String path; + + /** 文件扩展名 */ + private String ext; + + /** 文件MIME类型 */ + private String contentType; + + /** OSS平台标识 */ + private String platform; + + /** Content-MD5值 */ + private String md5; + + /** 上传者姓名 */ + private String creatorName; + + /** 上传者UID */ + private String creatorUid; + + /** 上传时间 */ + private LocalDateTime createdTime; +} diff --git a/safety-eval-domain/src/main/java/org/qinan/safetyeval/domain/exception/ErrorCode.java b/safety-eval-domain/src/main/java/org/qinan/safetyeval/domain/exception/ErrorCode.java index 295a5d3..c8a2705 100644 --- a/safety-eval-domain/src/main/java/org/qinan/safetyeval/domain/exception/ErrorCode.java +++ b/safety-eval-domain/src/main/java/org/qinan/safetyeval/domain/exception/ErrorCode.java @@ -62,6 +62,16 @@ public enum ErrorCode { // ---- 备案申请人员证书 ---- QUAL_FILING_PERSONNEL_CERT_NOT_FOUND("02-08-001", "备案申请人员证书不存在"), + // ---- 文件存储 ---- + FILE_UPLOAD_FAILED("03-01-001", "文件上传失败"), + FILE_UPLOAD_EMPTY("03-01-002", "上传文件为空"), + FILE_NOT_FOUND("03-01-003", "文件不存在"), + FILE_DELETE_FAILED("03-01-004", "文件删除失败"), + FILE_DOWNLOAD_FAILED("03-01-005", "文件下载失败"), + FILE_OSS_OPERATION_FAILED("03-01-006", "OSS操作异常"), + FILE_URL_GENERATE_FAILED("03-01-007", "文件地址生成失败"), + FILE_SIZE_EXCEEDED("03-01-008", "文件大小超出限制"), + /** 未知错误 */ UNKNOWN_ERROR("99-99-999", "系统异常,请稍后重试"); diff --git a/safety-eval-domain/src/main/java/org/qinan/safetyeval/domain/gateway/ResourceGateway.java b/safety-eval-domain/src/main/java/org/qinan/safetyeval/domain/gateway/ResourceGateway.java new file mode 100644 index 0000000..78a78ee --- /dev/null +++ b/safety-eval-domain/src/main/java/org/qinan/safetyeval/domain/gateway/ResourceGateway.java @@ -0,0 +1,63 @@ +package org.qinan.safetyeval.domain.gateway; + +import org.qinan.safetyeval.domain.entity.FileStorage; + +import java.util.List; + +/** + * 文件资源Gateway接口(Domain层定义) + * + * @author safety-eval + */ +public interface ResourceGateway { + + /** + * 保存文件资源元信息 + */ + FileStorage save(FileStorage fileStorage); + + /** + * 批量保存文件资源元信息 + */ + List saveBatch(List fileStorages); + + /** + * 根据ID查询 + */ + FileStorage get(Integer id); + + /** + * 根据文件名查询 + */ + FileStorage getByFilename(String filename); + + /** + * 根据URL查询 + */ + FileStorage getByUrl(String url); + + /** + * 根据ID删除 + */ + void delete(Integer id); + + /** + * 批量根据ID删除 + */ + void deleteBatch(List ids); + + /** + * 更新资源信息 + */ + FileStorage update(FileStorage fileStorage); + + /** + * 查询所有资源列表 + */ + List listAll(); + + /** + * 根据创建者查询 + */ + List listByCreator(String creatorUid); +} diff --git a/safety-eval-domain/src/main/java/org/qinan/safetyeval/domain/service/FileStorageDomainService.java b/safety-eval-domain/src/main/java/org/qinan/safetyeval/domain/service/FileStorageDomainService.java new file mode 100644 index 0000000..583f4bf --- /dev/null +++ b/safety-eval-domain/src/main/java/org/qinan/safetyeval/domain/service/FileStorageDomainService.java @@ -0,0 +1,102 @@ +package org.qinan.safetyeval.domain.service; + +import org.qinan.safetyeval.domain.entity.FileStorage; +import org.qinan.safetyeval.domain.exception.BizException; +import org.qinan.safetyeval.domain.exception.ErrorCode; +import org.qinan.safetyeval.domain.gateway.ResourceGateway; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * 文件存储领域服务 + *

+ * 协调 OSS 存储操作与本地数据库维护管理, + * 确保文件存储/删除时同步更新 filestorage 表。 + *

+ * + * @author safety-eval + */ +@Service +public class FileStorageDomainService { + + @javax.annotation.Resource + private ResourceGateway resourceGateway; + + /** + * 保存文件元信息到数据库 + */ + public FileStorage saveResource(FileStorage fileStorage) { + if (fileStorage == null) { + throw new BizException(ErrorCode.FILE_UPLOAD_EMPTY); + } + return resourceGateway.save(fileStorage); + } + + /** + * 批量保存文件元信息 + */ + public List saveResourceBatch(List fileStorages) { + if (fileStorages == null || fileStorages.isEmpty()) { + throw new BizException(ErrorCode.FILE_UPLOAD_EMPTY); + } + return resourceGateway.saveBatch(fileStorages); + } + + /** + * 根据ID获取文件元信息 + */ + public FileStorage getResource(Integer id) { + FileStorage fileStorage = resourceGateway.get(id); + if (fileStorage == null) { + throw new BizException(ErrorCode.FILE_NOT_FOUND); + } + return fileStorage; + } + + /** + * 根据文件名获取文件元信息 + */ + public FileStorage getResourceByFilename(String filename) { + FileStorage fileStorage = resourceGateway.getByFilename(filename); + if (fileStorage == null) { + throw new BizException(ErrorCode.FILE_NOT_FOUND); + } + return fileStorage; + } + + /** + * 删除文件元信息 + */ + public void deleteResource(Integer id) { + FileStorage fileStorage = resourceGateway.get(id); + if (fileStorage == null) { + throw new BizException(ErrorCode.FILE_NOT_FOUND); + } + resourceGateway.delete(id); + } + + /** + * 批量删除文件元信息 + */ + public void deleteResourceBatch(List ids) { + if (ids == null || ids.isEmpty()) { + return; + } + resourceGateway.deleteBatch(ids); + } + + /** + * 获取所有文件列表 + */ + public List listAllResources() { + return resourceGateway.listAll(); + } + + /** + * 根据创建者获取文件列表 + */ + public List listResourcesByCreator(String creatorUid) { + return resourceGateway.listByCreator(creatorUid); + } +} diff --git a/safety-eval-infrastructure/pom.xml b/safety-eval-infrastructure/pom.xml index fcc829d..ca241d2 100644 --- a/safety-eval-infrastructure/pom.xml +++ b/safety-eval-infrastructure/pom.xml @@ -79,5 +79,17 @@ zcloud_gbscommon 1.0.0-SNAPSHOT + + + + org.dromara.x-file-storage + x-file-storage-spring + + + + + com.aliyun.oss + aliyun-sdk-oss + \ No newline at end of file diff --git a/safety-eval-infrastructure/src/main/java/org/qinan/safetyeval/infrastructure/config/FileStorageAutoConfiguration.java b/safety-eval-infrastructure/src/main/java/org/qinan/safetyeval/infrastructure/config/FileStorageAutoConfiguration.java new file mode 100644 index 0000000..be32df7 --- /dev/null +++ b/safety-eval-infrastructure/src/main/java/org/qinan/safetyeval/infrastructure/config/FileStorageAutoConfiguration.java @@ -0,0 +1,18 @@ +package org.qinan.safetyeval.infrastructure.config; + +import org.dromara.x.file.storage.spring.EnableFileStorage; +import org.springframework.context.annotation.Configuration; + +/** + * 文件存储自动配置 + *

+ * 启用 x-file-storage 的 Spring 集成, + * OSS 具体参数通过 application.yml 注入。 + *

+ * + * @author safety-eval + */ +@Configuration +@EnableFileStorage +public class FileStorageAutoConfiguration { +} diff --git a/safety-eval-infrastructure/src/main/java/org/qinan/safetyeval/infrastructure/dataobject/ResourceDO.java b/safety-eval-infrastructure/src/main/java/org/qinan/safetyeval/infrastructure/dataobject/ResourceDO.java new file mode 100644 index 0000000..54eadd5 --- /dev/null +++ b/safety-eval-infrastructure/src/main/java/org/qinan/safetyeval/infrastructure/dataobject/ResourceDO.java @@ -0,0 +1,61 @@ +package org.qinan.safetyeval.infrastructure.dataobject; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 文件资源数据对象(Infrastructure层 - 对应 filestorage 表) + * + * @author safety-eval + */ +@Data +@TableName("filestorage") +public class ResourceDO { + + /** 主键ID */ + @TableId(type = IdType.AUTO) + private Integer id; + + /** OSS文件完整访问URL */ + private String url; + + /** 文件大小(字节) */ + private Long size; + + /** OSS存储文件名 */ + private String filename; + + /** 文件原始名称 */ + private String originalFilename; + + /** OSS存储根路径 */ + private String basePath; + + /** OSS存储相对路径 */ + private String path; + + /** 文件扩展名 */ + private String ext; + + /** 文件MIME类型 */ + private String contentType; + + /** OSS平台标识 */ + private String platform; + + /** Content-MD5值 */ + private String md5; + + /** 上传者姓名 */ + private String creatorName; + + /** 上传者UID */ + private String creatorUid; + + /** 上传时间 */ + private LocalDateTime createdTime; +} diff --git a/safety-eval-infrastructure/src/main/java/org/qinan/safetyeval/infrastructure/gatewayimpl/ResourceGatewayImpl.java b/safety-eval-infrastructure/src/main/java/org/qinan/safetyeval/infrastructure/gatewayimpl/ResourceGatewayImpl.java new file mode 100644 index 0000000..7a47dac --- /dev/null +++ b/safety-eval-infrastructure/src/main/java/org/qinan/safetyeval/infrastructure/gatewayimpl/ResourceGatewayImpl.java @@ -0,0 +1,145 @@ +package org.qinan.safetyeval.infrastructure.gatewayimpl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import org.qinan.safetyeval.domain.entity.FileStorage; +import org.qinan.safetyeval.domain.gateway.ResourceGateway; +import org.qinan.safetyeval.infrastructure.dataobject.ResourceDO; +import org.qinan.safetyeval.infrastructure.mapper.ResourceMapper; +import org.springframework.stereotype.Component; + +import javax.annotation.Resource; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 文件资源Gateway实现(Infrastructure层) + * + * @author safety-eval + */ +@Component +public class ResourceGatewayImpl implements ResourceGateway { + + @Resource + private ResourceMapper resourceMapper; + + @Override + public FileStorage save(FileStorage fileStorage) { + ResourceDO dataObject = toDO(fileStorage); + resourceMapper.insert(dataObject); + fileStorage.setId(dataObject.getId()); + return fileStorage; + } + + @Override + public List saveBatch(List fileStorages) { + List dataObjects = fileStorages.stream() + .map(this::toDO) + .collect(Collectors.toList()); + for (ResourceDO dataObject : dataObjects) { + resourceMapper.insert(dataObject); + } + for (int i = 0; i < fileStorages.size(); i++) { + fileStorages.get(i).setId(dataObjects.get(i).getId()); + } + return fileStorages; + } + + @Override + public FileStorage get(Integer id) { + ResourceDO dataObject = resourceMapper.selectById(id); + return toEntity(dataObject); + } + + @Override + public FileStorage getByFilename(String filename) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(ResourceDO::getFilename, filename); + ResourceDO dataObject = resourceMapper.selectOne(wrapper); + return toEntity(dataObject); + } + + @Override + public FileStorage getByUrl(String url) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(ResourceDO::getUrl, url); + ResourceDO dataObject = resourceMapper.selectOne(wrapper); + return toEntity(dataObject); + } + + @Override + public void delete(Integer id) { + resourceMapper.deleteById(id); + } + + @Override + public void deleteBatch(List ids) { + resourceMapper.deleteBatchIds(ids); + } + + @Override + public FileStorage update(FileStorage fileStorage) { + ResourceDO dataObject = toDO(fileStorage); + dataObject.setId(fileStorage.getId()); + resourceMapper.updateById(dataObject); + return get(fileStorage.getId()); + } + + @Override + public List listAll() { + List dataObjects = resourceMapper.selectList(null); + return dataObjects.stream() + .map(this::toEntity) + .collect(Collectors.toList()); + } + + @Override + public List listByCreator(String creatorUid) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(ResourceDO::getCreatorUid, creatorUid); + List dataObjects = resourceMapper.selectList(wrapper); + return dataObjects.stream() + .map(this::toEntity) + .collect(Collectors.toList()); + } + + // ---------- 实体转换 ---------- + + private ResourceDO toDO(FileStorage entity) { + if (entity == null) return null; + ResourceDO dataObject = new ResourceDO(); + dataObject.setUrl(entity.getUrl()); + dataObject.setSize(entity.getSize()); + dataObject.setFilename(entity.getFilename()); + dataObject.setOriginalFilename(entity.getOriginalFilename()); + dataObject.setBasePath(entity.getBasePath()); + dataObject.setPath(entity.getPath()); + dataObject.setExt(entity.getExt()); + dataObject.setContentType(entity.getContentType()); + dataObject.setPlatform(entity.getPlatform()); + dataObject.setMd5(entity.getMd5()); + dataObject.setCreatorName(entity.getCreatorName()); + dataObject.setCreatorUid(entity.getCreatorUid()); + dataObject.setCreatedTime(entity.getCreatedTime()); + return dataObject; + } + + private FileStorage toEntity(ResourceDO dataObject) { + if (dataObject == null) return null; + FileStorage entity = new FileStorage(); + entity.setId(dataObject.getId()); + entity.setUrl(dataObject.getUrl()); + entity.setSize(dataObject.getSize()); + entity.setFilename(dataObject.getFilename()); + entity.setOriginalFilename(dataObject.getOriginalFilename()); + entity.setBasePath(dataObject.getBasePath()); + entity.setPath(dataObject.getPath()); + entity.setExt(dataObject.getExt()); + entity.setContentType(dataObject.getContentType()); + entity.setPlatform(dataObject.getPlatform()); + entity.setMd5(dataObject.getMd5()); + entity.setCreatorName(dataObject.getCreatorName()); + entity.setCreatorUid(dataObject.getCreatorUid()); + entity.setCreatedTime(dataObject.getCreatedTime()); + return entity; + } +} diff --git a/safety-eval-infrastructure/src/main/java/org/qinan/safetyeval/infrastructure/mapper/ResourceMapper.java b/safety-eval-infrastructure/src/main/java/org/qinan/safetyeval/infrastructure/mapper/ResourceMapper.java new file mode 100644 index 0000000..5c8be21 --- /dev/null +++ b/safety-eval-infrastructure/src/main/java/org/qinan/safetyeval/infrastructure/mapper/ResourceMapper.java @@ -0,0 +1,14 @@ +package org.qinan.safetyeval.infrastructure.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import org.qinan.safetyeval.infrastructure.dataobject.ResourceDO; + +/** + * 文件资源 Mapper + * + * @author safety-eval + */ +@Mapper +public interface ResourceMapper extends BaseMapper { +} diff --git a/safety-eval-start/src/main/resources/application-local.yml b/safety-eval-start/src/main/resources/application-local.yml index d9b078e..add2438 100644 --- a/safety-eval-start/src/main/resources/application-local.yml +++ b/safety-eval-start/src/main/resources/application-local.yml @@ -114,6 +114,8 @@ safety-eval: - /org-personnel-change - /org-resign-apply - /org-equipment + - /file-storage + - /file # 本地联调默认用户上下文(与 init-v2.sql 测试数据 @org_id=1 @tenant_id=1001 一致) public-api: system-user-id: 1 diff --git a/safety-eval-start/src/main/resources/application.yml b/safety-eval-start/src/main/resources/application.yml index 060216d..7dcd404 100644 --- a/safety-eval-start/src/main/resources/application.yml +++ b/safety-eval-start/src/main/resources/application.yml @@ -24,6 +24,11 @@ spring: config: import: - classpath:sdk-prod2.yml + servlet: + multipart: + enabled: true + max-file-size: 100MB + max-request-size: 500MB # ---------- MyBatis Plus ---------- @@ -38,6 +43,21 @@ mybatis-plus: configuration: map-underscore-to-camel-case: true +# ---------- 文件存储(阿里云 OSS) ---------- +dromara: + x-file-storage: + default-platform: aliyun-oss-1 + thumbnail-suffix: "" # 不使用缩略图 + aliyun-oss: + - platform: aliyun-oss-1 + enable-storage: true + access-key: LTAI5tFerUgBCLEbXtinDD6d + secret-key: XyGVNV2RvW8U3ceoJDiy9A9oLvCGYp + end-point: oss-cn-hangzhou.aliyuncs.com + bucket-name: test-dragon-yf-pub + domain: https://test-dragon-yf-pub.oss-cn-hangzhou.aliyuncs.com/ + base-path: jjb/ + # ---------- Dubbo(环境无关的公共部分,各profile覆盖地址/端口) ---------- dubbo: application: @@ -71,7 +91,7 @@ safety-eval: - /swagger-ui - /swagger-resources - /webjars - - /public/org-personnel + - /public - /test - /tmp - /mock