package payment import ( "context" "encoding/json" "errors" "fmt" "net/http" "strings" "sync" "time" "server/models" beego "github.com/beego/beego/v2/server/web" ) // ============================================================= // 渠道适配器接口与运行时配置 // // 五个渠道的差异全部收敛在各 Channel 实现里(wechat/alipay/unionpay/cloudpay/paypal), // 支付服务(service.go)只面向本接口编程,业务页面不出现渠道 if-else。 // ============================================================= // 渠道标识(与 yz_platform_payment_channel.channel、前端页面一一对应) const ( ChannelWechat = "wechat" ChannelAlipay = "alipay" ChannelUnionPay = "unionpay" ChannelCloudPay = "cloudpay" ChannelPayPal = "paypal" ) // 归一化交易状态(各渠道状态统一映射到这几个值) const ( StateSuccess = "success" StatePending = "pending" StateFailed = "failed" StateClosed = "closed" StateRefunded = "refunded" ) // 预下单方式 const ( PayTypeQR = "qr" // 扫码(微信 Native) PayTypeWeb = "web" // PC 网页跳转(支付宝电脑网站支付 / 银联网关 / PayPal) PayTypeH5 = "h5" // 手机网页跳转 PayTypeJSAPI = "jsapi" // 公众号内支付(需要 OpenID) ) var ( ErrChannelNotConfigured = errors.New("渠道未配置或参数不完整") ErrChannelDisabled = errors.New("渠道已停用") ErrChannelNotFound = errors.New("渠道不存在") ) // PayParams 渠道下单后返回给前端的拉起支付参数 type PayParams struct { Channel string `json:"channel"` PayType string `json:"pay_type"` CodeURL string `json:"code_url,omitempty"` // 扫码:二维码内容 RedirectURL string `json:"redirect_url,omitempty"` // 跳转:直接 302 或前端 window.open Form string `json:"form,omitempty"` // 表单:写入页面后自动提交(银联前台跳转等) JSAPI map[string]string `json:"jsapi,omitempty"` // 公众号内:WeixinJSBridge 调用参数 ChannelTradeNo string `json:"channel_trade_no,omitempty"` // 渠道侧单号(如有) } // ChannelState 渠道侧交易状态(已归一化) type ChannelState struct { ChannelTradeNo string TradeState string // StateSuccess / StatePending / StateFailed / StateClosed Amount int64 // 分 PaidAt *time.Time // 渠道侧支付完成时间 Raw string // 渠道原始响应(排查用) } // NotifyResult 渠道异步通知解析结果(已验签、已解密) type NotifyResult struct { PayNo string OutTradeNo string ChannelTradeNo string EventType string // 渠道事件类型原文 EventID string // 渠道事件/通知 ID(用于幂等去重) TradeState string // 归一化状态 Amount int64 // 分 Paid bool // 是否支付成功事件 AckBody string // 需要原样返回给渠道的响应体(SUCCESS / success / ok / "") Raw string // 通知报文(脱敏后入库) } // PrepayOption 预下单附加参数 type PrepayOption struct { PayType string // PayTypeQR / PayTypeWeb / PayTypeH5 / PayTypeJSAPI;空值由适配器给默认 OpenID string // JSAPI 支付必填 } // ChannelConfig 渠道运行时配置(敏感参数已解密) type ChannelConfig struct { ID uint64 Channel string Name string MerchantNo string Enabled bool CallbackURL string Params map[string]string // 解密后的渠道参数 Extra map[string]any // 渠道扩展开关(云闪付标识、PayPal 环境等) CertPaths map[string]string // 证书文件路径(cert_json) } // Get 读取渠道参数 func (c *ChannelConfig) Get(key string) string { if c == nil || c.Params == nil { return "" } return strings.TrimSpace(c.Params[key]) } // ExtraBool 读取扩展开关 func (c *ChannelConfig) ExtraBool(key string, def bool) bool { if c == nil || c.Extra == nil { return def } if v, ok := c.Extra[key].(bool); ok { return v } return def } // EnsureUsable 校验配置可用(已启用且参数非空) func (c *ChannelConfig) EnsureUsable() error { if c == nil || len(c.Params) == 0 { return ErrChannelNotConfigured } if !c.Enabled { return ErrChannelDisabled } return nil } // Channel 渠道适配器接口 type Channel interface { Code() string Name() string // Prepay 预下单,返回拉起支付参数 Prepay(ctx context.Context, order *models.PlatformPaymentOrder, cfg *ChannelConfig, opt PrepayOption) (*PayParams, error) // Query 主动查询渠道侧交易状态 Query(ctx context.Context, order *models.PlatformPaymentOrder, cfg *ChannelConfig) (*ChannelState, error) // ParseNotify 解析并验签渠道异步通知 ParseNotify(ctx context.Context, r *http.Request, cfg *ChannelConfig) (*NotifyResult, error) // Refund 原路退回(兜底能力),返回渠道退回流水号 Refund(ctx context.Context, order *models.PlatformPaymentOrder, refundNo string, amount int64, reason string, cfg *ChannelConfig) (string, error) // TestConnect 连通性/凭证校验,返回人类可读的结果说明 TestConnect(ctx context.Context, cfg *ChannelConfig) (string, error) } var ( registryMu sync.RWMutex registry = map[string]Channel{} ) // Register 注册渠道适配器 func Register(ch Channel) { registryMu.Lock() defer registryMu.Unlock() registry[ch.Code()] = ch } // GetChannelAdapter 取渠道适配器 func GetChannelAdapter(code string) (Channel, error) { registryMu.RLock() defer registryMu.RUnlock() ch, ok := registry[code] if !ok { return nil, ErrChannelNotFound } return ch, nil } // Channels 返回全部已注册适配器 func Channels() []Channel { registryMu.RLock() defer registryMu.RUnlock() list := make([]Channel, 0, len(registry)) for _, ch := range registry { list = append(list, ch) } return list } // LoadChannelConfig 从库加载渠道配置(敏感参数解密) func LoadChannelConfig(channel string) (*ChannelConfig, error) { row := &models.PlatformPaymentChannel{} err := models.Orm.QueryTable(new(models.PlatformPaymentChannel)). Filter("channel", channel). Filter("delete_time__isnull", true). One(row) if err != nil { return nil, fmt.Errorf("渠道 %s 配置不存在", channel) } cfg := &ChannelConfig{ ID: row.ID, Channel: row.Channel, Name: row.Name, MerchantNo: row.MerchantNo, Enabled: row.Enabled == 1, CallbackURL: row.CallbackURL, Params: map[string]string{}, Extra: map[string]any{}, CertPaths: map[string]string{}, } if row.ConfigJSON != nil && *row.ConfigJSON != "" { plain, derr := DecryptConfig(*row.ConfigJSON) if derr != nil { return nil, fmt.Errorf("渠道 %s 参数解密失败: %w", channel, derr) } _ = json.Unmarshal([]byte(plain), &cfg.Params) } if row.ExtraJSON != nil && *row.ExtraJSON != "" { _ = json.Unmarshal([]byte(*row.ExtraJSON), &cfg.Extra) } if row.CertJSON != nil && *row.CertJSON != "" { _ = json.Unmarshal([]byte(*row.CertJSON), &cfg.CertPaths) } return cfg, nil } // LoadEnabledChannelConfig 加载「已启用且参数完整」的渠道配置 func LoadEnabledChannelConfig(channel string) (*ChannelConfig, error) { cfg, err := LoadChannelConfig(channel) if err != nil { return nil, err } if err := cfg.EnsureUsable(); err != nil { return nil, err } return cfg, nil } // PublicBaseURL 对外域名基址:app.conf 的 payment_callback_base(如 https://pay.example.com) // 未配置时返回空串,回调地址形如 /api/payment/callback/wechat(由网关补齐域名)。 func PublicBaseURL() string { v, _ := beego.AppConfig.String("payment_callback_base") return strings.TrimRight(strings.TrimSpace(v), "/") } // CallbackURL 渠道回调地址(与渠道配置表 callback_url 保持一致) func CallbackURL(channel string) string { if cfg, err := LoadChannelConfig(channel); err == nil && cfg.CallbackURL != "" { return cfg.CallbackURL } return PublicBaseURL() + "/api/payment/callback/" + channel } func init() { Register(&WechatChannel{}) Register(&AlipayChannel{}) Register(&UnionPayChannel{}) Register(&CloudPayChannel{}) Register(&PayPalChannel{}) }