From b6d25c1a0f706993d3618608ccbec7105070f281 Mon Sep 17 00:00:00 2001 From: Yvan <8574526@qq,com> Date: Sat, 29 Aug 2026 02:58:00 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/biz/payment/payment.go | 42 ++- internal/biz/payment/payment_event.go | 94 +++++ internal/biz/payment/payment_order.go | 27 +- internal/biz/payment/payment_test.go | 10 + internal/data/migrations_test.go | 6 +- internal/data/payment/demo_seed.go | 147 ++++++++ internal/data/payment/demo_seed_test.go | 59 +++ internal/data/payment/migrations.go | 3 + internal/data/payment/payment_event.go | 117 ++++++ internal/data/payment/payment_order.go | 174 +++++++-- internal/data/payment/payment_order_test.go | 56 ++- internal/data/system/surface_upgrade.go | 200 ++++++++++ internal/data/system/surface_upgrade_test.go | 109 ++++++ internal/modules/catalog_test.go | 8 +- internal/modules/payment/definition.go | 55 ++- internal/modules/payment/definition_test.go | 8 +- internal/routecatalog/catalog.go | 2 + internal/server/gin_test.go | 2 +- internal/server/handler/payment.go | 65 +++- internal/server/router/payment.go | 2 + internal/service/dto/payment.go | 171 ++++++--- internal/service/payment/payment.go | 139 ++++++- web/src/api/payment.js | 21 +- web/src/view/systemTools/payment/orders.vue | 364 ++++++++++++++----- 24 files changed, 1657 insertions(+), 224 deletions(-) create mode 100644 internal/biz/payment/payment_event.go create mode 100644 internal/data/payment/demo_seed.go create mode 100644 internal/data/payment/demo_seed_test.go create mode 100644 internal/data/payment/payment_event.go create mode 100644 internal/data/system/surface_upgrade.go create mode 100644 internal/data/system/surface_upgrade_test.go diff --git a/internal/biz/payment/payment.go b/internal/biz/payment/payment.go index b1b8215..b259141 100644 --- a/internal/biz/payment/payment.go +++ b/internal/biz/payment/payment.go @@ -72,6 +72,8 @@ type PaymentRequest struct { BusinessID string `json:"businessId"` PaymentMode string `json:"paymentMode"` OriginalAmount int64 `json:"originalAmount"` + UserAgent string `json:"-"` + DeviceID string `json:"-"` } // PaymentRefundRequest carries the durable provider identities needed to @@ -415,6 +417,23 @@ func (uc *PaymentUsecase) Orders(ctx context.Context, page, pageSize int, filter return uc.orders.ListPaymentOrders(ctx, page, pageSize, filter) } +func (uc *PaymentUsecase) OrderSummary(ctx context.Context, filter PaymentOrderFilter) (*PaymentOrderSummary, error) { + if uc.orders == nil { + return nil, errors.New("支付订单仓储未接入") + } + return uc.orders.SummarizePaymentOrders(ctx, filter) +} + +func (uc *PaymentUsecase) OrderEvents(ctx context.Context, provider, tradeNo string, limit int) ([]*PaymentEvent, error) { + if uc.orders == nil { + return nil, errors.New("支付订单仓储未接入") + } + if !validPaymentText(provider, 64) || !validPaymentText(tradeNo, 128) { + return nil, errors.New("支付订单事件查询参数不完整") + } + return uc.orders.ListPaymentEvents(ctx, provider, tradeNo, limit) +} + func (uc *PaymentUsecase) Create(ctx context.Context, req *PaymentRequest) (*PaymentResult, error) { if req == nil || !validPaymentText(req.Provider, 64) || !validPaymentText(req.TradeNo, 128) || !validPaymentText(req.BusinessType, 64) || !validPaymentText(req.BusinessID, 128) { return nil, errors.New("支付参数不完整") @@ -644,6 +663,9 @@ func (uc *PaymentUsecase) createWithOrder(ctx context.Context, req *PaymentReque ConfirmationID: paymentConfirmationID(req.Provider, req.TradeNo), RequestFingerprint: paymentOrderFingerprint(req, extra), Extra: extra, + ClientIP: req.ClientIP, + UserAgent: req.UserAgent, + DeviceID: req.DeviceID, } order, created, err := uc.orders.CreatePaymentOrder(ctx, order) if err != nil { @@ -691,7 +713,9 @@ func (uc *PaymentUsecase) createWithOrder(ctx context.Context, req *PaymentReque if err = validatePaymentBreakdown(result); err != nil { return nil, fmt.Errorf("内部支付金额拆分无效: %w", err) } - if _, err = uc.orders.RecordPaymentCreate(ctx, req.Provider, req.TradeNo, paymentProviderUpdate(result)); err != nil { + update := paymentProviderUpdate(result) + update.EventSource = PaymentInternal + if _, err = uc.orders.RecordPaymentCreate(ctx, req.Provider, req.TradeNo, update); err != nil { return nil, err } return uc.reconcilePaymentResult(ctx, result, nil) @@ -707,7 +731,9 @@ func (uc *PaymentUsecase) createWithOrder(ctx context.Context, req *PaymentReque uc.logger.Error(ctx, "payment.order.provider_create_invalid", "provider", req.Provider, "trade_no", req.TradeNo, "error", err) return nil, err } - order, err = uc.orders.RecordPaymentCreate(ctx, req.Provider, req.TradeNo, paymentProviderUpdate(result)) + update := paymentProviderUpdate(result) + update.EventSource = req.Provider + order, err = uc.orders.RecordPaymentCreate(ctx, req.Provider, req.TradeNo, update) if err != nil { uc.logger.Error(ctx, "payment.order.provider_create_record_failed", "provider", req.Provider, "trade_no", req.TradeNo, "error", err) return nil, err @@ -773,7 +799,17 @@ func (uc *PaymentUsecase) reconcilePaymentResult(ctx context.Context, result, ca return fail(err) } } - order, err = uc.orders.ApplyPaymentResult(ctx, result.Provider, result.TradeNo, paymentProviderUpdate(result)) + update := paymentProviderUpdate(result) + operation := PaymentOperationFromContext(ctx) + update.EventSource = operation.Source + update.EventType = PaymentEventPaymentSynchronized + if callbackSource != nil { + update.EventType = PaymentEventPaymentCallback + if update.EventSource == "" { + update.EventSource = result.Provider + } + } + order, err = uc.orders.ApplyPaymentResult(ctx, result.Provider, result.TradeNo, update) if err != nil { uc.logger.Error(ctx, "payment.order.result_record_failed", "provider", result.Provider, "trade_no", result.TradeNo, "error", err) return fail(err) diff --git a/internal/biz/payment/payment_event.go b/internal/biz/payment/payment_event.go new file mode 100644 index 0000000..f6762fa --- /dev/null +++ b/internal/biz/payment/payment_event.go @@ -0,0 +1,94 @@ +package payment + +import ( + "context" + "strings" + "time" +) + +const ( + PaymentEventOrderCreated = "order_created" + PaymentEventProviderCreated = "provider_created" + PaymentEventPaymentSynchronized = "payment_synchronized" + PaymentEventPaymentCallback = "payment_callback" + PaymentEventFulfillmentStarted = "fulfillment_started" + PaymentEventFulfillmentFinished = "fulfillment_finished" + PaymentEventRefundStarted = "refund_started" + PaymentEventRefundSubmitted = "refund_submitted" + PaymentEventRefundConfirmed = "refund_confirmed" +) + +// PaymentOperation describes who or what initiated a payment state change. +// It is carried in context so repo method signatures remain focused on domain +// state while event persistence can still retain transport-level attribution. +type PaymentOperation struct { + Source string + Reason string + OperatorID uint + OperatorName string + ClientIP string + UserAgent string + DeviceID string +} + +type paymentOperationContextKey struct{} + +func WithPaymentOperation(ctx context.Context, operation PaymentOperation) context.Context { + if ctx == nil { + ctx = context.Background() + } + return context.WithValue(ctx, paymentOperationContextKey{}, operation) +} + +func PaymentOperationFromContext(ctx context.Context) PaymentOperation { + if ctx == nil { + return PaymentOperation{} + } + operation, _ := ctx.Value(paymentOperationContextKey{}).(PaymentOperation) + operation.Source = strings.TrimSpace(operation.Source) + operation.Reason = strings.TrimSpace(operation.Reason) + operation.OperatorName = strings.TrimSpace(operation.OperatorName) + operation.ClientIP = strings.TrimSpace(operation.ClientIP) + operation.UserAgent = strings.TrimSpace(operation.UserAgent) + operation.DeviceID = strings.TrimSpace(operation.DeviceID) + return operation +} + +type PaymentEvent struct { + ID uint64 + Provider string + TradeNo string + Type string + Source string + Status string + ProviderStatus string + Message string + EventID string + PayloadHash string + Amount int64 + Currency string + OperatorID uint + OperatorName string + ClientIP string + UserAgent string + DeviceID string + CreatedAt time.Time +} + +type PaymentCurrencySummary struct { + Currency string + OrderAmount int64 + PaidAmount int64 + RefundedAmount int64 + SettlementAmount int64 + NetAmount int64 +} + +type PaymentOrderSummary struct { + OrderCount int64 + PendingCount int64 + PaidCount int64 + RefundedCount int64 + IssueCount int64 + Currencies []PaymentCurrencySummary +} diff --git a/internal/biz/payment/payment_order.go b/internal/biz/payment/payment_order.go index 1e9d576..f05c377 100644 --- a/internal/biz/payment/payment_order.go +++ b/internal/biz/payment/payment_order.go @@ -88,6 +88,9 @@ type PaymentOrder struct { RefundedAt *time.Time FulfillmentLeaseUntil *time.Time RefundLeaseUntil *time.Time + ClientIP string + UserAgent string + DeviceID string } type PaymentProviderUpdate struct { @@ -109,21 +112,33 @@ type PaymentProviderUpdate struct { EventID string PayloadHash string CreatePayload json.RawMessage + EventType string + EventSource string + EventMessage string } type PaymentOrderFilter struct { - Provider string - TradeNo string - BusinessType string - BusinessID string - PaymentStatus string - RefundStatus string + Provider string + TradeNo string + ProviderTradeNo string + Keyword string + BusinessType string + BusinessID string + PaymentStatus string + RefundStatus string + CreatedFrom *time.Time + CreatedTo *time.Time + PaidFrom *time.Time + PaidTo *time.Time + IssueOnly bool } type PaymentOrderRepo interface { CreatePaymentOrder(context.Context, *PaymentOrder) (order *PaymentOrder, created bool, err error) FindPaymentOrder(context.Context, string, string) (*PaymentOrder, error) ListPaymentOrders(context.Context, int, int, PaymentOrderFilter) ([]*PaymentOrder, int64, error) + SummarizePaymentOrders(context.Context, PaymentOrderFilter) (*PaymentOrderSummary, error) + ListPaymentEvents(context.Context, string, string, int) ([]*PaymentEvent, error) RecordPaymentCreate(context.Context, string, string, *PaymentProviderUpdate) (*PaymentOrder, error) ApplyPaymentResult(context.Context, string, string, *PaymentProviderUpdate) (*PaymentOrder, error) BeginPaymentFulfillment(context.Context, string, string, time.Duration) (order *PaymentOrder, token string, duplicate bool, err error) diff --git a/internal/biz/payment/payment_test.go b/internal/biz/payment/payment_test.go index eea7ebc..74d5e67 100644 --- a/internal/biz/payment/payment_test.go +++ b/internal/biz/payment/payment_test.go @@ -52,6 +52,16 @@ func (r *paymentOrderRepoStub) ListPaymentOrders(context.Context, int, int, Paym } return []*PaymentOrder{r.order}, 1, nil } +func (r *paymentOrderRepoStub) SummarizePaymentOrders(context.Context, PaymentOrderFilter) (*PaymentOrderSummary, error) { + count := int64(0) + if r.order != nil { + count = 1 + } + return &PaymentOrderSummary{OrderCount: count}, nil +} +func (r *paymentOrderRepoStub) ListPaymentEvents(context.Context, string, string, int) ([]*PaymentEvent, error) { + return []*PaymentEvent{}, nil +} func (r *paymentOrderRepoStub) RecordPaymentCreate(_ context.Context, _, _ string, update *PaymentProviderUpdate) (*PaymentOrder, error) { r.order.PaymentStatus = PaymentStatusPending r.order.CreatePayload = append(r.order.CreatePayload[:0], update.CreatePayload...) diff --git a/internal/data/migrations_test.go b/internal/data/migrations_test.go index 3661dd0..0c44ce8 100644 --- a/internal/data/migrations_test.go +++ b/internal/data/migrations_test.go @@ -25,7 +25,7 @@ func TestMigrateAllRunsModuleSchemasWithoutBootstrapSeed(t *testing.T) { if err = migrateAll(db, testCatalog()); err != nil { t.Fatalf("second migration run: %v", err) } - for _, table := range []string{"sys_integration_configs", "sys_users", "sys_base_menus", "pay_orders"} { + for _, table := range []string{"sys_integration_configs", "sys_users", "sys_base_menus", "pay_orders", "pay_events"} { if !db.Migrator().HasTable(table) { t.Fatalf("migration did not create %s", table) } @@ -34,8 +34,8 @@ func TestMigrateAllRunsModuleSchemasWithoutBootstrapSeed(t *testing.T) { if err = db.Table(migration.TableName).Count(&versions).Error; err != nil { t.Fatal(err) } - if versions != 8 { - t.Fatalf("migration versions = %d, want 8", versions) + if versions != 11 { + t.Fatalf("migration versions = %d, want 11", versions) } var communicationRows []dataintegration.ConfigPO if err = db.Where("kind IN ?", []string{"mq", "websocket"}).Order("kind, provider").Find(&communicationRows).Error; err != nil { diff --git a/internal/data/payment/demo_seed.go b/internal/data/payment/demo_seed.go new file mode 100644 index 0000000..89f3287 --- /dev/null +++ b/internal/data/payment/demo_seed.go @@ -0,0 +1,147 @@ +package payment + +import ( + "fmt" + bizpayment "kra/internal/biz/payment" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +const DemoTradePrefix = "DEMO-PAY-" + +type DemoSeedResult struct { + Orders int + Events int +} + +// SeedDemoOrders replaces only DEMO-PAY-* records with a representative set +// of payment operations for administration-page review. +func SeedDemoOrders(db *gorm.DB, now time.Time) (*DemoSeedResult, error) { + if db == nil { + return nil, fmt.Errorf("demo payment database is nil") + } + if !db.Migrator().HasTable(&paymentOrderPO{}) || !db.Migrator().HasTable(&paymentEventPO{}) { + return nil, fmt.Errorf("payment tables are not migrated") + } + now = now.UTC().Truncate(time.Second) + if now.IsZero() { + now = time.Now().UTC().Truncate(time.Second) + } + + type demoOrder struct { + order paymentOrderPO + events []paymentEventPO + } + makeOrder := func(index int, provider, subject, status, fulfillment, refund string, amount int64, currency string, created time.Time) paymentOrderPO { + tradeNo := fmt.Sprintf("%s%02d", DemoTradePrefix, index) + return paymentOrderPO{ + TradeNo: tradeNo, Provider: provider, BusinessType: "demo_subscription", BusinessID: fmt.Sprintf("DEMO-LICENSE-%02d", index), + Subject: subject, PaymentMode: bizpayment.PaymentModeExternal, OriginalAmount: amount, Amount: amount, + Currency: currency, PaymentStatus: status, FulfillmentStatus: fulfillment, RefundStatus: refund, + ConfirmationID: uuid.NewString(), RequestFingerprint: fmt.Sprintf("demo-fingerprint-%02d", index), + CreatePayload: "{}", Extra: "{}", Version: 1, CreatedAt: created, UpdatedAt: created, + } + } + makeEvent := func(order paymentOrderPO, eventType, source, status, providerStatus, message string, amount int64, at time.Time) paymentEventPO { + event := paymentEventPO{ + Provider: order.Provider, TradeNo: order.TradeNo, Type: eventType, Source: source, Status: status, + ProviderStatus: providerStatus, Message: message, Amount: amount, Currency: order.Currency, CreatedAt: at, + } + if source == "client" { + event.ClientIP, event.UserAgent, event.DeviceID = "111.29.161.79", "Kra-Demo/1.2 Android/16", fmt.Sprintf("DEMO-DEVICE-%s", order.TradeNo) + } + if source == "admin" { + event.OperatorID, event.OperatorName = 1, "demo-admin" + } + return event + } + providerDate := now.Format("20060102") + + created1, paid1, fulfilled1 := now.Add(-35*time.Minute), now.Add(-34*time.Minute), now.Add(-33*time.Minute) + paid := makeOrder(1, bizpayment.PaymentAlipay, "专业版 7 天授权", bizpayment.PaymentStatusPaid, bizpayment.FulfillmentStatusSucceeded, bizpayment.RefundStatusNone, 940, "CNY", created1) + providerTrade1 := fmt.Sprintf("DEMO-ALIPAY-%s%04d", providerDate, 1) + paid.ProviderTradeNo = &providerTrade1 + paid.ProviderStatus, paid.PaidAmount, paid.PayerPaidAmount, paid.CashPaidAmount, paid.SettlementAmount = "TRADE_SUCCESS", 940, 940, 940, 934 + paid.AmountBreakdownKnown, paid.PaidAt, paid.FulfilledAt, paid.UpdatedAt = true, &paid1, &fulfilled1, fulfilled1 + + pendingCreated := now.Add(-28 * time.Minute) + pending := makeOrder(2, bizpayment.PaymentWechatV3, "专业版 30 天授权", bizpayment.PaymentStatusPending, bizpayment.FulfillmentStatusPending, bizpayment.RefundStatusNone, 2990, "CNY", pendingCreated) + pending.ProviderStatus = "NOTPAY" + pending.UpdatedAt = now.Add(-27 * time.Minute) + + failedCreated := now.Add(-24 * time.Minute) + failed := makeOrder(3, bizpayment.PaymentPayPal, "海外数字商品", bizpayment.PaymentStatusFailed, bizpayment.FulfillmentStatusPending, bizpayment.RefundStatusNone, 1299, "USD", failedCreated) + failed.ProviderStatus, failed.LastError = "DECLINED", "付款方式被渠道拒绝" + failed.UpdatedAt = now.Add(-23 * time.Minute) + + fulfillCreated, fulfillPaid := now.Add(-20*time.Minute), now.Add(-19*time.Minute) + fulfillFailed := makeOrder(4, bizpayment.PaymentAlipayV3, "企业版 90 天授权", bizpayment.PaymentStatusPaid, bizpayment.FulfillmentStatusFailed, bizpayment.RefundStatusNone, 8990, "CNY", fulfillCreated) + providerTrade4 := fmt.Sprintf("DEMO-ALIPAY-%s%04d", providerDate, 4) + fulfillFailed.ProviderTradeNo, fulfillFailed.ProviderStatus = &providerTrade4, "TRADE_SUCCESS" + fulfillFailed.PaidAmount, fulfillFailed.SettlementAmount, fulfillFailed.PaidAt = 8990, 8936, &fulfillPaid + fulfillFailed.LastError, fulfillFailed.UpdatedAt = "业务授权服务暂时不可用,可人工重试发货", now.Add(-18*time.Minute) + + partialCreated, partialPaid, partialRefunded := now.Add(-16*time.Minute), now.Add(-15*time.Minute), now.Add(-12*time.Minute) + partial := makeOrder(5, bizpayment.PaymentWechatV3, "团队版 30 天授权", bizpayment.PaymentStatusPartiallyRefunded, bizpayment.FulfillmentStatusSucceeded, bizpayment.RefundStatusPartial, 5000, "CNY", partialCreated) + providerTrade5 := fmt.Sprintf("DEMO-WECHAT-%s%04d", providerDate, 5) + partial.ProviderTradeNo, partial.ProviderStatus = &providerTrade5, "SUCCESS" + partial.PaidAmount, partial.SettlementAmount, partial.RefundedAmount = 5000, 4970, 1200 + partial.PaidAt, partial.FulfilledAt, partial.RefundedAt, partial.UpdatedAt = &partialPaid, &partialPaid, &partialRefunded, partialRefunded + + refundCreated, refundPaid := now.Add(-11*time.Minute), now.Add(-10*time.Minute) + refundPending := makeOrder(6, bizpayment.PaymentAlipay, "增值服务包", bizpayment.PaymentStatusPaid, bizpayment.FulfillmentStatusSucceeded, bizpayment.RefundStatusPending, 1880, "CNY", refundCreated) + providerTrade6 := fmt.Sprintf("DEMO-ALIPAY-%s%04d", providerDate, 6) + refundPending.ProviderTradeNo, refundPending.ProviderStatus = &providerTrade6, "TRADE_SUCCESS" + refundPending.PaidAmount, refundPending.SettlementAmount, refundPending.RefundRequestedAmount = 1880, 1869, 600 + refundPending.RefundNo, refundPending.PaidAt, refundPending.FulfilledAt, refundPending.UpdatedAt = "DEMO-REFUND-06", &refundPaid, &refundPaid, now.Add(-8*time.Minute) + + refundedCreated, refundedPaid, refundedAt := now.Add(-9*time.Minute), now.Add(-8*time.Minute), now.Add(-5*time.Minute) + refunded := makeOrder(7, bizpayment.PaymentWechatV3, "体验卡 7 天", bizpayment.PaymentStatusRefunded, bizpayment.FulfillmentStatusSucceeded, bizpayment.RefundStatusSucceeded, 500, "CNY", refundedCreated) + providerTrade7 := fmt.Sprintf("DEMO-WECHAT-%s%04d", providerDate, 7) + refunded.ProviderTradeNo, refunded.ProviderStatus = &providerTrade7, "REFUND_SUCCESS" + refunded.PaidAmount, refunded.SettlementAmount, refunded.RefundedAmount = 500, 497, 500 + refunded.PaidAt, refunded.FulfilledAt, refunded.RefundedAt, refunded.UpdatedAt = &refundedPaid, &refundedPaid, &refundedAt, refundedAt + + internalCreated, internalPaid := now.Add(-4*time.Minute), now.Add(-3*time.Minute) + internal := makeOrder(8, bizpayment.PaymentInternal, "积分兑换月卡", bizpayment.PaymentStatusPaid, bizpayment.FulfillmentStatusSucceeded, bizpayment.RefundStatusNone, 1200, "POINT", internalCreated) + internal.PaymentMode, internal.ProviderStatus = bizpayment.PaymentModeInternal, "AUTHORIZED" + internal.PaidAmount, internal.PointPaidAmount, internal.SettlementAmount, internal.AmountBreakdownKnown = 1200, 1200, 1200, true + internal.PaidAt, internal.FulfilledAt, internal.UpdatedAt = &internalPaid, &internalPaid, internalPaid + + demos := []demoOrder{ + {paid, []paymentEventPO{makeEvent(paid, bizpayment.PaymentEventOrderCreated, "client", bizpayment.PaymentStatusInitialized, "", "客户端创建订单", paid.Amount, created1), makeEvent(paid, bizpayment.PaymentEventPaymentCallback, bizpayment.PaymentAlipay, bizpayment.PaymentStatusPaid, paid.ProviderStatus, "支付宝异步通知验签并主动查单成功", paid.PaidAmount, paid1), makeEvent(paid, bizpayment.PaymentEventFulfillmentFinished, "system", bizpayment.FulfillmentStatusSucceeded, "", "授权自动激活成功,有效期 7 天", 0, fulfilled1)}}, + {pending, []paymentEventPO{makeEvent(pending, bizpayment.PaymentEventOrderCreated, "client", bizpayment.PaymentStatusInitialized, "", "客户端创建订单", pending.Amount, pendingCreated), makeEvent(pending, bizpayment.PaymentEventProviderCreated, bizpayment.PaymentWechatV3, bizpayment.PaymentStatusPending, pending.ProviderStatus, "预支付订单已创建,等待买家付款", pending.Amount, pending.UpdatedAt)}}, + {failed, []paymentEventPO{makeEvent(failed, bizpayment.PaymentEventOrderCreated, "client", bizpayment.PaymentStatusInitialized, "", "客户端创建订单", failed.Amount, failedCreated), makeEvent(failed, bizpayment.PaymentEventPaymentSynchronized, "admin", bizpayment.PaymentStatusFailed, failed.ProviderStatus, failed.LastError, 0, failed.UpdatedAt)}}, + {fulfillFailed, []paymentEventPO{makeEvent(fulfillFailed, bizpayment.PaymentEventPaymentCallback, bizpayment.PaymentAlipayV3, bizpayment.PaymentStatusPaid, fulfillFailed.ProviderStatus, "支付成功", fulfillFailed.PaidAmount, fulfillPaid), makeEvent(fulfillFailed, bizpayment.PaymentEventFulfillmentFinished, "system", bizpayment.FulfillmentStatusFailed, "", fulfillFailed.LastError, 0, fulfillFailed.UpdatedAt)}}, + {partial, []paymentEventPO{makeEvent(partial, bizpayment.PaymentEventPaymentCallback, bizpayment.PaymentWechatV3, bizpayment.PaymentStatusPaid, partial.ProviderStatus, "支付成功并完成发货", partial.PaidAmount, partialPaid), makeEvent(partial, bizpayment.PaymentEventRefundConfirmed, bizpayment.PaymentWechatV3, bizpayment.RefundStatusPartial, "SUCCESS", "售后部分退款 12.00 CNY", partial.RefundedAmount, partialRefunded)}}, + {refundPending, []paymentEventPO{makeEvent(refundPending, bizpayment.PaymentEventPaymentCallback, bizpayment.PaymentAlipay, bizpayment.PaymentStatusPaid, refundPending.ProviderStatus, "支付成功并完成发货", refundPending.PaidAmount, refundPaid), makeEvent(refundPending, bizpayment.PaymentEventRefundSubmitted, "admin", bizpayment.RefundStatusPending, "", "客户重复购买,退款已提交渠道", refundPending.RefundRequestedAmount, refundPending.UpdatedAt)}}, + {refunded, []paymentEventPO{makeEvent(refunded, bizpayment.PaymentEventPaymentCallback, bizpayment.PaymentWechatV3, bizpayment.PaymentStatusPaid, "SUCCESS", "支付成功并完成发货", refunded.PaidAmount, refundedPaid), makeEvent(refunded, bizpayment.PaymentEventRefundConfirmed, bizpayment.PaymentWechatV3, bizpayment.RefundStatusSucceeded, refunded.ProviderStatus, "原路全额退款成功", refunded.RefundedAmount, refundedAt)}}, + {internal, []paymentEventPO{makeEvent(internal, bizpayment.PaymentEventOrderCreated, "client", bizpayment.PaymentStatusInitialized, "", "创建积分支付订单", internal.Amount, internalCreated), makeEvent(internal, bizpayment.PaymentEventPaymentSynchronized, bizpayment.PaymentInternal, bizpayment.PaymentStatusPaid, internal.ProviderStatus, "积分扣减与授权发放成功", internal.PaidAmount, internalPaid)}}, + } + + result := &DemoSeedResult{} + err := db.Transaction(func(tx *gorm.DB) error { + if err := tx.Where("trade_no LIKE ?", DemoTradePrefix+"%").Delete(&paymentEventPO{}).Error; err != nil { + return err + } + if err := tx.Where("trade_no LIKE ?", DemoTradePrefix+"%").Delete(&paymentOrderPO{}).Error; err != nil { + return err + } + for i := range demos { + if err := tx.Create(&demos[i].order).Error; err != nil { + return err + } + result.Orders++ + if len(demos[i].events) > 0 { + if err := tx.Create(&demos[i].events).Error; err != nil { + return err + } + result.Events += len(demos[i].events) + } + } + return nil + }) + return result, err +} diff --git a/internal/data/payment/demo_seed_test.go b/internal/data/payment/demo_seed_test.go new file mode 100644 index 0000000..856c2b0 --- /dev/null +++ b/internal/data/payment/demo_seed_test.go @@ -0,0 +1,59 @@ +package payment + +import ( + "strings" + "testing" + "time" +) + +func TestSeedDemoOrdersIsRepeatableAndCoversOperationalStates(t *testing.T) { + repo := newPaymentOrderRepoForTest(t) + db := repo.data.DB() + now := time.Date(2026, 8, 29, 2, 0, 0, 0, time.UTC) + for attempt := 0; attempt < 2; attempt++ { + result, err := SeedDemoOrders(db, now) + if err != nil { + t.Fatal(err) + } + if result.Orders != 8 || result.Events < 16 { + t.Fatalf("seed result = %#v", result) + } + } + var orders int64 + if err := db.Model(&paymentOrderPO{}).Where("trade_no LIKE ?", DemoTradePrefix+"%").Count(&orders).Error; err != nil { + t.Fatal(err) + } + if orders != 8 { + t.Fatalf("demo orders = %d, want 8", orders) + } + for _, status := range []string{"pending", "failed", "paid", "partially_refunded", "refunded"} { + var count int64 + if err := db.Model(&paymentOrderPO{}).Where("trade_no LIKE ? AND payment_status = ?", DemoTradePrefix+"%", status).Count(&count).Error; err != nil { + t.Fatal(err) + } + if count == 0 { + t.Fatalf("demo status %q is missing", status) + } + } + var partialRefund paymentEventPO + if err := db.Where("trade_no = ? AND type = ?", DemoTradePrefix+"05", "refund_confirmed").First(&partialRefund).Error; err != nil { + t.Fatal(err) + } + if partialRefund.Amount != 1200 { + t.Fatalf("partial refund event amount = %d, want 1200", partialRefund.Amount) + } + var created paymentEventPO + if err := db.Where("trade_no = ? AND type = ?", DemoTradePrefix+"01", "order_created").First(&created).Error; err != nil { + t.Fatal(err) + } + if created.ProviderStatus != "" || created.ClientIP == "" || created.DeviceID == "" { + t.Fatalf("created event metadata = %#v", created) + } + var paid paymentOrderPO + if err := db.Where("trade_no = ?", DemoTradePrefix+"01").First(&paid).Error; err != nil { + t.Fatal(err) + } + if paid.ProviderTradeNo == nil || !strings.Contains(*paid.ProviderTradeNo, "20260829") { + t.Fatalf("provider trade number = %#v", paid.ProviderTradeNo) + } +} diff --git a/internal/data/payment/migrations.go b/internal/data/payment/migrations.go index e985a7e..f0b39a7 100644 --- a/internal/data/payment/migrations.go +++ b/internal/data/payment/migrations.go @@ -11,5 +11,8 @@ func Migrations() []migration.Step { {ID: "202608200003_payment_schema", Migrate: func(db *gorm.DB) error { return migration.CreateMissingTables(db, &paymentOrderPO{}) }}, + {ID: "202608290001_payment_event_schema", Migrate: func(db *gorm.DB) error { + return migration.CreateMissingTables(db, &paymentEventPO{}) + }}, } } diff --git a/internal/data/payment/payment_event.go b/internal/data/payment/payment_event.go new file mode 100644 index 0000000..aa6a637 --- /dev/null +++ b/internal/data/payment/payment_event.go @@ -0,0 +1,117 @@ +package payment + +import ( + "context" + bizpayment "kra/internal/biz/payment" + "time" + + "gorm.io/gorm" +) + +type paymentEventPO struct { + ID uint64 `gorm:"primaryKey"` + Provider string `gorm:"size:64;not null;index:idx_pay_events_order,priority:1"` + TradeNo string `gorm:"size:128;not null;index:idx_pay_events_order,priority:2"` + Type string `gorm:"size:64;not null;index"` + Source string `gorm:"size:64;not null;index"` + Status string `gorm:"size:64"` + ProviderStatus string `gorm:"size:64"` + Message string `gorm:"size:512"` + EventID string `gorm:"size:128;index"` + PayloadHash string `gorm:"size:64"` + Amount int64 `gorm:"not null;default:0"` + Currency string `gorm:"size:16"` + OperatorID uint `gorm:"index"` + OperatorName string `gorm:"size:128"` + ClientIP string `gorm:"size:64"` + UserAgent string `gorm:"size:512"` + DeviceID string `gorm:"size:128"` + CreatedAt time.Time `gorm:"index"` +} + +func (paymentEventPO) TableName() string { return "pay_events" } + +func toBizPaymentEvent(po *paymentEventPO) *bizpayment.PaymentEvent { + if po == nil { + return nil + } + return &bizpayment.PaymentEvent{ + ID: po.ID, Provider: po.Provider, TradeNo: po.TradeNo, Type: po.Type, Source: po.Source, + Status: po.Status, ProviderStatus: po.ProviderStatus, Message: po.Message, EventID: po.EventID, + PayloadHash: po.PayloadHash, Amount: po.Amount, Currency: po.Currency, OperatorID: po.OperatorID, + OperatorName: po.OperatorName, ClientIP: po.ClientIP, UserAgent: po.UserAgent, DeviceID: po.DeviceID, + CreatedAt: po.CreatedAt, + } +} + +func appendPaymentEvent(ctx context.Context, tx *gorm.DB, order *paymentOrderPO, event *bizpayment.PaymentEvent) error { + if tx == nil || order == nil || event == nil { + return nil + } + operation := bizpayment.PaymentOperationFromContext(ctx) + source := trimTo(event.Source, 64) + if source == "" { + source = trimTo(operation.Source, 64) + } + if source == "" { + source = "system" + } + message := trimTo(event.Message, 512) + if message == "" { + message = trimTo(operation.Reason, 512) + } + operatorID := event.OperatorID + if operatorID == 0 { + operatorID = operation.OperatorID + } + operatorName := trimTo(event.OperatorName, 128) + if operatorName == "" { + operatorName = trimTo(operation.OperatorName, 128) + } + clientIP := trimTo(event.ClientIP, 64) + if clientIP == "" { + clientIP = trimTo(operation.ClientIP, 64) + } + userAgent := trimTo(event.UserAgent, 512) + if userAgent == "" { + userAgent = trimTo(operation.UserAgent, 512) + } + deviceID := trimTo(event.DeviceID, 128) + if deviceID == "" { + deviceID = trimTo(operation.DeviceID, 128) + } + return tx.Create(&paymentEventPO{ + Provider: order.Provider, TradeNo: order.TradeNo, Type: trimTo(event.Type, 64), Source: source, + Status: trimTo(event.Status, 64), ProviderStatus: trimTo(event.ProviderStatus, 64), Message: message, + EventID: trimTo(event.EventID, 128), PayloadHash: trimTo(event.PayloadHash, 64), Amount: event.Amount, + Currency: trimTo(event.Currency, 16), OperatorID: operatorID, OperatorName: operatorName, + ClientIP: clientIP, UserAgent: userAgent, DeviceID: deviceID, + }).Error +} + +func savePaymentOrderWithEvent(ctx context.Context, tx *gorm.DB, order *paymentOrderPO, event *bizpayment.PaymentEvent) error { + if err := tx.Save(order).Error; err != nil { + return err + } + return appendPaymentEvent(ctx, tx, order, event) +} + +func (r *paymentOrderRepo) ListPaymentEvents(ctx context.Context, provider, tradeNo string, limit int) ([]*bizpayment.PaymentEvent, error) { + if limit <= 0 { + limit = 100 + } + if limit > 500 { + limit = 500 + } + var rows []paymentEventPO + if err := r.data.DB().WithContext(ctx). + Where("provider = ? AND trade_no = ?", provider, tradeNo). + Order("id DESC").Limit(limit).Find(&rows).Error; err != nil { + return nil, err + } + items := make([]*bizpayment.PaymentEvent, 0, len(rows)) + for i := range rows { + items = append(items, toBizPaymentEvent(&rows[i])) + } + return items, nil +} diff --git a/internal/data/payment/payment_order.go b/internal/data/payment/payment_order.go index d61e90d..23c9da2 100644 --- a/internal/data/payment/payment_order.go +++ b/internal/data/payment/payment_order.go @@ -146,7 +146,17 @@ func (r *paymentOrderRepo) CreatePaymentOrder(ctx context.Context, order *bizpay if !errors.Is(err, gorm.ErrRecordNotFound) { return nil, false, err } - if err = db.Create(po).Error; err == nil { + err = db.Transaction(func(tx *gorm.DB) error { + if createErr := tx.Create(po).Error; createErr != nil { + return createErr + } + return appendPaymentEvent(ctx, tx, po, &bizpayment.PaymentEvent{ + Type: bizpayment.PaymentEventOrderCreated, Source: "client", Status: po.PaymentStatus, + Amount: po.Amount, Currency: po.Currency, ClientIP: order.ClientIP, + UserAgent: order.UserAgent, DeviceID: order.DeviceID, + }) + }) + if err == nil { return toBizPaymentOrder(po), true, nil } if lookupErr := db.Where("provider = ? AND trade_no = ?", po.Provider, po.TradeNo).First(¤t).Error; lookupErr == nil { @@ -167,25 +177,7 @@ func (r *paymentOrderRepo) FindPaymentOrder(ctx context.Context, provider, trade } func (r *paymentOrderRepo) ListPaymentOrders(ctx context.Context, page, pageSize int, filter bizpayment.PaymentOrderFilter) ([]*bizpayment.PaymentOrder, int64, error) { - db := r.data.DB().WithContext(ctx).Model(&paymentOrderPO{}) - if value := strings.TrimSpace(filter.Provider); value != "" { - db = db.Where("provider = ?", value) - } - if value := strings.TrimSpace(filter.TradeNo); value != "" { - db = db.Where("trade_no LIKE ?", "%"+value+"%") - } - if value := strings.TrimSpace(filter.BusinessType); value != "" { - db = db.Where("business_type = ?", value) - } - if value := strings.TrimSpace(filter.BusinessID); value != "" { - db = db.Where("business_id LIKE ?", "%"+value+"%") - } - if value := strings.TrimSpace(filter.PaymentStatus); value != "" { - db = db.Where("payment_status = ?", value) - } - if value := strings.TrimSpace(filter.RefundStatus); value != "" { - db = db.Where("refund_status = ?", value) - } + db := r.filteredPaymentOrders(ctx, filter) var total int64 if err := db.Count(&total).Error; err != nil { return nil, 0, err @@ -201,6 +193,105 @@ func (r *paymentOrderRepo) ListPaymentOrders(ctx context.Context, page, pageSize return items, total, nil } +func (r *paymentOrderRepo) filteredPaymentOrders(ctx context.Context, filter bizpayment.PaymentOrderFilter) *gorm.DB { + db := r.data.DB().WithContext(ctx).Model(&paymentOrderPO{}) + if value := strings.TrimSpace(filter.Provider); value != "" { + db = db.Where("provider = ?", value) + } + if value := strings.TrimSpace(filter.TradeNo); value != "" { + db = db.Where("trade_no LIKE ?", "%"+value+"%") + } + if value := strings.TrimSpace(filter.ProviderTradeNo); value != "" { + db = db.Where("provider_trade_no LIKE ?", "%"+value+"%") + } + if value := strings.TrimSpace(filter.Keyword); value != "" { + like := "%" + value + "%" + db = db.Where("trade_no LIKE ? OR provider_trade_no LIKE ? OR business_id LIKE ? OR subject LIKE ?", like, like, like, like) + } + if value := strings.TrimSpace(filter.BusinessType); value != "" { + db = db.Where("business_type = ?", value) + } + if value := strings.TrimSpace(filter.BusinessID); value != "" { + db = db.Where("business_id LIKE ?", "%"+value+"%") + } + if value := strings.TrimSpace(filter.PaymentStatus); value != "" { + db = db.Where("payment_status = ?", value) + } + if value := strings.TrimSpace(filter.RefundStatus); value != "" { + db = db.Where("refund_status = ?", value) + } + if filter.CreatedFrom != nil { + db = db.Where("created_at >= ?", filter.CreatedFrom.UTC()) + } + if filter.CreatedTo != nil { + db = db.Where("created_at < ?", filter.CreatedTo.UTC()) + } + if filter.PaidFrom != nil { + db = db.Where("paid_at >= ?", filter.PaidFrom.UTC()) + } + if filter.PaidTo != nil { + db = db.Where("paid_at < ?", filter.PaidTo.UTC()) + } + if filter.IssueOnly { + db = db.Where("payment_status = ? OR fulfillment_status = ? OR refund_status = ? OR last_error <> ''", + bizpayment.PaymentStatusFailed, bizpayment.FulfillmentStatusFailed, bizpayment.RefundStatusFailed) + } + return db +} + +func (r *paymentOrderRepo) SummarizePaymentOrders(ctx context.Context, filter bizpayment.PaymentOrderFilter) (*bizpayment.PaymentOrderSummary, error) { + type countRow struct { + OrderCount int64 + PendingCount int64 + PaidCount int64 + RefundedCount int64 + IssueCount int64 + } + var counts countRow + if err := r.filteredPaymentOrders(ctx, filter).Select(` + COUNT(*) AS order_count, + COALESCE(SUM(CASE WHEN payment_status IN ? THEN 1 ELSE 0 END), 0) AS pending_count, + COALESCE(SUM(CASE WHEN payment_status IN ? THEN 1 ELSE 0 END), 0) AS paid_count, + COALESCE(SUM(CASE WHEN refunded_amount > 0 THEN 1 ELSE 0 END), 0) AS refunded_count, + COALESCE(SUM(CASE WHEN payment_status = ? OR fulfillment_status = ? OR refund_status = ? OR last_error <> '' THEN 1 ELSE 0 END), 0) AS issue_count`, + []string{bizpayment.PaymentStatusInitialized, bizpayment.PaymentStatusPending}, + []string{bizpayment.PaymentStatusPaid, bizpayment.PaymentStatusPartiallyRefunded, bizpayment.PaymentStatusRefunded}, + bizpayment.PaymentStatusFailed, bizpayment.FulfillmentStatusFailed, bizpayment.RefundStatusFailed, + ).Scan(&counts).Error; err != nil { + return nil, err + } + type currencyRow struct { + Currency string + OrderAmount int64 + PaidAmount int64 + RefundedAmount int64 + SettlementAmount int64 + } + var rows []currencyRow + if err := r.filteredPaymentOrders(ctx, filter). + Select(`currency, + COALESCE(SUM(amount), 0) AS order_amount, + COALESCE(SUM(paid_amount), 0) AS paid_amount, + COALESCE(SUM(refunded_amount), 0) AS refunded_amount, + COALESCE(SUM(CASE WHEN settlement_amount > 0 THEN settlement_amount ELSE paid_amount END), 0) AS settlement_amount`). + Group("currency").Order("currency ASC").Scan(&rows).Error; err != nil { + return nil, err + } + summary := &bizpayment.PaymentOrderSummary{ + OrderCount: counts.OrderCount, PendingCount: counts.PendingCount, PaidCount: counts.PaidCount, + RefundedCount: counts.RefundedCount, IssueCount: counts.IssueCount, + Currencies: make([]bizpayment.PaymentCurrencySummary, 0, len(rows)), + } + for _, row := range rows { + summary.Currencies = append(summary.Currencies, bizpayment.PaymentCurrencySummary{ + Currency: row.Currency, OrderAmount: row.OrderAmount, PaidAmount: row.PaidAmount, + RefundedAmount: row.RefundedAmount, SettlementAmount: row.SettlementAmount, + NetAmount: row.SettlementAmount - row.RefundedAmount, + }) + } + return summary, nil +} + func (r *paymentOrderRepo) RecordPaymentCreate(ctx context.Context, provider, tradeNo string, update *bizpayment.PaymentProviderUpdate) (*bizpayment.PaymentOrder, error) { return r.withLockedOrder(ctx, provider, tradeNo, func(tx *gorm.DB, po *paymentOrderPO) error { if update == nil { @@ -224,7 +315,11 @@ func (r *paymentOrderRepo) RecordPaymentCreate(ctx context.Context, provider, tr po.PaymentStatus = status } po.Version++ - return tx.Save(po).Error + return savePaymentOrderWithEvent(ctx, tx, po, &bizpayment.PaymentEvent{ + Type: bizpayment.PaymentEventProviderCreated, Source: update.EventSource, Status: po.PaymentStatus, + ProviderStatus: po.ProviderStatus, Message: update.EventMessage, EventID: update.EventID, + PayloadHash: update.PayloadHash, Amount: po.Amount, Currency: po.Currency, + }) }) } @@ -279,7 +374,15 @@ func (r *paymentOrderRepo) ApplyPaymentResult(ctx context.Context, provider, tra } } po.Version++ - return tx.Save(po).Error + eventType := update.EventType + if eventType == "" { + eventType = bizpayment.PaymentEventPaymentSynchronized + } + return savePaymentOrderWithEvent(ctx, tx, po, &bizpayment.PaymentEvent{ + Type: eventType, Source: update.EventSource, Status: po.PaymentStatus, + ProviderStatus: po.ProviderStatus, Message: update.EventMessage, EventID: update.EventID, + PayloadHash: update.PayloadHash, Amount: po.PaidAmount, Currency: po.Currency, + }) }) } @@ -308,7 +411,9 @@ func (r *paymentOrderRepo) BeginPaymentFulfillment(ctx context.Context, provider po.FulfillmentLeaseUntil = &until po.LastError = "" po.Version++ - return tx.Save(po).Error + return savePaymentOrderWithEvent(ctx, tx, po, &bizpayment.PaymentEvent{ + Type: bizpayment.PaymentEventFulfillmentStarted, Status: po.FulfillmentStatus, + }) }) return order, token, duplicate, err } @@ -329,7 +434,9 @@ func (r *paymentOrderRepo) CompletePaymentFulfillment(ctx context.Context, provi po.FulfillmentStatus = bizpayment.FulfillmentStatusFailed } po.Version++ - return tx.Save(po).Error + return savePaymentOrderWithEvent(ctx, tx, po, &bizpayment.PaymentEvent{ + Type: bizpayment.PaymentEventFulfillmentFinished, Status: po.FulfillmentStatus, Message: message, + }) }) } @@ -378,7 +485,9 @@ func (r *paymentOrderRepo) BeginPaymentRefund(ctx context.Context, provider, tra po.RefundLeaseUntil = &until po.LastError = "" po.Version++ - return tx.Save(po).Error + return savePaymentOrderWithEvent(ctx, tx, po, &bizpayment.PaymentEvent{ + Type: bizpayment.PaymentEventRefundStarted, Status: po.RefundStatus, Amount: amount, Currency: po.Currency, + }) }) return order, token, err } @@ -399,7 +508,10 @@ func (r *paymentOrderRepo) CompletePaymentRefundRequest(ctx context.Context, pro po.RefundNo = "" } po.Version++ - return tx.Save(po).Error + return savePaymentOrderWithEvent(ctx, tx, po, &bizpayment.PaymentEvent{ + Type: bizpayment.PaymentEventRefundSubmitted, Status: po.RefundStatus, Message: message, + Amount: po.RefundRequestedAmount, Currency: po.Currency, + }) }) } @@ -414,7 +526,10 @@ func (r *paymentOrderRepo) ConfirmPaymentRefund(ctx context.Context, provider, t po.RefundStatus = bizpayment.RefundStatusFailed po.RefundNo = "" po.Version++ - return tx.Save(po).Error + return savePaymentOrderWithEvent(ctx, tx, po, &bizpayment.PaymentEvent{ + Type: bizpayment.PaymentEventRefundConfirmed, Status: po.RefundStatus, Message: message, + Amount: amount, Currency: po.Currency, + }) } po.RefundedAmount += amount if po.RefundedAmount >= po.Amount { @@ -428,7 +543,10 @@ func (r *paymentOrderRepo) ConfirmPaymentRefund(ctx context.Context, provider, t now := time.Now().UTC() po.RefundedAt = &now po.Version++ - return tx.Save(po).Error + return savePaymentOrderWithEvent(ctx, tx, po, &bizpayment.PaymentEvent{ + Type: bizpayment.PaymentEventRefundConfirmed, Status: po.RefundStatus, Message: message, + Amount: amount, Currency: po.Currency, + }) }) } diff --git a/internal/data/payment/payment_order_test.go b/internal/data/payment/payment_order_test.go index c9772fb..4ade4db 100644 --- a/internal/data/payment/payment_order_test.go +++ b/internal/data/payment/payment_order_test.go @@ -13,7 +13,7 @@ func newPaymentOrderRepoForTest(t *testing.T) *paymentOrderRepo { if err != nil { t.Fatal(err) } - if err = db.AutoMigrate(&paymentOrderPO{}); err != nil { + if err = db.AutoMigrate(&paymentOrderPO{}, &paymentEventPO{}); err != nil { t.Fatal(err) } return &paymentOrderRepo{data: &Data{gormDB: newReloadableDB(db, nil)}} @@ -114,3 +114,57 @@ func TestPaymentOrderRepositoryListsWithFilters(t *testing.T) { t.Fatalf("filtered orders = %#v total=%d", items, total) } } + +func TestPaymentOrderRepositorySummarizesAndListsEvents(t *testing.T) { + repo := newPaymentOrderRepoForTest(t) + ctx := bizpayment.WithPaymentOperation(context.Background(), bizpayment.PaymentOperation{ + Source: "admin", Reason: "客户重复购买", OperatorID: 7, OperatorName: "operator", + ClientIP: "127.0.0.1", DeviceID: "device-1", + }) + order := testPaymentOrder() + order.ClientIP = "10.0.0.8" + order.UserAgent = "payment-client/1.0" + if _, _, err := repo.CreatePaymentOrder(ctx, order); err != nil { + t.Fatal(err) + } + if _, err := repo.ApplyPaymentResult(ctx, bizpayment.PaymentAlipay, order.TradeNo, &bizpayment.PaymentProviderUpdate{ + Status: "success", ProviderStatus: "TRADE_SUCCESS", ProviderTradeNo: "provider-summary-1", + Amount: 100, Currency: "CNY", EventType: bizpayment.PaymentEventPaymentSynchronized, + }); err != nil { + t.Fatal(err) + } + current, token, err := repo.BeginPaymentRefund(ctx, bizpayment.PaymentAlipay, order.TradeNo, 40, time.Minute) + if err != nil { + t.Fatal(err) + } + if current, err = repo.CompletePaymentRefundRequest(ctx, bizpayment.PaymentAlipay, order.TradeNo, token, true, ""); err != nil { + t.Fatal(err) + } + if _, err = repo.ConfirmPaymentRefund(ctx, bizpayment.PaymentAlipay, order.TradeNo, current.RefundNo, 40, true, ""); err != nil { + t.Fatal(err) + } + + summary, err := repo.SummarizePaymentOrders(ctx, bizpayment.PaymentOrderFilter{ProviderTradeNo: "summary-1"}) + if err != nil { + t.Fatal(err) + } + if summary.OrderCount != 1 || summary.PaidCount != 1 || summary.RefundedCount != 1 || len(summary.Currencies) != 1 || summary.Currencies[0].NetAmount != 60 { + t.Fatalf("unexpected summary: %#v", summary) + } + events, err := repo.ListPaymentEvents(ctx, bizpayment.PaymentAlipay, order.TradeNo, 100) + if err != nil { + t.Fatal(err) + } + if len(events) < 5 { + t.Fatalf("event count = %d, want at least 5", len(events)) + } + var foundRefund bool + for _, event := range events { + if event.Type == bizpayment.PaymentEventRefundStarted { + foundRefund = event.Message == "客户重复购买" && event.OperatorID == 7 && event.DeviceID == "device-1" + } + } + if !foundRefund { + t.Fatalf("refund audit event missing operation context: %#v", events) + } +} diff --git a/internal/data/system/surface_upgrade.go b/internal/data/system/surface_upgrade.go new file mode 100644 index 0000000..530f272 --- /dev/null +++ b/internal/data/system/surface_upgrade.go @@ -0,0 +1,200 @@ +package system + +import ( + "errors" + "strconv" + "strings" + + platformmodule "kra/pkg/module" + + "gorm.io/gorm" +) + +// EnsureAdminSurface upgrades an already initialized installation with a +// module's current menus, API metadata, root menu links, and root policies. +// It intentionally does nothing before first-install seed data exists. +func EnsureAdminSurface(db *gorm.DB, surface platformmodule.Surface, rootAuthorityID uint) error { + if db == nil || !db.Migrator().HasTable(&menuPO{}) || !db.Migrator().HasTable(&apiPO{}) { + return nil + } + return db.Transaction(func(tx *gorm.DB) error { + parentIDs := map[string]uint{} + for _, item := range surface.Menus { + if item.ParentName == "" { + continue + } + if _, ok := parentIDs[item.ParentName]; ok { + continue + } + var parent menuPO + if err := tx.Where("name = ?", item.ParentName).First(&parent).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + return err + } + parentIDs[item.ParentName] = parent.ID + } + + menus := make([]menuPO, 0, len(surface.Menus)) + for _, item := range surface.Menus { + menu := menuPO{ + MenuLevel: 1, ParentID: parentIDs[item.ParentName], Path: item.Path, Name: item.Name, + Component: item.Component, Title: item.Title, Icon: item.Icon, Sort: item.Sort, + } + if item.ParentName == "" { + menu.MenuLevel = 0 + } + var current menuPO + err := tx.Where("name = ?", item.Name).First(¤t).Error + switch { + case errors.Is(err, gorm.ErrRecordNotFound): + if err = tx.Create(&menu).Error; err != nil { + return err + } + case err != nil: + return err + default: + if err = tx.Model(¤t).Updates(map[string]any{ + "menu_level": menu.MenuLevel, "parent_id": menu.ParentID, "path": menu.Path, + "component": menu.Component, "title": menu.Title, "icon": menu.Icon, "sort": menu.Sort, + }).Error; err != nil { + return err + } + menu.ID = current.ID + } + menus = append(menus, menu) + } + + apis := make([]apiPO, 0, len(surface.APIs)) + for _, item := range surface.APIs { + api := apiPO{Path: item.Path, Method: strings.ToUpper(item.Method), APIGroup: item.Group, Description: item.Description} + var current apiPO + err := tx.Where("path = ? AND method = ?", api.Path, api.Method).First(¤t).Error + switch { + case errors.Is(err, gorm.ErrRecordNotFound): + if err = tx.Create(&api).Error; err != nil { + return err + } + case err != nil: + return err + default: + if err = tx.Model(¤t).Updates(map[string]any{"api_group": api.APIGroup, "description": api.Description}).Error; err != nil { + return err + } + api.ID = current.ID + } + apis = append(apis, api) + } + + if rootAuthorityID == 0 || !tx.Migrator().HasTable(&authorityPO{}) || !tx.Migrator().HasTable(&authorityMenuPO{}) || !tx.Migrator().HasTable(&casbinRulePO{}) { + return nil + } + var root authorityPO + if err := tx.Where("authority_id = ?", rootAuthorityID).First(&root).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + return err + } + for _, menu := range menus { + link := authorityMenuPO{SysAuthorityAuthorityID: rootAuthorityID, SysBaseMenuID: menu.ID} + var count int64 + if err := tx.Model(&authorityMenuPO{}). + Where("sys_authority_authority_id = ? AND sys_base_menu_id = ?", rootAuthorityID, menu.ID). + Count(&count).Error; err != nil { + return err + } + if count == 0 { + if err := tx.Create(&link).Error; err != nil { + return err + } + } + } + for _, api := range apis { + exists, err := policyExists(tx, rootAuthorityID, api.Path, api.Method) + if err != nil { + return err + } + if !exists { + rule := newPolicyRule(rootAuthorityID, api.Path, api.Method) + if err := tx.Create(&rule).Error; err != nil { + return err + } + } + } + return nil + }) +} + +// InheritAPIPolicy grants a new route only to authorities that already own +// the supplied predecessor route. This preserves existing read-access intent +// without broadening unrelated write permissions. +func InheritAPIPolicy(db *gorm.DB, sourcePath, sourceMethod, targetPath, targetMethod string) error { + if db == nil || !db.Migrator().HasTable(&casbinRulePO{}) { + return nil + } + sourceMethod = strings.ToUpper(sourceMethod) + targetMethod = strings.ToUpper(targetMethod) + return db.Transaction(func(tx *gorm.DB) error { + var rows []casbinRulePO + if err := policyScope(tx).Where("v1 = ? AND v2 = ?", sourcePath, sourceMethod).Find(&rows).Error; err != nil { + return err + } + for _, row := range rows { + parsed, err := strconv.ParseUint(row.V0, 10, 64) + if err != nil { + continue + } + authorityID := uint(parsed) + exists, err := policyExists(tx, authorityID, targetPath, targetMethod) + if err != nil { + return err + } + if !exists { + rule := newPolicyRule(authorityID, targetPath, targetMethod) + if err := tx.Create(&rule).Error; err != nil { + return err + } + } + } + return nil + }) +} + +// GrantMenuAPIs makes a visible administration menu usable by granting only +// its explicitly supplied API set to authorities that already own the menu. +func GrantMenuAPIs(db *gorm.DB, menuName string, apis []platformmodule.API) error { + if db == nil || !db.Migrator().HasTable(&menuPO{}) || !db.Migrator().HasTable(&authorityMenuPO{}) || !db.Migrator().HasTable(&casbinRulePO{}) { + return nil + } + return db.Transaction(func(tx *gorm.DB) error { + var menu menuPO + if err := tx.Where("name = ?", menuName).First(&menu).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + return err + } + var links []authorityMenuPO + if err := tx.Where("sys_base_menu_id = ?", menu.ID).Find(&links).Error; err != nil { + return err + } + for _, link := range links { + for _, api := range apis { + method := strings.ToUpper(api.Method) + exists, err := policyExists(tx, link.SysAuthorityAuthorityID, api.Path, method) + if err != nil { + return err + } + if !exists { + rule := newPolicyRule(link.SysAuthorityAuthorityID, api.Path, method) + if err := tx.Create(&rule).Error; err != nil { + return err + } + } + } + } + return nil + }) +} diff --git a/internal/data/system/surface_upgrade_test.go b/internal/data/system/surface_upgrade_test.go new file mode 100644 index 0000000..ffb8e66 --- /dev/null +++ b/internal/data/system/surface_upgrade_test.go @@ -0,0 +1,109 @@ +package system + +import ( + "testing" + + platformmodule "kra/pkg/module" +) + +func TestEnsureAdminSurfaceAndPolicyInheritance(t *testing.T) { + db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared") + if err != nil { + t.Fatal(err) + } + if err = migrateAll(db); err != nil { + t.Fatal(err) + } + parent := menuPO{Name: "extensions", Path: "extensions", Title: "扩展功能"} + if err = db.Create(&parent).Error; err != nil { + t.Fatal(err) + } + rootParent := uint(0) + if err = db.Create(&[]authorityPO{ + {AuthorityID: 888, AuthorityName: "root", ParentID: &rootParent}, + {AuthorityID: 999, AuthorityName: "operator", ParentID: &rootParent}, + }).Error; err != nil { + t.Fatal(err) + } + if err = db.Create(&[]casbinRulePO{ + newPolicyRule(999, "/payment/orders", "GET"), + newPolicyRule(999, "/payment/orders/:provider/:tradeNo", "GET"), + }).Error; err != nil { + t.Fatal(err) + } + surface := platformmodule.Surface{ + Menus: []platformmodule.Menu{ + {Name: "paymentOrders", Path: "paymentOrders", ParentName: "extensions", Component: "view/payment/orders.vue", Title: "支付订单", Sort: 6}, + {Name: "paymentConfig", Path: "paymentConfig", ParentName: "extensions", Component: "view/payment/config.vue", Title: "支付配置", Sort: 7}, + }, + APIs: []platformmodule.API{ + {Path: "/payment/orders", Method: "GET", Group: "支付", Description: "订单列表"}, + {Path: "/payment/orders/summary", Method: "GET", Group: "支付", Description: "订单汇总"}, + {Path: "/payment/orders/:provider/:tradeNo", Method: "GET", Group: "支付", Description: "订单详情"}, + {Path: "/payment/orders/:provider/:tradeNo/events", Method: "GET", Group: "支付", Description: "订单事件"}, + {Path: "/payment/refund", Method: "POST", Group: "支付", Description: "退款"}, + }, + } + for attempt := 0; attempt < 2; attempt++ { + if err = EnsureAdminSurface(db, surface, 888); err != nil { + t.Fatal(err) + } + var paymentMenu menuPO + if err = db.Where("name = ?", "paymentOrders").First(&paymentMenu).Error; err != nil { + t.Fatal(err) + } + var linkCount int64 + if err = db.Model(&authorityMenuPO{}).Where("sys_authority_authority_id = ? AND sys_base_menu_id = ?", 999, paymentMenu.ID).Count(&linkCount).Error; err != nil { + t.Fatal(err) + } + if linkCount == 0 { + if err = db.Create(&authorityMenuPO{SysAuthorityAuthorityID: 999, SysBaseMenuID: paymentMenu.ID}).Error; err != nil { + t.Fatal(err) + } + } + if err = InheritAPIPolicy(db, "/payment/orders", "GET", "/payment/orders/summary", "GET"); err != nil { + t.Fatal(err) + } + if err = InheritAPIPolicy(db, "/payment/orders/:provider/:tradeNo", "GET", "/payment/orders/:provider/:tradeNo/events", "GET"); err != nil { + t.Fatal(err) + } + if err = GrantMenuAPIs(db, "paymentOrders", surface.APIs[:4]); err != nil { + t.Fatal(err) + } + } + for _, test := range []struct { + authority uint + path string + method string + want bool + }{ + {888, "/payment/orders", "GET", true}, + {888, "/payment/refund", "POST", true}, + {999, "/payment/orders/summary", "GET", true}, + {999, "/payment/orders", "GET", true}, + {999, "/payment/orders/:provider/:tradeNo/events", "GET", true}, + {999, "/payment/refund", "POST", false}, + } { + exists, checkErr := policyExists(db, test.authority, test.path, test.method) + if checkErr != nil { + t.Fatal(checkErr) + } + if exists != test.want { + t.Fatalf("policy %d %s %s = %v, want %v", test.authority, test.method, test.path, exists, test.want) + } + } + var apiCount int64 + if err = db.Model(&apiPO{}).Where("path LIKE ?", "/payment/%").Count(&apiCount).Error; err != nil { + t.Fatal(err) + } + if apiCount != int64(len(surface.APIs)) { + t.Fatalf("payment APIs = %d, want %d", apiCount, len(surface.APIs)) + } + var rootLinks int64 + if err = db.Model(&authorityMenuPO{}).Where("sys_authority_authority_id = ?", 888).Count(&rootLinks).Error; err != nil { + t.Fatal(err) + } + if rootLinks != int64(len(surface.Menus)) { + t.Fatalf("root menu links = %d, want %d", rootLinks, len(surface.Menus)) + } +} diff --git a/internal/modules/catalog_test.go b/internal/modules/catalog_test.go index 8dfd29a..ff06848 100644 --- a/internal/modules/catalog_test.go +++ b/internal/modules/catalog_test.go @@ -25,16 +25,16 @@ func TestCatalogContainsBuiltInModulesInDependencyOrder(t *testing.T) { t.Fatalf("definition[%d] = %q, want %q", index, got, name) } } - if got := catalog.MigrationSteps(); len(got) != 8 { - t.Fatalf("module migrations = %d, want 8", len(got)) + if got := catalog.MigrationSteps(); len(got) != 11 { + t.Fatalf("module migrations = %d, want 11", len(got)) } var menus, apis int for _, definition := range catalog.Definitions { menus += len(definition.Surface.Menus) apis += len(definition.Surface.APIs) } - if menus != 3 || apis != 15 { - t.Fatalf("admin surface = %d menus/%d APIs, want 3/15", menus, apis) + if menus != 3 || apis != 17 { + t.Fatalf("admin surface = %d menus/%d APIs, want 3/17", menus, apis) } if got := catalog.DefaultTimedTasks(); len(got) != 2 { t.Fatalf("default timed tasks = %d, want 2", len(got)) diff --git a/internal/modules/payment/definition.go b/internal/modules/payment/definition.go index cd04175..82cacb1 100644 --- a/internal/modules/payment/definition.go +++ b/internal/modules/payment/definition.go @@ -4,21 +4,60 @@ package payment import ( datapayment "kra/internal/data/payment" + datasystem "kra/internal/data/system" "kra/internal/modules/surface" + "kra/pkg/database/migration" "kra/pkg/module" + + "gorm.io/gorm" ) // Definition declares the payment-owned schema and administration surface. func Definition() module.Definition { + menus := []module.Menu{ + {Name: "paymentOrders", Path: "paymentOrders", ParentName: "extensions", Component: "view/systemTools/payment/orders.vue", Title: "支付订单", Icon: "wallet", Sort: 6}, + {Name: "paymentConfig", Path: "paymentConfig", ParentName: "extensions", Component: "view/systemTools/payment/config.vue", Title: "支付配置", Icon: "credit-card", Sort: 7}, + } + adminSurface := module.Surface{ + Menus: menus, + APIs: surface.APIsForPrefix("/payment", 12), + } + // Keep the contents of shipped migrations immutable. Future payment routes + // belong in a new migration instead of silently changing this snapshot. + upgradeSurface := module.Surface{Menus: menus, APIs: []module.API{ + {Path: "/payment/orders", Method: "GET", Group: "支付", Description: "分页查询支付订单"}, + {Path: "/payment/orders/summary", Method: "GET", Group: "支付", Description: "汇总支付订单运营指标"}, + {Path: "/payment/orders/:provider/:tradeNo", Method: "GET", Group: "支付", Description: "按路径查询支付订单"}, + {Path: "/payment/orders/:provider/:tradeNo/events", Method: "GET", Group: "支付", Description: "查询支付订单事件流水"}, + {Path: "/payment/create", Method: "POST", Group: "支付", Description: "创建支付订单"}, + {Path: "/payment/fulfill", Method: "POST", Group: "支付", Description: "重试支付订单发货"}, + {Path: "/payment/order", Method: "POST", Group: "支付", Description: "查询支付订单"}, + {Path: "/payment/orders/:provider/:tradeNo/fulfill", Method: "POST", Group: "支付", Description: "按路径重试支付订单发货"}, + {Path: "/payment/orders/:provider/:tradeNo/refund", Method: "POST", Group: "支付", Description: "按路径申请支付订单退款"}, + {Path: "/payment/providers/:provider/test", Method: "POST", Group: "支付", Description: "测试支付渠道配置与沙箱交易链路"}, + {Path: "/payment/query", Method: "POST", Group: "支付", Description: "同步支付订单状态"}, + {Path: "/payment/refund", Method: "POST", Group: "支付", Description: "申请支付订单退款"}, + }} + orderReadAPIs := make([]module.API, 0, 4) + for _, api := range upgradeSurface.APIs { + if api.Method == "GET" && (api.Path == "/payment/orders" || api.Path == "/payment/orders/summary" || api.Path == "/payment/orders/:provider/:tradeNo" || api.Path == "/payment/orders/:provider/:tradeNo/events") { + orderReadAPIs = append(orderReadAPIs, api) + } + } + migrations := append(datapayment.Migrations(), migration.Step{ID: "202608290002_payment_admin_surface", Migrate: func(db *gorm.DB) error { + if err := datasystem.EnsureAdminSurface(db, upgradeSurface, 888); err != nil { + return err + } + if err := datasystem.InheritAPIPolicy(db, "/payment/orders", "GET", "/payment/orders/summary", "GET"); err != nil { + return err + } + return datasystem.InheritAPIPolicy(db, "/payment/orders/:provider/:tradeNo", "GET", "/payment/orders/:provider/:tradeNo/events", "GET") + }}, migration.Step{ID: "202608290003_payment_order_read_permissions", Migrate: func(db *gorm.DB) error { + return datasystem.GrantMenuAPIs(db, "paymentOrders", orderReadAPIs) + }}) return module.Definition{ Name: "payment", - Migrations: datapayment.Migrations(), - Surface: module.Surface{ - Menus: []module.Menu{ - {Name: "paymentOrders", Path: "paymentOrders", ParentName: "extensions", Component: "view/systemTools/payment/orders.vue", Title: "支付订单", Icon: "wallet", Sort: 6}, - {Name: "paymentConfig", Path: "paymentConfig", ParentName: "extensions", Component: "view/systemTools/payment/config.vue", Title: "支付配置", Icon: "credit-card", Sort: 7}, - }, - APIs: surface.APIsForPrefix("/payment", 10), - }, + Migrations: migrations, + Surface: adminSurface, } } diff --git a/internal/modules/payment/definition_test.go b/internal/modules/payment/definition_test.go index 93fd5b2..a73a1f6 100644 --- a/internal/modules/payment/definition_test.go +++ b/internal/modules/payment/definition_test.go @@ -7,10 +7,10 @@ func TestDefinitionOwnsPaymentContributions(t *testing.T) { if definition.Name != "payment" { t.Fatalf("name = %q, want payment", definition.Name) } - if len(definition.Migrations) != 1 { - t.Fatalf("migrations = %d, want 1", len(definition.Migrations)) + if len(definition.Migrations) != 4 { + t.Fatalf("migrations = %d, want 4", len(definition.Migrations)) } - if len(definition.Surface.Menus) != 2 || len(definition.Surface.APIs) != 10 { - t.Fatalf("surface = %d menus/%d APIs, want 2/10", len(definition.Surface.Menus), len(definition.Surface.APIs)) + if len(definition.Surface.Menus) != 2 || len(definition.Surface.APIs) != 12 { + t.Fatalf("surface = %d menus/%d APIs, want 2/12", len(definition.Surface.Menus), len(definition.Surface.APIs)) } } diff --git a/internal/routecatalog/catalog.go b/internal/routecatalog/catalog.go index c349532..2f8215e 100644 --- a/internal/routecatalog/catalog.go +++ b/internal/routecatalog/catalog.go @@ -86,7 +86,9 @@ var routes = map[string]routeValue{ "GET /logViewer/files": {group: "文件日志", description: "获取日期下的日志文件"}, "GET /menu/getMenuRoles": {group: "菜单", description: "获取菜单关联角色列表"}, "GET /payment/orders": {group: "支付", description: "分页查询支付订单"}, + "GET /payment/orders/summary": {group: "支付", description: "汇总支付订单运营指标"}, "GET /payment/orders/:provider/:tradeNo": {group: "支付", description: "按路径查询支付订单"}, + "GET /payment/orders/:provider/:tradeNo/events": {group: "支付", description: "查询支付订单事件流水"}, "GET /position/findPosition": {group: "岗位", description: "根据ID获取岗位"}, "GET /position/getPositionUsers": {group: "岗位", description: "获取岗位成员ID列表"}, "GET /securityConfig/getSecurityConfig": {group: "安全配置", description: "获取安全配置"}, diff --git a/internal/server/gin_test.go b/internal/server/gin_test.go index 086b16a..8c6b83e 100644 --- a/internal/server/gin_test.go +++ b/internal/server/gin_test.go @@ -97,7 +97,7 @@ func TestGinStartupLogsEveryRegisteredRoute(t *testing.T) { if got, want := strings.Count(text, `"msg":"router registered"`), len(engine.Routes()); got != want { t.Fatalf("registered route log count = %d, want %d", got, want) } - if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":194`) { + if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":196`) { t.Fatalf("startup route summary is missing: %s", text) } } diff --git a/internal/server/handler/payment.go b/internal/server/handler/payment.go index 75232f8..f7bc208 100644 --- a/internal/server/handler/payment.go +++ b/internal/server/handler/payment.go @@ -39,15 +39,16 @@ func (h *Payment) OrderByPath(c *gin.Context) { func (h *Payment) RefundByPath(c *gin.Context) { var req struct { - Amount int64 `json:"amount"` + Amount int64 `json:"amount"` + Reason string `json:"reason"` } if err := c.ShouldBindJSON(&req); err != nil { Fail(c, err.Error()) return } - result, err := h.service.Refund(c.Request.Context(), &dto.PaymentRefundRequest{ - Provider: c.Param("provider"), TradeNo: c.Param("tradeNo"), Amount: req.Amount, - }) + request := &dto.PaymentRefundRequest{Provider: c.Param("provider"), TradeNo: c.Param("tradeNo"), Amount: req.Amount, Reason: req.Reason} + setPaymentRequestActor(c, &request.OperatorID, &request.OperatorName, &request.ClientIP, &request.UserAgent, &request.DeviceID) + result, err := h.service.Refund(c.Request.Context(), request) if err != nil { Fail(c, err.Error()) return @@ -56,9 +57,9 @@ func (h *Payment) RefundByPath(c *gin.Context) { } func (h *Payment) FulfillByPath(c *gin.Context) { - result, err := h.service.Fulfill(c.Request.Context(), &dto.PaymentFulfillRequest{ - Provider: c.Param("provider"), TradeNo: c.Param("tradeNo"), - }) + request := &dto.PaymentFulfillRequest{Provider: c.Param("provider"), TradeNo: c.Param("tradeNo")} + setPaymentRequestActor(c, &request.OperatorID, &request.OperatorName, &request.ClientIP, &request.UserAgent, &request.DeviceID) + result, err := h.service.Fulfill(c.Request.Context(), request) if err != nil { Fail(c, err.Error()) return @@ -80,6 +81,36 @@ func (h *Payment) Orders(c *gin.Context) { Write(c, CodeSuccess, PageResult{List: items, Total: total, Page: page, PageSize: pageSize}, "获取成功") } +func (h *Payment) OrderSummary(c *gin.Context) { + var req dto.PaymentOrderListRequest + if err := c.ShouldBindQuery(&req); err != nil { + Fail(c, err.Error()) + return + } + result, err := h.service.OrderSummary(c.Request.Context(), &req) + if err != nil { + Fail(c, err.Error()) + return + } + OKWithData(c, result) +} + +func (h *Payment) OrderEvents(c *gin.Context) { + var req struct { + Limit int `form:"limit"` + } + if err := c.ShouldBindQuery(&req); err != nil { + Fail(c, err.Error()) + return + } + items, err := h.service.OrderEvents(c.Request.Context(), c.Param("provider"), c.Param("tradeNo"), req.Limit) + if err != nil { + Fail(c, err.Error()) + return + } + OKWithData(c, items) +} + func (h *Payment) TestProvider(c *gin.Context) { result, err := h.service.TestProvider(c.Request.Context(), c.Param("provider")) if err != nil { @@ -99,6 +130,8 @@ func (h *Payment) Create(c *gin.Context) { return } req.ClientIP = c.ClientIP() + req.UserAgent = c.Request.UserAgent() + req.DeviceID = c.GetHeader("X-Device-Id") result, err := h.service.Create(c.Request.Context(), &req) if err != nil { Fail(c, err.Error()) @@ -112,6 +145,7 @@ func (h *Payment) Query(c *gin.Context) { Fail(c, err.Error()) return } + setPaymentRequestActor(c, &req.OperatorID, &req.OperatorName, &req.ClientIP, &req.UserAgent, &req.DeviceID) result, err := h.service.Query(c.Request.Context(), &req) if err != nil { Fail(c, err.Error()) @@ -125,6 +159,7 @@ func (h *Payment) Refund(c *gin.Context) { Fail(c, err.Error()) return } + setPaymentRequestActor(c, &req.OperatorID, &req.OperatorName, &req.ClientIP, &req.UserAgent, &req.DeviceID) result, err := h.service.Refund(c.Request.Context(), &req) if err != nil { Fail(c, err.Error()) @@ -139,6 +174,7 @@ func (h *Payment) Fulfill(c *gin.Context) { Fail(c, err.Error()) return } + setPaymentRequestActor(c, &req.OperatorID, &req.OperatorName, &req.ClientIP, &req.UserAgent, &req.DeviceID) result, err := h.service.Fulfill(c.Request.Context(), &req) if err != nil { Fail(c, err.Error()) @@ -166,13 +202,26 @@ func (h *Payment) Callback(c *gin.Context) { query[k] = v[0] } } - ack, err := h.service.Callback(c.Request.Context(), &dto.PaymentCallbackRequest{Provider: c.Param("provider"), Headers: headers, Body: raw, Query: query}) + ack, err := h.service.Callback(c.Request.Context(), &dto.PaymentCallbackRequest{ + Provider: c.Param("provider"), Headers: headers, Body: raw, Query: query, + ClientIP: c.ClientIP(), UserAgent: c.Request.UserAgent(), + }) writePaymentCallbackAck(c, ack) if err != nil { _ = c.Error(err) } } +func setPaymentRequestActor(c *gin.Context, operatorID *uint, operatorName, clientIP, userAgent, deviceID *string) { + if claims := Claims(c); claims != nil { + *operatorID = claims.ID + *operatorName = claims.Username + } + *clientIP = c.ClientIP() + *userAgent = c.Request.UserAgent() + *deviceID = c.GetHeader("X-Device-Id") +} + func writePaymentCallbackAck(c *gin.Context, ack dto.PaymentCallbackAck) { if ack.StatusCode == 0 { ack.StatusCode = http.StatusInternalServerError diff --git a/internal/server/router/payment.go b/internal/server/router/payment.go index f4ca82a..36bb36d 100644 --- a/internal/server/router/payment.go +++ b/internal/server/router/payment.go @@ -8,9 +8,11 @@ import ( func RegisterPayment(group, public *gin.RouterGroup, h *handler.Payment) { payment := group.Group("/payment") payment.GET("/orders", h.Orders) + payment.GET("/orders/summary", h.OrderSummary) payment.POST("/providers/:provider/test", h.TestProvider) payment.POST("/order", h.Order) payment.GET("/orders/:provider/:tradeNo", h.OrderByPath) + payment.GET("/orders/:provider/:tradeNo/events", h.OrderEvents) payment.POST("/create", h.Create) payment.POST("/query", h.Query) payment.POST("/refund", h.Refund) diff --git a/internal/service/dto/payment.go b/internal/service/dto/payment.go index 0bfbf45..a1a7c34 100644 --- a/internal/service/dto/payment.go +++ b/internal/service/dto/payment.go @@ -6,14 +6,21 @@ import ( ) type PaymentOrderListRequest struct { - Page int `form:"page"` - PageSize int `form:"pageSize"` - Provider string `form:"provider"` - TradeNo string `form:"tradeNo"` - BusinessType string `form:"businessType"` - BusinessID string `form:"businessId"` - PaymentStatus string `form:"paymentStatus"` - RefundStatus string `form:"refundStatus"` + Page int `form:"page"` + PageSize int `form:"pageSize"` + Provider string `form:"provider"` + TradeNo string `form:"tradeNo"` + ProviderTradeNo string `form:"providerTradeNo"` + Keyword string `form:"keyword"` + BusinessType string `form:"businessType"` + BusinessID string `form:"businessId"` + PaymentStatus string `form:"paymentStatus"` + RefundStatus string `form:"refundStatus"` + CreatedFrom string `form:"createdFrom"` + CreatedTo string `form:"createdTo"` + PaidFrom string `form:"paidFrom"` + PaidTo string `form:"paidTo"` + IssueOnly bool `form:"issueOnly"` } type PaymentRequest struct { @@ -30,29 +37,49 @@ type PaymentRequest struct { BusinessType string `json:"businessType"` BusinessID string `json:"businessId"` PaymentMode string `json:"paymentMode"` + UserAgent string `json:"-"` + DeviceID string `json:"-"` } type PaymentQueryRequest struct { - Provider string `json:"provider"` - TradeNo string `json:"tradeNo"` + Provider string `json:"provider"` + TradeNo string `json:"tradeNo"` + OperatorID uint `json:"-"` + OperatorName string `json:"-"` + ClientIP string `json:"-"` + UserAgent string `json:"-"` + DeviceID string `json:"-"` } type PaymentRefundRequest struct { - Provider string `json:"provider"` - TradeNo string `json:"tradeNo"` - Amount int64 `json:"amount"` + Provider string `json:"provider"` + TradeNo string `json:"tradeNo"` + Amount int64 `json:"amount"` + Reason string `json:"reason"` + OperatorID uint `json:"-"` + OperatorName string `json:"-"` + ClientIP string `json:"-"` + UserAgent string `json:"-"` + DeviceID string `json:"-"` } type PaymentFulfillRequest struct { - Provider string `json:"provider"` - TradeNo string `json:"tradeNo"` + Provider string `json:"provider"` + TradeNo string `json:"tradeNo"` + OperatorID uint `json:"-"` + OperatorName string `json:"-"` + ClientIP string `json:"-"` + UserAgent string `json:"-"` + DeviceID string `json:"-"` } // PaymentCallbackRequest is populated by the HTTP transport and converted to // the biz callback object inside service. type PaymentCallbackRequest struct { - Provider string - Headers map[string]string - Body []byte - Query map[string]string + Provider string + Headers map[string]string + Body []byte + Query map[string]string + ClientIP string + UserAgent string } type PaymentCallbackAck struct { @@ -103,38 +130,76 @@ type PaymentTestResult struct { } type PaymentOrderResponse struct { - ID uint64 `json:"ID"` - Provider string `json:"provider"` - TradeNo string `json:"tradeNo"` - ProviderTradeNo string `json:"providerTradeNo"` - BusinessType string `json:"businessType"` - BusinessID string `json:"businessId"` - Subject string `json:"subject"` - PaymentMode string `json:"paymentMode"` - OriginalAmount int64 `json:"originalAmount"` - Amount int64 `json:"amount"` - PaidAmount int64 `json:"paidAmount"` - PayerPaidAmount int64 `json:"payerPaidAmount"` - CashPaidAmount int64 `json:"cashPaidAmount"` - PointPaidAmount int64 `json:"pointPaidAmount"` - DiscountAmount int64 `json:"discountAmount"` - ProviderDiscountAmount int64 `json:"providerDiscountAmount"` - MerchantDiscountAmount int64 `json:"merchantDiscountAmount"` - SettlementAmount int64 `json:"settlementAmount"` - Currency string `json:"currency"` - PayerCurrency string `json:"payerCurrency,omitempty"` - AmountBreakdownKnown bool `json:"amountBreakdownKnown"` - PaymentStatus string `json:"paymentStatus"` - ProviderStatus string `json:"providerStatus"` - FulfillmentStatus string `json:"fulfillmentStatus"` - RefundStatus string `json:"refundStatus"` - RefundedAmount int64 `json:"refundedAmount"` - RefundRequestedAmount int64 `json:"refundRequestedAmount"` - RefundNo string `json:"refundNo,omitempty"` - LastError string `json:"lastError,omitempty"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` - PaidAt *time.Time `json:"paidAt,omitempty"` - FulfilledAt *time.Time `json:"fulfilledAt,omitempty"` - RefundedAt *time.Time `json:"refundedAt,omitempty"` + ID uint64 `json:"ID"` + Provider string `json:"provider"` + TradeNo string `json:"tradeNo"` + ProviderTradeNo string `json:"providerTradeNo"` + BusinessType string `json:"businessType"` + BusinessID string `json:"businessId"` + Subject string `json:"subject"` + PaymentMode string `json:"paymentMode"` + OriginalAmount int64 `json:"originalAmount"` + Amount int64 `json:"amount"` + PaidAmount int64 `json:"paidAmount"` + PayerPaidAmount int64 `json:"payerPaidAmount"` + CashPaidAmount int64 `json:"cashPaidAmount"` + PointPaidAmount int64 `json:"pointPaidAmount"` + DiscountAmount int64 `json:"discountAmount"` + ProviderDiscountAmount int64 `json:"providerDiscountAmount"` + MerchantDiscountAmount int64 `json:"merchantDiscountAmount"` + SettlementAmount int64 `json:"settlementAmount"` + Currency string `json:"currency"` + PayerCurrency string `json:"payerCurrency,omitempty"` + AmountBreakdownKnown bool `json:"amountBreakdownKnown"` + PaymentStatus string `json:"paymentStatus"` + ProviderStatus string `json:"providerStatus"` + FulfillmentStatus string `json:"fulfillmentStatus"` + RefundStatus string `json:"refundStatus"` + RefundedAmount int64 `json:"refundedAmount"` + RefundRequestedAmount int64 `json:"refundRequestedAmount"` + RefundNo string `json:"refundNo,omitempty"` + LastError string `json:"lastError,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + PaidAt *time.Time `json:"paidAt,omitempty"` + FulfilledAt *time.Time `json:"fulfilledAt,omitempty"` + RefundedAt *time.Time `json:"refundedAt,omitempty"` + Events []*PaymentEventResponse `json:"events,omitempty"` +} + +type PaymentEventResponse struct { + ID uint64 `json:"id"` + Type string `json:"type"` + Source string `json:"source"` + Status string `json:"status"` + ProviderStatus string `json:"providerStatus,omitempty"` + Message string `json:"message,omitempty"` + EventID string `json:"eventId,omitempty"` + PayloadHash string `json:"payloadHash,omitempty"` + Amount int64 `json:"amount,omitempty"` + Currency string `json:"currency,omitempty"` + OperatorID uint `json:"operatorId,omitempty"` + OperatorName string `json:"operatorName,omitempty"` + ClientIP string `json:"clientIp,omitempty"` + UserAgent string `json:"userAgent,omitempty"` + DeviceID string `json:"deviceId,omitempty"` + CreatedAt time.Time `json:"createdAt"` +} + +type PaymentCurrencySummaryResponse struct { + Currency string `json:"currency"` + OrderAmount int64 `json:"orderAmount"` + PaidAmount int64 `json:"paidAmount"` + RefundedAmount int64 `json:"refundedAmount"` + SettlementAmount int64 `json:"settlementAmount"` + NetAmount int64 `json:"netAmount"` +} + +type PaymentOrderSummaryResponse struct { + OrderCount int64 `json:"orderCount"` + PendingCount int64 `json:"pendingCount"` + PaidCount int64 `json:"paidCount"` + RefundedCount int64 `json:"refundedCount"` + IssueCount int64 `json:"issueCount"` + Currencies []*PaymentCurrencySummaryResponse `json:"currencies"` } diff --git a/internal/service/payment/payment.go b/internal/service/payment/payment.go index a87f6bf..7bde468 100644 --- a/internal/service/payment/payment.go +++ b/internal/service/payment/payment.go @@ -4,6 +4,8 @@ import ( "context" "errors" paymentbiz "kra/internal/biz/payment" + "strings" + "time" "kra/internal/service/dto" ) @@ -16,7 +18,16 @@ func (s *PaymentService) Order(ctx context.Context, provider, tradeNo string) (* if err != nil { return nil, err } - return paymentOrderResponse(order) + response, err := paymentOrderResponse(order) + if err != nil { + return nil, err + } + events, err := s.uc.OrderEvents(ctx, provider, tradeNo, 100) + if err != nil { + return nil, err + } + response.Events = paymentEventResponses(events) + return response, nil } func (s *PaymentService) Orders(ctx context.Context, req *dto.PaymentOrderListRequest) ([]*dto.PaymentOrderResponse, int64, int, int, error) { @@ -30,10 +41,11 @@ func (s *PaymentService) Orders(ctx context.Context, req *dto.PaymentOrderListRe if pageSize <= 0 { pageSize = 10 } - items, total, err := s.uc.Orders(ctx, page, pageSize, paymentbiz.PaymentOrderFilter{ - Provider: req.Provider, TradeNo: req.TradeNo, BusinessType: req.BusinessType, - BusinessID: req.BusinessID, PaymentStatus: req.PaymentStatus, RefundStatus: req.RefundStatus, - }) + filter, err := paymentOrderFilter(req) + if err != nil { + return nil, 0, page, pageSize, err + } + items, total, err := s.uc.Orders(ctx, page, pageSize, filter) if err != nil { return nil, 0, page, pageSize, err } @@ -48,6 +60,113 @@ func (s *PaymentService) Orders(ctx context.Context, req *dto.PaymentOrderListRe return result, total, page, pageSize, nil } +func (s *PaymentService) OrderSummary(ctx context.Context, req *dto.PaymentOrderListRequest) (*dto.PaymentOrderSummaryResponse, error) { + filter, err := paymentOrderFilter(req) + if err != nil { + return nil, err + } + summary, err := s.uc.OrderSummary(ctx, filter) + if err != nil { + return nil, err + } + response := &dto.PaymentOrderSummaryResponse{ + OrderCount: summary.OrderCount, PendingCount: summary.PendingCount, PaidCount: summary.PaidCount, + RefundedCount: summary.RefundedCount, IssueCount: summary.IssueCount, + Currencies: make([]*dto.PaymentCurrencySummaryResponse, 0, len(summary.Currencies)), + } + for _, item := range summary.Currencies { + response.Currencies = append(response.Currencies, &dto.PaymentCurrencySummaryResponse{ + Currency: item.Currency, OrderAmount: item.OrderAmount, PaidAmount: item.PaidAmount, + RefundedAmount: item.RefundedAmount, SettlementAmount: item.SettlementAmount, NetAmount: item.NetAmount, + }) + } + return response, nil +} + +func (s *PaymentService) OrderEvents(ctx context.Context, provider, tradeNo string, limit int) ([]*dto.PaymentEventResponse, error) { + events, err := s.uc.OrderEvents(ctx, provider, tradeNo, limit) + if err != nil { + return nil, err + } + return paymentEventResponses(events), nil +} + +func paymentOrderFilter(req *dto.PaymentOrderListRequest) (paymentbiz.PaymentOrderFilter, error) { + if req == nil { + return paymentbiz.PaymentOrderFilter{}, errors.New("支付订单筛选请求为空") + } + createdFrom, err := parsePaymentFilterTime(req.CreatedFrom, false) + if err != nil { + return paymentbiz.PaymentOrderFilter{}, err + } + createdTo, err := parsePaymentFilterTime(req.CreatedTo, true) + if err != nil { + return paymentbiz.PaymentOrderFilter{}, err + } + paidFrom, err := parsePaymentFilterTime(req.PaidFrom, false) + if err != nil { + return paymentbiz.PaymentOrderFilter{}, err + } + paidTo, err := parsePaymentFilterTime(req.PaidTo, true) + if err != nil { + return paymentbiz.PaymentOrderFilter{}, err + } + if createdFrom != nil && createdTo != nil && !createdFrom.Before(*createdTo) { + return paymentbiz.PaymentOrderFilter{}, errors.New("订单创建时间范围无效") + } + if paidFrom != nil && paidTo != nil && !paidFrom.Before(*paidTo) { + return paymentbiz.PaymentOrderFilter{}, errors.New("支付完成时间范围无效") + } + return paymentbiz.PaymentOrderFilter{ + Provider: req.Provider, TradeNo: req.TradeNo, ProviderTradeNo: req.ProviderTradeNo, Keyword: req.Keyword, + BusinessType: req.BusinessType, BusinessID: req.BusinessID, PaymentStatus: req.PaymentStatus, + RefundStatus: req.RefundStatus, CreatedFrom: createdFrom, CreatedTo: createdTo, + PaidFrom: paidFrom, PaidTo: paidTo, IssueOnly: req.IssueOnly, + }, nil +} + +func parsePaymentFilterTime(value string, endOfDate bool) (*time.Time, error) { + value = strings.TrimSpace(value) + if value == "" { + return nil, nil + } + if parsed, err := time.Parse(time.RFC3339, value); err == nil { + return &parsed, nil + } + parsed, err := time.ParseInLocation("2006-01-02", value, time.Local) + if err != nil { + return nil, errors.New("支付订单时间格式无效") + } + if endOfDate { + parsed = parsed.AddDate(0, 0, 1) + } + return &parsed, nil +} + +func paymentEventResponses(events []*paymentbiz.PaymentEvent) []*dto.PaymentEventResponse { + result := make([]*dto.PaymentEventResponse, 0, len(events)) + for _, event := range events { + if event == nil { + continue + } + result = append(result, &dto.PaymentEventResponse{ + ID: event.ID, Type: event.Type, Source: event.Source, Status: event.Status, + ProviderStatus: event.ProviderStatus, Message: event.Message, EventID: event.EventID, + PayloadHash: event.PayloadHash, Amount: event.Amount, Currency: event.Currency, + OperatorID: event.OperatorID, OperatorName: event.OperatorName, ClientIP: event.ClientIP, + UserAgent: event.UserAgent, DeviceID: event.DeviceID, CreatedAt: event.CreatedAt, + }) + } + return result +} + +func operationContext(ctx context.Context, source, reason string, operatorID uint, operatorName, clientIP, userAgent, deviceID string) context.Context { + return paymentbiz.WithPaymentOperation(ctx, paymentbiz.PaymentOperation{ + Source: source, Reason: reason, OperatorID: operatorID, OperatorName: operatorName, + ClientIP: clientIP, UserAgent: userAgent, DeviceID: deviceID, + }) +} + func paymentOrderResponse(order *paymentbiz.PaymentOrder) (*dto.PaymentOrderResponse, error) { if order == nil { return nil, errors.New("支付订单为空") @@ -75,7 +194,7 @@ func (s *PaymentService) Create(ctx context.Context, req *dto.PaymentRequest) (* Amount: req.Amount, OriginalAmount: req.OriginalAmount, Currency: req.Currency, NotifyURL: req.NotifyURL, ReturnURL: req.ReturnURL, ClientIP: req.ClientIP, Extra: req.Extra, BusinessType: req.BusinessType, BusinessID: req.BusinessID, - PaymentMode: req.PaymentMode, + PaymentMode: req.PaymentMode, UserAgent: req.UserAgent, DeviceID: req.DeviceID, }) return paymentResultResponse(result), err } @@ -83,6 +202,7 @@ func (s *PaymentService) Query(ctx context.Context, req *dto.PaymentQueryRequest if req == nil { return nil, errors.New("支付查询请求为空") } + ctx = operationContext(ctx, "admin", "主动同步支付平台状态", req.OperatorID, req.OperatorName, req.ClientIP, req.UserAgent, req.DeviceID) result, err := s.uc.Query(ctx, req.Provider, req.TradeNo) return paymentResultResponse(result), err } @@ -90,6 +210,11 @@ func (s *PaymentService) Refund(ctx context.Context, req *dto.PaymentRefundReque if req == nil { return nil, errors.New("支付退款请求为空") } + reason := strings.TrimSpace(req.Reason) + if reason == "" { + reason = "未填写退款原因" + } + ctx = operationContext(ctx, "admin", reason, req.OperatorID, req.OperatorName, req.ClientIP, req.UserAgent, req.DeviceID) result, err := s.uc.Refund(ctx, req.Provider, req.TradeNo, req.Amount) return paymentResultResponse(result), err } @@ -111,6 +236,7 @@ func (s *PaymentService) Fulfill(ctx context.Context, req *dto.PaymentFulfillReq if req == nil { return nil, errors.New("支付发货请求为空") } + ctx = operationContext(ctx, "admin", "人工执行或重试业务发货", req.OperatorID, req.OperatorName, req.ClientIP, req.UserAgent, req.DeviceID) result, err := s.uc.Fulfill(ctx, req.Provider, req.TradeNo) return paymentResultResponse(result), err } @@ -118,6 +244,7 @@ func (s *PaymentService) Callback(ctx context.Context, req *dto.PaymentCallbackR if req == nil { return paymentCallbackAckResponse(paymentbiz.DefaultPaymentCallbackAck("", false)), errors.New("支付回调请求为空") } + ctx = operationContext(ctx, req.Provider, "支付平台异步通知", 0, "", req.ClientIP, req.UserAgent, "") result, err := s.uc.Callback(ctx, &paymentbiz.PaymentCallback{Provider: req.Provider, Headers: req.Headers, Body: req.Body, Query: req.Query}) if err != nil { return paymentCallbackAckResponse(paymentbiz.CallbackFailure(err, paymentbiz.DefaultPaymentCallbackAck(req.Provider, false))), err diff --git a/web/src/api/payment.js b/web/src/api/payment.js index e714be5..debcc92 100644 --- a/web/src/api/payment.js +++ b/web/src/api/payment.js @@ -7,10 +7,23 @@ export const getPaymentOrders = (params) => service({ donNotShowLoading: true }) -export const getPaymentOrder = (data) => service({ - url: '/payment/order', - method: 'post', - data, +export const getPaymentOrderSummary = (params) => service({ + url: '/payment/orders/summary', + method: 'get', + params, + donNotShowLoading: true +}) + +export const getPaymentOrderEvents = (provider, tradeNo, params = {}) => service({ + url: `/payment/orders/${encodeURIComponent(provider)}/${encodeURIComponent(tradeNo)}/events`, + method: 'get', + params, + donNotShowLoading: true +}) + +export const getPaymentOrder = ({ provider, tradeNo }) => service({ + url: `/payment/orders/${encodeURIComponent(provider)}/${encodeURIComponent(tradeNo)}`, + method: 'get', donNotShowLoading: true }) diff --git a/web/src/view/systemTools/payment/orders.vue b/web/src/view/systemTools/payment/orders.vue index a8786b8..e878daa 100644 --- a/web/src/view/systemTools/payment/orders.vue +++ b/web/src/view/systemTools/payment/orders.vue @@ -1,5 +1,5 @@ @@ -175,6 +230,9 @@ {{ refundPrecisionText }} + + +