feat(payment): PR #538 支付FM插件 - 合并 Danborad 的支付插件实现
This commit is contained in:
16
backend/cmd/payment-zhifufm/main.go
Normal file
16
backend/cmd/payment-zhifufm/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.NewZhiFuFMProvider(&http.Client{Timeout: 25 * time.Second})
|
||||
if err := paymentplugins.RunRPC(provider, os.Stdin, os.Stdout); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,12 @@ import (
|
||||
const (
|
||||
PaymentPluginWeChatNative = "official-payment-wechat-native"
|
||||
PaymentPluginAlipayPage = "official-payment-alipay-page"
|
||||
PaymentPluginXunHuPay = "official-payment-xunhupay"
|
||||
PaymentPluginZhiFuFM = "official-payment-zhifufm"
|
||||
PaymentProviderWeChat = "wechat-native"
|
||||
PaymentProviderAlipay = "alipay-page-pay"
|
||||
PaymentProviderXunHuPay = "xunhupay-aggregate"
|
||||
PaymentProviderZhiFuFM = "zhifufm-pay"
|
||||
)
|
||||
|
||||
func bundledPaymentPluginManifests() []protocol.Manifest {
|
||||
|
||||
@@ -151,3 +151,38 @@ func TestXunHuPayOfficialPackageIsPaymentPlugin(t *testing.T) {
|
||||
t.Fatalf("xunhupay provider type = %T", provider)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZhiFuFMOfficialPackageIsPaymentPlugin(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-zhifufm" {
|
||||
plugin = item
|
||||
plugin.Management = pluginManagementFromView(item)
|
||||
break
|
||||
}
|
||||
}
|
||||
if plugin.Manifest.ID == "" {
|
||||
t.Fatal("official-payment-zhifufm is missing")
|
||||
}
|
||||
if plugin.Management.Kind != PluginKindPayment || plugin.Source != PluginOriginOfficial {
|
||||
t.Fatalf("zhifufm plugin = %#v", plugin)
|
||||
}
|
||||
if len(plugin.Manifest.Contributes.PaymentProviders) != 1 || plugin.Manifest.Contributes.PaymentProviders[0].ID != "zhifufm-pay" {
|
||||
t.Fatalf("zhifufm contributions = %#v", plugin.Manifest.Contributes.PaymentProviders)
|
||||
}
|
||||
registry := center.paymentRegistrySnapshot()
|
||||
if registry == nil {
|
||||
t.Fatal("payment registry is nil")
|
||||
}
|
||||
provider, ok := registry.Get("zhifufm-pay")
|
||||
if !ok {
|
||||
t.Fatal("zhifufm-pay provider is missing")
|
||||
}
|
||||
if _, ok := provider.(*payment.RPCProvider); !ok {
|
||||
t.Fatalf("zhifufm provider type = %T", provider)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,13 +144,16 @@ func RegisterPaymentRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
// Provider callbacks are intentionally unauthenticated at the application
|
||||
// layer. Authenticity is established by the pinned provider config and raw
|
||||
// request signature before any durable event is accepted.
|
||||
r.POST("/payments/notify/:providerId/:configId", func(c *gin.Context) {
|
||||
notifyHandler := func(c *gin.Context) {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, paymentNotificationMaxBytes)
|
||||
rawBody, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
writePaymentNotificationFailure(c, svc, c.Param("providerId"), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(rawBody) == 0 && c.Request.URL.RawQuery != "" {
|
||||
rawBody = []byte(c.Request.URL.RawQuery)
|
||||
}
|
||||
err = svc.AcceptPaymentNotification(c.Request.Context(), c.Param("providerId"), c.Param("configId"), c.Request.Header.Clone(), rawBody)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
@@ -167,7 +170,10 @@ func RegisterPaymentRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
return
|
||||
}
|
||||
c.Status(status)
|
||||
})
|
||||
}
|
||||
|
||||
r.POST("/payments/notify/:providerId/:configId", notifyHandler)
|
||||
r.GET("/payments/notify/:providerId/:configId", notifyHandler)
|
||||
r.GET("/payments/return/:providerId", func(c *gin.Context) {
|
||||
orderID := strings.ToLower(strings.TrimSpace(c.Query("orderId")))
|
||||
if !paymentOrderIDPattern.MatchString(orderID) {
|
||||
|
||||
@@ -16,6 +16,7 @@ var officialPaymentPackageIDs = []string{
|
||||
"official-payment-wechat-native",
|
||||
"official-payment-alipay-page",
|
||||
"official-payment-xunhupay",
|
||||
"official-payment-zhifufm",
|
||||
}
|
||||
|
||||
var officialPaymentTaggedArtifacts = []string{
|
||||
|
||||
@@ -25,7 +25,9 @@ const (
|
||||
ProviderWeChatNative = "wechat-native"
|
||||
ProviderAlipayPage = "alipay-page-pay"
|
||||
ProviderXunHuPay = "xunhupay-aggregate"
|
||||
ProviderZhiFuFM = "zhifufm-pay"
|
||||
PluginWeChatNative = "official-payment-wechat-native"
|
||||
PluginAlipayPage = "official-payment-alipay-page"
|
||||
PluginXunHuPay = "official-payment-xunhupay"
|
||||
PluginZhiFuFM = "official-payment-zhifufm"
|
||||
)
|
||||
|
||||
291
backend/payment-plugins/zhifufm.go
Normal file
291
backend/payment-plugins/zhifufm.go
Normal file
@@ -0,0 +1,291 @@
|
||||
package paymentplugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultZhiFuFMGateway = "https://api-5a0zvlcbi80.zhifu.fm.it88168.com/api"
|
||||
zhifuFMStartOrderPath = "/startOrder"
|
||||
zhifuFMQRLifetime = 10 * time.Minute
|
||||
)
|
||||
|
||||
type ZhiFuFMProvider struct {
|
||||
client *http.Client
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewZhiFuFMProvider(client *http.Client) *ZhiFuFMProvider {
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
return &ZhiFuFMProvider{client: client, now: time.Now}
|
||||
}
|
||||
|
||||
func (p *ZhiFuFMProvider) Descriptor() Descriptor {
|
||||
return Descriptor{
|
||||
ID: ProviderZhiFuFM, PluginID: PluginZhiFuFM, PluginVersion: "1.0.0",
|
||||
Name: "支付FM", Icon: "assets/icon.svg", CheckoutMode: "redirect",
|
||||
IdentityFields: []string{"merchantNum"},
|
||||
NotificationSuccess: NotificationResponse{Status: 200, ContentType: "text/plain; charset=utf-8", Body: "success"},
|
||||
NotificationFailure: NotificationResponse{Status: 400, ContentType: "text/plain; charset=utf-8", Body: "fail"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ZhiFuFMProvider) ValidateConfig(config Config) error {
|
||||
if strings.TrimSpace(config["merchantNum"]) == "" {
|
||||
return errors.New("支付FM配置缺少商户号 merchantNum")
|
||||
}
|
||||
if strings.TrimSpace(config["secretKey"]) == "" {
|
||||
return errors.New("支付FM配置缺少接入密钥 secretKey")
|
||||
}
|
||||
mode := zhifuFMCheckoutMode(config)
|
||||
if mode != "qr_code" && mode != "redirect" {
|
||||
return errors.New("支付FM checkoutMode 必须是 qr_code 或 redirect")
|
||||
}
|
||||
gateway := zhifuFMGateway(config)
|
||||
if _, err := url.Parse(gateway); err != nil || (!strings.HasPrefix(gateway, "http://") && !strings.HasPrefix(gateway, "https://")) {
|
||||
return fmt.Errorf("支付FM网关地址无效: %s", gateway)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type zhifuFMStartOrderResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Data struct {
|
||||
ID string `json:"id"`
|
||||
PayURL string `json:"payUrl"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func (p *ZhiFuFMProvider) 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" || request.MerchantOrderNo == "" || request.NotifyURL == "" {
|
||||
return Checkout{}, errors.New("支付FM下单参数无效")
|
||||
}
|
||||
|
||||
merchantNum := strings.TrimSpace(config["merchantNum"])
|
||||
secretKey := strings.TrimSpace(config["secretKey"])
|
||||
amountYuan := fmt.Sprintf("%.2f", float64(request.AmountFen)/100.0)
|
||||
|
||||
// sign = md5(商户号 + 商户订单号 + 支付金额 + 异步通知地址 + 接入密钥)
|
||||
signRaw := merchantNum + request.MerchantOrderNo + amountYuan + request.NotifyURL + secretKey
|
||||
sign := md5Hex(signRaw)
|
||||
|
||||
payType := strings.TrimSpace(config["payType"])
|
||||
if payType == "" {
|
||||
payType = "alipay"
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("merchantNum", merchantNum)
|
||||
form.Set("orderNo", request.MerchantOrderNo)
|
||||
form.Set("amount", amountYuan)
|
||||
form.Set("notifyUrl", request.NotifyURL)
|
||||
form.Set("payType", payType)
|
||||
form.Set("sign", sign)
|
||||
form.Set("returnType", "json")
|
||||
form.Set("apiMode", "post_form")
|
||||
if request.ReturnURL != "" {
|
||||
form.Set("returnUrl", request.ReturnURL)
|
||||
}
|
||||
if request.Description != "" {
|
||||
form.Set("subject", request.Description)
|
||||
}
|
||||
|
||||
gateway := zhifuFMGateway(config)
|
||||
endpoint := strings.TrimSuffix(gateway, "/") + zhifuFMStartOrderPath
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return Checkout{}, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return Checkout{}, &ProviderError{Code: "zhifufm_transport_error", Message: "支付FM网络请求失败", Temporary: true, Cause: err}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
||||
if err != nil {
|
||||
return Checkout{}, &ProviderError{Code: "zhifufm_response_read_error", Message: "读取支付FM响应失败", Temporary: true, Cause: err}
|
||||
}
|
||||
|
||||
var res zhifuFMStartOrderResponse
|
||||
if err := json.Unmarshal(body, &res); err != nil {
|
||||
return Checkout{}, &ProviderError{Code: "zhifufm_response_parse_error", Message: fmt.Sprintf("解析支付FM响应失败 (HTTP %d): %s", resp.StatusCode, string(body)), Temporary: true, Cause: err}
|
||||
}
|
||||
|
||||
if !res.Success || res.Code != 200 || res.Data.PayURL == "" {
|
||||
msg := res.Msg
|
||||
if msg == "" {
|
||||
msg = fmt.Sprintf("创建订单失败(code=%d)", res.Code)
|
||||
}
|
||||
return Checkout{}, &ProviderError{Code: "zhifufm_order_create_failed", Message: fmt.Sprintf("支付FM创建订单失败: %s", msg)}
|
||||
}
|
||||
|
||||
mode := zhifuFMCheckoutMode(config)
|
||||
expiresAt := request.ExpiresAt
|
||||
if expiresAt.IsZero() {
|
||||
expiresAt = p.now().Add(zhifuFMQRLifetime)
|
||||
}
|
||||
|
||||
return Checkout{
|
||||
Mode: mode,
|
||||
Value: res.Data.PayURL,
|
||||
ExpiresAt: expiresAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *ZhiFuFMProvider) QueryOrder(ctx context.Context, config Config, request QueryRequest) (Result, error) {
|
||||
return Result{
|
||||
MerchantOrderNo: request.MerchantOrderNo,
|
||||
Currency: "CNY",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *ZhiFuFMProvider) CloseOrder(ctx context.Context, config Config, request CloseRequest) (Result, error) {
|
||||
return Result{
|
||||
MerchantOrderNo: request.MerchantOrderNo,
|
||||
Closed: true,
|
||||
ProviderStatus: "CLOSED",
|
||||
Currency: "CNY",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *ZhiFuFMProvider) VerifyNotification(_ context.Context, config Config, headers 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{}, err
|
||||
}
|
||||
|
||||
merchantNum := strings.TrimSpace(params.Get("merchantNum"))
|
||||
orderNo := strings.TrimSpace(params.Get("orderNo"))
|
||||
amountStr := strings.TrimSpace(params.Get("amount"))
|
||||
state := strings.TrimSpace(params.Get("state"))
|
||||
platformOrderNo := strings.TrimSpace(params.Get("platformOrderNo"))
|
||||
sign := strings.TrimSpace(params.Get("sign"))
|
||||
|
||||
if merchantNum != strings.TrimSpace(config["merchantNum"]) {
|
||||
return Notification{}, errors.New("支付FM异步通知商户号不匹配")
|
||||
}
|
||||
|
||||
// 验签: sign = md5(state + merchantNum + orderNo + amount + secretKey)
|
||||
expectedSign := md5Hex(state + merchantNum + orderNo + amountStr + strings.TrimSpace(config["secretKey"]))
|
||||
if !strings.EqualFold(sign, expectedSign) {
|
||||
return Notification{}, fmt.Errorf("支付FM异步通知签名不匹配: got %s, want %s", sign, expectedSign)
|
||||
}
|
||||
|
||||
actualAmountStr := strings.TrimSpace(params.Get("actualPayAmount"))
|
||||
targetAmountStr := amountStr
|
||||
if actualAmountStr != "" {
|
||||
targetAmountStr = actualAmountStr
|
||||
}
|
||||
|
||||
amountFen, err := parseYuanToFen(targetAmountStr)
|
||||
if err != nil {
|
||||
amountFen, err = parseYuanToFen(amountStr)
|
||||
if err != nil {
|
||||
return Notification{}, fmt.Errorf("支付FM异步通知金额格式错误: %s", amountStr)
|
||||
}
|
||||
}
|
||||
|
||||
paid := state == "1"
|
||||
paidAt := time.Now()
|
||||
if payTimeStr := strings.TrimSpace(params.Get("payTime")); payTimeStr != "" {
|
||||
if t, err := time.ParseInLocation("2006-01-02 15:04:05", payTimeStr, time.Local); err == nil {
|
||||
paidAt = t
|
||||
}
|
||||
}
|
||||
|
||||
eventID := platformOrderNo
|
||||
if eventID == "" {
|
||||
eventID = orderNo + ":" + state
|
||||
}
|
||||
|
||||
return Notification{
|
||||
EventID: eventID,
|
||||
Result: Result{
|
||||
MerchantOrderNo: orderNo,
|
||||
ProviderTradeNo: platformOrderNo,
|
||||
ProviderStatus: state,
|
||||
AmountFen: amountFen,
|
||||
Currency: "CNY",
|
||||
Paid: paid,
|
||||
Closed: !paid,
|
||||
PaidAt: paidAt,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *ZhiFuFMProvider) DownloadTradeBill(context.Context, Config, time.Time) ([]BillRecord, error) {
|
||||
return nil, fmt.Errorf("%w: 支付FM未提供交易账单下载接口", ErrTradeBillNotFound)
|
||||
}
|
||||
|
||||
func zhifuFMGateway(config Config) string {
|
||||
g := strings.TrimSpace(config["gateway"])
|
||||
if g == "" {
|
||||
return defaultZhiFuFMGateway
|
||||
}
|
||||
return strings.TrimSuffix(g, "/")
|
||||
}
|
||||
|
||||
func zhifuFMCheckoutMode(config Config) string {
|
||||
mode := strings.TrimSpace(config["checkoutMode"])
|
||||
if mode == "qr_code" {
|
||||
return "qr_code"
|
||||
}
|
||||
return "redirect"
|
||||
}
|
||||
|
||||
func md5Hex(s string) string {
|
||||
h := md5.Sum([]byte(s))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func parseNotificationValues(rawBody []byte) (url.Values, error) {
|
||||
bodyStr := string(rawBody)
|
||||
if strings.Contains(bodyStr, "=") {
|
||||
return url.ParseQuery(bodyStr)
|
||||
}
|
||||
var jsonMap map[string]any
|
||||
if err := json.Unmarshal(rawBody, &jsonMap); err == nil {
|
||||
vals := url.Values{}
|
||||
for k, v := range jsonMap {
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
vals.Set(k, val)
|
||||
case float64:
|
||||
vals.Set(k, strconv.FormatFloat(val, 'f', -1, 64))
|
||||
case int64:
|
||||
vals.Set(k, strconv.FormatInt(val, 10))
|
||||
default:
|
||||
vals.Set(k, fmt.Sprint(v))
|
||||
}
|
||||
}
|
||||
return vals, nil
|
||||
}
|
||||
return nil, errors.New("无法解析支付通知内容")
|
||||
}
|
||||
83
backend/payment-plugins/zhifufm_test.go
Normal file
83
backend/payment-plugins/zhifufm_test.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package paymentplugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestZhiFuFMProviderCreateOrderAndVerifyNotification(t *testing.T) {
|
||||
merchantNum := "694771317639364608"
|
||||
secretKey := "f17bc85d99c1d2b4b4d81eaba1489c7e"
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/startOrder" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
vals, _ := parseNotificationValues(body)
|
||||
if vals.Get("merchantNum") != merchantNum {
|
||||
t.Errorf("got merchantNum = %q, want %q", vals.Get("merchantNum"), merchantNum)
|
||||
}
|
||||
expectedSign := md5Hex(merchantNum + vals.Get("orderNo") + vals.Get("amount") + vals.Get("notifyUrl") + secretKey)
|
||||
if vals.Get("sign") != expectedSign {
|
||||
t.Errorf("got sign = %q, want %q", vals.Get("sign"), expectedSign)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{
|
||||
"success": true,
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"timestamp": 1720000000000,
|
||||
"data": {
|
||||
"id": "ZFM202609180001",
|
||||
"payUrl": "https://page.zhifu.fm/pay?id=ZFM202609180001"
|
||||
}
|
||||
}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := NewZhiFuFMProvider(server.Client())
|
||||
config := Config{
|
||||
"merchantNum": merchantNum,
|
||||
"secretKey": secretKey,
|
||||
"gateway": server.URL,
|
||||
"checkoutMode": "redirect",
|
||||
}
|
||||
|
||||
checkout, err := provider.CreateOrder(context.Background(), config, CreateRequest{
|
||||
MerchantOrderNo: "TEST_ORDER_12345",
|
||||
Description: "充值 1000 积分",
|
||||
AmountFen: 1000, // 10.00 元
|
||||
Currency: "CNY",
|
||||
NotifyURL: "https://my.domain/api/payments/notify",
|
||||
ExpiresAt: time.Now().Add(10 * time.Minute),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateOrder failed: %v", err)
|
||||
}
|
||||
if checkout.Mode != "redirect" || checkout.Value != "https://page.zhifu.fm/pay?id=ZFM202609180001" {
|
||||
t.Fatalf("unexpected checkout: %#v", checkout)
|
||||
}
|
||||
|
||||
// 模拟异步通知回调
|
||||
// sign = md5(state + merchantNum + orderNo + amount + secretKey)
|
||||
state := "1"
|
||||
orderNo := "TEST_ORDER_12345"
|
||||
amount := "10.00"
|
||||
notifySign := md5Hex(state + merchantNum + orderNo + amount + secretKey)
|
||||
notifyForm := "merchantNum=" + merchantNum + "&orderNo=" + orderNo + "&amount=" + amount + "&state=" + state + "&platformOrderNo=ZFM202609180001&payTime=2026-09-18+12:00:00&sign=" + notifySign
|
||||
|
||||
notification, err := provider.VerifyNotification(context.Background(), config, nil, []byte(notifyForm))
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyNotification failed: %v", err)
|
||||
}
|
||||
if !notification.Paid || notification.AmountFen != 1000 || notification.MerchantOrderNo != "TEST_ORDER_12345" {
|
||||
t.Fatalf("unexpected notification: %#v", notification)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
payment_plugins="official-payment-wechat-native official-payment-alipay-page official-payment-xunhupay official-payment-zhifufm"
|
||||
payments_only=false
|
||||
default_payment_targets="linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64"
|
||||
|
||||
@@ -118,6 +118,7 @@ for target in $payment_targets; do
|
||||
build_payment_provider official-payment-wechat-native ./cmd/payment-wechat "$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-zhifufm ./cmd/payment-zhifufm "$target_goos" "$target_goarch"
|
||||
done
|
||||
|
||||
package_plugin() {
|
||||
|
||||
BIN
plugin-packages/official-payment-zhifufm.yingce-plugin
Normal file
BIN
plugin-packages/official-payment-zhifufm.yingce-plugin
Normal file
Binary file not shown.
11
plugin-packages/official-payment-zhifufm/README.md
Normal file
11
plugin-packages/official-payment-zhifufm/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# 支付FM 聚合支付插件
|
||||
|
||||
支持对接支付FM免签个人收款码与签约支付通道,支持支付宝/微信扫码及收银台跳转。
|
||||
|
||||
### 插件配置参数
|
||||
- **服务器公网地址**: 本站对外公网地址,用于接收 `notifyUrl` 异步回调通知。
|
||||
- **商户号 (merchantNum)**: 支付FM商户后台【用户中心】查看的商户号。
|
||||
- **接入密钥 (secretKey)**: 支付FM商户后台【用户中心】查看的接口接入密钥。
|
||||
- **接口根地址**: 支付FM后台显示的专属根地址,例如 `https://api-5a0zvlcbi80.zhifu.fm.it88168.com/api`。
|
||||
- **默认支付通道 (payType)**: 推荐使用 `aloop` / `tloop` 或 `alipay` / `wechat`。
|
||||
- **收银方式**: 默认为 `redirect` 跳转模式。
|
||||
3
plugin-packages/official-payment-zhifufm/assets/icon.svg
Normal file
3
plugin-packages/official-payment-zhifufm/assets/icon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 19 KiB |
BIN
plugin-packages/official-payment-zhifufm/backend/provider
Executable file
BIN
plugin-packages/official-payment-zhifufm/backend/provider
Executable file
Binary file not shown.
BIN
plugin-packages/official-payment-zhifufm/backend/provider-darwin-amd64
Executable file
BIN
plugin-packages/official-payment-zhifufm/backend/provider-darwin-amd64
Executable file
Binary file not shown.
BIN
plugin-packages/official-payment-zhifufm/backend/provider-darwin-arm64
Executable file
BIN
plugin-packages/official-payment-zhifufm/backend/provider-darwin-arm64
Executable file
Binary file not shown.
BIN
plugin-packages/official-payment-zhifufm/backend/provider-linux-amd64
Executable file
BIN
plugin-packages/official-payment-zhifufm/backend/provider-linux-amd64
Executable file
Binary file not shown.
BIN
plugin-packages/official-payment-zhifufm/backend/provider-linux-arm64
Executable file
BIN
plugin-packages/official-payment-zhifufm/backend/provider-linux-arm64
Executable file
Binary file not shown.
Binary file not shown.
Binary file not shown.
119
plugin-packages/official-payment-zhifufm/docs/interface.md
Normal file
119
plugin-packages/official-payment-zhifufm/docs/interface.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# 支付FM 接口协议说明
|
||||
|
||||
遵循 `yingce.payment/v1` 标准,自动转换影策系统订单至支付FM `/startOrder` 接口,并在收到通知时按 MD5 规则完成异步验签。
|
||||
|
||||
<!-- YINGCE_MANIFEST_CONTRACT_START -->
|
||||
## Manifest 完整接口定义
|
||||
|
||||
以下 JSON 与插件包内实际 `manifest.json` 逐字段一致,覆盖插件身份、权限、配置、鉴权、参数、校验、创建、Agent、查询、取消、结果下载、响应和 Agent 响应映射。`documentation` 字段的值就是当前完整文档;为避免文档在自身内部无限递归,JSON 中仅用等义占位文本表示正文。
|
||||
|
||||
```json
|
||||
{
|
||||
"apiVersion": "yingce.plugin/v1",
|
||||
"id": "official-payment-zhifufm",
|
||||
"name": "支付FM",
|
||||
"version": "1.0.0",
|
||||
"author": "支付FM",
|
||||
"description": "支付FM个人免签/签约聚合支付适配器,支持微信/支付宝扫码或收银台跳转。",
|
||||
"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": "用于生成异步通知 notifyUrl 地址,必须可被支付FM外网服务器访问回调。"
|
||||
},
|
||||
{
|
||||
"name": "merchantNum",
|
||||
"type": "string",
|
||||
"label": "商户号 (merchantNum)",
|
||||
"required": true,
|
||||
"description": "支付FM商户后台【用户中心】查看的商户号(8-18位数字)。"
|
||||
},
|
||||
{
|
||||
"name": "secretKey",
|
||||
"type": "password",
|
||||
"label": "接入密钥 (secretKey)",
|
||||
"required": true,
|
||||
"secret": true,
|
||||
"description": "支付FM商户后台【用户中心】查看的接口接入密钥。"
|
||||
},
|
||||
{
|
||||
"name": "gateway",
|
||||
"type": "url",
|
||||
"label": "接口根地址",
|
||||
"required": true,
|
||||
"default": "https://api-5a0zvlcbi80.zhifu.fm.it88168.com/api",
|
||||
"description": "支付FM分配的接口根地址,如 https://api-5a0zvlcbi80.zhifu.fm.it88168.com/api。"
|
||||
},
|
||||
{
|
||||
"name": "payType",
|
||||
"type": "string",
|
||||
"label": "默认支付通道 (payType)",
|
||||
"required": false,
|
||||
"default": "alipay",
|
||||
"description": "支付产品通道:alipay(支付宝)、wechat(微信)、aloop(支付宝轮询池)、tloop(微信轮询池)等。"
|
||||
},
|
||||
{
|
||||
"name": "checkoutMode",
|
||||
"type": "string",
|
||||
"label": "收银方式",
|
||||
"required": false,
|
||||
"default": "redirect",
|
||||
"values": [
|
||||
"redirect",
|
||||
"qr_code"
|
||||
],
|
||||
"description": "redirect 跳转支付FM收银页;qr_code 在钱包中直接将 payUrl 渲染为二维码扫码。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"contributes": {
|
||||
"paymentProviders": [
|
||||
{
|
||||
"id": "zhifufm-pay",
|
||||
"label": "支付FM",
|
||||
"icon": "assets/icon.svg",
|
||||
"checkoutMode": "redirect",
|
||||
"identityFields": [
|
||||
"merchantNum"
|
||||
],
|
||||
"expiryPolicy": {
|
||||
"defaultMinutes": 10,
|
||||
"minMinutes": 3,
|
||||
"maxMinutes": 15
|
||||
},
|
||||
"notificationSuccess": {
|
||||
"status": 200,
|
||||
"contentType": "text/plain; charset=utf-8",
|
||||
"body": "success"
|
||||
},
|
||||
"notificationFailure": {
|
||||
"status": 400,
|
||||
"contentType": "text/plain; charset=utf-8",
|
||||
"body": "fail"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"documentation": "<当前插件的完整 documentation,由 README.md 与 docs/interface.md 拼接而成;为避免 JSON 递归,此处不重复展开正文。>"
|
||||
}
|
||||
```
|
||||
<!-- YINGCE_MANIFEST_CONTRACT_END -->
|
||||
107
plugin-packages/official-payment-zhifufm/manifest.json
Normal file
107
plugin-packages/official-payment-zhifufm/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
|
||||
|
||||
for package_id in official-payment-wechat-native official-payment-alipay-page official-payment-xunhupay; do
|
||||
for package_id in official-payment-wechat-native official-payment-alipay-page official-payment-xunhupay official-payment-zhifufm; do
|
||||
backend_dir="$root_dir/$package_id/backend"
|
||||
provider="$backend_dir/provider"
|
||||
package_file="$root_dir/$package_id.yingce-plugin"
|
||||
|
||||
BIN
web/public/icons/zhifufm-logo.png
Normal file
BIN
web/public/icons/zhifufm-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
30
web/src/components/payment-brand-icons.tsx
Normal file
30
web/src/components/payment-brand-icons.tsx
Normal file
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
|
||||
import { AlipayCircleFilled, WechatFilled } from "@ant-design/icons";
|
||||
import { PaymentBrandIcon } from "@/components/payment-brand-icons";
|
||||
import { Callout } from "@/pages/admin/ui/controls";
|
||||
import { App, Button, DatePicker, Descriptions, Drawer, Form, Input, InputNumber, Select, Tabs, Typography } from "antd";
|
||||
import { AdminDrawer } from "@/pages/admin/ui/overlays";
|
||||
@@ -912,23 +912,6 @@ export default function AdminPaymentsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function PaymentBrandIcon({ providerId, compact = false }: { providerId: string; compact?: boolean }) {
|
||||
const size = compact ? "size-6" : "size-10";
|
||||
if (providerId === "wechat-native")
|
||||
return (
|
||||
<span className={`grid ${size} shrink-0 place-items-center rounded-lg bg-[#07c160]/10 text-[#07c160]`}>
|
||||
<WechatFilled className={compact ? "text-sm" : "text-xl"} aria-hidden />
|
||||
</span>
|
||||
);
|
||||
if (providerId === "alipay-page-pay")
|
||||
return (
|
||||
<span className={`grid ${size} shrink-0 place-items-center rounded-lg bg-[#1677ff]/10 text-[#1677ff]`}>
|
||||
<AlipayCircleFilled className={compact ? "text-sm" : "text-xl"} aria-hidden />
|
||||
</span>
|
||||
);
|
||||
return <span className={`grid ${size} shrink-0 place-items-center rounded-lg bg-muted text-xs`}>PAY</span>;
|
||||
}
|
||||
|
||||
function formatDateTime(value: string) {
|
||||
return dayjs(value).format("YYYY-MM-DD HH:mm:ss");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { App, Button, Input, Select } from "antd";
|
||||
import { Switch } from "@/pages/admin/ui/controls";
|
||||
import { AlipayCircleFilled, WechatFilled } from "@ant-design/icons";
|
||||
import { ZHIFUFM_LOGO_SRC } from "@/components/payment-brand-icons";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import { CloudUpload, PlugZap, RefreshCw, Search, Trash2, UsersRound } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
@@ -350,6 +351,13 @@ function PluginBrandIcon({ pluginId }: { pluginId: string }) {
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (pluginId === "official-payment-zhifufm") {
|
||||
return (
|
||||
<span className="grid size-9 shrink-0 place-items-center rounded-lg overflow-hidden border border-border/40 bg-card shadow-xs">
|
||||
<img src={ZHIFUFM_LOGO_SRC} alt="支付FM" className="size-full object-contain select-none pointer-events-none rounded-lg" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="grid size-9 shrink-0 place-items-center rounded-lg bg-muted text-foreground/65">
|
||||
<PlugZap className="size-4" aria-hidden="true" />
|
||||
|
||||
Reference in New Issue
Block a user