第1部分:环境与基本配置
在使用腾讯云对象存储(COS)之前,我们需要在 Spring Boot 项目中完成一些必要的环境准备工作,包括引入依赖、在腾讯云控制台创建访问密钥以及进行一些基础的配置。
1.1 申请腾讯云COS的账户、开通服务及获取密钥
- 如果你还没有腾讯云的账号,先注册一个腾讯云账号并进行实名认证。
- 进入 腾讯云对象存储控制台,创建存储桶(Bucket)。
- 你可以自定义命名你的存储桶,如
my-bucket-1250000000
,注意名称要全局唯一。 - 存储桶所在的区域(Region)需要记住,比如
ap-beijing
、ap-shanghai
等。
- 你可以自定义命名你的存储桶,如
- 在 访问管理控制台 里创建或查看已有的 SecretId 和 SecretKey。
- SecretId 和 SecretKey 会用来对请求进行签名,保证请求合法性。
1.2 引入依赖
腾讯云官方提供了多种 SDK,Java 版本的 SDK 可以通过 Maven 或者 Gradle 引入。以下以 Maven 为例展示如何引入依赖,版本号可以在 官方文档 或 Maven中央仓库 查看并更新。
<dependency>
<groupId>com.qcloud</groupId>
<artifactId>cos_api</artifactId>
<version>5.6.59</version>
</dependency>
注意:版本号可能会更新,请自行查看最新版本。
1.3 Spring Boot 中的配置
在 Spring Boot 项目中,我们可以通过 application.yml
或者 application.properties
来配置 COS 的相关信息,方便后续读取和管理。
1.3.1 application.yml 示例
tencent:
cos:
secret-id: yourSecretId
secret-key: yourSecretKey
region: ap-shanghai
bucket-name: my-bucket-1250000000
secret-id
:就是上面申请到的 SecretIdsecret-key
:对应的 SecretKeyregion
:你创建存储桶时选择的区域bucket-name
:创建的存储桶名称(一般会有 AppId 后缀,比如my-bucket-1250000000
)
1.3.2 编写配置类
为了方便在项目中使用,可以新建一个配置类来读取以上参数并创建一个 COSClient
(官方 SDK 提供的客户端类,用于操作 COS)。
java">package com.example.demo.config;
import com.qcloud.cos.COSClient;
import com.qcloud.cos.ClientConfig;
import com.qcloud.cos.auth.BasicCOSCredentials;
import com.qcloud.cos.region.Region;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class CosConfig {
@Value("${tencent.cos.secret-id}")
private String secretId;
@Value("${tencent.cos.secret-key}")
private String secretKey;
@Value("${tencent.cos.region}")
private String region;
@Bean
public COSClient cosClient() {
// 1. 初始化用户身份信息(secretId, secretKey)。
BasicCOSCredentials cred = new BasicCOSCredentials(secretId, secretKey);
// 2. 设置 bucket 的区域, COS 地域的简称请参阅 https://cloud.tencent.com/document/product/436/6224
ClientConfig clientConfig = new ClientConfig(new Region(region));
// 3. 生成 cos 客户端。
return new COSClient(cred, clientConfig);
}
}
这样我们在需要使用 COSClient
的地方就可以通过 Spring 的依赖注入直接使用了(如 @Autowired
或构造函数注入)。
同时,将 bucketName
放在配置文件中也很常见,可以继续在同一个类或者在其他地方读取:
java">@Value("${tencent.cos.bucket-name}")
private String bucketName;
这样就把“初始化 COSClient”以及“读取配置信息”都抽离到一个配置类中了。
第2部分:基础操作
2.1 上传文件
2.1.1 小文件上传示例
如果文件不是特别大(官方文档中建议单个文件小于 5GB,直接使用 putObject 上传),可以直接使用 putObject
方法上传。常规步骤如下:
java">package com.example.demo.service;
import com.qcloud.cos.COSClient;
import com.qcloud.cos.model.PutObjectRequest;
import com.qcloud.cos.model.PutObjectResult;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.io.File;
@Service
public class CosService {
@Autowired
private COSClient cosClient;
@Value("${tencent.cos.bucket-name}")
private String bucketName;
/**
* 上传文件到 COS
*
* @param localFilePath 本地文件路径,如 "C:/Users/xxx/Desktop/test.jpg"
* @param key 在 COS 上的 key,如 "images/test.jpg"
* @return 文件访问链接(如果是公共读桶,可以直接访问;若是私有读,需要后面另行签名或鉴权)
*/
public String uploadFile(String localFilePath, String key) {
// 1. 准备要上传的文件
File localFile = new File(localFilePath);
// 2. 构造上传请求
PutObjectRequest putObjectRequest = new PutObjectRequest(bucketName, key, localFile);
// 3. 调用 COSClient 的 putObject 方法上传
PutObjectResult putObjectResult = cosClient.putObject(putObjectRequest);
// putObjectResult 里面会有一些 ETag 等信息,如果需要可以进行处理
// 4. 拼接访问链接
// 通常 COS 对象的访问链接格式:
// https://<bucketName>.cos.<region>.myqcloud.com/<key>
String url = String.format("https://%s.cos.%s.myqcloud.com/%s",
bucketName, // my-bucket-1250000000
"ap-shanghai", // 这里要和你的实际地区对应
key
);
return url;
}
}
key
可以视为“在 COS 上的文件路径”。- 这里的
url
如果是公共读的存储桶,就可以直接访问;如果是私有读的存储桶,直接访问会 403 Forbidden,需要在后端生成带签名的 URL 或者使用临时密钥签名来访问(这部分会在访问权限的章节里详细讲解)。
上传的注意点:
- 如果 Key 中包含类似
/
的分隔符,就会看起来像一个目录结构,实际上 COS 里的对象是平面的,不存在真正的“文件夹”概念,但前缀带来的层级感可以让管理更有条理。 - 如果你要覆盖同名文件,直接
putObject
同样的 Key 即可,会被覆盖。 - 若要在后端生成 Key(比如使用 UUID 或按日期命名),可以在后端代码里动态拼接。
2.1.2 大文件(> 5GB)上传
大文件的情况,需要使用分块上传(也叫 Multipart Upload),否则如果直接 putObject
超过5GB会报错。我们在第3部分:大文件高级功能再做详细的流程讲解和代码演示,这里先埋个伏笔,让你知道有这个限制即可。
2.2 下载文件
从 COS 下载文件到本地,可以使用 getObject
方法。以下代码示例演示了从 COS 下载一个对象并保存到本地:
java">public String downloadFile(String key, String localFilePath) {
// localFilePath 如 "C:/Users/xxx/Desktop/downloaded.jpg"
File downFile = new File(localFilePath);
cosClient.getObject(
new com.qcloud.cos.model.GetObjectRequest(bucketName, key),
downFile
);
return "Download success. File saved to: " + localFilePath;
}
下载为流
有时候我们并不想直接写到本地文件,而是需要获取一个 InputStream
在程序里使用(比如直接返回给用户或做其他处理)。可以这样获取:
java">public InputStream downloadFileAsStream(String key) {
com.qcloud.cos.model.S3Object s3Object = cosClient.getObject(bucketName, key);
// 里面封装了文件内容的输入流
return s3Object.getObjectContent();
}
- 拿到流之后,请注意使用完要及时关闭,避免资源泄露。
- 如果是给浏览器下载,需要自己包装成
ResponseEntity<InputStreamResource>
或者其他形式输出给前端,这就属于 Spring MVC 的文件下载范畴了。
2.3 删除文件
删除对象也很简单,只需要调用 deleteObject
方法即可:
java">public void deleteFile(String key) {
cosClient.deleteObject(bucketName, key);
}
如果要一次性删除多条对象,可以使用批量删除的方法 deleteObjects
,它接受一个 DeleteObjectsRequest
对象:
java">public void deleteFiles(List<String> keys) {
DeleteObjectsRequest deleteObjectsRequest = new DeleteObjectsRequest(bucketName);
List<DeleteObjectsRequest.KeyVersion> keyList = keys.stream()
.map(k -> new DeleteObjectsRequest.KeyVersion(k))
.collect(Collectors.toList());
deleteObjectsRequest.setKeys(keyList);
cosClient.deleteObjects(deleteObjectsRequest);
}
2.4 列举文件
列举存储桶中的对象(也叫“罗列”或“查询”),可以通过 listObjects
或 listObjectsV2
方法。常见用途包括:
- 查询某个“目录”(即某个前缀)下的文件列表。
- 获取所有文件的列表并分页。
示例:列举指定前缀(如 images/
)下的对象:
java">public List<String> listFiles(String prefix) {
// prefix: 可以指定一个前缀(相当于"目录"),例如 "images/"
ListObjectsRequest listObjectsRequest = new ListObjectsRequest();
listObjectsRequest.setBucketName(bucketName);
listObjectsRequest.setPrefix(prefix);
// 设置最多列举多少个, 可以按需调整
listObjectsRequest.setMaxKeys(100);
ObjectListing objectListing = cosClient.listObjects(listObjectsRequest);
List<COSObjectSummary> summaryList = objectListing.getObjectSummaries();
List<String> keys = new ArrayList<>();
for (COSObjectSummary summary : summaryList) {
// 获取文件的 key
String key = summary.getKey();
keys.add(key);
}
return keys;
}
- 如果文件很多,需要不断拿到
objectListing.isTruncated()
判断是否还有更多结果,并通过objectListing.getNextMarker()
继续分页获取。也可以使用listObjectsV2
提供的更灵活分页方式。- 也可以不设置前缀
prefix
,直接列举所有对象,但通常会有权限或性能上的考虑,一般我们希望分级管理。
2.5 关于 Key 的设计与访问链接
2.5.1 Key 的设置
- 文件名与目录结构:在 COS 上并没有真正的目录层级,但我们可以在 Key 中使用
/
来模拟目录,比如:images/avatar.jpg
videos/2025/02/06/bigmovie.mp4
- 动态生成 Key:很多时候,为了避免用户上传文件名重复导致冲突,会在后端生成一个随机的文件名或者带时间戳/UUID。例如:
java">String key = "images/" + UUID.randomUUID().toString() + ".jpg";
- Key 长度:官方文档中说明 Key 的最大长度可以达到 1024 字符,基本够用,但尽量不要过度嵌套或过长。
2.5.2 访问链接拼接
默认的访问域名格式是:
https://<bucketName>.cos.<region>.myqcloud.com/<key>
例如:
- bucket:
my-bucket-1250000000
- region:
ap-shanghai
- key:
images/test.jpg
最终链接可写成:
https://my-bucket-1250000000.cos.ap-shanghai.myqcloud.com/images/test.jpg
注意:
- 如果存储桶是公共读或公共读私有写,则这个链接可以直接访问文件。
- 如果存储桶是私有读写,则通过此链接访问时,会返回
403 Forbidden
。必须配合签名或临时密钥(STS)才能访问,后续访问权限部分会详细说明。 - 腾讯云支持自定义域名绑定,如果你有自己的域名并完成了 CNAME 解析,也可以使用自定义域名进行访问。
第3部分:预签名 URL
3.1 预签名 URL 的概念
预签名 URL(Presigned URL) 是指后端基于自己的「SecretId」和「SecretKey」对一个即将访问 COS 对象的 HTTP 请求进行签名,并且在其查询参数中附上签名和有效期等信息,从而生成一个可直接访问 COS 的临时链接。
- 这个链接在指定的过期时间内是有效的(可以使用多次,一直到过期为止),任何拿到此链接的人都能直接对对应的对象执行指定操作(GET/PUT 等),而不需要额外的鉴权或临时密钥。
- 超过过期时间则变为无效,再访问时会返回 403 或者签名过期的错误。
- 如果你想要限制只能下载或者只能上传,就要在生成预签名 URL 时指定对应的 HTTP 方法(
GET
或PUT
)。 - 适用于私有读写的场景:当我们不想让对象长期公开,但又想给某人或前端临时访问/上传的权限,就可以使用预签名 URL。
3.2 预签名 URL 的生成:Java 后端示例
腾讯云官方 Java SDK 提供了一个方法 generatePresignedUrl
来生成该 URL。
3.2.1 代码示例:生成“下载”用的预签名 URL
java">@Service
public class CosPresignedUrlService {
@Autowired
private COSClient cosClient;
@Value("${tencent.cos.bucket-name}")
private String bucketName;
/**
* 生成预签名的下载链接(GET)
* @param key 对象在 COS 上的 key
* @param expirationSeconds 有效时间(秒),例如 300 表示 5 分钟
* @return 预签名 URL
*/
public URL generatePresignedDownloadUrl(String key, int expirationSeconds) {
// 1. 计算过期时间
long currentTimeMillis = System.currentTimeMillis();
Date expirationDate = new Date(currentTimeMillis + expirationSeconds * 1000L);
// 2. 构造预签名请求
GeneratePresignedUrlRequest request =
new GeneratePresignedUrlRequest(bucketName, key, HttpMethodName.GET);
// 可以指定过期时间
request.setExpiration(expirationDate);
// 可选:如果要限制 Content-Type、Content-Disposition 等,也可在这里设置
// request.setContentType("image/jpeg");
// 3. 生成 URL
URL presignedUrl = cosClient.generatePresignedUrl(request);
// 4. 返回给调用方
return presignedUrl;
}
}
- 参数说明:
key
: 需要访问的对象路径,如images/test.jpg
。expirationSeconds
: 此链接在多长时间内有效,单位秒。
- 生成的
URL
可以通过url.toString()
转成字符串返回给前端。
如果是私有读写的桶,普通情况下通过 https://bucket.cos.region.myqcloud.com/key
无法直接访问,会返回 403 Forbidden
;但拿着这个预签名 URL 就可以在有效期内访问。
3.2.2 代码示例:生成“上传”用的预签名 URL
如果你希望前端直接往这个 Key 上传文件,而不是下载,那么只需要把 HttpMethodName.GET
改为 HttpMethodName.PUT
或 HttpMethodName.POST
。
示例:
java">public URL generatePresignedUploadUrl(String key, int expirationSeconds) {
Date expirationDate = new Date(System.currentTimeMillis() + expirationSeconds * 1000L);
GeneratePresignedUrlRequest request =
new GeneratePresignedUrlRequest(bucketName, key, HttpMethodName.PUT);
request.setExpiration(expirationDate);
// 如果对 Content-Type 有要求,可以设置
// request.setContentType("application/octet-stream");
return cosClient.generatePresignedUrl(request);
}
- 拿到该 URL 后,可以在前端用
PUT
方法直接上传文件到该对象。
3.3 前端如何使用预签名 URL
3.3.1 前端拿到“下载”用链接
- 假设后端返回的是一个字符串形式的 URL:
https://my-bucket-xxx.cos.ap-shanghai.myqcloud.com/my-object?sign=xxxxxx...
- 前端拿到后,可以在浏览器地址栏直接访问,或者在代码里用
<a href="presignedUrl" download>下载</a>
,或者在脚本里发起 GET 请求(axios.get(presignedUrl)
/fetch(presignedUrl)
)都可以。 - 在有效期内,该链接可被重复访问多次;过期后再访问会返回 403。
3.3.2 前端拿到“上传”用链接
- 前端可以使用
fetch
或axios
等工具发送一个PUT
请求,将文件二进制数据上传过去。java">const file = ...; // input type="file" 获得的 File 对象 const presignedUrl = "后端返回的预签名URL"; fetch(presignedUrl, { method: 'PUT', body: file, headers: { // 设置你希望的 Content-Type,若后端签名时指定了必须一致 'Content-Type': file.type } }).then(response => { if (response.ok) { console.log("Upload success!"); } else { console.error("Upload failed!", response.status); } });
- 同理,这个链接在有效期内可以使用多次,但同一个 Key被多次 PUT 会导致文件被覆盖;如果你只想让它上传一次,业务上可以在后端再做约束,比如生成后端记录并只允许调用一次等。
3.4 预签名 URL 常见问题解答
3.4.1 预签名 URL 是不是用一次就失效?
不是的。预签名 URL在设定的有效期内是可多次使用的,无论上传或下载;只要不超时,都能成功发起请求。
- 如果需要“只用一次就失效”的效果,需要在后端用逻辑来限制.
- SDK 本身不会限制“只用一次”,它就是一个时间限制的签名。
3.4.2 预签名 URL 的过期时间是多长?
你可以自行设置,最短可以是几秒,最长可到七天(604800秒),这是 COS 的默认限制。如果你设置过长,有安全风险;太短则可能来不及执行操作。需要根据业务场景平衡。
3.4.3 如果文件很大,预签名 URL 能用来做分块上传吗?
- 使用
PUT
的预签名 URL 只能一次性地上传对象,不支持自动多分片。对于小中型文件没有问题,但若文件真的很大(> 5GB)就不合适了。 - 如果需要大文件分块上传 + 前端直传场景,官方推荐使用后端下发 STS 临时密钥,然后前端用 cos-js-sdk-v5 提供的分块上传能力。
- 或者后端每一块都生成一个预签名 URL(对同一个 Key 的不同
partNumber
),然后前端依次 PUT,但实现起来比较繁琐;因此STS 临时密钥方案会更常见、更灵活。
3.4.4 如果签名 URL 在有效期内泄露,怎么办?
- 在有效期内,任何人拿到该 URL 都能访问/上传,所以敏感文件要慎重签发。
- 如果有紧急情况,比如某个链接暴露,你可以立即删除对应的对象或者修改其权限(不过对签名 URL 的过期无效),最彻底的方法是删除对象或更新对象,使原来的预签名失效(因为原 Key 对象不存在或已变更)。
- 因此,预签名 URL适合做临时授权,有效期不要过长,并做好后端管理与审计。
3.4.5 预签名 URL 与临时密钥(STS)的区别
- 预签名 URL:
- 只针对特定的对象(Key)和操作(GET/PUT)。
- 仅在指定时间内有效,不需要前端再算签名。
- 前端拿到后就可以直接发起请求,适用于简单的“下载一个文件”或“上传一个文件”。
- STS 临时密钥:
- 颁发给前端一个包含
tmpSecretId/tmpSecretKey/Token
的鉴权凭证,一段时间内可对指定范围的 Key执行多种操作(上传、下载、删除、列举等)。 - 前端可以用官方 JS SDK 进行分块上传、断点续传、批量操作等更高级的功能。
- 控制更灵活,对应的策略也可以写得很精细。
- 颁发给前端一个包含
总结:
- 如果只是一张图片或一个小文件,需要一次下载/上传,用预签名 URL非常方便。
- 如果你需要大量、频繁、灵活的操作,以及分块上传等功能,则STS 临时密钥更适合。
第四部分:在微信小程序前端使用预签名 URL 分片上传到腾讯云 COS
后端负责:
- 调用 COS 的 InitiateMultipartUpload,拿到
uploadId
。- 计算分片数量/大小后,依次为每个
partNumber
生成一个 PUT 预签名 URL。- 返回这些预签名 URL 给前端。
- 前端所有分片上传完毕后,调用后端的“完成合并”接口,后端执行 CompleteMultipartUpload。
- 返回最终的访问地址给前端或存入数据库。
前端负责:
- 读取文件并切分为多个分片,每个分片对应一个预签名 URL。
- 通过 wx.request (PUT) 将分片数据直传到 COS(不经过后端)。
- 失败后可重试单个分片,不用重新上传全部。
- 全部分片上传成功后,调用后端“完成合并”接口,让后端调用 COS 进行合并。
4.1 后端:三个核心接口
我们要提供给前端三个主要 API:
- Init:初始化分片上传,获取
uploadId
和每个分块的预签名 URLPOST /initiate-upload?fileName=xxx&fileSize=xxx
- Complete:完成分片上传
POST /complete-upload
- (可选)Abort:如果需要中止上传,可以提供
POST /abort-upload
(本文不做展开)
4.1.1 Init接口:InitiateMultipartUpload + 生成预签名链接
示例代码:
java">@RestController
@RequestMapping("/api/upload")
public class MultipartUploadController {
@Autowired
private COSClient cosClient;
@Value("${tencent.cos.bucket-name}")
private String bucketName;
// ===================== 1. INITIATE UPLOAD ===================== //
@PostMapping("/initiate-upload")
public InitMultipartUploadResponse initiateUpload(
@RequestParam String fileName,
@RequestParam long fileSize
) {
// 1. 计算对象在 COS 上的存储 key
// 这里可以根据业务需求自定义生成,例如加上日期、用户ID等
// 也可以直接使用 fileName。注意如果多个用户上传相同名字会覆盖,因此一般用UUID或别的规则
String cosKey = "uploads/" + System.currentTimeMillis() + "_" + fileName;
// 2. 发起分块上传请求,拿到 uploadId
InitiateMultipartUploadRequest request = new InitiateMultipartUploadRequest(bucketName, cosKey);
InitiateMultipartUploadResult result = cosClient.initiateMultipartUpload(request);
String uploadId = result.getUploadId();
// 3. 确定分片大小和分片数量(前后端都要一致)
// 一般每片 5MB~10MB 较常见。此处举例 5MB
long partSize = 5L * 1024L * 1024L; // 5MB
int partCount = (int) (fileSize / partSize);
if (fileSize % partSize != 0) {
partCount++;
}
// 4. 为每个分块生成 PUT 预签名 URL(后端一次性生成并返回给前端)
List<PartInfo> partList = new ArrayList<>();
for (int i = 1; i <= partCount; i++) {
// 分块序号必须从 1 开始递增
int partNumber = i;
// 过期时间,可自定义,这里示例设为1小时
Date expiration = new Date(System.currentTimeMillis() + 3600 * 1000);
// 构造预签名请求
GeneratePresignedUrlRequest urlRequest = new GeneratePresignedUrlRequest(
bucketName,
cosKey,
HttpMethodName.PUT
);
urlRequest.setExpiration(expiration);
// 这里要指定分块参数 partNumber & uploadId
// COS 在接收分片上传时,需要这两个信息
urlRequest.addRequestParameter("partNumber", String.valueOf(partNumber));
urlRequest.addRequestParameter("uploadId", uploadId);
// 生成预签名 URL
URL presignedUrl = cosClient.generatePresignedUrl(urlRequest);
// 封装返回给前端
PartInfo partInfo = new PartInfo();
partInfo.setPartNumber(partNumber);
partInfo.setPresignedUrl(presignedUrl.toString());
partList.add(partInfo);
}
// 5. 返回给前端
InitMultipartUploadResponse resp = new InitMultipartUploadResponse();
resp.setBucket(bucketName);
resp.setKey(cosKey);
resp.setUploadId(uploadId);
resp.setPartSize(partSize);
resp.setPartCount(partCount);
resp.setPartInfos(partList);
return resp;
}
// 响应体示例
public static class InitMultipartUploadResponse {
private String bucket;
private String key;
private String uploadId;
private long partSize;
private int partCount;
private List<PartInfo> partInfos;
// getters/setters ...
}
public static class PartInfo {
private int partNumber;
private String presignedUrl;
// getters/setters ...
}
}
解释:
- InitiateMultipartUploadRequest:向 COS 发起一个分块上传请求,会返回一个
uploadId
。这是后续所有分块上传和最终合并都需要的。 - partNumber:COS 要求分块编号从 1 开始连续递增,最多 10000 块。
- generatePresignedUrl:通过添加请求参数
partNumber
与uploadId
,来告诉 COS “这是第几块,该块的 uploadId 是多少”。 - 分片大小:示例里用 5MB,真实场景可根据文件大小、用户网络情况自行决定;COS 要求单块最小 1MB(最后一块可以小于 1MB)。
- 返回给前端一个 JSON,包含:
uploadId
- 每个分片对应的
PUT
预签名 URL - 分片大小
partSize
和分片总数partCount
(前后端要用同样的分片逻辑)
4.1.2 Complete接口:合并分片
分片全部上传成功后,前端会把每个分片的 ETag
上传结果告诉后端,然后后端调用 CompleteMultipartUpload
来完成合并。
java">@RestController
@RequestMapping("/api/upload")
public class MultipartUploadController {
@PostMapping("/complete-upload")
public CompleteMultipartUploadResponse completeUpload(
@RequestParam String key,
@RequestParam String uploadId,
@RequestBody List<UploadedPart> parts
) {
// 1. 构造 COS 需要的 ETag 列表
// parts 中包含 (partNumber, eTag)
List<PartETag> partETags = new ArrayList<>();
for (UploadedPart p : parts) {
// 顺序要保证 partNumber 从小到大,通常前端就按顺序传回来
partETags.add(new PartETag(p.getPartNumber(), p.getETag()));
}
// 2. 发起 CompleteMultipartUpload
CompleteMultipartUploadRequest completeReq =
new CompleteMultipartUploadRequest(bucketName, key, uploadId, partETags);
CompleteMultipartUploadResult compResult = cosClient.completeMultipartUpload(completeReq);
// 3. 获取最终访问地址
String finalUrl = compResult.getLocation();
// 4. 返回给前端,或存数据库
CompleteMultipartUploadResponse resp = new CompleteMultipartUploadResponse();
resp.setLocation(finalUrl);
resp.setKey(key);
return resp;
}
public static class UploadedPart {
private int partNumber;
private String eTag;
// getters/setters ...
}
public static class CompleteMultipartUploadResponse {
private String key;
private String location;
// getters/setters ...
}
}
解释:
- 前端在每次分块上传成功时,COS 会返回一个
ETag
(或通过wx.request
的响应头获取),前端要保存这些ETag
。 - 当所有分块都上传完成后,前端把所有
(partNumber, ETag)
的列表传给后端。 - 后端调用
completeMultipartUpload
,COS 会把所有已上传的分块合并为一个完整对象。 - 合并后得到一个
location
字段,即对象最终的访问地址,比如:https://my-bucket-1250000000.cos.ap-shanghai.myqcloud.com/uploads/167575121231_myvideo.mp4
。 - 你可以将这个地址存到数据库里,或者以 JSON 形式返回给前端。
4.2 微信小程序前端示例
下面是一个示例,演示怎么在微信小程序端实现分片、请求预签名 URL、逐片 PUT 上传、并在最后完成合并的流程。
注意:小程序里读取本地文件可以用
wx.chooseMessageFile
或wx.chooseMedia
来获得文件临时路径,然后用wx.getFileSystemManager()
读取文件的 ArrayBuffer。请根据小程序 API 版本自行检查。
4.2.1 前端数据结构
java">// 用于记录每个分块的信息
class PartItem {
constructor(partNumber, start, end, url) {
this.partNumber = partNumber; // 分块序号
this.start = start; // 文件分块起始位置(字节)
this.end = end; // 文件分块结束位置(字节)
this.url = url; // 对应的预签名 PUT URL
this.etag = ""; // 上传成功后,从响应中解析的 ETag
}
}
4.2.2 核心流程
- 选择文件 -> 读取文件信息(大小、名称)
- 向后端发起 /initiate-upload 请求 -> 得到
uploadId
、partSize
、partCount
、以及每个 part 的presignedUrl
。 - 本地切片 -> 用小程序的
FileSystemManager.readFile
读取 ArrayBuffer,并按partSize
切分。 - 逐片/并行上传:对每个 part 调用
wx.request
(method=PUT),上传到其对应的presignedUrl
。 - 记录 ETag:COS 返回
ETag
,存到partItem.etag
。 - 所有分片成功后 -> 调用后端 /complete-upload,携带
uploadId
、key
、以及所有(partNumber, ETag)
。 - 后端执行合并,返回最终访问地址。前端提示成功,或做后续业务逻辑。
4.2.3 代码示例
下面是一段相对完整的示例(可根据自己项目需求来改写),演示在小程序端如何实现这些步骤。
假设:
- 服务器地址为
https://example.com
- 我们在
initiateUpload()
和completeUpload()
接口里,分别对应/api/upload/initiate-upload
和/api/upload/complete-upload
。
java">Page({
data: {
fileName: '',
fileSize: 0,
tempFilePath: '', // 小程序选择的临时文件路径
partSize: 5 * 1024 * 1024, // 后端约定的分片大小(从后端返回为准,这里只是演示)
partList: [], // PartItem数组
key: '',
uploadId: '',
uploadProgress: 0, // 总体上传进度
},
// 1. 选择文件
chooseFile() {
wx.chooseMessageFile({
count: 1,
type: 'file',
success: (res) => {
const file = res.tempFiles[0];
this.setData({
fileName: file.name,
fileSize: file.size,
tempFilePath: file.path,
});
}
});
},
// 2. INIT接口:后端返回uploadId、partInfos
initiateUpload() {
if (!this.data.fileName) {
wx.showToast({ title: '请先选择文件', icon: 'none' });
return;
}
// 调用后端 /initiate-upload 接口
wx.request({
url: 'https://example.com/api/upload/initiate-upload',
method: 'POST',
data: {
fileName: this.data.fileName,
fileSize: this.data.fileSize,
},
header: { 'content-type': 'application/x-www-form-urlencoded' },
success: (res) => {
if (res.statusCode === 200) {
const resp = res.data;
// 拿到后台返回的 key, uploadId, partSize, partCount, partInfos(每块的 presignedUrl)
this.setData({
key: resp.key,
uploadId: resp.uploadId,
partSize: resp.partSize,
});
// 生成 partList 数组(也可以直接用后端返回的partInfos)
const partList = [];
resp.partInfos.forEach((info) => {
const partItem = new PartItem(info.partNumber, 0, 0, info.presignedUrl);
partList.push(partItem);
});
this.setData({ partList });
wx.showToast({ title: 'Init 分片成功', icon: 'none' });
} else {
wx.showToast({ title: 'Init 分片失败', icon: 'none' });
}
},
fail: () => {
wx.showToast({ title: '网络错误', icon: 'none' });
}
});
},
// 3. 分片并上传
uploadFileInParts() {
const { tempFilePath, fileSize, partSize, partList } = this.data;
if (!tempFilePath || !partList.length) {
wx.showToast({ title: '请先 INIT', icon: 'none' });
return;
}
// 读取整个文件为 ArrayBuffer
const fs = wx.getFileSystemManager();
fs.readFile({
filePath: tempFilePath,
success: (readRes) => {
const arrayBuffer = readRes.data; // 全文件数据
// 切片 & 上传
this._uploadParts(arrayBuffer, fileSize, partSize, partList);
},
fail: (e) => {
console.error(e);
wx.showToast({ title: '读取文件失败', icon: 'none' });
}
});
},
// 内部方法:切分并上传每个块
_uploadParts(arrayBuffer, fileSize, partSize, partList) {
let offset = 0;
for (let i = 0; i < partList.length; i++) {
const partItem = partList[i];
const start = offset;
let end = start + partSize;
if (end > fileSize) end = fileSize;
offset = end;
// 记录到 partItem,方便后续调试
partItem.start = start;
partItem.end = end;
}
// 这里示例用 "并行" or "串行" 均可,看需求
this._uploadPartSerial(arrayBuffer, 0);
// 或使用 Promise.all 并行上传 _uploadPartParallel(...) 也行
},
// 串行上传(简单演示,可控错误处理;并行逻辑更复杂)
_uploadPartSerial(arrayBuffer, index) {
const { partList } = this.data;
if (index >= partList.length) {
// 全部分片上传结束
wx.showToast({ title: '分片全部上传成功', icon: 'none' });
return;
}
const partItem = partList[index];
const sliceData = arrayBuffer.slice(partItem.start, partItem.end); // 截取分片
const url = partItem.url;
// PUT 上传
wx.request({
url: url, // 预签名URL
method: 'PUT',
data: sliceData,
header: {
'Content-Type': 'application/octet-stream', // 必须是二进制流
},
success: (res) => {
// COS 会在响应头中带 ETag
const eTag = res.header['ETag'] || res.header['Etag'];
partItem.etag = eTag;
// 更新进度
const progress = Math.floor(((index + 1) / partList.length) * 100);
this.setData({ uploadProgress: progress });
console.log(`Part ${partItem.partNumber} uploaded, ETag=${eTag}`);
// 递归上传下一个分片
this._uploadPartSerial(arrayBuffer, index + 1);
},
fail: (err) => {
console.error(err);
wx.showToast({ title: `Part ${partItem.partNumber} 上传失败`, icon: 'none' });
}
});
},
// 4. 全部分片上传完毕 -> 调用 /complete-upload
completeUpload() {
const { key, uploadId, partList } = this.data;
if (!uploadId || !partList.length) {
wx.showToast({ title: '还未init或上传', icon: 'none' });
return;
}
// 构造后端需要的 ETag 信息
const partsParam = partList.map(p => ({
partNumber: p.partNumber,
eTag: p.etag,
}));
wx.request({
url: 'https://example.com/api/upload/complete-upload',
method: 'POST',
data: {
key: key,
uploadId: uploadId,
parts: partsParam,
},
header: {
'content-type': 'application/json',
},
success: (res) => {
if (res.statusCode === 200) {
wx.showToast({ title: '合并成功', icon: 'none' });
console.log('Final Location:', res.data.location);
} else {
wx.showToast({ title: '合并失败', icon: 'none' });
}
},
fail: (err) => {
console.error(err);
wx.showToast({ title: '请求失败', icon: 'none' });
}
});
}
});
几点说明:
- 分片读取:
arrayBuffer.slice(partItem.start, partItem.end)
得到对应字节区间的数据。- 注意要把
Content-Type
设为'application/octet-stream'
或与后端一致的类型。
- 并行与串行:
- 样例里是串行上传,每个分块上传成功后再传下一个,逻辑简单但速度较慢。
- 可以改造成并行(如 Promise.all),同时发起多个请求来加快速度。但要做好失败重试、并发控制等。
- ETag:
- COS 返回的 ETag 一般包含引号,如
"9f6203fa3e9f472b89d9c39e4b0736e7"
, 你可以保存原样,或者去掉引号再传后端都行(后端合并时不区分引号)。
- COS 返回的 ETag 一般包含引号,如
- partSize 和 partCount:
- 与后端协商一致,后台
initiateUpload()
返回的partSize
应该与前端实际分割文件的大小匹配,否则上传可能出错。
- 与后端协商一致,后台
- 失败重试:
- 如果某个分块上传失败,可再次调用 PUT 同一个 URL(在预签名 URL 过期前)进行重试。
- 如果预签名 URL 过期或 uploadId 因故失效,就需要从头再执行
initiate-upload
。
第五部分:从腾讯云 COS 使用预签名 URL 来进行分片下载到微信小程序端
5.1 后端生成预签名 URL 的接口
思路:
- 通常只需要一个带 GET 权限的预签名 URL,就可以对同一个对象多次发起请求(包括带有 Range 头的部分下载)。
- 只要在有效期内、且请求方法(GET)一致,就能成功下载任何 Range的数据分段。
在后端中可以提供一个简单的 API,例如:GET /api/download/presign?key=xxxx
用来返回一个带签名的临时下载链接。
java">@RestController
@RequestMapping("/api/download")
public class PresignedDownloadController {
@Autowired
private COSClient cosClient;
@Value("${tencent.cos.bucket-name}")
private String bucketName;
/**
* 生成预签名的下载URL (GET),可支持 Range 分块下载
*/
@GetMapping("/presign")
public Map<String, String> generatePresignedDownloadUrl(@RequestParam String key) {
// 1. 过期时间,比如 1 小时 (3600秒)
Date expiration = new Date(System.currentTimeMillis() + 3600 * 1000);
// 2. 构造预签名请求
GeneratePresignedUrlRequest req = new GeneratePresignedUrlRequest(bucketName, key, HttpMethodName.GET);
req.setExpiration(expiration);
// 3. 生成 URL
URL presignedUrl = cosClient.generatePresignedUrl(req);
// 4. 返回给前端
Map<String, String> resp = new HashMap<>();
resp.put("presignedUrl", presignedUrl.toString());
return resp;
}
}
说明:
- 这个
presignedUrl
在有效期内可多次使用,用于下载同一个 Key 对象的任何部分(或整个文件)。 - 如果你的存储桶是“私有读”,那用户想直接下载这个对象必须通过签名访问——这个预签名 URL就提供了临时可访问的能力。
- 如果文件较大,前端可以带
Range
头分段下载;也可以直接一次性下载整个文件(不分段)——都共用这一个 URL。
5.2 前端(微信小程序)实现分片下载并合并
5.2.1 整体思路
- 获取文件信息:知道文件大小 (可以由后端告知,也可以前端先通过 HEAD 请求获取
Content-Length
)。 - 确定分块大小(chunkSize),比如 2MB、5MB 等。
- 循环/并行对同一个 presignedUrl 发送带有
Range
头的 GET 请求,获取第 i 块数据。- 请求示例:
Range: bytes=start-end
- 请求示例:
- 拼接每块返回的二进制数据,形成一个完整的 ArrayBuffer。
- 小程序中可把分段数据存到 ArrayBuffer 数组,然后最后合并。
- 或者逐片写入小程序的临时文件,再在所有块结束后得到一个完整文件。
- 保存或预览:
- 在小程序里可以把合并后的 ArrayBuffer 写入临时文件,然后打开或分享等后续操作。
5.2.2 示例:单个预签名 URL + 多次 Range 请求
小程序页面脚本
示例代码如下,演示一个最小可行的流程(串行下载)。你可以根据需要改成并行、多线程等。
javascript">Page({
data: {
presignedUrl: '',
fileKey: 'videos/test.mp4', // 需要下载的对象key
fileSize: 0,
chunkSize: 2 * 1024 * 1024, // 2MB分片大小
totalChunks: 0,
downloadedChunks: 0,
downloadProgress: 0,
fileBufferArray: [], // 存放每块 ArrayBuffer
},
// 1. 向后端请求预签名URL
getPresignedUrl() {
wx.request({
url: 'https://example.com/api/download/presign?key=' + this.data.fileKey,
method: 'GET',
success: (res) => {
if (res.statusCode === 200) {
this.setData({
presignedUrl: res.data.presignedUrl
});
wx.showToast({ title: '获取预签名URL成功' });
} else {
wx.showToast({ title: '请求失败', icon: 'none' });
}
},
fail: () => {
wx.showToast({ title: '网络错误', icon: 'none' });
}
});
},
// 2. 获取文件大小 (可由后端返回,也可以自己HEAD一下)
getFileSize() {
if (!this.data.presignedUrl) {
wx.showToast({ title: '请先获取预签名链接', icon: 'none' });
return;
}
// 发起 HEAD 请求获取 Content-Length
wx.request({
url: this.data.presignedUrl,
method: 'HEAD',
success: (res) => {
if (res.statusCode === 200) {
// 可能在 res.header["Content-Length"] 里
const lengthStr = res.header['Content-Length'];
const size = parseInt(lengthStr, 10);
if (!isNaN(size)) {
this.setData({
fileSize: size
});
wx.showToast({ title: '文件大小: ' + size });
} else {
wx.showToast({ title: '无法获取文件大小', icon: 'none' });
}
} else {
wx.showToast({ title: 'HEAD请求失败', icon: 'none' });
}
},
fail: () => {
wx.showToast({ title: '网络错误', icon: 'none' });
}
});
},
// 3. 分段下载
startChunkDownload() {
const { fileSize, chunkSize } = this.data;
if (fileSize <= 0) {
wx.showToast({ title: '文件大小未知', icon: 'none' });
return;
}
// 计算分块数量
let totalChunks = Math.ceil(fileSize / chunkSize);
this.setData({
totalChunks,
downloadedChunks: 0,
downloadProgress: 0,
fileBufferArray: [],
});
// 串行下载 (从第0块到最后一块)
this.downloadChunkSerial(0);
},
// 串行下载逐块
downloadChunkSerial(index) {
const { totalChunks } = this.data;
if (index >= totalChunks) {
// 全部分块都下载完毕
this.onAllChunksDownloaded();
return;
}
// 计算Range区间
const start = index * this.data.chunkSize;
let end = start + this.data.chunkSize - 1;
if (end >= this.data.fileSize) {
end = this.data.fileSize - 1;
}
const rangeHeader = `bytes=${start}-${end}`;
// 发起请求
wx.request({
url: this.data.presignedUrl,
method: 'GET',
header: {
'Range': rangeHeader,
},
responseType: 'arraybuffer', // 重要:表示返回二进制
success: (res) => {
if (res.statusCode === 206 || res.statusCode === 200) {
// 206 Partial Content | 200 (如果请求的是整个文件)
const chunkData = res.data; // arraybuffer
// 将这块数据存起来
let newFileBufferArray = this.data.fileBufferArray;
newFileBufferArray[index] = chunkData;
// 更新进度
const downloadedChunks = index + 1;
const progress = Math.floor((downloadedChunks / totalChunks) * 100);
this.setData({
fileBufferArray: newFileBufferArray,
downloadedChunks,
downloadProgress: progress
});
// 继续下载下一块
this.downloadChunkSerial(index + 1);
} else {
wx.showToast({ title: '分块下载失败:' + res.statusCode, icon: 'none' });
}
},
fail: (err) => {
wx.showToast({ title: '网络错误:' + err.errMsg, icon: 'none' });
}
});
},
// 4. 所有分块都下载完毕,合并为完整文件
onAllChunksDownloaded() {
// 4.1 合并 ArrayBuffer
const { fileBufferArray, fileSize, totalChunks } = this.data;
// 先计算所有分片总长度
let totalLength = 0;
fileBufferArray.forEach(buf => {
totalLength += buf.byteLength;
});
// 创建一个总长度的 ArrayBuffer
const fullArrayBuffer = new ArrayBuffer(totalLength);
const fullView = new Uint8Array(fullArrayBuffer);
// 依次拷贝
let offset = 0;
for (let i = 0; i < totalChunks; i++) {
const chunk = new Uint8Array(fileBufferArray[i]);
fullView.set(chunk, offset);
offset += chunk.byteLength;
}
// 4.2 写入小程序临时文件
const fs = wx.getFileSystemManager();
const tempFilePath = `${wx.env.USER_DATA_PATH}/downloaded_${Date.now()}.bin`;
// 你可以带扩展名,比如 .mp4/.jpg等
fs.writeFile({
filePath: tempFilePath,
data: fullArrayBuffer,
encoding: 'binary',
success: () => {
wx.showToast({ title: '下载合并完成' });
// 这里可以做:打开文件、预览、或保存到相册(如果是图片/视频)
console.log('File saved to:', tempFilePath);
},
fail: (err) => {
console.error(err);
wx.showToast({ title: '写文件失败', icon: 'none' });
}
});
},
});
代码说明
getPresignedUrl()
: 向后端请求一个可 GET 的预签名 URL,存到this.data.presignedUrl
。getFileSize()
: 用wx.request({ method: 'HEAD' })
来获取文件大小 (Content-Length
) 并保存在fileSize
。你也可以在后端一起返回文件大小信息,这样就不需要额外的 HEAD 步骤。startChunkDownload()
:- 计算
totalChunks = ceil(fileSize / chunkSize)
; - 初始化进度状态;
- 开始执行
downloadChunkSerial(0)
。
- 计算
downloadChunkSerial(index)
:- 计算本块的 Range(
bytes=start-end
),发起请求。 - 由于是串行,每次块下载完成后再进入下一块。
- 若需要并行,可以用类似于分块上传那样的并行池控制,这里就不展开。
- 计算本块的 Range(
- 响应状态码
206
表示分块下载成功 (Partial Content
),如果下载的是整个文件(从头到尾只请求一次),可能就是200
。 onAllChunksDownloaded()
:- 先把所有分块的
ArrayBuffer
依次拼接成一个新的ArrayBuffer
。 - 再调用
fs.writeFile
写入到小程序的USER_DATA_PATH
下生成一个临时文件;你也可以手动再改后缀名(比如.mp4
)方便后续使用。 - 还可以使用
wx.openDocument
(只支持部分格式)或wx.previewMedia
(如果是视频/图片)来做预览,也可以让用户另存。
- 先把所有分块的
关于 Range
请求的说明
- HTTP Range 头格式:
bytes=start-end
- 如果 end 超过文件实际大小,服务器可能返回到文件末尾,并给出 206 或 200 等状态。
- COS 对 Range 请求的支持:一般默认支持,但要注意你生成预签名 URL 时,如果你在签名里限制了特定的请求头,则必须保持一致,否则签名会无效。
- 在常见用法下(
generatePresignedUrl
未对 Range 做特殊限定),同一个预签名 URL 对任何 Range 请求都是合法的。
第六部分:关键文件备份为低频存储
6.1 腾讯云 COS 存储类别概览
存储类型 | 适用场景 | 价格 | 访问时效 | 读取方式 |
---|---|---|---|---|
STANDARD(标准存储) | 适合频繁访问的数据,如网站资源 | 最高 | 即时访问 | 直接 GET |
STANDARD_IA(低频存储) | 适合不经常访问但仍需要快速取回的文件,如备份数据 | 较低 | 即时访问 | 直接 GET |
ARCHIVE(归档存储) | 适合长期不访问的数据,如日志存档 | 最低 | 需解冻,最快1分钟 | 先 restoreObject() 解冻,再 GET |
STANDARD_IA(低频访问)与 STANDARD(标准)相比,存储费用更低,但每次读取会额外产生请求费用。
ARCHIVE(归档)存储费用最低,但读取前必须先解冻(冷数据恢复),适用于长期存档的数据。
6.2 代码示例
java">import com.qcloud.cos.COSClient;
import com.qcloud.cos.model.CopyObjectRequest;
import com.qcloud.cos.model.ObjectMetadata;
import com.qcloud.cos.model.StorageClass;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@Service
public class CosStorageService {
@Autowired
private COSClient cosClient;
@Value("${tencent.cos.bucket-name}")
private String bucketName;
/**
* 迁移文件到低频存储(STANDARD_IA)
* @param key 文件的对象键(路径),例如 "images/sample.jpg"
*/
public void migrateToStandardIA(String key) {
try {
// 1. 创建 CopyObjectRequest (源桶和目标桶都是同一个)
CopyObjectRequest copyRequest = new CopyObjectRequest(bucketName, key, bucketName, key);
// 2. 设置新的存储类别为 STANDARD_IA
ObjectMetadata metadata = new ObjectMetadata();
metadata.setHeader("x-cos-storage-class", StorageClass.Standard_IA.toString());
copyRequest.setNewObjectMetadata(metadata);
// 3. 执行存储类别转换
cosClient.copyObject(copyRequest);
System.out.println("成功迁移文件:" + key + " 到 STANDARD_IA 存储");
} catch (Exception e) {
System.err.println("迁移失败:" + e.getMessage());
}
}
}
-
创建
CopyObjectRequest
java">CopyObjectRequest copyRequest = new CopyObjectRequest(bucketName, key, bucketName, key);
- 源桶 和 目标桶 设置为同一个(即
bucketName, key
复制到bucketName, key
) - 只改变文件存储类别,不改动其他文件属性
- 源桶 和 目标桶 设置为同一个(即
-
设置新存储类别
java">ObjectMetadata metadata = new ObjectMetadata(); metadata.setHeader("x-cos-storage-class", StorageClass.Standard_IA.toString());
x-cos-storage-class
设置为 STANDARD_IA- Tencent COS 服务器会自动处理存储类别变更
-
执行
copyObject()
java">cosClient.copyObject(copyRequest);
- COS API 直接修改存储类别,不会影响文件路径
- 迁移后文件仍然在 相同 Key 下,但存储类别已经变更