chore: 初始化 idsync 项目
飞书通讯录 ↔ LDAP 同步工具的首个提交。 - internal/feishu: 飞书通讯录客户端、快照与重试 - internal/ldaplib: LDAP 读写与批量操作 - internal/mapping: 飞书部门/用户到 LDAP 条目的映射 - internal/syncer: 同步计划生成、执行与调度 - internal/store: SQLite 台账 - internal/server + internal/webui: 管理后台与内嵌前端产物 - internal/password: SSHA 口令处理 - config.example.yml / Dockerfile / Makefile / web 前端源码 真实凭据(config.yml)、运行时数据(data/、backup/)与构建产物(bin/)不入库。
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// --- 账号台账 ---
|
||||
|
||||
// AllAccounts 返回全部受管账号。
|
||||
func (s *Store) AllAccounts() ([]*Account, error) {
|
||||
var out []*Account
|
||||
if err := s.db.Order("uid").Find(&out).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AccountsByUID 返回 uid → 账号 的映射,供 diff 引擎使用。
|
||||
func (s *Store) AccountsByUID() (map[string]*Account, error) {
|
||||
list, err := s.AllAccounts()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[string]*Account, len(list))
|
||||
for _, a := range list {
|
||||
out[a.UID] = a
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UpsertAccount 写入或更新一条账号台账。
|
||||
func (s *Store) UpsertAccount(a *Account) error {
|
||||
a.LastSyncedAt = time.Now()
|
||||
return s.db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "uid"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"dn", "union_id", "user_id", "email", "name",
|
||||
"state", "attrs_hash", "dept_ids", "last_synced_at",
|
||||
}),
|
||||
}).Create(a).Error
|
||||
}
|
||||
|
||||
// SetAccountState 更新账号状态与 DN(用于在职 ↔ 离职迁移)。
|
||||
func (s *Store) SetAccountState(uid, state, dn string) error {
|
||||
return s.db.Model(&Account{}).Where("uid = ?", uid).
|
||||
Updates(map[string]any{"state": state, "dn": dn}).Error
|
||||
}
|
||||
|
||||
// DeleteAccount 从台账删除一条账号。
|
||||
func (s *Store) DeleteAccount(uid string) error {
|
||||
return s.db.Where("uid = ?", uid).Delete(&Account{}).Error
|
||||
}
|
||||
|
||||
// --- 分组台账 ---
|
||||
|
||||
// AllGroups 返回全部受管分组。
|
||||
func (s *Store) AllGroups() ([]*Group, error) {
|
||||
var out []*Group
|
||||
if err := s.db.Order("dept_id").Find(&out).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GroupsByDeptID 返回 dept_id → 分组 的映射。
|
||||
func (s *Store) GroupsByDeptID() (map[string]*Group, error) {
|
||||
list, err := s.AllGroups()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[string]*Group, len(list))
|
||||
for _, g := range list {
|
||||
out[g.DeptID] = g
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UpsertGroup 写入或更新一条分组台账。
|
||||
func (s *Store) UpsertGroup(g *Group) error {
|
||||
g.LastSyncedAt = time.Now()
|
||||
return s.db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "dept_id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"dn", "name", "parent_id", "state", "last_synced_at",
|
||||
}),
|
||||
}).Create(g).Error
|
||||
}
|
||||
|
||||
// SetGroupState 更新分组状态。
|
||||
func (s *Store) SetGroupState(deptID, state string) error {
|
||||
return s.db.Model(&Group{}).Where("dept_id = ?", deptID).
|
||||
Update("state", state).Error
|
||||
}
|
||||
|
||||
// DeleteGroup 从台账删除一条分组。
|
||||
func (s *Store) DeleteGroup(deptID string) error {
|
||||
return s.db.Where("dept_id = ?", deptID).Delete(&Group{}).Error
|
||||
}
|
||||
|
||||
// --- 同步记录 ---
|
||||
|
||||
// StartRun 创建一条同步记录并返回该记录。
|
||||
//
|
||||
// 返回完整记录而非仅 ID:调用方若自行构造一个只含 ID 的结构体再保存,
|
||||
// GORM 的 Save 会把未赋值的 StartedAt/Mode/Trigger 一并写成零值,
|
||||
// 导致历史页面显示 0001-01-01 与空白模式。
|
||||
func (s *Store) StartRun(mode, trigger string) (*SyncRun, error) {
|
||||
run := &SyncRun{
|
||||
StartedAt: time.Now(),
|
||||
Mode: mode,
|
||||
Trigger: trigger,
|
||||
Status: "running",
|
||||
}
|
||||
if err := s.db.Create(run).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return run, nil
|
||||
}
|
||||
|
||||
// FinishRun 完成一条同步记录。
|
||||
func (s *Store) FinishRun(run *SyncRun) error {
|
||||
run.FinishedAt = time.Now()
|
||||
return s.db.Save(run).Error
|
||||
}
|
||||
|
||||
// RecentRuns 返回最近的同步记录,最新在前。
|
||||
func (s *Store) RecentRuns(limit int) ([]*SyncRun, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
var out []*SyncRun
|
||||
if err := s.db.Order("id desc").Limit(limit).Find(&out).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// LastRun 返回最近一次成功执行的记录,没有则返回 nil。
|
||||
func (s *Store) LastRun() (*SyncRun, error) {
|
||||
var out SyncRun
|
||||
err := s.db.Where("status = ?", "success").Order("id desc").First(&out).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// 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(&Account{}, &Group{}, &SyncRun{}); 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 }
|
||||
Reference in New Issue
Block a user