恢复被删除的代码
This commit is contained in:
446
soul-api/internal/wechat/miniprogram.go
Normal file
446
soul-api/internal/wechat/miniprogram.go
Normal file
@@ -0,0 +1,446 @@
|
||||
package wechat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/config"
|
||||
|
||||
"github.com/ArtisanCloud/PowerLibs/v3/object"
|
||||
"github.com/ArtisanCloud/PowerWeChat/v3/src/kernel/models"
|
||||
"github.com/ArtisanCloud/PowerWeChat/v3/src/kernel/power"
|
||||
"github.com/ArtisanCloud/PowerWeChat/v3/src/miniProgram"
|
||||
"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"
|
||||
subrequest "github.com/ArtisanCloud/PowerWeChat/v3/src/basicService/subscribeMessage/request"
|
||||
)
|
||||
|
||||
var (
|
||||
miniProgramApp *miniProgram.MiniProgram
|
||||
paymentApp *payment.Payment
|
||||
cfg *config.Config
|
||||
)
|
||||
|
||||
// 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,
|
||||
HttpDebug: cfg.Mode == "debug",
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("初始化小程序失败: %w", err)
|
||||
}
|
||||
|
||||
certPath, keyPath, err := resolveCertPaths(cfg)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
// Code2Session 小程序登录
|
||||
func Code2Session(code string) (openID, sessionKey, unionID string, err error) {
|
||||
ctx := context.Background()
|
||||
response, err := miniProgramApp.Auth.Session(ctx, code)
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("code2Session失败: %w", err)
|
||||
}
|
||||
|
||||
// PowerWeChat v3 返回的是 *object.HashMap
|
||||
if response.ErrCode != 0 {
|
||||
return "", "", "", fmt.Errorf("微信返回错误: %d - %s", response.ErrCode, response.ErrMsg)
|
||||
}
|
||||
|
||||
openID = response.OpenID
|
||||
sessionKey = response.SessionKey
|
||||
unionID = response.UnionID
|
||||
|
||||
return openID, sessionKey, unionID, nil
|
||||
}
|
||||
|
||||
// GetAccessToken 获取小程序 access_token(用于手机号解密、小程序码生成)
|
||||
func GetAccessToken() (string, error) {
|
||||
ctx := context.Background()
|
||||
tokenResp, err := miniProgramApp.AccessToken.GetToken(ctx, false)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("获取access_token失败: %w", err)
|
||||
}
|
||||
return tokenResp.AccessToken, nil
|
||||
}
|
||||
|
||||
// GetPhoneNumber 获取用户手机号
|
||||
func GetPhoneNumber(code string) (phoneNumber, countryCode string, err error) {
|
||||
token, err := GetAccessToken()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=%s", token)
|
||||
|
||||
reqBody := map[string]string{"code": code}
|
||||
jsonData, _ := json.Marshal(reqBody)
|
||||
|
||||
resp, err := http.Post(url, "application/json", bytes.NewReader(jsonData))
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("请求微信接口失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
var result struct {
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
PhoneInfo struct {
|
||||
PhoneNumber string `json:"phoneNumber"`
|
||||
PurePhoneNumber string `json:"purePhoneNumber"`
|
||||
CountryCode string `json:"countryCode"`
|
||||
} `json:"phone_info"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return "", "", fmt.Errorf("解析微信返回失败: %w", err)
|
||||
}
|
||||
|
||||
if result.ErrCode != 0 {
|
||||
return "", "", fmt.Errorf("微信返回错误: %d - %s", result.ErrCode, result.ErrMsg)
|
||||
}
|
||||
|
||||
phoneNumber = result.PhoneInfo.PhoneNumber
|
||||
if phoneNumber == "" {
|
||||
phoneNumber = result.PhoneInfo.PurePhoneNumber
|
||||
}
|
||||
countryCode = result.PhoneInfo.CountryCode
|
||||
if countryCode == "" {
|
||||
countryCode = "86"
|
||||
}
|
||||
|
||||
return phoneNumber, countryCode, nil
|
||||
}
|
||||
|
||||
// GenerateMiniProgramCode 生成小程序码
|
||||
func GenerateMiniProgramCode(scene, page string, width int) ([]byte, error) {
|
||||
token, err := GetAccessToken()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token=%s", token)
|
||||
|
||||
if width <= 0 || width > 430 {
|
||||
width = 280
|
||||
}
|
||||
if page == "" {
|
||||
page = "pages/index/index"
|
||||
}
|
||||
// 微信建议 scene 仅含英文字母、数字;& 和 = 可能导致异常,将 & 转为 _ 再传给微信
|
||||
scene = strings.ReplaceAll(scene, "&", "_")
|
||||
if len(scene) > 32 {
|
||||
scene = scene[:32]
|
||||
}
|
||||
|
||||
// 仅 developer/trial 生成对应版本码,其余一律正式版,避免扫码提示「开发版已过期」
|
||||
envVersion := "release"
|
||||
if cfg != nil {
|
||||
state := strings.TrimSpace(cfg.WechatMiniProgramState)
|
||||
switch state {
|
||||
case "developer":
|
||||
envVersion = "develop"
|
||||
case "trial":
|
||||
envVersion = "trial"
|
||||
default:
|
||||
envVersion = "release"
|
||||
}
|
||||
}
|
||||
if cfg != nil {
|
||||
fmt.Printf("[GenerateMiniProgramCode] env_version=%s (WechatMiniProgramState=%q)\n", envVersion, cfg.WechatMiniProgramState)
|
||||
}
|
||||
reqBody := map[string]interface{}{
|
||||
"scene": scene,
|
||||
"page": page,
|
||||
"width": width,
|
||||
"auto_color": false,
|
||||
"line_color": map[string]int{"r": 0, "g": 206, "b": 209},
|
||||
"is_hyaline": false,
|
||||
"env_version": envVersion,
|
||||
}
|
||||
jsonData, _ := json.Marshal(reqBody)
|
||||
resp, err := http.Post(url, "application/json", bytes.NewReader(jsonData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("请求微信接口失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
// 无论 Content-Type,先尝试按 JSON 解析:微信错误时返回小体积 JSON,否则会误报「图片数据异常(太小)」
|
||||
var errResult struct {
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
}
|
||||
if json.Unmarshal(body, &errResult) == nil && errResult.ErrCode != 0 {
|
||||
return nil, fmt.Errorf("生成小程序码失败: %d - %s", errResult.ErrCode, errResult.ErrMsg)
|
||||
}
|
||||
if len(body) < 1000 {
|
||||
return nil, fmt.Errorf("返回的图片数据异常(太小),可能未发布对应版本或参数错误")
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// 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 "", err
|
||||
}
|
||||
if res == nil || res.PrepayID == "" {
|
||||
return "", fmt.Errorf("微信返回 prepay_id 为空")
|
||||
}
|
||||
return res.PrepayID, nil
|
||||
}
|
||||
|
||||
// GetJSAPIPayParams 根据 prepay_id 生成小程序 wx.requestPayment 所需参数(v3 签名)
|
||||
func GetJSAPIPayParams(prepayID string) (map[string]string, error) {
|
||||
if paymentApp == nil {
|
||||
return nil, fmt.Errorf("支付未初始化")
|
||||
}
|
||||
cfgMap, err := paymentApp.JSSDK.BridgeConfig(prepayID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[string]string)
|
||||
if m, ok := cfgMap.(*object.StringMap); ok && m != nil {
|
||||
for k, v := range *m {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
})
|
||||
}
|
||||
|
||||
// HandleTransferNotify 处理商家转账结果回调:验签并解密后调用 handler,返回应写回微信的 HTTP 响应
|
||||
// handler 参数:outBillNo(商户单号/即我们存的 detail_no)、transferBillNo、state(SUCCESS/FAIL/CANCELLED)、failReason
|
||||
func HandleTransferNotify(req *http.Request, handler func(outBillNo, transferBillNo, state, failReason string) error) (*http.Response, error) {
|
||||
if paymentApp == nil {
|
||||
return nil, fmt.Errorf("支付/转账未初始化")
|
||||
}
|
||||
return paymentApp.HandleTransferBillsNotify(req, func(_ *notifyrequest.RequestNotify, bill *models.TransferBills, fail func(string)) interface{} {
|
||||
if bill == nil {
|
||||
fail("bill is nil")
|
||||
return nil
|
||||
}
|
||||
outBillNo := bill.OutBillNo
|
||||
transferBillNo := bill.TransferBillNo
|
||||
state := bill.State
|
||||
failReason := bill.FailReason
|
||||
if err := handler(outBillNo, transferBillNo, state, failReason); err != nil {
|
||||
fail(err.Error())
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// GenerateOrderSn 生成订单号
|
||||
func GenerateOrderSn() string {
|
||||
now := time.Now()
|
||||
timestamp := now.Format("20060102150405")
|
||||
random := now.UnixNano() % 1000000
|
||||
return fmt.Sprintf("MP%s%06d", timestamp, random)
|
||||
}
|
||||
|
||||
// WithdrawSubscribeTemplateID 提现结果订阅消息模板 ID(与小程序 app.js withdrawSubscribeTmplId 一致)
|
||||
const WithdrawSubscribeTemplateID = "u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE"
|
||||
|
||||
// SendWithdrawSubscribeMessage 发起转账成功后发订阅消息(提现成功/待确认收款)
|
||||
// openID 为接收人 openid,amount 为提现金额(元),success 为 true 表示打款已受理
|
||||
func SendWithdrawSubscribeMessage(ctx context.Context, openID string, amount float64, success bool) error {
|
||||
if miniProgramApp == nil {
|
||||
return fmt.Errorf("小程序未初始化")
|
||||
}
|
||||
phrase := "提现成功"
|
||||
thing8 := "微信打款成功,请点击查收"
|
||||
if !success {
|
||||
phrase = "提现失败"
|
||||
thing8 = "请联系官方客服"
|
||||
}
|
||||
amountStr := fmt.Sprintf("¥%.2f", amount)
|
||||
data := &power.HashMap{
|
||||
"phrase4": object.HashMap{"value": phrase},
|
||||
"amount5": object.HashMap{"value": amountStr},
|
||||
"thing8": object.HashMap{"value": thing8},
|
||||
}
|
||||
state := "formal"
|
||||
if cfg != nil && cfg.WechatMiniProgramState != "" {
|
||||
state = cfg.WechatMiniProgramState
|
||||
}
|
||||
_, err := miniProgramApp.SubscribeMessage.Send(ctx, &subrequest.RequestSubscribeMessageSend{
|
||||
ToUser: openID,
|
||||
TemplateID: WithdrawSubscribeTemplateID,
|
||||
Page: "/pages/my/my",
|
||||
MiniProgramState: state,
|
||||
Lang: "zh_CN",
|
||||
Data: data,
|
||||
})
|
||||
return err
|
||||
}
|
||||
230
soul-api/internal/wechat/transfer.go
Normal file
230
soul-api/internal/wechat/transfer.go
Normal file
@@ -0,0 +1,230 @@
|
||||
package wechat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"soul-api/internal/config"
|
||||
|
||||
"github.com/ArtisanCloud/PowerLibs/v3/object"
|
||||
fundAppRequest "github.com/ArtisanCloud/PowerWeChat/v3/src/payment/fundApp/request"
|
||||
"github.com/ArtisanCloud/PowerWeChat/v3/src/payment/transfer/request"
|
||||
)
|
||||
|
||||
// TransferParams 转账参数
|
||||
type TransferParams struct {
|
||||
OutBatchNo string // 商家批次单号(唯一)
|
||||
OutDetailNo string // 商家明细单号(唯一)
|
||||
OpenID string // 收款用户 openid
|
||||
Amount int // 转账金额(分)
|
||||
UserName string // 收款用户姓名(可选,用于实名校验)
|
||||
Remark string // 转账备注
|
||||
BatchName string // 批次名称(如"提现")
|
||||
BatchRemark string // 批次备注
|
||||
}
|
||||
|
||||
// TransferResult 转账结果
|
||||
type TransferResult struct {
|
||||
BatchID string // 微信批次单号
|
||||
OutBatchNo string // 商家批次单号
|
||||
CreateTime time.Time // 批次创建时间
|
||||
BatchStatus string // 批次状态:ACCEPTED-已受理 等
|
||||
}
|
||||
|
||||
// InitTransfer 保留兼容:转账已由 Init() 中 PowerWeChat Payment 统一初始化,调用无副作用
|
||||
func InitTransfer(_ *config.Config) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// InitiateTransfer 发起商家转账到零钱(PowerWeChat TransferBatch)
|
||||
func InitiateTransfer(params TransferParams) (*TransferResult, error) {
|
||||
if paymentApp == nil {
|
||||
return nil, fmt.Errorf("支付/转账未初始化,请先调用 wechat.Init")
|
||||
}
|
||||
|
||||
detail := &request.TransferDetail{
|
||||
OutDetailNO: params.OutDetailNo,
|
||||
TransferAmount: params.Amount,
|
||||
TransferRemark: params.Remark,
|
||||
OpenID: params.OpenID,
|
||||
}
|
||||
if params.UserName != "" {
|
||||
detail.UserName = object.NewNullString(params.UserName, true)
|
||||
}
|
||||
req := &request.RequestTransferBatch{
|
||||
AppID: cfg.WechatAppID,
|
||||
OutBatchNO: params.OutBatchNo,
|
||||
BatchName: params.BatchName,
|
||||
BatchRemark: params.BatchRemark,
|
||||
TotalAmount: params.Amount,
|
||||
TotalNum: 1,
|
||||
TransferDetailList: []*request.TransferDetail{detail},
|
||||
}
|
||||
if cfg.WechatTransferURL != "" {
|
||||
req.SetNotifyUrl(cfg.WechatTransferURL)
|
||||
}
|
||||
|
||||
resp, err := paymentApp.TransferBatch.Batch(context.Background(), req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("发起转账失败: %w", err)
|
||||
}
|
||||
if resp == nil {
|
||||
return nil, fmt.Errorf("转账返回为空")
|
||||
}
|
||||
|
||||
result := &TransferResult{
|
||||
OutBatchNo: resp.OutBatchNo,
|
||||
BatchStatus: "ACCEPTED",
|
||||
}
|
||||
if resp.BatchId != "" {
|
||||
result.BatchID = resp.BatchId
|
||||
}
|
||||
if !resp.CreateTime.IsZero() {
|
||||
result.CreateTime = resp.CreateTime
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// QueryTransfer 查询转账结果(可选,转账状态也可通过回调获取)
|
||||
func QueryTransfer(outBatchNo, outDetailNo string) (map[string]interface{}, error) {
|
||||
if paymentApp == nil {
|
||||
return map[string]interface{}{
|
||||
"out_batch_no": outBatchNo,
|
||||
"out_detail_no": outDetailNo,
|
||||
"status": "unknown",
|
||||
"message": "转账未初始化",
|
||||
}, nil
|
||||
}
|
||||
detail, err := paymentApp.TransferBatch.QueryOutBatchNoDetail(context.Background(), outBatchNo, outDetailNo)
|
||||
if err != nil {
|
||||
return map[string]interface{}{
|
||||
"out_batch_no": outBatchNo,
|
||||
"out_detail_no": outDetailNo,
|
||||
"status": "processing",
|
||||
"message": err.Error(),
|
||||
}, nil
|
||||
}
|
||||
if detail == nil {
|
||||
return map[string]interface{}{
|
||||
"out_batch_no": outBatchNo,
|
||||
"out_detail_no": outDetailNo,
|
||||
"status": "processing",
|
||||
"message": "转账处理中",
|
||||
}, nil
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"out_batch_no": outBatchNo,
|
||||
"out_detail_no": outDetailNo,
|
||||
"detail_status": detail.DetailStatus,
|
||||
"fail_reason": detail.FailReason,
|
||||
"transfer_amount": detail.TransferAmount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GenerateTransferBatchNo 生成转账批次单号
|
||||
func GenerateTransferBatchNo() string {
|
||||
now := time.Now()
|
||||
timestamp := now.Format("20060102150405")
|
||||
random := now.UnixNano() % 1000000
|
||||
return fmt.Sprintf("WD%s%06d", timestamp, random)
|
||||
}
|
||||
|
||||
// GenerateTransferDetailNo 生成转账明细单号
|
||||
func GenerateTransferDetailNo() string {
|
||||
now := time.Now()
|
||||
timestamp := now.Format("20060102150405")
|
||||
random := now.UnixNano() % 1000000
|
||||
return fmt.Sprintf("WDD%s%06d", timestamp, random)
|
||||
}
|
||||
|
||||
// FundAppTransferParams 单笔转账(FundApp 发起转账)参数
|
||||
type FundAppTransferParams struct {
|
||||
OutBillNo string // 商户单号(唯一,回调时 out_bill_no 即此值,建议存到 withdrawal.detail_no)
|
||||
OpenID string
|
||||
UserName string // 可选
|
||||
Amount int // 分
|
||||
Remark string
|
||||
NotifyURL string
|
||||
TransferSceneId string // 可选,如 "1005"
|
||||
}
|
||||
|
||||
// FundAppTransferResult 单笔转账结果(微信同步返回,无需等回调即可落库)
|
||||
type FundAppTransferResult struct {
|
||||
OutBillNo string // 商户单号
|
||||
TransferBillNo string // 微信转账单号
|
||||
State string // 如 WAIT_USER_CONFIRM 表示待用户确认收款
|
||||
PackageInfo string // 供小程序 wx.requestMerchantTransfer 使用
|
||||
CreateTime string // 微信返回的 create_time
|
||||
}
|
||||
|
||||
// InitiateTransferByFundApp 发起商家转账到零钱(PowerWeChat FundApp.TransferBills 单笔接口)
|
||||
// 与 TransferBatch 不同,此为 /v3/fund-app/mch-transfer/transfer-bills 单笔发起,回调仍为 MCHTRANSFER.BILL.FINISHED,解密后 out_bill_no 即本接口传入的 OutBillNo
|
||||
func InitiateTransferByFundApp(params FundAppTransferParams) (*FundAppTransferResult, error) {
|
||||
if paymentApp == nil || paymentApp.FundApp == nil {
|
||||
return nil, fmt.Errorf("支付/转账未初始化,请先调用 wechat.Init")
|
||||
}
|
||||
req := &fundAppRequest.RequestTransferBills{
|
||||
Appid: cfg.WechatAppID,
|
||||
OutBillNo: params.OutBillNo,
|
||||
TransferSceneId: params.TransferSceneId,
|
||||
Openid: params.OpenID,
|
||||
UserName: params.UserName,
|
||||
TransferAmount: params.Amount,
|
||||
TransferRemark: params.Remark,
|
||||
NotifyUrl: params.NotifyURL,
|
||||
}
|
||||
// 1005=佣金报酬:微信要求同时传 transfer_scene_report_infos,岗位类型与报酬说明分开两条
|
||||
if params.TransferSceneId == "1005" {
|
||||
req.TransferSceneReportInfos = []fundAppRequest.TransferSceneReportInfo{
|
||||
{InfoType: "岗位类型", InfoContent: "会员"},
|
||||
{InfoType: "报酬说明", InfoContent: "提现"},
|
||||
}
|
||||
}
|
||||
if req.NotifyUrl == "" && cfg.WechatTransferURL != "" {
|
||||
req.NotifyUrl = cfg.WechatTransferURL
|
||||
}
|
||||
ctx := context.Background()
|
||||
resp, err := paymentApp.FundApp.TransferBills(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("发起转账失败: %w", err)
|
||||
}
|
||||
if resp == nil {
|
||||
return nil, fmt.Errorf("转账返回为空")
|
||||
}
|
||||
// 微信返回 4xx 时 body 可能被解析到 resp,需根据 code 或 out_bill_no 判断是否成功
|
||||
if resp.Code != "" {
|
||||
msg := resp.Message
|
||||
if msg == "" {
|
||||
msg = resp.Code
|
||||
}
|
||||
return nil, fmt.Errorf("微信接口报错: %s", msg)
|
||||
}
|
||||
if resp.OutBillNo == "" {
|
||||
return nil, fmt.Errorf("微信未返回商户单号,可能请求被拒绝(如IP未加入白名单)")
|
||||
}
|
||||
result := &FundAppTransferResult{
|
||||
OutBillNo: resp.OutBillNo,
|
||||
TransferBillNo: resp.TransferBillNo,
|
||||
State: resp.State,
|
||||
PackageInfo: resp.PackageInfo,
|
||||
CreateTime: resp.CreateTime,
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// QueryTransferByOutBill 按商户单号查询单笔转账结果(FundApp 接口,用于 sync)
|
||||
func QueryTransferByOutBill(outBillNo string) (state, transferBillNo, failReason string, err error) {
|
||||
if paymentApp == nil || paymentApp.FundApp == nil {
|
||||
return "", "", "", fmt.Errorf("支付/转账未初始化")
|
||||
}
|
||||
ctx := context.Background()
|
||||
resp, err := paymentApp.FundApp.QueryOutBill(ctx, outBillNo)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
if resp == nil {
|
||||
return "", "", "", nil
|
||||
}
|
||||
return resp.State, resp.TransferBillNo, resp.FailReason, nil
|
||||
}
|
||||
120
soul-api/internal/wechat/transferv3/client.go
Normal file
120
soul-api/internal/wechat/transferv3/client.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package transferv3
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
const wechatAPIBase = "https://api.mch.weixin.qq.com"
|
||||
|
||||
func nonce() string {
|
||||
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
b := make([]byte, 32)
|
||||
_, _ = rand.Read(b)
|
||||
for i := range b {
|
||||
b[i] = chars[int(b[i])%len(chars)]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// Client 文档 V3 商家转账到零钱(签名 + HTTP)
|
||||
type Client struct {
|
||||
MchID string
|
||||
AppID string
|
||||
SerialNo string
|
||||
PrivateKey *rsa.PrivateKey
|
||||
BaseURL string
|
||||
}
|
||||
|
||||
// NewClient 使用已有私钥创建 Client
|
||||
func NewClient(mchID, appID, serialNo string, privateKey *rsa.PrivateKey) *Client {
|
||||
base := wechatAPIBase
|
||||
return &Client{
|
||||
MchID: mchID,
|
||||
AppID: appID,
|
||||
SerialNo: serialNo,
|
||||
PrivateKey: privateKey,
|
||||
BaseURL: base,
|
||||
}
|
||||
}
|
||||
|
||||
// LoadPrivateKeyFromPath 从 PEM 文件路径加载商户私钥
|
||||
func LoadPrivateKeyFromPath(path string) (*rsa.PrivateKey, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return LoadPrivateKeyFromPEM(data)
|
||||
}
|
||||
|
||||
// LoadPrivateKeyFromPEM 从 PEM 内容解析商户私钥(支持 PKCS#1 或 PKCS#8)
|
||||
func LoadPrivateKeyFromPEM(pemContent []byte) (*rsa.PrivateKey, error) {
|
||||
block, _ := pem.Decode(pemContent)
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("no PEM block found")
|
||||
}
|
||||
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||
if err == nil {
|
||||
return key, nil
|
||||
}
|
||||
k, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rsaKey, ok := k.(*rsa.PrivateKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("not RSA private key")
|
||||
}
|
||||
return rsaKey, nil
|
||||
}
|
||||
|
||||
// do 带签名的 HTTP 请求
|
||||
func (c *Client) do(method, path, body string) ([]byte, int, error) {
|
||||
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
nonceStr := nonce()
|
||||
signMsg := BuildSignMessage(method, path, timestamp, nonceStr, body)
|
||||
sig, err := Sign(signMsg, c.PrivateKey)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
auth := BuildAuthorization(c.MchID, nonceStr, sig, timestamp, c.SerialNo)
|
||||
|
||||
fullURL := c.BaseURL + path
|
||||
req, err := http.NewRequest(method, fullURL, bytes.NewBufferString(body))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req.Header.Set("Authorization", auth)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
return data, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// PostBatches 发起转账(文档:POST /v3/transfer/batches)
|
||||
func (c *Client) PostBatches(body []byte) ([]byte, int, error) {
|
||||
return c.do("POST", "/v3/transfer/batches", string(body))
|
||||
}
|
||||
|
||||
// GetTransferDetail 按商户批次单号、商户明细单号查询(文档:GET .../batch-id/{}/details/detail-id/{})
|
||||
func (c *Client) GetTransferDetail(outBatchNo, outDetailNo string) ([]byte, int, error) {
|
||||
path := "/v3/transfer/batches/batch-id/" + url.PathEscape(outBatchNo) +
|
||||
"/details/detail-id/" + url.PathEscape(outDetailNo)
|
||||
return c.do("GET", path, "")
|
||||
}
|
||||
52
soul-api/internal/wechat/transferv3/decrypt.go
Normal file
52
soul-api/internal/wechat/transferv3/decrypt.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package transferv3
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// DecryptResource 解密回调 resource(文档:AEAD_AES_256_GCM,密钥 APIv3 32 字节,密文=实际密文+16 字节 tag)
|
||||
func DecryptResource(ciphertextBase64, nonce, associatedData string, apiV3Key []byte) ([]byte, error) {
|
||||
if len(apiV3Key) != 32 {
|
||||
return nil, fmt.Errorf("apiV3 key must be 32 bytes, got %d", len(apiV3Key))
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(ciphertextBase64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("base64 decode: %w", err)
|
||||
}
|
||||
if len(raw) < 16 {
|
||||
return nil, fmt.Errorf("ciphertext too short")
|
||||
}
|
||||
tag := raw[len(raw)-16:]
|
||||
ctext := raw[:len(raw)-16]
|
||||
|
||||
block, err := aes.NewCipher(apiV3Key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plain, err := aead.Open(nil, []byte(nonce), append(ctext, tag...), []byte(associatedData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("aes-gcm decrypt: %w", err)
|
||||
}
|
||||
return plain, nil
|
||||
}
|
||||
|
||||
// DecryptResourceJSON 解密并解析为 JSON 对象(回调解密后的 resource)
|
||||
func DecryptResourceJSON(ciphertextBase64, nonce, associatedData string, apiV3Key []byte) (map[string]interface{}, error) {
|
||||
plain, err := DecryptResource(ciphertextBase64, nonce, associatedData, apiV3Key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out map[string]interface{}
|
||||
if err := json.Unmarshal(plain, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
48
soul-api/internal/wechat/transferv3/sign.go
Normal file
48
soul-api/internal/wechat/transferv3/sign.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package transferv3
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// BuildSignMessage 构建请求签名串(文档:请求方法\n请求URL路径\n时间戳\n随机串\n请求报文主体\n)
|
||||
func BuildSignMessage(method, urlPath, timestamp, nonce, body string) string {
|
||||
return method + "\n" + urlPath + "\n" + timestamp + "\n" + nonce + "\n" + body + "\n"
|
||||
}
|
||||
|
||||
// Sign 使用商户私钥 SHA256withRSA 签名,返回 Base64
|
||||
func Sign(signMessage string, privateKey *rsa.PrivateKey) (string, error) {
|
||||
h := sha256.Sum256([]byte(signMessage))
|
||||
sig, err := rsa.SignPKCS1v15(nil, privateKey, crypto.SHA256, h[:])
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("rsa sign: %w", err)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(sig), nil
|
||||
}
|
||||
|
||||
// BuildAuthorization 构建 Authorization 头(文档格式)
|
||||
func BuildAuthorization(mchID, nonce, signature, timestamp, serialNo string) string {
|
||||
return fmt.Sprintf(`WECHATPAY2-SHA256-RSA2048 mchid="%s",nonce_str="%s",signature="%s",timestamp="%s",serial_no="%s"`,
|
||||
mchID, nonce, signature, timestamp, serialNo)
|
||||
}
|
||||
|
||||
// BuildVerifyMessage 构建回调验签串(文档:时间戳\n随机串\n请求报文主体\n)
|
||||
func BuildVerifyMessage(timestamp, nonce, body string) string {
|
||||
return timestamp + "\n" + nonce + "\n" + body + "\n"
|
||||
}
|
||||
|
||||
// VerifySignature 使用平台公钥验证回调签名(Wechatpay-Signature 为 Base64)
|
||||
func VerifySignature(timestamp, nonce, body, signatureBase64 string, publicKey *rsa.PublicKey) bool {
|
||||
msg := BuildVerifyMessage(timestamp, nonce, body)
|
||||
sig, err := base64.StdEncoding.DecodeString(strings.TrimSpace(signatureBase64))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
h := sha256.Sum256([]byte(msg))
|
||||
err = rsa.VerifyPKCS1v15(publicKey, crypto.SHA256, h[:], sig)
|
||||
return err == nil
|
||||
}
|
||||
Reference in New Issue
Block a user