feat: add schema fingerprint and Z10d alter; refresh sync coop docs

Ship ensure ADD COLUMN and fingerprint API for Yuheng reconcile, and rewrite §0.2–0.3 cooperation checklist in the联调意见.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
whm
2026-08-05 18:16:55 +08:00
parent cb76824e94
commit 4fae7ae718
5 changed files with 420 additions and 72 deletions

View File

@@ -37,8 +37,8 @@ func EnsureTableFromRow(ctx context.Context, db *sql.DB, driver Driver, table, p
return EnsureTableFromColumns(ctx, db, driver, table, pkCol, cols)
}
// EnsureTableFromColumns 表不存在时按列名建空表(全部 TEXT指定 PK已存在则幂等跳过。
// 用于空表结构同步:本机有空表 → 线上也建同名空表(无需 outbox 行)
// EnsureTableFromColumns 按列名建空表(全部 TEXT指定 PK
// 已存在时补齐缺失列ADD COLUMN解决「先按 _row_id 建表、后 push 注入 id」导致的缺列 503
func EnsureTableFromColumns(ctx context.Context, db *sql.DB, driver Driver, table, pkCol string, columns []string) error {
table = strings.TrimSpace(table)
pkCol = strings.TrimSpace(pkCol)
@@ -55,27 +55,57 @@ func EnsureTableFromColumns(ctx context.Context, db *sql.DB, driver Driver, tabl
if err != nil {
return err
}
if exists {
return nil
}
want := make([]string, 0, len(columns)+1)
seen := map[string]struct{}{}
defs := make([]string, 0, len(columns)+1)
defs = append(defs, fmt.Sprintf("%s TEXT PRIMARY KEY", quoteIdent(driver, pkCol)))
seen[pkCol] = struct{}{}
for _, c := range columns {
add := func(c string) {
c = strings.TrimSpace(c)
if c == "" {
continue
return
}
if _, ok := seen[c]; ok {
continue
return
}
seen[c] = struct{}{}
defs = append(defs, fmt.Sprintf("%s TEXT", quoteIdent(driver, c)))
want = append(want, c)
}
if len(defs) == 0 {
add(pkCol)
for _, c := range columns {
add(c)
}
if len(want) == 0 {
return fmt.Errorf("columns required")
}
if exists {
have, err := listColumns(ctx, db, driver, table)
if err != nil {
return err
}
haveSet := map[string]struct{}{}
for _, c := range have {
haveSet[c] = struct{}{}
}
for _, c := range want {
if _, ok := haveSet[c]; ok {
continue
}
ddl := fmt.Sprintf(`ALTER TABLE %s ADD COLUMN %s TEXT`, quoteIdent(driver, table), quoteIdent(driver, c))
if _, err := db.ExecContext(ctx, ddl); err != nil {
// SQLite 无 IF NOT EXISTS并发下可能已存在
if !strings.Contains(strings.ToLower(err.Error()), "duplicate column") {
return err
}
}
}
return nil
}
defs := make([]string, 0, len(want))
defs = append(defs, fmt.Sprintf("%s TEXT PRIMARY KEY", quoteIdent(driver, pkCol)))
for _, c := range want {
if c == pkCol {
continue
}
defs = append(defs, fmt.Sprintf("%s TEXT", quoteIdent(driver, c)))
}
ddl := fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s (%s)`, quoteIdent(driver, table), strings.Join(defs, ", "))
_, err = db.ExecContext(ctx, ddl)
return err

View File

@@ -0,0 +1,199 @@
package dbsync
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"sort"
"strings"
)
// FingerprintTable 单表指纹(与宇恒 sync_fingerprint 算法对齐)。
type FingerprintTable struct {
Name string `json:"name"`
PKColumn string `json:"pk_column"`
RowCount int64 `json:"row_count"`
ContentHash string `json:"content_hash"`
Columns []string `json:"columns,omitempty"`
Error string `json:"error,omitempty"`
}
// FingerprintRequest agent 拉线上表指纹。
type FingerprintRequest struct {
OnlineDBID string `json:"online_db_id"`
Tables []string `json:"tables,omitempty"`
}
// FingerprintResult 批量指纹。
type FingerprintResult struct {
OK bool `json:"ok"`
Tables []FingerprintTable `json:"tables"`
}
// FingerprintRemoteTables 计算线上 A 业务表 row_count + content_hash。
// 哈希sha256( table+"|"+pk+"|" + Σ json.dumps(row, sort_keys=True)+"\n" ) 取前 32 hex。
func FingerprintRemoteTables(ctx context.Context, ch *Channel, req FingerprintRequest) (*FingerprintResult, error) {
if ch == nil {
return nil, fmt.Errorf("channel is nil")
}
db, err := AcquireRemote(ch.Remote.Driver, ch.Remote.DSN)
if err != nil {
return nil, wrapOpenRemote(err)
}
if err := EnsureMeta(ctx, db, ch.Remote.Driver); err != nil {
return nil, Retryablef("ensure meta: %v", err)
}
names, err := ListTables(ctx, db, ch.Remote.Driver)
if err != nil {
return nil, Retryablef("list tables: %v", err)
}
want := map[string]struct{}{}
for _, t := range req.Tables {
t = strings.TrimSpace(t)
if t != "" {
want[t] = struct{}{}
}
}
out := &FingerprintResult{OK: true, Tables: make([]FingerprintTable, 0, len(names))}
for _, name := range names {
if strings.HasPrefix(name, "_ajz_") {
continue
}
if len(want) > 0 {
if _, ok := want[name]; !ok {
continue
}
}
if !tableInChannel(ch, name) {
continue
}
fp, ferr := fingerprintOneTable(ctx, db, ch, name)
if ferr != nil {
fp.Error = ferr.Error()
out.OK = false
}
out.Tables = append(out.Tables, fp)
}
return out, nil
}
func fingerprintOneTable(ctx context.Context, db *sql.DB, ch *Channel, table string) (FingerprintTable, error) {
cols, err := listColumns(ctx, db, ch.Remote.Driver, table)
if err != nil {
return FingerprintTable{Name: table}, err
}
pkCol := "id"
if ch.PKColumns != nil && strings.TrimSpace(ch.PKColumns[table]) != "" {
pkCol = ch.PKColumns[table]
}
if !containsStr(cols, pkCol) {
for _, c := range []string{"id", "__row_id", "_row_id"} {
if containsStr(cols, c) {
pkCol = c
break
}
}
if !containsStr(cols, pkCol) && len(cols) > 0 {
pkCol = cols[0]
}
}
n, err := countRows(ctx, db, ch.Remote.Driver, table)
if err != nil {
return FingerprintTable{Name: table, PKColumn: pkCol, Columns: cols}, err
}
hash, err := contentHashOrdered(ctx, db, ch.Remote.Driver, table, pkCol)
if err != nil {
return FingerprintTable{Name: table, PKColumn: pkCol, RowCount: n, Columns: cols}, err
}
return FingerprintTable{
Name: table,
PKColumn: pkCol,
RowCount: n,
ContentHash: hash,
Columns: cols,
}, nil
}
func containsStr(ss []string, x string) bool {
for _, s := range ss {
if s == x {
return true
}
}
return false
}
func contentHashOrdered(ctx context.Context, db *sql.DB, driver Driver, table, pkCol string) (string, error) {
q := fmt.Sprintf(`SELECT * FROM %s ORDER BY %s`, quoteIdent(driver, table), quoteIdent(driver, pkCol))
rows, err := db.QueryContext(ctx, q)
if err != nil {
return "", err
}
defer rows.Close()
colNames, err := rows.Columns()
if err != nil {
return "", err
}
h := sha256.New()
_, _ = h.Write([]byte(table + "|" + pkCol + "|"))
ptrs := make([]any, len(colNames))
vals := make([]any, len(colNames))
for i := range vals {
ptrs[i] = &vals[i]
}
for rows.Next() {
for i := range vals {
vals[i] = nil
}
if err := rows.Scan(ptrs...); err != nil {
return "", err
}
m := make(map[string]any, len(colNames))
for i, c := range colNames {
m[c] = normalizeSQLValue(vals[i])
}
blob, err := stableJSON(m)
if err != nil {
return "", err
}
_, _ = h.Write(blob)
_, _ = h.Write([]byte("\n"))
}
if err := rows.Err(); err != nil {
return "", err
}
sum := hex.EncodeToString(h.Sum(nil))
if len(sum) > 32 {
sum = sum[:32]
}
return sum, nil
}
// stableJSON 按 key 排序,贴近 Python json.dumps(..., sort_keys=True, default=str)。
func stableJSON(m map[string]any) ([]byte, error) {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, k := range keys {
kb, err := json.Marshal(k)
if err != nil {
return nil, err
}
vb, err := json.Marshal(m[k])
if err != nil {
// fallback string
vb, err = json.Marshal(fmt.Sprint(m[k]))
if err != nil {
return nil, err
}
}
parts = append(parts, string(kb)+":"+string(vb))
}
return []byte("{" + strings.Join(parts, ",") + "}"), nil
}

View File

@@ -403,6 +403,53 @@ func agentSyncSchemaDescribeHandler(svcCtx *svc.ServiceContext) http.HandlerFunc
}
}
func agentSyncSchemaFingerprintHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
started := time.Now()
reqID := r.Header.Get("X-Request-Id")
if !requireDBSync(svcCtx, w) {
return
}
channelID := pathvar.Vars(r)["id"]
ch, err := svcCtx.DBSync.Store().GetChannelForTenant(channelID, syncTenantID(r))
if err != nil {
authx.WriteError(w, http.StatusNotFound, err.Error())
return
}
var req dbsync.FingerprintRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
if !authorizeUserOnlineDB(svcCtx, w, r, channelID, req.OnlineDBID, "用户自助 schema/fingerprint 须带 online_db_id且须为本人 Binding") {
return
}
if !authorizeUserSyncChannel(svcCtx, w, r, channelID) {
return
}
res, err := dbsync.FingerprintRemoteTables(r.Context(), ch, req)
dur := time.Since(started)
if err != nil {
logSyncReq(r, "schema/fingerprint", channelID, "", "", "err", err.Error(), dur, reqID)
if dbsync.IsRetryable(err) {
authx.WriteRetryableError(w, http.StatusServiceUnavailable, err.Error())
return
}
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
n := 0
if res != nil {
n = len(res.Tables)
}
logSyncReq(r, "schema/fingerprint", channelID, "", "", "ok", "", dur, reqID)
auditSync(svcCtx, r, "dbsync.schema_fingerprint", map[string]any{
"channel_id": channelID, "n": n, "ms": dur.Milliseconds(), "req_id": reqID,
})
httpx.OkJson(w, map[string]any{"success": true, "result": res})
}
}
func uniqueStringSlice(a, b []string) []string {
seen := map[string]struct{}{}
var out []string

View File

@@ -161,6 +161,7 @@ func RegisterHandlers(server *rest.Server, svcCtx *svc.ServiceContext) {
{Method: http.MethodPost, Path: "/api/v1/agent/sync/channels/:id/bootstrap", Handler: chain(agentSyncBootstrapHandler(svcCtx), rl, authMW, tenant, syncPush)},
{Method: http.MethodPost, Path: "/api/v1/agent/sync/channels/:id/schema", Handler: chain(agentSyncSchemaDescribeHandler(svcCtx), rl, authMW, tenant, syncPush)},
{Method: http.MethodPost, Path: "/api/v1/agent/sync/channels/:id/schema/ensure", Handler: chain(agentSyncSchemaEnsureHandler(svcCtx), rl, authMW, tenant, syncPush)},
{Method: http.MethodPost, Path: "/api/v1/agent/sync/channels/:id/schema/fingerprint", Handler: chain(agentSyncSchemaFingerprintHandler(svcCtx), rl, authMW, tenant, syncPush)},
// 存储POST 创建对象GET 读取(无 /upload 动词路径;旧路径保留别名防断裂)
{Method: http.MethodPost, Path: "/api/v1/storage", Handler: chain(uploadHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm上传文件))},