增加支付功能
This commit is contained in:
@@ -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{})
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
|
||||
"github.com/smartwalle/alipay/v3"
|
||||
)
|
||||
|
||||
// AlipayChannel 支付宝(电脑网站支付 / 手机网站支付,RSA2)
|
||||
//
|
||||
// 渠道参数(config_json):
|
||||
//
|
||||
// appid 开放平台应用 APPID
|
||||
// app_private_key 应用私钥(RSA2,PKCS8)
|
||||
// alipay_public_key 支付宝公钥(回显公钥,用于验签)
|
||||
// is_production "1" 正式环境 / 其他为沙箱
|
||||
type AlipayChannel struct{}
|
||||
|
||||
func (c *AlipayChannel) Code() string { return ChannelAlipay }
|
||||
func (c *AlipayChannel) Name() string { return "支付宝" }
|
||||
|
||||
func (c *AlipayChannel) buildClient(cfg *ChannelConfig) (*alipay.Client, error) {
|
||||
appID := cfg.Get("appid")
|
||||
priv := cfg.Get("app_private_key")
|
||||
pub := cfg.Get("alipay_public_key")
|
||||
if appID == "" || priv == "" || pub == "" {
|
||||
return nil, fmt.Errorf("支付宝参数不完整:需要 appid / app_private_key / alipay_public_key")
|
||||
}
|
||||
client, err := alipay.New(appID, priv, cfg.Get("is_production") == "1")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建支付宝客户端失败: %w", err)
|
||||
}
|
||||
if err := client.LoadAliPayPublicKey(pub); err != nil {
|
||||
return nil, fmt.Errorf("加载支付宝公钥失败: %w", err)
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (c *AlipayChannel) Prepay(ctx context.Context, order *models.PlatformPaymentOrder, cfg *ChannelConfig, opt PrepayOption) (*PayParams, error) {
|
||||
client, err := c.buildClient(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
trade := alipay.Trade{
|
||||
NotifyURL: cfg.CallbackURL,
|
||||
ReturnURL: order.ReturnURL,
|
||||
Subject: order.Subject,
|
||||
OutTradeNo: order.PayNo,
|
||||
TotalAmount: FenToYuan(order.Amount),
|
||||
}
|
||||
if order.ExpireAt != nil {
|
||||
mins := int(time.Until(*order.ExpireAt).Minutes())
|
||||
if mins < 1 {
|
||||
mins = 1
|
||||
}
|
||||
trade.TimeoutExpress = fmt.Sprintf("%dm", mins)
|
||||
}
|
||||
|
||||
if strings.EqualFold(opt.PayType, PayTypeH5) {
|
||||
// 手机网站支付
|
||||
trade.ProductCode = "QUICK_WAP_WAY"
|
||||
u, err := client.TradeWapPay(alipay.TradeWapPay{Trade: trade})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("支付宝手机网站下单失败: %w", err)
|
||||
}
|
||||
return &PayParams{Channel: ChannelAlipay, PayType: PayTypeH5, RedirectURL: u.String()}, nil
|
||||
}
|
||||
|
||||
// 默认:电脑网站支付(PC 收银台)
|
||||
trade.ProductCode = "FAST_INSTANT_TRADE_PAY"
|
||||
u, err := client.TradePagePay(alipay.TradePagePay{Trade: trade})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("支付宝电脑网站下单失败: %w", err)
|
||||
}
|
||||
return &PayParams{Channel: ChannelAlipay, PayType: PayTypeWeb, RedirectURL: u.String()}, nil
|
||||
}
|
||||
|
||||
// normalizeAlipayStatus 支付宝交易状态 -> 归一化状态
|
||||
func normalizeAlipayStatus(status alipay.TradeStatus) string {
|
||||
switch status {
|
||||
case alipay.TradeStatusSuccess, alipay.TradeStatusFinished:
|
||||
return StateSuccess
|
||||
case alipay.TradeStatusClosed:
|
||||
return StateClosed
|
||||
case alipay.TradeStatusWaitBuyerPay:
|
||||
return StatePending
|
||||
default:
|
||||
return StatePending
|
||||
}
|
||||
}
|
||||
|
||||
func (c *AlipayChannel) Query(ctx context.Context, order *models.PlatformPaymentOrder, cfg *ChannelConfig) (*ChannelState, error) {
|
||||
client, err := c.buildClient(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rsp, err := client.TradeQuery(ctx, alipay.TradeQuery{OutTradeNo: order.PayNo})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("支付宝查询订单失败: %w", err)
|
||||
}
|
||||
// 交易不存在(如超时未付被渠道关闭):不视为失败,返回待支付以便走关闭逻辑
|
||||
if rsp.Code != "10000" {
|
||||
if rsp.SubCode == "ACQ.TRADE_NOT_EXIST" {
|
||||
return &ChannelState{TradeState: StatePending, Raw: rsp.SubMsg}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("支付宝查询失败: %s %s %s", rsp.Code, rsp.SubCode, rsp.SubMsg)
|
||||
}
|
||||
|
||||
state := &ChannelState{
|
||||
ChannelTradeNo: rsp.TradeNo,
|
||||
TradeState: normalizeAlipayStatus(rsp.TradeStatus),
|
||||
Raw: fmt.Sprintf("trade_no=%s status=%s total=%s", rsp.TradeNo, rsp.TradeStatus, rsp.TotalAmount),
|
||||
}
|
||||
if fen, err := YuanToFen(rsp.TotalAmount); err == nil {
|
||||
state.Amount = fen
|
||||
}
|
||||
if state.TradeState == StateSuccess {
|
||||
now := time.Now()
|
||||
state.PaidAt = &now
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (c *AlipayChannel) ParseNotify(ctx context.Context, r *http.Request, cfg *ChannelConfig) (*NotifyResult, error) {
|
||||
client, err := c.buildClient(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
return nil, fmt.Errorf("解析支付宝通知参数失败: %w", err)
|
||||
}
|
||||
notification, err := client.DecodeNotification(ctx, r.Form)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("支付宝通知验签失败: %w", err)
|
||||
}
|
||||
|
||||
result := &NotifyResult{
|
||||
EventType: notification.NotifyType,
|
||||
EventID: notification.NotifyId,
|
||||
OutTradeNo: notification.OutTradeNo,
|
||||
PayNo: notification.OutTradeNo,
|
||||
ChannelTradeNo: notification.TradeNo,
|
||||
AckBody: "success",
|
||||
}
|
||||
if fen, err := YuanToFen(notification.TotalAmount); err == nil {
|
||||
result.Amount = fen
|
||||
}
|
||||
result.TradeState = normalizeAlipayStatus(notification.TradeStatus)
|
||||
result.Paid = result.TradeState == StateSuccess
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *AlipayChannel) Refund(ctx context.Context, order *models.PlatformPaymentOrder, refundNo string, amount int64, reason string, cfg *ChannelConfig) (string, error) {
|
||||
client, err := c.buildClient(cfg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
rsp, err := client.TradeRefund(ctx, alipay.TradeRefund{
|
||||
OutTradeNo: order.PayNo,
|
||||
RefundAmount: FenToYuan(amount),
|
||||
RefundReason: reason,
|
||||
OutRequestNo: refundNo,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("支付宝退回请求失败: %w", err)
|
||||
}
|
||||
if rsp.Code != "10000" {
|
||||
return "", fmt.Errorf("支付宝退回失败: %s %s %s", rsp.Code, rsp.SubCode, rsp.SubMsg)
|
||||
}
|
||||
return rsp.TradeNo, nil
|
||||
}
|
||||
|
||||
func (c *AlipayChannel) TestConnect(ctx context.Context, cfg *ChannelConfig) (string, error) {
|
||||
client, err := c.buildClient(cfg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// 查询一笔必然不存在的交易:能返回「交易不存在」即说明 APPID 与签名配置正确
|
||||
rsp, err := client.TradeQuery(ctx, alipay.TradeQuery{OutTradeNo: "__connect_test__"})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("支付宝凭证校验失败: %w", err)
|
||||
}
|
||||
if rsp.Code == "10000" || rsp.SubCode == "ACQ.TRADE_NOT_EXIST" {
|
||||
return "连接成功:APPID 与 RSA2 签名校验通过", nil
|
||||
}
|
||||
return "", fmt.Errorf("支付宝凭证校验未通过: %s %s %s", rsp.Code, rsp.SubCode, rsp.SubMsg)
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
|
||||
"github.com/plutov/paypal/v4"
|
||||
)
|
||||
|
||||
// PayPalChannel PayPal(Checkout v2 + Webhook)
|
||||
//
|
||||
// 渠道参数(config_json):
|
||||
//
|
||||
// client_id REST App Client ID
|
||||
// client_secret REST App Secret
|
||||
// webhook_id Webhook ID(用于验签)
|
||||
// currency 币种,默认 USD
|
||||
//
|
||||
// 扩展(extra_json):
|
||||
//
|
||||
// env sandbox / live
|
||||
type PayPalChannel struct{}
|
||||
|
||||
func (c *PayPalChannel) Code() string { return ChannelPayPal }
|
||||
func (c *PayPalChannel) Name() string { return "PayPal" }
|
||||
|
||||
func (c *PayPalChannel) buildClient(cfg *ChannelConfig) (*paypal.Client, error) {
|
||||
clientID := cfg.Get("client_id")
|
||||
secret := cfg.Get("client_secret")
|
||||
if clientID == "" || secret == "" {
|
||||
return nil, fmt.Errorf("PayPal 参数不完整:需要 client_id / client_secret")
|
||||
}
|
||||
apiBase := paypal.APIBaseSandBox
|
||||
if strings.EqualFold(fmt.Sprint(cfg.Extra["env"]), "live") {
|
||||
apiBase = paypal.APIBaseLive
|
||||
}
|
||||
client, err := paypal.NewClient(clientID, secret, apiBase)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建 PayPal 客户端失败: %w", err)
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (c *PayPalChannel) currency(cfg *ChannelConfig) string {
|
||||
if v := cfg.Get("currency"); v != "" {
|
||||
return v
|
||||
}
|
||||
return "USD"
|
||||
}
|
||||
|
||||
func (c *PayPalChannel) Prepay(ctx context.Context, order *models.PlatformPaymentOrder, cfg *ChannelConfig, opt PrepayOption) (*PayParams, error) {
|
||||
client, err := c.buildClient(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
subject := truncateStr(order.Subject, 120)
|
||||
payOrder, err := client.CreateOrder(ctx, paypal.OrderIntentCapture,
|
||||
[]paypal.PurchaseUnitRequest{{
|
||||
CustomID: order.PayNo,
|
||||
Description: subject,
|
||||
Amount: &paypal.PurchaseUnitAmount{
|
||||
Currency: c.currency(cfg),
|
||||
Value: FenToYuan(order.Amount),
|
||||
},
|
||||
}},
|
||||
nil,
|
||||
&paypal.ApplicationContext{
|
||||
ReturnURL: order.ReturnURL,
|
||||
CancelURL: order.ReturnURL,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("PayPal 下单失败: %w", err)
|
||||
}
|
||||
|
||||
// v2 的跳转链接 rel 为 payer-action(旧版为 approve)
|
||||
approve := ""
|
||||
for _, l := range payOrder.Links {
|
||||
if l.Rel == "approve" || l.Rel == "payer-action" {
|
||||
approve = l.Href
|
||||
break
|
||||
}
|
||||
}
|
||||
if approve == "" {
|
||||
return nil, fmt.Errorf("PayPal 下单未返回跳转链接")
|
||||
}
|
||||
return &PayParams{
|
||||
Channel: ChannelPayPal,
|
||||
PayType: PayTypeWeb,
|
||||
RedirectURL: approve,
|
||||
ChannelTradeNo: payOrder.ID, // PayPal 订单号,回调后替换为 capture id
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *PayPalChannel) Query(ctx context.Context, order *models.PlatformPaymentOrder, cfg *ChannelConfig) (*ChannelState, error) {
|
||||
client, err := c.buildClient(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if order.ChannelTradeNo == "" {
|
||||
return nil, fmt.Errorf("PayPal 查询需要渠道订单号(尚未下单成功)")
|
||||
}
|
||||
payOrder, err := client.GetOrder(ctx, order.ChannelTradeNo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("PayPal 查询订单失败: %w", err)
|
||||
}
|
||||
state := &ChannelState{ChannelTradeNo: payOrder.ID, Raw: fmt.Sprintf("status=%s", payOrder.Status)}
|
||||
switch payOrder.Status {
|
||||
case "COMPLETED":
|
||||
state.TradeState = StateSuccess
|
||||
now := time.Now()
|
||||
state.PaidAt = &now
|
||||
case "VOIDED":
|
||||
state.TradeState = StateClosed
|
||||
case "APPROVED":
|
||||
// 用户已批准但未捕获,等待 webhook 或人工捕获
|
||||
state.TradeState = StatePending
|
||||
default:
|
||||
state.TradeState = StatePending
|
||||
}
|
||||
for _, pu := range payOrder.PurchaseUnits {
|
||||
if pu.Amount != nil {
|
||||
if fen, err := YuanToFen(pu.Amount.Value); err == nil {
|
||||
state.Amount = fen
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
type paypalEvent struct {
|
||||
ID string `json:"id"`
|
||||
EventType string `json:"event_type"`
|
||||
Resource struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
CustomID string `json:"custom_id"`
|
||||
Amount struct {
|
||||
Value string `json:"value"`
|
||||
CurrencyCode string `json:"currency_code"`
|
||||
} `json:"amount"`
|
||||
} `json:"resource"`
|
||||
}
|
||||
|
||||
func (c *PayPalChannel) ParseNotify(ctx context.Context, r *http.Request, cfg *ChannelConfig) (*NotifyResult, error) {
|
||||
client, err := c.buildClient(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
webhookID := cfg.Get("webhook_id")
|
||||
if webhookID == "" {
|
||||
return nil, fmt.Errorf("PayPal 参数不完整:需要 webhook_id")
|
||||
}
|
||||
|
||||
// VerifyWebhookSignature 会读取请求体,先读出来再回填,供后面解析事件内容
|
||||
raw, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取 PayPal 通知失败: %w", err)
|
||||
}
|
||||
r.Body = io.NopCloser(bytes.NewReader(raw))
|
||||
|
||||
verify, err := client.VerifyWebhookSignature(ctx, r, webhookID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("PayPal 验签请求失败: %w", err)
|
||||
}
|
||||
if verify == nil || verify.VerificationStatus != "SUCCESS" {
|
||||
return nil, fmt.Errorf("PayPal 通知验签失败: %s", verify.VerificationStatus)
|
||||
}
|
||||
|
||||
event := &paypalEvent{}
|
||||
if err := json.Unmarshal(raw, event); err != nil {
|
||||
return nil, fmt.Errorf("解析 PayPal 事件失败: %w", err)
|
||||
}
|
||||
|
||||
result := &NotifyResult{
|
||||
EventType: event.EventType,
|
||||
EventID: event.ID,
|
||||
AckBody: "",
|
||||
Raw: truncateStr(string(raw), 8192),
|
||||
}
|
||||
result.PayNo = event.Resource.CustomID
|
||||
result.OutTradeNo = event.Resource.CustomID
|
||||
result.ChannelTradeNo = event.Resource.ID
|
||||
if fen, ferr := YuanToFen(event.Resource.Amount.Value); ferr == nil {
|
||||
result.Amount = fen
|
||||
}
|
||||
switch event.EventType {
|
||||
case "PAYMENT.CAPTURE.COMPLETED":
|
||||
result.TradeState = StateSuccess
|
||||
result.Paid = true
|
||||
case "PAYMENT.CAPTURE.DENIED", "PAYMENT.CAPTURE.DECLINED":
|
||||
result.TradeState = StateFailed
|
||||
case "PAYMENT.CAPTURE.REFUNDED", "PAYMENT.CAPTURE.REVERSED":
|
||||
result.TradeState = StateRefunded
|
||||
default:
|
||||
result.TradeState = StatePending
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *PayPalChannel) Refund(ctx context.Context, order *models.PlatformPaymentOrder, refundNo string, amount int64, reason string, cfg *ChannelConfig) (string, error) {
|
||||
client, err := c.buildClient(cfg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if order.ChannelTradeNo == "" {
|
||||
return "", fmt.Errorf("PayPal 退回需要 capture id(支付成功回调后写入),请先确认该单已收到成功回调")
|
||||
}
|
||||
resp, err := client.RefundCapture(ctx, order.ChannelTradeNo, paypal.RefundCaptureRequest{
|
||||
Amount: &paypal.Money{
|
||||
Currency: c.currency(cfg),
|
||||
Value: FenToYuan(amount),
|
||||
},
|
||||
NoteToPayer: truncateStr(reason, 200),
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("PayPal 退回请求失败: %w", err)
|
||||
}
|
||||
return resp.ID, nil
|
||||
}
|
||||
|
||||
func (c *PayPalChannel) TestConnect(ctx context.Context, cfg *ChannelConfig) (string, error) {
|
||||
client, err := c.buildClient(cfg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
token, err := client.GetAccessToken(ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("PayPal OAuth2 凭证校验失败: %w", err)
|
||||
}
|
||||
if token == nil || token.Token == "" {
|
||||
return "", fmt.Errorf("PayPal OAuth2 未返回访问令牌")
|
||||
}
|
||||
return fmt.Sprintf("连接成功:OAuth2 凭证校验通过(环境 %s)", cfg.Extra["env"]), nil
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
|
||||
pkcs12 "software.sslmate.com/src/go-pkcs12"
|
||||
)
|
||||
|
||||
// UnionPayChannel 银联全渠道(PC 网关支付 / 手机控件支付)
|
||||
//
|
||||
// 银联没有官方 Go SDK,这里按「全渠道支付平台产品接口规范」实现签名与请求:
|
||||
// - 签名:SHA256withRSA,字段按 key ASCII 升序拼 key=value&... 后私钥签名,base64 放入 signature;
|
||||
// - 下单:POST /gateway/api/frontTransReq.do(前台跳转,返回自动提交表单);
|
||||
// - 查询:POST /gateway/api/queryTrans.do(txnType=00);
|
||||
// - 退货:POST /gateway/api/backTransReq.do(txnType=04)。
|
||||
//
|
||||
// 渠道参数(config_json):
|
||||
//
|
||||
// mer_id 商户号
|
||||
// acq_ins_code 收单机构代码
|
||||
// cert_password 商户证书密码
|
||||
// is_production "1" 生产网关 / 其他为测试网关
|
||||
//
|
||||
// 证书(cert_json):
|
||||
//
|
||||
// cert_path 商户证书(.pfx / .p12,或 .cer + .pem 私钥组合)
|
||||
type UnionPayChannel struct{}
|
||||
|
||||
func (c *UnionPayChannel) Code() string { return ChannelUnionPay }
|
||||
func (c *UnionPayChannel) Name() string { return "银联" }
|
||||
|
||||
const (
|
||||
unionPayGatewayProd = "https://gateway.95516.com"
|
||||
unionPayGatewayTest = "https://gateway.test.95516.com"
|
||||
unionPayVersion = "5.1.0"
|
||||
unionPayCurrencyCNY = "156"
|
||||
unionPayDefaultBizType = "000201" // B2C 网关支付
|
||||
)
|
||||
|
||||
func unionPayGateway(cfg *ChannelConfig) string {
|
||||
if cfg.Get("is_production") == "1" {
|
||||
return unionPayGatewayProd
|
||||
}
|
||||
return unionPayGatewayTest
|
||||
}
|
||||
|
||||
// unionPayKey 加载商户私钥与证书(支持 .pfx/.p12 与 .pem+.cer 两种形式)
|
||||
func unionPayKey(cfg *ChannelConfig) (*rsa.PrivateKey, *x509.Certificate, error) {
|
||||
certPath := cfg.CertPaths["cert_path"]
|
||||
if certPath == "" {
|
||||
return nil, nil, fmt.Errorf("银联缺少商户证书:请先上传商户证书")
|
||||
}
|
||||
data, err := os.ReadFile(certPath)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("读取银联证书失败: %w", err)
|
||||
}
|
||||
if block, _ := pem.Decode(data); block != nil && strings.Contains(block.Type, "PRIVATE KEY") {
|
||||
keyData, kerr := os.ReadFile(cfg.CertPaths["key_path"])
|
||||
if kerr != nil {
|
||||
return nil, nil, fmt.Errorf("读取银联私钥失败: %w", kerr)
|
||||
}
|
||||
priv, perr := parsePKCS1Or8(string(keyData))
|
||||
if perr != nil {
|
||||
return nil, nil, perr
|
||||
}
|
||||
cert, cerr := loadCertFile(certPath)
|
||||
if cerr != nil {
|
||||
return nil, nil, cerr
|
||||
}
|
||||
return priv, cert, nil
|
||||
}
|
||||
|
||||
privAny, cert, err := pkcs12.Decode(data, cfg.Get("cert_password"))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("解析银联 .pfx 证书失败(密码是否正确): %w", err)
|
||||
}
|
||||
priv, ok := privAny.(*rsa.PrivateKey)
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("银联证书私钥不是 RSA 私钥")
|
||||
}
|
||||
if cert == nil {
|
||||
return nil, nil, fmt.Errorf("银联证书缺少证书信息")
|
||||
}
|
||||
return priv, cert, nil
|
||||
}
|
||||
|
||||
func parsePKCS1Or8(pemStr string) (*rsa.PrivateKey, error) {
|
||||
block, _ := pem.Decode([]byte(pemStr))
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("私钥不是有效的 PEM")
|
||||
}
|
||||
if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
|
||||
return key, nil
|
||||
}
|
||||
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析私钥失败: %w", err)
|
||||
}
|
||||
rsaKey, ok := key.(*rsa.PrivateKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("私钥不是 RSA 私钥")
|
||||
}
|
||||
return rsaKey, nil
|
||||
}
|
||||
|
||||
func loadCertFile(path string) (*x509.Certificate, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
block, _ := pem.Decode(data)
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("证书不是有效的 PEM")
|
||||
}
|
||||
return x509.ParseCertificate(block.Bytes)
|
||||
}
|
||||
|
||||
// unionPayCertID 银联证书序列号(SerialNumber 十六进制大写)
|
||||
func unionPayCertID(cert *x509.Certificate) string {
|
||||
return strings.ToUpper(fmt.Sprintf("%x", cert.SerialNumber))
|
||||
}
|
||||
|
||||
// unionPaySign 银联签名:key 升序拼 key=value&... 后 SHA256withRSA
|
||||
func unionPaySign(params map[string]string, key *rsa.PrivateKey) (string, error) {
|
||||
keys := make([]string, 0, len(params))
|
||||
for k, v := range params {
|
||||
if k == "signature" || v == "" {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
pairs := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
pairs = append(pairs, k+"="+params[k])
|
||||
}
|
||||
digest := sha256.Sum256([]byte(strings.Join(pairs, "&")))
|
||||
sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, digest[:])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(sig), nil
|
||||
}
|
||||
|
||||
// unionPaySigned 组装带签名与 certId 的请求参数
|
||||
func unionPaySigned(cfg *ChannelConfig, priv *rsa.PrivateKey, cert *x509.Certificate, extra map[string]string) (map[string]string, error) {
|
||||
params := map[string]string{
|
||||
"version": unionPayVersion,
|
||||
"encoding": "UTF-8",
|
||||
"signMethod": "01",
|
||||
"accessType": "0",
|
||||
"merId": cfg.Get("mer_id"),
|
||||
}
|
||||
for k, v := range extra {
|
||||
if v != "" {
|
||||
params[k] = v
|
||||
}
|
||||
}
|
||||
sig, err := unionPaySign(params, priv)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("银联签名失败: %w", err)
|
||||
}
|
||||
params["signature"] = sig
|
||||
params["certId"] = unionPayCertID(cert)
|
||||
return params, nil
|
||||
}
|
||||
|
||||
// unionPayPostRaw 提交表单并返回原始响应
|
||||
func unionPayPostRaw(ctx context.Context, endpoint string, params map[string]string) (string, error) {
|
||||
form := url.Values{}
|
||||
for k, v := range params {
|
||||
form.Set(k, v)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded;charset=UTF-8")
|
||||
resp, err := (&http.Client{Timeout: 20 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("请求银联网关失败: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("银联网关返回 HTTP %d: %s", resp.StatusCode, truncateStr(string(body), 200))
|
||||
}
|
||||
return string(body), nil
|
||||
}
|
||||
|
||||
// unionPayPostForm 解析 form-urlencoded 响应(queryTrans / backTransReq.do)
|
||||
func unionPayPostForm(ctx context.Context, endpoint string, params map[string]string) (url.Values, error) {
|
||||
body, err := unionPayPostRaw(ctx, endpoint, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
vals, perr := url.ParseQuery(body)
|
||||
if perr != nil || len(vals) == 0 {
|
||||
return nil, fmt.Errorf("银联响应不是有效的表单数据: %s", truncateStr(body, 200))
|
||||
}
|
||||
return vals, nil
|
||||
}
|
||||
|
||||
func truncateStr(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "..."
|
||||
}
|
||||
|
||||
func (c *UnionPayChannel) Prepay(ctx context.Context, order *models.PlatformPaymentOrder, cfg *ChannelConfig, opt PrepayOption) (*PayParams, error) {
|
||||
priv, cert, err := unionPayKey(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
extra := map[string]string{
|
||||
"txnType": "01",
|
||||
"txnSubType": "01",
|
||||
"bizType": unionPayDefaultBizType,
|
||||
"orderId": order.PayNo,
|
||||
"txnTime": time.Now().Format("20060102150405"),
|
||||
"txnAmt": strconv.FormatInt(order.Amount, 10),
|
||||
"currencyCode": unionPayCurrencyCNY,
|
||||
"backUrl": cfg.CallbackURL,
|
||||
"frontUrl": order.ReturnURL,
|
||||
}
|
||||
params, err := unionPaySigned(cfg, priv, cert, extra)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 前台跳转:银联返回自动提交表单,前端写入页面即可拉起收银台
|
||||
html, err := unionPayPostRaw(ctx, unionPayGateway(cfg)+"/gateway/api/frontTransReq.do", params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("银联下单失败: %w", err)
|
||||
}
|
||||
payType := PayTypeWeb
|
||||
if strings.EqualFold(opt.PayType, PayTypeH5) {
|
||||
payType = PayTypeH5
|
||||
}
|
||||
return &PayParams{Channel: ChannelUnionPay, PayType: payType, Form: html}, nil
|
||||
}
|
||||
|
||||
func (c *UnionPayChannel) Query(ctx context.Context, order *models.PlatformPaymentOrder, cfg *ChannelConfig) (*ChannelState, error) {
|
||||
priv, cert, err := unionPayKey(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
extra := map[string]string{
|
||||
"txnType": "00",
|
||||
"txnSubType": "00",
|
||||
"bizType": unionPayDefaultBizType,
|
||||
"orderId": order.PayNo,
|
||||
"txnTime": order.CreateTime.Format("20060102150405"),
|
||||
}
|
||||
params, err := unionPaySigned(cfg, priv, cert, extra)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// TODO(银联): 查询响应字段(origRespCode/origTxnAmt/queryId 等)以入网时下发的接口文档为准,联调时校准。
|
||||
vals, err := unionPayPostForm(ctx, unionPayGateway(cfg)+"/gateway/api/queryTrans.do", params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
state := &ChannelState{Raw: fmt.Sprint(vals)}
|
||||
if vals.Get("queryId") != "" {
|
||||
state.ChannelTradeNo = vals.Get("queryId")
|
||||
}
|
||||
if vals.Get("origRespCode") == "00" {
|
||||
state.TradeState = StateSuccess
|
||||
if amt, aerr := strconv.ParseInt(vals.Get("origTxnAmt"), 10, 64); aerr == nil {
|
||||
state.Amount = amt
|
||||
}
|
||||
now := time.Now()
|
||||
state.PaidAt = &now
|
||||
} else {
|
||||
state.TradeState = StatePending
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (c *UnionPayChannel) ParseNotify(ctx context.Context, r *http.Request, cfg *ChannelConfig) (*NotifyResult, error) {
|
||||
priv, _, err := unionPayKey(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
return nil, fmt.Errorf("解析银联通知失败: %w", err)
|
||||
}
|
||||
form := map[string]string{}
|
||||
for k := range r.Form {
|
||||
form[k] = r.Form.Get(k)
|
||||
}
|
||||
expect, err := unionPaySign(form, priv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if expect != r.Form.Get("signature") {
|
||||
return nil, fmt.Errorf("银联通知验签失败")
|
||||
}
|
||||
|
||||
result := &NotifyResult{
|
||||
EventType: "UNIONPAY.NOTIFY",
|
||||
AckBody: "ok",
|
||||
Raw: fmt.Sprint(r.Form),
|
||||
}
|
||||
result.PayNo = form["orderId"]
|
||||
result.OutTradeNo = form["orderId"]
|
||||
result.ChannelTradeNo = form["queryId"]
|
||||
if amt, aerr := strconv.ParseInt(form["txnAmt"], 10, 64); aerr == nil {
|
||||
result.Amount = amt
|
||||
}
|
||||
if form["respCode"] == "00" {
|
||||
result.TradeState = StateSuccess
|
||||
result.Paid = true
|
||||
} else {
|
||||
result.TradeState = StateFailed
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *UnionPayChannel) Refund(ctx context.Context, order *models.PlatformPaymentOrder, refundNo string, amount int64, reason string, cfg *ChannelConfig) (string, error) {
|
||||
if order.ChannelTradeNo == "" {
|
||||
return "", fmt.Errorf("银联退回需要原交易流水号,请先执行「手动查询渠道状态」")
|
||||
}
|
||||
priv, cert, err := unionPayKey(cfg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
extra := map[string]string{
|
||||
"txnType": "04",
|
||||
"txnSubType": "00",
|
||||
"bizType": unionPayDefaultBizType,
|
||||
"orderId": refundNo,
|
||||
"txnTime": time.Now().Format("20060102150405"),
|
||||
"txnAmt": strconv.FormatInt(amount, 10),
|
||||
"origQryId": order.ChannelTradeNo,
|
||||
"backUrl": cfg.CallbackURL,
|
||||
}
|
||||
params, err := unionPaySigned(cfg, priv, cert, extra)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// TODO(银联): 退货响应字段与重试策略以入网时下发的接口文档为准,联调时校准。
|
||||
vals, err := unionPayPostForm(ctx, unionPayGateway(cfg)+"/gateway/api/backTransReq.do", params)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if vals.Get("respCode") != "00" {
|
||||
return "", fmt.Errorf("银联退回失败: %s %s", vals.Get("respCode"), vals.Get("respMsg"))
|
||||
}
|
||||
return vals.Get("queryId"), nil
|
||||
}
|
||||
|
||||
func (c *UnionPayChannel) TestConnect(ctx context.Context, cfg *ChannelConfig) (string, error) {
|
||||
priv, cert, err := unionPayKey(cfg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := unionPaySign(map[string]string{"merId": cfg.Get("mer_id"), "orderId": "__connect_test__"}, priv); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// TODO(银联): 如需校验网关连通性,可在入网报备后用一笔查询交易确认。
|
||||
return fmt.Sprintf("连接成功:证书加载与签名校验通过(证书序列号 %s,网关 %s)", unionPayCertID(cert), unionPayGateway(cfg)), nil
|
||||
}
|
||||
|
||||
// CloudPayChannel 云闪付:走银联通道,商户参数与证书完全复用银联配置;
|
||||
// 收银台是否展示云闪付标识由 extra_json.show_logo 控制(由前端收银台读取),适配器层面与银联一致。
|
||||
type CloudPayChannel struct {
|
||||
UnionPayChannel
|
||||
}
|
||||
|
||||
func (c *CloudPayChannel) Code() string { return ChannelCloudPay }
|
||||
func (c *CloudPayChannel) Name() string { return "云闪付" }
|
||||
@@ -0,0 +1,316 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/core"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/core/auth/verifiers"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/core/consts"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/core/downloader"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/core/notify"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/core/option"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/services/payments"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/jsapi"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/native"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/utils"
|
||||
)
|
||||
|
||||
// WechatChannel 微信支付(JSAPI / Native 扫码)
|
||||
//
|
||||
// 渠道参数(config_json):
|
||||
//
|
||||
// mch_id 商户号
|
||||
// appid 公众号 / 小程序 APPID
|
||||
// cert_serial_no 商户 API 证书序列号
|
||||
// api_v3_key APIv3 密钥
|
||||
//
|
||||
// 证书(cert_json):
|
||||
//
|
||||
// key_path apiclient_key.pem 上传后的服务端路径(不入库内容,见渠道证书上传接口)
|
||||
type WechatChannel struct{}
|
||||
|
||||
func (c *WechatChannel) Code() string { return ChannelWechat }
|
||||
func (c *WechatChannel) Name() string { return "微信支付" }
|
||||
|
||||
// buildClient 创建微信支付 APIv3 客户端(带自动证书更新与加解密能力)
|
||||
func (c *WechatChannel) buildClient(cfg *ChannelConfig) (*core.Client, string, error) {
|
||||
mchID := cfg.Get("mch_id")
|
||||
serial := cfg.Get("cert_serial_no")
|
||||
apiV3Key := cfg.Get("api_v3_key")
|
||||
if mchID == "" || serial == "" || apiV3Key == "" {
|
||||
return nil, "", fmt.Errorf("微信支付参数不完整:需要 mch_id / cert_serial_no / api_v3_key")
|
||||
}
|
||||
|
||||
keyPath := cfg.CertPaths["key_path"]
|
||||
if keyPath == "" {
|
||||
return nil, "", fmt.Errorf("微信支付缺少商户私钥:请先上传 apiclient_key.pem")
|
||||
}
|
||||
pem, err := os.ReadFile(keyPath)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("读取商户私钥失败: %w", err)
|
||||
}
|
||||
privateKey, err := utils.LoadPrivateKey(string(pem))
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("解析商户私钥失败: %w", err)
|
||||
}
|
||||
|
||||
client, err := core.NewClient(context.Background(),
|
||||
option.WithMerchantCredential(mchID, serial, privateKey),
|
||||
option.WithWechatPayAutoAuthCipher(mchID, serial, privateKey, apiV3Key),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("创建微信支付客户端失败: %w", err)
|
||||
}
|
||||
return client, mchID, nil
|
||||
}
|
||||
|
||||
func (c *WechatChannel) Prepay(ctx context.Context, order *models.PlatformPaymentOrder, cfg *ChannelConfig, opt PrepayOption) (*PayParams, error) {
|
||||
client, mchID, err := c.buildClient(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
appID := cfg.Get("appid")
|
||||
if appID == "" {
|
||||
return nil, fmt.Errorf("微信支付参数不完整:需要 appid")
|
||||
}
|
||||
notifyURL := cfg.CallbackURL
|
||||
amount := &native.Amount{Total: core.Int64(order.Amount), Currency: core.String("CNY")}
|
||||
|
||||
// 公众号内支付
|
||||
if strings.EqualFold(opt.PayType, PayTypeJSAPI) {
|
||||
if opt.OpenID == "" {
|
||||
return nil, fmt.Errorf("微信 JSAPI 支付需要 OpenID")
|
||||
}
|
||||
svc := jsapi.JsapiApiService{Client: client}
|
||||
resp, _, err := svc.PrepayWithRequestPayment(ctx, jsapi.PrepayRequest{
|
||||
Appid: core.String(appID),
|
||||
Mchid: core.String(mchID),
|
||||
Description: core.String(order.Subject),
|
||||
OutTradeNo: core.String(order.PayNo),
|
||||
NotifyUrl: core.String(notifyURL),
|
||||
Amount: &jsapi.Amount{Total: core.Int64(order.Amount), Currency: core.String("CNY")},
|
||||
Payer: &jsapi.Payer{Openid: core.String(opt.OpenID)},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("微信 JSAPI 下单失败: %w", err)
|
||||
}
|
||||
if resp == nil || resp.PrepayId == nil {
|
||||
return nil, fmt.Errorf("微信 JSAPI 下单未返回 prepay_id")
|
||||
}
|
||||
|
||||
// 按微信规范自行生成 JSAPI 调起参数(timeStamp/nonceStr/paySign)
|
||||
ts := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
nonce, _ := randomNonce(16)
|
||||
message := appID + "\n" + ts + "\n" + nonce + "\n" + "prepay_id=" + *resp.PrepayId + "\n"
|
||||
sign, err := client.Sign(ctx, message)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("生成 JSAPI 签名失败: %w", err)
|
||||
}
|
||||
return &PayParams{
|
||||
Channel: ChannelWechat,
|
||||
PayType: PayTypeJSAPI,
|
||||
JSAPI: map[string]string{
|
||||
"appId": appID,
|
||||
"timeStamp": ts,
|
||||
"nonceStr": nonce,
|
||||
"package": "prepay_id=" + *resp.PrepayId,
|
||||
"signType": "RSA",
|
||||
"paySign": sign.Signature,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 默认:Native 扫码(PC 收银台)
|
||||
req := native.PrepayRequest{
|
||||
Appid: core.String(appID),
|
||||
Mchid: core.String(mchID),
|
||||
Description: core.String(order.Subject),
|
||||
OutTradeNo: core.String(order.PayNo),
|
||||
NotifyUrl: core.String(notifyURL),
|
||||
Amount: amount,
|
||||
}
|
||||
if order.ExpireAt != nil {
|
||||
req.TimeExpire = order.ExpireAt
|
||||
}
|
||||
svc := native.NativeApiService{Client: client}
|
||||
resp, _, err := svc.Prepay(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("微信 Native 下单失败: %w", err)
|
||||
}
|
||||
if resp == nil || resp.CodeUrl == nil {
|
||||
return nil, fmt.Errorf("微信 Native 下单未返回 code_url")
|
||||
}
|
||||
return &PayParams{Channel: ChannelWechat, PayType: PayTypeQR, CodeURL: *resp.CodeUrl}, nil
|
||||
}
|
||||
|
||||
// normalizeWechatState 微信交易状态 -> 归一化状态
|
||||
func normalizeWechatState(state string) string {
|
||||
switch state {
|
||||
case "SUCCESS":
|
||||
return StateSuccess
|
||||
case "NOTPAY", "USERPAYING":
|
||||
return StatePending
|
||||
case "CLOSED", "REVOKED":
|
||||
return StateClosed
|
||||
case "PAYERROR":
|
||||
return StateFailed
|
||||
case "REFUND":
|
||||
return StateRefunded
|
||||
default:
|
||||
return StatePending
|
||||
}
|
||||
}
|
||||
|
||||
func (c *WechatChannel) Query(ctx context.Context, order *models.PlatformPaymentOrder, cfg *ChannelConfig) (*ChannelState, error) {
|
||||
client, mchID, err := c.buildClient(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
url := consts.WechatPayAPIServer + "/v3/pay/transactions/out-trade-no/" + order.PayNo + "?mchid=" + mchID
|
||||
result, err := client.Get(ctx, url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("微信查询订单失败: %w", err)
|
||||
}
|
||||
body, err := io.ReadAll(result.Response.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取微信查询响应失败: %w", err)
|
||||
}
|
||||
tx := &payments.Transaction{}
|
||||
if err := json.Unmarshal(body, tx); err != nil {
|
||||
return nil, fmt.Errorf("解析微信查询响应失败: %w", err)
|
||||
}
|
||||
|
||||
state := &ChannelState{Raw: string(body)}
|
||||
if tx.TransactionId != nil {
|
||||
state.ChannelTradeNo = *tx.TransactionId
|
||||
}
|
||||
state.TradeState = normalizeWechatState(valueOrEmpty(tx.TradeState))
|
||||
if tx.Amount != nil && tx.Amount.Total != nil {
|
||||
state.Amount = *tx.Amount.Total
|
||||
}
|
||||
if tx.SuccessTime != nil {
|
||||
if t, perr := time.Parse(time.RFC3339, *tx.SuccessTime); perr == nil {
|
||||
state.PaidAt = &t
|
||||
}
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (c *WechatChannel) ParseNotify(ctx context.Context, r *http.Request, cfg *ChannelConfig) (*NotifyResult, error) {
|
||||
mchID := cfg.Get("mch_id")
|
||||
apiV3Key := cfg.Get("api_v3_key")
|
||||
|
||||
// 平台证书由 WithWechatPayAutoAuthCipher 注册的下载器维护,直接复用其证书访问器验签
|
||||
visitor := downloader.MgrInstance().GetCertificateVisitor(mchID)
|
||||
handler, err := notify.NewRSANotifyHandler(apiV3Key, verifiers.NewSHA256WithRSAVerifier(visitor))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建微信通知处理器失败: %w", err)
|
||||
}
|
||||
|
||||
tx := &payments.Transaction{}
|
||||
req, err := handler.ParseNotifyRequest(ctx, r, tx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("微信通知验签/解密失败: %w", err)
|
||||
}
|
||||
|
||||
result := &NotifyResult{
|
||||
EventType: req.EventType,
|
||||
EventID: req.ID,
|
||||
AckBody: `{"code":"SUCCESS","message":"成功"}`,
|
||||
Raw: req.Resource.Plaintext,
|
||||
}
|
||||
if tx.OutTradeNo != nil {
|
||||
result.OutTradeNo = *tx.OutTradeNo
|
||||
result.PayNo = *tx.OutTradeNo
|
||||
}
|
||||
if tx.TransactionId != nil {
|
||||
result.ChannelTradeNo = *tx.TransactionId
|
||||
}
|
||||
if tx.Amount != nil && tx.Amount.Total != nil {
|
||||
result.Amount = *tx.Amount.Total
|
||||
}
|
||||
if tx.SuccessTime != nil {
|
||||
if t, perr := time.Parse(time.RFC3339, *tx.SuccessTime); perr == nil {
|
||||
_ = t
|
||||
}
|
||||
}
|
||||
result.TradeState = normalizeWechatState(valueOrEmpty(tx.TradeState))
|
||||
result.Paid = result.TradeState == StateSuccess
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *WechatChannel) Refund(ctx context.Context, order *models.PlatformPaymentOrder, refundNo string, amount int64, reason string, cfg *ChannelConfig) (string, error) {
|
||||
client, _, err := c.buildClient(cfg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
body := map[string]any{
|
||||
"out_trade_no": order.PayNo,
|
||||
"out_refund_no": refundNo,
|
||||
"reason": reason,
|
||||
"notify_url": "", // 退回结果以主动查询为准;如需退回回调,配置后填入
|
||||
"amount": map[string]any{
|
||||
"refund": amount,
|
||||
"total": order.Amount,
|
||||
"currency": "CNY",
|
||||
},
|
||||
}
|
||||
result, err := client.Post(ctx, consts.WechatPayAPIServer+"/v3/refund/domestic/refunds", body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("微信退回请求失败: %w", err)
|
||||
}
|
||||
respBody, _ := io.ReadAll(result.Response.Body)
|
||||
var resp struct {
|
||||
RefundID string `json:"refund_id"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
_ = json.Unmarshal(respBody, &resp)
|
||||
if resp.Status != "" && resp.Status != "SUCCESS" && resp.Status != "PROCESSING" {
|
||||
return resp.RefundID, fmt.Errorf("微信退回未受理,状态: %s", resp.Status)
|
||||
}
|
||||
return resp.RefundID, nil
|
||||
}
|
||||
|
||||
func (c *WechatChannel) TestConnect(ctx context.Context, cfg *ChannelConfig) (string, error) {
|
||||
client, _, err := c.buildClient(cfg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// 平台证书接口只做签名校验,不产生任何交易
|
||||
result, err := client.Get(ctx, consts.WechatPayAPIServer+"/v3/certificates")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("微信凭证校验失败: %w", err)
|
||||
}
|
||||
body, _ := io.ReadAll(result.Response.Body)
|
||||
return fmt.Sprintf("连接成功:商户证书与 APIv3 密钥校验通过(平台证书响应 %d 字节)", len(body)), nil
|
||||
}
|
||||
|
||||
/* ---------------- 小工具 ---------------- */
|
||||
|
||||
func valueOrEmpty(v *string) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return *v
|
||||
}
|
||||
|
||||
// randomNonce 生成 n 字节随机数的十六进制串
|
||||
func randomNonce(n int) (string, error) {
|
||||
buf := make([]byte, n)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(buf), nil
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
beelog "github.com/beego/beego/v2/core/logs"
|
||||
)
|
||||
|
||||
/* =============================================================
|
||||
* 推广佣金:规则匹配 -> 金额计算 -> 佣金单生成(幂等)-> 发放登记
|
||||
* 佣金是平台自有资金支出,与租户收款资金流解耦。
|
||||
* ============================================================= */
|
||||
|
||||
// OnOrderPaid 支付成功后生成佣金单(幂等:同一支付单 + 同一推广方只生成一张)
|
||||
// 无推广方 / 无匹配规则 / 佣金金额为 0 时返回 (nil, nil)。
|
||||
func OnOrderPaid(ctx context.Context, order *models.PlatformPaymentOrder) (*models.PlatformCommissionOrder, error) {
|
||||
if order == nil || order.PromoterID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// 幂等查重
|
||||
exist := &models.PlatformCommissionOrder{}
|
||||
err := models.Orm.QueryTable(new(models.PlatformCommissionOrder)).
|
||||
Filter("pay_no", order.PayNo).
|
||||
Filter("promoter_id", order.PromoterID).
|
||||
Filter("delete_time__isnull", true).
|
||||
One(exist)
|
||||
if err == nil {
|
||||
return exist, nil
|
||||
} else if err != orm.ErrNoRows {
|
||||
return nil, fmt.Errorf("查询佣金单失败: %w", err)
|
||||
}
|
||||
|
||||
rule, err := matchCommissionRule(order.OrderType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rule == nil {
|
||||
return nil, nil
|
||||
}
|
||||
amount := calcCommissionAmount(rule, order.Amount)
|
||||
if amount <= 0 {
|
||||
beelog.Warn("佣金规则 %s 计算金额为 0,跳过(订单 %s)", rule.RuleName, order.PayNo)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
row := &models.PlatformCommissionOrder{
|
||||
CommissionNo: NextCommissionNo(),
|
||||
PayNo: order.PayNo,
|
||||
OutTradeNo: order.OutTradeNo,
|
||||
RuleID: rule.ID,
|
||||
RuleName: rule.RuleName,
|
||||
CalcType: rule.CalcType,
|
||||
PromoterID: order.PromoterID,
|
||||
PromoterName: order.PromoterName,
|
||||
PromoterType: order.PromoterType,
|
||||
BaseAmount: order.Amount,
|
||||
Amount: amount,
|
||||
Status: models.CommissionStatusPayable,
|
||||
Remark: fmt.Sprintf("支付单 %s 支付成功自动生成", order.PayNo),
|
||||
}
|
||||
if _, err := models.Orm.Insert(row); err != nil {
|
||||
if strings.Contains(err.Error(), "Duplicate") || strings.Contains(err.Error(), "1062") {
|
||||
// 并发生成命中唯一键:查回已有记录
|
||||
return exist, nil
|
||||
}
|
||||
return nil, fmt.Errorf("创建佣金单失败: %w", err)
|
||||
}
|
||||
beelog.Info("佣金单已生成: %s 支付单 %s 推广方 %s 金额 %d 分", row.CommissionNo, order.PayNo, order.PromoterName, amount)
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// matchCommissionRule 匹配启用中的佣金规则(同类型规则取最新一条)
|
||||
func matchCommissionRule(orderType string) (*models.PlatformCommissionRule, error) {
|
||||
var rules []models.PlatformCommissionRule
|
||||
_, err := models.Orm.QueryTable(new(models.PlatformCommissionRule)).
|
||||
Filter("status", 1).
|
||||
Filter("delete_time__isnull", true).
|
||||
OrderBy("-id").
|
||||
All(&rules)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range rules {
|
||||
if rules[i].OrderType == models.CommissionOrderTypeAll || rules[i].OrderType == orderType {
|
||||
return &rules[i], nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// calcCommissionAmount 按规则计算佣金(分),向下取整
|
||||
func calcCommissionAmount(rule *models.PlatformCommissionRule, baseAmount int64) int64 {
|
||||
if rule == nil || baseAmount <= 0 {
|
||||
return 0
|
||||
}
|
||||
switch rule.CalcType {
|
||||
case models.CommissionCalcPercent:
|
||||
return baseAmount * rule.Value / 10000 // value = 百分比 × 100
|
||||
case models.CommissionCalcFixed:
|
||||
return rule.Value
|
||||
case models.CommissionCalcTiered:
|
||||
for _, tier := range parseTiers(rule.TierJSON) {
|
||||
if baseAmount < tier.Min {
|
||||
continue
|
||||
}
|
||||
if tier.Max != nil && baseAmount >= *tier.Max {
|
||||
continue
|
||||
}
|
||||
return baseAmount * tier.Rate / 10000
|
||||
}
|
||||
return 0
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func parseTiers(raw *string) []models.CommissionTier {
|
||||
tiers := []models.CommissionTier{}
|
||||
if raw == nil || *raw == "" {
|
||||
return tiers
|
||||
}
|
||||
_ = json.Unmarshal([]byte(*raw), &tiers)
|
||||
return tiers
|
||||
}
|
||||
|
||||
/* ---------------- 佣金台账查询与发放 ---------------- */
|
||||
|
||||
// CommissionListInput 台账查询条件
|
||||
type CommissionListInput struct {
|
||||
Promoter string
|
||||
Status string
|
||||
StartTime string // YYYY-MM-DD
|
||||
EndTime string
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
// ListCommissions 佣金台账分页
|
||||
func ListCommissions(in CommissionListInput) ([]models.PlatformCommissionOrder, int64, error) {
|
||||
qs := models.Orm.QueryTable(new(models.PlatformCommissionOrder)).Filter("delete_time__isnull", true)
|
||||
if in.Promoter != "" {
|
||||
qs = qs.Filter("promoter_name__icontains", in.Promoter)
|
||||
}
|
||||
if in.Status != "" {
|
||||
qs = qs.Filter("status", in.Status)
|
||||
}
|
||||
if in.StartTime != "" {
|
||||
if t, err := time.ParseInLocation("2006-01-02", in.StartTime, time.Local); err == nil {
|
||||
qs = qs.Filter("create_time__gte", t)
|
||||
}
|
||||
}
|
||||
if in.EndTime != "" {
|
||||
if t, err := time.ParseInLocation("2006-01-02", in.EndTime, time.Local); err == nil {
|
||||
qs = qs.Filter("create_time__lt", t.AddDate(0, 0, 1))
|
||||
}
|
||||
}
|
||||
total, err := qs.Count()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if in.Page <= 0 {
|
||||
in.Page = 1
|
||||
}
|
||||
if in.PageSize <= 0 || in.PageSize > 200 {
|
||||
in.PageSize = 10
|
||||
}
|
||||
var rows []models.PlatformCommissionOrder
|
||||
_, err = qs.OrderBy("-id").Limit(in.PageSize, (in.Page-1)*in.PageSize).All(&rows)
|
||||
return rows, total, err
|
||||
}
|
||||
|
||||
// CommissionStats 台账统计(应付 / 已付 / 待处理笔数)
|
||||
type CommissionStats struct {
|
||||
PayableAmount int64 `json:"payable_amount"`
|
||||
PaidAmount int64 `json:"paid_amount"`
|
||||
PendingCount int64 `json:"pending_count"`
|
||||
}
|
||||
|
||||
// GetCommissionStats 统计口径:
|
||||
//
|
||||
// 应付总额 = status=payable 的金额合计;已付总额 = status=settled;待处理笔数 = payable + tax_processing。
|
||||
func GetCommissionStats() (*CommissionStats, error) {
|
||||
type row struct {
|
||||
Total int64 `orm:"column(total)"`
|
||||
Cnt int64 `orm:"column(cnt)"`
|
||||
}
|
||||
stats := &CommissionStats{}
|
||||
pay := row{}
|
||||
if err := models.Orm.Raw(
|
||||
"SELECT COALESCE(SUM(amount),0) AS total, COUNT(*) AS cnt FROM yz_platform_commission_order WHERE delete_time IS NULL AND status = ?",
|
||||
models.CommissionStatusPayable).QueryRow(&pay); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats.PayableAmount = pay.Total
|
||||
stats.PendingCount = pay.Cnt
|
||||
|
||||
paid := row{}
|
||||
if err := models.Orm.Raw(
|
||||
"SELECT COALESCE(SUM(amount),0) AS total, COUNT(*) AS cnt FROM yz_platform_commission_order WHERE delete_time IS NULL AND status = ?",
|
||||
models.CommissionStatusSettled).QueryRow(&paid); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats.PaidAmount = paid.Total
|
||||
|
||||
tax := row{}
|
||||
if err := models.Orm.Raw(
|
||||
"SELECT COALESCE(SUM(amount),0) AS total, COUNT(*) AS cnt FROM yz_platform_commission_order WHERE delete_time IS NULL AND status = ?",
|
||||
models.CommissionStatusTaxProcessing).QueryRow(&tax); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats.PendingCount += tax.Cnt
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// MarkCommissionPaidInput 标记已付入参
|
||||
type MarkCommissionPaidInput struct {
|
||||
ID uint64
|
||||
PayMethod string
|
||||
PayVoucher string
|
||||
Remark string
|
||||
OperatorID string
|
||||
OperatorName string
|
||||
}
|
||||
|
||||
// MarkCommissionPaid 发放登记:应付/税务处理中 -> 已付
|
||||
func MarkCommissionPaid(ctx context.Context, in MarkCommissionPaidInput) (*models.PlatformCommissionOrder, error) {
|
||||
row := &models.PlatformCommissionOrder{}
|
||||
if err := models.Orm.QueryTable(new(models.PlatformCommissionOrder)).
|
||||
Filter("id", in.ID).
|
||||
Filter("delete_time__isnull", true).
|
||||
One(row); err != nil {
|
||||
return nil, fmt.Errorf("佣金单不存在")
|
||||
}
|
||||
if row.Status == models.CommissionStatusSettled {
|
||||
return nil, errors.New("该佣金单已是已付状态")
|
||||
}
|
||||
now := time.Now()
|
||||
_, err := models.Orm.QueryTable(new(models.PlatformCommissionOrder)).
|
||||
Filter("id", row.ID).
|
||||
Update(orm.Params{
|
||||
"status": models.CommissionStatusSettled,
|
||||
"pay_method": in.PayMethod,
|
||||
"pay_voucher": in.PayVoucher,
|
||||
"paid_at": now,
|
||||
"paid_user_id": in.OperatorID,
|
||||
"paid_user_name": in.OperatorName,
|
||||
"remark": in.Remark,
|
||||
"update_time": now,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return GetCommissionByID(in.ID)
|
||||
}
|
||||
|
||||
// GetCommissionByID 按 ID 查询佣金单
|
||||
func GetCommissionByID(id uint64) (*models.PlatformCommissionOrder, error) {
|
||||
row := &models.PlatformCommissionOrder{}
|
||||
err := models.Orm.QueryTable(new(models.PlatformCommissionOrder)).
|
||||
Filter("id", id).
|
||||
Filter("delete_time__isnull", true).
|
||||
One(row)
|
||||
return row, err
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
)
|
||||
|
||||
// =============================================================
|
||||
// 渠道敏感参数的对称加密与掩码
|
||||
//
|
||||
// yz_platform_payment_channel.config_json 中的密钥/私钥/Secret 以 AES-256-GCM 加密后存储,
|
||||
// 格式:base64( "YZP1:" + nonce + ciphertext )。
|
||||
// 密钥来源:app.conf 的 payment_secret_key(任意长度字符串,内部做 SHA-256 派生);
|
||||
// 未配置时退回内置兜底密钥(保证开箱可用),上线前务必在 app.conf 配置该值,
|
||||
// 且注意:更换密钥后,历史密文将无法解密,需要重新保存各渠道配置。
|
||||
// =============================================================
|
||||
|
||||
const configCipherPrefix = "YZP1:"
|
||||
|
||||
// fallbackSecretKey 内置兜底密钥,仅用于开发/未配置场景
|
||||
const fallbackSecretKey = "yunzer_payment_default_secret_key_v1"
|
||||
|
||||
func configKey() []byte {
|
||||
raw, _ := beego.AppConfig.String("payment_secret_key")
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
raw = fallbackSecretKey
|
||||
}
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
return sum[:]
|
||||
}
|
||||
|
||||
// EncryptConfig 加密渠道参数 JSON
|
||||
func EncryptConfig(plain string) (string, error) {
|
||||
if plain == "" {
|
||||
return "", nil
|
||||
}
|
||||
block, err := aes.NewCipher(configKey())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
ciphertext := gcm.Seal(nil, nonce, []byte(plain), nil)
|
||||
buf := append([]byte(configCipherPrefix), nonce...)
|
||||
buf = append(buf, ciphertext...)
|
||||
return base64.StdEncoding.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
// DecryptConfig 解密渠道参数 JSON;空串原样返回
|
||||
func DecryptConfig(encoded string) (string, error) {
|
||||
if encoded == "" {
|
||||
return "", nil
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return "", errors.New("渠道参数不是有效的 base64")
|
||||
}
|
||||
if len(raw) < len(configCipherPrefix) || string(raw[:len(configCipherPrefix)]) != configCipherPrefix {
|
||||
return "", errors.New("渠道参数格式不正确(缺少加密前缀)")
|
||||
}
|
||||
block, err := aes.NewCipher(configKey())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
nonce := raw[len(configCipherPrefix) : len(configCipherPrefix)+gcm.NonceSize()]
|
||||
ciphertext := raw[len(configCipherPrefix)+gcm.NonceSize():]
|
||||
plain, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return "", errors.New("渠道参数解密失败(payment_secret_key 是否被更换过?)")
|
||||
}
|
||||
return string(plain), nil
|
||||
}
|
||||
|
||||
// MaskSecret 生成掩码:保留末 4 位
|
||||
func MaskSecret(v string) string {
|
||||
if v == "" {
|
||||
return ""
|
||||
}
|
||||
if len(v) <= 4 {
|
||||
return "******"
|
||||
}
|
||||
return "******" + v[len(v)-4:]
|
||||
}
|
||||
|
||||
// IsMasked 判断前端回传的值是否仍是掩码(表示「不修改原值」)
|
||||
func IsMasked(v string) bool {
|
||||
return strings.HasPrefix(v, "******")
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
/* ---------------- 金额工具 ---------------- */
|
||||
|
||||
// FenToYuan 分 -> 元(两位小数字符串),供渠道下单参数使用
|
||||
func FenToYuan(fen int64) string {
|
||||
if fen < 0 {
|
||||
fen = 0
|
||||
}
|
||||
return strconv.FormatInt(fen/100, 10) + "." + fmt.Sprintf("%02d", fen%100)
|
||||
}
|
||||
|
||||
// YuanToFen 元(字符串)-> 分
|
||||
func YuanToFen(yuan string) (int64, error) {
|
||||
yuan = strings.TrimSpace(yuan)
|
||||
if yuan == "" {
|
||||
return 0, nil
|
||||
}
|
||||
f, err := strconv.ParseFloat(yuan, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("金额格式不正确: %s", yuan)
|
||||
}
|
||||
if f < 0 {
|
||||
return 0, fmt.Errorf("金额不能为负数: %s", yuan)
|
||||
}
|
||||
return int64(f*100 + 0.5), nil
|
||||
}
|
||||
|
||||
/* ---------------- 单号生成 ----------------
|
||||
|
||||
规则:前缀 + yyyyMMddHHmmss + 4 位随机数字(进程内串行生成,避免同秒碰撞;
|
||||
落库仍有唯一键兜底,撞号时由调用方重试一次)。
|
||||
支付单 P202609151024310001
|
||||
退回单 R202609151024310001
|
||||
佣金单 CM202609151024310001
|
||||
对账批次 RC202609151024310001
|
||||
---------------------------------------- */
|
||||
|
||||
var (
|
||||
noMu sync.Mutex
|
||||
lastNoTime int64
|
||||
lastNoSeq int64
|
||||
)
|
||||
|
||||
func nextSerial(prefix string) string {
|
||||
noMu.Lock()
|
||||
defer noMu.Unlock()
|
||||
|
||||
now := time.Now().Unix()
|
||||
seq := int64(0)
|
||||
if now == lastNoTime {
|
||||
seq = lastNoSeq + 1
|
||||
}
|
||||
lastNoTime = now
|
||||
lastNoSeq = seq
|
||||
|
||||
n, _ := rand.Int(rand.Reader, big.NewInt(10000))
|
||||
return fmt.Sprintf("%s%s%04d%04d", prefix, time.Now().Format("20060102150405"), seq, n.Int64())
|
||||
}
|
||||
|
||||
// NextPayNo 生成支付单号
|
||||
func NextPayNo() string { return nextSerial("P") }
|
||||
|
||||
// NextRefundNo 生成退回单号
|
||||
func NextRefundNo() string { return nextSerial("R") }
|
||||
|
||||
// NextCommissionNo 生成佣金单号
|
||||
func NextCommissionNo() string { return nextSerial("CM") }
|
||||
|
||||
// NextBatchNo 生成对账批次号
|
||||
func NextBatchNo() string { return nextSerial("RC") }
|
||||
@@ -0,0 +1,367 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
beelog "github.com/beego/beego/v2/core/logs"
|
||||
)
|
||||
|
||||
/* =============================================================
|
||||
* 日终对账:渠道账单导入 -> 与本地支付流水比对 -> 产出差异 -> 人工标记处理
|
||||
* 差异类型:long 长款(渠道有本地无)/ short 短款(本地有渠道无)/ status_mismatch 状态不一致
|
||||
* ============================================================= */
|
||||
|
||||
// BillRow 渠道账单明细行(导入用)
|
||||
type BillRow struct {
|
||||
ChannelTradeNo string // 渠道流水号
|
||||
OutTradeNo string // 渠道侧商户订单号(下单时传的 pay_no)
|
||||
Amount int64 // 渠道金额(分)
|
||||
Fee int64 // 渠道手续费(分)
|
||||
TradeState string // 渠道侧交易状态
|
||||
TradeTime *time.Time // 渠道交易时间
|
||||
}
|
||||
|
||||
// ImportChannelBill 导入某渠道某日的账单明细(幂等:按 channel + channel_trade_no 更新或插入)
|
||||
func ImportChannelBill(ctx context.Context, channel, billDate string, rows []BillRow) (int, error) {
|
||||
day, err := time.ParseInLocation("2006-01-02", billDate, time.Local)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("对账日期格式应为 YYYY-MM-DD")
|
||||
}
|
||||
imported := 0
|
||||
for _, r := range rows {
|
||||
if strings.TrimSpace(r.ChannelTradeNo) == "" {
|
||||
continue
|
||||
}
|
||||
exist := &models.PlatformPaymentChannelBill{}
|
||||
err := models.Orm.QueryTable(new(models.PlatformPaymentChannelBill)).
|
||||
Filter("channel", channel).
|
||||
Filter("channel_trade_no", r.ChannelTradeNo).
|
||||
Filter("delete_time__isnull", true).
|
||||
One(exist)
|
||||
if err == nil {
|
||||
_, _ = models.Orm.QueryTable(new(models.PlatformPaymentChannelBill)).
|
||||
Filter("id", exist.ID).
|
||||
Update(orm.Params{
|
||||
"amount": r.Amount, "fee": r.Fee, "trade_state": r.TradeState,
|
||||
"update_time": time.Now(),
|
||||
})
|
||||
imported++
|
||||
continue
|
||||
}
|
||||
row := &models.PlatformPaymentChannelBill{
|
||||
Channel: channel,
|
||||
BillDate: day,
|
||||
ChannelTradeNo: r.ChannelTradeNo,
|
||||
OutTradeNo: r.OutTradeNo,
|
||||
Amount: r.Amount,
|
||||
Fee: r.Fee,
|
||||
TradeState: r.TradeState,
|
||||
TradeTime: r.TradeTime,
|
||||
}
|
||||
if _, err := models.Orm.Insert(row); err == nil {
|
||||
imported++
|
||||
}
|
||||
}
|
||||
return imported, nil
|
||||
}
|
||||
|
||||
// ParseChannelBillCSV 解析账单 CSV。
|
||||
// 约定列:channel_trade_no, out_trade_no, amount(元), fee(元), trade_state, trade_time
|
||||
// TODO(渠道): 各渠道账单文件列名/编码不同(微信为制表符分隔、支付宝为 GBK CSV),
|
||||
// 对接时按渠道导出文件写各自的解析器,统一转成 BillRow。
|
||||
func ParseChannelBillCSV(data []byte) ([]BillRow, error) {
|
||||
reader := csv.NewReader(strings.NewReader(string(data)))
|
||||
reader.TrimLeadingSpace = true
|
||||
records, err := reader.ReadAll()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows := make([]BillRow, 0, len(records))
|
||||
for i, rec := range records {
|
||||
if i == 0 || len(rec) == 0 {
|
||||
continue
|
||||
}
|
||||
get := func(idx int) string {
|
||||
if idx < len(rec) {
|
||||
return strings.TrimSpace(rec[idx])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
row := BillRow{
|
||||
ChannelTradeNo: get(0),
|
||||
OutTradeNo: get(1),
|
||||
TradeState: get(4),
|
||||
}
|
||||
if row.ChannelTradeNo == "" {
|
||||
continue
|
||||
}
|
||||
if fen, err := YuanToFen(get(2)); err == nil {
|
||||
row.Amount = fen
|
||||
}
|
||||
if fen, err := YuanToFen(get(3)); err == nil {
|
||||
row.Fee = fen
|
||||
}
|
||||
if t := get(5); t != "" {
|
||||
for _, layout := range []string{"2006-01-02 15:04:05", time.RFC3339, "2006-01-02"} {
|
||||
if tt, err := time.ParseInLocation(layout, t, time.Local); err == nil {
|
||||
row.TradeTime = &tt
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// ReconcileSummary 一次对账的执行结果
|
||||
type ReconcileSummary struct {
|
||||
BatchNo string `json:"batch_no"`
|
||||
Channel string `json:"channel"`
|
||||
BillDate string `json:"bill_date"`
|
||||
BillCount int `json:"bill_count"`
|
||||
Matched int `json:"matched"`
|
||||
LongCount int `json:"long_count"`
|
||||
ShortCount int `json:"short_count"`
|
||||
MismatchCount int `json:"mismatch_count"`
|
||||
}
|
||||
|
||||
// RunReconcile 对某渠道某日的账单与本地支付流水做比对,产出差异(幂等:按唯一键更新已有差异)。
|
||||
// 匹配策略:渠道账单的商户订单号即本地下单时传的 pay_no,优先按 pay_no 匹配,其次按 out_trade_no;
|
||||
// 本地侧只统计支付成功日(paid_at)落在对账日内的支付单。
|
||||
func RunReconcile(ctx context.Context, channel, billDate string) (*ReconcileSummary, error) {
|
||||
day, err := time.ParseInLocation("2006-01-02", billDate, time.Local)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("对账日期格式应为 YYYY-MM-DD")
|
||||
}
|
||||
if _, err := LoadChannelConfig(channel); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var bills []models.PlatformPaymentChannelBill
|
||||
_, err = models.Orm.QueryTable(new(models.PlatformPaymentChannelBill)).
|
||||
Filter("channel", channel).
|
||||
Filter("bill_date", day).
|
||||
Filter("delete_time__isnull", true).
|
||||
All(&bills)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var locals []models.PlatformPaymentOrder
|
||||
_, err = models.Orm.QueryTable(new(models.PlatformPaymentOrder)).
|
||||
Filter("delete_time__isnull", true).
|
||||
Filter("status", models.PayStatusPaid).
|
||||
Filter("paid_at__gte", day).
|
||||
Filter("paid_at__lt", day.AddDate(0, 0, 1)).
|
||||
All(&locals)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
localByPayNo := map[string]*models.PlatformPaymentOrder{}
|
||||
localByOutTrade := map[string]*models.PlatformPaymentOrder{}
|
||||
for i := range locals {
|
||||
localByPayNo[locals[i].PayNo] = &locals[i]
|
||||
localByOutTrade[locals[i].OutTradeNo] = &locals[i]
|
||||
}
|
||||
|
||||
batchNo := NextBatchNo()
|
||||
summary := &ReconcileSummary{BatchNo: batchNo, Channel: channel, BillDate: billDate, BillCount: len(bills)}
|
||||
matchedPayNos := map[string]bool{}
|
||||
|
||||
for i := range bills {
|
||||
bill := bills[i]
|
||||
local := localByPayNo[bill.OutTradeNo]
|
||||
if local == nil {
|
||||
local = localByOutTrade[bill.OutTradeNo]
|
||||
}
|
||||
|
||||
if local == nil {
|
||||
summary.LongCount++
|
||||
_ = upsertDiff(channel, day, batchNo, "", bill.ChannelTradeNo,
|
||||
models.DiffTypeLong, nil, &bill.Amount, "渠道账单存在该流水,本地无对应支付单")
|
||||
continue
|
||||
}
|
||||
|
||||
if local.Status == models.PayStatusPaid && bill.Amount == local.Amount {
|
||||
summary.Matched++
|
||||
matchedPayNos[local.PayNo] = true
|
||||
_, _ = models.Orm.QueryTable(new(models.PlatformPaymentChannelBill)).
|
||||
Filter("id", bill.ID).
|
||||
Update(orm.Params{"is_matched": 1, "matched_pay_no": local.PayNo, "update_time": time.Now()})
|
||||
continue
|
||||
}
|
||||
|
||||
summary.MismatchCount++
|
||||
reason := "本地与渠道金额不一致"
|
||||
if bill.Amount == local.Amount {
|
||||
reason = fmt.Sprintf("渠道已成功但本地状态为 %s", local.Status)
|
||||
}
|
||||
_ = upsertDiff(channel, day, batchNo, local.PayNo, bill.ChannelTradeNo,
|
||||
models.DiffTypeStatusMismatch, &local.Amount, &bill.Amount, reason)
|
||||
}
|
||||
|
||||
for i := range locals {
|
||||
if matchedPayNos[locals[i].PayNo] {
|
||||
continue
|
||||
}
|
||||
summary.ShortCount++
|
||||
_ = upsertDiff(channel, day, batchNo, locals[i].PayNo, "",
|
||||
models.DiffTypeShort, &locals[i].Amount, nil, "本地已支付,渠道账单无对应流水")
|
||||
}
|
||||
|
||||
beelog.Info("对账完成: 渠道=%s 日期=%s 账单 %d 笔,匹配 %d,长款 %d,短款 %d,不一致 %d(批次 %s)",
|
||||
channel, billDate, summary.BillCount, summary.Matched, summary.LongCount, summary.ShortCount, summary.MismatchCount, batchNo)
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
// upsertDiff 写入/更新差异记录(幂等:唯一键 channel+bill_date+diff_type+channel_trade_no+local_trade_no)
|
||||
func upsertDiff(channel string, day time.Time, batchNo, localTradeNo, channelTradeNo, diffType string, localAmount, channelAmount *int64, remark string) error {
|
||||
exist := &models.PlatformPaymentReconcileDiff{}
|
||||
err := models.Orm.QueryTable(new(models.PlatformPaymentReconcileDiff)).
|
||||
Filter("channel", channel).
|
||||
Filter("bill_date", day).
|
||||
Filter("diff_type", diffType).
|
||||
Filter("channel_trade_no", channelTradeNo).
|
||||
Filter("local_trade_no", localTradeNo).
|
||||
Filter("delete_time__isnull", true).
|
||||
One(exist)
|
||||
|
||||
diffAmount := int64(0)
|
||||
switch {
|
||||
case channelAmount != nil && localAmount != nil:
|
||||
diffAmount = *channelAmount - *localAmount
|
||||
case channelAmount != nil:
|
||||
diffAmount = *channelAmount
|
||||
case localAmount != nil:
|
||||
diffAmount = -*localAmount
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
_, uerr := models.Orm.QueryTable(new(models.PlatformPaymentReconcileDiff)).
|
||||
Filter("id", exist.ID).
|
||||
Update(orm.Params{
|
||||
"batch_no": batchNo,
|
||||
"local_amount": localAmount,
|
||||
"channel_amount": channelAmount,
|
||||
"diff_amount": diffAmount,
|
||||
"update_time": time.Now(),
|
||||
})
|
||||
return uerr
|
||||
}
|
||||
|
||||
row := &models.PlatformPaymentReconcileDiff{
|
||||
Channel: channel,
|
||||
BillDate: day,
|
||||
BatchNo: batchNo,
|
||||
LocalTradeNo: localTradeNo,
|
||||
ChannelTradeNo: channelTradeNo,
|
||||
LocalAmount: localAmount,
|
||||
ChannelAmount: channelAmount,
|
||||
DiffAmount: diffAmount,
|
||||
DiffType: diffType,
|
||||
HandleStatus: models.DiffHandleUnhandled,
|
||||
Remark: remark,
|
||||
}
|
||||
_, err = models.Orm.Insert(row)
|
||||
return err
|
||||
}
|
||||
|
||||
// ReconcileListInput 差异列表查询条件
|
||||
type ReconcileListInput struct {
|
||||
Channel string
|
||||
BillDate string
|
||||
DiffType string
|
||||
HandleStatus string
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
// ListReconcileDiffs 对账差异分页
|
||||
func ListReconcileDiffs(in ReconcileListInput) ([]models.PlatformPaymentReconcileDiff, int64, error) {
|
||||
qs := models.Orm.QueryTable(new(models.PlatformPaymentReconcileDiff)).Filter("delete_time__isnull", true)
|
||||
if in.Channel != "" {
|
||||
qs = qs.Filter("channel", in.Channel)
|
||||
}
|
||||
if in.BillDate != "" {
|
||||
if t, err := time.ParseInLocation("2006-01-02", in.BillDate, time.Local); err == nil {
|
||||
qs = qs.Filter("bill_date", t)
|
||||
}
|
||||
}
|
||||
if in.DiffType != "" {
|
||||
qs = qs.Filter("diff_type", in.DiffType)
|
||||
}
|
||||
if in.HandleStatus != "" {
|
||||
qs = qs.Filter("handle_status", in.HandleStatus)
|
||||
}
|
||||
total, err := qs.Count()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if in.Page <= 0 {
|
||||
in.Page = 1
|
||||
}
|
||||
if in.PageSize <= 0 || in.PageSize > 200 {
|
||||
in.PageSize = 10
|
||||
}
|
||||
var rows []models.PlatformPaymentReconcileDiff
|
||||
_, err = qs.OrderBy("-id").Limit(in.PageSize, (in.Page-1)*in.PageSize).All(&rows)
|
||||
return rows, total, err
|
||||
}
|
||||
|
||||
// MarkDiffHandledInput 标记差异处理入参
|
||||
type MarkDiffHandledInput struct {
|
||||
ID uint64
|
||||
HandleStatus string // handled / handling / ignored
|
||||
Remark string
|
||||
OperatorID string
|
||||
OperatorName string
|
||||
}
|
||||
|
||||
// MarkDiffHandled 标记对账差异处理状态
|
||||
func MarkDiffHandled(ctx context.Context, in MarkDiffHandledInput) (*models.PlatformPaymentReconcileDiff, error) {
|
||||
row := &models.PlatformPaymentReconcileDiff{}
|
||||
if err := models.Orm.QueryTable(new(models.PlatformPaymentReconcileDiff)).
|
||||
Filter("id", in.ID).
|
||||
Filter("delete_time__isnull", true).
|
||||
One(row); err != nil {
|
||||
return nil, fmt.Errorf("差异记录不存在")
|
||||
}
|
||||
switch in.HandleStatus {
|
||||
case models.DiffHandleHandled, models.DiffHandleHandling, models.DiffHandleIgnored:
|
||||
default:
|
||||
return nil, fmt.Errorf("处理状态不合法")
|
||||
}
|
||||
now := time.Now()
|
||||
_, err := models.Orm.QueryTable(new(models.PlatformPaymentReconcileDiff)).
|
||||
Filter("id", row.ID).
|
||||
Update(orm.Params{
|
||||
"handle_status": in.HandleStatus,
|
||||
"remark": in.Remark,
|
||||
"handle_user_id": in.OperatorID,
|
||||
"handle_user_name": in.OperatorName,
|
||||
"handle_time": now,
|
||||
"update_time": now,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return GetReconcileDiffByID(in.ID)
|
||||
}
|
||||
|
||||
// GetReconcileDiffByID 按 ID 查询差异记录
|
||||
func GetReconcileDiffByID(id uint64) (*models.PlatformPaymentReconcileDiff, error) {
|
||||
row := &models.PlatformPaymentReconcileDiff{}
|
||||
err := models.Orm.QueryTable(new(models.PlatformPaymentReconcileDiff)).
|
||||
Filter("id", id).
|
||||
Filter("delete_time__isnull", true).
|
||||
One(row)
|
||||
return row, err
|
||||
}
|
||||
@@ -0,0 +1,613 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
beelog "github.com/beego/beego/v2/core/logs"
|
||||
)
|
||||
|
||||
/* =============================================================
|
||||
* 支付服务核心:创建支付单 / 查询 / 状态机 / 回调幂等 / 兜底退回 / 补偿任务
|
||||
*
|
||||
* 与业务模块的解耦点:
|
||||
* 1. 业务订单的「已支付」同步通过 RegisterOrderSyncHook 注册钩子完成;
|
||||
* 未注册钩子时视为无需同步(order_synced 直接置 1)。
|
||||
* 2. 佣金推广方:下单入参可携带(快照到支付单),未携带时由
|
||||
* RegisterPromoterResolver 注册的钩子推导(如按租户归属的渠道伙伴)。
|
||||
* ============================================================= */
|
||||
|
||||
// OrderSyncFunc 业务订单状态同步钩子
|
||||
type OrderSyncFunc func(ctx context.Context, order *models.PlatformPaymentOrder) error
|
||||
|
||||
// PromoterResolveFunc 推广方解析钩子
|
||||
type PromoterResolveFunc func(ctx context.Context, order *models.PlatformPaymentOrder) (promoterID, promoterName, promoterType string, ok bool)
|
||||
|
||||
var (
|
||||
orderSyncHook OrderSyncFunc
|
||||
promoterResolve PromoterResolveFunc
|
||||
)
|
||||
|
||||
// RegisterOrderSyncHook 注册业务订单同步钩子(在业务模块 init 时调用)
|
||||
func RegisterOrderSyncHook(fn OrderSyncFunc) { orderSyncHook = fn }
|
||||
|
||||
// RegisterPromoterResolver 注册推广方解析钩子
|
||||
func RegisterPromoterResolver(fn PromoterResolveFunc) { promoterResolve = fn }
|
||||
|
||||
// CreateInput 创建支付单入参(租户端下单页 -> POST /backend/payment/create)
|
||||
type CreateInput struct {
|
||||
OutTradeNo string // 业务订单号(必填)
|
||||
OrderType string // platform_usage / module_shop / service_fee
|
||||
TenantID string
|
||||
TenantName string
|
||||
Amount int64 // 分
|
||||
Channel string // 渠道标识(必填)
|
||||
Subject string
|
||||
ReturnURL string // 支付完成同步跳回地址
|
||||
ClientIP string
|
||||
PayType string // qr / web / h5 / jsapi;空值由适配器给默认
|
||||
OpenID string // JSAPI 必填
|
||||
PromoterID string
|
||||
PromoterName string
|
||||
PromoterType string
|
||||
ExpireMinutes int // 支付有效期(分钟),默认 30
|
||||
}
|
||||
|
||||
const defaultExpireMinutes = 30
|
||||
|
||||
// GetOrderByPayNo 按支付单号查询
|
||||
func GetOrderByPayNo(payNo string) (*models.PlatformPaymentOrder, error) {
|
||||
row := &models.PlatformPaymentOrder{}
|
||||
err := models.Orm.QueryTable(new(models.PlatformPaymentOrder)).
|
||||
Filter("pay_no", payNo).
|
||||
Filter("delete_time__isnull", true).
|
||||
One(row)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// LogStateChange 记录状态流转流水
|
||||
func LogStateChange(payNo, fromStatus, toStatus, operator, remark string) {
|
||||
row := &models.PlatformPaymentOrderLog{
|
||||
PayNo: payNo,
|
||||
FromStatus: fromStatus,
|
||||
ToStatus: toStatus,
|
||||
Operator: operator,
|
||||
Remark: remark,
|
||||
}
|
||||
if _, err := models.Orm.Insert(row); err != nil {
|
||||
beelog.Warn("支付单流转日志写入失败: %s %v", payNo, err)
|
||||
}
|
||||
}
|
||||
|
||||
// CreatePayment 创建支付单并调用渠道下单
|
||||
// 幂等:同一 out_trade_no + channel 存在未终态支付单时,直接复用并重新拉起支付参数。
|
||||
func CreatePayment(ctx context.Context, in CreateInput) (*models.PlatformPaymentOrder, *PayParams, error) {
|
||||
if strings.TrimSpace(in.OutTradeNo) == "" {
|
||||
return nil, nil, errors.New("业务订单号不能为空")
|
||||
}
|
||||
if in.Amount <= 0 {
|
||||
return nil, nil, errors.New("支付金额必须大于 0")
|
||||
}
|
||||
adapter, err := GetChannelAdapter(in.Channel)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
cfg, err := LoadEnabledChannelConfig(in.Channel)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// 幂等:复用未终态支付单
|
||||
var exist models.PlatformPaymentOrder
|
||||
err = models.Orm.QueryTable(new(models.PlatformPaymentOrder)).
|
||||
Filter("out_trade_no", in.OutTradeNo).
|
||||
Filter("channel", in.Channel).
|
||||
Filter("delete_time__isnull", true).
|
||||
Filter("status__in", models.PayStatusCreated, models.PayStatusPending, models.PayStatusPaying).
|
||||
One(&exist)
|
||||
if err == nil {
|
||||
params, perr := adapter.Prepay(ctx, &exist, cfg, PrepayOption{PayType: in.PayType, OpenID: in.OpenID})
|
||||
if perr != nil {
|
||||
return nil, nil, perr
|
||||
}
|
||||
return &exist, params, nil
|
||||
} else if err != orm.ErrNoRows {
|
||||
return nil, nil, fmt.Errorf("查询支付单失败: %w", err)
|
||||
}
|
||||
|
||||
payNo := NextPayNo()
|
||||
now := time.Now()
|
||||
minutes := in.ExpireMinutes
|
||||
if minutes <= 0 {
|
||||
minutes = defaultExpireMinutes
|
||||
}
|
||||
expireAt := now.Add(time.Duration(minutes) * time.Minute)
|
||||
|
||||
order := &models.PlatformPaymentOrder{
|
||||
PayNo: payNo,
|
||||
OutTradeNo: in.OutTradeNo,
|
||||
OrderType: in.OrderType,
|
||||
Subject: in.Subject,
|
||||
TenantID: in.TenantID,
|
||||
TenantName: in.TenantName,
|
||||
Amount: in.Amount,
|
||||
OrderAmount: in.Amount,
|
||||
Currency: "CNY",
|
||||
Channel: in.Channel,
|
||||
MerchantNo: cfg.MerchantNo,
|
||||
Status: models.PayStatusCreated,
|
||||
ClientIP: in.ClientIP,
|
||||
ReturnURL: in.ReturnURL,
|
||||
NotifyURL: cfg.CallbackURL,
|
||||
ExpireAt: &expireAt,
|
||||
PromoterID: in.PromoterID,
|
||||
PromoterName: in.PromoterName,
|
||||
PromoterType: in.PromoterType,
|
||||
}
|
||||
// 下单未携带推广方时,交给业务模块推导(如租户的签约渠道伙伴)
|
||||
if order.PromoterID == "" && promoterResolve != nil {
|
||||
if pid, pname, ptype, ok := promoterResolve(ctx, order); ok {
|
||||
order.PromoterID, order.PromoterName, order.PromoterType = pid, pname, ptype
|
||||
}
|
||||
}
|
||||
if _, err := models.Orm.Insert(order); err != nil {
|
||||
return nil, nil, fmt.Errorf("创建支付单失败: %w", err)
|
||||
}
|
||||
LogStateChange(order.PayNo, "", models.PayStatusCreated, "租户端下单页", "创建支付单,渠道:"+cfg.Name)
|
||||
|
||||
params, err := adapter.Prepay(ctx, order, cfg, PrepayOption{PayType: in.PayType, OpenID: in.OpenID})
|
||||
if err != nil {
|
||||
// 渠道下单失败:保留支付单并置为 failed,便于排查与重下
|
||||
_, _ = models.Orm.QueryTable(new(models.PlatformPaymentOrder)).
|
||||
Filter("id", order.ID).
|
||||
Update(orm.Params{"status": models.PayStatusFailed, "update_time": now})
|
||||
LogStateChange(order.PayNo, models.PayStatusCreated, models.PayStatusFailed, "支付服务", "渠道下单失败:"+err.Error())
|
||||
return nil, nil, err
|
||||
}
|
||||
LogStateChange(order.PayNo, models.PayStatusCreated, models.PayStatusCreated, "支付服务", "渠道下单成功,返回支付参数")
|
||||
if params.ChannelTradeNo != "" {
|
||||
_, _ = models.Orm.QueryTable(new(models.PlatformPaymentOrder)).
|
||||
Filter("id", order.ID).
|
||||
Update(orm.Params{"channel_trade_no": params.ChannelTradeNo, "update_time": time.Now()})
|
||||
order.ChannelTradeNo = params.ChannelTradeNo
|
||||
}
|
||||
return order, params, nil
|
||||
}
|
||||
|
||||
// QueryPayment 查询支付单;syncChannel=true 时主动向渠道查询并同步状态(兜底回调丢失)
|
||||
func QueryPayment(ctx context.Context, payNo string, syncChannel bool) (*models.PlatformPaymentOrder, error) {
|
||||
order, err := GetOrderByPayNo(payNo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("支付单不存在")
|
||||
}
|
||||
now := time.Now()
|
||||
_, _ = models.Orm.QueryTable(new(models.PlatformPaymentOrder)).
|
||||
Filter("id", order.ID).
|
||||
Update(orm.Params{"last_query_at": now, "update_time": now})
|
||||
order.LastQueryAt = &now
|
||||
|
||||
if !syncChannel {
|
||||
return order, nil
|
||||
}
|
||||
adapter, aerr := GetChannelAdapter(order.Channel)
|
||||
cfg, cerr := LoadChannelConfig(order.Channel)
|
||||
if aerr != nil || cerr != nil {
|
||||
return order, nil
|
||||
}
|
||||
st, qerr := adapter.Query(ctx, order, cfg)
|
||||
if qerr != nil {
|
||||
LogStateChange(order.PayNo, order.Status, order.Status, "手动查询", "查询渠道状态失败:"+qerr.Error())
|
||||
return order, nil
|
||||
}
|
||||
if _, serr := applyChannelState(ctx, order, st, "手动查询", "主动查询渠道状态同步"); serr != nil {
|
||||
beelog.Warn("支付单 %s 渠道状态同步失败: %v", order.PayNo, serr)
|
||||
}
|
||||
return GetOrderByPayNo(payNo)
|
||||
}
|
||||
|
||||
// HandleNotify 渠道异步通知统一入口:验签 -> 幂等 -> 状态机 -> 佣金,返回需回给渠道的响应体
|
||||
func HandleNotify(ctx context.Context, channel string, r *http.Request, ip string) (string, error) {
|
||||
adapter, err := GetChannelAdapter(channel)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cfg, err := LoadChannelConfig(channel) // 停用渠道的历史回调仍需处理
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
result, err := adapter.ParseNotify(ctx, r, cfg)
|
||||
if err != nil {
|
||||
insertCallbackLog(channel, "", "", "", 0, 0, false, false, err.Error(), "", ip)
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 幂等:同一事件(channel + event_id)已成功处理过则直接命中
|
||||
duplicate := existsCallbackHandled(channel, result.EventID, result.ChannelTradeNo, result.EventType)
|
||||
insertCallbackLog(channel, result.PayNo, result.OutTradeNo, result.ChannelTradeNo, result.Amount, 1, duplicate, false, "", result.Raw, ip)
|
||||
if duplicate {
|
||||
return result.AckBody, nil
|
||||
}
|
||||
|
||||
order, oerr := GetOrderByPayNo(result.PayNo)
|
||||
if oerr != nil {
|
||||
// 再尝试按业务订单号找最近一笔(回调里可能只回传 out_trade_no)
|
||||
order, oerr = findLatestOrderByOutTradeNo(result.OutTradeNo)
|
||||
}
|
||||
if oerr != nil {
|
||||
insertCallbackLog(channel, result.PayNo, result.OutTradeNo, result.ChannelTradeNo, result.Amount, 1, false, false, "支付单不存在", result.Raw, ip)
|
||||
return "", fmt.Errorf("支付单不存在: %s", result.PayNo)
|
||||
}
|
||||
|
||||
// 非成功/失败/关闭事件只记录日志
|
||||
if result.TradeState != StateSuccess && result.TradeState != StateFailed && result.TradeState != StateClosed {
|
||||
insertCallbackLog(channel, result.PayNo, result.OutTradeNo, result.ChannelTradeNo, result.Amount, 1, false, true, "事件无需变更状态: "+result.EventType, result.Raw, ip)
|
||||
return result.AckBody, nil
|
||||
}
|
||||
|
||||
changed, serr := applyChannelState(ctx, order, &ChannelState{
|
||||
ChannelTradeNo: result.ChannelTradeNo,
|
||||
TradeState: result.TradeState,
|
||||
Amount: result.Amount,
|
||||
}, "渠道回调", "渠道通知同步,事件:"+result.EventType)
|
||||
if serr != nil {
|
||||
insertCallbackLog(channel, result.PayNo, result.OutTradeNo, result.ChannelTradeNo, result.Amount, 1, false, false, "状态更新失败: "+serr.Error(), result.Raw, ip)
|
||||
return "", serr
|
||||
}
|
||||
if !changed {
|
||||
insertCallbackLog(channel, result.PayNo, result.OutTradeNo, result.ChannelTradeNo, result.Amount, 1, false, true, "状态未变化(幂等)", result.Raw, ip)
|
||||
} else {
|
||||
insertCallbackLog(channel, result.PayNo, result.OutTradeNo, result.ChannelTradeNo, result.Amount, 1, false, true, "状态已同步为 "+result.TradeState, result.Raw, ip)
|
||||
}
|
||||
return result.AckBody, nil
|
||||
}
|
||||
|
||||
// existsCallbackHandled 是否已存在处理成功的同事件回调
|
||||
func existsCallbackHandled(channel, eventID, channelTradeNo, eventType string) bool {
|
||||
qs := models.Orm.QueryTable(new(models.PlatformPaymentCallbackLog)).
|
||||
Filter("channel", channel).
|
||||
Filter("handle_result", 1).
|
||||
Filter("delete_time__isnull", true)
|
||||
if eventID != "" {
|
||||
qs = qs.Filter("event_id", eventID)
|
||||
} else {
|
||||
qs = qs.Filter("channel_trade_no", channelTradeNo).Filter("event_type", eventType)
|
||||
}
|
||||
cnt, _ := qs.Count()
|
||||
return cnt > 0
|
||||
}
|
||||
|
||||
func insertCallbackLog(channel, payNo, outTradeNo, channelTradeNo string, amount int64, verifyResult int8, duplicate, handled bool, msg, raw, ip string) {
|
||||
row := &models.PlatformPaymentCallbackLog{
|
||||
Channel: channel,
|
||||
PayNo: payNo,
|
||||
OutTradeNo: outTradeNo,
|
||||
ChannelTradeNo: channelTradeNo,
|
||||
Amount: amount,
|
||||
VerifyResult: verifyResult,
|
||||
HandleMsg: msg,
|
||||
ClientIP: ip,
|
||||
}
|
||||
if duplicate {
|
||||
row.IsDuplicate = 1
|
||||
}
|
||||
if handled {
|
||||
row.HandleResult = 1
|
||||
}
|
||||
if raw != "" {
|
||||
raw = truncateStr(raw, 60000)
|
||||
row.RawBody = &raw
|
||||
}
|
||||
if _, err := models.Orm.Insert(row); err != nil {
|
||||
beelog.Warn("回调日志写入失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func findLatestOrderByOutTradeNo(outTradeNo string) (*models.PlatformPaymentOrder, error) {
|
||||
row := &models.PlatformPaymentOrder{}
|
||||
err := models.Orm.QueryTable(new(models.PlatformPaymentOrder)).
|
||||
Filter("out_trade_no", outTradeNo).
|
||||
Filter("delete_time__isnull", true).
|
||||
OrderBy("-id").
|
||||
One(row)
|
||||
return row, err
|
||||
}
|
||||
|
||||
// applyChannelState 把渠道状态落到本地(回调与主动查询共用),返回是否发生状态变更。
|
||||
// 支付成功采用条件更新(仅 created/pending/paying 可置为 paid)保证并发幂等。
|
||||
func applyChannelState(ctx context.Context, order *models.PlatformPaymentOrder, st *ChannelState, operator, remark string) (bool, error) {
|
||||
if st == nil {
|
||||
return false, nil
|
||||
}
|
||||
now := time.Now()
|
||||
active := []interface{}{models.PayStatusCreated, models.PayStatusPending, models.PayStatusPaying}
|
||||
|
||||
switch st.TradeState {
|
||||
case StateSuccess:
|
||||
if order.Status == models.PayStatusPaid {
|
||||
if order.ChannelTradeNo == "" && st.ChannelTradeNo != "" {
|
||||
_, _ = models.Orm.QueryTable(new(models.PlatformPaymentOrder)).
|
||||
Filter("id", order.ID).
|
||||
Update(orm.Params{"channel_trade_no": st.ChannelTradeNo, "update_time": now})
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
note := remark
|
||||
if st.Amount > 0 && st.Amount != order.Amount {
|
||||
note = fmt.Sprintf("%s(注意:渠道金额 %d 分与本地 %d 分不一致,请核对)", remark, st.Amount, order.Amount)
|
||||
}
|
||||
updates := orm.Params{
|
||||
"status": models.PayStatusPaid,
|
||||
"paid_at": now,
|
||||
"notify_at": now,
|
||||
"order_synced": 0,
|
||||
"update_time": now,
|
||||
}
|
||||
if st.ChannelTradeNo != "" {
|
||||
updates["channel_trade_no"] = st.ChannelTradeNo
|
||||
}
|
||||
n, err := models.Orm.QueryTable(new(models.PlatformPaymentOrder)).
|
||||
Filter("id", order.ID).
|
||||
Filter("status__in", active...).
|
||||
Update(updates)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if n == 0 {
|
||||
return false, nil // 并发下已被其他通知处理
|
||||
}
|
||||
order.Status = models.PayStatusPaid
|
||||
order.PaidAt = &now
|
||||
if st.ChannelTradeNo != "" {
|
||||
order.ChannelTradeNo = st.ChannelTradeNo
|
||||
}
|
||||
LogStateChange(order.PayNo, models.PayStatusPaying, models.PayStatusPaid, operator, note)
|
||||
|
||||
// 1. 同步业务订单状态(钩子;失败保留 order_synced=0 由补偿任务重试)
|
||||
if orderSyncHook == nil {
|
||||
_, _ = models.Orm.QueryTable(new(models.PlatformPaymentOrder)).
|
||||
Filter("id", order.ID).
|
||||
Update(orm.Params{"order_synced": 1, "update_time": time.Now()})
|
||||
} else if serr := orderSyncHook(ctx, order); serr != nil {
|
||||
LogStateChange(order.PayNo, models.PayStatusPaid, models.PayStatusPaid, operator, "业务订单同步失败:"+serr.Error())
|
||||
} else {
|
||||
_, _ = models.Orm.QueryTable(new(models.PlatformPaymentOrder)).
|
||||
Filter("id", order.ID).
|
||||
Update(orm.Params{"order_synced": 1, "update_time": time.Now()})
|
||||
}
|
||||
|
||||
// 2. 生成推广佣金(平台自有资金支出,失败不影响收款状态)
|
||||
if _, cerr := OnOrderPaid(ctx, order); cerr != nil {
|
||||
beelog.Warn("支付单 %s 佣金生成失败: %v", order.PayNo, cerr)
|
||||
}
|
||||
return true, nil
|
||||
|
||||
case StateClosed:
|
||||
if order.Status == models.PayStatusPaid {
|
||||
return false, nil
|
||||
}
|
||||
_, err := models.Orm.QueryTable(new(models.PlatformPaymentOrder)).
|
||||
Filter("id", order.ID).
|
||||
Filter("status__in", active...).
|
||||
Update(orm.Params{"status": models.PayStatusClosed, "closed_at": now, "update_time": now})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
order.Status = models.PayStatusClosed
|
||||
LogStateChange(order.PayNo, models.PayStatusPending, models.PayStatusClosed, operator, remark)
|
||||
return true, nil
|
||||
|
||||
case StateFailed:
|
||||
if order.Status == models.PayStatusPaid {
|
||||
return false, nil
|
||||
}
|
||||
_, err := models.Orm.QueryTable(new(models.PlatformPaymentOrder)).
|
||||
Filter("id", order.ID).
|
||||
Filter("status__in", active...).
|
||||
Update(orm.Params{"status": models.PayStatusFailed, "update_time": now})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
order.Status = models.PayStatusFailed
|
||||
LogStateChange(order.PayNo, models.PayStatusPending, models.PayStatusFailed, operator, remark)
|
||||
return true, nil
|
||||
|
||||
case StatePending:
|
||||
if order.Status == models.PayStatusCreated {
|
||||
_, err := models.Orm.QueryTable(new(models.PlatformPaymentOrder)).
|
||||
Filter("id", order.ID).
|
||||
Update(orm.Params{"status": models.PayStatusPending, "update_time": now})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
order.Status = models.PayStatusPending
|
||||
LogStateChange(order.PayNo, models.PayStatusCreated, models.PayStatusPending, operator, remark)
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
|
||||
default:
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
// RefundInput 手动原路退回入参(兜底能力)
|
||||
type RefundInput struct {
|
||||
PayNo string
|
||||
Amount int64 // 分;<=0 表示全额退回
|
||||
Reason string // 必填
|
||||
OperatorID string
|
||||
OperatorName string
|
||||
}
|
||||
|
||||
// ManualRefund 手动原路退回(不做业务层退款流程)
|
||||
func ManualRefund(ctx context.Context, in RefundInput) (*models.PlatformPaymentRefund, error) {
|
||||
if strings.TrimSpace(in.Reason) == "" {
|
||||
return nil, errors.New("退回原因不能为空")
|
||||
}
|
||||
order, err := GetOrderByPayNo(in.PayNo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("支付单不存在")
|
||||
}
|
||||
if order.Status != models.PayStatusPaid {
|
||||
return nil, fmt.Errorf("仅支付成功的支付单支持原路退回(当前状态 %s)", order.Status)
|
||||
}
|
||||
remain := order.Amount - order.RefundAmount
|
||||
amount := in.Amount
|
||||
if amount <= 0 {
|
||||
amount = remain
|
||||
}
|
||||
if amount > remain {
|
||||
return nil, fmt.Errorf("退回金额 %d 分超过可退余额 %d 分", amount, remain)
|
||||
}
|
||||
|
||||
refund := &models.PlatformPaymentRefund{
|
||||
RefundNo: NextRefundNo(),
|
||||
PayNo: order.PayNo,
|
||||
OutTradeNo: order.OutTradeNo,
|
||||
Channel: order.Channel,
|
||||
Amount: amount,
|
||||
Reason: in.Reason,
|
||||
Status: models.RefundStatusProcessing,
|
||||
OperatorID: in.OperatorID,
|
||||
OperatorName: in.OperatorName,
|
||||
}
|
||||
if _, err := models.Orm.Insert(refund); err != nil {
|
||||
return nil, fmt.Errorf("创建退回单失败: %w", err)
|
||||
}
|
||||
|
||||
adapter, aerr := GetChannelAdapter(order.Channel)
|
||||
cfg, cerr := LoadChannelConfig(order.Channel)
|
||||
if aerr != nil || cerr != nil {
|
||||
failRefund(refund, "渠道适配器/配置不可用")
|
||||
return refund, fmt.Errorf("渠道适配器/配置不可用")
|
||||
}
|
||||
|
||||
channelRefundNo, rerr := adapter.Refund(ctx, order, refund.RefundNo, amount, in.Reason, cfg)
|
||||
now := time.Now()
|
||||
if rerr != nil {
|
||||
failRefund(refund, rerr.Error())
|
||||
LogStateChange(order.PayNo, order.Status, order.Status, "平台管理员", "手动原路退回失败:"+rerr.Error())
|
||||
return refund, rerr
|
||||
}
|
||||
|
||||
_, _ = models.Orm.QueryTable(new(models.PlatformPaymentRefund)).
|
||||
Filter("id", refund.ID).
|
||||
Update(orm.Params{"status": models.RefundStatusSuccess, "channel_refund_no": channelRefundNo, "finished_at": now, "update_time": now})
|
||||
refund.Status = models.RefundStatusSuccess
|
||||
refund.ChannelRefundNo = channelRefundNo
|
||||
refund.FinishedAt = &now
|
||||
|
||||
newRefunded := order.RefundAmount + amount
|
||||
toStatus := models.PayStatusRefunding
|
||||
if newRefunded >= order.Amount {
|
||||
toStatus = models.PayStatusRefunded
|
||||
}
|
||||
_, _ = models.Orm.QueryTable(new(models.PlatformPaymentOrder)).
|
||||
Filter("id", order.ID).
|
||||
Update(orm.Params{"refund_amount": newRefunded, "status": toStatus, "update_time": now})
|
||||
order.RefundAmount = newRefunded
|
||||
order.Status = toStatus
|
||||
LogStateChange(order.PayNo, models.PayStatusPaid, toStatus, "平台管理员", fmt.Sprintf("手动原路退回 ¥%s:%s", FenToYuan(amount), in.Reason))
|
||||
return refund, nil
|
||||
}
|
||||
|
||||
func failRefund(refund *models.PlatformPaymentRefund, msg string) {
|
||||
_, _ = models.Orm.QueryTable(new(models.PlatformPaymentRefund)).
|
||||
Filter("id", refund.ID).
|
||||
Update(orm.Params{"status": models.RefundStatusFailed, "fail_reason": truncateStr(msg, 480), "update_time": time.Now()})
|
||||
refund.Status = models.RefundStatusFailed
|
||||
refund.FailReason = msg
|
||||
}
|
||||
|
||||
// CloseExpiredOrders 关闭超时未支付订单(补偿任务),返回关闭数量
|
||||
func CloseExpiredOrders(ctx context.Context) (int, error) {
|
||||
var orders []models.PlatformPaymentOrder
|
||||
_, err := models.Orm.QueryTable(new(models.PlatformPaymentOrder)).
|
||||
Filter("delete_time__isnull", true).
|
||||
Filter("status__in", models.PayStatusCreated, models.PayStatusPending, models.PayStatusPaying).
|
||||
Filter("expire_at__lt", time.Now()).
|
||||
All(&orders)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
closed := 0
|
||||
now := time.Now()
|
||||
for i := range orders {
|
||||
_, err := models.Orm.QueryTable(new(models.PlatformPaymentOrder)).
|
||||
Filter("id", orders[i].ID).
|
||||
Filter("status__in", models.PayStatusCreated, models.PayStatusPending, models.PayStatusPaying).
|
||||
Update(orm.Params{"status": models.PayStatusClosed, "closed_at": now, "update_time": now})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
LogStateChange(orders[i].PayNo, orders[i].Status, models.PayStatusClosed, "定时补偿", "支付超时自动关闭")
|
||||
closed++
|
||||
}
|
||||
return closed, nil
|
||||
}
|
||||
|
||||
// SyncPendingOrders 重试同步「已支付但业务订单未更新」的支付单(补偿任务),返回处理数量
|
||||
func SyncPendingOrders(ctx context.Context) (int, error) {
|
||||
if orderSyncHook == nil {
|
||||
return 0, nil
|
||||
}
|
||||
var orders []models.PlatformPaymentOrder
|
||||
_, err := models.Orm.QueryTable(new(models.PlatformPaymentOrder)).
|
||||
Filter("delete_time__isnull", true).
|
||||
Filter("status", models.PayStatusPaid).
|
||||
Filter("order_synced", 0).
|
||||
All(&orders)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
done := 0
|
||||
for i := range orders {
|
||||
if serr := orderSyncHook(ctx, &orders[i]); serr != nil {
|
||||
beelog.Warn("支付单 %s 业务订单同步重试失败: %v", orders[i].PayNo, serr)
|
||||
continue
|
||||
}
|
||||
_, _ = models.Orm.QueryTable(new(models.PlatformPaymentOrder)).
|
||||
Filter("id", orders[i].ID).
|
||||
Update(orm.Params{"order_synced": 1, "update_time": time.Now()})
|
||||
LogStateChange(orders[i].PayNo, models.PayStatusPaid, models.PayStatusPaid, "定时补偿", "业务订单同步成功(重试)")
|
||||
done++
|
||||
}
|
||||
return done, nil
|
||||
}
|
||||
|
||||
// StartPaymentScheduler 支付补偿任务:关闭超时订单 + 重试业务订单同步(5 分钟一轮)
|
||||
func StartPaymentScheduler(stop <-chan struct{}) {
|
||||
go func() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-ticker.C:
|
||||
ctx := context.Background()
|
||||
if n, err := CloseExpiredOrders(ctx); err != nil {
|
||||
beelog.Warn("支付超时关单失败: %v", err)
|
||||
} else if n > 0 {
|
||||
beelog.Info("支付超时关单 %d 笔", n)
|
||||
}
|
||||
if n, err := SyncPendingOrders(ctx); err != nil {
|
||||
beelog.Warn("业务订单同步重试失败: %v", err)
|
||||
} else if n > 0 {
|
||||
beelog.Info("业务订单同步重试完成 %d 笔", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
Reference in New Issue
Block a user