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>
This commit is contained in:
@@ -11,12 +11,13 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// FileStore 持久化通道与冲突队列(JSON),不依赖业务库类型。
|
||||
// FileStore 持久化通道、冲突队列与超管 LWW 审计(JSON),不依赖业务库类型。
|
||||
type FileStore struct {
|
||||
mu sync.Mutex
|
||||
dir string
|
||||
chPath string
|
||||
cfPath string
|
||||
mu sync.Mutex
|
||||
dir string
|
||||
chPath string
|
||||
cfPath string
|
||||
lwwPath string
|
||||
}
|
||||
|
||||
func NewFileStore(dir string) (*FileStore, error) {
|
||||
@@ -27,9 +28,10 @@ func NewFileStore(dir string) (*FileStore, error) {
|
||||
return nil, err
|
||||
}
|
||||
return &FileStore{
|
||||
dir: dir,
|
||||
chPath: filepath.Join(dir, "channels.json"),
|
||||
cfPath: filepath.Join(dir, "conflicts.json"),
|
||||
dir: dir,
|
||||
chPath: filepath.Join(dir, "channels.json"),
|
||||
cfPath: filepath.Join(dir, "conflicts.json"),
|
||||
lwwPath: filepath.Join(dir, "lww_overrides.json"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -103,7 +105,7 @@ func (s *FileStore) SaveChannel(ch Channel) (Channel, error) {
|
||||
ch.Direction = DirLocalToRemote
|
||||
}
|
||||
if ch.ConflictPolicy == "" {
|
||||
ch.ConflictPolicy = PolicyQueue
|
||||
ch.ConflictPolicy = PolicyLWWSource
|
||||
}
|
||||
if ch.PKColumns == nil {
|
||||
ch.PKColumns = map[string]string{}
|
||||
@@ -120,6 +122,7 @@ func (s *FileStore) SaveChannel(ch Channel) (Channel, error) {
|
||||
}
|
||||
ch.CreatedAt = list[i].CreatedAt
|
||||
ch.Stats = list[i].Stats
|
||||
ch.LastReconcileAt = list[i].LastReconcileAt
|
||||
list[i] = ch
|
||||
found = true
|
||||
break
|
||||
@@ -325,3 +328,117 @@ func (s *FileStore) writeConflicts(list []Conflict) error {
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user