package com.cadlabel.service.client; import com.cadlabel.common.exception.BizException; import com.cadlabel.config.CosProperties; import com.qcloud.cos.COSClient; import com.qcloud.cos.ClientConfig; import com.qcloud.cos.auth.BasicCOSCredentials; import com.qcloud.cos.auth.BasicSessionCredentials; import com.qcloud.cos.auth.COSCredentials; import com.qcloud.cos.exception.CosClientException; import com.qcloud.cos.model.ObjectMetadata; import com.qcloud.cos.model.PutObjectRequest; import com.qcloud.cos.region.Region; import com.tencent.cloud.CosStsClient; import com.tencent.cloud.Response; import java.io.IOException; import java.io.InputStream; import java.time.Duration; import java.util.TreeMap; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.DisposableBean; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; @Slf4j @Component public class TencentCosObjectStorageClient implements CosObjectStorageClient, DisposableBean { private static final String[] ALLOWED_ACTIONS = new String[]{ "name/cos:PutObject", "name/cos:InitiateMultipartUpload", "name/cos:ListMultipartUploads", "name/cos:ListParts", "name/cos:UploadPart", "name/cos:CompleteMultipartUpload", "name/cos:AbortMultipartUpload" }; private final CosProperties cosProperties; private final COSClient cosClient; public TencentCosObjectStorageClient(CosProperties cosProperties) { this.cosProperties = cosProperties; this.cosClient = createCosClient(cosProperties); } @Override public TemporaryCredentials issueTemporaryCredentials(String keyPrefix, Duration duration) { validateConfiguration(); int durationSeconds = validateDuration(duration); TreeMap config = new TreeMap<>(); config.put("secretId", cosProperties.secretId()); config.put("secretKey", cosProperties.secretKey()); config.put("bucket", cosProperties.bucket()); config.put("region", cosProperties.region()); config.put("durationSeconds", durationSeconds); config.put("allowPrefixes", new String[]{normalizeKeyPrefix(keyPrefix) + "*"}); config.put("allowActions", ALLOWED_ACTIONS); try { Response response = CosStsClient.getCredential(config); if (response == null || response.credentials == null || !StringUtils.hasText(response.credentials.tmpSecretId) || !StringUtils.hasText(response.credentials.tmpSecretKey) || !StringUtils.hasText(response.credentials.sessionToken)) { throw new BizException(HttpStatus.BAD_GATEWAY.value(), "COS_UPLOAD_CREDENTIALS_FAILED", "COS 未返回完整的临时上传凭证"); } return new TemporaryCredentials( response.credentials.tmpSecretId, response.credentials.tmpSecretKey, response.credentials.sessionToken, response.startTime, response.expiredTime ); } catch (IOException exception) { log.error("Failed to issue COS upload credentials. keyPrefix={}", keyPrefix, exception); throw new BizException(HttpStatus.BAD_GATEWAY.value(), "COS_UPLOAD_CREDENTIALS_FAILED", "申请 COS 临时上传凭证失败: " + exception.getMessage()); } } @Override public void putObject(String objectKey, InputStream inputStream, long contentLength, String contentType) { validateConfiguration(); ObjectMetadata metadata = new ObjectMetadata(); metadata.setContentLength(contentLength); if (StringUtils.hasText(contentType)) { metadata.setContentType(contentType); } try { cosClient.putObject(new PutObjectRequest(cosProperties.bucket(), normalizeKeyPrefix(objectKey), inputStream, metadata)); } catch (CosClientException exception) { log.error("Failed to upload COS object. objectKey={}", objectKey, exception); throw new BizException(HttpStatus.BAD_GATEWAY.value(), "COS_OBJECT_UPLOAD_FAILED", "上传 COS 对象失败: " + exception.getMessage()); } } @Override public void destroy() { cosClient.shutdown(); } private static COSClient createCosClient(CosProperties cosProperties) { String region = StringUtils.hasText(cosProperties.region()) ? cosProperties.region().trim() : "ap-beijing"; COSCredentials credentials = StringUtils.hasText(cosProperties.sessionToken()) ? new BasicSessionCredentials(defaultText(cosProperties.secretId()), defaultText(cosProperties.secretKey()), cosProperties.sessionToken().trim()) : new BasicCOSCredentials(defaultText(cosProperties.secretId()), defaultText(cosProperties.secretKey())); return new COSClient( credentials, new ClientConfig(new Region(region)) ); } private void validateConfiguration() { requireText(cosProperties.secretId(), "APP_COS_SECRET_ID"); requireText(cosProperties.secretKey(), "APP_COS_SECRET_KEY"); requireText(cosProperties.bucket(), "APP_COS_BUCKET"); requireText(cosProperties.region(), "APP_COS_REGION"); } private int validateDuration(Duration duration) { if (duration == null || duration.isZero() || duration.isNegative()) { throw new BizException(HttpStatus.INTERNAL_SERVER_ERROR.value(), "COS_CONFIG_ERROR", "COS 上传会话有效期配置非法"); } long seconds = duration.toSeconds(); if (seconds > Integer.MAX_VALUE) { throw new BizException(HttpStatus.INTERNAL_SERVER_ERROR.value(), "COS_CONFIG_ERROR", "COS 上传会话有效期配置过大"); } return (int) seconds; } private String normalizeKeyPrefix(String keyPrefix) { String normalized = defaultText(keyPrefix).trim().replace("\\", "/"); while (normalized.startsWith("/")) { normalized = normalized.substring(1); } return normalized; } private void requireText(String value, String envName) { if (!StringUtils.hasText(value)) { throw new BizException(HttpStatus.INTERNAL_SERVER_ERROR.value(), "COS_CONFIG_ERROR", "COS 配置缺失: " + envName); } } private static String defaultText(String value) { return value == null ? "" : value; } }