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 }