From 987fe0d885803837cdbaae800580038170506c59 Mon Sep 17 00:00:00 2001 From: Tim <135014430+nagisa77@users.noreply.github.com> Date: Sat, 9 Aug 2025 02:08:02 +0800 Subject: [PATCH 01/15] feat: implement medal feature --- .../openisle/controller/MedalController.java | 23 +++++ .../com/openisle/dto/CommentMedalDto.java | 11 +++ .../main/java/com/openisle/dto/MedalDto.java | 13 +++ .../java/com/openisle/dto/PostMedalDto.java | 11 +++ .../com/openisle/dto/SeedUserMedalDto.java | 11 +++ .../java/com/openisle/model/MedalType.java | 7 ++ .../repository/CommentRepository.java | 2 + .../openisle/repository/PostRepository.java | 2 + .../com/openisle/service/MedalService.java | 84 +++++++++++++++++++ .../controller/MedalControllerTest.java | 52 ++++++++++++ .../openisle/service/MedalServiceTest.java | 53 ++++++++++++ 11 files changed, 269 insertions(+) create mode 100644 backend/src/main/java/com/openisle/controller/MedalController.java create mode 100644 backend/src/main/java/com/openisle/dto/CommentMedalDto.java create mode 100644 backend/src/main/java/com/openisle/dto/MedalDto.java create mode 100644 backend/src/main/java/com/openisle/dto/PostMedalDto.java create mode 100644 backend/src/main/java/com/openisle/dto/SeedUserMedalDto.java create mode 100644 backend/src/main/java/com/openisle/model/MedalType.java create mode 100644 backend/src/main/java/com/openisle/service/MedalService.java create mode 100644 backend/src/test/java/com/openisle/controller/MedalControllerTest.java create mode 100644 backend/src/test/java/com/openisle/service/MedalServiceTest.java diff --git a/backend/src/main/java/com/openisle/controller/MedalController.java b/backend/src/main/java/com/openisle/controller/MedalController.java new file mode 100644 index 000000000..086d3f844 --- /dev/null +++ b/backend/src/main/java/com/openisle/controller/MedalController.java @@ -0,0 +1,23 @@ +package com.openisle.controller; + +import com.openisle.dto.MedalDto; +import com.openisle.service.MedalService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api/medals") +@RequiredArgsConstructor +public class MedalController { + private final MedalService medalService; + + @GetMapping + public List getMedals(@RequestParam(value = "userId", required = false) Long userId) { + return medalService.getMedals(userId); + } +} diff --git a/backend/src/main/java/com/openisle/dto/CommentMedalDto.java b/backend/src/main/java/com/openisle/dto/CommentMedalDto.java new file mode 100644 index 000000000..045194f9b --- /dev/null +++ b/backend/src/main/java/com/openisle/dto/CommentMedalDto.java @@ -0,0 +1,11 @@ +package com.openisle.dto; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = true) +public class CommentMedalDto extends MedalDto { + private long currentCommentCount; + private long targetCommentCount; +} diff --git a/backend/src/main/java/com/openisle/dto/MedalDto.java b/backend/src/main/java/com/openisle/dto/MedalDto.java new file mode 100644 index 000000000..757fe5c15 --- /dev/null +++ b/backend/src/main/java/com/openisle/dto/MedalDto.java @@ -0,0 +1,13 @@ +package com.openisle.dto; + +import com.openisle.model.MedalType; +import lombok.Data; + +@Data +public class MedalDto { + private String icon; + private String title; + private String description; + private MedalType type; + private boolean completed; +} diff --git a/backend/src/main/java/com/openisle/dto/PostMedalDto.java b/backend/src/main/java/com/openisle/dto/PostMedalDto.java new file mode 100644 index 000000000..9b538b207 --- /dev/null +++ b/backend/src/main/java/com/openisle/dto/PostMedalDto.java @@ -0,0 +1,11 @@ +package com.openisle.dto; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = true) +public class PostMedalDto extends MedalDto { + private long currentPostCount; + private long targetPostCount; +} diff --git a/backend/src/main/java/com/openisle/dto/SeedUserMedalDto.java b/backend/src/main/java/com/openisle/dto/SeedUserMedalDto.java new file mode 100644 index 000000000..b1a961dbd --- /dev/null +++ b/backend/src/main/java/com/openisle/dto/SeedUserMedalDto.java @@ -0,0 +1,11 @@ +package com.openisle.dto; + +import java.time.LocalDateTime; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = true) +public class SeedUserMedalDto extends MedalDto { + private LocalDateTime registerDate; +} diff --git a/backend/src/main/java/com/openisle/model/MedalType.java b/backend/src/main/java/com/openisle/model/MedalType.java new file mode 100644 index 000000000..58ab4e22e --- /dev/null +++ b/backend/src/main/java/com/openisle/model/MedalType.java @@ -0,0 +1,7 @@ +package com.openisle.model; + +public enum MedalType { + COMMENT, + POST, + SEED +} diff --git a/backend/src/main/java/com/openisle/repository/CommentRepository.java b/backend/src/main/java/com/openisle/repository/CommentRepository.java index fa2418f58..de65699cd 100644 --- a/backend/src/main/java/com/openisle/repository/CommentRepository.java +++ b/backend/src/main/java/com/openisle/repository/CommentRepository.java @@ -30,4 +30,6 @@ public interface CommentRepository extends JpaRepository { @org.springframework.data.jpa.repository.Query("SELECT COUNT(c) FROM Comment c WHERE c.post.id = :postId") long countByPostId(@org.springframework.data.repository.query.Param("postId") Long postId); + long countByAuthor_Id(Long userId); + } diff --git a/backend/src/main/java/com/openisle/repository/PostRepository.java b/backend/src/main/java/com/openisle/repository/PostRepository.java index bfe410dc2..1c238db75 100644 --- a/backend/src/main/java/com/openisle/repository/PostRepository.java +++ b/backend/src/main/java/com/openisle/repository/PostRepository.java @@ -93,4 +93,6 @@ public interface PostRepository extends JpaRepository { long countByCategory_Id(Long categoryId); long countDistinctByTags_Id(Long tagId); + + long countByAuthor_Id(Long userId); } diff --git a/backend/src/main/java/com/openisle/service/MedalService.java b/backend/src/main/java/com/openisle/service/MedalService.java new file mode 100644 index 000000000..b80f5965d --- /dev/null +++ b/backend/src/main/java/com/openisle/service/MedalService.java @@ -0,0 +1,84 @@ +package com.openisle.service; + +import com.openisle.dto.CommentMedalDto; +import com.openisle.dto.MedalDto; +import com.openisle.dto.PostMedalDto; +import com.openisle.dto.SeedUserMedalDto; +import com.openisle.model.MedalType; +import com.openisle.repository.CommentRepository; +import com.openisle.repository.PostRepository; +import com.openisle.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class MedalService { + private static final long COMMENT_TARGET = 100; + private static final long POST_TARGET = 100; + private static final LocalDateTime SEED_USER_DEADLINE = LocalDateTime.of(2025, 9, 16, 0, 0); + + private final CommentRepository commentRepository; + private final PostRepository postRepository; + private final UserRepository userRepository; + + public List getMedals(Long userId) { + List medals = new ArrayList<>(); + + CommentMedalDto commentMedal = new CommentMedalDto(); + commentMedal.setIcon("comment.png"); + commentMedal.setTitle("评论达人"); + commentMedal.setDescription("评论超过100条"); + commentMedal.setType(MedalType.COMMENT); + commentMedal.setTargetCommentCount(COMMENT_TARGET); + if (userId != null) { + long count = commentRepository.countByAuthor_Id(userId); + commentMedal.setCurrentCommentCount(count); + commentMedal.setCompleted(count >= COMMENT_TARGET); + } else { + commentMedal.setCurrentCommentCount(0); + commentMedal.setCompleted(false); + } + medals.add(commentMedal); + + PostMedalDto postMedal = new PostMedalDto(); + postMedal.setIcon("post.png"); + postMedal.setTitle("发帖达人"); + postMedal.setDescription("评论超过100条"); + postMedal.setType(MedalType.POST); + postMedal.setTargetPostCount(POST_TARGET); + if (userId != null) { + long count = postRepository.countByAuthor_Id(userId); + postMedal.setCurrentPostCount(count); + postMedal.setCompleted(count >= POST_TARGET); + } else { + postMedal.setCurrentPostCount(0); + postMedal.setCompleted(false); + } + medals.add(postMedal); + + SeedUserMedalDto seedUserMedal = new SeedUserMedalDto(); + seedUserMedal.setIcon("seed.png"); + seedUserMedal.setTitle("种子用户"); + seedUserMedal.setDescription("2025.9.16前注册的用户"); + seedUserMedal.setType(MedalType.SEED); + if (userId != null) { + userRepository.findById(userId).ifPresent(user -> { + seedUserMedal.setRegisterDate(user.getCreatedAt()); + seedUserMedal.setCompleted(user.getCreatedAt().isBefore(SEED_USER_DEADLINE)); + }); + if (seedUserMedal.getRegisterDate() == null) { + seedUserMedal.setCompleted(false); + } + } else { + seedUserMedal.setCompleted(false); + } + medals.add(seedUserMedal); + + return medals; + } +} diff --git a/backend/src/test/java/com/openisle/controller/MedalControllerTest.java b/backend/src/test/java/com/openisle/controller/MedalControllerTest.java new file mode 100644 index 000000000..1dc054cab --- /dev/null +++ b/backend/src/test/java/com/openisle/controller/MedalControllerTest.java @@ -0,0 +1,52 @@ +package com.openisle.controller; + +import com.openisle.dto.CommentMedalDto; +import com.openisle.model.MedalType; +import com.openisle.service.MedalService; +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.test.web.servlet.MockMvc; + +import java.util.List; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(MedalController.class) +@AutoConfigureMockMvc(addFilters = false) +class MedalControllerTest { + @Autowired + private MockMvc mockMvc; + + @MockBean + private MedalService medalService; + + @Test + void listMedals() throws Exception { + CommentMedalDto medal = new CommentMedalDto(); + medal.setTitle("评论达人"); + medal.setType(MedalType.COMMENT); + Mockito.when(medalService.getMedals(null)).thenReturn(List.of(medal)); + + mockMvc.perform(get("/api/medals")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].title").value("评论达人")); + } + + @Test + void listMedalsWithUser() throws Exception { + CommentMedalDto medal = new CommentMedalDto(); + medal.setCompleted(true); + medal.setType(MedalType.COMMENT); + Mockito.when(medalService.getMedals(1L)).thenReturn(List.of(medal)); + + mockMvc.perform(get("/api/medals").param("userId", "1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].completed").value(true)); + } +} diff --git a/backend/src/test/java/com/openisle/service/MedalServiceTest.java b/backend/src/test/java/com/openisle/service/MedalServiceTest.java new file mode 100644 index 000000000..297f1c47d --- /dev/null +++ b/backend/src/test/java/com/openisle/service/MedalServiceTest.java @@ -0,0 +1,53 @@ +package com.openisle.service; + +import com.openisle.dto.MedalDto; +import com.openisle.model.MedalType; +import com.openisle.model.User; +import com.openisle.repository.CommentRepository; +import com.openisle.repository.PostRepository; +import com.openisle.repository.UserRepository; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class MedalServiceTest { + @Test + void getMedalsWithoutUser() { + CommentRepository commentRepo = mock(CommentRepository.class); + PostRepository postRepo = mock(PostRepository.class); + UserRepository userRepo = mock(UserRepository.class); + + MedalService service = new MedalService(commentRepo, postRepo, userRepo); + + List medals = service.getMedals(null); + assertFalse(medals.get(0).isCompleted()); + assertFalse(medals.get(1).isCompleted()); + assertFalse(medals.get(2).isCompleted()); + } + + @Test + void getMedalsWithUser() { + CommentRepository commentRepo = mock(CommentRepository.class); + PostRepository postRepo = mock(PostRepository.class); + UserRepository userRepo = mock(UserRepository.class); + + when(commentRepo.countByAuthor_Id(1L)).thenReturn(120L); + when(postRepo.countByAuthor_Id(1L)).thenReturn(80L); + User user = new User(); + user.setId(1L); + user.setCreatedAt(LocalDateTime.of(2025, 9, 15, 0, 0)); + when(userRepo.findById(1L)).thenReturn(Optional.of(user)); + + MedalService service = new MedalService(commentRepo, postRepo, userRepo); + List medals = service.getMedals(1L); + + assertTrue(medals.stream().filter(m -> m.getType() == MedalType.COMMENT).findFirst().orElseThrow().isCompleted()); + assertFalse(medals.stream().filter(m -> m.getType() == MedalType.POST).findFirst().orElseThrow().isCompleted()); + assertTrue(medals.stream().filter(m -> m.getType() == MedalType.SEED).findFirst().orElseThrow().isCompleted()); + } +} From 4207886dce117937fd2927e68005b6df9abe236a Mon Sep 17 00:00:00 2001 From: tim Date: Sat, 9 Aug 2025 14:15:54 +0800 Subject: [PATCH 02/15] feat: achivement --- frontend_nuxt/components/AchievementList.vue | 70 ++++++++++++++++++++ frontend_nuxt/pages/users/[id].vue | 46 +++++++------ 2 files changed, 95 insertions(+), 21 deletions(-) create mode 100644 frontend_nuxt/components/AchievementList.vue diff --git a/frontend_nuxt/components/AchievementList.vue b/frontend_nuxt/components/AchievementList.vue new file mode 100644 index 000000000..016958c33 --- /dev/null +++ b/frontend_nuxt/components/AchievementList.vue @@ -0,0 +1,70 @@ + + + + + \ No newline at end of file diff --git a/frontend_nuxt/pages/users/[id].vue b/frontend_nuxt/pages/users/[id].vue index d85b0a592..7dd2866f9 100644 --- a/frontend_nuxt/pages/users/[id].vue +++ b/frontend_nuxt/pages/users/[id].vue @@ -20,17 +20,10 @@ 取消关注 - +
目标 Lv.{{ levelInfo.currentLevel + 1 }} - +
@@ -46,7 +39,9 @@
最后评论时间:
-
{{ user.lastCommentTime!=null?formatDate(user.lastCommentTime):"暂无评论" }}
+
{{ user.lastCommentTime != null ? formatDate(user.lastCommentTime) : + "暂无评论" }} +
浏览量:
@@ -68,6 +63,11 @@
关注
+
+ +
勋章与成就
+
@@ -228,13 +228,13 @@
-
- +
From 6b5b6b8c81cb57ef607822b392df1b15e28f97cf Mon Sep 17 00:00:00 2001 From: tim Date: Sat, 9 Aug 2025 16:47:40 +0800 Subject: [PATCH 04/15] feat: fix some code --- .../src/main/java/com/openisle/config/SecurityConfig.java | 3 ++- frontend_nuxt/components/AchievementList.vue | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/backend/src/main/java/com/openisle/config/SecurityConfig.java b/backend/src/main/java/com/openisle/config/SecurityConfig.java index e08791768..5e9cdde73 100644 --- a/backend/src/main/java/com/openisle/config/SecurityConfig.java +++ b/backend/src/main/java/com/openisle/config/SecurityConfig.java @@ -112,6 +112,7 @@ public class SecurityConfig { .requestMatchers(HttpMethod.POST,"/api/auth/reason").permitAll() .requestMatchers(HttpMethod.GET, "/api/search/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/users/**").permitAll() + .requestMatchers(HttpMethod.GET, "/api/medals/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/push/public-key").permitAll() .requestMatchers(HttpMethod.GET, "/api/reaction-types").permitAll() .requestMatchers(HttpMethod.GET, "/api/activities/**").permitAll() @@ -147,7 +148,7 @@ public class SecurityConfig { uri.startsWith("/api/search") || uri.startsWith("/api/users") || uri.startsWith("/api/reaction-types") || uri.startsWith("/api/config") || uri.startsWith("/api/activities") || uri.startsWith("/api/push/public-key") || - uri.startsWith("/api/sitemap.xml")); + uri.startsWith("/api/sitemap.xml") || uri.startsWith("/api/medals")); if (authHeader != null && authHeader.startsWith("Bearer ")) { String token = authHeader.substring(7); diff --git a/frontend_nuxt/components/AchievementList.vue b/frontend_nuxt/components/AchievementList.vue index b1ddadd1a..8434b1012 100644 --- a/frontend_nuxt/components/AchievementList.vue +++ b/frontend_nuxt/components/AchievementList.vue @@ -26,7 +26,7 @@ + diff --git a/frontend_nuxt/pages/users/[id].vue b/frontend_nuxt/pages/users/[id].vue index 2aaa20763..1ca4aea78 100644 --- a/frontend_nuxt/pages/users/[id].vue +++ b/frontend_nuxt/pages/users/[id].vue @@ -288,7 +288,11 @@ export default { const subscribed = ref(false) const isLoading = ref(true) const tabLoading = ref(false) - const selectedTab = ref('summary') + const selectedTab = ref( + ['summary', 'timeline', 'following', 'achievements'].includes(route.query.tab) + ? route.query.tab + : 'summary' + ) const followTab = ref('followers') const levelInfo = computed(() => { @@ -473,6 +477,7 @@ export default { onMounted(init) watch(selectedTab, async val => { + router.replace({ query: { ...route.query, tab: val } }) if (val === 'timeline' && timelineItems.value.length === 0) { await loadTimeline() } else if (val === 'following' && followers.value.length === 0 && followings.value.length === 0) { From 6aedec7a9b03d9c75a36d7fd1ca688339815f7ab Mon Sep 17 00:00:00 2001 From: tim Date: Sat, 9 Aug 2025 22:26:46 +0800 Subject: [PATCH 08/15] feat: achievement select to show --- frontend_nuxt/components/AchievementList.vue | 39 +++++++++++++++++++- frontend_nuxt/components/MedalPopup.vue | 6 +-- frontend_nuxt/pages/users/[id].vue | 14 +++++-- 3 files changed, 52 insertions(+), 7 deletions(-) diff --git a/frontend_nuxt/components/AchievementList.vue b/frontend_nuxt/components/AchievementList.vue index 232d6d48c..bb687a123 100644 --- a/frontend_nuxt/components/AchievementList.vue +++ b/frontend_nuxt/components/AchievementList.vue @@ -3,13 +3,14 @@
+
展示
{{ medal.title }}
{{ medal.description }} @@ -54,6 +55,7 @@ const sortedMedals = computed(() => { } .achievements-list-item { + position: relative; display: flex; flex-direction: column; align-items: center; @@ -64,6 +66,10 @@ const sortedMedals = computed(() => { border-radius: 10px; } +.achievements-list-item.select { + border: 2px solid var(--primary-color); +} + .achievements-list-item-icon { width: 200px; height: 200px; @@ -82,5 +88,36 @@ const sortedMedals = computed(() => { .not_completed { filter: grayscale(100%); } + +.achievements-list-item-top-right-label { + font-size: 10px; + color: white; + background-color: var(--primary-color); + padding: 2px 4px; + border-radius: 2px; + position: absolute; + top: 10px; + right: 10px; +} + +@media (max-width: 768px) { + .achievements-list-item-icon { + width: 100px; + height: 100px; + } + + .achievements-list-item-title { + font-size: 14px; + } + + .achievements-list-item-description { + font-size: 12px; + } + + .achievements-list-item { + min-width: calc(50% - 30px); + } +} + diff --git a/frontend_nuxt/components/MedalPopup.vue b/frontend_nuxt/components/MedalPopup.vue index fd0b79229..2407baf72 100644 --- a/frontend_nuxt/components/MedalPopup.vue +++ b/frontend_nuxt/components/MedalPopup.vue @@ -9,8 +9,8 @@
-
去看看
知道了
+
去看看
@@ -75,8 +75,8 @@ export default { } .medal-popup-item-icon { - width: 60px; - height: 60px; + width: 100px; + height: 100px; object-fit: contain; } diff --git a/frontend_nuxt/pages/users/[id].vue b/frontend_nuxt/pages/users/[id].vue index 1ca4aea78..59700f8ad 100644 --- a/frontend_nuxt/pages/users/[id].vue +++ b/frontend_nuxt/pages/users/[id].vue @@ -66,7 +66,7 @@
-
勋章与成就
+
勋章
@@ -466,7 +466,15 @@ export default { const init = async () => { try { await fetchUser() - await loadSummary() + if (selectedTab.value === 'summary') { + await loadSummary() + } else if (selectedTab.value === 'timeline') { + await loadTimeline() + } else if (selectedTab.value === 'following') { + await loadFollow() + } else if (selectedTab.value === 'achievements') { + await loadAchievements() + } } catch (e) { console.error(e) } finally { @@ -477,7 +485,7 @@ export default { onMounted(init) watch(selectedTab, async val => { - router.replace({ query: { ...route.query, tab: val } }) + // router.replace({ query: { ...route.query, tab: val } }) if (val === 'timeline' && timelineItems.value.length === 0) { await loadTimeline() } else if (val === 'following' && followers.value.length === 0 && followings.value.length === 0) { From 041496cf98c0fe7b4c8fc51dfc782aa92b4a5cce Mon Sep 17 00:00:00 2001 From: Tim <135014430+nagisa77@users.noreply.github.com> Date: Sun, 10 Aug 2025 00:13:54 +0800 Subject: [PATCH 09/15] feat: add user medal selection and display --- .../openisle/controller/MedalController.java | 18 ++++++-- .../main/java/com/openisle/dto/AuthorDto.java | 2 + .../main/java/com/openisle/dto/MedalDto.java | 1 + .../com/openisle/dto/MedalSelectRequest.java | 9 ++++ .../java/com/openisle/mapper/UserMapper.java | 1 + .../main/java/com/openisle/model/User.java | 3 ++ .../com/openisle/service/MedalService.java | 38 ++++++++++++----- .../controller/MedalControllerTest.java | 22 ++++++++++ .../openisle/service/MedalServiceTest.java | 42 +++++++++++++++++++ frontend_nuxt/components/AchievementList.vue | 41 +++++++++++++++++- frontend_nuxt/components/CommentItem.vue | 10 ++++- frontend_nuxt/pages/posts/[id]/index.vue | 24 ++++++++++- frontend_nuxt/pages/users/[id].vue | 2 +- frontend_nuxt/utils/medal.js | 9 ++++ 14 files changed, 202 insertions(+), 20 deletions(-) create mode 100644 backend/src/main/java/com/openisle/dto/MedalSelectRequest.java create mode 100644 frontend_nuxt/utils/medal.js diff --git a/backend/src/main/java/com/openisle/controller/MedalController.java b/backend/src/main/java/com/openisle/controller/MedalController.java index 086d3f844..b710f7557 100644 --- a/backend/src/main/java/com/openisle/controller/MedalController.java +++ b/backend/src/main/java/com/openisle/controller/MedalController.java @@ -1,12 +1,12 @@ package com.openisle.controller; import com.openisle.dto.MedalDto; +import com.openisle.dto.MedalSelectRequest; import com.openisle.service.MedalService; import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; import java.util.List; @@ -20,4 +20,14 @@ public class MedalController { public List getMedals(@RequestParam(value = "userId", required = false) Long userId) { return medalService.getMedals(userId); } + + @PostMapping("/select") + public ResponseEntity selectMedal(@RequestBody MedalSelectRequest req, Authentication auth) { + try { + medalService.selectMedal(auth.getName(), req.getType()); + return ResponseEntity.ok().build(); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().build(); + } + } } diff --git a/backend/src/main/java/com/openisle/dto/AuthorDto.java b/backend/src/main/java/com/openisle/dto/AuthorDto.java index 9892df611..4f0bc4887 100644 --- a/backend/src/main/java/com/openisle/dto/AuthorDto.java +++ b/backend/src/main/java/com/openisle/dto/AuthorDto.java @@ -1,6 +1,7 @@ package com.openisle.dto; import lombok.Data; +import com.openisle.model.MedalType; /** * DTO representing a post or comment author. @@ -10,5 +11,6 @@ public class AuthorDto { private Long id; private String username; private String avatar; + private MedalType displayMedal; } diff --git a/backend/src/main/java/com/openisle/dto/MedalDto.java b/backend/src/main/java/com/openisle/dto/MedalDto.java index 757fe5c15..5d6bc8f9a 100644 --- a/backend/src/main/java/com/openisle/dto/MedalDto.java +++ b/backend/src/main/java/com/openisle/dto/MedalDto.java @@ -10,4 +10,5 @@ public class MedalDto { private String description; private MedalType type; private boolean completed; + private boolean selected; } diff --git a/backend/src/main/java/com/openisle/dto/MedalSelectRequest.java b/backend/src/main/java/com/openisle/dto/MedalSelectRequest.java new file mode 100644 index 000000000..0d1f94d1d --- /dev/null +++ b/backend/src/main/java/com/openisle/dto/MedalSelectRequest.java @@ -0,0 +1,9 @@ +package com.openisle.dto; + +import com.openisle.model.MedalType; +import lombok.Data; + +@Data +public class MedalSelectRequest { + private MedalType type; +} diff --git a/backend/src/main/java/com/openisle/mapper/UserMapper.java b/backend/src/main/java/com/openisle/mapper/UserMapper.java index 0244b151e..6e9cfef0e 100644 --- a/backend/src/main/java/com/openisle/mapper/UserMapper.java +++ b/backend/src/main/java/com/openisle/mapper/UserMapper.java @@ -31,6 +31,7 @@ public class UserMapper { dto.setId(user.getId()); dto.setUsername(user.getUsername()); dto.setAvatar(user.getAvatar()); + dto.setDisplayMedal(user.getDisplayMedal()); return dto; } diff --git a/backend/src/main/java/com/openisle/model/User.java b/backend/src/main/java/com/openisle/model/User.java index 8a17dfa85..43bcbe268 100644 --- a/backend/src/main/java/com/openisle/model/User.java +++ b/backend/src/main/java/com/openisle/model/User.java @@ -59,6 +59,9 @@ public class User { @Column(nullable = false) private Role role = Role.USER; + @Enumerated(EnumType.STRING) + private MedalType displayMedal; + @CreationTimestamp @Column(nullable = false, updatable = false, columnDefinition = "DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6)") diff --git a/backend/src/main/java/com/openisle/service/MedalService.java b/backend/src/main/java/com/openisle/service/MedalService.java index daba7c97a..f40ceeb00 100644 --- a/backend/src/main/java/com/openisle/service/MedalService.java +++ b/backend/src/main/java/com/openisle/service/MedalService.java @@ -5,6 +5,7 @@ import com.openisle.dto.MedalDto; import com.openisle.dto.PostMedalDto; import com.openisle.dto.SeedUserMedalDto; import com.openisle.model.MedalType; +import com.openisle.model.User; import com.openisle.repository.CommentRepository; import com.openisle.repository.PostRepository; import com.openisle.repository.UserRepository; @@ -28,6 +29,11 @@ public class MedalService { public List getMedals(Long userId) { List medals = new ArrayList<>(); + User user = null; + if (userId != null) { + user = userRepository.findById(userId).orElse(null); + } + MedalType selected = user != null ? user.getDisplayMedal() : null; CommentMedalDto commentMedal = new CommentMedalDto(); commentMedal.setIcon("https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/achi_comment.png"); @@ -35,7 +41,7 @@ public class MedalService { commentMedal.setDescription("评论超过100条"); commentMedal.setType(MedalType.COMMENT); commentMedal.setTargetCommentCount(COMMENT_TARGET); - if (userId != null) { + if (user != null) { long count = commentRepository.countByAuthor_Id(userId); commentMedal.setCurrentCommentCount(count); commentMedal.setCompleted(count >= COMMENT_TARGET); @@ -43,6 +49,7 @@ public class MedalService { commentMedal.setCurrentCommentCount(0); commentMedal.setCompleted(false); } + commentMedal.setSelected(selected == MedalType.COMMENT); medals.add(commentMedal); PostMedalDto postMedal = new PostMedalDto(); @@ -51,7 +58,7 @@ public class MedalService { postMedal.setDescription("发帖超过100条"); postMedal.setType(MedalType.POST); postMedal.setTargetPostCount(POST_TARGET); - if (userId != null) { + if (user != null) { long count = postRepository.countByAuthor_Id(userId); postMedal.setCurrentPostCount(count); postMedal.setCompleted(count >= POST_TARGET); @@ -59,6 +66,7 @@ public class MedalService { postMedal.setCurrentPostCount(0); postMedal.setCompleted(false); } + postMedal.setSelected(selected == MedalType.POST); medals.add(postMedal); SeedUserMedalDto seedUserMedal = new SeedUserMedalDto(); @@ -66,19 +74,29 @@ public class MedalService { seedUserMedal.setTitle("种子用户"); seedUserMedal.setDescription("2025.9.16前注册的用户"); seedUserMedal.setType(MedalType.SEED); - if (userId != null) { - userRepository.findById(userId).ifPresent(user -> { - seedUserMedal.setRegisterDate(user.getCreatedAt()); - seedUserMedal.setCompleted(user.getCreatedAt().isBefore(SEED_USER_DEADLINE)); - }); - if (seedUserMedal.getRegisterDate() == null) { - seedUserMedal.setCompleted(false); - } + if (user != null) { + seedUserMedal.setRegisterDate(user.getCreatedAt()); + seedUserMedal.setCompleted(user.getCreatedAt().isBefore(SEED_USER_DEADLINE)); } else { seedUserMedal.setCompleted(false); } + seedUserMedal.setSelected(selected == MedalType.SEED); medals.add(seedUserMedal); return medals; } + + public void selectMedal(String username, MedalType type) { + User user = userRepository.findByUsername(username).orElseThrow(); + boolean completed = getMedals(user.getId()).stream() + .filter(m -> m.getType() == type) + .findFirst() + .map(MedalDto::isCompleted) + .orElse(false); + if (!completed) { + throw new IllegalArgumentException("Medal not completed"); + } + user.setDisplayMedal(type); + userRepository.save(user); + } } diff --git a/backend/src/test/java/com/openisle/controller/MedalControllerTest.java b/backend/src/test/java/com/openisle/controller/MedalControllerTest.java index 1dc054cab..1853f92b8 100644 --- a/backend/src/test/java/com/openisle/controller/MedalControllerTest.java +++ b/backend/src/test/java/com/openisle/controller/MedalControllerTest.java @@ -9,11 +9,13 @@ 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.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -49,4 +51,24 @@ class MedalControllerTest { .andExpect(status().isOk()) .andExpect(jsonPath("$[0].completed").value(true)); } + + @Test + void selectMedal() throws Exception { + mockMvc.perform(post("/api/medals/select") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"type\":\"COMMENT\"}") + .principal(() -> "user")) + .andExpect(status().isOk()); + } + + @Test + void selectMedalBadRequest() throws Exception { + Mockito.doThrow(new IllegalArgumentException()).when(medalService) + .selectMedal("user", MedalType.COMMENT); + mockMvc.perform(post("/api/medals/select") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"type\":\"COMMENT\"}") + .principal(() -> "user")) + .andExpect(status().isBadRequest()); + } } diff --git a/backend/src/test/java/com/openisle/service/MedalServiceTest.java b/backend/src/test/java/com/openisle/service/MedalServiceTest.java index 297f1c47d..bcc8659c3 100644 --- a/backend/src/test/java/com/openisle/service/MedalServiceTest.java +++ b/backend/src/test/java/com/openisle/service/MedalServiceTest.java @@ -41,13 +41,55 @@ class MedalServiceTest { User user = new User(); user.setId(1L); user.setCreatedAt(LocalDateTime.of(2025, 9, 15, 0, 0)); + user.setDisplayMedal(MedalType.COMMENT); when(userRepo.findById(1L)).thenReturn(Optional.of(user)); + when(userRepo.findByUsername("user")).thenReturn(Optional.of(user)); MedalService service = new MedalService(commentRepo, postRepo, userRepo); List medals = service.getMedals(1L); assertTrue(medals.stream().filter(m -> m.getType() == MedalType.COMMENT).findFirst().orElseThrow().isCompleted()); + assertTrue(medals.stream().filter(m -> m.getType() == MedalType.COMMENT).findFirst().orElseThrow().isSelected()); assertFalse(medals.stream().filter(m -> m.getType() == MedalType.POST).findFirst().orElseThrow().isCompleted()); + assertFalse(medals.stream().filter(m -> m.getType() == MedalType.POST).findFirst().orElseThrow().isSelected()); assertTrue(medals.stream().filter(m -> m.getType() == MedalType.SEED).findFirst().orElseThrow().isCompleted()); + assertFalse(medals.stream().filter(m -> m.getType() == MedalType.SEED).findFirst().orElseThrow().isSelected()); + } + + @Test + void selectMedal() { + CommentRepository commentRepo = mock(CommentRepository.class); + PostRepository postRepo = mock(PostRepository.class); + UserRepository userRepo = mock(UserRepository.class); + + when(commentRepo.countByAuthor_Id(1L)).thenReturn(120L); + when(postRepo.countByAuthor_Id(1L)).thenReturn(0L); + User user = new User(); + user.setId(1L); + user.setCreatedAt(LocalDateTime.of(2025, 9, 15, 0, 0)); + when(userRepo.findByUsername("user")).thenReturn(Optional.of(user)); + when(userRepo.findById(1L)).thenReturn(Optional.of(user)); + + MedalService service = new MedalService(commentRepo, postRepo, userRepo); + service.selectMedal("user", MedalType.COMMENT); + assertEquals(MedalType.COMMENT, user.getDisplayMedal()); + } + + @Test + void selectMedalNotCompleted() { + CommentRepository commentRepo = mock(CommentRepository.class); + PostRepository postRepo = mock(PostRepository.class); + UserRepository userRepo = mock(UserRepository.class); + + when(commentRepo.countByAuthor_Id(1L)).thenReturn(10L); + when(postRepo.countByAuthor_Id(1L)).thenReturn(0L); + User user = new User(); + user.setId(1L); + user.setCreatedAt(LocalDateTime.of(2025, 9, 15, 0, 0)); + when(userRepo.findByUsername("user")).thenReturn(Optional.of(user)); + when(userRepo.findById(1L)).thenReturn(Optional.of(user)); + + MedalService service = new MedalService(commentRepo, postRepo, userRepo); + assertThrows(IllegalArgumentException.class, () -> service.selectMedal("user", MedalType.COMMENT)); } } diff --git a/frontend_nuxt/components/AchievementList.vue b/frontend_nuxt/components/AchievementList.vue index bb687a123..aefc7bbdf 100644 --- a/frontend_nuxt/components/AchievementList.vue +++ b/frontend_nuxt/components/AchievementList.vue @@ -3,14 +3,15 @@
-
展示
+
展示
{{ medal.title }}
{{ medal.description }} @@ -27,12 +28,18 @@