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:
whm
2026-08-05 11:47:20 +08:00
parent b04b180d30
commit cb56e6847e
31 changed files with 1882 additions and 91 deletions

View 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)
}

View File

@@ -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"`

View File

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