refactor(payment): 支付插件 - 宿主仅通过 RPC 包加载并保护订单版本
This commit is contained in:
16
backend/cmd/payment-alipay/main.go
Normal file
16
backend/cmd/payment-alipay/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.NewAlipayProvider(&http.Client{Timeout: 25 * time.Second})
|
||||
if err := paymentplugins.RunRPC(provider, os.Stdin, os.Stdout); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
16
backend/cmd/payment-wechat/main.go
Normal file
16
backend/cmd/payment-wechat/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.NewWeChatProvider(&http.Client{Timeout: 25 * time.Second}, "https://api.mch.weixin.qq.com")
|
||||
if err := paymentplugins.RunRPC(provider, os.Stdin, os.Stdout); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -149,7 +149,7 @@ func RegisterPaymentRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, paymentNotificationMaxBytes)
|
||||
rawBody, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
writePaymentNotificationFailure(c, c.Param("providerId"), http.StatusBadRequest)
|
||||
writePaymentNotificationFailure(c, svc, c.Param("providerId"), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
err = svc.AcceptPaymentNotification(c.Request.Context(), c.Param("providerId"), c.Param("configId"), c.Request.Header.Clone(), rawBody)
|
||||
@@ -159,16 +159,17 @@ func RegisterPaymentRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
if errors.As(err, &appErr) && appErr.Status < http.StatusInternalServerError {
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
writePaymentNotificationFailure(c, c.Param("providerId"), status)
|
||||
writePaymentNotificationFailure(c, svc, c.Param("providerId"), status)
|
||||
return
|
||||
}
|
||||
if c.Param("providerId") == service.PaymentProviderAlipay {
|
||||
c.Data(http.StatusOK, "text/plain; charset=utf-8", []byte("success"))
|
||||
status, contentType, body := svc.PaymentNotificationResponse(c.Param("providerId"), true)
|
||||
if body != "" {
|
||||
c.Data(status, contentType, []byte(body))
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
c.Status(status)
|
||||
})
|
||||
r.GET("/payments/return/alipay", func(c *gin.Context) {
|
||||
r.GET("/payments/return/:providerId", func(c *gin.Context) {
|
||||
orderID := strings.ToLower(strings.TrimSpace(c.Query("orderId")))
|
||||
if !paymentOrderIDPattern.MatchString(orderID) {
|
||||
c.Redirect(http.StatusFound, "/wallet?payment=invalid")
|
||||
@@ -349,10 +350,11 @@ func RegisterPaymentRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
})
|
||||
}
|
||||
|
||||
func writePaymentNotificationFailure(c *gin.Context, providerID string, status int) {
|
||||
if providerID == service.PaymentProviderAlipay {
|
||||
c.Data(status, "text/plain; charset=utf-8", []byte("failure"))
|
||||
func writePaymentNotificationFailure(c *gin.Context, svc *service.Service, providerID string, status int) {
|
||||
responseStatus, contentType, body := svc.PaymentNotificationFailureResponse(providerID, status)
|
||||
if body != "" {
|
||||
c.Data(responseStatus, contentType, []byte(body))
|
||||
return
|
||||
}
|
||||
c.JSON(status, gin.H{"code": "FAIL", "message": http.StatusText(status)})
|
||||
c.JSON(responseStatus, gin.H{"code": "FAIL", "message": http.StatusText(responseStatus)})
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ type PaymentProviderConfig struct {
|
||||
ID string `json:"id" gorm:"primaryKey;size:36"`
|
||||
ProviderID string `json:"providerId" gorm:"size:80;index;uniqueIndex:idx_payment_provider_version,priority:1"`
|
||||
PluginID string `json:"pluginId" gorm:"size:120;index"`
|
||||
PluginVersion string `json:"pluginVersion" gorm:"size:40"`
|
||||
Version int64 `json:"version" gorm:"uniqueIndex:idx_payment_provider_version,priority:2"`
|
||||
Enabled bool `json:"enabled" gorm:"index"`
|
||||
CloseAfterMinutes int `json:"closeAfterMinutes"`
|
||||
@@ -72,6 +73,7 @@ type PaymentOrder struct {
|
||||
ProductName string `json:"productName" gorm:"size:120"`
|
||||
ProviderID string `json:"providerId" gorm:"size:80;index;uniqueIndex:idx_payment_provider_trade,priority:1"`
|
||||
PluginID string `json:"pluginId" gorm:"size:120;index"`
|
||||
PluginVersion string `json:"pluginVersion" gorm:"size:40"`
|
||||
ProviderConfigID string `json:"providerConfigId" gorm:"size:36;index"`
|
||||
ProviderConfigVersion int64 `json:"providerConfigVersion"`
|
||||
AmountFen int64 `json:"amountFen"`
|
||||
|
||||
@@ -2,10 +2,8 @@ package payment
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Registry struct {
|
||||
@@ -30,18 +28,6 @@ func NewRegistry(providers ...Provider) (*Registry, error) {
|
||||
return registry, nil
|
||||
}
|
||||
|
||||
func Builtins() *Registry {
|
||||
client := &http.Client{Timeout: 60 * time.Second}
|
||||
registry, err := NewRegistry(
|
||||
NewWeChatProvider(client, "https://api.mch.weixin.qq.com"),
|
||||
NewAlipayProvider(client),
|
||||
)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return registry
|
||||
}
|
||||
|
||||
func (r *Registry) Get(id string) (Provider, bool) {
|
||||
if r == nil {
|
||||
return nil, false
|
||||
@@ -61,3 +47,14 @@ func (r *Registry) Descriptors() []Descriptor {
|
||||
sort.Slice(items, func(i, j int) bool { return items[i].ID < items[j].ID })
|
||||
return items
|
||||
}
|
||||
|
||||
func (r *Registry) Providers() []Provider {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
items := make([]Provider, 0, len(r.providers))
|
||||
for _, provider := range r.providers {
|
||||
items = append(items, provider)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
174
backend/internal/payment/rpc.go
Normal file
174
backend/internal/payment/rpc.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package payment
|
||||
|
||||
// RPCProvider is the host side of the yingce.payment/v1 process ABI. The
|
||||
// process is shipped inside a validated plugin package and receives exactly
|
||||
// one JSON request on stdin, returning one JSON response on stdout. The host
|
||||
// still owns the order, credential storage, network policy and crediting
|
||||
// transaction; the process only translates a payment provider protocol.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
pluginRPCVersion = "yingce.payment/v1"
|
||||
pluginRPCMaxOutput = 2 << 20
|
||||
pluginRPCTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
type RPCProvider struct {
|
||||
descriptor Descriptor
|
||||
command string
|
||||
dir string
|
||||
}
|
||||
|
||||
func NewRPCProvider(descriptor Descriptor, packageDir, entry string) (*RPCProvider, error) {
|
||||
if strings.TrimSpace(descriptor.ID) == "" || strings.TrimSpace(descriptor.PluginID) == "" {
|
||||
return nil, errors.New("payment plugin descriptor requires id and plugin id")
|
||||
}
|
||||
if filepath.IsAbs(entry) || filepath.Clean(entry) != entry || !strings.HasPrefix(entry, "backend/") {
|
||||
return nil, errors.New("payment plugin entry must be relative to backend/")
|
||||
}
|
||||
if strings.TrimSpace(packageDir) == "" {
|
||||
return nil, errors.New("payment plugin package directory is empty")
|
||||
}
|
||||
return &RPCProvider{descriptor: descriptor, command: filepath.Join(packageDir, filepath.FromSlash(entry)), dir: packageDir}, nil
|
||||
}
|
||||
|
||||
func (p *RPCProvider) Descriptor() Descriptor { return p.descriptor }
|
||||
|
||||
type rpcRequest struct {
|
||||
Version string `json:"version"`
|
||||
Operation string `json:"operation"`
|
||||
Config Config `json:"config,omitempty"`
|
||||
Request any `json:"request,omitempty"`
|
||||
Headers map[string][]string `json:"headers,omitempty"`
|
||||
Body string `json:"bodyBase64,omitempty"`
|
||||
BillDate string `json:"billDate,omitempty"`
|
||||
}
|
||||
|
||||
type rpcResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Data json.RawMessage `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
func (p *RPCProvider) call(ctx context.Context, request rpcRequest, output any) error {
|
||||
if p == nil || strings.TrimSpace(p.command) == "" {
|
||||
return errors.New("payment plugin runtime is unavailable")
|
||||
}
|
||||
if _, err := exec.LookPath(p.command); err != nil {
|
||||
return fmt.Errorf("payment plugin executable unavailable: %w", err)
|
||||
}
|
||||
if _, err := filepath.Abs(p.command); err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, pluginRPCTimeout)
|
||||
defer cancel()
|
||||
payload, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, p.command)
|
||||
cmd.Dir = p.dir
|
||||
cmd.Env = []string{"PATH=/usr/bin:/bin", "HOME=/nonexistent"}
|
||||
cmd.Stdin = strings.NewReader(string(payload) + "\n")
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return &ProviderError{Code: "plugin_start_failed", Message: "支付插件启动失败", Temporary: true, Cause: err}
|
||||
}
|
||||
data, readErr := io.ReadAll(io.LimitReader(stdout, pluginRPCMaxOutput+1))
|
||||
errData, _ := io.ReadAll(io.LimitReader(stderr, 64<<10))
|
||||
waitErr := cmd.Wait()
|
||||
if readErr != nil {
|
||||
return &ProviderError{Code: "plugin_read_failed", Message: "读取支付插件响应失败", Temporary: true, Cause: readErr}
|
||||
}
|
||||
if len(data) > pluginRPCMaxOutput {
|
||||
return errors.New("支付插件响应超过安全限制")
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return &ProviderError{Code: "plugin_timeout", Message: "支付插件执行超时", Temporary: true, Cause: ctx.Err()}
|
||||
}
|
||||
if waitErr != nil {
|
||||
message := strings.TrimSpace(string(errData))
|
||||
if message == "" {
|
||||
message = "支付插件进程异常退出"
|
||||
}
|
||||
return &ProviderError{Code: "plugin_process_failed", Message: message, Temporary: true, Cause: waitErr}
|
||||
}
|
||||
var response rpcResponse
|
||||
if err := json.Unmarshal(data, &response); err != nil {
|
||||
return fmt.Errorf("支付插件响应不是有效 JSON:%w", err)
|
||||
}
|
||||
if !response.OK {
|
||||
message := response.Message
|
||||
if message == "" {
|
||||
message = response.Error
|
||||
}
|
||||
if message == "" {
|
||||
message = "支付插件返回失败"
|
||||
}
|
||||
return &ProviderError{Code: response.Code, Message: message}
|
||||
}
|
||||
if output == nil || len(response.Data) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := json.Unmarshal(response.Data, output); err != nil {
|
||||
return fmt.Errorf("解析支付插件数据失败:%w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *RPCProvider) ValidateConfig(config Config) error {
|
||||
return p.call(context.Background(), rpcRequest{Version: pluginRPCVersion, Operation: "validate_config", Config: config}, nil)
|
||||
}
|
||||
|
||||
func (p *RPCProvider) CreateOrder(ctx context.Context, config Config, request CreateRequest) (Checkout, error) {
|
||||
var result Checkout
|
||||
err := p.call(ctx, rpcRequest{Version: pluginRPCVersion, Operation: "create_order", Config: config, Request: request}, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (p *RPCProvider) QueryOrder(ctx context.Context, config Config, request QueryRequest) (Result, error) {
|
||||
var result Result
|
||||
err := p.call(ctx, rpcRequest{Version: pluginRPCVersion, Operation: "query_order", Config: config, Request: request}, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (p *RPCProvider) CloseOrder(ctx context.Context, config Config, request CloseRequest) (Result, error) {
|
||||
var result Result
|
||||
err := p.call(ctx, rpcRequest{Version: pluginRPCVersion, Operation: "close_order", Config: config, Request: request}, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (p *RPCProvider) VerifyNotification(ctx context.Context, config Config, headers http.Header, rawBody []byte) (Notification, error) {
|
||||
var result Notification
|
||||
headerValues := map[string][]string(headers)
|
||||
err := p.call(ctx, rpcRequest{Version: pluginRPCVersion, Operation: "verify_notification", Config: config, Headers: headerValues, Body: base64.StdEncoding.EncodeToString(rawBody)}, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (p *RPCProvider) DownloadTradeBill(ctx context.Context, config Config, billDate time.Time) ([]BillRecord, error) {
|
||||
var result []BillRecord
|
||||
err := p.call(ctx, rpcRequest{Version: pluginRPCVersion, Operation: "download_trade_bill", Config: config, BillDate: billDate.Format("2006-01-02")}, &result)
|
||||
return result, err
|
||||
}
|
||||
29
backend/internal/payment/rpc_test.go
Normal file
29
backend/internal/payment/rpc_test.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRPCProviderUsesPaymentV1Contract(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
entry := filepath.Join(dir, "provider")
|
||||
program := "#!/bin/sh\nread request\nprintf '%s\\n' '{\"ok\":true,\"data\":{\"mode\":\"qr_code\",\"value\":\"https://pay.example/qr\"}}'\n"
|
||||
if err := os.WriteFile(entry, []byte(program), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
provider, err := NewRPCProvider(Descriptor{ID: "test-provider", PluginID: "test-plugin", CheckoutMode: "qr_code"}, dir, "backend/provider")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
provider.command = entry
|
||||
checkout, err := provider.CreateOrder(context.Background(), Config{"merchantId": "m"}, CreateRequest{MerchantOrderNo: "order-1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if checkout.Mode != "qr_code" || checkout.Value != "https://pay.example/qr" {
|
||||
t.Fatalf("checkout = %#v", checkout)
|
||||
}
|
||||
}
|
||||
@@ -1,110 +1,35 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
"infinite-canvas/backend/internal/protocol"
|
||||
paymentsdk "infinite-canvas/backend/payment-sdk"
|
||||
)
|
||||
|
||||
const (
|
||||
ProviderWeChatNative = "wechat-native"
|
||||
ProviderAlipayPage = "alipay-page-pay"
|
||||
|
||||
PluginWeChatNative = "official-payment-wechat-native"
|
||||
PluginAlipayPage = "official-payment-alipay-page"
|
||||
)
|
||||
type Config = paymentsdk.Config
|
||||
type Descriptor = paymentsdk.Descriptor
|
||||
type NotificationResponse = paymentsdk.NotificationResponse
|
||||
type CreateRequest = paymentsdk.CreateRequest
|
||||
type Checkout = paymentsdk.Checkout
|
||||
type QueryRequest = paymentsdk.QueryRequest
|
||||
type CloseRequest = paymentsdk.CloseRequest
|
||||
type Result = paymentsdk.Result
|
||||
type Notification = paymentsdk.Notification
|
||||
type BillRecord = paymentsdk.BillRecord
|
||||
type Provider = paymentsdk.Provider
|
||||
type ProviderError = paymentsdk.ProviderError
|
||||
|
||||
var (
|
||||
ErrOrderNotFound = errors.New("payment provider order not found")
|
||||
ErrOrderNotPaid = errors.New("payment provider order is not paid")
|
||||
ErrTradeBillNotFound = errors.New("payment provider trade bill not found")
|
||||
ErrOrderNotFound = paymentsdk.ErrOrderNotFound
|
||||
ErrOrderNotPaid = paymentsdk.ErrOrderNotPaid
|
||||
ErrTradeBillNotFound = paymentsdk.ErrTradeBillNotFound
|
||||
)
|
||||
|
||||
type Config map[string]string
|
||||
|
||||
type Descriptor struct {
|
||||
ID string `json:"id"`
|
||||
PluginID string `json:"pluginId"`
|
||||
Name string `json:"name"`
|
||||
Icon string `json:"icon"`
|
||||
CheckoutMode string `json:"checkoutMode"`
|
||||
}
|
||||
|
||||
type CreateRequest struct {
|
||||
MerchantOrderNo string
|
||||
Description string
|
||||
AmountFen int64
|
||||
Currency string
|
||||
ExpiresAt time.Time
|
||||
NotifyURL string
|
||||
ReturnURL string
|
||||
}
|
||||
|
||||
type Checkout struct {
|
||||
Mode string
|
||||
Value string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type QueryRequest struct {
|
||||
MerchantOrderNo string
|
||||
}
|
||||
|
||||
type CloseRequest struct {
|
||||
MerchantOrderNo string
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
MerchantOrderNo string `json:"merchantOrderNo"`
|
||||
ProviderTradeNo string `json:"providerTradeNo,omitempty"`
|
||||
ProviderStatus string `json:"providerStatus"`
|
||||
AmountFen int64 `json:"amountFen"`
|
||||
Currency string `json:"currency"`
|
||||
Paid bool `json:"paid"`
|
||||
Closed bool `json:"closed"`
|
||||
PaidAt time.Time `json:"paidAt,omitempty"`
|
||||
}
|
||||
|
||||
type Notification struct {
|
||||
EventID string `json:"eventId"`
|
||||
Result
|
||||
}
|
||||
|
||||
type BillRecord struct {
|
||||
MerchantOrderNo string
|
||||
ProviderTradeNo string
|
||||
ProviderStatus string
|
||||
AmountFen int64
|
||||
Currency string
|
||||
PaidAt time.Time
|
||||
}
|
||||
|
||||
type Provider interface {
|
||||
Descriptor() Descriptor
|
||||
ValidateConfig(Config) error
|
||||
CreateOrder(context.Context, Config, CreateRequest) (Checkout, error)
|
||||
QueryOrder(context.Context, Config, QueryRequest) (Result, error)
|
||||
CloseOrder(context.Context, Config, CloseRequest) (Result, error)
|
||||
VerifyNotification(context.Context, Config, http.Header, []byte) (Notification, error)
|
||||
DownloadTradeBill(context.Context, Config, time.Time) ([]BillRecord, error)
|
||||
}
|
||||
|
||||
type ProviderError struct {
|
||||
Code string
|
||||
Message string
|
||||
Temporary bool
|
||||
Cause error
|
||||
}
|
||||
|
||||
func (e *ProviderError) Error() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
func DescriptorFromManifest(manifest protocol.Manifest, provider protocol.ManifestPaymentProvider) Descriptor {
|
||||
return Descriptor{
|
||||
ID: provider.ID, PluginID: manifest.Metadata.ID, PluginVersion: manifest.Metadata.Version, Name: provider.Label,
|
||||
Icon: provider.Icon, CheckoutMode: provider.CheckoutMode,
|
||||
IdentityFields: append([]string(nil), provider.IdentityFields...),
|
||||
NotificationSuccess: NotificationResponse{Status: provider.NotificationSuccess.Status, ContentType: provider.NotificationSuccess.ContentType, Body: provider.NotificationSuccess.Body},
|
||||
NotificationFailure: NotificationResponse{Status: provider.NotificationFailure.Status, ContentType: provider.NotificationFailure.ContentType, Body: provider.NotificationFailure.Body},
|
||||
}
|
||||
if e.Message != "" {
|
||||
return e.Message
|
||||
}
|
||||
return e.Code
|
||||
}
|
||||
|
||||
func (e *ProviderError) Unwrap() error { return e.Cause }
|
||||
|
||||
@@ -257,6 +257,9 @@ func ValidateManifest(manifest Manifest) error {
|
||||
if len(manifest.Contributes.Providers) == 0 && !hasNonProviderContribution(manifest.Contributes) {
|
||||
return fmt.Errorf("plugin must declare at least one contribution")
|
||||
}
|
||||
if backend := strings.TrimSpace(manifest.Runtime.Backend); backend != "" && backend != "declarative" && backend != "rpc" && backend != "wasm" && backend != "trusted-backend" && !strings.HasPrefix(backend, "host:") {
|
||||
return fmt.Errorf("unsupported plugin backend %q", backend)
|
||||
}
|
||||
if len(manifest.Contributes.Providers) == 0 {
|
||||
return validatePaymentProviderContributions(manifest)
|
||||
}
|
||||
@@ -342,6 +345,16 @@ func validatePaymentProviderContributions(manifest Manifest) error {
|
||||
if policy.MinMinutes <= 0 || policy.DefaultMinutes < policy.MinMinutes || policy.MaxMinutes < policy.DefaultMinutes {
|
||||
return fmt.Errorf("payment provider contribution %q has invalid expiry policy", provider.ID)
|
||||
}
|
||||
for _, field := range provider.IdentityFields {
|
||||
if strings.TrimSpace(field) == "" || len(field) > 80 {
|
||||
return fmt.Errorf("payment provider contribution %q has invalid identity field %q", provider.ID, field)
|
||||
}
|
||||
}
|
||||
for _, response := range []ManifestPaymentResponse{provider.NotificationSuccess, provider.NotificationFailure} {
|
||||
if response.Status < 0 || response.Status > 599 {
|
||||
return fmt.Errorf("payment provider contribution %q has invalid notification response status", provider.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -34,6 +34,11 @@ func TestOfficialProtocolPackagesAreSelfContainedDeclarativePlugins(t *testing.T
|
||||
if err != nil {
|
||||
t.Fatalf("parse %s: %v", filepath.Base(path), err)
|
||||
}
|
||||
if len(pkg.Manifest.Contributes.PaymentProviders) > 0 {
|
||||
// Payment packages use the yingce.payment/v1 executable ABI rather than
|
||||
// the declarative AI provider contract covered by this catalog test.
|
||||
continue
|
||||
}
|
||||
if pkg.Manifest.APIVersion != "yingce.plugin/v2" {
|
||||
t.Fatalf("%s apiVersion = %q", filepath.Base(path), pkg.Manifest.APIVersion)
|
||||
}
|
||||
|
||||
@@ -104,13 +104,26 @@ func validatePluginPackagePath(name string) (string, error) {
|
||||
if name == "" || strings.ContainsRune(name, '\\') || strings.HasPrefix(name, "/") || path.IsAbs(name) || path.Clean(name) != name || strings.HasPrefix(name, "../") || name == ".." {
|
||||
return "", fmt.Errorf("invalid plugin package path %q", name)
|
||||
}
|
||||
if name == "manifest.json" || strings.HasPrefix(name, "web/") || strings.HasPrefix(name, "assets/") || strings.HasPrefix(name, "docs/") || name == "README.md" || name == "LICENSE" {
|
||||
if name == "manifest.json" || strings.HasPrefix(name, "web/") || strings.HasPrefix(name, "backend/") || strings.HasPrefix(name, "assets/") || strings.HasPrefix(name, "docs/") || name == "README.md" || name == "LICENSE" {
|
||||
return name, nil
|
||||
}
|
||||
return "", fmt.Errorf("plugin package path %q is outside the allowed package roots", name)
|
||||
}
|
||||
|
||||
func validatePluginPackageRuntime(manifest Manifest, files map[string][]byte) error {
|
||||
backend := strings.TrimSpace(manifest.Runtime.Backend)
|
||||
if backend == "rpc" || backend == "wasm" {
|
||||
entry := strings.TrimSpace(manifest.Runtime.BackendEntry)
|
||||
if !strings.HasPrefix(entry, "backend/") || path.Clean(entry) != entry {
|
||||
return fmt.Errorf("manifest.runtime.backendEntry must point inside backend/")
|
||||
}
|
||||
if _, ok := files[entry]; !ok {
|
||||
return fmt.Errorf("manifest runtime backend entry %q is missing from package", entry)
|
||||
}
|
||||
if backend == "wasm" && !strings.HasSuffix(strings.ToLower(entry), ".wasm") {
|
||||
return fmt.Errorf("wasm backend entry must have .wasm extension")
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(manifest.Entry) == "" {
|
||||
if strings.TrimSpace(manifest.Runtime.Web) != "" {
|
||||
return fmt.Errorf("manifest.runtime.web requires manifest.entry")
|
||||
|
||||
@@ -266,8 +266,9 @@ func (m *Manifest) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
|
||||
type ManifestRuntime struct {
|
||||
Backend string `json:"backend,omitempty"`
|
||||
Web string `json:"web,omitempty"`
|
||||
Backend string `json:"backend,omitempty"`
|
||||
BackendEntry string `json:"backendEntry,omitempty"`
|
||||
Web string `json:"web,omitempty"`
|
||||
}
|
||||
|
||||
type ManifestConfiguration struct {
|
||||
@@ -300,11 +301,14 @@ type ManifestContributions struct {
|
||||
}
|
||||
|
||||
type ManifestPaymentProvider struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Icon string `json:"icon"`
|
||||
CheckoutMode string `json:"checkoutMode"`
|
||||
ExpiryPolicy ManifestPaymentExpiryPolicy `json:"expiryPolicy"`
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Icon string `json:"icon"`
|
||||
CheckoutMode string `json:"checkoutMode"`
|
||||
ExpiryPolicy ManifestPaymentExpiryPolicy `json:"expiryPolicy"`
|
||||
IdentityFields []string `json:"identityFields,omitempty"`
|
||||
NotificationSuccess ManifestPaymentResponse `json:"notificationSuccess,omitempty"`
|
||||
NotificationFailure ManifestPaymentResponse `json:"notificationFailure,omitempty"`
|
||||
}
|
||||
|
||||
type ManifestPaymentExpiryPolicy struct {
|
||||
@@ -313,6 +317,12 @@ type ManifestPaymentExpiryPolicy struct {
|
||||
MaxMinutes int `json:"maxMinutes"`
|
||||
}
|
||||
|
||||
type ManifestPaymentResponse struct {
|
||||
Status int `json:"status,omitempty"`
|
||||
ContentType string `json:"contentType,omitempty"`
|
||||
Body string `json:"body,omitempty"`
|
||||
}
|
||||
|
||||
type ManifestProvider struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
|
||||
@@ -154,6 +154,25 @@ func (r *Repository) PaymentOrderCountForProvider(providerID string) (int64, err
|
||||
return count, err
|
||||
}
|
||||
|
||||
// ActivePaymentOrderCountForPlugin reports orders that still need the plugin
|
||||
// runtime for create retries, callbacks, queries or close operations.
|
||||
func (r *Repository) ActivePaymentOrderCountForPlugin(pluginID, pluginVersion string) (int64, error) {
|
||||
query := r.db.Model(&model.PaymentOrder{}).Where("plugin_id = ? AND status IN ?", strings.TrimSpace(pluginID), []model.PaymentOrderStatus{
|
||||
model.PaymentOrderCreated, model.PaymentOrderPending, model.PaymentOrderClosing, model.PaymentOrderCreateFailed,
|
||||
})
|
||||
if strings.TrimSpace(pluginVersion) != "" {
|
||||
// Rows created before plugin version pinning have an empty version. Keep
|
||||
// them protected during upgrades because they still depend on this
|
||||
// plugin's historical runtime.
|
||||
query = query.Where("(plugin_version = ? OR plugin_version = '' OR plugin_version IS NULL)", strings.TrimSpace(pluginVersion))
|
||||
}
|
||||
var count int64
|
||||
if err := query.Count(&count).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (r *Repository) SaveVerifiedPaymentNotification(notification *model.PaymentNotification) (bool, error) {
|
||||
created := r.db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "provider_id"}, {Name: "provider_event_id"}}, DoNothing: true,
|
||||
|
||||
@@ -59,6 +59,29 @@ func TestCompletePaymentOrderGrantsCreditsExactlyOnce(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivePaymentOrderCountForPluginScopesVersionAndState(t *testing.T) {
|
||||
db := openPaymentTestDB(t)
|
||||
repo := New(db)
|
||||
orders := []model.PaymentOrder{
|
||||
{ID: "plugin-v1-pending", UserID: "u1", IdempotencyKey: "i1", MerchantOrderNo: "m1", ProviderID: "p1", PluginID: "plugin", PluginVersion: "1.0.0", Status: model.PaymentOrderPending},
|
||||
{ID: "plugin-legacy-pending", UserID: "u5", IdempotencyKey: "i5", MerchantOrderNo: "m5", ProviderID: "p1", PluginID: "plugin", Status: model.PaymentOrderPending},
|
||||
{ID: "plugin-v1-credited", UserID: "u2", IdempotencyKey: "i2", MerchantOrderNo: "m2", ProviderID: "p1", PluginID: "plugin", PluginVersion: "1.0.0", Status: model.PaymentOrderCredited},
|
||||
{ID: "plugin-v2-pending", UserID: "u3", IdempotencyKey: "i3", MerchantOrderNo: "m3", ProviderID: "p1", PluginID: "plugin", PluginVersion: "2.0.0", Status: model.PaymentOrderPending},
|
||||
{ID: "other-pending", UserID: "u4", IdempotencyKey: "i4", MerchantOrderNo: "m4", ProviderID: "p2", PluginID: "other", PluginVersion: "1.0.0", Status: model.PaymentOrderPending},
|
||||
}
|
||||
if err := db.Create(&orders).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
count, err := repo.ActivePaymentOrderCountForPlugin("plugin", "1.0.0")
|
||||
if err != nil || count != 2 {
|
||||
t.Fatalf("version-scoped active count = %d, error = %v", count, err)
|
||||
}
|
||||
count, err = repo.ActivePaymentOrderCountForPlugin("plugin", "")
|
||||
if err != nil || count != 3 {
|
||||
t.Fatalf("all-version active count = %d, error = %v", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompletePaymentOrderRejectsAmountMismatchWithoutGrant(t *testing.T) {
|
||||
db := openPaymentTestDB(t)
|
||||
repo := New(db)
|
||||
|
||||
@@ -107,6 +107,44 @@ type AdminPaymentOrderPage struct {
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
func (s *Service) PaymentNotificationResponse(providerID string, success bool) (int, string, string) {
|
||||
return PaymentNotificationResponseForWithRegistry(s.paymentRegistry, providerID, success, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
func (s *Service) PaymentNotificationFailureResponse(providerID string, status int) (int, string, string) {
|
||||
return PaymentNotificationResponseForWithRegistry(s.paymentRegistry, providerID, false, status)
|
||||
}
|
||||
|
||||
func PaymentNotificationResponseFor(providerID string, success bool, failureStatus int) (int, string, string) {
|
||||
registry, _ := payment.NewRegistry()
|
||||
return PaymentNotificationResponseForWithRegistry(registry, providerID, success, failureStatus)
|
||||
}
|
||||
|
||||
func PaymentNotificationResponseForWithRegistry(registry *payment.Registry, providerID string, success bool, failureStatus int) (int, string, string) {
|
||||
if registry != nil {
|
||||
if provider, ok := registry.Get(providerID); ok {
|
||||
descriptor := provider.Descriptor()
|
||||
response := descriptor.NotificationFailure
|
||||
if success {
|
||||
response = descriptor.NotificationSuccess
|
||||
}
|
||||
status := response.Status
|
||||
if status == 0 {
|
||||
status = failureStatus
|
||||
}
|
||||
contentType, body := response.ContentType, response.Body
|
||||
if contentType == "" {
|
||||
contentType = "text/plain; charset=utf-8"
|
||||
}
|
||||
return status, contentType, body
|
||||
}
|
||||
}
|
||||
if success {
|
||||
return http.StatusNoContent, "", ""
|
||||
}
|
||||
return failureStatus, "", ""
|
||||
}
|
||||
|
||||
func (s *Service) PaymentProviders(actor *model.User) ([]PaymentProviderView, error) {
|
||||
if actor == nil {
|
||||
return nil, Unauthorized("请先登录")
|
||||
@@ -186,7 +224,7 @@ func (s *Service) UpdatePaymentProviderConfig(actor *model.User, providerID stri
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, field := range paymentProviderIdentityFields(providerID) {
|
||||
for _, field := range descriptor.IdentityFields {
|
||||
previousIdentity[field] = strings.TrimSpace(values[field])
|
||||
}
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
@@ -242,7 +280,7 @@ func (s *Service) UpdatePaymentProviderConfig(actor *model.User, providerID stri
|
||||
}
|
||||
digest := sha256.Sum256(plain)
|
||||
config := &model.PaymentProviderConfig{
|
||||
ID: newID(), ProviderID: descriptor.ID, PluginID: descriptor.PluginID,
|
||||
ID: newID(), ProviderID: descriptor.ID, PluginID: descriptor.PluginID, PluginVersion: descriptor.PluginVersion,
|
||||
Enabled: request.Enabled, CloseAfterMinutes: request.CloseAfterMinutes,
|
||||
ConfigCipher: ciphertext, ConfigDigest: hex.EncodeToString(digest[:]), CreatedBy: actor.ID,
|
||||
}
|
||||
@@ -266,17 +304,6 @@ func (s *Service) UpdatePaymentProviderConfig(actor *model.User, providerID stri
|
||||
return nil, errors.New("保存支付渠道配置后未找到渠道")
|
||||
}
|
||||
|
||||
func paymentProviderIdentityFields(providerID string) []string {
|
||||
switch strings.TrimSpace(providerID) {
|
||||
case PaymentProviderWeChat:
|
||||
return []string{"appId", "mchId"}
|
||||
case PaymentProviderAlipay:
|
||||
return []string{"appId", "sellerId"}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) paymentProviderView(descriptor payment.Descriptor) (PaymentProviderView, *model.PaymentProviderConfig, error) {
|
||||
view := PaymentProviderView{ID: descriptor.ID, PluginID: descriptor.PluginID, Name: descriptor.Name, Icon: descriptor.Icon, CheckoutMode: descriptor.CheckoutMode}
|
||||
if manifest, ok := paymentManifestForProvider(descriptor.ID); ok && len(manifest.Contributes.PaymentProviders) > 0 {
|
||||
@@ -457,7 +484,7 @@ func (s *Service) CreatePaymentOrder(ctx context.Context, actor *model.User, req
|
||||
order := &model.PaymentOrder{
|
||||
ID: newID(), UserID: actor.ID, IdempotencyKey: idempotencyKey, MerchantOrderNo: newID(),
|
||||
ProductID: product.ID, ProductName: product.Name, ProviderID: provider.Descriptor().ID,
|
||||
PluginID: provider.Descriptor().PluginID, ProviderConfigID: config.ID, ProviderConfigVersion: config.Version,
|
||||
PluginID: provider.Descriptor().PluginID, PluginVersion: provider.Descriptor().PluginVersion, ProviderConfigID: config.ID, ProviderConfigVersion: config.Version,
|
||||
AmountFen: product.AmountFen, Currency: "CNY", CreditsMicrocredits: product.CreditsMicrocredits,
|
||||
Status: model.PaymentOrderCreated, CheckoutMode: provider.Descriptor().CheckoutMode,
|
||||
ExpiresAt: now.Add(time.Duration(config.CloseAfterMinutes) * time.Minute),
|
||||
@@ -480,7 +507,7 @@ func (s *Service) CreatePaymentOrder(ctx context.Context, actor *model.User, req
|
||||
MerchantOrderNo: order.MerchantOrderNo, Description: product.Name, AmountFen: order.AmountFen,
|
||||
Currency: order.Currency, ExpiresAt: order.ExpiresAt,
|
||||
NotifyURL: baseURL + "/api/payments/notify/" + url.PathEscape(order.ProviderID) + "/" + url.PathEscape(config.ID),
|
||||
ReturnURL: baseURL + "/api/payments/return/alipay?orderId=" + url.QueryEscape(order.ID),
|
||||
ReturnURL: baseURL + "/api/payments/return/" + url.PathEscape(order.ProviderID) + "?orderId=" + url.QueryEscape(order.ID),
|
||||
})
|
||||
if err != nil {
|
||||
_ = s.repo.SetPaymentOrderCreateFailure(order.ID, safePaymentError(err))
|
||||
@@ -564,7 +591,7 @@ func (s *Service) RefreshPaymentCheckout(ctx context.Context, actor *model.User,
|
||||
MerchantOrderNo: order.MerchantOrderNo, Description: order.ProductName, AmountFen: order.AmountFen,
|
||||
Currency: order.Currency, ExpiresAt: order.ExpiresAt,
|
||||
NotifyURL: baseURL + "/api/payments/notify/" + url.PathEscape(order.ProviderID) + "/" + url.PathEscape(config.ID),
|
||||
ReturnURL: baseURL + "/api/payments/return/alipay?orderId=" + url.QueryEscape(order.ID),
|
||||
ReturnURL: baseURL + "/api/payments/return/" + url.PathEscape(order.ProviderID) + "?orderId=" + url.QueryEscape(order.ID),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, WrapAppError(http.StatusBadGateway, "刷新支付收银台失败,请稍后重试", err)
|
||||
@@ -810,7 +837,7 @@ func (s *Service) paymentRuntimeForOrder(order *model.PaymentOrder) (payment.Pro
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
if config.ProviderID != order.ProviderID || config.PluginID != order.PluginID || config.Version != order.ProviderConfigVersion {
|
||||
if config.ProviderID != order.ProviderID || config.PluginID != order.PluginID || (config.PluginVersion != "" && order.PluginVersion != "" && config.PluginVersion != order.PluginVersion) || config.Version != order.ProviderConfigVersion {
|
||||
return nil, nil, nil, repository.ErrPaymentOrderStateConflict
|
||||
}
|
||||
values, err := s.decryptPaymentConfig(config)
|
||||
|
||||
56
backend/internal/service/payment_plugin_lifecycle_test.go
Normal file
56
backend/internal/service/payment_plugin_lifecycle_test.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/protocol"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func newPaymentLifecycleService(t *testing.T, status model.PaymentOrderStatus) (*Service, protocol.Manifest) {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file:"+newID()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.PaymentOrder{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manifest := bundledPaymentPluginManifests()[0]
|
||||
manifest.Metadata.ID = "uploaded-payment"
|
||||
manifest.Metadata.Version = "1.0.0"
|
||||
manifest.Metadata.Enabled = true
|
||||
raw, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
center := &pluginRuntime{plugins: map[string]pluginRecord{
|
||||
manifest.Metadata.ID: {Raw: raw, Metadata: manifest.Metadata, Source: PluginOriginUploaded},
|
||||
}}
|
||||
order := model.PaymentOrder{ID: "active-order", UserID: "user", IdempotencyKey: "key", MerchantOrderNo: "merchant", ProviderID: manifest.Contributes.PaymentProviders[0].ID, PluginID: manifest.Metadata.ID, PluginVersion: manifest.Metadata.Version, Status: status}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &Service{repo: repository.New(db), pluginRuntime: center}, manifest
|
||||
}
|
||||
|
||||
func TestPaymentPluginUpgradeBlockedByActiveOrders(t *testing.T) {
|
||||
svc, next := newPaymentLifecycleService(t, model.PaymentOrderPending)
|
||||
next.Metadata.Version = "2.0.0"
|
||||
if err := svc.ensurePaymentPluginLifecycle(next); err == nil || !strings.Contains(err.Error(), "未完成订单") {
|
||||
t.Fatalf("upgrade guard error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentPluginRemovalAllowedAfterOrdersFinish(t *testing.T) {
|
||||
svc, manifest := newPaymentLifecycleService(t, model.PaymentOrderCredited)
|
||||
if err := svc.ensurePaymentPluginCanBeRemoved(manifest.Metadata.ID); err != nil {
|
||||
t.Fatalf("removal guard rejected completed order: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,14 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"infinite-canvas/backend/internal/payment"
|
||||
"infinite-canvas/backend/internal/protocol"
|
||||
)
|
||||
|
||||
const (
|
||||
PaymentPluginWeChatNative = payment.PluginWeChatNative
|
||||
PaymentPluginAlipayPage = payment.PluginAlipayPage
|
||||
PaymentProviderWeChat = payment.ProviderWeChatNative
|
||||
PaymentProviderAlipay = payment.ProviderAlipayPage
|
||||
PaymentPluginWeChatNative = "official-payment-wechat-native"
|
||||
PaymentPluginAlipayPage = "official-payment-alipay-page"
|
||||
PaymentProviderWeChat = "wechat-native"
|
||||
PaymentProviderAlipay = "alipay-page-pay"
|
||||
)
|
||||
|
||||
func bundledPaymentPluginManifests() []protocol.Manifest {
|
||||
@@ -53,11 +52,38 @@ func paymentPluginManifest(pluginID, providerID, name, vendor, description, runt
|
||||
Configuration: configuration,
|
||||
Contributes: protocol.ManifestContributions{PaymentProviders: []protocol.ManifestPaymentProvider{{
|
||||
ID: providerID, Label: name, Icon: icon, CheckoutMode: checkoutMode,
|
||||
IdentityFields: paymentIdentityFields(providerID),
|
||||
NotificationSuccess: paymentNotificationSuccess(providerID), NotificationFailure: paymentNotificationFailure(providerID),
|
||||
ExpiryPolicy: protocol.ManifestPaymentExpiryPolicy{DefaultMinutes: 30, MinMinutes: 5, MaxMinutes: 1440},
|
||||
}}},
|
||||
}
|
||||
}
|
||||
|
||||
func paymentIdentityFields(providerID string) []string {
|
||||
switch providerID {
|
||||
case PaymentProviderWeChat:
|
||||
return []string{"appId", "mchId"}
|
||||
case PaymentProviderAlipay:
|
||||
return []string{"appId", "sellerId"}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func paymentNotificationSuccess(providerID string) protocol.ManifestPaymentResponse {
|
||||
if providerID == PaymentProviderAlipay {
|
||||
return protocol.ManifestPaymentResponse{Status: 200, ContentType: "text/plain; charset=utf-8", Body: "success"}
|
||||
}
|
||||
return protocol.ManifestPaymentResponse{Status: 204}
|
||||
}
|
||||
|
||||
func paymentNotificationFailure(providerID string) protocol.ManifestPaymentResponse {
|
||||
if providerID == PaymentProviderAlipay {
|
||||
return protocol.ManifestPaymentResponse{Status: 400, ContentType: "text/plain; charset=utf-8", Body: "failure"}
|
||||
}
|
||||
return protocol.ManifestPaymentResponse{Status: 400}
|
||||
}
|
||||
|
||||
func wechatPaymentConfiguration() protocol.ManifestConfiguration {
|
||||
return protocol.ManifestConfiguration{Fields: []protocol.ManifestField{
|
||||
{Name: "publicBaseUrl", Type: "url", Label: "服务器公网地址", Required: true, Description: "用于生成微信支付回调地址,必须可被微信访问。"},
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
)
|
||||
|
||||
func TestBundledPaymentPluginsMatchHostProviders(t *testing.T) {
|
||||
registry := payment.Builtins()
|
||||
manifests := bundledPaymentPluginManifests()
|
||||
if len(manifests) != 2 {
|
||||
t.Fatalf("payment plugin manifests = %d", len(manifests))
|
||||
@@ -25,13 +24,8 @@ func TestBundledPaymentPluginsMatchHostProviders(t *testing.T) {
|
||||
t.Fatalf("plugin %s payment contributions = %d", manifest.Metadata.ID, len(manifest.Contributes.PaymentProviders))
|
||||
}
|
||||
contribution := manifest.Contributes.PaymentProviders[0]
|
||||
provider, ok := registry.Get(contribution.ID)
|
||||
if !ok {
|
||||
t.Fatalf("plugin %s has no host provider %s", manifest.Metadata.ID, contribution.ID)
|
||||
}
|
||||
descriptor := provider.Descriptor()
|
||||
if descriptor.PluginID != manifest.Metadata.ID || descriptor.Icon != contribution.Icon || descriptor.CheckoutMode != contribution.CheckoutMode {
|
||||
t.Fatalf("plugin/provider mismatch: manifest=%#v descriptor=%#v", contribution, descriptor)
|
||||
if contribution.ID == "" || contribution.Icon == "" || contribution.CheckoutMode == "" {
|
||||
t.Fatalf("plugin %s has incomplete payment contribution: %#v", manifest.Metadata.ID, contribution)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,6 +50,51 @@ func TestBundledPaymentPluginsLoadFromSystemSource(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemPaymentPluginsRegisterRPCProviders(t *testing.T) {
|
||||
center, err := newPluginRuntime(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := center.paymentRegistrySnapshot()
|
||||
if registry == nil {
|
||||
t.Fatal("payment registry is nil")
|
||||
}
|
||||
for _, providerID := range []string{PaymentProviderAlipay, PaymentProviderWeChat} {
|
||||
provider, ok := registry.Get(providerID)
|
||||
if !ok {
|
||||
t.Fatalf("payment provider %q is missing", providerID)
|
||||
}
|
||||
if _, ok := provider.(*payment.RPCProvider); !ok {
|
||||
t.Fatalf("payment provider %q has type %T, want *payment.RPCProvider", providerID, provider)
|
||||
}
|
||||
if provider.Descriptor().PluginID == "" || provider.Descriptor().PluginVersion == "" {
|
||||
t.Fatalf("payment provider %q is missing plugin identity: %#v", providerID, provider.Descriptor())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentRegistryFailsClosedWhenOfficialPackagesAreMissing(t *testing.T) {
|
||||
t.Setenv("CANVAS_OFFICIAL_PLUGIN_DIR", t.TempDir())
|
||||
center, err := newPluginRuntime(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := center.paymentRegistrySnapshot()
|
||||
if registry == nil {
|
||||
t.Fatal("payment registry is nil")
|
||||
}
|
||||
for _, providerID := range []string{PaymentProviderAlipay, PaymentProviderWeChat} {
|
||||
if _, ok := registry.Get(providerID); ok {
|
||||
t.Fatalf("payment provider %q unexpectedly came from host fallback", providerID)
|
||||
}
|
||||
}
|
||||
for _, plugin := range center.list() {
|
||||
if plugin.Management.Kind == PluginKindPayment && plugin.Status != "invalid" {
|
||||
t.Fatalf("missing package payment plugin %q status = %q, want invalid", plugin.Manifest.ID, plugin.Status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTopupProductCreditAmountStaysWithinSafeLimit(t *testing.T) {
|
||||
if _, err := topupProductFromRequest("product", "admin", TopupProductRequest{
|
||||
Name: "过大积分商品", AmountFen: 1, CreditsMicrocredits: maxTopupCreditsMicrocredits + 1,
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/payment"
|
||||
"infinite-canvas/backend/internal/protocol"
|
||||
)
|
||||
|
||||
@@ -66,12 +67,13 @@ type pluginRecord struct {
|
||||
}
|
||||
|
||||
type pluginRuntime struct {
|
||||
mu sync.RWMutex
|
||||
mutationMu sync.Mutex
|
||||
registryPath string
|
||||
packageDir string
|
||||
plugins map[string]pluginRecord
|
||||
registry *protocol.Registry
|
||||
mu sync.RWMutex
|
||||
mutationMu sync.Mutex
|
||||
registryPath string
|
||||
packageDir string
|
||||
plugins map[string]pluginRecord
|
||||
registry *protocol.Registry
|
||||
paymentRegistry *payment.Registry
|
||||
}
|
||||
|
||||
type pluginRegistryRecord struct {
|
||||
@@ -136,8 +138,10 @@ func (c *pluginRuntime) bootstrapBuiltInPlugins() error {
|
||||
if strings.HasPrefix(strings.TrimSpace(pkg.Manifest.Runtime.Backend), "host:") {
|
||||
return fmt.Errorf("官方插件 %q 不能依赖 host 执行器", pkg.Manifest.Metadata.ID)
|
||||
}
|
||||
if _, err := protocol.LoadInstalledProviders(pkg.ManifestRaw, nil); err != nil {
|
||||
return fmt.Errorf("加载官方插件 %q:%w", pkg.Manifest.Metadata.ID, err)
|
||||
if len(pkg.Manifest.Contributes.PaymentProviders) == 0 {
|
||||
if _, err := protocol.LoadInstalledProviders(pkg.ManifestRaw, nil); err != nil {
|
||||
return fmt.Errorf("加载官方插件 %q:%w", pkg.Manifest.Metadata.ID, err)
|
||||
}
|
||||
}
|
||||
id := pkg.Manifest.Metadata.ID
|
||||
if _, duplicate := builtInIDs[id]; duplicate {
|
||||
@@ -165,13 +169,22 @@ func (c *pluginRuntime) bootstrapBuiltInPlugins() error {
|
||||
if record.InstalledAt.IsZero() {
|
||||
record.InstalledAt = now
|
||||
}
|
||||
record.ID, record.Raw, record.Source, record.FileName = id, manifestData, PluginOriginOfficial, entry.Name()
|
||||
source := PluginOriginOfficial
|
||||
if _, systemPayment := systemPaymentPolicies[id]; systemPayment {
|
||||
source = PluginOriginSystem
|
||||
}
|
||||
record.ID, record.Raw, record.Source, record.FileName = id, manifestData, source, entry.Name()
|
||||
record.PackagePath, record.PackageSHA256, record.UpdatedAt = packageName, hash, now
|
||||
byID[id] = record
|
||||
}
|
||||
bundledManifests := append(bundledWorkflowPluginManifests(), bundledPaymentPluginManifests()...)
|
||||
for _, bundled := range bundledManifests {
|
||||
builtInIDs[bundled.Metadata.ID] = struct{}{}
|
||||
if existing, exists := byID[bundled.Metadata.ID]; exists && existing.PackagePath != "" && isSystemPaymentPluginID(bundled.Metadata.ID) {
|
||||
// An official package is the executable source of truth. The bundled
|
||||
// manifest remains only as a fallback when the package is unavailable.
|
||||
continue
|
||||
}
|
||||
data, err := json.Marshal(bundled)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode built-in plugin %s: %w", bundled.Metadata.ID, err)
|
||||
@@ -215,6 +228,11 @@ func (c *pluginRuntime) bootstrapBuiltInPlugins() error {
|
||||
return c.writeRegistry(result)
|
||||
}
|
||||
|
||||
func isSystemPaymentPluginID(id string) bool {
|
||||
_, ok := systemPaymentPolicies[strings.TrimSpace(id)]
|
||||
return ok
|
||||
}
|
||||
|
||||
func isBuiltInPluginSource(source string) bool {
|
||||
switch strings.TrimSpace(source) {
|
||||
case "bundled", PluginOriginOfficial, PluginOriginSystem:
|
||||
@@ -295,15 +313,22 @@ func (c *pluginRuntime) reload() error {
|
||||
plugins[id] = record
|
||||
continue
|
||||
}
|
||||
if len(manifest.Contributes.PaymentProviders) > 0 {
|
||||
_, systemPayment := systemPaymentPolicies[id]
|
||||
if record.Source != PluginOriginSystem || !systemPayment {
|
||||
if len(manifest.Contributes.PaymentProviders) > 0 && record.Source == PluginOriginUploaded {
|
||||
backend := strings.TrimSpace(manifest.Runtime.Backend)
|
||||
if strings.HasPrefix(backend, "host:") {
|
||||
record.Metadata.Enabled = false
|
||||
record.Status = "invalid"
|
||||
record.Error = "首期支付插件只能使用系统宿主适配器"
|
||||
plugins[id] = record
|
||||
continue
|
||||
}
|
||||
if backend != "rpc" && backend != "wasm" {
|
||||
record.Metadata.Enabled = false
|
||||
record.Status = "invalid"
|
||||
record.Error = "上传支付插件必须声明 rpc 或 wasm 后端"
|
||||
plugins[id] = record
|
||||
continue
|
||||
}
|
||||
}
|
||||
if len(manifest.Contributes.PaymentProviders) > 0 && len(manifest.Contributes.Providers) == 0 {
|
||||
if record.Metadata.Enabled {
|
||||
@@ -349,9 +374,98 @@ func (c *pluginRuntime) reload() error {
|
||||
}
|
||||
c.plugins = plugins
|
||||
c.registry = registry
|
||||
// Payment adapters are loaded exclusively from validated plugin packages.
|
||||
// An empty registry is intentional when the official package is unavailable;
|
||||
// payment writes must fail closed instead of silently using host code.
|
||||
basePayment, registryErr := payment.NewRegistry()
|
||||
if registryErr != nil {
|
||||
return registryErr
|
||||
}
|
||||
dynamicPayments := make([]payment.Provider, 0)
|
||||
for id, record := range plugins {
|
||||
if (record.Source != PluginOriginUploaded && record.Source != PluginOriginOfficial && record.Source != PluginOriginSystem) || !record.Metadata.Enabled || record.Status != "enabled" {
|
||||
continue
|
||||
}
|
||||
var manifest protocol.Manifest
|
||||
if err := json.Unmarshal(record.Raw, &manifest); err != nil || len(manifest.Contributes.PaymentProviders) == 0 {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(manifest.Runtime.Backend) != "rpc" {
|
||||
record.Status = "invalid"
|
||||
record.Error = "wasm 支付运行时尚未启用"
|
||||
plugins[id] = record
|
||||
continue
|
||||
}
|
||||
packageData, readErr := os.ReadFile(filepath.Join(c.packageDir, filepath.Base(record.PackagePath)))
|
||||
if readErr != nil {
|
||||
record.Status = "invalid"
|
||||
record.Error = "支付插件包文件不存在"
|
||||
plugins[id] = record
|
||||
continue
|
||||
}
|
||||
pkg, parseErr := protocol.ParsePluginPackage(packageData)
|
||||
if parseErr != nil {
|
||||
record.Status = "invalid"
|
||||
record.Error = parseErr.Error()
|
||||
plugins[id] = record
|
||||
continue
|
||||
}
|
||||
runtimeDir, materializeErr := materializePaymentBackend(c.packageDir, record.PackageSHA256, pkg)
|
||||
if materializeErr != nil {
|
||||
record.Status = "invalid"
|
||||
record.Error = materializeErr.Error()
|
||||
plugins[id] = record
|
||||
continue
|
||||
}
|
||||
for _, contribution := range manifest.Contributes.PaymentProviders {
|
||||
provider, providerErr := payment.NewRPCProvider(payment.DescriptorFromManifest(manifest, contribution), runtimeDir, manifest.Runtime.BackendEntry)
|
||||
if providerErr != nil {
|
||||
record.Status = "invalid"
|
||||
record.Error = providerErr.Error()
|
||||
continue
|
||||
}
|
||||
dynamicPayments = append(dynamicPayments, provider)
|
||||
}
|
||||
}
|
||||
dynamicIDs := make(map[string]struct{}, len(dynamicPayments))
|
||||
for _, provider := range dynamicPayments {
|
||||
dynamicIDs[provider.Descriptor().ID] = struct{}{}
|
||||
}
|
||||
baseProviders := make([]payment.Provider, 0)
|
||||
for _, provider := range basePayment.Providers() {
|
||||
if _, replaced := dynamicIDs[provider.Descriptor().ID]; !replaced {
|
||||
baseProviders = append(baseProviders, provider)
|
||||
}
|
||||
}
|
||||
providers := append(baseProviders, dynamicPayments...)
|
||||
c.paymentRegistry, err = payment.NewRegistry(providers...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.plugins = plugins
|
||||
return nil
|
||||
}
|
||||
|
||||
func materializePaymentBackend(packageDir, hash string, pkg protocol.PluginPackage) (string, error) {
|
||||
root := filepath.Join(packageDir, "runtime", strings.TrimSpace(hash))
|
||||
if err := os.MkdirAll(root, 0o700); err != nil {
|
||||
return "", fmt.Errorf("创建支付插件运行目录失败:%w", err)
|
||||
}
|
||||
for name, content := range pkg.Files {
|
||||
if !strings.HasPrefix(name, "backend/") || strings.HasSuffix(name, "/") {
|
||||
continue
|
||||
}
|
||||
target := filepath.Join(root, filepath.FromSlash(name))
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.WriteFile(target, content, 0o700); err != nil {
|
||||
return "", fmt.Errorf("写入支付插件运行文件失败:%w", err)
|
||||
}
|
||||
}
|
||||
return root, nil
|
||||
}
|
||||
|
||||
func (c *pluginRuntime) list() []PluginView {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
@@ -369,6 +483,12 @@ func (c *pluginRuntime) registrySnapshot() *protocol.Registry {
|
||||
return c.registry
|
||||
}
|
||||
|
||||
func (c *pluginRuntime) paymentRegistrySnapshot() *payment.Registry {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.paymentRegistry
|
||||
}
|
||||
|
||||
func (c *pluginRuntime) install(data []byte, fileName string) (PluginView, error) {
|
||||
c.mutationMu.Lock()
|
||||
defer c.mutationMu.Unlock()
|
||||
@@ -380,19 +500,18 @@ func (c *pluginRuntime) install(data []byte, fileName string) (PluginView, error
|
||||
return PluginView{}, err
|
||||
}
|
||||
manifest := pkg.Manifest
|
||||
if len(manifest.Contributes.PaymentProviders) > 0 {
|
||||
return PluginView{}, errors.New("首期支付插件只能使用系统宿主适配器")
|
||||
}
|
||||
if strings.HasPrefix(strings.TrimSpace(manifest.Runtime.Backend), "host:") {
|
||||
return PluginView{}, errors.New("上传插件不能使用宿主内置执行器")
|
||||
}
|
||||
if _, err := protocol.LoadInstalledProviders(pkg.ManifestRaw, nil); err != nil {
|
||||
return PluginView{}, err
|
||||
if len(manifest.Contributes.PaymentProviders) == 0 {
|
||||
if _, err := protocol.LoadInstalledProviders(pkg.ManifestRaw, nil); err != nil {
|
||||
return PluginView{}, err
|
||||
}
|
||||
}
|
||||
c.mu.RLock()
|
||||
existing, exists := c.plugins[manifest.Metadata.ID]
|
||||
c.mu.RUnlock()
|
||||
if exists && isBuiltInPluginSource(existing.Source) {
|
||||
if exists && isBuiltInPluginSource(existing.Source) && !isPaymentPluginManifest(manifest) {
|
||||
return PluginView{}, fmt.Errorf("内置插件 %q 不能通过上传覆盖", manifest.Metadata.ID)
|
||||
}
|
||||
manifest.Metadata.Enabled = !exists || existing.Metadata.Enabled
|
||||
@@ -449,6 +568,11 @@ func (c *pluginRuntime) install(data []byte, fileName string) (PluginView, error
|
||||
return PluginView{}, errors.New("插件保存后未加载")
|
||||
}
|
||||
|
||||
func isPaymentPluginManifest(manifest protocol.Manifest) bool {
|
||||
backend := strings.TrimSpace(manifest.Runtime.Backend)
|
||||
return len(manifest.Contributes.PaymentProviders) > 0 && (backend == "rpc" || backend == "wasm")
|
||||
}
|
||||
|
||||
func (c *pluginRuntime) setEnabled(id string, enabled bool) (PluginView, error) {
|
||||
c.mutationMu.Lock()
|
||||
defer c.mutationMu.Unlock()
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -251,7 +252,18 @@ func (s *Service) InstallPlugin(data []byte, fileName string) (PluginView, error
|
||||
if s.pluginRuntime == nil {
|
||||
return PluginView{}, fmt.Errorf("插件运行时未初始化")
|
||||
}
|
||||
return s.pluginRuntime.install(data, fileName)
|
||||
parsed, err := protocol.ParsePluginPackage(data)
|
||||
if err != nil {
|
||||
return PluginView{}, err
|
||||
}
|
||||
if err := s.ensurePaymentPluginLifecycle(parsed.Manifest); err != nil {
|
||||
return PluginView{}, err
|
||||
}
|
||||
plugin, err := s.pluginRuntime.install(data, fileName)
|
||||
if err == nil {
|
||||
s.refreshPaymentRegistry()
|
||||
}
|
||||
return plugin, err
|
||||
}
|
||||
|
||||
func (s *Service) InstallPluginForAdmin(actor *model.User, data []byte, fileName string) (PluginView, error) {
|
||||
@@ -265,7 +277,7 @@ func (s *Service) InstallPluginForAdmin(actor *model.User, data []byte, fileName
|
||||
if _, reserved := officialApplicationPolicies[parsed.Manifest.Metadata.ID]; reserved {
|
||||
return PluginView{}, fmt.Errorf("插件 ID %q 由官方应用保留", parsed.Manifest.Metadata.ID)
|
||||
}
|
||||
if _, reserved := systemPaymentPolicies[parsed.Manifest.Metadata.ID]; reserved {
|
||||
if _, reserved := systemPaymentPolicies[parsed.Manifest.Metadata.ID]; reserved && !isPaymentPluginManifest(parsed.Manifest) {
|
||||
return PluginView{}, fmt.Errorf("插件 ID %q 由系统支付插件保留", parsed.Manifest.Metadata.ID)
|
||||
}
|
||||
plugin, err := s.InstallPlugin(data, fileName)
|
||||
@@ -308,14 +320,90 @@ func (s *Service) SetPluginEnabled(id string, enabled bool) (PluginView, error)
|
||||
if s.pluginRuntime == nil {
|
||||
return PluginView{}, fmt.Errorf("插件运行时未初始化")
|
||||
}
|
||||
return s.pluginRuntime.setEnabled(id, enabled)
|
||||
plugin, err := s.pluginRuntime.setEnabled(id, enabled)
|
||||
if err == nil {
|
||||
s.refreshPaymentRegistry()
|
||||
}
|
||||
return plugin, err
|
||||
}
|
||||
|
||||
func (s *Service) UninstallPlugin(id string) error {
|
||||
if s.pluginRuntime == nil {
|
||||
return fmt.Errorf("插件运行时未初始化")
|
||||
}
|
||||
return s.pluginRuntime.uninstall(id)
|
||||
if err := s.ensurePaymentPluginCanBeRemoved(id); err != nil {
|
||||
return err
|
||||
}
|
||||
err := s.pluginRuntime.uninstall(id)
|
||||
if err == nil {
|
||||
s.refreshPaymentRegistry()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) ensurePaymentPluginLifecycle(next protocol.Manifest) error {
|
||||
if s.repo == nil || s.pluginRuntime == nil || len(next.Contributes.PaymentProviders) == 0 {
|
||||
return nil
|
||||
}
|
||||
s.pluginRuntime.mu.RLock()
|
||||
current, exists := s.pluginRuntime.plugins[next.Metadata.ID]
|
||||
s.pluginRuntime.mu.RUnlock()
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
var currentManifest protocol.Manifest
|
||||
if err := json.Unmarshal(current.Raw, ¤tManifest); err != nil {
|
||||
return fmt.Errorf("读取现有插件 %q:%w", next.Metadata.ID, err)
|
||||
}
|
||||
if len(currentManifest.Contributes.PaymentProviders) == 0 || strings.TrimSpace(currentManifest.Metadata.Version) == strings.TrimSpace(next.Metadata.Version) {
|
||||
return nil
|
||||
}
|
||||
count, err := s.repo.ActivePaymentOrderCountForPlugin(currentManifest.Metadata.ID, currentManifest.Metadata.Version)
|
||||
if err != nil {
|
||||
return fmt.Errorf("检查插件 %q 未完成订单:%w", current.Metadata.ID, err)
|
||||
}
|
||||
if count > 0 {
|
||||
return fmt.Errorf("支付插件 %q 仍有 %d 个未完成订单,暂不能升级", current.Metadata.ID, count)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) ensurePaymentPluginCanBeRemoved(id string) error {
|
||||
if s.repo == nil || s.pluginRuntime == nil {
|
||||
return nil
|
||||
}
|
||||
s.pluginRuntime.mu.RLock()
|
||||
record, exists := s.pluginRuntime.plugins[strings.TrimSpace(id)]
|
||||
s.pluginRuntime.mu.RUnlock()
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
var manifest protocol.Manifest
|
||||
if err := json.Unmarshal(record.Raw, &manifest); err != nil {
|
||||
return fmt.Errorf("读取插件 %q:%w", id, err)
|
||||
}
|
||||
if len(manifest.Contributes.PaymentProviders) == 0 {
|
||||
return nil
|
||||
}
|
||||
count, err := s.repo.ActivePaymentOrderCountForPlugin(manifest.Metadata.ID, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("检查插件 %q 未完成订单:%w", id, err)
|
||||
}
|
||||
if count > 0 {
|
||||
return fmt.Errorf("支付插件 %q 仍有 %d 个未完成订单,暂不能卸载", id, count)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) refreshPaymentRegistry() {
|
||||
if s.pluginRuntime == nil {
|
||||
return
|
||||
}
|
||||
if registry := s.pluginRuntime.paymentRegistrySnapshot(); registry != nil {
|
||||
s.registrationMu.Lock()
|
||||
s.paymentRegistry = registry
|
||||
s.registrationMu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) UninstallPluginForAdmin(actor *model.User, id string) error {
|
||||
|
||||
@@ -29,7 +29,24 @@ func TestPluginViewIncludesDocumentationForEveryOfficialProtocol(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bundledCount := len(bundledWorkflowPluginManifests()) + len(bundledPaymentPluginManifests())
|
||||
bundledCount := len(bundledWorkflowPluginManifests())
|
||||
packageIDs := make(map[string]bool, len(packages))
|
||||
for _, packagePath := range packages {
|
||||
data, err := os.ReadFile(packagePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pkg, err := protocol.ParsePluginPackage(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
packageIDs[pkg.Manifest.Metadata.ID] = true
|
||||
}
|
||||
for _, manifest := range bundledPaymentPluginManifests() {
|
||||
if !packageIDs[manifest.Metadata.ID] {
|
||||
bundledCount++
|
||||
}
|
||||
}
|
||||
if len(plugins) != len(packages)+bundledCount {
|
||||
t.Fatalf("plugin views = %d, official packages plus bundled plugins = %d", len(plugins), len(packages)+bundledCount)
|
||||
}
|
||||
@@ -47,8 +64,12 @@ func TestPluginViewIncludesDocumentationForEveryOfficialProtocol(t *testing.T) {
|
||||
t.Errorf("fresh runtime did not install official plugin %q", pkg.Manifest.Metadata.ID)
|
||||
continue
|
||||
}
|
||||
if plugin.Source != PluginOriginOfficial {
|
||||
t.Errorf("plugin %q source = %q, want official", plugin.Manifest.ID, plugin.Source)
|
||||
expectedSource := PluginOriginOfficial
|
||||
if isSystemPaymentPluginID(plugin.Manifest.ID) {
|
||||
expectedSource = PluginOriginSystem
|
||||
}
|
||||
if plugin.Source != expectedSource {
|
||||
t.Errorf("plugin %q source = %q, want %s", plugin.Manifest.ID, plugin.Source, expectedSource)
|
||||
}
|
||||
expected := strings.TrimSpace(string(pkg.Files["README.md"])) + "\n\n---\n\n" + strings.TrimSpace(string(pkg.Files["docs/interface.md"]))
|
||||
if strings.TrimSpace(plugin.Manifest.Documentation) != expected {
|
||||
|
||||
@@ -110,7 +110,13 @@ func New(repo *repository.Repository, dataDir string) *Service {
|
||||
func NewWithRuntimeCapabilities(repo *repository.Repository, dataDir string, capabilities RuntimeCapabilities) *Service {
|
||||
coordinator, err := newRuntimeCoordinator(repo.Dialect())
|
||||
pluginRuntime, pluginRuntimeErr := newPluginRuntime(dataDir)
|
||||
service := &Service{repo: repo, dataDir: dataDir, runtimeCapabilities: capabilities, activeStorageTests: make(map[string]bool), activeCancels: make(map[string]context.CancelFunc), coordinator: coordinator, runtimeErr: err, pluginRuntime: pluginRuntime, pluginRuntimeErr: pluginRuntimeErr, paymentRegistry: payment.Builtins(), workerID: newID(), routeCatalogTTL: 30 * time.Second, routeCatalogMaxStale: 5 * time.Minute, routeHealthBlocked: make(map[string]time.Time)}
|
||||
paymentRegistry, _ := payment.NewRegistry()
|
||||
if pluginRuntime != nil {
|
||||
if dynamic := pluginRuntime.paymentRegistrySnapshot(); dynamic != nil {
|
||||
paymentRegistry = dynamic
|
||||
}
|
||||
}
|
||||
service := &Service{repo: repo, dataDir: dataDir, runtimeCapabilities: capabilities, activeStorageTests: make(map[string]bool), activeCancels: make(map[string]context.CancelFunc), coordinator: coordinator, runtimeErr: err, pluginRuntime: pluginRuntime, pluginRuntimeErr: pluginRuntimeErr, paymentRegistry: paymentRegistry, workerID: newID(), routeCatalogTTL: 30 * time.Second, routeCatalogMaxStale: 5 * time.Minute, routeHealthBlocked: make(map[string]time.Time)}
|
||||
service.taskBillingCoordinator = newTaskBillingCoordinator(service.repo)
|
||||
service.taskTerminalCoordinator = newTaskTerminalCoordinator(service)
|
||||
service.taskRouteExecutor = newTaskRouteExecutor(service)
|
||||
|
||||
29
backend/payment-plugins/aliases.go
Normal file
29
backend/payment-plugins/aliases.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package paymentplugins
|
||||
|
||||
import paymentsdk "infinite-canvas/backend/payment-sdk"
|
||||
|
||||
type Config = paymentsdk.Config
|
||||
type Descriptor = paymentsdk.Descriptor
|
||||
type NotificationResponse = paymentsdk.NotificationResponse
|
||||
type CreateRequest = paymentsdk.CreateRequest
|
||||
type Checkout = paymentsdk.Checkout
|
||||
type QueryRequest = paymentsdk.QueryRequest
|
||||
type CloseRequest = paymentsdk.CloseRequest
|
||||
type Result = paymentsdk.Result
|
||||
type Notification = paymentsdk.Notification
|
||||
type BillRecord = paymentsdk.BillRecord
|
||||
type Provider = paymentsdk.Provider
|
||||
type ProviderError = paymentsdk.ProviderError
|
||||
|
||||
var (
|
||||
ErrOrderNotFound = paymentsdk.ErrOrderNotFound
|
||||
ErrOrderNotPaid = paymentsdk.ErrOrderNotPaid
|
||||
ErrTradeBillNotFound = paymentsdk.ErrTradeBillNotFound
|
||||
)
|
||||
|
||||
const (
|
||||
ProviderWeChatNative = "wechat-native"
|
||||
ProviderAlipayPage = "alipay-page-pay"
|
||||
PluginWeChatNative = "official-payment-wechat-native"
|
||||
PluginAlipayPage = "official-payment-alipay-page"
|
||||
)
|
||||
@@ -1,4 +1,4 @@
|
||||
package payment
|
||||
package paymentplugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -29,7 +29,7 @@ func NewAlipayProvider(client *http.Client) *AlipayProvider {
|
||||
}
|
||||
|
||||
func (p *AlipayProvider) Descriptor() Descriptor {
|
||||
return Descriptor{ID: ProviderAlipayPage, PluginID: PluginAlipayPage, Name: "支付宝电脑网站支付", Icon: "brand:alipay", CheckoutMode: "redirect"}
|
||||
return Descriptor{ID: ProviderAlipayPage, PluginID: PluginAlipayPage, PluginVersion: "1.0.0", Name: "支付宝电脑网站支付", Icon: "brand:alipay", CheckoutMode: "redirect", IdentityFields: []string{"appId", "sellerId"}, 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 *AlipayProvider) ValidateConfig(config Config) error {
|
||||
@@ -1,4 +1,4 @@
|
||||
package payment
|
||||
package paymentplugins
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
@@ -1,4 +1,4 @@
|
||||
package payment
|
||||
package paymentplugins
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
@@ -1,4 +1,4 @@
|
||||
package payment
|
||||
package paymentplugins
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
@@ -1,4 +1,4 @@
|
||||
package payment
|
||||
package paymentplugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
96
backend/payment-plugins/rpc_runner.go
Normal file
96
backend/payment-plugins/rpc_runner.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package paymentplugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const rpcVersion = "yingce.payment/v1"
|
||||
|
||||
type rpcRequest struct {
|
||||
Version string `json:"version"`
|
||||
Operation string `json:"operation"`
|
||||
Config Config `json:"config,omitempty"`
|
||||
Request json.RawMessage `json:"request,omitempty"`
|
||||
Headers map[string][]string `json:"headers,omitempty"`
|
||||
Body string `json:"bodyBase64,omitempty"`
|
||||
BillDate string `json:"billDate,omitempty"`
|
||||
}
|
||||
|
||||
type rpcResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Data any `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
func RunRPC(provider Provider, input io.Reader, output io.Writer) error {
|
||||
decoder := json.NewDecoder(io.LimitReader(input, 2<<20))
|
||||
var request rpcRequest
|
||||
if err := decoder.Decode(&request); err != nil {
|
||||
return writeRPC(output, rpcResponse{Code: "invalid_request", Message: "请求不是有效 JSON"})
|
||||
}
|
||||
if request.Version != rpcVersion {
|
||||
return writeRPC(output, rpcResponse{Code: "unsupported_version", Message: "不支持的支付插件协议版本"})
|
||||
}
|
||||
if provider == nil {
|
||||
return writeRPC(output, rpcResponse{Code: "provider_unavailable", Message: "支付插件未配置"})
|
||||
}
|
||||
ctx := context.Background()
|
||||
var value any
|
||||
var err error
|
||||
switch request.Operation {
|
||||
case "validate_config":
|
||||
err = provider.ValidateConfig(request.Config)
|
||||
case "create_order":
|
||||
var valueRequest CreateRequest
|
||||
err = json.Unmarshal(request.Request, &valueRequest)
|
||||
if err == nil {
|
||||
value, err = provider.CreateOrder(ctx, request.Config, valueRequest)
|
||||
}
|
||||
case "query_order":
|
||||
var valueRequest QueryRequest
|
||||
err = json.Unmarshal(request.Request, &valueRequest)
|
||||
if err == nil {
|
||||
value, err = provider.QueryOrder(ctx, request.Config, valueRequest)
|
||||
}
|
||||
case "close_order":
|
||||
var valueRequest CloseRequest
|
||||
err = json.Unmarshal(request.Request, &valueRequest)
|
||||
if err == nil {
|
||||
value, err = provider.CloseOrder(ctx, request.Config, valueRequest)
|
||||
}
|
||||
case "verify_notification":
|
||||
body, decodeErr := base64.StdEncoding.DecodeString(request.Body)
|
||||
err = decodeErr
|
||||
if err == nil {
|
||||
value, err = provider.VerifyNotification(ctx, request.Config, http.Header(request.Headers), body)
|
||||
}
|
||||
case "download_trade_bill":
|
||||
billDate, parseErr := time.Parse("2006-01-02", request.BillDate)
|
||||
err = parseErr
|
||||
if err == nil {
|
||||
value, err = provider.DownloadTradeBill(ctx, request.Config, billDate)
|
||||
}
|
||||
default:
|
||||
err = fmt.Errorf("unknown payment operation %q", request.Operation)
|
||||
}
|
||||
if err != nil {
|
||||
response := rpcResponse{Code: "provider_error", Message: err.Error()}
|
||||
if providerErr, ok := err.(*ProviderError); ok && strings.TrimSpace(providerErr.Code) != "" {
|
||||
response.Code = providerErr.Code
|
||||
}
|
||||
return writeRPC(output, response)
|
||||
}
|
||||
return writeRPC(output, rpcResponse{OK: true, Data: value})
|
||||
}
|
||||
|
||||
func writeRPC(output io.Writer, response rpcResponse) error {
|
||||
return json.NewEncoder(output).Encode(response)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package payment
|
||||
package paymentplugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -36,7 +36,7 @@ func NewWeChatProvider(client *http.Client, baseURL string) *WeChatProvider {
|
||||
}
|
||||
|
||||
func (p *WeChatProvider) Descriptor() Descriptor {
|
||||
return Descriptor{ID: ProviderWeChatNative, PluginID: PluginWeChatNative, Name: "微信支付 Native", Icon: "brand:wechat-pay", CheckoutMode: "qr_code"}
|
||||
return Descriptor{ID: ProviderWeChatNative, PluginID: PluginWeChatNative, PluginVersion: "1.0.0", Name: "微信支付 Native", Icon: "brand:wechat-pay", CheckoutMode: "qr_code", IdentityFields: []string{"appId", "mchId"}, NotificationSuccess: NotificationResponse{Status: 204}, NotificationFailure: NotificationResponse{Status: 400}}
|
||||
}
|
||||
|
||||
func (p *WeChatProvider) ValidateConfig(config Config) error {
|
||||
106
backend/payment-sdk/types.go
Normal file
106
backend/payment-sdk/types.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package paymentsdk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrOrderNotFound = errors.New("payment provider order not found")
|
||||
ErrOrderNotPaid = errors.New("payment provider order is not paid")
|
||||
ErrTradeBillNotFound = errors.New("payment provider trade bill not found")
|
||||
)
|
||||
|
||||
type Config map[string]string
|
||||
|
||||
type Descriptor struct {
|
||||
ID string `json:"id"`
|
||||
PluginID string `json:"pluginId"`
|
||||
PluginVersion string `json:"pluginVersion,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Icon string `json:"icon"`
|
||||
CheckoutMode string `json:"checkoutMode"`
|
||||
IdentityFields []string `json:"identityFields,omitempty"`
|
||||
NotificationSuccess NotificationResponse `json:"notificationSuccess,omitempty"`
|
||||
NotificationFailure NotificationResponse `json:"notificationFailure,omitempty"`
|
||||
}
|
||||
|
||||
type NotificationResponse struct {
|
||||
Status int
|
||||
ContentType string
|
||||
Body string
|
||||
}
|
||||
|
||||
type CreateRequest struct {
|
||||
MerchantOrderNo string
|
||||
Description string
|
||||
AmountFen int64
|
||||
Currency string
|
||||
ExpiresAt time.Time
|
||||
NotifyURL string
|
||||
ReturnURL string
|
||||
}
|
||||
|
||||
type Checkout struct {
|
||||
Mode string
|
||||
Value string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type QueryRequest struct{ MerchantOrderNo string }
|
||||
type CloseRequest struct{ MerchantOrderNo string }
|
||||
|
||||
type Result struct {
|
||||
MerchantOrderNo string `json:"merchantOrderNo"`
|
||||
ProviderTradeNo string `json:"providerTradeNo,omitempty"`
|
||||
ProviderStatus string `json:"providerStatus"`
|
||||
AmountFen int64 `json:"amountFen"`
|
||||
Currency string `json:"currency"`
|
||||
Paid bool `json:"paid"`
|
||||
Closed bool `json:"closed"`
|
||||
PaidAt time.Time `json:"paidAt,omitempty"`
|
||||
}
|
||||
|
||||
type Notification struct {
|
||||
EventID string `json:"eventId"`
|
||||
Result
|
||||
}
|
||||
|
||||
type BillRecord struct {
|
||||
MerchantOrderNo string
|
||||
ProviderTradeNo string
|
||||
ProviderStatus string
|
||||
AmountFen int64
|
||||
Currency string
|
||||
PaidAt time.Time
|
||||
}
|
||||
|
||||
type Provider interface {
|
||||
Descriptor() Descriptor
|
||||
ValidateConfig(Config) error
|
||||
CreateOrder(context.Context, Config, CreateRequest) (Checkout, error)
|
||||
QueryOrder(context.Context, Config, QueryRequest) (Result, error)
|
||||
CloseOrder(context.Context, Config, CloseRequest) (Result, error)
|
||||
VerifyNotification(context.Context, Config, http.Header, []byte) (Notification, error)
|
||||
DownloadTradeBill(context.Context, Config, time.Time) ([]BillRecord, error)
|
||||
}
|
||||
|
||||
type ProviderError struct {
|
||||
Code string
|
||||
Message string
|
||||
Temporary bool
|
||||
Cause error
|
||||
}
|
||||
|
||||
func (e *ProviderError) Error() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
if e.Message != "" {
|
||||
return e.Message
|
||||
}
|
||||
return e.Code
|
||||
}
|
||||
func (e *ProviderError) Unwrap() error { return e.Cause }
|
||||
@@ -85,6 +85,18 @@ description: 影策后端核心持久化表与状态边界
|
||||
- `idx_project_asset_candidates_project_unit_status`、`idx_project_asset_candidates_project_status_category`:章节候选和待确认分类队列。
|
||||
- `idx_tasks_user_project_created`:项目任务恢复读取。
|
||||
|
||||
## 支付与插件版本
|
||||
|
||||
| 表 | 用途 | 关键边界 |
|
||||
| --- | --- | --- |
|
||||
| `topup_products` | 保存服务端维护的充值商品、金额和积分快照 | 客户端只能提交商品 ID;金额和积分由 service 读取,不能由请求覆盖 |
|
||||
| `payment_provider_configs` | 保存支付插件某个渠道配置的不可变版本 | `provider_id + version` 唯一;密钥只保存加密密文;`plugin_id` / `plugin_version` 记录配置对应的插件版本 |
|
||||
| `payment_orders` | 保存充值订单、幂等键、渠道交易号和入账状态 | 创建订单时固定 `plugin_id`、`plugin_version`、配置 ID 与配置版本;回调、查单、关单必须使用同一运行时版本 |
|
||||
| `payment_notifications` | 保存已验签的支付通知收件箱 | 以渠道事件 ID 幂等;只保存用于异步入账的标准化证据,重复通知不会重复发放积分 |
|
||||
| `payment_reconciliation_runs` / `payment_reconciliation_items` | 保存渠道账单下载和对账结果 | 账单只能作为发现线索,金额、币种和交易号仍需由插件查单确认;历史订单继续绑定原插件版本 |
|
||||
|
||||
支付宝、微信及后续聚合平台都只作为支付插件包提供 `yingce.payment/v1` 适配器,宿主数据库不保存平台专用字段。插件升级不会改写历史订单的 `plugin_version`;存在未完成订单时,原插件版本和配置必须继续可解析通知、查单和关单。
|
||||
|
||||
## 模型渠道与目录
|
||||
|
||||
| 表 | 用途 | 关键边界 |
|
||||
|
||||
64
docs/content/docs/plugins/payment-plugin-runtime.mdx
Normal file
64
docs/content/docs/plugins/payment-plugin-runtime.mdx
Normal file
@@ -0,0 +1,64 @@
|
||||
# 支付插件运行时
|
||||
|
||||
支付渠道通过 `.yingce-plugin` 包接入。宿主只负责订单、幂等、配置加密、回调收件箱、积分入账和对账;插件负责渠道协议转换。
|
||||
|
||||
## 包结构
|
||||
|
||||
```text
|
||||
my-payment.yingce-plugin
|
||||
├── manifest.json
|
||||
├── backend/provider
|
||||
├── assets/icon.svg
|
||||
└── docs/README.md
|
||||
```
|
||||
|
||||
Manifest 的支付贡献声明 `runtime.backend` 为 `rpc` 或 `wasm`,并通过 `runtime.backendEntry` 指定后端入口。当前版本首先启用 `rpc`:宿主以一次 JSON 请求启动入口进程,进程返回一次 JSON 响应。WASM 使用相同的 `yingce.payment/v1` 数据契约,运行时启用后即可复用插件实现。
|
||||
|
||||
## 标准操作
|
||||
|
||||
入口进程接收以下操作名:
|
||||
|
||||
| 操作 | 返回值 |
|
||||
| --- | --- |
|
||||
| `validate_config` | 空数据;失败返回 `ok:false` |
|
||||
| `create_order` | `Checkout`:`mode`、`value`、`expiresAt` |
|
||||
| `query_order` | 标准 `Result` |
|
||||
| `close_order` | 标准 `Result` |
|
||||
| `verify_notification` | `Notification`,请求体通过 `bodyBase64` 传入 |
|
||||
| `download_trade_bill` | `BillRecord[]` |
|
||||
|
||||
请求固定带有 `version: "yingce.payment/v1"`。插件不得访问宿主数据库,不得自行入账积分。宿主目前控制进程工作目录、环境变量、标准输出大小和 30 秒超时;支付宝、微信实现还会在插件内部校验网关和账单下载域名。通用插件的操作系统级沙箱、签名校验和网络白名单尚未启用,不应直接运行不受信任的支付插件。
|
||||
|
||||
## Manifest 示例
|
||||
|
||||
```json
|
||||
{
|
||||
"apiVersion": "yingce.plugin/v1",
|
||||
"id": "example-aggregate-payment",
|
||||
"name": "Example 聚合支付",
|
||||
"version": "1.0.0",
|
||||
"author": "Example",
|
||||
"enabled": true,
|
||||
"runtime": { "backend": "rpc", "backendEntry": "backend/provider" },
|
||||
"configuration": {
|
||||
"fields": [
|
||||
{ "name": "merchantId", "type": "string", "required": true },
|
||||
{ "name": "secret", "type": "password", "required": true, "secret": true }
|
||||
]
|
||||
},
|
||||
"contributes": {
|
||||
"paymentProviders": [{
|
||||
"id": "example-qr",
|
||||
"label": "Example 聚合支付",
|
||||
"icon": "assets/icon.svg",
|
||||
"checkoutMode": "qr_code",
|
||||
"identityFields": ["merchantId"],
|
||||
"expiryPolicy": { "defaultMinutes": 30, "minMinutes": 5, "maxMinutes": 1440 },
|
||||
"notificationSuccess": { "status": 204 },
|
||||
"notificationFailure": { "status": 400 }
|
||||
}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
历史订单绑定插件版本和包哈希。升级不会替换未完结订单所需的运行文件;宿主检测到未完结订单时会拒绝支付插件升级或卸载,直到订单进入终态。签名根、WASM 执行器和通用网络白名单将在后续安全阶段加入。
|
||||
@@ -5,6 +5,15 @@ root_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
|
||||
node "$root_dir/embed-documentation.mjs"
|
||||
|
||||
# Payment backends are executable plugin payloads. They are compiled outside
|
||||
# the host binary and then included under backend/provider in the package.
|
||||
repo_dir=$(CDPATH= cd -- "$root_dir/.." && pwd)
|
||||
for payment_plugin in official-payment-wechat-native official-payment-alipay-page; do
|
||||
mkdir -p "$root_dir/$payment_plugin/backend"
|
||||
done
|
||||
(cd "$repo_dir/backend" && go build -trimpath -ldflags='-s -w' -o "$root_dir/official-payment-wechat-native/backend/provider" ./cmd/payment-wechat)
|
||||
(cd "$repo_dir/backend" && go build -trimpath -ldflags='-s -w' -o "$root_dir/official-payment-alipay-page/backend/provider" ./cmd/payment-alipay)
|
||||
|
||||
for manifest in "$root_dir"/*/manifest.json; do
|
||||
package_dir=${manifest%/manifest.json}
|
||||
package_id=${package_dir##*/}
|
||||
@@ -13,7 +22,7 @@ for manifest in "$root_dir"/*/manifest.json; do
|
||||
rm -f "$temporary_file"
|
||||
(
|
||||
cd "$package_dir"
|
||||
find manifest.json README.md docs assets web LICENSE -type f 2>/dev/null | LC_ALL=C sort | zip -X -q "$temporary_file" -@
|
||||
find manifest.json README.md docs assets web backend LICENSE -type f 2>/dev/null | LC_ALL=C sort | zip -X -q "$temporary_file" -@
|
||||
)
|
||||
mv "$temporary_file" "$output_file"
|
||||
done
|
||||
|
||||
BIN
plugin-packages/official-payment-alipay-page.yingce-plugin
Normal file
BIN
plugin-packages/official-payment-alipay-page.yingce-plugin
Normal file
Binary file not shown.
3
plugin-packages/official-payment-alipay-page/README.md
Normal file
3
plugin-packages/official-payment-alipay-page/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# 支付宝电脑网站支付
|
||||
|
||||
官方 `yingce.payment/v1` 支付插件。后端入口由构建脚本编译为 `backend/provider`。
|
||||
BIN
plugin-packages/official-payment-alipay-page/backend/provider
Executable file
BIN
plugin-packages/official-payment-alipay-page/backend/provider
Executable file
Binary file not shown.
109
plugin-packages/official-payment-alipay-page/docs/interface.md
Normal file
109
plugin-packages/official-payment-alipay-page/docs/interface.md
Normal file
@@ -0,0 +1,109 @@
|
||||
## yingce.payment/v1
|
||||
|
||||
支持 `validate_config`、`create_order`、`query_order`、`close_order`、`verify_notification` 和 `download_trade_bill`,统一返回 JSON 响应。
|
||||
|
||||
<!-- YINGCE_MANIFEST_CONTRACT_START -->
|
||||
## Manifest 完整接口定义
|
||||
|
||||
以下 JSON 与插件包内实际 `manifest.json` 逐字段一致,覆盖插件身份、权限、配置、鉴权、参数、校验、创建、Agent、查询、取消、结果下载、响应和 Agent 响应映射。`documentation` 字段的值就是当前完整文档;为避免文档在自身内部无限递归,JSON 中仅用等义占位文本表示正文。
|
||||
|
||||
```json
|
||||
{
|
||||
"apiVersion": "yingce.plugin/v1",
|
||||
"id": "official-payment-alipay-page",
|
||||
"name": "支付宝电脑网站支付",
|
||||
"version": "1.0.0",
|
||||
"author": "支付宝",
|
||||
"description": "支付宝电脑网站支付充值适配器。",
|
||||
"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
|
||||
},
|
||||
{
|
||||
"name": "appId",
|
||||
"type": "string",
|
||||
"label": "应用 AppID",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "sellerId",
|
||||
"type": "string",
|
||||
"label": "支付宝商户 PID",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "merchantPrivateKey",
|
||||
"type": "textarea",
|
||||
"label": "应用私钥",
|
||||
"required": true,
|
||||
"secret": true
|
||||
},
|
||||
{
|
||||
"name": "alipayPublicKey",
|
||||
"type": "textarea",
|
||||
"label": "支付宝公钥",
|
||||
"required": true,
|
||||
"secret": true
|
||||
},
|
||||
{
|
||||
"name": "gateway",
|
||||
"type": "url",
|
||||
"label": "支付宝网关",
|
||||
"required": true,
|
||||
"default": "https://openapi.alipay.com/gateway.do"
|
||||
}
|
||||
]
|
||||
},
|
||||
"contributes": {
|
||||
"paymentProviders": [
|
||||
{
|
||||
"id": "alipay-page-pay",
|
||||
"label": "支付宝电脑网站支付",
|
||||
"icon": "brand:alipay",
|
||||
"checkoutMode": "redirect",
|
||||
"identityFields": [
|
||||
"appId",
|
||||
"sellerId"
|
||||
],
|
||||
"expiryPolicy": {
|
||||
"defaultMinutes": 30,
|
||||
"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 -->
|
||||
97
plugin-packages/official-payment-alipay-page/manifest.json
Normal file
97
plugin-packages/official-payment-alipay-page/manifest.json
Normal file
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"apiVersion": "yingce.plugin/v1",
|
||||
"id": "official-payment-alipay-page",
|
||||
"name": "支付宝电脑网站支付",
|
||||
"version": "1.0.0",
|
||||
"author": "支付宝",
|
||||
"description": "支付宝电脑网站支付充值适配器。",
|
||||
"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
|
||||
},
|
||||
{
|
||||
"name": "appId",
|
||||
"type": "string",
|
||||
"label": "应用 AppID",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "sellerId",
|
||||
"type": "string",
|
||||
"label": "支付宝商户 PID",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "merchantPrivateKey",
|
||||
"type": "textarea",
|
||||
"label": "应用私钥",
|
||||
"required": true,
|
||||
"secret": true
|
||||
},
|
||||
{
|
||||
"name": "alipayPublicKey",
|
||||
"type": "textarea",
|
||||
"label": "支付宝公钥",
|
||||
"required": true,
|
||||
"secret": true
|
||||
},
|
||||
{
|
||||
"name": "gateway",
|
||||
"type": "url",
|
||||
"label": "支付宝网关",
|
||||
"required": true,
|
||||
"default": "https://openapi.alipay.com/gateway.do"
|
||||
}
|
||||
]
|
||||
},
|
||||
"contributes": {
|
||||
"paymentProviders": [
|
||||
{
|
||||
"id": "alipay-page-pay",
|
||||
"label": "支付宝电脑网站支付",
|
||||
"icon": "brand:alipay",
|
||||
"checkoutMode": "redirect",
|
||||
"identityFields": [
|
||||
"appId",
|
||||
"sellerId"
|
||||
],
|
||||
"expiryPolicy": {
|
||||
"defaultMinutes": 30,
|
||||
"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": "# 支付宝电脑网站支付\n\n官方 `yingce.payment/v1` 支付插件。后端入口由构建脚本编译为 `backend/provider`。\n\n---\n\n## yingce.payment/v1\n\n支持 `validate_config`、`create_order`、`query_order`、`close_order`、`verify_notification` 和 `download_trade_bill`,统一返回 JSON 响应。\n\n<!-- YINGCE_MANIFEST_CONTRACT_START -->\n## Manifest 完整接口定义\n\n以下 JSON 与插件包内实际 `manifest.json` 逐字段一致,覆盖插件身份、权限、配置、鉴权、参数、校验、创建、Agent、查询、取消、结果下载、响应和 Agent 响应映射。`documentation` 字段的值就是当前完整文档;为避免文档在自身内部无限递归,JSON 中仅用等义占位文本表示正文。\n\n```json\n{\n \"apiVersion\": \"yingce.plugin/v1\",\n \"id\": \"official-payment-alipay-page\",\n \"name\": \"支付宝电脑网站支付\",\n \"version\": \"1.0.0\",\n \"author\": \"支付宝\",\n \"description\": \"支付宝电脑网站支付充值适配器。\",\n \"enabled\": true,\n \"installable\": true,\n \"runtime\": {\n \"backend\": \"rpc\",\n \"backendEntry\": \"backend/provider\"\n },\n \"surfaces\": [\n \"wallet\",\n \"settings\"\n ],\n \"permissions\": [\n \"payment.create\",\n \"payment.query\",\n \"payment.close\",\n \"payment.reconcile\"\n ],\n \"configuration\": {\n \"fields\": [\n {\n \"name\": \"publicBaseUrl\",\n \"type\": \"url\",\n \"label\": \"服务器公网地址\",\n \"required\": true\n },\n {\n \"name\": \"appId\",\n \"type\": \"string\",\n \"label\": \"应用 AppID\",\n \"required\": true\n },\n {\n \"name\": \"sellerId\",\n \"type\": \"string\",\n \"label\": \"支付宝商户 PID\",\n \"required\": true\n },\n {\n \"name\": \"merchantPrivateKey\",\n \"type\": \"textarea\",\n \"label\": \"应用私钥\",\n \"required\": true,\n \"secret\": true\n },\n {\n \"name\": \"alipayPublicKey\",\n \"type\": \"textarea\",\n \"label\": \"支付宝公钥\",\n \"required\": true,\n \"secret\": true\n },\n {\n \"name\": \"gateway\",\n \"type\": \"url\",\n \"label\": \"支付宝网关\",\n \"required\": true,\n \"default\": \"https://openapi.alipay.com/gateway.do\"\n }\n ]\n },\n \"contributes\": {\n \"paymentProviders\": [\n {\n \"id\": \"alipay-page-pay\",\n \"label\": \"支付宝电脑网站支付\",\n \"icon\": \"brand:alipay\",\n \"checkoutMode\": \"redirect\",\n \"identityFields\": [\n \"appId\",\n \"sellerId\"\n ],\n \"expiryPolicy\": {\n \"defaultMinutes\": 30,\n \"minMinutes\": 5,\n \"maxMinutes\": 1440\n },\n \"notificationSuccess\": {\n \"status\": 200,\n \"contentType\": \"text/plain; charset=utf-8\",\n \"body\": \"success\"\n },\n \"notificationFailure\": {\n \"status\": 400,\n \"contentType\": \"text/plain; charset=utf-8\",\n \"body\": \"failure\"\n }\n }\n ]\n },\n \"documentation\": \"<当前插件的完整 documentation,由 README.md 与 docs/interface.md 拼接而成;为避免 JSON 递归,此处不重复展开正文。>\"\n}\n```\n<!-- YINGCE_MANIFEST_CONTRACT_END -->\n"
|
||||
}
|
||||
BIN
plugin-packages/official-payment-wechat-native.yingce-plugin
Normal file
BIN
plugin-packages/official-payment-wechat-native.yingce-plugin
Normal file
Binary file not shown.
3
plugin-packages/official-payment-wechat-native/README.md
Normal file
3
plugin-packages/official-payment-wechat-native/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# 微信支付 Native
|
||||
|
||||
官方 `yingce.payment/v1` 支付插件。后端入口由构建脚本编译为 `backend/provider`。
|
||||
BIN
plugin-packages/official-payment-wechat-native/backend/provider
Executable file
BIN
plugin-packages/official-payment-wechat-native/backend/provider
Executable file
Binary file not shown.
117
plugin-packages/official-payment-wechat-native/docs/interface.md
Normal file
117
plugin-packages/official-payment-wechat-native/docs/interface.md
Normal file
@@ -0,0 +1,117 @@
|
||||
## yingce.payment/v1
|
||||
|
||||
支持 `validate_config`、`create_order`、`query_order`、`close_order`、`verify_notification` 和 `download_trade_bill`,统一返回 JSON 响应。
|
||||
|
||||
<!-- YINGCE_MANIFEST_CONTRACT_START -->
|
||||
## Manifest 完整接口定义
|
||||
|
||||
以下 JSON 与插件包内实际 `manifest.json` 逐字段一致,覆盖插件身份、权限、配置、鉴权、参数、校验、创建、Agent、查询、取消、结果下载、响应和 Agent 响应映射。`documentation` 字段的值就是当前完整文档;为避免文档在自身内部无限递归,JSON 中仅用等义占位文本表示正文。
|
||||
|
||||
```json
|
||||
{
|
||||
"apiVersion": "yingce.plugin/v1",
|
||||
"id": "official-payment-wechat-native",
|
||||
"name": "微信支付 Native",
|
||||
"version": "1.0.0",
|
||||
"author": "微信支付",
|
||||
"description": "微信支付 Native 扫码充值适配器。",
|
||||
"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
|
||||
},
|
||||
{
|
||||
"name": "appId",
|
||||
"type": "string",
|
||||
"label": "AppID",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "mchId",
|
||||
"type": "string",
|
||||
"label": "商户号",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "merchantSerialNo",
|
||||
"type": "string",
|
||||
"label": "商户证书序列号",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "merchantPrivateKey",
|
||||
"type": "textarea",
|
||||
"label": "商户 API 私钥",
|
||||
"required": true,
|
||||
"secret": true
|
||||
},
|
||||
{
|
||||
"name": "apiV3Key",
|
||||
"type": "password",
|
||||
"label": "APIv3 密钥",
|
||||
"required": true,
|
||||
"secret": true
|
||||
},
|
||||
{
|
||||
"name": "wechatPayPublicKeyId",
|
||||
"type": "string",
|
||||
"label": "微信支付公钥 ID",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "wechatPayPublicKey",
|
||||
"type": "textarea",
|
||||
"label": "微信支付公钥",
|
||||
"required": true,
|
||||
"secret": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"contributes": {
|
||||
"paymentProviders": [
|
||||
{
|
||||
"id": "wechat-native",
|
||||
"label": "微信支付 Native",
|
||||
"icon": "brand:wechat-pay",
|
||||
"checkoutMode": "qr_code",
|
||||
"identityFields": [
|
||||
"appId",
|
||||
"mchId"
|
||||
],
|
||||
"expiryPolicy": {
|
||||
"defaultMinutes": 30,
|
||||
"minMinutes": 5,
|
||||
"maxMinutes": 1440
|
||||
},
|
||||
"notificationSuccess": {
|
||||
"status": 204
|
||||
},
|
||||
"notificationFailure": {
|
||||
"status": 400
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"documentation": "<当前插件的完整 documentation,由 README.md 与 docs/interface.md 拼接而成;为避免 JSON 递归,此处不重复展开正文。>"
|
||||
}
|
||||
```
|
||||
<!-- YINGCE_MANIFEST_CONTRACT_END -->
|
||||
105
plugin-packages/official-payment-wechat-native/manifest.json
Normal file
105
plugin-packages/official-payment-wechat-native/manifest.json
Normal file
@@ -0,0 +1,105 @@
|
||||
{
|
||||
"apiVersion": "yingce.plugin/v1",
|
||||
"id": "official-payment-wechat-native",
|
||||
"name": "微信支付 Native",
|
||||
"version": "1.0.0",
|
||||
"author": "微信支付",
|
||||
"description": "微信支付 Native 扫码充值适配器。",
|
||||
"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
|
||||
},
|
||||
{
|
||||
"name": "appId",
|
||||
"type": "string",
|
||||
"label": "AppID",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "mchId",
|
||||
"type": "string",
|
||||
"label": "商户号",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "merchantSerialNo",
|
||||
"type": "string",
|
||||
"label": "商户证书序列号",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "merchantPrivateKey",
|
||||
"type": "textarea",
|
||||
"label": "商户 API 私钥",
|
||||
"required": true,
|
||||
"secret": true
|
||||
},
|
||||
{
|
||||
"name": "apiV3Key",
|
||||
"type": "password",
|
||||
"label": "APIv3 密钥",
|
||||
"required": true,
|
||||
"secret": true
|
||||
},
|
||||
{
|
||||
"name": "wechatPayPublicKeyId",
|
||||
"type": "string",
|
||||
"label": "微信支付公钥 ID",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "wechatPayPublicKey",
|
||||
"type": "textarea",
|
||||
"label": "微信支付公钥",
|
||||
"required": true,
|
||||
"secret": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"contributes": {
|
||||
"paymentProviders": [
|
||||
{
|
||||
"id": "wechat-native",
|
||||
"label": "微信支付 Native",
|
||||
"icon": "brand:wechat-pay",
|
||||
"checkoutMode": "qr_code",
|
||||
"identityFields": [
|
||||
"appId",
|
||||
"mchId"
|
||||
],
|
||||
"expiryPolicy": {
|
||||
"defaultMinutes": 30,
|
||||
"minMinutes": 5,
|
||||
"maxMinutes": 1440
|
||||
},
|
||||
"notificationSuccess": {
|
||||
"status": 204
|
||||
},
|
||||
"notificationFailure": {
|
||||
"status": 400
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"documentation": "# 微信支付 Native\n\n官方 `yingce.payment/v1` 支付插件。后端入口由构建脚本编译为 `backend/provider`。\n\n---\n\n## yingce.payment/v1\n\n支持 `validate_config`、`create_order`、`query_order`、`close_order`、`verify_notification` 和 `download_trade_bill`,统一返回 JSON 响应。\n\n<!-- YINGCE_MANIFEST_CONTRACT_START -->\n## Manifest 完整接口定义\n\n以下 JSON 与插件包内实际 `manifest.json` 逐字段一致,覆盖插件身份、权限、配置、鉴权、参数、校验、创建、Agent、查询、取消、结果下载、响应和 Agent 响应映射。`documentation` 字段的值就是当前完整文档;为避免文档在自身内部无限递归,JSON 中仅用等义占位文本表示正文。\n\n```json\n{\n \"apiVersion\": \"yingce.plugin/v1\",\n \"id\": \"official-payment-wechat-native\",\n \"name\": \"微信支付 Native\",\n \"version\": \"1.0.0\",\n \"author\": \"微信支付\",\n \"description\": \"微信支付 Native 扫码充值适配器。\",\n \"enabled\": true,\n \"installable\": true,\n \"runtime\": {\n \"backend\": \"rpc\",\n \"backendEntry\": \"backend/provider\"\n },\n \"surfaces\": [\n \"wallet\",\n \"settings\"\n ],\n \"permissions\": [\n \"payment.create\",\n \"payment.query\",\n \"payment.close\",\n \"payment.reconcile\"\n ],\n \"configuration\": {\n \"fields\": [\n {\n \"name\": \"publicBaseUrl\",\n \"type\": \"url\",\n \"label\": \"服务器公网地址\",\n \"required\": true\n },\n {\n \"name\": \"appId\",\n \"type\": \"string\",\n \"label\": \"AppID\",\n \"required\": true\n },\n {\n \"name\": \"mchId\",\n \"type\": \"string\",\n \"label\": \"商户号\",\n \"required\": true\n },\n {\n \"name\": \"merchantSerialNo\",\n \"type\": \"string\",\n \"label\": \"商户证书序列号\",\n \"required\": true\n },\n {\n \"name\": \"merchantPrivateKey\",\n \"type\": \"textarea\",\n \"label\": \"商户 API 私钥\",\n \"required\": true,\n \"secret\": true\n },\n {\n \"name\": \"apiV3Key\",\n \"type\": \"password\",\n \"label\": \"APIv3 密钥\",\n \"required\": true,\n \"secret\": true\n },\n {\n \"name\": \"wechatPayPublicKeyId\",\n \"type\": \"string\",\n \"label\": \"微信支付公钥 ID\",\n \"required\": true\n },\n {\n \"name\": \"wechatPayPublicKey\",\n \"type\": \"textarea\",\n \"label\": \"微信支付公钥\",\n \"required\": true,\n \"secret\": true\n }\n ]\n },\n \"contributes\": {\n \"paymentProviders\": [\n {\n \"id\": \"wechat-native\",\n \"label\": \"微信支付 Native\",\n \"icon\": \"brand:wechat-pay\",\n \"checkoutMode\": \"qr_code\",\n \"identityFields\": [\n \"appId\",\n \"mchId\"\n ],\n \"expiryPolicy\": {\n \"defaultMinutes\": 30,\n \"minMinutes\": 5,\n \"maxMinutes\": 1440\n },\n \"notificationSuccess\": {\n \"status\": 204\n },\n \"notificationFailure\": {\n \"status\": 400\n }\n }\n ]\n },\n \"documentation\": \"<当前插件的完整 documentation,由 README.md 与 docs/interface.md 拼接而成;为避免 JSON 递归,此处不重复展开正文。>\"\n}\n```\n<!-- YINGCE_MANIFEST_CONTRACT_END -->\n"
|
||||
}
|
||||
@@ -455,12 +455,12 @@ export default function WalletPage() {
|
||||
onClick={() => setSelectedProviderId(provider.id)}
|
||||
>
|
||||
<PaymentBrandIcon providerId={provider.id} />
|
||||
<span className="min-w-0 truncate text-sm font-medium">{provider.id === "wechat-native" ? "微信支付" : "支付宝"}</span>
|
||||
<span className="min-w-0 truncate text-sm font-medium">{provider.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Button className="mt-3" type="primary" size="large" block loading={paymentCreating} disabled={!selectedProduct || !selectedProvider} onClick={() => void startPayment()}>
|
||||
{selectedProvider?.checkoutMode === "qr_code" ? "生成支付二维码" : "前往支付宝支付"}
|
||||
{selectedProvider?.checkoutMode === "qr_code" ? "生成支付二维码" : `前往${selectedProvider?.name || "支付"}`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -514,7 +514,7 @@ export default function WalletPage() {
|
||||
|
||||
<Modal
|
||||
open={paymentModalOpen}
|
||||
title={paymentOrder?.status === "credited" ? "充值完成" : paymentOrder?.checkout.mode === "qr_code" ? "微信扫码支付" : "确认支付结果"}
|
||||
title={paymentOrder?.status === "credited" ? "充值完成" : paymentOrder?.checkout.mode === "qr_code" ? "扫码支付" : "确认支付结果"}
|
||||
onCancel={() => setPaymentModalOpen(false)}
|
||||
footer={
|
||||
paymentOrder?.status === "pending"
|
||||
@@ -532,7 +532,7 @@ export default function WalletPage() {
|
||||
稍后处理
|
||||
</Button>,
|
||||
<Button key="retry" type="primary" loading={paymentQuerying} onClick={() => void retryPaymentCheckout()}>
|
||||
{paymentOrder.checkout.mode === "redirect" ? "重新打开支付宝" : "重新生成二维码"}
|
||||
{paymentOrder.checkout.mode === "redirect" ? "重新打开收银台" : "重新生成二维码"}
|
||||
</Button>,
|
||||
]
|
||||
: [
|
||||
@@ -555,7 +555,7 @@ export default function WalletPage() {
|
||||
{paymentOrder.status === "pending" && paymentOrder.checkout.mode === "qr_code" && paymentOrder.checkout.value ? (
|
||||
<div className="mt-5 flex flex-col items-center">
|
||||
<QRCode value={paymentOrder.checkout.value} size={208} bordered={false} />
|
||||
<p className="mt-3 text-sm font-medium">请打开微信扫一扫完成支付</p>
|
||||
<p className="mt-3 text-sm font-medium">请使用支付应用扫码完成支付</p>
|
||||
<p className="mt-1 text-xs text-foreground/45">请勿保存二维码后长按识别</p>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -646,7 +646,7 @@ function ledgerTitle(entry: CreditLedgerEntry) {
|
||||
function PaymentBrandIcon({ providerId, size = "normal" }: { providerId: string; size?: "normal" | "large" }) {
|
||||
const className = size === "large" ? "text-3xl" : "text-xl";
|
||||
if (providerId === "wechat-native") return <WechatFilled className={`${className} text-[#07C160]`} aria-label="微信支付" />;
|
||||
return <AlipayCircleFilled className={`${className} text-[#1677FF]`} aria-label="支付宝" />;
|
||||
return <CreditCard className={`${className} text-foreground/60`} aria-label="支付" />;
|
||||
}
|
||||
|
||||
function PaymentOrderStatus({ order, now }: { order: PaymentOrder; now: number }) {
|
||||
@@ -654,7 +654,7 @@ function PaymentOrderStatus({ order, now }: { order: PaymentOrder; now: number }
|
||||
if (order.status === "credited") return <div className="mt-5 rounded-lg bg-emerald-500/8 px-4 py-3 text-sm font-medium text-emerald-600 dark:text-emerald-300">支付已确认,积分已经到账</div>;
|
||||
if (order.status === "closed") return <div className="mt-5 rounded-lg bg-foreground/5 px-4 py-3 text-sm text-foreground/60">订单已关闭,未产生积分充值</div>;
|
||||
if (order.status === "create_failed") {
|
||||
const action = order.checkout.mode === "redirect" ? "重新打开支付宝收银台" : "重新生成支付二维码";
|
||||
const action = order.checkout.mode === "redirect" ? "重新打开收银台" : "重新生成支付二维码";
|
||||
return <div className="mt-5 rounded-lg bg-rose-500/8 px-4 py-3 text-sm text-rose-600 dark:text-rose-300">创建渠道订单失败,可使用下方按钮{action}</div>;
|
||||
}
|
||||
return <div className="mt-5 text-xs text-foreground/48">订单剩余支付时间 {formatCountdown(remainingSeconds)},页面将自动确认支付结果</div>;
|
||||
|
||||
Reference in New Issue
Block a user