《从零开发校园墙微信小程序》第 7 篇。校园墙的图片多来自手机相册,一张原图动辄 5~10 MB。如果不处理直接存,流量和存储很快就爆了。

为什么用 MinIO

图片不应该存数据库,也不适合放在应用服务器的磁盘上(部署一次、扩容一次就容易丢)。需要一个独立的对象存储 。
MinIO 是一个兼容 S3 协议的开源对象存储,自己一台服务器、一个 Docker 容器就能跑,Java 有官方 SDK:

implementation("io.minio:minio:8.5.3")

处理流程

投稿图片的上传接口 /weibo/uploadPicture 做三件事:缩放 → 压缩为 JPG → 上传 。

@RequestMapping(value = "/uploadPicture")
public Mono<ResponseEntity<String>> uploadPicture(@RequestPart("file") FilePart file) {
    // FilePart 读取 + ImageIO 压缩 + MinIO 上传都是阻塞操作,整体放弹性线程池
    return Mono.fromCallable(() -> {
        byte[] bytes = DataBufferUtils.join(file.content())
                .map(dataBuffer -> {
                    byte[] b = new byte[dataBuffer.readableByteCount()];
                    dataBuffer.read(b);
                    DataBufferUtils.release(dataBuffer);     // 记得释放,防内存泄漏
                    return b;
                }).block();

        BufferedImage original = ImageIO.read(new ByteArrayInputStream(bytes));
        BufferedImage resized  = resizeImageIfNeeded(original);
        byte[] compressed      = compressImage(resized);

        if (compressed.length > 5 * 1024 * 1024) {
            return ResponseEntity.<String>badRequest().body("Image size exceeds 5MB after compression.");
        }
        // …… 包装成 MultipartFile,交给 MinioUploader
        Image image = minioUploader.uploadImage(compressedFile);
        return ResponseEntity.ok(image.getUrl());
    }).subscribeOn(Schedulers.boundedElastic());
}

缩放:限制到 1920×1080

private BufferedImage resizeImageIfNeeded(BufferedImage originalImage) {
    int maxWidth = 1920, maxHeight = 1080;
    if (originalImage.getWidth() > maxWidth || originalImage.getHeight() > maxHeight) {
        return Scalr.resize(originalImage, Scalr.Method.QUALITY,
                            Scalr.Mode.AUTOMATIC, maxWidth, maxHeight, Scalr.OP_ANTIALIAS);
    }
    return originalImage;     // 不超限就原样返回,不做无谓的二次处理
}

用的是 imgscalr:

  • Method.QUALITY:质量优先(比 SPEED 慢,但手机照片缩小后清晰得多);
  • Mode.AUTOMATIC:等比缩放 ,按图片方向自动决定以宽还是以高为准,不会拉伸变形;
  • OP_ANTIALIAS:抗锯齿,避免缩小后出现毛边。

墙上展示图片不需要 4000×3000 的原图,缩到 1920 边长内,肉眼几乎无差别,体积能小一个数量级。

压缩:统一转 JPG

private byte[] compressImage(BufferedImage image) throws IOException {
    ByteArrayOutputStream baos = new ByteArrayOutputStream();
    ImageIO.write(image, "jpg", baos);
    return baos.toByteArray();
}

统一成 JPG,格式一致,前端处理也简单。压缩后仍超过 5 MB 就直接拒绝并提示用户。

一个可以改进的点 :ImageIO.write(image, "jpg", ...) 用的是默认压缩质量,且对带透明通道的图(如 PNG)会失败 ——JPG 不支持 alpha,需要先把图画到一个 TYPE_INT_RGB 的新 BufferedImage 上再写出。如果你的用户会传透明 PNG(表情包常见),建议在 compressImage 前加这一步转换。另外想精确控制质量,可以用 ImageWriteParam 设置 setCompressionQuality(0.8f)。

MinIO 上传:内外网两个地址

上传工具类 MinioUploader 有一个很实用的设计——连接地址和对外地址分开配置 :

@Component
@ConfigurationProperties(prefix = "minio")
public class MinioUploader {
    private String url;          // 后端连接 MinIO 用的地址(内网 http)
    private String accessKey;
    private String secretKey;
    private String bucketName;
    private String publicUrl;    // 返回给前端/小程序的访问地址(https 反代)
    ...
}

为什么要分开?

  1. 后端和 MinIO 在同一台机器 / 内网 ,直接用内网 http://127.0.0.1:9000 连,快、不绕公网、不受证书影响;
  2. 小程序要求所有请求域名都是 HTTPS 且已备案配置 ,所以用户看图的地址必须是你用 Nginx 反代出来的 HTTPS 域名;
  3. 如果把内网地址直接返回给前端,要么是 http(小程序拒绝加载),要么暴露了内网结构。

所以 publicUrl 留空就回退到 url(本地开发时方便),生产环境配置为反代后的 HTTPS 地址。
配置示例(占位符):

minio:
  url: http://127.0.0.1:9000          # 连接地址(内网)
  publicUrl: https://img.example.com  # 返回给前端的访问地址
  bucketName: pzsl-images
  accessKey: ******
  secretKey: ******

对象名:时间戳 + UUID

上传时不要直接用用户给的文件名:容易重名覆盖,也可能带非法字符。工具类自己生成一个全局唯一的对象名,并统一用 .jpg 扩展名(因为前面已经统一转成了 JPG):

private String generateObjectName(String originalFilename) {
    String uuid = UUID.randomUUID().toString();
    String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
    return "image_" + timestamp + "_" + uuid + ".jpg";
}

生成的名字形如 image_20261111120000_xxxxxxxx-....jpg:按时间能大致排序,UUID 保证不重复。
上传成功后,把对象名、访问地址、上传时间写进 image 表(imageService.insertImageInfo)。

可以改进的点 :image 表其实有 openid 字段,但目前上传链路没有把上传者写进去。如果你想在出问题(比如有人上传违规图)时追溯到具体用户,建议在上传时从 token 取 openid 一并落库,这样配合第 5 篇的封禁机制才能闭环。

安全:图片上传是高危入口

  • 大小限制 :除了上面压缩后 5 MB 的限制,Spring 的 multipart 也要设上限(本项目设为 100 MB 作为外层兜底);
  • 类型校验 :ImageIO.read 读不出图片会返回 null,必须处理,不能直接当图片用;
  • 内容审核 :违规图片要在上传之前 拦截(见第 5 篇,微信 imgSecCheck),不要先存了再说;
  • 对象存储不要公开写 :只有后端持有 accessKey,前端永远不直接写 MinIO。

小结

  1. 图片 → 对象存储,不进数据库;
  2. 先等比缩放 到 1920×1080,再统一转 JPG,压缩后设上限;
  3. 全程是阻塞操作,放 boundedElastic;
  4. 内网连接地址和对外访问地址要分开配置 ;
  5. 带透明通道的 PNG 转 JPG 要先转 RGB;
  6. 上传记录要落库(最好带上传者 openid),方便追溯。

最后一篇:把这一切部署上线,以及我在部署上踩过的几个坑。

最后修改:2026 年 11 月 12 日
如果觉得我的文章对你有用,请随意赞赏