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 为接收人 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 }