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:
whm
2026-08-07 17:31:12 +08:00
parent 43dc708f1e
commit 473113682f
13 changed files with 402 additions and 207 deletions

View File

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

View File

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

View File

@@ -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 Z36merge 无增量时,有 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
}