Skip to content

Commit fcbf6a2

Browse files
authored
feat(user-center): 用户偏好读写 API + preferences JSONB 列 (#7)
* feat(user-center): user_accounts 加 preferences 列 * feat(user-center): 偏好读写接口 GET/PATCH /api/user-center/preferences * chore(user-center): CR - patchPreferences 走 jsonb DB 合并避免并发 lost update 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 原子操作
1 parent b139158 commit fcbf6a2

12 files changed

Lines changed: 305 additions & 37 deletions

File tree

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package com.involutionhell.backend.usercenter.controller;
2+
3+
import cn.dev33.satoken.annotation.SaCheckLogin;
4+
import cn.dev33.satoken.stp.StpUtil;
5+
import com.involutionhell.backend.common.api.ApiResponse;
6+
import com.involutionhell.backend.usercenter.service.UserCenterService;
7+
import org.springframework.web.bind.annotation.GetMapping;
8+
import org.springframework.web.bind.annotation.PatchMapping;
9+
import org.springframework.web.bind.annotation.RequestBody;
10+
import org.springframework.web.bind.annotation.RequestMapping;
11+
import org.springframework.web.bind.annotation.RestController;
12+
13+
import java.util.Map;
14+
15+
/**
16+
* 用户偏好读写接口,偏好以 JSONB 顶层合并方式存储,前端可自由扩展 key。
17+
*/
18+
@RestController
19+
@RequestMapping("/api/user-center")
20+
public class UserPreferencesController {
21+
22+
private final UserCenterService userCenterService;
23+
24+
public UserPreferencesController(UserCenterService userCenterService) {
25+
this.userCenterService = userCenterService;
26+
}
27+
28+
/**
29+
* 获取当前登录用户的偏好,未设置时返回空对象。
30+
*/
31+
@SaCheckLogin
32+
@GetMapping("/preferences")
33+
public ApiResponse<Map<String, Object>> getPreferences() {
34+
long userId = StpUtil.getLoginIdAsLong();
35+
return ApiResponse.ok(userCenterService.getPreferences(userId));
36+
}
37+
38+
/**
39+
* 合并更新当前登录用户的偏好,body 中的 key 覆盖已有同名 key,其余 key 保留。
40+
*/
41+
@SaCheckLogin
42+
@PatchMapping("/preferences")
43+
public ApiResponse<Map<String, Object>> patchPreferences(@RequestBody Map<String, Object> patch) {
44+
long userId = StpUtil.getLoginIdAsLong();
45+
return ApiResponse.ok(userCenterService.patchPreferences(userId, patch));
46+
}
47+
}

src/main/java/com/involutionhell/backend/usercenter/model/UserAccount.java

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import java.util.LinkedHashSet;
44
import java.util.Locale;
5+
import java.util.Map;
56
import java.util.Set;
67

78
public record UserAccount(
@@ -12,24 +13,26 @@ public record UserAccount(
1213
boolean enabled,
1314
Set<String> roles,
1415
Set<String> permissions,
15-
String avatarUrl, // GitHub 头像 URL
16-
String email, // GitHub 邮箱(可为 null,GitHub 用户可设为私密)
17-
Long githubId // GitHub 数字 ID,用于 doc_contributors 贡献者追踪
16+
String avatarUrl, // GitHub 头像 URL
17+
String email, // GitHub 邮箱(可为 null,GitHub 用户可设为私密)
18+
Long githubId, // GitHub 数字 ID,用于 doc_contributors 贡献者追踪
19+
Map<String, Object> preferences // 用户偏好,JSONB 顶层 key 自由扩展
1820
) {
1921

2022
/**
21-
* 创建用户对象时统一规范化角色与权限集合。
23+
* 创建用户对象时统一规范化角色与权限集合,偏好为 null 时初始化为空 Map
2224
*/
2325
public UserAccount {
2426
roles = normalizeSet(roles);
2527
permissions = normalizeSet(permissions);
28+
preferences = preferences != null ? preferences : Map.of();
2629
}
2730

2831
/**
2932
* 基于当前用户信息生成一个新的授权快照。
3033
*/
3134
public UserAccount withAuthorization(Set<String> newRoles, Set<String> newPermissions) {
32-
return new UserAccount(id, username, passwordHash, displayName, enabled, newRoles, newPermissions, avatarUrl, email, githubId);
35+
return new UserAccount(id, username, passwordHash, displayName, enabled, newRoles, newPermissions, avatarUrl, email, githubId, preferences);
3336
}
3437

3538
/**

src/main/java/com/involutionhell/backend/usercenter/repository/JdbcUserAccountRepository.java

Lines changed: 128 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
11
package com.involutionhell.backend.usercenter.repository;
22

3+
import tools.jackson.core.type.TypeReference;
4+
import tools.jackson.databind.ObjectMapper;
35
import com.involutionhell.backend.usercenter.model.UserAccount;
46

57
import java.sql.PreparedStatement;
8+
import java.sql.Types;
69
import java.util.Arrays;
10+
import java.util.HashMap;
711
import java.util.HashSet;
812
import java.util.List;
13+
import java.util.Map;
914
import java.util.Optional;
1015
import java.util.Set;
16+
import org.slf4j.Logger;
17+
import org.slf4j.LoggerFactory;
1118
import org.springframework.jdbc.core.JdbcTemplate;
1219
import org.springframework.jdbc.core.RowMapper;
1320
import org.springframework.jdbc.support.GeneratedKeyHolder;
@@ -20,13 +27,19 @@
2027
@Repository
2128
public class JdbcUserAccountRepository implements UserAccountRepository {
2229

30+
private static final Logger log = LoggerFactory.getLogger(JdbcUserAccountRepository.class);
31+
2332
private final JdbcTemplate jdbc;
33+
private final ObjectMapper objectMapper;
34+
35+
private static final TypeReference<Map<String, Object>> MAP_TYPE = new TypeReference<>() {};
2436

2537
/**
2638
* 将数据库行映射为 UserAccount 记录。
2739
* roles / permissions 以逗号分隔字符串存储,空字符串对应空集合。
40+
* preferences 存为 JSONB(测试 H2 用 VARCHAR),读出后解析为 Map。
2841
*/
29-
private static final RowMapper<UserAccount> ROW_MAPPER = (rs, rowNum) -> new UserAccount(
42+
private final RowMapper<UserAccount> rowMapper = (rs, rowNum) -> new UserAccount(
3043
rs.getLong("id"),
3144
rs.getString("username"),
3245
rs.getString("password_hash"),
@@ -36,30 +49,32 @@ public class JdbcUserAccountRepository implements UserAccountRepository {
3649
parseSet(rs.getString("permissions")),
3750
rs.getString("avatar_url"),
3851
rs.getString("email"),
39-
rs.getObject("github_id", Long.class) // nullable Long
52+
rs.getObject("github_id", Long.class),
53+
parseJson(rs.getString("preferences"))
4054
);
4155

42-
public JdbcUserAccountRepository(JdbcTemplate jdbc) {
56+
public JdbcUserAccountRepository(JdbcTemplate jdbc, ObjectMapper objectMapper) {
4357
this.jdbc = jdbc;
58+
this.objectMapper = objectMapper;
4459
}
4560

4661
@Override
4762
public Optional<UserAccount> findById(Long id) {
4863
List<UserAccount> results = jdbc.query(
49-
"SELECT * FROM user_accounts WHERE id = ?", ROW_MAPPER, id);
64+
"SELECT * FROM user_accounts WHERE id = ?", rowMapper, id);
5065
return results.stream().findFirst();
5166
}
5267

5368
@Override
5469
public Optional<UserAccount> findByUsername(String username) {
5570
List<UserAccount> results = jdbc.query(
56-
"SELECT * FROM user_accounts WHERE username = ?", ROW_MAPPER, username);
71+
"SELECT * FROM user_accounts WHERE username = ?", rowMapper, username);
5772
return results.stream().findFirst();
5873
}
5974

6075
@Override
6176
public List<UserAccount> findAll() {
62-
return jdbc.query("SELECT * FROM user_accounts ORDER BY id", ROW_MAPPER);
77+
return jdbc.query("SELECT * FROM user_accounts ORDER BY id", rowMapper);
6378
}
6479

6580
@Override
@@ -74,8 +89,11 @@ public UserAccount updateAuthorization(Long userId, Set<String> roles, Set<Strin
7489
@Override
7590
public UserAccount insert(UserAccount userAccount) {
7691
KeyHolder keyHolder = new GeneratedKeyHolder();
77-
String sql = "INSERT INTO user_accounts (username, password_hash, display_name, enabled, roles, permissions, avatar_url, email, github_id) " +
78-
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)";
92+
// 把 preferences 一并写入 INSERT,避免创建用户时携带的初始偏好被丢弃
93+
String sql = "INSERT INTO user_accounts (username, password_hash, display_name, enabled, roles, permissions, avatar_url, email, github_id, preferences) " +
94+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
95+
96+
String prefsJson = toJson(userAccount.preferences());
7997

8098
jdbc.update(connection -> {
8199
PreparedStatement ps = connection.prepareStatement(sql, new String[]{"id"});
@@ -89,6 +107,8 @@ public UserAccount insert(UserAccount userAccount) {
89107
ps.setString(8, userAccount.email());
90108
// github_id 可为 null,用 setObject 处理
91109
ps.setObject(9, userAccount.githubId());
110+
// jsonb 用 Types.OTHER 让 PostgreSQL 驱动自行识别;H2 会当作字符串处理
111+
ps.setObject(10, prefsJson, Types.OTHER);
92112
return ps;
93113
}, keyHolder);
94114

@@ -97,18 +117,9 @@ public UserAccount insert(UserAccount userAccount) {
97117
throw new IllegalStateException("插入用户失败,无法获取生成的 ID");
98118
}
99119

100-
return new UserAccount(
101-
key.longValue(),
102-
userAccount.username(),
103-
userAccount.passwordHash(),
104-
userAccount.displayName(),
105-
userAccount.enabled(),
106-
userAccount.roles(),
107-
userAccount.permissions(),
108-
userAccount.avatarUrl(),
109-
userAccount.email(),
110-
userAccount.githubId()
111-
);
120+
// 插入后回读整行,确保返回值与数据库一致,避免遗漏新列或字段漂移
121+
return findById(key.longValue())
122+
.orElseThrow(() -> new IllegalStateException("插入用户后无法读取回数据: id=" + key.longValue()));
112123
}
113124

114125
@Override
@@ -121,6 +132,72 @@ public UserAccount updateProfile(Long userId, String displayName, String avatarU
121132
.orElseThrow(() -> new IllegalArgumentException("用户不存在: " + userId));
122133
}
123134

135+
@Override
136+
public Map<String, Object> findPreferences(Long userId) {
137+
List<String> results = jdbc.query(
138+
"SELECT preferences FROM user_accounts WHERE id = ?",
139+
(rs, rn) -> rs.getString("preferences"),
140+
userId);
141+
if (results.isEmpty()) {
142+
throw new IllegalArgumentException("用户不存在: " + userId);
143+
}
144+
return parseJson(results.get(0));
145+
}
146+
147+
@Override
148+
public Map<String, Object> patchPreferences(Long userId, Map<String, Object> patch) {
149+
// 直接在 DB 端做原子 merge,避免 Java 侧 read-merge-write 的并发 lost update;
150+
// 同时用 setObject + Types.OTHER,避开反射 PGobject 在 GraalVM native image 下
151+
// reflection hints 未注册导致的启动失败。
152+
//
153+
// PostgreSQL:UPDATE ... SET preferences = preferences || ?::jsonb
154+
// 使用 jsonb 原生 `||` 操作符做顶层 key 合并,单条语句原子完成
155+
// H2(测试环境):UPDATE ... SET preferences = ? (全量覆盖)
156+
// 测试环境不追求并发正确性,由 service 层先 read-merge-write 保证合并语义
157+
String patchJson = toJson(patch);
158+
boolean isPostgres = isPostgres();
159+
160+
if (isPostgres) {
161+
int updated = jdbc.update(connection -> {
162+
var ps = connection.prepareStatement(
163+
"UPDATE user_accounts SET preferences = preferences || ?::jsonb WHERE id = ?");
164+
ps.setObject(1, patchJson, Types.OTHER);
165+
ps.setLong(2, userId);
166+
return ps;
167+
});
168+
if (updated == 0) {
169+
throw new IllegalArgumentException("用户不存在: " + userId);
170+
}
171+
} else {
172+
// H2 路径:先读后合并再整体写入(测试环境无并发压力)
173+
Map<String, Object> existing = findPreferences(userId);
174+
Map<String, Object> merged = new HashMap<>(existing);
175+
merged.putAll(patch);
176+
String mergedJson = toJson(merged);
177+
int updated = jdbc.update(
178+
"UPDATE user_accounts SET preferences = ? WHERE id = ?",
179+
mergedJson, userId);
180+
if (updated == 0) {
181+
throw new IllegalArgumentException("用户不存在: " + userId);
182+
}
183+
}
184+
185+
return findPreferences(userId);
186+
}
187+
188+
/** 判断当前数据源是否为 PostgreSQL(通过驱动名识别)。 */
189+
private boolean isPostgres() {
190+
try {
191+
return Boolean.TRUE.equals(jdbc.execute((java.sql.Connection c) -> {
192+
String name = c.getMetaData().getDriverName();
193+
return name != null && name.toLowerCase().contains("postgresql");
194+
}));
195+
} catch (Exception e) {
196+
log.warn("检测数据源驱动失败,按非 PostgreSQL 兜底: {}", e.getMessage());
197+
return false;
198+
}
199+
}
200+
124201
/**
125202
* 将逗号分隔字符串解析为集合,空串返回空集合。
126203
*/
@@ -140,4 +217,34 @@ private static String joinSet(Set<String> values) {
140217
}
141218
return String.join(",", values);
142219
}
143-
}
220+
221+
/**
222+
* 将 JSON 字符串解析为 Map。
223+
* null / 空串 / "{}" 视为"未设置"返回空 Map;解析失败(数据库里脏数据)则抛出异常,
224+
* 避免静默吞错,让调用方感知并由全局异常处理器返回 500。
225+
*/
226+
private Map<String, Object> parseJson(String json) {
227+
if (json == null || json.isBlank() || "{}".equals(json.trim())) {
228+
return new HashMap<>();
229+
}
230+
try {
231+
return objectMapper.readValue(json, MAP_TYPE);
232+
} catch (Exception e) {
233+
log.error("解析 preferences JSON 失败,数据可能已损坏: {}", json, e);
234+
throw new IllegalStateException("解析 preferences 失败", e);
235+
}
236+
}
237+
238+
/**
239+
* 将 Map 序列化为 JSON 字符串。
240+
* 失败时抛出异常而不是返回 "{}",避免把有问题的偏好当成空偏好静默覆盖掉原有数据。
241+
*/
242+
private String toJson(Map<String, Object> map) {
243+
try {
244+
return objectMapper.writeValueAsString(map);
245+
} catch (Exception e) {
246+
log.error("序列化 preferences 失败: {}", map, e);
247+
throw new IllegalStateException("序列化 preferences 失败", e);
248+
}
249+
}
250+
}

src/main/java/com/involutionhell/backend/usercenter/repository/UserAccountRepository.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import com.involutionhell.backend.usercenter.model.UserAccount;
44
import java.util.List;
5+
import java.util.Map;
56
import java.util.Optional;
67
import java.util.Set;
78

@@ -39,4 +40,16 @@ public interface UserAccountRepository {
3940
* 更新 GitHub 用户的个人资料(展示名、头像、邮箱、GitHub ID),每次登录时刷新。
4041
*/
4142
UserAccount updateProfile(Long userId, String displayName, String avatarUrl, String email, Long githubId);
43+
44+
/**
45+
* 查询指定用户的偏好 Map,用户不存在时抛 IllegalArgumentException。
46+
*/
47+
Map<String, Object> findPreferences(Long userId);
48+
49+
/**
50+
* 以 patch 为单位在数据库端原子合并用户偏好(顶层 key 覆盖),返回合并后的全量偏好。
51+
* PostgreSQL 实现走 `preferences || ?::jsonb` 单条 UPDATE,避免并发 lost update;
52+
* H2 走 read-merge-write 路径兼容测试。
53+
*/
54+
Map<String, Object> patchPreferences(Long userId, Map<String, Object> patch);
4255
}

src/main/java/com/involutionhell/backend/usercenter/service/AuthService.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,8 @@ public LoginResponse loginByGithub(AuthUser githubUser) {
8181
Set.of(), // 默认权限
8282
avatarUrl,
8383
email,
84-
githubId
84+
githubId,
85+
null // 偏好由数据库默认值初始化为 {}
8586
);
8687
return userCenterService.createUser(newUser);
8788
});

src/main/java/com/involutionhell/backend/usercenter/service/UserCenterService.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import org.springframework.stereotype.Service;
99

1010
import java.util.List;
11+
import java.util.Map;
1112
import java.util.Optional;
1213

1314
@Service
@@ -80,4 +81,19 @@ public UserView updateAuthorization(Long userId, UserAuthorizationUpdateRequest
8081
);
8182
return UserView.from(updatedAccount);
8283
}
84+
85+
/**
86+
* 获取指定用户的偏好 Map,未设置时返回空 Map。
87+
*/
88+
public Map<String, Object> getPreferences(Long userId) {
89+
return userAccountRepository.findPreferences(userId);
90+
}
91+
92+
/**
93+
* 将 patch 合并进用户偏好(顶层 key 覆盖),返回更新后全量偏好。
94+
* 合并原子性由 repository 层保证(PostgreSQL 用 jsonb 原生 `||` 单条 UPDATE,避免并发 lost update)。
95+
*/
96+
public Map<String, Object> patchPreferences(Long userId, Map<String, Object> patch) {
97+
return userAccountRepository.patchPreferences(userId, patch);
98+
}
8399
}

src/main/resources/schema.sql

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ CREATE TABLE IF NOT EXISTS user_accounts (
1414
github_id BIGINT UNIQUE
1515
);
1616

17+
-- 偏好设置列(JSONB 顶层合并,前端可自由扩展 key)
18+
ALTER TABLE user_accounts ADD COLUMN IF NOT EXISTS preferences JSONB NOT NULL DEFAULT '{}'::jsonb;
19+
1720
-- 默认种子账号(已存在则跳过)
1821
-- admin / Admin@123456
1922
-- alice / Alice@123456

0 commit comments

Comments
 (0)