feat(payment): 虎皮椒聚合支付 - 官方插件与跨平台可执行文件
新增 xunhupay 官方包,支付 RPC 与宿主只保留订单和验签边界;官方支付包同时带上各系统 provider,避免把 Linux ELF 当成本机入口。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -13,10 +13,10 @@ import (
|
||||
|
||||
const (
|
||||
PluginPackageFormat = "yingce.plugin/package-v1"
|
||||
PluginPackageMaxBytes = 16 << 20
|
||||
PluginPackageMaxBytes = 48 << 20
|
||||
PluginManifestMaxBytes = 512 << 10
|
||||
pluginPackageMaxFiles = 256
|
||||
pluginPackageMaxEntry = 8 << 20
|
||||
pluginPackageMaxEntry = 16 << 20
|
||||
)
|
||||
|
||||
// PluginPackage is the transport envelope for every uploaded plugin. The
|
||||
@@ -117,7 +117,11 @@ func validatePluginPackageRuntime(manifest Manifest, files map[string][]byte) er
|
||||
if !strings.HasPrefix(entry, "backend/") || path.Clean(entry) != entry {
|
||||
return fmt.Errorf("manifest.runtime.backendEntry must point inside backend/")
|
||||
}
|
||||
if _, ok := files[entry]; !ok {
|
||||
if backend == "rpc" {
|
||||
if !HasAnyPaymentRPCBackend(entry, files) {
|
||||
return fmt.Errorf("manifest runtime backend entry %q is missing a host-executable artifact", entry)
|
||||
}
|
||||
} else if _, ok := files[entry]; !ok {
|
||||
return fmt.Errorf("manifest runtime backend entry %q is missing from package", entry)
|
||||
}
|
||||
if backend == "wasm" && !strings.HasSuffix(strings.ToLower(entry), ".wasm") {
|
||||
|
||||
@@ -42,6 +42,33 @@ func TestParsePluginPackageValidatesWebEntry(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePluginPackageAcceptsTaggedRPCBackend(t *testing.T) {
|
||||
manifest := []byte(`{
|
||||
"apiVersion":"yingce.plugin/v1",
|
||||
"id":"tagged-payment",
|
||||
"name":"Tagged Payment",
|
||||
"version":"1.0.0",
|
||||
"author":"Test",
|
||||
"enabled":true,
|
||||
"runtime":{"backend":"rpc","backendEntry":"backend/provider"},
|
||||
"contributes":{"paymentProviders":[{"id":"tagged-pay","label":"Tagged","icon":"brand:test","checkoutMode":"qr_code","expiryPolicy":{"defaultMinutes":30,"minMinutes":5,"maxMinutes":1440}}]}
|
||||
}`)
|
||||
pkg, err := ParsePluginPackage(zipPluginPackage(t, map[string][]byte{
|
||||
"manifest.json": manifest,
|
||||
"backend/provider-darwin-arm64": []byte("darwin-provider"),
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := pkg.Files["backend/provider"]; ok {
|
||||
t.Fatal("canonical backend/provider should not be required when a tagged artifact exists")
|
||||
}
|
||||
|
||||
if _, err := ParsePluginPackage(zipPluginPackage(t, map[string][]byte{"manifest.json": manifest})); err == nil {
|
||||
t.Fatal("rpc package without any backend artifact was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func zipPluginPackage(t *testing.T, files map[string][]byte) []byte {
|
||||
t.Helper()
|
||||
var buffer bytes.Buffer
|
||||
|
||||
@@ -12,8 +12,23 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
var officialPaymentPackageIDs = []string{
|
||||
"official-payment-wechat-native",
|
||||
"official-payment-alipay-page",
|
||||
"official-payment-xunhupay",
|
||||
}
|
||||
|
||||
var officialPaymentTaggedArtifacts = []string{
|
||||
"backend/provider-linux-amd64",
|
||||
"backend/provider-linux-arm64",
|
||||
"backend/provider-darwin-amd64",
|
||||
"backend/provider-darwin-arm64",
|
||||
"backend/provider-windows-amd64.exe",
|
||||
"backend/provider-windows-arm64.exe",
|
||||
}
|
||||
|
||||
func TestOfficialPaymentArtifactsAreCanonicalLinuxAMD64(t *testing.T) {
|
||||
for _, packageID := range []string{"official-payment-wechat-native", "official-payment-alipay-page"} {
|
||||
for _, packageID := range officialPaymentPackageIDs {
|
||||
t.Run(packageID, func(t *testing.T) {
|
||||
root := filepath.Join("..", "..", "..", "plugin-packages")
|
||||
providerPath := filepath.Join(root, packageID, "backend", "provider")
|
||||
@@ -33,6 +48,10 @@ func TestOfficialPaymentArtifactsAreCanonicalLinuxAMD64(t *testing.T) {
|
||||
if !bytes.Equal(directoryProvider, packagedProvider) {
|
||||
t.Fatal("directory provider differs from .yingce-plugin backend/provider")
|
||||
}
|
||||
linuxAMD64 := pkg.Files["backend/provider-linux-amd64"]
|
||||
if !bytes.Equal(directoryProvider, linuxAMD64) {
|
||||
t.Fatal("canonical backend/provider differs from backend/provider-linux-amd64")
|
||||
}
|
||||
binary, err := elf.NewFile(bytes.NewReader(directoryProvider))
|
||||
if err != nil {
|
||||
t.Fatalf("provider is not ELF: %v", err)
|
||||
@@ -52,31 +71,91 @@ func TestOfficialPaymentArtifactsAreCanonicalLinuxAMD64(t *testing.T) {
|
||||
t.Fatalf("provider build settings = GOOS=%s GOARCH=%s CGO_ENABLED=%s", settings["GOOS"], settings["GOARCH"], settings["CGO_ENABLED"])
|
||||
}
|
||||
|
||||
for _, name := range officialPaymentTaggedArtifacts {
|
||||
directoryTagged, err := os.ReadFile(filepath.Join(root, packageID, filepath.FromSlash(name)))
|
||||
if err != nil {
|
||||
t.Fatalf("missing directory artifact %s: %v", name, err)
|
||||
}
|
||||
packagedTagged := pkg.Files[name]
|
||||
if !bytes.Equal(directoryTagged, packagedTagged) {
|
||||
t.Fatalf("directory %s differs from packaged artifact", name)
|
||||
}
|
||||
taggedInfo, err := buildinfo.Read(bytes.NewReader(directoryTagged))
|
||||
if err != nil {
|
||||
t.Fatalf("read %s build info: %v", name, err)
|
||||
}
|
||||
taggedSettings := make(map[string]string, len(taggedInfo.Settings))
|
||||
for _, setting := range taggedInfo.Settings {
|
||||
taggedSettings[setting.Key] = setting.Value
|
||||
}
|
||||
wantOS, wantArch := expectedPaymentArtifactPlatform(name)
|
||||
if taggedSettings["GOOS"] != wantOS || taggedSettings["GOARCH"] != wantArch || taggedSettings["CGO_ENABLED"] != "0" {
|
||||
t.Fatalf("%s build settings = GOOS=%s GOARCH=%s CGO_ENABLED=%s", name, taggedSettings["GOOS"], taggedSettings["GOARCH"], taggedSettings["CGO_ENABLED"])
|
||||
}
|
||||
}
|
||||
|
||||
if runtime.GOOS == "linux" && runtime.GOARCH == "amd64" {
|
||||
fileInfo, err := os.Stat(providerPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fileInfo.Mode().Perm()&0o111 == 0 {
|
||||
t.Fatal("provider is not executable")
|
||||
}
|
||||
command := exec.Command(providerPath)
|
||||
command.Stdin = bytes.NewBufferString("invalid-json\n")
|
||||
output, err := command.Output()
|
||||
if err != nil {
|
||||
t.Fatalf("provider smoke test failed: %v", err)
|
||||
}
|
||||
var response struct {
|
||||
OK bool `json:"ok"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := json.Unmarshal(output, &response); err != nil {
|
||||
t.Fatalf("provider smoke response is invalid JSON: %v", err)
|
||||
}
|
||||
if response.OK || response.Code != "invalid_request" {
|
||||
t.Fatalf("provider smoke response = %s", output)
|
||||
}
|
||||
smokePaymentProvider(t, providerPath)
|
||||
return
|
||||
}
|
||||
hostCandidates := PaymentRPCBackendCandidates("backend/provider", runtime.GOOS, runtime.GOARCH)
|
||||
if len(hostCandidates) == 0 {
|
||||
t.Fatalf("no RPC candidates for %s/%s", runtime.GOOS, runtime.GOARCH)
|
||||
}
|
||||
hostArtifact := hostCandidates[0]
|
||||
hostPath := filepath.Join(root, packageID, filepath.FromSlash(hostArtifact))
|
||||
if _, err := os.Stat(hostPath); err != nil {
|
||||
t.Fatalf("host artifact %s is missing: %v", hostArtifact, err)
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
smokePaymentProvider(t, hostPath)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func expectedPaymentArtifactPlatform(name string) (string, string) {
|
||||
switch name {
|
||||
case "backend/provider-linux-amd64":
|
||||
return "linux", "amd64"
|
||||
case "backend/provider-linux-arm64":
|
||||
return "linux", "arm64"
|
||||
case "backend/provider-darwin-amd64":
|
||||
return "darwin", "amd64"
|
||||
case "backend/provider-darwin-arm64":
|
||||
return "darwin", "arm64"
|
||||
case "backend/provider-windows-amd64.exe":
|
||||
return "windows", "amd64"
|
||||
case "backend/provider-windows-arm64.exe":
|
||||
return "windows", "arm64"
|
||||
default:
|
||||
return "", ""
|
||||
}
|
||||
}
|
||||
|
||||
func smokePaymentProvider(t *testing.T, providerPath string) {
|
||||
t.Helper()
|
||||
fileInfo, err := os.Stat(providerPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fileInfo.Mode().Perm()&0o111 == 0 {
|
||||
t.Fatal("provider is not executable")
|
||||
}
|
||||
command := exec.Command(providerPath)
|
||||
command.Stdin = bytes.NewBufferString("invalid-json\n")
|
||||
output, err := command.Output()
|
||||
if err != nil {
|
||||
t.Fatalf("provider smoke test failed: %v", err)
|
||||
}
|
||||
var response struct {
|
||||
OK bool `json:"ok"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := json.Unmarshal(output, &response); err != nil {
|
||||
t.Fatalf("provider smoke response is invalid JSON: %v", err)
|
||||
}
|
||||
if response.OK || response.Code != "invalid_request" {
|
||||
t.Fatalf("provider smoke response = %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
72
backend/internal/protocol/payment_rpc.go
Normal file
72
backend/internal/protocol/payment_rpc.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func PaymentRPCBackendCandidates(entry, goos, goarch string) []string {
|
||||
entry = strings.TrimSuffix(strings.TrimSpace(entry), ".exe")
|
||||
goos = strings.TrimSpace(goos)
|
||||
goarch = strings.TrimSpace(goarch)
|
||||
if entry == "" || goos == "" || goarch == "" || path.Clean(entry) != entry || !strings.HasPrefix(entry, "backend/") {
|
||||
return nil
|
||||
}
|
||||
base := path.Base(entry)
|
||||
dir := path.Dir(entry)
|
||||
tagged := entry + "-" + goos + "-" + goarch
|
||||
nested := dir + "/" + goos + "-" + goarch + "/" + base
|
||||
candidates := make([]string, 0, 6)
|
||||
if goos == "windows" {
|
||||
candidates = append(candidates, tagged+".exe", nested+".exe", entry+".exe")
|
||||
}
|
||||
candidates = append(candidates, tagged, nested, entry)
|
||||
return uniqueStrings(candidates)
|
||||
}
|
||||
|
||||
func HasAnyPaymentRPCBackend(entry string, files map[string][]byte) bool {
|
||||
entry = strings.TrimSuffix(strings.TrimSpace(entry), ".exe")
|
||||
if entry == "" {
|
||||
return false
|
||||
}
|
||||
if _, ok := files[entry]; ok {
|
||||
return true
|
||||
}
|
||||
if _, ok := files[entry+".exe"]; ok {
|
||||
return true
|
||||
}
|
||||
prefix := entry + "-"
|
||||
nestedRoot := path.Dir(entry) + "/"
|
||||
base := path.Base(entry)
|
||||
for name := range files {
|
||||
if strings.HasPrefix(name, prefix) {
|
||||
return true
|
||||
}
|
||||
rest, ok := strings.CutPrefix(name, nestedRoot)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
slash := strings.IndexByte(rest, '/')
|
||||
if slash <= 0 {
|
||||
continue
|
||||
}
|
||||
leaf := rest[slash+1:]
|
||||
if leaf == base || leaf == base+".exe" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func uniqueStrings(values []string) []string {
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
if _, exists := seen[value]; exists {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
29
backend/internal/protocol/payment_rpc_test.go
Normal file
29
backend/internal/protocol/payment_rpc_test.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package protocol
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPaymentRPCBackendCandidatesPreferPlatformTaggedFiles(t *testing.T) {
|
||||
darwin := PaymentRPCBackendCandidates("backend/provider", "darwin", "arm64")
|
||||
if got, want := darwin[0], "backend/provider-darwin-arm64"; got != want {
|
||||
t.Fatalf("darwin candidates[0] = %q, want %q (%v)", got, want, darwin)
|
||||
}
|
||||
windows := PaymentRPCBackendCandidates("backend/provider", "windows", "amd64")
|
||||
if got, want := windows[0], "backend/provider-windows-amd64.exe"; got != want {
|
||||
t.Fatalf("windows candidates[0] = %q, want %q (%v)", got, want, windows)
|
||||
}
|
||||
if windows[len(windows)-1] != "backend/provider" {
|
||||
t.Fatalf("windows fallback = %v", windows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasAnyPaymentRPCBackendAcceptsTaggedArtifacts(t *testing.T) {
|
||||
if HasAnyPaymentRPCBackend("backend/provider", map[string][]byte{"backend/provider-darwin-arm64": {1}}) != true {
|
||||
t.Fatal("tagged darwin provider should be accepted")
|
||||
}
|
||||
if HasAnyPaymentRPCBackend("backend/provider", map[string][]byte{"backend/windows-amd64/provider.exe": {1}}) != true {
|
||||
t.Fatal("nested windows provider should be accepted")
|
||||
}
|
||||
if HasAnyPaymentRPCBackend("backend/provider", map[string][]byte{"backend/unrelated": {1}}) {
|
||||
t.Fatal("unrelated backend file should not satisfy the payment entry")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user