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