新增支付配置备份导入

This commit is contained in:
yml2213
2026-06-07 20:17:58 +08:00
parent a5e55bc045
commit 44747e7bae
8 changed files with 318 additions and 1 deletions
@@ -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"`
}
@@ -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 管理后台-支付配置
@@ -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"
@@ -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)
}
}
@@ -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)