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

@@ -0,0 +1,143 @@
package dbsync
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/google/uuid"
)
// Binding 本机库 ↔ 线上库映射P1不挡推送供登记/查询)。
type Binding struct {
ID string `json:"id"`
TenantID int64 `json:"tenant_id"`
UserID int64 `json:"user_id,omitempty"`
LocalDatabaseID string `json:"local_database_id"`
OnlineDBID string `json:"online_db_id"`
ChannelID string `json:"channel_id,omitempty"`
Note string `json:"note,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (s *FileStore) bindingPath() string {
return filepath.Join(s.dir, "bindings.json")
}
func (s *FileStore) EnsureBinding(b Binding) (Binding, error) {
s.mu.Lock()
defer s.mu.Unlock()
list, err := s.readBindingsUnlocked()
if err != nil {
return b, err
}
b.LocalDatabaseID = strings.TrimSpace(b.LocalDatabaseID)
b.OnlineDBID = strings.TrimSpace(b.OnlineDBID)
if b.TenantID <= 0 {
return b, fmt.Errorf("tenant_id required")
}
if b.LocalDatabaseID == "" || b.OnlineDBID == "" {
return b, fmt.Errorf("local_database_id and online_db_id required")
}
now := time.Now().UTC()
for i := range list {
if list[i].TenantID == b.TenantID && list[i].LocalDatabaseID == b.LocalDatabaseID {
if b.UserID > 0 && list[i].UserID > 0 && list[i].UserID != b.UserID {
continue
}
list[i].OnlineDBID = b.OnlineDBID
if b.ChannelID != "" {
list[i].ChannelID = b.ChannelID
}
if b.Note != "" {
list[i].Note = b.Note
}
if b.UserID > 0 {
list[i].UserID = b.UserID
}
list[i].UpdatedAt = now
if err := s.writeBindingsUnlocked(list); err != nil {
return b, err
}
return list[i], nil
}
}
if b.ID == "" {
b.ID = uuid.NewString()
}
b.CreatedAt = now
b.UpdatedAt = now
list = append(list, b)
if err := s.writeBindingsUnlocked(list); err != nil {
return b, err
}
return b, nil
}
func (s *FileStore) ListBindings(tenantID int64, localDatabaseID string) ([]Binding, error) {
s.mu.Lock()
defer s.mu.Unlock()
list, err := s.readBindingsUnlocked()
if err != nil {
return nil, err
}
out := make([]Binding, 0)
for _, b := range list {
if b.TenantID != tenantID {
continue
}
if localDatabaseID != "" && b.LocalDatabaseID != localDatabaseID {
continue
}
out = append(out, b)
}
return out, nil
}
func (s *FileStore) GetBinding(tenantID int64, localDatabaseID string) (*Binding, error) {
list, err := s.ListBindings(tenantID, localDatabaseID)
if err != nil {
return nil, err
}
if len(list) == 0 {
return nil, fmt.Errorf("binding not found")
}
cp := list[0]
return &cp, nil
}
func (s *FileStore) readBindingsUnlocked() ([]Binding, error) {
path := s.bindingPath()
b, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return []Binding{}, nil
}
return nil, err
}
if len(b) == 0 {
return []Binding{}, nil
}
var list []Binding
if err := json.Unmarshal(b, &list); err != nil {
return nil, err
}
return list, nil
}
func (s *FileStore) writeBindingsUnlocked(list []Binding) error {
path := s.bindingPath()
raw, err := json.MarshalIndent(list, "", " ")
if err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
return err
}
return os.Rename(tmp, path)
}