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