From f50201aef697ab88e15a16a434ce3503c3cc6a8f Mon Sep 17 00:00:00 2001 From: Tim <135014430+nagisa77@users.noreply.github.com> Date: Wed, 2 Jul 2025 13:19:55 +0800 Subject: [PATCH] Add tag module with post associations --- .../com/openisle/config/SecurityConfig.java | 6 +- .../openisle/controller/PostController.java | 6 +- .../openisle/controller/TagController.java | 58 ++++++++++++++++++ src/main/java/com/openisle/model/Post.java | 11 ++++ src/main/java/com/openisle/model/Tag.java | 20 +++++++ .../openisle/repository/TagRepository.java | 7 +++ .../com/openisle/service/PostService.java | 18 +++++- .../java/com/openisle/service/TagService.java | 33 +++++++++++ .../controller/PostControllerTest.java | 13 +++- .../controller/TagControllerTest.java | 59 +++++++++++++++++++ .../ComplexFlowIntegrationTest.java | 14 ++++- .../PublishModeIntegrationTest.java | 7 ++- .../integration/SearchIntegrationTest.java | 6 +- 13 files changed, 249 insertions(+), 9 deletions(-) create mode 100644 src/main/java/com/openisle/controller/TagController.java create mode 100644 src/main/java/com/openisle/model/Tag.java create mode 100644 src/main/java/com/openisle/repository/TagRepository.java create mode 100644 src/main/java/com/openisle/service/TagService.java create mode 100644 src/test/java/com/openisle/controller/TagControllerTest.java diff --git a/src/main/java/com/openisle/config/SecurityConfig.java b/src/main/java/com/openisle/config/SecurityConfig.java index d91b553c8..c27ceb63d 100644 --- a/src/main/java/com/openisle/config/SecurityConfig.java +++ b/src/main/java/com/openisle/config/SecurityConfig.java @@ -69,9 +69,12 @@ public class SecurityConfig { .requestMatchers(HttpMethod.GET, "/api/posts/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/comments/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/categories/**").permitAll() + .requestMatchers(HttpMethod.GET, "/api/tags/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/search/**").permitAll() .requestMatchers(HttpMethod.POST, "/api/categories/**").hasAuthority("ADMIN") + .requestMatchers(HttpMethod.POST, "/api/tags/**").hasAuthority("ADMIN") .requestMatchers(HttpMethod.DELETE, "/api/categories/**").hasAuthority("ADMIN") + .requestMatchers(HttpMethod.DELETE, "/api/tags/**").hasAuthority("ADMIN") .requestMatchers("/api/admin/**").hasAuthority("ADMIN") .anyRequest().authenticated() ) @@ -89,7 +92,8 @@ public class SecurityConfig { boolean publicGet = "GET".equalsIgnoreCase(request.getMethod()) && (uri.startsWith("/api/posts") || uri.startsWith("/api/comments") || - uri.startsWith("/api/categories") || uri.startsWith("/api/search")); + uri.startsWith("/api/categories") || uri.startsWith("/api/tags") || + uri.startsWith("/api/search")); if (authHeader != null && authHeader.startsWith("Bearer ")) { String token = authHeader.substring(7); diff --git a/src/main/java/com/openisle/controller/PostController.java b/src/main/java/com/openisle/controller/PostController.java index 2cb44c16b..6246bcdc0 100644 --- a/src/main/java/com/openisle/controller/PostController.java +++ b/src/main/java/com/openisle/controller/PostController.java @@ -38,7 +38,8 @@ public class PostController { if (captchaEnabled && postCaptchaEnabled && !captchaService.verify(req.getCaptcha())) { return ResponseEntity.badRequest().build(); } - Post post = postService.createPost(auth.getName(), req.getCategoryId(), req.getTitle(), req.getContent()); + Post post = postService.createPost(auth.getName(), req.getCategoryId(), + req.getTitle(), req.getContent(), req.getTagIds()); return ResponseEntity.ok(toDto(post)); } @@ -69,6 +70,7 @@ public class PostController { dto.setCreatedAt(post.getCreatedAt()); dto.setAuthor(post.getAuthor().getUsername()); dto.setCategory(post.getCategory().getName()); + dto.setTags(post.getTags().stream().map(com.openisle.model.Tag::getName).collect(Collectors.toList())); dto.setViews(post.getViews()); List reactions = reactionService.getReactionsForPost(post.getId()) @@ -130,6 +132,7 @@ public class PostController { private Long categoryId; private String title; private String content; + private java.util.List tagIds; private String captcha; } @@ -141,6 +144,7 @@ public class PostController { private LocalDateTime createdAt; private String author; private String category; + private java.util.List tags; private long views; private List comments; private List reactions; diff --git a/src/main/java/com/openisle/controller/TagController.java b/src/main/java/com/openisle/controller/TagController.java new file mode 100644 index 000000000..ce4a959d6 --- /dev/null +++ b/src/main/java/com/openisle/controller/TagController.java @@ -0,0 +1,58 @@ +package com.openisle.controller; + +import com.openisle.model.Tag; +import com.openisle.service.TagService; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/api/tags") +@RequiredArgsConstructor +public class TagController { + private final TagService tagService; + + @PostMapping + public TagDto create(@RequestBody TagRequest req) { + Tag tag = tagService.createTag(req.getName()); + return toDto(tag); + } + + @DeleteMapping("/{id}") + public void delete(@PathVariable Long id) { + tagService.deleteTag(id); + } + + @GetMapping + public List list() { + return tagService.listTags().stream() + .map(this::toDto) + .collect(Collectors.toList()); + } + + @GetMapping("/{id}") + public TagDto get(@PathVariable Long id) { + return toDto(tagService.getTag(id)); + } + + private TagDto toDto(Tag tag) { + TagDto dto = new TagDto(); + dto.setId(tag.getId()); + dto.setName(tag.getName()); + return dto; + } + + @Data + private static class TagRequest { + private String name; + } + + @Data + private static class TagDto { + private Long id; + private String name; + } +} diff --git a/src/main/java/com/openisle/model/Post.java b/src/main/java/com/openisle/model/Post.java index a7d823752..6e8ce8efd 100644 --- a/src/main/java/com/openisle/model/Post.java +++ b/src/main/java/com/openisle/model/Post.java @@ -5,6 +5,11 @@ import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; +import java.util.HashSet; +import java.util.Set; + +import com.openisle.model.Tag; + import java.time.LocalDateTime; @@ -38,6 +43,12 @@ public class Post { @JoinColumn(name = "category_id") private Category category; + @ManyToMany(fetch = FetchType.LAZY) + @JoinTable(name = "post_tags", + joinColumns = @JoinColumn(name = "post_id"), + inverseJoinColumns = @JoinColumn(name = "tag_id")) + private java.util.Set tags = new java.util.HashSet<>(); + @Column(nullable = false) private long views = 0; diff --git a/src/main/java/com/openisle/model/Tag.java b/src/main/java/com/openisle/model/Tag.java new file mode 100644 index 000000000..8ed53b5b1 --- /dev/null +++ b/src/main/java/com/openisle/model/Tag.java @@ -0,0 +1,20 @@ +package com.openisle.model; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@Table(name = "tags") +public class Tag { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private String name; +} diff --git a/src/main/java/com/openisle/repository/TagRepository.java b/src/main/java/com/openisle/repository/TagRepository.java new file mode 100644 index 000000000..197d21a14 --- /dev/null +++ b/src/main/java/com/openisle/repository/TagRepository.java @@ -0,0 +1,7 @@ +package com.openisle.repository; + +import com.openisle.model.Tag; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TagRepository extends JpaRepository { +} diff --git a/src/main/java/com/openisle/service/PostService.java b/src/main/java/com/openisle/service/PostService.java index 738c2e4de..e3a5c27bc 100644 --- a/src/main/java/com/openisle/service/PostService.java +++ b/src/main/java/com/openisle/service/PostService.java @@ -8,6 +8,7 @@ import com.openisle.model.Category; import com.openisle.repository.PostRepository; import com.openisle.repository.UserRepository; import com.openisle.repository.CategoryRepository; +import com.openisle.repository.TagRepository; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @@ -20,29 +21,44 @@ public class PostService { private final PostRepository postRepository; private final UserRepository userRepository; private final CategoryRepository categoryRepository; + private final TagRepository tagRepository; private final PublishMode publishMode; @org.springframework.beans.factory.annotation.Autowired public PostService(PostRepository postRepository, UserRepository userRepository, CategoryRepository categoryRepository, + TagRepository tagRepository, @Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode) { this.postRepository = postRepository; this.userRepository = userRepository; this.categoryRepository = categoryRepository; + this.tagRepository = tagRepository; this.publishMode = publishMode; } - public Post createPost(String username, Long categoryId, String title, String content) { + public Post createPost(String username, + Long categoryId, + String title, + String content, + java.util.List tagIds) { + if (tagIds == null || tagIds.isEmpty()) { + throw new IllegalArgumentException("At least one tag required"); + } User author = userRepository.findByUsername(username) .orElseThrow(() -> new IllegalArgumentException("User not found")); Category category = categoryRepository.findById(categoryId) .orElseThrow(() -> new IllegalArgumentException("Category not found")); + java.util.List tags = tagRepository.findAllById(tagIds); + if (tags.isEmpty()) { + throw new IllegalArgumentException("Tag not found"); + } Post post = new Post(); post.setTitle(title); post.setContent(content); post.setAuthor(author); post.setCategory(category); + post.setTags(new java.util.HashSet<>(tags)); post.setStatus(publishMode == PublishMode.REVIEW ? PostStatus.PENDING : PostStatus.PUBLISHED); return postRepository.save(post); } diff --git a/src/main/java/com/openisle/service/TagService.java b/src/main/java/com/openisle/service/TagService.java new file mode 100644 index 000000000..9e9aa6174 --- /dev/null +++ b/src/main/java/com/openisle/service/TagService.java @@ -0,0 +1,33 @@ +package com.openisle.service; + +import com.openisle.model.Tag; +import com.openisle.repository.TagRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class TagService { + private final TagRepository tagRepository; + + public Tag createTag(String name) { + Tag tag = new Tag(); + tag.setName(name); + return tagRepository.save(tag); + } + + public void deleteTag(Long id) { + tagRepository.deleteById(id); + } + + public Tag getTag(Long id) { + return tagRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Tag not found")); + } + + public List listTags() { + return tagRepository.findAll(); + } +} diff --git a/src/test/java/com/openisle/controller/PostControllerTest.java b/src/test/java/com/openisle/controller/PostControllerTest.java index e030ee8e5..245c4158a 100644 --- a/src/test/java/com/openisle/controller/PostControllerTest.java +++ b/src/test/java/com/openisle/controller/PostControllerTest.java @@ -3,6 +3,7 @@ package com.openisle.controller; import com.openisle.model.Post; import com.openisle.model.User; import com.openisle.model.Category; +import com.openisle.model.Tag; import com.openisle.service.PostService; import com.openisle.service.CommentService; import com.openisle.service.ReactionService; @@ -48,6 +49,9 @@ class PostControllerTest { Category cat = new Category(); cat.setId(1L); cat.setName("tech"); + Tag tag = new Tag(); + tag.setId(1L); + tag.setName("java"); Post post = new Post(); post.setId(1L); post.setTitle("t"); @@ -55,12 +59,13 @@ class PostControllerTest { post.setCreatedAt(LocalDateTime.now()); post.setAuthor(user); post.setCategory(cat); - Mockito.when(postService.createPost(eq("alice"), eq(1L), eq("t"), eq("c"))).thenReturn(post); + post.setTags(java.util.Set.of(tag)); + Mockito.when(postService.createPost(eq("alice"), eq(1L), eq("t"), eq("c"), eq(java.util.List.of(1L)))).thenReturn(post); Mockito.when(postService.getPost(1L)).thenReturn(post); mockMvc.perform(post("/api/posts") .contentType("application/json") - .content("{\"title\":\"t\",\"content\":\"c\",\"categoryId\":1}") + .content("{\"title\":\"t\",\"content\":\"c\",\"categoryId\":1,\"tagIds\":[1]}") .principal(new UsernamePasswordAuthenticationToken("alice", "p"))) .andExpect(status().isOk()) .andExpect(jsonPath("$.title").value("t")); @@ -77,6 +82,9 @@ class PostControllerTest { Category cat = new Category(); cat.setId(1L); cat.setName("tech"); + Tag tag = new Tag(); + tag.setId(1L); + tag.setName("java"); Post post = new Post(); post.setId(2L); post.setTitle("hello"); @@ -84,6 +92,7 @@ class PostControllerTest { post.setCreatedAt(LocalDateTime.now()); post.setAuthor(user); post.setCategory(cat); + post.setTags(java.util.Set.of(tag)); Mockito.when(postService.listPostsByCategories(Mockito.isNull(), Mockito.isNull(), Mockito.isNull())) .thenReturn(List.of(post)); diff --git a/src/test/java/com/openisle/controller/TagControllerTest.java b/src/test/java/com/openisle/controller/TagControllerTest.java new file mode 100644 index 000000000..bb360ea5c --- /dev/null +++ b/src/test/java/com/openisle/controller/TagControllerTest.java @@ -0,0 +1,59 @@ +package com.openisle.controller; + +import com.openisle.model.Tag; +import com.openisle.service.TagService; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.eq; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(TagController.class) +@AutoConfigureMockMvc(addFilters = false) +class TagControllerTest { + @Autowired + private MockMvc mockMvc; + + @MockBean + private TagService tagService; + + @Test + void createAndGetTag() throws Exception { + Tag t = new Tag(); + t.setId(1L); + t.setName("java"); + Mockito.when(tagService.createTag(eq("java"))).thenReturn(t); + Mockito.when(tagService.getTag(1L)).thenReturn(t); + + mockMvc.perform(post("/api/tags") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"java\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("java")); + + mockMvc.perform(get("/api/tags/1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(1)); + } + + @Test + void listTags() throws Exception { + Tag t = new Tag(); + t.setId(2L); + t.setName("spring"); + Mockito.when(tagService.listTags()).thenReturn(List.of(t)); + + mockMvc.perform(get("/api/tags")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].name").value("spring")); + } +} diff --git a/src/test/java/com/openisle/integration/ComplexFlowIntegrationTest.java b/src/test/java/com/openisle/integration/ComplexFlowIntegrationTest.java index c13cc2c12..4adfa855b 100644 --- a/src/test/java/com/openisle/integration/ComplexFlowIntegrationTest.java +++ b/src/test/java/com/openisle/integration/ComplexFlowIntegrationTest.java @@ -69,8 +69,13 @@ class ComplexFlowIntegrationTest { Map.of("name", "general"), adminToken); Long catId = ((Number)catResp.getBody().get("id")).longValue(); + ResponseEntity tagResp = postJson("/api/tags", + Map.of("name", "java"), adminToken); + Long tagId = ((Number)tagResp.getBody().get("id")).longValue(); + ResponseEntity postResp = postJson("/api/posts", - Map.of("title", "Hello", "content", "World", "categoryId", catId), t1); + Map.of("title", "Hello", "content", "World", "categoryId", catId, + "tagIds", List.of(tagId)), t1); Long postId = ((Number)postResp.getBody().get("id")).longValue(); ResponseEntity c1Resp = postJson("/api/posts/" + postId + "/comments", @@ -122,8 +127,13 @@ class ComplexFlowIntegrationTest { catId = ((Number)catResp.getBody().get("id")).longValue(); } + ResponseEntity tagResp = postJson("/api/tags", + Map.of("name", "spring"), adminToken); + Long tagId = ((Number)tagResp.getBody().get("id")).longValue(); + ResponseEntity postResp = postJson("/api/posts", - Map.of("title", "React", "content", "Test", "categoryId", catId), t1); + Map.of("title", "React", "content", "Test", "categoryId", catId, + "tagIds", List.of(tagId)), t1); Long postId = ((Number)postResp.getBody().get("id")).longValue(); postJson("/api/posts/" + postId + "/reactions", diff --git a/src/test/java/com/openisle/integration/PublishModeIntegrationTest.java b/src/test/java/com/openisle/integration/PublishModeIntegrationTest.java index 8dc946f4e..c12cc2d8e 100644 --- a/src/test/java/com/openisle/integration/PublishModeIntegrationTest.java +++ b/src/test/java/com/openisle/integration/PublishModeIntegrationTest.java @@ -73,8 +73,13 @@ class PublishModeIntegrationTest { Map.of("name", "review"), adminToken); Long catId = ((Number)catResp.getBody().get("id")).longValue(); + ResponseEntity tagResp = postJson("/api/tags", + Map.of("name", "t1"), adminToken); + Long tagId = ((Number)tagResp.getBody().get("id")).longValue(); + ResponseEntity postResp = postJson("/api/posts", - Map.of("title", "Need", "content", "Review", "categoryId", catId), userToken); + Map.of("title", "Need", "content", "Review", "categoryId", catId, + "tagIds", List.of(tagId)), userToken); Long postId = ((Number)postResp.getBody().get("id")).longValue(); List list = rest.getForObject("/api/posts", List.class); diff --git a/src/test/java/com/openisle/integration/SearchIntegrationTest.java b/src/test/java/com/openisle/integration/SearchIntegrationTest.java index 4ab6b0f0d..cfc9a66ea 100644 --- a/src/test/java/com/openisle/integration/SearchIntegrationTest.java +++ b/src/test/java/com/openisle/integration/SearchIntegrationTest.java @@ -63,8 +63,12 @@ class SearchIntegrationTest { ResponseEntity catResp = postJson("/api/categories", Map.of("name", "misc"), admin); Long catId = ((Number)catResp.getBody().get("id")).longValue(); + ResponseEntity tagResp = postJson("/api/tags", Map.of("name", "misc"), admin); + Long tagId = ((Number)tagResp.getBody().get("id")).longValue(); + ResponseEntity postResp = postJson("/api/posts", - Map.of("title", "Hello World Nice", "content", "Some content", "categoryId", catId), user); + Map.of("title", "Hello World Nice", "content", "Some content", "categoryId", catId, + "tagIds", List.of(tagId)), user); Long postId = ((Number)postResp.getBody().get("id")).longValue(); postJson("/api/posts/" + postId + "/comments",