fix(merge): PR 集成 - fix merge 上传状态与易支付校验
在 #544、#545、#546 的原作者 merge commit 之后独立修复:保留上传 Promise、处理重复上传与复制失败、格式对齐,以及易支付网关、订单证据、签名、通知幂等与安全关单校验。同步重建六平台插件产物和包。 验证:前端 1832 tests、build/typecheck、lint、相关格式检查通过;财务 SQLite、支付、插件 RPC 与产物专项通过。后端全量出现一项未改动的素材删除 SQLite 表锁失败,单独复跑 3 次通过,保留全量未通过记录。浏览器与真实商户验收待完成。#543 因迁移历史与能力限制回退暂缓。
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package paymentplugins
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"crypto/subtle"
|
||||
@@ -18,10 +19,9 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
defaultEpayGateway = "https://pay.admincloudai.com"
|
||||
epayPluginVersion = "1.0.8"
|
||||
epayQRLifetime = 10 * time.Minute
|
||||
maxEpayResponse = 2 << 20
|
||||
epayPluginVersion = "1.0.9"
|
||||
epayQRLifetime = 10 * time.Minute
|
||||
maxEpayResponse = 2 << 20
|
||||
)
|
||||
|
||||
type EpayProvider struct {
|
||||
@@ -102,7 +102,11 @@ func (p *EpayProvider) CreateOrder(ctx context.Context, config Config, request C
|
||||
if version == "1" {
|
||||
params["method"] = "jump"
|
||||
params["timestamp"] = strconv.FormatInt(p.now().Unix(), 10)
|
||||
params["sign"] = epayRSASign(params, config["privateKey"])
|
||||
signature, err := epayRSASign(params, config["privateKey"])
|
||||
if err != nil {
|
||||
return Checkout{}, &ProviderError{Code: "epay_sign_failed", Message: "易支付请求签名失败", Cause: err}
|
||||
}
|
||||
params["sign"] = signature
|
||||
params["sign_type"] = "RSA"
|
||||
path = "/api/pay/create"
|
||||
successCode = "0"
|
||||
@@ -137,6 +141,7 @@ func (p *EpayProvider) CreateOrder(ctx context.Context, config Config, request C
|
||||
} else {
|
||||
// urlscheme 是小程序跳转链接,前端直接跳;没有本地收银台时二维码链接也直接跳过去
|
||||
value = firstNonEmpty(epayString(response["payurl"]), epayString(response["urlscheme"]), qrcode)
|
||||
mode = "redirect"
|
||||
}
|
||||
}
|
||||
if !isEpayURL(value) {
|
||||
@@ -179,7 +184,10 @@ func (p *EpayProvider) QueryOrder(ctx context.Context, config Config, request Qu
|
||||
}
|
||||
return Result{}, &ProviderError{Code: "epay_query_failed", Message: message, Temporary: status >= 500}
|
||||
}
|
||||
return epayResult(response, request.MerchantOrderNo), nil
|
||||
if response["pid"] != "" && response["pid"] != strings.TrimSpace(config["pid"]) {
|
||||
return Result{}, errors.New("易支付查单商户号不匹配")
|
||||
}
|
||||
return epayResult(response, request.MerchantOrderNo)
|
||||
}
|
||||
|
||||
func (p *EpayProvider) CloseOrder(ctx context.Context, config Config, request CloseRequest) (Result, error) {
|
||||
@@ -201,8 +209,8 @@ func (p *EpayProvider) CloseOrder(ctx context.Context, config Config, request Cl
|
||||
result.ProviderStatus = "TRADE_CLOSED"
|
||||
return result, nil
|
||||
}
|
||||
// V2 没有通用远程关单接口,只关闭宿主本地订单。
|
||||
return Result{MerchantOrderNo: request.MerchantOrderNo, ProviderStatus: "TRADE_CLOSED", Currency: "CNY", Closed: true}, nil
|
||||
// 未能查证上游状态时不能把未知订单标为已关闭。
|
||||
return Result{}, &ProviderError{Code: "epay_close_unsupported", Message: "易支付 V2 暂不支持查单及安全关单,请在支付网关核对订单"}
|
||||
}
|
||||
|
||||
func (p *EpayProvider) VerifyNotification(_ context.Context, config Config, _ http.Header, rawBody []byte) (Notification, error) {
|
||||
@@ -215,9 +223,10 @@ func (p *EpayProvider) VerifyNotification(_ context.Context, config Config, _ ht
|
||||
}
|
||||
values := make(map[string]string, len(params))
|
||||
for key, items := range params {
|
||||
if len(items) > 0 {
|
||||
values[key] = items[0]
|
||||
if len(items) != 1 {
|
||||
return Notification{}, errors.New("易支付异步通知存在重复参数")
|
||||
}
|
||||
values[key] = items[0]
|
||||
}
|
||||
if values["pid"] != strings.TrimSpace(config["pid"]) {
|
||||
return Notification{}, errors.New("易支付异步通知商户号不匹配")
|
||||
@@ -250,19 +259,25 @@ func (p *EpayProvider) VerifyNotification(_ context.Context, config Config, _ ht
|
||||
}
|
||||
|
||||
amount, err := parseYuanToFen(values["money"])
|
||||
if err != nil {
|
||||
return Notification{}, fmt.Errorf("易支付异步通知金额无效: %w", err)
|
||||
if err != nil || amount <= 0 {
|
||||
return Notification{}, errors.New("易支付异步通知金额无效")
|
||||
}
|
||||
status := strings.TrimSpace(values["trade_status"])
|
||||
paid := status == "TRADE_SUCCESS"
|
||||
// 宿主通知队列只处理到账事件,其他状态不得占用同一流水的幂等键。
|
||||
if status != "TRADE_SUCCESS" {
|
||||
return Notification{}, errors.New("易支付异步通知不是成功状态")
|
||||
}
|
||||
providerTradeNo := firstNonEmpty(values["trade_no"], values["api_trade_no"])
|
||||
if providerTradeNo == "" {
|
||||
return Notification{}, errors.New("易支付成功通知缺少支付流水号")
|
||||
}
|
||||
eventID := firstNonEmpty(values["notify_id"], providerTradeNo, values["out_trade_no"]+":"+status)
|
||||
return Notification{
|
||||
EventID: eventID,
|
||||
Result: Result{
|
||||
MerchantOrderNo: values["out_trade_no"], ProviderTradeNo: providerTradeNo,
|
||||
ProviderStatus: status, AmountFen: amount, Currency: "CNY",
|
||||
Paid: paid, Closed: status == "TRADE_CLOSED", PaidAt: parseEpayTime(firstNonEmpty(values["endtime"], values["gmt_payment"])),
|
||||
Paid: true, PaidAt: parseEpayTime(firstNonEmpty(values["endtime"], values["gmt_payment"])),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -322,9 +337,14 @@ func epayJSONResponse(resp *http.Response) (epayResponse, int, error) {
|
||||
}
|
||||
var raw map[string]any
|
||||
// 有的网关输出带 BOM
|
||||
if err := json.Unmarshal([]byte(strings.TrimPrefix(string(body), "\xEF\xBB\xBF")), &raw); err != nil {
|
||||
decoder := json.NewDecoder(bytes.NewReader(bytes.TrimPrefix(body, []byte("\xEF\xBB\xBF"))))
|
||||
decoder.UseNumber()
|
||||
if err := decoder.Decode(&raw); err != nil {
|
||||
return nil, resp.StatusCode, &ProviderError{Code: "epay_response_parse_error", Message: "易支付响应不是有效 JSON", Temporary: true, Cause: err}
|
||||
}
|
||||
if err := decoder.Decode(new(any)); err != io.EOF {
|
||||
return nil, resp.StatusCode, &ProviderError{Code: "epay_response_parse_error", Message: "易支付响应包含多余内容"}
|
||||
}
|
||||
result := make(epayResponse, len(raw))
|
||||
for key, value := range raw {
|
||||
result[key] = epayString(value)
|
||||
@@ -335,7 +355,7 @@ func epayJSONResponse(resp *http.Response) (epayResponse, int, error) {
|
||||
func epayGateway(config Config) (string, error) {
|
||||
gateway := strings.TrimSpace(config["gateway"])
|
||||
if gateway == "" {
|
||||
gateway = defaultEpayGateway
|
||||
return "", errors.New("易支付配置缺少支付网关 gateway")
|
||||
}
|
||||
parsed, err := url.Parse(gateway)
|
||||
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
@@ -414,35 +434,46 @@ func epayMD5Hex(value string) string {
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
func epayRSASign(values map[string]string, privateKey string) string {
|
||||
func epayRSASign(values map[string]string, privateKey string) (string, error) {
|
||||
key, err := parseRSAPrivateKey(privateKey)
|
||||
if err != nil {
|
||||
return ""
|
||||
return "", err
|
||||
}
|
||||
signature, err := rsaSHA256Sign(key, []byte(epayCanonical(values)))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return signature
|
||||
return rsaSHA256Sign(key, []byte(epayCanonical(values)))
|
||||
}
|
||||
|
||||
func epaySafetyEquals(actual, expected string) bool {
|
||||
return actual != "" && len(actual) == len(expected) && subtle.ConstantTimeCompare([]byte(actual), []byte(expected)) == 1
|
||||
}
|
||||
|
||||
func epayResult(response epayResponse, fallbackOrderNo string) Result {
|
||||
func epayResult(response epayResponse, expectedOrderNo string) (Result, error) {
|
||||
status := epayString(response["status"])
|
||||
if status == "" && response["trade_status"] != "" {
|
||||
status = response["trade_status"]
|
||||
}
|
||||
amount, _ := parseYuanToFen(firstNonEmpty(epayString(response["money"]), epayString(response["total_amount"])))
|
||||
switch status {
|
||||
case "0", "1", "WAIT_BUYER_PAY", "TRADE_SUCCESS", "TRADE_CLOSED":
|
||||
default:
|
||||
return Result{}, errors.New("易支付查单支付状态缺失或无效")
|
||||
}
|
||||
if response["out_trade_no"] != expectedOrderNo {
|
||||
return Result{}, errors.New("易支付查单订单号缺失或不匹配")
|
||||
}
|
||||
amount, err := parseYuanToFen(firstNonEmpty(response["money"], response["total_amount"]))
|
||||
if err != nil || amount <= 0 {
|
||||
return Result{}, errors.New("易支付查单金额无效")
|
||||
}
|
||||
providerTradeNo := firstNonEmpty(response["trade_no"], response["api_trade_no"])
|
||||
if (status == "1" || status == "TRADE_SUCCESS") && providerTradeNo == "" {
|
||||
return Result{}, errors.New("易支付成功订单缺少支付流水号")
|
||||
}
|
||||
return Result{
|
||||
MerchantOrderNo: firstNonEmpty(epayString(response["out_trade_no"]), fallbackOrderNo),
|
||||
ProviderTradeNo: firstNonEmpty(epayString(response["trade_no"]), epayString(response["api_trade_no"])),
|
||||
MerchantOrderNo: response["out_trade_no"],
|
||||
ProviderTradeNo: providerTradeNo,
|
||||
ProviderStatus: status, AmountFen: amount, Currency: "CNY",
|
||||
Paid: status == "1" || status == "TRADE_SUCCESS", Closed: status == "TRADE_CLOSED",
|
||||
PaidAt: parseEpayTime(firstNonEmpty(epayString(response["endtime"]), epayString(response["gmt_payment"]))),
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
func epayString(value any) string {
|
||||
@@ -460,21 +491,20 @@ func epayString(value any) string {
|
||||
}
|
||||
}
|
||||
|
||||
// 只认带协议头的地址(http(s),或 weixin:// 这类唤起 App 的),挡掉 javascript: 之类
|
||||
// 只允许浏览器支付页和已知支付 App 协议,不接受任意本机协议。
|
||||
func isEpayURL(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil || parsed.Scheme == "" {
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" || parsed.User != nil {
|
||||
return false
|
||||
}
|
||||
scheme := strings.ToLower(parsed.Scheme)
|
||||
if scheme == "javascript" || scheme == "data" || scheme == "vbscript" {
|
||||
switch scheme {
|
||||
case "http", "https", "weixin", "alipay", "alipays", "mqqapi":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
if scheme == "http" || scheme == "https" {
|
||||
return parsed.Host != ""
|
||||
}
|
||||
return strings.Contains(value, "://")
|
||||
}
|
||||
|
||||
func parseEpayTime(value string) time.Time {
|
||||
|
||||
157
backend/payment-plugins/epay_merge_test.go
Normal file
157
backend/payment-plugins/epay_merge_test.go
Normal file
@@ -0,0 +1,157 @@
|
||||
package paymentplugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEpayRequiresExplicitGateway(t *testing.T) {
|
||||
if err := NewEpayProvider(nil).ValidateConfig(Config{"pid": "merchant", "key": "test-key"}); err == nil {
|
||||
t.Fatal("missing gateway must not send merchant credentials to a default third party")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEpayCheckoutURLAllowlist(t *testing.T) {
|
||||
for _, value := range []string{"file://host/etc/passwd", "smb://host/share", "vscode://file/tmp/a", "custom://pay", "javascript://alert(1)", "data://host/value", "https://user:pass@pay.example", "weixin:", "//pay.example"} {
|
||||
if isEpayURL(value) {
|
||||
t.Errorf("accepted unsafe checkout: %s", value)
|
||||
}
|
||||
}
|
||||
for _, value := range []string{"https://pay.example/order", "weixin://wxpay/bizpayurl?pr=test", "alipays://platformapi/startapp?appId=1", "mqqapi://wallet/pay"} {
|
||||
if !isEpayURL(value) {
|
||||
t.Errorf("rejected payment URL: %s", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEpayQueryRejectsIncompleteEvidence(t *testing.T) {
|
||||
for _, test := range []struct{ name, response string }{
|
||||
{"missing order", `{"code":1,"status":1,"money":"1.00","trade_no":"trade"}`},
|
||||
{"different order", `{"code":1,"out_trade_no":"other","status":1,"money":"1.00","trade_no":"trade"}`},
|
||||
{"different merchant", `{"code":1,"pid":"other","out_trade_no":"order","status":1,"money":"1.00","trade_no":"trade"}`},
|
||||
{"invalid amount", `{"code":1,"out_trade_no":"order","status":1,"money":"bad","trade_no":"trade"}`},
|
||||
{"missing amount", `{"code":1,"out_trade_no":"order","status":1,"trade_no":"trade"}`},
|
||||
{"zero amount", `{"code":1,"out_trade_no":"order","status":1,"money":"0","trade_no":"trade"}`},
|
||||
{"missing transaction", `{"code":1,"out_trade_no":"order","status":1,"money":"1.00"}`},
|
||||
{"missing status", `{"code":1,"out_trade_no":"order","money":"1.00","trade_no":"trade"}`},
|
||||
{"trailing JSON", `{"code":1,"out_trade_no":"order","status":1,"money":"1.00","trade_no":"trade"}{}`},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, test.response) }))
|
||||
defer server.Close()
|
||||
provider := NewEpayProvider(server.Client())
|
||||
config := Config{"pid": "merchant", "key": "test-key", "gateway": server.URL}
|
||||
if result, err := provider.QueryOrder(context.Background(), config, QueryRequest{MerchantOrderNo: "order"}); err == nil {
|
||||
t.Fatalf("invalid payment evidence accepted: %+v", result)
|
||||
}
|
||||
if result, err := provider.CloseOrder(context.Background(), config, CloseRequest{MerchantOrderNo: "order"}); err == nil {
|
||||
t.Fatalf("invalid evidence must not close an order: %+v", result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEpayQueryPreservesNumericTradeID(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, `{"code":1,"out_trade_no":"order","status":1,"money":"1.00","trade_no":1234567890123456789}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
result, err := NewEpayProvider(server.Client()).QueryOrder(context.Background(), Config{"pid": "merchant", "key": "test-key", "gateway": server.URL}, QueryRequest{MerchantOrderNo: "order"})
|
||||
if err != nil || !result.Paid || result.ProviderTradeNo != "1234567890123456789" {
|
||||
t.Fatalf("trade ID lost precision: %+v, %v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEpayQRCodePreferenceFallsBackToRedirectForPayURL(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, `{"code":1,"payurl":"https://pay.example/checkout"}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
checkout, err := NewEpayProvider(server.Client()).CreateOrder(context.Background(), Config{"pid": "merchant", "key": "test-key", "gateway": server.URL, "checkoutMode": "qr_code"}, CreateRequest{
|
||||
MerchantOrderNo: "order", AmountFen: 100, Currency: "CNY", NotifyURL: "https://merchant.example/notify",
|
||||
})
|
||||
if err != nil || checkout.Mode != "redirect" {
|
||||
t.Fatalf("checkout must match returned payload: %+v, %v", checkout, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEpayNotificationRejectsDuplicateAndInvalidEvidence(t *testing.T) {
|
||||
config := Config{"pid": "merchant", "key": "test-key", "gateway": "https://pay.example"}
|
||||
for _, kind := range []string{"duplicate", "invalid amount", "zero amount", "missing transaction", "tampered", "pending", "closed", "missing status"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
values := map[string]string{"pid": "merchant", "out_trade_no": "order", "trade_no": "trade", "money": "1.00", "trade_status": "TRADE_SUCCESS"}
|
||||
switch kind {
|
||||
case "invalid amount":
|
||||
values["money"] = "invalid"
|
||||
case "zero amount":
|
||||
values["money"] = "0"
|
||||
case "missing transaction":
|
||||
delete(values, "trade_no")
|
||||
case "pending":
|
||||
values["trade_status"] = "WAIT_BUYER_PAY"
|
||||
case "closed":
|
||||
values["trade_status"] = "TRADE_CLOSED"
|
||||
case "missing status":
|
||||
delete(values, "trade_status")
|
||||
}
|
||||
values["sign"] = epayMD5Sign(values, config["key"])
|
||||
if kind == "tampered" {
|
||||
values["money"] = "9.00"
|
||||
}
|
||||
form := url.Values{}
|
||||
for key, value := range values {
|
||||
form.Set(key, value)
|
||||
}
|
||||
if kind == "duplicate" {
|
||||
form.Add("money", "9.00")
|
||||
}
|
||||
if _, err := NewEpayProvider(nil).VerifyNotification(context.Background(), config, nil, []byte(form.Encode())); err == nil {
|
||||
t.Fatal("invalid notification accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEpayV2NotificationAndUnsupportedClose(t *testing.T) {
|
||||
privateKey, _ := testRSAKeyPair(t)
|
||||
config := Config{"pid": "merchant", "version": "1", "gateway": "https://pay.example", "privateKey": testPrivatePEM(privateKey), "platformPublicKey": testPublicPEM(&privateKey.PublicKey)}
|
||||
values := map[string]string{"pid": "merchant", "out_trade_no": "order", "trade_no": "trade", "money": "1.00", "trade_status": "TRADE_SUCCESS", "sign_type": "RSA"}
|
||||
signature, err := epayRSASign(values, config["privateKey"])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
values["sign"] = signature
|
||||
form := url.Values{}
|
||||
for key, value := range values {
|
||||
form.Set(key, value)
|
||||
}
|
||||
provider := NewEpayProvider(nil)
|
||||
notification, err := provider.VerifyNotification(context.Background(), config, nil, []byte(form.Encode()))
|
||||
if err != nil || !notification.Paid || notification.AmountFen != 100 {
|
||||
t.Fatalf("valid RSA notification rejected: %+v, %v", notification, err)
|
||||
}
|
||||
form.Set("money", "9.00")
|
||||
if _, err := provider.VerifyNotification(context.Background(), config, nil, []byte(form.Encode())); err == nil {
|
||||
t.Fatal("tampered RSA notification accepted")
|
||||
}
|
||||
if result, err := provider.CloseOrder(context.Background(), config, CloseRequest{MerchantOrderNo: "order"}); err == nil || result.Closed {
|
||||
t.Fatalf("unsupported V2 close must not report success: %+v, %v", result, err)
|
||||
}
|
||||
if _, err := epayRSASign(values, "invalid-key"); err == nil {
|
||||
t.Fatal("signing error must not be swallowed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEpayTransportErrorsDoNotExposeMerchantKey(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||
server.Close()
|
||||
_, err := NewEpayProvider(server.Client()).QueryOrder(context.Background(), Config{"pid": "merchant", "key": "secret-marker", "gateway": server.URL}, QueryRequest{MerchantOrderNo: "order"})
|
||||
if err == nil || strings.Contains(err.Error(), "secret-marker") {
|
||||
t.Fatalf("unsafe transport error: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user