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:
whm
2026-07-31 17:54:14 +08:00
parent 632057c857
commit 76cdcd760e
39 changed files with 3302 additions and 199 deletions

View File

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