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.
This commit is contained in:
@@ -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`(宇恒对接契约)
|
||||
|
||||
@@ -44,3 +44,5 @@ DBSync:
|
||||
DataDir: /app/data/dbsync
|
||||
LwwAuditTTLDays: 90
|
||||
ReconcileMinSec: 300
|
||||
DefaultRemoteDriver: postgres
|
||||
DefaultRemoteDSN: ""
|
||||
|
||||
@@ -49,3 +49,6 @@ DBSync:
|
||||
DataDir: ./data/dbsync
|
||||
LwwAuditTTLDays: 90
|
||||
ReconcileMinSec: 300
|
||||
# Z12:生产填公司默认 Postgres DSN;空则用 sqlite 联调文件
|
||||
DefaultRemoteDriver: postgres
|
||||
DefaultRemoteDSN: ""
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"},
|
||||
|
||||
218
platform/internal/bindcodestore/store.go
Normal file
218
platform/internal/bindcodestore/store.go
Normal file
@@ -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")
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -72,4 +72,7 @@ type DBSyncConf struct {
|
||||
DataDir string `json:",default=./data/dbsync"` // 通道/冲突/LWW 审计 JSON
|
||||
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
|
||||
}
|
||||
|
||||
109
platform/internal/dbsync/ensure_default_channel.go
Normal file
109
platform/internal/dbsync/ensure_default_channel.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
48
platform/internal/handler/ensure_import.go
Normal file
48
platform/internal/handler/ensure_import.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
91
platform/internal/logic/applogic/agent_sync_bind.go
Normal file
91
platform/internal/logic/applogic/agent_sync_bind.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
357
platform/internal/logic/applogic/bind_flow.go
Normal file
357
platform/internal/logic/applogic/bind_flow.go
Normal file
@@ -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))
|
||||
}
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
85
platform/internal/meta/backfill_import.go
Normal file
85
platform/internal/meta/backfill_import.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
59
platform/internal/meta/backfill_import_test.go
Normal file
59
platform/internal/meta/backfill_import_test.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
92
platform/internal/schema/alter.go
Normal file
92
platform/internal/schema/alter.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<Mod[]>([]);
|
||||
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: {
|
||||
<Card
|
||||
title="模块管理"
|
||||
extra={
|
||||
<Space>
|
||||
<Button icon={<ImportOutlined />} loading={ensuring} onClick={() => onEnsureImport()}>
|
||||
一键开启导入
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={refresh} loading={loading}>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Typography.Paragraph type="secondary" style={{ marginTop: 0 }}>
|
||||
管理账号(如 owner)可查看本租户<strong>全部模块</strong>,含<strong>在建</strong>与已发布。
|
||||
智能体默认不限制模块(可自由发布自建 slug);仅当配置了白名单时才受限。
|
||||
{scope === "all" ? " 当前范围:管理全部。" : scope === "open" ? " 当前范围:智能体开放。" : scope === "granted" ? " 当前范围:白名单。" : null}
|
||||
「一键开启导入」修复旧模块因蓝图缺 import 导致的导入 400(Z9e)。
|
||||
{scope === "all"
|
||||
? " 当前范围:管理全部。"
|
||||
: scope === "open"
|
||||
? " 当前范围:智能体开放。"
|
||||
: scope === "granted"
|
||||
? " 当前范围:白名单。"
|
||||
: null}
|
||||
</Typography.Paragraph>
|
||||
<Table
|
||||
rowKey="app_id"
|
||||
|
||||
@@ -415,6 +415,16 @@ export function SyncPage(props: {
|
||||
dataSource={visibleChannels}
|
||||
pagination={false}
|
||||
columns={[
|
||||
{
|
||||
title: "通道 ID",
|
||||
dataIndex: "id",
|
||||
width: 280,
|
||||
render: (id: string) => (
|
||||
<Typography.Text copyable={{ text: id }} style={{ fontSize: 12 }}>
|
||||
{id}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{ 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 (
|
||||
<span>
|
||||
{ag ? ag.name : r.agent_id ? `#${r.agent_id}` : "—"}
|
||||
<br />
|
||||
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
|
||||
{r.app_slug || "未绑模块"}
|
||||
{moduleLabel}
|
||||
{r.app_slug && app ? (
|
||||
<>
|
||||
<br />
|
||||
<span style={{ opacity: 0.75 }}>{r.app_slug}</span>
|
||||
</>
|
||||
) : null}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
);
|
||||
@@ -640,13 +662,15 @@ export function SyncPage(props: {
|
||||
</Typography.Text>
|
||||
);
|
||||
}
|
||||
const tables = (ch.remote?.tables || []).join(", ") || "(按 Binding 任意表)";
|
||||
const tables = (ch.remote?.tables || []).join(", ");
|
||||
return (
|
||||
<span>
|
||||
{ch.name || id}
|
||||
<br />
|
||||
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
|
||||
通道表白名单配置:{tables}
|
||||
{tables
|
||||
? `策略表(参考,非整库限制):${tables}`
|
||||
: "整库同步中(Binding 任意表可 push)"}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
);
|
||||
@@ -869,9 +893,9 @@ export function SyncPage(props: {
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={["local", "tables"]}
|
||||
label="表(逗号分隔)"
|
||||
label="策略表(参考)"
|
||||
rules={[{ required: true }]}
|
||||
extra="通道表名单仅作提示;用户 JWT + Binding 下任意表可 push"
|
||||
extra="Z4:不按此名单拒收;用户 JWT + Binding 下任意表可 push。此处仅运维提示。"
|
||||
>
|
||||
<Input placeholder="article,order" />
|
||||
</Form.Item>
|
||||
@@ -901,9 +925,9 @@ export function SyncPage(props: {
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={["remote", "tables"]}
|
||||
label="表(逗号分隔)"
|
||||
label="策略表(参考)"
|
||||
rules={[{ required: true }]}
|
||||
extra="与本地提示表一致即可;验同步请用「查看线上表」"
|
||||
extra="非整库限制;与本地提示一致即可。验同步请用「查看线上表」"
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2,14 +2,15 @@
|
||||
|
||||
> 面向:**宇恒一号客户端**对接同学
|
||||
> 平台侧仓库:智建(ai建站)
|
||||
> 依据:`松离线-dbsync方案-最终版.md`(冻结)
|
||||
> 依据:`松离线-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=<Bearer:管理员/智能体「数据同步」,或登录用户 JWT(自助须先 Binding)>
|
||||
# 可选
|
||||
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 弹窗与选同步即用**由宇恒在己方仓库实现,勿改智建仓业务代码。
|
||||
|
||||
155
联调后修改意见-宇恒松离线.md
155
联调后修改意见-宇恒松离线.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 默认无感约束。
|
||||
|
||||
Reference in New Issue
Block a user