Add UUID/FK channel checks, agent whitelist/push APIs, bindings, super-admin LWW audit with rollback, reconcile rate limits, and sync docs. Default customers stay opt-in; company conflict UI is removed. Co-authored-by: Cursor <cursoragent@cursor.com>
445 lines
9.2 KiB
Go
445 lines
9.2 KiB
Go
package dbsync
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/google/uuid"
|
||
)
|
||
|
||
// FileStore 持久化通道、冲突队列与超管 LWW 审计(JSON),不依赖业务库类型。
|
||
type FileStore struct {
|
||
mu sync.Mutex
|
||
dir string
|
||
chPath string
|
||
cfPath string
|
||
lwwPath string
|
||
}
|
||
|
||
func NewFileStore(dir string) (*FileStore, error) {
|
||
if dir == "" {
|
||
dir = "./data/dbsync"
|
||
}
|
||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||
return nil, err
|
||
}
|
||
return &FileStore{
|
||
dir: dir,
|
||
chPath: filepath.Join(dir, "channels.json"),
|
||
cfPath: filepath.Join(dir, "conflicts.json"),
|
||
lwwPath: filepath.Join(dir, "lww_overrides.json"),
|
||
}, nil
|
||
}
|
||
|
||
func (s *FileStore) ListChannels() ([]Channel, error) {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
return s.readChannels()
|
||
}
|
||
|
||
func (s *FileStore) ListChannelsByTenant(tenantID int64) ([]Channel, error) {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
list, err := s.readChannels()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out := make([]Channel, 0)
|
||
for _, c := range list {
|
||
if c.TenantID == tenantID {
|
||
out = append(out, c)
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func (s *FileStore) GetChannel(id string) (*Channel, error) {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
list, err := s.readChannels()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
for i := range list {
|
||
if list[i].ID == id {
|
||
cp := list[i]
|
||
return &cp, nil
|
||
}
|
||
}
|
||
return nil, fmt.Errorf("channel not found")
|
||
}
|
||
|
||
// GetChannelForTenant 仅返回属于该租户的通道。
|
||
func (s *FileStore) GetChannelForTenant(id string, tenantID int64) (*Channel, error) {
|
||
ch, err := s.GetChannel(id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if ch.TenantID != tenantID {
|
||
return nil, fmt.Errorf("channel not found")
|
||
}
|
||
return ch, nil
|
||
}
|
||
|
||
func (s *FileStore) SaveChannel(ch Channel) (Channel, error) {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
list, err := s.readChannels()
|
||
if err != nil {
|
||
return ch, err
|
||
}
|
||
now := time.Now().UTC()
|
||
if ch.ID == "" {
|
||
ch.ID = uuid.NewString()
|
||
ch.CreatedAt = now
|
||
}
|
||
ch.UpdatedAt = now
|
||
if ch.PollIntervalMS <= 0 {
|
||
ch.PollIntervalMS = 500
|
||
}
|
||
if ch.Direction == "" {
|
||
ch.Direction = DirLocalToRemote
|
||
}
|
||
if ch.ConflictPolicy == "" {
|
||
ch.ConflictPolicy = PolicyLWWSource
|
||
}
|
||
if ch.PKColumns == nil {
|
||
ch.PKColumns = map[string]string{}
|
||
}
|
||
found := false
|
||
for i := range list {
|
||
if list[i].ID == ch.ID {
|
||
// 禁止跨租户覆盖;更新时锁定原 tenant_id
|
||
if list[i].TenantID != 0 && ch.TenantID != 0 && list[i].TenantID != ch.TenantID {
|
||
return ch, fmt.Errorf("channel belongs to another tenant")
|
||
}
|
||
if ch.TenantID == 0 {
|
||
ch.TenantID = list[i].TenantID
|
||
}
|
||
ch.CreatedAt = list[i].CreatedAt
|
||
ch.Stats = list[i].Stats
|
||
ch.LastReconcileAt = list[i].LastReconcileAt
|
||
list[i] = ch
|
||
found = true
|
||
break
|
||
}
|
||
}
|
||
if !found {
|
||
if ch.TenantID <= 0 {
|
||
return ch, fmt.Errorf("tenant_id required")
|
||
}
|
||
list = append(list, ch)
|
||
}
|
||
if err := s.writeChannels(list); err != nil {
|
||
return ch, err
|
||
}
|
||
return ch, nil
|
||
}
|
||
|
||
func (s *FileStore) DeleteChannel(id string) error {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
list, err := s.readChannels()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
next := list[:0]
|
||
for _, c := range list {
|
||
if c.ID != id {
|
||
next = append(next, c)
|
||
}
|
||
}
|
||
return s.writeChannels(next)
|
||
}
|
||
|
||
func (s *FileStore) DeleteChannelForTenant(id string, tenantID int64) error {
|
||
ch, err := s.GetChannelForTenant(id, tenantID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
_ = ch
|
||
return s.DeleteChannel(id)
|
||
}
|
||
|
||
func (s *FileStore) PatchStats(id string, fn func(*Channel)) error {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
list, err := s.readChannels()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
for i := range list {
|
||
if list[i].ID == id {
|
||
fn(&list[i])
|
||
list[i].UpdatedAt = time.Now().UTC()
|
||
return s.writeChannels(list)
|
||
}
|
||
}
|
||
return fmt.Errorf("channel not found")
|
||
}
|
||
|
||
func (s *FileStore) AddConflict(c Conflict) error {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
list, err := s.readConflicts()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if c.ID == "" {
|
||
c.ID = uuid.NewString()
|
||
}
|
||
if c.CreatedAt.IsZero() {
|
||
c.CreatedAt = time.Now().UTC()
|
||
}
|
||
list = append(list, c)
|
||
return s.writeConflicts(list)
|
||
}
|
||
|
||
func (s *FileStore) ListConflicts(unresolvedOnly bool) ([]Conflict, error) {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
list, err := s.readConflicts()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if !unresolvedOnly {
|
||
return list, nil
|
||
}
|
||
out := make([]Conflict, 0)
|
||
for _, c := range list {
|
||
if !c.Resolved {
|
||
out = append(out, c)
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func (s *FileStore) ListConflictsByTenant(tenantID int64, unresolvedOnly bool) ([]Conflict, error) {
|
||
list, err := s.ListConflicts(unresolvedOnly)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out := make([]Conflict, 0)
|
||
for _, c := range list {
|
||
if c.TenantID == tenantID {
|
||
out = append(out, c)
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func (s *FileStore) ResolveConflict(id, resolution string) error {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
list, err := s.readConflicts()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
for i := range list {
|
||
if list[i].ID == id {
|
||
list[i].Resolved = true
|
||
list[i].Resolution = resolution
|
||
return s.writeConflicts(list)
|
||
}
|
||
}
|
||
return fmt.Errorf("conflict not found")
|
||
}
|
||
|
||
func (s *FileStore) ResolveConflictForTenant(id string, tenantID int64, resolution string) error {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
list, err := s.readConflicts()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
for i := range list {
|
||
if list[i].ID == id {
|
||
if list[i].TenantID != tenantID {
|
||
return fmt.Errorf("conflict not found")
|
||
}
|
||
list[i].Resolved = true
|
||
list[i].Resolution = resolution
|
||
return s.writeConflicts(list)
|
||
}
|
||
}
|
||
return fmt.Errorf("conflict not found")
|
||
}
|
||
|
||
func (s *FileStore) readChannels() ([]Channel, error) {
|
||
b, err := os.ReadFile(s.chPath)
|
||
if err != nil {
|
||
if os.IsNotExist(err) {
|
||
return []Channel{}, nil
|
||
}
|
||
return nil, err
|
||
}
|
||
var list []Channel
|
||
if len(b) == 0 {
|
||
return []Channel{}, nil
|
||
}
|
||
if err := json.Unmarshal(b, &list); err != nil {
|
||
return nil, err
|
||
}
|
||
return list, nil
|
||
}
|
||
|
||
func (s *FileStore) writeChannels(list []Channel) error {
|
||
b, err := json.MarshalIndent(list, "", " ")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
tmp := s.chPath + ".tmp"
|
||
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
||
return err
|
||
}
|
||
return os.Rename(tmp, s.chPath)
|
||
}
|
||
|
||
func (s *FileStore) readConflicts() ([]Conflict, error) {
|
||
b, err := os.ReadFile(s.cfPath)
|
||
if err != nil {
|
||
if os.IsNotExist(err) {
|
||
return []Conflict{}, nil
|
||
}
|
||
return nil, err
|
||
}
|
||
var list []Conflict
|
||
if len(b) == 0 {
|
||
return []Conflict{}, nil
|
||
}
|
||
if err := json.Unmarshal(b, &list); err != nil {
|
||
return nil, err
|
||
}
|
||
return list, nil
|
||
}
|
||
|
||
func (s *FileStore) writeConflicts(list []Conflict) error {
|
||
b, err := json.MarshalIndent(list, "", " ")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
tmp := s.cfPath + ".tmp"
|
||
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
||
return err
|
||
}
|
||
return os.Rename(tmp, s.cfPath)
|
||
}
|
||
|
||
func (s *FileStore) AddLwwOverride(o LwwOverride) error {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
list, err := s.readLwwOverrides()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if o.ID == "" {
|
||
o.ID = uuid.NewString()
|
||
}
|
||
if o.CreatedAt.IsZero() {
|
||
o.CreatedAt = time.Now().UTC()
|
||
}
|
||
list = append(list, o)
|
||
return s.writeLwwOverrides(list)
|
||
}
|
||
|
||
func (s *FileStore) GetLwwOverride(id string) (*LwwOverride, error) {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
list, err := s.readLwwOverrides()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
for i := range list {
|
||
if list[i].ID == id {
|
||
cp := list[i]
|
||
return &cp, nil
|
||
}
|
||
}
|
||
return nil, fmt.Errorf("lww override not found")
|
||
}
|
||
|
||
// ListLwwOverrides 超管查询;tenantID/channelID 为 0/空 表示不过滤。
|
||
func (s *FileStore) ListLwwOverrides(tenantID int64, channelID string, limit int) ([]LwwOverride, error) {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
list, err := s.readLwwOverrides()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out := make([]LwwOverride, 0, len(list))
|
||
for i := len(list) - 1; i >= 0; i-- { // 新→旧
|
||
o := list[i]
|
||
if tenantID > 0 && o.TenantID != tenantID {
|
||
continue
|
||
}
|
||
if channelID != "" && o.ChannelID != channelID {
|
||
continue
|
||
}
|
||
out = append(out, o)
|
||
if limit > 0 && len(out) >= limit {
|
||
break
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// PurgeLwwOverridesBefore 删除 created_at 早于 cutoff 的记录,返回删除条数。
|
||
func (s *FileStore) PurgeLwwOverridesBefore(cutoff time.Time) (int, error) {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
list, err := s.readLwwOverrides()
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
keep := make([]LwwOverride, 0, len(list))
|
||
removed := 0
|
||
for _, o := range list {
|
||
if o.CreatedAt.Before(cutoff) {
|
||
removed++
|
||
continue
|
||
}
|
||
keep = append(keep, o)
|
||
}
|
||
if removed == 0 {
|
||
return 0, nil
|
||
}
|
||
if err := s.writeLwwOverrides(keep); err != nil {
|
||
return 0, err
|
||
}
|
||
return removed, nil
|
||
}
|
||
|
||
func (s *FileStore) readLwwOverrides() ([]LwwOverride, error) {
|
||
b, err := os.ReadFile(s.lwwPath)
|
||
if err != nil {
|
||
if os.IsNotExist(err) {
|
||
return []LwwOverride{}, nil
|
||
}
|
||
return nil, err
|
||
}
|
||
if len(b) == 0 {
|
||
return []LwwOverride{}, nil
|
||
}
|
||
var list []LwwOverride
|
||
if err := json.Unmarshal(b, &list); err != nil {
|
||
return nil, err
|
||
}
|
||
return list, nil
|
||
}
|
||
|
||
func (s *FileStore) writeLwwOverrides(list []LwwOverride) error {
|
||
b, err := json.MarshalIndent(list, "", " ")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
tmp := s.lwwPath + ".tmp"
|
||
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
||
return err
|
||
}
|
||
return os.Rename(tmp, s.lwwPath)
|
||
}
|