This commit is contained in:
Marsway 2026-03-24 12:43:31 +08:00
parent e4d0dee311
commit 527ef78d36
32 changed files with 7271 additions and 6271 deletions

View File

@ -24,6 +24,7 @@
- `ldap.groups_base_dn`: 例如 `OU=linux,OU=Groups,DC=example,DC=com` - `ldap.groups_base_dn`: 例如 `OU=linux,OU=Groups,DC=example,DC=com`
- `defaults.initial_uid_number`: 默认为 `2106` - `defaults.initial_uid_number`: 默认为 `2106`
- `defaults.initial_password`: 默认初始密码 `"1234.com"` - `defaults.initial_password`: 默认初始密码 `"1234.com"`
- `groups_gid_map`: 组名与 gidNumber 映射(会在批量执行后自动增量更新)
- `paths.uid_state_file`: uidNumber 持久化文件 - `paths.uid_state_file`: uidNumber 持久化文件
- `paths.group_gid_map_file`: 组与 gidNumber 映射文件(默认 `staff: 3000` - `paths.group_gid_map_file`: 组与 gidNumber 映射文件(默认 `staff: 3000`
- `behavior.require_ldaps_for_password`: 密码设置要求 LDAPS建议保持 `true` - `behavior.require_ldaps_for_password`: 密码设置要求 LDAPS建议保持 `true`
@ -145,8 +146,11 @@ dry-run 示例:
## 输出与日志 ## 输出与日志
- 批量结果:`state/last_batch_result.csv` - 批量结果:`report.xlsx`(程序运行目录)
- 运行日志:`state/run.log` - 运行日志:`state/run.log`
- 报告字段包含:`uid`、`linuxuidnumber`、`基础组gid`、`项目组gid`、`资源组gid`
- 组 gid 读取优先级:先查 AD 组对象的 `gidNumber`,然后回退 `groups_gid_map`
- 批量执行后会把本次从 AD 发现的组 gid 增量回写到 `config.yaml``groups_gid_map`
- 批量状态: - 批量状态:
- `CREATED`:新建用户成功 - `CREATED`:新建用户成功
- `UPDATED`:已存在用户,属性或组关系发生更新 - `UPDATED`:已存在用户,属性或组关系发生更新

Binary file not shown.

View File

@ -33,4 +33,9 @@ def build_parser() -> argparse.ArgumentParser:
init_parser = subparsers.add_parser("init-state", help="初始化状态文件") init_parser = subparsers.add_parser("init-state", help="初始化状态文件")
init_parser.add_argument("--dry-run", action="store_true", help="仅打印,不落盘") init_parser.add_argument("--dry-run", action="store_true", help="仅打印,不落盘")
web_parser = subparsers.add_parser("web", help="启动 Web 服务")
web_parser.add_argument("--host", default="0.0.0.0", help="监听地址")
web_parser.add_argument("--port", type=int, default=8000, help="监听端口")
web_parser.add_argument("--config", default="config/config.yaml", help="yaml 配置文件路径")
return parser return parser

View File

@ -5,6 +5,7 @@ from pathlib import Path
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
import yaml import yaml
from filelock import FileLock
from ad_user_creator.exceptions import ConfigError from ad_user_creator.exceptions import ConfigError
from ad_user_creator.models import AppConfig, BehaviorConfig, DefaultsConfig, LdapConfig, PathsConfig from ad_user_creator.models import AppConfig, BehaviorConfig, DefaultsConfig, LdapConfig, PathsConfig
@ -113,6 +114,30 @@ def _merge_behavior_config(yaml_data: Dict[str, Any], cli_dry_run: Optional[bool
) )
def _merge_ui_options_config(yaml_data: Dict[str, Any]) -> "UIOptionsConfig":
from ad_user_creator.models import UIOptionsConfig
ui_yaml = yaml_data.get("ui_options", {}) or {}
return UIOptionsConfig(
ou_list=list(ui_yaml.get("ou_list", [])),
base_group_list=list(ui_yaml.get("base_group_list", [])),
project_group_list=list(ui_yaml.get("project_group_list", [])),
resource_group_list=list(ui_yaml.get("resource_group_list", [])),
)
def _merge_groups_gid_map(yaml_data: Dict[str, Any]) -> Dict[str, int]:
raw_map = yaml_data.get("groups_gid_map", {}) or {}
if not isinstance(raw_map, dict):
raise ConfigError("groups_gid_map 必须是键值字典")
merged: Dict[str, int] = {}
for group, gid in raw_map.items():
try:
merged[str(group)] = int(gid)
except (TypeError, ValueError) as exc:
raise ConfigError(f"groups_gid_map 非法值: {group}={gid}") from exc
return merged
def _resolve_paths(config: AppConfig, workspace_root: Path) -> AppConfig: def _resolve_paths(config: AppConfig, workspace_root: Path) -> AppConfig:
def make_abs(path_text: str) -> str: def make_abs(path_text: str) -> str:
path = Path(path_text) path = Path(path_text)
@ -144,8 +169,44 @@ def load_config(
defaults=_merge_defaults_config(yaml_data), defaults=_merge_defaults_config(yaml_data),
paths=_merge_paths_config(yaml_data), paths=_merge_paths_config(yaml_data),
behavior=_merge_behavior_config(yaml_data, cli_dry_run=cli_dry_run), behavior=_merge_behavior_config(yaml_data, cli_dry_run=cli_dry_run),
groups_gid_map=_merge_groups_gid_map(yaml_data),
ui_options=_merge_ui_options_config(yaml_data),
) )
app_config = _resolve_paths(app_config, root) app_config = _resolve_paths(app_config, root)
if app_config.behavior.require_ldaps_for_password and not app_config.ldap.use_ssl: if app_config.behavior.require_ldaps_for_password and not app_config.ldap.use_ssl:
raise ConfigError("启用密码设置时必须使用 LDAPS请将 ldap.use_ssl 设置为 true") raise ConfigError("启用密码设置时必须使用 LDAPS请将 ldap.use_ssl 设置为 true")
return app_config return app_config
def update_group_gid_map(config_path: str, discovered_map: Dict[str, int]) -> None:
if not discovered_map:
return
path = Path(config_path).resolve()
lock = FileLock(str(path) + ".lock")
with lock:
data = _read_yaml(path)
current = data.get("groups_gid_map", {}) or {}
if not isinstance(current, dict):
current = {}
merged = {str(k): int(v) for k, v in current.items()}
for group, gid in discovered_map.items():
merged[str(group)] = int(gid)
data["groups_gid_map"] = dict(sorted(merged.items()))
temp_path = path.with_suffix(path.suffix + ".tmp")
with temp_path.open("w", encoding="utf-8") as handle:
yaml.safe_dump(data, handle, allow_unicode=False, sort_keys=False)
temp_path.replace(path)
def update_ui_options(config_path: str, ui_options_data: dict) -> None:
path = Path(config_path).resolve()
lock = FileLock(str(path) + ".lock")
with lock:
data = _read_yaml(path)
data["ui_options"] = ui_options_data
temp_path = path.with_suffix(path.suffix + ".tmp")
with temp_path.open("w", encoding="utf-8") as handle:
yaml.safe_dump(data, handle, allow_unicode=True, sort_keys=False)
temp_path.replace(path)

View File

@ -76,6 +76,25 @@ class LdapClient:
raise LdapOperationError(f"组不存在: {group_name}") raise LdapOperationError(f"组不存在: {group_name}")
return str(self.conn.entries[0].distinguishedName.value) return str(self.conn.entries[0].distinguishedName.value)
def get_group_gid_number(self, group_name: str) -> Optional[int]:
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=["gidNumber"])
if not ok or len(self.conn.entries) == 0:
return None
entry = self.conn.entries[0]
if not hasattr(entry, "gidNumber"):
return None
value = entry.gidNumber.value
if value in (None, ""):
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def create_user(self, user_dn: str, attributes: Dict[str, object]) -> None: def create_user(self, user_dn: str, attributes: Dict[str, object]) -> None:
self.ensure_connected() self.ensure_connected()
assert self.conn is not None assert self.conn is not None

View File

@ -1,12 +1,13 @@
from __future__ import annotations from __future__ import annotations
import csv
import sys import sys
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional from typing import Dict, List, Optional
import pandas as pd
from ad_user_creator.cli import build_parser from ad_user_creator.cli import build_parser
from ad_user_creator.config import load_config from ad_user_creator.config import load_config, update_group_gid_map
from ad_user_creator.exceptions import AppError, ConfigError, InputValidationError, LdapConnectionError from ad_user_creator.exceptions import AppError, ConfigError, InputValidationError, LdapConnectionError
from ad_user_creator.input_parser import parse_input_file from ad_user_creator.input_parser import parse_input_file
from ad_user_creator.interactive import run_interactive_create from ad_user_creator.interactive import run_interactive_create
@ -17,15 +18,35 @@ from ad_user_creator.persistence import StateStore
from ad_user_creator.user_service import UserService from ad_user_creator.user_service import UserService
def _write_batch_results(path: str, rows: List[Dict[str, str]]) -> None: def _format_gid_map(gid_map: Optional[Dict[str, str]]) -> str:
if not gid_map:
return ""
return ";".join(f"{group}:{gid}" for group, gid in gid_map.items())
def _write_batch_report_xlsx(path: str, rows: List[Dict[str, str]]) -> None:
output = Path(path) output = Path(path)
output.parent.mkdir(parents=True, exist_ok=True) output.parent.mkdir(parents=True, exist_ok=True)
headers = ["姓名", "用户名", "邮箱", "部门 OU", "基础组", "项目组", "资源组", "状态", "原因", "用户DN", "uidNumber"] headers = [
with output.open("w", encoding="utf-8-sig", newline="") as handle: "姓名",
writer = csv.DictWriter(handle, fieldnames=headers) "用户名",
writer.writeheader() "邮箱",
for row in rows: "部门 OU",
writer.writerow(row) "基础组",
"项目组",
"资源组",
"状态",
"原因",
"用户DN",
"uid",
"linuxuidnumber",
"基础组gid",
"项目组gid",
"资源组gid",
]
dataframe = pd.DataFrame(rows)
dataframe = dataframe.reindex(columns=headers)
dataframe.to_excel(output, index=False, engine="openpyxl", sheet_name="report")
def _to_bool_text(value: str) -> bool: def _to_bool_text(value: str) -> bool:
@ -44,7 +65,11 @@ def _result_to_row(raw: Dict[str, str], result: UserProcessResult) -> Dict[str,
"状态": result.status, "状态": result.status,
"原因": result.reason, "原因": result.reason,
"用户DN": result.user_dn, "用户DN": result.user_dn,
"uidNumber": "" if result.uid_number is None else str(result.uid_number), "uid": "" if result.uid is None else str(result.uid),
"linuxuidnumber": "" if result.linux_uid_number is None else str(result.linux_uid_number),
"基础组gid": "" if result.base_gid is None else str(result.base_gid),
"项目组gid": _format_gid_map(result.project_group_gid_map),
"资源组gid": _format_gid_map(result.resource_group_gid_map),
} }
@ -54,6 +79,8 @@ def execute_command(
dry_run: bool = False, dry_run: bool = False,
input_path: Optional[str] = None, input_path: Optional[str] = None,
continue_on_error: bool = True, continue_on_error: bool = True,
host: Optional[str] = None,
port: Optional[int] = None,
) -> int: ) -> int:
try: try:
config = load_config(config_path=config_path, cli_dry_run=dry_run) config = load_config(config_path=config_path, cli_dry_run=dry_run)
@ -78,6 +105,15 @@ def execute_command(
print("状态文件初始化完成。") print("状态文件初始化完成。")
return 0 return 0
if command == "web":
state.ensure_state_files()
from ad_user_creator.web import create_app
import uvicorn
config._config_path = config_path
app = create_app(config, state)
uvicorn.run(app, host=host or "0.0.0.0", port=port or 8000)
return 0
state.ensure_state_files() state.ensure_state_files()
ldap_client = LdapClient(config.ldap) ldap_client = LdapClient(config.ldap)
if not config.behavior.dry_run: if not config.behavior.dry_run:
@ -123,7 +159,7 @@ def execute_command(
try: try:
result = service.process_user(record, dry_run=config.behavior.dry_run) result = service.process_user(record, dry_run=config.behavior.dry_run)
except (InputValidationError, AppError) as exc: except (InputValidationError, AppError) as exc:
result = UserProcessResult(status="FAILED", reason=str(exc), raw=raw) result = UserProcessResult(status="FAILED", reason=str(exc), raw=raw, uid=record.sam_account_name)
if result.status == "CREATED": if result.status == "CREATED":
created += 1 created += 1
@ -140,11 +176,16 @@ def execute_command(
if result.status == "FAILED" and not continue_on_error: if result.status == "FAILED" and not continue_on_error:
break break
_write_batch_results(config.paths.batch_result_file, result_rows) report_path = str((Path.cwd() / "report.xlsx").resolve())
_write_batch_report_xlsx(report_path, result_rows)
try:
update_group_gid_map(config_path, service.get_discovered_group_gid_map())
except Exception as exc:
logger.warning("回写 groups_gid_map 到 config.yaml 失败: %s", exc)
total = len(result_rows) total = len(result_rows)
print( print(
f"完成: total={total}, created={created}, updated={updated}, skipped={skipped}, failed={failed}, " f"完成: total={total}, created={created}, updated={updated}, skipped={skipped}, failed={failed}, "
f"result={config.paths.batch_result_file}" f"result={report_path}"
) )
return 1 if failed > 0 else 0 return 1 if failed > 0 else 0
finally: finally:
@ -159,12 +200,16 @@ def run() -> int:
dry_run = bool(getattr(args, "dry_run", False)) dry_run = bool(getattr(args, "dry_run", False))
input_path = getattr(args, "input", None) input_path = getattr(args, "input", None)
continue_on_error = _to_bool_text(getattr(args, "continue_on_error", "true")) continue_on_error = _to_bool_text(getattr(args, "continue_on_error", "true"))
host = getattr(args, "host", None)
port = getattr(args, "port", None)
return execute_command( return execute_command(
command=args.command, command=args.command,
config_path=args.config, config_path=args.config,
dry_run=dry_run, dry_run=dry_run,
input_path=input_path, input_path=input_path,
continue_on_error=continue_on_error, continue_on_error=continue_on_error,
host=host,
port=port,
) )

View File

@ -69,12 +69,22 @@ class BehaviorConfig:
require_ldaps_for_password: bool = True require_ldaps_for_password: bool = True
@dataclass
class UIOptionsConfig:
ou_list: List[str] = field(default_factory=list)
base_group_list: List[str] = field(default_factory=list)
project_group_list: List[str] = field(default_factory=list)
resource_group_list: List[str] = field(default_factory=list)
@dataclass @dataclass
class AppConfig: class AppConfig:
ldap: LdapConfig ldap: LdapConfig
defaults: DefaultsConfig = field(default_factory=DefaultsConfig) defaults: DefaultsConfig = field(default_factory=DefaultsConfig)
paths: PathsConfig = field(default_factory=PathsConfig) paths: PathsConfig = field(default_factory=PathsConfig)
behavior: BehaviorConfig = field(default_factory=BehaviorConfig) behavior: BehaviorConfig = field(default_factory=BehaviorConfig)
groups_gid_map: Dict[str, int] = field(default_factory=dict)
ui_options: UIOptionsConfig = field(default_factory=UIOptionsConfig)
@dataclass @dataclass
@ -83,4 +93,9 @@ class UserProcessResult:
reason: str = "" reason: str = ""
user_dn: str = "" user_dn: str = ""
uid_number: Optional[int] = None uid_number: Optional[int] = None
uid: Optional[str] = None
base_gid: Optional[int] = None
project_group_gid_map: Optional[Dict[str, str]] = None
resource_group_gid_map: Optional[Dict[str, str]] = None
linux_uid_number: Optional[int] = None
raw: Optional[Dict[str, str]] = None raw: Optional[Dict[str, str]] = None

View File

@ -0,0 +1,428 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>AD 用户创建</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-50 text-gray-900 font-sans min-h-screen py-10">
<div class="max-w-4xl mx-auto bg-white shadow-xl rounded-2xl overflow-hidden border border-gray-100">
<!-- Header -->
<div class="bg-gradient-to-r from-blue-600 to-indigo-700 px-8 py-6">
<h1 class="text-2xl font-bold text-white tracking-tight">AD 域用户自动创建系统</h1>
<p class="text-blue-100 text-sm mt-1">请填写以下信息以预览或创建新用户</p>
</div>
<!-- Main Content -->
<div class="p-8">
<form id="form" class="space-y-6">
<!-- Grid: Basic Info -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">显示名称 (姓名)</label>
<input type="text" name="display_name" required
class="block w-full rounded-md border-gray-300 shadow-sm border p-2.5 focus:border-blue-500 focus:ring-blue-500 sm:text-sm bg-gray-50 focus:bg-white transition-colors"
placeholder="例如:张三" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">用户名 (sAMAccountName)</label>
<input type="text" name="sam_account_name" required
class="block w-full rounded-md border-gray-300 shadow-sm border p-2.5 focus:border-blue-500 focus:ring-blue-500 sm:text-sm bg-gray-50 focus:bg-white transition-colors"
placeholder="例如zhangsan" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">邮箱 (mail)</label>
<input type="email" name="email" required
class="block w-full rounded-md border-gray-300 shadow-sm border p-2.5 focus:border-blue-500 focus:ring-blue-500 sm:text-sm bg-gray-50 focus:bg-white transition-colors"
placeholder="例如zhangsan@example.com" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">部门 OU</label>
<select name="dept_ou" id="dept_ou" required
class="block w-full rounded-md border-gray-300 shadow-sm border p-2.5 focus:border-blue-500 focus:ring-blue-500 sm:text-sm bg-gray-50 focus:bg-white transition-colors">
<option value="">请选择部门 OU...</option>
</select>
</div>
</div>
<hr class="border-gray-200" />
<!-- Grid: Groups -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">基础组</label>
<select name="base_group" id="base_group"
class="block w-full rounded-md border-gray-300 shadow-sm border p-2.5 focus:border-blue-500 focus:ring-blue-500 sm:text-sm bg-gray-50 focus:bg-white transition-colors">
<option value="">(默认 staff)</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">项目组</label>
<!-- Checkboxes will be injected here -->
<div id="project_groups_container" class="space-y-2 mt-2 max-h-40 overflow-y-auto p-2 border border-gray-200 rounded-md bg-gray-50">
<span class="text-sm text-gray-400">加载中...</span>
</div>
<!-- Hidden input to store comma-separated values -->
<input type="hidden" name="project_groups" id="project_groups_input" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">资源组</label>
<div id="resource_groups_container" class="space-y-2 mt-2 max-h-40 overflow-y-auto p-2 border border-gray-200 rounded-md bg-gray-50">
<span class="text-sm text-gray-400">加载中...</span>
</div>
<input type="hidden" name="resource_groups" id="resource_groups_input" />
</div>
</div>
<!-- Actions -->
<div class="pt-4 flex items-center justify-between">
<button type="button" id="btnConfig" class="inline-flex justify-center rounded-md border border-gray-300 bg-gray-50 px-4 py-2 text-sm font-medium text-gray-600 shadow-sm hover:bg-gray-100 focus:outline-none transition-all">
⚙️ 组别配置
</button>
<div class="flex space-x-4">
<button type="button" id="btnPreview"
class="inline-flex justify-center rounded-md border border-gray-300 bg-white px-6 py-2.5 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-all">
预览
</button>
<button type="button" id="btnCreate"
class="inline-flex justify-center rounded-md border border-transparent bg-blue-600 px-6 py-2.5 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-all">
确认创建
</button>
</div>
</div>
</form>
<!-- Output Area -->
<div id="output" class="mt-8 hidden">
<!-- Content will be injected here by JS -->
</div>
</div>
</div>
<!-- Config Modal -->
<div id="configModal" class="fixed inset-0 z-50 hidden overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true" id="modalBackdrop"></div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<div class="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-2xl sm:w-full sm:p-6">
<div>
<h3 class="text-lg leading-6 font-medium text-gray-900" id="modal-title">组别与 OU 配置</h3>
<p class="text-sm text-gray-500 mt-1">每行填写一个选项。保存后将写入配置文件并即时生效。</p>
<div class="mt-4 grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700">部门 OU 列表</label>
<textarea id="config_ou_list" rows="4" class="mt-1 block w-full rounded-md border border-gray-300 shadow-sm p-2 sm:text-sm focus:border-blue-500 focus:ring-blue-500"></textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">基础组列表</label>
<textarea id="config_base_group_list" rows="4" class="mt-1 block w-full rounded-md border border-gray-300 shadow-sm p-2 sm:text-sm focus:border-blue-500 focus:ring-blue-500"></textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">项目组列表</label>
<textarea id="config_project_group_list" rows="4" class="mt-1 block w-full rounded-md border border-gray-300 shadow-sm p-2 sm:text-sm focus:border-blue-500 focus:ring-blue-500"></textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">资源组列表</label>
<textarea id="config_resource_group_list" rows="4" class="mt-1 block w-full rounded-md border border-gray-300 shadow-sm p-2 sm:text-sm focus:border-blue-500 focus:ring-blue-500"></textarea>
</div>
</div>
</div>
<div class="mt-5 sm:mt-6 sm:flex sm:flex-row-reverse">
<button type="button" id="btnSaveConfig" class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none sm:ml-3 sm:w-auto sm:text-sm transition-all">保存配置</button>
<button type="button" id="btnCancelConfig" class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none sm:mt-0 sm:w-auto sm:text-sm transition-all">取消</button>
</div>
</div>
</div>
</div>
<script>
const form = document.getElementById('form');
const output = document.getElementById('output');
const btnPreview = document.getElementById('btnPreview');
const btnCreate = document.getElementById('btnCreate');
// Config elements
const btnConfig = document.getElementById('btnConfig');
const configModal = document.getElementById('configModal');
const btnSaveConfig = document.getElementById('btnSaveConfig');
const btnCancelConfig = document.getElementById('btnCancelConfig');
const modalBackdrop = document.getElementById('modalBackdrop');
// UI options data
let currentUIOptions = {
ou_list: [],
base_group_list: [],
project_group_list: [],
resource_group_list: []
};
function updateCheckboxValues(containerId, inputId) {
const container = document.getElementById(containerId);
const input = document.getElementById(inputId);
const checkedBoxes = container.querySelectorAll('input[type="checkbox"]:checked');
const values = Array.from(checkedBoxes).map(cb => cb.value);
input.value = values.join(',');
}
function renderCheckboxes(containerId, inputId, items) {
const container = document.getElementById(containerId);
if (!items || items.length === 0) {
container.innerHTML = '<span class="text-sm text-gray-400">无可用选项</span>';
return;
}
container.innerHTML = '';
items.forEach(item => {
const div = document.createElement('div');
div.className = 'flex items-center';
div.innerHTML = `
<input type="checkbox" value="${item}" class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" onchange="updateCheckboxValues('${containerId}', '${inputId}')">
<label class="ml-2 block text-sm text-gray-900">${item}</label>
`;
container.appendChild(div);
});
}
function renderSelect(selectId, items, defaultPlaceholder) {
const select = document.getElementById(selectId);
select.innerHTML = `<option value="">${defaultPlaceholder}</option>`;
if (items && items.length > 0) {
items.forEach(item => {
const opt = document.createElement('option');
opt.value = item;
opt.textContent = item;
select.appendChild(opt);
});
}
}
async function loadUIOptions() {
try {
const res = await fetch('/api/config/ui-options');
if (res.ok) {
currentUIOptions = await res.json();
renderSelect('dept_ou', currentUIOptions.ou_list, '请选择部门 OU...');
renderSelect('base_group', currentUIOptions.base_group_list, '请选择基础组...');
renderCheckboxes('project_groups_container', 'project_groups_input', currentUIOptions.project_group_list);
renderCheckboxes('resource_groups_container', 'resource_groups_input', currentUIOptions.resource_group_list);
}
} catch (err) {
console.error("加载 UI 配置失败", err);
}
}
// Modal behavior
function openConfigModal() {
document.getElementById('config_ou_list').value = currentUIOptions.ou_list.join('\n');
document.getElementById('config_base_group_list').value = currentUIOptions.base_group_list.join('\n');
document.getElementById('config_project_group_list').value = currentUIOptions.project_group_list.join('\n');
document.getElementById('config_resource_group_list').value = currentUIOptions.resource_group_list.join('\n');
configModal.classList.remove('hidden');
}
function closeConfigModal() {
configModal.classList.add('hidden');
}
async function saveConfig() {
const newVal = {
ou_list: document.getElementById('config_ou_list').value.split('\n'),
base_group_list: document.getElementById('config_base_group_list').value.split('\n'),
project_group_list: document.getElementById('config_project_group_list').value.split('\n'),
resource_group_list: document.getElementById('config_resource_group_list').value.split('\n')
};
btnSaveConfig.disabled = true;
btnSaveConfig.textContent = '保存中...';
try {
const res = await fetch('/api/config/ui-options', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newVal)
});
if (res.ok) {
closeConfigModal();
await loadUIOptions(); // reload UI
} else {
alert('保存失败');
}
} catch (err) {
alert('网络错误');
} finally {
btnSaveConfig.disabled = false;
btnSaveConfig.textContent = '保存配置';
}
}
btnConfig.addEventListener('click', openConfigModal);
btnCancelConfig.addEventListener('click', closeConfigModal);
modalBackdrop.addEventListener('click', closeConfigModal);
btnSaveConfig.addEventListener('click', saveConfig);
// Initial load
loadUIOptions();
function getBody() {
const fd = new FormData(form);
return {
display_name: (fd.get('display_name') || '').trim(),
sam_account_name: (fd.get('sam_account_name') || '').trim(),
email: (fd.get('email') || '').trim(),
dept_ou: (fd.get('dept_ou') || '').trim(),
base_group: (fd.get('base_group') || '').trim(),
project_groups: (fd.get('project_groups') || '').trim(),
resource_groups: (fd.get('resource_groups') || '').trim(),
};
}
// 格式化值的辅助函数,处理嵌套对象或数组
function formatValue(val, key, fullData) {
if (val === null || val === undefined) return '<span class="text-gray-400 italic">null</span>';
if (typeof val === 'boolean') {
return val
? '<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">True</span>'
: '<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">False</span>';
}
if (Array.isArray(val)) {
if (val.length === 0) return '<span class="text-gray-400 italic">空数组</span>';
return '<ul class="list-disc pl-4 space-y-1 text-sm">' + val.map(v => {
let itemHtml = formatValue(v, key, fullData);
// 特殊处理组的展示状态
if (fullData && fullData.groups_exist_in_ldap && fullData.groups_exist_in_ldap[v] !== undefined) {
const exists = fullData.groups_exist_in_ldap[v];
const badge = exists
? '<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">已存在</span>'
: '<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">不存在</span>';
return `<li>${itemHtml} ${badge}</li>`;
}
return `<li>${itemHtml}</li>`;
}).join('') + '</ul>';
}
if (typeof val === 'object') {
return `<pre class="text-xs bg-gray-50 p-2 rounded border border-gray-100 overflow-x-auto">${JSON.stringify(val, null, 2)}</pre>`;
}
let html = `<span class="text-sm text-gray-900">${val}</span>`;
// 特殊处理基础组
if (key === 'base_group' && fullData && fullData.groups_exist_in_ldap && fullData.groups_exist_in_ldap[val] !== undefined) {
const exists = fullData.groups_exist_in_ldap[val];
const badge = exists
? '<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">已存在</span>'
: '<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">不存在</span>';
html += badge;
}
return html;
}
function show(data, isError) {
output.classList.remove('hidden');
output.innerHTML = ''; // 清空内容
if (isError) {
// 渲染错误警告框
const errorMsg = typeof data === 'string' ? data : JSON.stringify(data);
output.innerHTML = `
<div class="rounded-md bg-red-50 p-4 border border-red-200">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">操作失败</h3>
<div class="mt-2 text-sm text-red-700 whitespace-pre-wrap">${errorMsg}</div>
</div>
</div>
</div>
`;
return;
}
// 渲染成功的表格 (如果是对象的话)
if (typeof data === 'object' && data !== null) {
let rowsHtml = '';
for (const [key, value] of Object.entries(data)) {
if (key === 'groups_exist_in_ldap') continue; // hide this helper property from table
rowsHtml += `
<tr class="hover:bg-gray-50 transition-colors">
<td class="whitespace-nowrap py-3 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6 border-b border-gray-200">${key}</td>
<td class="px-3 py-3 text-sm text-gray-500 border-b border-gray-200">${formatValue(value, key, data)}</td>
</tr>
`;
}
output.innerHTML = `
<div class="mt-6">
<h3 class="text-lg leading-6 font-medium text-gray-900 mb-4">执行结果</h3>
<div class="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
<table class="min-w-full divide-y divide-gray-300">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6 w-1/3">属性 (Key)</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">值 (Value)</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
${rowsHtml}
</tbody>
</table>
</div>
</div>
`;
} else {
// 如果后端返回的不是对象,降级显示为文本
output.innerHTML = `<div class="p-4 bg-gray-50 rounded-md border border-gray-200 text-sm whitespace-pre-wrap">${data}</div>`;
}
}
async function post(url) {
// 禁用按钮并显示加载状态(可选,提升体验)
const prevTextCreate = btnCreate.textContent;
const prevTextPreview = btnPreview.textContent;
btnCreate.disabled = true;
btnPreview.disabled = true;
btnCreate.classList.add('opacity-50', 'cursor-not-allowed');
btnPreview.classList.add('opacity-50', 'cursor-not-allowed');
if (url.includes('create')) btnCreate.textContent = '处理中...';
if (url.includes('preview')) btnPreview.textContent = '预览中...';
try {
const body = getBody();
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
let json;
try {
json = await res.json();
} catch(e) {
json = {};
}
if (!res.ok) {
show(json.detail || res.statusText || '请求失败', true);
return;
}
show(json);
} catch (err) {
show(err.message || '网络或未知错误', true);
} finally {
// 恢复按钮状态
btnCreate.disabled = false;
btnPreview.disabled = false;
btnCreate.classList.remove('opacity-50', 'cursor-not-allowed');
btnPreview.classList.remove('opacity-50', 'cursor-not-allowed');
btnCreate.textContent = prevTextCreate;
btnPreview.textContent = prevTextPreview;
}
}
btnPreview.addEventListener('click', () => post('/api/preview'));
btnCreate.addEventListener('click', () => post('/api/create'));
</script>
</body>
</html>

View File

@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import asdict from dataclasses import asdict
from typing import Dict, List, Tuple from typing import Dict, List, Optional, Tuple
from ad_user_creator.exceptions import InputValidationError, LdapOperationError from ad_user_creator.exceptions import InputValidationError, LdapOperationError
from ad_user_creator.ldap_client import LdapClient from ad_user_creator.ldap_client import LdapClient
@ -26,15 +26,34 @@ class UserService:
self.config = config self.config = config
self.state_store = state_store self.state_store = state_store
self.ldap_client = ldap_client self.ldap_client = ldap_client
self.group_gid_map = self.state_store.load_group_gid_map() self.group_gid_map = dict(config.groups_gid_map)
self.discovered_group_gid_map: Dict[str, int] = {}
def get_discovered_group_gid_map(self) -> Dict[str, int]:
return dict(self.discovered_group_gid_map)
def preview_plan(self, record: UserInputRecord) -> ResolvedUserPlan: def preview_plan(self, record: UserInputRecord) -> ResolvedUserPlan:
gid_number = self._resolve_base_gid(record.base_group) gid_number = self._resolve_base_gid(record.base_group, allow_ad_gid_lookup=False)
next_uid_number = self.state_store.get_next_uid_number() next_uid_number = self.state_store.get_next_uid_number()
return self._resolve_plan(record, next_uid_number, gid_number, optional_missing_groups=[]) return self._resolve_plan(record, next_uid_number, gid_number, optional_missing_groups=[])
def process_user(self, record: UserInputRecord, dry_run: bool = False) -> UserProcessResult: def process_user(self, record: UserInputRecord, dry_run: bool = False) -> UserProcessResult:
gid_number = self._resolve_base_gid(record.base_group) uid_value = record.sam_account_name
allow_ad_gid_lookup = not dry_run
project_gid_map = self._build_group_gid_map(record.project_groups, allow_ad_gid_lookup)
resource_gid_map = self._build_group_gid_map(record.resource_groups, allow_ad_gid_lookup)
try:
gid_number = self._resolve_base_gid(record.base_group, allow_ad_gid_lookup)
except InputValidationError as exc:
return UserProcessResult(
status="FAILED",
reason=str(exc),
uid=uid_value,
base_gid=None,
project_group_gid_map=project_gid_map,
resource_group_gid_map=resource_gid_map,
raw=asdict(record),
)
optional_missing_groups: List[str] = [] optional_missing_groups: List[str] = []
if not dry_run: if not dry_run:
@ -44,12 +63,20 @@ class UserService:
return UserProcessResult( return UserProcessResult(
status="FAILED", status="FAILED",
reason=f"基础组不存在: {record.base_group}", reason=f"基础组不存在: {record.base_group}",
uid=uid_value,
base_gid=gid_number,
project_group_gid_map=project_gid_map,
resource_group_gid_map=resource_gid_map,
raw=asdict(record), raw=asdict(record),
) )
if missing_optional and not self.config.behavior.skip_missing_optional_groups: if missing_optional and not self.config.behavior.skip_missing_optional_groups:
return UserProcessResult( return UserProcessResult(
status="FAILED", status="FAILED",
reason=f"可选组不存在且配置不允许跳过: {','.join(missing_optional)}", reason=f"可选组不存在且配置不允许跳过: {','.join(missing_optional)}",
uid=uid_value,
base_gid=gid_number,
project_group_gid_map=project_gid_map,
resource_group_gid_map=resource_gid_map,
raw=asdict(record), raw=asdict(record),
) )
@ -61,6 +88,9 @@ class UserService:
existing_user_dn=existing_user_dn, existing_user_dn=existing_user_dn,
gid_number=gid_number, gid_number=gid_number,
optional_missing_groups=optional_missing_groups, optional_missing_groups=optional_missing_groups,
uid_value=uid_value,
project_gid_map=project_gid_map,
resource_gid_map=resource_gid_map,
) )
uid_number = self.state_store.get_next_uid_number() if dry_run else self.state_store.commit_next_uid_number() uid_number = self.state_store.get_next_uid_number() if dry_run else self.state_store.commit_next_uid_number()
@ -75,6 +105,11 @@ class UserService:
reason=reason, reason=reason,
user_dn=plan.user_dn, user_dn=plan.user_dn,
uid_number=plan.uid_number, uid_number=plan.uid_number,
uid=uid_value,
base_gid=gid_number,
project_group_gid_map=project_gid_map,
resource_group_gid_map=resource_gid_map,
linux_uid_number=plan.uid_number,
raw=asdict(record), raw=asdict(record),
) )
@ -86,6 +121,11 @@ class UserService:
status="FAILED", status="FAILED",
reason=f"create-user-failed: {exc}", reason=f"create-user-failed: {exc}",
user_dn=plan.user_dn, user_dn=plan.user_dn,
uid=uid_value,
base_gid=gid_number,
project_group_gid_map=project_gid_map,
resource_group_gid_map=resource_gid_map,
linux_uid_number=plan.uid_number,
raw=asdict(record), raw=asdict(record),
) )
@ -96,6 +136,11 @@ class UserService:
status="FAILED", status="FAILED",
reason=f"password-set-failed: {exc}", reason=f"password-set-failed: {exc}",
user_dn=plan.user_dn, user_dn=plan.user_dn,
uid=uid_value,
base_gid=gid_number,
project_group_gid_map=project_gid_map,
resource_group_gid_map=resource_gid_map,
linux_uid_number=plan.uid_number,
raw=asdict(record), raw=asdict(record),
) )
@ -106,16 +151,31 @@ class UserService:
status="FAILED", status="FAILED",
reason=f"enable-user-failed: {exc}", reason=f"enable-user-failed: {exc}",
user_dn=plan.user_dn, user_dn=plan.user_dn,
uid=uid_value,
base_gid=gid_number,
project_group_gid_map=project_gid_map,
resource_group_gid_map=resource_gid_map,
linux_uid_number=plan.uid_number,
raw=asdict(record), raw=asdict(record),
) )
try: try:
added_groups = self._ensure_groups(plan.user_dn, plan.base_group, plan.project_groups, plan.resource_groups, plan.optional_missing_groups) 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: except LdapOperationError as exc:
return UserProcessResult( return UserProcessResult(
status="FAILED", status="FAILED",
reason=f"add-group-failed: {exc}", reason=f"add-group-failed: {exc}",
user_dn=plan.user_dn, user_dn=plan.user_dn,
uid=uid_value,
base_gid=gid_number,
project_group_gid_map=project_gid_map,
resource_group_gid_map=resource_gid_map,
raw=asdict(record), raw=asdict(record),
) )
@ -129,13 +189,31 @@ class UserService:
reason=reason, reason=reason,
user_dn=plan.user_dn, user_dn=plan.user_dn,
uid_number=plan.uid_number, uid_number=plan.uid_number,
uid=uid_value,
base_gid=gid_number,
project_group_gid_map=project_gid_map,
resource_group_gid_map=resource_gid_map,
linux_uid_number=plan.uid_number,
raw=asdict(record), raw=asdict(record),
) )
def _resolve_base_gid(self, base_group: str) -> int: def _resolve_group_gid(self, group_name: str, allow_ad_gid_lookup: bool) -> Tuple[Optional[int], str]:
if base_group not in self.group_gid_map: if allow_ad_gid_lookup:
gid_from_ad = self.ldap_client.get_group_gid_number(group_name)
if gid_from_ad is not None:
self.group_gid_map[group_name] = gid_from_ad
self.discovered_group_gid_map[group_name] = gid_from_ad
return gid_from_ad, "ad"
gid_from_config = self.group_gid_map.get(group_name)
if gid_from_config is not None:
return int(gid_from_config), "config"
return None, "none"
def _resolve_base_gid(self, base_group: str, allow_ad_gid_lookup: bool) -> int:
gid, _ = self._resolve_group_gid(base_group, allow_ad_gid_lookup)
if gid is None:
raise InputValidationError(f"基础组未配置 gidNumber: {base_group}") raise InputValidationError(f"基础组未配置 gidNumber: {base_group}")
return int(self.group_gid_map[base_group]) return gid
def _resolve_plan( def _resolve_plan(
self, self,
@ -197,6 +275,13 @@ class UserService:
desired["userPrincipalName"] = f"{record.sam_account_name}@{self.config.ldap.upn_suffix}" desired["userPrincipalName"] = f"{record.sam_account_name}@{self.config.ldap.upn_suffix}"
return desired return desired
def _build_group_gid_map(self, groups: List[str], allow_ad_gid_lookup: bool) -> Dict[str, str]:
group_gid_map: Dict[str, str] = {}
for group in groups:
gid, _ = self._resolve_group_gid(group, allow_ad_gid_lookup)
group_gid_map[group] = "NA" if gid is None else str(gid)
return group_gid_map
def _calculate_attr_changes(self, current: Dict[str, str], desired: Dict[str, str]) -> Dict[str, str]: def _calculate_attr_changes(self, current: Dict[str, str], desired: Dict[str, str]) -> Dict[str, str]:
changes: Dict[str, str] = {} changes: Dict[str, str] = {}
for key, desired_value in desired.items(): for key, desired_value in desired.items():
@ -234,8 +319,11 @@ class UserService:
existing_user_dn: str, existing_user_dn: str,
gid_number: int, gid_number: int,
optional_missing_groups: List[str], optional_missing_groups: List[str],
uid_value: str,
project_gid_map: Dict[str, str],
resource_gid_map: Dict[str, str],
) -> UserProcessResult: ) -> UserProcessResult:
attrs_to_read = ["displayName", "mail", "uid", "unixHomeDirectory", "gidNumber"] attrs_to_read = ["displayName", "mail", "uid", "uidNumber", "unixHomeDirectory", "gidNumber"]
if self.config.ldap.upn_suffix: if self.config.ldap.upn_suffix:
attrs_to_read.append("userPrincipalName") attrs_to_read.append("userPrincipalName")
@ -258,8 +346,17 @@ class UserService:
status="FAILED", status="FAILED",
reason=f"update-user-failed: {exc}", reason=f"update-user-failed: {exc}",
user_dn=existing_user_dn, user_dn=existing_user_dn,
uid=uid_value,
base_gid=gid_number,
project_group_gid_map=project_gid_map,
resource_group_gid_map=resource_gid_map,
linux_uid_number=None,
raw=asdict(record), raw=asdict(record),
) )
linux_uid_number: Optional[int] = None
raw_uid_number = str(current_attrs.get("uidNumber", "") or "").strip()
if raw_uid_number.isdigit():
linux_uid_number = int(raw_uid_number)
if changes: if changes:
reason = f"已更新字段: {','.join(changes.keys())}" reason = f"已更新字段: {','.join(changes.keys())}"
@ -267,12 +364,42 @@ class UserService:
reason += f";新增组成员: {','.join(added_groups)}" reason += f";新增组成员: {','.join(added_groups)}"
if optional_missing_groups: if optional_missing_groups:
reason += f";可选组已跳过: {','.join(optional_missing_groups)}" reason += f";可选组已跳过: {','.join(optional_missing_groups)}"
return UserProcessResult(status="UPDATED", reason=reason, user_dn=existing_user_dn, raw=asdict(record)) return UserProcessResult(
status="UPDATED",
reason=reason,
user_dn=existing_user_dn,
uid=uid_value,
base_gid=gid_number,
project_group_gid_map=project_gid_map,
resource_group_gid_map=resource_gid_map,
linux_uid_number=linux_uid_number,
raw=asdict(record),
)
reason = "用户已存在且字段无变化" reason = "用户已存在且字段无变化"
if added_groups: if added_groups:
reason += f";新增组成员: {','.join(added_groups)}" reason += f";新增组成员: {','.join(added_groups)}"
return UserProcessResult(status="UPDATED", reason=reason, user_dn=existing_user_dn, raw=asdict(record)) return UserProcessResult(
status="UPDATED",
reason=reason,
user_dn=existing_user_dn,
uid=uid_value,
base_gid=gid_number,
project_group_gid_map=project_gid_map,
resource_group_gid_map=resource_gid_map,
linux_uid_number=linux_uid_number,
raw=asdict(record),
)
if optional_missing_groups: if optional_missing_groups:
reason += f";可选组已跳过: {','.join(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)) return UserProcessResult(
status="SKIPPED_NO_CHANGE",
reason=reason,
user_dn=existing_user_dn,
uid=uid_value,
base_gid=gid_number,
project_group_gid_map=project_gid_map,
resource_group_gid_map=resource_gid_map,
linux_uid_number=linux_uid_number,
raw=asdict(record),
)

152
ad_user_creator/web.py Normal file
View File

@ -0,0 +1,152 @@
from __future__ import annotations
from contextlib import asynccontextmanager
from dataclasses import asdict
from pathlib import Path
from typing import Any, Dict, List
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, JSONResponse
from ad_user_creator.config import update_ui_options
from ad_user_creator.exceptions import AppError, InputValidationError
from ad_user_creator.ldap_client import LdapClient
from ad_user_creator.models import AppConfig, UserInputRecord, UIOptionsConfig
from ad_user_creator.persistence import StateStore
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 _body_to_record(body: Dict[str, Any], default_base_group: str) -> UserInputRecord:
display_name = (body.get("display_name") or "").strip()
sam_account_name = (body.get("sam_account_name") or "").strip()
email = (body.get("email") or "").strip()
dept_ou = (body.get("dept_ou") or "").strip()
base_group_input = (body.get("base_group") or "").strip()
base_group = base_group_input or default_base_group
project_groups = _split_optional_groups(body.get("project_groups") or "")
resource_groups = _split_optional_groups(body.get("resource_groups") or "")
return 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,
)
def create_app(config: AppConfig, state_store: StateStore) -> FastAPI:
@asynccontextmanager
async def lifespan(app: FastAPI):
ldap_client = LdapClient(config.ldap)
if not config.behavior.dry_run:
ldap_client.connect()
service = UserService(config=config, state_store=state_store, ldap_client=ldap_client)
app.state.config = config
app.state.service = service
yield
ldap_client.close()
app = FastAPI(lifespan=lifespan)
@app.get("/", response_class=HTMLResponse)
async def index() -> HTMLResponse:
path = Path(__file__).parent / "templates" / "index.html"
html = path.read_text(encoding="utf-8")
return HTMLResponse(html)
@app.post("/api/preview")
async def api_preview(request: Request) -> JSONResponse:
body = await request.json()
config: AppConfig = request.app.state.config
record = _body_to_record(body, config.defaults.base_group)
if not record.display_name or not record.sam_account_name or not record.email or not record.dept_ou:
return JSONResponse(
status_code=400,
content={"detail": "显示名称、用户名、邮箱、部门 OU 为必填项"},
)
service: UserService = request.app.state.service
plan = service.preview_plan(record)
# 检查组是否存在于 LDAP
groups_check = {}
if not config.behavior.dry_run:
groups_check[record.base_group] = service.ldap_client.group_exists(record.base_group)
for g in record.project_groups + record.resource_groups:
groups_check[g] = service.ldap_client.group_exists(g)
result_dict = asdict(plan)
if groups_check:
result_dict["groups_exist_in_ldap"] = groups_check
return JSONResponse(content=result_dict)
@app.get("/api/config/ui-options")
async def api_get_ui_options(request: Request) -> JSONResponse:
config: AppConfig = request.app.state.config
return JSONResponse(content=asdict(config.ui_options))
@app.put("/api/config/ui-options")
async def api_update_ui_options(request: Request) -> JSONResponse:
body = await request.json()
config: AppConfig = request.app.state.config
# 验证输入格式
def _parse_list(val: Any) -> List[str]:
if not isinstance(val, list):
return []
return [str(v).strip() for v in val if str(v).strip()]
new_options = {
"ou_list": _parse_list(body.get("ou_list")),
"base_group_list": _parse_list(body.get("base_group_list")),
"project_group_list": _parse_list(body.get("project_group_list")),
"resource_group_list": _parse_list(body.get("resource_group_list")),
}
# 由于我们不在 web.py 里直接知道 config_path我们需要从启动的地方或者约定一个环境变量/默认值
# ad_user_creator/main.py 或者 cli 里设置的配置路径
config_path_str = getattr(config, "_config_path", "config/config.yaml")
try:
update_ui_options(config_path_str, new_options)
# 热更新当前内存中的配置
config.ui_options = UIOptionsConfig(**new_options)
return JSONResponse(content={"detail": "配置已更新"})
except Exception as exc:
return JSONResponse(status_code=500, content={"detail": f"保存配置失败: {exc}"})
@app.post("/api/create")
async def api_create(request: Request) -> JSONResponse:
body = await request.json()
config: AppConfig = request.app.state.config
record = _body_to_record(body, config.defaults.base_group)
if not record.display_name or not record.sam_account_name or not record.email or not record.dept_ou:
return JSONResponse(
status_code=400,
content={"detail": "显示名称、用户名、邮箱、部门 OU 为必填项"},
)
service: UserService = request.app.state.service
result = service.process_user(record, dry_run=config.behavior.dry_run)
return JSONResponse(content=asdict(result))
@app.exception_handler(InputValidationError)
async def handle_input_validation_error(request: Request, exc: InputValidationError) -> JSONResponse:
return JSONResponse(status_code=400, content={"detail": str(exc)})
@app.exception_handler(AppError)
async def handle_app_error(request: Request, exc: AppError) -> JSONResponse:
return JSONResponse(status_code=500, content={"detail": str(exc)})
@app.exception_handler(Exception)
async def handle_exception(request: Request, exc: Exception) -> JSONResponse:
return JSONResponse(status_code=500, content={"detail": "服务器内部错误"})
return app

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.

View File

@ -7284,6 +7284,7 @@ imports:
&#8226; <a href="#ad_user_creator">ad_user_creator</a> &#8226; <a href="#ad_user_creator">ad_user_creator</a>
&#8226; <a href="#ad_user_creator.exceptions">ad_user_creator.exceptions</a> &#8226; <a href="#ad_user_creator.exceptions">ad_user_creator.exceptions</a>
&#8226; <a href="#ad_user_creator.models">ad_user_creator.models</a> &#8226; <a href="#ad_user_creator.models">ad_user_creator.models</a>
&#8226; <a href="#filelock">filelock</a>
&#8226; <a href="#os">os</a> &#8226; <a href="#os">os</a>
&#8226; <a href="#pathlib">pathlib</a> &#8226; <a href="#pathlib">pathlib</a>
&#8226; <a href="#typing">typing</a> &#8226; <a href="#typing">typing</a>
@ -7422,7 +7423,7 @@ imports:
&#8226; <a href="#ad_user_creator.models">ad_user_creator.models</a> &#8226; <a href="#ad_user_creator.models">ad_user_creator.models</a>
&#8226; <a href="#ad_user_creator.persistence">ad_user_creator.persistence</a> &#8226; <a href="#ad_user_creator.persistence">ad_user_creator.persistence</a>
&#8226; <a href="#ad_user_creator.user_service">ad_user_creator.user_service</a> &#8226; <a href="#ad_user_creator.user_service">ad_user_creator.user_service</a>
&#8226; <a href="#csv">csv</a> &#8226; <a href="#pandas">pandas</a>
&#8226; <a href="#pathlib">pathlib</a> &#8226; <a href="#pathlib">pathlib</a>
&#8226; <a href="#sys">sys</a> &#8226; <a href="#sys">sys</a>
&#8226; <a href="#typing">typing</a> &#8226; <a href="#typing">typing</a>
@ -12457,8 +12458,7 @@ imports:
</div> </div>
<div class="import"> <div class="import">
imported by: imported by:
<a href="#ad_user_creator.main">ad_user_creator.main</a> <a href="#importlib.metadata">importlib.metadata</a>
&#8226; <a href="#importlib.metadata">importlib.metadata</a>
&#8226; <a href="#pandas._testing.contexts">pandas._testing.contexts</a> &#8226; <a href="#pandas._testing.contexts">pandas._testing.contexts</a>
&#8226; <a href="#pandas.core.arrays.categorical">pandas.core.arrays.categorical</a> &#8226; <a href="#pandas.core.arrays.categorical">pandas.core.arrays.categorical</a>
&#8226; <a href="#pandas.io.formats.csvs">pandas.io.formats.csvs</a> &#8226; <a href="#pandas.io.formats.csvs">pandas.io.formats.csvs</a>
@ -17189,7 +17189,8 @@ imports:
</div> </div>
<div class="import"> <div class="import">
imported by: imported by:
<a href="#ad_user_creator.persistence">ad_user_creator.persistence</a> <a href="#ad_user_creator.config">ad_user_creator.config</a>
&#8226; <a href="#ad_user_creator.persistence">ad_user_creator.persistence</a>
&#8226; <a href="#filelock._api">filelock._api</a> &#8226; <a href="#filelock._api">filelock._api</a>
&#8226; <a href="#filelock._error">filelock._error</a> &#8226; <a href="#filelock._error">filelock._error</a>
&#8226; <a href="#filelock._soft">filelock._soft</a> &#8226; <a href="#filelock._soft">filelock._soft</a>
@ -38487,6 +38488,7 @@ imports:
<div class="import"> <div class="import">
imported by: imported by:
<a href="#ad_user_creator.input_parser">ad_user_creator.input_parser</a> <a href="#ad_user_creator.input_parser">ad_user_creator.input_parser</a>
&#8226; <a href="#ad_user_creator.main">ad_user_creator.main</a>
&#8226; <a href="#entry.py">entry.py</a> &#8226; <a href="#entry.py">entry.py</a>
&#8226; <a href="#openpyxl.utils.dataframe">openpyxl.utils.dataframe</a> &#8226; <a href="#openpyxl.utils.dataframe">openpyxl.utils.dataframe</a>
&#8226; <a href="#pandas">pandas</a> &#8226; <a href="#pandas">pandas</a>

View File

@ -1,34 +1,54 @@
ldap: ldap:
host: "10.10.22.21" host: 10.10.22.21
port: 636 port: 636
use_ssl: true use_ssl: true
bind_dn: "dcadmin@aflowx.com" bind_dn: dcadmin@aflowx.com
bind_password: "Ycw_Admin$" bind_password: Ycw_Admin$
base_dn: "DC=aflowx,DC=com" base_dn: DC=aflowx,DC=com
people_base_dn: "OU=People,DC=aflowx,DC=com" people_base_dn: OU=People,DC=aflowx,DC=com
groups_base_dn: "OU=linux,OU=Groups,DC=aflowx,DC=com" groups_base_dn: OU=linux,OU=Groups,DC=aflowx,DC=com
upn_suffix: "aflowx.com" upn_suffix: aflowx.com
user_object_classes: user_object_classes:
- top - top
- person - person
- organizationalPerson - organizationalPerson
- user - user
- posixAccount - posixAccount
user_rdn_attr: "CN" user_rdn_attr: CN
defaults: defaults:
base_group: "staff" base_group: staff
initial_uid_number: 2106 initial_uid_number: 2106
initial_password: "1234.com" initial_password: 1234.com
paths: paths:
uid_state_file: "state/uid_state.json" uid_state_file: state/uid_state.json
group_gid_map_file: "state/group_gid_map.yaml" group_gid_map_file: state/group_gid_map.yaml
batch_result_file: "state/last_batch_result.csv" batch_result_file: state/last_batch_result.csv
log_file: "state/run.log" log_file: state/run.log
behavior: behavior:
skip_if_user_exists: true skip_if_user_exists: true
skip_missing_optional_groups: true skip_missing_optional_groups: true
dry_run: false dry_run: false
require_ldaps_for_password: true require_ldaps_for_password: true
groups_gid_map:
outsourcing: 3001
prj_demo: 3100
prj_r3xx_alg: 3101
prj_r3xx_hw: 3102
prj_r3xx_sw: 3103
prj_r3xx_vt: 3104
staff: 3000
ui_options:
ou_list:
- ALL
- RnD/tm_hardware
- RnD/tm_verify_test
- RnD/tm_algorithm
- RnD/tm_software
base_group_list:
- staff
- outsourcing
project_group_list:
- prj_r500_hwvt
- prj_demo
- prj_r500_swalg
resource_group_list: []

View File

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

0
config/config.yaml.lock Normal file
View File

BIN
dist/ad-user-creator vendored

Binary file not shown.

BIN
report.xlsx Normal file

Binary file not shown.

View File

@ -6,3 +6,5 @@ openpyxl
pydantic pydantic
filelock filelock
rich rich
fastapi
uvicorn[standard]

View File

@ -180,3 +180,124 @@
2026-02-24 15:56:50,653 [INFO] 处理第 17 条: zhanght 2026-02-24 15:56:50,653 [INFO] 处理第 17 条: zhanght
2026-02-24 16:58:20,988 [INFO] 配置加载完成 2026-02-24 16:58:20,988 [INFO] 配置加载完成
2026-02-24 16:58:35,515 [INFO] 配置加载完成 2026-02-24 16:58:35,515 [INFO] 配置加载完成
2026-02-25 11:39:40,955 [INFO] 配置加载完成
2026-02-25 11:39:40,965 [INFO] 处理第 1 条: antonio
2026-02-25 11:39:40,966 [INFO] 处理第 2 条: yangbin
2026-02-25 11:39:40,967 [INFO] 处理第 3 条: sunt
2026-02-25 11:39:40,967 [INFO] 处理第 4 条: jiaoyp
2026-02-25 11:39:40,967 [INFO] 处理第 5 条: zhangzf
2026-02-25 11:39:40,967 [INFO] 处理第 6 条: silf
2026-02-25 11:39:40,967 [INFO] 处理第 7 条: wangst
2026-02-25 11:39:40,968 [INFO] 处理第 8 条: chenxf
2026-02-25 11:39:40,968 [INFO] 处理第 9 条: zhujy
2026-02-25 11:39:40,968 [INFO] 处理第 10 条: mengfb
2026-02-25 11:39:40,968 [INFO] 处理第 11 条: zhangkf
2026-02-25 11:39:40,968 [INFO] 处理第 12 条: chenzx
2026-02-25 11:39:40,969 [INFO] 处理第 13 条: chengy
2026-02-25 11:39:40,969 [INFO] 处理第 14 条: huangxin
2026-02-25 11:39:40,969 [INFO] 处理第 15 条: xuxf
2026-02-25 11:39:40,969 [INFO] 处理第 16 条: chenhq
2026-02-25 11:39:40,969 [INFO] 处理第 17 条: zhanght
2026-02-25 11:46:40,338 [INFO] 配置加载完成
2026-02-25 11:46:40,589 [INFO] LDAP 连接成功
2026-02-25 11:46:40,602 [INFO] 处理第 1 条: antonio
2026-02-25 11:46:40,793 [INFO] 处理第 2 条: yangbin
2026-02-25 11:46:40,874 [INFO] 处理第 3 条: sunt
2026-02-25 11:46:40,952 [INFO] 处理第 4 条: jiaoyp
2026-02-25 11:46:41,181 [INFO] 处理第 5 条: zhangzf
2026-02-25 11:46:41,379 [INFO] 处理第 6 条: silf
2026-02-25 11:46:41,543 [INFO] 处理第 7 条: wangst
2026-02-25 11:46:41,628 [INFO] 处理第 8 条: chenxf
2026-02-25 11:46:41,890 [INFO] 处理第 9 条: zhujy
2026-02-25 11:46:42,032 [INFO] 处理第 10 条: mengfb
2026-02-25 11:46:42,212 [INFO] 处理第 11 条: zhangkf
2026-02-25 11:46:42,468 [INFO] 处理第 12 条: chenzx
2026-02-25 11:46:42,654 [INFO] 处理第 13 条: chengy
2026-02-25 11:46:42,893 [INFO] 处理第 14 条: huangxin
2026-02-25 11:46:43,017 [INFO] 处理第 15 条: xuxf
2026-02-25 11:46:43,208 [INFO] 处理第 16 条: chenhq
2026-02-25 11:46:43,406 [INFO] 处理第 17 条: zhanght
2026-02-25 11:46:52,165 [INFO] 配置加载完成
2026-02-25 11:46:52,465 [INFO] LDAP 连接成功
2026-02-25 11:46:52,472 [INFO] 处理第 1 条: antonio
2026-02-25 11:46:52,565 [INFO] 处理第 2 条: yangbin
2026-02-25 11:46:52,642 [INFO] 处理第 3 条: sunt
2026-02-25 11:46:52,709 [INFO] 处理第 4 条: jiaoyp
2026-02-25 11:46:52,945 [INFO] 处理第 5 条: zhangzf
2026-02-25 11:46:53,059 [INFO] 处理第 6 条: silf
2026-02-25 11:46:53,325 [INFO] 处理第 7 条: wangst
2026-02-25 11:46:53,430 [INFO] 处理第 8 条: chenxf
2026-02-25 11:46:53,586 [INFO] 处理第 9 条: zhujy
2026-02-25 11:46:53,700 [INFO] 处理第 10 条: mengfb
2026-02-25 11:46:53,956 [INFO] 处理第 11 条: zhangkf
2026-02-25 11:46:54,161 [INFO] 处理第 12 条: chenzx
2026-02-25 11:46:54,405 [INFO] 处理第 13 条: chengy
2026-02-25 11:46:54,566 [INFO] 处理第 14 条: huangxin
2026-02-25 11:46:54,696 [INFO] 处理第 15 条: xuxf
2026-02-25 11:46:54,926 [INFO] 处理第 16 条: chenhq
2026-02-25 11:46:55,044 [INFO] 处理第 17 条: zhanght
2026-02-25 11:53:04,909 [INFO] 配置加载完成
2026-02-25 11:53:04,922 [INFO] 处理第 1 条: antonio
2026-02-25 11:53:04,923 [INFO] 处理第 2 条: yangbin
2026-02-25 11:53:04,923 [INFO] 处理第 3 条: sunt
2026-02-25 11:53:04,923 [INFO] 处理第 4 条: jiaoyp
2026-02-25 11:53:04,924 [INFO] 处理第 5 条: zhangzf
2026-02-25 11:53:04,924 [INFO] 处理第 6 条: silf
2026-02-25 11:53:04,924 [INFO] 处理第 7 条: wangst
2026-02-25 11:53:04,924 [INFO] 处理第 8 条: chenxf
2026-02-25 11:53:04,925 [INFO] 处理第 9 条: zhujy
2026-02-25 11:53:04,925 [INFO] 处理第 10 条: mengfb
2026-02-25 11:53:04,925 [INFO] 处理第 11 条: zhangkf
2026-02-25 11:53:04,925 [INFO] 处理第 12 条: chenzx
2026-02-25 11:53:04,925 [INFO] 处理第 13 条: chengy
2026-02-25 11:53:04,925 [INFO] 处理第 14 条: huangxin
2026-02-25 11:53:04,926 [INFO] 处理第 15 条: xuxf
2026-02-25 11:53:04,926 [INFO] 处理第 16 条: chenhq
2026-02-25 11:53:04,926 [INFO] 处理第 17 条: zhanght
2026-02-25 11:54:01,873 [INFO] 配置加载完成
2026-02-25 11:54:02,143 [INFO] LDAP 连接成功
2026-02-25 11:54:02,153 [INFO] 处理第 1 条: antonio
2026-02-25 11:54:02,347 [INFO] 处理第 2 条: yangbin
2026-02-25 11:54:02,423 [INFO] 处理第 3 条: sunt
2026-02-25 11:54:02,576 [INFO] 处理第 4 条: jiaoyp
2026-02-25 11:54:02,739 [INFO] 处理第 5 条: zhangzf
2026-02-25 11:54:02,941 [INFO] 处理第 6 条: silf
2026-02-25 11:54:03,205 [INFO] 处理第 7 条: wangst
2026-02-25 11:54:03,428 [INFO] 处理第 8 条: chenxf
2026-02-25 11:54:03,682 [INFO] 处理第 9 条: zhujy
2026-02-25 11:54:03,811 [INFO] 处理第 10 条: mengfb
2026-02-25 11:54:04,158 [INFO] 处理第 11 条: zhangkf
2026-02-25 11:54:04,328 [INFO] 处理第 12 条: chenzx
2026-02-25 11:54:04,668 [INFO] 处理第 13 条: chengy
2026-02-25 11:54:04,848 [INFO] 处理第 14 条: huangxin
2026-02-25 11:54:05,168 [INFO] 处理第 15 条: xuxf
2026-02-25 11:54:05,358 [INFO] 处理第 16 条: chenhq
2026-02-25 11:54:05,563 [INFO] 处理第 17 条: zhanght
2026-02-26 15:03:26,978 [INFO] 配置加载完成
2026-02-26 15:03:26,996 [INFO] 处理第 1 条: antonio
2026-02-26 15:03:26,997 [INFO] 处理第 2 条: yangbin
2026-02-26 15:03:26,997 [INFO] 处理第 3 条: sunt
2026-02-26 15:03:26,998 [INFO] 处理第 4 条: jiaoyp
2026-02-26 15:03:26,998 [INFO] 处理第 5 条: zhangzf
2026-02-26 15:03:26,998 [INFO] 处理第 6 条: silf
2026-02-26 15:03:26,998 [INFO] 处理第 7 条: wangst
2026-02-26 15:03:26,998 [INFO] 处理第 8 条: chenxf
2026-02-26 15:03:26,998 [INFO] 处理第 9 条: zhujy
2026-02-26 15:03:26,998 [INFO] 处理第 10 条: mengfb
2026-02-26 15:03:26,999 [INFO] 处理第 11 条: zhangkf
2026-02-26 15:03:26,999 [INFO] 处理第 12 条: chenzx
2026-02-26 15:03:26,999 [INFO] 处理第 13 条: chengy
2026-02-26 15:03:26,999 [INFO] 处理第 14 条: huangxin
2026-02-26 15:03:26,999 [INFO] 处理第 15 条: xuxf
2026-02-26 15:03:26,999 [INFO] 处理第 16 条: chenhq
2026-02-26 15:03:26,999 [INFO] 处理第 17 条: zhanght
2026-03-12 09:07:37,398 [INFO] 配置加载完成
2026-03-12 09:07:37,605 [INFO] LDAP 连接成功
2026-03-12 12:07:26,111 [INFO] 配置加载完成
2026-03-12 12:07:42,525 [INFO] 配置加载完成
2026-03-12 12:07:50,009 [INFO] 配置加载完成
2026-03-12 12:07:54,744 [INFO] 配置加载完成
2026-03-13 17:09:11,478 [INFO] 配置加载完成
2026-03-17 10:44:31,846 [INFO] 配置加载完成
2026-03-19 23:46:47,952 [INFO] 配置加载完成
2026-03-24 12:16:13,498 [INFO] 配置加载完成

View File

@ -1,4 +1,4 @@
{ {
"next_uid_number": 2124, "next_uid_number": 2132,
"updated_at": "2026-02-23T09:48:48+00:00" "updated_at": "2026-03-17T02:44:35+00:00"
} }