多表实现文件数据存储
1. 表的职责
图片文件存入对象存储,数据库分别保存文件元数据、业务内容和关联关系。
| 表 | 职责 |
|---|---|
tio_boot_admin_system_upload_file | 文件原名、大小、MD5、平台、Bucket、对象路径、上传人 |
demo_article | 文章标题、正文、发布人和发布状态 |
demo_article_media | 文章与文件的关联、展示顺序、图片宽高及必要快照 |
一篇文章可以有多张图片;删除文章中的图片关联,不等于删除 OSS 对象。文件表设计和扩容说明见文件信息表,统一上传服务见文件上传。
2. 示例表结构
以下 SQL 由用户手动执行,应用启动不自动建表。文件表使用前述章节的结构;这里展示业务主表与关联表。按应用约定使用逻辑关联,不建立数据库外键。
CREATE TABLE demo_article (
id BIGINT PRIMARY KEY CHECK (id > 0),
user_id BIGINT NOT NULL,
title VARCHAR(200) NOT NULL,
content TEXT,
publish_status SMALLINT NOT NULL DEFAULT 0,
creator VARCHAR(64) NOT NULL DEFAULT '',
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updater VARCHAR(64) NOT NULL DEFAULT '',
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted SMALLINT NOT NULL DEFAULT 0,
tenant_id BIGINT NOT NULL DEFAULT 0
);
CREATE TABLE demo_article_media (
id BIGINT PRIMARY KEY CHECK (id > 0),
article_id BIGINT NOT NULL,
upload_file_id BIGINT NOT NULL CHECK (upload_file_id > 0),
mime_type VARCHAR(100) NOT NULL,
size_bytes BIGINT NOT NULL CHECK (size_bytes >= 0),
width INTEGER CHECK (width > 0),
height INTEGER CHECK (height > 0),
sort INTEGER NOT NULL DEFAULT 0,
creator VARCHAR(64) NOT NULL DEFAULT '',
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updater VARCHAR(64) NOT NULL DEFAULT '',
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted SMALLINT NOT NULL DEFAULT 0,
tenant_id BIGINT NOT NULL DEFAULT 0
);
CREATE INDEX idx_demo_article_media_article
ON demo_article_media (tenant_id, article_id, sort, id)
WHERE deleted = 0;
CREATE INDEX idx_demo_article_media_upload
ON demo_article_media (tenant_id, upload_file_id)
WHERE deleted = 0;
新建业务可以要求 upload_file_id 必填。迁移历史 URL 图片时,可暂时允许该字段为空,并保留原来的 file_key、url。编辑时只接受属于当前文章的历史关联 ID,不能接受任意外部 URL 代替上传记录。不同 Bucket 可能有同名对象,不要仅凭对象路径自动回填历史关联。
3. 请求流程
- 用户登录,拦截器确认身份。
- 客户端通过 multipart 上传图片,后端验证文件内容。
UniStorageService上传文件并保存文件元数据。- 应用建立可校验上传人、租户的文件记录,返回文件 ID 和预览 URL。
- 保存文章时提交文件 ID 数组,后端验证归属后,在数据库事务中保存文章及图片关联。
- 查询文章时先检查文章可见性,再根据关联记录生成图片 URL。
接口职责保持为:拦截器 → Handler → Service → Db。Handler 解析请求;Service 处理文件归属和事务;Handler 使用 TioRequestContext.getResponse().respond(serviceResult) 输出结果。
配置类和 Handler 直接创建,Service 与 DAO 使用 Aop。普通用户上传接口使用用户鉴权,不应复用仅允许管理员访问的文件接口。
4. 上传与归属校验
校验文件内容
不能只检查文件名或客户端传入的 Content-Type。服务端根据图片内容识别 JPEG、PNG、GIF 等允许的格式,核对扩展名,并读取真实宽高。SVG 等类型应单独设计处理规则,不直接混入普通图片上传。
上传字节数、图片像素数是可配置的图片业务规则;HTTP 整体请求体限制仍由框架控制。读取图片时先检查宽高和像素数,再解码,避免不受控制地分配内存。MIME、大小和宽高由后端生成,不从发帖参数直接落库。
注意统一服务的 MD5 去重
UniStorageService 当前按 MD5 查找已有上传记录,去重不等于上传权限。相同内容可能命中另一个用户的记录,不能把返回记录的 user_id 直接改成当前用户。
一种可用方案是:复用云端对象,但在文件表中为当前用户另建一条上传记录,复制平台、Bucket、对象路径和大小,写入当前上传人、租户及经过验证的图片元数据。每个用户取得自己的文件记录 ID,原记录的归属保持不变。
private final UniStorageService storage = Aop.get(UniStorageService.class);
// file 已通过图片内容校验,imageMetadata 由后端生成。
UploadResult uploaded = storage.uploadFile("pictures", file);
Kv source = Db.findFirstMap(
"select * from tio_boot_admin_system_upload_file where id=? and tenant_id=? and deleted=0",
new String[] {"tags"}, uploaded.getId(), tenantId);
BusinessException.require(source != null, 404, "Uploaded file not found");
Kv receipt = Kv.create();
for (String key : List.of("md5", "size", "platform", "region_name", "bucket_name", "file_id", "target_name")) {
receipt.set(key, source.get(key));
}
receipt.set("id", SnowflakeIdUtils.id())
.set("name", file.getName())
.set("user_id", String.valueOf(userId))
.set("tenant_id", tenantId)
.set("creator", String.valueOf(userId))
.set("tags", imageMetadata);
Kv saved = Db.insertMapReturning("tio_boot_admin_system_upload_file", receipt, new String[] {"tags"});
RespBodyVo serviceResult = RespBodyVo.ok(Kv.create().set("upload_file_id", saved.get("id")));
这是 Service 中的代码片段:file、userId、tenantId 和 imageMetadata 由调用上下文提供。imageMetadata 可包含 mime_type、width、height,保存到文件表的 tags。
示例的 Kv 为 com.jfinal.kit.Kv,Db 为 nexus.io.db.activerecord.Db,其余类型与文件上传章节一致;雪花 ID 工具为 nexus.io.tio.utils.snowflake.SnowflakeIdUtils。租户来自后端身份上下文,不接受客户端自行指定。
多租户系统还需要让底层文件去重按租户、平台、Bucket 隔离;仅新增上传人字段不能修复底层跨租户去重策略。上面按租户查询命中记录失败时应拒绝关联,不能绕过租户判断。
5. 保存文章及图片关联
客户端只提交文件记录 ID,数组顺序表示图片顺序:
{
"title": "示例文章",
"content": "正文",
"media": [
{"upload_file_id": "690000000000000001"},
{"upload_file_id": "690000000000000002"}
]
}
后端保存前应完成以下检查:
- 当前用户有权编辑文章。
- 图片数量不超过业务上限,文件 ID 不重复。
- 文件记录存在、未删除,租户和上传人匹配。
- 记录包含后端验证过的图片 MIME、宽高等信息。
读取文件归属的条件应包含:
SELECT * FROM tio_boot_admin_system_upload_file
WHERE id = ? AND tenant_id = ? AND user_id = ? AND deleted = 0;
文章写入、旧关联逻辑删除、新关联插入必须放在同一个 Db.txResult(...) 事务中。先完成所有引用校验,再替换关联;任何失败都回滚整次保存,避免旧图片已经删除而新图片写入失败。
关联表的 MIME、大小、宽高从已验证上传记录读取。不要保存客户端传来的签名 URL,也不要把客户端传来的文件大小当成真实大小。事务返回结果与 RespBodyVo.ok(result) 分开书写。
6. 查询展示与编辑
查询文章先检查可见性和用户权限,再读取有效关联,按 sort,id 排序。对每个 upload_file_id 调用统一服务生成签名 URL:
UploadResult file = storage.getPresignedDownloadUrl(uploadFileId);
String imageUrl = file == null ? null : file.getUrl();
签名 URL 只用于本次响应,不持久化到文章表、关联表或 Elasticsearch。阿里云默认有效期为 30 分钟,重新打开页面时重新生成。缓存详情响应时应考虑 URL 有效期,不能长期缓存过期地址。
编辑接口返回关联 ID、文件 ID、图片信息及预览地址。用户移除图片后,保存时只移除文章关联,不立即删除上传记录或云端对象,因为同一对象可能被多个记录引用。
上传成功但用户未提交文章时,可能留下未关联文件。清理应设置保留期,并检查所有有效引用;使用去重时还要检查相同平台、Bucket、对象路径的其他记录。
7. 前端与验证
网页和小程序可使用 uni.chooseImage 选图,再使用 uni.uploadFile 逐张上传。上传期间禁用保存,成功后立即加入预览列表;部分失败时保留已成功的图片,让用户重试失败项。
小程序上线前需配置自己的 API 上传域名和对象存储图片访问域名。签名 URL 的 HTTPS 域名必须符合小程序平台要求。
建议验证:上传真实图片、拒绝伪装文件、数量与尺寸上限、跨用户引用拒绝、编辑保留及移除、空列表清空、数据库事务回滚、历史图片保留,以及签名地址下载内容的一致性。
