fix: Z39 isolate module data by slug
Keep agent-bound modules in per-app schemas and prevent stale frontend routes from displaying another module's blueprint or rows. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -309,12 +309,14 @@ func (l *CapsuleLogic) Build(slug string) (*types.CapsuleResp, error) {
|
||||
base = fmt.Sprintf("http://127.0.0.1:%d", l.svcCtx.Config.Port)
|
||||
}
|
||||
desc := &agentcap.Descriptor{
|
||||
Version: "1",
|
||||
BaseURL: strings.TrimRight(base, "/"),
|
||||
AppSlug: slug,
|
||||
TenantHint: fmt.Sprintf("t%d", tenantID),
|
||||
Auth: agentcap.AuthSpec{Type: "bearer_jwt", Header: "Authorization"},
|
||||
Notes: "Decrypt with agent_key from /auth/token. Never expose plaintext API map in UI.",
|
||||
Version: "1",
|
||||
BaseURL: strings.TrimRight(base, "/"),
|
||||
AppSlug: slug,
|
||||
TenantHint: fmt.Sprintf("t%d", tenantID),
|
||||
SchemaName: app.SchemaName,
|
||||
BlueprintRevision: app.Blueprint.Revision(),
|
||||
Auth: agentcap.AuthSpec{Type: "bearer_jwt", Header: "Authorization"},
|
||||
Notes: "Decrypt with agent_key from /auth/token. Never expose plaintext API map in UI.",
|
||||
}
|
||||
for _, r := range app.Blueprint.Apis.Resources {
|
||||
path := r.Path
|
||||
@@ -337,11 +339,13 @@ func (l *CapsuleLogic) Build(slug string) (*types.CapsuleResp, error) {
|
||||
}
|
||||
var entityFields []agentcap.FieldSpec
|
||||
pk := "id"
|
||||
table := ""
|
||||
for _, e := range app.Blueprint.Entities {
|
||||
if e.Name != r.Entity {
|
||||
continue
|
||||
}
|
||||
pk = e.PrimaryKey
|
||||
table = e.Table
|
||||
for _, f := range e.Fields {
|
||||
entityFields = append(entityFields, agentcap.FieldSpec{Name: f.Name, Type: f.Type})
|
||||
}
|
||||
@@ -360,6 +364,7 @@ func (l *CapsuleLogic) Build(slug string) (*types.CapsuleResp, error) {
|
||||
Sorts: sorts,
|
||||
Fields: entityFields,
|
||||
PrimaryKey: pk,
|
||||
Table: app.SchemaName + "." + table,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,12 @@ package applogic
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
|
||||
"aijianzhan/platform/internal/authx"
|
||||
"aijianzhan/platform/internal/crud"
|
||||
"aijianzhan/platform/internal/meta"
|
||||
"aijianzhan/platform/internal/svc"
|
||||
"aijianzhan/platform/internal/types"
|
||||
)
|
||||
@@ -40,7 +42,7 @@ func (l *CrudLogic) withOrgScope() context.Context {
|
||||
func (l *CrudLogic) List(slug, resource string, page, pageSize int, filters map[string]string, sortBy string) (*types.PageResult, error) {
|
||||
ctx := l.withOrgScope()
|
||||
tenantID := authx.TenantID(ctx)
|
||||
ref, err := l.svcCtx.Meta.ResolveResource(ctx, tenantID, slug, resource)
|
||||
ref, err := l.resolveResource(ctx, tenantID, slug, resource, "list")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -60,7 +62,7 @@ func (l *CrudLogic) List(slug, resource string, page, pageSize int, filters map[
|
||||
func (l *CrudLogic) Get(slug, resource, id string) (map[string]any, error) {
|
||||
ctx := l.withOrgScope()
|
||||
tenantID := authx.TenantID(ctx)
|
||||
ref, err := l.svcCtx.Meta.ResolveResource(ctx, tenantID, slug, resource)
|
||||
ref, err := l.resolveResource(ctx, tenantID, slug, resource, "get")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -71,7 +73,7 @@ func (l *CrudLogic) Create(slug, resource string, body map[string]any) (map[stri
|
||||
ctx := l.withOrgScope()
|
||||
tenantID := authx.TenantID(ctx)
|
||||
userID := authx.UserID(ctx)
|
||||
ref, err := l.svcCtx.Meta.ResolveResource(ctx, tenantID, slug, resource)
|
||||
ref, err := l.resolveResource(ctx, tenantID, slug, resource, "create")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -81,7 +83,7 @@ func (l *CrudLogic) Create(slug, resource string, body map[string]any) (map[stri
|
||||
func (l *CrudLogic) Update(slug, resource, id string, body map[string]any) (map[string]any, error) {
|
||||
ctx := l.withOrgScope()
|
||||
tenantID := authx.TenantID(ctx)
|
||||
ref, err := l.svcCtx.Meta.ResolveResource(ctx, tenantID, slug, resource)
|
||||
ref, err := l.resolveResource(ctx, tenantID, slug, resource, "update")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -91,13 +93,31 @@ func (l *CrudLogic) Update(slug, resource, id string, body map[string]any) (map[
|
||||
func (l *CrudLogic) Delete(slug, resource, id string) error {
|
||||
ctx := l.withOrgScope()
|
||||
tenantID := authx.TenantID(ctx)
|
||||
ref, err := l.svcCtx.Meta.ResolveResource(ctx, tenantID, slug, resource)
|
||||
ref, err := l.resolveResource(ctx, tenantID, slug, resource, "delete")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return l.svcCtx.CRUD.Delete(ctx, ref, tenantID, id)
|
||||
}
|
||||
|
||||
func (l *CrudLogic) resolveResource(ctx context.Context, tenantID int64, slug, resource, operation string) (*meta.ResourceRef, error) {
|
||||
ref, err := l.svcCtx.Meta.ResolveResource(ctx, tenantID, slug, resource)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ref == nil || ref.App == nil || ref.App.Slug != slug {
|
||||
return nil, fmt.Errorf("resource isolation mismatch for app %s", slug)
|
||||
}
|
||||
if ref.App.SchemaName == "" || ref.Entity.Table == "" {
|
||||
return nil, fmt.Errorf("resource storage mapping missing for app %s", slug)
|
||||
}
|
||||
log.Printf(
|
||||
"app_resource op=%s tenant=%d requested_slug=%s resource=%s resolved_schema=%s resolved_table=%s blueprint_revision=%s",
|
||||
operation, tenantID, slug, resource, ref.App.SchemaName, ref.Entity.Table, ref.App.Blueprint.Revision(),
|
||||
)
|
||||
return ref, nil
|
||||
}
|
||||
|
||||
func (l *CrudLogic) GetBlueprint(slug string) (any, error) {
|
||||
tenantID := authx.TenantID(l.ctx)
|
||||
app, err := l.svcCtx.Meta.GetBySlug(l.ctx, tenantID, slug)
|
||||
|
||||
@@ -230,11 +230,18 @@ func (l *PublishLogic) Publish(slug string, req *types.PublishReq) (*types.Publi
|
||||
mergeRes, err = blueprint.MergeInto(bp, incoming)
|
||||
if err != nil {
|
||||
if isNothingNewToPublish(err) {
|
||||
return l.handleNoBlueprintDelta(existing, req, slug, tenantID, userID)
|
||||
if needsSchemaIsolation(existing, authx.Role(l.ctx)) {
|
||||
publishMode = "storage_isolated"
|
||||
mergeRes = nil
|
||||
} else {
|
||||
return l.handleNoBlueprintDelta(existing, req, slug, tenantID, userID)
|
||||
}
|
||||
} else {
|
||||
return nil, err
|
||||
}
|
||||
return nil, err
|
||||
} else {
|
||||
publishMode = "pages_added"
|
||||
}
|
||||
publishMode = "pages_added"
|
||||
case mode == "replace":
|
||||
bp = incoming
|
||||
if exists {
|
||||
@@ -253,11 +260,18 @@ func (l *PublishLogic) Publish(slug string, req *types.PublishReq) (*types.Publi
|
||||
mergeRes, err = blueprint.MergeInto(bp, incoming)
|
||||
if err != nil {
|
||||
if isNothingNewToPublish(err) {
|
||||
return l.handleNoBlueprintDelta(existing, req, slug, tenantID, userID)
|
||||
if needsSchemaIsolation(existing, authx.Role(l.ctx)) {
|
||||
publishMode = "storage_isolated"
|
||||
mergeRes = nil
|
||||
} else {
|
||||
return l.handleNoBlueprintDelta(existing, req, slug, tenantID, userID)
|
||||
}
|
||||
} else {
|
||||
return nil, err
|
||||
}
|
||||
return nil, err
|
||||
} else {
|
||||
publishMode = "pages_added"
|
||||
}
|
||||
publishMode = "pages_added"
|
||||
} else {
|
||||
bp = incoming
|
||||
publishMode = "created"
|
||||
@@ -278,26 +292,37 @@ func (l *PublishLogic) Publish(slug string, req *types.PublishReq) (*types.Publi
|
||||
|
||||
schemaName := bp.AssignSchemaName(tenantID)
|
||||
dbName := bp.AssignDatabaseName(tenantID)
|
||||
// Z8c:智能体若绑定了合法 database_name,新建模块优先落到该库(database_per_app)
|
||||
boundAgentDatabase := false
|
||||
// Z8c/Z39:智能体绑定库可由多个模块共享,因此库内仍须按 tenant+slug 分 Schema,
|
||||
// 不能把所有模块都放进 public 并按同名 entity table 复用。
|
||||
// Z35:中文昵称等非法名不得用于 EnsureDatabase,回落 AssignDatabaseName
|
||||
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 != "" && schema.ValidDBName(dn) {
|
||||
if !exists || existing.DatabaseName == "" {
|
||||
bp.Storage.Mode = "database_per_app"
|
||||
schemaName = bp.AssignSchemaName(tenantID)
|
||||
dbName = dn
|
||||
bp.Storage.Mode = "schema_per_app"
|
||||
schemaName = bp.AssignSchemaName(tenantID)
|
||||
dbName = dn
|
||||
if exists && existing.DatabaseName != "" {
|
||||
dbName = existing.DatabaseName
|
||||
}
|
||||
boundAgentDatabase = true
|
||||
} else if dn != "" && !schema.ValidDBName(dn) {
|
||||
// 历史脏数据(如 DisplayName 当库名):纠正智能体落点库名,避免反复 400
|
||||
safeName := schema.SanitizeDBName(tenantID, slug)
|
||||
_, _ = l.svcCtx.Agents.Update(l.ctx, tenantID, aid, agentstore.UpdateInput{
|
||||
DatabaseName: &safeName,
|
||||
})
|
||||
bp.Storage.Mode = "database_per_app"
|
||||
bp.Storage.Mode = "schema_per_app"
|
||||
schemaName = bp.AssignSchemaName(tenantID)
|
||||
dbName = safeName
|
||||
boundAgentDatabase = true
|
||||
} else if exists && existing.DatabaseName != "" {
|
||||
// 已发布模块仍位于智能体绑定库;重发时一并升级旧 public Schema。
|
||||
bp.Storage.Mode = "schema_per_app"
|
||||
schemaName = bp.AssignSchemaName(tenantID)
|
||||
dbName = existing.DatabaseName
|
||||
boundAgentDatabase = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -314,7 +339,9 @@ func (l *PublishLogic) Publish(slug string, req *types.PublishReq) (*types.Publi
|
||||
appID = existing.AppID
|
||||
now = existing.CreatedAt
|
||||
republish = existing.Status == meta.StatusPublished
|
||||
if existing.SchemaName != "" {
|
||||
// Z39:旧版把智能体同库下的所有模块放入 public,导致同名资源串表。
|
||||
// 重发时切到独立 app_t{tenant}_{slug};数据由既有双向同步重新灌入。
|
||||
if existing.SchemaName != "" && !(boundAgentDatabase && existing.SchemaName == "public") {
|
||||
schemaName = existing.SchemaName
|
||||
bp.Storage.SchemaName = existing.SchemaName
|
||||
}
|
||||
@@ -415,6 +442,7 @@ func (l *PublishLogic) Publish(slug string, req *types.PublishReq) (*types.Publi
|
||||
detail := map[string]any{
|
||||
"slug": slug, "schema": schemaName, "database": dbName, "user_id": userID,
|
||||
"republish": republish, "publish_mode": publishMode,
|
||||
"blueprint_revision": bp.Revision(), "resource_tables": resourceTables(bp),
|
||||
}
|
||||
if mergeRes != nil {
|
||||
detail["added_pages"] = mergeRes.AddedPages
|
||||
@@ -465,21 +493,23 @@ func (l *PublishLogic) Publish(slug string, req *types.PublishReq) (*types.Publi
|
||||
}
|
||||
|
||||
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,
|
||||
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,
|
||||
BlueprintRevision: bp.Revision(),
|
||||
ResourceTables: resourceTables(bp),
|
||||
}
|
||||
if mergeRes != nil {
|
||||
resp.AddedPages = mergeRes.AddedPages
|
||||
@@ -500,6 +530,13 @@ func isNothingNewToPublish(err error) bool {
|
||||
return err != nil && strings.Contains(err.Error(), "nothing new to publish")
|
||||
}
|
||||
|
||||
func needsSchemaIsolation(existing *meta.AppRecord, role string) bool {
|
||||
return role == authx.RoleAgent &&
|
||||
existing != nil &&
|
||||
existing.DatabaseName != "" &&
|
||||
existing.SchemaName == "public"
|
||||
}
|
||||
|
||||
// handleNoBlueprintDelta Z36:merge 无增量时,有 module_name 则仅更新 host_meta;否则中文业务错误。
|
||||
func (l *PublishLogic) handleNoBlueprintDelta(
|
||||
existing *meta.AppRecord,
|
||||
@@ -585,20 +622,22 @@ func (l *PublishLogic) publishHostMetaOnly(
|
||||
endpoints = buildEndpoints(bp)
|
||||
}
|
||||
return &types.PublishResp{
|
||||
AppID: rec.AppID,
|
||||
Slug: slug,
|
||||
SchemaName: rec.SchemaName,
|
||||
DatabaseName: rec.DatabaseName,
|
||||
Status: string(rec.Status),
|
||||
Endpoints: endpoints,
|
||||
MemoryMode: l.svcCtx.MemoryMode,
|
||||
PublishMode: "host_meta_updated",
|
||||
ModuleName: moduleName,
|
||||
PublishStyle: publishStyle,
|
||||
AccessPath: accessPath,
|
||||
AccessURL: accessURL,
|
||||
PublishedAt: rec.UpdatedAt.UTC().Format(time.RFC3339),
|
||||
OwnerID: ownerID,
|
||||
AppID: rec.AppID,
|
||||
Slug: slug,
|
||||
SchemaName: rec.SchemaName,
|
||||
DatabaseName: rec.DatabaseName,
|
||||
Status: string(rec.Status),
|
||||
Endpoints: endpoints,
|
||||
MemoryMode: l.svcCtx.MemoryMode,
|
||||
PublishMode: "host_meta_updated",
|
||||
ModuleName: moduleName,
|
||||
PublishStyle: publishStyle,
|
||||
AccessPath: accessPath,
|
||||
AccessURL: accessURL,
|
||||
PublishedAt: rec.UpdatedAt.UTC().Format(time.RFC3339),
|
||||
OwnerID: ownerID,
|
||||
BlueprintRevision: bp.Revision(),
|
||||
ResourceTables: resourceTables(bp),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -635,3 +674,18 @@ func buildEndpoints(bp *blueprint.Blueprint) []string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func resourceTables(bp *blueprint.Blueprint) map[string]string {
|
||||
out := make(map[string]string, len(bp.Apis.Resources))
|
||||
entities := make(map[string]string, len(bp.Entities))
|
||||
for _, entity := range bp.Entities {
|
||||
entities[entity.Name] = entity.Table
|
||||
}
|
||||
for _, resource := range bp.Apis.Resources {
|
||||
path := strings.TrimPrefix(resource.Path, "/")
|
||||
if table := entities[resource.Entity]; path != "" && table != "" {
|
||||
out[path] = bp.Storage.SchemaName + "." + table
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user