From 7a191cf0622bedab2aea7c9ff567441891696d8e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Apr 2026 03:35:18 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix(events):=20=E5=93=8D=E5=BA=94=20PR=20#9?= =?UTF-8?q?=20Copilot=20CR=20=E2=80=94=20N+1=20/=20seed=20=E5=B9=82?= =?UTF-8?q?=E7=AD=89=20/=20admin=20seed=20/=20=E6=B5=8B=E8=AF=95=20schema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复 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,避免插错数据。 --- .../controller/EventAdminController.java | 8 ++- .../events/controller/EventController.java | 6 +- .../backend/events/dto/EventRequest.java | 9 ++- .../repository/EventInterestRepository.java | 28 ++++++++ .../backend/events/service/EventService.java | 10 +++ src/main/resources/schema.sql | 69 +++++++++++-------- src/test/resources/test-schema.sql | 6 +- 7 files changed, 102 insertions(+), 34 deletions(-) diff --git a/src/main/java/com/involutionhell/backend/events/controller/EventAdminController.java b/src/main/java/com/involutionhell/backend/events/controller/EventAdminController.java index 554670b..4ba2547 100644 --- a/src/main/java/com/involutionhell/backend/events/controller/EventAdminController.java +++ b/src/main/java/com/involutionhell/backend/events/controller/EventAdminController.java @@ -17,7 +17,9 @@ import org.springframework.web.bind.annotation.RestController; import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.stream.Collectors; /** * 活动管理接口(需要 admin 角色)。 @@ -46,8 +48,12 @@ public EventAdminController(EventService eventService) { @GetMapping public ApiResponse> list() { List events = eventService.listAllForAdmin(); + // 批量查 interest count 避免 N+1;admin 列表可能包含大量历史活动, + // 单独 COUNT 每条会明显拖慢后台 + List ids = events.stream().map(Event::id).collect(Collectors.toList()); + Map interestCounts = eventService.countInterestByEventIds(ids); List views = events.stream() - .map(e -> EventView.from(e, eventService.countInterest(e.id()))) + .map(e -> EventView.from(e, interestCounts.getOrDefault(e.id(), 0L))) .toList(); return ApiResponse.ok(views); } diff --git a/src/main/java/com/involutionhell/backend/events/controller/EventController.java b/src/main/java/com/involutionhell/backend/events/controller/EventController.java index a3740fc..2e782ca 100644 --- a/src/main/java/com/involutionhell/backend/events/controller/EventController.java +++ b/src/main/java/com/involutionhell/backend/events/controller/EventController.java @@ -14,6 +14,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.stream.Collectors; /** * 活动公开读接口(匿名可访问)。 @@ -39,8 +40,11 @@ public EventController(EventService eventService) { @GetMapping public ApiResponse> list() { List events = eventService.listPublic(); + // 批量一次查完 interest count,避免每个 event 都单独 COUNT(N+1) + List ids = events.stream().map(Event::id).collect(Collectors.toList()); + Map interestCounts = eventService.countInterestByEventIds(ids); List views = events.stream() - .map(e -> EventView.from(e, eventService.countInterest(e.id()))) + .map(e -> EventView.from(e, interestCounts.getOrDefault(e.id(), 0L))) .toList(); return ApiResponse.ok(views); } diff --git a/src/main/java/com/involutionhell/backend/events/dto/EventRequest.java b/src/main/java/com/involutionhell/backend/events/dto/EventRequest.java index c81ea17..e5c29ec 100644 --- a/src/main/java/com/involutionhell/backend/events/dto/EventRequest.java +++ b/src/main/java/com/involutionhell/backend/events/dto/EventRequest.java @@ -52,6 +52,13 @@ private static String emptyToNull(String s) { private static String joinTags(List tags) { if (tags == null || tags.isEmpty()) return ""; - return String.join(",", tags.stream().map(String::trim).filter(s -> !s.isEmpty()).toList()); + // 先过滤 null 再 trim:JSON 客户端允许数组里放 null,裸调 String::trim 会 NPE → 500 + return String.join( + ",", + tags.stream() + .filter(tag -> tag != null) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .toList()); } } diff --git a/src/main/java/com/involutionhell/backend/events/repository/EventInterestRepository.java b/src/main/java/com/involutionhell/backend/events/repository/EventInterestRepository.java index 4213419..a7fe345 100644 --- a/src/main/java/com/involutionhell/backend/events/repository/EventInterestRepository.java +++ b/src/main/java/com/involutionhell/backend/events/repository/EventInterestRepository.java @@ -2,9 +2,14 @@ import org.springframework.dao.DuplicateKeyException; import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.stereotype.Repository; +import java.util.Collection; +import java.util.HashMap; import java.util.List; +import java.util.Map; /** * event_interests 表的数据访问。语义和 FollowService 类似—— @@ -17,9 +22,12 @@ public class EventInterestRepository { private final JdbcTemplate jdbc; + private final NamedParameterJdbcTemplate namedJdbc; public EventInterestRepository(JdbcTemplate jdbc) { this.jdbc = jdbc; + // 批量 count 用 named parameter 的 IN 子句,比自己拼 "?,?,?" 更安全 + this.namedJdbc = new NamedParameterJdbcTemplate(jdbc); } /** 添加感兴趣记录。幂等:同一 (event, user) 已存在时不报错。 */ @@ -49,6 +57,26 @@ public long countByEvent(long eventId) { return cnt != null ? cnt : 0L; } + /** + * 批量统计多场活动的兴趣人数,避免列表接口 N+1 查询。 + * + * 一次 GROUP BY 查完返回 map;没出现在结果里的 event id(即兴趣人数为 0)调用方 + * 自己 getOrDefault(id, 0L) 兜底。传入空集合直接返回空 map,不打 DB。 + */ + public Map countByEventIds(Collection eventIds) { + if (eventIds == null || eventIds.isEmpty()) return Map.of(); + Map 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; + } + /** 当前登录用户是否对某活动感兴趣。匿名调用方需自己短路 false,不要调这个。 */ public boolean isInterested(long eventId, long userId) { Integer cnt = jdbc.queryForObject( diff --git a/src/main/java/com/involutionhell/backend/events/service/EventService.java b/src/main/java/com/involutionhell/backend/events/service/EventService.java index 5807b98..78c7971 100644 --- a/src/main/java/com/involutionhell/backend/events/service/EventService.java +++ b/src/main/java/com/involutionhell/backend/events/service/EventService.java @@ -5,7 +5,9 @@ import com.involutionhell.backend.events.repository.EventRepository; import org.springframework.stereotype.Service; +import java.util.Collection; import java.util.List; +import java.util.Map; import java.util.Optional; /** @@ -63,6 +65,14 @@ public long countInterest(long eventId) { return interestRepository.countByEvent(eventId); } + /** + * 批量拿多场活动的兴趣人数。列表接口用,避免 N+1。 + * 返回 map 中不存在的 key 表示该活动兴趣人数为 0,调用方自己 getOrDefault 兜底。 + */ + public Map countInterestByEventIds(Collection eventIds) { + return interestRepository.countByEventIds(eventIds); + } + /** 当前登录用户是否对某活动感兴趣。匿名调用方需短路传 false,不要调这个。 */ public boolean isInterested(long eventId, long userId) { return interestRepository.isInterested(eventId, userId); diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index e1d7619..59b9805 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -27,16 +27,16 @@ VALUES ('admin', 'ad89b64d66caa8e30e5d5ce4a9763f4ecc205814c412175f3e2c50027471 ('auditor', 'ccabaaba054fb98905b5b9ee47174f57cb6088e04b1526f08b872dc06eaa6bb9', 'Auditor', TRUE, 'auditor', 'user:profile:read,user:center:read') ON CONFLICT (username) DO NOTHING; --- 站点维护者(GitHub OAuth 登录后由 sync 服务补 github_id;此处按 username 打 admin role) --- 生产 Neon 上这些账号是 GitHub OAuth 登录后由 AuthService 自动创建的,所以用 --- ON CONFLICT DO UPDATE 幂等升级角色,避免漏 seed。 -INSERT INTO user_accounts (username, password_hash, display_name, enabled, roles, permissions) -VALUES ('longsizhuo', '', 'Siz Long', TRUE, 'admin,user', 'user:profile:read,user:center:read,user:center:manage'), - ('Mira190', '', 'Mira', TRUE, 'admin,user', 'user:profile:read,user:center:read,user:center:manage'), - ('Crokily', '', 'Crokily', TRUE, 'admin,user', 'user:profile:read,user:center:read,user:center:manage') -ON CONFLICT (username) DO UPDATE - SET roles = 'admin,user', - permissions = 'user:profile:read,user:center:read,user:center:manage'; +-- 站点维护者 admin 升级:原先尝试按 GitHub handle(longsizhuo / Mira190 / Crokily) +-- 做 seed,但 AuthService.loginByGithub 实际创建的 username 是 "github_{githubId}" 格式 +-- (见 AuthService.java),按 handle seed 只会插一批永远没人用的本地管理员账号。 +-- 正确做法:维护者首次 GitHub OAuth 登录后,手动(或由 admin 管理界面)按 github_id +-- 升 roles: +-- UPDATE user_accounts +-- SET roles = 'admin,user', +-- permissions = 'user:profile:read,user:center:read,user:center:manage' +-- WHERE github_id IN (114939201, ...); +-- 本文件不再插入 admin seed 账号。 -- ============================================================================= -- Events(活动)相关表 @@ -76,24 +76,33 @@ CREATE TABLE IF NOT EXISTS event_interests ( CREATE INDEX IF NOT EXISTS idx_event_interests_user_id ON event_interests(user_id); --- 种子:原来 data/event.json 里的 4 条活动(startTime 不填,先只保留元信息; --- 管理员登录后在 /admin/events 里补时间再 publish) +-- 种子:原来 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 +); diff --git a/src/test/resources/test-schema.sql b/src/test/resources/test-schema.sql index bec2174..5283039 100644 --- a/src/test/resources/test-schema.sql +++ b/src/test/resources/test-schema.sql @@ -46,5 +46,9 @@ CREATE TABLE IF NOT EXISTS event_interests ( event_id BIGINT NOT NULL, user_id BIGINT NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (event_id, user_id) + PRIMARY KEY (event_id, user_id), + -- 补齐外键与 ON DELETE CASCADE 和生产 schema 对齐,否则 H2 测试既不能覆盖 + -- "删 event 级联清 interest" 这个关键路径,也可能插入不存在的 event/user 产生脏数据 + FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES user_accounts(id) ON DELETE CASCADE ); From 088d470542fd52e36d03471622110170f00a8e70 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Apr 2026 03:52:11 +0000 Subject: [PATCH 2/2] =?UTF-8?q?feat(admin):=20superadmin=20=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E7=AE=A1=E7=90=86=20+=20PR=20#10=20CR=EF=BC=88title?= =?UTF-8?q?=20UNIQUE=20/=20=E4=BB=93=E5=82=A8=E6=B5=8B=E8=AF=95=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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。 --- .../controller/AdminUserController.java | 126 ++++++++++++++++ .../backend/events/dto/AdminUserView.java | 38 +++++ .../dto/UpdateUserAdminRoleRequest.java | 17 +++ .../repository/EventInterestRepository.java | 14 +- src/main/resources/schema.sql | 74 +++++----- .../EventInterestRepositoryTests.java | 136 ++++++++++++++++++ src/test/resources/test-schema.sql | 2 +- 7 files changed, 365 insertions(+), 42 deletions(-) create mode 100644 src/main/java/com/involutionhell/backend/events/controller/AdminUserController.java create mode 100644 src/main/java/com/involutionhell/backend/events/dto/AdminUserView.java create mode 100644 src/main/java/com/involutionhell/backend/events/dto/UpdateUserAdminRoleRequest.java create mode 100644 src/test/java/com/involutionhell/backend/events/repository/EventInterestRepositoryTests.java diff --git a/src/main/java/com/involutionhell/backend/events/controller/AdminUserController.java b/src/main/java/com/involutionhell/backend/events/controller/AdminUserController.java new file mode 100644 index 0000000..c942896 --- /dev/null +++ b/src/main/java/com/involutionhell/backend/events/controller/AdminUserController.java @@ -0,0 +1,126 @@ +package com.involutionhell.backend.events.controller; + +import cn.dev33.satoken.annotation.SaCheckRole; +import cn.dev33.satoken.stp.StpUtil; +import com.involutionhell.backend.common.api.ApiResponse; +import com.involutionhell.backend.events.dto.AdminUserView; +import com.involutionhell.backend.events.dto.UpdateUserAdminRoleRequest; +import com.involutionhell.backend.usercenter.model.UserAccount; +import com.involutionhell.backend.usercenter.repository.UserAccountRepository; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; + +/** + * 超管用户管理接口。@SaCheckRole("superadmin") 类级保护。 + * + * 路由: + * - GET /api/admin/users?q=xxx 列出全部用户(可按 username / display_name 模糊搜索) + * - PUT /api/admin/users/{id}/admin {admin: true|false} 授予 / 撤销 admin 角色 + * + * 为什么只有这两个接口: + * - 产品目前只需要"让超管给其他人挂 / 摘 admin",不需要编辑邮箱 / 头像等资料 + * - superadmin 角色不允许通过 API 授予;想加第二个 superadmin 只能走 DB,防止误操作 + * - user 角色由 AuthService OAuth 流程自动挂,也不用 API 管 + * + * 路径前缀 /api/admin/* 和 EventAdminController 保持同一家族,SaToken 白名单默认 + * 不放行,走登录 + 角色校验兜底。 + */ +@RestController +@RequestMapping("/api/admin/users") +@SaCheckRole("superadmin") +public class AdminUserController { + + /** 允许由 API 授予的角色白名单。superadmin 不在此列——必须走 DB,防误操作。 */ + private static final String ROLE_ADMIN = "admin"; + private static final String ROLE_USER = "user"; + private static final String ROLE_SUPERADMIN = "superadmin"; + + private final UserAccountRepository userAccountRepository; + + public AdminUserController(UserAccountRepository userAccountRepository) { + this.userAccountRepository = userAccountRepository; + } + + @GetMapping + public ApiResponse> list(@RequestParam(required = false) String q) { + List all = userAccountRepository.findAll(); + String keyword = q == null ? null : q.trim().toLowerCase(Locale.ROOT); + List views = all.stream() + .filter(u -> matches(u, keyword)) + .map(AdminUserView::from) + .toList(); + return ApiResponse.ok(views); + } + + private static boolean matches(UserAccount u, String kw) { + if (kw == null || kw.isEmpty()) return true; + if (u.username() != null + && u.username().toLowerCase(Locale.ROOT).contains(kw)) { + return true; + } + if (u.displayName() != null + && u.displayName().toLowerCase(Locale.ROOT).contains(kw)) { + return true; + } + if (u.email() != null + && u.email().toLowerCase(Locale.ROOT).contains(kw)) { + return true; + } + return false; + } + + /** + * 切换某用户 admin 角色。 + * + * 规则: + * - 目标用户是 superadmin 时直接 403 —— 不允许给 superadmin 再 "去 admin 化" + * (superadmin 本来就包含 admin 语义,降级 superadmin 只能走 DB) + * - 自己不能给自己摘 admin(防止唯一 admin 把自己锁出来) + * - user 角色始终保留;superadmin 角色保留不动 + * - permissions 字段我们在这一阶段还不细管,直接保留原值;未来如果要按 role + * 派发权限,这里再扩 + */ + @PutMapping("/{userId}/admin") + public ApiResponse setAdminRole( + @PathVariable Long userId, + @RequestBody UpdateUserAdminRoleRequest req) { + if (req == null) return new ApiResponse<>(false, "请求体不能为空", null); + + Optional maybe = userAccountRepository.findById(userId); + if (maybe.isEmpty()) return new ApiResponse<>(false, "用户不存在", null); + UserAccount target = maybe.get(); + + if (target.roles().contains(ROLE_SUPERADMIN)) { + return new ApiResponse<>(false, "superadmin 用户不允许通过 API 修改角色", null); + } + + long self = StpUtil.getLoginIdAsLong(); + if (target.id().equals(self) && !req.admin()) { + return new ApiResponse<>(false, "不能给自己撤销 admin 角色", null); + } + + // 维持原 roles 集合,移除 ROLE_ADMIN 后按请求再加回去;user 始终保留 + Set next = new LinkedHashSet<>(target.roles()); + next.add(ROLE_USER); + if (req.admin()) { + next.add(ROLE_ADMIN); + } else { + next.remove(ROLE_ADMIN); + } + + UserAccount updated = userAccountRepository.updateAuthorization( + target.id(), next, target.permissions()); + return ApiResponse.ok("角色已更新", AdminUserView.from(updated)); + } +} diff --git a/src/main/java/com/involutionhell/backend/events/dto/AdminUserView.java b/src/main/java/com/involutionhell/backend/events/dto/AdminUserView.java new file mode 100644 index 0000000..ab3609b --- /dev/null +++ b/src/main/java/com/involutionhell/backend/events/dto/AdminUserView.java @@ -0,0 +1,38 @@ +package com.involutionhell.backend.events.dto; + +import com.involutionhell.backend.usercenter.model.UserAccount; + +import java.util.List; + +/** + * 超管用户管理列表项。 + * + * 独立于现有的 UserView:这里加了 roles 完整快照(前端 checkbox 显隐需要), + * 不含 passwordHash 等敏感字段。 + * + * 放在 events 模块的 dto 包是因为"管理员界面"入口目前由 Events 模块承担; + * 之后如果拆出独立的 admin 模块,再连同 AdminUserController 一起搬过去。 + */ +public record AdminUserView( + Long id, + String username, + String displayName, + String email, + String avatarUrl, + Long githubId, + boolean enabled, + List roles +) { + public static AdminUserView from(UserAccount u) { + return new AdminUserView( + u.id(), + u.username(), + u.displayName(), + u.email(), + u.avatarUrl(), + u.githubId(), + u.enabled(), + List.copyOf(u.roles()) + ); + } +} diff --git a/src/main/java/com/involutionhell/backend/events/dto/UpdateUserAdminRoleRequest.java b/src/main/java/com/involutionhell/backend/events/dto/UpdateUserAdminRoleRequest.java new file mode 100644 index 0000000..235ba5e --- /dev/null +++ b/src/main/java/com/involutionhell/backend/events/dto/UpdateUserAdminRoleRequest.java @@ -0,0 +1,17 @@ +package com.involutionhell.backend.events.dto; + +/** + * 超管界面的"切换用户 admin 角色"入参。 + * + * 刻意只暴露一个布尔字段 admin: + * true → 授予 admin 角色(普通用户变管理员) + * false → 撤销 admin 角色(降级回普通用户) + * + * 为什么不让前端直接传 roles 列表: + * - 如果暴露 roles 字段,前端可以伪造 "superadmin"、"owner" 等未定义角色, + * 后端就要做严格白名单,不如直接收一个布尔动作 + * - superadmin 角色永远不允许通过 API 授予 / 撤销(防止误操作把唯一站长 + * 降级锁死后台);想升级新的 superadmin 只能走数据库 + * - user 角色由 AuthService 在 OAuth 登录时自动挂上,前端不用管 + */ +public record UpdateUserAdminRoleRequest(boolean admin) {} diff --git a/src/main/java/com/involutionhell/backend/events/repository/EventInterestRepository.java b/src/main/java/com/involutionhell/backend/events/repository/EventInterestRepository.java index a7fe345..f2e69df 100644 --- a/src/main/java/com/involutionhell/backend/events/repository/EventInterestRepository.java +++ b/src/main/java/com/involutionhell/backend/events/repository/EventInterestRepository.java @@ -30,15 +30,21 @@ public EventInterestRepository(JdbcTemplate jdbc) { this.namedJdbc = new NamedParameterJdbcTemplate(jdbc); } - /** 添加感兴趣记录。幂等:同一 (event, user) 已存在时不报错。 */ + /** + * 添加感兴趣记录。幂等:同一 (event, user) 已存在时不报错。 + * + * 为什么不用 ON CONFLICT:H2 在 PostgreSQL MODE 下也不保证支持完整 ON CONFLICT + * 语法(JdbcSQLSyntaxError,而不是 DuplicateKeyException),测试 / 生产方言 + * 一致性更重要。纯 INSERT + PK 唯一约束触发的 DuplicateKeyException 在两种 + * 数据库行为一致——吞掉即可保证幂等语义。 + */ public void add(long eventId, long userId) { try { jdbc.update( - "INSERT INTO event_interests (event_id, user_id, created_at) VALUES (?, ?, NOW()) " - + "ON CONFLICT (event_id, user_id) DO NOTHING", + "INSERT INTO event_interests (event_id, user_id, created_at) VALUES (?, ?, NOW())", eventId, userId); } catch (DuplicateKeyException ignored) { - // H2 或其他驱动可能走 DuplicateKey 分支,一起吞掉保持幂等 + // 已经存在的 (event, user) 组合,幂等吞掉 } } diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index 59b9805..5c4fbf9 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -27,16 +27,21 @@ VALUES ('admin', 'ad89b64d66caa8e30e5d5ce4a9763f4ecc205814c412175f3e2c50027471 ('auditor', 'ccabaaba054fb98905b5b9ee47174f57cb6088e04b1526f08b872dc06eaa6bb9', 'Auditor', TRUE, 'auditor', 'user:profile:read,user:center:read') ON CONFLICT (username) DO NOTHING; --- 站点维护者 admin 升级:原先尝试按 GitHub handle(longsizhuo / Mira190 / Crokily) --- 做 seed,但 AuthService.loginByGithub 实际创建的 username 是 "github_{githubId}" 格式 --- (见 AuthService.java),按 handle seed 只会插一批永远没人用的本地管理员账号。 --- 正确做法:维护者首次 GitHub OAuth 登录后,手动(或由 admin 管理界面)按 github_id --- 升 roles: +-- 站点超管(superadmin)升级: +-- 原先尝试按 GitHub handle 做 seed,但 AuthService.loginByGithub 创建的 username 是 +-- "github_{githubId}" 格式,seed 成其他 username 会插一批永远没人用的死账号。 +-- 正确做法: +-- 1. superadmin 首次 GitHub OAuth 登录过站点(AuthService 会建 github_{id} 账号) +-- 2. 在 DB 跑一次下面这条 SQL 升级 roles;之后 superadmin 在 /admin/users 页面 +-- 给其他维护者打 admin 角色,不再碰 DB +-- -- UPDATE user_accounts --- SET roles = 'admin,user', +-- SET roles = 'superadmin,admin,user', -- permissions = 'user:profile:read,user:center:read,user:center:manage' --- WHERE github_id IN (114939201, ...); --- 本文件不再插入 admin seed 账号。 +-- WHERE github_id = 114939201; -- longsizhuo,按需加其他 superadmin +-- +-- superadmin 语义:拥有全部 admin 权限 + 能管理其他人的 admin 角色。 +-- API 层禁止通过 /api/admin/users 接口授予或撤销 superadmin(防误操作锁死后台)。 -- ============================================================================= -- Events(活动)相关表 @@ -45,9 +50,11 @@ ON CONFLICT (username) DO NOTHING; -- 原先维护在前端 data/event.json,新增/改时间都要改代码发版,迁到后端让管理员自助编辑。 -- 活动主表 +-- title UNIQUE 给 seed 做真正的幂等(ON CONFLICT(title) DO NOTHING)兜底,顺便防 +-- 管理员误建同名活动。实际产品上两场同名活动毫无意义,这个约束的代价是 0。 CREATE TABLE IF NOT EXISTS events ( id BIGSERIAL PRIMARY KEY, - title VARCHAR(255) NOT NULL, + title VARCHAR(255) NOT NULL UNIQUE, description TEXT NOT NULL DEFAULT '', cover_url VARCHAR(500), start_time TIMESTAMPTZ, -- 活动开始时间,null 表示未排期 @@ -76,33 +83,26 @@ CREATE TABLE IF NOT EXISTS event_interests ( CREATE INDEX IF NOT EXISTS idx_event_interests_user_id ON event_interests(user_id); --- 种子:原来 data/event.json 里的 4 条活动。幂等策略: --- events 表主键是 BIGSERIAL id,INSERT 不带 id 所以不会冲突;原先用 ON CONFLICT --- DO NOTHING 实际上不防重复。这里改用 WHERE NOT EXISTS(title) 做幂等—— --- 每次 schema 重跑时,title 已存在的就跳过。 +-- 种子:原来 data/event.json 里的 4 条活动。 +-- 幂等策略:title 是 UNIQUE 约束列,重跑 schema.sql 或并发初始化时由 Postgres +-- 原生 ON CONFLICT(title) DO NOTHING 保证原子幂等,不会插重复。 -- startTime / endTime 先不填,管理员登录后在 /admin/events 里补时间再 publish。 INSERT INTO events (title, description, cover_url, discord_link, playback_url, tags, status) -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 -); +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 (title) DO NOTHING; diff --git a/src/test/java/com/involutionhell/backend/events/repository/EventInterestRepositoryTests.java b/src/test/java/com/involutionhell/backend/events/repository/EventInterestRepositoryTests.java new file mode 100644 index 0000000..68d8858 --- /dev/null +++ b/src/test/java/com/involutionhell/backend/events/repository/EventInterestRepositoryTests.java @@ -0,0 +1,136 @@ +package com.involutionhell.backend.events.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +/** + * EventInterestRepository 集成测试。 + * + * 重点覆盖 CR 要求的场景: + * - countByEventIds 多 event 准确聚合 + * - 未出现在结果集中的 id 对外要能 getOrDefault(0) + * - 传入空集合直接短路,不打 DB + * - add / remove 幂等 + * - ON DELETE CASCADE(H2 test-schema 和生产对齐,删 event 自动清 interest) + * + * 和 JdbcUserAccountRepositoryTests 同一套 H2 + test-schema 模式, + * @Transactional 自动回滚保证互不污染。 + */ +@SpringBootTest( + properties = { + "spring.datasource.url=jdbc:h2:mem:backend;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.sql.init.mode=always", + "spring.sql.init.schema-locations=classpath:test-schema.sql" + }) +@ActiveProfiles("test") +@Transactional +class EventInterestRepositoryTests { + + @Autowired private EventInterestRepository repository; + @Autowired private JdbcTemplate jdbc; + + /** 每个用例开始前种 2 条活动 + 拿 2 个已有种子用户(alice id=2, admin id=1)。 */ + @BeforeEach + void seedEvents() { + jdbc.update( + "INSERT INTO events (title, description, status) VALUES (?, '', 'published')", + "event-A"); + jdbc.update( + "INSERT INTO events (title, description, status) VALUES (?, '', 'published')", + "event-B"); + } + + private long eventId(String title) { + return jdbc.queryForObject("SELECT id FROM events WHERE title = ?", Long.class, title); + } + + @Test + void add_isIdempotent_and_count_reflectsUniqueUsers() { + long a = eventId("event-A"); + repository.add(a, 1L); + repository.add(a, 1L); // 重复 add 不应增加计数 + repository.add(a, 2L); + + assertThat(repository.countByEvent(a)).isEqualTo(2L); + } + + @Test + void remove_isIdempotent() { + long a = eventId("event-A"); + repository.add(a, 1L); + repository.remove(a, 1L); + repository.remove(a, 1L); // 再删一次不报错 + + assertThat(repository.countByEvent(a)).isZero(); + } + + @Test + void countByEventIds_aggregatesAcrossEvents() { + long a = eventId("event-A"); + long b = eventId("event-B"); + repository.add(a, 1L); + repository.add(a, 2L); + repository.add(b, 2L); + + Map result = repository.countByEventIds(List.of(a, b)); + assertThat(result).containsEntry(a, 2L).containsEntry(b, 1L); + } + + @Test + void countByEventIds_missingEventId_returnsNoEntry() { + long a = eventId("event-A"); + long b = eventId("event-B"); + repository.add(a, 1L); + // event-B 没人感兴趣,不应在返回 map 里;调用方自己 getOrDefault(0L) + + Map result = repository.countByEventIds(List.of(a, b)); + assertThat(result).containsEntry(a, 1L).doesNotContainKey(b); + assertThat(result.getOrDefault(b, 0L)).isZero(); + } + + @Test + void countByEventIds_emptyInput_doesNotHitDb() { + // 空集合要短路,不要发出 "WHERE event_id IN ()" 那种非法 SQL + Map result = repository.countByEventIds(Set.of()); + assertThat(result).isEmpty(); + } + + @Test + void isInterested_tracksSingleUser() { + long a = eventId("event-A"); + repository.add(a, 1L); + + assertThat(repository.isInterested(a, 1L)).isTrue(); + assertThat(repository.isInterested(a, 2L)).isFalse(); + } + + @Test + void cascadeDelete_removesInterestsWhenEventDropped() { + long a = eventId("event-A"); + repository.add(a, 1L); + repository.add(a, 2L); + + jdbc.update("DELETE FROM events WHERE id = ?", a); + + // FK ON DELETE CASCADE 应该把两条 interest 都清掉 + Integer remaining = + jdbc.queryForObject( + "SELECT COUNT(*) FROM event_interests WHERE event_id = ?", + Integer.class, + a); + assertThat(remaining).isZero(); + } +} diff --git a/src/test/resources/test-schema.sql b/src/test/resources/test-schema.sql index 5283039..fff0aa6 100644 --- a/src/test/resources/test-schema.sql +++ b/src/test/resources/test-schema.sql @@ -27,7 +27,7 @@ MERGE INTO user_accounts (username, password_hash, display_name, enabled, roles, -- Events 相关表(测试用 H2 语法)。JSONB 用 VARCHAR 代替,与 user_accounts.preferences 的策略一致 CREATE TABLE IF NOT EXISTS events ( id BIGSERIAL PRIMARY KEY, - title VARCHAR(255) NOT NULL, + title VARCHAR(255) NOT NULL UNIQUE, description TEXT NOT NULL DEFAULT '', cover_url VARCHAR(500), start_time TIMESTAMP,