Files
idsync/internal/feishu/client.go
T
caowangrenbo 947b1a1475 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/)不入库。
2026-09-29 00:10:59 +08:00

148 lines
3.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
})
}