From 48b0ea35fcbf770104f403f3cab489ef2d99a708 Mon Sep 17 00:00:00 2001 From: CaoWangrenbo Date: Tue, 29 Sep 2026 23:19:47 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20store=20=E9=95=9C=E5=83=8F=E5=B1=82?= =?UTF-8?q?=EF=BC=88=E5=85=A8=E5=AD=97=E6=AE=B5=E9=95=9C=E5=83=8F+?= =?UTF-8?q?=E9=83=A8=E9=97=A8=E5=85=B3=E7=B3=BB+=E7=94=9F=E5=91=BD?= =?UTF-8?q?=E5=91=A8=E6=9C=9F+=E4=B8=80=E6=AC=A1=E6=80=A7=20token=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增镜像存储层并保留旧台账过渡: - MirrorUser/MirrorDepartment/MirrorUserDepartment/ResignConfirmToken 模型 - ReplaceMirror 单事务整体替换:用户/部门/关系一致更新,失败回滚保留旧版 - 生命周期状态机:active→leave_candidate→pending_confirm→confirmed_leave→deleted, 非法转移结构性报错;快照只覆盖飞书事实不清洗状态 - 一次性离职确认 token:crypto/rand 生成、一次使用、过期作废、可批量 revoke - 单测:事务回滚、状态机转移、token 一次/过期/复用被拒 全部通过 - docs/migration-baseline.md 记录阶段0基线字段 --- docs/migration-baseline.md | 45 ++++ internal/store/mirror.go | 453 ++++++++++++++++++++++++++++++++++ internal/store/mirror_test.go | 260 +++++++++++++++++++ internal/store/store.go | 3 +- 4 files changed, 760 insertions(+), 1 deletion(-) create mode 100644 docs/migration-baseline.md create mode 100644 internal/store/mirror.go create mode 100644 internal/store/mirror_test.go diff --git a/docs/migration-baseline.md b/docs/migration-baseline.md new file mode 100644 index 0000000..d110126 --- /dev/null +++ b/docs/migration-baseline.md @@ -0,0 +1,45 @@ +# 阶段 0:基线冻结记录 + +## 基线验证 +- `make build`:通过(`bin/idsync` 编译成功) +- `make test`:全绿(config/mapping/password/server/syncer 有测试;feishu/ldaplib/store/webui 无测试) + +## 迁移前 config 字段(`config.yml` / `config.example.yml`) + +### system +`mode`(debug/release)、`data-dir` + +### server +`listen`、`admin-user`、`admin-password`、`admin-password-hash`、`session-secret`、`secure-cookie` + +### feishu +`app-id`、`app-secret`、`base-url`、`dept-list`、`qps`、`timeout-sec` + +### ldap +`url`、`base-dn`、`admin-dn`、`admin-pass`、`user-ou`、`inactive-ou`、 +`group-root-name`、`group-object-class`、`group-member-attr`、`timeout-sec`、 +`start-tls`、`insecure-skip-verify`、`initial-password` + +### database +`source` + +### sync +`enable`、`schedule`、`default-dry-run`、`max-deactivate`、`max-deactivate-percent` + +## 迁移后应删除的项目(阶段 5) +- `ldap.user-ou` / `ldap.inactive-ou` → 固定 `ou=people` +- `ldap.group-root-name` / `ldap.group-object-class` / `ldap.group-member-attr` → LLDAP 形态,不再需要 +- `ldap.initial-password` → 密码 LLDAP 独占 +- `sync.*` 单套 → 拆为两套 schedule(snapshot / sync) + +## 迁移后应新增的项目(阶段 5) +- `ldap` 写通道:`base-url`、`admin-user`、`admin-pass`(供 GraphQL login) +- `feishu.webhook-url`(离职卡片自定义机器人) +- `server.confirm-base-url`(一次性确认端点对外 URL) +- 两套 `schedule.snapshot` / `schedule.sync` + +## 迁移状态(AGENTS.md「迁移状态」节引用的旧实现) +- `internal/ldaplib/ops.go` 写路径(Add/Modify/ModifyDN)、`internal/password`(SSHA)、 + 旧 config 项 —— 迁移完成前不作目标行为 +- `internal/store` 现为轻量台账(Account/Group/SyncRun),待扩展为全字段镜像 + 部门关系 +- `internal/feishu` 尚无可卡发送能力,离职确认通道未落地 \ No newline at end of file diff --git a/internal/store/mirror.go b/internal/store/mirror.go new file mode 100644 index 0000000..5c77239 --- /dev/null +++ b/internal/store/mirror.go @@ -0,0 +1,453 @@ +package store + +// 镜像存储层:全字段用户镜像 + 部门关系 + 生命周期状态 + 一次性离职确认 token。 +// +// 设计要点(与 AGENTS.md 硬约束对应): +// - 飞书是唯一真相,本层只做「从飞书快照写入」与「生命周期状态转移」,绝不发明业务字段。 +// - 快照整体刷新在单个事务内完成,失败回滚保留上一版镜像,绝不留下「半新半旧」。 +// - 删除(confirmed_leave → deleted → 物化删除)只由生命周期状态驱动,不在此层直接删用户。 +// - token 由 crypto/rand 生成、一次性使用、带过期时间;确认必须落在程序自己的端点。 +// +// 注意:本层仍与旧实现(Account/Group 台账,见 repo.go)共存,直到 runner/server 迁移完成。 + +import ( + "crypto/rand" + "encoding/base64" + "fmt" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// 用户生命周期状态。只有 active 用户会出现在物化期望状态里, +// 其余状态都是在「离职双确认」流程中流转的中间/终态。 +const ( + StateActive = "active" // 在职:物化的期望来源 + StateLeaveCandidate = "leave_candidate" // 飞书缺席且机器确认离职,等待发卡片 + StatePendingConfirm = "pending_confirm" // 已发离职确认卡片,等待人工点击一次性端点 + StateConfirmedLeave = "confirmed_leave" // 人工确认离职,等待下一轮物化物理删除 + StateDeleted = "deleted" // 已从 LLDAP 删除,仅作历史痕迹保留 +) + +// 一次性离职确认 token 的流转状态。 +const ( + TokenPending = "pending" // 已生成、可被消费 + TokenUsed = "used" // 已被确认端点消费(一次使用) + TokenRevoked = "revoked" // 用户重新可见时作废 +) + +// MirrorUser 是 镜像 + 台账 合一的全字段用户记录。 +// +// uid 是登录名(企业邮箱 @ 前缀)同时为主键;union_id 唯一,用于重新入职判别 +// 与离职回查。skipped 为纯展示信息:缺少企业邮箱的用户没有 uid,这里以 union_id +// 作为 uid 落一条记录,WebUI 据此列出「谁没被同步」,避免静默丢失。 +type MirrorUser struct { + UID string `gorm:"primaryKey;size:190"` + UnionID string `gorm:"size:190;uniqueIndex;not null"` + UserID string `gorm:"size:190"` + Email string `gorm:"size:255"` // 企业邮箱 + DisplayName string `gorm:"size:255"` // 优先中文名 + GivenName string `gorm:"size:255"` // 优先英文名 + SN string `gorm:"size:255"` // 姓氏姓名,sn 为空会违 inetOrgPerson + Mobile string `gorm:"size:64"` + EmployeeNumber string `gorm:"size:64"` + DepartmentNumber string `gorm:"size:255"` // 职位 + PostalAddress string `gorm:"size:255"` // 工位 + BusinessCategory string `gorm:"size:512"` // 所属部门名(逗号分隔) + EmployeeType string `gorm:"size:32"` + Description string `gorm:"size:512"` + // DepartmentIDs 仅快照构建期间用于重建关系表,不落库(关联存放在 + // mirror_user_departments)。 + DepartmentIDs []string `gorm:"-"` + State string `gorm:"size:32;not null;default:active;index"` + // AttrsHash 是上一次写入 LLDAP 的属性内容指纹;快照刷新不得覆盖它。 + AttrsHash string `gorm:"size:64"` + Skipped bool `gorm:"not null;default:false"` + SkipReason string `gorm:"size:512"` + LastSyncedAt time.Time + CreatedAt time.Time + UpdatedAt time.Time +} + +// MirrorDepartment 是部门镜像。 +type MirrorDepartment struct { + DeptID string `gorm:"primaryKey;size:190"` + Name string `gorm:"check:name <> ''"` + PinyinName string `gorm:"size:255"` + DN string `gorm:"size:512"` + ParentID string `gorm:"size:190;index"` + Level int + State string `gorm:"size:32;not null;default:active;index"` + LastSyncedAt time.Time + CreatedAt time.Time + UpdatedAt time.Time +} + +// MirrorUserDepartment 是用户 ↔ 部门 的关联表,用于按部门查人的索引。 +type MirrorUserDepartment struct { + UID string `gorm:"primaryKey;size:190"` + DeptID string `gorm:"primaryKey;size:190;index"` +} + +// ResignConfirmToken 是一次性离职确认 token。 +type ResignConfirmToken struct { + Token string `gorm:"primaryKey;size:64"` + UID string `gorm:"size:190;index;not null"` + Status string `gorm:"size:16;not null;default:pending"` + ExpiresAt time.Time + CreatedAt time.Time + UsedAt *time.Time +} + +// MirrorSnapshot 是一次快照整体替换的输入来源。它只携带「飞书当前有什么」, +// 由调用方(快照 runner)从飞书拉取后交给 ReplaceMirror 落库。 +type MirrorSnapshot struct { + Users []*MirrorUser // 有企业邮箱、可生成 uid 的用户(在飞书可见) + Departments []*MirrorDepartment + // Skipped 是缺少企业邮箱而被跳过的用户,以 union_id 作为 uid 落库供 WebUI 展示。 + Skipped []*MirrorUser +} + +// ReplaceMirror 在单个事务内整体替换镜像的用户、部门,并重建用户-部门关系表。 +// +// 事务保证:任何一步失败都回滚,SQLite 停留在上一版完整镜像,绝不出现半新半旧。 +// 快照只覆盖「飞书事实」列;生命周期状态(state)与 attrs_hash 在 upsert 冲突时 +// 刻意不覆盖——否则会把正在走离职流程的用户重置回 active,违反镜像业务。 +// +// 用户的生命周期状态由独立的 reconcile 步骤驱动(见 syncer),此处不做任何决策。 +func (s *Store) ReplaceMirror(snap *MirrorSnapshot) error { + return s.db.Transaction(func(tx *gorm.DB) error { + now := time.Now() + + // 部门整体替换:快照里的都 upsert;快照之外的一律清除,保证镜像 = 飞书当前有的部门。 + // 部门是两个「整体替换」之一,用户因受离职生命周期约束不在本层删。 + if err := tx.Session(&gorm.Session{AllowGlobalUpdate: true}). + Delete(&MirrorDepartment{}).Error; err != nil { + return fmt.Errorf("清空部门镜像失败: %w", err) + } + for _, d := range snap.Departments { + d.State = StateActive + d.LastSyncedAt = now + if err := tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "dept_id"}}, + DoUpdates: clause.AssignmentColumns([]string{ + "name", "pinyin_name", "dn", "parent_id", "level", "state", "last_synced_at", + }), + }).Create(d).Error; err != nil { + return fmt.Errorf("写入部门镜像 %s(%s) 失败: %w", d.Name, d.DeptID, err) + } + } + + // 用户整体替换:upsert 有账者,不轮询删除缺席者(其生命周期由 reconcile 处理)。 + for _, u := range snap.Users { + u.State = StateActive + u.Skipped = false + u.SkipReason = "" + u.LastSyncedAt = now + if err := tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "uid"}}, + // 只更新飞书事实列;state / attrs_hash / created_at 保留。 + DoUpdates: clause.AssignmentColumns([]string{ + "union_id", "user_id", "email", "display_name", "given_name", "sn", + "mobile", "employee_number", "department_number", "postal_address", + "business_category", "employee_type", "description", + "skipped", "skip_reason", "last_synced_at", + }), + }).Create(u).Error; err != nil { + return fmt.Errorf("写入用户镜像 %s(%s) 失败: %w", u.UID, u.DisplayName, err) + } + } + + // 被跳过的用户也落库,但标记 skipped=true,不进入 any 物化视图。 + for _, u := range snap.Skipped { + u.Skipped = true + u.SkipReason = "缺少企业邮箱,无法推导登录名" + u.LastSyncedAt = now + if err := tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "uid"}}, + DoUpdates: clause.AssignmentColumns([]string{ + "union_id", "user_id", "email", "display_name", "given_name", "sn", + "mobile", "employee_number", "department_number", "postal_address", + "business_category", "employee_type", "description", + "state", "skipped", "skip_reason", "last_synced_at", + }), + }).Create(u).Error; err != nil { + return fmt.Errorf("写入跳过用户镜像 %s(%s) 失败: %w", u.UID, u.DisplayName, err) + } + } + + // 重建用户-部门关系表:整体删除后按本次快照重新插入。 + if err := tx.Session(&gorm.Session{AllowGlobalUpdate: true}). + Delete(&MirrorUserDepartment{}).Error; err != nil { + return fmt.Errorf("清空用户-部门关系失败: %w", err) + } + var rels []MirrorUserDepartment + for _, u := range snap.Users { + for _, deptID := range u.DepartmentIDs { + rels = append(rels, MirrorUserDepartment{UID: u.UID, DeptID: deptID}) + } + } + if len(rels) > 0 { + // ChunkSize 分批插入,避免一条 SQL 过长。 + if err := tx.CreateInBatches(&rels, 500).Error; err != nil { + return fmt.Errorf("重建用户-部门关系失败: %w", err) + } + } + return nil + }) +} + +// --- 生命周期状态机 --- + +// transition 做一次受校验的状态转移:仅当当前状态为 from 时才置为 to。 +// 放在事务里执行,避免并发下读到半路状态。转移失败说明状态机被绕过了, +// 直接报错——「未确认的离职候选绝不入计划」就是靠这一步结构性地拦住的。 +func (s *Store) transition(uid, from, to string) error { + return s.db.Transaction(func(tx *gorm.DB) error { + res := tx.Model(&MirrorUser{}). + Where("uid = ? AND state = ?", uid, from). + Update("state", to) + if res.Error != nil { + return res.Error + } + if res.RowsAffected != 1 { + return fmt.Errorf("状态转移 uid=%s %s→%s 失败:当前状态不允许该转移", uid, from, to) + } + return nil + }) +} + +// ToLeaveCandidate 飞书缺席 + 机器确认离职:active → leave_candidate。 +func (s *Store) ToLeaveCandidate(uid string) error { + return s.transition(uid, StateActive, StateLeaveCandidate) +} + +// ToPendingConfirm 已对用户发送离职确认卡片:leave_candidate → pending_confirm。 +func (s *Store) ToPendingConfirm(uid string) error { + return s.transition(uid, StateLeaveCandidate, StatePendingConfirm) +} + +// ConfirmLeave 人工在一次性端点上确认离职:pending_confirm → confirmed_leave。 +func (s *Store) ConfirmLeave(uid string) error { + return s.transition(uid, StatePendingConfirm, StateConfirmedLeave) +} + +// MarkDeleted 该用户已从 LLDAP 物理删除:confirmed_leave → deleted。 +func (s *Store) MarkDeleted(uid string) error { + return s.transition(uid, StateConfirmedLeave, StateDeleted) +} + +// RevertToActive 用户重新在飞书可见:撤销离职流程回 active。 +// +// 只有真正进入删除(deleted)后的用户不能回退——他已经从 LLDAP 删了, +// 下一轮物化会以「重建」处理。还在双确认流程内的都可回退。 +func (s *Store) RevertToActive(uid string) error { + return s.db.Model(&MirrorUser{}). + Where("uid = ? AND state IN ?", uid, + []string{StateLeaveCandidate, StatePendingConfirm, StateConfirmedLeave}). + Update("state", StateActive).Error +} + +// --- 查询视图 --- + +// ActiveUsers 返回在职视图:state=active 且未跳过。这是物化期望状态的唯一来源。 +func (s *Store) ActiveUsers() ([]*MirrorUser, error) { + var out []*MirrorUser + if err := s.db.Where("state = ? AND skipped = ?", StateActive, false). + Order("uid").Find(&out).Error; err != nil { + return nil, err + } + return out, nil +} + +// UsersByState 返回指定生命周期状态的全部用户。 +func (s *Store) UsersByState(state string) ([]*MirrorUser, error) { + var out []*MirrorUser + if err := s.db.Where("state = ?", state).Order("uid").Find(&out).Error; err != nil { + return nil, err + } + return out, nil +} + +// PendingConfirm 返回待人工确认的离职候选。 +func (s *Store) PendingConfirm() ([]*MirrorUser, error) { + return s.UsersByState(StatePendingConfirm) +} + +// ConfirmedLeave 返回已确认离职、等待物化删除的列表。 +func (s *Store) ConfirmedLeave() ([]*MirrorUser, error) { + return s.UsersByState(StateConfirmedLeave) +} + +// LeaveCandidates 返回已机器确认、尚未发卡片的候选。 +func (s *Store) LeaveCandidates() ([]*MirrorUser, error) { + return s.UsersByState(StateLeaveCandidate) +} + +// SkippedUsers 返回被跳过的用户记录(无企业邮箱),供 WebUI 展示。 +func (s *Store) SkippedUsers() ([]*MirrorUser, error) { + var out []*MirrorUser + if err := s.db.Where("skipped = ?", true).Order("uid").Find(&out).Error; err != nil { + return nil, err + } + return out, nil +} + +// AllMirrorUsers 返回全部镜像用户(含所有状态),用于测试与全量展示。 +func (s *Store) AllMirrorUsers() ([]*MirrorUser, error) { + var out []*MirrorUser + if err := s.db.Order("uid").Find(&out).Error; err != nil { + return nil, err + } + return out, nil +} + +// MapUsersByUID 返回 uid → 镜像用户 映射。 +func (s *Store) MapUsersByUID() (map[string]*MirrorUser, error) { + list, err := s.AllMirrorUsers() + if err != nil { + return nil, err + } + out := make(map[string]*MirrorUser, len(list)) + for _, u := range list { + out[u.UID] = u + } + return out, nil +} + +// ActiveDepartments 返回在职视图下的部门镜像。 +func (s *Store) ActiveDepartments() ([]*MirrorDepartment, error) { + var out []*MirrorDepartment + if err := s.db.Where("state = ?", StateActive).Order("dept_id").Find(&out).Error; err != nil { + return nil, err + } + return out, nil +} + +// AllDepartments 返回全部部门镜像。 +func (s *Store) AllDepartments() ([]*MirrorDepartment, error) { + var out []*MirrorDepartment + if err := s.db.Order("dept_id").Find(&out).Error; err != nil { + return nil, err + } + return out, nil +} + +// MapDepartmentsByID 返回 dept_id → 部门镜像 映射。 +func (s *Store) MapDepartmentsByID() (map[string]*MirrorDepartment, error) { + list, err := s.AllDepartments() + if err != nil { + return nil, err + } + out := make(map[string]*MirrorDepartment, len(list)) + for _, d := range list { + out[d.DeptID] = d + } + return out, nil +} + +// UserDeptIDs 返回某用户所属的部门 ID 列表。 +func (s *Store) UserDeptIDs(uid string) ([]string, error) { + var rels []MirrorUserDepartment + if err := s.db.Where("uid = ?", uid).Find(&rels).Error; err != nil { + return nil, err + } + out := make([]string, 0, len(rels)) + for _, r := range rels { + out = append(out, r.DeptID) + } + return out, nil +} + +// DeptUserIDs 返回某部门下的用户 uid 列表(按部门查人的索引)。 +func (s *Store) DeptUserIDs(deptID string) ([]string, error) { + var rels []MirrorUserDepartment + if err := s.db.Where("dept_id = ?", deptID).Find(&rels).Error; err != nil { + return nil, err + } + out := make([]string, 0, len(rels)) + for _, r := range rels { + out = append(out, r.UID) + } + return out, nil +} + +// --- 一次性离职确认 token --- + +// CreateToken 为 uid 生成一个新的一次性离职确认 token。 +// +// 用 crypto/rand 生成,不依赖时间/内存地址等可预测来源;token 本身即凭证, +// 确认端点拿到它就可以把用户置为 confirmed_leave,因此必须唯一不可猜测。 +func (s *Store) CreateToken(uid string, ttl time.Duration) (*ResignConfirmToken, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return nil, fmt.Errorf("生成 token 失败: %w", err) + } + rec := &ResignConfirmToken{ + Token: base64.RawURLEncoding.EncodeToString(b), + UID: uid, + Status: TokenPending, + ExpiresAt: time.Now().Add(ttl), + } + if err := s.db.Create(rec).Error; err != nil { + return nil, fmt.Errorf("保存 token 失败: %w", err) + } + return rec, nil +} + +// ConsumeToken 消费一个一次性 token:校验存在、未用、未过期后置为 used。 +// +// 放在事务里,保证并发下同一 token 只会被消费一次(二次使用被拒)。 +// 消费成功后返回 token 记录(含 uid),供确认端点回写 confirmed_leave。 +func (s *Store) ConsumeToken(token string) (*ResignConfirmToken, error) { + var rec ResignConfirmToken + err := s.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Where("token = ?", token).First(&rec).Error; err != nil { + return fmt.Errorf("token 不存在或无效") + } + if rec.Status != TokenPending { + return fmt.Errorf("token 已被使用或已作废") + } + if time.Now().After(rec.ExpiresAt) { + return fmt.Errorf("确认链接已过期,请向管理员重新发起离职确认") + } + usedAt := time.Now() + rec.Status = TokenUsed + rec.UsedAt = &usedAt + if err := tx.Model(&rec).Updates(map[string]any{ + "status": TokenUsed, "used_at": usedAt, + }).Error; err != nil { + return err + } + return nil + }) + if err != nil { + return nil, err + } + return &rec, nil +} + +// RevokeTokensForUser 作废某用户全部未使用的 token。 +// +// 用户在飞书重新可见而撤销离职流程时调用:未用 token 若留着,点开仍能把人 +// 误标成离职,因此必须一次性作废。 +func (s *Store) RevokeTokensForUser(uid string) error { + return s.db.Model(&ResignConfirmToken{}). + Where("uid = ? AND status = ?", uid, TokenPending). + Update("status", TokenRevoked).Error +} + +// LastPendingToken 返回某用户最近一条未使用的 token(用于离职提醒节流判断)。 +func (s *Store) LastPendingToken(uid string) (*ResignConfirmToken, error) { + var rec ResignConfirmToken + err := s.db.Where("uid = ? AND status = ?", uid, TokenPending). + Order("created_at desc").First(&rec).Error + if err == gorm.ErrRecordNotFound { + return nil, nil + } + if err != nil { + return nil, err + } + return &rec, nil +} \ No newline at end of file diff --git a/internal/store/mirror_test.go b/internal/store/mirror_test.go new file mode 100644 index 0000000..9a9e133 --- /dev/null +++ b/internal/store/mirror_test.go @@ -0,0 +1,260 @@ +package store + +import ( + "path/filepath" + "strings" + "testing" + "time" +) + +// mu 构造一个镜像用户,DepartmentIDs 用于驱动关系表重建。 +func mu(uid, email, name string, deptIDs ...string) *MirrorUser { + return &MirrorUser{ + UID: uid, + UnionID: "u-" + uid, + Email: email, + DisplayName: name, + SN: name, + GivenName: name, + EmployeeType: "Active", + DepartmentIDs: deptIDs, + State: StateActive, + } +} + +// md 构造一个部门镜像。 +func md(id, parent, name string) *MirrorDepartment { + return &MirrorDepartment{DeptID: id, ParentID: parent, Name: name} +} + +// openStore 打开一个临时文件库,保证多事务测试有真实磁盘语义。 +func openStore(t *testing.T) *Store { + t.Helper() + path := filepath.Join(t.TempDir(), "idsync-test.db") + st, err := Open(path) + if err != nil { + t.Fatalf("打开 store 失败: %v", err) + } + t.Cleanup(func() { _ = st.Close() }) + return st +} + +// TestReplaceMirrorTransactionalRollback 验证快照整体替换的事务性: +// 中途失败时必须回滚到上一版完整镜像,绝不留下半新半旧。 +func TestReplaceMirrorTransactionalRollback(t *testing.T) { + st := openStore(t) + + // 先写入一版正常镜像。 + good := &MirrorSnapshot{ + Departments: []*MirrorDepartment{md("od-root", "", "Acme")}, + Users: []*MirrorUser{ + mu("alice", "alice@example.com", "Alice", "od-root"), + }, + } + if err := st.ReplaceMirror(good); err != nil { + t.Fatalf("初始镜像写入失败: %v", err) + } + + // 第二版镜像故意带一个空部门名,触发 CHECK 约束失败,中断事务。 + bad := &MirrorSnapshot{ + Departments: []*MirrorDepartment{{DeptID: "od-bad", ParentID: "", Name: ""}}, + Users: []*MirrorUser{ + mu("bob", "bob@example.com", "Bob", "od-bad"), + }, + } + if err := st.ReplaceMirror(bad); err == nil { + t.Fatalf("含非法部门名的镜像应当写失败,却成功了") + } + + // 回滚后镜像必须仍是第一版:alice 还在、od-root 还在、bob 与 od-bad 都不在。 + users, err := st.MapUsersByUID() + if err != nil { + t.Fatalf("读取用户镜像失败: %v", err) + } + if _, ok := users["alice"]; !ok { + t.Errorf("回滚后 alice 丢失,镜像被污染") + } + if _, ok := users["bob"]; ok { + t.Errorf("回滚后 bob 残留,镜像被污染") + } + + depts, err := st.MapDepartmentsByID() + if err != nil { + t.Fatalf("读取部门镜像失败: %v", err) + } + if _, ok := depts["od-root"]; !ok { + t.Errorf("回滚后 od-root 丢失(清空操作也被回滚)") + } + if _, ok := depts["od-bad"]; ok { + t.Errorf("回滚后 od-bad 残留") + } +} + +// TestReplaceMirrorPreservesLifecycleState 验证:快照刷新只覆盖飞书事实, +// 不清洗生命周期状态——正在走离职流程的用户不会被快照重置回 active。 +func TestReplaceMirrorPreservesLifecycleState(t *testing.T) { + st := openStore(t) + snap := &MirrorSnapshot{ + Departments: []*MirrorDepartment{md("od-root", "", "Acme")}, + Users: []*MirrorUser{mu("alice", "alice@example.com", "Alice", "od-root")}, + } + if err := st.ReplaceMirror(snap); err != nil { + t.Fatalf("初始写入失败: %v", err) + } + if err := st.ToLeaveCandidate("alice"); err != nil { + t.Fatalf("转入 leave_candidate 失败: %v", err) + } + + // 用户仍可见于飞书,快照再次刷新。 + if err := st.ReplaceMirror(snap); err != nil { + t.Fatalf("再次刷新失败: %v", err) + } + users, _ := st.MapUsersByUID() + if users["alice"].State != StateLeaveCandidate { + t.Errorf("快照刷新后 state = %q, 期望仍为 %q(生命周期不应被快照覆盖)", + users["alice"].State, StateLeaveCandidate) + } + // 关系表应重建无误。 + ids, err := st.UserDeptIDs("alice") + if err != nil || len(ids) != 1 || ids[0] != "od-root" { + t.Errorf("关系表重建异常: %v -> %v", err, ids) + } +} + +// TestRejectAbsentDepartmentsCleared 验证部门整体替换:快照之外的历史部门被清除, +// 保证镜像 = 飞书当前有的部门,不会残留幽灵组。 +func TestRejectAbsentDepartmentsCleared(t *testing.T) { + st := openStore(t) + if err := st.ReplaceMirror(&MirrorSnapshot{ + Departments: []*MirrorDepartment{md("od-old", "", "Old")}, + }); err != nil { + t.Fatalf("初始写入失败: %v", err) + } + if err := st.ReplaceMirror(&MirrorSnapshot{ + Departments: []*MirrorDepartment{md("od-new", "", "New")}, + }); err != nil { + t.Fatalf("刷新失败: %v", err) + } + depts, _ := st.MapDepartmentsByID() + if _, ok := depts["od-new"]; !ok { + t.Errorf("新部门未写入") + } + if _, ok := depts["od-old"]; ok { + t.Errorf("快照之外的旧部门 od-old 仍残留在部门镜像中") + } +} + +// TestStateMachine 验证生命周期状态机的合法转移与非法拦截。 +func TestStateMachine(t *testing.T) { + st := openStore(t) + if err := st.ReplaceMirror(&MirrorSnapshot{ + Users: []*MirrorUser{mu("alice", "alice@example.com", "Alice")}, + }); err != nil { + t.Fatalf("初始写入失败: %v", err) + } + + // 合法链:active → leave_candidate → pending_confirm → confirmed_leave → deleted + checks := []struct { + name string + apply func() error + want string + }{ + {"机器确认离职", func() error { return st.ToLeaveCandidate("alice") }, StateLeaveCandidate}, + {"发卡片待确认", func() error { return st.ToPendingConfirm("alice") }, StatePendingConfirm}, + {"人工确认离职", func() error { return st.ConfirmLeave("alice") }, StateConfirmedLeave}, + {"物化删除", func() error { return st.MarkDeleted("alice") }, StateDeleted}, + } + for _, c := range checks { + if err := c.apply(); err != nil { + t.Fatalf("%s 失败: %v", c.name, err) + } + assertState(t, st, "alice", c.want) + } + + // 非法转移:deleted 状态不能转 pending_confirm(状态机被绕过必须报错)。 + if err := st.ToPendingConfirm("alice"); err == nil { + t.Errorf("deleted 状态的用户应无法转 pending_confirm,却成功了") + } + // deleted 后 revert 不应生效(已物理删除,不能回退,交给「重建」处理)。 + if err := st.RevertToActive("alice"); err != nil { + t.Fatalf("RevertToActive 不应报错: %v", err) + } + assertState(t, st, "alice", StateDeleted) + + // 双确认流程内可回退(重新入职)。 + if err := st.ReplaceMirror(&MirrorSnapshot{ + Users: []*MirrorUser{mu("bob", "bob@example.com", "Bob")}, + }); err != nil { + t.Fatalf("写入 bob 失败: %v", err) + } + if err := st.ToLeaveCandidate("bob"); err != nil { + t.Fatalf("bob 转 leave_candidate 失败: %v", err) + } + if err := st.RevertToActive("bob"); err != nil { + t.Fatalf("bob revert 失败: %v", err) + } + assertState(t, st, "bob", StateActive) +} + +// TestTokenOneTimeExpiryRevoke 验证一次性 token:试用即废、过期失效、作废后拒绝。 +func TestTokenOneTimeExpiryRevoke(t *testing.T) { + st := openStore(t) + if err := st.ReplaceMirror(&MirrorSnapshot{ + Users: []*MirrorUser{mu("alice", "alice@example.com", "Alice")}, + }); err != nil { + t.Fatalf("初始写入失败: %v", err) + } + + // 正常 token:消费成功,返回 uid。 + tok, err := st.CreateToken("alice", time.Hour) + if err != nil { + t.Fatalf("创建 token 失败: %v", err) + } + consumed, err := st.ConsumeToken(tok.Token) + if err != nil { + t.Fatalf("消费 token 失败: %v", err) + } + if consumed.UID != "alice" { + t.Errorf("消费返回 uid = %q, 期望 alice", consumed.UID) + } + // 再次使用同一 token 必须被拒(一次使用)。 + if _, err := st.ConsumeToken(tok.Token); err == nil { + t.Errorf("已使用的 token 应被拒绝复用") + } + + // 过期的 token:负 TTL 使 ExpiresAt 落在过去。 + expired, err := st.CreateToken("alice", -time.Minute) + if err != nil { + t.Fatalf("创建过期 token 失败: %v", err) + } + if _, err := st.ConsumeToken(expired.Token); err == nil || !strings.Contains(err.Error(), "过期") { + t.Errorf("过期 token 应报过期错误,实际: %v", err) + } + + // 作废的 token:用户重新可见后 revoke,未用 token 拒绝消费。 + pending, err := st.CreateToken("alice", time.Hour) + if err != nil { + t.Fatalf("创建 token 失败: %v", err) + } + if err := st.RevokeTokensForUser("alice"); err != nil { + t.Fatalf("作废 token 失败: %v", err) + } + if _, err := st.ConsumeToken(pending.Token); err == nil { + t.Errorf("已作废的 token 应被拒绝消费") + } +} + +func assertState(t *testing.T, st *Store, uid, want string) { + t.Helper() + users, err := st.MapUsersByUID() + if err != nil { + t.Fatalf("读取用户失败: %v", err) + } + u, ok := users[uid] + if !ok { + t.Fatalf("用户 %s 不存在", uid) + } + if u.State != want { + t.Errorf("用户 %s state = %q, 期望 %q", uid, u.State, want) + } +} \ No newline at end of file diff --git a/internal/store/store.go b/internal/store/store.go index a0b463d..929220d 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -87,7 +87,8 @@ func Open(path string) (*Store, error) { } sqlDB.SetMaxOpenConns(1) - if err := db.AutoMigrate(&Account{}, &Group{}, &SyncRun{}); err != nil { + if err := db.AutoMigrate(&MirrorUser{}, &MirrorDepartment{}, &MirrorUserDepartment{}, + &ResignConfirmToken{}, &SyncRun{}, &Account{}, &Group{}); err != nil { return nil, fmt.Errorf("建表失败: %w", err) } return &Store{db: db}, nil