// Package feishu 封装飞书通讯录的读取,产出与 LDAP 无关的领域模型。 // // 这里刻意只做“读”与规范化,不做任何映射或写入决策:把飞书的原始事实 // 转成干净的结构,diff 引擎再拿它与 LDAP 现状比较。 package feishu import ( "context" "fmt" "log/slog" "sync" "time" lark "github.com/larksuite/oapi-sdk-go/v3" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" larkcontact "github.com/larksuite/oapi-sdk-go/v3/service/contact/v3" ) // 部门 ID 类型与用户 ID 类型固定使用 open_department_id / union_id: // 前者在租户内全局稳定且不随自定义 ID 变化,后者跨应用一致,适合做去重键。 const ( deptIDType = "open_department_id" userIDType = "union_id" ) // Client 是对官方 SDK 的薄封装,负责限速、超时与重试。 type Client struct { lark *lark.Client log *slog.Logger timeout time.Duration // ticker 实现一个简单的令牌节奏限速,避免触发飞书侧频控。 mu sync.Mutex lastAt time.Time period time.Duration } // Options 是构造 Client 所需的参数。 type Options struct { AppID string AppSecret string BaseURL string QPS int Timeout time.Duration } // New 构造飞书客户端。 func New(opt Options, log *slog.Logger) (*Client, error) { if opt.AppID == "" || opt.AppSecret == "" { return nil, fmt.Errorf("飞书 app-id / app-secret 未配置") } if opt.QPS <= 0 { opt.QPS = 5 } if opt.Timeout <= 0 { opt.Timeout = 30 * time.Second } opts := []lark.ClientOptionFunc{ lark.WithReqTimeout(opt.Timeout), lark.WithLogLevel(larkcore.LogLevelError), } if opt.BaseURL != "" { opts = append(opts, lark.WithOpenBaseUrl(opt.BaseURL)) } return &Client{ lark: lark.NewClient(opt.AppID, opt.AppSecret, opts...), log: log, timeout: opt.Timeout, period: time.Second / time.Duration(opt.QPS), }, nil } // wait 在两次外部调用之间维持最小间隔,实现全局限速。 func (c *Client) wait(ctx context.Context) { c.mu.Lock() now := time.Now() gap := c.period - now.Sub(c.lastAt) if gap > 0 { c.lastAt = now.Add(gap) } else { c.lastAt = now } c.mu.Unlock() if gap > 0 { select { case <-time.After(gap): case <-ctx.Done(): } } } // call 统一处理限速、超时与有限次数的指数退避重试。 // // 只重试看起来是暂时性的错误(超时、连接问题、5xx);权限或参数错误立即返回, // 重试它们只会放大问题并拖慢整轮同步。 func (c *Client) call(ctx context.Context, what string, fn func(ctx context.Context) error) error { const maxAttempts = 3 var err error for attempt := 1; attempt <= maxAttempts; attempt++ { c.wait(ctx) callCtx, cancel := context.WithTimeout(ctx, c.timeout) err = fn(callCtx) cancel() if err == nil { return nil } if !isRetryable(err) || attempt == maxAttempts { return fmt.Errorf("%s 失败: %w", what, err) } backoff := time.Duration(attempt) * 500 * time.Millisecond c.log.Warn("飞书调用失败,准备重试", "op", what, "attempt", attempt, "backoff", backoff, "err", err) select { case <-time.After(backoff): case <-ctx.Done(): return ctx.Err() } } return fmt.Errorf("%s 失败: %w", what, err) } // Test 做一次轻量的连通性检查:拉取根部门下的一页部门。 func (c *Client) Test(ctx context.Context) error { return c.call(ctx, "连通性测试", func(ctx context.Context) error { resp, err := c.lark.Contact.V3.Department.List(ctx, larkcontact.NewListDepartmentReqBuilder(). ParentDepartmentId("0"). DepartmentIdType(deptIDType). PageSize(1). Build()) if err != nil { return err } if !resp.Success() { return fmt.Errorf("飞书返回错误: code=%d msg=%s", resp.Code, resp.Msg) } return nil }) }