// Package payment 是充值渠道适配层(设计见 PAYMENT_DESIGN.md §3/§5)。 // P5.1 的兑换码不走这里(无「待支付」态,核销即入账);本包面向真渠道: // 下单出支付凭据 → 回调/查单确认 → 上层 MarkOrderPaid 幂等入账。 package payment import ( "context" "errors" "fmt" "log" "net/http" "os" "github.com/wechatpay-apiv3/wechatpay-go/core" "github.com/wechatpay-apiv3/wechatpay-go/core/auth/verifiers" "github.com/wechatpay-apiv3/wechatpay-go/core/downloader" "github.com/wechatpay-apiv3/wechatpay-go/core/notify" "github.com/wechatpay-apiv3/wechatpay-go/core/option" "github.com/wechatpay-apiv3/wechatpay-go/services/payments" "github.com/wechatpay-apiv3/wechatpay-go/services/payments/native" "github.com/wechatpay-apiv3/wechatpay-go/utils" ) // Wechat 微信支付 Native(扫码)适配器。凭据一律 env 注入: // // WECHAT_MCHID 商户号 // WECHAT_MCH_CERT_SERIAL 商户 API 证书序列号 // WECHAT_MCH_PRIVATE_KEY 商户 API 私钥文件路径(apiclient_key.pem) // WECHAT_APIV3_KEY APIv3 密钥(32 字节) // WECHAT_APPID 关联的公众号/小程序/APP 的 appid // WECHAT_NOTIFY_URL 支付回调地址(须公网 https,如 https://api.example.com/api/v1/billing/callback/wechat) // // 任一缺失 → NewWechatFromEnv 返回 nil,渠道自动隐藏(半配置状态不许把下单路由搞出 5xx)。 // 本地开发收不到公网回调没关系:前端轮询的 GET /billing/orders/:id 会主动查单确认, // 回调只是生产环境更快的到账通道,两条路都汇入同一个幂等入账闸。 type Wechat struct { mchID string appID string notifyURL string apiv3Key string client *core.Client svc native.NativeApiService } // NewWechatFromEnv 依据环境变量装配微信渠道;未配置(或配置不全/私钥读不了)返回 nil。 func NewWechatFromEnv(ctx context.Context) *Wechat { mchID := os.Getenv("WECHAT_MCHID") serial := os.Getenv("WECHAT_MCH_CERT_SERIAL") keyPath := os.Getenv("WECHAT_MCH_PRIVATE_KEY") apiv3 := os.Getenv("WECHAT_APIV3_KEY") appID := os.Getenv("WECHAT_APPID") notifyURL := os.Getenv("WECHAT_NOTIFY_URL") if mchID == "" && serial == "" && keyPath == "" && apiv3 == "" { return nil // 完全未配置:静默(大多数开发环境) } if mchID == "" || serial == "" || keyPath == "" || apiv3 == "" || appID == "" || notifyURL == "" { log.Printf("[payment] 微信支付配置不全(MCHID/CERT_SERIAL/PRIVATE_KEY/APIV3_KEY/APPID/NOTIFY_URL 缺一不可),渠道保持隐藏") return nil } priv, err := utils.LoadPrivateKeyWithPath(keyPath) if err != nil { log.Printf("[payment] 微信商户私钥加载失败(%s),渠道保持隐藏: %v", keyPath, err) return nil } client, err := core.NewClient(ctx, option.WithWechatPayAutoAuthCipher(mchID, serial, priv, apiv3)) if err != nil { log.Printf("[payment] 微信支付客户端初始化失败,渠道保持隐藏: %v", err) return nil } log.Printf("[payment] 微信支付 Native 渠道已启用 (mchid=%s)", mchID) return &Wechat{ mchID: mchID, appID: appID, notifyURL: notifyURL, apiv3Key: apiv3, client: client, svc: native.NativeApiService{Client: client}, } } // CreatePay Native 下单:返回 code_url(前端渲染成二维码)。金额取订单锁定值。 func (w *Wechat) CreatePay(ctx context.Context, orderID, description string, amountFen int64) (string, error) { resp, _, err := w.svc.Prepay(ctx, native.PrepayRequest{ Appid: core.String(w.appID), Mchid: core.String(w.mchID), Description: core.String(description), OutTradeNo: core.String(orderID), NotifyUrl: core.String(w.notifyURL), Amount: &native.Amount{Total: core.Int64(amountFen), Currency: core.String("CNY")}, }) if err != nil { return "", err } if resp.CodeUrl == nil || *resp.CodeUrl == "" { return "", errors.New("微信未返回 code_url") } return *resp.CodeUrl, nil } // QueryResult 查单/回调解析后的统一结果。 type QueryResult struct { OrderID string // out_trade_no ChannelTxn string // transaction_id Paid bool // TradeState == SUCCESS Closed bool // CLOSED/REVOKED/PAYERROR 等终态失败 AmountFen int64 // 用户实付(分);回调/查单都带,供金额核对 } func fromTransaction(t *payments.Transaction) QueryResult { r := QueryResult{} if t.OutTradeNo != nil { r.OrderID = *t.OutTradeNo } if t.TransactionId != nil { r.ChannelTxn = *t.TransactionId } if t.Amount != nil && t.Amount.PayerTotal != nil { r.AmountFen = *t.Amount.PayerTotal } else if t.Amount != nil && t.Amount.Total != nil { r.AmountFen = *t.Amount.Total } if t.TradeState != nil { switch *t.TradeState { case "SUCCESS": r.Paid = true case "CLOSED", "REVOKED", "PAYERROR": r.Closed = true } } return r } // QueryOrder 主动查单(本地开发确认到账、生产掉单补偿共用)。 func (w *Wechat) QueryOrder(ctx context.Context, orderID string) (QueryResult, error) { t, _, err := w.svc.QueryOrderByOutTradeNo(ctx, native.QueryOrderByOutTradeNoRequest{ OutTradeNo: core.String(orderID), Mchid: core.String(w.mchID), }) if err != nil { return QueryResult{}, err } return fromTransaction(t), nil } // VerifyCallback 验签 + 解密支付回调(APIv3:平台证书验签、AES-GCM 解密资源)。 // 验签失败一律拒绝——回调路由是公开的,签名是唯一的门。 func (w *Wechat) VerifyCallback(req *http.Request) (QueryResult, error) { certVisitor := downloader.MgrInstance().GetCertificateVisitor(w.mchID) h, err := notify.NewRSANotifyHandler(w.apiv3Key, verifiers.NewSHA256WithRSAVerifier(certVisitor)) if err != nil { return QueryResult{}, fmt.Errorf("回调处理器初始化失败: %w", err) } txn := new(payments.Transaction) if _, err := h.ParseNotifyRequest(req.Context(), req, txn); err != nil { return QueryResult{}, err } return fromTransaction(txn), nil }