diff --git a/internal/config/confirm.go b/internal/config/confirm.go new file mode 100644 index 0000000..967fa64 --- /dev/null +++ b/internal/config/confirm.go @@ -0,0 +1,24 @@ +package config + +import ( + "net/url" + "strings" +) + +// ResignConfirmRoute 是一次性离职确认端点的路由。 +// +// 它被两处引用:server 用它注册路由(且此路由不经 Auth 中间件,token 即凭证), +// 快照 runner 用它拼接飞书卡片按钮的跳转 URL。放在 config 包而非 server/feishu, +// 是为了避免 server↔syncer 的循环依赖。 +const ResignConfirmRoute = "/api/resign/confirm" + +// ConfirmURL 用对外 base-url、uid 与一次性 token 拼出确认端点地址。 +// +// token 内嵌在链接里,因此该链接必须具备一次性、过期等约束——这正是 +// store 的 ResignConfirmToken 提供的语义,而不是仅靠链接难以猜测。 +func ConfirmURL(baseURL, uid, token string) string { + q := url.Values{} + q.Set("uid", uid) + q.Set("token", token) + return strings.TrimRight(baseURL, "/") + ResignConfirmRoute + "?" + q.Encode() +} \ No newline at end of file diff --git a/internal/feishu/card.go b/internal/feishu/card.go new file mode 100644 index 0000000..0aace7c --- /dev/null +++ b/internal/feishu/card.go @@ -0,0 +1,120 @@ +package feishu + +// 离职确认卡片:通过自定义机器人 webhook 发给用户管理群。 +// +// 这张卡片是本工具对外发送的**唯一**主动通知,属于同步业务流程而非认证链路。 +// 卡片按钮不依赖开放平台的卡片回调回执(那需要额外的事件订阅配置),而是把 +// 程序自己的一次性 HTTP 确认端点地址放进按钮 url——用户点击后跳到该端点, +// token 校验通过才把用户置为 confirmed_leave。这样「点击确认」的落点在我们 +// 自己的控制之下,与 AGENTS.md 硬约束 3 一致。 + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" +) + +// ResignCard 是构造离职确认卡片所需的全部信息。 +type ResignCard struct { + // DisplayName 是用户中文名,UID 是登录名,用于在卡片里定位到人。 + DisplayName string + UID string + // Reason 是离职依据(如「飞书通讯录中已不可见,经机器确认已离职」)。 + Reason string + // ConfirmURL 是程序自身的一次性确认端点地址,按钮点击后跳转。 + ConfirmURL string +} + +// BuildResignCardPayload 构造飞书自定义机器人 interactive 卡片的 JSON 载荷。 +// +// 分离出纯构建函数,便于用本地断言验证卡片内容(姓名/uid/离职依据/确认链接) +// 而不必真实发送。按钮 url 必须是绝对的、可被公网/反代访问的地址。 +func BuildResignCardPayload(sc *ResignCard) ([]byte, error) { + if sc == nil { + return nil, fmt.Errorf("离职卡片内容为空") + } + if sc.ConfirmURL == "" { + return nil, fmt.Errorf("离职确认链接为空,无法在卡片上提供确认入口") + } + + content := fmt.Sprintf("**%s**(登录名:%s)\n\n%s", sc.DisplayName, sc.UID, sc.Reason) + + body := map[string]any{ + "msg_type": "interactive", + "card": map[string]any{ + "config": map[string]any{"wide_screen_mode": true}, + "header": map[string]any{ + "template": "orange", + "title": map[string]any{"tag": "plain_text", "content": "离职确认"}, + }, + "elements": []any{ + map[string]any{ + "tag": "div", + "text": map[string]any{"tag": "lark_md", "content": content}, + }, + map[string]any{ + "tag": "action", + "actions": []any{ + map[string]any{ + "tag": "button", + "text": map[string]any{"tag": "plain_text", "content": "确认离职"}, + "type": "primary", + "url": sc.ConfirmURL, + }, + }, + }, + }, + }, + } + return json.Marshal(body) +} + +// SendResignCard 把卡片载荷发送到自定义机器人 webhook,校验飞书侧返回值。 +// +// 这个发送不需要应用凭据——自定义机器人由 webhook 地址本身鉴权,所以做成一个 +// 独立函数而非 Client 方法,测试时只需把 webhookURL 指向本地假 http 端点即可。 +func SendResignCard(ctx context.Context, webhookURL string, payload []byte) error { + url := strings.TrimSpace(webhookURL) + if url == "" { + return fmt.Errorf("未配置飞书离职提醒 webhook 地址") + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) + if err != nil { + return fmt.Errorf("构造离职确认卡片请求失败: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("发送离职确认卡片失败: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("发送离职确认卡片失败: webhook 返回 HTTP %d", resp.StatusCode) + } + + // 飞书 webhook 成功返回 {"StatusCode":0,...};个别变体用 {"code":0,...}。 + var out struct { + Code int `json:"code"` + StatusCode int `json:"StatusCode"` + StatusMessage string `json:"StatusMessage"` + Msg string `json:"msg"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + // 空 body 或非 JSON 时按成功处理——某些代理会吞掉响应体, + // 只要 HTTP 200 就视为投递成功,否则会把本可成功的卡片误报为失败。 + return nil + } + if out.Code != 0 || out.StatusCode != 0 { + return fmt.Errorf("发送离职确认卡片失败: webhook 返回 code=%d status=%d msg=%s %s", + out.Code, out.StatusCode, out.StatusMessage, out.Msg) + } + return nil +} \ No newline at end of file diff --git a/internal/feishu/card_test.go b/internal/feishu/card_test.go new file mode 100644 index 0000000..17dc1f5 --- /dev/null +++ b/internal/feishu/card_test.go @@ -0,0 +1,134 @@ +package feishu + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func testCard() *ResignCard { + return &ResignCard{ + DisplayName: "张三", + UID: "zhangsan", + Reason: "飞书通讯录中已不可见,经机器确认已离职。", + ConfirmURL: "https://confirm.example.com/api/resign/confirm?uid=zhangsan&token=abc123", + } +} + +// TestBuildResignCardPayload 验证卡片载荷基本结构完整,且包含了姓名/uid/离职依据/确认链接。 +func TestBuildResignCardPayload(t *testing.T) { + raw, err := BuildResignCardPayload(testCard()) + if err != nil { + t.Fatalf("构造卡片载荷失败: %v", err) + } + + var doc struct { + MsgType string `json:"msg_type"` + Card struct { + Header struct { + Title struct { + Content string `json:"content"` + } `json:"title"` + } `json:"header"` + Elements []map[string]any `json:"elements"` + } `json:"card"` + } + if err := json.Unmarshal(raw, &doc); err != nil { + t.Fatalf("卡片载荷不是合法 JSON: %v", err) + } + + if doc.MsgType != "interactive" { + t.Errorf("msg_type = %q, 期望 interactive", doc.MsgType) + } + if doc.Card.Header.Title.Content != "离职确认" { + t.Errorf("卡片标题 = %q, 期望 离职确认", doc.Card.Header.Title.Content) + } + + // 找到 div 里的正文与 action 里的确认按钮。 + joined := string(raw) + for _, want := range []string{"张三", "zhangsan", "飞书通讯录中已不可见", "https://confirm.example.com"} { + if !strings.Contains(joined, want) { + t.Errorf("卡片载荷缺少关键内容 %q", want) + } + } +} + +// TestBuildResignCardPayloadRejectsMissingConfirmURL 验证没有确认链接时不构造卡片。 +// 卡片按钮是该流程的唯一确认入口,缺失链接等于拿掉半道闸,必须拒绝。 +func TestBuildResignCardPayloadRejectsMissingConfirmURL(t *testing.T) { + c := testCard() + c.ConfirmURL = "" + if _, err := BuildResignCardPayload(c); err == nil { + t.Errorf("缺少确认链接时不应构造卡片,却成功了") + } +} + +// TestSendResignCardSuccess 验证向本地假 webhook 发送成功并携带正确载荷。 +func TestSendResignCardSuccess(t *testing.T) { + var received []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("请求方法 = %s, 期望 POST", r.Method) + } + if ct := r.Header.Get("Content-Type"); !strings.Contains(ct, "application/json") { + t.Errorf("Content-Type = %q", ct) + } + received, _ = io.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"StatusCode":0,"StatusMessage":"success"}`)) + })) + defer srv.Close() + + payload, _ := BuildResignCardPayload(testCard()) + if err := SendResignCard(context.Background(), srv.URL, payload); err != nil { + t.Fatalf("发送失败: %v", err) + } + if len(received) == 0 { + t.Errorf("webhook 未收到请求体") + } + if string(received) != string(payload) { + t.Errorf("webhook 收到的载荷与构造的不一致") + } +} + +// TestSendResignCardWebhookError 验证 webhook 返回错误码时上报失败。 +func TestSendResignCardWebhookError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"code":9499,"msg":"openapi signature error"}`)) + })) + defer srv.Close() + + payload, _ := BuildResignCardPayload(testCard()) + err := SendResignCard(context.Background(), srv.URL, payload) + if err == nil { + t.Fatalf("webhook 返回错误码应上报失败,却成功了") + } + if !strings.Contains(err.Error(), "9499") { + t.Errorf("错误信息应包含错误码,实际: %v", err) + } +} + +// TestSendResignCardRejectsEmptyURL 验证未配置 webhook 地址时报错。 +func TestSendResignCardRejectsEmptyURL(t *testing.T) { + payload, _ := BuildResignCardPayload(testCard()) + if err := SendResignCard(context.Background(), "", payload); err == nil { + t.Errorf("空 webhook 地址应报错,却成功了") + } +} + +// TestSendResignCardRejectsNon2xx 验证 HTTP 非 200 时上报失败。 +func TestSendResignCardRejectsNon2xx(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + })) + defer srv.Close() + + payload, _ := BuildResignCardPayload(testCard()) + if err := SendResignCard(context.Background(), srv.URL, payload); err == nil { + t.Errorf("HTTP 400 应上报失败,却成功了") + } +} \ No newline at end of file diff --git a/internal/mapping/flat.go b/internal/mapping/flat.go new file mode 100644 index 0000000..3d64c79 --- /dev/null +++ b/internal/mapping/flat.go @@ -0,0 +1,79 @@ +package mapping + +// 部门组的拍平命名与 DN 构造。 +// +// LLDAP 组不能嵌套(组里只能有用户,没有父/子组概念),因此部门树必须拍平: +// 组名 = 父链拼音点分拼接(如 cn=yanfa.pingtai)。拼音为空时回落到部门 ID 保证 +// 非空稳定。组名天然唯一(部门树路径唯一),重名时追加部门 ID 消歧。 +// +// 这些 DN 在快照落库阶段一次性算好并持久化到 mirror_departments,供物化阶段 +// 直接复用,也让 WebUI 能展示每个部门的预期 DN。 + +import ( + "strings" + + "idsync/internal/feishu" +) + +// FlatDept 是一个部门的拍平命名结果。 +type FlatDept struct { + DeptID string + // Chain 是拍平后的拼音链,如 "yanfa.pingtai"。 + Chain string + // DN 是完整组 DN,如 "cn=yanfa.pingtai,ou=groups,"。 + DN string + // Level 是部门在树中的层级,根为 0。 + Level int +} + +// GroupRootDN 返回部门组根 OU 的完整 DN。LLDAP 分组固定挂在 ou=groups 下。 +func GroupRootDN(baseDN string) string { + return "ou=groups," + strings.TrimSpace(baseDN) +} + +// FlatDeptInfo 把部门树一次性拍平为拼音点分命名并计算每股的 DN。 +// +// 输入是快照里的全量部门;返回 dept_id → FlatDept。父先于子顺序处理, +// 因此计算子部门时可复用父部门的 Chain。纯函数,不触碰 IO。 +func FlatDeptInfo(depts []*feishu.Department, baseDN string) map[string]*FlatDept { + byID := make(map[string]*feishu.Department, len(depts)) + for _, d := range depts { + byID[d.ID] = d + } + ordered := orderByDepth(depts, byID) + + root := GroupRootDN(baseDN) + out := make(map[string]*FlatDept, len(depts)) + used := make(map[string]string, len(depts)) // chain -> 占用它的 dept_id + + for _, d := range ordered { + prefix, parentLevel := "", -1 + if p, ok := out[d.ParentID]; ok { + prefix = p.Chain + parentLevel = p.Level + } + level := parentLevel + 1 + + // 单个部门的拼音段;拼音为空时回落部门 ID(cnName 保证非空)。 + seg := cnName(d.Name, d.ID) + chain := seg + if prefix != "" { + chain = prefix + "." + seg + } + + // 重名消歧:同一层出现相同拼音(如两个「开发组」)会让 Chain 撞车, + // 组名必须唯一,因此在链尾追加部门 ID 片段。 + if other, taken := used[chain]; taken && other != d.ID { + chain += "." + d.ID + } + used[chain] = d.ID + + out[d.ID] = &FlatDept{ + DeptID: d.ID, + Chain: chain, + DN: "cn=" + chain + "," + root, + Level: level, + } + } + return out +} \ No newline at end of file diff --git a/internal/syncer/snapshot.go b/internal/syncer/snapshot.go new file mode 100644 index 0000000..f6d5c8f --- /dev/null +++ b/internal/syncer/snapshot.go @@ -0,0 +1,336 @@ +package syncer + +// 快照 runner:拉取飞书全量 → 事务写入 SQLite 镜像 → 离职生命周期 reconcile。 +// +// 这是两阶段架构的「第一段」:它只把「飞书当前有什么」落到镜像,并把离职流程 +// 推进到「已发卡片待人工确认」为止。物化(写 LLDAP)由另一条链路承担,本 runner +// 不碰 LLDAP,也不做任何写入决策。 + +import ( + "context" + "fmt" + "log/slog" + "sort" + "strings" + "time" + + "idsync/internal/config" + "idsync/internal/feishu" + "idsync/internal/mapping" + "idsync/internal/store" +) + +// SnapshotOptions 是快照 runner 的运行参数。 +type SnapshotOptions struct { + // WebhookURL 是离职确认卡片的自定义机器人地址。 + WebhookURL string + // ConfirmBaseURL 是确认端点的对外 base-url(可被公网/反代访问)。 + ConfirmBaseURL string + // ReNotifyInterval 是离职提醒的最短间隔,缺省 24h,防刷屏。 + ReNotifyInterval time.Duration + // TokenTTL 是一次性确认 token 的有效期,缺省 72h。 + TokenTTL time.Duration +} + +func (o SnapshotOptions) withDefaults() SnapshotOptions { + if o.ReNotifyInterval <= 0 { + o.ReNotifyInterval = 24 * time.Hour + } + if o.TokenTTL <= 0 { + o.TokenTTL = 72 * time.Hour + } + return o +} + +// SnapshotRunResult 是一次快照执行的统计结果。 +type SnapshotRunResult struct { + Users int + Departments int + Skipped int + LeaveCandidates int + PendingConfirm int + Reinstated int + CardsSent int + Warnings []string +} + +// SnapshotRunner 编排快照流程。 +type SnapshotRunner struct { + cfg *config.Config + feishu *feishu.Client + store *store.Store + log *slog.Logger + opts SnapshotOptions +} + +// NewSnapshotRunner 构造快照 runner。 +func NewSnapshotRunner(cfg *config.Config, f *feishu.Client, st *store.Store, log *slog.Logger, opts SnapshotOptions) *SnapshotRunner { + return &SnapshotRunner{cfg: cfg, feishu: f, store: st, log: log, opts: opts.withDefaults()} +} + +// RunSnapshot 执行一轮快照:拉取、落库、推进离职流程。 +func (r *SnapshotRunner) RunSnapshot(ctx context.Context) (*SnapshotRunResult, error) { + snap, err := r.feishu.Fetch(ctx, r.cfg.Feishu.DeptList) + if err != nil { + return nil, fmt.Errorf("读取飞书快照失败: %w", err) + } + r.log.Info("已读取飞书快照", + "departments", len(snap.Departments), "users", len(snap.Users), + "skipped_no_email", len(snap.SkippedNoEmail), "duplicates", len(snap.SkippedDuplicate)) + + mirror := r.buildMirror(snap) + if err := r.store.ReplaceMirror(mirror); err != nil { + return nil, fmt.Errorf("写入 SQLite 镜像失败: %w", err) + } + + present := make(map[string]bool, len(mirror.Users)) + for _, u := range mirror.Users { + present[u.UID] = true + } + + res := &SnapshotRunResult{ + Users: len(mirror.Users), + Departments: len(snap.Departments), + Skipped: len(mirror.Skipped), + } + if err := r.reconcile(ctx, present, res); err != nil { + return res, err + } + return res, nil +} + +// buildMirror 把飞书快照转成镜像存储层需要的输入。 +// +// 部门拍平命名与 DN 由 mapping.FlatDeptInfo 一次性算好并持久化到 +// mirror_departments,物化阶段直接复用、WebUI 亦可展示。 +func (r *SnapshotRunner) buildMirror(snap *feishu.Snapshot) *store.MirrorSnapshot { + flat := mapping.FlatDeptInfo(snap.Departments, r.cfg.Ldap.BaseDN) + deptName := make(map[string]string, len(snap.Departments)) + for _, d := range snap.Departments { + deptName[d.ID] = d.Name + } + + depts := make([]*store.MirrorDepartment, 0, len(snap.Departments)) + for _, d := range snap.Departments { + f := flat[d.ID] + depts = append(depts, &store.MirrorDepartment{ + DeptID: d.ID, Name: d.Name, PinyinName: f.Chain, DN: f.DN, + ParentID: d.ParentID, Level: f.Level, State: store.StateActive, + }) + } + + users := make([]*store.MirrorUser, 0, len(snap.Users)) + for _, u := range snap.Users { + uid, ok := feishu.LoginName(u) + if !ok { + continue // 已在 SkippedNoEmail 报告中,不必重复 + } + users = append(users, toMirrorUser(u, uid, deptName)) + } + + // 缺少企业邮箱的用户以 union_id 作为 uid 落一条 skipped 记录,供 WebUI 列出。 + skipped := make([]*store.MirrorUser, 0, len(snap.SkippedNoEmail)) + for _, u := range snap.SkippedNoEmail { + skipped = append(skipped, &store.MirrorUser{ + UID: u.UnionID, + UnionID: u.UnionID, + UserID: u.UserID, + DisplayName: feishu.DisplayName(u), + DepartmentIDs: u.DepartmentIDs, + State: store.StateActive, + }) + } + + return &store.MirrorSnapshot{Users: users, Departments: depts, Skipped: skipped} +} + +// toMirrorUser 把飞书用户域模型转成镜像用户。字段语义与 mapping 一致, +// business_category 展开为部门中文名便于人工阅读。 +func toMirrorUser(u *feishu.User, uid string, deptName map[string]string) *store.MirrorUser { + var bc []string + for _, id := range u.DepartmentIDs { + if n, ok := deptName[id]; ok && n != "" { + bc = append(bc, n) + } + } + sort.Strings(bc) + + return &store.MirrorUser{ + UID: uid, + UnionID: u.UnionID, + UserID: u.UserID, + Email: u.EnterpriseEmail, + DisplayName: firstNonEmpty(u.Name, u.EnName, uid), + GivenName: firstNonEmpty(u.EnName, u.Name, uid), + SN: firstName(u), + Mobile: u.Mobile, + EmployeeNumber: u.EmployeeNo, + DepartmentNumber: u.JobTitle, + PostalAddress: u.WorkStation, + BusinessCategory: strings.Join(bc, ","), + EmployeeType: mapping.EmployeeTypeActive, + Description: firstNonEmpty(u.Name, u.EnName, uid), + DepartmentIDs: u.DepartmentIDs, + State: store.StateActive, + } +} + +// reconcile 处理镜像里每个用户的生命周期:本轮在飞书可见者撤销离职流程; +// 缺席者在职用户做机器确认,确认后进发卡流程;已在流程内者按节流策略补发提醒。 +// +// 这里的每个状态转移都基于 store 的受校验状态机,任何非法转移会被结构性拦下。 +func (r *SnapshotRunner) reconcile(ctx context.Context, present map[string]bool, res *SnapshotRunResult) error { + all, err := r.store.AllMirrorUsers() + if err != nil { + return err + } + + for _, u := range all { + if u.Skipped { + continue + } + + if present[u.UID] { + // 本轮在飞书重新可见:撤销离职流程并作废未用的 token。 + switch u.State { + case store.StateLeaveCandidate, store.StatePendingConfirm, store.StateConfirmedLeave: + if err := r.store.RevertToActive(u.UID); err != nil { + return fmt.Errorf("撤销离职流程 uid=%s: %w", u.UID, err) + } + if err := r.store.RevokeTokensForUser(u.UID); err != nil { + return fmt.Errorf("作废离职 token uid=%s: %w", u.UID, err) + } + res.Reinstated++ + r.log.Info("用户重新可见,已撤销离职流程", "uid", u.UID) + case store.StateDeleted: + // 已物理删除又可见,物化阶段会以「重建」处理,这里只记录。 + r.log.Info("已删除用户重新可见,将按重建处理", "uid", u.UID) + } + continue + } + + // 本轮缺席。 + switch u.State { + case store.StateActive: + if err := r.handleAbsentCandidate(ctx, u, res); err != nil { + return err + } + case store.StateLeaveCandidate: + // 上轮已机器确认离职、卡片尚未发出,本轮补发。 + if err := r.sendResignCard(ctx, u); err != nil { + res.Warnings = append(res.Warnings, fmt.Sprintf("uid=%s 离职卡片发送失败,稍后重试: %v", u.UID, err)) + continue + } + res.CardsSent++ + if err := r.store.ToPendingConfirm(u.UID); err != nil { + return fmt.Errorf("转入待确认 uid=%s: %w", u.UID, err) + } + res.PendingConfirm++ + case store.StatePendingConfirm: + if err := r.renotify(ctx, u, res); err != nil { + return err + } + case store.StateConfirmedLeave, store.StateDeleted: + // 已人工确认、等待物化删除一组:不再打扰。 + } + } + return nil +} + +// handleAbsentCandidate 处理「在职但本轮缺席」的用户:先做飞书机器确认, +// 确认离职才进入发卡流程;未确认或缺 union_id 时保守不动作并告警。 +func (r *SnapshotRunner) handleAbsentCandidate(ctx context.Context, u *store.MirrorUser, res *SnapshotRunResult) error { + if u.UnionID == "" { + res.Warnings = append(res.Warnings, fmt.Sprintf("用户 %s 缺席但无 union_id,无法机器确认离职,暂不动作", u.UID)) + return nil + } + + resigned, err := r.feishu.CheckResigned(ctx, []string{u.UnionID}) + if err != nil { + res.Warnings = append(res.Warnings, fmt.Sprintf("确认 uid=%s 离职状态失败,本轮不动: %v", u.UID, err)) + return nil + } + if len(resigned) == 0 { + res.Warnings = append(res.Warnings, fmt.Sprintf("用户 %s 缺席但飞书未确认离职(可能因通讯录权限范围收窄),暂不动作", u.UID)) + return nil + } + + if err := r.store.ToLeaveCandidate(u.UID); err != nil { + return fmt.Errorf("标记离职候选 uid=%s: %w", u.UID, err) + } + res.LeaveCandidates++ + + if err := r.sendResignCard(ctx, u); err != nil { + res.Warnings = append(res.Warnings, fmt.Sprintf("uid=%s 离职卡片发送失败,稍后重试: %v", u.UID, err)) + return nil + } + res.CardsSent++ + if err := r.store.ToPendingConfirm(u.UID); err != nil { + return fmt.Errorf("转入待确认 uid=%s: %w", u.UID, err) + } + res.PendingConfirm++ + return nil +} + +// renotify 对「已待确认但长期未点」的用户按节流间隔重发提醒,避免每轮刷屏。 +// 超时后发一张新卡片并作废旧 token,卡内的新 token 重新起效。 +func (r *SnapshotRunner) renotify(ctx context.Context, u *store.MirrorUser, res *SnapshotRunResult) error { + last, err := r.store.LastPendingToken(u.UID) + if err != nil { + return err + } + if last != nil && time.Since(last.CreatedAt) < r.opts.ReNotifyInterval { + return nil // 节流:距上次提醒不足间隔,不重发、不打扰 + } + if err := r.sendResignCard(ctx, u); err != nil { + res.Warnings = append(res.Warnings, fmt.Sprintf("uid=%s 离职提醒发送失败: %v", u.UID, err)) + return nil + } + res.CardsSent++ + return nil +} + +// sendResignCard 为某用户发一张离职确认卡片:生成一次性 token(作废旧 token, +// 保证任一时刻至多一张有效的确认链接,减少不确定的凭证)、构造确认 URL、发送。 +func (r *SnapshotRunner) sendResignCard(ctx context.Context, u *store.MirrorUser) error { + // 先作废旧 token 再发新 token,避免卡片按钮连着多张「都能确认」的链接。 + if err := r.store.RevokeTokensForUser(u.UID); err != nil { + return err + } + tok, err := r.store.CreateToken(u.UID, r.opts.TokenTTL) + if err != nil { + return err + } + + confirmURL := config.ConfirmURL(r.opts.ConfirmBaseURL, u.UID, tok.Token) + payload, err := feishu.BuildResignCardPayload(&feishu.ResignCard{ + DisplayName: u.DisplayName, + UID: u.UID, + Reason: "飞书通讯录中已不可见,经机器确认已离职,请人工复核后确认。", + ConfirmURL: confirmURL, + }) + if err != nil { + return err + } + return feishu.SendResignCard(ctx, r.opts.WebhookURL, payload) +} + +// firstName 为 SN 提供非空值(与映射语义一致)。 +func firstName(u *feishu.User) string { + if n := strings.TrimSpace(u.Name); n != "" { + return n + } + if n := strings.TrimSpace(u.EnName); n != "" { + return n + } + return u.UnionID +} + +func firstNonEmpty(vals ...string) string { + for _, v := range vals { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} \ No newline at end of file diff --git a/internal/syncer/snapshot_test.go b/internal/syncer/snapshot_test.go new file mode 100644 index 0000000..b5ebe11 --- /dev/null +++ b/internal/syncer/snapshot_test.go @@ -0,0 +1,343 @@ +package syncer + +// 快照 runner 的本地链路测试:用一个假飞书 HTTP 服务 + 一个假 webhook, +// 验证「拉快照 → 落库镜像 → 离职机器确认 → 发卡片 → 状态机转移 → 节流 → 重新入职」。 +// +// 不需要真实飞书凭据,也不需要 LLDAP——快照段根本不碰 LLDAP。 + +import ( + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "idsync/internal/config" + "idsync/internal/feishu" + "idsync/internal/store" +) + +// sfDept 是假飞书里的部门。 +type sfDept struct { + openID string + parentID string + name string +} + +// sfUser 是假飞书里的用户。 +type sfUser struct { + unionID string + name string + enName string + enterpriseEm string + mobile string + employeeNo string + jobTitle string + deptIDs []string + resigned bool +} + +// sfFeishu 只实现快照链路需要的接口。 +type sfFeishu struct { + depts []sfDept + users []sfUser +} + +func (f *sfFeishu) handler() http.Handler { + mux := http.NewServeMux() + + mux.HandleFunc("/open-apis/auth/v3/tenant_access_token/internal", func(w http.ResponseWriter, r *http.Request) { + writeSf(w, map[string]any{"code": 0, "tenant_access_token": "tok", "expire": 7200}) + }) + + mux.HandleFunc("/open-apis/contact/v3/departments", func(w http.ResponseWriter, r *http.Request) { + parent := r.URL.Query().Get("parent_department_id") + if parent == "" { + parent = "0" + } + fetchChild := r.URL.Query().Get("fetch_child") == "true" + want := map[string]bool{parent: true} + if fetchChild { + for changed := true; changed; { + changed = false + for _, d := range f.depts { + if !want[d.openID] && want[d.parentID] { + want[d.openID] = true + changed = true + } + } + } + } + items := make([]map[string]any, 0) + for _, d := range f.depts { + if !want[d.openID] || d.openID == parent { + continue + } + items = append(items, map[string]any{ + "name": d.name, + "parent_department_id": d.parentID, + "open_department_id": d.openID, + }) + } + writeSf(w, map[string]any{"code": 0, "data": map[string]any{"has_more": false, "items": items}}) + }) + + mux.HandleFunc("/open-apis/contact/v3/users/find_by_department", func(w http.ResponseWriter, r *http.Request) { + deptID := r.URL.Query().Get("department_id") + items := make([]map[string]any, 0) + for _, u := range f.users { + if u.resigned { + continue + } + if !contains(u.deptIDs, deptID) { + continue + } + items = append(items, map[string]any{ + "union_id": u.unionID, + "name": u.name, + "en_name": u.enName, + "enterprise_email": u.enterpriseEm, + "mobile": u.mobile, + "employee_no": u.employeeNo, + "job_title": u.jobTitle, + "department_ids": u.deptIDs, + }) + } + writeSf(w, map[string]any{"code": 0, "data": map[string]any{"has_more": false, "items": items}}) + }) + + mux.HandleFunc("/open-apis/contact/v3/users/", func(w http.ResponseWriter, r *http.Request) { + parts := strings.Split(strings.TrimRight(r.URL.Path, "/"), "/") + unionID := parts[len(parts)-1] + for _, u := range f.users { + if u.unionID != unionID { + continue + } + writeSf(w, map[string]any{"code": 0, "data": map[string]any{"user": map[string]any{ + "union_id": unionID, + "name": u.name, + "status": map[string]any{"is_resigned": u.resigned}, + }}}) + return + } + writeSf(w, map[string]any{"code": 10001, "msg": "user not found"}) + }) + + return mux +} + +func writeSf(w http.ResponseWriter, body any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(body) +} + +func contains(list []string, s string) bool { + for _, v := range list { + if v == s { + return true + } + } + return false +} + +// fakeWebhook 是假 webhook,统计收到卡片的次数并返回成功。 +type fakeWebhook struct { + posts int + body []byte + srv *httptest.Server +} + +func newFakeWebhook() *fakeWebhook { + fw := &fakeWebhook{} + fw.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fw.posts++ + fw.body, _ = io.ReadAll(r.Body) + _, _ = w.Write([]byte(`{"StatusCode":0,"StatusMessage":"success"}`)) + })) + return fw +} + +func (fw *fakeWebhook) Close() { fw.srv.Close() } + +// snapshotHarness 组装快照 runner 所需的整套环境。 +type snapshotHarness struct { + fs *sfFeishu + webhook *fakeWebhook + store *store.Store + runner *SnapshotRunner + cfg *config.Config +} + +func newSnapshotHarness(t *testing.T) *snapshotHarness { + t.Helper() + + fs := &sfFeishu{depts: []sfDept{ + {openID: "od-acme", parentID: "0", name: "Acme"}, + {openID: "od-yanfa", parentID: "od-acme", name: "yanfa"}, + }} + fs.users = []sfUser{ + {unionID: "u-alice", name: "Zhang San", enName: "Alice", enterpriseEm: "alice@example.com", deptIDs: []string{"od-yanfa"}}, + {unionID: "u-bob", name: "Li Ming", enName: "Bob", enterpriseEm: "bob@example.com", deptIDs: []string{"od-yanfa"}}, + } + srv := httptest.NewServer(fs.handler()) + + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + fc, err := feishu.New(feishu.Options{ + AppID: "fake", AppSecret: "fake", BaseURL: srv.URL, QPS: 100, Timeout: 10 * time.Second, + }, log) + if err != nil { + t.Fatalf("构造飞书客户端失败: %v", err) + } + + dbPath := filepath.Join(t.TempDir(), "idsync.db") + st, err := store.Open(dbPath) + if err != nil { + t.Fatalf("打开 store 失败: %v", err) + } + + cfg := &config.Config{ + Feishu: config.Feishu{AppID: "fake", AppSecret: "fake", QPS: 100, TimeoutSec: 10}, + Ldap: config.Ldap{BaseDN: "dc=example,dc=com"}, + } + + webhook := newFakeWebhook() + runner := NewSnapshotRunner(cfg, fc, st, log, SnapshotOptions{ + WebhookURL: webhook.srv.URL, + ConfirmBaseURL: "https://confirm.example.com", + ReNotifyInterval: 24 * time.Hour, + TokenTTL: 72 * time.Hour, + }) + + t.Cleanup(func() { + srv.Close() + webhook.Close() + _ = st.Close() + }) + + return &snapshotHarness{fs: fs, webhook: webhook, store: st, runner: runner, cfg: cfg} +} + +func (h *snapshotHarness) run(t *testing.T) *SnapshotRunResult { + t.Helper() + res, err := h.runner.RunSnapshot(t.Context()) + if err != nil { + t.Fatalf("RunSnapshot 失败: %v", err) + } + return res +} + +func userState(t *testing.T, h *snapshotHarness, uid string) string { + t.Helper() + m, err := h.store.MapUsersByUID() + if err != nil { + t.Fatalf("读取镜像失败: %v", err) + } + u, ok := m[uid] + if !ok { + t.Fatalf("镜像中不存在 uid=%s", uid) + } + return u.State +} + +// TestSnapshotLeaveLifecycle 覆盖完整链路:快照落库 → 离职机器确认 → 发卡片 → +// 状态机转移 → 节流不刷屏 → 重新入职撤销离职。 +func TestSnapshotLeaveLifecycle(t *testing.T) { + h := newSnapshotHarness(t) + + // 第 1 轮:全员在职,只落库,不发卡片、不产生离职候选。 + res := h.run(t) + if res.CardsSent != 0 || res.LeaveCandidates != 0 || res.PendingConfirm != 0 { + t.Errorf("首轮不应触发离职流程: %+v", res) + } + if res.Users != 2 { + t.Errorf("第 1 轮应落 2 个用户,实际 %d", res.Users) + } + if userState(t, h, "bob") != store.StateActive { + t.Errorf("bob 初始应为 active,实际 %q", userState(t, h, "bob")) + } + + // 部门镜像应持久化拍平拼音与 DN。 + dm, err := h.store.MapDepartmentsByID() + if err != nil { + t.Fatalf("读取部门镜像失败: %v", err) + } + if d := dm["od-yanfa"]; d == nil || d.DN != "cn=Acme.yanfa,ou=groups,dc=example,dc=com" { + t.Errorf("od-yanfa DN = %+v, 期望拍平拼音", d) + } + if d := dm["od-yanfa"]; d == nil || d.PinyinName != "Acme.yanfa" { + t.Errorf("od-yanfa PinyinName = %+v", d) + } + + // 第 2 轮:bob 在飞书标记离职 → 不在 FindByDepartment 返回(视为缺席), + // 但 Get 仍能查到并返回 is_resigned=true → 应机器确认、发 1 张卡片并转入 pending_confirm。 + markResigned(h.fs, "u-bob", true) + + res = h.run(t) + if res.CardsSent != 1 { + t.Errorf("bob 离职应发 1 张卡片,实际 %d", res.CardsSent) + } + if res.PendingConfirm != 1 { + t.Errorf("bob 应转入 pending_confirm,实际 %d", res.PendingConfirm) + } + if userState(t, h, "bob") != store.StatePendingConfirm { + t.Errorf("bob 应为 pending_confirm,实际 %q", userState(t, h, "bob")) + } + // 卡片应指向一次性确认端点。 + if !strings.Contains(string(h.webhook.body), "/api/resign/confirm") { + t.Errorf("卡片载荷应包含确认端点地址") + } + + // 第 3 轮(立即):bob 仍缺席且待确认,应被节流,不再发卡片。 + res = h.run(t) + if h.webhook.posts != 1 { + t.Errorf("节流未生效:离上次提醒不足间隔又发出了卡片,当前 %d 次", h.webhook.posts) + } + + // 第 4 轮:bob 重新可见 → 撤销离职流程并作废 token。 + markResigned(h.fs, "u-bob", false) + res = h.run(t) + if res.Reinstated != 1 { + t.Errorf("bob 重新入职应计 1 次撤销,实际 %d", res.Reinstated) + } + if userState(t, h, "bob") != store.StateActive { + t.Errorf("bob 撤销后应为 active,实际 %q", userState(t, h, "bob")) + } + toks, _ := h.store.LastPendingToken("bob") + if toks != nil { + t.Errorf("bob 的 token 应已作废,仍有 pending 记录") + } +} + +// TestSnapshotSkippedUserRecorded 验证缺少企业邮箱的用户被跳过但落了 skipped 记录。 +func TestSnapshotSkippedUserRecorded(t *testing.T) { + h := newSnapshotHarness(t) + h.fs.users = []sfUser{ + {unionID: "u-nomail", name: "Wang Wu", enterpriseEm: "", deptIDs: []string{"od-yanfa"}}, + } + res := h.run(t) + if res.Skipped != 1 { + t.Errorf("应跳过 1 个无邮箱用户,实际 %d", res.Skipped) + } + if res.Users != 0 { + t.Errorf("无邮箱用户不应进入在职视图,实际 %d", res.Users) + } + skipped, err := h.store.SkippedUsers() + if err != nil || len(skipped) != 1 { + t.Fatalf("应有一条 skipped 记录: %v", err) + } + if skipped[0].UID != "u-nomail" { + t.Errorf("skipped 记录 uid = %q, 期望以 union_id 落库", skipped[0].UID) + } +} + +func markResigned(f *sfFeishu, unionID string, resigned bool) { + for i := range f.users { + if f.users[i].unionID == unionID { + f.users[i].resigned = resigned + } + } +} \ No newline at end of file