Add image reference tracking

This commit is contained in:
Tim
2025-07-24 13:42:16 +08:00
parent f80bee1281
commit 0081e51459
9 changed files with 176 additions and 16 deletions

View File

@@ -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;
}

View File

@@ -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<Image, Long> {
Optional<Image> findByUrl(String url);
}

View File

@@ -31,6 +31,7 @@ public class CommentService {
private final ReactionRepository reactionRepository; private final ReactionRepository reactionRepository;
private final CommentSubscriptionRepository commentSubscriptionRepository; private final CommentSubscriptionRepository commentSubscriptionRepository;
private final NotificationRepository notificationRepository; private final NotificationRepository notificationRepository;
private final ImageUploader imageUploader;
public Comment addComment(String username, Long postId, String content) { public Comment addComment(String username, Long postId, String content) {
User author = userRepository.findByUsername(username) User author = userRepository.findByUsername(username)
@@ -42,6 +43,7 @@ public class CommentService {
comment.setPost(post); comment.setPost(post);
comment.setContent(content); comment.setContent(content);
comment = commentRepository.save(comment); comment = commentRepository.save(comment);
imageUploader.addReferences(imageUploader.extractUrls(content));
if (!author.getId().equals(post.getAuthor().getId())) { if (!author.getId().equals(post.getAuthor().getId())) {
notificationService.createNotification(post.getAuthor(), NotificationType.COMMENT_REPLY, post, comment, null, null, null, null); notificationService.createNotification(post.getAuthor(), NotificationType.COMMENT_REPLY, post, comment, null, null, null, null);
} }
@@ -69,6 +71,7 @@ public class CommentService {
comment.setParent(parent); comment.setParent(parent);
comment.setContent(content); comment.setContent(content);
comment = commentRepository.save(comment); comment = commentRepository.save(comment);
imageUploader.addReferences(imageUploader.extractUrls(content));
if (!author.getId().equals(parent.getAuthor().getId())) { if (!author.getId().equals(parent.getAuthor().getId())) {
notificationService.createNotification(parent.getAuthor(), NotificationType.COMMENT_REPLY, parent.getPost(), comment, null, null, null, null); 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); reactionRepository.findByComment(comment).forEach(reactionRepository::delete);
commentSubscriptionRepository.findByComment(comment).forEach(commentSubscriptionRepository::delete); commentSubscriptionRepository.findByComment(comment).forEach(commentSubscriptionRepository::delete);
notificationRepository.deleteAll(notificationRepository.findByComment(comment)); notificationRepository.deleteAll(notificationRepository.findByComment(comment));
imageUploader.removeReferences(imageUploader.extractUrls(comment.getContent()));
commentRepository.delete(comment); commentRepository.delete(comment);
} }
} }

View File

@@ -34,11 +34,13 @@ public class CosImageUploader extends ImageUploader {
@org.springframework.beans.factory.annotation.Autowired @org.springframework.beans.factory.annotation.Autowired
public CosImageUploader( public CosImageUploader(
com.openisle.repository.ImageRepository imageRepository,
@Value("${cos.secret-id:}") String secretId, @Value("${cos.secret-id:}") String secretId,
@Value("${cos.secret-key:}") String secretKey, @Value("${cos.secret-key:}") String secretKey,
@Value("${cos.region:ap-guangzhou}") String region, @Value("${cos.region:ap-guangzhou}") String region,
@Value("${cos.bucket-name:}") String bucketName, @Value("${cos.bucket-name:}") String bucketName,
@Value("${cos.base-url:https://example.com}") String baseUrl) { @Value("${cos.base-url:https://example.com}") String baseUrl) {
super(imageRepository, baseUrl);
COSCredentials cred = new BasicCOSCredentials(secretId, secretKey); COSCredentials cred = new BasicCOSCredentials(secretId, secretKey);
ClientConfig config = new ClientConfig(new Region(region)); ClientConfig config = new ClientConfig(new Region(region));
this.cosClient = new COSClient(cred, config); this.cosClient = new COSClient(cred, config);
@@ -48,7 +50,11 @@ public class CosImageUploader extends ImageUploader {
} }
// for tests // 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.cosClient = cosClient;
this.bucketName = bucketName; this.bucketName = bucketName;
this.baseUrl = baseUrl; this.baseUrl = baseUrl;
@@ -56,7 +62,7 @@ public class CosImageUploader extends ImageUploader {
} }
@Override @Override
public CompletableFuture<String> upload(byte[] data, String filename) { protected CompletableFuture<String> doUpload(byte[] data, String filename) {
return CompletableFuture.supplyAsync(() -> { return CompletableFuture.supplyAsync(() -> {
logger.debug("Uploading {} bytes as {}", data.length, filename); logger.debug("Uploading {} bytes as {}", data.length, filename);
String ext = ""; String ext = "";
@@ -81,4 +87,13 @@ public class CosImageUploader extends ImageUploader {
return url; return url;
}, executor); }, 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);
}
}
} }

View File

@@ -1,19 +1,101 @@
package com.openisle.service; 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 { public abstract class ImageUploader {
/** private final ImageRepository imageRepository;
* Upload an image and return its accessible URL. private final String baseUrl;
* @param data image binary data private final Pattern urlPattern;
* @param filename name of the file
* @return accessible URL of the uploaded file 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. * 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<String> upload(byte[] data, String filename); public CompletableFuture<String> upload(byte[] data, String filename) {
return doUpload(data, filename).thenApply(url -> {
addReference(url);
return url;
});
}
protected abstract CompletableFuture<String> doUpload(byte[] data, String filename);
protected abstract void deleteFromStore(String key);
/** Extract COS URLs from text. */
public Set<String> extractUrls(String text) {
Set<String> 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<String> urls) {
for (String u : urls) addReference(u);
}
public void removeReferences(Set<String> urls) {
for (String u : urls) removeReference(u);
}
public void adjustReferences(String oldText, String newText) {
Set<String> oldUrls = extractUrls(oldText);
Set<String> 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);
}
});
}
} }

View File

@@ -41,6 +41,7 @@ public class PostService {
private final PostSubscriptionRepository postSubscriptionRepository; private final PostSubscriptionRepository postSubscriptionRepository;
private final NotificationRepository notificationRepository; private final NotificationRepository notificationRepository;
private final PostReadService postReadService; private final PostReadService postReadService;
private final ImageUploader imageUploader;
@org.springframework.beans.factory.annotation.Autowired @org.springframework.beans.factory.annotation.Autowired
public PostService(PostRepository postRepository, public PostService(PostRepository postRepository,
@@ -55,6 +56,7 @@ public class PostService {
PostSubscriptionRepository postSubscriptionRepository, PostSubscriptionRepository postSubscriptionRepository,
NotificationRepository notificationRepository, NotificationRepository notificationRepository,
PostReadService postReadService, PostReadService postReadService,
ImageUploader imageUploader,
@Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode) { @Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode) {
this.postRepository = postRepository; this.postRepository = postRepository;
this.userRepository = userRepository; this.userRepository = userRepository;
@@ -68,6 +70,7 @@ public class PostService {
this.postSubscriptionRepository = postSubscriptionRepository; this.postSubscriptionRepository = postSubscriptionRepository;
this.notificationRepository = notificationRepository; this.notificationRepository = notificationRepository;
this.postReadService = postReadService; this.postReadService = postReadService;
this.imageUploader = imageUploader;
this.publishMode = publishMode; this.publishMode = publishMode;
} }
@@ -106,6 +109,7 @@ public class PostService {
post.setTags(new java.util.HashSet<>(tags)); post.setTags(new java.util.HashSet<>(tags));
post.setStatus(publishMode == PublishMode.REVIEW ? PostStatus.PENDING : PostStatus.PUBLISHED); post.setStatus(publishMode == PublishMode.REVIEW ? PostStatus.PENDING : PostStatus.PUBLISHED);
post = postRepository.save(post); post = postRepository.save(post);
imageUploader.addReferences(imageUploader.extractUrls(content));
if (post.getStatus() == PostStatus.PENDING) { if (post.getStatus() == PostStatus.PENDING) {
java.util.List<User> admins = userRepository.findByRole(com.openisle.model.Role.ADMIN); java.util.List<User> admins = userRepository.findByRole(com.openisle.model.Role.ADMIN);
for (User admin : admins) { for (User admin : admins) {
@@ -391,10 +395,13 @@ public class PostService {
throw new IllegalArgumentException("Tag not found"); throw new IllegalArgumentException("Tag not found");
} }
post.setTitle(title); post.setTitle(title);
String oldContent = post.getContent();
post.setContent(content); post.setContent(content);
post.setCategory(category); post.setCategory(category);
post.setTags(new java.util.HashSet<>(tags)); 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 @org.springframework.transaction.annotation.Transactional
@@ -413,6 +420,7 @@ public class PostService {
postSubscriptionRepository.findByPost(post).forEach(postSubscriptionRepository::delete); postSubscriptionRepository.findByPost(post).forEach(postSubscriptionRepository::delete);
notificationRepository.deleteAll(notificationRepository.findByPost(post)); notificationRepository.deleteAll(notificationRepository.findByPost(post));
postReadService.deleteByPost(post); postReadService.deleteByPost(post);
imageUploader.removeReferences(imageUploader.extractUrls(post.getContent()));
postRepository.delete(post); postRepository.delete(post);
} }

View File

@@ -21,6 +21,7 @@ public class UserService {
private final PasswordValidator passwordValidator; private final PasswordValidator passwordValidator;
private final UsernameValidator usernameValidator; private final UsernameValidator usernameValidator;
private final PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); 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) { public User register(String username, String email, String password, String reason, com.openisle.model.RegisterMode mode) {
usernameValidator.validate(username); usernameValidator.validate(username);
@@ -120,8 +121,16 @@ public class UserService {
public User updateAvatar(String username, String avatarUrl) { public User updateAvatar(String username, String avatarUrl) {
User user = userRepository.findByUsername(username) User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
String old = user.getAvatar();
user.setAvatar(avatarUrl); 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) { public User updateReason(String username, String reason) {

View File

@@ -2,6 +2,7 @@ package com.openisle.service;
import com.qcloud.cos.COSClient; import com.qcloud.cos.COSClient;
import com.qcloud.cos.model.PutObjectRequest; import com.qcloud.cos.model.PutObjectRequest;
import com.openisle.repository.ImageRepository;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
@@ -11,7 +12,8 @@ class CosImageUploaderTest {
@Test @Test
void uploadReturnsUrl() { void uploadReturnsUrl() {
COSClient client = mock(COSClient.class); 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(); String url = uploader.upload("data".getBytes(), "img.png").join();

View File

@@ -24,11 +24,12 @@ class PostServiceTest {
PostSubscriptionRepository subRepo = mock(PostSubscriptionRepository.class); PostSubscriptionRepository subRepo = mock(PostSubscriptionRepository.class);
NotificationRepository notificationRepo = mock(NotificationRepository.class); NotificationRepository notificationRepo = mock(NotificationRepository.class);
PostReadService postReadService = mock(PostReadService.class); PostReadService postReadService = mock(PostReadService.class);
ImageUploader imageUploader = mock(ImageUploader.class);
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo,
notifService, subService, commentService, commentRepo, notifService, subService, commentService, commentRepo,
reactionRepo, subRepo, notificationRepo, postReadService, reactionRepo, subRepo, notificationRepo, postReadService,
PublishMode.DIRECT); imageUploader, PublishMode.DIRECT);
Post post = new Post(); Post post = new Post();
post.setId(1L); post.setId(1L);