文件存储接入与使用指南
sz-admin 用 “场景 scenes ” 管理文件。业务上传时提交 sceneCode,场景决定文件存到本地磁盘还是对象存储、如何命名,以及后续怎样访问。
第一次接入,可以按下面四步完成:
- 选访问方式:公开图片用 DIRECT,临时分享用 PRESIGNED,需要业务权限的附件用 PROTECTED。
- 配置存储:在接入案例中选择 LOCAL、RustFS、MinIO、腾讯云、阿里云或七牛云,合并到当前环境的
oss.yml。 - 上传一个文件:按上传与验证检查文件位置和返回地址。
- 接入业务页面:按业务附件接入保存资源引用、配置下载接口和前端组件。
已有项目只需调整部分配置时,可以直接查看多场景配置、寻址风格和 base-url。
选择存储和访问方式
存储类型
| 类型 | 适合场景 | 必要配置 |
|---|---|---|
LOCAL | 单机或共享磁盘、由 Nginx 提供静态文件、由业务接口读取的附件 | 场景 path;根目录 sz.resource.root 默认 ./data,DIRECT 还要配置 base-url |
OSS | MinIO、RustFS、阿里云 OSS、腾讯云 COS、七牛云等 S3 兼容存储 | 一组完整的 sz.oss 默认连接;场景可以复用或覆盖 |
项目只使用 LOCAL 时可以不配置 sz.oss。只要启用 OSS,就应保留一组可用的全局 OSS 连接;需要连接其他实例的场景再填写自己的连接字段。
访问模式
serve-mode | 适合场景 | 返回的 accessUrl | 谁能访问 |
|---|---|---|---|
DIRECT | 头像、Logo、公开富文本图片 | 稳定公开地址 | 拿到地址的人都可以访问 |
PRESIGNED | 私有 OSS 中无需业务权限判断的短时展示 | 有效期内的签名地址 | 拿到签名地址的人在有效期内可以访问 |
PROTECTED | 需要业务权限判断的合同、模板、附件 | null | 通过所属业务接口鉴权后访问 |
省略 serve-mode 时默认是 PROTECTED。公开文件必须显式配置 DIRECT。PRESIGNED 只限制链接有效期,不会检查用户是否有权查看某条业务记录;需要业务权限的附件使用 PROTECTED。
访问模式取决于实际场景配置。当前仓库的配置中,template.excel 和 teacher.attachment 在 local 环境仍是 DIRECT,在 dev、preview、prod 中是 PROTECTED;system.protected 在四个环境中都是 PROTECTED。不要仅凭场景名称判断文件是否受保护。
DIRECT 只让应用生成公开地址,不会自动开放 bucket 或创建静态路由。公开地址能否访问,还取决于存储策略和代理配置。
各类存储接入案例
选择实际使用的存储,先完成一个场景的上传和读取,再接入业务页面。同一个应用也可以同时使用 LOCAL 和多个 OSS 服务,配置方法见多场景配置。
配置前先准备什么
云厂商案例按官方 S3 接入要求与当前配置字段编写,仍需在自己的账号和 bucket 中完成下文验证。
- 找到当前环境的
config/{local,dev,preview,prod}/oss.yml。下面示例合并到这份文件中,保留已有业务场景;已有相同code时修改原条目,不要重复追加。 - 使用 OSS 时,先在存储服务中创建 bucket,准备有相应对象读写权限的访问密钥。框架不会自动创建 bucket。
- 示例中的
${OSS_ACCESS_KEY}等值是环境变量引用,需在应用的运行环境中设置。使用哪个厂商,就填写哪家的密钥。 - 修改 YAML 后重启应用。默认场景配置在启动时加载;启动成功后,还要检查实际上传和读取。
sz.oss 是 OSS 公共配置,sz.resource.scenes 是业务场景配置。一个场景使用公共连接时,只写自己的用途和访问方式即可,不必重复填写 endpoint、bucket、region 和寻址风格。
本文使用 access-key、secret-key、bucket-name 写法;现有配置中的 accessKey、secretKey、bucketName 同样有效。
LOCAL:文件存到本地磁盘
适合单机部署,或已经准备好共享磁盘的项目。先为应用准备可读写的目录;容器部署时,将该目录挂载为持久化存储。只使用 LOCAL 时不需要配置 sz.oss。
下面把公开图片放进 article,合同附件放进 contracts:
sz:
resource:
root: ./data # 文件根目录;相对于应用工作目录,也可写 /data 或 E:/sz-data
scenes:
- code: article.image # 上传时传入的 sceneCode
name: 文章图片
type: LOCAL # 使用本地磁盘
serve-mode: DIRECT # 公开访问
path: article # 文件保存在 root/article 下
base-url: /api/admin/resource/file/article # 开发时使用后端公开文件路由
naming: UUID # 用随机文件名避免重名
path-strategy: DATE # 按 yyyyMMdd 分目录
exts: [jpg, jpeg, png, gif, webp] # 这个场景允许的扩展名
max-size: 5 # 单文件上限,单位 MB
- code: contract.attachment
name: 合同附件
type: LOCAL
serve-mode: PROTECTED # 保存业务记录后,通过业务接口下载
path: contracts # 与公开图片使用不同目录
naming: UUID
path-strategy: BIZ_DATE # 业务路径段 + 日期目录
exts: [pdf, doc, docx]
max-size: 10启动后,用 article.image 上传一张图片。假设返回 objectKey=article/20261010/abc.png,应能在 ./data/article/20261010/abc.png 找到文件,返回地址为 /api/admin/resource/file/article/20261010/abc.png。这里的文件名仅用于说明路径,实际由 UUID 生成。
该相对地址由浏览器通过当前站点访问,前后端分开部署时,要让站点的 /api 代理指向后端。Java 路由中的 article 匹配场景 base-url 的最后一段;不同公开场景不要使用相同的末段,否则无法唯一匹配,会返回 404。
生产环境用 Nginx 提供静态文件时,可以把 base-url 改成 /resource/article,将这个 URL 前缀映射到同一个图片目录。例如,应用将 root 配为 /data,Nginx 也能读取 /data/article/ 时,在站点的 server 中加入:
location /resource/article/ {
alias /data/article/;
autoindex off;
}此时 base-url 写 /resource/article;文件地址就是 /resource/article/20261010/abc.png。Nginx 和应用运行在不同容器时,两边必须挂载同一份文件。只开放 article 目录,不能把整个 /data 或 contracts 目录一起公开。
contract.attachment 上传成功时 accessUrl 为 null,接下来按业务附件接入保存引用并提供受保护下载。
LOCAL 不支持 PRESIGNED,也不能填写 bucket、endpoint、region、寻址风格等 OSS 字段。多个场景使用同一个 path 时,不会因 code 不同自动隔离;多副本部署应使用共享存储或改用 OSS。框架会检查路径穿越、跨场景目录和指向 root 外部的符号链接。
RustFS:通过 S3 接口接入
先部署好 RustFS,创建私有 bucket documents,并取得 S3 API 地址和访问密钥。endpoint 填 S3 API 地址,不填管理控制台页面地址。
sz:
oss:
provider: RUSTFS # 使用 RustFS
endpoint: https://storage.example.com # RustFS 的 S3 API 地址,不带 bucket
access-key: ${RUSTFS_ACCESS_KEY} # 访问密钥 ID
secret-key: ${RUSTFS_SECRET_KEY} # 访问密钥 Secret
bucket-name: documents # 默认 bucket,需提前创建
region: us-east-1 # 按 RustFS 服务端设置填写
# PATH:对象地址如 https://storage.example.com/documents/20261010/report.pdf
addressing-style: PATH # 可选 PATH、VIRTUAL_HOSTED;常规部署使用 PATH
resource:
scenes:
- code: rustfs.document
name: RustFS 文档
type: OSS
serve-mode: PRESIGNED # 私有桶,返回临时签名地址
expire: 900 # 地址有效期,单位秒
path-strategy: DATE # 按日期分目录,文件名默认使用 UUID重启后,用 rustfs.document 上传一份 PDF。在 RustFS 的 documents bucket 中检查对象,再打开返回的 accessUrl。签名地址需要能从浏览器访问,因此不能填写仅后端可达的容器服务名。
如果已有虚拟主机部署,可以改用 VIRTUAL_HOSTED,并同时配置 RustFS 服务端、DNS 和证书,见寻址风格。仅修改应用中的这一行,不会完成服务端配置。
MinIO:使用现有 S3 服务
先创建私有 bucket documents,准备 S3 API 地址和访问密钥。下面使用 PATH 风格,适合通过固定域名或 IP 访问的部署:
sz:
oss:
provider: MINIO
endpoint: https://minio.example.com # S3 API 地址,不是控制台地址
access-key: ${MINIO_ACCESS_KEY}
secret-key: ${MINIO_SECRET_KEY}
bucket-name: documents # 已创建的 bucket
region: us-east-1 # 与服务端配置一致
addressing-style: PATH # bucket 位于路径中,如 https://minio.example.com/documents/report.pdf
resource:
scenes:
- code: minio.document
name: MinIO 文档
type: OSS
serve-mode: PRESIGNED # 临时签名访问
expire: 900 # 单位秒
path-strategy: DATE重启后,用 minio.document 上传 PDF,检查 documents 中的对象并打开签名地址。MinIO 与 RustFS 都通过项目现有的 S3 客户端接入;更换存储时,要分别更新连接配置并处理历史文件,不能只更改 provider。
腾讯云 COS:注意地域和完整桶名
在 COS 控制台创建私有桶,记录地域和完整桶名;在访问管理中准备有目标桶访问权限的 SecretId、SecretKey。下面以广州地域为例,按腾讯云 S3 兼容配置填写:
sz:
oss:
provider: TENCENT
endpoint: https://cos.ap-guangzhou.myqcloud.com # 广州地域服务地址,不带 bucket
access-key: ${TENCENT_SECRET_ID} # 腾讯云 SecretId
secret-key: ${TENCENT_SECRET_KEY} # 腾讯云 SecretKey
bucket-name: documents-1250000000 # 替换为真实完整桶名,包含 APPID
region: ap-guangzhou # 与 bucket 所在地域一致
# 对象地址如 https://documents-1250000000.cos.ap-guangzhou.myqcloud.com/report.pdf
addressing-style: VIRTUAL_HOSTED # bucket 位于域名前
resource:
scenes:
- code: tencent.document
name: 腾讯云文档
type: OSS
serve-mode: PRESIGNED # 私有桶临时签名访问
expire: 900 # 有效期 15 分钟
path-strategy: DATE重启后,用 tencent.document 上传 PDF,检查控制台对象和返回的签名地址。改用其他地域时,endpoint 与 region 一起修改;只改 region 不会切换服务地址。新建 COS 桶使用虚拟主机风格,不能直接沿用自建存储的 PATH 配置。
阿里云 OSS:使用 S3 兼容 endpoint
在 OSS 控制台创建私有 bucket,记录桶名和地域,并准备具有目标桶权限的 RAM AccessKey。项目使用 AWS SDK,因此本例采用阿里云官方的 S3 兼容 endpoint。
sz:
oss:
provider: ALIYUN
endpoint: https://s3.oss-cn-hangzhou.aliyuncs.com # 杭州地域的 S3 兼容地址
access-key: ${ALIYUN_ACCESS_KEY_ID} # RAM AccessKey ID
secret-key: ${ALIYUN_ACCESS_KEY_SECRET} # RAM AccessKey Secret
bucket-name: ${ALIYUN_BUCKET} # 已创建的 OSS bucket 名称
region: aws-global # 本例按阿里官方 AWS Java SDK 2.x 的 Region.AWS_GLOBAL 填写
# 对象地址如 https://my-documents.s3.oss-cn-hangzhou.aliyuncs.com/report.pdf
addressing-style: VIRTUAL_HOSTED # OSS 使用虚拟主机风格
resource:
scenes:
- code: aliyun.document
name: 阿里云文档
type: OSS
serve-mode: PRESIGNED # 生成 S3 临时签名地址,接入时需实际验证
expire: 900 # 有效期 15 分钟
path-strategy: DATE这里的 aws-global 对应阿里云 AWS Java SDK 2.x 示例;桶所在地域仍体现在 endpoint 的 cn-hangzhou 中。换地域时修改 endpoint,不能把其他语言 SDK 的 region 写法直接套用到这份示例。寻址使用 VIRTUAL_HOSTED,详见 OSS 与 S3 的兼容差异。
重启后,先用 aliyun.document 验证小文件上传和签名下载,再测试实际业务需要的文件大小。官方示例使用同步客户端,当前项目上传使用 CRT 异步客户端;此配置还需要在真实 bucket 中验证上传、分片上传和预签名。若出现 aws-chunked 相关错误,应检查 SDK 传输兼容性,不能仅靠更换 region 或密钥解决。
七牛云:使用 S3 空间名和 S3 服务域名
在七牛控制台创建私有空间,在“空间概览”取得 S3 空间名,再准备 AccessKey、SecretKey。S3 空间名可能与普通空间名称不同,请以控制台显示为准。下面以华东浙江为例,地域与域名参见七牛 S3 服务域名。
sz:
oss:
provider: QINIU
endpoint: https://s3.cn-east-1.qiniucs.com # S3 API 地址,不是上传域名或 CDN 域名
access-key: ${QINIU_ACCESS_KEY}
secret-key: ${QINIU_SECRET_KEY}
bucket-name: ${QINIU_S3_BUCKET} # 空间概览中的 S3 空间名
region: cn-east-1 # 对应空间地域,与上面的 endpoint 配套
# 对象地址如 https://my-documents.s3.cn-east-1.qiniucs.com/report.pdf
addressing-style: VIRTUAL_HOSTED # 七牛也支持 PATH,本例采用虚拟主机风格
resource:
scenes:
- code: qiniu.document
name: 七牛云文档
type: OSS
serve-mode: PRESIGNED # S3 服务域名需要签名访问
expire: 900 # 有效期 15 分钟
path-strategy: DATE重启后,用 qiniu.document 上传 PDF,检查空间中的对象并打开签名地址。七牛 S3 服务域名不支持匿名读取,因此不要直接把上例改为 DIRECT 并依赖 endpoint 自动生成公开地址;公开图片应配置已绑定的自定义域名,见下一节。
七牛对 2026 年 4 月 8 日 10:00(UTC+8)之后新建空间调整了浏览器访问策略:通过 S3 外网域名访问时,会作为附件下载。图片需要在浏览器中直接展示时,使用自定义源站或 CDN 域名,并按其访问规则配置,详见七牛空间访问策略说明。
公开图片使用 CDN 或自定义域名
上面的 OSS 文档场景使用私有 bucket 和临时签名。头像、Logo、富文本图片需要长期公开地址时,可以另外创建公开图片场景:
sz:
resource:
scenes:
- code: site.image
name: 站点公开图片
type: OSS
serve-mode: DIRECT # 返回稳定的公开地址
bucket: media-public # 改为实际图片 bucket;腾讯云需填写含 APPID 的完整名称
base-url: https://cdn.example.com # 已绑定该 bucket 的 CDN 或自定义域名
path-strategy: DATE
exts: [jpg, jpeg, png, gif, webp]这段场景配置沿用所选厂商的 sz.oss。先在厂商控制台绑定域名、配置 DNS 和 HTTPS,再填写 base-url。假设桶内 key 为 20261010/logo.png,最终地址就是 https://cdn.example.com/20261010/logo.png,程序不会再次追加 bucket。
CDN 需要能够读取对应对象,可以使用公开读源站,也可以配置厂商支持的私有源站回源授权。DIRECT 生成的是不带签名的公开地址;如果 CDN 还要求每次访问附带 URL 鉴权参数,需要另行接入该厂商的 CDN 鉴权,当前 base-url 不会自动生成这些参数。
base-url 也可以写成 /media 这样的同源路径,具体拼接与代理规则见 base-url 说明。PRESIGNED 使用 S3 endpoint 生成签名,不能通过填写 CDN base-url 来替换签名域名。
上传并验证配置
1. 确认场景生效
重启后检查应用是否正常启动,再登录后台,在文件管理页面或接口调试工具中调用 GET /api/admin/resource/scenes,确认能找到新增的 scene code 和名称。此接口只返回场景名称,不会返回存储密钥。
2. 上传一个小文件
在接口调试工具中沿用当前应用的登录凭证,以 multipart/form-data 提交。先使用一张图片或一份小于 1MB 的 PDF,避免先遇到文件大小限制。sceneCode 填所选案例中的值,例如 tencent.document;下面以 LOCAL 合同场景为例:
POST /api/admin/resource/upload
Content-Type: multipart/form-data
sceneCode=contract.attachment
pathSegments=1001,contract
file=<binary>表单字段:
| 字段 | 是否必填 | 说明 |
|---|---|---|
sceneCode | 是 | 选择资源场景。 |
file | 是 | 上传文件。 |
bizKey | 仅 naming: BIZ_KEY 时必填 | 用于生成文件名;Java 服务内部形参名是 namingKey,HTTP 字段仍是 bizKey。 |
pathSegments | 否 | 逗号分隔的路径段,仅 BIZ / BIZ_DATE 使用。 |
成功响应中的 data:
{
"resourceId": "10086",
"sceneCode": "contract.attachment",
"objectKey": "contracts/1001/contract/20261010/550e8400e29b41d4a716446655440000.pdf",
"originName": "report.pdf",
"contentType": "application/pdf",
"size": 98304,
"eTag": null,
"accessUrl": null
}ResourceUploadResult 包含 resourceId、sceneCode、objectKey、originName、contentType、size、eTag 和 accessUrl。PROTECTED 返回 accessUrl: null 是正常结果。
富文本批量上传使用 POST /api/admin/resource/batchUpload,提交 sceneCode、可选 pathSegments 和 multipart 文件列表,返回同一结构的数组。
3. 检查文件和访问地址
| 使用的模式 | 上传后检查什么 | 预期结果 |
|---|---|---|
| LOCAL | 查看 root 下对应 scene.path 目录 | 能找到返回 objectKey 对应的文件 |
| OSS | 在存储控制台查看当前场景的 bucket | 能找到对象;桶内 key 是 objectKey 去掉 bucket 前缀后的部分 |
| DIRECT | 在浏览器打开 accessUrl | 能读取公开文件;同源路径要通过前端站点访问 |
| PRESIGNED | 打开 accessUrl,并在过期后重新生成地址 | 有效期内可读;不能把这条临时 URL 当成长期业务数据 |
| PROTECTED | 保存业务记录后,通过所属业务的下载接口读取 | 上传返回 accessUrl 为 null;有权限且关联该资源时才能下载 |
最后重新进入业务页面确认历史文件仍可读取。PROTECTED 场景还应使用无业务权限或没有该资源关联的记录验证访问被拒绝。上传成功只说明写入链路正常,不能代替读取和业务权限检查。
业务附件接入
以合同附件为例:前端上传得到资源引用,保存合同时由后端核对引用,查询时按访问方式补充地址,下载时检查合同权限。存储更换为 LOCAL 或任意 S3 服务后,这套业务接入方式相同。
1. 使用 ResourceRef 保存附件
多文件业务字段使用前端 ResourceRef[]:
export type ResourceRef = {
objectKey: string;
originName: string;
contentType: string;
sceneCode?: string;
accessUrl?: string | null;
resourceId?: string;
eTag?: string;
size?: number;
};字段用途:
| 字段 | 用途 |
|---|---|
resourceId | sys_resource 主键。前端始终按字符串处理,不要转为 number。 |
sceneCode、objectKey | 服务端定位文件;业务调用引用规范化方法后,按 resourceId 从资源记录重新取得。 |
originName、contentType | 展示文件名和生成下载响应;引用规范化时按资源记录重新取得。 |
accessUrl | DIRECT / PRESIGNED 的展示地址;不用于授权,也不应作为长期持久字段。 |
eTag、size | 前端扩展信息。后端标准 ResourceRef 不含这两个字段,规范化后不会保留在业务附件 JSON 中;资源登记表仍保存它们。 |
上面的类型是前端定义。后端标准 ResourceRef 包含 resourceId、sceneCode、objectKey、originName、contentType、accessUrl,其中 accessUrl 为只读返回字段,引用规范化时不会生成它。
DIRECT / PRESIGNED 附件在重新查询后需要展示 URL 时,应在查询代码中生成,例如调用 resourceService.fillAccessUrl(vo.getAttachments())。当前生成器的业务查询模板没有自动执行这一步;PROTECTED 始终不生成公开地址。
不要只保存 URL 或 string 数组。受管业务附件至少要保留有效的 resourceId,再由后端核对和重建其余字段。
2. 保存时校验资源引用
create / update 不能原样保存浏览器提交的 ResourceRef。业务 Service 使用 ResourceReferenceService:
entity.setAttachments(resourceReferenceService.normalizeForCreate(
dto.getAttachments(), "contract.attachment", StpUtil.getLoginIdAsLong()));
entity.setAttachments(resourceReferenceService.normalizeForUpdate(
existing.getAttachments(), dto.getAttachments(),
"contract.attachment", StpUtil.getLoginIdAsLong()));服务会:
- 检查
resourceId是有效正数,且对应的sys_resource存在、未删除。 - 检查资源的 scene 与业务字段要求的 scene 一致。
- 新增引用必须由当前用户上传。
- 更新时允许保留该记录原来已经关联的资源。
- 从
sys_resource重新生成 sceneCode、objectKey、文件名和 MIME,不信任前端同名字段。 - 不接收前端提交的
accessUrl,按resourceId去重并保持提交顺序。
例如原记录关联 A、B,更新后提交 A、C,最终只保存 A、C。B 只是解除业务关联,不会自动删除 sys_resource 或物理文件。
3. 提供业务下载和预览接口
PROTECTED 文件通过所属业务的固定接口访问。Controller 先检查接口权限,Service 再确认当前记录确实关联该 resourceId,最后才调用公共流式服务。
@SaCheckPermission("contract.record.query_table")
@PostMapping("/{id}/resources/{resourceId}/download")
public ResponseEntity<StreamingResponseBody> downloadResource(
@PathVariable Long id, @PathVariable Long resourceId) {
contractService.validateResourceAccess(id, resourceId);
return resourceDownloadService.download(resourceId);
}项目内已有示例:
| 业务 | 下载 / 预览路径 | 主要检查 |
|---|---|---|
| 教师统计演示(dev/local/preview) | POST /api/admin/teacher-statistics/{id}/resources/{resourceId}/download 和 /preview | 查询权限、业务数据范围、记录包含该资源 |
| 当前模板 | POST /api/admin/sys-temp-file/{id}/resources/{resourceId}/download 和 /preview | 查询权限、活动记录、资源 ID 与 JSON 引用同时匹配 |
| 模板历史 | POST /api/admin/sys-temp-file-history/{id}/resources/{resourceId}/download 和 /preview | 查询权限、活动父记录、历史资源 ID 与 JSON 引用同时匹配 |
ResourceDownloadService 查询活动资源,通过 sceneCode + objectKey 读取文件流。业务权限和记录关联需要在调用它之前检查,该服务自身不判断用户能否访问某条业务记录。下载响应使用 attachment,预览使用 inline,并返回安全文件名、X-Content-Type-Options: nosniff 和 Cache-Control: no-store。
内置预览只允许 PNG、JPEG、WebP、GIF 和 PDF。其他类型返回 R2006,前端应提示用户改用下载。
不要新增只接收 resourceId 的通用下载 Controller。仅知道资源 ID 不能证明当前用户有权访问所属业务记录。业务状态限制也应写在 validateResourceAccess 中,例如只有已发布合同允许下载。
通用模板下载入口
GET /api/admin/common/download/templates 按模板名称或别名查找文件,顺序为:
classpath:/templates/下的静态模板。sys_temp_file中登记的模板,通过资源服务读取。- 根据
@ExcelTemplate注册的 DTO 动态生成模板。
这个入口与上面的模板管理下载接口不同,当前没有调用 validateResourceAccess,也没有声明模板管理的业务权限检查。如果模板需要按权限隔离,必须同时处理这个入口,不能只依赖 serve-mode: PROTECTED。
4. 接入前端组件
FileDownloadList
列表和详情使用 FileDownloadList,同时传业务 ID 和固定业务 API:
<FileDownloadList
:files="row.attachments"
:biz-id="String(row.id)"
:download-api="downloadContractResourceApi"
:preview-api="previewContractResourceApi"
/>组件访问顺序:
- 有
bizId + 业务 API + resourceId时,调用业务接口并处理 Blob。 - 上述业务参数不齐全、但有
accessUrl时,直接访问公开或签名地址。业务接口已经发出请求后,如果返回错误,不会再降级为公开地址。 - 两者都没有时提示从已保存的业务记录进入,不回退到 URL 代理。
当前 FileDownloadList 只为识别为图片的文件显示预览按钮,PDF 需要自行接入预览界面,或直接下载。BMP、SVG 等图片可能显示按钮,但通过受保护预览接口访问时会被后端拒绝;后端允许在线预览的类型仍是 PNG、JPEG、WebP、GIF 和 PDF。
UploadFiles
编辑表单使用 UploadFiles。默认 sceneCode 是 system.protected,正式业务建议改成自己的场景。
<UploadFiles
v-model:modelValue="form.attachments"
scene-code="contract.attachment"
:biz-id="form.id ? String(form.id) : undefined"
:download-api="downloadContractResourceApi"
@uploading-change="uploading = $event"
/>提交时:
- 上传过程中禁止提交。
- 使用
normalizeResourceFiles()去掉uploading、localPreviewUrl和blob:地址,并按resourceId去重;没有 resourceId 的旧数据按sceneCode + objectKey去重。 - 新增记录还没有业务 ID 时,PROTECTED 文件不能调用业务下载接口;先保存业务记录,再从该记录进入下载。
- 当前
UploadFiles的文件列表只提供下载和删除,没有预览按钮,也没有previewApi参数。它为新上传图片生成的本地 Blob URL,不代表已经提供预览功能。 - 保存后传入业务 ID、下载 API 和资源 ID,PROTECTED 文件才会通过业务接口下载。组件会在移除文件、替换对应图片或卸载时释放保存的 Blob URL。
UploadFiles 下载时有可用 accessUrl 会直接访问;没有公开地址且传入 bizId + downloadApi 时才调用业务接口。PROTECTED 正常情况下没有 accessUrl,因此会走业务接口。
使用代码生成器时
生成器按字段的 Java 类型 List<ResourceRef> 识别附件字段,生成以下配套代码:
- create / update 中的资源规范化。
validateResourceAccess(id, resourceId)。- Controller 下载和预览接口。
- 前端下载、预览 API。
fileUpload、imageUpload 等控件配置决定前端使用的组件和绑定方式。不同组件支持的参数不同,下载与预览能力按上节说明接入。
面板会加载已注册场景供选择,未配置时默认 system.protected。未注册的场景目前只显示警告,不会阻止生成,生成前应确认场景已在目标环境配置并生效。正式模块建议使用业务专属场景,便于识别文件用途。
当前生成条件是字段类型 List<ResourceRef>,不会根据所选场景的 serve-mode 判断是否需要受保护接口。选择 PROTECTED 时可以直接沿用生成结果;如果选择 DIRECT 或 PRESIGNED,应在生成后检查下载/预览接口是否仍有必要,并删除不需要的入口。头像、Logo 这类单值 objectKey 字段不使用 List<ResourceRef>,也不会触发这一套生成逻辑。
生成的 validateResourceAccess 会检查记录存在、资源关联,并按生成器的数据权限选项应用数据范围。项目还有发布状态、流程节点等限制时,需要在生成后补充相应验证。
头像和 Logo 使用单值 objectKey
头像和 Logo 保存 objectKey,不使用 ResourceRef,也不走受保护下载接口:
| 返回对象 | 持久字段 | 展示字段 |
|---|---|---|
SysUserVO | logo | logoUrl |
UserProfileVO | avatar | avatarUrl |
登录态 BaseUserInfo | logo | 没有单独 URL 字段 |
后端通过用户头像场景把 objectKey 解析为 logoUrl/avatarUrl。前端新增、编辑用户时保存 logo,提交前删除 logoUrl;个人资料保存 avatar。派生 URL 不能回写数据库。
文件管理
文件管理页面用于查看资源登记和排查配置:
- 显示文件名、用途、大小、访问方式、存储方式、创建人和登记时间。
- DIRECT 可以打开和复制公开地址。
- PRESIGNED 可以打开当前生成的临时地址,过期后刷新列表重新获取。
- PROTECTED 只显示详情,并提示从所属业务记录访问,不提供下载入口。
system.protected只能说明它是通用业务附件,不能从资源记录推断具体业务归属。
场景 name 会显示为文件用途。业务专属场景的 code 和 name 越清楚,排查越容易。文件管理页面不会代替业务权限校验。
多场景配置
按场景连接不同存储
同一个应用可以让不同场景连接不同的 S3 兼容存储。例如公开图片使用默认 MinIO,归档文件使用另一套 RustFS。业务仍使用统一的 ResourceService 和存储驱动,不需要按厂商编写不同的接入代码。
每个场景最终只使用一套连接。场景需要独立连接时,同时填写 endpoint、access-key、secret-key;同一实例只是 bucket 不同时,只填写不同的 bucket。
公共配置写一次,场景只写差异
sz.oss 保存默认连接、bucket、region、寻址风格和公开域名。普通 OSS 场景直接复用;只有 bucket、签名区域、寻址风格、公开地址或连接不同的场景才单独配置。
对 RustFS 的支持
RustFS 通过现有 S3 客户端接入,配置步骤见 RustFS 案例。provider: RUSTFS 提供默认寻址设置,不会自动更换 endpoint、创建 bucket 或迁移历史文件。
配置继承规则
| 配置 | 生效规则 |
|---|---|
code | 场景唯一标识。业务上传和读取都依赖它,确定后不要随意更改。 |
name | 场景展示名称,用于文件管理页面等位置。 |
endpoint、access-key、secret-key | 场景三项都不写时使用全局连接。任一项出现时,三项必须全部填写,不能把场景 endpoint 与全局账号混用。 |
bucket | 场景值优先;未填写时使用 sz.oss.bucket-name。独立连接场景也可以继承全局 bucket。 |
region | 场景值优先;未填写时使用全局 region,全局缺省值为 us-east-1。单独修改 region 不会切换 endpoint。 |
provider | 场景值优先;用于厂商默认设置,单独修改不会切换 endpoint 或账号。 |
addressing-style | 场景值优先;未填写时使用全局设置或 provider 默认值。 |
domain | 只供使用默认连接的 DIRECT 场景自动生成公开地址。使用独立连接时不会继承。 |
base-url | 只对当前 DIRECT 场景生效;显式非空值优先于 domain 和 endpoint。LOCAL DIRECT 必须填写。 |
启用 OSS 时,全局 endpoint、access-key、secret-key 应作为一组配置和更换。即使业务场景都填写了独立连接,当前装配仍需要可用的全局 endpoint,因此不要给仅 OSS 项目删除整个 sz.oss 段。
旧 sz.oss.instances 和场景 oss-instance 已取消。独立连接直接写在场景顶层。
同一实例使用多个 bucket
保留已配置的 sz.oss,把需要的场景合并为下例。两个场景使用不同 bucket;已有同名场景时修改对应条目。桶名要替换为目标服务中的实际名称:
sz:
resource:
scenes:
- code: site.image
type: OSS
serve-mode: DIRECT
bucket: media-public
- code: report.private
type: OSS
serve-mode: PRESIGNED
bucket: reports-private
expire: 900公开与私有文件建议分 bucket 管理。serve-mode 不会修改存储桶策略;PRESIGNED 或 PROTECTED 文件如果放进允许匿名读取的 bucket,仍可以绕过应用直接访问。
场景使用独立连接
场景可以连接另一套服务,也可以在同一个 endpoint 上使用另一组账号:
sz:
oss:
provider: MINIO
endpoint: https://storage.example.com
access-key: ${OSS_ACCESS_KEY}
secret-key: ${OSS_SECRET_KEY}
bucket-name: media-public
region: us-east-1
addressing-style: PATH
resource:
scenes:
- code: partner.private
type: OSS
serve-mode: PRESIGNED
bucket: partner-documents
endpoint: https://storage.example.com
access-key: ${PARTNER_ACCESS_KEY}
secret-key: ${PARTNER_SECRET_KEY}
expire: 900独立连接的 bucket、region 和寻址风格仍可从全局继承。DIRECT 独立连接没有 base-url 时,只会按自己的 endpoint 生成地址,不使用全局 domain。
多个存储服务同时使用
默认连接使用 MinIO,指定场景改用腾讯云 COS 时,可以这样配置。场景需要同时填写 endpoint 和两项密钥,再写上腾讯云要求的 region、bucket 和寻址风格;其他场景继续复用全局连接。
sz:
oss:
provider: MINIO
endpoint: https://storage.example.com
access-key: ${OSS_ACCESS_KEY}
secret-key: ${OSS_SECRET_KEY}
bucket-name: media-public
region: us-east-1
addressing-style: PATH
resource:
scenes:
- code: tencent.document
type: OSS
serve-mode: PRESIGNED
provider: TENCENT
endpoint: https://cos.ap-guangzhou.myqcloud.com
access-key: ${TENCENT_SECRET_ID}
secret-key: ${TENCENT_SECRET_KEY}
bucket: documents-1250000000
region: ap-guangzhou
addressing-style: VIRTUAL_HOSTED
expire: 900腾讯云 endpoint 必须对应 bucket 所在区域,bucket 名称包含 APPID。腾讯云当前建议使用虚拟主机风格,详见腾讯云 COS 的 S3 兼容配置。
provider 默认值速查
| provider | 常见服务 | 默认寻址风格 |
|---|---|---|
MINIO | MinIO | PATH |
RUSTFS | RustFS | PATH |
ALIYUN | 阿里云 OSS | VIRTUAL_HOSTED |
TENCENT | 腾讯云 COS | VIRTUAL_HOSTED |
QINIU | 七牛云 S3 | VIRTUAL_HOSTED |
provider 只是默认设置。目标服务要求的 endpoint、region、bucket 和寻址方式仍要按实际环境填写。
寻址风格
addressing-style 决定 bucket 放在请求路径中,还是放在域名前面;region 是签名区域,两者分别配置。以 endpoint=https://storage.example.com 为例:
| 值 | bucket=images、key=2026/10/logo.png 时的地址 |
|---|---|
PATH | https://storage.example.com/images/2026/10/logo.png |
VIRTUAL_HOSTED | https://images.storage.example.com/2026/10/logo.png |
优先级为:场景 addressing-style → 全局 addressing-style → 旧全局 force-path-style → provider 默认值。场景可以单独覆盖。
旧全局 sz.oss.force-path-style 仍兼容。如果它与全局 sz.oss.addressing-style 同时存在且含义冲突,应用会启动失败;场景单独设置不同的 addressing-style 是允许的。新配置使用 addressing-style。
RustFS 的 PATH 模式不需要配置虚拟主机域名。使用 VIRTUAL_HOSTED 时,服务端要设置 RUSTFS_SERVER_DOMAINS,例如基础域名 storage.example.com,并让 images.storage.example.com 等 bucket 子域名解析到存储服务。使用 HTTPS 时,证书也要覆盖实际访问的子域名;经过其他端口的代理时,还需按服务端要求配置端口。详见 RustFS Virtual Host Style。
base-url 的两种写法与优先级
scene.base-url 只用于 DIRECT,支持两种同级写法:
- 同源路径:
/media。浏览器使用当前页面的协议、域名和端口访问,部署时必须配置对应代理。 - 完整地址:
https://files.example.com/media。适合独立文件域名、CDN 或反向代理。
两种写法都是最终公开前缀。程序不会再追加 endpoint、bucket 或 LOCAL path,只会追加从 objectKey 去掉当前场景存储前缀后的相对路径。
地址如何拼接
假设场景配置如下:
sz:
resource:
scenes:
- code: site.image
type: OSS
serve-mode: DIRECT
bucket: images
base-url: /media以已经存储的 objectKey=images/2026/10/logo.png 为例:
objectKey = images/2026/10/logo.png
去掉 bucket 后的相对 key = 2026/10/logo.png
页面 origin = https://admin.example.com
最终 URL = https://admin.example.com/media/2026/10/logo.png代理需要完成以下映射:
外部 /media/2026/10/logo.png
→ 存储 /images/2026/10/logo.png只写 base-url 不会自动创建代理规则。改成 base-url: https://files.example.com/media 后,最终 URL 为 https://files.example.com/media/2026/10/logo.png,相对 key 不变。
未填写 base-url 时
OSS DIRECT 会自动生成公开地址:
- 使用默认连接时,先使用
sz.oss.domain,没有 domain 再使用 endpoint。 - 使用独立连接时,只使用场景自己的 endpoint,不继承全局 domain。
- LOCAL DIRECT 不使用任何 OSS 地址,必须显式填写
base-url。
自动生成地址不会改变存储服务的访问规则。例如七牛的 S3 服务域名需要签名,DIRECT 场景应使用已绑定的自定义域名,不能直接使用自动拼出的 S3 地址。
domain 与 base-url 的含义不同:
sz:
oss:
# PATH:domain 是服务根地址,程序会追加 bucket
addressing-style: PATH
domain: https://files.example.combucket 为 images 时,PATH 最终前缀是 https://files.example.com/images。
VIRTUAL_HOSTED 可以为单个 bucket 填写完整桶域名:
sz:
oss:
addressing-style: VIRTUAL_HOSTED
domain: https://images.files.example.com多个 bucket 可以使用模板:
sz:
oss:
addressing-style: VIRTUAL_HOSTED
domain: https://{bucket}.files.example.comscheme 只会给未带协议的 endpoint 或 domain 补协议,默认是 https;它不会处理场景 base-url。新配置建议直接写完整的 http:// 或 https:// endpoint。
PRESIGNED 使用实际 endpoint、region 和寻址风格参与签名,不使用 domain 或 base-url。endpoint 必须能被浏览器访问,生成签名后不能替换主机名。PROTECTED 不生成公开地址。
文件名、目录和上传限制
场景字段速查
| 字段 | 可选值和默认值 | 说明 |
|---|---|---|
code | 无默认值 | 场景唯一标识,业务上传、读取和配置都使用它。 |
name | 可省略 | 场景展示名称,未填写时显示 code。 |
type | LOCAL(默认)、OSS | LOCAL 使用 path,OSS 使用 bucket。当前省略时固定为 LOCAL,sz.resource.default-storage-type 不会改变此默认值,建议逐场景明确填写。 |
serve-mode | PROTECTED(默认)、DIRECT、PRESIGNED | PRESIGNED 仅支持 OSS。 |
naming | UUID(默认)、BIZ_KEY、ORIGINAL、TIMESTAMP | BIZ_KEY 使用上传表单中的 bizKey;ORIGINAL 遇到同一 objectKey 已存在时会追加时间戳。 |
path-strategy | FLAT(默认)、DATE、BIZ、BIZ_DATE | 同时作用于 LOCAL 和 OSS;BIZ 使用 pathSegments,与 bizKey 无关。 |
expire | 默认 3600 秒 | 只对 PRESIGNED 生效,必须大于 0。 |
exts | 小写扩展名集合 | 场景白名单与全局 allowed-exts 同时生效,结果相当于取交集。 |
mimes | MIME 集合 | 非空时使用场景 MIME 白名单;未配置或为空时使用全局 allowed-mime-types。校验依据是上传请求的 Content-Type,未提供时跳过此项。 |
max-size | 数字,单位 MB;0 表示不额外限制 | 与全局 security.max-size 取较小值。 |
路径策略示例,假设基础目录为 files、文件名为 report.pdf、pathSegments=1001,contract:
| 策略 | objectKey 目录结构 |
|---|---|
FLAT | files/report.pdf |
DATE | files/yyyyMMdd/report.pdf |
BIZ | files/1001/contract/report.pdf |
BIZ_DATE | files/1001/contract/yyyyMMdd/report.pdf |
BIZ_KEY 使用处理后的 bizKey 加文件扩展名命名,相同目录、bizKey 和扩展名会得到同一 objectKey,可以覆盖旧文件。UUID 使用不含连字符的随机 UUID;ORIGINAL 尽量保留原文件名,不支持的字符会替换为下划线,同名冲突时追加时间戳;TIMESTAMP 使用 yyyyMMddHHmmssSSS 格式的日期时间命名。
全局扩展名或 MIME 白名单未配置、为空时,会使用框架内置白名单;全局文件大小上限默认 50MB。场景 exts 只能收窄全局范围,非空 mimes 则替代全局 MIME 列表,两者规则不同。
无论扩展名白名单如何设置,上传都会拒绝内置危险扩展名和含危险双扩展名的文件。
设置全局上传限制
例如,项目只接收 JPEG、PNG 和 PDF,可以这样设置。它会影响所有场景,已有业务需要其他格式时应一并保留:
sz:
resource:
security:
allowed-exts: [jpg, jpeg, png, pdf] # 全局扩展名白名单,无需写点号
allowed-mime-types: [image/jpeg, image/png, application/pdf] # 请求 Content-Type 白名单
max-size: 10MB # 全局大小上限,带单位;场景 max-size 则是以 MB 为单位的数字WARNING
资源模块限制不是 HTTP 上传的唯一限制。当前 Spring 配置的 spring.servlet.multipart.max-file-size 为 10MB,max-request-size 为 15MB;Nginx、网关也可能有自己的上限。即使 sz.resource.security.max-size 配成 50MB,请求仍可能先被这些入口拒绝。提高上传上限时要一起检查 Spring、Nginx 和网关配置。
部署、切换存储与升级
部署检查
- bucket 需要提前创建。框架不会自动创建、删除或迁移 bucket。
- 私有附件 bucket 保持私有。DIRECT 直连源站时需允许公开读;使用 CDN 时也可以配置私有源站回源授权。不要开放匿名写入。
- PRESIGNED 的 endpoint 必须是浏览器可达地址,且不能在签名后替换域名。
- LOCAL 生产环境只对 DIRECT 目录配置 Nginx 白名单,PROTECTED 目录不能静态开放。
- RustFS 使用 VIRTUAL_HOSTED 时,同时检查
RUSTFS_SERVER_DOMAINS、DNS、证书和实际请求端口。
切换 RustFS 或其他存储
仅调整 provider 不会自动迁移文件。实际更换存储时,先备份配置、对象和数据库引用,再选择处理方式:
| 更换方式 | 历史文件如何处理 |
|---|---|
| 新 scene 接收新文件,旧 scene 保留 | 旧引用继续使用旧 scene 和旧存储,不必迁移历史文件。 |
| 同一 scene 只更换 endpoint | 把对象复制到新存储,保持 bucket 名称和桶内 key 不变,原引用才能继续使用。 |
| 同一 scene 更换 bucket | 除复制对象外,还要核对并同步 sys_resource.object_key 与业务 ResourceRef 中的桶名前缀。 |
例如,原 objectKey 为 old-bucket/20261010/report.pdf,实际桶内 key 是 20261010/report.pdf。把场景 bucket 改成 new-bucket 后,程序不会自动改写旧前缀:未更新的引用会尝试读取新桶中的 old-bucket/20261010/report.pdf。如果按原桶内 key 复制文件,引用也应同步为 new-bucket/20261010/report.pdf。
迁移对象使用 S3 API 或迁移工具,不要直接把 MinIO 数据目录挂载给 RustFS。先在验证环境检查对象数量、关键前缀、抽样内容,以及历史读取和新上传,再安排正式切换。若同时升级到 v2.1.0,先按 v2.1.0 升级指南 完成资源访问契约迁移;仅切换存储时,按本节完成对象、引用和回退验证。
常见错误与排查
受保护资源访问使用以下业务码:
| HTTP | 业务码 | 含义 |
|---|---|---|
| 403 | R2001 | 当前用户无权访问该业务资源 |
| 404 | R2002 | 业务记录或活动资源不存在 |
| 400 | R2003 | 资源引用无效 |
| 400 | R2004 | 资源场景不匹配 |
| 403 | R2005 | 新增资源不是当前用户上传 |
| 415 | R2006 | 文件类型不允许在线预览 |
| 500 | R2007 | 存储读取失败 |
当前组件会显示后端返回的错误信息。415 / R2006 表示不允许预览,可以改用下载;403 不应降级为公开访问;404 应刷新业务数据并检查关联是否仍存在。
常见问题
| 现象 | 优先检查 |
|---|---|
| 上传返回 413 或还没进入业务接口就失败 | Spring multipart 的 10MB / 15MB 限制、Nginx client_max_body_size、网关限制 |
PROTECTED 上传成功但 accessUrl 为空 | 正常行为;保存业务记录后通过固定业务接口访问 |
| DIRECT 地址 404 | base-url 对应的 Nginx / 网关路由、bucket 策略、LOCAL path 映射 |
| PRESIGNED 打开后签名错误 | endpoint、region、寻址风格、浏览器实际访问主机是否与签名一致 |
| 云存储报 NoSuchBucket | endpoint 对应地域、完整 bucket 名称;腾讯云包含 APPID,七牛使用 S3 空间名 |
| 阿里云上传报 aws-chunked 错误 | AWS SDK 传输兼容性;按阿里云接入章节检查,不要只修改密钥 |
| 七牛文件能下载但不能直接预览 | 新建空间的 S3 外网域名访问策略;需要公开展示时使用已配置的自定义域名 |
| 启动时报独立连接缺字段 | 场景 endpoint、access-key、secret-key 必须三项齐全 |
| 启动时报旧实例字段或寻址冲突 | 删除 sz.oss.instances / oss-instance;统一 addressing-style 与旧 force-path-style |
| 同 bucket 的两个场景文件混在一起 | scene.code 不会自动创建目录;使用不同 bucket、LOCAL path 或 BIZ pathSegments 隔离 |
不兼容变更
serve-mode: TOKEN已改为PROTECTED。POST /api/admin/common/files/download任意 URL 代理已删除。ProxyDownloadDTO、CommonService.urlDownload()、前端useUrlDownload和fileDownload({ url })已删除。- 业务下载使用
bizId + downloadApi;FileDownloadList的业务预览另传previewApi,不使用旧accessContext。 ResourceRef.accessUrl不是受保护文件的定位或授权依据。- 通用模板下载入口仍保留,按 classpath、模板表、动态模板的顺序查找;它不复用按资源 ID 下载接口的业务校验。
相关文档
- 升级指南
- 更新日志
- 配置说明
- 后端详细说明:
sz-common-resource/docs/resource-user-guide.md - RustFS Virtual Host Style
- 腾讯云 COS 的 S3 兼容配置
