feat(upms): 重构连接器导入功能,支持钉钉和企业微信Excel导入

- 优化钉钉部门同步逻辑,提取常量到 ConnectConstants
- 新增企业微信部门及用户Excel导入功能
- 实现钉钉部门和用户Excel导入功能
- 移除API同步功能,统一使用导入方式

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
PIGCLOUDandClaude Opus 4.5 committed 2026-03-08 14:58:10 +08:00
1 parent 31ad1fbeb8
commit d2f49723d6
10 files changed
+776 -330

No files matched your search

@@ -0,0 +1,73 @@
package com.pig4cloud.pigx.admin.api.constant;
/**
* 第三方连接常量类
* <p>
* 用于定义第三方平台(如钉钉)连接相关的字段名称和常量值
*
* @author lengleng
* @date 2026/01/10
*/
public interface ConnectConstants {
/**
* JSON字段名: 手机号
*/
String FIELD_MOBILE = "mobile";
/**
* JSON字段名: 头像
*/
String FIELD_AVATAR = "avatar";
/**
* JSON字段名: 昵称
*/
String FIELD_NICKNAME = "nickname";
/**
* JSON字段名: 姓名
*/
String FIELD_NAME = "name";
/**
* JSON字段名: 邮箱
*/
String FIELD_EMAIL = "email";
/**
* JSON字段名: 部门ID
*/
String FIELD_DEPT_ID = "dept_id";
/**
* JSON字段名: 父部门ID
*/
String FIELD_PARENT_ID = "parent_id";
/**
* JSON字段名: 部门顺序列表
*/
String FIELD_DEPT_ORDER_LIST = "dept_order_list";
/**
* JSON字段名: 用户ID列表
*/
String FIELD_USERID_LIST = "userid_list";
/**
* JSON字段名: 返回结果
*/
String FIELD_RESULT = "result";
/**
* 语言代码: 简体中文
*/
String LANGUAGE_ZH_CN = "zh_CN";
/**
* 钉钉根部门ID
*/
Long ROOT_DEPT_ID = 1L;
}
@@ -0,0 +1,35 @@
package com.pig4cloud.pigx.admin.api.vo;
import cn.idev.excel.annotation.ExcelIgnore;
import cn.idev.excel.annotation.ExcelProperty;
import com.pig4cloud.pigx.common.excel.annotation.ExcelLine;
import lombok.Data;
import java.io.Serializable;
/**
* 钉钉部门导入VO
* 用于解析钉钉导出的Excel中的部门数据
*
* @author lengleng
* @date 2026-01-10
*/
@Data
public class DingTalkDeptExcelVO implements Serializable {
/**
* 导入时回显行号(用于错误定位)
*/
@ExcelLine
@ExcelIgnore
private Long lineNum;
/**
* 部门路径
* 格式: "公司/部门A/子部门B" (用"/"表示层级关系)
* 同一行可能包含多个部门路径,用";"分隔
*/
@ExcelProperty("部门")
private String deptPath;
}
@@ -39,17 +39,24 @@ public class DingUserExcelVo implements Serializable {
private String name;
/**
* 部门名称
* 部门路径
* 格式: "公司/部门A/子部门B" (用"/"表示层级关系)
* 支持多部门: "部门1;部门2" (用英文分号分隔,第一个为主部门)
*/
@NotBlank(message = "部门不能为空")
@ExcelProperty("部门")
private String deptName;
private String deptPath;
/**
* 邮箱
*/
@ExcelProperty("邮箱")
private String email;
/**
* 锁定标记
* 0: 正常(激活)
* 9: 锁定(未激活)
*/
@ExcelProperty("激活状态,0:是,9:否")
private String lockFlag;
@@ -0,0 +1,36 @@
package com.pig4cloud.pigx.admin.api.vo;
import cn.idev.excel.annotation.ExcelIgnore;
import cn.idev.excel.annotation.ExcelProperty;
import com.pig4cloud.pigx.common.excel.annotation.ExcelLine;
import lombok.Data;
import java.io.Serializable;
/**
* 企业微信部门导入VO
* 用于解析企业微信导出的通讯录Excel中的部门数据
*
* @author lengleng
* @date 2026-01-10
*/
@Data
public class WeChatDeptExcelVO implements Serializable {
/**
* 导入时回显行号(用于错误定位)
*/
@ExcelLine
@ExcelIgnore
private Long lineNum;
/**
* 部门路径
* 格式: "腾讯公司/微信事业群/广州研发部" (用"/"表示层级关系)
* 同一行可能包含多个部门路径,用";"分隔,如: "部门路径1;部门路径2"
* 企业微信Excel中对应"部门"列(第5列,索引4)
*/
@ExcelProperty(value = "部门", index = 4)
private String deptPath;
}
@@ -0,0 +1,85 @@
package com.pig4cloud.pigx.admin.api.vo;
import cn.idev.excel.annotation.ExcelIgnore;
import cn.idev.excel.annotation.ExcelProperty;
import com.pig4cloud.pigx.common.excel.annotation.ExcelLine;
import lombok.Data;
import java.io.Serializable;
/**
* 企业微信用户导入VO
* 用于解析企业微信导出的通讯录Excel中的用户数据
*
* @author lengleng
* @date 2026-01-10
*/
@Data
public class WeChatUserExcelVO implements Serializable {
/**
* 导入时回显行号(用于错误定位)
*/
@ExcelLine
@ExcelIgnore
private Long lineNum;
/**
* 姓名
*/
@ExcelProperty(value = "姓名", index = 0)
private String name;
/**
* 账号 (userid)
* 企业微信中的唯一标识
*/
@ExcelProperty(value = "账号", index = 1)
private String account;
/**
* 别名
*/
@ExcelProperty(value = "别名", index = 2)
private String alias;
/**
* 职务
*/
@ExcelProperty(value = "职务", index = 3)
private String position;
/**
* 部门路径
* 格式: "腾讯公司/微信事业群/广州研发部"
* 支持多部门: "部门1;部门2" (用英文分号分隔,第一个为主部门)
* 实际Excel列:第5列(索引4)
*/
@ExcelProperty(value = "部门", index = 4)
private String deptPath;
/**
* 性别 (男/女)
*/
@ExcelProperty(value = "性别", index = 5)
private String gender;
/**
* 手机号 (关键字段,用于判重)
*/
@ExcelProperty(value = "手机", index = 6)
private String phone;
/**
* 座机
*/
@ExcelProperty(value = "座机", index = 7)
private String telephone;
/**
* 邮箱
*/
@ExcelProperty(value = "邮箱", index = 8)
private String email;
}
@@ -1,18 +1,24 @@
package com.pig4cloud.pigx.admin.controller;
import com.pig4cloud.pigx.admin.api.entity.SysDept;
import com.pig4cloud.pigx.admin.api.vo.DingTalkDeptExcelVO;
import com.pig4cloud.pigx.admin.api.vo.DingUserExcelVo;
import com.pig4cloud.pigx.admin.api.vo.WeChatDeptExcelVO;
import com.pig4cloud.pigx.admin.api.vo.WeChatUserExcelVO;
import com.pig4cloud.pigx.admin.service.ConnectService;
import com.pig4cloud.pigx.admin.service.SysDeptService;
import com.pig4cloud.pigx.common.core.util.R;
import com.pig4cloud.pigx.common.excel.annotation.RequestExcel;
import com.pig4cloud.pigx.common.security.annotation.HasPermission;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 钉钉、微信 互联
*
@@ -28,50 +34,56 @@ public class ConnectController {
private final ConnectService connectService;
private final SysDeptService deptService;
/**
* 同步钉钉用户信息
* @return 操作结果
* 导入企业微信部门
*
* @param excelVOList Excel数据列表(通过@RequestExcel自动解析)
* @param bindingResult 校验结果
* @return 导入结果
*/
@PostMapping("/sync/ding/user")
@PostMapping("/import/wecom/dept")
@HasPermission("sys_connect_sync")
public R syncUser() {
for (SysDept sysDept : deptService.list()) {
connectService.syncDingUser(sysDept.getDeptId());
}
return R.ok();
public R importWeComDept(@RequestExcel(headRowNumber = 9) List<WeChatDeptExcelVO> excelVOList, BindingResult bindingResult) {
return connectService.importWeChatDept(excelVOList, bindingResult);
}
/**
* 同步钉钉部门信息
* @return 同步操作结果
* @see R
* 导入企业微信用户
*
* @param excelVOList Excel数据列表(通过@RequestExcel自动解析)
* @param bindingResult 校验结果
* @return 导入结果
*/
@PostMapping("/sync/ding/dept")
@PostMapping("/import/wecom/user")
@HasPermission("sys_connect_sync")
public R syncDept() {
return R.ok(connectService.syncDingDept());
public R importWeComUser(@RequestExcel(headRowNumber = 9) List<WeChatUserExcelVO> excelVOList, BindingResult bindingResult) {
return connectService.importWeChatUser(excelVOList, bindingResult);
}
/**
* 同步企业微信用户信息
* @return 同步操作结果
* 导入钉钉部门
*
* @param excelVOList Excel数据列表(通过@RequestExcel自动解析)
* @param bindingResult 校验结果
* @return 导入结果
*/
@PostMapping("/sync/cp/user")
@PostMapping("/import/ding/dept")
@HasPermission("sys_connect_sync")
public R syncCpUser() {
return connectService.syncCpUser();
public R importDingDept(@RequestExcel(headRowNumber = 3) List<DingTalkDeptExcelVO> excelVOList, BindingResult bindingResult) {
return connectService.importDingTalkDept(excelVOList, bindingResult);
}
/**
* 同步企业微信部门信息
* @return 包含同步结果的响应对象
* 导入钉钉用户
*
* @param excelVOList Excel数据列表(通过@RequestExcel自动解析)
* @param bindingResult 校验结果
* @return 导入结果
*/
@PostMapping("/sync/cp/dept")
@PostMapping("/import/ding/user")
@HasPermission("sys_connect_sync")
public R syncCpDept() {
return connectService.syncCpDept();
public R importDingUser(@RequestExcel(headRowNumber = 3) List<DingUserExcelVo> excelVOList, BindingResult bindingResult) {
return connectService.importDingTalkUser(excelVOList, bindingResult);
}
}
@@ -1,7 +1,13 @@
package com.pig4cloud.pigx.admin.service;
import com.pig4cloud.pigx.admin.api.vo.DingTalkDeptExcelVO;
import com.pig4cloud.pigx.admin.api.vo.DingUserExcelVo;
import com.pig4cloud.pigx.admin.api.vo.WeChatDeptExcelVO;
import com.pig4cloud.pigx.admin.api.vo.WeChatUserExcelVO;
import com.pig4cloud.pigx.common.core.util.R;
import me.chanjar.weixin.cp.config.impl.WxCpDefaultConfigImpl;
import org.springframework.validation.BindingResult;
import java.util.List;
/**
* @author lengleng
@@ -12,27 +18,39 @@ import me.chanjar.weixin.cp.config.impl.WxCpDefaultConfigImpl;
public interface ConnectService {
/**
* 同步钉钉部门
* 导入企业微信部门
*
* @param excelVOList Excel数据列表
* @param bindingResult 校验结果
* @return 导入结果
*/
Boolean syncDingDept();
R importWeChatDept(List<WeChatDeptExcelVO> excelVOList, BindingResult bindingResult);
/**
* 同步钉钉用户
* 导入企业微信用户
*
* @param excelVOList Excel数据列表
* @param bindingResult 校验结果
* @return 导入结果
*/
R syncDingUser(Long deptId);
R importWeChatUser(List<WeChatUserExcelVO> excelVOList, BindingResult bindingResult);
/**
* 同步企微部门
* @return
* 导入钉钉部门
*
* @param excelVOList Excel数据列表
* @param bindingResult 校验结果
* @return 导入结果
*/
R<Boolean> syncCpDept();
R importDingTalkDept(List<DingTalkDeptExcelVO> excelVOList, BindingResult bindingResult);
/**
* 同步企微用户
* @return
* 导入钉钉用户
*
* @param excelVOList Excel数据列表
* @param bindingResult 校验结果
* @return 导入结果
*/
R<Boolean> syncCpUser();
WxCpDefaultConfigImpl getCpConfig();
R importDingTalkUser(List<DingUserExcelVo> excelVOList, BindingResult bindingResult);
}
@@ -1,57 +1,37 @@
package com.pig4cloud.pigx.admin.service.impl;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.convert.Convert;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.toolkit.CollectionUtils;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.dingtalk.api.DefaultDingTalkClient;
import com.dingtalk.api.DingTalkClient;
import com.dingtalk.api.request.OapiGettokenRequest;
import com.dingtalk.api.request.OapiUserListidRequest;
import com.dingtalk.api.request.OapiV2DepartmentListsubRequest;
import com.dingtalk.api.request.OapiV2UserGetRequest;
import com.dingtalk.api.response.OapiGettokenResponse;
import com.dingtalk.api.response.OapiUserListidResponse;
import com.dingtalk.api.response.OapiV2DepartmentListsubResponse;
import com.dingtalk.api.response.OapiV2UserGetResponse;
import com.pig4cloud.pigx.admin.api.constant.UpmsErrorCodes;
import com.pig4cloud.pigx.admin.api.dto.UserDTO;
import com.pig4cloud.pigx.admin.api.entity.SysDept;
import com.pig4cloud.pigx.admin.api.entity.SysSocialDetails;
import com.pig4cloud.pigx.admin.api.entity.SysUser;
import com.pig4cloud.pigx.admin.mapper.SysSocialDetailsMapper;
import com.pig4cloud.pigx.admin.service.ConnectService;
import com.pig4cloud.pigx.admin.service.SysDeptService;
import com.pig4cloud.pigx.admin.service.SysUserService;
import com.pig4cloud.pigx.common.core.constant.SecurityConstants;
import com.pig4cloud.pigx.common.core.constant.enums.LoginTypeEnum;
import com.pig4cloud.pigx.common.core.util.MsgUtils;
import com.pig4cloud.pigx.common.core.util.R;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import me.chanjar.weixin.common.error.WxErrorException;
import me.chanjar.weixin.cp.api.WxCpService;
import me.chanjar.weixin.cp.api.impl.WxCpServiceImpl;
import me.chanjar.weixin.cp.bean.WxCpDepart;
import me.chanjar.weixin.cp.bean.WxCpUser;
import me.chanjar.weixin.cp.config.impl.WxCpDefaultConfigImpl;
import org.springframework.http.HttpMethod;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.LinkedList;
import java.util.List;
import java.util.*;
import java.util.stream.Collectors;
import java.util.ArrayList;
import com.pig4cloud.pigx.admin.api.vo.DingTalkDeptExcelVO;
import com.pig4cloud.pigx.admin.api.vo.DingUserExcelVo;
import com.pig4cloud.pigx.admin.api.vo.WeChatDeptExcelVO;
import com.pig4cloud.pigx.admin.api.vo.WeChatUserExcelVO;
import com.pig4cloud.pigx.common.excel.vo.ErrorMessage;
import org.springframework.validation.BindingResult;
/**
* 互联平台实现
* 互联平台服务实现类
* <p>
* 钉钉: https://open-dev.dingtalk.com/apiExplorer
* 提供企业微信和钉钉组织架构导入功能,包括部门导入和用户导入
*
* @author lengleng
* @date 2022/4/22
@@ -62,306 +42,506 @@ import java.util.List;
@RequiredArgsConstructor
public class ConnectServiceImpl implements ConnectService {
private final SysSocialDetailsMapper sysSocialDetailsMapper;
/**
* 部门服务
*/
private final SysDeptService sysDeptService;
/**
* 用户服务
*/
private final SysUserService sysUserService;
// JSON field name constants for DingTalk/WeChat API
private static final String FIELD_MOBILE = "mobile";
private static final String FIELD_AVATAR = "avatar";
private static final String FIELD_NICKNAME = "nickname";
private static final String FIELD_NAME = "name";
private static final String FIELD_EMAIL = "email";
private static final String FIELD_DEPT_ID = "dept_id";
private static final String FIELD_PARENT_ID = "parent_id";
private static final String FIELD_DEPT_ORDER_LIST = "dept_order_list";
private static final String FIELD_USERID_LIST = "userid_list";
private static final String FIELD_RESULT = "result";
// WeChat Enterprise config field name constants
private static final String FIELD_AGENT_ID = "agentId";
private static final String FIELD_TOKEN = "token";
private static final String FIELD_AES_KEY = "aesKey";
// Language code constant
private static final String LANGUAGE_ZH_CN = "zh_CN";
/**
* 同步钉钉部门
* 导入企业微信部门
* <p>
* 解析企业微信导出的通讯录Excel,支持两种格式:
* 1. 单部门路径: "腾讯公司/微信事业群/广州研发部"
* 2. 多部门路径: "部门A;部门B" (用分号分隔)
* <p>
* 处理逻辑:
* - 解析部门路径,从顶级部门开始逐级创建
* - 如果部门已存在(相同父部门下同名),则更新部门信息(覆盖模式)
* - 自动维护部门的 parentId 关系
*
* @return Boolean
* @param excelVOList Excel数据列表
* @param bindingResult 字段级别校验结果
* @return 导入结果
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Boolean syncDingDept() {
OapiV2DepartmentListsubRequest req = new OapiV2DepartmentListsubRequest();
req.setDeptId(1L);
req.setLanguage(LANGUAGE_ZH_CN);
public R importWeChatDept(List<WeChatDeptExcelVO> excelVOList, BindingResult bindingResult) {
List<ErrorMessage> errorMessageList = (List<ErrorMessage>) bindingResult.getTarget();
// 数据库中现有的部门
List<SysDept> deptList = sysDeptService.list(Wrappers.emptyWrapper());
// 加载现有部门缓存
Map<String, SysDept> deptKeyMap = sysDeptService.list(Wrappers.emptyWrapper()).stream()
.collect(Collectors.toMap(
dept -> buildDeptKey(dept.getParentId(), dept.getName()),
dept -> dept,
(v1, v2) -> v1
));
// 查询根部门下的所有部门
List<SysDept> insertDept = queryChildDept(req, getDingAccessToken(), deptList);
int successCount = 0;
// 去重
List<SysDept> syncDept = insertDept.stream().distinct().toList();
// 保存部门以及部门关系
if (CollectionUtils.isNotEmpty(syncDept)) {
syncDept.forEach(sysDeptService::save);
}
return Boolean.TRUE;
}
/**
* 同步部门下的钉钉用户
*
* @param deptId 部门ID
* @return R
*/
@SneakyThrows
@Override
@Transactional(rollbackFor = Exception.class)
public R syncDingUser(Long deptId) {
// 数据库中现有用户
List<SysUser> users = sysUserService.list(Wrappers.emptyWrapper());
String token = getDingAccessToken();
// 查询部门下的用户id列表
DingTalkClient client = new DefaultDingTalkClient(SecurityConstants.DING_DEPT_USERIDS_URL);
OapiUserListidRequest req = new OapiUserListidRequest();
req.setDeptId(deptId);
OapiUserListidResponse rsp = client.execute(req, token);
JSONObject jsonObject = JSONUtil.parseObj(rsp.getBody());
JSONObject result = jsonObject.getJSONObject(FIELD_RESULT);
if (result == null) {
return R.ok();
}
List<String> useridList = result.get(FIELD_USERID_LIST, List.class);
// 查询用户详情
for (String userid : useridList) {
DingTalkClient userClient = new DefaultDingTalkClient(SecurityConstants.DING_USER_INFO_URL);
OapiV2UserGetRequest userGetRequest = new OapiV2UserGetRequest();
userGetRequest.setUserid(userid);
OapiV2UserGetResponse userGetResponse = userClient.execute(userGetRequest, token);
JSONObject userResult = JSONUtil.parseObj(userGetResponse.getBody());
JSONObject userObj = JSONUtil.parseObj(userResult.get(FIELD_RESULT));
boolean exist = users.stream()
.filter(user -> StrUtil.isNotBlank(user.getPhone()))
.anyMatch(user -> user.getPhone().equals(userObj.getStr(FIELD_MOBILE)));
if (exist || StrUtil.isBlank(userObj.getStr(FIELD_MOBILE))) {
log.info("用户已存在或手机号不合法 {}", userid);
for (WeChatDeptExcelVO excel : excelVOList) {
if (StrUtil.isBlank(excel.getDeptPath())) {
errorMessageList.add(new ErrorMessage(excel.getLineNum(), Set.of("部门路径不能为空")));
continue;
}
// 用户部门信息
JSONArray deptOrders = userObj.getJSONArray(FIELD_DEPT_ORDER_LIST);
JSONObject deptInfo = (JSONObject) deptOrders.get(0);
// 用户信息
UserDTO userDTO = new UserDTO();
userDTO.setDeptId(deptInfo.getLong(FIELD_DEPT_ID));
userDTO.setAvatar(userObj.getStr(FIELD_AVATAR));
userDTO.setUsername(userObj.getStr(FIELD_MOBILE));
userDTO.setPhone(userObj.getStr(FIELD_MOBILE));
userDTO.setNickname(userObj.getStr(FIELD_NICKNAME));
userDTO.setName(userObj.getStr(FIELD_NAME));
userDTO.setEmail(userObj.getStr(FIELD_EMAIL));
// 初始化密码为 手机号
userDTO.setPassword(userObj.getStr(FIELD_MOBILE));
userDTO.setPasswordModifyTime(LocalDateTime.now());
sysUserService.saveUser(userDTO);
for (String deptPath : excel.getDeptPath().split(";")) {
deptPath = deptPath.trim();
if (StrUtil.isNotBlank(deptPath)) {
successCount += createDeptHierarchy(deptPath, deptKeyMap);
}
}
}
return R.ok();
if (CollUtil.isNotEmpty(errorMessageList)) {
return R.failed(errorMessageList);
}
return R.ok(null, String.format("部门导入成功! 新增: %d 个", successCount));
}
/**
* 同步企微部门
*
* @return R
* 构建部门唯一键 (parentId + name)
*/
@Override
@Transactional(rollbackFor = Exception.class)
public R<Boolean> syncCpDept() {
WxCpService wxCpService = new WxCpServiceImpl();
wxCpService.setWxCpConfigStorage(getCpConfig());
List<WxCpDepart> departList;
try {
departList = wxCpService.getDepartmentService().list(null);
} catch (WxErrorException e) {
log.error("获取企业微信部门列表失败", e);
return R.failed(MsgUtils.getMessage(UpmsErrorCodes.SYS_CONNECT_CP_DEPT_SYNC_ERROR));
}
if (CollUtil.isEmpty(departList)) {
return R.ok();
}
// 数据库中现有的部门
List<SysDept> deptList = sysDeptService.list(Wrappers.emptyWrapper());
departList.stream()
.filter(depart -> deptList.stream().noneMatch(sysDept -> depart.getName().equals(sysDept.getName())))
.map(dept -> {
SysDept sysDept = new SysDept();
sysDept.setName(dept.getName());
sysDept.setDeptId(dept.getId());
sysDept.setParentId(dept.getParentId());
sysDept.setSortOrder(dept.getOrder().intValue());
return sysDept;
})
.forEach(sysDeptService::save);
return R.ok();
private String buildDeptKey(Long parentId, String deptName) {
return parentId + StrUtil.COLON + deptName;
}
/**
* 同步企微用户
* 标准化手机号 (去除区号前缀)
* 支持格式: +86-17034642119, +86 17034642119, 86-17034642119, 8617034642119
*
* @return R
* @param phone 原始手机号
* @return 纯手机号 (11位)
*/
@Override
public R<Boolean> syncCpUser() {
WxCpService wxCpService = new WxCpServiceImpl();
wxCpService.setWxCpConfigStorage(getCpConfig());
List<WxCpUser> cpUserList;
try {
cpUserList = wxCpService.getUserService().listByDepartment(1L, true, 0);
} catch (WxErrorException e) {
log.error("获取企业微信用户列表失败", e);
return R.failed(MsgUtils.getMessage(UpmsErrorCodes.SYS_CONNECT_CP_USER_SYNC_ERROR));
private String normalizePhone(String phone) {
if (StrUtil.isBlank(phone)) {
return phone;
}
if (CollUtil.isEmpty(cpUserList)) {
return R.ok();
// 移除所有非数字字符,然后取后11位
String digits = phone.replaceAll("\\D", "");
if (digits.length() > 11) {
return digits.substring(digits.length() - 11);
}
return digits;
}
// 系统原有用户
List<SysUser> userList = sysUserService.list(Wrappers.emptyWrapper());
/**
* 创建部门层级
*
* @param deptPath 部门路径 "公司/部门/子部门"
* @param deptKeyMap 部门缓存 (会被更新)
* @return 新创建的部门数量
*/
private int createDeptHierarchy(String deptPath, Map<String, SysDept> deptKeyMap) {
int created = 0;
String[] deptNames = deptPath.split(StrUtil.SLASH);
Long parentId = 0L;
for (WxCpUser cpUser : cpUserList) {
boolean exist = userList.stream()
.filter(user -> StrUtil.isNotBlank(user.getPhone()))
.anyMatch(user -> user.getPhone().equals(cpUser.getMobile()));
if (exist || StrUtil.isBlank(cpUser.getMobile())) {
log.info("用户已存在或手机号不合法跳过 {}", cpUser);
for (String rawDeptName : deptNames) {
String deptName = rawDeptName.trim();
if (StrUtil.isBlank(deptName)) {
continue;
}
UserDTO user = new UserDTO();
user.setUsername(cpUser.getMobile());
user.setName(cpUser.getName());
user.setDeptId(cpUser.getDepartIds()[0]);
user.setEmail(cpUser.getEmail());
user.setPhone(cpUser.getMobile());
user.setAvatar(cpUser.getAvatar());
// 初始化密码为 手机号
user.setPassword(cpUser.getMobile());
sysUserService.saveUser(user);
String deptKey = buildDeptKey(parentId, deptName);
SysDept existingDept = deptKeyMap.get(deptKey);
if (existingDept != null) {
parentId = existingDept.getDeptId();
} else {
SysDept newDept = new SysDept();
newDept.setName(deptName);
newDept.setParentId(parentId);
newDept.setSortOrder(1000);
sysDeptService.save(newDept);
deptKeyMap.put(deptKey, newDept);
parentId = newDept.getDeptId();
created++;
}
}
return R.ok();
}
private String getDingAccessToken() {
SysSocialDetails dingTalk = sysSocialDetailsMapper.selectOne(Wrappers.<SysSocialDetails>lambdaQuery()
.eq(SysSocialDetails::getType, LoginTypeEnum.DINGTALK.getType()));
DingTalkClient client = new DefaultDingTalkClient(SecurityConstants.DING_OLD_GET_TOKEN);
OapiGettokenRequest request = new OapiGettokenRequest();
request.setAppkey(dingTalk.getAppId());
request.setAppsecret(dingTalk.getAppSecret());
request.setHttpMethod(HttpMethod.GET.name());
try {
OapiGettokenResponse response = client.execute(request);
return response.getAccessToken();
} catch (Exception e) {
log.error("调用钉钉异常", e);
}
return null;
}
public WxCpDefaultConfigImpl getCpConfig() {
SysSocialDetails cp = sysSocialDetailsMapper.selectOne(Wrappers.<SysSocialDetails>lambdaQuery()
.eq(SysSocialDetails::getType, LoginTypeEnum.WEIXIN_CP.getType()));
WxCpDefaultConfigImpl config = new WxCpDefaultConfigImpl();
config.setCorpId(cp.getAppId()); // 设置微信企业号的appid
config.setCorpSecret(cp.getAppSecret()); // 设置微信企业号的app
// corpSecret
JSONObject ext = JSONUtil.parseObj(cp.getExt());
config.setAgentId(ext.getInt(FIELD_AGENT_ID)); // 设置微信企业号应用ID
config.setToken(ext.getStr(FIELD_TOKEN)); // 设置微信企业号应用的token
config.setAesKey(ext.getStr(FIELD_AES_KEY)); // 设置微信企业号应用的EncodingAESKey
return config;
return created;
}
/**
* 查询当前部门下的所有部门
* 导入企业微信用户
* <p>
* 解析企业微信导出的通讯录 Excel 中的用户数据
* <p>
* 处理逻辑:
* - 通过手机号判断用户是否已存在
* - 已存在用户: 更新用户信息(覆盖模式)
* - 新用户: 创建用户账号
* - 部门不存在: 分配到根部门(parentId=0)
* - 初始密码: 手机号
*
* @param excelVOList Excel 数据列表
* @param bindingResult 字段级别校验结果
* @return 导入结果
*/
@SneakyThrows
private List<SysDept> queryChildDept(OapiV2DepartmentListsubRequest req, String token, List<SysDept> deptList) {
List<SysDept> sysDepts = new LinkedList<>();
@Override
@Transactional(rollbackFor = Exception.class)
public R importWeChatUser(List<WeChatUserExcelVO> excelVOList, BindingResult bindingResult) {
// 1. 获取字段级别校验失败的数据
List<ErrorMessage> errorMessageList = (List<ErrorMessage>) bindingResult.getTarget();
// 调用Api查询
DingTalkClient client = new DefaultDingTalkClient(SecurityConstants.DING_OLD_DEPT_URL);
OapiV2DepartmentListsubResponse rsp = client.execute(req, token);
// 2. 加载现有数据
List<SysUser> existingUsers = sysUserService.list(Wrappers.emptyWrapper());
Map<String, SysUser> phoneUserMap = existingUsers.stream()
.filter(user -> StrUtil.isNotBlank(user.getPhone()))
.collect(Collectors.toMap(SysUser::getPhone, user -> user, (v1, v2) -> v1));
JSONObject result = JSONUtil.parseObj(rsp.getBody());
List<JSONObject> resultList = result.get(FIELD_RESULT, List.class);
// 构建部门缓存 (使用精确匹配: parentId:name)
List<SysDept> allDepts = sysDeptService.list(Wrappers.emptyWrapper());
Map<String, SysDept> deptKeyMap = allDepts.stream()
.collect(Collectors.toMap(
dept -> buildDeptKey(dept.getParentId(), dept.getName()),
dept -> dept,
(v1, v2) -> v1
));
// 同时保留按名称查找的备用映射 (兼容简单场景)
Map<String, SysDept> deptNameMap = allDepts.stream()
.collect(Collectors.toMap(SysDept::getName, dept -> dept, (v1, v2) -> v1));
if (CollUtil.isEmpty(resultList)) {
return sysDepts;
// 3. 统计计数器
int successCount = 0;
int updateCount = 0;
// 4. 遍历Excel中的每一行
for (WeChatUserExcelVO excel : excelVOList) {
Set<String> errorMsg = new HashSet<>();
// 5. 基础字段校验
String phone = excel.getPhone();
if (StrUtil.isBlank(phone)) {
errorMsg.add("手机号不能为空");
}
if (StrUtil.isBlank(excel.getName())) {
errorMsg.add("姓名不能为空");
}
if (!errorMsg.isEmpty()) {
errorMessageList.add(new ErrorMessage(excel.getLineNum(), errorMsg));
continue;
}
// 6. 解析部门信息(支持多部门,使用层级精确匹配)
List<Long> deptIds = resolveDeptIdsWithHierarchy(excel.getDeptPath(), deptKeyMap, deptNameMap, excel.getName());
Long primaryDeptId = deptIds.isEmpty() ? 0L : deptIds.get(0);
// 7. 计算昵称 (优先使用别名,否则使用姓名)
String nickname = StrUtil.isNotBlank(excel.getAlias()) ? excel.getAlias() : excel.getName();
// 8. 判断用户是否已存在(通过手机号)
SysUser existingUser = phoneUserMap.get(phone);
if (existingUser != null) {
// 用户已存在,更新用户信息(覆盖模式)
existingUser.setName(excel.getName());
existingUser.setNickname(nickname);
existingUser.setEmail(excel.getEmail());
existingUser.setDeptId(primaryDeptId);
existingUser.setWxCpUserid(excel.getAccount());
sysUserService.updateById(existingUser);
updateCount++;
} else {
// 新用户,创建账号
UserDTO userDTO = new UserDTO();
userDTO.setUsername(phone);
userDTO.setPhone(phone);
userDTO.setName(excel.getName());
userDTO.setNickname(nickname);
userDTO.setEmail(excel.getEmail());
userDTO.setDeptIds(deptIds); // 设置多部门
userDTO.setPassword(phone);
userDTO.setPasswordModifyTime(LocalDateTime.now());
userDTO.setWxCpUserid(excel.getAccount());
sysUserService.saveUser(userDTO);
successCount++;
}
}
// 过滤出数据库存在的部门
List<SysDept> depts = resultList.stream()
.filter(dept -> deptList.stream().noneMatch(sysDept -> sysDept.getName().equals(dept.get(FIELD_NAME))))
.map(dept -> {
SysDept sysDept = new SysDept();
sysDept.setName(Convert.toStr(dept.get(FIELD_NAME)));
sysDept.setDeptId(Convert.toLong(dept.get(FIELD_DEPT_ID)));
sysDept.setParentId(Convert.toLong(dept.get(FIELD_PARENT_ID)));
return sysDept;
})
.toList();
// 递归查询下一级
if (CollectionUtils.isNotEmpty(depts)) {
sysDepts.addAll(depts);
depts.forEach(dept -> {
OapiV2DepartmentListsubRequest request = new OapiV2DepartmentListsubRequest();
request.setDeptId(dept.getDeptId());
request.setLanguage(LANGUAGE_ZH_CN);
List<SysDept> childDept = queryChildDept(request, token, deptList);
sysDepts.addAll(childDept);
});
// 9. 返回结果
if (CollUtil.isNotEmpty(errorMessageList)) {
return R.failed(errorMessageList);
}
return sysDepts;
return R.ok(null, String.format("用户导入成功! 新增: %d 个, 更新: %d 个", successCount, updateCount));
}
/**
* 解析部门路径,使用层级精确匹配
*
* @param deptPath 部门路径 (支持多部门用";"分隔)
* @param deptKeyMap 精确匹配缓存 (parentId:name -> SysDept)
* @param deptNameMap 名称匹配缓存 (备用)
* @param userName 用户名 (用于日志)
* @return 部门ID列表,第一个为主部门
*/
private List<Long> resolveDeptIdsWithHierarchy(String deptPath,
Map<String, SysDept> deptKeyMap,
Map<String, SysDept> deptNameMap,
String userName) {
List<Long> deptIds = new ArrayList<>();
if (StrUtil.isBlank(deptPath)) {
deptIds.add(0L);
return deptIds;
}
for (String path : deptPath.split(";")) {
String trimmedPath = path.trim();
if (StrUtil.isBlank(trimmedPath)) {
continue;
}
// 使用统一的部门路径解析 (支持降级匹配)
Long deptId = resolveDeptPath(trimmedPath, deptKeyMap, deptNameMap);
if (deptId != null) {
deptIds.add(deptId);
} else {
log.warn("部门不存在: {}, 用户: {}", trimmedPath, userName);
}
}
if (deptIds.isEmpty()) {
log.warn("用户没有有效部门,将分配到根部门, 用户: {}", userName);
deptIds.add(0L);
}
return deptIds;
}
/**
* 解析部门路径,返回最终部门ID
*
* @param deptPath 部门路径 "公司/部门A/子部门B"
* @param deptKeyMap 精确匹配缓存 (parentId:name -> SysDept)
* @param deptNameMap 名称匹配缓存 (可选,用于降级匹配,传null则不降级)
* @return 部门ID,未找到返回 null
*/
private Long resolveDeptPath(String deptPath,
Map<String, SysDept> deptKeyMap,
Map<String, SysDept> deptNameMap) {
String[] deptNames = deptPath.split(StrUtil.SLASH);
Long parentId = 0L;
SysDept targetDept = null;
for (String rawName : deptNames) {
String deptName = rawName.trim();
if (StrUtil.isBlank(deptName)) {
continue;
}
String deptKey = buildDeptKey(parentId, deptName);
targetDept = deptKeyMap.get(deptKey);
if (targetDept == null && deptNameMap != null) {
// 降级匹配: 仅按名称
targetDept = deptNameMap.get(deptName);
}
if (targetDept == null) {
return null;
}
parentId = targetDept.getDeptId();
}
return targetDept != null ? targetDept.getDeptId() : null;
}
/**
* 导入钉钉部门
* <p>
* 解析钉钉导出的 Excel 中的部门数据
* <p>
* 处理逻辑:
* - 解析部门路径 "公司/部门A/子部门B" (支持多部门用";"分隔)
* - 从顶级部门开始逐级创建
* - 使用 parentId + name 作为唯一键,支持同名部门
* - 部门已存在则复用,不存在则创建
* - 自动维护 parentId 层级关系
*
* @param excelVOList Excel 数据列表
* @param bindingResult 字段级别校验结果
* @return 导入结果
*/
@Override
@Transactional(rollbackFor = Exception.class)
public R importDingTalkDept(List<DingTalkDeptExcelVO> excelVOList, BindingResult bindingResult) {
List<ErrorMessage> errorMessageList = (List<ErrorMessage>) bindingResult.getTarget();
// 加载现有部门缓存
Map<String, SysDept> deptKeyMap = sysDeptService.list(Wrappers.emptyWrapper()).stream()
.collect(Collectors.toMap(
dept -> buildDeptKey(dept.getParentId(), dept.getName()),
dept -> dept,
(v1, v2) -> v1
));
int successCount = 0;
for (DingTalkDeptExcelVO excel : excelVOList) {
if (StrUtil.isBlank(excel.getDeptPath())) {
errorMessageList.add(new ErrorMessage(excel.getLineNum(), Set.of("部门路径不能为空")));
continue;
}
for (String deptPath : excel.getDeptPath().split(";")) {
deptPath = deptPath.trim();
if (StrUtil.isNotBlank(deptPath)) {
successCount += createDeptHierarchy(deptPath, deptKeyMap);
}
}
}
if (CollUtil.isNotEmpty(errorMessageList)) {
return R.failed(errorMessageList);
}
return R.ok(null, String.format("部门导入成功! 新增: %d 个", successCount));
}
/**
* 导入钉钉用户
* <p>
* 解析钉钉导出的 Excel 中的用户数据
* <p>
* 处理逻辑:
* - 通过手机号判断用户是否已存在
* - 已存在用户: 更新用户信息(覆盖模式)
* - 新用户: 创建用户账号,用户名=手机号
* - 部门不存在: 分配到根部门(parentId=0)
* - 初始密码: 手机号
*
* @param excelVOList Excel 数据列表
* @param bindingResult 字段级别校验结果
* @return 导入结果
*/
@Override
@Transactional(rollbackFor = Exception.class)
public R importDingTalkUser(List<DingUserExcelVo> excelVOList, BindingResult bindingResult) {
// 1. 获取字段级别校验失败的数据
List<ErrorMessage> errorMessageList = (List<ErrorMessage>) bindingResult.getTarget();
// 2. 加载现有数据
List<SysUser> existingUsers = sysUserService.list(Wrappers.emptyWrapper());
Map<String, SysUser> phoneUserMap = existingUsers.stream()
.filter(user -> StrUtil.isNotBlank(user.getPhone()))
.collect(Collectors.toMap(SysUser::getPhone, user -> user, (v1, v2) -> v1));
// 构建部门缓存 (使用精确匹配: parentId:name)
List<SysDept> allDepts = sysDeptService.list(Wrappers.emptyWrapper());
Map<String, SysDept> deptKeyMap = allDepts.stream()
.collect(Collectors.toMap(
dept -> buildDeptKey(dept.getParentId(), dept.getName()),
dept -> dept,
(v1, v2) -> v1
));
// 同时保留按名称查找的备用映射 (兼容简单场景)
Map<String, SysDept> deptNameMap = allDepts.stream()
.collect(Collectors.toMap(SysDept::getName, dept -> dept, (v1, v2) -> v1));
// 3. 统计计数器
int successCount = 0;
int updateCount = 0;
// 4. 遍历Excel中的每一行
for (DingUserExcelVo excel : excelVOList) {
Set<String> errorMsg = new HashSet<>();
// 5. 基础字段校验 (标准化手机号,去除区号前缀)
String phone = normalizePhone(excel.getPhone());
if (StrUtil.isBlank(phone)) {
errorMsg.add("手机号不能为空");
}
if (StrUtil.isBlank(excel.getName())) {
errorMsg.add("姓名不能为空");
}
if (CollUtil.isNotEmpty(errorMsg)) {
errorMessageList.add(new ErrorMessage(excel.getLineNum(), errorMsg));
continue;
}
// 6. 解析部门信息
List<Long> deptIds = new ArrayList<>();
if (StrUtil.isNotBlank(excel.getDeptPath())) {
// 支持多部门: "部门路径1;部门路径2"
String[] deptPaths = excel.getDeptPath().split(";");
for (String deptPath : deptPaths) {
deptPath = deptPath.trim();
if (StrUtil.isBlank(deptPath)) {
continue;
}
// 解析部门层级路径
Long deptId = resolveDeptPath(deptPath, deptKeyMap, deptNameMap);
if (deptId != null) {
deptIds.add(deptId);
}
}
}
// 如果没有找到部门,分配到根部门
if (CollUtil.isEmpty(deptIds)) {
deptIds.add(0L);
}
// 7. 计算昵称 (钉钉没有别名字段,直接使用姓名)
String nickname = excel.getName();
// 8. 判断用户是否存在 (通过手机号)
SysUser existingUser = phoneUserMap.get(phone);
if (existingUser != null) {
// 用户已存在,更新用户信息 (参考企业微信实现,使用 UserDTO 统一处理)
UserDTO userDTO = new UserDTO();
userDTO.setUserId(existingUser.getUserId());
userDTO.setName(excel.getName());
userDTO.setUsername(phone);
userDTO.setNickname(nickname);
userDTO.setEmail(excel.getEmail());
userDTO.setDeptIds(deptIds); // 设置多部门,由 updateUser 自动处理部门关联
userDTO.setLockFlag(StrUtil.isNotBlank(excel.getLockFlag()) ? excel.getLockFlag() : "0");
sysUserService.updateUser(userDTO);
updateCount++;
} else {
// 新用户,创建账号 (参考企业微信实现)
UserDTO userDTO = new UserDTO();
userDTO.setUsername(phone); // 用户名=手机号
userDTO.setPhone(phone);
userDTO.setName(excel.getName());
userDTO.setNickname(nickname);
userDTO.setEmail(excel.getEmail());
userDTO.setDeptIds(deptIds); // 设置多部门
userDTO.setPassword(phone); // 初始密码=手机号
userDTO.setPasswordModifyTime(LocalDateTime.now());
userDTO.setLockFlag(StrUtil.isNotBlank(excel.getLockFlag()) ? excel.getLockFlag() : "0");
sysUserService.saveUser(userDTO);
successCount++;
}
}
// 8. 返回结果
if (CollUtil.isNotEmpty(errorMessageList)) {
return R.failed(errorMessageList);
}
return R.ok(null, String.format("用户导入成功! 新增: %d 个, 更新: %d 个", successCount, updateCount));
}
}