diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 803b0b0e7..f5bee3642 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -35,7 +35,7 @@ jobs: - name: Verify canonical payment packages env: PAYMENT_PLUGIN_SMOKE_TEST: "1" - run: sh plugin-packages/verify-payment-packages.sh linux amd64 + run: sh plugin-packages/verify-payment-packages.sh --all linux amd64 - uses: docker/setup-qemu-action@v3 - uses: docker/setup-buildx-action@v3 - name: Build and execute payment packages for every image platform diff --git a/backend/cmd/payment-xunhupay/main.go b/backend/cmd/payment-xunhupay/main.go new file mode 100644 index 000000000..a12ba5cc6 --- /dev/null +++ b/backend/cmd/payment-xunhupay/main.go @@ -0,0 +1,16 @@ +package main + +import ( + "net/http" + "os" + "time" + + paymentplugins "infinite-canvas/backend/payment-plugins" +) + +func main() { + provider := paymentplugins.NewXunHuPayProvider(&http.Client{Timeout: 25 * time.Second}) + if err := paymentplugins.RunRPC(provider, os.Stdin, os.Stdout); err != nil { + os.Exit(1) + } +} diff --git a/backend/internal/app/payment.go b/backend/internal/app/payment.go index d7260b7d4..b51fed643 100644 --- a/backend/internal/app/payment.go +++ b/backend/internal/app/payment.go @@ -187,7 +187,7 @@ func (s *Service) AdminPaymentProviders(actor *model.User) ([]AdminPaymentProvid if err != nil { return nil, err } - manifest, _ := paymentManifestForProvider(descriptor.ID) + manifest, _ := s.paymentManifestForProvider(descriptor.ID) view := AdminPaymentProviderView{PaymentProviderView: base, Values: map[string]string{}, SecretConfigured: map[string]bool{}, ConfigFields: manifest.Configuration.Fields} if config != nil { values, err := s.decryptPaymentConfig(config) @@ -220,11 +220,14 @@ func (s *Service) UpdatePaymentProviderConfig(actor *model.User, providerID stri return nil, BadAuthRequest("未知支付渠道") } descriptor := provider.Descriptor() - manifest, ok := paymentManifestForProvider(descriptor.ID) + manifest, ok := s.paymentManifestForProvider(descriptor.ID) + if !ok { + return nil, BadAuthRequest("支付插件清单不存在") + } + policy, ok := paymentExpiryPolicy(manifest, descriptor.ID) if !ok { return nil, BadAuthRequest("支付插件清单不存在") } - policy := manifest.Contributes.PaymentProviders[0].ExpiryPolicy if request.CloseAfterMinutes < policy.MinMinutes || request.CloseAfterMinutes > policy.MaxMinutes { return nil, BadAuthRequest(fmt.Sprintf("未支付关闭时间必须为 %d-%d 分钟", policy.MinMinutes, policy.MaxMinutes)) } @@ -318,8 +321,10 @@ func (s *Service) UpdatePaymentProviderConfig(actor *model.User, providerID stri 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 { - view.CloseAfterMinutes = manifest.Contributes.PaymentProviders[0].ExpiryPolicy.DefaultMinutes + if manifest, ok := s.paymentManifestForProvider(descriptor.ID); ok { + if policy, found := paymentExpiryPolicy(manifest, descriptor.ID); found { + view.CloseAfterMinutes = policy.DefaultMinutes + } } state, err := s.pluginStateForUser(nil, descriptor.PluginID, s.Plugins()) if err == nil { @@ -339,7 +344,24 @@ func (s *Service) paymentProviderView(descriptor payment.Descriptor) (PaymentPro return view, config, nil } -func paymentManifestForProvider(providerID string) (protocol.Manifest, bool) { +func (s *Service) paymentManifestForProvider(providerID string) (protocol.Manifest, bool) { + providerID = strings.TrimSpace(providerID) + if providerID == "" { + return protocol.Manifest{}, false + } + if s != nil { + for _, plugin := range s.Plugins() { + for _, contribution := range plugin.Manifest.Contributes.PaymentProviders { + if contribution.ID == providerID { + return protocolManifestFromPluginView(plugin), true + } + } + } + } + return bundledPaymentManifestForProvider(providerID) +} + +func bundledPaymentManifestForProvider(providerID string) (protocol.Manifest, bool) { for _, manifest := range bundledPaymentPluginManifests() { for _, contribution := range manifest.Contributes.PaymentProviders { if contribution.ID == providerID { @@ -350,6 +372,34 @@ func paymentManifestForProvider(providerID string) (protocol.Manifest, bool) { return protocol.Manifest{}, false } +func protocolManifestFromPluginView(plugin PluginView) protocol.Manifest { + return protocol.Manifest{ + APIVersion: plugin.Manifest.APIVersion, + Metadata: protocol.Metadata{ + ID: plugin.Manifest.ID, Version: plugin.Manifest.Version, Name: plugin.Manifest.Name, + Vendor: plugin.Manifest.Author, Description: plugin.Manifest.Description, + Documentation: plugin.Manifest.Documentation, + }, + Surfaces: plugin.Manifest.Surfaces, + Runtime: plugin.Manifest.Runtime, + Permissions: plugin.Manifest.Permissions, + Configuration: plugin.Manifest.Configuration, + Contributes: plugin.Manifest.Contributes, + } +} + +func paymentExpiryPolicy(manifest protocol.Manifest, providerID string) (protocol.ManifestPaymentExpiryPolicy, bool) { + for _, contribution := range manifest.Contributes.PaymentProviders { + if contribution.ID == providerID { + return contribution.ExpiryPolicy, true + } + } + if len(manifest.Contributes.PaymentProviders) == 1 { + return manifest.Contributes.PaymentProviders[0].ExpiryPolicy, true + } + return protocol.ManifestPaymentExpiryPolicy{}, false +} + func (s *Service) decryptPaymentConfig(config *model.PaymentProviderConfig) (payment.Config, error) { if config == nil { return nil, errors.New("支付渠道配置不存在") diff --git a/backend/internal/app/payment_plugins_test.go b/backend/internal/app/payment_plugins_test.go index 30d1b67b3..e28ab2d90 100644 --- a/backend/internal/app/payment_plugins_test.go +++ b/backend/internal/app/payment_plugins_test.go @@ -1,6 +1,7 @@ package app import ( + "errors" "testing" "infinite-canvas/backend/internal/payment" @@ -59,7 +60,7 @@ func TestSystemPaymentPluginsRegisterRPCProviders(t *testing.T) { if registry == nil { t.Fatal("payment registry is nil") } - for _, providerID := range []string{PaymentProviderAlipay, PaymentProviderWeChat} { + for _, providerID := range []string{PaymentProviderAlipay, PaymentProviderWeChat, "xunhupay-aggregate"} { provider, ok := registry.Get(providerID) if !ok { t.Fatalf("payment provider %q is missing", providerID) @@ -70,6 +71,14 @@ func TestSystemPaymentPluginsRegisterRPCProviders(t *testing.T) { if provider.Descriptor().PluginID == "" || provider.Descriptor().PluginVersion == "" { t.Fatalf("payment provider %q is missing plugin identity: %#v", providerID, provider.Descriptor()) } + err := provider.ValidateConfig(nil) + var providerErr *payment.ProviderError + if !errors.As(err, &providerErr) { + t.Fatalf("provider %q ValidateConfig() = %T %v, want ProviderError", providerID, err, err) + } + if providerErr.Code == "plugin_executable_missing" || providerErr.Code == "plugin_exec_format_error" || providerErr.Code == "plugin_start_failed" { + t.Fatalf("provider %q failed to start: %#v cause=%v", providerID, providerErr, providerErr.Cause) + } } } @@ -83,7 +92,7 @@ func TestPaymentRegistryFailsClosedWhenOfficialPackagesAreMissing(t *testing.T) if registry == nil { t.Fatal("payment registry is nil") } - for _, providerID := range []string{PaymentProviderAlipay, PaymentProviderWeChat} { + for _, providerID := range []string{PaymentProviderAlipay, PaymentProviderWeChat, "xunhupay-aggregate"} { if _, ok := registry.Get(providerID); ok { t.Fatalf("payment provider %q unexpectedly came from host fallback", providerID) } @@ -107,3 +116,38 @@ func TestTopupProductCreditAmountStaysWithinSafeLimit(t *testing.T) { t.Fatalf("maximum safe top-up credits: %v", err) } } + +func TestXunHuPayOfficialPackageIsPaymentPlugin(t *testing.T) { + center, err := newPluginRuntime(t.TempDir()) + if err != nil { + t.Fatal(err) + } + var plugin PluginView + for _, item := range center.list() { + if item.Manifest.ID == "official-payment-xunhupay" { + plugin = item + plugin.Management = pluginManagementFromView(item) + break + } + } + if plugin.Manifest.ID == "" { + t.Fatal("official-payment-xunhupay is missing") + } + if plugin.Management.Kind != PluginKindPayment || plugin.Source != PluginOriginOfficial { + t.Fatalf("xunhupay plugin = %#v", plugin) + } + if len(plugin.Manifest.Contributes.PaymentProviders) != 1 || plugin.Manifest.Contributes.PaymentProviders[0].ID != "xunhupay-aggregate" { + t.Fatalf("xunhupay contributions = %#v", plugin.Manifest.Contributes.PaymentProviders) + } + registry := center.paymentRegistrySnapshot() + if registry == nil { + t.Fatal("payment registry is nil") + } + provider, ok := registry.Get("xunhupay-aggregate") + if !ok { + t.Fatal("xunhupay-aggregate provider is missing") + } + if _, ok := provider.(*payment.RPCProvider); !ok { + t.Fatalf("xunhupay provider type = %T", provider) + } +} diff --git a/backend/internal/app/payment_runtime_cache_test.go b/backend/internal/app/payment_runtime_cache_test.go index 786ae1c0d..ea9464b5e 100644 --- a/backend/internal/app/payment_runtime_cache_test.go +++ b/backend/internal/app/payment_runtime_cache_test.go @@ -139,12 +139,47 @@ func TestPaymentRuntimeRejectsInvalidDigest(t *testing.T) { } } +func TestPaymentRuntimeReadyAcceptsTaggedBackend(t *testing.T) { + packageData := paymentRuntimePackageFiles(t, map[string][]byte{"backend/provider-darwin-arm64": []byte("darwin-provider")}) + pkg, err := protocol.ParsePluginPackage(packageData) + if err != nil { + t.Fatal(err) + } + packageDir := t.TempDir() + runtimeDir, err := materializePaymentBackend(packageDir, pluginHash(packageData), pkg) + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(runtimeDir, "backend", "provider")); !os.IsNotExist(err) { + t.Fatalf("canonical provider should be absent: %v", err) + } + actual, err := os.ReadFile(filepath.Join(runtimeDir, "backend", "provider-darwin-arm64")) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(actual, []byte("darwin-provider")) { + t.Fatalf("tagged runtime provider = %q", actual) + } + if !paymentRuntimeReady(runtimeDir, pluginHash(packageData), "backend/provider") { + t.Fatal("tagged payment runtime was not marked ready") + } +} + func paymentRuntimePackage(t *testing.T, provider []byte) []byte { + t.Helper() + return paymentRuntimePackageFiles(t, map[string][]byte{"backend/provider": provider}) +} + +func paymentRuntimePackageFiles(t *testing.T, files map[string][]byte) []byte { t.Helper() manifest := []byte(`{"apiVersion":"yingce.plugin/v1","id":"test-payment-runtime","version":"1.0.0","name":"Test Payment Runtime","author":"Test","enabled":true,"runtime":{"backend":"rpc","backendEntry":"backend/provider"},"contributes":{"paymentProviders":[{"id":"test-payment","label":"Test Payment","icon":"brand:test","checkoutMode":"redirect","expiryPolicy":{"defaultMinutes":30,"minMinutes":5,"maxMinutes":1440}}]}}`) + payload := map[string][]byte{"manifest.json": manifest} + for name, content := range files { + payload[name] = content + } var buffer bytes.Buffer writer := zip.NewWriter(&buffer) - for name, content := range map[string][]byte{"manifest.json": manifest, "backend/provider": provider} { + for name, content := range payload { file, err := writer.Create(name) if err != nil { t.Fatal(err) diff --git a/backend/internal/app/plugin_management.go b/backend/internal/app/plugin_management.go index c06437d73..ed4245fb4 100644 --- a/backend/internal/app/plugin_management.go +++ b/backend/internal/app/plugin_management.go @@ -118,6 +118,21 @@ func pluginManagement(pluginID string, source string) PluginManagementView { } } +func pluginManagementFromView(plugin PluginView) PluginManagementView { + policy := pluginManagement(plugin.Manifest.ID, plugin.Source) + if policy.Kind != PluginKindProtocol || len(plugin.Manifest.Contributes.PaymentProviders) == 0 { + return policy + } + origin := strings.TrimSpace(plugin.Source) + if origin == "" { + origin = PluginOriginOfficial + } + return PluginManagementView{ + Origin: origin, Kind: PluginKindPayment, + ActivationScope: PluginScopeSystem, ConfigurationScope: PluginConfigurationSystem, + } +} + func knownPluginIDs(items []PluginView) []string { seen := make(map[string]struct{}, len(items)+len(officialApplicationPolicies)+len(systemPaymentPolicies)) ids := make([]string, 0, len(items)+len(officialApplicationPolicies)+len(systemPaymentPolicies)) diff --git a/backend/internal/app/plugin_management_test.go b/backend/internal/app/plugin_management_test.go index 4a2672bf5..482dd0df3 100644 --- a/backend/internal/app/plugin_management_test.go +++ b/backend/internal/app/plugin_management_test.go @@ -4,6 +4,7 @@ import ( "testing" "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/protocol" "infinite-canvas/backend/internal/repository" "gorm.io/driver/sqlite" @@ -70,6 +71,22 @@ func TestUploadedManifestCannotClaimUserActivationScope(t *testing.T) { } } +func TestPaymentContributionInfersPaymentManagementKind(t *testing.T) { + plugin := PluginView{ + Source: PluginOriginOfficial, + Manifest: PluginManifestView{ + ID: "official-payment-xunhupay", + Contributes: protocol.ManifestContributions{PaymentProviders: []protocol.ManifestPaymentProvider{{ + ID: "xunhupay-aggregate", Label: "虎皮椒聚合支付", Icon: "assets/icon.svg", CheckoutMode: "qr_code", + }}}, + }, + } + policy := pluginManagementFromView(plugin) + if policy.Kind != PluginKindPayment || policy.Origin != PluginOriginOfficial || policy.ActivationScope != PluginScopeSystem { + t.Fatalf("inferred payment policy = %#v", policy) + } +} + func TestArtCritiqueIsUserToggleableApplication(t *testing.T) { policy := pluginManagement(PluginAIArtCritique, "bundled") if policy.Origin != PluginOriginOfficial || policy.Kind != PluginKindApplication || policy.ActivationScope != PluginScopeUser || policy.ConfigurationScope != PluginConfigurationNone { diff --git a/backend/internal/app/protocol_plugins.go b/backend/internal/app/protocol_plugins.go index a7b75be8c..976ec7add 100644 --- a/backend/internal/app/protocol_plugins.go +++ b/backend/internal/app/protocol_plugins.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "io/fs" "os" "path/filepath" "sort" @@ -91,6 +92,15 @@ type pluginRegistryRecord struct { } func newPluginRuntime(dataDir string) (*pluginRuntime, error) { + dataDir = strings.TrimSpace(dataDir) + if dataDir == "" { + return nil, errors.New("plugin data directory is empty") + } + var err error + dataDir, err = filepath.Abs(dataDir) + if err != nil { + return nil, fmt.Errorf("plugin data directory is invalid: %w", err) + } if err := os.MkdirAll(dataDir, 0o755); err != nil { return nil, fmt.Errorf("create plugin registry directory: %w", err) } @@ -519,8 +529,24 @@ func paymentRuntimeReady(root, digest, backendEntry string) bool { if err != nil || strings.TrimSpace(string(ready)) != digest { return false } - entry, err := os.Stat(filepath.Join(root, filepath.FromSlash(backendEntry))) - return err == nil && !entry.IsDir() + return protocol.HasAnyPaymentRPCBackend(backendEntry, paymentRuntimeBackendFiles(root)) +} + +func paymentRuntimeBackendFiles(root string) map[string][]byte { + files := map[string][]byte{} + backendRoot := filepath.Join(root, "backend") + _ = filepath.WalkDir(backendRoot, func(path string, entry fs.DirEntry, err error) error { + if err != nil || entry.IsDir() { + return err + } + rel, relErr := filepath.Rel(root, path) + if relErr != nil { + return nil + } + files[filepath.ToSlash(rel)] = []byte{1} + return nil + }) + return files } func (c *pluginRuntime) list() []PluginView { diff --git a/backend/internal/app/protocol_registry.go b/backend/internal/app/protocol_registry.go index a27141eb5..e1271b993 100644 --- a/backend/internal/app/protocol_registry.go +++ b/backend/internal/app/protocol_registry.go @@ -137,7 +137,7 @@ func (s *Service) Plugins() []PluginView { } items := s.pluginRuntime.list() for index := range items { - items[index].Management = pluginManagement(items[index].Manifest.ID, items[index].Source) + items[index].Management = pluginManagementFromView(items[index]) } return items } @@ -202,7 +202,7 @@ func (s *Service) InstallPluginForAdmin(actor *model.User, data []byte, fileName if err != nil { return PluginView{}, err } - plugin.Management = pluginManagement(plugin.Manifest.ID, plugin.Source) + plugin.Management = pluginManagementFromView(plugin) now := time.Now() state := &model.PluginPlatformState{PluginID: plugin.Manifest.ID, Available: plugin.Status == "enabled", UpdatedBy: actor.ID, CreatedAt: now, UpdatedAt: now} if err := s.repo.SavePluginPlatformState(state); err != nil { diff --git a/backend/internal/database/migrations.go b/backend/internal/database/migrations.go index f1b8ef549..311955de9 100644 --- a/backend/internal/database/migrations.go +++ b/backend/internal/database/migrations.go @@ -11,7 +11,7 @@ import ( "gorm.io/gorm" ) -const CurrentSchemaVersion int64 = 18 +const CurrentSchemaVersion int64 = 19 const baselineSchemaChecksum = "sha256:open-ai-canvas-schema-v1-20260830" const schemaMigrationAppliedAtIndexChecksum = "sha256:schema-migrations-applied-at-index-v2-20260830" @@ -76,6 +76,7 @@ var schemaMigrations = []migration{ {version: 18, name: "agent_memory_settings", checksum: "sha256:agent-memory-settings-v18-20260917", apply: func(tx *gorm.DB) error { return tx.AutoMigrate(&model.AgentMemorySetting{}) }}, + {version: 19, name: "payment_plugin_version", checksum: "sha256:payment-plugin-version-v19-20260917", apply: migrateSchemaV19}, } func migrateSchemaV14(tx *gorm.DB) error { @@ -239,6 +240,28 @@ func migrateSchemaV5(tx *gorm.DB) error { return nil } +func migrateSchemaV19(tx *gorm.DB) error { + for _, value := range []any{&model.PaymentProviderConfig{}, &model.PaymentOrder{}} { + if !tx.Migrator().HasTable(value) { + continue + } + if err := addPaymentPluginVersionColumn(tx, value); err != nil { + return err + } + } + return nil +} + +func addPaymentPluginVersionColumn(tx *gorm.DB, value any) error { + if tx.Migrator().HasColumn(value, "plugin_version") { + return nil + } + if err := tx.Migrator().AddColumn(value, "PluginVersion"); err != nil { + return fmt.Errorf("增加支付插件版本列:%w", err) + } + return nil +} + func migrateSchemaV6(tx *gorm.DB) error { if !tx.Migrator().HasTable(&model.Resource{}) { return fmt.Errorf("资源表不存在") diff --git a/backend/internal/database/migrations_test.go b/backend/internal/database/migrations_test.go index 42c0c7c76..d1b8359c4 100644 --- a/backend/internal/database/migrations_test.go +++ b/backend/internal/database/migrations_test.go @@ -44,6 +44,9 @@ func TestMigrateSchemaRecordsAndValidatesVersion(t *testing.T) { if !db.Migrator().HasTable(&model.AgentMemorySetting{}) { t.Fatal("schema migration v18 did not create agent memory settings") } + if !db.Migrator().HasColumn(&model.PaymentProviderConfig{}, "plugin_version") || !db.Migrator().HasColumn(&model.PaymentOrder{}, "plugin_version") { + t.Fatal("schema migration v19 did not add payment plugin version columns") + } if err := MigrateSchema(db); err != nil { t.Fatalf("migration should be idempotent: %v", err) } @@ -153,6 +156,38 @@ func TestMigrateSchemaV18UpgradesExistingDatabase(t *testing.T) { } } +func TestMigrateSchemaV19AddsPaymentPluginVersion(t *testing.T) { + db, err := Open(Config{Driver: "sqlite", DSN: "file:migration-payment-plugin-version-v19?mode=memory&cache=shared"}) + if err != nil { + t.Fatal(err) + } + if err := MigrateSchema(db); err != nil { + t.Fatal(err) + } + if err := db.Migrator().DropColumn(&model.PaymentProviderConfig{}, "PluginVersion"); err != nil { + t.Fatal(err) + } + if err := db.Migrator().DropColumn(&model.PaymentOrder{}, "PluginVersion"); err != nil { + t.Fatal(err) + } + if err := db.Where("version = ?", 19).Delete(&schemaMigration{}).Error; err != nil { + t.Fatal(err) + } + if err := MigrateSchema(db); err != nil { + t.Fatalf("upgrade from v18: %v", err) + } + if !db.Migrator().HasColumn(&model.PaymentProviderConfig{}, "plugin_version") { + t.Fatal("v19 upgrade did not add payment_provider_configs.plugin_version") + } + if !db.Migrator().HasColumn(&model.PaymentOrder{}, "plugin_version") { + t.Fatal("v19 upgrade did not add payment_orders.plugin_version") + } + status, err := ReadSchemaStatus(db) + if err != nil || !status.Ready || status.Current != CurrentSchemaVersion { + t.Fatalf("unexpected upgraded schema status: %+v, %v", status, err) + } +} + func TestMigrateSchemaV8AllowsReusingArchivedLogicalModelCode(t *testing.T) { db, err := Open(Config{Driver: "sqlite", DSN: "file:migration-logical-model-active-code?mode=memory&cache=shared"}) if err != nil { diff --git a/backend/internal/payment/rpc.go b/backend/internal/payment/rpc.go index 573c64c25..ff9914d2d 100644 --- a/backend/internal/payment/rpc.go +++ b/backend/internal/payment/rpc.go @@ -10,6 +10,8 @@ import ( "bytes" "context" "debug/elf" + "debug/macho" + "debug/pe" "encoding/base64" "encoding/json" "errors" @@ -25,6 +27,8 @@ import ( "strings" "syscall" "time" + + "infinite-canvas/backend/internal/protocol" ) const ( @@ -35,6 +39,7 @@ const ( type RPCProvider struct { descriptor Descriptor + entry string command string dir string } @@ -49,7 +54,11 @@ func NewRPCProvider(descriptor Descriptor, packageDir, entry string) (*RPCProvid 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 + packageDir, err := filepath.Abs(packageDir) + if err != nil { + return nil, fmt.Errorf("payment plugin package directory is invalid: %w", err) + } + return &RPCProvider{descriptor: descriptor, entry: entry, dir: packageDir}, nil } func (p *RPCProvider) Descriptor() Descriptor { return p.descriptor } @@ -73,10 +82,14 @@ type rpcResponse struct { } func (p *RPCProvider) call(ctx context.Context, request rpcRequest, output any) error { - if p == nil || strings.TrimSpace(p.command) == "" { + if p == nil { return errors.New("payment plugin runtime is unavailable") } - info, err := os.Stat(p.command) + command, err := p.executable() + if err != nil { + return p.startError(err) + } + info, err := os.Stat(command) if err != nil { return p.startError(err) } @@ -86,7 +99,7 @@ func (p *RPCProvider) call(ctx context.Context, request rpcRequest, output any) if runtime.GOOS != "windows" && info.Mode().Perm()&0o111 == 0 { return p.startError(fmt.Errorf("payment plugin executable has no execute bit: %w", syscall.EACCES)) } - if err := validatePaymentExecutablePlatform(p.command); err != nil { + if err := validatePaymentExecutablePlatform(command); err != nil { return p.startError(err) } ctx, cancel := context.WithTimeout(ctx, pluginRPCTimeout) @@ -95,9 +108,9 @@ func (p *RPCProvider) call(ctx context.Context, request rpcRequest, output any) if err != nil { return err } - cmd := exec.CommandContext(ctx, p.command) + cmd := exec.CommandContext(ctx, command) cmd.Dir = p.dir - cmd.Env = []string{"PATH=/usr/bin:/bin", "HOME=/nonexistent"} + cmd.Env = paymentPluginCommandEnv() cmd.Stdin = strings.NewReader(string(payload) + "\n") stdout, err := cmd.StdoutPipe() if err != nil { @@ -148,21 +161,96 @@ func (p *RPCProvider) call(ctx context.Context, request rpcRequest, output any) return nil } +func (p *RPCProvider) executable() (string, error) { + if p == nil { + return "", errors.New("payment plugin runtime is unavailable") + } + if strings.TrimSpace(p.command) != "" { + return p.command, nil + } + command, err := ResolveRPCBackendPath(p.dir, p.entry) + if err != nil { + return "", err + } + command, err = filepath.Abs(command) + if err != nil { + return "", err + } + p.command = command + return command, nil +} + func (p *RPCProvider) startError(cause error) error { providerErr := classifyPaymentPluginStartError(cause) + command := strings.TrimSpace(p.command) + if command == "" { + command, _ = ResolveRPCBackendPath(p.dir, p.entry) + } log.Printf( "payment plugin executable failed: provider=%q plugin=%q host=%s/%s command=%q code=%s error=%v", p.descriptor.ID, p.descriptor.PluginID, runtime.GOOS, runtime.GOARCH, - p.command, + command, providerErr.Code, cause, ) return providerErr } +func paymentPluginCommandEnv() []string { + if runtime.GOOS == "windows" { + systemRoot := strings.TrimSpace(os.Getenv("SystemRoot")) + if systemRoot == "" { + systemRoot = strings.TrimSpace(os.Getenv("SYSTEMROOT")) + } + env := []string{"HOME=" + filepath.Join("C:\\", "nonexistent")} + if systemRoot != "" { + env = append(env, "SystemRoot="+systemRoot, "SYSTEMROOT="+systemRoot) + } + return env + } + return []string{"PATH=/usr/bin:/bin", "HOME=/nonexistent"} +} + +func ResolveRPCBackendPath(packageDir, entry string) (string, error) { + if strings.TrimSpace(packageDir) == "" { + return "", fmt.Errorf("payment plugin executable for %s/%s is missing: %w", runtime.GOOS, runtime.GOARCH, os.ErrNotExist) + } + var missingErr, formatErr error + for _, candidate := range protocol.PaymentRPCBackendCandidates(entry, runtime.GOOS, runtime.GOARCH) { + target := filepath.Join(packageDir, filepath.FromSlash(candidate)) + info, err := os.Stat(target) + if err != nil { + if missingErr == nil { + missingErr = err + } + continue + } + if info.IsDir() { + if missingErr == nil { + missingErr = os.ErrNotExist + } + continue + } + if err := validatePaymentExecutablePlatform(target); err != nil { + if formatErr == nil { + formatErr = err + } + continue + } + return target, nil + } + if formatErr != nil { + return "", fmt.Errorf("payment plugin executable for %s/%s is incompatible: %w", runtime.GOOS, runtime.GOARCH, formatErr) + } + if missingErr == nil { + missingErr = os.ErrNotExist + } + return "", fmt.Errorf("payment plugin executable for %s/%s is missing: %w", runtime.GOOS, runtime.GOARCH, missingErr) +} + func classifyPaymentPluginStartError(cause error) *ProviderError { switch { case errors.Is(cause, os.ErrNotExist), errors.Is(cause, syscall.ENOENT), errors.Is(cause, exec.ErrNotFound): @@ -177,9 +265,6 @@ func classifyPaymentPluginStartError(cause error) *ProviderError { } func validatePaymentExecutablePlatform(command string) error { - if runtime.GOOS != "linux" { - return nil - } file, err := os.Open(command) if err != nil { return err @@ -190,26 +275,90 @@ func validatePaymentExecutablePlatform(command string) error { if _, err := io.ReadFull(file, magic[:]); err != nil { return fmt.Errorf("payment plugin executable header is incomplete: %w", syscall.ENOEXEC) } - if bytes.Equal(magic[:2], []byte{'M', 'Z'}) { - return fmt.Errorf("payment plugin executable is Windows PE on linux/%s: %w", runtime.GOARCH, syscall.ENOEXEC) - } - if isMachOMagic(magic) { - return fmt.Errorf("payment plugin executable is Mach-O on linux/%s: %w", runtime.GOARCH, syscall.ENOEXEC) - } - if !bytes.Equal(magic[:], []byte{0x7f, 'E', 'L', 'F'}) { - // Scripts and other kernel-supported executable formats are left to - // cmd.Start. Unknown binary data will be classified from ENOEXEC there. + + switch runtime.GOOS { + case "linux": + if bytes.Equal(magic[:2], []byte{'M', 'Z'}) { + return fmt.Errorf("payment plugin executable is Windows PE on linux/%s: %w", runtime.GOARCH, syscall.ENOEXEC) + } + if isMachOMagic(magic) { + return fmt.Errorf("payment plugin executable is Mach-O on linux/%s: %w", runtime.GOARCH, syscall.ENOEXEC) + } + if !bytes.Equal(magic[:], []byte{0x7f, 'E', 'L', 'F'}) { + return nil + } + binary, err := elf.NewFile(file) + if err != nil { + return fmt.Errorf("payment plugin ELF header is invalid: %w", syscall.ENOEXEC) + } + defer binary.Close() + expected, ok := expectedPaymentELFMachine(runtime.GOARCH) + if binary.Class != elf.ELFCLASS64 || (ok && binary.Machine != expected) { + return fmt.Errorf("payment plugin ELF architecture mismatch: host=linux/%s class=%s machine=%s: %w", runtime.GOARCH, binary.Class, binary.Machine, syscall.ENOEXEC) + } + return nil + case "darwin": + if bytes.Equal(magic[:], []byte{0x7f, 'E', 'L', 'F'}) { + return fmt.Errorf("payment plugin executable is ELF on darwin/%s: %w", runtime.GOARCH, syscall.ENOEXEC) + } + if bytes.Equal(magic[:2], []byte{'M', 'Z'}) { + return fmt.Errorf("payment plugin executable is Windows PE on darwin/%s: %w", runtime.GOARCH, syscall.ENOEXEC) + } + if !isMachOMagic(magic) { + return nil + } + return validateMachOArchitecture(command) + case "windows": + if bytes.Equal(magic[:], []byte{0x7f, 'E', 'L', 'F'}) { + return fmt.Errorf("payment plugin executable is ELF on windows/%s: %w", runtime.GOARCH, syscall.ENOEXEC) + } + if isMachOMagic(magic) { + return fmt.Errorf("payment plugin executable is Mach-O on windows/%s: %w", runtime.GOARCH, syscall.ENOEXEC) + } + if !bytes.Equal(magic[:2], []byte{'M', 'Z'}) { + return nil + } + return validateWindowsPEArchitecture(command) + default: return nil } +} - binary, err := elf.NewFile(file) - if err != nil { - return fmt.Errorf("payment plugin ELF header is invalid: %w", syscall.ENOEXEC) +func validateMachOArchitecture(command string) error { + expected, ok := expectedMachOCPU(runtime.GOARCH) + file, err := macho.Open(command) + if err == nil { + defer file.Close() + if ok && file.Cpu != expected { + return fmt.Errorf("payment plugin Mach-O architecture mismatch: host=darwin/%s cpu=%s: %w", runtime.GOARCH, file.Cpu, syscall.ENOEXEC) + } + return nil } - defer binary.Close() - expected, ok := expectedPaymentELFMachine(runtime.GOARCH) - if binary.Class != elf.ELFCLASS64 || (ok && binary.Machine != expected) { - return fmt.Errorf("payment plugin ELF architecture mismatch: host=linux/%s class=%s machine=%s: %w", runtime.GOARCH, binary.Class, binary.Machine, syscall.ENOEXEC) + fat, fatErr := macho.OpenFat(command) + if fatErr != nil { + return fmt.Errorf("payment plugin Mach-O header is invalid: %w", syscall.ENOEXEC) + } + defer fat.Close() + if !ok { + return nil + } + for _, arch := range fat.Arches { + if arch.Cpu == expected { + return nil + } + } + return fmt.Errorf("payment plugin Mach-O architecture mismatch: host=darwin/%s: %w", runtime.GOARCH, syscall.ENOEXEC) +} + +func validateWindowsPEArchitecture(command string) error { + file, err := pe.Open(command) + if err != nil { + return fmt.Errorf("payment plugin PE header is invalid: %w", syscall.ENOEXEC) + } + defer file.Close() + expected, ok := expectedPEMachine(runtime.GOARCH) + if ok && file.Machine != expected { + return fmt.Errorf("payment plugin PE architecture mismatch: host=windows/%s machine=%d: %w", runtime.GOARCH, file.Machine, syscall.ENOEXEC) } return nil } @@ -232,6 +381,28 @@ func expectedPaymentELFMachine(arch string) (elf.Machine, bool) { } } +func expectedMachOCPU(arch string) (macho.Cpu, bool) { + switch arch { + case "amd64": + return macho.CpuAmd64, true + case "arm64": + return macho.CpuArm64, true + default: + return 0, false + } +} + +func expectedPEMachine(arch string) (uint16, bool) { + switch arch { + case "amd64": + return pe.IMAGE_FILE_MACHINE_AMD64, true + case "arm64": + return pe.IMAGE_FILE_MACHINE_ARM64, true + default: + return 0, false + } +} + func (p *RPCProvider) ValidateConfig(config Config) error { return p.call(context.Background(), rpcRequest{Version: pluginRPCVersion, Operation: "validate_config", Config: config}, nil) } diff --git a/backend/internal/payment/rpc_test.go b/backend/internal/payment/rpc_test.go index 266b5570e..f2b7cd2e0 100644 --- a/backend/internal/payment/rpc_test.go +++ b/backend/internal/payment/rpc_test.go @@ -82,6 +82,148 @@ func TestRPCProviderClassifiesExecutableStartFailures(t *testing.T) { } } +func TestRPCProviderRejectsIncompatibleFallbackBinary(t *testing.T) { + if runtime.GOOS == "linux" { + t.Skip("linux already classifies foreign binaries in TestRPCProviderClassifiesExecutableStartFailures") + } + dir := t.TempDir() + entry := filepath.Join(dir, "backend", "provider") + if err := os.MkdirAll(filepath.Dir(entry), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(entry, []byte{0x7f, 'E', 'L', 'F', 0x02, 0x01, 0x01, 0x00}, 0o700); err != nil { + t.Fatal(err) + } + provider, err := NewRPCProvider(Descriptor{ID: "test-provider", PluginID: "test-plugin"}, dir, "backend/provider") + if err != nil { + t.Fatal(err) + } + err = provider.ValidateConfig(Config{}) + var providerErr *ProviderError + if !errors.As(err, &providerErr) { + t.Fatalf("ValidateConfig() error = %T %v, want ProviderError", err, err) + } + if providerErr.Code != "plugin_exec_format_error" || providerErr.Message != "支付插件可执行文件与当前操作系统或 CPU 架构不兼容" { + t.Fatalf("ProviderError = %#v", providerErr) + } +} + +func TestResolveRPCBackendPathPrefersHostTaggedBinary(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("tagged fallback uses a POSIX shell script") + } + dir := t.TempDir() + backend := filepath.Join(dir, "backend") + if err := os.MkdirAll(backend, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(backend, "provider"), []byte{0x7f, 'E', 'L', 'F', 0x02, 0x01, 0x01, 0x00}, 0o700); err != nil { + t.Fatal(err) + } + tagged := "provider-" + runtime.GOOS + "-" + runtime.GOARCH + script := []byte("#!/bin/sh\nexit 0\n") + if err := os.WriteFile(filepath.Join(backend, tagged), script, 0o700); err != nil { + t.Fatal(err) + } + resolved, err := ResolveRPCBackendPath(dir, "backend/provider") + if err != nil { + t.Fatal(err) + } + if filepath.Base(resolved) != tagged { + t.Fatalf("resolved %q, want tagged %q", resolved, tagged) + } +} + +func TestRPCProviderStartsOfficialHostArtifact(t *testing.T) { + artifact := "provider-" + runtime.GOOS + "-" + runtime.GOARCH + if runtime.GOOS == "windows" { + artifact += ".exe" + } + sourceDir := filepath.Join("..", "..", "..", "plugin-packages", "official-payment-xunhupay", "backend") + hostBinary, err := os.ReadFile(filepath.Join(sourceDir, artifact)) + if err != nil { + t.Fatalf("read host artifact: %v", err) + } + fallback, err := os.ReadFile(filepath.Join(sourceDir, "provider")) + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + backend := filepath.Join(dir, "backend") + if err := os.MkdirAll(backend, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(backend, artifact), hostBinary, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(backend, "provider"), fallback, 0o700); err != nil { + t.Fatal(err) + } + provider, err := NewRPCProvider(Descriptor{ID: "xunhupay-aggregate", PluginID: "official-payment-xunhupay"}, dir, "backend/provider") + if err != nil { + t.Fatal(err) + } + err = provider.ValidateConfig(Config{}) + var providerErr *ProviderError + if !errors.As(err, &providerErr) { + t.Fatalf("ValidateConfig() error = %T %v, want ProviderError", err, err) + } + if providerErr.Code == "plugin_executable_missing" || providerErr.Code == "plugin_exec_format_error" || providerErr.Code == "plugin_start_failed" { + t.Fatalf("host artifact failed to start: %#v cause=%v", providerErr, providerErr.Cause) + } +} + +func TestRPCProviderStartsFromRelativePackageDir(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("relative package directories are asserted against POSIX path resolution") + } + artifact := "provider-" + runtime.GOOS + "-" + runtime.GOARCH + source := filepath.Join("..", "..", "..", "plugin-packages", "official-payment-xunhupay", "backend", artifact) + hostBinary, err := os.ReadFile(source) + if err != nil { + t.Fatalf("read host artifact: %v", err) + } + absDir := t.TempDir() + relDir, err := filepath.Rel(mustWorkingDir(t), absDir) + if err != nil { + t.Fatal(err) + } + if filepath.IsAbs(relDir) { + t.Fatalf("test setup produced an absolute package dir %q", relDir) + } + backend := filepath.Join(absDir, "backend") + if err := os.MkdirAll(backend, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(backend, artifact), hostBinary, 0o700); err != nil { + t.Fatal(err) + } + provider, err := NewRPCProvider(Descriptor{ID: "xunhupay-aggregate", PluginID: "official-payment-xunhupay"}, relDir, "backend/provider") + if err != nil { + t.Fatal(err) + } + if !filepath.IsAbs(provider.dir) { + t.Fatalf("package dir = %q, want absolute", provider.dir) + } + err = provider.ValidateConfig(Config{}) + var providerErr *ProviderError + if !errors.As(err, &providerErr) { + t.Fatalf("ValidateConfig() error = %T %v, want ProviderError", err, err) + } + if providerErr.Code == "plugin_executable_missing" || providerErr.Code == "plugin_exec_format_error" || providerErr.Code == "plugin_start_failed" { + t.Fatalf("relative package dir failed to start host artifact: %#v cause=%v", providerErr, providerErr.Cause) + } +} + +func mustWorkingDir(t *testing.T) string { + t.Helper() + dir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + return dir +} + func TestClassifyPaymentPluginStartError(t *testing.T) { tests := []struct { name string diff --git a/backend/internal/protocol/package.go b/backend/internal/protocol/package.go index 262994b0c..63725a1da 100644 --- a/backend/internal/protocol/package.go +++ b/backend/internal/protocol/package.go @@ -13,10 +13,10 @@ import ( const ( PluginPackageFormat = "yingce.plugin/package-v1" - PluginPackageMaxBytes = 16 << 20 + PluginPackageMaxBytes = 48 << 20 PluginManifestMaxBytes = 512 << 10 pluginPackageMaxFiles = 256 - pluginPackageMaxEntry = 8 << 20 + pluginPackageMaxEntry = 16 << 20 ) // PluginPackage is the transport envelope for every uploaded plugin. The @@ -117,7 +117,11 @@ func validatePluginPackageRuntime(manifest Manifest, files map[string][]byte) er if !strings.HasPrefix(entry, "backend/") || path.Clean(entry) != entry { return fmt.Errorf("manifest.runtime.backendEntry must point inside backend/") } - if _, ok := files[entry]; !ok { + if backend == "rpc" { + if !HasAnyPaymentRPCBackend(entry, files) { + return fmt.Errorf("manifest runtime backend entry %q is missing a host-executable artifact", entry) + } + } else 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") { diff --git a/backend/internal/protocol/package_test.go b/backend/internal/protocol/package_test.go index 252ececba..5e441c7d9 100644 --- a/backend/internal/protocol/package_test.go +++ b/backend/internal/protocol/package_test.go @@ -42,6 +42,33 @@ func TestParsePluginPackageValidatesWebEntry(t *testing.T) { } } +func TestParsePluginPackageAcceptsTaggedRPCBackend(t *testing.T) { + manifest := []byte(`{ + "apiVersion":"yingce.plugin/v1", + "id":"tagged-payment", + "name":"Tagged Payment", + "version":"1.0.0", + "author":"Test", + "enabled":true, + "runtime":{"backend":"rpc","backendEntry":"backend/provider"}, + "contributes":{"paymentProviders":[{"id":"tagged-pay","label":"Tagged","icon":"brand:test","checkoutMode":"qr_code","expiryPolicy":{"defaultMinutes":30,"minMinutes":5,"maxMinutes":1440}}]} + }`) + pkg, err := ParsePluginPackage(zipPluginPackage(t, map[string][]byte{ + "manifest.json": manifest, + "backend/provider-darwin-arm64": []byte("darwin-provider"), + })) + if err != nil { + t.Fatal(err) + } + if _, ok := pkg.Files["backend/provider"]; ok { + t.Fatal("canonical backend/provider should not be required when a tagged artifact exists") + } + + if _, err := ParsePluginPackage(zipPluginPackage(t, map[string][]byte{"manifest.json": manifest})); err == nil { + t.Fatal("rpc package without any backend artifact was accepted") + } +} + func zipPluginPackage(t *testing.T, files map[string][]byte) []byte { t.Helper() var buffer bytes.Buffer diff --git a/backend/internal/protocol/payment_artifact_test.go b/backend/internal/protocol/payment_artifact_test.go index d7c7b2d14..25f1185da 100644 --- a/backend/internal/protocol/payment_artifact_test.go +++ b/backend/internal/protocol/payment_artifact_test.go @@ -12,8 +12,23 @@ import ( "testing" ) +var officialPaymentPackageIDs = []string{ + "official-payment-wechat-native", + "official-payment-alipay-page", + "official-payment-xunhupay", +} + +var officialPaymentTaggedArtifacts = []string{ + "backend/provider-linux-amd64", + "backend/provider-linux-arm64", + "backend/provider-darwin-amd64", + "backend/provider-darwin-arm64", + "backend/provider-windows-amd64.exe", + "backend/provider-windows-arm64.exe", +} + func TestOfficialPaymentArtifactsAreCanonicalLinuxAMD64(t *testing.T) { - for _, packageID := range []string{"official-payment-wechat-native", "official-payment-alipay-page"} { + for _, packageID := range officialPaymentPackageIDs { t.Run(packageID, func(t *testing.T) { root := filepath.Join("..", "..", "..", "plugin-packages") providerPath := filepath.Join(root, packageID, "backend", "provider") @@ -33,6 +48,10 @@ func TestOfficialPaymentArtifactsAreCanonicalLinuxAMD64(t *testing.T) { if !bytes.Equal(directoryProvider, packagedProvider) { t.Fatal("directory provider differs from .yingce-plugin backend/provider") } + linuxAMD64 := pkg.Files["backend/provider-linux-amd64"] + if !bytes.Equal(directoryProvider, linuxAMD64) { + t.Fatal("canonical backend/provider differs from backend/provider-linux-amd64") + } binary, err := elf.NewFile(bytes.NewReader(directoryProvider)) if err != nil { t.Fatalf("provider is not ELF: %v", err) @@ -52,31 +71,91 @@ func TestOfficialPaymentArtifactsAreCanonicalLinuxAMD64(t *testing.T) { t.Fatalf("provider build settings = GOOS=%s GOARCH=%s CGO_ENABLED=%s", settings["GOOS"], settings["GOARCH"], settings["CGO_ENABLED"]) } + for _, name := range officialPaymentTaggedArtifacts { + directoryTagged, err := os.ReadFile(filepath.Join(root, packageID, filepath.FromSlash(name))) + if err != nil { + t.Fatalf("missing directory artifact %s: %v", name, err) + } + packagedTagged := pkg.Files[name] + if !bytes.Equal(directoryTagged, packagedTagged) { + t.Fatalf("directory %s differs from packaged artifact", name) + } + taggedInfo, err := buildinfo.Read(bytes.NewReader(directoryTagged)) + if err != nil { + t.Fatalf("read %s build info: %v", name, err) + } + taggedSettings := make(map[string]string, len(taggedInfo.Settings)) + for _, setting := range taggedInfo.Settings { + taggedSettings[setting.Key] = setting.Value + } + wantOS, wantArch := expectedPaymentArtifactPlatform(name) + if taggedSettings["GOOS"] != wantOS || taggedSettings["GOARCH"] != wantArch || taggedSettings["CGO_ENABLED"] != "0" { + t.Fatalf("%s build settings = GOOS=%s GOARCH=%s CGO_ENABLED=%s", name, taggedSettings["GOOS"], taggedSettings["GOARCH"], taggedSettings["CGO_ENABLED"]) + } + } + if runtime.GOOS == "linux" && runtime.GOARCH == "amd64" { - fileInfo, err := os.Stat(providerPath) - if err != nil { - t.Fatal(err) - } - if fileInfo.Mode().Perm()&0o111 == 0 { - t.Fatal("provider is not executable") - } - command := exec.Command(providerPath) - command.Stdin = bytes.NewBufferString("invalid-json\n") - output, err := command.Output() - if err != nil { - t.Fatalf("provider smoke test failed: %v", err) - } - var response struct { - OK bool `json:"ok"` - Code string `json:"code"` - } - if err := json.Unmarshal(output, &response); err != nil { - t.Fatalf("provider smoke response is invalid JSON: %v", err) - } - if response.OK || response.Code != "invalid_request" { - t.Fatalf("provider smoke response = %s", output) - } + smokePaymentProvider(t, providerPath) + return + } + hostCandidates := PaymentRPCBackendCandidates("backend/provider", runtime.GOOS, runtime.GOARCH) + if len(hostCandidates) == 0 { + t.Fatalf("no RPC candidates for %s/%s", runtime.GOOS, runtime.GOARCH) + } + hostArtifact := hostCandidates[0] + hostPath := filepath.Join(root, packageID, filepath.FromSlash(hostArtifact)) + if _, err := os.Stat(hostPath); err != nil { + t.Fatalf("host artifact %s is missing: %v", hostArtifact, err) + } + if runtime.GOOS != "windows" { + smokePaymentProvider(t, hostPath) } }) } } + +func expectedPaymentArtifactPlatform(name string) (string, string) { + switch name { + case "backend/provider-linux-amd64": + return "linux", "amd64" + case "backend/provider-linux-arm64": + return "linux", "arm64" + case "backend/provider-darwin-amd64": + return "darwin", "amd64" + case "backend/provider-darwin-arm64": + return "darwin", "arm64" + case "backend/provider-windows-amd64.exe": + return "windows", "amd64" + case "backend/provider-windows-arm64.exe": + return "windows", "arm64" + default: + return "", "" + } +} + +func smokePaymentProvider(t *testing.T, providerPath string) { + t.Helper() + fileInfo, err := os.Stat(providerPath) + if err != nil { + t.Fatal(err) + } + if fileInfo.Mode().Perm()&0o111 == 0 { + t.Fatal("provider is not executable") + } + command := exec.Command(providerPath) + command.Stdin = bytes.NewBufferString("invalid-json\n") + output, err := command.Output() + if err != nil { + t.Fatalf("provider smoke test failed: %v", err) + } + var response struct { + OK bool `json:"ok"` + Code string `json:"code"` + } + if err := json.Unmarshal(output, &response); err != nil { + t.Fatalf("provider smoke response is invalid JSON: %v", err) + } + if response.OK || response.Code != "invalid_request" { + t.Fatalf("provider smoke response = %s", output) + } +} diff --git a/backend/internal/protocol/payment_rpc.go b/backend/internal/protocol/payment_rpc.go new file mode 100644 index 000000000..7220f4645 --- /dev/null +++ b/backend/internal/protocol/payment_rpc.go @@ -0,0 +1,72 @@ +package protocol + +import ( + "path" + "strings" +) + +func PaymentRPCBackendCandidates(entry, goos, goarch string) []string { + entry = strings.TrimSuffix(strings.TrimSpace(entry), ".exe") + goos = strings.TrimSpace(goos) + goarch = strings.TrimSpace(goarch) + if entry == "" || goos == "" || goarch == "" || path.Clean(entry) != entry || !strings.HasPrefix(entry, "backend/") { + return nil + } + base := path.Base(entry) + dir := path.Dir(entry) + tagged := entry + "-" + goos + "-" + goarch + nested := dir + "/" + goos + "-" + goarch + "/" + base + candidates := make([]string, 0, 6) + if goos == "windows" { + candidates = append(candidates, tagged+".exe", nested+".exe", entry+".exe") + } + candidates = append(candidates, tagged, nested, entry) + return uniqueStrings(candidates) +} + +func HasAnyPaymentRPCBackend(entry string, files map[string][]byte) bool { + entry = strings.TrimSuffix(strings.TrimSpace(entry), ".exe") + if entry == "" { + return false + } + if _, ok := files[entry]; ok { + return true + } + if _, ok := files[entry+".exe"]; ok { + return true + } + prefix := entry + "-" + nestedRoot := path.Dir(entry) + "/" + base := path.Base(entry) + for name := range files { + if strings.HasPrefix(name, prefix) { + return true + } + rest, ok := strings.CutPrefix(name, nestedRoot) + if !ok { + continue + } + slash := strings.IndexByte(rest, '/') + if slash <= 0 { + continue + } + leaf := rest[slash+1:] + if leaf == base || leaf == base+".exe" { + return true + } + } + return false +} + +func uniqueStrings(values []string) []string { + seen := make(map[string]struct{}, len(values)) + result := make([]string, 0, len(values)) + for _, value := range values { + if _, exists := seen[value]; exists { + continue + } + seen[value] = struct{}{} + result = append(result, value) + } + return result +} diff --git a/backend/internal/protocol/payment_rpc_test.go b/backend/internal/protocol/payment_rpc_test.go new file mode 100644 index 000000000..9d79fa15b --- /dev/null +++ b/backend/internal/protocol/payment_rpc_test.go @@ -0,0 +1,29 @@ +package protocol + +import "testing" + +func TestPaymentRPCBackendCandidatesPreferPlatformTaggedFiles(t *testing.T) { + darwin := PaymentRPCBackendCandidates("backend/provider", "darwin", "arm64") + if got, want := darwin[0], "backend/provider-darwin-arm64"; got != want { + t.Fatalf("darwin candidates[0] = %q, want %q (%v)", got, want, darwin) + } + windows := PaymentRPCBackendCandidates("backend/provider", "windows", "amd64") + if got, want := windows[0], "backend/provider-windows-amd64.exe"; got != want { + t.Fatalf("windows candidates[0] = %q, want %q (%v)", got, want, windows) + } + if windows[len(windows)-1] != "backend/provider" { + t.Fatalf("windows fallback = %v", windows) + } +} + +func TestHasAnyPaymentRPCBackendAcceptsTaggedArtifacts(t *testing.T) { + if HasAnyPaymentRPCBackend("backend/provider", map[string][]byte{"backend/provider-darwin-arm64": {1}}) != true { + t.Fatal("tagged darwin provider should be accepted") + } + if HasAnyPaymentRPCBackend("backend/provider", map[string][]byte{"backend/windows-amd64/provider.exe": {1}}) != true { + t.Fatal("nested windows provider should be accepted") + } + if HasAnyPaymentRPCBackend("backend/provider", map[string][]byte{"backend/unrelated": {1}}) { + t.Fatal("unrelated backend file should not satisfy the payment entry") + } +} diff --git a/backend/payment-plugins/aliases.go b/backend/payment-plugins/aliases.go index bdf820b19..6ddecb1f3 100644 --- a/backend/payment-plugins/aliases.go +++ b/backend/payment-plugins/aliases.go @@ -24,6 +24,8 @@ var ( const ( ProviderWeChatNative = "wechat-native" ProviderAlipayPage = "alipay-page-pay" + ProviderXunHuPay = "xunhupay-aggregate" PluginWeChatNative = "official-payment-wechat-native" PluginAlipayPage = "official-payment-alipay-page" + PluginXunHuPay = "official-payment-xunhupay" ) diff --git a/backend/payment-plugins/xunhupay.go b/backend/payment-plugins/xunhupay.go new file mode 100644 index 000000000..ddd405089 --- /dev/null +++ b/backend/payment-plugins/xunhupay.go @@ -0,0 +1,499 @@ +package paymentplugins + +import ( + "bytes" + "context" + "crypto/md5" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "strconv" + "strings" + "time" +) + +const ( + defaultXunHuPayGateway = "https://api.xunhupay.com" + xunhupayAPIVersion = "1.1" + xunhupayPluginName = "yingce-xunhupay" + xunhupayPayPath = "/payment/do.html" + xunhupayQueryPath = "/payment/query.html" + xunhupayQRLifetime = 5 * time.Minute +) + +var xunhupayGatewayHosts = map[string]struct{}{ + "api.xunhupay.com": {}, + "api.dpweixin.com": {}, + "api.diypc.com.cn": {}, +} + +type XunHuPayProvider struct { + client *http.Client + now func() time.Time + baseURL string + nonce func() string +} + +func NewXunHuPayProvider(client *http.Client) *XunHuPayProvider { + if client == nil { + client = http.DefaultClient + } + return &XunHuPayProvider{client: client, now: time.Now, nonce: randomNonce} +} + +func (p *XunHuPayProvider) Descriptor() Descriptor { + return Descriptor{ + ID: ProviderXunHuPay, PluginID: PluginXunHuPay, PluginVersion: "1.0.0", + Name: "虎皮椒聚合支付", Icon: "assets/icon.svg", CheckoutMode: "qr_code", + IdentityFields: []string{"appId"}, + 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 *XunHuPayProvider) ValidateConfig(config Config) error { + if strings.TrimSpace(config["appId"]) == "" { + return errors.New("虎皮椒配置缺少 appId") + } + if strings.TrimSpace(config["appSecret"]) == "" { + return errors.New("虎皮椒配置缺少 appSecret") + } + mode := xunhupayCheckoutMode(config) + if mode != "qr_code" && mode != "redirect" { + return errors.New("虎皮椒 checkoutMode 必须是 qr_code 或 redirect") + } + if _, err := xunhupayGatewayOrigin(config); err != nil { + return err + } + return nil +} + +func (p *XunHuPayProvider) CreateOrder(ctx context.Context, config Config, request CreateRequest) (Checkout, error) { + if err := p.ValidateConfig(config); err != nil { + return Checkout{}, err + } + if request.AmountFen <= 0 || request.Currency != "CNY" || request.MerchantOrderNo == "" || request.NotifyURL == "" { + return Checkout{}, errors.New("虎皮椒下单参数无效") + } + params := map[string]string{ + "version": xunhupayAPIVersion, + "appid": strings.TrimSpace(config["appId"]), + "trade_order_id": request.MerchantOrderNo, + "total_fee": formatFen(request.AmountFen), + "title": xunhupayTitle(request.Description), + "time": strconv.FormatInt(p.now().Unix(), 10), + "notify_url": request.NotifyURL, + "plugins": xunhupayPluginName, + "nonce_str": p.nonce(), + } + if request.ReturnURL != "" { + params["return_url"] = request.ReturnURL + params["callback_url"] = request.ReturnURL + } + var response xunhupayPayResponse + if err := p.call(ctx, config, xunhupayPayPath, params, &response); err != nil { + return Checkout{}, err + } + mode := xunhupayCheckoutMode(config) + // url_qrcode 是网关已经画好的二维码图片地址,钱包按图片展示,不能再编码成新二维码。 + value := strings.TrimSpace(response.URLQrcode) + if mode == "redirect" { + value = strings.TrimSpace(response.URL) + } + if value == "" { + if mode == "redirect" { + return Checkout{}, errors.New("虎皮椒下单未返回支付跳转地址") + } + return Checkout{}, errors.New("虎皮椒下单未返回支付二维码") + } + expiresAt := request.ExpiresAt + if mode == "qr_code" { + qrExpires := p.now().Add(xunhupayQRLifetime) + if expiresAt.IsZero() || qrExpires.Before(expiresAt) { + expiresAt = qrExpires + } + } + return Checkout{Mode: mode, Value: value, ExpiresAt: expiresAt}, nil +} + +func (p *XunHuPayProvider) QueryOrder(ctx context.Context, config Config, request QueryRequest) (Result, error) { + if err := p.ValidateConfig(config); err != nil { + return Result{}, err + } + if strings.TrimSpace(request.MerchantOrderNo) == "" { + return Result{}, errors.New("虎皮椒查单缺少商户订单号") + } + order, err := p.query(ctx, config, request.MerchantOrderNo) + if err != nil { + return Result{}, err + } + return xunhupayResult(request.MerchantOrderNo, order), nil +} + +func (p *XunHuPayProvider) CloseOrder(ctx context.Context, config Config, request CloseRequest) (Result, error) { + if err := p.ValidateConfig(config); err != nil { + return Result{}, err + } + order, err := p.query(ctx, config, request.MerchantOrderNo) + if err != nil { + return Result{}, err + } + result := xunhupayResult(request.MerchantOrderNo, order) + if result.Paid { + return result, nil + } + result.Closed = true + if result.ProviderStatus == "" || result.ProviderStatus == "WP" { + result.ProviderStatus = "CD" + } + return result, nil +} + +func (p *XunHuPayProvider) VerifyNotification(_ context.Context, config Config, _ http.Header, rawBody []byte) (Notification, error) { + if err := p.ValidateConfig(config); err != nil { + return Notification{}, err + } + values, err := parseXunHuPayNotification(rawBody) + if err != nil { + return Notification{}, err + } + if !xunhupayHashEqual(values, config["appSecret"], values["hash"]) { + return Notification{}, errors.New("虎皮椒异步通知签名无效") + } + if strings.TrimSpace(values["appid"]) != strings.TrimSpace(config["appId"]) { + return Notification{}, errors.New("虎皮椒异步通知商户身份不匹配") + } + status := strings.TrimSpace(values["status"]) + merchantOrderNo := firstNonEmpty(values["trade_order_id"], values["out_trade_order"]) + if merchantOrderNo == "" { + return Notification{}, errors.New("虎皮椒异步通知缺少商户订单号") + } + amount, err := parseYuanToFen(firstNonEmpty(values["total_fee"], values["total_amount"])) + if err != nil { + return Notification{}, errors.New("虎皮椒异步通知金额无效") + } + paidAt := parseXunHuPayTime(values["time"]) + eventID := firstNonEmpty(values["transaction_id"], values["open_order_id"], merchantOrderNo) + ":" + status + ":" + values["time"] + return Notification{ + EventID: eventID, + Result: Result{ + MerchantOrderNo: merchantOrderNo, + ProviderTradeNo: firstNonEmpty(values["transaction_id"], values["open_order_id"]), + ProviderStatus: status, + AmountFen: amount, + Currency: "CNY", + Paid: status == "OD", + Closed: status == "CD", + PaidAt: paidAt, + }, + }, nil +} + +func (p *XunHuPayProvider) DownloadTradeBill(context.Context, Config, time.Time) ([]BillRecord, error) { + return nil, fmt.Errorf("%w: 虎皮椒未提供交易账单下载接口", ErrTradeBillNotFound) +} + +func (p *XunHuPayProvider) query(ctx context.Context, config Config, merchantOrderNo string) (xunhupayOrderData, error) { + params := map[string]string{ + "appid": strings.TrimSpace(config["appId"]), + "out_trade_order": merchantOrderNo, + "time": strconv.FormatInt(p.now().Unix(), 10), + "nonce_str": p.nonce(), + } + var envelope xunhupayQueryResponse + if err := p.call(ctx, config, xunhupayQueryPath, params, &envelope); err != nil { + return xunhupayOrderData{}, err + } + if strings.TrimSpace(envelope.Data.Status) == "" { + return xunhupayOrderData{}, errors.New("虎皮椒查单未返回订单状态") + } + return envelope.Data, nil +} + +func (p *XunHuPayProvider) call(ctx context.Context, config Config, path string, params map[string]string, output any) error { + params["hash"] = xunhupayHash(params, config["appSecret"]) + payload, err := json.Marshal(params) + if err != nil { + return err + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, p.endpoint(config, path), bytes.NewReader(payload)) + if err != nil { + return err + } + request.Header.Set("Content-Type", "application/json; charset=utf-8") + response, err := p.client.Do(request) + if err != nil { + return &ProviderError{Code: "xunhupay_transport_error", Message: "虎皮椒网络请求失败", Temporary: true, Cause: err} + } + defer response.Body.Close() + body, err := io.ReadAll(io.LimitReader(response.Body, 2<<20)) + if err != nil { + return &ProviderError{Code: "xunhupay_response_read_error", Message: "读取虎皮椒响应失败", Temporary: true, Cause: err} + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + return &ProviderError{Code: "xunhupay_http_error", Message: "虎皮椒接口返回失败", Temporary: response.StatusCode >= 500} + } + var envelope xunhupayEnvelope + if err := json.Unmarshal(body, &envelope); err != nil { + return fmt.Errorf("解析虎皮椒响应:%w", err) + } + if envelope.Hash != "" { + values, err := jsonObjectStrings(body) + if err != nil { + return fmt.Errorf("解析虎皮椒响应签名字段:%w", err) + } + if !xunhupayHashEqual(values, config["appSecret"], envelope.Hash) { + return errors.New("虎皮椒响应签名无效") + } + } + if envelope.ErrCode != 0 { + message := strings.TrimSpace(envelope.ErrMsg) + if message == "" { + message = "虎皮椒业务请求失败" + } + if isXunHuPayOrderMissing(message) { + return fmt.Errorf("%w: %s", ErrOrderNotFound, message) + } + return &ProviderError{Code: strconv.Itoa(int(envelope.ErrCode)), Message: message} + } + if output == nil { + return nil + } + return json.Unmarshal(body, output) +} + +func (p *XunHuPayProvider) endpoint(config Config, path string) string { + if strings.TrimSpace(p.baseURL) != "" { + return strings.TrimRight(p.baseURL, "/") + path + } + origin, err := xunhupayGatewayOrigin(config) + if err != nil { + return defaultXunHuPayGateway + path + } + return origin + path +} + +type xunhupayEnvelope struct { + ErrCode xunhupayCode `json:"errcode"` + ErrMsg string `json:"errmsg"` + Hash string `json:"hash"` +} + +type xunhupayCode int + +func (code *xunhupayCode) UnmarshalJSON(raw []byte) error { + raw = bytes.TrimSpace(raw) + if len(raw) == 0 || string(raw) == "null" { + *code = 0 + return nil + } + if raw[0] == '"' { + var text string + if err := json.Unmarshal(raw, &text); err != nil { + return err + } + if strings.TrimSpace(text) == "" { + *code = 0 + return nil + } + parsed, err := strconv.Atoi(strings.TrimSpace(text)) + if err != nil { + return err + } + *code = xunhupayCode(parsed) + return nil + } + var value int + if err := json.Unmarshal(raw, &value); err != nil { + return err + } + *code = xunhupayCode(value) + return nil +} + +type xunhupayPayResponse struct { + xunhupayEnvelope + URLQrcode string `json:"url_qrcode"` + URL string `json:"url"` +} + +type xunhupayQueryResponse struct { + xunhupayEnvelope + Data xunhupayOrderData `json:"data"` +} + +type xunhupayOrderData struct { + Status string `json:"status"` + OpenOrderID string `json:"open_order_id"` + TransactionID string `json:"transaction_id"` + TotalAmount string `json:"total_amount"` + TotalFee string `json:"total_fee"` + OutTradeOrder string `json:"out_trade_order"` + PaidDate string `json:"paid_date"` +} + +func xunhupayResult(merchantOrderNo string, order xunhupayOrderData) Result { + amount, _ := parseYuanToFen(firstNonEmpty(order.TotalFee, order.TotalAmount)) + status := strings.TrimSpace(order.Status) + return Result{ + MerchantOrderNo: firstNonEmpty(order.OutTradeOrder, merchantOrderNo), + ProviderTradeNo: firstNonEmpty(order.TransactionID, order.OpenOrderID), + ProviderStatus: status, + AmountFen: amount, + Currency: "CNY", + Paid: status == "OD", + Closed: status == "CD", + PaidAt: parseXunHuPayTime(order.PaidDate), + } +} + +func xunhupayGatewayOrigin(config Config) (string, error) { + raw := strings.TrimSpace(config["gateway"]) + if raw == "" { + raw = defaultXunHuPayGateway + } + parsed, err := url.Parse(raw) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.Fragment != "" || parsed.RawQuery != "" || (parsed.Port() != "" && parsed.Port() != "443") { + return "", errors.New("虎皮椒网关必须是官方 HTTPS 地址") + } + host := strings.ToLower(parsed.Hostname()) + if _, ok := xunhupayGatewayHosts[host]; !ok { + return "", errors.New("虎皮椒网关仅支持官方生产或备用域名") + } + if parsed.Path != "" && parsed.Path != "/" && !strings.HasPrefix(parsed.Path, "/payment/") { + return "", errors.New("虎皮椒网关路径无效") + } + return "https://" + parsed.Hostname(), nil +} + +func xunhupayCheckoutMode(config Config) string { + mode := strings.TrimSpace(config["checkoutMode"]) + if mode == "" { + return "qr_code" + } + return mode +} + +func xunhupayTitle(value string) string { + title := strings.ReplaceAll(truncateUTF8(value, 120), "%", "") + if title == "" { + return "积分充值" + } + return title +} + +func xunhupayHash(params map[string]string, secret string) string { + keys := make([]string, 0, len(params)) + for key, value := range params { + if key == "hash" || value == "" { + continue + } + keys = append(keys, key) + } + sort.Strings(keys) + parts := make([]string, 0, len(keys)) + for _, key := range keys { + parts = append(parts, key+"="+params[key]) + } + sum := md5.Sum([]byte(strings.Join(parts, "&") + secret)) + return hex.EncodeToString(sum[:]) +} + +func xunhupayHashEqual(params map[string]string, secret, actual string) bool { + expected := xunhupayHash(params, secret) + return actual != "" && strings.EqualFold(expected, strings.TrimSpace(actual)) +} + +func parseXunHuPayNotification(rawBody []byte) (map[string]string, error) { + trimmed := bytes.TrimSpace(rawBody) + if len(trimmed) == 0 { + return nil, errors.New("虎皮椒异步通知为空") + } + if trimmed[0] == '{' { + values, err := jsonObjectStrings(trimmed) + if err != nil { + return nil, fmt.Errorf("解析虎皮椒异步通知:%w", err) + } + return values, nil + } + form, err := url.ParseQuery(string(trimmed)) + if err != nil { + return nil, fmt.Errorf("解析虎皮椒异步通知:%w", err) + } + values := make(map[string]string, len(form)) + for key, items := range form { + if len(items) > 0 { + values[key] = items[0] + } + } + return values, nil +} + +func jsonObjectStrings(raw []byte) (map[string]string, error) { + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + var object map[string]any + if err := decoder.Decode(&object); err != nil { + return nil, err + } + values := make(map[string]string, len(object)) + for key, value := range object { + if key == "" || value == nil { + continue + } + switch typed := value.(type) { + case string: + values[key] = typed + case json.Number: + values[key] = typed.String() + case bool: + values[key] = strconv.FormatBool(typed) + } + } + return values, nil +} + +func parseXunHuPayTime(value string) time.Time { + value = strings.TrimSpace(value) + if value == "" { + return time.Time{} + } + if unix, err := strconv.ParseInt(value, 10, 64); err == nil && unix > 0 { + return time.Unix(unix, 0) + } + if parsed, err := time.ParseInLocation("2006-01-02 15:04:05", value, time.FixedZone("CST", 8*60*60)); err == nil { + return parsed + } + if parsed, err := time.Parse(time.RFC3339, value); err == nil { + return parsed + } + return time.Time{} +} + +func isXunHuPayOrderMissing(message string) bool { + lower := strings.ToLower(message) + return strings.Contains(lower, "not exist") || strings.Contains(lower, "not found") || strings.Contains(message, "不存在") +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func randomNonce() string { + var raw [16]byte + if _, err := rand.Read(raw[:]); err != nil { + return strconv.FormatInt(time.Now().UnixNano(), 16) + } + return hex.EncodeToString(raw[:]) +} diff --git a/backend/payment-plugins/xunhupay_test.go b/backend/payment-plugins/xunhupay_test.go new file mode 100644 index 000000000..fce5b85b5 --- /dev/null +++ b/backend/payment-plugins/xunhupay_test.go @@ -0,0 +1,168 @@ +package paymentplugins + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestXunHuPayCreateOrderSignsJSONAndReturnsQRCode(t *testing.T) { + fixedNow := time.Date(2026, 9, 17, 12, 0, 0, 0, time.FixedZone("CST", 8*60*60)) + config := testXunHuPayConfig() + var captured map[string]string + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodPost || request.URL.Path != "/payment/do.html" { + t.Fatalf("request = %s %s", request.Method, request.URL.Path) + } + if !strings.Contains(request.Header.Get("Content-Type"), "application/json") { + t.Fatalf("content-type = %q", request.Header.Get("Content-Type")) + } + body, err := io.ReadAll(request.Body) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(body, &captured); err != nil { + t.Fatal(err) + } + if captured["trade_order_id"] != "order-1" || captured["total_fee"] != "1.00" || captured["version"] != "1.1" { + t.Fatalf("payload = %#v", captured) + } + if captured["hash"] != xunhupayHash(captured, config["appSecret"]) { + t.Fatalf("hash = %q", captured["hash"]) + } + response := map[string]string{ + "errcode": "0", "errmsg": "success!", "url_qrcode": "https://api.xunhupay.com/qrcode.png", + "url": "https://api.xunhupay.com/pay/jump", + } + response["hash"] = xunhupayHash(response, config["appSecret"]) + _ = json.NewEncoder(writer).Encode(response) + })) + defer server.Close() + + provider := NewXunHuPayProvider(server.Client()) + provider.baseURL = server.URL + provider.now = func() time.Time { return fixedNow } + provider.nonce = func() string { return "fixed-nonce" } + checkout, err := provider.CreateOrder(context.Background(), config, CreateRequest{ + MerchantOrderNo: "order-1", Description: "100 积分", AmountFen: 100, Currency: "CNY", + ExpiresAt: fixedNow.Add(30 * time.Minute), NotifyURL: "https://merchant.example/api/payments/notify/xunhupay-aggregate/cfg", + ReturnURL: "https://merchant.example/api/payments/return/xunhupay-aggregate?orderId=1", + }) + if err != nil { + t.Fatal(err) + } + if checkout.Mode != "qr_code" || checkout.Value != "https://api.xunhupay.com/qrcode.png" { + t.Fatalf("checkout = %#v", checkout) + } + if !checkout.ExpiresAt.Equal(fixedNow.Add(5 * time.Minute)) { + t.Fatalf("expiresAt = %s", checkout.ExpiresAt) + } +} + +func TestXunHuPayCreateOrderCanReturnRedirectCheckout(t *testing.T) { + config := testXunHuPayConfig() + config["checkoutMode"] = "redirect" + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + response := map[string]string{"errcode": "0", "errmsg": "success!", "url": "https://api.xunhupay.com/pay/jump"} + response["hash"] = xunhupayHash(response, config["appSecret"]) + _ = json.NewEncoder(writer).Encode(response) + })) + defer server.Close() + provider := NewXunHuPayProvider(server.Client()) + provider.baseURL = server.URL + provider.nonce = func() string { return "fixed-nonce" } + checkout, err := provider.CreateOrder(context.Background(), config, CreateRequest{ + MerchantOrderNo: "order-2", Description: "充值", AmountFen: 200, Currency: "CNY", + ExpiresAt: time.Now().Add(20 * time.Minute), NotifyURL: "https://merchant.example/notify", + }) + if err != nil { + t.Fatal(err) + } + if checkout.Mode != "redirect" || checkout.Value != "https://api.xunhupay.com/pay/jump" { + t.Fatalf("checkout = %#v", checkout) + } +} + +func TestXunHuPayQueryAndCloseMapOfficialStatuses(t *testing.T) { + config := testXunHuPayConfig() + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/payment/query.html" { + t.Fatalf("path = %s", request.URL.Path) + } + var payload map[string]string + if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { + t.Fatal(err) + } + if payload["out_trade_order"] != "order-3" || payload["hash"] != xunhupayHash(payload, config["appSecret"]) { + t.Fatalf("payload = %#v", payload) + } + response := map[string]any{ + "errcode": 0, "errmsg": "success!", + "data": map[string]string{"status": "WP", "out_trade_order": "order-3", "open_order_id": "open-3", "total_fee": "1.50"}, + } + _ = json.NewEncoder(writer).Encode(response) + })) + defer server.Close() + provider := NewXunHuPayProvider(server.Client()) + provider.baseURL = server.URL + provider.nonce = func() string { return "fixed-nonce" } + result, err := provider.QueryOrder(context.Background(), config, QueryRequest{MerchantOrderNo: "order-3"}) + if err != nil { + t.Fatal(err) + } + if result.Paid || result.Closed || result.AmountFen != 150 || result.ProviderStatus != "WP" { + t.Fatalf("query = %#v", result) + } + closed, err := provider.CloseOrder(context.Background(), config, CloseRequest{MerchantOrderNo: "order-3"}) + if err != nil { + t.Fatal(err) + } + if !closed.Closed || closed.Paid || closed.ProviderStatus != "CD" { + t.Fatalf("close = %#v", closed) + } +} + +func TestXunHuPayNotificationVerifiesFormHash(t *testing.T) { + config := testXunHuPayConfig() + values := map[string]string{ + "trade_order_id": "order-4", "total_fee": "2.00", "transaction_id": "tx-4", + "open_order_id": "open-4", "status": "OD", "appid": config["appId"], "time": "1788321600", "nonce_str": "cb-nonce", + } + values["hash"] = xunhupayHash(values, config["appSecret"]) + body := "trade_order_id=order-4&total_fee=2.00&transaction_id=tx-4&open_order_id=open-4&status=OD&appid=" + config["appId"] + "&time=1788321600&nonce_str=cb-nonce&hash=" + values["hash"] + notification, err := NewXunHuPayProvider(http.DefaultClient).VerifyNotification(context.Background(), config, nil, []byte(body)) + if err != nil { + t.Fatal(err) + } + if !notification.Paid || notification.AmountFen != 200 || notification.MerchantOrderNo != "order-4" || notification.ProviderTradeNo != "tx-4" { + t.Fatalf("notification = %#v", notification) + } +} + +func TestXunHuPayRejectsUnknownGatewayAndMissingBills(t *testing.T) { + provider := NewXunHuPayProvider(http.DefaultClient) + config := testXunHuPayConfig() + config["gateway"] = "https://evil.example/payment/do.html" + if err := provider.ValidateConfig(config); err == nil { + t.Fatal("expected untrusted gateway to fail") + } + _, err := provider.DownloadTradeBill(context.Background(), testXunHuPayConfig(), time.Now()) + if !errors.Is(err, ErrTradeBillNotFound) { + t.Fatalf("bill error = %v", err) + } +} + +func testXunHuPayConfig() Config { + return Config{ + "publicBaseUrl": "https://merchant.example", + "appId": "20146122002", + "appSecret": "test-app-secret", + "gateway": defaultXunHuPayGateway, + } +} diff --git a/docs/content/docs/backend/backend-database.mdx b/docs/content/docs/backend/backend-database.mdx index 92e897356..3fa4cce88 100644 --- a/docs/content/docs/backend/backend-database.mdx +++ b/docs/content/docs/backend/backend-database.mdx @@ -23,7 +23,7 @@ description: 影策后端核心持久化表与状态边界 容器内命令 `migrate-schema up` 应用缺失迁移,`migrate-schema status` 输出当前和期望版本,`migrate-schema verify` 只检查兼容性。`CANVAS_AUTO_MIGRATE=true` 仅用于开发和旧部署兼容;受管生产部署设置为 `false`,并由 Compose 的一次性 `migrate` 服务先完成迁移。后续迁移必须新增版本,不得修改已经发布迁移的实现或校验和。 -当前结构版本为 18。版本 2 为 `schema_migrations.applied_at` 增加 `idx_schema_migrations_applied_at` 索引;版本 3 `asset_taxonomy_candidate_identity` 为资产候选增加规范化名称和来源,收敛资产分类,并为待确认候选建立项目内部分唯一索引;版本 4 `resource_upload_key` 为资源上传幂等补充 `resources.upload_key` 列和用户维度唯一索引;版本 5 `payment_topup` 创建积分充值、支付通知和对账所需结构;版本 6 `resource_playback_variant` 增加资源播放副本状态;版本 7 `asset_library_folders` 增加用户素材自定义分类及 `assets.folder_id`;版本 8 `logical_model_active_code` 将前台模型 code 的唯一约束限定为未归档记录;版本 9 `channel_presentation` 为渠道添加 `public_alias`(默认空字符串)与 `sort_order`(默认 0),为渠道模型添加 `sort_order`(默认 0),不重写既有凭据、价格或能力配置;版本 10 `creation_runtime` 创建创作运行与提交表,并为任务增加可空且唯一的 `creation_submission_id`,旧任务无需伪造提交标识即可继续使用;版本 11 `cloud_agent_runtime` 创建 `cloud_agent_executions`;版本 12 `agent_token_charge_limit` 为账单增加 Agent Token 收费上限;版本 13 `cloud_agent_canvas_mutation` 持久化 Agent 画布变更;版本 14 `cloud_agent_recovery_control` 增加运行恢复控制字段和旧运行回填;版本 15 `agent_profiles` 创建常驻偏好表;版本 16 `agent_lessons` 创建用户记忆表(含分类、路线 JSON、注入/查阅计数);版本 17 `agent_lessons_owner_index` 为 `(author_user_id, status)` 增加复合索引;版本 18 `agent_memory_settings` 创建按用户隔离的压缩周期与最近一次压缩任务状态。已发布版本的实现和校验和不得修改。 +当前结构版本为 19。版本 2 为 `schema_migrations.applied_at` 增加 `idx_schema_migrations_applied_at` 索引;版本 3 `asset_taxonomy_candidate_identity` 为资产候选增加规范化名称和来源,收敛资产分类,并为待确认候选建立项目内部分唯一索引;版本 4 `resource_upload_key` 为资源上传幂等补充 `resources.upload_key` 列和用户维度唯一索引;版本 5 `payment_topup` 创建积分充值、支付通知和对账所需结构;版本 6 `resource_playback_variant` 增加资源播放副本状态;版本 7 `asset_library_folders` 增加用户素材自定义分类及 `assets.folder_id`;版本 8 `logical_model_active_code` 将前台模型 code 的唯一约束限定为未归档记录;版本 9 `channel_presentation` 为渠道添加 `public_alias`(默认空字符串)与 `sort_order`(默认 0),为渠道模型添加 `sort_order`(默认 0),不重写既有凭据、价格或能力配置;版本 10 `creation_runtime` 创建创作运行与提交表,并为任务增加可空且唯一的 `creation_submission_id`,旧任务无需伪造提交标识即可继续使用;版本 11 `cloud_agent_runtime` 创建 `cloud_agent_executions`;版本 12 `agent_token_charge_limit` 为账单增加 Agent Token 收费上限;版本 13 `cloud_agent_canvas_mutation` 持久化 Agent 画布变更;版本 14 `cloud_agent_recovery_control` 增加运行恢复控制字段和旧运行回填;版本 15 `agent_profiles` 创建常驻偏好表;版本 16 `agent_lessons` 创建用户记忆表(含分类、路线 JSON、注入/查阅计数);版本 17 `agent_lessons_owner_index` 为 `(author_user_id, status)` 增加复合索引;版本 18 `agent_memory_settings` 创建按用户隔离的压缩周期与最近一次压缩任务状态;版本 19 `payment_plugin_version` 为已有 `payment_provider_configs` 和 `payment_orders` 补上 `plugin_version`,用于把配置和订单钉在具体插件版本上。已发布版本的实现和校验和不得修改。 历史发布曾使用版本 6 `asset_library_folders`,之后合并引入了上述 6/7 顺序冲突。迁移器仅在该历史记录的名称和校验和均精确匹配时选择历史顺序:保留版本 6 的素材目录记录及时间,在版本 7 应用 `resource_playback_variant`,再继续版本 8。新数据库及已采用“6 播放副本、7 素材目录”的数据库保持原顺序;状态检查与启动校验采用同一套识别规则。未知名称、被改动的校验和或冲突的版本 7 记录仍会拒绝迁移,不通过删除、改名或伪造已应用记录绕过检查。 diff --git a/docs/content/docs/plugins/payment-plugin-runtime.mdx b/docs/content/docs/plugins/payment-plugin-runtime.mdx index 522799aed..8069604dd 100644 --- a/docs/content/docs/plugins/payment-plugin-runtime.mdx +++ b/docs/content/docs/plugins/payment-plugin-runtime.mdx @@ -12,13 +12,27 @@ description: 影策支付插件的运行时协议、原生包边界与安全约 ```text my-payment.yingce-plugin ├── manifest.json -├── backend/provider +├── backend/provider # 规范副本:linux/amd64 +├── backend/provider-linux-amd64 +├── backend/provider-linux-arm64 +├── backend/provider-darwin-amd64 +├── backend/provider-darwin-arm64 +├── backend/provider-windows-amd64.exe +├── backend/provider-windows-arm64.exe ├── assets/icon.svg └── docs/README.md ``` Manifest 的支付贡献声明 `runtime.backend` 为 `rpc` 或 `wasm`,并通过 `runtime.backendEntry` 指定后端入口。当前版本首先启用 `rpc`:宿主以一次 JSON 请求启动入口进程,进程返回一次 JSON 响应。WASM 使用相同的 `yingce.payment/v1` 数据契约,运行时启用后即可复用插件实现。 +`backendEntry` 仍写 `backend/provider`。宿主按当前 `GOOS/GOARCH` 依次尝试: + +1. `backend/provider-{goos}-{goarch}[.exe]` +2. `backend/{goos}-{goarch}/provider[.exe]` +3. `backend/provider[.exe]`,再回退到无扩展名的 `backend/provider` + +选中文件后会校验 ELF / Mach-O / PE 魔数和 CPU,不兼容的文件会被跳过,不会拿 Linux ELF 在 macOS 或 Windows 上硬启动。 + ## 标准操作 入口进程接收以下操作名: @@ -32,28 +46,39 @@ Manifest 的支付贡献声明 `runtime.backend` 为 `rpc` 或 `wasm`,并通 | `verify_notification` | `Notification`,请求体通过 `bodyBase64` 传入 | | `download_trade_bill` | `BillRecord[]` | +`create_order` 的 `Checkout.value` 随 `mode` 含义不同:`qr_code` 可以是待编码载荷(如微信支付 `weixin://...`),也可以是渠道已经画好的二维码图片 URL(如虎皮椒 `url_qrcode`,形如 `https://api.xunhupay.com/qrcode.png`)。钱包识别图片 URL 后直接展示,不会把图片地址再编码成新的二维码。`redirect` 由宿主包装成 `/api/payments/orders/:id/checkout` 跳转。扫码区固定白底深色码点,避免暗色主题反色后难扫。 + 请求固定带有 `version: "yingce.payment/v1"`。插件不得访问宿主数据库,不得自行入账积分。宿主目前控制进程工作目录、环境变量、标准输出大小和 30 秒超时;支付宝、微信实现还会在插件内部校验网关和账单下载域名。通用插件的操作系统级沙箱、签名校验和网络白名单尚未启用,不应直接运行不受信任的支付插件。 ## 原生包的平台边界 -`runtime.backend: "rpc"` 的 `backend/provider` 是原生可执行文件,不是平台无关静态资源。官方 backend 镜像在 Docker 构建中读取 `TARGETOS` 和 `TARGETARCH`,以 `CGO_ENABLED=0` 分别生成目标平台 provider,并在同一阶段重新生成 `.yingce-plugin`;目录文件与包内文件会比较内容,并在目标平台容器中真实执行冒烟请求。因此 Apple Silicon、Intel Mac 或 Windows 上发起的 Docker 构建不会把宿主二进制复制进 Linux 镜像。 +`runtime.backend: "rpc"` 的入口是原生可执行文件,不是平台无关静态资源。仓库中的官方支付包同时包含 macOS、Linux、Windows 的 amd64/arm64 产物;`backend/provider` 固定为 `linux/amd64` 副本,供 Linux CI 和默认冒烟使用。 -仓库中保留的官方支付包用于 Linux amd64 源码运行和回归测试,其规范平台固定为 `linux/amd64`。在其他平台直接运行源码时,应先在具有 Node.js 和 `zip` 的 POSIX shell 中执行: +官方 backend 镜像在 Docker 构建中读取 `TARGETOS` 和 `TARGETARCH`,设置 `PAYMENT_PLUGIN_GOOS` / `PAYMENT_PLUGIN_GOARCH`,只编译镜像目标平台并重新打包 `.yingce-plugin`,避免把 darwin/windows 二进制打进 Linux 镜像。目录文件与包内文件会比较内容,并在目标平台容器中真实执行冒烟请求。 + +在 POSIX 环境重建官方支付包: ```bash -PAYMENT_PLUGIN_GOOS="$(go env GOOS)" \ -PAYMENT_PLUGIN_GOARCH="$(go env GOARCH)" \ PAYMENT_PLUGIN_CGO_ENABLED=0 \ ./plugin-packages/build-packages.sh --payments-only ``` -当前 manifest 尚未声明 native artifact 的 OS/CPU;第三方市场若要分发原生 RPC 插件,应后续在 package index 或 manifest ABI 中增加平台选择信息,不能把单一 native package 视为跨平台包。 +不设置 `PAYMENT_PLUGIN_GOOS` 时会交叉编译全部六个目标。只构建当前镜像平台时: + +```bash +PAYMENT_PLUGIN_GOOS=linux \ +PAYMENT_PLUGIN_GOARCH=amd64 \ +PAYMENT_PLUGIN_CGO_ENABLED=0 \ + ./plugin-packages/build-packages.sh --payments-only +``` + +第三方 RPC 插件至少需要提供当前宿主对应的 tagged 文件;只放 Linux ELF 时,macOS / Windows 会在启动前报格式不兼容,而不是当作脚本解释器缺失。 ## 运行时缓存与诊断 -宿主以完整 `.yingce-plugin` 字节的 SHA-256 作为 package 文件名和 `runtime/` 缓存键。provider 内容变化会产生新 digest 和新运行目录;解包先写临时目录,完成后写入 ready marker 并原子发布,旧目录不会覆盖新包,也不要求升级时手工清空整个数据卷。 +宿主以完整 `.yingce-plugin` 字节的 SHA-256 作为 package 文件名和 `runtime/` 缓存键。provider 内容变化会产生新 digest 和新运行目录;解包先写临时目录,完成后写入 ready marker 并原子发布,旧目录不会覆盖新包,也不要求升级时手工清空整个数据卷。ready 检查接受任意平台的 RPC 产物,不要求 `backend/provider` 这个规范文件名一定存在。 -执行前,Linux 宿主会拒绝 Mach-O、Windows PE、损坏的 ELF 和与当前 CPU 不匹配的 ELF。管理员可区分可执行文件缺失或加载器不可用、权限不足、格式或架构不兼容、进程异常退出、超时和无效 JSON。服务端启动日志只记录插件 ID、provider ID、宿主平台、可执行路径和操作系统错误;不会记录 RPC 请求、支付配置、密钥或插件 stderr。 +执行前,Linux 会拒绝 Mach-O、Windows PE、损坏的 ELF 和与当前 CPU 不匹配的 ELF;macOS 会拒绝 ELF/PE 以及 CPU 不匹配的 Mach-O;Windows 会拒绝 ELF/Mach-O 以及 CPU 不匹配的 PE。管理员可区分可执行文件缺失或加载器不可用、权限不足、格式或架构不兼容、进程异常退出、超时和无效 JSON。服务端启动日志只记录插件 ID、provider ID、宿主平台、可执行路径和操作系统错误;不会记录 RPC 请求、支付配置、密钥或插件 stderr。 ## Manifest 示例 diff --git a/plugin-packages/build-packages.sh b/plugin-packages/build-packages.sh index 1dc9b892a..5ece7f3e9 100755 --- a/plugin-packages/build-packages.sh +++ b/plugin-packages/build-packages.sh @@ -3,8 +3,9 @@ set -eu root_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) repo_dir=$(CDPATH= cd -- "$root_dir/.." && pwd) -payment_plugins="official-payment-wechat-native official-payment-alipay-page" +payment_plugins="official-payment-wechat-native official-payment-alipay-page official-payment-xunhupay" payments_only=false +default_payment_targets="linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64" if [ "${1:-}" = "--payments-only" ]; then payments_only=true @@ -21,10 +22,9 @@ else node "$root_dir/embed-documentation.mjs" fi -# Payment backends are executable plugin payloads. They are compiled outside -# the host binary and then included under backend/provider in the package. -payment_goos=${PAYMENT_PLUGIN_GOOS:-$(go env GOOS)} -payment_goarch=${PAYMENT_PLUGIN_GOARCH:-$(go env GOARCH)} +# Payment backends are executable plugin payloads. Unset PAYMENT_PLUGIN_GOOS +# builds every supported host platform into the package. Docker image builds +# set PAYMENT_PLUGIN_GOOS/GOARCH to the image TARGETOS/TARGETARCH only. payment_cgo=${PAYMENT_PLUGIN_CGO_ENABLED:-0} case "$payment_cgo" in @@ -35,32 +35,90 @@ case "$payment_cgo" in ;; esac +if [ -n "${PAYMENT_PLUGIN_GOOS:-}" ]; then + if [ -z "${PAYMENT_PLUGIN_GOARCH:-}" ]; then + echo "PAYMENT_PLUGIN_GOARCH is required when PAYMENT_PLUGIN_GOOS is set" >&2 + exit 2 + fi + payment_targets="${PAYMENT_PLUGIN_GOOS}/${PAYMENT_PLUGIN_GOARCH}" + canonical_target="$payment_targets" +else + payment_targets="$default_payment_targets" + canonical_target="linux/amd64" +fi + +payment_artifact_name() { + goos=$1 + goarch=$2 + if [ "$goos" = "windows" ]; then + printf 'provider-%s-%s.exe\n' "$goos" "$goarch" + else + printf 'provider-%s-%s\n' "$goos" "$goarch" + fi +} + +clean_payment_backends() { + package_id=$1 + backend_dir="$root_dir/$package_id/backend" + mkdir -p "$backend_dir" + rm -f \ + "$backend_dir/provider" \ + "$backend_dir"/provider-linux-* \ + "$backend_dir"/provider-darwin-* \ + "$backend_dir"/provider-windows-*.exe + rm -rf \ + "$backend_dir/linux-amd64" \ + "$backend_dir/linux-arm64" \ + "$backend_dir/darwin-amd64" \ + "$backend_dir/darwin-arm64" \ + "$backend_dir/windows-amd64" \ + "$backend_dir/windows-arm64" +} + build_payment_provider() { package_id=$1 command_path=$2 - output_file="$root_dir/$package_id/backend/provider" + goos=$3 + goarch=$4 + backend_dir="$root_dir/$package_id/backend" + artifact_name=$(payment_artifact_name "$goos" "$goarch") + output_file="$backend_dir/$artifact_name" temporary_file="$output_file.tmp" - mkdir -p "$(dirname -- "$output_file")" + mkdir -p "$backend_dir" rm -f "$temporary_file" ( cd "$repo_dir/backend" CGO_ENABLED="$payment_cgo" \ - GOOS="$payment_goos" \ - GOARCH="$payment_goarch" \ + GOOS="$goos" \ + GOARCH="$goarch" \ go build -trimpath -ldflags='-s -w' -o "$temporary_file" "$command_path" ) mv "$temporary_file" "$output_file" chmod 0755 "$output_file" build_info=$(go version -m "$output_file") - printf '%s\n' "$build_info" | grep -F "GOOS=$payment_goos" >/dev/null - printf '%s\n' "$build_info" | grep -F "GOARCH=$payment_goarch" >/dev/null + printf '%s\n' "$build_info" | grep -F "GOOS=$goos" >/dev/null + printf '%s\n' "$build_info" | grep -F "GOARCH=$goarch" >/dev/null printf '%s\n' "$build_info" | grep -F "CGO_ENABLED=$payment_cgo" >/dev/null + + if [ "$goos/$goarch" = "$canonical_target" ]; then + cp "$output_file" "$backend_dir/provider" + chmod 0755 "$backend_dir/provider" + fi } -build_payment_provider official-payment-wechat-native ./cmd/payment-wechat -build_payment_provider official-payment-alipay-page ./cmd/payment-alipay +for payment_plugin in $payment_plugins; do + clean_payment_backends "$payment_plugin" +done + +for target in $payment_targets; do + target_goos=${target%/*} + target_goarch=${target#*/} + build_payment_provider official-payment-wechat-native ./cmd/payment-wechat "$target_goos" "$target_goarch" + build_payment_provider official-payment-alipay-page ./cmd/payment-alipay "$target_goos" "$target_goarch" + build_payment_provider official-payment-xunhupay ./cmd/payment-xunhupay "$target_goos" "$target_goarch" +done package_plugin() { package_id=$1 diff --git a/plugin-packages/official-payment-alipay-page.yingce-plugin b/plugin-packages/official-payment-alipay-page.yingce-plugin index 0044e9c16..cc7cb0dc0 100644 Binary files a/plugin-packages/official-payment-alipay-page.yingce-plugin 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 index 9f790cbd1..d209dcdbd 100644 --- a/plugin-packages/official-payment-alipay-page/README.md +++ b/plugin-packages/official-payment-alipay-page/README.md @@ -1,3 +1,3 @@ # 支付宝电脑网站支付 -官方 `yingce.payment/v1` 支付插件。后端入口由构建脚本编译为 `backend/provider`。 +官方 `yingce.payment/v1` 支付插件。构建脚本交叉编译 macOS、Linux、Windows 的 amd64/arm64,入口仍声明为 `backend/provider`。 diff --git a/plugin-packages/official-payment-alipay-page/backend/provider b/plugin-packages/official-payment-alipay-page/backend/provider index 9c3a27b0a..14273215a 100755 Binary files a/plugin-packages/official-payment-alipay-page/backend/provider and b/plugin-packages/official-payment-alipay-page/backend/provider differ diff --git a/plugin-packages/official-payment-alipay-page/backend/provider-darwin-amd64 b/plugin-packages/official-payment-alipay-page/backend/provider-darwin-amd64 new file mode 100755 index 000000000..4c696b843 Binary files /dev/null and b/plugin-packages/official-payment-alipay-page/backend/provider-darwin-amd64 differ diff --git a/plugin-packages/official-payment-alipay-page/backend/provider-darwin-arm64 b/plugin-packages/official-payment-alipay-page/backend/provider-darwin-arm64 new file mode 100755 index 000000000..9e2b71a74 Binary files /dev/null and b/plugin-packages/official-payment-alipay-page/backend/provider-darwin-arm64 differ diff --git a/plugin-packages/official-payment-alipay-page/backend/provider-linux-amd64 b/plugin-packages/official-payment-alipay-page/backend/provider-linux-amd64 new file mode 100755 index 000000000..14273215a Binary files /dev/null and b/plugin-packages/official-payment-alipay-page/backend/provider-linux-amd64 differ diff --git a/plugin-packages/official-payment-alipay-page/backend/provider-linux-arm64 b/plugin-packages/official-payment-alipay-page/backend/provider-linux-arm64 new file mode 100755 index 000000000..ee29e376a Binary files /dev/null and b/plugin-packages/official-payment-alipay-page/backend/provider-linux-arm64 differ diff --git a/plugin-packages/official-payment-alipay-page/backend/provider-windows-amd64.exe b/plugin-packages/official-payment-alipay-page/backend/provider-windows-amd64.exe new file mode 100755 index 000000000..94755475f Binary files /dev/null and b/plugin-packages/official-payment-alipay-page/backend/provider-windows-amd64.exe differ diff --git a/plugin-packages/official-payment-alipay-page/backend/provider-windows-arm64.exe b/plugin-packages/official-payment-alipay-page/backend/provider-windows-arm64.exe new file mode 100755 index 000000000..1b0a248ea Binary files /dev/null and b/plugin-packages/official-payment-alipay-page/backend/provider-windows-arm64.exe differ diff --git a/plugin-packages/official-payment-alipay-page/manifest.json b/plugin-packages/official-payment-alipay-page/manifest.json index e7724ccf4..ef5904934 100644 --- a/plugin-packages/official-payment-alipay-page/manifest.json +++ b/plugin-packages/official-payment-alipay-page/manifest.json @@ -93,5 +93,5 @@ } ] }, - "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" + "documentation": "# 支付宝电脑网站支付\n\n官方 `yingce.payment/v1` 支付插件。构建脚本交叉编译 macOS、Linux、Windows 的 amd64/arm64,入口仍声明为 `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 index 336e01827..722201145 100644 Binary files a/plugin-packages/official-payment-wechat-native.yingce-plugin 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 index 49692037c..f5d02ce53 100644 --- a/plugin-packages/official-payment-wechat-native/README.md +++ b/plugin-packages/official-payment-wechat-native/README.md @@ -1,3 +1,3 @@ # 微信支付 Native -官方 `yingce.payment/v1` 支付插件。后端入口由构建脚本编译为 `backend/provider`。 +官方 `yingce.payment/v1` 支付插件。构建脚本交叉编译 macOS、Linux、Windows 的 amd64/arm64,入口仍声明为 `backend/provider`。 diff --git a/plugin-packages/official-payment-wechat-native/backend/provider b/plugin-packages/official-payment-wechat-native/backend/provider index ceeef027a..efad8661b 100755 Binary files a/plugin-packages/official-payment-wechat-native/backend/provider and b/plugin-packages/official-payment-wechat-native/backend/provider differ diff --git a/plugin-packages/official-payment-wechat-native/backend/provider-darwin-amd64 b/plugin-packages/official-payment-wechat-native/backend/provider-darwin-amd64 new file mode 100755 index 000000000..a0781de3d Binary files /dev/null and b/plugin-packages/official-payment-wechat-native/backend/provider-darwin-amd64 differ diff --git a/plugin-packages/official-payment-wechat-native/backend/provider-darwin-arm64 b/plugin-packages/official-payment-wechat-native/backend/provider-darwin-arm64 new file mode 100755 index 000000000..bd0b35935 Binary files /dev/null and b/plugin-packages/official-payment-wechat-native/backend/provider-darwin-arm64 differ diff --git a/plugin-packages/official-payment-wechat-native/backend/provider-linux-amd64 b/plugin-packages/official-payment-wechat-native/backend/provider-linux-amd64 new file mode 100755 index 000000000..efad8661b Binary files /dev/null and b/plugin-packages/official-payment-wechat-native/backend/provider-linux-amd64 differ diff --git a/plugin-packages/official-payment-wechat-native/backend/provider-linux-arm64 b/plugin-packages/official-payment-wechat-native/backend/provider-linux-arm64 new file mode 100755 index 000000000..5f523b652 Binary files /dev/null and b/plugin-packages/official-payment-wechat-native/backend/provider-linux-arm64 differ diff --git a/plugin-packages/official-payment-wechat-native/backend/provider-windows-amd64.exe b/plugin-packages/official-payment-wechat-native/backend/provider-windows-amd64.exe new file mode 100755 index 000000000..3b7eb6a6f Binary files /dev/null and b/plugin-packages/official-payment-wechat-native/backend/provider-windows-amd64.exe differ diff --git a/plugin-packages/official-payment-wechat-native/backend/provider-windows-arm64.exe b/plugin-packages/official-payment-wechat-native/backend/provider-windows-arm64.exe new file mode 100755 index 000000000..7940833d6 Binary files /dev/null and b/plugin-packages/official-payment-wechat-native/backend/provider-windows-arm64.exe differ diff --git a/plugin-packages/official-payment-wechat-native/manifest.json b/plugin-packages/official-payment-wechat-native/manifest.json index e5130a27b..9d9847722 100644 --- a/plugin-packages/official-payment-wechat-native/manifest.json +++ b/plugin-packages/official-payment-wechat-native/manifest.json @@ -101,5 +101,5 @@ } ] }, - "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" + "documentation": "# 微信支付 Native\n\n官方 `yingce.payment/v1` 支付插件。构建脚本交叉编译 macOS、Linux、Windows 的 amd64/arm64,入口仍声明为 `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/plugin-packages/official-payment-xunhupay.yingce-plugin b/plugin-packages/official-payment-xunhupay.yingce-plugin new file mode 100644 index 000000000..1d1849222 Binary files /dev/null and b/plugin-packages/official-payment-xunhupay.yingce-plugin differ diff --git a/plugin-packages/official-payment-xunhupay/README.md b/plugin-packages/official-payment-xunhupay/README.md new file mode 100644 index 000000000..9e8a625f6 --- /dev/null +++ b/plugin-packages/official-payment-xunhupay/README.md @@ -0,0 +1,3 @@ +# 虎皮椒聚合支付 + +官方 `yingce.payment/v1` 支付插件。构建脚本交叉编译 macOS、Linux、Windows 的 amd64/arm64,入口仍声明为 `backend/provider`。扫码模式返回虎皮椒 `url_qrcode` 图片地址,由钱包直接展示该图。 diff --git a/plugin-packages/official-payment-xunhupay/assets/icon.svg b/plugin-packages/official-payment-xunhupay/assets/icon.svg new file mode 100644 index 000000000..77d51762e --- /dev/null +++ b/plugin-packages/official-payment-xunhupay/assets/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/plugin-packages/official-payment-xunhupay/backend/provider b/plugin-packages/official-payment-xunhupay/backend/provider new file mode 100755 index 000000000..2ff190b46 Binary files /dev/null and b/plugin-packages/official-payment-xunhupay/backend/provider differ diff --git a/plugin-packages/official-payment-xunhupay/backend/provider-darwin-amd64 b/plugin-packages/official-payment-xunhupay/backend/provider-darwin-amd64 new file mode 100755 index 000000000..4524b544b Binary files /dev/null and b/plugin-packages/official-payment-xunhupay/backend/provider-darwin-amd64 differ diff --git a/plugin-packages/official-payment-xunhupay/backend/provider-darwin-arm64 b/plugin-packages/official-payment-xunhupay/backend/provider-darwin-arm64 new file mode 100755 index 000000000..c16506709 Binary files /dev/null and b/plugin-packages/official-payment-xunhupay/backend/provider-darwin-arm64 differ diff --git a/plugin-packages/official-payment-xunhupay/backend/provider-linux-amd64 b/plugin-packages/official-payment-xunhupay/backend/provider-linux-amd64 new file mode 100755 index 000000000..2ff190b46 Binary files /dev/null and b/plugin-packages/official-payment-xunhupay/backend/provider-linux-amd64 differ diff --git a/plugin-packages/official-payment-xunhupay/backend/provider-linux-arm64 b/plugin-packages/official-payment-xunhupay/backend/provider-linux-arm64 new file mode 100755 index 000000000..3f37d9510 Binary files /dev/null and b/plugin-packages/official-payment-xunhupay/backend/provider-linux-arm64 differ diff --git a/plugin-packages/official-payment-xunhupay/backend/provider-windows-amd64.exe b/plugin-packages/official-payment-xunhupay/backend/provider-windows-amd64.exe new file mode 100755 index 000000000..2b1f5da0c Binary files /dev/null and b/plugin-packages/official-payment-xunhupay/backend/provider-windows-amd64.exe differ diff --git a/plugin-packages/official-payment-xunhupay/backend/provider-windows-arm64.exe b/plugin-packages/official-payment-xunhupay/backend/provider-windows-arm64.exe new file mode 100755 index 000000000..25c869014 Binary files /dev/null and b/plugin-packages/official-payment-xunhupay/backend/provider-windows-arm64.exe differ diff --git a/plugin-packages/official-payment-xunhupay/docs/interface.md b/plugin-packages/official-payment-xunhupay/docs/interface.md new file mode 100644 index 000000000..4e19bf9ab --- /dev/null +++ b/plugin-packages/official-payment-xunhupay/docs/interface.md @@ -0,0 +1,111 @@ +## yingce.payment/v1 + +支持 `validate_config`、`create_order`、`query_order`、`close_order`、`verify_notification` 和 `download_trade_bill`,统一返回 JSON 响应。 + +虎皮椒渠道协议全部封装在本插件内:下单 `POST /payment/do.html`(JSON),查单 `POST /payment/query.html`,异步通知为 form,成功应答 `success`。配置字段包括 `publicBaseUrl`、`appId`、`appSecret`、`gateway`、`checkoutMode`。没有独立关单和交易账单下载接口:未支付关单按查单结果本地关闭,对账账单返回 not found。 + + +## Manifest 完整接口定义 + +以下 JSON 与插件包内实际 `manifest.json` 逐字段一致,覆盖插件身份、权限、配置、鉴权、参数、校验、创建、Agent、查询、取消、结果下载、响应和 Agent 响应映射。`documentation` 字段的值就是当前完整文档;为避免文档在自身内部无限递归,JSON 中仅用等义占位文本表示正文。 + +```json +{ + "apiVersion": "yingce.plugin/v1", + "id": "official-payment-xunhupay", + "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, + "description": "用于生成异步通知和同步返回地址,必须可被虎皮椒访问。" + }, + { + "name": "appId", + "type": "string", + "label": "虎皮椒 APPID", + "required": true + }, + { + "name": "appSecret", + "type": "password", + "label": "虎皮椒 APPSECRET", + "required": true, + "secret": true + }, + { + "name": "gateway", + "type": "url", + "label": "支付网关", + "required": true, + "default": "https://api.xunhupay.com", + "description": "正式环境 https://api.xunhupay.com,备用 https://api.dpweixin.com。" + }, + { + "name": "checkoutMode", + "type": "string", + "label": "收银方式", + "required": false, + "default": "qr_code", + "values": [ + "qr_code", + "redirect" + ], + "description": "qr_code 展示渠道返回的二维码图片 url_qrcode(约 5 分钟有效),钱包直接显示该图片;redirect 跳转手机端支付页 url。" + } + ] + }, + "contributes": { + "paymentProviders": [ + { + "id": "xunhupay-aggregate", + "label": "虎皮椒聚合支付", + "icon": "assets/icon.svg", + "checkoutMode": "qr_code", + "identityFields": [ + "appId" + ], + "expiryPolicy": { + "defaultMinutes": 5, + "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-xunhupay/manifest.json b/plugin-packages/official-payment-xunhupay/manifest.json new file mode 100644 index 000000000..cef683e66 --- /dev/null +++ b/plugin-packages/official-payment-xunhupay/manifest.json @@ -0,0 +1,97 @@ +{ + "apiVersion": "yingce.plugin/v1", + "id": "official-payment-xunhupay", + "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, + "description": "用于生成异步通知和同步返回地址,必须可被虎皮椒访问。" + }, + { + "name": "appId", + "type": "string", + "label": "虎皮椒 APPID", + "required": true + }, + { + "name": "appSecret", + "type": "password", + "label": "虎皮椒 APPSECRET", + "required": true, + "secret": true + }, + { + "name": "gateway", + "type": "url", + "label": "支付网关", + "required": true, + "default": "https://api.xunhupay.com", + "description": "正式环境 https://api.xunhupay.com,备用 https://api.dpweixin.com。" + }, + { + "name": "checkoutMode", + "type": "string", + "label": "收银方式", + "required": false, + "default": "qr_code", + "values": [ + "qr_code", + "redirect" + ], + "description": "qr_code 展示渠道返回的二维码图片 url_qrcode(约 5 分钟有效),钱包直接显示该图片;redirect 跳转手机端支付页 url。" + } + ] + }, + "contributes": { + "paymentProviders": [ + { + "id": "xunhupay-aggregate", + "label": "虎皮椒聚合支付", + "icon": "assets/icon.svg", + "checkoutMode": "qr_code", + "identityFields": [ + "appId" + ], + "expiryPolicy": { + "defaultMinutes": 5, + "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` 支付插件。构建脚本交叉编译 macOS、Linux、Windows 的 amd64/arm64,入口仍声明为 `backend/provider`。扫码模式返回虎皮椒 `url_qrcode` 图片地址,由钱包直接展示该图。\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虎皮椒渠道协议全部封装在本插件内:下单 `POST /payment/do.html`(JSON),查单 `POST /payment/query.html`,异步通知为 form,成功应答 `success`。配置字段包括 `publicBaseUrl`、`appId`、`appSecret`、`gateway`、`checkoutMode`。没有独立关单和交易账单下载接口:未支付关单按查单结果本地关闭,对账账单返回 not found。\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-xunhupay\",\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 \"description\": \"用于生成异步通知和同步返回地址,必须可被虎皮椒访问。\"\n },\n {\n \"name\": \"appId\",\n \"type\": \"string\",\n \"label\": \"虎皮椒 APPID\",\n \"required\": true\n },\n {\n \"name\": \"appSecret\",\n \"type\": \"password\",\n \"label\": \"虎皮椒 APPSECRET\",\n \"required\": true,\n \"secret\": true\n },\n {\n \"name\": \"gateway\",\n \"type\": \"url\",\n \"label\": \"支付网关\",\n \"required\": true,\n \"default\": \"https://api.xunhupay.com\",\n \"description\": \"正式环境 https://api.xunhupay.com,备用 https://api.dpweixin.com。\"\n },\n {\n \"name\": \"checkoutMode\",\n \"type\": \"string\",\n \"label\": \"收银方式\",\n \"required\": false,\n \"default\": \"qr_code\",\n \"values\": [\n \"qr_code\",\n \"redirect\"\n ],\n \"description\": \"qr_code 展示渠道返回的二维码图片 url_qrcode(约 5 分钟有效),钱包直接显示该图片;redirect 跳转手机端支付页 url。\"\n }\n ]\n },\n \"contributes\": {\n \"paymentProviders\": [\n {\n \"id\": \"xunhupay-aggregate\",\n \"label\": \"虎皮椒聚合支付\",\n \"icon\": \"assets/icon.svg\",\n \"checkoutMode\": \"qr_code\",\n \"identityFields\": [\n \"appId\"\n ],\n \"expiryPolicy\": {\n \"defaultMinutes\": 5,\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/verify-payment-packages.sh b/plugin-packages/verify-payment-packages.sh index e5936e876..e44029e81 100644 --- a/plugin-packages/verify-payment-packages.sh +++ b/plugin-packages/verify-payment-packages.sh @@ -2,17 +2,31 @@ set -eu root_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +require_all=false + +if [ "${1:-}" = "--all" ]; then + require_all=true + shift +fi + expected_goos=${1:-} expected_goarch=${2:-} -if [ "$expected_goos" != "linux" ]; then - echo "payment package verification currently requires expected GOOS linux" >&2 +if [ -z "$expected_goos" ] || [ -z "$expected_goarch" ]; then + echo "usage: $0 [--all] " >&2 exit 2 fi +case "$expected_goos" in + linux|darwin|windows) ;; + *) + echo "unsupported payment package OS: $expected_goos" >&2 + exit 2 + ;; +esac + case "$expected_goarch" in - amd64) expected_machine=3e00 ;; - arm64) expected_machine=b700 ;; + amd64|arm64) ;; *) echo "unsupported payment package architecture: $expected_goarch" >&2 exit 2 @@ -22,44 +36,132 @@ esac temporary_dir=$(mktemp -d) trap 'rm -rf "$temporary_dir"' EXIT HUP INT TERM -for package_id in official-payment-wechat-native official-payment-alipay-page; do - provider="$root_dir/$package_id/backend/provider" +file_magic() { + od -An -tx1 -N4 "$1" | tr -d '[:space:]' +} + +payment_artifact_name() { + goos=$1 + goarch=$2 + if [ "$goos" = "windows" ]; then + printf 'provider-%s-%s.exe\n' "$goos" "$goarch" + else + printf 'provider-%s-%s\n' "$goos" "$goarch" + fi +} + +verify_platform_magic() { + path=$1 + goos=$2 + goarch=$3 + magic=$(file_magic "$path") + case "$goos" in + linux) + if [ "$magic" != "7f454c46" ]; then + echo "$path is not an ELF executable (magic=$magic)" >&2 + return 1 + fi + class=$(od -An -tx1 -j4 -N1 "$path" | tr -d '[:space:]') + byte_order=$(od -An -tx1 -j5 -N1 "$path" | tr -d '[:space:]') + if [ "$class" != "02" ] || [ "$byte_order" != "01" ]; then + echo "$path is not a 64-bit little-endian ELF executable" >&2 + return 1 + fi + expected_machine=3e00 + if [ "$goarch" = "arm64" ]; then + expected_machine=b700 + fi + machine=$(od -An -tx1 -j18 -N2 "$path" | tr -d '[:space:]') + if [ "$machine" != "$expected_machine" ]; then + echo "$path architecture does not match linux/$goarch (ELF machine=$machine)" >&2 + return 1 + fi + ;; + darwin) + case "$magic" in + cffaedfe|cefaedfe|feedfacf|feedface|cafebabe|bebafeca) ;; + *) + echo "$path is not a Mach-O executable (magic=$magic)" >&2 + return 1 + ;; + esac + ;; + windows) + mz=$(od -An -tx1 -N2 "$path" | tr -d '[:space:]') + if [ "$mz" != "4d5a" ]; then + echo "$path is not a Windows PE executable (magic=$mz)" >&2 + return 1 + fi + ;; + esac +} + +verify_packaged_file() { + package_file=$1 + relative_path=$2 + source_file=$3 + archived_file=$4 + unzip -p "$package_file" "$relative_path" >"$archived_file" + if ! cmp -s "$source_file" "$archived_file"; then + echo "$relative_path in $package_file differs from the directory file" >&2 + return 1 + fi +} + +all_targets="linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64" +verify_targets="$expected_goos/$expected_goarch" +if [ "$require_all" = true ]; then + verify_targets="$all_targets" + if [ "$expected_goos/$expected_goarch" != "linux/amd64" ]; then + echo "--all currently expects canonical linux/amd64 as the host verification target" >&2 + exit 2 + fi +fi + +for package_id in official-payment-wechat-native official-payment-alipay-page official-payment-xunhupay; do + backend_dir="$root_dir/$package_id/backend" + provider="$backend_dir/provider" package_file="$root_dir/$package_id.yingce-plugin" - archived_provider="$temporary_dir/$package_id-provider" + expected_artifact=$(payment_artifact_name "$expected_goos" "$expected_goarch") test -f "$provider" - if [ "$(uname -s)" = "Linux" ]; then + test -f "$package_file" + if [ "$(uname -s)" = "Linux" ] || [ "$(uname -s)" = "Darwin" ]; then test -x "$provider" fi - test -f "$package_file" - magic=$(od -An -tx1 -N4 "$provider" | tr -d '[:space:]') - if [ "$magic" != "7f454c46" ]; then - echo "$package_id provider is not an ELF executable (magic=$magic)" >&2 + verify_platform_magic "$provider" "$expected_goos" "$expected_goarch" + verify_packaged_file "$package_file" backend/provider "$provider" "$temporary_dir/$package_id-provider" + + test -f "$backend_dir/$expected_artifact" + if ! cmp -s "$provider" "$backend_dir/$expected_artifact"; then + echo "$package_id backend/provider does not match $expected_artifact" >&2 exit 1 fi + verify_packaged_file "$package_file" "backend/$expected_artifact" "$backend_dir/$expected_artifact" "$temporary_dir/$package_id-$expected_artifact" - class=$(od -An -tx1 -j4 -N1 "$provider" | tr -d '[:space:]') - byte_order=$(od -An -tx1 -j5 -N1 "$provider" | tr -d '[:space:]') - if [ "$class" != "02" ] || [ "$byte_order" != "01" ]; then - echo "$package_id provider is not a 64-bit little-endian ELF executable" >&2 - exit 1 - fi - - machine=$(od -An -tx1 -j18 -N2 "$provider" | tr -d '[:space:]') - if [ "$machine" != "$expected_machine" ]; then - echo "$package_id provider architecture does not match linux/$expected_goarch (ELF machine=$machine)" >&2 - exit 1 - fi - - unzip -p "$package_file" backend/provider >"$archived_provider" - if ! cmp -s "$provider" "$archived_provider"; then - echo "$package_id directory provider and packaged provider differ" >&2 - exit 1 - fi + for target in $verify_targets; do + target_goos=${target%/*} + target_goarch=${target#*/} + artifact_name=$(payment_artifact_name "$target_goos" "$target_goarch") + artifact_path="$backend_dir/$artifact_name" + test -f "$artifact_path" + verify_platform_magic "$artifact_path" "$target_goos" "$target_goarch" + verify_packaged_file "$package_file" "backend/$artifact_name" "$artifact_path" "$temporary_dir/$package_id-$artifact_name" + done if [ "${PAYMENT_PLUGIN_SMOKE_TEST:-0}" = "1" ]; then - response=$(printf 'invalid-json\n' | "$provider") - printf '%s\n' "$response" | grep -F '"code":"invalid_request"' >/dev/null + current_sys=$(uname -s) + can_smoke=0 + if [ "$expected_goos" = "linux" ] && [ "$current_sys" = "Linux" ]; then + can_smoke=1 + fi + if [ "$expected_goos" = "darwin" ] && [ "$current_sys" = "Darwin" ]; then + can_smoke=1 + fi + if [ "$can_smoke" = "1" ]; then + response=$(printf 'invalid-json\n' | "$provider") + printf '%s\n' "$response" | grep -F '"code":"invalid_request"' >/dev/null + fi fi done diff --git a/web/src/components/payment-checkout-code.css b/web/src/components/payment-checkout-code.css new file mode 100644 index 000000000..5b7fd2a5e --- /dev/null +++ b/web/src/components/payment-checkout-code.css @@ -0,0 +1,23 @@ +.payment-checkout-code { + display: grid; + place-items: center; + box-sizing: border-box; + width: 232px; + height: 232px; + padding: 12px; + border-radius: 16px; + background: #ffffff; +} + +.payment-checkout-code img, +.payment-checkout-code canvas, +.payment-checkout-code svg { + display: block; + width: 208px; + height: 208px; +} + +.payment-checkout-code img { + object-fit: contain; + background: #ffffff; +} diff --git a/web/src/components/payment-checkout-code.tsx b/web/src/components/payment-checkout-code.tsx new file mode 100644 index 000000000..b1f96d1f1 --- /dev/null +++ b/web/src/components/payment-checkout-code.tsx @@ -0,0 +1,24 @@ +import { QRCode } from "antd"; + +import { isPaymentQrImageURL } from "@/lib/payment-checkout-code"; + +import "./payment-checkout-code.css"; + +const QR_SIZE = 208; +const QR_DARK = "#111111"; +const QR_LIGHT = "#ffffff"; + +export function PaymentCheckoutCode({ value }: { value: string }) { + if (isPaymentQrImageURL(value)) { + return ( +
+ 支付二维码 +
+ ); + } + return ( +
+ +
+ ); +} diff --git a/web/src/lib/payment-checkout-code.test.ts b/web/src/lib/payment-checkout-code.test.ts new file mode 100644 index 000000000..fb38895b5 --- /dev/null +++ b/web/src/lib/payment-checkout-code.test.ts @@ -0,0 +1,19 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { isPaymentQrImageURL } from "./payment-checkout-code"; + +test("isPaymentQrImageURL 识别虎皮椒二维码图片地址", () => { + assert.equal(isPaymentQrImageURL("https://api.xunhupay.com/qrcode.png"), true); + assert.equal(isPaymentQrImageURL(" https://api.dpweixin.com/qrcode.png "), true); + assert.equal(isPaymentQrImageURL("https://pay.example/qrcode"), true); + assert.equal(isPaymentQrImageURL("http://pay.example/images/order-qr.jpg"), true); +}); + +test("isPaymentQrImageURL 不把待编码载荷或收银页当成图片", () => { + assert.equal(isPaymentQrImageURL("weixin://wxpay/bizpayurl?pr=abc"), false); + assert.equal(isPaymentQrImageURL("https://api.xunhupay.com/pay/jump"), false); + assert.equal(isPaymentQrImageURL("https://pay.example/checkout?qrcode=1"), false); + assert.equal(isPaymentQrImageURL(""), false); + assert.equal(isPaymentQrImageURL("/qrcode.png"), false); +}); diff --git a/web/src/lib/payment-checkout-code.ts b/web/src/lib/payment-checkout-code.ts new file mode 100644 index 000000000..acf2d3a77 --- /dev/null +++ b/web/src/lib/payment-checkout-code.ts @@ -0,0 +1,20 @@ +const IMAGE_PATH = /\.(?:png|jpe?g|gif|webp|bmp|svg)$/i; +const QRCODE_PATH = /(?:^|\/)qrcode(?:[-_./]|$)/i; + +/** 渠道已生成的二维码图片地址,应直接展示,不能再编码成新的二维码。 */ +export function isPaymentQrImageURL(value: string): boolean { + const raw = value.trim(); + if (!raw) { + return false; + } + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + return false; + } + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { + return false; + } + return IMAGE_PATH.test(parsed.pathname) || QRCODE_PATH.test(parsed.pathname); +} diff --git a/web/src/pages/plugins/plugin-development-guide.md b/web/src/pages/plugins/plugin-development-guide.md index fe336ee63..3577f6289 100644 --- a/web/src/pages/plugins/plugin-development-guide.md +++ b/web/src/pages/plugins/plugin-development-guide.md @@ -4,7 +4,7 @@ ## 1. 包格式 -上传文件必须是 `.yingce-plugin` ZIP 包,大小不超过 16 MiB。包内必须有根目录 `manifest.json`;不能上传裸 JSON。清单和可选的 Web 运行时代码、静态资源属于同一个版本、权限和生命周期: +上传文件必须是 `.yingce-plugin` ZIP 包,大小不超过 48 MiB。包内必须有根目录 `manifest.json`;不能上传裸 JSON。清单和可选的 Web 运行时代码、静态资源属于同一个版本、权限和生命周期: ```text my-plugin.yingce-plugin diff --git a/web/src/pages/plugins/plugin-documentation-modals.tsx b/web/src/pages/plugins/plugin-documentation-modals.tsx index bad4e0da3..dc28f7c4e 100644 --- a/web/src/pages/plugins/plugin-documentation-modals.tsx +++ b/web/src/pages/plugins/plugin-documentation-modals.tsx @@ -82,7 +82,7 @@ export function UploadPluginModal({ open, onClose, onUpload }: UploadPluginModal >

{isDraggingPlugin ? "释放文件以上传插件" : "点击选择插件文件,也可拖拽到此处"}

-

支持 .yingce-plugin 包 · 大小不超过 16 MiB

+

支持 .yingce-plugin 包 · 大小不超过 48 MiB