fix(events): 响应 PR #9 Copilot CR — N+1 / seed 幂等 / admin seed / 测试 schema#10
fix(events): 响应 PR #9 Copilot CR — N+1 / seed 幂等 / admin seed / 测试 schema#10longsizhuo merged 2 commits intomainfrom
Conversation
…hema
修复 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,避免插错数据。
There was a problem hiding this comment.
Pull request overview
本 PR 主要针对 events 模块在代码审查中提出的问题做修复:消除列表接口的 N+1 查询、增强入参健壮性、让测试 schema 与生产对齐以覆盖级联删除路径,并调整/移除不正确的 seed 逻辑以减少脏数据与误导。
Changes:
- 将“兴趣人数”统计改为按 eventIds 批量 GROUP BY 查询,并在公开/管理员列表接口使用批量结果替代逐条 COUNT(消除 N+1)。
- 加固
EventRequest.joinTags:先过滤 null 再 trim,避免 JSON 数组含 null 导致 NPE。 - 对齐测试/生产 schema:测试表补齐 FK +
ON DELETE CASCADE;生产schema.sql调整 events seed 的幂等策略并移除错误的 admin seed。
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| src/test/resources/test-schema.sql | 为 event_interests 补 FK + 级联删除,提升 H2 测试对关键路径覆盖能力 |
| src/main/resources/schema.sql | 移除不正确的 admin seed;调整 events seed 幂等插入逻辑 |
| src/main/java/com/involutionhell/backend/events/service/EventService.java | 新增批量兴趣数统计 service 方法供列表接口使用 |
| src/main/java/com/involutionhell/backend/events/repository/EventInterestRepository.java | 新增 countByEventIds 批量统计实现,避免 N+1 |
| src/main/java/com/involutionhell/backend/events/dto/EventRequest.java | joinTags 过滤 null,避免客户端传 null 导致 500 |
| src/main/java/com/involutionhell/backend/events/controller/EventController.java | 公开列表接口改为批量统计兴趣数 |
| src/main/java/com/involutionhell/backend/events/controller/EventAdminController.java | 管理员列表接口改为批量统计兴趣数 |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| -- 种子:原来 data/event.json 里的 4 条活动。幂等策略: | ||
| -- events 表主键是 BIGSERIAL id,INSERT 不带 id 所以不会冲突;原先用 ON CONFLICT | ||
| -- DO NOTHING 实际上不防重复。这里改用 WHERE NOT EXISTS(title) 做幂等—— | ||
| -- 每次 schema 重跑时,title 已存在的就跳过。 | ||
| -- startTime / endTime 先不填,管理员登录后在 /admin/events 里补时间再 publish。 | ||
| INSERT INTO events (title, description, cover_url, discord_link, playback_url, tags, status) | ||
| VALUES | ||
| ('Mock Interview', '模拟面试专场:匹配面试官 1v1,结束即反馈,积累真实面试体感。', '/event/mockInterview.webp', | ||
| 'https://discord.gg/QHsjqezfC?event=1430500169299922965', | ||
| 'https://involutionhell.com/docs/jobs/event-keynote/event-takeway', | ||
| 'interview,mock', 'archived'), | ||
| ('Coffee Chat', '邀请业界嘉宾小范围交流,聊 career path、求职反思、日常 dev 体感。', '/event/coffeeChat.webp', | ||
| 'https://discord.com/invite/8AQZj7sa?event=1432010537402761348', | ||
| 'https://involutionhell.com/docs/jobs/event-keynote/coffee-chat', | ||
| 'career,chat', 'archived'), | ||
| ('Career Journey', '资深从业者分享完整职业路径 + 关键决策点。', '/event/careerJourney.webp', | ||
| 'https://discord.com/invite/8AQZj7sa?event=1432010537402761348', | ||
| 'https://involutionhell.com/docs/jobs/event-keynote/event-takeway', | ||
| 'career,sharing', 'archived'), | ||
| ('Open.Onion', '持续进行中的开源 / 内部项目协作节奏,参与即获得 contributor 标签。', '/event/openOnion.webp', | ||
| 'https://discord.gg/kJZFMr5chU?event=1477581193582088304', | ||
| NULL, | ||
| 'project,open-source', 'published') | ||
| ON CONFLICT DO NOTHING; | ||
| SELECT seed.title, seed.description, seed.cover_url, seed.discord_link, | ||
| seed.playback_url, seed.tags, seed.status | ||
| FROM ( | ||
| VALUES | ||
| ('Mock Interview', '模拟面试专场:匹配面试官 1v1,结束即反馈,积累真实面试体感。', '/event/mockInterview.webp', | ||
| 'https://discord.gg/QHsjqezfC?event=1430500169299922965', | ||
| 'https://involutionhell.com/docs/jobs/event-keynote/event-takeway', | ||
| 'interview,mock', 'archived'), | ||
| ('Coffee Chat', '邀请业界嘉宾小范围交流,聊 career path、求职反思、日常 dev 体感。', '/event/coffeeChat.webp', | ||
| 'https://discord.com/invite/8AQZj7sa?event=1432010537402761348', | ||
| 'https://involutionhell.com/docs/jobs/event-keynote/coffee-chat', | ||
| 'career,chat', 'archived'), | ||
| ('Career Journey', '资深从业者分享完整职业路径 + 关键决策点。', '/event/careerJourney.webp', | ||
| 'https://discord.com/invite/8AQZj7sa?event=1432010537402761348', | ||
| 'https://involutionhell.com/docs/jobs/event-keynote/event-takeway', | ||
| 'career,sharing', 'archived'), | ||
| ('Open.Onion', '持续进行中的开源 / 内部项目协作节奏,参与即获得 contributor 标签。', '/event/openOnion.webp', | ||
| 'https://discord.gg/kJZFMr5chU?event=1477581193582088304', | ||
| NULL, | ||
| 'project,open-source', 'published') | ||
| ) AS seed(title, description, cover_url, discord_link, playback_url, tags, status) | ||
| WHERE NOT EXISTS ( | ||
| SELECT 1 FROM events e WHERE e.title = seed.title | ||
| ); |
There was a problem hiding this comment.
这里用 WHERE NOT EXISTS (SELECT 1 FROM events e WHERE e.title = seed.title) 做幂等,但 events.title 在 schema 里并没有唯一约束/唯一索引,因此该写法在并发初始化(两个实例同时跑 schema.sql)时仍可能插入重复行;同时它也不能防止 seed VALUES 内部出现重复 title 的情况。若需要“真正幂等”,建议为自然键(如 title)加唯一约束并用 ON CONFLICT,或给这 4 条 seed 指定固定 id(并同步 setval 序列)后用 ON CONFLICT (id)。
| /** | ||
| * 批量统计多场活动的兴趣人数,避免列表接口 N+1 查询。 | ||
| * | ||
| * 一次 GROUP BY 查完返回 map;没出现在结果里的 event id(即兴趣人数为 0)调用方 | ||
| * 自己 getOrDefault(id, 0L) 兜底。传入空集合直接返回空 map,不打 DB。 | ||
| */ | ||
| public Map<Long, Long> countByEventIds(Collection<Long> eventIds) { | ||
| if (eventIds == null || eventIds.isEmpty()) return Map.of(); | ||
| Map<Long, Long> result = new HashMap<>(); | ||
| MapSqlParameterSource params = new MapSqlParameterSource("ids", eventIds); | ||
| namedJdbc.query( | ||
| "SELECT event_id, COUNT(*) AS cnt FROM event_interests " | ||
| + "WHERE event_id IN (:ids) GROUP BY event_id", | ||
| params, | ||
| rs -> { | ||
| result.put(rs.getLong("event_id"), rs.getLong("cnt")); | ||
| }); | ||
| return result; | ||
| } |
There was a problem hiding this comment.
新增的批量统计方法缺少回归测试覆盖(例如:多 event_id 计数是否正确、未出现在结果集中的 id 是否按 0 处理、传入空集合是否短路不打 DB)。仓库里已有 JdbcTemplate 仓储的集成测试模式(H2 + test-schema.sql),建议为该方法补一组类似的 repository 集成测试,避免后续改 SQL/方言时回归。
## PR #10 Copilot CR 修复 1) schema.sql 给 events.title 加 UNIQUE 约束,seed 改用真正原子幂等的 ON CONFLICT (title) DO NOTHING(并发初始化 / 重跑都安全)。 2) 新增 EventInterestRepositoryTests(H2 + test-schema)覆盖: - add 幂等、remove 幂等 - countByEventIds 多 id 聚合正确 - 未出现的 id 由调用方 getOrDefault(0L) 兜底 - 空集合不打 DB - cascadeDelete 事件删除级联清理 interest(test-schema FK 对齐生产) 3) 顺手把 EventInterestRepository.add 的 ON CONFLICT 去掉,改纯 INSERT + DuplicateKeyException 兜底:H2 在 PostgreSQL MODE 下不稳支持 ON CONFLICT 语法,改写后测试 / 生产方言一致,跑 7 个测试全绿。 ## 顶级权限(superadmin)功能 - 新增 AdminUserController,类级 @SaCheckRole("superadmin") - GET /api/admin/users?q=xxx 列出所有用户(可按 username / displayName / email 模糊搜,后端做一遍防止大用户表全拉到前端) - PUT /api/admin/users/{id}/admin {admin: true|false} 切换 admin 角色 - 新增 DTO:AdminUserView(外发快照,不含敏感字段)+ UpdateUserAdminRoleRequest (只收一个布尔动作,避免前端伪造未定义角色) - 规则: - superadmin 角色永远不允许通过 API 授予 / 撤销(防误操作锁死后台) - 自己不能给自己撤销 admin(防唯一管理员把自己锁出来) - user 角色始终保留(OAuth 登录时自动挂) ## Seed 文档更新 schema.sql 顶部注释写清 superadmin 升级流程:首次 OAuth 登录 → 手动 UPDATE 一次 roles='superadmin,admin,user';之后所有 admin 授予 / 撤销都在 /admin/users 页面完成,不再碰 DB。
修两条独立但叠加的生产部署 bug:
1) docker-compose.yml 的 environment 缺 AUTH_URL 透传。
application.properties 里 justauth redirect-uri 是
${AUTH_URL:http://localhost:3000}/api/auth/callback/github,
compose 不显式透传时 .env 的 AUTH_URL=https://involutionhell.com
进不到容器,生产 /oauth/render/github 302 的 redirect_uri
变成 localhost,跳 GitHub 必报 redirect_uri_mismatch。
docker inspect 查到运行中容器 AUTH_URL=http://localhost:3010
是老容器手工 docker run 遗留。
2) deploy.yml 的 docker compose up -d 没加 --force-recreate。
CI 的 docker build 会把 :latest 标签指向新 sha,但 compose 看
image 名没变就不重建容器,:latest 指向新 sha 而运行中容器仍
绑死老 sha —— PR #9 / #10 的 SaToken /api/events 白名单、
events controller 全都躺在新镜像里没生效,/api/events 一直 401。
合起来就是"部署全 success 但生产没变化"的伪成功。
后续 merge 这个 PR 触发 deploy workflow 后,验证:
curl -I https://api.involutionhell.com/oauth/render/github | grep -i location
# → redirect_uri=https%3A%2F%2Finvolutionhell.com%2Fapi%2Fauth%2Fcallback%2Fgithub
curl https://api.involutionhell.com/api/events
# → {"success":true,"data":[...]}
⚠️ GitHub OAuth App 的 Authorization callback URLs 需要手动加
https://involutionhell.com/api/auth/callback/github(若还没加)。
修两条独立但叠加的生产部署 bug:
1) docker-compose.yml 的 environment 缺 AUTH_URL 透传。
application.properties 里 justauth redirect-uri 是
${AUTH_URL:http://localhost:3000}/api/auth/callback/github,
compose 不显式透传时 .env 的 AUTH_URL=https://involutionhell.com
进不到容器,生产 /oauth/render/github 302 的 redirect_uri
变成 localhost,跳 GitHub 必报 redirect_uri_mismatch。
docker inspect 查到运行中容器 AUTH_URL=http://localhost:3010
是老容器手工 docker run 遗留。
2) deploy.yml 的 docker compose up -d 没加 --force-recreate。
CI 的 docker build 会把 :latest 标签指向新 sha,但 compose 看
image 名没变就不重建容器,:latest 指向新 sha 而运行中容器仍
绑死老 sha —— PR #9 / #10 的 SaToken /api/events 白名单、
events controller 全都躺在新镜像里没生效,/api/events 一直 401。
合起来就是"部署全 success 但生产没变化"的伪成功。
后续 merge 这个 PR 触发 deploy workflow 后,验证:
curl -I https://api.involutionhell.com/oauth/render/github | grep -i location
# → redirect_uri=https%3A%2F%2Finvolutionhell.com%2Fapi%2Fauth%2Fcallback%2Fgithub
curl https://api.involutionhell.com/api/events
# → {"success":true,"data":[...]}
⚠️ GitHub OAuth App 的 Authorization callback URLs 需要手动加
https://involutionhell.com/api/auth/callback/github(若还没加)。
* fix(deploy): AUTH_URL 透传 + 强制重建容器
修两条独立但叠加的生产部署 bug:
1) docker-compose.yml 的 environment 缺 AUTH_URL 透传。
application.properties 里 justauth redirect-uri 是
${AUTH_URL:http://localhost:3000}/api/auth/callback/github,
compose 不显式透传时 .env 的 AUTH_URL=https://involutionhell.com
进不到容器,生产 /oauth/render/github 302 的 redirect_uri
变成 localhost,跳 GitHub 必报 redirect_uri_mismatch。
docker inspect 查到运行中容器 AUTH_URL=http://localhost:3010
是老容器手工 docker run 遗留。
2) deploy.yml 的 docker compose up -d 没加 --force-recreate。
CI 的 docker build 会把 :latest 标签指向新 sha,但 compose 看
image 名没变就不重建容器,:latest 指向新 sha 而运行中容器仍
绑死老 sha —— PR #9 / #10 的 SaToken /api/events 白名单、
events controller 全都躺在新镜像里没生效,/api/events 一直 401。
合起来就是"部署全 success 但生产没变化"的伪成功。
后续 merge 这个 PR 触发 deploy workflow 后,验证:
curl -I https://api.involutionhell.com/oauth/render/github | grep -i location
# → redirect_uri=https%3A%2F%2Finvolutionhell.com%2Fapi%2Fauth%2Fcallback%2Fgithub
curl https://api.involutionhell.com/api/events
# → {"success":true,"data":[...]}
⚠️ GitHub OAuth App 的 Authorization callback URLs 需要手动加
https://involutionhell.com/api/auth/callback/github(若还没加)。
* Apply suggestion from @Copilot
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* fix(deploy): 回滚路径也加 --force-recreate + --no-deps
CR 指出:回滚分支把 rollback tag 覆盖回 latest 后 image 名依旧是 :latest,
compose 同样会判断"无需重建",导致回滚等于没回(容器还绑着失败那版的 sha)。
- --force-recreate 保证 rollback tag 切换后容器真的重建
- --no-deps 限定只重建 backend 服务,不碰 postgres 等依赖,
避免部署失败同时顺带重启 DB 扩大爆炸半径
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
针对 #9 的 Copilot CR 全部修完。commit author 是 Claude。
修的 5 条
```sql
UPDATE user_accounts
SET roles = 'admin,user',
permissions = 'user:profile:read,user:center:read,user:center:manage'
WHERE github_id IN (114939201, 163523387, 7187663);
-- longsizhuo, Mira190, Crokily
```
前提:这三位先用 GitHub OAuth 登录过站点一次,让 AuthService 创建各自的 `github_{id}` 账号。
验证