This commit is contained in:
Marsway 2026-02-24 17:00:27 +08:00
commit e4d0dee311
52 changed files with 122188 additions and 0 deletions

181
README.md Normal file
View File

@ -0,0 +1,181 @@
# AD User Creator
用于 AD 用户自动化创建的 Python 脚本集支持交互式与批量模式CSV/XLSX并提供菜单式单入口程序。
## Python 环境
- 解释器:`/opt/homebrew/Caskroom/miniconda/base/bin/python`
## 安装依赖
```bash
/opt/homebrew/Caskroom/miniconda/base/bin/python -m pip install -r requirements.txt
```
## 配置
1. 修改 `config/config.yaml`(所有配置均在此文件中维护)。
配置优先级:命令行参数(如 `--config`> `config.yaml`
关键字段:
- `ldap.people_base_dn`: 例如 `OU=People,DC=example,DC=com`
- `ldap.groups_base_dn`: 例如 `OU=linux,OU=Groups,DC=example,DC=com`
- `defaults.initial_uid_number`: 默认为 `2106`
- `defaults.initial_password`: 默认初始密码 `"1234.com"`
- `paths.uid_state_file`: uidNumber 持久化文件
- `paths.group_gid_map_file`: 组与 gidNumber 映射文件(默认 `staff: 3000`
- `behavior.require_ldaps_for_password`: 密码设置要求 LDAPS建议保持 `true`
## 初始化状态文件
```bash
/opt/homebrew/Caskroom/miniconda/base/bin/python -m ad_user_creator.main init-state
```
## 菜单式入口(推荐)
新增菜单入口:
```bash
/opt/homebrew/Caskroom/miniconda/base/bin/python -m ad_user_creator.entry
```
启动后会先选择模式:
- `1` 交互式创建
- `2` 批量导入
- `3` 修改配置文件路径
- `q` 退出
## 交互式创建(命令行直达)
```bash
/opt/homebrew/Caskroom/miniconda/base/bin/python -m ad_user_creator.main interactive --config config/config.yaml
```
或直接运行根目录脚本(默认交互式):
```bash
./run.sh
```
dry-run
```bash
/opt/homebrew/Caskroom/miniconda/base/bin/python -m ad_user_creator.main interactive --dry-run
```
## 批量创建(正式支持 CSV/XLSX
CSV:
```bash
/opt/homebrew/Caskroom/miniconda/base/bin/python -m ad_user_creator.main batch --input users.csv --continue-on-error true
```
```bash
./run.sh -f users.csv
```
XLSX:
```bash
/opt/homebrew/Caskroom/miniconda/base/bin/python -m ad_user_creator.main batch --input users.xlsx --continue-on-error true
```
```bash
./run.sh -f users.xlsx
```
dry-run 示例:
```bash
./run.sh -f users.xlsx --dry-run
```
仅支持 `.csv``.xlsx`
## 输入表头格式
必须包含以下列:
- `姓名`
- `用户名`
- `邮箱`
- `部门 OU`
- `基础组`
- `项目组`
- `资源组`
示例:
```csv
姓名,用户名,邮箱,部门 OU,基础组,项目组,资源组
杨滨,yangbin,tony.yang@aflowx.com,CEO,staff,,
孙彤,sunt,sun.tong@aflowx.com,CTO,staff,,
矫渊培,jiaoyp,jiao.yp@aflowx.com,RnD/tm_hardware,staff,"prj_r3xx_hw,prj_demo",
```
规则:
- `部门 OU=CEO` -> 用户 DN 路径包含 `OU=CEO,<people_base_dn>`
- `部门 OU=RnD/tm_hardware` -> 用户 DN 路径包含 `OU=tm_hardware,OU=RnD,<people_base_dn>`
- `项目组`、`资源组` 支持逗号分隔,可空
## Linux 属性映射
创建用户时会写入:
- `uid = sAMAccountName`
- `uidNumber = state/uid_state.json` 自增分配(起始 2106
- `unixHomeDirectory = /home/<sAMAccountName>`
- `gidNumber = 基础组 gidNumber`(来自 `state/group_gid_map.yaml`
- `mail = 邮箱`
## 账号启用与初始密码
用户创建流程为:
1. 先以禁用状态创建用户(`userAccountControl=514`
2. 设置初始密码(默认 `"1234.com"`
3. 启用用户(`userAccountControl=512`
4. 添加基础组与可选组
## 输出与日志
- 批量结果:`state/last_batch_result.csv`
- 运行日志:`state/run.log`
- 批量状态:
- `CREATED`:新建用户成功
- `UPDATED`:已存在用户,属性或组关系发生更新
- `SKIPPED_NO_CHANGE`:已存在用户且无任何变化
- `FAILED`:处理失败
## 常见问题
- LDAP 连接失败:检查 host/port/use_ssl/bind_dn/bind_password
- 基础组缺失或未映射 gid检查 AD 组是否存在,以及 `state/group_gid_map.yaml`
- 文件格式报错:确认输入文件后缀是 `.csv``.xlsx`
- `WILL_NOT_PERFORM`:通常是未使用 LDAPS、密码策略不满足、或权限不足
## 打包为单文件二进制
安装 PyInstaller
```bash
/opt/homebrew/Caskroom/miniconda/base/bin/python -m pip install pyinstaller
```
使用 spec 构建:
```bash
pyinstaller build/ad_user_creator.spec
```
构建完成后,可执行文件位于:
- `dist/ad-user-creator`
运行后将先显示菜单供选择模式。

View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

36
ad_user_creator/cli.py Normal file
View File

@ -0,0 +1,36 @@
from __future__ import annotations
import argparse
from pathlib import Path
def _validate_batch_input(value: str) -> str:
suffix = Path(value).suffix.lower()
if suffix not in {".csv", ".xlsx"}:
raise argparse.ArgumentTypeError("batch 输入文件仅支持 .csv 或 .xlsx")
return value
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="AD 用户自动化创建工具")
parser.add_argument("--config", default="config/config.yaml", help="yaml 配置文件路径")
subparsers = parser.add_subparsers(dest="command", required=True)
interactive_parser = subparsers.add_parser("interactive", help="交互式创建 AD 用户")
interactive_parser.add_argument("--dry-run", action="store_true", help="仅演练,不写入 LDAP")
batch_parser = subparsers.add_parser("batch", help="批量创建 AD 用户")
batch_parser.add_argument("--input", required=True, type=_validate_batch_input, help="输入文件(.csv/.xlsx)")
batch_parser.add_argument("--dry-run", action="store_true", help="仅演练,不写入 LDAP")
batch_parser.add_argument(
"--continue-on-error",
choices=["true", "false"],
default="true",
help="遇错是否继续处理后续行",
)
init_parser = subparsers.add_parser("init-state", help="初始化状态文件")
init_parser.add_argument("--dry-run", action="store_true", help="仅打印,不落盘")
return parser

151
ad_user_creator/config.py Normal file
View File

@ -0,0 +1,151 @@
from __future__ import annotations
import os
from pathlib import Path
from typing import Any, Dict, Optional
import yaml
from ad_user_creator.exceptions import ConfigError
from ad_user_creator.models import AppConfig, BehaviorConfig, DefaultsConfig, LdapConfig, PathsConfig
def _parse_bool(value: Any, default: bool) -> bool:
if value is None:
return default
if isinstance(value, bool):
return value
text = str(value).strip().lower()
if text in {"1", "true", "yes", "y", "on"}:
return True
if text in {"0", "false", "no", "n", "off"}:
return False
return default
def _read_yaml(path: Path) -> Dict[str, Any]:
if not path.exists():
raise ConfigError(f"yaml 配置文件不存在: {path}")
try:
with path.open("r", encoding="utf-8") as handle:
data = yaml.safe_load(handle) or {}
if not isinstance(data, dict):
raise ConfigError("yaml 顶层结构必须是对象")
return data
except yaml.YAMLError as exc:
raise ConfigError(f"yaml 解析失败: {exc}") from exc
def _merge_ldap_config(yaml_data: Dict[str, Any]) -> LdapConfig:
ldap_yaml = yaml_data.get("ldap", {}) or {}
host = ldap_yaml.get("host")
port = int(ldap_yaml.get("port", 636))
use_ssl = _parse_bool(ldap_yaml.get("use_ssl", True), True)
bind_dn = ldap_yaml.get("bind_dn")
bind_password = ldap_yaml.get("bind_password", "")
base_dn = ldap_yaml.get("base_dn")
people_base_dn = ldap_yaml.get("people_base_dn")
groups_base_dn = ldap_yaml.get("groups_base_dn")
missing = [
key
for key, value in {
"ldap.host": host,
"ldap.bind_dn": bind_dn,
"ldap.base_dn": base_dn,
"ldap.people_base_dn": people_base_dn,
"ldap.groups_base_dn": groups_base_dn,
}.items()
if not value
]
if missing:
raise ConfigError(f"缺少必要 LDAP 配置: {', '.join(missing)}")
return LdapConfig(
host=str(host),
port=port,
use_ssl=use_ssl,
bind_dn=str(bind_dn),
bind_password=str(bind_password),
base_dn=str(base_dn),
people_base_dn=str(people_base_dn),
groups_base_dn=str(groups_base_dn),
upn_suffix=str(ldap_yaml.get("upn_suffix", "")),
user_object_classes=list(
ldap_yaml.get(
"user_object_classes",
["top", "person", "organizationalPerson", "user", "posixAccount"],
)
),
user_rdn_attr=str(ldap_yaml.get("user_rdn_attr", "CN")),
)
def _merge_defaults_config(yaml_data: Dict[str, Any]) -> DefaultsConfig:
defaults_yaml = yaml_data.get("defaults", {}) or {}
return DefaultsConfig(
base_group=str(defaults_yaml.get("base_group", "staff")),
initial_uid_number=int(defaults_yaml.get("initial_uid_number", 2106)),
initial_password=str(defaults_yaml.get("initial_password", "1234.com")),
)
def _merge_paths_config(yaml_data: Dict[str, Any]) -> PathsConfig:
paths_yaml = yaml_data.get("paths", {}) or {}
return PathsConfig(
uid_state_file=str(paths_yaml.get("uid_state_file", "state/uid_state.json")),
group_gid_map_file=str(paths_yaml.get("group_gid_map_file", "state/group_gid_map.yaml")),
batch_result_file=str(paths_yaml.get("batch_result_file", "state/last_batch_result.csv")),
log_file=str(paths_yaml.get("log_file", "state/run.log")),
)
def _merge_behavior_config(yaml_data: Dict[str, Any], cli_dry_run: Optional[bool]) -> BehaviorConfig:
behavior_yaml = yaml_data.get("behavior", {}) or {}
base_dry_run = _parse_bool(behavior_yaml.get("dry_run"), False)
dry_run = cli_dry_run if cli_dry_run is not None else base_dry_run
return BehaviorConfig(
skip_if_user_exists=_parse_bool(behavior_yaml.get("skip_if_user_exists"), True),
skip_missing_optional_groups=_parse_bool(behavior_yaml.get("skip_missing_optional_groups"), True),
dry_run=dry_run,
require_ldaps_for_password=_parse_bool(behavior_yaml.get("require_ldaps_for_password"), True),
)
def _resolve_paths(config: AppConfig, workspace_root: Path) -> AppConfig:
def make_abs(path_text: str) -> str:
path = Path(path_text)
if path.is_absolute():
return str(path)
return str((workspace_root / path).resolve())
config.paths.uid_state_file = make_abs(config.paths.uid_state_file)
config.paths.group_gid_map_file = make_abs(config.paths.group_gid_map_file)
config.paths.batch_result_file = make_abs(config.paths.batch_result_file)
config.paths.log_file = make_abs(config.paths.log_file)
return config
def load_config(
config_path: str = "config/config.yaml",
cli_dry_run: Optional[bool] = None,
workspace_root: Optional[str] = None,
) -> AppConfig:
root = Path(workspace_root or os.getcwd()).resolve()
yaml_full_path = Path(config_path)
if not yaml_full_path.is_absolute():
yaml_full_path = (root / yaml_full_path).resolve()
yaml_data = _read_yaml(yaml_full_path)
app_config = AppConfig(
ldap=_merge_ldap_config(yaml_data),
defaults=_merge_defaults_config(yaml_data),
paths=_merge_paths_config(yaml_data),
behavior=_merge_behavior_config(yaml_data, cli_dry_run=cli_dry_run),
)
app_config = _resolve_paths(app_config, root)
if app_config.behavior.require_ldaps_for_password and not app_config.ldap.use_ssl:
raise ConfigError("启用密码设置时必须使用 LDAPS请将 ldap.use_ssl 设置为 true")
return app_config

82
ad_user_creator/entry.py Normal file
View File

@ -0,0 +1,82 @@
from __future__ import annotations
import sys
from pathlib import Path
from ad_user_creator.main import execute_command
def _ask_yes_no(prompt: str, default: bool) -> bool:
default_text = "Y/n" if default else "y/N"
value = input(f"{prompt} [{default_text}]: ").strip().lower()
if not value:
return default
return value in {"y", "yes", "1", "true"}
def _clean_path(path_text: str) -> str:
text = path_text.strip()
if len(text) >= 2 and ((text[0] == "'" and text[-1] == "'") or (text[0] == '"' and text[-1] == '"')):
text = text[1:-1]
return text
def _run_interactive(config_path: str) -> int:
dry_run = _ask_yes_no("是否启用 dry-run不写入 LDAP", default=False)
return execute_command(command="interactive", config_path=config_path, dry_run=dry_run)
def _run_batch(config_path: str) -> int:
input_path = _clean_path(input("请输入批量文件路径 (.csv/.xlsx): "))
if not input_path:
print("未提供文件路径。")
return 1
suffix = Path(input_path).suffix.lower()
if suffix not in {".csv", ".xlsx"}:
print(f"文件格式错误,仅支持 .csv 或 .xlsx: {input_path}")
return 1
dry_run = _ask_yes_no("是否启用 dry-run不写入 LDAP", default=False)
continue_on_error = _ask_yes_no("遇到错误是否继续处理后续记录", default=True)
return execute_command(
command="batch",
config_path=config_path,
dry_run=dry_run,
input_path=input_path,
continue_on_error=continue_on_error,
)
def menu() -> int:
config_path = "config/config.yaml"
while True:
print("\n=== AD User Creator ===")
print(f"当前配置文件: {config_path}")
print("1) 交互式创建")
print("2) 批量导入")
print("3) 修改配置文件路径")
print("q) 退出")
choice = input("请选择模式: ").strip().lower()
if choice == "1":
code = _run_interactive(config_path)
print(f"执行完成,返回码: {code}")
elif choice == "2":
code = _run_batch(config_path)
print(f"执行完成,返回码: {code}")
elif choice == "3":
new_path = _clean_path(input("请输入新的 config.yaml 路径: "))
if not new_path:
print("配置路径未变更。")
else:
config_path = new_path
print(f"已更新配置路径: {config_path}")
elif choice in {"q", "quit", "exit"}:
print("已退出。")
return 0
else:
print("无效选项,请输入 1/2/3/q。")
if __name__ == "__main__":
sys.exit(menu())

View File

@ -0,0 +1,22 @@
class AppError(Exception):
"""Base error type for application-specific exceptions."""
class ConfigError(AppError):
"""Raised when configuration is invalid or missing."""
class InputValidationError(AppError):
"""Raised when input records fail validation."""
class LdapConnectionError(AppError):
"""Raised when LDAP connection or bind fails."""
class LdapOperationError(AppError):
"""Raised for LDAP operation failures."""
class StatePersistenceError(AppError):
"""Raised when state files cannot be read or written."""

View File

@ -0,0 +1,114 @@
from __future__ import annotations
import re
from pathlib import Path
from typing import Dict, List, Tuple
import pandas as pd
from ad_user_creator.exceptions import InputValidationError
from ad_user_creator.models import UserInputRecord
REQUIRED_HEADERS = ["姓名", "用户名", "邮箱", "部门 OU", "基础组", "项目组", "资源组"]
USERNAME_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$")
EMAIL_PATTERN = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
def _split_groups(value: object) -> List[str]:
if value is None:
return []
text = str(value).strip()
if not text or text.lower() == "nan":
return []
normalized = text.replace("", ",")
groups = [item.strip() for item in normalized.split(",") if item.strip()]
deduped: List[str] = []
seen = set()
for group in groups:
if group not in seen:
deduped.append(group)
seen.add(group)
return deduped
def _read_table(input_path: str) -> pd.DataFrame:
file_path = Path(input_path)
if not file_path.exists():
raise InputValidationError(f"输入文件不存在: {input_path}")
suffix = file_path.suffix.lower()
if suffix not in {".csv", ".xlsx"}:
raise InputValidationError(f"仅支持 .csv 和 .xlsx当前为: {suffix}")
if suffix == ".csv":
try:
return pd.read_csv(file_path, encoding="utf-8-sig")
except UnicodeDecodeError:
return pd.read_csv(file_path, encoding="utf-8")
return pd.read_excel(file_path, engine="openpyxl")
def _validate_headers(df: pd.DataFrame) -> None:
missing = [header for header in REQUIRED_HEADERS if header not in df.columns]
if missing:
raise InputValidationError(f"输入文件缺少列: {', '.join(missing)}")
def parse_input_file(input_path: str) -> List[Tuple[UserInputRecord, Dict[str, str]]]:
df = _read_table(input_path)
_validate_headers(df)
df = df.fillna("")
parsed: List[Tuple[UserInputRecord, Dict[str, str]]] = []
for index, row in df.iterrows():
line_no = index + 2
display_name = str(row["姓名"]).strip()
sam_account_name = str(row["用户名"]).strip()
email = str(row["邮箱"]).strip()
dept_ou = str(row["部门 OU"]).strip()
base_group = str(row["基础组"]).strip()
project_groups = _split_groups(row["项目组"])
resource_groups = _split_groups(row["资源组"])
required_missing = []
if not display_name:
required_missing.append("姓名")
if not sam_account_name:
required_missing.append("用户名")
if not email:
required_missing.append("邮箱")
if not dept_ou:
required_missing.append("部门 OU")
if not base_group:
required_missing.append("基础组")
if required_missing:
raise InputValidationError(
f"{line_no} 行缺少必填字段: {', '.join(required_missing)}"
)
if not USERNAME_PATTERN.match(sam_account_name):
raise InputValidationError(
f"{line_no} 行用户名非法: {sam_account_name},只允许字母数字下划线短横线"
)
if not EMAIL_PATTERN.match(email):
raise InputValidationError(f"{line_no} 行邮箱格式非法: {email}")
record = UserInputRecord(
display_name=display_name,
sam_account_name=sam_account_name,
email=email,
dept_ou=dept_ou,
base_group=base_group,
project_groups=project_groups,
resource_groups=resource_groups,
)
raw = {
"姓名": display_name,
"用户名": sam_account_name,
"邮箱": email,
"部门 OU": dept_ou,
"基础组": base_group,
"项目组": ",".join(project_groups),
"资源组": ",".join(resource_groups),
}
parsed.append((record, raw))
return parsed

View File

@ -0,0 +1,58 @@
from __future__ import annotations
from typing import List
from ad_user_creator.models import UserInputRecord
from ad_user_creator.user_service import UserService
def _split_optional_groups(text: str) -> List[str]:
if not text.strip():
return []
items = [item.strip() for item in text.replace("", ",").split(",")]
return [item for item in items if item]
def run_interactive_create(user_service: UserService, default_base_group: str, dry_run: bool = False) -> None:
print("请输入 AD 用户信息:")
display_name = input("显示名称(姓名): ").strip()
sam_account_name = input("用户名(sAMAccountName): ").strip()
email = input("邮箱(mail): ").strip()
dept_ou = input("部门 OU (例如 CEO 或 RnD/tm_hardware): ").strip()
base_group_input = input(f"基础组(默认 {default_base_group}): ").strip()
base_group = base_group_input or default_base_group
project_groups = _split_optional_groups(input("项目组(逗号分隔,可空): "))
resource_groups = _split_optional_groups(input("资源组(逗号分隔,可空): "))
record = UserInputRecord(
display_name=display_name,
sam_account_name=sam_account_name,
email=email,
dept_ou=dept_ou,
base_group=base_group,
project_groups=project_groups,
resource_groups=resource_groups,
)
plan = user_service.preview_plan(record)
print("\n预览:")
print(f"- 显示名称: {plan.display_name}")
print(f"- 用户名: {plan.sam_account_name}")
print(f"- 邮箱: {plan.email}")
print(f"- 部门 OU: {record.dept_ou}")
print(f"- 用户 DN: {plan.user_dn}")
print(f"- 基础组: {plan.base_group}")
print(f"- 项目组: {', '.join(plan.project_groups) if plan.project_groups else '(空)'}")
print(f"- 资源组: {', '.join(plan.resource_groups) if plan.resource_groups else '(空)'}")
print(f"- uid: {plan.uid}")
print(f"- uidNumber: {plan.uid_number}")
print(f"- gidNumber: {plan.gid_number}")
print(f"- unixHomeDirectory: {plan.unix_home_directory}")
confirm = input("\n确认创建? [y/N]: ").strip().lower()
if confirm not in {"y", "yes"}:
print("已取消。")
return
result = user_service.process_user(record, dry_run=dry_run)
print(f"执行结果: {result.status} {result.reason}")

View File

@ -0,0 +1,156 @@
from __future__ import annotations
from typing import Dict, Optional
from ldap3 import ALL, BASE, Connection, MODIFY_REPLACE, Server
from ldap3.core.exceptions import LDAPException
from ldap3.utils.conv import escape_filter_chars
from ad_user_creator.exceptions import LdapConnectionError, LdapOperationError
from ad_user_creator.models import LdapConfig
class LdapClient:
def __init__(self, config: LdapConfig) -> None:
self.config = config
self.server: Optional[Server] = None
self.conn: Optional[Connection] = None
def connect(self) -> None:
try:
self.server = Server(self.config.host, port=self.config.port, use_ssl=self.config.use_ssl, get_info=ALL)
self.conn = Connection(
self.server,
user=self.config.bind_dn,
password=self.config.bind_password,
auto_bind=True,
)
except LDAPException as exc:
raise LdapConnectionError(f"LDAP 连接或绑定失败: {exc}") from exc
def ensure_connected(self) -> None:
if self.conn is None or not self.conn.bound:
self.connect()
def close(self) -> None:
if self.conn is not None and self.conn.bound:
self.conn.unbind()
def user_exists(self, sam_account_name: str) -> bool:
self.ensure_connected()
assert self.conn is not None
escaped = escape_filter_chars(sam_account_name)
search_filter = f"(sAMAccountName={escaped})"
ok = self.conn.search(self.config.base_dn, search_filter, attributes=["distinguishedName"])
if not ok:
return False
return len(self.conn.entries) > 0
def find_user_dn_by_sam(self, sam_account_name: str) -> Optional[str]:
self.ensure_connected()
assert self.conn is not None
escaped = escape_filter_chars(sam_account_name)
search_filter = f"(sAMAccountName={escaped})"
ok = self.conn.search(self.config.base_dn, search_filter, attributes=["distinguishedName"])
if not ok or len(self.conn.entries) == 0:
return None
return str(self.conn.entries[0].distinguishedName.value)
def group_exists(self, group_name: str) -> bool:
self.ensure_connected()
assert self.conn is not None
escaped = escape_filter_chars(group_name)
search_filter = f"(&(objectClass=group)(cn={escaped}))"
ok = self.conn.search(self.config.groups_base_dn, search_filter, attributes=["distinguishedName"])
if not ok:
return False
return len(self.conn.entries) > 0
def get_group_dn(self, group_name: str) -> str:
self.ensure_connected()
assert self.conn is not None
escaped = escape_filter_chars(group_name)
search_filter = f"(&(objectClass=group)(cn={escaped}))"
ok = self.conn.search(self.config.groups_base_dn, search_filter, attributes=["distinguishedName"])
if not ok or len(self.conn.entries) == 0:
raise LdapOperationError(f"组不存在: {group_name}")
return str(self.conn.entries[0].distinguishedName.value)
def create_user(self, user_dn: str, attributes: Dict[str, object]) -> None:
self.ensure_connected()
assert self.conn is not None
ok = self.conn.add(user_dn, object_class=self.config.user_object_classes, attributes=attributes)
if not ok:
raise LdapOperationError(f"创建用户失败: {self.conn.result}")
def set_user_password(self, user_dn: str, new_password: str) -> None:
self.ensure_connected()
assert self.conn is not None
ok = self.conn.extend.microsoft.modify_password(user_dn, new_password)
if not ok:
raise LdapOperationError(f"设置用户密码失败 user={user_dn} result={self.conn.result}")
def set_user_enabled(self, user_dn: str, enabled: bool) -> None:
self.ensure_connected()
assert self.conn is not None
value = "512" if enabled else "514"
ok = self.conn.modify(user_dn, {"userAccountControl": [(MODIFY_REPLACE, [value])]})
if not ok:
action = "启用" if enabled else "禁用"
raise LdapOperationError(f"{action}用户失败 user={user_dn} result={self.conn.result}")
def add_user_to_group(self, user_dn: str, group_dn: str) -> None:
self.ensure_connected()
assert self.conn is not None
ok = self.conn.extend.microsoft.add_members_to_groups(user_dn, group_dn)
if not ok:
raise LdapOperationError(
f"添加组成员失败 user={user_dn} group={group_dn} result={self.conn.result}"
)
def get_user_attributes(self, user_dn: str, attrs: list[str]) -> Dict[str, str]:
self.ensure_connected()
assert self.conn is not None
ok = self.conn.search(search_base=user_dn, search_filter="(objectClass=*)", search_scope=BASE, attributes=attrs)
if not ok or len(self.conn.entries) == 0:
raise LdapOperationError(f"读取用户属性失败 user={user_dn} result={self.conn.result}")
entry = self.conn.entries[0]
result: Dict[str, str] = {}
for attr in attrs:
if hasattr(entry, attr):
value = getattr(entry, attr).value
if value is None:
result[attr] = ""
elif isinstance(value, list):
result[attr] = ",".join(str(v) for v in value)
else:
result[attr] = str(value)
else:
result[attr] = ""
return result
def modify_user_attributes(self, user_dn: str, changes: Dict[str, str]) -> None:
self.ensure_connected()
assert self.conn is not None
operations = {key: [(MODIFY_REPLACE, [value])] for key, value in changes.items()}
ok = self.conn.modify(user_dn, operations)
if not ok:
raise LdapOperationError(f"更新用户属性失败 user={user_dn} result={self.conn.result}")
def add_user_to_group_if_missing(self, user_dn: str, group_dn: str) -> bool:
self.ensure_connected()
assert self.conn is not None
ok = self.conn.search(
search_base=group_dn,
search_filter="(objectClass=group)",
search_scope=BASE,
attributes=["member"],
)
if not ok or len(self.conn.entries) == 0:
raise LdapOperationError(f"读取组成员失败 group={group_dn} result={self.conn.result}")
members = self.conn.entries[0].member.values if hasattr(self.conn.entries[0], "member") else []
normalized_members = {str(item).lower() for item in members}
if user_dn.lower() in normalized_members:
return False
self.add_user_to_group(user_dn, group_dn)
return True

View File

@ -0,0 +1,25 @@
from __future__ import annotations
import logging
from pathlib import Path
def setup_logging(log_file: str) -> logging.Logger:
log_path = Path(log_file)
log_path.parent.mkdir(parents=True, exist_ok=True)
logger = logging.getLogger("ad_user_creator")
logger.setLevel(logging.INFO)
logger.handlers.clear()
formatter = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")
stream_handler = logging.StreamHandler()
stream_handler.setFormatter(formatter)
logger.addHandler(stream_handler)
file_handler = logging.FileHandler(log_path, encoding="utf-8")
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
return logger

172
ad_user_creator/main.py Normal file
View File

@ -0,0 +1,172 @@
from __future__ import annotations
import csv
import sys
from pathlib import Path
from typing import Dict, List, Optional
from ad_user_creator.cli import build_parser
from ad_user_creator.config import load_config
from ad_user_creator.exceptions import AppError, ConfigError, InputValidationError, LdapConnectionError
from ad_user_creator.input_parser import parse_input_file
from ad_user_creator.interactive import run_interactive_create
from ad_user_creator.ldap_client import LdapClient
from ad_user_creator.logging_setup import setup_logging
from ad_user_creator.models import UserProcessResult
from ad_user_creator.persistence import StateStore
from ad_user_creator.user_service import UserService
def _write_batch_results(path: str, rows: List[Dict[str, str]]) -> None:
output = Path(path)
output.parent.mkdir(parents=True, exist_ok=True)
headers = ["姓名", "用户名", "邮箱", "部门 OU", "基础组", "项目组", "资源组", "状态", "原因", "用户DN", "uidNumber"]
with output.open("w", encoding="utf-8-sig", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=headers)
writer.writeheader()
for row in rows:
writer.writerow(row)
def _to_bool_text(value: str) -> bool:
return value.lower() == "true"
def _result_to_row(raw: Dict[str, str], result: UserProcessResult) -> Dict[str, str]:
return {
"姓名": raw.get("姓名", ""),
"用户名": raw.get("用户名", ""),
"邮箱": raw.get("邮箱", ""),
"部门 OU": raw.get("部门 OU", ""),
"基础组": raw.get("基础组", ""),
"项目组": raw.get("项目组", ""),
"资源组": raw.get("资源组", ""),
"状态": result.status,
"原因": result.reason,
"用户DN": result.user_dn,
"uidNumber": "" if result.uid_number is None else str(result.uid_number),
}
def execute_command(
command: str,
config_path: str = "config/config.yaml",
dry_run: bool = False,
input_path: Optional[str] = None,
continue_on_error: bool = True,
) -> int:
try:
config = load_config(config_path=config_path, cli_dry_run=dry_run)
except ConfigError as exc:
print(f"[FATAL] 配置错误: {exc}")
return 2
logger = setup_logging(config.paths.log_file)
logger.info("配置加载完成")
state = StateStore(
uid_state_file=config.paths.uid_state_file,
group_gid_map_file=config.paths.group_gid_map_file,
initial_uid_number=config.defaults.initial_uid_number,
)
if command == "init-state":
if dry_run:
print(f"[DRY-RUN] 将初始化: {config.paths.uid_state_file}{config.paths.group_gid_map_file}")
return 0
state.ensure_state_files()
print("状态文件初始化完成。")
return 0
state.ensure_state_files()
ldap_client = LdapClient(config.ldap)
if not config.behavior.dry_run:
try:
ldap_client.connect()
logger.info("LDAP 连接成功")
except LdapConnectionError as exc:
print(f"[FATAL] LDAP 连接失败: {exc}")
return 2
service = UserService(config=config, state_store=state, ldap_client=ldap_client)
try:
if command == "interactive":
try:
run_interactive_create(
user_service=service,
default_base_group=config.defaults.base_group,
dry_run=config.behavior.dry_run,
)
return 0
except (InputValidationError, AppError) as exc:
print(f"[ERROR] 交互式执行失败: {exc}")
return 1
if command == "batch":
if not input_path:
print("[ERROR] batch 模式需要提供输入文件路径")
return 1
try:
records = parse_input_file(input_path)
except InputValidationError as exc:
print(f"[ERROR] 输入文件校验失败: {exc}")
return 1
created = 0
updated = 0
skipped = 0
failed = 0
result_rows: List[Dict[str, str]] = []
for idx, (record, raw) in enumerate(records, start=1):
logger.info("处理第 %s 条: %s", idx, record.sam_account_name)
try:
result = service.process_user(record, dry_run=config.behavior.dry_run)
except (InputValidationError, AppError) as exc:
result = UserProcessResult(status="FAILED", reason=str(exc), raw=raw)
if result.status == "CREATED":
created += 1
elif result.status == "UPDATED":
updated += 1
elif result.status in {"SKIPPED_EXISTS", "SKIPPED_NO_CHANGE"}:
skipped += 1
else:
failed += 1
result_rows.append(_result_to_row(raw, result))
print(f"[{idx}/{len(records)}] {raw.get('用户名', '')} -> {result.status} {result.reason}")
if result.status == "FAILED" and not continue_on_error:
break
_write_batch_results(config.paths.batch_result_file, result_rows)
total = len(result_rows)
print(
f"完成: total={total}, created={created}, updated={updated}, skipped={skipped}, failed={failed}, "
f"result={config.paths.batch_result_file}"
)
return 1 if failed > 0 else 0
finally:
ldap_client.close()
return 0
def run() -> int:
parser = build_parser()
args = parser.parse_args()
dry_run = bool(getattr(args, "dry_run", False))
input_path = getattr(args, "input", None)
continue_on_error = _to_bool_text(getattr(args, "continue_on_error", "true"))
return execute_command(
command=args.command,
config_path=args.config,
dry_run=dry_run,
input_path=input_path,
continue_on_error=continue_on_error,
)
if __name__ == "__main__":
sys.exit(run())

86
ad_user_creator/models.py Normal file
View File

@ -0,0 +1,86 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Dict, List, Optional
@dataclass
class UserInputRecord:
display_name: str
sam_account_name: str
email: str
dept_ou: str
base_group: str
project_groups: List[str] = field(default_factory=list)
resource_groups: List[str] = field(default_factory=list)
@dataclass
class ResolvedUserPlan:
user_dn: str
display_name: str
sam_account_name: str
email: str
uid: str
uid_number: int
gid_number: int
unix_home_directory: str
base_group: str
project_groups: List[str]
resource_groups: List[str]
optional_missing_groups: List[str] = field(default_factory=list)
@dataclass
class LdapConfig:
host: str
port: int
use_ssl: bool
bind_dn: str
bind_password: str
base_dn: str
people_base_dn: str
groups_base_dn: str
upn_suffix: str = ""
user_object_classes: List[str] = field(default_factory=list)
user_rdn_attr: str = "CN"
@dataclass
class DefaultsConfig:
base_group: str = "staff"
initial_uid_number: int = 2106
initial_password: str = "1234.com"
@dataclass
class PathsConfig:
uid_state_file: str = "state/uid_state.json"
group_gid_map_file: str = "state/group_gid_map.yaml"
batch_result_file: str = "state/last_batch_result.csv"
log_file: str = "state/run.log"
@dataclass
class BehaviorConfig:
skip_if_user_exists: bool = True
skip_missing_optional_groups: bool = True
dry_run: bool = False
require_ldaps_for_password: bool = True
@dataclass
class AppConfig:
ldap: LdapConfig
defaults: DefaultsConfig = field(default_factory=DefaultsConfig)
paths: PathsConfig = field(default_factory=PathsConfig)
behavior: BehaviorConfig = field(default_factory=BehaviorConfig)
@dataclass
class UserProcessResult:
status: str
reason: str = ""
user_dn: str = ""
uid_number: Optional[int] = None
raw: Optional[Dict[str, str]] = None

View File

@ -0,0 +1,83 @@
from __future__ import annotations
import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict
import yaml
from filelock import FileLock
from ad_user_creator.exceptions import StatePersistenceError
class StateStore:
def __init__(self, uid_state_file: str, group_gid_map_file: str, initial_uid_number: int = 2106) -> None:
self.uid_state_path = Path(uid_state_file)
self.group_gid_map_path = Path(group_gid_map_file)
self.initial_uid_number = initial_uid_number
self._uid_lock = FileLock(str(self.uid_state_path) + ".lock")
def ensure_state_files(self) -> None:
self.uid_state_path.parent.mkdir(parents=True, exist_ok=True)
self.group_gid_map_path.parent.mkdir(parents=True, exist_ok=True)
if not self.uid_state_path.exists():
self._write_uid_state({"next_uid_number": self.initial_uid_number, "updated_at": self._now_iso()})
if not self.group_gid_map_path.exists():
self._write_group_gid_map({"staff": 3000})
def get_next_uid_number(self) -> int:
with self._uid_lock:
state = self._read_uid_state()
return int(state["next_uid_number"])
def commit_next_uid_number(self) -> int:
with self._uid_lock:
state = self._read_uid_state()
current = int(state["next_uid_number"])
next_value = current + 1
self._write_uid_state({"next_uid_number": next_value, "updated_at": self._now_iso()})
return current
def load_group_gid_map(self) -> Dict[str, int]:
if not self.group_gid_map_path.exists():
raise StatePersistenceError(f"gid 映射文件不存在: {self.group_gid_map_path}")
try:
with self.group_gid_map_path.open("r", encoding="utf-8") as handle:
data = yaml.safe_load(handle) or {}
if not isinstance(data, dict):
raise StatePersistenceError("gid 映射文件内容必须是字典")
return {str(k): int(v) for k, v in data.items()}
except (yaml.YAMLError, ValueError, TypeError) as exc:
raise StatePersistenceError(f"读取 gid 映射失败: {exc}") from exc
def _read_uid_state(self) -> Dict[str, object]:
if not self.uid_state_path.exists():
raise StatePersistenceError(f"uid 状态文件不存在: {self.uid_state_path}")
try:
with self.uid_state_path.open("r", encoding="utf-8") as handle:
data = json.load(handle)
if "next_uid_number" not in data:
raise StatePersistenceError("uid 状态缺少 next_uid_number")
return data
except json.JSONDecodeError as exc:
raise StatePersistenceError(f"uid 状态解析失败: {exc}") from exc
def _write_uid_state(self, payload: Dict[str, object]) -> None:
try:
with self.uid_state_path.open("w", encoding="utf-8") as handle:
json.dump(payload, handle, ensure_ascii=True, indent=2)
except OSError as exc:
raise StatePersistenceError(f"写入 uid 状态失败: {exc}") from exc
def _write_group_gid_map(self, payload: Dict[str, int]) -> None:
try:
with self.group_gid_map_path.open("w", encoding="utf-8") as handle:
yaml.safe_dump(payload, handle, allow_unicode=False, sort_keys=True)
except OSError as exc:
raise StatePersistenceError(f"写入 gid 映射失败: {exc}") from exc
@staticmethod
def _now_iso() -> str:
return datetime.now(tz=timezone.utc).replace(microsecond=0).isoformat()

View File

@ -0,0 +1,278 @@
from __future__ import annotations
from dataclasses import asdict
from typing import Dict, List, Tuple
from ad_user_creator.exceptions import InputValidationError, LdapOperationError
from ad_user_creator.ldap_client import LdapClient
from ad_user_creator.models import AppConfig, ResolvedUserPlan, UserInputRecord, UserProcessResult
from ad_user_creator.persistence import StateStore
def build_department_dn_fragment(dept_ou: str) -> str:
parts = [part.strip() for part in dept_ou.split("/") if part.strip()]
if not parts:
raise InputValidationError("部门 OU 不能为空")
return ",".join(f"OU={part}" for part in reversed(parts))
def build_user_dn(display_name: str, dept_ou: str, people_base_dn: str) -> str:
dept_fragment = build_department_dn_fragment(dept_ou)
return f"CN={display_name},{dept_fragment},{people_base_dn}"
class UserService:
def __init__(self, config: AppConfig, state_store: StateStore, ldap_client: LdapClient) -> None:
self.config = config
self.state_store = state_store
self.ldap_client = ldap_client
self.group_gid_map = self.state_store.load_group_gid_map()
def preview_plan(self, record: UserInputRecord) -> ResolvedUserPlan:
gid_number = self._resolve_base_gid(record.base_group)
next_uid_number = self.state_store.get_next_uid_number()
return self._resolve_plan(record, next_uid_number, gid_number, optional_missing_groups=[])
def process_user(self, record: UserInputRecord, dry_run: bool = False) -> UserProcessResult:
gid_number = self._resolve_base_gid(record.base_group)
optional_missing_groups: List[str] = []
if not dry_run:
base_ok, missing_optional = self._validate_groups(record)
optional_missing_groups = missing_optional
if not base_ok:
return UserProcessResult(
status="FAILED",
reason=f"基础组不存在: {record.base_group}",
raw=asdict(record),
)
if missing_optional and not self.config.behavior.skip_missing_optional_groups:
return UserProcessResult(
status="FAILED",
reason=f"可选组不存在且配置不允许跳过: {','.join(missing_optional)}",
raw=asdict(record),
)
if not dry_run:
existing_user_dn = self.ldap_client.find_user_dn_by_sam(record.sam_account_name)
if existing_user_dn:
return self._process_existing_user(
record=record,
existing_user_dn=existing_user_dn,
gid_number=gid_number,
optional_missing_groups=optional_missing_groups,
)
uid_number = self.state_store.get_next_uid_number() if dry_run else self.state_store.commit_next_uid_number()
plan = self._resolve_plan(record, uid_number, gid_number, optional_missing_groups=optional_missing_groups)
if dry_run:
reason = "dry-run 未写入 LDAP将执行: 创建用户->设置初始密码->启用用户->加组)"
if optional_missing_groups:
reason += f";可选组缺失: {','.join(optional_missing_groups)}"
return UserProcessResult(
status="CREATED",
reason=reason,
user_dn=plan.user_dn,
uid_number=plan.uid_number,
raw=asdict(record),
)
attrs = self._build_ldap_attributes(plan)
try:
self.ldap_client.create_user(plan.user_dn, attrs)
except LdapOperationError as exc:
return UserProcessResult(
status="FAILED",
reason=f"create-user-failed: {exc}",
user_dn=plan.user_dn,
raw=asdict(record),
)
try:
self.ldap_client.set_user_password(plan.user_dn, self.config.defaults.initial_password)
except LdapOperationError as exc:
return UserProcessResult(
status="FAILED",
reason=f"password-set-failed: {exc}",
user_dn=plan.user_dn,
raw=asdict(record),
)
try:
self.ldap_client.set_user_enabled(plan.user_dn, enabled=True)
except LdapOperationError as exc:
return UserProcessResult(
status="FAILED",
reason=f"enable-user-failed: {exc}",
user_dn=plan.user_dn,
raw=asdict(record),
)
try:
added_groups = self._ensure_groups(plan.user_dn, plan.base_group, plan.project_groups, plan.resource_groups, plan.optional_missing_groups)
except LdapOperationError as exc:
return UserProcessResult(
status="FAILED",
reason=f"add-group-failed: {exc}",
user_dn=plan.user_dn,
raw=asdict(record),
)
reason = "创建成功"
if optional_missing_groups:
reason += f";可选组已跳过: {','.join(optional_missing_groups)}"
if added_groups:
reason += f";新增组成员: {','.join(added_groups)}"
return UserProcessResult(
status="CREATED",
reason=reason,
user_dn=plan.user_dn,
uid_number=plan.uid_number,
raw=asdict(record),
)
def _resolve_base_gid(self, base_group: str) -> int:
if base_group not in self.group_gid_map:
raise InputValidationError(f"基础组未配置 gidNumber: {base_group}")
return int(self.group_gid_map[base_group])
def _resolve_plan(
self,
record: UserInputRecord,
uid_number: int,
gid_number: int,
optional_missing_groups: List[str],
) -> ResolvedUserPlan:
user_dn = build_user_dn(record.display_name, record.dept_ou, self.config.ldap.people_base_dn)
return ResolvedUserPlan(
user_dn=user_dn,
display_name=record.display_name,
sam_account_name=record.sam_account_name,
email=record.email,
uid=record.sam_account_name,
uid_number=uid_number,
gid_number=gid_number,
unix_home_directory=f"/home/{record.sam_account_name}",
base_group=record.base_group,
project_groups=record.project_groups,
resource_groups=record.resource_groups,
optional_missing_groups=optional_missing_groups,
)
def _validate_groups(self, record: UserInputRecord) -> Tuple[bool, List[str]]:
if not self.ldap_client.group_exists(record.base_group):
return False, []
optional_missing: List[str] = []
for group in record.project_groups + record.resource_groups:
if not self.ldap_client.group_exists(group):
optional_missing.append(group)
return True, optional_missing
def _build_ldap_attributes(self, plan: ResolvedUserPlan) -> Dict[str, object]:
attrs: Dict[str, object] = {
"cn": plan.display_name,
"displayName": plan.display_name,
"sAMAccountName": plan.sam_account_name,
"mail": plan.email,
"uid": plan.uid,
"uidNumber": str(plan.uid_number),
"gidNumber": str(plan.gid_number),
"unixHomeDirectory": plan.unix_home_directory,
"userAccountControl": "514",
}
if self.config.ldap.upn_suffix:
attrs["userPrincipalName"] = f"{plan.sam_account_name}@{self.config.ldap.upn_suffix}"
return attrs
def _build_desired_update_attrs(self, record: UserInputRecord, gid_number: int) -> Dict[str, str]:
desired = {
"displayName": record.display_name,
"mail": record.email,
"uid": record.sam_account_name,
"unixHomeDirectory": f"/home/{record.sam_account_name}",
"gidNumber": str(gid_number),
}
if self.config.ldap.upn_suffix:
desired["userPrincipalName"] = f"{record.sam_account_name}@{self.config.ldap.upn_suffix}"
return desired
def _calculate_attr_changes(self, current: Dict[str, str], desired: Dict[str, str]) -> Dict[str, str]:
changes: Dict[str, str] = {}
for key, desired_value in desired.items():
current_value = str(current.get(key, "") or "").strip()
if current_value != str(desired_value).strip():
changes[key] = desired_value
return changes
def _ensure_groups(
self,
user_dn: str,
base_group: str,
project_groups: List[str],
resource_groups: List[str],
optional_missing_groups: List[str],
) -> List[str]:
added_groups: List[str] = []
base_group_dn = self.ldap_client.get_group_dn(base_group)
if self.ldap_client.add_user_to_group_if_missing(user_dn, base_group_dn):
added_groups.append(base_group)
optional_groups = list(project_groups) + list(resource_groups)
skip_set = set(optional_missing_groups)
for group in optional_groups:
if group in skip_set:
continue
group_dn = self.ldap_client.get_group_dn(group)
if self.ldap_client.add_user_to_group_if_missing(user_dn, group_dn):
added_groups.append(group)
return added_groups
def _process_existing_user(
self,
record: UserInputRecord,
existing_user_dn: str,
gid_number: int,
optional_missing_groups: List[str],
) -> UserProcessResult:
attrs_to_read = ["displayName", "mail", "uid", "unixHomeDirectory", "gidNumber"]
if self.config.ldap.upn_suffix:
attrs_to_read.append("userPrincipalName")
try:
current_attrs = self.ldap_client.get_user_attributes(existing_user_dn, attrs_to_read)
desired_attrs = self._build_desired_update_attrs(record, gid_number)
changes = self._calculate_attr_changes(current_attrs, desired_attrs)
if changes:
self.ldap_client.modify_user_attributes(existing_user_dn, changes)
added_groups = self._ensure_groups(
user_dn=existing_user_dn,
base_group=record.base_group,
project_groups=record.project_groups,
resource_groups=record.resource_groups,
optional_missing_groups=optional_missing_groups,
)
except LdapOperationError as exc:
return UserProcessResult(
status="FAILED",
reason=f"update-user-failed: {exc}",
user_dn=existing_user_dn,
raw=asdict(record),
)
if changes:
reason = f"已更新字段: {','.join(changes.keys())}"
if added_groups:
reason += f";新增组成员: {','.join(added_groups)}"
if optional_missing_groups:
reason += f";可选组已跳过: {','.join(optional_missing_groups)}"
return UserProcessResult(status="UPDATED", reason=reason, user_dn=existing_user_dn, raw=asdict(record))
reason = "用户已存在且字段无变化"
if added_groups:
reason += f";新增组成员: {','.join(added_groups)}"
return UserProcessResult(status="UPDATED", reason=reason, user_dn=existing_user_dn, raw=asdict(record))
if optional_missing_groups:
reason += f";可选组已跳过: {','.join(optional_missing_groups)}"
return UserProcessResult(status="SKIPPED_NO_CHANGE", reason=reason, user_dn=existing_user_dn, raw=asdict(record))

View File

@ -0,0 +1,44 @@
# -*- mode: python ; coding: utf-8 -*-
from PyInstaller.utils.hooks import collect_submodules
hiddenimports = []
hiddenimports += collect_submodules("pandas")
hiddenimports += collect_submodules("openpyxl")
hiddenimports += collect_submodules("ldap3")
a = Analysis(
["../ad_user_creator/entry.py"],
pathex=[],
binaries=[],
datas=[("../config/config.yaml.example", "config")],
hiddenimports=hiddenimports,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name="ad-user-creator",
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=True,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

34
config/config.yaml Normal file
View File

@ -0,0 +1,34 @@
ldap:
host: "10.10.22.21"
port: 636
use_ssl: true
bind_dn: "dcadmin@aflowx.com"
bind_password: "Ycw_Admin$"
base_dn: "DC=aflowx,DC=com"
people_base_dn: "OU=People,DC=aflowx,DC=com"
groups_base_dn: "OU=linux,OU=Groups,DC=aflowx,DC=com"
upn_suffix: "aflowx.com"
user_object_classes:
- top
- person
- organizationalPerson
- user
- posixAccount
user_rdn_attr: "CN"
defaults:
base_group: "staff"
initial_uid_number: 2106
initial_password: "1234.com"
paths:
uid_state_file: "state/uid_state.json"
group_gid_map_file: "state/group_gid_map.yaml"
batch_result_file: "state/last_batch_result.csv"
log_file: "state/run.log"
behavior:
skip_if_user_exists: true
skip_missing_optional_groups: true
dry_run: false
require_ldaps_for_password: true

View File

@ -0,0 +1,34 @@
ldap:
host: "ad.example.com"
port: 636
use_ssl: true
bind_dn: "CN=svc_ad,OU=Service,DC=example,DC=com"
bind_password: ""
base_dn: "DC=example,DC=com"
people_base_dn: "OU=People,DC=example,DC=com"
groups_base_dn: "OU=linux,OU=Groups,DC=example,DC=com"
upn_suffix: "example.com"
user_object_classes:
- top
- person
- organizationalPerson
- user
- posixAccount
user_rdn_attr: "CN"
defaults:
base_group: "staff"
initial_uid_number: 2106
initial_password: "1234.com"
paths:
uid_state_file: "state/uid_state.json"
group_gid_map_file: "state/group_gid_map.yaml"
batch_result_file: "state/last_batch_result.csv"
log_file: "state/run.log"
behavior:
skip_if_user_exists: true
skip_missing_optional_groups: true
dry_run: false
require_ldaps_for_password: true

BIN
dist/ad-user-creator vendored Executable file

Binary file not shown.

8
requirements.txt Normal file
View File

@ -0,0 +1,8 @@
ldap3
python-dotenv
PyYAML
pandas
openpyxl
pydantic
filelock
rich

96
run.sh Executable file
View File

@ -0,0 +1,96 @@
#!/usr/bin/env bash
set -euo pipefail
PYTHON_BIN="/opt/homebrew/Caskroom/miniconda/base/bin/python"
CONFIG_PATH="config/config.yaml"
INPUT_FILE=""
DRY_RUN="false"
CONTINUE_ON_ERROR="true"
usage() {
cat <<'EOF'
Usage:
./run.sh
./run.sh -f users.csv
./run.sh -f users.xlsx
./run.sh -f users.csv --dry-run
./run.sh -f users.xlsx --continue-on-error false
./run.sh -c config/config.yaml
Options:
-f <file> Batch input file (.csv/.xlsx). If omitted, interactive mode is used.
-c <path> YAML config path (default: config/config.yaml)
--dry-run Dry run mode
--continue-on-error true|false Batch only, default true
-h, --help Show this help
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
-f)
INPUT_FILE="${2:-}"
shift 2
;;
-c)
CONFIG_PATH="${2:-}"
shift 2
;;
--dry-run)
DRY_RUN="true"
shift
;;
--continue-on-error)
CONTINUE_ON_ERROR="${2:-}"
if [[ "${CONTINUE_ON_ERROR}" != "true" && "${CONTINUE_ON_ERROR}" != "false" ]]; then
echo "错误: --continue-on-error 仅支持 true 或 false" >&2
exit 2
fi
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "未知参数: $1" >&2
usage
exit 2
;;
esac
done
if [[ -z "${INPUT_FILE}" ]]; then
CMD=("${PYTHON_BIN}" -m ad_user_creator.main --config "${CONFIG_PATH}" interactive)
if [[ "${DRY_RUN}" == "true" ]]; then
CMD+=(--dry-run)
fi
exec "${CMD[@]}"
fi
if [[ ! -f "${INPUT_FILE}" ]]; then
echo "错误: 输入文件不存在: ${INPUT_FILE}" >&2
exit 2
fi
case "${INPUT_FILE##*.}" in
csv|CSV|xlsx|XLSX)
;;
*)
echo "错误: 仅支持 .csv 或 .xlsx 文件: ${INPUT_FILE}" >&2
exit 2
;;
esac
CMD=(
"${PYTHON_BIN}" -m ad_user_creator.main
--config "${CONFIG_PATH}"
batch
--input "${INPUT_FILE}"
--continue-on-error "${CONTINUE_ON_ERROR}"
)
if [[ "${DRY_RUN}" == "true" ]]; then
CMD+=(--dry-run)
fi
exec "${CMD[@]}"

2
state/group_gid_map.yaml Normal file
View File

@ -0,0 +1,2 @@
staff: 3000
outsourcing: 3001

View File

@ -0,0 +1,18 @@
姓名,用户名,邮箱,部门 OU,基础组,项目组,资源组,状态,原因,用户DN,uidNumber
Antonio,antonio,antonio@aflowx.com,IT,staff,,itadmins,CREATED,dry-run 未写入 LDAP将执行: 创建用户->设置初始密码->启用用户->加组),"CN=Antonio,OU=IT,OU=People,DC=aflowx,DC=com",2124
杨滨,yangbin,tony.yang@aflowx.com,CEO,staff,,,CREATED,dry-run 未写入 LDAP将执行: 创建用户->设置初始密码->启用用户->加组),"CN=杨滨,OU=CEO,OU=People,DC=aflowx,DC=com",2124
孙彤,sunt,patrick.sun@aflowx.com,CTO,staff,,,CREATED,dry-run 未写入 LDAP将执行: 创建用户->设置初始密码->启用用户->加组),"CN=孙彤,OU=CTO,OU=People,DC=aflowx,DC=com",2124
矫渊培,jiaoyp,agnarr.jiao@aflowx.com,RnD/tm_hardware,staff,"prj_r3xx_hw,prj_demo",,CREATED,dry-run 未写入 LDAP将执行: 创建用户->设置初始密码->启用用户->加组),"CN=矫渊培,OU=tm_hardware,OU=RnD,OU=People,DC=aflowx,DC=com",2124
张志峰,zhangzf,zhangzhifeng@aflowx.com,RnD/tm_hardware,outsourcing,prj_r3xx_hw,,CREATED,dry-run 未写入 LDAP将执行: 创建用户->设置初始密码->启用用户->加组),"CN=张志峰,OU=tm_hardware,OU=RnD,OU=People,DC=aflowx,DC=com",2124
司林飞,silf,silinfei@aflowx.com,RnD/tm_hardware,staff,"prj_r3xx_hw,prj_demo",,CREATED,dry-run 未写入 LDAP将执行: 创建用户->设置初始密码->启用用户->加组),"CN=司林飞,OU=tm_hardware,OU=RnD,OU=People,DC=aflowx,DC=com",2124
王顺涛,wangst,shawn.wang@aflowx.com,RnD/tm_hardware,staff,prj_r3xx_hw,,CREATED,dry-run 未写入 LDAP将执行: 创建用户->设置初始密码->启用用户->加组),"CN=王顺涛,OU=tm_hardware,OU=RnD,OU=People,DC=aflowx,DC=com",2124
陈兴峰,chenxf,robinson.chen@aflowx.com,RnD/tm_hardware,staff,"prj_r3xx_hw,prj_demo",,CREATED,dry-run 未写入 LDAP将执行: 创建用户->设置初始密码->启用用户->加组),"CN=陈兴峰,OU=tm_hardware,OU=RnD,OU=People,DC=aflowx,DC=com",2124
朱久运,zhujy,john.zhu@aflowx.com,RnD/tm_hardware,staff,prj_r3xx_hw,,CREATED,dry-run 未写入 LDAP将执行: 创建用户->设置初始密码->启用用户->加组),"CN=朱久运,OU=tm_hardware,OU=RnD,OU=People,DC=aflowx,DC=com",2124
孟凡博,mengfb,frank.meng@aflowx.com,RnD/tm_verify_test,staff,"prj_r3xx_vt,prj_demo",,CREATED,dry-run 未写入 LDAP将执行: 创建用户->设置初始密码->启用用户->加组),"CN=孟凡博,OU=tm_verify_test,OU=RnD,OU=People,DC=aflowx,DC=com",2124
张凯飞,zhangkf,zhangkaifei@aflowx.com,RnD/tm_verify_test,staff,"prj_r3xx_vt,prj_demo",,CREATED,dry-run 未写入 LDAP将执行: 创建用户->设置初始密码->启用用户->加组),"CN=张凯飞,OU=tm_verify_test,OU=RnD,OU=People,DC=aflowx,DC=com",2124
陈智新,chenzx,chenzhixin@aflowx.com,RnD/tm_verify_test,staff,"prj_r3xx_vt,prj_demo",,CREATED,dry-run 未写入 LDAP将执行: 创建用户->设置初始密码->启用用户->加组),"CN=陈智新,OU=tm_verify_test,OU=RnD,OU=People,DC=aflowx,DC=com",2124
程杨,chengy,chengyang@aflowx.com,RnD/tm_verify_test,staff,"prj_r3xx_vt,prj_demo",,CREATED,dry-run 未写入 LDAP将执行: 创建用户->设置初始密码->启用用户->加组),"CN=程杨,OU=tm_verify_test,OU=RnD,OU=People,DC=aflowx,DC=com",2124
黄鑫,huangxin,huangxin@aflowx.com,RnD/tm_algorithm,staff,prj_r3xx_alg,,CREATED,dry-run 未写入 LDAP将执行: 创建用户->设置初始密码->启用用户->加组),"CN=黄鑫,OU=tm_algorithm,OU=RnD,OU=People,DC=aflowx,DC=com",2124
徐晓锋,xuxf,keith.xu@aflowx.com,RnD/tm_algorithm,staff,"prj_r3xx_sw,prj_demo",,CREATED,dry-run 未写入 LDAP将执行: 创建用户->设置初始密码->启用用户->加组),"CN=徐晓锋,OU=tm_algorithm,OU=RnD,OU=People,DC=aflowx,DC=com",2124
陈华庆,chenhq,chenhuaqing@aflowx.com,RnD/tm_algorithm,staff,prj_r3xx_alg,,CREATED,dry-run 未写入 LDAP将执行: 创建用户->设置初始密码->启用用户->加组),"CN=陈华庆,OU=tm_algorithm,OU=RnD,OU=People,DC=aflowx,DC=com",2124
张海涛,zhanght,hogan.zhang@aflowx.com,RnD/tm_algorithm,staff,"prj_r3xx_sw,prj_demo",,CREATED,dry-run 未写入 LDAP将执行: 创建用户->设置初始密码->启用用户->加组),"CN=张海涛,OU=tm_algorithm,OU=RnD,OU=People,DC=aflowx,DC=com",2124
1 姓名 用户名 邮箱 部门 OU 基础组 项目组 资源组 状态 原因 用户DN uidNumber
2 Antonio antonio antonio@aflowx.com IT staff itadmins CREATED dry-run 未写入 LDAP(将执行: 创建用户->设置初始密码->启用用户->加组) CN=Antonio,OU=IT,OU=People,DC=aflowx,DC=com 2124
3 杨滨 yangbin tony.yang@aflowx.com CEO staff CREATED dry-run 未写入 LDAP(将执行: 创建用户->设置初始密码->启用用户->加组) CN=杨滨,OU=CEO,OU=People,DC=aflowx,DC=com 2124
4 孙彤 sunt patrick.sun@aflowx.com CTO staff CREATED dry-run 未写入 LDAP(将执行: 创建用户->设置初始密码->启用用户->加组) CN=孙彤,OU=CTO,OU=People,DC=aflowx,DC=com 2124
5 矫渊培 jiaoyp agnarr.jiao@aflowx.com RnD/tm_hardware staff prj_r3xx_hw,prj_demo CREATED dry-run 未写入 LDAP(将执行: 创建用户->设置初始密码->启用用户->加组) CN=矫渊培,OU=tm_hardware,OU=RnD,OU=People,DC=aflowx,DC=com 2124
6 张志峰 zhangzf zhangzhifeng@aflowx.com RnD/tm_hardware outsourcing prj_r3xx_hw CREATED dry-run 未写入 LDAP(将执行: 创建用户->设置初始密码->启用用户->加组) CN=张志峰,OU=tm_hardware,OU=RnD,OU=People,DC=aflowx,DC=com 2124
7 司林飞 silf silinfei@aflowx.com RnD/tm_hardware staff prj_r3xx_hw,prj_demo CREATED dry-run 未写入 LDAP(将执行: 创建用户->设置初始密码->启用用户->加组) CN=司林飞,OU=tm_hardware,OU=RnD,OU=People,DC=aflowx,DC=com 2124
8 王顺涛 wangst shawn.wang@aflowx.com RnD/tm_hardware staff prj_r3xx_hw CREATED dry-run 未写入 LDAP(将执行: 创建用户->设置初始密码->启用用户->加组) CN=王顺涛,OU=tm_hardware,OU=RnD,OU=People,DC=aflowx,DC=com 2124
9 陈兴峰 chenxf robinson.chen@aflowx.com RnD/tm_hardware staff prj_r3xx_hw,prj_demo CREATED dry-run 未写入 LDAP(将执行: 创建用户->设置初始密码->启用用户->加组) CN=陈兴峰,OU=tm_hardware,OU=RnD,OU=People,DC=aflowx,DC=com 2124
10 朱久运 zhujy john.zhu@aflowx.com RnD/tm_hardware staff prj_r3xx_hw CREATED dry-run 未写入 LDAP(将执行: 创建用户->设置初始密码->启用用户->加组) CN=朱久运,OU=tm_hardware,OU=RnD,OU=People,DC=aflowx,DC=com 2124
11 孟凡博 mengfb frank.meng@aflowx.com RnD/tm_verify_test staff prj_r3xx_vt,prj_demo CREATED dry-run 未写入 LDAP(将执行: 创建用户->设置初始密码->启用用户->加组) CN=孟凡博,OU=tm_verify_test,OU=RnD,OU=People,DC=aflowx,DC=com 2124
12 张凯飞 zhangkf zhangkaifei@aflowx.com RnD/tm_verify_test staff prj_r3xx_vt,prj_demo CREATED dry-run 未写入 LDAP(将执行: 创建用户->设置初始密码->启用用户->加组) CN=张凯飞,OU=tm_verify_test,OU=RnD,OU=People,DC=aflowx,DC=com 2124
13 陈智新 chenzx chenzhixin@aflowx.com RnD/tm_verify_test staff prj_r3xx_vt,prj_demo CREATED dry-run 未写入 LDAP(将执行: 创建用户->设置初始密码->启用用户->加组) CN=陈智新,OU=tm_verify_test,OU=RnD,OU=People,DC=aflowx,DC=com 2124
14 程杨 chengy chengyang@aflowx.com RnD/tm_verify_test staff prj_r3xx_vt,prj_demo CREATED dry-run 未写入 LDAP(将执行: 创建用户->设置初始密码->启用用户->加组) CN=程杨,OU=tm_verify_test,OU=RnD,OU=People,DC=aflowx,DC=com 2124
15 黄鑫 huangxin huangxin@aflowx.com RnD/tm_algorithm staff prj_r3xx_alg CREATED dry-run 未写入 LDAP(将执行: 创建用户->设置初始密码->启用用户->加组) CN=黄鑫,OU=tm_algorithm,OU=RnD,OU=People,DC=aflowx,DC=com 2124
16 徐晓锋 xuxf keith.xu@aflowx.com RnD/tm_algorithm staff prj_r3xx_sw,prj_demo CREATED dry-run 未写入 LDAP(将执行: 创建用户->设置初始密码->启用用户->加组) CN=徐晓锋,OU=tm_algorithm,OU=RnD,OU=People,DC=aflowx,DC=com 2124
17 陈华庆 chenhq chenhuaqing@aflowx.com RnD/tm_algorithm staff prj_r3xx_alg CREATED dry-run 未写入 LDAP(将执行: 创建用户->设置初始密码->启用用户->加组) CN=陈华庆,OU=tm_algorithm,OU=RnD,OU=People,DC=aflowx,DC=com 2124
18 张海涛 zhanght hogan.zhang@aflowx.com RnD/tm_algorithm staff prj_r3xx_sw,prj_demo CREATED dry-run 未写入 LDAP(将执行: 创建用户->设置初始密码->启用用户->加组) CN=张海涛,OU=tm_algorithm,OU=RnD,OU=People,DC=aflowx,DC=com 2124

182
state/run.log Normal file
View File

@ -0,0 +1,182 @@
2026-02-12 10:32:27,715 [INFO] 配置加载完成
2026-02-12 10:32:27,854 [INFO] LDAP 连接成功
2026-02-12 10:33:32,556 [INFO] 配置加载完成
2026-02-12 10:33:32,780 [INFO] LDAP 连接成功
2026-02-12 10:33:40,075 [INFO] 配置加载完成
2026-02-12 10:33:40,210 [INFO] LDAP 连接成功
2026-02-23 16:53:41,761 [INFO] 配置加载完成
2026-02-23 16:53:41,769 [INFO] 处理第 1 条: yangbin
2026-02-23 16:53:41,770 [INFO] 处理第 2 条: sunt
2026-02-23 16:53:41,770 [INFO] 处理第 3 条: jiaoyp
2026-02-23 16:53:41,771 [INFO] 处理第 4 条: wangst
2026-02-23 16:53:41,771 [INFO] 处理第 5 条: chenxf
2026-02-23 16:53:41,771 [INFO] 处理第 6 条: mengfb
2026-02-23 16:53:41,771 [INFO] 处理第 7 条: zhangkf
2026-02-23 16:53:41,772 [INFO] 处理第 8 条: huangxin
2026-02-23 16:53:41,772 [INFO] 处理第 9 条: xuxf
2026-02-23 17:43:38,430 [INFO] 配置加载完成
2026-02-23 17:43:38,894 [INFO] LDAP 连接成功
2026-02-23 17:46:24,258 [INFO] 配置加载完成
2026-02-23 17:46:24,276 [INFO] 处理第 1 条: yangbin
2026-02-23 17:46:24,276 [INFO] 处理第 2 条: sunt
2026-02-23 17:46:24,276 [INFO] 处理第 3 条: jiaoyp
2026-02-23 17:46:24,277 [INFO] 处理第 4 条: zhangzf
2026-02-23 17:46:24,277 [INFO] 处理第 5 条: jiaoyp
2026-02-23 17:46:24,277 [INFO] 处理第 6 条: wangst
2026-02-23 17:46:24,277 [INFO] 处理第 7 条: chenxf
2026-02-23 17:46:24,277 [INFO] 处理第 8 条: zhujy
2026-02-23 17:46:24,277 [INFO] 处理第 9 条: mengfb
2026-02-23 17:46:24,277 [INFO] 处理第 10 条: zhangkf
2026-02-23 17:46:24,277 [INFO] 处理第 11 条: chenzx
2026-02-23 17:46:24,277 [INFO] 处理第 12 条: chengy
2026-02-23 17:46:24,277 [INFO] 处理第 13 条: huangxin
2026-02-23 17:46:24,277 [INFO] 处理第 14 条: xuxf
2026-02-23 17:46:24,277 [INFO] 处理第 15 条: chenhq
2026-02-23 17:46:24,277 [INFO] 处理第 16 条: zhanght
2026-02-23 17:46:57,829 [INFO] 配置加载完成
2026-02-23 17:46:58,139 [INFO] LDAP 连接成功
2026-02-23 17:46:58,149 [INFO] 处理第 1 条: yangbin
2026-02-23 17:46:58,362 [INFO] 处理第 2 条: sunt
2026-02-23 17:46:58,548 [INFO] 处理第 3 条: jiaoyp
2026-02-23 17:46:59,343 [INFO] 处理第 4 条: zhangzf
2026-02-23 17:46:59,375 [INFO] 处理第 5 条: jiaoyp
2026-02-23 17:46:59,521 [INFO] 处理第 6 条: wangst
2026-02-23 17:46:59,839 [INFO] 处理第 7 条: chenxf
2026-02-23 17:47:00,425 [INFO] 处理第 8 条: zhujy
2026-02-23 17:47:00,872 [INFO] 处理第 9 条: mengfb
2026-02-23 17:47:01,275 [INFO] 处理第 10 条: zhangkf
2026-02-23 17:47:01,555 [INFO] 处理第 11 条: chenzx
2026-02-23 17:47:01,806 [INFO] 处理第 12 条: chengy
2026-02-23 17:47:02,002 [INFO] 处理第 13 条: huangxin
2026-02-23 17:47:02,180 [INFO] 处理第 14 条: xuxf
2026-02-23 17:47:02,411 [INFO] 处理第 15 条: chenhq
2026-02-23 17:47:02,591 [INFO] 处理第 16 条: zhanght
2026-02-23 17:48:10,486 [INFO] 配置加载完成
2026-02-23 17:48:10,776 [INFO] LDAP 连接成功
2026-02-23 17:48:10,789 [INFO] 处理第 1 条: yangbin
2026-02-23 17:48:10,797 [INFO] 处理第 2 条: sunt
2026-02-23 17:48:10,828 [INFO] 处理第 3 条: jiaoyp
2026-02-23 17:48:10,853 [INFO] 处理第 4 条: zhangzf
2026-02-23 17:48:10,875 [INFO] 处理第 5 条: silf
2026-02-23 17:48:11,180 [INFO] 处理第 6 条: wangst
2026-02-23 17:48:11,185 [INFO] 处理第 7 条: chenxf
2026-02-23 17:48:11,198 [INFO] 处理第 8 条: zhujy
2026-02-23 17:48:11,205 [INFO] 处理第 9 条: mengfb
2026-02-23 17:48:11,226 [INFO] 处理第 10 条: zhangkf
2026-02-23 17:48:11,250 [INFO] 处理第 11 条: chenzx
2026-02-23 17:48:11,261 [INFO] 处理第 12 条: chengy
2026-02-23 17:48:11,275 [INFO] 处理第 13 条: huangxin
2026-02-23 17:48:11,296 [INFO] 处理第 14 条: xuxf
2026-02-23 17:48:11,321 [INFO] 处理第 15 条: chenhq
2026-02-23 17:48:11,344 [INFO] 处理第 16 条: zhanght
2026-02-23 17:48:47,580 [INFO] 配置加载完成
2026-02-23 17:48:47,966 [INFO] LDAP 连接成功
2026-02-23 17:48:47,987 [INFO] 处理第 1 条: yangbin
2026-02-23 17:48:48,015 [INFO] 处理第 2 条: sunt
2026-02-23 17:48:48,092 [INFO] 处理第 3 条: jiaoyp
2026-02-23 17:48:48,169 [INFO] 处理第 4 条: zhangzf
2026-02-23 17:48:48,708 [INFO] 处理第 5 条: silf
2026-02-23 17:48:48,757 [INFO] 处理第 6 条: wangst
2026-02-23 17:48:48,765 [INFO] 处理第 7 条: chenxf
2026-02-23 17:48:48,785 [INFO] 处理第 8 条: zhujy
2026-02-23 17:48:48,805 [INFO] 处理第 9 条: mengfb
2026-02-23 17:48:48,826 [INFO] 处理第 10 条: zhangkf
2026-02-23 17:48:48,885 [INFO] 处理第 11 条: chenzx
2026-02-23 17:48:48,892 [INFO] 处理第 12 条: chengy
2026-02-23 17:48:48,901 [INFO] 处理第 13 条: huangxin
2026-02-23 17:48:48,908 [INFO] 处理第 14 条: xuxf
2026-02-23 17:48:48,919 [INFO] 处理第 15 条: chenhq
2026-02-23 17:48:48,936 [INFO] 处理第 16 条: zhanght
2026-02-24 14:04:53,433 [INFO] 配置加载完成
2026-02-24 14:04:53,444 [INFO] 处理第 1 条: antonio
2026-02-24 14:04:53,444 [INFO] 处理第 2 条: yangbin
2026-02-24 14:04:53,444 [INFO] 处理第 3 条: sunt
2026-02-24 14:04:53,445 [INFO] 处理第 4 条: jiaoyp
2026-02-24 14:04:53,445 [INFO] 处理第 5 条: zhangzf
2026-02-24 14:04:53,445 [INFO] 处理第 6 条: silf
2026-02-24 14:04:53,445 [INFO] 处理第 7 条: wangst
2026-02-24 14:04:53,445 [INFO] 处理第 8 条: chenxf
2026-02-24 14:04:53,445 [INFO] 处理第 9 条: zhujy
2026-02-24 14:04:53,445 [INFO] 处理第 10 条: mengfb
2026-02-24 14:04:53,446 [INFO] 处理第 11 条: zhangkf
2026-02-24 14:04:53,446 [INFO] 处理第 12 条: chenzx
2026-02-24 14:04:53,446 [INFO] 处理第 13 条: chengy
2026-02-24 14:04:53,446 [INFO] 处理第 14 条: huangxin
2026-02-24 14:04:53,446 [INFO] 处理第 15 条: xuxf
2026-02-24 14:04:53,446 [INFO] 处理第 16 条: chenhq
2026-02-24 14:04:53,447 [INFO] 处理第 17 条: zhanght
2026-02-24 14:06:18,789 [INFO] 配置加载完成
2026-02-24 14:06:18,799 [INFO] 处理第 1 条: antonio
2026-02-24 14:06:18,803 [INFO] 处理第 2 条: yangbin
2026-02-24 14:06:18,803 [INFO] 处理第 3 条: sunt
2026-02-24 14:06:18,804 [INFO] 处理第 4 条: jiaoyp
2026-02-24 14:06:18,804 [INFO] 处理第 5 条: zhangzf
2026-02-24 14:06:18,804 [INFO] 处理第 6 条: silf
2026-02-24 14:06:18,804 [INFO] 处理第 7 条: wangst
2026-02-24 14:06:18,804 [INFO] 处理第 8 条: chenxf
2026-02-24 14:06:18,804 [INFO] 处理第 9 条: zhujy
2026-02-24 14:06:18,804 [INFO] 处理第 10 条: mengfb
2026-02-24 14:06:18,804 [INFO] 处理第 11 条: zhangkf
2026-02-24 14:06:18,804 [INFO] 处理第 12 条: chenzx
2026-02-24 14:06:18,804 [INFO] 处理第 13 条: chengy
2026-02-24 14:06:18,805 [INFO] 处理第 14 条: huangxin
2026-02-24 14:06:18,805 [INFO] 处理第 15 条: xuxf
2026-02-24 14:06:18,805 [INFO] 处理第 16 条: chenhq
2026-02-24 14:06:18,805 [INFO] 处理第 17 条: zhanght
2026-02-24 14:06:24,509 [INFO] 配置加载完成
2026-02-24 14:06:24,904 [INFO] LDAP 连接成功
2026-02-24 14:06:24,913 [INFO] 处理第 1 条: antonio
2026-02-24 14:06:25,172 [INFO] 处理第 2 条: yangbin
2026-02-24 14:06:25,295 [INFO] 处理第 3 条: sunt
2026-02-24 14:06:25,542 [INFO] 处理第 4 条: jiaoyp
2026-02-24 14:06:25,859 [INFO] 处理第 5 条: zhangzf
2026-02-24 14:06:25,993 [INFO] 处理第 6 条: silf
2026-02-24 14:06:26,169 [INFO] 处理第 7 条: wangst
2026-02-24 14:06:26,276 [INFO] 处理第 8 条: chenxf
2026-02-24 14:06:26,426 [INFO] 处理第 9 条: zhujy
2026-02-24 14:06:26,558 [INFO] 处理第 10 条: mengfb
2026-02-24 14:06:26,705 [INFO] 处理第 11 条: zhangkf
2026-02-24 14:06:26,898 [INFO] 处理第 12 条: chenzx
2026-02-24 14:06:27,091 [INFO] 处理第 13 条: chengy
2026-02-24 14:06:27,215 [INFO] 处理第 14 条: huangxin
2026-02-24 14:06:27,310 [INFO] 处理第 15 条: xuxf
2026-02-24 14:06:27,451 [INFO] 处理第 16 条: chenhq
2026-02-24 14:06:27,548 [INFO] 处理第 17 条: zhanght
2026-02-24 14:45:41,019 [INFO] 配置加载完成
2026-02-24 14:45:41,030 [INFO] 处理第 1 条: antonio
2026-02-24 14:45:41,030 [INFO] 处理第 2 条: yangbin
2026-02-24 14:45:41,031 [INFO] 处理第 3 条: sunt
2026-02-24 14:45:41,031 [INFO] 处理第 4 条: jiaoyp
2026-02-24 14:45:41,031 [INFO] 处理第 5 条: zhangzf
2026-02-24 14:45:41,031 [INFO] 处理第 6 条: silf
2026-02-24 14:45:41,031 [INFO] 处理第 7 条: wangst
2026-02-24 14:45:41,031 [INFO] 处理第 8 条: chenxf
2026-02-24 14:45:41,032 [INFO] 处理第 9 条: zhujy
2026-02-24 14:45:41,032 [INFO] 处理第 10 条: mengfb
2026-02-24 14:45:41,032 [INFO] 处理第 11 条: zhangkf
2026-02-24 14:45:41,032 [INFO] 处理第 12 条: chenzx
2026-02-24 14:45:41,032 [INFO] 处理第 13 条: chengy
2026-02-24 14:45:41,032 [INFO] 处理第 14 条: huangxin
2026-02-24 14:45:41,033 [INFO] 处理第 15 条: xuxf
2026-02-24 14:45:41,033 [INFO] 处理第 16 条: chenhq
2026-02-24 14:45:41,033 [INFO] 处理第 17 条: zhanght
2026-02-24 15:56:50,639 [INFO] 配置加载完成
2026-02-24 15:56:50,650 [INFO] 处理第 1 条: antonio
2026-02-24 15:56:50,651 [INFO] 处理第 2 条: yangbin
2026-02-24 15:56:50,651 [INFO] 处理第 3 条: sunt
2026-02-24 15:56:50,651 [INFO] 处理第 4 条: jiaoyp
2026-02-24 15:56:50,651 [INFO] 处理第 5 条: zhangzf
2026-02-24 15:56:50,651 [INFO] 处理第 6 条: silf
2026-02-24 15:56:50,652 [INFO] 处理第 7 条: wangst
2026-02-24 15:56:50,652 [INFO] 处理第 8 条: chenxf
2026-02-24 15:56:50,652 [INFO] 处理第 9 条: zhujy
2026-02-24 15:56:50,652 [INFO] 处理第 10 条: mengfb
2026-02-24 15:56:50,652 [INFO] 处理第 11 条: zhangkf
2026-02-24 15:56:50,652 [INFO] 处理第 12 条: chenzx
2026-02-24 15:56:50,652 [INFO] 处理第 13 条: chengy
2026-02-24 15:56:50,653 [INFO] 处理第 14 条: huangxin
2026-02-24 15:56:50,653 [INFO] 处理第 15 条: xuxf
2026-02-24 15:56:50,653 [INFO] 处理第 16 条: chenhq
2026-02-24 15:56:50,653 [INFO] 处理第 17 条: zhanght
2026-02-24 16:58:20,988 [INFO] 配置加载完成
2026-02-24 16:58:35,515 [INFO] 配置加载完成

4
state/uid_state.json Normal file
View File

@ -0,0 +1,4 @@
{
"next_uid_number": 2124,
"updated_at": "2026-02-23T09:48:48+00:00"
}

View File

18
users.csv Normal file
View File

@ -0,0 +1,18 @@
姓名,用户名,邮箱,部门 OU,基础组,项目组,资源组
Antonio,antonio,antonio@aflowx.com,IT,staff,,itadmins
杨滨,yangbin,tony.yang@aflowx.com,CEO,staff,,
孙彤,sunt,patrick.sun@aflowx.com,CTO,staff,,
矫渊培,jiaoyp,agnarr.jiao@aflowx.com,RnD/tm_hardware,staff,"prj_r3xx_hw,prj_demo",
张志峰,zhangzf,zhangzhifeng@aflowx.com,RnD/tm_hardware,outsourcing,prj_r3xx_hw,
司林飞,silf,silinfei@aflowx.com,RnD/tm_hardware,staff,"prj_r3xx_hw,prj_demo",
王顺涛,wangst,shawn.wang@aflowx.com,RnD/tm_hardware,staff,prj_r3xx_hw,
陈兴峰,chenxf,robinson.chen@aflowx.com,RnD/tm_hardware,staff,"prj_r3xx_hw,prj_demo",
朱久运,zhujy,john.zhu@aflowx.com,RnD/tm_hardware,staff,prj_r3xx_hw,
孟凡博,mengfb,frank.meng@aflowx.com,RnD/tm_verify_test,staff,"prj_r3xx_vt,prj_demo",
张凯飞,zhangkf,zhangkaifei@aflowx.com,RnD/tm_verify_test,staff,"prj_r3xx_vt,prj_demo",
陈智新,chenzx,chenzhixin@aflowx.com,RnD/tm_verify_test,staff,"prj_r3xx_vt,prj_demo",
程杨,chengy,chengyang@aflowx.com,RnD/tm_verify_test,staff,"prj_r3xx_vt,prj_demo",
黄鑫,huangxin,huangxin@aflowx.com,RnD/tm_algorithm,staff,prj_r3xx_alg,
徐晓锋,xuxf,keith.xu@aflowx.com,RnD/tm_algorithm,staff,"prj_r3xx_sw,prj_demo",
陈华庆,chenhq,chenhuaqing@aflowx.com,RnD/tm_algorithm,staff,prj_r3xx_alg,
张海涛,zhanght,hogan.zhang@aflowx.com,RnD/tm_algorithm,staff,"prj_r3xx_sw,prj_demo",
1 姓名 用户名 邮箱 部门 OU 基础组 项目组 资源组
2 Antonio antonio antonio@aflowx.com IT staff itadmins
3 杨滨 yangbin tony.yang@aflowx.com CEO staff
4 孙彤 sunt patrick.sun@aflowx.com CTO staff
5 矫渊培 jiaoyp agnarr.jiao@aflowx.com RnD/tm_hardware staff prj_r3xx_hw,prj_demo
6 张志峰 zhangzf zhangzhifeng@aflowx.com RnD/tm_hardware outsourcing prj_r3xx_hw
7 司林飞 silf silinfei@aflowx.com RnD/tm_hardware staff prj_r3xx_hw,prj_demo
8 王顺涛 wangst shawn.wang@aflowx.com RnD/tm_hardware staff prj_r3xx_hw
9 陈兴峰 chenxf robinson.chen@aflowx.com RnD/tm_hardware staff prj_r3xx_hw,prj_demo
10 朱久运 zhujy john.zhu@aflowx.com RnD/tm_hardware staff prj_r3xx_hw
11 孟凡博 mengfb frank.meng@aflowx.com RnD/tm_verify_test staff prj_r3xx_vt,prj_demo
12 张凯飞 zhangkf zhangkaifei@aflowx.com RnD/tm_verify_test staff prj_r3xx_vt,prj_demo
13 陈智新 chenzx chenzhixin@aflowx.com RnD/tm_verify_test staff prj_r3xx_vt,prj_demo
14 程杨 chengy chengyang@aflowx.com RnD/tm_verify_test staff prj_r3xx_vt,prj_demo
15 黄鑫 huangxin huangxin@aflowx.com RnD/tm_algorithm staff prj_r3xx_alg
16 徐晓锋 xuxf keith.xu@aflowx.com RnD/tm_algorithm staff prj_r3xx_sw,prj_demo
17 陈华庆 chenhq chenhuaqing@aflowx.com RnD/tm_algorithm staff prj_r3xx_alg
18 张海涛 zhanght hogan.zhang@aflowx.com RnD/tm_algorithm staff prj_r3xx_sw,prj_demo