diff --git a/pjcloud-boot/src/main/resources/application.yml b/pjcloud-boot/src/main/resources/application.yml index 785ad1d6..3a304089 100644 --- a/pjcloud-boot/src/main/resources/application.yml +++ b/pjcloud-boot/src/main/resources/application.yml @@ -47,6 +47,9 @@ security: - /druid/** - /actuator/** - /code/** + - /dept/invite/preview + - /dept/invite/register + - /register/** #--------------如下配置尽量不要变动------------- # mybatis-plus 配置 diff --git a/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/constant/enums/MerchantAttrEnum.java b/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/constant/enums/MerchantAttrEnum.java new file mode 100644 index 00000000..94e7b81e --- /dev/null +++ b/pjcloud-common/pjcloud-common-core/src/main/java/com/bsd/pjcloud/common/core/constant/enums/MerchantAttrEnum.java @@ -0,0 +1,45 @@ +package com.bsd.pjcloud.common.core.constant.enums; + +import cn.hutool.core.util.ObjectUtil; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; + +/** + * 商户属性(与业态 {@link MerchantTypeEnum} 正交) + */ +@RequiredArgsConstructor +@Getter +public enum MerchantAttrEnum { + + NORMAL("NORMAL", "普通"), + SCHOOL("SCHOOL", "学校"), + GOVERNMENT("GOVERNMENT", "政府"), + FACTORY("FACTORY", "工厂"), + ; + + private final String code; + private final String label; + + public static MerchantAttrEnum valueOfByCode(String code) { + if (ObjectUtil.isEmpty(code)) { + return null; + } + return Arrays.stream(values()) + .filter(v -> ObjectUtil.equal(code, v.getCode())) + .findFirst() + .orElse(null); + } + + /** + * 学校类型默认学生身份,其余默认身份。 + */ + public static String resolveDefaultIdentityType(String merchantAttr) { + MerchantAttrEnum attr = valueOfByCode(merchantAttr); + if (attr == SCHOOL) { + return "STUDENT"; + } + return "DEFAULT"; + } +} diff --git a/pjcloud-common/pjcloud-common-security/src/main/java/com/bsd/pjcloud/common/security/component/PermitAllUrlProperties.java b/pjcloud-common/pjcloud-common-security/src/main/java/com/bsd/pjcloud/common/security/component/PermitAllUrlProperties.java index 7c6786b8..0e98b26b 100644 --- a/pjcloud-common/pjcloud-common-security/src/main/java/com/bsd/pjcloud/common/security/component/PermitAllUrlProperties.java +++ b/pjcloud-common/pjcloud-common-security/src/main/java/com/bsd/pjcloud/common/security/component/PermitAllUrlProperties.java @@ -44,7 +44,14 @@ public class PermitAllUrlProperties implements InitializingBean { private static final Pattern PATTERN = Pattern.compile("\\{(.*?)\\}"); - private static final String[] DEFAULT_IGNORE_URLS = new String[]{"/actuator/**", "/error", "/v3/api-docs"}; + private static final String[] DEFAULT_IGNORE_URLS = new String[]{ + "/actuator/**", + "/error", + "/v3/api-docs", + // 部门邀请公开接口(免登录);写入默认白名单,避免仅依赖注解扫描遗漏 + "/dept/invite/preview", + "/dept/invite/register" + }; @Getter @Setter @@ -70,18 +77,23 @@ public class PermitAllUrlProperties implements InitializingBean { // 获取方法上边的注解 替代path variable 为 * Inner method = AnnotationUtils.findAnnotation(handlerMethod.getMethod(), Inner.class); - Optional.ofNullable(method) - .ifPresent(inner -> Objects.requireNonNull(info.getPathPatternsCondition()) - .getPatternValues() - .forEach(url -> ignoreUrls.add(ReUtil.replaceAll(url, PATTERN, "*")))); + Optional.ofNullable(method).ifPresent(inner -> addIgnoreUrls(info)); // 获取类上边的注解, 替代path variable 为 * Inner controller = AnnotationUtils.findAnnotation(handlerMethod.getBeanType(), Inner.class); - Optional.ofNullable(controller) - .ifPresent(inner -> Objects.requireNonNull(info.getPathPatternsCondition()) - .getPatternValues() - .forEach(url -> ignoreUrls.add(ReUtil.replaceAll(url, PATTERN, "*")))); + Optional.ofNullable(controller).ifPresent(inner -> addIgnoreUrls(info)); }); } + private void addIgnoreUrls(RequestMappingInfo info) { + Set patterns = new HashSet<>(); + if (info.getPathPatternsCondition() != null) { + patterns.addAll(info.getPathPatternsCondition().getPatternValues()); + } + if (info.getPatternsCondition() != null) { + patterns.addAll(info.getPatternsCondition().getPatterns()); + } + patterns.forEach(url -> ignoreUrls.add(ReUtil.replaceAll(url, PATTERN, "*"))); + } + } diff --git a/pjcloud-common/pjcloud-common-security/src/main/java/com/bsd/pjcloud/common/security/service/PjcloudUserDetailsService.java b/pjcloud-common/pjcloud-common-security/src/main/java/com/bsd/pjcloud/common/security/service/PjcloudUserDetailsService.java index 6930f03b..c5b9fdd2 100644 --- a/pjcloud-common/pjcloud-common-security/src/main/java/com/bsd/pjcloud/common/security/service/PjcloudUserDetailsService.java +++ b/pjcloud-common/pjcloud-common-security/src/main/java/com/bsd/pjcloud/common/security/service/PjcloudUserDetailsService.java @@ -17,18 +17,22 @@ package com.bsd.pjcloud.common.security.service; import cn.hutool.core.util.ArrayUtil; +import cn.hutool.core.util.StrUtil; import com.bsd.pjcloud.admin.api.dto.UserInfo; import com.bsd.pjcloud.admin.api.entity.SysUser; import com.bsd.pjcloud.common.core.constant.CommonConstants; import com.bsd.pjcloud.common.core.constant.SecurityConstants; import com.bsd.pjcloud.common.core.constant.enums.UserTypeEnum; import com.bsd.pjcloud.common.core.util.MsgUtils; +import com.bsd.pjcloud.common.security.util.OAuth2ErrorCodesExpand; import org.springframework.core.Ordered; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.AuthorityUtils; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; import java.util.*; @@ -96,6 +100,16 @@ public interface PjcloudUserDetailsService extends UserDetailsService, Ordered { Collection authorities = AuthorityUtils .createAuthorityList(dbAuthsSet.toArray(new String[0])); SysUser user = info.getSysUser(); + // 与 TaskCheckStatusEnum 对齐:审核中/拒绝不可登录(邀请注册等场景仅写 processStatus,不锁账号) + String processStatus = user.getProcessStatus(); + if (StrUtil.equals(processStatus, "1")) { + throw new OAuth2AuthenticationException(new OAuth2Error(OAuth2ErrorCodesExpand.USER_PENDING_AUDIT, + MsgUtils.getSecurityMessage("AbstractUserDetailsAuthenticationProvider.pendingAudit"), "")); + } + if (StrUtil.equals(processStatus, "3")) { + throw new OAuth2AuthenticationException(new OAuth2Error(OAuth2ErrorCodesExpand.USER_AUDIT_REJECTED, + MsgUtils.getSecurityMessage("AbstractUserDetailsAuthenticationProvider.auditRejected"), "")); + } // 构造security用户 return new PjcloudUser(user.getUserId(), user.getUsername(), user.getDeptId(), user.getPhone(), user.getAvatar(), diff --git a/pjcloud-common/pjcloud-common-security/src/main/java/com/bsd/pjcloud/common/security/util/OAuth2ErrorCodesExpand.java b/pjcloud-common/pjcloud-common-security/src/main/java/com/bsd/pjcloud/common/security/util/OAuth2ErrorCodesExpand.java index e40f2073..a49b05cf 100644 --- a/pjcloud-common/pjcloud-common-security/src/main/java/com/bsd/pjcloud/common/security/util/OAuth2ErrorCodesExpand.java +++ b/pjcloud-common/pjcloud-common-security/src/main/java/com/bsd/pjcloud/common/security/util/OAuth2ErrorCodesExpand.java @@ -18,6 +18,12 @@ public interface OAuth2ErrorCodesExpand { /** 用户禁用 */ String USER_DISABLE = "user_disable"; + /** 用户审核中 */ + String USER_PENDING_AUDIT = "user_pending_audit"; + + /** 用户审核拒绝 */ + String USER_AUDIT_REJECTED = "user_audit_rejected"; + /** 用户过期 */ String USER_EXPIRED = "user_expired"; diff --git a/pjcloud-common/pjcloud-common-security/src/main/resources/errors/messages_en.properties b/pjcloud-common/pjcloud-common-security/src/main/resources/errors/messages_en.properties index 3278de58..ef5ea5b7 100644 --- a/pjcloud-common/pjcloud-common-security/src/main/resources/errors/messages_en.properties +++ b/pjcloud-common/pjcloud-common-security/src/main/resources/errors/messages_en.properties @@ -7,6 +7,8 @@ AbstractUserDetailsAuthenticationProvider.credentialsExpired=Credentials have ex OAuth2ResourceOwnerBaseAuthenticationProvider.tokenExpired=Credentials have expired AbstractUserDetailsAuthenticationProvider.disabled=User is not activated AbstractUserDetailsAuthenticationProvider.locked=User account is locked +AbstractUserDetailsAuthenticationProvider.pendingAudit=Account is pending approval. Please wait until approved before signing in +AbstractUserDetailsAuthenticationProvider.auditRejected=Account approval was rejected. Sign-in is not allowed AbstractUserDetailsAuthenticationProvider.expired=User account has expired JdbcDaoImpl.notFound=User {0} not found UserDetailsService.notFound=User does not exist diff --git a/pjcloud-common/pjcloud-common-security/src/main/resources/errors/messages_zh_CN.properties b/pjcloud-common/pjcloud-common-security/src/main/resources/errors/messages_zh_CN.properties index 0f225330..d4d746b6 100644 --- a/pjcloud-common/pjcloud-common-security/src/main/resources/errors/messages_zh_CN.properties +++ b/pjcloud-common/pjcloud-common-security/src/main/resources/errors/messages_zh_CN.properties @@ -29,6 +29,8 @@ OAuth2ResourceOwnerBaseAuthenticationProvider.tokenExpired=\u7528\u6237\u51ED\u8 AbstractUserDetailsAuthenticationProvider.disabled=\u7528\u6237\u672A\u6FC0\u6D3B AbstractUserDetailsAuthenticationProvider.expired=\u7528\u6237\u5E10\u53F7\u5DF2\u8FC7\u671F AbstractUserDetailsAuthenticationProvider.locked=\u7528\u6237\u5E10\u53F7\u5DF2\u88AB\u9501\u5B9A +AbstractUserDetailsAuthenticationProvider.pendingAudit=\u8D26\u53F7\u5BA1\u6838\u4E2D\uFF0C\u8BF7\u7B49\u5F85\u5BA1\u6279\u901A\u8FC7\u540E\u518D\u767B\u5F55 +AbstractUserDetailsAuthenticationProvider.auditRejected=\u8D26\u53F7\u5BA1\u6838\u672A\u901A\u8FC7\uFF0C\u65E0\u6CD5\u767B\u5F55 AbstractUserDetailsAuthenticationProvider.onlySupports=\u4EC5\u4EC5\u652F\u6301UsernamePasswordAuthenticationToken AbstractUserDetailsAuthenticationProvider.badTenantId=\u65E0\u6548\u7684\u79DF\u6237\u7F16\u53F7:{0} AccountStatusUserDetailsChecker.credentialsExpired=\u7528\u6237\u51ED\u8BC1\u5DF2\u8FC7\u671F diff --git a/pjcloud-common/pjcloud-common-security/src/main/resources/errors/messages_zh_HK.properties b/pjcloud-common/pjcloud-common-security/src/main/resources/errors/messages_zh_HK.properties index ee158b63..d7302b0d 100644 --- a/pjcloud-common/pjcloud-common-security/src/main/resources/errors/messages_zh_HK.properties +++ b/pjcloud-common/pjcloud-common-security/src/main/resources/errors/messages_zh_HK.properties @@ -7,6 +7,8 @@ AbstractUserDetailsAuthenticationProvider.credentialsExpired=用戶憑證已過 OAuth2ResourceOwnerBaseAuthenticationProvider.tokenExpired=用戶憑證已過期 AbstractUserDetailsAuthenticationProvider.disabled=用戶未啟用 AbstractUserDetailsAuthenticationProvider.locked=用戶帳號已鎖定 +AbstractUserDetailsAuthenticationProvider.pendingAudit=帳號審核中,請等待審批通過後再登入 +AbstractUserDetailsAuthenticationProvider.auditRejected=帳號審核未通過,無法登入 AbstractUserDetailsAuthenticationProvider.expired=用戶帳號已過期 JdbcDaoImpl.notFound=未找到用戶{0} UserDetailsService.notFound=用戶不存在 diff --git a/pjcloud-flow/pjcloud-flow-task/pjcloud-flow-task-api/src/main/java/com/bsd/pjcloud/flow/task/api/feign/RemoteFlowApiFlowService.java b/pjcloud-flow/pjcloud-flow-task/pjcloud-flow-task-api/src/main/java/com/bsd/pjcloud/flow/task/api/feign/RemoteFlowApiFlowService.java index bf97b7dd..79a8effa 100644 --- a/pjcloud-flow/pjcloud-flow-task/pjcloud-flow-task-api/src/main/java/com/bsd/pjcloud/flow/task/api/feign/RemoteFlowApiFlowService.java +++ b/pjcloud-flow/pjcloud-flow-task/pjcloud-flow-task-api/src/main/java/com/bsd/pjcloud/flow/task/api/feign/RemoteFlowApiFlowService.java @@ -3,13 +3,13 @@ package com.bsd.pjcloud.flow.task.api.feign; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.bsd.pjcloud.common.core.constant.ServiceNameConstants; import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.feign.annotation.NoToken; import com.bsd.pjcloud.flow.task.dto.ProcessInstanceParamDto; import com.bsd.pjcloud.flow.task.dto.TaskDto; import com.bsd.pjcloud.flow.task.dto.TaskQueryParamDto; import com.bsd.pjcloud.flow.task.entity.Process; import com.bsd.pjcloud.flow.task.vo.FormGroupVo; import com.bsd.pjcloud.flow.task.vo.ProcessVO; -import lombok.SneakyThrows; import org.springframework.cloud.openfeign.FeignClient; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; @@ -25,56 +25,59 @@ import java.util.List; @FeignClient(contextId = "remoteAiFlowService", value = ServiceNameConstants.FLOW_TASK_SERVER) public interface RemoteFlowApiFlowService { - /** - * 查询所有我可以发起的表单 - * - * @return R - */ - @GetMapping("/combination/group/listCurrentUserStartGroup") - R> listCurrentUserStartGroup(); + /** + * 查询所有我可以发起的表单 + * @return R + */ + @GetMapping("/combination/group/listCurrentUserStartGroup") + R> listCurrentUserStartGroup(); - /** - * 获取流程详情 - * - * @param flowId 流程id - * @return R - */ - @GetMapping("/process/getDetail") - R getDetail(@RequestParam("flowId") String flowId); + /** + * 获取流程详情 + * @param flowId 流程id + * @return R + */ + @GetMapping("/process/getDetail") + R getDetail(@RequestParam("flowId") String flowId); + /** + * 启动流程实例 + * @param paramDto param dto + * @return 流程实例ID + */ + @PostMapping("/process-instance/startProcessInstance") + R startProcessInstance(@RequestBody ProcessInstanceParamDto paramDto); - /** - * 启动流程实例 - * - * @param paramDto param dto - * @return {@link R } 流程实例ID {@link String} - */ - @PostMapping("/process-instance/startProcessInstance") - R startProcessInstance(@RequestBody ProcessInstanceParamDto paramDto); + /** + * 内部启动流程实例(无登录态 / 公开业务回调场景) + */ + @NoToken + @PostMapping("/process-instance/startInnerProcessInstance") + R startInnerProcessInstance(@RequestBody ProcessInstanceParamDto paramDto); - /** - * 查询当前用户的代办任务 - * - * @param queryParamDto - * @return {@link R } 流程实例ID {@link String} - */ - @PostMapping("/process-instance/queryMineTask") - R> queryMineTask(@RequestBody TaskQueryParamDto queryParamDto); + /** + * 查询当前用户的代办任务 + */ + @PostMapping("/process-instance/queryMineTask") + R> queryMineTask(@RequestBody TaskQueryParamDto queryParamDto); - /** - * 查询当前用户的已任务 - * - * @param queryParamDto - * @return {@link R } 流程实例ID {@link String} - */ - @PostMapping("/process-instance/queryMineEndTask") - R> queryMineEndTask(@RequestBody TaskQueryParamDto queryParamDto); + /** + * 查询当前用户的已办任务 + */ + @PostMapping("/process-instance/queryMineEndTask") + R> queryMineEndTask(@RequestBody TaskQueryParamDto queryParamDto); - /** - * 获取流程数据 - * @param businessKey 业务key - * @return - */ - @GetMapping("/process/getProcessByBusinessKey") - R getProcessByBusinessKey(@RequestParam("businessKey")String businessKey); + /** + * 获取流程数据 + * @param businessKey 业务key + */ + @GetMapping("/process/getProcessByBusinessKey") + R getProcessByBusinessKey(@RequestParam("businessKey") String businessKey); + + /** + * 内部获取流程数据(无登录态) + */ + @NoToken + @GetMapping("/process/getInnerProcessByBusinessKey") + R getInnerProcessByBusinessKey(@RequestParam("businessKey") String businessKey); } diff --git a/pjcloud-flow/pjcloud-flow-task/pjcloud-flow-task-biz/src/main/java/com/bsd/pjcloud/flow/task/controller/ProcessController.java b/pjcloud-flow/pjcloud-flow-task/pjcloud-flow-task-biz/src/main/java/com/bsd/pjcloud/flow/task/controller/ProcessController.java index 6af74206..c7a805b4 100644 --- a/pjcloud-flow/pjcloud-flow-task/pjcloud-flow-task-biz/src/main/java/com/bsd/pjcloud/flow/task/controller/ProcessController.java +++ b/pjcloud-flow/pjcloud-flow-task/pjcloud-flow-task-biz/src/main/java/com/bsd/pjcloud/flow/task/controller/ProcessController.java @@ -1,6 +1,7 @@ package com.bsd.pjcloud.flow.task.controller; import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.security.annotation.Inner; import com.bsd.pjcloud.flow.task.entity.Process; import com.bsd.pjcloud.flow.task.service.IProcessService; import com.bsd.pjcloud.flow.task.vo.ProcessVO; @@ -33,6 +34,16 @@ public class ProcessController { public R getProcessByBusinessKey(@RequestParam("businessKey")String businessKey) { return processService.getProcessByBusinessKey(businessKey); } + + /** + * 内部服务查询流程定义(免登录,需 FROM 内网标识) + */ + @Inner + @GetMapping("/getInnerProcessByBusinessKey") + public R getInnerProcessByBusinessKey(@RequestParam("businessKey") String businessKey) { + return processService.getProcessByBusinessKey(businessKey); + } + /** * 创建流程 * @param process diff --git a/pjcloud-integrated/pjcloud-integrated-biz/pom.xml b/pjcloud-integrated/pjcloud-integrated-biz/pom.xml index 821b1737..e1376208 100644 --- a/pjcloud-integrated/pjcloud-integrated-biz/pom.xml +++ b/pjcloud-integrated/pjcloud-integrated-biz/pom.xml @@ -106,11 +106,17 @@ pjcloud-common-seata 5.7.0 - + com.bsd pjcloud-common-log + + + com.jcraft + jsch + 0.1.55 + com.baomidou diff --git a/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/controller/app/AppVersionController.java b/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/controller/app/AppVersionController.java index c8c66f87..b09c85b0 100644 --- a/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/controller/app/AppVersionController.java +++ b/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/controller/app/AppVersionController.java @@ -7,7 +7,9 @@ import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.log.annotation.SysLog; import com.bsd.pjcloud.integrated.dto.app.AppVersionDto; import com.bsd.pjcloud.integrated.entity.app.AppVersionEntity; +import com.bsd.pjcloud.integrated.service.AppVersionPackageUploadService; import com.bsd.pjcloud.integrated.service.AppVersionService; +import com.bsd.pjcloud.integrated.vo.app.AppPackageUploadVo; import com.bsd.pjcloud.integrated.vo.app.AppVersionVo; import org.springframework.security.access.prepost.PreAuthorize; import io.swagger.v3.oas.annotations.security.SecurityRequirement; @@ -17,6 +19,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.Operation; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; /** * app版本控制表 @@ -29,6 +32,7 @@ import org.springframework.web.bind.annotation.*; public class AppVersionController { private final AppVersionService appVersionService; + private final AppVersionPackageUploadService appVersionPackageUploadService; /* ************************************* start 设备端 start ************************************* */ @Operation(summary = "分页查询", description = "分页查询") @@ -45,6 +49,16 @@ public class AppVersionController { public R lastVersion(@ParameterObject AppVersionDto dto) { return R.ok(appVersionService.lastVersion(dto)); } + + /** + * 部门邀请等场景:取安卓最新已发布 APK 整包地址(排除 wgt 热更) + */ + @Operation(summary = "最新安卓APK整包", description = "平台含安卓且 downloadUrl 以 .apk 结尾的最新已发布版本") + @GetMapping("/latest-android-apk") + @PreAuthorize("@pms.hasPermission('integrated_appVersion_view') || @pms.hasPermission('sys_dept_edit') || @pms.hasPermission('sys_dept_view')") + public R latestAndroidApk() { + return R.ok(appVersionService.latestAndroidApk()); + } /* ************************************* end app end ************************************* */ /** @@ -89,6 +103,29 @@ public class AppVersionController { return R.ok(appVersionService.updateById(appVersion)); } + /** + * 上传安装包到指定服务器目录(SFTP),返回对外下载地址 + */ + @Operation(summary = "上传安装包到远程服务器", description = "SFTP 上传 apk/wgt 等到宝塔等服务器指定目录") + @SysLog("上传App安装包到远程服务器") + @PostMapping("/upload-package") + @PreAuthorize("@pms.hasPermission('integrated_appVersion_add')") + public R uploadPackage(@RequestPart("file") MultipartFile file, + @RequestParam("host") String host, + @RequestParam(value = "port", required = false) Integer port, + @RequestParam("username") String username, + @RequestParam("password") String password, + @RequestParam("remoteDir") String remoteDir, + @RequestParam("publicBaseUrl") String publicBaseUrl) { + try { + return R.ok(appVersionPackageUploadService.uploadBySftp(file, host, port, username, password, remoteDir, + publicBaseUrl)); + } + catch (IllegalArgumentException ex) { + return R.failed(ex.getMessage()); + } + } + /** * 通过id删除app版本控制表 */ diff --git a/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/service/AppVersionPackageUploadService.java b/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/service/AppVersionPackageUploadService.java new file mode 100644 index 00000000..d7f88e43 --- /dev/null +++ b/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/service/AppVersionPackageUploadService.java @@ -0,0 +1,24 @@ +package com.bsd.pjcloud.integrated.service; + +import com.bsd.pjcloud.integrated.vo.app.AppPackageUploadVo; +import org.springframework.web.multipart.MultipartFile; + +/** + * App 安装包上传到远程服务器(宝塔等) + */ +public interface AppVersionPackageUploadService { + + /** + * 通过 SFTP 上传安装包,并返回对外可访问的下载地址。 + * + * @param file 安装包文件 + * @param host 服务器 IP/域名 + * @param port SFTP 端口,默认 22 + * @param username 登录用户名 + * @param password 登录密码 + * @param remoteDir 远程目录,如 /www/wwwroot/h5.zianxin.com/download + * @param publicBaseUrl 对外访问前缀,如 https://h5.zianxin.com/download + */ + AppPackageUploadVo uploadBySftp(MultipartFile file, String host, Integer port, String username, String password, + String remoteDir, String publicBaseUrl); +} diff --git a/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/service/AppVersionService.java b/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/service/AppVersionService.java index c8294d87..ddd51393 100755 --- a/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/service/AppVersionService.java +++ b/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/service/AppVersionService.java @@ -26,4 +26,9 @@ public interface AppVersionService extends IService { * @return 仅包含匹配终端的已发布版本 */ Page terminalPageList(Page page, AppVersionDto dto); + + /** + * 安卓整包(.apk)最新已发布版本,供邀请下载二维码使用。 + */ + AppVersionEntity latestAndroidApk(); } diff --git a/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/service/impl/AppVersionPackageUploadServiceImpl.java b/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/service/impl/AppVersionPackageUploadServiceImpl.java new file mode 100644 index 00000000..7bd5b5d6 --- /dev/null +++ b/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/service/impl/AppVersionPackageUploadServiceImpl.java @@ -0,0 +1,128 @@ +package com.bsd.pjcloud.integrated.service.impl; + +import cn.hutool.core.io.FileUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.extra.ssh.Sftp; +import com.bsd.pjcloud.integrated.service.AppVersionPackageUploadService; +import com.bsd.pjcloud.integrated.vo.app.AppPackageUploadVo; +import com.jcraft.jsch.JSch; +import com.jcraft.jsch.Session; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.InputStream; +import java.util.Locale; +import java.util.Set; + +/** + * 将 APK/WGT 等安装包经 SFTP 上传到宝塔等目标机指定目录。 + */ +@Slf4j +@Service +public class AppVersionPackageUploadServiceImpl implements AppVersionPackageUploadService { + + private static final String TAG = "AppPackageUpload"; + + private static final Set ALLOWED_EXT = Set.of("apk", "wgt", "ipa", "zip"); + + @Override + public AppPackageUploadVo uploadBySftp(MultipartFile file, String host, Integer port, String username, + String password, String remoteDir, String publicBaseUrl) { + if (file == null || file.isEmpty()) { + throw new IllegalArgumentException("请选择要上传的安装包文件"); + } + if (StrUtil.hasBlank(host, username, password, remoteDir, publicBaseUrl)) { + throw new IllegalArgumentException("服务器地址、账号、密码、远程目录、对外访问前缀不能为空"); + } + String safeDir = normalizeRemoteDir(remoteDir.trim()); + String originalName = file.getOriginalFilename(); + if (StrUtil.isBlank(originalName)) { + throw new IllegalArgumentException("无法识别文件名"); + } + String fileName = FileUtil.getName(originalName); + String ext = FileUtil.extName(fileName); + if (StrUtil.isBlank(ext) || !ALLOWED_EXT.contains(ext.toLowerCase(Locale.ROOT))) { + throw new IllegalArgumentException("仅支持上传 apk/wgt/ipa/zip 文件"); + } + int sftpPort = (port == null || port <= 0) ? 22 : port; + String remoteFilePath = safeDir + "/" + fileName; + String publicUrl = joinUrl(publicBaseUrl.trim(), fileName); + + Session session = null; + Sftp sftp = null; + try { + JSch jsch = new JSch(); + session = jsch.getSession(username.trim(), host.trim(), sftpPort); + session.setPassword(password); + session.setConfig("StrictHostKeyChecking", "no"); + session.connect(15000); + sftp = new Sftp(session); + try { + if (!sftp.exist(safeDir)) { + sftp.mkDirs(safeDir); + } + } + catch (Exception ex) { + // 部分环境 exist 不稳定,直接尝试建目录 + try { + sftp.mkDirs(safeDir); + } + catch (Exception mkdirEx) { + log.warn("|{}|创建目录失败(可能已存在):|{}|", TAG, mkdirEx.getMessage()); + } + } + try (InputStream in = file.getInputStream()) { + boolean ok = sftp.upload(safeDir, fileName, in); + if (!ok) { + throw new IllegalArgumentException("SFTP 上传返回失败"); + } + } + log.info("|{}|上传成功 host:|{}| remote:|{}| size:|{}|", TAG, host, remoteFilePath, file.getSize()); + return AppPackageUploadVo.builder() + .url(publicUrl) + .fileName(fileName) + .remotePath(remoteFilePath) + .build(); + } + catch (IllegalArgumentException ex) { + throw ex; + } + catch (Exception ex) { + log.error("|{}|SFTP上传失败 host:|{}| dir:|{}| error:|{}|", TAG, host, safeDir, ex.getMessage(), ex); + throw new IllegalArgumentException("上传到服务器失败:" + StrUtil.blankToDefault(ex.getMessage(), "请检查账号、目录与网络")); + } + finally { + if (sftp != null) { + try { + sftp.close(); + } + catch (Exception ignore) { + // ignore + } + } + if (session != null && session.isConnected()) { + session.disconnect(); + } + } + } + + private static String normalizeRemoteDir(String remoteDir) { + String dir = remoteDir.replace('\\', '/'); + if (dir.contains("..")) { + throw new IllegalArgumentException("远程目录非法"); + } + if (!dir.startsWith("/")) { + dir = "/" + dir; + } + while (dir.endsWith("/") && dir.length() > 1) { + dir = dir.substring(0, dir.length() - 1); + } + return dir; + } + + private static String joinUrl(String base, String fileName) { + String b = StrUtil.removeSuffix(base, "/"); + return b + "/" + fileName; + } +} diff --git a/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/service/impl/app/AppVersionServiceImpl.java b/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/service/impl/app/AppVersionServiceImpl.java index 1355b227..ba3fc1d7 100755 --- a/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/service/impl/app/AppVersionServiceImpl.java +++ b/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/service/impl/app/AppVersionServiceImpl.java @@ -4,6 +4,7 @@ import cn.hutool.core.util.ObjectUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.bsd.pjcloud.integrated.constant.app.AppPlatformEnum; import com.bsd.pjcloud.integrated.constant.app.AppVersionStatusEnum; import com.bsd.pjcloud.integrated.dto.app.AppVersionDto; import com.bsd.pjcloud.integrated.entity.app.AppVersionEntity; @@ -58,4 +59,18 @@ public class AppVersionServiceImpl extends ServiceImpl排除 .wgt 热更补丁,供部门邀请下载二维码使用。

+ */ + @Override + public AppVersionEntity latestAndroidApk() { + return baseMapper.selectOne(new LambdaQueryWrapper() + .eq(AppVersionEntity::getStatus, AppVersionStatusEnum.PUBLISHED.getCode()) + .apply("FIND_IN_SET({0}, platform) > 0", AppPlatformEnum.ANDROID.getCode()) + .apply("LOWER(download_url) LIKE '%.apk'") + .orderByDesc(AppVersionEntity::getVersionCode) + .last("LIMIT 1")); + } } diff --git a/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/vo/app/AppPackageUploadVo.java b/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/vo/app/AppPackageUploadVo.java new file mode 100644 index 00000000..50029e39 --- /dev/null +++ b/pjcloud-integrated/pjcloud-integrated-biz/src/main/java/com/bsd/pjcloud/integrated/vo/app/AppPackageUploadVo.java @@ -0,0 +1,23 @@ +package com.bsd.pjcloud.integrated.vo.app; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Data; + +/** + * App 安装包远程上传结果 + */ +@Data +@Builder +@Schema(description = "App安装包上传结果") +public class AppPackageUploadVo { + + @Schema(description = "对外下载地址") + private String url; + + @Schema(description = "服务器上的文件名") + private String fileName; + + @Schema(description = "服务器完整路径") + private String remotePath; +} diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/DeptInviteRegisterDTO.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/DeptInviteRegisterDTO.java new file mode 100644 index 00000000..43f81098 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/DeptInviteRegisterDTO.java @@ -0,0 +1,30 @@ +package com.bsd.pjcloud.admin.api.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 部门邀请注册请求 + */ +@Data +@Schema(description = "部门邀请注册") +public class DeptInviteRegisterDTO { + + @Schema(description = "邀请 token", requiredMode = Schema.RequiredMode.REQUIRED) + private String token; + + @Schema(description = "账号(可与手机号相同)", requiredMode = Schema.RequiredMode.REQUIRED) + private String username; + + @Schema(description = "密码", requiredMode = Schema.RequiredMode.REQUIRED) + private String password; + + @Schema(description = "姓名", requiredMode = Schema.RequiredMode.REQUIRED) + private String name; + + @Schema(description = "手机号", requiredMode = Schema.RequiredMode.REQUIRED) + private String phone; + + @Schema(description = "性别 0未知 1男 2女") + private Integer gender; +} diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/SysDeptInviteSaveDTO.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/SysDeptInviteSaveDTO.java new file mode 100644 index 00000000..b1afec15 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/SysDeptInviteSaveDTO.java @@ -0,0 +1,44 @@ +package com.bsd.pjcloud.admin.api.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 保存/更新部门邀请配置 + */ +@Data +@Schema(description = "部门邀请配置") +public class SysDeptInviteSaveDTO { + + @Schema(description = "部门ID", requiredMode = Schema.RequiredMode.REQUIRED) + private Long deptId; + + @Schema(description = "是否需要审核") + private Boolean needAudit; + + @Schema(description = "是否启用,默认 true") + private Boolean enabled; + + @Schema(description = "App 下载地址,用于生成二维码") + private String downloadUrl; + + @Schema(description = "邀请码类型:DIGIT6 / DIGIT8 / UUID") + private String tokenType; + + @Schema(description = "是否重新生成邀请码") + private Boolean regenerateToken; + + @Schema(description = "生效开始时间,空=立即") + private LocalDateTime validFrom; + + @Schema(description = "过期时间,空=长期有效") + private LocalDateTime expireTime; + + @Schema(description = "是否清空过期时间(长期有效)") + private Boolean clearExpire; + + @Schema(description = "备注") + private String remark; +} diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/entity/SysDeptInviteEntity.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/entity/SysDeptInviteEntity.java new file mode 100644 index 00000000..7ab38ccc --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/entity/SysDeptInviteEntity.java @@ -0,0 +1,71 @@ +package com.bsd.pjcloud.admin.api.entity; + +import com.baomidou.mybatisplus.annotation.*; +import com.baomidou.mybatisplus.extension.activerecord.Model; +import com.bsd.pjcloud.common.core.util.TenantTable; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.LocalDateTime; + +/** + * 部门邀请注册配置 + */ +@Data +@TenantTable +@TableName("sys_dept_invite") +@EqualsAndHashCode(callSuper = true) +@Schema(description = "部门邀请注册配置") +public class SysDeptInviteEntity extends Model { + + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + @Schema(description = "目标加入部门") + private Long deptId; + + @Schema(description = "邀请码") + private String token; + + @Schema(description = "邀请码类型 DIGIT6/DIGIT8/UUID") + private String tokenType; + + @Schema(description = "App 下载地址(二维码内容)") + private String downloadUrl; + + @Schema(description = "是否需要审核 1是0否") + private String needAudit; + + @Schema(description = "是否启用 1启用0停用") + private String enabled; + + @Schema(description = "生效开始时间,空=立即") + @TableField(updateStrategy = FieldStrategy.ALWAYS) + private LocalDateTime validFrom; + + @Schema(description = "过期时间,空=长期") + @TableField(updateStrategy = FieldStrategy.ALWAYS) + private LocalDateTime expireTime; + + @Schema(description = "备注") + private String remark; + + @TableField(fill = FieldFill.INSERT) + private String createBy; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private String updateBy; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + + @TableLogic + @TableField(fill = FieldFill.INSERT) + private String delFlag; + + private Long tenantId; +} diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysDeptInviteListVO.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysDeptInviteListVO.java new file mode 100644 index 00000000..70151e74 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysDeptInviteListVO.java @@ -0,0 +1,49 @@ +package com.bsd.pjcloud.admin.api.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 部门邀请列表项(管理端) + */ +@Data +@Schema(description = "部门邀请列表") +public class SysDeptInviteListVO { + + private Long id; + + private Long deptId; + + @Schema(description = "部门名称") + private String deptName; + + @Schema(description = "邀请码") + private String token; + + @Schema(description = "邀请码类型") + private String tokenType; + + @Schema(description = "是否需要审核 1是0否") + private String needAudit; + + @Schema(description = "是否启用 1启用0停用") + private String enabled; + + private LocalDateTime validFrom; + + private LocalDateTime expireTime; + + private String remark; + + private LocalDateTime createTime; + + private LocalDateTime updateTime; + + @Schema(description = "状态:ENABLED/DISABLED/NOT_STARTED/EXPIRED") + private String status; + + @Schema(description = "状态文案") + private String statusLabel; +} diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysDeptInvitePreviewVO.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysDeptInvitePreviewVO.java new file mode 100644 index 00000000..cd4dbb5f --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/SysDeptInvitePreviewVO.java @@ -0,0 +1,42 @@ +package com.bsd.pjcloud.admin.api.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 邀请入口公开信息(注册页预览) + */ +@Data +@Schema(description = "部门邀请预览") +public class SysDeptInvitePreviewVO { + + private String token; + + private Long deptId; + + private String deptName; + + @Schema(description = "是否需要审核") + private Boolean needAudit; + + @Schema(description = "解析到的商户ID") + private Long merchantId; + + @Schema(description = "商户属性") + private String merchantAttr; + + @Schema(description = "商户属性名称") + private String merchantAttrLabel; + + @Schema(description = "默认身份 DEFAULT/STUDENT") + private String identityType; + + @Schema(description = "默认身份名称") + private String identityTypeLabel; + + @Schema(description = "生效开始时间") + private java.time.LocalDateTime validFrom; + + @Schema(description = "过期时间") + private java.time.LocalDateTime expireTime; +} diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysDeptInviteController.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysDeptInviteController.java new file mode 100644 index 00000000..fa7153ba --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysDeptInviteController.java @@ -0,0 +1,74 @@ +package com.bsd.pjcloud.admin.controller; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.admin.api.dto.SysDeptInviteSaveDTO; +import com.bsd.pjcloud.admin.api.entity.SysDeptInviteEntity; +import com.bsd.pjcloud.admin.api.vo.SysDeptInviteListVO; +import com.bsd.pjcloud.admin.service.SysDeptInviteService; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.log.annotation.SysLog; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.http.HttpHeaders; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +/** + * 部门邀请配置(管理端,需登录权限) + */ +@RestController +@RequiredArgsConstructor +@RequestMapping("/dept/invite") +@Tag(description = "deptInvite", name = "部门邀请注册-管理") +@SecurityRequirement(name = HttpHeaders.AUTHORIZATION) +public class SysDeptInviteController { + + private final SysDeptInviteService deptInviteService; + + @Operation(summary = "获取或生成部门邀请配置") + @SysLog("生成部门邀请") + @PostMapping + @PreAuthorize("@pms.hasPermission('sys_dept_edit')") + public R getOrCreate(@RequestBody SysDeptInviteSaveDTO dto) { + try { + return R.ok(deptInviteService.getOrCreate(dto)); + } + catch (IllegalArgumentException ex) { + return R.failed(ex.getMessage()); + } + } + + @Operation(summary = "按部门查询邀请配置") + @GetMapping("/by-dept/{deptId}") + @PreAuthorize("@pms.hasPermission('sys_dept_edit')") + public R getByDept(@PathVariable Long deptId) { + return R.ok(deptInviteService.getByDeptId(deptId)); + } + + @Operation(summary = "部门邀请分页列表") + @GetMapping("/page") + @PreAuthorize("@pms.hasPermission('sys_dept_edit')") + public R> page(@ParameterObject Page page, + @RequestParam(required = false) String deptName, + @RequestParam(required = false) String token, + @RequestParam(required = false) String enabled) { + return R.ok(deptInviteService.pageList(page, deptName, token, enabled)); + } + + @Operation(summary = "删除部门邀请配置") + @SysLog("删除部门邀请") + @DeleteMapping("/{id}") + @PreAuthorize("@pms.hasPermission('sys_dept_edit')") + public R remove(@PathVariable Long id) { + try { + return R.ok(deptInviteService.removeInvite(id)); + } + catch (IllegalArgumentException ex) { + return R.failed(ex.getMessage()); + } + } +} diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysDeptInvitePublicController.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysDeptInvitePublicController.java new file mode 100644 index 00000000..9dd3c799 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysDeptInvitePublicController.java @@ -0,0 +1,48 @@ +package com.bsd.pjcloud.admin.controller; + +import com.bsd.pjcloud.admin.api.dto.DeptInviteRegisterDTO; +import com.bsd.pjcloud.admin.api.vo.SysDeptInvitePreviewVO; +import com.bsd.pjcloud.admin.service.SysDeptInviteService; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.log.annotation.SysLog; +import com.bsd.pjcloud.common.security.annotation.Inner; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +/** + * 部门邀请公开接口(免登录),与管理端配置接口分离,避免鉴权白名单漏扫。 + */ +@Inner(value = false) +@RestController +@RequiredArgsConstructor +@RequestMapping("/dept/invite") +@Tag(description = "deptInvitePublic", name = "部门邀请注册-公开") +public class SysDeptInvitePublicController { + + private final SysDeptInviteService deptInviteService; + + @Operation(summary = "邀请入口预览(公开)") + @GetMapping("/preview") + public R preview(@RequestParam("token") String token) { + try { + return R.ok(deptInviteService.preview(token)); + } + catch (IllegalArgumentException ex) { + return R.failed(ex.getMessage()); + } + } + + @SysLog("部门邀请注册") + @Operation(summary = "邀请注册(公开)") + @PostMapping("/register") + public R register(@RequestBody DeptInviteRegisterDTO dto) { + try { + return deptInviteService.register(dto); + } + catch (IllegalArgumentException ex) { + return R.failed(ex.getMessage()); + } + } +} diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysDeptInviteMapper.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysDeptInviteMapper.java new file mode 100644 index 00000000..0d53f12f --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysDeptInviteMapper.java @@ -0,0 +1,9 @@ +package com.bsd.pjcloud.admin.mapper; + +import com.bsd.pjcloud.admin.api.entity.SysDeptInviteEntity; +import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface SysDeptInviteMapper extends PjcloudBaseMapper { +} diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysDeptInviteService.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysDeptInviteService.java new file mode 100644 index 00000000..769e3e22 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysDeptInviteService.java @@ -0,0 +1,32 @@ +package com.bsd.pjcloud.admin.service; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.admin.api.dto.DeptInviteRegisterDTO; +import com.bsd.pjcloud.admin.api.dto.SysDeptInviteSaveDTO; +import com.bsd.pjcloud.admin.api.entity.SysDeptInviteEntity; +import com.bsd.pjcloud.admin.api.vo.SysDeptInviteListVO; +import com.bsd.pjcloud.admin.api.vo.SysDeptInvitePreviewVO; +import com.bsd.pjcloud.common.core.util.R; + +/** + * 部门邀请注册 + */ +public interface SysDeptInviteService { + + SysDeptInviteEntity getOrCreate(SysDeptInviteSaveDTO dto); + + SysDeptInviteEntity updateConfig(SysDeptInviteSaveDTO dto); + + SysDeptInviteEntity getByDeptId(Long deptId); + + /** 管理端分页列表 */ + IPage pageList(Page page, String deptName, String token, String enabled); + + /** 删除邀请配置(逻辑删除) */ + boolean removeInvite(Long id); + + SysDeptInvitePreviewVO preview(String token); + + R register(DeptInviteRegisterDTO dto); +} diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysDeptInviteServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysDeptInviteServiceImpl.java new file mode 100644 index 00000000..d61bcf38 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysDeptInviteServiceImpl.java @@ -0,0 +1,543 @@ +package com.bsd.pjcloud.admin.service.impl; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.IdUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.RandomUtil; +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.bsd.pjcloud.admin.api.constant.UserExpireEnum; +import com.bsd.pjcloud.admin.api.constant.UserStaffingTypeEnum; +import com.bsd.pjcloud.admin.api.constant.UserStateEnum; +import com.bsd.pjcloud.admin.api.constant.user.UserIdentityTypeEnum; +import com.bsd.pjcloud.admin.api.dto.DeptInviteRegisterDTO; +import com.bsd.pjcloud.admin.api.dto.SysDeptInviteSaveDTO; +import com.bsd.pjcloud.admin.api.dto.UserDTO; +import com.bsd.pjcloud.admin.api.entity.SysDept; +import com.bsd.pjcloud.admin.api.entity.SysDeptInviteEntity; +import com.bsd.pjcloud.admin.api.entity.SysRole; +import com.bsd.pjcloud.admin.api.entity.SysUser; +import com.bsd.pjcloud.admin.api.entity.SysUserRole; +import com.bsd.pjcloud.admin.api.util.PersonNameUtils; +import com.bsd.pjcloud.admin.api.vo.SysDeptInviteListVO; +import com.bsd.pjcloud.admin.api.vo.SysDeptInvitePreviewVO; +import com.bsd.pjcloud.admin.mapper.SysDeptInviteMapper; +import com.bsd.pjcloud.admin.mapper.SysUserMapper; +import com.bsd.pjcloud.admin.mapper.SysUserRoleMapper; +import com.bsd.pjcloud.admin.service.SysDeptInviteService; +import com.bsd.pjcloud.admin.service.SysDeptService; +import com.bsd.pjcloud.admin.service.SysRoleService; +import com.bsd.pjcloud.admin.service.SysUserIdentityService; +import com.bsd.pjcloud.admin.utils.flow.FlowUtil; +import com.bsd.pjcloud.admin.utils.flow.dto.UserApplyFlowDto; +import com.bsd.pjcloud.common.core.constant.enums.MerchantAttrEnum; +import com.bsd.pjcloud.common.core.exception.ErrorCodes; +import com.bsd.pjcloud.common.core.util.MsgUtils; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.data.resolver.ParamResolver; +import com.bsd.pjcloud.flow.task.constant.TaskCheckStatusEnum; +import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; +import com.bsd.pjcloud.wallet.api.feign.RemoteWalletService; +import com.bsd.pjcloud.wallet.api.vo.MchManageVo; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.context.request.RequestContextHolder; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** + * 部门邀请注册:生成链接/二维码入口,按部门→单位部门向上解析商户属性决定默认身份。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class SysDeptInviteServiceImpl extends ServiceImpl + implements SysDeptInviteService { + + private static final String TAG = "SysDeptInvite"; + private static final PasswordEncoder ENCODER = new BCryptPasswordEncoder(); + + private final SysDeptService sysDeptService; + private final SysUserMapper sysUserMapper; + private final SysUserRoleMapper sysUserRoleMapper; + private final SysRoleService sysRoleService; + private final SysUserIdentityService sysUserIdentityService; + private final RemoteMchService remoteMchService; + private final RemoteWalletService remoteWalletService; + private final FlowUtil flowUtil; + + /** 邀请码类型:6 位数字 / 8 位数字 / UUID(默认) */ + private static final String TOKEN_DIGIT6 = "DIGIT6"; + private static final String TOKEN_DIGIT8 = "DIGIT8"; + private static final String TOKEN_UUID = "UUID"; + /** 登录账号:4-20 位,字母开头,仅字母数字下划线 */ + private static final Pattern USERNAME_PATTERN = Pattern.compile("^[a-zA-Z][a-zA-Z0-9_]{3,19}$"); + /** 中国大陆手机号 */ + private static final Pattern PHONE_PATTERN = Pattern.compile( + "^1(3\\d|4[01456879]|5[0-35-9]|6[2567]|7[0-8]|8\\d|9[0-35-9])\\d{8}$"); + + @Override + @Transactional(rollbackFor = Exception.class) + public SysDeptInviteEntity getOrCreate(SysDeptInviteSaveDTO dto) { + if (dto == null || dto.getDeptId() == null) { + throw new IllegalArgumentException("部门不能为空"); + } + SysDept dept = sysDeptService.getById(dto.getDeptId()); + if (dept == null) { + throw new IllegalArgumentException("部门不存在"); + } + validateExpireRange(dto.getValidFrom(), dto.getExpireTime()); + String tokenType = normalizeTokenType(dto.getTokenType()); + SysDeptInviteEntity exist = getByDeptId(dto.getDeptId()); + if (exist != null) { + if (dto.getNeedAudit() != null) { + exist.setNeedAudit(Boolean.TRUE.equals(dto.getNeedAudit()) ? "1" : "0"); + } + if (dto.getEnabled() != null) { + exist.setEnabled(Boolean.FALSE.equals(dto.getEnabled()) ? "0" : "1"); + } + if (dto.getRemark() != null) { + exist.setRemark(dto.getRemark()); + } + if (StrUtil.isNotBlank(dto.getDownloadUrl())) { + exist.setDownloadUrl(dto.getDownloadUrl().trim()); + } + // 有效时段:clearExpire=长期;否则有入参才覆盖(避免仅改审核时误清空) + if (Boolean.TRUE.equals(dto.getClearExpire())) { + exist.setValidFrom(null); + exist.setExpireTime(null); + } + else if (dto.getValidFrom() != null || dto.getExpireTime() != null) { + exist.setValidFrom(dto.getValidFrom()); + exist.setExpireTime(dto.getExpireTime()); + } + boolean regenerate = Boolean.TRUE.equals(dto.getRegenerateToken()) + || (StrUtil.isNotBlank(dto.getTokenType()) + && !tokenType.equals(StrUtil.blankToDefault(exist.getTokenType(), TOKEN_UUID))); + if (regenerate) { + exist.setTokenType(tokenType); + exist.setToken(generateUniqueToken(tokenType, exist.getId())); + } + else if (StrUtil.isNotBlank(dto.getTokenType())) { + exist.setTokenType(tokenType); + } + updateById(exist); + return exist; + } + SysDeptInviteEntity entity = new SysDeptInviteEntity(); + entity.setDeptId(dto.getDeptId()); + entity.setTokenType(tokenType); + entity.setToken(generateUniqueToken(tokenType, null)); + entity.setDownloadUrl(StrUtil.trim(dto.getDownloadUrl())); + entity.setNeedAudit(Boolean.TRUE.equals(dto.getNeedAudit()) ? "1" : "0"); + entity.setEnabled(Boolean.FALSE.equals(dto.getEnabled()) ? "0" : "1"); + entity.setValidFrom(dto.getValidFrom()); + entity.setExpireTime(Boolean.TRUE.equals(dto.getClearExpire()) ? null : dto.getExpireTime()); + entity.setRemark(dto.getRemark()); + save(entity); + return entity; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public SysDeptInviteEntity updateConfig(SysDeptInviteSaveDTO dto) { + return getOrCreate(dto); + } + + @Override + public SysDeptInviteEntity getByDeptId(Long deptId) { + if (deptId == null) { + return null; + } + return getOne(Wrappers.lambdaQuery() + .eq(SysDeptInviteEntity::getDeptId, deptId) + .last("LIMIT 1")); + } + + @Override + public IPage pageList(Page page, String deptName, String token, String enabled) { + List deptIds = null; + if (StrUtil.isNotBlank(deptName)) { + List depts = sysDeptService.list(Wrappers.lambdaQuery() + .like(SysDept::getName, deptName.trim())); + if (CollUtil.isEmpty(depts)) { + Page empty = new Page<>(page.getCurrent(), page.getSize(), 0); + empty.setRecords(List.of()); + return empty; + } + deptIds = depts.stream().map(SysDept::getDeptId).filter(Objects::nonNull).toList(); + } + Page entityPage = page(new Page<>(page.getCurrent(), page.getSize()), + Wrappers.lambdaQuery() + .in(CollUtil.isNotEmpty(deptIds), SysDeptInviteEntity::getDeptId, deptIds) + .like(StrUtil.isNotBlank(token), SysDeptInviteEntity::getToken, StrUtil.trim(token)) + .eq(StrUtil.isNotBlank(enabled), SysDeptInviteEntity::getEnabled, enabled) + .orderByDesc(SysDeptInviteEntity::getUpdateTime) + .orderByDesc(SysDeptInviteEntity::getCreateTime)); + List records = entityPage.getRecords(); + Map deptNameMap = new HashMap<>(); + if (CollUtil.isNotEmpty(records)) { + List ids = records.stream().map(SysDeptInviteEntity::getDeptId).filter(Objects::nonNull).distinct() + .toList(); + if (CollUtil.isNotEmpty(ids)) { + deptNameMap = sysDeptService.listByIds(ids).stream() + .filter(d -> d.getDeptId() != null) + .collect(Collectors.toMap(SysDept::getDeptId, SysDept::getName, (a, b) -> a)); + } + } + Map finalDeptNameMap = deptNameMap; + Page voPage = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal()); + voPage.setRecords(records.stream().map(e -> toListVO(e, finalDeptNameMap.get(e.getDeptId()))).toList()); + return voPage; + } + + private SysDeptInviteListVO toListVO(SysDeptInviteEntity entity, String deptName) { + SysDeptInviteListVO vo = new SysDeptInviteListVO(); + vo.setId(entity.getId()); + vo.setDeptId(entity.getDeptId()); + vo.setDeptName(deptName); + vo.setToken(entity.getToken()); + vo.setTokenType(entity.getTokenType()); + vo.setNeedAudit(entity.getNeedAudit()); + vo.setEnabled(entity.getEnabled()); + vo.setValidFrom(entity.getValidFrom()); + vo.setExpireTime(entity.getExpireTime()); + vo.setRemark(entity.getRemark()); + vo.setCreateTime(entity.getCreateTime()); + vo.setUpdateTime(entity.getUpdateTime()); + LocalDateTime now = LocalDateTime.now(); + if (!"1".equals(entity.getEnabled())) { + vo.setStatus("DISABLED"); + vo.setStatusLabel("已停用"); + } + else if (entity.getValidFrom() != null && entity.getValidFrom().isAfter(now)) { + vo.setStatus("NOT_STARTED"); + vo.setStatusLabel("未生效"); + } + else if (entity.getExpireTime() != null && entity.getExpireTime().isBefore(now)) { + vo.setStatus("EXPIRED"); + vo.setStatusLabel("已过期"); + } + else { + vo.setStatus("ENABLED"); + vo.setStatusLabel("可用"); + } + return vo; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean removeInvite(Long id) { + if (id == null) { + throw new IllegalArgumentException("邀请配置不能为空"); + } + SysDeptInviteEntity exist = getById(id); + if (exist == null) { + throw new IllegalArgumentException("邀请配置不存在或已删除"); + } + return removeById(id); + } + + @Override + public SysDeptInvitePreviewVO preview(String token) { + SysDeptInviteEntity invite = requireValidInvite(token); + SysDept dept = sysDeptService.getById(invite.getDeptId()); + MchManageVo mch = resolveMerchantAlongUnitChain(invite.getDeptId()); + String attr = mch != null ? StrUtil.blankToDefault(mch.getMerchantAttr(), MerchantAttrEnum.NORMAL.getCode()) + : MerchantAttrEnum.NORMAL.getCode(); + MerchantAttrEnum attrEnum = MerchantAttrEnum.valueOfByCode(attr); + String identityType = MerchantAttrEnum.resolveDefaultIdentityType(attr); + UserIdentityTypeEnum identity = UserIdentityTypeEnum.valueOfByType(identityType); + + SysDeptInvitePreviewVO vo = new SysDeptInvitePreviewVO(); + vo.setToken(invite.getToken()); + vo.setDeptId(invite.getDeptId()); + vo.setDeptName(dept != null ? dept.getName() : null); + vo.setNeedAudit("1".equals(invite.getNeedAudit())); + vo.setMerchantId(mch != null ? mch.getId() : null); + vo.setMerchantAttr(attr); + vo.setMerchantAttrLabel(attrEnum != null ? attrEnum.getLabel() : MerchantAttrEnum.NORMAL.getLabel()); + vo.setIdentityType(identityType); + vo.setIdentityTypeLabel(identity != null ? identity.getDescription() : UserIdentityTypeEnum.DEFAULT.getDescription()); + vo.setValidFrom(invite.getValidFrom()); + vo.setExpireTime(invite.getExpireTime()); + return vo; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public R register(DeptInviteRegisterDTO dto) { + if (dto == null || StrUtil.isBlank(dto.getToken())) { + return R.failed("邀请码无效"); + } + if (StrUtil.isBlank(dto.getUsername()) || StrUtil.isBlank(dto.getPassword()) + || StrUtil.isBlank(dto.getName()) || StrUtil.isBlank(dto.getPhone())) { + return R.failed("请完整填写账号、密码、姓名、手机号"); + } + String username = dto.getUsername().trim(); + String password = dto.getPassword(); + String phone = dto.getPhone().trim(); + String usernameErr = validateUsername(username); + if (usernameErr != null) { + return R.failed(usernameErr); + } + String passwordErr = validateInvitePassword(password); + if (passwordErr != null) { + return R.failed(passwordErr); + } + String nameErr = validateInviteName(dto.getName()); + if (nameErr != null) { + return R.failed(nameErr); + } + if (!PHONE_PATTERN.matcher(phone).matches()) { + return R.failed("请输入正确的手机号"); + } + + SysDeptInviteEntity invite = requireValidInvite(dto.getToken()); + boolean usernameExists = sysUserMapper.exists(Wrappers.lambdaQuery().eq(SysUser::getUsername, username)); + if (usernameExists) { + return R.failed(MsgUtils.getMessage(ErrorCodes.SYS_USER_USERNAME_EXISTING, username)); + } + boolean phoneExists = sysUserMapper.exists(Wrappers.lambdaQuery().eq(SysUser::getPhone, phone)); + if (phoneExists) { + return R.failed(MsgUtils.getMessage(ErrorCodes.SYS_USER_PHONE_EXISTING, phone)); + } + + MchManageVo mch = resolveMerchantAlongUnitChain(invite.getDeptId()); + String attr = mch != null ? StrUtil.blankToDefault(mch.getMerchantAttr(), MerchantAttrEnum.NORMAL.getCode()) + : MerchantAttrEnum.NORMAL.getCode(); + String identityType = MerchantAttrEnum.resolveDefaultIdentityType(attr); + boolean needAudit = "1".equals(invite.getNeedAudit()); + + SysUser sysUser = new SysUser(); + sysUser.setUsername(username); + sysUser.setPhone(phone); + sysUser.setName(PersonNameUtils.normalize(dto.getName())); + sysUser.setNickname(sysUser.getName()); + sysUser.setGender(dto.getGender()); + sysUser.setDeptId(invite.getDeptId()); + // 与后台「新增用户审批」一致:正常账号 + 审核中状态,并真正发起 ADD_USER_FLOW 写入 processId + sysUser.setLockFlag(UserStateEnum.NORMAL.getCode()); + sysUser.setCreateBy(username); + sysUser.setUpdateBy(username); + sysUser.setPassword(ENCODER.encode(password)); + sysUser.setPasswordModifyTime(LocalDateTime.now()); + sysUser.setProcessStatus(needAudit ? TaskCheckStatusEnum.PADDING.getCode() : TaskCheckStatusEnum.RESULT.getCode()); + sysUser.setProcessTime(LocalDateTime.now()); + sysUser.setStaffingType(UserStaffingTypeEnum.NONE_STAFFING.getType()); + // 邀请注册已自设密码,无需强制改密;EXPIRE(9) 会被当成凭证过期无法登录 + sysUser.setPasswordExpireFlag(UserExpireEnum.NORMAL.getCode()); + sysUserMapper.insert(sysUser); + + List roleList = sysRoleService.list(); + String walletAutoCreate = ParamResolver.getStr("WALLET_AUTO_CREATE"); + String userDefaultRole = ParamResolver.getStr("USER_DEFAULT_ROLE"); + List roleIds = roleList.stream() + .filter(v -> ObjectUtil.equals(v.getRoleCode(), walletAutoCreate) + || ObjectUtil.equals(v.getRoleCode(), userDefaultRole)) + .map(SysRole::getRoleId) + .toList(); + for (Long roleId : roleIds) { + SysUserRole userRole = new SysUserRole(); + userRole.setUserId(sysUser.getUserId()); + userRole.setRoleId(roleId); + sysUserRoleMapper.insert(userRole); + } + + UserDTO identityDto = new UserDTO(); + identityDto.setIdentityTypes(List.of(identityType)); + sysUserIdentityService.saveUserIdentity(sysUser.getUserId(), identityDto); + + if (needAudit) { + startInviteUserFlow(sysUser, invite.getDeptId()); + return R.ok(true, "注册成功,请等待审核通过后再登录"); + } + + RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); + CompletableFuture.runAsync(() -> { + try { + RequestContextHolder.setRequestAttributes(requestAttributes); + remoteWalletService.createWallet(sysUser.getUserId()); + remoteWalletService.userChange(sysUser.getUserId(), Boolean.FALSE); + } + catch (Exception e) { + log.error("|{}|创建用户钱包失败error:|{}|", TAG, e.getMessage()); + } + }); + return R.ok(true, "注册成功"); + } + + /** + * 邀请注册开启审核:走与后台新增用户相同的 ADD_USER_FLOW,保证用户列表可点「开始处理」。 + */ + private void startInviteUserFlow(SysUser sysUser, Long inviteDeptId) { + try { + SysDept inviteDept = sysDeptService.getById(inviteDeptId); + UserApplyFlowDto flowDto = new UserApplyFlowDto(); + if (inviteDept != null) { + flowDto.setApplyDeptName(inviteDept.getName()); + } + // 发起人为注册用户本人;审批人由 ADD_USER_FLOW 流程配置决定 + String processId = flowUtil.startFlow("ADD_USER_FLOW", sysUser, sysUser.getUserId(), flowDto); + if (StrUtil.isBlank(processId)) { + throw new IllegalArgumentException("发起用户审核流程失败,请稍后重试或联系管理员检查 ADD_USER_FLOW 配置"); + } + SysUser updateObj = new SysUser(); + updateObj.setUserId(sysUser.getUserId()); + updateObj.setProcessId(processId); + updateObj.setProcessStatus(TaskCheckStatusEnum.PADDING.getCode()); + sysUserMapper.updateById(updateObj); + } + catch (IllegalArgumentException ex) { + throw ex; + } + catch (Exception ex) { + log.error("|{}|邀请注册发起审批流失败 userId:|{}| error:|{}|", TAG, sysUser.getUserId(), ex.getMessage()); + throw new IllegalArgumentException(StrUtil.blankToDefault(ex.getMessage(), "发起用户审核流程失败,请检查 ADD_USER_FLOW 配置")); + } + } + + private String validateUsername(String username) { + if (!USERNAME_PATTERN.matcher(username).matches()) { + return "账号需为4-20位,字母开头,仅支持字母、数字、下划线"; + } + return null; + } + + /** 与改密一致:8-20 位字母+数字组合 */ + private String validateInvitePassword(String password) { + if (StrUtil.length(password) < 8 || StrUtil.length(password) > 20) { + return MsgUtils.getMessage(ErrorCodes.SYS_USER_PASSWORD_LENGTH); + } + boolean hasLetter = false; + boolean hasDigit = false; + for (char c : password.toCharArray()) { + if (Character.isLetter(c)) { + hasLetter = true; + } + else if (Character.isDigit(c)) { + hasDigit = true; + } + else { + return MsgUtils.getMessage(ErrorCodes.SYS_USER_PASSWORD_CHARS_ONLY); + } + } + if (!(hasLetter && hasDigit)) { + return MsgUtils.getMessage(ErrorCodes.SYS_USER_PASSWORD_LETTER_DIGIT); + } + return null; + } + + /** 姓名:走统一规则,并拒绝纯数字/单字符等弱填写 */ + private String validateInviteName(String name) { + String nameErr = PersonNameUtils.validateOrNull(name, "姓名"); + if (nameErr != null) { + return nameErr; + } + String normalized = PersonNameUtils.normalize(name); + if (normalized.length() < 2) { + return "姓名至少2个字符"; + } + if (!normalized.matches(".*[\\u4E00-\\u9FA5A-Za-z].*")) { + return "姓名需包含中文或英文字母"; + } + return null; + } + + /** + * 当前部门有绑定商户则用;否则沿上级链路只匹配 is_unit=1 的单位部门(禁止水平匹配同级)。 + */ + private MchManageVo resolveMerchantAlongUnitChain(Long deptId) { + List chain = sysDeptService.getParentDeptList(deptId); + if (CollUtil.isEmpty(chain)) { + return null; + } + for (int i = 0; i < chain.size(); i++) { + SysDept dept = chain.get(i); + if (dept == null || dept.getDeptId() == null) { + continue; + } + // 首个为邀请部门本身;其后仅单位部门可参与匹配 + if (i > 0 && !Integer.valueOf(1).equals(dept.getIsUnit())) { + continue; + } + R> resp = remoteMchService.getByDeptIds(List.of(dept.getDeptId())); + if (resp != null && resp.isOk() && CollUtil.isNotEmpty(resp.getData())) { + return resp.getData().get(0); + } + } + return null; + } + + private SysDeptInviteEntity requireValidInvite(String token) { + if (StrUtil.isBlank(token)) { + throw new IllegalArgumentException("邀请码无效"); + } + SysDeptInviteEntity invite = getOne(Wrappers.lambdaQuery() + .eq(SysDeptInviteEntity::getToken, token.trim()) + .last("LIMIT 1")); + if (invite == null) { + throw new IllegalArgumentException("邀请码错误或已失效"); + } + if (!"1".equals(invite.getEnabled())) { + throw new IllegalArgumentException("该邀请入口已停用"); + } + LocalDateTime now = LocalDateTime.now(); + if (invite.getValidFrom() != null && invite.getValidFrom().isAfter(now)) { + throw new IllegalArgumentException("邀请尚未生效"); + } + if (invite.getExpireTime() != null && invite.getExpireTime().isBefore(now)) { + throw new IllegalArgumentException("邀请码已过期"); + } + return invite; + } + + private void validateExpireRange(LocalDateTime validFrom, LocalDateTime expireTime) { + if (validFrom != null && expireTime != null && !expireTime.isAfter(validFrom)) { + throw new IllegalArgumentException("过期时间必须晚于生效时间"); + } + } + + private String normalizeTokenType(String tokenType) { + String type = StrUtil.blankToDefault(tokenType, TOKEN_UUID).trim().toUpperCase(); + if (TOKEN_DIGIT6.equals(type) || TOKEN_DIGIT8.equals(type) || TOKEN_UUID.equals(type)) { + return type; + } + throw new IllegalArgumentException("不支持的邀请码类型,请选择 6位数字 / 8位数字 / UUID"); + } + + /** + * 按类型生成唯一邀请码;短数字码碰撞时重试,防止唯一索引冲突。 + */ + private String generateUniqueToken(String tokenType, Long excludeId) { + for (int i = 0; i < 32; i++) { + String token = switch (tokenType) { + case TOKEN_DIGIT6 -> RandomUtil.randomNumbers(6); + case TOKEN_DIGIT8 -> RandomUtil.randomNumbers(8); + default -> IdUtil.fastSimpleUUID(); + }; + boolean exists = count(Wrappers.lambdaQuery() + .eq(SysDeptInviteEntity::getToken, token) + .ne(excludeId != null, SysDeptInviteEntity::getId, excludeId)) > 0; + if (!exists) { + return token; + } + } + throw new IllegalArgumentException("邀请码生成失败,请重试"); + } +} diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java index 524a8148..778218cf 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java @@ -1163,7 +1163,15 @@ public class SysUserServiceImpl extends ServiceImpl impl updateUser.setUserId(detail.getUserId()); updateUser.setProcessStatus(processStatus); updateUser.setProcessTime(LocalDateTime.now()); + if (isConsent) { + // 邀请注册等场景可能曾锁定;审批通过后必须恢复可登录,并清掉用户缓存 + updateUser.setLockFlag(UserStateEnum.NORMAL.getCode()); + } baseMapper.updateById(updateUser); + Cache cache = cacheManager.getCache(CacheConstants.USER_DETAILS); + if (Objects.nonNull(cache)) { + cache.evict(detail.getUsername()); + } if (isConsent) { // 创建用户默认钱包 remoteWalletService.createWallet(detail.getUserId()); diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/utils/flow/FlowUtil.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/utils/flow/FlowUtil.java index 020823c8..9a8c175f 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/utils/flow/FlowUtil.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/utils/flow/FlowUtil.java @@ -16,6 +16,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.bsd.pjcloud.admin.api.entity.SysUser; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.data.resolver.ParamResolver; +import com.bsd.pjcloud.common.security.util.SecurityUtils; import com.bsd.pjcloud.flow.task.api.feign.RemoteFlowApiFlowService; import com.bsd.pjcloud.flow.task.api.feign.RemoteFlowEngineService; import com.bsd.pjcloud.flow.task.constant.NodeUserTypeEnum; @@ -58,8 +59,11 @@ public class FlowUtil { log.error("|{}|请配置跨单位审批流程ID;userId:|{}|businessId:|{}|", TAG, user.getUserId(), businessId); throw new RuntimeException("请配置跨单位审批流程ID"); } - R detail = flowApiFlowService.getProcessByBusinessKey(defaultRole); - if (Optional.of(detail.getData()).map(Process::getId).isEmpty()) { + // 公开接口无登录态时走内部 Feign(@NoToken + @Inner),避免 424 + boolean innerCall = SecurityUtils.getUser() == null; + R detail = innerCall ? flowApiFlowService.getInnerProcessByBusinessKey(defaultRole) + : flowApiFlowService.getProcessByBusinessKey(defaultRole); + if (Optional.ofNullable(detail).map(R::getData).map(Process::getId).isEmpty()) { log.error("|{}|未查询到该审批流程;userId:|{}|businessId:|{}|", TAG, user.getUserId(), businessId); throw new RuntimeException("未查询到该审批流程"); } @@ -85,7 +89,13 @@ public class FlowUtil { processDto.setFlowId(processVo.getFlowId()); processDto.setParamMap(paramMap); processDto.setStartUserId(String.valueOf(user.getUserId())); - R startVo = flowApiFlowService.startProcessInstance(processDto); + R startVo = innerCall ? flowApiFlowService.startInnerProcessInstance(processDto) + : flowApiFlowService.startProcessInstance(processDto); + if (startVo == null || !startVo.isOk() || ObjectUtil.isEmpty(startVo.getData())) { + log.error("|{}|启动审批流程失败;userId:|{}|businessId:|{}|resp:|{}|", TAG, user.getUserId(), businessId, + startVo); + throw new RuntimeException(Optional.ofNullable(startVo).map(R::getMsg).orElse("启动审批流程失败")); + } return startVo.getData(); } diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/resources/application.yml b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/application.yml index ea0bfaf4..0a91b09f 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/resources/application.yml +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/application.yml @@ -40,4 +40,11 @@ security: encrypt: enable: true # 开启请求加解密 default-encrypt-type: AES # 可选:AES、SM4,默认 AES - aes-key: pigxpigxpigxpigx # AES 密钥必须为 16 位 \ No newline at end of file + aes-key: pigxpigxpigxpigx # AES 密钥必须为 16 位 + oauth2: + client: + # 公开接口兜底白名单(与 @Inner(false) 双保险;Nacos 刷新时不会丢) + ignore-urls: + - /dept/invite/preview + - /dept/invite/register + - /druid/** \ No newline at end of file diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchManageEntity.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchManageEntity.java index c818e448..4e76055e 100644 --- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchManageEntity.java +++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/entity/mch/MchManageEntity.java @@ -63,6 +63,12 @@ public class MchManageEntity extends Model { @Schema(description = "店铺类型,逗号分隔") private String types; + /** + * 商户属性:NORMAL普通/SCHOOL学校/GOVERNMENT政府/FACTORY工厂 + */ + @Schema(description = "商户属性") + private String merchantAttr; + /** * 食堂是否对外营业 */