企业微信-AD域用户同步工具使用说明
功能简介
此工具用于自动同步企业微信通讯录与Windows Active Directory之间的用户和组织架构信息。
核心功能
-
组织架构同步
- 自动创建企业微信部门对应的AD组织单位(OU)
- 支持多层级部门结构
- 支持部门排除配置
- 自动创建部门对应的安全组
-
用户账户管理
- 自动创建/更新AD用户账户
- 智能邮箱管理(已有邮箱不覆盖)
- 自动将用户加入对应部门安全组
- 自动禁用不存在的用户账户
-
执行反馈
- 企业微信机器人通知
- 详细的运行日志
- 禁用用户记录
- 执行时间统计
配置文件说明
配置文件(config.ini)包含以下部分:
[WeChat]
CorpID = 企业微信的CorpID
CorpSecret = 企业微信的应用Secret
[WeChatBot]
WebhookUrl = 企业微信机器人的Webhook地址
[Domain]
Name = 域名(如: company.com)
[ExcludeUsers]
SystemAccounts = 系统账户列表(逗号分隔)
CustomAccounts = 自定义排除账户列表(逗号分隔)
[ExcludeDepartments]
Names = 不创建OU的部门列表(逗号分隔)
[Account]
DefaultPassword = 新建用户默认密码
ForceChangePassword = 是否强制用户首次登录修改密码
使用方法
安装准备
-
确保运行环境:
- Windows Server 2012 R2或更高版本
- PowerShell 5.0或更高版本
- 安装AD管理工具
-
权限要求:
- 运行账户需要域管理员权限
- 企业微信API访问权限
运行方式
- 直接运行可执行文件:
AD Sync WeCom.exe
- 使用Python脚本运行:
python "AD Sync WeCom.py"
运行结果
-
日志文件
- 程序运行日志:
ad_wecom_sync_YYYYMMDD_HHMMSS.log
- 禁用账户记录:
disabled_accounts_YYYYMMDD_HHMMSS.csv
- 企业微信机器人通知
- 程序运行日志:
-
检查项目
- AD用户和组织单位是否正确创建
- 用户属性是否正确更新
- 禁用账户是否正确处理
编译说明
环境准备
- 安装Python环境(建议Python 3.8+)
- 安装必要的依赖包:
pip install -r requirements.txt
pip install pyinstaller
编译步骤
-
准备文件
- 确保主程序文件
AD Sync WeCom.py
- 准备配置文件
config.ini
- 准备编译配置
ad_sync.spec
- 确保主程序文件
-
使用PyInstaller编译
# 方式一:使用spec文件编译(推荐)
pyinstaller --clean ad_sync.spec
# 方式二:直接编译(不推荐)
pyinstaller --clean -F "AD Sync WeCom.py"
- 编译结果
- 编译完成后在
dist
目录下找到可执行文件 - 将
config.ini
复制到可执行文件同级目录
- 编译完成后在
打包发布
- 创建发布包目录结构:
AD_Sync_WeCom/
├── ad_sync.exe
├── config.ini
└── README.md
- 实际部署时:
- 修改
config.ini
中的相关配置 - 确保运行账户有足够权限
- 建议创建运行快捷方式
- 修改
常见编译问题
-
缺少依赖模块
# 安装缺失的模块 pip install <module_name>
-
编译后运行报错
- 检查 spec 文件中的 hiddenimports 是否完整
- 确认所有依赖都已正确安装
- 尝试使用 --debug 参数编译获取详细信息
-
文件路径问题
- 使用绝对路径可能导致问题
- 建议使用相对路径处理配置文件
特性说明
智能邮箱处理
- 创建新用户时自动设置邮箱
- 更新用户时保留已有邮箱
- 支持默认邮箱规则(用户名@域名)
部门管理
- 支持多级部门结构
- 可配置排除特定部门
- 自动创建部门安全组
用户安全
- 支持排除系统账户
- 支持自定义排除账户
- 禁用账户自动移动到特定OU
执行报告
- 同步统计信息
- 执行时间统计
- 禁用账户列表
- 错误信息通知
注意事项
运行环境要求
- 操作系统:Windows Server 2012 R2或更高
- PowerShell:5.0或更高版本
- 必要组件:AD管理工具
- 权限要求:域管理员权限
安全建议
- 定期更改默认密码配置
- 及时查看同步日志
- 定期检查被禁用的账户
- 妥善保管配置文件
使用限制
- 不支持批量修改密码
- 不会删除AD中的组织单位
- 不会物理删除用户账户
- 被排除的部门仍然可以作为用户的所属组
常见问题
-
如何接收同步结果通知?
- 在config.ini中配置企业微信机器人的Webhook地址
-
如何处理特定部门?
- 在ExcludeDepartments.Names中添加部门名称
-
如何确保系统账户安全?
- 在ExcludeUsers.SystemAccounts中添加需要保护的账户
-
同步失败如何处理?
- 查看日志文件详细错误信息
- 检查企业微信API权限
- 验证AD域控制器连接
- 确认运行权限是否足够
技术支持
如遇到问题,请按以下顺序排查:
- 检查程序运行日志
- 查看企业微信机器人通知
- 确认网络连接状态
- 验证所需权限
- 检查配置文件正确性
问题反馈
如遇到问题,欢迎关注微信公众号“大刘讲IT”。
代码
import os
import sys
import logging
import json
import csv
import requests
import subprocess
import configparser
from datetime import datetime
from typing import Dict, List, Optional
import time
# 设置标准输入输出的UTF8编码
sys.stdin.reconfigure(encoding='utf-8')
sys.stdout.reconfigure(encoding='utf-8')
def setup_logging():
"""设置日志配置"""
global log_filename # 使变量全局可访问
log_filename = f"ad_wecom_sync_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log"
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler(log_filename, encoding='utf-8'),
logging.StreamHandler()
]
)
return logging.getLogger(__name__)
class WeComAPI:
def __init__(self, corpid: str, corpsecret: str):
self.corpid = corpid
self.corpsecret = corpsecret
self.access_token = self._get_access_token()
self.logger = logging.getLogger(__name__)
def _get_access_token(self) -> str:
"""获取企业微信API访问token"""
url = f"https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={self.corpid}&corpsecret={self.corpsecret}"
response = requests.get(url)
result = response.json()
if result.get('errcode') == 0:
return result['access_token']
else:
error_msg = f"获取access_token失败: {result.get('errmsg')}"
self.logger.error(error_msg)
raise Exception(error_msg)
def get_department_list(self) -> List[Dict]:
"""获取部门列表"""
url = f"https://qyapi.weixin.qq.com/cgi-bin/department/list?access_token={self.access_token}"
response = requests.get(url)
result = response.json()
if result.get('errcode') == 0:
return result["department"]
else:
error_msg = f"获取部门列表失败: {result.get('errmsg')}"
self.logger.error(error_msg)
raise Exception(error_msg)
def get_department_users(self, department_id: int) -> List[Dict]:
"""获取部门成员详情"""
url = f"https://qyapi.weixin.qq.com/cgi-bin/user/list?access_token={self.access_token}&department_id={department_id}&fetch_child=0"
response = requests.get(url)
result = response.json()
if result.get('errcode') == 0:
return result["userlist"]
else:
error_msg = f"获取部门成员失败: {result.get('errmsg')}"
self.logger.error(error_msg)
raise Exception(error_msg)
def get_user_detail(self, userid: str) -> Dict:
"""获取成员详情"""
url = f"https://qyapi.weixin.qq.com/cgi-bin/user/get?access_token={self.access_token}&userid={userid}"
response = requests.get(url)
result = response.json()
if result.get('errcode') == 0:
return result
else:
error_msg = f"获取用户详情失败: {userid}, {result.get('errmsg')}"
self.logger.error(error_msg)
return {}
def get_all_users(self) -> List[Dict]:
"""获取所有企业微信用户"""
all_users = []
try:
departments = self.get_department_list()
for dept in departments:
users = self.get_department_users(dept['id'])
all_users.extend(users)
# 去重处理
seen_userids = set()
unique_users = []
for user in all_users:
if user['userid'] not in seen_userids:
seen_userids.add(user['userid'])
unique_users.append(user)
return unique_users
except Exception as e:
self.logger.error(f"获取所有用户失败: {str(e)}")
return []
class ADSync:
def __init__(self, domain: str, exclude_departments: List[str] = None, exclude_accounts: List[str] = None):
self.domain = domain
self.exclude_departments = exclude_departments or []
self.exclude_accounts = exclude_accounts or []
self.logger = logging.getLogger(__name__)
self.init_powershell_encoding()
self.ensure_disabled_users_ou() # 初始化时确保 Disabled Users OU 存在
def init_powershell_encoding(self):
"""初始化PowerShell的UTF8编码支持"""
commands = [
"$OutputEncoding = [Console]::OutputEncoding = [Text.Encoding]::UTF8",
"chcp 65001"
]
for cmd in commands:
self.run_powershell(cmd)
def run_powershell(self, command: str) -> tuple:
"""执行PowerShell命令并返回结果"""
try:
full_command = f"""
$OutputEncoding = [Console]::OutputEncoding = [Text.Encoding]::UTF8
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
{command}
"""
process = subprocess.run(
["powershell", "-Command", full_command],
capture_output=True,
text=True,
encoding='utf-8'
)
return process.returncode == 0, process.stdout.strip()
except Exception as e:
self.logger.error(f"执行PowerShell命令失败: {str(e)}")
return False, str(e)
def get_ou_dn(self, ou_path: List[str]) -> str:
"""获取OU的Distinguished Name"""
return ','.join([f"OU={ou}" for ou in reversed(ou_path)]) + f',DC={self.domain.replace(".", ",DC=")}'
def ou_exists(self, ou_dn: str) -> bool:
"""检查OU是否存在"""
command = f"Get-ADOrganizationalUnit -Identity '{ou_dn}' -ErrorAction SilentlyContinue"
success, _ = self.run_powershell(command)
return success
def create_ou(self, ou_name: str, parent_dn: str) -> bool:
"""创建OU和对应的安全组"""
try:
# 检查是否在排除列表中
if ou_name in self.exclude_departments:
self.logger.info(f"跳过创建OU: {ou_name} (在排除列表中)")
return True
if not self.ou_exists(f"OU={ou_name},{parent_dn}"):
command = f"""
New-ADOrganizationalUnit `
-Name "{ou_name}" `
-Path "{parent_dn}" `
-ProtectedFromAccidentalDeletion $false
"""
success, output = self.run_powershell(command)
if success:
self.logger.info(f"创建OU成功: {ou_name}")
ou_dn = f"OU={ou_name},{parent_dn}"
group_command = f"""
New-ADGroup `
-Name "{ou_name}" `
-GroupScope Global `
-GroupCategory Security `
-Path "{ou_dn}"
"""
group_success, group_output = self.run_powershell(group_command)
if group_success:
self.logger.info(f"创建同名安全组成功: {ou_name}")
return True
else:
self.logger.error(f"创建安全组失败: {ou_name}, 错误: {group_output}")
else:
self.logger.error(f"创建OU失败: {ou_name}, 错误: {output}")
else:
self.logger.info(f"OU已存在: {ou_name}")
return False
except Exception as e:
self.logger.error(f"创建OU过程出错: {str(e)}")
return False
def get_user(self, username: str) -> Optional[dict]:
"""获取AD用户信息"""
command = f"""
Get-ADUser -Identity '{username}' -Properties * |
Select-Object * |
ConvertTo-Json
"""
success, output = self.run_powershell(command)
if success and output:
try:
return json.loads(output)
except json.JSONDecodeError:
return None
return None
def check_email_exists(self, email: str, exclude_user: str = None) -> bool:
"""检查邮箱是否已被其他用户使用"""
try:
command = f"""
Get-ADUser -Filter {{Mail -eq '{email}' -and SamAccountName -ne '{exclude_user}'}} |
Select-Object -First 1 |
Select-Object -ExpandProperty SamAccountName
"""
success, output = self.run_powershell(command)
return success and bool(output.strip())
except Exception as e:
self.logger.error(f"检查邮箱是否存在时出错: {str(e)}")
return False
def get_user_email(self, username: str) -> str:
"""获取AD用户当前的邮箱地址"""
try:
command = f"""
Get-ADUser -Identity '{username}' -Properties Mail |
Select-Object -ExpandProperty Mail
"""
success, output = self.run_powershell(command)
return output.strip() if success and output.strip() else ""
except Exception as e:
self.logger.error(f"获取用户邮箱失败 {username}: {str(e)}")
return ""
def create_user(self, username: str, display_name: str, email: str, ou_dn: str) -> bool:
"""创建AD用户"""
try:
command = f"""
$securePassword = ConvertTo-SecureString -String 'Notting8899' -AsPlainText -Force
New-ADUser `
-SamAccountName '{username}' `
-Name '{display_name}' `
-DisplayName '{display_name}' `
-EmailAddress '{email}' `
-Enabled $true `
-Path '{ou_dn}' `
-AccountPassword $securePassword `
-ChangePasswordAtLogon $true
"""
success, output = self.run_powershell(command)
if success:
self.logger.info(f"创建用户成功: {username} ({display_name})")
return True
else:
self.logger.error(f"创建用户失败: {username} ({display_name}), 错误: {output}")
return False
except Exception as e:
self.logger.error(f"创建用户过程出错: {str(e)}")
return False
def update_user(self, username: str, display_name: str, email: str, ou_dn: str) -> bool:
"""更新AD用户信息"""
try:
# 检查用户当前是否已有邮箱
current_email = self.get_user_email(username)
if current_email:
self.logger.info(f"用户 {username} 已有邮箱 {current_email},保持不变")
command = f"""
Get-ADUser -Identity '{username}' |
Set-ADUser `
-DisplayName '{display_name}'
"""
else:
self.logger.info(f"用户 {username} 无邮箱,设置新邮箱: {email}")
command = f"""
Get-ADUser -Identity '{username}' |
Set-ADUser `
-DisplayName '{display_name}' `
-EmailAddress '{email}'
"""
success, output = self.run_powershell(command)
if success:
# 移动用户到指定OU
move_command = f"""
Move-ADObject `
-Identity (Get-ADUser -Identity '{username}').DistinguishedName `
-TargetPath '{ou_dn}'
"""
move_success, move_output = self.run_powershell(move_command)
if move_success:
self.logger.info(f"更新用户成功: {username} ({display_name})")
return True
else:
self.logger.error(f"移动用户失败: {username}, 错误: {move_output}")
else:
self.logger.error(f"更新用户信息失败: {username}, 错误: {output}")
return False
except Exception as e:
self.logger.error(f"更新用户过程出错: {str(e)}")
return False
def add_user_to_group(self, username: str, group_name: str) -> bool:
"""将用户添加到安全组"""
try:
command = f"""
Add-ADGroupMember `
-Identity '{group_name}' `
-Members '{username}' `
-ErrorAction SilentlyContinue
"""
success, output = self.run_powershell(command)
if success:
self.logger.info(f"添加用户到组成功: {username} -> {group_name}")
return True
else:
self.logger.error(f"添加用户到组失败: {username} -> {group_name}, 错误: {output}")
return False
except Exception as e:
self.logger.error(f"添加用户到组过程出错: {str(e)}")
return False
def get_all_enabled_users(self) -> List[str]:
"""获取所有启用状态的AD用户账户(排除系统账户和配置的排除账户)"""
try:
# 构建排除账户的过滤条件
exclude_accounts = '|'.join(self.exclude_accounts)
command = f"""
Get-ADUser -Filter {{Enabled -eq $true}} -Properties SamAccountName |
Where-Object {{
$_.SamAccountName -notmatch '^({exclude_accounts})$' -and
$_.SamAccountName -notlike '*$'
}} |
Select-Object -ExpandProperty SamAccountName |
ConvertTo-Json
"""
success, output = self.run_powershell(command)
if success and output:
try:
return json.loads(output)
except json.JSONDecodeError:
self.logger.error("解析AD用户列表失败")
return []
return []
except Exception as e:
self.logger.error(f"获取AD用户列表失败: {str(e)}")
return []
def is_user_active(self, username: str) -> bool:
"""检查用户是否处于启用状态"""
try:
command = f"""
(Get-ADUser -Identity '{username}' -Properties Enabled).Enabled
"""
success, output = self.run_powershell(command)
return success and output.strip().lower() == 'true'
except Exception as e:
self.logger.error(f"检查用户状态失败 {username}: {str(e)}")
return False
def disable_user(self, username: str) -> bool:
"""禁用AD用户账户"""
try:
# 确保 Disabled Users OU 存在
if not self.ensure_disabled_users_ou():
self.logger.error("无法确保 Disabled Users OU 存在,禁用用户操作可能会失败")
# 首先检查用户是否存在且处于启用状态
if not self.is_user_active(username):
self.logger.info(f"用户 {username} 已经处于禁用状态或不存在")
return True
# 禁用账户
disable_command = f"""
$user = Get-ADUser -Identity '{username}'
if ($user) {{
Disable-ADAccount -Identity $user
Set-ADUser -Identity $user `
-Description "Account disabled - Not found in WeChat Work - {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
Write-Output "Success"
}} else {{
Write-Error "User not found"
}}
"""
success, output = self.run_powershell(disable_command)
if success and "Success" in output:
self.logger.info(f"成功禁用账户: {username}")
# 移动到禁用用户OU
disabled_ou = f"OU=Disabled Users,DC={self.domain.replace('.', ',DC=')}"
move_command = f"""
$user = Get-ADUser -Identity '{username}'
if ($user) {{
Move-ADObject -Identity $user.DistinguishedName -TargetPath '{disabled_ou}'
Write-Output "Moved"
}}
"""
move_success, _ = self.run_powershell(move_command)
if move_success:
self.logger.info(f"已将禁用账户 {username} 移动到 Disabled Users OU")
return True
else:
self.logger.error(f"禁用账户失败 {username}: {output}")
return False
except Exception as e:
self.logger.error(f"禁用账户过程出错 {username}: {str(e)}")
return False
def get_user_details(self, username: str) -> Dict:
"""获取AD用户的详细信息"""
try:
command = f"""
Get-ADUser -Identity '{username}' -Properties DisplayName, Mail, Created, Modified, LastLogonDate, Description |
Select-Object SamAccountName, DisplayName, Mail, Created, Modified, LastLogonDate, Description |
ConvertTo-Json
"""
success, output = self.run_powershell(command)
if success and output:
try:
return json.loads(output)
except json.JSONDecodeError:
return {}
return {}
except Exception as e:
self.logger.error(f"获取用户详情失败: {str(e)}")
return {}
def ensure_disabled_users_ou(self) -> bool:
"""确保 Disabled Users OU 存在,不存在则创建"""
try:
disabled_ou = f"OU=Disabled Users,DC={self.domain.replace('.', ',DC=')}"
if not self.ou_exists(disabled_ou):
self.logger.info("Disabled Users OU 不存在,正在创建...")
command = f"""
New-ADOrganizationalUnit `
-Name "Disabled Users" `
-Path "DC={self.domain.replace('.', ',DC=')}" `
-Description "存放已禁用的用户账户" `
-ProtectedFromAccidentalDeletion $false
"""
success, output = self.run_powershell(command)
if success:
self.logger.info("成功创建 Disabled Users OU")
return True
else:
self.logger.error(f"创建 Disabled Users OU 失败: {output}")
return False
else:
self.logger.info("Disabled Users OU 已存在")
return True
except Exception as e:
self.logger.error(f"检查/创建 Disabled Users OU 时出错: {str(e)}")
return False
class WeChatBot:
"""企业微信机器人通知类"""
def __init__(self, webhook_url: str):
self.webhook_url = webhook_url
self.logger = logging.getLogger(__name__)
self.logger.info(f"初始化企业微信机器人: {webhook_url}")
def send_message(self, content: str) -> bool:
"""发送消息到企业微信机器人"""
try:
self.logger.info("开始发送企业微信机器人消息")
self.logger.debug(f"消息内容: {content}")
data = {
"msgtype": "markdown",
"markdown": {
"content": content
}
}
response = requests.post(
self.webhook_url,
json=data,
timeout=10 # 添加超时设置
)
response.raise_for_status() # 抛出HTTP错误
result = response.json()
if result.get('errcode') == 0:
self.logger.info("机器人消息发送成功")
return True
else:
self.logger.error(f"机器人消息发送失败: {result}")
return False
except requests.RequestException as e:
self.logger.error(f"发送请求失败: {str(e)}")
return False
except json.JSONDecodeError as e:
self.logger.error(f"解析响应JSON失败: {str(e)}")
return False
except Exception as e:
self.logger.error(f"发送机器人消息时出错: {str(e)}")
return False
def format_time_duration(seconds: float) -> str:
"""格式化时间间隔"""
minutes, seconds = divmod(int(seconds), 60)
hours, minutes = divmod(minutes, 60)
if hours > 0:
return f"{hours}小时{minutes}分钟{seconds}秒"
elif minutes > 0:
return f"{minutes}分钟{seconds}秒"
else:
return f"{seconds}秒"
def main():
start_time = time.time()
sync_stats = {
'total_users': 0,
'processed_users': 0,
'disabled_users': [],
'error_count': 0,
'log_file': '' # 添加日志文件路径
}
# 设置日志
logger = setup_logging()
sync_stats['log_file'] = log_filename # 保存日志文件名
# 读取配置文件
config_parser = configparser.ConfigParser()
config_parser.read('config.ini', encoding='utf-8')
# 配置信息
config = {
'wecom': {
'corpid': config_parser.get('WeChat', 'CorpID'),
'corpsecret': config_parser.get('WeChat', 'CorpSecret')
},
'domain': config_parser.get('Domain', 'Name'),
'exclude_departments': [d.strip() for d in config_parser.get('ExcludeDepartments', 'Names').split(',')],
'exclude_accounts': [
*[acc.strip() for acc in config_parser.get('ExcludeUsers', 'SystemAccounts').split(',') if acc.strip()],
*[acc.strip() for acc in config_parser.get('ExcludeUsers', 'CustomAccounts').split(',') if acc.strip()]
],
'webhook_url': config_parser.get('WeChatBot', 'WebhookUrl')
}
try:
# 验证机器人webhook地址
if not config['webhook_url'] or 'key=' not in config['webhook_url']:
logger.error("企业微信机器人webhook地址无效")
raise ValueError("无效的webhook地址")
bot = WeChatBot(config['webhook_url'])
# 发送开始执行通知
start_message = f"""## 企业微信-AD同步开始执行
> 开始时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
> 域名: {config['domain']}
"""
bot.send_message(start_message)
# 初始化企业微信API和AD同步
wecom = WeComAPI(config['wecom']['corpid'], config['wecom']['corpsecret'])
ad_sync = ADSync(
config['domain'],
config['exclude_departments'],
config['exclude_accounts']
)
# 获取所有企业微信用户ID
wecom_users = set()
departments = wecom.get_department_list()
for dept in departments:
users = wecom.get_department_users(dept['id'])
wecom_users.update(user['userid'] for user in users)
logger.info(f"企业微信中共有 {len(wecom_users)} 个用户账户")
# 构建部门树和计算路径的逻辑需要修改
dept_tree = {}
for dept in departments:
dept_tree[dept['id']] = {
'name': dept['name'],
'parentid': dept['parentid'],
'path': []
}
# 计算每个部门的完整路径
for dept_id in dept_tree:
path = []
current_id = dept_id
while (current_id != 0): # 根部门的 parentid 为 0
if (current_id not in dept_tree):
break
path.insert(0, dept_tree[current_id]['name'])
current_id = dept_tree[current_id]['parentid']
dept_tree[dept_id]['path'] = path
# 同步OU结构
for dept_id, dept_info in dept_tree.items():
current_path = []
for ou_name in dept_info['path']:
current_path.append(ou_name)
if len(current_path) > 1:
parent_path = current_path[:-1]
parent_dn = ad_sync.get_ou_dn(parent_path)
else:
parent_dn = f"DC={config['domain'].replace('.', ',DC=')}"
ad_sync.create_ou(ou_name, parent_dn)
# 同步用户
logger.info("开始同步用户...")
processed_users = set()
# 收集用户的所有部门信息
user_departments = {} # userid -> List[dept_info]
for dept_id in dept_tree:
users = wecom.get_department_users(dept_id)
for user in users:
userid = user['userid']
if userid not in user_departments:
user_departments[userid] = {
'user_info': user,
'departments': []
}
user_departments[userid]['departments'].append(dept_tree[dept_id])
# 处理所有用户
for userid, info in user_departments.items():
if userid in processed_users:
continue
user = info['user_info']
departments = info['departments']
username = user['userid']
display_name = user['name']
# 选择合适的部门作为用户的OU
target_dept = None
for dept in departments:
# 如果部门不在排除列表中,则选择该部门
if dept['path'] and dept['path'][-1] not in config['exclude_departments']:
target_dept = dept
break
if not target_dept:
logger.warning(f"用户 {username} ({display_name}) 所有部门都在排除列表中,跳过处理")
processed_users.add(userid)
continue
ou_path = target_dept['path']
ou_dn = ad_sync.get_ou_dn(ou_path)
# 获取用户详细信息
user_detail = wecom.get_user_detail(username)
email = user_detail.get('email', '')
# 如果企业微信没有设置邮箱,则使用默认规则生成
if not email:
email = f"{username}@{config['domain']}"
logger.warning(f"用户 {display_name}({username}) 在企业微信中未设置邮箱,使用默认邮箱: {email}")
logger.info(f"处理用户: {display_name}, 用户ID: {username}, 邮箱: {email}, 选定部门: {ou_path[-1]}")
# 检查用户是否存在
existing_user = ad_sync.get_user(username)
if existing_user:
# 更新现有用户
ad_sync.update_user(
username,
display_name,
email,
ou_dn
)
else:
# 创建新用户
if ad_sync.create_user(
username,
display_name,
email,
ou_dn
):
logger.info(f"成功创建用户: {username}")
else:
logger.error(f"创建用户失败: {username}")
continue
# 将用户添加到所有非排除部门的安全组中
for dept in departments:
if dept['path'] and dept['path'][-1] not in config['exclude_departments']:
ou_name = dept['path'][-1]
ad_sync.add_user_to_group(username, ou_name)
processed_users.add(userid)
# 更新统计信息
sync_stats['total_users'] = len(wecom_users)
sync_stats['processed_users'] = len(processed_users)
logger.info(f"用户同步完成,共处理 {len(processed_users)} 个用户")
# 处理需要禁用的账户
logger.info("开始处理需要禁用的账户...")
enabled_ad_users = ad_sync.get_all_enabled_users()
logger.info(f"AD域控中共有 {len(enabled_ad_users)} 个启用状态的账户")
# 找出需要禁用的账户(排除特定账户)
users_to_disable = set(enabled_ad_users) - wecom_users
if users_to_disable:
logger.info(f"发现 {len(users_to_disable)} 个需要禁用的账户")
# 记录禁用操作的详细日志
disable_log_filename = f"disabled_accounts_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
with open(disable_log_filename, 'w', encoding='utf-8', newline='') as f:
# 修改字段名以匹配 AD 用户信息中的实际字段
writer = csv.DictWriter(f, fieldnames=[
'SamAccountName',
'DisplayName',
'Mail', # 改为与 AD 返回的字段名一致
'Created',
'Modified',
'LastLogonDate',
'Description',
'DisableTime'
])
writer.writeheader()
for username in users_to_disable:
user_details = ad_sync.get_user_details(username)
if user_details:
# 添加禁用时间
user_details['DisableTime'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
# 确保所有字段都存在
for field in writer.fieldnames:
if field not in user_details:
user_details[field] = ''
writer.writerow(user_details)
# 禁用账户
ad_sync.disable_user(username)
logger.info(f"已将禁用账户信息记录到文件: {disable_log_filename}")
sync_stats['disabled_users'] = list(users_to_disable)
else:
logger.info("没有需要禁用的账户")
# 计算执行时间
end_time = time.time()
duration = format_time_duration(end_time - start_time)
# 构建通知消息
result_message = f"""## 企业微信-AD同步执行结果
> 执行时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
> 耗时: {duration}
### 同步统计
- 企业微信总用户数: {sync_stats['total_users']}
- 成功同步用户数: {sync_stats['processed_users']}
- 禁用用户数: {len(sync_stats['disabled_users'])}
- 错误数: {sync_stats['error_count']}
{"### 被禁用的账户" if sync_stats['disabled_users'] else ""}
{"".join([f"- {user}\n" for user in sync_stats['disabled_users']])}
详细日志请查看: {sync_stats['log_file']}
"""
send_result = bot.send_message(result_message)
if not send_result:
logger.warning("发送执行结果通知失败")
logger.info("所有同步操作已完成")
except Exception as e:
sync_stats['error_count'] += 1
error_message = f"""## 企业微信-AD同步执行异常
> 执行时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
### 错误信息
{str(e)}
请检查日志文件了解详细信息。
"""
bot.send_message(error_message)
logger.error(f"同步过程出现错误: {str(e)}")
raise
if __name__ == '__main__':
main()