飞书通讯录 ↔ 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/)不入库。
452 lines
12 KiB
Go
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
|
|
}
|