mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-23 06:30:48 +08:00
Merge pull request #253 from nagisa77/codex/implement-image-reference-counting-logic
Add COS image reference tracking
This commit is contained in:
26
src/main/java/com/openisle/model/Image.java
Normal file
26
src/main/java/com/openisle/model/Image.java
Normal 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;
|
||||
}
|
||||
13
src/main/java/com/openisle/repository/ImageRepository.java
Normal file
13
src/main/java/com/openisle/repository/ImageRepository.java
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user