增加支付功能

This commit is contained in:
2026-09-15 12:46:29 +08:00
parent 943c3708b0
commit a82b300b1a
46 changed files with 11675 additions and 21 deletions
+261
View File
@@ -0,0 +1,261 @@
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{})
}