关于微服务项目的文件存储(其二,完结)
创建新模块
我们创建一个新模块,叫做 service-third-party,意思是第三方服务,以后我们还不止是需要第三方提交图片文件,还需要短信、查物流等第三方调用。
添加依赖
在新建的模块中,添加依赖:
<dependency>
<groupId>com.aliyun.ossgroupId>
<artifactId>aliyun-sdk-ossartifactId>
<version>3.17.4version>
dependency>
<dependency>
<groupId>com.aliyungroupId>
<artifactId>sts20150401artifactId>
<version>1.1.6version>
dependency>编写后端程序
package com.sangui.sanguimall.thirdparty.oss.web;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sangui.sanguimall.result.R;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.apache.commons.codec.binary.Base64;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.time.*;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.util.*;
/**
* @Author: sangui
* @CreateTime: 2025-11-08
* @Description: 对象存储 OSS 的 Controller
* @Version: 1.0
*/
("/oss")
public class OssController {
String dir = "test06/";
String bucketName = "sanguimall-test";
String endpoint = "oss-cn-beijing.aliyuncs.com";
String accessKeyId = System.getenv("OSS_ACCESS_KEY_ID");
String accessKeySecret = System.getenv("OSS_ACCESS_KEY_SECRET");
/**
* 通过指定有效的时长(秒)生成过期时间。
*
* @param seconds 有效时长(秒)。
* @return ISO8601 时间字符串,如:"2014-12-01T12:00:00.000Z"。
*/
public static String generateExpiration(long seconds) {
// 获取当前时间戳(以秒为单位)
long now = Instant.now().getEpochSecond();
// 计算过期时间的时间戳
long expirationTime = now + seconds;
// 将时间戳转换为Instant对象,并格式化为ISO8601格式
Instant instant = Instant.ofEpochSecond(expirationTime);
// 定义时区为UTC
ZoneId zone = ZoneOffset.UTC;
// 将 Instant 转换为 ZonedDateTime
ZonedDateTime zonedDateTime = instant.atZone(zone);
// 定义日期时间格式,例如2023-12-03T13:00:00.000Z
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'");
// 格式化日期时间
String formattedDate = zonedDateTime.format(formatter);
// 输出结果
return formattedDate;
}
("/getPolicy")
public R getPostSignatureForOssUpload() throws Exception {
if (accessKeyId == null || accessKeySecret == null) {
return R.fail("OSS 密钥未配置");
}
Map<String, Object> policy = new HashMap<>();
policy.put("expiration", generateExpiration(3600));
List<Object> conditions = new ArrayList<>();
conditions.add(Map.of("bucket", bucketName));
conditions.add(Arrays.asList("content-length-range", 1, 104857600));
conditions.add(Arrays.asList("eq", "$success_action_status", "200"));
conditions.add(Arrays.asList("starts-with", "$key", dir));
policy.put("conditions", conditions);
ObjectMapper mapper = new ObjectMapper();
String jsonPolicy = mapper.writeValueAsString(policy);
String encodedPolicy = Base64.encodeBase64String(jsonPolicy.getBytes());
Mac mac = Mac.getInstance("HmacSHA1");
mac.init(new SecretKeySpec(accessKeySecret.getBytes(), "HmacSHA1"));
byte[] signBytes = mac.doFinal(encodedPolicy.getBytes());
String signature = Base64.encodeBase64String(signBytes);
Map<String, String> resp = new HashMap<>();
resp.put("host", "http://" + bucketName + "." + endpoint);
resp.put("dir", dir);
resp.put("policy", encodedPolicy);
resp.put("baseUrl", "https://" + bucketName + "." + endpoint);
resp.put("signature", signature);
resp.put("accessKeyId", accessKeyId);
return R.ok(resp);
}
}注意:这个后端的 Controller 写得并不规范,因为 Controller 中不写逻辑代码,我是因为要在一个程序能写完,就先没写 Service,请自行修改,另外,常量类也需自己另写文件存储
编写前端程序
<template>
<div>
<el-upload
class="upload-demo"
:http-request="customUpload"
action="#"
:show-file-list="false"
:limit="1"
:on-exceed="() => messageTip('一次只能上传一个文件', 'warning')"
>
<el-button type="primary">点击上传图片el-button>
<template #tip>
<div class="el-upload__tip">
jpg/png files with a size less than 10MB.
div>
template>
el-upload>
<div v-if="uploadedImageUrl" style="margin-top: 16px; display: flex; align-items: center; gap: 12px;">
<span style="color: #67c23a; font-weight: 500;">已上传:span>
<el-image :src="uploadedImageUrl" style="width: 80px; height: 80px;" fit="cover"/>
<el-button size="small" type="text" @click="copyUrl">复制路径el-button>
<el-button size="small" type="danger" @click="clearUploaded">清除el-button>
div>
div>
template>
<script>
import {defineComponent} from "vue";
import {doGet, doPut} from "../../http/HttpRequest.js";
import {getUUID, messageAlert, messageTip} from "../../util/util.js";
export default defineComponent({
name: "BrandView",
data() {
return {
uploadedImageUrl: '', // 例如: https://xxx.com/test06/abc123.jpg
]
}
},
methods: {
// 自定义上传:核心!直传 OSS
customUpload(params) {
const file = params.file;
// 校验
if (!['image/jpeg', 'image/jpg', 'image/png'].includes(file.type)) {
messageTip('只能上传 jpg/png 文件!', 'error');
return Promise.reject();
}
if (file.size / 1024 > 10000) {
messageTip('文件不能超过 10MB!', 'error');
return Promise.reject();
}
doGet("/api/thirdParty/oss/getPolicy", {
dir: "test7/"
}).then(resp => {
if (resp.data.code === 200) {
const data = resp.data.data;
const uploadUrl = `//${data.host.split('://')[1]}`;
// 生成唯一文件名
const uuid = getUUID();
const fileExt = file.name.split('.').pop();
const ossFileName = `${uuid}.${fileExt}`; // 例如: abc123.jpg
const formData = new FormData();
formData.append('key', data.dir + ossFileName);
formData.append('OSSAccessKeyId', data.accessKeyId);
formData.append('policy', data.policy);
formData.append('signature', data.signature);
formData.append('success_action_status', '200');
formData.append('file', file);
doPost(uploadUrl, formData).then((response) => {
// console.log("ali 返回内容:")
// console.log(response);
if (response.status === 200) {
// 拼接完整 URL
const finalUrl = `${data.baseUrl}/${data.dir}${ossFileName}`;
// 并保存到变量
this.uploadedImageUrl = finalUrl;
params.onSuccess({url: finalUrl}, params.file);
messageTip("上传成功!", "success");
} else {
messageTip("上传失败!", "error");
}
})
} else {
messageTip("用户验证失败!", "error");
}
})
},
// 复制路径
copyUrl() {
navigator.clipboard.writeText(this.uploadedImageUrl).then(() => {
messageTip('路径已复制到剪贴板', 'success');
});
},
// 清除已上传
clearUploaded() {
this.uploadedImageUrl = '';
messageTip('已清除', 'info');
},
// ... 你的其他 methods ...
}
script>
<style scoped>
.upload-demo {
margin-top: 20px;
}
style>注意,这里我前端使用的是 Vue 来实现上传的,且使用了 Element-plus 框架。我的的请求方法也是之前封装好的,之前博客出现过。
配置 OSS CORS
此时我们试着运行编写好的程序,浏览器会报出 No 'Access-Control-Allow-Origin' 错误,即 CORS 错误。这就需要我们在阿里云的 就行配置跨域问题。
首先进入自己对应的 Bucket,进去之后,找到数据安全下面的跨域设置,点击创建规则。开始创建跨域规则。
来源这一块填自己前端的具体 ip 端口,我写的是:http://localhost:8080 ,或者直接写一个 * 也行。
允许 Methods 这一块勾选 POST 就行。
允许 Headers 这一块,写一个 * 就好。
其他的字段无需填写。
最后点击确定即可创建 OSS 的跨域规则。
至此,我们就完成了项目中最常见的图片上传功能。
但是,还差最后一个步骤,就是图片预览,或者说图片查看功能。
在我们创建我们自己的 OSS 对象的时候,阿里云会默认开启阻止公共访问,也就是说,一开始我们的 Bucket 的权限是 private 的,也就是 私有的。Bucket 总共有三种权限:
public-read-write(公共读写)
public-read(公共读)
private(私有)
这三种方式,各自有各自的优势和缺点。
先说公共读写,在这个权限下,任何人都可以对你的 Bucket 里的内容进行读写,这种模式很少使用,因为我们的云存储都是计费的,若是任何人都可以写,极有可能导致额外计费,所以一般不推荐使用这个权限。
再说公共读,在这个模式下,当程序员通过可验证的签名上传一个文件之后,会生成一个固定的 url,这个 url 是不会变的,任何人访问这个 url 都可以获取到这个文件,但是却只有程序员可以上传文件,其他人不能上传,因为没有签名。优点是程序方便简单,防止了恶意写文件。缺点是可以通过频繁访问已知的 url ,而消耗掉对应 OSS 的流量,导致额外计费。
最后说私有,这个权限下,并不是说任何人都看不了上传的文件了,若是这样,那云存储也便没了意义。私有权限下,程序员这一块还是和公共读一样,通过可验证的签名上传文件,但是,OSS 端会将这个文件,会生成一个 基础固定的 url + 动态签名,这个动态签名包含了此 url 的过期时间、可验证的签名信息。无论是访问过期的 url 或 不能验证的签名的 url,都是不可浏览的。但是若是这样,没有签名的用户该怎么预览图片呢?很简单,我们在后端的数据库中存储基础的 url 文本,而用户每次要请求预览图片的时候,前端发送请求给后端,后端传给前端一个动态签名的 url(包含基础url+过期时间 + 可验证的签名信息),用户便可通过这个动态的 url 来访问。优点是权限保护得很好,不会产生额外的费用。缺点是程序较公共读稍复杂,多一个获取动态签名的步骤。
前端的实现:
data() {
return {
// 保存上传成功的 URL,例如: test06/abc123.jpg
uploadedImageUrl: '',
// 进过签名验证的完整 URL,该路径可直接预览
signedImageUrl: '',
}
}
methods: {
customUpload(params) {
const file = params.file;
// 校验
if (!['image/jpeg', 'image/jpg', 'image/png'].includes(file.type)) {
messageTip('只能上传 jpg/png 文件!', 'error');
return Promise.reject();
}
if (file.size / 1024 > 10000) {
messageTip('文件不能超过 10MB!', 'error');
return Promise.reject();
}
doGet("/api/thirdParty/oss/getPolicy", {
dir: "test7/"
}).then(resp => {
if (resp.data.code === 200) {
const data = resp.data.data;
const uploadUrl = `//${data.host.split('://')[1]}`;
// 生成唯一文件名
const uuid = getUUID();
const fileExt = file.name.split('.').pop();
const ossFileName = `${uuid}.${fileExt}`; // 例如: abc123.jpg
const formData = new FormData();
formData.append('key', data.dir + ossFileName);
formData.append('OSSAccessKeyId', data.accessKeyId);
formData.append('policy', data.policy);
formData.append('signature', data.signature);
formData.append('success_action_status', '200');
formData.append('file', file);
doPost(uploadUrl, formData).then((response) => {
// console.log("ali 返回内容:")
// console.log(response);
if (response.status === 200) {
// 拼接完整 URL
const finalUrl = `${data.baseUrl}/${data.dir}${ossFileName}`;
// 并保存到变量
this.uploadedImageUrl = data.dir + ossFileName;
params.onSuccess({url: finalUrl}, params.file);
this.getSignedImageUrl(data.dir + ossFileName);
messageTip("上传成功!", "success");
} else {
messageTip("上传失败!", "error");
}
})
} else {
messageTip("用户验证失败!", "error");
}
})
},
// 工具函数:获取签名 URL
getSignedImageUrl(url) {
doGet('/api/thirdParty/oss/getSignedUrl', {
uploadedImageUrl: url
}).then(resp => {
if (resp.data.code === 200) {
this.signedImageUrl = resp.data.data;
}
})
},
}总结讲解一下这个程序,这个程序的主体还是之前上传的那个程序的主体,我增加了一个 getSignedImageUrl 方法,这个方法调用给后端传本次图片的 url (例如:test01/abc.jpg),以获取真实的、经过验证的 url ,通过这个网址,可以直接浏览对应的图片。
后端的实现::
("/getSignedUrl")
public R getSignedUrl((value = "uploadedImageUrl") String uploadedImageUrl) {
String endpoint = "oss-cn-beijing.aliyuncs.com";
String accessKeyId = System.getenv("OSS_ACCESS_KEY_ID");
String accessKeySecret = System.getenv("OSS_ACCESS_KEY_SECRET");
String bucketName = "sanguimall-test";
OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
// 设置过期时间:300秒
Date expiration = new Date(System.currentTimeMillis() + 300 * 1000L);
URL url = ossClient.generatePresignedUrl(bucketName, uploadedImageUrl, expiration);
ossClient.shutdown();
System.out.println("后端返回的最终 url = "+ url.toString());
return R.ok(url.toString());
}注意:我的这个 Controller 方法依然写了逻辑,后续我会自行写入 service 方法里,这里为了展示方便,什么常量也直接写了。这里调用 OSS ,通过自己的 accessKey 等的信息,获取一个可供查看的 url。
最后一点,我目前写的程序中,只能针对一张图片的预览(单次),在一些多图片预览的表格、文章等会一次性展示多张图片的场景,其实不太适用这种方法。
我的想法是这样的,数据库中存储的图片地址,就是在 OSS 中的相对路径(例如:test01/abc.jpg、ccc.png 等),前端向后端对应的微服务发送多次图片的请求时,对应的微服务使用 OpenFeign 来调用我写好的第三方微服务的 getSignedUrl 。放弃前端反复发送给后端的异步请求获取完整的 url,直接在微服务之间互相调用。下面来看我的一个展示多张图片的例子:
我的微服务叫做商品微服务,前端有一个页面,就是展示所有商品的品牌,而每个品牌都有它的品牌 logo 图片,这就是我的展示多张图片的例子。
public PageInfo<BrandDo> getBrandsByPage(Integer current) {
// 1. 设置 PageHelper
PageHelper.startPage(current, Constants.PAGE_SIZE);
// 2. 查询
List<BrandDo> list = brandMapper.selectBrandByPage();
// 3. 封装分页数据到 PageInfo
return new PageInfo<>(list);
}以上代码就是我的原本的代码。
现在我在我的商品微服务中,加入 OpenFeign 的接口:
package com.sangui.sanguimall.product.feign;
import com.sangui.sanguimall.result.R;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
/**
* @Author: sangui
* @CreateTime: 2025-11-09
* @Description: 第三方服务的 Feign 客户端接口
* @Version: 1.0
*/
("service-third-party")
public interface ThirdPartyFeignClient {
("/oss/getSignedUrl")
R getSignedUrl((value = "uploadedImageUrl") String uploadedImageUrl);
}具体关于 OpenFeign 的讲解我在之前博客中有涉及,简单来说 OpenFeign 就是能负载均衡得访问其他微服务的 Controller 的组件。这里,我直接访问的时 第三方服务的 Oss 的 getSignedUrl ,即是我之前写的获取真实 url 的方法。
public PageInfo<BrandDo> getBrandsByPage(Integer current) {
// 1. 设置 PageHelper
PageHelper.startPage(current, Constants.PAGE_SIZE);
// 2. 查询
List<BrandDo> list = brandMapper.selectBrandByPage();
for (BrandDo brandDo : list) {
String url = (String)thirdPartyFeignClient.getSignedUrl(brandDo.getLogo()).getData();
brandDo.setLogo(url);
}
// 3. 封装分页数据到 PageInfo
return new PageInfo<>(list);
}上述代码就是我,改进之后的 getBrandsByPage ,增加了四行代码,将 logo 设置成 真实的 url。
- 微信
- 赶快加我聊天吧

- 赶快加我聊天吧
