diff --git a/internal/mapping/flat_test.go b/internal/mapping/flat_test.go
new file mode 100644
index 0000000..6b2ef69
--- /dev/null
+++ b/internal/mapping/flat_test.go
@@ -0,0 +1,82 @@
+package mapping
+
+import (
+ "strings"
+ "testing"
+
+ "idsync/internal/feishu"
+)
+
+// TestFlatDeptInfoParentChild 验证拍平拼音链:子部门链 = 父链.子拼音。
+func TestFlatDeptInfoParentChild(t *testing.T) {
+ depts := []*feishu.Department{
+ dept("od-root", "0", "Acme"),
+ dept("od-rd", "od-root", "研发部"),
+ dept("od-backend", "od-rd", "后端组"),
+ }
+ out := FlatDeptInfo(depts, "dc=example,dc=com")
+
+ if out["od-root"].Chain != "Acme" || out["od-root"].Level != 0 {
+ t.Errorf("根部门 = %+v", out["od-root"])
+ }
+ if out["od-rd"].Chain != "Acme.yanfabu" || out["od-rd"].Level != 1 {
+ t.Errorf("研发部 = %+v", out["od-rd"])
+ }
+ if out["od-backend"].Chain != "Acme.yanfabu.houduanzu" || out["od-backend"].Level != 2 {
+ t.Errorf("后端组 = %+v", out["od-backend"])
+ }
+ wantDN := "cn=Acme.yanfabu.houduanzu,ou=groups,dc=example,dc=com"
+ if out["od-backend"].DN != wantDN {
+ t.Errorf("后端组 DN = %q, 期望 %q", out["od-backend"].DN, wantDN)
+ }
+}
+
+// TestFlatDeptInfoFallsBackOnEmptyPinyin 验证纯符号部门名回落部门 ID,链非空且稳定。
+func TestFlatDeptInfoFallsBackOnEmptyPinyin(t *testing.T) {
+ depts := []*feishu.Department{
+ dept("od-root", "0", "Acme"),
+ dept("od-sym", "od-root", "★☆"),
+ }
+ out := FlatDeptInfo(depts, "dc=example,dc=com")
+ if out["od-sym"].Chain != "Acme.dept-od-sym" {
+ t.Errorf("纯符号部门链 = %q, 期望回落部门 ID", out["od-sym"].Chain)
+ }
+ if !strings.Contains(out["od-sym"].DN, out["od-sym"].Chain) {
+ t.Errorf("DN 未包含 Chain: %q", out["od-sym"].DN)
+ }
+}
+
+// TestFlatDeptInfoDisambiguatesCollision 验证同名部门消歧:链撞车时追加部门 ID,组名保持唯一。
+func TestFlatDeptInfoDisambiguatesCollision(t *testing.T) {
+ depts := []*feishu.Department{
+ dept("od-root", "0", "Acme"),
+ dept("od-a", "od-root", "开发组"),
+ dept("od-b", "od-root", "开发组"),
+ }
+ out := FlatDeptInfo(depts, "dc=example,dc=com")
+
+ if out["od-a"].Chain != "Acme.kaifazu" {
+ t.Errorf("od-a 链 = %q", out["od-a"].Chain)
+ }
+ if out["od-b"].Chain != "Acme.kaifazu.od-b" {
+ t.Errorf("od-b 链 = %q, 期望追加部门 ID 消歧", out["od-b"].Chain)
+ }
+ if out["od-a"].DN == out["od-b"].DN {
+ t.Errorf("两个同名部门的 DN 不应相同")
+ }
+}
+
+// TestFlatDeptInfoStable 验证同一输入产生的 DN 稳定(幂等,便于物化比对)。
+func TestFlatDeptInfoStable(t *testing.T) {
+ depts := []*feishu.Department{
+ dept("od-root", "0", "Acme"),
+ dept("od-rd", "od-root", "研发部"),
+ }
+ a := FlatDeptInfo(depts, "dc=example,dc=com")
+ b := FlatDeptInfo(depts, "dc=example,dc=com")
+ for id := range a {
+ if a[id].DN != b[id].DN || a[id].Chain != b[id].Chain {
+ t.Errorf("部门 %s 的命名不稳定: %q vs %q", id, a[id].DN, b[id].DN)
+ }
+ }
+}
\ No newline at end of file
diff --git a/internal/mapping/mapping_test.go b/internal/mapping/mapping_test.go
index db89cc9..59b9958 100644
--- a/internal/mapping/mapping_test.go
+++ b/internal/mapping/mapping_test.go
@@ -85,33 +85,8 @@ func TestBuildGroupOrderingIsParentFirst(t *testing.T) {
}
}
-// TestBuildGroupDNReflectsNesting 验证 DN 正确表达部门层级。
-func TestBuildGroupDNReflectsNesting(t *testing.T) {
- snap := &feishu.Snapshot{
- Departments: []*feishu.Department{
- dept("od-root", "0", "Acme"),
- dept("od-rd", "od-root", "研发部"),
- dept("od-backend", "od-rd", "后端组"),
- },
- }
-
- st := Build(snap, options())
-
- rootDN := st.GroupByDeptID["od-root"].DN
- if rootDN != "cn=Acme,ou=feishuroot,dc=example,dc=com" {
- t.Errorf("根部门 DN = %q", rootDN)
- }
-
- rdDN := st.GroupByDeptID["od-rd"].DN
- if rdDN != "cn=yanfabu,cn=Acme,ou=feishuroot,dc=example,dc=com" {
- t.Errorf("研发部 DN = %q(中文名应转为拼音)", rdDN)
- }
-
- bdDN := st.GroupByDeptID["od-backend"].DN
- if bdDN != "cn=houduanzu,cn=yanfabu,cn=Acme,ou=feishuroot,dc=example,dc=com" {
- t.Errorf("后端组 DN = %q", bdDN)
- }
-}
+// TestBuildGroupDNReflectsNesting 已废弃:目标架构用 LLDAP 拍平拼音命名,
+// 不再使用嵌套 CN。拍平命名的覆盖见 flat_test.go / mirror_test.go。
// TestBuildUsernameFromEnterpriseEmail 验证登录名来源。
func TestBuildUsernameFromEnterpriseEmail(t *testing.T) {
diff --git a/internal/mapping/mirror.go b/internal/mapping/mirror.go
new file mode 100644
index 0000000..febeb6f
--- /dev/null
+++ b/internal/mapping/mirror.go
@@ -0,0 +1,122 @@
+package mapping
+
+// 镜像 → 期望 LLDAP 状态 的落库化映射。
+//
+// 与旧 `Build(feishu.Snapshot)`(OpenLDAP 单阶段时代的嵌套 CN + inactive OU 模型)
+// 不同,这里是目标架构的映射:输入是 SQLite 镜像的活动用户与活动部门,输出是
+// 期望写入 LLDAP 的纯函数状态。LLDAP 组不能嵌套,因此组用拍平拼音点分命名;
+// 用户 DN 固定挂在 ou=people 下,离职不靠 DN 移动而是物理删除(见 syncer 阶段 6)。
+
+import (
+ "sort"
+ "strings"
+
+ "idsync/internal/ldaplib"
+ "idsync/internal/store"
+)
+
+// DesiredGroup 是期望的分组(拍平命名)。
+type DesiredGroup struct {
+ DeptID string
+ // DN 是完整组 DN(来自 mirror_departments,形式 cn=<父.子>,ou=groups,)。
+ DN string
+ // Chain 是拍平拼音链,仅展示用。
+ Chain string
+ Name string
+ // Level 是部门在树中的层级,用于保证父组先于子组创建。
+ Level int
+ // Members 是期望成员的用户 DN 集合。
+ Members map[string]struct{}
+}
+
+// DesiredUser 是期望的用户。
+//
+// 不再有 InactiveDN:离职走物理删除,用户 DN 固定 ou=people/。
+type DesiredUser struct {
+ UID string
+ DN string
+ CN string
+ SN string
+ GivenName string
+ DisplayName string
+ Mail string
+ Mobile string
+ EmployeeNumber string
+ DepartmentNumber string
+ PostalAddress string
+ BusinessCategory string
+ Description string
+ DeptIDs []string
+}
+
+// DesiredState 是期望写入 LLDAP 的完整状态。
+type DesiredState struct {
+ Groups []*DesiredGroup
+ Users []*DesiredUser
+ GroupByDeptID map[string]*DesiredGroup
+}
+
+// BuildFromMirror 由镜像的活动用户与活动部门构造期望状态。纯函数,不触碰 IO。
+//
+// 组名与 DN 直接复用镜像落库时算好的 PinyinName/DN(见 FlatDeptInfo),保证
+// 物化与预览看到的是同一份命名,不会出现「写入了但预览里看不到」的分叉。
+func BuildFromMirror(users []*store.MirrorUser, depts []*store.MirrorDepartment, baseDN string) *DesiredState {
+ st := &DesiredState{
+ Groups: make([]*DesiredGroup, 0, len(depts)),
+ Users: make([]*DesiredUser, 0, len(users)),
+ GroupByDeptID: make(map[string]*DesiredGroup, len(depts)),
+ }
+ peopleOU := "ou=people," + strings.TrimSpace(baseDN)
+
+ for _, d := range depts {
+ g := &DesiredGroup{
+ DeptID: d.DeptID,
+ DN: d.DN,
+ Chain: d.PinyinName,
+ Name: d.Name,
+ Level: d.Level,
+ Members: make(map[string]struct{}),
+ }
+ st.Groups = append(st.Groups, g)
+ st.GroupByDeptID[d.DeptID] = g
+ }
+
+ for _, u := range users {
+ uid := u.UID
+ du := &DesiredUser{
+ UID: uid,
+ DN: ldaplib.UserDN(uid, peopleOU),
+ CN: uid,
+ SN: u.SN,
+ GivenName: u.GivenName,
+ DisplayName: u.DisplayName,
+ Mail: u.Email,
+ Mobile: u.Mobile,
+ EmployeeNumber: u.EmployeeNumber,
+ DepartmentNumber: u.DepartmentNumber,
+ PostalAddress: u.PostalAddress,
+ BusinessCategory: u.BusinessCategory,
+ Description: u.Description,
+ 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{}{}
+ }
+ }
+ }
+
+ // 分组按层级排序,父组必须排在子组之前(LLDAP 组虽不嵌套,但物化创建
+ // 顺序仍要保持父先于子,避免依赖隐含顺序造成问题)。
+ sort.SliceStable(st.Groups, func(i, j int) bool {
+ if st.Groups[i].Level != st.Groups[j].Level {
+ return st.Groups[i].Level < st.Groups[j].Level
+ }
+ 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
+}
\ No newline at end of file
diff --git a/internal/mapping/mirror_test.go b/internal/mapping/mirror_test.go
new file mode 100644
index 0000000..f88aa9d
--- /dev/null
+++ b/internal/mapping/mirror_test.go
@@ -0,0 +1,98 @@
+package mapping
+
+// BuildFromMirror(镜像 → 期望状态)的单测。
+//
+// 覆盖:用户 DN 固定 ou=people、组名直接复用镜像拍平 DN、成员关系按用户所属部门
+// 归集、分组按层级父先于子排序。
+
+import (
+ "testing"
+
+ "idsync/internal/store"
+)
+
+func mirrorUser(uid, email, deptID string) *store.MirrorUser {
+ return &store.MirrorUser{
+ UID: uid,
+ Email: email,
+ DisplayName: uid,
+ SN: uid,
+ GivenName: uid,
+ DepartmentIDs: []string{deptID},
+ }
+}
+
+func mirrorDept(id, name, pinyin, dn string, level int, parent string) *store.MirrorDepartment {
+ return &store.MirrorDepartment{
+ DeptID: id, Name: name, PinyinName: pinyin, DN: dn, Level: level, ParentID: parent,
+ }
+}
+
+// TestBuildFromMirrorUserDN 验证用户 DN 固定挂在 ou=people 下,不再有离职 OU。
+func TestBuildFromMirrorUserDN(t *testing.T) {
+ depts := []*store.MirrorDepartment{
+ mirrorDept("od-root", "Acme", "Acme", "cn=Acme,ou=groups,dc=example,dc=com", 0, ""),
+ }
+ users := []*store.MirrorUser{mirrorUser("alice", "alice@example.com", "od-root")}
+
+ st := BuildFromMirror(users, depts, "dc=example,dc=com")
+ if len(st.Users) != 1 {
+ t.Fatalf("期望 1 个用户,实际 %d", len(st.Users))
+ }
+ u := st.Users[0]
+ if u.DN != "uid=alice,ou=people,dc=example,dc=com" {
+ t.Errorf("用户 DN = %q, 期望固定 ou=people", u.DN)
+ }
+ if u.Mail != "alice@example.com" {
+ t.Errorf("mail = %q", u.Mail)
+ }
+}
+
+// TestBuildFromMirrorGroupMembers 验证成员关系按用户所属部门归集到对应的组。
+func TestBuildFromMirrorGroupMembers(t *testing.T) {
+ depts := []*store.MirrorDepartment{
+ mirrorDept("od-root", "Acme", "Acme", "cn=Acme,ou=groups,dc=example,dc=com", 0, ""),
+ mirrorDept("od-rd", "研发部", "Acme.yanfabu", "cn=Acme.yanfabu,ou=groups,dc=example,dc=com", 1, "od-root"),
+ }
+ users := []*store.MirrorUser{
+ mirrorUser("alice", "alice@example.com", "od-rd"),
+ // lisi 属多部门
+ {UID: "lisi", Email: "lisi@example.com", DisplayName: "lisi", SN: "lisi",
+ GivenName: "lisi", DepartmentIDs: []string{"od-root", "od-rd"}},
+ }
+
+ st := BuildFromMirror(users, depts, "dc=example,dc=com")
+
+ rd := st.GroupByDeptID["od-rd"]
+ if _, ok := rd.Members["uid=alice,ou=people,dc=example,dc=com"]; !ok {
+ t.Errorf("alice 未加入 %s", rd.DN)
+ }
+ if _, ok := rd.Members["uid=lisi,ou=people,dc=example,dc=com"]; !ok {
+ t.Errorf("lisi 未加入 %s", rd.DN)
+ }
+ root := st.GroupByDeptID["od-root"]
+ if _, ok := root.Members["uid=alice,ou=people,dc=example,dc=com"]; ok {
+ t.Errorf("alice 只属研发部,不应出现在根部门")
+ }
+ if _, ok := root.Members["uid=lisi,ou=people,dc=example,dc=com"]; !ok {
+ t.Errorf("lisi 属根部门,应出现")
+ }
+}
+
+// TestBuildFromMirrorGroupOrderIsParentFirst 验证分组输出按层级父先于子排序。
+func TestBuildFromMirrorGroupOrderIsParentFirst(t *testing.T) {
+ depts := []*store.MirrorDepartment{
+ // 刻意让子部门先给出,验证 BuildFromMirror 内部仍按 Level 排回父先于子。
+ mirrorDept("od-rd", "研发部", "Acme.yanfabu", "cn=Acme.yanfabu,ou=groups,dc=example,dc=com", 1, "od-root"),
+ mirrorDept("od-root", "Acme", "Acme", "cn=Acme,ou=groups,dc=example,dc=com", 0, ""),
+ }
+
+ st := BuildFromMirror(nil, depts, "dc=example,dc=com")
+ seen := map[string]int{}
+ for i, g := range st.Groups {
+ seen[g.DeptID] = i
+ }
+ if seen["od-root"] > seen["od-rd"] {
+ t.Errorf("父部门应排在子部门之前:%v", seen)
+ }
+}
\ No newline at end of file