OSS 文件存储与安全访问
sz-admin 将“文件存在哪里”和“谁能访问文件”分开处理:
| 层次 | 职责 | 不负责 |
|---|---|---|
sz-common-oss | MinIO、阿里云 OSS、腾讯云 COS、七牛云等 S3 兼容存储适配 | 业务权限 |
sz-common-resource | 场景、上传、命名、存储读写、ResourceRef、统一文件流响应 | 具体业务记录的访问判断 |
| 业务模块 | 固定下载/预览路由、接口权限、数据范围、记录与资源关系 | 解析或代理调用方 URL |
sys_resource 是受管资源元数据的权威来源。服务端不会根据前端提交的 URL、objectKey、sceneCode 去发起任意网络请求。
访问模式
| 模式 | 适用场景 | accessUrl | 前端行为 |
|---|---|---|---|
DIRECT | 头像、Logo、公开富文本图片 | 稳定公开地址 | 浏览器直接访问 |
PRESIGNED | 私有 OSS 中无需业务权限的短时展示 | 短时签名地址 | 浏览器直连存储 |
PROTECTED | 教师附件、模板当前/历史、合同附件等业务文件 | null | 调用所属业务下载/预览接口 |
省略 serve-mode 时默认 PROTECTED。公开资源必须显式声明 DIRECT。PRESIGNED 只解决存储访问时效,不等于业务授权。
PROTECTED 没有 ticket、token、bizType 或通用授权器。每次访问都由业务接口重新校验当前登录用户、接口权限、数据范围、业务状态和记录—资源关系。
以下文件无需强行改为受保护 ResourceRef:
- classpath 内置 Excel 模板;
- 动态生成的导入模板和导出文件;
- 公开头像、Logo、富文本图片;
- 原本就由业务接口实时生成的文件流。
配置
sz:
oss:
provider: MINIO
endpoint: your-minio-host:9000
accessKey: your-access-key
secretKey: your-secret-key
bucketName: your-bucket
domain: https://files.example.com
scheme: https
resource:
root: ./data
default-storage-type: LOCAL
security:
allowed-exts: [jpg, jpeg, png, gif, webp, pdf, doc, docx, xls, xlsx, txt, csv, zip]
max-size: 50MB
scenes:
- code: admin.user.logo
name: 账户头像
type: LOCAL
serve-mode: DIRECT
path: logo
base-url: /api/admin/resource/file/logo
naming: ORIGINAL
path-strategy: BIZ_DATE
exts: [svg, png, jpg, jpeg, webp, gif]
max-size: 3
- code: template.excel
name: 模板文件管理 / 导入模板
type: LOCAL
serve-mode: PROTECTED
path: template
naming: ORIGINAL
path-strategy: DATE
exts: [xls, xlsx]
max-size: 10
- code: teacher.attachment
name: 教师统计 / 附件
type: LOCAL
serve-mode: PROTECTED
path: teacher-attachments
naming: ORIGINAL
path-strategy: BIZ_DATE
exts: [png, jpg, jpeg, webp, gif, pdf, doc, docx, xls, xlsx]
max-size: 10
- code: system.protected
name: 通用业务附件
type: LOCAL
serve-mode: PROTECTED
path: protected
naming: ORIGINAL
path-strategy: DATE
exts: [png, jpg, jpeg, webp, gif, pdf, doc, docx, xls, xlsx]
max-size: 10配置规则:
PROTECTED不配置base-url;DIRECT的base-url必须是浏览器可访问地址;- 本地
dev/local/preview可以通过 Java 资源端点提供 DIRECT 文件; - 生产环境应由 Nginx、OSS 或生产启用的受控资源服务承载 DIRECT 地址;
system.temp是 DIRECT 兼容场景,新生成的业务附件默认使用system.protected;- 正式业务建议定义稳定、可读的
code/name,例如contract.attachment / 合同管理 / 附件; - 各环境必须保持同一场景编码,存储类型和公开地址可以按环境变化。
旧配置中的 serve-mode: TOKEN 必须改为 PROTECTED;expire 仅对 PRESIGNED 有效。
上传与 ResourceRef
上传接口
POST /api/admin/resource/upload
Content-Type: multipart/form-data
sceneCode=teacher.attachment
pathSegments=1001
file=<binary>成功响应中的 data 示例:
{
"resourceId": "10086",
"sceneCode": "teacher.attachment",
"objectKey": "teacher-attachments/1001/20260904/report.pdf",
"originName": "report.pdf",
"contentType": "application/pdf",
"size": 98304,
"eTag": null,
"accessUrl": null
}PROTECTED 返回 accessUrl: null 是正常结果,不表示上传失败。
业务引用契约
多文件 JSON 字段统一保存 ResourceRef[]:
export type ResourceRef = {
resourceId: string;
sceneCode?: string;
objectKey?: string;
originName?: string;
contentType?: string;
accessUrl?: string | null;
};字段含义:
resourceId:受管资源主标识,网络和前端按字符串处理;sceneCode:业务字段允许使用的固定资源场景;objectKey:存储定位键;originName:展示和下载文件名;contentType:后端生成响应类型的依据;accessUrl:DIRECT/PRESIGNED 的只读派生地址,不入库、不参与授权。
前端不能将 resourceId 转为 number,也不能只保存 URL 或 string 数组。
保存时的可信引用
业务 Service 在 create/update 时不能原样保存客户端提交的 ResourceRef。通过 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重建 objectKey、文件名和 MIME; - 清空
accessUrl,按resourceId去重并保持提交顺序。
原记录 A/B 更新为 A/C 后,只保存 A/C。B 只是解除业务引用,不会自动删除 sys_resource 或物理文件。
受保护下载与预览
内置接口:
| 业务 | 下载 | 预览 | 授权边界 |
|---|---|---|---|
| 教师统计附件 | POST /api/admin/teacher-statistics/{id}/resources/{resourceId}/download | POST /api/admin/teacher-statistics/{id}/resources/{resourceId}/preview | 查询权限、数据范围、记录包含资源 |
| 当前模板 | POST /api/admin/sys-temp-file/{id}/resources/{resourceId}/download | POST /api/admin/sys-temp-file/{id}/resources/{resourceId}/preview | 查询权限、活动记录、双字段一致 |
| 模板历史 | POST /api/admin/sys-temp-file-history/{id}/resources/{resourceId}/download | POST /api/admin/sys-temp-file-history/{id}/resources/{resourceId}/preview | 查询权限、活动父记录、历史记录双字段一致 |
业务接口先执行 validateResourceAccess(id, 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);
}ResourceDownloadService 只负责:
- 根据
resourceId查询活动的受管资源; - 通过
sceneCode + objectKey调用存储抽象读取文件流; - 下载使用 attachment,预览使用 inline;
- 预览仅允许 PNG、JPEG、WebP、GIF、PDF;
- 返回安全文件名、
X-Content-Type-Options: nosniff和Cache-Control: no-store。
业务状态限制应写在 validateResourceAccess 中。例如合同只有“已发布”状态允许下载,不需要扩展公共下载服务。
WARNING
禁止新增只接收 resourceId、不校验业务记录的通用下载 Controller。否则知道资源 ID 的用户可能越权访问不属于自己的文件。
前端接入
<FileDownloadList
:files="row.attachments"
:biz-id="String(row.id)"
:download-api="downloadContractResourceApi"
:preview-api="previewContractResourceApi"
/>访问顺序:
- 存在
bizId + 业务 API + resourceId:调用固定业务接口并处理 Blob; - 不存在业务接口,但 DIRECT/PRESIGNED 返回
accessUrl:浏览器直接访问; - 两者都没有:拒绝访问,提示从已保存的业务记录进入;
- 不回退到服务端 URL 代理。
编辑和提交规则:
- 保留完整
ResourceRef[]; - 上传过程中禁止提交;
- 提交前调用
normalizeResourceFiles(),移除本地预览状态并按resourceId去重; - 新增记录尚无业务 ID 时,新上传图片使用本地 Blob URL 预览;
- 保存后重新进入页面,PROTECTED 文件改走业务接口;
- Blob 预览关闭或组件卸载时释放
URL.createObjectURL()生成的地址。
代码生成器
字段使用 List<ResourceRef> 且控件为 fileUpload/imageUpload 时,生成器会自动生成:
- create/update 资源规范化;
validateResourceAccess(id, resourceId);- Controller 下载/预览接口;
- 前端下载/预览 API;
bizId、downloadApi、previewApi组件绑定。
生成前必须选择已注册的资源场景。system.protected 可以作为默认值,但正式模块建议定义业务专属 scene,便于文件管理和运维识别。需要额外业务状态限制时,在生成后的 validateResourceAccess 中补充。
头像、Logo、公开富文本图片使用 DIRECT/PRESIGNED,不生成受保护下载接口。
头像
头像不使用 ResourceRef,也不走受保护下载。数据库和提交保存 objectKey,专用返回 VO 提供展示 URL:
| 对象 | 持久/提交字段 | 展示字段 |
|---|---|---|
SysUserVO | logo | logoUrl |
UserProfileVO | avatar | avatarUrl |
| 专用登录返回 VO | objectKey 字段 | URL 字段 |
BaseUserInfo | logo | 无 URL 字段 |
前端保存前应删除 logoUrl/avatarUrl,不能把派生 URL 回写数据库。
文件管理
文件管理页面是资源登记和排障入口,不是绕过业务权限的下载中心。列表展示文件名、用途、大小、访问方式、存储方式、创建人和登记时间:
- DIRECT:允许直接访问或复制地址;
- PRESIGNED:按需生成短时访问地址;
- PROTECTED:仅提供详情,并提示从所属业务记录预览或下载;
system.protected:显示“通用业务附件”,不会凭资源记录猜测具体业务归属。
场景配置中的 name 用于说明用途。业务专属 scene 越清晰,文件管理列表越容易排查。
历史数据与 Liquibase
v2.1.0 没有数据库结构变化。sys_resource 沿用已有表结构。
demo/2.1.0/001_demo_resource_references.xml 只处理官方演示数据:
- 登记 8 个教师统计演示资源;
- 为教师统计 ID 25、26 补齐确定性的
resourceId; - 对齐当前模板和模板历史的演示 JSON;
- 精确前置条件不满足时
MARK_RAN,不覆盖二开数据; - 不扫描、不下载、不删除、不猜测自定义文件。
自定义业务数据不会自动迁移。升级前必须确保每个 PROTECTED ResourceRef.resourceId 都能命中活动的 sys_resource;无法确定原文件与资源记录的映射时,应重新上传并保存业务记录。
系统没有 OFF/AUDIT/REPAIR 迁移模式,没有 H01-H20 报告,也没有自动修复运行器。
错误语义
| HTTP | 业务码 | 含义 |
|---|---|---|
| 400 | R2003 / R2004 | 引用无效或资源场景不匹配 |
| 403 | R2001 / R2005 | 无访问权限或新增资源不属于当前用户 |
| 404 | R2002 | 业务记录或活动资源不存在 |
| 415 | R2006 | 文件类型不允许预览 |
| 500 | R2007 | 存储读取失败 |
前端应对 415 提示用户改用下载;403 不提供降级访问;404 刷新业务数据。
不兼容变更
POST /api/admin/common/files/download任意 URL 代理已删除;ProxyDownloadDTO、CommonService.urlDownload()、前端useUrlDownload与fileDownload({ url })已删除;serve-mode: TOKEN改为PROTECTED;- 受保护组件参数是
bizId + downloadApi + previewApi,不是accessContext; ResourceRef.accessUrl不再作为持久化字段或受保护文件定位依据;- 前后端必须成套升级;
/common/download/templates仍保留,用于受控 classpath 模板下载。
安全不变量
- 后端不得对用户提交的 URL 调用
openStream()、HTTP Client 或跟随重定向; - 每次受保护下载/预览必须校验接口权限、数据范围和记录—资源关系;
sys_resource是资源定位的唯一权威来源;- PREVIEW 不能放开 HTML、SVG 等主动内容;
- 回滚可以关闭文件入口,但不能恢复任意 URL 代理。
相关文档
- v2.1.0 升级指南
- 更新日志
- 配置说明
- 后端仓库:
sz-common-resource/docs/resource-user-guide.md
