优化结构
This commit is contained in:
@@ -72,6 +72,8 @@ type PaymentRequest struct {
|
||||
BusinessID string `json:"businessId"`
|
||||
PaymentMode string `json:"paymentMode"`
|
||||
OriginalAmount int64 `json:"originalAmount"`
|
||||
UserAgent string `json:"-"`
|
||||
DeviceID string `json:"-"`
|
||||
}
|
||||
|
||||
// PaymentRefundRequest carries the durable provider identities needed to
|
||||
@@ -415,6 +417,23 @@ func (uc *PaymentUsecase) Orders(ctx context.Context, page, pageSize int, filter
|
||||
return uc.orders.ListPaymentOrders(ctx, page, pageSize, filter)
|
||||
}
|
||||
|
||||
func (uc *PaymentUsecase) OrderSummary(ctx context.Context, filter PaymentOrderFilter) (*PaymentOrderSummary, error) {
|
||||
if uc.orders == nil {
|
||||
return nil, errors.New("支付订单仓储未接入")
|
||||
}
|
||||
return uc.orders.SummarizePaymentOrders(ctx, filter)
|
||||
}
|
||||
|
||||
func (uc *PaymentUsecase) OrderEvents(ctx context.Context, provider, tradeNo string, limit int) ([]*PaymentEvent, error) {
|
||||
if uc.orders == nil {
|
||||
return nil, errors.New("支付订单仓储未接入")
|
||||
}
|
||||
if !validPaymentText(provider, 64) || !validPaymentText(tradeNo, 128) {
|
||||
return nil, errors.New("支付订单事件查询参数不完整")
|
||||
}
|
||||
return uc.orders.ListPaymentEvents(ctx, provider, tradeNo, limit)
|
||||
}
|
||||
|
||||
func (uc *PaymentUsecase) Create(ctx context.Context, req *PaymentRequest) (*PaymentResult, error) {
|
||||
if req == nil || !validPaymentText(req.Provider, 64) || !validPaymentText(req.TradeNo, 128) || !validPaymentText(req.BusinessType, 64) || !validPaymentText(req.BusinessID, 128) {
|
||||
return nil, errors.New("支付参数不完整")
|
||||
@@ -644,6 +663,9 @@ func (uc *PaymentUsecase) createWithOrder(ctx context.Context, req *PaymentReque
|
||||
ConfirmationID: paymentConfirmationID(req.Provider, req.TradeNo),
|
||||
RequestFingerprint: paymentOrderFingerprint(req, extra),
|
||||
Extra: extra,
|
||||
ClientIP: req.ClientIP,
|
||||
UserAgent: req.UserAgent,
|
||||
DeviceID: req.DeviceID,
|
||||
}
|
||||
order, created, err := uc.orders.CreatePaymentOrder(ctx, order)
|
||||
if err != nil {
|
||||
@@ -691,7 +713,9 @@ func (uc *PaymentUsecase) createWithOrder(ctx context.Context, req *PaymentReque
|
||||
if err = validatePaymentBreakdown(result); err != nil {
|
||||
return nil, fmt.Errorf("内部支付金额拆分无效: %w", err)
|
||||
}
|
||||
if _, err = uc.orders.RecordPaymentCreate(ctx, req.Provider, req.TradeNo, paymentProviderUpdate(result)); err != nil {
|
||||
update := paymentProviderUpdate(result)
|
||||
update.EventSource = PaymentInternal
|
||||
if _, err = uc.orders.RecordPaymentCreate(ctx, req.Provider, req.TradeNo, update); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return uc.reconcilePaymentResult(ctx, result, nil)
|
||||
@@ -707,7 +731,9 @@ func (uc *PaymentUsecase) createWithOrder(ctx context.Context, req *PaymentReque
|
||||
uc.logger.Error(ctx, "payment.order.provider_create_invalid", "provider", req.Provider, "trade_no", req.TradeNo, "error", err)
|
||||
return nil, err
|
||||
}
|
||||
order, err = uc.orders.RecordPaymentCreate(ctx, req.Provider, req.TradeNo, paymentProviderUpdate(result))
|
||||
update := paymentProviderUpdate(result)
|
||||
update.EventSource = req.Provider
|
||||
order, err = uc.orders.RecordPaymentCreate(ctx, req.Provider, req.TradeNo, update)
|
||||
if err != nil {
|
||||
uc.logger.Error(ctx, "payment.order.provider_create_record_failed", "provider", req.Provider, "trade_no", req.TradeNo, "error", err)
|
||||
return nil, err
|
||||
@@ -773,7 +799,17 @@ func (uc *PaymentUsecase) reconcilePaymentResult(ctx context.Context, result, ca
|
||||
return fail(err)
|
||||
}
|
||||
}
|
||||
order, err = uc.orders.ApplyPaymentResult(ctx, result.Provider, result.TradeNo, paymentProviderUpdate(result))
|
||||
update := paymentProviderUpdate(result)
|
||||
operation := PaymentOperationFromContext(ctx)
|
||||
update.EventSource = operation.Source
|
||||
update.EventType = PaymentEventPaymentSynchronized
|
||||
if callbackSource != nil {
|
||||
update.EventType = PaymentEventPaymentCallback
|
||||
if update.EventSource == "" {
|
||||
update.EventSource = result.Provider
|
||||
}
|
||||
}
|
||||
order, err = uc.orders.ApplyPaymentResult(ctx, result.Provider, result.TradeNo, update)
|
||||
if err != nil {
|
||||
uc.logger.Error(ctx, "payment.order.result_record_failed", "provider", result.Provider, "trade_no", result.TradeNo, "error", err)
|
||||
return fail(err)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -88,6 +88,9 @@ type PaymentOrder struct {
|
||||
RefundedAt *time.Time
|
||||
FulfillmentLeaseUntil *time.Time
|
||||
RefundLeaseUntil *time.Time
|
||||
ClientIP string
|
||||
UserAgent string
|
||||
DeviceID string
|
||||
}
|
||||
|
||||
type PaymentProviderUpdate struct {
|
||||
@@ -109,21 +112,33 @@ type PaymentProviderUpdate struct {
|
||||
EventID string
|
||||
PayloadHash string
|
||||
CreatePayload json.RawMessage
|
||||
EventType string
|
||||
EventSource string
|
||||
EventMessage string
|
||||
}
|
||||
|
||||
type PaymentOrderFilter struct {
|
||||
Provider string
|
||||
TradeNo string
|
||||
BusinessType string
|
||||
BusinessID string
|
||||
PaymentStatus string
|
||||
RefundStatus string
|
||||
Provider string
|
||||
TradeNo string
|
||||
ProviderTradeNo string
|
||||
Keyword string
|
||||
BusinessType string
|
||||
BusinessID string
|
||||
PaymentStatus string
|
||||
RefundStatus string
|
||||
CreatedFrom *time.Time
|
||||
CreatedTo *time.Time
|
||||
PaidFrom *time.Time
|
||||
PaidTo *time.Time
|
||||
IssueOnly bool
|
||||
}
|
||||
|
||||
type PaymentOrderRepo interface {
|
||||
CreatePaymentOrder(context.Context, *PaymentOrder) (order *PaymentOrder, created bool, err error)
|
||||
FindPaymentOrder(context.Context, string, string) (*PaymentOrder, error)
|
||||
ListPaymentOrders(context.Context, int, int, PaymentOrderFilter) ([]*PaymentOrder, int64, error)
|
||||
SummarizePaymentOrders(context.Context, PaymentOrderFilter) (*PaymentOrderSummary, error)
|
||||
ListPaymentEvents(context.Context, string, string, int) ([]*PaymentEvent, error)
|
||||
RecordPaymentCreate(context.Context, string, string, *PaymentProviderUpdate) (*PaymentOrder, error)
|
||||
ApplyPaymentResult(context.Context, string, string, *PaymentProviderUpdate) (*PaymentOrder, error)
|
||||
BeginPaymentFulfillment(context.Context, string, string, time.Duration) (order *PaymentOrder, token string, duplicate bool, err error)
|
||||
|
||||
@@ -52,6 +52,16 @@ func (r *paymentOrderRepoStub) ListPaymentOrders(context.Context, int, int, Paym
|
||||
}
|
||||
return []*PaymentOrder{r.order}, 1, nil
|
||||
}
|
||||
func (r *paymentOrderRepoStub) SummarizePaymentOrders(context.Context, PaymentOrderFilter) (*PaymentOrderSummary, error) {
|
||||
count := int64(0)
|
||||
if r.order != nil {
|
||||
count = 1
|
||||
}
|
||||
return &PaymentOrderSummary{OrderCount: count}, nil
|
||||
}
|
||||
func (r *paymentOrderRepoStub) ListPaymentEvents(context.Context, string, string, int) ([]*PaymentEvent, error) {
|
||||
return []*PaymentEvent{}, nil
|
||||
}
|
||||
func (r *paymentOrderRepoStub) RecordPaymentCreate(_ context.Context, _, _ string, update *PaymentProviderUpdate) (*PaymentOrder, error) {
|
||||
r.order.PaymentStatus = PaymentStatusPending
|
||||
r.order.CreatePayload = append(r.order.CreatePayload[:0], update.CreatePayload...)
|
||||
|
||||
@@ -25,7 +25,7 @@ func TestMigrateAllRunsModuleSchemasWithoutBootstrapSeed(t *testing.T) {
|
||||
if err = migrateAll(db, testCatalog()); err != nil {
|
||||
t.Fatalf("second migration run: %v", err)
|
||||
}
|
||||
for _, table := range []string{"sys_integration_configs", "sys_users", "sys_base_menus", "pay_orders"} {
|
||||
for _, table := range []string{"sys_integration_configs", "sys_users", "sys_base_menus", "pay_orders", "pay_events"} {
|
||||
if !db.Migrator().HasTable(table) {
|
||||
t.Fatalf("migration did not create %s", table)
|
||||
}
|
||||
@@ -34,8 +34,8 @@ func TestMigrateAllRunsModuleSchemasWithoutBootstrapSeed(t *testing.T) {
|
||||
if err = db.Table(migration.TableName).Count(&versions).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if versions != 8 {
|
||||
t.Fatalf("migration versions = %d, want 8", versions)
|
||||
if versions != 11 {
|
||||
t.Fatalf("migration versions = %d, want 11", versions)
|
||||
}
|
||||
var communicationRows []dataintegration.ConfigPO
|
||||
if err = db.Where("kind IN ?", []string{"mq", "websocket"}).Order("kind, provider").Find(&communicationRows).Error; err != nil {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -11,5 +11,8 @@ func Migrations() []migration.Step {
|
||||
{ID: "202608200003_payment_schema", Migrate: func(db *gorm.DB) error {
|
||||
return migration.CreateMissingTables(db, &paymentOrderPO{})
|
||||
}},
|
||||
{ID: "202608290001_payment_event_schema", Migrate: func(db *gorm.DB) error {
|
||||
return migration.CreateMissingTables(db, &paymentEventPO{})
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -146,7 +146,17 @@ func (r *paymentOrderRepo) CreatePaymentOrder(ctx context.Context, order *bizpay
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, false, err
|
||||
}
|
||||
if err = db.Create(po).Error; err == nil {
|
||||
err = db.Transaction(func(tx *gorm.DB) error {
|
||||
if createErr := tx.Create(po).Error; createErr != nil {
|
||||
return createErr
|
||||
}
|
||||
return appendPaymentEvent(ctx, tx, po, &bizpayment.PaymentEvent{
|
||||
Type: bizpayment.PaymentEventOrderCreated, Source: "client", Status: po.PaymentStatus,
|
||||
Amount: po.Amount, Currency: po.Currency, ClientIP: order.ClientIP,
|
||||
UserAgent: order.UserAgent, DeviceID: order.DeviceID,
|
||||
})
|
||||
})
|
||||
if err == nil {
|
||||
return toBizPaymentOrder(po), true, nil
|
||||
}
|
||||
if lookupErr := db.Where("provider = ? AND trade_no = ?", po.Provider, po.TradeNo).First(¤t).Error; lookupErr == nil {
|
||||
@@ -167,25 +177,7 @@ func (r *paymentOrderRepo) FindPaymentOrder(ctx context.Context, provider, trade
|
||||
}
|
||||
|
||||
func (r *paymentOrderRepo) ListPaymentOrders(ctx context.Context, page, pageSize int, filter bizpayment.PaymentOrderFilter) ([]*bizpayment.PaymentOrder, int64, error) {
|
||||
db := r.data.DB().WithContext(ctx).Model(&paymentOrderPO{})
|
||||
if value := strings.TrimSpace(filter.Provider); value != "" {
|
||||
db = db.Where("provider = ?", value)
|
||||
}
|
||||
if value := strings.TrimSpace(filter.TradeNo); value != "" {
|
||||
db = db.Where("trade_no LIKE ?", "%"+value+"%")
|
||||
}
|
||||
if value := strings.TrimSpace(filter.BusinessType); value != "" {
|
||||
db = db.Where("business_type = ?", value)
|
||||
}
|
||||
if value := strings.TrimSpace(filter.BusinessID); value != "" {
|
||||
db = db.Where("business_id LIKE ?", "%"+value+"%")
|
||||
}
|
||||
if value := strings.TrimSpace(filter.PaymentStatus); value != "" {
|
||||
db = db.Where("payment_status = ?", value)
|
||||
}
|
||||
if value := strings.TrimSpace(filter.RefundStatus); value != "" {
|
||||
db = db.Where("refund_status = ?", value)
|
||||
}
|
||||
db := r.filteredPaymentOrders(ctx, filter)
|
||||
var total int64
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
@@ -201,6 +193,105 @@ func (r *paymentOrderRepo) ListPaymentOrders(ctx context.Context, page, pageSize
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
func (r *paymentOrderRepo) filteredPaymentOrders(ctx context.Context, filter bizpayment.PaymentOrderFilter) *gorm.DB {
|
||||
db := r.data.DB().WithContext(ctx).Model(&paymentOrderPO{})
|
||||
if value := strings.TrimSpace(filter.Provider); value != "" {
|
||||
db = db.Where("provider = ?", value)
|
||||
}
|
||||
if value := strings.TrimSpace(filter.TradeNo); value != "" {
|
||||
db = db.Where("trade_no LIKE ?", "%"+value+"%")
|
||||
}
|
||||
if value := strings.TrimSpace(filter.ProviderTradeNo); value != "" {
|
||||
db = db.Where("provider_trade_no LIKE ?", "%"+value+"%")
|
||||
}
|
||||
if value := strings.TrimSpace(filter.Keyword); value != "" {
|
||||
like := "%" + value + "%"
|
||||
db = db.Where("trade_no LIKE ? OR provider_trade_no LIKE ? OR business_id LIKE ? OR subject LIKE ?", like, like, like, like)
|
||||
}
|
||||
if value := strings.TrimSpace(filter.BusinessType); value != "" {
|
||||
db = db.Where("business_type = ?", value)
|
||||
}
|
||||
if value := strings.TrimSpace(filter.BusinessID); value != "" {
|
||||
db = db.Where("business_id LIKE ?", "%"+value+"%")
|
||||
}
|
||||
if value := strings.TrimSpace(filter.PaymentStatus); value != "" {
|
||||
db = db.Where("payment_status = ?", value)
|
||||
}
|
||||
if value := strings.TrimSpace(filter.RefundStatus); value != "" {
|
||||
db = db.Where("refund_status = ?", value)
|
||||
}
|
||||
if filter.CreatedFrom != nil {
|
||||
db = db.Where("created_at >= ?", filter.CreatedFrom.UTC())
|
||||
}
|
||||
if filter.CreatedTo != nil {
|
||||
db = db.Where("created_at < ?", filter.CreatedTo.UTC())
|
||||
}
|
||||
if filter.PaidFrom != nil {
|
||||
db = db.Where("paid_at >= ?", filter.PaidFrom.UTC())
|
||||
}
|
||||
if filter.PaidTo != nil {
|
||||
db = db.Where("paid_at < ?", filter.PaidTo.UTC())
|
||||
}
|
||||
if filter.IssueOnly {
|
||||
db = db.Where("payment_status = ? OR fulfillment_status = ? OR refund_status = ? OR last_error <> ''",
|
||||
bizpayment.PaymentStatusFailed, bizpayment.FulfillmentStatusFailed, bizpayment.RefundStatusFailed)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func (r *paymentOrderRepo) SummarizePaymentOrders(ctx context.Context, filter bizpayment.PaymentOrderFilter) (*bizpayment.PaymentOrderSummary, error) {
|
||||
type countRow struct {
|
||||
OrderCount int64
|
||||
PendingCount int64
|
||||
PaidCount int64
|
||||
RefundedCount int64
|
||||
IssueCount int64
|
||||
}
|
||||
var counts countRow
|
||||
if err := r.filteredPaymentOrders(ctx, filter).Select(`
|
||||
COUNT(*) AS order_count,
|
||||
COALESCE(SUM(CASE WHEN payment_status IN ? THEN 1 ELSE 0 END), 0) AS pending_count,
|
||||
COALESCE(SUM(CASE WHEN payment_status IN ? THEN 1 ELSE 0 END), 0) AS paid_count,
|
||||
COALESCE(SUM(CASE WHEN refunded_amount > 0 THEN 1 ELSE 0 END), 0) AS refunded_count,
|
||||
COALESCE(SUM(CASE WHEN payment_status = ? OR fulfillment_status = ? OR refund_status = ? OR last_error <> '' THEN 1 ELSE 0 END), 0) AS issue_count`,
|
||||
[]string{bizpayment.PaymentStatusInitialized, bizpayment.PaymentStatusPending},
|
||||
[]string{bizpayment.PaymentStatusPaid, bizpayment.PaymentStatusPartiallyRefunded, bizpayment.PaymentStatusRefunded},
|
||||
bizpayment.PaymentStatusFailed, bizpayment.FulfillmentStatusFailed, bizpayment.RefundStatusFailed,
|
||||
).Scan(&counts).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
type currencyRow struct {
|
||||
Currency string
|
||||
OrderAmount int64
|
||||
PaidAmount int64
|
||||
RefundedAmount int64
|
||||
SettlementAmount int64
|
||||
}
|
||||
var rows []currencyRow
|
||||
if err := r.filteredPaymentOrders(ctx, filter).
|
||||
Select(`currency,
|
||||
COALESCE(SUM(amount), 0) AS order_amount,
|
||||
COALESCE(SUM(paid_amount), 0) AS paid_amount,
|
||||
COALESCE(SUM(refunded_amount), 0) AS refunded_amount,
|
||||
COALESCE(SUM(CASE WHEN settlement_amount > 0 THEN settlement_amount ELSE paid_amount END), 0) AS settlement_amount`).
|
||||
Group("currency").Order("currency ASC").Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
summary := &bizpayment.PaymentOrderSummary{
|
||||
OrderCount: counts.OrderCount, PendingCount: counts.PendingCount, PaidCount: counts.PaidCount,
|
||||
RefundedCount: counts.RefundedCount, IssueCount: counts.IssueCount,
|
||||
Currencies: make([]bizpayment.PaymentCurrencySummary, 0, len(rows)),
|
||||
}
|
||||
for _, row := range rows {
|
||||
summary.Currencies = append(summary.Currencies, bizpayment.PaymentCurrencySummary{
|
||||
Currency: row.Currency, OrderAmount: row.OrderAmount, PaidAmount: row.PaidAmount,
|
||||
RefundedAmount: row.RefundedAmount, SettlementAmount: row.SettlementAmount,
|
||||
NetAmount: row.SettlementAmount - row.RefundedAmount,
|
||||
})
|
||||
}
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
func (r *paymentOrderRepo) RecordPaymentCreate(ctx context.Context, provider, tradeNo string, update *bizpayment.PaymentProviderUpdate) (*bizpayment.PaymentOrder, error) {
|
||||
return r.withLockedOrder(ctx, provider, tradeNo, func(tx *gorm.DB, po *paymentOrderPO) error {
|
||||
if update == nil {
|
||||
@@ -224,7 +315,11 @@ func (r *paymentOrderRepo) RecordPaymentCreate(ctx context.Context, provider, tr
|
||||
po.PaymentStatus = status
|
||||
}
|
||||
po.Version++
|
||||
return tx.Save(po).Error
|
||||
return savePaymentOrderWithEvent(ctx, tx, po, &bizpayment.PaymentEvent{
|
||||
Type: bizpayment.PaymentEventProviderCreated, Source: update.EventSource, Status: po.PaymentStatus,
|
||||
ProviderStatus: po.ProviderStatus, Message: update.EventMessage, EventID: update.EventID,
|
||||
PayloadHash: update.PayloadHash, Amount: po.Amount, Currency: po.Currency,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -279,7 +374,15 @@ func (r *paymentOrderRepo) ApplyPaymentResult(ctx context.Context, provider, tra
|
||||
}
|
||||
}
|
||||
po.Version++
|
||||
return tx.Save(po).Error
|
||||
eventType := update.EventType
|
||||
if eventType == "" {
|
||||
eventType = bizpayment.PaymentEventPaymentSynchronized
|
||||
}
|
||||
return savePaymentOrderWithEvent(ctx, tx, po, &bizpayment.PaymentEvent{
|
||||
Type: eventType, Source: update.EventSource, Status: po.PaymentStatus,
|
||||
ProviderStatus: po.ProviderStatus, Message: update.EventMessage, EventID: update.EventID,
|
||||
PayloadHash: update.PayloadHash, Amount: po.PaidAmount, Currency: po.Currency,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -308,7 +411,9 @@ func (r *paymentOrderRepo) BeginPaymentFulfillment(ctx context.Context, provider
|
||||
po.FulfillmentLeaseUntil = &until
|
||||
po.LastError = ""
|
||||
po.Version++
|
||||
return tx.Save(po).Error
|
||||
return savePaymentOrderWithEvent(ctx, tx, po, &bizpayment.PaymentEvent{
|
||||
Type: bizpayment.PaymentEventFulfillmentStarted, Status: po.FulfillmentStatus,
|
||||
})
|
||||
})
|
||||
return order, token, duplicate, err
|
||||
}
|
||||
@@ -329,7 +434,9 @@ func (r *paymentOrderRepo) CompletePaymentFulfillment(ctx context.Context, provi
|
||||
po.FulfillmentStatus = bizpayment.FulfillmentStatusFailed
|
||||
}
|
||||
po.Version++
|
||||
return tx.Save(po).Error
|
||||
return savePaymentOrderWithEvent(ctx, tx, po, &bizpayment.PaymentEvent{
|
||||
Type: bizpayment.PaymentEventFulfillmentFinished, Status: po.FulfillmentStatus, Message: message,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -378,7 +485,9 @@ func (r *paymentOrderRepo) BeginPaymentRefund(ctx context.Context, provider, tra
|
||||
po.RefundLeaseUntil = &until
|
||||
po.LastError = ""
|
||||
po.Version++
|
||||
return tx.Save(po).Error
|
||||
return savePaymentOrderWithEvent(ctx, tx, po, &bizpayment.PaymentEvent{
|
||||
Type: bizpayment.PaymentEventRefundStarted, Status: po.RefundStatus, Amount: amount, Currency: po.Currency,
|
||||
})
|
||||
})
|
||||
return order, token, err
|
||||
}
|
||||
@@ -399,7 +508,10 @@ func (r *paymentOrderRepo) CompletePaymentRefundRequest(ctx context.Context, pro
|
||||
po.RefundNo = ""
|
||||
}
|
||||
po.Version++
|
||||
return tx.Save(po).Error
|
||||
return savePaymentOrderWithEvent(ctx, tx, po, &bizpayment.PaymentEvent{
|
||||
Type: bizpayment.PaymentEventRefundSubmitted, Status: po.RefundStatus, Message: message,
|
||||
Amount: po.RefundRequestedAmount, Currency: po.Currency,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -414,7 +526,10 @@ func (r *paymentOrderRepo) ConfirmPaymentRefund(ctx context.Context, provider, t
|
||||
po.RefundStatus = bizpayment.RefundStatusFailed
|
||||
po.RefundNo = ""
|
||||
po.Version++
|
||||
return tx.Save(po).Error
|
||||
return savePaymentOrderWithEvent(ctx, tx, po, &bizpayment.PaymentEvent{
|
||||
Type: bizpayment.PaymentEventRefundConfirmed, Status: po.RefundStatus, Message: message,
|
||||
Amount: amount, Currency: po.Currency,
|
||||
})
|
||||
}
|
||||
po.RefundedAmount += amount
|
||||
if po.RefundedAmount >= po.Amount {
|
||||
@@ -428,7 +543,10 @@ func (r *paymentOrderRepo) ConfirmPaymentRefund(ctx context.Context, provider, t
|
||||
now := time.Now().UTC()
|
||||
po.RefundedAt = &now
|
||||
po.Version++
|
||||
return tx.Save(po).Error
|
||||
return savePaymentOrderWithEvent(ctx, tx, po, &bizpayment.PaymentEvent{
|
||||
Type: bizpayment.PaymentEventRefundConfirmed, Status: po.RefundStatus, Message: message,
|
||||
Amount: amount, Currency: po.Currency,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ func newPaymentOrderRepoForTest(t *testing.T) *paymentOrderRepo {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&paymentOrderPO{}); err != nil {
|
||||
if err = db.AutoMigrate(&paymentOrderPO{}, &paymentEventPO{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &paymentOrderRepo{data: &Data{gormDB: newReloadableDB(db, nil)}}
|
||||
@@ -114,3 +114,57 @@ func TestPaymentOrderRepositoryListsWithFilters(t *testing.T) {
|
||||
t.Fatalf("filtered orders = %#v total=%d", items, total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentOrderRepositorySummarizesAndListsEvents(t *testing.T) {
|
||||
repo := newPaymentOrderRepoForTest(t)
|
||||
ctx := bizpayment.WithPaymentOperation(context.Background(), bizpayment.PaymentOperation{
|
||||
Source: "admin", Reason: "客户重复购买", OperatorID: 7, OperatorName: "operator",
|
||||
ClientIP: "127.0.0.1", DeviceID: "device-1",
|
||||
})
|
||||
order := testPaymentOrder()
|
||||
order.ClientIP = "10.0.0.8"
|
||||
order.UserAgent = "payment-client/1.0"
|
||||
if _, _, err := repo.CreatePaymentOrder(ctx, order); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := repo.ApplyPaymentResult(ctx, bizpayment.PaymentAlipay, order.TradeNo, &bizpayment.PaymentProviderUpdate{
|
||||
Status: "success", ProviderStatus: "TRADE_SUCCESS", ProviderTradeNo: "provider-summary-1",
|
||||
Amount: 100, Currency: "CNY", EventType: bizpayment.PaymentEventPaymentSynchronized,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
current, token, err := repo.BeginPaymentRefund(ctx, bizpayment.PaymentAlipay, order.TradeNo, 40, time.Minute)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if current, err = repo.CompletePaymentRefundRequest(ctx, bizpayment.PaymentAlipay, order.TradeNo, token, true, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = repo.ConfirmPaymentRefund(ctx, bizpayment.PaymentAlipay, order.TradeNo, current.RefundNo, 40, true, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
summary, err := repo.SummarizePaymentOrders(ctx, bizpayment.PaymentOrderFilter{ProviderTradeNo: "summary-1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if summary.OrderCount != 1 || summary.PaidCount != 1 || summary.RefundedCount != 1 || len(summary.Currencies) != 1 || summary.Currencies[0].NetAmount != 60 {
|
||||
t.Fatalf("unexpected summary: %#v", summary)
|
||||
}
|
||||
events, err := repo.ListPaymentEvents(ctx, bizpayment.PaymentAlipay, order.TradeNo, 100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(events) < 5 {
|
||||
t.Fatalf("event count = %d, want at least 5", len(events))
|
||||
}
|
||||
var foundRefund bool
|
||||
for _, event := range events {
|
||||
if event.Type == bizpayment.PaymentEventRefundStarted {
|
||||
foundRefund = event.Message == "客户重复购买" && event.OperatorID == 7 && event.DeviceID == "device-1"
|
||||
}
|
||||
}
|
||||
if !foundRefund {
|
||||
t.Fatalf("refund audit event missing operation context: %#v", events)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
platformmodule "kra/pkg/module"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// EnsureAdminSurface upgrades an already initialized installation with a
|
||||
// module's current menus, API metadata, root menu links, and root policies.
|
||||
// It intentionally does nothing before first-install seed data exists.
|
||||
func EnsureAdminSurface(db *gorm.DB, surface platformmodule.Surface, rootAuthorityID uint) error {
|
||||
if db == nil || !db.Migrator().HasTable(&menuPO{}) || !db.Migrator().HasTable(&apiPO{}) {
|
||||
return nil
|
||||
}
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
parentIDs := map[string]uint{}
|
||||
for _, item := range surface.Menus {
|
||||
if item.ParentName == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := parentIDs[item.ParentName]; ok {
|
||||
continue
|
||||
}
|
||||
var parent menuPO
|
||||
if err := tx.Where("name = ?", item.ParentName).First(&parent).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
parentIDs[item.ParentName] = parent.ID
|
||||
}
|
||||
|
||||
menus := make([]menuPO, 0, len(surface.Menus))
|
||||
for _, item := range surface.Menus {
|
||||
menu := menuPO{
|
||||
MenuLevel: 1, ParentID: parentIDs[item.ParentName], Path: item.Path, Name: item.Name,
|
||||
Component: item.Component, Title: item.Title, Icon: item.Icon, Sort: item.Sort,
|
||||
}
|
||||
if item.ParentName == "" {
|
||||
menu.MenuLevel = 0
|
||||
}
|
||||
var current menuPO
|
||||
err := tx.Where("name = ?", item.Name).First(¤t).Error
|
||||
switch {
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
if err = tx.Create(&menu).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
case err != nil:
|
||||
return err
|
||||
default:
|
||||
if err = tx.Model(¤t).Updates(map[string]any{
|
||||
"menu_level": menu.MenuLevel, "parent_id": menu.ParentID, "path": menu.Path,
|
||||
"component": menu.Component, "title": menu.Title, "icon": menu.Icon, "sort": menu.Sort,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
menu.ID = current.ID
|
||||
}
|
||||
menus = append(menus, menu)
|
||||
}
|
||||
|
||||
apis := make([]apiPO, 0, len(surface.APIs))
|
||||
for _, item := range surface.APIs {
|
||||
api := apiPO{Path: item.Path, Method: strings.ToUpper(item.Method), APIGroup: item.Group, Description: item.Description}
|
||||
var current apiPO
|
||||
err := tx.Where("path = ? AND method = ?", api.Path, api.Method).First(¤t).Error
|
||||
switch {
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
if err = tx.Create(&api).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
case err != nil:
|
||||
return err
|
||||
default:
|
||||
if err = tx.Model(¤t).Updates(map[string]any{"api_group": api.APIGroup, "description": api.Description}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
api.ID = current.ID
|
||||
}
|
||||
apis = append(apis, api)
|
||||
}
|
||||
|
||||
if rootAuthorityID == 0 || !tx.Migrator().HasTable(&authorityPO{}) || !tx.Migrator().HasTable(&authorityMenuPO{}) || !tx.Migrator().HasTable(&casbinRulePO{}) {
|
||||
return nil
|
||||
}
|
||||
var root authorityPO
|
||||
if err := tx.Where("authority_id = ?", rootAuthorityID).First(&root).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
for _, menu := range menus {
|
||||
link := authorityMenuPO{SysAuthorityAuthorityID: rootAuthorityID, SysBaseMenuID: menu.ID}
|
||||
var count int64
|
||||
if err := tx.Model(&authorityMenuPO{}).
|
||||
Where("sys_authority_authority_id = ? AND sys_base_menu_id = ?", rootAuthorityID, menu.ID).
|
||||
Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
if err := tx.Create(&link).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, api := range apis {
|
||||
exists, err := policyExists(tx, rootAuthorityID, api.Path, api.Method)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
rule := newPolicyRule(rootAuthorityID, api.Path, api.Method)
|
||||
if err := tx.Create(&rule).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// InheritAPIPolicy grants a new route only to authorities that already own
|
||||
// the supplied predecessor route. This preserves existing read-access intent
|
||||
// without broadening unrelated write permissions.
|
||||
func InheritAPIPolicy(db *gorm.DB, sourcePath, sourceMethod, targetPath, targetMethod string) error {
|
||||
if db == nil || !db.Migrator().HasTable(&casbinRulePO{}) {
|
||||
return nil
|
||||
}
|
||||
sourceMethod = strings.ToUpper(sourceMethod)
|
||||
targetMethod = strings.ToUpper(targetMethod)
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
var rows []casbinRulePO
|
||||
if err := policyScope(tx).Where("v1 = ? AND v2 = ?", sourcePath, sourceMethod).Find(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, row := range rows {
|
||||
parsed, err := strconv.ParseUint(row.V0, 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
authorityID := uint(parsed)
|
||||
exists, err := policyExists(tx, authorityID, targetPath, targetMethod)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
rule := newPolicyRule(authorityID, targetPath, targetMethod)
|
||||
if err := tx.Create(&rule).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// GrantMenuAPIs makes a visible administration menu usable by granting only
|
||||
// its explicitly supplied API set to authorities that already own the menu.
|
||||
func GrantMenuAPIs(db *gorm.DB, menuName string, apis []platformmodule.API) error {
|
||||
if db == nil || !db.Migrator().HasTable(&menuPO{}) || !db.Migrator().HasTable(&authorityMenuPO{}) || !db.Migrator().HasTable(&casbinRulePO{}) {
|
||||
return nil
|
||||
}
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
var menu menuPO
|
||||
if err := tx.Where("name = ?", menuName).First(&menu).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
var links []authorityMenuPO
|
||||
if err := tx.Where("sys_base_menu_id = ?", menu.ID).Find(&links).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, link := range links {
|
||||
for _, api := range apis {
|
||||
method := strings.ToUpper(api.Method)
|
||||
exists, err := policyExists(tx, link.SysAuthorityAuthorityID, api.Path, method)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
rule := newPolicyRule(link.SysAuthorityAuthorityID, api.Path, method)
|
||||
if err := tx.Create(&rule).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -25,16 +25,16 @@ func TestCatalogContainsBuiltInModulesInDependencyOrder(t *testing.T) {
|
||||
t.Fatalf("definition[%d] = %q, want %q", index, got, name)
|
||||
}
|
||||
}
|
||||
if got := catalog.MigrationSteps(); len(got) != 8 {
|
||||
t.Fatalf("module migrations = %d, want 8", len(got))
|
||||
if got := catalog.MigrationSteps(); len(got) != 11 {
|
||||
t.Fatalf("module migrations = %d, want 11", len(got))
|
||||
}
|
||||
var menus, apis int
|
||||
for _, definition := range catalog.Definitions {
|
||||
menus += len(definition.Surface.Menus)
|
||||
apis += len(definition.Surface.APIs)
|
||||
}
|
||||
if menus != 3 || apis != 15 {
|
||||
t.Fatalf("admin surface = %d menus/%d APIs, want 3/15", menus, apis)
|
||||
if menus != 3 || apis != 17 {
|
||||
t.Fatalf("admin surface = %d menus/%d APIs, want 3/17", menus, apis)
|
||||
}
|
||||
if got := catalog.DefaultTimedTasks(); len(got) != 2 {
|
||||
t.Fatalf("default timed tasks = %d, want 2", len(got))
|
||||
|
||||
@@ -4,21 +4,60 @@ package payment
|
||||
|
||||
import (
|
||||
datapayment "kra/internal/data/payment"
|
||||
datasystem "kra/internal/data/system"
|
||||
"kra/internal/modules/surface"
|
||||
"kra/pkg/database/migration"
|
||||
"kra/pkg/module"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Definition declares the payment-owned schema and administration surface.
|
||||
func Definition() module.Definition {
|
||||
menus := []module.Menu{
|
||||
{Name: "paymentOrders", Path: "paymentOrders", ParentName: "extensions", Component: "view/systemTools/payment/orders.vue", Title: "支付订单", Icon: "wallet", Sort: 6},
|
||||
{Name: "paymentConfig", Path: "paymentConfig", ParentName: "extensions", Component: "view/systemTools/payment/config.vue", Title: "支付配置", Icon: "credit-card", Sort: 7},
|
||||
}
|
||||
adminSurface := module.Surface{
|
||||
Menus: menus,
|
||||
APIs: surface.APIsForPrefix("/payment", 12),
|
||||
}
|
||||
// Keep the contents of shipped migrations immutable. Future payment routes
|
||||
// belong in a new migration instead of silently changing this snapshot.
|
||||
upgradeSurface := module.Surface{Menus: menus, APIs: []module.API{
|
||||
{Path: "/payment/orders", Method: "GET", Group: "支付", Description: "分页查询支付订单"},
|
||||
{Path: "/payment/orders/summary", Method: "GET", Group: "支付", Description: "汇总支付订单运营指标"},
|
||||
{Path: "/payment/orders/:provider/:tradeNo", Method: "GET", Group: "支付", Description: "按路径查询支付订单"},
|
||||
{Path: "/payment/orders/:provider/:tradeNo/events", Method: "GET", Group: "支付", Description: "查询支付订单事件流水"},
|
||||
{Path: "/payment/create", Method: "POST", Group: "支付", Description: "创建支付订单"},
|
||||
{Path: "/payment/fulfill", Method: "POST", Group: "支付", Description: "重试支付订单发货"},
|
||||
{Path: "/payment/order", Method: "POST", Group: "支付", Description: "查询支付订单"},
|
||||
{Path: "/payment/orders/:provider/:tradeNo/fulfill", Method: "POST", Group: "支付", Description: "按路径重试支付订单发货"},
|
||||
{Path: "/payment/orders/:provider/:tradeNo/refund", Method: "POST", Group: "支付", Description: "按路径申请支付订单退款"},
|
||||
{Path: "/payment/providers/:provider/test", Method: "POST", Group: "支付", Description: "测试支付渠道配置与沙箱交易链路"},
|
||||
{Path: "/payment/query", Method: "POST", Group: "支付", Description: "同步支付订单状态"},
|
||||
{Path: "/payment/refund", Method: "POST", Group: "支付", Description: "申请支付订单退款"},
|
||||
}}
|
||||
orderReadAPIs := make([]module.API, 0, 4)
|
||||
for _, api := range upgradeSurface.APIs {
|
||||
if api.Method == "GET" && (api.Path == "/payment/orders" || api.Path == "/payment/orders/summary" || api.Path == "/payment/orders/:provider/:tradeNo" || api.Path == "/payment/orders/:provider/:tradeNo/events") {
|
||||
orderReadAPIs = append(orderReadAPIs, api)
|
||||
}
|
||||
}
|
||||
migrations := append(datapayment.Migrations(), migration.Step{ID: "202608290002_payment_admin_surface", Migrate: func(db *gorm.DB) error {
|
||||
if err := datasystem.EnsureAdminSurface(db, upgradeSurface, 888); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := datasystem.InheritAPIPolicy(db, "/payment/orders", "GET", "/payment/orders/summary", "GET"); err != nil {
|
||||
return err
|
||||
}
|
||||
return datasystem.InheritAPIPolicy(db, "/payment/orders/:provider/:tradeNo", "GET", "/payment/orders/:provider/:tradeNo/events", "GET")
|
||||
}}, migration.Step{ID: "202608290003_payment_order_read_permissions", Migrate: func(db *gorm.DB) error {
|
||||
return datasystem.GrantMenuAPIs(db, "paymentOrders", orderReadAPIs)
|
||||
}})
|
||||
return module.Definition{
|
||||
Name: "payment",
|
||||
Migrations: datapayment.Migrations(),
|
||||
Surface: module.Surface{
|
||||
Menus: []module.Menu{
|
||||
{Name: "paymentOrders", Path: "paymentOrders", ParentName: "extensions", Component: "view/systemTools/payment/orders.vue", Title: "支付订单", Icon: "wallet", Sort: 6},
|
||||
{Name: "paymentConfig", Path: "paymentConfig", ParentName: "extensions", Component: "view/systemTools/payment/config.vue", Title: "支付配置", Icon: "credit-card", Sort: 7},
|
||||
},
|
||||
APIs: surface.APIsForPrefix("/payment", 10),
|
||||
},
|
||||
Migrations: migrations,
|
||||
Surface: adminSurface,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,10 @@ func TestDefinitionOwnsPaymentContributions(t *testing.T) {
|
||||
if definition.Name != "payment" {
|
||||
t.Fatalf("name = %q, want payment", definition.Name)
|
||||
}
|
||||
if len(definition.Migrations) != 1 {
|
||||
t.Fatalf("migrations = %d, want 1", len(definition.Migrations))
|
||||
if len(definition.Migrations) != 4 {
|
||||
t.Fatalf("migrations = %d, want 4", len(definition.Migrations))
|
||||
}
|
||||
if len(definition.Surface.Menus) != 2 || len(definition.Surface.APIs) != 10 {
|
||||
t.Fatalf("surface = %d menus/%d APIs, want 2/10", len(definition.Surface.Menus), len(definition.Surface.APIs))
|
||||
if len(definition.Surface.Menus) != 2 || len(definition.Surface.APIs) != 12 {
|
||||
t.Fatalf("surface = %d menus/%d APIs, want 2/12", len(definition.Surface.Menus), len(definition.Surface.APIs))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +86,9 @@ var routes = map[string]routeValue{
|
||||
"GET /logViewer/files": {group: "文件日志", description: "获取日期下的日志文件"},
|
||||
"GET /menu/getMenuRoles": {group: "菜单", description: "获取菜单关联角色列表"},
|
||||
"GET /payment/orders": {group: "支付", description: "分页查询支付订单"},
|
||||
"GET /payment/orders/summary": {group: "支付", description: "汇总支付订单运营指标"},
|
||||
"GET /payment/orders/:provider/:tradeNo": {group: "支付", description: "按路径查询支付订单"},
|
||||
"GET /payment/orders/:provider/:tradeNo/events": {group: "支付", description: "查询支付订单事件流水"},
|
||||
"GET /position/findPosition": {group: "岗位", description: "根据ID获取岗位"},
|
||||
"GET /position/getPositionUsers": {group: "岗位", description: "获取岗位成员ID列表"},
|
||||
"GET /securityConfig/getSecurityConfig": {group: "安全配置", description: "获取安全配置"},
|
||||
|
||||
@@ -97,7 +97,7 @@ func TestGinStartupLogsEveryRegisteredRoute(t *testing.T) {
|
||||
if got, want := strings.Count(text, `"msg":"router registered"`), len(engine.Routes()); got != want {
|
||||
t.Fatalf("registered route log count = %d, want %d", got, want)
|
||||
}
|
||||
if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":194`) {
|
||||
if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":196`) {
|
||||
t.Fatalf("startup route summary is missing: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,15 +39,16 @@ func (h *Payment) OrderByPath(c *gin.Context) {
|
||||
|
||||
func (h *Payment) RefundByPath(c *gin.Context) {
|
||||
var req struct {
|
||||
Amount int64 `json:"amount"`
|
||||
Amount int64 `json:"amount"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
Fail(c, err.Error())
|
||||
return
|
||||
}
|
||||
result, err := h.service.Refund(c.Request.Context(), &dto.PaymentRefundRequest{
|
||||
Provider: c.Param("provider"), TradeNo: c.Param("tradeNo"), Amount: req.Amount,
|
||||
})
|
||||
request := &dto.PaymentRefundRequest{Provider: c.Param("provider"), TradeNo: c.Param("tradeNo"), Amount: req.Amount, Reason: req.Reason}
|
||||
setPaymentRequestActor(c, &request.OperatorID, &request.OperatorName, &request.ClientIP, &request.UserAgent, &request.DeviceID)
|
||||
result, err := h.service.Refund(c.Request.Context(), request)
|
||||
if err != nil {
|
||||
Fail(c, err.Error())
|
||||
return
|
||||
@@ -56,9 +57,9 @@ func (h *Payment) RefundByPath(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *Payment) FulfillByPath(c *gin.Context) {
|
||||
result, err := h.service.Fulfill(c.Request.Context(), &dto.PaymentFulfillRequest{
|
||||
Provider: c.Param("provider"), TradeNo: c.Param("tradeNo"),
|
||||
})
|
||||
request := &dto.PaymentFulfillRequest{Provider: c.Param("provider"), TradeNo: c.Param("tradeNo")}
|
||||
setPaymentRequestActor(c, &request.OperatorID, &request.OperatorName, &request.ClientIP, &request.UserAgent, &request.DeviceID)
|
||||
result, err := h.service.Fulfill(c.Request.Context(), request)
|
||||
if err != nil {
|
||||
Fail(c, err.Error())
|
||||
return
|
||||
@@ -80,6 +81,36 @@ func (h *Payment) Orders(c *gin.Context) {
|
||||
Write(c, CodeSuccess, PageResult{List: items, Total: total, Page: page, PageSize: pageSize}, "获取成功")
|
||||
}
|
||||
|
||||
func (h *Payment) OrderSummary(c *gin.Context) {
|
||||
var req dto.PaymentOrderListRequest
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
Fail(c, err.Error())
|
||||
return
|
||||
}
|
||||
result, err := h.service.OrderSummary(c.Request.Context(), &req)
|
||||
if err != nil {
|
||||
Fail(c, err.Error())
|
||||
return
|
||||
}
|
||||
OKWithData(c, result)
|
||||
}
|
||||
|
||||
func (h *Payment) OrderEvents(c *gin.Context) {
|
||||
var req struct {
|
||||
Limit int `form:"limit"`
|
||||
}
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
Fail(c, err.Error())
|
||||
return
|
||||
}
|
||||
items, err := h.service.OrderEvents(c.Request.Context(), c.Param("provider"), c.Param("tradeNo"), req.Limit)
|
||||
if err != nil {
|
||||
Fail(c, err.Error())
|
||||
return
|
||||
}
|
||||
OKWithData(c, items)
|
||||
}
|
||||
|
||||
func (h *Payment) TestProvider(c *gin.Context) {
|
||||
result, err := h.service.TestProvider(c.Request.Context(), c.Param("provider"))
|
||||
if err != nil {
|
||||
@@ -99,6 +130,8 @@ func (h *Payment) Create(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
req.ClientIP = c.ClientIP()
|
||||
req.UserAgent = c.Request.UserAgent()
|
||||
req.DeviceID = c.GetHeader("X-Device-Id")
|
||||
result, err := h.service.Create(c.Request.Context(), &req)
|
||||
if err != nil {
|
||||
Fail(c, err.Error())
|
||||
@@ -112,6 +145,7 @@ func (h *Payment) Query(c *gin.Context) {
|
||||
Fail(c, err.Error())
|
||||
return
|
||||
}
|
||||
setPaymentRequestActor(c, &req.OperatorID, &req.OperatorName, &req.ClientIP, &req.UserAgent, &req.DeviceID)
|
||||
result, err := h.service.Query(c.Request.Context(), &req)
|
||||
if err != nil {
|
||||
Fail(c, err.Error())
|
||||
@@ -125,6 +159,7 @@ func (h *Payment) Refund(c *gin.Context) {
|
||||
Fail(c, err.Error())
|
||||
return
|
||||
}
|
||||
setPaymentRequestActor(c, &req.OperatorID, &req.OperatorName, &req.ClientIP, &req.UserAgent, &req.DeviceID)
|
||||
result, err := h.service.Refund(c.Request.Context(), &req)
|
||||
if err != nil {
|
||||
Fail(c, err.Error())
|
||||
@@ -139,6 +174,7 @@ func (h *Payment) Fulfill(c *gin.Context) {
|
||||
Fail(c, err.Error())
|
||||
return
|
||||
}
|
||||
setPaymentRequestActor(c, &req.OperatorID, &req.OperatorName, &req.ClientIP, &req.UserAgent, &req.DeviceID)
|
||||
result, err := h.service.Fulfill(c.Request.Context(), &req)
|
||||
if err != nil {
|
||||
Fail(c, err.Error())
|
||||
@@ -166,13 +202,26 @@ func (h *Payment) Callback(c *gin.Context) {
|
||||
query[k] = v[0]
|
||||
}
|
||||
}
|
||||
ack, err := h.service.Callback(c.Request.Context(), &dto.PaymentCallbackRequest{Provider: c.Param("provider"), Headers: headers, Body: raw, Query: query})
|
||||
ack, err := h.service.Callback(c.Request.Context(), &dto.PaymentCallbackRequest{
|
||||
Provider: c.Param("provider"), Headers: headers, Body: raw, Query: query,
|
||||
ClientIP: c.ClientIP(), UserAgent: c.Request.UserAgent(),
|
||||
})
|
||||
writePaymentCallbackAck(c, ack)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
func setPaymentRequestActor(c *gin.Context, operatorID *uint, operatorName, clientIP, userAgent, deviceID *string) {
|
||||
if claims := Claims(c); claims != nil {
|
||||
*operatorID = claims.ID
|
||||
*operatorName = claims.Username
|
||||
}
|
||||
*clientIP = c.ClientIP()
|
||||
*userAgent = c.Request.UserAgent()
|
||||
*deviceID = c.GetHeader("X-Device-Id")
|
||||
}
|
||||
|
||||
func writePaymentCallbackAck(c *gin.Context, ack dto.PaymentCallbackAck) {
|
||||
if ack.StatusCode == 0 {
|
||||
ack.StatusCode = http.StatusInternalServerError
|
||||
|
||||
@@ -8,9 +8,11 @@ import (
|
||||
func RegisterPayment(group, public *gin.RouterGroup, h *handler.Payment) {
|
||||
payment := group.Group("/payment")
|
||||
payment.GET("/orders", h.Orders)
|
||||
payment.GET("/orders/summary", h.OrderSummary)
|
||||
payment.POST("/providers/:provider/test", h.TestProvider)
|
||||
payment.POST("/order", h.Order)
|
||||
payment.GET("/orders/:provider/:tradeNo", h.OrderByPath)
|
||||
payment.GET("/orders/:provider/:tradeNo/events", h.OrderEvents)
|
||||
payment.POST("/create", h.Create)
|
||||
payment.POST("/query", h.Query)
|
||||
payment.POST("/refund", h.Refund)
|
||||
|
||||
+118
-53
@@ -6,14 +6,21 @@ import (
|
||||
)
|
||||
|
||||
type PaymentOrderListRequest struct {
|
||||
Page int `form:"page"`
|
||||
PageSize int `form:"pageSize"`
|
||||
Provider string `form:"provider"`
|
||||
TradeNo string `form:"tradeNo"`
|
||||
BusinessType string `form:"businessType"`
|
||||
BusinessID string `form:"businessId"`
|
||||
PaymentStatus string `form:"paymentStatus"`
|
||||
RefundStatus string `form:"refundStatus"`
|
||||
Page int `form:"page"`
|
||||
PageSize int `form:"pageSize"`
|
||||
Provider string `form:"provider"`
|
||||
TradeNo string `form:"tradeNo"`
|
||||
ProviderTradeNo string `form:"providerTradeNo"`
|
||||
Keyword string `form:"keyword"`
|
||||
BusinessType string `form:"businessType"`
|
||||
BusinessID string `form:"businessId"`
|
||||
PaymentStatus string `form:"paymentStatus"`
|
||||
RefundStatus string `form:"refundStatus"`
|
||||
CreatedFrom string `form:"createdFrom"`
|
||||
CreatedTo string `form:"createdTo"`
|
||||
PaidFrom string `form:"paidFrom"`
|
||||
PaidTo string `form:"paidTo"`
|
||||
IssueOnly bool `form:"issueOnly"`
|
||||
}
|
||||
|
||||
type PaymentRequest struct {
|
||||
@@ -30,29 +37,49 @@ type PaymentRequest struct {
|
||||
BusinessType string `json:"businessType"`
|
||||
BusinessID string `json:"businessId"`
|
||||
PaymentMode string `json:"paymentMode"`
|
||||
UserAgent string `json:"-"`
|
||||
DeviceID string `json:"-"`
|
||||
}
|
||||
type PaymentQueryRequest struct {
|
||||
Provider string `json:"provider"`
|
||||
TradeNo string `json:"tradeNo"`
|
||||
Provider string `json:"provider"`
|
||||
TradeNo string `json:"tradeNo"`
|
||||
OperatorID uint `json:"-"`
|
||||
OperatorName string `json:"-"`
|
||||
ClientIP string `json:"-"`
|
||||
UserAgent string `json:"-"`
|
||||
DeviceID string `json:"-"`
|
||||
}
|
||||
type PaymentRefundRequest struct {
|
||||
Provider string `json:"provider"`
|
||||
TradeNo string `json:"tradeNo"`
|
||||
Amount int64 `json:"amount"`
|
||||
Provider string `json:"provider"`
|
||||
TradeNo string `json:"tradeNo"`
|
||||
Amount int64 `json:"amount"`
|
||||
Reason string `json:"reason"`
|
||||
OperatorID uint `json:"-"`
|
||||
OperatorName string `json:"-"`
|
||||
ClientIP string `json:"-"`
|
||||
UserAgent string `json:"-"`
|
||||
DeviceID string `json:"-"`
|
||||
}
|
||||
|
||||
type PaymentFulfillRequest struct {
|
||||
Provider string `json:"provider"`
|
||||
TradeNo string `json:"tradeNo"`
|
||||
Provider string `json:"provider"`
|
||||
TradeNo string `json:"tradeNo"`
|
||||
OperatorID uint `json:"-"`
|
||||
OperatorName string `json:"-"`
|
||||
ClientIP string `json:"-"`
|
||||
UserAgent string `json:"-"`
|
||||
DeviceID string `json:"-"`
|
||||
}
|
||||
|
||||
// PaymentCallbackRequest is populated by the HTTP transport and converted to
|
||||
// the biz callback object inside service.
|
||||
type PaymentCallbackRequest struct {
|
||||
Provider string
|
||||
Headers map[string]string
|
||||
Body []byte
|
||||
Query map[string]string
|
||||
Provider string
|
||||
Headers map[string]string
|
||||
Body []byte
|
||||
Query map[string]string
|
||||
ClientIP string
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
type PaymentCallbackAck struct {
|
||||
@@ -103,38 +130,76 @@ type PaymentTestResult struct {
|
||||
}
|
||||
|
||||
type PaymentOrderResponse struct {
|
||||
ID uint64 `json:"ID"`
|
||||
Provider string `json:"provider"`
|
||||
TradeNo string `json:"tradeNo"`
|
||||
ProviderTradeNo string `json:"providerTradeNo"`
|
||||
BusinessType string `json:"businessType"`
|
||||
BusinessID string `json:"businessId"`
|
||||
Subject string `json:"subject"`
|
||||
PaymentMode string `json:"paymentMode"`
|
||||
OriginalAmount int64 `json:"originalAmount"`
|
||||
Amount int64 `json:"amount"`
|
||||
PaidAmount int64 `json:"paidAmount"`
|
||||
PayerPaidAmount int64 `json:"payerPaidAmount"`
|
||||
CashPaidAmount int64 `json:"cashPaidAmount"`
|
||||
PointPaidAmount int64 `json:"pointPaidAmount"`
|
||||
DiscountAmount int64 `json:"discountAmount"`
|
||||
ProviderDiscountAmount int64 `json:"providerDiscountAmount"`
|
||||
MerchantDiscountAmount int64 `json:"merchantDiscountAmount"`
|
||||
SettlementAmount int64 `json:"settlementAmount"`
|
||||
Currency string `json:"currency"`
|
||||
PayerCurrency string `json:"payerCurrency,omitempty"`
|
||||
AmountBreakdownKnown bool `json:"amountBreakdownKnown"`
|
||||
PaymentStatus string `json:"paymentStatus"`
|
||||
ProviderStatus string `json:"providerStatus"`
|
||||
FulfillmentStatus string `json:"fulfillmentStatus"`
|
||||
RefundStatus string `json:"refundStatus"`
|
||||
RefundedAmount int64 `json:"refundedAmount"`
|
||||
RefundRequestedAmount int64 `json:"refundRequestedAmount"`
|
||||
RefundNo string `json:"refundNo,omitempty"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
PaidAt *time.Time `json:"paidAt,omitempty"`
|
||||
FulfilledAt *time.Time `json:"fulfilledAt,omitempty"`
|
||||
RefundedAt *time.Time `json:"refundedAt,omitempty"`
|
||||
ID uint64 `json:"ID"`
|
||||
Provider string `json:"provider"`
|
||||
TradeNo string `json:"tradeNo"`
|
||||
ProviderTradeNo string `json:"providerTradeNo"`
|
||||
BusinessType string `json:"businessType"`
|
||||
BusinessID string `json:"businessId"`
|
||||
Subject string `json:"subject"`
|
||||
PaymentMode string `json:"paymentMode"`
|
||||
OriginalAmount int64 `json:"originalAmount"`
|
||||
Amount int64 `json:"amount"`
|
||||
PaidAmount int64 `json:"paidAmount"`
|
||||
PayerPaidAmount int64 `json:"payerPaidAmount"`
|
||||
CashPaidAmount int64 `json:"cashPaidAmount"`
|
||||
PointPaidAmount int64 `json:"pointPaidAmount"`
|
||||
DiscountAmount int64 `json:"discountAmount"`
|
||||
ProviderDiscountAmount int64 `json:"providerDiscountAmount"`
|
||||
MerchantDiscountAmount int64 `json:"merchantDiscountAmount"`
|
||||
SettlementAmount int64 `json:"settlementAmount"`
|
||||
Currency string `json:"currency"`
|
||||
PayerCurrency string `json:"payerCurrency,omitempty"`
|
||||
AmountBreakdownKnown bool `json:"amountBreakdownKnown"`
|
||||
PaymentStatus string `json:"paymentStatus"`
|
||||
ProviderStatus string `json:"providerStatus"`
|
||||
FulfillmentStatus string `json:"fulfillmentStatus"`
|
||||
RefundStatus string `json:"refundStatus"`
|
||||
RefundedAmount int64 `json:"refundedAmount"`
|
||||
RefundRequestedAmount int64 `json:"refundRequestedAmount"`
|
||||
RefundNo string `json:"refundNo,omitempty"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
PaidAt *time.Time `json:"paidAt,omitempty"`
|
||||
FulfilledAt *time.Time `json:"fulfilledAt,omitempty"`
|
||||
RefundedAt *time.Time `json:"refundedAt,omitempty"`
|
||||
Events []*PaymentEventResponse `json:"events,omitempty"`
|
||||
}
|
||||
|
||||
type PaymentEventResponse struct {
|
||||
ID uint64 `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Source string `json:"source"`
|
||||
Status string `json:"status"`
|
||||
ProviderStatus string `json:"providerStatus,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
EventID string `json:"eventId,omitempty"`
|
||||
PayloadHash string `json:"payloadHash,omitempty"`
|
||||
Amount int64 `json:"amount,omitempty"`
|
||||
Currency string `json:"currency,omitempty"`
|
||||
OperatorID uint `json:"operatorId,omitempty"`
|
||||
OperatorName string `json:"operatorName,omitempty"`
|
||||
ClientIP string `json:"clientIp,omitempty"`
|
||||
UserAgent string `json:"userAgent,omitempty"`
|
||||
DeviceID string `json:"deviceId,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type PaymentCurrencySummaryResponse struct {
|
||||
Currency string `json:"currency"`
|
||||
OrderAmount int64 `json:"orderAmount"`
|
||||
PaidAmount int64 `json:"paidAmount"`
|
||||
RefundedAmount int64 `json:"refundedAmount"`
|
||||
SettlementAmount int64 `json:"settlementAmount"`
|
||||
NetAmount int64 `json:"netAmount"`
|
||||
}
|
||||
|
||||
type PaymentOrderSummaryResponse struct {
|
||||
OrderCount int64 `json:"orderCount"`
|
||||
PendingCount int64 `json:"pendingCount"`
|
||||
PaidCount int64 `json:"paidCount"`
|
||||
RefundedCount int64 `json:"refundedCount"`
|
||||
IssueCount int64 `json:"issueCount"`
|
||||
Currencies []*PaymentCurrencySummaryResponse `json:"currencies"`
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
paymentbiz "kra/internal/biz/payment"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"kra/internal/service/dto"
|
||||
)
|
||||
@@ -16,7 +18,16 @@ func (s *PaymentService) Order(ctx context.Context, provider, tradeNo string) (*
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return paymentOrderResponse(order)
|
||||
response, err := paymentOrderResponse(order)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
events, err := s.uc.OrderEvents(ctx, provider, tradeNo, 100)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response.Events = paymentEventResponses(events)
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *PaymentService) Orders(ctx context.Context, req *dto.PaymentOrderListRequest) ([]*dto.PaymentOrderResponse, int64, int, int, error) {
|
||||
@@ -30,10 +41,11 @@ func (s *PaymentService) Orders(ctx context.Context, req *dto.PaymentOrderListRe
|
||||
if pageSize <= 0 {
|
||||
pageSize = 10
|
||||
}
|
||||
items, total, err := s.uc.Orders(ctx, page, pageSize, paymentbiz.PaymentOrderFilter{
|
||||
Provider: req.Provider, TradeNo: req.TradeNo, BusinessType: req.BusinessType,
|
||||
BusinessID: req.BusinessID, PaymentStatus: req.PaymentStatus, RefundStatus: req.RefundStatus,
|
||||
})
|
||||
filter, err := paymentOrderFilter(req)
|
||||
if err != nil {
|
||||
return nil, 0, page, pageSize, err
|
||||
}
|
||||
items, total, err := s.uc.Orders(ctx, page, pageSize, filter)
|
||||
if err != nil {
|
||||
return nil, 0, page, pageSize, err
|
||||
}
|
||||
@@ -48,6 +60,113 @@ func (s *PaymentService) Orders(ctx context.Context, req *dto.PaymentOrderListRe
|
||||
return result, total, page, pageSize, nil
|
||||
}
|
||||
|
||||
func (s *PaymentService) OrderSummary(ctx context.Context, req *dto.PaymentOrderListRequest) (*dto.PaymentOrderSummaryResponse, error) {
|
||||
filter, err := paymentOrderFilter(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
summary, err := s.uc.OrderSummary(ctx, filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response := &dto.PaymentOrderSummaryResponse{
|
||||
OrderCount: summary.OrderCount, PendingCount: summary.PendingCount, PaidCount: summary.PaidCount,
|
||||
RefundedCount: summary.RefundedCount, IssueCount: summary.IssueCount,
|
||||
Currencies: make([]*dto.PaymentCurrencySummaryResponse, 0, len(summary.Currencies)),
|
||||
}
|
||||
for _, item := range summary.Currencies {
|
||||
response.Currencies = append(response.Currencies, &dto.PaymentCurrencySummaryResponse{
|
||||
Currency: item.Currency, OrderAmount: item.OrderAmount, PaidAmount: item.PaidAmount,
|
||||
RefundedAmount: item.RefundedAmount, SettlementAmount: item.SettlementAmount, NetAmount: item.NetAmount,
|
||||
})
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *PaymentService) OrderEvents(ctx context.Context, provider, tradeNo string, limit int) ([]*dto.PaymentEventResponse, error) {
|
||||
events, err := s.uc.OrderEvents(ctx, provider, tradeNo, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return paymentEventResponses(events), nil
|
||||
}
|
||||
|
||||
func paymentOrderFilter(req *dto.PaymentOrderListRequest) (paymentbiz.PaymentOrderFilter, error) {
|
||||
if req == nil {
|
||||
return paymentbiz.PaymentOrderFilter{}, errors.New("支付订单筛选请求为空")
|
||||
}
|
||||
createdFrom, err := parsePaymentFilterTime(req.CreatedFrom, false)
|
||||
if err != nil {
|
||||
return paymentbiz.PaymentOrderFilter{}, err
|
||||
}
|
||||
createdTo, err := parsePaymentFilterTime(req.CreatedTo, true)
|
||||
if err != nil {
|
||||
return paymentbiz.PaymentOrderFilter{}, err
|
||||
}
|
||||
paidFrom, err := parsePaymentFilterTime(req.PaidFrom, false)
|
||||
if err != nil {
|
||||
return paymentbiz.PaymentOrderFilter{}, err
|
||||
}
|
||||
paidTo, err := parsePaymentFilterTime(req.PaidTo, true)
|
||||
if err != nil {
|
||||
return paymentbiz.PaymentOrderFilter{}, err
|
||||
}
|
||||
if createdFrom != nil && createdTo != nil && !createdFrom.Before(*createdTo) {
|
||||
return paymentbiz.PaymentOrderFilter{}, errors.New("订单创建时间范围无效")
|
||||
}
|
||||
if paidFrom != nil && paidTo != nil && !paidFrom.Before(*paidTo) {
|
||||
return paymentbiz.PaymentOrderFilter{}, errors.New("支付完成时间范围无效")
|
||||
}
|
||||
return paymentbiz.PaymentOrderFilter{
|
||||
Provider: req.Provider, TradeNo: req.TradeNo, ProviderTradeNo: req.ProviderTradeNo, Keyword: req.Keyword,
|
||||
BusinessType: req.BusinessType, BusinessID: req.BusinessID, PaymentStatus: req.PaymentStatus,
|
||||
RefundStatus: req.RefundStatus, CreatedFrom: createdFrom, CreatedTo: createdTo,
|
||||
PaidFrom: paidFrom, PaidTo: paidTo, IssueOnly: req.IssueOnly,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parsePaymentFilterTime(value string, endOfDate bool) (*time.Time, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if parsed, err := time.Parse(time.RFC3339, value); err == nil {
|
||||
return &parsed, nil
|
||||
}
|
||||
parsed, err := time.ParseInLocation("2006-01-02", value, time.Local)
|
||||
if err != nil {
|
||||
return nil, errors.New("支付订单时间格式无效")
|
||||
}
|
||||
if endOfDate {
|
||||
parsed = parsed.AddDate(0, 0, 1)
|
||||
}
|
||||
return &parsed, nil
|
||||
}
|
||||
|
||||
func paymentEventResponses(events []*paymentbiz.PaymentEvent) []*dto.PaymentEventResponse {
|
||||
result := make([]*dto.PaymentEventResponse, 0, len(events))
|
||||
for _, event := range events {
|
||||
if event == nil {
|
||||
continue
|
||||
}
|
||||
result = append(result, &dto.PaymentEventResponse{
|
||||
ID: event.ID, Type: event.Type, Source: event.Source, Status: event.Status,
|
||||
ProviderStatus: event.ProviderStatus, Message: event.Message, EventID: event.EventID,
|
||||
PayloadHash: event.PayloadHash, Amount: event.Amount, Currency: event.Currency,
|
||||
OperatorID: event.OperatorID, OperatorName: event.OperatorName, ClientIP: event.ClientIP,
|
||||
UserAgent: event.UserAgent, DeviceID: event.DeviceID, CreatedAt: event.CreatedAt,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func operationContext(ctx context.Context, source, reason string, operatorID uint, operatorName, clientIP, userAgent, deviceID string) context.Context {
|
||||
return paymentbiz.WithPaymentOperation(ctx, paymentbiz.PaymentOperation{
|
||||
Source: source, Reason: reason, OperatorID: operatorID, OperatorName: operatorName,
|
||||
ClientIP: clientIP, UserAgent: userAgent, DeviceID: deviceID,
|
||||
})
|
||||
}
|
||||
|
||||
func paymentOrderResponse(order *paymentbiz.PaymentOrder) (*dto.PaymentOrderResponse, error) {
|
||||
if order == nil {
|
||||
return nil, errors.New("支付订单为空")
|
||||
@@ -75,7 +194,7 @@ func (s *PaymentService) Create(ctx context.Context, req *dto.PaymentRequest) (*
|
||||
Amount: req.Amount, OriginalAmount: req.OriginalAmount, Currency: req.Currency,
|
||||
NotifyURL: req.NotifyURL, ReturnURL: req.ReturnURL, ClientIP: req.ClientIP,
|
||||
Extra: req.Extra, BusinessType: req.BusinessType, BusinessID: req.BusinessID,
|
||||
PaymentMode: req.PaymentMode,
|
||||
PaymentMode: req.PaymentMode, UserAgent: req.UserAgent, DeviceID: req.DeviceID,
|
||||
})
|
||||
return paymentResultResponse(result), err
|
||||
}
|
||||
@@ -83,6 +202,7 @@ func (s *PaymentService) Query(ctx context.Context, req *dto.PaymentQueryRequest
|
||||
if req == nil {
|
||||
return nil, errors.New("支付查询请求为空")
|
||||
}
|
||||
ctx = operationContext(ctx, "admin", "主动同步支付平台状态", req.OperatorID, req.OperatorName, req.ClientIP, req.UserAgent, req.DeviceID)
|
||||
result, err := s.uc.Query(ctx, req.Provider, req.TradeNo)
|
||||
return paymentResultResponse(result), err
|
||||
}
|
||||
@@ -90,6 +210,11 @@ func (s *PaymentService) Refund(ctx context.Context, req *dto.PaymentRefundReque
|
||||
if req == nil {
|
||||
return nil, errors.New("支付退款请求为空")
|
||||
}
|
||||
reason := strings.TrimSpace(req.Reason)
|
||||
if reason == "" {
|
||||
reason = "未填写退款原因"
|
||||
}
|
||||
ctx = operationContext(ctx, "admin", reason, req.OperatorID, req.OperatorName, req.ClientIP, req.UserAgent, req.DeviceID)
|
||||
result, err := s.uc.Refund(ctx, req.Provider, req.TradeNo, req.Amount)
|
||||
return paymentResultResponse(result), err
|
||||
}
|
||||
@@ -111,6 +236,7 @@ func (s *PaymentService) Fulfill(ctx context.Context, req *dto.PaymentFulfillReq
|
||||
if req == nil {
|
||||
return nil, errors.New("支付发货请求为空")
|
||||
}
|
||||
ctx = operationContext(ctx, "admin", "人工执行或重试业务发货", req.OperatorID, req.OperatorName, req.ClientIP, req.UserAgent, req.DeviceID)
|
||||
result, err := s.uc.Fulfill(ctx, req.Provider, req.TradeNo)
|
||||
return paymentResultResponse(result), err
|
||||
}
|
||||
@@ -118,6 +244,7 @@ func (s *PaymentService) Callback(ctx context.Context, req *dto.PaymentCallbackR
|
||||
if req == nil {
|
||||
return paymentCallbackAckResponse(paymentbiz.DefaultPaymentCallbackAck("", false)), errors.New("支付回调请求为空")
|
||||
}
|
||||
ctx = operationContext(ctx, req.Provider, "支付平台异步通知", 0, "", req.ClientIP, req.UserAgent, "")
|
||||
result, err := s.uc.Callback(ctx, &paymentbiz.PaymentCallback{Provider: req.Provider, Headers: req.Headers, Body: req.Body, Query: req.Query})
|
||||
if err != nil {
|
||||
return paymentCallbackAckResponse(paymentbiz.CallbackFailure(err, paymentbiz.DefaultPaymentCallbackAck(req.Provider, false))), err
|
||||
|
||||
Reference in New Issue
Block a user