262 lines
8.2 KiB
Go
262 lines
8.2 KiB
Go
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{})
|
||
}
|