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) }