feat: feishu 离职卡片 + 快照 runner(快照→镜像→离职流程)
快照段(飞书→SQLite 镜像→离职双确认推进)落地,不碰 LLDAP: - feishu 新增离职确认卡片:自定义机器人 webhook 发送 interactive 卡片, 确认按钮 url 跳程序自己的一次性端点;本地假 webhook 单测验证载荷与错误处理 - config.ConfirmURL/ResignConfirmRoute:确认端点路由与 URL 拼接(server/syncer 共用避免循环) - mapping.FlatDeptInfo:部门树拍平为拼音点分命名并计算 DN(纯函数), 供快照落库持久化 pinyin_name/dn,物化与 WebUI 复用 - SnapshotRunner.RunSnapshot:拉飞书→事务写镜像→离职生命周期 reconcile (机器确认→token+卡片→pending_confirm;节流防刷屏;重新可见撤销并作废 token) - 链路单测:快照落库、离职机器确认、发卡、状态机转移、节流、重新入职全部通过
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user