diff --git a/platform/internal/handler/sync.go b/platform/internal/handler/sync.go index f0a45d7..b025657 100644 --- a/platform/internal/handler/sync.go +++ b/platform/internal/handler/sync.go @@ -9,6 +9,7 @@ import ( "aijianzhan/platform/internal/audit" "aijianzhan/platform/internal/authx" "aijianzhan/platform/internal/dbsync" + "aijianzhan/platform/internal/logic/applogic" "aijianzhan/platform/internal/svc" "github.com/zeromicro/go-zero/rest/httpx" @@ -41,7 +42,12 @@ func syncListHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { if !requireDBSync(svcCtx, w) { return } - list, err := svcCtx.DBSync.Store().ListChannelsByTenant(syncTenantID(r)) + tid := syncTenantID(r) + if _, err := applogic.HealTenantSyncBind(r.Context(), svcCtx, tid); err != nil { + // 列表仍继续,避免自愈失败挡运维 + _ = err + } + list, err := svcCtx.DBSync.Store().ListChannelsByTenant(tid) if err != nil { authx.WriteError(w, http.StatusBadRequest, err.Error()) return @@ -109,15 +115,26 @@ func syncDeleteHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { return } id := pathvar.Vars(r)["id"] - if _, err := svcCtx.DBSync.Store().GetChannelForTenant(id, syncTenantID(r)); err != nil { + tid := syncTenantID(r) + ch, err := svcCtx.DBSync.Store().GetChannelForTenant(id, tid) + if err != nil { authx.WriteError(w, http.StatusNotFound, err.Error()) return } + // Z12h-1:公司默认同步通道禁止删除(账号落点依赖它) + if ch.IsSystemDefault { + authx.WriteError(w, http.StatusBadRequest, "公司默认同步通道不可删除;账号绑定落点依赖此通道。若异常请刷新页面自动修复") + return + } svcCtx.DBSync.StopChannel(id) if err := svcCtx.DBSync.Store().DeleteChannel(id); err != nil { authx.WriteError(w, http.StatusBadRequest, err.Error()) return } + // 若误删后仍有 Binding 挂死,heal 会挂回默认通道 + if _, hErr := applogic.HealTenantSyncBind(r.Context(), svcCtx, tid); hErr != nil { + _ = hErr + } httpx.OkJson(w, map[string]any{"ok": true}) } } diff --git a/platform/internal/handler/sync_binding.go b/platform/internal/handler/sync_binding.go index 85a8f97..88e46da 100644 --- a/platform/internal/handler/sync_binding.go +++ b/platform/internal/handler/sync_binding.go @@ -6,6 +6,7 @@ import ( "aijianzhan/platform/internal/authx" "aijianzhan/platform/internal/dbsync" + "aijianzhan/platform/internal/logic/applogic" "aijianzhan/platform/internal/svc" "github.com/zeromicro/go-zero/rest/httpx" @@ -16,8 +17,11 @@ func syncBindingsListHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { if !requireDBSync(svcCtx, w) { return } - localID := r.URL.Query().Get("local_database_id") tid := syncTenantID(r) + if _, err := applogic.HealTenantSyncBind(r.Context(), svcCtx, tid); err != nil { + _ = err + } + localID := r.URL.Query().Get("local_database_id") var ( list []dbsync.Binding err error @@ -74,6 +78,18 @@ func syncBindingsEnsureHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { return } } + // Z12h-2:通道空/已删 → 静默挂回公司默认同步 + needHeal := body.ChannelID == "" + if !needHeal { + if _, e := svcCtx.DBSync.Store().GetChannelForTenant(body.ChannelID, body.TenantID); e != nil { + needHeal = true + } + } + if needHeal { + if healed, hErr := applogic.HealTenantSyncBind(r.Context(), svcCtx, body.TenantID); hErr == nil && healed != nil && healed.ChannelID != "" { + body.ChannelID = healed.ChannelID + } + } saved, err := svcCtx.DBSync.Store().EnsureBinding(body) if err != nil { authx.WriteError(w, http.StatusBadRequest, err.Error()) diff --git a/platform/internal/logic/applogic/agent_sync_bind.go b/platform/internal/logic/applogic/agent_sync_bind.go index 60afef0..bd8d8a8 100644 --- a/platform/internal/logic/applogic/agent_sync_bind.go +++ b/platform/internal/logic/applogic/agent_sync_bind.go @@ -47,45 +47,59 @@ func fillUserSyncOnToken(svcCtx *svc.ServiceContext, resp *types.TokenResp, u *u resp.SyncBound = resp.ChannelID != "" && resp.OnlineDBID != "" } -// ensureAgentSyncBind Z12c:启用/创建时若无通道则自动创建公司默认同步通道并写回智能体。 +// ensureAgentSyncBind Z12c/Z12h:无通道或通道已删除时,创建/复用默认同步通道并写回智能体。 func (l *AgentAdminLogic) ensureAgentSyncBind(acc *agentstore.Account) (*agentstore.Account, error) { if acc == nil { return nil, fmt.Errorf("agent nil") } - if strings.TrimSpace(acc.ChannelID) != "" && strings.TrimSpace(acc.OnlineDBID) != "" { - return acc, nil - } if l.svcCtx.DBSync == nil { return acc, nil } - cfg := l.svcCtx.Config.DBSync - driver := dbsync.Driver(strings.TrimSpace(cfg.DefaultRemoteDriver)) - if driver == "" { - driver = dbsync.DriverPostgres + alive := channelAlive(l.svcCtx.DBSync.Store(), acc.TenantID, acc.ChannelID) + if alive && strings.TrimSpace(acc.OnlineDBID) != "" { + return acc, nil } - ch, err := l.svcCtx.DBSync.EnsureAndStartSystemDefaultChannel(dbsync.DefaultChannelOpts{ - TenantID: acc.TenantID, - AgentID: acc.AgentID, - Name: fmt.Sprintf("默认同步 · %s", acc.Name), - RemoteDriver: driver, - RemoteDSN: strings.TrimSpace(cfg.DefaultRemoteDSN), - DatabaseName: acc.DatabaseName, - }) - if err != nil { - return nil, fmt.Errorf("ensure default channel: %w", err) - } - online := dbsync.ResolveOnlineDBID(acc.OnlineDBID, ch.ID) - dbName := strings.TrimSpace(acc.DatabaseName) - if dbName == "" { - dbName = fmt.Sprintf("agent_%d", acc.AgentID) + if _, err := HealTenantSyncBind(l.ctx, l.svcCtx, acc.TenantID); err != nil { + return nil, fmt.Errorf("heal sync bind: %w", err) } st, err := l.store() if err != nil { return nil, err } - updated, err := st.AttachSyncBind(l.ctx, acc.AgentID, acc.TenantID, ch.ID, online, dbName, acc.Status == agentstore.StatusActive || acc.Status == "") + updated, err := st.Get(l.ctx, acc.TenantID, acc.AgentID) if err != nil { return nil, err } + // 若 heal 未覆盖该智能体(例如无 Binding 仅 Agent),再显式挂一次 + if !channelAlive(l.svcCtx.DBSync.Store(), updated.TenantID, updated.ChannelID) || strings.TrimSpace(updated.OnlineDBID) == "" { + cfg := l.svcCtx.Config.DBSync + driver := dbsync.Driver(strings.TrimSpace(cfg.DefaultRemoteDriver)) + if driver == "" { + driver = dbsync.DriverPostgres + } + ch, err := l.svcCtx.DBSync.EnsureAndStartSystemDefaultChannel(dbsync.DefaultChannelOpts{ + TenantID: acc.TenantID, + AgentID: acc.AgentID, + Name: fmt.Sprintf("默认同步 · %s", acc.Name), + RemoteDriver: driver, + RemoteDSN: strings.TrimSpace(cfg.DefaultRemoteDSN), + DatabaseName: acc.DatabaseName, + }) + if err != nil { + return nil, fmt.Errorf("ensure default channel: %w", err) + } + online := strings.TrimSpace(updated.OnlineDBID) + if online == "" { + online = dbsync.ResolveOnlineDBID("", ch.ID) + } + dbName := strings.TrimSpace(updated.DatabaseName) + if dbName == "" { + dbName = fmt.Sprintf("agent_%d", updated.AgentID) + } + updated, err = st.AttachSyncBind(l.ctx, updated.AgentID, updated.TenantID, ch.ID, online, dbName, updated.Status == agentstore.StatusActive || updated.Status == "") + if err != nil { + return nil, err + } + } return updated, nil } diff --git a/platform/internal/logic/applogic/agents.go b/platform/internal/logic/applogic/agents.go index 539e144..3d32187 100644 --- a/platform/internal/logic/applogic/agents.go +++ b/platform/internal/logic/applogic/agents.go @@ -243,6 +243,13 @@ func (l *AuthLogic) IssueClientCredentials(clientID, clientSecret string) (*type if err != nil { return nil, err } + if l.svcCtx.DBSync != nil { + if _, hErr := HealTenantSyncBind(l.ctx, l.svcCtx, acc.TenantID); hErr == nil { + if refreshed, gErr := l.svcCtx.Agents.Get(l.ctx, acc.TenantID, acc.AgentID); gErr == nil && refreshed != nil { + acc = refreshed + } + } + } token, exp, err := authx.IssueAgentToken(l.svcCtx.JWT, acc.TenantID, acc.AgentID, acc.Perms) if err != nil { return nil, err diff --git a/platform/internal/logic/applogic/bind_flow.go b/platform/internal/logic/applogic/bind_flow.go index 3579d22..cbfd2ec 100644 --- a/platform/internal/logic/applogic/bind_flow.go +++ b/platform/internal/logic/applogic/bind_flow.go @@ -3,6 +3,7 @@ package applogic import ( "encoding/json" "fmt" + "log" "strings" "time" @@ -14,7 +15,7 @@ import ( "aijianzhan/platform/internal/userstore" ) -// AgentMe Z12b:智能体自查绑定(无需「管理智能体」)。 +// AgentMe Z12b/Z12h:智能体自查绑定;通道缺失时自愈。 func (l *AuthLogic) AgentMe() (*agentstore.Account, error) { if !authx.IsAgent(authx.Role(l.ctx)) { return nil, fmt.Errorf("仅智能体可访问") @@ -28,6 +29,13 @@ func (l *AuthLogic) AgentMe() (*agentstore.Account, error) { if err != nil { return nil, err } + if l.svcCtx.DBSync != nil { + if _, hErr := HealTenantSyncBind(l.ctx, l.svcCtx, tid); hErr != nil { + log.Printf("agent me heal: %v", hErr) + } else if refreshed, gErr := l.svcCtx.Agents.Get(l.ctx, tid, aid); gErr == nil && refreshed != nil { + acc = refreshed + } + } return acc, nil } @@ -52,30 +60,13 @@ func (l *AuthLogic) CreateBindCode(req BindCodeCreateReq) (*bindcodestore.BindCo online := strings.TrimSpace(req.OnlineDBID) dbName := strings.TrimSpace(req.DatabaseName) if channelID == "" && l.svcCtx.DBSync != nil { - if ch, err := l.svcCtx.DBSync.Store().FindSystemDefaultChannel(tid); err == nil && ch != nil { - channelID = ch.ID + // Z12h:生成绑定码前确保默认通道存在(含误删自愈) + if healed, err := HealTenantSyncBind(l.ctx, l.svcCtx, tid); err != nil { + return nil, fmt.Errorf("无默认同步通道:%w", err) + } else if healed != nil && strings.TrimSpace(healed.ChannelID) != "" { + channelID = healed.ChannelID if online == "" { - online = dbsync.ResolveOnlineDBID("", ch.ID) - } - _ = l.svcCtx.DBSync.StartChannel(ch.ID) // 已有默认同步通道也保持运行中 - } else { - // 尝试创建默认同步通道 - cfg := l.svcCtx.Config.DBSync - driver := dbsync.Driver(strings.TrimSpace(cfg.DefaultRemoteDriver)) - if driver == "" { - driver = dbsync.DriverPostgres - } - saved, err := l.svcCtx.DBSync.EnsureAndStartSystemDefaultChannel(dbsync.DefaultChannelOpts{ - TenantID: tid, - RemoteDriver: driver, - RemoteDSN: strings.TrimSpace(cfg.DefaultRemoteDSN), - }) - if err != nil { - return nil, fmt.Errorf("无默认同步通道:%w", err) - } - channelID = saved.ID - if online == "" { - online = dbsync.ResolveOnlineDBID("", saved.ID) + online = dbsync.ResolveOnlineDBID("", channelID) } } } diff --git a/platform/internal/logic/applogic/sync_heal.go b/platform/internal/logic/applogic/sync_heal.go new file mode 100644 index 0000000..2d750dc --- /dev/null +++ b/platform/internal/logic/applogic/sync_heal.go @@ -0,0 +1,131 @@ +package applogic + +import ( + "context" + "fmt" + "log" + "strings" + "sync" + + "aijianzhan/platform/internal/agentstore" + "aijianzhan/platform/internal/dbsync" + "aijianzhan/platform/internal/svc" +) + +// HealResult Z12h 自愈结果。 +type HealResult struct { + ChannelID string `json:"channel_id"` + BindingsFixed int `json:"bindings_fixed"` + AgentsFixed int `json:"agents_fixed"` + ChannelCreated bool `json:"channel_created,omitempty"` +} + +var healTenantMu sync.Map // tenantID -> *sync.Mutex;避免 SyncPage 并行列表双建默认通道 + +func lockTenantHeal(tenantID int64) func() { + v, _ := healTenantMu.LoadOrStore(tenantID, &sync.Mutex{}) + mu := v.(*sync.Mutex) + mu.Lock() + return mu.Unlock +} + +// channelAlive 通道是否仍属于该租户。 +func channelAlive(store *dbsync.FileStore, tenantID int64, channelID string) bool { + id := strings.TrimSpace(channelID) + if id == "" || store == nil || tenantID <= 0 { + return false + } + _, err := store.GetChannelForTenant(id, tenantID) + return err == nil +} + +func agentNeedsSyncHeal(store *dbsync.FileStore, a agentstore.Account) bool { + cid := strings.TrimSpace(a.ChannelID) + alive := channelAlive(store, a.TenantID, cid) + if cid != "" { + if !alive { + return true // 挂死通道 + } + return strings.TrimSpace(a.OnlineDBID) == "" // 通道在但缺 online + } + // 无通道:仅 active(与 Z12c 启用即绑一致),勿动 pending + return a.Status == agentstore.StatusActive || a.Status == "" +} + +// HealTenantSyncBind Z12h:确保公司默认同步通道存在并运行;重挂挂死通道的 Agent/Binding。 +// online_db_id 有值则保留(同一 remote 上的逻辑库名不变);仅改 channel_id。 +func HealTenantSyncBind(ctx context.Context, svcCtx *svc.ServiceContext, tenantID int64) (*HealResult, error) { + if svcCtx == nil || svcCtx.DBSync == nil || tenantID <= 0 { + return nil, nil + } + unlock := lockTenantHeal(tenantID) + defer unlock() + + cfg := svcCtx.Config.DBSync + driver := dbsync.Driver(strings.TrimSpace(cfg.DefaultRemoteDriver)) + if driver == "" { + driver = dbsync.DriverPostgres + } + before, _ := svcCtx.DBSync.Store().FindSystemDefaultChannel(tenantID) + ch, err := svcCtx.DBSync.EnsureAndStartSystemDefaultChannel(dbsync.DefaultChannelOpts{ + TenantID: tenantID, + RemoteDriver: driver, + RemoteDSN: strings.TrimSpace(cfg.DefaultRemoteDSN), + Name: fmt.Sprintf("公司默认同步 #%d", tenantID), + }) + if err != nil { + return nil, err + } + out := &HealResult{ + ChannelID: ch.ID, + ChannelCreated: before == nil || before.ID != ch.ID, + } + // Binding:通道空或已删除 → 改挂默认通道(保留 online_db_id) + list, err := svcCtx.DBSync.Store().ListBindings(tenantID, "") + if err == nil { + for _, b := range list { + if channelAlive(svcCtx.DBSync.Store(), tenantID, b.ChannelID) { + continue + } + b.ChannelID = ch.ID + if strings.TrimSpace(b.OnlineDBID) == "" { + b.OnlineDBID = dbsync.ResolveOnlineDBID("", ch.ID) + } + if _, e := svcCtx.DBSync.Store().EnsureBinding(b); e != nil { + log.Printf("dbsync heal binding tenant=%d local=%s: %v", tenantID, b.LocalDatabaseID, e) + continue + } + out.BindingsFixed++ + } + } + // Agent:挂死通道 / 缺 online / active 无通道 → AttachSyncBind(保留 online_db_id) + if svcCtx.Agents != nil { + agents, err := svcCtx.Agents.List(ctx, tenantID) + if err == nil { + for i := range agents { + a := agents[i] + if !agentNeedsSyncHeal(svcCtx.DBSync.Store(), a) { + continue + } + online := strings.TrimSpace(a.OnlineDBID) + if online == "" { + online = dbsync.ResolveOnlineDBID("", ch.ID) + } + dbName := strings.TrimSpace(a.DatabaseName) + if dbName == "" { + dbName = fmt.Sprintf("agent_%d", a.AgentID) + } + if _, e := svcCtx.Agents.AttachSyncBind(ctx, a.AgentID, tenantID, ch.ID, online, dbName, a.Status == agentstore.StatusActive || a.Status == ""); e != nil { + log.Printf("dbsync heal agent tenant=%d agent=%d: %v", tenantID, a.AgentID, e) + continue + } + out.AgentsFixed++ + } + } + } + if out.BindingsFixed > 0 || out.AgentsFixed > 0 || out.ChannelCreated { + log.Printf("dbsync heal tenant=%d channel=%s bindings=%d agents=%d created=%v", + tenantID, ch.ID, out.BindingsFixed, out.AgentsFixed, out.ChannelCreated) + } + return out, nil +} diff --git a/platform/internal/logic/applogic/yuheng_ticket.go b/platform/internal/logic/applogic/yuheng_ticket.go index 0064732..65205f0 100644 --- a/platform/internal/logic/applogic/yuheng_ticket.go +++ b/platform/internal/logic/applogic/yuheng_ticket.go @@ -83,6 +83,12 @@ func (l *AuthLogic) ExchangeYuhengTicket(req YuhengTicketExchangeReq) (*YuhengTi if err != nil { return nil, err } + // Z12h:换票时顺带自愈本租户挂死通道的 Binding/Agent + if _, hErr := HealTenantSyncBind(l.ctx, l.svcCtx, u.TenantID); hErr == nil { + if refreshed, gErr := l.svcCtx.Agents.Get(l.ctx, acc.TenantID, acc.AgentID); gErr == nil && refreshed != nil { + acc = refreshed + } + } if err := l.ensureAgentSyncPerm(acc); err != nil { return nil, err } diff --git a/联调后修改意见-宇恒松离线.md b/联调后修改意见-宇恒松离线.md index f58255f..91fd4bf 100644 --- a/联调后修改意见-宇恒松离线.md +++ b/联调后修改意见-宇恒松离线.md @@ -1,7 +1,7 @@ # 联调后修改意见 · 宇恒松离线(形态 B) -> 初稿:2026-08-01 · 修订至 **2026-08-05 18:25** -> 焦点:**§0.2 / §0.3**;Z10d / Z14 / Z14c **代码已合入**,生产须 `bash ./restart.sh --pull`;已修项勿再挂「仍待办」 +> 初稿:2026-08-01 · 修订至 **2026-08-06**(Z12h 已落实) +> 焦点:**§0.2**(按负责方:宇恒改宇恒、智建改智建);配合见 **§0.3** > 来源:宇恒 `yuhengyihao_client` ↔ 智建(生产 `aisite.yuxindazhineng.com`) > 依据:`松离线-dbsync方案-最终版.md`、`宇恒-松离线数据同步使用文档.md` @@ -11,13 +11,14 @@ | 级别 | 状态 | 说明 | |------|------|------| -| **硬改(阻塞接口)** | **无** | Z1–Z14 代码侧齐;剩生产 pull + 配号/Secret/白名单 | +| **硬改(阻塞接口)** | **无** | 契约不变;剩生产 pull + 配号/Secret | | **生产栈** | **已恢复** | `SMS.Provider: "off"` 须引号;health `platform/ai=true` | | **Z1–Z9 / Z11–Z13** | **已落实** | 绑定/凭票/模块导入等;见 §5 | | **Z10 + Z10d** | **代码齐;生产 pull** | 空表 ensure;已存在表 **ADD COLUMN**(§5.7) | -| **Z12 默认同步** | **已落实** | 启用即通道;**默认运行中**(`cb76824`) | -| **Z14 + Z14c** | **代码齐;生产 pull** | fingerprint API;智能体挂载 `online_db_id` 已放行(§5.11) | -| **仍关注** | 用法 | SQLite 按库串行 drain;历史数据靠 full-push,不是「点启动」 | +| **Z12 默认同步** | **已落实(含 Z12h)** | 启用即通道;误删 → Ensure+重挂(§5.9) | +| **Z14 + Z14c** | **代码齐;生产 pull** | fingerprint;智能体挂载 `online_db_id`(§5.11) | +| **Z14d 双向补齐** | **宇恒已接** | 误删任一侧 → 心跳/选同步按主键并集 pull↔push(§5.11) | +| **仍关注** | 用法 | 通道断了靠智建自愈;表数据靠宇恒双向指纹;不是「再点启动」 | ### 0.1 绑定产品冻结(Z13 · 试运行) @@ -31,6 +32,7 @@ ### 0.2 待改清单(按负责方) +> **分工冻结**:宇恒只改 `yuhengyihao_client`;智建只改 `ai建站`;互不代改对方仓。 > 智建轨迹:`d195aa4` 凭票 → `4222667` SMS 引号 → `cb76824` 默认启动 → `4fae7ae` Z10d+fingerprint → **`bb59bb5` Z14c**。生产:`bash ./restart.sh --pull`。 #### A. 宇恒 · 已完成 @@ -39,14 +41,16 @@ |------|------| | **Z10c** | `ensure_before_drain`(选同步 force + drain 前 TTL) | | **Z13b–e** | 绑定码 / 同号确认 / policy / 凭票 / 选「同步」即 Binding+drain | -| **Z14a** | 表级指纹慢心跳;不一致 → ensure + full-push | +| **Z14a** | 表级指纹慢心跳(原:不一致 → ensure + full-push) | +| **Z14d** | **双向并集补齐(2026-08-06)**:不一致 → 先 `pull` 灌本机(不进 outbox)再 ensure+full-push;选「同步」同样先 pull 再 push;**默认不做 prune**(`YXD_SYNC_PRUNE_EXTRAS=1` 才清线上多余)。覆盖:线上误删 / 本地误删 / 缺表 | -#### A′. 宇恒 · 配合注意(非新开发) +#### A′. 宇恒 · 配合注意(非阻塞新开发) | 优先级 | 内容 | |--------|------| -| **P0** | 端到端见 **§0.3**:绑定 → 库选「同步」→ drain;线上表只反映已 push/ensure | -| **P0** | **`online_db_id`**:生产 pull Z14c 后,智能体 JWT **用** ticket/`agents/me` 的 `{channel}_uN`;人类自助仍走本人 Binding | +| **P0** | 端到端见 **§0.3**:绑定 → 库选「同步」→ drain;线上表只反映已 push/ensure/pull | +| **P0** | **`online_db_id`**:生产 pull Z14c 后,智能体 JWT **用** ticket/`agents/me` 的 `{channel}_uN`;`apply_bind_success` 勿再长期吞掉 `_uN` | +| **P1** | 通道失效(push/agents/me 报通道不存在)→ **重新换票**,用返回的新 `channel_id` 覆盖 env(根因自愈见智建 **Z12h**) | | **P1** | Binding 带可读名;同库串行 drain;指纹优先 `schema/fingerprint`,404 回退 `row_count` | #### B. 智建 · 已完成(仓内) @@ -58,12 +62,21 @@ | 默认同步通道默认启动 | 创建即 enabled;重启自动 Start | | **Z14c** ACL | 智能体 JWT 放行挂载 `online_db_id`(`agentOwnsOnlineDB`) | | Podman DNS / 脚本可执行位 | compose aliases;`restart.sh` 100755 | +| **Z12h / Z12h-1 / Z12h-2** | `HealTenantSyncBind`:Ensure 默认通道 + 重挂 Agent/Binding;`is_system_default` 禁删;列表/换票/`agents/me`/ensure Binding 触发;见 §5.9 | + +> 产品一句:**账号已绑定 ⇒ 落点信息固定;通道没了平台自动补,终端无需手填 channel_id。** + +#### B″. 智建 · 仍待办(**开发** · 本仓改) + +| 优先级 | 编号 | 项 | 说明 | +|--------|------|----|------| +| — | — | **无阻塞开发项** | Z12h 已合入;后续仅生产 pull 与运维配号 | #### B′. 智建 · 仍待办(仅运维) | 优先级 | 项 | 说明 | |--------|----|------| -| **P0** | **生产 pull 本批** | Z10d + `schema/fingerprint` + Z14c;`bash ./restart.sh --pull` | +| **P0** | **生产 pull 本批** | Z10d + `schema/fingerprint` + Z14c + **Z12h**;`bash ./restart.sh --pull` | | **P0** | 成员手机 | 「宇信达」绑 **`13531041944`**;勿超管号 | | **P0** | 通道表白名单 | **勿**「填入测试默认」;形态 B 可空 | | **P1** | 凭票 Secret | `YuhengTicket.Secret` ≡ `YXD_YUHENG_TICKET_SECRET` | @@ -75,8 +88,9 @@ | 项 | 说明 | |----|------| | 终端冲突台 | 仅平台超管 | -| 智建代改宇恒仓 | 分工冻结 | +| 智建代改宇恒仓 / 宇恒代改智建仓 | **分工冻结** | | 平台 push 租约 | version 幂等 + 宇恒串行 drain | +| 用户手填 channel_id / DSN 为开通终态 | 已否决;见 Z12 / Z12h | ### 0.3 双方怎么配合(开通 → 看到线上表) @@ -87,20 +101,25 @@ 3. 启用智能体 → 默认同步通道应「运行中」 4.(可选)生成绑定码 +【智建 · Z12h 已落实】 + 误删默认同步 / Binding 挂死 → 打开 SyncPage / 换票 / agents/me 自动 Ensure+重挂 + 【宇恒终端】 5. 未绑定 → policy → lookup/confirm 或 redeem 或 ticket-exchange - 6. 本机库选「同步」→ Binding + ensure + drain/full_push(Z13e) - 7. 慢心跳指纹(Z14);缺列依赖生产已 pull 的 Z10d + 6. 本机库选「同步」→ Binding + pull + ensure + drain/full_push(Z13e / Z14d) + 7. 慢心跳指纹(Z14d):本地缺←pull,线上缺→push;缺列依赖生产已 pull 的 Z10d + 8. 若仍报通道不存在 → 再换票刷新 channel_id(生产须已 pull Z12h) 【智建验收】 - 8. 「查看线上表」刷新 → 应有业务表(含曾 0 行空表) - 9. 仍空 = 终端尚未 push/ensure,不是再点一次「启动」就能灌表 + 9. 「查看线上表」刷新 → 应有业务表(含曾 0 行空表) + 10. 仍空 = 终端尚未 push/ensure/pull,不是再点一次「启动」就能灌表 + 11. 故意删默认同步通道后:换票或打开 SyncPage / agents/me → 通道自动回来,Binding 不再红字 ``` | 角色 | 负责 | 不负责 | |------|------|--------| -| **智建** | 落点/通道/鉴权/ensure/fingerprint;控制台看线上表 | 不替宇恒灌本机历史行 | -| **宇恒** | 绑定 UX、选同步、outbox、drain、指纹修复、full-push | 不以手填 DSN/通道为产品终态 | +| **智建** | 落点/通道自愈/鉴权/ensure/fingerprint API;控制台看线上表 | 不替宇恒灌本机历史行;不改宇恒仓 | +| **宇恒** | 绑定 UX、选同步、outbox、drain、**双向**指纹修复、full-push/pull | 不以手填 DSN/通道为产品终态;不改智建仓 | | **共同** | 联调号 `13531041944`;Secret 对齐;试运行免短信仍须确认 | 不用超管号 | 宇恒对照脚本:`smoke_zhijian_e2e.py` / `smoke_offline_concurrent.py` / `smoke_zhijian_gateway_burst.py` / `smoke_stress_sync.py`。 @@ -511,7 +530,7 @@ POST .../schema **说明**:存量表补列一律可空,避免 NOT NULL 迁库失败。已有库需**再发布一次**(或编辑发布增字段)才会 ALTER;之后新字段随发布自动补。 -### 5.9 【智建已落实 Z12】生产:账号绑定即落点,禁止手填通道 / 手建通道(2026-08-05) +### 5.9 【Z12 · 已落实含 Z12h】账号绑定即落点,禁止手填通道(2026-08-05 / 修订 2026-08-06) > **产品约定(生产 · 冻结意向)** > 1. **一个登录账号 / 一个智能体 ↔ 本公司同步落点**:绑定后,该账号的本机库只进自己的(或公司共享的)线上库,**不会**接到别人账号的库。 @@ -532,18 +551,27 @@ POST .../schema | **Z12e** | **P1** | **唯一默认通道** | 每公司至多 1 条 `is_system_default`;复用已有默认 | **智建已落实** + 宇恒「仅 1 条则写入 env」 | | **Z12f** | **P1** | **开通 UX** | 启用智能体即可;DSN 用公司级默认 | **智建已落实** | | **Z12g** | **P1** | **默认同步通道默认启动** | 创建 `Enabled=true`;重启对 `IsSystemDefault` 自动 Start;控制台保存后自动启动 | **智建已落实**(`cb76824`) | +| **Z12h** | **P0** | **通道误删自愈** | `HealTenantSyncBind`:Ensure 默认通道 + 重挂挂死 Agent/Binding(保留 `online_db_id`);触发:`ensureAgentSyncBind` / `agents/me` / 换票 / 通道·Binding 列表 / 删非默认通道后 | **智建已落实** | +| **Z12h-1** | **P1** | **禁删或删后重建默认同步** | `is_system_default` **禁止删除**;删其它通道后 heal | **智建已落实** | +| **Z12h-2** | **P1** | **orphan Binding heal** | 列表 / Ensure Binding 时通道缺失 → 静默改挂默认通道 | **智建已落实** | + +**缺口说明(2026-08-06 · 已关闭)** + +- 原:SyncPage「通道已删除」、`ensureAgentSyncBind` 见非空 `channel_id` 即跳过。 +- 现:列表/换票等路径先 heal;默认通道不可删。生产须 **pull** 后验收第 4 条。 **宇恒已做(2026-08-05)** -1. 当前生产通道 `691575c0-…` 已写入 env(过渡缓存;Z12c 落地后应由换票/唯一通道自动获得)。 +1. 当前生产通道已写入 env(过渡缓存;Z12c/Z12h 落地后应由换票/自愈自动获得)。 2. `resolve_sync_channel_id()`:env → Binding → 租户仅 1 通道自动选用。 -3. 不把「手建通道 + 手抄 ID」当作产品终态。 +3. 不把「手建通道 + 手抄 ID」当作产品终态;通道失效时配合再换票(根因仍靠 Z12h)。 **验收** 1. 新公司:只「启用智能体」,**不**点新建通道,换票已带 `channel_id`,宇恒可 drain。 2. 账号 A 的本机库 push 不会出现在账号 B 的线上库;共享库仅在显式共享时可见。 -3. 用户全程无需 F12、无需手填 `YXD_SYNC_CHANNEL_ID`。 +3. 用户全程无需 F12、无需手填 `YXD_SYNC_CHANNEL_ID`。 +4. **(Z12h)** 默认同步通道 API **不可删**;若 Binding/Agent 仍挂历史死 `channel_id`(或误删非默认通道)→ `agents/me` / 换票 / 打开 SyncPage → Ensure 默认通道并重挂,「通道已删除」红字消失;宇恒无需手填新 ID。 ### 5.10 【双方代码已接 Z13】绑定流程简化:绑定码 / 手机号(2026-08-05) @@ -618,15 +646,17 @@ POST .../schema 6. 未确认 / 他人绑定码,不能绑进别的公司或别人账号。 7. 正式且启凭票:同号走 `ticket-exchange`,不再依赖明文 `attested_same_phone`。 -### 5.11 【新增 Z14 · 2026-08-05 晚】表级指纹对账(慢心跳)+ 联调踩坑 +### 5.11 【Z14 · 2026-08-05 / 修订 2026-08-06】表级指纹对账 + 双向补齐 + 联调踩坑 > **诉求** -> 仅靠 agent「推送心跳」(pending/pushed)发现不了「本机 96 行、线上 0 行」或「行数相同但内容不同」。 -> 需要**慢周期表级指纹**:`row_count` +(可选)`content_hash`;不一致则 ensure + 对该表 full-push。 +> 1. 仅靠 agent「推送心跳」发现不了「本机 96 行、线上 0 行」或「同 count 不同内容」。 +> 2. **已绑定账号后**:线上误删或本地误删,都应能从另一侧按主键**并集**补回;心跳检测到不一致即自动修。 +> 3. **通道被删**不属于表级修复,见 **Z12h**(智建已落实;须生产 pull)。 | 编号 | 优先级 | 诉求 | 现状 | |------|--------|------|------| -| **Z14a** | **P0** | 宇恒本机指纹 + 对比线上 + 自动修复 | **宇恒已接**:`sync_fingerprint.py`;TTL **600s**;`POST /database/sync/fingerprint` | +| **Z14a** | **P0** | 宇恒本机指纹 + 对比线上 | **宇恒已接**:`sync_fingerprint.py`;TTL **600s**;`POST /database/sync/fingerprint` | +| **Z14d** | **P0** | **双向并集修复** | **宇恒已接(2026-08-06)**:`sync_pull.py`;本地缺/更少 → pull(直写 B、不进 outbox);线上缺/更少 → ensure+full_push;hash/count 冲突 → 先 pull 再 push。选「同步」同路径;**默认不 prune** | | **Z14b** | **P1** | 智建 `content_hash` | **已合入,生产 pull**:`POST .../schema/fingerprint` | | **Z10d** | **P0** | ensure 已存在表补列 | **已合入,生产 pull**;未 pull 时 `填土高度` 缺 `id` 仍 503 | | **Z14c** | **P0** | 智能体 `online_db_id` ACL | **已合入,生产 pull**:`agentOwnsOnlineDB`;人类 Binding 规则不变 | @@ -641,33 +671,45 @@ POST /api/v1/agent/sync/channels/{id}/schema/fingerprint 哈希:`sha256( table+"|"+pk+"|" + Σ json(row, sort_keys=True)+"\n" )` 前 32 hex。 +下行(宇恒已用,智建保持兼容): + +```http +POST /api/v1/agent/sync/channels/{id}/pull +{ "mode": "bootstrap", "table": "...", "after_pk": "", "limit": 200, "online_db_id": "..." } +→ result: { columns, items[], has_more, next_after_pk, pk_column } +``` + **分工** | 方 | 做什么 | |----|--------| -| 宇恒 | 本机指纹;优先 fingerprint API,404 回退 `row_count`;不一致 ensure+full_push | -| 智建 | 生产 pull:fingerprint + Z10d + Z14c | +| 宇恒 | 本机指纹;404 回退 `row_count`;**Z14d** 双向 pull↔push;选同步默认不 prune | +| 智建 | 生产 pull:fingerprint + Z10d + Z14c + **Z12h**;保持 pull/bootstrap 契约 | **联调踩坑(宇信达 · 已回写)** -1. **行数对不上**:开通同步不会自动灌历史 → full-push;指纹可检出。 +1. **行数对不上**:开通同步不会自动灌历史 → full-push / 指纹修复;**Z14d** 后本地缺也会 pull。 2. **`填土高度` 缺 `id`**:push 默认 pk=`id`;须 Z10d 补列或删表重建后再推(宇恒 `mark_error` 堵队已修)。 -3. **`_uN` 曾 403**:智能体 JWT `user_id=agent_id`,Binding 在人类成员上 → **Z14c** 放行挂载 online。 +3. **`_uN` 曾 403**:智能体 JWT `user_id=agent_id`,Binding 在人类成员上 → **Z14c** 放行挂载 online。 +4. **删通道后再同步仍空**:Binding「通道已删除」→ 生产 pull **Z12h** 后打开 SyncPage/换票自愈;表级指纹无法重建通道。 **验收** 1. 改本机行数后 ≤TTL 检出 mismatch 并排队修复。 2. 未 pull fingerprint:仅 count 对账仍可用。 -3. pull 后:同 count 不同内容 → `hash_mismatch`。 -4. Z10d pull 后:`填土高度` 可推满。 +3. pull 后:同 count 不同内容 → `hash_mismatch` → 双向修。 +4. Z10d pull 后:`填土高度` 可推满。 +5. **(Z14d)** 线上删某表行 → 心跳后本机行仍在且线上恢复;本机删某表行(未 prune)→ 心跳后本机从线上拉回。 +6. **(Z12h)** 见 §5.9 验收第 4 条。 --- ## 6. 联系与附件 -- **待改清单(优先看)**:§0.2;**双方配合**见 **§0.3** +- **待改清单(优先看)**:§0.2(**A′ 宇恒配合 · B′ 智建运维**;开发项已清空);配合见 **§0.3** - 方案:`松离线-dbsync方案-最终版.md`(含 2026-08-01 联调建议落地记录) - 宇恒使用说明:`宇恒-松离线数据同步使用文档.md`(含 Z10 schema/ensure、Z13 绑定) - 开通说明:`docs/数据同步-开通说明.md` - 绑定策略探测:`GET /api/v1/auth/bind/policy`(`trial_mode` / `require_for_bind`) -- 本意见如与冻结方案冲突,**以冻结方案为准**;§5.1–5.11 为产品增量与复测记录,不推翻 H1–H6 默认无感约束。 +- 本意见如与冻结方案冲突,**以冻结方案为准**;§5.1–5.11 为产品增量与复测记录,不推翻 H1–H6 默认无感约束。 +- **分工**:宇恒只改宇恒仓;智建只改智建仓;互不代改。 \ No newline at end of file