增加支付功能

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