feat: heal dead sync channels and block deleting system default (Z12h)

Ensure default channel and rebind Agent/Binding on list, ticket, agents/me, and ensure-binding so SyncPage does not stay on deleted-channel errors.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
whm
2026-08-06 08:57:30 +08:00
parent 2a7da53769
commit 362dcee242
8 changed files with 309 additions and 85 deletions

View File

@@ -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
}