393 lines
9.4 KiB
Go
393 lines
9.4 KiB
Go
package wechat
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"crypto/md5"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"strconv"
|
||
"time"
|
||
|
||
"soul-api/internal/config"
|
||
|
||
"github.com/ArtisanCloud/PowerWeChat/v3/src/miniProgram"
|
||
"github.com/ArtisanCloud/PowerWeChat/v3/src/payment"
|
||
)
|
||
|
||
var (
|
||
miniProgramApp *miniProgram.MiniProgram
|
||
paymentApp *payment.Payment
|
||
cfg *config.Config
|
||
)
|
||
|
||
// Init 初始化微信客户端
|
||
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)
|
||
}
|
||
|
||
// 初始化支付(v2)
|
||
paymentApp, err = payment.NewPayment(&payment.UserConfig{
|
||
AppID: cfg.WechatAppID,
|
||
MchID: cfg.WechatMchID,
|
||
Key: cfg.WechatMchKey,
|
||
HttpDebug: cfg.Mode == "debug",
|
||
})
|
||
if err != nil {
|
||
return fmt.Errorf("初始化支付失败: %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
|
||
}
|
||
|
||
// 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)))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("请求统一下单接口失败: %w", 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 result["result_code"] != "SUCCESS" {
|
||
return nil, fmt.Errorf("下单失败: %s", result["err_code_des"])
|
||
}
|
||
|
||
return result, 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(),
|
||
}
|
||
|
||
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)))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("请求订单查询接口失败: %w", 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)
|
||
}
|
||
}
|
||
|
||
// 简单冒泡排序
|
||
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
|
||
}
|
||
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
|
||
}
|
||
|
||
// XMLToMap 导出供外部使用
|
||
func XMLToMap(xmlStr string) map[string]string {
|
||
return xmlToMap(xmlStr)
|
||
}
|
||
|
||
// GenerateOrderSn 生成订单号
|
||
func GenerateOrderSn() string {
|
||
now := time.Now()
|
||
timestamp := now.Format("20060102150405")
|
||
random := now.UnixNano() % 1000000
|
||
return fmt.Sprintf("MP%s%06d", timestamp, random)
|
||
}
|