acautomaton
acautomaton
Published on 2025-08-16 / 2 Visits
0
0

Spring Boot 接入腾讯云 COS 对象存储 [私有读写]

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();
            }
        }
    )
}

Comment