diff --git a/continew-admin-common/src/main/java/top/continew/admin/common/constant/CacheConstants.java b/continew-admin-common/src/main/java/top/continew/admin/common/constant/CacheConstants.java index 074dbaa9..1edad606 100644 --- a/continew-admin-common/src/main/java/top/continew/admin/common/constant/CacheConstants.java +++ b/continew-admin-common/src/main/java/top/continew/admin/common/constant/CacheConstants.java @@ -71,6 +71,11 @@ public class CacheConstants { */ public static final String USER_PASSWORD_ERROR_KEY_PREFIX = USER_KEY_PREFIX + "PASSWORD_ERROR" + DELIMITER; + /** + * 数据导入临时会话key + */ + public static final String DATA_IMPORT_KEY = "SYSTEM" + DELIMITER + "DATA_IMPORT" + DELIMITER; + private CacheConstants() { } } diff --git a/continew-admin-common/src/main/java/top/continew/admin/common/util/SecureUtils.java b/continew-admin-common/src/main/java/top/continew/admin/common/util/SecureUtils.java index 0ba46587..a7edaa29 100644 --- a/continew-admin-common/src/main/java/top/continew/admin/common/util/SecureUtils.java +++ b/continew-admin-common/src/main/java/top/continew/admin/common/util/SecureUtils.java @@ -19,8 +19,15 @@ import cn.hutool.core.codec.Base64; import cn.hutool.crypto.SecureUtil; import cn.hutool.crypto.asymmetric.KeyType; +import cn.hutool.extra.spring.SpringUtil; import top.continew.admin.common.config.properties.RsaProperties; +import top.continew.starter.core.exception.BusinessException; import top.continew.starter.core.util.validate.ValidationUtils; +import top.continew.starter.security.crypto.autoconfigure.CryptoProperties; +import top.continew.starter.security.crypto.encryptor.AesEncryptor; +import top.continew.starter.security.crypto.encryptor.IEncryptor; +import java.util.List; +import java.util.stream.Collectors; /** * 加密/解密工具类 @@ -33,29 +40,6 @@ public class SecureUtils { private SecureUtils() { } - /** - * 公钥加密 - * - * @param data 要加密的内容 - * @param publicKey 公钥 - * @return 公钥加密并 Base64 加密后的内容 - */ - public static String encryptByRsaPublicKey(String data, String publicKey) { - return Base64.encode(SecureUtil.rsa(null, publicKey).encrypt(data, KeyType.PublicKey)); - } - - /** - * 公钥加密 - * - * @param data 要加密的内容 - * @return 公钥加密并 Base64 加密后的内容 - */ - public static String encryptByRsaPublicKey(String data) { - String publicKey = RsaProperties.PUBLIC_KEY; - ValidationUtils.throwIfBlank(publicKey, "请配置 RSA 公钥"); - return encryptByRsaPublicKey(data, publicKey); - } - /** * 私钥解密 * @@ -78,4 +62,22 @@ public static String decryptByRsaPrivateKey(String data) { public static String decryptByRsaPrivateKey(String data, String privateKey) { return new String(SecureUtil.rsa(privateKey, null).decrypt(Base64.decode(data), KeyType.PrivateKey)); } + + /** + * 对普通加密字段列表进行AES加密,优化starter加密模块后优化这个方法 + * + * @param values 待加密内容 + * @return 加密后内容 + */ + public static List encryptFieldByAes(List values) { + IEncryptor encryptor = new AesEncryptor(); + CryptoProperties properties = SpringUtil.getBean(CryptoProperties.class); + return values.stream().map(value -> { + try { + return encryptor.encrypt(value, properties.getPassword(), properties.getPublicKey()); + } catch (Exception e) { + throw new BusinessException("字段加密异常"); + } + }).collect(Collectors.toList()); + } } diff --git a/continew-admin-system/src/main/java/top/continew/admin/system/enums/ImportPolicyEnum.java b/continew-admin-system/src/main/java/top/continew/admin/system/enums/ImportPolicyEnum.java new file mode 100644 index 00000000..81aad959 --- /dev/null +++ b/continew-admin-system/src/main/java/top/continew/admin/system/enums/ImportPolicyEnum.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package top.continew.admin.system.enums; + +import cn.hutool.core.collection.CollUtil; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import top.continew.starter.data.mybatis.plus.base.IBaseEnum; + +import java.util.List; + +/** + * 数据导入策略 + * + * @author Kils + * @since 2024-06-17 18:33 + */ +@Getter +@RequiredArgsConstructor +public enum ImportPolicyEnum implements IBaseEnum { + + /** + * 跳过该行 + */ + SKIP(1, "跳过该行"), + + /** + * 修改数据 + */ + UPDATE(2, "修改数据"), + + /** + * 停止导入 + */ + EXIT(3, "停止导入"); + + private final Integer value; + private final String description; + + public boolean validate(ImportPolicyEnum importPolicy, String data, List existList) { + return this == importPolicy && CollUtil.isNotEmpty(existList) && existList.contains(data); + } +} diff --git a/continew-admin-system/src/main/java/top/continew/admin/system/model/req/UserImportReq.java b/continew-admin-system/src/main/java/top/continew/admin/system/model/req/UserImportReq.java new file mode 100644 index 00000000..76a2ee25 --- /dev/null +++ b/continew-admin-system/src/main/java/top/continew/admin/system/model/req/UserImportReq.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package top.continew.admin.system.model.req; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import top.continew.admin.common.enums.DisEnableStatusEnum; +import top.continew.admin.system.enums.ImportPolicyEnum; +import top.continew.starter.extension.crud.model.req.BaseReq; + +import java.io.Serial; + +/** + * 用户导入参数 + * + * @author Kils + * @since 2024-6-17 16:42 + */ +@Data +@Schema(description = "用户导入参数") +public class UserImportReq extends BaseReq { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 导入会话KEY + */ + @Schema(description = "导入会话KEY", example = "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed") + @NotBlank(message = "导入已过期,请重新上传") + private String importKey; + + /** + * 用户重复策略 + */ + @Schema(description = "重复用户策略", type = "Integer", allowableValues = {"1", "2", "3", "4"}, example = "1") + @NotNull(message = "重复用户策略不能为空") + private ImportPolicyEnum duplicateUser; + + /** + * 重复邮箱策略 + */ + @Schema(description = "重复邮箱策略", type = "Integer", allowableValues = {"1", "2", "3", "4"}, example = "1") + @NotNull(message = "重复邮箱策略不能为空") + private ImportPolicyEnum duplicateEmail; + + /** + * 重复手机策略 + */ + @Schema(description = "重复手机策略", type = "Integer", allowableValues = {"1", "2", "3", "4"}, example = "1") + @NotNull(message = "重复手机策略不能为空") + private ImportPolicyEnum duplicatePhone; + + /** + * 默认状态 + */ + @Schema(description = "默认状态(1:启用;2:禁用)", type = "Integer", allowableValues = {"1", "2"}, example = "1") + private DisEnableStatusEnum defaultStatus; +} diff --git a/continew-admin-system/src/main/java/top/continew/admin/system/model/req/UserImportRowReq.java b/continew-admin-system/src/main/java/top/continew/admin/system/model/req/UserImportRowReq.java new file mode 100644 index 00000000..7e7e7ff5 --- /dev/null +++ b/continew-admin-system/src/main/java/top/continew/admin/system/model/req/UserImportRowReq.java @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package top.continew.admin.system.model.req; + +import cn.hutool.core.lang.RegexPool; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import lombok.Data; +import org.hibernate.validator.constraints.Length; +import top.continew.admin.common.constant.RegexConstants; +import top.continew.starter.extension.crud.model.req.BaseReq; +import top.continew.starter.extension.crud.util.ValidateGroup; + +import java.io.Serial; + +/** + * 用户导入行数据 + * + * @author Kils + * @since 2024-6-17 16:42 + */ +@Data +@Schema(description = "用户导入行数据") +public class UserImportRowReq extends BaseReq { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 用户名 + */ + @NotBlank(message = "用户名不能为空") + @Pattern(regexp = RegexConstants.USERNAME, message = "用户名长度为 4-64 个字符,支持大小写字母、数字、下划线,以字母开头") + private String username; + + /** + * 昵称 + */ + @NotBlank(message = "昵称不能为空") + @Pattern(regexp = RegexConstants.GENERAL_NAME, message = "昵称长度为 2-30 个字符,支持中文、字母、数字、下划线,短横线") + private String nickname; + + /** + * 密码 + */ + @NotBlank(message = "密码不能为空", groups = ValidateGroup.Crud.Add.class) + private String password; + + /** + * 部门名称 + */ + @NotNull(message = "所属部门不能为空") + private String deptName; + + /** + * 角色 + */ + @NotBlank(message = "所属角色不能为空") + private String roleName; + + /** + * 性别 + */ + private String gender; + + /** + * 邮箱 + */ + @Pattern(regexp = "^$|" + RegexPool.EMAIL, message = "邮箱格式错误") + @Length(max = 255, message = "邮箱长度不能超过 {max} 个字符") + private String email; + + /** + * 手机号码 + */ + @Pattern(regexp = "^$|" + RegexPool.MOBILE, message = "手机号码格式错误") + private String phone; + + /** + * 描述 + */ + private String description; +} diff --git a/continew-admin-system/src/main/java/top/continew/admin/system/model/resp/UserImportParseResp.java b/continew-admin-system/src/main/java/top/continew/admin/system/model/resp/UserImportParseResp.java new file mode 100644 index 00000000..8d959bbc --- /dev/null +++ b/continew-admin-system/src/main/java/top/continew/admin/system/model/resp/UserImportParseResp.java @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package top.continew.admin.system.model.resp; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 用户导入结果 + * + * @author kils + * @since 2024-06-18 14:37 + */ +@Data +@Schema(description = "用户导入结果") +@AllArgsConstructor +@NoArgsConstructor +public class UserImportParseResp { + + private static final long serialVersionUID = 1L; + + /** + * 导入会话KEY + */ + @Schema(description = "导入会话KEY", example = "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed") + private String importKey; + + /** + * 总计行数 + */ + @Schema(description = "总计行数", example = "100") + private Integer totalRows; + + /** + * 有效行数 + */ + @Schema(description = "有效行数", example = "100") + private Integer validRows; + + /** + * 用户重复行数 + */ + @Schema(description = "用户重复行数", example = "100") + private Integer duplicateUserRows; + + /** + * 重复邮箱行数 + */ + @Schema(description = "重复邮箱行数", example = "100") + private Integer duplicateEmailRows; + + /** + * 重复手机行数 + */ + @Schema(description = "重复手机行数", example = "100") + private Integer duplicatePhoneRows; +} diff --git a/continew-admin-system/src/main/java/top/continew/admin/system/model/resp/UserImportResp.java b/continew-admin-system/src/main/java/top/continew/admin/system/model/resp/UserImportResp.java new file mode 100644 index 00000000..eab02687 --- /dev/null +++ b/continew-admin-system/src/main/java/top/continew/admin/system/model/resp/UserImportResp.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package top.continew.admin.system.model.resp; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 用户导入结果 + * + * @author kils + * @since 2024-06-18 14:37 + */ +@Data +@Schema(description = "用户导入结果") +@AllArgsConstructor +@NoArgsConstructor +public class UserImportResp { + + private static final long serialVersionUID = 1L; + + /** + * 总计行数 + */ + @Schema(description = "总计行数", example = "100") + private Integer totalRows; + + /** + * 新增行数 + */ + @Schema(description = "新增行数", example = "100") + private Integer insertRows; + + /** + * 修改行数 + */ + @Schema(description = "修改行数", example = "100") + private Integer updateRows; +} diff --git a/continew-admin-system/src/main/java/top/continew/admin/system/service/DeptService.java b/continew-admin-system/src/main/java/top/continew/admin/system/service/DeptService.java index 82d0a43e..e8e66c3d 100644 --- a/continew-admin-system/src/main/java/top/continew/admin/system/service/DeptService.java +++ b/continew-admin-system/src/main/java/top/continew/admin/system/service/DeptService.java @@ -40,4 +40,20 @@ public interface DeptService extends BaseService listChildren(Long id); + + /** + * 通过名称查询部门 + * + * @param list 名称列表 + * @return 部门列表 + */ + List listByNames(List list); + + /** + * 通过名称查询部门数量 + * + * @param deptNames 名称列表 + * @return 部门数量 + */ + int countByNames(List deptNames); } diff --git a/continew-admin-system/src/main/java/top/continew/admin/system/service/RoleService.java b/continew-admin-system/src/main/java/top/continew/admin/system/service/RoleService.java index a7ba9060..04fd19e3 100644 --- a/continew-admin-system/src/main/java/top/continew/admin/system/service/RoleService.java +++ b/continew-admin-system/src/main/java/top/continew/admin/system/service/RoleService.java @@ -67,4 +67,20 @@ public interface RoleService extends BaseService listByNames(List list); + + /** + * 根据角色名称查询数量 + * + * @param roleNames 名称列表 + * @return 角色数量 + */ + int countByNames(List roleNames); } diff --git a/continew-admin-system/src/main/java/top/continew/admin/system/service/UserRoleService.java b/continew-admin-system/src/main/java/top/continew/admin/system/service/UserRoleService.java index 53f15663..628723a6 100644 --- a/continew-admin-system/src/main/java/top/continew/admin/system/service/UserRoleService.java +++ b/continew-admin-system/src/main/java/top/continew/admin/system/service/UserRoleService.java @@ -16,6 +16,8 @@ package top.continew.admin.system.service; +import top.continew.admin.system.model.entity.UserRoleDO; + import java.util.List; /** @@ -42,6 +44,13 @@ public interface UserRoleService { */ void deleteByUserIds(List userIds); + /** + * 批量插入 + * + * @param list 数据集 + */ + void saveBatch(List list); + /** * 根据用户 ID 查询 * diff --git a/continew-admin-system/src/main/java/top/continew/admin/system/service/UserService.java b/continew-admin-system/src/main/java/top/continew/admin/system/service/UserService.java index 663e9510..082db3ca 100644 --- a/continew-admin-system/src/main/java/top/continew/admin/system/service/UserService.java +++ b/continew-admin-system/src/main/java/top/continew/admin/system/service/UserService.java @@ -16,14 +16,14 @@ package top.continew.admin.system.service; +import jakarta.servlet.http.HttpServletResponse; import org.springframework.web.multipart.MultipartFile; import top.continew.admin.system.model.entity.UserDO; import top.continew.admin.system.model.query.UserQuery; -import top.continew.admin.system.model.req.UserBasicInfoUpdateReq; -import top.continew.admin.system.model.req.UserPasswordResetReq; -import top.continew.admin.system.model.req.UserReq; -import top.continew.admin.system.model.req.UserRoleUpdateReq; +import top.continew.admin.system.model.req.*; import top.continew.admin.system.model.resp.UserDetailResp; +import top.continew.admin.system.model.resp.UserImportParseResp; +import top.continew.admin.system.model.resp.UserImportResp; import top.continew.admin.system.model.resp.UserResp; import top.continew.starter.data.mybatis.plus.service.IService; import top.continew.starter.extension.crud.service.BaseService; @@ -138,4 +138,23 @@ public interface UserService extends BaseService deptIds); + + /** + * 下载用户导入模板 + */ + void downloadImportUserTemplate(HttpServletResponse response) throws IOException; + + /** + * 导入用户 + * + */ + UserImportResp importUser(UserImportReq req); + + /** + * 解析用户导入数据 + * + * @param file 导入用户文件 + * @return 解析结果 + */ + UserImportParseResp parseImportUser(MultipartFile file); } diff --git a/continew-admin-system/src/main/java/top/continew/admin/system/service/impl/DeptServiceImpl.java b/continew-admin-system/src/main/java/top/continew/admin/system/service/impl/DeptServiceImpl.java index ab14a979..d8cecc56 100644 --- a/continew-admin-system/src/main/java/top/continew/admin/system/service/impl/DeptServiceImpl.java +++ b/continew-admin-system/src/main/java/top/continew/admin/system/service/impl/DeptServiceImpl.java @@ -20,6 +20,7 @@ import cn.hutool.core.util.ObjectUtil; import cn.hutool.extra.spring.SpringUtil; import com.baomidou.dynamic.datasource.DynamicRoutingDataSource; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; import jakarta.annotation.Resource; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -37,9 +38,7 @@ import top.continew.starter.data.core.util.MetaUtils; import top.continew.starter.extension.crud.service.impl.BaseServiceImpl; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; +import java.util.*; /** * 部门业务实现 @@ -62,6 +61,22 @@ public List listChildren(Long id) { return baseMapper.lambdaQuery().apply(databaseType.findInSet(id, "ancestors")).list(); } + @Override + public List listByNames(List list) { + if (CollUtil.isEmpty(list)) { + return Collections.emptyList(); + } + return this.list(Wrappers.lambdaQuery().in(DeptDO::getName, list)); + } + + @Override + public int countByNames(List deptNames) { + if (CollUtil.isEmpty(deptNames)) { + return 0; + } + return (int)this.count(Wrappers.lambdaQuery().in(DeptDO::getName, deptNames)); + } + @Override protected void beforeAdd(DeptReq req) { String name = req.getName(); diff --git a/continew-admin-system/src/main/java/top/continew/admin/system/service/impl/RoleServiceImpl.java b/continew-admin-system/src/main/java/top/continew/admin/system/service/impl/RoleServiceImpl.java index 54fc7005..d8ff8f40 100644 --- a/continew-admin-system/src/main/java/top/continew/admin/system/service/impl/RoleServiceImpl.java +++ b/continew-admin-system/src/main/java/top/continew/admin/system/service/impl/RoleServiceImpl.java @@ -22,6 +22,7 @@ import cn.hutool.core.collection.CollUtil; import cn.hutool.core.util.ObjectUtil; import com.alicp.jetcache.anno.CacheInvalidate; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -161,6 +162,22 @@ public RoleDO getByCode(String code) { return baseMapper.lambdaQuery().eq(RoleDO::getCode, code).one(); } + @Override + public List listByNames(List list) { + if (CollUtil.isEmpty(list)) { + return Collections.emptyList(); + } + return this.list(Wrappers.lambdaQuery().in(RoleDO::getName, list)); + } + + @Override + public int countByNames(List roleNames) { + if (CollUtil.isEmpty(roleNames)) { + return 0; + } + return (int)this.count(Wrappers.lambdaQuery().in(RoleDO::getName, roleNames)); + } + /** * 名称是否存在 * diff --git a/continew-admin-system/src/main/java/top/continew/admin/system/service/impl/UserRoleServiceImpl.java b/continew-admin-system/src/main/java/top/continew/admin/system/service/impl/UserRoleServiceImpl.java index dddba4ba..e5f981fe 100644 --- a/continew-admin-system/src/main/java/top/continew/admin/system/service/impl/UserRoleServiceImpl.java +++ b/continew-admin-system/src/main/java/top/continew/admin/system/service/impl/UserRoleServiceImpl.java @@ -68,6 +68,11 @@ public void deleteByUserIds(List userIds) { baseMapper.lambdaUpdate().in(UserRoleDO::getUserId, userIds).remove(); } + @Override + public void saveBatch(List list) { + baseMapper.insertBatch(list); + } + @Override @ContainerMethod(namespace = ContainerConstants.USER_ROLE_ID_LIST, type = MappingType.ORDER_OF_KEYS) public List listRoleIdByUserId(Long userId) { diff --git a/continew-admin-system/src/main/java/top/continew/admin/system/service/impl/UserServiceImpl.java b/continew-admin-system/src/main/java/top/continew/admin/system/service/impl/UserServiceImpl.java index dd11fc1c..22254adf 100644 --- a/continew-admin-system/src/main/java/top/continew/admin/system/service/impl/UserServiceImpl.java +++ b/continew-admin-system/src/main/java/top/continew/admin/system/service/impl/UserServiceImpl.java @@ -20,18 +20,30 @@ import cn.hutool.core.bean.BeanUtil; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.img.ImgUtil; +import cn.hutool.core.io.FileUtil; +import cn.hutool.core.io.IoUtil; import cn.hutool.core.io.file.FileNameUtil; +import cn.hutool.core.lang.UUID; import cn.hutool.core.map.MapUtil; -import cn.hutool.core.util.ObjectUtil; -import cn.hutool.core.util.StrUtil; +import cn.hutool.core.util.*; +import cn.hutool.extra.validation.ValidationUtil; +import cn.hutool.http.ContentType; +import cn.hutool.json.JSONUtil; +import com.alibaba.excel.EasyExcel; import com.alicp.jetcache.anno.CacheInvalidate; import com.alicp.jetcache.anno.CacheType; import com.alicp.jetcache.anno.CacheUpdate; import com.alicp.jetcache.anno.Cached; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.core.toolkit.support.SFunction; import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; +import me.ahoo.cosid.IdGenerator; +import me.ahoo.cosid.provider.DefaultIdGeneratorProvider; +import net.dreamlu.mica.core.result.R; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @@ -41,19 +53,24 @@ import top.continew.admin.common.constant.CacheConstants; import top.continew.admin.common.constant.SysConstants; import top.continew.admin.common.enums.DisEnableStatusEnum; +import top.continew.admin.common.enums.GenderEnum; +import top.continew.admin.common.util.SecureUtils; import top.continew.admin.common.util.helper.LoginHelper; import top.continew.admin.system.mapper.UserMapper; import top.continew.admin.system.model.entity.DeptDO; +import top.continew.admin.system.model.entity.RoleDO; import top.continew.admin.system.model.entity.UserDO; +import top.continew.admin.system.model.entity.UserRoleDO; import top.continew.admin.system.model.query.UserQuery; -import top.continew.admin.system.model.req.UserBasicInfoUpdateReq; -import top.continew.admin.system.model.req.UserPasswordResetReq; -import top.continew.admin.system.model.req.UserReq; -import top.continew.admin.system.model.req.UserRoleUpdateReq; +import top.continew.admin.system.model.req.*; import top.continew.admin.system.model.resp.UserDetailResp; +import top.continew.admin.system.model.resp.UserImportParseResp; +import top.continew.admin.system.model.resp.UserImportResp; import top.continew.admin.system.model.resp.UserResp; import top.continew.admin.system.service.*; +import top.continew.starter.cache.redisson.util.RedisUtils; import top.continew.starter.core.constant.StringConstants; +import top.continew.starter.core.exception.BusinessException; import top.continew.starter.core.util.validate.CheckUtils; import top.continew.starter.extension.crud.model.query.PageQuery; import top.continew.starter.extension.crud.model.query.SortQuery; @@ -61,11 +78,15 @@ import top.continew.starter.extension.crud.service.CommonUserService; import top.continew.starter.extension.crud.service.impl.BaseServiceImpl; +import java.io.BufferedInputStream; import java.io.IOException; +import java.time.Duration; import java.time.LocalDateTime; import java.util.*; +import java.util.function.Function; import java.util.stream.Collectors; +import static top.continew.admin.system.enums.ImportPolicyEnum.*; import static top.continew.admin.system.enums.PasswordPolicyEnum.*; /** @@ -83,6 +104,8 @@ public class UserServiceImpl extends BaseServiceImpl userRowList; + // 读取表格数据 + try { + userRowList = EasyExcel.read(file.getInputStream()) + .head(UserImportRowReq.class) + .sheet() + .headRowNumber(1) + .doReadSync(); + } catch (Exception e) { + log.error("用户导入数据文件解析异常:", e); + throw new BusinessException("数据文件解析异常"); + } + userImportResp.setTotalRows(userRowList.size()); + if (CollUtil.isEmpty(userRowList)) { + throw new BusinessException("数据文件格式错误"); + } + + // 过滤无效数据 + List validUserRowList = filterErrorUserImportData(userRowList); + userImportResp.setValidRows(validUserRowList.size()); + if (CollUtil.isEmpty(validUserRowList)) { + throw new BusinessException("数据文件格式错误"); + } + + // 检测表格内数据是否合法 + Set seenEmails = new HashSet<>(); + boolean hasDuplicateEmail = validUserRowList.stream() + .map(UserImportRowReq::getEmail) + .anyMatch(email -> email != null && !seenEmails.add(email)); + CheckUtils.throwIf(hasDuplicateEmail, "存在重复邮箱,请检测数据"); + Set seenPhones = new HashSet<>(); + boolean hasDuplicatePhone = validUserRowList.stream() + .map(UserImportRowReq::getPhone) + .anyMatch(phone -> phone != null && !seenPhones.add(phone)); + CheckUtils.throwIf(hasDuplicatePhone, "存在重复手机,请检测数据"); + + // 校验是否存在错误角色 + List roleNames = validUserRowList.stream().map(UserImportRowReq::getRoleName).distinct().toList(); + int existRoleCount = roleService.countByNames(roleNames); + CheckUtils.throwIf(existRoleCount < roleNames.size(), "存在错误角色,请检查数据"); + // 校验是否存在错误部门 + List deptNames = validUserRowList.stream().map(UserImportRowReq::getDeptName).distinct().toList(); + int existDeptCount = deptService.countByNames(deptNames); + CheckUtils.throwIf(existDeptCount < deptNames.size(), "存在错误部门,请检查数据"); + + // 查询重复用户 + userImportResp + .setDuplicateUserRows(countExistByField(validUserRowList, UserImportRowReq::getUsername, UserDO::getUsername, false)); + // 查询重复邮箱 + userImportResp + .setDuplicateEmailRows(countExistByField(validUserRowList, UserImportRowReq::getEmail, UserDO::getEmail, true)); + // 查询重复手机 + userImportResp + .setDuplicatePhoneRows(countExistByField(validUserRowList, UserImportRowReq::getPhone, UserDO::getPhone, true)); + + // 设置导入会话并缓存数据,有效期10分钟 + String importKey = UUID.fastUUID().toString(true); + RedisUtils.set(CacheConstants.DATA_IMPORT_KEY + importKey, JSONUtil.toJsonStr(validUserRowList), Duration + .ofMinutes(10)); + userImportResp.setImportKey(importKey); + + return userImportResp; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public UserImportResp importUser(UserImportReq req) { + // 校验导入会话是否过期 + List importUserList; + try { + String data = RedisUtils.get(CacheConstants.DATA_IMPORT_KEY + req.getImportKey()); + importUserList = JSONUtil.toList(data, UserImportRowReq.class); + CheckUtils.throwIf(CollUtil.isEmpty(importUserList), "导入已过期,请重新上传"); + } catch (Exception e) { + log.error("导入异常:", e); + throw new BusinessException("导入已过期,请重新上传"); + } + // 已存在数据查询 + List existEmails = listExistByField(importUserList, UserImportRowReq::getEmail, UserDO::getEmail); + List existPhones = listExistByField(importUserList, UserImportRowReq::getPhone, UserDO::getPhone); + List existUserList = listByUsernames(importUserList.stream() + .map(UserImportRowReq::getUsername) + .filter(Objects::nonNull) + .toList()); + List existUsernames = existUserList.stream().map(UserDO::getUsername).toList(); + CheckUtils + .throwIf(isExitImportUser(req, importUserList, existUsernames, existEmails, existPhones), "数据不符合导入策略,已退出导入"); + + // 基础数据准备 + Map userMap = existUserList.stream() + .collect(Collectors.toMap(UserDO::getUsername, UserDO::getId)); + List roleList = roleService.listByNames(importUserList.stream() + .map(UserImportRowReq::getRoleName) + .distinct() + .toList()); + Map roleMap = roleList.stream().collect(Collectors.toMap(RoleDO::getName, RoleDO::getId)); + List deptList = deptService.listByNames(importUserList.stream() + .map(UserImportRowReq::getDeptName) + .distinct() + .toList()); + Map deptMap = deptList.stream().collect(Collectors.toMap(DeptDO::getName, DeptDO::getId)); + + // 批量操作数据库集合 + List insertList = new ArrayList<>(); + List updateList = new ArrayList<>(); + List userRoleDOList = new ArrayList<>(); + // ID生成器 + IdGenerator idGenerator = DefaultIdGeneratorProvider.INSTANCE.getShare(); + for (UserImportRowReq row : importUserList) { + if (isSkipUserImport(req, row, existUsernames, existPhones, existEmails)) { + // 按规则跳过该行 + continue; + } + UserDO userDO = BeanUtil.toBeanIgnoreError(row, UserDO.class); + userDO.setStatus(req.getDefaultStatus()); + userDO.setPwdResetTime(LocalDateTime.now()); + userDO.setGender(EnumUtil.getBy(GenderEnum::getDescription, row.getGender(), GenderEnum.UNKNOWN)); + userDO.setDeptId(deptMap.get(row.getDeptName())); + // 修改 or 新增 + if (UPDATE.validate(req.getDuplicateUser(), row.getUsername(), existUsernames)) { + userDO.setId(userMap.get(row.getUsername())); + updateList.add(userDO); + } else { + userDO.setId(idGenerator.generate()); + userDO.setIsSystem(false); + insertList.add(userDO); + } + userRoleDOList.add(new UserRoleDO(userDO.getId(), roleMap.get(row.getRoleName()))); + } + doImportUser(insertList, updateList, userRoleDOList); + RedisUtils.delete(CacheConstants.DATA_IMPORT_KEY + req.getImportKey()); + return new UserImportResp(insertList.size() + updateList.size(), insertList.size(), updateList.size()); + } + + @Transactional(rollbackFor = Exception.class) + public void doImportUser(List insertList, List updateList, List userRoleDOList) { + if (CollUtil.isNotEmpty(insertList)) { + baseMapper.insertBatch(insertList); + } + if (CollUtil.isNotEmpty(updateList)) { + this.updateBatchById(updateList); + userRoleService.deleteByUserIds(updateList.stream().map(UserDO::getId).toList()); + } + if (CollUtil.isNotEmpty(userRoleDOList)) { + userRoleService.saveBatch(userRoleDOList); + } + } + @Override @Transactional(rollbackFor = Exception.class) @CacheUpdate(key = "#id", value = "#req.nickname", name = CacheConstants.USER_KEY_PREFIX) @@ -316,6 +509,104 @@ protected void afterAdd(UserReq req, UserDO user) { userRoleService.add(req.getRoleIds(), userId); } + /** + * 判断是否跳过导入 + * + * @param req 导入参数 + * @param row 导入数据 + * @param existUsernames 导入数据中已存在的用户名 + * @param existEmails 导入数据中已存在的邮箱 + * @param existPhones 导入数据中已存在的手机号 + * @return 是否跳过 + */ + private boolean isSkipUserImport(UserImportReq req, + UserImportRowReq row, + List existUsernames, + List existPhones, + List existEmails) { + return SKIP.validate(req.getDuplicateUser(), row.getUsername(), existUsernames) || SKIP.validate(req + .getDuplicateEmail(), row.getEmail(), existEmails) || SKIP.validate(req.getDuplicatePhone(), row + .getPhone(), existPhones); + } + + /** + * 判断是否退出导入 + * + * @param req 导入参数 + * @param list 导入数据 + * @param existUsernames 导入数据中已存在的用户名 + * @param existEmails 导入数据中已存在的邮箱 + * @param existPhones 导入数据中已存在的手机号 + * @return 是否退出 + */ + private boolean isExitImportUser(UserImportReq req, + List list, + List existUsernames, + List existEmails, + List existPhones) { + return list.stream() + .anyMatch(row -> EXIT.validate(req.getDuplicateUser(), row.getUsername(), existUsernames) || EXIT + .validate(req.getDuplicateEmail(), row.getEmail(), existEmails) || EXIT.validate(req + .getDuplicatePhone(), row.getPhone(), existPhones)); + } + + /** + * 按指定数据集获取数据库已存在的数量 + * + * @param userRowList 导入的数据源 + * @param rowField 导入数据的字段 + * @param dbField 对比数据库的字段 + * @return 存在的数量 + */ + private int countExistByField(List userRowList, + Function rowField, + SFunction dbField, + boolean fieldEncrypt) { + List fieldValues = userRowList.stream().map(rowField).filter(Objects::nonNull).toList(); + if (fieldValues.isEmpty()) { + return 0; + } + return (int)this.count(Wrappers.lambdaQuery() + .in(dbField, fieldEncrypt ? SecureUtils.encryptFieldByAes(fieldValues) : fieldValues)); + } + + /** + * 按指定数据集获取数据库已存在内容 + * + * @param userRowList 导入的数据源 + * @param rowField 导入数据的字段 + * @param dbField 对比数据库的字段 + * @return 存在的内容 + */ + private List listExistByField(List userRowList, + Function rowField, + SFunction dbField) { + List fieldValues = userRowList.stream().map(rowField).filter(Objects::nonNull).toList(); + if (fieldValues.isEmpty()) { + return Collections.emptyList(); + } + List userDOList = baseMapper.selectList(Wrappers.lambdaQuery() + .in(dbField, SecureUtils.encryptFieldByAes(fieldValues)) + .select(dbField)); + return userDOList.stream().map(dbField).filter(Objects::nonNull).toList(); + } + + /** + * 过滤无效的导入用户数据,批量导入不严格校验数据 + */ + private List filterErrorUserImportData(List userImportList) { + // 校验过滤 + List list = userImportList.stream() + .filter(row -> ValidationUtil.validate(row).size() == 0) + .toList(); + // 用户名去重 + return list.stream() + .collect(Collectors.toMap(UserImportRowReq::getUsername, user -> user, (existing, replacement) -> existing)) + .values() + .stream() + .toList(); + } + /** * 检测密码合法性 * @@ -373,4 +664,10 @@ private boolean isPhoneExists(String phone, Long id) { Long count = baseMapper.selectCountByPhone(phone, id); return null != count && count > 0; } + + private List listByUsernames(List usernames) { + return this.list(Wrappers.lambdaQuery() + .in(UserDO::getUsername, usernames) + .select(UserDO::getId, UserDO::getUsername)); + } } diff --git a/continew-admin-webapi/src/main/java/top/continew/admin/webapi/system/UserController.java b/continew-admin-webapi/src/main/java/top/continew/admin/webapi/system/UserController.java index 09229009..c4a184f8 100644 --- a/continew-admin-webapi/src/main/java/top/continew/admin/webapi/system/UserController.java +++ b/continew-admin-webapi/src/main/java/top/continew/admin/webapi/system/UserController.java @@ -22,19 +22,21 @@ import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.PatchMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; import top.continew.admin.common.constant.RegexConstants; import top.continew.admin.common.util.SecureUtils; import top.continew.admin.system.model.query.UserQuery; +import top.continew.admin.system.model.req.UserImportReq; import top.continew.admin.system.model.req.UserPasswordResetReq; import top.continew.admin.system.model.req.UserReq; import top.continew.admin.system.model.req.UserRoleUpdateReq; -import top.continew.admin.system.model.resp.UserDetailResp; -import top.continew.admin.system.model.resp.UserResp; +import top.continew.admin.system.model.resp.*; import top.continew.admin.system.service.UserService; import top.continew.starter.core.util.ExceptionUtils; import top.continew.starter.core.util.validate.ValidationUtils; @@ -44,6 +46,8 @@ import top.continew.starter.extension.crud.util.ValidateGroup; import top.continew.starter.web.model.R; +import java.io.IOException; + /** * 用户管理 API * @@ -53,9 +57,12 @@ @Tag(name = "用户管理 API") @Validated @RestController +@RequiredArgsConstructor @CrudRequestMapping(value = "/system/user", api = {Api.PAGE, Api.GET, Api.ADD, Api.UPDATE, Api.DELETE, Api.EXPORT}) public class UserController extends BaseController { + private final UserService userService; + @Override public R add(@Validated(ValidateGroup.Crud.Add.class) @RequestBody UserReq req) { String rawPassword = ExceptionUtils.exToNull(() -> SecureUtils.decryptByRsaPrivateKey(req.getPassword())); @@ -66,6 +73,28 @@ public R add(@Validated(ValidateGroup.Crud.Add.class) @RequestBody UserReq return super.add(req); } + @Operation(summary = "下载用户导入模板", description = "下载用户导入模板") + @SaCheckPermission("system:user:import") + @GetMapping(value = "downloadImportUserTemplate", produces = MediaType.APPLICATION_OCTET_STREAM_VALUE) + public void downloadImportUserTemplate(HttpServletResponse response) throws IOException { + userService.downloadImportUserTemplate(response); + } + + @Operation(summary = "解析用户导入数据", description = "解析用户导入数据") + @SaCheckPermission("system:user:import") + @PostMapping(value = "parseImportUser") + public R parseImportUser(@NotNull(message = "文件不能为空") MultipartFile file) { + ValidationUtils.throwIf(file::isEmpty, "文件不能为空"); + return R.ok(userService.parseImportUser(file)); + } + + @Operation(summary = "导入用户", description = "导入用户") + @SaCheckPermission("system:user:import") + @PostMapping(value = "import") + public R importUser(@Validated @RequestBody UserImportReq req) { + return R.ok(userService.importUser(req)); + } + @Operation(summary = "重置密码", description = "重置用户登录密码") @Parameter(name = "id", description = "ID", example = "1", in = ParameterIn.PATH) @SaCheckPermission("system:user:resetPwd") diff --git a/continew-admin-webapi/src/main/resources/templates/import/userImportTemplate.xlsx b/continew-admin-webapi/src/main/resources/templates/import/userImportTemplate.xlsx new file mode 100644 index 00000000..03a9360f Binary files /dev/null and b/continew-admin-webapi/src/main/resources/templates/import/userImportTemplate.xlsx differ