183 lines
5.1 KiB
Go
183 lines
5.1 KiB
Go
package dbsync
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"encoding/json"
|
||
"fmt"
|
||
"time"
|
||
)
|
||
|
||
type ReconcileReport struct {
|
||
Table string `json:"table"`
|
||
OnlyLocal []string `json:"only_local"` // 在 B 有、A 无 → 需推到线上
|
||
OnlyRemote []string `json:"only_remote"` // 在 A 有、B 无 → 需拉到本地
|
||
PatchedPush int `json:"patched_push"`
|
||
PatchedPull int `json:"patched_pull"`
|
||
}
|
||
|
||
type ReconcileResult struct {
|
||
ChannelID string `json:"channel_id"`
|
||
Reports []ReconcileReport `json:"reports"`
|
||
Message string `json:"message"`
|
||
}
|
||
|
||
// ListPKs 列出表全部主键(用于对账,防漏)。
|
||
func ListPKs(ctx context.Context, db *sql.DB, driver Driver, table, pkCol string) ([]string, error) {
|
||
q := fmt.Sprintf(`SELECT %s FROM %s`, quoteIdent(driver, pkCol), quoteIdent(driver, table))
|
||
rows, err := db.QueryContext(ctx, q)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
var out []string
|
||
for rows.Next() {
|
||
var v any
|
||
if err := rows.Scan(&v); err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, fmt.Sprint(v))
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
// ReconcileChannel 全量主键对账:只在一端的行补投递,保证不漏;已有行靠 outbox/版本,不重复插入。
|
||
func ReconcileChannel(ctx context.Context, ch *Channel) (*ReconcileResult, error) {
|
||
local, err := Open(ch.Local.Driver, ch.Local.DSN)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("open local: %w", err)
|
||
}
|
||
defer local.Close()
|
||
remote, err := Open(ch.Remote.Driver, ch.Remote.DSN)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("open remote: %w", err)
|
||
}
|
||
defer remote.Close()
|
||
|
||
if err := prepareEndpoint(ctx, local, ch.Local, ch.PKColumns); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := prepareEndpoint(ctx, remote, ch.Remote, ch.PKColumns); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
tables := uniqueTables(ch.Local.Tables, ch.Remote.Tables)
|
||
res := &ReconcileResult{ChannelID: ch.ID, Message: "ok"}
|
||
for _, table := range tables {
|
||
pkCol := "id"
|
||
if ch.PKColumns != nil && ch.PKColumns[table] != "" {
|
||
pkCol = ch.PKColumns[table]
|
||
}
|
||
lpks, err := ListPKs(ctx, local, ch.Local.Driver, table, pkCol)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("list local %s: %w", table, err)
|
||
}
|
||
rpks, err := ListPKs(ctx, remote, ch.Remote.Driver, table, pkCol)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("list remote %s: %w", table, err)
|
||
}
|
||
lset := toSet(lpks)
|
||
rset := toSet(rpks)
|
||
rep := ReconcileReport{Table: table}
|
||
for pk := range lset {
|
||
if !rset[pk] {
|
||
rep.OnlyLocal = append(rep.OnlyLocal, pk)
|
||
}
|
||
}
|
||
for pk := range rset {
|
||
if !lset[pk] {
|
||
rep.OnlyRemote = append(rep.OnlyRemote, pk)
|
||
}
|
||
}
|
||
// 补漏:缺的一端从有的一端取行并 apply(带 WithApplying,不产生回声)
|
||
for _, pk := range rep.OnlyLocal {
|
||
js, _, ferr := FetchRowJSON(ctx, local, ch.Local.Driver, table, pkCol, pk)
|
||
if ferr != nil {
|
||
continue
|
||
}
|
||
ver := time.Now().UnixMilli()
|
||
if err := ApplyChange(ctx, remote, ch.Remote.Driver, table, pkCol, "upsert", js, ver); err == nil {
|
||
rep.PatchedPush++
|
||
_ = UpsertMeta(ctx, local, ch.Local.Driver, table, pk, ver)
|
||
}
|
||
}
|
||
for _, pk := range rep.OnlyRemote {
|
||
js, _, ferr := FetchRowJSON(ctx, remote, ch.Remote.Driver, table, pkCol, pk)
|
||
if ferr != nil {
|
||
continue
|
||
}
|
||
ver := time.Now().UnixMilli()
|
||
if err := ApplyChange(ctx, local, ch.Local.Driver, table, pkCol, "upsert", js, ver); err == nil {
|
||
rep.PatchedPull++
|
||
_ = UpsertMeta(ctx, remote, ch.Remote.Driver, table, pk, ver)
|
||
}
|
||
}
|
||
res.Reports = append(res.Reports, rep)
|
||
}
|
||
return res, nil
|
||
}
|
||
|
||
func uniqueTables(a, b []string) []string {
|
||
m := map[string]struct{}{}
|
||
var out []string
|
||
for _, t := range append(append([]string{}, a...), b...) {
|
||
if t == "" {
|
||
continue
|
||
}
|
||
if _, ok := m[t]; ok {
|
||
continue
|
||
}
|
||
m[t] = struct{}{}
|
||
out = append(out, t)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func toSet(xs []string) map[string]bool {
|
||
m := make(map[string]bool, len(xs))
|
||
for _, x := range xs {
|
||
m[x] = true
|
||
}
|
||
return m
|
||
}
|
||
|
||
// IngestRows:外部源 C → 写入本地 B(会触发 outbox,再同步到线上 A)。
|
||
// 按主键 upsert,同一 PK 重复灌入不会「多」出一行。
|
||
func IngestRows(ctx context.Context, ch *Channel, table string, rows []map[string]any, sourceLabel string) (int, error) {
|
||
if table == "" {
|
||
return 0, fmt.Errorf("table required")
|
||
}
|
||
pkCol := "id"
|
||
if ch.PKColumns != nil && ch.PKColumns[table] != "" {
|
||
pkCol = ch.PKColumns[table]
|
||
}
|
||
db, err := Open(ch.Local.Driver, ch.Local.DSN)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
defer db.Close()
|
||
if err := prepareEndpoint(ctx, db, ch.Local, ch.PKColumns); err != nil {
|
||
return 0, err
|
||
}
|
||
n := 0
|
||
for _, row := range rows {
|
||
if row == nil {
|
||
continue
|
||
}
|
||
pkVal := fmt.Sprint(row[pkCol])
|
||
if pkVal == "" || pkVal == "<nil>" {
|
||
return n, fmt.Errorf("row missing pk %s", pkCol)
|
||
}
|
||
// 不置 applying:让触发器写 outbox,随后 worker 推到 A
|
||
b, _ := json.Marshal(row)
|
||
ver := time.Now().UnixMilli()
|
||
// 正常写入本地:触发器入 outbox → worker 推到线上 A;同 PK upsert 不会多行
|
||
if err := applyChangeInner(ctx, db, ch.Local.Driver, table, pkCol, "upsert", string(b), ver); err != nil {
|
||
return n, err
|
||
}
|
||
_ = sourceLabel
|
||
n++
|
||
}
|
||
return n, nil
|
||
}
|