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

@ -9,12 +9,14 @@ import (
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/authidentity"
"github.com/Wei-Shaw/sub2api/ent/authidentitychannel"
"github.com/Wei-Shaw/sub2api/internal/payment"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/oauth"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
@ -35,6 +37,13 @@ const (
wechatOAuthDefaultFrontendCB = "/auth/wechat/callback"
wechatOAuthProviderKey = "wechat-main"
wechatOAuthLegacyProviderKey = "wechat"
wechatPaymentOAuthCookiePath = "/api/v1/auth/oauth/wechat/payment"
wechatPaymentOAuthStateName = "wechat_payment_oauth_state"
wechatPaymentOAuthRedirect = "wechat_payment_oauth_redirect"
wechatPaymentOAuthContextName = "wechat_payment_oauth_context"
wechatPaymentOAuthScope = "wechat_payment_oauth_scope"
wechatPaymentOAuthDefaultTo = "/purchase"
wechatPaymentOAuthFrontendCB = "/auth/wechat/payment/callback"
wechatOAuthIntentLogin = "login"
wechatOAuthIntentBind = "bind_current_user"
@ -76,6 +85,13 @@ type wechatOAuthUserInfoResponse struct {
ErrMsg string `json:"errmsg"`
}
type wechatPaymentOAuthContext struct {
PaymentType string `json:"payment_type"`
Amount string `json:"amount,omitempty"`
OrderType string `json:"order_type,omitempty"`
PlanID int64 `json:"plan_id,omitempty"`
}
// WeChatOAuthStart starts the WeChat OAuth login flow and stores the short-lived
// browser cookies required by the rebuild pending-auth bridge.
func (h *AuthHandler) WeChatOAuthStart(c *gin.Context) {
@ -294,6 +310,149 @@ func (h *AuthHandler) WeChatOAuthCallback(c *gin.Context) {
redirectToFrontendCallback(c, frontendCallback)
}
// WeChatPaymentOAuthStart starts the WeChat payment OAuth flow.
// GET /api/v1/auth/oauth/wechat/payment/start?payment_type=wxpay&redirect=/purchase
func (h *AuthHandler) WeChatPaymentOAuthStart(c *gin.Context) {
cfg, err := h.getWeChatOAuthConfig(c.Request.Context(), "mp", c)
if err != nil {
response.ErrorFrom(c, err)
return
}
paymentType := normalizeWeChatPaymentType(c.Query("payment_type"))
if paymentType == "" {
response.BadRequest(c, "Invalid payment type")
return
}
state, err := oauth.GenerateState()
if err != nil {
response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_STATE_GEN_FAILED", "failed to generate oauth state").WithCause(err))
return
}
redirectTo := normalizeWeChatPaymentRedirectPath(sanitizeFrontendRedirectPath(c.Query("redirect")))
if redirectTo == "" {
redirectTo = wechatPaymentOAuthDefaultTo
}
rawContext, err := encodeWeChatPaymentOAuthContext(wechatPaymentOAuthContext{
PaymentType: paymentType,
Amount: strings.TrimSpace(c.Query("amount")),
OrderType: strings.TrimSpace(c.Query("order_type")),
PlanID: parseWeChatPaymentPlanID(c.Query("plan_id")),
})
if err != nil {
response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_CONTEXT_ENCODE_FAILED", "failed to encode oauth context").WithCause(err))
return
}
scope := normalizeWeChatPaymentScope(c.Query("scope"))
secureCookie := isRequestHTTPS(c)
wechatPaymentSetCookie(c, wechatPaymentOAuthStateName, encodeCookieValue(state), wechatOAuthCookieMaxAgeSec, secureCookie)
wechatPaymentSetCookie(c, wechatPaymentOAuthRedirect, encodeCookieValue(redirectTo), wechatOAuthCookieMaxAgeSec, secureCookie)
wechatPaymentSetCookie(c, wechatPaymentOAuthContextName, encodeCookieValue(rawContext), wechatOAuthCookieMaxAgeSec, secureCookie)
wechatPaymentSetCookie(c, wechatPaymentOAuthScope, encodeCookieValue(scope), wechatOAuthCookieMaxAgeSec, secureCookie)
cfg.redirectURI = h.resolveWeChatPaymentOAuthCallbackURL(c.Request.Context(), c)
cfg.scope = scope
authURL, err := buildWeChatAuthorizeURL(cfg, state)
if err != nil {
response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_BUILD_URL_FAILED", "failed to build oauth authorization url").WithCause(err))
return
}
c.Redirect(http.StatusFound, authURL)
}
// WeChatPaymentOAuthCallback exchanges a payment OAuth code for an OpenID and
// forwards the browser back to the frontend callback route.
func (h *AuthHandler) WeChatPaymentOAuthCallback(c *gin.Context) {
frontendCallback := wechatPaymentOAuthFrontendCB
if providerErr := strings.TrimSpace(c.Query("error")); providerErr != "" {
redirectOAuthError(c, frontendCallback, "provider_error", providerErr, c.Query("error_description"))
return
}
code := strings.TrimSpace(c.Query("code"))
state := strings.TrimSpace(c.Query("state"))
if code == "" || state == "" {
redirectOAuthError(c, frontendCallback, "missing_params", "missing code/state", "")
return
}
secureCookie := isRequestHTTPS(c)
defer func() {
wechatPaymentClearCookie(c, wechatPaymentOAuthStateName, secureCookie)
wechatPaymentClearCookie(c, wechatPaymentOAuthRedirect, secureCookie)
wechatPaymentClearCookie(c, wechatPaymentOAuthContextName, secureCookie)
wechatPaymentClearCookie(c, wechatPaymentOAuthScope, secureCookie)
}()
expectedState, err := readCookieDecoded(c, wechatPaymentOAuthStateName)
if err != nil || expectedState == "" || state != expectedState {
redirectOAuthError(c, frontendCallback, "invalid_state", "invalid oauth state", "")
return
}
redirectTo, _ := readCookieDecoded(c, wechatPaymentOAuthRedirect)
redirectTo = normalizeWeChatPaymentRedirectPath(sanitizeFrontendRedirectPath(redirectTo))
if redirectTo == "" {
redirectTo = wechatPaymentOAuthDefaultTo
}
rawContext, _ := readCookieDecoded(c, wechatPaymentOAuthContextName)
paymentContext, err := decodeWeChatPaymentOAuthContext(rawContext)
if err != nil {
redirectOAuthError(c, frontendCallback, "invalid_context", "invalid oauth context", "")
return
}
if paymentContext.PaymentType == "" {
paymentContext.PaymentType = payment.TypeWxpay
}
scope, _ := readCookieDecoded(c, wechatPaymentOAuthScope)
scope = normalizeWeChatPaymentScope(scope)
cfg, err := h.getWeChatOAuthConfig(c.Request.Context(), "mp", c)
if err != nil {
redirectOAuthError(c, frontendCallback, "provider_error", infraerrors.Reason(err), infraerrors.Message(err))
return
}
cfg.redirectURI = h.resolveWeChatPaymentOAuthCallbackURL(c.Request.Context(), c)
tokenResp, err := exchangeWeChatOAuthCode(c.Request.Context(), cfg, code)
if err != nil {
redirectOAuthError(c, frontendCallback, "token_exchange_failed", "failed to exchange oauth code", err.Error())
return
}
openid := strings.TrimSpace(tokenResp.OpenID)
if openid == "" {
redirectOAuthError(c, frontendCallback, "missing_openid", "missing openid", "")
return
}
if strings.TrimSpace(tokenResp.Scope) != "" {
scope = strings.TrimSpace(tokenResp.Scope)
}
fragment := url.Values{}
fragment.Set("openid", openid)
fragment.Set("state", state)
fragment.Set("scope", scope)
fragment.Set("payment_type", paymentContext.PaymentType)
if paymentContext.Amount != "" {
fragment.Set("amount", paymentContext.Amount)
}
if paymentContext.OrderType != "" {
fragment.Set("order_type", paymentContext.OrderType)
}
if paymentContext.PlanID > 0 {
fragment.Set("plan_id", strconv.FormatInt(paymentContext.PlanID, 10))
}
fragment.Set("redirect", redirectTo)
redirectWithFragment(c, frontendCallback, fragment)
}
type completeWeChatOAuthRequest struct {
InvitationCode string `json:"invitation_code" binding:"required"`
AdoptDisplayName *bool `json:"adopt_display_name,omitempty"`
@ -950,3 +1109,99 @@ func wechatClearCookie(c *gin.Context, name string, secure bool) {
SameSite: http.SameSiteLaxMode,
})
}
func normalizeWeChatPaymentType(raw string) string {
switch strings.TrimSpace(raw) {
case payment.TypeWxpay, payment.TypeWxpayDirect:
return strings.TrimSpace(raw)
default:
return ""
}
}
func normalizeWeChatPaymentScope(raw string) string {
for _, part := range strings.FieldsFunc(strings.TrimSpace(raw), func(r rune) bool {
return r == ',' || r == ' ' || r == '\t' || r == '\n' || r == '\r'
}) {
switch strings.TrimSpace(part) {
case "snsapi_userinfo":
return "snsapi_userinfo"
case "snsapi_base":
return "snsapi_base"
}
}
return "snsapi_base"
}
func normalizeWeChatPaymentRedirectPath(path string) string {
path = strings.TrimSpace(path)
if path == "" {
return wechatPaymentOAuthDefaultTo
}
if path == "/payment" {
return "/purchase"
}
if strings.HasPrefix(path, "/payment?") {
return "/purchase" + strings.TrimPrefix(path, "/payment")
}
return path
}
func (h *AuthHandler) resolveWeChatPaymentOAuthCallbackURL(ctx context.Context, c *gin.Context) string {
apiBaseURL := ""
if h != nil && h.settingSvc != nil {
if settings, err := h.settingSvc.GetAllSettings(ctx); err == nil && settings != nil {
apiBaseURL = strings.TrimSpace(settings.APIBaseURL)
}
}
return resolveWeChatOAuthAbsoluteURL(apiBaseURL, c, "/api/v1/auth/oauth/wechat/payment/callback")
}
func encodeWeChatPaymentOAuthContext(ctx wechatPaymentOAuthContext) (string, error) {
data, err := json.Marshal(ctx)
if err != nil {
return "", err
}
return string(data), nil
}
func decodeWeChatPaymentOAuthContext(raw string) (wechatPaymentOAuthContext, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return wechatPaymentOAuthContext{}, nil
}
var ctx wechatPaymentOAuthContext
if err := json.Unmarshal([]byte(raw), &ctx); err != nil {
return wechatPaymentOAuthContext{}, err
}
return ctx, nil
}
func parseWeChatPaymentPlanID(raw string) int64 {
id, _ := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
return id
}
func wechatPaymentSetCookie(c *gin.Context, name string, value string, maxAgeSec int, secure bool) {
http.SetCookie(c.Writer, &http.Cookie{
Name: name,
Value: value,
Path: wechatPaymentOAuthCookiePath,
MaxAge: maxAgeSec,
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
})
}
func wechatPaymentClearCookie(c *gin.Context, name string, secure bool) {
http.SetCookie(c.Writer, &http.Cookie{
Name: name,
Value: "",
Path: wechatPaymentOAuthCookiePath,
MaxAge: -1,
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
})
}

View File

@ -204,10 +204,12 @@ func (h *PaymentHandler) GetLimits(c *gin.Context) {
type CreateOrderRequest struct {
Amount float64 `json:"amount"`
PaymentType string `json:"payment_type" binding:"required"`
OpenID string `json:"openid"`
ReturnURL string `json:"return_url"`
PaymentSource string `json:"payment_source"`
OrderType string `json:"order_type"`
PlanID int64 `json:"plan_id"`
IsMobile *bool `json:"is_mobile,omitempty"`
}
// CreateOrder creates a new payment order.
@ -224,13 +226,21 @@ func (h *PaymentHandler) CreateOrder(c *gin.Context) {
return
}
mobile := isMobile(c)
if req.IsMobile != nil {
mobile = *req.IsMobile
}
result, err := h.paymentService.CreateOrder(c.Request.Context(), service.CreateOrderRequest{
UserID: subject.UserID,
Amount: req.Amount,
PaymentType: req.PaymentType,
OpenID: req.OpenID,
ClientIP: c.ClientIP(),
IsMobile: isMobile(c),
IsMobile: mobile,
IsWeChatBrowser: isWeChatBrowser(c),
SrcHost: c.Request.Host,
SrcURL: c.Request.Referer(),
ReturnURL: req.ReturnURL,
PaymentSource: req.PaymentSource,
OrderType: req.OrderType,
@ -467,3 +477,7 @@ func isMobile(c *gin.Context) bool {
}
return false
}
func isWeChatBrowser(c *gin.Context) bool {
return strings.Contains(strings.ToLower(c.GetHeader("User-Agent")), "micromessenger")
}

View File

@ -6,8 +6,8 @@ import (
"crypto/rsa"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"sync"
"time"
@ -19,6 +19,7 @@ import (
"github.com/wechatpay-apiv3/wechatpay-go/core/option"
"github.com/wechatpay-apiv3/wechatpay-go/services/payments"
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/h5"
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/jsapi"
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/native"
"github.com/wechatpay-apiv3/wechatpay-go/services/refunddomestic"
"github.com/wechatpay-apiv3/wechatpay-go/utils"
@ -28,6 +29,14 @@ import (
const (
wxpayCurrency = "CNY"
wxpayH5Type = "Wap"
wxpayResultPath = "/payment/result"
)
// WeChat Pay create-payment modes.
const (
wxpayModeNative = "native"
wxpayModeH5 = "h5"
wxpayModeJSAPI = "jsapi"
)
// WeChat Pay trade states.
@ -48,6 +57,18 @@ const (
wxpayErrNoAuth = "NO_AUTH"
)
var (
wxpayNativePrepay = func(ctx context.Context, svc native.NativeApiService, req native.PrepayRequest) (*native.PrepayResponse, *core.APIResult, error) {
return svc.Prepay(ctx, req)
}
wxpayH5Prepay = func(ctx context.Context, svc h5.H5ApiService, req h5.PrepayRequest) (*h5.PrepayResponse, *core.APIResult, error) {
return svc.Prepay(ctx, req)
}
wxpayJSAPIPrepayWithRequestPayment = func(ctx context.Context, svc jsapi.JsapiApiService, req jsapi.PrepayRequest) (*jsapi.PrepayWithRequestPaymentResponse, *core.APIResult, error) {
return svc.PrepayWithRequestPayment(ctx, req)
}
)
type Wxpay struct {
instanceID string
config map[string]string
@ -75,6 +96,16 @@ func (w *Wxpay) SupportedTypes() []payment.PaymentType {
return []payment.PaymentType{payment.TypeWxpay}
}
// ResolveWxpayJSAPIAppID returns the AppID that JSAPI prepay will use for a
// given provider config. A dedicated MP AppID takes precedence over the base
// merchant AppID.
func ResolveWxpayJSAPIAppID(config map[string]string) string {
if appID := strings.TrimSpace(config["mpAppId"]); appID != "" {
return appID
}
return strings.TrimSpace(config["appId"])
}
func formatPEM(key, keyType string) string {
key = strings.TrimSpace(key)
if strings.HasPrefix(key, "-----BEGIN") {
@ -139,30 +170,68 @@ func (w *Wxpay) CreatePayment(ctx context.Context, req payment.CreatePaymentRequ
if err != nil {
return nil, fmt.Errorf("wxpay create payment: %w", err)
}
if req.IsMobile && req.ClientIP != "" {
resp, err := w.createOrder(ctx, client, req, notifyURL, totalFen, true)
mode, err := resolveWxpayCreateMode(req)
if err != nil {
return nil, err
}
switch mode {
case wxpayModeJSAPI:
return w.prepayJSAPI(ctx, client, req, notifyURL, totalFen)
case wxpayModeH5:
resp, err := w.prepayH5(ctx, client, req, notifyURL, totalFen)
if err == nil {
return resp, nil
}
if !strings.Contains(err.Error(), wxpayErrNoAuth) {
if strings.Contains(err.Error(), wxpayErrNoAuth) {
return nil, fmt.Errorf("wxpay h5 payments are not authorized for this merchant: %w", err)
}
return nil, err
case wxpayModeNative:
return w.prepayNative(ctx, client, req, notifyURL, totalFen)
default:
return nil, fmt.Errorf("wxpay create payment: unsupported mode %q", mode)
}
slog.Warn("wxpay H5 payment not authorized, falling back to native", "order", req.OrderID)
}
return w.createOrder(ctx, client, req, notifyURL, totalFen, false)
}
func (w *Wxpay) createOrder(ctx context.Context, c *core.Client, req payment.CreatePaymentRequest, notifyURL string, totalFen int64, useH5 bool) (*payment.CreatePaymentResponse, error) {
if useH5 {
return w.prepayH5(ctx, c, req, notifyURL, totalFen)
func (w *Wxpay) prepayJSAPI(ctx context.Context, c *core.Client, req payment.CreatePaymentRequest, notifyURL string, totalFen int64) (*payment.CreatePaymentResponse, error) {
svc := jsapi.JsapiApiService{Client: c}
cur := wxpayCurrency
appID := ResolveWxpayJSAPIAppID(w.config)
prepayReq := jsapi.PrepayRequest{
Appid: core.String(appID),
Mchid: core.String(w.config["mchId"]),
Description: core.String(req.Subject),
OutTradeNo: core.String(req.OrderID),
NotifyUrl: core.String(notifyURL),
Amount: &jsapi.Amount{Total: core.Int64(totalFen), Currency: &cur},
Payer: &jsapi.Payer{Openid: core.String(strings.TrimSpace(req.OpenID))},
}
return w.prepayNative(ctx, c, req, notifyURL, totalFen)
if clientIP := strings.TrimSpace(req.ClientIP); clientIP != "" {
prepayReq.SceneInfo = &jsapi.SceneInfo{PayerClientIp: core.String(clientIP)}
}
resp, _, err := wxpayJSAPIPrepayWithRequestPayment(ctx, svc, prepayReq)
if err != nil {
return nil, fmt.Errorf("wxpay jsapi prepay: %w", err)
}
return &payment.CreatePaymentResponse{
TradeNo: req.OrderID,
ResultType: payment.CreatePaymentResultJSAPIReady,
JSAPI: &payment.WechatJSAPIPayload{
AppID: wxSV(resp.Appid),
TimeStamp: wxSV(resp.TimeStamp),
NonceStr: wxSV(resp.NonceStr),
Package: wxSV(resp.Package),
SignType: wxSV(resp.SignType),
PaySign: wxSV(resp.PaySign),
},
}, nil
}
func (w *Wxpay) prepayNative(ctx context.Context, c *core.Client, req payment.CreatePaymentRequest, notifyURL string, totalFen int64) (*payment.CreatePaymentResponse, error) {
svc := native.NativeApiService{Client: c}
cur := wxpayCurrency
resp, _, err := svc.Prepay(ctx, native.PrepayRequest{
resp, _, err := wxpayNativePrepay(ctx, svc, native.PrepayRequest{
Appid: core.String(w.config["appId"]), Mchid: core.String(w.config["mchId"]),
Description: core.String(req.Subject), OutTradeNo: core.String(req.OrderID),
NotifyUrl: core.String(notifyURL),
@ -182,7 +251,7 @@ func (w *Wxpay) prepayH5(ctx context.Context, c *core.Client, req payment.Create
svc := h5.H5ApiService{Client: c}
cur := wxpayCurrency
tp := wxpayH5Type
resp, _, err := svc.Prepay(ctx, h5.PrepayRequest{
resp, _, err := wxpayH5Prepay(ctx, svc, h5.PrepayRequest{
Appid: core.String(w.config["appId"]), Mchid: core.String(w.config["mchId"]),
Description: core.String(req.Subject), OutTradeNo: core.String(req.OrderID),
NotifyUrl: core.String(notifyURL),
@ -196,9 +265,63 @@ func (w *Wxpay) prepayH5(ctx context.Context, c *core.Client, req payment.Create
if resp.H5Url != nil {
h5URL = *resp.H5Url
}
h5URL, err = appendWxpayRedirectURL(h5URL, req)
if err != nil {
return nil, err
}
return &payment.CreatePaymentResponse{TradeNo: req.OrderID, PayURL: h5URL}, nil
}
func resolveWxpayCreateMode(req payment.CreatePaymentRequest) (string, error) {
if strings.TrimSpace(req.OpenID) != "" {
return wxpayModeJSAPI, nil
}
if req.IsMobile {
if strings.TrimSpace(req.ClientIP) == "" {
return "", fmt.Errorf("wxpay H5 payment requires client IP")
}
return wxpayModeH5, nil
}
return wxpayModeNative, nil
}
func appendWxpayRedirectURL(h5URL string, req payment.CreatePaymentRequest) (string, error) {
h5URL = strings.TrimSpace(h5URL)
returnURL := strings.TrimSpace(req.ReturnURL)
if h5URL == "" || returnURL == "" {
return h5URL, nil
}
redirectURL, err := buildWxpayResultURL(returnURL, req)
if err != nil {
return "", err
}
sep := "&"
if !strings.Contains(h5URL, "?") {
sep = "?"
}
return h5URL + sep + "redirect_url=" + url.QueryEscape(redirectURL), nil
}
func buildWxpayResultURL(returnURL string, req payment.CreatePaymentRequest) (string, error) {
u, err := url.Parse(returnURL)
if err != nil || !u.IsAbs() || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
return "", fmt.Errorf("return URL must be an absolute http(s) URL")
}
values := url.Values{}
values.Set("out_trade_no", strings.TrimSpace(req.OrderID))
if paymentType := strings.TrimSpace(req.PaymentType); paymentType != "" {
values.Set("payment_type", paymentType)
}
u.Path = wxpayResultPath
u.RawPath = ""
u.RawQuery = values.Encode()
u.Fragment = ""
return u.String(), nil
}
func wxSV(s *string) string {
if s == nil {
return ""

View File

@ -3,10 +3,15 @@
package provider
import (
"context"
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/payment"
"github.com/wechatpay-apiv3/wechatpay-go/core"
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/h5"
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/jsapi"
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/native"
)
func TestMapWxState(t *testing.T) {
@ -257,3 +262,197 @@ func TestNewWxpay(t *testing.T) {
})
}
}
func TestResolveWxpayJSAPIAppID(t *testing.T) {
t.Parallel()
tests := []struct {
name string
config map[string]string
want string
}{
{
name: "prefers dedicated mp app id",
config: map[string]string{
"mpAppId": "wx-mp-app",
"appId": "wx-merchant-app",
},
want: "wx-mp-app",
},
{
name: "falls back to merchant app id",
config: map[string]string{
"appId": "wx-merchant-app",
},
want: "wx-merchant-app",
},
{
name: "missing app ids returns empty",
config: map[string]string{},
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := ResolveWxpayJSAPIAppID(tt.config); got != tt.want {
t.Fatalf("ResolveWxpayJSAPIAppID() = %q, want %q", got, tt.want)
}
})
}
}
func TestResolveWxpayCreateMode(t *testing.T) {
t.Parallel()
tests := []struct {
name string
req payment.CreatePaymentRequest
wantMode string
wantErr string
}{
{
name: "desktop uses native",
req: payment.CreatePaymentRequest{},
wantMode: wxpayModeNative,
},
{
name: "mobile uses h5 when client ip is present",
req: payment.CreatePaymentRequest{
IsMobile: true,
ClientIP: "203.0.113.10",
},
wantMode: wxpayModeH5,
},
{
name: "mobile without client ip returns clear error",
req: payment.CreatePaymentRequest{
IsMobile: true,
},
wantErr: "requires client IP",
},
{
name: "openid uses jsapi mode",
req: payment.CreatePaymentRequest{
OpenID: "openid-123",
},
wantMode: wxpayModeJSAPI,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := resolveWxpayCreateMode(tt.req)
if tt.wantErr != "" {
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error %q should contain %q", err.Error(), tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.wantMode {
t.Fatalf("resolveWxpayCreateMode() = %q, want %q", got, tt.wantMode)
}
})
}
}
func TestCreatePaymentWithOpenIDReturnsJSAPIResult(t *testing.T) {
origJSAPIPrepay := wxpayJSAPIPrepayWithRequestPayment
origNativePrepay := wxpayNativePrepay
origH5Prepay := wxpayH5Prepay
t.Cleanup(func() {
wxpayJSAPIPrepayWithRequestPayment = origJSAPIPrepay
wxpayNativePrepay = origNativePrepay
wxpayH5Prepay = origH5Prepay
})
jsapiCalls := 0
nativeCalls := 0
h5Calls := 0
wxpayJSAPIPrepayWithRequestPayment = func(ctx context.Context, svc jsapi.JsapiApiService, req jsapi.PrepayRequest) (*jsapi.PrepayWithRequestPaymentResponse, *core.APIResult, error) {
jsapiCalls++
if got := wxSV(req.Payer.Openid); got != "openid-123" {
t.Fatalf("openid = %q, want %q", got, "openid-123")
}
if req.SceneInfo == nil || wxSV(req.SceneInfo.PayerClientIp) != "203.0.113.10" {
t.Fatalf("scene_info payer_client_ip = %q, want %q", wxSV(req.SceneInfo.PayerClientIp), "203.0.113.10")
}
return &jsapi.PrepayWithRequestPaymentResponse{
Appid: core.String("wx123"),
TimeStamp: core.String("1712345678"),
NonceStr: core.String("nonce-123"),
Package: core.String("prepay_id=wx_prepay_123"),
SignType: core.String("RSA"),
PaySign: core.String("signed-payload"),
}, nil, nil
}
wxpayNativePrepay = func(ctx context.Context, svc native.NativeApiService, req native.PrepayRequest) (*native.PrepayResponse, *core.APIResult, error) {
nativeCalls++
return &native.PrepayResponse{}, nil, nil
}
wxpayH5Prepay = func(ctx context.Context, svc h5.H5ApiService, req h5.PrepayRequest) (*h5.PrepayResponse, *core.APIResult, error) {
h5Calls++
return &h5.PrepayResponse{}, nil, nil
}
provider := &Wxpay{
config: map[string]string{
"appId": "wx123",
"mchId": "mch123",
},
coreClient: &core.Client{},
}
resp, err := provider.CreatePayment(context.Background(), payment.CreatePaymentRequest{
OrderID: "sub2_88",
Amount: "66.88",
PaymentType: payment.TypeWxpay,
NotifyURL: "https://merchant.example/payment/notify",
OpenID: "openid-123",
ClientIP: "203.0.113.10",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if jsapiCalls != 1 {
t.Fatalf("jsapi prepay calls = %d, want 1", jsapiCalls)
}
if nativeCalls != 0 {
t.Fatalf("native prepay calls = %d, want 0", nativeCalls)
}
if h5Calls != 0 {
t.Fatalf("h5 prepay calls = %d, want 0", h5Calls)
}
if resp.ResultType != payment.CreatePaymentResultJSAPIReady {
t.Fatalf("result type = %q, want %q", resp.ResultType, payment.CreatePaymentResultJSAPIReady)
}
if resp.JSAPI == nil {
t.Fatal("expected jsapi payload, got nil")
}
if resp.JSAPI.AppID != "wx123" {
t.Fatalf("jsapi appId = %q, want %q", resp.JSAPI.AppID, "wx123")
}
if resp.JSAPI.TimeStamp != "1712345678" {
t.Fatalf("jsapi timeStamp = %q, want %q", resp.JSAPI.TimeStamp, "1712345678")
}
if resp.JSAPI.NonceStr != "nonce-123" {
t.Fatalf("jsapi nonceStr = %q, want %q", resp.JSAPI.NonceStr, "nonce-123")
}
if resp.JSAPI.Package != "prepay_id=wx_prepay_123" {
t.Fatalf("jsapi package = %q, want %q", resp.JSAPI.Package, "prepay_id=wx_prepay_123")
}
if resp.JSAPI.SignType != "RSA" {
t.Fatalf("jsapi signType = %q, want %q", resp.JSAPI.SignType, "RSA")
}
if resp.JSAPI.PaySign != "signed-payload" {
t.Fatalf("jsapi paySign = %q, want %q", resp.JSAPI.PaySign, "signed-payload")
}
}

View File

@ -101,17 +101,50 @@ type CreatePaymentRequest struct {
Subject string // Product description
NotifyURL string // Webhook callback URL
ReturnURL string // Browser redirect URL after payment
OpenID string // WeChat JSAPI payer OpenID when available
ClientIP string // Payer's IP address
IsMobile bool // Whether the request comes from a mobile device
InstanceSubMethods string // Comma-separated sub-methods from instance supported_types (for Stripe)
}
// CreatePaymentResultType describes the shape of the create-payment result.
type CreatePaymentResultType = string
const (
CreatePaymentResultOrderCreated CreatePaymentResultType = "order_created"
CreatePaymentResultOAuthRequired CreatePaymentResultType = "oauth_required"
CreatePaymentResultJSAPIReady CreatePaymentResultType = "jsapi_ready"
)
// WechatOAuthInfo describes the next step when WeChat OAuth is required before payment.
type WechatOAuthInfo struct {
AuthorizeURL string `json:"authorize_url,omitempty"`
AppID string `json:"appid,omitempty"`
OpenID string `json:"openid,omitempty"`
Scope string `json:"scope,omitempty"`
State string `json:"state,omitempty"`
RedirectURL string `json:"redirect_url,omitempty"`
}
// WechatJSAPIPayload contains the fields the frontend needs to invoke WeChat JSAPI payment.
type WechatJSAPIPayload struct {
AppID string `json:"appId,omitempty"`
TimeStamp string `json:"timeStamp,omitempty"`
NonceStr string `json:"nonceStr,omitempty"`
Package string `json:"package,omitempty"`
SignType string `json:"signType,omitempty"`
PaySign string `json:"paySign,omitempty"`
}
// CreatePaymentResponse is returned after successfully initiating a payment.
type CreatePaymentResponse struct {
TradeNo string // Third-party transaction ID
PayURL string // H5 payment URL (alipay/wxpay)
QRCode string // QR code content for scanning
ClientSecret string // Stripe PaymentIntent client secret
ResultType CreatePaymentResultType // Typed result contract for frontend flows
OAuth *WechatOAuthInfo // WeChat OAuth bootstrap payload when required
JSAPI *WechatJSAPIPayload // WeChat JSAPI invocation payload when ready
}
// QueryOrderResponse describes the payment status from the upstream provider.

View File

@ -66,6 +66,8 @@ func RegisterAuthRoutes(
auth.GET("/oauth/linuxdo/callback", h.Auth.LinuxDoOAuthCallback)
auth.GET("/oauth/wechat/start", h.Auth.WeChatOAuthStart)
auth.GET("/oauth/wechat/callback", h.Auth.WeChatOAuthCallback)
auth.GET("/oauth/wechat/payment/start", h.Auth.WeChatPaymentOAuthStart)
auth.GET("/oauth/wechat/payment/callback", h.Auth.WeChatPaymentOAuthCallback)
auth.POST("/oauth/pending/exchange",
rateLimiter.LimitWithOptions("oauth-pending-exchange", 20, time.Minute, middleware.RateLimitOptions{
FailureMode: middleware.RateLimitFailClose,

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,
providerReq := buildProviderCreatePaymentRequest(CreateOrderRequest{
PaymentType: req.PaymentType,
Subject: subject,
ReturnURL: providerReturnURL,
OpenID: req.OpenID,
ClientIP: req.ClientIP,
IsMobile: req.IsMobile,
InstanceSubMethods: sel.SupportedTypes,
})
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,
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,
PayURL: pr.PayURL,
QRCode: pr.QRCode,
ClientSecret: pr.ClientSecret,
ExpiresAt: order.ExpiresAt,
PaymentMode: sel.PaymentMode,
ResumeToken: resumeToken,
}, nil
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

@ -67,8 +67,10 @@ type CreateOrderRequest struct {
UserID int64
Amount float64
PaymentType string
OpenID string
ClientIP string
IsMobile bool
IsWeChatBrowser bool
SrcHost string
SrcURL string
ReturnURL string
@ -83,10 +85,15 @@ type CreateOrderResponse struct {
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"`

View File

@ -105,6 +105,50 @@ describe('decidePaymentLaunch', () => {
expect(decision.recovery.paymentMode).toBe('popup')
expect(decision.recovery.resumeToken).toBe('resume-2')
})
it('returns wechat oauth launch when backend requires in-app authorization', () => {
const decision = decidePaymentLaunch(createOrderResult({
result_type: 'oauth_required',
payment_type: 'wxpay',
oauth: {
authorize_url: '/api/v1/auth/oauth/wechat/payment/start?payment_type=wxpay',
appid: 'wx123',
scope: 'snsapi_base',
redirect_url: '/auth/wechat/payment/callback',
},
}), {
visibleMethod: 'wxpay',
orderType: 'balance',
isMobile: true,
})
expect(decision.kind).toBe('wechat_oauth')
expect(decision.oauth?.authorize_url).toContain('/api/v1/auth/oauth/wechat/payment/start')
expect(decision.paymentState.paymentType).toBe('wxpay')
})
it('returns wechat jsapi launch when backend has a jsapi payload ready', () => {
const decision = decidePaymentLaunch(createOrderResult({
result_type: 'jsapi_ready',
payment_type: 'wxpay',
jsapi: {
appId: 'wx123',
timeStamp: '1712345678',
nonceStr: 'nonce-123',
package: 'prepay_id=wx123',
signType: 'RSA',
paySign: 'signed-payload',
},
}), {
visibleMethod: 'wxpay',
orderType: 'subscription',
isMobile: true,
})
expect(decision.kind).toBe('wechat_jsapi')
expect(decision.jsapi?.appId).toBe('wx123')
expect(decision.paymentState.orderType).toBe('subscription')
})
})
describe('buildCreateOrderPayload', () => {

View File

@ -1,4 +1,11 @@
import type { CreateOrderRequest, CreateOrderResult, MethodLimit, OrderType } from '@/types/payment'
import type {
CreateOrderRequest,
CreateOrderResult,
MethodLimit,
OrderType,
WechatJSAPIPayload,
WechatOAuthInfo,
} from '@/types/payment'
export const PAYMENT_RECOVERY_STORAGE_KEY = 'payment.recovery.current'
@ -16,6 +23,8 @@ export type PaymentLaunchKind =
| 'redirect_waiting'
| 'stripe_popup'
| 'stripe_route'
| 'wechat_oauth'
| 'wechat_jsapi'
| 'unhandled'
export interface PaymentRecoverySnapshot {
@ -47,6 +56,8 @@ export interface PaymentLaunchDecision {
paymentState: PaymentRecoverySnapshot
recovery: PaymentRecoverySnapshot
stripeMethod?: StripeVisibleMethod
oauth?: WechatOAuthInfo
jsapi?: WechatJSAPIPayload
}
export interface BuildCreateOrderPayloadInput {
@ -139,6 +150,15 @@ export function decidePaymentLaunch(
return { kind, paymentState, recovery: paymentState, stripeMethod }
}
if (result.result_type === 'oauth_required' && result.oauth?.authorize_url) {
return { kind: 'wechat_oauth', paymentState: baseState, recovery: baseState, oauth: result.oauth }
}
const jsapiPayload = result.jsapi ?? result.jsapi_payload
if (result.result_type === 'jsapi_ready' && jsapiPayload) {
return { kind: 'wechat_jsapi', paymentState: baseState, recovery: baseState, jsapi: jsapiPayload }
}
if (baseState.qrCode) {
return { kind: 'qr_waiting', paymentState: baseState, recovery: baseState }
}

View File

@ -92,6 +92,15 @@ const routes: RouteRecordRaw[] = [
title: 'WeChat OAuth Callback'
}
},
{
path: '/auth/wechat/payment/callback',
name: 'WeChatPaymentOAuthCallback',
component: () => import('@/views/auth/WechatPaymentCallbackView.vue'),
meta: {
requiresAuth: false,
title: 'WeChat Payment Callback'
}
},
{
path: '/auth/oidc/callback',
name: 'OIDCOAuthCallback',

View File

@ -156,6 +156,28 @@ export interface CreateOrderRequest {
plan_id?: number
return_url?: string
payment_source?: string
openid?: string
is_mobile?: boolean
}
export type CreateOrderResultType = 'order_created' | 'oauth_required' | 'jsapi_ready'
export interface WechatOAuthInfo {
authorize_url?: string
appid?: string
openid?: string
scope?: string
state?: string
redirect_url?: string
}
export interface WechatJSAPIPayload {
appId?: string
timeStamp?: string
nonceStr?: string
package?: string
signType?: string
paySign?: string
}
export interface CreateOrderResult {
@ -167,8 +189,14 @@ export interface CreateOrderResult {
pay_amount: number
fee_rate: number
expires_at: string
result_type?: CreateOrderResultType
payment_type?: string
out_trade_no?: string
payment_mode?: string
resume_token?: string
oauth?: WechatOAuthInfo
jsapi?: WechatJSAPIPayload
jsapi_payload?: WechatJSAPIPayload
}
export interface DashboardStats {

View File

@ -0,0 +1,155 @@
<template>
<div class="min-h-screen bg-gray-50 px-4 py-10 dark:bg-dark-900">
<div class="mx-auto max-w-2xl">
<div class="card p-6">
<h1 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ callbackTitleText }}
</h1>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
{{ errorMessage || callbackProcessingText }}
</p>
<div
v-if="!errorMessage"
class="mt-6 flex items-center justify-center py-10"
>
<div
class="h-8 w-8 animate-spin rounded-full border-4 border-primary-500 border-t-transparent"
></div>
</div>
<div
v-else
class="mt-6 rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-700/50 dark:bg-red-900/20"
>
<p class="text-sm text-red-700 dark:text-red-400">
{{ errorMessage }}
</p>
<button
class="btn btn-primary mt-4"
type="button"
@click="goBackToPayment"
>
{{ backToPaymentText }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
const { t, locale } = useI18n()
const route = useRoute()
const router = useRouter()
const errorMessage = ref('')
function textWithFallback(key: string, zh: string, en: string): string {
const translated = t(key)
if (translated !== key) return translated
return String(locale.value).toLowerCase().startsWith('zh') ? zh : en
}
const callbackProcessingText = computed(() =>
textWithFallback(
'auth.wechatPayment.callbackProcessing',
'正在恢复微信支付...',
'Resuming WeChat payment...',
))
const callbackTitleText = computed(() =>
textWithFallback(
'auth.wechatPayment.callbackTitle',
'正在恢复微信支付',
'Resuming WeChat payment',
))
const backToPaymentText = computed(() =>
textWithFallback(
'auth.wechatPayment.backToPayment',
'返回支付页',
'Back to payment',
))
function readQueryString(key: string): string {
const value = route.query[key]
if (Array.isArray(value)) {
return typeof value[0] === 'string' ? value[0] : ''
}
return typeof value === 'string' ? value : ''
}
function parseFragmentParams(): URLSearchParams {
const raw = typeof window !== 'undefined' ? window.location.hash : ''
const hash = raw.startsWith('#') ? raw.slice(1) : raw
return new URLSearchParams(hash)
}
function normalizeRedirectPath(path: string | null | undefined): string {
const value = (path || '').trim()
if (!value) return '/purchase'
if (!value.startsWith('/')) return '/purchase'
if (value.startsWith('//') || value.includes('://')) return '/purchase'
if (value === '/payment') return '/purchase'
if (value.startsWith('/payment?')) return '/purchase' + value.slice('/payment'.length)
return value
}
function goBackToPayment() {
void router.replace('/purchase')
}
onMounted(async () => {
const fragment = parseFragmentParams()
const readParam = (key: string) => fragment.get(key) || readQueryString(key)
const error = readParam('error') || readParam('err_msg') || readParam('errmsg')
const errorDescription = readParam('error_description') || readParam('message')
if (error) {
errorMessage.value = errorDescription || error
return
}
const openid = readParam('openid')
const state = readParam('state')
const scope = readParam('scope')
const paymentType = readParam('payment_type')
const amount = readParam('amount')
const orderType = readParam('order_type')
const planId = readParam('plan_id')
const redirectURL = new URL(
normalizeRedirectPath(readParam('redirect')),
window.location.origin,
)
if (!openid) {
errorMessage.value = textWithFallback(
'auth.wechatPayment.callbackMissingOpenId',
'微信支付回调缺少 openid。',
'The WeChat payment callback is missing the openid.',
)
return
}
const query: Record<string, string> = {
...Object.fromEntries(redirectURL.searchParams.entries()),
wechat_resume: '1',
openid,
}
if (state) query.state = state
if (scope) query.scope = scope
if (paymentType) query.payment_type = paymentType
if (amount) query.amount = amount
if (orderType) query.order_type = orderType
if (planId) query.plan_id = planId
await router.replace({
path: redirectURL.pathname,
query,
})
})
</script>

View File

@ -0,0 +1,80 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import WechatPaymentCallbackView from '@/views/auth/WechatPaymentCallbackView.vue'
const { replaceMock, routeState, locationState } = vi.hoisted(() => ({
replaceMock: vi.fn(),
routeState: {
query: {} as Record<string, unknown>,
},
locationState: {
current: {
href: 'http://localhost/auth/wechat/payment/callback',
hash: '',
search: '',
pathname: '/auth/wechat/payment/callback',
origin: 'http://localhost',
} as Location & { origin: string },
},
}))
vi.mock('vue-router', () => ({
useRoute: () => routeState,
useRouter: () => ({
replace: replaceMock,
}),
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key,
locale: { value: 'zh-CN' },
}),
}))
describe('WechatPaymentCallbackView', () => {
beforeEach(() => {
replaceMock.mockReset()
routeState.query = {}
locationState.current = {
href: 'http://localhost/auth/wechat/payment/callback',
hash: '',
search: '',
pathname: '/auth/wechat/payment/callback',
origin: 'http://localhost',
} as Location & { origin: string }
Object.defineProperty(window, 'location', {
configurable: true,
value: locationState.current,
})
})
it('redirects back to purchase with openid and payment context from hash fragment', async () => {
locationState.current.hash = '#openid=openid-123&payment_type=wxpay&amount=12.5&order_type=balance&redirect=%2Fpurchase%3Ffrom%3Dwechat'
mount(WechatPaymentCallbackView)
await flushPromises()
expect(replaceMock).toHaveBeenCalledWith({
path: '/purchase',
query: {
from: 'wechat',
wechat_resume: '1',
openid: 'openid-123',
payment_type: 'wxpay',
amount: '12.5',
order_type: 'balance',
},
})
})
it('shows an error when the callback payload is missing openid', async () => {
locationState.current.hash = '#payment_type=wxpay'
const wrapper = mount(WechatPaymentCallbackView)
await flushPromises()
expect(replaceMock).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('微信支付回调缺少 openid。')
})
})

View File

@ -309,6 +309,20 @@ const previewImage = ref('')
const paymentPhase = ref<'select' | 'paying'>('select')
interface CreateOrderOptions {
openid?: string
paymentType?: string
isResume?: boolean
}
interface WeixinJSBridgeLike {
invoke(
action: string,
payload: Record<string, unknown>,
callback: (result: Record<string, unknown>) => void,
): void
}
function emptyPaymentState(): PaymentRecoverySnapshot {
return {
orderId: 0,
@ -326,6 +340,48 @@ function emptyPaymentState(): PaymentRecoverySnapshot {
}
}
function readRouteQueryValue(value: unknown): string {
if (Array.isArray(value)) {
return typeof value[0] === 'string' ? value[0] : ''
}
return typeof value === 'string' ? value : ''
}
function getWeixinJSBridge(): WeixinJSBridgeLike | undefined {
return (window as Window & { WeixinJSBridge?: WeixinJSBridgeLike }).WeixinJSBridge
}
function waitForWeixinJSBridge(timeoutMs = 4000): Promise<WeixinJSBridgeLike | null> {
const existing = getWeixinJSBridge()
if (existing) return Promise.resolve(existing)
return new Promise((resolve) => {
let settled = false
const finish = (bridge: WeixinJSBridgeLike | null) => {
if (settled) return
settled = true
document.removeEventListener('WeixinJSBridgeReady', handleReady)
document.removeEventListener('onWeixinJSBridgeReady', handleReady)
window.clearTimeout(timer)
resolve(bridge)
}
const handleReady = () => finish(getWeixinJSBridge() ?? null)
const timer = window.setTimeout(() => finish(getWeixinJSBridge() ?? null), timeoutMs)
document.addEventListener('WeixinJSBridgeReady', handleReady, false)
document.addEventListener('onWeixinJSBridgeReady', handleReady, false)
})
}
async function invokeWechatJsapiPayment(payload: Record<string, unknown>): Promise<Record<string, unknown>> {
const bridge = await waitForWeixinJSBridge()
if (!bridge) {
throw new Error('WeixinJSBridge is unavailable')
}
return new Promise((resolve) => {
bridge.invoke('getBrandWCPayRequest', payload, (result) => resolve(result || {}))
})
}
const paymentState = ref<PaymentRecoverySnapshot>(emptyPaymentState())
function persistRecoverySnapshot(snapshot: PaymentRecoverySnapshot) {
@ -560,25 +616,32 @@ async function confirmSubscribe() {
await createOrder(selectedPlan.value.price, 'subscription', selectedPlan.value.id)
}
async function createOrder(orderAmount: number, orderType: OrderType, planId?: number) {
async function createOrder(orderAmount: number, orderType: OrderType, planId?: number, options: CreateOrderOptions = {}) {
submitting.value = true
errorMessage.value = ''
try {
const result = await paymentStore.createOrder(buildCreateOrderPayload({
const requestType = normalizeVisibleMethod(options.paymentType || selectedMethod.value) || options.paymentType || selectedMethod.value
const payload = buildCreateOrderPayload({
amount: orderAmount,
paymentType: selectedMethod.value,
paymentType: requestType,
orderType,
planId,
origin: typeof window !== 'undefined' ? window.location.origin : '',
isWechatBrowser: typeof window !== 'undefined' && /MicroMessenger/i.test(window.navigator.userAgent),
})) as CreateOrderResult & { resume_token?: string }
})
if (options.openid) {
payload.openid = options.openid
}
payload.is_mobile = isMobileDevice()
const result = await paymentStore.createOrder(payload) as CreateOrderResult & { resume_token?: string }
const openWindow = (url: string, features = POPUP_WINDOW_FEATURES) => {
const win = window.open(url, 'paymentPopup', features)
if (!win || win.closed) {
window.location.href = url
}
}
const visibleMethod = normalizeVisibleMethod(selectedMethod.value) || selectedMethod.value
const visibleMethod = normalizeVisibleMethod(requestType) || requestType
const stripeMethod = visibleMethod === 'wxpay' ? 'wechat_pay' : 'alipay'
const stripeRouteUrl = result.client_secret
? router.resolve({
@ -599,6 +662,11 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
stripeRouteUrl,
})
if (decision.kind === 'wechat_oauth' && decision.oauth?.authorize_url) {
window.location.href = decision.oauth.authorize_url
return
}
if (decision.kind === 'unhandled') {
errorMessage.value = t('payment.result.failed')
appStore.showError(errorMessage.value)
@ -617,6 +685,16 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
window.location.href = decision.paymentState.payUrl
return
}
if (decision.kind === 'wechat_jsapi' && decision.jsapi) {
const jsapiResult = await invokeWechatJsapiPayment(decision.jsapi as Record<string, unknown>)
const errMsg = String(jsapiResult.err_msg || '').toLowerCase()
if (errMsg.includes('cancel')) {
appStore.showInfo(t('payment.qr.cancelled'))
} else if (errMsg && !errMsg.includes('ok')) {
appStore.showError(t('payment.result.failed'))
}
return
}
if (decision.kind === 'redirect_waiting' && decision.paymentState.payUrl) {
if (isMobileDevice()) {
window.location.href = decision.paymentState.payUrl
@ -640,6 +718,50 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
}
}
async function resumeWechatPaymentFromQuery() {
const openid = readRouteQueryValue(route.query.openid)
if (readRouteQueryValue(route.query.wechat_resume) !== '1' || !openid) {
return
}
const paymentType = normalizeVisibleMethod(readRouteQueryValue(route.query.payment_type)) || 'wxpay'
const orderType = readRouteQueryValue(route.query.order_type) === 'subscription' ? 'subscription' : 'balance'
const planId = Number.parseInt(readRouteQueryValue(route.query.plan_id), 10)
const rawAmount = Number.parseFloat(readRouteQueryValue(route.query.amount))
const orderAmount = Number.isFinite(rawAmount) && rawAmount > 0
? rawAmount
: (orderType === 'subscription'
? (checkout.value.plans.find(plan => plan.id === planId)?.price ?? 0)
: validAmount.value)
selectedMethod.value = paymentType
if (orderType === 'balance' && orderAmount > 0) {
amount.value = orderAmount
}
if (orderType === 'subscription' && Number.isFinite(planId) && planId > 0) {
selectedPlan.value = checkout.value.plans.find(plan => plan.id === planId) ?? null
}
const nextQuery = { ...route.query }
delete nextQuery.wechat_resume
delete nextQuery.openid
delete nextQuery.state
delete nextQuery.scope
delete nextQuery.payment_type
delete nextQuery.amount
delete nextQuery.order_type
delete nextQuery.plan_id
await router.replace({ path: route.path, query: nextQuery })
if (orderAmount > 0) {
await createOrder(orderAmount, orderType, Number.isFinite(planId) && planId > 0 ? planId : undefined, {
openid,
paymentType,
isResume: true,
})
}
}
onMounted(async () => {
try {
const res = await paymentAPI.getCheckoutInfo()
@ -672,6 +794,7 @@ onMounted(async () => {
removeRecoverySnapshot()
}
}
await resumeWechatPaymentFromQuery()
if (checkout.value.balance_disabled) {
activeTab.value = 'subscription'
}