// 平台 API 命令行工具。 package main import ( "encoding/json" "fmt" "io" "os" "strings" "git.apinb.com/bsm-sdk/core/database" "git.apinb.com/heqiapp/platforms/backend/api/internal/config" "git.apinb.com/heqiapp/platforms/backend/api/internal/initdb" deliverylogic "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/delivery" gaslogic "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/gas" "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" func main() { if len(os.Args) < 2 { printUsage() os.Exit(1) } switch os.Args[1] { case "version": fmt.Println("platform-cli 0.1.0") case "resource-contract": if err := writeResourceContract(os.Stdout); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } case "gas-resource-contract": if err := writeGasResourceContract(os.Stdout); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } case "delivery-resource-contract": if err := writeDeliveryResourceContract(os.Stdout); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } case "mock-data": if err := writeMockData(); err != nil { fmt.Fprintln(os.Stderr, err) 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() os.Exit(1) } } func printUsage() { fmt.Fprintln(os.Stderr, "usage: platform-cli ") } type route struct { Method string `json:"method"` Path string `json:"path"` } type contract struct { Domain string `json:"domain"` Name string `json:"name"` Path string `json:"path"` PageKind string `json:"pageKind"` Mode string `json:"mode"` } type manifest struct { Resources []contract `json:"resources"` Routes []route `json:"routes"` } func writeResourceContract(output io.Writer) error { gin.SetMode(gin.ReleaseMode) engine := gin.New() routers.RegisterPlatform(serviceKey, engine) routes := make([]route, 0, len(engine.Routes())) for _, item := range engine.Routes() { routes = append(routes, route{ Method: item.Method, Path: strings.TrimPrefix(item.Path, "/"+serviceKey+"/platform/v1"), }) } expected := platform.ExpectedResources() contracts := make([]contract, 0, len(expected)) for _, item := range expected { contracts = append(contracts, contract{ Domain: item.Domain, Name: item.Name, Path: item.Path, PageKind: item.PageKind, Mode: string(item.Mode), }) } return json.NewEncoder(output).Encode(manifest{Resources: contracts, Routes: routes}) } func writeGasResourceContract(output io.Writer) error { gin.SetMode(gin.ReleaseMode) engine := gin.New() routers.RegisterGas(serviceKey, engine) routes := make([]route, 0, len(engine.Routes())) for _, item := range engine.Routes() { routes = append(routes, route{ Method: item.Method, Path: strings.TrimPrefix(item.Path, "/"+serviceKey+"/gas/v1"), }) } expected := gaslogic.ExpectedResources() contracts := make([]contract, 0, len(expected)) for _, item := range expected { contracts = append(contracts, contract{ Domain: item.Domain, Name: item.Name, Path: item.Path, PageKind: item.PageKind, Mode: string(item.Mode), }) } return json.NewEncoder(output).Encode(manifest{Resources: contracts, Routes: routes}) } func writeDeliveryResourceContract(output io.Writer) error { gin.SetMode(gin.ReleaseMode) engine := gin.New() routers.RegisterDelivery(serviceKey, engine) routes := make([]route, 0, len(engine.Routes())) for _, item := range engine.Routes() { routes = append(routes, route{ Method: item.Method, Path: strings.TrimPrefix(item.Path, "/"+serviceKey+"/delivery/v1"), }) } expected := deliverylogic.ExpectedResources() contracts := make([]contract, 0, len(expected)) for _, item := range expected { contracts = append(contracts, contract{ Domain: item.Domain, Name: item.Name, Path: item.Path, PageKind: item.PageKind, Mode: item.Mode, }) } return json.NewEncoder(output).Encode(manifest{Resources: contracts, Routes: routes}) } func writeMockData() error { config.New("heqi") if config.Spec.Databases == nil { return fmt.Errorf("database configuration is required") } databaseService, err := database.NewDatabase( config.Spec.Databases.Driver, config.Spec.Databases.Source, nil, ) if err != nil { return fmt.Errorf("connect database: %w", err) } if err := initdb.New(databaseService); err != nil { return fmt.Errorf("initialize platform data: %w", err) } if err := seed.MockData(databaseService); err != nil { return fmt.Errorf("write mock data: %w", err) } return nil } func migrateDatabase() error { config.New("heqi") if config.Spec.Databases == nil { return fmt.Errorf("database configuration is required") } var migrationDatabase *gorm.DB var err error driver := strings.ToLower(config.Spec.Databases.Driver) switch 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) } if err := prepareAdditiveMigrations(migrationDatabase, driver); err != nil { return err } if err := rejectLegacyPaymentSchema(migrationDatabase); err != nil { return 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) } // rejectLegacyPaymentSchema 阻止通用迁移物理删除历史资金表。 // 旧支付事实必须通过单独评审的数据迁移保留和对账,不能由 migrate 命令静默重置。 func rejectLegacyPaymentSchema(databaseService *gorm.DB) error { for _, table := range []string{"wallet_refund", "wallet_payment", "gasorder_payment"} { if databaseService.Migrator().HasTable(table) { return fmt.Errorf("检测到历史资金表 %s:请先执行经财务与审计批准的保留式迁移", table) } } return nil } // prepareAdditiveMigrations 先处理无法由 GORM AutoMigrate 安全完成的新增非空字段。 // 旧轨迹没有服务端接收时间时,以定位发生时间(再退化到创建时间)回填, // 避免直接 ADD NOT NULL 因历史行存在而中断整库迁移。 func prepareAdditiveMigrations(databaseService *gorm.DB, driver string) error { if !databaseService.Migrator().HasTable(&models.GasorderTrackPoint{}) { return nil } var statements []string hasReceivedAt := databaseService.Migrator().HasColumn(&models.GasorderTrackPoint{}, "received_at") switch driver { case "postgres": if !hasReceivedAt { statements = append(statements, `ALTER TABLE "gasorder_track_point" ADD COLUMN "received_at" timestamptz`) } statements = append(statements, `UPDATE "gasorder_track_point" SET "received_at" = COALESCE("occurred_at", "created_at", CURRENT_TIMESTAMP) WHERE "received_at" IS NULL`, `ALTER TABLE "gasorder_track_point" ALTER COLUMN "received_at" SET NOT NULL`, ) case "mysql": if !hasReceivedAt { statements = append(statements, "ALTER TABLE `gasorder_track_point` ADD COLUMN `received_at` datetime(3) NULL") } statements = append(statements, "UPDATE `gasorder_track_point` SET `received_at` = COALESCE(`occurred_at`, `created_at`, CURRENT_TIMESTAMP(3)) WHERE `received_at` IS NULL", "ALTER TABLE `gasorder_track_point` MODIFY COLUMN `received_at` datetime(3) NOT NULL", ) default: return fmt.Errorf("unsupported database driver for additive migrations: %s", driver) } for _, statement := range statements { if err := databaseService.Exec(statement).Error; err != nil { return fmt.Errorf("backfill gasorder_track_point.received_at: %w", err) } } return nil }