Skip to content

文件上传 ​

简介 ​

本文件介绍了如何使用 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,由配置选择云存储平台。

已有业务文件表:使用统一对象工具 ​

已有附件表、文件归属和备份机制的应用,可以直接使用 UniStorageUtils 完成对象上传和签名下载,让业务表继续管理文件关联。它与 UniStorageService 使用相同的 app.storage.platform 配置,统一支持 aliyun_oss、aws_s3、cloudflare_r2、tencent_cos、minio。

java
import nexus.io.tio.boot.admin.utils.storage.UniStorageUtils;

public class BusinessObjectStorage {
  public String upload(String objectKey, byte[] bytes, String suffix) {
    return UniStorageUtils.upload(objectKey, bytes, suffix);
  }

  public String downloadUrl(String region, String bucket, String objectKey) {
    return UniStorageUtils.getPresignedDownloadUrl(region, bucket, objectKey);
  }

  public String bucket() {
    return UniStorageUtils.getBucketName();
  }

  public String region() {
    return UniStorageUtils.getRegionName();
  }
}

业务记录应保存平台、区域、Bucket、对象路径和内容摘要。签名 URL 按需生成,不作为永久地址落库。应用可提供稳定的下载接口,在服务端通过签名 URL 读取字节并核对摘要;设置连接与读取超时、限制字节数,并避免在日志或响应中输出签名参数。

使用内容摘要构造对象路径可以让相同字节复用对象;文件归属仍由业务记录分别维护。上传后回读校验成功,再在数据库事务中切换引用,可使迁移失败时保留原有访问路径。备份可按快照中的平台与对象路径收集字节,恢复到独立目标后再选择存储策略。

工具类复用平台客户端,上传批次结束后继续保留连接供后续请求使用;在应用退出时调用所选平台的关闭方法。配置应在首次调用前加载,切换平台、区域或 Endpoint 后重启应用。StorageUploadService 的单文件与批量上传均使用对应平台的 URL 生成方法。

配置平台与 SDK ​

properties
app.storage.platform=aliyun_oss
配置值平台配置及上传说明
aliyun_oss阿里云 OSS
aws_s3亚马逊 S3
cloudflare_r2Cloudflare R2
tencent_cos腾讯 COS
minioMinIO

应用还需要引入对应平台 SDK,并加载该平台的密钥、区域和 Bucket 配置。先完成配置加载,再使用 Service;修改平台配置后重启应用。未指定平台时默认为 cloudflare_r2,请显式填写所使用的平台。

初始化与职责 ​

使用内置数据库配置和 ApiTable,并手动创建上面的文件信息表。UniStorageService 负责上传、按 MD5 查找已有记录、保存文件元数据以及获取 URL;Handler 负责解析和校验请求,拦截器负责身份验证,业务 Service 负责文件归属和业务关联。

阿里云客户端由工具类共享,在应用退出时通过 HookCan.me().addDestroyMethod(AliyunOssUtils::shutdown) 关闭一次,具体说明见平台章节。切换平台时使用对应 SDK 的客户端生命周期管理方式。

Handler 调用 UniStorageService ​

java
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.model.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。

java
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。以下接口应受到后台管理员鉴权保护,不要加入公共接口排除列表。

java
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。

sh
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 上传与数据库保存也不是同一个事务,数据库写入失败时应根据日志排查可能遗留的对象。

下载私有文件 ​

sh
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 服务交互。

xml
<dependency>
  <groupId>software.amazon.awssdk</groupId>
  <artifactId>s3</artifactId>
  <version>2.17.100</version> <!-- 请使用最新版本 -->
</dependency>

配置文件(app.properties) ​

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值>
  • 响应示例:
json
{ "data": null, "ok": false, "code": 0, "msg": null }

2. 上传文件 ​

将文件上传到 S3,同时将文件信息存储到数据库。

  • 请求方式: POST
  • 接口: /api/system/file/upload
  • 请求参数:
    • file(二进制文件)
    • category(文件分类,如:translate)
  • 响应示例:
json
{
  "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。

核心代码 ​

java
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) ​

ts
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,
  },
];

文件字段 ​

ts
{
  title: "Files",
  dataIndex: "files",
  valueType: "text",
  hideInForm: true,
  search: false,
  render: (_, row) => (
    <Upload
      listType="text"
      fileList={row.files}
      showUploadList={{
        showRemoveIcon: false,
        showPreviewIcon: true,
      }}
    />
  ),
},

图片字段 ​

ts
{
  title: "Files",
  dataIndex: "files",
  valueType: "text",
  hideInForm: true,
  search: false,
  render: (_, row) => (
    <UploadPreview
      listType="picture-circle"
      fileList={row.files}
    />
  ),
},

数据交互服务(documentService.ts) ​

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 ​

java
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:
json
{
  "current": 1,
  "pageSize": 20,
  "idType": "long",
  "json_fields": ["files"],
  "deleted": 0
}

响应 ​

json
{
  "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
}

显示效果 ​

Alt textAlt textAlt text

总结 ​

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

关联业务数据 ​

文章、商品等业务与上传文件分表管理,参考多表实现文件数据存储。

MIT Licensed