Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 4
feat(user-center): 用户偏好读写 API + preferences JSONB 列#7
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| package com.involutionhell.backend.usercenter.controller; | ||
| import cn.dev33.satoken.annotation.SaCheckLogin; | ||
| import cn.dev33.satoken.stp.StpUtil; | ||
| import com.involutionhell.backend.common.api.ApiResponse; | ||
| import com.involutionhell.backend.usercenter.service.UserCenterService; | ||
| import org.springframework.web.bind.annotation.GetMapping; | ||
| import org.springframework.web.bind.annotation.PatchMapping; | ||
| import org.springframework.web.bind.annotation.RequestBody; | ||
| import org.springframework.web.bind.annotation.RequestMapping; | ||
| import org.springframework.web.bind.annotation.RestController; | ||
| import java.util.Map; | ||
| /** | ||
| * 用户偏好读写接口,偏好以 JSONB 顶层合并方式存储,前端可自由扩展 key。 | ||
| */ | ||
| @RestController | ||
| @RequestMapping("/api/user-center") | ||
| public class UserPreferencesController { | ||
| private final UserCenterService userCenterService; | ||
| public UserPreferencesController(UserCenterService userCenterService) { | ||
| this.userCenterService = userCenterService; | ||
| } | ||
| /** | ||
| * 获取当前登录用户的偏好,未设置时返回空对象。 | ||
| */ | ||
| @SaCheckLogin | ||
| @GetMapping("/preferences") | ||
| public ApiResponse<Map<String, Object>> getPreferences() { | ||
| long userId = StpUtil.getLoginIdAsLong(); | ||
| return ApiResponse.ok(userCenterService.getPreferences(userId)); | ||
| } | ||
| /** | ||
| * 合并更新当前登录用户的偏好,body 中的 key 覆盖已有同名 key,其余 key 保留。 | ||
| */ | ||
| @SaCheckLogin | ||
| @PatchMapping("/preferences") | ||
| public ApiResponse<Map<String, Object>> patchPreferences(@RequestBody Map<String, Object> patch) { | ||
| long userId = StpUtil.getLoginIdAsLong(); | ||
| return ApiResponse.ok(userCenterService.patchPreferences(userId, patch)); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,13 +1,20 @@ | ||
| package com.involutionhell.backend.usercenter.repository; | ||
| import tools.jackson.core.type.TypeReference; | ||
| import tools.jackson.databind.ObjectMapper; | ||
| import com.involutionhell.backend.usercenter.model.UserAccount; | ||
| import java.sql.PreparedStatement; | ||
| import java.sql.Types; | ||
| import java.util.Arrays; | ||
| import java.util.HashMap; | ||
| import java.util.HashSet; | ||
| import java.util.List; | ||
| import java.util.Map; | ||
| import java.util.Optional; | ||
| import java.util.Set; | ||
| import org.slf4j.Logger; | ||
| import org.slf4j.LoggerFactory; | ||
| import org.springframework.jdbc.core.JdbcTemplate; | ||
| import org.springframework.jdbc.core.RowMapper; | ||
| import org.springframework.jdbc.support.GeneratedKeyHolder; | ||
| @@ -20,13 +27,19 @@ | ||
| @Repository | ||
| public class JdbcUserAccountRepository implements UserAccountRepository { | ||
| private static final Logger log = LoggerFactory.getLogger(JdbcUserAccountRepository.class); | ||
| private final JdbcTemplate jdbc; | ||
| private final ObjectMapper objectMapper; | ||
| private static final TypeReference<Map<String, Object>> MAP_TYPE = new TypeReference<>() {}; | ||
| /** | ||
| * 将数据库行映射为 UserAccount 记录。 | ||
| * roles / permissions 以逗号分隔字符串存储,空字符串对应空集合。 | ||
| * preferences 存为 JSONB(测试 H2 用 VARCHAR),读出后解析为 Map。 | ||
| */ | ||
| private static final RowMapper<UserAccount> ROW_MAPPER = (rs, rowNum) -> new UserAccount( | ||
| private final RowMapper<UserAccount> rowMapper = (rs, rowNum) -> new UserAccount( | ||
| rs.getLong("id"), | ||
| rs.getString("username"), | ||
| rs.getString("password_hash"), | ||
| @@ -36,30 +49,32 @@ public class JdbcUserAccountRepository implements UserAccountRepository { | ||
| parseSet(rs.getString("permissions")), | ||
| rs.getString("avatar_url"), | ||
| rs.getString("email"), | ||
| rs.getObject("github_id", Long.class) // nullable Long | ||
| rs.getObject("github_id", Long.class), | ||
| parseJson(rs.getString("preferences")) | ||
| ); | ||
| public JdbcUserAccountRepository(JdbcTemplate jdbc) { | ||
| public JdbcUserAccountRepository(JdbcTemplate jdbc, ObjectMapper objectMapper) { | ||
| this.jdbc = jdbc; | ||
| this.objectMapper = objectMapper; | ||
| } | ||
| @Override | ||
| public Optional<UserAccount> findById(Long id) { | ||
| List<UserAccount> results = jdbc.query( | ||
| "SELECT * FROM user_accounts WHERE id = ?", ROW_MAPPER, id); | ||
| "SELECT * FROM user_accounts WHERE id = ?", rowMapper, id); | ||
| return results.stream().findFirst(); | ||
| } | ||
| @Override | ||
| public Optional<UserAccount> findByUsername(String username) { | ||
| List<UserAccount> results = jdbc.query( | ||
| "SELECT * FROM user_accounts WHERE username = ?", ROW_MAPPER, username); | ||
| "SELECT * FROM user_accounts WHERE username = ?", rowMapper, username); | ||
| return results.stream().findFirst(); | ||
| } | ||
| @Override | ||
| public List<UserAccount> findAll() { | ||
| return jdbc.query("SELECT * FROM user_accounts ORDER BY id", ROW_MAPPER); | ||
| return jdbc.query("SELECT * FROM user_accounts ORDER BY id", rowMapper); | ||
| } | ||
| @Override | ||
| @@ -74,8 +89,11 @@ public UserAccount updateAuthorization(Long userId, Set<String> roles, Set<Strin | ||
| @Override | ||
| public UserAccount insert(UserAccount userAccount) { | ||
| KeyHolder keyHolder = new GeneratedKeyHolder(); | ||
| String sql = "INSERT INTO user_accounts (username, password_hash, display_name, enabled, roles, permissions, avatar_url, email, github_id) " + | ||
| "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"; | ||
| // 把 preferences 一并写入 INSERT,避免创建用户时携带的初始偏好被丢弃 | ||
| String sql = "INSERT INTO user_accounts (username, password_hash, display_name, enabled, roles, permissions, avatar_url, email, github_id, preferences) " + | ||
| "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; | ||
| String prefsJson = toJson(userAccount.preferences()); | ||
| jdbc.update(connection -> { | ||
| PreparedStatement ps = connection.prepareStatement(sql, new String[]{"id"}); | ||
| @@ -89,6 +107,8 @@ public UserAccount insert(UserAccount userAccount) { | ||
| ps.setString(8, userAccount.email()); | ||
| // github_id 可为 null,用 setObject 处理 | ||
| ps.setObject(9, userAccount.githubId()); | ||
| // jsonb 用 Types.OTHER 让 PostgreSQL 驱动自行识别;H2 会当作字符串处理 | ||
| ps.setObject(10, prefsJson, Types.OTHER); | ||
| return ps; | ||
| }, keyHolder); | ||
| @@ -97,18 +117,9 @@ public UserAccount insert(UserAccount userAccount) { | ||
| throw new IllegalStateException("插入用户失败,无法获取生成的 ID"); | ||
| } | ||
| return new UserAccount( | ||
| key.longValue(), | ||
| userAccount.username(), | ||
| userAccount.passwordHash(), | ||
| userAccount.displayName(), | ||
| userAccount.enabled(), | ||
| userAccount.roles(), | ||
| userAccount.permissions(), | ||
| userAccount.avatarUrl(), | ||
| userAccount.email(), | ||
| userAccount.githubId() | ||
| ); | ||
| // 插入后回读整行,确保返回值与数据库一致,避免遗漏新列或字段漂移 | ||
| return findById(key.longValue()) | ||
| .orElseThrow(() -> new IllegalStateException("插入用户后无法读取回数据: id=" + key.longValue())); | ||
| } | ||
| @Override | ||
| @@ -121,6 +132,72 @@ public UserAccount updateProfile(Long userId, String displayName, String avatarU | ||
| .orElseThrow(() -> new IllegalArgumentException("用户不存在: " + userId)); | ||
| } | ||
| @Override | ||
| public Map<String, Object> findPreferences(Long userId) { | ||
| List<String> results = jdbc.query( | ||
| "SELECT preferences FROM user_accounts WHERE id = ?", | ||
| (rs, rn) -> rs.getString("preferences"), | ||
| userId); | ||
| if (results.isEmpty()) { | ||
| throw new IllegalArgumentException("用户不存在: " + userId); | ||
| } | ||
| return parseJson(results.get(0)); | ||
| } | ||
| @Override | ||
| public Map<String, Object> patchPreferences(Long userId, Map<String, Object> patch) { | ||
| // 直接在 DB 端做原子 merge,避免 Java 侧 read-merge-write 的并发 lost update; | ||
| // 同时用 setObject + Types.OTHER,避开反射 PGobject 在 GraalVM native image 下 | ||
| // reflection hints 未注册导致的启动失败。 | ||
| // | ||
| // PostgreSQL:UPDATE ... SET preferences = preferences || ?::jsonb | ||
| // 使用 jsonb 原生 `||` 操作符做顶层 key 合并,单条语句原子完成 | ||
| // H2(测试环境):UPDATE ... SET preferences = ? (全量覆盖) | ||
| // 测试环境不追求并发正确性,由 service 层先 read-merge-write 保证合并语义 | ||
| String patchJson = toJson(patch); | ||
| boolean isPostgres = isPostgres(); | ||
| if (isPostgres) { | ||
| int updated = jdbc.update(connection -> { | ||
| var ps = connection.prepareStatement( | ||
| "UPDATE user_accounts SET preferences = preferences || ?::jsonb WHERE id = ?"); | ||
| ps.setObject(1, patchJson, Types.OTHER); | ||
| ps.setLong(2, userId); | ||
| return ps; | ||
| }); | ||
| if (updated == 0) { | ||
| throw new IllegalArgumentException("用户不存在: " + userId); | ||
| } | ||
| } else { | ||
| // H2 路径:先读后合并再整体写入(测试环境无并发压力) | ||
| Map<String, Object> existing = findPreferences(userId); | ||
| Map<String, Object> merged = new HashMap<>(existing); | ||
| merged.putAll(patch); | ||
| String mergedJson = toJson(merged); | ||
| int updated = jdbc.update( | ||
| "UPDATE user_accounts SET preferences = ? WHERE id = ?", | ||
| mergedJson, userId); | ||
| if (updated == 0) { | ||
| throw new IllegalArgumentException("用户不存在: " + userId); | ||
| } | ||
| } | ||
| return findPreferences(userId); | ||
| } | ||
| /** 判断当前数据源是否为 PostgreSQL(通过驱动名识别)。 */ | ||
| private boolean isPostgres() { | ||
| try { | ||
| return Boolean.TRUE.equals(jdbc.execute((java.sql.Connection c) -> { | ||
| String name = c.getMetaData().getDriverName(); | ||
| return name != null && name.toLowerCase().contains("postgresql"); | ||
| })); | ||
| } catch (Exception e) { | ||
| log.warn("检测数据源驱动失败,按非 PostgreSQL 兜底: {}", e.getMessage()); | ||
| return false; | ||
| } | ||
| } | ||
| /** | ||
| * 将逗号分隔字符串解析为集合,空串返回空集合。 | ||
| */ | ||
| @@ -140,4 +217,34 @@ private static String joinSet(Set<String> values) { | ||
| } | ||
| return String.join(",", values); | ||
| } | ||
| } | ||
| /** | ||
| * 将 JSON 字符串解析为 Map。 | ||
| * null / 空串 / "{}" 视为"未设置"返回空 Map;解析失败(数据库里脏数据)则抛出异常, | ||
| * 避免静默吞错,让调用方感知并由全局异常处理器返回 500。 | ||
| */ | ||
| private Map<String, Object> parseJson(String json) { | ||
| if (json == null || json.isBlank() || "{}".equals(json.trim())) { | ||
| return new HashMap<>(); | ||
| } | ||
| try { | ||
| return objectMapper.readValue(json, MAP_TYPE); | ||
| } catch (Exception e) { | ||
| log.error("解析 preferences JSON 失败,数据可能已损坏: {}", json, e); | ||
| throw new IllegalStateException("解析 preferences 失败", e); | ||
| } | ||
| } | ||
| /** | ||
| * 将 Map 序列化为 JSON 字符串。 | ||
| * 失败时抛出异常而不是返回 "{}",避免把有问题的偏好当成空偏好静默覆盖掉原有数据。 | ||
| */ | ||
| private String toJson(Map<String, Object> map) { | ||
| try { | ||
| return objectMapper.writeValueAsString(map); | ||
| } catch (Exception e) { | ||
| log.error("序列化 preferences 失败: {}", map, e); | ||
| throw new IllegalStateException("序列化 preferences 失败", e); | ||
| } | ||
Comment on lines
+221
to
+248
CopilotAI | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.
CopilotAIApr 14, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
UserAccount的preferences仅在为 null 时用Map.of()初始化;当传入的是可变 Map(例如从 JDBC/JSON 解析得到的HashMap)时会直接被记录持有引用,破坏 record 的“快照/不可变”语义,也可能被外部修改导致状态漂移。建议在 compact constructor 里做防御性拷贝并不可变化(如preferences = preferences == null ? Map.of() : Map.copyOf(preferences),必要时对嵌套结构另行约束)。