Merge remote-tracking branch 'origin/main'
This commit is contained in:
40
githooks/commit-msg
Normal file
40
githooks/commit-msg
Normal file
@@ -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
|
||||
@@ -63,8 +63,9 @@ public class ApiEncryptProperties {
|
||||
* 注意:
|
||||
* 1. 如果是【对称加密】时,它「后端」对应的是“密钥”。对应的,「前端」也对应的也是“密钥”。
|
||||
* 2. 如果是【非对称加密】时,它「后端」对应的是“公钥”。对应的,「前端」对应的是“私钥”。(重要!!!)
|
||||
*
|
||||
* 当前 system 模块密码加密方案不启用响应加密,因此该配置可为空。
|
||||
*/
|
||||
@NotEmpty(message = "响应的加密密钥不能为空")
|
||||
private String responseKey;
|
||||
|
||||
}
|
||||
|
||||
@@ -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> 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);
|
||||
|
||||
}
|
||||
|
||||
@@ -15,6 +15,13 @@ public @interface ApiEncrypt {
|
||||
*/
|
||||
boolean request() default true;
|
||||
|
||||
/**
|
||||
* 需要解密的请求字段
|
||||
*
|
||||
* 仅在 request = true 时生效
|
||||
*/
|
||||
String[] requestFields() default {};
|
||||
|
||||
/**
|
||||
* 是否对响应结果进行加密,默认 true
|
||||
*/
|
||||
|
||||
@@ -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<String> 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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
#################### 监控相关配置 ####################
|
||||
|
||||
@@ -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
|
||||
|
||||
#################### 监控相关配置 ####################
|
||||
|
||||
@@ -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<AuthLoginRespVO> 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<AuthLoginRespVO> register(@RequestBody @Valid AuthRegisterReqVO registerReqVO) {
|
||||
return success(authService.register(registerReqVO));
|
||||
}
|
||||
|
||||
@@ -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<Long> 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<Boolean> updateUserPassword(@Valid @RequestBody UserUpdatePasswordReqVO reqVO) {
|
||||
userService.updateUserPassword(reqVO.getId(), reqVO.getPassword());
|
||||
return success(true);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<Boolean> updateUserProfilePassword(@Valid @RequestBody UserProfileUpdatePasswordReqVO reqVO) {
|
||||
userService.updateUserPassword(getLoginUserId(), reqVO);
|
||||
return success(true);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
|
||||
#################### 数据库相关配置 ####################
|
||||
|
||||
@@ -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 # 密码,建议生产环境开启
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
99
scripts/commit.ps1
Normal file
99
scripts/commit.ps1
Normal file
@@ -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
|
||||
130
scripts/commit.sh
Normal file
130
scripts/commit.sh
Normal file
@@ -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"
|
||||
9
scripts/install-git-hooks.ps1
Normal file
9
scripts/install-git-hooks.ps1
Normal file
@@ -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"
|
||||
11
scripts/install-git-hooks.sh
Normal file
11
scripts/install-git-hooks.sh
Normal file
@@ -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"
|
||||
Reference in New Issue
Block a user