363 lines
12 KiB
Go
363 lines
12 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/url"
|
|
"strconv"
|
|
"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"
|
|
|
|
wechatPaymentResumeTokenType = "wechat_payment_resume"
|
|
|
|
paymentResumeNotConfiguredCode = "PAYMENT_RESUME_NOT_CONFIGURED"
|
|
paymentResumeNotConfiguredMessage = "payment resume tokens require a configured signing key"
|
|
)
|
|
|
|
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 WeChatPaymentResumeClaims struct {
|
|
TokenType string `json:"tk,omitempty"`
|
|
OpenID string `json:"openid"`
|
|
PaymentType string `json:"pt,omitempty"`
|
|
Amount string `json:"amt,omitempty"`
|
|
OrderType string `json:"ot,omitempty"`
|
|
PlanID int64 `json:"pid,omitempty"`
|
|
RedirectTo string `json:"rd,omitempty"`
|
|
Scope string `json:"scp,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 (s *PaymentResumeService) isSigningConfigured() bool {
|
|
return s != nil && len(s.signingKey) > 0
|
|
}
|
|
|
|
func (s *PaymentResumeService) ensureSigningKey() error {
|
|
if s.isSigningConfigured() {
|
|
return nil
|
|
}
|
|
return infraerrors.ServiceUnavailable(paymentResumeNotConfiguredCode, paymentResumeNotConfiguredMessage)
|
|
}
|
|
|
|
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 buildPaymentReturnURL(base string, orderID int64, resumeToken string) (string, error) {
|
|
canonical, err := CanonicalizeReturnURL(base)
|
|
if err != nil || canonical == "" {
|
|
return canonical, err
|
|
}
|
|
|
|
parsed, err := url.Parse(canonical)
|
|
if err != nil {
|
|
return "", infraerrors.BadRequest("INVALID_RETURN_URL", "return_url must be a valid URL")
|
|
}
|
|
|
|
query := parsed.Query()
|
|
if orderID > 0 {
|
|
query.Set("order_id", strconv.FormatInt(orderID, 10))
|
|
}
|
|
if strings.TrimSpace(resumeToken) != "" {
|
|
query.Set("resume_token", strings.TrimSpace(resumeToken))
|
|
}
|
|
query.Set("status", "success")
|
|
parsed.RawQuery = query.Encode()
|
|
|
|
return parsed.String(), nil
|
|
}
|
|
|
|
func (s *PaymentResumeService) CreateToken(claims ResumeTokenClaims) (string, error) {
|
|
if err := s.ensureSigningKey(); err != nil {
|
|
return "", err
|
|
}
|
|
if claims.OrderID <= 0 {
|
|
return "", fmt.Errorf("resume token requires order id")
|
|
}
|
|
if claims.IssuedAt == 0 {
|
|
claims.IssuedAt = time.Now().Unix()
|
|
}
|
|
return s.createSignedToken(claims)
|
|
}
|
|
|
|
func (s *PaymentResumeService) ParseToken(token string) (*ResumeTokenClaims, error) {
|
|
if err := s.ensureSigningKey(); err != nil {
|
|
return nil, err
|
|
}
|
|
var claims ResumeTokenClaims
|
|
if err := s.parseSignedToken(token, &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) CreateWeChatPaymentResumeToken(claims WeChatPaymentResumeClaims) (string, error) {
|
|
if err := s.ensureSigningKey(); err != nil {
|
|
return "", err
|
|
}
|
|
claims.OpenID = strings.TrimSpace(claims.OpenID)
|
|
if claims.OpenID == "" {
|
|
return "", fmt.Errorf("wechat payment resume token requires openid")
|
|
}
|
|
if claims.IssuedAt == 0 {
|
|
claims.IssuedAt = time.Now().Unix()
|
|
}
|
|
if normalized := NormalizeVisibleMethod(claims.PaymentType); normalized != "" {
|
|
claims.PaymentType = normalized
|
|
}
|
|
if claims.PaymentType == "" {
|
|
claims.PaymentType = payment.TypeWxpay
|
|
}
|
|
if claims.OrderType == "" {
|
|
claims.OrderType = payment.OrderTypeBalance
|
|
}
|
|
claims.TokenType = wechatPaymentResumeTokenType
|
|
return s.createSignedToken(claims)
|
|
}
|
|
|
|
func (s *PaymentResumeService) ParseWeChatPaymentResumeToken(token string) (*WeChatPaymentResumeClaims, error) {
|
|
if err := s.ensureSigningKey(); err != nil {
|
|
return nil, err
|
|
}
|
|
var claims WeChatPaymentResumeClaims
|
|
if err := s.parseSignedToken(token, &claims); err != nil {
|
|
return nil, infraerrors.BadRequest("INVALID_WECHAT_PAYMENT_RESUME_TOKEN", "wechat payment resume token payload is invalid")
|
|
}
|
|
if claims.TokenType != wechatPaymentResumeTokenType {
|
|
return nil, infraerrors.BadRequest("INVALID_WECHAT_PAYMENT_RESUME_TOKEN", "wechat payment resume token type mismatch")
|
|
}
|
|
claims.OpenID = strings.TrimSpace(claims.OpenID)
|
|
if claims.OpenID == "" {
|
|
return nil, infraerrors.BadRequest("INVALID_WECHAT_PAYMENT_RESUME_TOKEN", "wechat payment resume token missing openid")
|
|
}
|
|
if normalized := NormalizeVisibleMethod(claims.PaymentType); normalized != "" {
|
|
claims.PaymentType = normalized
|
|
}
|
|
if claims.PaymentType == "" {
|
|
claims.PaymentType = payment.TypeWxpay
|
|
}
|
|
if claims.OrderType == "" {
|
|
claims.OrderType = payment.OrderTypeBalance
|
|
}
|
|
return &claims, nil
|
|
}
|
|
|
|
func (s *PaymentResumeService) createSignedToken(claims any) (string, error) {
|
|
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) parseSignedToken(token string, dest any) error {
|
|
parts := strings.Split(token, ".")
|
|
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
|
return infraerrors.BadRequest("INVALID_RESUME_TOKEN", "resume token is malformed")
|
|
}
|
|
if !hmac.Equal([]byte(parts[1]), []byte(s.sign(parts[0]))) {
|
|
return infraerrors.BadRequest("INVALID_RESUME_TOKEN", "resume token signature mismatch")
|
|
}
|
|
payload, err := base64.RawURLEncoding.DecodeString(parts[0])
|
|
if err != nil {
|
|
return infraerrors.BadRequest("INVALID_RESUME_TOKEN", "resume token payload is malformed")
|
|
}
|
|
return json.Unmarshal(payload, dest)
|
|
}
|
|
|
|
func (s *PaymentResumeService) sign(payload string) string {
|
|
mac := hmac.New(sha256.New, s.signingKey)
|
|
_, _ = mac.Write([]byte(payload))
|
|
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
|
}
|