Files
ai_site/platform/internal/dbsync/reconcile.go
2026-07-31 10:31:17 +08:00

183 lines
5.1 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"
"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
}