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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user