feat(payment): 虎皮椒聚合支付 - 官方插件与跨平台可执行文件
新增 xunhupay 官方包,支付 RPC 与宿主只保留订单和验签边界;官方支付包同时带上各系统 provider,避免把 Linux ELF 当成本机入口。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2
.github/workflows/quality.yml
vendored
2
.github/workflows/quality.yml
vendored
@@ -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
|
||||
|
||||
16
backend/cmd/payment-xunhupay/main.go
Normal file
16
backend/cmd/payment-xunhupay/main.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
paymentplugins "infinite-canvas/backend/payment-plugins"
|
||||
)
|
||||
|
||||
func main() {
|
||||
provider := paymentplugins.NewXunHuPayProvider(&http.Client{Timeout: 25 * time.Second})
|
||||
if err := paymentplugins.RunRPC(provider, os.Stdin, os.Stdout); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -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("支付渠道配置不存在")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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("资源表不存在")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
72
backend/internal/protocol/payment_rpc.go
Normal file
72
backend/internal/protocol/payment_rpc.go
Normal file
@@ -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
|
||||
}
|
||||
29
backend/internal/protocol/payment_rpc_test.go
Normal file
29
backend/internal/protocol/payment_rpc_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
499
backend/payment-plugins/xunhupay.go
Normal file
499
backend/payment-plugins/xunhupay.go
Normal file
@@ -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[:])
|
||||
}
|
||||
168
backend/payment-plugins/xunhupay_test.go
Normal file
168
backend/payment-plugins/xunhupay_test.go
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 记录仍会拒绝迁移,不通过删除、改名或伪造已应用记录绕过检查。
|
||||
|
||||
|
||||
@@ -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/<digest>` 缓存键。provider 内容变化会产生新 digest 和新运行目录;解包先写临时目录,完成后写入 ready marker 并原子发布,旧目录不会覆盖新包,也不要求升级时手工清空整个数据卷。
|
||||
宿主以完整 `.yingce-plugin` 字节的 SHA-256 作为 package 文件名和 `runtime/<digest>` 缓存键。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 示例
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
@@ -1,3 +1,3 @@
|
||||
# 支付宝电脑网站支付
|
||||
|
||||
官方 `yingce.payment/v1` 支付插件。后端入口由构建脚本编译为 `backend/provider`。
|
||||
官方 `yingce.payment/v1` 支付插件。构建脚本交叉编译 macOS、Linux、Windows 的 amd64/arm64,入口仍声明为 `backend/provider`。
|
||||
|
||||
Binary file not shown.
BIN
plugin-packages/official-payment-alipay-page/backend/provider-darwin-amd64
Executable file
BIN
plugin-packages/official-payment-alipay-page/backend/provider-darwin-amd64
Executable file
Binary file not shown.
BIN
plugin-packages/official-payment-alipay-page/backend/provider-darwin-arm64
Executable file
BIN
plugin-packages/official-payment-alipay-page/backend/provider-darwin-arm64
Executable file
Binary file not shown.
BIN
plugin-packages/official-payment-alipay-page/backend/provider-linux-amd64
Executable file
BIN
plugin-packages/official-payment-alipay-page/backend/provider-linux-amd64
Executable file
Binary file not shown.
BIN
plugin-packages/official-payment-alipay-page/backend/provider-linux-arm64
Executable file
BIN
plugin-packages/official-payment-alipay-page/backend/provider-linux-arm64
Executable file
Binary file not shown.
BIN
plugin-packages/official-payment-alipay-page/backend/provider-windows-amd64.exe
Executable file
BIN
plugin-packages/official-payment-alipay-page/backend/provider-windows-amd64.exe
Executable file
Binary file not shown.
BIN
plugin-packages/official-payment-alipay-page/backend/provider-windows-arm64.exe
Executable file
BIN
plugin-packages/official-payment-alipay-page/backend/provider-windows-arm64.exe
Executable file
Binary file not shown.
@@ -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<!-- YINGCE_MANIFEST_CONTRACT_START -->\n## Manifest 完整接口定义\n\n以下 JSON 与插件包内实际 `manifest.json` 逐字段一致,覆盖插件身份、权限、配置、鉴权、参数、校验、创建、Agent、查询、取消、结果下载、响应和 Agent 响应映射。`documentation` 字段的值就是当前完整文档;为避免文档在自身内部无限递归,JSON 中仅用等义占位文本表示正文。\n\n```json\n{\n \"apiVersion\": \"yingce.plugin/v1\",\n \"id\": \"official-payment-alipay-page\",\n \"name\": \"支付宝电脑网站支付\",\n \"version\": \"1.0.0\",\n \"author\": \"支付宝\",\n \"description\": \"支付宝电脑网站支付充值适配器。\",\n \"enabled\": true,\n \"installable\": true,\n \"runtime\": {\n \"backend\": \"rpc\",\n \"backendEntry\": \"backend/provider\"\n },\n \"surfaces\": [\n \"wallet\",\n \"settings\"\n ],\n \"permissions\": [\n \"payment.create\",\n \"payment.query\",\n \"payment.close\",\n \"payment.reconcile\"\n ],\n \"configuration\": {\n \"fields\": [\n {\n \"name\": \"publicBaseUrl\",\n \"type\": \"url\",\n \"label\": \"服务器公网地址\",\n \"required\": true\n },\n {\n \"name\": \"appId\",\n \"type\": \"string\",\n \"label\": \"应用 AppID\",\n \"required\": true\n },\n {\n \"name\": \"sellerId\",\n \"type\": \"string\",\n \"label\": \"支付宝商户 PID\",\n \"required\": true\n },\n {\n \"name\": \"merchantPrivateKey\",\n \"type\": \"textarea\",\n \"label\": \"应用私钥\",\n \"required\": true,\n \"secret\": true\n },\n {\n \"name\": \"alipayPublicKey\",\n \"type\": \"textarea\",\n \"label\": \"支付宝公钥\",\n \"required\": true,\n \"secret\": true\n },\n {\n \"name\": \"gateway\",\n \"type\": \"url\",\n \"label\": \"支付宝网关\",\n \"required\": true,\n \"default\": \"https://openapi.alipay.com/gateway.do\"\n }\n ]\n },\n \"contributes\": {\n \"paymentProviders\": [\n {\n \"id\": \"alipay-page-pay\",\n \"label\": \"支付宝电脑网站支付\",\n \"icon\": \"brand:alipay\",\n \"checkoutMode\": \"redirect\",\n \"identityFields\": [\n \"appId\",\n \"sellerId\"\n ],\n \"expiryPolicy\": {\n \"defaultMinutes\": 30,\n \"minMinutes\": 5,\n \"maxMinutes\": 1440\n },\n \"notificationSuccess\": {\n \"status\": 200,\n \"contentType\": \"text/plain; charset=utf-8\",\n \"body\": \"success\"\n },\n \"notificationFailure\": {\n \"status\": 400,\n \"contentType\": \"text/plain; charset=utf-8\",\n \"body\": \"failure\"\n }\n }\n ]\n },\n \"documentation\": \"<当前插件的完整 documentation,由 README.md 与 docs/interface.md 拼接而成;为避免 JSON 递归,此处不重复展开正文。>\"\n}\n```\n<!-- YINGCE_MANIFEST_CONTRACT_END -->\n"
|
||||
"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<!-- YINGCE_MANIFEST_CONTRACT_START -->\n## Manifest 完整接口定义\n\n以下 JSON 与插件包内实际 `manifest.json` 逐字段一致,覆盖插件身份、权限、配置、鉴权、参数、校验、创建、Agent、查询、取消、结果下载、响应和 Agent 响应映射。`documentation` 字段的值就是当前完整文档;为避免文档在自身内部无限递归,JSON 中仅用等义占位文本表示正文。\n\n```json\n{\n \"apiVersion\": \"yingce.plugin/v1\",\n \"id\": \"official-payment-alipay-page\",\n \"name\": \"支付宝电脑网站支付\",\n \"version\": \"1.0.0\",\n \"author\": \"支付宝\",\n \"description\": \"支付宝电脑网站支付充值适配器。\",\n \"enabled\": true,\n \"installable\": true,\n \"runtime\": {\n \"backend\": \"rpc\",\n \"backendEntry\": \"backend/provider\"\n },\n \"surfaces\": [\n \"wallet\",\n \"settings\"\n ],\n \"permissions\": [\n \"payment.create\",\n \"payment.query\",\n \"payment.close\",\n \"payment.reconcile\"\n ],\n \"configuration\": {\n \"fields\": [\n {\n \"name\": \"publicBaseUrl\",\n \"type\": \"url\",\n \"label\": \"服务器公网地址\",\n \"required\": true\n },\n {\n \"name\": \"appId\",\n \"type\": \"string\",\n \"label\": \"应用 AppID\",\n \"required\": true\n },\n {\n \"name\": \"sellerId\",\n \"type\": \"string\",\n \"label\": \"支付宝商户 PID\",\n \"required\": true\n },\n {\n \"name\": \"merchantPrivateKey\",\n \"type\": \"textarea\",\n \"label\": \"应用私钥\",\n \"required\": true,\n \"secret\": true\n },\n {\n \"name\": \"alipayPublicKey\",\n \"type\": \"textarea\",\n \"label\": \"支付宝公钥\",\n \"required\": true,\n \"secret\": true\n },\n {\n \"name\": \"gateway\",\n \"type\": \"url\",\n \"label\": \"支付宝网关\",\n \"required\": true,\n \"default\": \"https://openapi.alipay.com/gateway.do\"\n }\n ]\n },\n \"contributes\": {\n \"paymentProviders\": [\n {\n \"id\": \"alipay-page-pay\",\n \"label\": \"支付宝电脑网站支付\",\n \"icon\": \"brand:alipay\",\n \"checkoutMode\": \"redirect\",\n \"identityFields\": [\n \"appId\",\n \"sellerId\"\n ],\n \"expiryPolicy\": {\n \"defaultMinutes\": 30,\n \"minMinutes\": 5,\n \"maxMinutes\": 1440\n },\n \"notificationSuccess\": {\n \"status\": 200,\n \"contentType\": \"text/plain; charset=utf-8\",\n \"body\": \"success\"\n },\n \"notificationFailure\": {\n \"status\": 400,\n \"contentType\": \"text/plain; charset=utf-8\",\n \"body\": \"failure\"\n }\n }\n ]\n },\n \"documentation\": \"<当前插件的完整 documentation,由 README.md 与 docs/interface.md 拼接而成;为避免 JSON 递归,此处不重复展开正文。>\"\n}\n```\n<!-- YINGCE_MANIFEST_CONTRACT_END -->\n"
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -1,3 +1,3 @@
|
||||
# 微信支付 Native
|
||||
|
||||
官方 `yingce.payment/v1` 支付插件。后端入口由构建脚本编译为 `backend/provider`。
|
||||
官方 `yingce.payment/v1` 支付插件。构建脚本交叉编译 macOS、Linux、Windows 的 amd64/arm64,入口仍声明为 `backend/provider`。
|
||||
|
||||
Binary file not shown.
BIN
plugin-packages/official-payment-wechat-native/backend/provider-darwin-amd64
Executable file
BIN
plugin-packages/official-payment-wechat-native/backend/provider-darwin-amd64
Executable file
Binary file not shown.
BIN
plugin-packages/official-payment-wechat-native/backend/provider-darwin-arm64
Executable file
BIN
plugin-packages/official-payment-wechat-native/backend/provider-darwin-arm64
Executable file
Binary file not shown.
BIN
plugin-packages/official-payment-wechat-native/backend/provider-linux-amd64
Executable file
BIN
plugin-packages/official-payment-wechat-native/backend/provider-linux-amd64
Executable file
Binary file not shown.
BIN
plugin-packages/official-payment-wechat-native/backend/provider-linux-arm64
Executable file
BIN
plugin-packages/official-payment-wechat-native/backend/provider-linux-arm64
Executable file
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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<!-- YINGCE_MANIFEST_CONTRACT_START -->\n## Manifest 完整接口定义\n\n以下 JSON 与插件包内实际 `manifest.json` 逐字段一致,覆盖插件身份、权限、配置、鉴权、参数、校验、创建、Agent、查询、取消、结果下载、响应和 Agent 响应映射。`documentation` 字段的值就是当前完整文档;为避免文档在自身内部无限递归,JSON 中仅用等义占位文本表示正文。\n\n```json\n{\n \"apiVersion\": \"yingce.plugin/v1\",\n \"id\": \"official-payment-wechat-native\",\n \"name\": \"微信支付 Native\",\n \"version\": \"1.0.0\",\n \"author\": \"微信支付\",\n \"description\": \"微信支付 Native 扫码充值适配器。\",\n \"enabled\": true,\n \"installable\": true,\n \"runtime\": {\n \"backend\": \"rpc\",\n \"backendEntry\": \"backend/provider\"\n },\n \"surfaces\": [\n \"wallet\",\n \"settings\"\n ],\n \"permissions\": [\n \"payment.create\",\n \"payment.query\",\n \"payment.close\",\n \"payment.reconcile\"\n ],\n \"configuration\": {\n \"fields\": [\n {\n \"name\": \"publicBaseUrl\",\n \"type\": \"url\",\n \"label\": \"服务器公网地址\",\n \"required\": true\n },\n {\n \"name\": \"appId\",\n \"type\": \"string\",\n \"label\": \"AppID\",\n \"required\": true\n },\n {\n \"name\": \"mchId\",\n \"type\": \"string\",\n \"label\": \"商户号\",\n \"required\": true\n },\n {\n \"name\": \"merchantSerialNo\",\n \"type\": \"string\",\n \"label\": \"商户证书序列号\",\n \"required\": true\n },\n {\n \"name\": \"merchantPrivateKey\",\n \"type\": \"textarea\",\n \"label\": \"商户 API 私钥\",\n \"required\": true,\n \"secret\": true\n },\n {\n \"name\": \"apiV3Key\",\n \"type\": \"password\",\n \"label\": \"APIv3 密钥\",\n \"required\": true,\n \"secret\": true\n },\n {\n \"name\": \"wechatPayPublicKeyId\",\n \"type\": \"string\",\n \"label\": \"微信支付公钥 ID\",\n \"required\": true\n },\n {\n \"name\": \"wechatPayPublicKey\",\n \"type\": \"textarea\",\n \"label\": \"微信支付公钥\",\n \"required\": true,\n \"secret\": true\n }\n ]\n },\n \"contributes\": {\n \"paymentProviders\": [\n {\n \"id\": \"wechat-native\",\n \"label\": \"微信支付 Native\",\n \"icon\": \"brand:wechat-pay\",\n \"checkoutMode\": \"qr_code\",\n \"identityFields\": [\n \"appId\",\n \"mchId\"\n ],\n \"expiryPolicy\": {\n \"defaultMinutes\": 30,\n \"minMinutes\": 5,\n \"maxMinutes\": 1440\n },\n \"notificationSuccess\": {\n \"status\": 204\n },\n \"notificationFailure\": {\n \"status\": 400\n }\n }\n ]\n },\n \"documentation\": \"<当前插件的完整 documentation,由 README.md 与 docs/interface.md 拼接而成;为避免 JSON 递归,此处不重复展开正文。>\"\n}\n```\n<!-- YINGCE_MANIFEST_CONTRACT_END -->\n"
|
||||
"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<!-- YINGCE_MANIFEST_CONTRACT_START -->\n## Manifest 完整接口定义\n\n以下 JSON 与插件包内实际 `manifest.json` 逐字段一致,覆盖插件身份、权限、配置、鉴权、参数、校验、创建、Agent、查询、取消、结果下载、响应和 Agent 响应映射。`documentation` 字段的值就是当前完整文档;为避免文档在自身内部无限递归,JSON 中仅用等义占位文本表示正文。\n\n```json\n{\n \"apiVersion\": \"yingce.plugin/v1\",\n \"id\": \"official-payment-wechat-native\",\n \"name\": \"微信支付 Native\",\n \"version\": \"1.0.0\",\n \"author\": \"微信支付\",\n \"description\": \"微信支付 Native 扫码充值适配器。\",\n \"enabled\": true,\n \"installable\": true,\n \"runtime\": {\n \"backend\": \"rpc\",\n \"backendEntry\": \"backend/provider\"\n },\n \"surfaces\": [\n \"wallet\",\n \"settings\"\n ],\n \"permissions\": [\n \"payment.create\",\n \"payment.query\",\n \"payment.close\",\n \"payment.reconcile\"\n ],\n \"configuration\": {\n \"fields\": [\n {\n \"name\": \"publicBaseUrl\",\n \"type\": \"url\",\n \"label\": \"服务器公网地址\",\n \"required\": true\n },\n {\n \"name\": \"appId\",\n \"type\": \"string\",\n \"label\": \"AppID\",\n \"required\": true\n },\n {\n \"name\": \"mchId\",\n \"type\": \"string\",\n \"label\": \"商户号\",\n \"required\": true\n },\n {\n \"name\": \"merchantSerialNo\",\n \"type\": \"string\",\n \"label\": \"商户证书序列号\",\n \"required\": true\n },\n {\n \"name\": \"merchantPrivateKey\",\n \"type\": \"textarea\",\n \"label\": \"商户 API 私钥\",\n \"required\": true,\n \"secret\": true\n },\n {\n \"name\": \"apiV3Key\",\n \"type\": \"password\",\n \"label\": \"APIv3 密钥\",\n \"required\": true,\n \"secret\": true\n },\n {\n \"name\": \"wechatPayPublicKeyId\",\n \"type\": \"string\",\n \"label\": \"微信支付公钥 ID\",\n \"required\": true\n },\n {\n \"name\": \"wechatPayPublicKey\",\n \"type\": \"textarea\",\n \"label\": \"微信支付公钥\",\n \"required\": true,\n \"secret\": true\n }\n ]\n },\n \"contributes\": {\n \"paymentProviders\": [\n {\n \"id\": \"wechat-native\",\n \"label\": \"微信支付 Native\",\n \"icon\": \"brand:wechat-pay\",\n \"checkoutMode\": \"qr_code\",\n \"identityFields\": [\n \"appId\",\n \"mchId\"\n ],\n \"expiryPolicy\": {\n \"defaultMinutes\": 30,\n \"minMinutes\": 5,\n \"maxMinutes\": 1440\n },\n \"notificationSuccess\": {\n \"status\": 204\n },\n \"notificationFailure\": {\n \"status\": 400\n }\n }\n ]\n },\n \"documentation\": \"<当前插件的完整 documentation,由 README.md 与 docs/interface.md 拼接而成;为避免 JSON 递归,此处不重复展开正文。>\"\n}\n```\n<!-- YINGCE_MANIFEST_CONTRACT_END -->\n"
|
||||
}
|
||||
|
||||
BIN
plugin-packages/official-payment-xunhupay.yingce-plugin
Normal file
BIN
plugin-packages/official-payment-xunhupay.yingce-plugin
Normal file
Binary file not shown.
3
plugin-packages/official-payment-xunhupay/README.md
Normal file
3
plugin-packages/official-payment-xunhupay/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# 虎皮椒聚合支付
|
||||
|
||||
官方 `yingce.payment/v1` 支付插件。构建脚本交叉编译 macOS、Linux、Windows 的 amd64/arm64,入口仍声明为 `backend/provider`。扫码模式返回虎皮椒 `url_qrcode` 图片地址,由钱包直接展示该图。
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||
<rect width="64" height="64" rx="16" fill="#E23C2F"/>
|
||||
<path d="M18 40c6 8 22 8 28 0 2-8-4-16-14-22-10 6-16 14-14 22Z" fill="#F6C14A"/>
|
||||
<path d="M32 14c2 4 3 8 2 12" stroke="#2F7D32" stroke-width="4" stroke-linecap="round"/>
|
||||
<circle cx="32" cy="36" r="6" fill="#fff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 356 B |
BIN
plugin-packages/official-payment-xunhupay/backend/provider
Executable file
BIN
plugin-packages/official-payment-xunhupay/backend/provider
Executable file
Binary file not shown.
BIN
plugin-packages/official-payment-xunhupay/backend/provider-darwin-amd64
Executable file
BIN
plugin-packages/official-payment-xunhupay/backend/provider-darwin-amd64
Executable file
Binary file not shown.
BIN
plugin-packages/official-payment-xunhupay/backend/provider-darwin-arm64
Executable file
BIN
plugin-packages/official-payment-xunhupay/backend/provider-darwin-arm64
Executable file
Binary file not shown.
BIN
plugin-packages/official-payment-xunhupay/backend/provider-linux-amd64
Executable file
BIN
plugin-packages/official-payment-xunhupay/backend/provider-linux-amd64
Executable file
Binary file not shown.
BIN
plugin-packages/official-payment-xunhupay/backend/provider-linux-arm64
Executable file
BIN
plugin-packages/official-payment-xunhupay/backend/provider-linux-arm64
Executable file
Binary file not shown.
BIN
plugin-packages/official-payment-xunhupay/backend/provider-windows-amd64.exe
Executable file
BIN
plugin-packages/official-payment-xunhupay/backend/provider-windows-amd64.exe
Executable file
Binary file not shown.
BIN
plugin-packages/official-payment-xunhupay/backend/provider-windows-arm64.exe
Executable file
BIN
plugin-packages/official-payment-xunhupay/backend/provider-windows-arm64.exe
Executable file
Binary file not shown.
111
plugin-packages/official-payment-xunhupay/docs/interface.md
Normal file
111
plugin-packages/official-payment-xunhupay/docs/interface.md
Normal file
@@ -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。
|
||||
|
||||
<!-- YINGCE_MANIFEST_CONTRACT_START -->
|
||||
## 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 递归,此处不重复展开正文。>"
|
||||
}
|
||||
```
|
||||
<!-- YINGCE_MANIFEST_CONTRACT_END -->
|
||||
97
plugin-packages/official-payment-xunhupay/manifest.json
Normal file
97
plugin-packages/official-payment-xunhupay/manifest.json
Normal file
@@ -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<!-- YINGCE_MANIFEST_CONTRACT_START -->\n## Manifest 完整接口定义\n\n以下 JSON 与插件包内实际 `manifest.json` 逐字段一致,覆盖插件身份、权限、配置、鉴权、参数、校验、创建、Agent、查询、取消、结果下载、响应和 Agent 响应映射。`documentation` 字段的值就是当前完整文档;为避免文档在自身内部无限递归,JSON 中仅用等义占位文本表示正文。\n\n```json\n{\n \"apiVersion\": \"yingce.plugin/v1\",\n \"id\": \"official-payment-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<!-- YINGCE_MANIFEST_CONTRACT_END -->\n"
|
||||
}
|
||||
@@ -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] <goos> <goarch>" >&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
|
||||
|
||||
23
web/src/components/payment-checkout-code.css
Normal file
23
web/src/components/payment-checkout-code.css
Normal file
@@ -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;
|
||||
}
|
||||
24
web/src/components/payment-checkout-code.tsx
Normal file
24
web/src/components/payment-checkout-code.tsx
Normal file
@@ -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 (
|
||||
<div className="payment-checkout-code">
|
||||
<img src={value} alt="支付二维码" width={QR_SIZE} height={QR_SIZE} referrerPolicy="no-referrer" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="payment-checkout-code">
|
||||
<QRCode value={value} size={QR_SIZE} bordered={false} color={QR_DARK} bgColor={QR_LIGHT} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
web/src/lib/payment-checkout-code.test.ts
Normal file
19
web/src/lib/payment-checkout-code.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
20
web/src/lib/payment-checkout-code.ts
Normal file
20
web/src/lib/payment-checkout-code.ts
Normal file
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -82,7 +82,7 @@ export function UploadPluginModal({ open, onClose, onUpload }: UploadPluginModal
|
||||
>
|
||||
<CloudUpload className="plugin-upload-dropzone-icon" />
|
||||
<p className="ant-upload-text">{isDraggingPlugin ? "释放文件以上传插件" : "点击选择插件文件,也可拖拽到此处"}</p>
|
||||
<p className="ant-upload-hint">支持 .yingce-plugin 包 · 大小不超过 16 MiB</p>
|
||||
<p className="ant-upload-hint">支持 .yingce-plugin 包 · 大小不超过 48 MiB</p>
|
||||
</Upload.Dragger>
|
||||
</div>
|
||||
<div className="plugin-upload-notice">
|
||||
|
||||
Reference in New Issue
Block a user