Files
whm 76cdcd760e feat: ship loose-offline dbsync (validate, agent push, LWW audit)
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>
2026-07-31 17:54:14 +08:00

445 lines
9.2 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}