文件上传
简介
本文件介绍了如何使用 Tio Boot Admin 技术栈(前端使用 tio-boot-admin-react,后端使用 tio-boot-admin)实现一个基于文件表单的业务功能模块。本模块支持文件上传、下载、预览以及与数据库的集成管理。
数据表设计
文件信息表
系统文件上传表存储了文件上传的详细信息,如文件名、大小、MD5 值等。
手动创建 tio_boot_admin_system_upload_file,参见文件信息表。已建表的应用无需重新建表。
UniStorageService:统一文件上传服务
nexus.io.tio.boot.admin.services.storage.UniStorageService 提供统一的上传、文件记录和 URL 查询入口。业务代码调用同一个 Service,由配置选择云存储平台。
配置平台与 SDK
app.storage.platform=aliyun_oss
| 配置值 | 平台配置及上传说明 |
|---|---|
aliyun_oss | 阿里云 OSS |
aws_s3 | 亚马逊 S3 |
cloudflare_r2 | Cloudflare R2 |
tencent_cos | 腾讯 COS |
应用还需要引入对应平台 SDK,并加载该平台的密钥、区域和 Bucket 配置。先完成配置加载,再使用 Service;修改平台配置后重启应用。未指定平台时默认为 cloudflare_r2,请显式填写所使用的平台。
初始化与职责
使用内置数据库配置和 ApiTable,并手动创建上面的文件信息表。UniStorageService 负责上传、按 MD5 查找已有记录、保存文件元数据以及获取 URL;Handler 负责解析和校验请求,拦截器负责身份验证,业务 Service 负责文件归属和业务关联。
阿里云客户端由工具类共享,在应用退出时通过 HookCan.me().addDestroyMethod(AliyunOssUtils::shutdown) 关闭一次,具体说明见平台章节。切换平台时使用对应 SDK 的客户端生命周期管理方式。
Handler 调用 UniStorageService
import nexus.io.jfinal.aop.Aop;
import nexus.io.model.body.RespBodyVo;
import nexus.io.model.upload.UploadFile;
import nexus.io.model.upload.UploadResult;
import nexus.io.tio.boot.admin.services.storage.UniStorageService;
import nexus.io.tio.boot.exception.BusinessException;
import nexus.io.tio.boot.http.TioRequestContext;
import nexus.io.tio.http.common.HttpRequest;
import nexus.io.tio.http.common.HttpResponse;
import nexus.io.tio.utils.validator.ParameterValidator;
public class FileStorageHandler {
private final UniStorageService storage = Aop.get(UniStorageService.class);
public HttpResponse upload(HttpRequest request) {
UploadFile file = request.getUploadFile("file");
ParameterValidator.require(file != null && file.getSize() > 0, "file is required");
FileUploadValidator.validateUploadFilename(file.getName());
String category = FileUploadValidator.validateUploadCategory(request.getParam("category"));
RespBodyVo serviceResult = storage.upload(category, file);
return TioRequestContext.getResponse().respond(serviceResult);
}
public HttpResponse downloadUrl(HttpRequest request) {
long id = ParameterValidator.id(request.getParam("id"), "id");
UploadResult result = storage.getPresignedDownloadUrl(id);
BusinessException.require(result != null, 404, "File not found");
RespBodyVo serviceResult = RespBodyVo.ok(result);
return TioRequestContext.getResponse().respond(serviceResult);
}
}
校验集中放在应用的参数校验类中;已有校验类时直接合并下面的方法。FileUploadValidator 是示例应用类,不是框架内置类,需与 Handler 放在同一包或添加对应 import。
import nexus.io.tio.utils.validator.ParameterValidator;
public final class FileUploadValidator {
public static final int UPLOAD_FILENAME_MAX_LENGTH = 255;
public static final int UPLOAD_CATEGORY_MAX_LENGTH = 128;
private FileUploadValidator() {
}
public static String validateUploadFilename(String name) {
ParameterValidator.text(name, "filename", UPLOAD_FILENAME_MAX_LENGTH);
// The service stores the original name, so validate its untrimmed length too.
ParameterValidator.require(name.length() <= UPLOAD_FILENAME_MAX_LENGTH, "filename is too long");
ParameterValidator.require(!name.contains("/") && !name.contains("\\")
&& name.codePoints().noneMatch(Character::isISOControl), "Invalid filename");
int dot = name.lastIndexOf('.');
if (dot >= 0) {
ParameterValidator.require(name.substring(dot + 1).matches("[A-Za-z0-9]+"), "Invalid file extension");
}
return name;
}
public static String validateUploadCategory(Object value) {
String category = ParameterValidator.text(value, "category", UPLOAD_CATEGORY_MAX_LENGTH, false);
if (category == null || category.isBlank()) {
return "uploads";
}
ParameterValidator.require(category.matches("[A-Za-z0-9_-]+"), "Invalid category");
return category;
}
}
原名上限 255,分类上限 128;后缀不再单独限制为 10 位,而是受完整文件名上限约束。禁止文件名携带目录分隔符、控制字符,分类只接受字母、数字、下划线和短横线。表的 target_name 扩容为 1024,为生成的对象路径留出空间。已有表先执行文件表扩容 SQL,再启用新长度;HTTP 请求体大小限制仍由框架配置控制。
在数据库、后台鉴权配置完成后注册路由。Handler 使用 new;共享 Service 使用 Aop。以下接口应受到后台管理员鉴权保护,不要加入公共接口排除列表。
HttpRequestRouter router = TioBootServer.me().getRequestRouter();
FileStorageHandler handler = new FileStorageHandler();
router.add(HttpMethod.POST, "/api/system/file/cloud/upload", handler::upload);
router.add(HttpMethod.GET, "/api/system/file/cloud/download-url", handler::downloadUrl);
配置示例中的类型分别来自 nexus.io.tio.http.server.router.HttpRequestRouter、nexus.io.tio.boot.server.TioBootServer、nexus.io.tio.http.common.HttpMethod。
上传结果与落库行为
请求使用 multipart/form-data,文件字段名称是 file,分类字段为可选的 category。
curl -X POST 'http://127.0.0.1:8100/admin/api/system/file/cloud/upload' \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-F 'category=uploads' \
-F 'file=@example.txt'
/admin 是示例应用的 context path,按实际配置调整。浏览器使用 FormData 时不要自行拼写 multipart 的 Content-Type,让浏览器生成 boundary。
成功响应的 data 包含 id、name、size、md5、url。id 应按字符串处理,避免 JavaScript 大整数精度丢失。url 是普通对象地址,私有 Bucket 下载需要下一节的签名 URL。
UniStorageService 调用所选平台完成上传并通过 ApiTable 保存记录。主要列为 name、size、md5、platform、region_name、bucket_name、target_name 和 file_id。使用阿里云时,平台值为 aliyun_oss,file_id 保存上传返回的 ETag。
数据库记录 ID 与 target_name 中的雪花编号不一定相同,下载时必须按记录读取 target_name,不能自行使用记录 ID 拼路径。
现有实现会按 MD5 查找未删除记录并复用。该查询不是按租户或存储平台隔离的并发唯一约束;多租户、切换 Bucket、并发上传等场景需要结合业务完善去重规则。OSS 上传与数据库保存也不是同一个事务,数据库写入失败时应根据日志排查可能遗留的对象。
下载私有文件
curl 'http://127.0.0.1:8100/admin/api/system/file/cloud/download-url?id=文件记录ID' \
-H "Authorization: Bearer $ADMIN_TOKEN"
使用响应 data.url 下载。阿里云 OSS 实现的默认签名有效期为 30 分钟,其他平台以对应实现为准,不要将临时签名 URL 当作永久文件地址保存。获取 URL 的鉴权由应用拦截器负责,业务文件还应校验文件归属。
签名下载可以设置 Content-Disposition,但不要附加 response-content-type:OSS 会返回 InvalidRequest 和 Can not override response header on content-type。文件类型应在上传时写入对象元数据。框架保留旧的 contentType 参数以兼容调用,但不再生成该响应覆盖参数。参见阿里云错误说明。
联调检查
- 管理员登录后上传非敏感小文件,检查响应的文件 ID、大小和 MD5。
- 查询文件表确认
platform=aliyun_oss、区域、Bucket 和target_name正确。 - 获取签名 URL 并下载,校验下载字节与上传内容一致。
- 验证缺少文件、非法分类、未登录和错误 HTTP 方法的行为。
本章只完成文件存储接口。文件与帖子、商品等业务对象的关联,需要在业务表设计和权限规则确定后单独接入。
以下 S3 专用接口与前端表单示例供已有项目参考;新接口可以采用上面的统一服务。
文件上传到 S3
Maven 依赖
在 pom.xml 中添加 AWS S3 SDK 的依赖,用于与 AWS S3 服务交互。
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>s3</artifactId>
<version>2.17.100</version> <!-- 请使用最新版本 -->
</dependency>
配置文件(app.properties)
AWS_S3_ACCESS_KEY_ID=your_access_key
AWS_S3_SECRET_ACCESS_KEY=your_secret_key
AWS_S3_REGION_NAME=us-west-1
AWS_S3_BUCKET_NAME=your_bucket_name
AWS_S3_BUCKET_DOMAIN=assets.myget.ai
后端功能实现
上传文件接口
1. 判断文件是否已存在
通过文件的 MD5 值判断是否重复上传。
- 请求方式:
GET - 接口:
/api/system/file/url?md5=<md5值> - 响应示例:
{ "data": null, "ok": false, "code": 0, "msg": null }
2. 上传文件
将文件上传到 S3,同时将文件信息存储到数据库。
- 请求方式:
POST - 接口:
/api/system/file/upload - 请求参数:
file(二进制文件)category(文件分类,如:translate)
- 响应示例:
{
"data": {
"id": "448348926798479360",
"name": "PracticeMidterm1_SOLUTIONS.pdf",
"md5": "d87cce163277ca00097663d8e2a8af91",
"url": "https://rumiapp.s3.us-west-1.amazonaws.com/translate/448348917974118400.pdf"
},
"ok": true,
"code": 1,
"msg": null
}
Controller 配置
SystemFileAwsS3Handler 是一个集成的文件处理器,负责文件上传到 S3 和获取文件 URL。
核心代码
HttpRequestRouter r = TioBootServer.me().getRequestRouter();
if (r != null) {
SystemFileAwsS3Handler systemUploadHandler = new SystemFileAwsS3Handler();
r.add(HttpMethod.POST, "/api/system/file/upload", systemUploadHandler::upload);
r.add(HttpMethod.GET, "/api/system/file/url", systemUploadHandler::getUrl);
}
前端实现
表单列定义(.tsx)
import { ProColumns } from "@ant-design/pro-components";
import React from "react";
import { Upload } from "antd";
export const max_kb_document_translate_columns = (): ProColumns<any>[] => [
{
title: "name",
dataIndex: "name",
valueType: "text",
},
{
title: "src_lang",
dataIndex: "src_lang",
valueType: "text",
},
{
title: "src_content",
dataIndex: "src_content",
valueType: "textarea",
ellipsis: true,
},
{
title: "dst_lang",
dataIndex: "dst_lang",
valueType: "text",
},
{
title: "dst_content",
dataIndex: "dst_content",
valueType: "textarea",
ellipsis: true,
},
{
title: "Files",
dataIndex: "files",
valueType: "text",
hideInForm: true,
search: false,
render: (_, row) => (
<Upload
listType="text"
fileList={row.files}
showUploadList={{
showRemoveIcon: false,
showPreviewIcon: true,
}}
/>
),
},
{
title: "Remark",
dataIndex: "remark",
},
{
title: "update_time",
dataIndex: "update_time",
valueType: "dateTime",
hideInSearch: true,
hideInForm: true,
},
{
key: "update_time",
title: "update_time",
dataIndex: "update_time_range",
valueType: "dateTimeRange",
hideInTable: true,
hideInForm: true,
hideInDescriptions: true,
},
];
文件字段
{
title: "Files",
dataIndex: "files",
valueType: "text",
hideInForm: true,
search: false,
render: (_, row) => (
<Upload
listType="text"
fileList={row.files}
showUploadList={{
showRemoveIcon: false,
showPreviewIcon: true,
}}
/>
),
},
图片字段
{
title: "Files",
dataIndex: "files",
valueType: "text",
hideInForm: true,
search: false,
render: (_, row) => (
<UploadPreview
listType="picture-circle"
fileList={row.files}
/>
),
},
数据交互服务(documentService.ts)
export const beforeDocumentPageRequest = (params: any, isRecoveryMode?: boolean, containsUpload?: boolean) => {
params.idType = "long";
if (containsUpload) {
params.json_fields = ["files"];
}
if (isRecoveryMode) {
params.deleted = 1;
} else {
params.deleted = 0;
}
return params;
};
export const beforeDocumentCreateRequest = (formValues: any) => {
return {
...formValues,
idType: "long",
};
};
documentIndex.tsx
import React from 'react';
import {max_kb_document_translate_columns} from "@/pages/website/document/documentColumn";
import ApiTable from "@/components/common/ApiTable";
import {beforeDocumentCreateRequest, beforeDocumentPageRequest} from "@/pages/website/document/documentService";
export default () => {
const from = "max_kb_document_translate";
return (
<ApiTable
from={from}
columns={max_kb_document_translate_columns()}
beforePageRequest={beforeDocumentPageRequest}
beforeCreateRequest={beforeDocumentCreateRequest}
containsUpload={true}
maxFiles={1}
uploadCategory="translate"
/>
);
};
数据交互示例
分页查询
请求
- 接口:
GET /api/table/max_kb_document_translate/page - Payload:
{
"current": 1,
"pageSize": 20,
"idType": "long",
"json_fields": ["files"],
"deleted": 0
}
响应
{
"data": {
"total": 1,
"list": [
{
"id": "448354608041959424",
"name": "PracticeMidterm1_SOLUTIONS",
"src_lang": "English",
"dst_lang": "Chinese",
"files": [
{
"uid": "rc-upload-1731970944784-2",
"size": 250207,
"name": "PracticeMidterm1_SOLUTIONS.pdf",
"url": "https://rumiapp.s3.us-west-1.amazonaws.com/translate/448348917974118400.pdf",
"status": "done"
}
],
"update_time": 1731972080828
}
]
},
"ok": true,
"code": 1,
"msg": null
}
显示效果



总结
本文档展示了基于 Tio Boot Admin 的文件表单模块实现的完整流程,包括数据库设计、文件上传到 S3 的配置、后端接口开发以及前端表单组件的集成,适合在具有文件管理需求的业务场景中使用。
关联业务数据
文章、商品等业务与上传文件分表管理,参考多表实现文件数据存储。
