Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<AdminUserView>> list(@RequestParam(required = false) String q) {
List<UserAccount> all = userAccountRepository.findAll();
String keyword = q == null ? null : q.trim().toLowerCase(Locale.ROOT);
List<AdminUserView> 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<AdminUserView> setAdminRole(
@PathVariable Long userId,
@RequestBody UpdateUserAdminRoleRequest req) {
if (req == null) return new ApiResponse<>(false, "请求体不能为空", null);

Optional<UserAccount> 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<String> 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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 角色)。
Expand Down Expand Up @@ -46,8 +48,12 @@ public EventAdminController(EventService eventService) {
@GetMapping
public ApiResponse<List<EventView>> list() {
List<Event> events = eventService.listAllForAdmin();
// 批量查 interest count 避免 N+1;admin 列表可能包含大量历史活动,
// 单独 COUNT 每条会明显拖慢后台
List<Long> ids = events.stream().map(Event::id).collect(Collectors.toList());
Map<Long, Long> interestCounts = eventService.countInterestByEventIds(ids);
List<EventView> 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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;

/**
* 活动公开读接口(匿名可访问)。
Expand All @@ -39,8 +40,11 @@ public EventController(EventService eventService) {
@GetMapping
public ApiResponse<List<EventView>> list() {
List<Event> events = eventService.listPublic();
// 批量一次查完 interest count,避免每个 event 都单独 COUNT(N+1)
List<Long> ids = events.stream().map(Event::id).collect(Collectors.toList());
Map<Long, Long> interestCounts = eventService.countInterestByEventIds(ids);
List<EventView> 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);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> 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())
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ private static String emptyToNull(String s) {

private static String joinTags(List<String> 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());
}
}
Original file line number Diff line number Diff line change
@@ -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) {}
Original file line number Diff line number Diff line change
Expand Up @@ -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 类似——
Expand All @@ -17,20 +22,29 @@
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) 已存在时不报错。 */
/**
* 添加感兴趣记录。幂等:同一 (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) 组合,幂等吞掉
}
}

Expand All @@ -49,6 +63,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<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;
}
Comment on lines +66 to +84

Copilot AI Apr 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

新增的批量统计方法缺少回归测试覆盖(例如:多 event_id 计数是否正确、未出现在结果集中的 id 是否按 0 处理、传入空集合是否短路不打 DB)。仓库里已有 JdbcTemplate 仓储的集成测试模式(H2 + test-schema.sql),建议为该方法补一组类似的 repository 集成测试,避免后续改 SQL/方言时回归。

Copilot uses AI. Check for mistakes.

/** 当前登录用户是否对某活动感兴趣。匿名调用方需自己短路 false,不要调这个。 */
public boolean isInterested(long eventId, long userId) {
Integer cnt = jdbc.queryForObject(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -63,6 +65,14 @@ public long countInterest(long eventId) {
return interestRepository.countByEvent(eventId);
}

/**
* 批量拿多场活动的兴趣人数。列表接口用,避免 N+1。
* 返回 map 中不存在的 key 表示该活动兴趣人数为 0,调用方自己 getOrDefault 兜底。
*/
public Map<Long, Long> countInterestByEventIds(Collection<Long> eventIds) {
return interestRepository.countByEventIds(eventIds);
}

/** 当前登录用户是否对某活动感兴趣。匿名调用方需短路传 false,不要调这个。 */
public boolean isInterested(long eventId, long userId) {
return interestRepository.isInterested(eventId, userId);
Expand Down
Loading