init
This commit is contained in:
commit
e4d0dee311
|
|
@ -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`
|
||||
|
||||
运行后将先显示菜单供选择模式。
|
||||
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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())
|
||||
|
|
@ -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."""
|
||||
|
|
@ -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
|
||||
|
|
@ -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}")
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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())
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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))
|
||||
|
|
@ -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
|
|
@ -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
|
||||
|
|
@ -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
|
||||
Binary file not shown.
|
|
@ -0,0 +1,8 @@
|
|||
ldap3
|
||||
python-dotenv
|
||||
PyYAML
|
||||
pandas
|
||||
openpyxl
|
||||
pydantic
|
||||
filelock
|
||||
rich
|
||||
|
|
@ -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[@]}"
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
staff: 3000
|
||||
outsourcing: 3001
|
||||
|
|
@ -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
|
||||
|
|
|
@ -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] 配置加载完成
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"next_uid_number": 2124,
|
||||
"updated_at": "2026-02-23T09:48:48+00:00"
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
Loading…
Reference in New Issue