Files
caowangrenbo 0fffdd5ccb test: 集成测试改用真 LLDAP 容器;修复两处物化缺陷
- 集成测试重构:syncer e2e/materialize、ldaplib 读路径、lldapgql 全改用本地
  LLDAP 容器(LDAP 3890 / GraphQL /api/graphql 17170),删旧 OpenLDAP 断言
- 修复 1:ActiveUsers 不填 DepartmentIDs(存于关系表),BuildFromMirror 拿到空成员;
  新增 ActiveUsersWithDepts,物化 plan 用之
- 修复 2:deleted 用户重新可见后不回 active,无法重建;RevertToActive 纳入 deleted,
  快照 reconcile 对其置回 active + 作废 token
- 关键安全回归单测保留并配变异验证:未确认不删、非受管不删、admin 永不移出、
  删除最后阶段、绝不碰密码。全量单测+vet+集成全绿
2026-09-29 23:53:24 +08:00

507 lines
19 KiB
Go
Raw Permalink 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 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 是部门中文名,纯展示用(可读名走 DB,不进 LDAP 组名)。
Name string `gorm:"check:name <> ''"`
DN string `gorm:"size:512;index"`
ParentID string `gorm:"size:190;index"`
Level int
// GroupID 是 LLDAP 中该部门组的自增 int 组 id。LDAP 视角无法取到它,
// 必须由物化阶段持久化,供 GraphQL 成员操作直接用(约束 12)。
GroupID int `gorm:"column:group_id"`
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;快照之外的一律清除,保证镜像 = 飞书当前有的部门。
// 注意 upsert 冲突时**不碰 group_id**——物化阶段持久化的 dept_id→groupId 映射
// 必须跨快照刷新保留,否则每次快照都会把组 id 清零。
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", "dn", "parent_id", "level", "state", "last_synced_at",
}),
}).Create(d).Error; err != nil {
return fmt.Errorf("写入部门镜像 %s(%s) 失败: %w", d.Name, d.DeptID, err)
}
}
// 清除本轮快照之外的部门(group_id 随之丢弃,其组已成孤儿)。
if len(snap.Departments) == 0 {
if err := tx.Session(&gorm.Session{AllowGlobalUpdate: true}).
Delete(&MirrorDepartment{}).Error; err != nil {
return fmt.Errorf("清除部门镜像失败: %w", err)
}
} else {
presentIDs := make([]string, 0, len(snap.Departments))
for _, d := range snap.Departments {
presentIDs = append(presentIDs, d.DeptID)
}
if err := tx.Where("dept_id NOT IN ?", presentIDs).
Delete(&MirrorDepartment{}).Error; err != nil {
return fmt.Errorf("清除旧部门失败: %w", 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 物理删除的用户再次可见意味着重新入职,置回 active
// 后物化会以「重建」把它再写进 LLDAP。仍在双确认流程内的(candidate/pending/
// confirmed)同样回退,未用 token 由调用方一并作废。
func (s *Store) RevertToActive(uid string) error {
return s.db.Model(&MirrorUser{}).
Where("uid = ? AND state IN ?", uid,
[]string{StateLeaveCandidate, StatePendingConfirm, StateConfirmedLeave, StateDeleted}).
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
}
// ActiveUsersWithDepts 返回在职用户并填充各自的 DepartmentIDs(来自关系表)。
//
// DepartmentIDs 存于 mirror_user_departments,不落 MirrorUser.DepartmentIDs;
// 物化期望状态(BuildFromMirror 的组归属)依赖它,必须在此补齐。
func (s *Store) ActiveUsersWithDepts() ([]*MirrorUser, error) {
users, err := s.ActiveUsers()
if err != nil {
return nil, err
}
for _, u := range users {
ids, err := s.UserDeptIDs(u.UID)
if err != nil {
return nil, err
}
u.DepartmentIDs = ids
}
return users, 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
}
// SetDeptGroupID 持久化部门组在 LLDAP 里的自增 groupId。
//
// LDAP 视角取不到这个 int id,只能由物化阶段在创建/核对组时写入,成员操作据此直接用。
// 快照刷新刻意不覆盖 group_id,保证映射跨刷新稳定。
func (s *Store) SetDeptGroupID(deptID string, groupID int) error {
return s.db.Model(&MirrorDepartment{}).
Where("dept_id = ?", deptID).
Update("group_id", groupID).Error
}
// SetUserAttrsHash 记录某用户上次写入 LLDAP 的属性指纹,供漂移比对与展示。
func (s *Store) SetUserAttrsHash(uid, hash string) error {
return s.db.Model(&MirrorUser{}).
Where("uid = ?", uid).
Update("attrs_hash", hash).Error
}
// 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
}