payment_resume_service.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476
  1. package service
  2. import (
  3. "bytes"
  4. "context"
  5. "crypto/hmac"
  6. "crypto/sha256"
  7. "encoding/base64"
  8. "encoding/json"
  9. "fmt"
  10. "net"
  11. "net/url"
  12. "strconv"
  13. "strings"
  14. "time"
  15. "github.com/Wei-Shaw/sub2api/internal/payment"
  16. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  17. )
  18. const paymentResultReturnPath = "/payment/result"
  19. const (
  20. PaymentSourceHostedRedirect = "hosted_redirect"
  21. PaymentSourceWechatInAppResume = "wechat_in_app_resume"
  22. SettingPaymentVisibleMethodAlipaySource = "payment_visible_method_alipay_source"
  23. SettingPaymentVisibleMethodWxpaySource = "payment_visible_method_wxpay_source"
  24. SettingPaymentVisibleMethodAlipayEnabled = "payment_visible_method_alipay_enabled"
  25. SettingPaymentVisibleMethodWxpayEnabled = "payment_visible_method_wxpay_enabled"
  26. VisibleMethodSourceOfficialAlipay = "official_alipay"
  27. VisibleMethodSourceEasyPayAlipay = "easypay_alipay"
  28. VisibleMethodSourceOfficialWechat = "official_wxpay"
  29. VisibleMethodSourceEasyPayWechat = "easypay_wxpay"
  30. wechatPaymentResumeTokenType = "wechat_payment_resume"
  31. paymentResumeNotConfiguredCode = "PAYMENT_RESUME_NOT_CONFIGURED"
  32. paymentResumeNotConfiguredMessage = "payment resume tokens require a configured signing key"
  33. paymentResumeTokenTTL = 24 * time.Hour
  34. wechatPaymentResumeTokenTTL = 15 * time.Minute
  35. )
  36. type ResumeTokenClaims struct {
  37. OrderID int64 `json:"oid"`
  38. UserID int64 `json:"uid,omitempty"`
  39. ProviderInstanceID string `json:"pi,omitempty"`
  40. ProviderKey string `json:"pk,omitempty"`
  41. PaymentType string `json:"pt,omitempty"`
  42. CanonicalReturnURL string `json:"ru,omitempty"`
  43. IssuedAt int64 `json:"iat"`
  44. ExpiresAt int64 `json:"exp,omitempty"`
  45. }
  46. type WeChatPaymentResumeClaims struct {
  47. TokenType string `json:"tk,omitempty"`
  48. OpenID string `json:"openid"`
  49. PaymentType string `json:"pt,omitempty"`
  50. Amount string `json:"amt,omitempty"`
  51. OrderType string `json:"ot,omitempty"`
  52. PlanID int64 `json:"pid,omitempty"`
  53. RedirectTo string `json:"rd,omitempty"`
  54. Scope string `json:"scp,omitempty"`
  55. IssuedAt int64 `json:"iat"`
  56. ExpiresAt int64 `json:"exp,omitempty"`
  57. }
  58. type PaymentResumeService struct {
  59. signingKey []byte
  60. verifyKeys [][]byte
  61. }
  62. type visibleMethodLoadBalancer struct {
  63. inner payment.LoadBalancer
  64. configService *PaymentConfigService
  65. }
  66. func NewPaymentResumeService(signingKey []byte, verifyFallbacks ...[]byte) *PaymentResumeService {
  67. svc := &PaymentResumeService{}
  68. if len(signingKey) > 0 {
  69. svc.signingKey = append([]byte(nil), signingKey...)
  70. svc.verifyKeys = append(svc.verifyKeys, svc.signingKey)
  71. }
  72. for _, fallback := range verifyFallbacks {
  73. if len(fallback) == 0 {
  74. continue
  75. }
  76. cloned := append([]byte(nil), fallback...)
  77. duplicate := false
  78. for _, existing := range svc.verifyKeys {
  79. if bytes.Equal(existing, cloned) {
  80. duplicate = true
  81. break
  82. }
  83. }
  84. if !duplicate {
  85. svc.verifyKeys = append(svc.verifyKeys, cloned)
  86. }
  87. }
  88. return svc
  89. }
  90. func (s *PaymentResumeService) isSigningConfigured() bool {
  91. return s != nil && len(s.signingKey) > 0
  92. }
  93. func (s *PaymentResumeService) ensureSigningKey() error {
  94. if s.isSigningConfigured() {
  95. return nil
  96. }
  97. return infraerrors.ServiceUnavailable(paymentResumeNotConfiguredCode, paymentResumeNotConfiguredMessage)
  98. }
  99. func NormalizeVisibleMethod(method string) string {
  100. return payment.GetBasePaymentType(strings.TrimSpace(method))
  101. }
  102. func NormalizeVisibleMethods(methods []string) []string {
  103. if len(methods) == 0 {
  104. return nil
  105. }
  106. seen := make(map[string]struct{}, len(methods))
  107. out := make([]string, 0, len(methods))
  108. for _, method := range methods {
  109. normalized := NormalizeVisibleMethod(method)
  110. if normalized == "" {
  111. continue
  112. }
  113. if _, ok := seen[normalized]; ok {
  114. continue
  115. }
  116. seen[normalized] = struct{}{}
  117. out = append(out, normalized)
  118. }
  119. return out
  120. }
  121. func NormalizePaymentSource(source string) string {
  122. switch strings.TrimSpace(strings.ToLower(source)) {
  123. case "", PaymentSourceHostedRedirect:
  124. return PaymentSourceHostedRedirect
  125. case "wechat_in_app", "wxpay_resume", PaymentSourceWechatInAppResume:
  126. return PaymentSourceWechatInAppResume
  127. default:
  128. return strings.TrimSpace(strings.ToLower(source))
  129. }
  130. }
  131. func NormalizeVisibleMethodSource(method, source string) string {
  132. switch NormalizeVisibleMethod(method) {
  133. case payment.TypeAlipay:
  134. switch strings.TrimSpace(strings.ToLower(source)) {
  135. case VisibleMethodSourceOfficialAlipay, payment.TypeAlipay, payment.TypeAlipayDirect, "official":
  136. return VisibleMethodSourceOfficialAlipay
  137. case VisibleMethodSourceEasyPayAlipay, payment.TypeEasyPay:
  138. return VisibleMethodSourceEasyPayAlipay
  139. }
  140. case payment.TypeWxpay:
  141. switch strings.TrimSpace(strings.ToLower(source)) {
  142. case VisibleMethodSourceOfficialWechat, payment.TypeWxpay, payment.TypeWxpayDirect, "wechat", "official":
  143. return VisibleMethodSourceOfficialWechat
  144. case VisibleMethodSourceEasyPayWechat, payment.TypeEasyPay:
  145. return VisibleMethodSourceEasyPayWechat
  146. }
  147. }
  148. return ""
  149. }
  150. func VisibleMethodProviderKeyForSource(method, source string) (string, bool) {
  151. switch NormalizeVisibleMethodSource(method, source) {
  152. case VisibleMethodSourceOfficialAlipay:
  153. return payment.TypeAlipay, NormalizeVisibleMethod(method) == payment.TypeAlipay
  154. case VisibleMethodSourceEasyPayAlipay:
  155. return payment.TypeEasyPay, NormalizeVisibleMethod(method) == payment.TypeAlipay
  156. case VisibleMethodSourceOfficialWechat:
  157. return payment.TypeWxpay, NormalizeVisibleMethod(method) == payment.TypeWxpay
  158. case VisibleMethodSourceEasyPayWechat:
  159. return payment.TypeEasyPay, NormalizeVisibleMethod(method) == payment.TypeWxpay
  160. default:
  161. return "", false
  162. }
  163. }
  164. func newVisibleMethodLoadBalancer(inner payment.LoadBalancer, configService *PaymentConfigService) payment.LoadBalancer {
  165. if inner == nil || configService == nil || configService.entClient == nil {
  166. return inner
  167. }
  168. return &visibleMethodLoadBalancer{inner: inner, configService: configService}
  169. }
  170. func (lb *visibleMethodLoadBalancer) GetInstanceConfig(ctx context.Context, instanceID int64) (map[string]string, error) {
  171. return lb.inner.GetInstanceConfig(ctx, instanceID)
  172. }
  173. func (lb *visibleMethodLoadBalancer) SelectInstance(ctx context.Context, providerKey string, paymentType payment.PaymentType, strategy payment.Strategy, orderAmount float64) (*payment.InstanceSelection, error) {
  174. visibleMethod := NormalizeVisibleMethod(paymentType)
  175. if providerKey != "" || (visibleMethod != payment.TypeAlipay && visibleMethod != payment.TypeWxpay) {
  176. return lb.inner.SelectInstance(ctx, providerKey, paymentType, strategy, orderAmount)
  177. }
  178. inst, err := lb.configService.resolveEnabledVisibleMethodInstance(ctx, visibleMethod)
  179. if err != nil {
  180. return nil, err
  181. }
  182. if inst == nil {
  183. return nil, fmt.Errorf("visible payment method %s has no enabled provider instance", visibleMethod)
  184. }
  185. return lb.inner.SelectInstance(ctx, inst.ProviderKey, paymentType, strategy, orderAmount)
  186. }
  187. func visibleMethodEnabledSettingKey(method string) string {
  188. switch NormalizeVisibleMethod(method) {
  189. case payment.TypeAlipay:
  190. return SettingPaymentVisibleMethodAlipayEnabled
  191. case payment.TypeWxpay:
  192. return SettingPaymentVisibleMethodWxpayEnabled
  193. default:
  194. return ""
  195. }
  196. }
  197. func visibleMethodSourceSettingKey(method string) string {
  198. switch NormalizeVisibleMethod(method) {
  199. case payment.TypeAlipay:
  200. return SettingPaymentVisibleMethodAlipaySource
  201. case payment.TypeWxpay:
  202. return SettingPaymentVisibleMethodWxpaySource
  203. default:
  204. return ""
  205. }
  206. }
  207. func CanonicalizeReturnURL(raw string, srcHost string, srcURL string) (string, error) {
  208. raw = strings.TrimSpace(raw)
  209. if raw == "" {
  210. return "", nil
  211. }
  212. parsed, err := url.Parse(raw)
  213. if err != nil || !parsed.IsAbs() || parsed.Host == "" {
  214. return "", infraerrors.BadRequest("INVALID_RETURN_URL", "return_url must be an absolute http/https URL")
  215. }
  216. if parsed.Scheme != "http" && parsed.Scheme != "https" {
  217. return "", infraerrors.BadRequest("INVALID_RETURN_URL", "return_url must use http or https")
  218. }
  219. parsed.Fragment = ""
  220. if parsed.Path == "" {
  221. parsed.Path = "/"
  222. }
  223. if parsed.Path != paymentResultReturnPath {
  224. return "", infraerrors.BadRequest("INVALID_RETURN_URL", "return_url must target the canonical internal payment result page")
  225. }
  226. if !allowedReturnURLHost(parsed.Host, srcHost, srcURL) {
  227. return "", infraerrors.BadRequest("INVALID_RETURN_URL", "return_url must use the same host as the current site or browser origin")
  228. }
  229. return parsed.String(), nil
  230. }
  231. func allowedReturnURLHost(returnURLHost string, requestHost string, refererURL string) bool {
  232. if sameOriginHost(returnURLHost, requestHost) {
  233. return true
  234. }
  235. refererURL = strings.TrimSpace(refererURL)
  236. if refererURL == "" {
  237. return false
  238. }
  239. parsedReferer, err := url.Parse(refererURL)
  240. if err != nil || parsedReferer.Host == "" {
  241. return false
  242. }
  243. return sameOriginHost(returnURLHost, parsedReferer.Host)
  244. }
  245. func buildPaymentReturnURL(base string, orderID int64, outTradeNo string, resumeToken string) (string, error) {
  246. canonical := strings.TrimSpace(base)
  247. if canonical == "" {
  248. return "", nil
  249. }
  250. parsed, err := url.Parse(canonical)
  251. if err != nil {
  252. return "", infraerrors.BadRequest("INVALID_RETURN_URL", "return_url must be a valid URL")
  253. }
  254. if !parsed.IsAbs() || parsed.Host == "" {
  255. return "", infraerrors.BadRequest("INVALID_RETURN_URL", "return_url must be a valid absolute URL")
  256. }
  257. parsed.Fragment = ""
  258. query := parsed.Query()
  259. if orderID > 0 {
  260. query.Set("order_id", strconv.FormatInt(orderID, 10))
  261. }
  262. if strings.TrimSpace(outTradeNo) != "" {
  263. query.Set("out_trade_no", strings.TrimSpace(outTradeNo))
  264. }
  265. if strings.TrimSpace(resumeToken) != "" {
  266. query.Set("resume_token", strings.TrimSpace(resumeToken))
  267. }
  268. query.Set("status", "success")
  269. parsed.RawQuery = query.Encode()
  270. return parsed.String(), nil
  271. }
  272. func sameOriginHost(returnURLHost string, requestHost string) bool {
  273. returnHost := strings.TrimSpace(returnURLHost)
  274. reqHost := strings.TrimSpace(requestHost)
  275. if returnHost == "" || reqHost == "" {
  276. return false
  277. }
  278. if strings.EqualFold(returnHost, reqHost) {
  279. return true
  280. }
  281. returnName, returnPort := splitHostPortDefault(returnHost)
  282. reqName, reqPort := splitHostPortDefault(reqHost)
  283. if returnName == "" || reqName == "" {
  284. return false
  285. }
  286. return strings.EqualFold(returnName, reqName) && returnPort == reqPort
  287. }
  288. func splitHostPortDefault(raw string) (string, string) {
  289. if host, port, err := net.SplitHostPort(raw); err == nil {
  290. return host, port
  291. }
  292. return raw, ""
  293. }
  294. func (s *PaymentResumeService) CreateToken(claims ResumeTokenClaims) (string, error) {
  295. if err := s.ensureSigningKey(); err != nil {
  296. return "", err
  297. }
  298. if claims.OrderID <= 0 {
  299. return "", fmt.Errorf("resume token requires order id")
  300. }
  301. if claims.IssuedAt == 0 {
  302. claims.IssuedAt = time.Now().Unix()
  303. }
  304. if claims.ExpiresAt == 0 {
  305. claims.ExpiresAt = time.Now().Add(paymentResumeTokenTTL).Unix()
  306. }
  307. return s.createSignedToken(claims)
  308. }
  309. func (s *PaymentResumeService) ParseToken(token string) (*ResumeTokenClaims, error) {
  310. if err := s.ensureSigningKey(); err != nil {
  311. return nil, err
  312. }
  313. var claims ResumeTokenClaims
  314. if err := s.parseSignedToken(token, &claims); err != nil {
  315. return nil, infraerrors.BadRequest("INVALID_RESUME_TOKEN", "resume token payload is invalid")
  316. }
  317. if claims.OrderID <= 0 {
  318. return nil, infraerrors.BadRequest("INVALID_RESUME_TOKEN", "resume token missing order id")
  319. }
  320. if err := validatePaymentResumeExpiry(claims.ExpiresAt, "INVALID_RESUME_TOKEN", "resume token has expired"); err != nil {
  321. return nil, err
  322. }
  323. return &claims, nil
  324. }
  325. func (s *PaymentResumeService) CreateWeChatPaymentResumeToken(claims WeChatPaymentResumeClaims) (string, error) {
  326. if err := s.ensureSigningKey(); err != nil {
  327. return "", err
  328. }
  329. claims.OpenID = strings.TrimSpace(claims.OpenID)
  330. if claims.OpenID == "" {
  331. return "", fmt.Errorf("wechat payment resume token requires openid")
  332. }
  333. if claims.IssuedAt == 0 {
  334. claims.IssuedAt = time.Now().Unix()
  335. }
  336. if claims.ExpiresAt == 0 {
  337. claims.ExpiresAt = time.Now().Add(wechatPaymentResumeTokenTTL).Unix()
  338. }
  339. if normalized := NormalizeVisibleMethod(claims.PaymentType); normalized != "" {
  340. claims.PaymentType = normalized
  341. }
  342. if claims.PaymentType == "" {
  343. claims.PaymentType = payment.TypeWxpay
  344. }
  345. if claims.OrderType == "" {
  346. claims.OrderType = payment.OrderTypeBalance
  347. }
  348. claims.TokenType = wechatPaymentResumeTokenType
  349. return s.createSignedToken(claims)
  350. }
  351. func (s *PaymentResumeService) ParseWeChatPaymentResumeToken(token string) (*WeChatPaymentResumeClaims, error) {
  352. if err := s.ensureSigningKey(); err != nil {
  353. return nil, err
  354. }
  355. var claims WeChatPaymentResumeClaims
  356. if err := s.parseSignedToken(token, &claims); err != nil {
  357. return nil, infraerrors.BadRequest("INVALID_WECHAT_PAYMENT_RESUME_TOKEN", "wechat payment resume token payload is invalid")
  358. }
  359. if claims.TokenType != wechatPaymentResumeTokenType {
  360. return nil, infraerrors.BadRequest("INVALID_WECHAT_PAYMENT_RESUME_TOKEN", "wechat payment resume token type mismatch")
  361. }
  362. claims.OpenID = strings.TrimSpace(claims.OpenID)
  363. if claims.OpenID == "" {
  364. return nil, infraerrors.BadRequest("INVALID_WECHAT_PAYMENT_RESUME_TOKEN", "wechat payment resume token missing openid")
  365. }
  366. if err := validatePaymentResumeExpiry(claims.ExpiresAt, "INVALID_WECHAT_PAYMENT_RESUME_TOKEN", "wechat payment resume token has expired"); err != nil {
  367. return nil, err
  368. }
  369. if normalized := NormalizeVisibleMethod(claims.PaymentType); normalized != "" {
  370. claims.PaymentType = normalized
  371. }
  372. if claims.PaymentType == "" {
  373. claims.PaymentType = payment.TypeWxpay
  374. }
  375. if claims.OrderType == "" {
  376. claims.OrderType = payment.OrderTypeBalance
  377. }
  378. return &claims, nil
  379. }
  380. func (s *PaymentResumeService) createSignedToken(claims any) (string, error) {
  381. payload, err := json.Marshal(claims)
  382. if err != nil {
  383. return "", fmt.Errorf("marshal resume claims: %w", err)
  384. }
  385. encodedPayload := base64.RawURLEncoding.EncodeToString(payload)
  386. return encodedPayload + "." + s.sign(encodedPayload), nil
  387. }
  388. func (s *PaymentResumeService) parseSignedToken(token string, dest any) error {
  389. parts := strings.Split(token, ".")
  390. if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
  391. return infraerrors.BadRequest("INVALID_RESUME_TOKEN", "resume token is malformed")
  392. }
  393. if !s.verifySignature(parts[0], parts[1]) {
  394. return infraerrors.BadRequest("INVALID_RESUME_TOKEN", "resume token signature mismatch")
  395. }
  396. payload, err := base64.RawURLEncoding.DecodeString(parts[0])
  397. if err != nil {
  398. return infraerrors.BadRequest("INVALID_RESUME_TOKEN", "resume token payload is malformed")
  399. }
  400. return json.Unmarshal(payload, dest)
  401. }
  402. func (s *PaymentResumeService) verifySignature(payload string, signature string) bool {
  403. if s == nil {
  404. return false
  405. }
  406. for _, key := range s.verifyKeys {
  407. if hmac.Equal([]byte(signature), []byte(signPaymentResumePayload(payload, key))) {
  408. return true
  409. }
  410. }
  411. return false
  412. }
  413. func validatePaymentResumeExpiry(expiresAt int64, code, message string) error {
  414. if expiresAt <= 0 {
  415. return nil
  416. }
  417. if time.Now().Unix() > expiresAt {
  418. return infraerrors.BadRequest(code, message)
  419. }
  420. return nil
  421. }
  422. func (s *PaymentResumeService) sign(payload string) string {
  423. return signPaymentResumePayload(payload, s.signingKey)
  424. }
  425. func signPaymentResumePayload(payload string, key []byte) string {
  426. mac := hmac.New(sha256.New, key)
  427. _, _ = mac.Write([]byte(payload))
  428. return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
  429. }