Files
caowangrenbo 947b1a1475 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/)不入库。
2026-09-29 00:10:59 +08:00

452 lines
12 KiB
Go

package feishu
import (
"context"
"fmt"
"sort"
"strings"
larkcontact "github.com/larksuite/oapi-sdk-go/v3/service/contact/v3"
)
// maxItems 是单次读取允许的最大条目数,用于防御分页异常。
// 一个租户的部门或成员数量远超此值时必然意味着接口行为异常
// (例如服务端在 has_more=false 时仍返回非空游标导致原地打转),
// 宁可直接失败也不要无限翻页拖垮同步。
const maxItems = 200000
// Department 是从飞书读取的部门。
type Department struct {
ID string // open_department_id
ParentID string
Name string
// Order 是同级排序,用于稳定输出
Order int
}
// User 是从飞书读取的用户,字段已规范化为可空字符串。
type User struct {
UnionID string
UserID string
OpenID string
Name string
EnName string
EnterpriseEmail string
Email string
Mobile string
EmployeeNo string
JobTitle string
WorkStation string
DepartmentIDs []string // open_department_id 列表
}
// Resigned 表示飞书确认已离职的用户。
type Resigned struct {
UnionID string
UserID string
Name string
}
// Snapshot 是一次完整读取的结果。
type Snapshot struct {
Departments []*Department
Users []*User
// ResignedUsers 仅在显式检查时填充(见 CheckResigned)。
ResignedUsers []*Resigned
// SkippedNoEmail 记录因缺少企业邮箱而无法生成登录名的用户。
// 这些用户不会被同步,但必须在界面上显式暴露,避免“静默丢失”。
SkippedNoEmail []*User
// SkippedDuplicate 记录企业邮箱前缀与他人冲突的用户(uid, 姓名)。
SkippedDuplicate []DuplicateConflict
}
// DuplicateConflict 描述一次登录名冲突。
type DuplicateConflict struct {
UID string
Names []string
}
// Fetch 拉取部门与用户,构建期望状态快照。
//
// 如果 onlyDepts 非空,则只同步列出的部门;前缀 ^ 表示排除该部门。
func (c *Client) Fetch(ctx context.Context, onlyDepts []string) (*Snapshot, error) {
depts, err := c.fetchDepartments(ctx)
if err != nil {
return nil, err
}
depts = filterDepartments(depts, onlyDepts)
users, skippedNoEmail, dups, err := c.fetchUsers(ctx, depts)
if err != nil {
return nil, err
}
return &Snapshot{
Departments: depts,
Users: users,
SkippedNoEmail: skippedNoEmail,
SkippedDuplicate: dups,
}, nil
}
// fetchDepartments 拉取全量部门(含子部门)。
//
// 使用 ParentDepartmentId("0") + FetchChild(true) 一次性取回整棵树,
// 避免像旧实现那样逐部门递归调用造成 O(部门数) 次请求。
func (c *Client) fetchDepartments(ctx context.Context) ([]*Department, error) {
var out []*Department
err := c.call(ctx, "获取部门列表", func(ctx context.Context) error {
iter, err := c.lark.Contact.V3.Department.ListByIterator(ctx,
larkcontact.NewListDepartmentReqBuilder().
ParentDepartmentId("0").
FetchChild(true).
DepartmentIdType(deptIDType).
PageSize(50).
Build())
if err != nil {
return err
}
out = out[:0]
for {
ok, dept, err := iter.Next()
if err != nil {
return err
}
if !ok {
return nil
}
if dept == nil || dept.OpenDepartmentId == nil {
continue
}
if len(out) >= maxItems {
return fmt.Errorf("部门数量超过上限 %d,疑似分页异常", maxItems)
}
d := &Department{
ID: deref(dept.OpenDepartmentId),
ParentID: deref(dept.ParentDepartmentId),
Name: deref(dept.Name),
}
if dept.Order != nil {
if n, err := parseInt(dept.Order); err == nil {
d.Order = n
}
}
out = append(out, d)
}
})
if err != nil {
return nil, err
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].Order != out[j].Order {
return out[i].Order < out[j].Order
}
return out[i].ID < out[j].ID
})
return out, nil
}
// fetchUsers 遍历部门拉取用户并按 union_id 去重。
//
// 为什么按部门遍历而不是用 Email 批量查询:官方 SDK 明确说明
// BatchGetId 的 emails 参数不支持企业邮箱,而我们的登录名正是企业邮箱前缀,
// 因此只能以部门为入口枚举,再按 union_id 去重(同一用户可属于多个部门)。
func (c *Client) fetchUsers(ctx context.Context, depts []*Department) (
users []*User, skippedNoEmail []*User, dups []DuplicateConflict, err error,
) {
byUnionID := make(map[string]*User)
seenUID := make(map[string][]string) // uid -> 姓名列表,用于检测冲突
for _, d := range depts {
err := c.call(ctx, "获取部门 "+d.ID+" 的用户", func(ctx context.Context) error {
iter, err := c.lark.Contact.V3.User.FindByDepartmentByIterator(ctx,
larkcontact.NewFindByDepartmentUserReqBuilder().
DepartmentId(d.ID).
DepartmentIdType(deptIDType).
UserIdType(userIDType).
PageSize(50).
Build())
if err != nil {
return err
}
for {
ok, u, err := iter.Next()
if err != nil {
return err
}
if !ok {
return nil
}
if u == nil || u.UnionId == nil {
continue
}
if len(byUnionID) >= maxItems {
return fmt.Errorf("用户数量超过上限 %d,疑似分页异常", maxItems)
}
// FindByDepartment 只返回在职人员,但仍做一次防御性过滤。
if u.Status != nil && u.Status.IsResigned != nil && *u.Status.IsResigned {
continue
}
uid := deref(u.UnionId)
if existing, ok := byUnionID[uid]; ok {
// 同一用户在多部门出现:合并部门列表,而不是覆盖。
existing.DepartmentIDs = mergeUnique(existing.DepartmentIDs, derefDepts(u.DepartmentIds))
continue
}
nu := &User{
UnionID: uid,
UserID: deref(u.UserId),
OpenID: deref(u.OpenId),
Name: deref(u.Name),
EnName: deref(u.EnName),
EnterpriseEmail: deref(u.EnterpriseEmail),
Email: deref(u.Email),
Mobile: deref(u.Mobile),
EmployeeNo: deref(u.EmployeeNo),
JobTitle: deref(u.JobTitle),
WorkStation: deref(u.WorkStation),
DepartmentIDs: derefDepts(u.DepartmentIds),
}
byUnionID[uid] = nu
}
})
if err != nil {
return nil, nil, nil, err
}
}
// 校验登录名可用性:必须有企业邮箱,且前缀不与他人冲突。
for _, u := range byUnionID {
uid, ok := loginName(u)
if !ok {
skippedNoEmail = append(skippedNoEmail, u)
continue
}
seenUID[uid] = append(seenUID[uid], displayName(u))
}
conflicted := make(map[string]bool)
for uid, names := range seenUID {
if len(names) > 1 {
conflicted[uid] = true
dups = append(dups, DuplicateConflict{UID: uid, Names: names})
}
}
sort.SliceStable(dups, func(i, j int) bool { return dups[i].UID < dups[j].UID })
for _, u := range byUnionID {
uid, ok := loginName(u)
if !ok || conflicted[uid] {
continue // 已在 skippedNoEmail / dups 中报告
}
users = append(users, u)
}
sort.SliceStable(users, func(i, j int) bool {
return loginName2(users[i]) < loginName2(users[j])
})
return users, skippedNoEmail, dups, nil
}
// CheckResigned 对给定的 union_id 列表确认其是否确实离职。
//
// 背景:FindByDepartment 不返回离职人员,所以“离职”只能通过缺席推断。
// 但缺席也可能是权限范围变更或部门调整导致的假阳性,因此对候选中的人
// 逐个调用 Get 读取真实的 status.is_resigned,确认后才停用。
func (c *Client) CheckResigned(ctx context.Context, unionIDs []string) ([]*Resigned, error) {
var out []*Resigned
for _, uid := range unionIDs {
var got *Resigned
err := c.call(ctx, "确认用户状态 "+uid, func(ctx context.Context) error {
resp, err := c.lark.Contact.V3.User.Get(ctx,
larkcontact.NewGetUserReqBuilder().
UserId(uid).
UserIdType(userIDType).
Build())
if err != nil {
return err
}
if !resp.Success() {
return fmt.Errorf("飞书返回错误: code=%d msg=%s", resp.Code, resp.Msg)
}
if resp.Data == nil || resp.Data.User == nil {
return nil
}
u := resp.Data.User
if u.Status != nil && u.Status.IsResigned != nil && *u.Status.IsResigned {
got = &Resigned{UnionID: uid, UserID: deref(u.UserId), Name: deref(u.Name)}
}
return nil
})
if err != nil {
return nil, err
}
if got != nil {
out = append(out, got)
}
}
return out, nil
}
// LoginName 导出登录名(企业邮箱 @ 前缀)。
func LoginName(u *User) (string, bool) { return loginName(u) }
// DisplayName 导出用于展示的名字,找不到时回落到登录名。
func DisplayName(u *User) string { return displayName(u) }
// loginName 从企业邮箱推导登录名。
//
// 刻意不使用个人邮箱兜底:企业邮箱是组织内唯一且受控的标识,
// 回落到个人邮箱会产生不可预期、且在离职后仍可用的登录名。
func loginName(u *User) (string, bool) {
email := strings.TrimSpace(u.EnterpriseEmail)
if email == "" {
return "", false
}
at := strings.Index(email, "@")
if at <= 0 {
return "", false
}
name := strings.TrimSpace(email[:at])
if name == "" {
return "", false
}
return strings.ToLower(name), true
}
func displayName(u *User) string {
if strings.TrimSpace(u.Name) != "" {
return u.Name
}
return u.EnName
}
func loginName2(u *User) string {
n, _ := loginName(u)
return n
}
// filterDepartments 按配置裁剪部门集合。
//
// 规则与旧实现保持一致:留空表示全量;列出的部门及其子部门被包含;
// 前缀 ^ 表示排除该部门及其子部门。
func filterDepartments(all []*Department, want []string) []*Department {
if len(want) == 0 {
return all
}
byID := make(map[string]*Department, len(all))
children := make(map[string][]*Department)
for _, d := range all {
byID[d.ID] = d
children[d.ParentID] = append(children[d.ParentID], d)
}
var include, exclude []string
for _, w := range want {
w = strings.TrimSpace(w)
if w == "" {
continue
}
if strings.HasPrefix(w, "^") {
exclude = append(exclude, strings.TrimPrefix(w, "^"))
} else {
include = append(include, w)
}
}
keep := make(map[string]bool)
var mark func(id string)
mark = func(id string) {
if keep[id] {
return
}
if d, ok := byID[id]; ok {
keep[d.ID] = true
}
for _, ch := range children[id] {
mark(ch.ID)
}
}
var unmark func(id string)
unmark = func(id string) {
delete(keep, id)
for _, ch := range children[id] {
unmark(ch.ID)
}
}
if len(include) > 0 {
for _, id := range include {
mark(id)
}
} else {
for _, d := range all {
keep[d.ID] = true
}
}
for _, id := range exclude {
unmark(id)
}
out := make([]*Department, 0, len(all))
for _, d := range all {
if keep[d.ID] {
out = append(out, d)
}
}
return out
}
func deref(s *string) string {
if s == nil {
return ""
}
return *s
}
func derefDepts(in []string) []string {
if len(in) == 0 {
return nil
}
out := make([]string, 0, len(in))
for _, v := range in {
if v != "" {
out = append(out, v)
}
}
return out
}
// mergeUnique 合并两个字符串切片并去重,保持原有顺序。
func mergeUnique(a, b []string) []string {
seen := make(map[string]bool, len(a)+len(b))
out := make([]string, 0, len(a)+len(b))
for _, list := range [][]string{a, b} {
for _, v := range list {
if v == "" || seen[v] {
continue
}
seen[v] = true
out = append(out, v)
}
}
return out
}
func parseInt(s *string) (int, error) {
var n int
_, err := fmt.Sscanf(deref(s), "%d", &n)
return n, err
}