fix: restore wechat payment oauth and jsapi flow

This commit is contained in:
IanShaw027
2026-04-20 23:34:57 +08:00
parent 6f00efa350
commit 7ef7fd19e7
16 changed files with 1563 additions and 87 deletions

View File

@@ -5,6 +5,8 @@ import (
"fmt"
"log/slog"
"math"
"net/url"
"os"
"strconv"
"strings"
"time"
@@ -57,11 +59,25 @@ func (s *PaymentService) CreateOrder(ctx context.Context, req CreateOrderRequest
feeRate := cfg.RechargeFeeRate
payAmountStr := payment.CalculatePayAmount(limitAmount, feeRate)
payAmount, _ := strconv.ParseFloat(payAmountStr, 64)
sel, err := s.selectCreateOrderInstance(ctx, req, cfg, payAmount)
if err != nil {
return nil, err
}
if err := s.validateSelectedCreateOrderInstance(ctx, req, sel); err != nil {
return nil, err
}
oauthResp, err := s.maybeBuildWeChatOAuthRequiredResponseForSelection(ctx, req, limitAmount, payAmount, feeRate, sel)
if err != nil {
return nil, err
}
if oauthResp != nil {
return oauthResp, nil
}
order, err := s.createOrderInTx(ctx, req, user, plan, cfg, orderAmount, limitAmount, feeRate, payAmount)
if err != nil {
return nil, err
}
resp, err := s.invokeProvider(ctx, order, req, cfg, limitAmount, payAmountStr, payAmount, plan)
resp, err := s.invokeProvider(ctx, order, req, cfg, limitAmount, payAmountStr, payAmount, plan, sel)
if err != nil {
_, _ = s.entClient.PaymentOrder.UpdateOneID(order.ID).
SetStatus(OrderStatusFailed).
@@ -199,9 +215,7 @@ func (s *PaymentService) checkDailyLimit(ctx context.Context, tx *dbent.Tx, user
return nil
}
func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.PaymentOrder, req CreateOrderRequest, cfg *PaymentConfig, limitAmount float64, payAmountStr string, payAmount float64, plan *dbent.SubscriptionPlan) (*CreateOrderResponse, error) {
// Select an instance across all providers that support the requested payment type.
// This enables cross-provider load balancing (e.g. EasyPay + Alipay direct for "alipay").
func (s *PaymentService) selectCreateOrderInstance(ctx context.Context, req CreateOrderRequest, cfg *PaymentConfig, payAmount float64) (*payment.InstanceSelection, error) {
sel, err := s.loadBalancer.SelectInstance(ctx, "", req.PaymentType, payment.Strategy(cfg.LoadBalanceStrategy), payAmount)
if err != nil {
return nil, infraerrors.ServiceUnavailable("PAYMENT_GATEWAY_ERROR", fmt.Sprintf("payment method (%s) is not configured", req.PaymentType))
@@ -209,6 +223,10 @@ func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.Paymen
if sel == nil {
return nil, infraerrors.TooManyRequests("NO_AVAILABLE_INSTANCE", "no available payment instance")
}
return sel, nil
}
func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.PaymentOrder, req CreateOrderRequest, cfg *PaymentConfig, limitAmount float64, payAmountStr string, payAmount float64, plan *dbent.SubscriptionPlan, sel *payment.InstanceSelection) (*CreateOrderResponse, error) {
prov, err := provider.CreateProvider(sel.ProviderKey, sel.InstanceID, sel.Config)
if err != nil {
return nil, infraerrors.ServiceUnavailable("PAYMENT_GATEWAY_ERROR", "payment method is temporarily unavailable")
@@ -237,19 +255,17 @@ func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.Paymen
if err != nil {
return nil, err
}
pr, err := prov.CreatePayment(ctx, payment.CreatePaymentRequest{
OrderID: outTradeNo,
Amount: payAmountStr,
PaymentType: req.PaymentType,
Subject: subject,
ReturnURL: providerReturnURL,
ClientIP: req.ClientIP,
IsMobile: req.IsMobile,
InstanceSubMethods: sel.SupportedTypes,
})
providerReq := buildProviderCreatePaymentRequest(CreateOrderRequest{
PaymentType: req.PaymentType,
OpenID: req.OpenID,
ClientIP: req.ClientIP,
IsMobile: req.IsMobile,
ReturnURL: providerReturnURL,
}, sel, outTradeNo, payAmountStr, subject)
pr, err := prov.CreatePayment(ctx, providerReq)
if err != nil {
slog.Error("[PaymentService] CreatePayment failed", "provider", sel.ProviderKey, "instance", sel.InstanceID, "error", err)
return nil, infraerrors.ServiceUnavailable("PAYMENT_GATEWAY_ERROR", fmt.Sprintf("payment gateway error: %s", err.Error()))
return nil, classifyCreatePaymentError(req, sel.ProviderKey, err)
}
_, err = s.entClient.PaymentOrder.UpdateOneID(order.ID).
SetNillablePaymentTradeNo(psNilIfEmpty(pr.TradeNo)).
@@ -269,20 +285,34 @@ func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.Paymen
"orderType": req.OrderType,
"paymentSource": NormalizePaymentSource(req.PaymentSource),
})
return &CreateOrderResponse{
OrderID: order.ID,
Amount: order.Amount,
PayAmount: payAmount,
FeeRate: order.FeeRate,
Status: OrderStatusPending,
PaymentType: req.PaymentType,
PayURL: pr.PayURL,
QRCode: pr.QRCode,
ClientSecret: pr.ClientSecret,
ExpiresAt: order.ExpiresAt,
PaymentMode: sel.PaymentMode,
ResumeToken: resumeToken,
}, nil
resultType := pr.ResultType
if resultType == "" {
resultType = payment.CreatePaymentResultOrderCreated
}
resp := buildCreateOrderResponse(order, req, payAmount, sel, pr, resultType)
resp.ResumeToken = resumeToken
return resp, nil
}
func buildProviderCreatePaymentRequest(req CreateOrderRequest, sel *payment.InstanceSelection, orderID, amount, subject string) payment.CreatePaymentRequest {
return payment.CreatePaymentRequest{
OrderID: orderID,
Amount: amount,
PaymentType: req.PaymentType,
Subject: subject,
ReturnURL: req.ReturnURL,
OpenID: strings.TrimSpace(req.OpenID),
ClientIP: req.ClientIP,
IsMobile: req.IsMobile,
InstanceSubMethods: selectedInstanceSupportedTypes(sel),
}
}
func selectedInstanceSupportedTypes(sel *payment.InstanceSelection) string {
if sel == nil {
return ""
}
return sel.SupportedTypes
}
func (s *PaymentService) buildPaymentSubject(plan *dbent.SubscriptionPlan, limitAmount float64, cfg *PaymentConfig) string {
@@ -301,6 +331,183 @@ func (s *PaymentService) buildPaymentSubject(plan *dbent.SubscriptionPlan, limit
return "Sub2API " + amountStr + " CNY"
}
func (s *PaymentService) maybeBuildWeChatOAuthRequiredResponse(ctx context.Context, req CreateOrderRequest, amount, payAmount, feeRate float64) (*CreateOrderResponse, error) {
return s.maybeBuildWeChatOAuthRequiredResponseForSelection(ctx, req, amount, payAmount, feeRate, nil)
}
func (s *PaymentService) maybeBuildWeChatOAuthRequiredResponseForSelection(ctx context.Context, req CreateOrderRequest, amount, payAmount, feeRate float64, sel *payment.InstanceSelection) (*CreateOrderResponse, error) {
if sel != nil && sel.ProviderKey != "" && sel.ProviderKey != payment.TypeWxpay {
return nil, nil
}
if strings.TrimSpace(req.OpenID) != "" || !req.IsWeChatBrowser || payment.GetBasePaymentType(req.PaymentType) != payment.TypeWxpay {
return nil, nil
}
return s.buildWeChatOAuthRequiredResponse(ctx, req, amount, payAmount, feeRate)
}
func (s *PaymentService) buildWeChatOAuthRequiredResponse(ctx context.Context, req CreateOrderRequest, amount, payAmount, feeRate float64) (*CreateOrderResponse, error) {
appID, _, err := s.getWeChatPaymentOAuthCredential(ctx)
if err != nil {
return nil, err
}
authorizeURL, err := buildWeChatPaymentOAuthStartURL(req, "snsapi_base")
if err != nil {
return nil, err
}
return &CreateOrderResponse{
Amount: amount,
PayAmount: payAmount,
FeeRate: feeRate,
ResultType: payment.CreatePaymentResultOAuthRequired,
PaymentType: req.PaymentType,
OAuth: &payment.WechatOAuthInfo{
AuthorizeURL: authorizeURL,
AppID: appID,
Scope: "snsapi_base",
RedirectURL: "/auth/wechat/payment/callback",
},
}, nil
}
func (s *PaymentService) validateSelectedCreateOrderInstance(ctx context.Context, req CreateOrderRequest, sel *payment.InstanceSelection) error {
if !requiresWeChatJSAPICompatibleSelection(req, sel) {
return nil
}
expectedAppID, _, err := s.getWeChatPaymentOAuthCredential(ctx)
if err != nil {
return err
}
selectedAppID := provider.ResolveWxpayJSAPIAppID(sel.Config)
if selectedAppID == "" || selectedAppID != expectedAppID {
return infraerrors.TooManyRequests("NO_AVAILABLE_INSTANCE", "selected payment instance is not compatible with the current WeChat OAuth app")
}
return nil
}
func requiresWeChatJSAPICompatibleSelection(req CreateOrderRequest, sel *payment.InstanceSelection) bool {
if sel == nil || sel.ProviderKey != payment.TypeWxpay || payment.GetBasePaymentType(req.PaymentType) != payment.TypeWxpay {
return false
}
return req.IsWeChatBrowser || strings.TrimSpace(req.OpenID) != ""
}
func (s *PaymentService) getWeChatPaymentOAuthCredential(context.Context) (string, string, error) {
appID := strings.TrimSpace(os.Getenv("WECHAT_OAUTH_MP_APP_ID"))
appSecret := strings.TrimSpace(os.Getenv("WECHAT_OAUTH_MP_APP_SECRET"))
if appID == "" || appSecret == "" {
return "", "", infraerrors.ServiceUnavailable(
"WECHAT_PAYMENT_MP_NOT_CONFIGURED",
"wechat in-app payment requires a complete WeChat MP OAuth credential",
)
}
return appID, appSecret, nil
}
func classifyCreatePaymentError(req CreateOrderRequest, providerKey string, err error) error {
if err == nil {
return nil
}
if providerKey == payment.TypeWxpay &&
payment.GetBasePaymentType(req.PaymentType) == payment.TypeWxpay &&
strings.Contains(err.Error(), "wxpay h5 payments are not authorized for this merchant") {
return infraerrors.ServiceUnavailable(
"WECHAT_H5_NOT_AUTHORIZED",
"wechat h5 payment is not available for this merchant",
).WithMetadata(map[string]string{
"action": "open_in_wechat_or_scan_qr",
})
}
return infraerrors.ServiceUnavailable("PAYMENT_GATEWAY_ERROR", fmt.Sprintf("payment gateway error: %s", err.Error()))
}
func buildCreateOrderResponse(order *dbent.PaymentOrder, req CreateOrderRequest, payAmount float64, sel *payment.InstanceSelection, pr *payment.CreatePaymentResponse, resultType payment.CreatePaymentResultType) *CreateOrderResponse {
return &CreateOrderResponse{
OrderID: order.ID,
Amount: order.Amount,
PayAmount: payAmount,
FeeRate: order.FeeRate,
Status: OrderStatusPending,
ResultType: resultType,
PaymentType: req.PaymentType,
OutTradeNo: order.OutTradeNo,
PayURL: pr.PayURL,
QRCode: pr.QRCode,
ClientSecret: pr.ClientSecret,
OAuth: pr.OAuth,
JSAPI: pr.JSAPI,
JSAPIPayload: pr.JSAPI,
ExpiresAt: order.ExpiresAt,
PaymentMode: sel.PaymentMode,
}
}
func buildWeChatPaymentOAuthStartURL(req CreateOrderRequest, scope string) (string, error) {
u, err := url.Parse("/api/v1/auth/oauth/wechat/payment/start")
if err != nil {
return "", fmt.Errorf("build wechat payment oauth start url: %w", err)
}
q := u.Query()
q.Set("payment_type", strings.TrimSpace(req.PaymentType))
if req.Amount > 0 {
q.Set("amount", strconv.FormatFloat(req.Amount, 'f', -1, 64))
}
if orderType := strings.TrimSpace(req.OrderType); orderType != "" {
q.Set("order_type", orderType)
}
if req.PlanID > 0 {
q.Set("plan_id", strconv.FormatInt(req.PlanID, 10))
}
if scope = strings.TrimSpace(scope); scope != "" {
q.Set("scope", scope)
}
if redirectTo := paymentRedirectPathFromURL(req.SrcURL); redirectTo != "" {
q.Set("redirect", redirectTo)
}
u.RawQuery = q.Encode()
return u.String(), nil
}
func paymentRedirectPathFromURL(rawURL string) string {
rawURL = strings.TrimSpace(rawURL)
if rawURL == "" {
return "/purchase"
}
if strings.HasPrefix(rawURL, "/") && !strings.HasPrefix(rawURL, "//") {
return normalizePaymentRedirectPath(rawURL)
}
u, err := url.Parse(rawURL)
if err != nil {
return "/purchase"
}
path := strings.TrimSpace(u.EscapedPath())
if path == "" {
path = strings.TrimSpace(u.Path)
}
if path == "" || !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "//") {
return "/purchase"
}
if strings.TrimSpace(u.RawQuery) != "" {
path += "?" + u.RawQuery
}
return normalizePaymentRedirectPath(path)
}
func normalizePaymentRedirectPath(path string) string {
path = strings.TrimSpace(path)
if path == "" {
return "/purchase"
}
if path == "/payment" {
return "/purchase"
}
if strings.HasPrefix(path, "/payment?") {
return "/purchase" + strings.TrimPrefix(path, "/payment")
}
return path
}
// --- Order Queries ---
func (s *PaymentService) GetOrder(ctx context.Context, orderID, userID int64) (*dbent.PaymentOrder, error) {

View File

@@ -0,0 +1,177 @@
package service
import (
"context"
"testing"
"time"
dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/internal/payment"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
)
func TestBuildCreateOrderResponseDefaultsToOrderCreated(t *testing.T) {
t.Parallel()
expiresAt := time.Date(2026, 4, 16, 12, 0, 0, 0, time.UTC)
resp := buildCreateOrderResponse(
&dbent.PaymentOrder{
ID: 42,
Amount: 12.34,
FeeRate: 0.03,
ExpiresAt: expiresAt,
OutTradeNo: "sub2_42",
},
CreateOrderRequest{PaymentType: payment.TypeWxpay},
12.71,
&payment.InstanceSelection{PaymentMode: "qrcode"},
&payment.CreatePaymentResponse{
TradeNo: "sub2_42",
QRCode: "weixin://wxpay/bizpayurl?pr=test",
},
payment.CreatePaymentResultOrderCreated,
)
if resp.ResultType != payment.CreatePaymentResultOrderCreated {
t.Fatalf("result type = %q, want %q", resp.ResultType, payment.CreatePaymentResultOrderCreated)
}
if resp.OutTradeNo != "sub2_42" {
t.Fatalf("out_trade_no = %q, want %q", resp.OutTradeNo, "sub2_42")
}
if resp.QRCode != "weixin://wxpay/bizpayurl?pr=test" {
t.Fatalf("qr_code = %q, want %q", resp.QRCode, "weixin://wxpay/bizpayurl?pr=test")
}
if resp.JSAPI != nil || resp.JSAPIPayload != nil {
t.Fatal("order_created response should not include jsapi payload")
}
if !resp.ExpiresAt.Equal(expiresAt) {
t.Fatalf("expires_at = %v, want %v", resp.ExpiresAt, expiresAt)
}
}
func TestBuildCreateOrderResponseCopiesJSAPIPayload(t *testing.T) {
t.Parallel()
jsapiPayload := &payment.WechatJSAPIPayload{
AppID: "wx123",
TimeStamp: "1712345678",
NonceStr: "nonce-123",
Package: "prepay_id=wx123",
SignType: "RSA",
PaySign: "signed-payload",
}
resp := buildCreateOrderResponse(
&dbent.PaymentOrder{
ID: 88,
Amount: 66.88,
FeeRate: 0.01,
ExpiresAt: time.Date(2026, 4, 16, 13, 0, 0, 0, time.UTC),
OutTradeNo: "sub2_88",
},
CreateOrderRequest{PaymentType: payment.TypeWxpay},
67.55,
&payment.InstanceSelection{PaymentMode: "popup"},
&payment.CreatePaymentResponse{
TradeNo: "sub2_88",
ResultType: payment.CreatePaymentResultJSAPIReady,
JSAPI: jsapiPayload,
},
payment.CreatePaymentResultJSAPIReady,
)
if resp.ResultType != payment.CreatePaymentResultJSAPIReady {
t.Fatalf("result type = %q, want %q", resp.ResultType, payment.CreatePaymentResultJSAPIReady)
}
if resp.JSAPI == nil || resp.JSAPIPayload == nil {
t.Fatal("expected jsapi payload aliases to be populated")
}
if resp.JSAPI != jsapiPayload || resp.JSAPIPayload != jsapiPayload {
t.Fatal("expected jsapi aliases to preserve the original pointer")
}
}
func TestMaybeBuildWeChatOAuthRequiredResponse(t *testing.T) {
t.Setenv("WECHAT_OAUTH_MP_APP_ID", "wx123456")
t.Setenv("WECHAT_OAUTH_MP_APP_SECRET", "wechat-secret")
svc := &PaymentService{}
resp, err := svc.maybeBuildWeChatOAuthRequiredResponse(context.Background(), CreateOrderRequest{
Amount: 12.5,
PaymentType: payment.TypeWxpay,
IsWeChatBrowser: true,
SrcURL: "https://merchant.example/payment?from=wechat",
OrderType: payment.OrderTypeBalance,
}, 12.5, 12.88, 0.03)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp == nil {
t.Fatal("expected oauth_required response, got nil")
}
if resp.ResultType != payment.CreatePaymentResultOAuthRequired {
t.Fatalf("result type = %q, want %q", resp.ResultType, payment.CreatePaymentResultOAuthRequired)
}
if resp.OAuth == nil {
t.Fatal("expected oauth payload, got nil")
}
if resp.OAuth.AppID != "wx123456" {
t.Fatalf("appid = %q, want %q", resp.OAuth.AppID, "wx123456")
}
if resp.OAuth.Scope != "snsapi_base" {
t.Fatalf("scope = %q, want %q", resp.OAuth.Scope, "snsapi_base")
}
if resp.OAuth.RedirectURL != "/auth/wechat/payment/callback" {
t.Fatalf("redirect_url = %q, want %q", resp.OAuth.RedirectURL, "/auth/wechat/payment/callback")
}
if resp.OAuth.AuthorizeURL != "/api/v1/auth/oauth/wechat/payment/start?amount=12.5&order_type=balance&payment_type=wxpay&redirect=%2Fpurchase%3Ffrom%3Dwechat&scope=snsapi_base" {
t.Fatalf("authorize_url = %q", resp.OAuth.AuthorizeURL)
}
}
func TestMaybeBuildWeChatOAuthRequiredResponseRequiresMPConfigInWeChat(t *testing.T) {
t.Parallel()
svc := &PaymentService{}
resp, err := svc.maybeBuildWeChatOAuthRequiredResponse(context.Background(), CreateOrderRequest{
Amount: 12.5,
PaymentType: payment.TypeWxpay,
IsWeChatBrowser: true,
SrcURL: "https://merchant.example/payment?from=wechat",
OrderType: payment.OrderTypeBalance,
}, 12.5, 12.88, 0.03)
if resp != nil {
t.Fatalf("expected nil response, got %+v", resp)
}
if err == nil {
t.Fatal("expected error, got nil")
}
appErr := infraerrors.FromError(err)
if appErr.Reason != "WECHAT_PAYMENT_MP_NOT_CONFIGURED" {
t.Fatalf("reason = %q, want %q", appErr.Reason, "WECHAT_PAYMENT_MP_NOT_CONFIGURED")
}
}
func TestMaybeBuildWeChatOAuthRequiredResponseForSelectionSkipsEasyPayProvider(t *testing.T) {
t.Setenv("WECHAT_OAUTH_MP_APP_ID", "wx123456")
t.Setenv("WECHAT_OAUTH_MP_APP_SECRET", "wechat-secret")
svc := &PaymentService{}
resp, err := svc.maybeBuildWeChatOAuthRequiredResponseForSelection(context.Background(), CreateOrderRequest{
Amount: 12.5,
PaymentType: payment.TypeWxpay,
IsWeChatBrowser: true,
OrderType: payment.OrderTypeBalance,
}, 12.5, 12.88, 0.03, &payment.InstanceSelection{
ProviderKey: payment.TypeEasyPay,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp != nil {
t.Fatalf("expected nil response, got %+v", resp)
}
}

View File

@@ -64,32 +64,39 @@ func generateRandomString(n int) string {
}
type CreateOrderRequest struct {
UserID int64
Amount float64
PaymentType string
ClientIP string
IsMobile bool
SrcHost string
SrcURL string
ReturnURL string
PaymentSource string
OrderType string
PlanID int64
UserID int64
Amount float64
PaymentType string
OpenID string
ClientIP string
IsMobile bool
IsWeChatBrowser bool
SrcHost string
SrcURL string
ReturnURL string
PaymentSource string
OrderType string
PlanID int64
}
type CreateOrderResponse struct {
OrderID int64 `json:"order_id"`
Amount float64 `json:"amount"`
PayAmount float64 `json:"pay_amount"`
FeeRate float64 `json:"fee_rate"`
Status string `json:"status"`
PaymentType string `json:"payment_type"`
PayURL string `json:"pay_url,omitempty"`
QRCode string `json:"qr_code,omitempty"`
ClientSecret string `json:"client_secret,omitempty"`
ExpiresAt time.Time `json:"expires_at"`
PaymentMode string `json:"payment_mode,omitempty"`
ResumeToken string `json:"resume_token,omitempty"`
OrderID int64 `json:"order_id"`
Amount float64 `json:"amount"`
PayAmount float64 `json:"pay_amount"`
FeeRate float64 `json:"fee_rate"`
Status string `json:"status"`
ResultType payment.CreatePaymentResultType `json:"result_type,omitempty"`
PaymentType string `json:"payment_type"`
OutTradeNo string `json:"out_trade_no,omitempty"`
PayURL string `json:"pay_url,omitempty"`
QRCode string `json:"qr_code,omitempty"`
ClientSecret string `json:"client_secret,omitempty"`
OAuth *payment.WechatOAuthInfo `json:"oauth,omitempty"`
JSAPI *payment.WechatJSAPIPayload `json:"jsapi,omitempty"`
JSAPIPayload *payment.WechatJSAPIPayload `json:"jsapi_payload,omitempty"`
ExpiresAt time.Time `json:"expires_at"`
PaymentMode string `json:"payment_mode,omitempty"`
ResumeToken string `json:"resume_token,omitempty"`
}
type OrderListParams struct {