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

435 lines
13 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/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"
}
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
})
}
// 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 为接收人 openidamount 为提现金额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
}