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:
2026-09-29 23:24:39 +08:00
parent 48b0ea35fc
commit f997e48a75
6 changed files with 1036 additions and 0 deletions
+120
View File
@@ -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
}
+134
View File
@@ -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 应上报失败,却成功了")
}
}