Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@

import java.util.LinkedHashSet;
import java.util.Locale;
import java.util.Map;
import java.util.Set;

public record UserAccount(
Expand All@@ -12,24 +13,26 @@ public record UserAccount(
boolean enabled,
Set<String> roles,
Set<String> 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<String, Object> preferences // 用户偏好,JSONB 顶层 key 自由扩展
) {

/**
* 创建用户对象时统一规范化角色与权限集合。
* 创建用户对象时统一规范化角色与权限集合,偏好为 null 时初始化为空 Map
*/
public UserAccount {
roles = normalizeSet(roles);
permissions = normalizeSet(permissions);
preferences = preferences != null ? preferences : Map.of();
Comment on lines 24 to +28

CopilotAIApr 14, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UserAccountpreferences 仅在为 null 时用 Map.of() 初始化;当传入的是可变 Map(例如从 JDBC/JSON 解析得到的 HashMap)时会直接被记录持有引用,破坏 record 的“快照/不可变”语义,也可能被外部修改导致状态漂移。建议在 compact constructor 里做防御性拷贝并不可变化(如 preferences = preferences == null ? Map.of() : Map.copyOf(preferences),必要时对嵌套结构另行约束)。

Suggested change
*/
publicUserAccount {
roles = normalizeSet(roles);
permissions = normalizeSet(permissions);
preferences = preferences != null ? preferences : Map.of();
* 同时对偏好做防御性拷贝避免持有外部可变Map引用
*/
publicUserAccount {
roles = normalizeSet(roles);
permissions = normalizeSet(permissions);
preferences = preferences == null ? Map.of() : Map.copyOf(preferences);

Copilot uses AI. Check for mistakes.
}

/**
* 基于当前用户信息生成一个新的授权快照。
*/
public UserAccount withAuthorization(Set<String> newRoles, Set<String> 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);
}

/**
Expand Down
Original file line numberDiff line numberDiff 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;
Expand All@@ -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"),
Expand All@@ -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
Expand All@@ -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"});
Expand All@@ -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);

Expand All@@ -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
Expand All@@ -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;
}
}

/**
* 将逗号分隔字符串解析为集合,空串返回空集合。
*/
Expand All@@ -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

CopilotAIApr 14, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parseJson/toJson 在解析或序列化失败时直接返回空对象/"{}",会把“数据库里有脏数据”或“请求体含不可序列化值”等问题静默吞掉;随后一次 PATCH 很可能把原偏好整体覆盖成 {},造成数据丢失且难以排查。建议至少记录日志并抛出异常(让调用方返回 500),或显式区分“未设置”和“解析失败”两类情况,避免静默清空。

Copilot uses AI. Check for mistakes.
}
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;

Expand DownExpand Up@@ -39,4 +40,16 @@ public interface UserAccountRepository {
* 更新 GitHub 用户的个人资料(展示名、头像、邮箱、GitHub ID),每次登录时刷新。
*/
UserAccount updateProfile(Long userId, String displayName, String avatarUrl, String email, Long githubId);

/**
* 查询指定用户的偏好 Map,用户不存在时抛 IllegalArgumentException。
*/
Map<String, Object> findPreferences(Long userId);

/**
* 以 patch 为单位在数据库端原子合并用户偏好(顶层 key 覆盖),返回合并后的全量偏好。
* PostgreSQL 实现走 `preferences || ?::jsonb` 单条 UPDATE,避免并发 lost update;
* H2 走 read-merge-write 路径兼容测试。
*/
Map<String, Object> patchPreferences(Long userId, Map<String, Object> patch);
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -81,7 +81,8 @@ public LoginResponse loginByGithub(AuthUser githubUser) {
Set.of(), // 默认权限
avatarUrl,
email,
githubId
githubId,
null // 偏好由数据库默认值初始化为 {}
);
return userCenterService.createUser(newUser);
});
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,7 @@
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Map;
import java.util.Optional;

@Service
Expand DownExpand Up@@ -80,4 +81,19 @@ public UserView updateAuthorization(Long userId, UserAuthorizationUpdateRequest
);
return UserView.from(updatedAccount);
}

/**
* 获取指定用户的偏好 Map,未设置时返回空 Map。
*/
public Map<String, Object> getPreferences(Long userId) {
return userAccountRepository.findPreferences(userId);
}

/**
* 将 patch 合并进用户偏好(顶层 key 覆盖),返回更新后全量偏好。
* 合并原子性由 repository 层保证(PostgreSQL 用 jsonb 原生 `||` 单条 UPDATE,避免并发 lost update)。
*/
public Map<String, Object> patchPreferences(Long userId, Map<String, Object> patch) {
return userAccountRepository.patchPreferences(userId, patch);
}
}
3 changes: 3 additions & 0 deletions src/main/resources/schema.sql
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
Loading