| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229 |
- package channel
- import (
- "bytes"
- "context"
- "crypto"
- "crypto/rand"
- "crypto/rsa"
- "crypto/sha256"
- "crypto/x509"
- "encoding/base64"
- "encoding/json"
- "encoding/pem"
- "errors"
- "fmt"
- "io"
- "math"
- "net/http"
- "os"
- "strings"
- "time"
- "wails-app/internal/config"
- )
- const (
- wechatAPIBase = "https://api.mch.weixin.qq.com"
- wechatNativePay = "/v3/pay/transactions/native"
- wechatQueryByID = "/v3/pay/transactions/out-trade-no/"
- )
- // WechatChannel 微信支付 Native 扫码(APIv3)。
- // 到账确认走查单接口(ADR-0002);notify_url 仅透传给微信做预留挂点,
- // 系统内没有回调端点。v1 未校验应答的微信平台签名(查询应答为明文 JSON),
- // 商户私钥签名请求已启用。
- type WechatChannel struct {
- appID string
- mchID string
- serialNo string
- privateKey *rsa.PrivateKey
- notifyURL string
- currency string
- client *http.Client
- }
- func NewWechatChannelFromConfig(cfg config.WechatPay) (*WechatChannel, error) {
- if !cfg.Enabled {
- return nil, errors.New("微信支付未启用")
- }
- if cfg.AppID == "" || cfg.MchID == "" || cfg.SerialNo == "" || cfg.PrivateKeyPath == "" {
- return nil, errors.New("微信支付商户配置不完整(app-id/mch-id/serial-no/private-key-path)")
- }
- pemBytes, err := os.ReadFile(cfg.PrivateKeyPath)
- if err != nil {
- return nil, fmt.Errorf("读取商户私钥失败: %w", err)
- }
- key, err := parsePrivateKey(pemBytes)
- if err != nil {
- return nil, err
- }
- currency := strings.ToUpper(strings.TrimSpace(cfg.Currency))
- if currency == "" {
- currency = "CNY"
- }
- return &WechatChannel{
- appID: cfg.AppID, mchID: cfg.MchID, serialNo: cfg.SerialNo,
- privateKey: key, notifyURL: cfg.NotifyURL, currency: currency,
- client: &http.Client{Timeout: 10 * time.Second},
- }, nil
- }
- func parsePrivateKey(pemBytes []byte) (*rsa.PrivateKey, error) {
- for {
- block, rest := pem.Decode(pemBytes)
- if block == nil {
- return nil, errors.New("商户私钥 PEM 解析失败")
- }
- if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
- return key, nil
- }
- if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil {
- if rsaKey, ok := key.(*rsa.PrivateKey); ok {
- return rsaKey, nil
- }
- return nil, errors.New("私钥不是 RSA 类型")
- }
- pemBytes = rest
- }
- }
- func (w *WechatChannel) Code() string { return ChannelWechat }
- type wechatNativeResp struct {
- CodeURL string `json:"code_url"`
- Message string `json:"message"`
- }
- func (w *WechatChannel) CreateOrder(ctx context.Context, req CreateOrderRequest) (CreateOrderResult, error) {
- total, err := yuanToFen(req.Amount)
- if err != nil {
- return CreateOrderResult{}, err
- }
- body := map[string]interface{}{
- "appid": w.appID,
- "mchid": w.mchID,
- "description": req.Description,
- "out_trade_no": req.OrderNo,
- "amount": map[string]interface{}{"total": total, "currency": w.currency},
- }
- if w.notifyURL != "" {
- body["notify_url"] = w.notifyURL
- }
- var resp wechatNativeResp
- if err := w.call(ctx, http.MethodPost, wechatNativePay, body, &resp); err != nil {
- return CreateOrderResult{}, err
- }
- if resp.CodeURL == "" {
- return CreateOrderResult{}, fmt.Errorf("微信下单未返回 code_url: %s", resp.Message)
- }
- return CreateOrderResult{CodeURL: resp.CodeURL}, nil
- }
- type wechatQueryResp struct {
- TradeState string `json:"trade_state"`
- TransactionID string `json:"transaction_id"`
- OutTradeNo string `json:"out_trade_no"`
- }
- func (w *WechatChannel) QueryOrder(ctx context.Context, orderNo string) (QueryOrderResult, error) {
- path := wechatQueryByID + orderNo + "?mchid=" + w.mchID
- var resp wechatQueryResp
- if err := w.call(ctx, http.MethodGet, path, nil, &resp); err != nil {
- return QueryOrderResult{}, err
- }
- switch resp.TradeState {
- case "SUCCESS":
- return QueryOrderResult{Status: QueryPaid, TransactionID: resp.TransactionID}, nil
- case "CLOSED", "REVOKED", "PAYERROR":
- return QueryOrderResult{Status: QueryClosed}, nil
- default: // NOTPAY, USERPAYING 等
- return QueryOrderResult{Status: QueryPending}, nil
- }
- }
- // call 发起微信 APIv3 请求并用商户私钥签名(WECHATPAY2-SHA256withRSA)。
- func (w *WechatChannel) call(ctx context.Context, method, path string, body interface{}, out interface{}) error {
- var bodyBytes []byte
- if body != nil {
- var err error
- bodyBytes, err = json.Marshal(body)
- if err != nil {
- return err
- }
- }
- req, err := http.NewRequestWithContext(ctx, method, wechatAPIBase+path, bytes.NewReader(bodyBytes))
- if err != nil {
- return err
- }
- authorization, err := w.authorizationHeader(method, path, bodyBytes)
- if err != nil {
- return err
- }
- req.Header.Set("Authorization", authorization)
- req.Header.Set("Content-Type", "application/json")
- req.Header.Set("Accept", "application/json")
- req.Header.Set("User-Agent", "smart-parking-exit-kiosk")
- resp, err := w.client.Do(req)
- if err != nil {
- return fmt.Errorf("微信支付请求失败: %w", err)
- }
- defer resp.Body.Close()
- respBytes, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
- if err != nil {
- return err
- }
- if resp.StatusCode != http.StatusOK {
- return fmt.Errorf("微信支付接口错误 HTTP %d: %s", resp.StatusCode, truncate(respBytes, 300))
- }
- if out != nil && len(respBytes) > 0 {
- if err := json.Unmarshal(respBytes, out); err != nil {
- return fmt.Errorf("微信支付应答解析失败: %w", err)
- }
- }
- return nil
- }
- func (w *WechatChannel) authorizationHeader(method, path string, body []byte) (string, error) {
- timestamp := fmt.Sprintf("%d", time.Now().Unix())
- nonce, err := randomNonce()
- if err != nil {
- return "", err
- }
- message := strings.Join([]string{method, path, timestamp, nonce, string(body)}, "\n") + "\n"
- digest := sha256.Sum256([]byte(message))
- signature, err := rsa.SignPKCS1v15(rand.Reader, w.privateKey, crypto.SHA256, digest[:])
- if err != nil {
- return "", fmt.Errorf("微信支付签名失败: %w", err)
- }
- return fmt.Sprintf(`WECHATPAY2-SHA256withRSA mchid="%s",nonce_str="%s",signature="%s",timestamp="%s",serial_no="%s"`,
- w.mchID, nonce, base64.StdEncoding.EncodeToString(signature), timestamp, w.serialNo), nil
- }
- func randomNonce() (string, error) {
- buf := make([]byte, 16)
- if _, err := rand.Read(buf); err != nil {
- return "", err
- }
- return base64.RawURLEncoding.EncodeToString(buf), nil
- }
- // yuanToFen 金额(元)转分,超出两位小数视为非法。
- func yuanToFen(amount float64) (int64, error) {
- if math.IsNaN(amount) || math.IsInf(amount, 0) || amount < 0 {
- return 0, errors.New("支付金额必须是有限的非负数")
- }
- fen := math.Round(amount * 100)
- if math.Abs(fen-amount*100) > 0.51 {
- return 0, fmt.Errorf("支付金额精度超限: %.4f", amount)
- }
- return int64(fen), nil
- }
- func truncate(b []byte, n int) string {
- if len(b) <= n {
- return string(b)
- }
- return string(b[:n]) + "..."
- }
|