package syncer // 两阶段编排器:快照(飞书→镜像)与物化(镜像→LLDAP)。 // // 这两段共享一把单实例互斥锁,保证快照与物化绝不同时执行(镜像事务化 + 物化 // 全成功才回写的前提),否则物化可能读到半新半旧的镜像污染 LDAP(约束 2)。 import ( "context" "fmt" "log/slog" "strings" "sync" "idsync/internal/config" "idsync/internal/feishu" "idsync/internal/ldaplib" "idsync/internal/lldapgql" "idsync/internal/mapping" "idsync/internal/store" ) // Runner 编排快照与物化。 type Runner struct { cfg *config.Config feishu *feishu.Client ldap *ldaplib.Client gql *lldapgql.Client store *store.Store snapshot *SnapshotRunner log *slog.Logger guard Guard // mu 让快照与物化在单实例内互斥。 mu sync.Mutex groupIDs map[string]int } // NewRunner 构造编排器。 func NewRunner(cfg *config.Config, f *feishu.Client, l *ldaplib.Client, g *lldapgql.Client, s *store.Store, log *slog.Logger, opts SnapshotOptions) *Runner { return &Runner{ cfg: cfg, feishu: f, ldap: l, gql: g, store: s, snapshot: NewSnapshotRunner(cfg, f, s, log, opts), log: log, guard: Guard{ MaxDeactivate: cfg.Guard.MaxDeactivate, MaxDeactivatePercent: cfg.Guard.MaxDeactivatePercent, }, } } // RunSnapshot 触发一轮快照(飞书 → 镜像 + 离职流程推进)。 func (r *Runner) RunSnapshot(ctx context.Context) (*SnapshotRunResult, error) { r.mu.Lock() defer r.mu.Unlock() return r.snapshot.RunSnapshot(ctx) } // Preview 只计算物化计划,不做任何写入。dry-run 与真正执行共用同一份计划代码。 func (r *Runner) Preview(ctx context.Context) (*Plan, error) { plan, _, err := r.plan(ctx) return plan, err } // Run 执行一轮物化(镜像 → LLDAP)。dryRun 为 true 时只计算并返回计划。 func (r *Runner) Run(ctx context.Context, dryRun bool, force bool, trigger string) (*Plan, *Result, error) { r.mu.Lock() defer r.mu.Unlock() run, err := r.store.StartRun(modeOf(dryRun), trigger) if err != nil { return nil, nil, fmt.Errorf("创建物化记录失败: %w", err) } plan, desired, err := r.prepare(ctx, run, force) if err != nil { return plan, nil, err } if dryRun { fillRunFromPlan(run, plan) run.Status = "success" if err := r.store.FinishRun(run); err != nil { r.log.Warn("写入物化记录失败", "err", err) } return plan, nil, nil } res := r.apply(ctx, plan) fillRunFromResult(run, plan, res) // 只有全部成功才回写镜像:部分失败时保持镜像不变,下轮重算差异。 if res.Failed == 0 { if err := r.persist(ctx, plan, desired); err != nil { run.Status = "failed" run.ErrorMsg = "回写镜像失败: " + err.Error() _ = r.store.FinishRun(run) return plan, res, fmt.Errorf("回写镜像失败: %w", err) } } else { r.log.Warn("存在失败的操作,跳过镜像回写", "failed", res.Failed) } run.Status = "success" if res.Failed > 0 { run.Status = "failed" run.ErrorMsg = strings.Join(res.Errors, "; ") } if err := r.store.FinishRun(run); err != nil { r.log.Warn("写入物化记录失败", "err", err) } return plan, res, nil } // prepare 计算计划并做安全检查。 func (r *Runner) prepare(ctx context.Context, run *store.SyncRun, force bool) (*Plan, *mapping.DesiredState, error) { plan, desired, err := r.plan(ctx) if err != nil { run.Status = "failed" run.ErrorMsg = err.Error() _ = r.store.FinishRun(run) return nil, nil, err } guard := r.guard if force { guard.AllowBulk = true } if err := guard.Check(plan); err != nil { run.Status = "failed" run.ErrorMsg = err.Error() _ = r.store.FinishRun(run) return plan, desired, err } return plan, desired, nil } // plan 读两侧状态并计算物化计划。 func (r *Runner) plan(ctx context.Context) (*Plan, *mapping.DesiredState, error) { // 期望侧:只读 SQLite 镜像的 active 视图,不碰飞书。 activeUsers, err := r.store.ActiveUsers() if err != nil { return nil, nil, err } depts, err := r.store.ActiveDepartments() if err != nil { return nil, nil, err } desired := mapping.BuildFromMirror(activeUsers, depts, r.cfg.Ldap.BaseDN) // 实际侧:读 LLDAP 的 LDAP 视图。 conn, err := r.ldap.Dial() if err != nil { return nil, nil, err } defer conn.Close() actualUsers, err := conn.ListUsers(r.cfg.UserOUDN()) if err != nil { return nil, nil, err } actualGroups, err := conn.ListGroups(r.cfg.GroupRootDN()) if err != nil { return nil, nil, err } // 所有权与删除白名单都来自镜像台账。 all, err := r.store.AllMirrorUsers() if err != nil { return nil, nil, err } owned := make(map[string]struct{}, len(all)) confirmed := make(map[string]bool) for _, u := range all { if u.Skipped { continue } owned[u.UID] = struct{}{} if u.State == store.StateConfirmedLeave { confirmed[u.UID] = true } } plan := ComputePlan(&Input{ Desired: desired, ActualUsers: actualUsers, ActualGroups: actualGroups, OwnedUsers: owned, ConfirmedLeave: confirmed, AdminDN: r.cfg.Ldap.AdminDN, PeopleOU: r.cfg.UserOUDN(), GroupRootDN: r.cfg.GroupRootDN(), }) return plan, desired, nil } // apply 通过 GraphQL 执行计划。每次物化开始时登录一次,并拉全量组建立 dept_id→groupId 映射。 func (r *Runner) apply(ctx context.Context, plan *Plan) *Result { if err := r.gql.Login(ctx, r.cfg.Lldap.AdminUser, r.cfg.Lldap.AdminPass); err != nil { return &Result{Failed: 1, Errors: []string{err.Error()}} } groups, err := r.gql.Groups(ctx) if err != nil { return &Result{Failed: 1, Errors: []string{err.Error()}} } groupIDs := make(map[string]int, len(groups)) for _, g := range groups { groupIDs[g.DisplayName] = g.ID } exec := NewExecutor(r.gql, ExecConfig{PeopleOU: r.cfg.UserOUDN()}, r.log) res := exec.Apply(ctx, plan, groupIDs) r.groupIDs = groupIDs return res } // persist 在物化全部成功后回写镜像:用户 attrs_hash、部门 groupId、被删用户标记 deleted。 func (r *Runner) persist(ctx context.Context, plan *Plan, desired *mapping.DesiredState) error { for _, u := range desired.Users { if err := r.store.SetUserAttrsHash(u.UID, AttrsHash(u)); err != nil { return err } } for deptID, gid := range r.groupIDs { if err := r.store.SetDeptGroupID(deptID, gid); err != nil { return err } } for _, op := range plan.Ops { if op.Kind == OpDeleteUser { if err := r.store.MarkDeleted(op.Target); err != nil { return err } } } return nil } // modeOf 把 dry-run 标志转成记录用的模式名。 func modeOf(dryRun bool) string { if dryRun { return "dry-run" } return "apply" } // fillRunFromPlan 用计划统计填充物化记录(dry-run 时使用)。 func fillRunFromPlan(run *store.SyncRun, plan *Plan) { run.Created = plan.Stats.CreateUser + plan.Stats.CreateGroup run.Updated = plan.Stats.UpdateUser run.Added = plan.Stats.AddMember run.Removed = plan.Stats.RemoveMember run.Skipped = plan.Stats.Unchanged } // fillRunFromResult 用实际执行结果填充物化记录。 func fillRunFromResult(run *store.SyncRun, plan *Plan, res *Result) { run.Created = res.Created run.Updated = res.Updated run.Added = res.Added run.Removed = res.Removed run.Failed = res.Failed run.Skipped = plan.Stats.Unchanged }