文件上传有两种,一种是给后端传腾讯云cos,另一种是前端值传腾讯云cos,后端相对安全,密钥不会被盗取,前端就直接暴露了。那么可以考虑通过永久性密钥申请临时密钥给前端,让前端通过临时密钥直传文件,这样能极大减轻服务器压力
springboot的yml配置(假的参数,可以根据自己的修改)
# 腾讯云COS
tencent:
cos:
# 密钥id
secretId: AKID4zn3rsmSvGjisSDuOehPyRjfIsoY4M
# 密钥key
secretKey: 96qMeaFjz2qQun5ThjXAZkm2PYSsi
# 所属区域
region: ap-guangzhou
# 存储桶名称
bucketName: jrauto-1331731126
# COS存储文件夹
folder: /cars/
# 访问地址
webUrl: https://jrauto-1331731126.cos.ap-guangzhou.myqcloud.com
durationSeconds: 1800 # 签名有效时间
创建 STS 服务类
创建一个服务类来封装获取临时密钥的逻辑。
package com.jrauto.CarAppBackend.service;
/**
* @author : huiMing
* Date : 2025年07月04日 15:16
* @version V1.0
*/
@Service
@Slf4j
public class StsService {
@Value("${tencent.cos.secretId}")
private String secretId;
@Value("${tencent.cos.secretKey}")
private String secretKey;
@Value("${tencent.cos.bucketName}")
private String bucketName;
@Value("${tencent.cos.region}")
private String region;
@Value("${tencent.cos.durationSeconds}")
private int durationSeconds;
public Response getTempCredentials() {
TreeMap<String, Object> config = new TreeMap<>();
try {
config.put("secretId", secretId);
config.put("secretKey", secretKey);
config.put("durationSeconds", durationSeconds);
config.put("bucket", bucketName);
config.put("region", region);
Policy policy = new Policy();
Statement statement = new Statement();
statement.setEffect("allow");
// 权限列表
statement.addActions(new String[]{
"cos:PutObject",
"cos:PostObject",
"cos:InitiateMultipartUpload",
"cos:ListMultipartUploads",
"cos:ListParts",
"cos:UploadPart",
"cos:CompleteMultipartUpload",
"ci:CreateMediaJobs",
"ci:CreateFileProcessJobs"
});
// 资源表达式,允许访问所有对象
statement.addResources(new String[]{
String.format("qcs::cos:%s:uid/%s:%s/*", region, bucketName.split("-")[1], bucketName),
String.format("qcs::ci:%s:uid/%s:bucket/%s/*", region, bucketName.split("-")[1], bucketName)
});
policy.addStatement(statement);
config.put("policy", Jackson.toJsonPrettyString(policy));
return CosStsClient.getCredential(config);
} catch (Exception e) {
// log.error("获取临时密钥失败: {}", e.getMessage(), e);
throw new RuntimeException("Failed to get temporary credentials: " + e.getMessage());
}
}
}
创建 REST 控制器
创建一个 REST 控制器来暴露获取临时密钥的接口
@RestController
@RequestMapping("/cos")
@CrossOrigin(origins = "*") // 允许所有源进行跨域请求,实际项目中请限制为你的前端域名
public class CosController {
@Resource
private StsService stsService;
@GetMapping("/getTempCredentials")
public Map<String, Object> getTempCredentials() {
Map<String, Object> result = new HashMap<>();
try {
Response response = stsService.getTempCredentials();
result.put("code", 0);
result.put("message", "Success");
Map<String, String> credentials = new HashMap<>();
credentials.put("tmpSecretId", response.credentials.tmpSecretId);
credentials.put("tmpSecretKey", response.credentials.tmpSecretKey);
credentials.put("sessionToken", response.credentials.sessionToken);
credentials.put("startTime", String.valueOf(response.startTime));
credentials.put("expiredTime", String.valueOf(response.expiredTime));
result.put("data", credentials);
} catch (RuntimeException e) {
result.put("code", -1);
result.put("message", e.getMessage());
}
return result;
}
}
api测试工具
例如新建一个 utils/cos.js,项目内全局使用:
const util = require('./cos-wx-sdk-v5.min.js'); // 开发时使用
// const COS = require('./lib/cos-wx-sdk-v5.min.js'); // 上线时使用压缩包
const cos = new util({
SimpleUploadMethod: 'putObject', // 强烈建议,高级上传、批量上传内部对小文件做简单上传时使用putObject,sdk版本至少需要v1.3.0
getAuthorization: function (options, callback) {
// 初始化时不会调用,只有调用 cos 方法(例如 cos.putObject)时才会进入
// 异步获取临时密钥
// 服务端 JS 示例:https://github.com/tencentyun/cos-js-sdk-v5/blob/master/server/
// 服务端其他语言参考 COS STS SDK :https://github.com/tencentyun/qcloud-cos-sts-sdk
// STS 详细文档指引看:https://cloud.tencent.com/document/product/436/14048
const stsUrl = 'http://127.0.0.1:8089/api/cos/getTempCredentials'; // stsUrl 替换成您自己的后端服务
wx.request({
url: stsUrl,
data: {
bucket: 'jrauto-13638',
region: 'ap-guangzhou',
},
dataType: 'json',
success: function (result) {
// console.log(result.data);
const credentials = result.data.data;
// const credentials = data && data.credentials;
// if (!data || !credentials) return console.error('credentials invalid');
// 检查 credentials 格式
console.log(credentials);
callback({
TmpSecretId: credentials.tmpSecretId,
TmpSecretKey: credentials.tmpSecretKey,
// v1.2.0之前版本的 SDK 使用 XCosSecurityToken 而不是 SecurityToken
SecurityToken: credentials.sessionToken,
// 建议返回服务器时间作为签名的开始时间,避免用户浏览器本地时间偏差过大导致签名错误
StartTime: credentials.startTime, // 时间戳,单位秒,如:1580000000
ExpiredTime: credentials.expiredTime, // 时间戳,单位秒,如:1580000900
});
}
});
}
});
export default cos;
uniapp批量上传源码
<template>
<view class="upload-container">
<button class="upload-button" @click="selectAndUploadImages" :disabled="isUploading">
{{ isUploading ? '正在上传...' : '选择图片并上传' }}
</button>
<view v-if="globalUploadProgress > 0 && globalUploadProgress < 100" class="progress-bar-container">
<view class="progress-bar" :style="{ width: globalUploadProgress + '%' }"></view>
<text class="progress-text">总进度: {{ globalUploadProgress.toFixed(2) }}%</text>
</view>
<view v-if="isUploading && globalUploadProgress === 100" class="upload-status">
处理中...
</view>
<view class="image-list">
<view v-for="(image, index) in uploadedImages" :key="index" class="image-item">
<image :src="image.url" mode="aspectFill" class="uploaded-image"></image>
<view v-if="image.progress !== 100" class="image-progress-overlay">
<text>{{ image.progress.toFixed(0) }}%</text>
</view>
<text v-if="image.error" class="image-status-error">失败</text>
<text v-else-if="image.progress === 100 && !image.error" class="image-status-success">完成</text>
</view>
</view>
<view v-if="message" :class="['message-box', messageType]">
{{ message }}
</view>
</view>
</template>
<script setup>
import { ref } from 'vue';
import cos from '@/utils/cos.js'; // 导入你封装好的 cos 实例
// --- 响应式数据 ---
const isUploading = ref(false); // 是否正在上传
const globalUploadProgress = ref(0); // 整体上传进度(0-100)
const uploadedImages = ref([]); // 存储每张图片的状态 { id, url, progress, error }
const uploadedUrls = ref([]); // 存储所有上传成功的图片地址
const message = ref(''); // 提示信息
const messageType = ref(''); // 提示类型: 'success', 'error', 'info'
// --- 方法 ---
// 选择图片并上传多图
const selectAndUploadImages = () => {
if (isUploading.value) {
return; // 避免重复点击
}
wx.chooseImage({
count: 9, // 最多选择9张图片
sizeType: ['original', 'compressed'], // 可以指定是原图还是压缩图
sourceType: ['album', 'camera'], // 可以指定来源
success: async (res) => {
// 重置状态
isUploading.value = true;
globalUploadProgress.value = 0;
uploadedImages.value = [];
uploadedUrls.value = [];
message.value = '';
messageType.value = '';
const filesToUpload = res.tempFiles.map((file, index) => {
const fileName = file.path.substr(file.path.lastIndexOf('/') + 1);
const uniqueKey = `uploads/${Date.now()}_${index}_${fileName}`; // 保证 Key 的唯一性
// 为每张图片初始化状态
uploadedImages.value.push({
id: uniqueKey, // 用 Key 作为唯一标识
url: file.path, // 初始显示本地路径
progress: 0,
error: false,
isFinished: false,
});
return {
Bucket: 'jrauto-1363555616', // **替换成你的实际 Bucket 名称**
Region: 'ap-guangzhou', // **替换成你的实际地域,例如 ap-guangzhou**
Key: uniqueKey,
FilePath: file.path,
// onTaskReady 和 Headers 可以在这里定义,或者使用 uploadFiles 的全局 onFileFinish
};
});
try {
await cos.uploadFiles({
files: filesToUpload,
SliceSize: 1024 * 1024 * 5, // 超过5MB使用分块上传
onProgress: (info) => {
// 整体上传进度
globalUploadProgress.value = parseFloat((info.percent * 100).toFixed(2));
console.log('总进度:', globalUploadProgress.value, '%');
},
onFileFinish: (err, data, options) => {
// 单个文件上传完成/失败
const fileKey = options.Key;
const index = uploadedImages.value.findIndex(item => item.id === fileKey);
if (index !== -1) {
if (err) {
uploadedImages.value[index].error = true;
console.error(`文件 ${options.Key} 上传失败:`, err);
} else {
uploadedImages.value[index].url = data.Location; // 更新为上传后的 COS 地址
uploadedImages.value[index].progress = 100; // 确保显示100%
uploadedImages.value[index].isFinished = true;
uploadedUrls.value.push(data.Location); // 将成功地址添加到列表
console.log(`文件 ${options.Key} 上传成功:`, data.Location);
}
}
},
});
// 所有文件处理完毕
isUploading.value = false;
const failedCount = uploadedImages.value.filter(item => item.error).length;
if (failedCount === 0) {
message.value = `所有图片上传成功!共 ${uploadedUrls.value.length} 张。`;
messageType.value = 'success';
console.log('所有上传成功的图片地址:', uploadedUrls.value);
} else {
message.value = `部分图片上传失败。成功 ${uploadedUrls.value.length} 张,失败 ${failedCount} 张。`;
messageType.value = 'error';
}
} catch (e) {
isUploading.value = false;
message.value = `上传过程中发生错误: ${e.message || '未知错误'}`;
messageType.value = 'error';
console.error('批量上传发生异常:', e);
}
},
fail: (err) => {
isUploading.value = false; // 用户取消也算上传结束
if (err.errMsg === 'chooseImage:fail cancel') {
message.value = '您取消了图片选择。';
messageType.value = 'info';
} else {
message.value = `选择图片失败: ${err.errMsg}`;
messageType.value = 'error';
}
console.error('选择图片失败:', err);
}
});
};
// 你也可以添加其他操作,例如暂停、重启、取消单个上传任务(需要单个任务ID)
// 注意:cos.uploadFiles 的 onFileFinish 不直接提供 taskId,
// 如果你需要细粒度控制,可以考虑在 files 数组的每个对象里定义 onTaskReady 回调来获取 taskId。
// 或者使用 cos.uploadFile 单个调用并管理多个 Promise。
</script>
<style>
.upload-container {
padding: 20px;
display: flex;
flex-direction: column;
align-items: center;
}
.upload-button {
width: 80%;
padding: 10px 0;
margin-bottom: 20px;
background-color: #007aff;
color: white;
border-radius: 5px;
font-size: 16px;
text-align: center;
transition: background-color 0.3s ease;
}
.upload-button[disabled] {
background-color: #a0cfff;
cursor: not-allowed;
}
/* 全局进度条样式 */
.progress-bar-container {
width: 90%;
height: 8px;
background-color: #e0e0e0;
border-radius: 5px;
overflow: hidden;
margin-bottom: 15px;
position: relative;
}
.progress-bar {
height: 100%;
background-color: #4cd964; /* 绿色 */
width: 0%;
border-radius: 5px;
transition: width 0.3s ease-out; /* 动画效果 */
}
.progress-text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 12px;
color: #666;
text-shadow: 0 0 2px white; /* 增加可读性 */
}
.upload-status {
margin-bottom: 15px;
font-size: 14px;
color: #666;
}
/* 图片列表样式 */
.image-list {
display: flex;
flex-wrap: wrap;
gap: 10px; /* 图片之间的间距 */
justify-content: flex-start;
width: 100%;
}
.image-item {
position: relative;
width: calc(33.33% - 7px); /* 每行3个,减去间距 */
height: 100px; /* 固定高度 */
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box; /* 包含 padding 和 border */
}
.uploaded-image {
width: 100%;
height: 100%;
object-fit: cover; /* 保持图片比例覆盖整个区域 */
}
.image-progress-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5); /* 半透明背景 */
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
z-index: 10;
}
.image-status-success {
position: absolute;
bottom: 5px;
right: 5px;
background-color: #4cd964;
color: white;
padding: 2px 5px;
border-radius: 3px;
font-size: 10px;
z-index: 10;
}
.image-status-error {
position: absolute;
bottom: 5px;
right: 5px;
background-color: #ff3b30; /* 红色 */
color: white;
padding: 2px 5px;
border-radius: 3px;
font-size: 10px;
z-index: 10;
}
/* 提示消息样式 */
.message-box {
width: 90%;
padding: 10px;
margin-top: 20px;
border-radius: 5px;
text-align: center;
font-size: 14px;
}
.message-box.success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.message-box.error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.message-box.info {
background-color: #d1ecf1;
color: #0c5460;
border: 1px solid #bee5eb;
}
</style>