Files
idsync/internal/feishu/card.go
T
caowangrenbo f997e48a75 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)
- 链路单测:快照落库、离职机器确认、发卡、状态机转移、节流、重新入职全部通过
2026-09-29 23:24:39 +08:00

120 lines
4.3 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
// 离职确认卡片:通过自定义机器人 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
}