From 0081e51459120c35bf521e774a74c99bc20b1519 Mon Sep 17 00:00:00 2001 From: Tim <135014430+nagisa77@users.noreply.github.com> Date: Thu, 24 Jul 2025 13:42:16 +0800 Subject: [PATCH] Add image reference tracking --- src/main/java/com/openisle/model/Image.java | 26 +++++ .../openisle/repository/ImageRepository.java | 13 +++ .../com/openisle/service/CommentService.java | 4 + .../openisle/service/CosImageUploader.java | 19 +++- .../com/openisle/service/ImageUploader.java | 102 ++++++++++++++++-- .../com/openisle/service/PostService.java | 10 +- .../com/openisle/service/UserService.java | 11 +- .../service/CosImageUploaderTest.java | 4 +- .../com/openisle/service/PostServiceTest.java | 3 +- 9 files changed, 176 insertions(+), 16 deletions(-) create mode 100644 src/main/java/com/openisle/model/Image.java create mode 100644 src/main/java/com/openisle/repository/ImageRepository.java diff --git a/src/main/java/com/openisle/model/Image.java b/src/main/java/com/openisle/model/Image.java new file mode 100644 index 000000000..4df308330 --- /dev/null +++ b/src/main/java/com/openisle/model/Image.java @@ -0,0 +1,26 @@ +package com.openisle.model; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * Image entity tracking COS image reference counts. + */ +@Entity +@Getter +@Setter +@NoArgsConstructor +@Table(name = "images") +public class Image { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 512) + private String url; + + @Column(nullable = false) + private long refCount = 0; +} diff --git a/src/main/java/com/openisle/repository/ImageRepository.java b/src/main/java/com/openisle/repository/ImageRepository.java new file mode 100644 index 000000000..0b39be747 --- /dev/null +++ b/src/main/java/com/openisle/repository/ImageRepository.java @@ -0,0 +1,13 @@ +package com.openisle.repository; + +import com.openisle.model.Image; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +/** + * Repository for images stored on COS. + */ +public interface ImageRepository extends JpaRepository { + Optional findByUrl(String url); +} diff --git a/src/main/java/com/openisle/service/CommentService.java b/src/main/java/com/openisle/service/CommentService.java index e30408c2b..a48fc996e 100644 --- a/src/main/java/com/openisle/service/CommentService.java +++ b/src/main/java/com/openisle/service/CommentService.java @@ -31,6 +31,7 @@ public class CommentService { private final ReactionRepository reactionRepository; private final CommentSubscriptionRepository commentSubscriptionRepository; private final NotificationRepository notificationRepository; + private final ImageUploader imageUploader; public Comment addComment(String username, Long postId, String content) { User author = userRepository.findByUsername(username) @@ -42,6 +43,7 @@ public class CommentService { comment.setPost(post); comment.setContent(content); comment = commentRepository.save(comment); + imageUploader.addReferences(imageUploader.extractUrls(content)); if (!author.getId().equals(post.getAuthor().getId())) { notificationService.createNotification(post.getAuthor(), NotificationType.COMMENT_REPLY, post, comment, null, null, null, null); } @@ -69,6 +71,7 @@ public class CommentService { comment.setParent(parent); comment.setContent(content); comment = commentRepository.save(comment); + imageUploader.addReferences(imageUploader.extractUrls(content)); if (!author.getId().equals(parent.getAuthor().getId())) { notificationService.createNotification(parent.getAuthor(), NotificationType.COMMENT_REPLY, parent.getPost(), comment, null, null, null, null); } @@ -150,6 +153,7 @@ public class CommentService { reactionRepository.findByComment(comment).forEach(reactionRepository::delete); commentSubscriptionRepository.findByComment(comment).forEach(commentSubscriptionRepository::delete); notificationRepository.deleteAll(notificationRepository.findByComment(comment)); + imageUploader.removeReferences(imageUploader.extractUrls(comment.getContent())); commentRepository.delete(comment); } } diff --git a/src/main/java/com/openisle/service/CosImageUploader.java b/src/main/java/com/openisle/service/CosImageUploader.java index 401de5225..7ae193f35 100644 --- a/src/main/java/com/openisle/service/CosImageUploader.java +++ b/src/main/java/com/openisle/service/CosImageUploader.java @@ -34,11 +34,13 @@ public class CosImageUploader extends ImageUploader { @org.springframework.beans.factory.annotation.Autowired public CosImageUploader( + com.openisle.repository.ImageRepository imageRepository, @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) { + super(imageRepository, baseUrl); COSCredentials cred = new BasicCOSCredentials(secretId, secretKey); ClientConfig config = new ClientConfig(new Region(region)); this.cosClient = new COSClient(cred, config); @@ -48,7 +50,11 @@ public class CosImageUploader extends ImageUploader { } // for tests - CosImageUploader(COSClient cosClient, String bucketName, String baseUrl) { + CosImageUploader(COSClient cosClient, + com.openisle.repository.ImageRepository imageRepository, + String bucketName, + String baseUrl) { + super(imageRepository, baseUrl); this.cosClient = cosClient; this.bucketName = bucketName; this.baseUrl = baseUrl; @@ -56,7 +62,7 @@ public class CosImageUploader extends ImageUploader { } @Override - public CompletableFuture upload(byte[] data, String filename) { + protected CompletableFuture doUpload(byte[] data, String filename) { return CompletableFuture.supplyAsync(() -> { logger.debug("Uploading {} bytes as {}", data.length, filename); String ext = ""; @@ -81,4 +87,13 @@ public class CosImageUploader extends ImageUploader { return url; }, executor); } + + @Override + protected void deleteFromStore(String key) { + try { + cosClient.deleteObject(bucketName, key); + } catch (Exception e) { + logger.warn("Failed to delete image {} from COS", key, e); + } + } } diff --git a/src/main/java/com/openisle/service/ImageUploader.java b/src/main/java/com/openisle/service/ImageUploader.java index 6f67ff917..6c1ba482b 100644 --- a/src/main/java/com/openisle/service/ImageUploader.java +++ b/src/main/java/com/openisle/service/ImageUploader.java @@ -1,19 +1,101 @@ package com.openisle.service; +import com.openisle.model.Image; +import com.openisle.repository.ImageRepository; + +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + /** - * Abstract service for uploading images. + * Abstract service for uploading images and tracking their references. */ public abstract class ImageUploader { - /** - * Upload an image and return its accessible URL. - * @param data image binary data - * @param filename name of the file - * @return accessible URL of the uploaded file - */ + private final ImageRepository imageRepository; + private final String baseUrl; + private final Pattern urlPattern; + + protected ImageUploader(ImageRepository imageRepository, String baseUrl) { + this.imageRepository = imageRepository; + if (baseUrl.endsWith("/")) { + this.baseUrl = baseUrl.substring(0, baseUrl.length() - 1); + } else { + this.baseUrl = baseUrl; + } + this.urlPattern = Pattern.compile(Pattern.quote(this.baseUrl) + "/[^\\s)]+"); + } + /** * 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); + public CompletableFuture upload(byte[] data, String filename) { + return doUpload(data, filename).thenApply(url -> { + addReference(url); + return url; + }); + } + + protected abstract CompletableFuture doUpload(byte[] data, String filename); + + protected abstract void deleteFromStore(String key); + + /** Extract COS URLs from text. */ + public Set extractUrls(String text) { + Set set = new HashSet<>(); + if (text == null) return set; + Matcher m = urlPattern.matcher(text); + while (m.find()) { + set.add(m.group()); + } + return set; + } + + public void addReferences(Set urls) { + for (String u : urls) addReference(u); + } + + public void removeReferences(Set urls) { + for (String u : urls) removeReference(u); + } + + public void adjustReferences(String oldText, String newText) { + Set oldUrls = extractUrls(oldText); + Set newUrls = extractUrls(newText); + for (String u : newUrls) { + if (!oldUrls.contains(u)) addReference(u); + } + for (String u : oldUrls) { + if (!newUrls.contains(u)) removeReference(u); + } + } + + private void addReference(String url) { + if (!url.startsWith(baseUrl)) return; + imageRepository.findByUrl(url).ifPresentOrElse(img -> { + img.setRefCount(img.getRefCount() + 1); + imageRepository.save(img); + }, () -> { + Image img = new Image(); + img.setUrl(url); + img.setRefCount(1); + imageRepository.save(img); + }); + } + + private void removeReference(String url) { + if (!url.startsWith(baseUrl)) return; + imageRepository.findByUrl(url).ifPresent(img -> { + long count = img.getRefCount() - 1; + if (count <= 0) { + imageRepository.delete(img); + String key = url.substring(baseUrl.length() + 1); + deleteFromStore(key); + } else { + img.setRefCount(count); + imageRepository.save(img); + } + }); + } } diff --git a/src/main/java/com/openisle/service/PostService.java b/src/main/java/com/openisle/service/PostService.java index 789fd74d3..70d8e5935 100644 --- a/src/main/java/com/openisle/service/PostService.java +++ b/src/main/java/com/openisle/service/PostService.java @@ -41,6 +41,7 @@ public class PostService { private final PostSubscriptionRepository postSubscriptionRepository; private final NotificationRepository notificationRepository; private final PostReadService postReadService; + private final ImageUploader imageUploader; @org.springframework.beans.factory.annotation.Autowired public PostService(PostRepository postRepository, @@ -55,6 +56,7 @@ public class PostService { PostSubscriptionRepository postSubscriptionRepository, NotificationRepository notificationRepository, PostReadService postReadService, + ImageUploader imageUploader, @Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode) { this.postRepository = postRepository; this.userRepository = userRepository; @@ -68,6 +70,7 @@ public class PostService { this.postSubscriptionRepository = postSubscriptionRepository; this.notificationRepository = notificationRepository; this.postReadService = postReadService; + this.imageUploader = imageUploader; this.publishMode = publishMode; } @@ -106,6 +109,7 @@ public class PostService { post.setTags(new java.util.HashSet<>(tags)); post.setStatus(publishMode == PublishMode.REVIEW ? PostStatus.PENDING : PostStatus.PUBLISHED); post = postRepository.save(post); + imageUploader.addReferences(imageUploader.extractUrls(content)); if (post.getStatus() == PostStatus.PENDING) { java.util.List admins = userRepository.findByRole(com.openisle.model.Role.ADMIN); for (User admin : admins) { @@ -391,10 +395,13 @@ public class PostService { throw new IllegalArgumentException("Tag not found"); } post.setTitle(title); + String oldContent = post.getContent(); post.setContent(content); post.setCategory(category); post.setTags(new java.util.HashSet<>(tags)); - return postRepository.save(post); + Post updated = postRepository.save(post); + imageUploader.adjustReferences(oldContent, content); + return updated; } @org.springframework.transaction.annotation.Transactional @@ -413,6 +420,7 @@ public class PostService { postSubscriptionRepository.findByPost(post).forEach(postSubscriptionRepository::delete); notificationRepository.deleteAll(notificationRepository.findByPost(post)); postReadService.deleteByPost(post); + imageUploader.removeReferences(imageUploader.extractUrls(post.getContent())); postRepository.delete(post); } diff --git a/src/main/java/com/openisle/service/UserService.java b/src/main/java/com/openisle/service/UserService.java index 4549a0cfe..1f37fb8e7 100644 --- a/src/main/java/com/openisle/service/UserService.java +++ b/src/main/java/com/openisle/service/UserService.java @@ -21,6 +21,7 @@ public class UserService { private final PasswordValidator passwordValidator; private final UsernameValidator usernameValidator; private final PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); + private final ImageUploader imageUploader; public User register(String username, String email, String password, String reason, com.openisle.model.RegisterMode mode) { usernameValidator.validate(username); @@ -120,8 +121,16 @@ public class UserService { public User updateAvatar(String username, String avatarUrl) { User user = userRepository.findByUsername(username) .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + String old = user.getAvatar(); user.setAvatar(avatarUrl); - return userRepository.save(user); + User saved = userRepository.save(user); + if (old != null && !old.equals(avatarUrl)) { + imageUploader.removeReferences(java.util.Set.of(old)); + } + if (avatarUrl != null) { + imageUploader.addReferences(java.util.Set.of(avatarUrl)); + } + return saved; } public User updateReason(String username, String reason) { diff --git a/src/test/java/com/openisle/service/CosImageUploaderTest.java b/src/test/java/com/openisle/service/CosImageUploaderTest.java index 395481648..fe4bccf14 100644 --- a/src/test/java/com/openisle/service/CosImageUploaderTest.java +++ b/src/test/java/com/openisle/service/CosImageUploaderTest.java @@ -2,6 +2,7 @@ package com.openisle.service; import com.qcloud.cos.COSClient; import com.qcloud.cos.model.PutObjectRequest; +import com.openisle.repository.ImageRepository; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; @@ -11,7 +12,8 @@ class CosImageUploaderTest { @Test void uploadReturnsUrl() { COSClient client = mock(COSClient.class); - CosImageUploader uploader = new CosImageUploader(client, "bucket", "http://cos.example.com"); + ImageRepository repo = mock(ImageRepository.class); + CosImageUploader uploader = new CosImageUploader(client, repo, "bucket", "http://cos.example.com"); String url = uploader.upload("data".getBytes(), "img.png").join(); diff --git a/src/test/java/com/openisle/service/PostServiceTest.java b/src/test/java/com/openisle/service/PostServiceTest.java index d81d73ee5..3356d5948 100644 --- a/src/test/java/com/openisle/service/PostServiceTest.java +++ b/src/test/java/com/openisle/service/PostServiceTest.java @@ -24,11 +24,12 @@ class PostServiceTest { PostSubscriptionRepository subRepo = mock(PostSubscriptionRepository.class); NotificationRepository notificationRepo = mock(NotificationRepository.class); PostReadService postReadService = mock(PostReadService.class); + ImageUploader imageUploader = mock(ImageUploader.class); PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, notifService, subService, commentService, commentRepo, reactionRepo, subRepo, notificationRepo, postReadService, - PublishMode.DIRECT); + imageUploader, PublishMode.DIRECT); Post post = new Post(); post.setId(1L);