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]) + "..." }