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

@@ -93,10 +93,24 @@ func (m *Manager) loop(ctx context.Context, id string) {
log.Printf("dbsync channel %s: %v", id, err)
}
ticks++
// 约每分钟主键对账一次,补漏(漏投递 / 触发器未装时的存量差
if ticks%120 == 0 && ch.Direction == DirBidirectional {
if _, rerr := ReconcileChannel(ctx, ch); rerr != nil {
log.Printf("dbsync reconcile %s: %v", id, rerr)
// 双向通道自动对账:默认约每 15 分钟一次(限流
autoEvery := 1800 // poll 500ms → ~15min
if ch.PollIntervalMS > 0 {
autoEvery = int((15 * time.Minute) / (time.Duration(ch.PollIntervalMS) * time.Millisecond))
if autoEvery < 60 {
autoEvery = 60
}
}
if ticks%autoEvery == 0 && ch.Direction == DirBidirectional {
if allow, _ := CanReconcile(ch, 15*time.Minute); allow {
if _, rerr := ReconcileChannel(ctx, ch); rerr != nil {
log.Printf("dbsync reconcile %s: %v", id, rerr)
} else {
_ = m.store.PatchStats(id, func(c *Channel) {
now := time.Now().UTC()
c.LastReconcileAt = &now
})
}
}
}
select {
@@ -214,12 +228,63 @@ func (m *Manager) drain(ctx context.Context, ch *Channel, sourceName string, src
continue
}
if has && tgtVer > r.Version {
switch ch.ConflictPolicy {
policy := ch.ConflictPolicy
if policy == "" {
policy = PolicyLWWSource
}
switch policy {
case PolicyLWWTarget:
loser := payload
winner := SnapshotTargetRow(ctx, dst, dstEp.Driver, r.TableName, pkCol, r.RowPK)
RecordLwwOverride(m.store, LwwOverride{
TenantID: ch.TenantID,
ChannelID: ch.ID,
Table: r.TableName,
RowPK: r.RowPK,
Op: r.Op,
Entry: EntryDrain,
Policy: string(PolicyLWWTarget),
Outcome: OutcomeKept,
LoserPayload: loser,
WinnerPayload: winner,
TargetVer: tgtVer,
SourceVer: r.Version,
})
done = append(done, r.ID)
continue
case PolicyLWWSource:
// fallthrough apply
loser := SnapshotTargetRow(ctx, dst, dstEp.Driver, r.TableName, pkCol, r.RowPK)
if err := ApplyChange(ctx, dst, dstEp.Driver, r.TableName, pkCol, r.Op, payload, r.Version); err != nil {
_ = m.store.PatchStats(ch.ID, func(c *Channel) {
c.Stats.Retries++
c.LastError = err.Error()
})
continue
}
RecordLwwOverride(m.store, LwwOverride{
TenantID: ch.TenantID,
ChannelID: ch.ID,
Table: r.TableName,
RowPK: r.RowPK,
Op: r.Op,
Entry: EntryDrain,
Policy: string(PolicyLWWSource),
Outcome: OutcomeApplied,
LoserPayload: loser,
WinnerPayload: payload,
TargetVer: tgtVer,
SourceVer: r.Version,
})
done = append(done, r.ID)
okCount++
_ = m.store.PatchStats(ch.ID, func(c *Channel) {
if sourceName == "local" {
c.Stats.PushedOK++
} else {
c.Stats.PulledOK++
}
})
continue
default:
_ = m.store.AddConflict(Conflict{
TenantID: ch.TenantID,
@@ -264,6 +329,9 @@ func (m *Manager) drain(ctx context.Context, ch *Channel, sourceName string, src
// PrepareChannel 连接两端、建 outbox/触发器,供「测试/启用」调用。
func PrepareChannel(ctx context.Context, ch *Channel) error {
if err := ValidateChannelAgainstDB(ctx, ch); err != nil {
return err
}
for _, ep := range []Endpoint{ch.Local, ch.Remote} {
db, err := Open(ep.Driver, ep.DSN)
if err != nil {