diff --git a/backend/cmd/payment-epay/main.go b/backend/cmd/payment-epay/main.go new file mode 100644 index 000000000..0c4b83a24 --- /dev/null +++ b/backend/cmd/payment-epay/main.go @@ -0,0 +1,16 @@ +package main + +import ( + "net/http" + "os" + "time" + + paymentplugins "infinite-canvas/backend/payment-plugins" +) + +func main() { + provider := paymentplugins.NewEpayProvider(&http.Client{Timeout: 25 * time.Second}) + if err := paymentplugins.RunRPC(provider, os.Stdin, os.Stdout); err != nil { + os.Exit(1) + } +} diff --git a/backend/internal/app/payment_plugins.go b/backend/internal/app/payment_plugins.go index 8c3c50b7d..9dd0428f8 100644 --- a/backend/internal/app/payment_plugins.go +++ b/backend/internal/app/payment_plugins.go @@ -9,10 +9,12 @@ const ( PaymentPluginAlipayPage = "official-payment-alipay-page" PaymentPluginXunHuPay = "official-payment-xunhupay" PaymentPluginZhiFuFM = "official-payment-zhifufm" + PaymentPluginEpay = "official-payment-epay" PaymentProviderWeChat = "wechat-native" PaymentProviderAlipay = "alipay-page-pay" PaymentProviderXunHuPay = "xunhupay-aggregate" PaymentProviderZhiFuFM = "zhifufm-pay" + PaymentProviderEpay = "epay" ) func bundledPaymentPluginManifests() []protocol.Manifest { diff --git a/backend/internal/app/payment_plugins_test.go b/backend/internal/app/payment_plugins_test.go index 883fa5c1a..f3cff10ff 100644 --- a/backend/internal/app/payment_plugins_test.go +++ b/backend/internal/app/payment_plugins_test.go @@ -186,3 +186,38 @@ func TestZhiFuFMOfficialPackageIsPaymentPlugin(t *testing.T) { t.Fatalf("zhifufm provider type = %T", provider) } } + +func TestEpayOfficialPackageIsPaymentPlugin(t *testing.T) { + center, err := newPluginRuntime(t.TempDir()) + if err != nil { + t.Fatal(err) + } + var plugin PluginView + for _, item := range center.list() { + if item.Manifest.ID == "official-payment-epay" { + plugin = item + plugin.Management = pluginManagementFromView(item) + break + } + } + if plugin.Manifest.ID == "" { + t.Fatal("official-payment-epay is missing") + } + if plugin.Management.Kind != PluginKindPayment || plugin.Source != PluginOriginOfficial { + t.Fatalf("epay plugin = %#v", plugin) + } + if len(plugin.Manifest.Contributes.PaymentProviders) != 1 || plugin.Manifest.Contributes.PaymentProviders[0].ID != "epay" { + t.Fatalf("epay contributions = %#v", plugin.Manifest.Contributes.PaymentProviders) + } + registry := center.paymentRegistrySnapshot() + if registry == nil { + t.Fatal("payment registry is nil") + } + provider, ok := registry.Get("epay") + if !ok { + t.Fatal("epay provider is missing") + } + if _, ok := provider.(*payment.RPCProvider); !ok { + t.Fatalf("epay provider type = %T", provider) + } +} diff --git a/backend/internal/protocol/payment_artifact_test.go b/backend/internal/protocol/payment_artifact_test.go index ffa8d7f66..71bd1a4eb 100644 --- a/backend/internal/protocol/payment_artifact_test.go +++ b/backend/internal/protocol/payment_artifact_test.go @@ -17,6 +17,7 @@ var officialPaymentPackageIDs = []string{ "official-payment-alipay-page", "official-payment-xunhupay", "official-payment-zhifufm", + "official-payment-epay", } var officialPaymentTaggedArtifacts = []string{ diff --git a/backend/payment-plugins/aliases.go b/backend/payment-plugins/aliases.go index 674ab8914..09e78175d 100644 --- a/backend/payment-plugins/aliases.go +++ b/backend/payment-plugins/aliases.go @@ -26,8 +26,10 @@ const ( ProviderAlipayPage = "alipay-page-pay" ProviderXunHuPay = "xunhupay-aggregate" ProviderZhiFuFM = "zhifufm-pay" + ProviderEpay = "epay" PluginWeChatNative = "official-payment-wechat-native" PluginAlipayPage = "official-payment-alipay-page" PluginXunHuPay = "official-payment-xunhupay" PluginZhiFuFM = "official-payment-zhifufm" + PluginEpay = "official-payment-epay" ) diff --git a/backend/payment-plugins/epay.go b/backend/payment-plugins/epay.go new file mode 100644 index 000000000..533101c5f --- /dev/null +++ b/backend/payment-plugins/epay.go @@ -0,0 +1,488 @@ +package paymentplugins + +import ( + "context" + "crypto/md5" + "crypto/subtle" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "strconv" + "strings" + "time" +) + +const ( + defaultEpayGateway = "https://pay.admincloudai.com" + epayPluginVersion = "1.0.8" + epayQRLifetime = 10 * time.Minute + maxEpayResponse = 2 << 20 +) + +type EpayProvider struct { + client *http.Client + now func() time.Time +} + +func NewEpayProvider(client *http.Client) *EpayProvider { + if client == nil { + client = http.DefaultClient + } + return &EpayProvider{client: client, now: time.Now} +} + +func (p *EpayProvider) Descriptor() Descriptor { + return Descriptor{ + ID: ProviderEpay, PluginID: PluginEpay, PluginVersion: epayPluginVersion, + Name: "易支付", Icon: "assets/icon.svg", CheckoutMode: "redirect", + IdentityFields: []string{"pid"}, + NotificationSuccess: NotificationResponse{Status: 200, ContentType: "text/plain; charset=utf-8", Body: "success"}, + NotificationFailure: NotificationResponse{Status: 400, ContentType: "text/plain; charset=utf-8", Body: "failure"}, + } +} + +func (p *EpayProvider) ValidateConfig(config Config) error { + if strings.TrimSpace(config["pid"]) == "" { + return errors.New("易支付配置缺少商户号 pid") + } + version := epayVersion(config) + if version != "0" && version != "1" { + return errors.New("易支付 version 必须是 0 或 1") + } + if version == "0" && strings.TrimSpace(config["key"]) == "" { + return errors.New("易支付 V1 配置缺少商户密钥 key") + } + if version == "1" { + if strings.TrimSpace(config["privateKey"]) == "" || strings.TrimSpace(config["platformPublicKey"]) == "" { + return errors.New("易支付 V2 配置缺少 privateKey 或 platformPublicKey") + } + if _, err := parseRSAPrivateKey(config["privateKey"]); err != nil { + return fmt.Errorf("易支付商户私钥无效: %w", err) + } + if _, err := parseRSAPublicKey(config["platformPublicKey"]); err != nil { + return fmt.Errorf("易支付平台公钥无效: %w", err) + } + } + if _, err := epayGateway(config); err != nil { + return err + } + return nil +} + +func (p *EpayProvider) CreateOrder(ctx context.Context, config Config, request CreateRequest) (Checkout, error) { + if err := p.ValidateConfig(config); err != nil { + return Checkout{}, err + } + if request.AmountFen <= 0 || request.Currency != "CNY" || strings.TrimSpace(request.MerchantOrderNo) == "" || strings.TrimSpace(request.NotifyURL) == "" { + return Checkout{}, errors.New("易支付下单参数无效") + } + + params := map[string]string{ + "pid": strings.TrimSpace(config["pid"]), + "name": epayTitle(request.Description, request.MerchantOrderNo), + "type": epayPayType(config), + "money": formatEpayAmount(request.AmountFen), + "out_trade_no": request.MerchantOrderNo, + "notify_url": request.NotifyURL, + "return_url": request.ReturnURL, + "sitename": request.MerchantOrderNo, + // CreateRequest 不带买家 IP;mapi 仍要求 clientip,占位即可。 + "clientip": "0.0.0.0", + "device": epayDevice(config), + } + + version := epayVersion(config) + path := "/mapi.php" + successCode := "1" + if version == "1" { + params["method"] = "jump" + params["timestamp"] = strconv.FormatInt(p.now().Unix(), 10) + params["sign"] = epayRSASign(params, config["privateKey"]) + params["sign_type"] = "RSA" + path = "/api/pay/create" + successCode = "0" + } else { + params["sign"] = epayMD5Sign(params, config["key"]) + params["sign_type"] = "MD5" + } + + response, status, err := p.postForm(ctx, config, path, params) + if err != nil { + return Checkout{}, err + } + if response["code"] != successCode { + message := strings.TrimSpace(epayString(response["msg"])) + if message == "" { + message = fmt.Sprintf("易支付返回失败状态 code=%s", response["code"]) + } + return Checkout{}, &ProviderError{Code: "epay_order_create_failed", Message: message, Temporary: status >= 500} + } + + mode := epayCheckoutMode(config) + var value string + if version == "1" { + // method=jump 只会返回跳转地址 + value = epayString(response["pay_info"]) + mode = "redirect" + } else { + // payurl、qrcode、urlscheme 三者只会返回其一 + qrcode := epayString(response["qrcode"]) + if mode == "qr_code" && qrcode != "" { + value = qrcode + } else { + // urlscheme 是小程序跳转链接,前端直接跳;没有本地收银台时二维码链接也直接跳过去 + value = firstNonEmpty(epayString(response["payurl"]), epayString(response["urlscheme"]), qrcode) + } + } + if !isEpayURL(value) { + return Checkout{}, &ProviderError{Code: "epay_invalid_checkout", Message: "易支付未返回有效的支付地址"} + } + + expiresAt := request.ExpiresAt + if expiresAt.IsZero() { + expiresAt = p.now().Add(epayQRLifetime) + } + return Checkout{Mode: mode, Value: value, ExpiresAt: expiresAt}, nil +} + +func (p *EpayProvider) QueryOrder(ctx context.Context, config Config, request QueryRequest) (Result, error) { + if err := p.ValidateConfig(config); err != nil { + return Result{}, err + } + if strings.TrimSpace(request.MerchantOrderNo) == "" { + return Result{}, errors.New("易支付查单缺少商户订单号") + } + if epayVersion(config) == "1" { + return Result{}, &ProviderError{Code: "epay_query_unsupported", Message: "易支付 V2 查单接口未在当前协议中实现"} + } + + base, _ := epayGateway(config) + query := url.Values{} + query.Set("act", "order") + query.Set("pid", strings.TrimSpace(config["pid"])) + query.Set("key", strings.TrimSpace(config["key"])) + query.Set("out_trade_no", request.MerchantOrderNo) + endpoint := strings.TrimRight(base, "/") + "/api.php?" + query.Encode() + response, status, err := p.getJSON(ctx, endpoint) + if err != nil { + return Result{}, err + } + if response["code"] != "1" { + message := strings.TrimSpace(epayString(response["msg"])) + if message == "" { + message = "易支付订单不存在或查询失败" + } + return Result{}, &ProviderError{Code: "epay_query_failed", Message: message, Temporary: status >= 500} + } + return epayResult(response, request.MerchantOrderNo), nil +} + +func (p *EpayProvider) CloseOrder(ctx context.Context, config Config, request CloseRequest) (Result, error) { + if err := p.ValidateConfig(config); err != nil { + return Result{}, err + } + if strings.TrimSpace(request.MerchantOrderNo) == "" { + return Result{}, errors.New("易支付关单缺少商户订单号") + } + if epayVersion(config) == "0" { + result, err := p.QueryOrder(ctx, config, QueryRequest{MerchantOrderNo: request.MerchantOrderNo}) + if err != nil { + return Result{}, err + } + if result.Paid { + return result, nil + } + result.Closed = true + result.ProviderStatus = "TRADE_CLOSED" + return result, nil + } + // V2 没有通用远程关单接口,只关闭宿主本地订单。 + return Result{MerchantOrderNo: request.MerchantOrderNo, ProviderStatus: "TRADE_CLOSED", Currency: "CNY", Closed: true}, nil +} + +func (p *EpayProvider) VerifyNotification(_ context.Context, config Config, _ http.Header, rawBody []byte) (Notification, error) { + if err := p.ValidateConfig(config); err != nil { + return Notification{}, err + } + params, err := parseNotificationValues(rawBody) + if err != nil { + return Notification{}, fmt.Errorf("解析易支付异步通知失败: %w", err) + } + values := make(map[string]string, len(params)) + for key, items := range params { + if len(items) > 0 { + values[key] = items[0] + } + } + if values["pid"] != strings.TrimSpace(config["pid"]) { + return Notification{}, errors.New("易支付异步通知商户号不匹配") + } + if strings.TrimSpace(values["out_trade_no"]) == "" || strings.TrimSpace(values["money"]) == "" { + return Notification{}, errors.New("易支付异步通知缺少订单号或金额") + } + + if epayVersion(config) == "1" { + signType := strings.ToUpper(strings.TrimSpace(values["sign_type"])) + if signType != "" && signType != "RSA" && signType != "RSA2" { + return Notification{}, errors.New("易支付异步通知签名类型无效") + } + publicKey, err := parseRSAPublicKey(config["platformPublicKey"]) + if err != nil { + return Notification{}, err + } + if err := rsaSHA256Verify(publicKey, []byte(epayCanonical(values)), values["sign"]); err != nil { + return Notification{}, errors.New("易支付异步通知 RSA 签名无效") + } + } else { + signType := strings.ToUpper(strings.TrimSpace(values["sign_type"])) + if signType != "" && signType != "MD5" { + return Notification{}, errors.New("易支付异步通知签名类型无效") + } + // 定时安全比较,且只吃字符串(挡掉 sign 传数组/缺失);与其它支付插件一致 + if !epaySafetyEquals(values["sign"], epayMD5Sign(values, config["key"])) { + return Notification{}, errors.New("易支付异步通知 MD5 签名无效") + } + } + + amount, err := parseYuanToFen(values["money"]) + if err != nil { + return Notification{}, fmt.Errorf("易支付异步通知金额无效: %w", err) + } + status := strings.TrimSpace(values["trade_status"]) + paid := status == "TRADE_SUCCESS" + providerTradeNo := firstNonEmpty(values["trade_no"], values["api_trade_no"]) + 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"])), + }, + }, nil +} + +func (p *EpayProvider) DownloadTradeBill(context.Context, Config, time.Time) ([]BillRecord, error) { + return nil, fmt.Errorf("%w: 易支付未提供统一账单下载接口", ErrTradeBillNotFound) +} + +type epayResponse map[string]string + +func (p *EpayProvider) postForm(ctx context.Context, config Config, path string, values map[string]string) (epayResponse, int, error) { + base, err := epayGateway(config) + if err != nil { + return nil, 0, err + } + form := url.Values{} + for key, value := range values { + form.Set(key, value) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(base, "/")+path, strings.NewReader(form.Encode())) + if err != nil { + return nil, 0, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + // 网关卡住不能一直等;超时由注入的 http.Client 控制(RPC 入口约 25s) + resp, err := p.client.Do(req) + if err != nil { + return nil, 0, &ProviderError{Code: "epay_transport_error", Message: "易支付网络请求失败", Temporary: true, Cause: err} + } + defer resp.Body.Close() + return epayJSONResponse(resp) +} + +func (p *EpayProvider) getJSON(ctx context.Context, endpoint string) (epayResponse, int, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, 0, err + } + resp, err := p.client.Do(req) + if err != nil { + return nil, 0, &ProviderError{Code: "epay_transport_error", Message: "易支付网络请求失败", Temporary: true, Cause: err} + } + defer resp.Body.Close() + return epayJSONResponse(resp) +} + +func epayJSONResponse(resp *http.Response) (epayResponse, int, error) { + body, err := io.ReadAll(io.LimitReader(resp.Body, maxEpayResponse+1)) + if err != nil { + return nil, resp.StatusCode, &ProviderError{Code: "epay_response_read_error", Message: "读取易支付响应失败", Temporary: true, Cause: err} + } + if len(body) > maxEpayResponse { + return nil, resp.StatusCode, &ProviderError{Code: "epay_response_too_large", Message: "易支付响应过大"} + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, resp.StatusCode, &ProviderError{Code: "epay_http_error", Message: fmt.Sprintf("易支付 HTTP 状态异常: %d", resp.StatusCode), Temporary: resp.StatusCode >= 500} + } + var raw map[string]any + // 有的网关输出带 BOM + if err := json.Unmarshal([]byte(strings.TrimPrefix(string(body), "\xEF\xBB\xBF")), &raw); err != nil { + return nil, resp.StatusCode, &ProviderError{Code: "epay_response_parse_error", Message: "易支付响应不是有效 JSON", Temporary: true, Cause: err} + } + result := make(epayResponse, len(raw)) + for key, value := range raw { + result[key] = epayString(value) + } + return result, resp.StatusCode, nil +} + +func epayGateway(config Config) (string, error) { + gateway := strings.TrimSpace(config["gateway"]) + if gateway == "" { + gateway = defaultEpayGateway + } + parsed, err := url.Parse(gateway) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { + return "", errors.New("易支付网关必须是有效的 HTTP(S) 根地址") + } + return strings.TrimRight(gateway, "/"), nil +} + +func epayVersion(config Config) string { + version := strings.TrimSpace(config["version"]) + if version == "" { + return "0" + } + return version +} + +func epayCheckoutMode(config Config) string { + if strings.TrimSpace(config["checkoutMode"]) == "qr_code" { + return "qr_code" + } + return "redirect" +} + +func epayPayType(config Config) string { + payType := strings.TrimSpace(config["payType"]) + if payType == "" { + return "alipay" + } + return payType +} + +// mapi 是服务端下单,网关看不到买家的浏览器,只能靠 device 决定给什么支付页。 +// 微信/QQ/支付宝里打开的要报对,不然拿到的是 App 内打不开的 H5 或二维码。 +// 宿主 CreateRequest 不带 UA,可用配置 device 覆盖;留空默认 pc。 +func epayDevice(config Config) string { + if device := strings.TrimSpace(config["device"]); device != "" { + return device + } + return "pc" +} + +func epayTitle(description, orderNo string) string { + title := strings.TrimSpace(description) + if title == "" { + title = "商品订单号:" + orderNo + } + return strings.ReplaceAll(title, "%", "") +} + +func formatEpayAmount(amountFen int64) string { + return strconv.FormatInt(amountFen/100, 10) + "." + fmt.Sprintf("%02d", amountFen%100) +} + +func epayMD5Sign(values map[string]string, key string) string { + return epayMD5Hex(epayCanonical(values) + key) +} + +func epayCanonical(values map[string]string) string { + keys := make([]string, 0, len(values)) + for key, value := range values { + if key == "sign" || key == "sign_type" || value == "" { + continue + } + keys = append(keys, key) + } + sort.Strings(keys) + parts := make([]string, 0, len(keys)) + for _, key := range keys { + parts = append(parts, key+"="+values[key]) + } + return strings.Join(parts, "&") +} + +func epayMD5Hex(value string) string { + hash := md5.Sum([]byte(value)) + return hex.EncodeToString(hash[:]) +} + +func epayRSASign(values map[string]string, privateKey string) string { + key, err := parseRSAPrivateKey(privateKey) + if err != nil { + return "" + } + signature, err := rsaSHA256Sign(key, []byte(epayCanonical(values))) + if err != nil { + return "" + } + return signature +} + +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 { + status := epayString(response["status"]) + if status == "" && response["trade_status"] != "" { + status = response["trade_status"] + } + amount, _ := parseYuanToFen(firstNonEmpty(epayString(response["money"]), epayString(response["total_amount"]))) + return Result{ + MerchantOrderNo: firstNonEmpty(epayString(response["out_trade_no"]), fallbackOrderNo), + ProviderTradeNo: firstNonEmpty(epayString(response["trade_no"]), epayString(response["api_trade_no"])), + 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"]))), + } +} + +func epayString(value any) string { + switch typed := value.(type) { + case string: + return strings.TrimSpace(typed) + case json.Number: + return typed.String() + case float64: + return strconv.FormatFloat(typed, 'f', -1, 64) + case int: + return strconv.Itoa(typed) + default: + return "" + } +} + +// 只认带协议头的地址(http(s),或 weixin:// 这类唤起 App 的),挡掉 javascript: 之类 +func isEpayURL(value string) bool { + value = strings.TrimSpace(value) + parsed, err := url.Parse(value) + if err != nil || parsed.Scheme == "" { + return false + } + scheme := strings.ToLower(parsed.Scheme) + if scheme == "javascript" || scheme == "data" || scheme == "vbscript" { + return false + } + if scheme == "http" || scheme == "https" { + return parsed.Host != "" + } + return strings.Contains(value, "://") +} + +func parseEpayTime(value string) time.Time { + value = strings.TrimSpace(value) + for _, layout := range []string{"2006-01-02 15:04:05", time.RFC3339} { + if parsed, err := time.ParseInLocation(layout, value, time.Local); err == nil { + return parsed + } + } + return time.Time{} +} diff --git a/backend/payment-plugins/epay_test.go b/backend/payment-plugins/epay_test.go new file mode 100644 index 000000000..983a4c5e0 --- /dev/null +++ b/backend/payment-plugins/epay_test.go @@ -0,0 +1,151 @@ +package paymentplugins + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" +) + +func TestEpayV1CreateAndVerifyNotification(t *testing.T) { + const key = "epay-test-key" + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodPost || request.URL.Path != "/mapi.php" { + t.Fatalf("request = %s %s", request.Method, request.URL.Path) + } + values := mustEpayForm(t, request) + if values["device"] != "pc" || values["type"] != "alipay" { + t.Fatalf("form = %#v", values) + } + if !epaySafetyEquals(values["sign"], epayMD5Sign(values, key)) { + t.Fatalf("sign = %#v", values) + } + // BOM 前缀应被剥离 + _, _ = writer.Write([]byte("\xEF\xBB\xBF" + `{"code":1,"msg":"ok","trade_no":"EPAY-1","qrcode":"https://pay.example/qr/1"}`)) + })) + defer server.Close() + + provider := NewEpayProvider(server.Client()) + config := Config{"pid": "1001", "key": key, "gateway": server.URL, "checkoutMode": "qr_code"} + checkout, err := provider.CreateOrder(context.Background(), config, CreateRequest{ + MerchantOrderNo: "ORDER-1", Description: "充值 10 积分", AmountFen: 100, + Currency: "CNY", NotifyURL: "https://merchant.example/pay/notify", + }) + if err != nil { + t.Fatal(err) + } + if checkout.Mode != "qr_code" || checkout.Value != "https://pay.example/qr/1" { + t.Fatalf("checkout = %#v", checkout) + } + + values := map[string]string{ + "pid": "1001", "trade_no": "EPAY-1", "out_trade_no": "ORDER-1", "type": "alipay", + "name": "充值 10 积分", "money": "1.00", "trade_status": "TRADE_SUCCESS", "endtime": "2026-09-19 12:01:00", + } + values["sign"] = epayMD5Sign(values, key) + form := url.Values{} + for name, value := range values { + form.Set(name, value) + } + notification, err := provider.VerifyNotification(context.Background(), config, nil, []byte(form.Encode())) + if err != nil { + t.Fatal(err) + } + if !notification.Paid || notification.AmountFen != 100 || notification.ProviderTradeNo != "EPAY-1" { + t.Fatalf("notification = %#v", notification) + } +} + +func TestEpayV1QueryAndLocalClose(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/api.php" || request.URL.Query().Get("act") != "order" { + t.Fatalf("request = %s %s", request.Method, request.URL.String()) + } + _, _ = writer.Write([]byte(`{"code":1,"out_trade_no":"ORDER-2","trade_no":"EPAY-2","status":0,"money":"2.00"}`)) + })) + defer server.Close() + + provider := NewEpayProvider(server.Client()) + config := Config{"pid": "1001", "key": "key", "gateway": server.URL} + result, err := provider.QueryOrder(context.Background(), config, QueryRequest{MerchantOrderNo: "ORDER-2"}) + if err != nil { + t.Fatal(err) + } + if result.Paid || result.AmountFen != 200 || result.ProviderTradeNo != "EPAY-2" { + t.Fatalf("result = %#v", result) + } + closed, err := provider.CloseOrder(context.Background(), config, CloseRequest{MerchantOrderNo: "ORDER-2"}) + if err != nil { + t.Fatal(err) + } + if !closed.Closed || closed.Paid { + t.Fatalf("closed = %#v", closed) + } +} + +func TestEpayV2CreateUsesRSASignature(t *testing.T) { + privateKey, publicKey := testRSAKeyPair(t) + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/api/pay/create" { + t.Fatalf("path = %s", request.URL.Path) + } + values := mustEpayForm(t, request) + if err := rsaSHA256Verify(publicKey, []byte(epayCanonical(values)), values["sign"]); err != nil { + t.Fatalf("sign: %v", err) + } + _, _ = writer.Write([]byte(`{"code":0,"pay_info":"https://pay.example/v2/1"}`)) + })) + defer server.Close() + + provider := NewEpayProvider(server.Client()) + config := Config{ + "pid": "1001", "version": "1", "gateway": server.URL, + "privateKey": testPrivatePEM(privateKey), "platformPublicKey": testPublicPEM(&privateKey.PublicKey), + } + checkout, err := provider.CreateOrder(context.Background(), config, CreateRequest{ + MerchantOrderNo: "ORDER-V2", AmountFen: 100, Currency: "CNY", NotifyURL: "https://merchant.example/notify", + }) + if err != nil { + t.Fatal(err) + } + if checkout.Mode != "redirect" || checkout.Value != "https://pay.example/v2/1" { + t.Fatalf("checkout = %#v", checkout) + } +} + +func TestEpayRejectsUnsafeGatewayAndMissingBills(t *testing.T) { + provider := NewEpayProvider(http.DefaultClient) + if err := provider.ValidateConfig(Config{"pid": "1001", "key": "key", "gateway": "https://user:pass@example.invalid"}); err == nil { + t.Fatal("expected unsafe gateway error") + } + _, err := provider.DownloadTradeBill(context.Background(), Config{}, time.Now()) + if !errors.Is(err, ErrTradeBillNotFound) { + t.Fatalf("error = %v", err) + } + if isEpayURL("javascript:alert(1)") || !isEpayURL("weixin://dl/business/?t=1") { + t.Fatal("isEpayURL scheme rules mismatch") + } +} + +func mustEpayForm(t *testing.T, request *http.Request) map[string]string { + t.Helper() + body, err := io.ReadAll(request.Body) + if err != nil { + t.Fatal(err) + } + form, err := url.ParseQuery(string(body)) + if err != nil { + t.Fatal(err) + } + values := make(map[string]string, len(form)) + for name, items := range form { + if len(items) > 0 { + values[name] = items[0] + } + } + return values +} diff --git a/plugin-packages/build-packages.sh b/plugin-packages/build-packages.sh index b6d486373..6986650cc 100755 --- a/plugin-packages/build-packages.sh +++ b/plugin-packages/build-packages.sh @@ -3,7 +3,7 @@ set -eu root_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) repo_dir=$(CDPATH= cd -- "$root_dir/.." && pwd) -payment_plugins="official-payment-wechat-native official-payment-alipay-page official-payment-xunhupay official-payment-zhifufm" +payment_plugins="official-payment-wechat-native official-payment-alipay-page official-payment-xunhupay official-payment-zhifufm official-payment-epay" payments_only=false default_payment_targets="linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64" @@ -119,6 +119,7 @@ for target in $payment_targets; do build_payment_provider official-payment-alipay-page ./cmd/payment-alipay "$target_goos" "$target_goarch" build_payment_provider official-payment-xunhupay ./cmd/payment-xunhupay "$target_goos" "$target_goarch" build_payment_provider official-payment-zhifufm ./cmd/payment-zhifufm "$target_goos" "$target_goarch" + build_payment_provider official-payment-epay ./cmd/payment-epay "$target_goos" "$target_goarch" done package_plugin() { diff --git a/plugin-packages/official-payment-epay.yingce-plugin b/plugin-packages/official-payment-epay.yingce-plugin new file mode 100644 index 000000000..5c9bfa442 Binary files /dev/null and b/plugin-packages/official-payment-epay.yingce-plugin differ diff --git a/plugin-packages/official-payment-epay/README.md b/plugin-packages/official-payment-epay/README.md new file mode 100644 index 000000000..20ff818a9 --- /dev/null +++ b/plugin-packages/official-payment-epay/README.md @@ -0,0 +1,3 @@ +# 易支付 + +官方 `yingce.payment/v1` 支付插件。兼容常见易支付网关:V1/MD5 `mapi.php` 与 V2/RSA `/api/pay/create`,支持支付宝/微信/QQ 等通道。协议实现参考 [acg-faka](https://github.com/lizhipay/acg-faka)。 diff --git a/plugin-packages/official-payment-epay/assets/icon.svg b/plugin-packages/official-payment-epay/assets/icon.svg new file mode 100644 index 000000000..2d61bcf5e --- /dev/null +++ b/plugin-packages/official-payment-epay/assets/icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/plugin-packages/official-payment-epay/backend/provider b/plugin-packages/official-payment-epay/backend/provider new file mode 100755 index 000000000..dcf476e11 Binary files /dev/null and b/plugin-packages/official-payment-epay/backend/provider differ diff --git a/plugin-packages/official-payment-epay/backend/provider-darwin-amd64 b/plugin-packages/official-payment-epay/backend/provider-darwin-amd64 new file mode 100755 index 000000000..c522dc26f Binary files /dev/null and b/plugin-packages/official-payment-epay/backend/provider-darwin-amd64 differ diff --git a/plugin-packages/official-payment-epay/backend/provider-darwin-arm64 b/plugin-packages/official-payment-epay/backend/provider-darwin-arm64 new file mode 100755 index 000000000..6ae76a2ce Binary files /dev/null and b/plugin-packages/official-payment-epay/backend/provider-darwin-arm64 differ diff --git a/plugin-packages/official-payment-epay/backend/provider-linux-amd64 b/plugin-packages/official-payment-epay/backend/provider-linux-amd64 new file mode 100755 index 000000000..dcf476e11 Binary files /dev/null and b/plugin-packages/official-payment-epay/backend/provider-linux-amd64 differ diff --git a/plugin-packages/official-payment-epay/backend/provider-linux-arm64 b/plugin-packages/official-payment-epay/backend/provider-linux-arm64 new file mode 100755 index 000000000..23741f6b7 Binary files /dev/null and b/plugin-packages/official-payment-epay/backend/provider-linux-arm64 differ diff --git a/plugin-packages/official-payment-epay/backend/provider-windows-amd64.exe b/plugin-packages/official-payment-epay/backend/provider-windows-amd64.exe new file mode 100755 index 000000000..98cf31726 Binary files /dev/null and b/plugin-packages/official-payment-epay/backend/provider-windows-amd64.exe differ diff --git a/plugin-packages/official-payment-epay/backend/provider-windows-arm64.exe b/plugin-packages/official-payment-epay/backend/provider-windows-arm64.exe new file mode 100755 index 000000000..7842130eb Binary files /dev/null and b/plugin-packages/official-payment-epay/backend/provider-windows-arm64.exe differ diff --git a/plugin-packages/official-payment-epay/docs/interface.md b/plugin-packages/official-payment-epay/docs/interface.md new file mode 100644 index 000000000..507e0d1d8 --- /dev/null +++ b/plugin-packages/official-payment-epay/docs/interface.md @@ -0,0 +1,152 @@ +## yingce.payment/v1 + +支持 `validate_config`、`create_order`、`query_order`、`close_order`、`verify_notification` 和 `download_trade_bill`,统一返回 JSON 响应。 + +易支付渠道协议全部封装在本插件内:V1 下单 `POST /mapi.php`(MD5),查单 `GET /api.php?act=order`;V2 下单 `POST /api/pay/create`(RSA,`method=jump`),异步通知为 form/JSON,成功应答 `success`。配置字段包括 `publicBaseUrl`、`pid`、`key`、`gateway`、`payType`、`version`、`checkoutMode`、`privateKey`、`platformPublicKey`。V2 无查单和交易账单下载:关单只关本地订单,对账账单返回 not found。 + + +## Manifest 完整接口定义 + +以下 JSON 与插件包内实际 `manifest.json` 逐字段一致,覆盖插件身份、权限、配置、鉴权、参数、校验、创建、Agent、查询、取消、结果下载、响应和 Agent 响应映射。`documentation` 字段的值就是当前完整文档;为避免文档在自身内部无限递归,JSON 中仅用等义占位文本表示正文。 + +```json +{ + "apiVersion": "yingce.plugin/v1", + "id": "official-payment-epay", + "name": "易支付", + "version": "1.0.8", + "author": "Epay", + "description": "易支付适配器,支持 V1/MD5 与 V2/RSA 的支付宝/微信/QQ 通道。", + "enabled": true, + "installable": true, + "runtime": { + "backend": "rpc", + "backendEntry": "backend/provider" + }, + "surfaces": [ + "wallet", + "settings" + ], + "permissions": [ + "payment.create", + "payment.query", + "payment.close", + "payment.reconcile" + ], + "configuration": { + "fields": [ + { + "name": "publicBaseUrl", + "type": "url", + "label": "服务器公网地址", + "required": true, + "description": "用于确认回调可达;实际 notify_url 由宿主订单生成。" + }, + { + "name": "pid", + "type": "string", + "label": "商户号 (pid)", + "required": true + }, + { + "name": "key", + "type": "password", + "label": "商户密钥 (key)", + "required": false, + "secret": true, + "description": "V1/MD5 密钥;version=1 可留空。" + }, + { + "name": "gateway", + "type": "url", + "label": "支付网关", + "required": true, + "default": "https://pay.admincloudai.com", + "description": "易支付根地址,不要带 /mapi.php。" + }, + { + "name": "payType", + "type": "string", + "label": "默认支付方式", + "required": false, + "default": "alipay", + "values": [ + "alipay", + "wxpay", + "qqpay", + "usdt" + ] + }, + { + "name": "version", + "type": "string", + "label": "接口版本", + "required": false, + "default": "0", + "values": [ + "0", + "1" + ], + "description": "0=V1/MD5,1=V2/RSA。" + }, + { + "name": "checkoutMode", + "type": "string", + "label": "收银方式", + "required": false, + "default": "redirect", + "values": [ + "redirect", + "qr_code" + ], + "description": "redirect 跳转;qr_code 展示二维码载荷。" + }, + { + "name": "privateKey", + "type": "textarea", + "label": "V2 商户私钥", + "required": false, + "secret": true, + "description": "仅 version=1,PEM 或裸 Base64。" + }, + { + "name": "platformPublicKey", + "type": "textarea", + "label": "V2 平台公钥", + "required": false, + "description": "仅 version=1,PEM 或裸 Base64。" + } + ] + }, + "contributes": { + "paymentProviders": [ + { + "id": "epay", + "label": "易支付", + "icon": "assets/icon.svg", + "checkoutMode": "redirect", + "identityFields": [ + "pid" + ], + "expiryPolicy": { + "defaultMinutes": 10, + "minMinutes": 5, + "maxMinutes": 1440 + }, + "notificationSuccess": { + "status": 200, + "contentType": "text/plain; charset=utf-8", + "body": "success" + }, + "notificationFailure": { + "status": 400, + "contentType": "text/plain; charset=utf-8", + "body": "failure" + } + } + ] + }, + "documentation": "<当前插件的完整 documentation,由 README.md 与 docs/interface.md 拼接而成;为避免 JSON 递归,此处不重复展开正文。>" +} +``` + diff --git a/plugin-packages/official-payment-epay/manifest.json b/plugin-packages/official-payment-epay/manifest.json new file mode 100644 index 000000000..2c6d8b66c --- /dev/null +++ b/plugin-packages/official-payment-epay/manifest.json @@ -0,0 +1,138 @@ +{ + "apiVersion": "yingce.plugin/v1", + "id": "official-payment-epay", + "name": "易支付", + "version": "1.0.8", + "author": "Epay", + "description": "易支付适配器,支持 V1/MD5 与 V2/RSA 的支付宝/微信/QQ 通道。", + "enabled": true, + "installable": true, + "runtime": { + "backend": "rpc", + "backendEntry": "backend/provider" + }, + "surfaces": [ + "wallet", + "settings" + ], + "permissions": [ + "payment.create", + "payment.query", + "payment.close", + "payment.reconcile" + ], + "configuration": { + "fields": [ + { + "name": "publicBaseUrl", + "type": "url", + "label": "服务器公网地址", + "required": true, + "description": "用于确认回调可达;实际 notify_url 由宿主订单生成。" + }, + { + "name": "pid", + "type": "string", + "label": "商户号 (pid)", + "required": true + }, + { + "name": "key", + "type": "password", + "label": "商户密钥 (key)", + "required": false, + "secret": true, + "description": "V1/MD5 密钥;version=1 可留空。" + }, + { + "name": "gateway", + "type": "url", + "label": "支付网关", + "required": true, + "default": "https://pay.admincloudai.com", + "description": "易支付根地址,不要带 /mapi.php。" + }, + { + "name": "payType", + "type": "string", + "label": "默认支付方式", + "required": false, + "default": "alipay", + "values": [ + "alipay", + "wxpay", + "qqpay", + "usdt" + ] + }, + { + "name": "version", + "type": "string", + "label": "接口版本", + "required": false, + "default": "0", + "values": [ + "0", + "1" + ], + "description": "0=V1/MD5,1=V2/RSA。" + }, + { + "name": "checkoutMode", + "type": "string", + "label": "收银方式", + "required": false, + "default": "redirect", + "values": [ + "redirect", + "qr_code" + ], + "description": "redirect 跳转;qr_code 展示二维码载荷。" + }, + { + "name": "privateKey", + "type": "textarea", + "label": "V2 商户私钥", + "required": false, + "secret": true, + "description": "仅 version=1,PEM 或裸 Base64。" + }, + { + "name": "platformPublicKey", + "type": "textarea", + "label": "V2 平台公钥", + "required": false, + "description": "仅 version=1,PEM 或裸 Base64。" + } + ] + }, + "contributes": { + "paymentProviders": [ + { + "id": "epay", + "label": "易支付", + "icon": "assets/icon.svg", + "checkoutMode": "redirect", + "identityFields": [ + "pid" + ], + "expiryPolicy": { + "defaultMinutes": 10, + "minMinutes": 5, + "maxMinutes": 1440 + }, + "notificationSuccess": { + "status": 200, + "contentType": "text/plain; charset=utf-8", + "body": "success" + }, + "notificationFailure": { + "status": 400, + "contentType": "text/plain; charset=utf-8", + "body": "failure" + } + } + ] + }, + "documentation": "# 易支付\n\n官方 `yingce.payment/v1` 支付插件。兼容常见易支付网关:V1/MD5 `mapi.php` 与 V2/RSA `/api/pay/create`,支持支付宝/微信/QQ 等通道。协议实现参考 [acg-faka](https://github.com/lizhipay/acg-faka)。\n\n---\n\n## yingce.payment/v1\n\n支持 `validate_config`、`create_order`、`query_order`、`close_order`、`verify_notification` 和 `download_trade_bill`,统一返回 JSON 响应。\n\n易支付渠道协议全部封装在本插件内:V1 下单 `POST /mapi.php`(MD5),查单 `GET /api.php?act=order`;V2 下单 `POST /api/pay/create`(RSA,`method=jump`),异步通知为 form/JSON,成功应答 `success`。配置字段包括 `publicBaseUrl`、`pid`、`key`、`gateway`、`payType`、`version`、`checkoutMode`、`privateKey`、`platformPublicKey`。V2 无查单和交易账单下载:关单只关本地订单,对账账单返回 not found。\n\n\n## Manifest 完整接口定义\n\n以下 JSON 与插件包内实际 `manifest.json` 逐字段一致,覆盖插件身份、权限、配置、鉴权、参数、校验、创建、Agent、查询、取消、结果下载、响应和 Agent 响应映射。`documentation` 字段的值就是当前完整文档;为避免文档在自身内部无限递归,JSON 中仅用等义占位文本表示正文。\n\n```json\n{\n \"apiVersion\": \"yingce.plugin/v1\",\n \"id\": \"official-payment-epay\",\n \"name\": \"易支付\",\n \"version\": \"1.0.8\",\n \"author\": \"Epay\",\n \"description\": \"易支付适配器,支持 V1/MD5 与 V2/RSA 的支付宝/微信/QQ 通道。\",\n \"enabled\": true,\n \"installable\": true,\n \"runtime\": {\n \"backend\": \"rpc\",\n \"backendEntry\": \"backend/provider\"\n },\n \"surfaces\": [\n \"wallet\",\n \"settings\"\n ],\n \"permissions\": [\n \"payment.create\",\n \"payment.query\",\n \"payment.close\",\n \"payment.reconcile\"\n ],\n \"configuration\": {\n \"fields\": [\n {\n \"name\": \"publicBaseUrl\",\n \"type\": \"url\",\n \"label\": \"服务器公网地址\",\n \"required\": true,\n \"description\": \"用于确认回调可达;实际 notify_url 由宿主订单生成。\"\n },\n {\n \"name\": \"pid\",\n \"type\": \"string\",\n \"label\": \"商户号 (pid)\",\n \"required\": true\n },\n {\n \"name\": \"key\",\n \"type\": \"password\",\n \"label\": \"商户密钥 (key)\",\n \"required\": false,\n \"secret\": true,\n \"description\": \"V1/MD5 密钥;version=1 可留空。\"\n },\n {\n \"name\": \"gateway\",\n \"type\": \"url\",\n \"label\": \"支付网关\",\n \"required\": true,\n \"default\": \"https://pay.admincloudai.com\",\n \"description\": \"易支付根地址,不要带 /mapi.php。\"\n },\n {\n \"name\": \"payType\",\n \"type\": \"string\",\n \"label\": \"默认支付方式\",\n \"required\": false,\n \"default\": \"alipay\",\n \"values\": [\n \"alipay\",\n \"wxpay\",\n \"qqpay\",\n \"usdt\"\n ]\n },\n {\n \"name\": \"version\",\n \"type\": \"string\",\n \"label\": \"接口版本\",\n \"required\": false,\n \"default\": \"0\",\n \"values\": [\n \"0\",\n \"1\"\n ],\n \"description\": \"0=V1/MD5,1=V2/RSA。\"\n },\n {\n \"name\": \"checkoutMode\",\n \"type\": \"string\",\n \"label\": \"收银方式\",\n \"required\": false,\n \"default\": \"redirect\",\n \"values\": [\n \"redirect\",\n \"qr_code\"\n ],\n \"description\": \"redirect 跳转;qr_code 展示二维码载荷。\"\n },\n {\n \"name\": \"privateKey\",\n \"type\": \"textarea\",\n \"label\": \"V2 商户私钥\",\n \"required\": false,\n \"secret\": true,\n \"description\": \"仅 version=1,PEM 或裸 Base64。\"\n },\n {\n \"name\": \"platformPublicKey\",\n \"type\": \"textarea\",\n \"label\": \"V2 平台公钥\",\n \"required\": false,\n \"description\": \"仅 version=1,PEM 或裸 Base64。\"\n }\n ]\n },\n \"contributes\": {\n \"paymentProviders\": [\n {\n \"id\": \"epay\",\n \"label\": \"易支付\",\n \"icon\": \"assets/icon.svg\",\n \"checkoutMode\": \"redirect\",\n \"identityFields\": [\n \"pid\"\n ],\n \"expiryPolicy\": {\n \"defaultMinutes\": 10,\n \"minMinutes\": 5,\n \"maxMinutes\": 1440\n },\n \"notificationSuccess\": {\n \"status\": 200,\n \"contentType\": \"text/plain; charset=utf-8\",\n \"body\": \"success\"\n },\n \"notificationFailure\": {\n \"status\": 400,\n \"contentType\": \"text/plain; charset=utf-8\",\n \"body\": \"failure\"\n }\n }\n ]\n },\n \"documentation\": \"<当前插件的完整 documentation,由 README.md 与 docs/interface.md 拼接而成;为避免 JSON 递归,此处不重复展开正文。>\"\n}\n```\n\n" +} diff --git a/plugin-packages/verify-payment-packages.sh b/plugin-packages/verify-payment-packages.sh index 2d44adec2..9cd4082dd 100644 --- a/plugin-packages/verify-payment-packages.sh +++ b/plugin-packages/verify-payment-packages.sh @@ -118,7 +118,7 @@ if [ "$require_all" = true ]; then fi fi -for package_id in official-payment-wechat-native official-payment-alipay-page official-payment-xunhupay official-payment-zhifufm; do +for package_id in official-payment-wechat-native official-payment-alipay-page official-payment-xunhupay official-payment-zhifufm official-payment-epay; do backend_dir="$root_dir/$package_id/backend" provider="$backend_dir/provider" package_file="$root_dir/$package_id.yingce-plugin"