Files
soul-yongping/soul-api/internal/wechat/miniprogram.go

374 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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/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 (
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"
}
if len(scene) > 32 {
scene = scene[:32]
}
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": "trial", // 体验版,正式发布后改为 release
}
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)
// 检查是否是 JSON 错误返回
if resp.Header.Get("Content-Type") == "application/json" {
var errResult struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if err := json.Unmarshal(body, &errResult); err == 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
})
}
// GenerateOrderSn 生成订单号
func GenerateOrderSn() string {
now := time.Now()
timestamp := now.Format("20060102150405")
random := now.UnixNano() % 1000000
return fmt.Sprintf("MP%s%06d", timestamp, random)
}