// Package mapping 把飞书快照转换为期望的 LDAP 状态。 // // 这一层是纯函数:不触碰 IO,不依赖配置以外的全局状态。DN 的构造规则 // 集中在此,便于测试与审查——DN 出错会造成最难排查的故障。 package mapping import ( "fmt" "sort" "strings" "github.com/mozillazg/go-pinyin" "idsync/internal/feishu" "idsync/internal/ldaplib" ) // 员工在职状态标记。 // // 定义在 mapping 而非 syncer:它是「期望状态」的一部分,需要参与差异比对, // 因此必须与期望状态放在一起,否则会出现「写入了但预览里看不到」的不一致。 const ( EmployeeTypeActive = "Active" EmployeeTypeResigned = "Resigned" ) // State 是期望写入 LDAP 的完整状态。 type State struct { Groups []*Group Users []*User // GroupByDeptID 便于按部门查组 GroupByDeptID map[string]*Group // Warnings 是映射过程中发现的问题,需要人工留意。 Warnings []string } // Group 是期望的分组。 type Group struct { DeptID string DN string Name string ParentID string // Depth 是部门在树中的层级,根为 0。用于保证父分组先于子分组创建。 Depth int // Members 是期望成员的用户 uid 集合 Members map[string]struct{} } // User 是期望的用户。 type User struct { UID string DN string InactiveDN string CN string SN string GivenName string DisplayName string Mail string Mobile string EmployeeNumber string DepartmentNumber string PostalAddress string BusinessCategory string Description string // EmployeeType 为 Active;离职条目由执行器单独标记为 Resigned。 EmployeeType string DeptIDs []string } // BuildOptions 是构造期望状态所需的配置。 type BuildOptions struct { UserOUDN string InactiveOUDN string // GroupRootName 是部门树根 OU 的名称(不含 ou=) GroupRootName string GroupRootDN string // CollisionSuffix 在登录名冲突时用于消歧,默认取员工工号或 union_id 片段 Disambiguate func(u *feishu.User) string } // Build 由飞书快照构造期望状态。 func Build(snap *feishu.Snapshot, opt BuildOptions) *State { st := &State{ GroupByDeptID: make(map[string]*Group), Warnings: make([]string, 0), } // 部门 → 分组的 DN 需要自顶向下构造,先建索引再按层级解析。 deptByID := make(map[string]*feishu.Department, len(snap.Departments)) for _, d := range snap.Departments { deptByID[d.ID] = d } // 按层级排序,确保父部门先于子部门处理,从而能复用父的 DN。 ordered := orderByDepth(snap.Departments, deptByID) for _, d := range ordered { parentDN := opt.GroupRootDN if d.ParentID != "" && d.ParentID != "0" { switch pg, ok := st.GroupByDeptID[d.ParentID]; { case ok: parentDN = pg.DN case deptByID[d.ParentID] != nil: // 父部门在范围内但尚未处理:正常情况下不会发生, // 因为 ordered 已保证父先于子。出现即说明建树有缺陷。 st.Warnings = append(st.Warnings, fmt.Sprintf("部门 %s(%s) 的父部门 %s 尚未处理,已跳过该部门", d.Name, d.ID, d.ParentID)) continue default: // 父部门完全不在本次同步范围内(例如 dept-list 配置不当, // 或通讯录权限被收窄)。此时挂到分组根下,让它至少能被同步, // 但必须显式告警——静默地把部门挂到错误层级比失败更难察觉。 st.Warnings = append(st.Warnings, fmt.Sprintf("部门 %s(%s) 的父部门 %s 不在同步范围内,已挂到 %s 下,请确认层级是否正确", d.Name, d.ID, d.ParentID, opt.GroupRootDN)) } } name := cnName(d.Name, d.ID) g := &Group{ DeptID: d.ID, DN: ldaplib.GroupDN(name, parentDN), Name: d.Name, ParentID: d.ParentID, Depth: depthOf(d.ID, deptByID), Members: make(map[string]struct{}), } st.Groups = append(st.Groups, g) st.GroupByDeptID[d.ID] = g } // 用户 for _, u := range snap.Users { uid, ok := feishu.LoginName(u) if !ok { continue // 已在快照的 SkippedNoEmail 中报告 } du := &User{ UID: uid, DN: ldaplib.UserDN(uid, opt.UserOUDN), InactiveDN: ldaplib.UserDN(uid, opt.InactiveOUDN), CN: uid, SN: firstName(u), GivenName: firstNonEmpty(u.EnName, u.Name, uid), DisplayName: firstNonEmpty(u.Name, u.EnName, uid), Mail: u.EnterpriseEmail, Mobile: u.Mobile, EmployeeNumber: u.EmployeeNo, DepartmentNumber: u.JobTitle, PostalAddress: u.WorkStation, BusinessCategory: strings.Join(deptNames(u.DepartmentIDs, deptByID), ","), Description: firstNonEmpty(u.Name, u.EnName, uid), EmployeeType: EmployeeTypeActive, DeptIDs: u.DepartmentIDs, } st.Users = append(st.Users, du) // 维护成员关系:只把用户加入其实际所属且存在于本次同步范围的部门组。 for _, deptID := range u.DepartmentIDs { if g, ok := st.GroupByDeptID[deptID]; ok { g.Members[du.DN] = struct{}{} } } } // 关键:按层级排序,父分组必须排在子分组之前。 // // 不能用 DeptID 字母序:部门 ID 与层级无关,字母序会把子部门排到父部门 // 前面,导致创建子分组时父分组尚不存在(LDAP 报 No Such Object)。 // 这个问题在部门名恰好有序时才不会暴露,因此必须显式按深度排序。 sort.SliceStable(st.Groups, func(i, j int) bool { if st.Groups[i].Depth != st.Groups[j].Depth { return st.Groups[i].Depth < st.Groups[j].Depth } return st.Groups[i].DeptID < st.Groups[j].DeptID }) sort.SliceStable(st.Users, func(i, j int) bool { return st.Users[i].UID < st.Users[j].UID }) return st } // depthOf 计算部门在树中的深度,根部门为 0。 // // 带有步数上限,防止数据异常(如父子互指形成环)时无限循环。 func depthOf(id string, byID map[string]*feishu.Department) int { const maxDepth = 64 depth := 0 cur := id for i := 0; i < maxDepth; 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 } // cnName 为部门生成稳定的 CN。 // // 用拼音而非中文:中文作 RDN 在 DN 中需要转义为 #hex 形式,可读性差且 // 在部分工具链下有兼容问题;拼音与现有目录的部门命名也保持一致。 // 拼音为空(部门名为纯符号)时回落到部门 ID,保证 CN 非空且稳定。 func cnName(deptName, deptID string) string { name := toPinyin(deptName) if name != "" { return name } return "dept-" + deptID } // toPinyin 把中文转为无音调拼音,保留 ASCII 与数字。 func toPinyin(s string) string { s = strings.TrimSpace(s) if s == "" { return "" } // 已全是 ASCII 时直接使用,避免拼音库对英文做多余处理。 if isASCII(s) { return sanitizeCN(s) } args := pinyin.NewArgs() args.Style = pinyin.Normal parts := pinyin.Pinyin(s, args) var b strings.Builder for _, p := range parts { if len(p) == 0 { continue } b.WriteString(p[0]) } return sanitizeCN(b.String()) } // sanitizeCN 清理 CN 中会导致 DN 书写困难的字符。 func sanitizeCN(s string) string { var b strings.Builder for _, r := range s { switch { case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': b.WriteRune(r) case r == '-' || r == '_': b.WriteRune(r) case r == ' ' || r == '.': // 空格与点号在 CN 中会造成歧义,转为下划线 b.WriteRune('_') case r > 127: // 剩余的非 ASCII(拼音库未能转换的字符)丢弃 default: b.WriteRune(r) } } return b.String() } func isASCII(s string) bool { for _, r := range s { if r > 127 { return false } } return true } // orderByDepth 返回按层级(父在子前)排序的部门列表。 // // 使用迭代式的层级展开,并对异常数据(环、悬挂父引用)做保护: // 无法归入任何层级的部门会被追加到末尾,绝不死循环。 func orderByDepth(all []*feishu.Department, byID map[string]*feishu.Department) []*feishu.Department { children := make(map[string][]*feishu.Department) roots := make([]*feishu.Department, 0) for _, d := range all { if d.ParentID == "" || d.ParentID == "0" { roots = append(roots, d) continue } if _, ok := byID[d.ParentID]; !ok { roots = append(roots, d) // 父不在范围内,当根处理 continue } children[d.ParentID] = append(children[d.ParentID], d) } out := make([]*feishu.Department, 0, len(all)) visited := make(map[string]bool, len(all)) var walk func(list []*feishu.Department) walk = func(list []*feishu.Department) { for _, d := range list { if visited[d.ID] { continue // 环保护 } visited[d.ID] = true out = append(out, d) walk(children[d.ID]) } } walk(roots) // 未被访问到的(处在环中)按 ID 顺序补齐,避免静默丢部门。 var rest []*feishu.Department for _, d := range all { if !visited[d.ID] { rest = append(rest, d) } } sort.SliceStable(rest, func(i, j int) bool { return rest[i].ID < rest[j].ID }) out = append(out, rest...) return out } func deptNames(ids []string, byID map[string]*feishu.Department) []string { out := make([]string, 0, len(ids)) for _, id := range ids { if d, ok := byID[id]; ok && d.Name != "" { out = append(out, d.Name) } } sort.Strings(out) return out } // firstName 为 inetOrgPerson 的必填属性 sn 提供值。 // 拆不出姓氏时用全名,保证非空。 func firstName(u *feishu.User) string { if n := strings.TrimSpace(u.Name); n != "" { return n } if n := strings.TrimSpace(u.EnName); n != "" { return n } return u.UnionID } func firstNonEmpty(vals ...string) string { for _, v := range vals { if strings.TrimSpace(v) != "" { return v } } return "" }