feat(payment): 支付插件 - 接入支付FM官方标准插件 official-payment-zhifufm
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
Normal file
BIN
plugin-packages/official-payment-zhifufm/backend/provider
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
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