diff --git a/githooks/commit-msg b/githooks/commit-msg new file mode 100644 index 0000000..075ebc3 --- /dev/null +++ b/githooks/commit-msg @@ -0,0 +1,40 @@ +#!/bin/sh + +set -eu + +MSG_FILE="$1" +FIRST_LINE="$(sed -n '1p' "$MSG_FILE" | tr -d '\r')" + +# Allow Git-generated merge commits and revert commits to pass through. +case "$FIRST_LINE" in + Merge\ *|Revert\ \"*) + exit 0 + ;; +esac + +PATTERN='^(feat|feat-wip|fix|docs|typo|style|refactor|perf|optimize|test|build|ci|chore|revert)\([a-z][a-z0-9-]*\): .+$' + +if printf '%s\n' "$FIRST_LINE" | grep -Eq "$PATTERN"; then + exit 0 +fi + +cat <<'EOF' >&2 +ERROR: commit message must follow Conventional Commits. + +Required format: + type(scope): 描述 + +Examples: + fix(system): 修复组织树查询空指针问题 + refactor(framework): 拆分权限上下文装配逻辑 + docs(other): 补充后端提交规范说明 + +Allowed types: + feat, feat-wip, fix, docs, typo, style, refactor, perf, + optimize, test, build, ci, chore, revert + +Tip: + 优先使用贴近模块的 scope,例如 system、gateway、framework、security、sql、deps、other。 +EOF + +exit 1 diff --git a/rdms-framework/rdms-spring-boot-starter-web/src/main/java/com/njcn/rdms/framework/encrypt/config/ApiEncryptProperties.java b/rdms-framework/rdms-spring-boot-starter-web/src/main/java/com/njcn/rdms/framework/encrypt/config/ApiEncryptProperties.java index 749100e..c1e4f06 100644 --- a/rdms-framework/rdms-spring-boot-starter-web/src/main/java/com/njcn/rdms/framework/encrypt/config/ApiEncryptProperties.java +++ b/rdms-framework/rdms-spring-boot-starter-web/src/main/java/com/njcn/rdms/framework/encrypt/config/ApiEncryptProperties.java @@ -63,8 +63,9 @@ public class ApiEncryptProperties { * 注意: * 1. 如果是【对称加密】时,它「后端」对应的是“密钥”。对应的,「前端」也对应的也是“密钥”。 * 2. 如果是【非对称加密】时,它「后端」对应的是“公钥”。对应的,「前端」对应的是“私钥”。(重要!!!) + * + * 当前 system 模块密码加密方案不启用响应加密,因此该配置可为空。 */ - @NotEmpty(message = "响应的加密密钥不能为空") private String responseKey; } diff --git a/rdms-framework/rdms-spring-boot-starter-web/src/main/java/com/njcn/rdms/framework/encrypt/config/RdmsApiEncryptAutoConfiguration.java b/rdms-framework/rdms-spring-boot-starter-web/src/main/java/com/njcn/rdms/framework/encrypt/config/RdmsApiEncryptAutoConfiguration.java index 1c0a459..8c8135a 100644 --- a/rdms-framework/rdms-spring-boot-starter-web/src/main/java/com/njcn/rdms/framework/encrypt/config/RdmsApiEncryptAutoConfiguration.java +++ b/rdms-framework/rdms-spring-boot-starter-web/src/main/java/com/njcn/rdms/framework/encrypt/config/RdmsApiEncryptAutoConfiguration.java @@ -4,6 +4,7 @@ import com.njcn.rdms.framework.common.enums.WebFilterOrderEnum; import com.njcn.rdms.framework.encrypt.core.filter.ApiEncryptFilter; import com.njcn.rdms.framework.web.config.WebProperties; import com.njcn.rdms.framework.web.core.handler.GlobalExceptionHandler; +import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -24,9 +25,10 @@ public class RdmsApiEncryptAutoConfiguration { public FilterRegistrationBean apiEncryptFilter(WebProperties webProperties, ApiEncryptProperties apiEncryptProperties, RequestMappingHandlerMapping requestMappingHandlerMapping, - GlobalExceptionHandler globalExceptionHandler) { + GlobalExceptionHandler globalExceptionHandler, + ObjectMapper objectMapper) { ApiEncryptFilter filter = new ApiEncryptFilter(webProperties, apiEncryptProperties, - requestMappingHandlerMapping, globalExceptionHandler); + requestMappingHandlerMapping, globalExceptionHandler, objectMapper); return createFilterBean(filter, WebFilterOrderEnum.API_ENCRYPT_FILTER); } diff --git a/rdms-framework/rdms-spring-boot-starter-web/src/main/java/com/njcn/rdms/framework/encrypt/core/annotation/ApiEncrypt.java b/rdms-framework/rdms-spring-boot-starter-web/src/main/java/com/njcn/rdms/framework/encrypt/core/annotation/ApiEncrypt.java index bd774df..a0d74bf 100644 --- a/rdms-framework/rdms-spring-boot-starter-web/src/main/java/com/njcn/rdms/framework/encrypt/core/annotation/ApiEncrypt.java +++ b/rdms-framework/rdms-spring-boot-starter-web/src/main/java/com/njcn/rdms/framework/encrypt/core/annotation/ApiEncrypt.java @@ -15,6 +15,13 @@ public @interface ApiEncrypt { */ boolean request() default true; + /** + * 需要解密的请求字段 + * + * 仅在 request = true 时生效 + */ + String[] requestFields() default {}; + /** * 是否对响应结果进行加密,默认 true */ diff --git a/rdms-framework/rdms-spring-boot-starter-web/src/main/java/com/njcn/rdms/framework/encrypt/core/filter/ApiDecryptRequestWrapper.java b/rdms-framework/rdms-spring-boot-starter-web/src/main/java/com/njcn/rdms/framework/encrypt/core/filter/ApiDecryptRequestWrapper.java index ae0bc6d..ff3cfa7 100644 --- a/rdms-framework/rdms-spring-boot-starter-web/src/main/java/com/njcn/rdms/framework/encrypt/core/filter/ApiDecryptRequestWrapper.java +++ b/rdms-framework/rdms-spring-boot-starter-web/src/main/java/com/njcn/rdms/framework/encrypt/core/filter/ApiDecryptRequestWrapper.java @@ -1,7 +1,11 @@ package com.njcn.rdms.framework.encrypt.core.filter; +import cn.hutool.core.collection.CollUtil; import cn.hutool.core.io.IoUtil; import cn.hutool.core.util.StrUtil; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; import cn.hutool.crypto.asymmetric.AsymmetricDecryptor; import cn.hutool.crypto.asymmetric.KeyType; import cn.hutool.crypto.symmetric.SymmetricDecryptor; @@ -14,6 +18,9 @@ import java.io.BufferedReader; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStreamReader; +import java.util.List; + +import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.invalidParamException; /** * 解密请求 {@link HttpServletRequestWrapper} 实现类 @@ -25,16 +32,41 @@ public class ApiDecryptRequestWrapper extends HttpServletRequestWrapper { private final byte[] body; public ApiDecryptRequestWrapper(HttpServletRequest request, + ObjectMapper objectMapper, + List requestFields, SymmetricDecryptor symmetricDecryptor, AsymmetricDecryptor asymmetricDecryptor) throws IOException { super(request); - // 读取 body,允许 HEX、BASE64 传输 - String requestBody = StrUtil.utf8Str( - IoUtil.readBytes(request.getInputStream(), false)); + if (CollUtil.isEmpty(requestFields)) { + throw invalidParamException("请求解密失败,请刷新页面后重试"); + } + String requestBody = StrUtil.utf8Str(IoUtil.readBytes(request.getInputStream(), false)); + if (StrUtil.isBlank(requestBody)) { + throw invalidParamException("请求解密失败,请刷新页面后重试"); + } - // 解密 body - body = symmetricDecryptor != null ? symmetricDecryptor.decrypt(requestBody) - : asymmetricDecryptor.decrypt(requestBody, KeyType.PrivateKey); + JsonNode requestJson; + try { + requestJson = objectMapper.readTree(requestBody); + } catch (Exception ex) { + throw invalidParamException("请求解密失败,请刷新页面后重试"); + } + if (!(requestJson instanceof ObjectNode requestObject)) { + throw invalidParamException("请求解密失败,请刷新页面后重试"); + } + + for (String requestField : requestFields) { + JsonNode fieldNode = requestObject.get(requestField); + if (fieldNode == null || fieldNode.isNull() || !fieldNode.isTextual() || StrUtil.isBlank(fieldNode.asText())) { + throw invalidParamException("请求解密失败,请刷新页面后重试"); + } + byte[] decryptedBytes = symmetricDecryptor != null + ? symmetricDecryptor.decrypt(fieldNode.asText()) + : asymmetricDecryptor.decrypt(fieldNode.asText(), KeyType.PrivateKey); + requestObject.put(requestField, StrUtil.utf8Str(decryptedBytes)); + } + + body = objectMapper.writeValueAsBytes(requestObject); } @Override diff --git a/rdms-framework/rdms-spring-boot-starter-web/src/main/java/com/njcn/rdms/framework/encrypt/core/filter/ApiEncryptFilter.java b/rdms-framework/rdms-spring-boot-starter-web/src/main/java/com/njcn/rdms/framework/encrypt/core/filter/ApiEncryptFilter.java index 8b3147b..e0aad30 100644 --- a/rdms-framework/rdms-spring-boot-starter-web/src/main/java/com/njcn/rdms/framework/encrypt/core/filter/ApiEncryptFilter.java +++ b/rdms-framework/rdms-spring-boot-starter-web/src/main/java/com/njcn/rdms/framework/encrypt/core/filter/ApiEncryptFilter.java @@ -6,6 +6,7 @@ import cn.hutool.crypto.asymmetric.AsymmetricDecryptor; import cn.hutool.crypto.asymmetric.AsymmetricEncryptor; import cn.hutool.crypto.symmetric.SymmetricDecryptor; import cn.hutool.crypto.symmetric.SymmetricEncryptor; +import com.fasterxml.jackson.databind.ObjectMapper; import com.njcn.rdms.framework.common.pojo.CommonResult; import com.njcn.rdms.framework.common.util.object.ObjectUtils; import com.njcn.rdms.framework.common.util.servlet.ServletUtils; @@ -26,6 +27,7 @@ import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandl import org.springframework.web.util.ServletRequestPathUtils; import java.io.IOException; +import java.util.Arrays; import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil.invalidParamException; @@ -43,12 +45,17 @@ import static com.njcn.rdms.framework.common.exception.util.ServiceExceptionUtil @Slf4j public class ApiEncryptFilter extends ApiRequestFilter { + private static final String MISSING_ENCRYPT_HEADER_MESSAGE = "当前接口要求加密传输,请刷新页面后重试"; + private static final String DECRYPT_FAILED_MESSAGE = "请求解密失败,请刷新页面后重试"; + private final ApiEncryptProperties apiEncryptProperties; private final RequestMappingHandlerMapping requestMappingHandlerMapping; private final GlobalExceptionHandler globalExceptionHandler; + private final ObjectMapper objectMapper; + private final SymmetricDecryptor requestSymmetricDecryptor; private final AsymmetricDecryptor requestAsymmetricDecryptor; @@ -58,21 +65,25 @@ public class ApiEncryptFilter extends ApiRequestFilter { public ApiEncryptFilter(WebProperties webProperties, ApiEncryptProperties apiEncryptProperties, RequestMappingHandlerMapping requestMappingHandlerMapping, - GlobalExceptionHandler globalExceptionHandler) { + GlobalExceptionHandler globalExceptionHandler, + ObjectMapper objectMapper) { super(webProperties); this.apiEncryptProperties = apiEncryptProperties; this.requestMappingHandlerMapping = requestMappingHandlerMapping; this.globalExceptionHandler = globalExceptionHandler; + this.objectMapper = objectMapper; if (StrUtil.equalsIgnoreCase(apiEncryptProperties.getAlgorithm(), "AES")) { this.requestSymmetricDecryptor = SecureUtil.aes(StrUtil.utf8Bytes(apiEncryptProperties.getRequestKey())); this.requestAsymmetricDecryptor = null; - this.responseSymmetricEncryptor = SecureUtil.aes(StrUtil.utf8Bytes(apiEncryptProperties.getResponseKey())); + this.responseSymmetricEncryptor = StrUtil.isNotBlank(apiEncryptProperties.getResponseKey()) + ? SecureUtil.aes(StrUtil.utf8Bytes(apiEncryptProperties.getResponseKey())) : null; this.responseAsymmetricEncryptor = null; } else if (StrUtil.equalsIgnoreCase(apiEncryptProperties.getAlgorithm(), "RSA")) { this.requestSymmetricDecryptor = null; this.requestAsymmetricDecryptor = SecureUtil.rsa(apiEncryptProperties.getRequestKey(), null); this.responseSymmetricEncryptor = null; - this.responseAsymmetricEncryptor = SecureUtil.rsa(null, apiEncryptProperties.getResponseKey()); + this.responseAsymmetricEncryptor = StrUtil.isNotBlank(apiEncryptProperties.getResponseKey()) + ? SecureUtil.rsa(null, apiEncryptProperties.getResponseKey()) : null; } else { // 补充说明:如果要支持 SM2、SM4 等算法,可在此处增加对应实例的创建,并添加相应的 Maven 依赖即可。 throw new IllegalArgumentException("不支持的加密算法:" + apiEncryptProperties.getAlgorithm()); @@ -86,9 +97,10 @@ public class ApiEncryptFilter extends ApiRequestFilter { // 获取 @ApiEncrypt 注解 ApiEncrypt apiEncrypt = getApiEncrypt(request); boolean requestEnable = apiEncrypt != null && apiEncrypt.request(); - boolean responseEnable = apiEncrypt != null && apiEncrypt.response(); + boolean responseEnable = apiEncrypt != null && apiEncrypt.response() + && (responseSymmetricEncryptor != null || responseAsymmetricEncryptor != null); String encryptHeader = request.getHeader(apiEncryptProperties.getHeader()); - if (!requestEnable && !responseEnable && StrUtil.isBlank(encryptHeader)) { + if (!requestEnable && !responseEnable) { chain.doFilter(request, response); return; } @@ -97,13 +109,19 @@ public class ApiEncryptFilter extends ApiRequestFilter { if (ObjectUtils.equalsAny(HttpMethod.valueOf(request.getMethod()), HttpMethod.POST, HttpMethod.PUT, HttpMethod.DELETE)) { try { - if (StrUtil.isNotBlank(encryptHeader)) { - request = new ApiDecryptRequestWrapper(request, + if (requestEnable) { + if (StrUtil.isBlank(encryptHeader)) { + throw invalidParamException(MISSING_ENCRYPT_HEADER_MESSAGE); + } + request = new ApiDecryptRequestWrapper(request, objectMapper, + Arrays.asList(apiEncrypt.requestFields()), requestSymmetricDecryptor, requestAsymmetricDecryptor); - } else if (requestEnable) { - throw invalidParamException("请求未包含加密标头,请检查是否正确配置了加密标头"); } } catch (Exception ex) { + if (!(ex instanceof com.njcn.rdms.framework.common.exception.ServiceException) + || !StrUtil.equals(ex.getMessage(), MISSING_ENCRYPT_HEADER_MESSAGE)) { + ex = invalidParamException(DECRYPT_FAILED_MESSAGE); + } CommonResult result = globalExceptionHandler.allExceptionHandler(request, ex); ServletUtils.writeJSON(response, result); return; diff --git a/rdms-gateway/src/main/resources/application-dev.yaml b/rdms-gateway/src/main/resources/application-dev.yaml index a93a128..197e44d 100644 --- a/rdms-gateway/src/main/resources/application-dev.yaml +++ b/rdms-gateway/src/main/resources/application-dev.yaml @@ -6,10 +6,10 @@ spring: username: # Nacos 账号 password: # Nacos 密码 discovery: # 【配置中心】配置项 - namespace: dev # 命名空间。这里使用 dev 开发环境 + namespace: 1e0fcd92-49b4-4cda-b531-828c7d36fef5 # 命名空间。这里使用 dev 开发环境 group: DEFAULT_GROUP # 使用的 Nacos 配置分组,默认为 DEFAULT_GROUP config: # 【注册中心】配置项 - namespace: dev # 命名空间。这里使用 dev 开发环境 + namespace: 1e0fcd92-49b4-4cda-b531-828c7d36fef5 # 命名空间。这里使用 dev 开发环境 group: DEFAULT_GROUP # 使用的 Nacos 配置分组,默认为 DEFAULT_GROUP #################### 监控相关配置 #################### diff --git a/rdms-gateway/src/main/resources/application-local.yaml b/rdms-gateway/src/main/resources/application-local.yaml index 5d2e345..f5587ae 100644 --- a/rdms-gateway/src/main/resources/application-local.yaml +++ b/rdms-gateway/src/main/resources/application-local.yaml @@ -6,10 +6,10 @@ spring: username: # Nacos 账号 password: # Nacos 密码 discovery: # 【配置中心】配置项 - namespace: dev # 命名空间。这里使用 dev 开发环境 + namespace: 1e0fcd92-49b4-4cda-b531-828c7d36fef5 # 命名空间。这里使用 dev 开发环境 group: DEFAULT_GROUP # 使用的 Nacos 配置分组,默认为 DEFAULT_GROUP config: # 【注册中心】配置项 - namespace: dev # 命名空间。这里使用 dev 开发环境 + namespace: 1e0fcd92-49b4-4cda-b531-828c7d36fef5 # 命名空间。这里使用 dev 开发环境 group: DEFAULT_GROUP # 使用的 Nacos 配置分组,默认为 DEFAULT_GROUP #################### 监控相关配置 #################### diff --git a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/auth/AuthController.java b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/auth/AuthController.java index 29d4ad6..1676d58 100644 --- a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/auth/AuthController.java +++ b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/auth/AuthController.java @@ -4,6 +4,7 @@ import cn.hutool.core.collection.CollUtil; import cn.hutool.core.util.StrUtil; import com.njcn.rdms.framework.common.enums.CommonStatusEnum; import com.njcn.rdms.framework.common.pojo.CommonResult; +import com.njcn.rdms.framework.encrypt.core.annotation.ApiEncrypt; import com.njcn.rdms.framework.security.config.SecurityProperties; import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils; import com.njcn.rdms.module.system.controller.admin.auth.vo.AuthLoginReqVO; @@ -69,6 +70,7 @@ public class AuthController { @PostMapping("/login") @PermitAll @Operation(summary = "使用账号密码登录") + @ApiEncrypt(response = false, requestFields = {"password"}) public CommonResult login(@RequestBody @Valid AuthLoginReqVO reqVO) { return success(authService.login(reqVO)); } @@ -141,6 +143,7 @@ public class AuthController { @PostMapping("/register") @PermitAll @Operation(summary = "注册用户") + @ApiEncrypt(response = false, requestFields = {"password"}) public CommonResult register(@RequestBody @Valid AuthRegisterReqVO registerReqVO) { return success(authService.register(registerReqVO)); } diff --git a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/user/UserController.java b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/user/UserController.java index f6c8c18..902ca0b 100644 --- a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/user/UserController.java +++ b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/user/UserController.java @@ -6,6 +6,7 @@ import com.njcn.rdms.framework.common.enums.CommonStatusEnum; import com.njcn.rdms.framework.common.pojo.CommonResult; import com.njcn.rdms.framework.common.pojo.PageParam; import com.njcn.rdms.framework.common.pojo.PageResult; +import com.njcn.rdms.framework.encrypt.core.annotation.ApiEncrypt; import com.njcn.rdms.framework.excel.core.util.ExcelUtils; import com.njcn.rdms.module.system.controller.admin.user.vo.user.UserImportExcelVO; import com.njcn.rdms.module.system.controller.admin.user.vo.user.UserImportRespVO; @@ -67,6 +68,7 @@ public class UserController { @PostMapping("/create") @Operation(summary = "新增用户") @PreAuthorize("@ss.hasPermission('system:user:create')") + @ApiEncrypt(response = false, requestFields = {"password"}) public CommonResult createUser(@Valid @RequestBody UserSaveReqVO reqVO) { Long id = userService.createUser(reqVO); return success(id); @@ -101,6 +103,7 @@ public class UserController { @PutMapping("/update-password") @Operation(summary = "重置用户密码") @PreAuthorize("@ss.hasPermission('system:user:update-password')") + @ApiEncrypt(response = false, requestFields = {"password"}) public CommonResult updateUserPassword(@Valid @RequestBody UserUpdatePasswordReqVO reqVO) { userService.updateUserPassword(reqVO.getId(), reqVO.getPassword()); return success(true); diff --git a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/user/UserPreferenceController.java b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/user/UserPreferenceController.java new file mode 100644 index 0000000..0722b0c --- /dev/null +++ b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/user/UserPreferenceController.java @@ -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 getTheme() { + // 只允许读取当前登录用户自己的主题配置,不提供按 userId 查询他人配置的入口。 + return success(UserPreferenceThemeRespVO.of(userPreferenceService.getThemeSettings(getLoginUserId()))); + } + + @PutMapping("/theme") + @Operation(summary = "保存当前登录用户主题配置") + public CommonResult saveTheme(@Valid @RequestBody UserPreferenceThemeSaveReqVO reqVO) { + // 保存归属始终以后端登录态为准,禁止前端通过请求体指定目标用户。 + userPreferenceService.saveThemeSettings(getLoginUserId(), reqVO.getThemeSettings()); + return success(true); + } + + @DeleteMapping("/theme") + @Operation(summary = "重置当前登录用户主题配置") + public CommonResult resetTheme() { + // 重置语义是清空当前用户的个性化覆盖项,前端随后回退到默认主题配置。 + userPreferenceService.resetThemeSettings(getLoginUserId()); + return success(true); + } + +} diff --git a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/user/UserProfileController.java b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/user/UserProfileController.java index 5bd6acb..445443e 100644 --- a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/user/UserProfileController.java +++ b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/user/UserProfileController.java @@ -2,6 +2,7 @@ package com.njcn.rdms.module.system.controller.admin.user; import com.njcn.rdms.framework.common.pojo.CommonResult; import com.njcn.rdms.framework.common.enums.CommonStatusEnum; +import com.njcn.rdms.framework.encrypt.core.annotation.ApiEncrypt; import com.njcn.rdms.module.system.controller.admin.user.vo.profile.UserProfileRespVO; import com.njcn.rdms.module.system.controller.admin.user.vo.profile.UserProfileUpdatePasswordReqVO; import com.njcn.rdms.module.system.controller.admin.user.vo.profile.UserProfileUpdateReqVO; @@ -77,6 +78,7 @@ public class UserProfileController { @PutMapping("/update-password") @Operation(summary = "修改用户个人密码") + @ApiEncrypt(response = false, requestFields = {"oldPassword", "newPassword"}) public CommonResult updateUserProfilePassword(@Valid @RequestBody UserProfileUpdatePasswordReqVO reqVO) { userService.updateUserPassword(getLoginUserId(), reqVO); return success(true); diff --git a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/user/vo/preference/UserPreferenceThemeRespVO.java b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/user/vo/preference/UserPreferenceThemeRespVO.java new file mode 100644 index 0000000..247a73f --- /dev/null +++ b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/user/vo/preference/UserPreferenceThemeRespVO.java @@ -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 themeSettings = new LinkedHashMap<>(); + + @JsonIgnore + public Map getThemeSettings() { + return themeSettings; + } + + @JsonAnyGetter + public Map any() { + return themeSettings; + } + + @JsonAnySetter + public void put(String key, Object value) { + // 保持响应体扁平化,避免序列化成 { "themeSettings": { ... } } 的额外嵌套。 + themeSettings.put(key, value); + } + + public static UserPreferenceThemeRespVO of(Map themeSettings) { + UserPreferenceThemeRespVO respVO = new UserPreferenceThemeRespVO(); + if (themeSettings != null) { + // 仅复制覆盖项本身,不在后端补默认主题配置,默认值仍由前端维护。 + respVO.getThemeSettings().putAll(themeSettings); + } + return respVO; + } + +} diff --git a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/user/vo/preference/UserPreferenceThemeSaveReqVO.java b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/user/vo/preference/UserPreferenceThemeSaveReqVO.java new file mode 100644 index 0000000..76934aa --- /dev/null +++ b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/controller/admin/user/vo/preference/UserPreferenceThemeSaveReqVO.java @@ -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 themeSettings = new LinkedHashMap<>(); + + @JsonIgnore + public Map getThemeSettings() { + return themeSettings; + } + + @JsonAnyGetter + public Map any() { + return themeSettings; + } + + @JsonAnySetter + public void put(String key, Object value) { + // 将请求体中的任意 JSON 字段收集为主题覆盖项,保持接口形态仍然是“直接 JSON 对象”。 + themeSettings.put(key, value); + } + +} diff --git a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/dal/dataobject/user/UserPreferenceDO.java b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/dal/dataobject/user/UserPreferenceDO.java new file mode 100644 index 0000000..1778569 --- /dev/null +++ b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/dal/dataobject/user/UserPreferenceDO.java @@ -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 themeSettingsJson; + +} diff --git a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/dal/mysql/user/UserPreferenceMapper.java b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/dal/mysql/user/UserPreferenceMapper.java new file mode 100644 index 0000000..bbad5d1 --- /dev/null +++ b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/dal/mysql/user/UserPreferenceMapper.java @@ -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 { + + default UserPreferenceDO selectByUserId(Long userId) { + // 主题偏好按 user_id 一对一绑定,查询入口只保留按用户读取。 + return selectOne(UserPreferenceDO::getUserId, userId); + } + + default int updateThemeSettingsByUserId(Long userId, Map themeSettingsJson) { + UserPreferenceDO updateObj = new UserPreferenceDO(); + updateObj.setThemeSettingsJson(themeSettingsJson); + // 只更新主题 JSON 字段,避免误改用户归属;允许更新为 null,用于“重置主题”场景。 + return update(updateObj, new LambdaUpdateWrapper() + .eq(UserPreferenceDO::getUserId, userId)); + } + +} diff --git a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/user/UserPreferenceService.java b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/user/UserPreferenceService.java new file mode 100644 index 0000000..1f234b6 --- /dev/null +++ b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/user/UserPreferenceService.java @@ -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 getThemeSettings(Long userId); + + /** + * 保存当前用户主题配置覆盖项 + * + * 约束: + * 1. 保存的是用户主题覆盖项,不是完整默认主题; + * 2. 前端不允许传 userId 决定归属,归属由调用方基于登录态传入; + * 3. 传入空对象时,等价于清空个性化覆盖项。 + * + * @param userId 用户 ID + * @param themeSettings 主题配置覆盖项 + */ + void saveThemeSettings(Long userId, @Valid Map themeSettings); + + /** + * 重置当前用户主题配置覆盖项 + * + * 约束: + * 1. 重置后接口再次读取应返回空对象 {}; + * 2. 具体实现不要求物理删除记录,但外部语义必须等价于“没有个性化配置”。 + * + * @param userId 用户 ID + */ + void resetThemeSettings(Long userId); + +} diff --git a/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/user/UserPreferenceServiceImpl.java b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/user/UserPreferenceServiceImpl.java new file mode 100644 index 0000000..691ef5b --- /dev/null +++ b/rdms-system/rdms-system-boot/src/main/java/com/njcn/rdms/module/system/service/user/UserPreferenceServiceImpl.java @@ -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 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 themeSettings) { + UserPreferenceDO preference = userPreferenceMapper.selectByUserId(userId); + if (MapUtil.isEmpty(themeSettings)) { + // 空对象等价于“没有个性化覆盖项”,直接复用重置语义,避免数据库残留无意义的 {}。 + if (preference != null) { + userPreferenceMapper.updateThemeSettingsByUserId(userId, null); + } + return; + } + + // 保存副本而不是直接持有入参引用,避免后续调用链继续改动同一 Map。 + Map 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); + } + +} diff --git a/rdms-system/rdms-system-boot/src/main/resources/application-dev.yaml b/rdms-system/rdms-system-boot/src/main/resources/application-dev.yaml index 3237cc0..5661ca7 100644 --- a/rdms-system/rdms-system-boot/src/main/resources/application-dev.yaml +++ b/rdms-system/rdms-system-boot/src/main/resources/application-dev.yaml @@ -7,12 +7,12 @@ spring: username: # Nacos 账号 password: # Nacos 密码 discovery: # 【配置中心】配置项 - namespace: dev # 命名空间。这里使用 dev 开发环境 + namespace: 1e0fcd92-49b4-4cda-b531-828c7d36fef5 # 命名空间。这里使用 dev 开发环境 group: DEFAULT_GROUP # 使用的 Nacos 配置分组,默认为 DEFAULT_GROUP metadata: version: 1.0.0 # 服务实例的版本号,可用于灰度发布 config: # 【注册中心】配置项 - namespace: dev # 命名空间。这里使用 dev 开发环境 + namespace: 1e0fcd92-49b4-4cda-b531-828c7d36fef5 # 命名空间。这里使用 dev 开发环境 group: DEFAULT_GROUP # 使用的 Nacos 配置分组,默认为 DEFAULT_GROUP #################### 数据库相关配置 #################### diff --git a/rdms-system/rdms-system-boot/src/main/resources/application-local.yaml b/rdms-system/rdms-system-boot/src/main/resources/application-local.yaml index 2731175..3b08b4c 100644 --- a/rdms-system/rdms-system-boot/src/main/resources/application-local.yaml +++ b/rdms-system/rdms-system-boot/src/main/resources/application-local.yaml @@ -6,12 +6,12 @@ spring: username: # Nacos 账号 password: # Nacos 密码 discovery: # 【配置中心】配置项 - namespace: dev # 命名空间。这里使用 dev 开发环境 + namespace: 1e0fcd92-49b4-4cda-b531-828c7d36fef5 # 命名空间。这里使用 dev 开发环境 group: DEFAULT_GROUP # 使用的 Nacos 配置分组,默认为 DEFAULT_GROUP metadata: version: 1.0.0 # 服务实例的版本号,可用于灰度发布 config: # 【注册中心】配置项 - namespace: dev # 命名空间。这里使用 dev 开发环境 + namespace: 1e0fcd92-49b4-4cda-b531-828c7d36fef5 # 命名空间。这里使用 dev 开发环境 group: DEFAULT_GROUP # 使用的 Nacos 配置分组,默认为 DEFAULT_GROUP #################### 数据库相关配置 #################### @@ -63,7 +63,7 @@ spring: data: redis: host: 127.0.0.1 # 地址 - port: 6379 # 端口 + port: 16379 # 端口 database: 1 # 数据库索引 # password: njcnpqs # 密码,建议生产环境开启 diff --git a/rdms-system/rdms-system-boot/src/main/resources/application.yaml b/rdms-system/rdms-system-boot/src/main/resources/application.yaml index 7b7dcf8..fcf2de5 100644 --- a/rdms-system/rdms-system-boot/src/main/resources/application.yaml +++ b/rdms-system/rdms-system-boot/src/main/resources/application.yaml @@ -122,6 +122,10 @@ rdms: websocket: enable: true # 是否开启 WebSocket path: /system/ws # WebSocket 路径 + api-encrypt: + enable: true # 启用密码相关接口的请求解密能力 + header: X-Api-Encrypt # 请求加密标记头 + algorithm: RSA # 密码相关接口的请求体采用 RSA 非对称加密 + request-key: 'MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQC/aShtWjlpINa+ZZkgp4sbt2jA4tPCN1YjDLv5SZMHDd7q8lbkE0SOudbuSKp5P3tVCPZXowyZom5+l56AAIYCaG5OcbzeRUtB6JcvmuU9SZ008zw7z2BIzeIzMtJSGf6u8BocVeMo27bGyyh1ifUXbpKVU7V7DBLzYADAQ9Jqi0vsqrxDGDu+Zm3LpFwSOnv85pgC0d+9re57CIYynXVmTLAo+V5DedPsceNCAByRs1kUyFMwyoPNbmgjcpKbewD6laxR9GtnFR/bCzfnz8Up7ANtuHCPe7vfU1teU75ZR+/cW9t2GS1e1T/XkULRv5PH5gchSGQ1NHO4imIbv5dzAgMBAAECggEACTjSS051BKUh44N2mLWpxJiWEfD7vdg3rLGg3tZWIJlg+5XYbN2myG+YtNtIZ1YRJZwsbjV7Vm2WgD/i0Yz05+nLIrllHZpeEVtY6WC/ma/RxKrRZJpNq8RLmSbiLjV1aU1FHMdgjefkCvjfxqXyaoIXyt0BGeAPi6087AZ4fUyKVYgPyGr53RnD8+4nCDaRhZYMCv6zpb+YVF3llZZNhvK7+hDLZX0WhUgIAzStzFsPZhDfJxW8MQFB4FNtmnJ4kpInkgIAROlfVvKIwRKwoCH+sveGjYdlZR/wTYt6HQoKudG9Qx2IssUcVGFwAsCiWM+81rfBDd5pMUwzyGQ9OQKBgQDHOp7Eio4M6LaPO1Uz6Ozlp28evWBVPaU+wk50p5SQl//pF0VgDkmrrt3Wu9IppBL6VObIzjOsZJrEVHXheA/1qqOVYm/m6nel1EUAqbIqxREtw+GJPoKp3Ql1CxK6pvm/KxOhJvCDIUNCZ4in+rvsCvquF784iIbQ33ED3hWi2wKBgQD19DbAL1Y6/XHXX17t6yZJVsIijmSOo5tjeNHouOSP5emgc8i2ESaW4WPIzkgi7EJ2aertgUkwIOpunYvMWYfn6zrYNaSuvCCZF+6oIiYPPXEVZJTnzGA/KsJtHeH6xtiGuettw6RnPxXvNZibJhfLdOqQvZmRDRTXh/MiRuelSQKBgQC154IbNd7pTnmRYb0zvlK+hRfiW0rfyX9dRBBaVsBBHWedrY+8Wo9NYEZQ0ADd4F8rjeWCJzPrDZh59hwDl5oK1pixxsUhc6d3E89FAawZfQFoZddBdn/bFGSUJ14camTR9UTg+SrUr8Q3l0yhA0AeDxA/cJM5zP47LCiGPXpHzQKBgQDV00sGKiE9h7nBFBjjntvaRqLgiArEN1iQUimruZJ7x9YkuIR2RNLXuXuWyD/OnLfrWonzkcKfJP6qzC0Nq4iMB+VQstJJVyS/9B537bhI55G4l4kdPIEwaWw+kQw1iUoVVu1mr//uAtp+7ImP2L43E54Z17v6bvT/rCGkWyBogQKBgQC6pqnciYteAE5KmWnPM9LWoEorSBPCzbWCVwuja7NbVoADUPvAnUeDgvKs8KpWvL+X3eRGSZXOBqjBMsdDPBnQzr5yZCI3Mv6Svg9RxBfuWw1mF1w2GAwK1r7+6ZDwxFqRUiVUACRRJ8S1kBa+CvNWm7UFi/7V1D4UDyKKmBU6Sw==' debug: false - diff --git a/rdms-system/rdms-system-boot/src/main/resources/sql/permission-v2/01_permission_v2_stage1_schema.sql b/rdms-system/rdms-system-boot/src/main/resources/sql/permission-v2/01_permission_v2_stage1_schema.sql new file mode 100644 index 0000000..f17961e --- /dev/null +++ b/rdms-system/rdms-system-boot/src/main/resources/sql/permission-v2/01_permission_v2_stage1_schema.sql @@ -0,0 +1,127 @@ +-- permission-v2 stage 1 schema for MySQL 8.0 +-- execution target: existing rdms system schema +-- execution mode: manual +-- +-- purpose: +-- 1. add target-model columns +-- 2. create visibility relation tables +-- 3. keep legacy fields temporarily for subsequent code migration +-- +-- note: +-- 1. run this script before stage 2/3 code changes +-- 2. do not drop leader_user_id / post_ids / data_scope in this stage + +START TRANSACTION; + +ALTER TABLE system_dept + ADD COLUMN org_type VARCHAR(20) NOT NULL DEFAULT 'dept' COMMENT '组织节点类型:company/dept/direction/team' AFTER parent_id, + ADD COLUMN path VARCHAR(1024) CHARACTER SET ascii NOT NULL DEFAULT '/' COMMENT '组织物化路径,格式如 /1/2/3/' AFTER org_type, + ADD COLUMN level INT NOT NULL DEFAULT 1 COMMENT '组织层级,根节点为 1' AFTER path, + ADD COLUMN code VARCHAR(64) NULL COMMENT '组织编码' AFTER level; + +ALTER TABLE system_users + ADD COLUMN position_id BIGINT NULL COMMENT '主岗位ID' AFTER dept_id, + ADD COLUMN resigned_at DATETIME NULL COMMENT '离职时间' AFTER position_id; + +ALTER TABLE system_post + ADD COLUMN post_type VARCHAR(20) NULL COMMENT '岗位类型:management/technical/business' AFTER code, + ADD COLUMN level_rank INT NULL COMMENT '岗位等级排序,越大级别越高' AFTER post_type; + +ALTER TABLE system_dept + ADD KEY idx_system_dept_parent_id (parent_id), + ADD KEY idx_system_dept_org_type (org_type), + ADD KEY idx_system_dept_path (path(191)), + ADD UNIQUE KEY uk_system_dept_code (code); + +ALTER TABLE system_users + ADD KEY idx_system_users_dept_id (dept_id), + ADD KEY idx_system_users_position_id (position_id); + +ALTER TABLE system_post + ADD KEY idx_system_post_type (post_type), + ADD KEY idx_system_post_level_rank (level_rank); + +CREATE TABLE system_org_leader_relation ( + id BIGINT NOT NULL COMMENT '主键ID', + dept_id BIGINT NOT NULL COMMENT '组织节点ID', + user_id BIGINT NOT NULL COMMENT '负责人用户ID', + effective_from DATETIME NULL COMMENT '生效开始时间,空表示立即长期生效', + effective_until DATETIME NULL COMMENT '生效结束时间,空表示长期有效', + remark VARCHAR(500) NULL COMMENT '备注', + creator VARCHAR(64) NULL DEFAULT '' COMMENT '创建者', + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updater VARCHAR(64) NULL DEFAULT '' COMMENT '更新者', + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + deleted BIT(1) NOT NULL DEFAULT b'0' COMMENT '是否删除', + PRIMARY KEY (id), + KEY idx_org_leader_user (user_id, deleted, effective_from, effective_until), + KEY idx_org_leader_dept (dept_id, deleted, effective_from, effective_until), + UNIQUE KEY uk_org_leader_once (dept_id, user_id, effective_from) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='组织负责人关系表'; + +CREATE TABLE system_user_management_relation ( + id BIGINT NOT NULL COMMENT '主键ID', + manager_user_id BIGINT NOT NULL COMMENT '管理者用户ID', + subordinate_user_id BIGINT NOT NULL COMMENT '被管理用户ID', + effective_from DATETIME NULL COMMENT '生效开始时间,空表示立即长期生效', + effective_until DATETIME NULL COMMENT '生效结束时间,空表示长期有效', + remark VARCHAR(500) NULL COMMENT '备注', + creator VARCHAR(64) NULL DEFAULT '' COMMENT '创建者', + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updater VARCHAR(64) NULL DEFAULT '' COMMENT '更新者', + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + deleted BIT(1) NOT NULL DEFAULT b'0' COMMENT '是否删除', + PRIMARY KEY (id), + KEY idx_mgr_user (manager_user_id, deleted, effective_from, effective_until), + KEY idx_sub_user (subordinate_user_id, deleted, effective_from, effective_until), + UNIQUE KEY uk_mgr_sub_once (manager_user_id, subordinate_user_id, effective_from) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户直接管理关系表'; + +CREATE TABLE system_user_visibility_config ( + id BIGINT NOT NULL COMMENT '主键ID', + user_id BIGINT NOT NULL COMMENT '用户ID', + visibility_type VARCHAR(20) NOT NULL COMMENT '可见范围类型:all/directions/projects', + visible_direction_ids JSON NULL COMMENT '补充可见方向ID集合', + visible_project_ids JSON NULL COMMENT '补充可见项目ID集合', + remark VARCHAR(500) NULL COMMENT '备注', + creator VARCHAR(64) NULL DEFAULT '' COMMENT '创建者', + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updater VARCHAR(64) NULL DEFAULT '' COMMENT '更新者', + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + deleted BIT(1) NOT NULL DEFAULT b'0' COMMENT '是否删除', + PRIMARY KEY (id), + UNIQUE KEY uk_visibility_user (user_id), + KEY idx_visibility_type (visibility_type), + CONSTRAINT chk_visibility_type CHECK (visibility_type IN ('all', 'directions', 'projects')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户补充可见范围配置表'; + +CREATE TABLE system_project_member ( + id BIGINT NOT NULL COMMENT '主键ID', + project_id BIGINT NOT NULL COMMENT '项目ID', + user_id BIGINT NOT NULL COMMENT '用户ID', + project_role VARCHAR(20) NOT NULL COMMENT '项目角色:pm/product/developer/tester/viewer', + member_type VARCHAR(20) NOT NULL DEFAULT 'core' COMMENT '成员类型:core/support', + joined_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '加入时间', + left_at DATETIME NULL COMMENT '退出时间,空表示仍在项目中', + remark VARCHAR(500) NULL COMMENT '备注', + creator VARCHAR(64) NULL DEFAULT '' COMMENT '创建者', + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updater VARCHAR(64) NULL DEFAULT '' COMMENT '更新者', + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + deleted BIT(1) NOT NULL DEFAULT b'0' COMMENT '是否删除', + active_user_id BIGINT GENERATED ALWAYS AS ( + CASE WHEN left_at IS NULL THEN user_id ELSE NULL END + ) STORED, + active_pm_project_id BIGINT GENERATED ALWAYS AS ( + CASE WHEN project_role = 'pm' AND left_at IS NULL THEN project_id ELSE NULL END + ) STORED, + PRIMARY KEY (id), + UNIQUE KEY uk_project_active_member (project_id, active_user_id), + UNIQUE KEY uk_project_active_pm (active_pm_project_id), + KEY idx_pm_user_active (user_id, left_at), + KEY idx_pm_project_role_active (project_id, project_role, left_at), + CONSTRAINT chk_project_role CHECK (project_role IN ('pm', 'product', 'developer', 'tester', 'viewer')), + CONSTRAINT chk_member_type CHECK (member_type IN ('core', 'support')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='项目成员关系表'; + +COMMIT; diff --git a/rdms-system/rdms-system-boot/src/main/resources/sql/permission-v2/02_permission_v2_stage1_org_backfill.sql b/rdms-system/rdms-system-boot/src/main/resources/sql/permission-v2/02_permission_v2_stage1_org_backfill.sql new file mode 100644 index 0000000..bf204f4 --- /dev/null +++ b/rdms-system/rdms-system-boot/src/main/resources/sql/permission-v2/02_permission_v2_stage1_org_backfill.sql @@ -0,0 +1,44 @@ +-- permission-v2 stage 1 organization backfill for MySQL 8.0 +-- purpose: +-- 1. initialize org_type +-- 2. backfill path and level from parent_id +-- +-- assumption: +-- 1. root organization nodes satisfy parent_id = 0 +-- 2. system_dept currently has no parent cycle + +START TRANSACTION; + +UPDATE system_dept +SET org_type = 'dept' +WHERE org_type IS NULL OR org_type = ''; + +WITH RECURSIVE dept_tree AS ( + SELECT + id, + parent_id, + CAST(CONCAT('/', id, '/') AS CHAR(1024)) AS new_path, + 1 AS new_level + FROM system_dept + WHERE parent_id = 0 + + UNION ALL + + SELECT + child.id, + child.parent_id, + CAST(CONCAT(parent.new_path, child.id, '/') AS CHAR(1024)) AS new_path, + parent.new_level + 1 AS new_level + FROM system_dept child + INNER JOIN dept_tree parent ON child.parent_id = parent.id +) +UPDATE system_dept d +INNER JOIN dept_tree t ON d.id = t.id +SET + d.path = t.new_path, + d.level = t.new_level; + +COMMIT; + +-- verification: +-- SELECT id, name, parent_id, org_type, path, level FROM system_dept ORDER BY parent_id, sort, id; diff --git a/rdms-system/rdms-system-boot/src/main/resources/sql/permission-v2/03_permission_v2_stage1_seed.sql b/rdms-system/rdms-system-boot/src/main/resources/sql/permission-v2/03_permission_v2_stage1_seed.sql new file mode 100644 index 0000000..39969ad --- /dev/null +++ b/rdms-system/rdms-system-boot/src/main/resources/sql/permission-v2/03_permission_v2_stage1_seed.sql @@ -0,0 +1,69 @@ +-- permission-v2 stage 1 seed for MySQL 8.0 +-- purpose: +-- 1. ensure there is at least one root organization node for user association +-- 2. ensure there is at least one available post for user association +-- 3. bind admin user to an organization and a primary position +-- +-- note: +-- 1. the reserved ids below are only for cold-start initialization +-- 2. adjust them before execution if your database already uses the same ids + +START TRANSACTION; + +INSERT INTO system_dept ( + id, name, parent_id, sort, org_type, path, level, code, phone, email, status, + creator, create_time, updater, update_time, deleted +) +SELECT + 900000000000000001, '平台根组织', 0, 0, 'company', '/900000000000000001/', 1, 'ROOT', + NULL, NULL, 0, + 'system', NOW(), 'system', NOW(), b'0' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM system_dept WHERE parent_id = 0 AND deleted = b'0' +); + +INSERT INTO system_post ( + id, name, code, post_type, level_rank, sort, status, remark, + creator, create_time, updater, update_time, deleted +) +SELECT + 900000000000000101, '系统管理员岗', 'SYS_ADMIN_POST', 'management', 100, 0, 0, 'permission-v2 cold-start seed', + 'system', NOW(), 'system', NOW(), b'0' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM system_post WHERE deleted = b'0' +); + +UPDATE system_users +SET + dept_id = COALESCE( + dept_id, + (SELECT root_dept.id + FROM ( + SELECT id + FROM system_dept + WHERE parent_id = 0 AND deleted = b'0' + ORDER BY sort ASC, id ASC + LIMIT 1 + ) root_dept) + ), + position_id = COALESCE( + position_id, + (SELECT chosen_post.id + FROM ( + SELECT id + FROM system_post + WHERE deleted = b'0' + ORDER BY sort ASC, id ASC + LIMIT 1 + ) chosen_post) + ), + update_time = NOW(), + updater = 'system' +WHERE username = 'admin'; + +COMMIT; + +-- verification: +-- SELECT id, username, dept_id, position_id FROM system_users WHERE username = 'admin'; diff --git a/rdms-system/rdms-system-boot/src/main/resources/sql/permission-v2/04_permission_v2_stage1_finalize.sql b/rdms-system/rdms-system-boot/src/main/resources/sql/permission-v2/04_permission_v2_stage1_finalize.sql new file mode 100644 index 0000000..4c5a4ba --- /dev/null +++ b/rdms-system/rdms-system-boot/src/main/resources/sql/permission-v2/04_permission_v2_stage1_finalize.sql @@ -0,0 +1,29 @@ +-- permission-v2 stage 1 finalize for MySQL 8.0 +-- purpose: +-- 1. verify required user-position data is complete +-- 2. convert system_users.position_id to NOT NULL +-- +-- execute this script only after: +-- 1. 01/02/03 have completed successfully +-- 2. every existing user has a valid position_id + +SELECT COUNT(*) AS missing_position_count +FROM system_users +WHERE position_id IS NULL; + +SET @missing_position_count := ( + SELECT COUNT(*) FROM system_users WHERE position_id IS NULL +); + +SET @finalize_sql := IF( + @missing_position_count = 0, + 'ALTER TABLE system_users MODIFY COLUMN position_id BIGINT NOT NULL COMMENT ''主岗位ID''', + 'SELECT ''skip finalize: system_users.position_id still has NULL rows'' AS message' +); + +PREPARE stmt FROM @finalize_sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- verification: +-- SHOW CREATE TABLE system_users; diff --git a/rdms-system/rdms-system-boot/src/main/resources/sql/permission-v2/system_dept.sql b/rdms-system/rdms-system-boot/src/main/resources/sql/permission-v2/system_dept.sql index 4684409..bcdf45a 100644 --- a/rdms-system/rdms-system-boot/src/main/resources/sql/permission-v2/system_dept.sql +++ b/rdms-system/rdms-system-boot/src/main/resources/sql/permission-v2/system_dept.sql @@ -702,4 +702,9 @@ SET `component` = 'view.iframe-page', `route_props_json` = '{\"url\":\"https://cn.vuejs.org/\"}' WHERE `id` = 900016; +INSERT INTO `system_menu` VALUES (1036, '组织负责人查询', 'system:org-leader:query', 3, 5, 103, '', '#', '', NULL, 0, b'1', b'1', b'1', 'admin', '2026-03-25 00:00:00', 'admin', '2026-03-25 00:00:00', b'0'); +INSERT INTO `system_menu` VALUES (1037, '组织负责人新增', 'system:org-leader:create', 3, 6, 103, '', '#', '', NULL, 0, b'1', b'1', b'1', 'admin', '2026-03-25 00:00:00', 'admin', '2026-03-25 00:00:00', b'0'); +INSERT INTO `system_menu` VALUES (1038, '组织负责人修改', 'system:org-leader:update', 3, 7, 103, '', '#', '', NULL, 0, b'1', b'1', b'1', 'admin', '2026-03-25 00:00:00', 'admin', '2026-03-25 00:00:00', b'0'); +INSERT INTO `system_menu` VALUES (1039, '组织负责人删除', 'system:org-leader:delete', 3, 8, 103, '', '#', '', NULL, 0, b'1', b'1', b'1', 'admin', '2026-03-25 00:00:00', 'admin', '2026-03-25 00:00:00', b'0'); + SET FOREIGN_KEY_CHECKS = 1; diff --git a/scripts/commit.ps1 b/scripts/commit.ps1 new file mode 100644 index 0000000..9db3431 --- /dev/null +++ b/scripts/commit.ps1 @@ -0,0 +1,99 @@ +$ErrorActionPreference = 'Stop' + +function Select-Option { + param( + [string]$Title, + [string[]]$Options + ) + + while ($true) { + Write-Host "" + Write-Host $Title + for ($i = 0; $i -lt $Options.Length; $i++) { + Write-Host ("{0}. {1}" -f ($i + 1), $Options[$i]) + } + + $inputValue = Read-Host "请输入序号" + $index = 0 + if ([int]::TryParse($inputValue, [ref]$index) -and $index -ge 1 -and $index -le $Options.Length) { + return $Options[$index - 1] + } + + Write-Host "输入无效,请重新选择。" + } +} + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..') + +if (-not (git -C $repoRoot rev-parse --is-inside-work-tree 2>$null)) { + throw "当前目录不是 Git 仓库。" +} + +git -C $repoRoot diff --cached --quiet +if ($LASTEXITCODE -eq 0) { + throw "当前没有已暂存的改动,请先执行 git add。" +} + +$stagedFiles = @(git -C $repoRoot diff --cached --name-only) + +$types = @( + 'feat', + 'feat-wip', + 'fix', + 'docs', + 'typo', + 'style', + 'refactor', + 'perf', + 'optimize', + 'test', + 'build', + 'ci', + 'chore', + 'revert' +) + +$scopes = @( + 'system', + 'gateway', + 'framework', + 'common', + 'security', + 'web', + 'mybatis', + 'redis', + 'mq', + 'rpc', + 'sql', + 'deps', + 'other' +) + +$type = Select-Option -Title '请选择提交类型 type' -Options $types +$scope = Select-Option -Title '请选择提交范围 scope' -Options $scopes + +while ($true) { + $description = (Read-Host '请输入提交描述').Trim() + if ($description) { + break + } + Write-Host "描述不能为空,请重新输入。" +} + +$message = "{0}({1}): {2}" -f $type, $scope, $description + +Write-Host "" +Write-Host "提交信息预览:$message" +Write-Host "" +Write-Host "本次将提交以下已暂存文件:" +foreach ($file in $stagedFiles) { + Write-Host " - $file" +} + +$confirm = Read-Host '确认提交请输入 y,取消请输入其他任意内容' +if ($confirm -ne 'y' -and $confirm -ne 'Y') { + Write-Host '已取消提交。' + exit 1 +} + +git -C $repoRoot commit -m $message diff --git a/scripts/commit.sh b/scripts/commit.sh new file mode 100644 index 0000000..d324151 --- /dev/null +++ b/scripts/commit.sh @@ -0,0 +1,130 @@ +#!/bin/sh + +set -eu + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname "$0")" && pwd)" +REPO_ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)" + +if ! git -C "$REPO_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + printf '%s\n' "当前目录不是 Git 仓库。" >&2 + exit 1 +fi + +if git -C "$REPO_ROOT" diff --cached --quiet; then + printf '%s\n' "当前没有已暂存的改动,请先执行 git add。" >&2 + exit 1 +fi + +choose_type() { + printf '%s\n' "" + printf '%s\n' "请选择提交类型 type" + printf '%s\n' "1. feat" + printf '%s\n' "2. feat-wip" + printf '%s\n' "3. fix" + printf '%s\n' "4. docs" + printf '%s\n' "5. typo" + printf '%s\n' "6. style" + printf '%s\n' "7. refactor" + printf '%s\n' "8. perf" + printf '%s\n' "9. optimize" + printf '%s\n' "10. test" + printf '%s\n' "11. build" + printf '%s\n' "12. ci" + printf '%s\n' "13. chore" + printf '%s\n' "14. revert" + printf '%s' "请输入序号: " + read -r answer + + case "$answer" in + 1) printf '%s' "feat" ;; + 2) printf '%s' "feat-wip" ;; + 3) printf '%s' "fix" ;; + 4) printf '%s' "docs" ;; + 5) printf '%s' "typo" ;; + 6) printf '%s' "style" ;; + 7) printf '%s' "refactor" ;; + 8) printf '%s' "perf" ;; + 9) printf '%s' "optimize" ;; + 10) printf '%s' "test" ;; + 11) printf '%s' "build" ;; + 12) printf '%s' "ci" ;; + 13) printf '%s' "chore" ;; + 14) printf '%s' "revert" ;; + *) return 1 ;; + esac +} + +choose_scope() { + printf '%s\n' "" + printf '%s\n' "请选择提交范围 scope" + printf '%s\n' "1. system" + printf '%s\n' "2. gateway" + printf '%s\n' "3. framework" + printf '%s\n' "4. common" + printf '%s\n' "5. security" + printf '%s\n' "6. web" + printf '%s\n' "7. mybatis" + printf '%s\n' "8. redis" + printf '%s\n' "9. mq" + printf '%s\n' "10. rpc" + printf '%s\n' "11. sql" + printf '%s\n' "12. deps" + printf '%s\n' "13. other" + printf '%s' "请输入序号: " + read -r answer + + case "$answer" in + 1) printf '%s' "system" ;; + 2) printf '%s' "gateway" ;; + 3) printf '%s' "framework" ;; + 4) printf '%s' "common" ;; + 5) printf '%s' "security" ;; + 6) printf '%s' "web" ;; + 7) printf '%s' "mybatis" ;; + 8) printf '%s' "redis" ;; + 9) printf '%s' "mq" ;; + 10) printf '%s' "rpc" ;; + 11) printf '%s' "sql" ;; + 12) printf '%s' "deps" ;; + 13) printf '%s' "other" ;; + *) return 1 ;; + esac +} + +while :; do + if type_value="$(choose_type)"; then + break + fi + printf '%s\n' "输入无效,请重新选择。" +done + +while :; do + if scope_value="$(choose_scope)"; then + break + fi + printf '%s\n' "输入无效,请重新选择。" +done + +while :; do + printf '%s' "请输入提交描述: " + read -r description + description="$(printf '%s' "$description" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" + if [ -n "$description" ]; then + break + fi + printf '%s\n' "描述不能为空,请重新输入。" +done + +message="$type_value($scope_value): $description" + +printf '%s\n' "" +printf '%s\n' "提交信息预览:$message" +printf '%s' "确认提交请输入 y,取消请输入其他任意内容: " +read -r confirm + +if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then + printf '%s\n' "已取消提交。" + exit 1 +fi + +git -C "$REPO_ROOT" commit -m "$message" diff --git a/scripts/install-git-hooks.ps1 b/scripts/install-git-hooks.ps1 new file mode 100644 index 0000000..353611d --- /dev/null +++ b/scripts/install-git-hooks.ps1 @@ -0,0 +1,9 @@ +$ErrorActionPreference = 'Stop' + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..') +$hooksPath = Join-Path $repoRoot 'githooks' + +git -C $repoRoot config core.hooksPath $hooksPath + +Write-Host "Git hooks installed." +Write-Host "core.hooksPath=$hooksPath" diff --git a/scripts/install-git-hooks.sh b/scripts/install-git-hooks.sh new file mode 100644 index 0000000..313e60c --- /dev/null +++ b/scripts/install-git-hooks.sh @@ -0,0 +1,11 @@ +#!/bin/sh + +set -eu + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname "$0")" && pwd)" +REPO_ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)" + +git -C "$REPO_ROOT" config core.hooksPath "$REPO_ROOT/githooks" + +printf '%s\n' "Git hooks installed." +printf '%s\n' "core.hooksPath=$REPO_ROOT/githooks"