diff --git a/blade-auth/src/main/java/org/springblade/auth/config/BladeAuthConfiguration.java b/blade-auth/src/main/java/org/springblade/auth/config/BladeAuthConfiguration.java index f587e7e..e75bdb5 100644 --- a/blade-auth/src/main/java/org/springblade/auth/config/BladeAuthConfiguration.java +++ b/blade-auth/src/main/java/org/springblade/auth/config/BladeAuthConfiguration.java @@ -25,6 +25,7 @@ */ package org.springblade.auth.config; +import org.springblade.auth.filter.LogoutLogFilter; import org.springblade.auth.granter.IamAwareTokenGranterFactory; import org.springblade.auth.handler.BladeAuthorizationHandler; import org.springblade.auth.handler.BladeLockHandler; @@ -53,9 +54,11 @@ import org.springblade.core.tenant.BladeTenantProperties; import org.springblade.system.feign.IUserClient; import org.springframework.boot.autoconfigure.AutoConfigureBefore; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Primary; +import org.springframework.core.Ordered; import org.springframework.jdbc.core.JdbcTemplate; import java.util.List; @@ -88,6 +91,16 @@ public class BladeAuthConfiguration { return new BladeLogHandler(authLogClient, bladeProperties, serverInfo); } + @Bean + public FilterRegistrationBean logoutLogFilter(BladeLogHandler logHandler) { + FilterRegistrationBean registration = new FilterRegistrationBean<>(); + registration.setFilter(new LogoutLogFilter(logHandler)); + registration.addUrlPatterns("/*"); + registration.setName("logoutLogFilter"); + registration.setOrder(Ordered.LOWEST_PRECEDENCE - 100); + return registration; + } + @Bean public PasswordHandler passwordHandler(OAuth2Properties properties) { return new BladePasswordHandler(properties); diff --git a/blade-auth/src/main/java/org/springblade/auth/filter/LogoutLogFilter.java b/blade-auth/src/main/java/org/springblade/auth/filter/LogoutLogFilter.java new file mode 100644 index 0000000..2a62565 --- /dev/null +++ b/blade-auth/src/main/java/org/springblade/auth/filter/LogoutLogFilter.java @@ -0,0 +1,80 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.auth.filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springblade.auth.handler.BladeLogHandler; +import org.springblade.core.secure.BladeUser; +import org.springblade.core.secure.utils.AuthUtil; +import org.springblade.core.tool.utils.StringUtil; +import org.springframework.core.Ordered; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +/** + * 退出登录日志过滤器 + * 在清除 Token 前解析当前用户并异步写入认证日志 + * + * @author BladeX + */ +@RequiredArgsConstructor +public class LogoutLogFilter extends OncePerRequestFilter implements Ordered { + + private static final String LOGOUT_PATH = "/oauth/logout"; + + private final BladeLogHandler logHandler; + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String path = request.getRequestURI(); + return StringUtil.isBlank(path) || !path.contains(LOGOUT_PATH); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + // 必须在 logout 清 token 之前取用户,否则无法关联账号 + BladeUser user = AuthUtil.getUser(); + try { + filterChain.doFilter(request, response); + } finally { + if (user != null) { + logHandler.handleLogoutLog(user, request); + } + } + } + + @Override + public int getOrder() { + return Ordered.LOWEST_PRECEDENCE - 100; + } + +} diff --git a/blade-auth/src/main/java/org/springblade/auth/handler/BladeLogHandler.java b/blade-auth/src/main/java/org/springblade/auth/handler/BladeLogHandler.java index a57b4fc..1d6bfbb 100644 --- a/blade-auth/src/main/java/org/springblade/auth/handler/BladeLogHandler.java +++ b/blade-auth/src/main/java/org/springblade/auth/handler/BladeLogHandler.java @@ -29,12 +29,15 @@ import org.springblade.core.launch.props.BladeProperties; import org.springblade.core.launch.server.ServerInfo; import org.springblade.core.oauth2.provider.OAuth2Request; import org.springblade.core.oauth2.service.OAuth2User; +import org.springblade.core.secure.BladeUser; import org.springblade.core.tool.utils.DateUtil; import org.springblade.core.tool.utils.Func; +import org.springblade.core.tool.utils.StringUtil; import org.springblade.core.tool.utils.WebUtil; -import org.springblade.system.pojo.entity.AuthLog; import org.springblade.system.feign.IAuthLogClient; +import org.springblade.system.pojo.entity.AuthLog; +import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -42,7 +45,7 @@ import java.util.concurrent.CompletableFuture; /** * 认证日志处理器 - * 在用户认证成功时异步记录登录日志 + * 在用户认证成功/退出时异步记录登录、退出日志 * * @author BladeX */ @@ -50,6 +53,11 @@ import java.util.concurrent.CompletableFuture; @RequiredArgsConstructor public class BladeLogHandler { + /** + * 退出登录授权类型标识 + */ + public static final String GRANT_TYPE_LOGOUT = "logout"; + private final IAuthLogClient authLogClient; private final BladeProperties bladeProperties; private final ServerInfo serverInfo; @@ -72,6 +80,26 @@ public class BladeLogHandler { }); } + /** + * 记录退出登录日志 + * + * @param user 当前登录用户 + * @param request HTTP 请求 + */ + public void handleLogoutLog(BladeUser user, HttpServletRequest request) { + if (user == null) { + return; + } + CompletableFuture.runAsync(() -> { + try { + AuthLog authLog = buildLogoutLog(user, request); + authLogClient.saveAuthLog(authLog); + } catch (Exception exception) { + log.error("记录退出日志异常:{}", exception.getMessage(), exception); + } + }); + } + /** * 构建认证日志实体 * @@ -96,4 +124,38 @@ public class BladeLogHandler { return authLog; } + /** + * 构建退出日志实体 + * + * @param user 当前登录用户 + * @param request HTTP 请求 + * @return AuthLog + */ + private AuthLog buildLogoutLog(BladeUser user, HttpServletRequest request) { + AuthLog authLog = new AuthLog(); + authLog.setUserId(user.getUserId()); + authLog.setTenantId(user.getTenantId()); + authLog.setServiceId(bladeProperties.getName()); + authLog.setServerIp(serverInfo.getIpWithPort()); + authLog.setServerHost(serverInfo.getHostName()); + authLog.setEnv(bladeProperties.getEnv()); + authLog.setAccount(user.getAccount()); + authLog.setRealName(resolveRealName(user)); + authLog.setGrantType(GRANT_TYPE_LOGOUT); + authLog.setRemoteIp(WebUtil.getIP(request)); + authLog.setUserAgent(WebUtil.getUserAgent(request)); + authLog.setLoginTime(DateUtil.now()); + return authLog; + } + + private String resolveRealName(BladeUser user) { + if (StringUtil.isNotBlank(user.getNickName())) { + return user.getNickName(); + } + if (StringUtil.isNotBlank(user.getUserName())) { + return user.getUserName(); + } + return user.getAccount(); + } + } diff --git a/blade-common/pom.xml b/blade-common/pom.xml index 25f7365..a81cd93 100644 --- a/blade-common/pom.xml +++ b/blade-common/pom.xml @@ -31,6 +31,16 @@ 的 WebFlux 网关里启动即崩。业务服务经 blade-core-boot 自带 starter-log,运行时不受影响。 --> provided + + io.swagger.core.v3 + swagger-annotations + provided + + + org.springframework.boot + spring-boot-starter-aop + provided + org.springblade blade-core-auto diff --git a/blade-common/src/main/java/org/springblade/common/aspect/OperationApiLogAspect.java b/blade-common/src/main/java/org/springblade/common/aspect/OperationApiLogAspect.java new file mode 100644 index 0000000..62b7c76 --- /dev/null +++ b/blade-common/src/main/java/org/springblade/common/aspect/OperationApiLogAspect.java @@ -0,0 +1,145 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.common.aspect; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.springblade.core.log.annotation.ApiLog; +import org.springblade.core.log.constant.EventConstant; +import org.springblade.core.log.event.ApiLogEvent; +import org.springblade.core.log.model.LogApi; +import org.springblade.core.log.utils.LogAbstractUtil; +import org.springblade.core.tool.constant.BladeConstant; +import org.springblade.core.tool.utils.SpringUtil; +import org.springblade.core.tool.utils.StringUtil; +import org.springblade.core.tool.utils.WebUtil; +import org.springframework.core.annotation.AnnotationUtils; + +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.Map; + +/** + * 增删改操作日志切面 + * 自动拦截 Controller 中的 save/submit/update/remove/delete 等方法,写入 blade_log_api + * 已标注 {@link ApiLog} 的方法跳过,避免重复记录 + * + * @author BladeX + */ +@Slf4j +@Aspect +public class OperationApiLogAspect { + + @Around(""" + execution(* org.springblade..controller..*.save*(..)) + || execution(* org.springblade..controller..*.submit*(..)) + || execution(* org.springblade..controller..*.update*(..)) + || execution(* org.springblade..controller..*.remove*(..)) + || execution(* org.springblade..controller..*.delete*(..)) + """) + public Object around(ProceedingJoinPoint point) throws Throwable { + MethodSignature signature = (MethodSignature) point.getSignature(); + Method method = signature.getMethod(); + if (!shouldRecord(method)) { + return point.proceed(); + } + + String className = point.getTarget().getClass().getName(); + String methodName = method.getName(); + String title = resolveTitle(point.getTarget().getClass(), method); + long beginTime = System.currentTimeMillis(); + Object result = point.proceed(); + long time = System.currentTimeMillis() - beginTime; + try { + publishEvent(methodName, className, title, time); + } catch (Exception e) { + log.warn("记录操作日志失败: {}#{} - {}", className, methodName, e.getMessage()); + } + return result; + } + + private boolean shouldRecord(Method method) { + if (method.getAnnotation(ApiLog.class) != null) { + return false; + } + HttpServletRequest request = WebUtil.getRequest(); + if (request == null) { + return true; + } + String httpMethod = request.getMethod(); + return !"GET".equalsIgnoreCase(httpMethod) + && !"HEAD".equalsIgnoreCase(httpMethod) + && !"OPTIONS".equalsIgnoreCase(httpMethod); + } + + private String resolveTitle(Class targetClass, Method method) { + Tag tag = AnnotationUtils.findAnnotation(targetClass, Tag.class); + Operation operation = AnnotationUtils.findAnnotation(method, Operation.class); + String module = tag == null ? null : firstNonBlank(tag.name(), tag.description()); + String action = operation == null ? null : firstNonBlank(operation.summary(), operation.description()); + if (StringUtil.isNotBlank(module) && StringUtil.isNotBlank(action)) { + return module + "-" + action; + } + if (StringUtil.isNotBlank(action)) { + return action; + } + String simpleName = targetClass.getSimpleName().replace("Controller", ""); + return simpleName + "-" + method.getName(); + } + + private String firstNonBlank(String... values) { + if (values == null) { + return null; + } + for (String value : values) { + if (StringUtil.isNotBlank(value)) { + return value.trim(); + } + } + return null; + } + + private void publishEvent(String methodName, String methodClass, String title, long time) { + HttpServletRequest request = WebUtil.getRequest(); + LogApi logApi = new LogApi(); + logApi.setType(BladeConstant.LOG_NORMAL_TYPE); + logApi.setTitle(title); + logApi.setTime(String.valueOf(time)); + logApi.setMethodClass(methodClass); + logApi.setMethodName(methodName); + LogAbstractUtil.addRequestInfoToLog(request, logApi); + Map event = new HashMap<>(16); + event.put(EventConstant.EVENT_LOG, logApi); + SpringUtil.publishEvent(new ApiLogEvent(event)); + } + +} diff --git a/blade-common/src/main/java/org/springblade/common/config/BladeCommonConfiguration.java b/blade-common/src/main/java/org/springblade/common/config/BladeCommonConfiguration.java index d3a2164..f5e9e34 100644 --- a/blade-common/src/main/java/org/springblade/common/config/BladeCommonConfiguration.java +++ b/blade-common/src/main/java/org/springblade/common/config/BladeCommonConfiguration.java @@ -26,16 +26,27 @@ package org.springblade.common.config; -import lombok.AllArgsConstructor; -import org.springframework.context.annotation.Configuration; +import org.springblade.common.aspect.OperationApiLogAspect; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.context.annotation.Bean; /** * 公共封装包配置类 * * @author Chill */ -@Configuration(proxyBeanMethods = false) -@AllArgsConstructor +@AutoConfiguration public class BladeCommonConfiguration { + @Bean + @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) + @ConditionalOnClass(name = "org.springblade.core.log.event.ApiLogEvent") + @ConditionalOnProperty(value = "blade.log.operation.enabled", havingValue = "true", matchIfMissing = true) + public OperationApiLogAspect operationApiLogAspect() { + return new OperationApiLogAspect(); + } + } diff --git a/blade-common/src/main/java/org/springblade/common/utils/PasswordRuleUtil.java b/blade-common/src/main/java/org/springblade/common/utils/PasswordRuleUtil.java new file mode 100644 index 0000000..b314e33 --- /dev/null +++ b/blade-common/src/main/java/org/springblade/common/utils/PasswordRuleUtil.java @@ -0,0 +1,106 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.common.utils; + +import org.springblade.core.tool.utils.StringUtil; + +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.regex.Pattern; + +/** + * 登录密码强度规则工具 + * 规则:大于8位,同时包含字母、数字、特殊字符(.!@#$%^&*) + * + * @author BladeX + */ +public final class PasswordRuleUtil { + + /** + * 允许的特殊字符集合 + */ + public static final String SPECIAL_CHARS = ".!@#$%^&*"; + + /** + * 最小长度(大于 8 位 => 至少 9 位) + */ + public static final int MIN_LENGTH = 9; + + /** + * 自动生成密码长度 + */ + public static final int GENERATE_LENGTH = 10; + + /** + * 规则提示文案 + */ + public static final String RULE_MESSAGE = "密码须大于8位,且同时包含字母、数字和特殊字符(.!@#$%^&*)"; + + private static final String LETTERS = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + private static final String DIGITS = "23456789"; + private static final String ALL_CHARS = LETTERS + DIGITS + SPECIAL_CHARS; + private static final Pattern PASSWORD_PATTERN = Pattern.compile( + "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[.!@#$%^&*]).{" + MIN_LENGTH + ",}$" + ); + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + + private PasswordRuleUtil() { + } + + /** + * 校验密码是否符合强度规则 + * + * @param password 明文密码 + * @return true-符合 + */ + public static boolean isValid(String password) { + return StringUtil.isNotBlank(password) && PASSWORD_PATTERN.matcher(password).matches(); + } + + /** + * 生成符合规则的随机密码 + * + * @return 随机密码 + */ + public static String generate() { + List chars = new ArrayList<>(GENERATE_LENGTH); + chars.add(LETTERS.charAt(SECURE_RANDOM.nextInt(LETTERS.length()))); + chars.add(DIGITS.charAt(SECURE_RANDOM.nextInt(DIGITS.length()))); + chars.add(SPECIAL_CHARS.charAt(SECURE_RANDOM.nextInt(SPECIAL_CHARS.length()))); + for (int i = chars.size(); i < GENERATE_LENGTH; i++) { + chars.add(ALL_CHARS.charAt(SECURE_RANDOM.nextInt(ALL_CHARS.length()))); + } + Collections.shuffle(chars, SECURE_RANDOM); + StringBuilder password = new StringBuilder(GENERATE_LENGTH); + for (Character ch : chars) { + password.append(ch); + } + return password.toString(); + } + +} diff --git a/blade-common/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/blade-common/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..3396373 --- /dev/null +++ b/blade-common/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +org.springblade.common.config.BladeCommonConfiguration diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/CustomerReceiptAccount.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/CustomerReceiptAccount.java index e491a21..c0c67d5 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/CustomerReceiptAccount.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/CustomerReceiptAccount.java @@ -59,6 +59,9 @@ public class CustomerReceiptAccount extends TenantEntity { @Schema(description = "开户行名称") private String bankName; + @Schema(description = "联行号") + private String cnapsCode; + @Schema(description = "银行账号") private String bankAccount; diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/UserServiceImpl.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/UserServiceImpl.java index 8b3e924..ace4730 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/UserServiceImpl.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/UserServiceImpl.java @@ -39,6 +39,7 @@ import org.springblade.common.constant.DataStatusEnum; import org.bouncycastle.util.encoders.Hex; import org.springblade.common.constant.ParamConstant; import org.springblade.common.constant.TenantConstant; +import org.springblade.common.utils.PasswordRuleUtil; import org.springblade.core.cache.utils.CacheUtil; import org.springblade.core.log.exception.ServiceException; import org.springblade.core.mp.base.BaseServiceImpl; @@ -76,7 +77,6 @@ import org.springblade.system.wrapper.UserWrapper; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.security.SecureRandom; import java.io.IOException; import java.net.URI; import java.net.http.HttpClient; @@ -106,9 +106,6 @@ import static org.springblade.core.tenant.TenantGuard.EntityType.USER; @Slf4j public class UserServiceImpl extends BaseServiceImpl implements IUserService { private static final String GUEST_NAME = "guest"; - private static final String PASSWORD_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789"; - private static final int RANDOM_PASSWORD_LENGTH = 8; - private static final SecureRandom SECURE_RANDOM = new SecureRandom(); private static final String IAM_DEFAULT_DICT_CODE = "iam_default"; private static final String IAM_DEFAULT_ROLE_NAME = "默认角色"; private static final String IAM_DEFAULT_DEPT_NAME = "默认部门"; @@ -588,7 +585,7 @@ public class UserServiceImpl extends BaseServiceImpl implement List userList = TenantGuard.verifyBatch(this, idList, USER); Map passwordMap = new LinkedHashMap<>(); for (User user : userList) { - String password = randomPassword(); + String password = PasswordRuleUtil.generate(); User updateUser = new User(); updateUser.setPassword(DigestUtil.encrypt(password)); updateUser.setUpdateTime(DateUtil.now()); @@ -604,6 +601,7 @@ public class UserServiceImpl extends BaseServiceImpl implement if (!Objects.equals(password, password2)) { throw new ServiceException("两次输入密码不一致!"); } + validatePlainPassword(password); User user = TenantGuard.verify(this, userId, USER); User updateUser = new User(); updateUser.setPassword(DigestUtil.encrypt(password)); @@ -672,7 +670,8 @@ public class UserServiceImpl extends BaseServiceImpl implement if (user.getId() != null) { this.updateUser(user); } else { - user.setPassword(ParamCache.getValue(DEFAULT_PARAM_PASSWORD)); + // 空密码交由 saveUser 按规则填充(初始密码不合规时自动生成) + user.setPassword(null); this.submit(user); } }); @@ -1064,8 +1063,10 @@ public class UserServiceImpl extends BaseServiceImpl implement } } if (Func.isEmpty(user.getPassword())) { - user.setPassword(ParamCache.getValue(DEFAULT_PARAM_PASSWORD)); + String initPassword = ParamCache.getValue(DEFAULT_PARAM_PASSWORD); + user.setPassword(PasswordRuleUtil.isValid(initPassword) ? initPassword : PasswordRuleUtil.generate()); } + validatePlainPassword(user.getPassword()); user.setPassword(DigestUtil.encrypt(user.getPassword())); Long userCount = baseMapper.selectCount(Wrappers.query().lambda().eq(User::getTenantId, tenantId).eq(User::getAccount, user.getAccount())); if (userCount > 0L && Func.isEmpty(user.getId())) { @@ -1147,12 +1148,10 @@ public class UserServiceImpl extends BaseServiceImpl implement // } } - private String randomPassword() { - StringBuilder password = new StringBuilder(RANDOM_PASSWORD_LENGTH); - for (int index = 0; index < RANDOM_PASSWORD_LENGTH; index++) { - password.append(PASSWORD_CHARS.charAt(SECURE_RANDOM.nextInt(PASSWORD_CHARS.length()))); + private void validatePlainPassword(String password) { + if (!PasswordRuleUtil.isValid(password)) { + throw new ServiceException(PasswordRuleUtil.RULE_MESSAGE); } - return password.toString(); } } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IReceivablePayableDetailService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IReceivablePayableDetailService.java index 6cc8546..ace2009 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IReceivablePayableDetailService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IReceivablePayableDetailService.java @@ -37,6 +37,7 @@ import org.springblade.transport.pojo.vo.ReceivablePayableCargoFeeVO; import org.springblade.transport.pojo.vo.ReceivablePayableDetailVO; import org.springblade.transport.pojo.vo.ReceivablePayableFeeDetailVO; +import java.math.BigDecimal; import java.util.List; import java.util.Map; import java.util.Collection; @@ -82,6 +83,12 @@ public interface IReceivablePayableDetailService extends BaseService waybillIds); + /** + * 运单维护里程后,同步未挂结算单的应收应付明细里程; + * 对按里程、按吨·公里计费的费用行按新里程重新计算。 + */ + void syncMileageFromWaybill(Long waybillId, BigDecimal mileage); + /** * 批量导入完成运单后按合同系统计费模式生成应收、应付明细。 *

与导入事务共用同一事务,运单尚未提交,因此直接传入实体而非主键。

diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ContractManageServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ContractManageServiceImpl.java index abe6d25..aa08bff 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ContractManageServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ContractManageServiceImpl.java @@ -657,6 +657,10 @@ public class ContractManageServiceImpl extends BaseServiceImpl existingMap = detailFees(detail.getId()).stream() .collect(Collectors.toMap(PreSettlementDetailFee::getId, Function.identity())); if (request.getRows().size() != existingMap.size()) { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceivablePayableDetailServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceivablePayableDetailServiceImpl.java index 50749f1..a761ddb 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceivablePayableDetailServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceivablePayableDetailServiceImpl.java @@ -198,6 +198,209 @@ public class ReceivablePayableDetailServiceImpl return result; } + @Override + @Transactional(rollbackFor = Exception.class) + public void syncMileageFromWaybill(Long waybillId, BigDecimal mileage) { + if (waybillId == null || mileage == null) { + return; + } + Waybill waybill = waybillService.getById(waybillId); + if (waybill == null || Objects.equals(waybill.getIsDeleted(), 1)) { + return; + } + BigDecimal normalizedMileage = normalizeGeneratedMileage(mileage); + Set detailIds = new LinkedHashSet<>(); + list(Wrappers.lambdaQuery() + .eq(ReceivablePayableDetail::getIsDeleted, 0) + .eq(ReceivablePayableDetail::getWaybillId, waybillId) + .eq(ReceivablePayableDetail::getSettlementStatus, "pending") + .and(wrapper -> wrapper.isNull(ReceivablePayableDetail::getPreSettlementNo) + .or().eq(ReceivablePayableDetail::getPreSettlementNo, "")) + .and(wrapper -> wrapper.isNull(ReceivablePayableDetail::getFormalSettlementNo) + .or().eq(ReceivablePayableDetail::getFormalSettlementNo, ""))) + .forEach(detail -> detailIds.add(detail.getId())); + List relatedFees = cargoFeeMapper.selectList( + Wrappers.lambdaQuery() + .eq(ReceivablePayableCargoFee::getIsDeleted, 0) + .eq(ReceivablePayableCargoFee::getWaybillId, waybillId)); + relatedFees.stream() + .map(ReceivablePayableCargoFee::getDetailId) + .filter(Objects::nonNull) + .forEach(detailIds::add); + if (detailIds.isEmpty()) { + return; + } + for (Long detailId : detailIds) { + ReceivablePayableDetail detail = getById(detailId); + if (detail == null || Objects.equals(detail.getIsDeleted(), 1) + || !"pending".equals(detail.getSettlementStatus()) + || Func.isNotEmpty(detail.getPreSettlementNo()) + || Func.isNotEmpty(detail.getFormalSettlementNo())) { + continue; + } + syncDetailMileage(detail, waybill, normalizedMileage); + } + } + + private void syncDetailMileage(ReceivablePayableDetail detail, Waybill waybill, BigDecimal mileage) { + List rows = activeCargoFees(detail.getId()); + if (rows.isEmpty()) { + if (Objects.equals(detail.getWaybillId(), waybill.getId())) { + detail.setMileage(mileage); + updateById(detail); + } + return; + } + List changes = new ArrayList<>(); + boolean feeChanged = false; + for (ReceivablePayableCargoFee fee : rows) { + boolean related = Objects.equals(fee.getWaybillId(), waybill.getId()) + || (fee.getWaybillId() == null && Objects.equals(detail.getWaybillId(), waybill.getId())); + if (!related) { + continue; + } + BigDecimal oldMileage = normalizeGeneratedMileage(fee.getMileage()); + fee.setMileage(mileage); + if (isMileageBasedElement(fee.getBillingFactor()) && !isManualFee(fee) + && recalculateMileageBasedFee(detail, fee, waybill, oldMileage)) { + changes.add("【运单维护里程】按" + fee.getBillingFactor() + "重新计算费用"); + } + cargoFeeMapper.updateById(fee); + feeChanged = true; + appendChange(changes, "里程", oldMileage, mileage); + } + if (!feeChanged && !Objects.equals(detail.getWaybillId(), waybill.getId())) { + return; + } + refreshAdjustedDetail(detail, activeCargoFees(detail.getId())); + if (Objects.equals(detail.getWaybillId(), waybill.getId())) { + detail.setMileage(mileage); + updateById(detail); + } + if (!changes.isEmpty()) { + saveChangeRecord(detail, String.join(";", changes), "运单维护里程同步", "0001"); + } + } + + /** + * 按最新运单里程重算「按里程 / 按吨·公里」费用行。 + */ + private boolean recalculateMileageBasedFee(ReceivablePayableDetail detail, ReceivablePayableCargoFee fee, + Waybill sourceWaybill, BigDecimal previousMileage) { + Waybill calcWaybill = waybillForMileageRecalc(sourceWaybill, fee, previousMileage); + List> rules = parseList(fee.getBillingRulesJson()); + if (rules.isEmpty()) { + ContractManage contract = contractManageService.getById(detail.getContractId()); + if (contract != null) { + rules = matchingAdjustedRules(contract, fee, calcWaybill); + if (!rules.isEmpty()) { + fee.setBillingRulesJson(JsonUtil.toJson(rules)); + } + } + } + if (rules.isEmpty()) { + log.warn("运单维护里程重算跳过:未找到计费规则,detailId={}, feeId={}", detail.getId(), fee.getId()); + return false; + } + Map calculatedAmounts = new LinkedHashMap<>(); + BigDecimal calculatedFreight = BigDecimal.ZERO; + boolean freightRuleMatched = false; + Map measureRule = null; + for (Map rule : rules) { + BigDecimal amount = calculateRule(rule, calcWaybill); + if (amount == null) { + continue; + } + String feeItem = stringValue(rule, "feeItem", "费用"); + calculatedAmounts.merge(feeItem, amount, BigDecimal::add); + if (measureRule == null || isFreightRule(rule)) { + measureRule = rule; + } + if (isFreightRule(rule)) { + calculatedFreight = calculatedFreight.add(amount); + freightRuleMatched = true; + } + } + if (calculatedAmounts.isEmpty() || measureRule == null) { + log.warn("运单维护里程重算跳过:计费规则无法试算,detailId={}, feeId={}", detail.getId(), fee.getId()); + return false; + } + Map feeItems = new LinkedHashMap<>(); + parseMap(fee.getFeeItemsJson()).forEach((name, value) -> feeItems.put(name, decimal(value))); + calculatedAmounts.forEach(feeItems::put); + fee.setTransportQuantity(resolveStoredTransportQuantity(measureRule, calcWaybill)); + fee.setUnitPrice(resolveCalculatedUnitPrice(measureRule, calcWaybill)); + fee.setBillingFactor(stringValue(measureRule, "billingElement", fee.getBillingFactor())); + fee.setBillingType(stringValue(measureRule, "billingType", fee.getBillingType())); + fee.setPriceUnit(stringValue(measureRule, "billingUnit", fee.getPriceUnit())); + if (freightRuleMatched) { + fee.setFreightAmount(calculatedFreight); + } + fee.setFeeItemsJson(JsonUtil.toJson(feeItems)); + BigDecimal afterAmount = adjustedAfterAmount(money(fee.getFreightAmount()), feeItems); + fee.setAdjustAmount(afterAmount.subtract(money(fee.getOriginalAmount()))); + fee.setAfterAmount(afterAmount); + return true; + } + + private Waybill waybillForMileageRecalc(Waybill sourceWaybill, ReceivablePayableCargoFee fee, + BigDecimal previousMileage) { + Waybill calcWaybill = Objects.requireNonNull(BeanUtil.copyProperties(sourceWaybill, Waybill.class)); + calcWaybill.setMileage(sourceWaybill.getMileage()); + String element = fee.getBillingFactor(); + if (!"按吨·公里".equals(element) && !"按重量".equals(element) + && !"按数量".equals(element) && !"按体积".equals(element)) { + return calcWaybill; + } + List> matchedGoods = parseList(sourceWaybill.getGoodsJson()).stream() + .filter(goods -> matchesFeeCargo(goods, fee)) + .toList(); + if (!matchedGoods.isEmpty()) { + Map goods = matchedGoods.get(0); + calcWaybill.setGoodsJson(JsonUtil.toJson(List.of(goods))); + calcWaybill.setCargoName(stringValue(goods, "cargoName", fee.getCargoName())); + calcWaybill.setCargoType(stringValue(goods, "cargoType", fee.getCargoType())); + calcWaybill.setSpecification(stringValue(goods, "specification", fee.getSpecification())); + calcWaybill.setModel(stringValue(goods, "model", fee.getModel())); + calcWaybill.setQuantity(decimal(goods.get("quantity"))); + calcWaybill.setQuantityUnit(stringValue(goods, "quantityUnit", fee.getQuantityUnit())); + return calcWaybill; + } + Map goods = new LinkedHashMap<>(); + goods.put("cargoName", fee.getCargoName()); + goods.put("cargoType", fee.getCargoType()); + goods.put("specification", fee.getSpecification()); + goods.put("model", fee.getModel()); + goods.put("quantityUnit", fee.getQuantityUnit()); + BigDecimal quantity = money(fee.getTransportQuantity()); + if ("按吨·公里".equals(element)) { + BigDecimal baseMileage = money(previousMileage); + if (baseMileage.signum() > 0) { + quantity = quantity.divide(baseMileage, 8, RoundingMode.HALF_UP); + } + } + goods.put("quantity", quantity); + if ("按体积".equals(element)) { + goods.put("volume", quantity); + } + calcWaybill.setGoodsJson(JsonUtil.toJson(List.of(goods))); + calcWaybill.setCargoName(fee.getCargoName()); + calcWaybill.setCargoType(fee.getCargoType()); + calcWaybill.setSpecification(fee.getSpecification()); + calcWaybill.setModel(fee.getModel()); + calcWaybill.setQuantity(quantity); + calcWaybill.setQuantityUnit(fee.getQuantityUnit()); + return calcWaybill; + } + + private boolean matchesFeeCargo(Map goods, ReceivablePayableCargoFee fee) { + return Objects.equals(stringValue(goods, "cargoName", ""), Objects.toString(fee.getCargoName(), "")) + && Objects.equals(stringValue(goods, "cargoType", ""), Objects.toString(fee.getCargoType(), "")) + && Objects.equals(stringValue(goods, "specification", ""), Objects.toString(fee.getSpecification(), "")) + && Objects.equals(stringValue(goods, "model", ""), Objects.toString(fee.getModel(), "")) + && Objects.equals(stringValue(goods, "quantityUnit", ""), Objects.toString(fee.getQuantityUnit(), "")); + } + @Override public ReceivablePayableFeeDetailVO feeDetail(Long id) { ReceivablePayableDetail detail = getExisting(id); @@ -890,11 +1093,68 @@ public class ReceivablePayableDetailServiceImpl Map plan = resolveDefaultBillingPlan(plans, waybill.getTransportType()); if (plan == null) return null; if (!(plan.get("rules") instanceof List rules)) return null; - boolean matched = rules.stream().anyMatch(value -> value instanceof Map raw && matchesRule(raw, waybill)); + boolean matched = rules.stream().anyMatch(value -> { + if (!(value instanceof Map raw)) return false; + Map rule = toRuleMap(raw); + return feeWaybills(rule, waybill).stream() + .anyMatch(feeWaybill -> matchesBillingRule(raw, rule, feeWaybill)); + }); if (!matched) return null; return "__matched__"; } + private Map toRuleMap(Map raw) { + Map rule = new LinkedHashMap<>(); + raw.forEach((key, item) -> rule.put(String.valueOf(key), item)); + return rule; + } + + /** + * 费用生成规则命中: + * 1. 货物需满足匹配规则(始发/目的/货类/货名) + * 2. 普通计费要素还要求货物数量单位与计费单位一致 + * 3. 按里程、按吨·公里按运单里程(×重量)另行计算,不校验数量单位 + */ + private boolean matchesBillingRule(Map raw, Map rule, Waybill feeWaybill) { + if (!matchesRule(raw, feeWaybill)) { + return false; + } + String element = stringValue(rule, "billingElement", ""); + if (isMileageBasedElement(element)) { + return true; + } + return matchesBillingUnit(rule, feeWaybill); + } + + private boolean isMileageBasedElement(String element) { + return "按里程".equals(element) || "按吨·公里".equals(element); + } + + private boolean matchesBillingUnit(Map rule, Waybill feeWaybill) { + String billingUnit = stringValue(rule, "billingUnit", "").trim(); + if (billingUnit.isEmpty()) { + return true; + } + String quantityUnit = resolveQuantityUnit(feeWaybill); + if (quantityUnit.isEmpty()) { + return false; + } + return billingUnit.equals(quantityUnit); + } + + private String resolveQuantityUnit(Waybill feeWaybill) { + String quantityUnit = feeWaybill.getQuantityUnit() == null ? "" : feeWaybill.getQuantityUnit().trim(); + if (!quantityUnit.isEmpty()) { + return quantityUnit; + } + List> goods = parseList(feeWaybill.getGoodsJson()); + return goods.stream() + .map(item -> stringValue(item, "quantityUnit", "").trim()) + .filter(Func::isNotEmpty) + .findFirst() + .orElse(""); + } + private boolean matchesRule(Map raw, Waybill waybill) { Object conditionValue = raw.get("matchCondition"); if (!(conditionValue instanceof Map condition) || !hasConfiguredMatchCondition(condition)) return true; @@ -1387,10 +1647,10 @@ public class ReceivablePayableDetailServiceImpl Set> freightBillingCargoKeys = new LinkedHashSet<>(); for (Object value : (List) plan.get("rules")) { if (!(value instanceof Map raw)) continue; - Map rule = new LinkedHashMap<>(); - raw.forEach((key, item) -> rule.put(String.valueOf(key), item)); + Map rule = toRuleMap(raw); for (Waybill feeWaybill : feeWaybills(rule, waybill)) { - if (matchOnly && !matchesRule(raw, feeWaybill)) continue; + // 统一生成:匹配规则内货物 + 数量单位一致;按里程/按吨·公里跳过单位匹配 + if (!matchesBillingRule(raw, rule, feeWaybill)) continue; BigDecimal amount = calculateRule(rule, feeWaybill); if (amount == null) continue; String feeItem = stringValue(rule, "feeItem", "费用"); @@ -1446,13 +1706,25 @@ public class ReceivablePayableDetailServiceImpl private void fillCalculatedBillingFields(ReceivablePayableCargoFee fee, Waybill feeWaybill, Map rule) { - fee.setBillingFactor(stringValue(rule, "billingElement", "")); + String element = stringValue(rule, "billingElement", ""); + fee.setBillingFactor(element); fee.setBillingType(stringValue(rule, "billingType", "")); - fee.setTransportQuantity(measure(rule, feeWaybill)); + fee.setTransportQuantity(resolveStoredTransportQuantity(rule, feeWaybill)); fee.setPriceUnit(stringValue(rule, "billingUnit", feeWaybill.getPriceUnit())); fee.setUnitPrice(resolveCalculatedUnitPrice(rule, feeWaybill)); } + /** + * 费用行运输量展示值:按车辆/固定金额的计费量仅试算用(为 1),不回写到运输量字段。 + */ + private BigDecimal resolveStoredTransportQuantity(Map rule, Waybill waybill) { + String element = stringValue(rule, "billingElement", ""); + if ("按车辆".equals(element) || "固定金额(整单一口价)".equals(element)) { + return null; + } + return measure(rule, waybill); + } + /** * 解析费用行展示用的实际命中单价。 * 区间计费的规则默认单价仅用于兜底,费用行应展示当前计费量命中的区间单价。 @@ -1635,7 +1907,9 @@ public class ReceivablePayableDetailServiceImpl return switch (element) { case "按体积" -> goods.stream().map(this::goodsVolume).reduce(BigDecimal.ZERO, BigDecimal::add); case "按车辆", "固定金额(整单一口价)" -> BigDecimal.ONE; + // 按里程:计费量=运单里程;固定单价/区间单价/阶梯等均基于该计费量 case "按里程" -> money(waybill.getMileage()); + // 按吨·公里:计费量=货物重量数量 × 运单里程 case "按吨·公里" -> goodsQuantity(goods).multiply(money(waybill.getMileage())); case "按数量" -> goodsQuantity(goods); default -> goodsQuantity(goods); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java index 835aa47..acff318 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java @@ -1045,7 +1045,11 @@ public class WaybillServiceImpl extends BaseServiceImpl TransportBusinessSupport.validateLength(mileageRemark, 200, "里程维护备注不能超过200字"); waybill.setMileage(mileage); waybill.setMileageRemark(mileageRemark); - return updateById(waybill); + boolean updated = updateById(waybill); + if (updated) { + receivablePayableDetailService.syncMileageFromWaybill(waybill.getId(), mileage); + } + return updated; } @Override diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/ContractManageWrapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/ContractManageWrapper.java index b4571b9..16631a8 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/ContractManageWrapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/ContractManageWrapper.java @@ -27,6 +27,7 @@ import org.springblade.core.tool.utils.BeanUtil; import org.springblade.transport.pojo.entity.ContractManage; import org.springblade.transport.pojo.vo.ContractManageVO; +import java.math.BigDecimal; import java.util.Objects; /** @@ -47,6 +48,10 @@ public class ContractManageWrapper extends BaseEntityWrapper { waybillVO.setBusinessStatus(displayStatus); waybillVO.setBusinessStatusName(businessStatusName(displayStatus)); waybillVO.setRequireAccept(WaybillProcessSupport.requiresDriverAcceptConfirmation(waybill.getProcessJson())); + // 未维护里程等占位值 -1 对外展示为空 + if (isSentinelMinusOne(waybillVO.getMileage())) { + waybillVO.setMileage(null); + } + if (isSentinelMinusOne(waybillVO.getQuantity())) { + waybillVO.setQuantity(null); + } + if (isSentinelMinusOne(waybillVO.getUnitPrice())) { + waybillVO.setUnitPrice(null); + } return waybillVO; } + private static boolean isSentinelMinusOne(BigDecimal value) { + return value != null && value.compareTo(BigDecimal.valueOf(-1)) == 0; + } + public static String businessStatusName(String status) { if (status == null) { return "未知"; diff --git a/doc/nacos/blade.yaml b/doc/nacos/blade.yaml index 81bcee2..3265744 100644 --- a/doc/nacos/blade.yaml +++ b/doc/nacos/blade.yaml @@ -197,6 +197,9 @@ blade: - /wechat/** #开启错误日志入库 error-log: true + #增删改操作日志自动入库(blade_log_api) + operation: + enabled: true #xss配置 xss: enabled: true diff --git a/doc/sql/transport/blade_customer_archive.sql b/doc/sql/transport/blade_customer_archive.sql index 640af81..79d5a91 100644 --- a/doc/sql/transport/blade_customer_archive.sql +++ b/doc/sql/transport/blade_customer_archive.sql @@ -93,6 +93,7 @@ CREATE TABLE `blade_customer_receipt_account` ( `account_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '收款方名称', `account_holder_name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '开户人姓名', `bank_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '开户行名称', + `cnaps_code` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '联行号', `bank_account` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '银行账号', `registered_phone` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '注册电话', `registered_address` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '注册地址', diff --git a/doc/sql/transport/blade_customer_receipt_account_cnaps_code_20260922.sql b/doc/sql/transport/blade_customer_receipt_account_cnaps_code_20260922.sql new file mode 100644 index 0000000..1c9423c --- /dev/null +++ b/doc/sql/transport/blade_customer_receipt_account_cnaps_code_20260922.sql @@ -0,0 +1,4 @@ +-- 客商收款信息:新增联行号 + +ALTER TABLE `blade_customer_receipt_account` + ADD COLUMN `cnaps_code` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '联行号' AFTER `bank_name`;