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,17 +226,25 @@ 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,
ClientIP: c.ClientIP(),
IsMobile: isMobile(c),
SrcHost: c.Request.Host,
ReturnURL: req.ReturnURL,
PaymentSource: req.PaymentSource,
OrderType: req.OrderType,
PlanID: req.PlanID,
UserID: subject.UserID,
Amount: req.Amount,
PaymentType: req.PaymentType,
OpenID: req.OpenID,
ClientIP: c.ClientIP(),
IsMobile: mobile,
IsWeChatBrowser: isWeChatBrowser(c),
SrcHost: c.Request.Host,
SrcURL: c.Request.Referer(),
ReturnURL: req.ReturnURL,
PaymentSource: req.PaymentSource,
OrderType: req.OrderType,
PlanID: req.PlanID,
})
if err != nil {
response.ErrorFrom(c, err)
@@ -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")
}