Files
2026-09-15 12:46:29 +08:00

262 lines
8.2 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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{})
}