Skip to content

Commit 97a7ca6

Browse files
committed
feat(analytics): 新增事件聚合接口 /analytics/events/summary
1 parent 8e90464 commit 97a7ca6

8 files changed

Lines changed: 288 additions & 0 deletions

File tree

pom.xml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,16 @@
109109
<version>1.39.0</version>
110110
</dependency>
111111

112+
<!-- Spring Cache + Caffeine 本地缓存 -->
113+
<dependency>
114+
<groupId>org.springframework.boot</groupId>
115+
<artifactId>spring-boot-starter-cache</artifactId>
116+
</dependency>
117+
<dependency>
118+
<groupId>com.github.ben-manes.caffeine</groupId>
119+
<artifactId>caffeine</artifactId>
120+
</dependency>
121+
112122
<!-- &lt;!&ndash; redis &ndash;&gt;-->
113123
<!-- <dependency>-->
114124
<!-- <groupId>org.springframework.boot</groupId>-->

src/main/java/com/involutionhell/backend/BackendApplication.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22

33
import org.springframework.boot.SpringApplication;
44
import org.springframework.boot.autoconfigure.SpringBootApplication;
5+
import org.springframework.cache.annotation.EnableCaching;
56

67
@SpringBootApplication
8+
@EnableCaching
79
public class BackendApplication {
810

911
/**
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package com.involutionhell.backend.analytics.controller;
2+
3+
import com.involutionhell.backend.analytics.dto.EventSummaryDto;
4+
import com.involutionhell.backend.analytics.service.EventSummaryService;
5+
import com.involutionhell.backend.common.api.ApiResponse;
6+
import org.springframework.web.bind.annotation.GetMapping;
7+
import org.springframework.web.bind.annotation.RequestMapping;
8+
import org.springframework.web.bind.annotation.RequestParam;
9+
import org.springframework.web.bind.annotation.RestController;
10+
11+
import java.util.List;
12+
13+
/**
14+
* Analytics 聚合接口,目前所有端点均为公开路由(SaTokenConfigure 白名单)。
15+
*/
16+
@RestController
17+
@RequestMapping("/analytics")
18+
public class AnalyticsController {
19+
20+
private final EventSummaryService eventSummaryService;
21+
22+
public AnalyticsController(EventSummaryService eventSummaryService) {
23+
this.eventSummaryService = eventSummaryService;
24+
}
25+
26+
/**
27+
* 按时间窗口返回各事件类型的总数和独立用户数。
28+
*
29+
* @param window 7d | 30d | all,非法值回退到 30d
30+
*/
31+
@GetMapping("/events/summary")
32+
public ApiResponse<List<EventSummaryDto>> eventsSummary(
33+
@RequestParam(defaultValue = "30d") String window) {
34+
return ApiResponse.ok(eventSummaryService.summarize(window));
35+
}
36+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.involutionhell.backend.analytics.dto;
2+
3+
/**
4+
* 事件聚合摘要 DTO。
5+
* uniqueUsers 只统计非 null 的 userId(匿名用户不计入),
6+
* 这样数字更具实际意义,匿名流量可通过 count 减去有用户的事件推算。
7+
*/
8+
public record EventSummaryDto(
9+
String eventType,
10+
long count,
11+
long uniqueUsers
12+
) {}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package com.involutionhell.backend.analytics.service;
2+
3+
import com.involutionhell.backend.analytics.dto.EventSummaryDto;
4+
import org.springframework.cache.annotation.Cacheable;
5+
import org.springframework.jdbc.core.JdbcTemplate;
6+
import org.springframework.stereotype.Service;
7+
8+
import java.util.List;
9+
10+
/**
11+
* 事件聚合服务,只读查询 AnalyticsEvent 表。
12+
* 注意:Prisma 生成的表名和字段名均为 PascalCase/camelCase,
13+
* PostgreSQL 大小写敏感,必须加双引号。
14+
*/
15+
@Service
16+
public class EventSummaryService {
17+
18+
// window 参数映射为 PostgreSQL interval 字符串
19+
private static final String INTERVAL_7D = "7 days";
20+
private static final String INTERVAL_30D = "30 days";
21+
// "all" 时不加 WHERE 时间条件,直接查全量
22+
23+
private final JdbcTemplate jdbcTemplate;
24+
25+
public EventSummaryService(JdbcTemplate jdbcTemplate) {
26+
this.jdbcTemplate = jdbcTemplate;
27+
}
28+
29+
/**
30+
* 按时间窗口聚合各 eventType 的总数和独立用户数。
31+
* TTL 5 分钟由 Caffeine 配置控制,key 为 window 参数值。
32+
*
33+
* @param window "7d" | "30d" | "all",非法值回退到 "30d"
34+
*/
35+
@Cacheable(value = "eventSummary", key = "#window")
36+
public List<EventSummaryDto> summarize(String window) {
37+
String normalizedWindow = normalize(window);
38+
39+
if ("all".equals(normalizedWindow)) {
40+
return jdbcTemplate.query(
41+
"""
42+
SELECT "eventType",
43+
count(*) AS total,
44+
count(DISTINCT "userId") AS unique_users
45+
FROM "AnalyticsEvent"
46+
GROUP BY "eventType"
47+
ORDER BY total DESC
48+
""",
49+
(rs, rowNum) -> new EventSummaryDto(
50+
rs.getString("eventType"),
51+
rs.getLong("total"),
52+
rs.getLong("unique_users")
53+
)
54+
);
55+
}
56+
57+
// 有时间窗口:用 JDBC 占位符传 interval 值,防止 SQL 注入
58+
String interval = "7d".equals(normalizedWindow) ? INTERVAL_7D : INTERVAL_30D;
59+
return jdbcTemplate.query(
60+
"""
61+
SELECT "eventType",
62+
count(*) AS total,
63+
count(DISTINCT "userId") AS unique_users
64+
FROM "AnalyticsEvent"
65+
WHERE "createdAt" > now() - ?::interval
66+
GROUP BY "eventType"
67+
ORDER BY total DESC
68+
""",
69+
(rs, rowNum) -> new EventSummaryDto(
70+
rs.getString("eventType"),
71+
rs.getLong("total"),
72+
rs.getLong("unique_users")
73+
),
74+
interval
75+
);
76+
}
77+
78+
/**
79+
* 将用户传入的 window 字符串规范化。
80+
* 非法值(null 或未知字符串)回退到默认值 "30d"。
81+
*/
82+
String normalize(String window) {
83+
if ("7d".equals(window) || "all".equals(window)) {
84+
return window;
85+
}
86+
return "30d";
87+
}
88+
}

src/main/java/com/involutionhell/backend/common/config/SaTokenConfigure.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ public void addInterceptors(InterceptorRegistry registry) {
2222
.notMatch("/auth/register") // 注册
2323
.notMatch("/oauth/render/github") // GitHub OAuth 授权发起
2424
.notMatch("/api/auth/callback/github") // GitHub OAuth 回调(路径与 OAuth App 注册保持一致)
25+
.notMatch("/analytics/events/summary") // 事件聚合摘要,公开只读接口
2526
.check(r -> StpUtil.checkLogin()); // 未登录抛出 NotLoginException
2627
})).addPathPatterns("/**");
2728
}

src/main/resources/application.properties

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@ justauth.type.github.redirect-uri=${AUTH_URL:http://localhost:3000}/api/auth/cal
3030
# JWT ?? (Temporarily Commented Out for JustAuth Migration)
3131
# jwt.secret-key=${AUTH_SECRET:involutionhell-default-secret-key-32-chars-long}
3232

33+
# ==========================================
34+
# Spring Cache (Caffeine)
35+
# ==========================================
36+
spring.cache.type=caffeine
37+
# eventSummary:TTL 5 分钟,最多缓存 100 个 key
38+
spring.cache.caffeine.spec=maximumSize=100,expireAfterWrite=300s
39+
spring.cache.cache-names=eventSummary
40+
3341
# Actuator
3442
management.endpoints.web.exposure.include=${MANAGEMENT_ENDPOINTS_WEB_EXPOSURE_INCLUDE:health,info,metrics}
3543
# when-authorized:未认证请求只看到 UP/DOWN,不暴露数据库、磁盘等细节
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
package com.involutionhell.backend.analytics.service;
2+
3+
import com.involutionhell.backend.analytics.dto.EventSummaryDto;
4+
import org.junit.jupiter.api.BeforeEach;
5+
import org.junit.jupiter.api.Test;
6+
import org.junit.jupiter.api.extension.ExtendWith;
7+
import org.mockito.ArgumentCaptor;
8+
import org.mockito.Mock;
9+
import org.mockito.junit.jupiter.MockitoExtension;
10+
import org.springframework.jdbc.core.JdbcTemplate;
11+
import org.springframework.jdbc.core.RowMapper;
12+
13+
import java.util.List;
14+
15+
import static org.assertj.core.api.Assertions.assertThat;
16+
import static org.mockito.ArgumentMatchers.*;
17+
import static org.mockito.Mockito.verify;
18+
import static org.mockito.Mockito.when;
19+
20+
/**
21+
* EventSummaryService 单元测试,使用 Mockito mock JdbcTemplate,
22+
* 不依赖真实数据库,专注测试 window 参数解析和 SQL 传参逻辑。
23+
*/
24+
@ExtendWith(MockitoExtension.class)
25+
class EventSummaryServiceTests {
26+
27+
@Mock
28+
private JdbcTemplate jdbcTemplate;
29+
30+
private EventSummaryService service;
31+
32+
@BeforeEach
33+
void setUp() {
34+
service = new EventSummaryService(jdbcTemplate);
35+
}
36+
37+
// ---- window 参数规范化测试 ----
38+
39+
@Test
40+
void normalize_返回7d_当入参为7d() {
41+
assertThat(service.normalize("7d")).isEqualTo("7d");
42+
}
43+
44+
@Test
45+
void normalize_返回30d_当入参为30d() {
46+
assertThat(service.normalize("30d")).isEqualTo("30d");
47+
}
48+
49+
@Test
50+
void normalize_返回all_当入参为all() {
51+
assertThat(service.normalize("all")).isEqualTo("all");
52+
}
53+
54+
@Test
55+
void normalize_回退到30d_当入参为null() {
56+
assertThat(service.normalize(null)).isEqualTo("30d");
57+
}
58+
59+
@Test
60+
void normalize_回退到30d_当入参非法() {
61+
assertThat(service.normalize("invalid")).isEqualTo("30d");
62+
}
63+
64+
// ---- SQL 聚合参数传递测试 ----
65+
66+
@Test
67+
@SuppressWarnings("unchecked")
68+
void summarize_7d_传入正确interval参数() {
69+
List<EventSummaryDto> fakeResult = List.of(
70+
new EventSummaryDto("page_view", 100L, 30L)
71+
);
72+
when(jdbcTemplate.query(anyString(), any(RowMapper.class), any()))
73+
.thenReturn(fakeResult);
74+
75+
List<EventSummaryDto> result = service.summarize("7d");
76+
77+
assertThat(result).hasSize(1);
78+
assertThat(result.getFirst().eventType()).isEqualTo("page_view");
79+
assertThat(result.getFirst().count()).isEqualTo(100L);
80+
81+
// 验证 interval 参数传的是 "7 days"
82+
ArgumentCaptor<Object> intervalCaptor = ArgumentCaptor.forClass(Object.class);
83+
verify(jdbcTemplate).query(anyString(), any(RowMapper.class), intervalCaptor.capture());
84+
assertThat(intervalCaptor.getValue()).isEqualTo("7 days");
85+
}
86+
87+
@Test
88+
@SuppressWarnings("unchecked")
89+
void summarize_30d_传入正确interval参数() {
90+
when(jdbcTemplate.query(anyString(), any(RowMapper.class), any()))
91+
.thenReturn(List.of());
92+
93+
service.summarize("30d");
94+
95+
ArgumentCaptor<Object> intervalCaptor = ArgumentCaptor.forClass(Object.class);
96+
verify(jdbcTemplate).query(anyString(), any(RowMapper.class), intervalCaptor.capture());
97+
assertThat(intervalCaptor.getValue()).isEqualTo("30 days");
98+
}
99+
100+
@Test
101+
@SuppressWarnings("unchecked")
102+
void summarize_all_不传interval参数() {
103+
List<EventSummaryDto> fakeResult = List.of(
104+
new EventSummaryDto("page_view", 500L, 80L),
105+
new EventSummaryDto("agent_welcome", 120L, 40L)
106+
);
107+
// "all" 分支调用无可变参的重载
108+
when(jdbcTemplate.query(anyString(), any(RowMapper.class)))
109+
.thenReturn(fakeResult);
110+
111+
List<EventSummaryDto> result = service.summarize("all");
112+
113+
assertThat(result).hasSize(2);
114+
assertThat(result.get(0).eventType()).isEqualTo("page_view");
115+
assertThat(result.get(1).eventType()).isEqualTo("agent_welcome");
116+
}
117+
118+
@Test
119+
@SuppressWarnings("unchecked")
120+
void summarize_非法window_回退到30d() {
121+
when(jdbcTemplate.query(anyString(), any(RowMapper.class), any()))
122+
.thenReturn(List.of());
123+
124+
service.summarize("xyz");
125+
126+
ArgumentCaptor<Object> intervalCaptor = ArgumentCaptor.forClass(Object.class);
127+
verify(jdbcTemplate).query(anyString(), any(RowMapper.class), intervalCaptor.capture());
128+
// 非法值回退 30d,interval 应为 "30 days"
129+
assertThat(intervalCaptor.getValue()).isEqualTo("30 days");
130+
}
131+
}

0 commit comments

Comments
 (0)