Merge pull request #253 from nagisa77/codex/implement-image-reference-counting-logic

Add COS image reference tracking
This commit is contained in:
Tim
2025-07-24 15:41:55 +08:00
committed by GitHub
9 changed files with 173 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 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);
}
}

View File

@@ -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<String> upload(byte[] data, String filename) {
protected CompletableFuture<String> 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);
}
}
}

View File

@@ -1,19 +1,98 @@
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<String> upload(byte[] data, String filename);
public CompletableFuture<String> upload(byte[] data, String filename) {
return doUpload(data, filename).thenApply(url -> 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 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<User> 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);
}

View File

@@ -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) {

View File

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

View File

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