refactor: config + lldaplib 收敛到 LLDAP 形态(读只读、删 OpenLDAP 写路径与密码包)

- config 重写:删 user-ou/inactive-ou/group-root-* / group-object-class /
  group-member-attr / initial-password;新增 lldap 写通道 + webhook + confirm-base-url
  + 两套 schedule + guard
- ldaplib 收拢为只读:属性常量/DN 归入 attrs.go;删 ops.go 写路径与 internal/password
- config.example.yml 重写成 LLDAP 形态
- 组命名修订(呼应 AGENTS.md 约束12):用部门稳定索引 cn=<dept_id> 做组身份,
  mirror_departments 增 group_id 列并跨快照保留,可读中文名只走 DB 展示
This commit is contained in:
2026-09-29 23:48:49 +08:00
parent 10ed3984dc
commit 979a98d502
16 changed files with 387 additions and 1031 deletions
+25 -6
View File
@@ -101,10 +101,8 @@ func (r *SnapshotRunner) RunSnapshot(ctx context.Context) (*SnapshotRunResult, e
// buildMirror 把飞书快照转成镜像存储层需要的输入。
//
// 部门拍平命名与 DN 由 mapping.FlatDeptInfo 一次性算好并持久化到
// mirror_departments,物化阶段直接复用、WebUI 亦可展示。
// 部门组 DN 用稳定索引派生(mapping.DeptGroupDN),可读中文名只落 name 列供展示。
func (r *SnapshotRunner) buildMirror(snap *feishu.Snapshot) *store.MirrorSnapshot {
flat := mapping.FlatDeptInfo(snap.Departments, r.cfg.Ldap.BaseDN)
deptName := make(map[string]string, len(snap.Departments))
for _, d := range snap.Departments {
deptName[d.ID] = d.Name
@@ -112,10 +110,9 @@ func (r *SnapshotRunner) buildMirror(snap *feishu.Snapshot) *store.MirrorSnapsho
depts := make([]*store.MirrorDepartment, 0, len(snap.Departments))
for _, d := range snap.Departments {
f := flat[d.ID]
depts = append(depts, &store.MirrorDepartment{
DeptID: d.ID, Name: d.Name, PinyinName: f.Chain, DN: f.DN,
ParentID: d.ParentID, Level: f.Level, State: store.StateActive,
DeptID: d.ID, Name: d.Name, DN: mapping.DeptGroupDN(d.ID, r.cfg.Ldap.BaseDN),
ParentID: d.ParentID, Level: deptLevel(d.ID, snap.Departments), State: store.StateActive,
})
}
@@ -144,6 +141,28 @@ func (r *SnapshotRunner) buildMirror(snap *feishu.Snapshot) *store.MirrorSnapsho
return &store.MirrorSnapshot{Users: users, Departments: depts, Skipped: skipped}
}
// deptLevel 计算部门在树中的层级,根为 0。带步数上限防环。
func deptLevel(id string, depts []*feishu.Department) int {
byID := make(map[string]*feishu.Department, len(depts))
for _, d := range depts {
byID[d.ID] = d
}
depth := 0
cur := id
for i := 0; i < 64; i++ {
d, ok := byID[cur]
if !ok || d.ParentID == "" || d.ParentID == "0" {
return depth
}
if _, ok := byID[d.ParentID]; !ok {
return depth // 父不在同步范围内,视为根
}
depth++
cur = d.ParentID
}
return depth
}
// toMirrorUser 把飞书用户域模型转成镜像用户。字段语义与 mapping 一致,
// business_category 展开为部门中文名便于人工阅读。
func toMirrorUser(u *feishu.User, uid string, deptName map[string]string) *store.MirrorUser {
+3 -6
View File
@@ -260,16 +260,13 @@ func TestSnapshotLeaveLifecycle(t *testing.T) {
t.Errorf("bob 初始应为 active,实际 %q", userState(t, h, "bob"))
}
// 部门镜像应持久化拍平拼音与 DN。
// 部门镜像应持久化稳定索引派生出的组 DN。
dm, err := h.store.MapDepartmentsByID()
if err != nil {
t.Fatalf("读取部门镜像失败: %v", err)
}
if d := dm["od-yanfa"]; d == nil || d.DN != "cn=Acme.yanfa,ou=groups,dc=example,dc=com" {
t.Errorf("od-yanfa DN = %+v, 期望拍平拼音", d)
}
if d := dm["od-yanfa"]; d == nil || d.PinyinName != "Acme.yanfa" {
t.Errorf("od-yanfa PinyinName = %+v", d)
if d := dm["od-yanfa"]; d == nil || d.DN != "cn=od-yanfa,ou=groups,dc=example,dc=com" {
t.Errorf("od-yanfa DN = %+v, 期望索引派生 cn=<dept_id>", d)
}
// 第 2 轮:bob 在飞书标记离职 → 不在 FindByDepartment 返回(视为缺席),