diff --git a/backend/src/main/java/com/openisle/OpenIsleApplication.java b/backend/src/main/java/com/openisle/OpenIsleApplication.java index ba2421898..e0c86581f 100644 --- a/backend/src/main/java/com/openisle/OpenIsleApplication.java +++ b/backend/src/main/java/com/openisle/OpenIsleApplication.java @@ -2,8 +2,10 @@ package com.openisle; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication +@EnableScheduling public class OpenIsleApplication { public static void main(String[] args) { SpringApplication.run(OpenIsleApplication.class, args); 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/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..b710f7557 --- /dev/null +++ b/backend/src/main/java/com/openisle/controller/MedalController.java @@ -0,0 +1,33 @@ +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.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +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); + } + + @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/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/ContributorMedalDto.java b/backend/src/main/java/com/openisle/dto/ContributorMedalDto.java new file mode 100644 index 000000000..da130ccca --- /dev/null +++ b/backend/src/main/java/com/openisle/dto/ContributorMedalDto.java @@ -0,0 +1,12 @@ +package com.openisle.dto; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = true) +public class ContributorMedalDto extends MedalDto { + private long currentContributionLines; + private long targetContributionLines; +} + 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..5d6bc8f9a --- /dev/null +++ b/backend/src/main/java/com/openisle/dto/MedalDto.java @@ -0,0 +1,14 @@ +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; + 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/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/mapper/UserMapper.java b/backend/src/main/java/com/openisle/mapper/UserMapper.java index 0244b151e..e96c64a11 100644 --- a/backend/src/main/java/com/openisle/mapper/UserMapper.java +++ b/backend/src/main/java/com/openisle/mapper/UserMapper.java @@ -22,19 +22,23 @@ public class UserMapper { private final UserVisitService userVisitService; private final PostReadService postReadService; private final LevelService levelService; + private final MedalService medalService; @Value("${app.snippet-length:50}") private int snippetLength; public AuthorDto toAuthorDto(User user) { + medalService.ensureDisplayMedal(user); AuthorDto dto = new AuthorDto(); dto.setId(user.getId()); dto.setUsername(user.getUsername()); dto.setAvatar(user.getAvatar()); + dto.setDisplayMedal(user.getDisplayMedal()); return dto; } public UserDto toDto(User user, Authentication viewer) { + medalService.ensureDisplayMedal(user); UserDto dto = new UserDto(); dto.setId(user.getId()); dto.setUsername(user.getUsername()); diff --git a/backend/src/main/java/com/openisle/model/ContributorConfig.java b/backend/src/main/java/com/openisle/model/ContributorConfig.java new file mode 100644 index 000000000..de94b0aa5 --- /dev/null +++ b/backend/src/main/java/com/openisle/model/ContributorConfig.java @@ -0,0 +1,27 @@ +package com.openisle.model; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@Table(name = "contributor_configs") +public class ContributorConfig { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private String userIname; + + @Column(nullable = false, unique = true) + private String githubId; + + @Column(nullable = false) + private long contributionLines = 0; +} + 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..d6c31bb7d --- /dev/null +++ b/backend/src/main/java/com/openisle/model/MedalType.java @@ -0,0 +1,8 @@ +package com.openisle.model; + +public enum MedalType { + COMMENT, + POST, + CONTRIBUTOR, + SEED +} 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/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/ContributorConfigRepository.java b/backend/src/main/java/com/openisle/repository/ContributorConfigRepository.java new file mode 100644 index 000000000..852f142a0 --- /dev/null +++ b/backend/src/main/java/com/openisle/repository/ContributorConfigRepository.java @@ -0,0 +1,11 @@ +package com.openisle.repository; + +import com.openisle.model.ContributorConfig; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface ContributorConfigRepository extends JpaRepository { + Optional findByUserIname(String userIname); +} + 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/ContributorService.java b/backend/src/main/java/com/openisle/service/ContributorService.java new file mode 100644 index 000000000..4bf3b04d3 --- /dev/null +++ b/backend/src/main/java/com/openisle/service/ContributorService.java @@ -0,0 +1,87 @@ +package com.openisle.service; + +import com.openisle.model.ContributorConfig; +import com.openisle.repository.ContributorConfigRepository; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.util.List; +import java.util.Map; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ContributorService { + private static final String OWNER = "nagisa77"; + private static final String REPO = "OpenIsle"; + + private final ContributorConfigRepository repository; + private final RestTemplate restTemplate = new RestTemplate(); + + @PostConstruct + @Scheduled(cron = "0 0 * * * *") + public void updateContributions() { + for (ContributorConfig config : repository.findAll()) { + long lines = fetchContributionLines(config.getGithubId()); + config.setContributionLines(lines); + repository.save(config); + } + } + + private long fetchContributionLines(String githubId) { + try { + String url = String.format("https://api.github.com/repos/%s/%s/stats/contributors", OWNER, REPO); + ResponseEntity response = restTemplate.getForEntity(url, Object.class); + + // 检查是否为202,GitHub有时会返回202表示正在生成统计数据 + if (response.getStatusCodeValue() == 202) { + log.warn("GitHub API 返回202,统计数据正在生成中,githubId: {}", githubId); + return 0; + } + + Object body = response.getBody(); + if (!(body instanceof List)) { + // 不是List类型,直接返回0 + return 0; + } + List listBody = (List) body; + for (Object itemObj : listBody) { + if (!(itemObj instanceof Map)) continue; + Map item = (Map) itemObj; + Map author = (Map) item.get("author"); + if (author != null && githubId.equals(author.get("login"))) { + List> weeks = (List>) item.get("weeks"); + long total = 0; + if (weeks != null) { + for (Map week : weeks) { + Number a = (Number) week.get("a"); + Number d = (Number) week.get("d"); + if (a != null) { + total += a.longValue(); + } + if (d != null) { + total += d.longValue(); + } + } + } + return total; + } + } + } catch (Exception e) { + log.warn(e.getMessage()); + } + return 0; + } + + public long getContributionLines(String userIname) { + return repository.findByUserIname(userIname) + .map(ContributorConfig::getContributionLines) + .orElse(0L); + } +} + 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..ba64923fc --- /dev/null +++ b/backend/src/main/java/com/openisle/service/MedalService.java @@ -0,0 +1,150 @@ +package com.openisle.service; + +import com.openisle.dto.CommentMedalDto; +import com.openisle.dto.ContributorMedalDto; +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; +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 static final long CONTRIBUTION_TARGET = 1; + + private final CommentRepository commentRepository; + private final PostRepository postRepository; + private final UserRepository userRepository; + private final ContributorService contributorService; + + 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"); + commentMedal.setTitle("评论达人"); + commentMedal.setDescription("评论超过100条"); + commentMedal.setType(MedalType.COMMENT); + commentMedal.setTargetCommentCount(COMMENT_TARGET); + if (user != null) { + long count = commentRepository.countByAuthor_Id(userId); + commentMedal.setCurrentCommentCount(count); + commentMedal.setCompleted(count >= COMMENT_TARGET); + } else { + commentMedal.setCurrentCommentCount(0); + commentMedal.setCompleted(false); + } + commentMedal.setSelected(selected == MedalType.COMMENT); + medals.add(commentMedal); + + PostMedalDto postMedal = new PostMedalDto(); + postMedal.setIcon("https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/achi_post.png"); + postMedal.setTitle("发帖达人"); + postMedal.setDescription("发帖超过100条"); + postMedal.setType(MedalType.POST); + postMedal.setTargetPostCount(POST_TARGET); + if (user != null) { + long count = postRepository.countByAuthor_Id(userId); + postMedal.setCurrentPostCount(count); + postMedal.setCompleted(count >= POST_TARGET); + } else { + postMedal.setCurrentPostCount(0); + postMedal.setCompleted(false); + } + postMedal.setSelected(selected == MedalType.POST); + medals.add(postMedal); + + ContributorMedalDto contributorMedal = new ContributorMedalDto(); + contributorMedal.setIcon("https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/achi_coder.png"); + contributorMedal.setTitle("贡献者"); + contributorMedal.setDescription("对仓库贡献超过1行代码"); + contributorMedal.setType(MedalType.CONTRIBUTOR); + contributorMedal.setTargetContributionLines(CONTRIBUTION_TARGET); + if (user != null) { + long lines = contributorService.getContributionLines(user.getUsername()); + contributorMedal.setCurrentContributionLines(lines); + contributorMedal.setCompleted(lines >= CONTRIBUTION_TARGET); + } else { + contributorMedal.setCurrentContributionLines(0); + contributorMedal.setCompleted(false); + } + contributorMedal.setSelected(selected == MedalType.CONTRIBUTOR); + medals.add(contributorMedal); + + SeedUserMedalDto seedUserMedal = new SeedUserMedalDto(); + seedUserMedal.setIcon("https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/achi_seed.png"); + seedUserMedal.setTitle("种子用户"); + seedUserMedal.setDescription("2025.9.16前注册的用户"); + seedUserMedal.setType(MedalType.SEED); + 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); + if (user != null && selected == null) { + for (MedalDto medal : medals) { + if (medal.isCompleted()) { + medal.setSelected(true); + user.setDisplayMedal(medal.getType()); + userRepository.save(user); + break; + } + } + } + + return medals; + } + + public void ensureDisplayMedal(User user) { + if (user == null || user.getDisplayMedal() != null) { + return; + } + if (commentRepository.countByAuthor_Id(user.getId()) >= COMMENT_TARGET) { + user.setDisplayMedal(MedalType.COMMENT); + } else if (postRepository.countByAuthor_Id(user.getId()) >= POST_TARGET) { + user.setDisplayMedal(MedalType.POST); + } else if (contributorService.getContributionLines(user.getUsername()) >= CONTRIBUTION_TARGET) { + user.setDisplayMedal(MedalType.CONTRIBUTOR); + } else if (user.getCreatedAt().isBefore(SEED_USER_DEADLINE)) { + user.setDisplayMedal(MedalType.SEED); + } + if (user.getDisplayMedal() != null) { + userRepository.save(user); + } + } + + 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 new file mode 100644 index 000000000..1853f92b8 --- /dev/null +++ b/backend/src/test/java/com/openisle/controller/MedalControllerTest.java @@ -0,0 +1,74 @@ +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.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; + +@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)); + } + + @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 new file mode 100644 index 000000000..034979dca --- /dev/null +++ b/backend/src/test/java/com/openisle/service/MedalServiceTest.java @@ -0,0 +1,96 @@ +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)); + when(userRepo.findByUsername("user")).thenReturn(Optional.of(user)); + + MedalService service = new MedalService(commentRepo, postRepo, userRepo); + List medals = service.getMedals(1L); + + assertEquals(MedalType.COMMENT, user.getDisplayMedal()); + 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()); + verify(userRepo).save(user); + } + + @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 new file mode 100644 index 000000000..5c925c61d --- /dev/null +++ b/frontend_nuxt/components/AchievementList.vue @@ -0,0 +1,163 @@ + + + + + + diff --git a/frontend_nuxt/components/CommentItem.vue b/frontend_nuxt/components/CommentItem.vue index 3eab5be1b..d2ee2d415 100644 --- a/frontend_nuxt/components/CommentItem.vue +++ b/frontend_nuxt/components/CommentItem.vue @@ -11,6 +11,12 @@
{{ comment.userName }} + + {{ getMedalTitle(comment.medal) }} {{ comment.parentUserName }} @@ -64,6 +70,7 @@ import VueEasyLightbox from 'vue-easy-lightbox' import { useRouter } from 'vue-router' import CommentEditor from './CommentEditor.vue' import { renderMarkdown, handleMarkdownClick } from '../utils/markdown' +import { getMedalTitle } from '../utils/medal' import TimeManager from '../utils/time' import BaseTimeline from './BaseTimeline.vue' import { API_BASE_URL, toast } from '../main' @@ -232,7 +239,7 @@ const CommentItem = { lightboxVisible.value = true } } - return { showReplies, toggleReplies, showEditor, toggleEditor, submitReply, copyCommentLink, renderMarkdown, isWaitingForReply, commentMenuItems, deleteComment, lightboxVisible, lightboxIndex, lightboxImgs, handleContentClick, loggedIn, replyCount, replyList } + return { showReplies, toggleReplies, showEditor, toggleEditor, submitReply, copyCommentLink, renderMarkdown, isWaitingForReply, commentMenuItems, deleteComment, lightboxVisible, lightboxIndex, lightboxImgs, handleContentClick, loggedIn, replyCount, replyList, getMedalTitle } } } @@ -283,6 +290,23 @@ export default CommentItem opacity: 0.3; } +.medal-name { + font-size: 12px; + margin-left: 1px; + opacity: 0.6; + cursor: pointer; + text-decoration: none; + color: var(--text-color); +} + +.medal-icon { + font-size: 12px; + opacity: 0.6; + cursor: pointer; + text-decoration: none; + margin-left: 10px; +} + @keyframes highlight { from { background-color: yellow; diff --git a/frontend_nuxt/components/GlobalPopups.vue b/frontend_nuxt/components/GlobalPopups.vue index fd9e60dfc..33090c2bf 100644 --- a/frontend_nuxt/components/GlobalPopups.vue +++ b/frontend_nuxt/components/GlobalPopups.vue @@ -6,24 +6,36 @@ text="建站送奶茶活动火热进行中,快来参与吧!" @close="closeMilkTeaPopup" /> +
+ diff --git a/frontend_nuxt/components/MedalPopup.vue b/frontend_nuxt/components/MedalPopup.vue new file mode 100644 index 000000000..2407baf72 --- /dev/null +++ b/frontend_nuxt/components/MedalPopup.vue @@ -0,0 +1,113 @@ + + + + + + diff --git a/frontend_nuxt/pages/posts/[id]/index.vue b/frontend_nuxt/pages/posts/[id]/index.vue index 78803c38c..8cee34217 100644 --- a/frontend_nuxt/pages/posts/[id]/index.vue +++ b/frontend_nuxt/pages/posts/[id]/index.vue @@ -41,14 +41,28 @@ avatar
-
{{ author.username }}
+
+ {{ author.username }} + {{ getMedalTitle(author.displayMedal) }} +
{{ postTime }}
-
{{ author.username }}
+
+ {{ author.username }} + {{ getMedalTitle(author.displayMedal) }} +
{{ postTime }}
@@ -116,6 +130,7 @@ import ArticleCategory from '../../../components/ArticleCategory.vue' import ReactionsGroup from '../../../components/ReactionsGroup.vue' import DropdownMenu from '../../../components/DropdownMenu.vue' import { renderMarkdown, handleMarkdownClick, stripMarkdownLength } from '../../../utils/markdown' +import { getMedalTitle } from '../../../utils/medal' import { API_BASE_URL, toast } from '../../../main' import { getToken, authState } from '../../../utils/auth' import TimeManager from '../../../utils/time' @@ -228,6 +243,8 @@ export default { const mapComment = (c, parentUserName = '', level = 0) => ({ id: c.id, userName: c.author.username, + medal: c.author.displayMedal, + userId: c.author.id, time: TimeManager.format(c.createdAt), avatar: c.author.avatar, text: c.content, @@ -648,6 +665,8 @@ export default { commentSort, fetchCommentSorts, isFetchingComments + , + getMedalTitle } } } @@ -926,6 +945,13 @@ export default { opacity: 0.7; } +.user-medal { + font-size: 12px; + margin-left: 4px; + opacity: 0.6; + cursor: pointer; +} + .post-time { font-size: 14px; opacity: 0.5; @@ -990,6 +1016,10 @@ export default { font-size: 14px; } + .user-medal { + font-size: 12px; + } + .post-time { font-size: 12px; } diff --git a/frontend_nuxt/pages/users/[id].vue b/frontend_nuxt/pages/users/[id].vue index d85b0a592..760204293 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 @@
-