| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603 |
- // Package provider contains concrete payment provider implementations.
- package provider
- import (
- "context"
- "crypto/hmac"
- "crypto/md5"
- "encoding/hex"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "net/url"
- "sort"
- "strconv"
- "strings"
- "time"
- "unicode/utf8"
- "github.com/Wei-Shaw/sub2api/internal/payment"
- )
- // EasyPay constants.
- const (
- easypayCodeSuccess = 1
- easypayStatusPaid = 1
- easypayHTTPTimeout = 10 * time.Second
- maxEasypayResponseSize = 1 << 20 // 1MB
- maxEasypayErrorSummary = 512
- tradeStatusSuccess = "TRADE_SUCCESS"
- signTypeMD5 = "MD5"
- paymentModePopup = "popup"
- deviceMobile = "mobile"
- )
- // EasyPay implements payment.Provider for the EasyPay aggregation platform.
- type EasyPay struct {
- instanceID string
- config map[string]string
- httpClient *http.Client
- }
- type easyPayCustomMethod struct {
- Type string `json:"type"`
- UpstreamType string `json:"upstreamType"`
- DisplayName string `json:"displayName"`
- }
- // NewEasyPay creates a new EasyPay provider.
- // config keys: pid, pkey, apiBase, notifyUrl, returnUrl, cid, cidAlipay, cidWxpay
- func NewEasyPay(instanceID string, config map[string]string) (*EasyPay, error) {
- for _, k := range []string{"pid", "pkey", "apiBase", "notifyUrl", "returnUrl"} {
- if strings.TrimSpace(config[k]) == "" {
- return nil, fmt.Errorf("easypay config missing required key: %s", k)
- }
- }
- cfg := make(map[string]string, len(config))
- for k, v := range config {
- cfg[k] = v
- }
- cfg["apiBase"] = normalizeEasyPayAPIBase(cfg["apiBase"])
- return &EasyPay{
- instanceID: instanceID,
- config: cfg,
- httpClient: &http.Client{Timeout: easypayHTTPTimeout},
- }, nil
- }
- func normalizeEasyPayAPIBase(apiBase string) string {
- base := strings.TrimSpace(apiBase)
- if base == "" {
- return ""
- }
- if parsed, err := url.Parse(base); err == nil && parsed.Scheme != "" && parsed.Host != "" {
- parsed.RawQuery = ""
- parsed.Fragment = ""
- parsed.RawPath = ""
- parsed.Path = trimEasyPayEndpointPath(parsed.Path)
- return strings.TrimRight(parsed.String(), "/")
- }
- return strings.TrimRight(trimEasyPayEndpointPath(base), "/")
- }
- func trimEasyPayEndpointPath(path string) string {
- path = strings.TrimRight(strings.TrimSpace(path), "/")
- lower := strings.ToLower(path)
- for _, endpoint := range []string{"/submit.php", "/mapi.php", "/api.php"} {
- if strings.HasSuffix(lower, endpoint) {
- return strings.TrimRight(path[:len(path)-len(endpoint)], "/")
- }
- }
- return path
- }
- func (e *EasyPay) apiBase() string {
- if e == nil {
- return ""
- }
- return normalizeEasyPayAPIBase(e.config["apiBase"])
- }
- func (e *EasyPay) Name() string { return "EasyPay" }
- func (e *EasyPay) ProviderKey() string { return payment.TypeEasyPay }
- func (e *EasyPay) SupportedTypes() []payment.PaymentType {
- types := []payment.PaymentType{payment.TypeAlipay, payment.TypeWxpay}
- for _, method := range e.customMethods() {
- if method.Type != "" {
- types = append(types, method.Type)
- }
- }
- return types
- }
- func (e *EasyPay) MerchantIdentityMetadata() map[string]string {
- if e == nil {
- return nil
- }
- pid := strings.TrimSpace(e.config["pid"])
- if pid == "" {
- return nil
- }
- return map[string]string{"pid": pid}
- }
- func (e *EasyPay) CreatePayment(ctx context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
- // Payment mode determined by instance config, not payment type.
- // "popup" → hosted page (submit.php); "qrcode"/default → API call (mapi.php).
- mode := e.config["paymentMode"]
- if mode == paymentModePopup {
- return e.createRedirectPayment(req)
- }
- return e.createAPIPayment(ctx, req)
- }
- // createRedirectPayment builds a submit.php URL for browser redirect.
- // No server-side API call — the user is redirected to EasyPay's hosted page.
- // TradeNo is empty; it arrives via the notify callback after payment.
- func (e *EasyPay) createRedirectPayment(req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
- notifyURL, returnURL := e.resolveURLs(req)
- paymentType := e.upstreamPaymentType(req.PaymentType)
- params := map[string]string{
- "pid": e.config["pid"], "type": paymentType,
- "out_trade_no": req.OrderID, "notify_url": notifyURL,
- "return_url": returnURL, "name": req.Subject,
- "money": req.Amount,
- }
- if cid := e.resolveCID(paymentType); cid != "" {
- params["cid"] = cid
- }
- if req.IsMobile {
- params["device"] = deviceMobile
- }
- params["sign"] = easyPaySign(params, e.config["pkey"])
- params["sign_type"] = signTypeMD5
- q := url.Values{}
- for k, v := range params {
- q.Set(k, v)
- }
- payURL := e.apiBase() + "/submit.php?" + q.Encode()
- return &payment.CreatePaymentResponse{PayURL: payURL}, nil
- }
- // createAPIPayment calls mapi.php to get payurl/qrcode (existing behavior).
- func (e *EasyPay) createAPIPayment(ctx context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
- notifyURL, returnURL := e.resolveURLs(req)
- paymentType := e.upstreamPaymentType(req.PaymentType)
- params := map[string]string{
- "pid": e.config["pid"], "type": paymentType,
- "out_trade_no": req.OrderID, "notify_url": notifyURL,
- "return_url": returnURL, "name": req.Subject,
- "money": req.Amount, "clientip": req.ClientIP,
- }
- if cid := e.resolveCID(paymentType); cid != "" {
- params["cid"] = cid
- }
- if req.IsMobile {
- params["device"] = deviceMobile
- }
- params["sign"] = easyPaySign(params, e.config["pkey"])
- params["sign_type"] = signTypeMD5
- body, err := e.post(ctx, e.apiBase()+"/mapi.php", params)
- if err != nil {
- return nil, fmt.Errorf("easypay create: %w", err)
- }
- var resp struct {
- Code int `json:"code"`
- Msg string `json:"msg"`
- TradeNo string `json:"trade_no"`
- PayURL string `json:"payurl"`
- PayURL2 string `json:"payurl2"` // H5 mobile payment URL
- QRCode string `json:"qrcode"`
- }
- if err := json.Unmarshal(body, &resp); err != nil {
- return nil, fmt.Errorf("easypay parse: %w", err)
- }
- if resp.Code != easypayCodeSuccess {
- return nil, fmt.Errorf("easypay error: %s", resp.Msg)
- }
- payURL := resp.PayURL
- if req.IsMobile && resp.PayURL2 != "" {
- payURL = resp.PayURL2
- }
- base := e.apiBase()
- return &payment.CreatePaymentResponse{
- TradeNo: resp.TradeNo,
- PayURL: resolveEasyPayReturnedRef(base, payURL),
- QRCode: resolveEasyPayReturnedRef(base, resp.QRCode),
- }, nil
- }
- // resolveEasyPayReturnedRef absolutizes a payurl/payurl2/qrcode reference that
- // mapi.php returned, resolving it against the instance's configured apiBase.
- //
- // EasyPay-compatible upstreams disagree on this field: some answer with a full
- // checkout URL, others with a site-root-relative path such as
- // "/api/pay/toapp/<order>". createRedirectPayment already builds an absolute URL
- // itself (apiBase + "/submit.php?..."), but the mapi.php branch stored whatever
- // came back verbatim, and nothing downstream repairs it —
- // sanitizeCreatePaymentResponseDetails only strips NUL bytes before the value is
- // persisted to pay_url/qr_code. The frontend then feeds qr_code straight into
- // QRCode.toCanvas (PaymentQRCodeView.renderQR), so a relative path becomes a QR
- // whose payload is a bare path: WeChat renders it as text, and pay_url resolves
- // against the gateway's own domain and 404s.
- //
- // That is precisely the outcome the Alipay provider already refuses to produce —
- // "Setting it as QRCode would let the frontend render an unscannable image"
- // (createPagePayTrade) — so EasyPay is brought in line with the same rule.
- //
- // Only references beginning with "/" are resolved. Anything carrying a scheme is
- // already actionable and is returned untouched, which covers both absolute
- // https:// checkout URLs and app deep links (weixin://, wxp://, alipays://). A
- // reference without a leading slash is left alone as well: QR payloads are
- // frequently opaque tokens rather than paths, and rewriting "OrderToken123" into
- // "<apiBase>/OrderToken123" would break a payload that works today — strictly
- // worse than the bug this fixes.
- func resolveEasyPayReturnedRef(apiBase, ref string) string {
- trimmed := strings.TrimSpace(ref)
- if !strings.HasPrefix(trimmed, "/") {
- return ref
- }
- base, err := url.Parse(strings.TrimSpace(apiBase))
- if err != nil || base.Scheme == "" || base.Host == "" {
- return ref
- }
- parsed, err := url.Parse(trimmed)
- if err != nil || parsed.Scheme != "" {
- return ref
- }
- return base.ResolveReference(parsed).String()
- }
- // resolveURLs returns (notifyURL, returnURL) preferring request values,
- // falling back to instance config.
- func (e *EasyPay) resolveURLs(req payment.CreatePaymentRequest) (string, string) {
- notifyURL := req.NotifyURL
- if notifyURL == "" {
- notifyURL = e.config["notifyUrl"]
- }
- returnURL := req.ReturnURL
- if returnURL == "" {
- returnURL = e.config["returnUrl"]
- }
- return notifyURL, returnURL
- }
- func (e *EasyPay) customMethods() []easyPayCustomMethod {
- if e == nil {
- return nil
- }
- raw := strings.TrimSpace(e.config["customMethods"])
- if raw == "" {
- return nil
- }
- var methods []easyPayCustomMethod
- if err := json.Unmarshal([]byte(raw), &methods); err != nil {
- return nil
- }
- result := make([]easyPayCustomMethod, 0, len(methods))
- for _, method := range methods {
- method.Type = strings.TrimSpace(method.Type)
- method.UpstreamType = strings.TrimSpace(method.UpstreamType)
- method.DisplayName = strings.TrimSpace(method.DisplayName)
- if method.Type == "" || method.UpstreamType == "" {
- continue
- }
- result = append(result, method)
- }
- return result
- }
- func (e *EasyPay) upstreamPaymentType(paymentType string) string {
- paymentType = strings.TrimSpace(paymentType)
- for _, method := range e.customMethods() {
- if paymentType == method.Type {
- return method.UpstreamType
- }
- }
- return paymentType
- }
- func (e *EasyPay) QueryOrder(ctx context.Context, tradeNo string) (*payment.QueryOrderResponse, error) {
- params := map[string]string{
- "act": "order", "pid": e.config["pid"],
- "key": e.config["pkey"], "out_trade_no": tradeNo,
- }
- body, err := e.post(ctx, e.apiBase()+"/api.php", params)
- if err != nil {
- return nil, fmt.Errorf("easypay query: %w", err)
- }
- type easyPayQueryData struct {
- TradeStatus *string `json:"trade_status"`
- Status *int `json:"status"`
- Money *string `json:"money"`
- TradeNo *string `json:"trade_no"`
- }
- var resp struct {
- Code int `json:"code"`
- Msg string `json:"msg"`
- TradeStatus *string `json:"trade_status"`
- Status *int `json:"status"`
- Money *string `json:"money"`
- TradeNo *string `json:"trade_no"`
- Data easyPayQueryData `json:"data"`
- }
- if err := json.Unmarshal(body, &resp); err != nil {
- return nil, fmt.Errorf("easypay parse query: %w", err)
- }
- status := payment.ProviderStatusPending
- if resp.TradeStatus != nil {
- if *resp.TradeStatus == tradeStatusSuccess {
- status = payment.ProviderStatusPaid
- }
- } else if resp.Data.TradeStatus != nil {
- if *resp.Data.TradeStatus == tradeStatusSuccess {
- status = payment.ProviderStatusPaid
- }
- } else if resp.Status != nil {
- if *resp.Status == easypayStatusPaid {
- status = payment.ProviderStatusPaid
- }
- } else if resp.Data.Status != nil && *resp.Data.Status == easypayStatusPaid {
- status = payment.ProviderStatusPaid
- }
- money := ""
- if resp.Money != nil {
- money = *resp.Money
- } else if resp.Data.Money != nil {
- money = *resp.Data.Money
- }
- responseTradeNo := tradeNo
- if resp.TradeNo != nil {
- if *resp.TradeNo != "" {
- responseTradeNo = *resp.TradeNo
- }
- } else if resp.Data.TradeNo != nil && *resp.Data.TradeNo != "" {
- responseTradeNo = *resp.Data.TradeNo
- }
- amount, _ := strconv.ParseFloat(money, 64)
- return &payment.QueryOrderResponse{
- TradeNo: responseTradeNo,
- Status: status,
- Amount: amount,
- Metadata: e.MerchantIdentityMetadata(),
- }, nil
- }
- func (e *EasyPay) VerifyNotification(_ context.Context, rawBody string, _ map[string]string) (*payment.PaymentNotification, error) {
- values, err := url.ParseQuery(rawBody)
- if err != nil {
- return nil, fmt.Errorf("parse notify: %w", err)
- }
- // url.ParseQuery already decodes values — no additional decode needed.
- params := make(map[string]string)
- for k := range values {
- params[k] = values.Get(k)
- }
- sign := params["sign"]
- if sign == "" {
- return nil, fmt.Errorf("missing sign")
- }
- if !easyPayVerifySign(params, e.config["pkey"], sign) {
- return nil, fmt.Errorf("invalid signature")
- }
- status := payment.ProviderStatusFailed
- if params["trade_status"] == tradeStatusSuccess {
- status = payment.ProviderStatusSuccess
- }
- amount, _ := strconv.ParseFloat(params["money"], 64)
- metadata := e.MerchantIdentityMetadata()
- if pid := strings.TrimSpace(params["pid"]); pid != "" {
- if metadata == nil {
- metadata = map[string]string{}
- }
- metadata["pid"] = pid
- }
- return &payment.PaymentNotification{
- TradeNo: params["trade_no"], OrderID: params["out_trade_no"],
- Amount: amount, Status: status, RawData: rawBody, Metadata: metadata,
- }, nil
- }
- func (e *EasyPay) Refund(ctx context.Context, req payment.RefundRequest) (*payment.RefundResponse, error) {
- attempts := e.refundAttempts(req)
- if len(attempts) == 0 {
- return nil, fmt.Errorf("easypay refund missing order identifier")
- }
- var firstErr error
- for i, attempt := range attempts {
- body, status, err := e.postRaw(ctx, e.apiBase()+"/api.php?act=refund", attempt.params)
- if err != nil {
- return nil, fmt.Errorf("easypay refund request: %w", err)
- }
- if err := parseEasyPayRefundResponse(status, body); err != nil {
- if firstErr == nil {
- firstErr = err
- }
- if i+1 < len(attempts) && isEasyPayRefundOrderNotFound(err) {
- continue
- }
- return nil, err
- }
- return &payment.RefundResponse{RefundID: attempt.refundID, Status: payment.ProviderStatusSuccess}, nil
- }
- return nil, firstErr
- }
- type easyPayRefundAttempt struct {
- params map[string]string
- refundID string
- }
- func (e *EasyPay) refundAttempts(req payment.RefundRequest) []easyPayRefundAttempt {
- base := map[string]string{
- "pid": e.config["pid"], "key": e.config["pkey"], "money": req.Amount,
- }
- var attempts []easyPayRefundAttempt
- if orderID := strings.TrimSpace(req.OrderID); orderID != "" {
- params := cloneStringMap(base)
- params["out_trade_no"] = orderID
- attempts = append(attempts, easyPayRefundAttempt{params: params, refundID: orderID})
- }
- if tradeNo := strings.TrimSpace(req.TradeNo); tradeNo != "" {
- params := cloneStringMap(base)
- params["trade_no"] = tradeNo
- attempts = append(attempts, easyPayRefundAttempt{params: params, refundID: tradeNo})
- }
- return attempts
- }
- func cloneStringMap(in map[string]string) map[string]string {
- out := make(map[string]string, len(in))
- for k, v := range in {
- out[k] = v
- }
- return out
- }
- func isEasyPayRefundOrderNotFound(err error) bool {
- if err == nil {
- return false
- }
- msg := err.Error()
- lower := strings.ToLower(msg)
- return strings.Contains(msg, "订单编号不存在") ||
- strings.Contains(msg, "订单不存在") ||
- strings.Contains(lower, "order not found") ||
- strings.Contains(lower, "not exist")
- }
- func parseEasyPayRefundResponse(status int, body []byte) error {
- summary := summarizeEasyPayResponse(body)
- if status < http.StatusOK || status >= http.StatusMultipleChoices {
- return fmt.Errorf("easypay refund HTTP %d: %s", status, summary)
- }
- trimmed := strings.TrimSpace(string(body))
- if trimmed == "" {
- return fmt.Errorf("easypay refund empty response (HTTP %d): %s", status, summary)
- }
- lower := strings.ToLower(trimmed)
- if strings.HasPrefix(lower, "<!doctype html") || strings.HasPrefix(lower, "<html") ||
- (strings.HasPrefix(lower, "<") && strings.Contains(lower, "html")) {
- return fmt.Errorf("easypay refund non-JSON response (HTTP %d): %s", status, summary)
- }
- var resp struct {
- Code any `json:"code"`
- Msg string `json:"msg"`
- }
- if err := json.Unmarshal(body, &resp); err != nil {
- return fmt.Errorf("easypay refund non-JSON response (HTTP %d): %s", status, summary)
- }
- if !easyPayResponseCodeIsSuccess(resp.Code) {
- msg := strings.TrimSpace(resp.Msg)
- if msg == "" {
- msg = summary
- }
- return fmt.Errorf("easypay refund failed (HTTP %d): %s", status, msg)
- }
- return nil
- }
- func easyPayResponseCodeIsSuccess(code any) bool {
- switch v := code.(type) {
- case float64:
- return int(v) == easypayCodeSuccess
- case string:
- n, err := strconv.Atoi(strings.TrimSpace(v))
- return err == nil && n == easypayCodeSuccess
- default:
- return false
- }
- }
- func summarizeEasyPayResponse(body []byte) string {
- summary := strings.Join(strings.Fields(string(body)), " ")
- if summary == "" {
- return "<empty>"
- }
- if len(summary) > maxEasypayErrorSummary {
- truncated := summary[:maxEasypayErrorSummary]
- for len(truncated) > 0 && !utf8.ValidString(truncated) {
- truncated = truncated[:len(truncated)-1]
- }
- return truncated + "..."
- }
- return summary
- }
- func (e *EasyPay) resolveCID(paymentType string) string {
- if strings.HasPrefix(paymentType, "alipay") {
- if v := e.config["cidAlipay"]; v != "" {
- return v
- }
- return e.config["cid"]
- }
- if v := e.config["cidWxpay"]; v != "" {
- return v
- }
- return e.config["cid"]
- }
- func (e *EasyPay) post(ctx context.Context, endpoint string, params map[string]string) ([]byte, error) {
- body, _, err := e.postRaw(ctx, endpoint, params)
- return body, err
- }
- func (e *EasyPay) postRaw(ctx context.Context, endpoint string, params map[string]string) ([]byte, int, error) {
- form := url.Values{}
- for k, v := range params {
- form.Set(k, v)
- }
- req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
- if err != nil {
- return nil, 0, err
- }
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
- client := e.httpClient
- if client == nil {
- client = &http.Client{Timeout: easypayHTTPTimeout}
- }
- resp, err := client.Do(req)
- if err != nil {
- return nil, 0, err
- }
- defer func() { _ = resp.Body.Close() }()
- body, err := io.ReadAll(io.LimitReader(resp.Body, maxEasypayResponseSize))
- if err != nil {
- return nil, resp.StatusCode, err
- }
- return body, resp.StatusCode, nil
- }
- func easyPaySign(params map[string]string, pkey string) string {
- keys := make([]string, 0, len(params))
- for k, v := range params {
- if k == "sign" || k == "sign_type" || v == "" {
- continue
- }
- keys = append(keys, k)
- }
- sort.Strings(keys)
- var buf strings.Builder
- for i, k := range keys {
- if i > 0 {
- _ = buf.WriteByte('&')
- }
- _, _ = buf.WriteString(k + "=" + params[k])
- }
- _, _ = buf.WriteString(pkey)
- hash := md5.Sum([]byte(buf.String()))
- return hex.EncodeToString(hash[:])
- }
- func easyPayVerifySign(params map[string]string, pkey string, sign string) bool {
- return hmac.Equal([]byte(easyPaySign(params, pkey)), []byte(sign))
- }
|