优化提现记录页面,新增“领取零钱”按钮以支持用户提现操作,并更新相关API以获取转账信息。调整页面布局以提升用户体验,同时确保状态字段的一致性和可读性。
This commit is contained in:
@@ -3,18 +3,23 @@ package wechat
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/config"
|
||||
|
||||
"github.com/ArtisanCloud/PowerLibs/v3/object"
|
||||
"github.com/ArtisanCloud/PowerWeChat/v3/src/miniProgram"
|
||||
"github.com/ArtisanCloud/PowerWeChat/v3/src/kernel/models"
|
||||
"github.com/ArtisanCloud/PowerWeChat/v3/src/payment"
|
||||
notifyrequest "github.com/ArtisanCloud/PowerWeChat/v3/src/payment/notify/request"
|
||||
"github.com/ArtisanCloud/PowerWeChat/v3/src/payment/order/request"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -23,30 +28,86 @@ var (
|
||||
cfg *config.Config
|
||||
)
|
||||
|
||||
// Init 初始化微信客户端
|
||||
// resolveCertPaths 若证书/私钥路径为 URL 则下载到临时文件并返回本地路径
|
||||
func resolveCertPaths(c *config.Config) (certPath, keyPath string, err error) {
|
||||
certPath = c.WechatCertPath
|
||||
keyPath = c.WechatKeyPath
|
||||
if certPath == "" || keyPath == "" {
|
||||
return certPath, keyPath, nil
|
||||
}
|
||||
if strings.HasPrefix(keyPath, "http://") || strings.HasPrefix(keyPath, "https://") {
|
||||
dir, e := os.MkdirTemp("", "wechat_cert_*")
|
||||
if e != nil {
|
||||
return "", "", fmt.Errorf("创建临时目录失败: %w", e)
|
||||
}
|
||||
keyPath, e = downloadToFile(keyPath, filepath.Join(dir, "apiclient_key.pem"))
|
||||
if e != nil {
|
||||
return "", "", e
|
||||
}
|
||||
if strings.HasPrefix(certPath, "http://") || strings.HasPrefix(certPath, "https://") {
|
||||
certPath, e = downloadToFile(certPath, filepath.Join(dir, "apiclient_cert.pem"))
|
||||
if e != nil {
|
||||
return "", "", e
|
||||
}
|
||||
} else {
|
||||
// cert 是本地路径,只下载了 key
|
||||
certPath = c.WechatCertPath
|
||||
}
|
||||
}
|
||||
return certPath, keyPath, nil
|
||||
}
|
||||
|
||||
func downloadToFile(url, filePath string) (string, error) {
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("下载文件失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("下载返回状态: %d", resp.StatusCode)
|
||||
}
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取内容失败: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(filePath, data, 0600); err != nil {
|
||||
return "", fmt.Errorf("写入临时文件失败: %w", err)
|
||||
}
|
||||
return filePath, nil
|
||||
}
|
||||
|
||||
// Init 初始化微信客户端(小程序 + 支付 v3 + 转账均使用 PowerWeChat)
|
||||
func Init(c *config.Config) error {
|
||||
cfg = c
|
||||
|
||||
// 初始化小程序
|
||||
|
||||
var err error
|
||||
miniProgramApp, err = miniProgram.NewMiniProgram(&miniProgram.UserConfig{
|
||||
AppID: cfg.WechatAppID,
|
||||
Secret: cfg.WechatAppSecret,
|
||||
AppID: cfg.WechatAppID,
|
||||
Secret: cfg.WechatAppSecret,
|
||||
HttpDebug: cfg.Mode == "debug",
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("初始化小程序失败: %w", err)
|
||||
}
|
||||
|
||||
// 初始化支付(v2)
|
||||
paymentApp, err = payment.NewPayment(&payment.UserConfig{
|
||||
AppID: cfg.WechatAppID,
|
||||
MchID: cfg.WechatMchID,
|
||||
Key: cfg.WechatMchKey,
|
||||
HttpDebug: cfg.Mode == "debug",
|
||||
})
|
||||
certPath, keyPath, err := resolveCertPaths(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("初始化支付失败: %w", err)
|
||||
return fmt.Errorf("解析证书路径: %w", err)
|
||||
}
|
||||
paymentConfig := &payment.UserConfig{
|
||||
AppID: cfg.WechatAppID,
|
||||
MchID: cfg.WechatMchID,
|
||||
MchApiV3Key: cfg.WechatAPIv3Key,
|
||||
Key: cfg.WechatMchKey,
|
||||
CertPath: certPath,
|
||||
KeyPath: keyPath,
|
||||
SerialNo: cfg.WechatSerialNo,
|
||||
NotifyURL: cfg.WechatNotifyURL,
|
||||
HttpDebug: cfg.Mode == "debug",
|
||||
}
|
||||
paymentApp, err = payment.NewPayment(paymentConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("初始化支付(v3)失败: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -188,199 +249,119 @@ func GenerateMiniProgramCode(scene, page string, width int) ([]byte, error) {
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// PayV2UnifiedOrder 微信支付 v2 统一下单
|
||||
func PayV2UnifiedOrder(params map[string]string) (map[string]string, error) {
|
||||
// 添加必要参数
|
||||
params["appid"] = cfg.WechatAppID
|
||||
params["mch_id"] = cfg.WechatMchID
|
||||
params["nonce_str"] = generateNonceStr()
|
||||
params["sign_type"] = "MD5"
|
||||
|
||||
// 生成签名
|
||||
params["sign"] = generateSign(params, cfg.WechatMchKey)
|
||||
|
||||
// 转换为 XML
|
||||
xmlData := mapToXML(params)
|
||||
|
||||
// 发送请求
|
||||
resp, err := http.Post("https://api.mch.weixin.qq.com/pay/unifiedorder", "application/xml", bytes.NewReader([]byte(xmlData)))
|
||||
// GetPayNotifyURL 返回支付回调地址(与商户平台配置一致)
|
||||
func GetPayNotifyURL() string {
|
||||
if cfg != nil && cfg.WechatNotifyURL != "" {
|
||||
return cfg.WechatNotifyURL
|
||||
}
|
||||
return "https://soul.quwanzhi.com/api/miniprogram/pay/notify"
|
||||
}
|
||||
|
||||
// PayJSAPIOrder 微信支付 v3 小程序 JSAPI 统一下单,返回 prepay_id
|
||||
func PayJSAPIOrder(ctx context.Context, openID, orderSn string, amountCents int, description, attach string) (prepayID string, err error) {
|
||||
if paymentApp == nil {
|
||||
return "", fmt.Errorf("支付未初始化")
|
||||
}
|
||||
req := &request.RequestJSAPIPrepay{
|
||||
PrepayBase: request.PrepayBase{
|
||||
AppID: cfg.WechatAppID,
|
||||
MchID: cfg.WechatMchID,
|
||||
NotifyUrl: GetPayNotifyURL(),
|
||||
},
|
||||
Description: description,
|
||||
OutTradeNo: orderSn,
|
||||
Amount: &request.JSAPIAmount{
|
||||
Total: amountCents,
|
||||
Currency: "CNY",
|
||||
},
|
||||
Payer: &request.JSAPIPayer{OpenID: openID},
|
||||
Attach: attach,
|
||||
}
|
||||
res, err := paymentApp.Order.JSAPITransaction(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("请求统一下单接口失败: %w", err)
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
result := xmlToMap(string(body))
|
||||
|
||||
if result["return_code"] != "SUCCESS" {
|
||||
return nil, fmt.Errorf("统一下单失败: %s", result["return_msg"])
|
||||
if res == nil || res.PrepayID == "" {
|
||||
return "", fmt.Errorf("微信返回 prepay_id 为空")
|
||||
}
|
||||
|
||||
if result["result_code"] != "SUCCESS" {
|
||||
return nil, fmt.Errorf("下单失败: %s", result["err_code_des"])
|
||||
}
|
||||
|
||||
return result, nil
|
||||
return res.PrepayID, nil
|
||||
}
|
||||
|
||||
// PayV2OrderQuery 微信支付 v2 订单查询
|
||||
func PayV2OrderQuery(outTradeNo string) (map[string]string, error) {
|
||||
params := map[string]string{
|
||||
"appid": cfg.WechatAppID,
|
||||
"mch_id": cfg.WechatMchID,
|
||||
"out_trade_no": outTradeNo,
|
||||
"nonce_str": generateNonceStr(),
|
||||
// GetJSAPIPayParams 根据 prepay_id 生成小程序 wx.requestPayment 所需参数(v3 签名)
|
||||
func GetJSAPIPayParams(prepayID string) (map[string]string, error) {
|
||||
if paymentApp == nil {
|
||||
return nil, fmt.Errorf("支付未初始化")
|
||||
}
|
||||
|
||||
params["sign"] = generateSign(params, cfg.WechatMchKey)
|
||||
xmlData := mapToXML(params)
|
||||
|
||||
resp, err := http.Post("https://api.mch.weixin.qq.com/pay/orderquery", "application/xml", bytes.NewReader([]byte(xmlData)))
|
||||
cfgMap, err := paymentApp.JSSDK.BridgeConfig(prepayID, false)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("请求订单查询接口失败: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
result := xmlToMap(string(body))
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// VerifyPayNotify 验证支付回调签名
|
||||
func VerifyPayNotify(data map[string]string) bool {
|
||||
receivedSign := data["sign"]
|
||||
if receivedSign == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
delete(data, "sign")
|
||||
calculatedSign := generateSign(data, cfg.WechatMchKey)
|
||||
|
||||
return receivedSign == calculatedSign
|
||||
}
|
||||
|
||||
// GenerateJSAPIPayParams 生成小程序支付参数
|
||||
func GenerateJSAPIPayParams(prepayID string) map[string]string {
|
||||
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
nonceStr := generateNonceStr()
|
||||
|
||||
params := map[string]string{
|
||||
"appId": cfg.WechatAppID,
|
||||
"timeStamp": timestamp,
|
||||
"nonceStr": nonceStr,
|
||||
"package": fmt.Sprintf("prepay_id=%s", prepayID),
|
||||
"signType": "MD5",
|
||||
}
|
||||
|
||||
params["paySign"] = generateSign(params, cfg.WechatMchKey)
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
// === 辅助函数 ===
|
||||
|
||||
func generateNonceStr() string {
|
||||
return fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
}
|
||||
|
||||
func generateSign(params map[string]string, key string) string {
|
||||
// 按字典序排序
|
||||
var keys []string
|
||||
for k := range params {
|
||||
if k != "sign" && params[k] != "" {
|
||||
keys = append(keys, k)
|
||||
out := make(map[string]string)
|
||||
if m, ok := cfgMap.(*object.StringMap); ok && m != nil {
|
||||
for k, v := range *m {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
// 简单冒泡排序
|
||||
for i := 0; i < len(keys); i++ {
|
||||
for j := i + 1; j < len(keys); j++ {
|
||||
if keys[i] > keys[j] {
|
||||
keys[i], keys[j] = keys[j], keys[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 拼接字符串
|
||||
var signStr string
|
||||
for _, k := range keys {
|
||||
signStr += fmt.Sprintf("%s=%s&", k, params[k])
|
||||
}
|
||||
signStr += fmt.Sprintf("key=%s", key)
|
||||
|
||||
// MD5
|
||||
hash := md5.Sum([]byte(signStr))
|
||||
return fmt.Sprintf("%X", hash) // 大写
|
||||
}
|
||||
|
||||
func mapToXML(data map[string]string) string {
|
||||
xml := "<xml>"
|
||||
for k, v := range data {
|
||||
xml += fmt.Sprintf("<%s><![CDATA[%s]]></%s>", k, v, k)
|
||||
}
|
||||
xml += "</xml>"
|
||||
return xml
|
||||
}
|
||||
|
||||
func xmlToMap(xmlStr string) map[string]string {
|
||||
result := make(map[string]string)
|
||||
|
||||
// 简单的 XML 解析(仅支持 <key><![CDATA[value]]></key> 和 <key>value</key> 格式)
|
||||
var key, value string
|
||||
inCDATA := false
|
||||
inTag := false
|
||||
isClosing := false
|
||||
|
||||
for i := 0; i < len(xmlStr); i++ {
|
||||
ch := xmlStr[i]
|
||||
|
||||
if ch == '<' {
|
||||
if i+1 < len(xmlStr) && xmlStr[i+1] == '/' {
|
||||
isClosing = true
|
||||
i++ // skip '/'
|
||||
} else if i+8 < len(xmlStr) && xmlStr[i:i+9] == "<![CDATA[" {
|
||||
inCDATA = true
|
||||
i += 8 // skip "![CDATA["
|
||||
continue
|
||||
}
|
||||
inTag = true
|
||||
key = ""
|
||||
continue
|
||||
}
|
||||
|
||||
if ch == '>' {
|
||||
inTag = false
|
||||
if isClosing {
|
||||
if key != "" && key != "xml" {
|
||||
result[key] = value
|
||||
if len(out) == 0 && cfgMap != nil {
|
||||
if ms, ok := cfgMap.(map[string]interface{}); ok {
|
||||
for k, v := range ms {
|
||||
if s, ok := v.(string); ok {
|
||||
out[k] = s
|
||||
}
|
||||
key = ""
|
||||
value = ""
|
||||
isClosing = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if inCDATA && i+2 < len(xmlStr) && xmlStr[i:i+3] == "]]>" {
|
||||
inCDATA = false
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
|
||||
if inTag {
|
||||
key += string(ch)
|
||||
} else if !isClosing {
|
||||
value += string(ch)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// XMLToMap 导出供外部使用
|
||||
func XMLToMap(xmlStr string) map[string]string {
|
||||
return xmlToMap(xmlStr)
|
||||
// QueryOrderByOutTradeNo 根据商户订单号查询订单状态(v3)
|
||||
func QueryOrderByOutTradeNo(ctx context.Context, outTradeNo string) (tradeState, transactionID string, totalFee int, err error) {
|
||||
if paymentApp == nil {
|
||||
return "", "", 0, fmt.Errorf("支付未初始化")
|
||||
}
|
||||
res, err := paymentApp.Order.QueryByOutTradeNumber(ctx, outTradeNo)
|
||||
if err != nil {
|
||||
return "", "", 0, err
|
||||
}
|
||||
if res == nil {
|
||||
return "", "", 0, nil
|
||||
}
|
||||
tradeState = res.TradeState
|
||||
transactionID = res.TransactionID
|
||||
if res.Amount != nil {
|
||||
totalFee = int(res.Amount.Total)
|
||||
}
|
||||
return tradeState, transactionID, totalFee, nil
|
||||
}
|
||||
|
||||
// HandlePayNotify 处理 v3 支付回调:验签并解密后调用 handler,返回应写回微信的 HTTP 响应
|
||||
// handler 参数:orderSn, transactionID, totalFee(分), attach(JSON), openID
|
||||
func HandlePayNotify(req *http.Request, handler func(orderSn, transactionID string, totalFee int, attach, openID string) error) (*http.Response, error) {
|
||||
if paymentApp == nil {
|
||||
return nil, fmt.Errorf("支付未初始化")
|
||||
}
|
||||
return paymentApp.HandlePaidNotify(req, func(_ *notifyrequest.RequestNotify, transaction *models.Transaction, fail func(string)) interface{} {
|
||||
if transaction == nil {
|
||||
fail("transaction is nil")
|
||||
return nil
|
||||
}
|
||||
orderSn := transaction.OutTradeNo
|
||||
transactionID := transaction.TransactionID
|
||||
totalFee := 0
|
||||
if transaction.Amount != nil {
|
||||
totalFee = int(transaction.Amount.Total)
|
||||
}
|
||||
attach := transaction.Attach
|
||||
openID := ""
|
||||
if transaction.Payer != nil {
|
||||
openID = transaction.Payer.OpenID
|
||||
}
|
||||
if err := handler(orderSn, transactionID, totalFee, attach, openID); err != nil {
|
||||
fail(err.Error())
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// GenerateOrderSn 生成订单号
|
||||
|
||||
Reference in New Issue
Block a user