springboot整合腾讯云cos对象存储,获取临时密钥,前端直传图片文件

发布于:2025-07-05 ⋅ 阅读:(13) ⋅ 点赞:(0)

文件上传有两种,一种是给后端传腾讯云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>

在这里插入图片描述