Files
idsync/internal/syncer/exec.go
T
caowangrenbo 126e52a6ed 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 永不移出、删除最后阶段
2026-09-29 23:48:55 +08:00

139 lines
3.9 KiB
Go
Raw Permalink 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
// 物化执行器:把 diff 计划通过 GraphQL 写落到 LLDAP。
//
// 这里是两阶段架构里唯一写 LLDAP 的执行路径;读 LLDAP 走 ldaplib(见 plan 的
// ActualUsers/ActualGroups)。组间成员关系用 LLDAP 内置 addUserToGroup,groupId
// 来自 material 开始时通过 GraphQL 拉取的 displayName→id 映射,不凭名字反推。
import (
"context"
"fmt"
"log/slog"
"idsync/internal/lldapgql"
)
// ExecConfig 是执行器所需的环境参数。
type ExecConfig struct {
PeopleOU string
}
// Result 是执行结果统计。
type Result struct {
Created int
Updated int
Deleted int
Added int
Removed int
Failed int
Errors []string
}
// Executor 把计划应用到 LLDAP(写走 GraphQL)。
type Executor struct {
gql *lldapgql.Client
cfg ExecConfig
log *slog.Logger
}
// NewExecutor 构造执行器。
func NewExecutor(gql *lldapgql.Client, cfg ExecConfig, log *slog.Logger) *Executor {
return &Executor{gql: gql, cfg: cfg, log: log}
}
// Apply 执行计划。任何一步失败都会被记录并继续处理后续操作——一次物化中某个
// 用户的数据问题不应阻断其余几百人的同步。groupIDs 是 dept_id→groupId 映射,
// 会在创建新组时被原地扩充。
func (e *Executor) Apply(ctx context.Context, plan *Plan, groupIDs map[string]int) *Result {
res := &Result{}
for _, op := range plan.Ops {
if err := ctx.Err(); err != nil {
res.Errors = append(res.Errors, "物化被取消: "+err.Error())
return res
}
if err := e.applyOne(ctx, op, groupIDs); err != nil {
res.Failed++
msg := fmt.Sprintf("%s(%s): %s", op.Kind, op.Target, err.Error())
res.Errors = append(res.Errors, msg)
e.log.Error("物化操作失败", "kind", op.Kind, "target", op.Target, "err", err)
continue
}
switch op.Kind {
case OpCreateUser, OpCreateGroup:
res.Created++
case OpUpdateUser:
res.Updated++
case OpDeleteUser:
res.Deleted++
case OpAddMember:
res.Added++
case OpRemoveMember:
res.Removed++
}
}
return res
}
// applyOne 执行单个操作。
func (e *Executor) applyOne(ctx context.Context, op Op, groupIDs map[string]int) error {
switch op.Kind {
case OpCreateGroup:
// LLDAP 组 displayName 即部门索引;创建后把返回的 groupId 记进映射,
// 供后续成员操作用。
name := op.Group.DeptID
id, err := e.gql.CreateGroup(ctx, name)
if err != nil {
return err
}
groupIDs[name] = id
return nil
case OpCreateUser:
// 创建用户不设密码:密码由 LLDAP 独占、首次登录走 Authelia 忘记密码。
return e.gql.CreateUser(ctx, &lldapgql.CreateUserInput{
ID: op.User.UID,
Email: sp(op.User.Mail),
DisplayName: sp(op.User.DisplayName),
FirstName: sp(op.User.GivenName),
LastName: sp(op.User.SN),
})
case OpUpdateUser:
// 省略即不改:这里显式带全四个可写硬字段,与 attrsDiffer 判定的差异一致。
return e.gql.UpdateUser(ctx, &lldapgql.UpdateUserInput{
ID: op.User.UID,
Email: sp(op.User.Mail),
DisplayName: sp(op.User.DisplayName),
FirstName: sp(op.User.GivenName),
LastName: sp(op.User.SN),
})
case OpAddMember:
gid, ok := groupIDs[op.Group.DeptID]
if !ok {
return fmt.Errorf("未在 groupId 映射中找到部门 %s 的组", op.Group.DeptID)
}
return e.gql.AddUserToGroup(ctx, op.MemberUID, gid)
case OpRemoveMember:
gid, ok := groupIDs[op.Group.DeptID]
if !ok {
return fmt.Errorf("未在 groupId 映射中找到部门 %s 的组", op.Group.DeptID)
}
return e.gql.RemoveUserFromGroup(ctx, op.MemberUID, gid)
case OpDeleteUser:
// LLDAP deleteUser 会级联清理成员关系(外键 CASCADE),无需先移组再删。
return e.gql.DeleteUser(ctx, op.Target)
default:
return fmt.Errorf("未知操作类型: %s", op.Kind)
}
}
// sp 生成 *string。
func sp(s string) *string { return &s }