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,147 @@
|
||||
// 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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user