From 44747e7baefbd718c9c7ecf3d3aa77acbe798729 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 7 Jun 2026 20:17:58 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=94=AF=E4=BB=98=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E5=A4=87=E4=BB=BD=E5=AF=BC=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/modules/paymentconfig/dto.go | 9 + .../internal/modules/paymentconfig/handler.go | 40 +++++ .../modules/paymentconfig/repository.go | 158 ++++++++++++++++++ .../modules/paymentconfig/repository_test.go | 35 ++++ .../internal/modules/paymentconfig/service.go | 5 + backend/internal/router/router.go | 1 + .../src/features/admin/api/paymentConfig.ts | 11 ++ .../admin/views/AdminPaymentConfigsView.vue | 60 ++++++- 8 files changed, 318 insertions(+), 1 deletion(-) create mode 100644 backend/internal/modules/paymentconfig/repository_test.go diff --git a/backend/internal/modules/paymentconfig/dto.go b/backend/internal/modules/paymentconfig/dto.go index 38e456e..0ba660b 100644 --- a/backend/internal/modules/paymentconfig/dto.go +++ b/backend/internal/modules/paymentconfig/dto.go @@ -7,6 +7,8 @@ var ( ErrDuplicateDefault = errors.New("duplicate default config for provider") ErrInvalidProvider = errors.New("invalid payment provider") ErrInvalidStatus = errors.New("invalid status") + ErrInvalidBackup = errors.New("invalid payment config backup") + ErrEmptyBackup = errors.New("payment config backup is empty") ErrCannotDeleteInUse = errors.New("cannot delete config in use") ErrEncryptionFailed = errors.New("encryption failed") ErrDecryptionFailed = errors.New("decryption failed") @@ -116,3 +118,10 @@ type ExportBackup struct { Total int `json:"total"` Configs []ConfigDTO `json:"configs"` } + +// ImportBackupResult 支付配置备份导入结果。 +type ImportBackupResult struct { + Total int `json:"total"` + Created int `json:"created"` + Updated int `json:"updated"` +} diff --git a/backend/internal/modules/paymentconfig/handler.go b/backend/internal/modules/paymentconfig/handler.go index 8bfe3da..b66bba9 100644 --- a/backend/internal/modules/paymentconfig/handler.go +++ b/backend/internal/modules/paymentconfig/handler.go @@ -109,6 +109,46 @@ func (h *Handler) ExportBackup(c *gin.Context) { c.Data(http.StatusOK, contentType, data) } +// ImportBackup 导入支付配置备份 +// @Summary 导入支付配置备份 +// @Tags 管理后台-支付配置 +// @Param body body ExportBackup true "支付配置备份 JSON" +// @Success 200 {object} response.Response{data=ImportBackupResult} +// @Router /admin/payment-configs/import [post] +func (h *Handler) ImportBackup(c *gin.Context) { + adminID, ok := currentAdminID(c) + if !ok { + response.Unauthorized(c, "未授权") + return + } + + var backup ExportBackup + if err := c.ShouldBindJSON(&backup); err != nil { + response.BadRequest(c, "备份文件格式错误") + return + } + + result, err := h.service.ImportBackup(backup, adminID, auditMeta(c)) + if err == ErrInvalidBackup || err == ErrEmptyBackup || err == ErrInvalidProvider || + err == ErrNameRequired || err == ErrMerchantIDRequired || err == ErrGatewayURLRequired || + err == ErrSignKeyRequired || err == ErrNotifyKeyRequired || err == ErrNotifyURLRequired || + err == ErrInvalidSignType || err == ErrInvalidStatus || err == ErrAppIDRequired || + err == ErrSerialNoRequired || err == ErrTermNoRequired { + response.BadRequest(c, err.Error()) + return + } + if err == ErrEncryptionFailed { + response.Error(c, http.StatusInternalServerError, "encrypt_failed", "密钥加密失败") + return + } + if err != nil { + response.Error(c, http.StatusInternalServerError, "internal_error", "导入失败") + return + } + + response.OK(c, result) +} + // Create 创建支付配置 // @Summary 创建支付配置 // @Tags 管理后台-支付配置 diff --git a/backend/internal/modules/paymentconfig/repository.go b/backend/internal/modules/paymentconfig/repository.go index cfaeb28..816f45d 100644 --- a/backend/internal/modules/paymentconfig/repository.go +++ b/backend/internal/modules/paymentconfig/repository.go @@ -192,6 +192,112 @@ func (r *Repository) ExportBackup(actorID uint64, meta AuditMeta) (*ExportBackup return backup, nil } +// ImportBackup 导入支付配置备份,按 provider + merchant_id 更新或新增。 +func (r *Repository) ImportBackup(backup ExportBackup, actorID uint64, meta AuditMeta) (*ImportBackupResult, error) { + if backup.Type != "payment_config_backup" || backup.Version <= 0 { + return nil, ErrInvalidBackup + } + if len(backup.Configs) == 0 { + return nil, ErrEmptyBackup + } + + activeKey := backupActiveKey(backup.Configs) + result := &ImportBackupResult{Total: len(backup.Configs)} + + err := r.db.Transaction(func(tx *gorm.DB) error { + if activeKey != "" { + if err := deactivateOtherConfigs(tx, 0, actorID); err != nil { + return err + } + } + + for _, cfg := range backup.Configs { + req := importCreateRequest(cfg, activeKey) + if err := r.validateCreateRequest(req); err != nil { + return err + } + + encryptedSignKey, err := r.encryptor.Encrypt(req.SignKey) + if err != nil { + return ErrEncryptionFailed + } + encryptedNotifyKey, err := r.encryptor.Encrypt(req.NotifyKey) + if err != nil { + return ErrEncryptionFailed + } + + updates := map[string]any{ + "name": req.Name, + "gateway_url": req.GatewayURL, + "sign_key": encryptedSignKey, + "notify_key": encryptedNotifyKey, + "notify_url": req.NotifyURL, + "jump_url": req.JumpURL, + "pay_way": firstNonEmpty(req.PayWay, "ZFBZF"), + "jspay_flag": firstNonEmpty(req.JSPayFlag, "2"), + "sign_type": firstNonEmpty(req.SignType, defaultSignType(req.Provider)), + "extra_config": model.JSONMap(req.ExtraConfig), + "is_default": req.IsDefault, + "status": firstNonEmpty(req.Status, "active"), + "environment": firstNonEmpty(req.Environment, "production"), + "business_tags": model.JSONArray(req.BusinessTags), + "updated_by": actorID, + } + + var existing model.PaymentMerchantConfig + err = tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("provider = ? AND merchant_id = ?", req.Provider, req.MerchantID). + First(&existing).Error + if err == nil { + if err := tx.Model(&existing).Updates(updates).Error; err != nil { + return err + } + result.Updated++ + continue + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + + createdBy := actorID + item := model.PaymentMerchantConfig{ + Name: req.Name, + Provider: req.Provider, + MerchantID: req.MerchantID, + GatewayURL: req.GatewayURL, + SignKey: encryptedSignKey, + NotifyKey: encryptedNotifyKey, + NotifyURL: req.NotifyURL, + JumpURL: req.JumpURL, + PayWay: firstNonEmpty(req.PayWay, "ZFBZF"), + JSPayFlag: firstNonEmpty(req.JSPayFlag, "2"), + SignType: firstNonEmpty(req.SignType, defaultSignType(req.Provider)), + ExtraConfig: model.JSONMap(req.ExtraConfig), + IsDefault: req.IsDefault, + Status: firstNonEmpty(req.Status, "active"), + Environment: firstNonEmpty(req.Environment, "production"), + BusinessTags: req.BusinessTags, + CreatedBy: &createdBy, + UpdatedBy: &actorID, + } + if err := tx.Create(&item).Error; err != nil { + return err + } + result.Created++ + } + + return appendAuditLog(tx, actorID, "payment_config.import", 0, meta, map[string]any{ + "total": result.Total, + "created": result.Created, + "updated": result.Updated, + }) + }) + if err != nil { + return nil, err + } + return result, nil +} + // FindActiveByProvider 查询提供商的所有激活配置 func (r *Repository) FindActiveByProvider(provider string) ([]model.PaymentMerchantConfig, error) { var items []model.PaymentMerchantConfig @@ -547,6 +653,9 @@ func (r *Repository) validateCreateRequest(req CreateRequest) error { if req.Provider == "" { return ErrInvalidProvider } + if !isValidProvider(req.Provider) { + return ErrInvalidProvider + } if req.MerchantID == "" { return ErrMerchantIDRequired } @@ -608,6 +717,55 @@ func firstNonEmpty(vals ...string) string { return "" } +func isValidProvider(value string) bool { + return value == "leshua" || value == "lakala" || value == "mock" +} + +func backupActiveKey(configs []ConfigDTO) string { + for _, cfg := range configs { + if cfg.Status == "active" || cfg.IsDefault { + return paymentConfigImportKey(cfg.Provider, cfg.MerchantID) + } + } + return "" +} + +func importCreateRequest(cfg ConfigDTO, activeKey string) CreateRequest { + status := cfg.Status + if status == "" { + status = "disabled" + } + isActive := activeKey != "" && paymentConfigImportKey(cfg.Provider, cfg.MerchantID) == activeKey + if isActive { + status = "active" + } else if status == "active" { + status = "disabled" + } + + return CreateRequest{ + Name: cfg.Name, + Provider: cfg.Provider, + MerchantID: cfg.MerchantID, + GatewayURL: cfg.GatewayURL, + SignKey: cfg.SignKey, + NotifyKey: cfg.NotifyKey, + NotifyURL: cfg.NotifyURL, + JumpURL: cfg.JumpURL, + PayWay: cfg.PayWay, + JSPayFlag: cfg.JSPayFlag, + SignType: cfg.SignType, + ExtraConfig: cfg.ExtraConfig, + IsDefault: isActive, + Status: status, + Environment: cfg.Environment, + BusinessTags: cfg.BusinessTags, + } +} + +func paymentConfigImportKey(provider string, merchantID string) string { + return provider + ":" + merchantID +} + func defaultSignType(provider string) string { if provider == "lakala" { return "SHA256withRSA" diff --git a/backend/internal/modules/paymentconfig/repository_test.go b/backend/internal/modules/paymentconfig/repository_test.go new file mode 100644 index 0000000..9d25b68 --- /dev/null +++ b/backend/internal/modules/paymentconfig/repository_test.go @@ -0,0 +1,35 @@ +package paymentconfig + +import "testing" + +func TestImportCreateRequestKeepsOnlyOneActiveConfig(t *testing.T) { + configs := []ConfigDTO{ + {Provider: "lakala", MerchantID: "M1", Status: "active", IsDefault: true}, + {Provider: "leshua", MerchantID: "M2", Status: "active", IsDefault: false}, + } + activeKey := backupActiveKey(configs) + + first := importCreateRequest(configs[0], activeKey) + second := importCreateRequest(configs[1], activeKey) + + if first.Status != "active" || !first.IsDefault { + t.Fatalf("first config status/default = %s/%v, want active/true", first.Status, first.IsDefault) + } + if second.Status != "disabled" || second.IsDefault { + t.Fatalf("second config status/default = %s/%v, want disabled/false", second.Status, second.IsDefault) + } +} + +func TestImportCreateRequestPreservesTestingConfig(t *testing.T) { + cfg := ConfigDTO{ + Provider: "lakala", + MerchantID: "M1", + Status: "testing", + } + + req := importCreateRequest(cfg, "") + + if req.Status != "testing" || req.IsDefault { + t.Fatalf("status/default = %s/%v, want testing/false", req.Status, req.IsDefault) + } +} diff --git a/backend/internal/modules/paymentconfig/service.go b/backend/internal/modules/paymentconfig/service.go index b3dcaa4..efe1781 100644 --- a/backend/internal/modules/paymentconfig/service.go +++ b/backend/internal/modules/paymentconfig/service.go @@ -42,6 +42,11 @@ func (s *Service) ExportBackup(actorID uint64, meta AuditMeta) (*ExportBackup, e return s.repo.ExportBackup(actorID, meta) } +// ImportBackup 导入支付配置备份。 +func (s *Service) ImportBackup(backup ExportBackup, actorID uint64, meta AuditMeta) (*ImportBackupResult, error) { + return s.repo.ImportBackup(backup, actorID, meta) +} + // Create 创建配置 func (s *Service) Create(req CreateRequest, actorID uint64, meta AuditMeta) (*ConfigDTO, error) { return s.repo.Create(req, actorID, meta) diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 7616789..117030f 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -425,6 +425,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { if paymentConfigHandler != nil { adminRoutes.GET("/payment-configs", requirePerm("payment_config:list"), paymentConfigHandler.List) adminRoutes.GET("/payment-configs/export", requirePerm("payment_config:view_secret"), paymentConfigHandler.ExportBackup) + adminRoutes.POST("/payment-configs/import", requirePerm("payment_config:view_secret"), paymentConfigHandler.ImportBackup) adminRoutes.GET("/payment-configs/:id", requirePerm("payment_config:list"), paymentConfigHandler.Get) adminRoutes.POST("/payment-configs", requirePerm("payment_config:create"), paymentConfigHandler.Create) adminRoutes.PUT("/payment-configs/:id", requirePerm("payment_config:update"), paymentConfigHandler.Update) diff --git a/frontend/src/features/admin/api/paymentConfig.ts b/frontend/src/features/admin/api/paymentConfig.ts index 79574fd..775423a 100644 --- a/frontend/src/features/admin/api/paymentConfig.ts +++ b/frontend/src/features/admin/api/paymentConfig.ts @@ -35,6 +35,12 @@ export interface PaymentConfigListResponse { page_size: number } +export interface PaymentConfigImportResult { + total: number + created: number + updated: number +} + export interface CreatePaymentConfigRequest { name: string provider: string @@ -102,6 +108,11 @@ export async function exportPaymentConfigBackup() { } } +export async function importPaymentConfigBackup(payload: unknown) { + const { data } = await apiClient.post>('/admin/payment-configs/import', payload) + return data.data +} + export async function createPaymentConfig(payload: CreatePaymentConfigRequest) { const { data } = await apiClient.post>('/admin/payment-configs', payload) return data.data diff --git a/frontend/src/features/admin/views/AdminPaymentConfigsView.vue b/frontend/src/features/admin/views/AdminPaymentConfigsView.vue index c49fbfb..5b56138 100644 --- a/frontend/src/features/admin/views/AdminPaymentConfigsView.vue +++ b/frontend/src/features/admin/views/AdminPaymentConfigsView.vue @@ -1,12 +1,13 @@