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
+50 -16
View File
@@ -72,16 +72,19 @@ type MirrorUser struct {
// MirrorDepartment 是部门镜像。
type MirrorDepartment struct {
DeptID string `gorm:"primaryKey;size:190"`
Name string `gorm:"check:name <> ''"`
PinyinName string `gorm:"size:255"`
DN string `gorm:"size:512"`
ParentID string `gorm:"size:190;index"`
Level int
State string `gorm:"size:32;not null;default:active;index"`
LastSyncedAt time.Time
CreatedAt time.Time
UpdatedAt time.Time
DeptID string `gorm:"primaryKey;size:190"`
// Name 是部门中文名,纯展示用(可读名走 DB,不进 LDAP 组名)。
Name string `gorm:"check:name <> ''"`
DN string `gorm:"size:512;index"`
ParentID string `gorm:"size:190;index"`
Level int
// GroupID 是 LLDAP 中该部门组的自增 int 组 id。LDAP 视角无法取到它,
// 必须由物化阶段持久化,供 GraphQL 成员操作直接用(约束 12)。
GroupID int `gorm:"column:group_id"`
State string `gorm:"size:32;not null;default:active;index"`
LastSyncedAt time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
// MirrorUserDepartment 是用户 ↔ 部门 的关联表,用于按部门查人的索引。
@@ -121,24 +124,38 @@ func (s *Store) ReplaceMirror(snap *MirrorSnapshot) error {
now := time.Now()
// 部门整体替换:快照里的都 upsert;快照之外的一律清除,保证镜像 = 飞书当前有的部门。
// 部门是两个「整体替换」之一,用户因受离职生命周期约束不在本层删。
if err := tx.Session(&gorm.Session{AllowGlobalUpdate: true}).
Delete(&MirrorDepartment{}).Error; err != nil {
return fmt.Errorf("清空部门镜像失败: %w", err)
}
// 注意 upsert 冲突时**不碰 group_id**——物化阶段持久化的 dept_id→groupId 映射
// 必须跨快照刷新保留,否则每次快照都会把组 id 清零。
for _, d := range snap.Departments {
d.State = StateActive
d.LastSyncedAt = now
if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "dept_id"}},
DoUpdates: clause.AssignmentColumns([]string{
"name", "pinyin_name", "dn", "parent_id", "level", "state", "last_synced_at",
"name", "dn", "parent_id", "level", "state", "last_synced_at",
}),
}).Create(d).Error; err != nil {
return fmt.Errorf("写入部门镜像 %s(%s) 失败: %w", d.Name, d.DeptID, err)
}
}
// 清除本轮快照之外的部门(group_id 随之丢弃,其组已成孤儿)。
if len(snap.Departments) == 0 {
if err := tx.Session(&gorm.Session{AllowGlobalUpdate: true}).
Delete(&MirrorDepartment{}).Error; err != nil {
return fmt.Errorf("清除部门镜像失败: %w", err)
}
} else {
presentIDs := make([]string, 0, len(snap.Departments))
for _, d := range snap.Departments {
presentIDs = append(presentIDs, d.DeptID)
}
if err := tx.Where("dept_id NOT IN ?", presentIDs).
Delete(&MirrorDepartment{}).Error; err != nil {
return fmt.Errorf("清除旧部门失败: %w", err)
}
}
// 用户整体替换:upsert 有账者,不轮询删除缺席者(其生命周期由 reconcile 处理)。
for _, u := range snap.Users {
u.State = StateActive
@@ -347,6 +364,23 @@ func (s *Store) MapDepartmentsByID() (map[string]*MirrorDepartment, error) {
return out, nil
}
// SetDeptGroupID 持久化部门组在 LLDAP 里的自增 groupId。
//
// LDAP 视角取不到这个 int id,只能由物化阶段在创建/核对组时写入,成员操作据此直接用。
// 快照刷新刻意不覆盖 group_id,保证映射跨刷新稳定。
func (s *Store) SetDeptGroupID(deptID string, groupID int) error {
return s.db.Model(&MirrorDepartment{}).
Where("dept_id = ?", deptID).
Update("group_id", groupID).Error
}
// SetUserAttrsHash 记录某用户上次写入 LLDAP 的属性指纹,供漂移比对与展示。
func (s *Store) SetUserAttrsHash(uid, hash string) error {
return s.db.Model(&MirrorUser{}).
Where("uid = ?", uid).
Update("attrs_hash", hash).Error
}
// UserDeptIDs 返回某用户所属的部门 ID 列表。
func (s *Store) UserDeptIDs(uid string) ([]string, error) {
var rels []MirrorUserDepartment