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:
@@ -0,0 +1,451 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user