feat(payment): 支付插件 - 本地合并 PR #382 微信支付宝充值与对账

This commit is contained in:
ddCat committed 2026-09-02 13:22:17 +08:00
commit d6bbdbe27b
41 files changed
+6401 -136

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -0,0 +1,83 @@
package service
import (
"infinite-canvas/backend/internal/payment"
"infinite-canvas/backend/internal/protocol"
)
const (
PaymentPluginWeChatNative = payment.PluginWeChatNative
PaymentPluginAlipayPage = payment.PluginAlipayPage
PaymentProviderWeChat = payment.ProviderWeChatNative
PaymentProviderAlipay = payment.ProviderAlipayPage
)
func bundledPaymentPluginManifests() []protocol.Manifest {
return []protocol.Manifest{
paymentPluginManifest(
PaymentPluginWeChatNative,
PaymentProviderWeChat,
"微信支付 Native",
"微信支付",
"微信支付 Native 扫码充值适配器。",
"host:wechatpay-v3-native",
"brand:wechat-pay",
"qr_code",
wechatPaymentConfiguration(),
),
paymentPluginManifest(
PaymentPluginAlipayPage,
PaymentProviderAlipay,
"支付宝电脑网站支付",
"支付宝",
"支付宝 alipay.trade.page.pay 电脑网站充值适配器。",
"host:alipay-page-pay",
"brand:alipay",
"redirect",
alipayPaymentConfiguration(),
),
}
}
func paymentPluginManifest(pluginID, providerID, name, vendor, description, runtime, icon, checkoutMode string, configuration protocol.ManifestConfiguration) protocol.Manifest {
return protocol.Manifest{
APIVersion: "yingce.plugin/v1",
Metadata: protocol.Metadata{
ID: pluginID, Version: "1.0.0", Name: name, Vendor: vendor,
Description: description, Enabled: false, Installable: true,
Documentation: "# " + name + "\n\n系统宿主支付适配器。后端负责密钥、验签、查单、关单和对账。",
},
Surfaces: []string{"wallet", "settings"},
Runtime: protocol.ManifestRuntime{Backend: runtime, Web: "host"},
Permissions: []string{"payment.create", "payment.query", "payment.close", "payment.reconcile"},
Configuration: configuration,
Contributes: protocol.ManifestContributions{PaymentProviders: []protocol.ManifestPaymentProvider{{
ID: providerID, Label: name, Icon: icon, CheckoutMode: checkoutMode,
ExpiryPolicy: protocol.ManifestPaymentExpiryPolicy{DefaultMinutes: 30, MinMinutes: 5, MaxMinutes: 1440},
}}},
}
}
func wechatPaymentConfiguration() protocol.ManifestConfiguration {
return protocol.ManifestConfiguration{Fields: []protocol.ManifestField{
{Name: "publicBaseUrl", Type: "url", Label: "服务器公网地址", Required: true, Description: "用于生成微信支付回调地址,必须可被微信访问。"},
{Name: "appId", Type: "string", Label: "AppID", Required: true},
{Name: "mchId", Type: "string", Label: "商户号", Required: true},
{Name: "merchantSerialNo", Type: "string", Label: "商户证书序列号", Required: true},
{Name: "merchantPrivateKey", Type: "textarea", Label: "商户 API 私钥", Required: true, Secret: true},
{Name: "apiV3Key", Type: "password", Label: "APIv3 密钥", Required: true, Secret: true},
{Name: "wechatPayPublicKeyId", Type: "string", Label: "微信支付公钥 ID", Required: true},
{Name: "wechatPayPublicKey", Type: "textarea", Label: "微信支付公钥", Required: true, Secret: true},
}}
}
func alipayPaymentConfiguration() protocol.ManifestConfiguration {
return protocol.ManifestConfiguration{Fields: []protocol.ManifestField{
{Name: "publicBaseUrl", Type: "url", Label: "服务器公网地址", Required: true, Description: "用于生成支付宝异步通知和同步返回地址。"},
{Name: "appId", Type: "string", Label: "应用 AppID", Required: true},
{Name: "sellerId", Type: "string", Label: "支付宝商户 PID", Required: true},
{Name: "merchantPrivateKey", Type: "textarea", Label: "应用私钥", Required: true, Secret: true},
{Name: "alipayPublicKey", Type: "textarea", Label: "支付宝公钥", Required: true, Secret: true},
{Name: "gateway", Type: "url", Label: "支付宝网关", Required: true, Default: "https://openapi.alipay.com/gateway.do"},
}}
}
@@ -0,0 +1,70 @@
package service
import (
"testing"
"infinite-canvas/backend/internal/payment"
"infinite-canvas/backend/internal/protocol"
)
func TestBundledPaymentPluginsMatchHostProviders(t *testing.T) {
registry := payment.Builtins()
manifests := bundledPaymentPluginManifests()
if len(manifests) != 2 {
t.Fatalf("payment plugin manifests = %d", len(manifests))
}
for _, manifest := range manifests {
if err := protocol.ValidateManifest(manifest); err != nil {
t.Fatalf("validate %s: %v", manifest.Metadata.ID, err)
}
management := pluginManagement(manifest.Metadata.ID, PluginOriginSystem)
if management.Kind != PluginKindPayment || management.Origin != PluginOriginSystem || management.ActivationScope != PluginScopeSystem {
t.Fatalf("plugin %s management = %#v", manifest.Metadata.ID, management)
}
if len(manifest.Contributes.PaymentProviders) != 1 {
t.Fatalf("plugin %s payment contributions = %d", manifest.Metadata.ID, len(manifest.Contributes.PaymentProviders))
}
contribution := manifest.Contributes.PaymentProviders[0]
provider, ok := registry.Get(contribution.ID)
if !ok {
t.Fatalf("plugin %s has no host provider %s", manifest.Metadata.ID, contribution.ID)
}
descriptor := provider.Descriptor()
if descriptor.PluginID != manifest.Metadata.ID || descriptor.Icon != contribution.Icon || descriptor.CheckoutMode != contribution.CheckoutMode {
t.Fatalf("plugin/provider mismatch: manifest=%#v descriptor=%#v", contribution, descriptor)
}
}
}
func TestBundledPaymentPluginsLoadFromSystemSource(t *testing.T) {
center, err := newPluginRuntime(t.TempDir())
if err != nil {
t.Fatal(err)
}
plugins := make(map[string]PluginView)
for _, plugin := range center.list() {
plugins[plugin.Manifest.ID] = plugin
}
for _, manifest := range bundledPaymentPluginManifests() {
plugin, ok := plugins[manifest.Metadata.ID]
if !ok {
t.Fatalf("system payment plugin %s is missing", manifest.Metadata.ID)
}
if plugin.Source != PluginOriginSystem || !plugin.Manifest.Trusted {
t.Fatalf("system payment plugin %s = %#v", manifest.Metadata.ID, plugin)
}
}
}
func TestTopupProductCreditAmountStaysWithinSafeLimit(t *testing.T) {
if _, err := topupProductFromRequest("product", "admin", TopupProductRequest{
Name: "过大积分商品", AmountFen: 1, CreditsMicrocredits: maxTopupCreditsMicrocredits + 1,
}); err == nil {
t.Fatal("expected oversized top-up credits to be rejected")
}
if _, err := topupProductFromRequest("product", "admin", TopupProductRequest{
Name: "有效积分商品", AmountFen: 1, CreditsMicrocredits: maxTopupCreditsMicrocredits,
}); err != nil {
t.Fatalf("maximum safe top-up credits: %v", err)
}
}
@@ -0,0 +1,355 @@
package service
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"time"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/payment"
"infinite-canvas/backend/internal/repository"
"gorm.io/gorm"
)
var paymentBillLocation = time.FixedZone("Asia/Shanghai", 8*60*60)
type RunPaymentReconciliationRequest struct {
ProviderID string `json:"providerId"`
BillDate string `json:"billDate"`
}
type AdminPaymentReconciliationPage struct {
Runs []model.PaymentReconciliationRun `json:"runs"`
Total int64 `json:"total"`
Page int `json:"page"`
Limit int `json:"limit"`
}
type AdminPaymentReconciliationItemPage struct {
Run model.PaymentReconciliationRun `json:"run"`
Items []model.PaymentReconciliationItem `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
Limit int `json:"limit"`
}
func (s *Service) RunPaymentReconciliation(ctx context.Context, actor *model.User, request RunPaymentReconciliationRequest) (*model.PaymentReconciliationRun, error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, err
}
return s.runPaymentReconciliation(ctx, actor, strings.TrimSpace(request.ProviderID), strings.TrimSpace(request.BillDate))
}
func (s *Service) runPaymentReconciliation(ctx context.Context, actor *model.User, providerID, billDateValue string) (*model.PaymentReconciliationRun, error) {
provider, ok := s.paymentRegistry.Get(providerID)
if !ok {
return nil, BadAuthRequest("未知支付渠道")
}
billDate, err := parsePaymentBillDate(billDateValue)
if err != nil {
return nil, BadAuthRequest(err.Error())
}
config, err := s.repo.LatestPaymentProviderConfig(providerID)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, BadAuthRequest("支付渠道尚未配置")
}
if err != nil {
return nil, err
}
values, err := s.decryptPaymentConfig(config)
if err != nil {
return nil, err
}
if err := provider.ValidateConfig(values); err != nil {
return nil, BadAuthRequest(err.Error())
}
startedBy := "system"
if actor != nil {
startedBy = actor.ID
}
run, started, err := s.repo.BeginPaymentReconciliation(&model.PaymentReconciliationRun{
ID: newID(), ProviderID: providerID, ConfigID: config.ID, BillDate: billDateValue,
Status: model.PaymentReconciliationRunning, StartedBy: startedBy, StartedAt: time.Now(),
})
if err != nil || !started {
return run, err
}
if actor != nil {
if err := s.appendAdminAudit(actor, "payment_reconciliation.run", "payment_provider", providerID, "执行支付渠道对账", map[string]any{"billDate": billDateValue, "runId": run.ID}); err != nil {
_ = s.repo.FailPaymentReconciliation(run.ID, safePaymentError(err))
return nil, err
}
}
start := billDate.In(paymentBillLocation)
end := start.AddDate(0, 0, 1)
localCredited, err := s.repo.CreditedPaymentOrdersBetween(providerID, start, end)
if err != nil {
return s.failPaymentReconciliation(run, err)
}
records, err := provider.DownloadTradeBill(ctx, values, billDate)
if errors.Is(err, payment.ErrTradeBillNotFound) && len(localCredited) == 0 {
candidateCount, countErr := s.repo.UnresolvedPaymentOrderCandidateCountOverlapping(providerID, start, end)
if countErr != nil {
return s.failPaymentReconciliation(run, countErr)
}
if candidateCount == 0 {
records = []payment.BillRecord{}
err = nil
}
}
if err != nil {
return s.failPaymentReconciliation(run, err)
}
items, matched, recovered, failed, err := s.comparePaymentReconciliation(ctx, provider, values, run, billDate, records, localCredited)
if err != nil {
return s.failPaymentReconciliation(run, err)
}
if err := s.repo.CompletePaymentReconciliation(run.ID, items, matched, recovered, failed); err != nil {
return s.failPaymentReconciliation(run, err)
}
return s.repo.PaymentReconciliationRun(run.ID)
}
func (s *Service) comparePaymentReconciliation(ctx context.Context, provider payment.Provider, values payment.Config, run *model.PaymentReconciliationRun, billDate time.Time, records []payment.BillRecord, localCredited []model.PaymentOrder) ([]model.PaymentReconciliationItem, int, int, int, error) {
items := make([]model.PaymentReconciliationItem, 0, len(records)+len(localCredited))
seen := make(map[string]struct{}, len(records))
matched, recovered, failed := 0, 0, 0
for _, record := range records {
merchantOrderNo := strings.TrimSpace(record.MerchantOrderNo)
if merchantOrderNo == "" {
continue
}
if _, duplicate := seen[merchantOrderNo]; duplicate {
continue
}
seen[merchantOrderNo] = struct{}{}
item := model.PaymentReconciliationItem{
ID: newID(), RunID: run.ID, ProviderID: run.ProviderID,
MerchantOrderNo: merchantOrderNo, ProviderTradeNo: strings.TrimSpace(record.ProviderTradeNo),
AmountFen: record.AmountFen, Currency: record.Currency, CreatedAt: time.Now(),
}
order, err := s.repo.PaymentOrderByMerchant(run.ProviderID, merchantOrderNo)
if errors.Is(err, gorm.ErrRecordNotFound) {
item.Result = model.PaymentReconciliationLocalOrderNotFound
item.Detail = "渠道账单存在成功交易,但本地订单不存在"
items = append(items, item)
failed++
continue
}
if err != nil {
return nil, 0, 0, 0, err
}
item.PaymentOrderID = order.ID
if record.AmountFen != order.AmountFen || record.Currency != order.Currency {
item.Result = model.PaymentReconciliationAmountMismatch
item.Detail = fmt.Sprintf("渠道金额 %d %s,本地金额 %d %s", record.AmountFen, record.Currency, order.AmountFen, order.Currency)
items = append(items, item)
failed++
continue
}
if order.ProviderTradeNo != nil && *order.ProviderTradeNo != "" && *order.ProviderTradeNo != record.ProviderTradeNo {
item.Result = model.PaymentReconciliationTradeNoMismatch
item.Detail = "渠道交易号与本地已记录交易号不一致"
items = append(items, item)
failed++
continue
}
if order.Status == model.PaymentOrderCredited {
item.Result = model.PaymentReconciliationMatched
item.Resolved = true
items = append(items, item)
matched++
continue
}
// A downloaded bill is a reconciliation hint, not sufficient payment
// authorization. This is especially important for Alipay bill URLs that
// may use the provider-documented HTTP download host. Confirm every
// recovery through the signed API response before granting credits.
confirmed, confirmErr := provider.QueryOrder(ctx, values, payment.QueryRequest{MerchantOrderNo: merchantOrderNo})
if confirmErr != nil {
item.Result = model.PaymentReconciliationCreditFailed
item.Detail = "渠道账单显示已支付,但签名查单确认失败:" + safePaymentReconciliationError(confirmErr)
items = append(items, item)
failed++
continue
}
if strings.TrimSpace(confirmed.MerchantOrderNo) != merchantOrderNo || !confirmed.Paid {
item.Result = model.PaymentReconciliationCreditFailed
item.Detail = "渠道账单显示已支付,但签名查单未确认该订单已支付"
items = append(items, item)
failed++
continue
}
if confirmed.AmountFen != order.AmountFen || confirmed.Currency != order.Currency {
item.Result = model.PaymentReconciliationAmountMismatch
item.Detail = fmt.Sprintf("签名查单金额 %d %s,本地金额 %d %s", confirmed.AmountFen, confirmed.Currency, order.AmountFen, order.Currency)
items = append(items, item)
failed++
continue
}
if strings.TrimSpace(confirmed.ProviderTradeNo) == "" || strings.TrimSpace(confirmed.ProviderTradeNo) != strings.TrimSpace(record.ProviderTradeNo) {
item.Result = model.PaymentReconciliationTradeNoMismatch
item.Detail = "渠道账单交易号与签名查单交易号不一致"
items = append(items, item)
failed++
continue
}
paidAt := confirmed.PaidAt
if paidAt.IsZero() {
paidAt = record.PaidAt
}
if paidAt.IsZero() {
paidAt = billDate.Add(12 * time.Hour)
}
_, _, creditErr := s.repo.CompletePaymentOrder(run.ProviderID, merchantOrderNo, repository.PaymentEvidence{
ProviderTradeNo: confirmed.ProviderTradeNo, ProviderStatus: confirmed.ProviderStatus,
AmountFen: confirmed.AmountFen, Currency: confirmed.Currency, PaidAt: paidAt,
})
if creditErr != nil {
item.Result = model.PaymentReconciliationCreditFailed
item.Detail = "渠道账单确认已支付,但自动补发积分失败:" + safePaymentError(creditErr)
items = append(items, item)
failed++
continue
}
item.Result = model.PaymentReconciliationRecovered
item.Resolved = true
item.Detail = "已根据渠道账单自动补发积分"
items = append(items, item)
recovered++
}
for _, order := range localCredited {
if _, ok := seen[order.MerchantOrderNo]; ok {
continue
}
tradeNo := ""
if order.ProviderTradeNo != nil {
tradeNo = *order.ProviderTradeNo
}
items = append(items, model.PaymentReconciliationItem{
ID: newID(), RunID: run.ID, ProviderID: run.ProviderID, PaymentOrderID: order.ID,
MerchantOrderNo: order.MerchantOrderNo, ProviderTradeNo: tradeNo,
AmountFen: order.AmountFen, Currency: order.Currency,
Result: model.PaymentReconciliationProviderRecordMissing,
Detail: "本地订单已入账,但渠道成功交易账单中未找到对应记录", CreatedAt: time.Now(),
})
failed++
}
return items, matched, recovered, failed, nil
}
func (s *Service) failPaymentReconciliation(run *model.PaymentReconciliationRun, cause error) (*model.PaymentReconciliationRun, error) {
if run != nil {
_ = s.repo.FailPaymentReconciliation(run.ID, safePaymentReconciliationError(cause))
if latest, err := s.repo.PaymentReconciliationRun(run.ID); err == nil {
run = latest
}
}
return run, WrapAppError(http.StatusBadGateway, "支付渠道对账失败,请稍后重试", cause)
}
func safePaymentReconciliationError(err error) string {
if err == nil {
return ""
}
if errors.Is(err, payment.ErrTradeBillNotFound) {
return "TRADE_BILL_NOT_FOUND"
}
var providerErr *payment.ProviderError
if errors.As(err, &providerErr) && strings.TrimSpace(providerErr.Code) != "" {
return truncateRunes(providerErr.Code, 1000)
}
// Reconciliation runs are admin-only diagnostics. Parser and persistence
// errors contain no provider credentials and are more actionable than only
// storing their Go concrete type.
return truncateRunes(strings.TrimSpace(err.Error()), 1000)
}
func (s *Service) AdminPaymentReconciliationPage(actor *model.User, providerID, status string, page, limit int) (*AdminPaymentReconciliationPage, error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, err
}
page, limit = normalizeAdminPage(page, limit)
runs, total, err := s.repo.AdminPaymentReconciliationRuns(providerID, status, limit, (page-1)*limit)
if err != nil {
return nil, err
}
return &AdminPaymentReconciliationPage{Runs: runs, Total: total, Page: page, Limit: limit}, nil
}
func (s *Service) AdminPaymentReconciliationItems(actor *model.User, runID, result string, page, limit int) (*AdminPaymentReconciliationItemPage, error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, err
}
page, limit = normalizeAdminPage(page, limit)
run, err := s.repo.PaymentReconciliationRun(runID)
if err != nil {
return nil, err
}
items, total, err := s.repo.PaymentReconciliationItems(run.ID, result, limit, (page-1)*limit)
if err != nil {
return nil, err
}
return &AdminPaymentReconciliationItemPage{Run: *run, Items: items, Total: total, Page: page, Limit: limit}, nil
}
func parsePaymentBillDate(value string) (time.Time, error) {
parsed, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(value), paymentBillLocation)
if err != nil {
return time.Time{}, errors.New("账单日期格式必须为 YYYY-MM-DD")
}
today := time.Now().In(paymentBillLocation)
today = time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, paymentBillLocation)
if !parsed.Before(today) {
return time.Time{}, errors.New("只能对账昨天及更早的账单")
}
if parsed.Before(today.AddDate(0, -3, 0)) {
return time.Time{}, errors.New("首期仅支持最近三个月的账单对账")
}
return parsed, nil
}
func (s *Service) maybeRunDailyPaymentReconciliation(ctx context.Context) {
now := time.Now().In(paymentBillLocation)
if now.Hour() < 10 || (now.Hour() == 10 && now.Minute() < 15) {
return
}
billDate := now.AddDate(0, 0, -1).Format("2006-01-02")
for _, descriptor := range s.paymentRegistry.Descriptors() {
config, err := s.repo.LatestPaymentProviderConfig(descriptor.ID)
if err != nil {
continue
}
// Disabling a channel stops new orders, but must not stop T+1
// reconciliation for payments created before it was disabled.
provider, ok := s.paymentRegistry.Get(descriptor.ID)
if !ok {
continue
}
values, err := s.decryptPaymentConfig(config)
if err != nil || provider.ValidateConfig(values) != nil {
continue
}
existing, err := s.repo.PaymentReconciliationRunByDate(descriptor.ID, billDate)
if err == nil {
if existing.Status == model.PaymentReconciliationCompleted || (existing.Status == model.PaymentReconciliationRunning && existing.UpdatedAt.After(time.Now().Add(-30*time.Minute))) || (existing.Status == model.PaymentReconciliationFailed && existing.UpdatedAt.After(time.Now().Add(-30*time.Minute))) {
continue
}
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
continue
}
providerContext, cancel := context.WithTimeout(ctx, 2*time.Minute)
if _, err := s.runPaymentReconciliation(providerContext, nil, descriptor.ID, billDate); err != nil {
// The persisted run contains a safe error code. The worker retries
// failed T+1 runs after the cooldown above.
}
cancel()
}
}
+35 -4
View File
@@ -14,10 +14,12 @@ const (
PluginPortraitClearance = "portrait-clearance"
PluginOriginOfficial = "official"
PluginOriginSystem = "system"
PluginOriginUploaded = "uploaded"
PluginKindProtocol = "protocol"
PluginKindApplication = "application"
PluginKindPayment = "payment"
PluginScopeSystem = "system"
PluginScopeUser = "user"
@@ -75,6 +77,17 @@ var officialApplicationPolicies = map[string]PluginManagementView{
},
}
var systemPaymentPolicies = map[string]PluginManagementView{
PaymentPluginWeChatNative: {
Origin: PluginOriginSystem, Kind: PluginKindPayment,
ActivationScope: PluginScopeSystem, ConfigurationScope: PluginConfigurationSystem,
},
PaymentPluginAlipayPage: {
Origin: PluginOriginSystem, Kind: PluginKindPayment,
ActivationScope: PluginScopeSystem, ConfigurationScope: PluginConfigurationSystem,
},
}
func pluginManagement(pluginID string, source string) PluginManagementView {
if strings.TrimSpace(source) == PluginOriginUploaded {
return PluginManagementView{
@@ -85,6 +98,9 @@ func pluginManagement(pluginID string, source string) PluginManagementView {
if policy, ok := officialApplicationPolicies[strings.TrimSpace(pluginID)]; ok {
return policy
}
if policy, ok := systemPaymentPolicies[strings.TrimSpace(pluginID)]; ok {
return policy
}
return PluginManagementView{
Origin: PluginOriginOfficial, Kind: PluginKindProtocol,
ActivationScope: PluginScopeSystem, ConfigurationScope: PluginConfigurationSystem,
@@ -92,12 +108,19 @@ func pluginManagement(pluginID string, source string) PluginManagementView {
}
func knownPluginIDs(items []PluginView) []string {
seen := make(map[string]struct{}, len(items)+len(officialApplicationPolicies))
ids := make([]string, 0, len(items)+len(officialApplicationPolicies))
seen := make(map[string]struct{}, len(items)+len(officialApplicationPolicies)+len(systemPaymentPolicies))
ids := make([]string, 0, len(items)+len(officialApplicationPolicies)+len(systemPaymentPolicies))
for id := range officialApplicationPolicies {
seen[id] = struct{}{}
ids = append(ids, id)
}
for id := range systemPaymentPolicies {
if _, exists := seen[id]; exists {
continue
}
seen[id] = struct{}{}
ids = append(ids, id)
}
for _, item := range items {
if _, exists := seen[item.Manifest.ID]; exists {
continue
@@ -135,7 +158,7 @@ func (s *Service) pluginStateForUser(actor *model.User, pluginID string, items [
source := "bundled"
if hasRuntime {
source = runtimePlugin.Source
} else if _, known := officialApplicationPolicies[pluginID]; !known {
} else if !isKnownRuntimeOptionalPlugin(pluginID) {
return PluginStateView{}, fmt.Errorf("插件 %q 不存在", pluginID)
}
policy := pluginManagement(pluginID, source)
@@ -254,7 +277,7 @@ func (s *Service) SetPluginPlatformAvailability(actor *model.User, pluginID stri
source := "bundled"
if hasRuntime {
source = runtimePlugin.Source
} else if _, known := officialApplicationPolicies[pluginID]; !known {
} else if !isKnownRuntimeOptionalPlugin(pluginID) {
return AdminPluginStateView{}, fmt.Errorf("插件 %q 不存在", pluginID)
}
policy := pluginManagement(pluginID, source)
@@ -288,6 +311,14 @@ func (s *Service) SetPluginPlatformAvailability(actor *model.User, pluginID stri
return states[pluginID], nil
}
func isKnownRuntimeOptionalPlugin(pluginID string) bool {
if _, known := officialApplicationPolicies[pluginID]; known {
return true
}
_, known := systemPaymentPolicies[pluginID]
return known
}
func (s *Service) RequirePluginForUser(userID string, pluginID string) error {
state, err := s.pluginStateForUser(&model.User{ID: strings.TrimSpace(userID)}, pluginID, s.Plugins())
if err != nil {
+67 -25
View File
@@ -94,7 +94,7 @@ func newPluginRuntime(dataDir string) (*pluginRuntime, error) {
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.bootstrapOfficialPlugins(); err != nil {
if err := center.bootstrapBuiltInPlugins(); err != nil {
return nil, err
}
if err := center.reload(); err != nil {
@@ -103,7 +103,7 @@ func newPluginRuntime(dataDir string) (*pluginRuntime, error) {
return center, nil
}
func (c *pluginRuntime) bootstrapOfficialPlugins() error {
func (c *pluginRuntime) bootstrapBuiltInPlugins() error {
stored, err := c.readRegistry()
if err != nil {
return err
@@ -120,7 +120,7 @@ func (c *pluginRuntime) bootstrapOfficialPlugins() error {
if err != nil {
return fmt.Errorf("读取官方插件目录失败:%w", err)
}
officialIDs := make(map[string]struct{}, len(entries)+2)
builtInIDs := make(map[string]struct{}, len(entries)+2)
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(strings.ToLower(entry.Name()), ".yingce-plugin") {
continue
@@ -140,10 +140,10 @@ func (c *pluginRuntime) bootstrapOfficialPlugins() error {
return fmt.Errorf("加载官方插件 %q:%w", pkg.Manifest.Metadata.ID, err)
}
id := pkg.Manifest.Metadata.ID
if _, duplicate := officialIDs[id]; duplicate {
if _, duplicate := builtInIDs[id]; duplicate {
return fmt.Errorf("官方插件 ID %q 重复", id)
}
officialIDs[id] = struct{}{}
builtInIDs[id] = struct{}{}
manifest := pkg.Manifest
record := byID[id]
if len(record.Raw) > 0 {
@@ -169,38 +169,43 @@ func (c *pluginRuntime) bootstrapOfficialPlugins() error {
record.PackagePath, record.PackageSHA256, record.UpdatedAt = packageName, hash, now
byID[id] = record
}
for _, workflow := range bundledWorkflowPluginManifests() {
officialIDs[workflow.Metadata.ID] = struct{}{}
data, err := json.Marshal(workflow)
bundledManifests := append(bundledWorkflowPluginManifests(), bundledPaymentPluginManifests()...)
for _, bundled := range bundledManifests {
builtInIDs[bundled.Metadata.ID] = struct{}{}
data, err := json.Marshal(bundled)
if err != nil {
return fmt.Errorf("encode official workflow plugin %s: %w", workflow.Metadata.ID, err)
return fmt.Errorf("encode built-in plugin %s: %w", bundled.Metadata.ID, err)
}
record := byID[workflow.Metadata.ID]
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 bundled workflow plugin %s: %w", workflow.Metadata.ID, err)
return fmt.Errorf("decode built-in plugin %s: %w", bundled.Metadata.ID, err)
}
workflow.Metadata.Enabled = installed.Metadata.Enabled
data, err = json.Marshal(workflow)
bundled.Metadata.Enabled = installed.Metadata.Enabled
data, err = json.Marshal(bundled)
if err != nil {
return fmt.Errorf("encode bundled workflow plugin %s: %w", workflow.Metadata.ID, err)
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
}
record.UpdatedAt = now
record.ID, record.Raw, record.Source, record.PackagePath = workflow.Metadata.ID, data, PluginOriginOfficial, ""
byID[workflow.Metadata.ID] = record
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 record.Source == "bundled" || record.Source == PluginOriginOfficial {
if _, exists := officialIDs[record.ID]; !exists {
// Official records are reconciled from repository packages on every
// startup, so removed packages cannot survive as stale providers.
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
}
}
@@ -210,6 +215,15 @@ func (c *pluginRuntime) bootstrapOfficialPlugins() error {
return c.writeRegistry(result)
}
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)
@@ -275,6 +289,31 @@ func (c *pluginRuntime) reload() error {
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 {
_, systemPayment := systemPaymentPolicies[id]
if record.Source != PluginOriginSystem || !systemPayment {
record.Metadata.Enabled = false
record.Status = "invalid"
record.Error = "首期支付插件只能使用系统宿主适配器"
plugins[id] = record
continue
}
}
if len(manifest.Contributes.PaymentProviders) > 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
@@ -341,6 +380,9 @@ func (c *pluginRuntime) install(data []byte, fileName string) (PluginView, error
return PluginView{}, err
}
manifest := pkg.Manifest
if len(manifest.Contributes.PaymentProviders) > 0 {
return PluginView{}, errors.New("首期支付插件只能使用系统宿主适配器")
}
if strings.HasPrefix(strings.TrimSpace(manifest.Runtime.Backend), "host:") {
return PluginView{}, errors.New("上传插件不能使用宿主内置执行器")
}
@@ -350,8 +392,8 @@ func (c *pluginRuntime) install(data []byte, fileName string) (PluginView, error
c.mu.RLock()
existing, exists := c.plugins[manifest.Metadata.ID]
c.mu.RUnlock()
if exists && existing.Source == PluginOriginOfficial {
return PluginView{}, fmt.Errorf("官方插件 %q 不能通过上传覆盖", manifest.Metadata.ID)
if exists && isBuiltInPluginSource(existing.Source) {
return PluginView{}, fmt.Errorf("内置插件 %q 不能通过上传覆盖", manifest.Metadata.ID)
}
manifest.Metadata.Enabled = !exists || existing.Metadata.Enabled
manifestData, err := json.Marshal(manifest)
@@ -458,7 +500,7 @@ func pluginManifestView(raw []byte, metadata protocol.Metadata, source string) P
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: source == PluginOriginOfficial, Runtime: manifest.Runtime,
Permissions: manifest.Permissions, Trusted: isBuiltInPluginSource(source), Runtime: manifest.Runtime,
Configuration: manifest.Configuration, Contributes: manifest.Contributes,
}
}
@@ -472,8 +514,8 @@ func (c *pluginRuntime) uninstall(id string) error {
if !ok {
return fmt.Errorf("插件 %q 不存在", id)
}
if record.Source == PluginOriginOfficial {
return fmt.Errorf("官方插件 %q 不能卸载,可停用该插件", id)
if isBuiltInPluginSource(record.Source) {
return fmt.Errorf("内置插件 %q 不能卸载,可停用该插件", id)
}
stored, err := c.readRegistry()
if err != nil {
@@ -265,6 +265,9 @@ func (s *Service) InstallPluginForAdmin(actor *model.User, data []byte, fileName
if _, reserved := officialApplicationPolicies[parsed.Manifest.Metadata.ID]; reserved {
return PluginView{}, fmt.Errorf("插件 ID %q 由官方应用保留", parsed.Manifest.Metadata.ID)
}
if _, reserved := systemPaymentPolicies[parsed.Manifest.Metadata.ID]; reserved {
return PluginView{}, fmt.Errorf("插件 ID %q 由系统支付插件保留", parsed.Manifest.Metadata.ID)
}
plugin, err := s.InstallPlugin(data, fileName)
if err != nil {
return PluginView{}, err
@@ -29,8 +29,9 @@ func TestPluginViewIncludesDocumentationForEveryOfficialProtocol(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if len(plugins) != len(packages)+2 {
t.Fatalf("plugin views = %d, official packages plus workflow plugins = %d", len(plugins), len(packages)+2)
bundledCount := len(bundledWorkflowPluginManifests()) + len(bundledPaymentPluginManifests())
if len(plugins) != len(packages)+bundledCount {
t.Fatalf("plugin views = %d, official packages plus bundled plugins = %d", len(plugins), len(packages)+bundledCount)
}
for _, packagePath := range packages {
data, err := os.ReadFile(packagePath)
@@ -216,6 +217,32 @@ func TestPluginRuntimeDropsRemovedOfficialProtocol(t *testing.T) {
}
}
func TestPluginRuntimeRejectsPersistedUploadedPaymentProvider(t *testing.T) {
manifest := json.RawMessage(`{"apiVersion":"yingce.plugin/v1","id":"uploaded-payment-provider","version":"1.0.0","name":"Uploaded Payment Provider","author":"Test","enabled":true,"runtime":{"backend":"host:untrusted-payment"},"contributes":{"paymentProviders":[{"id":"untrusted-payment","label":"Untrusted Payment","icon":"brand:untrusted","checkoutMode":"redirect","expiryPolicy":{"defaultMinutes":30,"minMinutes":5,"maxMinutes":1440}}]}}`)
registryData, err := json.Marshal([]pluginRegistryRecord{{ID: "uploaded-payment-provider", Raw: manifest, Source: "uploaded"}})
if err != nil {
t.Fatal(err)
}
dataDir := t.TempDir()
if err := os.WriteFile(filepath.Join(dataDir, "plugin_registry.json"), registryData, 0o600); err != nil {
t.Fatal(err)
}
center, err := newPluginRuntime(dataDir)
if err != nil {
t.Fatal(err)
}
for _, plugin := range center.list() {
if plugin.Manifest.ID != "uploaded-payment-provider" {
continue
}
if plugin.Status != "invalid" || !strings.Contains(plugin.Error, "系统宿主适配器") {
t.Fatalf("persisted uploaded payment plugin = %#v", plugin)
}
return
}
t.Fatal("persisted uploaded payment plugin was not retained as invalid")
}
func TestAutoDLPluginPackageLoadsAsOfficialRuntime(t *testing.T) {
center, err := newPluginRuntime(t.TempDir())
if err != nil {
+4 -1
View File
@@ -13,6 +13,7 @@ import (
"unicode/utf8"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/payment"
"infinite-canvas/backend/internal/repository"
)
@@ -42,6 +43,7 @@ type Service struct {
runtimeErr error
pluginRuntime *pluginRuntime
pluginRuntimeErr error
paymentRegistry *payment.Registry
workerID string
routeCatalogMu sync.RWMutex
routeCatalogRefreshMu sync.Mutex
@@ -108,7 +110,7 @@ func New(repo *repository.Repository, dataDir string) *Service {
func NewWithRuntimeCapabilities(repo *repository.Repository, dataDir string, capabilities RuntimeCapabilities) *Service {
coordinator, err := newRuntimeCoordinator(repo.Dialect())
pluginRuntime, pluginRuntimeErr := newPluginRuntime(dataDir)
service := &Service{repo: repo, dataDir: dataDir, runtimeCapabilities: capabilities, activeStorageTests: make(map[string]bool), activeCancels: make(map[string]context.CancelFunc), coordinator: coordinator, runtimeErr: err, pluginRuntime: pluginRuntime, pluginRuntimeErr: pluginRuntimeErr, workerID: newID(), routeCatalogTTL: 30 * time.Second, routeCatalogMaxStale: 5 * time.Minute, routeHealthBlocked: make(map[string]time.Time)}
service := &Service{repo: repo, dataDir: dataDir, runtimeCapabilities: capabilities, activeStorageTests: make(map[string]bool), activeCancels: make(map[string]context.CancelFunc), coordinator: coordinator, runtimeErr: err, pluginRuntime: pluginRuntime, pluginRuntimeErr: pluginRuntimeErr, paymentRegistry: payment.Builtins(), workerID: newID(), routeCatalogTTL: 30 * time.Second, routeCatalogMaxStale: 5 * time.Minute, routeHealthBlocked: make(map[string]time.Time)}
service.taskBillingCoordinator = newTaskBillingCoordinator(service.repo)
service.taskTerminalCoordinator = newTaskTerminalCoordinator(service)
service.taskRouteExecutor = newTaskRouteExecutor(service)
@@ -136,6 +138,7 @@ func (s *Service) StartWorker() {
s.taskWorker().start(ctx)
s.startResourceDeletionWorker(ctx)
s.startSkillSyncWorker(ctx)
s.startPaymentWorker(ctx)
}
func (s *Service) BeginDrain() { s.backgroundWorkers().beginDrain() }