From 78cb681bec57731f25ab31d6450b1a8f901517ab Mon Sep 17 00:00:00 2001 From: yml2213 Date: Wed, 10 Jun 2026 14:31:05 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8B=86=E5=88=86=E6=94=AF=E4=BB=98=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=20Repository=20=E6=96=87=E4=BB=B6=E8=81=8C=E8=B4=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/modules/paymentconfig/audit.go | 27 + .../internal/modules/paymentconfig/backup.go | 206 +++++ .../modules/paymentconfig/mutation.go | 265 ++++++ .../modules/paymentconfig/presenter.go | 73 ++ .../internal/modules/paymentconfig/query.go | 161 ++++ .../modules/paymentconfig/repository.go | 792 ------------------ .../internal/modules/paymentconfig/usage.go | 59 ++ .../modules/paymentconfig/validation.go | 90 ++ 8 files changed, 881 insertions(+), 792 deletions(-) create mode 100644 backend/internal/modules/paymentconfig/audit.go create mode 100644 backend/internal/modules/paymentconfig/backup.go create mode 100644 backend/internal/modules/paymentconfig/mutation.go create mode 100644 backend/internal/modules/paymentconfig/presenter.go create mode 100644 backend/internal/modules/paymentconfig/query.go create mode 100644 backend/internal/modules/paymentconfig/usage.go create mode 100644 backend/internal/modules/paymentconfig/validation.go diff --git a/backend/internal/modules/paymentconfig/audit.go b/backend/internal/modules/paymentconfig/audit.go new file mode 100644 index 0000000..305fb42 --- /dev/null +++ b/backend/internal/modules/paymentconfig/audit.go @@ -0,0 +1,27 @@ +package paymentconfig + +import ( + "encoding/json" + + "hfb_sys/backend/internal/model" + + "gorm.io/gorm" +) + +func appendAuditLog(tx *gorm.DB, actorID uint64, action string, bizID uint64, meta AuditMeta, detail map[string]any) error { + detailJSON, err := json.Marshal(detail) + if err != nil { + return err + } + log := model.AuditLog{ + ActorType: "admin", + ActorID: actorID, + Action: action, + BizType: "payment_config", + BizID: &bizID, + IP: meta.IP, + UserAgent: meta.UserAgent, + Detail: detailJSON, + } + return tx.Create(&log).Error +} diff --git a/backend/internal/modules/paymentconfig/backup.go b/backend/internal/modules/paymentconfig/backup.go new file mode 100644 index 0000000..e20fbb9 --- /dev/null +++ b/backend/internal/modules/paymentconfig/backup.go @@ -0,0 +1,206 @@ +package paymentconfig + +import ( + "context" + "errors" + "time" + + "hfb_sys/backend/internal/model" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// ExportBackup 导出所有支付配置备份,包含解密后的密钥。 +func (r *Repository) ExportBackup(ctx context.Context, actorID uint64, meta AuditMeta) (*ExportBackup, error) { + var backup *ExportBackup + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var items []model.PaymentMerchantConfig + if err := tx.Order("is_default DESC, id DESC").Find(&items).Error; err != nil { + return err + } + + configs := make([]ConfigDTO, 0, len(items)) + for _, item := range items { + dto, err := r.toDTO(item, true) + if err != nil { + return err + } + configs = append(configs, dto) + } + + exportedAt := time.Now().Format(time.RFC3339) + backup = &ExportBackup{ + Type: "payment_config_backup", + Version: 1, + ExportedAt: exportedAt, + ExportedBy: actorID, + Total: len(configs), + Configs: configs, + } + + return appendAuditLog(tx, actorID, "payment_config.export", 0, meta, map[string]any{ + "total": len(configs), + "exported_at": exportedAt, + }) + }) + if err != nil { + return nil, err + } + return backup, nil +} + +// ImportBackup 导入支付配置备份,按 provider + merchant_id 更新或新增。 + +// ImportBackup 导入支付配置备份,按 provider + merchant_id 更新或新增。 +func (r *Repository) ImportBackup(ctx context.Context, 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.WithContext(ctx).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 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 +} diff --git a/backend/internal/modules/paymentconfig/mutation.go b/backend/internal/modules/paymentconfig/mutation.go new file mode 100644 index 0000000..41fbcff --- /dev/null +++ b/backend/internal/modules/paymentconfig/mutation.go @@ -0,0 +1,265 @@ +package paymentconfig + +import ( + "context" + "errors" + + "hfb_sys/backend/internal/model" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// Create 创建配置 +func (r *Repository) Create(ctx context.Context, req CreateRequest, actorID uint64, meta AuditMeta) (*ConfigDTO, error) { + // 验证必填字段 + if err := r.validateCreateRequest(req); err != nil { + return nil, err + } + + var dto ConfigDTO + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + // 加密密钥 + encryptedSignKey, err := r.encryptor.Encrypt(req.SignKey) + if err != nil { + return ErrEncryptionFailed + } + encryptedNotifyKey, err := r.encryptor.Encrypt(req.NotifyKey) + if err != nil { + return ErrEncryptionFailed + } + + status := req.Status + if status == "" { + status = "active" + } + environment := req.Environment + if environment == "" { + environment = "production" + } + isDefault := req.IsDefault + if status == "active" { + if err := deactivateOtherConfigs(tx, 0, actorID); err != nil { + return err + } + isDefault = true + } else { + isDefault = false + } + + 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: req.ExtraConfig, + IsDefault: isDefault, + Status: status, + Environment: environment, + BusinessTags: req.BusinessTags, + CreatedBy: &actorID, + UpdatedBy: &actorID, + } + + if err := tx.Create(&item).Error; err != nil { + return err + } + + // 记录审计日志 + if err := appendAuditLog(tx, actorID, "payment_config.create", item.ID, meta, map[string]any{ + "name": item.Name, + "provider": item.Provider, + }); err != nil { + return err + } + + dto, err = r.toDTO(item, false) + return err + }) + + if err != nil { + return nil, err + } + return &dto, nil +} + +// Update 更新配置 + +// Update 更新配置 +func (r *Repository) Update(ctx context.Context, id uint64, req UpdateRequest, actorID uint64, meta AuditMeta) (*ConfigDTO, error) { + if req.SignType != nil && *req.SignType != "" && !isValidSignType(*req.SignType) { + return nil, ErrInvalidSignType + } + if req.Status != nil && !isValidStatus(*req.Status) { + return nil, ErrInvalidStatus + } + if req.NotifyURL != nil && *req.NotifyURL == "" { + return nil, ErrNotifyURLRequired + } + + var dto ConfigDTO + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var item model.PaymentMerchantConfig + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", id).First(&item).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrConfigNotFound + } + return err + } + + updates := make(map[string]any) + if req.Name != nil { + updates["name"] = *req.Name + } + if req.MerchantID != nil { + updates["merchant_id"] = *req.MerchantID + } + if req.GatewayURL != nil { + updates["gateway_url"] = *req.GatewayURL + } + if req.SignKey != nil { + encrypted, err := r.encryptor.Encrypt(*req.SignKey) + if err != nil { + return ErrEncryptionFailed + } + updates["sign_key"] = encrypted + } + if req.NotifyKey != nil { + encrypted, err := r.encryptor.Encrypt(*req.NotifyKey) + if err != nil { + return ErrEncryptionFailed + } + updates["notify_key"] = encrypted + } + if req.NotifyURL != nil { + updates["notify_url"] = *req.NotifyURL + } + if req.JumpURL != nil { + updates["jump_url"] = *req.JumpURL + } + if req.PayWay != nil { + updates["pay_way"] = *req.PayWay + } + if req.JSPayFlag != nil { + updates["jspay_flag"] = *req.JSPayFlag + } + if req.SignType != nil { + updates["sign_type"] = *req.SignType + } + if req.ExtraConfig != nil { + updates["extra_config"] = model.JSONMap(req.ExtraConfig) + } + if req.IsDefault != nil { + updates["is_default"] = *req.IsDefault + } + if req.Status != nil { + updates["status"] = *req.Status + } + if req.Environment != nil { + updates["environment"] = *req.Environment + } + if req.BusinessTags != nil { + updates["business_tags"] = model.JSONArray(req.BusinessTags) + } + updates["updated_by"] = actorID + + finalStatus := item.Status + if req.Status != nil { + finalStatus = *req.Status + } + if finalStatus == "active" { + if err := deactivateOtherConfigs(tx, id, actorID); err != nil { + return err + } + updates["status"] = "active" + updates["is_default"] = true + } else { + updates["is_default"] = false + } + + if err := tx.Model(&item).Updates(updates).Error; err != nil { + return err + } + + // 记录审计日志 + if err := appendAuditLog(tx, actorID, "payment_config.update", item.ID, meta, map[string]any{ + "updates": updates, + }); err != nil { + return err + } + + // 重新查询 + if err := tx.Where("id = ?", id).First(&item).Error; err != nil { + return err + } + + var dtoErr error + dto, dtoErr = r.toDTO(item, false) + return dtoErr + }) + + if err != nil { + return nil, err + } + return &dto, nil +} + +// Delete 删除配置 + +// Delete 删除配置 +func (r *Repository) Delete(ctx context.Context, id uint64, actorID uint64, meta AuditMeta) error { + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var item model.PaymentMerchantConfig + if err := tx.Where("id = ?", id).First(&item).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrConfigNotFound + } + return err + } + + var usedCount int64 + if err := tx.Model(&model.PaymentOrder{}). + Where("provider = ? AND merchant_id = ?", item.Provider, item.MerchantID). + Count(&usedCount).Error; err != nil { + return err + } + if usedCount > 0 { + return ErrCannotDeleteInUse + } + + if err := tx.Delete(&item).Error; err != nil { + return err + } + + // 记录审计日志 + return appendAuditLog(tx, actorID, "payment_config.delete", item.ID, meta, map[string]any{ + "name": item.Name, + "provider": item.Provider, + }) + }) +} + +// IncrementUsage 增加使用统计 + +// deactivateOtherConfigs 保证全局同一时间只有一个启用配置。 +func deactivateOtherConfigs(tx *gorm.DB, activeID uint64, actorID uint64) error { + db := tx.Model(&model.PaymentMerchantConfig{}). + Where("status = ? OR is_default = ?", "active", true) + if activeID > 0 { + db = db.Where("id != ?", activeID) + } + return db.Updates(map[string]any{ + "status": "disabled", + "is_default": false, + "updated_by": actorID, + }).Error +} + +// toDTO 转换为 DTO diff --git a/backend/internal/modules/paymentconfig/presenter.go b/backend/internal/modules/paymentconfig/presenter.go new file mode 100644 index 0000000..386f07e --- /dev/null +++ b/backend/internal/modules/paymentconfig/presenter.go @@ -0,0 +1,73 @@ +package paymentconfig + +import ( + "time" + + "hfb_sys/backend/internal/model" +) + +// toDTO 转换为 DTO +func (r *Repository) toDTO(item model.PaymentMerchantConfig, includeSecret bool) (ConfigDTO, error) { + dto := ConfigDTO{ + ID: item.ID, + Name: item.Name, + Provider: item.Provider, + MerchantID: item.MerchantID, + GatewayURL: item.GatewayURL, + NotifyURL: item.NotifyURL, + JumpURL: item.JumpURL, + PayWay: item.PayWay, + JSPayFlag: item.JSPayFlag, + SignType: item.SignType, + ExtraConfig: item.ExtraConfig, + IsDefault: item.IsDefault, + Status: item.Status, + Environment: item.Environment, + BusinessTags: item.BusinessTags, + TotalTransactions: item.TotalTransactions, + TotalAmountCent: item.TotalAmountCent, + CreatedBy: item.CreatedBy, + UpdatedBy: item.UpdatedBy, + CreatedAt: item.CreatedAt.Format(time.RFC3339), + UpdatedAt: item.UpdatedAt.Format(time.RFC3339), + } + + if item.LastUsedAt != nil { + s := item.LastUsedAt.Format(time.RFC3339) + dto.LastUsedAt = &s + } + + // 只有明确要求时才解密并返回密钥 + if includeSecret { + signKey, err := r.encryptor.Decrypt(item.SignKey) + if err != nil { + return ConfigDTO{}, ErrDecryptionFailed + } + notifyKey, err := r.encryptor.Decrypt(item.NotifyKey) + if err != nil { + return ConfigDTO{}, ErrDecryptionFailed + } + dto.SignKey = signKey + dto.NotifyKey = notifyKey + } + + return dto, nil +} + +// validateCreateRequest 验证创建请求 + +func firstNonEmpty(vals ...string) string { + for _, v := range vals { + if v != "" { + return v + } + } + return "" +} + +func defaultSignType(provider string) string { + if provider == "lakala" { + return "SHA256withRSA" + } + return "MD5" +} diff --git a/backend/internal/modules/paymentconfig/query.go b/backend/internal/modules/paymentconfig/query.go new file mode 100644 index 0000000..3bbebe9 --- /dev/null +++ b/backend/internal/modules/paymentconfig/query.go @@ -0,0 +1,161 @@ +package paymentconfig + +import ( + "context" + "errors" + + "hfb_sys/backend/internal/model" + + "gorm.io/gorm" +) + +// List 获取配置列表 +func (r *Repository) List(ctx context.Context, query ListQuery) ([]ConfigDTO, int64, error) { + var items []model.PaymentMerchantConfig + var total int64 + + db := r.db.WithContext(ctx).Model(&model.PaymentMerchantConfig{}) + + // 过滤条件 + if query.Provider != "" { + db = db.Where("provider = ?", query.Provider) + } + if query.Status != "" { + db = db.Where("status = ?", query.Status) + } + if query.Environment != "" { + db = db.Where("environment = ?", query.Environment) + } + + // 获取总数 + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + + // 分页 + page := query.Page + if page < 1 { + page = 1 + } + pageSize := query.PageSize + if pageSize < 1 { + pageSize = 20 + } + if pageSize > 100 { + pageSize = 100 + } + + offset := (page - 1) * pageSize + if err := db.Order("is_default DESC, id DESC").Offset(offset).Limit(pageSize).Find(&items).Error; err != nil { + return nil, 0, err + } + + dtos := make([]ConfigDTO, 0, len(items)) + for _, item := range items { + dto, err := r.toDTO(item, false) + if err != nil { + return nil, 0, err + } + dtos = append(dtos, dto) + } + + return dtos, total, nil +} + +// FindByID 根据 ID 查询配置 + +// FindByID 根据 ID 查询配置 +func (r *Repository) FindByID(ctx context.Context, id uint64, includeSecret bool) (*ConfigDTO, error) { + var item model.PaymentMerchantConfig + if err := r.db.WithContext(ctx).Where("id = ?", id).First(&item).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrConfigNotFound + } + return nil, err + } + dto, err := r.toDTO(item, includeSecret) + if err != nil { + return nil, err + } + return &dto, nil +} + +// FindDefault 查询默认配置 + +// FindDefault 查询默认配置 +func (r *Repository) FindDefault(ctx context.Context, provider string) (*model.PaymentMerchantConfig, error) { + var item model.PaymentMerchantConfig + if err := r.db.WithContext(ctx).Where("provider = ? AND is_default = ? AND status = ?", provider, true, "active").First(&item).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrNoActiveConfigFound + } + return nil, err + } + return &item, nil +} + +// FindDefaultAny 查询任意服务商的默认启用配置。 + +// FindDefaultAny 查询任意服务商的默认启用配置。 +func (r *Repository) FindDefaultAny(ctx context.Context, includeSecret bool) (*ConfigDTO, error) { + var item model.PaymentMerchantConfig + if err := r.db.WithContext(ctx).Where("status = ?", "active").Order("is_default DESC, id DESC").First(&item).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrNoActiveConfigFound + } + return nil, err + } + dto, err := r.toDTO(item, includeSecret) + if err != nil { + return nil, err + } + return &dto, nil +} + +// FindDefaultByProvider 查询指定服务商的默认启用配置。 + +// FindDefaultByProvider 查询指定服务商的默认启用配置。 +func (r *Repository) FindDefaultByProvider(ctx context.Context, provider string, includeSecret bool) (*ConfigDTO, error) { + item, err := r.FindDefault(ctx, provider) + if err != nil { + return nil, err + } + dto, err := r.toDTO(*item, includeSecret) + if err != nil { + return nil, err + } + return &dto, nil +} + +// FindByProviderMerchant 根据服务商和商户号查配置,用于历史支付单继续使用原商户密钥。 + +// FindByProviderMerchant 根据服务商和商户号查配置,用于历史支付单继续使用原商户密钥。 +func (r *Repository) FindByProviderMerchant(ctx context.Context, provider string, merchantID string, includeSecret bool) (*ConfigDTO, error) { + var item model.PaymentMerchantConfig + if err := r.db.WithContext(ctx).Where("provider = ? AND merchant_id = ?", provider, merchantID). + Order("status = 'active' DESC, is_default DESC, id DESC"). + First(&item).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrConfigNotFound + } + return nil, err + } + dto, err := r.toDTO(item, includeSecret) + if err != nil { + return nil, err + } + return &dto, nil +} + +// ExportBackup 导出所有支付配置备份,包含解密后的密钥。 + +// FindActiveByProvider 查询提供商的所有激活配置 +func (r *Repository) FindActiveByProvider(ctx context.Context, provider string) ([]model.PaymentMerchantConfig, error) { + var items []model.PaymentMerchantConfig + if err := r.db.WithContext(ctx).Where("provider = ? AND status = ?", provider, "active").Order("is_default DESC, id DESC").Find(&items).Error; err != nil { + return nil, err + } + return items, nil +} + +// Create 创建配置 diff --git a/backend/internal/modules/paymentconfig/repository.go b/backend/internal/modules/paymentconfig/repository.go index b86fe4b..b95f0af 100644 --- a/backend/internal/modules/paymentconfig/repository.go +++ b/backend/internal/modules/paymentconfig/repository.go @@ -1,15 +1,9 @@ package paymentconfig import ( - "context" - "encoding/json" - "errors" "hfb_sys/backend/internal/auditlog" - "hfb_sys/backend/internal/model" - "time" "gorm.io/gorm" - "gorm.io/gorm/clause" ) type Repository struct { @@ -27,789 +21,3 @@ func NewRepository(db *gorm.DB, encryptor Encryptor) *Repository { } // List 获取配置列表 -func (r *Repository) List(ctx context.Context, query ListQuery) ([]ConfigDTO, int64, error) { - var items []model.PaymentMerchantConfig - var total int64 - - db := r.db.WithContext(ctx).Model(&model.PaymentMerchantConfig{}) - - // 过滤条件 - if query.Provider != "" { - db = db.Where("provider = ?", query.Provider) - } - if query.Status != "" { - db = db.Where("status = ?", query.Status) - } - if query.Environment != "" { - db = db.Where("environment = ?", query.Environment) - } - - // 获取总数 - if err := db.Count(&total).Error; err != nil { - return nil, 0, err - } - - // 分页 - page := query.Page - if page < 1 { - page = 1 - } - pageSize := query.PageSize - if pageSize < 1 { - pageSize = 20 - } - if pageSize > 100 { - pageSize = 100 - } - - offset := (page - 1) * pageSize - if err := db.Order("is_default DESC, id DESC").Offset(offset).Limit(pageSize).Find(&items).Error; err != nil { - return nil, 0, err - } - - dtos := make([]ConfigDTO, 0, len(items)) - for _, item := range items { - dto, err := r.toDTO(item, false) - if err != nil { - return nil, 0, err - } - dtos = append(dtos, dto) - } - - return dtos, total, nil -} - -// FindByID 根据 ID 查询配置 -func (r *Repository) FindByID(ctx context.Context, id uint64, includeSecret bool) (*ConfigDTO, error) { - var item model.PaymentMerchantConfig - if err := r.db.WithContext(ctx).Where("id = ?", id).First(&item).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, ErrConfigNotFound - } - return nil, err - } - dto, err := r.toDTO(item, includeSecret) - if err != nil { - return nil, err - } - return &dto, nil -} - -// FindDefault 查询默认配置 -func (r *Repository) FindDefault(ctx context.Context, provider string) (*model.PaymentMerchantConfig, error) { - var item model.PaymentMerchantConfig - if err := r.db.WithContext(ctx).Where("provider = ? AND is_default = ? AND status = ?", provider, true, "active").First(&item).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, ErrNoActiveConfigFound - } - return nil, err - } - return &item, nil -} - -// FindDefaultAny 查询任意服务商的默认启用配置。 -func (r *Repository) FindDefaultAny(ctx context.Context, includeSecret bool) (*ConfigDTO, error) { - var item model.PaymentMerchantConfig - if err := r.db.WithContext(ctx).Where("status = ?", "active").Order("is_default DESC, id DESC").First(&item).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, ErrNoActiveConfigFound - } - return nil, err - } - dto, err := r.toDTO(item, includeSecret) - if err != nil { - return nil, err - } - return &dto, nil -} - -// FindDefaultByProvider 查询指定服务商的默认启用配置。 -func (r *Repository) FindDefaultByProvider(ctx context.Context, provider string, includeSecret bool) (*ConfigDTO, error) { - item, err := r.FindDefault(ctx, provider) - if err != nil { - return nil, err - } - dto, err := r.toDTO(*item, includeSecret) - if err != nil { - return nil, err - } - return &dto, nil -} - -// FindByProviderMerchant 根据服务商和商户号查配置,用于历史支付单继续使用原商户密钥。 -func (r *Repository) FindByProviderMerchant(ctx context.Context, provider string, merchantID string, includeSecret bool) (*ConfigDTO, error) { - var item model.PaymentMerchantConfig - if err := r.db.WithContext(ctx).Where("provider = ? AND merchant_id = ?", provider, merchantID). - Order("status = 'active' DESC, is_default DESC, id DESC"). - First(&item).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, ErrConfigNotFound - } - return nil, err - } - dto, err := r.toDTO(item, includeSecret) - if err != nil { - return nil, err - } - return &dto, nil -} - -// ExportBackup 导出所有支付配置备份,包含解密后的密钥。 -func (r *Repository) ExportBackup(ctx context.Context, actorID uint64, meta AuditMeta) (*ExportBackup, error) { - var backup *ExportBackup - err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - var items []model.PaymentMerchantConfig - if err := tx.Order("is_default DESC, id DESC").Find(&items).Error; err != nil { - return err - } - - configs := make([]ConfigDTO, 0, len(items)) - for _, item := range items { - dto, err := r.toDTO(item, true) - if err != nil { - return err - } - configs = append(configs, dto) - } - - exportedAt := time.Now().Format(time.RFC3339) - backup = &ExportBackup{ - Type: "payment_config_backup", - Version: 1, - ExportedAt: exportedAt, - ExportedBy: actorID, - Total: len(configs), - Configs: configs, - } - - return appendAuditLog(tx, actorID, "payment_config.export", 0, meta, map[string]any{ - "total": len(configs), - "exported_at": exportedAt, - }) - }) - if err != nil { - return nil, err - } - return backup, nil -} - -// ImportBackup 导入支付配置备份,按 provider + merchant_id 更新或新增。 -func (r *Repository) ImportBackup(ctx context.Context, 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.WithContext(ctx).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(ctx context.Context, provider string) ([]model.PaymentMerchantConfig, error) { - var items []model.PaymentMerchantConfig - if err := r.db.WithContext(ctx).Where("provider = ? AND status = ?", provider, "active").Order("is_default DESC, id DESC").Find(&items).Error; err != nil { - return nil, err - } - return items, nil -} - -// Create 创建配置 -func (r *Repository) Create(ctx context.Context, req CreateRequest, actorID uint64, meta AuditMeta) (*ConfigDTO, error) { - // 验证必填字段 - if err := r.validateCreateRequest(req); err != nil { - return nil, err - } - - var dto ConfigDTO - err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - // 加密密钥 - encryptedSignKey, err := r.encryptor.Encrypt(req.SignKey) - if err != nil { - return ErrEncryptionFailed - } - encryptedNotifyKey, err := r.encryptor.Encrypt(req.NotifyKey) - if err != nil { - return ErrEncryptionFailed - } - - status := req.Status - if status == "" { - status = "active" - } - environment := req.Environment - if environment == "" { - environment = "production" - } - isDefault := req.IsDefault - if status == "active" { - if err := deactivateOtherConfigs(tx, 0, actorID); err != nil { - return err - } - isDefault = true - } else { - isDefault = false - } - - 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: req.ExtraConfig, - IsDefault: isDefault, - Status: status, - Environment: environment, - BusinessTags: req.BusinessTags, - CreatedBy: &actorID, - UpdatedBy: &actorID, - } - - if err := tx.Create(&item).Error; err != nil { - return err - } - - // 记录审计日志 - if err := appendAuditLog(tx, actorID, "payment_config.create", item.ID, meta, map[string]any{ - "name": item.Name, - "provider": item.Provider, - }); err != nil { - return err - } - - dto, err = r.toDTO(item, false) - return err - }) - - if err != nil { - return nil, err - } - return &dto, nil -} - -// Update 更新配置 -func (r *Repository) Update(ctx context.Context, id uint64, req UpdateRequest, actorID uint64, meta AuditMeta) (*ConfigDTO, error) { - if req.SignType != nil && *req.SignType != "" && !isValidSignType(*req.SignType) { - return nil, ErrInvalidSignType - } - if req.Status != nil && !isValidStatus(*req.Status) { - return nil, ErrInvalidStatus - } - if req.NotifyURL != nil && *req.NotifyURL == "" { - return nil, ErrNotifyURLRequired - } - - var dto ConfigDTO - err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - var item model.PaymentMerchantConfig - if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", id).First(&item).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return ErrConfigNotFound - } - return err - } - - updates := make(map[string]any) - if req.Name != nil { - updates["name"] = *req.Name - } - if req.MerchantID != nil { - updates["merchant_id"] = *req.MerchantID - } - if req.GatewayURL != nil { - updates["gateway_url"] = *req.GatewayURL - } - if req.SignKey != nil { - encrypted, err := r.encryptor.Encrypt(*req.SignKey) - if err != nil { - return ErrEncryptionFailed - } - updates["sign_key"] = encrypted - } - if req.NotifyKey != nil { - encrypted, err := r.encryptor.Encrypt(*req.NotifyKey) - if err != nil { - return ErrEncryptionFailed - } - updates["notify_key"] = encrypted - } - if req.NotifyURL != nil { - updates["notify_url"] = *req.NotifyURL - } - if req.JumpURL != nil { - updates["jump_url"] = *req.JumpURL - } - if req.PayWay != nil { - updates["pay_way"] = *req.PayWay - } - if req.JSPayFlag != nil { - updates["jspay_flag"] = *req.JSPayFlag - } - if req.SignType != nil { - updates["sign_type"] = *req.SignType - } - if req.ExtraConfig != nil { - updates["extra_config"] = model.JSONMap(req.ExtraConfig) - } - if req.IsDefault != nil { - updates["is_default"] = *req.IsDefault - } - if req.Status != nil { - updates["status"] = *req.Status - } - if req.Environment != nil { - updates["environment"] = *req.Environment - } - if req.BusinessTags != nil { - updates["business_tags"] = model.JSONArray(req.BusinessTags) - } - updates["updated_by"] = actorID - - finalStatus := item.Status - if req.Status != nil { - finalStatus = *req.Status - } - if finalStatus == "active" { - if err := deactivateOtherConfigs(tx, id, actorID); err != nil { - return err - } - updates["status"] = "active" - updates["is_default"] = true - } else { - updates["is_default"] = false - } - - if err := tx.Model(&item).Updates(updates).Error; err != nil { - return err - } - - // 记录审计日志 - if err := appendAuditLog(tx, actorID, "payment_config.update", item.ID, meta, map[string]any{ - "updates": updates, - }); err != nil { - return err - } - - // 重新查询 - if err := tx.Where("id = ?", id).First(&item).Error; err != nil { - return err - } - - var dtoErr error - dto, dtoErr = r.toDTO(item, false) - return dtoErr - }) - - if err != nil { - return nil, err - } - return &dto, nil -} - -// Delete 删除配置 -func (r *Repository) Delete(ctx context.Context, id uint64, actorID uint64, meta AuditMeta) error { - return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - var item model.PaymentMerchantConfig - if err := tx.Where("id = ?", id).First(&item).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return ErrConfigNotFound - } - return err - } - - var usedCount int64 - if err := tx.Model(&model.PaymentOrder{}). - Where("provider = ? AND merchant_id = ?", item.Provider, item.MerchantID). - Count(&usedCount).Error; err != nil { - return err - } - if usedCount > 0 { - return ErrCannotDeleteInUse - } - - if err := tx.Delete(&item).Error; err != nil { - return err - } - - // 记录审计日志 - return appendAuditLog(tx, actorID, "payment_config.delete", item.ID, meta, map[string]any{ - "name": item.Name, - "provider": item.Provider, - }) - }) -} - -// IncrementUsage 增加使用统计 -func (r *Repository) IncrementUsage(ctx context.Context, id uint64, amountCent int64) error { - now := time.Now() - return r.db.WithContext(ctx).Model(&model.PaymentMerchantConfig{}).Where("id = ?", id).Updates(map[string]any{ - "total_transactions": gorm.Expr("total_transactions + ?", 1), - "total_amount_cent": gorm.Expr("total_amount_cent + ?", amountCent), - "last_used_at": now, - }).Error -} - -// RecordUsage 记录支付配置命中情况,同一个支付单只记录一次。 -func (r *Repository) RecordUsage(ctx context.Context, configID uint64, paymentOrderID uint64, provider string, merchantID string, amountCent int64, bizType string) error { - if configID == 0 || paymentOrderID == 0 { - return nil - } - return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - var existing model.PaymentConfigUsageLog - err := tx.Where("payment_order_id = ?", paymentOrderID).First(&existing).Error - if err == nil { - return nil - } - if !errors.Is(err, gorm.ErrRecordNotFound) { - return err - } - log := model.PaymentConfigUsageLog{ - ConfigID: configID, - PaymentOrderID: paymentOrderID, - Provider: provider, - MerchantID: merchantID, - AmountCent: amountCent, - BizType: bizType, - } - if err := tx.Create(&log).Error; err != nil { - return err - } - now := time.Now() - return tx.Model(&model.PaymentMerchantConfig{}).Where("id = ?", configID).Updates(map[string]any{ - "total_transactions": gorm.Expr("total_transactions + ?", 1), - "total_amount_cent": gorm.Expr("total_amount_cent + ?", amountCent), - "last_used_at": now, - }).Error - }) -} - -// deactivateOtherConfigs 保证全局同一时间只有一个启用配置。 -func deactivateOtherConfigs(tx *gorm.DB, activeID uint64, actorID uint64) error { - db := tx.Model(&model.PaymentMerchantConfig{}). - Where("status = ? OR is_default = ?", "active", true) - if activeID > 0 { - db = db.Where("id != ?", activeID) - } - return db.Updates(map[string]any{ - "status": "disabled", - "is_default": false, - "updated_by": actorID, - }).Error -} - -// toDTO 转换为 DTO -func (r *Repository) toDTO(item model.PaymentMerchantConfig, includeSecret bool) (ConfigDTO, error) { - dto := ConfigDTO{ - ID: item.ID, - Name: item.Name, - Provider: item.Provider, - MerchantID: item.MerchantID, - GatewayURL: item.GatewayURL, - NotifyURL: item.NotifyURL, - JumpURL: item.JumpURL, - PayWay: item.PayWay, - JSPayFlag: item.JSPayFlag, - SignType: item.SignType, - ExtraConfig: item.ExtraConfig, - IsDefault: item.IsDefault, - Status: item.Status, - Environment: item.Environment, - BusinessTags: item.BusinessTags, - TotalTransactions: item.TotalTransactions, - TotalAmountCent: item.TotalAmountCent, - CreatedBy: item.CreatedBy, - UpdatedBy: item.UpdatedBy, - CreatedAt: item.CreatedAt.Format(time.RFC3339), - UpdatedAt: item.UpdatedAt.Format(time.RFC3339), - } - - if item.LastUsedAt != nil { - s := item.LastUsedAt.Format(time.RFC3339) - dto.LastUsedAt = &s - } - - // 只有明确要求时才解密并返回密钥 - if includeSecret { - signKey, err := r.encryptor.Decrypt(item.SignKey) - if err != nil { - return ConfigDTO{}, ErrDecryptionFailed - } - notifyKey, err := r.encryptor.Decrypt(item.NotifyKey) - if err != nil { - return ConfigDTO{}, ErrDecryptionFailed - } - dto.SignKey = signKey - dto.NotifyKey = notifyKey - } - - return dto, nil -} - -// validateCreateRequest 验证创建请求 -func (r *Repository) validateCreateRequest(req CreateRequest) error { - if req.Name == "" { - return ErrNameRequired - } - if req.Provider == "" { - return ErrInvalidProvider - } - if !isValidProvider(req.Provider) { - return ErrInvalidProvider - } - if req.MerchantID == "" { - return ErrMerchantIDRequired - } - if req.SignType != "" && !isValidSignType(req.SignType) { - return ErrInvalidSignType - } - if req.Status != "" && !isValidStatus(req.Status) { - return ErrInvalidStatus - } - - // leshua 特定验证 - if req.Provider == "leshua" { - if req.GatewayURL == "" { - return ErrGatewayURLRequired - } - if req.SignKey == "" { - return ErrSignKeyRequired - } - if req.NotifyKey == "" { - return ErrNotifyKeyRequired - } - if req.NotifyURL == "" { - return ErrNotifyURLRequired - } - } - if req.Provider == "lakala" { - if req.GatewayURL == "" { - return ErrGatewayURLRequired - } - if req.SignKey == "" { - return ErrSignKeyRequired - } - if req.NotifyKey == "" { - return ErrNotifyKeyRequired - } - if req.NotifyURL == "" { - return ErrNotifyURLRequired - } - if extraString(req.ExtraConfig, "app_id") == "" { - return ErrAppIDRequired - } - if extraString(req.ExtraConfig, "serial_no") == "" { - return ErrSerialNoRequired - } - if extraString(req.ExtraConfig, "term_no") == "" { - return ErrTermNoRequired - } - } - - return nil -} - -func firstNonEmpty(vals ...string) string { - for _, v := range vals { - if v != "" { - return v - } - } - 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" - } - return "MD5" -} - -func isValidSignType(value string) bool { - return value == "MD5" || value == "SHA256withRSA" -} - -func isValidStatus(value string) bool { - return value == "active" || value == "disabled" || value == "testing" -} - -func extraString(config map[string]any, key string) string { - if config == nil { - return "" - } - value, ok := config[key] - if !ok || value == nil { - return "" - } - if s, ok := value.(string); ok { - return s - } - return "" -} - -func appendAuditLog(tx *gorm.DB, actorID uint64, action string, bizID uint64, meta AuditMeta, detail map[string]any) error { - detailJSON, err := json.Marshal(detail) - if err != nil { - return err - } - log := model.AuditLog{ - ActorType: "admin", - ActorID: actorID, - Action: action, - BizType: "payment_config", - BizID: &bizID, - IP: meta.IP, - UserAgent: meta.UserAgent, - Detail: detailJSON, - } - return tx.Create(&log).Error -} diff --git a/backend/internal/modules/paymentconfig/usage.go b/backend/internal/modules/paymentconfig/usage.go new file mode 100644 index 0000000..a7d5043 --- /dev/null +++ b/backend/internal/modules/paymentconfig/usage.go @@ -0,0 +1,59 @@ +package paymentconfig + +import ( + "context" + "errors" + "time" + + "hfb_sys/backend/internal/model" + + "gorm.io/gorm" +) + +// IncrementUsage 增加使用统计 +func (r *Repository) IncrementUsage(ctx context.Context, id uint64, amountCent int64) error { + now := time.Now() + return r.db.WithContext(ctx).Model(&model.PaymentMerchantConfig{}).Where("id = ?", id).Updates(map[string]any{ + "total_transactions": gorm.Expr("total_transactions + ?", 1), + "total_amount_cent": gorm.Expr("total_amount_cent + ?", amountCent), + "last_used_at": now, + }).Error +} + +// RecordUsage 记录支付配置命中情况,同一个支付单只记录一次。 + +// RecordUsage 记录支付配置命中情况,同一个支付单只记录一次。 +func (r *Repository) RecordUsage(ctx context.Context, configID uint64, paymentOrderID uint64, provider string, merchantID string, amountCent int64, bizType string) error { + if configID == 0 || paymentOrderID == 0 { + return nil + } + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var existing model.PaymentConfigUsageLog + err := tx.Where("payment_order_id = ?", paymentOrderID).First(&existing).Error + if err == nil { + return nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + log := model.PaymentConfigUsageLog{ + ConfigID: configID, + PaymentOrderID: paymentOrderID, + Provider: provider, + MerchantID: merchantID, + AmountCent: amountCent, + BizType: bizType, + } + if err := tx.Create(&log).Error; err != nil { + return err + } + now := time.Now() + return tx.Model(&model.PaymentMerchantConfig{}).Where("id = ?", configID).Updates(map[string]any{ + "total_transactions": gorm.Expr("total_transactions + ?", 1), + "total_amount_cent": gorm.Expr("total_amount_cent + ?", amountCent), + "last_used_at": now, + }).Error + }) +} + +// deactivateOtherConfigs 保证全局同一时间只有一个启用配置。 diff --git a/backend/internal/modules/paymentconfig/validation.go b/backend/internal/modules/paymentconfig/validation.go new file mode 100644 index 0000000..bb5d738 --- /dev/null +++ b/backend/internal/modules/paymentconfig/validation.go @@ -0,0 +1,90 @@ +package paymentconfig + +// validateCreateRequest 验证创建请求 +func (r *Repository) validateCreateRequest(req CreateRequest) error { + if req.Name == "" { + return ErrNameRequired + } + if req.Provider == "" { + return ErrInvalidProvider + } + if !isValidProvider(req.Provider) { + return ErrInvalidProvider + } + if req.MerchantID == "" { + return ErrMerchantIDRequired + } + if req.SignType != "" && !isValidSignType(req.SignType) { + return ErrInvalidSignType + } + if req.Status != "" && !isValidStatus(req.Status) { + return ErrInvalidStatus + } + + // leshua 特定验证 + if req.Provider == "leshua" { + if req.GatewayURL == "" { + return ErrGatewayURLRequired + } + if req.SignKey == "" { + return ErrSignKeyRequired + } + if req.NotifyKey == "" { + return ErrNotifyKeyRequired + } + if req.NotifyURL == "" { + return ErrNotifyURLRequired + } + } + if req.Provider == "lakala" { + if req.GatewayURL == "" { + return ErrGatewayURLRequired + } + if req.SignKey == "" { + return ErrSignKeyRequired + } + if req.NotifyKey == "" { + return ErrNotifyKeyRequired + } + if req.NotifyURL == "" { + return ErrNotifyURLRequired + } + if extraString(req.ExtraConfig, "app_id") == "" { + return ErrAppIDRequired + } + if extraString(req.ExtraConfig, "serial_no") == "" { + return ErrSerialNoRequired + } + if extraString(req.ExtraConfig, "term_no") == "" { + return ErrTermNoRequired + } + } + + return nil +} + +func isValidProvider(value string) bool { + return value == "leshua" || value == "lakala" || value == "mock" +} + +func isValidSignType(value string) bool { + return value == "MD5" || value == "SHA256withRSA" +} + +func isValidStatus(value string) bool { + return value == "active" || value == "disabled" || value == "testing" +} + +func extraString(config map[string]any, key string) string { + if config == nil { + return "" + } + value, ok := config[key] + if !ok || value == nil { + return "" + } + if s, ok := value.(string); ok { + return s + } + return "" +}