mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-19 16:17:56 +00:00
Order query:
- 彩虹易支付 and ZPay read act=order parameters from the URL query only;
a body-only request is answered with "No Act!", so order status queries
(reconcile, payment status checks) never reached the order. Parameters
are now sent in the URL query and mirrored in the form body for forks
that read the body.
- Decode status/money/trade_no leniently: PHP deployments commonly return
integers as strings ("status":"1"), which previously failed the whole
response decode. JSON null falls back to the nested data object.
- Treat a non-success code as a query error instead of an unpaid order,
and require an echoed out_trade_no to match the queried order.
- Strip the request URL (which carries the merchant key) from transport
errors before they are returned.
Notifications:
- Add payment.NotificationConfirmer. After a notification is verified, the
webhook asks the same provider instance to confirm it; EasyPay queries
the upstream order and requires it to be paid with a matching amount and
trade_no (a missing notify trade_no is filled from the query). Failures
are recorded as PAYMENT_UPSTREAM_UNCONFIRMED and answered with 502 so the
gateway retries; the order stays pending and reconcile still applies.
- EasyPay notifications must carry the instance's configured pid, and
field values other than name must be plain tokens.
- Return URLs must use a plain hostname or IP literal without userinfo.
728 lines
23 KiB
Go
728 lines
23 KiB
Go
// Package provider contains concrete payment provider implementations.
|
|
package provider
|
|
|
|
import (
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/md5"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
"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
|
|
easypayAmountTolerance = 0.005
|
|
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) {
|
|
result, err := e.queryOrder(ctx, tradeNo)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
responseTradeNo := tradeNo
|
|
if result.tradeNo != "" {
|
|
responseTradeNo = result.tradeNo
|
|
}
|
|
amount, _ := strconv.ParseFloat(result.money, 64)
|
|
return &payment.QueryOrderResponse{
|
|
TradeNo: responseTradeNo,
|
|
Status: result.status,
|
|
Amount: amount,
|
|
Metadata: e.MerchantIdentityMetadata(),
|
|
}, nil
|
|
}
|
|
|
|
// easyPayQueryResult is the normalized api.php?act=order answer. tradeNo is
|
|
// the upstream trade number exactly as returned (empty when absent).
|
|
type easyPayQueryResult struct {
|
|
status string
|
|
money string
|
|
tradeNo string
|
|
}
|
|
|
|
func (e *EasyPay) queryOrder(ctx context.Context, outTradeNo string) (*easyPayQueryResult, error) {
|
|
params := map[string]string{
|
|
"act": "order", "pid": e.config["pid"],
|
|
"key": e.config["pkey"], "out_trade_no": outTradeNo,
|
|
}
|
|
// 彩虹易支付 and ZPay read act=order parameters from $_GET only (a
|
|
// body-only request is answered with "No Act!"), while other forks read
|
|
// the form body; send them in both places.
|
|
query := url.Values{}
|
|
for k, v := range params {
|
|
query.Set(k, v)
|
|
}
|
|
body, err := e.post(ctx, e.apiBase()+"/api.php?"+query.Encode(), params)
|
|
if err != nil {
|
|
// The request URL carries the merchant key; never let it reach logs
|
|
// or audit records through *url.Error.
|
|
var urlErr *url.Error
|
|
if errors.As(err, &urlErr) {
|
|
err = urlErr.Err
|
|
}
|
|
return nil, fmt.Errorf("easypay query: %w", err)
|
|
}
|
|
// Scalars are decoded leniently: PHP deployments commonly serialize
|
|
// integer columns as strings ("status":"1").
|
|
type easyPayQueryData struct {
|
|
TradeStatus *string `json:"trade_status"`
|
|
Status json.RawMessage `json:"status"`
|
|
Money json.RawMessage `json:"money"`
|
|
TradeNo json.RawMessage `json:"trade_no"`
|
|
OutTradeNo json.RawMessage `json:"out_trade_no"`
|
|
}
|
|
var resp struct {
|
|
easyPayQueryData
|
|
Code any `json:"code"`
|
|
Msg string `json:"msg"`
|
|
Data easyPayQueryData `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(body, &resp); err != nil {
|
|
return nil, fmt.Errorf("easypay parse query: %w", err)
|
|
}
|
|
// An explicit failure code wins over any paid-looking status field that
|
|
// may accompany it.
|
|
if resp.Code != nil && !easyPayResponseCodeIsSuccess(resp.Code) {
|
|
msg := strings.TrimSpace(resp.Msg)
|
|
if msg == "" {
|
|
msg = summarizeEasyPayResponse(body)
|
|
}
|
|
return nil, fmt.Errorf("easypay query failed: %s", msg)
|
|
}
|
|
// When the upstream echoes the order identity it must be this order; some
|
|
// implementations silently ignore unknown lookup parameters.
|
|
if echoed := easyPayJSONScalar(pickEasyPayJSON(resp.OutTradeNo, resp.Data.OutTradeNo)); echoed != "" && echoed != strings.TrimSpace(outTradeNo) {
|
|
return nil, fmt.Errorf("easypay query out_trade_no mismatch: expected %s, got %s", strings.TrimSpace(outTradeNo), echoed)
|
|
}
|
|
|
|
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 raw := pickEasyPayJSON(resp.Status, resp.Data.Status); raw != nil {
|
|
if easyPayJSONScalar(raw) == strconv.Itoa(easypayStatusPaid) {
|
|
status = payment.ProviderStatusPaid
|
|
}
|
|
}
|
|
|
|
return &easyPayQueryResult{
|
|
status: status,
|
|
money: easyPayJSONScalar(pickEasyPayJSON(resp.Money, resp.Data.Money)),
|
|
tradeNo: easyPayJSONScalar(pickEasyPayJSON(resp.TradeNo, resp.Data.TradeNo)),
|
|
}, nil
|
|
}
|
|
|
|
// ConfirmNotification checks a verified success notification against the
|
|
// upstream order before it is fulfilled, so crediting follows the gateway's
|
|
// own order state (paid, amount, trade_no). The query is sent to this
|
|
// instance's own apiBase with its own credentials.
|
|
func (e *EasyPay) ConfirmNotification(ctx context.Context, n *payment.PaymentNotification) error {
|
|
if n == nil || n.Status != payment.NotificationStatusSuccess {
|
|
return nil
|
|
}
|
|
result, err := e.queryOrder(ctx, n.OrderID)
|
|
if err != nil {
|
|
return fmt.Errorf("easypay confirm: %w", err)
|
|
}
|
|
if result.status != payment.ProviderStatusPaid {
|
|
return fmt.Errorf("easypay confirm: upstream reports order %s as not paid", n.OrderID)
|
|
}
|
|
queriedAmount, err := strconv.ParseFloat(result.money, 64)
|
|
if err != nil || math.Abs(queriedAmount-n.Amount) > easypayAmountTolerance {
|
|
return fmt.Errorf("easypay confirm: amount mismatch: notified %v, upstream %q", n.Amount, result.money)
|
|
}
|
|
if result.tradeNo != "" {
|
|
if n.TradeNo != "" && n.TradeNo != result.tradeNo {
|
|
return fmt.Errorf("easypay confirm: trade_no mismatch: notified %s, upstream %s", n.TradeNo, result.tradeNo)
|
|
}
|
|
n.TradeNo = result.tradeNo
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func easyPayJSONScalar(raw json.RawMessage) string {
|
|
if len(raw) == 0 {
|
|
return ""
|
|
}
|
|
var str string
|
|
if err := json.Unmarshal(raw, &str); err == nil {
|
|
return strings.TrimSpace(str)
|
|
}
|
|
var num json.Number
|
|
if err := json.Unmarshal(raw, &num); err == nil {
|
|
return num.String()
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// pickEasyPayJSON returns the top-level field when present, otherwise the
|
|
// nested data field. JSON null counts as absent.
|
|
func pickEasyPayJSON(top, nested json.RawMessage) json.RawMessage {
|
|
if len(top) > 0 && string(top) != "null" {
|
|
return top
|
|
}
|
|
if len(nested) > 0 && string(nested) != "null" {
|
|
return nested
|
|
}
|
|
return 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 {
|
|
if !easyPayNotifyAllowedParams[k] {
|
|
return nil, fmt.Errorf("unexpected notify param: %s", k)
|
|
}
|
|
v := values.Get(k)
|
|
// Notify values are plain tokens; only the merchant-defined product
|
|
// name is free text. Delimiter characters elsewhere are rejected so
|
|
// each value maps to exactly one field of the signed string.
|
|
if k != "name" && strings.ContainsAny(v, "&=") {
|
|
return nil, fmt.Errorf("invalid notify param value: %s", k)
|
|
}
|
|
params[k] = v
|
|
}
|
|
sign := params["sign"]
|
|
if sign == "" {
|
|
return nil, fmt.Errorf("missing sign")
|
|
}
|
|
if !easyPayVerifySign(params, e.config["pkey"], sign) {
|
|
return nil, fmt.Errorf("invalid signature")
|
|
}
|
|
// Bind the notification to this instance's merchant directly instead of
|
|
// relying on the order snapshot, which legacy orders do not carry.
|
|
if expected, actual := strings.TrimSpace(e.config["pid"]), strings.TrimSpace(params["pid"]); actual != expected {
|
|
return nil, fmt.Errorf("easypay pid mismatch: expected %s, got %s", expected, actual)
|
|
}
|
|
status := payment.ProviderStatusFailed
|
|
if params["trade_status"] == tradeStatusSuccess {
|
|
status = payment.ProviderStatusSuccess
|
|
}
|
|
amount, _ := strconv.ParseFloat(params["money"], 64)
|
|
|
|
return &payment.PaymentNotification{
|
|
TradeNo: params["trade_no"], OrderID: params["out_trade_no"],
|
|
Amount: amount, Status: status, RawData: rawBody, Metadata: e.MerchantIdentityMetadata(),
|
|
}, 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))
|
|
}
|
|
|
|
// easyPayNotifyAllowedParams is the exact parameter set a genuine EasyPay
|
|
// (彩虹易支付-compatible) async notification carries. Order-creation-only
|
|
// fields (notify_url, return_url, cid, device, clientip, ...) must never
|
|
// appear in a callback: because the sign base string concatenates values
|
|
// unescaped, a signed order URL whose return_url embeds e.g.
|
|
// "trade_status=TRADE_SUCCESS" could otherwise be replayed as a forged
|
|
// payment-success notification (issue #7881). Rejecting unknown keys closes
|
|
// the whole smuggling class; genuinely paid orders rejected by an exotic
|
|
// upstream variant are still recovered by the upstream QueryOrder reconcile
|
|
// path.
|
|
var easyPayNotifyAllowedParams = map[string]bool{
|
|
"pid": true,
|
|
"trade_no": true,
|
|
"out_trade_no": true,
|
|
"type": true,
|
|
"name": true,
|
|
"money": true,
|
|
"trade_status": true,
|
|
"param": true,
|
|
"sign": true,
|
|
"sign_type": true,
|
|
}
|