From b93bc85270ee3becb3db83789e0cbf5ec791f662 Mon Sep 17 00:00:00 2001 From: longsizhuo Date: Tue, 14 Apr 2026 18:43:23 +0000 Subject: [PATCH 1/3] =?UTF-8?q?feat(user-center):=20user=5Faccounts=20?= =?UTF-8?q?=E5=8A=A0=20preferences=20=E5=88=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/schema.sql | 3 +++ src/test/resources/test-schema.sql | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index 39c97b1..1791d9e 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -14,6 +14,9 @@ CREATE TABLE IF NOT EXISTS user_accounts ( github_id BIGINT UNIQUE ); +-- 偏好设置列(JSONB 顶层合并,前端可自由扩展 key) +ALTER TABLE user_accounts ADD COLUMN IF NOT EXISTS preferences JSONB NOT NULL DEFAULT '{}'::jsonb; + -- 默认种子账号(已存在则跳过) -- admin / Admin@123456 -- alice / Alice@123456 diff --git a/src/test/resources/test-schema.sql b/src/test/resources/test-schema.sql index aea8bca..89d7d50 100644 --- a/src/test/resources/test-schema.sql +++ b/src/test/resources/test-schema.sql @@ -9,7 +9,8 @@ CREATE TABLE IF NOT EXISTS user_accounts ( permissions TEXT NOT NULL DEFAULT '', avatar_url VARCHAR(500), email VARCHAR(255), - github_id BIGINT UNIQUE + github_id BIGINT UNIQUE, + preferences VARCHAR(4000) NOT NULL DEFAULT '{}' ); -- 种子账号(与生产保持一致)逐行插入,H2 兼容写法 From 18e363b8fcd84701827201a192fc37c80d1dfeb1 Mon Sep 17 00:00:00 2001 From: longsizhuo Date: Tue, 14 Apr 2026 18:43:30 +0000 Subject: [PATCH 2/3] =?UTF-8?q?feat(user-center):=20=E5=81=8F=E5=A5=BD?= =?UTF-8?q?=E8=AF=BB=E5=86=99=E6=8E=A5=E5=8F=A3=20GET/PATCH=20/api/user-ce?= =?UTF-8?q?nter/preferences?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/UserPreferencesController.java | 47 ++++++++++ .../backend/usercenter/model/UserAccount.java | 13 ++- .../repository/JdbcUserAccountRepository.java | 92 +++++++++++++++++-- .../repository/UserAccountRepository.java | 11 +++ .../usercenter/service/AuthService.java | 3 +- .../usercenter/service/UserCenterService.java | 20 ++++ ...PreferencesControllerIntegrationTests.java | 77 ++++++++++++++++ .../usercenter/model/UserAccountTests.java | 6 +- .../JdbcUserAccountRepositoryTests.java | 6 +- .../usercenter/service/AuthServiceTests.java | 6 +- 10 files changed, 258 insertions(+), 23 deletions(-) create mode 100644 src/main/java/com/involutionhell/backend/usercenter/controller/UserPreferencesController.java create mode 100644 src/test/java/com/involutionhell/backend/usercenter/controller/UserPreferencesControllerIntegrationTests.java diff --git a/src/main/java/com/involutionhell/backend/usercenter/controller/UserPreferencesController.java b/src/main/java/com/involutionhell/backend/usercenter/controller/UserPreferencesController.java new file mode 100644 index 0000000..cfb9b2e --- /dev/null +++ b/src/main/java/com/involutionhell/backend/usercenter/controller/UserPreferencesController.java @@ -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> getPreferences() { + long userId = StpUtil.getLoginIdAsLong(); + return ApiResponse.ok(userCenterService.getPreferences(userId)); + } + + /** + * 合并更新当前登录用户的偏好,body 中的 key 覆盖已有同名 key,其余 key 保留。 + */ + @SaCheckLogin + @PatchMapping("/preferences") + public ApiResponse> patchPreferences(@RequestBody Map patch) { + long userId = StpUtil.getLoginIdAsLong(); + return ApiResponse.ok(userCenterService.patchPreferences(userId, patch)); + } +} diff --git a/src/main/java/com/involutionhell/backend/usercenter/model/UserAccount.java b/src/main/java/com/involutionhell/backend/usercenter/model/UserAccount.java index 59bba0d..c79b085 100644 --- a/src/main/java/com/involutionhell/backend/usercenter/model/UserAccount.java +++ b/src/main/java/com/involutionhell/backend/usercenter/model/UserAccount.java @@ -2,6 +2,7 @@ import java.util.LinkedHashSet; import java.util.Locale; +import java.util.Map; import java.util.Set; public record UserAccount( @@ -12,24 +13,26 @@ public record UserAccount( boolean enabled, Set roles, Set permissions, - String avatarUrl, // GitHub 头像 URL - String email, // GitHub 邮箱(可为 null,GitHub 用户可设为私密) - Long githubId // GitHub 数字 ID,用于 doc_contributors 贡献者追踪 + String avatarUrl, // GitHub 头像 URL + String email, // GitHub 邮箱(可为 null,GitHub 用户可设为私密) + Long githubId, // GitHub 数字 ID,用于 doc_contributors 贡献者追踪 + Map preferences // 用户偏好,JSONB 顶层 key 自由扩展 ) { /** - * 创建用户对象时统一规范化角色与权限集合。 + * 创建用户对象时统一规范化角色与权限集合,偏好为 null 时初始化为空 Map。 */ public UserAccount { roles = normalizeSet(roles); permissions = normalizeSet(permissions); + preferences = preferences != null ? preferences : Map.of(); } /** * 基于当前用户信息生成一个新的授权快照。 */ public UserAccount withAuthorization(Set newRoles, Set newPermissions) { - return new UserAccount(id, username, passwordHash, displayName, enabled, newRoles, newPermissions, avatarUrl, email, githubId); + return new UserAccount(id, username, passwordHash, displayName, enabled, newRoles, newPermissions, avatarUrl, email, githubId, preferences); } /** diff --git a/src/main/java/com/involutionhell/backend/usercenter/repository/JdbcUserAccountRepository.java b/src/main/java/com/involutionhell/backend/usercenter/repository/JdbcUserAccountRepository.java index 9c02405..65f3e7d 100644 --- a/src/main/java/com/involutionhell/backend/usercenter/repository/JdbcUserAccountRepository.java +++ b/src/main/java/com/involutionhell/backend/usercenter/repository/JdbcUserAccountRepository.java @@ -1,11 +1,15 @@ 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.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.springframework.jdbc.core.JdbcTemplate; @@ -21,12 +25,16 @@ public class JdbcUserAccountRepository implements UserAccountRepository { private final JdbcTemplate jdbc; + private final ObjectMapper objectMapper; + + private static final TypeReference> MAP_TYPE = new TypeReference<>() {}; /** * 将数据库行映射为 UserAccount 记录。 * roles / permissions 以逗号分隔字符串存储,空字符串对应空集合。 + * preferences 存为 JSONB(测试 H2 用 VARCHAR),读出后解析为 Map。 */ - private static final RowMapper ROW_MAPPER = (rs, rowNum) -> new UserAccount( + private final RowMapper rowMapper = (rs, rowNum) -> new UserAccount( rs.getLong("id"), rs.getString("username"), rs.getString("password_hash"), @@ -36,30 +44,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 findById(Long id) { List 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 findByUsername(String username) { List 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 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 @@ -107,7 +117,8 @@ public UserAccount insert(UserAccount userAccount) { userAccount.permissions(), userAccount.avatarUrl(), userAccount.email(), - userAccount.githubId() + userAccount.githubId(), + Map.of() ); } @@ -121,6 +132,46 @@ public UserAccount updateProfile(Long userId, String displayName, String avatarU .orElseThrow(() -> new IllegalArgumentException("用户不存在: " + userId)); } + @Override + public Map findPreferences(Long userId) { + List 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 updatePreferences(Long userId, Map merged) { + // 接收已合并好的全量偏好,直接覆盖写入(合并逻辑在 service 层完成,兼容 H2 测试环境) + String mergedJson = toJson(merged); + jdbc.update(connection -> { + var ps = connection.prepareStatement( + "UPDATE user_accounts SET preferences = ? WHERE id = ?"); + // PostgreSQL 连接时用 PGobject 传 jsonb 类型;H2 等直接用 String + String driverName = connection.getMetaData().getDriverName(); + if (driverName != null && driverName.toLowerCase().contains("postgresql")) { + try { + var pgObjectClass = Class.forName("org.postgresql.util.PGobject"); + var pgObject = pgObjectClass.getDeclaredConstructor().newInstance(); + pgObjectClass.getMethod("setType", String.class).invoke(pgObject, "jsonb"); + pgObjectClass.getMethod("setValue", String.class).invoke(pgObject, mergedJson); + ps.setObject(1, pgObject); + } catch (Exception e) { + ps.setString(1, mergedJson); + } + } else { + ps.setString(1, mergedJson); + } + ps.setLong(2, userId); + return ps; + }); + return findPreferences(userId); + } + /** * 将逗号分隔字符串解析为集合,空串返回空集合。 */ @@ -140,4 +191,29 @@ private static String joinSet(Set values) { } return String.join(",", values); } -} \ No newline at end of file + + /** + * 将 JSON 字符串解析为 Map,null 或解析失败时返回空 Map。 + */ + private Map parseJson(String json) { + if (json == null || json.isBlank() || "{}".equals(json.trim())) { + return new HashMap<>(); + } + try { + return objectMapper.readValue(json, MAP_TYPE); + } catch (Exception e) { + return new HashMap<>(); + } + } + + /** + * 将 Map 序列化为 JSON 字符串。 + */ + private String toJson(Map map) { + try { + return objectMapper.writeValueAsString(map); + } catch (Exception e) { + return "{}"; + } + } +} diff --git a/src/main/java/com/involutionhell/backend/usercenter/repository/UserAccountRepository.java b/src/main/java/com/involutionhell/backend/usercenter/repository/UserAccountRepository.java index 0083456..5a05251 100644 --- a/src/main/java/com/involutionhell/backend/usercenter/repository/UserAccountRepository.java +++ b/src/main/java/com/involutionhell/backend/usercenter/repository/UserAccountRepository.java @@ -2,6 +2,7 @@ import com.involutionhell.backend.usercenter.model.UserAccount; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; @@ -39,4 +40,14 @@ public interface UserAccountRepository { * 更新 GitHub 用户的个人资料(展示名、头像、邮箱、GitHub ID),每次登录时刷新。 */ UserAccount updateProfile(Long userId, String displayName, String avatarUrl, String email, Long githubId); + + /** + * 查询指定用户的偏好 Map,用户不存在时抛 IllegalArgumentException。 + */ + Map findPreferences(Long userId); + + /** + * 将已合并好的全量偏好写入数据库,返回写入后的值。 + */ + Map updatePreferences(Long userId, Map merged); } \ No newline at end of file diff --git a/src/main/java/com/involutionhell/backend/usercenter/service/AuthService.java b/src/main/java/com/involutionhell/backend/usercenter/service/AuthService.java index 2aa130f..cdaede1 100644 --- a/src/main/java/com/involutionhell/backend/usercenter/service/AuthService.java +++ b/src/main/java/com/involutionhell/backend/usercenter/service/AuthService.java @@ -81,7 +81,8 @@ public LoginResponse loginByGithub(AuthUser githubUser) { Set.of(), // 默认权限 avatarUrl, email, - githubId + githubId, + null // 偏好由数据库默认值初始化为 {} ); return userCenterService.createUser(newUser); }); diff --git a/src/main/java/com/involutionhell/backend/usercenter/service/UserCenterService.java b/src/main/java/com/involutionhell/backend/usercenter/service/UserCenterService.java index 752d421..bedcdc4 100644 --- a/src/main/java/com/involutionhell/backend/usercenter/service/UserCenterService.java +++ b/src/main/java/com/involutionhell/backend/usercenter/service/UserCenterService.java @@ -7,7 +7,9 @@ import com.involutionhell.backend.usercenter.repository.UserAccountRepository; import org.springframework.stereotype.Service; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Optional; @Service @@ -80,4 +82,22 @@ public UserView updateAuthorization(Long userId, UserAuthorizationUpdateRequest ); return UserView.from(updatedAccount); } + + /** + * 获取指定用户的偏好 Map,未设置时返回空 Map。 + */ + public Map getPreferences(Long userId) { + return userAccountRepository.findPreferences(userId); + } + + /** + * 将 patch 合并进用户偏好(顶层 key 覆盖),返回更新后全量偏好。 + */ + public Map patchPreferences(Long userId, Map patch) { + // 先读出现有偏好,再在 Java 侧合并,最后整体写回(兼容 H2 测试环境) + Map existing = userAccountRepository.findPreferences(userId); + Map merged = new HashMap<>(existing); + merged.putAll(patch); + return userAccountRepository.updatePreferences(userId, merged); + } } \ No newline at end of file diff --git a/src/test/java/com/involutionhell/backend/usercenter/controller/UserPreferencesControllerIntegrationTests.java b/src/test/java/com/involutionhell/backend/usercenter/controller/UserPreferencesControllerIntegrationTests.java new file mode 100644 index 0000000..1cd10aa --- /dev/null +++ b/src/test/java/com/involutionhell/backend/usercenter/controller/UserPreferencesControllerIntegrationTests.java @@ -0,0 +1,77 @@ +package com.involutionhell.backend.usercenter.controller; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.involutionhell.backend.support.AbstractWebIntegrationTest; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; + +/** + * 用户偏好 GET/PATCH 接口集成测试。 + */ +class UserPreferencesControllerIntegrationTests extends AbstractWebIntegrationTest { + + /** 未登录访问 GET /api/user-center/preferences 应返回 401。 */ + @Test + void getPreferencesRejectsAnonymousRequest() throws Exception { + mockMvc.perform(get("/api/user-center/preferences")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.success").value(false)); + } + + /** 未登录访问 PATCH /api/user-center/preferences 应返回 401。 */ + @Test + void patchPreferencesRejectsAnonymousRequest() throws Exception { + mockMvc.perform(patch("/api/user-center/preferences") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"theme\":\"dark\"}")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.success").value(false)); + } + + /** + * 登录用户多次 PATCH 应正确合并偏好: + * {} -> {theme:dark} -> {theme:dark, language:zh} -> {theme:light, language:zh} + */ + @Test + void patchPreferencesMergesCorrectly() throws Exception { + String token = loginAsAlice(); + + // 第一次:写入 theme + mockMvc.perform(patch("/api/user-center/preferences") + .header("satoken", token) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"theme\":\"dark\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.theme").value("dark")); + + // 第二次:追加 language,theme 应保留 + mockMvc.perform(patch("/api/user-center/preferences") + .header("satoken", token) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"language\":\"zh\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.theme").value("dark")) + .andExpect(jsonPath("$.data.language").value("zh")); + + // 第三次:更新 theme,language 应保留 + mockMvc.perform(patch("/api/user-center/preferences") + .header("satoken", token) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"theme\":\"light\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.theme").value("light")) + .andExpect(jsonPath("$.data.language").value("zh")); + + // GET 验证最终状态 + mockMvc.perform(get("/api/user-center/preferences") + .header("satoken", token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.theme").value("light")) + .andExpect(jsonPath("$.data.language").value("zh")); + } +} diff --git a/src/test/java/com/involutionhell/backend/usercenter/model/UserAccountTests.java b/src/test/java/com/involutionhell/backend/usercenter/model/UserAccountTests.java index 3d50b26..1481970 100644 --- a/src/test/java/com/involutionhell/backend/usercenter/model/UserAccountTests.java +++ b/src/test/java/com/involutionhell/backend/usercenter/model/UserAccountTests.java @@ -18,7 +18,7 @@ void constructorNormalizesRolesAndPermissions() { true, Set.of(" Admin ", "admin", "USER"), Set.of(" user:profile:read ", "USER:PROFILE:READ", "user:center:read"), - null, null, null + null, null, null, null ); assertThat(account.roles()).containsExactlyInAnyOrder("admin", "user"); @@ -35,7 +35,7 @@ void withAuthorizationCreatesNewNormalizedSnapshot() { true, Set.of("admin"), Set.of("user:profile:read"), - null, null, null + null, null, null, null ); UserAccount updated = account.withAuthorization(Set.of(" Reviewer "), Set.of(" USER:CENTER:READ ")); @@ -55,7 +55,7 @@ void userViewFromConvertsAccountToView() { true, Set.of("user"), Set.of("user:profile:read"), - null, null, null + null, null, null, null ); UserView view = UserView.from(account); diff --git a/src/test/java/com/involutionhell/backend/usercenter/repository/JdbcUserAccountRepositoryTests.java b/src/test/java/com/involutionhell/backend/usercenter/repository/JdbcUserAccountRepositoryTests.java index 3961acb..84bf618 100644 --- a/src/test/java/com/involutionhell/backend/usercenter/repository/JdbcUserAccountRepositoryTests.java +++ b/src/test/java/com/involutionhell/backend/usercenter/repository/JdbcUserAccountRepositoryTests.java @@ -100,7 +100,7 @@ void insertCreatesUserAndReturnsWithGeneratedId() { UserAccount toInsert = new UserAccount( null, "newuser", "hash-value", "新用户", true, Set.of("user"), Set.of("user:profile:read"), - "https://avatar.example.com", "newuser@example.com", 99999L + "https://avatar.example.com", "newuser@example.com", 99999L, null ); UserAccount saved = repository.insert(toInsert); @@ -122,7 +122,7 @@ void insertHandlesNullGithubIdAndEmail() { UserAccount toInsert = new UserAccount( null, "github_user", "random-hash", "GitHub 用户", true, Set.of("user"), Set.of(), - null, null, null + null, null, null, null ); UserAccount saved = repository.insert(toInsert); @@ -138,7 +138,7 @@ void insertHandlesNullGithubIdAndEmail() { void insertPersistsEmptyRolesAsEmptySet() { UserAccount toInsert = new UserAccount( null, "norole_user", "hash", "无角色用户", - true, Set.of(), Set.of(), null, null, null + true, Set.of(), Set.of(), null, null, null, null ); UserAccount saved = repository.insert(toInsert); diff --git a/src/test/java/com/involutionhell/backend/usercenter/service/AuthServiceTests.java b/src/test/java/com/involutionhell/backend/usercenter/service/AuthServiceTests.java index d8509ea..7a6188a 100644 --- a/src/test/java/com/involutionhell/backend/usercenter/service/AuthServiceTests.java +++ b/src/test/java/com/involutionhell/backend/usercenter/service/AuthServiceTests.java @@ -46,13 +46,13 @@ class AuthServiceTests { /** 创建一个已启用的标准用户。 */ private UserAccount enabledUser(Long id, String username, String passwordHash) { return new UserAccount(id, username, passwordHash, "显示名称", true, - Set.of("user"), Set.of("user:profile:read"), null, null, null); + Set.of("user"), Set.of("user:profile:read"), null, null, null, null); } /** 创建一个已停用的用户。 */ private UserAccount disabledUser(Long id, String username) { return new UserAccount(id, username, "hash", "显示名称", false, - Set.of("user"), Set.of(), null, null, null); + Set.of("user"), Set.of(), null, null, null, null); } /** @@ -220,7 +220,7 @@ void loginByGithubUpdatesProfileWhenUserAlreadyExists() { UserAccount afterUpdate = new UserAccount( 10L, "github_12345", "hash", "UpdatedNick", true, Set.of("user"), Set.of("user:profile:read"), - "https://new-avatar.url", "new@github.com", 12345L + "https://new-avatar.url", "new@github.com", 12345L, null ); when(userCenterService.findByUsername("github_12345")).thenReturn(Optional.of(existing)); when(userCenterService.updateProfile(10L, "UpdatedNick", "https://new-avatar.url", "new@github.com", 12345L)) From a585a609c0921b9b28bd8bb911ecb80d1eba057d Mon Sep 17 00:00:00 2001 From: longsizhuo Date: Tue, 14 Apr 2026 19:11:27 +0000 Subject: [PATCH 3/3] =?UTF-8?q?chore(user-center):=20CR=20-=20patchPrefere?= =?UTF-8?q?nces=20=E8=B5=B0=20jsonb=20DB=20=E5=90=88=E5=B9=B6=E9=81=BF?= =?UTF-8?q?=E5=85=8D=E5=B9=B6=E5=8F=91=20lost=20update?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot CR #7(多项): - UserAccount: preferences 用 Map.copyOf 做防御性拷贝,保证 record 的不可变快照语义 - JdbcUserAccountRepository.insert: INSERT 写入 preferences 列,插入后 findById 回读, 避免初始偏好被丢 + 字段漂移 - JdbcUserAccountRepository.patchPreferences: 改名自 updatePreferences, PostgreSQL 路径用 'preferences || ?::jsonb' 单条 UPDATE 原子合并,setObject + Types.OTHER 替代反射 PGobject,兼容 GraalVM native image;H2 测试路径保留 read-merge-write - JdbcUserAccountRepository.parseJson/toJson: 失败时抛异常 + log.error, 不再静默吞掉把偏好覆盖成 '{}' - UserAccountRepository: 接口改名 updatePreferences → patchPreferences,写清合并语义 - UserCenterService: 删 Java 侧 read-merge-write,直接交给 repository 原子操作 --- .../repository/JdbcUserAccountRepository.java | 113 +++++++++++------- .../repository/UserAccountRepository.java | 6 +- .../usercenter/service/UserCenterService.java | 8 +- 3 files changed, 78 insertions(+), 49 deletions(-) diff --git a/src/main/java/com/involutionhell/backend/usercenter/repository/JdbcUserAccountRepository.java b/src/main/java/com/involutionhell/backend/usercenter/repository/JdbcUserAccountRepository.java index 65f3e7d..1fde05c 100644 --- a/src/main/java/com/involutionhell/backend/usercenter/repository/JdbcUserAccountRepository.java +++ b/src/main/java/com/involutionhell/backend/usercenter/repository/JdbcUserAccountRepository.java @@ -5,6 +5,7 @@ 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; @@ -12,6 +13,8 @@ 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; @@ -24,6 +27,8 @@ @Repository public class JdbcUserAccountRepository implements UserAccountRepository { + private static final Logger log = LoggerFactory.getLogger(JdbcUserAccountRepository.class); + private final JdbcTemplate jdbc; private final ObjectMapper objectMapper; @@ -84,8 +89,11 @@ public UserAccount updateAuthorization(Long userId, Set roles, Set { PreparedStatement ps = connection.prepareStatement(sql, new String[]{"id"}); @@ -99,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); @@ -107,19 +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(), - Map.of() - ); + // 插入后回读整行,确保返回值与数据库一致,避免遗漏新列或字段漂移 + return findById(key.longValue()) + .orElseThrow(() -> new IllegalStateException("插入用户后无法读取回数据: id=" + key.longValue())); } @Override @@ -145,33 +145,59 @@ public Map findPreferences(Long userId) { } @Override - public Map updatePreferences(Long userId, Map merged) { - // 接收已合并好的全量偏好,直接覆盖写入(合并逻辑在 service 层完成,兼容 H2 测试环境) - String mergedJson = toJson(merged); - jdbc.update(connection -> { - var ps = connection.prepareStatement( - "UPDATE user_accounts SET preferences = ? WHERE id = ?"); - // PostgreSQL 连接时用 PGobject 传 jsonb 类型;H2 等直接用 String - String driverName = connection.getMetaData().getDriverName(); - if (driverName != null && driverName.toLowerCase().contains("postgresql")) { - try { - var pgObjectClass = Class.forName("org.postgresql.util.PGobject"); - var pgObject = pgObjectClass.getDeclaredConstructor().newInstance(); - pgObjectClass.getMethod("setType", String.class).invoke(pgObject, "jsonb"); - pgObjectClass.getMethod("setValue", String.class).invoke(pgObject, mergedJson); - ps.setObject(1, pgObject); - } catch (Exception e) { - ps.setString(1, mergedJson); - } - } else { - ps.setString(1, mergedJson); + public Map patchPreferences(Long userId, Map 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); } - ps.setLong(2, userId); - return ps; - }); + } else { + // H2 路径:先读后合并再整体写入(测试环境无并发压力) + Map existing = findPreferences(userId); + Map 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; + } + } + /** * 将逗号分隔字符串解析为集合,空串返回空集合。 */ @@ -193,7 +219,9 @@ private static String joinSet(Set values) { } /** - * 将 JSON 字符串解析为 Map,null 或解析失败时返回空 Map。 + * 将 JSON 字符串解析为 Map。 + * null / 空串 / "{}" 视为"未设置"返回空 Map;解析失败(数据库里脏数据)则抛出异常, + * 避免静默吞错,让调用方感知并由全局异常处理器返回 500。 */ private Map parseJson(String json) { if (json == null || json.isBlank() || "{}".equals(json.trim())) { @@ -202,18 +230,21 @@ private Map parseJson(String json) { try { return objectMapper.readValue(json, MAP_TYPE); } catch (Exception e) { - return new HashMap<>(); + log.error("解析 preferences JSON 失败,数据可能已损坏: {}", json, e); + throw new IllegalStateException("解析 preferences 失败", e); } } /** * 将 Map 序列化为 JSON 字符串。 + * 失败时抛出异常而不是返回 "{}",避免把有问题的偏好当成空偏好静默覆盖掉原有数据。 */ private String toJson(Map map) { try { return objectMapper.writeValueAsString(map); } catch (Exception e) { - return "{}"; + log.error("序列化 preferences 失败: {}", map, e); + throw new IllegalStateException("序列化 preferences 失败", e); } } } diff --git a/src/main/java/com/involutionhell/backend/usercenter/repository/UserAccountRepository.java b/src/main/java/com/involutionhell/backend/usercenter/repository/UserAccountRepository.java index 5a05251..448815f 100644 --- a/src/main/java/com/involutionhell/backend/usercenter/repository/UserAccountRepository.java +++ b/src/main/java/com/involutionhell/backend/usercenter/repository/UserAccountRepository.java @@ -47,7 +47,9 @@ public interface UserAccountRepository { Map findPreferences(Long userId); /** - * 将已合并好的全量偏好写入数据库,返回写入后的值。 + * 以 patch 为单位在数据库端原子合并用户偏好(顶层 key 覆盖),返回合并后的全量偏好。 + * PostgreSQL 实现走 `preferences || ?::jsonb` 单条 UPDATE,避免并发 lost update; + * H2 走 read-merge-write 路径兼容测试。 */ - Map updatePreferences(Long userId, Map merged); + Map patchPreferences(Long userId, Map patch); } \ No newline at end of file diff --git a/src/main/java/com/involutionhell/backend/usercenter/service/UserCenterService.java b/src/main/java/com/involutionhell/backend/usercenter/service/UserCenterService.java index bedcdc4..bbee26a 100644 --- a/src/main/java/com/involutionhell/backend/usercenter/service/UserCenterService.java +++ b/src/main/java/com/involutionhell/backend/usercenter/service/UserCenterService.java @@ -7,7 +7,6 @@ import com.involutionhell.backend.usercenter.repository.UserAccountRepository; import org.springframework.stereotype.Service; -import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -92,12 +91,9 @@ public Map getPreferences(Long userId) { /** * 将 patch 合并进用户偏好(顶层 key 覆盖),返回更新后全量偏好。 + * 合并原子性由 repository 层保证(PostgreSQL 用 jsonb 原生 `||` 单条 UPDATE,避免并发 lost update)。 */ public Map patchPreferences(Long userId, Map patch) { - // 先读出现有偏好,再在 Java 侧合并,最后整体写回(兼容 H2 测试环境) - Map existing = userAccountRepository.findPreferences(userId); - Map merged = new HashMap<>(existing); - merged.putAll(patch); - return userAccountRepository.updatePreferences(userId, merged); + return userAccountRepository.patchPreferences(userId, patch); } } \ No newline at end of file