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 }