fix(payment): 支付插件 - 修复跨平台构建与运行时缓存 (#402)
This commit is contained in:
171
backend/internal/service/payment_runtime_cache_test.go
Normal file
171
backend/internal/service/payment_runtime_cache_test.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/protocol"
|
||||
)
|
||||
|
||||
func TestPaymentRuntimeCacheUsesCompletePackageDigest(t *testing.T) {
|
||||
packageA := paymentRuntimePackage(t, []byte("provider-a"))
|
||||
packageB := paymentRuntimePackage(t, []byte("provider-b"))
|
||||
if pluginHash(packageA) == pluginHash(packageB) {
|
||||
t.Fatal("payment packages with different providers have the same digest")
|
||||
}
|
||||
|
||||
pkgA, err := protocol.ParsePluginPackage(packageA)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pkgB, err := protocol.ParsePluginPackage(packageB)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
packageDir := t.TempDir()
|
||||
runtimeA, err := materializePaymentBackend(packageDir, pluginHash(packageA), pkgA)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runtimeB, err := materializePaymentBackend(packageDir, pluginHash(packageB), pkgB)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if runtimeA == runtimeB {
|
||||
t.Fatalf("different package contents reused runtime path %q", runtimeA)
|
||||
}
|
||||
assertPaymentRuntimeProvider(t, runtimeA, []byte("provider-a"))
|
||||
assertPaymentRuntimeProvider(t, runtimeB, []byte("provider-b"))
|
||||
}
|
||||
|
||||
func TestPaymentRuntimeReplacesInterruptedExtraction(t *testing.T) {
|
||||
packageData := paymentRuntimePackage(t, []byte("complete-provider"))
|
||||
pkg, err := protocol.ParsePluginPackage(packageData)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
digest := pluginHash(packageData)
|
||||
packageDir := t.TempDir()
|
||||
interruptedRoot := filepath.Join(packageDir, "runtime", digest)
|
||||
if err := os.MkdirAll(filepath.Join(interruptedRoot, "backend"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(interruptedRoot, "backend", "provider"), []byte("partial-provider"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
runtimeDir, err := materializePaymentBackend(packageDir, digest, pkg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if runtimeDir != interruptedRoot {
|
||||
t.Fatalf("runtime directory = %q, want %q", runtimeDir, interruptedRoot)
|
||||
}
|
||||
assertPaymentRuntimeProvider(t, runtimeDir, []byte("complete-provider"))
|
||||
ready, err := os.ReadFile(filepath.Join(runtimeDir, paymentRuntimeReadyFile))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(ready) != digest+"\n" {
|
||||
t.Fatalf("runtime ready marker = %q", ready)
|
||||
}
|
||||
if err := os.Remove(filepath.Join(runtimeDir, "backend", "provider")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := materializePaymentBackend(packageDir, digest, pkg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertPaymentRuntimeProvider(t, runtimeDir, []byte("complete-provider"))
|
||||
}
|
||||
|
||||
func TestBuiltInPaymentPackageUpgradeSelectsNewRuntimeDigest(t *testing.T) {
|
||||
oldPackage := paymentRuntimePackage(t, []byte("old-provider"))
|
||||
newPackage := paymentRuntimePackage(t, []byte("new-provider"))
|
||||
oldDigest := pluginHash(oldPackage)
|
||||
newDigest := pluginHash(newPackage)
|
||||
oldPkg, err := protocol.ParsePluginPackage(oldPackage)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dataDir := t.TempDir()
|
||||
packageDir := filepath.Join(dataDir, "plugin-packages")
|
||||
if err := os.MkdirAll(packageDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(packageDir, oldDigest+".yingce-plugin"), oldPackage, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := materializePaymentBackend(packageDir, oldDigest, oldPkg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := []pluginRegistryRecord{{
|
||||
ID: "test-payment-runtime", Raw: oldPkg.ManifestRaw, Source: PluginOriginOfficial,
|
||||
FileName: "test-payment-runtime.yingce-plugin", PackagePath: oldDigest + ".yingce-plugin", PackageSHA256: oldDigest,
|
||||
}}
|
||||
if err := (&pluginRuntime{registryPath: filepath.Join(dataDir, "plugin_registry.json")}).writeRegistry(registry); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
officialDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(officialDir, "test-payment-runtime.yingce-plugin"), newPackage, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("CANVAS_OFFICIAL_PLUGIN_DIR", officialDir)
|
||||
center, err := newPluginRuntime(dataDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assertPaymentRuntimeProvider(t, filepath.Join(packageDir, "runtime", newDigest), []byte("new-provider"))
|
||||
for _, plugin := range center.list() {
|
||||
if plugin.Manifest.ID == "test-payment-runtime" {
|
||||
if plugin.SHA256 != newDigest {
|
||||
t.Fatalf("upgraded plugin digest = %q, want %q", plugin.SHA256, newDigest)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("upgraded payment plugin is missing")
|
||||
}
|
||||
|
||||
func TestPaymentRuntimeRejectsInvalidDigest(t *testing.T) {
|
||||
pkg := protocol.PluginPackage{Files: map[string][]byte{"backend/provider": []byte("provider")}}
|
||||
if _, err := materializePaymentBackend(t.TempDir(), "../outside", pkg); err == nil {
|
||||
t.Fatal("invalid package digest was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func paymentRuntimePackage(t *testing.T, provider []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}}]}}`)
|
||||
var buffer bytes.Buffer
|
||||
writer := zip.NewWriter(&buffer)
|
||||
for name, content := range map[string][]byte{"manifest.json": manifest, "backend/provider": provider} {
|
||||
file, err := writer.Create(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := file.Write(content); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buffer.Bytes()
|
||||
}
|
||||
|
||||
func assertPaymentRuntimeProvider(t *testing.T, runtimeDir string, expected []byte) {
|
||||
t.Helper()
|
||||
actual, err := os.ReadFile(filepath.Join(runtimeDir, "backend", "provider"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(actual, expected) {
|
||||
t.Fatalf("runtime provider = %q, want %q", actual, expected)
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,10 @@ import (
|
||||
"infinite-canvas/backend/internal/protocol"
|
||||
)
|
||||
|
||||
const protocolPluginMaxBytes = protocol.PluginManifestMaxBytes
|
||||
const (
|
||||
protocolPluginMaxBytes = protocol.PluginManifestMaxBytes
|
||||
paymentRuntimeReadyFile = ".ready"
|
||||
)
|
||||
|
||||
// PluginView is the backend representation consumed by the single frontend
|
||||
// plugin center. Protocol-specific runtime data is nested under Protocol so
|
||||
@@ -297,7 +300,7 @@ func (c *pluginRuntime) reload() error {
|
||||
return fmt.Errorf("duplicate installed protocol %q", metadata.ID)
|
||||
}
|
||||
packageSHA256 := storedRecord.PackageSHA256
|
||||
if packageSHA256 == "" {
|
||||
if packageSHA256 == "" && strings.TrimSpace(storedRecord.PackagePath) == "" {
|
||||
packageSHA256 = pluginHash(data)
|
||||
}
|
||||
plugins[metadata.ID] = pluginRecord{Raw: data, Metadata: metadata, Source: storedRecord.Source, FileName: storedRecord.FileName, PackagePath: storedRecord.PackagePath, PackageSHA256: packageSHA256, SHA256: packageSHA256, InstalledAt: storedRecord.InstalledAt, UpdatedAt: storedRecord.UpdatedAt, Status: "invalid"}
|
||||
@@ -403,6 +406,16 @@ func (c *pluginRuntime) reload() error {
|
||||
plugins[id] = record
|
||||
continue
|
||||
}
|
||||
packageSHA256 := pluginHash(packageData)
|
||||
if expected := strings.TrimSpace(record.PackageSHA256); expected != "" && !strings.EqualFold(expected, packageSHA256) {
|
||||
record.Status = "invalid"
|
||||
record.Error = "支付插件包完整性校验失败"
|
||||
plugins[id] = record
|
||||
continue
|
||||
}
|
||||
record.PackageSHA256 = packageSHA256
|
||||
record.SHA256 = packageSHA256
|
||||
plugins[id] = record
|
||||
pkg, parseErr := protocol.ParsePluginPackage(packageData)
|
||||
if parseErr != nil {
|
||||
record.Status = "invalid"
|
||||
@@ -410,7 +423,7 @@ func (c *pluginRuntime) reload() error {
|
||||
plugins[id] = record
|
||||
continue
|
||||
}
|
||||
runtimeDir, materializeErr := materializePaymentBackend(c.packageDir, record.PackageSHA256, pkg)
|
||||
runtimeDir, materializeErr := materializePaymentBackend(c.packageDir, packageSHA256, pkg)
|
||||
if materializeErr != nil {
|
||||
record.Status = "invalid"
|
||||
record.Error = materializeErr.Error()
|
||||
@@ -447,15 +460,34 @@ func (c *pluginRuntime) reload() error {
|
||||
}
|
||||
|
||||
func materializePaymentBackend(packageDir, hash string, pkg protocol.PluginPackage) (string, error) {
|
||||
root := filepath.Join(packageDir, "runtime", strings.TrimSpace(hash))
|
||||
if err := os.MkdirAll(root, 0o700); err != nil {
|
||||
digest := strings.ToLower(strings.TrimSpace(hash))
|
||||
if len(digest) != sha256.Size*2 {
|
||||
return "", errors.New("支付插件包摘要无效")
|
||||
}
|
||||
if _, err := hex.DecodeString(digest); err != nil {
|
||||
return "", errors.New("支付插件包摘要无效")
|
||||
}
|
||||
|
||||
runtimeRoot := filepath.Join(packageDir, "runtime")
|
||||
root := filepath.Join(runtimeRoot, digest)
|
||||
backendEntry := strings.TrimSpace(pkg.Manifest.Runtime.BackendEntry)
|
||||
if paymentRuntimeReady(root, digest, backendEntry) {
|
||||
return root, nil
|
||||
}
|
||||
if err := os.MkdirAll(runtimeRoot, 0o700); err != nil {
|
||||
return "", fmt.Errorf("创建支付插件运行目录失败:%w", err)
|
||||
}
|
||||
temporaryRoot, err := os.MkdirTemp(runtimeRoot, ".payment-runtime-")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("创建支付插件临时运行目录失败:%w", err)
|
||||
}
|
||||
defer os.RemoveAll(temporaryRoot)
|
||||
|
||||
for name, content := range pkg.Files {
|
||||
if !strings.HasPrefix(name, "backend/") || strings.HasSuffix(name, "/") {
|
||||
continue
|
||||
}
|
||||
target := filepath.Join(root, filepath.FromSlash(name))
|
||||
target := filepath.Join(temporaryRoot, filepath.FromSlash(name))
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -463,9 +495,34 @@ func materializePaymentBackend(packageDir, hash string, pkg protocol.PluginPacka
|
||||
return "", fmt.Errorf("写入支付插件运行文件失败:%w", err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(temporaryRoot, paymentRuntimeReadyFile), []byte(digest+"\n"), 0o600); err != nil {
|
||||
return "", fmt.Errorf("写入支付插件运行状态失败:%w", err)
|
||||
}
|
||||
if err := os.Rename(temporaryRoot, root); err != nil {
|
||||
if paymentRuntimeReady(root, digest, backendEntry) {
|
||||
return root, nil
|
||||
}
|
||||
// Digest directories are immutable once ready. An unmarked directory can
|
||||
// only be residue from an interrupted extraction and is safe to replace.
|
||||
if removeErr := os.RemoveAll(root); removeErr != nil {
|
||||
return "", fmt.Errorf("清理未完成的支付插件运行目录失败:%w", removeErr)
|
||||
}
|
||||
if renameErr := os.Rename(temporaryRoot, root); renameErr != nil {
|
||||
return "", fmt.Errorf("发布支付插件运行目录失败:%w", renameErr)
|
||||
}
|
||||
}
|
||||
return root, nil
|
||||
}
|
||||
|
||||
func paymentRuntimeReady(root, digest, backendEntry string) bool {
|
||||
ready, err := os.ReadFile(filepath.Join(root, paymentRuntimeReadyFile))
|
||||
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()
|
||||
}
|
||||
|
||||
func (c *pluginRuntime) list() []PluginView {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
Reference in New Issue
Block a user