refactor: mapping 落库化(期望状态改从镜像视图构造 + 拼音拍平组名)

- mapping.BuildFromMirror:由镜像 active 用户/部门构造期望 LLDAP 状态(纯函数),
  组名/DN 直接复用快照落库时算好的拍平拼音,用户 DN 固定 ou=people、去掉离职 OU
- Feat: FlatDeptInfo 已在本阶段完成测试覆盖(拍平拼音父子/空拼音回落/重名消歧/DN 稳定)
- 移除已被目标架构取代的旧「嵌套 CN」测试断言
- 旧 Build(OpenLDAP 单阶段)暂留保证过渡期编译,阶段6退役
This commit is contained in:
2026-09-29 23:25:50 +08:00
parent f997e48a75
commit 8fd10f134b
4 changed files with 304 additions and 27 deletions
+82
View File
@@ -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)
}
}
}
+2 -27
View File
@@ -85,33 +85,8 @@ func TestBuildGroupOrderingIsParentFirst(t *testing.T) {
} }
} }
// TestBuildGroupDNReflectsNesting 验证 DN 正确表达部门层级。 // TestBuildGroupDNReflectsNesting 已废弃:目标架构用 LLDAP 拍平拼音命名,
func TestBuildGroupDNReflectsNesting(t *testing.T) { // 不再使用嵌套 CN。拍平命名的覆盖见 flat_test.go / mirror_test.go。
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)
}
}
// TestBuildUsernameFromEnterpriseEmail 验证登录名来源。 // TestBuildUsernameFromEnterpriseEmail 验证登录名来源。
func TestBuildUsernameFromEnterpriseEmail(t *testing.T) { func TestBuildUsernameFromEnterpriseEmail(t *testing.T) {
+122
View File
@@ -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,<base>)。
DN string
// Chain 是拍平拼音链,仅展示用。
Chain string
Name string
// Level 是部门在树中的层级,用于保证父组先于子组创建。
Level int
// Members 是期望成员的用户 DN 集合。
Members map[string]struct{}
}
// DesiredUser 是期望的用户。
//
// 不再有 InactiveDN:离职走物理删除,用户 DN 固定 ou=people/<base>。
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
}
+98
View File
@@ -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)
}
}