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:
@@ -22,6 +22,8 @@ type Descriptor struct {
|
|||||||
BaseURL string `json:"base_url"`
|
BaseURL string `json:"base_url"`
|
||||||
AppSlug string `json:"app_slug"`
|
AppSlug string `json:"app_slug"`
|
||||||
TenantHint string `json:"tenant_hint,omitempty"`
|
TenantHint string `json:"tenant_hint,omitempty"`
|
||||||
|
SchemaName string `json:"schema_name,omitempty"`
|
||||||
|
BlueprintRevision string `json:"blueprint_revision,omitempty"`
|
||||||
Auth AuthSpec `json:"auth"`
|
Auth AuthSpec `json:"auth"`
|
||||||
Resources []ResourceSpec `json:"resources"`
|
Resources []ResourceSpec `json:"resources"`
|
||||||
Notes string `json:"notes,omitempty"`
|
Notes string `json:"notes,omitempty"`
|
||||||
@@ -40,6 +42,7 @@ type ResourceSpec struct {
|
|||||||
Sorts []string `json:"sorts,omitempty"`
|
Sorts []string `json:"sorts,omitempty"`
|
||||||
Fields []FieldSpec `json:"fields,omitempty"`
|
Fields []FieldSpec `json:"fields,omitempty"`
|
||||||
PrimaryKey string `json:"primary_key"`
|
PrimaryKey string `json:"primary_key"`
|
||||||
|
Table string `json:"table,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type FieldSpec struct {
|
type FieldSpec struct {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package blueprint
|
package blueprint
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
@@ -271,10 +272,7 @@ func (bp *Blueprint) AssignSchemaName(tenantID int64) string {
|
|||||||
bp.Storage.SchemaName = "public"
|
bp.Storage.SchemaName = "public"
|
||||||
return "public"
|
return "public"
|
||||||
}
|
}
|
||||||
name := fmt.Sprintf("app_t%d_%s", tenantID, bp.Meta.Slug)
|
name := scopedIdent(fmt.Sprintf("app_t%d_%s", tenantID, bp.Meta.Slug))
|
||||||
if len(name) > 48 {
|
|
||||||
name = name[:48]
|
|
||||||
}
|
|
||||||
bp.Storage.SchemaName = name
|
bp.Storage.SchemaName = name
|
||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
@@ -284,11 +282,30 @@ func (bp *Blueprint) AssignDatabaseName(tenantID int64) string {
|
|||||||
if bp.Storage.Mode != "database_per_app" {
|
if bp.Storage.Mode != "database_per_app" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
name := fmt.Sprintf("appdb_t%d_%s", tenantID, bp.Meta.Slug)
|
return scopedIdent(fmt.Sprintf("appdb_t%d_%s", tenantID, bp.Meta.Slug))
|
||||||
if len(name) > 48 {
|
}
|
||||||
name = name[:48]
|
|
||||||
|
// scopedIdent 保留可读前缀,并为超长标识符附加内容哈希,避免不同 slug 截断后碰撞。
|
||||||
|
func scopedIdent(raw string) string {
|
||||||
|
if len(raw) <= 48 {
|
||||||
|
return raw
|
||||||
}
|
}
|
||||||
return name
|
sum := sha256.Sum256([]byte(raw))
|
||||||
|
suffix := fmt.Sprintf("_%x", sum[:6])
|
||||||
|
return raw[:48-len(suffix)] + suffix
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revision 返回蓝图内容的稳定短哈希,用于发布回执、缓存隔离与诊断日志。
|
||||||
|
func (bp *Blueprint) Revision() string {
|
||||||
|
if bp == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(bp)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(raw)
|
||||||
|
return fmt.Sprintf("%x", sum[:8])
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkIdent(field, v string) error {
|
func checkIdent(field, v string) error {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package blueprint
|
package blueprint
|
||||||
|
|
||||||
import "testing"
|
import "testing"
|
||||||
|
|
||||||
@@ -18,6 +18,32 @@ func TestNormalizeIdent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAssignSchemaNameKeepsLongSlugsDistinct(t *testing.T) {
|
||||||
|
prefix := "module_with_a_very_long_shared_slug_prefix_that_used_to_collide_"
|
||||||
|
a := &Blueprint{Meta: Meta{Slug: prefix + "a"}, Storage: Storage{Mode: "schema_per_app"}}
|
||||||
|
b := &Blueprint{Meta: Meta{Slug: prefix + "b"}, Storage: Storage{Mode: "schema_per_app"}}
|
||||||
|
|
||||||
|
gotA := a.AssignSchemaName(12)
|
||||||
|
gotB := b.AssignSchemaName(12)
|
||||||
|
if gotA == gotB {
|
||||||
|
t.Fatalf("different slugs resolved to the same schema: %q", gotA)
|
||||||
|
}
|
||||||
|
if len(gotA) > 48 || len(gotB) > 48 {
|
||||||
|
t.Fatalf("schema names exceed identifier limit: %q / %q", gotA, gotB)
|
||||||
|
}
|
||||||
|
if gotA != a.AssignSchemaName(12) {
|
||||||
|
t.Fatalf("schema name is not deterministic: %q", gotA)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRevisionChangesWithSlug(t *testing.T) {
|
||||||
|
a := &Blueprint{Version: "1.0", Meta: Meta{Slug: "whm1"}}
|
||||||
|
b := &Blueprint{Version: "1.0", Meta: Meta{Slug: "whm11"}}
|
||||||
|
if a.Revision() == "" || a.Revision() == b.Revision() {
|
||||||
|
t.Fatalf("revision must include blueprint slug: %q / %q", a.Revision(), b.Revision())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSanitizeFields(t *testing.T) {
|
func TestSanitizeFields(t *testing.T) {
|
||||||
bp := &Blueprint{
|
bp := &Blueprint{
|
||||||
Version: "1.0",
|
Version: "1.0",
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"aijianzhan/platform/internal/agentcap"
|
||||||
"aijianzhan/platform/internal/apidef"
|
"aijianzhan/platform/internal/apidef"
|
||||||
"aijianzhan/platform/internal/audit"
|
"aijianzhan/platform/internal/audit"
|
||||||
"aijianzhan/platform/internal/authx"
|
"aijianzhan/platform/internal/authx"
|
||||||
"aijianzhan/platform/internal/agentcap"
|
|
||||||
"aijianzhan/platform/internal/logic/applogic"
|
"aijianzhan/platform/internal/logic/applogic"
|
||||||
"aijianzhan/platform/internal/meta"
|
"aijianzhan/platform/internal/meta"
|
||||||
"aijianzhan/platform/internal/svc"
|
"aijianzhan/platform/internal/svc"
|
||||||
@@ -1069,6 +1069,9 @@ func publicBlueprintHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|||||||
authx.WriteError(w, http.StatusNotFound, "blueprint missing")
|
authx.WriteError(w, http.StatusNotFound, "blueprint missing")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.Header().Set("X-AJZ-App-Slug", app.Slug)
|
||||||
|
w.Header().Set("X-AJZ-Blueprint-Revision", app.Blueprint.Revision())
|
||||||
httpx.OkJson(w, app.Blueprint)
|
httpx.OkJson(w, app.Blueprint)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1122,6 +1125,12 @@ func publicListHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|||||||
authx.WriteError(w, http.StatusBadRequest, err.Error())
|
authx.WriteError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.Header().Set("X-AJZ-App-Slug", app.Slug)
|
||||||
|
w.Header().Set("X-AJZ-Resolved-Schema", app.SchemaName)
|
||||||
|
if app.Blueprint != nil {
|
||||||
|
w.Header().Set("X-AJZ-Blueprint-Revision", app.Blueprint.Revision())
|
||||||
|
}
|
||||||
httpx.OkJson(w, resp)
|
httpx.OkJson(w, resp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -313,6 +313,8 @@ func (l *CapsuleLogic) Build(slug string) (*types.CapsuleResp, error) {
|
|||||||
BaseURL: strings.TrimRight(base, "/"),
|
BaseURL: strings.TrimRight(base, "/"),
|
||||||
AppSlug: slug,
|
AppSlug: slug,
|
||||||
TenantHint: fmt.Sprintf("t%d", tenantID),
|
TenantHint: fmt.Sprintf("t%d", tenantID),
|
||||||
|
SchemaName: app.SchemaName,
|
||||||
|
BlueprintRevision: app.Blueprint.Revision(),
|
||||||
Auth: agentcap.AuthSpec{Type: "bearer_jwt", Header: "Authorization"},
|
Auth: agentcap.AuthSpec{Type: "bearer_jwt", Header: "Authorization"},
|
||||||
Notes: "Decrypt with agent_key from /auth/token. Never expose plaintext API map in UI.",
|
Notes: "Decrypt with agent_key from /auth/token. Never expose plaintext API map in UI.",
|
||||||
}
|
}
|
||||||
@@ -337,11 +339,13 @@ func (l *CapsuleLogic) Build(slug string) (*types.CapsuleResp, error) {
|
|||||||
}
|
}
|
||||||
var entityFields []agentcap.FieldSpec
|
var entityFields []agentcap.FieldSpec
|
||||||
pk := "id"
|
pk := "id"
|
||||||
|
table := ""
|
||||||
for _, e := range app.Blueprint.Entities {
|
for _, e := range app.Blueprint.Entities {
|
||||||
if e.Name != r.Entity {
|
if e.Name != r.Entity {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
pk = e.PrimaryKey
|
pk = e.PrimaryKey
|
||||||
|
table = e.Table
|
||||||
for _, f := range e.Fields {
|
for _, f := range e.Fields {
|
||||||
entityFields = append(entityFields, agentcap.FieldSpec{Name: f.Name, Type: f.Type})
|
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,
|
Sorts: sorts,
|
||||||
Fields: entityFields,
|
Fields: entityFields,
|
||||||
PrimaryKey: pk,
|
PrimaryKey: pk,
|
||||||
|
Table: app.SchemaName + "." + table,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ package applogic
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"aijianzhan/platform/internal/authx"
|
"aijianzhan/platform/internal/authx"
|
||||||
"aijianzhan/platform/internal/crud"
|
"aijianzhan/platform/internal/crud"
|
||||||
|
"aijianzhan/platform/internal/meta"
|
||||||
"aijianzhan/platform/internal/svc"
|
"aijianzhan/platform/internal/svc"
|
||||||
"aijianzhan/platform/internal/types"
|
"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) {
|
func (l *CrudLogic) List(slug, resource string, page, pageSize int, filters map[string]string, sortBy string) (*types.PageResult, error) {
|
||||||
ctx := l.withOrgScope()
|
ctx := l.withOrgScope()
|
||||||
tenantID := authx.TenantID(ctx)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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) {
|
func (l *CrudLogic) Get(slug, resource, id string) (map[string]any, error) {
|
||||||
ctx := l.withOrgScope()
|
ctx := l.withOrgScope()
|
||||||
tenantID := authx.TenantID(ctx)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -71,7 +73,7 @@ func (l *CrudLogic) Create(slug, resource string, body map[string]any) (map[stri
|
|||||||
ctx := l.withOrgScope()
|
ctx := l.withOrgScope()
|
||||||
tenantID := authx.TenantID(ctx)
|
tenantID := authx.TenantID(ctx)
|
||||||
userID := authx.UserID(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 {
|
if err != nil {
|
||||||
return nil, err
|
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) {
|
func (l *CrudLogic) Update(slug, resource, id string, body map[string]any) (map[string]any, error) {
|
||||||
ctx := l.withOrgScope()
|
ctx := l.withOrgScope()
|
||||||
tenantID := authx.TenantID(ctx)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
func (l *CrudLogic) Delete(slug, resource, id string) error {
|
||||||
ctx := l.withOrgScope()
|
ctx := l.withOrgScope()
|
||||||
tenantID := authx.TenantID(ctx)
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return l.svcCtx.CRUD.Delete(ctx, ref, tenantID, id)
|
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) {
|
func (l *CrudLogic) GetBlueprint(slug string) (any, error) {
|
||||||
tenantID := authx.TenantID(l.ctx)
|
tenantID := authx.TenantID(l.ctx)
|
||||||
app, err := l.svcCtx.Meta.GetBySlug(l.ctx, tenantID, slug)
|
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)
|
mergeRes, err = blueprint.MergeInto(bp, incoming)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if isNothingNewToPublish(err) {
|
if isNothingNewToPublish(err) {
|
||||||
|
if needsSchemaIsolation(existing, authx.Role(l.ctx)) {
|
||||||
|
publishMode = "storage_isolated"
|
||||||
|
mergeRes = nil
|
||||||
|
} else {
|
||||||
return l.handleNoBlueprintDelta(existing, req, slug, tenantID, userID)
|
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":
|
case mode == "replace":
|
||||||
bp = incoming
|
bp = incoming
|
||||||
if exists {
|
if exists {
|
||||||
@@ -253,11 +260,18 @@ func (l *PublishLogic) Publish(slug string, req *types.PublishReq) (*types.Publi
|
|||||||
mergeRes, err = blueprint.MergeInto(bp, incoming)
|
mergeRes, err = blueprint.MergeInto(bp, incoming)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if isNothingNewToPublish(err) {
|
if isNothingNewToPublish(err) {
|
||||||
|
if needsSchemaIsolation(existing, authx.Role(l.ctx)) {
|
||||||
|
publishMode = "storage_isolated"
|
||||||
|
mergeRes = nil
|
||||||
|
} else {
|
||||||
return l.handleNoBlueprintDelta(existing, req, slug, tenantID, userID)
|
return l.handleNoBlueprintDelta(existing, req, slug, tenantID, userID)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
publishMode = "pages_added"
|
publishMode = "pages_added"
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
bp = incoming
|
bp = incoming
|
||||||
publishMode = "created"
|
publishMode = "created"
|
||||||
@@ -278,26 +292,37 @@ func (l *PublishLogic) Publish(slug string, req *types.PublishReq) (*types.Publi
|
|||||||
|
|
||||||
schemaName := bp.AssignSchemaName(tenantID)
|
schemaName := bp.AssignSchemaName(tenantID)
|
||||||
dbName := bp.AssignDatabaseName(tenantID)
|
dbName := bp.AssignDatabaseName(tenantID)
|
||||||
// Z8c:智能体若绑定了合法 database_name,新建模块优先落到该库(database_per_app)
|
boundAgentDatabase := false
|
||||||
|
// Z8c/Z39:智能体绑定库可由多个模块共享,因此库内仍须按 tenant+slug 分 Schema,
|
||||||
|
// 不能把所有模块都放进 public 并按同名 entity table 复用。
|
||||||
// Z35:中文昵称等非法名不得用于 EnsureDatabase,回落 AssignDatabaseName
|
// Z35:中文昵称等非法名不得用于 EnsureDatabase,回落 AssignDatabaseName
|
||||||
if authx.Role(l.ctx) == authx.RoleAgent && l.svcCtx.Agents != nil {
|
if authx.Role(l.ctx) == authx.RoleAgent && l.svcCtx.Agents != nil {
|
||||||
if aid := authx.AgentID(l.ctx); aid > 0 {
|
if aid := authx.AgentID(l.ctx); aid > 0 {
|
||||||
if acc, err := l.svcCtx.Agents.Get(l.ctx, tenantID, aid); err == nil && acc != nil {
|
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 dn := strings.TrimSpace(acc.DatabaseName); dn != "" && schema.ValidDBName(dn) {
|
||||||
if !exists || existing.DatabaseName == "" {
|
bp.Storage.Mode = "schema_per_app"
|
||||||
bp.Storage.Mode = "database_per_app"
|
|
||||||
schemaName = bp.AssignSchemaName(tenantID)
|
schemaName = bp.AssignSchemaName(tenantID)
|
||||||
dbName = dn
|
dbName = dn
|
||||||
|
if exists && existing.DatabaseName != "" {
|
||||||
|
dbName = existing.DatabaseName
|
||||||
}
|
}
|
||||||
|
boundAgentDatabase = true
|
||||||
} else if dn != "" && !schema.ValidDBName(dn) {
|
} else if dn != "" && !schema.ValidDBName(dn) {
|
||||||
// 历史脏数据(如 DisplayName 当库名):纠正智能体落点库名,避免反复 400
|
// 历史脏数据(如 DisplayName 当库名):纠正智能体落点库名,避免反复 400
|
||||||
safeName := schema.SanitizeDBName(tenantID, slug)
|
safeName := schema.SanitizeDBName(tenantID, slug)
|
||||||
_, _ = l.svcCtx.Agents.Update(l.ctx, tenantID, aid, agentstore.UpdateInput{
|
_, _ = l.svcCtx.Agents.Update(l.ctx, tenantID, aid, agentstore.UpdateInput{
|
||||||
DatabaseName: &safeName,
|
DatabaseName: &safeName,
|
||||||
})
|
})
|
||||||
bp.Storage.Mode = "database_per_app"
|
bp.Storage.Mode = "schema_per_app"
|
||||||
schemaName = bp.AssignSchemaName(tenantID)
|
schemaName = bp.AssignSchemaName(tenantID)
|
||||||
dbName = safeName
|
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
|
appID = existing.AppID
|
||||||
now = existing.CreatedAt
|
now = existing.CreatedAt
|
||||||
republish = existing.Status == meta.StatusPublished
|
republish = existing.Status == meta.StatusPublished
|
||||||
if existing.SchemaName != "" {
|
// Z39:旧版把智能体同库下的所有模块放入 public,导致同名资源串表。
|
||||||
|
// 重发时切到独立 app_t{tenant}_{slug};数据由既有双向同步重新灌入。
|
||||||
|
if existing.SchemaName != "" && !(boundAgentDatabase && existing.SchemaName == "public") {
|
||||||
schemaName = existing.SchemaName
|
schemaName = existing.SchemaName
|
||||||
bp.Storage.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{
|
detail := map[string]any{
|
||||||
"slug": slug, "schema": schemaName, "database": dbName, "user_id": userID,
|
"slug": slug, "schema": schemaName, "database": dbName, "user_id": userID,
|
||||||
"republish": republish, "publish_mode": publishMode,
|
"republish": republish, "publish_mode": publishMode,
|
||||||
|
"blueprint_revision": bp.Revision(), "resource_tables": resourceTables(bp),
|
||||||
}
|
}
|
||||||
if mergeRes != nil {
|
if mergeRes != nil {
|
||||||
detail["added_pages"] = mergeRes.AddedPages
|
detail["added_pages"] = mergeRes.AddedPages
|
||||||
@@ -480,6 +508,8 @@ func (l *PublishLogic) Publish(slug string, req *types.PublishReq) (*types.Publi
|
|||||||
AccessURL: accessURL,
|
AccessURL: accessURL,
|
||||||
PublishedAt: rec.UpdatedAt.UTC().Format(time.RFC3339),
|
PublishedAt: rec.UpdatedAt.UTC().Format(time.RFC3339),
|
||||||
OwnerID: ownerID,
|
OwnerID: ownerID,
|
||||||
|
BlueprintRevision: bp.Revision(),
|
||||||
|
ResourceTables: resourceTables(bp),
|
||||||
}
|
}
|
||||||
if mergeRes != nil {
|
if mergeRes != nil {
|
||||||
resp.AddedPages = mergeRes.AddedPages
|
resp.AddedPages = mergeRes.AddedPages
|
||||||
@@ -500,6 +530,13 @@ func isNothingNewToPublish(err error) bool {
|
|||||||
return err != nil && strings.Contains(err.Error(), "nothing new to publish")
|
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;否则中文业务错误。
|
// handleNoBlueprintDelta Z36:merge 无增量时,有 module_name 则仅更新 host_meta;否则中文业务错误。
|
||||||
func (l *PublishLogic) handleNoBlueprintDelta(
|
func (l *PublishLogic) handleNoBlueprintDelta(
|
||||||
existing *meta.AppRecord,
|
existing *meta.AppRecord,
|
||||||
@@ -599,6 +636,8 @@ func (l *PublishLogic) publishHostMetaOnly(
|
|||||||
AccessURL: accessURL,
|
AccessURL: accessURL,
|
||||||
PublishedAt: rec.UpdatedAt.UTC().Format(time.RFC3339),
|
PublishedAt: rec.UpdatedAt.UTC().Format(time.RFC3339),
|
||||||
OwnerID: ownerID,
|
OwnerID: ownerID,
|
||||||
|
BlueprintRevision: bp.Revision(),
|
||||||
|
ResourceTables: resourceTables(bp),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -635,3 +674,18 @@ func buildEndpoints(bp *blueprint.Blueprint) []string {
|
|||||||
}
|
}
|
||||||
return out
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,10 +29,12 @@ type PublishResp struct {
|
|||||||
Endpoints []string `json:"endpoints"`
|
Endpoints []string `json:"endpoints"`
|
||||||
DDL []string `json:"ddl,omitempty"`
|
DDL []string `json:"ddl,omitempty"`
|
||||||
MemoryMode bool `json:"memory_mode"`
|
MemoryMode bool `json:"memory_mode"`
|
||||||
PublishMode string `json:"publish_mode"` // created | pages_added | replaced | host_meta_updated
|
PublishMode string `json:"publish_mode"` // created | pages_added | replaced | host_meta_updated | storage_isolated
|
||||||
AddedPages []string `json:"added_pages,omitempty"`
|
AddedPages []string `json:"added_pages,omitempty"`
|
||||||
AddedEntities []string `json:"added_entities,omitempty"`
|
AddedEntities []string `json:"added_entities,omitempty"`
|
||||||
AddedResources []string `json:"added_resources,omitempty"`
|
AddedResources []string `json:"added_resources,omitempty"`
|
||||||
|
BlueprintRevision string `json:"blueprint_revision,omitempty"`
|
||||||
|
ResourceTables map[string]string `json:"resource_tables,omitempty"`
|
||||||
// 宿主建站回执(对应「AI 表格数据」)
|
// 宿主建站回执(对应「AI 表格数据」)
|
||||||
ModuleName string `json:"module_name,omitempty"`
|
ModuleName string `json:"module_name,omitempty"`
|
||||||
PublishStyle string `json:"publish_style,omitempty"`
|
PublishStyle string `json:"publish_style,omitempty"`
|
||||||
@@ -154,7 +156,6 @@ type RoleUpdateReq struct {
|
|||||||
Permissions *[]string `json:"permissions,omitempty"`
|
Permissions *[]string `json:"permissions,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// AgentSelfRegisterReq 宿主首次连接自注册(公开)。
|
// AgentSelfRegisterReq 宿主首次连接自注册(公开)。
|
||||||
type AgentSelfRegisterReq struct {
|
type AgentSelfRegisterReq struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
2
web/dist/index.html
vendored
2
web/dist/index.html
vendored
@@ -7,7 +7,7 @@
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,600;9..144,700&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,600;9..144,700&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
<script type="module" crossorigin src="/assets/index-aOZIXFeC.js"></script>
|
<script type="module" crossorigin src="/assets/index-Pe8anoMp.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CtqUfnO-.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-CtqUfnO-.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ export default function App() {
|
|||||||
const excelInputRef = useRef<HTMLInputElement>(null);
|
const excelInputRef = useRef<HTMLInputElement>(null);
|
||||||
const imagesInputRef = useRef<HTMLInputElement>(null);
|
const imagesInputRef = useRef<HTMLInputElement>(null);
|
||||||
const layoutsInputRef = useRef<HTMLInputElement>(null);
|
const layoutsInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const routeRequestRef = useRef(0);
|
||||||
const [storageMode, setStorageMode] = useState<"schema_per_app" | "database_per_app">("schema_per_app");
|
const [storageMode, setStorageMode] = useState<"schema_per_app" | "database_per_app">("schema_per_app");
|
||||||
const [llmProvider, setLlmProvider] = useState("deepseek");
|
const [llmProvider, setLlmProvider] = useState("deepseek");
|
||||||
const [llmModel, setLlmModel] = useState("deepseek-chat");
|
const [llmModel, setLlmModel] = useState("deepseek-chat");
|
||||||
@@ -226,22 +227,25 @@ export default function App() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
async function syncRoute() {
|
async function syncRoute() {
|
||||||
|
const requestID = ++routeRequestRef.current;
|
||||||
|
const isStale = () => cancelled || requestID !== routeRequestRef.current;
|
||||||
const pid = previewRouteId();
|
const pid = previewRouteId();
|
||||||
if (pid) {
|
if (pid) {
|
||||||
if (!cancelled) {
|
if (!isStale()) {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError("");
|
setError("");
|
||||||
|
setBlueprint(null);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const pack = await fetchPreview(pid);
|
const pack = await fetchPreview(pid);
|
||||||
if (cancelled) return;
|
if (isStale()) return;
|
||||||
setBlueprint(pack.blueprint);
|
setBlueprint(pack.blueprint);
|
||||||
setDraft(pack.blueprint);
|
setDraft(pack.blueprint);
|
||||||
setSlug(pack.blueprint?.meta?.slug || "preview");
|
setSlug(pack.blueprint?.meta?.slug || "preview");
|
||||||
setResource(pack.resource || "records");
|
setResource(pack.resource || "records");
|
||||||
setView("app");
|
setView("app");
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (!cancelled) {
|
if (!isStale()) {
|
||||||
clearActivePreview();
|
clearActivePreview();
|
||||||
setError(e.message || String(e));
|
setError(e.message || String(e));
|
||||||
setView("console");
|
setView("console");
|
||||||
@@ -249,7 +253,7 @@ export default function App() {
|
|||||||
navigateToConsole();
|
navigateToConsole();
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) setBusy(false);
|
if (!isStale()) setBusy(false);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -257,41 +261,46 @@ export default function App() {
|
|||||||
clearActivePreview();
|
clearActivePreview();
|
||||||
const routeSlug = appRouteSlug();
|
const routeSlug = appRouteSlug();
|
||||||
if (!routeSlug) {
|
if (!routeSlug) {
|
||||||
if (!cancelled) {
|
if (!isStale()) {
|
||||||
setView("console");
|
setView("console");
|
||||||
setBlueprint(null);
|
setBlueprint(null);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!cancelled) {
|
if (!isStale()) {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError("");
|
setError("");
|
||||||
setSlug(routeSlug);
|
setSlug(routeSlug);
|
||||||
|
setBlueprint(null);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const sess = session || loadSession();
|
const sess = session || loadSession();
|
||||||
const bp = await getBlueprint(sess, routeSlug);
|
const bp = await getBlueprint(sess, routeSlug);
|
||||||
if (cancelled) return;
|
if (isStale()) return;
|
||||||
|
if (String(bp?.meta?.slug || "") !== routeSlug) {
|
||||||
|
throw new Error(`模块路由不匹配:请求 ${routeSlug},返回 ${String(bp?.meta?.slug || "空")}`);
|
||||||
|
}
|
||||||
setBlueprint(bp);
|
setBlueprint(bp);
|
||||||
setDraft(bp);
|
setDraft(bp);
|
||||||
const r0 = bp?.apis?.resources?.[0]?.path?.replace(/^\//, "") || resource;
|
const r0 = bp?.apis?.resources?.[0]?.path?.replace(/^\//, "") || resource;
|
||||||
setResource(r0);
|
setResource(r0);
|
||||||
setView("app");
|
setView("app");
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (!cancelled) {
|
if (!isStale()) {
|
||||||
setError(e.message || String(e));
|
setError(e.message || String(e));
|
||||||
setView("console");
|
setView("console");
|
||||||
setBlueprint(null);
|
setBlueprint(null);
|
||||||
navigateToConsole();
|
navigateToConsole();
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) setBusy(false);
|
if (!isStale()) setBusy(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
void syncRoute();
|
void syncRoute();
|
||||||
window.addEventListener("hashchange", syncRoute);
|
window.addEventListener("hashchange", syncRoute);
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
routeRequestRef.current++;
|
||||||
window.removeEventListener("hashchange", syncRoute);
|
window.removeEventListener("hashchange", syncRoute);
|
||||||
};
|
};
|
||||||
}, [session]);
|
}, [session]);
|
||||||
@@ -576,6 +585,7 @@ export default function App() {
|
|||||||
if (view === "app" && blueprint) {
|
if (view === "app" && blueprint) {
|
||||||
return (
|
return (
|
||||||
<GeneratedApp
|
<GeneratedApp
|
||||||
|
key={String(blueprint?.meta?.slug || slug)}
|
||||||
session={session}
|
session={session}
|
||||||
blueprint={blueprint}
|
blueprint={blueprint}
|
||||||
onBack={() => {
|
onBack={() => {
|
||||||
|
|||||||
@@ -352,12 +352,13 @@ export async function getBlueprint(session: Session | null | undefined, slug: st
|
|||||||
if (session?.accessToken) {
|
if (session?.accessToken) {
|
||||||
const res = await apiFetch(`${PLATFORM}/api/v1/apps/${slug}/blueprint`, {
|
const res = await apiFetch(`${PLATFORM}/api/v1/apps/${slug}/blueprint`, {
|
||||||
headers: { Authorization: `Bearer ${session.accessToken}` },
|
headers: { Authorization: `Bearer ${session.accessToken}` },
|
||||||
|
cache: "no-store",
|
||||||
});
|
});
|
||||||
const data = await readJson(res);
|
const data = await readJson(res);
|
||||||
throwIfBad(res, data, "blueprint failed");
|
throwIfBad(res, data, "blueprint failed");
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
const res = await apiFetch(`${PLATFORM}/api/v1/public/apps/${slug}/blueprint`);
|
const res = await apiFetch(`${PLATFORM}/api/v1/public/apps/${slug}/blueprint`, { cache: "no-store" });
|
||||||
const data = await readJson(res);
|
const data = await readJson(res);
|
||||||
throwIfBad(res, data, "blueprint failed");
|
throwIfBad(res, data, "blueprint failed");
|
||||||
return data;
|
return data;
|
||||||
@@ -400,7 +401,7 @@ export async function listRows(
|
|||||||
}
|
}
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (session?.accessToken) headers.Authorization = `Bearer ${session.accessToken}`;
|
if (session?.accessToken) headers.Authorization = `Bearer ${session.accessToken}`;
|
||||||
const res = await apiFetch(path, { headers });
|
const res = await apiFetch(path, { headers, cache: "no-store" });
|
||||||
const data = await readJson(res);
|
const data = await readJson(res);
|
||||||
throwIfBad(res, data, "list failed");
|
throwIfBad(res, data, "list failed");
|
||||||
return data as { items: Record<string, unknown>[]; total: number };
|
return data as { items: Record<string, unknown>[]; total: number };
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 联调后修改意见 · 宇恒松离线(形态 B)
|
# 联调后修改意见 · 宇恒松离线(形态 B)
|
||||||
|
|
||||||
> 初稿:2026-08-01 · 修订至 **2026-08-07**(+Z38 返回上一级/智能体权限)
|
> 初稿:2026-08-01 · 修订至 **2026-08-07**(+Z39 跨模块数据隔离/本地表唯一键)
|
||||||
> 焦点:**§0.2**(按负责方);**改代码前须先写入本意见**(见 §0.0)
|
> 焦点:**§0.2**(按负责方);**改代码前须先写入本意见**(见 §0.0)
|
||||||
> 来源:宇恒 `yuhengyihao_client` ↔ 智建(生产 `aisite.yuxindazhineng.com`)
|
> 来源:宇恒 `yuhengyihao_client` ↔ 智建(生产 `aisite.yuxindazhineng.com`)
|
||||||
> 依据:`松离线-dbsync方案-最终版.md`、`宇恒-松离线数据同步使用文档.md`
|
> 依据:`松离线-dbsync方案-最终版.md`、`宇恒-松离线数据同步使用文档.md`
|
||||||
@@ -33,7 +33,8 @@
|
|||||||
| **Z35 库名非法中文** | **智建已落实(须生产 pull)** | 绑定/AttachSyncBind/heal 纠正;publish 拒非法名;§5.33;**宇恒勿改** |
|
| **Z35 库名非法中文** | **智建已落实(须生产 pull)** | 绑定/AttachSyncBind/heal 纠正;publish 拒非法名;§5.33;**宇恒勿改** |
|
||||||
| **Z36 编辑发布无增量** | **智建已落实(须生产 pull)** | 无增量 + `host_meta.module_name` → `host_meta_updated`;否则中文 `[NO_BLUEPRINT_DELTA]`(§5.34) |
|
| **Z36 编辑发布无增量** | **智建已落实(须生产 pull)** | 无增量 + `host_meta.module_name` → `host_meta_updated`;否则中文 `[NO_BLUEPRINT_DELTA]`(§5.34) |
|
||||||
| **Z37 SyncPage LWW 403 吓人** | **智建已落实(须生产 pull)** | SyncPage 无冲突队列;中性提示 + 空通道说明;conflicts 403 静默(§5.35) |
|
| **Z37 SyncPage LWW 403 吓人** | **智建已落实(须生产 pull)** | SyncPage 无冲突队列;中性提示 + 空通道说明;conflicts 403 静默(§5.35) |
|
||||||
| **Z38 绑定即用 + 返回上一级** | **智建已落实(须生产 pull)** | 绑定自动并集补齐建站权限并回传;超管公司视图全局可返回平台(§5.36) |
|
| **Z38 绑定即用 + 返回上一级** | **智建已落实(须生产 pull)** | 绑定自动补齐建站权限;超管公司视图全局返回平台(§5.36) |
|
||||||
|
| **Z39 跨模块串数据/页面复用** | **双方已改;智建须生产 pull** | 智能体共享库内按 tenant+slug 分 Schema;页面路由防竞态/旧蓝图;回执、capsule、日志可核对映射和 revision(§5.37) |
|
||||||
| **仍关注** | 用法 | 通道断了靠智建自愈;表数据靠宇恒双向指纹 / 数据恢复;不是「再点启动」 |
|
| **仍关注** | 用法 | 通道断了靠智建自愈;表数据靠宇恒双向指纹 / 数据恢复;不是「再点启动」 |
|
||||||
|
|
||||||
### 0.0 修改流程(冻结)
|
### 0.0 修改流程(冻结)
|
||||||
@@ -79,6 +80,7 @@
|
|||||||
| **Z33** | **仅数据同步时勿因缺 app.read 进换绑(2026-08-06 · 宇恒已改完)**:GET /apps 403 且已 sync_bound → apps=[] 继续模块菜单并提示开通读取模块。**智建无需改**(控制台给智能体开「读取模块」即可列模块) |
|
| **Z33** | **仅数据同步时勿因缺 app.read 进换绑(2026-08-06 · 宇恒已改完)**:GET /apps 403 且已 sync_bound → apps=[] 继续模块菜单并提示开通读取模块。**智建无需改**(控制台给智能体开「读取模块」即可列模块) |
|
||||||
| **Z34** | **换机按账号恢复(2026-08-06 · 宇恒半程已改完)**:有手机号时可静默 ticket/confirm。**完整「仅宇恒 ID」见智建 Z34b(已落实)** |
|
| **Z34** | **换机按账号恢复(2026-08-06 · 宇恒半程已改完)**:有手机号时可静默 ticket/confirm。**完整「仅宇恒 ID」见智建 Z34b(已落实)** |
|
||||||
| **Z36** | **编辑发布无增量中文取消(2026-08-07 · 宇恒已改完;智建 Z36 已落实)**:宇恒侧预处理;智建 `host_meta_updated` / `[NO_BLUEPRINT_DELTA]`(§5.34) |
|
| **Z36** | **编辑发布无增量中文取消(2026-08-07 · 宇恒已改完;智建 Z36 已落实)**:宇恒侧预处理;智建 `host_meta_updated` / `[NO_BLUEPRINT_DELTA]`(§5.34) |
|
||||||
|
| **Z39-YH** | **本地模块镜像表唯一键(2026-08-07 · 宇恒已改完)**:表名始终包含唯一 `slug`;发布建表、自动导入、独立导入、手动新增统一传递模块展示名/slug/实体标签。见 §5.37 |
|
||||||
|
|
||||||
#### A′. 宇恒 · 配合注意(非阻塞新开发)
|
#### A′. 宇恒 · 配合注意(非阻塞新开发)
|
||||||
|
|
||||||
@@ -104,7 +106,8 @@
|
|||||||
| **Z35** | 绑定 `database_name=user_{id}`;publish 拒绝中文库名并纠正脏数据;见 §5.33 |
|
| **Z35** | 绑定 `database_name=user_{id}`;publish 拒绝中文库名并纠正脏数据;见 §5.33 |
|
||||||
| **Z36** | merge 无增量:有 `host_meta.module_name` → `publish_mode=host_meta_updated`;否则中文 `[NO_BLUEPRINT_DELTA]`;见 §5.34 |
|
| **Z36** | merge 无增量:有 `host_meta.module_name` → `publish_mode=host_meta_updated`;否则中文 `[NO_BLUEPRINT_DELTA]`;见 §5.34 |
|
||||||
| **Z37** | SyncPage 无冲突队列;中性 LWW 说明 +「该公司暂无通道」空态;`listSyncConflicts` 403 静默;见 §5.35 |
|
| **Z37** | SyncPage 无冲突队列;中性 LWW 说明 +「该公司暂无通道」空态;`listSyncConflicts` 403 静默;见 §5.35 |
|
||||||
| **Z38** | 宇恒各绑定/恢复路径并集补齐 9 项建站权限;回执/Token 可验;全局返回平台;智能体列表展示权限;见 §5.36 |
|
| **Z38** | 超管进公司后顶栏「返回平台工作台」;绑定默认可带模块读/发权或列表明示权限;见 §5.36 |
|
||||||
|
| **Z39-ZJ** | 智能体共享库不再把各模块都落到 `public`;改为 `app_t{tenant}_{slug}`,长 slug 带哈希防碰撞;页面切换防旧请求覆盖;见 §5.37 |
|
||||||
|
|
||||||
> 产品一句:**账号已绑定 ⇒ 落点信息固定;通道没了平台自动补,终端无需手填 channel_id。**
|
> 产品一句:**账号已绑定 ⇒ 落点信息固定;通道没了平台自动补,终端无需手填 channel_id。**
|
||||||
|
|
||||||
@@ -112,7 +115,7 @@
|
|||||||
|
|
||||||
| 优先级 | 编号 | 项 | 说明 |
|
| 优先级 | 编号 | 项 | 说明 |
|
||||||
|--------|------|----|------|
|
|--------|------|----|------|
|
||||||
| — | — | (Z38 已合入见 B) | 当前无待开发项;运维见 B′ |
|
| — | — | **当前开发项已清** | Z38、Z39 已落实;剩 B′ 生产 pull 与联调验收。 |
|
||||||
|
|
||||||
#### B‴. 智建 · 本次明确不改(Z15)
|
#### B‴. 智建 · 本次明确不改(Z15)
|
||||||
|
|
||||||
@@ -128,7 +131,7 @@
|
|||||||
|
|
||||||
| 优先级 | 项 | 说明 |
|
| 优先级 | 项 | 说明 |
|
||||||
|--------|----|------|
|
|--------|----|------|
|
||||||
| **P0** | **生产 pull 本批** | Z10d + fingerprint + Z14c + **Z12h** + **数据恢复** + **Z34b** + **Z35** + **Z36** + **Z37** + **Z38**;`bash ./restart.sh --pull` |
|
| **P0** | **生产 pull 本批** | Z10d + fingerprint + Z14c + **Z12h** + **数据恢复** + **Z34b** + **Z35** + **Z36** + **Z37** + **Z38** + **Z39**;`bash ./restart.sh --pull` |
|
||||||
| **P0** | 成员手机 | 「宇信达」绑 **`13531041944`**;勿超管号 |
|
| **P0** | 成员手机 | 「宇信达」绑 **`13531041944`**;勿超管号 |
|
||||||
| **P0** | 通道表白名单 | **勿**「填入测试默认」;形态 B 可空 |
|
| **P0** | 通道表白名单 | **勿**「填入测试默认」;形态 B 可空 |
|
||||||
| **P1** | 凭票 Secret | `YuhengTicket.Secret` ≡ `YXD_YUHENG_TICKET_SECRET` |
|
| **P1** | 凭票 Secret | `YuhengTicket.Secret` ≡ `YXD_YUHENG_TICKET_SECRET` |
|
||||||
@@ -1202,7 +1205,7 @@ POST /api/v1/agent/sync/channels/{id}/pull
|
|||||||
|
|
||||||
### 5.36 【Z38 · 2026-08-07】宇恒绑定后须直接可用;超管进公司须能返回上一级
|
### 5.36 【Z38 · 2026-08-07】宇恒绑定后须直接可用;超管进公司须能返回上一级
|
||||||
|
|
||||||
**状态**:**智建已落实(须生产 pull);宇恒已改提示文案**。
|
**状态**:**双方已落实;智建须生产 pull**。
|
||||||
|
|
||||||
#### 现象
|
#### 现象
|
||||||
|
|
||||||
@@ -1216,7 +1219,7 @@ POST /api/v1/agent/sync/channels/{id}/pull
|
|||||||
| **绑定权限不完整** | 绑定/凭票路径 `ensureAgentSyncPerm` 目前**只保证「数据同步」**;绑定码 `RedeemBindCode` 甚至未调用该补权函数。宇恒建站技能还需模块、数据和文件权限,因此绑定虽然成功,`GET /api/v1/apps` 仍会因缺 `读取模块` 而 403。 |
|
| **绑定权限不完整** | 绑定/凭票路径 `ensureAgentSyncPerm` 目前**只保证「数据同步」**;绑定码 `RedeemBindCode` 甚至未调用该补权函数。宇恒建站技能还需模块、数据和文件权限,因此绑定虽然成功,`GET /api/v1/apps` 仍会因缺 `读取模块` 而 403。 |
|
||||||
| **返回按钮位置** | `PlatformTenantsPage` 有「返回平台工作台」(`exitPlatformTenant`);`ConsoleLayout` 顶栏在进公司后未放同款按钮,离开公司总览页后用户找不到返回。 |
|
| **返回按钮位置** | `PlatformTenantsPage` 有「返回平台工作台」(`exitPlatformTenant`);`ConsoleLayout` 顶栏在进公司后未放同款按钮,离开公司总览页后用户找不到返回。 |
|
||||||
|
|
||||||
#### 智建改(已落实)
|
#### 智建改(须)
|
||||||
|
|
||||||
1. **绑定即用(P0 · 必须)**:把 `ensureAgentSyncPerm` 改为语义明确的 `ensureYuhengAgentPerms`(名字可自定),以**并集追加、不得覆盖/删除已有权限**的方式保证宇恒建站权限包:
|
1. **绑定即用(P0 · 必须)**:把 `ensureAgentSyncPerm` 改为语义明确的 `ensureYuhengAgentPerms`(名字可自定),以**并集追加、不得覆盖/删除已有权限**的方式保证宇恒建站权限包:
|
||||||
`读取模块`、`写入模块`、`发布模块`、`查询数据`、`新增数据`、`导入数据`、`上传文件`、`下载文件`、`数据同步`。
|
`读取模块`、`写入模块`、`发布模块`、`查询数据`、`新增数据`、`导入数据`、`上传文件`、`下载文件`、`数据同步`。
|
||||||
@@ -1232,13 +1235,6 @@ POST /api/v1/agent/sync/channels/{id}/pull
|
|||||||
5. **全局返回(P0)**:`ConsoleLayout`(或所有公司侧页顶栏)在「超管 + 已 enter 某公司」时固定展示按钮 **「返回平台工作台」/「返回上一级」**,调用已有 `exitPlatformTenant`,回到平台公司总览。
|
5. **全局返回(P0)**:`ConsoleLayout`(或所有公司侧页顶栏)在「超管 + 已 enter 某公司」时固定展示按钮 **「返回平台工作台」/「返回上一级」**,调用已有 `exitPlatformTenant`,回到平台公司总览。
|
||||||
6. **权限可见性(P1)**:`AgentUsersPage` 增加「权限」列/Tag,展示至少「读取模块、发布模块、数据同步」;这是诊断能力,**不能替代绑定自动授权**。
|
6. **权限可见性(P1)**:`AgentUsersPage` 增加「权限」列/Tag,展示至少「读取模块、发布模块、数据同步」;这是诊断能力,**不能替代绑定自动授权**。
|
||||||
|
|
||||||
#### 落实记录(2026-08-07)
|
|
||||||
|
|
||||||
- `ensureYuhengAgentPerms` 对现有权限做并集追加,保证 9 项宇恒建站权限并激活账号,不删除已有权限;补权写库失败时绑定/换票直接失败。
|
|
||||||
- `ticket-exchange`、手机号确认、绑定码兑换、`restore-by-host` 及已绑定账号的 `client_credentials` 换票均在轮换密钥/签发 Token 前补权;恢复路由已接回,回执返回最终 `permissions`,成功文案统一为「绑定并授权完成」。
|
|
||||||
- `ConsoleLayout` 在平台超管进入公司上下文后固定显示「返回平台工作台」,任意公司侧页面均可退出管理视图。
|
|
||||||
- `AgentUsersPage` 增加权限 Tag 列,便于核对「读取模块、发布模块、数据同步」等有效权限。
|
|
||||||
|
|
||||||
#### 宇恒
|
#### 宇恒
|
||||||
|
|
||||||
已改缺权提示,明确平台授权未完成;宇恒不再把「后台人工勾选」定义为正常开通步骤。接口成功后直接继续模块操作。
|
已改缺权提示,明确平台授权未完成;宇恒不再把「后台人工勾选」定义为正常开通步骤。接口成功后直接继续模块操作。
|
||||||
@@ -1255,9 +1251,62 @@ POST /api/v1/agent/sync/channels/{id}/pull
|
|||||||
4. 超管「管理该公司」后,无论停在智能体/同步/模块哪一页,顶栏均可一键回平台工作台。
|
4. 超管「管理该公司」后,无论停在智能体/同步/模块哪一页,顶栏均可一键回平台工作台。
|
||||||
5. 智能体列表可见权限,但用户正常使用不依赖该页面。
|
5. 智能体列表可见权限,但用户正常使用不依赖该页面。
|
||||||
|
|
||||||
|
### 5.37 【Z39 · 2026-08-07】不同模块页面串数据;本地镜像表唯一键不完整
|
||||||
|
|
||||||
|
**状态**:**双方已落实;智建须生产 pull 并按下述场景联调验收**。
|
||||||
|
|
||||||
|
#### 现象与证据
|
||||||
|
|
||||||
|
本次新建模块 `whm11` 后,公开页面标题虽为 `whm11`,记录列表却显示了另一模块 `whm1` 的 100 条数据,且页面结构与前一模块一致。
|
||||||
|
|
||||||
|
宇恒本机库实查:
|
||||||
|
|
||||||
|
```text
|
||||||
|
模块·whm1·数据 100 条
|
||||||
|
模块·whm11·数据 0 条
|
||||||
|
```
|
||||||
|
|
||||||
|
两张 SQLite 表名称、Schema 和行数均已分开。因此**本次 `whm11` 页面显示 100 条旧数据,不是宇恒把这两批数据写入同一张本地表**;智建须检查公开页实际请求的 slug、资源路由、物理 Schema/table 和发布缓存。
|
||||||
|
|
||||||
|
同时发现宇恒本地镜像存在一个独立的潜在碰撞点:`table_name_for_resource()` 在 `module_name`/蓝图 `meta.name` 存在时优先使用展示名,未强制把唯一 `slug` 放进表名。两个不同 slug 若展示名相同,后建模块会把“表已存在”当作成功并复用旧表。该风险不是上述 `whm1`/`whm11` 当前串读的直接原因,但必须一并修正。
|
||||||
|
|
||||||
|
> 页面布局相似本身不一定是错误:相同字段和相同需求可能生成相似模板。若页面标题、接口 base path、数据源或构建产物也沿用前一 slug,则属于发布产物/缓存隔离错误。
|
||||||
|
|
||||||
|
#### 智建改(须 · P0)
|
||||||
|
|
||||||
|
1. **数据路由强隔离**:`/api/v1/apps/{slug}/{resource}` 从请求到 ORM/SQL 的解析键必须包含 `tenant_id + app_slug + resource`,禁止只按实体名、resource(如 `records`/`数据`)或当前默认通道选表。
|
||||||
|
2. **`schema_per_app` 真隔离**:不同 slug 必须落到不同物理 Schema/table;publish 回执及 `agent-capsule` 应返回可核对的 `app_slug`、`schema_name`、resource→table 映射和 revision。
|
||||||
|
3. **生成页面不可硬编码旧 slug**:公开页的数据请求 base path 必须来自本次发布 slug;切换/新建模块不得继承上一个模块的 API 地址。
|
||||||
|
4. **发布缓存隔离**:构建/部署缓存键至少包含 `tenant_id + slug + blueprint revision/hash`;不得因页面结构相似复用另一模块的静态产物或运行时配置。
|
||||||
|
5. **补诊断日志**:每次页面数据查询记录 `requested_slug → resolved_schema/table → blueprint_revision`;publish 记录缓存命中键,便于直接确认串读点。
|
||||||
|
6. **禁止默认模块回退**:请求带合法 slug 但映射不存在时应明确 404/配置错误,不能回退第一模块、最近发布模块或通道上的 `app_slug`。
|
||||||
|
|
||||||
|
#### 智建落实记录(2026-08-07)
|
||||||
|
|
||||||
|
1. **根因修复**:智能体绑定的 `database_name` 是多模块共享库;旧逻辑却强制 `database_per_app/public`,导致不同 slug 的同名实体表复用。现改为共享库内 `schema_per_app`,每个模块使用独立 `app_t{tenant}_{slug}`;旧 `public` 模块重发时升级到独立 Schema,随后由双向同步重新灌入所属数据。
|
||||||
|
2. **标识符防碰撞**:Schema/独立库名超过 48 字符时保留可读前缀并附加 slug 内容哈希,不再直接截断造成长 slug 碰撞。
|
||||||
|
3. **页面防串包**:hash 路由加载增加请求序号,快速切换模块时旧请求不得覆盖新蓝图;加载期间卸载旧模块,且校验返回蓝图 `meta.slug` 必须等于 URL slug。蓝图/数据 GET 使用 `no-store`。
|
||||||
|
4. **回执与 capsule 可核对**:publish 增加 `blueprint_revision`、`resource_tables`;agent-capsule 增加 `app_slug`、`schema_name`、revision 和各资源物理 table。
|
||||||
|
5. **诊断与拒绝回退**:CRUD 解析后强校验 requested slug 与 resolved app 一致、Schema/table 非空;日志记录 tenant、requested slug、resource、resolved Schema/table、revision。现有 Meta 查询仍严格按 `tenant_id + slug`,不存在默认模块回退。
|
||||||
|
6. **缓存说明**:当前仓无按页面结构复用的构建产物缓存;以 tenant+slug 定位元数据并以蓝图 revision 作为诊断/响应标识,HTTP 明确 `no-store`,避免旧运行时配置被复用。
|
||||||
|
|
||||||
|
#### 宇恒改(须)
|
||||||
|
|
||||||
|
1. **已完成**:`local_module_mirror.table_name_for_resource()` 的隔离键始终包含规范化 `slug`;展示名仅用于可读前缀,不能替代 slug。格式为 `模块·{展示名}·{slug}·{资源}`;展示名与 slug 相同时不重复。
|
||||||
|
2. **已完成**:发布建表、自动导入、独立导入、手动新增统一传递模块展示名、slug 和实体标签,避免同一模块因调用入口不同落到不同表。
|
||||||
|
3. **已保留**行级 `模块slug` 元数据;“表已存在”仅作为同名表幂等处理。字段 Schema 严格一致性校验列入后续增强,不阻塞本次唯一键修复。
|
||||||
|
|
||||||
|
#### 验收
|
||||||
|
|
||||||
|
1. 同租户建立 `whm1`、`whm11`,两者都定义同名资源 `数据/records`;分别导入 A、B 两组明显不同的数据。
|
||||||
|
2. 打开 `/#/app/whm1` 只能看到 A;打开 `/#/app/whm11` 只能看到 B;两个 API 交叉查询均不得串行。
|
||||||
|
3. 两模块使用相同展示名时,本地仍生成两张包含各自 slug 的表,导入/新增互不混写。
|
||||||
|
4. 两次发布使用相同页面结构时,页面 API base path、Schema 和构建 revision 仍分别对应各自 slug。
|
||||||
|
5. 日志可直接看到每次请求的 requested slug 与最终物理 Schema/table,且不存在“找不到映射后回退默认模块”。
|
||||||
|
|
||||||
## 6. 联系与附件
|
## 6. 联系与附件
|
||||||
|
|
||||||
- **待改清单(优先看)**:§0.2(当前开发项已清;**B′ 运维 pull**);配合见 **§0.3**
|
- **待改清单(优先看)**:§0.2(**当前开发项已清 · B′ 运维 pull**);**改代码前先写本意见**(§0.0);配合见 **§0.3**
|
||||||
- 方案:`松离线-dbsync方案-最终版.md`(含 2026-08-01 联调建议落地记录)
|
- 方案:`松离线-dbsync方案-最终版.md`(含 2026-08-01 联调建议落地记录)
|
||||||
- 宇恒使用说明:`宇恒-松离线数据同步使用文档.md`(含 Z10 schema/ensure、Z13 绑定)
|
- 宇恒使用说明:`宇恒-松离线数据同步使用文档.md`(含 Z10 schema/ensure、Z13 绑定)
|
||||||
- 开通说明:`docs/数据同步-开通说明.md`(含 **数据恢复**)
|
- 开通说明:`docs/数据同步-开通说明.md`(含 **数据恢复**)
|
||||||
|
|||||||
Reference in New Issue
Block a user