From cb56e6847eab319101618b6268509c8cbbf1d24c Mon Sep 17 00:00:00 2001 From: whm <973418690@qq.com> Date: Wed, 5 Aug 2026 11:47:20 +0800 Subject: [PATCH] feat: add Z12/Z13 bind APIs, stock import, and sync docs Enable auto default sync channels on agent activate, bind-code/phone confirm flows, publish ALTER, and align admin/yuheng docs with the production bind path. --- docs/数据同步-开通说明.md | 79 ++-- platform/etc/platform.docker.yaml | 2 + platform/etc/platform.yaml | 3 + platform/internal/agentstore/store.go | 93 +++++ platform/internal/apidef/catalog.go | 8 + platform/internal/bindcodestore/store.go | 218 +++++++++++ platform/internal/blueprint/merge.go | 8 + platform/internal/config/config.go | 9 +- .../internal/dbsync/ensure_default_channel.go | 109 ++++++ platform/internal/dbsync/types.go | 2 + platform/internal/dbsync/validate.go | 3 + platform/internal/handler/ensure_import.go | 48 +++ platform/internal/handler/routes.go | 120 ++++++ .../logic/applogic/agent_sync_bind.go | 91 +++++ platform/internal/logic/applogic/agents.go | 19 +- platform/internal/logic/applogic/bind_flow.go | 357 ++++++++++++++++++ platform/internal/logic/applogic/impex.go | 52 ++- platform/internal/logic/applogic/publish.go | 20 + .../internal/logic/applogic/tenant_invite.go | 1 + platform/internal/meta/backfill_import.go | 85 +++++ .../internal/meta/backfill_import_test.go | 59 +++ platform/internal/meta/postgres.go | 23 ++ platform/internal/meta/store.go | 13 + platform/internal/schema/alter.go | 92 +++++ platform/internal/svc/servicecontext.go | 13 + platform/internal/types/types.go | 5 + web/src/ModulesPage.tsx | 49 ++- web/src/SyncPage.tsx | 38 +- web/src/api.ts | 19 + 宇恒-松离线数据同步使用文档.md | 180 ++++++++- 联调后修改意见-宇恒松离线.md | 155 ++++++-- 31 files changed, 1882 insertions(+), 91 deletions(-) create mode 100644 platform/internal/bindcodestore/store.go create mode 100644 platform/internal/dbsync/ensure_default_channel.go create mode 100644 platform/internal/handler/ensure_import.go create mode 100644 platform/internal/logic/applogic/agent_sync_bind.go create mode 100644 platform/internal/logic/applogic/bind_flow.go create mode 100644 platform/internal/meta/backfill_import.go create mode 100644 platform/internal/meta/backfill_import_test.go create mode 100644 platform/internal/schema/alter.go diff --git a/docs/数据同步-开通说明.md b/docs/数据同步-开通说明.md index 1e71dba..46da910 100644 --- a/docs/数据同步-开通说明.md +++ b/docs/数据同步-开通说明.md @@ -1,40 +1,72 @@ # 数据同步 · 开通说明(管理员) -> 依据:`松离线-dbsync方案-最终版.md` -> **默认客户无感**:未显式开通时,终端保存与自增表行为与现网一致。 +> 依据:`松离线-dbsync方案-最终版.md`、`联调后修改意见-宇恒松离线.md`(Z12/Z13) +> **默认客户无感**:未显式开通时,终端保存与自增表行为与现网一致。 +> **生产开通主路径**:启用智能体自动绑通道 / 绑定码 / 同号确认;**不要**把「手建通道 + 抄通道 ID」当作普通开通必经步骤。 -## 三分模式(客户端配置,智建通道为表白名单源) +## 三分模式(客户端配置) | 模式 | 含义 | 谁改 | |------|------|------| | `local_only` | 仅本地,无同步 | 默认之一 | | `online_primary` | HTTP 双写 / 离线 pending(旧路径) | 已配线上 API 且未写 MODE 时兼容升 | -| `local_dbsync` | 松离线 + 表白名单 + 本机 agent | **仅显式配置**,禁止自动升 | +| `local_dbsync` | 松离线 + 本机 agent(表白名单或 Binding 整库) | **仅显式配置**或用户库级选「同步」,禁止静默全员升 | -智建控制台「数据同步」配的是**通道 + 表白名单 + 线上 DSN**;是否走 `local_dbsync` 由客户端环境变量决定,平台**不会**替全员切默认。 +是否走 `local_dbsync` 由客户端环境变量 / 库级策略决定,平台**不会**替全员切默认。 + +## 联调账号 + +| 环境 | 手机号 | 密码 | 说明 | +|------|--------|------|------| +| 本机演示公司 | `13800000001` | `demo123` | 仅本机;勿推生产宇信达通道 | +| **生产 · 宇信达** | **`13531041944`** | 开通时设定 | 公司侧联调专用;智建须在「宇信达」写入此成员手机;宇恒 `YXD_SYNC_LOGIN_PHONE` 与同号绑定均对齐此号 | +| 平台超管(禁止联调) | `13531041945` | `ljk_admin` | 最顶级管理员;**禁止**用作同步/绑定联调 | ## 线上库 A:统一 Postgres | 场景 | 推荐 | |------|------| | **生产 A** | **`postgres`**(经智建 agent push 写入;可与平台同机或独立实例) | -| 本机 B | 宇恒侧多为 SQLite(通道 `local` 仅描述表白名单,形态 B 下平台可不连 B) | +| 本机 B | 宇恒侧多为 SQLite(形态 B 下平台可不连 B) | | 联调临时 A | 可用 sqlite 文件;**不宜**多 agent 并发写 | +公司默认同步通道的 remote DSN:生产在 `platform.yaml` / 环境配置 `DBSync.DefaultRemoteDSN`;空则联调用 sqlite 文件。 + Postgres DSN 示例: ```text postgres://user:pass@127.0.0.1:5432/app_online?sslmode=disable ``` -## 单服务器 / 多服务器(同一套 API) +## 生产开通(推荐 · Z12/Z13) -同步**一律经智建平台**(agent → whitelist/push),不因服务器数量改协议。 +普通管理员路径:**启用智能体 → 完成**(勿引导先「新建通道」、勿要求用户抄 `channel_id`)。 + +| 步骤 | 做什么 | +|------|--------| +| 1 | 确认公司默认同步 DSN(`DefaultRemoteDSN`)指向生产 Postgres | +| 2 | 「用户管理」**启用智能体** → 平台自动创建 `is_system_default` 通道,并写回该智能体 `channel_id` / `online_db_id` | +| 3 | (可选)`POST /api/v1/admin/bind-codes` 生成绑定码,发给终端用户 | +| 4 | 终端:已绑则换票带 `sync_bound=true`;未绑则 **绑定码兑换** 或 **手机号 lookup→确认**(同号必须弹窗,禁止静默绑) | +| 5 | 宇恒本机库选「同步」→ 自动 Binding + agent drain;本地未装 agent 仍可保存,文案「需 agent 才上云」 | + +同号硬约束:宇恒手机 = 公司成员手机(联调样例 **`13531041944`**)→ 必须询问「已有账号是否绑定」;**勿用**超管号 `13531041945`。 + +换票 / 自查: + +- `POST /api/v1/auth/token` → `channel_id` / `online_db_id` / `database_name` / `sync_bound` +- `GET /api/v1/agents/me`(智能体 Bearer) + +## 运维高级:手建通道(过渡 / 多机) + +「数据同步」页仍可新建/改通道(改 DSN、多服务器、排障),**不是**普通开通必经 UI。 | 部署 | 怎么配 | |------|--------| -| **单服务器** | 公司一条通道:`remote` 指向该 Postgres;宇恒配置一个 `YXD_SYNC_CHANNEL_ID` | -| **多服务器** | **每台线上库一条通道**(不同 remote DSN);宇恒按 `local_database_id` / Binding 选用对应 `channel_id`(或多 agent 实例各绑一通道) | +| **单服务器** | 优先用公司**一条**默认同步通道(自动创建);宇恒优先换票落点,`YXD_SYNC_CHANNEL_ID` 仅作过渡缓存 | +| **多服务器** | **每台线上库一条通道**(不同 remote DSN);宇恒按 `local_database_id` / Binding 选用对应 `channel_id` | + +手建时建议:方向 **本地 → 线上**,策略 **源端覆盖(lww_source)**;表白名单可空(整库 Binding);若填表须 UUID PK + FK 闭包。 Binding:`POST /api/v1/admin/sync/bindings` 登记 `local_database_id → online_db_id`,并带上 `channel_id`。 管理员 Token 或**登录用户 JWT**均可登记;普通用户只能登记/查看本人 Binding。 @@ -45,38 +77,31 @@ Binding:`POST /api/v1/admin/sync/bindings` 登记 `local_database_id → onlin |------|------| | **本机 B 管理面** | 模块业务表应能在宇恒本机库(如「AI建站智能体API」)里增删改查/导入,与普通表同一体验 | | **上云** | 开通 sync 后经 outbox → agent push → 智能体绑定的线上库 A | -| **智能体绑库** | 控制台「用户管理」为智能体填 `channel_id` / `online_db_id` / `database_name`;「数据同步」可按智能体筛通道 | +| **智能体绑库** | 生产由启用智能体自动写入;控制台仍可查看/改 `channel_id` / `online_db_id` / `database_name`;「数据同步」可按智能体筛通道 | | **发布落库** | 智能体 Token 新建发布时,若已设 `database_name`,优先 `database_per_app` 写入该库 | -不要默认维持「模块只在平台 `/apps` schema、松离线又一套 SQLite」双轨;对账时本机有、线上无 = 尚未 push,不是串库。 +不要默认维持「模块只在平台 `/apps` schema、松离线又一套 SQLite」双轨;对账时本机有、线上无 = 尚未 push / 未 ensure,不是串库。 ## 用户自助(库级三态) 客户端对每个库可选:仅本地 / 仅线上 / 同步。选同步后: -1. 用户 JWT → `POST /api/v1/admin/sync/bindings`(带 `channel_id`) -2. 同 JWT → `GET .../agent/sync/channels/{id}/whitelist` -3. 同 JWT → `POST .../push`,Body 须含本人 `online_db_id` +1. 确认已绑定本公司落点(换票 `sync_bound` 或 Binding);未绑先走绑定码 / 同号确认 +2. 用户 JWT → `POST /api/v1/admin/sync/bindings`(带 `channel_id`) +3. 同 JWT → `GET .../agent/sync/channels/{id}/whitelist` +4. 同 JWT → `POST .../push`,Body 须含本人 `online_db_id` -通道表白名单**非空**时只同步命中表;**空名单**时平台接受整库表名(UUID PK)。 +通道表白名单**非空**时只同步命中表;**空名单**(默认同步通道常见)时平台接受整库表名(UUID PK)。 「仅线上 / 下行拉齐」:`POST .../agent/sync/channels/{id}/pull`(`mode=bootstrap|pks|rows`)或 `.../bootstrap`;平台返回 A 上行,客户端写入本机 B。 **空表也要两侧建齐**:本机空表 → `POST .../schema/ensure`;线上空表 → `POST .../schema`(或 pull 的 `columns`)在本机 `CREATE IF NOT EXISTS`。仅靠 outbox 行 push **不会**带上空表。 控制台「查看线上表」可 **删表**(`POST .../admin/sync/channels/{id}/drop-table`):只删当前查看侧,**不同步** `DROP` 到另一侧;本机仍有同名表时下次 push/ensure 可能再建回来。 -## 开通步骤(增值) - -1. 「数据同步」新建通道:`remote.driver=postgres` + 线上 DSN;表白名单须 **UUID TEXT/UUID PK** + FK 闭包。 -2. 方向 **本地 → 线上**,策略 **源端覆盖(lww_source)**。 -3. 客户端显式 `YXD_SYNC_MODE=local_dbsync`,配置 `YXD_ONLINE_API_BASE`、`YXD_SYNC_CHANNEL_ID`、token(见 `同步表约定.md`)。 -4. 装本机 sync agent 后才上云;未装:**本地可保存**,文案「需 agent 才上云」。 -5. 多库时登记 Binding,避免串库。 - ## 谁能看什么 | 角色 | 可见 | |------|------| -| 公司管理员 | 通道配置、对账(同步修复)、统计;**无**冲突台 / LWW 覆盖明细 | -| 普通登录用户 | 不可配通道;可自助 Binding + 用登录 JWT push 本人库 | +| 公司管理员 | 启用智能体、绑定码、通道配置(运维)、对账统计;**无**冲突台 / LWW 覆盖明细 | +| 普通登录用户 | 不可配通道;可自助 Binding + 用登录 JWT push 本人库;可走绑定码/同号确认 | | 平台超级管理员 | LWW 覆盖审计(平台工作台) | | 未开通终端用户 | **零同步文案**,无强制状态条 | @@ -85,3 +110,5 @@ Binding:`POST /api/v1/admin/sync/bindings` 登记 `local_database_id → onlin - [同步表约定.md](./同步表约定.md) - [数据同步-迁移手册.md](./数据同步-迁移手册.md)(旧客从 HTTP 双写迁入) - [数据同步-中间件.md](./数据同步-中间件.md) +- `联调后修改意见-宇恒松离线.md`(Z12/Z13 验收与分工) +- `宇恒-松离线数据同步使用文档.md`(宇恒对接契约) diff --git a/platform/etc/platform.docker.yaml b/platform/etc/platform.docker.yaml index 4384757..5ceed14 100644 --- a/platform/etc/platform.docker.yaml +++ b/platform/etc/platform.docker.yaml @@ -44,3 +44,5 @@ DBSync: DataDir: /app/data/dbsync LwwAuditTTLDays: 90 ReconcileMinSec: 300 + DefaultRemoteDriver: postgres + DefaultRemoteDSN: "" diff --git a/platform/etc/platform.yaml b/platform/etc/platform.yaml index 1f62962..c0a1713 100644 --- a/platform/etc/platform.yaml +++ b/platform/etc/platform.yaml @@ -49,3 +49,6 @@ DBSync: DataDir: ./data/dbsync LwwAuditTTLDays: 90 ReconcileMinSec: 300 + # Z12:生产填公司默认 Postgres DSN;空则用 sqlite 联调文件 + DefaultRemoteDriver: postgres + DefaultRemoteDSN: "" diff --git a/platform/internal/agentstore/store.go b/platform/internal/agentstore/store.go index 2d713ba..7a43eb7 100644 --- a/platform/internal/agentstore/store.go +++ b/platform/internal/agentstore/store.go @@ -79,6 +79,10 @@ type Store interface { HasAppAccess(ctx context.Context, agentID int64, slug string) (bool, error) // GrantAppSlug 将 slug 写入智能体可访问模块(幂等)。新建发布时自动授权用。 GrantAppSlug(ctx context.Context, agentID int64, slug string) error + // FindByHostKey 按宿主机 key 查找(跨租户,Z13 绑定用)。 + FindByHostKey(ctx context.Context, hostKey string) (*Account, error) + // AttachSyncBind 写入同步落点;可改挂租户并激活(Z12/Z13)。 + AttachSyncBind(ctx context.Context, agentID, tenantID int64, channelID, onlineDBID, databaseName string, activate bool) (*Account, error) } type memAcc struct { @@ -341,6 +345,42 @@ func (s *MemoryStore) GrantAppSlug(_ context.Context, agentID int64, slug string return nil } +func (s *MemoryStore) FindByHostKey(_ context.Context, hostKey string) (*Account, error) { + hostKey = strings.TrimSpace(hostKey) + if hostKey == "" { + return nil, fmt.Errorf("host_key required") + } + s.mu.Lock() + defer s.mu.Unlock() + for _, a := range s.byID { + if a.HostKey == hostKey { + cp := cloneAcc(&a.Account) + return &cp, nil + } + } + return nil, fmt.Errorf("agent not found") +} + +func (s *MemoryStore) AttachSyncBind(_ context.Context, agentID, tenantID int64, channelID, onlineDBID, databaseName string, activate bool) (*Account, error) { + s.mu.Lock() + defer s.mu.Unlock() + a, ok := s.byID[agentID] + if !ok { + return nil, fmt.Errorf("agent not found") + } + if tenantID > 0 { + a.TenantID = tenantID + } + a.ChannelID = strings.TrimSpace(channelID) + a.OnlineDBID = strings.TrimSpace(onlineDBID) + a.DatabaseName = strings.TrimSpace(databaseName) + if activate { + a.Status = StatusActive + } + cp := cloneAcc(&a.Account) + return &cp, nil +} + type PostgresStore struct { DB *sql.DB } @@ -653,6 +693,59 @@ INSERT INTO platform_meta.agent_app_grants(agent_id, slug) VALUES($1,$2)`, agent return err } +func (s *PostgresStore) FindByHostKey(ctx context.Context, hostKey string) (*Account, error) { + hostKey = strings.TrimSpace(hostKey) + if hostKey == "" { + return nil, fmt.Errorf("host_key required") + } + var a Account + var last sql.NullTime + err := s.DB.QueryRowContext(ctx, ` +SELECT agent_id, tenant_id, name, client_id, COALESCE(host_key,''), COALESCE(role_id,0), + COALESCE(channel_id,''), COALESCE(online_db_id,''), COALESCE(database_name,''), + status, created_by, created_at, last_token_at +FROM platform_meta.agent_accounts WHERE host_key=$1 +ORDER BY agent_id DESC LIMIT 1`, hostKey, + ).Scan(&a.AgentID, &a.TenantID, &a.Name, &a.ClientID, &a.HostKey, &a.RoleID, + &a.ChannelID, &a.OnlineDBID, &a.DatabaseName, + &a.Status, &a.CreatedBy, &a.CreatedAt, &last) + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("agent not found") + } + if err != nil { + return nil, err + } + if last.Valid { + t := last.Time + a.LastTokenAt = &t + } + perms, slugs, err := s.loadKids(ctx, a.AgentID) + if err != nil { + return nil, err + } + a.Perms, a.AppSlugs = perms, slugs + return &a, nil +} + +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} + if activate { + statusSQL = ", status='active'" + } + res, err := s.DB.ExecContext(ctx, ` +UPDATE platform_meta.agent_accounts SET tenant_id=$1, channel_id=$2, online_db_id=$3, database_name=$4`+statusSQL+` +WHERE agent_id=$5`, args...) + if err != nil { + return nil, err + } + n, _ := res.RowsAffected() + if n == 0 { + return nil, fmt.Errorf("agent not found") + } + return s.Get(ctx, tenantID, agentID) +} + func (s *PostgresStore) loadKids(ctx context.Context, agentID int64) ([]string, []string, error) { prows, err := s.DB.QueryContext(ctx, `SELECT perm FROM platform_meta.agent_permissions WHERE agent_id=$1`, agentID) if err != nil { diff --git a/platform/internal/apidef/catalog.go b/platform/internal/apidef/catalog.go index ef0e51e..b518f25 100644 --- a/platform/internal/apidef/catalog.go +++ b/platform/internal/apidef/catalog.go @@ -27,12 +27,20 @@ var Catalog = []Entry{ {Method: "PUT", Path: "/api/v1/auth/phone", OperationID: "bindPhone", Summary: "绑定或更换手机号", Group: "auth"}, {Method: "POST", Path: "/api/v1/auth/token", OperationID: "authToken", Summary: "服务/智能体签发 JWT(含 client_credentials)", Public: true, Group: "auth"}, {Method: "POST", Path: "/api/v1/auth/agent/register", OperationID: "agentSelfRegister", Summary: "宿主首次连接自注册(pending)", Public: true, Group: "auth"}, + {Method: "POST", Path: "/api/v1/auth/bind-code/redeem", OperationID: "redeemBindCode", Summary: "绑定码兑换(host_key+code)", Public: true, Group: "auth"}, + {Method: "POST", Path: "/api/v1/auth/bind/phone-lookup", OperationID: "bindPhoneLookup", Summary: "同号探测(不绑定)", Public: true, Group: "auth"}, + {Method: "POST", Path: "/api/v1/auth/bind/phone-confirm", OperationID: "bindPhoneConfirm", Summary: "同号确认后绑定", Public: true, Group: "auth"}, {Method: "POST", Path: "/api/v1/auth/invites/accept", OperationID: "acceptInvite", Summary: "接受邀请加入租户", Group: "auth"}, {Method: "POST", Path: "/api/v1/tenants", OperationID: "createTenant", Summary: "pending 用户创建自己的公司", Group: "auth"}, {Method: "GET", Path: "/api/v1/meta/apis", OperationID: "listApis", Summary: "API 目录(防重复约定)", Public: true, Group: "meta"}, {Method: "GET", Path: "/api/v1/meta/openapi.yaml", OperationID: "getOpenAPI", Summary: "OpenAPI 契约原文", Public: true, Group: "meta"}, + {Method: "GET", Path: "/api/v1/agents/me", OperationID: "agentMe", Summary: "智能体自查绑定落点", Group: "agent"}, + {Method: "GET", Path: "/api/v1/admin/bind-codes", OperationID: "listBindCodes", Summary: "列出公司绑定码", Group: "admin"}, + {Method: "POST", Path: "/api/v1/admin/bind-codes", OperationID: "createBindCode", Summary: "生成绑定码", Group: "admin"}, + {Method: "DELETE", Path: "/api/v1/admin/bind-codes/{code}", OperationID: "revokeBindCode", Summary: "撤销绑定码", Group: "admin"}, + {Method: "GET", Path: "/api/v1/admin/agents", OperationID: "listAgents", Summary: "列出智能体账号", Group: "admin"}, {Method: "POST", Path: "/api/v1/admin/agents", OperationID: "createAgent", Summary: "创建智能体账号", Group: "admin"}, {Method: "GET", Path: "/api/v1/admin/agents/{id}", OperationID: "getAgent", Summary: "智能体详情", Group: "admin"}, diff --git a/platform/internal/bindcodestore/store.go b/platform/internal/bindcodestore/store.go new file mode 100644 index 0000000..65bc82d --- /dev/null +++ b/platform/internal/bindcodestore/store.go @@ -0,0 +1,218 @@ +package bindcodestore + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +// BindCode 公司绑定码(Z13):兑换后挂默认同步落点。 +type BindCode struct { + Code string `json:"code"` + TenantID int64 `json:"tenant_id"` + ChannelID string `json:"channel_id,omitempty"` + OnlineDBID string `json:"online_db_id,omitempty"` + DatabaseName string `json:"database_name,omitempty"` + MaxUses int `json:"max_uses"` + UsedCount int `json:"used_count"` + Revoked bool `json:"revoked"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + CreatedBy int64 `json:"created_by"` + CreatedAt time.Time `json:"created_at"` + Note string `json:"note,omitempty"` +} + +type CreateInput struct { + ChannelID string + OnlineDBID string + DatabaseName string + MaxUses int // 默认 1 + ExpiresIn time.Duration // 0=7天 + Note string +} + +type Store interface { + Create(ctx context.Context, tenantID, createdBy int64, in CreateInput) (*BindCode, error) + List(ctx context.Context, tenantID int64) ([]BindCode, error) + Revoke(ctx context.Context, tenantID int64, code string) error + Redeem(ctx context.Context, code string) (*BindCode, error) // 校验并 +1 used + Get(ctx context.Context, code string) (*BindCode, error) +} + +type FileStore struct { + mu sync.Mutex + path string +} + +func NewFileStore(dir string) (*FileStore, error) { + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, err + } + return &FileStore{path: filepath.Join(dir, "bind_codes.json")}, nil +} + +func (s *FileStore) read() ([]BindCode, error) { + b, err := os.ReadFile(s.path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + if len(b) == 0 { + return nil, nil + } + var list []BindCode + if err := json.Unmarshal(b, &list); err != nil { + return nil, err + } + return list, nil +} + +func (s *FileStore) write(list []BindCode) error { + b, err := json.MarshalIndent(list, "", " ") + if err != nil { + return err + } + tmp := s.path + ".tmp" + if err := os.WriteFile(tmp, b, 0o644); err != nil { + return err + } + return os.Rename(tmp, s.path) +} + +func genCode() (string, error) { + var buf [8]byte + if _, err := rand.Read(buf[:]); err != nil { + return "", err + } + return strings.ToUpper(hex.EncodeToString(buf[:])), nil +} + +func (s *FileStore) Create(_ context.Context, tenantID, createdBy int64, in CreateInput) (*BindCode, error) { + s.mu.Lock() + defer s.mu.Unlock() + list, err := s.read() + if err != nil { + return nil, err + } + code, err := genCode() + if err != nil { + return nil, err + } + maxUses := in.MaxUses + if maxUses <= 0 { + maxUses = 1 + } + expIn := in.ExpiresIn + if expIn <= 0 { + expIn = 7 * 24 * time.Hour + } + exp := time.Now().UTC().Add(expIn) + bc := BindCode{ + Code: code, + TenantID: tenantID, + ChannelID: strings.TrimSpace(in.ChannelID), + OnlineDBID: strings.TrimSpace(in.OnlineDBID), + DatabaseName: strings.TrimSpace(in.DatabaseName), + MaxUses: maxUses, + CreatedBy: createdBy, + CreatedAt: time.Now().UTC(), + ExpiresAt: &exp, + Note: in.Note, + } + list = append(list, bc) + if err := s.write(list); err != nil { + return nil, err + } + return &bc, nil +} + +func (s *FileStore) List(_ context.Context, tenantID int64) ([]BindCode, error) { + s.mu.Lock() + defer s.mu.Unlock() + list, err := s.read() + if err != nil { + return nil, err + } + out := make([]BindCode, 0) + for _, bc := range list { + if bc.TenantID == tenantID { + out = append(out, bc) + } + } + return out, nil +} + +func (s *FileStore) Revoke(_ context.Context, tenantID int64, code string) error { + s.mu.Lock() + defer s.mu.Unlock() + code = strings.ToUpper(strings.TrimSpace(code)) + list, err := s.read() + if err != nil { + return err + } + for i := range list { + if list[i].Code == code && list[i].TenantID == tenantID { + list[i].Revoked = true + return s.write(list) + } + } + return fmt.Errorf("bind code not found") +} + +func (s *FileStore) Get(_ context.Context, code string) (*BindCode, error) { + s.mu.Lock() + defer s.mu.Unlock() + code = strings.ToUpper(strings.TrimSpace(code)) + list, err := s.read() + if err != nil { + return nil, err + } + for i := range list { + if list[i].Code == code { + cp := list[i] + return &cp, nil + } + } + return nil, fmt.Errorf("bind code not found") +} + +func (s *FileStore) Redeem(_ context.Context, code string) (*BindCode, error) { + s.mu.Lock() + defer s.mu.Unlock() + code = strings.ToUpper(strings.TrimSpace(code)) + list, err := s.read() + if err != nil { + return nil, err + } + for i := range list { + bc := &list[i] + if bc.Code != code { + continue + } + if bc.Revoked { + return nil, fmt.Errorf("bind code revoked") + } + if bc.ExpiresAt != nil && time.Now().UTC().After(*bc.ExpiresAt) { + return nil, fmt.Errorf("bind code expired") + } + if bc.UsedCount >= bc.MaxUses { + return nil, fmt.Errorf("bind code exhausted") + } + bc.UsedCount++ + if err := s.write(list); err != nil { + return nil, err + } + cp := *bc + return &cp, nil + } + return nil, fmt.Errorf("bind code not found") +} diff --git a/platform/internal/blueprint/merge.go b/platform/internal/blueprint/merge.go index e9c0f4f..85959cd 100644 --- a/platform/internal/blueprint/merge.go +++ b/platform/internal/blueprint/merge.go @@ -10,6 +10,7 @@ type MergeResult struct { AddedPages []string AddedEntities []string AddedResources []string + AddedFields []string // entity.field UpdatedPages []string UpdatedResources []string } @@ -34,7 +35,13 @@ func MergeInto(base, incoming *Blueprint) (*MergeResult, error) { } for _, e := range incoming.Entities { if idx, ok := entityByName[e.Name]; ok { + before := len(base.Entities[idx].Fields) base.Entities[idx] = mergeEntity(base.Entities[idx], e) + if len(base.Entities[idx].Fields) > before { + for _, f := range base.Entities[idx].Fields[before:] { + res.AddedFields = append(res.AddedFields, e.Name+"."+f.Name) + } + } continue } base.Entities = append(base.Entities, e) @@ -105,6 +112,7 @@ func MergeInto(base, incoming *Blueprint) (*MergeResult, error) { if len(res.AddedPages) == 0 && len(res.AddedEntities) == 0 && len(res.AddedResources) == 0 && + len(res.AddedFields) == 0 && len(res.UpdatedPages) == 0 && len(res.UpdatedResources) == 0 { return nil, fmt.Errorf("nothing new to publish: provide newly generated pages (and entities/apis if needed) for an existing app") diff --git a/platform/internal/config/config.go b/platform/internal/config/config.go index 3acb6ee..cde138f 100644 --- a/platform/internal/config/config.go +++ b/platform/internal/config/config.go @@ -68,8 +68,11 @@ type StorageConf struct { } type DBSyncConf struct { - Enabled bool `json:",default=true"` + Enabled bool `json:",default=true"` DataDir string `json:",default=./data/dbsync"` // 通道/冲突/LWW 审计 JSON - LwwAuditTTLDays int `json:",default=90"` // 超管 LWW 覆盖日志保留天数 - ReconcileMinSec int `json:",default=300"` // 手动对账最小间隔(秒) + LwwAuditTTLDays int `json:",default=90"` // 超管 LWW 覆盖日志保留天数 + ReconcileMinSec int `json:",default=300"` // 手动对账最小间隔(秒) + // Z12:公司默认同步通道(启用智能体时自动创建) + DefaultRemoteDriver string `json:",default=postgres"` + DefaultRemoteDSN string `json:",optional"` // 空则用 sqlite 联调文件:./data/dbsync/tenant_{id}_online.db } diff --git a/platform/internal/dbsync/ensure_default_channel.go b/platform/internal/dbsync/ensure_default_channel.go new file mode 100644 index 0000000..2266dab --- /dev/null +++ b/platform/internal/dbsync/ensure_default_channel.go @@ -0,0 +1,109 @@ +package dbsync + +import ( + "fmt" + "path/filepath" + "strings" +) + +// DefaultChannelOpts 创建公司默认同步通道(Z12c)。 +type DefaultChannelOpts struct { + TenantID int64 + AgentID int64 + Name string + RemoteDriver Driver + RemoteDSN string + OnlineDBID string // 空则用 channel id + DatabaseName string +} + +// FindSystemDefaultChannel 返回该公司 IsSystemDefault 通道(若有多条取最新启用的)。 +func (s *FileStore) FindSystemDefaultChannel(tenantID int64) (*Channel, error) { + s.mu.Lock() + defer s.mu.Unlock() + list, err := s.readChannels() + if err != nil { + return nil, err + } + var best *Channel + for i := range list { + ch := list[i] + if ch.TenantID != tenantID || !ch.IsSystemDefault { + continue + } + cp := ch + if best == nil || cp.UpdatedAt.After(best.UpdatedAt) { + best = &cp + } + } + return best, nil +} + +// EnsureSystemDefaultChannel 若无默认同步通道则创建;返回通道(已存在则复用)。 +func (s *FileStore) EnsureSystemDefaultChannel(opts DefaultChannelOpts) (Channel, error) { + if opts.TenantID <= 0 { + return Channel{}, fmt.Errorf("tenant_id required") + } + if existing, err := s.FindSystemDefaultChannel(opts.TenantID); err == nil && existing != nil { + ch := *existing + if opts.AgentID > 0 && ch.AgentID == 0 { + ch.AgentID = opts.AgentID + saved, err := s.SaveChannel(ch) + if err != nil { + return Channel{}, err + } + return saved, nil + } + return ch, nil + } + driver := opts.RemoteDriver + if driver == "" { + driver = DriverPostgres + } + dsn := strings.TrimSpace(opts.RemoteDSN) + if dsn == "" { + driver = DriverSQLite + dsn = filepath.ToSlash(filepath.Join(".", "data", "dbsync", fmt.Sprintf("tenant_%d_online.db", opts.TenantID))) + dsn = "file:" + dsn + "?_pragma=busy_timeout(5000)" + } + name := strings.TrimSpace(opts.Name) + if name == "" { + name = fmt.Sprintf("公司默认同步 #%d", opts.TenantID) + } + ch := Channel{ + TenantID: opts.TenantID, + Name: name, + Enabled: false, // 默认同步通道供 agent push 落点;不启本地 outbox 轮询 + Direction: DirLocalToRemote, + ConflictPolicy: PolicyLWWSource, + IsSystemDefault: true, + AgentID: opts.AgentID, + Local: Endpoint{ + Driver: DriverSQLite, + DSN: "file:./data/dbsync/local_placeholder.db?_pragma=busy_timeout(5000)", + Tables: nil, + }, + Remote: Endpoint{ + Driver: driver, + DSN: dsn, + Tables: nil, + }, + PKColumns: map[string]string{}, + } + if err := ValidateChannelConfig(&ch); err != nil { + return Channel{}, err + } + saved, err := s.SaveChannel(ch) + if err != nil { + return Channel{}, err + } + return saved, nil +} + +// ResolveOnlineDBID 默认 online_db_id:优先显式值,否则通道 id。 +func ResolveOnlineDBID(explicit, channelID string) string { + if s := strings.TrimSpace(explicit); s != "" { + return s + } + return strings.TrimSpace(channelID) +} diff --git a/platform/internal/dbsync/types.go b/platform/internal/dbsync/types.go index 059104d..ed886f5 100644 --- a/platform/internal/dbsync/types.go +++ b/platform/internal/dbsync/types.go @@ -51,6 +51,8 @@ type Channel struct { // AgentID / AppSlug:把通道挂到某个智能体及其模块,便于「模块数据进该智能体库」对照。 AgentID int64 `json:"agent_id,omitempty"` AppSlug string `json:"app_slug,omitempty"` + // IsSystemDefault:公司默认同步通道(Z12);表白名单可空(Z4 整库 push) + IsSystemDefault bool `json:"is_system_default,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` LastError string `json:"last_error,omitempty"` diff --git a/platform/internal/dbsync/validate.go b/platform/internal/dbsync/validate.go index e70e09a..4348977 100644 --- a/platform/internal/dbsync/validate.go +++ b/platform/internal/dbsync/validate.go @@ -14,6 +14,9 @@ func ValidateChannelConfig(ch *Channel) error { } tables := uniqueTables(ch.Local.Tables, ch.Remote.Tables) if len(tables) == 0 { + if ch.IsSystemDefault { + return nil // Z12 默认同步通道:不强制表白名单(Z4 整库) + } return fmt.Errorf("同步表白名单为空:请至少在 local 或 remote 填写表名") } for _, t := range tables { diff --git a/platform/internal/handler/ensure_import.go b/platform/internal/handler/ensure_import.go new file mode 100644 index 0000000..8bbe66b --- /dev/null +++ b/platform/internal/handler/ensure_import.go @@ -0,0 +1,48 @@ +package handler + +import ( + "net/http" + "strings" + + "aijianzhan/platform/internal/audit" + "aijianzhan/platform/internal/authx" + "aijianzhan/platform/internal/meta" + "aijianzhan/platform/internal/svc" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +// platformEnsureImportHandler Z9e:超管扫全库已发布模块补齐 import/export。 +// POST /api/v1/platform/apps/ensure-import?dry_run=1 +func platformEnsureImportHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + dry := r.URL.Query().Get("dry_run") == "1" || strings.EqualFold(r.URL.Query().Get("dry_run"), "true") + res, err := meta.BackfillDefaultImportExport(r.Context(), svcCtx.Meta, dry) + if err != nil { + authx.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + if svcCtx.Audit != nil { + _ = svcCtx.Audit.Log(r.Context(), 0, authx.UserID(r.Context()), "apps.ensure_import", audit.DetailJSON(res)) + } + httpx.OkJson(w, res) + } +} + +// adminEnsureImportHandler Z9e:租户管理员对本公司已发布模块一键开启导入。 +// POST /api/v1/admin/apps/ensure-import?dry_run=1 +func adminEnsureImportHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + dry := r.URL.Query().Get("dry_run") == "1" || strings.EqualFold(r.URL.Query().Get("dry_run"), "true") + tid := authx.TenantID(r.Context()) + res, err := meta.BackfillDefaultImportExportForTenant(r.Context(), svcCtx.Meta, tid, dry) + if err != nil { + authx.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + if svcCtx.Audit != nil { + _ = svcCtx.Audit.Log(r.Context(), tid, authx.UserID(r.Context()), "apps.ensure_import", audit.DetailJSON(res)) + } + httpx.OkJson(w, res) + } +} diff --git a/platform/internal/handler/routes.go b/platform/internal/handler/routes.go index b32072a..1df473d 100644 --- a/platform/internal/handler/routes.go +++ b/platform/internal/handler/routes.go @@ -41,6 +41,9 @@ func RegisterHandlers(server *rest.Server, svcCtx *svc.ServiceContext) { server.AddRoutes([]rest.Route{ {Method: http.MethodPost, Path: "/api/v1/auth/token", Handler: rl(tokenHandler(svcCtx))}, {Method: http.MethodPost, Path: "/api/v1/auth/agent/register", Handler: rl(agentSelfRegisterHandler(svcCtx))}, + {Method: http.MethodPost, Path: "/api/v1/auth/bind-code/redeem", Handler: rl(bindCodeRedeemHandler(svcCtx))}, + {Method: http.MethodPost, Path: "/api/v1/auth/bind/phone-lookup", Handler: rl(bindPhoneLookupHandler(svcCtx))}, + {Method: http.MethodPost, Path: "/api/v1/auth/bind/phone-confirm", Handler: rl(bindPhoneConfirmHandler(svcCtx))}, {Method: http.MethodPost, Path: "/api/v1/auth/register", Handler: rl(registerHandler(svcCtx))}, {Method: http.MethodPost, Path: "/api/v1/auth/login", Handler: rl(loginHandler(svcCtx))}, {Method: http.MethodPost, Path: "/api/v1/auth/sms/send", Handler: rl(sendLoginSMSHandler(svcCtx))}, @@ -83,11 +86,13 @@ func RegisterHandlers(server *rest.Server, svcCtx *svc.ServiceContext) { {Method: http.MethodPut, Path: "/api/v1/platform/tenants/:id/permissions", Handler: chain(platformSetTenantPermsHandler(svcCtx), rl, authMW, platformAdmin)}, {Method: http.MethodGet, Path: "/api/v1/platform/dbsync/lww-overrides", Handler: chain(platformLwwOverridesHandler(svcCtx), rl, authMW, platformAdmin)}, {Method: http.MethodPost, Path: "/api/v1/platform/dbsync/lww-overrides/:id/rollback", Handler: chain(platformLwwRollbackHandler(svcCtx), rl, authMW, platformAdmin)}, + {Method: http.MethodPost, Path: "/api/v1/platform/apps/ensure-import", Handler: chain(platformEnsureImportHandler(svcCtx), rl, authMW, platformAdmin)}, }) // —— 鉴权:需已加入租户 —— server.AddRoutes([]rest.Route{ {Method: http.MethodGet, Path: "/api/v1/apps", Handler: chain(listAppsHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm读取模块))}, + {Method: http.MethodPost, Path: "/api/v1/admin/apps/ensure-import", Handler: chain(adminEnsureImportHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm发布模块))}, {Method: http.MethodPut, Path: "/api/v1/apps/:slug/draft", Handler: chain(saveDraftHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm写入模块), appGrant)}, {Method: http.MethodPost, Path: "/api/v1/apps/:slug/publish", Handler: chain(publishHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm发布模块), appGrant)}, {Method: http.MethodGet, Path: "/api/v1/apps/:slug/blueprint", Handler: chain(getBlueprintHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm读取模块), appGrant)}, @@ -100,6 +105,12 @@ func RegisterHandlers(server *rest.Server, svcCtx *svc.ServiceContext) { {Method: http.MethodPut, Path: "/api/v1/admin/agents/:id", Handler: chain(agentUpdateHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm管理智能体))}, {Method: http.MethodPost, Path: "/api/v1/admin/agents/:id/rotate-secret", Handler: chain(agentRotateHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm管理智能体))}, {Method: http.MethodDelete, Path: "/api/v1/admin/agents/:id", Handler: chain(agentDeleteHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm管理智能体))}, + // Z12b:智能体自查(无需管理智能体) + {Method: http.MethodGet, Path: "/api/v1/agents/me", Handler: chain(agentMeHandler(svcCtx), rl, authMW)}, + + {Method: http.MethodGet, Path: "/api/v1/admin/bind-codes", Handler: chain(bindCodeListHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm数据同步))}, + {Method: http.MethodPost, Path: "/api/v1/admin/bind-codes", Handler: chain(bindCodeCreateHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm数据同步))}, + {Method: http.MethodDelete, Path: "/api/v1/admin/bind-codes/:code", Handler: chain(bindCodeRevokeHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm数据同步))}, {Method: http.MethodGet, Path: "/api/v1/admin/roles", Handler: chain(roleListHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm管理智能体))}, {Method: http.MethodGet, Path: "/api/v1/admin/entitlements", Handler: chain(companyEntitlementsHandler(svcCtx), rl, authMW, tenant, perm(authx.Perm读取模块))}, @@ -284,6 +295,115 @@ func agentDeleteHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { } } +func agentMeHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + acc, err := applogic.NewAuthLogic(r.Context(), svcCtx).AgentMe() + if err != nil { + authx.WriteError(w, http.StatusForbidden, err.Error()) + return + } + httpx.OkJson(w, map[string]any{ + "agent_id": acc.AgentID, + "tenant_id": acc.TenantID, + "name": acc.Name, + "client_id": acc.ClientID, + "status": acc.Status, + "channel_id": acc.ChannelID, + "online_db_id": acc.OnlineDBID, + "database_name": acc.DatabaseName, + "sync_bound": strings.TrimSpace(acc.ChannelID) != "" && strings.TrimSpace(acc.OnlineDBID) != "", + "app_slugs": acc.AppSlugs, + "permissions": acc.Perms, + }) + } +} + +func bindCodeListHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + items, err := applogic.NewAuthLogic(r.Context(), svcCtx).ListBindCodes() + if err != nil { + authx.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + httpx.OkJson(w, map[string]any{"items": items}) + } +} + +func bindCodeCreateHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req applogic.BindCodeCreateReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err != io.EOF { + authx.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + bc, err := applogic.NewAuthLogic(r.Context(), svcCtx).CreateBindCode(req) + if err != nil { + authx.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + httpx.OkJson(w, bc) + } +} + +func bindCodeRevokeHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + code := pathvar.Vars(r)["code"] + if err := applogic.NewAuthLogic(r.Context(), svcCtx).RevokeBindCode(code); err != nil { + authx.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + httpx.OkJson(w, map[string]any{"ok": true}) + } +} + +func bindCodeRedeemHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req applogic.BindCodeRedeemReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + authx.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + resp, err := applogic.NewAuthLogic(r.Context(), svcCtx).RedeemBindCode(req) + if err != nil { + authx.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + httpx.OkJson(w, resp) + } +} + +func bindPhoneLookupHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req applogic.PhoneLookupReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + authx.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + resp, err := applogic.NewAuthLogic(r.Context(), svcCtx).PhoneLookup(req) + if err != nil { + authx.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + httpx.OkJson(w, resp) + } +} + +func bindPhoneConfirmHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req applogic.PhoneConfirmReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + authx.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + resp, err := applogic.NewAuthLogic(r.Context(), svcCtx).PhoneConfirm(req) + if err != nil { + authx.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + httpx.OkJson(w, resp) + } +} + func roleListHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { items, err := applogic.NewRoleAdminLogic(r.Context(), svcCtx).List() diff --git a/platform/internal/logic/applogic/agent_sync_bind.go b/platform/internal/logic/applogic/agent_sync_bind.go new file mode 100644 index 0000000..4b21f81 --- /dev/null +++ b/platform/internal/logic/applogic/agent_sync_bind.go @@ -0,0 +1,91 @@ +package applogic + +import ( + "fmt" + "strings" + + "aijianzhan/platform/internal/agentstore" + "aijianzhan/platform/internal/dbsync" + "aijianzhan/platform/internal/svc" + "aijianzhan/platform/internal/types" + "aijianzhan/platform/internal/userstore" +) + +// fillAgentSyncOnToken Z12a:换票带回同步落点。 +func fillAgentSyncOnToken(resp *types.TokenResp, acc *agentstore.Account) { + if resp == nil || acc == nil { + return + } + resp.ChannelID = strings.TrimSpace(acc.ChannelID) + resp.OnlineDBID = strings.TrimSpace(acc.OnlineDBID) + resp.DatabaseName = strings.TrimSpace(acc.DatabaseName) + resp.SyncBound = resp.ChannelID != "" && resp.OnlineDBID != "" +} + +// fillUserSyncOnToken:人类登录优先从本人 Binding 带回落点。 +func fillUserSyncOnToken(svcCtx *svc.ServiceContext, resp *types.TokenResp, u *userstore.User) { + if resp == nil || u == nil || svcCtx == nil || svcCtx.DBSync == nil || u.TenantID <= 0 { + return + } + list, err := svcCtx.DBSync.Store().ListBindingsFiltered(u.TenantID, u.UserID, "") + if err != nil || len(list) == 0 { + return + } + b := list[0] + for i := range list { + if list[i].ChannelID != "" && list[i].OnlineDBID != "" { + b = list[i] + break + } + } + resp.ChannelID = strings.TrimSpace(b.ChannelID) + resp.OnlineDBID = strings.TrimSpace(b.OnlineDBID) + resp.DatabaseName = strings.TrimSpace(b.DatabaseName) + if resp.DatabaseName == "" { + resp.DatabaseName = strings.TrimSpace(b.DisplayName) + } + resp.SyncBound = resp.ChannelID != "" && resp.OnlineDBID != "" +} + +// ensureAgentSyncBind Z12c:启用/创建时若无通道则自动创建公司默认同步通道并写回智能体。 +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 + } + ch, err := l.svcCtx.DBSync.Store().EnsureSystemDefaultChannel(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) + } + 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 == "") + 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 ebe4e84..539e144 100644 --- a/platform/internal/logic/applogic/agents.go +++ b/platform/internal/logic/applogic/agents.go @@ -111,6 +111,13 @@ func (l *AgentAdminLogic) Create(req *types.AgentCreateReq) (*types.AgentCreateR if err != nil { return nil, err } + if acc2, err := l.ensureAgentSyncBind(acc); err == nil && acc2 != nil { + acc = acc2 + } else if err != nil { + // 自动绑通道失败不阻断创建,但返回提示 + l.attachRole(acc) + return &types.AgentCreateResp{Account: *acc, ClientSecret: secret}, nil + } l.attachRole(acc) return &types.AgentCreateResp{Account: *acc, ClientSecret: secret}, nil } @@ -194,6 +201,12 @@ func (l *AgentAdminLogic) Update(agentID int64, req *types.AgentUpdateReq) (*age if err != nil { return nil, err } + // Z12c:启用为 active 且无通道时自动绑默认同步通道 + if acc.Status == agentstore.StatusActive { + if acc2, err := l.ensureAgentSyncBind(acc); err == nil && acc2 != nil { + acc = acc2 + } + } l.attachRole(acc) return acc, nil } @@ -239,7 +252,7 @@ func (l *AuthLogic) IssueClientCredentials(clientID, clientSecret string) (*type if secret == "" { secret = l.svcCtx.JWT.AccessSecret } - return &types.TokenResp{ + resp := &types.TokenResp{ AccessToken: token, TokenType: "Bearer", ExpiresAt: exp, @@ -252,5 +265,7 @@ func (l *AuthLogic) IssueClientCredentials(clientID, clientSecret string) (*type AgentID: acc.AgentID, Permissions: append([]string{}, acc.Perms...), AppSlugs: append([]string{}, acc.AppSlugs...), - }, nil + } + fillAgentSyncOnToken(resp, acc) + return resp, nil } diff --git a/platform/internal/logic/applogic/bind_flow.go b/platform/internal/logic/applogic/bind_flow.go new file mode 100644 index 0000000..0acf23d --- /dev/null +++ b/platform/internal/logic/applogic/bind_flow.go @@ -0,0 +1,357 @@ +package applogic + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "aijianzhan/platform/internal/agentstore" + "aijianzhan/platform/internal/authx" + "aijianzhan/platform/internal/bindcodestore" + "aijianzhan/platform/internal/dbsync" + "aijianzhan/platform/internal/userstore" +) + +// AgentMe Z12b:智能体自查绑定(无需「管理智能体」)。 +func (l *AuthLogic) AgentMe() (*agentstore.Account, error) { + if !authx.IsAgent(authx.Role(l.ctx)) { + return nil, fmt.Errorf("仅智能体可访问") + } + if l.svcCtx.Agents == nil { + return nil, fmt.Errorf("agent store unavailable") + } + tid := authx.TenantID(l.ctx) + aid := authx.UserID(l.ctx) + acc, err := l.svcCtx.Agents.Get(l.ctx, tid, aid) + if err != nil { + return nil, err + } + return acc, nil +} + +type BindCodeCreateReq struct { + ChannelID string `json:"channel_id"` + OnlineDBID string `json:"online_db_id"` + DatabaseName string `json:"database_name"` + MaxUses int `json:"max_uses"` + ExpiresHours int `json:"expires_hours"` // 0=168h + Note string `json:"note"` +} + +func (l *AuthLogic) CreateBindCode(req BindCodeCreateReq) (*bindcodestore.BindCode, error) { + if l.svcCtx.BindCodes == nil { + return nil, fmt.Errorf("bind code store unavailable") + } + tid := authx.TenantID(l.ctx) + if tid <= 0 { + return nil, fmt.Errorf("未加入公司") + } + channelID := strings.TrimSpace(req.ChannelID) + 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 + if online == "" { + online = dbsync.ResolveOnlineDBID("", 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.Store().EnsureSystemDefaultChannel(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) + } + } + } + if channelID == "" { + return nil, fmt.Errorf("请先启用智能体以生成默认同步通道,或指定 channel_id") + } + if online == "" { + online = dbsync.ResolveOnlineDBID("", channelID) + } + exp := time.Duration(req.ExpiresHours) * time.Hour + return l.svcCtx.BindCodes.Create(l.ctx, tid, authx.UserID(l.ctx), bindcodestore.CreateInput{ + ChannelID: channelID, + OnlineDBID: online, + DatabaseName: dbName, + MaxUses: req.MaxUses, + ExpiresIn: exp, + Note: req.Note, + }) +} + +func (l *AuthLogic) ListBindCodes() ([]bindcodestore.BindCode, error) { + if l.svcCtx.BindCodes == nil { + return nil, fmt.Errorf("bind code store unavailable") + } + tid := authx.TenantID(l.ctx) + return l.svcCtx.BindCodes.List(l.ctx, tid) +} + +func (l *AuthLogic) RevokeBindCode(code string) error { + if l.svcCtx.BindCodes == nil { + return fmt.Errorf("bind code store unavailable") + } + return l.svcCtx.BindCodes.Revoke(l.ctx, authx.TenantID(l.ctx), code) +} + +type BindCodeRedeemReq struct { + Code string `json:"code"` + HostKey string `json:"host_key"` + Name string `json:"name"` // 可选:无 agent 时注册用 +} + +type BindCodeRedeemResp struct { + OK bool `json:"ok"` + TenantID int64 `json:"tenant_id"` + AgentID int64 `json:"agent_id"` + ClientID string `json:"client_id,omitempty"` + ChannelID string `json:"channel_id"` + OnlineDBID string `json:"online_db_id"` + DatabaseName string `json:"database_name,omitempty"` + SyncBound bool `json:"sync_bound"` + Message string `json:"message,omitempty"` +} + +func (l *AuthLogic) RedeemBindCode(req BindCodeRedeemReq) (*BindCodeRedeemResp, error) { + if l.svcCtx.BindCodes == nil || l.svcCtx.Agents == nil { + return nil, fmt.Errorf("bind service unavailable") + } + code := strings.TrimSpace(req.Code) + hostKey := strings.TrimSpace(req.HostKey) + if code == "" || hostKey == "" { + return nil, fmt.Errorf("code and host_key required") + } + bc, err := l.svcCtx.BindCodes.Redeem(l.ctx, code) + if err != nil { + return nil, err + } + acc, err := l.svcCtx.Agents.FindByHostKey(l.ctx, hostKey) + if err != nil { + name := strings.TrimSpace(req.Name) + if name == "" { + name = "离线终端" + } + created, secret, _, regErr := l.svcCtx.Agents.Register(l.ctx, bc.TenantID, name, hostKey) + if regErr != nil { + return nil, fmt.Errorf("register agent: %w", regErr) + } + _ = secret + acc = created + } + online := strings.TrimSpace(bc.OnlineDBID) + if online == "" { + online = dbsync.ResolveOnlineDBID("", bc.ChannelID) + } + dbName := strings.TrimSpace(bc.DatabaseName) + if dbName == "" { + dbName = fmt.Sprintf("agent_%d", acc.AgentID) + } + updated, err := l.svcCtx.Agents.AttachSyncBind(l.ctx, acc.AgentID, bc.TenantID, bc.ChannelID, online, dbName, true) + if err != nil { + return nil, err + } + _ = l.writeBindAudit("bind_code_redeem", updated.TenantID, updated.AgentID, map[string]any{ + "code": bc.Code, "channel_id": bc.ChannelID, "online_db_id": online, + }) + return &BindCodeRedeemResp{ + OK: true, + TenantID: updated.TenantID, + AgentID: updated.AgentID, + ClientID: updated.ClientID, + ChannelID: updated.ChannelID, + OnlineDBID: updated.OnlineDBID, + DatabaseName: updated.DatabaseName, + SyncBound: true, + Message: "绑定成功", + }, nil +} + +type PhoneLookupReq struct { + Phone string `json:"phone"` +} + +type PhoneLookupResp struct { + Exists bool `json:"exists"` + TenantID int64 `json:"tenant_id,omitempty"` + TenantName string `json:"tenant_name,omitempty"` + MaskedName string `json:"masked_name,omitempty"` + NeedConfirm bool `json:"need_confirm"` + Message string `json:"message,omitempty"` +} + +func maskDisplayName(name string) string { + name = strings.TrimSpace(name) + if name == "" { + return "***" + } + r := []rune(name) + if len(r) == 1 { + return string(r[0]) + "*" + } + if len(r) == 2 { + return string(r[0]) + "*" + } + return string(r[0]) + strings.Repeat("*", len(r)-2) + string(r[len(r)-1]) +} + +func (l *AuthLogic) PhoneLookup(req PhoneLookupReq) (*PhoneLookupResp, error) { + if l.svcCtx.Users == nil { + return nil, fmt.Errorf("user store unavailable") + } + phone, err := userstore.NormalizePhone(req.Phone) + if err != nil { + return &PhoneLookupResp{Exists: false, Message: "手机号格式不正确"}, nil + } + u, err := l.svcCtx.Users.GetByPhone(l.ctx, phone) + if err != nil || u == nil { + return &PhoneLookupResp{Exists: false, NeedConfirm: false, Message: "无此成员;请使用绑定码或联系管理员"}, nil + } + if u.TenantID <= 0 { + return &PhoneLookupResp{Exists: true, NeedConfirm: false, Message: "该手机号账号尚未加入公司"}, nil + } + tenantName := "" + if t, err := l.svcCtx.Users.GetTenant(l.ctx, u.TenantID); err == nil && t != nil { + tenantName = t.Name + } + return &PhoneLookupResp{ + Exists: true, + TenantID: u.TenantID, + TenantName: tenantName, + MaskedName: maskDisplayName(u.DisplayName), + NeedConfirm: true, + Message: fmt.Sprintf("已找到账号「%s」所属「%s」,是否绑定到本机?", maskDisplayName(u.DisplayName), tenantName), + }, nil +} + +type PhoneConfirmReq struct { + Phone string `json:"phone"` + HostKey string `json:"host_key"` + Name string `json:"name"` + Confirm bool `json:"confirm"` // 必须 true + LocalDBID string `json:"local_database_id"` +} + +type PhoneConfirmResp struct { + OK bool `json:"ok"` + TenantID int64 `json:"tenant_id"` + AgentID int64 `json:"agent_id"` + ChannelID string `json:"channel_id"` + OnlineDBID string `json:"online_db_id"` + DatabaseName string `json:"database_name,omitempty"` + SyncBound bool `json:"sync_bound"` + Message string `json:"message,omitempty"` +} + +func (l *AuthLogic) PhoneConfirm(req PhoneConfirmReq) (*PhoneConfirmResp, error) { + if !req.Confirm { + return nil, fmt.Errorf("须明确确认绑定(confirm=true)") + } + if l.svcCtx.Users == nil || l.svcCtx.Agents == nil || l.svcCtx.DBSync == nil { + return nil, fmt.Errorf("bind service unavailable") + } + phone, err := userstore.NormalizePhone(req.Phone) + if err != nil { + return nil, fmt.Errorf("手机号格式不正确") + } + hostKey := strings.TrimSpace(req.HostKey) + if hostKey == "" { + return nil, fmt.Errorf("host_key required") + } + u, err := l.svcCtx.Users.GetByPhone(l.ctx, phone) + if err != nil || u == nil { + return nil, fmt.Errorf("无此成员;请使用绑定码") + } + if u.TenantID <= 0 { + return nil, fmt.Errorf("该账号尚未加入公司") + } + cfg := l.svcCtx.Config.DBSync + driver := dbsync.Driver(strings.TrimSpace(cfg.DefaultRemoteDriver)) + if driver == "" { + driver = dbsync.DriverPostgres + } + ch, err := l.svcCtx.DBSync.Store().EnsureSystemDefaultChannel(dbsync.DefaultChannelOpts{ + TenantID: u.TenantID, + RemoteDriver: driver, + RemoteDSN: strings.TrimSpace(cfg.DefaultRemoteDSN), + }) + if err != nil { + return nil, fmt.Errorf("ensure channel: %w", err) + } + acc, err := l.svcCtx.Agents.FindByHostKey(l.ctx, hostKey) + if err != nil { + name := strings.TrimSpace(req.Name) + if name == "" { + name = "离线终端 · " + maskDisplayName(u.DisplayName) + } + created, _, _, regErr := l.svcCtx.Agents.Register(l.ctx, u.TenantID, name, hostKey) + if regErr != nil { + return nil, regErr + } + acc = created + } + online := dbsync.ResolveOnlineDBID("", ch.ID) + // 个人落点:按用户隔离 online_db_id + online = fmt.Sprintf("%s_u%d", online, u.UserID) + dbName := fmt.Sprintf("%s", strings.TrimSpace(u.DisplayName)) + if dbName == "" { + dbName = fmt.Sprintf("user_%d", 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 + } + localID := strings.TrimSpace(req.LocalDBID) + if localID == "" { + localID = "host:" + hostKey + } + _, _ = l.svcCtx.DBSync.Store().EnsureBinding(dbsync.Binding{ + TenantID: u.TenantID, + UserID: u.UserID, + LocalDatabaseID: localID, + OnlineDBID: online, + ChannelID: ch.ID, + DatabaseName: dbName, + DisplayName: dbName, + Note: "phone-confirm", + }) + _ = l.writeBindAudit("phone_confirm_bind", u.TenantID, updated.AgentID, map[string]any{ + "phone": phone, "user_id": u.UserID, "channel_id": ch.ID, "online_db_id": online, + }) + return &PhoneConfirmResp{ + OK: true, + TenantID: updated.TenantID, + AgentID: updated.AgentID, + ChannelID: updated.ChannelID, + OnlineDBID: updated.OnlineDBID, + DatabaseName: updated.DatabaseName, + SyncBound: true, + Message: "绑定成功", + }, nil +} + +func (l *AuthLogic) writeBindAudit(action string, tenantID, agentID int64, detail map[string]any) error { + if l.svcCtx.Audit == nil { + return nil + } + if detail == nil { + detail = map[string]any{} + } + detail["agent_id"] = agentID + b, _ := json.Marshal(detail) + return l.svcCtx.Audit.Log(l.ctx, tenantID, authx.UserID(l.ctx), action, string(b)) +} diff --git a/platform/internal/logic/applogic/impex.go b/platform/internal/logic/applogic/impex.go index adf2abd..6fa47f5 100644 --- a/platform/internal/logic/applogic/impex.go +++ b/platform/internal/logic/applogic/impex.go @@ -3,6 +3,7 @@ package applogic import ( "bytes" "encoding/csv" + "errors" "fmt" "io" "path/filepath" @@ -13,6 +14,7 @@ import ( "aijianzhan/platform/internal/meta" "aijianzhan/platform/internal/types" + "github.com/lib/pq" "github.com/xuri/excelize/v2" ) @@ -24,8 +26,10 @@ func (l *CrudLogic) ImportRows(slug, resource, filename string, r io.Reader) (*t if err != nil { return nil, err } + // Z9f:读路径内存兜底(与 Z9e 扫库双保险) + ensureImportOpInMemory(ref) if !hasOp(ref, "import") { - return nil, fmt.Errorf("operation import not allowed") + return nil, fmt.Errorf("蓝图未开启 import(operations 缺 import)。请管理员执行「一键开启全部业务表导入」或再发布模块;与账号权限 row.import 无关") } ext := strings.ToLower(filepath.Ext(filename)) @@ -86,7 +90,11 @@ func (l *CrudLogic) ImportRows(slug, resource, filename string, r io.Reader) (*t if _, err := l.svcCtx.CRUD.Create(l.ctx, ref, tenantID, userID, body); err != nil { resp.Skipped++ if len(resp.Errors) < 100 { - resp.Errors = append(resp.Errors, fmt.Sprintf("row %d: %v", rowNum+1, err)) + msg := err.Error() + if isUndefinedColumnErr(err) { + msg = fmt.Sprintf("%v(蓝图字段与库表不一致,请重新发布模块以自动补列,或缩小 Excel 表头至库已有列)", err) + } + resp.Errors = append(resp.Errors, fmt.Sprintf("row %d: %s", rowNum+1, msg)) } continue } @@ -107,8 +115,9 @@ func (l *CrudLogic) ExportExcel(slug, resource string) ([]byte, string, error) { if err != nil { return nil, "", err } + ensureImportOpInMemory(ref) if !hasOp(ref, "export") { - return nil, "", fmt.Errorf("operation export not allowed") + return nil, "", fmt.Errorf("蓝图未开启 export(operations 缺 export)。请管理员执行「一键开启全部业务表导入」或再发布模块") } items, _, err := l.svcCtx.CRUD.List(l.ctx, ref, tenantID, 1, 5000, nil, "") if err != nil { @@ -147,8 +156,9 @@ func (l *CrudLogic) ExportCSV(slug, resource string) ([]byte, error) { if err != nil { return nil, err } + ensureImportOpInMemory(ref) if !hasOp(ref, "export") { - return nil, fmt.Errorf("operation export not allowed") + return nil, fmt.Errorf("蓝图未开启 export(operations 缺 export)。请管理员执行「一键开启全部业务表导入」或再发布模块") } items, _, err := l.svcCtx.CRUD.List(l.ctx, ref, tenantID, 1, 5000, nil, "") if err != nil { @@ -237,6 +247,9 @@ func rowEmpty(rec []string) bool { } func hasOp(ref *meta.ResourceRef, op string) bool { + if ref == nil { + return false + } for _, o := range ref.Resource.Operations { if o == op { return true @@ -245,6 +258,37 @@ func hasOp(ref *meta.ResourceRef, op string) bool { return false } +// ensureImportOpInMemory Z9f:对已加载蓝图做与 EnsureDefaultImportExport 相同的内存补齐,并刷新 ref.Resource。 +func ensureImportOpInMemory(ref *meta.ResourceRef) { + if ref == nil || ref.App == nil || ref.App.Blueprint == nil { + return + } + ref.App.Blueprint.EnsureDefaultImportExport() + want := strings.TrimSpace(ref.Resource.Path) + if len(want) > 0 && want[0] == '/' { + want = want[1:] + } + for _, r := range ref.App.Blueprint.Apis.Resources { + path := r.Path + if len(path) > 0 && path[0] == '/' { + path = path[1:] + } + if path == want { + ref.Resource = r + return + } + } +} + +func isUndefinedColumnErr(err error) bool { + var pqErr *pq.Error + if errors.As(err, &pqErr) && pqErr.Code == "42703" { + return true + } + msg := err.Error() + return strings.Contains(msg, "42703") || strings.Contains(msg, "字段不存在") || strings.Contains(msg, "does not exist") +} + func mapHeaderToField(ref *meta.ResourceRef, header string) string { h := strings.TrimSpace(header) if h == "" { diff --git a/platform/internal/logic/applogic/publish.go b/platform/internal/logic/applogic/publish.go index c3ecc0a..77227d2 100644 --- a/platform/internal/logic/applogic/publish.go +++ b/platform/internal/logic/applogic/publish.go @@ -347,6 +347,26 @@ func (l *PublishLogic) Publish(slug string, req *types.PublishReq) (*types.Publi } } + // Z11a:蓝图新增字段 → ALTER TABLE ADD COLUMN IF NOT EXISTS + inspectDB := appDB + if inspectDB == nil { + inspectDB = l.svcCtx.DB + } + if inspectDB != nil { + alter, err := schema.BuildPostgresAlterDDL(l.ctx, inspectDB, bp) + if err != nil { + rec.Status = meta.StatusFailed + rec.Error = err.Error() + _ = l.svcCtx.Meta.Save(l.ctx, rec) + return nil, fmt.Errorf("build alter ddl: %w", err) + } + if len(alter) > 0 { + ddl = schema.MergeDDL(ddl, alter) + rec.DDL = ddl + _ = l.svcCtx.Meta.Save(l.ctx, rec) + } + } + if err := runner.ExecDDL(l.ctx, ddl); err != nil { rec.Status = meta.StatusFailed rec.Error = err.Error() diff --git a/platform/internal/logic/applogic/tenant_invite.go b/platform/internal/logic/applogic/tenant_invite.go index ceab2cc..ed0a764 100644 --- a/platform/internal/logic/applogic/tenant_invite.go +++ b/platform/internal/logic/applogic/tenant_invite.go @@ -28,6 +28,7 @@ func (l *AuthLogic) issueUser(u *userstore.User) (*types.TokenResp, error) { resp.Status = userstore.StatusPending } } + fillUserSyncOnToken(l.svcCtx, resp, u) if u.IsPlatformAdmin() { resp.Message = "平台超级管理员工作台:管理全部公司;打开某公司可查看其内部功能" return resp, nil diff --git a/platform/internal/meta/backfill_import.go b/platform/internal/meta/backfill_import.go new file mode 100644 index 0000000..e413d67 --- /dev/null +++ b/platform/internal/meta/backfill_import.go @@ -0,0 +1,85 @@ +package meta + +import ( + "context" + "encoding/json" + "fmt" + "log" +) + +// BackfillImportResult Z9e 扫库补齐 import/export 的结果。 +type BackfillImportResult struct { + Scanned int `json:"scanned"` + Updated int `json:"updated"` + Skipped int `json:"skipped"` + DryRun bool `json:"dry_run"` + Slugs []string `json:"updated_slugs,omitempty"` + Message string `json:"message,omitempty"` +} + +// PublishedLister 列出全部已发布应用(跨租户,供 Z9e 启动扫库)。 +type PublishedLister interface { + ListPublishedApps(ctx context.Context) ([]*AppRecord, error) +} + +// BackfillDefaultImportExport 遍历已发布蓝图,对可写 resource 补齐 import/export 并落库(Z9e)。 +// dryRun=true 只统计不写库。tenantID>0 时仅处理该租户。 +func BackfillDefaultImportExport(ctx context.Context, store Store, dryRun bool) (*BackfillImportResult, error) { + return BackfillDefaultImportExportForTenant(ctx, store, 0, dryRun) +} + +// BackfillDefaultImportExportForTenant 同 BackfillDefaultImportExport;tenantID=0 表示全库。 +func BackfillDefaultImportExportForTenant(ctx context.Context, store Store, tenantID int64, dryRun bool) (*BackfillImportResult, error) { + if store == nil { + return nil, fmt.Errorf("meta store nil") + } + lister, ok := store.(PublishedLister) + if !ok { + return nil, fmt.Errorf("meta store does not support ListPublishedApps") + } + apps, err := lister.ListPublishedApps(ctx) + if err != nil { + return nil, err + } + out := &BackfillImportResult{DryRun: dryRun, Slugs: make([]string, 0)} + for _, app := range apps { + if app == nil || app.Blueprint == nil { + continue + } + if tenantID > 0 && app.TenantID != tenantID { + continue + } + out.Scanned++ + before, _ := json.Marshal(app.Blueprint) + app.Blueprint.EnsureDefaultImportExport() + after, _ := json.Marshal(app.Blueprint) + if string(before) == string(after) { + out.Skipped++ + continue + } + out.Updated++ + out.Slugs = append(out.Slugs, fmt.Sprintf("%d/%s", app.TenantID, app.Slug)) + if dryRun { + continue + } + if err := store.Save(ctx, app); err != nil { + return out, fmt.Errorf("save %s: %w", app.Slug, err) + } + } + out.Message = fmt.Sprintf("scanned=%d updated=%d skipped=%d dry_run=%v", out.Scanned, out.Updated, out.Skipped, dryRun) + return out, nil +} + +// RunBackfillDefaultImportExportOnBoot 启动时扫库(失败只打日志,不阻断启动)。 +func RunBackfillDefaultImportExportOnBoot(ctx context.Context, store Store) { + res, err := BackfillDefaultImportExport(ctx, store, false) + if err != nil { + log.Printf("Z9e backfill import ops: %v", err) + return + } + if res != nil && res.Updated > 0 { + log.Printf("Z9e backfill import ops: %s slugs=%v", res.Message, res.Slugs) + } else if res != nil { + log.Printf("Z9e backfill import ops: %s", res.Message) + } +} diff --git a/platform/internal/meta/backfill_import_test.go b/platform/internal/meta/backfill_import_test.go new file mode 100644 index 0000000..c17836d --- /dev/null +++ b/platform/internal/meta/backfill_import_test.go @@ -0,0 +1,59 @@ +package meta + +import ( + "context" + "testing" + + "aijianzhan/platform/internal/blueprint" +) + +func TestBackfillDefaultImportExport(t *testing.T) { + store := NewMemoryStore() + bp := &blueprint.Blueprint{} + bp.Meta.Name = "coerce" + bp.Meta.Slug = "coerce_fields" + bp.Apis.Resources = []blueprint.APIResource{{ + Path: "items", + Entity: "item", + Operations: []string{"list", "get", "create", "update", "delete"}, + }} + bp.Entities = []blueprint.Entity{{Name: "item", Table: "item", PrimaryKey: "id"}} + bp.Pages = []blueprint.Page{{ID: "list1", Type: "list", Layout: &blueprint.PageLayout{Actions: []string{"create", "refresh"}}}} + _ = store.Save(context.Background(), &AppRecord{ + AppID: "a1", TenantID: 1, Slug: "coerce_fields", Name: "coerce", + Status: StatusPublished, Blueprint: bp, + }) + + dry, err := BackfillDefaultImportExport(context.Background(), store, true) + if err != nil { + t.Fatal(err) + } + if dry.Updated != 1 { + t.Fatalf("dry updated=%d", dry.Updated) + } + app, _ := store.GetBySlug(context.Background(), 1, "coerce_fields") + if hasImport(app.Blueprint.Apis.Resources[0].Operations) { + t.Fatal("dry run should not persist") + } + + res, err := BackfillDefaultImportExport(context.Background(), store, false) + if err != nil { + t.Fatal(err) + } + if res.Updated != 1 { + t.Fatalf("updated=%d", res.Updated) + } + app, _ = store.GetBySlug(context.Background(), 1, "coerce_fields") + if !hasImport(app.Blueprint.Apis.Resources[0].Operations) { + t.Fatalf("ops=%v", app.Blueprint.Apis.Resources[0].Operations) + } +} + +func hasImport(ops []string) bool { + for _, op := range ops { + if op == "import" { + return true + } + } + return false +} diff --git a/platform/internal/meta/postgres.go b/platform/internal/meta/postgres.go index 6e9515e..24db0c4 100644 --- a/platform/internal/meta/postgres.go +++ b/platform/internal/meta/postgres.go @@ -75,6 +75,29 @@ ORDER BY updated_at DESC` return out, rows.Err() } +func (s *PostgresStore) ListPublishedApps(ctx context.Context) ([]*AppRecord, error) { + const q = ` +SELECT app_id, tenant_id, slug, name, schema_name, COALESCE(database_name,''), engine, status, + blueprint_json, ddl_json, endpoints_json, error_msg, created_at, updated_at +FROM platform_meta.tenant_apps +WHERE status = $1 +ORDER BY tenant_id, slug` + rows, err := s.DB.QueryContext(ctx, q, string(StatusPublished)) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]*AppRecord, 0) + for rows.Next() { + rec, err := scanApp(rows) + if err != nil { + return nil, err + } + out = append(out, rec) + } + return out, rows.Err() +} + func (s *PostgresStore) Save(ctx context.Context, app *AppRecord) error { if app == nil { return fmt.Errorf("app is nil") diff --git a/platform/internal/meta/store.go b/platform/internal/meta/store.go index feca696..797557e 100644 --- a/platform/internal/meta/store.go +++ b/platform/internal/meta/store.go @@ -140,6 +140,19 @@ func (s *MemoryStore) ListByTenant(_ context.Context, tenantID int64) ([]AppSumm return out, nil } +func (s *MemoryStore) ListPublishedApps(_ context.Context) ([]*AppRecord, error) { + s.mu.RLock() + defer s.mu.RUnlock() + out := make([]*AppRecord, 0) + for _, app := range s.apps { + if app == nil || app.Status != StatusPublished { + continue + } + out = append(out, cloneApp(app)) + } + return out, nil +} + func summarizeApp(app *AppRecord) AppSummary { sum := AppSummary{ AppID: app.AppID, diff --git a/platform/internal/schema/alter.go b/platform/internal/schema/alter.go new file mode 100644 index 0000000..db19705 --- /dev/null +++ b/platform/internal/schema/alter.go @@ -0,0 +1,92 @@ +package schema + +import ( + "context" + "database/sql" + "fmt" + "strings" + + "aijianzhan/platform/internal/blueprint" +) + +// BuildPostgresAlterDDL 对比蓝图与已有表,生成 ADD COLUMN IF NOT EXISTS(Z11a)。 +// db 可为 nil:则只返回空(调用方应先 CREATE TABLE)。 +func BuildPostgresAlterDDL(ctx context.Context, db *sql.DB, bp *blueprint.Blueprint) ([]string, error) { + if bp == nil || bp.Storage.SchemaName == "" { + return nil, fmt.Errorf("schema_name empty") + } + if db == nil { + return nil, nil + } + schemaName := bp.Storage.SchemaName + var stmts []string + for _, e := range bp.Entities { + existing, err := listTableColumns(ctx, db, schemaName, e.Table) + if err != nil { + return nil, err + } + if existing == nil { + // 表尚不存在:由 CREATE TABLE IF NOT EXISTS 处理 + continue + } + for _, f := range e.Fields { + if _, ok := existing[f.Name]; ok { + continue + } + sqlType, err := mapType(f) + if err != nil { + return nil, err + } + // 存量表补列一律可空,避免非空约束导致迁库失败 + stmts = append(stmts, fmt.Sprintf( + `ALTER TABLE %s.%s ADD COLUMN IF NOT EXISTS %s %s`, + quoteIdent(schemaName), quoteIdent(e.Table), quoteIdent(f.Name), sqlType, + )) + } + } + return stmts, nil +} + +func listTableColumns(ctx context.Context, db *sql.DB, schemaName, table string) (map[string]struct{}, error) { + const q = ` +SELECT column_name FROM information_schema.columns +WHERE table_schema = $1 AND table_name = $2` + rows, err := db.QueryContext(ctx, q, schemaName, table) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[string]struct{}{} + found := false + for rows.Next() { + found = true + var name string + if err := rows.Scan(&name); err != nil { + return nil, err + } + out[name] = struct{}{} + } + if err := rows.Err(); err != nil { + return nil, err + } + if !found { + return nil, nil + } + return out, nil +} + +// MergeDDL 将 alter 语句追加到 create ddl 之后。 +func MergeDDL(create, alter []string) []string { + if len(alter) == 0 { + return create + } + out := make([]string, 0, len(create)+len(alter)) + out = append(out, create...) + out = append(out, alter...) + return out +} + +// Quote for tests +func normalizeSchemaTable(s string) string { + return strings.TrimSpace(s) +} diff --git a/platform/internal/svc/servicecontext.go b/platform/internal/svc/servicecontext.go index 156e0b4..b5a1662 100644 --- a/platform/internal/svc/servicecontext.go +++ b/platform/internal/svc/servicecontext.go @@ -11,6 +11,7 @@ import ( "aijianzhan/platform/internal/agentstore" "aijianzhan/platform/internal/audit" "aijianzhan/platform/internal/authx" + "aijianzhan/platform/internal/bindcodestore" "aijianzhan/platform/internal/config" "aijianzhan/platform/internal/crud" "aijianzhan/platform/internal/dbsync" @@ -47,6 +48,7 @@ type ServiceContext struct { DB *sql.DB JWT authx.JWTConfig DBSync *dbsync.Manager + BindCodes bindcodestore.Store TenantPerm tenantperm.Store SMS *smsstore.Store License *license.Manager @@ -160,6 +162,12 @@ func NewServiceContext(c config.Config) *ServiceContext { return ok } } + // Z9e:存量已发布模块补齐 import/export(不依赖用户再发布) + if ctx.Meta != nil && !ctx.MemoryMode { + bfCtx, bfCancel := context.WithTimeout(context.Background(), 30*time.Second) + meta.RunBackfillDefaultImportExportOnBoot(bfCtx, ctx.Meta) + bfCancel() + } if c.DryRun { ctx.Schema = schema.NoopRunner{} } @@ -176,6 +184,11 @@ func NewServiceContext(c config.Config) *ServiceContext { } else { ctx.DBSync = dbsync.NewManager(store) log.Printf("dbsync middleware enabled (dir=%s)", dir) + if bc, err := bindcodestore.NewFileStore(dir); err != nil { + log.Printf("bind code store: %v", err) + } else { + ctx.BindCodes = bc + } } } diff --git a/platform/internal/types/types.go b/platform/internal/types/types.go index bba033e..5e424f7 100644 --- a/platform/internal/types/types.go +++ b/platform/internal/types/types.go @@ -103,6 +103,11 @@ type TokenResp struct { AppSlugs []string `json:"app_slugs,omitempty"` Message string `json:"message,omitempty"` TenantName string `json:"tenant_name,omitempty"` // 超管打开某公司管理视图时带回 + // Z12a:同步落点(智能体/已绑用户) + ChannelID string `json:"channel_id,omitempty"` + OnlineDBID string `json:"online_db_id,omitempty"` + DatabaseName string `json:"database_name,omitempty"` + SyncBound bool `json:"sync_bound"` } type AgentCreateReq struct { diff --git a/web/src/ModulesPage.tsx b/web/src/ModulesPage.tsx index ba07229..8760f02 100644 --- a/web/src/ModulesPage.tsx +++ b/web/src/ModulesPage.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; -import { Button, Card, Space, Table, Tag, Typography, message } from "antd"; -import { ReloadOutlined } from "@ant-design/icons"; -import { Session, listApps } from "./api"; +import { App as AntApp, Button, Card, Space, Table, Tag, Typography, message } from "antd"; +import { ReloadOutlined, ImportOutlined } from "@ant-design/icons"; +import { Session, ensureAppsImport, listApps } from "./api"; type Mod = { app_id: string; @@ -21,9 +21,11 @@ export function ModulesPage(props: { onContinueDraft: (slug: string) => void; }) { const { session, onOpenPublished, onContinueDraft } = props; + const { modal } = AntApp.useApp(); const [items, setItems] = useState([]); const [scope, setScope] = useState(""); const [loading, setLoading] = useState(false); + const [ensuring, setEnsuring] = useState(false); async function refresh() { setLoading(true); @@ -38,6 +40,27 @@ export function ModulesPage(props: { } } + function onEnsureImport() { + modal.confirm({ + title: "一键开启全部业务表导入?", + content: "将为本公司所有已发布模块补齐蓝图 import/export(无需逐个再发布)。只读表不会改动。", + okText: "开启", + onOk: async () => { + setEnsuring(true); + try { + const res = await ensureAppsImport(session, false); + message.success(res.message || `已更新 ${res.updated} 个模块`); + await refresh(); + } catch (e: any) { + message.error(e.message || String(e)); + throw e; + } finally { + setEnsuring(false); + } + }, + }); + } + useEffect(() => { refresh(); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -54,15 +77,27 @@ export function ModulesPage(props: { } onClick={refresh} loading={loading}> - 刷新 - + + + + } > 管理账号(如 owner)可查看本租户全部模块,含在建与已发布。 智能体默认不限制模块(可自由发布自建 slug);仅当配置了白名单时才受限。 - {scope === "all" ? " 当前范围:管理全部。" : scope === "open" ? " 当前范围:智能体开放。" : scope === "granted" ? " 当前范围:白名单。" : null} + 「一键开启导入」修复旧模块因蓝图缺 import 导致的导入 400(Z9e)。 + {scope === "all" + ? " 当前范围:管理全部。" + : scope === "open" + ? " 当前范围:智能体开放。" + : scope === "granted" + ? " 当前范围:白名单。" + : null} ( + + {id} + + ), + }, { title: "名称", dataIndex: "name" }, { title: "方向", @@ -448,12 +458,24 @@ export function SyncPage(props: { title: "智能体/模块", render: (_: unknown, r: SyncChannel) => { const ag = agents.find((a) => a.agent_id === r.agent_id); + const app = appOptions.find((o) => o.value === r.app_slug); + const moduleLabel = r.app_slug + ? app + ? `${(app.label.split(" (")[0] || app.label).trim()}` + : r.app_slug + : "未绑模块"; return ( {ag ? ag.name : r.agent_id ? `#${r.agent_id}` : "—"}
- {r.app_slug || "未绑模块"} + {moduleLabel} + {r.app_slug && app ? ( + <> +
+ {r.app_slug} + + ) : null}
); @@ -640,13 +662,15 @@ export function SyncPage(props: { ); } - const tables = (ch.remote?.tables || []).join(", ") || "(按 Binding 任意表)"; + const tables = (ch.remote?.tables || []).join(", "); return ( {ch.name || id}
- 通道表白名单配置:{tables} + {tables + ? `策略表(参考,非整库限制):${tables}` + : "整库同步中(Binding 任意表可 push)"}
); @@ -869,9 +893,9 @@ export function SyncPage(props: { @@ -901,9 +925,9 @@ export function SyncPage(props: { diff --git a/web/src/api.ts b/web/src/api.ts index 2275902..fc2c660 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -296,6 +296,25 @@ export async function listApps(session: Session) { }; } +/** Z9e:为本租户已发布模块补齐 import/export(一键开启导入) */ +export async function ensureAppsImport(session: Session, dryRun = false) { + const q = dryRun ? "?dry_run=1" : ""; + const res = await apiFetch(`${PLATFORM}/api/v1/admin/apps/ensure-import${q}`, { + method: "POST", + headers: { Authorization: `Bearer ${session.accessToken}` }, + }); + const data = await readJson(res); + throwIfBad(res, data, "一键开启导入失败"); + return data as { + scanned: number; + updated: number; + skipped: number; + dry_run: boolean; + updated_slugs?: string[]; + message?: string; + }; +} + export async function saveDraft(session: Session, slug: string, blueprint: any) { const res = await apiFetch(`${PLATFORM}/api/v1/apps/${slug}/draft`, { method: "PUT", diff --git a/宇恒-松离线数据同步使用文档.md b/宇恒-松离线数据同步使用文档.md index 123a756..1a9f1fb 100644 --- a/宇恒-松离线数据同步使用文档.md +++ b/宇恒-松离线数据同步使用文档.md @@ -2,14 +2,15 @@ > 面向:**宇恒一号客户端**对接同学 > 平台侧仓库:智建(ai建站) -> 依据:`松离线-dbsync方案-最终版.md`(冻结) -> 本地测试基址示例:`http://127.0.0.1:8180`;生产示例:`https://aisite.yuxindazhineng.com` +> 依据:`松离线-dbsync方案-最终版.md`(冻结)、`联调后修改意见-宇恒松离线.md`(Z12/Z13 产品路径) +> 本地测试基址示例:`http://127.0.0.1:8180`;生产示例:`https://aisite.yuxindazhineng.com` +> **生产·宇信达联调登录**:手机号 `13531041944`(公司侧专用;勿用超管号 `13531041945` / 演示号 `13800000001`) --- ## 1. 你要做什么(一句话) -未开通用户**零改动**。仅当显式 `YXD_SYNC_MODE=local_dbsync` 且表在白名单时:业务写进本机正式库 B(UUID)→ 写 outbox → **本机 agent** 调智建 push API → 落到线上库 A。agent 停了也**不能挡保存**。 +未开通用户**零改动**。仅当显式 `YXD_SYNC_MODE=local_dbsync`(或库级选「同步」)且走白名单/Binding 时:业务写进本机正式库 B(UUID)→ 写 outbox → **本机 agent** 调智建 push API → 落到线上库 A。agent 停了也**不能挡保存**。 ```text 业务保存 ──► 本机 B(最终 UUID,立刻可见) @@ -21,6 +22,10 @@ 智建 POST .../push ──► 线上 A ``` +**生产开通(推荐,Z12/Z13)**:不要引导用户手抄 `channel_id`。 +后台启用智能体 / 发绑定码 / 同号确认 → 换票带 `sync_bound` + 落点 → 本机库选「同步」即可用。 +手建通道 + `YXD_SYNC_CHANNEL_ID` 仅作**运维高级 / 联调过渡**。 + --- ## 2. 硬约束(违反即不合入) @@ -28,7 +33,7 @@ | # | 要求 | |---|------| | H1 | 未开通:保存 / 自增表 / 插件与现网 **行为 diff = 0** | -| H2 | **禁止**自动升为 `local_dbsync`(仅显式配置) | +| H2 | **禁止**自动升为 `local_dbsync`(仅显式配置或用户库级选「同步」) | | H3 | agent 停运 / 推送失败 → **保存仍成功**;心跳只驱动 UI | | H4 | 默认建表仍自增;仅「同步表」用 UUID TEXT PK | | H5 | 旧 `online_primary` 客户禁止静默关双写 | @@ -37,7 +42,7 @@ 开通判定(写路径): ```text -mode == local_dbsync AND 表白名单缓存命中该表 +mode == local_dbsync AND (表白名单缓存命中 或 Binding 整库策略) → 走松离线旁路 否则 → 原写路径 ``` @@ -61,18 +66,21 @@ mode == local_dbsync AND 表白名单缓存命中该表 YXD_SYNC_MODE=local_only ``` -开通增值: +开通增值(**过渡 / 运维**仍可用 env;生产优先换票落点): ```bash YXD_SYNC_MODE=local_dbsync YXD_SYNC_AGENT=1 YXD_ONLINE_API_BASE=http://127.0.0.1:8180 -YXD_SYNC_CHANNEL_ID=<智建控制台通道 ID> +# 过渡:可手填;生产应优先用换票/agents/me 的 channel_id +YXD_SYNC_CHANNEL_ID=<可选过渡缓存> YXD_SYNC_ACCESS_TOKEN= # 可选 YXD_SYNC_WHITELIST_TTL_SEC=600 YXD_SYNC_AGENT_INTERVAL_SEC=5 YXD_SYNC_WHITELIST_CACHE=cache/db_sync/whitelist.json +# 同号绑定联调(生产宇信达) +YXD_SYNC_LOGIN_PHONE=13531041944 ``` 调试兜底表白名单(通道拉取失败前): @@ -85,18 +93,131 @@ YXD_SYNC_DBSYNC_TABLES=orders,order_items --- +## 3.1 生产绑定与落点(Z12 / Z13 · 必读) + +### 产品约定 + +1. **一个登录账号 / 一个智能体 ↔ 本公司同步落点**;个人库默认隔离,禁止 A 数据进 B 库。 +2. **禁止**把「手抄通道 ID / 先去控制台新建通道」当作普通用户开通主路径。 +3. 公司默认同步通道由智建在**启用智能体**时自动创建(`is_system_default`);控制台「数据同步」留给运维改 DSN。 +4. 库选「同步」→ 自动 Binding + drain(宇恒 Z13e);全程零手填 DSN/通道。 + +### 换票带回绑定(Z12a) + +`POST /api/v1/auth/token`(client_credentials)或登录响应可含: + +| 字段 | 说明 | +|------|------| +| `channel_id` | 已绑则非空 | +| `online_db_id` | 已绑则非空 | +| `database_name` | 可读落库名 | +| `sync_bound` | `true` = 已绑落点;`false` = 须走绑定流程 | + +通道解析顺序建议:`env 过渡缓存` → 换票/`agents/me` → Binding → 租户仅 1 条通道自动选用。 + +### 智能体自查(Z12b) + +```http +GET /api/v1/agents/me +Authorization: Bearer <智能体 Token> +``` + +返回 `channel_id` / `online_db_id` / `database_name` / `sync_bound` / `status`(无需「管理智能体」权限)。 + +### 绑定码(Z13a/b) + +管理员(需「数据同步」): + +```http +POST /api/v1/admin/bind-codes +Authorization: Bearer <管理员 JWT> +Content-Type: application/json + +{ "max_uses": 1, "expires_hours": 168, "note": "开通单" } +``` + +```http +GET /api/v1/admin/bind-codes +DELETE /api/v1/admin/bind-codes/{code} +``` + +终端兑换(公开): + +```http +POST /api/v1/auth/bind-code/redeem +Content-Type: application/json + +{ "code": "A1B2C3D4", "host_key": "<本机稳定宿主机 key>", "name": "可选显示名" } +``` + +成功 → 智能体挂到该公司 + 默认同步落点,`sync_bound=true`。 + +### 同号探测 / 确认(Z13c / Z13c-1 · 硬约束) + +**禁止静默绑定**。有手机号时必须先 lookup,命中后**弹窗确认**再 confirm。 + +```http +POST /api/v1/auth/bind/phone-lookup +Content-Type: application/json + +{ "phone": "13531041944" } +``` + +响应要点:`exists`、`tenant_name`、`masked_name`、`need_confirm`、`message`(**不**执行绑定)。 + +```http +POST /api/v1/auth/bind/phone-confirm +Content-Type: application/json + +{ + "phone": "13531041944", + "host_key": "<本机 host_key>", + "confirm": true, + "local_database_id": "可选,本机库 ID" +} +``` + +| 场景 | 行为 | +|------|------| +| 同号命中公司成员 | **必须询问**「已有账号是否绑定」;确认才 `phone-confirm` | +| 用户取消 | 不绑定;可改走绑定码 / 换号 | +| 无此成员 | 提示用绑定码;**勿**用超管号 `13531041945` 测 | +| 生产联调样例 | 智建与宇恒均为 **`13531041944`** | + +### 未绑定时客户端流程(Z13d · 宇恒待接) + +```text +换票 / agents/me → sync_bound=false + ├─ 有手机号 → phone-lookup + │ ├─ need_confirm → 【弹窗】确认?→ phone-confirm / 取消 + │ └─ 未命中 → 绑定码或换号表单 + └─ 无手机号 → 绑定码表单 +绑定成功 → 库选「同步」→ Binding + drain(零通道配置) +``` + +--- + ## 4. 智建侧前置(公司管理员) +### 生产推荐(Z12/Z13) + +1. 公司级配置默认同步 DSN(`DBSync.DefaultRemoteDSN`,生产 Postgres)。 +2. **启用智能体**(勿要求用户先「新建通道」)→ 平台自动建默认同步通道并写回智能体落点。 +3. (可选)生成**绑定码**发给终端;或引导用户用公司成员手机号做同号确认(须弹窗)。 +4. 运维需要时再在「数据同步」改 remote DSN / 查看通道 ID(可复制)。 + +### 运维高级 / 联调过渡(手建通道) + 1. 登录智建控制台 → **数据同步** → 新建通道。 2. **生产**:`remote.driver=postgres`,DSN 例:`postgres://user:pass@host:5432/db?sslmode=disable`(须可达)。 -3. 表白名单须 **UUID TEXT/UUID PK** + **外键闭包**。 +3. 表白名单可空(整库 Binding 策略);若填表须 **UUID TEXT/UUID PK** + **外键闭包**。 4. 方向推荐 **本地 → 线上**;冲突策略推荐 **源端覆盖(lww_source)**。 -5. 保存通过校验后记下 **通道 ID**。 +5. 保存通过校验后记下 **通道 ID**(过渡写入 `YXD_SYNC_CHANNEL_ID`)。 6. **二选一鉴权**: - 管理路径:发带「数据同步」的智能体 Token / 管理员 JWT;或 - **用户自助**:终端用登录用户 JWT;先登记 Binding,push 带本人 `online_db_id`。 -**单服务器**:一条通道 + 一个 `YXD_SYNC_CHANNEL_ID` 即可。 +**单服务器**:一条默认同步通道即可(自动或手建)。 **多服务器**:每台线上 Postgres **各建一条通道**;按本机库选择对应 `channel_id`(建议登记 Binding)。 Binding(多库 / 用户自助时建议登记;登录 JWT 即可,不必管理员权): @@ -359,6 +480,15 @@ CREATE TABLE IF NOT EXISTS orders ( - [ ] 重复 push 同 version 幂等 - [ ] agent 停:仍可本地保存;恢复后按序追上 - [ ] delete 保序,不换新 UUID +- [ ] 空表:`schema/ensure` 后线上出现空表(Z10c) + +### 生产绑定(Z12/Z13) + +- [ ] 启用智能体后换票 / `agents/me` 已有 `sync_bound=true`(无需手抄通道) +- [ ] 绑定码 redeem 成功 +- [ ] 同号 `13531041944`:lookup → **弹窗** → confirm;取消不绑 +- [ ] 未用超管号 `13531041945` 做绑定联调 +- [ ] 库选「同步」后可 drain,零手填 `CHANNEL_ID` ### 冲突 @@ -377,15 +507,19 @@ CREATE TABLE IF NOT EXISTS orders ( | 方法 | 路径 | 谁用 | |------|------|------| +| POST | `/api/v1/auth/token` | 换票;响应可含 `channel_id`/`online_db_id`/`sync_bound`(Z12a) | +| GET | `/api/v1/agents/me` | 智能体自查落点(Z12b) | +| POST | `/api/v1/auth/bind-code/redeem` | 绑定码兑换(公开,Z13b) | +| POST | `/api/v1/auth/bind/phone-lookup` | 同号探测,不绑定(Z13c) | +| POST | `/api/v1/auth/bind/phone-confirm` | 同号确认后绑定(Z13c-1) | +| GET/POST/DELETE | `/api/v1/admin/bind-codes` | 管理员生成/列表/撤销绑定码(Z13a) | | GET | `/api/v1/agent/sync/channels/{id}/whitelist` | agent / 用户 JWT | | POST | `/api/v1/agent/sync/channels/{id}/push` | agent / 用户 JWT | | POST | `/api/v1/agent/sync/channels/{id}/push/batch` | agent / 用户 JWT | -| POST | `/api/v1/agent/sync/channels/{id}/pull` | agent / 用户 JWT | -| POST | `/api/v1/agent/sync/channels/{id}/bootstrap` | agent / 用户 JWT | -| POST | `/api/v1/agent/sync/channels/{id}/schema` | agent / 用户 JWT(拉线上表结构,含空表) | -| POST | `/api/v1/agent/sync/channels/{id}/schema/ensure` | agent / 用户 JWT(本机空表建到线上) | | POST | `/api/v1/agent/sync/channels/{id}/pull` | agent / 用户 JWT(下行) | -| POST | `/api/v1/agent/sync/channels/{id}/bootstrap` | 全量灌库别名 | +| POST | `/api/v1/agent/sync/channels/{id}/bootstrap` | 全量灌库 | +| POST | `/api/v1/agent/sync/channels/{id}/schema` | 拉线上表结构(含空表) | +| POST | `/api/v1/agent/sync/channels/{id}/schema/ensure` | 本机空表建到线上(Z10) | | GET/POST | `/api/v1/admin/sync/bindings` | 管理员或用户自助 | | GET | `/api/v1/platform/dbsync/lww-overrides` | 仅超管 | | POST | `/api/v1/platform/dbsync/lww-overrides/{id}/rollback` | 仅超管 | @@ -396,8 +530,18 @@ OpenAPI:`GET /api/v1/meta/openapi.yaml` ## 10. 本地联调最小步骤 +### 生产路径(推荐) + +1. 智建起栈;公司配置 `DefaultRemoteDSN`(或联调用空 DSN → sqlite 文件)。 +2. 启用智能体 → 自动绑默认同步通道;或管理员 `POST .../admin/bind-codes` 发码。 +3. 宇恒:`host_key` 换票 / `agents/me` 看 `sync_bound`;未绑则 lookup→确认 或 redeem。 +4. 本机库选「同步」→ Binding + agent drain;**不必**手填 `YXD_SYNC_CHANNEL_ID`。 +5. 同号联调用 **`13531041944`**,禁止超管号。 + +### 过渡 / 手建通道(仍可用) + 1. 智建本机起栈(`.env` 已是本地:`AIJZ_PUBLIC_BASE_URL=http://127.0.0.1:8180`)。 -2. 控制台建通道:`remote` 优先本机 **Postgres**(与平台同实例或独立库均可);拷贝通道 ID。 +2. 控制台建通道:`remote` 优先本机 **Postgres**;拷贝通道 ID。 3. 拿 token(管理员登录或智能体 client_credentials)。 4. 宇恒设: @@ -411,6 +555,7 @@ YXD_SYNC_ACCESS_TOKEN=... 5. 对白名单表插一行 → 看本机 B + outbox → agent 推上 A → 比对 UUID。 6. 多服务器演练:再建第二条通道指向另一 Postgres,换 `CHANNEL_ID` 验证不串库。 +7. **空表**:sync 周期调 `schema/ensure` + `schema`(Z10c),否则空表不会出现在线上。 --- @@ -419,6 +564,7 @@ YXD_SYNC_ACCESS_TOKEN=... | 文档 | 内容 | |------|------| | `松离线-dbsync方案-最终版.md` | 双方冻结方案 | +| `联调后修改意见-宇恒松离线.md` | 联调结论 + Z12/Z13 产品路径 | | `docs/同步表约定.md` | UUID / 接口约定 | | `docs/数据同步-开通说明.md` | 管理员开通 | | `docs/数据同步-迁移手册.md` | 旧客迁移 | @@ -427,4 +573,4 @@ YXD_SYNC_ACCESS_TOKEN=... --- -**分工提醒**:智建只提供平台通道、校验、push/whitelist/Binding/LWW;**写网关、outbox、agent、未开通回归**由宇恒在己方仓库实现,勿改智建仓业务代码。 +**分工提醒**:智建只提供平台通道、校验、push/whitelist/Binding/LWW/绑定 API;**写网关、outbox、agent、未开通回归、Z13 弹窗与选同步即用**由宇恒在己方仓库实现,勿改智建仓业务代码。 diff --git a/联调后修改意见-宇恒松离线.md b/联调后修改意见-宇恒松离线.md index 617bdd9..7e256f5 100644 --- a/联调后修改意见-宇恒松离线.md +++ b/联调后修改意见-宇恒松离线.md @@ -7,6 +7,9 @@ > 再修订:2026-08-04 晚(§5.5 Z8f 本机镜像已通;§5.5.2 Z8g SyncPage) > 再修订:2026-08-04(§5.7 **Z10 空表双侧建齐**;§0/§1/§5.4 现状回写) > 再修订:2026-08-05(§5.6 **Z9e/Z9f 存量 import 必须扫库**;`coerce_fields` 复现) +> 再修订:2026-08-05(智建落实 **Z9e/Z9f、Z8g、Z11a/b**;Z12/Z13 仍待) +> 再修订:2026-08-05(智建落实 **Z12a/b/c/e、Z13a–c-1** API;Z12c-2/Z13d–e 待宇恒;绑定码控制台 UI 可后补) +> 再修订:2026-08-05(`宇恒-松离线数据同步使用文档.md`、`docs/数据同步-开通说明.md` 对齐 Z12/Z13 生产路径) > 来源:宇恒客户端 `yuhengyihao_client` ↔ 本机智建 `127.0.0.1:8180`(gateway)/ `:8888`(platform) > 依据:`松离线-dbsync方案-最终版.md`、`宇恒-松离线数据同步使用文档.md` @@ -16,13 +19,14 @@ | 级别 | 状态 | 说明 | |------|------|------| -| **硬改(阻塞宇恒上线)** | **无**(松离线主路径) | Z1–Z7、**Z9**、**Z10a/b(智建 API)** 已落实;主路径可上线 | +| **硬改(阻塞宇恒上线)** | **无**(松离线主路径) | Z1–Z7、**Z9(含存量扫库)**、**Z10a/b** 已落实;主路径可上线 | | **原建议项 2.1–2.5** | **智建侧基本已落实** | 见 §2 对照表;接口请保持兼容 | | **用户自助 Z1–Z7** | **已落实** | Z2 宇恒复测通过;Z7 Binding 可读名已合入(需宇恒 ensure 时带 `database_name`/`display_name`) | -| **Z8 模块↔本机库+智能体库** | **大部分落实** | Z8a/b/c/e 智建;**Z8f 宇恒本机 `模块·名·资源` 已可管+可 sync**;**Z8d 路径已通**;**Z8g** 展示增强仍待智建(P1,非硬阻塞)见 §5.5.2 | -| **Z9 模块导入默认能力(方案 A)** | **新建已落实;存量未闭环** | Z9a–d 发布时兜底;**Z9e/Z9f 必须做**:旧模块未再发布仍会 `operation import not allowed`(2026-08-05 再复现),见 §5.6 | +| **Z8 模块↔本机库+智能体库** | **大部分落实** | Z8a–f;**Z8g 展示已落实**(模块名 + 策略表文案);见 §5.5.2 | +| **Z9 模块导入默认能力(方案 A)** | **已落实(含存量)** | Z9a–f:启动扫库 + 一键开启导入 + 读路径内存兜底;见 §5.6 | | **Z10 空表双侧建齐** | **智建 API 已落实;宇恒待接** | `schema/ensure` + `schema` + pull `columns`;**Z10c 待宇恒** sync 周期调用,见 §5.7 | -| **Z11 蓝图↔库列一致** | **待智建** | 增字段未迁 Postgres → 导入 42703;见 §5.8 | +| **Z11 蓝图↔库列一致** | **智建已落实** | 发布自动 `ADD COLUMN IF NOT EXISTS`;导入 42703 可读提示;见 §5.8 | +| **Z12/Z13 开通/绑定 UX** | **智建 API 已落实;宇恒待接** | Z12a/b/c/e、Z13 绑定码+同号确认 API 已合入;**Z13d/e 待宇恒**;见 §5.9–5.10;**Z12d 通道 ID 可复制已有** | | **仍建议关注** | 性能/运维 | SQLite remote **高并发 push** 易锁;agent 宜单库串行 drain(属宇恒用法,非平台硬改) | 宇恒对照脚本: @@ -54,7 +58,15 @@ 5. 鉴权:管理员 JWT(含「数据同步」)、智能体 Token,或**登录用户 JWT**(须本人 Binding + `online_db_id`)均可调 agent API 6. 错误体优先带可读 `message`;忙/上游失败宜带 `retryable: true`(502/503) -演示账号联调可用:`phone=13800000001` / `demo123`(超管快捷号见控制台;另见 seed `13531041945`)。 +**联调账号(写入约定,勿再用错租户 / 勿用超管号)** + +| 环境 | 用途 | 登录 | 说明 | +|------|------|------|------| +| 本机 / 演示公司 | 租户 1 演示 | `13800000001` / `demo123` | 仅本机联调;**不要**拿去推生产「宇信达」通道 | +| **生产 · 宇信达** | 公司管理 / 同号绑定联调 | **`13531041944`**(密码由开通时设定) | **专用联调号**;智建须在「宇信达」成员/管理员中写入此手机;宇恒 `YXD_SYNC_LOGIN_PHONE` 与 Z13 同号确认均以此号为准 | +| 平台超管(禁止联调) | 控制台「填入超管手机」 | `13531041945` / `ljk_admin` | **最顶级管理员**;seed `ensureDevPhone(ljk_admin)`;**禁止**用作同步登录 / 绑定联调,会与公司账号冲突 | + +> **智建待办**:生产「宇信达」写入成员手机 **`13531041944`**(公司管理员即可),勿再把同步联调绑到 `13531041945`。 --- @@ -152,8 +164,10 @@ | **Z5** | **P2** | **仅线上 / 下行补齐** | A→B / pull / bootstrap。 | **已落实** | | **Z6** | **P2** | **控制台文案** | Binding 即权限;管理员不负责维护表白名单。 | **已落实** | | **Z7** | **P1** | **SyncPage / Binding 展示「可读库名 + 映射」** | 通道「线上」列优先可读名;副文案 online_db_id / 落库路径;统计标注为 push 次数;Binding 列表「本地库名 ↔ 线上库名」。 | **已落实(2026-08-04)**:Binding 增 `database_name`/`display_name`;SyncPage 主标题优先本地 `database_name`,↑ 标注「推送次数 / 非表数」。宇恒 ensure 须带可读名,否则仍会回退到通道名或 driver | -| **Z8** | **P0** | **模块数据:本机库可管理 + 按智能体上云** | 见 §5.5:本机 B 管理模块表;push 到智能体绑定的 A;废弃默认双轨 | **大部分落实**:Z8a/b/c/d/e/f;**Z8g 展示增强待智建**(P1) | +| **Z8** | **P0** | **模块数据:本机库可管理 + 按智能体上云** | 见 §5.5:本机 B 管理模块表;push 到智能体绑定的 A;废弃默认双轨 | **已落实(含 Z8g 展示)** | +| **Z9** | **P0** | **模块默认 import + 存量扫库** | 见 §5.6 | **已落实 Z9a–f** | | **Z10** | **P0** | **空表两侧建齐** | 见 §5.7:仅靠 outbox 不会建空表 | **智建 API 已落实**;**Z10c 待宇恒**接 ensure/schema | +| **Z11** | **P1** | **蓝图增字段迁库** | 见 §5.8 | **智建已落实** ALTER + 导入提示 | ### 建议验收(智建改后) @@ -274,7 +288,7 @@ | **Z8c** | **P1** | **线上与本机对齐** | 智能体读模块数据时优先本机 B(离线)或已同步的 A;发布蓝图在本机建表(UUID PK)再 sync,避免只在平台 schema 建一份 | **部分**:智能体新建发布若设 `database_name` → `database_per_app` 落该库;本机建表靠宇恒 Z8f | | **Z8d** | **P1** | **本地上传 / 编辑 → 上云** | 本机模块表变更经 Binding + push 进该智能体 A;「查看线上表」可核对 | **路径已通**:本机改 `模块·…` 表 → drain → 「查看线上表」应见同表;若仍缺行 = 未 push / 未 drain | | **Z8e** | **P2** | **查看线上表默认藏 `_ajz_*`** | 减少「表名不对」误解 | **智建已做默认隐藏**(2026-08-04) | -| **Z8g** | **P1** | **本机可见 = 线上可见(展示闭环)** | SyncPage「智能体/模块」勿长期「未绑模块」;「查看线上表」列出本机已有业务表(含 `模块·…`);模块列优先展示名 | **待智建**,见 §5.5.2 | +| **Z8g** | **P1** | **本机可见 = 线上可见(展示闭环)** | SyncPage「智能体/模块」勿长期「未绑模块」;「查看线上表」列出本机已有业务表(含 `模块·…`);模块列优先展示名 | **智建已落实(2026-08-05)**:模块展示名 + slug 副文案;策略表标「参考/整库同步」 | **分工建议** @@ -289,7 +303,7 @@ 2. ✅ 本机改模块行 → sync 后智建「查看线上表」出现同表同行(有数据路径;空表见 Z10)。 3. ✅ 智能体 X 只落到绑定库;管理员可按智能体筛通道验数(Z8b)。 4. ✅ 文档写明:本机有、线上无 = 尚未 push/ensure,不是串库。 -5. ⬜ SyncPage「智能体/模块」显示智能体名 + 模块**展示名**(Z8g;目前多显示 slug /「未绑模块」)。 +5. ✅ SyncPage「智能体/模块」显示智能体名 + 模块**展示名**(Z8g;slug 作副文案)。 ### 5.5.1 【联调补丁·2026-08-04】编辑发布撞 page id @@ -300,20 +314,19 @@ **与 Z9**:Z9 默认带 import 后,此类「仅为开导入而编辑」会减少;本补丁仍保留给「改已有页能力」用。 -### 5.5.2 【待智建 Z8g】本机可见 ↔ 线上可见(展示增强 · 非硬阻塞) +### 5.5.2 【已落实 Z8g】本机可见 ↔ 线上可见(展示增强 · 2026-08-05) > **联调现象(对照两图)** > - 宇恒「数据表」已有:`orders`、`填土高度(6标一工区)`、**`模块·测·Sheet1`**(及业务表)。 -> - 智建 SyncPage 通道行若未写 `agent_id`/`app_slug` 会显示 **「未绑模块」**;有 slug 时目前多直接显示 slug,未解析模块**展示名**。 -> - 期望:本机库卡片能看见的业务表,同步后「查看线上表」也应能看见(行数允许短暂滞后;**空表走 Z10**)。 -> - **有数据表对齐**靠 push;**空表对齐**靠 Z10,不单靠 Z8g。 +> - 智建 SyncPage:有 `app_slug` 时解析已发布模块**展示名**,slug 作副文案;仅缺绑定才「未绑模块」。 +> - 通道表白名单 UI 标为「策略表(参考)」/「整库同步中」,避免误读为只能 sync 名单内表。 | 编号 | 优先级 | 诉求 | 说明 | 负责 | |------|--------|------|------|------| -| **Z8g-1** | **P1** | **SyncPage 模块列可读** | 「智能体/模块」:有 `app_slug` 时解析已发布模块**展示名**(如「测」),slug 作副文案;仅缺绑定才显示「未绑模块」。`agent_id` 有值但 agents 列表未加载时仍显示 `#id`,勿整列空白 | **智建** `web/src/SyncPage.tsx` | -| **Z8g-2** | **P1** | **通道绑定可被宇恒写入** | 保持 `PUT /api/v1/admin/sync/channels/{id}` 可写 `agent_id`、`app_slug`;列表 API 原样返回。宇恒已在 sync/mode、full-push 调 `ensure_channel_module_link`(环境 `YXD_SYNC_AGENT_ID` / `YXD_SYNC_APP_SLUG` / `YXD_SYNC_MODULE_NAME`) | **智建保持契约**;宇恒已接 | -| **Z8g-3** | **P1** | **查看线上表 = 本机业务表子集** | inspect 列出 remote 全部业务表(含中文/含 `模块·`);继续默认藏 `_ajz_*`。缺表文案明确:「本机有、这里没有 = 尚未 push」 | **智建**(展示已基本具备;验收与文案强化) | -| **Z8g-4** | **P2** | **白名单列勿误导** | Z4 已允许任意表 push;若通道 `tables` 仍只配 `["orders"]`,UI 勿暗示「只能同步 orders」。可标「策略表(参考)」或「整库同步中」 | **智建** SyncPage/配置抽屉 | +| **Z8g-1** | **P1** | **SyncPage 模块列可读** | 展示名 + slug 副文案 | **智建已落实** | +| **Z8g-2** | **P1** | **通道绑定可被宇恒写入** | `PUT` 可写 `agent_id`/`app_slug` | **智建保持契约**;宇恒已接 | +| **Z8g-3** | **P1** | **查看线上表 = 本机业务表子集** | inspect + 默认藏 `_ajz_*`;缺表文案 | **智建已具备** | +| **Z8g-4** | **P2** | **白名单列勿误导** | 「策略表(参考)」/「整库同步中」 | **智建已落实** | **验收** @@ -346,8 +359,8 @@ | **Z9b** | **P0** | **列表 actions 对齐** | list 页 `actions` 默认含 **`import`**(及可选 `export`) | **已落实**:发布兜底补 list actions | | **Z9c** | **P0** | **改 prompt / 文档** | `prompt-contract.md`:由「提到导入再加」改为「默认加;明确不要再去掉」 | **已落实** | | **Z9d** | **P0** | **publish/merge 兜底** | 发布或编辑合并时:业务 resource 缺 `import` 则自动补上(可配置,默认开) | **已落实**:`EnsureDefaultImportExport`;但**只在 publish 路径触发** | -| **Z9e** | **P0(升格)** | **存量模块一次性扫库补齐** | **禁止**依赖用户「碰巧再发布一次」。平台启动或管理接口:遍历租户已发布蓝图,对可写业务 resource 缺 `import/export` 的执行与 Z9d 相同补齐并落库(可干跑+确认)。目标:**任意旧模块** `POST .../import` 不再因缺 ops 400 | **待智建**;2026-08-05 `coerce_fields/items` 仍只有 list/create/… 无 import,联调第三次踩同一坑 | -| **Z9f** | **P1** | **读路径也兜底(双保险)** | `GetBlueprint` / `ResolveResource` / `ImportRows` 入口:若可写 resource 缺 import,**内存补齐后再校验**(或返回明确引导「请点修复导入能力」)。避免「UI 能点导入、API 仍 400」 | **待智建**;宇恒侧已在导入前自动 replace 补 ops,不能替代平台扫存量 | +| **Z9e** | **P0(升格)** | **存量模块一次性扫库补齐** | 平台启动扫库 + 管理接口;不依赖用户再发布 | **已落实(2026-08-05)**:启动 `RunBackfill…`;`POST /api/v1/admin/apps/ensure-import`;模块管理「一键开启导入」;超管 `…/platform/apps/ensure-import` | +| **Z9f** | **P1** | **读路径也兜底(双保险)** | Import/Export 入口内存补齐后再校验;可读错误文案 | **已落实**:`ensureImportOpInMemory` + 区分蓝图缺 import vs 权限 | **存量复现(必须消灭)** @@ -369,8 +382,8 @@ POST /api/v1/apps/coerce_fields/items/import | 方 | 负责 | |----|------| -| **智建** | **Z9e 扫存量(P0)**、Z9f 读路径兜底;保持 Z9a–d;错误文案区分「蓝图未开 import」vs「账号无 row.import」 | -| 宇恒 / 智能体 | 导入前尽力自动补齐(已做);表单选对 slug/resource;**不能**代替平台扫全租户 | +| **智建** | **Z9e/Z9f 已落实**;保持 Z9a–d;错误文案区分「蓝图未开 import」vs「账号无 row.import」 | +| 宇恒 / 智能体 | 导入前尽力自动补齐(已做);表单选对 slug/resource;**重启 platform 或点「一键开启导入」后**旧模块即可 import | **验收(含存量)** @@ -409,7 +422,7 @@ POST .../schema 2. 换机 / 仅线上:schema → 本机建空表 → bootstrap 灌行。 3. 有数据的表仍走原 push;ensure **不替代** outbox 行同步。 -### 5.8 【待智建 Z11】蓝图增字段须迁 Postgres(2026-08-04) +### 5.8 【已落实 Z11】蓝图增字段须迁 Postgres(2026-08-05) > **联调现象** > 模块 `record` 蓝图后来带了 `title/content/status`,但 Postgres 表仍只有 `col_0/col_1/col_2`。 @@ -418,11 +431,103 @@ POST .../schema | 编号 | 优先级 | 诉求 | 负责 | |------|--------|------|------| -| **Z11a** | **P1** | 编辑/发布增字段后 **自动 ALTER** 业务表补列(或发布失败并提示未迁库) | **智建** | -| **Z11b** | **P2** | 导入时:Excel 列映射到蓝图有、库无的字段 → 明确报「请先迁库」,勿只堆 28 条 pq 错误 | **智建** | +| **Z11a** | **P1** | 编辑/发布增字段后 **自动 ALTER** 业务表补列 | **智建已落实**:`BuildPostgresAlterDDL` + publish 合并执行;字段-only merge 可发布 | +| **Z11b** | **P2** | 导入时:Excel 列映射到蓝图有、库无的字段 → 明确提示迁库 | **智建已落实**:42703 行错误附加「请重新发布以自动补列」 | -**宇恒临时绕过**:导入 Excel 只保留库里已有列对应表头(填土:`序号/断面里程/填土高度` → col_0/1/2)。 +**说明**:存量表补列一律可空,避免 NOT NULL 迁库失败。已有库需**再发布一次**(或编辑发布增字段)才会 ALTER;之后新字段随发布自动补。 +### 5.9 【智建已落实 Z12】生产:账号绑定即落点,禁止手填通道 / 手建通道(2026-08-05) + +> **产品约定(生产 · 冻结意向)** +> 1. **一个登录账号 / 一个智能体 ↔ 本公司同步落点**:绑定后,该账号的本机库只进自己的(或公司共享的)线上库,**不会**接到别人账号的库。 +> 2. **用户不应**:手抄 `channel_id`、也不应先去「数据同步」里点「新建通道」才能用。 +> 3. **通道对用户隐藏或自动生成**:公司开通 / 智能体启用时,平台**自动**准备本公司默认同步通道(Postgres 线上 A)并写到智能体/账号绑定上;控制台「数据同步」留给运维改 DSN,不作为开通必经 UI。 +> 4. **共享库**:仅「公司共享数据库」可被多名成员同步到同一 `online_db_id`;个人库默认隔离。 +> 5. 方向默认 **`local_to_remote`**(松离线 agent push);「双向」不是用户必选项。 +> 6. SyncPage 列表须展示可复制 **通道 ID**(运维用;普通开通流程不依赖)。 + +| 编号 | 优先级 | 诉求 | 说明 | 负责 | +|------|--------|------|------|------| +| **Z12a** | **P0** | **换票带回绑定** | `POST /api/v1/auth/token`(及登录)响应含 `channel_id` / `online_db_id` / `database_name` / `sync_bound` | **智建已落实** | +| **Z12b** | **P0** | **智能体自查绑定** | `GET /api/v1/agents/me`:智能体 Bearer 可读自己的通道/线上库/状态 | **智建已落实** | +| **Z12c** | **P0** | **启用即自动绑通道** | 创建/启用智能体 → `EnsureSystemDefaultChannel`(`IsSystemDefault`,表白名单可空);写回 `channel_id`+`online_db_id`;DSN 见 `DBSync.DefaultRemoteDSN`(空则 sqlite 联调文件) | **智建已落实** | +| **Z12c-1** | **P0** | **账号级隔离** | Binding / push 校验:用户 JWT 只能写本人 `online_db_id` | **智建已有**(自助 Binding) | +| **Z12c-2** | **P1** | **公司共享库** | 支持标记 `shared=true` 的 `online_db_id` | **待智建** | +| **Z12d** | **P1** | **SyncPage 展示通道 ID** | 运维可见 + copyable | **智建已落实** | +| **Z12e** | **P1** | **唯一默认通道** | 每公司至多 1 条 `is_system_default`;复用已有默认 | **智建已落实** + 宇恒「仅 1 条则写入 env」 | +| **Z12f** | **P1** | **开通 UX** | 启用智能体即可;DSN 用公司级默认 | **智建已落实**(控制台引导文案可再收) | + +**宇恒已做(2026-08-05)** + +1. 当前生产通道 `691575c0-…` 已写入 env(过渡缓存;Z12c 落地后应由换票/唯一通道自动获得)。 +2. `resolve_sync_channel_id()`:env → Binding → 租户仅 1 通道自动选用。 +3. 不把「手建通道 + 手抄 ID」当作产品终态。 + +**验收** + +1. 新公司:只「启用智能体」,**不**点新建通道,换票已带 `channel_id`,宇恒可 drain。 +2. 账号 A 的本机库 push 不会出现在账号 B 的线上库;共享库仅在显式共享时可见。 +3. 用户全程无需 F12、无需手填 `YXD_SYNC_CHANNEL_ID`。 + +### 5.10 【智建 API 已落实 / 宇恒待接 Z13】绑定流程简化:绑定码 / 手机号(2026-08-05) + +> **端到端体验(生产目标)** +> 1. 后台已绑定(或用户用绑定码/手机号完成绑定)→ 宇恒本机库选 **「同步」** → **直接可用**(自动通道、自动 Binding、agent drain),无第二套配置。 +> 2. 绑定流程必须比「登记智能体 + 抄通道 ID + 填 env」简单。 +> 3. **生产·宇信达联调账号**:`13531041944`(公司侧专用;**禁止**用超管号 `13531041945`);宇恒侧同号对齐此号做「已有账号是否绑定」联调。 + +**推荐交互** + +```text +公司管理员(智建后台) + → 生成「绑定码」(一次性/短时有效,绑定到本公司 + 默认同步落点) + → 发给终端用户 / 贴在开通单 + +宇恒 · AI建站智能体 + → 启动 / 换票前检测绑定状态 + │ + ├─ 已绑定本公司 → 静默换票;本机库选「同步」即可用 + │ + └─ 未绑定 + ├─ 读取当前宇恒账号手机号(若有) + │ └─ 智建侧已存在「相同手机号」的公司成员/管理员账号 + │ → 【必弹】「检测到手机号 xxx 在「宇信达」已有账号,是否绑定到该账号?」 + │ ├─ 确认 → 绑定到该已有账号(继承公司 + 默认同步落点) + │ └─ 取消 → 改走绑定码 / 换号 + ├─ 无同号 或 用户选择手动 + │ → 表单:【绑定码】或【手机号】 + │ · 填绑定码 → redeem + │ · 填手机号 → 若命中已有账号,同样走「是否绑定」确认,禁止静默绑错人 + └─ 都没有 → 明确提示联系管理员要绑定码 + → 绑定成功后:本机选「同步」→ 直接 drain,零通道配置 +``` + +**同号规则(产品硬约束)** + +| 场景 | 行为 | +|------|------| +| 宇恒手机号 = 公司内已有成员手机号 | **必须先询问**「已有账号,是否绑定」;不得静默绑定 | +| 用户点确认 | 绑定到该已有账号;智能体 host_key 挂到该公司 | +| 用户点取消 | 不绑定;可改输绑定码或其他手机号 | +| 手机号在公司不存在 | 提示无此成员;引导用绑定码 | +| 生产联调样例 | 宇恒号与智建均为 **`13531041944`** → 应弹出「宇信达已有账号是否绑定」;**勿用** `13531041945` | + +| 编号 | 优先级 | 诉求 | 说明 | 负责 | +|------|--------|------|------|------| +| **Z13a** | **P0** | **后台生成绑定码** | `POST/GET/DELETE /api/v1/admin/bind-codes`(需「数据同步」);关联默认通道;过期/次数/撤销 | **智建已落实**(API;控制台 UI 可后补) | +| **Z13b** | **P0** | **绑定码兑换 API** | `POST /api/v1/auth/bind-code/redeem`(`host_key` + `code`)→ 激活并绑定落点 | **智建已落实** | +| **Z13c** | **P0** | **手机号探测 API** | `POST /api/v1/auth/bind/phone-lookup` → `{ exists, tenant_name, masked_name, need_confirm }`,**不**直接绑定 | **智建已落实** | +| **Z13c-1** | **P0** | **同号确认绑定** | `POST /api/v1/auth/bind/phone-confirm`(`confirm=true` 才绑);写审计 | **智建已落实** + **宇恒弹窗待接** | +| **Z13d** | **P0** | **未绑定检测与提示** | `sync_bound=false`:有手机号先 lookup;命中则弹确认;未命中再出绑定码/手机号表单 | **宇恒技能**(智建字段已提供) | +| **Z13e** | **P0** | **选同步即用** | 库选「同步」→ 自动 Binding + drain;零手填通道 | **宇恒**(依赖 Z12/Z13) | +| **Z13f** | **P1** | **安全** | 路由级限速已有;确认绑定写审计;lookup 不回明文全名 | **智建部分落实**(防枚举可再加强) | + +**验收** + +1. 管理员生成绑定码 → 用户填码 → 绑定成功 → 库选「同步」→ 数据进该公司线上库。 +2. **宇恒与智建同号 `13531041944`** → 必须弹出「已有账号是否绑定」;确认后绑定,取消则不绑(禁止用超管号 `13531041945` 测)。 +3. 未绑定用户:先同号探测,再绑定码/手机表单;**不**出现 DSN/通道 ID。 +4. 他人绑定码 / 未确认的同号,不能把数据绑进别的公司或别人账号。 --- ## 6. 联系与附件 @@ -430,4 +535,4 @@ POST .../schema - 方案:`松离线-dbsync方案-最终版.md`(含 2026-08-01 联调建议落地记录) - 宇恒使用说明:`宇恒-松离线数据同步使用文档.md`(含 Z10 schema/ensure) - 开通说明:`docs/数据同步-开通说明.md` -- 本意见如与冻结方案冲突,**以冻结方案为准**;§5.1–5.7 为产品增量与复测记录,不推翻 H1–H6 默认无感约束。 +- 本意见如与冻结方案冲突,**以冻结方案为准**;§5.1–5.10 为产品增量与复测记录,不推翻 H1–H6 默认无感约束。