Skip to content

Commit 18e363b

Browse files
committed
feat(user-center): 偏好读写接口 GET/PATCH /api/user-center/preferences
1 parent b93bc85 commit 18e363b

10 files changed

Lines changed: 258 additions & 23 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: 84 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
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;
68
import java.util.Arrays;
9+
import java.util.HashMap;
710
import java.util.HashSet;
811
import java.util.List;
12+
import java.util.Map;
913
import java.util.Optional;
1014
import java.util.Set;
1115
import org.springframework.jdbc.core.JdbcTemplate;
@@ -21,12 +25,16 @@
2125
public class JdbcUserAccountRepository implements UserAccountRepository {
2226

2327
private final JdbcTemplate jdbc;
28+
private final ObjectMapper objectMapper;
29+
30+
private static final TypeReference<Map<String, Object>> MAP_TYPE = new TypeReference<>() {};
2431

2532
/**
2633
* 将数据库行映射为 UserAccount 记录。
2734
* roles / permissions 以逗号分隔字符串存储,空字符串对应空集合。
35+
* preferences 存为 JSONB(测试 H2 用 VARCHAR),读出后解析为 Map。
2836
*/
29-
private static final RowMapper<UserAccount> ROW_MAPPER = (rs, rowNum) -> new UserAccount(
37+
private final RowMapper<UserAccount> rowMapper = (rs, rowNum) -> new UserAccount(
3038
rs.getLong("id"),
3139
rs.getString("username"),
3240
rs.getString("password_hash"),
@@ -36,30 +44,32 @@ public class JdbcUserAccountRepository implements UserAccountRepository {
3644
parseSet(rs.getString("permissions")),
3745
rs.getString("avatar_url"),
3846
rs.getString("email"),
39-
rs.getObject("github_id", Long.class) // nullable Long
47+
rs.getObject("github_id", Long.class),
48+
parseJson(rs.getString("preferences"))
4049
);
4150

42-
public JdbcUserAccountRepository(JdbcTemplate jdbc) {
51+
public JdbcUserAccountRepository(JdbcTemplate jdbc, ObjectMapper objectMapper) {
4352
this.jdbc = jdbc;
53+
this.objectMapper = objectMapper;
4454
}
4555

4656
@Override
4757
public Optional<UserAccount> findById(Long id) {
4858
List<UserAccount> results = jdbc.query(
49-
"SELECT * FROM user_accounts WHERE id = ?", ROW_MAPPER, id);
59+
"SELECT * FROM user_accounts WHERE id = ?", rowMapper, id);
5060
return results.stream().findFirst();
5161
}
5262

5363
@Override
5464
public Optional<UserAccount> findByUsername(String username) {
5565
List<UserAccount> results = jdbc.query(
56-
"SELECT * FROM user_accounts WHERE username = ?", ROW_MAPPER, username);
66+
"SELECT * FROM user_accounts WHERE username = ?", rowMapper, username);
5767
return results.stream().findFirst();
5868
}
5969

6070
@Override
6171
public List<UserAccount> findAll() {
62-
return jdbc.query("SELECT * FROM user_accounts ORDER BY id", ROW_MAPPER);
72+
return jdbc.query("SELECT * FROM user_accounts ORDER BY id", rowMapper);
6373
}
6474

6575
@Override
@@ -107,7 +117,8 @@ public UserAccount insert(UserAccount userAccount) {
107117
userAccount.permissions(),
108118
userAccount.avatarUrl(),
109119
userAccount.email(),
110-
userAccount.githubId()
120+
userAccount.githubId(),
121+
Map.of()
111122
);
112123
}
113124

@@ -121,6 +132,46 @@ 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> updatePreferences(Long userId, Map<String, Object> merged) {
149+
// 接收已合并好的全量偏好,直接覆盖写入(合并逻辑在 service 层完成,兼容 H2 测试环境)
150+
String mergedJson = toJson(merged);
151+
jdbc.update(connection -> {
152+
var ps = connection.prepareStatement(
153+
"UPDATE user_accounts SET preferences = ? WHERE id = ?");
154+
// PostgreSQL 连接时用 PGobject 传 jsonb 类型;H2 等直接用 String
155+
String driverName = connection.getMetaData().getDriverName();
156+
if (driverName != null && driverName.toLowerCase().contains("postgresql")) {
157+
try {
158+
var pgObjectClass = Class.forName("org.postgresql.util.PGobject");
159+
var pgObject = pgObjectClass.getDeclaredConstructor().newInstance();
160+
pgObjectClass.getMethod("setType", String.class).invoke(pgObject, "jsonb");
161+
pgObjectClass.getMethod("setValue", String.class).invoke(pgObject, mergedJson);
162+
ps.setObject(1, pgObject);
163+
} catch (Exception e) {
164+
ps.setString(1, mergedJson);
165+
}
166+
} else {
167+
ps.setString(1, mergedJson);
168+
}
169+
ps.setLong(2, userId);
170+
return ps;
171+
});
172+
return findPreferences(userId);
173+
}
174+
124175
/**
125176
* 将逗号分隔字符串解析为集合,空串返回空集合。
126177
*/
@@ -140,4 +191,29 @@ private static String joinSet(Set<String> values) {
140191
}
141192
return String.join(",", values);
142193
}
143-
}
194+
195+
/**
196+
* 将 JSON 字符串解析为 Map,null 或解析失败时返回空 Map。
197+
*/
198+
private Map<String, Object> parseJson(String json) {
199+
if (json == null || json.isBlank() || "{}".equals(json.trim())) {
200+
return new HashMap<>();
201+
}
202+
try {
203+
return objectMapper.readValue(json, MAP_TYPE);
204+
} catch (Exception e) {
205+
return new HashMap<>();
206+
}
207+
}
208+
209+
/**
210+
* 将 Map 序列化为 JSON 字符串。
211+
*/
212+
private String toJson(Map<String, Object> map) {
213+
try {
214+
return objectMapper.writeValueAsString(map);
215+
} catch (Exception e) {
216+
return "{}";
217+
}
218+
}
219+
}

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

Lines changed: 11 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,14 @@ 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+
* 将已合并好的全量偏好写入数据库,返回写入后的值。
51+
*/
52+
Map<String, Object> updatePreferences(Long userId, Map<String, Object> merged);
4253
}

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: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77
import com.involutionhell.backend.usercenter.repository.UserAccountRepository;
88
import org.springframework.stereotype.Service;
99

10+
import java.util.HashMap;
1011
import java.util.List;
12+
import java.util.Map;
1113
import java.util.Optional;
1214

1315
@Service
@@ -80,4 +82,22 @@ public UserView updateAuthorization(Long userId, UserAuthorizationUpdateRequest
8082
);
8183
return UserView.from(updatedAccount);
8284
}
85+
86+
/**
87+
* 获取指定用户的偏好 Map,未设置时返回空 Map。
88+
*/
89+
public Map<String, Object> getPreferences(Long userId) {
90+
return userAccountRepository.findPreferences(userId);
91+
}
92+
93+
/**
94+
* 将 patch 合并进用户偏好(顶层 key 覆盖),返回更新后全量偏好。
95+
*/
96+
public Map<String, Object> patchPreferences(Long userId, Map<String, Object> patch) {
97+
// 先读出现有偏好,再在 Java 侧合并,最后整体写回(兼容 H2 测试环境)
98+
Map<String, Object> existing = userAccountRepository.findPreferences(userId);
99+
Map<String, Object> merged = new HashMap<>(existing);
100+
merged.putAll(patch);
101+
return userAccountRepository.updatePreferences(userId, merged);
102+
}
83103
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package com.involutionhell.backend.usercenter.controller;
2+
3+
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
4+
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
5+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
6+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
7+
8+
import com.involutionhell.backend.support.AbstractWebIntegrationTest;
9+
import org.junit.jupiter.api.Test;
10+
import org.springframework.http.MediaType;
11+
12+
/**
13+
* 用户偏好 GET/PATCH 接口集成测试。
14+
*/
15+
class UserPreferencesControllerIntegrationTests extends AbstractWebIntegrationTest {
16+
17+
/** 未登录访问 GET /api/user-center/preferences 应返回 401。 */
18+
@Test
19+
void getPreferencesRejectsAnonymousRequest() throws Exception {
20+
mockMvc.perform(get("/api/user-center/preferences"))
21+
.andExpect(status().isUnauthorized())
22+
.andExpect(jsonPath("$.success").value(false));
23+
}
24+
25+
/** 未登录访问 PATCH /api/user-center/preferences 应返回 401。 */
26+
@Test
27+
void patchPreferencesRejectsAnonymousRequest() throws Exception {
28+
mockMvc.perform(patch("/api/user-center/preferences")
29+
.contentType(MediaType.APPLICATION_JSON)
30+
.content("{\"theme\":\"dark\"}"))
31+
.andExpect(status().isUnauthorized())
32+
.andExpect(jsonPath("$.success").value(false));
33+
}
34+
35+
/**
36+
* 登录用户多次 PATCH 应正确合并偏好:
37+
* {} -> {theme:dark} -> {theme:dark, language:zh} -> {theme:light, language:zh}
38+
*/
39+
@Test
40+
void patchPreferencesMergesCorrectly() throws Exception {
41+
String token = loginAsAlice();
42+
43+
// 第一次:写入 theme
44+
mockMvc.perform(patch("/api/user-center/preferences")
45+
.header("satoken", token)
46+
.contentType(MediaType.APPLICATION_JSON)
47+
.content("{\"theme\":\"dark\"}"))
48+
.andExpect(status().isOk())
49+
.andExpect(jsonPath("$.success").value(true))
50+
.andExpect(jsonPath("$.data.theme").value("dark"));
51+
52+
// 第二次:追加 language,theme 应保留
53+
mockMvc.perform(patch("/api/user-center/preferences")
54+
.header("satoken", token)
55+
.contentType(MediaType.APPLICATION_JSON)
56+
.content("{\"language\":\"zh\"}"))
57+
.andExpect(status().isOk())
58+
.andExpect(jsonPath("$.data.theme").value("dark"))
59+
.andExpect(jsonPath("$.data.language").value("zh"));
60+
61+
// 第三次:更新 theme,language 应保留
62+
mockMvc.perform(patch("/api/user-center/preferences")
63+
.header("satoken", token)
64+
.contentType(MediaType.APPLICATION_JSON)
65+
.content("{\"theme\":\"light\"}"))
66+
.andExpect(status().isOk())
67+
.andExpect(jsonPath("$.data.theme").value("light"))
68+
.andExpect(jsonPath("$.data.language").value("zh"));
69+
70+
// GET 验证最终状态
71+
mockMvc.perform(get("/api/user-center/preferences")
72+
.header("satoken", token))
73+
.andExpect(status().isOk())
74+
.andExpect(jsonPath("$.data.theme").value("light"))
75+
.andExpect(jsonPath("$.data.language").value("zh"));
76+
}
77+
}

0 commit comments

Comments
 (0)