chore: initial commit of ai site platform

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
whm
2026-07-31 10:31:17 +08:00
commit 4ca82fb58a
203 changed files with 45745 additions and 0 deletions

View File

@@ -0,0 +1,470 @@
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,
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.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"
}
}
if err := bp.Validate(slug); err != nil {
return nil, err
}
schemaName := bp.AssignSchemaName(tenantID)
dbName := bp.AssignDatabaseName(tenantID)
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)
}
}
}
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
}