feat(payment): 支付插件 - 本地合并 PR #382 微信支付宝充值与对账
This commit is contained in:
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()
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() }
|
||||
|
||||
Reference in new issue
Block a user