feat(system): 用户主题配置持久化

This commit is contained in:
2026-04-02 20:45:42 +08:00
parent fc1c976e46
commit a6d9c99376
9 changed files with 345 additions and 1 deletions

View File

@@ -0,0 +1,54 @@
package com.njcn.rdms.module.system.controller.admin.user;
import com.njcn.rdms.framework.common.pojo.CommonResult;
import com.njcn.rdms.module.system.controller.admin.user.vo.preference.UserPreferenceThemeRespVO;
import com.njcn.rdms.module.system.controller.admin.user.vo.preference.UserPreferenceThemeSaveReqVO;
import com.njcn.rdms.module.system.service.user.UserPreferenceService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import static com.njcn.rdms.framework.common.pojo.CommonResult.success;
import static com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
@Tag(name = "管理后台 - 用户主题偏好")
@RestController
@RequestMapping("/system/user-preference")
@Validated
public class UserPreferenceController {
@Resource
private UserPreferenceService userPreferenceService;
@GetMapping("/theme")
@Operation(summary = "获得当前登录用户主题配置")
public CommonResult<UserPreferenceThemeRespVO> getTheme() {
// 只允许读取当前登录用户自己的主题配置,不提供按 userId 查询他人配置的入口。
return success(UserPreferenceThemeRespVO.of(userPreferenceService.getThemeSettings(getLoginUserId())));
}
@PutMapping("/theme")
@Operation(summary = "保存当前登录用户主题配置")
public CommonResult<Boolean> saveTheme(@Valid @RequestBody UserPreferenceThemeSaveReqVO reqVO) {
// 保存归属始终以后端登录态为准,禁止前端通过请求体指定目标用户。
userPreferenceService.saveThemeSettings(getLoginUserId(), reqVO.getThemeSettings());
return success(true);
}
@DeleteMapping("/theme")
@Operation(summary = "重置当前登录用户主题配置")
public CommonResult<Boolean> resetTheme() {
// 重置语义是清空当前用户的个性化覆盖项,前端随后回退到默认主题配置。
userPreferenceService.resetThemeSettings(getLoginUserId());
return success(true);
}
}

View File

@@ -0,0 +1,48 @@
package com.njcn.rdms.module.system.controller.admin.user.vo.preference;
import com.fasterxml.jackson.annotation.JsonAnyGetter;
import com.fasterxml.jackson.annotation.JsonAnySetter;
import com.fasterxml.jackson.annotation.JsonIgnore;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.LinkedHashMap;
import java.util.Map;
@Schema(description = "管理后台 - 当前用户主题配置 Response VO")
public class UserPreferenceThemeRespVO {
/**
* 返回给前端的主题配置覆盖项。
*
* 后端只返回用户自己保存过的覆盖项;
* 若当前用户尚未保存,则由 Service 保证返回空对象 {}。
*/
@Schema(description = "主题配置覆盖项")
private final Map<String, Object> themeSettings = new LinkedHashMap<>();
@JsonIgnore
public Map<String, Object> getThemeSettings() {
return themeSettings;
}
@JsonAnyGetter
public Map<String, Object> any() {
return themeSettings;
}
@JsonAnySetter
public void put(String key, Object value) {
// 保持响应体扁平化,避免序列化成 { "themeSettings": { ... } } 的额外嵌套。
themeSettings.put(key, value);
}
public static UserPreferenceThemeRespVO of(Map<String, Object> themeSettings) {
UserPreferenceThemeRespVO respVO = new UserPreferenceThemeRespVO();
if (themeSettings != null) {
// 仅复制覆盖项本身,不在后端补默认主题配置,默认值仍由前端维护。
respVO.getThemeSettings().putAll(themeSettings);
}
return respVO;
}
}

View File

@@ -0,0 +1,39 @@
package com.njcn.rdms.module.system.controller.admin.user.vo.preference;
import com.fasterxml.jackson.annotation.JsonAnyGetter;
import com.fasterxml.jackson.annotation.JsonAnySetter;
import com.fasterxml.jackson.annotation.JsonIgnore;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.LinkedHashMap;
import java.util.Map;
@Schema(description = "管理后台 - 当前用户主题配置保存 Request VO")
public class UserPreferenceThemeSaveReqVO {
/**
* 前端传入的主题配置覆盖项。
*
* 这里故意不声明固定字段,原因是主题配置项是可扩展的;
* 同时不接收 userId 等归属字段,配置归属只以后端登录态识别。
*/
@Schema(description = "主题配置覆盖项")
private final Map<String, Object> themeSettings = new LinkedHashMap<>();
@JsonIgnore
public Map<String, Object> getThemeSettings() {
return themeSettings;
}
@JsonAnyGetter
public Map<String, Object> any() {
return themeSettings;
}
@JsonAnySetter
public void put(String key, Object value) {
// 将请求体中的任意 JSON 字段收集为主题覆盖项,保持接口形态仍然是“直接 JSON 对象”。
themeSettings.put(key, value);
}
}

View File

@@ -0,0 +1,54 @@
package com.njcn.rdms.module.system.dal.dataobject.user;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import com.njcn.rdms.framework.mybatis.core.dataobject.BaseDO;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.ToString;
import java.util.Map;
/**
* 用户偏好配置 DO
*/
@TableName(value = "system_user_preference", autoResultMap = true)
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserPreferenceDO extends BaseDO {
/**
* 主键 ID
*
* 由当前项目 MyBatis Plus 的 ASSIGN_ID 策略生成,不要求数据库自增。
*/
@TableId
private Long id;
/**
* 用户 ID
*
* 数据库层面对该字段有唯一约束,保证每个用户至多一条偏好记录。
*/
private Long userId;
/**
* 用户主题配置覆盖项
*
* 使用 updateStrategy = ALWAYS保证重置时可以将 JSON 字段更新为 null。
* 这里只保存用户覆盖项,不保存前端完整默认主题配置。
*/
@TableField(typeHandler = JacksonTypeHandler.class, updateStrategy = FieldStrategy.ALWAYS)
private Map<String, Object> themeSettingsJson;
}

View File

@@ -0,0 +1,26 @@
package com.njcn.rdms.module.system.dal.mysql.user;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.njcn.rdms.framework.mybatis.core.mapper.BaseMapperX;
import com.njcn.rdms.module.system.dal.dataobject.user.UserPreferenceDO;
import org.apache.ibatis.annotations.Mapper;
import java.util.Map;
@Mapper
public interface UserPreferenceMapper extends BaseMapperX<UserPreferenceDO> {
default UserPreferenceDO selectByUserId(Long userId) {
// 主题偏好按 user_id 一对一绑定,查询入口只保留按用户读取。
return selectOne(UserPreferenceDO::getUserId, userId);
}
default int updateThemeSettingsByUserId(Long userId, Map<String, Object> themeSettingsJson) {
UserPreferenceDO updateObj = new UserPreferenceDO();
updateObj.setThemeSettingsJson(themeSettingsJson);
// 只更新主题 JSON 字段,避免误改用户归属;允许更新为 null用于“重置主题”场景。
return update(updateObj, new LambdaUpdateWrapper<UserPreferenceDO>()
.eq(UserPreferenceDO::getUserId, userId));
}
}

View File

@@ -0,0 +1,48 @@
package com.njcn.rdms.module.system.service.user;
import jakarta.validation.Valid;
import java.util.Map;
/**
* 用户偏好配置 Service 接口
*/
public interface UserPreferenceService {
/**
* 获得当前用户主题配置覆盖项
*
* 约束:
* 1. 只处理当前登录用户自己的配置;
* 2. 若数据库中没有记录,返回空对象 {},而不是 null。
*
* @param userId 用户 ID
* @return 主题配置覆盖项;未保存时返回空对象
*/
Map<String, Object> getThemeSettings(Long userId);
/**
* 保存当前用户主题配置覆盖项
*
* 约束:
* 1. 保存的是用户主题覆盖项,不是完整默认主题;
* 2. 前端不允许传 userId 决定归属,归属由调用方基于登录态传入;
* 3. 传入空对象时,等价于清空个性化覆盖项。
*
* @param userId 用户 ID
* @param themeSettings 主题配置覆盖项
*/
void saveThemeSettings(Long userId, @Valid Map<String, Object> themeSettings);
/**
* 重置当前用户主题配置覆盖项
*
* 约束:
* 1. 重置后接口再次读取应返回空对象 {}
* 2. 具体实现不要求物理删除记录,但外部语义必须等价于“没有个性化配置”。
*
* @param userId 用户 ID
*/
void resetThemeSettings(Long userId);
}

View File

@@ -0,0 +1,68 @@
package com.njcn.rdms.module.system.service.user;
import cn.hutool.core.map.MapUtil;
import com.njcn.rdms.module.system.dal.dataobject.user.UserPreferenceDO;
import com.njcn.rdms.module.system.dal.mysql.user.UserPreferenceMapper;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 用户偏好配置 Service 实现类
*/
@Service
@Validated
@Slf4j
public class UserPreferenceServiceImpl implements UserPreferenceService {
@Resource
private UserPreferenceMapper userPreferenceMapper;
@Override
public Map<String, Object> getThemeSettings(Long userId) {
UserPreferenceDO preference = userPreferenceMapper.selectByUserId(userId);
if (preference == null || MapUtil.isEmpty(preference.getThemeSettingsJson())) {
// 对外固定返回 {},避免前端再区分 null 与空对象两套分支。
return new LinkedHashMap<>();
}
// 返回副本,避免上层误改持久化对象里的 Map 引用。
return new LinkedHashMap<>(preference.getThemeSettingsJson());
}
@Override
public void saveThemeSettings(Long userId, Map<String, Object> themeSettings) {
UserPreferenceDO preference = userPreferenceMapper.selectByUserId(userId);
if (MapUtil.isEmpty(themeSettings)) {
// 空对象等价于“没有个性化覆盖项”,直接复用重置语义,避免数据库残留无意义的 {}。
if (preference != null) {
userPreferenceMapper.updateThemeSettingsByUserId(userId, null);
}
return;
}
// 保存副本而不是直接持有入参引用,避免后续调用链继续改动同一 Map。
Map<String, Object> themeSettingsCopy = new LinkedHashMap<>(themeSettings);
if (preference == null) {
// 当前用户还没有偏好记录时创建首条数据,依赖 user_id 唯一约束保证一人一份。
userPreferenceMapper.insert(UserPreferenceDO.builder()
.userId(userId)
.themeSettingsJson(themeSettingsCopy)
.build());
return;
}
// 当前用户已有记录时仅更新主题覆盖项 JSON不改动归属 userId。
userPreferenceMapper.updateThemeSettingsByUserId(userId, themeSettingsCopy);
}
@Override
public void resetThemeSettings(Long userId) {
// 不直接做逻辑删除,避免 user_id 唯一键与逻辑删除组合后影响后续再次保存。
userPreferenceMapper.updateThemeSettingsByUserId(userId, null);
}
}

View File

@@ -63,7 +63,7 @@ spring:
data: data:
redis: redis:
host: 127.0.0.1 # 地址 host: 127.0.0.1 # 地址
port: 6379 # 端口 port: 16379 # 端口
database: 1 # 数据库索引 database: 1 # 数据库索引
# password: njcnpqs # 密码,建议生产环境开启 # password: njcnpqs # 密码,建议生产环境开启

View File

@@ -34,6 +34,8 @@ if ($LASTEXITCODE -eq 0) {
throw "当前没有已暂存的改动,请先执行 git add。" throw "当前没有已暂存的改动,请先执行 git add。"
} }
$stagedFiles = @(git -C $repoRoot diff --cached --name-only)
$types = @( $types = @(
'feat', 'feat',
'feat-wip', 'feat-wip',
@@ -82,6 +84,11 @@ $message = "{0}({1}): {2}" -f $type, $scope, $description
Write-Host "" Write-Host ""
Write-Host "提交信息预览:$message" Write-Host "提交信息预览:$message"
Write-Host ""
Write-Host "本次将提交以下已暂存文件:"
foreach ($file in $stagedFiles) {
Write-Host " - $file"
}
$confirm = Read-Host '确认提交请输入 y取消请输入其他任意内容' $confirm = Read-Host '确认提交请输入 y取消请输入其他任意内容'
if ($confirm -ne 'y' -and $confirm -ne 'Y') { if ($confirm -ne 'y' -and $confirm -ne 'Y') {