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,256 @@
package dbsync
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
)
// PushItem 本机 agent → 线上 A 的一条变更(形态 B
type PushItem struct {
Table string `json:"table"`
Op string `json:"op"` // insert|update|update_by_id|delete|upsert
RowPK string `json:"row_pk"`
Row map[string]any `json:"row"` // 单行;与 Rows 二选一
Rows []map[string]any `json:"rows"` // 兼容批量 insert
Version int64 `json:"version"` // outbox 单调 version0 则用时间戳
ClientOutboxID string `json:"client_outbox_id"`
OnlineDBID string `json:"online_db_id"`
}
// PushResult 单条推送结果。
type PushResult struct {
OK bool `json:"ok"`
Applied bool `json:"applied"`
Skipped bool `json:"skipped"`
Conflict bool `json:"conflict"`
AppliedVersion int64 `json:"applied_version"`
Message string `json:"message"`
ClientOutboxID string `json:"client_outbox_id,omitempty"`
}
// PushToRemote 仅打开 remote按 LWW/幂等将变更落到线上 A。不连本机 SQLite。
func PushToRemote(ctx context.Context, ch *Channel, store *FileStore, item PushItem) (*PushResult, error) {
if ch == nil {
return nil, fmt.Errorf("channel is nil")
}
table := strings.TrimSpace(item.Table)
if table == "" {
return nil, fmt.Errorf("table required")
}
if !tableInChannel(ch, table) {
return nil, fmt.Errorf("table %s 不在通道白名单", table)
}
pkCol := "id"
if ch.PKColumns != nil && strings.TrimSpace(ch.PKColumns[table]) != "" {
pkCol = ch.PKColumns[table]
}
op, payload, rowPK, err := normalizePushPayload(item, pkCol)
if err != nil {
return nil, err
}
version := item.Version
if version <= 0 {
version = time.Now().UnixNano()
}
db, err := Open(ch.Remote.Driver, ch.Remote.DSN)
if err != nil {
return nil, fmt.Errorf("open remote: %w", err)
}
defer db.Close()
if err := EnsureMeta(ctx, db, ch.Remote.Driver); err != nil {
return nil, fmt.Errorf("ensure meta: %w", err)
}
res := &PushResult{
ClientOutboxID: item.ClientOutboxID,
AppliedVersion: version,
}
tgtVer, has, err := GetMetaVersion(ctx, db, ch.Remote.Driver, table, rowPK)
if err != nil {
return nil, err
}
// 幂等:同 version 已落地 → 跳过
if has && tgtVer == version {
res.OK = true
res.Skipped = true
res.Message = "already applied (same version)"
return res, nil
}
if has && tgtVer > version {
policy := ch.ConflictPolicy
if policy == "" {
policy = PolicyLWWSource // B→A 默认偏源
}
switch policy {
case PolicyLWWTarget:
loser := payload
winner := SnapshotTargetRow(ctx, db, ch.Remote.Driver, table, pkCol, rowPK)
RecordLwwOverride(store, LwwOverride{
TenantID: ch.TenantID,
ChannelID: ch.ID,
Table: table,
RowPK: rowPK,
Op: op,
Entry: EntryAgentPush,
Policy: string(PolicyLWWTarget),
Outcome: OutcomeKept,
LoserPayload: loser,
WinnerPayload: winner,
TargetVer: tgtVer,
SourceVer: version,
})
res.OK = true
res.Skipped = true
res.Message = "target newer; kept (lww_target)"
return res, nil
case PolicyLWWSource:
loser := SnapshotTargetRow(ctx, db, ch.Remote.Driver, table, pkCol, rowPK)
if err := ApplyChange(ctx, db, ch.Remote.Driver, table, pkCol, op, payload, version); err != nil {
return nil, err
}
RecordLwwOverride(store, LwwOverride{
TenantID: ch.TenantID,
ChannelID: ch.ID,
Table: table,
RowPK: rowPK,
Op: op,
Entry: EntryAgentPush,
Policy: string(PolicyLWWSource),
Outcome: OutcomeApplied,
LoserPayload: loser,
WinnerPayload: payload,
TargetVer: tgtVer,
SourceVer: version,
})
if store != nil {
_ = store.PatchStats(ch.ID, func(c *Channel) { c.Stats.PushedOK++ })
}
res.OK = true
res.Applied = true
res.Message = "applied (lww_source override)"
return res, nil
default:
if store != nil {
_ = store.AddConflict(Conflict{
TenantID: ch.TenantID,
ChannelID: ch.ID,
Table: table,
RowPK: rowPK,
Op: op,
Source: "agent",
Payload: payload,
TargetVer: tgtVer,
SourceVer: version,
Message: "target version newer than agent push",
})
_ = store.PatchStats(ch.ID, func(c *Channel) { c.Stats.Conflicts++ })
}
res.OK = true
res.Skipped = true
res.Conflict = true
res.Message = "queued conflict; target newer"
return res, nil
}
}
if err := ApplyChange(ctx, db, ch.Remote.Driver, table, pkCol, op, payload, version); err != nil {
return nil, err
}
if store != nil {
_ = store.PatchStats(ch.ID, func(c *Channel) { c.Stats.PushedOK++ })
}
res.OK = true
res.Applied = true
res.Message = "applied"
return res, nil
}
// PushBatchToRemote 保序批量;遇错即停,已成功条数在返回切片中。
func PushBatchToRemote(ctx context.Context, ch *Channel, store *FileStore, items []PushItem) ([]PushResult, error) {
out := make([]PushResult, 0, len(items))
for i, it := range items {
r, err := PushToRemote(ctx, ch, store, it)
if err != nil {
fail := PushResult{
OK: false,
Message: err.Error(),
ClientOutboxID: it.ClientOutboxID,
}
out = append(out, fail)
return out, fmt.Errorf("item[%d]: %w", i, err)
}
out = append(out, *r)
}
return out, nil
}
func tableInChannel(ch *Channel, table string) bool {
for _, t := range uniqueTables(ch.Local.Tables, ch.Remote.Tables) {
if strings.EqualFold(strings.TrimSpace(t), table) {
return true
}
}
return false
}
func normalizePushPayload(item PushItem, pkCol string) (op string, payload string, rowPK string, err error) {
rawOp := strings.ToLower(strings.TrimSpace(item.Op))
if rawOp == "" {
rawOp = "upsert"
}
switch rawOp {
case "delete":
op = "delete"
case "insert", "update", "update_by_id", "upsert":
op = "upsert"
default:
return "", "", "", fmt.Errorf("unsupported op: %s", item.Op)
}
row := item.Row
if row == nil && len(item.Rows) > 0 {
row = item.Rows[0]
}
rowPK = strings.TrimSpace(item.RowPK)
if row != nil {
if rowPK == "" {
rowPK = strings.TrimSpace(fmt.Sprint(row[pkCol]))
}
if op == "upsert" {
// 保证 payload 含主键
if _, ok := row[pkCol]; !ok && rowPK != "" {
row = copyMap(row)
row[pkCol] = rowPK
}
}
}
if rowPK == "" || rowPK == "<nil>" {
return "", "", "", fmt.Errorf("row_pk required")
}
if op == "delete" && row == nil {
row = map[string]any{pkCol: rowPK}
}
if row == nil {
return "", "", "", fmt.Errorf("row required for %s", op)
}
b, err := json.Marshal(row)
if err != nil {
return "", "", "", err
}
return op, string(b), rowPK, nil
}
func copyMap(m map[string]any) map[string]any {
out := make(map[string]any, len(m)+1)
for k, v := range m {
out[k] = v
}
return out
}