feat: syncer/exec 物化(GraphQL 落库)+ 两阶段编排 main/server

- plan.go:新 diff 引擎(纯函数);输入=镜像期望状态 BuildFromMirror + LLDAP 实际
  LDAP 视图;操作顺序 分组→用户→加组→移组→删除,删除仅 confirmed_leave 且最后阶段;
  所有权只认镜像台账,服务账号永不碰;孤儿组只告警不自动删
- exec.go:写走 lldapgql;deleteUser 级联清成员;groupId 用 dept_id→groupId 映射
  (物化开始时经 GraphQL Groups 拉取),不凭名字反推
- runner.go:两阶段编排,快照与物化共享单实例互斥锁;物化开始登录一次、
  全成功才回写 attrs_hash/groupId/MarkDeleted
- main.go:-snapshot/-apply/-dry-run 命令行 + 两套定时任务(快照低频/物化略高频)+ check-config
- server.go:镜像视图用户/部门、待确认列表、快照触发、一次性离职确认端点
  (不经 Auth,token 即凭证)、skipped 展示
- 安全结构拦截均已配变异验证用例(plan_test):未确认不删、非受管不删、
  admin 永不移出、删除最后阶段
This commit is contained in:
2026-09-29 23:48:55 +08:00
parent 979a98d502
commit 126e52a6ed
6 changed files with 835 additions and 1191 deletions
+188 -312
View File
@@ -1,10 +1,13 @@
// Package syncer 计算期望状态与 LDAP 实际状态之间的差异,并负责执行。
// Package syncer 计算镜像期望状态与 LLDAP 实际状态之间的差异,并负责执行物化。
//
// 设计要点:
// - ComputePlan 是纯函数:输入快照与台账,输出操作列表,不触碰任何 IO。
// 因此它能被完整单测,且 dry-run 与真正执行共享同一份代码——预览即所得。
// - 所有权来自台账:只有台账记录在案的条目才会被修改或停用。台账之外的
// 条目(服务账号、手工账号、admin)永远不会出现在计划里,因此不可能被误伤。
// 两阶段架构的第二段:读 SQLite 镜像的 active 视图 + 读 LLDAP 实际状态,算出
// 差异计划,**写走 GraphQL(lldapgql)**。ComputePlan 是纯函数,dry-run 与真正
// 执行共用同一份代码——预览即所得。
//
// 所有权来自镜像台账:只有镜像里登记在案的条目才会被修改或删除;镜像之外的
// 条目(服务账号、手工账号、admin)永不入计划。离职用户只有在 confirmed_leave
// 状态才会生成删除操作,且删除永远排在最后一阶段。
package syncer
import (
@@ -24,10 +27,8 @@ type OpKind string
const (
OpCreateUser OpKind = "create_user"
OpUpdateUser OpKind = "update_user"
OpDeactivate OpKind = "deactivate_user"
OpReactivate OpKind = "reactivate_user"
OpDeleteUser OpKind = "delete_user"
OpCreateGroup OpKind = "create_group"
OpUpdateGroup OpKind = "update_group"
OpAddMember OpKind = "add_member"
OpRemoveMember OpKind = "remove_member"
)
@@ -37,99 +38,81 @@ type Op struct {
Kind OpKind `json:"kind"`
// Target 是操作对象的稳定标识:用户为 uid,分组为 dept_id。
Target string `json:"target"`
// Name 是展示名,方便界面阅读。
// Name 是展示名(用户中文名 / 部门中文名)。
Name string `json:"name"`
// DN 是操作涉及的条目 DN(新建时为目标 DN)。
// DN 是操作涉及的组/用户 DN。
DN string `json:"dn,omitempty"`
// Detail 是人类可读的说明,直接展示在预览页。
Detail string `json:"detail"`
// 以下字段供执行器使用,不参与 JSON 展示。
User *mapping.User `json:"-"`
Group *mapping.Group `json:"-"`
MemberDN string `json:"-"`
GroupDN string `json:"-"`
// TargetDN 是操作对象的现有 DN(用于移动/删除已存在的条目)。
TargetDN string `json:"-"`
AttrsHash string `json:"-"`
// Seq 记录该操作的生成顺序,用于在同一阶段内保持依赖顺序
// (例如父分组必须先于子分组创建)。
User *mapping.DesiredUser `json:"-"`
Group *mapping.DesiredGroup `json:"-"`
MemberUID string `json:"-"`
AttrsHash string `json:"-"`
// Seq 记录生成顺序,用于在同一阶段内保持稳定输出。
Seq int `json:"-"`
}
// Plan 是一次同步的完整计划。
// Plan 是一次物化的完整计划。
type Plan struct {
Ops []Op `json:"ops"`
// Warnings 是不阻塞执行、但需要人工留意的信息。
Warnings []string `json:"warnings"`
Stats Stats `json:"stats"`
Ops []Op `json:"ops"`
Warnings []string `json:"warnings"`
Stats Stats `json:"stats"`
}
// Stats 是计划的分类统计。
type Stats struct {
CreateUser int `json:"createUser"`
UpdateUser int `json:"updateUser"`
Deactivate int `json:"deactivateUser"`
Reactivate int `json:"reactivateUser"`
CreateGroup int `json:"createGroup"`
UpdateGroup int `json:"updateGroup"`
AddMember int `json:"addMember"`
RemoveMember int `json:"removeMember"`
TotalAccounts int `json:"totalAccounts"`
Unchanged int `json:"unchanged"`
CreateUser int `json:"createUser"`
UpdateUser int `json:"updateUser"`
DeleteUser int `json:"deleteUser"`
CreateGroup int `json:"createGroup"`
AddMember int `json:"addMember"`
RemoveMember int `json:"removeMember"`
TotalUsers int `json:"totalUsers"`
Unchanged int `json:"unchanged"`
}
// Input 是计算计划所需的全部输入。
// Input 是计算物化计划所需的全部输入。
type Input struct {
// Desired 是期望状态(来自飞书)
Desired *mapping.State
// Active 是 ou=people 下现有用户:uid(小写)→ 条目
Active map[string]*ldaplib.Entry
// Inactive 是 ou=Inactive 下现有用户:uid(小写)→ 条目
Inactive map[string]*ldaplib.Entry
// Groups 是现有分组:DN(小写)→ 条目
Groups map[string]*ldaplib.Entry
// LedgerAccounts 是台账中的账号:uid → state(active/inactive)
LedgerAccounts map[string]string
// LedgerGroups 是台账中的分组:dept_id → dn
LedgerGroups map[string]string
// AdminDN 与相关基础 DN
AdminDN string
GroupRootDN string
UserOUDN string
InactiveOUDN string
// Desired 是期望状态(来自镜像 BuildFromMirror)。
Desired *mapping.DesiredState
// ActualUsers 是 ou=people 下现有用户:uid(小写)→ 条目。
ActualUsers map[string]*ldaplib.Entry
// ActualGroups 是 ou=groups 下现有组:DN(小写)→ 条目。
ActualGroups map[string]*ldaplib.Entry
// OwnedUsers 是镜像台账中的受管用户(任何生命周期状态)。
OwnedUsers map[string]struct{}
// ConfirmedLeave 是已人工确认离职、待删除的用户集。
ConfirmedLeave map[string]bool
// AdminDN 与基础 OU DN。
AdminDN string
PeopleOU string
GroupRootDN string
}
// opPhase 决定操作的执行顺序。
// opPhase 决定操作的执行顺序:分组 → 用户 → 加组 → 移组 → 删除。
//
// 顺序是硬约束而非偏好:分组必须先于成员关系存在,用户必须先于其成员关系
// 存在,而停用必须最后执行(否则会把刚加进组的用户又移出去)。
// 早期版本按操作名字母序排序,导致 add_member 排在 create_group 之前,
// 集成测试立刻暴露了这个问题。
// 顺序是硬约束而非偏好:组必须先于成员关系存在,用户必须先于其成员关系存在,
// 而**删除永远是最后一阶段**——且只处理 confirmed_leave(在 plan 里已过滤)。
func opPhase(k OpKind) int {
switch k {
case OpCreateGroup, OpUpdateGroup:
case OpCreateGroup:
return 0
case OpCreateUser, OpUpdateUser:
return 1
case OpReactivate:
return 2
case OpAddMember:
return 3
return 2
case OpRemoveMember:
return 3
case OpDeleteUser:
return 4
case OpDeactivate:
return 5
default:
return 6
return 5
}
}
// sortOps 按依赖顺序排列操作。
//
// 阶段决定大类顺序;同阶段内按 Seq(生成顺序)而非字母序,这很关键:
// 分组必须父先于子创建,而字母序无法保证这一点(例如 “od-yanfa” 会排在
// “od-root” 之前,导致子分组先于父分组创建而失败)。
// sortOps 按依赖顺序排列操作;同阶段内按 Seq(生成顺序)稳定排序。
func sortOps(ops []Op) {
sort.SliceStable(ops, func(i, j int) bool {
pi, pj := opPhase(ops[i].Kind), opPhase(ops[j].Kind)
@@ -140,288 +123,171 @@ func sortOps(ops []Op) {
})
}
// ComputePlan 计算同步计划。它是纯函数,不产生任何副作用。
// ComputePlan 计算物化计划。它是纯函数,不产生任何副作用。
func ComputePlan(in *Input) *Plan {
p := &Plan{
Ops: make([]Op, 0, 64),
Warnings: append([]string{}, in.Desired.Warnings...),
Stats: Stats{
TotalAccounts: len(in.LedgerAccounts),
},
Warnings: make([]string, 0),
Stats: Stats{TotalUsers: len(in.ActualUsers)},
}
planGroups(p, in)
planUsers(p, in)
planMembership(p, in)
// 打上生成序号后排序:阶段决定大类顺序,序号保证同阶段内的依赖顺序
// (mapping 已按父先于子的顺序产出分组)。
for i := range p.Ops {
p.Ops[i].Seq = i
}
sortOps(p.Ops)
return p
}
// planGroups 处理分组的创建与更新。
// planGroups 处理组的创建;不在期望集合里的组只告警不自动删(怕扯动 ACL/成员引用)。
func planGroups(p *Plan, in *Input) {
desired := make(map[string]*mapping.DesiredGroup, len(in.Desired.Groups))
for _, g := range in.Desired.Groups {
existing, exists := in.Groups[strings.ToLower(g.DN)]
desired[strings.ToLower(g.DN)] = g
}
if !exists {
p.Ops = append(p.Ops, Op{
Kind: OpCreateGroup,
Target: g.DeptID,
Name: g.Name,
DN: g.DN,
Detail: fmt.Sprintf("创建部门分组 %s (%s)", g.Name, g.DN),
Group: g,
})
p.Stats.CreateGroup++
for _, g := range in.Desired.Groups {
if _, ok := in.ActualGroups[strings.ToLower(g.DN)]; ok {
continue
}
// 分组已存在:仅当描述不一致时更新。
// 说明:分组 DN 由部门名推导,部门改名会导致 DN 变化,此时会在下一轮
// 以“新 DN 不存在”的形式创建新组,旧组成为孤儿——这需要人工处理,
// 因此这里显式告警而不是自动改名(自动改名会牵动成员与 ACL)。
if desc := existing.Get(ldaplib.AttrDescription); desc != g.Name {
p.Ops = append(p.Ops, Op{
Kind: OpUpdateGroup,
Target: g.DeptID,
Name: g.Name,
DN: g.DN,
Detail: fmt.Sprintf("更新部门分组描述 %s: %q → %q", g.Name, desc, g.Name),
Group: g,
})
p.Stats.UpdateGroup++
}
p.Ops = append(p.Ops, Op{
Kind: OpCreateGroup, Target: g.DeptID, Name: g.Name, DN: g.DN, Group: g,
Detail: fmt.Sprintf("创建部门组 %s (%s)", g.Name, g.DN),
})
p.Stats.CreateGroup++
}
// 台账中存在、但期望状态里已消失的分组:不自动删除。
// 部门被删除往往意味着组织调整,删除组会连带丢失成员关系,值得人工确认。
wantedDept := make(map[string]string, len(in.Desired.Groups))
for _, g := range in.Desired.Groups {
wantedDept[g.DeptID] = g.DN
}
for deptID, dn := range in.LedgerGroups {
wantDN, stillWanted := wantedDept[deptID]
switch {
case !stillWanted:
p.Warnings = append(p.Warnings,
fmt.Sprintf("分组 %s (%s) 已从飞书消失,未自动删除,请人工确认", deptID, dn))
case !strings.EqualFold(wantDN, dn):
// 部门改名会改变其 CN 进而改变 DN。自动改名会牵动成员的
// uniqueMember 引用与依赖该 DN 的 ACL,因此交由人工处理。
p.Warnings = append(p.Warnings,
fmt.Sprintf("分组 %s 的 DN 已变化:%s → %s,需要人工改名(自动改名会影响 ACL 与成员引用)",
deptID, dn, wantDN))
// 受管组在期望里消失(部门被删/被移出范围)——只告警,不自动删除。
for _, e := range in.ActualGroups {
if _, ok := desired[strings.ToLower(e.DN)]; ok {
continue
}
p.Warnings = append(p.Warnings,
fmt.Sprintf("分组 %s 不在期望部门集合中,未自动删除,请人工确认", e.DN))
}
}
// planUsers 处理用户的创建、更新、停用与恢复。
// planUsers 处理用户的创建、更新与删除。
func planUsers(p *Plan, in *Input) {
desiredByUID := make(map[string]*mapping.User, len(in.Desired.Users))
desiredByUID := make(map[string]*mapping.DesiredUser, len(in.Desired.Users))
for _, u := range in.Desired.Users {
desiredByUID[u.UID] = u
}
for _, u := range in.Desired.Users {
_, inActiveOU := in.Active[u.UID]
_, inInactiveOU := in.Inactive[u.UID]
switch {
case inActiveOU:
// 已存在且在职:比对属性是否漂移。
existing := in.Active[u.UID]
canonical := strings.ToLower(existing.DN)
// DN 不匹配(例如所属 OU 配置变更导致 DN 变化)需要移动条目。
// 用大小写无关比较:LDAP 服务端可能对 DN 做规范化,
// 直接字符串比较会造成每轮都产生无意义的“更新”。
if !strings.EqualFold(existing.DN, u.DN) {
p.Ops = append(p.Ops, Op{
Kind: OpUpdateUser,
Target: u.UID,
Name: u.DisplayName,
DN: u.DN,
Detail: fmt.Sprintf("用户 %s 的 DN 需要校正:%s → %s", u.UID, existing.DN, u.DN),
User: u,
TargetDN: canonical,
})
p.Stats.UpdateUser++
continue
}
if attrsDiffer(existing, u) {
p.Ops = append(p.Ops, Op{
Kind: OpUpdateUser,
Target: u.UID,
Name: u.DisplayName,
DN: u.DN,
Detail: "用户属性发生变化:" + describeAttrDiff(existing, u),
User: u,
TargetDN: canonical,
})
p.Stats.UpdateUser++
continue
}
p.Stats.Unchanged++
case inInactiveOU:
// 存在于 ou=Inactive 但期望是在职:恢复。
// 仅当台账确认该账号由我们管理时才动它。
if _, managed := in.LedgerAccounts[u.UID]; !managed {
p.Warnings = append(p.Warnings,
fmt.Sprintf("uid=%s 存在于 %s 但不在台账中,不予恢复", u.UID, in.InactiveOUDN))
continue
}
existing, ok := in.ActualUsers[strings.ToLower(u.UID)]
if !ok {
p.Ops = append(p.Ops, Op{
Kind: OpReactivate,
Target: u.UID,
Name: u.DisplayName,
DN: u.DN,
Detail: fmt.Sprintf("用户 %s 重新入职,移回 %s", u.UID, in.UserOUDN),
User: u,
TargetDN: strings.ToLower(in.Inactive[u.UID].DN),
})
p.Stats.Reactivate++
default:
// 两处都没有:新建。
p.Ops = append(p.Ops, Op{
Kind: OpCreateUser,
Target: u.UID,
Name: u.DisplayName,
DN: u.DN,
Detail: fmt.Sprintf("创建用户 %s (%s) 于 %s", u.UID, u.DisplayName, in.UserOUDN),
User: u,
Kind: OpCreateUser, Target: u.UID, Name: u.DisplayName, DN: u.DN,
User: u, AttrsHash: AttrsHash(u),
Detail: fmt.Sprintf("创建用户 %s (%s)", u.UID, u.DisplayName),
})
p.Stats.CreateUser++
continue
}
if attrsDiffer(existing, u) {
p.Ops = append(p.Ops, Op{
Kind: OpUpdateUser, Target: u.UID, Name: u.DisplayName, DN: u.DN,
User: u, AttrsHash: AttrsHash(u),
Detail: "用户属性变化:" + describeAttrDiff(existing, u),
})
p.Stats.UpdateUser++
continue
}
p.Stats.Unchanged++
}
// 台账中在职、但期望状态里已消失:候选停用。
// 注意这里只标记为候选,真正的离职确认由调用方通过飞书接口完成。
for uid, state := range in.LedgerAccounts {
if state != "active" {
// 删除:在 LLDAP、在台账,但不在期望在职集合,且已 confirmed_leave。
// 未确认的一律不删(安全侧默认对);镜像之外的账号永远不碰。
for uid := range in.ActualUsers {
if _, want := desiredByUID[uid]; want {
continue
}
if _, ok := desiredByUID[uid]; ok {
if _, owned := in.OwnedUsers[uid]; !owned {
continue
}
entry, ok := in.Active[uid]
if !ok {
// 台账说在职但 LDAP 里找不到:数据不一致,仅告警。
if !in.ConfirmedLeave[uid] {
p.Warnings = append(p.Warnings,
fmt.Sprintf("台账记录 %s 为在职,但 %s 中不存在该条目", uid, in.UserOUDN))
fmt.Sprintf("用户 %s 在 LLDAP 但不在期望在职集合,且未完成离职确认,本轮不删除", uid))
continue
}
p.Ops = append(p.Ops, Op{
Kind: OpDeactivate,
Target: uid,
Name: entry.Get(ldaplib.AttrDisplayName),
DN: ldaplib.UserDN(uid, in.InactiveOUDN),
Detail: fmt.Sprintf("用户 %s 已离职,移入 %s", uid, in.InactiveOUDN),
TargetDN: strings.ToLower(entry.DN),
Kind: OpDeleteUser, Target: uid,
Name: in.ActualUsers[uid].Get(ldaplib.AttrDisplayName),
DN: ldaplib.UserDN(uid, in.PeopleOU),
Detail: fmt.Sprintf("删除已确认离职用户 %s", uid),
})
p.Stats.Deactivate++
p.Stats.DeleteUser++
}
}
// planMembership 比对分组内的 uniqueMember,产出加组/移组操作。
// planMembership 比对组的成员,产出加组/移组操作。
func planMembership(p *Plan, in *Input) {
for _, g := range in.Desired.Groups {
existing, ok := in.Groups[strings.ToLower(g.DN)]
if !ok {
// 分组是新建的,成员关系会在创建后由执行器处理;
// 这里仍生成加组操作,因为新建组的初始成员只有 admin(或为空)。
for memberDN := range g.Members {
p.Ops = append(p.Ops, Op{
Kind: OpAddMember,
Target: g.DeptID,
Name: g.Name,
Detail: fmt.Sprintf("将 %s 加入 %s", memberRDN(memberDN), g.Name),
Group: g,
GroupDN: g.DN,
MemberDN: memberDN,
})
p.Stats.AddMember++
}
continue
e, ok := in.ActualGroups[strings.ToLower(g.DN)]
actual := make(map[string]struct{})
if ok {
actual = e.Members()
}
desired := lowerMembers(g.Members)
actual := existing.Members()
// 期望有、实际没有 → 加入
for memberDN := range g.Members {
if _, has := actual[strings.ToLower(memberDN)]; has {
continue
}
uid, ok := uidFromDN(memberDN)
if !ok {
continue
}
p.Ops = append(p.Ops, Op{
Kind: OpAddMember,
Target: g.DeptID,
Name: g.Name,
Detail: fmt.Sprintf("将 %s 加入 %s", memberRDN(memberDN), g.Name),
Group: g,
GroupDN: existing.DN,
MemberDN: memberDN,
Kind: OpAddMember, Target: g.DeptID, Name: g.Name, Group: g, MemberUID: uid,
Detail: fmt.Sprintf("将 %s 加入 %s", uid, g.Name),
})
p.Stats.AddMember++
}
// 实际有、期望没有 → 移除。但永不移除 admin,避免把管理员踢出组。
if !ok {
continue
}
// 移出:实际有、期望没有,且只移受管、永不移 admin。
for memberDN := range actual {
if _, want := g.Members[memberDN]; want {
if _, want := desired[memberDN]; want {
continue
}
if in.AdminDN != "" && strings.EqualFold(memberDN, in.AdminDN) {
continue
}
// 只移除我们管理的用户,避免把外部手工加入的成员踢掉。
if !isManagedMember(memberDN, in) {
uid, ok := uidFromDN(memberDN)
if !ok {
continue
}
if _, managed := in.OwnedUsers[uid]; !managed {
p.Warnings = append(p.Warnings,
fmt.Sprintf("%s 中的成员 %s 非本工具管理,未移除", g.Name, memberRDN(memberDN)))
fmt.Sprintf("%s 中的成员 %s 非本工具受管,未移除", g.Name, uid))
continue
}
p.Ops = append(p.Ops, Op{
Kind: OpRemoveMember,
Target: g.DeptID,
Name: g.Name,
Detail: fmt.Sprintf("将 %s 移出 %s", memberRDN(memberDN), g.Name),
Group: g,
GroupDN: existing.DN,
MemberDN: memberDN,
Kind: OpRemoveMember, Target: g.DeptID, Name: g.Name, Group: g, MemberUID: uid,
Detail: fmt.Sprintf("将 %s 移出 %s", uid, g.Name),
})
p.Stats.RemoveMember++
}
}
}
// isManagedMember 判断某个成员 DN 是否属于本工具管理的范围。
//
// 判断依据是“该 DN 对应的 uid 在台账中”。台账是唯一的所有权来源,
// 因此手工创建的账号即使被手工加入部门组,也不会被自动移除。
func isManagedMember(memberDN string, in *Input) bool {
uid, ok := uidFromDN(memberDN)
if !ok {
return false
// lowerMembers 把成员 DN 集合转成小写形式,便于与实际(已小写)比对。
func lowerMembers(m map[string]struct{}) map[string]struct{} {
out := make(map[string]struct{}, len(m))
for dn := range m {
out[strings.ToLower(dn)] = struct{}{}
}
if _, managed := in.LedgerAccounts[uid]; managed {
return true
}
// 本轮新建的用户此时尚未进入台账,也应当被视为受管。
for _, u := range in.Desired.Users {
if u.UID == uid {
return true
}
}
return false
return out
}
// uidFromDN 从形如 uid=xxx,ou=... 的 DN 中取出 uid。
// uidFromDN 从形如 uid=xxx,ou=... 的 DN 中取出小写 uid。
func uidFromDN(dn string) (string, bool) {
first := dn
if i := strings.Index(dn, ","); i >= 0 {
@@ -434,33 +300,19 @@ func uidFromDN(dn string) (string, bool) {
return strings.ToLower(strings.TrimSpace(first[4:])), true
}
// memberRDN 从成员 DN 中提取用于展示的标识。
func memberRDN(dn string) string {
if uid, ok := uidFromDN(dn); ok {
return uid
}
return dn
}
// attrsDiffer 判断 LDAP 现有属性与期望是否不一致。
func attrsDiffer(existing *ldaplib.Entry, want *mapping.User) bool {
// attrsDiffer 判断 LLDAP 现有用户与期望在硬字段上是否不一致。
//
// LLDAP 硬字段为 id(uid)、email(mail)、displayName、givenName(firstName)、sn(lastName);
// 默认不建自定义属性,因此只比对这四类可写硬字段。
func attrsDiffer(existing *ldaplib.Entry, want *mapping.DesiredUser) bool {
checks := []struct {
attr string
want string
}{
{ldaplib.AttrCN, want.CN},
{ldaplib.AttrSN, want.SN},
{ldaplib.AttrGivenName, want.GivenName},
{ldaplib.AttrDisplayName, want.DisplayName},
{ldaplib.AttrMail, want.Mail},
{ldaplib.AttrMobile, want.Mobile},
{ldaplib.AttrEmployeeNumber, want.EmployeeNumber},
{ldaplib.AttrDepartmentNumber, want.DepartmentNumber},
{ldaplib.AttrPostalAddress, want.PostalAddress},
{ldaplib.AttrBusinessCategory, want.BusinessCategory},
{ldaplib.AttrDescription, want.Description},
// employeeType 也会被写入,必须纳入比对,否则预览会漏报这项变更。
{ldaplib.AttrEmployeeType, want.EmployeeType},
{ldaplib.AttrDisplayName, want.DisplayName},
{ldaplib.AttrGivenName, want.GivenName},
{ldaplib.AttrSN, want.SN},
}
for _, c := range checks {
if normalizeAttr(existing.Get(c.attr)) != normalizeAttr(c.want) {
@@ -471,7 +323,7 @@ func attrsDiffer(existing *ldaplib.Entry, want *mapping.User) bool {
}
// describeAttrDiff 生成人类可读的属性差异摘要,用于预览页。
func describeAttrDiff(existing *ldaplib.Entry, want *mapping.User) string {
func describeAttrDiff(existing *ldaplib.Entry, want *mapping.DesiredUser) string {
fields := []struct {
label string
attr string
@@ -479,16 +331,9 @@ func describeAttrDiff(existing *ldaplib.Entry, want *mapping.User) string {
}{
{"mail", ldaplib.AttrMail, want.Mail},
{"displayName", ldaplib.AttrDisplayName, want.DisplayName},
{"mobile", ldaplib.AttrMobile, want.Mobile},
{"employeeNumber", ldaplib.AttrEmployeeNumber, want.EmployeeNumber},
{"departmentNumber", ldaplib.AttrDepartmentNumber, want.DepartmentNumber},
{"postalAddress", ldaplib.AttrPostalAddress, want.PostalAddress},
{"businessCategory", ldaplib.AttrBusinessCategory, want.BusinessCategory},
{"givenName", ldaplib.AttrGivenName, want.GivenName},
{"sn", ldaplib.AttrSN, want.SN},
{"employeeType", ldaplib.AttrEmployeeType, want.EmployeeType},
}
var diffs []string
for _, f := range fields {
old := existing.Get(f.attr)
@@ -498,27 +343,58 @@ func describeAttrDiff(existing *ldaplib.Entry, want *mapping.User) string {
diffs = append(diffs, fmt.Sprintf("%s: %q → %q", f.label, old, f.want))
}
if len(diffs) == 0 {
return "属性无变化"
return "无变化"
}
return strings.Join(diffs, "; ")
}
// normalizeAttr 归一化属性值用于比较:忽略首尾空白与大小写差异,
// 避免因为无关紧要的格式差异产生大量无意义的更新操作。
func normalizeAttr(s string) string {
return strings.ToLower(strings.TrimSpace(s))
}
func normalizeAttr(s string) string { return strings.ToLower(strings.TrimSpace(s)) }
// AttrsHash 计算用户属性的指纹,供台账记录“上次写入的内容”。
func AttrsHash(u *mapping.User) string {
// AttrsHash 计算用户硬字段指纹,供台账记录「上次写入 LLDAP 的内容」。
func AttrsHash(u *mapping.DesiredUser) string {
h := sha256.New()
for _, v := range []string{
u.CN, u.SN, u.GivenName, u.DisplayName, u.Mail, u.Mobile,
u.EmployeeNumber, u.DepartmentNumber, u.PostalAddress,
u.BusinessCategory, u.Description,
} {
for _, v := range []string{u.Mail, u.DisplayName, u.GivenName, u.SN} {
h.Write([]byte(v))
h.Write([]byte{0})
}
return hex.EncodeToString(h.Sum(nil))
}
// Guard 是执行前的安全阈值。
type Guard struct {
// MaxDeactivate 单次允许删除的最大账号数,0 表示不限制。
MaxDeactivate int
// MaxDeactivatePercent 单次允许删除的账号比例(0-1),0 表示不限制。
MaxDeactivatePercent float64
// AllowBulk 显式跳过全部阈值检查。
AllowBulk bool
}
// GuardError 表示计划未通过安全阈值检查。
type GuardError struct{ Message string }
func (e *GuardError) Error() string { return e.Message }
// Check 校验计划是否满足安全阈值。
func (g Guard) Check(p *Plan) error {
if g.AllowBulk {
return nil
}
deletes := p.Stats.DeleteUser
if deletes == 0 {
return nil
}
if g.MaxDeactivate > 0 && deletes > g.MaxDeactivate {
return &GuardError{Message: fmt.Sprintf(
"本次计划要删除 %d 个账号,超过上限 %d;如确认无误,请使用强制模式重跑", deletes, g.MaxDeactivate)}
}
if g.MaxDeactivatePercent > 0 && p.Stats.TotalUsers > 0 {
ratio := float64(deletes) / float64(p.Stats.TotalUsers)
if ratio > g.MaxDeactivatePercent {
return &GuardError{Message: fmt.Sprintf(
"本次计划要删除 %d/%d 个账号(%.1f%%),超过比例上限 %.1f%%;如确认无误,请使用强制模式重跑",
deletes, p.Stats.TotalUsers, ratio*100, g.MaxDeactivatePercent*100)}
}
}
return nil
}