新增镜像存储层并保留旧台账过渡: - MirrorUser/MirrorDepartment/MirrorUserDepartment/ResignConfirmToken 模型 - ReplaceMirror 单事务整体替换:用户/部门/关系一致更新,失败回滚保留旧版 - 生命周期状态机:active→leave_candidate→pending_confirm→confirmed_leave→deleted, 非法转移结构性报错;快照只覆盖飞书事实不清洗状态 - 一次性离职确认 token:crypto/rand 生成、一次使用、过期作废、可批量 revoke - 单测:事务回滚、状态机转移、token 一次/过期/复用被拒 全部通过 - docs/migration-baseline.md 记录阶段0基线字段
108 lines
3.6 KiB
Go
108 lines
3.6 KiB
Go
// Package store 维护本地 SQLite 台账。
|
||
//
|
||
// 台账的职责只有一个:记录“哪些 LDAP 条目是我们创建的”。
|
||
// 这是安全删除的唯一依据——没有台账就没有删除权,因此
|
||
// 服务账号、手工创建的账号、admin 永远不会被同步任务误伤。
|
||
package store
|
||
|
||
import (
|
||
"fmt"
|
||
"time"
|
||
|
||
"github.com/glebarez/sqlite"
|
||
"gorm.io/gorm"
|
||
"gorm.io/gorm/logger"
|
||
)
|
||
|
||
// Account 是一个由同步器管理的用户账号。
|
||
type Account struct {
|
||
// UID 即登录名(企业邮箱前缀),同时是主键。
|
||
UID string `gorm:"primaryKey;size:190"`
|
||
DN string `gorm:"size:512;not null"`
|
||
UnionID string `gorm:"size:190;index"`
|
||
UserID string `gorm:"size:190"`
|
||
Email string `gorm:"size:255"`
|
||
Name string `gorm:"size:255"`
|
||
// State: active(在 ou=people)或 inactive(在 ou=Inactive)
|
||
State string `gorm:"size:16;not null;default:active"`
|
||
// AttrsHash 是上一次写入的属性指纹,用于快速判断是否漂移
|
||
AttrsHash string `gorm:"size:64"`
|
||
// DeptIDs 是所属部门,逗号分隔
|
||
DeptIDs string `gorm:"size:1024"`
|
||
LastSyncedAt time.Time
|
||
CreatedAt time.Time
|
||
UpdatedAt time.Time
|
||
}
|
||
|
||
// Group 是一个由同步器管理的部门分组。
|
||
type Group struct {
|
||
// DeptID 是飞书的 open_department_id,作为主键。
|
||
DeptID string `gorm:"primaryKey;size:190" json:"deptId"`
|
||
DN string `gorm:"size:512;not null;uniqueIndex" json:"dn"`
|
||
Name string `gorm:"size:255" json:"name"`
|
||
ParentID string `gorm:"size:190;index" json:"parentId"`
|
||
State string `gorm:"size:16;not null;default=active" json:"state"`
|
||
LastSyncedAt time.Time `json:"lastSyncedAt"`
|
||
CreatedAt time.Time `json:"createdAt"`
|
||
UpdatedAt time.Time `json:"updatedAt"`
|
||
}
|
||
|
||
// SyncRun 记录一次同步执行的结果,供历史页面展示。
|
||
type SyncRun struct {
|
||
ID uint `gorm:"primaryKey" json:"id"`
|
||
StartedAt time.Time `json:"startedAt"`
|
||
FinishedAt time.Time `json:"finishedAt"`
|
||
// Mode: dry-run 或 apply
|
||
Mode string `gorm:"size:16" json:"mode"`
|
||
Trigger string `gorm:"size:32" json:"trigger"` // manual 或 schedule
|
||
Status string `gorm:"size:16" json:"status"` // success 或 failed
|
||
Created int `json:"created"`
|
||
Updated int `json:"updated"`
|
||
Moved int `json:"moved"`
|
||
Added int `json:"added"` // 加入分组次数
|
||
Removed int `json:"removed"` // 移出分组次数
|
||
Skipped int `json:"skipped"`
|
||
Failed int `json:"failed"`
|
||
ErrorMsg string `gorm:"size:2048" json:"errorMsg"`
|
||
}
|
||
|
||
// Store 是台账的访问入口。
|
||
type Store struct {
|
||
db *gorm.DB
|
||
}
|
||
|
||
// Open 打开(必要时创建)SQLite 台账并完成建表。
|
||
func Open(path string) (*Store, error) {
|
||
db, err := gorm.Open(sqlite.Open(path), &gorm.Config{
|
||
Logger: logger.Default.LogMode(logger.Silent),
|
||
})
|
||
if err != nil {
|
||
return nil, fmt.Errorf("打开台账数据库失败 (%s): %w", path, err)
|
||
}
|
||
|
||
// SQLite 在并发写时容易报 database is locked;限制为单连接可彻底避免。
|
||
sqlDB, err := db.DB()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("获取数据库句柄失败: %w", err)
|
||
}
|
||
sqlDB.SetMaxOpenConns(1)
|
||
|
||
if err := db.AutoMigrate(&MirrorUser{}, &MirrorDepartment{}, &MirrorUserDepartment{},
|
||
&ResignConfirmToken{}, &SyncRun{}, &Account{}, &Group{}); err != nil {
|
||
return nil, fmt.Errorf("建表失败: %w", err)
|
||
}
|
||
return &Store{db: db}, nil
|
||
}
|
||
|
||
// Close 关闭底层连接。
|
||
func (s *Store) Close() error {
|
||
sqlDB, err := s.db.DB()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return sqlDB.Close()
|
||
}
|
||
|
||
// DB 暴露底层句柄,供需要自定义查询的调用方使用。
|
||
func (s *Store) DB() *gorm.DB { return s.db }
|