From 5053ac213d7792286f8ccd68b5722d5b41451f27 Mon Sep 17 00:00:00 2001 From: Tim <135014430+nagisa77@users.noreply.github.com> Date: Wed, 27 Aug 2025 15:42:49 +0800 Subject: [PATCH] test(points): cover trend endpoint --- .../controller/PointHistoryController.java | 8 +++ .../repository/PointHistoryRepository.java | 3 + .../com/openisle/service/PointService.java | 25 +++++++ .../PointHistoryControllerTest.java | 65 +++++++++++++++++++ frontend_nuxt/pages/points.vue | 41 +++++++++++- 5 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 backend/src/test/java/com/openisle/controller/PointHistoryControllerTest.java diff --git a/backend/src/main/java/com/openisle/controller/PointHistoryController.java b/backend/src/main/java/com/openisle/controller/PointHistoryController.java index a547d309a..1a4235e3a 100644 --- a/backend/src/main/java/com/openisle/controller/PointHistoryController.java +++ b/backend/src/main/java/com/openisle/controller/PointHistoryController.java @@ -7,9 +7,11 @@ import lombok.RequiredArgsConstructor; import org.springframework.security.core.Authentication; 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; +import java.util.Map; import java.util.stream.Collectors; @RestController @@ -25,4 +27,10 @@ public class PointHistoryController { .map(pointHistoryMapper::toDto) .collect(Collectors.toList()); } + + @GetMapping("/trend") + public List> trend(Authentication auth, + @RequestParam(value = "days", defaultValue = "30") int days) { + return pointService.trend(auth.getName(), days); + } } diff --git a/backend/src/main/java/com/openisle/repository/PointHistoryRepository.java b/backend/src/main/java/com/openisle/repository/PointHistoryRepository.java index ac1ee7096..ac62b7df3 100644 --- a/backend/src/main/java/com/openisle/repository/PointHistoryRepository.java +++ b/backend/src/main/java/com/openisle/repository/PointHistoryRepository.java @@ -4,9 +4,12 @@ import com.openisle.model.PointHistory; import com.openisle.model.User; import org.springframework.data.jpa.repository.JpaRepository; +import java.time.LocalDateTime; import java.util.List; public interface PointHistoryRepository extends JpaRepository { List findByUserOrderByIdDesc(User user); long countByUser(User user); + + List findByUserAndCreatedAtAfterOrderByCreatedAtDesc(User user, LocalDateTime createdAt); } diff --git a/backend/src/main/java/com/openisle/service/PointService.java b/backend/src/main/java/com/openisle/service/PointService.java index 2e5fa48b7..086f2c7a9 100644 --- a/backend/src/main/java/com/openisle/service/PointService.java +++ b/backend/src/main/java/com/openisle/service/PointService.java @@ -7,6 +7,10 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; @Service @RequiredArgsConstructor @@ -173,4 +177,25 @@ public class PointService { return pointHistoryRepository.findByUserOrderByIdDesc(user); } + public List> trend(String userName, int days) { + if (days < 1) days = 1; + User user = userRepository.findByUsername(userName).orElseThrow(); + LocalDate end = LocalDate.now(); + LocalDate start = end.minusDays(days - 1L); + var histories = pointHistoryRepository.findByUserAndCreatedAtAfterOrderByCreatedAtDesc( + user, start.atStartOfDay()); + int idx = 0; + int balance = user.getPoint(); + List> result = new ArrayList<>(); + for (LocalDate day = end; !day.isBefore(start); day = day.minusDays(1)) { + result.add(Map.of("date", day.toString(), "value", balance)); + while (idx < histories.size() && histories.get(idx).getCreatedAt().toLocalDate().isEqual(day)) { + balance -= histories.get(idx).getAmount(); + idx++; + } + } + Collections.reverse(result); + return result; + } + } diff --git a/backend/src/test/java/com/openisle/controller/PointHistoryControllerTest.java b/backend/src/test/java/com/openisle/controller/PointHistoryControllerTest.java new file mode 100644 index 000000000..ca28563cf --- /dev/null +++ b/backend/src/test/java/com/openisle/controller/PointHistoryControllerTest.java @@ -0,0 +1,65 @@ +package com.openisle.controller; + +import com.openisle.config.CustomAccessDeniedHandler; +import com.openisle.config.SecurityConfig; +import com.openisle.service.PointService; +import com.openisle.mapper.PointHistoryMapper; +import com.openisle.service.JwtService; +import com.openisle.repository.UserRepository; +import com.openisle.model.User; +import com.openisle.model.Role; +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.context.annotation.Import; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +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(PointHistoryController.class) +@AutoConfigureMockMvc +@Import({SecurityConfig.class, CustomAccessDeniedHandler.class}) +class PointHistoryControllerTest { + @Autowired + private MockMvc mockMvc; + + @MockBean + private JwtService jwtService; + @MockBean + private UserRepository userRepository; + @MockBean + private PointService pointService; + @MockBean + private PointHistoryMapper pointHistoryMapper; + + @Test + void trendReturnsSeries() throws Exception { + Mockito.when(jwtService.validateAndGetSubject("token")).thenReturn("user"); + User user = new User(); + user.setUsername("user"); + user.setPassword("p"); + user.setEmail("u@example.com"); + user.setRole(Role.USER); + Mockito.when(userRepository.findByUsername("user")).thenReturn(Optional.of(user)); + List> data = List.of( + Map.of("date", java.time.LocalDate.now().minusDays(1).toString(), "value", 100), + Map.of("date", java.time.LocalDate.now().toString(), "value", 110) + ); + Mockito.when(pointService.trend(Mockito.eq("user"), Mockito.anyInt())).thenReturn(data); + + mockMvc.perform(get("/api/point-histories/trend").param("days", "2") + .header("Authorization", "Bearer token")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].value").value(100)) + .andExpect(jsonPath("$[1].value").value(110)); + } +} diff --git a/frontend_nuxt/pages/points.vue b/frontend_nuxt/pages/points.vue index 7a1bba5b3..946df8be9 100644 --- a/frontend_nuxt/pages/points.vue +++ b/frontend_nuxt/pages/points.vue @@ -12,6 +12,13 @@ +
+
积分走势
+ + + +
+
@@ -178,6 +185,13 @@ import BasePlaceholder from '~/components/BasePlaceholder.vue' import { stripMarkdownLength } from '~/utils/markdown' import TimeManager from '~/utils/time' import BaseTabs from '~/components/BaseTabs.vue' +import { LineChart } from 'echarts/charts' +import { GridComponent, TooltipComponent } from 'echarts/components' +import { use } from 'echarts/core' +import { CanvasRenderer } from 'echarts/renderers' +import VChart from 'vue-echarts' + +use([LineChart, GridComponent, TooltipComponent, CanvasRenderer]) const config = useRuntimeConfig() const API_BASE_URL = config.public.apiBaseUrl @@ -192,6 +206,7 @@ const isLoading = ref(false) const histories = ref([]) const historyLoading = ref(false) const historyLoaded = ref(false) +const trendOption = ref(null) const pointRules = [ '发帖:每天前两次,每次 30 积分', @@ -221,13 +236,34 @@ const iconMap = { LOTTERY_REWARD: 'fas fa-ticket-alt', } +const loadTrend = async () => { + if (!authState.loggedIn) return + const token = getToken() + const res = await fetch(`${API_BASE_URL}/api/point-histories/trend?days=30`, { + headers: { Authorization: `Bearer ${token}` }, + }) + if (res.ok) { + const data = await res.json() + const dates = data.map((d) => d.date) + const values = data.map((d) => d.value) + trendOption.value = { + tooltip: { trigger: 'axis' }, + xAxis: { type: 'category', data: dates }, + yAxis: { type: 'value' }, + series: [{ type: 'line', areaStyle: {}, smooth: true, data: values }], + } + } +} + onMounted(async () => { isLoading.value = true if (authState.loggedIn) { const user = await fetchCurrentUser() point.value = user ? user.point : null + await Promise.all([loadGoods(), loadTrend()]) + } else { + await loadGoods() } - await loadGoods() isLoading.value = false }) @@ -363,7 +399,8 @@ const submitRedeem = async () => { } .rules, -.goods { +.goods, +.trend { margin-top: 20px; }