Spring Boot 接入腾讯云 COS 对象存储 [私有读写]
本文不涉及对象存储的开通与配置,详见官方文档:
https://cloud.tencent.com/document/product/436
本文对于权限控制的细粒度还比较粗糙,读者需根据实际情况加以实现。
本文使用的是私有读写模式,可以较好地防盗刷。
1. 引入腾讯云 COS SDK
<dependency>
<groupId>com.qcloud</groupId>
<artifactId>cos-sts_api</artifactId>
<version>3.1.1</version>
</dependency>
<dependency>
<groupId>com.qcloud</groupId>
<artifactId>cos_api</artifactId>
<version>5.6.240</version>
<exclusions>
<exclusion>
<groupId>commons-logging</groupId>
<artifactId>commons-logging</artifactId>
</exclusion>
</exclusions>
</dependency>
2. application.yml 增加配置
ac_forum
是工程名,后文中可以忽略其具体含义。
project:
tencent:
cos:
// 腾讯云 API 密钥的 secretId
secretId: ${ac_forum_tencent_cos_secretId}
// 腾讯云 API 密钥的 secretKey
secretKey: ${ac_forum_tencent_cos_secretKey}
// 存储桶名, 如 ac-forum-xxxxxxxx
bucketName: ${ac_forum_tencent_cos_bucketName}
// 存储桶资源前缀, 如 qcs::cos:ap-nanjing:uid/xxxxxxxx:ac-forum-xxxxxxxx/
bucketResourcePrepend: ${ac_forum_tencent_cos_bucketResourcePrepend}
// 存储桶地域, 如 ap-nanjing
region: ${ac_forum_tencent_cos_region}
// 外部访问域名, 可以填写配置好的加速域名/自定义源站域名
// 也可以填写默认的访问域名,不过不推荐,例如 https://ac-forum-xxxxxxxx.cos.ap-nanjing.myqcloud.com/
objectUrlPrepend: ${ac_forum_tencent_cos_objectUrlPrepend}
3. 开发 COS 服务类
@Slf4j
@Getter
@Service
public class CosService {
@Value("${project.tencent.cos.secretId}")
String secretId;
@Value("${project.tencent.cos.secretKey}")
String secretKey;
@Value("${project.tencent.cos.bucketName}")
String bucketName;
@Value("${project.tencent.cos.region}")
String region;
@Value("${project.tencent.cos.bucketResourcePrepend}")
String bucketResourcePrepend;
@Value("${project.tencent.cos.objectUrlPrepend}")
String objectUrlPrepend;
// 运行环境
@Value("${spring.profiles.active}")
String env;
COSClient cosClient;
@Async
@PostConstruct
public void initCosClient() {
COSCredentials cred = new BasicCOSCredentials(secretId, secretKey);
ClientConfig clientConfig = new ClientConfig();
clientConfig.setRegion(new Region(region));
clientConfig.setHttpProtocol(HttpProtocol.https);
cosClient = new COSClient(cred, clientConfig);
log.info("Tencent.COS -- 连接初始化成功");
}
// 用于发放公钥
public Credentials getCosAccessAuthorization(Integer expireTime, CosActions allowActions, List<String> allowResources) {
TreeMap<String, Object> configuration = new TreeMap<>();
configuration.put("secretId", secretId);
configuration.put("secretKey", secretKey);
Policy policy = new Policy();
// 因为正式环境也部署在腾讯云服务器上,所以如果在正式环境就可以使用内网加速域名
configuration.put("host", "sts.internal.tencentcloudapi.com");
if ("dev".equals(env)) {
// 如何是测试环境使用默认公网域名(不设置 host 就默认公网)
configuration.remove("host");
}
// 密钥过期时间最长 2h ,根据实际需求确定
if (expireTime > 60 * 60 * 2) {
log.warn("COS临时密钥时长非法: {}", expireTime);
throw new ForumIllegalArgumentException("系统错误,请稍后再试");
}
configuration.put("durationSeconds", expireTime);
configuration.put("bucket", bucketName);
configuration.put("region", region);
Statement statement = new Statement();
statement.setEffect("allow");
// 允许的操作
statement.addActions(allowActions.getActions());
// 允许访问的资源
for (String allowResource : allowResources) {
statement.addResource(bucketResourcePrepend + allowResource);
}
policy.addStatement(statement);
configuration.put("policy", Jackson.toJsonPrettyString(policy));
try {
Response response = CosStsClient.getCredential(configuration);
log.info("COS临时密钥生成成功: 过期时间 {} min, 允许操作 {}, 允许资源 {}", expireTime / 60, allowActions.getActions(), allowResources);
return response.credentials;
}
catch (IOException e) {
log.error("向COS服务器请求临时密钥时发生错误: {}", e.getMessage());
throw new ForumException("系统错误,请稍后再试");
}
}
// 这是一个快速放行公共资源的方法,CosAuthorizationVO 将在稍后介绍
public CosAuthorizationVO getPublicResourcesReadAuthorization() {
Integer expireTime = 60 * 60;
Credentials credentials = getCosAccessAuthorization(expireTime, CosActions.GET_OBJECT, List.of(
// 我这里放行了所有头像、文章图片等资源
CosFolderPath.AVATAR + "*",
CosFolderPath.TOPIC_AVATAR + "*",
CosFolderPath.ARTICLE_IMAGE + "*"
));
return CosAuthorizationVO.publicResourcesAuthorization(credentials, expireTime, bucketName, region);
}
public boolean objectExists(String key) {
try {
boolean exists = cosClient.doesObjectExist(bucketName, key);
log.debug("检测文件 {} 存在性: {}", bucketName + "/" + key, exists);
return exists;
} catch (CosClientException e) {
log.error("请求 COS 服务器时发生错误: {}", e.getMessage());
throw new ForumException("系统错误,请稍后再试");
}
}
}
3.1 CosFolderPath.class
保存常用的路径,仅供参考。
@Getter
@AllArgsConstructor
public enum CosFolderPath {
AVATAR("avatar/"),
TOPIC_AVATAR("topic/avatar/"),
ARTICLE_IMAGE("article/image/"),
CHAT_IMAGE("chat/image/");
private final String path;
@Override
public String toString() {
return this.path;
}
}
3.2 CosActions.class
对于一些常用的操作,所需要的权限。
访问下方网址查询所有操作对应的权限:
https://cloud.tencent.com/document/product/436/7738
@Getter
@AllArgsConstructor
public enum CosActions {
// 上传文件
PUT_OBJECT(new String[]{
"cos:PutObject",
"cos:InitiateMultipartUpload",
"cos:ListMultipartUploads",
"cos:ListParts",
"cos:UploadPart",
"cos:CompleteMultipartUpload",
"cos:AbortMultipartUpload"
}),
// 下载文件
GET_OBJECT(new String[]{
"cos:GetObject"
}),
// 判断文件是否存在
OBJECT_EXSIST(new String[]{
"cos:HeadObject"
});
private final String[] actions;
}
3.3 CosAuthorizationVO.class
这是返回给前端的授权类,前端拿到这些参数便可以访问 COS 资源。
@Data
@AllArgsConstructor
@JsonInclude(value = JsonInclude.Include.NON_NULL)
public class CosAuthorizationVO {
String secretId;
String secretKey;
String securityToken;
Long startTime;
Long expiredTime;
String bucket;
String region;
String prefix;
String key;
// 按照资源路径前缀匹配
public static CosAuthorizationVO prefixAuthorization(Credentials credentials, Integer expiredSeconds, String bucket, String region, String prefix) {
Date date = new Date();
return new CosAuthorizationVO(
credentials.tmpSecretId,
credentials.tmpSecretKey,
credentials.sessionToken,
date.getTime() / 1000L,
date.getTime() / 1000L + expiredSeconds,
bucket,
region,
prefix,
null
);
}
// 按照具体资源匹配
public static CosAuthorizationVO keyAuthorization(Credentials credentials, Integer expiredSeconds, String bucket, String region, String key) {
Date date = new Date();
return new CosAuthorizationVO(
credentials.tmpSecretId,
credentials.tmpSecretKey,
credentials.sessionToken,
date.getTime() / 1000L,
date.getTime() / 1000L + expiredSeconds,
bucket,
region,
null,
key
);
}
// 全部匹配
public static CosAuthorizationVO publicResourcesAuthorization(Credentials credentials, Integer expiredSeconds, String bucket, String region) {
Date date = new Date();
return new CosAuthorizationVO(
credentials.tmpSecretId,
credentials.tmpSecretKey,
credentials.sessionToken,
date.getTime() / 1000L,
date.getTime() / 1000L + expiredSeconds,
bucket,
region,
null,
null
);
}
}
4. 调用写好的 Service
仅供参考。
public CosAuthorizationVO getImageMessageUploadAuthorization(Integer uid, Integer chatId) {
// 业务逻辑 ...
Integer expireSeconds = 60;
String imageKey;
// 生成存放图片的地址,如果 uuid 重复了就重新生成
do {
imageKey = CosFolderPath.CHAT_IMAGE + chatId.toString() + "/" + UUID.randomUUID().toString().replaceAll("-", "").toUpperCase() + ".png";
} while (cosService.objectExists(imageKey));
// 对于具体文件授权
Credentials credentials = cosService.getCosAccessAuthorization(
expireSeconds, CosActions.PUT_OBJECT, List.of(imageKey)
);
log.info("用户 {} 请求了图片 {} 上传权限", uid, imageKey);
return CosAuthorizationVO.keyAuthorization(credentials, expireSeconds, cosService.getBucketName(), cosService.getRegion(), imageKey);
}
public CosAuthorizationVO getImageMessageDownloadAuthorization(Integer uid, Integer chatId) {
// 业务逻辑 ...
Integer expireSeconds = 60 * 60;
String prefix = CosFolderPath.CHAT_IMAGE + chatId.toString() + "/*";
// 对于某个目录下一次性授权
Credentials credentials = cosService.getCosAccessAuthorization(
expireSeconds, CosActions.GET_OBJECT, List.of(prefix)
);
log.info("用户 {} 请求了图片 {}/* 查看权限", uid, prefix);
return CosAuthorizationVO.prefixAuthorization(credentials, expireSeconds, cosService.getBucketName(), cosService.getRegion(), prefix);
}
5. 前端调用服务
// 下载
// 回调中会返回文件的临时 url
export const getObjectUrl = (cosAuthorization, callback, oringinal = false) => {
return new COS({
SecretId: cosAuthorization["secretId"],
SecretKey: cosAuthorization["secretKey"],
SecurityToken: cosAuthorization["securityToken"],
StartTime: cosAuthorization["startTime"],
ExpiredTime: cosAuthorization["expiredTime"],
}).getObjectUrl(
{
Bucket: cosAuthorization["bucket"],
Region: cosAuthorization["region"],
Key: cosAuthorization["key"],
// 是否开启图片压缩,需要的话需要先开通数据万象图片处理服务
QueryString: oringinal ? "" : "imageMogr2/format/webp",
},
(err, data) => {
if (err !== null) {
ElNotification({title: "服务器错误", type: "error", message: "存储服务发生错误"});
} else {
callback(data.Url);
}
}
);
}
// 上传
// 回调中会返回文件的临时 url
export const uploadObject = (cosAuthorization, file, callback) => {
return new COS({
SecretId: cosAuthorization["secretId"],
SecretKey: cosAuthorization["secretKey"],
SecurityToken: cosAuthorization["securityToken"],
StartTime: cosAuthorization["startTime"],
ExpiredTime: cosAuthorization["expiredTime"],
}).uploadFile(
{
Bucket: cosAuthorization["bucket"],
Region: cosAuthorization["region"],
Key: cosAuthorization["key"],
Body: file
},
(err) => {
if (err !== null) {
ElNotification({title: "服务器错误", type: "error", message: "存储服务发生错误"});
} else {
callback();
}
}
)
}