package service import ( "context" "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/json" "fmt" "net/url" "strings" "time" "github.com/Wei-Shaw/sub2api/internal/payment" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" ) const ( PaymentSourceHostedRedirect = "hosted_redirect" PaymentSourceWechatInAppResume = "wechat_in_app_resume" paymentResumeFallbackSigningKey = "sub2api-payment-resume" SettingPaymentVisibleMethodAlipaySource = "payment_visible_method_alipay_source" SettingPaymentVisibleMethodWxpaySource = "payment_visible_method_wxpay_source" SettingPaymentVisibleMethodAlipayEnabled = "payment_visible_method_alipay_enabled" SettingPaymentVisibleMethodWxpayEnabled = "payment_visible_method_wxpay_enabled" VisibleMethodSourceOfficialAlipay = "official_alipay" VisibleMethodSourceEasyPayAlipay = "easypay_alipay" VisibleMethodSourceOfficialWechat = "official_wxpay" VisibleMethodSourceEasyPayWechat = "easypay_wxpay" ) type ResumeTokenClaims struct { OrderID int64 `json:"oid"` UserID int64 `json:"uid,omitempty"` ProviderInstanceID string `json:"pi,omitempty"` ProviderKey string `json:"pk,omitempty"` PaymentType string `json:"pt,omitempty"` CanonicalReturnURL string `json:"ru,omitempty"` IssuedAt int64 `json:"iat"` } type PaymentResumeService struct { signingKey []byte } type visibleMethodLoadBalancer struct { inner payment.LoadBalancer configService *PaymentConfigService } func NewPaymentResumeService(signingKey []byte) *PaymentResumeService { return &PaymentResumeService{signingKey: signingKey} } func NormalizeVisibleMethod(method string) string { return payment.GetBasePaymentType(strings.TrimSpace(method)) } func NormalizeVisibleMethods(methods []string) []string { if len(methods) == 0 { return nil } seen := make(map[string]struct{}, len(methods)) out := make([]string, 0, len(methods)) for _, method := range methods { normalized := NormalizeVisibleMethod(method) if normalized == "" { continue } if _, ok := seen[normalized]; ok { continue } seen[normalized] = struct{}{} out = append(out, normalized) } return out } func NormalizePaymentSource(source string) string { switch strings.TrimSpace(strings.ToLower(source)) { case "", PaymentSourceHostedRedirect: return PaymentSourceHostedRedirect case "wechat_in_app", "wxpay_resume", PaymentSourceWechatInAppResume: return PaymentSourceWechatInAppResume default: return strings.TrimSpace(strings.ToLower(source)) } } func NormalizeVisibleMethodSource(method, source string) string { switch NormalizeVisibleMethod(method) { case payment.TypeAlipay: switch strings.TrimSpace(strings.ToLower(source)) { case VisibleMethodSourceOfficialAlipay, payment.TypeAlipay, payment.TypeAlipayDirect, "official": return VisibleMethodSourceOfficialAlipay case VisibleMethodSourceEasyPayAlipay, payment.TypeEasyPay: return VisibleMethodSourceEasyPayAlipay } case payment.TypeWxpay: switch strings.TrimSpace(strings.ToLower(source)) { case VisibleMethodSourceOfficialWechat, payment.TypeWxpay, payment.TypeWxpayDirect, "wechat", "official": return VisibleMethodSourceOfficialWechat case VisibleMethodSourceEasyPayWechat, payment.TypeEasyPay: return VisibleMethodSourceEasyPayWechat } } return "" } func VisibleMethodProviderKeyForSource(method, source string) (string, bool) { switch NormalizeVisibleMethodSource(method, source) { case VisibleMethodSourceOfficialAlipay: return payment.TypeAlipay, NormalizeVisibleMethod(method) == payment.TypeAlipay case VisibleMethodSourceEasyPayAlipay: return payment.TypeEasyPay, NormalizeVisibleMethod(method) == payment.TypeAlipay case VisibleMethodSourceOfficialWechat: return payment.TypeWxpay, NormalizeVisibleMethod(method) == payment.TypeWxpay case VisibleMethodSourceEasyPayWechat: return payment.TypeEasyPay, NormalizeVisibleMethod(method) == payment.TypeWxpay default: return "", false } } func newVisibleMethodLoadBalancer(inner payment.LoadBalancer, configService *PaymentConfigService) payment.LoadBalancer { if inner == nil || configService == nil || configService.settingRepo == nil { return inner } return &visibleMethodLoadBalancer{inner: inner, configService: configService} } func (lb *visibleMethodLoadBalancer) GetInstanceConfig(ctx context.Context, instanceID int64) (map[string]string, error) { return lb.inner.GetInstanceConfig(ctx, instanceID) } func (lb *visibleMethodLoadBalancer) SelectInstance(ctx context.Context, providerKey string, paymentType payment.PaymentType, strategy payment.Strategy, orderAmount float64) (*payment.InstanceSelection, error) { visibleMethod := NormalizeVisibleMethod(paymentType) if providerKey != "" || (visibleMethod != payment.TypeAlipay && visibleMethod != payment.TypeWxpay) { return lb.inner.SelectInstance(ctx, providerKey, paymentType, strategy, orderAmount) } enabledKey := visibleMethodEnabledSettingKey(visibleMethod) sourceKey := visibleMethodSourceSettingKey(visibleMethod) vals, err := lb.configService.settingRepo.GetMultiple(ctx, []string{enabledKey, sourceKey}) if err != nil { return nil, fmt.Errorf("load visible method routing for %s: %w", visibleMethod, err) } if vals[enabledKey] != "true" { return nil, fmt.Errorf("visible payment method %s is disabled", visibleMethod) } targetProviderKey, ok := VisibleMethodProviderKeyForSource(visibleMethod, vals[sourceKey]) if !ok { return nil, fmt.Errorf("visible payment method %s has no valid source", visibleMethod) } return lb.inner.SelectInstance(ctx, targetProviderKey, paymentType, strategy, orderAmount) } func visibleMethodEnabledSettingKey(method string) string { switch NormalizeVisibleMethod(method) { case payment.TypeAlipay: return SettingPaymentVisibleMethodAlipayEnabled case payment.TypeWxpay: return SettingPaymentVisibleMethodWxpayEnabled default: return "" } } func visibleMethodSourceSettingKey(method string) string { switch NormalizeVisibleMethod(method) { case payment.TypeAlipay: return SettingPaymentVisibleMethodAlipaySource case payment.TypeWxpay: return SettingPaymentVisibleMethodWxpaySource default: return "" } } func CanonicalizeReturnURL(raw string) (string, error) { raw = strings.TrimSpace(raw) if raw == "" { return "", nil } parsed, err := url.Parse(raw) if err != nil || !parsed.IsAbs() || parsed.Host == "" { return "", infraerrors.BadRequest("INVALID_RETURN_URL", "return_url must be an absolute http/https URL") } if parsed.Scheme != "http" && parsed.Scheme != "https" { return "", infraerrors.BadRequest("INVALID_RETURN_URL", "return_url must use http or https") } parsed.Fragment = "" if parsed.Path == "" { parsed.Path = "/" } return parsed.String(), nil } func (s *PaymentResumeService) CreateToken(claims ResumeTokenClaims) (string, error) { if claims.OrderID <= 0 { return "", fmt.Errorf("resume token requires order id") } if claims.IssuedAt == 0 { claims.IssuedAt = time.Now().Unix() } payload, err := json.Marshal(claims) if err != nil { return "", fmt.Errorf("marshal resume claims: %w", err) } encodedPayload := base64.RawURLEncoding.EncodeToString(payload) return encodedPayload + "." + s.sign(encodedPayload), nil } func (s *PaymentResumeService) ParseToken(token string) (*ResumeTokenClaims, error) { parts := strings.Split(token, ".") if len(parts) != 2 || parts[0] == "" || parts[1] == "" { return nil, infraerrors.BadRequest("INVALID_RESUME_TOKEN", "resume token is malformed") } if !hmac.Equal([]byte(parts[1]), []byte(s.sign(parts[0]))) { return nil, infraerrors.BadRequest("INVALID_RESUME_TOKEN", "resume token signature mismatch") } payload, err := base64.RawURLEncoding.DecodeString(parts[0]) if err != nil { return nil, infraerrors.BadRequest("INVALID_RESUME_TOKEN", "resume token payload is malformed") } var claims ResumeTokenClaims if err := json.Unmarshal(payload, &claims); err != nil { return nil, infraerrors.BadRequest("INVALID_RESUME_TOKEN", "resume token payload is invalid") } if claims.OrderID <= 0 { return nil, infraerrors.BadRequest("INVALID_RESUME_TOKEN", "resume token missing order id") } return &claims, nil } func (s *PaymentResumeService) sign(payload string) string { key := s.signingKey if len(key) == 0 { key = []byte(paymentResumeFallbackSigningKey) } mac := hmac.New(sha256.New, key) _, _ = mac.Write([]byte(payload)) return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) }