diff --git a/backend/api/cmd/cli/main.go b/backend/api/cmd/cli/main.go index 984e81a..3fb78cb 100644 --- a/backend/api/cmd/cli/main.go +++ b/backend/api/cmd/cli/main.go @@ -12,9 +12,11 @@ import ( "git.apinb.com/heqiapp/platforms/backend/api/internal/config" "git.apinb.com/heqiapp/platforms/backend/api/internal/initdb" "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/platform" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" "git.apinb.com/heqiapp/platforms/backend/api/internal/routers" "git.apinb.com/heqiapp/platforms/backend/api/internal/seed" "github.com/gin-gonic/gin" + "gorm.io/gorm" ) const serviceKey = "heqi" @@ -38,6 +40,12 @@ func main() { os.Exit(1) } fmt.Println("mock data written successfully") + case "migrate": + if err := migrateDatabase(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + fmt.Println("database migrated successfully") default: fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1]) printUsage() @@ -46,7 +54,7 @@ func main() { } func printUsage() { - fmt.Fprintln(os.Stderr, "usage: platform-cli ") + fmt.Fprintln(os.Stderr, "usage: platform-cli ") } type route struct { @@ -110,3 +118,40 @@ func writeMockData() error { } return nil } + +func migrateDatabase() error { + config.New("platform") + if config.Spec.Databases == nil { + return fmt.Errorf("database configuration is required") + } + var migrationDatabase interface { + Migrator() gorm.Migrator + } + var err error + switch strings.ToLower(config.Spec.Databases.Driver) { + case "postgres": + migrationDatabase, err = database.NewPostgres(config.Spec.Databases.Source, nil) + case "mysql": + migrationDatabase, err = database.NewMysql(config.Spec.Databases.Source, nil) + default: + return fmt.Errorf("unsupported database driver: %s", config.Spec.Databases.Driver) + } + if err != nil { + return fmt.Errorf("connect database before migration: %w", err) + } + const legacyPhoneIndex = "idx_platform_account_phone" + if migrationDatabase.Migrator().HasIndex(&models.PlatformAccount{}, legacyPhoneIndex) { + if err := migrationDatabase.Migrator().DropIndex(&models.PlatformAccount{}, legacyPhoneIndex); err != nil { + return fmt.Errorf("drop legacy platform account phone index: %w", err) + } + } + databaseService, err := database.NewDatabase( + config.Spec.Databases.Driver, + config.Spec.Databases.Source, + nil, + ) + if err != nil { + return fmt.Errorf("migrate database: %w", err) + } + return initdb.New(databaseService) +} diff --git a/backend/api/cmd/main/main.go b/backend/api/cmd/main/main.go index f24cf2f..b6d6927 100644 --- a/backend/api/cmd/main/main.go +++ b/backend/api/cmd/main/main.go @@ -11,10 +11,11 @@ import ( "time" "git.apinb.com/bsm-sdk/core/infra" - "git.apinb.com/bsm-sdk/core/middleware" + sdkmiddleware "git.apinb.com/bsm-sdk/core/middleware" "git.apinb.com/bsm-sdk/core/printer" "git.apinb.com/heqiapp/platforms/backend/api/internal/config" "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + appmiddleware "git.apinb.com/heqiapp/platforms/backend/api/internal/middleware" "git.apinb.com/heqiapp/platforms/backend/api/internal/routers" "github.com/gin-gonic/gin" ) @@ -31,8 +32,8 @@ func main() { // } app := gin.Default() - middleware.Mode(app) - app.Use(middleware.Cors()) + sdkmiddleware.Mode(app) + app.Use(appmiddleware.Cors()) app.Use(gin.Recovery()) app.HEAD("/", infra.Health) routers.Register(serviceKey, app) diff --git a/backend/api/internal/impl/new.go b/backend/api/internal/impl/new.go index 949673f..4112ec6 100644 --- a/backend/api/internal/impl/new.go +++ b/backend/api/internal/impl/new.go @@ -3,6 +3,7 @@ package impl import ( "git.apinb.com/bsm-sdk/core/cache/redis" + "git.apinb.com/bsm-sdk/core/database" "git.apinb.com/bsm-sdk/core/logger" "git.apinb.com/bsm-sdk/core/with" "git.apinb.com/heqiapp/platforms/backend/api/internal/config" @@ -20,6 +21,11 @@ var ( func NewImpl() { MemoryService = with.Memory(nil) RedisService = with.RedisCache(config.Spec.Cache) + // HTTP 服务启动只建立连接。表结构迁移由 platform-cli migrate 显式执行, + // 避免每次重启都对全部远程表执行耗时的元数据扫描。 + migrateTables := database.MigrateTables + database.MigrateTables = nil + defer func() { database.MigrateTables = migrateTables }() DBService = with.Databases(config.Spec.Databases, nil) logger.New(nil) } diff --git a/backend/api/internal/middleware/cors.go b/backend/api/internal/middleware/cors.go new file mode 100644 index 0000000..9758483 --- /dev/null +++ b/backend/api/internal/middleware/cors.go @@ -0,0 +1,27 @@ +// Package middleware 定义平台 API 自有的 HTTP 中间件。 +package middleware + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +const ( + allowedHeaders = "Origin, Content-Length, Content-Type, Workspace, Request-Id, Authorization, Token" + allowedMethods = "GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS" +) + +// Cors 允许平台前端跨域调用 API,包括状态更新使用的 PATCH 方法。 +func Cors() gin.HandlerFunc { + return func(ctx *gin.Context) { + ctx.Header("Access-Control-Allow-Origin", "*") + ctx.Header("Access-Control-Allow-Headers", allowedHeaders) + ctx.Header("Access-Control-Allow-Methods", allowedMethods) + if ctx.Request.Method == http.MethodOptions { + ctx.AbortWithStatus(http.StatusNoContent) + return + } + ctx.Next() + } +} diff --git a/backend/api/internal/middleware/cors_test.go b/backend/api/internal/middleware/cors_test.go new file mode 100644 index 0000000..9ef5280 --- /dev/null +++ b/backend/api/internal/middleware/cors_test.go @@ -0,0 +1,32 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestCorsAllowsPatchPreflight(t *testing.T) { + gin.SetMode(gin.TestMode) + engine := gin.New() + engine.Use(Cors()) + engine.PATCH("/resource/:identity/status", func(ctx *gin.Context) { + ctx.Status(http.StatusNoContent) + }) + + request := httptest.NewRequest(http.MethodOptions, "/resource/example/status", nil) + request.Header.Set("Origin", "http://localhost:5173") + request.Header.Set("Access-Control-Request-Method", http.MethodPatch) + recorder := httptest.NewRecorder() + engine.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusNoContent { + t.Fatalf("preflight status = %d, want %d", recorder.Code, http.StatusNoContent) + } + if methods := recorder.Header().Get("Access-Control-Allow-Methods"); !strings.Contains(methods, http.MethodPatch) { + t.Fatalf("allowed methods %q do not include PATCH", methods) + } +} diff --git a/backend/api/internal/models/platform_account.go b/backend/api/internal/models/platform_account.go index 9699fad..f12c046 100644 --- a/backend/api/internal/models/platform_account.go +++ b/backend/api/internal/models/platform_account.go @@ -5,12 +5,12 @@ import "git.apinb.com/bsm-sdk/core/database" // PlatformAccount 对应 platform_account,表示平台总后台登录账号。 type PlatformAccount struct { Entity // 公共实体字段 - Username string `gorm:"column:username;type:varchar(64);uniqueIndex;not null" json:"username"` // 登录用户名 - DisplayName string `gorm:"column:display_name;type:varchar(64);not null;default:''" json:"display_name"` // 用户展示名称 - Avatar string `gorm:"column:avatar;type:varchar(512);not null;default:''" json:"avatar"` // 头像资源地址 - PasswordHash string `gorm:"column:password_hash;type:varchar(255);not null;default:''" json:"-"` // 密码哈希值 - PlatformRoleCode string `gorm:"column:platform_role_code;type:varchar(64);not null;index" json:"platform_role_code"` // 平台角色编码 - Phone string `gorm:"column:phone;type:varchar(32);uniqueIndex;not null;default:''" json:"phone"` // 手机号 + Username string `gorm:"column:username;type:varchar(64);uniqueIndex;not null" json:"username"` // 登录用户名 + DisplayName string `gorm:"column:display_name;type:varchar(64);not null;default:''" json:"display_name"` // 用户展示名称 + Avatar string `gorm:"column:avatar;type:varchar(512);not null;default:''" json:"avatar"` // 头像资源地址 + PasswordHash string `gorm:"column:password_hash;type:varchar(255);not null;default:''" json:"-"` // 密码哈希值 + PlatformRoleCode string `gorm:"column:platform_role_code;type:varchar(64);not null;index" json:"platform_role_code"` // 平台角色编码 + Phone string `gorm:"column:phone;type:varchar(32);not null;default:'';uniqueIndex:idx_platform_account_phone_nonempty,where:phone <> ''" json:"phone"` // 手机号,非空值唯一 } func init() { database.AppendMigrate(&PlatformAccount{}) } diff --git a/frontend/platform_admin/src/views/shared/TreePage.vue b/frontend/platform_admin/src/views/shared/TreePage.vue index 86a973d..20a7360 100644 --- a/frontend/platform_admin/src/views/shared/TreePage.vue +++ b/frontend/platform_admin/src/views/shared/TreePage.vue @@ -69,8 +69,16 @@ const canArchive = computed( const tree = computed(() => { const byIdentity = new Map(); const roots: Node[] = []; - for (const item of list.value) - byIdentity.set(item.identity, { ...item, children: [] }); + for (const item of list.value) { + // Arco Tree 把 data.icon 当作图标渲染函数;平台菜单接口返回的是字符串图标名, + // 直接透传会导致 renderFunc is not a function 并破坏后续路由渲染。 + const { icon, ...data } = item; + byIdentity.set(item.identity, { + ...data, + ...(typeof icon === 'string' ? { icon_name: icon } : {}), + children: [], + }); + } for (const item of byIdentity.values()) { const parent = item.parent_identity ? byIdentity.get(item.parent_identity)