修改支付相关

This commit is contained in:
Yvan
2026-09-24 17:44:17 +08:00
parent 5c1da5bf55
commit a16b8f8e89
6 changed files with 270 additions and 15 deletions
+88 -13
View File
@@ -435,7 +435,14 @@ func (uc *PaymentUsecase) OrderEvents(ctx context.Context, provider, tradeNo str
return uc.orders.ListPaymentEvents(ctx, provider, tradeNo, limit) 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) (result *PaymentResult, err error) {
started := time.Now()
provider, tradeNo := "", ""
if req != nil {
provider, tradeNo = req.Provider, req.TradeNo
}
uc.logger.Info(ctx, "payment.create.started", "provider", provider, "trade_no", tradeNo)
defer func() { uc.logPaymentOperation(ctx, "create", started, provider, tradeNo, result, err) }()
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("支付参数不完整")
} }
@@ -450,6 +457,8 @@ func (uc *PaymentUsecase) Create(ctx context.Context, req *PaymentRequest) (*Pay
return nil, err return nil, err
} }
req = prepared req = prepared
uc.logger.Info(ctx, "payment.order.prepared", "provider", provider, "trade_no", tradeNo,
"business_type", req.BusinessType, "amount", req.Amount, "currency", req.Currency)
if !validPaymentText(req.Currency, 16) || !validPaymentText(req.Subject, 256) || req.Amount <= 0 { if !validPaymentText(req.Currency, 16) || !validPaymentText(req.Subject, 256) || req.Amount <= 0 {
return nil, errors.New("支付订单金额、币种或标题无效") return nil, errors.New("支付订单金额、币种或标题无效")
} }
@@ -542,7 +551,10 @@ func IsDemoPaymentOrder(order *PaymentOrder) bool {
return order != nil && order.BusinessType == DemoBusinessType && strings.HasPrefix(strings.TrimSpace(order.TradeNo), "DEMO-PAY-") return order != nil && order.BusinessType == DemoBusinessType && strings.HasPrefix(strings.TrimSpace(order.TradeNo), "DEMO-PAY-")
} }
func (uc *PaymentUsecase) Query(ctx context.Context, provider, tradeNo string) (*PaymentResult, error) { func (uc *PaymentUsecase) Query(ctx context.Context, provider, tradeNo string) (result *PaymentResult, err error) {
started := time.Now()
uc.logger.Info(ctx, "payment.query.started", "provider", provider, "trade_no", tradeNo)
defer func() { uc.logPaymentOperation(ctx, "query", started, provider, tradeNo, result, err) }()
if !validPaymentText(provider, 64) || !validPaymentText(tradeNo, 128) { if !validPaymentText(provider, 64) || !validPaymentText(tradeNo, 128) {
return nil, errors.New("查询支付参数不完整") return nil, errors.New("查询支付参数不完整")
} }
@@ -568,7 +580,8 @@ func (uc *PaymentUsecase) Query(ctx context.Context, provider, tradeNo string) (
} }
queryID = order.ProviderTradeNo queryID = order.ProviderTradeNo
} }
result, err := uc.repo.Query(ctx, provider, queryID) uc.logger.Info(ctx, "payment.provider.query.started", "provider", provider, "trade_no", tradeNo)
result, err = uc.repo.Query(ctx, provider, queryID)
if err != nil { if err != nil {
uc.logger.Error(ctx, "payment.query.failed", "provider", provider, "trade_no", tradeNo, "query_id", queryID, "error", err) uc.logger.Error(ctx, "payment.query.failed", "provider", provider, "trade_no", tradeNo, "query_id", queryID, "error", err)
return nil, err return nil, err
@@ -580,10 +593,15 @@ func (uc *PaymentUsecase) Query(ctx context.Context, provider, tradeNo string) (
if result.TradeNo != tradeNo { if result.TradeNo != tradeNo {
return nil, errors.New("支付平台查单商户订单号不匹配") return nil, errors.New("支付平台查单商户订单号不匹配")
} }
uc.logger.Info(ctx, "payment.provider.query.checked", "provider", provider, "trade_no", tradeNo,
"provider_status", result.Status, "provider_trade_no", result.ProviderTradeNo)
return uc.reconcilePaymentResult(ctx, result, nil) return uc.reconcilePaymentResult(ctx, result, nil)
} }
func (uc *PaymentUsecase) Refund(ctx context.Context, provider, tradeNo string, amount int64) (*PaymentResult, error) { func (uc *PaymentUsecase) Refund(ctx context.Context, provider, tradeNo string, amount int64) (result *PaymentResult, err error) {
started := time.Now()
uc.logger.Info(ctx, "payment.refund.started", "provider", provider, "trade_no", tradeNo, "amount", amount)
defer func() { uc.logPaymentOperation(ctx, "refund", started, provider, tradeNo, result, err) }()
if !validPaymentText(provider, 64) || !validPaymentText(tradeNo, 128) || amount <= 0 { if !validPaymentText(provider, 64) || !validPaymentText(tradeNo, 128) || amount <= 0 {
return nil, errors.New("退款参数不完整") return nil, errors.New("退款参数不完整")
} }
@@ -603,8 +621,26 @@ func (uc *PaymentUsecase) Refund(ctx context.Context, provider, tradeNo string,
// TestProvider exercises the configured provider without requiring a business // TestProvider exercises the configured provider without requiring a business
// module or a real customer order. Provider adapters use their configured // module or a real customer order. Provider adapters use their configured
// sandbox/test endpoint when one is selected in integration settings. // sandbox/test endpoint when one is selected in integration settings.
func (uc *PaymentUsecase) TestProvider(ctx context.Context, provider string) (*PaymentTestResult, error) { func (uc *PaymentUsecase) TestProvider(ctx context.Context, provider string) (result *PaymentTestResult, err error) {
started := time.Now()
provider = strings.TrimSpace(provider) provider = strings.TrimSpace(provider)
uc.logger.Info(ctx, "payment.test.started", "provider", provider)
defer func() {
if result != nil {
for _, stage := range result.Stages {
uc.logger.Info(ctx, "payment.test.stage", "provider", provider, "trade_no", stage.TradeNo,
"stage", stage.Name, "status", stage.Status, "duration_ms", stage.Duration)
}
uc.logger.Info(ctx, "payment.test.summary", "provider", provider, "trade_no", result.TradeNo,
"passed", result.Passed, "full_flow", result.FullFlow)
}
var paymentResult *PaymentResult
tradeNo := ""
if result != nil {
paymentResult, tradeNo = result.Result, result.TradeNo
}
uc.logPaymentOperation(ctx, "test", started, provider, tradeNo, paymentResult, err)
}()
if !supportedPaymentProvider(provider) { if !supportedPaymentProvider(provider) {
return nil, errors.New("不支持的支付渠道") return nil, errors.New("不支持的支付渠道")
} }
@@ -618,7 +654,10 @@ func (uc *PaymentUsecase) TestProvider(ctx context.Context, provider string) (*P
// Fulfill retries delivery for an already-paid order. It is intentionally // Fulfill retries delivery for an already-paid order. It is intentionally
// idempotent: the repository lease prevents concurrent attempts and a // idempotent: the repository lease prevents concurrent attempts and a
// succeeded order is returned as a duplicate without invoking the handler. // succeeded order is returned as a duplicate without invoking the handler.
func (uc *PaymentUsecase) Fulfill(ctx context.Context, provider, tradeNo string) (*PaymentResult, error) { func (uc *PaymentUsecase) Fulfill(ctx context.Context, provider, tradeNo string) (result *PaymentResult, err error) {
started := time.Now()
uc.logger.Info(ctx, "payment.fulfill.started", "provider", provider, "trade_no", tradeNo)
defer func() { uc.logPaymentOperation(ctx, "fulfill", started, provider, tradeNo, result, err) }()
if !validPaymentText(provider, 64) || !validPaymentText(tradeNo, 128) { if !validPaymentText(provider, 64) || !validPaymentText(tradeNo, 128) {
return nil, errors.New("发货参数不完整") return nil, errors.New("发货参数不完整")
} }
@@ -632,7 +671,7 @@ func (uc *PaymentUsecase) Fulfill(ctx context.Context, provider, tradeNo string)
if IsDemoPaymentOrder(order) { if IsDemoPaymentOrder(order) {
return nil, errors.New("演示订单不执行真实发货") return nil, errors.New("演示订单不执行真实发货")
} }
result := paymentResultFromOrder(order) result = paymentResultFromOrder(order)
if result == nil { if result == nil {
return nil, ErrPaymentOrderNotFound return nil, ErrPaymentOrderNotFound
} }
@@ -645,7 +684,20 @@ func (uc *PaymentUsecase) Fulfill(ctx context.Context, provider, tradeNo string)
// Callback verifies the notification in the adapter, then always performs a // Callback verifies the notification in the adapter, then always performs a
// provider-side query before it can dispatch fulfillment. Callback payloads // provider-side query before it can dispatch fulfillment. Callback payloads
// are never trusted for amount, currency, or final payment state. // are never trusted for amount, currency, or final payment state.
func (uc *PaymentUsecase) Callback(ctx context.Context, callback *PaymentCallback) (*PaymentResult, error) { func (uc *PaymentUsecase) Callback(ctx context.Context, callback *PaymentCallback) (result *PaymentResult, err error) {
started := time.Now()
provider := ""
if callback != nil {
provider = callback.Provider
}
uc.logger.Info(ctx, "payment.callback.received", "provider", provider)
defer func() {
tradeNo := ""
if result != nil {
tradeNo = result.TradeNo
}
uc.logPaymentOperation(ctx, "callback", started, provider, tradeNo, result, err)
}()
if callback == nil || strings.TrimSpace(callback.Provider) == "" || len(callback.Body) == 0 { if callback == nil || strings.TrimSpace(callback.Provider) == "" || len(callback.Body) == 0 {
err := errors.New("支付回调参数不完整") err := errors.New("支付回调参数不完整")
uc.logger.Error(ctx, "payment.callback.invalid", "error", err) uc.logger.Error(ctx, "payment.callback.invalid", "error", err)
@@ -686,6 +738,8 @@ func (uc *PaymentUsecase) createWithOrder(ctx context.Context, req *PaymentReque
if err != nil { if err != nil {
return nil, err return nil, err
} }
uc.logger.Info(ctx, "payment.order.persisted", "provider", req.Provider, "trade_no", req.TradeNo,
"created", created, "order_status", order.PaymentStatus)
if order.RequestFingerprint != paymentOrderFingerprint(req, extra) { if order.RequestFingerprint != paymentOrderFingerprint(req, extra) {
return nil, ErrPaymentOrderConflict return nil, ErrPaymentOrderConflict
} }
@@ -736,7 +790,7 @@ func (uc *PaymentUsecase) createWithOrder(ctx context.Context, req *PaymentReque
return uc.reconcilePaymentResult(ctx, result, nil) return uc.reconcilePaymentResult(ctx, result, nil)
} }
uc.logger.Info(ctx, "payment.order.create", "provider", req.Provider, "trade_no", req.TradeNo, "business_type", req.BusinessType, "business_id", req.BusinessID) uc.logger.Info(ctx, "payment.provider.create.started", "provider", req.Provider, "trade_no", req.TradeNo)
result, err := uc.repo.Create(ctx, req) result, err := uc.repo.Create(ctx, req)
if err != nil { if err != nil {
uc.logger.Error(ctx, "payment.order.provider_create_failed", "provider", req.Provider, "trade_no", req.TradeNo, "error", err) uc.logger.Error(ctx, "payment.order.provider_create_failed", "provider", req.Provider, "trade_no", req.TradeNo, "error", err)
@@ -746,6 +800,8 @@ 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
} }
uc.logger.Info(ctx, "payment.provider.create.checked", "provider", req.Provider, "trade_no", req.TradeNo,
"provider_status", result.Status, "provider_trade_no", result.ProviderTradeNo)
update := paymentProviderUpdate(result) update := paymentProviderUpdate(result)
update.EventSource = req.Provider update.EventSource = req.Provider
order, err = uc.orders.RecordPaymentCreate(ctx, req.Provider, req.TradeNo, update) order, err = uc.orders.RecordPaymentCreate(ctx, req.Provider, req.TradeNo, update)
@@ -769,23 +825,27 @@ func (uc *PaymentUsecase) callbackWithOrder(ctx context.Context, callback *Payme
err = errors.New("支付回调渠道或商户订单号校验失败") err = errors.New("支付回调渠道或商户订单号校验失败")
return nil, callbackResultError(callbackResult, err) return nil, callbackResultError(callbackResult, err)
} }
uc.logger.Info(ctx, "payment.callback.verified", "provider", callback.Provider, "trade_no", callbackResult.TradeNo)
queryID := callbackResult.QueryID queryID := callbackResult.QueryID
if queryID == "" { if queryID == "" {
queryID = callbackResult.TradeNo queryID = callbackResult.TradeNo
} }
uc.logger.Info(ctx, "payment.callback.provider_query.started", "provider", callback.Provider, "trade_no", callbackResult.TradeNo)
queryResult, err := uc.repo.Query(ctx, callbackResult.Provider, queryID) queryResult, err := uc.repo.Query(ctx, callbackResult.Provider, queryID)
if err != nil { if err != nil {
uc.logger.Error(ctx, "payment.callback.query_failed", "provider", callbackResult.Provider, "trade_no", callbackResult.TradeNo, "event_id", callbackResult.EventID, "error", err) uc.logger.Error(ctx, "payment.callback.query_failed", "provider", callbackResult.Provider, "trade_no", callbackResult.TradeNo, "error", err)
return nil, callbackResultError(callbackResult, err) return nil, callbackResultError(callbackResult, err)
} }
if err = validatePaymentQueryResult(callbackResult.Provider, queryID, queryResult); err != nil { if err = validatePaymentQueryResult(callbackResult.Provider, queryID, queryResult); err != nil {
uc.logger.Error(ctx, "payment.callback.query_invalid", "provider", callbackResult.Provider, "trade_no", callbackResult.TradeNo, "event_id", callbackResult.EventID, "error", err) uc.logger.Error(ctx, "payment.callback.query_invalid", "provider", callbackResult.Provider, "trade_no", callbackResult.TradeNo, "error", err)
return nil, callbackResultError(callbackResult, err) return nil, callbackResultError(callbackResult, err)
} }
if queryResult.TradeNo != callbackResult.TradeNo { if queryResult.TradeNo != callbackResult.TradeNo {
err = errors.New("支付回调商户订单号与主动查单结果不匹配") err = errors.New("支付回调商户订单号与主动查单结果不匹配")
return nil, callbackResultError(callbackResult, err) return nil, callbackResultError(callbackResult, err)
} }
uc.logger.Info(ctx, "payment.callback.provider_query.checked", "provider", callback.Provider,
"trade_no", callbackResult.TradeNo, "provider_status", queryResult.Status)
queryResult = clonePaymentResult(queryResult) queryResult = clonePaymentResult(queryResult)
queryResult.EventID = callbackResult.EventID queryResult.EventID = callbackResult.EventID
queryResult.SuccessAck = callbackResult.SuccessAck queryResult.SuccessAck = callbackResult.SuccessAck
@@ -829,6 +889,8 @@ func (uc *PaymentUsecase) reconcilePaymentResult(ctx context.Context, result, ca
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)
} }
uc.logger.Info(ctx, "payment.order.reconciled", "provider", result.Provider, "trade_no", result.TradeNo,
"source", update.EventSource, "provider_status", result.Status, "order_status", order.PaymentStatus)
result = attachOrderResult(result, order) result = attachOrderResult(result, order)
if !strings.EqualFold(result.Status, "success") { if !strings.EqualFold(result.Status, "success") {
return result, nil return result, nil
@@ -853,8 +915,10 @@ func (uc *PaymentUsecase) fulfillOrder(ctx context.Context, order *PaymentOrder,
if duplicate { if duplicate {
result.Duplicate = true result.Duplicate = true
result.Status = "fulfilled" result.Status = "fulfilled"
uc.logger.Info(ctx, "payment.fulfillment.duplicate", "provider", result.Provider, "trade_no", result.TradeNo)
return attachOrderResult(result, order), nil return attachOrderResult(result, order), nil
} }
uc.logger.Info(ctx, "payment.fulfillment.started", "provider", result.Provider, "trade_no", result.TradeNo, "business_type", order.BusinessType)
handler := uc.fulfillments.Handler(order.BusinessType) handler := uc.fulfillments.Handler(order.BusinessType)
if handler == nil { if handler == nil {
err = fmt.Errorf("支付发货 handler 未注册: %s", order.BusinessType) err = fmt.Errorf("支付发货 handler 未注册: %s", order.BusinessType)
@@ -877,6 +941,8 @@ func (uc *PaymentUsecase) fulfillOrder(ctx context.Context, order *PaymentOrder,
if err != nil { if err != nil {
return nil, err return nil, err
} }
uc.logger.Info(ctx, "payment.fulfillment.completed", "provider", result.Provider, "trade_no", result.TradeNo,
"fulfillment_status", order.FulfillmentStatus)
result.Status = "fulfilled" result.Status = "fulfilled"
return attachOrderResult(result, order), nil return attachOrderResult(result, order), nil
} }
@@ -898,6 +964,8 @@ func (uc *PaymentUsecase) refundWithOrder(ctx context.Context, current *PaymentO
if err != nil { if err != nil {
return nil, err return nil, err
} }
uc.logger.Info(ctx, "payment.refund.reserved", "provider", provider, "trade_no", tradeNo,
"refund_no", order.RefundNo, "amount", amount, "currency", order.Currency)
if provider == PaymentInternal || current.PaymentMode == PaymentModeInternal { if provider == PaymentInternal || current.PaymentMode == PaymentModeInternal {
processor, ok := handler.(PaymentInternalRefundProcessor) processor, ok := handler.(PaymentInternalRefundProcessor)
if !ok { if !ok {
@@ -930,6 +998,7 @@ func (uc *PaymentUsecase) refundWithOrder(ctx context.Context, current *PaymentO
} }
return attachOrderResult(&PaymentResult{Provider: provider, TradeNo: tradeNo, ProviderTradeNo: refund.RefundID, Amount: amount, Currency: current.Currency, Payload: refund.Payload, Status: "refunded"}, order), nil return attachOrderResult(&PaymentResult{Provider: provider, TradeNo: tradeNo, ProviderTradeNo: refund.RefundID, Amount: amount, Currency: current.Currency, Payload: refund.Payload, Status: "refunded"}, order), nil
} }
uc.logger.Info(ctx, "payment.provider.refund.started", "provider", provider, "trade_no", tradeNo, "refund_no", order.RefundNo)
result, providerErr := uc.repo.Refund(ctx, &PaymentRefundRequest{ result, providerErr := uc.repo.Refund(ctx, &PaymentRefundRequest{
Provider: order.Provider, Provider: order.Provider,
TradeNo: order.TradeNo, TradeNo: order.TradeNo,
@@ -943,6 +1012,10 @@ func (uc *PaymentUsecase) refundWithOrder(ctx context.Context, current *PaymentO
if providerErr == nil { if providerErr == nil {
providerErr = validatePaymentRefundResult(provider, tradeNo, result) providerErr = validatePaymentRefundResult(provider, tradeNo, result)
} }
if providerErr == nil {
uc.logger.Info(ctx, "payment.provider.refund.checked", "provider", provider, "trade_no", tradeNo,
"refund_no", order.RefundNo, "provider_status", result.Status)
}
if providerErr != nil && !errors.Is(providerErr, ErrPaymentOperationRejected) { if providerErr != nil && !errors.Is(providerErr, ErrPaymentOperationRejected) {
// A timeout or malformed response cannot prove that the provider did not // A timeout or malformed response cannot prove that the provider did not
// accept the refund. Keep the durable refund operation and its idempotency // accept the refund. Keep the durable refund operation and its idempotency
@@ -967,7 +1040,9 @@ func (uc *PaymentUsecase) refundWithOrder(ctx context.Context, current *PaymentO
// ConfirmRefund is called by a provider-specific refund notification or a // ConfirmRefund is called by a provider-specific refund notification or a
// reconciliation worker after the platform reports the final refund state. // reconciliation worker after the platform reports the final refund state.
func (uc *PaymentUsecase) ConfirmRefund(ctx context.Context, provider, tradeNo, refundNo string, amount int64, success bool, message string) (*PaymentResult, error) { func (uc *PaymentUsecase) ConfirmRefund(ctx context.Context, provider, tradeNo, refundNo string, amount int64, success bool, message string) (result *PaymentResult, err error) {
started := time.Now()
defer func() { uc.logPaymentOperation(ctx, "refund_confirmation", started, provider, tradeNo, result, err) }()
if uc.orders == nil { if uc.orders == nil {
return nil, errors.New("支付订单仓储未接入") return nil, errors.New("支付订单仓储未接入")
} }
@@ -978,7 +1053,7 @@ func (uc *PaymentUsecase) ConfirmRefund(ctx context.Context, provider, tradeNo,
if err != nil { if err != nil {
return nil, err return nil, err
} }
result := &PaymentResult{Provider: provider, TradeNo: tradeNo, Status: "refund_failed"} result = &PaymentResult{Provider: provider, TradeNo: tradeNo, Status: "refund_failed"}
if success { if success {
result.Status = "refunded" result.Status = "refunded"
} }
+56
View File
@@ -2,7 +2,10 @@ package payment
import ( import (
"context" "context"
"errors"
"log/slog" "log/slog"
"net"
"time"
) )
// PaymentLogger isolates payment audit logging from the payment workflow and // PaymentLogger isolates payment audit logging from the payment workflow and
@@ -28,5 +31,58 @@ func (l *slogPaymentLogger) Info(ctx context.Context, message string, args ...an
} }
func (l *slogPaymentLogger) Error(ctx context.Context, message string, args ...any) { func (l *slogPaymentLogger) Error(ctx context.Context, message string, args ...any) {
for i := 0; i+1 < len(args); i += 2 {
if key, ok := args[i].(string); ok && (key == "error" || key == "err") {
args[i] = "error_code"
if err, ok := args[i+1].(error); ok {
args[i+1] = paymentErrorCode(err)
} else {
args[i+1] = "redacted"
}
}
}
l.logger.ErrorContext(ctx, message, args...) l.logger.ErrorContext(ctx, message, args...)
} }
func paymentErrorCode(err error) string {
switch {
case errors.Is(err, ErrPaymentOrderNotFound):
return "order_not_found"
case errors.Is(err, ErrPaymentProviderNotFound):
return "provider_not_configured"
case errors.Is(err, ErrPaymentOrderConflict):
return "order_conflict"
case errors.Is(err, ErrPaymentProviderConflict):
return "provider_conflict"
case errors.Is(err, ErrPaymentOrderBusy):
return "order_busy"
case errors.Is(err, ErrPaymentOrderState):
return "invalid_order_state"
case errors.Is(err, ErrPaymentOperationRejected):
return "provider_rejected"
case errors.Is(err, context.Canceled):
return "cancelled"
case errors.Is(err, context.DeadlineExceeded):
return "timeout"
default:
var networkError net.Error
if errors.As(err, &networkError) && networkError.Timeout() {
return "timeout"
}
return "operation_failed"
}
}
func (uc *PaymentUsecase) logPaymentOperation(ctx context.Context, operation string, started time.Time, provider, tradeNo string, result *PaymentResult, err error) {
attrs := []any{"provider", provider, "trade_no", tradeNo, "duration_ms", time.Since(started).Milliseconds()}
if result != nil {
attrs = append(attrs, "provider_trade_no", result.ProviderTradeNo, "provider_status", result.Status,
"order_status", result.OrderStatus, "fulfillment_status", result.FulfillmentStatus,
"refund_status", result.RefundStatus, "duplicate", result.Duplicate)
}
if err != nil {
uc.logger.Error(ctx, "payment."+operation+".failed", append(attrs, "error_code", paymentErrorCode(err))...)
return
}
uc.logger.Info(ctx, "payment."+operation+".completed", attrs...)
}
+117
View File
@@ -0,0 +1,117 @@
package payment
import (
"bytes"
"context"
"encoding/json"
"errors"
"log/slog"
"strings"
"testing"
)
func TestPaymentLifecycleLogsDoNotIncludeProviderErrorsOrPayloads(t *testing.T) {
var output bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&output, nil))
repo := &paymentRepoStub{createErr: errors.New("provider response: secret-token"), callbackErr: errors.New("callback body: secret-token")}
orders := &paymentOrderRepoStub{}
sources := NewPaymentOrderSourceRegistry()
if err := sources.Register(&paymentSourceStub{intent: &PaymentIntent{
Provider: PaymentAlipay, TradeNo: "order-1", BusinessType: "game_item", BusinessID: "item-1",
Subject: "test item", Amount: 100, Currency: "CNY",
}}); err != nil {
t.Fatal(err)
}
uc := NewPaymentUsecase(repo, orders, nil, sources, nil, logger)
_, _ = uc.Create(context.Background(), &PaymentRequest{
Provider: PaymentAlipay, TradeNo: "order-1", BusinessType: "game_item", BusinessID: "item-1",
Extra: map[string]any{"api_key": "secret-token"},
})
_, _ = uc.Callback(context.Background(), &PaymentCallback{Provider: PaymentAlipay, Body: []byte("secret-token")})
text := output.String()
if strings.Contains(text, "secret-token") {
t.Fatal("provider error or request payload leaked to payment log")
}
for _, stage := range []string{
"payment.create.started", "payment.order.prepared", "payment.order.persisted",
"payment.provider.create.started", "payment.create.failed",
"payment.callback.received", "payment.callback.verify_failed", "payment.callback.failed",
} {
if !strings.Contains(text, stage) {
t.Errorf("missing lifecycle stage %s", stage)
}
}
for _, line := range strings.Split(strings.TrimSpace(text), "\n") {
var entry map[string]any
if err := json.Unmarshal([]byte(line), &entry); err != nil {
t.Fatalf("invalid structured log %q: %v", line, err)
}
if entry["mod"] != "payment" {
t.Fatalf("payment log module = %v", entry["mod"])
}
if entry["msg"] == "payment.create.failed" && entry["duration_ms"] == nil {
t.Fatal("create failure did not record duration")
}
}
}
func TestPaymentLogErrorUsesStableReasonWithoutOriginalMessage(t *testing.T) {
var output bytes.Buffer
logger := NewPaymentLogger(slog.New(slog.NewJSONHandler(&output, nil)))
logger.Error(context.Background(), "payment.test.failed", "error", PaymentRejected(errors.New("secret-token")))
if strings.Contains(output.String(), "secret-token") || !strings.Contains(output.String(), `"error_code":"provider_rejected"`) {
t.Fatalf("unsafe or missing error code in %s", output.String())
}
if got := paymentErrorCode(context.DeadlineExceeded); got != "timeout" {
t.Fatalf("deadline error code = %q, want timeout", got)
}
}
func TestPaymentLogCorrelatesQueryFulfillmentAndRefund(t *testing.T) {
var output bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&output, nil))
pending := successPaymentResult()
pending.Status = "pending"
repo := &paymentRepoStub{query: pending, refund: &PaymentResult{
Provider: PaymentAlipay, TradeNo: "order-1", Status: "pending", Amount: 40, Currency: "CNY",
}}
orders := &paymentOrderRepoStub{order: testBizPaymentOrder()}
registry := NewPaymentFulfillmentRegistry()
if err := registry.Register(&paymentFulfillmentStub{}); err != nil {
t.Fatal(err)
}
uc := NewPaymentUsecase(repo, orders, nil, nil, registry, logger)
if _, err := uc.Query(context.Background(), PaymentAlipay, "order-1"); err != nil {
t.Fatal(err)
}
orders.order.PaymentStatus = PaymentStatusPaid
if _, err := uc.Fulfill(context.Background(), PaymentAlipay, "order-1"); err != nil {
t.Fatal(err)
}
if _, err := uc.Refund(context.Background(), PaymentAlipay, "order-1", 40); err != nil {
t.Fatal(err)
}
if _, err := uc.ConfirmRefund(context.Background(), PaymentAlipay, "order-1", "refund-1", 40, true, "confirmed"); err != nil {
t.Fatal(err)
}
for _, stage := range []string{
"payment.provider.query.checked", "payment.order.reconciled", "payment.query.completed",
"payment.fulfillment.started", "payment.fulfillment.completed",
"payment.refund.reserved", "payment.provider.refund.checked",
"payment.refund.completed", "payment.refund_confirmation.completed",
} {
if !strings.Contains(output.String(), stage) {
t.Errorf("missing lifecycle stage %s", stage)
}
}
for _, line := range strings.Split(strings.TrimSpace(output.String()), "\n") {
var entry map[string]any
if err := json.Unmarshal([]byte(line), &entry); err != nil {
t.Fatal(err)
}
if entry["trade_no"] != "order-1" {
t.Fatalf("uncorrelated payment log: %s", line)
}
}
}
+4 -2
View File
@@ -12,12 +12,14 @@ import (
type paymentRepoStub struct { type paymentRepoStub struct {
create *PaymentResult create *PaymentResult
createErr error
createdReq *PaymentRequest createdReq *PaymentRequest
refund *PaymentResult refund *PaymentResult
refundErr error refundErr error
refundedReq *PaymentRefundRequest refundedReq *PaymentRefundRequest
callback *PaymentResult callback *PaymentResult
query *PaymentResult query *PaymentResult
queryErr error
queryCalls int queryCalls int
callbackErr error callbackErr error
} }
@@ -25,7 +27,7 @@ type paymentRepoStub struct {
func (r *paymentRepoStub) Create(_ context.Context, req *PaymentRequest) (*PaymentResult, error) { func (r *paymentRepoStub) Create(_ context.Context, req *PaymentRequest) (*PaymentResult, error) {
copyReq := *req copyReq := *req
r.createdReq = &copyReq r.createdReq = &copyReq
return r.create, nil return r.create, r.createErr
} }
type paymentOrderRepoStub struct { type paymentOrderRepoStub struct {
@@ -111,7 +113,7 @@ func (s *paymentSourceStub) PreparePayment(context.Context, string, string, stri
} }
func (r *paymentRepoStub) Query(context.Context, string, string) (*PaymentResult, error) { func (r *paymentRepoStub) Query(context.Context, string, string) (*PaymentResult, error) {
r.queryCalls++ r.queryCalls++
return r.query, nil return r.query, r.queryErr
} }
func (r *paymentRepoStub) Refund(_ context.Context, req *PaymentRefundRequest) (*PaymentResult, error) { func (r *paymentRepoStub) Refund(_ context.Context, req *PaymentRefundRequest) (*PaymentResult, error) {
if req != nil { if req != nil {
+2
View File
@@ -433,6 +433,8 @@ func routedLogPaths(module string, level zapcore.Level) []string {
paths = append(paths, filepath.Join("http", "access.log")) paths = append(paths, filepath.Join("http", "access.log"))
case "timedTask": case "timedTask":
paths = append(paths, filepath.Join("timedTask", "task.log")) paths = append(paths, filepath.Join("timedTask", "task.log"))
case "payment", "payment-callback":
paths = append(paths, filepath.Join("payment", "payment.log"))
case "websocket": case "websocket":
paths = append(paths, filepath.Join("websocket", "websocket.log")) paths = append(paths, filepath.Join("websocket", "websocket.log"))
case "error": case "error":
+3
View File
@@ -42,6 +42,8 @@ func TestZapHandlerRoutesHTTPAndErrorLogs(t *testing.T) {
logger := slog.New(handler) logger := slog.New(handler)
logger.Info("request", "mod", "http", "request_id", "req-1") logger.Info("request", "mod", "http", "request_id", "req-1")
logger.Info("handshake", "mod", "websocket") logger.Info("handshake", "mod", "websocket")
logger.Info("payment started", "mod", "payment", "trade_no", "order-1")
logger.Info("callback received", "mod", "payment-callback", "payment_provider", "wechat-v3")
logger.Error("failed", "mod", "users") logger.Error("failed", "mod", "users")
cleanup() cleanup()
@@ -54,6 +56,7 @@ func TestZapHandlerRoutesHTTPAndErrorLogs(t *testing.T) {
filepath.Join(root, date, "application.log"), filepath.Join(root, date, "application.log"),
filepath.Join(root, date, "http", "access.log"), filepath.Join(root, date, "http", "access.log"),
filepath.Join(root, date, "websocket", "websocket.log"), filepath.Join(root, date, "websocket", "websocket.log"),
filepath.Join(root, date, "payment", "payment.log"),
filepath.Join(root, date, "users", "application.log"), filepath.Join(root, date, "users", "application.log"),
filepath.Join(root, date, "error", "error.log"), filepath.Join(root, date, "error", "error.log"),
} }