chore: 初始化 idsync 项目

飞书通讯录 ↔ LDAP 同步工具的首个提交。

- internal/feishu: 飞书通讯录客户端、快照与重试
- internal/ldaplib: LDAP 读写与批量操作
- internal/mapping: 飞书部门/用户到 LDAP 条目的映射
- internal/syncer: 同步计划生成、执行与调度
- internal/store: SQLite 台账
- internal/server + internal/webui: 管理后台与内嵌前端产物
- internal/password: SSHA 口令处理
- config.example.yml / Dockerfile / Makefile / web 前端源码

真实凭据(config.yml)、运行时数据(data/、backup/)与构建产物(bin/)不入库。
This commit is contained in:
2026-09-29 00:10:59 +08:00
commit 947b1a1475
43 changed files with 10079 additions and 0 deletions
+354
View File
@@ -0,0 +1,354 @@
// 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 ""
}