package app import ( "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io/fs" "os" "path/filepath" "runtime" "sort" "strings" "sync" "time" "infinite-canvas/backend/internal/payment" "infinite-canvas/backend/internal/protocol" ) 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 // the public plugin contract can grow without adding another center API. type PluginView struct { Manifest PluginManifestView `json:"manifest"` Source string `json:"source"` FileName string `json:"fileName"` Package string `json:"package"` SHA256 string `json:"sha256"` InstalledAt time.Time `json:"installedAt"` UpdatedAt time.Time `json:"updatedAt"` Status string `json:"status"` Error string `json:"error,omitempty"` Management PluginManagementView `json:"management"` } type PluginManifestView struct { APIVersion string `json:"apiVersion"` ID string `json:"id"` Name string `json:"name"` Version string `json:"version"` Entry string `json:"entry,omitempty"` Surfaces []string `json:"surfaces,omitempty"` Description string `json:"description,omitempty"` Documentation string `json:"documentation,omitempty"` Author string `json:"author,omitempty"` Permissions []string `json:"permissions"` Trusted bool `json:"trusted"` Runtime protocol.ManifestRuntime `json:"runtime,omitempty"` Configuration protocol.ManifestConfiguration `json:"configuration,omitempty"` Contributes protocol.ManifestContributions `json:"contributes"` } type pluginRecord struct { Raw []byte Metadata protocol.Metadata Source string FileName string PackagePath string PackageSHA256 string SHA256 string InstalledAt time.Time UpdatedAt time.Time Status string Error string } type pluginRuntime struct { mu sync.RWMutex mutationMu sync.Mutex registryPath string packageDir string plugins map[string]pluginRecord registry *protocol.Registry paymentRegistry *payment.Registry } type pluginRegistryRecord struct { ID string `json:"id"` Raw json.RawMessage `json:"manifest"` Source string `json:"source"` FileName string `json:"fileName,omitempty"` PackagePath string `json:"packagePath,omitempty"` PackageSHA256 string `json:"packageSha256,omitempty"` InstalledAt time.Time `json:"installedAt"` UpdatedAt time.Time `json:"updatedAt"` } 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) } packageDir := filepath.Join(dataDir, "plugin-packages") if err := os.MkdirAll(packageDir, 0o700); err != nil { return nil, fmt.Errorf("create plugin package directory: %w", err) } center := &pluginRuntime{registryPath: filepath.Join(dataDir, "plugin_registry.json"), packageDir: packageDir, plugins: make(map[string]pluginRecord)} if err := center.bootstrapBuiltInPlugins(); err != nil { return nil, err } if err := center.reload(); err != nil { return nil, err } return center, nil } func (c *pluginRuntime) bootstrapBuiltInPlugins() error { stored, err := c.readRegistry() if err != nil { return err } byID := make(map[string]pluginRegistryRecord, len(stored)) for _, record := range stored { byID[record.ID] = record } officialDir, err := officialPluginPackageDir() if err != nil { return err } entries, err := os.ReadDir(officialDir) if err != nil { return fmt.Errorf("读取官方插件目录失败:%w", err) } builtInIDs := make(map[string]struct{}, len(entries)+2) for _, entry := range entries { if entry.IsDir() || !strings.HasSuffix(strings.ToLower(entry.Name()), ".yingce-plugin") { continue } official, err := inspectOfficialPluginPackage(filepath.Join(officialDir, entry.Name())) if err != nil { return fmt.Errorf("校验官方插件包 %s:%w", entry.Name(), err) } packageData, pkg := official.data, official.info if strings.HasPrefix(strings.TrimSpace(pkg.Manifest.Runtime.Backend), "host:") { return fmt.Errorf("官方插件 %q 不能依赖 host 执行器", pkg.Manifest.Metadata.ID) } if len(pkg.Manifest.Contributes.PaymentProviders) == 0 { if _, err := protocol.LoadInstalledProviders(pkg.ManifestRaw, nil); err != nil { return fmt.Errorf("加载官方插件 %q:%w", pkg.Manifest.Metadata.ID, err) } } id := pkg.Manifest.Metadata.ID if _, duplicate := builtInIDs[id]; duplicate { return fmt.Errorf("官方插件 ID %q 重复", id) } builtInIDs[id] = struct{}{} manifest := pkg.Manifest record := byID[id] if len(record.Raw) > 0 { var previous protocol.Manifest if err := json.Unmarshal(record.Raw, &previous); err == nil { manifest.Metadata.Enabled = previous.Metadata.Enabled } } manifestData, err := json.Marshal(manifest) if err != nil { return fmt.Errorf("编码官方插件 %q:%w", id, err) } hash := official.hash packageName := hash + ".yingce-plugin" if err := cachePluginPackage(filepath.Join(c.packageDir, packageName), packageData, official.path); err != nil { return fmt.Errorf("缓存官方插件 %q:%w", id, err) } now := time.Now().UTC() if record.InstalledAt.IsZero() { record.InstalledAt = now } source := PluginOriginOfficial if _, systemPayment := systemPaymentPolicies[id]; systemPayment { source = PluginOriginSystem } record.ID, record.Raw, record.Source, record.FileName = id, manifestData, source, entry.Name() record.PackagePath, record.PackageSHA256, record.UpdatedAt = packageName, hash, now byID[id] = record } bundledManifests := append(bundledWorkflowPluginManifests(), bundledPaymentPluginManifests()...) bundledManifests = append(bundledManifests, bundledSMSPluginManifests()...) for _, bundled := range bundledManifests { builtInIDs[bundled.Metadata.ID] = struct{}{} if existing, exists := byID[bundled.Metadata.ID]; exists && existing.PackagePath != "" && isSystemPaymentPluginID(bundled.Metadata.ID) { // An official package is the executable source of truth. The bundled // manifest remains only as a fallback when the package is unavailable. continue } data, err := json.Marshal(bundled) if err != nil { return fmt.Errorf("encode built-in plugin %s: %w", bundled.Metadata.ID, err) } record := byID[bundled.Metadata.ID] if len(record.Raw) > 0 { var installed protocol.Manifest if err := json.Unmarshal(record.Raw, &installed); err != nil { return fmt.Errorf("decode built-in plugin %s: %w", bundled.Metadata.ID, err) } bundled.Metadata.Enabled = installed.Metadata.Enabled data, err = json.Marshal(bundled) if err != nil { return fmt.Errorf("encode built-in plugin %s: %w", bundled.Metadata.ID, err) } } now := time.Now().UTC() if record.InstalledAt.IsZero() { record.InstalledAt = now } source := PluginOriginOfficial if _, systemPayment := systemPaymentPolicies[bundled.Metadata.ID]; systemPayment { source = PluginOriginSystem } if isSystemSMSPluginID(bundled.Metadata.ID) { source = PluginOriginSystem } record.UpdatedAt = now record.ID, record.Raw, record.Source, record.PackagePath = bundled.Metadata.ID, data, source, "" byID[bundled.Metadata.ID] = record } result := make([]pluginRegistryRecord, 0, len(byID)) for _, record := range byID { if isBuiltInPluginSource(record.Source) { if _, exists := builtInIDs[record.ID]; !exists { // Built-in records are reconciled from repository packages and host // manifests on every startup, so removed plugins cannot survive stale. continue } } result = append(result, record) } sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID }) return c.writeRegistry(result) } // officialPluginPackage 是一次读取官方插件包得到的全部结果:原始字节、摘要与清单。 type officialPluginPackage struct { path string data []byte hash string info protocol.PluginPackageInfo } type officialPluginPackageKey struct { path string size int64 modTime int64 } // officialPluginPackageCache 在进程内缓存官方插件包的读取结果。 // // 官方包随镜像发布、运行期间不会变化;以路径 + 大小 + 修改时间作键,文件被替换时 // 自然失效。生产环境每个进程只构造一次 Service,缓存几乎无收益;但测试会构造上千个 // Service,每次重新读取、哈希、解析 85MB 的官方包会让后端测试慢数分钟。 var officialPluginPackageCache sync.Map // officialPluginPackageKey -> officialPluginPackage func inspectOfficialPluginPackage(path string) (officialPluginPackage, error) { info, err := os.Stat(path) if err != nil { return officialPluginPackage{}, err } key := officialPluginPackageKey{path: path, size: info.Size(), modTime: info.ModTime().UnixNano()} if cached, ok := officialPluginPackageCache.Load(key); ok { result := cached.(officialPluginPackage) // Manifest 含指针与切片字段;每个调用方拿到独立解码的副本,缓存条目永远不会被共享修改。 // 清单只有几 KB,重新解码的成本可以忽略,昂贵的是读包、哈希与 zip 结构校验。 var manifest protocol.Manifest if err := json.Unmarshal(result.info.ManifestRaw, &manifest); err != nil { return officialPluginPackage{}, err } result.info.Manifest = manifest return result, nil } data, err := os.ReadFile(path) if err != nil { return officialPluginPackage{}, err } // 启动只需要清单:支付插件包内含多平台可执行文件(解压约 44MB/个), // 真正落盘执行时 reload 会按摘要校验并只解压本机平台需要的文件。 pkg, err := protocol.InspectPluginPackage(data) if err != nil { return officialPluginPackage{}, err } result := officialPluginPackage{path: path, data: data, hash: pluginHash(data), info: pkg} officialPluginPackageCache.Store(key, result) return result, nil } func isSystemPaymentPluginID(id string) bool { _, ok := systemPaymentPolicies[strings.TrimSpace(id)] return ok } func isBuiltInPluginSource(source string) bool { switch strings.TrimSpace(source) { case "bundled", PluginOriginOfficial, PluginOriginSystem: return true default: return false } } func officialPluginPackageDir() (string, error) { if configured := strings.TrimSpace(os.Getenv("CANVAS_OFFICIAL_PLUGIN_DIR")); configured != "" { info, err := os.Stat(configured) if err != nil || !info.IsDir() { return "", fmt.Errorf("CANVAS_OFFICIAL_PLUGIN_DIR 不是可读目录:%s", configured) } return configured, nil } workingDir, err := os.Getwd() if err != nil { return "", err } candidates := []string{"/app/plugin-packages"} current := workingDir for range 8 { candidates = append(candidates, filepath.Join(current, "plugin-packages")) parent := filepath.Dir(current) if parent == current { break } current = parent } for _, candidate := range candidates { if info, err := os.Stat(candidate); err == nil && info.IsDir() { return candidate, nil } } return "", errors.New("未找到官方 plugin-packages 目录;请设置 CANVAS_OFFICIAL_PLUGIN_DIR") } func (c *pluginRuntime) reload() error { c.mu.Lock() defer c.mu.Unlock() stored, err := c.readRegistry() if err != nil { return err } plugins := make(map[string]pluginRecord) for _, storedRecord := range stored { data := storedRecord.Raw if len(data) > protocolPluginMaxBytes { return fmt.Errorf("plugin %s exceeds %d bytes", storedRecord.ID, protocolPluginMaxBytes) } var manifest protocol.Manifest if err := json.Unmarshal(data, &manifest); err != nil { return fmt.Errorf("decode plugin %s: %w", storedRecord.ID, err) } metadata := manifest.Metadata if strings.TrimSpace(metadata.ID) == "" { return fmt.Errorf("plugin %s has no metadata id", storedRecord.ID) } if _, exists := plugins[metadata.ID]; exists { return fmt.Errorf("duplicate installed protocol %q", metadata.ID) } packageSHA256 := storedRecord.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"} } registry, err := protocol.NewRegistry() if err != nil { return err } for id, record := range plugins { var manifest protocol.Manifest if err := json.Unmarshal(record.Raw, &manifest); err != nil { record.Error = err.Error() plugins[id] = record continue } if len(manifest.Contributes.PaymentProviders) > 0 && record.Source == PluginOriginUploaded { backend := strings.TrimSpace(manifest.Runtime.Backend) if strings.HasPrefix(backend, "host:") { record.Metadata.Enabled = false record.Status = "invalid" record.Error = "首期支付插件只能使用系统宿主适配器" plugins[id] = record continue } if backend != "rpc" && backend != "wasm" { record.Metadata.Enabled = false record.Status = "invalid" record.Error = "上传支付插件必须声明 rpc 或 wasm 后端" plugins[id] = record continue } } if len(manifest.Contributes.SMSProviders) > 0 && (record.Source == PluginOriginUploaded || !isSystemSMSPluginID(id)) { record.Metadata.Enabled, record.Status, record.Error = false, "invalid", "短信插件仅支持系统宿主适配器" plugins[id] = record continue } if (len(manifest.Contributes.PaymentProviders) > 0 || len(manifest.Contributes.SMSProviders) > 0) && len(manifest.Contributes.Providers) == 0 { if record.Metadata.Enabled { record.Status = "enabled" } else { record.Status = "disabled" } plugins[id] = record continue } adapters, loadErr := protocol.LoadInstalledProviders(record.Raw, nil) if loadErr != nil { record.Metadata.Enabled = false record.Metadata.UnavailableReason = loadErr.Error() record.Error = loadErr.Error() _ = registry.Register(protocol.UnavailableAdapter{Info: record.Metadata}) plugins[id] = record continue } if !record.Metadata.Enabled { record.Status = "disabled" for _, adapter := range adapters { info := adapter.Metadata() info.Enabled = false _ = registry.Register(protocol.UnavailableAdapter{Info: info}) } plugins[id] = record continue } registrationFailed := false for _, adapter := range adapters { if err := registry.Register(adapter); err != nil { record.Error = err.Error() registrationFailed = true } } if registrationFailed { plugins[id] = record continue } record.Status = "enabled" plugins[id] = record } c.plugins = plugins c.registry = registry // Payment adapters are loaded exclusively from validated plugin packages. // An empty registry is intentional when the official package is unavailable; // payment writes must fail closed instead of silently using host code. basePayment, registryErr := payment.NewRegistry() if registryErr != nil { return registryErr } dynamicPayments := make([]payment.Provider, 0) for id, record := range plugins { if (record.Source != PluginOriginUploaded && record.Source != PluginOriginOfficial && record.Source != PluginOriginSystem) || !record.Metadata.Enabled || record.Status != "enabled" { continue } var manifest protocol.Manifest if err := json.Unmarshal(record.Raw, &manifest); err != nil || len(manifest.Contributes.PaymentProviders) == 0 { continue } if strings.TrimSpace(manifest.Runtime.Backend) != "rpc" { record.Status = "invalid" record.Error = "wasm 支付运行时尚未启用" plugins[id] = record continue } packageData, readErr := os.ReadFile(filepath.Join(c.packageDir, filepath.Base(record.PackagePath))) if readErr != nil { record.Status = "invalid" record.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 runtimeDir, materializeErr := materializePaymentBackendFromPackage(c.packageDir, packageSHA256, manifest.Runtime.BackendEntry, packageData) if materializeErr != nil { record.Status = "invalid" record.Error = materializeErr.Error() plugins[id] = record continue } for _, contribution := range manifest.Contributes.PaymentProviders { provider, providerErr := payment.NewRPCProvider(payment.DescriptorFromManifest(manifest, contribution), runtimeDir, manifest.Runtime.BackendEntry) if providerErr != nil { record.Status = "invalid" record.Error = providerErr.Error() continue } dynamicPayments = append(dynamicPayments, provider) } } dynamicIDs := make(map[string]struct{}, len(dynamicPayments)) for _, provider := range dynamicPayments { dynamicIDs[provider.Descriptor().ID] = struct{}{} } baseProviders := make([]payment.Provider, 0) for _, provider := range basePayment.Providers() { if _, replaced := dynamicIDs[provider.Descriptor().ID]; !replaced { baseProviders = append(baseProviders, provider) } } providers := append(baseProviders, dynamicPayments...) c.paymentRegistry, err = payment.NewRegistry(providers...) if err != nil { return err } c.plugins = plugins return nil } // materializePaymentBackendFromPackage 先按摘要检查运行目录是否已就绪,只有需要落盘时 // 才完整解析插件包。摘要已由调用方对原始字节校验,就绪标记与摘要一一对应, // 因此跳过解析不会放过被篡改的包。 func materializePaymentBackendFromPackage(packageDir, hash, backendEntry string, packageData []byte) (string, error) { digest := strings.ToLower(strings.TrimSpace(hash)) root := filepath.Join(packageDir, "runtime", digest) if len(digest) == sha256.Size*2 && paymentRuntimeReady(root, digest, strings.TrimSpace(backendEntry)) { return root, nil } if len(digest) == sha256.Size*2 && linkReadyPaymentRuntime(packageDir, digest, strings.TrimSpace(backendEntry)) { return root, nil } defer func() { rememberPaymentRuntime(digest, root, strings.TrimSpace(backendEntry)) }() // 只解压本机平台能执行的文件:官方包内含 7 个平台的可执行文件, // 其余平台的产物永远不会被 ResolveRPCBackendPath 选中,解压只是浪费 IO。 entry := strings.TrimSpace(backendEntry) hostCandidates := make(map[string]struct{}) for _, candidate := range protocol.PaymentRPCBackendCandidates(entry, runtime.GOOS, runtime.GOARCH) { hostCandidates[candidate] = struct{}{} } pkg, err := protocol.ParsePluginPackageFiles(packageData, func(name string) bool { if !strings.HasPrefix(name, "backend/") { return false } if _, host := hostCandidates[name]; host { return true } return !protocol.IsPaymentRPCBackendArtifact(entry, name) }) if err != nil { return "", err } return materializePaymentBackend(packageDir, hash, pkg) } // readyPaymentRuntimes 记录本进程内已就绪的支付插件运行目录(摘要 -> 目录)。 // 同一摘要的运行目录内容完全相同,新的数据目录可以直接硬链接复用, // 避免每次重新解压约 6MB 的可执行文件。生产环境只有一个数据目录,这里不会命中。 var readyPaymentRuntimes sync.Map // digest -> root func rememberPaymentRuntime(digest, root, backendEntry string) { if paymentRuntimeReady(root, digest, backendEntry) { readyPaymentRuntimes.Store(digest, root) } } // linkReadyPaymentRuntime 把本进程已就绪的同摘要运行目录硬链接到 packageDir。 // 源目录必须仍然通过就绪校验;任何一步失败都返回 false,由调用方走完整解压。 func linkReadyPaymentRuntime(packageDir, digest, backendEntry string) bool { cached, ok := readyPaymentRuntimes.Load(digest) if !ok { return false } source := cached.(string) root := filepath.Join(packageDir, "runtime", digest) if source == root || !paymentRuntimeReady(source, digest, backendEntry) { return false } runtimeRoot := filepath.Join(packageDir, "runtime") if err := os.MkdirAll(runtimeRoot, 0o700); err != nil { return false } temporaryRoot, err := os.MkdirTemp(runtimeRoot, ".payment-runtime-") if err != nil { return false } defer os.RemoveAll(temporaryRoot) linkErr := filepath.WalkDir(source, func(path string, entry fs.DirEntry, err error) error { if err != nil { return err } rel, err := filepath.Rel(source, path) if err != nil { return err } target := filepath.Join(temporaryRoot, rel) if entry.IsDir() { return os.MkdirAll(target, 0o700) } if !entry.Type().IsRegular() { return fmt.Errorf("unexpected runtime entry %q", rel) } return os.Link(path, target) }) if linkErr != nil || os.Rename(temporaryRoot, root) != nil { return false } return paymentRuntimeReady(root, digest, backendEntry) } func materializePaymentBackend(packageDir, hash string, pkg protocol.PluginPackage) (string, error) { 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 { // content 为 nil 表示该条目按需未解压(见 ParsePluginPackageFiles),不能写成空文件。 if content == nil || !strings.HasPrefix(name, "backend/") || strings.HasSuffix(name, "/") { continue } target := filepath.Join(temporaryRoot, filepath.FromSlash(name)) if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil { return "", err } if err := os.WriteFile(target, content, 0o700); err != nil { 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 } 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 { c.mu.RLock() defer c.mu.RUnlock() items := make([]PluginView, 0, len(c.plugins)) for _, item := range c.plugins { items = append(items, PluginView{Manifest: pluginManifestView(item.Raw, item.Metadata, item.Source), Source: item.Source, FileName: item.FileName, Package: protocol.PluginPackageFormat, SHA256: item.SHA256, InstalledAt: item.InstalledAt, UpdatedAt: item.UpdatedAt, Status: item.Status, Error: item.Error}) } sort.Slice(items, func(i, j int) bool { return items[i].Manifest.ID < items[j].Manifest.ID }) return items } func (c *pluginRuntime) registrySnapshot() *protocol.Registry { c.mu.RLock() defer c.mu.RUnlock() return c.registry } func (c *pluginRuntime) paymentRegistrySnapshot() *payment.Registry { c.mu.RLock() defer c.mu.RUnlock() return c.paymentRegistry } func (c *pluginRuntime) install(data []byte, fileName string) (PluginView, error) { c.mutationMu.Lock() defer c.mutationMu.Unlock() if len(data) == 0 || len(data) > protocol.PluginPackageMaxBytes { return PluginView{}, fmt.Errorf("plugin package must be between 1 and %d bytes", protocol.PluginPackageMaxBytes) } pkg, err := protocol.ParsePluginPackage(data) if err != nil { return PluginView{}, err } manifest := pkg.Manifest if strings.HasPrefix(strings.TrimSpace(manifest.Runtime.Backend), "host:") { return PluginView{}, errors.New("上传插件不能使用宿主内置执行器") } if len(manifest.Contributes.PaymentProviders) == 0 { if _, err := protocol.LoadInstalledProviders(pkg.ManifestRaw, nil); err != nil { return PluginView{}, err } } c.mu.RLock() existing, exists := c.plugins[manifest.Metadata.ID] c.mu.RUnlock() if exists && isBuiltInPluginSource(existing.Source) && !isPaymentPluginManifest(manifest) { return PluginView{}, fmt.Errorf("内置插件 %q 不能通过上传覆盖", manifest.Metadata.ID) } manifest.Metadata.Enabled = !exists || existing.Metadata.Enabled manifestData, err := json.Marshal(manifest) if err != nil { return PluginView{}, err } hash := pluginHash(data) packageName := filepath.Base(strings.TrimSpace(fileName)) if packageName == "." || packageName == "" || packageName == string(filepath.Separator) { packageName = manifest.Metadata.ID + ".yingce-plugin" } packagePath := filepath.Join(c.packageDir, hash+".yingce-plugin") if err := writePluginFile(packagePath, data); err != nil { return PluginView{}, fmt.Errorf("保存插件包失败:%w", err) } stored, err := c.readRegistry() if err != nil { _ = os.Remove(packagePath) return PluginView{}, err } previousStored := append([]pluginRegistryRecord(nil), stored...) now := time.Now().UTC() newRecord := pluginRegistryRecord{ID: manifest.Metadata.ID, Raw: manifestData, Source: "uploaded", FileName: packageName, PackagePath: filepath.Base(packagePath), PackageSHA256: hash, InstalledAt: now, UpdatedAt: now} if exists { newRecord.InstalledAt = existing.InstalledAt for index := range stored { if stored[index].ID == manifest.Metadata.ID { stored[index] = newRecord break } } } else { stored = append(stored, newRecord) } if err := c.writeRegistry(stored); err != nil { _ = os.Remove(packagePath) return PluginView{}, fmt.Errorf("保存插件失败:%w", err) } if err := c.reload(); err != nil { _ = c.writeRegistry(previousStored) _ = c.reload() _ = os.Remove(packagePath) return PluginView{}, err } if exists && existing.PackagePath != "" && existing.PackagePath != filepath.Base(packagePath) { _ = os.Remove(filepath.Join(c.packageDir, filepath.Base(existing.PackagePath))) } for _, item := range c.list() { if item.Manifest.ID == manifest.Metadata.ID { return item, nil } } return PluginView{}, errors.New("插件保存后未加载") } func isPaymentPluginManifest(manifest protocol.Manifest) bool { backend := strings.TrimSpace(manifest.Runtime.Backend) return len(manifest.Contributes.PaymentProviders) > 0 && (backend == "rpc" || backend == "wasm") } func (c *pluginRuntime) setEnabled(id string, enabled bool) (PluginView, error) { c.mutationMu.Lock() defer c.mutationMu.Unlock() c.mu.RLock() record, ok := c.plugins[strings.TrimSpace(id)] c.mu.RUnlock() if !ok { return PluginView{}, fmt.Errorf("插件 %q 不存在", id) } var manifest protocol.Manifest if err := json.Unmarshal(record.Raw, &manifest); err != nil { return PluginView{}, err } manifest.Metadata.Enabled = enabled data, err := json.Marshal(manifest) if err != nil { return PluginView{}, err } stored, err := c.readRegistry() if err != nil { return PluginView{}, err } for index := range stored { if stored[index].ID == record.Metadata.ID { stored[index].Raw = data stored[index].UpdatedAt = time.Now().UTC() } } if err := c.writeRegistry(stored); err != nil { return PluginView{}, err } if err := c.reload(); err != nil { return PluginView{}, err } for _, item := range c.list() { if item.Manifest.ID == manifest.Metadata.ID { return item, nil } } return PluginView{}, errors.New("插件状态更新后未加载") } func pluginManifestView(raw []byte, metadata protocol.Metadata, source string) PluginManifestView { var manifest protocol.Manifest _ = json.Unmarshal(raw, &manifest) if manifest.Metadata.ID == "" { manifest.Metadata = metadata } return PluginManifestView{ ID: metadata.ID, Name: metadata.Name, Version: metadata.Version, APIVersion: "yingce.plugin/v1", Entry: manifest.Entry, Surfaces: manifest.Surfaces, Description: metadata.Description, Documentation: metadata.Documentation, Author: metadata.Vendor, Permissions: manifest.Permissions, Trusted: isBuiltInPluginSource(source), Runtime: manifest.Runtime, Configuration: manifest.Configuration, Contributes: manifest.Contributes, } } func (c *pluginRuntime) uninstall(id string) error { c.mutationMu.Lock() defer c.mutationMu.Unlock() c.mu.RLock() record, ok := c.plugins[strings.TrimSpace(id)] c.mu.RUnlock() if !ok { return fmt.Errorf("插件 %q 不存在", id) } if isBuiltInPluginSource(record.Source) { return fmt.Errorf("内置插件 %q 不能卸载,可停用该插件", id) } stored, err := c.readRegistry() if err != nil { return err } filtered := stored[:0] for _, item := range stored { if item.ID != record.Metadata.ID { filtered = append(filtered, item) } } if err := c.writeRegistry(filtered); err != nil { return err } if err := c.reload(); err != nil { return err } if record.PackagePath != "" { _ = os.Remove(filepath.Join(c.packageDir, filepath.Base(record.PackagePath))) } return nil } func (c *pluginRuntime) readRegistry() ([]pluginRegistryRecord, error) { data, err := os.ReadFile(c.registryPath) if errors.Is(err, os.ErrNotExist) { return []pluginRegistryRecord{}, nil } if err != nil { return nil, err } if len(data) > protocolPluginMaxBytes*64 { return nil, fmt.Errorf("插件 registry 超过大小限制") } var records []pluginRegistryRecord if err := json.Unmarshal(data, &records); err != nil { return nil, fmt.Errorf("读取插件 registry 失败:%w", err) } return records, nil } func (c *pluginRuntime) writeRegistry(records []pluginRegistryRecord) error { data, err := json.MarshalIndent(records, "", " ") if err != nil { return err } return writePluginFile(c.registryPath, data) } // cachePluginPackage 写入以内容摘要命名的插件包缓存。文件名即摘要,且 writePluginFile // 通过临时文件 + rename 原子发布,所以同名文件存在且大小一致就是同一份内容,无需重写。 // // source 非空时先尝试硬链接到官方包原文件(同一文件系统内零拷贝);跨文件系统 // (生产环境镜像目录与 /data 卷)会失败并回退为普通写入。即使链接到的内容与摘要 // 不一致,reload 也会按摘要校验并把插件标为 invalid,不会放过错误的包。 func cachePluginPackage(path string, data []byte, source string) error { if info, err := os.Stat(path); err == nil && info.Mode().IsRegular() && info.Size() == int64(len(data)) { return nil } if source != "" && os.Link(source, path) == nil { return nil } return writePluginFile(path, data) } func writePluginFile(path string, data []byte) error { temporary, err := os.CreateTemp(filepath.Dir(path), ".plugin-*.tmp") if err != nil { return err } temporaryPath := temporary.Name() defer os.Remove(temporaryPath) if err := temporary.Chmod(0o600); err != nil { _ = temporary.Close() return err } if _, err := temporary.Write(data); err != nil { _ = temporary.Close() return err } if err := temporary.Sync(); err != nil { _ = temporary.Close() return err } if err := temporary.Close(); err != nil { return err } return os.Rename(temporaryPath, path) } func pluginSource(metadata protocol.Metadata) string { return "uploaded" } func pluginHash(data []byte) string { digest := sha256.Sum256(data) return hex.EncodeToString(digest[:]) }