优化结构

This commit is contained in:
Yvan 2026-08-29 02:58:00 +08:00
parent 3d108ebdfe
commit b6d25c1a0f
24 changed files with 1657 additions and 224 deletions

View File

@ -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)

View File

@ -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
}

View File

@ -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)

View File

@ -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...)

View File

@ -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 {

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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{})
}},
}
}

View File

@ -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
}

View File

@ -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(&current).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,
})
})
}

View File

@ -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)
}
}

View File

@ -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(&current).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(&current).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(&current).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(&current).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
})
}

View File

@ -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))
}
}

View File

@ -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))

View File

@ -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,
}
}

View File

@ -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))
}
}

View File

@ -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: "获取安全配置"},

View File

@ -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)
}
}

View File

@ -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

View File

@ -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)

View File

@ -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"`
}

View File

@ -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

View File

@ -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
})

View File

@ -1,5 +1,5 @@
<template>
<div class="payment-orders">
<div class="kra-table-box payment-orders">
<header class="page-heading">
<div>
<h2>支付订单</h2>
@ -8,111 +8,157 @@
<el-button :icon="Refresh" :loading="loading" @click="load()">刷新</el-button>
</header>
<section class="status-summary" aria-label="当前页订单概览">
<div v-for="item in pageSummary" :key="item.label" class="summary-item">
<span>{{ item.label }}</span>
<section class="status-summary" aria-label="筛选范围订单概览">
<div v-for="item in systemSummary" :key="item.label" class="summary-item" :class="`is-${item.tone}`">
<div class="summary-copy"><span>{{ item.label }}</span><small>{{ item.hint }}</small></div>
<strong>{{ item.value }}</strong>
<small>{{ item.hint }}</small>
<el-icon class="summary-icon"><component :is="item.icon" /></el-icon>
</div>
</section>
<div class="kra-search-box filter-band">
<section class="filter-band">
<el-form :model="searchInfo" label-position="top" @submit.prevent="reload">
<div class="filter-grid">
<div class="primary-filter-grid">
<el-form-item label="订单搜索" class="keyword-filter">
<el-input v-model="searchInfo.keyword" clearable placeholder="订单号、渠道单号、业务 ID 或商品标题" :prefix-icon="Search" />
</el-form-item>
<el-form-item label="支付渠道">
<el-select v-model="searchInfo.provider" clearable filterable placeholder="全部渠道">
<el-option v-for="item in providerOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="商户订单号">
<el-input v-model="searchInfo.tradeNo" clearable placeholder="支持模糊查询" />
</el-form-item>
<el-form-item label="业务类型">
<el-input v-model="searchInfo.businessType" clearable placeholder="精确匹配" />
</el-form-item>
<el-form-item label="业务 ID">
<el-input v-model="searchInfo.businessId" clearable placeholder="支持模糊查询" />
</el-form-item>
<el-form-item label="支付状态">
<el-select v-model="searchInfo.paymentStatus" clearable placeholder="全部状态">
<el-option v-for="item in paymentStatusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="退款状态">
<el-select v-model="searchInfo.refundStatus" clearable placeholder="全部状态">
<el-option v-for="item in refundStatusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<div class="filter-actions">
<el-button type="primary" :icon="Search" @click="reload">查询</el-button>
<el-button :icon="Refresh" @click="reset">重置</el-button>
<el-button text :icon="Filter" @click="advancedVisible = !advancedVisible">{{ advancedVisible ? '收起' : '更多筛选' }}</el-button>
</div>
</div>
<el-collapse-transition>
<div v-show="advancedVisible" class="advanced-filter-grid">
<el-form-item label="商户订单号"><el-input v-model="searchInfo.tradeNo" clearable placeholder="支持模糊查询" /></el-form-item>
<el-form-item label="第三方交易号"><el-input v-model="searchInfo.providerTradeNo" clearable placeholder="支付宝、微信等渠道单号" /></el-form-item>
<el-form-item label="业务类型"><el-input v-model="searchInfo.businessType" clearable placeholder="精确匹配" /></el-form-item>
<el-form-item label="业务 ID"><el-input v-model="searchInfo.businessId" clearable placeholder="支持模糊查询" /></el-form-item>
<el-form-item label="退款状态">
<el-select v-model="searchInfo.refundStatus" clearable placeholder="全部状态">
<el-option v-for="item in refundStatusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="创建时间" class="date-filter">
<el-date-picker v-model="searchInfo.createdRange" type="daterange" value-format="YYYY-MM-DD" range-separator="" start-placeholder="开始日期" end-placeholder="结束日期" clearable />
</el-form-item>
<el-form-item label="异常订单" class="issue-filter"><el-checkbox v-model="searchInfo.issueOnly">只看需要人工关注的订单</el-checkbox></el-form-item>
</div>
</el-collapse-transition>
<div class="quick-range">
<span>时间快捷筛选</span>
<el-radio-group v-model="quickRange" size="small" @change="applyQuickRange">
<el-radio-button value="today">今天</el-radio-button>
<el-radio-button value="7d"> 7 </el-radio-button>
<el-radio-button value="30d"> 30 </el-radio-button>
<el-radio-button value="90d"> 90 </el-radio-button>
<el-radio-button value="all">全部</el-radio-button>
</el-radio-group>
</div>
</el-form>
</div>
</section>
<div class="kra-table-box order-table-band">
<section class="order-table-band">
<div class="table-heading">
<div><strong>订单明细</strong><span> {{ total }} </span></div>
<span v-if="issueCount" class="issue-count">{{ issueCount }} 笔需要关注</span>
</div>
<el-table v-loading="loading" :data="rows" row-key="ID" stripe :row-class-name="rowClassName">
<el-table-column label="订单" min-width="250">
<el-table v-if="!isMobile" v-loading="loading" :data="rows" row-key="ID" :row-class-name="rowClassName">
<el-table-column label="订单与交易" min-width="285">
<template #default="scope">
<div class="order-cell">
<button type="button" class="order-link" @click="openDetail(scope.row)">{{ scope.row.tradeNo || '-' }}</button>
<span>{{ scope.row.subject || '未提供商品标题' }}</span>
<span v-if="scope.row.providerTradeNo">渠道单号 {{ scope.row.providerTradeNo }}</span>
<span>{{ scope.row.businessType || '-' }} · {{ scope.row.businessId || '-' }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="业务" min-width="165">
<el-table-column label="商品与业务" min-width="220">
<template #default="scope">
<div class="business-cell"><span>{{ scope.row.businessType || '-' }}</span><small>{{ scope.row.businessId || '-' }}</small></div>
<div class="business-cell"><span>{{ scope.row.subject || '未提供商品标题' }}</span><small>{{ scope.row.businessType || '-' }} · {{ scope.row.businessId || '-' }}</small></div>
</template>
</el-table-column>
<el-table-column label="渠道" width="135">
<template #default="scope">
<div class="provider-cell"><span>{{ providerText(scope.row.provider) }}</span><small>{{ scope.row.provider || '-' }}</small></div>
</template>
</el-table-column>
<el-table-column label="金额" width="145" align="right">
<el-table-column label="金额与结算" width="165" align="right">
<template #default="scope">
<div class="amount-cell">
<strong>{{ formatAmount(scope.row.amount, scope.row.currency) }}</strong>
<small v-if="scope.row.paidAmount">实付 {{ formatAmount(scope.row.paidAmount, scope.row.currency) }}</small>
<small v-if="scope.row.refundedAmount" class="refund-amount">已退 {{ formatAmount(scope.row.refundedAmount, scope.row.currency) }}</small>
</div>
</template>
</el-table-column>
<el-table-column label="支付" width="115" align="center">
<template #default="scope"><el-tag :type="paymentStatusType(scope.row.paymentStatus)">{{ paymentStatusText(scope.row.paymentStatus) }}</el-tag></template>
<el-table-column label="状态" width="230">
<template #default="scope">
<div class="status-stack">
<el-tag size="small" :type="paymentStatusType(scope.row.paymentStatus)">{{ paymentStatusText(scope.row.paymentStatus) }}</el-tag>
<el-tag size="small" :type="fulfillmentStatusType(scope.row.fulfillmentStatus)" effect="plain">{{ fulfillmentStatusText(scope.row.fulfillmentStatus, scope.row) }}</el-tag>
<el-tag v-if="scope.row.refundStatus !== 'none'" size="small" :type="refundStatusType(scope.row.refundStatus)" effect="plain">{{ refundStatusText(scope.row.refundStatus) }}</el-tag>
</div>
</template>
</el-table-column>
<el-table-column label="发货" width="115" align="center">
<template #default="scope"><el-tag :type="fulfillmentStatusType(scope.row.fulfillmentStatus)" effect="plain">{{ fulfillmentStatusText(scope.row.fulfillmentStatus, scope.row) }}</el-tag></template>
<el-table-column label="渠道与时间" width="190">
<template #default="scope">
<div class="provider-cell"><span>{{ providerText(scope.row.provider) }}</span><small>{{ formatDateValue(scope.row.createdAt) }}</small><small v-if="scope.row.paidAt">支付 {{ formatDateValue(scope.row.paidAt) }}</small></div>
</template>
</el-table-column>
<el-table-column label="退款" width="120" align="center">
<template #default="scope"><el-tag :type="refundStatusType(scope.row.refundStatus)" effect="plain">{{ refundStatusText(scope.row.refundStatus) }}</el-tag></template>
</el-table-column>
<el-table-column label="创建时间" width="170">
<template #default="scope">{{ formatDateValue(scope.row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" :fixed="operationFixed" width="310">
<el-table-column label="操作" :fixed="operationFixed" width="154" align="right">
<template #default="scope">
<div class="row-actions">
<el-button link type="primary" :icon="View" :disabled="isRowBusy(scope.row)" @click="openDetail(scope.row)">详情</el-button>
<el-button v-if="canSync(scope.row)" link type="primary" :icon="RefreshRight" :loading="rowAction(scope.row) === 'sync'" :disabled="isRowBusy(scope.row) && rowAction(scope.row) !== 'sync'" @click="syncOrder(scope.row)">同步</el-button>
<el-button v-if="canRefund(scope.row)" link type="warning" :icon="Money" :disabled="isRowBusy(scope.row)" @click="openRefund(scope.row)">退款</el-button>
<el-button v-if="canFulfill(scope.row)" link type="success" :icon="Promotion" :loading="rowAction(scope.row) === 'fulfill'" :disabled="isRowBusy(scope.row) && rowAction(scope.row) !== 'fulfill'" @click="retryFulfillment(scope.row)">{{ fulfillmentActionText(scope.row) }}</el-button>
<el-tooltip content="查看详情"><el-button circle :icon="View" aria-label="查看订单详情" :disabled="isRowBusy(scope.row)" @click="openDetail(scope.row)" /></el-tooltip>
<el-tooltip v-if="canSync(scope.row)" content="同步渠道状态"><el-button circle :icon="RefreshRight" aria-label="同步渠道状态" :loading="rowAction(scope.row) === 'sync'" :disabled="isRowBusy(scope.row) && rowAction(scope.row) !== 'sync'" @click="syncOrder(scope.row)" /></el-tooltip>
<el-dropdown v-if="canRefund(scope.row) || canFulfill(scope.row)" trigger="click" @command="(command) => handleRowCommand(command, scope.row)">
<el-button circle :icon="MoreFilled" :disabled="isRowBusy(scope.row)" aria-label="更多订单操作" />
<template #dropdown><el-dropdown-menu>
<el-dropdown-item command="refund" :disabled="!canRefund(scope.row)" :icon="Money">申请退款</el-dropdown-item>
<el-dropdown-item command="fulfill" :disabled="!canFulfill(scope.row)" :icon="Promotion">{{ fulfillmentActionText(scope.row) }}</el-dropdown-item>
</el-dropdown-menu></template>
</el-dropdown>
</div>
</template>
</el-table-column>
<template #empty><el-empty description="没有符合条件的支付订单" /></template>
</el-table>
<div v-else v-loading="loading" class="mobile-order-list">
<article v-for="order in rows" :key="order.ID" class="mobile-order" :class="{ 'is-payment-issue': isIssueOrder(order) }">
<button type="button" class="mobile-order-main" @click="openDetail(order)">
<span class="mobile-order-title"><strong>{{ order.subject || '未提供商品标题' }}</strong><small>{{ providerText(order.provider) }}</small></span>
<span class="mobile-order-amount">{{ formatAmount(order.amount, order.currency) }}</span>
</button>
<div class="mobile-order-identities"><span>{{ order.tradeNo }}</span><small v-if="order.providerTradeNo">{{ order.providerTradeNo }}</small></div>
<div class="mobile-order-meta"><span>{{ order.businessType || '-' }} · {{ order.businessId || '-' }}</span><span>{{ formatDateValue(order.createdAt) }}</span></div>
<div class="mobile-order-footer">
<div class="status-stack">
<el-tag size="small" :type="paymentStatusType(order.paymentStatus)">{{ paymentStatusText(order.paymentStatus) }}</el-tag>
<el-tag size="small" :type="fulfillmentStatusType(order.fulfillmentStatus)" effect="plain">{{ fulfillmentStatusText(order.fulfillmentStatus, order) }}</el-tag>
<el-tag v-if="order.refundStatus !== 'none'" size="small" :type="refundStatusType(order.refundStatus)" effect="plain">{{ refundStatusText(order.refundStatus) }}</el-tag>
</div>
<div class="mobile-order-actions">
<el-button link type="primary" :icon="View" :disabled="isRowBusy(order)" @click="openDetail(order)">详情</el-button>
<el-button v-if="canSync(order)" link type="primary" :icon="RefreshRight" :loading="rowAction(order) === 'sync'" :disabled="isRowBusy(order) && rowAction(order) !== 'sync'" @click="syncOrder(order)">同步</el-button>
<el-button v-if="canRefund(order)" link type="warning" :icon="Money" :disabled="isRowBusy(order)" @click="openRefund(order)">退款</el-button>
<el-button v-if="canFulfill(order)" link type="success" :icon="Promotion" :loading="rowAction(order) === 'fulfill'" :disabled="isRowBusy(order) && rowAction(order) !== 'fulfill'" @click="retryFulfillment(order)">{{ fulfillmentActionText(order) }}</el-button>
</div>
</div>
</article>
<el-empty v-if="!loading && !rows.length" description="没有符合条件的支付订单" />
</div>
<div class="kra-pagination">
<el-pagination :current-page="page" :page-size="pageSize" :page-sizes="[10, 30, 50, 100]" :total="total" :layout="paginationLayout" @current-change="changePage" @size-change="changeSize" />
</div>
</div>
</section>
<el-drawer v-model="detailVisible" title="支付订单详情" :size="drawerSize" destroy-on-close>
<div v-loading="detailLoading" class="order-detail">
@ -123,6 +169,11 @@
<el-tag :type="fulfillmentStatusType(detail.fulfillmentStatus)" effect="plain">{{ fulfillmentStatusText(detail.fulfillmentStatus, detail) }}</el-tag>
<el-tag :type="refundStatusType(detail.refundStatus)" effect="plain">{{ refundStatusText(detail.refundStatus) }}</el-tag>
</div>
<div class="detail-navigation">
<el-tooltip content="上一笔"><el-button circle :icon="ArrowLeft" aria-label="上一笔订单" :disabled="detailIndex <= 0" @click="navigateDetail(-1)" /></el-tooltip>
<span>{{ detailIndex >= 0 ? `${detailIndex + 1} / ${rows.length}` : '-' }}</span>
<el-tooltip content="下一笔"><el-button circle :icon="ArrowRight" aria-label="下一笔订单" :disabled="detailIndex < 0 || detailIndex >= rows.length - 1" @click="navigateDetail(1)" /></el-tooltip>
</div>
<div class="detail-actions">
<el-button v-if="canSync(detail)" :icon="RefreshRight" :loading="rowAction(detail) === 'sync'" :disabled="isRowBusy(detail) && rowAction(detail) !== 'sync'" @click="syncOrder(detail)">同步状态</el-button>
<el-button v-if="canFulfill(detail)" type="success" plain :icon="Promotion" :loading="rowAction(detail) === 'fulfill'" :disabled="isRowBusy(detail) && rowAction(detail) !== 'fulfill'" @click="retryFulfillment(detail)">{{ fulfillmentActionText(detail) }}</el-button>
@ -155,9 +206,13 @@
</div>
<el-alert v-if="!detail.amountBreakdownKnown && detail.paymentStatus === 'paid'" type="info" :closable="false" class="amount-alert" title="支付渠道未返回完整的实付、优惠与结算拆分。" />
<h3 class="section-heading">处理时间</h3>
<h3 class="section-heading">支付通知事件与审计日志</h3>
<el-timeline class="order-timeline">
<el-timeline-item v-for="item in detailTimeline" :key="item.label" :timestamp="item.time" :type="item.type">{{ item.label }}</el-timeline-item>
<el-timeline-item v-for="item in detailTimeline" :key="item.key" :timestamp="item.time" :type="item.type">
<div class="event-heading"><strong>{{ item.label }}</strong><el-tag size="small" effect="plain">{{ item.source }}</el-tag></div>
<p v-if="item.message" class="event-message">{{ item.message }}</p>
<div v-if="item.meta.length" class="event-meta"><span v-for="meta in item.meta" :key="meta">{{ meta }}</span></div>
</el-timeline-item>
</el-timeline>
</template>
</div>
@ -175,6 +230,9 @@
</el-input>
<span class="form-tip">{{ refundPrecisionText }}</span>
</el-form-item>
<el-form-item label="退款原因" prop="reason">
<el-input v-model="refundForm.reason" type="textarea" :rows="3" maxlength="200" show-word-limit placeholder="用于订单事件审计和售后追踪" />
</el-form-item>
</el-form>
<template #footer>
<el-button :disabled="refundSubmitting" @click="refundVisible = false">取消</el-button>
@ -185,11 +243,11 @@
</template>
<script setup>
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { useWindowSize } from '@vueuse/core'
import { ElMessage, ElMessageBox } from 'element-plus'
import { CopyDocument, Money, Promotion, Refresh, RefreshRight, Search, View } from '@element-plus/icons-vue'
import { fulfillPaymentOrder, getPaymentOrder, getPaymentOrders, queryPaymentOrder, refundPaymentOrder } from '@/api/payment'
import { ArrowLeft, ArrowRight, CircleCheck, CopyDocument, Filter, Money, MoreFilled, Promotion, Refresh, RefreshRight, Search, Tickets, View, Wallet, WarningFilled } from '@element-plus/icons-vue'
import { fulfillPaymentOrder, getPaymentOrder, getPaymentOrders, getPaymentOrderSummary, queryPaymentOrder, refundPaymentOrder } from '@/api/payment'
import { formatDate } from '@/utils/format'
defineOptions({ name: 'PaymentOrders' })
@ -209,6 +267,17 @@ const REFUND_STATUS_META = {
pending: { label: '渠道处理中', type: 'warning' }, partial: { label: '部分退款', type: 'warning' },
succeeded: { label: '已退款', type: 'success' }, failed: { label: '退款失败', type: 'danger' }
}
const EVENT_META = {
order_created: { label: '订单创建', type: 'primary' },
provider_created: { label: '渠道下单完成', type: 'primary' },
payment_synchronized: { label: '支付状态同步', type: 'success' },
payment_callback: { label: '支付平台通知', type: 'success' },
fulfillment_started: { label: '开始业务发货', type: 'warning' },
fulfillment_finished: { label: '业务发货完成', type: 'success' },
refund_started: { label: '发起退款', type: 'warning' },
refund_submitted: { label: '退款已提交渠道', type: 'warning' },
refund_confirmed: { label: '退款结果确认', type: 'warning' }
}
const providerOptions = [
['alipay', '支付宝'], ['alipay-v3', '支付宝 V3'], ['wechat-v2', '微信支付 V2'], ['wechat-v3', '微信支付 V3'],
['apple-iap', 'Apple IAP'], ['douyin', '抖音支付'], ['qq', 'QQ 钱包'], ['allinpay', '通联支付'],
@ -216,6 +285,7 @@ const providerOptions = [
['supper-pay', 'Supper Pay'], ['wechat-game-pay', '微信小游戏支付'], ['douyin-game-pay', '抖音小游戏支付'], ['internal', '内部支付']
].map(([value, label]) => ({ value, label }))
const providerNames = Object.fromEntries(providerOptions.map((item) => [item.value, item.label]))
Object.assign(providerNames, { admin: '后台人工操作', client: '客户端', system: '系统任务', internal: '内部支付' })
const paymentStatusOptions = Object.entries(PAYMENT_STATUS_META).map(([value, item]) => ({ value, label: item.label }))
const refundStatusOptions = Object.entries(REFUND_STATUS_META).map(([value, item]) => ({ value, label: item.label }))
const ZERO_DECIMAL_CURRENCIES = new Set(['BIF', 'CLP', 'DJF', 'GNF', 'ISK', 'JPY', 'KMF', 'KRW', 'PYG', 'RWF', 'UGX', 'UYI', 'VND', 'VUV', 'XAF', 'XOF', 'XPF'])
@ -228,6 +298,7 @@ const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
const rows = ref([])
const summary = ref({ orderCount: 0, pendingCount: 0, paidCount: 0, refundedCount: 0, issueCount: 0, currencies: [] })
const loading = ref(false)
const detail = ref(null)
const detailVisible = ref(false)
@ -237,22 +308,32 @@ const refundLoading = ref(false)
const refundSubmitting = ref(false)
const refundFormRef = ref()
const rowActions = reactive({})
const searchInfo = reactive({ provider: '', tradeNo: '', businessType: '', businessId: '', paymentStatus: '', refundStatus: '' })
const refundForm = reactive({ provider: '', tradeNo: '', currency: 'CNY', maxAmount: 0, amountText: '' })
const advancedVisible = ref(width.value >= 720)
const quickRange = ref('7d')
const searchInfo = reactive({ keyword: '', provider: '', tradeNo: '', providerTradeNo: '', businessType: '', businessId: '', paymentStatus: '', refundStatus: '', createdRange: defaultDateRange(7), issueOnly: false })
const refundForm = reactive({ provider: '', tradeNo: '', currency: 'CNY', maxAmount: 0, amountText: '', reason: '' })
let loadRequestID = 0
let detailRequestID = 0
const issueCount = computed(() => rows.value.filter(isIssueOrder).length)
const pageSummary = computed(() => [
{ label: '本页订单', value: rows.value.length, hint: `全部 ${total.value}` },
{ label: '待支付', value: rows.value.filter((row) => ['initialized', 'pending'].includes(row.paymentStatus)).length, hint: '尚未确认收款' },
{ label: '已确认收款', value: rows.value.filter((row) => ['paid', 'partially_refunded', 'refunded'].includes(row.paymentStatus)).length, hint: '含部分或全额退款' },
{ label: '需要关注', value: issueCount.value, hint: '支付、发货或退款异常' }
watch(width, (value, previous) => {
if (value < 720 && previous >= 720) advancedVisible.value = false
})
const issueCount = computed(() => Number(summary.value.issueCount || 0))
const primaryCurrency = computed(() => summary.value.currencies?.[0] || { currency: 'CNY', netAmount: 0, paidAmount: 0, refundedAmount: 0 })
const currencyHint = computed(() => summary.value.currencies?.length > 1 ? `另含 ${summary.value.currencies.length - 1} 种币种` : `已退款 ${formatAmount(primaryCurrency.value.refundedAmount, primaryCurrency.value.currency)}`)
const systemSummary = computed(() => [
{ label: '实际净到账', value: formatAmount(primaryCurrency.value.netAmount, primaryCurrency.value.currency), hint: currencyHint.value, tone: 'success', icon: Wallet },
{ label: '支付成功订单', value: Number(summary.value.paidCount || 0), hint: `待支付 ${Number(summary.value.pendingCount || 0)}`, tone: 'primary', icon: CircleCheck },
{ label: '订单总数', value: Number(summary.value.orderCount || 0), hint: `退款订单 ${Number(summary.value.refundedCount || 0)}`, tone: 'neutral', icon: Tickets },
{ label: '需要关注', value: issueCount.value, hint: '支付、发货或退款异常', tone: 'danger', icon: WarningFilled }
])
const paginationLayout = computed(() => width.value < 720 ? 'total, prev, pager, next' : 'total, sizes, prev, pager, next, jumper')
const operationFixed = computed(() => width.value >= 1180 ? 'right' : false)
const isMobile = computed(() => width.value < 720)
const drawerSize = computed(() => width.value < 720 ? '96%' : '720px')
const detailColumns = computed(() => width.value < 720 ? 1 : 2)
const detailIndex = computed(() => detail.value ? rows.value.findIndex((item) => item.provider === detail.value.provider && item.tradeNo === detail.value.tradeNo) : -1)
const refundPrecisionText = computed(() => currencyMinorDigits(refundForm.currency) === 0 ? '该币种仅支持整数金额' : `最多支持 ${currencyMinorDigits(refundForm.currency)} 位小数`)
const refundAmountPlaceholder = computed(() => currencyMinorDigits(refundForm.currency) === 0 ? '例如 100' : '例如 100.00')
const detailAmounts = computed(() => {
@ -279,15 +360,27 @@ const detailAmounts = computed(() => {
})
const detailTimeline = computed(() => {
if (!detail.value) return []
if (detail.value.events?.length) return detail.value.events.map((event) => {
const meta = EVENT_META[event.type] || { label: event.type || '支付事件', type: 'info' }
const operator = event.operatorName || (event.operatorId ? `用户 #${event.operatorId}` : '')
return {
key: event.id || `${event.type}-${event.createdAt}`,
label: meta.label, type: meta.type, source: providerText(event.source), message: event.message,
time: formatDateValue(event.createdAt),
meta: [event.providerStatus ? `渠道状态 ${event.providerStatus}` : '', event.amount ? formatAmount(event.amount, event.currency || detail.value.currency) : '', operator, event.clientIp ? `IP ${event.clientIp}` : '', event.deviceId ? `设备 ${event.deviceId}` : ''].filter(Boolean)
}
})
return [
{ label: '订单创建', value: detail.value.createdAt, type: 'primary' },
{ label: '支付确认', value: detail.value.paidAt, type: 'success' },
{ label: '业务发货', value: detail.value.fulfilledAt, type: 'success' },
{ label: '退款确认', value: detail.value.refundedAt, type: 'warning' },
{ label: '最后更新', value: detail.value.updatedAt, type: 'info' }
].filter((item) => item.value).map((item) => ({ ...item, time: formatDateValue(item.value) }))
{ key: 'created', label: '订单创建', value: detail.value.createdAt, type: 'primary', source: 'system', meta: [] },
{ key: 'paid', label: '支付确认', value: detail.value.paidAt, type: 'success', source: detail.value.provider, meta: [] },
{ key: 'fulfilled', label: '业务发货', value: detail.value.fulfilledAt, type: 'success', source: 'system', meta: [] },
{ key: 'refunded', label: '退款确认', value: detail.value.refundedAt, type: 'warning', source: detail.value.provider, meta: [] }
].filter((item) => item.value).map((item) => ({ ...item, source: providerText(item.source), time: formatDateValue(item.value) }))
})
const refundRules = { amountText: [{ required: true, message: '请输入退款金额', trigger: 'blur' }, { validator: validateRefundAmount, trigger: ['blur', 'change'] }] }
const refundRules = {
amountText: [{ required: true, message: '请输入退款金额', trigger: 'blur' }, { validator: validateRefundAmount, trigger: ['blur', 'change'] }],
reason: [{ required: true, message: '请输入退款原因', trigger: 'blur' }, { min: 2, max: 200, message: '退款原因应为 2 至 200 个字符', trigger: 'blur' }]
}
function currencyMinorDigits(currency) {
const code = String(currency || 'CNY').trim().toUpperCase()
@ -318,6 +411,18 @@ function formatAmountInput(value, currency) {
const whole = Math.floor(amount / scale)
return digits === 0 ? String(whole) : `${whole}.${String(amount % scale).padStart(digits, '0')}`
}
function formatLocalDate(value) {
const year = value.getFullYear()
const month = String(value.getMonth() + 1).padStart(2, '0')
const day = String(value.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
function defaultDateRange(days) {
const end = new Date()
const start = new Date()
start.setDate(start.getDate() - Math.max(0, days - 1))
return [formatLocalDate(start), formatLocalDate(end)]
}
function parseAmountToMinor(value, currency) {
const text = String(value || '').trim()
const digits = currencyMinorDigits(currency)
@ -362,19 +467,35 @@ function rowAction(order) { return rowActions[actionKey(order)] || '' }
function isRowBusy(order) { return Boolean(rowAction(order)) }
function setRowAction(order, action) { const key = actionKey(order); if (action) rowActions[key] = action; else delete rowActions[key] }
function requestFilters() {
return Object.fromEntries(Object.entries(searchInfo).filter(([, value]) => typeof value === 'string' && value.trim()).map(([key, value]) => [key, value.trim()]))
const filters = Object.fromEntries(Object.entries(searchInfo)
.filter(([key, value]) => key !== 'createdRange' && ((typeof value === 'string' && value.trim()) || value === true))
.map(([key, value]) => [key, typeof value === 'string' ? value.trim() : value]))
if (searchInfo.createdRange?.length === 2) {
filters.createdFrom = searchInfo.createdRange[0]
filters.createdTo = searchInfo.createdRange[1]
}
return filters
}
async function load(options = {}) {
const requestID = ++loadRequestID
if (!options.silent) loading.value = true
try {
const res = await getPaymentOrders({ page: page.value, pageSize: pageSize.value, ...requestFilters() })
if (requestID !== loadRequestID || res.code !== 0) return
rows.value = res.data?.list || []
total.value = Number(res.data?.total || 0)
page.value = Number(res.data?.page || page.value)
pageSize.value = Number(res.data?.pageSize || pageSize.value)
const filters = requestFilters()
const [ordersResult, summaryResult] = await Promise.allSettled([
getPaymentOrders({ page: page.value, pageSize: pageSize.value, ...filters }),
getPaymentOrderSummary(filters)
])
if (requestID !== loadRequestID) return
const ordersRes = ordersResult.status === 'fulfilled' ? ordersResult.value : null
const summaryRes = summaryResult.status === 'fulfilled' ? summaryResult.value : null
if (ordersRes?.code === 0) {
rows.value = ordersRes.data?.list || []
total.value = Number(ordersRes.data?.total || 0)
page.value = Number(ordersRes.data?.page || page.value)
pageSize.value = Number(ordersRes.data?.pageSize || pageSize.value)
}
if (summaryRes?.code === 0 && summaryRes.data) summary.value = summaryRes.data
} catch {
// The request layer already presents transport errors.
} finally {
@ -382,7 +503,16 @@ async function load(options = {}) {
}
}
function reload() { page.value = 1; load() }
function reset() { Object.assign(searchInfo, { provider: '', tradeNo: '', businessType: '', businessId: '', paymentStatus: '', refundStatus: '' }); reload() }
function reset() {
quickRange.value = '7d'
Object.assign(searchInfo, { keyword: '', provider: '', tradeNo: '', providerTradeNo: '', businessType: '', businessId: '', paymentStatus: '', refundStatus: '', createdRange: defaultDateRange(7), issueOnly: false })
reload()
}
function applyQuickRange(value) {
if (value === 'all') searchInfo.createdRange = []
else searchInfo.createdRange = defaultDateRange(value === 'today' ? 1 : Number.parseInt(value, 10))
reload()
}
function changePage(value) { page.value = value; load() }
function changeSize(value) { pageSize.value = value; page.value = 1; load() }
async function loadDetail(order) {
@ -401,11 +531,19 @@ async function loadDetail(order) {
}
}
function openDetail(order) { detail.value = { ...order }; detailVisible.value = true; loadDetail(order) }
function navigateDetail(offset) {
const next = rows.value[detailIndex.value + offset]
if (next) openDetail(next)
}
function handleRowCommand(command, order) {
if (command === 'refund' && canRefund(order)) openRefund(order)
if (command === 'fulfill' && canFulfill(order)) retryFulfillment(order)
}
async function refreshVisibleDetail(order) {
if (detailVisible.value && detail.value?.provider === order.provider && detail.value?.tradeNo === order.tradeNo) await loadDetail(order)
}
async function syncOrder(order) {
if (isRowBusy(order)) return
if (isRowBusy(order) || !canSync(order)) return
setRowAction(order, 'sync')
try {
const res = await queryPaymentOrder({ provider: order.provider, tradeNo: order.tradeNo })
@ -435,8 +573,9 @@ async function retryFulfillment(order) {
} finally { setRowAction(order, '') }
}
function openRefund(order) {
if (isRowBusy(order) || !canRefund(order)) return
const maxAmount = remainingRefundAmount(order)
Object.assign(refundForm, { provider: order.provider, tradeNo: order.tradeNo, currency: order.currency || 'CNY', maxAmount, amountText: formatAmountInput(maxAmount, order.currency || 'CNY') })
Object.assign(refundForm, { provider: order.provider, tradeNo: order.tradeNo, currency: order.currency || 'CNY', maxAmount, amountText: formatAmountInput(maxAmount, order.currency || 'CNY'), reason: '' })
refundVisible.value = true
nextTick(() => refundFormRef.value?.clearValidate())
}
@ -454,7 +593,7 @@ async function submitRefund() {
refundLoading.value = true
const order = { provider: refundForm.provider, tradeNo: refundForm.tradeNo }
try {
const res = await refundPaymentOrder({ provider: refundForm.provider, tradeNo: refundForm.tradeNo, amount })
const res = await refundPaymentOrder({ provider: refundForm.provider, tradeNo: refundForm.tradeNo, amount, reason: refundForm.reason.trim() })
if (res.code !== 0) return
ElMessage.success(res.data?.refundStatus === 'succeeded' ? '退款已完成' : '退款申请已提交,等待渠道确认')
refundVisible.value = false
@ -477,35 +616,65 @@ onMounted(load)
.page-heading { display: flex; align-items: center; justify-content: space-between; gap: 18px; margin-bottom: 16px; }
.page-heading h2 { margin: 0; color: var(--el-text-color-primary); font-size: 20px; font-weight: 600; letter-spacing: 0; }
.page-heading p { margin: 5px 0 0; color: var(--el-text-color-secondary); font-size: 13px; }
.status-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px; overflow: hidden; margin-bottom: 16px; border: 1px solid var(--el-border-color-lighter); border-radius: 6px; background: var(--el-border-color-lighter); }
.summary-item { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 3px 10px; min-width: 0; padding: 14px 16px; background: var(--el-bg-color); }
.summary-item span { color: var(--el-text-color-regular); font-size: 13px; }
.summary-item strong { grid-row: span 2; color: var(--el-text-color-primary); font-size: 24px; font-weight: 600; line-height: 1; }
.status-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; margin-bottom: 16px; }
.summary-item { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; min-width: 0; min-height: 76px; overflow: hidden; padding: 13px 48px 13px 15px; border: 1px solid var(--el-border-color-lighter); border-radius: 6px; background: var(--el-bg-color); }
.summary-item::before { position: absolute; inset: 0 auto 0 0; width: 3px; background: var(--summary-color); content: ''; }
.summary-copy { display: grid; min-width: 0; gap: 5px; }
.summary-item span { color: var(--el-text-color-regular); font-size: 12px; }
.summary-item strong { color: var(--el-text-color-primary); font-size: 22px; font-weight: 650; line-height: 1; white-space: nowrap; }
.summary-item small { overflow: hidden; color: var(--el-text-color-secondary); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.summary-icon { position: absolute; top: 12px; right: 13px; width: 27px; height: 27px; border-radius: 6px; background: color-mix(in srgb, var(--summary-color) 12%, transparent); color: var(--summary-color); font-size: 16px; }
.summary-item.is-success { --summary-color: var(--el-color-success); }
.summary-item.is-primary { --summary-color: var(--el-color-primary); }
.summary-item.is-neutral { --summary-color: var(--el-color-info); }
.summary-item.is-danger { --summary-color: var(--el-color-danger); }
.filter-band { margin-bottom: 16px; }
.filter-grid { display: grid; grid-template-columns: repeat(3, minmax(160px, 1fr)); gap: 0 14px; }
.filter-grid :deep(.el-form-item) { margin-bottom: 12px; }
.filter-grid :deep(.el-select), .filter-grid :deep(.el-input) { width: 100%; }
.primary-filter-grid { display: grid; grid-template-columns: minmax(280px, 2fr) minmax(160px, 1fr) minmax(160px, 1fr) auto; gap: 0 14px; }
.advanced-filter-grid { display: grid; grid-template-columns: repeat(4, minmax(160px, 1fr)); gap: 0 14px; padding-top: 3px; border-top: 1px solid var(--el-border-color-lighter); }
.primary-filter-grid :deep(.el-form-item), .advanced-filter-grid :deep(.el-form-item) { margin-bottom: 12px; }
.primary-filter-grid :deep(.el-select), .primary-filter-grid :deep(.el-input), .advanced-filter-grid :deep(.el-select), .advanced-filter-grid :deep(.el-input) { width: 100%; }
.issue-filter { align-self: end; }
.date-filter :deep(.el-date-editor) { width: 100%; }
.filter-actions { display: flex; align-items: flex-end; gap: 8px; padding-bottom: 12px; }
.quick-range { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; padding-top: 10px; border-top: 1px solid var(--el-border-color-lighter); color: var(--el-text-color-secondary); font-size: 12px; }
.order-table-band { min-width: 0; overflow: hidden; }
.table-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding-bottom: 12px; }
.table-heading > div { display: flex; align-items: baseline; gap: 9px; }
.table-heading strong { color: var(--el-text-color-primary); font-size: 15px; font-weight: 600; }
.table-heading span { color: var(--el-text-color-secondary); font-size: 12px; }
.issue-count { color: var(--el-color-danger) !important; }
.order-cell, .business-cell, .provider-cell, .amount-cell { display: grid; min-width: 0; gap: 4px; }
.order-cell, .business-cell, .provider-cell, .amount-cell { display: grid; min-width: 0; gap: 5px; }
.order-link { overflow: hidden; padding: 0; border: 0; background: transparent; color: var(--el-color-primary); font: inherit; font-weight: 500; text-align: left; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; }
.order-cell > span, .business-cell small, .provider-cell small, .amount-cell small { overflow: hidden; color: var(--el-text-color-secondary); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.business-cell > span, .provider-cell > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.amount-cell { justify-items: end; }
.amount-cell strong { color: var(--el-text-color-primary); font-weight: 600; white-space: nowrap; }
.row-actions { display: flex; align-items: center; min-height: 32px; white-space: nowrap; }
.row-actions :deep(.el-button + .el-button) { margin-left: 8px; }
.refund-amount { color: var(--el-color-danger) !important; }
.status-stack { display: flex; align-items: center; flex-wrap: wrap; gap: 5px; min-height: 24px; }
.row-actions { display: flex; align-items: center; justify-content: flex-end; gap: 6px; min-height: 32px; white-space: nowrap; }
.row-actions :deep(.el-button + .el-button) { margin-left: 0; }
.payment-orders :deep(.el-table__cell) { padding-top: 12px; padding-bottom: 12px; }
.payment-orders :deep(.el-table__row.is-payment-issue > td.el-table__cell) { background: var(--el-color-danger-light-9); }
.mobile-order-list { display: grid; gap: 10px; min-height: 160px; }
.mobile-order { overflow: hidden; border: 1px solid var(--el-border-color-lighter); border-radius: 6px; background: var(--el-bg-color); }
.mobile-order.is-payment-issue { border-color: var(--el-color-danger-light-5); background: var(--el-color-danger-light-9); }
.mobile-order-main { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: 12px; width: 100%; padding: 13px 13px 8px; border: 0; background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; }
.mobile-order-title { display: grid; min-width: 0; gap: 4px; }
.mobile-order-title strong { overflow: hidden; color: var(--el-text-color-primary); font-size: 14px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
.mobile-order-title small { color: var(--el-text-color-secondary); font-size: 11px; }
.mobile-order-amount { color: var(--el-text-color-primary); font-size: 15px; font-weight: 650; white-space: nowrap; }
.mobile-order-identities { display: grid; gap: 3px; padding: 0 13px 9px; color: var(--el-color-primary); font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 11px; word-break: break-all; }
.mobile-order-identities small { color: var(--el-text-color-secondary); font-size: 10px; }
.mobile-order-meta { display: flex; justify-content: space-between; gap: 10px; padding: 8px 13px; border-top: 1px solid var(--el-border-color-lighter); color: var(--el-text-color-secondary); font-size: 11px; }
.mobile-order-meta span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mobile-order-footer { display: grid; gap: 8px; padding: 9px 13px 10px; border-top: 1px solid var(--el-border-color-lighter); }
.mobile-order-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }
.mobile-order-actions :deep(.el-button + .el-button) { margin-left: 0; }
.kra-pagination { overflow-x: auto; }
.order-detail { min-height: 220px; }
.detail-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding-bottom: 16px; border-bottom: 1px solid var(--el-border-color-lighter); }
.detail-statuses, .detail-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.detail-statuses, .detail-actions, .detail-navigation { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.detail-navigation { margin-left: auto; color: var(--el-text-color-secondary); font-size: 12px; }
.detail-error { margin-top: 16px; }
.section-heading { margin: 22px 0 12px; color: var(--el-text-color-primary); font-size: 14px; font-weight: 600; letter-spacing: 0; }
.copy-value { display: inline-flex; align-items: center; gap: 4px; max-width: 100%; word-break: break-all; }
@ -515,11 +684,16 @@ onMounted(load)
.amount-item strong { overflow: hidden; color: var(--el-text-color-primary); font-size: 14px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
.amount-alert { margin-top: 12px; }
.order-timeline { margin: 0; padding-top: 4px; }
.event-heading { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; color: var(--el-text-color-primary); }
.event-heading strong { font-size: 13px; font-weight: 600; }
.event-message { margin: 6px 0 0; color: var(--el-text-color-regular); font-size: 12px; line-height: 1.55; }
.event-meta { display: flex; flex-wrap: wrap; gap: 5px 12px; margin-top: 6px; color: var(--el-text-color-secondary); font-size: 11px; }
.refund-summary { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px 14px; margin-bottom: 18px; padding: 12px 14px; border: 1px solid var(--el-border-color-lighter); border-radius: 6px; background: var(--el-fill-color-lighter); }
.refund-summary span { color: var(--el-text-color-secondary); font-size: 12px; }
.refund-summary strong { overflow-wrap: anywhere; color: var(--el-text-color-primary); font-size: 13px; text-align: right; }
.form-tip { width: 100%; margin-top: 5px; color: var(--el-text-color-secondary); font-size: 12px; line-height: 1.4; }
@media (max-width: 1000px) { .filter-grid { grid-template-columns: repeat(2, minmax(160px, 1fr)); } .amount-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 720px) { .page-heading { align-items: flex-start; } .page-heading p { max-width: 250px; } .status-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); } .summary-item { padding: 12px; } .summary-item strong { font-size: 20px; } .filter-grid { grid-template-columns: 1fr; } .filter-actions { align-items: stretch; padding-bottom: 4px; } .filter-actions :deep(.el-button) { flex: 1; } .detail-toolbar { align-items: stretch; flex-direction: column; } .detail-actions :deep(.el-button) { margin-left: 0; } .amount-grid { grid-template-columns: 1fr; } }
@media (max-width: 440px) { .status-summary { grid-template-columns: 1fr; } .table-heading { align-items: flex-start; flex-direction: column; gap: 5px; } .refund-summary { grid-template-columns: 1fr; } .refund-summary strong { text-align: left; } }
@media (max-width: 1100px) { .primary-filter-grid { grid-template-columns: repeat(2, minmax(180px, 1fr)); } .advanced-filter-grid { grid-template-columns: repeat(2, minmax(160px, 1fr)); } .filter-actions { align-items: stretch; } .amount-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 720px) { .page-heading { align-items: flex-start; } .page-heading p { max-width: 250px; } .status-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } .summary-item { grid-template-columns: 1fr; align-content: center; gap: 7px; min-height: 82px; padding: 11px 40px 11px 12px; } .summary-copy { gap: 3px; } .summary-item strong { font-size: 19px; } .summary-icon { top: 10px; right: 10px; } .primary-filter-grid, .advanced-filter-grid { grid-template-columns: 1fr; } .filter-actions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); padding-bottom: 4px; } .filter-actions :deep(.el-button) { width: 100%; margin: 0; } .quick-range { align-items: stretch; flex-direction: column; } .quick-range :deep(.el-radio-group) { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); width: 100%; } .quick-range :deep(.el-radio-button__inner) { width: 100%; } .table-heading { padding-bottom: 10px; } .detail-toolbar { align-items: stretch; flex-direction: column; } .detail-navigation { justify-content: center; order: 3; margin-left: 0; } .detail-actions :deep(.el-button) { margin-left: 0; } .amount-grid { grid-template-columns: 1fr; } }
@media (max-width: 440px) { .summary-item { min-height: 64px; } .table-heading { align-items: flex-start; flex-direction: column; gap: 5px; } .mobile-order-meta { align-items: flex-start; flex-direction: column; gap: 4px; } .refund-summary { grid-template-columns: 1fr; } .refund-summary strong { text-align: left; } }
@media (max-width: 360px) { .status-summary { grid-template-columns: 1fr; } }
</style>