Skip to content

Commit 7a191cf

Browse files
claudelongsizhuo
authored andcommitted
fix(events): 响应 PR #9 Copilot CR — N+1 / seed 幂等 / admin seed / 测试 schema
修复 5 个 Copilot 提出的问题: 1) EventInterestRepository 新增 countByEventIds 批量聚合,EventService 暴露 countInterestByEventIds;EventController 和 EventAdminController 列表接口 都改用一次 GROUP BY 拿全量 interestCount,避免每条活动单独 COUNT 的 N+1。 2) EventRequest.joinTags 先过滤 null 元素再 trim,避免 JSON 客户端在 tags 数组里放 null 时 NPE 导致 500。 3) test-schema.sql 的 event_interests 补齐对 events(id) / user_accounts(id) 的 外键与 ON DELETE CASCADE,和生产 schema 行为对齐;否则 H2 测试无法覆盖 "删 event 级联清 interest" 这条关键路径。 4) schema.sql 的 events seed 原先用 ON CONFLICT DO NOTHING 但 INSERT 不写 id, 正常根本不会产生 conflict,等于非幂等。改成 SELECT ... WHERE NOT EXISTS(title) 的 upsert 风格,重跑 schema 不会再插重复活动。 5) schema.sql 原先的 admin seed 按 GitHub handle(longsizhuo / Mira190 / Crokily)插 user_accounts,但 AuthService.loginByGithub 实际创建的 username 是 "github_{githubId}" 格式,seed 出来的是三个永远没人用的死账号。删掉错误 seed,改用注释指引:首次 OAuth 登录后按 github_id 执行一次 UPDATE 升 roles,避免插错数据。
1 parent c322d3a commit 7a191cf

7 files changed

Lines changed: 102 additions & 34 deletions

File tree

src/main/java/com/involutionhell/backend/events/controller/EventAdminController.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@
1717
import org.springframework.web.bind.annotation.RestController;
1818

1919
import java.util.List;
20+
import java.util.Map;
2021
import java.util.Optional;
22+
import java.util.stream.Collectors;
2123

2224
/**
2325
* 活动管理接口(需要 admin 角色)。
@@ -46,8 +48,12 @@ public EventAdminController(EventService eventService) {
4648
@GetMapping
4749
public ApiResponse<List<EventView>> list() {
4850
List<Event> events = eventService.listAllForAdmin();
51+
// 批量查 interest count 避免 N+1;admin 列表可能包含大量历史活动,
52+
// 单独 COUNT 每条会明显拖慢后台
53+
List<Long> ids = events.stream().map(Event::id).collect(Collectors.toList());
54+
Map<Long, Long> interestCounts = eventService.countInterestByEventIds(ids);
4955
List<EventView> views = events.stream()
50-
.map(e -> EventView.from(e, eventService.countInterest(e.id())))
56+
.map(e -> EventView.from(e, interestCounts.getOrDefault(e.id(), 0L)))
5157
.toList();
5258
return ApiResponse.ok(views);
5359
}

src/main/java/com/involutionhell/backend/events/controller/EventController.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import java.util.List;
1515
import java.util.Map;
1616
import java.util.Optional;
17+
import java.util.stream.Collectors;
1718

1819
/**
1920
* 活动公开读接口(匿名可访问)。
@@ -39,8 +40,11 @@ public EventController(EventService eventService) {
3940
@GetMapping
4041
public ApiResponse<List<EventView>> list() {
4142
List<Event> events = eventService.listPublic();
43+
// 批量一次查完 interest count,避免每个 event 都单独 COUNT(N+1)
44+
List<Long> ids = events.stream().map(Event::id).collect(Collectors.toList());
45+
Map<Long, Long> interestCounts = eventService.countInterestByEventIds(ids);
4246
List<EventView> views = events.stream()
43-
.map(e -> EventView.from(e, eventService.countInterest(e.id())))
47+
.map(e -> EventView.from(e, interestCounts.getOrDefault(e.id(), 0L)))
4448
.toList();
4549
return ApiResponse.ok(views);
4650
}

src/main/java/com/involutionhell/backend/events/dto/EventRequest.java

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,13 @@ private static String emptyToNull(String s) {
5252

5353
private static String joinTags(List<String> tags) {
5454
if (tags == null || tags.isEmpty()) return "";
55-
return String.join(",", tags.stream().map(String::trim).filter(s -> !s.isEmpty()).toList());
55+
// 先过滤 null 再 trim:JSON 客户端允许数组里放 null,裸调 String::trim 会 NPE → 500
56+
return String.join(
57+
",",
58+
tags.stream()
59+
.filter(tag -> tag != null)
60+
.map(String::trim)
61+
.filter(s -> !s.isEmpty())
62+
.toList());
5663
}
5764
}

src/main/java/com/involutionhell/backend/events/repository/EventInterestRepository.java

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,14 @@
22

33
import org.springframework.dao.DuplicateKeyException;
44
import org.springframework.jdbc.core.JdbcTemplate;
5+
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
6+
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
57
import org.springframework.stereotype.Repository;
68

9+
import java.util.Collection;
10+
import java.util.HashMap;
711
import java.util.List;
12+
import java.util.Map;
813

914
/**
1015
* event_interests 表的数据访问。语义和 FollowService 类似——
@@ -17,9 +22,12 @@
1722
public class EventInterestRepository {
1823

1924
private final JdbcTemplate jdbc;
25+
private final NamedParameterJdbcTemplate namedJdbc;
2026

2127
public EventInterestRepository(JdbcTemplate jdbc) {
2228
this.jdbc = jdbc;
29+
// 批量 count 用 named parameter 的 IN 子句,比自己拼 "?,?,?" 更安全
30+
this.namedJdbc = new NamedParameterJdbcTemplate(jdbc);
2331
}
2432

2533
/** 添加感兴趣记录。幂等:同一 (event, user) 已存在时不报错。 */
@@ -49,6 +57,26 @@ public long countByEvent(long eventId) {
4957
return cnt != null ? cnt : 0L;
5058
}
5159

60+
/**
61+
* 批量统计多场活动的兴趣人数,避免列表接口 N+1 查询。
62+
*
63+
* 一次 GROUP BY 查完返回 map;没出现在结果里的 event id(即兴趣人数为 0)调用方
64+
* 自己 getOrDefault(id, 0L) 兜底。传入空集合直接返回空 map,不打 DB。
65+
*/
66+
public Map<Long, Long> countByEventIds(Collection<Long> eventIds) {
67+
if (eventIds == null || eventIds.isEmpty()) return Map.of();
68+
Map<Long, Long> result = new HashMap<>();
69+
MapSqlParameterSource params = new MapSqlParameterSource("ids", eventIds);
70+
namedJdbc.query(
71+
"SELECT event_id, COUNT(*) AS cnt FROM event_interests "
72+
+ "WHERE event_id IN (:ids) GROUP BY event_id",
73+
params,
74+
rs -> {
75+
result.put(rs.getLong("event_id"), rs.getLong("cnt"));
76+
});
77+
return result;
78+
}
79+
5280
/** 当前登录用户是否对某活动感兴趣。匿名调用方需自己短路 false,不要调这个。 */
5381
public boolean isInterested(long eventId, long userId) {
5482
Integer cnt = jdbc.queryForObject(

src/main/java/com/involutionhell/backend/events/service/EventService.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
import com.involutionhell.backend.events.repository.EventRepository;
66
import org.springframework.stereotype.Service;
77

8+
import java.util.Collection;
89
import java.util.List;
10+
import java.util.Map;
911
import java.util.Optional;
1012

1113
/**
@@ -63,6 +65,14 @@ public long countInterest(long eventId) {
6365
return interestRepository.countByEvent(eventId);
6466
}
6567

68+
/**
69+
* 批量拿多场活动的兴趣人数。列表接口用,避免 N+1。
70+
* 返回 map 中不存在的 key 表示该活动兴趣人数为 0,调用方自己 getOrDefault 兜底。
71+
*/
72+
public Map<Long, Long> countInterestByEventIds(Collection<Long> eventIds) {
73+
return interestRepository.countByEventIds(eventIds);
74+
}
75+
6676
/** 当前登录用户是否对某活动感兴趣。匿名调用方需短路传 false,不要调这个。 */
6777
public boolean isInterested(long eventId, long userId) {
6878
return interestRepository.isInterested(eventId, userId);

src/main/resources/schema.sql

Lines changed: 39 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -27,16 +27,16 @@ VALUES ('admin', 'ad89b64d66caa8e30e5d5ce4a9763f4ecc205814c412175f3e2c50027471
2727
('auditor', 'ccabaaba054fb98905b5b9ee47174f57cb6088e04b1526f08b872dc06eaa6bb9', 'Auditor', TRUE, 'auditor', 'user:profile:read,user:center:read')
2828
ON CONFLICT (username) DO NOTHING;
2929

30-
-- 站点维护者(GitHub OAuth 登录后由 sync 服务补 github_id;此处按 username 打 admin role
31-
-- 生产 Neon 上这些账号是 GitHub OAuth 登录后由 AuthService 自动创建的,所以用
32-
-- ON CONFLICT DO UPDATE 幂等升级角色,避免漏 seed。
33-
INSERT INTO user_accounts (username, password_hash, display_name, enabled, roles, permissions)
34-
VALUES ('longsizhuo', '', 'Siz Long', TRUE, 'admin,user', 'user:profile:read,user:center:read,user:center:manage'),
35-
('Mira190', '', 'Mira', TRUE, 'admin,user', 'user:profile:read,user:center:read,user:center:manage'),
36-
('Crokily', '', 'Crokily', TRUE, 'admin,user', 'user:profile:read,user:center:read,user:center:manage')
37-
ON CONFLICT (username) DO UPDATE
38-
SET roles = 'admin,user',
39-
permissions = 'user:profile:read,user:center:read,user:center:manage';
30+
-- 站点维护者 admin 升级:原先尝试按 GitHub handle(longsizhuo / Mira190 / Crokily
31+
-- 做 seed,但 AuthService.loginByGithub 实际创建的 username 是 "github_{githubId}" 格式
32+
-- (见 AuthService.java),按 handle seed 只会插一批永远没人用的本地管理员账号
33+
-- 正确做法:维护者首次 GitHub OAuth 登录后,手动(或由 admin 管理界面)按 github_id
34+
-- 升 roles:
35+
-- UPDATE user_accounts
36+
-- SET roles = 'admin,user',
37+
-- permissions = 'user:profile:read,user:center:read,user:center:manage'
38+
-- WHERE github_id IN (114939201, ...);
39+
-- 本文件不再插入 admin seed 账号。
4040

4141
-- =============================================================================
4242
-- Events(活动)相关表
@@ -76,24 +76,33 @@ CREATE TABLE IF NOT EXISTS event_interests (
7676

7777
CREATE INDEX IF NOT EXISTS idx_event_interests_user_id ON event_interests(user_id);
7878

79-
-- 种子:原来 data/event.json 里的 4 条活动(startTime 不填,先只保留元信息;
80-
-- 管理员登录后在 /admin/events 里补时间再 publish)
79+
-- 种子:原来 data/event.json 里的 4 条活动。幂等策略:
80+
-- events 表主键是 BIGSERIAL id,INSERT 不带 id 所以不会冲突;原先用 ON CONFLICT
81+
-- DO NOTHING 实际上不防重复。这里改用 WHERE NOT EXISTS(title) 做幂等——
82+
-- 每次 schema 重跑时,title 已存在的就跳过。
83+
-- startTime / endTime 先不填,管理员登录后在 /admin/events 里补时间再 publish。
8184
INSERT INTO events (title, description, cover_url, discord_link, playback_url, tags, status)
82-
VALUES
83-
('Mock Interview', '模拟面试专场:匹配面试官 1v1,结束即反馈,积累真实面试体感。', '/event/mockInterview.webp',
84-
'https://discord.gg/QHsjqezfC?event=1430500169299922965',
85-
'https://involutionhell.com/docs/jobs/event-keynote/event-takeway',
86-
'interview,mock', 'archived'),
87-
('Coffee Chat', '邀请业界嘉宾小范围交流,聊 career path、求职反思、日常 dev 体感。', '/event/coffeeChat.webp',
88-
'https://discord.com/invite/8AQZj7sa?event=1432010537402761348',
89-
'https://involutionhell.com/docs/jobs/event-keynote/coffee-chat',
90-
'career,chat', 'archived'),
91-
('Career Journey', '资深从业者分享完整职业路径 + 关键决策点。', '/event/careerJourney.webp',
92-
'https://discord.com/invite/8AQZj7sa?event=1432010537402761348',
93-
'https://involutionhell.com/docs/jobs/event-keynote/event-takeway',
94-
'career,sharing', 'archived'),
95-
('Open.Onion', '持续进行中的开源 / 内部项目协作节奏,参与即获得 contributor 标签。', '/event/openOnion.webp',
96-
'https://discord.gg/kJZFMr5chU?event=1477581193582088304',
97-
NULL,
98-
'project,open-source', 'published')
99-
ON CONFLICT DO NOTHING;
85+
SELECT seed.title, seed.description, seed.cover_url, seed.discord_link,
86+
seed.playback_url, seed.tags, seed.status
87+
FROM (
88+
VALUES
89+
('Mock Interview', '模拟面试专场:匹配面试官 1v1,结束即反馈,积累真实面试体感。', '/event/mockInterview.webp',
90+
'https://discord.gg/QHsjqezfC?event=1430500169299922965',
91+
'https://involutionhell.com/docs/jobs/event-keynote/event-takeway',
92+
'interview,mock', 'archived'),
93+
('Coffee Chat', '邀请业界嘉宾小范围交流,聊 career path、求职反思、日常 dev 体感。', '/event/coffeeChat.webp',
94+
'https://discord.com/invite/8AQZj7sa?event=1432010537402761348',
95+
'https://involutionhell.com/docs/jobs/event-keynote/coffee-chat',
96+
'career,chat', 'archived'),
97+
('Career Journey', '资深从业者分享完整职业路径 + 关键决策点。', '/event/careerJourney.webp',
98+
'https://discord.com/invite/8AQZj7sa?event=1432010537402761348',
99+
'https://involutionhell.com/docs/jobs/event-keynote/event-takeway',
100+
'career,sharing', 'archived'),
101+
('Open.Onion', '持续进行中的开源 / 内部项目协作节奏,参与即获得 contributor 标签。', '/event/openOnion.webp',
102+
'https://discord.gg/kJZFMr5chU?event=1477581193582088304',
103+
NULL,
104+
'project,open-source', 'published')
105+
) AS seed(title, description, cover_url, discord_link, playback_url, tags, status)
106+
WHERE NOT EXISTS (
107+
SELECT 1 FROM events e WHERE e.title = seed.title
108+
);

src/test/resources/test-schema.sql

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,5 +46,9 @@ CREATE TABLE IF NOT EXISTS event_interests (
4646
event_id BIGINT NOT NULL,
4747
user_id BIGINT NOT NULL,
4848
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
49-
PRIMARY KEY (event_id, user_id)
49+
PRIMARY KEY (event_id, user_id),
50+
-- 补齐外键与 ON DELETE CASCADE 和生产 schema 对齐,否则 H2 测试既不能覆盖
51+
-- "删 event 级联清 interest" 这个关键路径,也可能插入不存在的 event/user 产生脏数据
52+
FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE,
53+
FOREIGN KEY (user_id) REFERENCES user_accounts(id) ON DELETE CASCADE
5054
);

0 commit comments

Comments
 (0)