feat(user-center): 用户偏好读写 API + preferences JSONB 列#7
Conversation
There was a problem hiding this comment.
Pull request overview
该 PR 为用户中心新增“用户偏好”读写能力:在 user_accounts 表引入 preferences(生产用 JSONB,测试用 VARCHAR),并提供受登录保护的 GET/PATCH 接口,PATCH 以“顶层 key merge”方式更新偏好,同时补充集成测试覆盖匿名访问与多次合并更新。
Changes:
- 数据库:为
user_accounts增加preferences列(生产schema.sql用 JSONB + 幂等 ALTER;测试test-schema.sql用 VARCHAR)。 - API:新增
GET /api/user-center/preferences与PATCH /api/user-center/preferences,并在 service/repository 增加偏好读写方法。 - 测试:新增偏好接口集成测试,并更新既有测试以适配
UserAccount新增字段。
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| src/main/resources/schema.sql | 通过幂等 ALTER 为生产库新增 preferences JSONB 列 |
| src/test/resources/test-schema.sql | 为 H2 测试库的 user_accounts 增加 preferences 列 |
| src/main/java/com/involutionhell/backend/usercenter/controller/UserPreferencesController.java | 新增偏好 GET/PATCH Controller(Sa-Token 登录保护) |
| src/main/java/com/involutionhell/backend/usercenter/service/UserCenterService.java | 增加偏好读取与 PATCH merge(read-merge-write)逻辑 |
| src/main/java/com/involutionhell/backend/usercenter/repository/UserAccountRepository.java | 扩展仓库接口:find/update preferences |
| src/main/java/com/involutionhell/backend/usercenter/repository/JdbcUserAccountRepository.java | JDBC 实现:preferences 的查询/更新 + JSON 序列化/反序列化 |
| src/main/java/com/involutionhell/backend/usercenter/model/UserAccount.java | UserAccount 增加 preferences 字段并在构造器中初始化 |
| src/main/java/com/involutionhell/backend/usercenter/service/AuthService.java | 新建 GitHub 用户时补齐 preferences 构造参数 |
| src/test/java/com/involutionhell/backend/usercenter/controller/UserPreferencesControllerIntegrationTests.java | 新增偏好接口集成测试(401 + 合并更新正确) |
| src/test/java/com/involutionhell/backend/usercenter/service/AuthServiceTests.java | 适配 UserAccount 构造参数新增字段 |
| src/test/java/com/involutionhell/backend/usercenter/repository/JdbcUserAccountRepositoryTests.java | 适配 UserAccount 构造参数新增字段 |
| src/test/java/com/involutionhell/backend/usercenter/model/UserAccountTests.java | 适配 UserAccount 构造参数新增字段 |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| /** | ||
| * 将 JSON 字符串解析为 Map,null 或解析失败时返回空 Map。 | ||
| */ | ||
| 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) { | ||
| return new HashMap<>(); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * 将 Map 序列化为 JSON 字符串。 | ||
| */ | ||
| private String toJson(Map<String, Object> map) { | ||
| try { | ||
| return objectMapper.writeValueAsString(map); | ||
| } catch (Exception e) { | ||
| return "{}"; | ||
| } |
There was a problem hiding this comment.
parseJson/toJson 在解析或序列化失败时直接返回空对象/"{}",会把“数据库里有脏数据”或“请求体含不可序列化值”等问题静默吞掉;随后一次 PATCH 很可能把原偏好整体覆盖成 {},造成数据丢失且难以排查。建议至少记录日志并抛出异常(让调用方返回 500),或显式区分“未设置”和“解析失败”两类情况,避免静默清空。
| */ | ||
| public UserAccount { | ||
| roles = normalizeSet(roles); | ||
| permissions = normalizeSet(permissions); | ||
| preferences = preferences != null ? preferences : Map.of(); |
There was a problem hiding this comment.
UserAccount 的 preferences 仅在为 null 时用 Map.of() 初始化;当传入的是可变 Map(例如从 JDBC/JSON 解析得到的 HashMap)时会直接被记录持有引用,破坏 record 的“快照/不可变”语义,也可能被外部修改导致状态漂移。建议在 compact constructor 里做防御性拷贝并不可变化(如 preferences = preferences == null ? Map.of() : Map.copyOf(preferences),必要时对嵌套结构另行约束)。
| */ | |
| public UserAccount { | |
| roles = normalizeSet(roles); | |
| permissions = normalizeSet(permissions); | |
| preferences = preferences != null ? preferences : Map.of(); | |
| * 同时对偏好做防御性拷贝,避免持有外部可变 Map 引用。 | |
| */ | |
| public UserAccount { | |
| roles = normalizeSet(roles); | |
| permissions = normalizeSet(permissions); | |
| preferences = preferences == null ? Map.of() : Map.copyOf(preferences); |
| userAccount.githubId() | ||
| userAccount.githubId(), | ||
| Map.of() | ||
| ); |
There was a problem hiding this comment.
insert 当前没有写入 preferences 列(依赖数据库默认值),但返回的 UserAccount 也固定构造为 Map.of(),会忽略调用方在 userAccount.preferences() 里传入的任何初始偏好;后续如果有创建用户时携带偏好的场景,这里会静默丢失。建议:要么在 INSERT 显式包含 preferences 并使用 toJson(userAccount.preferences()) 写入;要么在插入后 findById 回读整行,确保返回值与数据库一致并避免遗漏新列。
| public Map<String, Object> patchPreferences(Long userId, Map<String, Object> patch) { | ||
| // 先读出现有偏好,再在 Java 侧合并,最后整体写回(兼容 H2 测试环境) | ||
| Map<String, Object> existing = userAccountRepository.findPreferences(userId); | ||
| Map<String, Object> merged = new HashMap<>(existing); | ||
| merged.putAll(patch); | ||
| return userAccountRepository.updatePreferences(userId, merged); |
There was a problem hiding this comment.
patchPreferences 当前采用 read-merge-write,但没有事务/行锁或数据库端原子 merge;并发 PATCH(尤其是不同 key)会出现 lost update,后写入者可能覆盖掉先写入者的更新,导致偏好静默丢失。建议改为在 PostgreSQL 侧用单条 UPDATE 做 jsonb 顶层合并(如 preferences = preferences || :patch / RETURNING preferences),或在事务中对该行 SELECT ... FOR UPDATE 再合并写回。
| 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); | ||
| } |
There was a problem hiding this comment.
updatePreferences 通过反射创建 org.postgresql.util.PGobject 来写 jsonb;在 native-image/GraalVM 下该反射通常会失败(未注册 runtime hints),随后 fallback 到 ps.setString 可能导致 PostgreSQL 报类型不匹配(varchar → jsonb),从而让偏好更新在生产/原生镜像环境不可用。建议避免反射:在 PostgreSQL 分支用 ps.setObject(1, mergedJson, java.sql.Types.OTHER) 或把 SQL 写成 preferences = ?::jsonb(仅在 Postgres 分支使用),确保无需反射且类型稳定。
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 原子操作
Summary
user_accounts表新增preferences JSONB NOT NULL DEFAULT '{}'(schema.sql ALTER IF NOT EXISTS 幂等)GET /api/user-center/preferences和PATCH /api/user-center/preferences,@SaCheckLogin保护偏好 schema(软约定,后端不强校验,前端可自由扩展)
```
{ theme?: "light"|"dark"|"system", language?: "zh"|"en", aiDefaultProvider?: "intern"|"openai"|"gemini" }
```
Test plan
依赖
前端 PR: involutionhell#feature/user-preferences