From 669b83b2f81124ab63b4a59e6b246d850d1d4660 Mon Sep 17 00:00:00 2001 From: MasterChiefCN <49179444+MasterChiefCN@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:58:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(payment):=20=E6=96=B0=E5=A2=9E=E5=BE=AE?= =?UTF-8?q?=E4=BF=A1=E6=94=AF=E4=BB=98=E5=AE=9D=E7=A7=AF=E5=88=86=E5=85=85?= =?UTF-8?q?=E5=80=BC=E4=B8=8E=E5=AF=B9=E8=B4=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/migrate-sqlite-postgres/main.go | 6 + backend/cmd/server/main.go | 1 + backend/internal/database/migrations.go | 19 +- backend/internal/database/migrations_test.go | 2 +- backend/internal/database/schema.go | 6 + backend/internal/handler/payment.go | 358 ++++++ backend/internal/model/models.go | 1 + backend/internal/model/models_finance.go | 1 + backend/internal/model/models_payment.go | 148 +++ backend/internal/payment/alipay.go | 406 +++++++ backend/internal/payment/bill.go | 313 +++++ backend/internal/payment/bill_test.go | 76 ++ backend/internal/payment/crypto.go | 84 ++ backend/internal/payment/provider_test.go | 289 +++++ backend/internal/payment/registry.go | 63 ++ backend/internal/payment/types.go | 110 ++ backend/internal/payment/wechat.go | 369 ++++++ backend/internal/protocol/manifest.go | 31 +- backend/internal/protocol/types.go | 36 +- backend/internal/repository/payment.go | 538 +++++++++ backend/internal/repository/payment_test.go | 356 ++++++ backend/internal/service/payment.go | 1005 +++++++++++++++++ backend/internal/service/payment_plugins.go | 83 ++ .../internal/service/payment_plugins_test.go | 70 ++ .../service/payment_reconciliation.go | 355 ++++++ backend/internal/service/plugin_management.go | 39 +- backend/internal/service/protocol_plugins.go | 92 +- backend/internal/service/protocol_registry.go | 3 + .../internal/service/protocol_runtime_test.go | 31 +- backend/internal/service/service.go | 5 +- web/src/lib/plugins/plugin-types.ts | 25 +- .../pages/admin/components/admin-shell.tsx | 2 + .../pages/admin/payments/payments-page.css | 17 + .../pages/admin/payments/payments-page.tsx | 747 ++++++++++++ web/src/pages/admin/plugins/plugins-page.tsx | 181 ++- web/src/pages/plugins/index.tsx | 68 +- web/src/pages/wallet/index.tsx | 395 ++++++- web/src/router.tsx | 2 + web/src/services/api/payments.ts | 187 +++ web/src/services/api/plugins.ts | 4 +- web/src/services/api/wallet.ts | 13 +- 41 files changed, 6401 insertions(+), 136 deletions(-) create mode 100644 backend/internal/handler/payment.go create mode 100644 backend/internal/model/models_payment.go create mode 100644 backend/internal/payment/alipay.go create mode 100644 backend/internal/payment/bill.go create mode 100644 backend/internal/payment/bill_test.go create mode 100644 backend/internal/payment/crypto.go create mode 100644 backend/internal/payment/provider_test.go create mode 100644 backend/internal/payment/registry.go create mode 100644 backend/internal/payment/types.go create mode 100644 backend/internal/payment/wechat.go create mode 100644 backend/internal/repository/payment.go create mode 100644 backend/internal/repository/payment_test.go create mode 100644 backend/internal/service/payment.go create mode 100644 backend/internal/service/payment_plugins.go create mode 100644 backend/internal/service/payment_plugins_test.go create mode 100644 backend/internal/service/payment_reconciliation.go create mode 100644 web/src/pages/admin/payments/payments-page.css create mode 100644 web/src/pages/admin/payments/payments-page.tsx create mode 100644 web/src/services/api/payments.ts diff --git a/backend/cmd/migrate-sqlite-postgres/main.go b/backend/cmd/migrate-sqlite-postgres/main.go index 3b503cded..4a78c0054 100644 --- a/backend/cmd/migrate-sqlite-postgres/main.go +++ b/backend/cmd/migrate-sqlite-postgres/main.go @@ -232,6 +232,12 @@ func migrations() []tableMigration { migrateTable[model.CreditAccount]("credit_accounts"), migrateTable[model.CreditLedgerEntry]("credit_ledger_entries"), migrateTable[model.BillingOrder]("billing_orders"), + migrateTable[model.TopupProduct]("topup_products"), + migrateTable[model.PaymentProviderConfig]("payment_provider_configs"), + migrateTable[model.PaymentOrder]("payment_orders"), + migrateTable[model.PaymentNotification]("payment_notifications"), + migrateTable[model.PaymentReconciliationRun]("payment_reconciliation_runs"), + migrateTable[model.PaymentReconciliationItem]("payment_reconciliation_items"), migrateTable[model.RedeemBatch]("redeem_batches"), migrateTable[model.RedeemCode]("redeem_codes"), migrateTable[model.AdminAuditEvent]("admin_audit_events"), diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index b22c95c28..1b18b401b 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -121,6 +121,7 @@ func run(ctx context.Context) error { handler.RegisterAdminUpdateRoutes(api, svc) handler.RegisterAnnouncementRoutes(api, svc) handler.RegisterFinanceRoutes(api, svc) + handler.RegisterPaymentRoutes(api, svc) handler.RegisterLibTVRoutes(api, svc) handler.RegisterTapNowRoutes(api, svc) // 登录态模型目录代理:避免浏览器直连各上游时分别处理 CORS。 diff --git a/backend/internal/database/migrations.go b/backend/internal/database/migrations.go index ea1e3368b..5e9c1fec9 100644 --- a/backend/internal/database/migrations.go +++ b/backend/internal/database/migrations.go @@ -10,12 +10,13 @@ import ( "gorm.io/gorm" ) -const CurrentSchemaVersion int64 = 4 +const CurrentSchemaVersion int64 = 5 const baselineSchemaChecksum = "sha256:open-ai-canvas-schema-v1-20260830" const schemaMigrationAppliedAtIndexChecksum = "sha256:schema-migrations-applied-at-index-v2-20260830" const assetTaxonomyCandidateIdentityChecksum = "sha256:asset-taxonomy-candidate-identity-v3-20260831-r1" const resourceUploadKeyChecksum = "sha256:resource-upload-key-v4-20260901" +const paymentTopupChecksum = "sha256:payment-topup-v5-20260902" const postgresSchemaMigrationLockID int64 = 73123910420260830 @@ -46,6 +47,7 @@ var schemaMigrations = []migration{ {version: 2, name: "schema_migrations_applied_at_index", checksum: schemaMigrationAppliedAtIndexChecksum, apply: migrateSchemaV2}, {version: 3, name: "asset_taxonomy_candidate_identity", checksum: assetTaxonomyCandidateIdentityChecksum, apply: migrateSchemaV3}, {version: 4, name: "resource_upload_key", checksum: resourceUploadKeyChecksum, apply: migrateSchemaV4}, + {version: 5, name: "payment_topup", checksum: paymentTopupChecksum, apply: migrateSchemaV5}, } func migrateSchemaV2(tx *gorm.DB) error { @@ -106,6 +108,21 @@ func migrateSchemaV4(tx *gorm.DB) error { return nil } +func migrateSchemaV5(tx *gorm.DB) error { + if err := tx.AutoMigrate( + &model.CreditLedgerEntry{}, + &model.TopupProduct{}, + &model.PaymentProviderConfig{}, + &model.PaymentOrder{}, + &model.PaymentNotification{}, + &model.PaymentReconciliationRun{}, + &model.PaymentReconciliationItem{}, + ); err != nil { + return fmt.Errorf("创建积分支付与对账结构:%w", err) + } + return nil +} + func MigrateSchema(db *gorm.DB) error { return db.Transaction(func(tx *gorm.DB) error { if tx.Dialector.Name() == "postgres" { diff --git a/backend/internal/database/migrations_test.go b/backend/internal/database/migrations_test.go index 806df6995..e6dc7ec2d 100644 --- a/backend/internal/database/migrations_test.go +++ b/backend/internal/database/migrations_test.go @@ -120,7 +120,7 @@ func TestMigrateSchemaV4AddsResourceUploadKeyToExistingSchema(t *testing.T) { if err != nil { t.Fatal(err) } - if !status.Ready || status.Current != 4 { + if !status.Ready || status.Current != CurrentSchemaVersion { t.Fatalf("unexpected schema status: %#v", status) } diff --git a/backend/internal/database/schema.go b/backend/internal/database/schema.go index 96b0b120c..9e59fd87a 100644 --- a/backend/internal/database/schema.go +++ b/backend/internal/database/schema.go @@ -34,6 +34,12 @@ func Models() []any { &model.CreditAccount{}, &model.CreditLedgerEntry{}, &model.BillingOrder{}, + &model.TopupProduct{}, + &model.PaymentProviderConfig{}, + &model.PaymentOrder{}, + &model.PaymentNotification{}, + &model.PaymentReconciliationRun{}, + &model.PaymentReconciliationItem{}, &model.RedeemBatch{}, &model.RedeemCode{}, &model.AdminAuditEvent{}, diff --git a/backend/internal/handler/payment.go b/backend/internal/handler/payment.go new file mode 100644 index 000000000..3c633d09c --- /dev/null +++ b/backend/internal/handler/payment.go @@ -0,0 +1,358 @@ +package handler + +import ( + "errors" + "io" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + "time" + + "infinite-canvas/backend/internal/service" + + "github.com/gin-gonic/gin" +) + +const paymentNotificationMaxBytes = 1 << 20 + +var paymentOrderIDPattern = regexp.MustCompile(`^[a-f0-9]{32}$`) + +func RegisterPaymentRoutes(r *gin.RouterGroup, svc *service.Service) { + r.GET("/payments/providers", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + providers, err := svc.PaymentProviders(user) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"providers": providers}) + }) + r.GET("/payments/products", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + products, err := svc.TopupProducts(user) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"products": products}) + }) + r.POST("/payments/orders", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + if !enforceRateLimit(c, "payment-order:"+user.ID, 20, time.Hour) { + return + } + var request service.CreatePaymentOrderRequest + if err := c.ShouldBindJSON(&request); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + order, err := svc.CreatePaymentOrder(c.Request.Context(), user, request) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"order": order}) + }) + r.GET("/payments/orders/:id", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + order, err := svc.PaymentOrder(user, c.Param("id")) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"order": order}) + }) + r.POST("/payments/orders/:id/query", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + if !enforceRateLimit(c, "payment-query:"+user.ID+":"+c.Param("id"), 30, time.Minute) { + return + } + order, err := svc.QueryPaymentOrder(c.Request.Context(), user, c.Param("id")) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"order": order}) + }) + r.POST("/payments/orders/:id/close", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + if !enforceRateLimit(c, "payment-close:"+user.ID+":"+c.Param("id"), 10, time.Minute) { + return + } + order, err := svc.ClosePaymentOrder(c.Request.Context(), user, c.Param("id")) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"order": order}) + }) + r.GET("/payments/orders/:id/checkout", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + target, err := svc.PaymentCheckout(user, c.Param("id")) + if err != nil { + failService(c, err) + return + } + c.Redirect(http.StatusFound, target) + }) + r.POST("/payments/orders/:id/checkout/refresh", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + if !enforceRateLimit(c, "payment-checkout-refresh:"+user.ID+":"+c.Param("id"), 5, time.Hour) { + return + } + order, err := svc.RefreshPaymentCheckout(c.Request.Context(), user, c.Param("id")) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"order": order}) + }) + + // Provider callbacks are intentionally unauthenticated at the application + // layer. Authenticity is established by the pinned provider config and raw + // request signature before any durable event is accepted. + r.POST("/payments/notify/:providerId/:configId", func(c *gin.Context) { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, paymentNotificationMaxBytes) + rawBody, err := io.ReadAll(c.Request.Body) + if err != nil { + writePaymentNotificationFailure(c, c.Param("providerId"), http.StatusBadRequest) + return + } + err = svc.AcceptPaymentNotification(c.Request.Context(), c.Param("providerId"), c.Param("configId"), c.Request.Header.Clone(), rawBody) + if err != nil { + status := http.StatusInternalServerError + var appErr *service.AppError + if errors.As(err, &appErr) && appErr.Status < http.StatusInternalServerError { + status = http.StatusBadRequest + } + writePaymentNotificationFailure(c, c.Param("providerId"), status) + return + } + if c.Param("providerId") == service.PaymentProviderAlipay { + c.Data(http.StatusOK, "text/plain; charset=utf-8", []byte("success")) + return + } + c.Status(http.StatusNoContent) + }) + r.GET("/payments/return/alipay", func(c *gin.Context) { + orderID := strings.ToLower(strings.TrimSpace(c.Query("orderId"))) + if !paymentOrderIDPattern.MatchString(orderID) { + c.Redirect(http.StatusFound, "/wallet?payment=invalid") + return + } + c.Redirect(http.StatusFound, "/wallet?paymentOrder="+url.QueryEscape(orderID)) + }) + + admin := r.Group("/admin/payments") + admin.GET("/providers", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + providers, err := svc.AdminPaymentProviders(user) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"providers": providers}) + }) + admin.PUT("/providers/:id/config", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + var request service.UpdatePaymentProviderConfigRequest + if err := c.ShouldBindJSON(&request); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + provider, err := svc.UpdatePaymentProviderConfig(user, c.Param("id"), request) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"provider": provider}) + }) + admin.GET("/products", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + products, err := svc.AdminTopupProducts(user) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"products": products}) + }) + admin.POST("/products", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + var request service.TopupProductRequest + if err := c.ShouldBindJSON(&request); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + product, err := svc.CreateTopupProduct(user, request) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"product": product}) + }) + admin.PUT("/products/:id", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + var request service.TopupProductRequest + if err := c.ShouldBindJSON(&request); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + product, err := svc.UpdateTopupProduct(user, c.Param("id"), request) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"product": product}) + }) + admin.GET("/orders", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "30")) + result, err := svc.AdminPaymentOrderPage(user, c.Query("status"), c.Query("keyword"), page, limit) + if err != nil { + failService(c, err) + return + } + ok(c, result) + }) + admin.POST("/orders/:id/query", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + order, err := svc.AdminQueryPaymentOrder(c.Request.Context(), user, c.Param("id")) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"order": order}) + }) + admin.POST("/orders/:id/close", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + order, err := svc.AdminClosePaymentOrder(c.Request.Context(), user, c.Param("id")) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"order": order}) + }) + admin.POST("/reconciliations", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + var request service.RunPaymentReconciliationRequest + if err := c.ShouldBindJSON(&request); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + run, err := svc.RunPaymentReconciliation(c.Request.Context(), user, request) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"run": run}) + }) + admin.GET("/reconciliations", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "30")) + result, err := svc.AdminPaymentReconciliationPage(user, c.Query("providerId"), c.Query("status"), page, limit) + if err != nil { + failService(c, err) + return + } + ok(c, result) + }) + admin.GET("/reconciliations/:id/items", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50")) + result, err := svc.AdminPaymentReconciliationItems(user, c.Param("id"), c.Query("result"), page, limit) + if err != nil { + failService(c, err) + return + } + ok(c, result) + }) +} + +func writePaymentNotificationFailure(c *gin.Context, providerID string, status int) { + if providerID == service.PaymentProviderAlipay { + c.Data(status, "text/plain; charset=utf-8", []byte("failure")) + return + } + c.JSON(status, gin.H{"code": "FAIL", "message": http.StatusText(status)}) +} diff --git a/backend/internal/model/models.go b/backend/internal/model/models.go index 9e75a1527..bd09437ee 100644 --- a/backend/internal/model/models.go +++ b/backend/internal/model/models.go @@ -119,6 +119,7 @@ const ( CreditLedgerAdminAdjust CreditLedgerType = "admin_adjustment" CreditLedgerSignupBonus CreditLedgerType = "signup_bonus" CreditLedgerCheckinBonus CreditLedgerType = "checkin_bonus" + CreditLedgerPaymentTopup CreditLedgerType = "payment_topup" RedeemCodeUnused RedeemCodeStatus = "unused" RedeemCodeRedeemed RedeemCodeStatus = "redeemed" diff --git a/backend/internal/model/models_finance.go b/backend/internal/model/models_finance.go index 9c17e5405..e1911f7cc 100644 --- a/backend/internal/model/models_finance.go +++ b/backend/internal/model/models_finance.go @@ -21,6 +21,7 @@ type CreditLedgerEntry struct { AvailableAfterMicrocredits int64 `json:"availableAfterMicrocredits"` ReservedAfterMicrocredits int64 `json:"reservedAfterMicrocredits"` BillingOrderID string `json:"billingOrderId,omitempty" gorm:"index;size:36"` + PaymentOrderID string `json:"paymentOrderId,omitempty" gorm:"index;size:36"` RedeemCodeID string `json:"redeemCodeId,omitempty" gorm:"index;size:36"` ActorUserID string `json:"actorUserId,omitempty" gorm:"index;size:36"` Model string `json:"model,omitempty" gorm:"size:120;index"` diff --git a/backend/internal/model/models_payment.go b/backend/internal/model/models_payment.go new file mode 100644 index 000000000..c6898216e --- /dev/null +++ b/backend/internal/model/models_payment.go @@ -0,0 +1,148 @@ +package model + +import "time" + +type PaymentOrderStatus string +type PaymentNotificationStatus string +type PaymentReconciliationStatus string +type PaymentReconciliationResult string + +const ( + PaymentOrderCreated PaymentOrderStatus = "created" + PaymentOrderPending PaymentOrderStatus = "pending" + PaymentOrderClosing PaymentOrderStatus = "closing" + PaymentOrderClosed PaymentOrderStatus = "closed" + PaymentOrderCredited PaymentOrderStatus = "credited" + PaymentOrderCreateFailed PaymentOrderStatus = "create_failed" + + PaymentNotificationPending PaymentNotificationStatus = "pending" + PaymentNotificationProcessed PaymentNotificationStatus = "processed" + PaymentNotificationFailed PaymentNotificationStatus = "failed" + + PaymentReconciliationRunning PaymentReconciliationStatus = "running" + PaymentReconciliationCompleted PaymentReconciliationStatus = "completed" + PaymentReconciliationFailed PaymentReconciliationStatus = "failed" + + PaymentReconciliationMatched PaymentReconciliationResult = "matched" + PaymentReconciliationRecovered PaymentReconciliationResult = "recovered" + PaymentReconciliationLocalOrderNotFound PaymentReconciliationResult = "local_order_not_found" + PaymentReconciliationProviderRecordMissing PaymentReconciliationResult = "provider_record_missing" + PaymentReconciliationAmountMismatch PaymentReconciliationResult = "amount_mismatch" + PaymentReconciliationTradeNoMismatch PaymentReconciliationResult = "trade_no_mismatch" + PaymentReconciliationCreditFailed PaymentReconciliationResult = "credit_failed" +) + +// TopupProduct is the server-owned price and credit snapshot source. Clients +// select a product ID and can never submit their own payable amount or credits. +type TopupProduct struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + Name string `json:"name" gorm:"size:120"` + Description string `json:"description,omitempty" gorm:"size:500"` + AmountFen int64 `json:"amountFen" gorm:"index"` + CreditsMicrocredits int64 `json:"creditsMicrocredits"` + Enabled bool `json:"enabled" gorm:"index"` + SortOrder int `json:"sortOrder" gorm:"index"` + CreatedBy string `json:"createdBy" gorm:"index;size:36"` + UpdatedBy string `json:"updatedBy" gorm:"index;size:36"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// PaymentProviderConfig is immutable and versioned. Payment orders pin one +// version so key rotation cannot make an older callback unverifiable. +type PaymentProviderConfig struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + ProviderID string `json:"providerId" gorm:"size:80;index;uniqueIndex:idx_payment_provider_version,priority:1"` + PluginID string `json:"pluginId" gorm:"size:120;index"` + Version int64 `json:"version" gorm:"uniqueIndex:idx_payment_provider_version,priority:2"` + Enabled bool `json:"enabled" gorm:"index"` + CloseAfterMinutes int `json:"closeAfterMinutes"` + ConfigCipher string `json:"-" gorm:"type:text"` + ConfigDigest string `json:"configDigest" gorm:"size:64"` + CreatedBy string `json:"createdBy" gorm:"index;size:36"` + CreatedAt time.Time `json:"createdAt" gorm:"index"` +} + +type PaymentOrder struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"size:36;index;uniqueIndex:idx_payment_user_idempotency,priority:1"` + IdempotencyKey string `json:"idempotencyKey" gorm:"size:120;uniqueIndex:idx_payment_user_idempotency,priority:2"` + MerchantOrderNo string `json:"merchantOrderNo" gorm:"size:32;uniqueIndex"` + ProductID string `json:"productId" gorm:"size:36;index"` + ProductName string `json:"productName" gorm:"size:120"` + ProviderID string `json:"providerId" gorm:"size:80;index;uniqueIndex:idx_payment_provider_trade,priority:1"` + PluginID string `json:"pluginId" gorm:"size:120;index"` + ProviderConfigID string `json:"providerConfigId" gorm:"size:36;index"` + ProviderConfigVersion int64 `json:"providerConfigVersion"` + AmountFen int64 `json:"amountFen"` + Currency string `json:"currency" gorm:"size:8"` + CreditsMicrocredits int64 `json:"creditsMicrocredits"` + Status PaymentOrderStatus `json:"status" gorm:"size:24;index;index:idx_payment_order_status_expiry,priority:1"` + ProviderTradeNo *string `json:"providerTradeNo,omitempty" gorm:"size:96;uniqueIndex:idx_payment_provider_trade,priority:2"` + ProviderStatus string `json:"providerStatus,omitempty" gorm:"size:40;index"` + CheckoutMode string `json:"checkoutMode,omitempty" gorm:"size:24"` + CheckoutValue string `json:"-" gorm:"type:text"` + CheckoutExpiresAt *time.Time `json:"checkoutExpiresAt,omitempty"` + ExpiresAt time.Time `json:"expiresAt" gorm:"index:idx_payment_order_status_expiry,priority:2"` + ProviderPaidAt *time.Time `json:"providerPaidAt,omitempty" gorm:"index"` + CreditedAt *time.Time `json:"creditedAt,omitempty" gorm:"index"` + ClosedAt *time.Time `json:"closedAt,omitempty" gorm:"index"` + LastQueriedAt *time.Time `json:"lastQueriedAt,omitempty"` + LastError string `json:"lastError,omitempty" gorm:"size:1000"` + CreatedAt time.Time `json:"createdAt" gorm:"index"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// PaymentNotification is a durable, verified inbox. NormalizedJSON contains +// only the provider-neutral evidence required by the asynchronous credit job. +type PaymentNotification struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + ProviderID string `json:"providerId" gorm:"size:80;index;uniqueIndex:idx_payment_provider_event,priority:1"` + ProviderEventID string `json:"providerEventId" gorm:"size:160;uniqueIndex:idx_payment_provider_event,priority:2"` + ProviderConfigID string `json:"providerConfigId" gorm:"size:36;index"` + MerchantOrderNo string `json:"merchantOrderNo,omitempty" gorm:"size:32;index"` + PaymentOrderID string `json:"paymentOrderId,omitempty" gorm:"size:36;index"` + PayloadDigest string `json:"payloadDigest" gorm:"size:64"` + PayloadCipher string `json:"-" gorm:"type:text"` + NormalizedJSON string `json:"-" gorm:"type:text"` + Status PaymentNotificationStatus `json:"status" gorm:"size:24;index"` + Attempts int `json:"attempts"` + LastError string `json:"lastError,omitempty" gorm:"size:1000"` + NextAttemptAt time.Time `json:"nextAttemptAt" gorm:"index"` + ProcessedAt *time.Time `json:"processedAt,omitempty"` + CreatedAt time.Time `json:"createdAt" gorm:"index"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type PaymentReconciliationRun struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + ProviderID string `json:"providerId" gorm:"size:80;index;uniqueIndex:idx_payment_reconciliation_date,priority:1"` + ConfigID string `json:"configId" gorm:"size:36;index"` + BillDate string `json:"billDate" gorm:"size:10;index;uniqueIndex:idx_payment_reconciliation_date,priority:2"` + Status PaymentReconciliationStatus `json:"status" gorm:"size:24;index"` + TotalItems int `json:"totalItems"` + MatchItems int `json:"matchItems"` + RecoveredItems int `json:"recoveredItems"` + ErrorItems int `json:"errorItems"` + Error string `json:"error,omitempty" gorm:"size:1000"` + StartedBy string `json:"startedBy,omitempty" gorm:"size:36;index"` + StartedAt time.Time `json:"startedAt"` + CompletedAt *time.Time `json:"completedAt,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type PaymentReconciliationItem struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + RunID string `json:"runId" gorm:"size:36;index"` + ProviderID string `json:"providerId" gorm:"size:80;index"` + PaymentOrderID string `json:"paymentOrderId,omitempty" gorm:"size:36;index"` + MerchantOrderNo string `json:"merchantOrderNo" gorm:"size:32;index"` + ProviderTradeNo string `json:"providerTradeNo,omitempty" gorm:"size:96;index"` + AmountFen int64 `json:"amountFen"` + Currency string `json:"currency" gorm:"size:8"` + Result PaymentReconciliationResult `json:"result" gorm:"size:48;index"` + Resolved bool `json:"resolved" gorm:"index"` + Detail string `json:"detail,omitempty" gorm:"size:1000"` + CreatedAt time.Time `json:"createdAt"` +} diff --git a/backend/internal/payment/alipay.go b/backend/internal/payment/alipay.go new file mode 100644 index 000000000..fea316cba --- /dev/null +++ b/backend/internal/payment/alipay.go @@ -0,0 +1,406 @@ +package payment + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "strconv" + "strings" + "time" +) + +const defaultAlipayGateway = "https://openapi.alipay.com/gateway.do" + +type AlipayProvider struct { + client *http.Client + now func() time.Time +} + +func NewAlipayProvider(client *http.Client) *AlipayProvider { + if client == nil { + client = http.DefaultClient + } + return &AlipayProvider{client: client, now: time.Now} +} + +func (p *AlipayProvider) Descriptor() Descriptor { + return Descriptor{ID: ProviderAlipayPage, PluginID: PluginAlipayPage, Name: "支付宝电脑网站支付", Icon: "brand:alipay", CheckoutMode: "redirect"} +} + +func (p *AlipayProvider) ValidateConfig(config Config) error { + for _, key := range []string{"appId", "sellerId", "merchantPrivateKey", "alipayPublicKey"} { + if strings.TrimSpace(config[key]) == "" { + return fmt.Errorf("支付宝配置缺少 %s", key) + } + } + if _, err := parseRSAPrivateKey(config["merchantPrivateKey"]); err != nil { + return fmt.Errorf("支付宝应用私钥无效:%w", err) + } + if _, err := parseRSAPublicKey(config["alipayPublicKey"]); err != nil { + return fmt.Errorf("支付宝公钥无效:%w", err) + } + gateway := strings.TrimSpace(config["gateway"]) + if gateway == "" { + gateway = defaultAlipayGateway + } + parsed, err := url.Parse(gateway) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.Fragment != "" || parsed.RawQuery != "" || parsed.Path != "/gateway.do" || (parsed.Port() != "" && parsed.Port() != "443") { + return errors.New("支付宝网关必须是官方 HTTPS gateway.do 地址") + } + host := strings.ToLower(parsed.Hostname()) + if host != "openapi.alipay.com" && host != "openapi-sandbox.dl.alipaydev.com" { + return errors.New("支付宝网关仅支持官方生产或沙箱域名") + } + return nil +} + +func (p *AlipayProvider) CreateOrder(_ context.Context, config Config, request CreateRequest) (Checkout, error) { + if err := p.ValidateConfig(config); err != nil { + return Checkout{}, err + } + if request.AmountFen <= 0 || request.Currency != "CNY" || request.MerchantOrderNo == "" || request.NotifyURL == "" { + return Checkout{}, errors.New("支付宝电脑网站支付下单参数无效") + } + remaining := request.ExpiresAt.Sub(p.now()) + timeoutMinutes := int((remaining + time.Minute - 1) / time.Minute) + if remaining <= 0 || timeoutMinutes < 1 { + timeoutMinutes = 1 + } + bizContent, err := json.Marshal(map[string]any{ + "out_trade_no": request.MerchantOrderNo, + "total_amount": formatFen(request.AmountFen), + "subject": truncateUTF8(request.Description, 256), + "product_code": "FAST_INSTANT_TRADE_PAY", + "timeout_express": fmt.Sprintf("%dm", timeoutMinutes), + }) + if err != nil { + return Checkout{}, err + } + params := p.commonParams(config, "alipay.trade.page.pay") + params.Set("notify_url", request.NotifyURL) + if request.ReturnURL != "" { + params.Set("return_url", request.ReturnURL) + } + params.Set("biz_content", string(bizContent)) + if err := signAlipayParams(config, params); err != nil { + return Checkout{}, err + } + gateway := alipayGateway(config) + separator := "?" + if strings.Contains(gateway, "?") { + separator = "&" + } + return Checkout{Mode: "redirect", Value: gateway + separator + params.Encode(), ExpiresAt: request.ExpiresAt}, nil +} + +func (p *AlipayProvider) QueryOrder(ctx context.Context, config Config, request QueryRequest) (Result, error) { + if err := p.ValidateConfig(config); err != nil { + return Result{}, err + } + response, err := p.call(ctx, config, "alipay.trade.query", map[string]any{"out_trade_no": request.MerchantOrderNo}) + if err != nil { + return Result{}, err + } + return alipayResult(response), nil +} + +func (p *AlipayProvider) CloseOrder(ctx context.Context, config Config, request CloseRequest) (Result, error) { + if err := p.ValidateConfig(config); err != nil { + return Result{}, err + } + response, err := p.call(ctx, config, "alipay.trade.close", map[string]any{"out_trade_no": request.MerchantOrderNo}) + if err != nil { + return Result{}, err + } + result := alipayResult(response) + result.MerchantOrderNo = request.MerchantOrderNo + result.ProviderStatus = "CLOSED" + result.Closed = true + result.Currency = "CNY" + return result, nil +} + +func (p *AlipayProvider) VerifyNotification(_ context.Context, config Config, _ http.Header, rawBody []byte) (Notification, error) { + if err := p.ValidateConfig(config); err != nil { + return Notification{}, err + } + values, err := url.ParseQuery(string(rawBody)) + if err != nil { + return Notification{}, fmt.Errorf("解析支付宝异步通知:%w", err) + } + signature := values.Get("sign") + if signature == "" || values.Get("sign_type") != "RSA2" { + return Notification{}, errors.New("支付宝异步通知签名参数缺失") + } + verifyValues := cloneURLValues(values) + verifyValues.Del("sign") + verifyValues.Del("sign_type") + publicKey, err := parseRSAPublicKey(config["alipayPublicKey"]) + if err != nil { + return Notification{}, err + } + if err := rsaSHA256Verify(publicKey, []byte(canonicalAlipayParams(verifyValues)), signature); err != nil { + return Notification{}, err + } + status := values.Get("trade_status") + if values.Get("app_id") != config["appId"] || values.Get("seller_id") != config["sellerId"] || (status != "TRADE_SUCCESS" && status != "TRADE_FINISHED") { + return Notification{}, errors.New("支付宝异步通知商户身份或交易状态不匹配") + } + amount, err := parseYuanToFen(values.Get("total_amount")) + if err != nil { + return Notification{}, errors.New("支付宝异步通知金额无效") + } + paidAt, _ := time.ParseInLocation("2006-01-02 15:04:05", values.Get("gmt_payment"), time.FixedZone("CST", 8*60*60)) + eventID := strings.TrimSpace(values.Get("notify_id")) + if eventID == "" { + eventID = values.Get("trade_no") + ":" + status + ":" + values.Get("gmt_payment") + } + return Notification{EventID: eventID, Result: Result{ + MerchantOrderNo: values.Get("out_trade_no"), ProviderTradeNo: values.Get("trade_no"), ProviderStatus: status, + AmountFen: amount, Currency: "CNY", Paid: true, PaidAt: paidAt, + }}, nil +} + +func (p *AlipayProvider) DownloadTradeBill(ctx context.Context, config Config, billDate time.Time) ([]BillRecord, error) { + if err := p.ValidateConfig(config); err != nil { + return nil, err + } + var bill struct { + BillDownloadURL string `json:"bill_download_url"` + } + if err := p.callJSON(ctx, config, "alipay.data.dataservice.bill.downloadurl.query", map[string]any{ + "bill_type": "trade", "bill_date": billDate.Format("2006-01-02"), + }, &bill); err != nil { + return nil, err + } + alipayHTTPBillHosts := []string{"dwbillcenter.alipay.com", "dwbillcenter.alipaydev.com"} + target, err := trustedDownloadURLWithPolicy(bill.BillDownloadURL, alipayGateway(config), alipayHTTPBillHosts, "alipay.com", "alipayobjects.com", "alipaydev.com") + if err != nil { + return nil, err + } + request, err := http.NewRequestWithContext(ctx, http.MethodGet, target.String(), nil) + if err != nil { + return nil, err + } + response, err := clientWithTrustedRedirectPolicy(p.client, alipayGateway(config), alipayHTTPBillHosts, "alipay.com", "alipayobjects.com", "alipaydev.com").Do(request) + if err != nil { + return nil, &ProviderError{Code: "alipay_bill_download_error", Message: "下载支付宝交易账单失败", Temporary: true, Cause: err} + } + defer response.Body.Close() + if response.Request == nil { + return nil, errors.New("支付宝交易账单下载响应无效") + } + if _, err := trustedDownloadURLWithPolicy(response.Request.URL.String(), alipayGateway(config), alipayHTTPBillHosts, "alipay.com", "alipayobjects.com", "alipaydev.com"); err != nil { + return nil, err + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + return nil, &ProviderError{Code: "alipay_bill_http_error", Message: "支付宝交易账单下载失败", Temporary: response.StatusCode >= 500} + } + raw, err := io.ReadAll(io.LimitReader(response.Body, maxTradeBillBytes+1)) + if err != nil { + return nil, &ProviderError{Code: "alipay_bill_read_error", Message: "读取支付宝交易账单失败", Temporary: true, Cause: err} + } + if len(raw) > maxTradeBillBytes { + return nil, errors.New("支付宝交易账单超过安全限制") + } + return parseAlipayTradeBill(raw) +} + +type alipayTradeResponse struct { + Code string `json:"code"` + Msg string `json:"msg"` + SubCode string `json:"sub_code"` + SubMsg string `json:"sub_msg"` + OutTradeNo string `json:"out_trade_no"` + TradeNo string `json:"trade_no"` + TradeStatus string `json:"trade_status"` + TotalAmount string `json:"total_amount"` + SendPayDate string `json:"send_pay_date"` + ReceiptAmount string `json:"receipt_amount"` +} + +func alipayResult(response alipayTradeResponse) Result { + amount, _ := parseYuanToFen(response.TotalAmount) + paidAt, _ := time.ParseInLocation("2006-01-02 15:04:05", response.SendPayDate, time.FixedZone("CST", 8*60*60)) + paid := response.TradeStatus == "TRADE_SUCCESS" || response.TradeStatus == "TRADE_FINISHED" + return Result{ + MerchantOrderNo: response.OutTradeNo, ProviderTradeNo: response.TradeNo, ProviderStatus: response.TradeStatus, + AmountFen: amount, Currency: "CNY", Paid: paid, Closed: response.TradeStatus == "TRADE_CLOSED", PaidAt: paidAt, + } +} + +func (p *AlipayProvider) call(ctx context.Context, config Config, method string, biz any) (alipayTradeResponse, error) { + var tradeResponse alipayTradeResponse + if err := p.callJSON(ctx, config, method, biz, &tradeResponse); err != nil { + return alipayTradeResponse{}, err + } + return tradeResponse, nil +} + +func (p *AlipayProvider) callJSON(ctx context.Context, config Config, method string, biz any, output any) error { + bizContent, err := json.Marshal(biz) + if err != nil { + return err + } + params := p.commonParams(config, method) + params.Set("biz_content", string(bizContent)) + if err := signAlipayParams(config, params); err != nil { + return err + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, alipayGateway(config), strings.NewReader(params.Encode())) + if err != nil { + return err + } + request.Header.Set("Content-Type", "application/x-www-form-urlencoded;charset=UTF-8") + response, err := p.client.Do(request) + if err != nil { + return &ProviderError{Code: "alipay_transport_error", Message: "支付宝网络请求失败", Temporary: true, Cause: err} + } + defer response.Body.Close() + body, err := io.ReadAll(io.LimitReader(response.Body, 2<<20)) + if err != nil { + return &ProviderError{Code: "alipay_response_read_error", Message: "读取支付宝响应失败", Temporary: true, Cause: err} + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + return &ProviderError{Code: "alipay_http_error", Message: "支付宝接口返回失败", Temporary: response.StatusCode >= 500} + } + responseKey := strings.ReplaceAll(method, ".", "_") + "_response" + var envelope map[string]json.RawMessage + if err := json.Unmarshal(body, &envelope); err != nil { + return fmt.Errorf("解析支付宝响应:%w", err) + } + responseRaw := envelope[responseKey] + if len(responseRaw) == 0 { + return errors.New("支付宝响应缺少业务节点") + } + var signature string + if err := json.Unmarshal(envelope["sign"], &signature); err != nil || signature == "" { + return errors.New("支付宝响应缺少签名") + } + publicKey, err := parseRSAPublicKey(config["alipayPublicKey"]) + if err != nil { + return err + } + if err := rsaSHA256Verify(publicKey, responseRaw, signature); err != nil { + return fmt.Errorf("支付宝响应验签失败:%w", err) + } + var status struct { + Code string `json:"code"` + SubCode string `json:"sub_code"` + } + if err := json.Unmarshal(responseRaw, &status); err != nil { + return err + } + if status.Code != "10000" { + if status.SubCode == "ACQ.TRADE_NOT_EXIST" { + return fmt.Errorf("%w: %s", ErrOrderNotFound, status.SubCode) + } + if status.SubCode == "BILL_NOT_EXIST" { + return fmt.Errorf("%w: %s", ErrTradeBillNotFound, status.SubCode) + } + return &ProviderError{Code: status.SubCode, Message: "支付宝业务请求失败", Temporary: false} + } + if output == nil { + return nil + } + return json.Unmarshal(responseRaw, output) +} + +func (p *AlipayProvider) commonParams(config Config, method string) url.Values { + return url.Values{ + "app_id": {config["appId"]}, + "method": {method}, + "format": {"JSON"}, + "charset": {"utf-8"}, + "sign_type": {"RSA2"}, + "timestamp": {p.now().In(time.FixedZone("CST", 8*60*60)).Format("2006-01-02 15:04:05")}, + "version": {"1.0"}, + } +} + +func signAlipayParams(config Config, params url.Values) error { + privateKey, err := parseRSAPrivateKey(config["merchantPrivateKey"]) + if err != nil { + return err + } + signature, err := rsaSHA256Sign(privateKey, []byte(canonicalAlipayParams(params))) + if err != nil { + return err + } + params.Set("sign", signature) + return nil +} + +func canonicalAlipayParams(params url.Values) string { + keys := make([]string, 0, len(params)) + for key, values := range params { + if key != "sign" && len(values) > 0 && values[0] != "" { + keys = append(keys, key) + } + } + sort.Strings(keys) + parts := make([]string, 0, len(keys)) + for _, key := range keys { + parts = append(parts, key+"="+params.Get(key)) + } + return strings.Join(parts, "&") +} + +func cloneURLValues(input url.Values) url.Values { + result := make(url.Values, len(input)) + for key, values := range input { + result[key] = append([]string(nil), values...) + } + return result +} + +func alipayGateway(config Config) string { + if value := strings.TrimSpace(config["gateway"]); value != "" { + return value + } + return defaultAlipayGateway +} + +func formatFen(value int64) string { + return fmt.Sprintf("%d.%02d", value/100, value%100) +} + +func parseYuanToFen(value string) (int64, error) { + value = strings.TrimSpace(value) + if value == "" || strings.HasPrefix(value, "-") { + return 0, errors.New("invalid amount") + } + parts := strings.Split(value, ".") + if len(parts) > 2 { + return 0, errors.New("invalid amount") + } + yuan, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil || yuan > (1<<63-1)/100 { + return 0, errors.New("invalid amount") + } + fraction := "" + if len(parts) == 2 { + fraction = parts[1] + } + if len(fraction) > 2 { + return 0, errors.New("invalid amount precision") + } + fraction += strings.Repeat("0", 2-len(fraction)) + fen := int64(0) + if fraction != "" { + fen, err = strconv.ParseInt(fraction, 10, 64) + if err != nil { + return 0, errors.New("invalid amount") + } + } + const maxInt64 = int64(1<<63 - 1) + if yuan > (maxInt64-fen)/100 { + return 0, errors.New("invalid amount") + } + return yuan*100 + fen, nil +} diff --git a/backend/internal/payment/bill.go b/backend/internal/payment/bill.go new file mode 100644 index 000000000..c2c4b881d --- /dev/null +++ b/backend/internal/payment/bill.go @@ -0,0 +1,313 @@ +package payment + +import ( + "archive/zip" + "bytes" + "encoding/csv" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + "unicode/utf8" + + "golang.org/x/text/encoding/simplifiedchinese" +) + +const ( + maxTradeBillBytes = 64 << 20 + maxTradeBillRecords = 500_000 +) + +func parseWeChatTradeBill(raw []byte) ([]BillRecord, error) { + rows, err := readBillCSV(raw) + if err != nil { + return nil, fmt.Errorf("解析微信交易账单:%w", err) + } + headerIndex, columns := findBillHeader(rows, []string{"商户订单号", "微信订单号", "交易状态"}) + if headerIndex < 0 { + return nil, errors.New("微信交易账单缺少明细表头") + } + amountColumn := firstBillColumn(columns, "订单金额", "总金额", "应结订单金额") + if amountColumn < 0 { + return nil, errors.New("微信交易账单缺少订单金额") + } + records := make([]BillRecord, 0, len(rows)-headerIndex-1) + for _, row := range rows[headerIndex+1:] { + if len(records) >= maxTradeBillRecords { + return nil, errors.New("微信交易账单记录数超过安全限制") + } + status := billCell(row, columns["交易状态"]) + if status != "SUCCESS" { + continue + } + merchantOrderNo := billCell(row, columns["商户订单号"]) + providerTradeNo := billCell(row, columns["微信订单号"]) + if merchantOrderNo == "" || providerTradeNo == "" { + continue + } + amountFen, err := parseYuanToFen(billCell(row, amountColumn)) + if err != nil { + return nil, fmt.Errorf("微信订单 %s 金额无效", merchantOrderNo) + } + currency := "CNY" + if column := firstBillColumn(columns, "货币种类"); column >= 0 { + if value := billCell(row, column); value != "" { + currency = value + } + } + paidAt := parseBillTime(billCell(row, firstBillColumn(columns, "交易时间"))) + records = append(records, BillRecord{ + MerchantOrderNo: merchantOrderNo, ProviderTradeNo: providerTradeNo, + ProviderStatus: status, AmountFen: amountFen, Currency: currency, PaidAt: paidAt, + }) + } + return records, nil +} + +func parseAlipayTradeBill(raw []byte) ([]BillRecord, error) { + files, err := alipayBillFiles(raw) + if err != nil { + return nil, err + } + recordsByOrder := make(map[string]BillRecord) + foundHeader := false + for _, file := range files { + rows, err := readBillCSV(decodeAlipayBill(file)) + if err != nil { + continue + } + headerIndex, columns := findBillHeader(rows, []string{"商户订单号", "支付宝交易号"}) + if headerIndex < 0 { + continue + } + foundHeader = true + amountColumn := firstBillColumn(columns, "订单金额(元)", "订单金额(元)", "订单金额") + if amountColumn < 0 { + continue + } + businessColumn := firstBillColumn(columns, "业务类型", "交易状态") + paidAtColumn := firstBillColumn(columns, "完成时间", "交易付款时间", "交易时间") + for _, row := range rows[headerIndex+1:] { + if len(recordsByOrder) >= maxTradeBillRecords { + return nil, errors.New("支付宝交易账单记录数超过安全限制") + } + business := billCell(row, businessColumn) + if strings.Contains(business, "退款") || strings.Contains(business, "撤销") || strings.Contains(business, "关闭") { + continue + } + merchantOrderNo := billCell(row, columns["商户订单号"]) + providerTradeNo := billCell(row, columns["支付宝交易号"]) + if merchantOrderNo == "" || providerTradeNo == "" { + continue + } + amountFen, err := parseYuanToFen(billCell(row, amountColumn)) + if err != nil || amountFen <= 0 { + continue + } + recordsByOrder[merchantOrderNo] = BillRecord{ + MerchantOrderNo: merchantOrderNo, ProviderTradeNo: providerTradeNo, + ProviderStatus: "SUCCESS", AmountFen: amountFen, Currency: "CNY", + PaidAt: parseBillTime(billCell(row, paidAtColumn)), + } + } + } + if !foundHeader { + return nil, errors.New("支付宝交易账单缺少业务明细表头") + } + records := make([]BillRecord, 0, len(recordsByOrder)) + for _, record := range recordsByOrder { + records = append(records, record) + } + return records, nil +} + +func alipayBillFiles(raw []byte) ([][]byte, error) { + reader, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw))) + if err != nil { + // A few sandbox environments return an uncompressed CSV. Supporting it + // also makes the parser tolerant without weakening archive limits. + return [][]byte{raw}, nil + } + files := make([][]byte, 0, len(reader.File)) + var total int64 + for _, file := range reader.File { + if file.FileInfo().IsDir() || !strings.HasSuffix(strings.ToLower(file.Name), ".csv") { + continue + } + if file.UncompressedSize64 > maxTradeBillBytes || total+int64(file.UncompressedSize64) > maxTradeBillBytes { + return nil, errors.New("支付宝账单压缩包超过安全限制") + } + stream, err := file.Open() + if err != nil { + return nil, err + } + data, readErr := io.ReadAll(io.LimitReader(stream, maxTradeBillBytes+1)) + closeErr := stream.Close() + if readErr != nil { + return nil, readErr + } + if closeErr != nil { + return nil, closeErr + } + if len(data) > maxTradeBillBytes { + return nil, errors.New("支付宝账单文件超过安全限制") + } + total += int64(len(data)) + files = append(files, data) + } + if len(files) == 0 { + return nil, errors.New("支付宝账单压缩包不包含 CSV") + } + return files, nil +} + +func decodeAlipayBill(raw []byte) []byte { + if utf8.Valid(raw) { + return raw + } + decoded, err := simplifiedchinese.GBK.NewDecoder().Bytes(raw) + if err != nil { + return raw + } + return decoded +} + +func readBillCSV(raw []byte) ([][]string, error) { + reader := csv.NewReader(bytes.NewReader(raw)) + reader.FieldsPerRecord = -1 + reader.LazyQuotes = true + reader.TrimLeadingSpace = true + return reader.ReadAll() +} + +func findBillHeader(rows [][]string, required []string) (int, map[string]int) { + for rowIndex, row := range rows { + columns := make(map[string]int, len(row)) + for columnIndex, value := range row { + columns[normalizeBillCell(value)] = columnIndex + } + valid := true + for _, field := range required { + if _, ok := columns[field]; !ok { + valid = false + break + } + } + if valid { + return rowIndex, columns + } + } + return -1, nil +} + +func firstBillColumn(columns map[string]int, names ...string) int { + for _, name := range names { + if column, ok := columns[name]; ok { + return column + } + } + return -1 +} + +func billCell(row []string, column int) string { + if column < 0 || column >= len(row) { + return "" + } + return normalizeBillCell(row[column]) +} + +func normalizeBillCell(value string) string { + value = strings.TrimSpace(strings.TrimPrefix(value, "\ufeff")) + value = strings.TrimSpace(strings.TrimPrefix(value, "`")) + value = strings.TrimSpace(strings.TrimPrefix(value, "'")) + return value +} + +func parseBillTime(value string) time.Time { + location := time.FixedZone("CST", 8*60*60) + for _, layout := range []string{"2006-01-02 15:04:05", time.RFC3339} { + if parsed, err := time.ParseInLocation(layout, strings.TrimSpace(value), location); err == nil { + return parsed + } + } + return time.Time{} +} + +func trustedDownloadURL(raw, baseURL string, allowedSuffixes ...string) (*url.URL, error) { + return trustedDownloadURLWithPolicy(raw, baseURL, nil, allowedSuffixes...) +} + +func trustedDownloadURLWithPolicy(raw, baseURL string, allowedHTTPHosts []string, allowedSuffixes ...string) (*url.URL, error) { + target, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || target.Host == "" || target.User != nil || target.Fragment != "" { + return nil, errors.New("支付渠道返回了无效账单下载地址") + } + host := strings.ToLower(target.Hostname()) + base, _ := url.Parse(baseURL) + baseMatch := base != nil && strings.EqualFold(target.Host, base.Host) + suffixMatch := false + for _, suffix := range allowedSuffixes { + suffix = strings.ToLower(strings.TrimPrefix(strings.TrimSpace(suffix), ".")) + if host == suffix || strings.HasSuffix(host, "."+suffix) { + suffixMatch = true + break + } + } + if !baseMatch && !suffixMatch { + return nil, errors.New("支付渠道账单下载地址不在可信域名内") + } + if target.Scheme == "http" { + if baseMatch && base != nil && base.Scheme == "http" { + return target, nil + } + allowed := false + for _, allowedHost := range allowedHTTPHosts { + if strings.EqualFold(host, strings.TrimSpace(allowedHost)) { + allowed = true + break + } + } + if !allowed || (target.Port() != "" && target.Port() != "80") { + return nil, errors.New("支付渠道账单下载地址必须使用 HTTPS") + } + return target, nil + } + if target.Scheme != "https" { + return nil, errors.New("支付渠道账单下载地址必须使用 HTTPS") + } + if target.Port() != "" && target.Port() != "443" { + return nil, errors.New("支付渠道账单下载地址使用了非标准端口") + } + return target, nil +} + +// clientWithTrustedRedirects validates every redirect before it is followed. +// Validating only response.Request would be too late because an HTTP client may +// already have contacted an attacker-controlled or internal redirect target. +func clientWithTrustedRedirects(client *http.Client, baseURL string, allowedSuffixes ...string) *http.Client { + return clientWithTrustedRedirectPolicy(client, baseURL, nil, allowedSuffixes...) +} + +func clientWithTrustedRedirectPolicy(client *http.Client, baseURL string, allowedHTTPHosts []string, allowedSuffixes ...string) *http.Client { + if client == nil { + client = http.DefaultClient + } + copyClient := *client + previous := client.CheckRedirect + copyClient.CheckRedirect = func(request *http.Request, via []*http.Request) error { + if len(via) >= 10 { + return errors.New("支付渠道账单下载重定向过多") + } + if _, err := trustedDownloadURLWithPolicy(request.URL.String(), baseURL, allowedHTTPHosts, allowedSuffixes...); err != nil { + return err + } + if previous != nil { + return previous(request, via) + } + return nil + } + return ©Client +} diff --git a/backend/internal/payment/bill_test.go b/backend/internal/payment/bill_test.go new file mode 100644 index 000000000..8748e1ab7 --- /dev/null +++ b/backend/internal/payment/bill_test.go @@ -0,0 +1,76 @@ +package payment + +import ( + "archive/zip" + "bytes" + "net/http" + "net/url" + "testing" +) + +func TestParseWeChatSuccessTradeBill(t *testing.T) { + raw := []byte("`交易时间,`微信订单号,`商户订单号,`交易状态,`货币种类,`订单金额\n" + + "`2026-09-01 12:00:00,`420000001,`0123456789abcdef0123456789abcdef,`SUCCESS,`CNY,`1.23\n" + + "`总交易单数,`1\n") + records, err := parseWeChatTradeBill(raw) + if err != nil { + t.Fatal(err) + } + if len(records) != 1 || records[0].MerchantOrderNo != "0123456789abcdef0123456789abcdef" || records[0].ProviderTradeNo != "420000001" || records[0].AmountFen != 123 { + t.Fatalf("records = %#v", records) + } + if records[0].PaidAt.IsZero() { + t.Fatal("expected paid time") + } +} + +func TestParseAlipayTradeBillArchiveSkipsRefundRows(t *testing.T) { + var archive bytes.Buffer + writer := zip.NewWriter(&archive) + file, err := writer.Create("20260901_业务明细.csv") + if err != nil { + t.Fatal(err) + } + _, _ = file.Write([]byte("支付宝交易号,商户订单号,业务类型,完成时间,订单金额(元)\n" + + "202609010001,0123456789abcdef0123456789abcdef,交易,2026-09-01 12:00:00,2.50\n" + + "202609010001,0123456789abcdef0123456789abcdef,退款,2026-09-01 13:00:00,-2.50\n")) + if err := writer.Close(); err != nil { + t.Fatal(err) + } + records, err := parseAlipayTradeBill(archive.Bytes()) + if err != nil { + t.Fatal(err) + } + if len(records) != 1 || records[0].ProviderTradeNo != "202609010001" || records[0].AmountFen != 250 { + t.Fatalf("records = %#v", records) + } +} + +func TestTrustedDownloadURLRejectsUnrelatedHosts(t *testing.T) { + if _, err := trustedDownloadURL("https://evil.example/bill.csv", "https://openapi.alipay.com", "alipay.com"); err == nil { + t.Fatal("expected untrusted host error") + } + if _, err := trustedDownloadURL("https://dwbillcenter.alipay.com/bill.zip", "https://openapi.alipay.com", "alipay.com"); err != nil { + t.Fatal(err) + } + if _, err := trustedDownloadURLWithPolicy("http://dwbillcenter.alipay.com/bill.zip", "https://openapi.alipay.com", []string{"dwbillcenter.alipay.com"}, "alipay.com"); err != nil { + t.Fatal(err) + } + if _, err := trustedDownloadURLWithPolicy("http://other.alipay.com/bill.zip", "https://openapi.alipay.com", []string{"dwbillcenter.alipay.com"}, "alipay.com"); err == nil { + t.Fatal("expected non-bill-center HTTP URL error") + } + if _, err := trustedDownloadURL("https://api.mch.weixin.qq.com:8443/bill.csv", "https://api.mch.weixin.qq.com", "api.mch.weixin.qq.com"); err == nil { + t.Fatal("expected non-standard port error") + } +} + +func TestTrustedDownloadRedirectIsRejectedBeforeFollow(t *testing.T) { + client := clientWithTrustedRedirects(&http.Client{}, "https://openapi.alipay.com", "alipay.com") + target, err := url.Parse("https://internal.invalid/bill.zip") + if err != nil { + t.Fatal(err) + } + if err := client.CheckRedirect(&http.Request{URL: target}, nil); err == nil { + t.Fatal("expected untrusted redirect error") + } +} diff --git a/backend/internal/payment/crypto.go b/backend/internal/payment/crypto.go new file mode 100644 index 000000000..7bddc2238 --- /dev/null +++ b/backend/internal/payment/crypto.go @@ -0,0 +1,84 @@ +package payment + +import ( + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "errors" + "fmt" + "strings" +) + +func parseRSAPrivateKey(value string) (*rsa.PrivateKey, error) { + der, ok := decodePEMOrBase64(value) + if !ok { + return nil, errors.New("RSA 私钥 PEM 或 Base64 格式无效") + } + if key, err := x509.ParsePKCS8PrivateKey(der); err == nil { + if rsaKey, ok := key.(*rsa.PrivateKey); ok { + return rsaKey, nil + } + } + if key, err := x509.ParsePKCS1PrivateKey(der); err == nil { + return key, nil + } + return nil, errors.New("RSA 私钥必须为 PKCS#1 或 PKCS#8 格式") +} + +func parseRSAPublicKey(value string) (*rsa.PublicKey, error) { + der, ok := decodePEMOrBase64(value) + if !ok { + return nil, errors.New("RSA 公钥 PEM 或 Base64 格式无效") + } + if key, err := x509.ParsePKIXPublicKey(der); err == nil { + if rsaKey, ok := key.(*rsa.PublicKey); ok { + return rsaKey, nil + } + } + if key, err := x509.ParsePKCS1PublicKey(der); err == nil { + return key, nil + } + if certificate, err := x509.ParseCertificate(der); err == nil { + if rsaKey, ok := certificate.PublicKey.(*rsa.PublicKey); ok { + return rsaKey, nil + } + } + return nil, errors.New("RSA 公钥格式无效") +} + +func decodePEMOrBase64(value string) ([]byte, bool) { + normalized := strings.TrimSpace(value) + if block, _ := pem.Decode([]byte(normalized)); block != nil { + return block.Bytes, true + } + decoded, err := base64.StdEncoding.DecodeString(strings.Join(strings.Fields(normalized), "")) + if err != nil || len(decoded) == 0 { + return nil, false + } + return decoded, true +} + +func rsaSHA256Sign(privateKey *rsa.PrivateKey, content []byte) (string, error) { + digest := sha256.Sum256(content) + signature, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA256, digest[:]) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(signature), nil +} + +func rsaSHA256Verify(publicKey *rsa.PublicKey, content []byte, signature string) error { + decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(signature)) + if err != nil { + return fmt.Errorf("签名不是有效 Base64:%w", err) + } + digest := sha256.Sum256(content) + if err := rsa.VerifyPKCS1v15(publicKey, crypto.SHA256, digest[:], decoded); err != nil { + return errors.New("RSA2 签名验证失败") + } + return nil +} diff --git a/backend/internal/payment/provider_test.go b/backend/internal/payment/provider_test.go new file mode 100644 index 000000000..52d63cbdd --- /dev/null +++ b/backend/internal/payment/provider_test.go @@ -0,0 +1,289 @@ +package payment + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "strings" + "testing" + "time" +) + +func TestWeChatNativeCreateOrderSignsRequestAndReturnsQRCode(t *testing.T) { + merchantPrivate, merchantPublic := testRSAKeyPair(t) + platformPrivate, platformPublic := testRSAKeyPair(t) + fixedNow := time.Date(2026, 9, 2, 12, 0, 0, 0, time.FixedZone("CST", 8*60*60)) + config := testWeChatConfig(merchantPrivate, platformPublic) + + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodPost || request.URL.Path != "/v3/pay/transactions/native" { + t.Fatalf("request = %s %s", request.Method, request.URL.Path) + } + body, err := io.ReadAll(request.Body) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(request.Header.Get("Authorization"), `mchid="1900000001"`) { + t.Fatalf("authorization = %q", request.Header.Get("Authorization")) + } + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatal(err) + } + if payload["out_trade_no"] != "0123456789abcdef0123456789abcdef" { + t.Fatalf("out_trade_no = %v", payload["out_trade_no"]) + } + responseBody := []byte(`{"code_url":"weixin://wxpay/bizpayurl?pr=test"}`) + timestamp, nonce := fixedNow.Unix(), "response-nonce" + signature, err := rsaSHA256Sign(platformPrivate, []byte(testWeChatMessage(timestamp, nonce, responseBody))) + if err != nil { + t.Fatal(err) + } + writer.Header().Set("Wechatpay-Timestamp", "1788321600") + writer.Header().Set("Wechatpay-Nonce", nonce) + writer.Header().Set("Wechatpay-Serial", "PUB_KEY_ID_3000000001") + writer.Header().Set("Wechatpay-Signature", signature) + _, _ = writer.Write(responseBody) + })) + defer server.Close() + + provider := NewWeChatProvider(server.Client(), server.URL) + provider.now = func() time.Time { return fixedNow } + checkout, err := provider.CreateOrder(context.Background(), config, CreateRequest{ + MerchantOrderNo: "0123456789abcdef0123456789abcdef", Description: "100 积分", + AmountFen: 100, Currency: "CNY", ExpiresAt: fixedNow.Add(30 * time.Minute), NotifyURL: "https://merchant.example/api/notify", + }) + if err != nil { + t.Fatal(err) + } + if checkout.Mode != "qr_code" || !strings.HasPrefix(checkout.Value, "weixin://") || !checkout.ExpiresAt.Equal(fixedNow.Add(30*time.Minute)) { + t.Fatalf("checkout = %#v", checkout) + } + _ = merchantPublic +} + +func TestWeChatNotificationVerifiesAndDecrypts(t *testing.T) { + merchantPrivate, _ := testRSAKeyPair(t) + platformPrivate, platformPublic := testRSAKeyPair(t) + fixedNow := time.Date(2026, 9, 2, 12, 0, 0, 0, time.FixedZone("CST", 8*60*60)) + config := testWeChatConfig(merchantPrivate, platformPublic) + transaction := []byte(`{"appid":"wx-app","mchid":"1900000001","out_trade_no":"0123456789abcdef0123456789abcdef","transaction_id":"420000001","trade_type":"NATIVE","trade_state":"SUCCESS","success_time":"2026-09-02T11:59:00+08:00","amount":{"total":100,"currency":"CNY"}}`) + ciphertext := encryptWeChatTestResource(t, config["apiV3Key"], "0123456789ab", "transaction", transaction) + envelope, err := json.Marshal(map[string]any{ + "id": "EV-1", "event_type": "TRANSACTION.SUCCESS", "resource_type": "encrypt-resource", + "resource": map[string]any{"algorithm": "AEAD_AES_256_GCM", "ciphertext": ciphertext, "associated_data": "transaction", "nonce": "0123456789ab"}, + }) + if err != nil { + t.Fatal(err) + } + nonce := "notification-nonce" + signature, err := rsaSHA256Sign(platformPrivate, []byte(testWeChatMessage(fixedNow.Unix(), nonce, envelope))) + if err != nil { + t.Fatal(err) + } + headers := http.Header{ + "Wechatpay-Timestamp": {"1788321600"}, "Wechatpay-Nonce": {nonce}, + "Wechatpay-Serial": {"PUB_KEY_ID_3000000001"}, "Wechatpay-Signature": {signature}, + } + provider := NewWeChatProvider(http.DefaultClient, "https://example.invalid") + provider.now = func() time.Time { return fixedNow } + notification, err := provider.VerifyNotification(context.Background(), config, headers, envelope) + if err != nil { + t.Fatal(err) + } + if notification.EventID != "EV-1" || !notification.Paid || notification.AmountFen != 100 || notification.ProviderTradeNo != "420000001" { + t.Fatalf("notification = %#v", notification) + } +} + +func TestWeChatMissingTradeBillUsesBillSentinel(t *testing.T) { + merchantPrivate, _ := testRSAKeyPair(t) + platformPrivate, platformPublic := testRSAKeyPair(t) + fixedNow := time.Date(2026, 9, 2, 12, 0, 0, 0, time.FixedZone("CST", 8*60*60)) + config := testWeChatConfig(merchantPrivate, platformPublic) + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + body := []byte(`{"code":"NO_STATEMENT_EXIST","message":"not ready"}`) + nonce := "bill-response-nonce" + signature, err := rsaSHA256Sign(platformPrivate, []byte(testWeChatMessage(fixedNow.Unix(), nonce, body))) + if err != nil { + t.Fatal(err) + } + writer.Header().Set("Wechatpay-Timestamp", strconv.FormatInt(fixedNow.Unix(), 10)) + writer.Header().Set("Wechatpay-Nonce", nonce) + writer.Header().Set("Wechatpay-Serial", "PUB_KEY_ID_3000000001") + writer.Header().Set("Wechatpay-Signature", signature) + writer.WriteHeader(http.StatusNotFound) + _, _ = writer.Write(body) + })) + defer server.Close() + + provider := NewWeChatProvider(server.Client(), server.URL) + provider.now = func() time.Time { return fixedNow } + _, err := provider.DownloadTradeBill(context.Background(), config, fixedNow.AddDate(0, 0, -1)) + if !errors.Is(err, ErrTradeBillNotFound) { + t.Fatalf("error = %v", err) + } +} + +func TestAlipayPagePayBuildsSignedDesktopCheckout(t *testing.T) { + merchantPrivate, merchantPublic := testRSAKeyPair(t) + _, alipayPublic := testRSAKeyPair(t) + config := testAlipayConfig(merchantPrivate, alipayPublic) + provider := NewAlipayProvider(http.DefaultClient) + fixedNow := time.Date(2026, 9, 2, 12, 0, 0, 0, time.FixedZone("CST", 8*60*60)) + provider.now = func() time.Time { return fixedNow.Add(time.Second) } + checkout, err := provider.CreateOrder(context.Background(), config, CreateRequest{ + MerchantOrderNo: "0123456789abcdef0123456789abcdef", Description: "100 积分", + AmountFen: 100, Currency: "CNY", ExpiresAt: fixedNow.Add(30 * time.Minute), + NotifyURL: "https://merchant.example/api/notify", ReturnURL: "https://merchant.example/api/return", + }) + if err != nil { + t.Fatal(err) + } + parsed, err := url.Parse(checkout.Value) + if err != nil { + t.Fatal(err) + } + params := parsed.Query() + signature := params.Get("sign") + params.Del("sign") + if err := rsaSHA256Verify(merchantPublic, []byte(canonicalAlipayParams(params)), signature); err != nil { + t.Fatalf("checkout signature: %v", err) + } + if params.Get("method") != "alipay.trade.page.pay" || !strings.Contains(params.Get("biz_content"), `"product_code":"FAST_INSTANT_TRADE_PAY"`) || !strings.Contains(params.Get("biz_content"), `"timeout_express":"30m"`) { + t.Fatalf("params = %v", params) + } +} + +func TestAlipayRejectsNonOfficialGateway(t *testing.T) { + merchantPrivate, _ := testRSAKeyPair(t) + _, alipayPublic := testRSAKeyPair(t) + config := testAlipayConfig(merchantPrivate, alipayPublic) + config["gateway"] = "https://evil.example/gateway.do" + if err := NewAlipayProvider(http.DefaultClient).ValidateConfig(config); err == nil { + t.Fatal("expected non-official gateway error") + } +} + +func TestAlipayNotificationVerifiesMerchantAndAmount(t *testing.T) { + merchantPrivate, _ := testRSAKeyPair(t) + alipayPrivate, alipayPublic := testRSAKeyPair(t) + config := testAlipayConfig(merchantPrivate, alipayPublic) + values := url.Values{ + "notify_id": {"notify-1"}, "app_id": {"2026000000000001"}, "seller_id": {"2088000000000001"}, + "out_trade_no": {"0123456789abcdef0123456789abcdef"}, "trade_no": {"2026090200001"}, + "trade_status": {"TRADE_SUCCESS"}, "total_amount": {"1.00"}, "gmt_payment": {"2026-09-02 12:00:00"}, + "sign_type": {"RSA2"}, + } + unsigned := cloneURLValues(values) + unsigned.Del("sign_type") + signature, err := rsaSHA256Sign(alipayPrivate, []byte(canonicalAlipayParams(unsigned))) + if err != nil { + t.Fatal(err) + } + values.Set("sign", signature) + provider := NewAlipayProvider(http.DefaultClient) + notification, err := provider.VerifyNotification(context.Background(), config, nil, []byte(values.Encode())) + if err != nil { + t.Fatal(err) + } + if notification.EventID != "notify-1" || !notification.Paid || notification.AmountFen != 100 || notification.Currency != "CNY" { + t.Fatalf("notification = %#v", notification) + } +} + +func TestParseYuanToFenUsesExactDecimalArithmetic(t *testing.T) { + for value, expected := range map[string]int64{"0.01": 1, "1": 100, "1.2": 120, "1000000.99": 100000099} { + actual, err := parseYuanToFen(value) + if err != nil || actual != expected { + t.Fatalf("parseYuanToFen(%q) = %d, %v; want %d", value, actual, err, expected) + } + } + if _, err := parseYuanToFen("1.001"); err == nil { + t.Fatal("expected precision error") + } + if _, err := parseYuanToFen("92233720368547758.08"); err == nil { + t.Fatal("expected overflow error") + } +} + +func TestRSAKeyParsersAcceptRawBase64(t *testing.T) { + privateKey, publicKey := testRSAKeyPair(t) + privateDER, err := x509.MarshalPKCS8PrivateKey(privateKey) + if err != nil { + t.Fatal(err) + } + publicDER, err := x509.MarshalPKIXPublicKey(publicKey) + if err != nil { + t.Fatal(err) + } + if _, err := parseRSAPrivateKey(base64.StdEncoding.EncodeToString(privateDER)); err != nil { + t.Fatal(err) + } + if _, err := parseRSAPublicKey(base64.StdEncoding.EncodeToString(publicDER)); err != nil { + t.Fatal(err) + } +} + +func testRSAKeyPair(t *testing.T) (*rsa.PrivateKey, *rsa.PublicKey) { + t.Helper() + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + return privateKey, &privateKey.PublicKey +} + +func testPrivatePEM(key *rsa.PrivateKey) string { + encoded, _ := x509.MarshalPKCS8PrivateKey(key) + return string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: encoded})) +} + +func testPublicPEM(key *rsa.PublicKey) string { + encoded, _ := x509.MarshalPKIXPublicKey(key) + return string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: encoded})) +} + +func testWeChatConfig(merchantPrivate *rsa.PrivateKey, platformPublic *rsa.PublicKey) Config { + return Config{ + "appId": "wx-app", "mchId": "1900000001", "merchantSerialNo": "MERCHANT-SERIAL", + "merchantPrivateKey": testPrivatePEM(merchantPrivate), "apiV3Key": "0123456789abcdef0123456789abcdef", + "wechatPayPublicKeyId": "PUB_KEY_ID_3000000001", "wechatPayPublicKey": testPublicPEM(platformPublic), + } +} + +func testAlipayConfig(merchantPrivate *rsa.PrivateKey, alipayPublic *rsa.PublicKey) Config { + return Config{ + "appId": "2026000000000001", "sellerId": "2088000000000001", "merchantPrivateKey": testPrivatePEM(merchantPrivate), + "alipayPublicKey": testPublicPEM(alipayPublic), "gateway": "https://openapi.alipay.com/gateway.do", + } +} + +func testWeChatMessage(timestamp int64, nonce string, body []byte) string { + return strconv.FormatInt(timestamp, 10) + "\n" + nonce + "\n" + string(body) + "\n" +} + +func encryptWeChatTestResource(t *testing.T, key, nonce, associatedData string, plaintext []byte) string { + t.Helper() + block, err := aes.NewCipher([]byte(key)) + if err != nil { + t.Fatal(err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + t.Fatal(err) + } + return base64.StdEncoding.EncodeToString(gcm.Seal(nil, []byte(nonce), plaintext, []byte(associatedData))) +} diff --git a/backend/internal/payment/registry.go b/backend/internal/payment/registry.go new file mode 100644 index 000000000..c8dc9c312 --- /dev/null +++ b/backend/internal/payment/registry.go @@ -0,0 +1,63 @@ +package payment + +import ( + "fmt" + "net/http" + "sort" + "strings" + "time" +) + +type Registry struct { + providers map[string]Provider +} + +func NewRegistry(providers ...Provider) (*Registry, error) { + registry := &Registry{providers: make(map[string]Provider, len(providers))} + for _, provider := range providers { + if provider == nil { + return nil, fmt.Errorf("payment provider is nil") + } + id := strings.TrimSpace(provider.Descriptor().ID) + if id == "" { + return nil, fmt.Errorf("payment provider id is empty") + } + if _, exists := registry.providers[id]; exists { + return nil, fmt.Errorf("duplicate payment provider %q", id) + } + registry.providers[id] = provider + } + return registry, nil +} + +func Builtins() *Registry { + client := &http.Client{Timeout: 60 * time.Second} + registry, err := NewRegistry( + NewWeChatProvider(client, "https://api.mch.weixin.qq.com"), + NewAlipayProvider(client), + ) + if err != nil { + panic(err) + } + return registry +} + +func (r *Registry) Get(id string) (Provider, bool) { + if r == nil { + return nil, false + } + provider, ok := r.providers[strings.TrimSpace(id)] + return provider, ok +} + +func (r *Registry) Descriptors() []Descriptor { + if r == nil { + return []Descriptor{} + } + items := make([]Descriptor, 0, len(r.providers)) + for _, provider := range r.providers { + items = append(items, provider.Descriptor()) + } + sort.Slice(items, func(i, j int) bool { return items[i].ID < items[j].ID }) + return items +} diff --git a/backend/internal/payment/types.go b/backend/internal/payment/types.go new file mode 100644 index 000000000..3f5aaf191 --- /dev/null +++ b/backend/internal/payment/types.go @@ -0,0 +1,110 @@ +package payment + +import ( + "context" + "errors" + "net/http" + "time" +) + +const ( + ProviderWeChatNative = "wechat-native" + ProviderAlipayPage = "alipay-page-pay" + + PluginWeChatNative = "official-payment-wechat-native" + PluginAlipayPage = "official-payment-alipay-page" +) + +var ( + ErrOrderNotFound = errors.New("payment provider order not found") + ErrOrderNotPaid = errors.New("payment provider order is not paid") + ErrTradeBillNotFound = errors.New("payment provider trade bill not found") +) + +type Config map[string]string + +type Descriptor struct { + ID string `json:"id"` + PluginID string `json:"pluginId"` + Name string `json:"name"` + Icon string `json:"icon"` + CheckoutMode string `json:"checkoutMode"` +} + +type CreateRequest struct { + MerchantOrderNo string + Description string + AmountFen int64 + Currency string + ExpiresAt time.Time + NotifyURL string + ReturnURL string +} + +type Checkout struct { + Mode string + Value string + ExpiresAt time.Time +} + +type QueryRequest struct { + MerchantOrderNo string +} + +type CloseRequest struct { + MerchantOrderNo string +} + +type Result struct { + MerchantOrderNo string `json:"merchantOrderNo"` + ProviderTradeNo string `json:"providerTradeNo,omitempty"` + ProviderStatus string `json:"providerStatus"` + AmountFen int64 `json:"amountFen"` + Currency string `json:"currency"` + Paid bool `json:"paid"` + Closed bool `json:"closed"` + PaidAt time.Time `json:"paidAt,omitempty"` +} + +type Notification struct { + EventID string `json:"eventId"` + Result +} + +type BillRecord struct { + MerchantOrderNo string + ProviderTradeNo string + ProviderStatus string + AmountFen int64 + Currency string + PaidAt time.Time +} + +type Provider interface { + Descriptor() Descriptor + ValidateConfig(Config) error + CreateOrder(context.Context, Config, CreateRequest) (Checkout, error) + QueryOrder(context.Context, Config, QueryRequest) (Result, error) + CloseOrder(context.Context, Config, CloseRequest) (Result, error) + VerifyNotification(context.Context, Config, http.Header, []byte) (Notification, error) + DownloadTradeBill(context.Context, Config, time.Time) ([]BillRecord, error) +} + +type ProviderError struct { + Code string + Message string + Temporary bool + Cause error +} + +func (e *ProviderError) Error() string { + if e == nil { + return "" + } + if e.Message != "" { + return e.Message + } + return e.Code +} + +func (e *ProviderError) Unwrap() error { return e.Cause } diff --git a/backend/internal/payment/wechat.go b/backend/internal/payment/wechat.go new file mode 100644 index 000000000..0f0907edf --- /dev/null +++ b/backend/internal/payment/wechat.go @@ -0,0 +1,369 @@ +package payment + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha1" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +const wechatCheckoutLifetime = 2 * time.Hour + +type WeChatProvider struct { + client *http.Client + baseURL string + now func() time.Time +} + +func NewWeChatProvider(client *http.Client, baseURL string) *WeChatProvider { + if client == nil { + client = http.DefaultClient + } + return &WeChatProvider{client: client, baseURL: strings.TrimRight(baseURL, "/"), now: time.Now} +} + +func (p *WeChatProvider) Descriptor() Descriptor { + return Descriptor{ID: ProviderWeChatNative, PluginID: PluginWeChatNative, Name: "微信支付 Native", Icon: "brand:wechat-pay", CheckoutMode: "qr_code"} +} + +func (p *WeChatProvider) ValidateConfig(config Config) error { + for _, key := range []string{"appId", "mchId", "merchantSerialNo", "merchantPrivateKey", "apiV3Key", "wechatPayPublicKeyId", "wechatPayPublicKey"} { + if strings.TrimSpace(config[key]) == "" { + return fmt.Errorf("微信支付配置缺少 %s", key) + } + } + if len([]byte(config["apiV3Key"])) != 32 { + return errors.New("微信支付 APIv3 密钥必须为 32 字节") + } + if _, err := parseRSAPrivateKey(config["merchantPrivateKey"]); err != nil { + return fmt.Errorf("微信支付商户私钥无效:%w", err) + } + if _, err := parseRSAPublicKey(config["wechatPayPublicKey"]); err != nil { + return fmt.Errorf("微信支付公钥无效:%w", err) + } + return nil +} + +func (p *WeChatProvider) CreateOrder(ctx context.Context, config Config, request CreateRequest) (Checkout, error) { + if err := p.ValidateConfig(config); err != nil { + return Checkout{}, err + } + if request.AmountFen <= 0 || request.Currency != "CNY" || request.MerchantOrderNo == "" || request.NotifyURL == "" { + return Checkout{}, errors.New("微信 Native 下单参数无效") + } + payload := map[string]any{ + "appid": config["appId"], + "mchid": config["mchId"], + "description": truncateUTF8(request.Description, 127), + "out_trade_no": request.MerchantOrderNo, + "time_expire": request.ExpiresAt.Format(time.RFC3339), + "notify_url": request.NotifyURL, + "amount": map[string]any{"total": request.AmountFen, "currency": request.Currency}, + } + var response struct { + CodeURL string `json:"code_url"` + } + if err := p.do(ctx, config, http.MethodPost, "/v3/pay/transactions/native", payload, &response); err != nil { + return Checkout{}, err + } + if strings.TrimSpace(response.CodeURL) == "" { + return Checkout{}, errors.New("微信 Native 下单未返回 code_url") + } + expiresAt := p.now().Add(wechatCheckoutLifetime) + if request.ExpiresAt.Before(expiresAt) { + expiresAt = request.ExpiresAt + } + return Checkout{Mode: "qr_code", Value: response.CodeURL, ExpiresAt: expiresAt}, nil +} + +func (p *WeChatProvider) QueryOrder(ctx context.Context, config Config, request QueryRequest) (Result, error) { + if err := p.ValidateConfig(config); err != nil { + return Result{}, err + } + path := "/v3/pay/transactions/out-trade-no/" + url.PathEscape(request.MerchantOrderNo) + "?mchid=" + url.QueryEscape(config["mchId"]) + var response wechatTransaction + if err := p.do(ctx, config, http.MethodGet, path, nil, &response); err != nil { + return Result{}, err + } + if response.AppID != config["appId"] || response.MchID != config["mchId"] || response.OutTradeNo != request.MerchantOrderNo || (response.TradeType != "" && response.TradeType != "NATIVE") { + return Result{}, errors.New("微信支付查单结果与商户订单不匹配") + } + return wechatResult(response), nil +} + +func (p *WeChatProvider) CloseOrder(ctx context.Context, config Config, request CloseRequest) (Result, error) { + if err := p.ValidateConfig(config); err != nil { + return Result{}, err + } + path := "/v3/pay/transactions/out-trade-no/" + url.PathEscape(request.MerchantOrderNo) + "/close" + if err := p.do(ctx, config, http.MethodPost, path, map[string]string{"mchid": config["mchId"]}, nil); err != nil { + return Result{}, err + } + return Result{MerchantOrderNo: request.MerchantOrderNo, ProviderStatus: "CLOSED", Currency: "CNY", Closed: true}, nil +} + +func (p *WeChatProvider) VerifyNotification(_ context.Context, config Config, headers http.Header, rawBody []byte) (Notification, error) { + if err := p.ValidateConfig(config); err != nil { + return Notification{}, err + } + if err := p.verifyMessage(config, headers, rawBody); err != nil { + return Notification{}, err + } + var envelope struct { + ID string `json:"id"` + EventType string `json:"event_type"` + ResourceType string `json:"resource_type"` + Resource struct { + Algorithm string `json:"algorithm"` + Ciphertext string `json:"ciphertext"` + AssociatedData string `json:"associated_data"` + Nonce string `json:"nonce"` + } `json:"resource"` + } + if err := json.Unmarshal(rawBody, &envelope); err != nil { + return Notification{}, fmt.Errorf("解析微信支付通知:%w", err) + } + if envelope.ID == "" || envelope.EventType != "TRANSACTION.SUCCESS" || envelope.ResourceType != "encrypt-resource" || envelope.Resource.Algorithm != "AEAD_AES_256_GCM" { + return Notification{}, errors.New("微信支付通知类型无效") + } + plaintext, err := decryptWeChatResource(config["apiV3Key"], envelope.Resource.Nonce, envelope.Resource.AssociatedData, envelope.Resource.Ciphertext) + if err != nil { + return Notification{}, err + } + var transaction wechatTransaction + if err := json.Unmarshal(plaintext, &transaction); err != nil { + return Notification{}, fmt.Errorf("解析微信支付通知资源:%w", err) + } + if transaction.AppID != config["appId"] || transaction.MchID != config["mchId"] || transaction.TradeType != "NATIVE" || transaction.TradeState != "SUCCESS" { + return Notification{}, errors.New("微信支付通知商户身份或交易状态不匹配") + } + return Notification{EventID: envelope.ID, Result: wechatResult(transaction)}, nil +} + +func (p *WeChatProvider) DownloadTradeBill(ctx context.Context, config Config, billDate time.Time) ([]BillRecord, error) { + if err := p.ValidateConfig(config); err != nil { + return nil, err + } + path := "/v3/bill/tradebill?bill_date=" + url.QueryEscape(billDate.Format("2006-01-02")) + "&bill_type=SUCCESS" + var bill struct { + HashType string `json:"hash_type"` + HashValue string `json:"hash_value"` + DownloadURL string `json:"download_url"` + } + if err := p.do(ctx, config, http.MethodGet, path, nil, &bill); err != nil { + return nil, err + } + if !strings.EqualFold(bill.HashType, "SHA1") || strings.TrimSpace(bill.HashValue) == "" { + return nil, errors.New("微信交易账单摘要信息无效") + } + target, err := trustedDownloadURL(bill.DownloadURL, p.baseURL, "api.mch.weixin.qq.com", "api2.mch.weixin.qq.com") + if err != nil { + return nil, err + } + request, err := http.NewRequestWithContext(ctx, http.MethodGet, target.String(), nil) + if err != nil { + return nil, err + } + request.Header.Set("Accept", "application/octet-stream") + request.Header.Set("Wechatpay-Serial", config["wechatPayPublicKeyId"]) + request.Header.Set("Authorization", p.authorization(config, http.MethodGet, target.RequestURI(), nil)) + response, err := clientWithTrustedRedirects(p.client, p.baseURL, "api.mch.weixin.qq.com", "api2.mch.weixin.qq.com").Do(request) + if err != nil { + return nil, &ProviderError{Code: "wechat_bill_download_error", Message: "下载微信交易账单失败", Temporary: true, Cause: err} + } + defer response.Body.Close() + if response.Request == nil { + return nil, errors.New("微信交易账单下载响应无效") + } + if _, err := trustedDownloadURL(response.Request.URL.String(), p.baseURL, "api.mch.weixin.qq.com", "api2.mch.weixin.qq.com"); err != nil { + return nil, err + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + return nil, &ProviderError{Code: "wechat_bill_http_error", Message: "微信交易账单下载失败", Temporary: response.StatusCode >= 500} + } + raw, err := io.ReadAll(io.LimitReader(response.Body, maxTradeBillBytes+1)) + if err != nil { + return nil, &ProviderError{Code: "wechat_bill_read_error", Message: "读取微信交易账单失败", Temporary: true, Cause: err} + } + if len(raw) > maxTradeBillBytes { + return nil, errors.New("微信交易账单超过安全限制") + } + digest := sha1.Sum(raw) + if !strings.EqualFold(hex.EncodeToString(digest[:]), strings.TrimSpace(bill.HashValue)) { + return nil, errors.New("微信交易账单摘要校验失败") + } + return parseWeChatTradeBill(raw) +} + +type wechatTransaction struct { + AppID string `json:"appid"` + MchID string `json:"mchid"` + OutTradeNo string `json:"out_trade_no"` + TransactionID string `json:"transaction_id"` + TradeType string `json:"trade_type"` + TradeState string `json:"trade_state"` + TradeStateDesc string `json:"trade_state_desc"` + SuccessTime string `json:"success_time"` + Amount struct { + Total int64 `json:"total"` + Currency string `json:"currency"` + } `json:"amount"` +} + +func wechatResult(transaction wechatTransaction) Result { + paidAt, _ := time.Parse(time.RFC3339, transaction.SuccessTime) + return Result{ + MerchantOrderNo: transaction.OutTradeNo, + ProviderTradeNo: transaction.TransactionID, + ProviderStatus: transaction.TradeState, + AmountFen: transaction.Amount.Total, + Currency: transaction.Amount.Currency, + Paid: transaction.TradeState == "SUCCESS", + Closed: transaction.TradeState == "CLOSED", + PaidAt: paidAt, + } +} + +func (p *WeChatProvider) do(ctx context.Context, config Config, method, path string, payload any, output any) error { + var body []byte + var err error + if payload != nil { + body, err = json.Marshal(payload) + if err != nil { + return err + } + } + request, err := http.NewRequestWithContext(ctx, method, p.baseURL+path, strings.NewReader(string(body))) + if err != nil { + return err + } + if payload != nil { + request.Header.Set("Content-Type", "application/json") + } + request.Header.Set("Accept", "application/json") + request.Header.Set("Wechatpay-Serial", config["wechatPayPublicKeyId"]) + request.Header.Set("Authorization", p.authorization(config, method, request.URL.RequestURI(), body)) + response, err := p.client.Do(request) + if err != nil { + return &ProviderError{Code: "wechat_transport_error", Message: "微信支付网络请求失败", Temporary: true, Cause: err} + } + defer response.Body.Close() + responseBody, err := io.ReadAll(io.LimitReader(response.Body, 2<<20)) + if err != nil { + return &ProviderError{Code: "wechat_response_read_error", Message: "读取微信支付响应失败", Temporary: true, Cause: err} + } + if err := p.verifyMessage(config, response.Header, responseBody); err != nil { + return fmt.Errorf("微信支付响应验签失败:%w", err) + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + var detail struct { + Code string `json:"code"` + Message string `json:"message"` + } + _ = json.Unmarshal(responseBody, &detail) + if detail.Code == "NO_STATEMENT_EXIST" { + return fmt.Errorf("%w: %s", ErrTradeBillNotFound, detail.Code) + } + if response.StatusCode == http.StatusNotFound { + return fmt.Errorf("%w: %s", ErrOrderNotFound, detail.Code) + } + return &ProviderError{Code: detail.Code, Message: "微信支付接口返回失败", Temporary: response.StatusCode >= 500} + } + if output == nil || len(responseBody) == 0 { + return nil + } + if err := json.Unmarshal(responseBody, output); err != nil { + return fmt.Errorf("解析微信支付响应:%w", err) + } + return nil +} + +func (p *WeChatProvider) authorization(config Config, method, target string, body []byte) string { + timestamp := strconv.FormatInt(p.now().Unix(), 10) + nonce := randomHex(16) + message := method + "\n" + target + "\n" + timestamp + "\n" + nonce + "\n" + string(body) + "\n" + privateKey, _ := parseRSAPrivateKey(config["merchantPrivateKey"]) + signature, _ := rsaSHA256Sign(privateKey, []byte(message)) + return fmt.Sprintf(`WECHATPAY2-SHA256-RSA2048 mchid="%s",nonce_str="%s",signature="%s",timestamp="%s",serial_no="%s"`, config["mchId"], nonce, signature, timestamp, config["merchantSerialNo"]) +} + +func (p *WeChatProvider) verifyMessage(config Config, headers http.Header, body []byte) error { + timestamp := strings.TrimSpace(headers.Get("Wechatpay-Timestamp")) + nonce := headers.Get("Wechatpay-Nonce") + signature := headers.Get("Wechatpay-Signature") + serial := headers.Get("Wechatpay-Serial") + if timestamp == "" || nonce == "" || signature == "" || serial == "" { + return errors.New("微信支付签名请求头不完整") + } + if serial != config["wechatPayPublicKeyId"] { + return errors.New("微信支付公钥 ID 不匹配") + } + seconds, err := strconv.ParseInt(timestamp, 10, 64) + if err != nil || absDuration(p.now().Sub(time.Unix(seconds, 0))) > 5*time.Minute { + return errors.New("微信支付签名时间戳无效") + } + publicKey, err := parseRSAPublicKey(config["wechatPayPublicKey"]) + if err != nil { + return err + } + message := timestamp + "\n" + nonce + "\n" + string(body) + "\n" + return rsaSHA256Verify(publicKey, []byte(message), signature) +} + +func decryptWeChatResource(apiV3Key, nonce, associatedData, ciphertext string) ([]byte, error) { + block, err := aes.NewCipher([]byte(apiV3Key)) + if err != nil { + return nil, fmt.Errorf("创建微信支付解密器:%w", err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + decoded, err := base64.StdEncoding.DecodeString(ciphertext) + if err != nil { + return nil, fmt.Errorf("微信支付通知密文无效:%w", err) + } + plaintext, err := gcm.Open(nil, []byte(nonce), decoded, []byte(associatedData)) + if err != nil { + return nil, errors.New("微信支付通知解密失败") + } + return plaintext, nil +} + +func randomHex(size int) string { + value := make([]byte, size) + if _, err := rand.Read(value); err != nil { + sum := sha256.Sum256([]byte(strconv.FormatInt(time.Now().UnixNano(), 10))) + return hex.EncodeToString(sum[:size]) + } + return hex.EncodeToString(value) +} + +func absDuration(value time.Duration) time.Duration { + if value < 0 { + return -value + } + return value +} + +func truncateUTF8(value string, limit int) string { + value = strings.TrimSpace(value) + for len([]byte(value)) > limit { + runes := []rune(value) + value = string(runes[:len(runes)-1]) + } + return value +} diff --git a/backend/internal/protocol/manifest.go b/backend/internal/protocol/manifest.go index f0e5906c5..a99b8c02d 100644 --- a/backend/internal/protocol/manifest.go +++ b/backend/internal/protocol/manifest.go @@ -258,7 +258,7 @@ func ValidateManifest(manifest Manifest) error { return fmt.Errorf("plugin must declare at least one contribution") } if len(manifest.Contributes.Providers) == 0 { - return nil + return validatePaymentProviderContributions(manifest) } providerIDs := make(map[string]struct{}, len(manifest.Contributes.Providers)) for index, provider := range manifest.Contributes.Providers { @@ -316,6 +316,33 @@ func ValidateManifest(manifest Manifest) error { } } } + return validatePaymentProviderContributions(manifest) +} + +func validatePaymentProviderContributions(manifest Manifest) error { + seen := make(map[string]struct{}, len(manifest.Contributes.PaymentProviders)) + for index, provider := range manifest.Contributes.PaymentProviders { + provider.ID = strings.TrimSpace(provider.ID) + if provider.ID == "" || strings.TrimSpace(provider.Label) == "" || !validManifestIdentifier(provider.ID) { + return fmt.Errorf("payment provider contribution %d requires a valid id and label", index) + } + if _, exists := seen[provider.ID]; exists { + return fmt.Errorf("duplicate payment provider contribution %q", provider.ID) + } + seen[provider.ID] = struct{}{} + if strings.TrimSpace(provider.Icon) == "" { + return fmt.Errorf("payment provider contribution %q requires an icon", provider.ID) + } + switch provider.CheckoutMode { + case "qr_code", "redirect": + default: + return fmt.Errorf("payment provider contribution %q has unsupported checkout mode %q", provider.ID, provider.CheckoutMode) + } + policy := provider.ExpiryPolicy + if policy.MinMinutes <= 0 || policy.DefaultMinutes < policy.MinMinutes || policy.MaxMinutes < policy.DefaultMinutes { + return fmt.Errorf("payment provider contribution %q has invalid expiry policy", provider.ID) + } + } return nil } @@ -358,7 +385,7 @@ func normalizeManifestForProvider(manifest *Manifest, index int) error { } func hasNonProviderContribution(contributes ManifestContributions) bool { - return len(contributes.Workflows) > 0 || len(contributes.CanvasNodes) > 0 || len(contributes.Transforms) > 0 || len(contributes.Commands) > 0 || len(contributes.AssetSources) > 0 || len(contributes.UsageObservers) > 0 || len(contributes.AICapabilities) > 0 || len(contributes.Agents) > 0 || len(contributes.ImportExport) > 0 + return len(contributes.PaymentProviders) > 0 || len(contributes.Workflows) > 0 || len(contributes.CanvasNodes) > 0 || len(contributes.Transforms) > 0 || len(contributes.Commands) > 0 || len(contributes.AssetSources) > 0 || len(contributes.UsageObservers) > 0 || len(contributes.AICapabilities) > 0 || len(contributes.Agents) > 0 || len(contributes.ImportExport) > 0 } func operationSummary(operation ManifestOperation) string { diff --git a/backend/internal/protocol/types.go b/backend/internal/protocol/types.go index af3ae59a1..3178241d0 100644 --- a/backend/internal/protocol/types.go +++ b/backend/internal/protocol/types.go @@ -279,22 +279,38 @@ type ManifestField struct { Type string `json:"type"` Label string `json:"label,omitempty"` Required bool `json:"required,omitempty"` + Secret bool `json:"secret,omitempty"` Default any `json:"default,omitempty"` Description string `json:"description,omitempty"` Values []string `json:"values,omitempty"` } type ManifestContributions struct { - Providers []ManifestProvider `json:"providers,omitempty"` - Workflows []ManifestWorkflow `json:"workflows,omitempty"` - CanvasNodes []ManifestCanvasNode `json:"canvasNodes,omitempty"` - Transforms []ManifestTransform `json:"transforms,omitempty"` - Commands []ManifestCommand `json:"commands,omitempty"` - AssetSources []string `json:"assetSources,omitempty"` - UsageObservers []string `json:"usageObservers,omitempty"` - AICapabilities []string `json:"aiCapabilities,omitempty"` - Agents []string `json:"agents,omitempty"` - ImportExport []string `json:"importExport,omitempty"` + Providers []ManifestProvider `json:"providers,omitempty"` + PaymentProviders []ManifestPaymentProvider `json:"paymentProviders,omitempty"` + Workflows []ManifestWorkflow `json:"workflows,omitempty"` + CanvasNodes []ManifestCanvasNode `json:"canvasNodes,omitempty"` + Transforms []ManifestTransform `json:"transforms,omitempty"` + Commands []ManifestCommand `json:"commands,omitempty"` + AssetSources []string `json:"assetSources,omitempty"` + UsageObservers []string `json:"usageObservers,omitempty"` + AICapabilities []string `json:"aiCapabilities,omitempty"` + Agents []string `json:"agents,omitempty"` + ImportExport []string `json:"importExport,omitempty"` +} + +type ManifestPaymentProvider struct { + ID string `json:"id"` + Label string `json:"label"` + Icon string `json:"icon"` + CheckoutMode string `json:"checkoutMode"` + ExpiryPolicy ManifestPaymentExpiryPolicy `json:"expiryPolicy"` +} + +type ManifestPaymentExpiryPolicy struct { + DefaultMinutes int `json:"defaultMinutes"` + MinMinutes int `json:"minMinutes"` + MaxMinutes int `json:"maxMinutes"` } type ManifestProvider struct { diff --git a/backend/internal/repository/payment.go b/backend/internal/repository/payment.go new file mode 100644 index 000000000..10b4ec43c --- /dev/null +++ b/backend/internal/repository/payment.go @@ -0,0 +1,538 @@ +package repository + +import ( + "errors" + "strings" + "time" + + "infinite-canvas/backend/internal/model" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +var ( + ErrPaymentOrderStateConflict = errors.New("payment order state conflict") + ErrPaymentEvidenceMismatch = errors.New("payment evidence does not match order") + ErrPaymentTradeNoConflict = errors.New("payment provider trade number is already used") + ErrPaymentCreditOverflow = errors.New("payment credit balance would overflow") +) + +// Credit balances are serialized to JavaScript clients as JSON numbers. +// Keeping payment grants within Number.MAX_SAFE_INTEGER preserves exact values +// across the database, API, and wallet UI. +const maxPaymentCreditBalance int64 = 9_007_199_254_740_991 + +type PaymentEvidence struct { + ProviderTradeNo string + ProviderStatus string + AmountFen int64 + Currency string + PaidAt time.Time +} + +func (r *Repository) TopupProducts(includeDisabled bool) ([]model.TopupProduct, error) { + var products []model.TopupProduct + query := r.db.Order("sort_order asc, amount_fen asc, created_at asc") + if !includeDisabled { + query = query.Where("enabled = ?", true) + } + err := query.Find(&products).Error + return products, err +} + +func (r *Repository) TopupProduct(id string) (*model.TopupProduct, error) { + var product model.TopupProduct + return &product, r.db.First(&product, "id = ?", strings.TrimSpace(id)).Error +} + +func (r *Repository) CreateTopupProduct(product *model.TopupProduct) error { + return r.db.Create(product).Error +} + +func (r *Repository) UpdateTopupProduct(product *model.TopupProduct) error { + return r.db.Model(&model.TopupProduct{}).Where("id = ?", product.ID).Updates(map[string]any{ + "name": product.Name, "description": product.Description, "amount_fen": product.AmountFen, + "credits_microcredits": product.CreditsMicrocredits, "enabled": product.Enabled, + "sort_order": product.SortOrder, "updated_by": product.UpdatedBy, "updated_at": time.Now(), + }).Error +} + +func (r *Repository) CreatePaymentProviderConfig(config *model.PaymentProviderConfig) error { + return r.db.Transaction(func(tx *gorm.DB) error { + var latest struct{ Version int64 } + if err := tx.Model(&model.PaymentProviderConfig{}).Select("COALESCE(MAX(version), 0) AS version").Where("provider_id = ?", config.ProviderID).Scan(&latest).Error; err != nil { + return err + } + config.Version = latest.Version + 1 + return tx.Create(config).Error + }) +} + +func (r *Repository) LatestPaymentProviderConfig(providerID string) (*model.PaymentProviderConfig, error) { + var config model.PaymentProviderConfig + err := r.db.Where("provider_id = ?", strings.TrimSpace(providerID)).Order("version desc").First(&config).Error + return &config, err +} + +func (r *Repository) PaymentProviderConfig(id string) (*model.PaymentProviderConfig, error) { + var config model.PaymentProviderConfig + return &config, r.db.First(&config, "id = ?", strings.TrimSpace(id)).Error +} + +// CreatePaymentOrder claims the user idempotency key before contacting a +// provider. The returned created flag is false when a retry found its order. +func (r *Repository) CreatePaymentOrder(order *model.PaymentOrder) (*model.PaymentOrder, bool, error) { + created := r.db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "user_id"}, {Name: "idempotency_key"}}, + DoNothing: true, + }).Create(order) + if created.Error != nil { + return nil, false, created.Error + } + if created.RowsAffected == 1 { + return order, true, nil + } + var existing model.PaymentOrder + err := r.db.Where("user_id = ? AND idempotency_key = ?", order.UserID, order.IdempotencyKey).First(&existing).Error + return &existing, false, err +} + +func (r *Repository) SetPaymentOrderCheckout(id string, checkoutMode, checkoutValue string, checkoutExpiresAt time.Time) error { + updated := r.db.Model(&model.PaymentOrder{}). + Where("id = ? AND status IN ?", id, []model.PaymentOrderStatus{model.PaymentOrderCreated, model.PaymentOrderCreateFailed, model.PaymentOrderPending}). + Updates(map[string]any{ + "status": model.PaymentOrderPending, "checkout_mode": checkoutMode, "checkout_value": checkoutValue, + "checkout_expires_at": &checkoutExpiresAt, "last_error": "", "updated_at": time.Now(), + }) + if updated.Error != nil { + return updated.Error + } + if updated.RowsAffected != 1 { + return ErrPaymentOrderStateConflict + } + return nil +} + +func (r *Repository) SetPaymentOrderCreateFailure(id, message string) error { + return r.db.Model(&model.PaymentOrder{}).Where("id = ? AND status = ?", id, model.PaymentOrderCreated).Updates(map[string]any{ + "status": model.PaymentOrderCreateFailed, "last_error": message, "updated_at": time.Now(), + }).Error +} + +func (r *Repository) PaymentOrderForUser(userID, id string) (*model.PaymentOrder, error) { + var order model.PaymentOrder + return &order, r.db.First(&order, "id = ? AND user_id = ?", strings.TrimSpace(id), strings.TrimSpace(userID)).Error +} + +func (r *Repository) PaymentOrderByIdempotency(userID, idempotencyKey string) (*model.PaymentOrder, error) { + var order model.PaymentOrder + return &order, r.db.First(&order, "user_id = ? AND idempotency_key = ?", strings.TrimSpace(userID), strings.TrimSpace(idempotencyKey)).Error +} + +func (r *Repository) PaymentOrder(id string) (*model.PaymentOrder, error) { + var order model.PaymentOrder + return &order, r.db.First(&order, "id = ?", strings.TrimSpace(id)).Error +} + +func (r *Repository) PaymentOrderByMerchant(providerID, merchantOrderNo string) (*model.PaymentOrder, error) { + var order model.PaymentOrder + return &order, r.db.First(&order, "provider_id = ? AND merchant_order_no = ?", providerID, merchantOrderNo).Error +} + +func (r *Repository) ActivePaymentOrderCount(userID string) (int64, error) { + var count int64 + err := r.db.Model(&model.PaymentOrder{}).Where("user_id = ? AND status IN ?", userID, []model.PaymentOrderStatus{ + model.PaymentOrderCreated, model.PaymentOrderPending, model.PaymentOrderClosing, model.PaymentOrderCreateFailed, + }).Count(&count).Error + return count, err +} + +func (r *Repository) PaymentOrderCountForProvider(providerID string) (int64, error) { + var count int64 + err := r.db.Model(&model.PaymentOrder{}).Where("provider_id = ?", strings.TrimSpace(providerID)).Count(&count).Error + return count, err +} + +func (r *Repository) SaveVerifiedPaymentNotification(notification *model.PaymentNotification) (bool, error) { + created := r.db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "provider_id"}, {Name: "provider_event_id"}}, DoNothing: true, + }).Create(notification) + return created.RowsAffected == 1, created.Error +} + +func (r *Repository) PendingPaymentNotifications(limit int) ([]model.PaymentNotification, error) { + if limit <= 0 || limit > 100 { + limit = 32 + } + var items []model.PaymentNotification + err := r.db.Where("status IN ? AND next_attempt_at <= ? AND attempts < ?", []model.PaymentNotificationStatus{ + model.PaymentNotificationPending, model.PaymentNotificationFailed, + }, time.Now(), 20).Order("created_at asc").Limit(limit).Find(&items).Error + return items, err +} + +func (r *Repository) CompletePaymentNotification(id string) error { + now := time.Now() + return r.db.Model(&model.PaymentNotification{}).Where("id = ?", id).Updates(map[string]any{ + "status": model.PaymentNotificationProcessed, "processed_at": &now, "last_error": "", "updated_at": now, + }).Error +} + +func (r *Repository) RetryPaymentNotification(id, message string, next time.Time) error { + return r.db.Model(&model.PaymentNotification{}).Where("id = ?", id).Updates(map[string]any{ + "status": model.PaymentNotificationFailed, "attempts": gorm.Expr("attempts + 1"), + "last_error": message, "next_attempt_at": next, "updated_at": time.Now(), + }).Error +} + +// CompletePaymentOrder atomically records provider success and grants credits. +// The unique ledger reference is the final guard against callback/query races. +func (r *Repository) CompletePaymentOrder(providerID, merchantOrderNo string, evidence PaymentEvidence) (*model.PaymentOrder, bool, error) { + var order model.PaymentOrder + granted := false + err := r.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, "provider_id = ? AND merchant_order_no = ?", providerID, merchantOrderNo).Error; err != nil { + return err + } + if order.AmountFen != evidence.AmountFen || order.Currency != evidence.Currency || strings.TrimSpace(evidence.ProviderTradeNo) == "" { + return ErrPaymentEvidenceMismatch + } + if order.AmountFen <= 0 || order.CreditsMicrocredits <= 0 { + return ErrPaymentOrderStateConflict + } + if order.Status == model.PaymentOrderCredited { + if order.ProviderTradeNo == nil || strings.TrimSpace(*order.ProviderTradeNo) != strings.TrimSpace(evidence.ProviderTradeNo) { + return ErrPaymentEvidenceMismatch + } + return nil + } + var duplicate int64 + if err := tx.Model(&model.PaymentOrder{}).Where("provider_id = ? AND provider_trade_no = ? AND id <> ?", providerID, evidence.ProviderTradeNo, order.ID).Count(&duplicate).Error; err != nil { + return err + } + if duplicate > 0 { + return ErrPaymentTradeNoConflict + } + referenceKey := "payment:" + providerID + ":" + merchantOrderNo + entry := model.CreditLedgerEntry{ + ID: newRepositoryID(), UserID: order.UserID, Type: model.CreditLedgerPaymentTopup, + AmountMicrocredits: order.CreditsMicrocredits, PaymentOrderID: order.ID, + ReferenceKey: &referenceKey, Note: order.ProductName + " · 在线支付充值", + } + created := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "reference_key"}}, DoNothing: true}).Create(&entry) + if created.Error != nil { + return created.Error + } + if created.RowsAffected == 0 { + // A credited order is handled above. Reaching this branch means a + // ledger row exists while the order is still non-terminal, so treating + // the operation as successful would hide an inconsistent credit state. + return ErrPaymentOrderStateConflict + } + account := model.CreditAccount{UserID: order.UserID} + if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&account).Error; err != nil { + return err + } + now := time.Now() + accountUpdate := tx.Model(&model.CreditAccount{}). + Where("user_id = ? AND available_microcredits <= ?", order.UserID, maxPaymentCreditBalance-order.CreditsMicrocredits). + Updates(map[string]any{ + "available_microcredits": gorm.Expr("available_microcredits + ?", order.CreditsMicrocredits), + "version": gorm.Expr("version + 1"), "updated_at": now, + }) + if accountUpdate.Error != nil { + return accountUpdate.Error + } + if accountUpdate.RowsAffected != 1 { + return ErrPaymentCreditOverflow + } + if err := tx.First(&account, "user_id = ?", order.UserID).Error; err != nil { + return err + } + if err := tx.Model(&entry).Updates(map[string]any{ + "available_delta_microcredits": order.CreditsMicrocredits, + "available_after_microcredits": account.AvailableMicrocredits, + "reserved_after_microcredits": account.ReservedMicrocredits, + }).Error; err != nil { + return err + } + paidAt := evidence.PaidAt + if paidAt.IsZero() { + paidAt = now + } + tradeNo := evidence.ProviderTradeNo + updated := tx.Model(&model.PaymentOrder{}).Where("id = ? AND status <> ?", order.ID, model.PaymentOrderCredited).Updates(map[string]any{ + "status": model.PaymentOrderCredited, "provider_trade_no": &tradeNo, + "provider_status": evidence.ProviderStatus, "provider_paid_at": &paidAt, + "credited_at": &now, "last_error": "", "updated_at": now, + }) + if updated.Error != nil { + return updated.Error + } + if updated.RowsAffected != 1 { + return ErrPaymentOrderStateConflict + } + granted = true + return tx.First(&order, "id = ?", order.ID).Error + }) + if err == nil && !granted && order.ID != "" { + if reloadErr := r.db.First(&order, "id = ?", order.ID).Error; reloadErr != nil { + return &order, false, reloadErr + } + } + return &order, granted, err +} + +func (r *Repository) RecordPaymentQuery(id, providerStatus string) error { + now := time.Now() + return r.db.Model(&model.PaymentOrder{}).Where("id = ?", id).Updates(map[string]any{ + "provider_status": providerStatus, "last_queried_at": &now, "updated_at": now, + }).Error +} + +func (r *Repository) MarkPaymentOrderClosed(id, providerStatus string) error { + now := time.Now() + updated := r.db.Model(&model.PaymentOrder{}).Where("id = ? AND status IN ?", id, []model.PaymentOrderStatus{ + model.PaymentOrderCreated, model.PaymentOrderPending, model.PaymentOrderClosing, model.PaymentOrderCreateFailed, + }).Updates(map[string]any{ + "status": model.PaymentOrderClosed, "provider_status": providerStatus, + "closed_at": &now, "last_error": "", "updated_at": now, + }) + if updated.Error != nil { + return updated.Error + } + if updated.RowsAffected == 0 { + var order model.PaymentOrder + if err := r.db.First(&order, "id = ?", id).Error; err != nil { + return err + } + if order.Status != model.PaymentOrderCredited && order.Status != model.PaymentOrderClosed { + return ErrPaymentOrderStateConflict + } + } + return nil +} + +func (r *Repository) RestoreClosingPaymentOrder(id, message string) error { + return r.db.Model(&model.PaymentOrder{}).Where("id = ? AND status = ?", id, model.PaymentOrderClosing).Updates(map[string]any{ + "status": model.PaymentOrderPending, "last_error": message, "updated_at": time.Now(), + }).Error +} + +func (r *Repository) ClaimExpiredPaymentOrders(limit int) ([]model.PaymentOrder, error) { + if limit <= 0 || limit > 100 { + limit = 32 + } + now := time.Now() + claimable := []model.PaymentOrderStatus{model.PaymentOrderCreated, model.PaymentOrderPending, model.PaymentOrderCreateFailed} + var candidates []model.PaymentOrder + if err := r.db.Where( + "(status IN ? AND expires_at <= ?) OR (status = ? AND updated_at <= ?)", + claimable, now, model.PaymentOrderClosing, now.Add(-2*time.Minute), + ).Order("expires_at asc").Limit(limit).Find(&candidates).Error; err != nil { + return nil, err + } + claimed := make([]model.PaymentOrder, 0, len(candidates)) + for _, candidate := range candidates { + query := r.db.Model(&model.PaymentOrder{}).Where("id = ? AND status = ?", candidate.ID, candidate.Status) + if candidate.Status == model.PaymentOrderClosing { + query = query.Where("updated_at = ?", candidate.UpdatedAt) + } + updated := query.Updates(map[string]any{"status": model.PaymentOrderClosing, "updated_at": time.Now()}) + if updated.Error != nil { + return claimed, updated.Error + } + if updated.RowsAffected == 1 { + candidate.Status = model.PaymentOrderClosing + claimed = append(claimed, candidate) + } + } + return claimed, nil +} + +func (r *Repository) PaymentOrdersNeedingQuery(cutoff time.Time, limit int) ([]model.PaymentOrder, error) { + if limit <= 0 || limit > 100 { + limit = 32 + } + var items []model.PaymentOrder + err := r.db.Where("status IN ? AND (last_queried_at IS NULL OR last_queried_at < ?)", []model.PaymentOrderStatus{ + model.PaymentOrderCreated, model.PaymentOrderPending, model.PaymentOrderCreateFailed, + }, cutoff). + Order("created_at asc").Limit(limit).Find(&items).Error + return items, err +} + +func (r *Repository) AdminPaymentOrders(status, keyword string, limit, offset int) ([]model.PaymentOrder, int64, error) { + var items []model.PaymentOrder + var total int64 + query := r.db.Model(&model.PaymentOrder{}) + if normalized := strings.TrimSpace(status); normalized != "" && normalized != "all" { + query = query.Where("status = ?", normalized) + } + if normalized := strings.TrimSpace(keyword); normalized != "" { + like := "%" + normalized + "%" + query = query.Where("merchant_order_no LIKE ? OR provider_trade_no LIKE ? OR user_id LIKE ?", like, like, like) + } + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + err := query.Order("created_at desc").Limit(limit).Offset(offset).Find(&items).Error + return items, total, err +} + +// BeginPaymentReconciliation creates or safely reuses the single daily run +// for a provider. A recently running job is left alone so two workers cannot +// reconcile and grant the same missing credit concurrently. +func (r *Repository) BeginPaymentReconciliation(run *model.PaymentReconciliationRun) (*model.PaymentReconciliationRun, bool, error) { + if run == nil { + return nil, false, errors.New("payment reconciliation run is nil") + } + var current model.PaymentReconciliationRun + started := false + err := r.db.Transaction(func(tx *gorm.DB) error { + err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("provider_id = ? AND bill_date = ?", run.ProviderID, run.BillDate). + First(¤t).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + created := tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "provider_id"}, {Name: "bill_date"}}, + DoNothing: true, + }).Create(run) + if created.Error != nil { + return created.Error + } + if created.RowsAffected == 1 { + current = *run + started = true + return nil + } + // Another worker won the unique-key race. Reload and apply the + // same running/cooldown rules instead of surfacing a conflict. + if reloadErr := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("provider_id = ? AND bill_date = ?", run.ProviderID, run.BillDate). + First(¤t).Error; reloadErr != nil { + return reloadErr + } + err = nil + } + if err != nil { + return err + } + if current.Status == model.PaymentReconciliationRunning && current.UpdatedAt.After(time.Now().Add(-30*time.Minute)) { + return nil + } + if err := tx.Where("run_id = ?", current.ID).Delete(&model.PaymentReconciliationItem{}).Error; err != nil { + return err + } + now := time.Now() + if err := tx.Model(&model.PaymentReconciliationRun{}).Where("id = ?", current.ID).Updates(map[string]any{ + "config_id": run.ConfigID, "status": model.PaymentReconciliationRunning, + "total_items": 0, "match_items": 0, "recovered_items": 0, "error_items": 0, + "error": "", "started_by": run.StartedBy, "started_at": now, "completed_at": nil, + "updated_at": now, + }).Error; err != nil { + return err + } + started = true + return tx.First(¤t, "id = ?", current.ID).Error + }) + return ¤t, started, err +} + +func (r *Repository) CompletePaymentReconciliation(id string, items []model.PaymentReconciliationItem, matched, recovered, failed int) error { + return r.db.Transaction(func(tx *gorm.DB) error { + if len(items) > 0 { + if err := tx.CreateInBatches(items, 200).Error; err != nil { + return err + } + } + now := time.Now() + updated := tx.Model(&model.PaymentReconciliationRun{}). + Where("id = ? AND status = ?", strings.TrimSpace(id), model.PaymentReconciliationRunning). + Updates(map[string]any{ + "status": model.PaymentReconciliationCompleted, "total_items": len(items), + "match_items": matched, "recovered_items": recovered, "error_items": failed, + "error": "", "completed_at": &now, "updated_at": now, + }) + if updated.Error != nil { + return updated.Error + } + if updated.RowsAffected != 1 { + return errors.New("payment reconciliation run is no longer running") + } + return nil + }) +} + +func (r *Repository) FailPaymentReconciliation(id, message string) error { + now := time.Now() + return r.db.Model(&model.PaymentReconciliationRun{}).Where("id = ?", strings.TrimSpace(id)).Updates(map[string]any{ + "status": model.PaymentReconciliationFailed, "error": message, + "completed_at": &now, "updated_at": now, + }).Error +} + +func (r *Repository) PaymentReconciliationRun(id string) (*model.PaymentReconciliationRun, error) { + var run model.PaymentReconciliationRun + return &run, r.db.First(&run, "id = ?", strings.TrimSpace(id)).Error +} + +func (r *Repository) PaymentReconciliationRunByDate(providerID, billDate string) (*model.PaymentReconciliationRun, error) { + var run model.PaymentReconciliationRun + return &run, r.db.First(&run, "provider_id = ? AND bill_date = ?", strings.TrimSpace(providerID), strings.TrimSpace(billDate)).Error +} + +func (r *Repository) AdminPaymentReconciliationRuns(providerID, status string, limit, offset int) ([]model.PaymentReconciliationRun, int64, error) { + var runs []model.PaymentReconciliationRun + var total int64 + query := r.db.Model(&model.PaymentReconciliationRun{}) + if normalized := strings.TrimSpace(providerID); normalized != "" && normalized != "all" { + query = query.Where("provider_id = ?", normalized) + } + if normalized := strings.TrimSpace(status); normalized != "" && normalized != "all" { + query = query.Where("status = ?", normalized) + } + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + err := query.Order("bill_date desc, started_at desc").Limit(limit).Offset(offset).Find(&runs).Error + return runs, total, err +} + +func (r *Repository) PaymentReconciliationItems(runID, result string, limit, offset int) ([]model.PaymentReconciliationItem, int64, error) { + var items []model.PaymentReconciliationItem + var total int64 + query := r.db.Model(&model.PaymentReconciliationItem{}).Where("run_id = ?", strings.TrimSpace(runID)) + if normalized := strings.TrimSpace(result); normalized != "" && normalized != "all" { + query = query.Where("result = ?", normalized) + } + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + err := query.Order("resolved asc, created_at asc").Limit(limit).Offset(offset).Find(&items).Error + return items, total, err +} + +func (r *Repository) CreditedPaymentOrdersBetween(providerID string, start, end time.Time) ([]model.PaymentOrder, error) { + var orders []model.PaymentOrder + err := r.db.Where( + "provider_id = ? AND status = ? AND provider_paid_at >= ? AND provider_paid_at < ?", + strings.TrimSpace(providerID), model.PaymentOrderCredited, start, end, + ).Order("provider_paid_at asc").Find(&orders).Error + return orders, err +} + +// UnresolvedPaymentOrderCandidateCountOverlapping reports whether an order +// whose payment outcome is still ambiguous overlaps the bill day. Closed +// orders have already been confirmed unpaid by query/close, while credited +// orders are checked separately against the provider bill. +func (r *Repository) UnresolvedPaymentOrderCandidateCountOverlapping(providerID string, start, end time.Time) (int64, error) { + var count int64 + err := r.db.Model(&model.PaymentOrder{}). + Where("provider_id = ? AND status IN ? AND created_at < ? AND expires_at >= ?", strings.TrimSpace(providerID), []model.PaymentOrderStatus{ + model.PaymentOrderCreated, model.PaymentOrderPending, model.PaymentOrderClosing, model.PaymentOrderCreateFailed, + }, end, start). + Count(&count).Error + return count, err +} diff --git a/backend/internal/repository/payment_test.go b/backend/internal/repository/payment_test.go new file mode 100644 index 000000000..c2682000c --- /dev/null +++ b/backend/internal/repository/payment_test.go @@ -0,0 +1,356 @@ +package repository + +import ( + "errors" + "testing" + "time" + + "infinite-canvas/backend/internal/model" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestCompletePaymentOrderGrantsCreditsExactlyOnce(t *testing.T) { + db := openPaymentTestDB(t) + repo := New(db) + order := model.PaymentOrder{ + ID: "payment-order-1", UserID: "user-1", IdempotencyKey: "idem-1", MerchantOrderNo: "merchant-order-1", + ProductID: "product-1", ProductName: "100 积分", ProviderID: "wechat-native", PluginID: "plugin-1", + ProviderConfigID: "config-1", ProviderConfigVersion: 1, AmountFen: 100, Currency: "CNY", + CreditsMicrocredits: 100_000_000, Status: model.PaymentOrderPending, ExpiresAt: time.Now().Add(time.Hour), + } + if err := db.Create(&order).Error; err != nil { + t.Fatal(err) + } + evidence := PaymentEvidence{ProviderTradeNo: "wechat-trade-1", ProviderStatus: "SUCCESS", AmountFen: 100, Currency: "CNY", PaidAt: time.Now()} + completed, granted, err := repo.CompletePaymentOrder("wechat-native", "merchant-order-1", evidence) + if err != nil { + t.Fatal(err) + } + if !granted || completed.Status != model.PaymentOrderCredited { + t.Fatalf("first completion = %#v, granted=%v", completed, granted) + } + completed, granted, err = repo.CompletePaymentOrder("wechat-native", "merchant-order-1", evidence) + if err != nil { + t.Fatal(err) + } + if granted || completed.Status != model.PaymentOrderCredited { + t.Fatalf("duplicate completion = %#v, granted=%v", completed, granted) + } + var account model.CreditAccount + if err := db.First(&account, "user_id = ?", "user-1").Error; err != nil { + t.Fatal(err) + } + if account.AvailableMicrocredits != 100_000_000 { + t.Fatalf("available credits = %d", account.AvailableMicrocredits) + } + var ledgerCount int64 + if err := db.Model(&model.CreditLedgerEntry{}).Where("payment_order_id = ? AND type = ?", order.ID, model.CreditLedgerPaymentTopup).Count(&ledgerCount).Error; err != nil { + t.Fatal(err) + } + if ledgerCount != 1 { + t.Fatalf("payment ledger entries = %d", ledgerCount) + } + conflictingEvidence := evidence + conflictingEvidence.ProviderTradeNo = "wechat-trade-other" + if _, _, err := repo.CompletePaymentOrder(order.ProviderID, order.MerchantOrderNo, conflictingEvidence); !errors.Is(err, ErrPaymentEvidenceMismatch) { + t.Fatalf("conflicting trade number error = %v, want %v", err, ErrPaymentEvidenceMismatch) + } +} + +func TestCompletePaymentOrderRejectsAmountMismatchWithoutGrant(t *testing.T) { + db := openPaymentTestDB(t) + repo := New(db) + order := model.PaymentOrder{ + ID: "payment-order-2", UserID: "user-2", IdempotencyKey: "idem-2", MerchantOrderNo: "merchant-order-2", + ProductID: "product-2", ProductName: "200 积分", ProviderID: "alipay-page-pay", PluginID: "plugin-2", + ProviderConfigID: "config-2", ProviderConfigVersion: 1, AmountFen: 200, Currency: "CNY", + CreditsMicrocredits: 200_000_000, Status: model.PaymentOrderPending, ExpiresAt: time.Now().Add(time.Hour), + } + if err := db.Create(&order).Error; err != nil { + t.Fatal(err) + } + _, _, err := repo.CompletePaymentOrder("alipay-page-pay", "merchant-order-2", PaymentEvidence{ + ProviderTradeNo: "alipay-trade-2", ProviderStatus: "TRADE_SUCCESS", AmountFen: 199, Currency: "CNY", + }) + if !errors.Is(err, ErrPaymentEvidenceMismatch) { + t.Fatalf("error = %v", err) + } + var ledgerCount int64 + if err := db.Model(&model.CreditLedgerEntry{}).Count(&ledgerCount).Error; err != nil { + t.Fatal(err) + } + if ledgerCount != 0 { + t.Fatalf("ledger entries = %d", ledgerCount) + } +} + +func TestCompletePaymentOrderScopesTradeNumberUniquenessByProvider(t *testing.T) { + db := openPaymentTestDB(t) + repo := New(db) + orders := []model.PaymentOrder{ + { + ID: "wechat-order-a", UserID: "wechat-user-a", IdempotencyKey: "wechat-idem-a", MerchantOrderNo: "wechat-merchant-order-a", + ProductID: "product", ProductName: "积分", ProviderID: "wechat-native", PluginID: "wechat-plugin", + ProviderConfigID: "wechat-config", ProviderConfigVersion: 1, AmountFen: 100, Currency: "CNY", + CreditsMicrocredits: 100, Status: model.PaymentOrderPending, ExpiresAt: time.Now().Add(time.Hour), + }, + { + ID: "wechat-order-b", UserID: "wechat-user-b", IdempotencyKey: "wechat-idem-b", MerchantOrderNo: "wechat-merchant-order-b", + ProductID: "product", ProductName: "积分", ProviderID: "wechat-native", PluginID: "wechat-plugin", + ProviderConfigID: "wechat-config", ProviderConfigVersion: 1, AmountFen: 100, Currency: "CNY", + CreditsMicrocredits: 100, Status: model.PaymentOrderPending, ExpiresAt: time.Now().Add(time.Hour), + }, + { + ID: "alipay-order", UserID: "alipay-user", IdempotencyKey: "alipay-idem", MerchantOrderNo: "alipay-merchant-order", + ProductID: "product", ProductName: "积分", ProviderID: "alipay-page-pay", PluginID: "alipay-plugin", + ProviderConfigID: "alipay-config", ProviderConfigVersion: 1, AmountFen: 100, Currency: "CNY", + CreditsMicrocredits: 100, Status: model.PaymentOrderPending, ExpiresAt: time.Now().Add(time.Hour), + }, + } + if err := db.Create(&orders).Error; err != nil { + t.Fatal(err) + } + sharedTradeNo := "shared-provider-trade-number" + for _, order := range []model.PaymentOrder{orders[0], orders[2]} { + if _, granted, err := repo.CompletePaymentOrder(order.ProviderID, order.MerchantOrderNo, PaymentEvidence{ + ProviderTradeNo: sharedTradeNo, ProviderStatus: "SUCCESS", AmountFen: order.AmountFen, Currency: order.Currency, + }); err != nil || !granted { + t.Fatalf("complete %s = granted %v, error %v", order.ProviderID, granted, err) + } + } + if _, _, err := repo.CompletePaymentOrder(orders[1].ProviderID, orders[1].MerchantOrderNo, PaymentEvidence{ + ProviderTradeNo: sharedTradeNo, ProviderStatus: "SUCCESS", AmountFen: orders[1].AmountFen, Currency: orders[1].Currency, + }); !errors.Is(err, ErrPaymentTradeNoConflict) { + t.Fatalf("same-provider duplicate error = %v, want %v", err, ErrPaymentTradeNoConflict) + } +} + +func TestCompletePaymentOrderRejectsInconsistentExistingLedger(t *testing.T) { + db := openPaymentTestDB(t) + repo := New(db) + order := model.PaymentOrder{ + ID: "payment-order-inconsistent", UserID: "user-inconsistent", IdempotencyKey: "idem-inconsistent", MerchantOrderNo: "merchant-order-inconsistent", + ProductID: "product", ProductName: "100 积分", ProviderID: "wechat-native", PluginID: "plugin", + ProviderConfigID: "config", ProviderConfigVersion: 1, AmountFen: 100, Currency: "CNY", + CreditsMicrocredits: 100_000_000, Status: model.PaymentOrderPending, ExpiresAt: time.Now().Add(time.Hour), + } + if err := db.Create(&order).Error; err != nil { + t.Fatal(err) + } + referenceKey := "payment:" + order.ProviderID + ":" + order.MerchantOrderNo + if err := db.Create(&model.CreditLedgerEntry{ + ID: "ledger-inconsistent", UserID: order.UserID, Type: model.CreditLedgerPaymentTopup, + AmountMicrocredits: order.CreditsMicrocredits, PaymentOrderID: order.ID, ReferenceKey: &referenceKey, + }).Error; err != nil { + t.Fatal(err) + } + + _, granted, err := repo.CompletePaymentOrder(order.ProviderID, order.MerchantOrderNo, PaymentEvidence{ + ProviderTradeNo: "wechat-trade-inconsistent", ProviderStatus: "SUCCESS", AmountFen: order.AmountFen, Currency: order.Currency, + }) + if !errors.Is(err, ErrPaymentOrderStateConflict) { + t.Fatalf("error = %v, want %v", err, ErrPaymentOrderStateConflict) + } + if granted { + t.Fatal("inconsistent ledger must not report a granted credit") + } + var accountCount int64 + if err := db.Model(&model.CreditAccount{}).Where("user_id = ?", order.UserID).Count(&accountCount).Error; err != nil { + t.Fatal(err) + } + if accountCount != 0 { + t.Fatalf("credit accounts = %d", accountCount) + } +} + +func TestCompletePaymentOrderRejectsCreditBalanceOverflow(t *testing.T) { + db := openPaymentTestDB(t) + repo := New(db) + order := model.PaymentOrder{ + ID: "payment-order-overflow", UserID: "user-overflow", IdempotencyKey: "idem-overflow", MerchantOrderNo: "merchant-order-overflow", + ProductID: "product", ProductName: "积分", ProviderID: "wechat-native", PluginID: "plugin", + ProviderConfigID: "config", ProviderConfigVersion: 1, AmountFen: 100, Currency: "CNY", + CreditsMicrocredits: 100, Status: model.PaymentOrderPending, ExpiresAt: time.Now().Add(time.Hour), + } + if err := db.Create(&order).Error; err != nil { + t.Fatal(err) + } + account := model.CreditAccount{UserID: order.UserID, AvailableMicrocredits: maxPaymentCreditBalance - 50} + if err := db.Create(&account).Error; err != nil { + t.Fatal(err) + } + + _, granted, err := repo.CompletePaymentOrder(order.ProviderID, order.MerchantOrderNo, PaymentEvidence{ + ProviderTradeNo: "wechat-trade-overflow", ProviderStatus: "SUCCESS", AmountFen: order.AmountFen, Currency: order.Currency, + }) + if !errors.Is(err, ErrPaymentCreditOverflow) { + t.Fatalf("error = %v, want %v", err, ErrPaymentCreditOverflow) + } + if granted { + t.Fatal("overflowing credit must not report a grant") + } + if err := db.First(&account, "user_id = ?", order.UserID).Error; err != nil { + t.Fatal(err) + } + if account.AvailableMicrocredits != maxPaymentCreditBalance-50 { + t.Fatalf("available credits = %d", account.AvailableMicrocredits) + } + var ledgerCount int64 + if err := db.Model(&model.CreditLedgerEntry{}).Where("payment_order_id = ?", order.ID).Count(&ledgerCount).Error; err != nil { + t.Fatal(err) + } + if ledgerCount != 0 { + t.Fatalf("payment ledger entries = %d", ledgerCount) + } +} + +func TestCreatePaymentProviderConfigVersionsAreMonotonic(t *testing.T) { + db := openPaymentTestDB(t) + repo := New(db) + first := &model.PaymentProviderConfig{ID: "config-1", ProviderID: "wechat-native", PluginID: "plugin", CloseAfterMinutes: 30} + second := &model.PaymentProviderConfig{ID: "config-2", ProviderID: "wechat-native", PluginID: "plugin", CloseAfterMinutes: 60} + if err := repo.CreatePaymentProviderConfig(first); err != nil { + t.Fatal(err) + } + if err := repo.CreatePaymentProviderConfig(second); err != nil { + t.Fatal(err) + } + if first.Version != 1 || second.Version != 2 { + t.Fatalf("versions = %d, %d", first.Version, second.Version) + } +} + +func TestClaimExpiredPaymentOrdersIncludesAmbiguousAndStaleClosingStates(t *testing.T) { + db := openPaymentTestDB(t) + repo := New(db) + now := time.Now() + statuses := []model.PaymentOrderStatus{ + model.PaymentOrderCreated, + model.PaymentOrderPending, + model.PaymentOrderCreateFailed, + model.PaymentOrderClosing, + } + for index, status := range statuses { + order := model.PaymentOrder{ + ID: "expired-order-" + string(rune('a'+index)), UserID: "expired-user", IdempotencyKey: "expired-idem-" + string(rune('a'+index)), + MerchantOrderNo: "expired-merchant-order-0000000" + string(rune('a'+index)), ProductID: "product", ProductName: "积分", + ProviderID: "wechat-native", PluginID: "plugin", ProviderConfigID: "config", ProviderConfigVersion: 1, + AmountFen: 100, Currency: "CNY", CreditsMicrocredits: 1_000_000, Status: status, + ExpiresAt: now.Add(-time.Minute), UpdatedAt: now.Add(-3 * time.Minute), + } + if err := db.Create(&order).Error; err != nil { + t.Fatal(err) + } + } + freshClosing := model.PaymentOrder{ + ID: "fresh-closing", UserID: "expired-user", IdempotencyKey: "fresh-closing", MerchantOrderNo: "fresh-closing-merchant-000000000", + ProductID: "product", ProductName: "积分", ProviderID: "wechat-native", PluginID: "plugin", ProviderConfigID: "config", ProviderConfigVersion: 1, + AmountFen: 100, Currency: "CNY", CreditsMicrocredits: 1_000_000, Status: model.PaymentOrderClosing, + ExpiresAt: now.Add(-time.Minute), UpdatedAt: now, + } + if err := db.Create(&freshClosing).Error; err != nil { + t.Fatal(err) + } + + claimed, err := repo.ClaimExpiredPaymentOrders(10) + if err != nil { + t.Fatal(err) + } + if len(claimed) != len(statuses) { + t.Fatalf("claimed %d orders, want %d", len(claimed), len(statuses)) + } + for _, order := range claimed { + if order.Status != model.PaymentOrderClosing { + t.Fatalf("claimed status = %s", order.Status) + } + } +} + +func TestPaymentOrdersNeedingQueryIncludesAmbiguousCreationStates(t *testing.T) { + db := openPaymentTestDB(t) + repo := New(db) + statuses := []model.PaymentOrderStatus{ + model.PaymentOrderCreated, + model.PaymentOrderPending, + model.PaymentOrderCreateFailed, + model.PaymentOrderClosing, + model.PaymentOrderCredited, + model.PaymentOrderClosed, + } + for index, status := range statuses { + suffix := string(rune('a' + index)) + order := model.PaymentOrder{ + ID: "query-order-" + suffix, UserID: "query-user", IdempotencyKey: "query-idem-" + suffix, + MerchantOrderNo: "query-merchant-order-000000000" + suffix, ProductID: "product", ProductName: "积分", + ProviderID: "wechat-native", PluginID: "plugin", ProviderConfigID: "config", ProviderConfigVersion: 1, + AmountFen: 100, Currency: "CNY", CreditsMicrocredits: 1_000_000, Status: status, + ExpiresAt: time.Now().Add(time.Hour), + } + if err := db.Create(&order).Error; err != nil { + t.Fatal(err) + } + } + + orders, err := repo.PaymentOrdersNeedingQuery(time.Now(), 20) + if err != nil { + t.Fatal(err) + } + wanted := map[model.PaymentOrderStatus]bool{ + model.PaymentOrderCreated: true, model.PaymentOrderPending: true, model.PaymentOrderCreateFailed: true, + } + if len(orders) != len(wanted) { + t.Fatalf("query candidates = %d, want %d", len(orders), len(wanted)) + } + for _, order := range orders { + if !wanted[order.Status] { + t.Fatalf("unexpected query candidate status %s", order.Status) + } + } +} + +func TestUnresolvedPaymentOrderCandidateCountOverlapping(t *testing.T) { + db := openPaymentTestDB(t) + repo := New(db) + start := time.Date(2026, 9, 1, 0, 0, 0, 0, time.FixedZone("CST", 8*60*60)) + closedOrder := model.PaymentOrder{ + ID: "candidate-order", UserID: "candidate-user", IdempotencyKey: "candidate-idem", MerchantOrderNo: "candidate-merchant-order-0000000", + ProductID: "product", ProductName: "积分", ProviderID: "alipay-page-pay", PluginID: "plugin", ProviderConfigID: "config", ProviderConfigVersion: 1, + AmountFen: 100, Currency: "CNY", CreditsMicrocredits: 1_000_000, Status: model.PaymentOrderClosed, + ExpiresAt: start.Add(time.Hour), CreatedAt: start.Add(-time.Hour), + } + pendingOrder := closedOrder + pendingOrder.ID = "pending-candidate-order" + pendingOrder.IdempotencyKey = "pending-candidate-idem" + pendingOrder.MerchantOrderNo = "pending-candidate-merchant-00000" + pendingOrder.Status = model.PaymentOrderPending + if err := db.Create(&closedOrder).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&pendingOrder).Error; err != nil { + t.Fatal(err) + } + count, err := repo.UnresolvedPaymentOrderCandidateCountOverlapping("alipay-page-pay", start, start.AddDate(0, 0, 1)) + if err != nil { + t.Fatal(err) + } + if count != 1 { + t.Fatalf("unresolved candidate count = %d", count) + } +} + +func openPaymentTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared&_busy_timeout=5000"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate( + &model.CreditAccount{}, &model.CreditLedgerEntry{}, &model.TopupProduct{}, + &model.PaymentProviderConfig{}, &model.PaymentOrder{}, &model.PaymentNotification{}, + ); err != nil { + t.Fatal(err) + } + return db +} diff --git a/backend/internal/service/payment.go b/backend/internal/service/payment.go new file mode 100644 index 000000000..a416c1a25 --- /dev/null +++ b/backend/internal/service/payment.go @@ -0,0 +1,1005 @@ +package service + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "log" + "math" + "net/http" + "net/url" + "strings" + "time" + + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/payment" + "infinite-canvas/backend/internal/protocol" + "infinite-canvas/backend/internal/repository" + + "gorm.io/gorm" +) + +const ( + maxActivePaymentOrdersPerUser = 5 + maxTopupCreditsMicrocredits = int64(1_000_000_000) * CreditScale +) + +type PaymentProviderView struct { + ID string `json:"id"` + PluginID string `json:"pluginId"` + Name string `json:"name"` + Icon string `json:"icon"` + CheckoutMode string `json:"checkoutMode"` + Enabled bool `json:"enabled"` + PluginEnabled bool `json:"pluginEnabled"` + Configured bool `json:"configured"` + CloseAfterMinutes int `json:"closeAfterMinutes"` +} + +type AdminPaymentProviderView struct { + PaymentProviderView + ConfigID string `json:"configId,omitempty"` + ConfigEnabled bool `json:"configEnabled"` + Version int64 `json:"version"` + Values map[string]string `json:"values"` + SecretConfigured map[string]bool `json:"secretConfigured"` + ConfigFields []protocol.ManifestField `json:"configFields"` + UpdatedAt *time.Time `json:"updatedAt,omitempty"` +} + +type UpdatePaymentProviderConfigRequest struct { + Enabled bool `json:"enabled"` + CloseAfterMinutes int `json:"closeAfterMinutes"` + Values map[string]string `json:"values"` +} + +type TopupProductRequest struct { + Name string `json:"name"` + Description string `json:"description"` + AmountFen int64 `json:"amountFen"` + CreditsMicrocredits int64 `json:"creditsMicrocredits"` + Enabled bool `json:"enabled"` + SortOrder int `json:"sortOrder"` +} + +type CreatePaymentOrderRequest struct { + ProductID string `json:"productId"` + ProviderID string `json:"providerId"` + IdempotencyKey string `json:"idempotencyKey"` +} + +type PaymentCheckoutView struct { + Mode string `json:"mode"` + Value string `json:"value,omitempty"` + URL string `json:"url,omitempty"` + ExpiresAt *time.Time `json:"expiresAt,omitempty"` +} + +type PaymentOrderView struct { + ID string `json:"id"` + UserID string `json:"userId,omitempty"` + MerchantOrderNo string `json:"merchantOrderNo"` + ProductID string `json:"productId"` + ProductName string `json:"productName"` + ProviderID string `json:"providerId"` + AmountFen int64 `json:"amountFen"` + Currency string `json:"currency"` + CreditsMicrocredits int64 `json:"creditsMicrocredits"` + Status model.PaymentOrderStatus `json:"status"` + ProviderStatus string `json:"providerStatus,omitempty"` + ProviderTradeNo string `json:"providerTradeNo,omitempty"` + Checkout PaymentCheckoutView `json:"checkout"` + ExpiresAt time.Time `json:"expiresAt"` + ProviderPaidAt *time.Time `json:"providerPaidAt,omitempty"` + CreditedAt *time.Time `json:"creditedAt,omitempty"` + ClosedAt *time.Time `json:"closedAt,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type AdminPaymentOrderPage struct { + Orders []PaymentOrderView `json:"orders"` + Total int64 `json:"total"` + Page int `json:"page"` + Limit int `json:"limit"` +} + +func (s *Service) PaymentProviders(actor *model.User) ([]PaymentProviderView, error) { + if actor == nil { + return nil, Unauthorized("请先登录") + } + if err := s.RequireFeature(FeatureCredits); err != nil { + return nil, err + } + items := make([]PaymentProviderView, 0) + for _, descriptor := range s.paymentRegistry.Descriptors() { + view, _, err := s.paymentProviderView(descriptor) + if err != nil { + return nil, err + } + if view.Enabled && view.Configured { + items = append(items, view) + } + } + return items, nil +} + +func (s *Service) AdminPaymentProviders(actor *model.User) ([]AdminPaymentProviderView, error) { + if err := s.RequireAdmin(actor); err != nil { + return nil, err + } + items := make([]AdminPaymentProviderView, 0) + for _, descriptor := range s.paymentRegistry.Descriptors() { + base, config, err := s.paymentProviderView(descriptor) + if err != nil { + return nil, err + } + manifest, _ := paymentManifestForProvider(descriptor.ID) + view := AdminPaymentProviderView{PaymentProviderView: base, Values: map[string]string{}, SecretConfigured: map[string]bool{}, ConfigFields: manifest.Configuration.Fields} + if config != nil { + values, err := s.decryptPaymentConfig(config) + if err != nil { + return nil, err + } + view.ConfigID = config.ID + view.ConfigEnabled = config.Enabled + view.Version = config.Version + view.UpdatedAt = &config.CreatedAt + for _, field := range manifest.Configuration.Fields { + if field.Secret { + view.SecretConfigured[field.Name] = strings.TrimSpace(values[field.Name]) != "" + continue + } + view.Values[field.Name] = values[field.Name] + } + } + items = append(items, view) + } + return items, nil +} + +func (s *Service) UpdatePaymentProviderConfig(actor *model.User, providerID string, request UpdatePaymentProviderConfigRequest) (*AdminPaymentProviderView, error) { + if err := s.RequireAdmin(actor); err != nil { + return nil, err + } + provider, ok := s.paymentRegistry.Get(providerID) + if !ok { + return nil, BadAuthRequest("未知支付渠道") + } + descriptor := provider.Descriptor() + manifest, ok := paymentManifestForProvider(descriptor.ID) + if !ok { + return nil, BadAuthRequest("支付插件清单不存在") + } + policy := manifest.Contributes.PaymentProviders[0].ExpiryPolicy + if request.CloseAfterMinutes < policy.MinMinutes || request.CloseAfterMinutes > policy.MaxMinutes { + return nil, BadAuthRequest(fmt.Sprintf("未支付关闭时间必须为 %d-%d 分钟", policy.MinMinutes, policy.MaxMinutes)) + } + values := make(payment.Config) + previousIdentity := make(map[string]string) + current, err := s.repo.LatestPaymentProviderConfig(providerID) + if err == nil { + values, err = s.decryptPaymentConfig(current) + if err != nil { + return nil, err + } + for _, field := range paymentProviderIdentityFields(providerID) { + previousIdentity[field] = strings.TrimSpace(values[field]) + } + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + for _, field := range manifest.Configuration.Fields { + value, supplied := request.Values[field.Name] + value = strings.TrimSpace(value) + if field.Secret && (!supplied || value == "") { + continue + } + if !supplied && field.Default != nil && strings.TrimSpace(values[field.Name]) == "" { + value = strings.TrimSpace(fmt.Sprint(field.Default)) + } + values[field.Name] = value + } + changedIdentityField := "" + for field, previous := range previousIdentity { + if previous != "" && strings.TrimSpace(values[field]) != previous { + changedIdentityField = field + break + } + } + if changedIdentityField != "" { + orderCount, err := s.repo.PaymentOrderCountForProvider(providerID) + if err != nil { + return nil, err + } + if orderCount > 0 { + return nil, BadAuthRequest("该渠道已有历史订单,首期不支持切换商户身份;请保持 " + changedIdentityField + " 不变") + } + } + if base := values["publicBaseUrl"]; base != "" { + if err := validatePaymentPublicBaseURL(base); err != nil { + return nil, BadAuthRequest(err.Error()) + } + } + if request.Enabled { + if strings.TrimSpace(values["publicBaseUrl"]) == "" { + return nil, BadAuthRequest("启用支付渠道前必须填写服务器公网地址") + } + if err := provider.ValidateConfig(values); err != nil { + return nil, BadAuthRequest(err.Error()) + } + } + plain, err := json.Marshal(values) + if err != nil { + return nil, err + } + ciphertext, err := s.encryptSettingSecret(string(plain)) + if err != nil { + return nil, err + } + digest := sha256.Sum256(plain) + config := &model.PaymentProviderConfig{ + ID: newID(), ProviderID: descriptor.ID, PluginID: descriptor.PluginID, + Enabled: request.Enabled, CloseAfterMinutes: request.CloseAfterMinutes, + ConfigCipher: ciphertext, ConfigDigest: hex.EncodeToString(digest[:]), CreatedBy: actor.ID, + } + if err := s.repo.CreatePaymentProviderConfig(config); err != nil { + return nil, err + } + if err := s.appendAdminAudit(actor, "payment_provider.config.update", "payment_provider", providerID, "更新支付渠道配置", map[string]any{ + "version": config.Version, "enabled": config.Enabled, "closeAfterMinutes": config.CloseAfterMinutes, + }); err != nil { + return nil, err + } + items, err := s.AdminPaymentProviders(actor) + if err != nil { + return nil, err + } + for index := range items { + if items[index].ID == providerID { + return &items[index], nil + } + } + return nil, errors.New("保存支付渠道配置后未找到渠道") +} + +func paymentProviderIdentityFields(providerID string) []string { + switch strings.TrimSpace(providerID) { + case PaymentProviderWeChat: + return []string{"appId", "mchId"} + case PaymentProviderAlipay: + return []string{"appId", "sellerId"} + default: + return nil + } +} + +func (s *Service) paymentProviderView(descriptor payment.Descriptor) (PaymentProviderView, *model.PaymentProviderConfig, error) { + view := PaymentProviderView{ID: descriptor.ID, PluginID: descriptor.PluginID, Name: descriptor.Name, Icon: descriptor.Icon, CheckoutMode: descriptor.CheckoutMode} + if manifest, ok := paymentManifestForProvider(descriptor.ID); ok && len(manifest.Contributes.PaymentProviders) > 0 { + view.CloseAfterMinutes = manifest.Contributes.PaymentProviders[0].ExpiryPolicy.DefaultMinutes + } + state, err := s.pluginStateForUser(nil, descriptor.PluginID, s.Plugins()) + if err == nil { + view.PluginEnabled = state.PlatformAvailable + view.Enabled = state.PlatformAvailable + } + config, configErr := s.repo.LatestPaymentProviderConfig(descriptor.ID) + if errors.Is(configErr, gorm.ErrRecordNotFound) { + return view, nil, nil + } + if configErr != nil { + return view, nil, configErr + } + view.Configured = strings.TrimSpace(config.ConfigCipher) != "" + view.Enabled = view.Enabled && config.Enabled + view.CloseAfterMinutes = config.CloseAfterMinutes + return view, config, nil +} + +func paymentManifestForProvider(providerID string) (protocol.Manifest, bool) { + for _, manifest := range bundledPaymentPluginManifests() { + for _, contribution := range manifest.Contributes.PaymentProviders { + if contribution.ID == providerID { + return manifest, true + } + } + } + return protocol.Manifest{}, false +} + +func (s *Service) decryptPaymentConfig(config *model.PaymentProviderConfig) (payment.Config, error) { + if config == nil { + return nil, errors.New("支付渠道配置不存在") + } + plain, err := s.decryptSettingSecret(config.ConfigCipher) + if err != nil { + return nil, err + } + values := make(payment.Config) + if err := json.Unmarshal([]byte(plain), &values); err != nil { + return nil, errors.New("支付渠道配置内容无效") + } + return values, nil +} + +func validatePaymentPublicBaseURL(value string) error { + parsed, err := url.Parse(strings.TrimRight(strings.TrimSpace(value), "/")) + if err != nil || (parsed.Scheme != "https" && parsed.Scheme != "http") || parsed.Host == "" || parsed.User != nil || parsed.Fragment != "" || parsed.RawQuery != "" || (parsed.Path != "" && parsed.Path != "/") { + return errors.New("服务器公网地址必须是有效的 HTTP(S) 根地址") + } + return nil +} + +func (s *Service) TopupProducts(actor *model.User) ([]model.TopupProduct, error) { + if actor == nil { + return nil, Unauthorized("请先登录") + } + if err := s.RequireFeature(FeatureCredits); err != nil { + return nil, err + } + return s.repo.TopupProducts(false) +} + +func (s *Service) AdminTopupProducts(actor *model.User) ([]model.TopupProduct, error) { + if err := s.RequireAdmin(actor); err != nil { + return nil, err + } + return s.repo.TopupProducts(true) +} + +func (s *Service) CreateTopupProduct(actor *model.User, request TopupProductRequest) (*model.TopupProduct, error) { + if err := s.RequireAdmin(actor); err != nil { + return nil, err + } + product, err := topupProductFromRequest(newID(), actor.ID, request) + if err != nil { + return nil, err + } + if err := s.repo.CreateTopupProduct(product); err != nil { + return nil, err + } + if err := s.appendAdminAudit(actor, "topup_product.create", "topup_product", product.ID, "创建积分充值商品", map[string]any{"amountFen": product.AmountFen, "creditsMicrocredits": product.CreditsMicrocredits}); err != nil { + return nil, err + } + return product, nil +} + +func (s *Service) UpdateTopupProduct(actor *model.User, id string, request TopupProductRequest) (*model.TopupProduct, error) { + if err := s.RequireAdmin(actor); err != nil { + return nil, err + } + if _, err := s.repo.TopupProduct(id); err != nil { + return nil, err + } + product, err := topupProductFromRequest(strings.TrimSpace(id), actor.ID, request) + if err != nil { + return nil, err + } + if err := s.repo.UpdateTopupProduct(product); err != nil { + return nil, err + } + if err := s.appendAdminAudit(actor, "topup_product.update", "topup_product", product.ID, "更新积分充值商品", map[string]any{"enabled": product.Enabled}); err != nil { + return nil, err + } + return s.repo.TopupProduct(product.ID) +} + +func topupProductFromRequest(id, actorID string, request TopupProductRequest) (*model.TopupProduct, error) { + name := strings.TrimSpace(request.Name) + if name == "" || len([]rune(name)) > 120 { + return nil, BadAuthRequest("充值商品名称不能为空且不能超过 120 个字符") + } + if request.AmountFen <= 0 || request.AmountFen > 100_000_000 { + return nil, BadAuthRequest("充值金额必须为 1 分至 100 万元") + } + if request.CreditsMicrocredits <= 0 || request.CreditsMicrocredits > maxTopupCreditsMicrocredits { + return nil, BadAuthRequest("充值积分必须为 0.000001 至 10 亿积分") + } + return &model.TopupProduct{ + ID: id, Name: name, Description: truncateRunes(strings.TrimSpace(request.Description), 500), + AmountFen: request.AmountFen, CreditsMicrocredits: request.CreditsMicrocredits, + Enabled: request.Enabled, SortOrder: request.SortOrder, CreatedBy: actorID, UpdatedBy: actorID, + }, nil +} + +func (s *Service) CreatePaymentOrder(ctx context.Context, actor *model.User, request CreatePaymentOrderRequest) (*PaymentOrderView, error) { + if actor == nil { + return nil, Unauthorized("请先登录") + } + if err := s.RequireFeature(FeatureCredits); err != nil { + return nil, err + } + idempotencyKey := strings.TrimSpace(request.IdempotencyKey) + if idempotencyKey == "" { + return nil, BadAuthRequest("支付幂等标识不能为空") + } + if len(idempotencyKey) > 120 { + return nil, BadAuthRequest("支付幂等标识过长") + } + productID := strings.TrimSpace(request.ProductID) + providerID := strings.TrimSpace(request.ProviderID) + if existing, err := s.repo.PaymentOrderByIdempotency(actor.ID, idempotencyKey); err == nil { + if existing.ProductID != productID || existing.ProviderID != providerID { + return nil, NewAppError(http.StatusConflict, "支付幂等标识已用于不同的商品或支付渠道") + } + result := paymentOrderView(*existing) + return &result, nil + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + product, err := s.repo.TopupProduct(productID) + if err != nil || !product.Enabled { + return nil, BadAuthRequest("充值商品不存在或已停用") + } + provider, ok := s.paymentRegistry.Get(providerID) + if !ok { + return nil, BadAuthRequest("未知支付渠道") + } + view, config, err := s.paymentProviderView(provider.Descriptor()) + if err != nil { + return nil, err + } + if !view.Enabled || !view.Configured || config == nil { + return nil, Forbidden("支付渠道未启用或尚未配置") + } + activeCount, err := s.repo.ActivePaymentOrderCount(actor.ID) + if err != nil { + return nil, err + } + if activeCount >= maxActivePaymentOrdersPerUser { + return nil, NewAppError(http.StatusConflict, "未支付订单过多,请先完成或关闭已有订单") + } + now := time.Now() + order := &model.PaymentOrder{ + ID: newID(), UserID: actor.ID, IdempotencyKey: idempotencyKey, MerchantOrderNo: newID(), + ProductID: product.ID, ProductName: product.Name, ProviderID: provider.Descriptor().ID, + PluginID: provider.Descriptor().PluginID, ProviderConfigID: config.ID, ProviderConfigVersion: config.Version, + AmountFen: product.AmountFen, Currency: "CNY", CreditsMicrocredits: product.CreditsMicrocredits, + Status: model.PaymentOrderCreated, CheckoutMode: provider.Descriptor().CheckoutMode, + ExpiresAt: now.Add(time.Duration(config.CloseAfterMinutes) * time.Minute), + } + order, created, err := s.repo.CreatePaymentOrder(order) + if err != nil { + return nil, err + } + if !created { + view := paymentOrderView(*order) + return &view, nil + } + values, err := s.decryptPaymentConfig(config) + if err != nil { + _ = s.repo.SetPaymentOrderCreateFailure(order.ID, safePaymentError(err)) + return nil, err + } + baseURL := strings.TrimRight(values["publicBaseUrl"], "/") + checkout, err := provider.CreateOrder(ctx, values, payment.CreateRequest{ + MerchantOrderNo: order.MerchantOrderNo, Description: product.Name, AmountFen: order.AmountFen, + Currency: order.Currency, ExpiresAt: order.ExpiresAt, + NotifyURL: baseURL + "/api/payments/notify/" + url.PathEscape(order.ProviderID) + "/" + url.PathEscape(config.ID), + ReturnURL: baseURL + "/api/payments/return/alipay?orderId=" + url.QueryEscape(order.ID), + }) + if err != nil { + _ = s.repo.SetPaymentOrderCreateFailure(order.ID, safePaymentError(err)) + return nil, WrapAppError(http.StatusBadGateway, "支付渠道下单失败,请稍后重试", err) + } + if err := s.repo.SetPaymentOrderCheckout(order.ID, checkout.Mode, checkout.Value, checkout.ExpiresAt); err != nil { + _ = s.repo.SetPaymentOrderCreateFailure(order.ID, safePaymentError(err)) + return nil, err + } + order, err = s.repo.PaymentOrder(order.ID) + if err != nil { + return nil, err + } + result := paymentOrderView(*order) + return &result, nil +} + +func (s *Service) PaymentOrder(actor *model.User, id string) (*PaymentOrderView, error) { + if actor == nil { + return nil, Unauthorized("请先登录") + } + order, err := s.repo.PaymentOrderForUser(actor.ID, id) + if err != nil { + return nil, err + } + view := paymentOrderView(*order) + return &view, nil +} + +func (s *Service) PaymentCheckout(actor *model.User, id string) (string, error) { + if actor == nil { + return "", Unauthorized("请先登录") + } + order, err := s.repo.PaymentOrderForUser(actor.ID, id) + if err != nil { + return "", err + } + if order.Status != model.PaymentOrderPending || order.CheckoutMode != "redirect" || strings.TrimSpace(order.CheckoutValue) == "" || !order.ExpiresAt.After(time.Now()) { + return "", BadAuthRequest("支付订单当前不能跳转收银台") + } + return order.CheckoutValue, nil +} + +// RefreshPaymentCheckout reuses the original merchant order number and order +// snapshot. It always queries the provider before rebuilding a checkout so an +// ambiguous create response cannot turn into a duplicate payment attempt. +func (s *Service) RefreshPaymentCheckout(ctx context.Context, actor *model.User, id string) (*PaymentOrderView, error) { + if actor == nil { + return nil, Unauthorized("请先登录") + } + order, err := s.repo.PaymentOrderForUser(actor.ID, id) + if err != nil { + return nil, err + } + if (order.CheckoutMode != "qr_code" && order.CheckoutMode != "redirect") || (order.Status != model.PaymentOrderPending && order.Status != model.PaymentOrderCreateFailed) || !order.ExpiresAt.After(time.Now()) { + return nil, BadAuthRequest("支付订单当前不能刷新收银台") + } + provider, config, values, err := s.paymentRuntimeForOrder(order) + if err != nil { + return nil, err + } + providerResult, queryErr := provider.QueryOrder(ctx, values, payment.QueryRequest{MerchantOrderNo: order.MerchantOrderNo}) + if queryErr == nil { + if strings.TrimSpace(providerResult.MerchantOrderNo) != order.MerchantOrderNo { + return nil, repository.ErrPaymentEvidenceMismatch + } + _ = s.repo.RecordPaymentQuery(order.ID, providerResult.ProviderStatus) + updated, applyErr := s.applyPaymentResult(order.ProviderID, providerResult) + if applyErr != nil { + return nil, applyErr + } + if providerResult.Paid || providerResult.Closed { + view := paymentOrderView(*updated) + return &view, nil + } + } else if !errors.Is(queryErr, payment.ErrOrderNotFound) { + return nil, WrapAppError(http.StatusBadGateway, "刷新支付收银台前查单失败,请稍后重试", queryErr) + } + baseURL := strings.TrimRight(values["publicBaseUrl"], "/") + checkout, err := provider.CreateOrder(ctx, values, payment.CreateRequest{ + MerchantOrderNo: order.MerchantOrderNo, Description: order.ProductName, AmountFen: order.AmountFen, + Currency: order.Currency, ExpiresAt: order.ExpiresAt, + NotifyURL: baseURL + "/api/payments/notify/" + url.PathEscape(order.ProviderID) + "/" + url.PathEscape(config.ID), + ReturnURL: baseURL + "/api/payments/return/alipay?orderId=" + url.QueryEscape(order.ID), + }) + if err != nil { + return nil, WrapAppError(http.StatusBadGateway, "刷新支付收银台失败,请稍后重试", err) + } + if err := s.repo.SetPaymentOrderCheckout(order.ID, checkout.Mode, checkout.Value, checkout.ExpiresAt); err != nil { + return nil, err + } + order, err = s.repo.PaymentOrder(order.ID) + if err != nil { + return nil, err + } + view := paymentOrderView(*order) + return &view, nil +} + +func (s *Service) QueryPaymentOrder(ctx context.Context, actor *model.User, id string) (*PaymentOrderView, error) { + if actor == nil { + return nil, Unauthorized("请先登录") + } + order, err := s.repo.PaymentOrderForUser(actor.ID, id) + if err != nil { + return nil, err + } + if order.Status == model.PaymentOrderCredited || order.Status == model.PaymentOrderClosed { + view := paymentOrderView(*order) + return &view, nil + } + if order.LastQueriedAt != nil && time.Since(*order.LastQueriedAt) < 2*time.Second { + view := paymentOrderView(*order) + return &view, nil + } + if err := s.queryPaymentOrder(ctx, order); err != nil { + return nil, err + } + order, err = s.repo.PaymentOrder(order.ID) + if err != nil { + return nil, err + } + view := paymentOrderView(*order) + return &view, nil +} + +func (s *Service) ClosePaymentOrder(ctx context.Context, actor *model.User, id string) (*PaymentOrderView, error) { + if actor == nil { + return nil, Unauthorized("请先登录") + } + order, err := s.repo.PaymentOrderForUser(actor.ID, id) + if err != nil { + return nil, err + } + if order.Status == model.PaymentOrderCredited || order.Status == model.PaymentOrderClosed { + view := paymentOrderView(*order) + return &view, nil + } + if err := s.closePaymentOrder(ctx, order); err != nil { + return nil, err + } + order, err = s.repo.PaymentOrder(order.ID) + if err != nil { + return nil, err + } + view := paymentOrderView(*order) + return &view, nil +} + +func (s *Service) AcceptPaymentNotification(ctx context.Context, providerID, configID string, headers http.Header, rawBody []byte) error { + provider, ok := s.paymentRegistry.Get(providerID) + if !ok { + return BadAuthRequest("未知支付通知渠道") + } + config, err := s.repo.PaymentProviderConfig(configID) + if err != nil || config.ProviderID != providerID { + return BadAuthRequest("支付通知配置版本不存在") + } + values, err := s.decryptPaymentConfig(config) + if err != nil { + return err + } + notification, err := provider.VerifyNotification(ctx, values, headers, rawBody) + if err != nil { + return BadAuthRequest("支付通知验签失败") + } + order, err := s.repo.PaymentOrderByMerchant(providerID, notification.MerchantOrderNo) + if err != nil || order.ProviderConfigID != config.ID { + return BadAuthRequest("支付通知订单不存在或配置版本不匹配") + } + normalized, err := json.Marshal(notification.Result) + if err != nil { + return err + } + payloadCipher, err := s.encryptSettingSecret(string(rawBody)) + if err != nil { + return err + } + digest := sha256.Sum256(rawBody) + inbox := &model.PaymentNotification{ + ID: newID(), ProviderID: providerID, ProviderEventID: truncateRunes(notification.EventID, 160), + ProviderConfigID: config.ID, MerchantOrderNo: order.MerchantOrderNo, PaymentOrderID: order.ID, + PayloadDigest: hex.EncodeToString(digest[:]), PayloadCipher: payloadCipher, NormalizedJSON: string(normalized), + Status: model.PaymentNotificationPending, NextAttemptAt: time.Now(), + } + created, err := s.repo.SaveVerifiedPaymentNotification(inbox) + if err != nil || !created { + return err + } + // Keep the callback path fast but try once immediately. A durable inbox row + // remains for the worker if the credit transaction cannot complete now. + if err := s.processPaymentNotification(inbox); err != nil { + _ = s.repo.RetryPaymentNotification(inbox.ID, safePaymentError(err), time.Now().Add(5*time.Second)) + } + return nil +} + +func (s *Service) processPaymentNotification(notification *model.PaymentNotification) error { + var result payment.Result + if err := json.Unmarshal([]byte(notification.NormalizedJSON), &result); err != nil { + return err + } + if !result.Paid { + return errors.New("支付通知不是成功状态") + } + if _, err := s.applyPaymentResult(notification.ProviderID, result); err != nil { + return err + } + return s.repo.CompletePaymentNotification(notification.ID) +} + +func (s *Service) queryPaymentOrder(ctx context.Context, order *model.PaymentOrder) error { + provider, config, values, err := s.paymentRuntimeForOrder(order) + if err != nil { + return err + } + _ = config + result, err := provider.QueryOrder(ctx, values, payment.QueryRequest{MerchantOrderNo: order.MerchantOrderNo}) + if errors.Is(err, payment.ErrOrderNotFound) { + _ = s.repo.RecordPaymentQuery(order.ID, "NOT_FOUND") + return nil + } + if err != nil { + return WrapAppError(http.StatusBadGateway, "支付渠道查单失败,请稍后重试", err) + } + if strings.TrimSpace(result.MerchantOrderNo) != order.MerchantOrderNo { + return repository.ErrPaymentEvidenceMismatch + } + _ = s.repo.RecordPaymentQuery(order.ID, result.ProviderStatus) + _, err = s.applyPaymentResult(order.ProviderID, result) + return err +} + +func (s *Service) closePaymentOrder(ctx context.Context, order *model.PaymentOrder) error { + provider, _, values, err := s.paymentRuntimeForOrder(order) + if err != nil { + return err + } + queryNotFound := false + result, queryErr := provider.QueryOrder(ctx, values, payment.QueryRequest{MerchantOrderNo: order.MerchantOrderNo}) + if queryErr == nil { + if strings.TrimSpace(result.MerchantOrderNo) != order.MerchantOrderNo { + return repository.ErrPaymentEvidenceMismatch + } + _ = s.repo.RecordPaymentQuery(order.ID, result.ProviderStatus) + if _, err := s.applyPaymentResult(order.ProviderID, result); err != nil { + return err + } + if result.Paid || result.Closed { + return nil + } + } else if errors.Is(queryErr, payment.ErrOrderNotFound) { + queryNotFound = true + } else { + return WrapAppError(http.StatusBadGateway, "关单前查单失败,请稍后重试", queryErr) + } + closed, err := provider.CloseOrder(ctx, values, payment.CloseRequest{MerchantOrderNo: order.MerchantOrderNo}) + if err == nil { + if closed.MerchantOrderNo == "" { + closed.MerchantOrderNo = order.MerchantOrderNo + } + if strings.TrimSpace(closed.MerchantOrderNo) != order.MerchantOrderNo { + return repository.ErrPaymentEvidenceMismatch + } + if closed.Paid || closed.Closed { + _, applyErr := s.applyPaymentResult(order.ProviderID, closed) + return applyErr + } + return s.repo.MarkPaymentOrderClosed(order.ID, closed.ProviderStatus) + } + + // A payment can complete after the first query and immediately before the + // close request. Query once more on every close failure so a successful + // payment is credited instead of being left in a closing retry loop. + recheck, recheckErr := provider.QueryOrder(ctx, values, payment.QueryRequest{MerchantOrderNo: order.MerchantOrderNo}) + if recheckErr == nil { + if strings.TrimSpace(recheck.MerchantOrderNo) != order.MerchantOrderNo { + return repository.ErrPaymentEvidenceMismatch + } + _ = s.repo.RecordPaymentQuery(order.ID, recheck.ProviderStatus) + if _, applyErr := s.applyPaymentResult(order.ProviderID, recheck); applyErr != nil { + return applyErr + } + if recheck.Paid || recheck.Closed { + return nil + } + } + if queryNotFound && errors.Is(err, payment.ErrOrderNotFound) && errors.Is(recheckErr, payment.ErrOrderNotFound) { + return s.repo.MarkPaymentOrderClosed(order.ID, "NOT_FOUND") + } + return WrapAppError(http.StatusBadGateway, "支付渠道关单失败,请稍后重试", err) +} + +func (s *Service) applyPaymentResult(providerID string, result payment.Result) (*model.PaymentOrder, error) { + order, err := s.repo.PaymentOrderByMerchant(providerID, result.MerchantOrderNo) + if err != nil { + return nil, err + } + if result.Paid { + if result.AmountFen != order.AmountFen || result.Currency != order.Currency { + return nil, repository.ErrPaymentEvidenceMismatch + } + completed, _, err := s.repo.CompletePaymentOrder(providerID, result.MerchantOrderNo, repository.PaymentEvidence{ + ProviderTradeNo: result.ProviderTradeNo, ProviderStatus: result.ProviderStatus, + AmountFen: result.AmountFen, Currency: result.Currency, PaidAt: result.PaidAt, + }) + return completed, err + } + if result.Closed { + if err := s.repo.MarkPaymentOrderClosed(order.ID, result.ProviderStatus); err != nil { + return nil, err + } + return s.repo.PaymentOrder(order.ID) + } + return order, nil +} + +func (s *Service) paymentRuntimeForOrder(order *model.PaymentOrder) (payment.Provider, *model.PaymentProviderConfig, payment.Config, error) { + if order == nil { + return nil, nil, nil, errors.New("支付订单不存在") + } + provider, ok := s.paymentRegistry.Get(order.ProviderID) + if !ok { + return nil, nil, nil, errors.New("支付宿主适配器不存在") + } + config, err := s.repo.PaymentProviderConfig(order.ProviderConfigID) + if err != nil { + return nil, nil, nil, err + } + if config.ProviderID != order.ProviderID || config.PluginID != order.PluginID || config.Version != order.ProviderConfigVersion { + return nil, nil, nil, repository.ErrPaymentOrderStateConflict + } + values, err := s.decryptPaymentConfig(config) + return provider, config, values, err +} + +func paymentOrderView(order model.PaymentOrder) PaymentOrderView { + providerTradeNo := "" + if order.ProviderTradeNo != nil { + providerTradeNo = *order.ProviderTradeNo + } + checkout := PaymentCheckoutView{Mode: order.CheckoutMode, ExpiresAt: order.CheckoutExpiresAt} + if order.Status == model.PaymentOrderPending && order.ExpiresAt.After(time.Now()) { + if order.CheckoutMode == "qr_code" { + checkout.Value = order.CheckoutValue + } else if order.CheckoutMode == "redirect" { + checkout.URL = "/api/payments/orders/" + url.PathEscape(order.ID) + "/checkout" + } + } + return PaymentOrderView{ + ID: order.ID, UserID: order.UserID, MerchantOrderNo: order.MerchantOrderNo, ProductID: order.ProductID, ProductName: order.ProductName, + ProviderID: order.ProviderID, AmountFen: order.AmountFen, Currency: order.Currency, + CreditsMicrocredits: order.CreditsMicrocredits, Status: order.Status, ProviderStatus: order.ProviderStatus, + ProviderTradeNo: providerTradeNo, Checkout: checkout, ExpiresAt: order.ExpiresAt, + ProviderPaidAt: order.ProviderPaidAt, CreditedAt: order.CreditedAt, ClosedAt: order.ClosedAt, + CreatedAt: order.CreatedAt, UpdatedAt: order.UpdatedAt, + } +} + +func (s *Service) AdminPaymentOrderPage(actor *model.User, status, keyword string, page, limit int) (*AdminPaymentOrderPage, error) { + if err := s.RequireAdmin(actor); err != nil { + return nil, err + } + page, limit = normalizeAdminPage(page, limit) + orders, total, err := s.repo.AdminPaymentOrders(status, keyword, limit, (page-1)*limit) + if err != nil { + return nil, err + } + views := make([]PaymentOrderView, 0, len(orders)) + for _, order := range orders { + views = append(views, paymentOrderView(order)) + } + return &AdminPaymentOrderPage{Orders: views, Total: total, Page: page, Limit: limit}, nil +} + +func (s *Service) AdminQueryPaymentOrder(ctx context.Context, actor *model.User, id string) (*PaymentOrderView, error) { + if err := s.RequireAdmin(actor); err != nil { + return nil, err + } + order, err := s.repo.PaymentOrder(id) + if err != nil { + return nil, err + } + if order.Status == model.PaymentOrderCredited || order.Status == model.PaymentOrderClosed { + view := paymentOrderView(*order) + return &view, nil + } + if err := s.queryPaymentOrder(ctx, order); err != nil { + return nil, err + } + order, err = s.repo.PaymentOrder(id) + if err != nil { + return nil, err + } + view := paymentOrderView(*order) + return &view, nil +} + +func (s *Service) AdminClosePaymentOrder(ctx context.Context, actor *model.User, id string) (*PaymentOrderView, error) { + if err := s.RequireAdmin(actor); err != nil { + return nil, err + } + order, err := s.repo.PaymentOrder(id) + if err != nil { + return nil, err + } + if order.Status == model.PaymentOrderCredited || order.Status == model.PaymentOrderClosed { + view := paymentOrderView(*order) + return &view, nil + } + if err := s.closePaymentOrder(ctx, order); err != nil { + return nil, err + } + order, err = s.repo.PaymentOrder(id) + if err != nil { + return nil, err + } + view := paymentOrderView(*order) + return &view, nil +} + +func (s *Service) startPaymentWorker(ctx context.Context) { + s.runWorkerLoop(func(ctx context.Context) { + s.drainPaymentNotifications() + notificationTicker := time.NewTicker(15 * time.Second) + defer notificationTicker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-notificationTicker.C: + s.drainPaymentNotifications() + } + } + }) + s.runWorkerLoop(func(ctx context.Context) { + s.reconcileExpiredPaymentOrders(ctx) + orderTicker := time.NewTicker(15 * time.Second) + defer orderTicker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-orderTicker.C: + s.reconcileExpiredPaymentOrders(ctx) + s.queryPendingPaymentOrders(ctx) + } + } + }) + s.runWorkerLoop(func(ctx context.Context) { + s.maybeRunDailyPaymentReconciliation(ctx) + reconciliationTicker := time.NewTicker(30 * time.Minute) + defer reconciliationTicker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-reconciliationTicker.C: + s.maybeRunDailyPaymentReconciliation(ctx) + } + } + }) +} + +func (s *Service) drainPaymentNotifications() { + items, err := s.repo.PendingPaymentNotifications(32) + if err != nil { + log.Printf("payment notification query failed: %v", err) + return + } + for index := range items { + item := &items[index] + if err := s.processPaymentNotification(item); err != nil { + delay := time.Duration(math.Pow(2, math.Min(float64(item.Attempts), 8))) * 5 * time.Second + _ = s.repo.RetryPaymentNotification(item.ID, safePaymentError(err), time.Now().Add(delay)) + } + } +} + +func (s *Service) reconcileExpiredPaymentOrders(ctx context.Context) { + orders, err := s.repo.ClaimExpiredPaymentOrders(32) + if err != nil { + log.Printf("expired payment order claim failed: %v", err) + return + } + for index := range orders { + order := &orders[index] + operationContext, cancel := context.WithTimeout(ctx, 45*time.Second) + err := s.closePaymentOrder(operationContext, order) + cancel() + if err != nil { + _ = s.repo.RestoreClosingPaymentOrder(order.ID, safePaymentError(err)) + } + } +} + +func (s *Service) queryPendingPaymentOrders(ctx context.Context) { + orders, err := s.repo.PaymentOrdersNeedingQuery(time.Now().Add(-30*time.Second), 32) + if err != nil { + log.Printf("pending payment order query failed: %v", err) + return + } + for index := range orders { + operationContext, cancel := context.WithTimeout(ctx, 20*time.Second) + err := s.queryPaymentOrder(operationContext, &orders[index]) + cancel() + if err != nil { + log.Printf("payment order compensation query failed: order=%s error_type=%T", orders[index].ID, err) + } + } +} + +func safePaymentError(err error) string { + if err == nil { + return "" + } + var providerErr *payment.ProviderError + if errors.As(err, &providerErr) && providerErr.Code != "" { + return truncateRunes(providerErr.Code, 1000) + } + return truncateRunes(fmt.Sprintf("%T", err), 1000) +} diff --git a/backend/internal/service/payment_plugins.go b/backend/internal/service/payment_plugins.go new file mode 100644 index 000000000..54c96e94d --- /dev/null +++ b/backend/internal/service/payment_plugins.go @@ -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"}, + }} +} diff --git a/backend/internal/service/payment_plugins_test.go b/backend/internal/service/payment_plugins_test.go new file mode 100644 index 000000000..472c43ad4 --- /dev/null +++ b/backend/internal/service/payment_plugins_test.go @@ -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) + } +} diff --git a/backend/internal/service/payment_reconciliation.go b/backend/internal/service/payment_reconciliation.go new file mode 100644 index 000000000..2a31cf0d9 --- /dev/null +++ b/backend/internal/service/payment_reconciliation.go @@ -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() + } +} diff --git a/backend/internal/service/plugin_management.go b/backend/internal/service/plugin_management.go index e55321037..52b8f8c7c 100644 --- a/backend/internal/service/plugin_management.go +++ b/backend/internal/service/plugin_management.go @@ -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 { diff --git a/backend/internal/service/protocol_plugins.go b/backend/internal/service/protocol_plugins.go index a1d386024..e12111d4c 100644 --- a/backend/internal/service/protocol_plugins.go +++ b/backend/internal/service/protocol_plugins.go @@ -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 { diff --git a/backend/internal/service/protocol_registry.go b/backend/internal/service/protocol_registry.go index 601d52b87..65473cf34 100644 --- a/backend/internal/service/protocol_registry.go +++ b/backend/internal/service/protocol_registry.go @@ -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 diff --git a/backend/internal/service/protocol_runtime_test.go b/backend/internal/service/protocol_runtime_test.go index 896b9cf76..89e431399 100644 --- a/backend/internal/service/protocol_runtime_test.go +++ b/backend/internal/service/protocol_runtime_test.go @@ -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 { diff --git a/backend/internal/service/service.go b/backend/internal/service/service.go index 38d6e57bf..41d87e3cc 100644 --- a/backend/internal/service/service.go +++ b/backend/internal/service/service.go @@ -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() } diff --git a/web/src/lib/plugins/plugin-types.ts b/web/src/lib/plugins/plugin-types.ts index e60134a45..ac5da4409 100644 --- a/web/src/lib/plugins/plugin-types.ts +++ b/web/src/lib/plugins/plugin-types.ts @@ -2,8 +2,8 @@ import type { Asset } from "@/stores/use-asset-store"; export const PLUGIN_API_VERSION = "yingce.plugin/v1" as const; -export type PluginContributionKind = "provider" | "workflow" | "canvas-node" | "transform" | "command" | "asset-source" | "usage-observer" | "ai-capability" | "agent" | "import-export"; -export type PluginSurface = "node" | "fullscreen" | "hybrid" | "asset-source" | "settings"; +export type PluginContributionKind = "provider" | "payment-provider" | "workflow" | "canvas-node" | "transform" | "command" | "asset-source" | "usage-observer" | "ai-capability" | "agent" | "import-export"; +export type PluginSurface = "node" | "fullscreen" | "hybrid" | "asset-source" | "settings" | "wallet"; export type ProtocolCapability = "text" | "image" | "video" | "audio"; export type ProtocolScope = "admin.system-channel" | "user.custom-channel" | "canvas" | "creation" | "agent" | string; export type PluginRuntime = "declarative" | "sandbox" | "worker" | "trusted-backend"; @@ -60,6 +60,13 @@ export type PluginWorkflowContribution = { parameters: PluginParameter[]; defaults?: Record; }; +export type PluginPaymentProviderContribution = { + id: string; + label: string; + icon: string; + checkoutMode: "qr_code" | "redirect"; + expiryPolicy: { defaultMinutes: number; minMinutes: number; maxMinutes: number }; +}; export type PluginCanvasNodeContribution = { id: string; label: string; @@ -76,6 +83,7 @@ export type PluginTransformContribution = { }; export type PluginContributions = { providers?: PluginProviderContribution[]; + paymentProviders?: PluginPaymentProviderContribution[]; workflows?: PluginWorkflowContribution[]; canvasNodes?: PluginCanvasNodeContribution[]; transforms?: PluginTransformContribution[]; @@ -97,6 +105,10 @@ export type PluginPermission = | "ai.text" | "media.read" | "usage.read" + | "payment.create" + | "payment.query" + | "payment.close" + | "payment.reconcile" | "external.open"; export type PluginManifest = { @@ -124,9 +136,7 @@ export type PluginStorage = { remove(key: string): Promise; }; -export type PluginTextContentPart = - | { type: "text"; text: string } - | { type: "image_url"; image_url: { url: string } }; +export type PluginTextContentPart = { type: "text"; text: string } | { type: "image_url"; image_url: { url: string } }; export type PluginTextMessage = { role: "system" | "user" | "assistant"; @@ -218,10 +228,7 @@ export type PromptOptimizationResult = { }; export type PromptOptimizerProvider = { - optimize: ( - input: PromptOptimizationInput, - options?: { signal?: AbortSignal; onDelta?: (text: string) => void }, - ) => Promise; + optimize: (input: PromptOptimizationInput, options?: { signal?: AbortSignal; onDelta?: (text: string) => void }) => Promise; }; export type AssetSourceQuery = { diff --git a/web/src/pages/admin/components/admin-shell.tsx b/web/src/pages/admin/components/admin-shell.tsx index d7c8a84ed..57eb91f9c 100644 --- a/web/src/pages/admin/components/admin-shell.tsx +++ b/web/src/pages/admin/components/admin-shell.tsx @@ -9,6 +9,7 @@ import { ChevronDown, CloudUpload, Coins, + CreditCard, Database, FileClock, HardDrive, @@ -71,6 +72,7 @@ const adminNavigation: Array<{ label: string; items: AdminNavigationItem[] }> = label: "运营", items: [ { path: "/admin/announcements", label: "系统公告", description: "发布、关闭与历史公告", icon: }, + { path: "/admin/payments", label: "支付充值", description: "支付渠道、订单与对账", icon: }, { path: "/admin/credit-operations", label: "积分运营", description: "人工调账与异常计费", icon: }, { path: "/admin/redemption-codes", label: "兑换码", description: "生成与查看兑换码批次", icon: }, { path: "/admin/logs", label: "请求明细", description: "上游调用与费用", icon: }, diff --git a/web/src/pages/admin/payments/payments-page.css b/web/src/pages/admin/payments/payments-page.css new file mode 100644 index 000000000..0b3d10c21 --- /dev/null +++ b/web/src/pages/admin/payments/payments-page.css @@ -0,0 +1,17 @@ +.admin-list-toolbar .ant-btn.admin-toolbar-primary-action { + border-color: var(--btn-solid-bg) !important; + background: var(--btn-solid-bg) !important; + color: var(--btn-solid-fg) !important; +} + +.admin-list-toolbar .ant-btn.admin-toolbar-primary-action:not(:disabled):hover { + border-color: var(--btn-solid-hover-bg) !important; + background: var(--btn-solid-hover-bg) !important; + color: var(--btn-solid-fg) !important; +} + +.admin-list-toolbar .ant-btn.admin-toolbar-primary-action:not(:disabled):active { + border-color: var(--btn-solid-active-bg) !important; + background: var(--btn-solid-active-bg) !important; + color: var(--btn-solid-fg) !important; +} diff --git a/web/src/pages/admin/payments/payments-page.tsx b/web/src/pages/admin/payments/payments-page.tsx new file mode 100644 index 000000000..a779c425e --- /dev/null +++ b/web/src/pages/admin/payments/payments-page.tsx @@ -0,0 +1,747 @@ +import { AlipayCircleFilled, WechatFilled } from "@ant-design/icons"; +import { Alert, App, Button, DatePicker, Drawer, Form, Input, InputNumber, Select, Switch, Tabs } from "antd"; +import type { ColumnsType } from "antd/es/table"; +import dayjs, { type Dayjs } from "dayjs"; +import { Eye, Plus, RefreshCw, Search, Settings2, XCircle } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; + +import { PaginationBar } from "@/components/layout/workspace-page"; +import { formatCredits } from "@/constant/credits"; +import { + closeAdminPaymentOrder, + createAdminTopupProduct, + listAdminPaymentOrders, + listAdminPaymentProviders, + listAdminPaymentReconciliationItems, + listAdminPaymentReconciliations, + listAdminTopupProducts, + queryAdminPaymentOrder, + runAdminPaymentReconciliation, + updateAdminPaymentProvider, + updateAdminTopupProduct, + type AdminPaymentProvider, + type PaymentOrder, + type PaymentReconciliationItem, + type PaymentReconciliationRun, + type TopupProduct, +} from "@/services/api/payments"; + +import { AdminPageFrame } from "../components/admin-shell"; +import { AdminDataTable, AdminStatusBadge, AdminTableEmpty, configuredSecretText } from "../components/admin-ui"; +import "./payments-page.css"; + +type ProviderFormValues = { + enabled: boolean; + closeAfterMinutes: number; + values: Record; +}; + +type ProductFormValues = { + name: string; + description?: string; + amountYuan: number; + credits: number; + enabled: boolean; + sortOrder: number; +}; + +const paymentOrderStatus: Record = { + created: { label: "创建中", tone: "info" }, + pending: { label: "待支付", tone: "warning" }, + closing: { label: "关单中", tone: "warning" }, + closed: { label: "已关闭", tone: "neutral" }, + credited: { label: "已入账", tone: "success" }, + create_failed: { label: "下单失败", tone: "error" }, +}; + +const reconciliationResult: Record = { + matched: { label: "一致", tone: "success" }, + recovered: { label: "已自动补发", tone: "info" }, + local_order_not_found: { label: "本地订单缺失", tone: "error" }, + provider_record_missing: { label: "渠道记录缺失", tone: "error" }, + amount_mismatch: { label: "金额不一致", tone: "error" }, + trade_no_mismatch: { label: "交易号不一致", tone: "error" }, + credit_failed: { label: "补发失败", tone: "error" }, +}; + +export default function AdminPaymentsPage() { + const { message, modal } = App.useApp(); + const [activeTab, setActiveTab] = useState("providers"); + const [providers, setProviders] = useState([]); + const [products, setProducts] = useState([]); + const [loading, setLoading] = useState(true); + + const [providerDrawer, setProviderDrawer] = useState(); + const [providerSaving, setProviderSaving] = useState(false); + const [providerForm] = Form.useForm(); + + const [productDrawer, setProductDrawer] = useState(); + const [productSaving, setProductSaving] = useState(false); + const [productForm] = Form.useForm(); + + const [orders, setOrders] = useState([]); + const [orderTotal, setOrderTotal] = useState(0); + const [orderPage, setOrderPage] = useState(1); + const [orderPageSize, setOrderPageSize] = useState(30); + const [orderStatusFilter, setOrderStatusFilter] = useState("all"); + const [orderKeyword, setOrderKeyword] = useState(""); + const [ordersLoading, setOrdersLoading] = useState(false); + const [orderActionId, setOrderActionId] = useState(""); + + const [runs, setRuns] = useState([]); + const [runTotal, setRunTotal] = useState(0); + const [runPage, setRunPage] = useState(1); + const [runPageSize, setRunPageSize] = useState(30); + const [runsLoading, setRunsLoading] = useState(false); + const [runProviderFilter, setRunProviderFilter] = useState("all"); + const [runningBill, setRunningBill] = useState(false); + const [billProviderId, setBillProviderId] = useState(""); + const [billDate, setBillDate] = useState(dayjs().subtract(1, "day")); + const [detailRun, setDetailRun] = useState(); + const [detailItems, setDetailItems] = useState([]); + const [detailTotal, setDetailTotal] = useState(0); + const [detailPage, setDetailPage] = useState(1); + const [detailPageSize, setDetailPageSize] = useState(50); + const [detailResult, setDetailResult] = useState("all"); + const [detailLoading, setDetailLoading] = useState(false); + + const loadBase = async () => { + setLoading(true); + try { + const [providerResult, productResult] = await Promise.all([listAdminPaymentProviders(), listAdminTopupProducts()]); + setProviders(providerResult.providers); + setProducts(productResult.products); + setBillProviderId((current) => current || providerResult.providers.find((item) => item.configured)?.id || providerResult.providers[0]?.id || ""); + } catch (error) { + message.error(error instanceof Error ? error.message : "读取支付配置失败"); + } finally { + setLoading(false); + } + }; + + const loadOrders = async (page = orderPage, limit = orderPageSize) => { + setOrdersLoading(true); + try { + const result = await listAdminPaymentOrders({ status: orderStatusFilter === "all" ? undefined : orderStatusFilter, keyword: orderKeyword.trim() || undefined, page, limit }); + setOrders(result.orders); + setOrderTotal(result.total); + setOrderPage(result.page); + setOrderPageSize(result.limit); + } catch (error) { + message.error(error instanceof Error ? error.message : "读取支付订单失败"); + } finally { + setOrdersLoading(false); + } + }; + + const loadRuns = async (page = runPage, limit = runPageSize) => { + setRunsLoading(true); + try { + const result = await listAdminPaymentReconciliations({ providerId: runProviderFilter === "all" ? undefined : runProviderFilter, page, limit }); + setRuns(result.runs); + setRunTotal(result.total); + setRunPage(result.page); + setRunPageSize(result.limit); + } catch (error) { + message.error(error instanceof Error ? error.message : "读取对账记录失败"); + } finally { + setRunsLoading(false); + } + }; + + useEffect(() => { + void Promise.all([loadBase(), loadOrders(1, orderPageSize), loadRuns(1, runPageSize)]); + }, []); + + const refresh = async () => { + if (activeTab === "orders") await loadOrders(); + else if (activeTab === "reconciliation") await loadRuns(); + else await loadBase(); + }; + + const openProvider = (provider: AdminPaymentProvider) => { + providerForm.resetFields(); + const configValues = { ...(provider.values || {}) }; + for (const field of provider.configFields) { + if (!configValues[field.name] && field.default !== undefined && field.default !== null) configValues[field.name] = String(field.default); + } + providerForm.setFieldsValue({ enabled: provider.configEnabled, closeAfterMinutes: provider.closeAfterMinutes || 30, values: configValues }); + setProviderDrawer(provider); + }; + + const saveProvider = async () => { + if (!providerDrawer) return; + const values = await providerForm.validateFields(); + setProviderSaving(true); + try { + await updateAdminPaymentProvider(providerDrawer.id, { + enabled: values.enabled, + closeAfterMinutes: values.closeAfterMinutes, + values: Object.fromEntries(Object.entries(values.values || {}).map(([key, value]) => [key, String(value || "")])), + }); + message.success(`${providerDrawer.name}配置已保存为新版本`); + setProviderDrawer(undefined); + await loadBase(); + } catch (error) { + message.error(error instanceof Error ? error.message : "保存支付渠道失败"); + } finally { + setProviderSaving(false); + } + }; + + const openProduct = (product?: TopupProduct) => { + productForm.resetFields(); + productForm.setFieldsValue( + product + ? { + name: product.name, + description: product.description, + amountYuan: product.amountFen / 100, + credits: product.creditsMicrocredits / 1_000_000, + enabled: product.enabled, + sortOrder: product.sortOrder, + } + : { enabled: true, sortOrder: products.length * 10, amountYuan: 10, credits: 10 }, + ); + setProductDrawer(product || null); + }; + + const saveProduct = async () => { + if (productDrawer === undefined) return; + const values = await productForm.validateFields(); + const input = { + name: values.name.trim(), + description: values.description?.trim(), + amountFen: Math.round(values.amountYuan * 100), + creditsMicrocredits: Math.round(values.credits * 1_000_000), + enabled: values.enabled, + sortOrder: values.sortOrder || 0, + }; + setProductSaving(true); + try { + if (productDrawer) await updateAdminTopupProduct(productDrawer.id, input); + else await createAdminTopupProduct(input); + message.success(productDrawer ? "充值商品已更新" : "充值商品已创建"); + setProductDrawer(undefined); + await loadBase(); + } catch (error) { + message.error(error instanceof Error ? error.message : "保存充值商品失败"); + } finally { + setProductSaving(false); + } + }; + + const queryOrder = async (order: PaymentOrder) => { + setOrderActionId(order.id); + try { + await queryAdminPaymentOrder(order.id); + message.success("已向支付渠道查单"); + await loadOrders(); + } catch (error) { + message.error(error instanceof Error ? error.message : "查单失败"); + } finally { + setOrderActionId(""); + } + }; + + const closeOrder = (order: PaymentOrder) => { + modal.confirm({ + title: "关闭未支付订单?", + content: "系统会先向支付渠道查单;若渠道已支付则立即入账,否则执行关单。", + okText: "查单并关单", + cancelText: "取消", + onOk: async () => { + setOrderActionId(order.id); + try { + await closeAdminPaymentOrder(order.id); + message.success("订单状态已更新"); + await loadOrders(); + } catch (error) { + message.error(error instanceof Error ? error.message : "关单失败"); + throw error; + } finally { + setOrderActionId(""); + } + }, + }); + }; + + const runReconciliation = async () => { + if (!billProviderId || !billDate) return; + setRunningBill(true); + try { + const result = await runAdminPaymentReconciliation({ providerId: billProviderId, billDate: billDate.format("YYYY-MM-DD") }); + if (result.run.status === "running") { + message.info("该渠道与账单日期的对账正在执行,请稍后刷新查看结果"); + } else { + message.success(result.run.recoveredItems ? `对账完成,自动补发 ${result.run.recoveredItems} 笔` : "对账完成"); + } + await loadRuns(1, runPageSize); + } catch (error) { + message.error(error instanceof Error ? error.message : "执行对账失败"); + await loadRuns(1, runPageSize); + } finally { + setRunningBill(false); + } + }; + + const openRunDetails = async (run: PaymentReconciliationRun, page = 1, limit = detailPageSize, result = detailResult) => { + setDetailRun(run); + setDetailLoading(true); + try { + const response = await listAdminPaymentReconciliationItems(run.id, { result: result === "all" ? undefined : result, page, limit }); + setDetailRun(response.run); + setDetailItems(response.items); + setDetailTotal(response.total); + setDetailPage(response.page); + setDetailPageSize(response.limit); + } catch (error) { + message.error(error instanceof Error ? error.message : "读取对账明细失败"); + } finally { + setDetailLoading(false); + } + }; + + const providerNames = useMemo(() => Object.fromEntries(providers.map((item) => [item.id, item.name])), [providers]); + + const providerColumns: ColumnsType = [ + { + title: "支付渠道", + key: "provider", + render: (_, provider) => ( +
+ +
+
{provider.name}
+
{provider.id}
+
+
+ ), + }, + { title: "支付方式", dataIndex: "checkoutMode", width: 120, align: "center", render: (value) => (value === "qr_code" ? "扫码支付" : "网站跳转") }, + { + title: "状态", + key: "status", + width: 180, + align: "center", + render: (_, provider) => ( +
+ + + 插件{provider.pluginEnabled ? "已开放" : "已停用"} · 配置{provider.configEnabled ? "已启用" : "已停用"} + +
+ ), + }, + { title: "未支付自动关闭", dataIndex: "closeAfterMinutes", width: 150, align: "center", render: (value) => `${value || 30} 分钟` }, + { title: "配置版本", dataIndex: "version", width: 105, align: "center", render: (value) => (value ? `v${value}` : "未配置") }, + { + title: "操作", + key: "actions", + width: 100, + align: "center", + render: (_, provider) => ( + + ), + }, + ]; + + const productColumns: ColumnsType = [ + { + title: "商品", + key: "name", + render: (_, product) => ( +
+
{product.name}
+
{product.description || "无说明"}
+
+ ), + }, + { title: "售价", dataIndex: "amountFen", width: 130, align: "right", render: (value) => ¥ {(value / 100).toFixed(2)} }, + { title: "到账积分", dataIndex: "creditsMicrocredits", width: 150, align: "right", render: (value) => {formatCredits(value)} }, + { title: "排序", dataIndex: "sortOrder", width: 90, align: "center" }, + { title: "状态", dataIndex: "enabled", width: 100, align: "center", render: (value) => }, + { + title: "操作", + key: "actions", + width: 90, + align: "center", + render: (_, product) => ( + + ), + }, + ]; + + const orderColumns: ColumnsType = [ + { + title: "订单", + key: "order", + width: 280, + render: (_, order) => ( +
+
{order.merchantOrderNo}
+
+ 用户 {order.userId || "--"} · {order.productName} +
+
+ ), + }, + { + title: "渠道", + dataIndex: "providerId", + width: 160, + render: (value) => ( + + + {providerNames[value] || value} + + ), + }, + { + title: "金额 / 积分", + key: "amount", + width: 150, + align: "right", + render: (_, order) => ( +
+
¥ {(order.amountFen / 100).toFixed(2)}
+
{formatCredits(order.creditsMicrocredits)} 积分
+
+ ), + }, + { title: "状态", dataIndex: "status", width: 110, align: "center", render: (value) => }, + { title: "创建时间", dataIndex: "createdAt", width: 170, render: (value) => formatDateTime(value) }, + { + title: "操作", + key: "actions", + width: 150, + align: "center", + render: (_, order) => ( +
+ + +
+ ), + }, + ]; + + const runColumns: ColumnsType = [ + { title: "账单日期", dataIndex: "billDate", width: 120 }, + { + title: "渠道", + dataIndex: "providerId", + width: 190, + render: (value) => ( + + + {providerNames[value] || value} + + ), + }, + { + title: "状态", + dataIndex: "status", + width: 110, + align: "center", + render: (value) => , + }, + { title: "一致", dataIndex: "matchItems", width: 80, align: "right" }, + { title: "自动补发", dataIndex: "recoveredItems", width: 100, align: "right", render: (value) => {value} }, + { title: "异常", dataIndex: "errorItems", width: 80, align: "right", render: (value) => {value} }, + { title: "完成时间", dataIndex: "completedAt", width: 170, render: (value) => (value ? formatDateTime(value) : "--") }, + { + title: "明细", + key: "actions", + width: 90, + align: "center", + render: (_, run) => ( + + ), + }, + ]; + + const detailColumns: ColumnsType = [ + { title: "结果", dataIndex: "result", width: 135, render: (value) => }, + { title: "商户订单号", dataIndex: "merchantOrderNo", width: 255, render: (value) => {value} }, + { title: "渠道交易号", dataIndex: "providerTradeNo", width: 220, render: (value) => (value ? {value} : "--") }, + { title: "金额", dataIndex: "amountFen", width: 110, align: "right", render: (value, item) => `${item.currency} ${(value / 100).toFixed(2)}` }, + { title: "说明", dataIndex: "detail", render: (value) => value || "账单与本地订单一致" }, + ]; + + return ( + } loading={loading || ordersLoading || runsLoading} onClick={() => void refresh()}> + 刷新 + + } + scroll + > + + } />, + }, + { + key: "products", + label: "充值商品", + children: ( + } + trailing={ + + } + table={{ rowKey: "id", loading, columns: productColumns, dataSource: products, pagination: false, scroll: { x: 820 } }} + empty={} + /> + ), + }, + { + key: "orders", + label: "支付订单", + children: ( + } + value={orderKeyword} + placeholder="订单号、渠道交易号或用户 ID" + onChange={(event) => setOrderKeyword(event.target.value)} + onPressEnter={() => void loadOrders(1)} + /> + } + toolbarFilters={ + ({ value: provider.id, label: provider.name, disabled: !provider.configured }))} + /> + !date.isBefore(dayjs(), "day") || date.isBefore(dayjs().subtract(3, "month"), "day")} onChange={(date) => date && setBillDate(date)} /> + + 系统每天 10:15 后自动对账昨日账单;也可在此手动重跑最近三个月账单。 + + { + setRunProviderFilter(value); + setRunPage(1); + }} + options={[{ value: "all", label: "全部支付渠道" }, ...providers.map((provider) => ({ value: provider.id, label: provider.name }))]} + /> + } + trailing={} + table={{ rowKey: "id", loading: runsLoading, columns: runColumns, dataSource: runs, pagination: false, scroll: { x: 1000 } }} + empty={} + footer={ void loadRuns(size !== runPageSize ? 1 : page, size)} />} + /> + + ), + }, + ]} + /> + + setProviderDrawer(undefined)} + extra={ + + } + > + {providerDrawer ? ( +
+ + + + + + + + {providerDrawer.configFields.map((field) => { + const secretReady = Boolean(providerDrawer.secretConfigured[field.name]); + const rules = field.required && !secretReady ? [{ required: true, message: `请输入${field.label || field.name}` }] : undefined; + const placeholder = field.secret && secretReady ? configuredSecretText : field.description || undefined; + const input = + field.type === "textarea" ? ( + + ) : field.type === "password" || field.secret ? ( + + ) : ( + + ); + return ( + + {input} + + ); + })} + + ) : null} +
+ + setProductDrawer(undefined)} + extra={ + + } + > +
+ + + + + + +
+ + + + { + const credits = Number(value); + const microcredits = Math.round(credits * 1_000_000); + return Number.isFinite(credits) && credits > 0 && credits <= 1_000_000_000 && Number.isSafeInteger(microcredits) ? Promise.resolve() : Promise.reject(new Error("请输入 0.000001 至 10 亿之间且可安全处理的积分")); + }, + }, + ]} + > + + +
+ + + + + + +
+
+ + { + setDetailRun(undefined); + setDetailItems([]); + }} + > + {detailRun?.error ? : null} + { + setDetailResult(value); + if (detailRun) void openRunDetails(detailRun, 1, detailPageSize, value); + }} + options={[{ value: "all", label: "全部结果" }, ...Object.entries(reconciliationResult).map(([value, item]) => ({ value, label: item.label }))]} + /> + } + table={{ rowKey: "id", loading: detailLoading, columns: detailColumns, dataSource: detailItems, pagination: false, scroll: { x: 950 } }} + empty={} + footer={ detailRun && void openRunDetails(detailRun, size !== detailPageSize ? 1 : page, size, detailResult)} />} + /> + +
+ ); +} + +function PaymentBrandIcon({ providerId, compact = false }: { providerId: string; compact?: boolean }) { + const size = compact ? "size-6" : "size-10"; + if (providerId === "wechat-native") + return ( + + + + ); + if (providerId === "alipay-page-pay") + return ( + + + + ); + return PAY; +} + +function formatDateTime(value: string) { + return dayjs(value).format("YYYY-MM-DD HH:mm:ss"); +} diff --git a/web/src/pages/admin/plugins/plugins-page.tsx b/web/src/pages/admin/plugins/plugins-page.tsx index e23641f99..c136cf9ae 100644 --- a/web/src/pages/admin/plugins/plugins-page.tsx +++ b/web/src/pages/admin/plugins/plugins-page.tsx @@ -1,4 +1,5 @@ import { App, Button, Input, Select, Switch } from "antd"; +import { AlipayCircleFilled, WechatFilled } from "@ant-design/icons"; import type { ColumnsType } from "antd/es/table"; import { CloudUpload, PlugZap, RefreshCw, Search, Trash2, UsersRound } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; @@ -34,7 +35,7 @@ export default function AdminPluginsPage() { const [savingId, setSavingId] = useState(""); const [uploadOpen, setUploadOpen] = useState(false); const [search, setSearch] = useState(""); - const [kind, setKind] = useState<"all" | "application" | "protocol" | "uploaded">("all"); + const [kind, setKind] = useState<"all" | "application" | "protocol" | "payment" | "uploaded">("all"); const [availability, setAvailability] = useState<"all" | "available" | "unavailable">("all"); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(20); @@ -120,6 +121,7 @@ export default function AdminPluginsPage() { const applicationCount = items.filter((item) => item.management.kind === "application").length; const protocolCount = items.filter((item) => item.management.kind === "protocol").length; + const paymentCount = items.filter((item) => item.management.kind === "payment").length; const unavailableCount = items.filter((item) => !(states[item.manifest.id]?.platformAvailable ?? item.status === "enabled")).length; const hasFilters = Boolean(search.trim() || kind !== "all" || availability !== "all"); const paginated = filtered.slice((page - 1) * pageSize, page * pageSize); @@ -131,18 +133,22 @@ export default function AdminPluginsPage() { width: 410, render: (_, item) => (
- - +
{item.manifest.name} v{item.manifest.version}
- {item.manifest.id} - - {item.error || item.manifest.description || "未提供插件说明"} + + {item.manifest.id} + + + + {item.error || item.manifest.description || "未提供插件说明"} +
@@ -156,7 +162,11 @@ export default function AdminPluginsPage() { render: (_, item) => (
- {!item.manifest.trusted ?
: null} + {!item.manifest.trusted ? ( +
+ +
+ ) : null}
), }, @@ -165,20 +175,21 @@ export default function AdminPluginsPage() { key: "scope", width: 180, align: "center", - render: (_, item) => item.management.activationScope === "user" ? ( -
-
用户自主启用
-
-
- ) : ( -
-
全局生效
-
管理员统一控制
-
- ), + ) : ( +
+
全局生效
+
管理员统一控制
+
+ ), }, { title: "平台状态", @@ -190,7 +201,13 @@ export default function AdminPluginsPage() { return (
- void changeAvailability(item, checked)} /> + void changeAvailability(item, checked)} + />
); }, @@ -200,13 +217,14 @@ export default function AdminPluginsPage() { key: "actions", width: 72, align: "center", - render: (_, item) => item.management.origin === "uploaded" ? ( - - {user?.role === "admin" ? : null} + {user?.role === "admin" ? ( + + ) : null}
@@ -385,16 +384,18 @@ export default function PluginsPage() { v{plugin.manifest.version}
- - {sourceLabel} - + {sourceLabel} {trusted ? ( 可信插件 ) : null} - {contributionKindsFor(plugin.manifest).map((kind) => categoryLabels[kind] ?? kind).join(" · ")} + + {contributionKindsFor(plugin.manifest) + .map((kind) => categoryLabels[kind] ?? kind) + .join(" · ")} +
@@ -429,7 +430,14 @@ export default function PluginsPage() {