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.
508 lines
14 KiB
Go
508 lines
14 KiB
Go
package applogic
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
|
||
"aijianzhan/platform/internal/audit"
|
||
"aijianzhan/platform/internal/authx"
|
||
"aijianzhan/platform/internal/agentcap"
|
||
"aijianzhan/platform/internal/blueprint"
|
||
"aijianzhan/platform/internal/meta"
|
||
"aijianzhan/platform/internal/schema"
|
||
"aijianzhan/platform/internal/svc"
|
||
"aijianzhan/platform/internal/types"
|
||
|
||
"github.com/google/uuid"
|
||
)
|
||
|
||
type PublishLogic struct {
|
||
ctx context.Context
|
||
svcCtx *svc.ServiceContext
|
||
}
|
||
|
||
func NewPublishLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PublishLogic {
|
||
return &PublishLogic{ctx: ctx, svcCtx: svcCtx}
|
||
}
|
||
|
||
func (l *PublishLogic) ListApps() (*types.AppListResp, error) {
|
||
tenantID := authx.TenantID(l.ctx)
|
||
items, err := l.svcCtx.Meta.ListByTenant(l.ctx, tenantID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
allowed := map[string]struct{}{}
|
||
filter := false
|
||
scope := "all"
|
||
// 管理账号:本租户全部模块(含在建)
|
||
// 智能体:app_slugs 为空或含 * → 不限制;否则仅白名单
|
||
if authx.Role(l.ctx) == authx.RoleAgent && l.svcCtx.Agents != nil {
|
||
acc, err := l.svcCtx.Agents.Get(l.ctx, tenantID, authx.AgentID(l.ctx))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
open := len(acc.AppSlugs) == 0
|
||
for _, s := range acc.AppSlugs {
|
||
if s == "*" {
|
||
open = true
|
||
}
|
||
allowed[s] = struct{}{}
|
||
}
|
||
if open {
|
||
scope = "open"
|
||
filter = false
|
||
} else {
|
||
scope = "granted"
|
||
filter = true
|
||
}
|
||
}
|
||
out := make([]types.AppListItem, 0, len(items))
|
||
for _, it := range items {
|
||
if filter {
|
||
if _, ok := allowed[it.Slug]; !ok {
|
||
continue
|
||
}
|
||
}
|
||
st := it.Status
|
||
out = append(out, types.AppListItem{
|
||
AppID: it.AppID,
|
||
Slug: it.Slug,
|
||
Name: it.Name,
|
||
Status: string(st),
|
||
StatusLabel: meta.StatusLabelCN(st),
|
||
Building: meta.IsBuilding(st),
|
||
SchemaName: it.SchemaName,
|
||
DatabaseName: it.DatabaseName,
|
||
PageCount: it.PageCount,
|
||
EntityCount: it.EntityCount,
|
||
UpdatedAt: it.UpdatedAt.UTC().Format(time.RFC3339),
|
||
CreatedAt: it.CreatedAt.UTC().Format(time.RFC3339),
|
||
})
|
||
}
|
||
return &types.AppListResp{Items: out, Scope: scope}, nil
|
||
}
|
||
|
||
// SaveDraft 登记/更新「在建」模块蓝图(不跑 DDL)。管理账号可用来看到生成中尚未发布的模块。
|
||
func (l *PublishLogic) SaveDraft(slug string, req *types.DraftReq) (*types.AppListItem, error) {
|
||
tenantID := authx.TenantID(l.ctx)
|
||
bp, err := blueprint.Parse(req.Blueprint)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
slug = blueprint.NormalizeIdent(slug)
|
||
bp.Meta.Slug = blueprint.NormalizeIdent(bp.Meta.Slug)
|
||
if bp.Meta.Slug == "" {
|
||
bp.Meta.Slug = slug
|
||
}
|
||
if slug == "" {
|
||
slug = bp.Meta.Slug
|
||
}
|
||
bp.Meta.Slug = slug
|
||
if bp.Apis.BasePath == "" || strings.Contains(bp.Apis.BasePath, "-") {
|
||
bp.Apis.BasePath = "/api/v1/apps/" + slug
|
||
}
|
||
if bp.Meta.Name == "" {
|
||
bp.Meta.Name = slug
|
||
}
|
||
if bp.Version == "" {
|
||
bp.Version = "1.0"
|
||
}
|
||
if bp.Storage.Mode == "" {
|
||
bp.Storage.Mode = "schema_per_app"
|
||
}
|
||
if bp.Storage.Engine == "" {
|
||
bp.Storage.Engine = "postgres"
|
||
}
|
||
bp.EnsureDefaultImportExport()
|
||
// 草稿允许尚未完全合法;尽量校验,失败则仍以宽松方式保存关键字段
|
||
_ = bp.Validate(slug)
|
||
|
||
now := time.Now().UTC()
|
||
appID := uuid.NewString()
|
||
schemaName := bp.AssignSchemaName(tenantID)
|
||
if existing, err := l.svcCtx.Meta.GetBySlug(l.ctx, tenantID, slug); err == nil && existing != nil {
|
||
if existing.Status == meta.StatusPublished {
|
||
return nil, fmt.Errorf("module already published: %s (use publish to update pages)", slug)
|
||
}
|
||
appID = existing.AppID
|
||
now = existing.CreatedAt
|
||
if existing.SchemaName != "" {
|
||
schemaName = existing.SchemaName
|
||
bp.Storage.SchemaName = existing.SchemaName
|
||
}
|
||
}
|
||
rec := &meta.AppRecord{
|
||
AppID: appID,
|
||
TenantID: tenantID,
|
||
Slug: slug,
|
||
Name: bp.Meta.Name,
|
||
SchemaName: schemaName,
|
||
Engine: bp.Storage.Engine,
|
||
Status: meta.StatusDraft,
|
||
Blueprint: bp,
|
||
CreatedAt: now,
|
||
UpdatedAt: time.Now().UTC(),
|
||
}
|
||
if err := l.svcCtx.Meta.Save(l.ctx, rec); err != nil {
|
||
return nil, err
|
||
}
|
||
_ = l.audit("draft.save", audit.DetailJSON(map[string]any{"slug": slug, "user_id": authx.UserID(l.ctx)}))
|
||
return &types.AppListItem{
|
||
AppID: rec.AppID,
|
||
Slug: slug,
|
||
Name: rec.Name,
|
||
Status: string(meta.StatusDraft),
|
||
StatusLabel: meta.StatusLabelCN(meta.StatusDraft),
|
||
Building: true,
|
||
SchemaName: schemaName,
|
||
PageCount: len(bp.Pages),
|
||
EntityCount: len(bp.Entities),
|
||
UpdatedAt: rec.UpdatedAt.UTC().Format(time.RFC3339),
|
||
CreatedAt: rec.CreatedAt.UTC().Format(time.RFC3339),
|
||
}, nil
|
||
}
|
||
|
||
func (l *PublishLogic) Publish(slug string, req *types.PublishReq) (*types.PublishResp, error) {
|
||
tenantID := authx.TenantID(l.ctx)
|
||
userID := authx.UserID(l.ctx)
|
||
|
||
incoming, err := blueprint.Parse(req.Blueprint)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
slug = blueprint.NormalizeIdent(slug)
|
||
incoming.Meta.Slug = blueprint.NormalizeIdent(incoming.Meta.Slug)
|
||
if incoming.Meta.Slug == "" {
|
||
incoming.Meta.Slug = slug
|
||
}
|
||
if slug == "" {
|
||
slug = incoming.Meta.Slug
|
||
}
|
||
// 路径与蓝图不一致时以路径 slug 为准(先选应用再发布)
|
||
if slug != "" && slug != incoming.Meta.Slug {
|
||
incoming.Meta.Slug = slug
|
||
}
|
||
if incoming.Apis.BasePath == "" || strings.Contains(incoming.Apis.BasePath, "-") {
|
||
incoming.Apis.BasePath = "/api/v1/apps/" + slug
|
||
}
|
||
|
||
mode := strings.ToLower(strings.TrimSpace(req.Mode))
|
||
if mode == "" {
|
||
mode = "auto"
|
||
}
|
||
if mode == "merge" {
|
||
mode = "add_pages" // 兼容旧别名
|
||
}
|
||
switch mode {
|
||
case "auto", "add_pages", "create", "replace":
|
||
default:
|
||
return nil, fmt.Errorf("unsupported publish mode: %s (use auto|add_pages|create|replace)", mode)
|
||
}
|
||
|
||
existing, existErr := l.svcCtx.Meta.GetBySlug(l.ctx, tenantID, slug)
|
||
exists := existErr == nil && existing != nil
|
||
|
||
publishMode := "created"
|
||
var mergeRes *blueprint.MergeResult
|
||
var bp *blueprint.Blueprint
|
||
|
||
switch {
|
||
case mode == "create":
|
||
if exists {
|
||
return nil, fmt.Errorf("app already exists: %s (select it and publish with mode=add_pages to add newly generated pages)", slug)
|
||
}
|
||
bp = incoming
|
||
publishMode = "created"
|
||
case mode == "add_pages":
|
||
if !exists {
|
||
return nil, fmt.Errorf("app not found: %s (create it first, or use mode=create/auto)", slug)
|
||
}
|
||
if existing.Blueprint == nil {
|
||
return nil, fmt.Errorf("existing app has no blueprint")
|
||
}
|
||
bp = existing.Blueprint
|
||
bp.Meta.Slug = slug
|
||
bp.Apis.BasePath = "/api/v1/apps/" + slug
|
||
mergeRes, err = blueprint.MergeInto(bp, incoming)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
publishMode = "pages_added"
|
||
case mode == "replace":
|
||
bp = incoming
|
||
if exists {
|
||
publishMode = "replaced"
|
||
} else {
|
||
publishMode = "created"
|
||
}
|
||
default: // auto
|
||
if exists {
|
||
if existing.Blueprint == nil {
|
||
return nil, fmt.Errorf("existing app has no blueprint")
|
||
}
|
||
bp = existing.Blueprint
|
||
bp.Meta.Slug = slug
|
||
bp.Apis.BasePath = "/api/v1/apps/" + slug
|
||
mergeRes, err = blueprint.MergeInto(bp, incoming)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
publishMode = "pages_added"
|
||
} else {
|
||
bp = incoming
|
||
publishMode = "created"
|
||
}
|
||
}
|
||
|
||
bp.EnsureDefaultImportExport()
|
||
if err := bp.Validate(slug); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
schemaName := bp.AssignSchemaName(tenantID)
|
||
dbName := bp.AssignDatabaseName(tenantID)
|
||
// Z8c:智能体若绑定了 database_name,新建模块优先落到该库(database_per_app)
|
||
if authx.Role(l.ctx) == authx.RoleAgent && l.svcCtx.Agents != nil {
|
||
if aid := authx.AgentID(l.ctx); aid > 0 {
|
||
if acc, err := l.svcCtx.Agents.Get(l.ctx, tenantID, aid); err == nil && acc != nil {
|
||
if dn := strings.TrimSpace(acc.DatabaseName); dn != "" {
|
||
if !exists || existing.DatabaseName == "" {
|
||
bp.Storage.Mode = "database_per_app"
|
||
schemaName = bp.AssignSchemaName(tenantID)
|
||
dbName = dn
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
ddl, err := schema.BuildPostgresDDL(bp)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
appID := uuid.NewString()
|
||
now := time.Now().UTC()
|
||
republish := false
|
||
if exists {
|
||
appID = existing.AppID
|
||
now = existing.CreatedAt
|
||
republish = existing.Status == meta.StatusPublished
|
||
if existing.SchemaName != "" {
|
||
schemaName = existing.SchemaName
|
||
bp.Storage.SchemaName = existing.SchemaName
|
||
}
|
||
if existing.DatabaseName != "" {
|
||
dbName = existing.DatabaseName
|
||
}
|
||
ddl, err = schema.BuildPostgresDDL(bp)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
rec := &meta.AppRecord{
|
||
AppID: appID,
|
||
TenantID: tenantID,
|
||
Slug: slug,
|
||
Name: bp.Meta.Name,
|
||
SchemaName: schemaName,
|
||
DatabaseName: dbName,
|
||
Engine: bp.Storage.Engine,
|
||
Status: meta.StatusProvisioning,
|
||
Blueprint: bp,
|
||
DDL: ddl,
|
||
CreatedAt: now,
|
||
UpdatedAt: time.Now().UTC(),
|
||
}
|
||
if err := l.svcCtx.Meta.Save(l.ctx, rec); err != nil {
|
||
return nil, fmt.Errorf("meta save: %w", err)
|
||
}
|
||
|
||
runner := l.svcCtx.Schema
|
||
var appDB *sql.DB
|
||
if dbName != "" {
|
||
if err := runner.EnsureDatabase(l.ctx, dbName); err != nil {
|
||
rec.Status = meta.StatusFailed
|
||
rec.Error = err.Error()
|
||
_ = l.svcCtx.Meta.Save(l.ctx, rec)
|
||
_ = l.audit("publish.failed", audit.DetailJSON(map[string]any{"slug": slug, "error": err.Error()}))
|
||
return nil, fmt.Errorf("ensure database: %w", err)
|
||
}
|
||
if l.svcCtx.Config.DataSource != "" {
|
||
dsn, err := schema.DSNForDatabase(l.svcCtx.Config.DataSource, dbName)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
appDB, err = sql.Open("postgres", dsn)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer appDB.Close()
|
||
runner = &schema.PostgresRunner{DB: appDB}
|
||
if l.svcCtx.DBPool != nil {
|
||
_, _ = l.svcCtx.DBPool.ForApp(rec)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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()
|
||
rec.UpdatedAt = time.Now().UTC()
|
||
_ = l.svcCtx.Meta.Save(l.ctx, rec)
|
||
_ = l.audit("publish.failed", audit.DetailJSON(map[string]any{"slug": slug, "error": err.Error()}))
|
||
return nil, fmt.Errorf("provision failed: %w", err)
|
||
}
|
||
|
||
endpoints := buildEndpoints(bp)
|
||
rec.Status = meta.StatusPublished
|
||
rec.Endpoints = endpoints
|
||
rec.Error = ""
|
||
rec.UpdatedAt = time.Now().UTC()
|
||
if err := l.svcCtx.Meta.Save(l.ctx, rec); err != nil {
|
||
return nil, err
|
||
}
|
||
action := "publish.success"
|
||
if republish {
|
||
action = "publish.republish"
|
||
}
|
||
detail := map[string]any{
|
||
"slug": slug, "schema": schemaName, "database": dbName, "user_id": userID,
|
||
"republish": republish, "publish_mode": publishMode,
|
||
}
|
||
if mergeRes != nil {
|
||
detail["added_pages"] = mergeRes.AddedPages
|
||
detail["added_entities"] = mergeRes.AddedEntities
|
||
}
|
||
_ = l.audit(action, audit.DetailJSON(detail))
|
||
|
||
// 智能体新建发布:自动把该 slug 写入可访问模块,后续不必再去控制台授权
|
||
if authx.Role(l.ctx) == authx.RoleAgent && l.svcCtx.Agents != nil && slug != "" {
|
||
if aid := authx.AgentID(l.ctx); aid > 0 {
|
||
_ = l.svcCtx.Agents.GrantAppSlug(l.ctx, aid, slug)
|
||
}
|
||
}
|
||
|
||
ownerID := authx.AgentID(l.ctx)
|
||
if ownerID <= 0 {
|
||
ownerID = userID
|
||
}
|
||
secret := l.svcCtx.Config.Agent.CapsuleSecret
|
||
if secret == "" {
|
||
secret = l.svcCtx.JWT.AccessSecret
|
||
}
|
||
accessPath := ""
|
||
accessURL := ""
|
||
if secret != "" && ownerID > 0 {
|
||
_, filePath, err := agentcap.SealModulePath(secret, tenantID, ownerID, slug)
|
||
if err == nil {
|
||
accessPath = filePath
|
||
base := strings.TrimRight(l.svcCtx.Config.PublicBaseURL, "/")
|
||
if req.HostMeta != nil && strings.TrimSpace(req.HostMeta.HostBaseURL) != "" {
|
||
accessURL = strings.TrimRight(strings.TrimSpace(req.HostMeta.HostBaseURL), "/")
|
||
} else if base != "" {
|
||
accessURL = base + "/api/v1/public/" + filePath + "/blueprint"
|
||
}
|
||
}
|
||
}
|
||
|
||
moduleName := bp.Meta.Name
|
||
publishStyle := ""
|
||
if req.HostMeta != nil {
|
||
if n := strings.TrimSpace(req.HostMeta.ModuleName); n != "" {
|
||
moduleName = n
|
||
}
|
||
publishStyle = strings.TrimSpace(req.HostMeta.PublishStyle)
|
||
}
|
||
if publishStyle == "" {
|
||
publishStyle = "immediate"
|
||
}
|
||
|
||
resp := &types.PublishResp{
|
||
AppID: rec.AppID,
|
||
Slug: slug,
|
||
SchemaName: schemaName,
|
||
DatabaseName: dbName,
|
||
Status: string(meta.StatusPublished),
|
||
Endpoints: endpoints,
|
||
DDL: ddl,
|
||
MemoryMode: l.svcCtx.MemoryMode,
|
||
PublishMode: publishMode,
|
||
ModuleName: moduleName,
|
||
PublishStyle: publishStyle,
|
||
AccessPath: accessPath,
|
||
AccessURL: accessURL,
|
||
PublishedAt: rec.UpdatedAt.UTC().Format(time.RFC3339),
|
||
OwnerID: ownerID,
|
||
}
|
||
if mergeRes != nil {
|
||
resp.AddedPages = mergeRes.AddedPages
|
||
resp.AddedEntities = mergeRes.AddedEntities
|
||
resp.AddedResources = mergeRes.AddedResources
|
||
}
|
||
return resp, nil
|
||
}
|
||
|
||
func (l *PublishLogic) audit(action, detail string) error {
|
||
if l.svcCtx.Audit == nil {
|
||
return nil
|
||
}
|
||
return l.svcCtx.Audit.Log(l.ctx, authx.TenantID(l.ctx), authx.UserID(l.ctx), action, detail)
|
||
}
|
||
|
||
func buildEndpoints(bp *blueprint.Blueprint) []string {
|
||
base := strings.TrimRight(bp.Apis.BasePath, "/")
|
||
if base == "" {
|
||
base = "/api/v1/apps/" + bp.Meta.Slug
|
||
}
|
||
out := []string{"GET " + base + "/blueprint"}
|
||
for _, r := range bp.Apis.Resources {
|
||
path := r.Path
|
||
if !strings.HasPrefix(path, "/") {
|
||
path = "/" + path
|
||
}
|
||
full := base + path
|
||
for _, op := range r.Operations {
|
||
switch op {
|
||
case "list":
|
||
out = append(out, "GET "+full)
|
||
case "create":
|
||
out = append(out, "POST "+full)
|
||
case "get":
|
||
out = append(out, "GET "+full+"/{id}")
|
||
case "update":
|
||
out = append(out, "PUT "+full+"/{id}")
|
||
case "delete":
|
||
out = append(out, "DELETE "+full+"/{id}")
|
||
case "import":
|
||
out = append(out, "POST "+full+"/import")
|
||
case "export":
|
||
out = append(out, "GET "+full+"/export")
|
||
}
|
||
}
|
||
}
|
||
return out
|
||
}
|