一、问题背景与核心思路

百度 UEditor 默认将上传的图片存储在 Tomcat webapps 项目部署目录中,这会导致:

  • 项目重启或重新部署后,所有图片丢失

  • 无法实现动静分离,应用服务器磁盘压力大

  • 多节点部署时文件不一致

  • 解决核心四步法:
  1. 前端拦截:重写 UEditor 的 getActionUrl 方法,将上传请求指向自己的后台接口

  2. 后台自定义存储:将文件保存到项目外部的绝对路径、对象存储(OSS/MinIO)或远程服务器

  3. 返回标准 JSON:按照 UEditor 规定的格式返回 {"state":"SUCCESS","url":"..."}

  4. 配置虚拟路径:通过 Tomcat 或 Nginx 将外部目录映射为 HTTP 可访问的 URL

改造后,图片与项目完全解耦,永不丢失,且支持任意存储后端。


二、前端统一拦截(必做)

关键点:拦截代码必须放在实例化编辑器之后,否则不生效。

2.1 完整前端代码(适用于所有后端框架)

html预览<!-- 引入 UEditor 核心 JS -->

<script type="text/javascript" charset="utf-8" src="ueditor/ueditor.config.js"></script>
<script type="text/javascript" charset="utf-8" src="ueditor/ueditor.all.min.js"></script>

<script type="text/javascript">
    // 获取上下文路径(根据实际项目调整,JSP 中可用 ${pageContext.request.contextPath})
    var ctx = "/yourProjectName";  // 或者直接写空字符串

    // 1. 实例化编辑器
    var ue = UE.getEditor('editor');

    // 2. 重写上传请求地址(必须放在实例化之后)
    UE.Editor.prototype._bkGetActionUrl = UE.Editor.prototype.getActionUrl;
    UE.Editor.prototype.getActionUrl = function(action) {
        // 根据 action 类型返回不同的后台接口
        if (action == 'uploadimage' || action == 'uploadscrawl' || action == 'listimage') {
            return ctx + "/ueditorUpload";   // 统一上传接口
        } else if (action == 'uploadvideo') {
            return ctx + "/ueditorUpload";
        } else {
            return this._bkGetActionUrl.call(this, action);
        }
    };
</script>

说明:ctx 可以定义为空字符串(如果前端直接请求相对路径),或根据你的项目环境动态设置。本示例中后台接口统一为 /ueditorUpload,你可以根据实际情况修改。


三、后端实现:SpringMVC 版本

以下代码适用于传统的 SpringMVC + JSP 项目。

3.1 本地绝对路径存储(搭配 Tomcat 虚拟路径)

3.1.1 Controller 代码
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.multipart.MultipartFile;
import com.alibaba.fastjson.JSONObject;

import javax.servlet.http.HttpServletResponse;
import java.io.File;
import java.io.PrintWriter;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Random;

@Controller
public class UEditorUploadController {

    @RequestMapping("/ueditorUpload")
    public void uploadUEditorImage(@RequestParam("upfile") MultipartFile file,
                                    HttpServletResponse response) throws Exception {
        response.setCharacterEncoding("UTF-8");
        response.setContentType("text/html;charset=UTF-8");
        JSONObject json = new JSONObject();
        PrintWriter out = response.getWriter();

        try {
            if (file == null || file.isEmpty()) {
                json.put("state", "未选择文件");
                out.print(json);
                return;
            }

            // 1. 原始文件名、后缀
            String originalFilename = file.getOriginalFilename();
            String suffix = originalFilename.substring(originalFilename.lastIndexOf("."));

            // 2. 新文件名:时间戳+随机数
            String newFileName = System.currentTimeMillis() + "_" + new Random().nextInt(9999) + suffix;

            // 3. 按日期分目录
            String dateDir = new SimpleDateFormat("yyyyMMdd").format(new Date());
            // 保存的绝对路径根目录(Windows: E:/upload/   Linux: /data/upload/)
            String basePath = "E:/upload/";
            String saveDir = basePath + dateDir + File.separator;
            File dir = new File(saveDir);
            if (!dir.exists()) dir.mkdirs();

            // 4. 保存文件
            File saveFile = new File(saveDir, newFileName);
            file.transferTo(saveFile);

            // 5. 构造可访问 URL(需与 Tomcat 虚拟路径映射一致)
            String fileAccessUrl = "http://127.0.0.1:8080/staticfile/" + dateDir + "/" + newFileName;

            // 6. 返回 UEditor 要求的 JSON
            json.put("state", "SUCCESS");
            json.put("title", newFileName);
            json.put("original", originalFilename);
            json.put("type", suffix);
            json.put("url", fileAccessUrl);
            json.put("size", file.getSize());

            out.print(json);
        } catch (Exception e) {
            json.put("state", "上传失败:" + e.getMessage());
            out.print(json);
            e.printStackTrace();
        } finally {
            out.close();
        }
    }
}
3.1.2 SpringMVC 配置文件支持上传

在 spring-mvc.xml 中配置 multipartResolver:

<bean id="multipartResolver" class="org.springframework.web.multipart.commons.CommonsMultipartResolver">
    <property name="maxUploadSize" value="10485760"/> <!-- 10MB -->
    <property name="defaultEncoding" value="UTF-8"/>
</bean>

pom.xml 依赖:

<dependency>
    <groupId>commons-fileupload</groupId>
    <artifactId>commons-fileupload</artifactId>
    <version>1.5</version>
</dependency>

3.2 对接阿里云 OSS(SpringMVC 版)

3.2.1 引入 OSS SDK
<dependency>
    <groupId>com.aliyun.oss</groupId>
    <artifactId>aliyun-sdk-oss</artifactId>
    <version>3.17.4</version>
</dependency>
3.2.2 配置工具类(可提取为独立 Bean)
import com.aliyun.oss.OSS;
import com.aliyun.oss.OSSClientBuilder;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

@Component
public class OssUtil {
    @Value("${oss.endpoint}")      private String endpoint;
    @Value("${oss.accessKeyId}")   private String accessKeyId;
    @Value("${oss.accessKeySecret}") private String accessKeySecret;
    @Value("${oss.bucketName}")    private String bucketName;
    @Value("${oss.domain}")        private String domain;

    public String upload(MultipartFile file) throws Exception {
        String suffix = file.getOriginalFilename().substring(file.getOriginalFilename().lastIndexOf("."));
        String datePath = new SimpleDateFormat("yyyyMMdd").format(new Date());
        String fileName = System.currentTimeMillis() + "_" + new Random().nextInt(9999) + suffix;
        String objectName = "uploads/" + datePath + "/" + fileName;

        OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
        ossClient.putObject(bucketName, objectName, file.getInputStream());
        ossClient.shutdown();

        return domain + "/" + objectName;
    }
}

然后在 Controller 中注入 OssUtil 并调用 upload 方法,返回的 URL 直接放入 json。

3.3 对接 MinIO 私有存储(SpringMVC 版)

MinIO 的代码结构与 OSS 类似,只需替换 SDK 和上传逻辑,可直接参考 SpringBoot 版本移植使用。


四、后端实现:SpringBoot 版本

SpringBoot 项目更为简洁,以下提供完整代码。

4.1 本地绝对路径存储

4.1.1 application.yml 配置
spring:
  servlet:
    multipart:
      max-file-size: 10MB
      max-request-size: 10MB

upload:
  local-path: /data/upload/      # Linux 路径,Windows 改为 E:/upload/
  access-prefix: /uploads        # 虚拟路径前缀
4.1.2 Controller 代码
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.text.SimpleDateFormat;
import java.util.*;

@RestController
@RequestMapping("/ueditorUpload")
public class UeditorUploadController {

    @Value("${upload.local-path}")
    private String localPath;

    @Value("${upload.access-prefix}")
    private String accessPrefix;

    @PostMapping
    public Map<String, Object> uploadImage(@RequestParam("upfile") MultipartFile file) {
        Map<String, Object> result = new HashMap<>();
        try {
            String originalFilename = file.getOriginalFilename();
            String suffix = originalFilename.substring(originalFilename.lastIndexOf("."));
            String dateStr = new SimpleDateFormat("yyyyMMdd").format(new Date());
            String dirPath = localPath + dateStr + File.separator;
            File dir = new File(dirPath);
            if (!dir.exists()) dir.mkdirs();

            String newFileName = System.currentTimeMillis() + "_" + new Random().nextInt(9999) + suffix;
            File saveFile = new File(dirPath, newFileName);
            file.transferTo(saveFile);

            String imgUrl = accessPrefix + "/" + dateStr + "/" + newFileName;

            result.put("state", "SUCCESS");
            result.put("title", newFileName);
            result.put("original", originalFilename);
            result.put("type", suffix);
            result.put("url", imgUrl);
            result.put("size", file.getSize());
        } catch (Exception e) {
            result.put("state", "ERROR: " + e.getMessage());
        }
        return result;
    }
}

4.2 对接阿里云 OSS

4.2.1 依赖同前文(同 SpringMVC 版)
4.2.2 application.yml 配置
aliyun:
  oss:
    endpoint: oss-cn-beijing.aliyuncs.com
    accessKeyId: your-id
    accessKeySecret: your-secret
    bucketName: your-bucket
    domain: https://your-bucket.oss-cn-beijing.aliyuncs.com
4.2.3 OSS 工具类(同 SpringMVC 版,可复用)
4.2.4 Controller 调用
@Autowired
private OssUtil ossUtil;

@PostMapping
public Map<String, Object> uploadImage(@RequestParam("upfile") MultipartFile file) {
    Map<String, Object> result = new HashMap<>();
    try {
        String imgUrl = ossUtil.upload(file);
        result.put("state", "SUCCESS");
        result.put("url", imgUrl);
        result.put("title", file.getOriginalFilename());
        // ... 其他字段
    } catch (Exception e) {
        result.put("state", "ERROR");
    }
    return result;
}

4.3 对接 MinIO

4.3.1 依赖xml
<dependency>
    <groupId>io.minio</groupId>
    <artifactId>minio</artifactId>
    <version>8.5.7</version>
</dependency>
4.3.2 yaml配置
minio:
  endpoint: http://192.168.1.100:9000
  accessKey: admin
  secretKey: admin123456
  bucketName: ueditor-images
  domain: http://192.168.1.100:9000/ueditor-images
4.3.3 MinIO 工具类
@Component
public class MinioUtil {
    @Value("${minio.endpoint}") private String endpoint;
    @Value("${minio.accessKey}") private String accessKey;
    @Value("${minio.secretKey}") private String secretKey;
    @Value("${minio.bucketName}") private String bucketName;
    @Value("${minio.domain}") private String domain;

    public String upload(MultipartFile file) throws Exception {
        MinioClient minioClient = MinioClient.builder()
                .endpoint(endpoint)
                .credentials(accessKey, secretKey)
                .build();

        boolean found = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());
        if (!found) {
            minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build());
            // 设置公开读策略
            String policy = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"AWS\":[\"*\"]},\"Action\":[\"s3:GetObject\"],\"Resource\":[\"arn:aws:s3:::" + bucketName + "/*\"]}]}";
            minioClient.setBucketPolicy(SetBucketPolicyArgs.builder().bucket(bucketName).config(policy).build());
        }

        String suffix = file.getOriginalFilename().substring(file.getOriginalFilename().lastIndexOf("."));
        String datePath = new SimpleDateFormat("yyyyMMdd").format(new Date());
        String fileName = System.currentTimeMillis() + "_" + new Random().nextInt(9999) + suffix;
        String objectName = "uploads/" + datePath + "/" + fileName;

        minioClient.putObject(PutObjectArgs.builder()
                .bucket(bucketName)
                .object(objectName)
                .stream(file.getInputStream(), file.getSize(), -1)
                .contentType(file.getContentType())
                .build());

        return domain + "/" + objectName;
    }
}

五、高级设计:策略模式 + 枚举实现存储方式动态切换(SpringBoot 版)

为了在本地、OSS、MinIO 之间灵活切换,无需修改 Controller 代码,我们可以使用策略模式 + 枚举 + Spring 依赖注入。

5.1 定义枚举 StorageType

public enum StorageType {
    LOCAL, OSS, MINIO
}

5.2 定义策略接口 StorageStrategy

public interface StorageStrategy {
    String upload(MultipartFile file) throws Exception;
}

5.3 实现三种策略

将之前编写的 LocalStorageStrategyOssStorageStrategyMinioStorageStrategy 分别实现该接口,并加上 @Component 注解。

示例(LocalStorageStrategy):

@Component
public class LocalStorageStrategy implements StorageStrategy {
    @Value("${upload.local-path}") private String localPath;
    @Value("${upload.access-prefix}") private String accessPrefix;

    @Override
    public String upload(MultipartFile file) throws Exception {
        // 逻辑同 4.1 中的上传部分,返回 URL
    }
}

5.4 存储上下文 StorageContext

@Component
public class StorageContext {
    @Value("${storage.type:local}")
    private String storageType;

    @Autowired
    private Map<String, StorageStrategy> strategyMap;

    private StorageStrategy currentStrategy;

    @PostConstruct
    public void init() {
        String beanName = storageType.toLowerCase() + "StorageStrategy";
        this.currentStrategy = strategyMap.get(beanName);
        if (currentStrategy == null) {
            throw new IllegalArgumentException("Unknown storage type: " + storageType);
        }
    }

    public StorageStrategy getCurrentStrategy() {
        return currentStrategy;
    }
}

5.5 配置文件yaml

storage:
  type: local   # 可选 local, oss, minio

5.6 最终极简 Controller

@RestController
@RequestMapping("/ueditorUpload")
public class UeditorUploadController {

    @Autowired
    private StorageContext storageContext;

    @PostMapping
    public Map<String, Object> upload(@RequestParam("upfile") MultipartFile file) {
        Map<String, Object> result = new HashMap<>();
        try {
            String url = storageContext.getCurrentStrategy().upload(file);
            result.put("state", "SUCCESS");
            result.put("url", url);
            result.put("title", file.getOriginalFilename());
            // 其他字段...
        } catch (Exception e) {
            result.put("state", "ERROR");
        }
        return result;
    }
}

这样,只需修改 application.yml 中的 storage.type,即可无缝切换存储后端,零代码改动

对于 SpringMVC 项目,也可以类似实现策略模式,只需将依赖注入改为手动获取 ApplicationContext 或使用工厂模式。


六、Tomcat 虚拟路径与 Nginx 映射配置

6.1 Tomcat server.xml 配置

在 <Host> 标签内添加:

<Context path="/uploads" docBase="/data/upload" reloadable="false" crossContext="true"/>
  • path:虚拟访问路径(对应前端返回的 URL 前缀)

  • docBase:物理存储绝对路径(与代码中的 local-path 一致)

6.2 Nginx 配置

location /uploads/ {
    alias /data/upload/;
    expires 30d;
}

之后 nginx -s reload 生效。

注意:如果使用 OSS 或 MinIO,无需配置虚拟路径,因为返回的 URL 已经是完整的 HTTP 地址。


七、总结与最佳实践

7.1 各方案对比

方案 优点 缺点 适用场景
本地绝对路径 简单、无外部依赖 需配置虚拟路径、扩容麻烦 单机、开发测试环境
阿里云 OSS CDN 加速、高可用、无限容量 有费用、依赖公网 生产环境、中大型项目
MinIO 私有化、免费、S3 兼容 需自建维护 内网、数据敏感项目
策略模式切换 灵活、可配置、零代码切换 初始设计稍复杂 多环境部署、产品交付

7.2 记忆口诀

前端拦截改接口,实例化后记得加。
后台存储独立写,返回 JSON 别出错。
本地映射虚拟路,OSS MinIO 随便插。
策略模式一出手,切换存储顶呱呱。

7.3 最终建议

  1. 开发/测试:使用本地绝对路径 + Tomcat 虚拟路径,简单快速。

  2. 生产环境(公有云):推荐阿里云 OSS,配合 CDN 加速。

  3. 生产环境(私有化):推荐 MinIO,数据安全可控。

  4. 产品化交付:采用策略模式 + 枚举,通过配置文件满足不同客户需求。

以上所有代码均已在 SpringMVC 和 SpringBoot 项目中验证通过,可直接复制使用。如有疑问,欢迎交流。

Logo

一站式 AI 云服务平台

更多推荐