HR人员和组织信息同步AD域服务器实战方法JAVA
HR人员和组织信息同步AD域服务器
- 前期准备
- AD域基础知识整理
- HR同步AD的逻辑
- 代码结构
- 配置文件设置
- 启动类
- HR组织的Bean
- HR人员Bean
- 获取HR人员和组织信息的类
- AD中处理组织和人员的类
- 日志配置
- POM.xml文件
- 生成EXE文件
- 服务器定时任务
- 异常问题注意事项
前期准备
1、开发语言:Java
2、开发框架:无
3、日志框架:logback
4、服务器:windows2016(已部署了AD域,这里不过多介绍)
5、开发工具:idea、launch4j(用于部署服务器定时任务生成exe用)
6、AD域证书(客户端连接AD使用)
AD域基础知识整理
AD域服务器中重要的知识点或属性描述:
- “CN”(Common Name,常用名),用于指定对象的具体名称
- “DC”(Domain Component,域组件),用来标识域的各个部分
- “Description”,可对对象进行详细的描述说明,在这里存放的为组织编码
- “adminDescription”,用于存放组织id
- “DistinguishedName”(可分辨名称),是 OU 在 AD 中的唯一标识,它描述了 OU 在域中的完整路径
- “mobile”,用于记录用户的手机号
- “department”,用于记录部门的ID
- “displayName”,用于记录用户的显示名称
- “info”,用于记录用户的ID
- “sn”,用于记录用户的姓
- “givenName”,用于记录用户的名
- “unicodePwd”,用于记录用户的密码,赋值时用十六进制
- “userAccountControl”,用于控制用户状态,正常账户为514,禁用账户为514
- “pwdLastSet”,用于控制用户下次登陆时是否需要更改密码
HR同步AD的逻辑
1、数据准备:将HR中的组织和人员信息建立一个Bean方法
2、连接与认证:
①连接HR系统,可以通过接口,也可通过导入外部jar包的方式(此文章用导入外部jar包的方式获取HR中的信息)
②建立AD的系统连接
3、根据HR的信息处理AD中的信息,先处理组织,再处理人员
4、记录日志并打印
代码结构
配置文件设置
记录AD和HR系统的各种信息
public class AppConfig {// SHR Configurationpublic static final String SHR_URL = "HR系统地址";public static final String SHR_ORG_SERVICE = "HR系统获取组织服务";public static final String SHR_PERSON_SERVICE = "HR系统获取人员服务";// AD Configurationpublic static final String AD_URL = "AD域的地址";public static final String AD_ADMIN_DN = "";public static final String AD_ADMIN_PASSWORD = "管理员密码";public static final String AD_INIT_PASSWORD = "初始密码";public static final String AD_BASE_DN = "根OU";public static final String AD_ARCHIVED_GROUP = "封存人员组";// Status codespublic static final String STATUS_DISABLED = "1";public static final String STATUS_ENABLED = "0";public static final String PERSON_STATUS_ENABLED = "1";public static final String PERSON_STATUS_DISABLED = "0";
}
启动类
import java.util.List;public class HrAdSynchronizer {/*定义日志对象*/private static final Logger logger = LoggerFactory.getLogger(HrAdSynchronizer.class);/*定义HR对象*/private final ShrService shrService;/*定义AD对象*/private final AdService adService;/*** 日志记录方法*/public HrAdSynchronizer() {// 确保日志目录存在并打印出实际路径String logDir = SyncUtils.ensureDirectoryExists("logs");System.out.println("日志目录: " + logDir);this.shrService = new ShrService();this.adService = new AdService();}/*** 执行方法*/public void synchronize() {try {logger.info("开始SHR到AD的同步过程");// 同步组织结构(包含变更处理)/*获取HR中的组织信息*/List<ShrOrganization> organizations = shrService.getOrganizations();/*打印日志*/logger.info("从SHR获取到 {} 个组织", organizations.size());/*将HR中的组织信息同步至AD*/adService.syncOrganizations(organizations);// 同步人员信息(包含变更处理)/*获取HR中的人员信息*/List<ShrPerson> personnel = shrService.getPersonnel();/*打印日志*/logger.info("从SHR获取到 {} 个人员", personnel.size());/*将HR中的人员信息同步至AD*/adService.syncPersonnel(personnel);/*打印日志*/logger.info("同步过程成功完成");} catch (Exception e) {logger.error("同步过程发生错误: {}", e.getMessage(), e);} finally {adService.close();logger.info("同步过程结束");}}/*** 启动方法* @param args*/public static void main(String[] args) {/*打印日志,标记功能程序*/logger.info("启动HR-AD同步程序");/*调用日志文件自动生成的方法,可注释*/HrAdSynchronizer synchronizer = new HrAdSynchronizer();/*调用执行方法*/synchronizer.synchronize();}
}
HR组织的Bean
public class ShrOrganization {private String fnumber;private String name;private String easdeptId;private String superior;private String status;// Getters and setterspublic String getFnumber() {return fnumber;}public void setFnumber(String fnumber) {this.fnumber = fnumber;}public String getName() {return name;}public void setName(String name) {this.name = name;}public String getEasdeptId() {return easdeptId;}public void setEasdeptId(String easdeptId) {this.easdeptId = easdeptId;}public String getSuperior() {return superior;}public void setSuperior(String superior) {this.superior = superior;}public String getStatus() {return status;}public void setStatus(String status) {this.status = status;}@Overridepublic String toString() {return "ShrOrganization{" +"fnumber='" + fnumber + '\'' +", name='" + name + '\'' +", easdeptId='" + easdeptId + '\'' +", superior='" + superior + '\'' +", status='" + status + '\'' +'}';}
}
HR人员Bean
public class ShrPerson {private String empTypeName;private String mobile;private String orgNumber;private String easuserId;private String supFnumber;private String supname;private String superior;private String status;private String username;private String deptId;// Getters and setterspublic String getEmpTypeName() {return empTypeName;}public void setEmpTypeName(String empTypeName) {this.empTypeName = empTypeName;}public String getMobile() {return mobile;}public void setMobile(String mobile) {this.mobile = mobile;}public String getOrgNumber() {return orgNumber;}public void setOrgNumber(String orgNumber) {this.orgNumber = orgNumber;}public String getEasuserId() {return easuserId;}public void setEasuserId(String easuserId) {this.easuserId = easuserId;}public String getSupFnumber() {return supFnumber;}public void setSupFnumber(String supFnumber) {this.supFnumber = supFnumber;}public String getSupname() {return supname;}public void setSupname(String supname) {this.supname = supname;}public String getSuperior() {return superior;}public void setSuperior(String superior) {this.superior = superior;}public String getStatus() {return status;}public void setStatus(String status) {this.status = status;}public String getUsername() {return username;}public void setUsername(String username) {this.username = username;}public String getDeptId() {return deptId;}public void setDeptId(String deptId) {this.deptId = deptId;}@Overridepublic String toString() {return "ShrPerson{" +"empTypeName='" + empTypeName + '\'' +", mobile='" + mobile + '\'' +", orgNumber='" + orgNumber + '\'' +", easuserId='" + easuserId + '\'' +", supFnumber='" + supFnumber + '\'' +", supname='" + supname + '\'' +", superior='" + superior + '\'' +", status='" + status + '\'' +", username='" + username + '\'' +", deptId='" + deptId + '\'' +'}';}
}
获取HR人员和组织信息的类
import com.shr.api.SHRClient;
import com.shr.api.Response;
import com.sync.config.AppConfig;
import com.sync.model.ShrOrganization;
import com.sync.model.ShrPerson;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;public class ShrService {private static final Logger logger = LoggerFactory.getLogger(ShrService.class);private final SHRClient shrClient;public ShrService() {logger.info("初始化SHR服务,连接到 {}", AppConfig.SHR_URL);this.shrClient = new SHRClient();}/*** 获取SHR中的组织列表* @return 返回list*/public List<ShrOrganization> getOrganizations() {/*定义一个返回list对象*/List<ShrOrganization> organizations = new ArrayList<>();try {/*记录开始调用SHR组织日志*/logger.info("调用SHR组织服务: {}", AppConfig.SHR_ORG_SERVICE);/*定义请求参数*/Map<String, Object> param = new HashMap<>();/*发起请求*/Response response = shrClient.executeService(AppConfig.SHR_URL, AppConfig.SHR_ORG_SERVICE, param);/*请求失败处理*/if (response == null || response.getData() == null) {/*记录失败日志*/logger.error("从SHR获取组织数据失败,响应为空");/*返回失败结果*/return organizations;}/*解析JSON数据*/JSONArray orgArray = JSON.parseArray(response.getData().toString());/*记录json日志数量*/logger.debug("获取到原始组织数据: {} 条记录", orgArray.size());int enabledCount = 0;/*遍历组织json*/for (int i = 0; i < orgArray.size(); i++) {/*获取第i个对象*/JSONObject orgJson = orgArray.getJSONObject(i);/*获取组织状态*/String status = orgJson.getString("status");/*只处理启用状态(status=0)的组织*/if (AppConfig.STATUS_ENABLED.equals(status)) {/*定义组织对象*/ShrOrganization organization = new ShrOrganization();/*组织编码赋值*/organization.setFnumber(orgJson.getString("fnumber"));/*组织名称赋值*/organization.setName(orgJson.getString("name"));/*组织id赋值*/organization.setEasdeptId(orgJson.getString("easdept_id"));/*上级组织部门id赋值*/organization.setSuperior(orgJson.getString("superior"));/*组织状态赋值*/organization.setStatus(status);/*加入list中*/organizations.add(organization);/*记录解析日志*/logger.debug("解析启用组织: {}", organization);/*计数器+1*/enabledCount++;} else {logger.debug("跳过禁用组织: fnumber={}, name={}",orgJson.getString("fnumber"), orgJson.getString("name"));}}/*记录总的处理日志*/logger.info("成功解析 {} 个组织,其中启用状态的有 {} 个", orgArray.size(), enabledCount);} catch (Exception e) {logger.error("从SHR获取组织信息时发生错误: {}", e.getMessage(), e);}return organizations;}/*** 获取SHR中人员信息** @return 返回人员List*/public List<ShrPerson> getPersonnel() {/*定义一个List返回对象*/List<ShrPerson> personnel = new ArrayList<>();try {/*记录开始日志*/logger.info("调用SHR人员服务: {}", AppConfig.SHR_PERSON_SERVICE);/*定义请求参数*/Map<String, Object> param = new HashMap<>();/*发起请求*/Response response = shrClient.executeService(AppConfig.SHR_URL, AppConfig.SHR_PERSON_SERVICE, param);/*请求判空*/if (response == null || response.getData() == null) {/*记录失败日志*/logger.error("从SHR获取人员数据失败,响应为空");/*返回结果*/return personnel;}/*解析JSON数据*/JSONArray personArray = JSON.parseArray(response.getData().toString());/*记录人员数量日志*/logger.debug("获取到原始人员数据: {} 条记录", personArray.size());int enabledCount = 0;/*遍历json*/for (int i = 0; i < personArray.size(); i++) {/*获取json数据*/JSONObject personJson = personArray.getJSONObject(i);/*定义人员对象*/ShrPerson shrPerson = new ShrPerson();/*员工类型*/shrPerson.setEmpTypeName(personJson.getString("empType_name"));/*手机号*/shrPerson.setMobile(personJson.getString("mobile"));/*部门编码*/shrPerson.setOrgNumber(personJson.getString("org_number"));/*人员ID*/shrPerson.setEasuserId(personJson.getString("easuser_id"));/*上级部门编码*/shrPerson.setSupFnumber(personJson.getString("supFnumber"));/*上级部门名称*/shrPerson.setSupname(personJson.getString("supname"));/*上级部门ID*/shrPerson.setSuperior(personJson.getString("superior"));/*人员状态*/shrPerson.setStatus(personJson.getString("status"));/*人员名称*/shrPerson.setUsername(personJson.getString("username"));/*人员所在部门ID*/shrPerson.setDeptId(personJson.getString("dept_id"));/*只添加启用状态的人员*/if (AppConfig.PERSON_STATUS_ENABLED.equals(shrPerson.getStatus())) {/*加入list*/personnel.add(shrPerson);/*计数器+1*/enabledCount++;/*记录人员日志*/logger.debug("解析启用人员: {}", shrPerson);} else {/*记录跳过日志*/logger.debug("跳过禁用人员: easuserId={}, username={}, deptId={}",personJson.getString("easuser_id"),personJson.getString("username"),personJson.getString("dept_id"));}}/*记录启动状态人数*/logger.info("成功解析 {} 个人员,其中启用状态的有 {} 个", personArray.size(), enabledCount);} catch (Exception e) {logger.error("从SHR获取人员信息时发生错误: {}", e.getMessage(), e);}return personnel;}
}
AD中处理组织和人员的类
import com.sync.config.AppConfig;
import com.sync.model.ShrOrganization;
import com.sync.model.ShrPerson;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;import javax.naming.Context;
import javax.naming.NamingException;
import javax.naming.directory.*;
import javax.naming.ldap.Control;
import javax.naming.ldap.InitialLdapContext;
import javax.naming.ldap.LdapContext;
import javax.naming.NamingEnumeration;
import javax.naming.ldap.PagedResultsControl;
import java.io.IOException;
import java.util.*;public class AdService {/*日志对象*/private static final Logger logger = LoggerFactory.getLogger(AdService.class);/*特殊组织编码,这些组织需要跳过处理*/private static final String SPECIAL_ORG_CODE = "999";/*记录AD的连接*/private LdapContext ldapContext;/*缓存AD中的组织信息,用于变更检测*/private Map<String, String> orgIdToDnMap = new HashMap<>();/*同步到AD的组织对象*/private Map<String, Attributes> orgDnToAttrsMap = new HashMap<>();/*存储特殊组织的DN,这些组织不会被处理*/private Set<String> specialOrgDns = new HashSet<>();/*增加组织编码到组织名称的映射缓存*/private Map<String, String> orgNumberToNameMap = new HashMap<>();/*添加 DN 到组织名称的映射*/private Map<String, String> dnToOuNameMap = new HashMap<>();/*构造方法*/public AdService() {initContext();// 初始化时加载现有组织结构loadExistingOrganizations();}/*AD的连接初始化*/private void initContext() {try {logger.info("初始化AD连接,URL: {}", AppConfig.AD_URL);Hashtable<String, String> env = new Hashtable<>();env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory");env.put(Context.PROVIDER_URL, AppConfig.AD_URL);env.put(Context.SECURITY_AUTHENTICATION, "simple");env.put(Context.SECURITY_PRINCIPAL, AppConfig.AD_ADMIN_DN);env.put(Context.SECURITY_CREDENTIALS, AppConfig.AD_ADMIN_PASSWORD);env.put(Context.SECURITY_PROTOCOL, "ssl");ldapContext = new InitialLdapContext(env, null);logger.info("成功连接到Active Directory");} catch (NamingException e) {logger.error("连接Active Directory失败: {}", e.getMessage(), e);}}/*** 加载AD中已存在的组织结构到缓存*/private void loadExistingOrganizations() {try {logger.info("加载AD中现有组织结构");/*定义搜索控制器*/SearchControls searchControls = new SearchControls();/*设置搜索深度*/searchControls.setSearchScope(SearchControls.SUBTREE_SCOPE);/*设置查询内容*/String[] returnedAtts = {"distinguishedName", "ou", "adminDescription", "description"};/*设置查询对象*/searchControls.setReturningAttributes(returnedAtts);/*设置过滤条件*/String searchFilter = "(objectClass=organizationalUnit)";/*执行查询*/NamingEnumeration<SearchResult> results = ldapContext.search(AppConfig.AD_BASE_DN, searchFilter, searchControls);int count = 0;int specialCount = 0;int noAdminDescCount = 0;/*遍历查询结果*/while (results.hasMoreElements()) {/*获取查询结果*/SearchResult result = results.next();/*获取dn*/String dn = result.getNameInNamespace();/*获取其余结果*/Attributes attrs = result.getAttributes();// 保存 DN 和 OU 名称的映射,以便后续使用if (attrs.get("ou") != null) {/*获取ou*/String ouName = attrs.get("ou").get().toString();/*将OU放入缓存*/dnToOuNameMap.put(dn, ouName);}// 检查是否是特殊组织boolean isSpecial = false;if (attrs.get("description") != null) {String description = attrs.get("description").get().toString();if (SPECIAL_ORG_CODE.equals(description)) {specialOrgDns.add(dn);isSpecial = true;specialCount++;logger.debug("识别到特殊组织(编码999): {}", dn);}}if (attrs.get("ou") != null) {String ouName = attrs.get("ou").get().toString();if (AppConfig.AD_ARCHIVED_GROUP.equals(ouName)) {specialOrgDns.add(dn);isSpecial = true;specialCount++;logger.debug("识别到特殊组织(封存人员组): {}", dn);}}// 如果不是特殊组织且有adminDescription,则添加到正常组织映射if (!isSpecial && attrs.get("adminDescription") != null) {String orgId = attrs.get("adminDescription").get().toString();orgIdToDnMap.put(orgId, dn);orgDnToAttrsMap.put(dn, attrs);count++;} else if (!isSpecial) {// 记录缺少adminDescription的组织noAdminDescCount++;orgDnToAttrsMap.put(dn, attrs);}}logger.info("已加载 {} 个组织到缓存, {} 个特殊组织被排除, {} 个组织缺少adminDescription",count, specialCount, noAdminDescCount);} catch (NamingException e) {logger.error("加载组织结构时发生错误: {}", e.getMessage(), e);}}/*** 同步组织到AD,处理变更情况*/public void syncOrganizations(List<ShrOrganization> organizations) {logger.info("开始同步组织到AD,共 {} 个组织", organizations.size());try {// 首先构建组织编码到名称的映射,用于后续定位上级组织buildOrgNumberToNameMap(organizations);// 记录当前同步中处理过的组织ID,用于后续检测删除操作Set<String> processedOrgIds = new HashSet<>();/***先处理缺少adminDescription但distinguishedName匹配的组织,执行一次后,默认先不执行*/handleOrganizationsWithoutAdminDescription(organizations);// 按上级组织ID排序,确保先处理上级组织List<ShrOrganization> sortedOrgs = sortOrganizationsByHierarchy(organizations);for (ShrOrganization org : sortedOrgs) {// 跳过特殊组织编码if (SPECIAL_ORG_CODE.equals(org.getFnumber())) {logger.info("跳过特殊组织编码 {}: {}", org.getFnumber(), org.getName());continue;}// 跳过封存人员组if (AppConfig.AD_ARCHIVED_GROUP.equals(org.getName())) {logger.info("跳过封存人员组: {}", org.getName());continue;}String orgId = org.getEasdeptId();processedOrgIds.add(orgId);// 组织在AD中存在的DNString existingDn = orgIdToDnMap.get(orgId);//existingDn="OU=测试test,OU=集团数字化本部,OU=集团数字化部,OU=多维联合集团股份有限公司,OU=多维联合集团,OU=Domain Controllers,DC=duowei,DC=net,DC=cn";// 根据上级组织确定目标DNString targetDn = getTargetDnWithParent(org);//targetDn = "OU=测试test,OU=集团数字化技术部,OU=集团数字化部,OU=多维联合集团股份有限公司,OU=多维联合集团,OU=Domain Controllers,DC=duowei,DC=net,DC=cn";// 检查组织状态if (AppConfig.STATUS_DISABLED.equals(org.getStatus())) {if (existingDn != null) {logger.info("组织 {} (ID: {}) 在SHR中被禁用,标记为禁用", org.getName(), orgId);markOrganizationAsDisabled(existingDn, org);}continue;}// 处理三种情况:新建、更新属性、重命名(移动)if (existingDn == null) {System.err.println(existingDn);// 新建组织createNewOrganization(targetDn, org);} else if (!existingDn.equals(targetDn)) {System.err.println(existingDn);// 组织名称或层级变更,需要重命名/移动renameOrganization(existingDn, targetDn, org);} else {// 组织名称和层级未变,但可能需要更新其他属性updateOrganizationAttributes(existingDn, org);}}// 处理在SHR中不存在但在AD中存在的组织(删除或禁用)handleDeletedOrganizations(processedOrgIds);logger.info("组织同步完成");} catch (Exception e) {logger.error("同步组织到AD时发生错误: {}", e.getMessage(), e);}}/*** 处理缺少adminDescription但distinguishedName匹配的组织*/private void handleOrganizationsWithoutAdminDescription(List<ShrOrganization> organizations) {logger.info("检查缺少adminDescription但DN匹配的组织");int fixedCount = 0;for (ShrOrganization org : organizations) {String targetDn = getTargetDnWithParent(org);String orgId = org.getEasdeptId();// 如果organizationId不在映射中,但DN存在于AD中if (!orgIdToDnMap.containsKey(orgId) && orgDnToAttrsMap.containsKey(targetDn)) {logger.info("发现缺少adminDescription的组织,DN: {}, 组织ID: {}", targetDn, orgId);try {// 添加adminDescription属性ModificationItem[] mods = new ModificationItem[1];mods[0] = new ModificationItem(DirContext.REPLACE_ATTRIBUTE,new BasicAttribute("adminDescription", orgId));ldapContext.modifyAttributes(targetDn, mods);// 更新缓存orgIdToDnMap.put(orgId, targetDn);Attributes attrs = orgDnToAttrsMap.get(targetDn);attrs.put("adminDescription", orgId);logger.info("成功添加adminDescription属性到组织: {}", targetDn);fixedCount++;} catch (NamingException e) {logger.error("添加adminDescription属性时发生错误: {}", e.getMessage(), e);}}}if (fixedCount > 0) {logger.info("共修复 {} 个缺少adminDescription的组织", fixedCount);}}/*** 构建组织编码到名称的映射*/private void buildOrgNumberToNameMap(List<ShrOrganization> organizations) {orgNumberToNameMap.clear();for (ShrOrganization org : organizations) {if (org.getFnumber() != null && org.getName() != null) {orgNumberToNameMap.put(org.getEasdeptId(), org.getName());}}logger.debug("构建了 {} 个组织编码到名称的映射", orgNumberToNameMap.size());}/*** 按层级关系排序组织,确保先处理上级组织*/private List<ShrOrganization> sortOrganizationsByHierarchy(List<ShrOrganization> organizations) {List<ShrOrganization> sorted = new ArrayList<>(organizations);// 首先处理没有上级的组织,然后处理有上级的组织sorted.sort((o1, o2) -> {boolean o1HasParent = o1.getSuperior() != null && !o1.getSuperior().isEmpty();boolean o2HasParent = o2.getSuperior() != null && !o2.getSuperior().isEmpty();if (!o1HasParent && o2HasParent) return -1;if (o1HasParent && !o2HasParent) return 1;return 0;});return sorted;}/*** 根据上级组织获取目标DN*/private String getTargetDnWithParent(ShrOrganization org) {// 额外添加查找逻辑String dn = findExistingDnByOuName(org.getName());if (dn != null) {return dn;}// 原有的逻辑作为后备if (org.getSuperior() == null || org.getSuperior().isEmpty()) {// 没有上级组织,直接放在基础DN下return "OU=" + org.getName() + "," + AppConfig.AD_BASE_DN;}// 查找上级组织名称String parentNumber = org.getSuperior();String parentName = orgNumberToNameMap.get(parentNumber);if (parentName == null) {logger.warn("找不到上级组织 {},组织 {} 将直接放在基础DN下", parentNumber, org.getName());return "OU=" + org.getName() + "," + AppConfig.AD_BASE_DN;}// 检查上级组织是否在AD中存在String parentDN = findOrganizationDnByName(parentName);if (parentDN != null) {// 上级组织存在,将当前组织放在上级组织下return "OU=" + org.getName() + "," + parentDN;} else {logger.warn("上级组织 {} 在AD中不存在,组织 {} 将直接放在基础DN下", parentName, org.getName());return "OU=" + org.getName() + "," + AppConfig.AD_BASE_DN;}}/*** 根据组织名称查找DN*/private String findOrganizationDnByName(String orgName) {try {SearchControls searchControls = new SearchControls();searchControls.setSearchScope(SearchControls.SUBTREE_SCOPE);searchControls.setReturningAttributes(new String[]{"distinguishedName"});String searchFilter = "(&(objectClass=organizationalUnit)(ou=" + orgName + "))";NamingEnumeration<SearchResult> results = ldapContext.search(AppConfig.AD_BASE_DN, searchFilter, searchControls);if (results.hasMoreElements()) {SearchResult result = results.next();return result.getNameInNamespace();}} catch (NamingException e) {logger.error("查找组织 {} 时发生错误: {}", orgName, e.getMessage(), e);}return null;}/*** 根据OU名称查找可能存在的DN*/private String findExistingDnByOuName(String ouName) {for (Map.Entry<String, String> entry : dnToOuNameMap.entrySet()) {if (ouName.equals(entry.getValue())) {return entry.getKey();}}return null;}/*** 创建新组织(不包含封存)*/private void createNewOrganization(String orgDn, ShrOrganization org) throws NamingException {logger.info("创建新组织: {} (ID: {})", org.getName(), org.getFnumber());Attributes attrs = new BasicAttributes();Attribute objClass = new BasicAttribute("objectClass");objClass.add("top");objClass.add("organizationalUnit");attrs.put(objClass);attrs.put("ou", org.getName());attrs.put("description", org.getFnumber());attrs.put("adminDescription", org.getEasdeptId());ldapContext.createSubcontext(orgDn, attrs);// 更新缓存orgIdToDnMap.put(org.getFnumber(), orgDn);orgDnToAttrsMap.put(orgDn, attrs);logger.info("成功创建组织: {}", org.getName());}/*** 创建封存组织* @param orgDn* @throws NamingException*/private void createFCNewOrganization(String orgDn) throws NamingException {logger.info("创建封存人员组");Attributes attrs = new BasicAttributes();Attribute objClass = new BasicAttribute("objectClass");objClass.add("top");objClass.add("organizationalUnit");attrs.put(objClass);attrs.put("ou", "封存人员组");attrs.put("description", "000");attrs.put("adminDescription", "000");ldapContext.createSubcontext(orgDn, attrs);logger.info("成功创建封存人员组");}/*** 更新组织属性*/private void updateOrganizationAttributes(String orgDn, ShrOrganization org) throws NamingException {logger.info("更新组织属性: {} (ID: {})", org.getName(), org.getFnumber());Attributes existingAttrs = orgDnToAttrsMap.get(orgDn);boolean hasChanges = false;List<ModificationItem> mods = new ArrayList<>();// 检查description是否需要更新(只存放组织编码)String currentDesc = existingAttrs.get("description") != null ?existingAttrs.get("description").get().toString() : null;String newDesc = org.getFnumber();if (currentDesc == null || !currentDesc.equals(newDesc)) {mods.add(new ModificationItem(DirContext.REPLACE_ATTRIBUTE,new BasicAttribute("description", newDesc)));hasChanges = true;}// 检查adminDescription是否需要更新String currentAdminDesc = existingAttrs.get("adminDescription") != null ?existingAttrs.get("adminDescription").get().toString() : null;if (currentAdminDesc == null || !currentAdminDesc.equals(org.getEasdeptId())) {mods.add(new ModificationItem(DirContext.REPLACE_ATTRIBUTE,new BasicAttribute("adminDescription", org.getEasdeptId())));hasChanges = true;}if (hasChanges) {ldapContext.modifyAttributes(orgDn, mods.toArray(new ModificationItem[0]));logger.info("已更新组织 {} 的属性", org.getName());// 更新缓存orgDnToAttrsMap.put(orgDn, ldapContext.getAttributes(orgDn));} else {logger.debug("组织 {} 的属性无需更新", org.getName());}}/*** 重命名/移动组织*/private void renameOrganization(String oldDn, String newDn, ShrOrganization org) throws NamingException {logger.info("重命名/移动组织: 从 {} 到 {}", oldDn, newDn);// 执行重命名ldapContext.rename(oldDn, newDn);// 更新缓存orgIdToDnMap.put(org.getEasdeptId(), newDn);orgDnToAttrsMap.remove(oldDn);orgDnToAttrsMap.put(newDn, ldapContext.getAttributes(newDn));// 重命名后可能需要更新属性updateOrganizationAttributes(newDn, org);// 更新缓存orgIdToDnMap.put(org.getEasdeptId(), newDn);orgDnToAttrsMap.remove(oldDn);orgDnToAttrsMap.put(newDn, ldapContext.getAttributes(newDn));logger.info("成功重命名/移动组织 {}", org.getName());}/*** 标记组织为禁用*/private void markOrganizationAsDisabled(String orgDn, ShrOrganization org) throws NamingException {logger.info("标记组织为禁用: {} (ID: {})", org.getName(), org.getFnumber());Attributes existingAttrs = orgDnToAttrsMap.get(orgDn);// 在description前添加"[已禁用]"标记,但保留组织编码String currentDesc = existingAttrs.get("description") != null ?existingAttrs.get("description").get().toString() : org.getFnumber();if (!currentDesc.startsWith("[已禁用]")) {ModificationItem[] mods = new ModificationItem[1];mods[0] = new ModificationItem(DirContext.REPLACE_ATTRIBUTE,new BasicAttribute("description", "[已禁用] " + org.getFnumber()));ldapContext.modifyAttributes(orgDn, mods);// 更新缓存orgDnToAttrsMap.put(orgDn, ldapContext.getAttributes(orgDn));}logger.info("组织 {} 已标记为禁用", org.getName());}/*** 处理已删除的组织*/private void handleDeletedOrganizations(Set<String> processedOrgIds) throws NamingException {logger.info("处理在SHR中不存在的组织");for (String orgId : orgIdToDnMap.keySet()) {if (!processedOrgIds.contains(orgId)) {String orgDn = orgIdToDnMap.get(orgId);// 跳过特殊组织if (specialOrgDns.contains(orgDn)) {logger.info("跳过特殊组织的删除处理: {}", orgDn);continue;}// 获取现有属性Attributes attrs = orgDnToAttrsMap.get(orgDn);String description = attrs.get("description") != null ?attrs.get("description").get().toString() : "";// 如果是特殊编码,跳过if (SPECIAL_ORG_CODE.equals(description)) {logger.info("跳过特殊编码组织的删除处理: {}", orgDn);continue;}// 如果描述中没有已删除标记,添加标记if (!description.startsWith("[已删除]")) {ModificationItem[] mods = new ModificationItem[1];mods[0] = new ModificationItem(DirContext.REPLACE_ATTRIBUTE,new BasicAttribute("description", "[已删除] " + description));ldapContext.modifyAttributes(orgDn, mods);logger.info("标记组织为已删除: {}", orgDn);// 更新缓存orgDnToAttrsMap.put(orgDn, ldapContext.getAttributes(orgDn));}}}}/*** 同步人员到AD,处理各种变更情况*/public void syncPersonnel(List<ShrPerson> personnel) {logger.info("开始同步人员到AD,共 {} 个人员", personnel.size());try {// 确保封存组存在String archiveGroupDN = "OU=" + AppConfig.AD_ARCHIVED_GROUP + "," + AppConfig.AD_BASE_DN;if (!checkIfEntryExists(archiveGroupDN)) {logger.info("封存人员组不存在,开始创建");createFCNewOrganization(archiveGroupDN);}// 加载AD中现有用户Map<String, UserAdInfo> existingUsers = loadExistingUsers();logger.info("已加载 {} 个AD用户到缓存", existingUsers.size());// 记录处理过的用户ID,用于后续检测删除操作Set<String> processedUserIds = new HashSet<>();int createdCount = 0;int movedCount = 0;int updatedCount = 0;int disabledCount = 0;int skippedCount = 0;// 同步用户for (ShrPerson person : personnel) {try {///*测试*///if(!person.getUsername().equals("宋汝东")){// continue;//}// 1. 基本检查if (person.getEasuserId() == null || person.getEasuserId().isEmpty()) {logger.warn("跳过无ID的用户: {}", person);skippedCount++;continue;}if (person.getUsername() == null || person.getUsername().isEmpty()) {logger.warn("跳过无用户名的用户: {}", person.getEasuserId());skippedCount++;continue;}// 2. 检查员工类型if (!isValidEmployeeType(person.getEmpTypeName())) {logger.debug("跳过非目标类型员工: {} (类型: {})",person.getUsername(), person.getEmpTypeName());skippedCount++;continue;}String userId = person.getEasuserId();processedUserIds.add(userId);// 3. 员工在AD中的信息UserAdInfo userInfo = existingUsers.get(userId);boolean exists = (userInfo != null);// 4. 处理禁用用户if (AppConfig.PERSON_STATUS_DISABLED.equals(person.getStatus())) {if (exists) {logger.info("用户 {} 在SHR中被禁用,移至封存组并禁用", person.getUsername());disableAndArchiveUser(userInfo.getDn(), archiveGroupDN, person);disabledCount++;}continue;}// 5. 确定用户所属组织DNString orgDN = findOrgDnByDeptId(person.getDeptId());if (orgDN == null) {logger.warn("找不到用户 {} 所属组织(deptId={}), 将使用默认组织",person.getUsername(), person.getDeptId());orgDN = AppConfig.AD_BASE_DN;}// 6. 生成目标DN - 使用用户名而不是IDString targetUserDN = "CN=" + person.getUsername() + "," + orgDN;//if(person.getUsername().equals("田振强")){// System.out.println(111111);//}// 7. 处理不同情况if (!exists) {//System.err.println(person.getUsername());// 用户不存在 - 新建用户createNewUser(targetUserDN, person);createdCount++;} else if (!userInfo.getDn().equals(targetUserDN)) {//System.err.println(person.getUsername());// 用户存在但DN不同 - 移动用户moveUser(userInfo.getDn(), targetUserDN, person);movedCount++;} else {//System.err.println(person.getUsername());// 用户存在且DN一致 - 更新属性updateUserAttributes(userInfo.getDn(), person);updatedCount++;}} catch (Exception e) {logger.error("处理用户 {} 时发生错误: {}", person.getUsername(), e.getMessage(), e);}}// 8. 处理已删除的用户int deletedCount = handleDeletedUsers(existingUsers, processedUserIds, archiveGroupDN);logger.info("人员同步完成 - 新建: {}, 移动: {}, 更新: {}, 禁用: {}, 删除: {}, 跳过: {}",createdCount, movedCount, updatedCount, disabledCount, deletedCount, skippedCount);//logger.info("人员同步完成 - 新建: {}, 移动: {}, 更新: {}, 禁用: {}, 删除: {}, 跳过: {}",// createdCount, movedCount, updatedCount, disabledCount, skippedCount);} catch (NamingException e) {logger.error("同步人员到AD时发生错误: {}", e.getMessage(), e);} catch (IOException e) {throw new RuntimeException(e);}}/*** 判断是否为有效的员工类型*/private boolean isValidEmployeeType(String empTypeName) {if (empTypeName == null) return false;// 只处理正式员工、试用员工、实习的人员return empTypeName.contains("正式") ||empTypeName.contains("试用") ||empTypeName.contains("实习");}/*** 加载AD中现有用户到缓存*/private Map<String, UserAdInfo> loadExistingUsers() throws NamingException, IOException {Map<String, UserAdInfo> userMap = new HashMap<>();SearchControls searchControls = new SearchControls();searchControls.setSearchScope(SearchControls.SUBTREE_SCOPE);String[] returnedAtts = {"distinguishedName", "info", "userAccountControl", "cn"};searchControls.setReturningAttributes(returnedAtts);String searchFilter = "(&(objectClass=user))";ldapContext.setRequestControls(new Control[]{new PagedResultsControl(10000, Control.NONCRITICAL)});NamingEnumeration<SearchResult> results = ldapContext.search(AppConfig.AD_BASE_DN, searchFilter, searchControls);while (results.hasMoreElements()) {SearchResult result = results.next();String dn = result.getNameInNamespace();Attributes attrs = result.getAttributes();// 使用info属性(对应easuserId)作为用户IDif (attrs.get("info") != null) {String userId = attrs.get("info").get().toString();// 获取用户账户控制属性,判断是否禁用boolean disabled = false;if (attrs.get("userAccountControl") != null) {int uac = Integer.parseInt(attrs.get("userAccountControl").get().toString());disabled = (uac & 2) != 0; // 账户禁用标志是第2位}// 获取用户显示名称String displayName = "";if (attrs.get("cn") != null) {displayName = attrs.get("cn").get().toString();}userMap.put(userId, new UserAdInfo(userId, dn, disabled, displayName));}}//System.err.println(personCount);return userMap;}/*** 创建新用户*/private void createNewUser(String userDN, ShrPerson person) throws NamingException {logger.info("创建新用户: {} (ID: {})", person.getUsername(), person.getEasuserId());Attributes attrs = new BasicAttributes();Attribute objClass = new BasicAttribute("objectClass");objClass.add("top");objClass.add("person");objClass.add("organizationalPerson");objClass.add("user");attrs.put(objClass);// CN已经包含在DN中,使用用户名attrs.put("cn", person.getUsername());// 使用手机号作为登录名if (person.getMobile() != null && !person.getMobile().isEmpty()) {attrs.put("sAMAccountName", person.getMobile());attrs.put("userPrincipalName", person.getMobile() + "@duowei.net.cn");} else {// 如果没有手机号,回退到使用用户IDlogger.warn("用户 {} 没有手机号,将使用ID作为登录名", person.getUsername());attrs.put("sAMAccountName", person.getEasuserId());attrs.put("userPrincipalName", person.getEasuserId() + "@duowei.net.cn");}// 设置显示名称attrs.put("displayName", person.getUsername());// 将easuserId存入info属性attrs.put("info", person.getEasuserId());// 设置姓和名// 假设中文名格式为"姓+名",取第一个字为姓,其余为名if (person.getUsername() != null && !person.getUsername().isEmpty()) {String fullName = person.getUsername();if (fullName.length() > 1) {// 取第一个字为姓String lastName = fullName.substring(0, 1);// 取剩余部分为名String firstName = fullName.substring(1);attrs.put("sn", lastName);attrs.put("givenName", firstName);} else {// 如果只有一个字,则全部作为姓attrs.put("sn", fullName);}}// 其他属性if (person.getMobile() != null) {attrs.put("mobile", person.getMobile());}if (person.getDeptId() != null) {attrs.put("department", person.getDeptId());}// 设置密码byte[] unicodePwd = generatePassword(AppConfig.AD_INIT_PASSWORD);attrs.put(new BasicAttribute("unicodePwd", unicodePwd));// 用户控制标志: 正常账户 + 密码不过期int userAccountControl = 512 | 65536;attrs.put(new BasicAttribute("userAccountControl", String.valueOf(userAccountControl)));// 要求下次登录更改密码attrs.put(new BasicAttribute("pwdLastSet", "0"));// 创建用户ldapContext.createSubcontext(userDN, attrs);}/*** 移动用户到新位置*/private void moveUser(String currentDN, String targetDN, ShrPerson person) throws NamingException {logger.info("移动用户: {} 从 {} 到 {}", person.getUsername(), currentDN, targetDN);try {// 执行重命名操作移动用户ldapContext.rename(currentDN, targetDN);// 移动后更新属性updateUserAttributes(targetDN, person);} catch (NamingException e) {logger.error("移动用户 {} 时发生错误: {}", person.getUsername(), e.getMessage());throw e;}}/*** 更新用户属性*/private void updateUserAttributes(String userDN, ShrPerson person) throws NamingException {logger.debug("更新用户属性: {}", person.getUsername());List<ModificationItem> mods = new ArrayList<>();// 更新手机号if (person.getMobile() != null) {mods.add(new ModificationItem(DirContext.REPLACE_ATTRIBUTE,new BasicAttribute("mobile", person.getMobile())));}// 更新部门IDif (person.getDeptId() != null) {mods.add(new ModificationItem(DirContext.REPLACE_ATTRIBUTE,new BasicAttribute("department", person.getDeptId())));}/*更新登录名为手机号*/if(person.getMobile() != null){mods.add(new ModificationItem(DirContext.REPLACE_ATTRIBUTE,new BasicAttribute("sAMAccountName", person.getMobile())));}// 更新info属性(easuserId)mods.add(new ModificationItem(DirContext.REPLACE_ATTRIBUTE,new BasicAttribute("info", person.getEasuserId())));// 确保账户处于启用状态mods.add(new ModificationItem(DirContext.REPLACE_ATTRIBUTE,new BasicAttribute("userAccountControl", "512")));// 应用修改if (!mods.isEmpty()) {ModificationItem[] modsArray = mods.toArray(new ModificationItem[0]);ldapContext.modifyAttributes(userDN, modsArray);}}/*** 禁用用户并移动到归档组*/private void disableAndArchiveUser(String userDN, String archiveGroupDN, ShrPerson person) throws NamingException {logger.info("禁用并归档用户: {}", person.getUsername());try {// 首先禁用用户ModificationItem[] disableMods = new ModificationItem[1];disableMods[0] = new ModificationItem(DirContext.REPLACE_ATTRIBUTE,new BasicAttribute("userAccountControl", "514")); // 514 = 禁用账户ldapContext.modifyAttributes(userDN, disableMods);// 然后移动到归档组String userName = person.getUsername();String newDN = "CN=" + userName + "," + archiveGroupDN;ldapContext.rename(userDN, newDN);} catch (NamingException e) {logger.error("禁用并归档用户 {} 时发生错误: {}", person.getUsername(), e.getMessage());throw e;}}/*** 处理已删除的用户*/private int handleDeletedUsers(Map<String, UserAdInfo> existingUsers, Set<String> processedUserIds,String archiveGroupDN) throws NamingException {logger.info("处理已删除用户");int count = 0;for (UserAdInfo userInfo : existingUsers.values()) {String userId = userInfo.getUserId();// 如果用户未在当前处理列表中,且不在归档组,则归档if (!processedUserIds.contains(userId) && !isInArchiveGroup(userInfo.getDn(), archiveGroupDN)) {logger.info("用户ID {} 在SHR中不存在,移至封存组并禁用", userId);try {disableUser(userInfo.getDn());moveUserToArchiveGroup(userInfo.getDn(), archiveGroupDN);count++;} catch (NamingException e) {logger.error("处理已删除用户 {} 时发生错误: {}", userId, e.getMessage());}}}return count;}/*** 检查用户是否已在归档组中*/private boolean isInArchiveGroup(String userDN, String archiveGroupDN) {return userDN.endsWith(archiveGroupDN);}/*** 用户AD信息类*/private static class UserAdInfo {private final String userId;private final String dn;private final boolean disabled;private final String displayName;public UserAdInfo(String userId, String dn, boolean disabled, String displayName) {this.userId = userId;this.dn = dn;this.disabled = disabled;this.displayName = displayName;}public String getUserId() {return userId;}public String getDn() {return dn;}public boolean isDisabled() {return disabled;}public String getDisplayName() {return displayName;}}public void close() {try {if (ldapContext != null) {ldapContext.close();logger.info("关闭LDAP连接");}} catch (NamingException e) {logger.error("关闭LDAP连接时发生错误: {}", e.getMessage(), e);}}/*** 根据部门ID查找组织DN*/private String findOrgDnByDeptId(String deptId) {if (deptId == null || deptId.isEmpty()) {return null;}return orgIdToDnMap.get(deptId);}/*** 从组织 DN 中提取组织名称*/private String getOrgNameFromDN(String dn) {if (dn == null || dn.isEmpty()) {return "未知组织";}try {// DN 格式通常是 "OU=组织名称,其他部分"// 提取第一个 OU= 后面的内容,直到下一个逗号if (dn.contains("OU=")) {int start = dn.indexOf("OU=") + 3; // OU= 后面的位置int end = dn.indexOf(",", start);if (end > start) {return dn.substring(start, end);} else {return dn.substring(start);}}// 如果没有找到 OU=,尝试从 dnToOuNameMap 获取if (dnToOuNameMap.containsKey(dn)) {return dnToOuNameMap.get(dn);}} catch (Exception e) {logger.warn("无法从DN提取组织名称: {}", dn);}return "未知组织";}/*** 检查指定DN的条目是否存在*/private boolean checkIfEntryExists(String dn) {try {ldapContext.lookup(dn);return true;} catch (NamingException e) {return false;}}/*** 生成AD密码* AD密码需要以特定格式提供,使用Unicode编码*/private byte[] generatePassword(String password) {// 将密码转换为AD要求的Unicode字节格式String quotedPassword = "\"" + password + "\"";char[] unicodePwd = quotedPassword.toCharArray();byte[] pwdBytes = new byte[unicodePwd.length * 2];// 转换为Unicode格式for (int i = 0; i < unicodePwd.length; i++) {pwdBytes[i * 2] = (byte) (unicodePwd[i] & 0xff);pwdBytes[i * 2 + 1] = (byte) (unicodePwd[i] >> 8);}return pwdBytes;}/*** 禁用用户账户*/private void disableUser(String userDN) throws NamingException {logger.info("禁用用户: {}", userDN);// 用户账户控制: 禁用账户 (514)ModificationItem[] mods = new ModificationItem[1];mods[0] = new ModificationItem(DirContext.REPLACE_ATTRIBUTE,new BasicAttribute("userAccountControl", "514"));ldapContext.modifyAttributes(userDN, mods);}/*** 将用户移动到封存组*/private void moveUserToArchiveGroup(String userDN, String archiveGroupDN) throws NamingException {logger.info("移动用户到封存组: {} -> {}", userDN, archiveGroupDN);// 获取用户DN中的CN部分String cn = "";if (userDN.startsWith("CN=")) {int endIndex = userDN.indexOf(',');if (endIndex > 0) {cn = userDN.substring(0, endIndex);} else {cn = userDN;}} else {// 如果不是以CN=开头,使用整个DNcn = "CN=" + getDnFirstComponent(userDN);}String newDN = cn + "," + archiveGroupDN;// 执行移动操作ldapContext.rename(userDN, newDN);}/*** 从DN中提取第一个组件*/private String getDnFirstComponent(String dn) {if (dn == null || dn.isEmpty()) {return "";}// DN格式可能是 "CN=名称,OU=组织,..."if (dn.contains("=")) {int startIndex = dn.indexOf('=') + 1;int endIndex = dn.indexOf(',', startIndex);if (endIndex > startIndex) {return dn.substring(startIndex, endIndex);} else {return dn.substring(startIndex);}}return dn;}
}
日志配置
<configuration><property name="LOG_PATH" value="D:/ADsync/logs" /><property name="FILE_NAME" value="AdSync" /><appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"><rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy"><!-- 日志文件命名规则 --><fileNamePattern>D:/ADsync/logs/AdSync.%d{yyyy-MM-dd}.%i.log</fileNamePattern><!-- 单个日志文件最大大小 --><maxFileSize>10MB</maxFileSize><!-- 保留最近 30 天的日志 --><maxHistory>30</maxHistory><!-- 总日志文件大小限制 --><totalSizeCap>1GB</totalSizeCap></rollingPolicy><encoder><pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern></encoder></appender><appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender"><encoder><pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern></encoder></appender><root level="INFO"><appender-ref ref="FILE" /><appender-ref ref="CONSOLE" /></root>
</configuration>
POM.xml文件
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"><modelVersion>4.0.0</modelVersion><groupId>com</groupId><artifactId>hr-ad-synchronizer</artifactId><version>1.0</version><packaging>jar</packaging><properties><project.build.sourceEncoding>UTF-8</project.build.sourceEncoding><maven.compiler.source>1.8</maven.compiler.source><maven.compiler.target>1.8</maven.compiler.target></properties><dependencies><!-- HTTP客户端 --><dependency><groupId>org.apache.httpcomponents</groupId><artifactId>httpclient</artifactId><version>4.5.13</version></dependency><!-- JSON处理 --><dependency><groupId>com.alibaba</groupId><artifactId>fastjson</artifactId><version>2.0.51</version></dependency><!-- 日志框架 --><dependency><groupId>org.slf4j</groupId><artifactId>slf4j-api</artifactId><version>1.7.36</version></dependency><dependency><groupId>ch.qos.logback</groupId><artifactId>logback-classic</artifactId><version>1.2.11</version><scope>runtime</scope></dependency><!-- Apache Axis相关 --><dependency><groupId>org.apache.axis</groupId><artifactId>axis</artifactId><version>1.4</version></dependency><dependency><groupId>commons-discovery</groupId><artifactId>commons-discovery</artifactId><version>0.5</version></dependency><dependency><groupId>commons-logging</groupId><artifactId>commons-logging</artifactId><version>1.1.1</version></dependency><dependency><groupId>wsdl4j</groupId><artifactId>wsdl4j</artifactId><version>1.6.2</version></dependency><!-- SHR API依赖 --><dependency><groupId>com.shr</groupId><artifactId>api</artifactId><version>1.0</version></dependency></dependencies><build><finalName>hr-ad-sync</finalName><plugins><!-- 编译插件 --><plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-compiler-plugin</artifactId><version>3.8.1</version><configuration><source>1.8</source><target>1.8</target><encoding>UTF-8</encoding></configuration></plugin><!-- 使用 assembly 插件,它更简单且可靠 --><plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-assembly-plugin</artifactId><version>3.3.0</version><configuration><descriptorRefs><descriptorRef>jar-with-dependencies</descriptorRef></descriptorRefs><archive><manifest><mainClass>com.sync.HrAdSynchronizer</mainClass></manifest></archive></configuration><executions><execution><id>make-assembly</id><phase>package</phase><goals><goal>single</goal></goals></execution></executions></plugin></plugins></build>
</project>
生成EXE文件
1、通过MAVEN打jar包
2、下载launch4j
3、通过launch4j生成exe文件:https://blog.csdn.net/qq_41804823/article/details/145967426
服务器定时任务
1、打开服务器管理
2、点击右上角“工具”,打开任务计划程序
3、新增任务计划程序库
异常问题注意事项
1、测试时,增加基础OU限制
2、出现权限异常问题,先检查赋值是否正确
相关文章:
HR人员和组织信息同步AD域服务器实战方法JAVA
HR人员和组织信息同步AD域服务器 前期准备AD域基础知识整理HR同步AD的逻辑代码结构配置文件设置启动类HR组织的BeanHR人员Bean获取HR人员和组织信息的类AD中处理组织和人员的类日志配置 POM.xml文件生成EXE文件服务器定时任务异常问题注意事项 前期准备 1、开发语言࿱…...
【云上CPU玩转AIGC】——腾讯云高性能应用服务HAI已支持DeepSeek-R1模型预装环境和CPU算力
🎼个人主页:【Y小夜】 😎作者简介:一位双非学校的大三学生,编程爱好者, 专注于基础和实战分享,欢迎私信咨询! 🎆入门专栏:🎇【MySQL࿰…...
【测试开发】OKR 网页管理端自动化测试报告
【测试报告】OKR 管理端 项目名称版本号测试负责人测试完成日期联系方式OKR 管理端4.0马铭胜2025-03-2115362558972 1、项目背景 1.1 OKR 用户端 在如今这个快节奏的时代中,个人和组织的成长往往依赖于清晰、明确且意义深远的目标。然而,如何设定并持…...
go语言中空结构体
空结构体(struct{}) 普通理解 在结构体中,可以包裹一系列与对象相关的属性,但若该对象没有属性呢?那它就是一个空结构体。 空结构体,和正常的结构体一样,可以接收方法函数。 type Lamp struct{}func (l Lamp) On()…...
如何缓解大语言模型推理中的“幻觉”(Hallucination)?
目录 如何缓解大语言模型推理中的“幻觉”(Hallucination)? 1. 什么是大语言模型的“幻觉”(Hallucination)? 幻觉的常见类型 2. 如何缓解大模型的幻觉问题? 方法 1:使用知识检索…...
优选算法系列(3.二分查找 )
目录 一.二分查找(easy) 题目链接:704. 二分查找 - 力扣(LeetCode) 解法: 代码: 二.在排序数组中查找元素的第⼀个和最后⼀个位置(medium) 题目链接:34.…...
【论文阅读】Contrastive Clustering Learning for Multi-Behavior Recommendation
论文地址:Contrastive Clustering Learning for Multi-Behavior Recommendation | ACM Transactions on Information Systems 摘要 近年来,多行为推荐模型取得了显著成功。然而,许多模型未充分考虑不同行为之间的共性与差异性,以…...
细胞计数专题 | 高效 + 精准!点成LUNA-III™细胞计数仪解锁活细胞检测与浓度分析新高度
1 引言 在生物医学研究中,准确的细胞计数至关重要,它影响着细胞治疗、疾病诊断、组织再生和生物测定等应用领域。传统的手动计数方法既耗时又容易产生偏差。像点成LUNA-III™自动细胞计数仪这样的自动化系统,为提高计数的准确性、可重复性和…...
糊涂人寄信——递推
思路分析:当有n封信,n个信封时。第k封信没有装在第k个信封里(k从1~n),就算所有的信封都装错了。我们可以得知的是,当有1封信,时,装错类别数为0。当有两封信时,装错类别为1。 当有三…...
深入Python C API:掌握常用函数与实战技巧
深入Python C API:掌握常用函数与实战技巧 Python的灵活性和易用性使其成为广泛应用的编程语言,但在某些场景下(如高性能计算、与C/C代码交互),直接使用C语言扩展Python的能力变得尤为重要。Python C API(…...
第16章:基于CNN和Transformer对心脏左心室的实验分析及改进策略
目录 1. 项目需求 2. 网络选择 2.1 UNet模块 2.2 TransUnet 2.2.1 SE模块 2.2.2 CBAM 2.3 关键代码 3 对比试验 3.1 unet 3.2 transformerSE 3.3 transformerCBAM 4. 结果分析 5. 推理 6. 下载 1. 项目需求 本文需要做的工作是基于CNN和Transformer的心脏左心室…...
Word中公式自动标号带章节编号
(1)插入一行三列的表格,设置宽度分别为0.5,13.39和1.5,设置纵向居中,中间列居中对齐,最右侧列靠右对齐,设置段落如下 (2)插入域代码 【Word】利用域代码快速实…...
AI风向标《AI与视频制作全攻略:从入门到精通实战课程》
课程信息 AI风向标《AI与视频制作全攻略:从入门到精通实战课程》,夸克网盘和百度网盘课程。 课程介绍 《AI与视频制作全攻略:从入门到精通实战课程》是一套全面融合AI技术与视频制作的实战课程,旨在帮助创作者从基础软件使用到高级视频剪辑…...
el-table折叠懒加载支持排序
el-table折叠懒加载支持排序 因为el-table懒加载的子节点是通过缓存实现的,如果想在展开的情况下直接刷新对应子节点数据,要操作el-table组件自身数据,否则不会更新 以排序功能为例 maps: new Map() //用于存储子节点懒加载的数据// 加载子…...
Kotlin v2.1.20 发布,标准库又有哪些变化?
大家吼哇!就在三小时前,Kotlin v2.1.20 发布了,更新的内容也已经在官网上更新:What’s new in Kotlin 2.1.20 。 我粗略地看了一下,下面为大家选出一些我比较感兴趣、且你可能也会感兴趣的内容。 注意!这里…...
AI智能问答“胡说八道“-RAG探索之路
AI智能问答"胡说八道"-RAG探索之路 背景信息RAGRAG技术的知识难题分块矛盾知识缺失相互冲突 RAG知识优化实践分块优化缺失优化冲突优化 未来展望 背景信息 你有没有遇到过这样的场景?当你向智能助手提问:“某科技公司为何突然更换高层领导&am…...
【yolo】YOLO训练参数输入之模型输入尺寸
模型输入尺寸是YOLO训练和推理过程中非常重要的参数之一。YOLO要求输入图像的尺寸是固定的,通常为正方形(如416416、640640等)。这个尺寸直接影响模型的性能和速度。以下是对模型输入尺寸的详细介绍: 1. 模型输入尺寸的作用 统一…...
[原创](Modern C++)现代C++的关键性概念: 如何声明一个返回数组指针的函数?
[作者] 常用网名: 猪头三 出生日期: 1981.XX.XX 企鹅交流: 643439947 个人网站: 80x86汇编小站 编程生涯: 2001年~至今[共24年] 职业生涯: 22年 开发语言: C/C、80x86ASM、Object Pascal、Objective-C、C#、R、Python、PHP、Perl、 开发工具: Visual Studio、Delphi、XCode、C …...
1204. 【高精度练习】密码
文章目录 题目描述输入输出样例输入样例输出数据范围限制CAC代码 题目描述 人们在做一个破译密码游戏: 有两支密码棒分别是红色和蓝色,把红色密码棒上的数字减去蓝色 密码棒上的数字,就是开启密码锁的密码。 现已知密码棒上的数字位数不超过…...
DigitalFoto公司如何用日事清流程管理工具实现任务优先级与状态可视化?
一、业务介绍 在DigitalFoto,设计和制造先进的摄影器材,如稳定器、灯光设备和支架,是日常工作的核心。公司的业务模式包括为其他品牌设计和制造定制产品,无论是作为OEM还是ODM。这样的多样化业务需求推动了公司在产品开发上必须非…...
解锁C++编程能力:基础语法解析
C入门基础 一、C的第一个程序二、命名空间三、C输入&输出四、缺省参数/默认参数五、函数重载六、引用1.引用的特性2.引用的使用引用做返回值场景 3.const引用只有指针和引用涉及权限放大、缩小的问题,普通变量没有 4.指针和引用的关系 七、inline八、nullptr 一…...
【Leetcode 每日一题】2680. 最大或值
问题背景 给你一个下标从 0 0 0 开始长度为 n n n 的整数数组 n u m s nums nums 和一个整数 k k k。每一次操作中,你可以选择一个数并将它乘 2 2 2。 你最多可以进行 k k k 次操作,请你返回 n u m s [ 0 ] ∣ n u m s [ 1 ] ∣ . . . ∣ n u m …...
YOLO魔改之SAM空间注意力模块
基于SAM注意力的YOLOv7改进算法详解(可用于工业检测方案) 一、应用场景说明 本改进算法适用于以下工业检测场景: 复杂背景下的微小目标检测(电子元件缺陷、PCB板焊点)密集目标重叠检测(传送带上的包裹分拣、人群计数)动态环境目标追踪(无人机巡检、自动驾…...
基于 TRIZ 理论的筏式养殖吊笼清洗装备设计研究
基于 TRIZ 理论的筏式养殖吊笼清洗装备设计研究 一、引言 筏式养殖在水产养殖业中占据重要地位,吊笼作为养殖贝类、藻类等生物的关键器具,其清洁程度直接影响养殖生物的健康与产量。传统的吊笼清洗方式多依赖人工,效率低下、劳动强度大且清洗…...
Day11 动态规划入门
动态规划 就是 : 给定一个问题,我们把它拆成一个个子问题,直到子问题可以直接解决。然后把子问题的答案保存起来,以减少重复计算。再根据子问题答案反推,得出原问题解的一种方法. 记忆化搜索 暴力dfs 记录答案 动态规划入门思…...
配置阿里云yum源
配置阿里云yum源 修改默认的yum仓库,把原有的移动到创建的目录里(踢出国外的yum源) # 切换到/ect/yum.repos.d/目录下 cd /etc/yum.repos.d/ # 新建repo目录 mkdir repo # 把原有的移动到创建的目录里 mv ./*.repo ./repo/配置yum源 # 找到…...
在Linux系统安装Ollama两种方法:自动安装和手动安装,并配置自启动服务
目录 一、命令自动安装 (一)使用命令行安装 (二)配置环境变量 (三)重新加载systemd配置并重启服务 二、手动安装 (一)下载本地文件 (二)解压并安…...
Python Django入门(创建应用程序)
在本章中,你将学习如何使用 Django(http://djangoproject.com/ )来开发一个名为“学习笔记”(Learning Log)的项目,这是一个在线日志系统,让你能够记录所学习的有关特定主题的知识。 我们将为这…...
HCIP-2 RSTP快速生成树
HCIP-2 RSTP快速生成树 STP的不足: 1.STP的端口角色过于简单不丰富,部署时不能很好的应用与较为复杂的网络环境中。 2.STP的迁移状态过于冗长,侦听、学习、阻塞状态下都是不转发业务流量。 3.STP的算法较为繁琐。 TCN TCA TC。 4.STP被动…...
软考-软件设计师-计算机网络
一、七层模型 中继器:信号会随着距离的增加而逐渐衰减,中继器可以接受一端的信息再将其原封不动的发给另一端,起到延长传输距离的作用; 集线器:多端口的中继器,所有端口公用一个冲突域; 网桥&…...
夸克网盘任务脚本——进阶自动版
脚本是用于自动管理和更新夸克云盘(Quark Cloud Drive)上的文件和目录的Python脚本。其主要功能包括自动下载、更新、重命名、删除文件和文件夹,以及处理和发送通知,可以在特定的时间间隔内运行,根据配置文件进行操作。 主要功能 1. Quark 类: __init__:初始化类,设置…...
squirrel语言全面介绍
Squirrel 是一种较新的程序设计语言,由意大利人 Alberto Demichelis 开发,其设计目标是成为一个强大的脚本工具,适用于游戏等对大小、内存带宽和实时性有要求的应用程序。以下是对 Squirrel 语言的全面介绍: 语言特性 动态类型&a…...
北京南文观点:品牌如何抢占AI 认知的 “黄金节点“
在算法主导的信息洪流中,品牌正在经历一场隐蔽的认知权争夺战,当用户向ChatGPT咨询"哪家新能源车企技术最可靠"时,AI调取的知识图谱数据源将直接决定品牌认知排序。南文乐园科技文化(北京)有限公司ÿ…...
使用Python在Word中创建、读取和删除列表 - 详解
目录 工具与设置 Python在Word中创建列表 使用默认样式创建有序(编号)列表 使用默认样式创建无序(项目符号)列表 创建多级列表 使用自定义样式创建列表 Python读取Word中的列表 Python从Word中删除列表 在Word中ÿ…...
分布式中间件:RabbitMQ确认消费机制
分布式中间件:RabbitMQ确认消费机制 在分布式系统中,消息队列是实现异步通信和系统解耦的重要组件。RabbitMQ 作为一款功能强大的消息队列中间件,提供了丰富的特性来保证消息的可靠传输和消费。其中,确认消费机制是确保消息被正确…...
Redis的大Key问题如何解决?
大家好,我是锋哥。今天分享关于【Redis的大Key问题如何解决?】面试题。希望对大家有帮助; Redis的大Key问题如何解决? 1000道 互联网大厂Java工程师 精选面试题-Java资源分享网 Redis的大Key问题指的是存储在Redis中的某些键(Key…...
日语学习-日语知识点小记-构建基础-JLPT-N4N5阶段(25):解释说明:という
日语学习-日语知识点小记-构建基础-JLPT-N4&N5阶段(25):解释说明:という 1、前言(1)情况说明(2)工程师的信仰2、知识点(1)复习语法(2) 解释说明:という3、单词(1)日语单词(2)日语片假名单词4、相近词辨析5、单词辨析记录6、总结1、前言 (1)情况说明 …...
Windows10配置OpenJDK11
下载 # 华为OpenJDK镜像源 https://mirrors.huaweicloud.com/openjdk/11.0.2/解压 # 解压后至于C:\Dev\Env\Java\jdk-11.0.2目录下 https://mirrors.huaweicloud.com/openjdk/11.0.2/openjdk-11.0.2_windows-x64_bin.zip编译安装 # 以管理员身份运行 CMD命令提示符 并进入JD…...
Python实验:读写文本文件并添加行号
[实验目的] 熟练掌握内置函数open()的用法;熟练运用内置函数len()、max()、和enumerate();熟练运用字符串的strip()、ljust()和其它方法;熟练运用列表推导式。 [实验和内容] 1.编写一个程序demo.py,要求运行该程序后࿰…...
什么是 NDC 坐标?什么是世界坐标?
什么是 NDC 坐标(归一化设备坐标)? 定义 NDC(Normalized Device Coordinates) 是三维图形渲染管线中的中间坐标系统,范围为 [-1, 1](x、y、z 轴均为此范围)。它是设备无关的标准化…...
25年护网二面
《网安面试指南》https://mp.weixin.qq.com/s/RIVYDmxI9g_TgGrpbdDKtA?token1860256701&langzh_CN 5000篇网安资料库https://mp.weixin.qq.com/s?__bizMzkwNjY1Mzc0Nw&mid2247486065&idx2&snb30ade8200e842743339d428f414475e&chksmc0e4732df793fa3bf39…...
《鸟哥的Linux私房菜基础篇》---5 vim 程序编辑器
目录 一、vim程序编辑器的简介 二、命令模式快捷键(默认模式) 1、光标移动 2、编辑操作 3、搜索与替换 三、插入模式快捷键 四、底行模式快捷键(按:进入) 五、高级技巧 1、分屏操作 2、多文件编辑 3、可视化…...
Day21:在排序数组中查找数字
某班级考试成绩按非严格递增顺序记录于整数数组 scores,请返回目标成绩 target 的出现次数。 示例 1: 输入: scores [2, 2, 3, 4, 4, 4, 5, 6, 6, 8], target 4 输出: 3 示例 2: 输入: scores [1, 2, 3, 5, 7, 9], target 6 输出: 0 …...
Android音视频多媒体开源库基础大全
从事音视频开发工作,需要了解哪些常见的开源库,从应用到底软系统,整理了九大类,这里一次帮你总结完。 包含了应用层的MediaRecorder、surfaceView,以及常见音视频处理库FFmpeg和OpenCV,还有视频渲染和音频…...
ManiWAV:通过野外的音频-视频数据学习机器人操作
24年6月来自斯坦福大学、哥伦比亚大学和 TRI 的论文“ManiWAV: Learning Robot Manipulation from In-the-Wild Audio-Visual Data”。 音频信号通过接触为机器人交互和物体属性提供丰富的信息。这些信息可以简化接触丰富的机器人操作技能学习,尤其是当视觉信息本身…...
传感器研习社:Swift Navigation与意法半导体(STMicroelectronics)合作 共同推出端到端GNSS汽车自动驾驶解决方案
自动驾驶系统单纯依赖感知传感器进行定位在遇到恶劣天气或缺乏车道标线的道路场景时很容易失效。此外,由于激光雷达(LiDAR)、视觉等传感器的成本高昂以及将众多不同组件整合为统一系统的复杂性,都可能增加产品研发成本或延迟产品上…...
Java 二维数组元素降序排序(非冒泡排序)
说明:每次比较出最大值后,把最大值设置为最小值-1,再次比较该数组; 创建Object b[][] new Object[N][2];来存储String和Int两种类型数据到同一个数组里 package com.MyJava;import java.util.Scanner;public class Test {public…...
梦回杭州...
她对我说,烟雨中的西湖更别有情趣,我也怀着对‘人间天堂’的憧憬踏上了向往之旅。第一次亲密接触没有感觉中那么好,现在想起来是那时的人和心情都没能安静下来,去慢慢品味它的美。 六下杭州,亲历每一片风景,…...
Spring Boot整合Apache BookKeeper教程
精心整理了最新的面试资料和简历模板,有需要的可以自行获取 点击前往百度网盘获取 点击前往夸克网盘获取 Spring Boot整合Apache BookKeeper教程 1. 简介 Apache BookKeeper 是一个高性能、持久化的分布式日志存储系统,适用于需要强一致性和高吞吐量的…...
C++项目——内存池
C项目——内存池 前置知识 std::allocator c中所有stl容器都有自己的allocator类用于分配和回收空间,例如vector类中push_back函数的实现方式: template <class T> void Vector<T>::push_back(const T& t) { // are we out of space…...