fix: restore wechat payment oauth and jsapi flow
This commit is contained in:
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user