diff --git a/pom.xml b/pom.xml index 1c31f0b94..15ee53e6e 100644 --- a/pom.xml +++ b/pom.xml @@ -66,6 +66,11 @@ lombok true + + com.qcloud + cos_api + 5.6.247 + org.springframework.boot spring-boot-starter-test diff --git a/src/main/java/com/openisle/controller/UserController.java b/src/main/java/com/openisle/controller/UserController.java index ba19c5dbd..d1f351f6a 100644 --- a/src/main/java/com/openisle/controller/UserController.java +++ b/src/main/java/com/openisle/controller/UserController.java @@ -29,7 +29,7 @@ public class UserController { @PostMapping("/me/avatar") public ResponseEntity uploadAvatar(@RequestParam("file") MultipartFile file, Authentication auth) throws IOException { - String url = imageUploader.upload(file.getBytes(), file.getOriginalFilename()); + String url = imageUploader.upload(file.getBytes(), file.getOriginalFilename()).join(); userService.updateAvatar(auth.getName(), url); return ResponseEntity.ok(Map.of("url", url)); } diff --git a/src/main/java/com/openisle/service/CosImageUploader.java b/src/main/java/com/openisle/service/CosImageUploader.java index c55bfab59..2142fd30d 100644 --- a/src/main/java/com/openisle/service/CosImageUploader.java +++ b/src/main/java/com/openisle/service/CosImageUploader.java @@ -1,24 +1,66 @@ package com.openisle.service; +import com.qcloud.cos.COSClient; +import com.qcloud.cos.ClientConfig; +import com.qcloud.cos.auth.BasicCOSCredentials; +import com.qcloud.cos.auth.COSCredentials; +import com.qcloud.cos.model.ObjectMetadata; +import com.qcloud.cos.model.PutObjectRequest; +import com.qcloud.cos.region.Region; import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.concurrent.CustomizableThreadFactory; import org.springframework.stereotype.Service; +import java.io.ByteArrayInputStream; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + /** * ImageUploader implementation using Tencent Cloud COS. - * For simplicity this demo just returns a URL composed of the base URL and file name. */ @Service public class CosImageUploader extends ImageUploader { + private final COSClient cosClient; + private final String bucketName; private final String baseUrl; + private final ExecutorService executor = Executors.newFixedThreadPool(2, + new CustomizableThreadFactory("cos-upload-")); - public CosImageUploader(@Value("${cos.base-url:https://example.com}") String baseUrl) { + @org.springframework.beans.factory.annotation.Autowired + public CosImageUploader( + @Value("${cos.secret-id:}") String secretId, + @Value("${cos.secret-key:}") String secretKey, + @Value("${cos.region:ap-guangzhou}") String region, + @Value("${cos.bucket-name:}") String bucketName, + @Value("${cos.base-url:https://example.com}") String baseUrl) { + COSCredentials cred = new BasicCOSCredentials(secretId, secretKey); + ClientConfig config = new ClientConfig(new Region(region)); + this.cosClient = new COSClient(cred, config); + this.bucketName = bucketName; + this.baseUrl = baseUrl; + } + + // for tests + CosImageUploader(COSClient cosClient, String bucketName, String baseUrl) { + this.cosClient = cosClient; + this.bucketName = bucketName; this.baseUrl = baseUrl; } @Override - public String upload(byte[] data, String filename) { - // In a real implementation you would call COS SDK here - return baseUrl + "/" + filename; + public CompletableFuture upload(byte[] data, String filename) { + return CompletableFuture.supplyAsync(() -> { + ObjectMetadata meta = new ObjectMetadata(); + meta.setContentLength(data.length); + PutObjectRequest req = new PutObjectRequest( + bucketName, + filename, + new ByteArrayInputStream(data), + meta); + cosClient.putObject(req); + return baseUrl + "/" + filename; + }, executor); } } diff --git a/src/main/java/com/openisle/service/ImageUploader.java b/src/main/java/com/openisle/service/ImageUploader.java index d6cc8f944..6f67ff917 100644 --- a/src/main/java/com/openisle/service/ImageUploader.java +++ b/src/main/java/com/openisle/service/ImageUploader.java @@ -10,5 +10,10 @@ public abstract class ImageUploader { * @param filename name of the file * @return accessible URL of the uploaded file */ - public abstract String upload(byte[] data, String filename); + /** + * Upload an image asynchronously and return a future of its accessible URL. + * Implementations should complete the future exceptionally on failures so + * callers can react accordingly. + */ + public abstract java.util.concurrent.CompletableFuture upload(byte[] data, String filename); } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 95e062f5e..b59558ad2 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -5,6 +5,10 @@ spring.jpa.hibernate.ddl-auto=update resend.api.key=${RESEND_API_KEY:} cos.base-url=${COS_BASE_URL:https://example.com} +cos.secret-id=${COS_SECRET_ID:} +cos.secret-key=${COS_SECRET_KEY:} +cos.region=${COS_REGION:ap-guangzhou} +cos.bucket-name=${COS_BUCKET_NAME:} app.jwt.secret=${JWT_SECRET:ChangeThisSecretKeyForJwt} app.jwt.expiration=${JWT_EXPIRATION:86400000} diff --git a/src/test/java/com/openisle/controller/UserControllerTest.java b/src/test/java/com/openisle/controller/UserControllerTest.java index e75c85ded..4e789776a 100644 --- a/src/test/java/com/openisle/controller/UserControllerTest.java +++ b/src/test/java/com/openisle/controller/UserControllerTest.java @@ -49,7 +49,7 @@ class UserControllerTest { @Test void uploadAvatar() throws Exception { MockMultipartFile file = new MockMultipartFile("file", "a.png", MediaType.IMAGE_PNG_VALUE, "img".getBytes()); - Mockito.when(imageUploader.upload(any(), eq("a.png"))).thenReturn("http://img/a.png"); + Mockito.when(imageUploader.upload(any(), eq("a.png"))).thenReturn(java.util.concurrent.CompletableFuture.completedFuture("http://img/a.png")); mockMvc.perform(multipart("/api/users/me/avatar").file(file).principal(new UsernamePasswordAuthenticationToken("alice","p"))) .andExpect(status().isOk()) diff --git a/src/test/java/com/openisle/service/CosImageUploaderTest.java b/src/test/java/com/openisle/service/CosImageUploaderTest.java index 7ac38d807..cba91f9b2 100644 --- a/src/test/java/com/openisle/service/CosImageUploaderTest.java +++ b/src/test/java/com/openisle/service/CosImageUploaderTest.java @@ -1,14 +1,21 @@ package com.openisle.service; +import com.qcloud.cos.COSClient; +import com.qcloud.cos.model.PutObjectRequest; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; class CosImageUploaderTest { @Test void uploadReturnsUrl() { - CosImageUploader uploader = new CosImageUploader("http://cos.example.com"); - String url = uploader.upload("data".getBytes(), "img.png"); + COSClient client = mock(COSClient.class); + CosImageUploader uploader = new CosImageUploader(client, "bucket", "http://cos.example.com"); + + String url = uploader.upload("data".getBytes(), "img.png").join(); + + verify(client).putObject(any(PutObjectRequest.class)); assertEquals("http://cos.example.com/img.png", url); } } diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index fa5a6a264..0ff8e0dfa 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -6,6 +6,10 @@ spring.jpa.hibernate.ddl-auto=create-drop resend.api.key=dummy cos.base-url=http://test.example.com +cos.secret-id=dummy +cos.secret-key=dummy +cos.region=ap-guangzhou +cos.bucket-name=testbucket app.jwt.secret=TestSecret app.jwt.expiration=3600000