feat(payment): 虎皮椒聚合支付 - 官方插件与跨平台可执行文件

新增 xunhupay 官方包,支付 RPC 与宿主只保留订单和验签边界;官方支付包同时带上各系统 provider,避免把 Linux ELF 当成本机入口。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ddCat
2026-09-17 23:32:11 +08:00
parent e5a5f0ccc8
commit a23df0cb32
63 changed files with 2070 additions and 128 deletions

View File

@@ -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

View 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)
}
}

View File

@@ -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("支付渠道配置不存在")

View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -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))

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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("资源表不存在")

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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") {

View File

@@ -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

View File

@@ -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)
}
}

View 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
}

View 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")
}
}

View File

@@ -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"
)

View 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[:])
}

View 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,
}
}

View File

@@ -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 记录仍会拒绝迁移,不通过删除、改名或伪造已应用记录绕过检查。

View File

@@ -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 示例

View File

@@ -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

View File

@@ -1,3 +1,3 @@
# 支付宝电脑网站支付
官方 `yingce.payment/v1` 支付插件。后端入口由构建脚本编译为 `backend/provider`。
官方 `yingce.payment/v1` 支付插件。构建脚本交叉编译 macOS、Linux、Windows 的 amd64/arm64,入口仍声明为 `backend/provider`。

View File

@@ -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"
}

View File

@@ -1,3 +1,3 @@
# 微信支付 Native
官方 `yingce.payment/v1` 支付插件。后端入口由构建脚本编译为 `backend/provider`。
官方 `yingce.payment/v1` 支付插件。构建脚本交叉编译 macOS、Linux、Windows 的 amd64/arm64,入口仍声明为 `backend/provider`。

View File

@@ -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"
}

View File

@@ -0,0 +1,3 @@
# 虎皮椒聚合支付
官方 `yingce.payment/v1` 支付插件。构建脚本交叉编译 macOS、Linux、Windows 的 amd64/arm64,入口仍声明为 `backend/provider`。扫码模式返回虎皮椒 `url_qrcode` 图片地址,由钱包直接展示该图。

View File

@@ -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

Binary file not shown.

View 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 -->

View 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"
}

View File

@@ -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

View 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;
}

View 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>
);
}

View 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);
});

View 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);
}

View File

@@ -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

View File

@@ -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">