package syncer import ( "context" "fmt" "log/slog" "strings" "time" "idsync/internal/config" "idsync/internal/feishu" "idsync/internal/ldaplib" "idsync/internal/mapping" "idsync/internal/store" ) // Runner 编排一次完整的同步:读取飞书 → 读取 LDAP → 计算计划 → (可选)执行 → 回写台账。 type Runner struct { cfg *config.Config feishu *feishu.Client ldap *ldaplib.Client store *store.Store log *slog.Logger // guard 是执行前的安全阈值。 guard Guard } // NewRunner 构造编排器。 func NewRunner(cfg *config.Config, f *feishu.Client, l *ldaplib.Client, s *store.Store, log *slog.Logger) *Runner { return &Runner{ cfg: cfg, feishu: f, ldap: l, store: s, log: log, guard: Guard{ MaxDeactivate: cfg.Sync.MaxDeactivate, MaxDeactivatePercent: cfg.Sync.MaxDeactivatePercent, }, } } // Preview 只计算计划,不做任何写入。dry-run 与实际执行走同一份计划代码, // 因此预览内容与真正会发生的事情一致。 func (r *Runner) Preview(ctx context.Context) (*Plan, error) { plan, _, err := r.plan(ctx) return plan, err } // Run 执行一次同步。dryRun 为 true 时只计算并返回计划。 func (r *Runner) Run(ctx context.Context, dryRun bool, force bool, trigger string) (*Plan, *Result, error) { 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(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.State, 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 } // 阈值检查对 dry-run 也执行,让用户在预览阶段就知道本次变更规模异常。 // 只有显式传入 force 才跳过,避免把“预览”与“放行”混为一谈。 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 读取两侧状态并计算计划,同时把计划规模记录到 run。 func (r *Runner) plan(ctx context.Context) (*Plan, *mapping.State, error) { snap, err := r.feishu.Fetch(ctx, r.cfg.Feishu.DeptList) if err != nil { return nil, nil, fmt.Errorf("读取飞书数据失败: %w", err) } r.log.Info("已读取飞书数据", "departments", len(snap.Departments), "users", len(snap.Users), "skipped_no_email", len(snap.SkippedNoEmail), "duplicates", len(snap.SkippedDuplicate)) // 把“被跳过的用户”显式转成告警:这些人不会出现在 LDAP 里, // 如果不提示,管理者只能靠人肉比对才能发现有人没同步进来。 var skipWarnings []string for _, u := range snap.SkippedNoEmail { skipWarnings = append(skipWarnings, fmt.Sprintf( "用户 %s(union_id=%s)没有企业邮箱,无法推导登录名,已跳过", feishu.DisplayName(u), u.UnionID)) } for _, d := range snap.SkippedDuplicate { skipWarnings = append(skipWarnings, fmt.Sprintf( "企业邮箱前缀 %s 被多个用户占用(%s),为安全起见全部跳过,请人工确认后修正邮箱", d.UID, strings.Join(d.Names, "、"))) } desired := mapping.Build(snap, mapping.BuildOptions{ UserOUDN: r.cfg.UserOUDN(), InactiveOUDN: r.cfg.InactiveOUDN(), GroupRootDN: r.cfg.GroupRootDN(), }) conn, err := r.ldap.Dial() if err != nil { return nil, nil, err } defer conn.Close() active, err := conn.ListUsers(r.cfg.UserOUDN()) if err != nil { return nil, nil, err } inactive, err := conn.ListUsers(r.cfg.InactiveOUDN()) if err != nil { return nil, nil, err } groups, err := conn.ListGroups(r.cfg.GroupRootDN()) if err != nil { return nil, nil, err } accounts, err := r.store.AccountsByUID() if err != nil { return nil, nil, err } groupLedger, err := r.store.GroupsByDeptID() if err != nil { return nil, nil, err } ledgerAccounts := make(map[string]string, len(accounts)) for uid, a := range accounts { ledgerAccounts[uid] = a.State } ledgerGroups := make(map[string]string, len(groupLedger)) for deptID, g := range groupLedger { ledgerGroups[deptID] = g.DN } plan := ComputePlan(&Input{ Desired: desired, Active: active, Inactive: inactive, Groups: groups, LedgerAccounts: ledgerAccounts, LedgerGroups: ledgerGroups, AdminDN: r.cfg.Ldap.AdminDN, GroupRootDN: r.cfg.GroupRootDN(), UserOUDN: r.cfg.UserOUDN(), InactiveOUDN: r.cfg.InactiveOUDN(), }) plan.Warnings = append(skipWarnings, plan.Warnings...) // 对“候选停用”的用户,向飞书确认是否真的离职。 // 缺席也可能由权限范围收窄或部门调整造成,确认后再停用可避免误伤。 if err := r.confirmResigned(ctx, plan, snap); err != nil { r.log.Warn("确认离职状态时出错,相关账号本轮不停用", "err", err) } return plan, desired, nil } // confirmResigned 把未能确认离职的停用操作从计划中剔除,并追加告警。 func (r *Runner) confirmResigned(ctx context.Context, plan *Plan, _ *feishu.Snapshot) error { var uids []string // 只有台账里记录了 union_id 的账号才能精确回查;缺失者无从确认。 accounts, err := r.store.AccountsByUID() if err != nil { return err } for i := range plan.Ops { if plan.Ops[i].Kind != OpDeactivate { continue } acc, ok := accounts[plan.Ops[i].Target] if !ok || acc.UnionID == "" { continue } uids = append(uids, acc.UnionID) } if len(uids) == 0 { return nil } resigned, err := r.feishu.CheckResigned(ctx, uids) if err != nil { return err } confirmed := make(map[string]bool, len(resigned)) for _, rr := range resigned { confirmed[rr.UnionID] = true } keep := make([]Op, 0, len(plan.Ops)) for _, op := range plan.Ops { if op.Kind != OpDeactivate { keep = append(keep, op) continue } acc := accounts[op.Target] if acc != nil && confirmed[acc.UnionID] { keep = append(keep, op) continue } // 未能确认离职:不动作,并明确告知。 plan.Stats.Deactivate-- plan.Warnings = append(plan.Warnings, fmt.Sprintf( "账号 %s 在飞书已不可见,但未能确认离职状态(可能因通讯录权限范围变化),本轮不迁入 %s", op.Target, r.cfg.InactiveOUDN())) } plan.Ops = keep return nil } // apply 执行计划。 func (r *Runner) apply(ctx context.Context, plan *Plan) *Result { exec := NewExecutor(r.ldap, ExecConfig{ UserOUDN: r.cfg.UserOUDN(), InactiveOUDN: r.cfg.InactiveOUDN(), GroupRootDN: r.cfg.GroupRootDN(), GroupRootName: r.cfg.Ldap.GroupRootName, AdminDN: r.cfg.Ldap.AdminDN, InitialPassword: r.cfg.Ldap.InitialPassword, }, r.log) return exec.Apply(ctx, plan) } // persist 把本次同步的期望状态回写台账。 // // 关键点:按**期望状态**整体刷新,而不是只记录本次实际执行的操作。 // 否则会出现一个隐蔽问题——LDAP 中已存在、但台账里没有的分组永远不会被登记 // (因为它不需要新建,也就不会产生操作),于是本工具永远不认为它归自己管。 // 这个场景在接管既有目录时是常态,因此必须按期望状态登记。 func (r *Runner) persist(plan *Plan, desired *mapping.State) error { now := time.Now() // 分组:凡在本次期望范围内的都登记。 for _, g := range desired.Groups { if err := r.store.UpsertGroup(&store.Group{ DeptID: g.DeptID, DN: g.DN, Name: g.Name, ParentID: g.ParentID, State: "active", LastSyncedAt: now, }); err != nil { return err } } // 用户:在职的登记为 active。 for _, u := range desired.Users { if err := r.store.UpsertAccount(&store.Account{ UID: u.UID, DN: u.DN, Email: u.Mail, Name: u.DisplayName, State: "active", AttrsHash: AttrsHash(u), DeptIDs: strings.Join(u.DeptIDs, ","), LastSyncedAt: now, }); err != nil { return err } } // 停用的账号:更新状态与,DN 指向离职 OU。 for _, op := range plan.Ops { if op.Kind == OpDeactivate { if err := r.store.SetAccountState(op.Target, "inactive", op.DN); 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 + plan.Stats.UpdateGroup + plan.Stats.Reactivate + plan.Stats.Deactivate run.Moved = plan.Stats.Deactivate + plan.Stats.Reactivate 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.Moved = res.Moved run.Added = res.Added run.Removed = res.Removed run.Failed = res.Failed run.Skipped = plan.Stats.Unchanged }