- 集成测试重构: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+集成全绿
507 lines
19 KiB
Go
507 lines
19 KiB
Go
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
|
||
} |