优化结构

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"` BusinessID string `json:"businessId"`
PaymentMode string `json:"paymentMode"` PaymentMode string `json:"paymentMode"`
OriginalAmount int64 `json:"originalAmount"` OriginalAmount int64 `json:"originalAmount"`
UserAgent string `json:"-"`
DeviceID string `json:"-"`
} }
// PaymentRefundRequest carries the durable provider identities needed to // 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) 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) { 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) { if req == nil || !validPaymentText(req.Provider, 64) || !validPaymentText(req.TradeNo, 128) || !validPaymentText(req.BusinessType, 64) || !validPaymentText(req.BusinessID, 128) {
return nil, errors.New("支付参数不完整") return nil, errors.New("支付参数不完整")
@ -644,6 +663,9 @@ func (uc *PaymentUsecase) createWithOrder(ctx context.Context, req *PaymentReque
ConfirmationID: paymentConfirmationID(req.Provider, req.TradeNo), ConfirmationID: paymentConfirmationID(req.Provider, req.TradeNo),
RequestFingerprint: paymentOrderFingerprint(req, extra), RequestFingerprint: paymentOrderFingerprint(req, extra),
Extra: extra, Extra: extra,
ClientIP: req.ClientIP,
UserAgent: req.UserAgent,
DeviceID: req.DeviceID,
} }
order, created, err := uc.orders.CreatePaymentOrder(ctx, order) order, created, err := uc.orders.CreatePaymentOrder(ctx, order)
if err != nil { if err != nil {
@ -691,7 +713,9 @@ func (uc *PaymentUsecase) createWithOrder(ctx context.Context, req *PaymentReque
if err = validatePaymentBreakdown(result); err != nil { if err = validatePaymentBreakdown(result); err != nil {
return nil, fmt.Errorf("内部支付金额拆分无效: %w", err) 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 nil, err
} }
return uc.reconcilePaymentResult(ctx, result, nil) 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) uc.logger.Error(ctx, "payment.order.provider_create_invalid", "provider", req.Provider, "trade_no", req.TradeNo, "error", err)
return nil, 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 { if err != nil {
uc.logger.Error(ctx, "payment.order.provider_create_record_failed", "provider", req.Provider, "trade_no", req.TradeNo, "error", err) uc.logger.Error(ctx, "payment.order.provider_create_record_failed", "provider", req.Provider, "trade_no", req.TradeNo, "error", err)
return nil, err return nil, err
@ -773,7 +799,17 @@ func (uc *PaymentUsecase) reconcilePaymentResult(ctx context.Context, result, ca
return fail(err) 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 { if err != nil {
uc.logger.Error(ctx, "payment.order.result_record_failed", "provider", result.Provider, "trade_no", result.TradeNo, "error", err) uc.logger.Error(ctx, "payment.order.result_record_failed", "provider", result.Provider, "trade_no", result.TradeNo, "error", err)
return fail(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 RefundedAt *time.Time
FulfillmentLeaseUntil *time.Time FulfillmentLeaseUntil *time.Time
RefundLeaseUntil *time.Time RefundLeaseUntil *time.Time
ClientIP string
UserAgent string
DeviceID string
} }
type PaymentProviderUpdate struct { type PaymentProviderUpdate struct {
@ -109,21 +112,33 @@ type PaymentProviderUpdate struct {
EventID string EventID string
PayloadHash string PayloadHash string
CreatePayload json.RawMessage CreatePayload json.RawMessage
EventType string
EventSource string
EventMessage string
} }
type PaymentOrderFilter struct { type PaymentOrderFilter struct {
Provider string Provider string
TradeNo string TradeNo string
BusinessType string ProviderTradeNo string
BusinessID string Keyword string
PaymentStatus string BusinessType string
RefundStatus string BusinessID string
PaymentStatus string
RefundStatus string
CreatedFrom *time.Time
CreatedTo *time.Time
PaidFrom *time.Time
PaidTo *time.Time
IssueOnly bool
} }
type PaymentOrderRepo interface { type PaymentOrderRepo interface {
CreatePaymentOrder(context.Context, *PaymentOrder) (order *PaymentOrder, created bool, err error) CreatePaymentOrder(context.Context, *PaymentOrder) (order *PaymentOrder, created bool, err error)
FindPaymentOrder(context.Context, string, string) (*PaymentOrder, error) FindPaymentOrder(context.Context, string, string) (*PaymentOrder, error)
ListPaymentOrders(context.Context, int, int, PaymentOrderFilter) ([]*PaymentOrder, int64, 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) RecordPaymentCreate(context.Context, string, string, *PaymentProviderUpdate) (*PaymentOrder, error)
ApplyPaymentResult(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) 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 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) { func (r *paymentOrderRepoStub) RecordPaymentCreate(_ context.Context, _, _ string, update *PaymentProviderUpdate) (*PaymentOrder, error) {
r.order.PaymentStatus = PaymentStatusPending r.order.PaymentStatus = PaymentStatusPending
r.order.CreatePayload = append(r.order.CreatePayload[:0], update.CreatePayload...) 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 { if err = migrateAll(db, testCatalog()); err != nil {
t.Fatalf("second migration run: %v", err) 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) { if !db.Migrator().HasTable(table) {
t.Fatalf("migration did not create %s", 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 { if err = db.Table(migration.TableName).Count(&versions).Error; err != nil {
t.Fatal(err) t.Fatal(err)
} }
if versions != 8 { if versions != 11 {
t.Fatalf("migration versions = %d, want 8", versions) t.Fatalf("migration versions = %d, want 11", versions)
} }
var communicationRows []dataintegration.ConfigPO var communicationRows []dataintegration.ConfigPO
if err = db.Where("kind IN ?", []string{"mq", "websocket"}).Order("kind, provider").Find(&communicationRows).Error; err != nil { 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 { {ID: "202608200003_payment_schema", Migrate: func(db *gorm.DB) error {
return migration.CreateMissingTables(db, &paymentOrderPO{}) 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) { if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, false, err 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 return toBizPaymentOrder(po), true, nil
} }
if lookupErr := db.Where("provider = ? AND trade_no = ?", po.Provider, po.TradeNo).First(&current).Error; lookupErr == 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) { 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{}) db := r.filteredPaymentOrders(ctx, filter)
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)
}
var total int64 var total int64
if err := db.Count(&total).Error; err != nil { if err := db.Count(&total).Error; err != nil {
return nil, 0, err return nil, 0, err
@ -201,6 +193,105 @@ func (r *paymentOrderRepo) ListPaymentOrders(ctx context.Context, page, pageSize
return items, total, nil 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) { 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 { return r.withLockedOrder(ctx, provider, tradeNo, func(tx *gorm.DB, po *paymentOrderPO) error {
if update == nil { if update == nil {
@ -224,7 +315,11 @@ func (r *paymentOrderRepo) RecordPaymentCreate(ctx context.Context, provider, tr
po.PaymentStatus = status po.PaymentStatus = status
} }
po.Version++ 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++ 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.FulfillmentLeaseUntil = &until
po.LastError = "" po.LastError = ""
po.Version++ 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 return order, token, duplicate, err
} }
@ -329,7 +434,9 @@ func (r *paymentOrderRepo) CompletePaymentFulfillment(ctx context.Context, provi
po.FulfillmentStatus = bizpayment.FulfillmentStatusFailed po.FulfillmentStatus = bizpayment.FulfillmentStatusFailed
} }
po.Version++ 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.RefundLeaseUntil = &until
po.LastError = "" po.LastError = ""
po.Version++ 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 return order, token, err
} }
@ -399,7 +508,10 @@ func (r *paymentOrderRepo) CompletePaymentRefundRequest(ctx context.Context, pro
po.RefundNo = "" po.RefundNo = ""
} }
po.Version++ 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.RefundStatus = bizpayment.RefundStatusFailed
po.RefundNo = "" po.RefundNo = ""
po.Version++ 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 po.RefundedAmount += amount
if po.RefundedAmount >= po.Amount { if po.RefundedAmount >= po.Amount {
@ -428,7 +543,10 @@ func (r *paymentOrderRepo) ConfirmPaymentRefund(ctx context.Context, provider, t
now := time.Now().UTC() now := time.Now().UTC()
po.RefundedAt = &now po.RefundedAt = &now
po.Version++ 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err = db.AutoMigrate(&paymentOrderPO{}); err != nil { if err = db.AutoMigrate(&paymentOrderPO{}, &paymentEventPO{}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
return &paymentOrderRepo{data: &Data{gormDB: newReloadableDB(db, nil)}} 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) 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) t.Fatalf("definition[%d] = %q, want %q", index, got, name)
} }
} }
if got := catalog.MigrationSteps(); len(got) != 8 { if got := catalog.MigrationSteps(); len(got) != 11 {
t.Fatalf("module migrations = %d, want 8", len(got)) t.Fatalf("module migrations = %d, want 11", len(got))
} }
var menus, apis int var menus, apis int
for _, definition := range catalog.Definitions { for _, definition := range catalog.Definitions {
menus += len(definition.Surface.Menus) menus += len(definition.Surface.Menus)
apis += len(definition.Surface.APIs) apis += len(definition.Surface.APIs)
} }
if menus != 3 || apis != 15 { if menus != 3 || apis != 17 {
t.Fatalf("admin surface = %d menus/%d APIs, want 3/15", menus, apis) t.Fatalf("admin surface = %d menus/%d APIs, want 3/17", menus, apis)
} }
if got := catalog.DefaultTimedTasks(); len(got) != 2 { if got := catalog.DefaultTimedTasks(); len(got) != 2 {
t.Fatalf("default timed tasks = %d, want 2", len(got)) t.Fatalf("default timed tasks = %d, want 2", len(got))

View File

@ -4,21 +4,60 @@ package payment
import ( import (
datapayment "kra/internal/data/payment" datapayment "kra/internal/data/payment"
datasystem "kra/internal/data/system"
"kra/internal/modules/surface" "kra/internal/modules/surface"
"kra/pkg/database/migration"
"kra/pkg/module" "kra/pkg/module"
"gorm.io/gorm"
) )
// Definition declares the payment-owned schema and administration surface. // Definition declares the payment-owned schema and administration surface.
func Definition() module.Definition { 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{ return module.Definition{
Name: "payment", Name: "payment",
Migrations: datapayment.Migrations(), Migrations: migrations,
Surface: module.Surface{ Surface: adminSurface,
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),
},
} }
} }

View File

@ -7,10 +7,10 @@ func TestDefinitionOwnsPaymentContributions(t *testing.T) {
if definition.Name != "payment" { if definition.Name != "payment" {
t.Fatalf("name = %q, want payment", definition.Name) t.Fatalf("name = %q, want payment", definition.Name)
} }
if len(definition.Migrations) != 1 { if len(definition.Migrations) != 4 {
t.Fatalf("migrations = %d, want 1", len(definition.Migrations)) t.Fatalf("migrations = %d, want 4", len(definition.Migrations))
} }
if len(definition.Surface.Menus) != 2 || len(definition.Surface.APIs) != 10 { if len(definition.Surface.Menus) != 2 || len(definition.Surface.APIs) != 12 {
t.Fatalf("surface = %d menus/%d APIs, want 2/10", len(definition.Surface.Menus), len(definition.Surface.APIs)) 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 /logViewer/files": {group: "文件日志", description: "获取日期下的日志文件"},
"GET /menu/getMenuRoles": {group: "菜单", description: "获取菜单关联角色列表"}, "GET /menu/getMenuRoles": {group: "菜单", description: "获取菜单关联角色列表"},
"GET /payment/orders": {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": {group: "支付", description: "按路径查询支付订单"},
"GET /payment/orders/:provider/:tradeNo/events": {group: "支付", description: "查询支付订单事件流水"},
"GET /position/findPosition": {group: "岗位", description: "根据ID获取岗位"}, "GET /position/findPosition": {group: "岗位", description: "根据ID获取岗位"},
"GET /position/getPositionUsers": {group: "岗位", description: "获取岗位成员ID列表"}, "GET /position/getPositionUsers": {group: "岗位", description: "获取岗位成员ID列表"},
"GET /securityConfig/getSecurityConfig": {group: "安全配置", description: "获取安全配置"}, "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 { 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) 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) 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) { func (h *Payment) RefundByPath(c *gin.Context) {
var req struct { var req struct {
Amount int64 `json:"amount"` Amount int64 `json:"amount"`
Reason string `json:"reason"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
Fail(c, err.Error()) Fail(c, err.Error())
return return
} }
result, err := h.service.Refund(c.Request.Context(), &dto.PaymentRefundRequest{ request := &dto.PaymentRefundRequest{Provider: c.Param("provider"), TradeNo: c.Param("tradeNo"), Amount: req.Amount, Reason: req.Reason}
Provider: c.Param("provider"), TradeNo: c.Param("tradeNo"), Amount: req.Amount, setPaymentRequestActor(c, &request.OperatorID, &request.OperatorName, &request.ClientIP, &request.UserAgent, &request.DeviceID)
}) result, err := h.service.Refund(c.Request.Context(), request)
if err != nil { if err != nil {
Fail(c, err.Error()) Fail(c, err.Error())
return return
@ -56,9 +57,9 @@ func (h *Payment) RefundByPath(c *gin.Context) {
} }
func (h *Payment) FulfillByPath(c *gin.Context) { func (h *Payment) FulfillByPath(c *gin.Context) {
result, err := h.service.Fulfill(c.Request.Context(), &dto.PaymentFulfillRequest{ request := &dto.PaymentFulfillRequest{Provider: c.Param("provider"), TradeNo: c.Param("tradeNo")}
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 { if err != nil {
Fail(c, err.Error()) Fail(c, err.Error())
return return
@ -80,6 +81,36 @@ func (h *Payment) Orders(c *gin.Context) {
Write(c, CodeSuccess, PageResult{List: items, Total: total, Page: page, PageSize: pageSize}, "获取成功") 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) { func (h *Payment) TestProvider(c *gin.Context) {
result, err := h.service.TestProvider(c.Request.Context(), c.Param("provider")) result, err := h.service.TestProvider(c.Request.Context(), c.Param("provider"))
if err != nil { if err != nil {
@ -99,6 +130,8 @@ func (h *Payment) Create(c *gin.Context) {
return return
} }
req.ClientIP = c.ClientIP() 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) result, err := h.service.Create(c.Request.Context(), &req)
if err != nil { if err != nil {
Fail(c, err.Error()) Fail(c, err.Error())
@ -112,6 +145,7 @@ func (h *Payment) Query(c *gin.Context) {
Fail(c, err.Error()) Fail(c, err.Error())
return return
} }
setPaymentRequestActor(c, &req.OperatorID, &req.OperatorName, &req.ClientIP, &req.UserAgent, &req.DeviceID)
result, err := h.service.Query(c.Request.Context(), &req) result, err := h.service.Query(c.Request.Context(), &req)
if err != nil { if err != nil {
Fail(c, err.Error()) Fail(c, err.Error())
@ -125,6 +159,7 @@ func (h *Payment) Refund(c *gin.Context) {
Fail(c, err.Error()) Fail(c, err.Error())
return return
} }
setPaymentRequestActor(c, &req.OperatorID, &req.OperatorName, &req.ClientIP, &req.UserAgent, &req.DeviceID)
result, err := h.service.Refund(c.Request.Context(), &req) result, err := h.service.Refund(c.Request.Context(), &req)
if err != nil { if err != nil {
Fail(c, err.Error()) Fail(c, err.Error())
@ -139,6 +174,7 @@ func (h *Payment) Fulfill(c *gin.Context) {
Fail(c, err.Error()) Fail(c, err.Error())
return return
} }
setPaymentRequestActor(c, &req.OperatorID, &req.OperatorName, &req.ClientIP, &req.UserAgent, &req.DeviceID)
result, err := h.service.Fulfill(c.Request.Context(), &req) result, err := h.service.Fulfill(c.Request.Context(), &req)
if err != nil { if err != nil {
Fail(c, err.Error()) Fail(c, err.Error())
@ -166,13 +202,26 @@ func (h *Payment) Callback(c *gin.Context) {
query[k] = v[0] 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) writePaymentCallbackAck(c, ack)
if err != nil { if err != nil {
_ = c.Error(err) _ = 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) { func writePaymentCallbackAck(c *gin.Context, ack dto.PaymentCallbackAck) {
if ack.StatusCode == 0 { if ack.StatusCode == 0 {
ack.StatusCode = http.StatusInternalServerError ack.StatusCode = http.StatusInternalServerError

View File

@ -8,9 +8,11 @@ import (
func RegisterPayment(group, public *gin.RouterGroup, h *handler.Payment) { func RegisterPayment(group, public *gin.RouterGroup, h *handler.Payment) {
payment := group.Group("/payment") payment := group.Group("/payment")
payment.GET("/orders", h.Orders) payment.GET("/orders", h.Orders)
payment.GET("/orders/summary", h.OrderSummary)
payment.POST("/providers/:provider/test", h.TestProvider) payment.POST("/providers/:provider/test", h.TestProvider)
payment.POST("/order", h.Order) payment.POST("/order", h.Order)
payment.GET("/orders/:provider/:tradeNo", h.OrderByPath) payment.GET("/orders/:provider/:tradeNo", h.OrderByPath)
payment.GET("/orders/:provider/:tradeNo/events", h.OrderEvents)
payment.POST("/create", h.Create) payment.POST("/create", h.Create)
payment.POST("/query", h.Query) payment.POST("/query", h.Query)
payment.POST("/refund", h.Refund) payment.POST("/refund", h.Refund)

View File

@ -6,14 +6,21 @@ import (
) )
type PaymentOrderListRequest struct { type PaymentOrderListRequest struct {
Page int `form:"page"` Page int `form:"page"`
PageSize int `form:"pageSize"` PageSize int `form:"pageSize"`
Provider string `form:"provider"` Provider string `form:"provider"`
TradeNo string `form:"tradeNo"` TradeNo string `form:"tradeNo"`
BusinessType string `form:"businessType"` ProviderTradeNo string `form:"providerTradeNo"`
BusinessID string `form:"businessId"` Keyword string `form:"keyword"`
PaymentStatus string `form:"paymentStatus"` BusinessType string `form:"businessType"`
RefundStatus string `form:"refundStatus"` 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 { type PaymentRequest struct {
@ -30,29 +37,49 @@ type PaymentRequest struct {
BusinessType string `json:"businessType"` BusinessType string `json:"businessType"`
BusinessID string `json:"businessId"` BusinessID string `json:"businessId"`
PaymentMode string `json:"paymentMode"` PaymentMode string `json:"paymentMode"`
UserAgent string `json:"-"`
DeviceID string `json:"-"`
} }
type PaymentQueryRequest struct { type PaymentQueryRequest struct {
Provider string `json:"provider"` Provider string `json:"provider"`
TradeNo string `json:"tradeNo"` TradeNo string `json:"tradeNo"`
OperatorID uint `json:"-"`
OperatorName string `json:"-"`
ClientIP string `json:"-"`
UserAgent string `json:"-"`
DeviceID string `json:"-"`
} }
type PaymentRefundRequest struct { type PaymentRefundRequest struct {
Provider string `json:"provider"` Provider string `json:"provider"`
TradeNo string `json:"tradeNo"` TradeNo string `json:"tradeNo"`
Amount int64 `json:"amount"` 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 { type PaymentFulfillRequest struct {
Provider string `json:"provider"` Provider string `json:"provider"`
TradeNo string `json:"tradeNo"` 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 // PaymentCallbackRequest is populated by the HTTP transport and converted to
// the biz callback object inside service. // the biz callback object inside service.
type PaymentCallbackRequest struct { type PaymentCallbackRequest struct {
Provider string Provider string
Headers map[string]string Headers map[string]string
Body []byte Body []byte
Query map[string]string Query map[string]string
ClientIP string
UserAgent string
} }
type PaymentCallbackAck struct { type PaymentCallbackAck struct {
@ -103,38 +130,76 @@ type PaymentTestResult struct {
} }
type PaymentOrderResponse struct { type PaymentOrderResponse struct {
ID uint64 `json:"ID"` ID uint64 `json:"ID"`
Provider string `json:"provider"` Provider string `json:"provider"`
TradeNo string `json:"tradeNo"` TradeNo string `json:"tradeNo"`
ProviderTradeNo string `json:"providerTradeNo"` ProviderTradeNo string `json:"providerTradeNo"`
BusinessType string `json:"businessType"` BusinessType string `json:"businessType"`
BusinessID string `json:"businessId"` BusinessID string `json:"businessId"`
Subject string `json:"subject"` Subject string `json:"subject"`
PaymentMode string `json:"paymentMode"` PaymentMode string `json:"paymentMode"`
OriginalAmount int64 `json:"originalAmount"` OriginalAmount int64 `json:"originalAmount"`
Amount int64 `json:"amount"` Amount int64 `json:"amount"`
PaidAmount int64 `json:"paidAmount"` PaidAmount int64 `json:"paidAmount"`
PayerPaidAmount int64 `json:"payerPaidAmount"` PayerPaidAmount int64 `json:"payerPaidAmount"`
CashPaidAmount int64 `json:"cashPaidAmount"` CashPaidAmount int64 `json:"cashPaidAmount"`
PointPaidAmount int64 `json:"pointPaidAmount"` PointPaidAmount int64 `json:"pointPaidAmount"`
DiscountAmount int64 `json:"discountAmount"` DiscountAmount int64 `json:"discountAmount"`
ProviderDiscountAmount int64 `json:"providerDiscountAmount"` ProviderDiscountAmount int64 `json:"providerDiscountAmount"`
MerchantDiscountAmount int64 `json:"merchantDiscountAmount"` MerchantDiscountAmount int64 `json:"merchantDiscountAmount"`
SettlementAmount int64 `json:"settlementAmount"` SettlementAmount int64 `json:"settlementAmount"`
Currency string `json:"currency"` Currency string `json:"currency"`
PayerCurrency string `json:"payerCurrency,omitempty"` PayerCurrency string `json:"payerCurrency,omitempty"`
AmountBreakdownKnown bool `json:"amountBreakdownKnown"` AmountBreakdownKnown bool `json:"amountBreakdownKnown"`
PaymentStatus string `json:"paymentStatus"` PaymentStatus string `json:"paymentStatus"`
ProviderStatus string `json:"providerStatus"` ProviderStatus string `json:"providerStatus"`
FulfillmentStatus string `json:"fulfillmentStatus"` FulfillmentStatus string `json:"fulfillmentStatus"`
RefundStatus string `json:"refundStatus"` RefundStatus string `json:"refundStatus"`
RefundedAmount int64 `json:"refundedAmount"` RefundedAmount int64 `json:"refundedAmount"`
RefundRequestedAmount int64 `json:"refundRequestedAmount"` RefundRequestedAmount int64 `json:"refundRequestedAmount"`
RefundNo string `json:"refundNo,omitempty"` RefundNo string `json:"refundNo,omitempty"`
LastError string `json:"lastError,omitempty"` LastError string `json:"lastError,omitempty"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAt time.Time `json:"updatedAt"`
PaidAt *time.Time `json:"paidAt,omitempty"` PaidAt *time.Time `json:"paidAt,omitempty"`
FulfilledAt *time.Time `json:"fulfilledAt,omitempty"` FulfilledAt *time.Time `json:"fulfilledAt,omitempty"`
RefundedAt *time.Time `json:"refundedAt,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" "context"
"errors" "errors"
paymentbiz "kra/internal/biz/payment" paymentbiz "kra/internal/biz/payment"
"strings"
"time"
"kra/internal/service/dto" "kra/internal/service/dto"
) )
@ -16,7 +18,16 @@ func (s *PaymentService) Order(ctx context.Context, provider, tradeNo string) (*
if err != nil { if err != nil {
return nil, err 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) { 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 { if pageSize <= 0 {
pageSize = 10 pageSize = 10
} }
items, total, err := s.uc.Orders(ctx, page, pageSize, paymentbiz.PaymentOrderFilter{ filter, err := paymentOrderFilter(req)
Provider: req.Provider, TradeNo: req.TradeNo, BusinessType: req.BusinessType, if err != nil {
BusinessID: req.BusinessID, PaymentStatus: req.PaymentStatus, RefundStatus: req.RefundStatus, return nil, 0, page, pageSize, err
}) }
items, total, err := s.uc.Orders(ctx, page, pageSize, filter)
if err != nil { if err != nil {
return nil, 0, page, pageSize, err 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 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) { func paymentOrderResponse(order *paymentbiz.PaymentOrder) (*dto.PaymentOrderResponse, error) {
if order == nil { if order == nil {
return nil, errors.New("支付订单为空") 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, Amount: req.Amount, OriginalAmount: req.OriginalAmount, Currency: req.Currency,
NotifyURL: req.NotifyURL, ReturnURL: req.ReturnURL, ClientIP: req.ClientIP, NotifyURL: req.NotifyURL, ReturnURL: req.ReturnURL, ClientIP: req.ClientIP,
Extra: req.Extra, BusinessType: req.BusinessType, BusinessID: req.BusinessID, 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 return paymentResultResponse(result), err
} }
@ -83,6 +202,7 @@ func (s *PaymentService) Query(ctx context.Context, req *dto.PaymentQueryRequest
if req == nil { if req == nil {
return nil, errors.New("支付查询请求为空") 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) result, err := s.uc.Query(ctx, req.Provider, req.TradeNo)
return paymentResultResponse(result), err return paymentResultResponse(result), err
} }
@ -90,6 +210,11 @@ func (s *PaymentService) Refund(ctx context.Context, req *dto.PaymentRefundReque
if req == nil { if req == nil {
return nil, errors.New("支付退款请求为空") 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) result, err := s.uc.Refund(ctx, req.Provider, req.TradeNo, req.Amount)
return paymentResultResponse(result), err return paymentResultResponse(result), err
} }
@ -111,6 +236,7 @@ func (s *PaymentService) Fulfill(ctx context.Context, req *dto.PaymentFulfillReq
if req == nil { if req == nil {
return nil, errors.New("支付发货请求为空") 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) result, err := s.uc.Fulfill(ctx, req.Provider, req.TradeNo)
return paymentResultResponse(result), err return paymentResultResponse(result), err
} }
@ -118,6 +244,7 @@ func (s *PaymentService) Callback(ctx context.Context, req *dto.PaymentCallbackR
if req == nil { if req == nil {
return paymentCallbackAckResponse(paymentbiz.DefaultPaymentCallbackAck("", false)), errors.New("支付回调请求为空") 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}) result, err := s.uc.Callback(ctx, &paymentbiz.PaymentCallback{Provider: req.Provider, Headers: req.Headers, Body: req.Body, Query: req.Query})
if err != nil { if err != nil {
return paymentCallbackAckResponse(paymentbiz.CallbackFailure(err, paymentbiz.DefaultPaymentCallbackAck(req.Provider, false))), err return paymentCallbackAckResponse(paymentbiz.CallbackFailure(err, paymentbiz.DefaultPaymentCallbackAck(req.Provider, false))), err

View File

@ -7,10 +7,23 @@ export const getPaymentOrders = (params) => service({
donNotShowLoading: true donNotShowLoading: true
}) })
export const getPaymentOrder = (data) => service({ export const getPaymentOrderSummary = (params) => service({
url: '/payment/order', url: '/payment/orders/summary',
method: 'post', method: 'get',
data, 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 donNotShowLoading: true
}) })

View File

@ -1,5 +1,5 @@
<template> <template>
<div class="payment-orders"> <div class="kra-table-box payment-orders">
<header class="page-heading"> <header class="page-heading">
<div> <div>
<h2>支付订单</h2> <h2>支付订单</h2>
@ -8,111 +8,157 @@
<el-button :icon="Refresh" :loading="loading" @click="load()">刷新</el-button> <el-button :icon="Refresh" :loading="loading" @click="load()">刷新</el-button>
</header> </header>
<section class="status-summary" aria-label="当前页订单概览"> <section class="status-summary" aria-label="筛选范围订单概览">
<div v-for="item in pageSummary" :key="item.label" class="summary-item"> <div v-for="item in systemSummary" :key="item.label" class="summary-item" :class="`is-${item.tone}`">
<span>{{ item.label }}</span> <div class="summary-copy"><span>{{ item.label }}</span><small>{{ item.hint }}</small></div>
<strong>{{ item.value }}</strong> <strong>{{ item.value }}</strong>
<small>{{ item.hint }}</small> <el-icon class="summary-icon"><component :is="item.icon" /></el-icon>
</div> </div>
</section> </section>
<div class="kra-search-box filter-band"> <section class="filter-band">
<el-form :model="searchInfo" label-position="top" @submit.prevent="reload"> <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-form-item label="支付渠道">
<el-select v-model="searchInfo.provider" clearable filterable placeholder="全部渠道"> <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-option v-for="item in providerOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select> </el-select>
</el-form-item> </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-form-item label="支付状态">
<el-select v-model="searchInfo.paymentStatus" clearable placeholder="全部状态"> <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-option v-for="item in paymentStatusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select> </el-select>
</el-form-item> </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"> <div class="filter-actions">
<el-button type="primary" :icon="Search" @click="reload">查询</el-button> <el-button type="primary" :icon="Search" @click="reload">查询</el-button>
<el-button :icon="Refresh" @click="reset">重置</el-button> <el-button :icon="Refresh" @click="reset">重置</el-button>
<el-button text :icon="Filter" @click="advancedVisible = !advancedVisible">{{ advancedVisible ? '收起' : '更多筛选' }}</el-button>
</div> </div>
</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> </el-form>
</div> </section>
<div class="kra-table-box order-table-band"> <section class="order-table-band">
<div class="table-heading"> <div class="table-heading">
<div><strong>订单明细</strong><span> {{ total }} </span></div> <div><strong>订单明细</strong><span> {{ total }} </span></div>
<span v-if="issueCount" class="issue-count">{{ issueCount }} 笔需要关注</span> <span v-if="issueCount" class="issue-count">{{ issueCount }} 笔需要关注</span>
</div> </div>
<el-table v-loading="loading" :data="rows" row-key="ID" stripe :row-class-name="rowClassName"> <el-table v-if="!isMobile" v-loading="loading" :data="rows" row-key="ID" :row-class-name="rowClassName">
<el-table-column label="订单" min-width="250"> <el-table-column label="订单与交易" min-width="285">
<template #default="scope"> <template #default="scope">
<div class="order-cell"> <div class="order-cell">
<button type="button" class="order-link" @click="openDetail(scope.row)">{{ scope.row.tradeNo || '-' }}</button> <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> </div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="业务" min-width="165"> <el-table-column label="商品与业务" min-width="220">
<template #default="scope"> <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> </template>
</el-table-column> </el-table-column>
<el-table-column label="渠道" width="135"> <el-table-column label="金额与结算" width="165" align="right">
<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">
<template #default="scope"> <template #default="scope">
<div class="amount-cell"> <div class="amount-cell">
<strong>{{ formatAmount(scope.row.amount, scope.row.currency) }}</strong> <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.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> </div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="支付" width="115" align="center"> <el-table-column label="状态" width="230">
<template #default="scope"><el-tag :type="paymentStatusType(scope.row.paymentStatus)">{{ paymentStatusText(scope.row.paymentStatus) }}</el-tag></template> <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>
<el-table-column label="发货" width="115" align="center"> <el-table-column label="渠道与时间" width="190">
<template #default="scope"><el-tag :type="fulfillmentStatusType(scope.row.fulfillmentStatus)" effect="plain">{{ fulfillmentStatusText(scope.row.fulfillmentStatus, scope.row) }}</el-tag></template> <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>
<el-table-column label="退款" width="120" align="center"> <el-table-column label="操作" :fixed="operationFixed" width="154" align="right">
<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">
<template #default="scope"> <template #default="scope">
<div class="row-actions"> <div class="row-actions">
<el-button link type="primary" :icon="View" :disabled="isRowBusy(scope.row)" @click="openDetail(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-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-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-button v-if="canRefund(scope.row)" link type="warning" :icon="Money" :disabled="isRowBusy(scope.row)" @click="openRefund(scope.row)">退款</el-button> <el-dropdown v-if="canRefund(scope.row) || canFulfill(scope.row)" trigger="click" @command="(command) => handleRowCommand(command, scope.row)">
<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-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> </div>
</template> </template>
</el-table-column> </el-table-column>
<template #empty><el-empty description="没有符合条件的支付订单" /></template> <template #empty><el-empty description="没有符合条件的支付订单" /></template>
</el-table> </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"> <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" /> <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>
</div> </section>
<el-drawer v-model="detailVisible" title="支付订单详情" :size="drawerSize" destroy-on-close> <el-drawer v-model="detailVisible" title="支付订单详情" :size="drawerSize" destroy-on-close>
<div v-loading="detailLoading" class="order-detail"> <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="fulfillmentStatusType(detail.fulfillmentStatus)" effect="plain">{{ fulfillmentStatusText(detail.fulfillmentStatus, detail) }}</el-tag>
<el-tag :type="refundStatusType(detail.refundStatus)" effect="plain">{{ refundStatusText(detail.refundStatus) }}</el-tag> <el-tag :type="refundStatusType(detail.refundStatus)" effect="plain">{{ refundStatusText(detail.refundStatus) }}</el-tag>
</div> </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"> <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="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> <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> </div>
<el-alert v-if="!detail.amountBreakdownKnown && detail.paymentStatus === 'paid'" type="info" :closable="false" class="amount-alert" title="支付渠道未返回完整的实付、优惠与结算拆分。" /> <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 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> </el-timeline>
</template> </template>
</div> </div>
@ -175,6 +230,9 @@
</el-input> </el-input>
<span class="form-tip">{{ refundPrecisionText }}</span> <span class="form-tip">{{ refundPrecisionText }}</span>
</el-form-item> </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> </el-form>
<template #footer> <template #footer>
<el-button :disabled="refundSubmitting" @click="refundVisible = false">取消</el-button> <el-button :disabled="refundSubmitting" @click="refundVisible = false">取消</el-button>
@ -185,11 +243,11 @@
</template> </template>
<script setup> <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 { useWindowSize } from '@vueuse/core'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { CopyDocument, Money, Promotion, Refresh, RefreshRight, Search, View } from '@element-plus/icons-vue' 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, queryPaymentOrder, refundPaymentOrder } from '@/api/payment' import { fulfillPaymentOrder, getPaymentOrder, getPaymentOrders, getPaymentOrderSummary, queryPaymentOrder, refundPaymentOrder } from '@/api/payment'
import { formatDate } from '@/utils/format' import { formatDate } from '@/utils/format'
defineOptions({ name: 'PaymentOrders' }) defineOptions({ name: 'PaymentOrders' })
@ -209,6 +267,17 @@ const REFUND_STATUS_META = {
pending: { label: '渠道处理中', type: 'warning' }, partial: { label: '部分退款', type: 'warning' }, pending: { label: '渠道处理中', type: 'warning' }, partial: { label: '部分退款', type: 'warning' },
succeeded: { label: '已退款', type: 'success' }, failed: { label: '退款失败', type: 'danger' } 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 = [ const providerOptions = [
['alipay', '支付宝'], ['alipay-v3', '支付宝 V3'], ['wechat-v2', '微信支付 V2'], ['wechat-v3', '微信支付 V3'], ['alipay', '支付宝'], ['alipay-v3', '支付宝 V3'], ['wechat-v2', '微信支付 V2'], ['wechat-v3', '微信支付 V3'],
['apple-iap', 'Apple IAP'], ['douyin', '抖音支付'], ['qq', 'QQ 钱包'], ['allinpay', '通联支付'], ['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', '内部支付'] ['supper-pay', 'Supper Pay'], ['wechat-game-pay', '微信小游戏支付'], ['douyin-game-pay', '抖音小游戏支付'], ['internal', '内部支付']
].map(([value, label]) => ({ value, label })) ].map(([value, label]) => ({ value, label }))
const providerNames = Object.fromEntries(providerOptions.map((item) => [item.value, item.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 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 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']) 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 pageSize = ref(10)
const total = ref(0) const total = ref(0)
const rows = ref([]) const rows = ref([])
const summary = ref({ orderCount: 0, pendingCount: 0, paidCount: 0, refundedCount: 0, issueCount: 0, currencies: [] })
const loading = ref(false) const loading = ref(false)
const detail = ref(null) const detail = ref(null)
const detailVisible = ref(false) const detailVisible = ref(false)
@ -237,22 +308,32 @@ const refundLoading = ref(false)
const refundSubmitting = ref(false) const refundSubmitting = ref(false)
const refundFormRef = ref() const refundFormRef = ref()
const rowActions = reactive({}) const rowActions = reactive({})
const searchInfo = reactive({ provider: '', tradeNo: '', businessType: '', businessId: '', paymentStatus: '', refundStatus: '' }) const advancedVisible = ref(width.value >= 720)
const refundForm = reactive({ provider: '', tradeNo: '', currency: 'CNY', maxAmount: 0, amountText: '' }) 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 loadRequestID = 0
let detailRequestID = 0 let detailRequestID = 0
const issueCount = computed(() => rows.value.filter(isIssueOrder).length) watch(width, (value, previous) => {
const pageSummary = computed(() => [ if (value < 720 && previous >= 720) advancedVisible.value = false
{ 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: '含部分或全额退款' }, const issueCount = computed(() => Number(summary.value.issueCount || 0))
{ label: '需要关注', value: issueCount.value, hint: '支付、发货或退款异常' } 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 paginationLayout = computed(() => width.value < 720 ? 'total, prev, pager, next' : 'total, sizes, prev, pager, next, jumper')
const operationFixed = computed(() => width.value >= 1180 ? 'right' : false) const operationFixed = computed(() => width.value >= 1180 ? 'right' : false)
const isMobile = computed(() => width.value < 720)
const drawerSize = computed(() => width.value < 720 ? '96%' : '720px') const drawerSize = computed(() => width.value < 720 ? '96%' : '720px')
const detailColumns = computed(() => width.value < 720 ? 1 : 2) 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 refundPrecisionText = computed(() => currencyMinorDigits(refundForm.currency) === 0 ? '该币种仅支持整数金额' : `最多支持 ${currencyMinorDigits(refundForm.currency)} 位小数`)
const refundAmountPlaceholder = computed(() => currencyMinorDigits(refundForm.currency) === 0 ? '例如 100' : '例如 100.00') const refundAmountPlaceholder = computed(() => currencyMinorDigits(refundForm.currency) === 0 ? '例如 100' : '例如 100.00')
const detailAmounts = computed(() => { const detailAmounts = computed(() => {
@ -279,15 +360,27 @@ const detailAmounts = computed(() => {
}) })
const detailTimeline = computed(() => { const detailTimeline = computed(() => {
if (!detail.value) return [] 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 [ return [
{ label: '订单创建', value: detail.value.createdAt, type: 'primary' }, { key: 'created', label: '订单创建', value: detail.value.createdAt, type: 'primary', source: 'system', meta: [] },
{ label: '支付确认', value: detail.value.paidAt, type: 'success' }, { key: 'paid', label: '支付确认', value: detail.value.paidAt, type: 'success', source: detail.value.provider, meta: [] },
{ label: '业务发货', value: detail.value.fulfilledAt, type: 'success' }, { key: 'fulfilled', label: '业务发货', value: detail.value.fulfilledAt, type: 'success', source: 'system', meta: [] },
{ label: '退款确认', value: detail.value.refundedAt, type: 'warning' }, { key: 'refunded', label: '退款确认', value: detail.value.refundedAt, type: 'warning', source: detail.value.provider, meta: [] }
{ label: '最后更新', value: detail.value.updatedAt, type: 'info' } ].filter((item) => item.value).map((item) => ({ ...item, source: providerText(item.source), time: formatDateValue(item.value) }))
].filter((item) => item.value).map((item) => ({ ...item, 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) { function currencyMinorDigits(currency) {
const code = String(currency || 'CNY').trim().toUpperCase() const code = String(currency || 'CNY').trim().toUpperCase()
@ -318,6 +411,18 @@ function formatAmountInput(value, currency) {
const whole = Math.floor(amount / scale) const whole = Math.floor(amount / scale)
return digits === 0 ? String(whole) : `${whole}.${String(amount % scale).padStart(digits, '0')}` 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) { function parseAmountToMinor(value, currency) {
const text = String(value || '').trim() const text = String(value || '').trim()
const digits = currencyMinorDigits(currency) const digits = currencyMinorDigits(currency)
@ -362,19 +467,35 @@ function rowAction(order) { return rowActions[actionKey(order)] || '' }
function isRowBusy(order) { return Boolean(rowAction(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 setRowAction(order, action) { const key = actionKey(order); if (action) rowActions[key] = action; else delete rowActions[key] }
function requestFilters() { 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 = {}) { async function load(options = {}) {
const requestID = ++loadRequestID const requestID = ++loadRequestID
if (!options.silent) loading.value = true if (!options.silent) loading.value = true
try { try {
const res = await getPaymentOrders({ page: page.value, pageSize: pageSize.value, ...requestFilters() }) const filters = requestFilters()
if (requestID !== loadRequestID || res.code !== 0) return const [ordersResult, summaryResult] = await Promise.allSettled([
rows.value = res.data?.list || [] getPaymentOrders({ page: page.value, pageSize: pageSize.value, ...filters }),
total.value = Number(res.data?.total || 0) getPaymentOrderSummary(filters)
page.value = Number(res.data?.page || page.value) ])
pageSize.value = Number(res.data?.pageSize || pageSize.value) 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 { } catch {
// The request layer already presents transport errors. // The request layer already presents transport errors.
} finally { } finally {
@ -382,7 +503,16 @@ async function load(options = {}) {
} }
} }
function reload() { page.value = 1; load() } 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 changePage(value) { page.value = value; load() }
function changeSize(value) { pageSize.value = value; page.value = 1; load() } function changeSize(value) { pageSize.value = value; page.value = 1; load() }
async function loadDetail(order) { async function loadDetail(order) {
@ -401,11 +531,19 @@ async function loadDetail(order) {
} }
} }
function openDetail(order) { detail.value = { ...order }; detailVisible.value = true; 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) { async function refreshVisibleDetail(order) {
if (detailVisible.value && detail.value?.provider === order.provider && detail.value?.tradeNo === order.tradeNo) await loadDetail(order) if (detailVisible.value && detail.value?.provider === order.provider && detail.value?.tradeNo === order.tradeNo) await loadDetail(order)
} }
async function syncOrder(order) { async function syncOrder(order) {
if (isRowBusy(order)) return if (isRowBusy(order) || !canSync(order)) return
setRowAction(order, 'sync') setRowAction(order, 'sync')
try { try {
const res = await queryPaymentOrder({ provider: order.provider, tradeNo: order.tradeNo }) const res = await queryPaymentOrder({ provider: order.provider, tradeNo: order.tradeNo })
@ -435,8 +573,9 @@ async function retryFulfillment(order) {
} finally { setRowAction(order, '') } } finally { setRowAction(order, '') }
} }
function openRefund(order) { function openRefund(order) {
if (isRowBusy(order) || !canRefund(order)) return
const maxAmount = remainingRefundAmount(order) 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 refundVisible.value = true
nextTick(() => refundFormRef.value?.clearValidate()) nextTick(() => refundFormRef.value?.clearValidate())
} }
@ -454,7 +593,7 @@ async function submitRefund() {
refundLoading.value = true refundLoading.value = true
const order = { provider: refundForm.provider, tradeNo: refundForm.tradeNo } const order = { provider: refundForm.provider, tradeNo: refundForm.tradeNo }
try { 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 if (res.code !== 0) return
ElMessage.success(res.data?.refundStatus === 'succeeded' ? '退款已完成' : '退款申请已提交,等待渠道确认') ElMessage.success(res.data?.refundStatus === 'succeeded' ? '退款已完成' : '退款申请已提交,等待渠道确认')
refundVisible.value = false 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 { 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 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; } .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); } .status-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; margin-bottom: 16px; }
.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 { 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 span { color: var(--el-text-color-regular); font-size: 13px; } .summary-item::before { position: absolute; inset: 0 auto 0 0; width: 3px; background: var(--summary-color); content: ''; }
.summary-item strong { grid-row: span 2; color: var(--el-text-color-primary); font-size: 24px; font-weight: 600; line-height: 1; } .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-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-band { margin-bottom: 16px; }
.filter-grid { display: grid; grid-template-columns: repeat(3, minmax(160px, 1fr)); gap: 0 14px; } .primary-filter-grid { display: grid; grid-template-columns: minmax(280px, 2fr) minmax(160px, 1fr) minmax(160px, 1fr) auto; gap: 0 14px; }
.filter-grid :deep(.el-form-item) { margin-bottom: 12px; } .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); }
.filter-grid :deep(.el-select), .filter-grid :deep(.el-input) { width: 100%; } .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; } .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; } .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 { 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 > 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 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; } .table-heading span { color: var(--el-text-color-secondary); font-size: 12px; }
.issue-count { color: var(--el-color-danger) !important; } .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-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; } .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; } .business-cell > span, .provider-cell > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.amount-cell { justify-items: end; } .amount-cell { justify-items: end; }
.amount-cell strong { color: var(--el-text-color-primary); font-weight: 600; white-space: nowrap; } .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; } .refund-amount { color: var(--el-color-danger) !important; }
.row-actions :deep(.el-button + .el-button) { margin-left: 8px; } .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); } .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; } .kra-pagination { overflow-x: auto; }
.order-detail { min-height: 220px; } .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-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; } .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; } .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; } .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-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; } .amount-alert { margin-top: 12px; }
.order-timeline { margin: 0; padding-top: 4px; } .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 { 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 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; } .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; } .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: 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)); } .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: 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) { .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: 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> </style>