From c86afbfa8f8d6ec03185c22c2f28b2e5f6279727 Mon Sep 17 00:00:00 2001 From: huang Date: Mon, 16 Mar 2026 23:29:11 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=BE=AE=E4=BF=A1?= =?UTF-8?q?=E5=8F=82=E6=95=B0=E9=85=8D=E7=BD=AE=E6=A8=A1=E5=9D=97=EF=BC=88?= =?UTF-8?q?Model=E3=80=81DTO=E3=80=81Store=E3=80=81Service=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - wechat_config.go: WechatConfig GORM 模型,含 ProviderTypeWechat/Fuiou 常量 - wechat_config_dto.go: Create/Update/List 请求 DTO,响应 DTO 含脱敏逻辑 - wechat_config_store.go: CRUD、GetActive、ActivateInTx(事务内唯一激活)、软删除保护查询 - service.go: 业务逻辑,按渠道校验必填字段、Redis 缓存管理(wechat:config:active)、删除保护、审计日志 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- internal/model/dto/wechat_config_dto.go | 186 +++++++ internal/model/wechat_config.go | 56 +++ internal/service/wechat_config/service.go | 473 ++++++++++++++++++ .../store/postgres/wechat_config_store.go | 145 ++++++ 4 files changed, 860 insertions(+) create mode 100644 internal/model/dto/wechat_config_dto.go create mode 100644 internal/model/wechat_config.go create mode 100644 internal/service/wechat_config/service.go create mode 100644 internal/store/postgres/wechat_config_store.go diff --git a/internal/model/dto/wechat_config_dto.go b/internal/model/dto/wechat_config_dto.go new file mode 100644 index 0000000..fe708d2 --- /dev/null +++ b/internal/model/dto/wechat_config_dto.go @@ -0,0 +1,186 @@ +package dto + +import ( + "fmt" + + "github.com/break/junhong_cmp_fiber/internal/model" +) + +// CreateWechatConfigRequest 创建微信参数配置请求 +type CreateWechatConfigRequest struct { + Name string `json:"name" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"配置名称"` + Description string `json:"description" validate:"omitempty,max=500" maxLength:"500" description:"配置描述"` + ProviderType string `json:"provider_type" validate:"required,oneof=wechat fuiou" required:"true" description:"支付渠道类型 (wechat:微信直连, fuiou:富友)"` + + OaAppID string `json:"oa_app_id" validate:"omitempty,max=100" maxLength:"100" description:"公众号AppID"` + OaAppSecret string `json:"oa_app_secret" validate:"omitempty,max=200" maxLength:"200" description:"公众号AppSecret"` + OaToken string `json:"oa_token" validate:"omitempty,max=200" maxLength:"200" description:"公众号Token"` + OaAesKey string `json:"oa_aes_key" validate:"omitempty,max=200" maxLength:"200" description:"公众号AES加密Key"` + OaOAuthRedirectURL string `json:"oa_oauth_redirect_url" validate:"omitempty,max=500" maxLength:"500" description:"OAuth回调地址"` + + MiniappAppID string `json:"miniapp_app_id" validate:"omitempty,max=100" maxLength:"100" description:"小程序AppID"` + MiniappAppSecret string `json:"miniapp_app_secret" validate:"omitempty,max=200" maxLength:"200" description:"小程序AppSecret"` + + WxMchID string `json:"wx_mch_id" validate:"omitempty,max=100" maxLength:"100" description:"微信商户号"` + WxAPIV3Key string `json:"wx_api_v3_key" validate:"omitempty,max=200" maxLength:"200" description:"微信APIv3密钥"` + WxAPIV2Key string `json:"wx_api_v2_key" validate:"omitempty,max=200" maxLength:"200" description:"微信APIv2密钥"` + WxCertContent string `json:"wx_cert_content" validate:"omitempty" description:"微信支付证书内容(PEM格式)"` + WxKeyContent string `json:"wx_key_content" validate:"omitempty" description:"微信支付密钥内容(PEM格式)"` + WxSerialNo string `json:"wx_serial_no" validate:"omitempty,max=200" maxLength:"200" description:"微信证书序列号"` + WxNotifyURL string `json:"wx_notify_url" validate:"omitempty,max=500" maxLength:"500" description:"微信支付回调地址"` + + FyInsCd string `json:"fy_ins_cd" validate:"omitempty,max=50" maxLength:"50" description:"富友机构号"` + FyMchntCd string `json:"fy_mchnt_cd" validate:"omitempty,max=50" maxLength:"50" description:"富友商户号"` + FyTermID string `json:"fy_term_id" validate:"omitempty,max=50" maxLength:"50" description:"富友终端号"` + FyPrivateKey string `json:"fy_private_key" validate:"omitempty" description:"富友私钥(PEM格式)"` + FyPublicKey string `json:"fy_public_key" validate:"omitempty" description:"富友公钥(PEM格式)"` + FyAPIURL string `json:"fy_api_url" validate:"omitempty,max=500" maxLength:"500" description:"富友API地址"` + FyNotifyURL string `json:"fy_notify_url" validate:"omitempty,max=500" maxLength:"500" description:"富友支付回调地址"` +} + +// UpdateWechatConfigRequest 更新微信参数配置请求 +type UpdateWechatConfigRequest struct { + Name *string `json:"name" validate:"omitempty,min=1,max=100" minLength:"1" maxLength:"100" description:"配置名称"` + Description *string `json:"description" validate:"omitempty,max=500" maxLength:"500" description:"配置描述"` + ProviderType *string `json:"provider_type" validate:"omitempty,oneof=wechat fuiou" description:"支付渠道类型 (wechat:微信直连, fuiou:富友)"` + + OaAppID *string `json:"oa_app_id" validate:"omitempty,max=100" maxLength:"100" description:"公众号AppID"` + OaAppSecret *string `json:"oa_app_secret" validate:"omitempty,max=200" maxLength:"200" description:"公众号AppSecret"` + OaToken *string `json:"oa_token" validate:"omitempty,max=200" maxLength:"200" description:"公众号Token"` + OaAesKey *string `json:"oa_aes_key" validate:"omitempty,max=200" maxLength:"200" description:"公众号AES加密Key"` + OaOAuthRedirectURL *string `json:"oa_oauth_redirect_url" validate:"omitempty,max=500" maxLength:"500" description:"OAuth回调地址"` + + MiniappAppID *string `json:"miniapp_app_id" validate:"omitempty,max=100" maxLength:"100" description:"小程序AppID"` + MiniappAppSecret *string `json:"miniapp_app_secret" validate:"omitempty,max=200" maxLength:"200" description:"小程序AppSecret"` + + WxMchID *string `json:"wx_mch_id" validate:"omitempty,max=100" maxLength:"100" description:"微信商户号"` + WxAPIV3Key *string `json:"wx_api_v3_key" validate:"omitempty,max=200" maxLength:"200" description:"微信APIv3密钥"` + WxAPIV2Key *string `json:"wx_api_v2_key" validate:"omitempty,max=200" maxLength:"200" description:"微信APIv2密钥"` + WxCertContent *string `json:"wx_cert_content" validate:"omitempty" description:"微信支付证书内容(PEM格式)"` + WxKeyContent *string `json:"wx_key_content" validate:"omitempty" description:"微信支付密钥内容(PEM格式)"` + WxSerialNo *string `json:"wx_serial_no" validate:"omitempty,max=200" maxLength:"200" description:"微信证书序列号"` + WxNotifyURL *string `json:"wx_notify_url" validate:"omitempty,max=500" maxLength:"500" description:"微信支付回调地址"` + + FyInsCd *string `json:"fy_ins_cd" validate:"omitempty,max=50" maxLength:"50" description:"富友机构号"` + FyMchntCd *string `json:"fy_mchnt_cd" validate:"omitempty,max=50" maxLength:"50" description:"富友商户号"` + FyTermID *string `json:"fy_term_id" validate:"omitempty,max=50" maxLength:"50" description:"富友终端号"` + FyPrivateKey *string `json:"fy_private_key" validate:"omitempty" description:"富友私钥(PEM格式)"` + FyPublicKey *string `json:"fy_public_key" validate:"omitempty" description:"富友公钥(PEM格式)"` + FyAPIURL *string `json:"fy_api_url" validate:"omitempty,max=500" maxLength:"500" description:"富友API地址"` + FyNotifyURL *string `json:"fy_notify_url" validate:"omitempty,max=500" maxLength:"500" description:"富友支付回调地址"` +} + +// WechatConfigListRequest 微信参数配置列表查询请求 +type WechatConfigListRequest struct { + Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"` + PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"` + ProviderType *string `json:"provider_type" query:"provider_type" validate:"omitempty,oneof=wechat fuiou" description:"支付渠道类型 (wechat:微信直连, fuiou:富友)"` + IsActive *bool `json:"is_active" query:"is_active" description:"是否激活 (true:已激活, false:未激活)"` +} + +// WechatConfigResponse 微信参数配置响应 +type WechatConfigResponse struct { + ID uint `json:"id" description:"配置ID"` + Name string `json:"name" description:"配置名称"` + Description string `json:"description" description:"配置描述"` + ProviderType string `json:"provider_type" description:"支付渠道类型 (wechat:微信直连, fuiou:富友)"` + IsActive bool `json:"is_active" description:"是否激活"` + + OaAppID string `json:"oa_app_id" description:"公众号AppID"` + OaAppSecret string `json:"oa_app_secret" description:"公众号AppSecret(已脱敏)"` + OaToken string `json:"oa_token" description:"公众号Token(已脱敏)"` + OaAesKey string `json:"oa_aes_key" description:"公众号AES加密Key(已脱敏)"` + OaOAuthRedirectURL string `json:"oa_oauth_redirect_url" description:"OAuth回调地址"` + + MiniappAppID string `json:"miniapp_app_id" description:"小程序AppID"` + MiniappAppSecret string `json:"miniapp_app_secret" description:"小程序AppSecret(已脱敏)"` + + WxMchID string `json:"wx_mch_id" description:"微信商户号"` + WxAPIV3Key string `json:"wx_api_v3_key" description:"微信APIv3密钥(已脱敏)"` + WxAPIV2Key string `json:"wx_api_v2_key" description:"微信APIv2密钥(已脱敏)"` + WxCertContent string `json:"wx_cert_content" description:"微信支付证书内容(配置状态)"` + WxKeyContent string `json:"wx_key_content" description:"微信支付密钥内容(配置状态)"` + WxSerialNo string `json:"wx_serial_no" description:"微信证书序列号"` + WxNotifyURL string `json:"wx_notify_url" description:"微信支付回调地址"` + + FyInsCd string `json:"fy_ins_cd" description:"富友机构号"` + FyMchntCd string `json:"fy_mchnt_cd" description:"富友商户号"` + FyTermID string `json:"fy_term_id" description:"富友终端号"` + FyPrivateKey string `json:"fy_private_key" description:"富友私钥(配置状态)"` + FyPublicKey string `json:"fy_public_key" description:"富友公钥(配置状态)"` + FyAPIURL string `json:"fy_api_url" description:"富友API地址"` + FyNotifyURL string `json:"fy_notify_url" description:"富友支付回调地址"` + + CreatedAt string `json:"created_at" description:"创建时间"` + UpdatedAt string `json:"updated_at" description:"更新时间"` +} + +// WechatConfigListResponse 微信参数配置列表响应 +type WechatConfigListResponse struct { + List []*WechatConfigResponse `json:"list" description:"配置列表"` + Total int64 `json:"total" description:"总数"` + Page int `json:"page" description:"当前页"` + PageSize int `json:"page_size" description:"每页数量"` +} + +// MaskShortSecret 对短密钥进行脱敏处理 +// 长度小于 8 返回 "***",否则保留前4位和后4位 +func MaskShortSecret(val string) string { + if len(val) < 8 { + return "***" + } + return fmt.Sprintf("%s***%s", val[:4], val[len(val)-4:]) +} + +// MaskLongSecret 对长密钥/证书进行脱敏处理 +// 空值返回 "[未配置]",否则返回 "[已配置]" +func MaskLongSecret(val string) string { + if val == "" { + return "[未配置]" + } + return "[已配置]" +} + +// FromWechatConfigModel 从模型转换为响应 DTO,敏感字段自动脱敏 +func FromWechatConfigModel(m *model.WechatConfig) *WechatConfigResponse { + resp := &WechatConfigResponse{ + ID: m.ID, + Name: m.Name, + ProviderType: m.ProviderType, + IsActive: m.IsActive, + + OaAppID: m.OaAppID, + OaAppSecret: MaskShortSecret(m.OaAppSecret), + OaToken: MaskShortSecret(m.OaToken), + OaAesKey: MaskShortSecret(m.OaAesKey), + OaOAuthRedirectURL: m.OaOAuthRedirectURL, + + MiniappAppID: m.MiniappAppID, + MiniappAppSecret: MaskShortSecret(m.MiniappAppSecret), + + WxMchID: m.WxMchID, + WxAPIV3Key: MaskShortSecret(m.WxAPIV3Key), + WxAPIV2Key: MaskShortSecret(m.WxAPIV2Key), + WxCertContent: MaskLongSecret(m.WxCertContent), + WxKeyContent: MaskLongSecret(m.WxKeyContent), + WxSerialNo: m.WxSerialNo, + WxNotifyURL: m.WxNotifyURL, + + FyInsCd: m.FyInsCd, + FyMchntCd: m.FyMchntCd, + FyTermID: m.FyTermID, + FyPrivateKey: MaskLongSecret(m.FyPrivateKey), + FyPublicKey: MaskLongSecret(m.FyPublicKey), + FyAPIURL: m.FyAPIURL, + FyNotifyURL: m.FyNotifyURL, + + CreatedAt: m.CreatedAt.Format("2006-01-02 15:04:05"), + UpdatedAt: m.UpdatedAt.Format("2006-01-02 15:04:05"), + } + + if m.Description != nil { + resp.Description = *m.Description + } + + return resp +} diff --git a/internal/model/wechat_config.go b/internal/model/wechat_config.go new file mode 100644 index 0000000..88b04f8 --- /dev/null +++ b/internal/model/wechat_config.go @@ -0,0 +1,56 @@ +package model + +import "gorm.io/gorm" + +// 支付渠道类型常量 +const ( + ProviderTypeWechat = "wechat" // 微信直连 + ProviderTypeFuiou = "fuiou" // 富友 +) + +// WechatConfig 微信参数配置模型 +// 管理微信公众号 OAuth、小程序、微信直连支付、富友支付等多套配置 +// 同一时间只有一条记录处于 is_active=true(全局唯一生效配置) +type WechatConfig struct { + gorm.Model + BaseModel `gorm:"embedded"` + + Name string `gorm:"column:name;type:varchar(100);not null;comment:配置名称" json:"name"` + Description *string `gorm:"column:description;type:text;comment:配置描述" json:"description,omitempty"` + ProviderType string `gorm:"column:provider_type;type:varchar(20);not null;comment:支付渠道类型(wechat-微信直连,fuiou-富友)" json:"provider_type"` + IsActive bool `gorm:"column:is_active;type:boolean;not null;default:false;comment:是否激活(全局唯一)" json:"is_active"` + + // OAuth 公众号 + OaAppID string `gorm:"column:oa_app_id;type:varchar(100);not null;default:'';comment:公众号AppID" json:"oa_app_id"` + OaAppSecret string `gorm:"column:oa_app_secret;type:varchar(200);not null;default:'';comment:公众号AppSecret" json:"oa_app_secret"` + OaToken string `gorm:"column:oa_token;type:varchar(200);default:'';comment:公众号Token" json:"oa_token"` + OaAesKey string `gorm:"column:oa_aes_key;type:varchar(200);default:'';comment:公众号AES加密Key" json:"oa_aes_key"` + OaOAuthRedirectURL string `gorm:"column:oa_oauth_redirect_url;type:varchar(500);default:'';comment:OAuth回调地址" json:"oa_oauth_redirect_url"` + + // OAuth 小程序 + MiniappAppID string `gorm:"column:miniapp_app_id;type:varchar(100);default:'';comment:小程序AppID" json:"miniapp_app_id"` + MiniappAppSecret string `gorm:"column:miniapp_app_secret;type:varchar(200);default:'';comment:小程序AppSecret" json:"miniapp_app_secret"` + + // 支付-微信直连 + WxMchID string `gorm:"column:wx_mch_id;type:varchar(100);default:'';comment:微信商户号" json:"wx_mch_id"` + WxAPIV3Key string `gorm:"column:wx_api_v3_key;type:varchar(200);default:'';comment:微信APIv3密钥" json:"wx_api_v3_key"` + WxAPIV2Key string `gorm:"column:wx_api_v2_key;type:varchar(200);default:'';comment:微信APIv2密钥" json:"wx_api_v2_key"` + WxCertContent string `gorm:"column:wx_cert_content;type:text;default:'';comment:微信支付证书内容" json:"wx_cert_content"` + WxKeyContent string `gorm:"column:wx_key_content;type:text;default:'';comment:微信支付密钥内容" json:"wx_key_content"` + WxSerialNo string `gorm:"column:wx_serial_no;type:varchar(200);default:'';comment:微信证书序列号" json:"wx_serial_no"` + WxNotifyURL string `gorm:"column:wx_notify_url;type:varchar(500);default:'';comment:微信支付回调地址" json:"wx_notify_url"` + + // 支付-富友 + FyInsCd string `gorm:"column:fy_ins_cd;type:varchar(50);default:'';comment:富友机构号" json:"fy_ins_cd"` + FyMchntCd string `gorm:"column:fy_mchnt_cd;type:varchar(50);default:'';comment:富友商户号" json:"fy_mchnt_cd"` + FyTermID string `gorm:"column:fy_term_id;type:varchar(50);default:'';comment:富友终端号" json:"fy_term_id"` + FyPrivateKey string `gorm:"column:fy_private_key;type:text;default:'';comment:富友私钥" json:"fy_private_key"` + FyPublicKey string `gorm:"column:fy_public_key;type:text;default:'';comment:富友公钥" json:"fy_public_key"` + FyAPIURL string `gorm:"column:fy_api_url;type:varchar(500);default:'';comment:富友API地址" json:"fy_api_url"` + FyNotifyURL string `gorm:"column:fy_notify_url;type:varchar(500);default:'';comment:富友支付回调地址" json:"fy_notify_url"` +} + +// TableName 指定表名 +func (WechatConfig) TableName() string { + return "tb_wechat_config" +} diff --git a/internal/service/wechat_config/service.go b/internal/service/wechat_config/service.go new file mode 100644 index 0000000..d1d00ed --- /dev/null +++ b/internal/service/wechat_config/service.go @@ -0,0 +1,473 @@ +// Package wechat_config 提供微信参数配置管理的业务逻辑服务 +// 包含配置的 CRUD、激活/停用、Redis 缓存等功能 +package wechat_config + +import ( + "context" + "fmt" + "time" + + "github.com/bytedance/sonic" + "github.com/redis/go-redis/v9" + "go.uber.org/zap" + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + "github.com/break/junhong_cmp_fiber/internal/store" + "github.com/break/junhong_cmp_fiber/internal/store/postgres" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/middleware" +) + +// Redis 缓存键 +const redisActiveConfigKey = "wechat:config:active" + +// AuditServiceInterface 审计日志服务接口 +type AuditServiceInterface interface { + LogOperation(ctx context.Context, log *model.AccountOperationLog) +} + +// Service 微信参数配置业务服务 +type Service struct { + store *postgres.WechatConfigStore + orderStore *postgres.OrderStore + auditService AuditServiceInterface + redis *redis.Client + logger *zap.Logger +} + +// New 创建微信参数配置服务实例 +func New(store *postgres.WechatConfigStore, orderStore *postgres.OrderStore, auditService AuditServiceInterface, rdb *redis.Client, logger *zap.Logger) *Service { + return &Service{ + store: store, + orderStore: orderStore, + auditService: auditService, + redis: rdb, + logger: logger, + } +} + +// Create 创建微信参数配置 +// POST /api/admin/wechat-configs +func (s *Service) Create(ctx context.Context, req *dto.CreateWechatConfigRequest) (*dto.WechatConfigResponse, error) { + // 根据 provider_type 校验必填字段 + if err := s.validateProviderFields(req); err != nil { + return nil, err + } + + var desc *string + if req.Description != "" { + desc = &req.Description + } + + config := &model.WechatConfig{ + Name: req.Name, + Description: desc, + ProviderType: req.ProviderType, + IsActive: false, + OaAppID: req.OaAppID, + OaAppSecret: req.OaAppSecret, + OaToken: req.OaToken, + OaAesKey: req.OaAesKey, + OaOAuthRedirectURL: req.OaOAuthRedirectURL, + MiniappAppID: req.MiniappAppID, + MiniappAppSecret: req.MiniappAppSecret, + WxMchID: req.WxMchID, + WxAPIV3Key: req.WxAPIV3Key, + WxAPIV2Key: req.WxAPIV2Key, + WxCertContent: req.WxCertContent, + WxKeyContent: req.WxKeyContent, + WxSerialNo: req.WxSerialNo, + WxNotifyURL: req.WxNotifyURL, + FyInsCd: req.FyInsCd, + FyMchntCd: req.FyMchntCd, + FyTermID: req.FyTermID, + FyPrivateKey: req.FyPrivateKey, + FyPublicKey: req.FyPublicKey, + FyAPIURL: req.FyAPIURL, + FyNotifyURL: req.FyNotifyURL, + } + config.Creator = middleware.GetUserIDFromContext(ctx) + + if err := s.store.Create(ctx, config); err != nil { + return nil, errors.Wrap(errors.CodeInternalError, err, "创建微信支付配置失败") + } + + // 审计日志 + afterData := model.JSONB{ + "id": config.ID, + "name": config.Name, + "provider_type": config.ProviderType, + } + go s.auditService.LogOperation(ctx, &model.AccountOperationLog{ + OperatorID: middleware.GetUserIDFromContext(ctx), + OperatorType: middleware.GetUserTypeFromContext(ctx), + OperatorName: "", + OperationType: "create", + OperationDesc: fmt.Sprintf("创建微信支付配置:%s", config.Name), + AfterData: afterData, + RequestID: middleware.GetRequestIDFromContext(ctx), + IPAddress: middleware.GetIPFromContext(ctx), + UserAgent: middleware.GetUserAgentFromContext(ctx), + }) + + return dto.FromWechatConfigModel(config), nil +} + +// List 获取配置列表 +// GET /api/admin/wechat-configs +func (s *Service) List(ctx context.Context, req *dto.WechatConfigListRequest) ([]*dto.WechatConfigResponse, int64, error) { + opts := &store.QueryOptions{ + Page: req.Page, + PageSize: req.PageSize, + OrderBy: "id DESC", + } + if opts.Page == 0 { + opts.Page = 1 + } + if opts.PageSize == 0 { + opts.PageSize = constants.DefaultPageSize + } + + filters := make(map[string]interface{}) + if req.ProviderType != nil { + filters["provider_type"] = *req.ProviderType + } + filters["is_active"] = req.IsActive + + configs, total, err := s.store.List(ctx, opts, filters) + if err != nil { + return nil, 0, errors.Wrap(errors.CodeInternalError, err, "查询微信支付配置列表失败") + } + + responses := make([]*dto.WechatConfigResponse, len(configs)) + for i, c := range configs { + responses[i] = dto.FromWechatConfigModel(c) + } + + return responses, total, nil +} + +// Get 获取配置详情 +// GET /api/admin/wechat-configs/:id +func (s *Service) Get(ctx context.Context, id uint) (*dto.WechatConfigResponse, error) { + config, err := s.store.GetByID(ctx, id) + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeWechatConfigNotFound) + } + return nil, errors.Wrap(errors.CodeInternalError, err, "获取微信支付配置失败") + } + return dto.FromWechatConfigModel(config), nil +} + +// Update 更新微信参数配置 +// PUT /api/admin/wechat-configs/:id +func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateWechatConfigRequest) (*dto.WechatConfigResponse, error) { + config, err := s.store.GetByID(ctx, id) + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeWechatConfigNotFound) + } + return nil, errors.Wrap(errors.CodeInternalError, err, "获取微信支付配置失败") + } + + // 合并字段:指针非 nil 时更新,敏感字段空字符串表示保持原值 + if req.Name != nil { + config.Name = *req.Name + } + if req.Description != nil { + config.Description = req.Description + } + if req.ProviderType != nil { + config.ProviderType = *req.ProviderType + } + + // OAuth 公众号 + s.mergeStringField(&config.OaAppID, req.OaAppID) + s.mergeSensitiveField(&config.OaAppSecret, req.OaAppSecret) + s.mergeSensitiveField(&config.OaToken, req.OaToken) + s.mergeSensitiveField(&config.OaAesKey, req.OaAesKey) + s.mergeStringField(&config.OaOAuthRedirectURL, req.OaOAuthRedirectURL) + + // OAuth 小程序 + s.mergeStringField(&config.MiniappAppID, req.MiniappAppID) + s.mergeSensitiveField(&config.MiniappAppSecret, req.MiniappAppSecret) + + // 微信直连支付 + s.mergeStringField(&config.WxMchID, req.WxMchID) + s.mergeSensitiveField(&config.WxAPIV3Key, req.WxAPIV3Key) + s.mergeSensitiveField(&config.WxAPIV2Key, req.WxAPIV2Key) + s.mergeSensitiveField(&config.WxCertContent, req.WxCertContent) + s.mergeSensitiveField(&config.WxKeyContent, req.WxKeyContent) + s.mergeStringField(&config.WxSerialNo, req.WxSerialNo) + s.mergeStringField(&config.WxNotifyURL, req.WxNotifyURL) + + // 富友支付 + s.mergeStringField(&config.FyInsCd, req.FyInsCd) + s.mergeStringField(&config.FyMchntCd, req.FyMchntCd) + s.mergeStringField(&config.FyTermID, req.FyTermID) + s.mergeSensitiveField(&config.FyPrivateKey, req.FyPrivateKey) + s.mergeSensitiveField(&config.FyPublicKey, req.FyPublicKey) + s.mergeStringField(&config.FyAPIURL, req.FyAPIURL) + s.mergeStringField(&config.FyNotifyURL, req.FyNotifyURL) + + config.Updater = middleware.GetUserIDFromContext(ctx) + + if err := s.store.Update(ctx, config); err != nil { + return nil, errors.Wrap(errors.CodeInternalError, err, "更新微信支付配置失败") + } + + // 如果当前配置处于激活状态,清除缓存 + if config.IsActive { + s.clearActiveConfigCache(ctx) + } + + afterData := model.JSONB{ + "id": config.ID, + "name": config.Name, + "provider_type": config.ProviderType, + "is_active": config.IsActive, + } + go s.auditService.LogOperation(ctx, &model.AccountOperationLog{ + OperatorID: middleware.GetUserIDFromContext(ctx), + OperatorType: middleware.GetUserTypeFromContext(ctx), + OperatorName: "", + OperationType: "update", + OperationDesc: fmt.Sprintf("更新微信支付配置:%s", config.Name), + AfterData: afterData, + RequestID: middleware.GetRequestIDFromContext(ctx), + IPAddress: middleware.GetIPFromContext(ctx), + UserAgent: middleware.GetUserAgentFromContext(ctx), + }) + + return dto.FromWechatConfigModel(config), nil +} + +// Delete 删除微信参数配置 +// DELETE /api/admin/wechat-configs/:id +func (s *Service) Delete(ctx context.Context, id uint) error { + config, err := s.store.GetByID(ctx, id) + if err != nil { + if err == gorm.ErrRecordNotFound { + return errors.New(errors.CodeWechatConfigNotFound) + } + return errors.Wrap(errors.CodeInternalError, err, "获取微信支付配置失败") + } + + // 不允许删除正在激活的配置 + if config.IsActive { + return errors.New(errors.CodeWechatConfigActive) + } + + // 检查是否存在待支付订单 + pendingOrders, err := s.store.CountPendingOrdersByConfigID(ctx, id) + if err != nil { + return errors.Wrap(errors.CodeInternalError, err, "检查在途订单失败") + } + + pendingRecharges, err := s.store.CountPendingRechargesByConfigID(ctx, id) + if err != nil { + return errors.Wrap(errors.CodeInternalError, err, "检查在途充值失败") + } + + if pendingOrders > 0 || pendingRecharges > 0 { + return errors.New(errors.CodeWechatConfigHasPendingOrders) + } + + if err := s.store.SoftDelete(ctx, id); err != nil { + return errors.Wrap(errors.CodeInternalError, err, "删除微信支付配置失败") + } + + s.clearActiveConfigCache(ctx) + + beforeData := model.JSONB{ + "id": config.ID, + "name": config.Name, + "provider_type": config.ProviderType, + } + go s.auditService.LogOperation(ctx, &model.AccountOperationLog{ + OperatorID: middleware.GetUserIDFromContext(ctx), + OperatorType: middleware.GetUserTypeFromContext(ctx), + OperatorName: "", + OperationType: "delete", + OperationDesc: fmt.Sprintf("删除微信支付配置:%s", config.Name), + BeforeData: beforeData, + RequestID: middleware.GetRequestIDFromContext(ctx), + IPAddress: middleware.GetIPFromContext(ctx), + UserAgent: middleware.GetUserAgentFromContext(ctx), + }) + + return nil +} + +// Activate 激活指定配置(同一时间只有一个激活配置) +// POST /api/admin/wechat-configs/:id/activate +func (s *Service) Activate(ctx context.Context, id uint) (*dto.WechatConfigResponse, error) { + config, err := s.store.GetByID(ctx, id) + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeWechatConfigNotFound) + } + return nil, errors.Wrap(errors.CodeInternalError, err, "获取微信支付配置失败") + } + + // 记录旧的激活配置名称 + oldActiveName := "" + oldActive, oldErr := s.store.GetActive(ctx) + if oldErr == nil && oldActive != nil { + oldActiveName = oldActive.Name + } + + // 事务内激活 + db := s.store.DB() + if err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return s.store.ActivateInTx(ctx, tx, id) + }); err != nil { + return nil, errors.Wrap(errors.CodeInternalError, err, "激活微信支付配置失败") + } + + s.clearActiveConfigCache(ctx) + + // 重新查询最新状态 + config, _ = s.store.GetByID(ctx, id) + + desc := fmt.Sprintf("激活微信支付配置:%s", config.Name) + if oldActiveName != "" && oldActiveName != config.Name { + desc = fmt.Sprintf("激活微信支付配置:%s(原激活配置:%s)", config.Name, oldActiveName) + } + go s.auditService.LogOperation(ctx, &model.AccountOperationLog{ + OperatorID: middleware.GetUserIDFromContext(ctx), + OperatorType: middleware.GetUserTypeFromContext(ctx), + OperatorName: "", + OperationType: "activate", + OperationDesc: desc, + AfterData: model.JSONB{"id": config.ID, "name": config.Name, "is_active": true}, + RequestID: middleware.GetRequestIDFromContext(ctx), + IPAddress: middleware.GetIPFromContext(ctx), + UserAgent: middleware.GetUserAgentFromContext(ctx), + }) + + return dto.FromWechatConfigModel(config), nil +} + +// Deactivate 停用指定配置 +// POST /api/admin/wechat-configs/:id/deactivate +func (s *Service) Deactivate(ctx context.Context, id uint) (*dto.WechatConfigResponse, error) { + config, err := s.store.GetByID(ctx, id) + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeWechatConfigNotFound) + } + return nil, errors.Wrap(errors.CodeInternalError, err, "获取微信支付配置失败") + } + + if err := s.store.Deactivate(ctx, id); err != nil { + return nil, errors.Wrap(errors.CodeInternalError, err, "停用微信支付配置失败") + } + + s.clearActiveConfigCache(ctx) + + // 重新查询最新状态 + config, _ = s.store.GetByID(ctx, id) + + go s.auditService.LogOperation(ctx, &model.AccountOperationLog{ + OperatorID: middleware.GetUserIDFromContext(ctx), + OperatorType: middleware.GetUserTypeFromContext(ctx), + OperatorName: "", + OperationType: "deactivate", + OperationDesc: fmt.Sprintf("停用微信支付配置:%s", config.Name), + AfterData: model.JSONB{"id": config.ID, "name": config.Name, "is_active": false}, + RequestID: middleware.GetRequestIDFromContext(ctx), + IPAddress: middleware.GetIPFromContext(ctx), + UserAgent: middleware.GetUserAgentFromContext(ctx), + }) + + return dto.FromWechatConfigModel(config), nil +} + +// GetActiveConfig 获取当前生效的支付配置(带 Redis 缓存) +// 缓存策略:命中直接返回,未命中查 DB 后缓存 5 分钟,无记录缓存 "none" 1 分钟 +func (s *Service) GetActiveConfig(ctx context.Context) (*model.WechatConfig, error) { + // 尝试从 Redis 获取 + val, err := s.redis.Get(ctx, redisActiveConfigKey).Result() + if err == nil { + if val == "none" { + return nil, nil + } + var config model.WechatConfig + if err := sonic.UnmarshalString(val, &config); err == nil { + return &config, nil + } + } + + // Redis 未命中,查询数据库 + config, err := s.store.GetActive(ctx) + if err != nil { + if err == gorm.ErrRecordNotFound { + // 无激活配置,缓存空标记 1 分钟 + s.redis.Set(ctx, redisActiveConfigKey, "none", 1*time.Minute) + return nil, nil + } + return nil, errors.Wrap(errors.CodeInternalError, err, "查询激活配置失败") + } + + // 缓存配置 5 分钟 + if data, err := sonic.MarshalString(config); err == nil { + s.redis.Set(ctx, redisActiveConfigKey, data, 5*time.Minute) + } + + return config, nil +} + +// GetActiveConfigForAPI 获取当前生效的支付配置(API 响应,已脱敏) +func (s *Service) GetActiveConfigForAPI(ctx context.Context) (*dto.WechatConfigResponse, error) { + config, err := s.GetActiveConfig(ctx) + if err != nil { + return nil, err + } + if config == nil { + return nil, nil + } + return dto.FromWechatConfigModel(config), nil +} + +// clearActiveConfigCache 清除激活配置的 Redis 缓存 +func (s *Service) clearActiveConfigCache(ctx context.Context) { + if err := s.redis.Del(ctx, redisActiveConfigKey).Err(); err != nil { + s.logger.Warn("清除微信支付配置缓存失败", zap.Error(err)) + } +} + +// validateProviderFields 根据支付渠道类型校验必填字段 +func (s *Service) validateProviderFields(req *dto.CreateWechatConfigRequest) error { + switch req.ProviderType { + case model.ProviderTypeWechat: + if req.WxMchID == "" || req.WxAPIV3Key == "" || req.WxCertContent == "" || + req.WxKeyContent == "" || req.WxSerialNo == "" || req.WxNotifyURL == "" { + return errors.New(errors.CodeInvalidParam, "微信直连支付必填字段不完整:wx_mch_id, wx_api_v3_key, wx_cert_content, wx_key_content, wx_serial_no, wx_notify_url") + } + case model.ProviderTypeFuiou: + if req.FyInsCd == "" || req.FyMchntCd == "" || req.FyTermID == "" || + req.FyPrivateKey == "" || req.FyPublicKey == "" || req.FyAPIURL == "" || req.FyNotifyURL == "" { + return errors.New(errors.CodeInvalidParam, "富友支付必填字段不完整:fy_ins_cd, fy_mchnt_cd, fy_term_id, fy_private_key, fy_public_key, fy_api_url, fy_notify_url") + } + } + return nil +} + +// mergeStringField 合并普通字符串字段:指针非 nil 时用新值覆盖 +func (s *Service) mergeStringField(target *string, newVal *string) { + if newVal != nil { + *target = *newVal + } +} + +// mergeSensitiveField 合并敏感字段:指针非 nil 且非空字符串时覆盖,空字符串保持原值 +func (s *Service) mergeSensitiveField(target *string, newVal *string) { + if newVal != nil && *newVal != "" { + *target = *newVal + } +} diff --git a/internal/store/postgres/wechat_config_store.go b/internal/store/postgres/wechat_config_store.go new file mode 100644 index 0000000..cef0500 --- /dev/null +++ b/internal/store/postgres/wechat_config_store.go @@ -0,0 +1,145 @@ +package postgres + +import ( + "context" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/store" + "github.com/redis/go-redis/v9" + "gorm.io/gorm" +) + +// WechatConfigStore 微信参数配置数据访问层 +type WechatConfigStore struct { + db *gorm.DB + rdb *redis.Client +} + +// NewWechatConfigStore 创建微信参数配置 Store 实例 +func NewWechatConfigStore(db *gorm.DB, rdb *redis.Client) *WechatConfigStore { + return &WechatConfigStore{db: db, rdb: rdb} +} + +// Create 创建微信参数配置 +func (s *WechatConfigStore) Create(ctx context.Context, config *model.WechatConfig) error { + return s.db.WithContext(ctx).Create(config).Error +} + +// GetByID 根据 ID 获取配置(遵循软删除) +func (s *WechatConfigStore) GetByID(ctx context.Context, id uint) (*model.WechatConfig, error) { + var config model.WechatConfig + if err := s.db.WithContext(ctx).First(&config, id).Error; err != nil { + return nil, err + } + return &config, nil +} + +// GetByIDUnscoped 根据 ID 获取配置(包含已软删除的记录,用于回调处理) +func (s *WechatConfigStore) GetByIDUnscoped(ctx context.Context, id uint) (*model.WechatConfig, error) { + var config model.WechatConfig + if err := s.db.WithContext(ctx).Unscoped().First(&config, id).Error; err != nil { + return nil, err + } + return &config, nil +} + +// List 查询配置列表,支持按 provider_type 和 is_active 过滤 +func (s *WechatConfigStore) List(ctx context.Context, opts *store.QueryOptions, filters map[string]interface{}) ([]*model.WechatConfig, int64, error) { + var configs []*model.WechatConfig + var total int64 + + query := s.db.WithContext(ctx).Model(&model.WechatConfig{}) + + if providerType, ok := filters["provider_type"].(string); ok && providerType != "" { + query = query.Where("provider_type = ?", providerType) + } + if isActive, ok := filters["is_active"].(*bool); ok && isActive != nil { + query = query.Where("is_active = ?", *isActive) + } + + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + if opts == nil { + opts = store.DefaultQueryOptions() + } + offset := (opts.Page - 1) * opts.PageSize + query = query.Offset(offset).Limit(opts.PageSize) + + if opts.OrderBy != "" { + query = query.Order(opts.OrderBy) + } + + if err := query.Find(&configs).Error; err != nil { + return nil, 0, err + } + + return configs, total, nil +} + +// Update 更新微信参数配置 +func (s *WechatConfigStore) Update(ctx context.Context, config *model.WechatConfig) error { + return s.db.WithContext(ctx).Save(config).Error +} + +// SoftDelete 软删除微信参数配置 +func (s *WechatConfigStore) SoftDelete(ctx context.Context, id uint) error { + return s.db.WithContext(ctx).Delete(&model.WechatConfig{}, id).Error +} + +// GetActive 获取当前激活的配置 +// 返回 gorm.ErrRecordNotFound 如果没有激活的配置 +func (s *WechatConfigStore) GetActive(ctx context.Context) (*model.WechatConfig, error) { + var config model.WechatConfig + if err := s.db.WithContext(ctx).Where("is_active = ? AND deleted_at IS NULL", true).First(&config).Error; err != nil { + return nil, err + } + return &config, nil +} + +// ActivateInTx 在事务内激活指定配置(先停用所有,再激活指定记录) +func (s *WechatConfigStore) ActivateInTx(ctx context.Context, tx *gorm.DB, id uint) error { + // 先停用所有激活的配置 + if err := tx.WithContext(ctx).Model(&model.WechatConfig{}). + Where("is_active = ?", true). + Update("is_active", false).Error; err != nil { + return err + } + // 再激活指定配置 + return tx.WithContext(ctx).Model(&model.WechatConfig{}). + Where("id = ?", id). + Update("is_active", true).Error +} + +// DB 返回底层数据库连接,用于事务操作 +func (s *WechatConfigStore) DB() *gorm.DB { + return s.db +} + +// Deactivate 停用指定配置 +func (s *WechatConfigStore) Deactivate(ctx context.Context, id uint) error { + return s.db.WithContext(ctx).Model(&model.WechatConfig{}). + Where("id = ?", id). + Update("is_active", false).Error +} + +// CountPendingOrdersByConfigID 统计指定配置的待支付订单数 +func (s *WechatConfigStore) CountPendingOrdersByConfigID(ctx context.Context, configID uint) (int64, error) { + var count int64 + err := s.db.WithContext(ctx). + Table("tb_order"). + Where("payment_config_id = ? AND payment_status = ? AND deleted_at IS NULL", configID, 1). + Count(&count).Error + return count, err +} + +// CountPendingRechargesByConfigID 统计指定配置的待支付充值记录数 +func (s *WechatConfigStore) CountPendingRechargesByConfigID(ctx context.Context, configID uint) (int64, error) { + var count int64 + err := s.db.WithContext(ctx). + Table("tb_asset_recharge_record"). + Where("payment_config_id = ? AND status = ? AND deleted_at IS NULL", configID, 1). + Count(&count).Error + return count, err +}