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

@@ -44,3 +44,5 @@ DBSync:
DataDir: /app/data/dbsync
LwwAuditTTLDays: 90
ReconcileMinSec: 300
DefaultRemoteDriver: postgres
DefaultRemoteDSN: ""

View File

@@ -49,3 +49,6 @@ DBSync:
DataDir: ./data/dbsync
LwwAuditTTLDays: 90
ReconcileMinSec: 300
# Z12生产填公司默认 Postgres DSN空则用 sqlite 联调文件
DefaultRemoteDriver: postgres
DefaultRemoteDSN: ""

View File

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

View File

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

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

View File

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

View File

@@ -68,8 +68,11 @@ type StorageConf struct {
}
type DBSyncConf struct {
Enabled bool `json:",default=true"`
Enabled bool `json:",default=true"`
DataDir string `json:",default=./data/dbsync"` // 通道/冲突/LWW 审计 JSON
LwwAuditTTLDays int `json:",default=90"` // 超管 LWW 覆盖日志保留天数
ReconcileMinSec int `json:",default=300"` // 手动对账最小间隔(秒)
LwwAuditTTLDays int `json:",default=90"` // 超管 LWW 覆盖日志保留天数
ReconcileMinSec int `json:",default=300"` // 手动对账最小间隔(秒)
// Z12公司默认同步通道启用智能体时自动创建
DefaultRemoteDriver string `json:",default=postgres"`
DefaultRemoteDSN string `json:",optional"` // 空则用 sqlite 联调文件:./data/dbsync/tenant_{id}_online.db
}

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 {

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

View File

@@ -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()

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

View File

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

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

View File

@@ -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("蓝图未开启 importoperations 缺 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("蓝图未开启 exportoperations 缺 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("蓝图未开启 exportoperations 缺 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 == "" {

View File

@@ -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()

View File

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

View 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 同 BackfillDefaultImportExporttenantID=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)
}
}

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

View File

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

View File

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

View File

@@ -0,0 +1,92 @@
package schema
import (
"context"
"database/sql"
"fmt"
"strings"
"aijianzhan/platform/internal/blueprint"
)
// BuildPostgresAlterDDL 对比蓝图与已有表,生成 ADD COLUMN IF NOT EXISTSZ11a
// 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)
}

View File

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

View File

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