diff --git a/backend/api/cmd/cli/main.go b/backend/api/cmd/cli/main.go index eeb529a..7bda233 100644 --- a/backend/api/cmd/cli/main.go +++ b/backend/api/cmd/cli/main.go @@ -4,8 +4,15 @@ package main import ( "fmt" "os" + + "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" + "git.apinb.com/heqiapp/platforms/backend/api/internal/seed" ) +const serviceKey = "heqi" + func main() { if len(os.Args) < 2 { printUsage() @@ -19,6 +26,12 @@ func main() { 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") default: fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1]) printUsage() @@ -27,5 +40,27 @@ func main() { } func printUsage() { - fmt.Fprintln(os.Stderr, "usage: platform-cli ") + fmt.Fprintln(os.Stderr, "usage: platform-cli ") +} + +func writeMockData() error { + config.New(serviceKey) + 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 } diff --git a/backend/api/internal/seed/mock.go b/backend/api/internal/seed/mock.go new file mode 100644 index 0000000..13cab0b --- /dev/null +++ b/backend/api/internal/seed/mock.go @@ -0,0 +1,506 @@ +// Package seed writes linked development data without replacing existing rows. +package seed + +import ( + "fmt" + "reflect" + "time" + + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +const mockIdentityPrefix = "00000000-0000-7000-8000-" + +// MockData idempotently writes one connected development scenario across all +// domain tables. Fixed identities and unique business numbers make reruns safe. +func MockData(database *gorm.DB) error { + passwordHash, err := bcrypt.GenerateFromPassword([]byte("Mock@123456"), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("hash mock password: %w", err) + } + + now := time.Date(2026, time.July, 1, 9, 0, 0, 0, time.Local) + yesterday := now.Add(-24 * time.Hour) + nextYear := now.AddDate(1, 0, 0) + + return database.Transaction(func(tx *gorm.DB) error { + gas := models.GasBasic{ + Entity: entity(1, "enabled"), Code: "MOCK-GAS-001", Name: "和气示例气站", + CreditCode: "91310000MOCKGAS001", Principal: "张站长", + Address: "上海市浦东新区示例路 1 号", Longitude: "121.5440", Latitude: "31.2210", + } + if err := put(tx, &gas); err != nil { + return err + } + + gasAccount := models.GasAccount{ + Entity: entity(2, "enabled"), GasBasicID: gas.ID, Username: "mock_gas_admin", + DisplayName: "示例气站管理员", PasswordHash: string(passwordHash), RoleCode: "admin", + } + if err := put(tx, &gasAccount); err != nil { + return err + } + + delivery := models.DeliveryBasic{ + Entity: entity(3, "enabled"), DeliveryCode: "MOCK-DELIVERY-001", + GasBasicID: gas.ID, Name: "和气示例配送点", Principal: "李主管", + Address: "上海市浦东新区示例路 18 号", + } + if err := put(tx, &delivery); err != nil { + return err + } + + deliveryAccount := models.DeliveryAccount{ + Entity: entity(4, "enabled"), DeliveryBasicID: delivery.ID, + Username: "mock_delivery_admin", DisplayName: "示例配送点管理员", + PasswordHash: string(passwordHash), RoleCode: "admin", + } + if err := put(tx, &deliveryAccount); err != nil { + return err + } + + staff := models.StaffAccount{ + Entity: entity(5, "enabled"), Username: "mock_driver", PasswordHash: string(passwordHash), + Name: "王师傅", Phone: "13900000001", RoleCode: "driver", + GasBasicID: gas.ID, DeliveryBasicID: delivery.ID, WorkStatus: "on_duty", + } + if err := put(tx, &staff); err != nil { + return err + } + + credential := models.StaffCredential{ + Entity: entity(6, "enabled"), StaffAccountID: staff.ID, + CredentialType: "delivery", CredentialNo: "MOCK-CERT-001", ExpiredAt: &nextYear, + } + if err := put(tx, &credential); err != nil { + return err + } + + user := models.UserAccount{ + Entity: entity(7, "enabled"), Username: "mock_customer", PasswordHash: string(passwordHash), + Name: "陈女士", Phone: "13800000001", RealName: "陈示例", + } + if err := put(tx, &user); err != nil { + return err + } + + address := models.UserAddress{ + Entity: entity(8, "enabled"), UserAccountID: user.ID, + Address: "上海市浦东新区客户路 88 号", Longitude: "121.5500", + Latitude: "31.2250", IsDefault: true, + } + if err := put(tx, &address); err != nil { + return err + } + + serviceRelation := models.UserServiceRelation{ + Entity: entity(9, "enabled"), UserAccountID: user.ID, GasBasicID: gas.ID, + DeliveryBasicID: delivery.ID, StaffAccountID: staff.ID, + } + if err := put(tx, &serviceRelation); err != nil { + return err + } + + productType := models.ProductType{ + Entity: entity(10, "enabled"), Code: "MOCK-LPG-15KG", Name: "15kg 液化气钢瓶", + } + if err := put(tx, &productType); err != nil { + return err + } + + warehouse := models.ProductWarehouse{ + Entity: entity(11, "enabled"), Code: "MOCK-WH-001", Name: "示例中心库房", + Address: gas.Address, Manager: "赵库管", Phone: "13700000001", IsEnabled: true, + } + if err := put(tx, &warehouse); err != nil { + return err + } + + enabledAt := yesterday + productInfo := models.ProductInfo{ + Entity: entity(12, "enabled"), Code: "MOCK-CYLINDER-001", Name: "示例液化气钢瓶", + ProductTypeID: productType.ID, Params: `{"weight":"15kg","medium":"LPG"}`, + WarehouseID: warehouse.ID, GasBasicID: gas.ID, ProducedAt: now.AddDate(-1, 0, 0), + IsEnabled: true, EnabledAt: &enabledAt, + } + if err := put(tx, &productInfo); err != nil { + return err + } + + productOwner := models.ProductOwner{ + Entity: entity(13, "enabled"), ProductInfoID: productInfo.ID, + WarehouseID: warehouse.ID, GasBasicID: gas.ID, Action: "stock_in", + OccurredAt: yesterday, Reason: "模拟数据初始化", OperatorName: "系统", + } + if err := put(tx, &productOwner); err != nil { + return err + } + + completedAt := yesterday.Add(2 * time.Hour) + productRepair := models.ProductRepair{ + Entity: entity(14, "completed"), ProductInfoID: productInfo.ID, + RepairNo: "MOCK-REPAIR-001", RepairType: "inspection", StartedAt: yesterday, + CompletedAt: &completedAt, Result: "passed", TargetStatus: "enabled", + Content: "外观、阀门与气密性检查", Operator: staff.Name, + } + if err := put(tx, &productRepair); err != nil { + return err + } + + contract := models.GasorderContract{ + Entity: entity(15, "active"), ContractNo: "MOCK-CONTRACT-001", + UserAccountID: user.ID, GasBasicID: gas.ID, DeliveryBasicID: delivery.ID, + Title: "居民瓶装气配送示例合同", Terms: "按需配送,安全使用。", + DefaultDeliveryFee: 500, SignedAt: yesterday, EffectiveAt: yesterday, ExpiredAt: &nextYear, + } + if err := put(tx, &contract); err != nil { + return err + } + + contractRevision := models.GasorderContractRevision{ + Entity: entity(16, "active"), GasorderContractID: contract.ID, Action: "activate", + ContractStatus: "active", EffectiveAt: contract.EffectiveAt, ExpiredAt: contract.ExpiredAt, + OperatorIdentity: gasAccount.Identity, OperatorName: gasAccount.DisplayName, + OccurredAt: yesterday, Reason: "模拟合同启用", + } + if err := put(tx, &contractRevision); err != nil { + return err + } + + contractProduct := models.GasorderContractProduct{ + Entity: entity(17, "active"), GasorderContractID: contract.ID, + ProductInfoID: productInfo.ID, ProductCode: productInfo.Code, + ProductTypeName: productType.Name, ProductParams: productInfo.Params, + UnitPrice: 9800, BoundAt: yesterday, + } + if err := put(tx, &contractProduct); err != nil { + return err + } + + gasOrder := models.GasorderBasic{ + Entity: entity(18, "completed"), OrderNo: "MOCK-GASORDER-001", + RequestNo: "MOCK-REQ-GASORDER-001", GasorderContractID: contract.ID, + UserAccountID: user.ID, CreatorType: "user", CreatorID: user.ID, + CreatorIdentity: user.Identity, GasBasicID: gas.ID, DeliveryBasicID: delivery.ID, + StaffAccountID: staff.ID, Address: address.Address, Longitude: address.Longitude, + Latitude: address.Latitude, ContactName: user.Name, ContactPhone: user.Phone, + ProductAmount: 9800, DeliveryFee: 500, PayableAmount: 10300, + OperatorIdentity: user.Identity, OperatorName: user.Name, Remark: "示例配送订单", + } + if err := put(tx, &gasOrder); err != nil { + return err + } + + gasOrderItem := models.GasorderItem{ + Entity: entity(19, "completed"), GasorderBasicID: gasOrder.ID, + GasorderContractProductID: contractProduct.ID, ProductInfoID: productInfo.ID, + ProductCode: productInfo.Code, ProductTypeName: productType.Name, + ProductParams: productInfo.Params, UnitPrice: contractProduct.UnitPrice, + } + if err := put(tx, &gasOrderItem); err != nil { + return err + } + + assignment := models.GasorderAssign{ + Entity: entity(20, "completed"), GasorderBasicID: gasOrder.ID, GasBasicID: gas.ID, + DeliveryBasicID: delivery.ID, StaffAccountID: staff.ID, + AssignerIdentity: deliveryAccount.Identity, AssignerName: deliveryAccount.DisplayName, + AssignedAt: now.Add(-2 * time.Hour), Reason: "系统示例派单", + } + if err := put(tx, &assignment); err != nil { + return err + } + + orderStatus := models.GasorderStatus{ + Entity: entity(21, "completed"), GasorderBasicID: gasOrder.ID, + FromStatus: "delivering", ToStatus: "completed", + OperatorIdentity: staff.Identity, OperatorName: staff.Name, + OccurredAt: now, Reason: "用户已签收", + } + if err := put(tx, &orderStatus); err != nil { + return err + } + + trackCompletedAt := now.Add(-10 * time.Minute) + track := models.GasorderTrack{ + Entity: entity(22, "completed"), GasorderBasicID: gasOrder.ID, + StaffAccountID: staff.ID, AttemptNo: 1, StartedAt: now.Add(-90 * time.Minute), + CompletedAt: &trackCompletedAt, + } + if err := put(tx, &track); err != nil { + return err + } + + trackPoint := models.GasorderTrackPoint{ + Entity: entity(23, "completed"), GasorderTrackID: track.ID, + Longitude: address.Longitude, Latitude: address.Latitude, + OccurredAt: trackCompletedAt, Source: "gps", Accuracy: "10m", + } + if err := put(tx, &trackPoint); err != nil { + return err + } + + confirmation := models.GasorderConfirm{ + Entity: entity(24, "completed"), GasorderBasicID: gasOrder.ID, + ConfirmType: "signature", RecipientName: user.Name, RecipientPhone: user.Phone, + ProofURI: "/mock/proofs/gasorder-001.png", ConfirmedAt: now, Remark: "模拟签收", + } + if err := put(tx, &confirmation); err != nil { + return err + } + + category := models.EcCategory{ + Entity: entity(25, "enabled"), Name: "瓶装燃气", SortNo: 10, + } + if err := put(tx, &category); err != nil { + return err + } + + ecProduct := models.EcProduct{ + Entity: entity(26, "enabled"), EcCategoryID: category.ID, + ProductCode: "MOCK-EC-LPG-001", Name: "15kg 液化气配送服务", + PriceAmount: 10300, StockQuantity: 50, + } + if err := put(tx, &ecProduct); err != nil { + return err + } + + attribute := models.EcProductAttribute{ + Entity: entity(27, "enabled"), EcProductID: ecProduct.ID, + Name: "规格", Value: "15kg/瓶", SortNo: 1, + } + if err := put(tx, &attribute); err != nil { + return err + } + + image := models.EcProductImage{ + Entity: entity(28, "enabled"), EcProductID: ecProduct.ID, + ImageURI: "/mock/products/lpg-15kg.png", SortNo: 1, IsCover: true, + } + if err := put(tx, &image); err != nil { + return err + } + + cart := models.EcCart{ + Entity: entity(29, "enabled"), UserAccountID: user.ID, + EcProductID: ecProduct.ID, Quantity: 1, Selected: true, + } + if err := put(tx, &cart); err != nil { + return err + } + + ecOrder := models.EcOrder{ + Entity: entity(30, "paid"), OrderNo: "MOCK-ECORDER-001", + UserAccountID: user.ID, GasStationID: gas.ID, DeliveryPointID: delivery.ID, + TotalAmount: ecProduct.PriceAmount, + } + if err := put(tx, &ecOrder); err != nil { + return err + } + + ecOrderItem := models.EcOrderItem{ + Entity: entity(31, "paid"), EcOrderID: ecOrder.ID, EcProductID: ecProduct.ID, + ProductSnapshot: `{"code":"MOCK-EC-LPG-001","name":"15kg 液化气配送服务"}`, + Quantity: 1, SaleAmount: ecProduct.PriceAmount, + } + if err := put(tx, &ecOrderItem); err != nil { + return err + } + + review := models.EcReview{ + Entity: entity(32, "published"), EcOrderID: ecOrder.ID, + EcProductID: ecProduct.ID, UserAccountID: user.ID, + Score: 5, Content: "配送及时,服务规范。", + } + if err := put(tx, &review); err != nil { + return err + } + + wallet := models.WalletBasic{ + Entity: entity(33, "enabled"), OwnerType: "user", OwnerID: user.ID, + OwnerIdentity: user.Identity, AlipayID: "mock@example.com", AlipayName: user.Name, + WxpayID: "mock_customer", WxpayName: user.Name, + PayPasswordHash: string(passwordHash), Balance: 50000, WithdrawalBalance: 30000, + } + if err := put(tx, &wallet); err != nil { + return err + } + + bank := models.WalletBank{ + Entity: entity(34, "enabled"), WalletBasicID: wallet.ID, + CardNoCiphertext: "mock-ciphertext-card", CardFingerprint: "mock-card-fingerprint-001", + CardNoLast4: "8888", BankName: "示例银行", CardOwner: user.RealName, + IDCardCiphertext: "mock-ciphertext-id", PhoneCiphertext: "mock-ciphertext-phone", + BindID: "MOCK-BIND-001", BankType: "debit", Bank: "mock_bank", + } + if err := put(tx, &bank); err != nil { + return err + } + + walletPayment := models.WalletPayment{ + Entity: entity(35, "success"), WalletBasicID: wallet.ID, + PaymentNo: "MOCK-PAYMENT-001", OrderNo: gasOrder.OrderNo, + TradeNo: "MOCK-TRADE-001", PaymentType: "gasorder", + PayChannel: "balance", PayType: "wallet", Amount: gasOrder.PayableAmount, + Args: `{}`, CallbackMsg: `{"status":"success"}`, + } + if err := put(tx, &walletPayment); err != nil { + return err + } + + walletRecord := models.WalletRecord{ + Entity: entity(36, "completed"), WalletBasicID: wallet.ID, + RecordNo: "MOCK-RECORD-001", RequestNo: "MOCK-REQ-RECORD-001", + Direction: "in", TradeType: "recharge", Amount: 50000, + BalanceAfter: 50000, WithdrawalBalanceAfter: 30000, + InTradeNo: walletPayment.PaymentNo, PayChannel: "manual", + OperatorIdentity: gasAccount.Identity, OperatorName: gasAccount.DisplayName, + Ymd: 20260701, Ym: 202607, Remark: "模拟钱包充值", + } + if err := put(tx, &walletRecord); err != nil { + return err + } + + refundCompletedAt := now + refund := models.WalletRefund{ + Entity: entity(37, "completed"), WalletBasicID: wallet.ID, + WalletPaymentID: walletPayment.ID, RefundNo: "MOCK-REFUND-001", + OrderIdentity: gasOrder.Identity, Amount: 1000, Reason: "模拟部分退款", + OrderInfo: `{"order_no":"MOCK-GASORDER-001"}`, Result: `{"status":"success"}`, + TradeNo: "MOCK-REFUND-TRADE-001", CompletedAt: &refundCompletedAt, + } + if err := put(tx, &refund); err != nil { + return err + } + + applyCash := models.WalletApplyCash{ + Entity: entity(38, "approved"), WalletBasicID: wallet.ID, WalletBankID: bank.ID, + CashNo: "MOCK-CASH-001", RequestNo: "MOCK-REQ-CASH-001", Amount: 5000, + Channel: "bank", TradeNo: "MOCK-CASH-TRADE-001", Remark: "模拟提现", + ReviewerIdentity: gasAccount.Identity, ReviewerName: gasAccount.DisplayName, + ReviewedAt: &now, ReviewReason: "模拟审核通过", CompletedAt: &now, + } + if err := put(tx, &applyCash); err != nil { + return err + } + + gasOrderPayment := models.GasorderPayment{ + Entity: entity(39, "success"), GasorderBasicID: gasOrder.ID, + WalletPaymentID: walletPayment.ID, AttemptNo: 1, Amount: gasOrder.PayableAmount, + } + if err := put(tx, &gasOrderPayment); err != nil { + return err + } + + finPayment := models.FinPayment{ + Entity: entity(40, "paid"), EcOrderID: ecOrder.ID, + Channel: "wallet", Amount: ecOrder.TotalAmount, PaidAt: &now, + } + if err := put(tx, &finPayment); err != nil { + return err + } + + settlement := models.FinSettlement{ + Entity: entity(41, "completed"), SettlementNo: "MOCK-SETTLEMENT-001", + SubjectType: "gas", SubjectID: gas.ID, + PeriodStart: now.AddDate(0, 0, -30), PeriodEnd: now, + } + if err := put(tx, &settlement); err != nil { + return err + } + + reconciliation := models.FinReconciliation{ + Entity: entity(42, "matched"), Channel: "wallet", + BillDate: now, DifferenceAmount: 0, + } + if err := put(tx, &reconciliation); err != nil { + return err + } + + content := models.CmsContent{ + Entity: entity(43, "enabled"), ContentType: "notice", + Title: "模拟数据使用说明", Body: "本内容由 platform-cli mock-data 生成。", + VersionNo: 1, PublishStatus: "published", + } + if err := put(tx, &content); err != nil { + return err + } + + ticket := models.CsTicket{ + Entity: entity(44, "open"), TicketNo: "MOCK-TICKET-001", + UserAccountID: user.ID, Category: "delivery", Priority: "normal", + } + if err := put(tx, &ticket); err != nil { + return err + } + + role := models.PlatformRole{ + Entity: entity(45, "enabled"), RoleCode: "mock_operator", + Name: "模拟运营人员", DataScope: "global", IsSystem: false, + } + if err := put(tx, &role); err != nil { + return err + } + + platformAccount := models.PlatformAccount{ + Entity: entity(46, "enabled"), Username: "mock_operator", + DisplayName: "模拟运营人员", PasswordHash: string(passwordHash), + PlatformRoleCode: role.RoleCode, Phone: "13600000001", + } + if err := put(tx, &platformAccount); err != nil { + return err + } + + var menu models.PlatformMenu + if err := tx.Where("menu_code = ?", "dashboard").First(&menu).Error; err != nil { + return fmt.Errorf("find dashboard menu: %w", err) + } + roleMenu := models.PlatformRoleMenu{ + PlatformRoleID: role.ID, PlatformMenuID: menu.ID, + } + if err := tx.Where( + "platform_role_id = ? AND platform_menu_id = ?", + role.ID, menu.ID, + ).FirstOrCreate(&roleMenu).Error; err != nil { + return fmt.Errorf("seed platform_role_menu: %w", err) + } + return nil + }) +} + +func entity(sequence int, status string) models.Entity { + return models.Entity{ + Identity: fmt.Sprintf("%s%012d", mockIdentityPrefix, sequence), + Status: status, + Version: 1, + } +} + +func put[T any](database *gorm.DB, value *T) error { + identity := identityOf(value) + if identity == "" { + return fmt.Errorf("seed %T: missing identity", value) + } + if err := database.Where("identity = ?", identity).FirstOrCreate(value).Error; err != nil { + return fmt.Errorf("seed %T: %w", value, err) + } + return nil +} + +func identityOf(value any) string { + record := reflect.Indirect(reflect.ValueOf(value)) + if !record.IsValid() { + return "" + } + entityField := record.FieldByName("Entity") + if !entityField.IsValid() { + return "" + } + identityField := entityField.FieldByName("Identity") + if !identityField.IsValid() || identityField.Kind() != reflect.String { + return "" + } + return identityField.String() +} diff --git a/backend/api/internal/seed/mock_test.go b/backend/api/internal/seed/mock_test.go new file mode 100644 index 0000000..5f436bb --- /dev/null +++ b/backend/api/internal/seed/mock_test.go @@ -0,0 +1,41 @@ +package seed + +import ( + "regexp" + "testing" +) + +func TestMockEntityIdentitiesAreStableAndUnique(t *testing.T) { + identityPattern := regexp.MustCompile( + `^00000000-0000-7000-8000-[0-9]{12}$`, + ) + seen := make(map[string]struct{}, 46) + for sequence := 1; sequence <= 46; sequence++ { + record := entity(sequence, "enabled") + if !identityPattern.MatchString(record.Identity) { + t.Fatalf("entity(%d) identity = %q", sequence, record.Identity) + } + if _, exists := seen[record.Identity]; exists { + t.Fatalf("duplicate identity %q", record.Identity) + } + seen[record.Identity] = struct{}{} + if record.Status != "enabled" || record.Version != 1 { + t.Fatalf("entity(%d) has unexpected defaults: %#v", sequence, record) + } + } +} + +func TestIdentityOfEmbeddedEntity(t *testing.T) { + record := struct { + Entity struct { + Identity string + } + }{} + record.Entity.Identity = "mock-identity" + if got := identityOf(&record); got != record.Entity.Identity { + t.Fatalf("identityOf() = %q, want %q", got, record.Entity.Identity) + } + if got := identityOf(nil); got != "" { + t.Fatalf("identityOf(nil) = %q, want empty", got) + } +}