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,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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 应上报失败,却成功了")
|
||||
}
|
||||
}
|
||||
@@ -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,<base>"。
|
||||
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
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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