feat(payment): 支付插件 - 接入易支付官方标准插件 official-payment-epay
This commit is contained in:
16
backend/cmd/payment-epay/main.go
Normal file
16
backend/cmd/payment-epay/main.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,10 +9,12 @@ const (
|
|||||||
PaymentPluginAlipayPage = "official-payment-alipay-page"
|
PaymentPluginAlipayPage = "official-payment-alipay-page"
|
||||||
PaymentPluginXunHuPay = "official-payment-xunhupay"
|
PaymentPluginXunHuPay = "official-payment-xunhupay"
|
||||||
PaymentPluginZhiFuFM = "official-payment-zhifufm"
|
PaymentPluginZhiFuFM = "official-payment-zhifufm"
|
||||||
|
PaymentPluginEpay = "official-payment-epay"
|
||||||
PaymentProviderWeChat = "wechat-native"
|
PaymentProviderWeChat = "wechat-native"
|
||||||
PaymentProviderAlipay = "alipay-page-pay"
|
PaymentProviderAlipay = "alipay-page-pay"
|
||||||
PaymentProviderXunHuPay = "xunhupay-aggregate"
|
PaymentProviderXunHuPay = "xunhupay-aggregate"
|
||||||
PaymentProviderZhiFuFM = "zhifufm-pay"
|
PaymentProviderZhiFuFM = "zhifufm-pay"
|
||||||
|
PaymentProviderEpay = "epay"
|
||||||
)
|
)
|
||||||
|
|
||||||
func bundledPaymentPluginManifests() []protocol.Manifest {
|
func bundledPaymentPluginManifests() []protocol.Manifest {
|
||||||
|
|||||||
@@ -186,3 +186,38 @@ func TestZhiFuFMOfficialPackageIsPaymentPlugin(t *testing.T) {
|
|||||||
t.Fatalf("zhifufm provider type = %T", provider)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ var officialPaymentPackageIDs = []string{
|
|||||||
"official-payment-alipay-page",
|
"official-payment-alipay-page",
|
||||||
"official-payment-xunhupay",
|
"official-payment-xunhupay",
|
||||||
"official-payment-zhifufm",
|
"official-payment-zhifufm",
|
||||||
|
"official-payment-epay",
|
||||||
}
|
}
|
||||||
|
|
||||||
var officialPaymentTaggedArtifacts = []string{
|
var officialPaymentTaggedArtifacts = []string{
|
||||||
|
|||||||
@@ -26,8 +26,10 @@ const (
|
|||||||
ProviderAlipayPage = "alipay-page-pay"
|
ProviderAlipayPage = "alipay-page-pay"
|
||||||
ProviderXunHuPay = "xunhupay-aggregate"
|
ProviderXunHuPay = "xunhupay-aggregate"
|
||||||
ProviderZhiFuFM = "zhifufm-pay"
|
ProviderZhiFuFM = "zhifufm-pay"
|
||||||
|
ProviderEpay = "epay"
|
||||||
PluginWeChatNative = "official-payment-wechat-native"
|
PluginWeChatNative = "official-payment-wechat-native"
|
||||||
PluginAlipayPage = "official-payment-alipay-page"
|
PluginAlipayPage = "official-payment-alipay-page"
|
||||||
PluginXunHuPay = "official-payment-xunhupay"
|
PluginXunHuPay = "official-payment-xunhupay"
|
||||||
PluginZhiFuFM = "official-payment-zhifufm"
|
PluginZhiFuFM = "official-payment-zhifufm"
|
||||||
|
PluginEpay = "official-payment-epay"
|
||||||
)
|
)
|
||||||
|
|||||||
488
backend/payment-plugins/epay.go
Normal file
488
backend/payment-plugins/epay.go
Normal file
@@ -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{}
|
||||||
|
}
|
||||||
151
backend/payment-plugins/epay_test.go
Normal file
151
backend/payment-plugins/epay_test.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ set -eu
|
|||||||
|
|
||||||
root_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
root_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||||
repo_dir=$(CDPATH= cd -- "$root_dir/.." && 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
|
payments_only=false
|
||||||
default_payment_targets="linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64"
|
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-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-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-zhifufm ./cmd/payment-zhifufm "$target_goos" "$target_goarch"
|
||||||
|
build_payment_provider official-payment-epay ./cmd/payment-epay "$target_goos" "$target_goarch"
|
||||||
done
|
done
|
||||||
|
|
||||||
package_plugin() {
|
package_plugin() {
|
||||||
|
|||||||
BIN
plugin-packages/official-payment-epay.yingce-plugin
Normal file
BIN
plugin-packages/official-payment-epay.yingce-plugin
Normal file
Binary file not shown.
3
plugin-packages/official-payment-epay/README.md
Normal file
3
plugin-packages/official-payment-epay/README.md
Normal file
@@ -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)。
|
||||||
4
plugin-packages/official-payment-epay/assets/icon.svg
Normal file
4
plugin-packages/official-payment-epay/assets/icon.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="易支付">
|
||||||
|
<rect width="64" height="64" rx="14" fill="#1677ff"/>
|
||||||
|
<path fill="#fff" d="M19 17h26v7H27v7h15v7H27v9h-8V17Zm22 0h6v30h-6V17Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 235 B |
BIN
plugin-packages/official-payment-epay/backend/provider
Executable file
BIN
plugin-packages/official-payment-epay/backend/provider
Executable file
Binary file not shown.
BIN
plugin-packages/official-payment-epay/backend/provider-darwin-amd64
Executable file
BIN
plugin-packages/official-payment-epay/backend/provider-darwin-amd64
Executable file
Binary file not shown.
BIN
plugin-packages/official-payment-epay/backend/provider-darwin-arm64
Executable file
BIN
plugin-packages/official-payment-epay/backend/provider-darwin-arm64
Executable file
Binary file not shown.
BIN
plugin-packages/official-payment-epay/backend/provider-linux-amd64
Executable file
BIN
plugin-packages/official-payment-epay/backend/provider-linux-amd64
Executable file
Binary file not shown.
BIN
plugin-packages/official-payment-epay/backend/provider-linux-arm64
Executable file
BIN
plugin-packages/official-payment-epay/backend/provider-linux-arm64
Executable file
Binary file not shown.
BIN
plugin-packages/official-payment-epay/backend/provider-windows-amd64.exe
Executable file
BIN
plugin-packages/official-payment-epay/backend/provider-windows-amd64.exe
Executable file
Binary file not shown.
BIN
plugin-packages/official-payment-epay/backend/provider-windows-arm64.exe
Executable file
BIN
plugin-packages/official-payment-epay/backend/provider-windows-arm64.exe
Executable file
Binary file not shown.
152
plugin-packages/official-payment-epay/docs/interface.md
Normal file
152
plugin-packages/official-payment-epay/docs/interface.md
Normal file
@@ -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。
|
||||||
|
|
||||||
|
<!-- YINGCE_MANIFEST_CONTRACT_START -->
|
||||||
|
## 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 递归,此处不重复展开正文。>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
<!-- YINGCE_MANIFEST_CONTRACT_END -->
|
||||||
138
plugin-packages/official-payment-epay/manifest.json
Normal file
138
plugin-packages/official-payment-epay/manifest.json
Normal file
File diff suppressed because one or more lines are too long
@@ -118,7 +118,7 @@ if [ "$require_all" = true ]; then
|
|||||||
fi
|
fi
|
||||||
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"
|
backend_dir="$root_dir/$package_id/backend"
|
||||||
provider="$backend_dir/provider"
|
provider="$backend_dir/provider"
|
||||||
package_file="$root_dir/$package_id.yingce-plugin"
|
package_file="$root_dir/$package_id.yingce-plugin"
|
||||||
|
|||||||
Reference in New Issue
Block a user