diff --git a/backend/cmd/payment-alipay/main.go b/backend/cmd/payment-alipay/main.go new file mode 100644 index 000000000..5b1a7a105 --- /dev/null +++ b/backend/cmd/payment-alipay/main.go @@ -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) + } +} diff --git a/backend/cmd/payment-wechat/main.go b/backend/cmd/payment-wechat/main.go new file mode 100644 index 000000000..5b8452276 --- /dev/null +++ b/backend/cmd/payment-wechat/main.go @@ -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) + } +} diff --git a/backend/internal/handler/payment.go b/backend/internal/handler/payment.go index 3c633d09c..14cb2e6da 100644 --- a/backend/internal/handler/payment.go +++ b/backend/internal/handler/payment.go @@ -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)}) } diff --git a/backend/internal/model/models_payment.go b/backend/internal/model/models_payment.go index c6898216e..2996617af 100644 --- a/backend/internal/model/models_payment.go +++ b/backend/internal/model/models_payment.go @@ -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"` diff --git a/backend/internal/payment/registry.go b/backend/internal/payment/registry.go index c8dc9c312..a097febe7 100644 --- a/backend/internal/payment/registry.go +++ b/backend/internal/payment/registry.go @@ -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 +} diff --git a/backend/internal/payment/rpc.go b/backend/internal/payment/rpc.go new file mode 100644 index 000000000..2205e401e --- /dev/null +++ b/backend/internal/payment/rpc.go @@ -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 +} diff --git a/backend/internal/payment/rpc_test.go b/backend/internal/payment/rpc_test.go new file mode 100644 index 000000000..66c27fa57 --- /dev/null +++ b/backend/internal/payment/rpc_test.go @@ -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) + } +} diff --git a/backend/internal/payment/types.go b/backend/internal/payment/types.go index 3f5aaf191..3e54ea26b 100644 --- a/backend/internal/payment/types.go +++ b/backend/internal/payment/types.go @@ -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 } diff --git a/backend/internal/protocol/manifest.go b/backend/internal/protocol/manifest.go index a99b8c02d..2aa438b1f 100644 --- a/backend/internal/protocol/manifest.go +++ b/backend/internal/protocol/manifest.go @@ -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 } diff --git a/backend/internal/protocol/official_catalog_test.go b/backend/internal/protocol/official_catalog_test.go index 68c8c04c5..7c7ef3b2e 100644 --- a/backend/internal/protocol/official_catalog_test.go +++ b/backend/internal/protocol/official_catalog_test.go @@ -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) } diff --git a/backend/internal/protocol/package.go b/backend/internal/protocol/package.go index 3594a2ee5..262994b0c 100644 --- a/backend/internal/protocol/package.go +++ b/backend/internal/protocol/package.go @@ -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") diff --git a/backend/internal/protocol/types.go b/backend/internal/protocol/types.go index 3178241d0..1d2680140 100644 --- a/backend/internal/protocol/types.go +++ b/backend/internal/protocol/types.go @@ -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"` diff --git a/backend/internal/repository/payment.go b/backend/internal/repository/payment.go index 10b4ec43c..7fc156dbc 100644 --- a/backend/internal/repository/payment.go +++ b/backend/internal/repository/payment.go @@ -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, diff --git a/backend/internal/repository/payment_test.go b/backend/internal/repository/payment_test.go index c2682000c..158461580 100644 --- a/backend/internal/repository/payment_test.go +++ b/backend/internal/repository/payment_test.go @@ -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) diff --git a/backend/internal/service/payment.go b/backend/internal/service/payment.go index a416c1a25..01cc6954a 100644 --- a/backend/internal/service/payment.go +++ b/backend/internal/service/payment.go @@ -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) diff --git a/backend/internal/service/payment_plugin_lifecycle_test.go b/backend/internal/service/payment_plugin_lifecycle_test.go new file mode 100644 index 000000000..d75741b74 --- /dev/null +++ b/backend/internal/service/payment_plugin_lifecycle_test.go @@ -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) + } +} diff --git a/backend/internal/service/payment_plugins.go b/backend/internal/service/payment_plugins.go index 54c96e94d..90ba3fc31 100644 --- a/backend/internal/service/payment_plugins.go +++ b/backend/internal/service/payment_plugins.go @@ -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: "用于生成微信支付回调地址,必须可被微信访问。"}, diff --git a/backend/internal/service/payment_plugins_test.go b/backend/internal/service/payment_plugins_test.go index 472c43ad4..d790b0001 100644 --- a/backend/internal/service/payment_plugins_test.go +++ b/backend/internal/service/payment_plugins_test.go @@ -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, diff --git a/backend/internal/service/protocol_plugins.go b/backend/internal/service/protocol_plugins.go index e12111d4c..1b5188f47 100644 --- a/backend/internal/service/protocol_plugins.go +++ b/backend/internal/service/protocol_plugins.go @@ -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() diff --git a/backend/internal/service/protocol_registry.go b/backend/internal/service/protocol_registry.go index 65473cf34..301e08b18 100644 --- a/backend/internal/service/protocol_registry.go +++ b/backend/internal/service/protocol_registry.go @@ -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 { diff --git a/backend/internal/service/protocol_runtime_test.go b/backend/internal/service/protocol_runtime_test.go index 89e431399..195d87a89 100644 --- a/backend/internal/service/protocol_runtime_test.go +++ b/backend/internal/service/protocol_runtime_test.go @@ -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 { diff --git a/backend/internal/service/service.go b/backend/internal/service/service.go index 41d87e3cc..90309ffae 100644 --- a/backend/internal/service/service.go +++ b/backend/internal/service/service.go @@ -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) diff --git a/backend/payment-plugins/aliases.go b/backend/payment-plugins/aliases.go new file mode 100644 index 000000000..bdf820b19 --- /dev/null +++ b/backend/payment-plugins/aliases.go @@ -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" +) diff --git a/backend/internal/payment/alipay.go b/backend/payment-plugins/alipay.go similarity index 97% rename from backend/internal/payment/alipay.go rename to backend/payment-plugins/alipay.go index fea316cba..52804b00c 100644 --- a/backend/internal/payment/alipay.go +++ b/backend/payment-plugins/alipay.go @@ -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 { diff --git a/backend/internal/payment/bill.go b/backend/payment-plugins/bill.go similarity index 99% rename from backend/internal/payment/bill.go rename to backend/payment-plugins/bill.go index c2c4b881d..76b590d00 100644 --- a/backend/internal/payment/bill.go +++ b/backend/payment-plugins/bill.go @@ -1,4 +1,4 @@ -package payment +package paymentplugins import ( "archive/zip" diff --git a/backend/internal/payment/bill_test.go b/backend/payment-plugins/bill_test.go similarity index 99% rename from backend/internal/payment/bill_test.go rename to backend/payment-plugins/bill_test.go index 8748e1ab7..6ed16e7ee 100644 --- a/backend/internal/payment/bill_test.go +++ b/backend/payment-plugins/bill_test.go @@ -1,4 +1,4 @@ -package payment +package paymentplugins import ( "archive/zip" diff --git a/backend/internal/payment/crypto.go b/backend/payment-plugins/crypto.go similarity index 99% rename from backend/internal/payment/crypto.go rename to backend/payment-plugins/crypto.go index 7bddc2238..61499335a 100644 --- a/backend/internal/payment/crypto.go +++ b/backend/payment-plugins/crypto.go @@ -1,4 +1,4 @@ -package payment +package paymentplugins import ( "crypto" diff --git a/backend/internal/payment/provider_test.go b/backend/payment-plugins/provider_test.go similarity index 99% rename from backend/internal/payment/provider_test.go rename to backend/payment-plugins/provider_test.go index 52d63cbdd..2f57ee19b 100644 --- a/backend/internal/payment/provider_test.go +++ b/backend/payment-plugins/provider_test.go @@ -1,4 +1,4 @@ -package payment +package paymentplugins import ( "context" diff --git a/backend/payment-plugins/rpc_runner.go b/backend/payment-plugins/rpc_runner.go new file mode 100644 index 000000000..f877af142 --- /dev/null +++ b/backend/payment-plugins/rpc_runner.go @@ -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) +} diff --git a/backend/internal/payment/wechat.go b/backend/payment-plugins/wechat.go similarity index 97% rename from backend/internal/payment/wechat.go rename to backend/payment-plugins/wechat.go index 0f0907edf..1be781c63 100644 --- a/backend/internal/payment/wechat.go +++ b/backend/payment-plugins/wechat.go @@ -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 { diff --git a/backend/payment-sdk/types.go b/backend/payment-sdk/types.go new file mode 100644 index 000000000..dcc3bde66 --- /dev/null +++ b/backend/payment-sdk/types.go @@ -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 } diff --git a/docs/content/docs/backend/backend-database.mdx b/docs/content/docs/backend/backend-database.mdx index d1a86c782..820773f09 100644 --- a/docs/content/docs/backend/backend-database.mdx +++ b/docs/content/docs/backend/backend-database.mdx @@ -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`;存在未完成订单时,原插件版本和配置必须继续可解析通知、查单和关单。 + ## 模型渠道与目录 | 表 | 用途 | 关键边界 | diff --git a/docs/content/docs/plugins/payment-plugin-runtime.mdx b/docs/content/docs/plugins/payment-plugin-runtime.mdx new file mode 100644 index 000000000..9ad37b0ad --- /dev/null +++ b/docs/content/docs/plugins/payment-plugin-runtime.mdx @@ -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 执行器和通用网络白名单将在后续安全阶段加入。 diff --git a/plugin-packages/build-packages.sh b/plugin-packages/build-packages.sh index 8b04e36f5..4ff05ad4b 100755 --- a/plugin-packages/build-packages.sh +++ b/plugin-packages/build-packages.sh @@ -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 diff --git a/plugin-packages/official-payment-alipay-page.yingce-plugin b/plugin-packages/official-payment-alipay-page.yingce-plugin new file mode 100644 index 000000000..33725630b Binary files /dev/null and b/plugin-packages/official-payment-alipay-page.yingce-plugin differ diff --git a/plugin-packages/official-payment-alipay-page/README.md b/plugin-packages/official-payment-alipay-page/README.md new file mode 100644 index 000000000..9f790cbd1 --- /dev/null +++ b/plugin-packages/official-payment-alipay-page/README.md @@ -0,0 +1,3 @@ +# 支付宝电脑网站支付 + +官方 `yingce.payment/v1` 支付插件。后端入口由构建脚本编译为 `backend/provider`。 diff --git a/plugin-packages/official-payment-alipay-page/backend/provider b/plugin-packages/official-payment-alipay-page/backend/provider new file mode 100755 index 000000000..96db21374 Binary files /dev/null and b/plugin-packages/official-payment-alipay-page/backend/provider differ diff --git a/plugin-packages/official-payment-alipay-page/docs/interface.md b/plugin-packages/official-payment-alipay-page/docs/interface.md new file mode 100644 index 000000000..7764d6a8e --- /dev/null +++ b/plugin-packages/official-payment-alipay-page/docs/interface.md @@ -0,0 +1,109 @@ +## yingce.payment/v1 + +支持 `validate_config`、`create_order`、`query_order`、`close_order`、`verify_notification` 和 `download_trade_bill`,统一返回 JSON 响应。 + + +## 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 递归,此处不重复展开正文。>" +} +``` + diff --git a/plugin-packages/official-payment-alipay-page/manifest.json b/plugin-packages/official-payment-alipay-page/manifest.json new file mode 100644 index 000000000..e7724ccf4 --- /dev/null +++ b/plugin-packages/official-payment-alipay-page/manifest.json @@ -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\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\n" +} diff --git a/plugin-packages/official-payment-wechat-native.yingce-plugin b/plugin-packages/official-payment-wechat-native.yingce-plugin new file mode 100644 index 000000000..5eac518c6 Binary files /dev/null and b/plugin-packages/official-payment-wechat-native.yingce-plugin differ diff --git a/plugin-packages/official-payment-wechat-native/README.md b/plugin-packages/official-payment-wechat-native/README.md new file mode 100644 index 000000000..49692037c --- /dev/null +++ b/plugin-packages/official-payment-wechat-native/README.md @@ -0,0 +1,3 @@ +# 微信支付 Native + +官方 `yingce.payment/v1` 支付插件。后端入口由构建脚本编译为 `backend/provider`。 diff --git a/plugin-packages/official-payment-wechat-native/backend/provider b/plugin-packages/official-payment-wechat-native/backend/provider new file mode 100755 index 000000000..d2d1f824b Binary files /dev/null and b/plugin-packages/official-payment-wechat-native/backend/provider differ diff --git a/plugin-packages/official-payment-wechat-native/docs/interface.md b/plugin-packages/official-payment-wechat-native/docs/interface.md new file mode 100644 index 000000000..adec4c388 --- /dev/null +++ b/plugin-packages/official-payment-wechat-native/docs/interface.md @@ -0,0 +1,117 @@ +## yingce.payment/v1 + +支持 `validate_config`、`create_order`、`query_order`、`close_order`、`verify_notification` 和 `download_trade_bill`,统一返回 JSON 响应。 + + +## 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 递归,此处不重复展开正文。>" +} +``` + diff --git a/plugin-packages/official-payment-wechat-native/manifest.json b/plugin-packages/official-payment-wechat-native/manifest.json new file mode 100644 index 000000000..e5130a27b --- /dev/null +++ b/plugin-packages/official-payment-wechat-native/manifest.json @@ -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\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\n" +} diff --git a/web/src/pages/wallet/index.tsx b/web/src/pages/wallet/index.tsx index 51f0fbd37..220087368 100644 --- a/web/src/pages/wallet/index.tsx +++ b/web/src/pages/wallet/index.tsx @@ -455,12 +455,12 @@ export default function WalletPage() { onClick={() => setSelectedProviderId(provider.id)} > - {provider.id === "wechat-native" ? "微信支付" : "支付宝"} + {provider.name} ))} @@ -514,7 +514,7 @@ export default function WalletPage() { setPaymentModalOpen(false)} footer={ paymentOrder?.status === "pending" @@ -532,7 +532,7 @@ export default function WalletPage() { 稍后处理 , , ] : [ @@ -555,7 +555,7 @@ export default function WalletPage() { {paymentOrder.status === "pending" && paymentOrder.checkout.mode === "qr_code" && paymentOrder.checkout.value ? (
-

请打开微信扫一扫完成支付

+

请使用支付应用扫码完成支付

请勿保存二维码后长按识别

) : 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 ; - return ; + return ; } 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
支付已确认,积分已经到账
; if (order.status === "closed") return
订单已关闭,未产生积分充值
; if (order.status === "create_failed") { - const action = order.checkout.mode === "redirect" ? "重新打开支付宝收银台" : "重新生成支付二维码"; + const action = order.checkout.mode === "redirect" ? "重新打开收银台" : "重新生成支付二维码"; return
创建渠道订单失败,可使用下方按钮{action}
; } return
订单剩余支付时间 {formatCountdown(remainingSeconds)},页面将自动确认支付结果
;