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:
2026-09-29 00:10:59 +08:00
commit 947b1a1475
43 changed files with 10079 additions and 0 deletions
+147
View File
@@ -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
})
}