feat(payment): 支付插件 - 接入易支付官方标准插件 official-payment-epay

This commit is contained in:
qiuliw
2026-09-20 04:04:42 +08:00
parent 308cf4244f
commit 28b9930915
21 changed files with 995 additions and 2 deletions

View 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)
}
}

View File

@@ -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 {

View File

@@ -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)
}
}

View File

@@ -17,6 +17,7 @@ var officialPaymentPackageIDs = []string{
"official-payment-alipay-page",
"official-payment-xunhupay",
"official-payment-zhifufm",
"official-payment-epay",
}
var officialPaymentTaggedArtifacts = []string{

View File

@@ -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"
)

View 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{}
}

View 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
}

View File

@@ -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() {

Binary file not shown.

View 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)。

View 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

Binary file not shown.

View 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 -->

File diff suppressed because one or more lines are too long

View File

@@ -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"