add ui
This commit is contained in:
parent
e4d0dee311
commit
527ef78d36
|
|
@ -24,6 +24,7 @@
|
|||
- `ldap.groups_base_dn`: 例如 `OU=linux,OU=Groups,DC=example,DC=com`
|
||||
- `defaults.initial_uid_number`: 默认为 `2106`
|
||||
- `defaults.initial_password`: 默认初始密码 `"1234.com"`
|
||||
- `groups_gid_map`: 组名与 gidNumber 映射(会在批量执行后自动增量更新)
|
||||
- `paths.uid_state_file`: uidNumber 持久化文件
|
||||
- `paths.group_gid_map_file`: 组与 gidNumber 映射文件(默认 `staff: 3000`)
|
||||
- `behavior.require_ldaps_for_password`: 密码设置要求 LDAPS(建议保持 `true`)
|
||||
|
|
@ -145,8 +146,11 @@ dry-run 示例:
|
|||
|
||||
## 输出与日志
|
||||
|
||||
- 批量结果:`state/last_batch_result.csv`
|
||||
- 批量结果:`report.xlsx`(程序运行目录)
|
||||
- 运行日志:`state/run.log`
|
||||
- 报告字段包含:`uid`、`linuxuidnumber`、`基础组gid`、`项目组gid`、`资源组gid`
|
||||
- 组 gid 读取优先级:先查 AD 组对象的 `gidNumber`,然后回退 `groups_gid_map`
|
||||
- 批量执行后会把本次从 AD 发现的组 gid 增量回写到 `config.yaml` 的 `groups_gid_map`
|
||||
- 批量状态:
|
||||
- `CREATED`:新建用户成功
|
||||
- `UPDATED`:已存在用户,属性或组关系发生更新
|
||||
|
|
|
|||
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.
|
|
@ -33,4 +33,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|||
init_parser = subparsers.add_parser("init-state", 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
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from pathlib import Path
|
|||
from typing import Any, Dict, Optional
|
||||
|
||||
import yaml
|
||||
from filelock import FileLock
|
||||
|
||||
from ad_user_creator.exceptions import ConfigError
|
||||
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 make_abs(path_text: str) -> str:
|
||||
path = Path(path_text)
|
||||
|
|
@ -144,8 +169,44 @@ def load_config(
|
|||
defaults=_merge_defaults_config(yaml_data),
|
||||
paths=_merge_paths_config(yaml_data),
|
||||
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)
|
||||
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
|
||||
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -76,6 +76,25 @@ class LdapClient:
|
|||
raise LdapOperationError(f"组不存在: {group_name}")
|
||||
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:
|
||||
self.ensure_connected()
|
||||
assert self.conn is not None
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
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.input_parser import parse_input_file
|
||||
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
|
||||
|
||||
|
||||
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.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)
|
||||
headers = [
|
||||
"姓名",
|
||||
"用户名",
|
||||
"邮箱",
|
||||
"部门 OU",
|
||||
"基础组",
|
||||
"项目组",
|
||||
"资源组",
|
||||
"状态",
|
||||
"原因",
|
||||
"用户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:
|
||||
|
|
@ -44,7 +65,11 @@ def _result_to_row(raw: Dict[str, str], result: UserProcessResult) -> Dict[str,
|
|||
"状态": result.status,
|
||||
"原因": result.reason,
|
||||
"用户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,
|
||||
input_path: Optional[str] = None,
|
||||
continue_on_error: bool = True,
|
||||
host: Optional[str] = None,
|
||||
port: Optional[int] = None,
|
||||
) -> int:
|
||||
try:
|
||||
config = load_config(config_path=config_path, cli_dry_run=dry_run)
|
||||
|
|
@ -78,6 +105,15 @@ def execute_command(
|
|||
print("状态文件初始化完成。")
|
||||
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()
|
||||
ldap_client = LdapClient(config.ldap)
|
||||
if not config.behavior.dry_run:
|
||||
|
|
@ -123,7 +159,7 @@ def execute_command(
|
|||
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)
|
||||
result = UserProcessResult(status="FAILED", reason=str(exc), raw=raw, uid=record.sam_account_name)
|
||||
|
||||
if result.status == "CREATED":
|
||||
created += 1
|
||||
|
|
@ -140,11 +176,16 @@ def execute_command(
|
|||
if result.status == "FAILED" and not continue_on_error:
|
||||
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)
|
||||
print(
|
||||
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
|
||||
finally:
|
||||
|
|
@ -159,12 +200,16 @@ def run() -> int:
|
|||
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"))
|
||||
host = getattr(args, "host", None)
|
||||
port = getattr(args, "port", None)
|
||||
return execute_command(
|
||||
command=args.command,
|
||||
config_path=args.config,
|
||||
dry_run=dry_run,
|
||||
input_path=input_path,
|
||||
continue_on_error=continue_on_error,
|
||||
host=host,
|
||||
port=port,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -69,12 +69,22 @@ class BehaviorConfig:
|
|||
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
|
||||
class AppConfig:
|
||||
ldap: LdapConfig
|
||||
defaults: DefaultsConfig = field(default_factory=DefaultsConfig)
|
||||
paths: PathsConfig = field(default_factory=PathsConfig)
|
||||
behavior: BehaviorConfig = field(default_factory=BehaviorConfig)
|
||||
groups_gid_map: Dict[str, int] = field(default_factory=dict)
|
||||
ui_options: UIOptionsConfig = field(default_factory=UIOptionsConfig)
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -83,4 +93,9 @@ class UserProcessResult:
|
|||
reason: str = ""
|
||||
user_dn: str = ""
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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">​</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>
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
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.ldap_client import LdapClient
|
||||
|
|
@ -26,15 +26,34 @@ class UserService:
|
|||
self.config = config
|
||||
self.state_store = state_store
|
||||
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:
|
||||
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()
|
||||
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)
|
||||
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] = []
|
||||
if not dry_run:
|
||||
|
|
@ -44,12 +63,20 @@ class UserService:
|
|||
return UserProcessResult(
|
||||
status="FAILED",
|
||||
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),
|
||||
)
|
||||
if missing_optional and not self.config.behavior.skip_missing_optional_groups:
|
||||
return UserProcessResult(
|
||||
status="FAILED",
|
||||
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),
|
||||
)
|
||||
|
||||
|
|
@ -61,6 +88,9 @@ class UserService:
|
|||
existing_user_dn=existing_user_dn,
|
||||
gid_number=gid_number,
|
||||
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()
|
||||
|
|
@ -75,6 +105,11 @@ class UserService:
|
|||
reason=reason,
|
||||
user_dn=plan.user_dn,
|
||||
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),
|
||||
)
|
||||
|
||||
|
|
@ -86,6 +121,11 @@ class UserService:
|
|||
status="FAILED",
|
||||
reason=f"create-user-failed: {exc}",
|
||||
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),
|
||||
)
|
||||
|
||||
|
|
@ -96,6 +136,11 @@ class UserService:
|
|||
status="FAILED",
|
||||
reason=f"password-set-failed: {exc}",
|
||||
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),
|
||||
)
|
||||
|
||||
|
|
@ -106,16 +151,31 @@ class UserService:
|
|||
status="FAILED",
|
||||
reason=f"enable-user-failed: {exc}",
|
||||
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),
|
||||
)
|
||||
|
||||
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:
|
||||
return UserProcessResult(
|
||||
status="FAILED",
|
||||
reason=f"add-group-failed: {exc}",
|
||||
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),
|
||||
)
|
||||
|
||||
|
|
@ -129,13 +189,31 @@ class UserService:
|
|||
reason=reason,
|
||||
user_dn=plan.user_dn,
|
||||
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),
|
||||
)
|
||||
|
||||
def _resolve_base_gid(self, base_group: str) -> int:
|
||||
if base_group not in self.group_gid_map:
|
||||
def _resolve_group_gid(self, group_name: str, allow_ad_gid_lookup: bool) -> Tuple[Optional[int], str]:
|
||||
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}")
|
||||
return int(self.group_gid_map[base_group])
|
||||
return gid
|
||||
|
||||
def _resolve_plan(
|
||||
self,
|
||||
|
|
@ -197,6 +275,13 @@ class UserService:
|
|||
desired["userPrincipalName"] = f"{record.sam_account_name}@{self.config.ldap.upn_suffix}"
|
||||
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]:
|
||||
changes: Dict[str, str] = {}
|
||||
for key, desired_value in desired.items():
|
||||
|
|
@ -234,8 +319,11 @@ class UserService:
|
|||
existing_user_dn: str,
|
||||
gid_number: int,
|
||||
optional_missing_groups: List[str],
|
||||
uid_value: str,
|
||||
project_gid_map: Dict[str, str],
|
||||
resource_gid_map: Dict[str, str],
|
||||
) -> UserProcessResult:
|
||||
attrs_to_read = ["displayName", "mail", "uid", "unixHomeDirectory", "gidNumber"]
|
||||
attrs_to_read = ["displayName", "mail", "uid", "uidNumber", "unixHomeDirectory", "gidNumber"]
|
||||
if self.config.ldap.upn_suffix:
|
||||
attrs_to_read.append("userPrincipalName")
|
||||
|
||||
|
|
@ -258,8 +346,17 @@ class UserService:
|
|||
status="FAILED",
|
||||
reason=f"update-user-failed: {exc}",
|
||||
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),
|
||||
)
|
||||
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:
|
||||
reason = f"已更新字段: {','.join(changes.keys())}"
|
||||
|
|
@ -267,12 +364,42 @@ class UserService:
|
|||
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))
|
||||
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 = "用户已存在且字段无变化"
|
||||
if 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:
|
||||
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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.
Binary file not shown.
Binary file not shown.
|
|
@ -7284,6 +7284,7 @@ imports:
|
|||
• <a href="#ad_user_creator">ad_user_creator</a>
|
||||
• <a href="#ad_user_creator.exceptions">ad_user_creator.exceptions</a>
|
||||
• <a href="#ad_user_creator.models">ad_user_creator.models</a>
|
||||
• <a href="#filelock">filelock</a>
|
||||
• <a href="#os">os</a>
|
||||
• <a href="#pathlib">pathlib</a>
|
||||
• <a href="#typing">typing</a>
|
||||
|
|
@ -7422,7 +7423,7 @@ imports:
|
|||
• <a href="#ad_user_creator.models">ad_user_creator.models</a>
|
||||
• <a href="#ad_user_creator.persistence">ad_user_creator.persistence</a>
|
||||
• <a href="#ad_user_creator.user_service">ad_user_creator.user_service</a>
|
||||
• <a href="#csv">csv</a>
|
||||
• <a href="#pandas">pandas</a>
|
||||
• <a href="#pathlib">pathlib</a>
|
||||
• <a href="#sys">sys</a>
|
||||
• <a href="#typing">typing</a>
|
||||
|
|
@ -12457,8 +12458,7 @@ imports:
|
|||
</div>
|
||||
<div class="import">
|
||||
imported by:
|
||||
<a href="#ad_user_creator.main">ad_user_creator.main</a>
|
||||
• <a href="#importlib.metadata">importlib.metadata</a>
|
||||
<a href="#importlib.metadata">importlib.metadata</a>
|
||||
• <a href="#pandas._testing.contexts">pandas._testing.contexts</a>
|
||||
• <a href="#pandas.core.arrays.categorical">pandas.core.arrays.categorical</a>
|
||||
• <a href="#pandas.io.formats.csvs">pandas.io.formats.csvs</a>
|
||||
|
|
@ -17189,7 +17189,8 @@ imports:
|
|||
</div>
|
||||
<div class="import">
|
||||
imported by:
|
||||
<a href="#ad_user_creator.persistence">ad_user_creator.persistence</a>
|
||||
<a href="#ad_user_creator.config">ad_user_creator.config</a>
|
||||
• <a href="#ad_user_creator.persistence">ad_user_creator.persistence</a>
|
||||
• <a href="#filelock._api">filelock._api</a>
|
||||
• <a href="#filelock._error">filelock._error</a>
|
||||
• <a href="#filelock._soft">filelock._soft</a>
|
||||
|
|
@ -38487,6 +38488,7 @@ imports:
|
|||
<div class="import">
|
||||
imported by:
|
||||
<a href="#ad_user_creator.input_parser">ad_user_creator.input_parser</a>
|
||||
• <a href="#ad_user_creator.main">ad_user_creator.main</a>
|
||||
• <a href="#entry.py">entry.py</a>
|
||||
• <a href="#openpyxl.utils.dataframe">openpyxl.utils.dataframe</a>
|
||||
• <a href="#pandas">pandas</a>
|
||||
|
|
|
|||
|
|
@ -1,34 +1,54 @@
|
|||
ldap:
|
||||
host: "10.10.22.21"
|
||||
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"
|
||||
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"
|
||||
|
||||
- top
|
||||
- person
|
||||
- organizationalPerson
|
||||
- user
|
||||
- posixAccount
|
||||
user_rdn_attr: CN
|
||||
defaults:
|
||||
base_group: "staff"
|
||||
base_group: staff
|
||||
initial_uid_number: 2106
|
||||
initial_password: "1234.com"
|
||||
|
||||
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"
|
||||
|
||||
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
|
||||
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: []
|
||||
|
|
|
|||
|
|
@ -1,34 +1,33 @@
|
|||
ldap:
|
||||
host: "ad.example.com"
|
||||
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"
|
||||
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"
|
||||
|
||||
- top
|
||||
- person
|
||||
- organizationalPerson
|
||||
- user
|
||||
- posixAccount
|
||||
user_rdn_attr: CN
|
||||
defaults:
|
||||
base_group: "staff"
|
||||
base_group: staff
|
||||
initial_uid_number: 2106
|
||||
initial_password: "1234.com"
|
||||
|
||||
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"
|
||||
|
||||
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
|
||||
groups_gid_map:
|
||||
staff: 3000
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
|
|
@ -6,3 +6,5 @@ openpyxl
|
|||
pydantic
|
||||
filelock
|
||||
rich
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
|
|
|
|||
121
state/run.log
121
state/run.log
|
|
@ -180,3 +180,124 @@
|
|||
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] 配置加载完成
|
||||
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] 配置加载完成
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
{
|
||||
"next_uid_number": 2124,
|
||||
"updated_at": "2026-02-23T09:48:48+00:00"
|
||||
"next_uid_number": 2132,
|
||||
"updated_at": "2026-03-17T02:44:35+00:00"
|
||||
}
|
||||
Loading…
Reference in New Issue