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
+13 -63
View File
@@ -1,79 +1,29 @@
package mapping
// 部门组的拍平命名与 DN 构造。
// 部门组的命名与 DN 构造。
//
// LLDAP 组不能嵌套(组里只能有用户,没有父/子组概念),因此部门树必须拍平:
// 组名 = 父链拼音点分拼接(如 cn=yanfa.pingtai)。拼音为空时回落到部门 ID 保证
// 非空稳定。组名天然唯一(部门树路径唯一),重名时追加部门 ID 消歧。
//
// 这些 DN 在快照落库阶段一次性算好并持久化到 mirror_departments,供物化阶段
// 直接复用,也让 WebUI 能展示每个部门的预期 DN。
// LLDAP 组不嵌套(组里只能有用户),且组身份必须稳定:部门改名不能引起组
// displayName/CN 变化,否则 Authelia 与下游对组的引用会连锁失效。因此组名用
// 部门稳定索引(open_department_id)派生,而不是拼音——拼音对可读但多义、且
// 改名即换身份。可读的部门中文名不进入组名,存放在数据库的 name 列与用户的
// business_category 里供展示(见 AGENTS.md 约束 12)。
import (
"strings"
"idsync/internal/feishu"
"idsync/internal/ldaplib"
)
// FlatDept 是一个部门的拍平命名结果。
type FlatDept struct {
DeptID string
// Chain 是拍平后的拼音链,如 "yanfa.pingtai"。
Chain string
// DN 是完整组 DN,如 "cn=yanfa.pingtai,ou=groups,<base>"。
DN string
// Level 是部门在树中的层级,根为 0。
Level int
}
// GroupRootDN 返回部门组根 OU 的完整 DN。LLDAP 分组固定挂在 ou=groups 下。
func GroupRootDN(baseDN string) string {
return "ou=groups," + strings.TrimSpace(baseDN)
}
// FlatDeptInfo 把部门树一次性拍平为拼音点分命名并计算每股的 DN。
// DeptGroupDN 由部门稳定索引派生出该部门组的完整 DN。
//
// 输入是快照里的全量部门;返回 dept_id → FlatDept。父先于子顺序处理,
// 因此计算子部门时可复用父部门的 Chain。纯函数,不触碰 IO。
func FlatDeptInfo(depts []*feishu.Department, baseDN string) map[string]*FlatDept {
byID := make(map[string]*feishu.Department, len(depts))
for _, d := range depts {
byID[d.ID] = d
}
ordered := orderByDepth(depts, byID)
root := GroupRootDN(baseDN)
out := make(map[string]*FlatDept, len(depts))
used := make(map[string]string, len(depts)) // chain -> 占用它的 dept_id
for _, d := range ordered {
prefix, parentLevel := "", -1
if p, ok := out[d.ParentID]; ok {
prefix = p.Chain
parentLevel = p.Level
}
level := parentLevel + 1
// 单个部门的拼音段;拼音为空时回落部门 ID(cnName 保证非空)。
seg := cnName(d.Name, d.ID)
chain := seg
if prefix != "" {
chain = prefix + "." + seg
}
// 重名消歧:同一层出现相同拼音(如两个「开发组」)会让 Chain 撞车,
// 组名必须唯一,因此在链尾追加部门 ID 片段。
if other, taken := used[chain]; taken && other != d.ID {
chain += "." + d.ID
}
used[chain] = d.ID
out[d.ID] = &FlatDept{
DeptID: d.ID,
Chain: chain,
DN: "cn=" + chain + "," + root,
Level: level,
}
}
return out
// 形如 cn=<dept_id>,ou=groups,<base>。open_department_id 在飞书租户内全局唯一、
// 且部门改名/挪动不变,因此组身份稳定;部门树的父子/层级关系只维护在 SQLite 的
// mirror_departments.parent_id,不从组名解码(见 AGENTS.md 约束 12)。
func DeptGroupDN(deptID, baseDN string) string {
return ldaplib.GroupDN(deptID, GroupRootDN(baseDN))
}