Files
ai_site/platform/internal/dbsync/push.go
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

257 lines
6.9 KiB
Go
Raw 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 (
"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
}