From 5d3c7d8692b26aaf34ebd56ea8326f2bb6fa91b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=AD=90=E9=BB=98?= <925456043@qq.com> Date: Wed, 18 Mar 2026 21:56:05 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E8=B4=A6=E5=8F=B7?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E4=B8=8E=E5=BC=BA=E5=88=B6=E6=94=B9=E5=AF=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增账号导入模板下载、导入校验和默认密码重置标记 - 支持管理员重置密码并在登录后强制跳转修改密码 - 管理端与用户中心接入强密码校验和密码重置流程 --- .../system/SysAccountController.java | 66 ++- .../system/UcSysAccountController.java | 5 +- .../tech/easyflow/auth/entity/LoginVO.java | 13 + .../auth/service/impl/AuthServiceImpl.java | 1 + .../system/entity/base/SysAccountBase.java | 14 + .../entity/vo/SysAccountImportErrorRowVo.java | 47 ++ .../entity/vo/SysAccountImportResultVo.java | 50 ++ .../system/service/SysAccountService.java | 12 + .../service/impl/SysAccountServiceImpl.java | 530 +++++++++++++++++- .../system/util/SysPasswordPolicy.java | 30 + .../system/util/SysPasswordPolicyTest.java | 20 + easyflow-ui-admin/app/src/api/core/auth.ts | 1 + .../src/locales/langs/en-US/sysAccount.json | 20 +- .../src/locales/langs/zh-CN/sysAccount.json | 20 +- easyflow-ui-admin/app/src/router/guard.ts | 19 +- easyflow-ui-admin/app/src/store/auth.ts | 26 +- .../app/src/utils/password-policy.ts | 6 + .../app/src/utils/password-reset.ts | 52 ++ .../app/src/views/_core/profile/index.vue | 54 +- .../views/_core/profile/password-setting.vue | 40 +- .../sysAccount/SysAccountImportModal.vue | 362 ++++++++++++ .../system/sysAccount/SysAccountList.vue | 67 ++- .../system/sysAccount/SysAccountModal.vue | 75 ++- easyflow-ui-admin/packages/types/src/user.ts | 5 + .../app/src/api/core/auth.ts | 1 + .../app/src/api/core/user.ts | 2 +- .../src/locales/langs/en-US/sysAccount.json | 4 +- .../src/locales/langs/zh-CN/sysAccount.json | 4 +- .../app/src/router/guard.ts | 19 +- easyflow-ui-usercenter/app/src/store/auth.ts | 92 +-- .../app/src/utils/password-policy.ts | 6 + .../app/src/utils/password-reset.ts | 52 ++ .../app/src/views/_core/profile/index.vue | 66 ++- .../views/_core/profile/password-setting.vue | 68 ++- .../packages/types/src/user.ts | 5 + sql/01-easyflow-v2.ddl.sql | 1 + sql/02-easyflow-v2.data.sql | 2 +- ...-easyflow-v2.p2-account-password-reset.sql | 2 + sql/initdb/01-easyflow-v2.ddl.sql | 1 + sql/initdb/02-easyflow-v2.data.sql | 2 +- 40 files changed, 1720 insertions(+), 142 deletions(-) create mode 100644 easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/entity/vo/SysAccountImportErrorRowVo.java create mode 100644 easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/entity/vo/SysAccountImportResultVo.java create mode 100644 easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/util/SysPasswordPolicy.java create mode 100644 easyflow-modules/easyflow-module-system/src/test/java/tech/easyflow/system/util/SysPasswordPolicyTest.java create mode 100644 easyflow-ui-admin/app/src/utils/password-policy.ts create mode 100644 easyflow-ui-admin/app/src/utils/password-reset.ts create mode 100644 easyflow-ui-admin/app/src/views/system/sysAccount/SysAccountImportModal.vue create mode 100644 easyflow-ui-usercenter/app/src/utils/password-policy.ts create mode 100644 easyflow-ui-usercenter/app/src/utils/password-reset.ts create mode 100644 sql/05-easyflow-v2.p2-account-password-reset.sql diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/system/SysAccountController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/system/SysAccountController.java index ca98c61..ad1b4e8 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/system/SysAccountController.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/system/SysAccountController.java @@ -1,27 +1,32 @@ package tech.easyflow.admin.controller.system; -import tech.easyflow.common.constant.enums.EnumAccountType; -import tech.easyflow.common.constant.enums.EnumDataStatus; -import tech.easyflow.common.domain.Result; -import tech.easyflow.common.entity.LoginAccount; -import tech.easyflow.common.util.StringUtil; - -import tech.easyflow.common.web.controller.BaseCurdController; -import tech.easyflow.common.web.jsonbody.JsonBody; -import tech.easyflow.log.annotation.LogRecord; -import tech.easyflow.system.entity.SysAccount; -import tech.easyflow.system.service.SysAccountService; -import tech.easyflow.common.satoken.util.SaTokenUtil; +import cn.dev33.satoken.annotation.SaCheckPermission; import cn.dev33.satoken.stp.StpUtil; import cn.hutool.crypto.digest.BCrypt; import com.mybatisflex.core.paginate.Page; import com.mybatisflex.core.query.QueryWrapper; +import jakarta.servlet.http.HttpServletResponse; import org.springframework.dao.DuplicateKeyException; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; +import tech.easyflow.common.constant.enums.EnumAccountType; +import tech.easyflow.common.constant.enums.EnumDataStatus; +import tech.easyflow.common.domain.Result; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.satoken.util.SaTokenUtil; +import tech.easyflow.common.util.StringUtil; +import tech.easyflow.common.web.controller.BaseCurdController; +import tech.easyflow.common.web.jsonbody.JsonBody; +import tech.easyflow.log.annotation.LogRecord; +import tech.easyflow.system.entity.SysAccount; +import tech.easyflow.system.entity.vo.SysAccountImportResultVo; +import tech.easyflow.system.service.SysAccountService; +import tech.easyflow.system.util.SysPasswordPolicy; +import java.net.URLEncoder; import java.io.Serializable; import java.math.BigInteger; import java.util.Collection; @@ -62,13 +67,21 @@ public class SysAccountController extends BaseCurdController resetPassword(@JsonBody(value = "id", required = true) BigInteger id) { + service.resetPassword(id, SaTokenUtil.getLoginAccount().getId()); + return Result.ok(); + } + + @PostMapping("/importExcel") + @SaCheckPermission("/api/v1/sysAccount/save") + public Result importExcel(MultipartFile file) { + return Result.ok(service.importAccounts(file, SaTokenUtil.getLoginAccount())); + } + + @GetMapping("/downloadImportTemplate") + @SaCheckPermission("/api/v1/sysAccount/query") + public void downloadImportTemplate(HttpServletResponse response) throws Exception { + response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + response.setCharacterEncoding("utf-8"); + String fileName = URLEncoder.encode("user_import_template", "UTF-8").replaceAll("\\+", "%20"); + response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + fileName + ".xlsx"); + service.writeImportTemplate(response.getOutputStream()); + } + @Override @PostMapping("save") public Result save(@JsonBody SysAccount entity) { diff --git a/easyflow-api/easyflow-api-usercenter/src/main/java/tech/easyflow/usercenter/controller/system/UcSysAccountController.java b/easyflow-api/easyflow-api-usercenter/src/main/java/tech/easyflow/usercenter/controller/system/UcSysAccountController.java index bd5341f..46e66e9 100644 --- a/easyflow-api/easyflow-api-usercenter/src/main/java/tech/easyflow/usercenter/controller/system/UcSysAccountController.java +++ b/easyflow-api/easyflow-api-usercenter/src/main/java/tech/easyflow/usercenter/controller/system/UcSysAccountController.java @@ -11,6 +11,7 @@ import tech.easyflow.common.satoken.util.SaTokenUtil; import tech.easyflow.common.web.jsonbody.JsonBody; import tech.easyflow.system.entity.SysAccount; import tech.easyflow.system.service.SysAccountService; +import tech.easyflow.system.util.SysPasswordPolicy; import javax.annotation.Resource; import java.math.BigInteger; @@ -80,12 +81,14 @@ public class UcSysAccountController { if (!newPassword.equals(confirmPassword)) { return Result.fail(2, "两次密码不一致"); } + SysPasswordPolicy.validateStrongPassword(newPassword); SysAccount update = new SysAccount(); update.setId(loginAccountId); update.setPassword(BCrypt.hashpw(newPassword)); + update.setPasswordResetRequired(false); update.setModified(new Date()); update.setModifiedBy(loginAccountId); service.updateById(update); return Result.ok(); } -} \ No newline at end of file +} diff --git a/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/entity/LoginVO.java b/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/entity/LoginVO.java index 1442534..3218b63 100644 --- a/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/entity/LoginVO.java +++ b/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/entity/LoginVO.java @@ -15,6 +15,11 @@ public class LoginVO { */ private String avatar; + /** + * 是否强制修改密码 + */ + private boolean forceChangePassword; + public String getToken() { return token; } @@ -38,4 +43,12 @@ public class LoginVO { public void setAvatar(String avatar) { this.avatar = avatar; } + + public boolean isForceChangePassword() { + return forceChangePassword; + } + + public void setForceChangePassword(boolean forceChangePassword) { + this.forceChangePassword = forceChangePassword; + } } diff --git a/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/service/impl/AuthServiceImpl.java b/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/service/impl/AuthServiceImpl.java index 7f7bb77..9361944 100644 --- a/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/service/impl/AuthServiceImpl.java +++ b/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/service/impl/AuthServiceImpl.java @@ -88,6 +88,7 @@ public class AuthServiceImpl implements AuthService, StpInterface { res.setToken(StpUtil.getTokenValue()); res.setNickname(record.getNickname()); res.setAvatar(record.getAvatar()); + res.setForceChangePassword(Boolean.TRUE.equals(record.getPasswordResetRequired())); return res; } diff --git a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/entity/base/SysAccountBase.java b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/entity/base/SysAccountBase.java index d15c3c2..20264c8 100644 --- a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/entity/base/SysAccountBase.java +++ b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/entity/base/SysAccountBase.java @@ -43,6 +43,12 @@ public class SysAccountBase extends DateEntity implements Serializable { @Column(comment = "密码") private String password; + /** + * 是否需要重置密码 + */ + @Column(comment = "是否需要重置密码") + private Boolean passwordResetRequired; + /** * 账户类型 */ @@ -149,6 +155,14 @@ public class SysAccountBase extends DateEntity implements Serializable { this.password = password; } + public Boolean getPasswordResetRequired() { + return passwordResetRequired; + } + + public void setPasswordResetRequired(Boolean passwordResetRequired) { + this.passwordResetRequired = passwordResetRequired; + } + public Integer getAccountType() { return accountType; } diff --git a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/entity/vo/SysAccountImportErrorRowVo.java b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/entity/vo/SysAccountImportErrorRowVo.java new file mode 100644 index 0000000..b7c2686 --- /dev/null +++ b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/entity/vo/SysAccountImportErrorRowVo.java @@ -0,0 +1,47 @@ +package tech.easyflow.system.entity.vo; + +/** + * 用户导入失败行。 + */ +public class SysAccountImportErrorRowVo { + + private Integer rowNumber; + + private String deptCode; + + private String loginName; + + private String reason; + + public Integer getRowNumber() { + return rowNumber; + } + + public void setRowNumber(Integer rowNumber) { + this.rowNumber = rowNumber; + } + + public String getDeptCode() { + return deptCode; + } + + public void setDeptCode(String deptCode) { + this.deptCode = deptCode; + } + + public String getLoginName() { + return loginName; + } + + public void setLoginName(String loginName) { + this.loginName = loginName; + } + + public String getReason() { + return reason; + } + + public void setReason(String reason) { + this.reason = reason; + } +} diff --git a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/entity/vo/SysAccountImportResultVo.java b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/entity/vo/SysAccountImportResultVo.java new file mode 100644 index 0000000..b9a6094 --- /dev/null +++ b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/entity/vo/SysAccountImportResultVo.java @@ -0,0 +1,50 @@ +package tech.easyflow.system.entity.vo; + +import java.util.ArrayList; +import java.util.List; + +/** + * 用户导入结果。 + */ +public class SysAccountImportResultVo { + + private int successCount = 0; + + private int errorCount = 0; + + private int totalCount = 0; + + private List errorRows = new ArrayList<>(); + + public int getSuccessCount() { + return successCount; + } + + public void setSuccessCount(int successCount) { + this.successCount = successCount; + } + + public int getErrorCount() { + return errorCount; + } + + public void setErrorCount(int errorCount) { + this.errorCount = errorCount; + } + + public int getTotalCount() { + return totalCount; + } + + public void setTotalCount(int totalCount) { + this.totalCount = totalCount; + } + + public List getErrorRows() { + return errorRows; + } + + public void setErrorRows(List errorRows) { + this.errorRows = errorRows; + } +} diff --git a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/SysAccountService.java b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/SysAccountService.java index 6c064df..c899a5b 100644 --- a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/SysAccountService.java +++ b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/SysAccountService.java @@ -1,7 +1,13 @@ package tech.easyflow.system.service; import com.mybatisflex.core.service.IService; +import org.springframework.web.multipart.MultipartFile; +import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.system.entity.SysAccount; +import tech.easyflow.system.entity.vo.SysAccountImportResultVo; + +import java.io.OutputStream; +import java.math.BigInteger; /** * 用户表 服务层。 @@ -14,4 +20,10 @@ public interface SysAccountService extends IService { void syncRelations(SysAccount entity); SysAccount getByUsername(String userKey); + + void resetPassword(BigInteger accountId, BigInteger operatorId); + + SysAccountImportResultVo importAccounts(MultipartFile file, LoginAccount loginAccount); + + void writeImportTemplate(OutputStream outputStream); } diff --git a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/impl/SysAccountServiceImpl.java b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/impl/SysAccountServiceImpl.java index 88d7e1e..824e587 100644 --- a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/impl/SysAccountServiceImpl.java +++ b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/impl/SysAccountServiceImpl.java @@ -1,25 +1,54 @@ package tech.easyflow.system.service.impl; +import cn.dev33.satoken.stp.StpUtil; +import cn.hutool.crypto.digest.BCrypt; +import cn.idev.excel.EasyExcel; +import cn.idev.excel.FastExcel; +import cn.idev.excel.context.AnalysisContext; +import cn.idev.excel.metadata.data.ReadCellData; +import cn.idev.excel.read.listener.ReadListener; import com.mybatisflex.core.query.QueryWrapper; import com.mybatisflex.spring.service.impl.ServiceImpl; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.web.multipart.MultipartFile; import tech.easyflow.common.cache.RedisLockExecutor; +import tech.easyflow.common.constant.enums.EnumAccountType; +import tech.easyflow.common.constant.enums.EnumDataStatus; +import tech.easyflow.common.entity.LoginAccount; +import tech.easyflow.common.util.StringUtil; +import tech.easyflow.common.web.exceptions.BusinessException; import tech.easyflow.system.entity.SysAccount; import tech.easyflow.system.entity.SysAccountPosition; import tech.easyflow.system.entity.SysAccountRole; +import tech.easyflow.system.entity.SysDept; +import tech.easyflow.system.entity.SysPosition; +import tech.easyflow.system.entity.SysRole; +import tech.easyflow.system.entity.vo.SysAccountImportErrorRowVo; +import tech.easyflow.system.entity.vo.SysAccountImportResultVo; import tech.easyflow.system.mapper.SysAccountMapper; import tech.easyflow.system.mapper.SysAccountPositionMapper; import tech.easyflow.system.mapper.SysAccountRoleMapper; +import tech.easyflow.system.mapper.SysDeptMapper; +import tech.easyflow.system.mapper.SysPositionMapper; import tech.easyflow.system.mapper.SysRoleMapper; import tech.easyflow.system.service.SysAccountService; import javax.annotation.Resource; -import java.time.Duration; +import java.io.InputStream; +import java.io.OutputStream; import java.math.BigInteger; +import java.time.Duration; import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; import java.util.Set; /** @@ -34,6 +63,18 @@ public class SysAccountServiceImpl extends ServiceImpl { - //sync roleIds List roleIds = entity.getRoleIds(); if (roleIds != null) { QueryWrapper delW = QueryWrapper.create(); @@ -79,7 +125,6 @@ public class SysAccountServiceImpl extends ServiceImpl positionIds = entity.getPositionIds(); if (positionIds != null) { QueryWrapper delW = QueryWrapper.create(); @@ -112,4 +157,483 @@ public class SysAccountServiceImpl extends ServiceImpl rows = parseImportRows(file); + SysAccountImportResultVo result = new SysAccountImportResultVo(); + result.setTotalCount(rows.size()); + if (rows.isEmpty()) { + return result; + } + + Map deptMap = buildDeptCodeMap(); + Map roleMap = buildRoleKeyMap(); + Map positionMap = buildPositionCodeMap(); + for (SysAccountImportRow row : rows) { + try { + executeInRowTransaction(() -> importSingleRow(row, loginAccount, deptMap, roleMap, positionMap)); + result.setSuccessCount(result.getSuccessCount() + 1); + } catch (Exception e) { + result.setErrorCount(result.getErrorCount() + 1); + appendImportError(result, row, extractImportErrorMessage(e)); + } + } + return result; + } + + @Override + public void writeImportTemplate(OutputStream outputStream) { + EasyExcel.write(outputStream) + .head(buildImportHeadList()) + .sheet("模板") + .doWrite(new ArrayList<>()); + } + + private void executeInRowTransaction(Runnable action) { + TransactionTemplate template = new TransactionTemplate(transactionManager); + template.executeWithoutResult(status -> { + try { + action.run(); + } catch (RuntimeException e) { + status.setRollbackOnly(); + throw e; + } + }); + } + + private void importSingleRow(SysAccountImportRow row, + LoginAccount loginAccount, + Map deptMap, + Map roleMap, + Map positionMap) { + String deptCode = trimToNull(row.getDeptCode()); + String loginName = trimToNull(row.getLoginName()); + String nickname = trimToNull(row.getNickname()); + if (deptCode == null) { + throw new BusinessException("部门编码不能为空"); + } + if (loginName == null) { + throw new BusinessException("登录账号不能为空"); + } + if (nickname == null) { + throw new BusinessException("昵称不能为空"); + } + SysDept dept = deptMap.get(deptCode); + if (dept == null) { + throw new BusinessException("部门编码不存在: " + deptCode); + } + ensureLoginNameNotExists(loginName); + + List roleIds = resolveRoleIds(row.getRoleKeys(), roleMap); + List positionIds = resolvePositionIds(row.getPositionCodes(), positionMap); + + SysAccount entity = new SysAccount(); + entity.setDeptId(dept.getId()); + entity.setTenantId(loginAccount.getTenantId()); + entity.setLoginName(loginName); + entity.setPassword(BCrypt.hashpw(DEFAULT_RESET_PASSWORD)); + entity.setPasswordResetRequired(true); + entity.setAccountType(EnumAccountType.NORMAL.getCode()); + entity.setNickname(nickname); + entity.setMobile(trimToNull(row.getMobile())); + entity.setEmail(trimToNull(row.getEmail())); + entity.setStatus(parseStatus(row.getStatus())); + entity.setRemark(trimToNull(row.getRemark())); + entity.setCreated(new Date()); + entity.setCreatedBy(loginAccount.getId()); + entity.setModified(new Date()); + entity.setModifiedBy(loginAccount.getId()); + entity.setRoleIds(roleIds); + entity.setPositionIds(positionIds); + save(entity); + syncRelations(entity); + } + + private void ensureLoginNameNotExists(String loginName) { + QueryWrapper wrapper = QueryWrapper.create(); + wrapper.eq(SysAccount::getLoginName, loginName); + if (count(wrapper) > 0) { + throw new BusinessException("登录账号已存在: " + loginName); + } + } + + private Integer parseStatus(String rawStatus) { + String status = trimToNull(rawStatus); + if (status == null) { + return EnumDataStatus.AVAILABLE.getCode(); + } + if ("1".equals(status) || "已启用".equals(status) || "启用".equals(status)) { + return EnumDataStatus.AVAILABLE.getCode(); + } + if ("0".equals(status) || "未启用".equals(status) || "停用".equals(status) || "禁用".equals(status)) { + return EnumDataStatus.UNAVAILABLE.getCode(); + } + throw new BusinessException("状态不合法,仅支持 1/0 或 已启用/未启用"); + } + + private List resolveRoleIds(String roleKeysText, Map roleMap) { + List roleKeys = splitCodes(roleKeysText); + if (roleKeys.isEmpty()) { + return Collections.emptyList(); + } + List roleIds = new ArrayList<>(roleKeys.size()); + for (String roleKey : roleKeys) { + SysRole role = roleMap.get(roleKey); + if (role == null) { + throw new BusinessException("角色编码不存在: " + roleKey); + } + roleIds.add(role.getId()); + } + return roleIds; + } + + private List resolvePositionIds(String positionCodesText, Map positionMap) { + List positionCodes = splitCodes(positionCodesText); + if (positionCodes.isEmpty()) { + return Collections.emptyList(); + } + List positionIds = new ArrayList<>(positionCodes.size()); + for (String positionCode : positionCodes) { + SysPosition position = positionMap.get(positionCode); + if (position == null) { + throw new BusinessException("岗位编码不存在: " + positionCode); + } + positionIds.add(position.getId()); + } + return positionIds; + } + + private List splitCodes(String rawCodes) { + String codes = trimToNull(rawCodes); + if (codes == null) { + return Collections.emptyList(); + } + String[] values = codes.split("[,,]"); + List result = new ArrayList<>(); + Set uniqueValues = new LinkedHashSet<>(); + for (String value : values) { + String trimmed = trimToNull(value); + if (trimmed != null && uniqueValues.add(trimmed)) { + result.add(trimmed); + } + } + return result; + } + + private Map buildDeptCodeMap() { + List deptList = sysDeptMapper.selectListByQuery(QueryWrapper.create()); + Map deptMap = new HashMap<>(); + for (SysDept dept : deptList) { + String deptCode = trimToNull(dept.getDeptCode()); + if (deptCode != null) { + deptMap.putIfAbsent(deptCode, dept); + } + } + return deptMap; + } + + private Map buildRoleKeyMap() { + List roleList = sysRoleMapper.selectListByQuery(QueryWrapper.create()); + Map roleMap = new HashMap<>(); + for (SysRole role : roleList) { + String roleKey = trimToNull(role.getRoleKey()); + if (roleKey != null) { + roleMap.putIfAbsent(roleKey, role); + } + } + return roleMap; + } + + private Map buildPositionCodeMap() { + List positionList = sysPositionMapper.selectListByQuery(QueryWrapper.create()); + Map positionMap = new HashMap<>(); + for (SysPosition position : positionList) { + String positionCode = trimToNull(position.getPositionCode()); + if (positionCode != null) { + positionMap.putIfAbsent(positionCode, position); + } + } + return positionMap; + } + + private List parseImportRows(MultipartFile file) { + try (InputStream inputStream = file.getInputStream()) { + SysAccountExcelReadListener listener = new SysAccountExcelReadListener(); + FastExcel.read(inputStream, listener) + .sheet() + .doRead(); + return listener.getRows(); + } catch (BusinessException e) { + throw e; + } catch (Exception e) { + throw new BusinessException("用户导入文件解析失败"); + } + } + + private void validateImportFile(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new BusinessException("导入文件不能为空"); + } + if (file.getSize() > MAX_IMPORT_FILE_SIZE_BYTES) { + throw new BusinessException("导入文件大小不能超过10MB"); + } + String fileName = file.getOriginalFilename(); + String lowerName = fileName == null ? "" : fileName.toLowerCase(); + if (!(lowerName.endsWith(".xlsx") || lowerName.endsWith(".xls"))) { + throw new BusinessException("仅支持 xlsx/xls 文件"); + } + } + + private void appendImportError(SysAccountImportResultVo result, SysAccountImportRow row, String reason) { + SysAccountImportErrorRowVo errorRow = new SysAccountImportErrorRowVo(); + errorRow.setRowNumber(row.getRowNumber()); + errorRow.setDeptCode(row.getDeptCode()); + errorRow.setLoginName(row.getLoginName()); + errorRow.setReason(reason); + result.getErrorRows().add(errorRow); + } + + private String extractImportErrorMessage(Exception e) { + if (e == null) { + return "导入失败"; + } + if (e.getCause() != null && StringUtil.hasText(e.getCause().getMessage())) { + return e.getCause().getMessage(); + } + if (StringUtil.hasText(e.getMessage())) { + return e.getMessage(); + } + return "导入失败"; + } + + private List> buildImportHeadList() { + List> headList = new ArrayList<>(9); + headList.add(Collections.singletonList(IMPORT_HEAD_DEPT_CODE)); + headList.add(Collections.singletonList(IMPORT_HEAD_LOGIN_NAME)); + headList.add(Collections.singletonList(IMPORT_HEAD_NICKNAME)); + headList.add(Collections.singletonList(IMPORT_HEAD_MOBILE)); + headList.add(Collections.singletonList(IMPORT_HEAD_EMAIL)); + headList.add(Collections.singletonList(IMPORT_HEAD_STATUS)); + headList.add(Collections.singletonList(IMPORT_HEAD_ROLE_KEYS)); + headList.add(Collections.singletonList(IMPORT_HEAD_POSITION_CODES)); + headList.add(Collections.singletonList(IMPORT_HEAD_REMARK)); + return headList; + } + + private String trimToNull(String value) { + if (!StringUtil.hasText(value)) { + return null; + } + return value.trim(); + } + + private static class SysAccountImportRow { + private Integer rowNumber; + private String deptCode; + private String loginName; + private String nickname; + private String mobile; + private String email; + private String status; + private String roleKeys; + private String positionCodes; + private String remark; + + public Integer getRowNumber() { + return rowNumber; + } + + public void setRowNumber(Integer rowNumber) { + this.rowNumber = rowNumber; + } + + public String getDeptCode() { + return deptCode; + } + + public void setDeptCode(String deptCode) { + this.deptCode = deptCode; + } + + public String getLoginName() { + return loginName; + } + + public void setLoginName(String loginName) { + this.loginName = loginName; + } + + public String getNickname() { + return nickname; + } + + public void setNickname(String nickname) { + this.nickname = nickname; + } + + public String getMobile() { + return mobile; + } + + public void setMobile(String mobile) { + this.mobile = mobile; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getRoleKeys() { + return roleKeys; + } + + public void setRoleKeys(String roleKeys) { + this.roleKeys = roleKeys; + } + + public String getPositionCodes() { + return positionCodes; + } + + public void setPositionCodes(String positionCodes) { + this.positionCodes = positionCodes; + } + + public String getRemark() { + return remark; + } + + public void setRemark(String remark) { + this.remark = remark; + } + } + + private class SysAccountExcelReadListener implements ReadListener> { + + private final Map headIndex = new HashMap<>(); + private final List rows = new ArrayList<>(); + private int sheetRowNo; + + @Override + public void invoke(LinkedHashMap data, AnalysisContext context) { + sheetRowNo++; + String deptCode = getCellValue(data, IMPORT_HEAD_DEPT_CODE); + String loginName = getCellValue(data, IMPORT_HEAD_LOGIN_NAME); + String nickname = getCellValue(data, IMPORT_HEAD_NICKNAME); + String mobile = getCellValue(data, IMPORT_HEAD_MOBILE); + String email = getCellValue(data, IMPORT_HEAD_EMAIL); + String status = getCellValue(data, IMPORT_HEAD_STATUS); + String roleKeys = getCellValue(data, IMPORT_HEAD_ROLE_KEYS); + String positionCodes = getCellValue(data, IMPORT_HEAD_POSITION_CODES); + String remark = getCellValue(data, IMPORT_HEAD_REMARK); + if (!StringUtil.hasText(deptCode) + && !StringUtil.hasText(loginName) + && !StringUtil.hasText(nickname) + && !StringUtil.hasText(mobile) + && !StringUtil.hasText(email) + && !StringUtil.hasText(status) + && !StringUtil.hasText(roleKeys) + && !StringUtil.hasText(positionCodes) + && !StringUtil.hasText(remark)) { + return; + } + if (rows.size() >= MAX_IMPORT_ROWS) { + throw new BusinessException("单次最多导入5000个用户"); + } + SysAccountImportRow row = new SysAccountImportRow(); + row.setRowNumber(sheetRowNo + 1); + row.setDeptCode(deptCode); + row.setLoginName(loginName); + row.setNickname(nickname); + row.setMobile(mobile); + row.setEmail(email); + row.setStatus(status); + row.setRoleKeys(roleKeys); + row.setPositionCodes(positionCodes); + row.setRemark(remark); + rows.add(row); + } + + @Override + public void invokeHead(Map> headMap, AnalysisContext context) { + for (Map.Entry> entry : headMap.entrySet()) { + String headValue = entry.getValue() == null ? null : entry.getValue().getStringValue(); + String header = trimToNull(headValue); + if (header != null) { + headIndex.put(header, entry.getKey()); + } + } + List requiredHeads = List.of( + IMPORT_HEAD_DEPT_CODE, + IMPORT_HEAD_LOGIN_NAME, + IMPORT_HEAD_NICKNAME, + IMPORT_HEAD_MOBILE, + IMPORT_HEAD_EMAIL, + IMPORT_HEAD_STATUS, + IMPORT_HEAD_ROLE_KEYS, + IMPORT_HEAD_POSITION_CODES, + IMPORT_HEAD_REMARK + ); + for (String requiredHead : requiredHeads) { + if (!headIndex.containsKey(requiredHead)) { + throw new BusinessException("导入模板表头不正确,必须包含:" + String.join("、", requiredHeads)); + } + } + } + + @Override + public void doAfterAllAnalysed(AnalysisContext context) { + // no-op + } + + public List getRows() { + return rows; + } + + private String getCellValue(Map row, String headName) { + Integer index = headIndex.get(headName); + if (index == null) { + return null; + } + Object value = row.get(index); + return value == null ? null : String.valueOf(value).trim(); + } + } } diff --git a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/util/SysPasswordPolicy.java b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/util/SysPasswordPolicy.java new file mode 100644 index 0000000..8e6c025 --- /dev/null +++ b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/util/SysPasswordPolicy.java @@ -0,0 +1,30 @@ +package tech.easyflow.system.util; + +import tech.easyflow.common.web.exceptions.BusinessException; + +import java.util.regex.Pattern; + +/** + * 用户密码策略。 + */ +public final class SysPasswordPolicy { + + public static final String STRONG_PASSWORD_MESSAGE = "密码必须至少8位,且包含大写字母、小写字母、数字和特殊字符"; + + private static final Pattern STRONG_PASSWORD_PATTERN = Pattern.compile( + "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[^A-Za-z\\d]).{8,}$" + ); + + private SysPasswordPolicy() { + } + + public static boolean isStrongPassword(String password) { + return password != null && STRONG_PASSWORD_PATTERN.matcher(password).matches(); + } + + public static void validateStrongPassword(String password) { + if (!isStrongPassword(password)) { + throw new BusinessException(STRONG_PASSWORD_MESSAGE); + } + } +} diff --git a/easyflow-modules/easyflow-module-system/src/test/java/tech/easyflow/system/util/SysPasswordPolicyTest.java b/easyflow-modules/easyflow-module-system/src/test/java/tech/easyflow/system/util/SysPasswordPolicyTest.java new file mode 100644 index 0000000..fec8d84 --- /dev/null +++ b/easyflow-modules/easyflow-module-system/src/test/java/tech/easyflow/system/util/SysPasswordPolicyTest.java @@ -0,0 +1,20 @@ +package tech.easyflow.system.util; + +import org.junit.Assert; +import org.junit.Test; +import tech.easyflow.common.web.exceptions.BusinessException; + +public class SysPasswordPolicyTest { + + @Test + public void shouldAcceptStrongPassword() { + Assert.assertTrue(SysPasswordPolicy.isStrongPassword("Abcd1234!")); + SysPasswordPolicy.validateStrongPassword("Abcd1234!"); + } + + @Test(expected = BusinessException.class) + public void shouldRejectWeakPassword() { + Assert.assertFalse(SysPasswordPolicy.isStrongPassword("123456")); + SysPasswordPolicy.validateStrongPassword("123456"); + } +} diff --git a/easyflow-ui-admin/app/src/api/core/auth.ts b/easyflow-ui-admin/app/src/api/core/auth.ts index 3741248..cf45182 100644 --- a/easyflow-ui-admin/app/src/api/core/auth.ts +++ b/easyflow-ui-admin/app/src/api/core/auth.ts @@ -15,6 +15,7 @@ export namespace AuthApi { /** 登录接口返回值 */ export interface LoginResult { accessToken: string; + forceChangePassword?: boolean; token: string; } diff --git a/easyflow-ui-admin/app/src/locales/langs/en-US/sysAccount.json b/easyflow-ui-admin/app/src/locales/langs/en-US/sysAccount.json index e7796dc..71f087a 100644 --- a/easyflow-ui-admin/app/src/locales/langs/en-US/sysAccount.json +++ b/easyflow-ui-admin/app/src/locales/langs/en-US/sysAccount.json @@ -23,5 +23,23 @@ "newPwd": "NewPassword", "confirmPwd": "ConfirmPassword", "repeatPwd": "Please confirm your password again", - "notSamePwd": "The two passwords are inconsistent" + "notSamePwd": "The two passwords are inconsistent", + "passwordStrongTip": "Password must be at least 8 characters and include uppercase, lowercase, numbers, and special characters", + "forceChangePasswordNavigateTip": "For account security, please change your password before visiting other pages.", + "resetPassword": "Reset Password", + "resetPasswordConfirm": "Reset this account password to 123456? The user will be required to change it on next login.", + "resetPasswordSuccess": "Password has been reset to 123456 and must be changed on next login", + "importTitle": "Import Users", + "importUploadTitle": "Drag the Excel file here, or click to select a file", + "importUploadDesc": "Only .xlsx / .xls files are supported. Import only creates users and duplicate accounts will fail.", + "importSelectFileRequired": "Please select a file to import", + "downloadTemplate": "Download Template", + "importFinished": "User import completed", + "importResultTitle": "Import Result", + "importTotalCount": "Total", + "importSuccessCount": "Success", + "importErrorCount": "Failed", + "importRowNumber": "Row", + "importDeptCode": "Dept Code", + "importReason": "Reason" } diff --git a/easyflow-ui-admin/app/src/locales/langs/zh-CN/sysAccount.json b/easyflow-ui-admin/app/src/locales/langs/zh-CN/sysAccount.json index a55262a..8880375 100644 --- a/easyflow-ui-admin/app/src/locales/langs/zh-CN/sysAccount.json +++ b/easyflow-ui-admin/app/src/locales/langs/zh-CN/sysAccount.json @@ -24,5 +24,23 @@ "newPwd": "新密码", "confirmPwd": "确认密码", "repeatPwd": "请再次输入密码", - "notSamePwd": "两次输入的密码不一致" + "notSamePwd": "两次输入的密码不一致", + "passwordStrongTip": "密码必须至少 8 位,且包含大写字母、小写字母、数字和特殊字符", + "forceChangePasswordNavigateTip": "为了账户安全,请先修改密码后再继续访问其他页面", + "resetPassword": "重置密码", + "resetPasswordConfirm": "确认将该用户密码重置为 123456 吗?重置后用户下次登录必须先修改密码。", + "resetPasswordSuccess": "密码已重置为 123456,用户下次登录需修改密码", + "importTitle": "导入用户", + "importUploadTitle": "拖拽 Excel 文件到此处,或点击选择文件", + "importUploadDesc": "仅支持 .xlsx / .xls,导入只新增用户,重复账号会报错", + "importSelectFileRequired": "请先选择要导入的文件", + "downloadTemplate": "下载导入模板", + "importFinished": "用户导入完成", + "importResultTitle": "导入结果", + "importTotalCount": "总条数", + "importSuccessCount": "成功数", + "importErrorCount": "失败数", + "importRowNumber": "行号", + "importDeptCode": "部门编码", + "importReason": "失败原因" } diff --git a/easyflow-ui-admin/app/src/router/guard.ts b/easyflow-ui-admin/app/src/router/guard.ts index 6e20706..aaf8a63 100644 --- a/easyflow-ui-admin/app/src/router/guard.ts +++ b/easyflow-ui-admin/app/src/router/guard.ts @@ -14,6 +14,12 @@ import { removeDevLoginQuery, shouldAttemptDevLogin, } from './dev-login'; +import { + buildForcePasswordRoute, + isForcePasswordRoute, + notifyForcePasswordChange, + shouldForcePasswordChange, +} from '#/utils/password-reset'; interface NetworkConnectionLike { effectiveType?: string; @@ -183,6 +189,10 @@ function setupAccessGuard(router: Router) { // 基本路由,这些路由不需要进入权限拦截 if (coreRouteNames.includes(to.name as string)) { if (to.path === LOGIN_PATH && accessStore.accessToken) { + const currentUser = userStore.userInfo || (await authStore.fetchUserInfo()); + if (shouldForcePasswordChange(currentUser)) { + return buildForcePasswordRoute(); + } return decodeURIComponent( (to.query?.redirect as string) || userStore.userInfo?.homePath || @@ -219,6 +229,14 @@ function setupAccessGuard(router: Router) { return to; } + const userInfo = userStore.userInfo || (await authStore.fetchUserInfo()); + if (shouldForcePasswordChange(userInfo) && !isForcePasswordRoute(to)) { + if (from.name) { + notifyForcePasswordChange(); + } + return buildForcePasswordRoute(); + } + // 是否已经生成过动态路由 if (accessStore.isAccessChecked) { return true; @@ -226,7 +244,6 @@ function setupAccessGuard(router: Router) { // 生成路由表 // 当前登录用户拥有的角色标识列表 - const userInfo = userStore.userInfo || (await authStore.fetchUserInfo()); const userRoles = userInfo.roles ?? []; // 生成菜单和路由 diff --git a/easyflow-ui-admin/app/src/store/auth.ts b/easyflow-ui-admin/app/src/store/auth.ts index 2782445..d2f7370 100644 --- a/easyflow-ui-admin/app/src/store/auth.ts +++ b/easyflow-ui-admin/app/src/store/auth.ts @@ -18,6 +18,10 @@ import { logoutApi, } from '#/api'; import { $t } from '#/locales'; +import { + buildForcePasswordRoute, + shouldForcePasswordChange, +} from '#/utils/password-reset'; export const useAuthStore = defineStore('auth', () => { const accessStore = useAccessStore(); @@ -28,6 +32,7 @@ export const useAuthStore = defineStore('auth', () => { async function finalizeLogin( accessToken: string, + forceChangePassword?: boolean, options: { notify?: boolean; onSuccess?: () => Promise | void; @@ -47,8 +52,17 @@ export const useAuthStore = defineStore('auth', () => { userStore.setUserInfo(userInfo); accessStore.setAccessCodes(accessCodes); + const forcePasswordChange = shouldForcePasswordChange( + userInfo, + forceChangePassword, + ); + if (accessStore.loginExpired) { accessStore.setLoginExpired(false); + } + + if (forcePasswordChange) { + await router.push(buildForcePasswordRoute()); } else if (!options.skipRedirect) { const homePath = userInfo.homePath || preferences.app.defaultHomePath || '/'; @@ -81,10 +95,12 @@ export const useAuthStore = defineStore('auth', () => { ) { try { loginLoading.value = true; - const { token: accessToken } = await loginApi(params); + const { forceChangePassword, token: accessToken } = await loginApi(params); if (accessToken) { - return await finalizeLogin(accessToken, { onSuccess }); + return await finalizeLogin(accessToken, forceChangePassword, { + onSuccess, + }); } } finally { loginLoading.value = false; @@ -96,13 +112,15 @@ export const useAuthStore = defineStore('auth', () => { } async function authDevLogin(account: string) { - const { token: accessToken } = await devLoginApi({ account }); + const { forceChangePassword, token: accessToken } = await devLoginApi({ + account, + }); if (!accessToken) { return { userInfo: null, }; } - return finalizeLogin(accessToken, { + return finalizeLogin(accessToken, forceChangePassword, { notify: false, skipRedirect: true, }); diff --git a/easyflow-ui-admin/app/src/utils/password-policy.ts b/easyflow-ui-admin/app/src/utils/password-policy.ts new file mode 100644 index 0000000..f348723 --- /dev/null +++ b/easyflow-ui-admin/app/src/utils/password-policy.ts @@ -0,0 +1,6 @@ +export const strongPasswordPattern = + /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z\d]).{8,}$/; + +export function isStrongPassword(value?: string) { + return !!value && strongPasswordPattern.test(value); +} diff --git a/easyflow-ui-admin/app/src/utils/password-reset.ts b/easyflow-ui-admin/app/src/utils/password-reset.ts new file mode 100644 index 0000000..723799f --- /dev/null +++ b/easyflow-ui-admin/app/src/utils/password-reset.ts @@ -0,0 +1,52 @@ +import type { Router } from 'vue-router'; + +import { ElMessage } from 'element-plus'; + +import { $t } from '#/locales'; + +const FORCE_PASSWORD_NOTICE_INTERVAL = 1500; + +let lastForcePasswordNoticeAt = 0; + +export function buildForcePasswordRoute() { + return { + name: 'Profile', + query: { + force: '1', + tab: 'password', + }, + }; +} + +export function isForcePasswordRoute( + route: Pick<{ name?: string | symbol | null; query?: Record }, 'name' | 'query'>, +) { + return route.name === 'Profile' && route.query?.tab === 'password'; +} + +export function shouldForcePasswordChange( + userInfo?: null | Record, + forceChangePassword?: boolean, +) { + return !!forceChangePassword || !!userInfo?.passwordResetRequired; +} + +export function resolveForcePasswordPath(router: Router) { + return router.resolve(buildForcePasswordRoute()).fullPath; +} + +export function notifyForcePasswordChange() { + const now = Date.now(); + if (now - lastForcePasswordNoticeAt < FORCE_PASSWORD_NOTICE_INTERVAL) { + return; + } + lastForcePasswordNoticeAt = now; + ElMessage({ + message: $t('sysAccount.forceChangePasswordNavigateTip'), + type: 'warning', + duration: 2200, + grouping: true, + plain: true, + showClose: true, + }); +} diff --git a/easyflow-ui-admin/app/src/views/_core/profile/index.vue b/easyflow-ui-admin/app/src/views/_core/profile/index.vue index 66e9723..8ecb487 100644 --- a/easyflow-ui-admin/app/src/views/_core/profile/index.vue +++ b/easyflow-ui-admin/app/src/views/_core/profile/index.vue @@ -1,5 +1,5 @@