diff --git a/platform/internal/agentstore/store.go b/platform/internal/agentstore/store.go index 87077dc..85484f0 100644 --- a/platform/internal/agentstore/store.go +++ b/platform/internal/agentstore/store.go @@ -14,6 +14,7 @@ import ( "golang.org/x/crypto/bcrypt" "aijianzhan/platform/internal/authx" + "aijianzhan/platform/internal/schema" ) const ( @@ -259,7 +260,7 @@ func (s *MemoryStore) Update(_ context.Context, tenantID, agentID int64, in Upda a.OnlineDBID = strings.TrimSpace(*in.OnlineDBID) } if in.DatabaseName != nil { - a.DatabaseName = strings.TrimSpace(*in.DatabaseName) + a.DatabaseName = schema.SafeSyncDatabaseName(*in.DatabaseName, agentID, 0) } cp := cloneAcc(&a.Account) return &cp, nil @@ -394,7 +395,8 @@ func (s *MemoryStore) AttachSyncBind(_ context.Context, agentID, tenantID int64, } a.ChannelID = strings.TrimSpace(channelID) a.OnlineDBID = strings.TrimSpace(onlineDBID) - a.DatabaseName = strings.TrimSpace(databaseName) + // Z35:禁止中文 DisplayName 等非法串写入物理库名 + a.DatabaseName = schema.SafeSyncDatabaseName(databaseName, agentID, 0) if activate { a.Status = StatusActive } @@ -592,7 +594,7 @@ func (s *PostgresStore) Update(ctx context.Context, tenantID, agentID int64, in onlineDBID = strings.TrimSpace(*in.OnlineDBID) } if in.DatabaseName != nil { - dbName = strings.TrimSpace(*in.DatabaseName) + dbName = schema.SafeSyncDatabaseName(*in.DatabaseName, agentID, 0) } if _, err := s.DB.ExecContext(ctx, ` UPDATE platform_meta.agent_accounts SET name=$1, status=$2, role_id=NULLIF($3,0), @@ -760,7 +762,8 @@ ORDER BY agent_id DESC LIMIT 1`, hostKey, func (s *PostgresStore) AttachSyncBind(ctx context.Context, agentID, tenantID int64, channelID, onlineDBID, databaseName string, activate bool) (*Account, error) { statusSQL := "" - args := []any{tenantID, strings.TrimSpace(channelID), strings.TrimSpace(onlineDBID), strings.TrimSpace(databaseName), agentID} + safeDB := schema.SafeSyncDatabaseName(databaseName, agentID, 0) + args := []any{tenantID, strings.TrimSpace(channelID), strings.TrimSpace(onlineDBID), safeDB, agentID} if activate { statusSQL = ", status='active'" } diff --git a/platform/internal/logic/applogic/agent_sync_bind.go b/platform/internal/logic/applogic/agent_sync_bind.go index bd8d8a8..71721c7 100644 --- a/platform/internal/logic/applogic/agent_sync_bind.go +++ b/platform/internal/logic/applogic/agent_sync_bind.go @@ -6,6 +6,7 @@ import ( "aijianzhan/platform/internal/agentstore" "aijianzhan/platform/internal/dbsync" + "aijianzhan/platform/internal/schema" "aijianzhan/platform/internal/svc" "aijianzhan/platform/internal/types" "aijianzhan/platform/internal/userstore" @@ -19,6 +20,9 @@ func fillAgentSyncOnToken(resp *types.TokenResp, acc *agentstore.Account) { resp.ChannelID = strings.TrimSpace(acc.ChannelID) resp.OnlineDBID = strings.TrimSpace(acc.OnlineDBID) resp.DatabaseName = strings.TrimSpace(acc.DatabaseName) + if resp.DatabaseName != "" && !schema.ValidDBName(resp.DatabaseName) { + resp.DatabaseName = "" + } resp.SyncBound = resp.ChannelID != "" && resp.OnlineDBID != "" } @@ -40,9 +44,10 @@ func fillUserSyncOnToken(svcCtx *svc.ServiceContext, resp *types.TokenResp, u *u } resp.ChannelID = strings.TrimSpace(b.ChannelID) resp.OnlineDBID = strings.TrimSpace(b.OnlineDBID) + // Z35:database_name 仅物理库名;勿把中文 DisplayName 填进该字段 resp.DatabaseName = strings.TrimSpace(b.DatabaseName) - if resp.DatabaseName == "" { - resp.DatabaseName = strings.TrimSpace(b.DisplayName) + if resp.DatabaseName != "" && !schema.ValidDBName(resp.DatabaseName) { + resp.DatabaseName = "" } resp.SyncBound = resp.ChannelID != "" && resp.OnlineDBID != "" } @@ -92,10 +97,7 @@ func (l *AgentAdminLogic) ensureAgentSyncBind(acc *agentstore.Account) (*agentst if online == "" { online = dbsync.ResolveOnlineDBID("", ch.ID) } - dbName := strings.TrimSpace(updated.DatabaseName) - if dbName == "" { - dbName = fmt.Sprintf("agent_%d", updated.AgentID) - } + dbName := schema.SafeSyncDatabaseName(updated.DatabaseName, updated.AgentID, 0) 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 diff --git a/platform/internal/logic/applogic/bind_flow.go b/platform/internal/logic/applogic/bind_flow.go index 6f60f2d..89abed8 100644 --- a/platform/internal/logic/applogic/bind_flow.go +++ b/platform/internal/logic/applogic/bind_flow.go @@ -11,6 +11,7 @@ import ( "aijianzhan/platform/internal/authx" "aijianzhan/platform/internal/bindcodestore" "aijianzhan/platform/internal/dbsync" + "aijianzhan/platform/internal/schema" "aijianzhan/platform/internal/smsstore" "aijianzhan/platform/internal/userstore" ) @@ -58,7 +59,18 @@ func (l *AuthLogic) CreateBindCode(req BindCodeCreateReq) (*bindcodestore.BindCo } channelID := strings.TrimSpace(req.ChannelID) online := strings.TrimSpace(req.OnlineDBID) - dbName := strings.TrimSpace(req.DatabaseName) + rawDB := strings.TrimSpace(req.DatabaseName) + dbName := "" + displayNote := "" + if rawDB != "" { + if schema.ValidDBName(rawDB) { + dbName = rawDB + } else { + // 接受中文等展示名:落码存物理哈希;原文记入 note 便于对照 + dbName = schema.HashLabelDBName(rawDB, 0) + displayNote = rawDB + } + } if channelID == "" && l.svcCtx.DBSync != nil { // Z12h:生成绑定码前确保默认通道存在(含误删自愈) if healed, err := HealTenantSyncBind(l.ctx, l.svcCtx, tid); err != nil { @@ -76,6 +88,13 @@ func (l *AuthLogic) CreateBindCode(req BindCodeCreateReq) (*bindcodestore.BindCo if online == "" { online = dbsync.ResolveOnlineDBID("", channelID) } + note := strings.TrimSpace(req.Note) + if displayNote != "" { + if note != "" { + note = note + " | " + } + note += "display:" + displayNote + } exp := time.Duration(req.ExpiresHours) * time.Hour return l.svcCtx.BindCodes.Create(l.ctx, tid, authx.UserID(l.ctx), bindcodestore.CreateInput{ ChannelID: channelID, @@ -83,7 +102,7 @@ func (l *AuthLogic) CreateBindCode(req BindCodeCreateReq) (*bindcodestore.BindCo DatabaseName: dbName, MaxUses: req.MaxUses, ExpiresIn: exp, - Note: req.Note, + Note: note, }) } @@ -151,10 +170,7 @@ func (l *AuthLogic) RedeemBindCode(req BindCodeRedeemReq) (*BindCodeRedeemResp, if online == "" { online = dbsync.ResolveOnlineDBID("", bc.ChannelID) } - dbName := strings.TrimSpace(bc.DatabaseName) - if dbName == "" { - dbName = fmt.Sprintf("agent_%d", acc.AgentID) - } + dbName := schema.SafeSyncDatabaseName(bc.DatabaseName, acc.AgentID, 0) updated, err := l.svcCtx.Agents.AttachSyncBind(l.ctx, acc.AgentID, bc.TenantID, bc.ChannelID, online, dbName, true) if err != nil { return nil, err diff --git a/platform/internal/logic/applogic/sync_heal.go b/platform/internal/logic/applogic/sync_heal.go index 2d750dc..a42f803 100644 --- a/platform/internal/logic/applogic/sync_heal.go +++ b/platform/internal/logic/applogic/sync_heal.go @@ -9,6 +9,7 @@ import ( "aijianzhan/platform/internal/agentstore" "aijianzhan/platform/internal/dbsync" + "aijianzhan/platform/internal/schema" "aijianzhan/platform/internal/svc" ) @@ -40,6 +41,10 @@ func channelAlive(store *dbsync.FileStore, tenantID int64, channelID string) boo } func agentNeedsSyncHeal(store *dbsync.FileStore, a agentstore.Account) bool { + // Z35:非法物理库名(如中文昵称)也要纠正,否则发布 EnsureDatabase 400 + if dn := strings.TrimSpace(a.DatabaseName); dn != "" && !schema.ValidDBName(dn) { + return true + } cid := strings.TrimSpace(a.ChannelID) alive := channelAlive(store, a.TenantID, cid) if cid != "" { @@ -111,11 +116,12 @@ func HealTenantSyncBind(ctx context.Context, svcCtx *svc.ServiceContext, tenantI if online == "" { online = dbsync.ResolveOnlineDBID("", ch.ID) } - dbName := strings.TrimSpace(a.DatabaseName) - if dbName == "" { - dbName = fmt.Sprintf("agent_%d", a.AgentID) + cid := strings.TrimSpace(a.ChannelID) + if !channelAlive(svcCtx.DBSync.Store(), tenantID, cid) { + cid = ch.ID } - if _, e := svcCtx.Agents.AttachSyncBind(ctx, a.AgentID, tenantID, ch.ID, online, dbName, a.Status == agentstore.StatusActive || a.Status == ""); e != nil { + dbName := schema.SafeSyncDatabaseName(a.DatabaseName, a.AgentID, 0) + if _, e := svcCtx.Agents.AttachSyncBind(ctx, a.AgentID, tenantID, cid, 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 } diff --git a/platform/internal/logic/applogic/yuheng_ticket.go b/platform/internal/logic/applogic/yuheng_ticket.go index 0c1aa5f..e4d8f88 100644 --- a/platform/internal/logic/applogic/yuheng_ticket.go +++ b/platform/internal/logic/applogic/yuheng_ticket.go @@ -8,6 +8,7 @@ import ( "aijianzhan/platform/internal/agentstore" "aijianzhan/platform/internal/authx" "aijianzhan/platform/internal/dbsync" + "aijianzhan/platform/internal/schema" "aijianzhan/platform/internal/types" "aijianzhan/platform/internal/userstore" "aijianzhan/platform/internal/yuhticket" @@ -191,12 +192,12 @@ func (l *AuthLogic) bindUserHostSync(u *userstore.User, hostKey, name, localID, return nil, "", fmt.Errorf("host_key 已绑定其它公司") } online := fmt.Sprintf("%s_u%d", dbsync.ResolveOnlineDBID("", ch.ID), u.UserID) - // DisplayName 可中文;Postgres 物理库名必须 [a-z][a-z0-9_]{1,47}(Z35) + // 中文昵称可接受:写入 Binding 展示字段;物理库名映射为 user_{id} / 哈希(Z35) display := strings.TrimSpace(u.DisplayName) if display == "" { display = fmt.Sprintf("user_%d", u.UserID) } - dbName := fmt.Sprintf("user_%d", u.UserID) + dbName := schema.SafeSyncDatabaseName(display, acc.AgentID, u.UserID) updated, err := l.svcCtx.Agents.AttachSyncBind(l.ctx, acc.AgentID, u.TenantID, ch.ID, online, dbName, true) if err != nil { return nil, "", err @@ -215,7 +216,7 @@ func (l *AuthLogic) bindUserHostSync(u *userstore.User, hostKey, name, localID, LocalDatabaseID: localID, OnlineDBID: online, ChannelID: ch.ID, - DatabaseName: dbName, + DatabaseName: display, // 可读名(可中文) DisplayName: display, Note: note, }) diff --git a/platform/internal/schema/dbname_test.go b/platform/internal/schema/dbname_test.go new file mode 100644 index 0000000..c5db23a --- /dev/null +++ b/platform/internal/schema/dbname_test.go @@ -0,0 +1,33 @@ +package schema + +import "testing" + +func TestSafeSyncDatabaseName(t *testing.T) { + // 有用户:中文昵称 → 稳定 user_{id}(改昵称不换库) + if got := SafeSyncDatabaseName("婷婷管理员", 12, 34); got != "user_34" { + t.Fatalf("want user_34, got %s", got) + } + // 无用户:中文/非法标签 → 稳定哈希 + h1 := SafeSyncDatabaseName("婷婷管理员", 12, 0) + h2 := SafeSyncDatabaseName("婷婷管理员", 12, 0) + if h1 != h2 || !ValidDBName(h1) || h1[:2] != "db" { + t.Fatalf("hash mapping bad: %s", h1) + } + if SafeSyncDatabaseName("另一中文", 12, 0) == h1 { + t.Fatal("different labels should hash differently") + } + if got := SafeSyncDatabaseName("ok_db", 1, 2); got != "ok_db" { + t.Fatalf("want ok_db, got %s", got) + } + if !ValidDBName("user_1") || ValidDBName("婷婷管理员") { + t.Fatal("ValidDBName mismatch") + } +} + +func TestHashLabelDBNameStable(t *testing.T) { + a := HashLabelDBName("宇信达", 7) + b := HashLabelDBName("宇信达", 7) + if a != b || !ValidDBName(a) { + t.Fatalf("unstable or invalid: %s", a) + } +} diff --git a/platform/internal/schema/runner.go b/platform/internal/schema/runner.go index 0626b51..e68fe18 100644 --- a/platform/internal/schema/runner.go +++ b/platform/internal/schema/runner.go @@ -2,11 +2,14 @@ package schema import ( "context" + "crypto/sha256" "database/sql" + "encoding/hex" "fmt" "net/url" "regexp" "strings" + "unicode" ) var dbNameRe = regexp.MustCompile(`^[a-z][a-z0-9_]{1,47}$`) @@ -108,3 +111,52 @@ func SafePhysicalDBName(preferred, fallback string) string { } return "appdb_default" } + +// SafeSyncDatabaseName Z35:智能体同步落点的 Postgres 物理库名。 +// - 已是合法 ASCII → 原样 +// - 中文/非法串:有 userID → user_{id}(昵称可变仍稳定);否则对标签做稳定哈希 db{hex} +// - 展示中文请写 Binding.DisplayName / Binding.DatabaseName(可读名),勿把物理名当展示名 +func SafeSyncDatabaseName(preferred string, agentID, userID int64) string { + p := strings.TrimSpace(preferred) + if ValidDBName(p) { + return p + } + // 有登录用户:物理名跟用户稳定绑定;中文昵称只作展示 + if userID > 0 { + n := fmt.Sprintf("user_%d", userID) + if ValidDBName(n) { + return n + } + } + if p != "" { + return HashLabelDBName(p, agentID) + } + if agentID > 0 { + n := fmt.Sprintf("agent_%d", agentID) + if ValidDBName(n) { + return n + } + } + return "appdb_default" +} + +// HashLabelDBName 将任意标签(含中文)稳定映射为合法物理库名:db + 16 hex。 +func HashLabelDBName(label string, agentID int64) string { + label = strings.TrimSpace(label) + sum := sha256.Sum256([]byte(fmt.Sprintf("ajz-db:%d:%s", agentID, label))) + return "db" + hex.EncodeToString(sum[:8]) +} + +// IsDisplayLabel 是否更像「展示名」而非物理库名(含非 ASCII / 大写 / 连字符等)。 +func IsDisplayLabel(s string) bool { + s = strings.TrimSpace(s) + if s == "" || ValidDBName(s) { + return false + } + for _, r := range s { + if r > unicode.MaxASCII || unicode.Is(unicode.Han, r) { + return true + } + } + return true +} diff --git a/联调后修改意见-宇恒松离线.md b/联调后修改意见-宇恒松离线.md index 7488397..cfaf7ab 100644 --- a/联调后修改意见-宇恒松离线.md +++ b/联调后修改意见-宇恒松离线.md @@ -30,7 +30,7 @@ | **Z23 表单推送失败禁止空等** | **宇恒已改完;智建无需** | `ask_fields_via_chat` 推送失败立即抛错;进行中再选中会话给出提示;对齐 ty_host≥1.2.35(§5.21) | | **Z24 已绑定勿 force 清落点** | **宇恒已改完;智建无需** | 启动自动引导绑定时禁止 `force_rebind` 清 CHANNEL;仅用户点「换绑」才清(§5.22) | | **Z34 / Z34b 换机恢复** | **宇恒半程 + 智建已落实** | 有手机静默恢复(Z34);无手机 `POST …/restore-by-host`(Z34b · §5.32) | -| **Z35 库名非法中文** | **智建 · 意见已写清 · 生产须 pull** | DisplayName 当 PG 库名 → 400「婷婷管理员」;§5.33;**宇恒勿改** | +| **Z35 库名非法中文** | **智建已落实(须生产 pull)** | 绑定/AttachSyncBind/heal 纠正;publish 拒非法名;§5.33;**宇恒勿改** | | **仍关注** | 用法 | 通道断了靠智建自愈;表数据靠宇恒双向指纹 / 数据恢复;不是「再点启动」 | ### 0.0 修改流程(冻结) @@ -1086,7 +1086,7 @@ POST /api/v1/agent/sync/channels/{id}/pull ### 5.33 【Z35 · 2026-08-07】绑定勿用中文 DisplayName 作 Postgres 库名 -**状态**:**智建负责(须生产 pull)· 宇恒勿改代码**。 +**状态**:**智建已落实(须生产 `bash ./restart.sh --pull`)· 宇恒勿改代码**。 联调复现仍指向生产 `aisite…`:`HTTP 400: ensure database: invalid database name: 婷婷管理员` —— **在生产未 pull 前会继续报错**,不是宇恒终端问题。 #### 现象 @@ -1099,7 +1099,7 @@ POST /api/v1/agent/sync/channels/{id}/pull #### 根因 -1. 绑定路径 `bindUserHostSync` 把成员 **DisplayName(可中文)** 写入智能体 `database_name`。 +1. 绑定路径曾把成员 **DisplayName(可中文)** 写入智能体 `database_name`。 2. 发布 Z8c:智能体带 `database_name` 时走 `database_per_app` → `EnsureDatabase` → Postgres `CREATE DATABASE`。 3. 物理库名校验:`^[a-z][a-z0-9_]{1,47}$` —— 中文昵称非法 → 400。 @@ -1107,18 +1107,19 @@ POST /api/v1/agent/sync/channels/{id}/pull | 方 | 做什么 | 不做什么 | |----|--------|----------| -| **智建** | 改绑定落点库名规则 + publish 遇非法名纠正;**生产 `bash ./restart.sh --pull`** | — | +| **智建** | 绑定落点库名规则 + AttachSyncBind/heal 纠正脏数据 + publish 遇非法名纠正;**生产 pull** | — | | **宇恒** | **不改**;意见对齐与复测 | 不在终端侧绕过/改写智建 `database_name` | -#### 智建改法(意见约定 · 实现要点) +#### 智建改法(已落实) -1. **绑定**:`Agents.DatabaseName` / Binding 物理名用 `user_{userID}`(或同等合法 ASCII);`Binding.DisplayName` 仍用昵称「婷婷管理员」。 -2. **publish**:仅当 `ValidDBName(acc.DatabaseName)` 才用于 Ensure;否则回落 `AssignDatabaseName` / `SanitizeDBName`,并**回写纠正**智能体脏 `database_name`,避免反复 400。 -3. 已存在 app 的非法 `existing.DatabaseName` **不得**继续沿用。 +1. **中文可接受(展示层)**:昵称/库可读名可中文,写入 `Binding.database_name` / `display_name`。 +2. **物理层映射**:`Agents.database_name` 仅 ASCII —— 有用户 → `user_{id}`(改昵称不换库);无用户的中文标签 → 稳定哈希 `db`+16hex。 +3. **AttachSyncBind / heal / publish**:只对物理名做 EnsureDatabase;非法历史脏数据自动纠正。 +4. **换票 token**:不把中文 DisplayName 误当成物理库名下发。 #### 验收 -1. 生产 pull 后,昵称含中文的已绑账号 → 新建/发布模块 **不再** 400。 +1. 生产 pull 后,昵称含中文的已绑账号 → 打开 SyncPage/agents/me(触发 heal)或再发布 → **不再** 400。 2. 智能体 `database_name` 为合法 `a-z0-9_`;控制台展示名仍可为中文。 3. 宇恒侧无需发版即可复测通过。