This commit is contained in:
dzc committed 2026-09-08 18:28:34 +08:00
commit 4e7daed364
31 files changed
+1372 -61

No files matched your search

@@ -47,6 +47,9 @@ security:
- /druid/**
- /actuator/**
- /code/**
- /dept/invite/preview
- /dept/invite/register
- /register/**
#--------------如下配置尽量不要变动-------------
# mybatis-plus 配置
@@ -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";
}
}
@@ -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<String> 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, "*")));
}
}
@@ -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<? extends GrantedAuthority> 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(),
@@ -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";
@@ -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
@@ -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
@@ -7,6 +7,8 @@ AbstractUserDetailsAuthenticationProvider.credentialsExpired=用戶憑證已過
OAuth2ResourceOwnerBaseAuthenticationProvider.tokenExpired=用戶憑證已過期
AbstractUserDetailsAuthenticationProvider.disabled=用戶未啟用
AbstractUserDetailsAuthenticationProvider.locked=用戶帳號已鎖定
AbstractUserDetailsAuthenticationProvider.pendingAudit=帳號審核中,請等待審批通過後再登入
AbstractUserDetailsAuthenticationProvider.auditRejected=帳號審核未通過,無法登入
AbstractUserDetailsAuthenticationProvider.expired=用戶帳號已過期
JdbcDaoImpl.notFound=未找到用戶{0}
UserDetailsService.notFound=用戶不存在
@@ -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<List<FormGroupVo>> listCurrentUserStartGroup();
/**
* 查询所有我可以发起的表单
* @return R
*/
@GetMapping("/combination/group/listCurrentUserStartGroup")
R<List<FormGroupVo>> listCurrentUserStartGroup();
/**
* 获取流程详情
*
* @param flowId 流程id
* @return R
*/
@GetMapping("/process/getDetail")
R<ProcessVO> getDetail(@RequestParam("flowId") String flowId);
/**
* 获取流程详情
* @param flowId 流程id
* @return R
*/
@GetMapping("/process/getDetail")
R<ProcessVO> getDetail(@RequestParam("flowId") String flowId);
/**
* 启动流程实例
* @param paramDto param dto
* @return 流程实例ID
*/
@PostMapping("/process-instance/startProcessInstance")
R<String> startProcessInstance(@RequestBody ProcessInstanceParamDto paramDto);
/**
* 启动流程实例
*
* @param paramDto param dto
* @return {@link R } 流程实例ID {@link String}
*/
@PostMapping("/process-instance/startProcessInstance")
R<String> startProcessInstance(@RequestBody ProcessInstanceParamDto paramDto);
/**
* 内部启动流程实例(无登录态 / 公开业务回调场景)
*/
@NoToken
@PostMapping("/process-instance/startInnerProcessInstance")
R<String> startInnerProcessInstance(@RequestBody ProcessInstanceParamDto paramDto);
/**
* 查询当前用户的代办任务
*
* @param queryParamDto
* @return {@link R } 流程实例ID {@link String}
*/
@PostMapping("/process-instance/queryMineTask")
R<Page<TaskDto>> queryMineTask(@RequestBody TaskQueryParamDto queryParamDto);
/**
* 查询当前用户的代办任务
*/
@PostMapping("/process-instance/queryMineTask")
R<Page<TaskDto>> queryMineTask(@RequestBody TaskQueryParamDto queryParamDto);
/**
* 查询当前用户的已任务
*
* @param queryParamDto
* @return {@link R } 流程实例ID {@link String}
*/
@PostMapping("/process-instance/queryMineEndTask")
R<Page<TaskDto>> queryMineEndTask(@RequestBody TaskQueryParamDto queryParamDto);
/**
* 查询当前用户的已办任务
*/
@PostMapping("/process-instance/queryMineEndTask")
R<Page<TaskDto>> queryMineEndTask(@RequestBody TaskQueryParamDto queryParamDto);
/**
* 获取流程数据
* @param businessKey 业务key
* @return
*/
@GetMapping("/process/getProcessByBusinessKey")
R<Process> getProcessByBusinessKey(@RequestParam("businessKey")String businessKey);
/**
* 获取流程数据
* @param businessKey 业务key
*/
@GetMapping("/process/getProcessByBusinessKey")
R<Process> getProcessByBusinessKey(@RequestParam("businessKey") String businessKey);
/**
* 内部获取流程数据(无登录态)
*/
@NoToken
@GetMapping("/process/getInnerProcessByBusinessKey")
R<Process> getInnerProcessByBusinessKey(@RequestParam("businessKey") String businessKey);
}
@@ -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<Process> getProcessByBusinessKey(@RequestParam("businessKey")String businessKey) {
return processService.getProcessByBusinessKey(businessKey);
}
/**
* 内部服务查询流程定义(免登录,需 FROM 内网标识)
*/
@Inner
@GetMapping("/getInnerProcessByBusinessKey")
public R<Process> getInnerProcessByBusinessKey(@RequestParam("businessKey") String businessKey) {
return processService.getProcessByBusinessKey(businessKey);
}
/**
* 创建流程
* @param process
@@ -106,11 +106,17 @@
<artifactId>pjcloud-common-seata</artifactId>
<version>5.7.0</version>
</dependency>
<!--必备: log 依赖-->
<!--必备: log 依赖 -->
<dependency>
<groupId>com.bsd</groupId>
<artifactId>pjcloud-common-log</artifactId>
</dependency>
<!-- App 安装包上传到宝塔等服务器(SFTP) -->
<dependency>
<groupId>com.jcraft</groupId>
<artifactId>jsch</artifactId>
<version>0.1.55</version>
</dependency>
<!--选配: mybatis 依赖 -->
<dependency>
<groupId>com.baomidou</groupId>
@@ -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<AppVersionEntity> 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<AppVersionEntity> 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<AppPackageUploadVo> 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版本控制表
*/
@@ -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);
}
@@ -26,4 +26,9 @@ public interface AppVersionService extends IService<AppVersionEntity> {
* @return 仅包含匹配终端的已发布版本
*/
Page<AppVersionVo> terminalPageList(Page page, AppVersionDto dto);
/**
* 安卓整包(.apk)最新已发布版本,供邀请下载二维码使用。
*/
AppVersionEntity latestAndroidApk();
}
@@ -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<String> 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;
}
}
@@ -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<AppVersionMapper, AppVers
.last("limit 1");
return baseMapper.selectOne(queryWrapper);
}
/**
* 取安卓平台最新已发布的整包安装地址(downloadUrl 以 .apk 结尾)。
* <p>排除 .wgt 热更补丁,供部门邀请下载二维码使用。</p>
*/
@Override
public AppVersionEntity latestAndroidApk() {
return baseMapper.selectOne(new LambdaQueryWrapper<AppVersionEntity>()
.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"));
}
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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<SysDeptInviteEntity> {
@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;
}
@@ -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;
}
@@ -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;
}
@@ -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<SysDeptInviteEntity> 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<SysDeptInviteEntity> getByDept(@PathVariable Long deptId) {
return R.ok(deptInviteService.getByDeptId(deptId));
}
@Operation(summary = "部门邀请分页列表")
@GetMapping("/page")
@PreAuthorize("@pms.hasPermission('sys_dept_edit')")
public R<IPage<SysDeptInviteListVO>> 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<Boolean> remove(@PathVariable Long id) {
try {
return R.ok(deptInviteService.removeInvite(id));
}
catch (IllegalArgumentException ex) {
return R.failed(ex.getMessage());
}
}
}
@@ -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<SysDeptInvitePreviewVO> 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<Boolean> register(@RequestBody DeptInviteRegisterDTO dto) {
try {
return deptInviteService.register(dto);
}
catch (IllegalArgumentException ex) {
return R.failed(ex.getMessage());
}
}
}
@@ -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<SysDeptInviteEntity> {
}
@@ -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<SysDeptInviteListVO> pageList(Page<?> page, String deptName, String token, String enabled);
/** 删除邀请配置(逻辑删除) */
boolean removeInvite(Long id);
SysDeptInvitePreviewVO preview(String token);
R<Boolean> register(DeptInviteRegisterDTO dto);
}
@@ -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<SysDeptInviteMapper, SysDeptInviteEntity>
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.<SysDeptInviteEntity>lambdaQuery()
.eq(SysDeptInviteEntity::getDeptId, deptId)
.last("LIMIT 1"));
}
@Override
public IPage<SysDeptInviteListVO> pageList(Page<?> page, String deptName, String token, String enabled) {
List<Long> deptIds = null;
if (StrUtil.isNotBlank(deptName)) {
List<SysDept> depts = sysDeptService.list(Wrappers.<SysDept>lambdaQuery()
.like(SysDept::getName, deptName.trim()));
if (CollUtil.isEmpty(depts)) {
Page<SysDeptInviteListVO> 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<SysDeptInviteEntity> entityPage = page(new Page<>(page.getCurrent(), page.getSize()),
Wrappers.<SysDeptInviteEntity>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<SysDeptInviteEntity> records = entityPage.getRecords();
Map<Long, String> deptNameMap = new HashMap<>();
if (CollUtil.isNotEmpty(records)) {
List<Long> 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<Long, String> finalDeptNameMap = deptNameMap;
Page<SysDeptInviteListVO> 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<Boolean> 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.<SysUser>lambdaQuery().eq(SysUser::getUsername, username));
if (usernameExists) {
return R.failed(MsgUtils.getMessage(ErrorCodes.SYS_USER_USERNAME_EXISTING, username));
}
boolean phoneExists = sysUserMapper.exists(Wrappers.<SysUser>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<SysRole> roleList = sysRoleService.list();
String walletAutoCreate = ParamResolver.getStr("WALLET_AUTO_CREATE");
String userDefaultRole = ParamResolver.getStr("USER_DEFAULT_ROLE");
List<Long> 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<SysDept> 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<List<MchManageVo>> 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.<SysDeptInviteEntity>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.<SysDeptInviteEntity>lambdaQuery()
.eq(SysDeptInviteEntity::getToken, token)
.ne(excludeId != null, SysDeptInviteEntity::getId, excludeId)) > 0;
if (!exists) {
return token;
}
}
throw new IllegalArgumentException("邀请码生成失败,请重试");
}
}
@@ -1163,7 +1163,15 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> 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());
@@ -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<Process> detail = flowApiFlowService.getProcessByBusinessKey(defaultRole);
if (Optional.of(detail.getData()).map(Process::getId).isEmpty()) {
// 公开接口无登录态时走内部 Feign(@NoToken + @Inner),避免 424
boolean innerCall = SecurityUtils.getUser() == null;
R<Process> 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<String> startVo = flowApiFlowService.startProcessInstance(processDto);
R<String> 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();
}
@@ -40,4 +40,11 @@ security:
encrypt:
enable: true # 开启请求加解密
default-encrypt-type: AES # 可选:AES、SM4,默认 AES
aes-key: pigxpigxpigxpigx # AES 密钥必须为 16 位
aes-key: pigxpigxpigxpigx # AES 密钥必须为 16 位
oauth2:
client:
# 公开接口兜底白名单(与 @Inner(false) 双保险;Nacos 刷新时不会丢)
ignore-urls:
- /dept/invite/preview
- /dept/invite/register
- /druid/**
@@ -63,6 +63,12 @@ public class MchManageEntity extends Model<MchManageEntity> {
@Schema(description = "店铺类型,逗号分隔")
private String types;
/**
* 商户属性:NORMAL普通/SCHOOL学校/GOVERNMENT政府/FACTORY工厂
*/
@Schema(description = "商户属性")
private String merchantAttr;
/**
* 食堂是否对外营业
*/