Files
idsync/internal/syncer/runner.go
T
caowangrenbo 947b1a1475 chore: 初始化 idsync 项目
飞书通讯录 ↔ LDAP 同步工具的首个提交。

- internal/feishu: 飞书通讯录客户端、快照与重试
- internal/ldaplib: LDAP 读写与批量操作
- internal/mapping: 飞书部门/用户到 LDAP 条目的映射
- internal/syncer: 同步计划生成、执行与调度
- internal/store: SQLite 台账
- internal/server + internal/webui: 管理后台与内嵌前端产物
- internal/password: SSHA 口令处理
- config.example.yml / Dockerfile / Makefile / web 前端源码

真实凭据(config.yml)、运行时数据(data/、backup/)与构建产物(bin/)不入库。
2026-09-29 00:10:59 +08:00

365 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}