feat: add Z12/Z13 bind APIs, stock import, and sync docs

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.
This commit is contained in:
whm
2026-08-05 11:47:20 +08:00
parent b04b180d30
commit cb56e6847e
31 changed files with 1882 additions and 91 deletions

View File

@@ -0,0 +1,85 @@
package meta
import (
"context"
"encoding/json"
"fmt"
"log"
)
// BackfillImportResult Z9e 扫库补齐 import/export 的结果。
type BackfillImportResult struct {
Scanned int `json:"scanned"`
Updated int `json:"updated"`
Skipped int `json:"skipped"`
DryRun bool `json:"dry_run"`
Slugs []string `json:"updated_slugs,omitempty"`
Message string `json:"message,omitempty"`
}
// PublishedLister 列出全部已发布应用(跨租户,供 Z9e 启动扫库)。
type PublishedLister interface {
ListPublishedApps(ctx context.Context) ([]*AppRecord, error)
}
// BackfillDefaultImportExport 遍历已发布蓝图,对可写 resource 补齐 import/export 并落库Z9e
// dryRun=true 只统计不写库。tenantID>0 时仅处理该租户。
func BackfillDefaultImportExport(ctx context.Context, store Store, dryRun bool) (*BackfillImportResult, error) {
return BackfillDefaultImportExportForTenant(ctx, store, 0, dryRun)
}
// BackfillDefaultImportExportForTenant 同 BackfillDefaultImportExporttenantID=0 表示全库。
func BackfillDefaultImportExportForTenant(ctx context.Context, store Store, tenantID int64, dryRun bool) (*BackfillImportResult, error) {
if store == nil {
return nil, fmt.Errorf("meta store nil")
}
lister, ok := store.(PublishedLister)
if !ok {
return nil, fmt.Errorf("meta store does not support ListPublishedApps")
}
apps, err := lister.ListPublishedApps(ctx)
if err != nil {
return nil, err
}
out := &BackfillImportResult{DryRun: dryRun, Slugs: make([]string, 0)}
for _, app := range apps {
if app == nil || app.Blueprint == nil {
continue
}
if tenantID > 0 && app.TenantID != tenantID {
continue
}
out.Scanned++
before, _ := json.Marshal(app.Blueprint)
app.Blueprint.EnsureDefaultImportExport()
after, _ := json.Marshal(app.Blueprint)
if string(before) == string(after) {
out.Skipped++
continue
}
out.Updated++
out.Slugs = append(out.Slugs, fmt.Sprintf("%d/%s", app.TenantID, app.Slug))
if dryRun {
continue
}
if err := store.Save(ctx, app); err != nil {
return out, fmt.Errorf("save %s: %w", app.Slug, err)
}
}
out.Message = fmt.Sprintf("scanned=%d updated=%d skipped=%d dry_run=%v", out.Scanned, out.Updated, out.Skipped, dryRun)
return out, nil
}
// RunBackfillDefaultImportExportOnBoot 启动时扫库(失败只打日志,不阻断启动)。
func RunBackfillDefaultImportExportOnBoot(ctx context.Context, store Store) {
res, err := BackfillDefaultImportExport(ctx, store, false)
if err != nil {
log.Printf("Z9e backfill import ops: %v", err)
return
}
if res != nil && res.Updated > 0 {
log.Printf("Z9e backfill import ops: %s slugs=%v", res.Message, res.Slugs)
} else if res != nil {
log.Printf("Z9e backfill import ops: %s", res.Message)
}
}

View File

@@ -0,0 +1,59 @@
package meta
import (
"context"
"testing"
"aijianzhan/platform/internal/blueprint"
)
func TestBackfillDefaultImportExport(t *testing.T) {
store := NewMemoryStore()
bp := &blueprint.Blueprint{}
bp.Meta.Name = "coerce"
bp.Meta.Slug = "coerce_fields"
bp.Apis.Resources = []blueprint.APIResource{{
Path: "items",
Entity: "item",
Operations: []string{"list", "get", "create", "update", "delete"},
}}
bp.Entities = []blueprint.Entity{{Name: "item", Table: "item", PrimaryKey: "id"}}
bp.Pages = []blueprint.Page{{ID: "list1", Type: "list", Layout: &blueprint.PageLayout{Actions: []string{"create", "refresh"}}}}
_ = store.Save(context.Background(), &AppRecord{
AppID: "a1", TenantID: 1, Slug: "coerce_fields", Name: "coerce",
Status: StatusPublished, Blueprint: bp,
})
dry, err := BackfillDefaultImportExport(context.Background(), store, true)
if err != nil {
t.Fatal(err)
}
if dry.Updated != 1 {
t.Fatalf("dry updated=%d", dry.Updated)
}
app, _ := store.GetBySlug(context.Background(), 1, "coerce_fields")
if hasImport(app.Blueprint.Apis.Resources[0].Operations) {
t.Fatal("dry run should not persist")
}
res, err := BackfillDefaultImportExport(context.Background(), store, false)
if err != nil {
t.Fatal(err)
}
if res.Updated != 1 {
t.Fatalf("updated=%d", res.Updated)
}
app, _ = store.GetBySlug(context.Background(), 1, "coerce_fields")
if !hasImport(app.Blueprint.Apis.Resources[0].Operations) {
t.Fatalf("ops=%v", app.Blueprint.Apis.Resources[0].Operations)
}
}
func hasImport(ops []string) bool {
for _, op := range ops {
if op == "import" {
return true
}
}
return false
}

View File

@@ -75,6 +75,29 @@ ORDER BY updated_at DESC`
return out, rows.Err()
}
func (s *PostgresStore) ListPublishedApps(ctx context.Context) ([]*AppRecord, error) {
const q = `
SELECT app_id, tenant_id, slug, name, schema_name, COALESCE(database_name,''), engine, status,
blueprint_json, ddl_json, endpoints_json, error_msg, created_at, updated_at
FROM platform_meta.tenant_apps
WHERE status = $1
ORDER BY tenant_id, slug`
rows, err := s.DB.QueryContext(ctx, q, string(StatusPublished))
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]*AppRecord, 0)
for rows.Next() {
rec, err := scanApp(rows)
if err != nil {
return nil, err
}
out = append(out, rec)
}
return out, rows.Err()
}
func (s *PostgresStore) Save(ctx context.Context, app *AppRecord) error {
if app == nil {
return fmt.Errorf("app is nil")

View File

@@ -140,6 +140,19 @@ func (s *MemoryStore) ListByTenant(_ context.Context, tenantID int64) ([]AppSumm
return out, nil
}
func (s *MemoryStore) ListPublishedApps(_ context.Context) ([]*AppRecord, error) {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]*AppRecord, 0)
for _, app := range s.apps {
if app == nil || app.Status != StatusPublished {
continue
}
out = append(out, cloneApp(app))
}
return out, nil
}
func summarizeApp(app *AppRecord) AppSummary {
sum := AppSummary{
AppID: app.AppID,