支付相关

This commit is contained in:
Yvan
2026-08-19 18:06:03 +08:00
parent 1ef8ecb7b2
commit e54cbd1207
56 changed files with 8453 additions and 42 deletions
+1 -1
View File
@@ -3,4 +3,4 @@ package biz
import "github.com/google/wire"
// ProviderSet is biz providers.
var ProviderSet = wire.NewSet(NewUserUsecase, NewAuthenticationUsecase, NewSystemConfigUsecase, NewAuthorityUsecase, NewAPIUsecase, NewPermissionUsecase, NewAccessControlUsecase, NewMenuUsecase, NewDepartmentUsecase, NewPositionUsecase, NewDictionaryUsecase, NewParameterUsecase, NewTokenUsecase, NewSecurityUsecase, NewVersionUsecase, NewExportUsecase, NewAuditUsecase, NewAuditRecorderUsecase, NewLogViewerUsecase, NewTaskUsecase, NewTaskApplicationUsecase, NewMediaUsecase, NewAnnouncementUsecase, NewEmailUsecase)
var ProviderSet = wire.NewSet(NewUserUsecase, NewAuthenticationUsecase, NewSystemConfigUsecase, NewAuthorityUsecase, NewAPIUsecase, NewPermissionUsecase, NewAccessControlUsecase, NewMenuUsecase, NewDepartmentUsecase, NewPositionUsecase, NewDictionaryUsecase, NewParameterUsecase, NewTokenUsecase, NewSecurityUsecase, NewVersionUsecase, NewExportUsecase, NewAuditUsecase, NewAuditRecorderUsecase, NewLogViewerUsecase, NewTaskUsecase, NewTaskApplicationUsecase, NewMediaUsecase, NewAnnouncementUsecase, NewEmailUsecase, NewPaymentUsecase)
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
package biz
import (
"context"
"log/slog"
)
// PaymentLogger isolates payment audit logging from the payment workflow and
// lets an application replace slog with its own structured logger.
type PaymentLogger interface {
Info(context.Context, string, ...any)
Error(context.Context, string, ...any)
}
type slogPaymentLogger struct {
logger *slog.Logger
}
func NewPaymentLogger(logger *slog.Logger) PaymentLogger {
if logger == nil {
logger = slog.Default()
}
return &slogPaymentLogger{logger: logger.With("mod", "payment")}
}
func (l *slogPaymentLogger) Info(ctx context.Context, message string, args ...any) {
l.logger.InfoContext(ctx, message, args...)
}
func (l *slogPaymentLogger) Error(ctx context.Context, message string, args ...any) {
l.logger.ErrorContext(ctx, message, args...)
}
+210
View File
@@ -0,0 +1,210 @@
package biz
import (
"context"
"encoding/json"
"errors"
"strings"
"sync"
"time"
)
const (
PaymentStatusInitialized = "initialized"
PaymentStatusPending = "pending"
PaymentStatusPaid = "paid"
PaymentStatusFailed = "failed"
PaymentStatusClosed = "closed"
PaymentStatusPartiallyRefunded = "partially_refunded"
PaymentStatusRefunded = "refunded"
FulfillmentStatusPending = "pending"
FulfillmentStatusProcessing = "processing"
FulfillmentStatusSucceeded = "succeeded"
FulfillmentStatusFailed = "failed"
RefundStatusNone = "none"
RefundStatusProcessing = "processing"
RefundStatusPending = "pending"
RefundStatusPartial = "partial"
RefundStatusSucceeded = "succeeded"
RefundStatusFailed = "failed"
)
var (
ErrPaymentOrderNotFound = errors.New("支付订单不存在")
ErrPaymentOrderConflict = errors.New("支付订单参数冲突")
ErrPaymentOrderBusy = errors.New("支付订单正在处理中")
ErrPaymentOrderState = errors.New("支付订单状态不允许当前操作")
ErrPaymentProviderConflict = errors.New("支付平台交易号冲突")
)
// PaymentOrder is one payment attempt. Business modules own their domain
// orders and link to this record through BusinessType and BusinessID.
type PaymentOrder struct {
ID uint64
TradeNo string
Provider string
ProviderTradeNo string
QueryID string
BusinessType string
BusinessID string
Subject string
PaymentMode string
OriginalAmount int64
Amount int64
PaidAmount int64
PayerPaidAmount int64
CashPaidAmount int64
PointPaidAmount int64
DiscountAmount int64
ProviderDiscountAmount int64
MerchantDiscountAmount int64
SettlementAmount int64
Currency string
PayerCurrency string
AmountBreakdownKnown bool
PaymentStatus string
ProviderStatus string
FulfillmentStatus string
RefundStatus string
RefundedAmount int64
RefundRequestedAmount int64
RefundNo string
ConfirmationID string
RequestFingerprint string
CreatePayload json.RawMessage
Extra json.RawMessage
LastEventID string
LastPayloadHash string
LastError string
FulfillmentToken string
RefundToken string
Version uint64
CreatedAt time.Time
UpdatedAt time.Time
PaidAt *time.Time
FulfilledAt *time.Time
RefundedAt *time.Time
FulfillmentLeaseUntil *time.Time
RefundLeaseUntil *time.Time
}
type PaymentProviderUpdate struct {
Status string
ProviderStatus string
ProviderTradeNo string
QueryID string
Amount int64
PayerPaidAmount int64
CashPaidAmount int64
PointPaidAmount int64
DiscountAmount int64
ProviderDiscountAmount int64
MerchantDiscountAmount int64
SettlementAmount int64
Currency string
PayerCurrency string
AmountBreakdownKnown bool
EventID string
PayloadHash string
CreatePayload json.RawMessage
}
type PaymentOrderRepo interface {
CreatePaymentOrder(context.Context, *PaymentOrder) (order *PaymentOrder, created bool, err error)
FindPaymentOrder(context.Context, string, string) (*PaymentOrder, 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)
CompletePaymentFulfillment(context.Context, string, string, string, bool, string) (*PaymentOrder, error)
BeginPaymentRefund(context.Context, string, string, int64, time.Duration) (order *PaymentOrder, token string, err error)
CompletePaymentRefundRequest(context.Context, string, string, string, bool, string) (*PaymentOrder, error)
ConfirmPaymentRefund(context.Context, string, string, string, int64, bool, string) (*PaymentOrder, error)
}
// PaymentOrderSource is implemented by each business module. It returns the
// canonical order facts; client-provided amount, currency and subject are not
// trusted once the persistent payment-order flow is enabled.
type PaymentOrderSource interface {
Type() string
PreparePayment(context.Context, string, string, string) (*PaymentIntent, error)
}
type PaymentBusinessModule interface {
PaymentOrderSource
PaymentFulfillmentHandler
PaymentRefundAuthorizer
}
type PaymentRefundAuthorizer interface {
AuthorizeRefund(context.Context, *PaymentOrder, int64) error
}
type PaymentOrderSourceRegistry struct {
mu sync.RWMutex
sources map[string]PaymentOrderSource
}
func NewPaymentOrderSourceRegistry() *PaymentOrderSourceRegistry {
return &PaymentOrderSourceRegistry{sources: map[string]PaymentOrderSource{}}
}
func (r *PaymentOrderSourceRegistry) Register(source PaymentOrderSource) error {
if source == nil || strings.TrimSpace(source.Type()) == "" {
return errors.New("支付业务订单来源无效")
}
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.sources[source.Type()]; exists {
return errors.New("支付业务订单来源已注册: " + source.Type())
}
r.sources[source.Type()] = source
return nil
}
func (r *PaymentOrderSourceRegistry) Source(kind string) PaymentOrderSource {
if r == nil {
return nil
}
r.mu.RLock()
defer r.mu.RUnlock()
return r.sources[kind]
}
func (r *PaymentOrderSourceRegistry) Unregister(kind string) {
if r == nil {
return
}
r.mu.Lock()
defer r.mu.Unlock()
delete(r.sources, kind)
}
func (o *PaymentOrder) Intent() *PaymentIntent {
if o == nil {
return nil
}
return &PaymentIntent{
Provider: o.Provider,
TradeNo: o.TradeNo,
BusinessType: o.BusinessType,
BusinessID: o.BusinessID,
Subject: o.Subject,
Amount: o.Amount,
Currency: o.Currency,
Extra: append(json.RawMessage(nil), o.Extra...),
PaymentMode: defaultPaymentMode(o.Provider, o.PaymentMode),
OriginalAmount: o.OriginalAmount,
}
}
func defaultPaymentMode(provider, mode string) string {
if strings.TrimSpace(mode) != "" {
return mode
}
if provider == PaymentInternal {
return PaymentModeInternal
}
return PaymentModeExternal
}
+348
View File
@@ -0,0 +1,348 @@
package biz
import (
"context"
"errors"
"io"
"log/slog"
"testing"
"time"
)
type paymentRepoStub struct {
create *PaymentResult
createdReq *PaymentRequest
refund *PaymentResult
refundErr error
refundedReq *PaymentRefundRequest
callback *PaymentResult
query *PaymentResult
queryCalls int
callbackErr error
}
func (r *paymentRepoStub) ListConfigs(context.Context) ([]*PaymentConfig, error) { return nil, nil }
func (r *paymentRepoStub) SaveConfig(context.Context, *PaymentConfig) error { return nil }
func (r *paymentRepoStub) Create(_ context.Context, req *PaymentRequest) (*PaymentResult, error) {
copyReq := *req
r.createdReq = &copyReq
return r.create, nil
}
type paymentOrderRepoStub struct {
order *PaymentOrder
}
func (r *paymentOrderRepoStub) CreatePaymentOrder(_ context.Context, order *PaymentOrder) (*PaymentOrder, bool, error) {
if r.order != nil {
return r.order, false, nil
}
copyOrder := *order
r.order = &copyOrder
return r.order, true, nil
}
func (r *paymentOrderRepoStub) FindPaymentOrder(context.Context, string, string) (*PaymentOrder, error) {
if r.order == nil {
return nil, ErrPaymentOrderNotFound
}
return r.order, 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...)
return r.order, nil
}
func (r *paymentOrderRepoStub) ApplyPaymentResult(_ context.Context, _, _ string, update *PaymentProviderUpdate) (*PaymentOrder, error) {
if update.Status == "success" {
r.order.PaymentStatus = PaymentStatusPaid
r.order.PaidAmount = r.order.Amount
r.order.ProviderTradeNo = update.ProviderTradeNo
}
return r.order, nil
}
func (r *paymentOrderRepoStub) BeginPaymentFulfillment(context.Context, string, string, time.Duration) (*PaymentOrder, string, bool, error) {
if r.order.FulfillmentStatus == FulfillmentStatusSucceeded {
return r.order, "", true, nil
}
r.order.FulfillmentStatus = FulfillmentStatusProcessing
return r.order, "token", false, nil
}
func (r *paymentOrderRepoStub) CompletePaymentFulfillment(_ context.Context, _, _, _ string, success bool, _ string) (*PaymentOrder, error) {
if success {
r.order.FulfillmentStatus = FulfillmentStatusSucceeded
} else {
r.order.FulfillmentStatus = FulfillmentStatusFailed
}
return r.order, nil
}
func (r *paymentOrderRepoStub) BeginPaymentRefund(context.Context, string, string, int64, time.Duration) (*PaymentOrder, string, error) {
r.order.RefundNo = "refund-1"
r.order.RefundStatus = RefundStatusProcessing
return r.order, "refund-token", nil
}
func (r *paymentOrderRepoStub) CompletePaymentRefundRequest(context.Context, string, string, string, bool, string) (*PaymentOrder, error) {
r.order.RefundStatus = RefundStatusPending
return r.order, nil
}
func (r *paymentOrderRepoStub) ConfirmPaymentRefund(context.Context, string, string, string, int64, bool, string) (*PaymentOrder, error) {
return r.order, nil
}
type paymentSourceStub struct{ intent *PaymentIntent }
func (s *paymentSourceStub) Type() string { return s.intent.BusinessType }
func (s *paymentSourceStub) PreparePayment(context.Context, string, string, string) (*PaymentIntent, error) {
return s.intent, nil
}
func (r *paymentRepoStub) Query(context.Context, string, string) (*PaymentResult, error) {
r.queryCalls++
return r.query, nil
}
func (r *paymentRepoStub) Refund(_ context.Context, req *PaymentRefundRequest) (*PaymentResult, error) {
if req != nil {
copyReq := *req
r.refundedReq = &copyReq
}
return r.refund, r.refundErr
}
func (r *paymentRepoStub) HandleCallback(context.Context, *PaymentCallback) (*PaymentResult, error) {
return r.callback, r.callbackErr
}
type paymentFulfillmentStub struct {
calls int
failFirst bool
confirmIDs []string
}
func (h *paymentFulfillmentStub) Type() string { return "game_item" }
func (h *paymentFulfillmentStub) AuthorizeRefund(context.Context, *PaymentOrder, int64) error {
return nil
}
func (h *paymentFulfillmentStub) Fulfill(_ context.Context, confirmation *PaymentConfirmation) error {
h.calls++
h.confirmIDs = append(h.confirmIDs, confirmation.ID)
if h.failFirst && h.calls == 1 {
return errors.New("temporary fulfillment failure")
}
return nil
}
func newPaymentUsecaseForTest(repo PaymentRepo) *PaymentUsecase {
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
return NewPaymentUsecase(repo, &paymentOrderRepoStub{order: testBizPaymentOrder()}, logger)
}
func testBizPaymentOrder() *PaymentOrder {
return &PaymentOrder{Provider: PaymentAlipay, TradeNo: "order-1", BusinessType: "game_item", BusinessID: "item-1", Subject: "item", Amount: 100, Currency: "CNY", PaymentStatus: PaymentStatusPending, FulfillmentStatus: FulfillmentStatusPending, RefundStatus: RefundStatusNone, ConfirmationID: "11111111-1111-1111-1111-111111111111"}
}
func successPaymentResult() *PaymentResult {
return &PaymentResult{
Provider: PaymentAlipay,
Status: "success",
TradeNo: "order-1",
ProviderTradeNo: "ali-1",
Amount: 100,
Currency: "CNY",
}
}
func successIntent() *PaymentIntent {
return &PaymentIntent{
Provider: PaymentAlipay,
TradeNo: "order-1",
BusinessType: "game_item",
BusinessID: "item-1",
Amount: 100,
Currency: "CNY",
}
}
func TestPaymentCallbackQueriesBeforeFulfillment(t *testing.T) {
repo := &paymentRepoStub{callback: successPaymentResult(), query: successPaymentResult()}
uc := newPaymentUsecaseForTest(repo)
_, err := uc.Callback(context.Background(), &PaymentCallback{Provider: PaymentAlipay, Body: []byte("callback")})
if err == nil {
t.Fatal("callback succeeded without fulfillment handler")
}
if repo.queryCalls != 1 {
t.Fatalf("query calls = %d, want 1", repo.queryCalls)
}
}
func TestPaymentCallbackPendingDoesNotFulfill(t *testing.T) {
pending := successPaymentResult()
pending.Status = "pending"
repo := &paymentRepoStub{callback: successPaymentResult(), query: pending}
handler := &paymentFulfillmentStub{}
uc := newPaymentUsecaseForTest(repo)
registry := NewPaymentFulfillmentRegistry()
if err := registry.Register(handler); err != nil {
t.Fatal(err)
}
uc.SetFulfillmentRegistry(registry)
result, err := uc.Callback(context.Background(), &PaymentCallback{Provider: PaymentAlipay, Body: []byte("callback")})
if err != nil {
t.Fatal(err)
}
if result.Status != "pending" || handler.calls != 0 {
t.Fatalf("result=%+v handler=%d", result, handler.calls)
}
}
func TestPaymentCallbackRejectsAmountMismatch(t *testing.T) {
repo := &paymentRepoStub{callback: successPaymentResult(), query: successPaymentResult()}
handler := &paymentFulfillmentStub{}
uc := newPaymentUsecaseForTest(repo)
uc.orders.(*paymentOrderRepoStub).order.Amount = 101
registry := NewPaymentFulfillmentRegistry()
_ = registry.Register(handler)
uc.SetFulfillmentRegistry(registry)
if _, err := uc.Callback(context.Background(), &PaymentCallback{Provider: PaymentAlipay, Body: []byte("callback")}); err == nil {
t.Fatal("amount mismatch was accepted")
}
if handler.calls != 0 {
t.Fatal("handler ran after amount mismatch")
}
}
func TestPaymentCallbackRequiresFulfillmentHandler(t *testing.T) {
repo := &paymentRepoStub{callback: successPaymentResult(), query: successPaymentResult()}
uc := newPaymentUsecaseForTest(repo)
if _, err := uc.Callback(context.Background(), &PaymentCallback{Provider: PaymentAlipay, Body: []byte("callback")}); err == nil {
t.Fatal("callback succeeded without fulfillment handler")
}
}
func TestPaymentCallbackDuplicateDoesNotFulfillTwice(t *testing.T) {
repo := &paymentRepoStub{callback: successPaymentResult(), query: successPaymentResult()}
handler := &paymentFulfillmentStub{}
uc := newPaymentUsecaseForTest(repo)
registry := NewPaymentFulfillmentRegistry()
_ = registry.Register(handler)
uc.SetFulfillmentRegistry(registry)
callback := &PaymentCallback{Provider: PaymentAlipay, Body: []byte("callback")}
if _, err := uc.Callback(context.Background(), callback); err != nil {
t.Fatal(err)
}
second, err := uc.Callback(context.Background(), callback)
if err != nil {
t.Fatal(err)
}
if !second.Duplicate || handler.calls != 1 || repo.queryCalls != 2 {
t.Fatalf("duplicate=%v handler=%d query=%d", second.Duplicate, handler.calls, repo.queryCalls)
}
}
func TestPaymentCallbackFulfillmentFailureCanRetry(t *testing.T) {
repo := &paymentRepoStub{callback: successPaymentResult(), query: successPaymentResult()}
handler := &paymentFulfillmentStub{failFirst: true}
uc := newPaymentUsecaseForTest(repo)
registry := NewPaymentFulfillmentRegistry()
_ = registry.Register(handler)
uc.SetFulfillmentRegistry(registry)
callback := &PaymentCallback{Provider: PaymentAlipay, Body: []byte("callback")}
if _, err := uc.Callback(context.Background(), callback); err == nil {
t.Fatal("first fulfillment failure was accepted")
}
result, err := uc.Callback(context.Background(), callback)
if err != nil {
t.Fatal(err)
}
if result.Status != "fulfilled" || handler.calls != 2 || handler.confirmIDs[0] != handler.confirmIDs[1] {
t.Fatalf("result=%+v handler=%d ids=%v", result, handler.calls, handler.confirmIDs)
}
}
func TestPaymentQueryRejectsIncompleteSuccess(t *testing.T) {
result := successPaymentResult()
result.ProviderTradeNo = ""
uc := newPaymentUsecaseForTest(&paymentRepoStub{query: result})
if _, err := uc.Query(context.Background(), PaymentAlipay, "order-1"); err == nil {
t.Fatal("incomplete successful query was accepted")
}
}
func TestPaymentQueryRejectsUnknownStatus(t *testing.T) {
result := successPaymentResult()
result.Status = "maybe"
uc := newPaymentUsecaseForTest(&paymentRepoStub{query: result})
if _, err := uc.Query(context.Background(), PaymentAlipay, "order-1"); err == nil {
t.Fatal("unknown payment status was accepted")
}
}
func TestPaymentCallbackErrorCarriesSafeAck(t *testing.T) {
ack := DefaultPaymentCallbackAck(PaymentAlipay, false)
err := callbackResultError(&PaymentResult{FailureAck: ack}, errors.New("internal order resolver details"))
callbackErr, ok := err.(*PaymentCallbackError)
if !ok || string(callbackErr.Ack.Body) != "failure" {
t.Fatalf("callback error = %#v", err)
}
}
func TestPersistentPaymentCreateUsesBusinessCanonicalAmount(t *testing.T) {
repo := &paymentRepoStub{create: &PaymentResult{Provider: PaymentAlipay, Status: "created", TradeNo: "order-1", Payload: []byte(`{"prepay":"x"}`)}}
orders := &paymentOrderRepoStub{}
uc := NewPaymentUsecase(repo, orders, slog.New(slog.NewTextHandler(io.Discard, nil)))
sources := NewPaymentOrderSourceRegistry()
if err := sources.Register(&paymentSourceStub{intent: &PaymentIntent{Provider: PaymentAlipay, TradeNo: "order-1", BusinessType: "game_item", BusinessID: "item-1", Subject: "canonical item", Amount: 100, Currency: "CNY", Extra: []byte(`{"product_id":"canonical"}`)}}); err != nil {
t.Fatal(err)
}
uc.SetOrderSourceRegistry(sources)
result, err := uc.Create(context.Background(), &PaymentRequest{Provider: PaymentAlipay, TradeNo: "order-1", BusinessType: "game_item", BusinessID: "item-1", Subject: "forged", Amount: 1, Currency: "USD", Extra: map[string]any{"product_id": "forged"}})
if err != nil {
t.Fatal(err)
}
if repo.createdReq.Amount != 100 || repo.createdReq.Currency != "CNY" || repo.createdReq.Subject != "canonical item" || repo.createdReq.Extra["product_id"] != "canonical" || orders.order.Amount != 100 || result.OrderStatus != PaymentStatusPending {
t.Fatalf("canonical order was not used: request=%+v order=%+v result=%+v", repo.createdReq, orders.order, result)
}
}
func TestPersistentPaymentCallbackFulfillsAndPersistsDuplicate(t *testing.T) {
repo := &paymentRepoStub{callback: successPaymentResult(), query: successPaymentResult()}
orders := &paymentOrderRepoStub{order: &PaymentOrder{Provider: PaymentAlipay, TradeNo: "order-1", BusinessType: "game_item", BusinessID: "item-1", Subject: "item", Amount: 100, Currency: "CNY", PaymentStatus: PaymentStatusPending, FulfillmentStatus: FulfillmentStatusPending, RefundStatus: RefundStatusNone, ConfirmationID: "11111111-1111-1111-1111-111111111111"}}
handler := &paymentFulfillmentStub{}
uc := NewPaymentUsecase(repo, orders, slog.New(slog.NewTextHandler(io.Discard, nil)))
registry := NewPaymentFulfillmentRegistry()
if err := registry.Register(handler); err != nil {
t.Fatal(err)
}
uc.SetFulfillmentRegistry(registry)
first, err := uc.Callback(context.Background(), &PaymentCallback{Provider: PaymentAlipay, Body: []byte("callback")})
if err != nil {
t.Fatal(err)
}
second, err := uc.Callback(context.Background(), &PaymentCallback{Provider: PaymentAlipay, Body: []byte("callback")})
if err != nil {
t.Fatal(err)
}
if first.Status != "fulfilled" || second.Status != "fulfilled" || !second.Duplicate || handler.calls != 1 || orders.order.FulfillmentStatus != FulfillmentStatusSucceeded {
t.Fatalf("persistent callback flow failed: first=%+v second=%+v handler=%d order=%+v", first, second, handler.calls, orders.order)
}
}
func TestPaymentConfirmationAcceptsProviderDiscount(t *testing.T) {
intent := &PaymentIntent{Provider: PaymentAlipay, TradeNo: "T1", Amount: 1000, Currency: "CNY", BusinessType: "item", BusinessID: "I1"}
result := &PaymentResult{Provider: PaymentAlipay, TradeNo: "T1", ProviderTradeNo: "P1", Status: "success", Amount: 1000, PayerPaidAmount: 800, CashPaidAmount: 800, DiscountAmount: 200, ProviderDiscountAmount: 150, MerchantDiscountAmount: 50, SettlementAmount: 950, Currency: "CNY", AmountBreakdownKnown: true}
if err := validatePaymentConfirmation(intent, PaymentAlipay, "T1", result); err != nil {
t.Fatal(err)
}
}
func TestPaymentConfirmationRejectsOverpayment(t *testing.T) {
intent := &PaymentIntent{Provider: PaymentWechatV3, TradeNo: "T1", Amount: 1000, Currency: "CNY", BusinessType: "item", BusinessID: "I1"}
result := &PaymentResult{Provider: PaymentWechatV3, TradeNo: "T1", ProviderTradeNo: "P1", Status: "success", Amount: 1000, PayerPaidAmount: 1001, CashPaidAmount: 1001, Currency: "CNY", AmountBreakdownKnown: true}
if err := validatePaymentConfirmation(intent, PaymentWechatV3, "T1", result); err == nil {
t.Fatal("overpayment was accepted")
}
}
func TestPaymentConfirmationRejectsBrokenDiscountConservation(t *testing.T) {
intent := &PaymentIntent{Provider: PaymentWechatV2, TradeNo: "T1", Amount: 1000, Currency: "CNY", BusinessType: "item", BusinessID: "I1"}
result := &PaymentResult{Provider: PaymentWechatV2, TradeNo: "T1", ProviderTradeNo: "P1", Status: "success", Amount: 1000, PayerPaidAmount: 800, CashPaidAmount: 800, DiscountAmount: 100, Currency: "CNY", AmountBreakdownKnown: true}
if err := validatePaymentConfirmation(intent, PaymentWechatV2, "T1", result); err == nil {
t.Fatal("broken discount conservation was accepted")
}
}
+1 -1
View File
@@ -14,7 +14,7 @@ import (
"kra/internal/conf"
)
var ProviderSet = wire.NewSet(NewData, NewRuntimeSettings, NewTokenIssuer, NewUserRepo, NewInitializationRepo, NewAuthorityAccessRepo, NewAPIRepo, NewPermissionRepo, NewMenuRepo, NewDepartmentRepo, NewPositionRepo, NewDictionaryRepo, NewParameterRepo, NewAPITokenRepo, NewSecurityRepo, NewVersionRepo, NewExportRepo, NewAuditRepo, NewAuditRecorderRepo, NewLogFileRepo, NewTaskRepo, NewMediaRepo, NewAnnouncementRepo, NewEmailRepo, NewCache, NewFileStorage)
var ProviderSet = wire.NewSet(NewData, NewRuntimeSettings, NewTokenIssuer, NewUserRepo, NewInitializationRepo, NewAuthorityAccessRepo, NewAPIRepo, NewPermissionRepo, NewMenuRepo, NewDepartmentRepo, NewPositionRepo, NewDictionaryRepo, NewParameterRepo, NewAPITokenRepo, NewSecurityRepo, NewVersionRepo, NewExportRepo, NewAuditRepo, NewAuditRecorderRepo, NewLogFileRepo, NewTaskRepo, NewMediaRepo, NewAnnouncementRepo, NewEmailRepo, NewPaymentRepo, NewPaymentOrderRepo, NewCache, NewFileStorage)
type Data struct {
initMu sync.Mutex
+4
View File
@@ -25,9 +25,13 @@ func migrateAll(db *gorm.DB) error {
&operationPO{}, &loginLogPO{}, &dataAccessLogPO{}, &errorRecordPO{},
&taskPO{}, &taskLogPO{}, &mediaPO{}, &categoryPO{}, &uploadSessionPO{}, &uploadChunkPO{},
&announcementPO{},
&paymentOrderPO{},
); err != nil {
return err
}
if err := ensurePaymentIntegrationConfigs(db); err != nil {
return err
}
if err := migrateLegacyAuthorityAPIsToCasbinRules(db); err != nil {
return err
}
+410
View File
@@ -0,0 +1,410 @@
package data
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/url"
"strconv"
"strings"
"kra/internal/biz"
datapayment "kra/internal/data/payment"
"gorm.io/gorm"
)
type paymentRepo struct{ data *Data }
func NewPaymentRepo(data *Data) biz.PaymentRepo { return &paymentRepo{data: data} }
func ensurePaymentIntegrationConfigs(db *gorm.DB) error {
for _, provider := range biz.SupportedPaymentProviders {
var count int64
if err := db.Model(&integrationConfigPO{}).Where("kind = ? AND provider = ?", integrationKindPayment, provider).Count(&count).Error; err != nil {
return err
}
if count == 0 {
if err := db.Create(&integrationConfigPO{Kind: integrationKindPayment, Provider: provider, Enabled: false, Config: "{}"}).Error; err != nil {
return err
}
}
}
return nil
}
func (r *paymentRepo) row(ctx context.Context, provider string) (*integrationConfigPO, map[string]any, error) {
var row integrationConfigPO
if err := r.data.gormDB.WithContext(ctx).Where("kind = ? AND provider = ?", integrationKindPayment, provider).First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, biz.ErrPaymentProviderNotFound
}
return nil, nil, err
}
if !row.Enabled {
return nil, nil, fmt.Errorf("支付渠道 %s 未启用", provider)
}
values := map[string]any{}
if err := json.Unmarshal([]byte(row.Config), &values); err != nil {
return nil, nil, fmt.Errorf("支付配置格式错误: %w", err)
}
return &row, values, nil
}
func (r *paymentRepo) ListConfigs(ctx context.Context) ([]*biz.PaymentConfig, error) {
var rows []integrationConfigPO
if err := r.data.gormDB.WithContext(ctx).Where("kind = ?", integrationKindPayment).Order("provider ASC").Find(&rows).Error; err != nil {
return nil, err
}
out := make([]*biz.PaymentConfig, 0, len(rows))
for _, row := range rows {
values := map[string]any{}
if json.Unmarshal([]byte(row.Config), &values) == nil {
maskPaymentSecrets(values)
masked, _ := json.Marshal(values)
out = append(out, &biz.PaymentConfig{Provider: row.Provider, Enabled: row.Enabled, Values: masked})
continue
}
out = append(out, &biz.PaymentConfig{Provider: row.Provider, Enabled: row.Enabled, Values: json.RawMessage("{}")})
}
return out, nil
}
func (r *paymentRepo) SaveConfig(ctx context.Context, config *biz.PaymentConfig) error {
if config == nil || !contains(biz.SupportedPaymentProviders, config.Provider) {
return errors.New("不支持的支付渠道")
}
if !json.Valid(config.Values) {
return errors.New("支付配置必须是 JSON")
}
values := map[string]any{}
if err := json.Unmarshal(config.Values, &values); err != nil {
return errors.New("支付配置必须是 JSON 对象")
}
db := r.data.gormDB.WithContext(ctx)
var row integrationConfigPO
err := db.Where("kind = ? AND provider = ?", integrationKindPayment, config.Provider).First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
if config.Enabled {
if err = validatePaymentConfig(config.Provider, values); err != nil {
return err
}
}
encoded, _ := json.Marshal(values)
return db.Create(&integrationConfigPO{Kind: integrationKindPayment, Provider: config.Provider, Enabled: config.Enabled, Config: string(encoded)}).Error
}
if err != nil {
return err
}
old := map[string]any{}
_ = json.Unmarshal([]byte(row.Config), &old)
mergePaymentSecrets(values, old)
if config.Enabled {
if err = validatePaymentConfig(config.Provider, values); err != nil {
return err
}
}
encoded, _ := json.Marshal(values)
return db.Model(&row).Updates(map[string]any{"enabled": config.Enabled, "config": string(encoded)}).Error
}
func (r *paymentRepo) adapter(ctx context.Context, provider string) (datapayment.Adapter, map[string]any, error) {
_, values, err := r.row(ctx, provider)
if err != nil {
return nil, nil, err
}
adapter, err := datapayment.New(provider)
return adapter, values, err
}
func (r *paymentRepo) Create(ctx context.Context, req *biz.PaymentRequest) (*biz.PaymentResult, error) {
a, c, err := r.adapter(ctx, req.Provider)
if err != nil {
return nil, err
}
effective := *req
effective.NotifyURL = strings.TrimSpace(text(c, "notify_url"))
effective.ReturnURL = strings.TrimSpace(text(c, "return_url"))
if req.Provider != biz.PaymentApple && effective.NotifyURL == "" {
return nil, fmt.Errorf("支付渠道 %s 未配置服务端 notify_url", req.Provider)
}
return a.Create(ctx, &effective, c)
}
func (r *paymentRepo) Query(ctx context.Context, provider, tradeNo string) (*biz.PaymentResult, error) {
a, c, err := r.adapter(ctx, provider)
if err != nil {
return nil, err
}
return a.Query(ctx, tradeNo, c)
}
func (r *paymentRepo) Refund(ctx context.Context, req *biz.PaymentRefundRequest) (*biz.PaymentResult, error) {
if req == nil {
return nil, errors.New("支付退款请求为空")
}
a, c, err := r.adapter(ctx, req.Provider)
if err != nil {
return nil, err
}
return a.Refund(ctx, req, c)
}
func (r *paymentRepo) HandleCallback(ctx context.Context, callback *biz.PaymentCallback) (*biz.PaymentResult, error) {
if callback == nil {
return nil, errors.New("支付回调为空")
}
a, c, err := r.adapter(ctx, callback.Provider)
if err != nil {
return nil, err
}
result, err := a.Callback(ctx, callback, c)
if err != nil {
return nil, &biz.PaymentCallbackError{Cause: err, Ack: paymentCallbackAck(callback.Provider, c, false)}
}
if result == nil {
err = errors.New("支付回调解析结果为空")
return nil, &biz.PaymentCallbackError{Cause: err, Ack: paymentCallbackAck(callback.Provider, c, false)}
}
result.SuccessAck = paymentCallbackAck(callback.Provider, c, true)
result.FailureAck = paymentCallbackAck(callback.Provider, c, false)
if result.Provider != callback.Provider {
err = errors.New("支付回调渠道不匹配")
return nil, &biz.PaymentCallbackError{Cause: err, Ack: result.FailureAck}
}
fields := callbackFields(callback)
eventID := first(fields, "event_id", "notify_id", "notificationUUID", "id")
if eventID == "" {
hash := sha256.Sum256(append([]byte(callback.Provider+"\x00"), callback.Body...))
eventID = hex.EncodeToString(hash[:])
}
result.EventID = eventID
return result, nil
}
func paymentCallbackAck(provider string, values map[string]any, success bool) biz.PaymentCallbackAck {
ack := biz.DefaultPaymentCallbackAck(provider, success)
prefix := "callback_success_"
if !success {
prefix = "callback_failure_"
}
if configured := strings.TrimSpace(text(values, prefix+"status")); configured != "" {
if status, err := strconv.Atoi(configured); err == nil && status >= 200 && status <= 599 {
ack.StatusCode = status
}
}
if contentType := strings.TrimSpace(text(values, prefix+"content_type")); contentType != "" {
ack.ContentType = contentType
}
if body, exists := values[prefix+"body"]; exists {
ack.Body = []byte(fmt.Sprint(body))
}
return ack
}
func callbackFields(callback *biz.PaymentCallback) map[string]string {
fields := map[string]string{}
for key, value := range callback.Query {
fields[key] = value
}
contentType := strings.ToLower(callback.Headers["Content-Type"])
if strings.Contains(contentType, "application/x-www-form-urlencoded") {
if values, err := url.ParseQuery(string(callback.Body)); err == nil {
for key, value := range values {
if len(value) > 0 {
fields[key] = value[0]
}
}
}
}
var object map[string]any
if json.Unmarshal(callback.Body, &object) == nil {
for key, value := range object {
if text, ok := value.(string); ok {
fields[key] = text
}
}
}
return fields
}
func first(values map[string]string, keys ...string) string {
for _, key := range keys {
if values[key] != "" {
return values[key]
}
}
return ""
}
var paymentSecretKeys = map[string]bool{
"key": true, "secret": true, "secret_key": true, "private_key": true, "privatekey": true,
"client_key": true, "client_secret": true, "api_key": true, "api_v3_key": true,
"mch_key": true, "merchant_key": true, "signing_secret": true, "certificate": true,
"cert": true, "root_cert": true, "platform_cert": true, "public_cert": true,
"public_key": true, "client_cert": true, "app_secret": true, "appsecret": true,
"access_token": true, "app_auth_token": true, "credential_code": true, "cus_id": true, "p12": true,
"pkcs12": true, "merchant_private_key": true, "platform_key": true,
}
func maskPaymentSecrets(values map[string]any) {
for key, value := range values {
normalized := strings.ToLower(strings.ReplaceAll(key, "-", "_"))
if paymentSecretKeys[normalized] || strings.Contains(normalized, "private") || strings.Contains(normalized, "secret") {
if text, ok := value.(string); ok && text != "" {
values[key] = "******"
}
continue
}
if nested, ok := value.(map[string]any); ok {
maskPaymentSecrets(nested)
}
}
}
func mergePaymentSecrets(values, old map[string]any) {
for key, value := range values {
normalized := strings.ToLower(strings.ReplaceAll(key, "-", "_"))
if paymentSecretKeys[normalized] || strings.Contains(normalized, "private") || strings.Contains(normalized, "secret") {
if text, ok := value.(string); ok && text == "******" {
if prior, exists := old[key]; exists {
values[key] = prior
}
}
continue
}
if nested, ok := value.(map[string]any); ok {
if prior, ok := old[key].(map[string]any); ok {
mergePaymentSecrets(nested, prior)
}
}
}
}
func contains(values []string, value string) bool {
for _, item := range values {
if item == value {
return true
}
}
return false
}
func validatePaymentConfig(provider string, values map[string]any) error {
required := []string{}
switch provider {
case biz.PaymentAlipay:
required = []string{"app_id", "private_key", "public_key"}
case biz.PaymentWechatV2:
required = []string{"app_id", "merchant_id", "mch_key"}
case biz.PaymentWechatV3:
required = []string{"app_id", "merchant_id", "serial_no", "private_key", "api_v3_key", "platform_cert"}
case biz.PaymentApple:
required = []string{"issuer_id", "key_id", "bundle_id", "private_key"}
if configuredInt64(values, "price_divisor", 0) <= 0 {
if _, ok := values["price_divisors"].(map[string]any); !ok {
return fmt.Errorf("%s 缺少配置字段 price_divisor 或 price_divisors", provider)
}
}
case biz.PaymentDouyin:
required = []string{"merchant_id", "serial_no", "api_key", "private_key", "platform_cert", "platform_serial_no"}
case biz.PaymentQQ:
if firstAny(values, "mch_id", "merchant_id") == "" {
return fmt.Errorf("%s 缺少配置字段 mch_id", provider)
}
required = []string{"api_key"}
case biz.PaymentAllinPay:
required = []string{"cus_id", "app_id", "private_key", "public_key"}
case biz.PaymentLakala:
required = []string{"partner_code", "credential_code"}
case biz.PaymentPayPal:
required = []string{"client_id", "client_secret"}
case biz.PaymentSaobei:
required = []string{"inst_no", "key", "merchant_no", "terminal_id", "access_token"}
default:
required = []string{
"protocol_version", "create_url", "query_url", "query_status_field", "query_success_values",
"query_trade_no_field", "query_provider_trade_no_field", "query_amount_field",
"query_currency_field", "query_amount_scale", "callback_status_field",
"callback_success_values", "callback_trade_no_field", "callback_provider_trade_no_field",
}
if firstAny(values, "app_key", "merchant_key", "signing_secret", "token") == "" {
return fmt.Errorf("%s 缺少签名密钥", provider)
}
if err := validateConfiguredBreakdownFields(values); err != nil {
return fmt.Errorf("%s 金额拆分配置无效: %w", provider, err)
}
}
for _, key := range required {
if text(values, key) == "" {
return fmt.Errorf("%s 缺少配置字段 %s", provider, key)
}
}
if provider == biz.PaymentDouyin && firstAny(values, "platform_serial_no", "platform_cert_serial") == "" {
return fmt.Errorf("%s 缺少配置字段 platform_serial_no", provider)
}
if environment := strings.ToLower(strings.TrimSpace(text(values, "environment"))); environment != "" {
switch provider {
case biz.PaymentQQ, biz.PaymentDouyin, biz.PaymentLakala:
if environment != "production" && environment != "prod" {
return fmt.Errorf("%s 仅支持 production 环境", provider)
}
case biz.PaymentPayPal:
if environment != "production" && environment != "prod" && environment != "sandbox" {
return fmt.Errorf("%s environment 必须是 production 或 sandbox", provider)
}
default:
if environment != "production" && environment != "prod" && environment != "sandbox" {
return fmt.Errorf("%s environment 配置无效", provider)
}
}
}
if provider == biz.PaymentQQ {
signType := strings.ToUpper(strings.TrimSpace(text(values, "sign_type")))
if signType != "" && signType != "MD5" && signType != "HMAC-SHA256" {
return fmt.Errorf("%s sign_type 必须是 MD5 或 HMAC-SHA256", provider)
}
}
if err := validatePaymentCallbackAckConfig(values); err != nil {
return fmt.Errorf("%s 回调响应配置无效: %w", provider, err)
}
return nil
}
func validateConfiguredBreakdownFields(values map[string]any) error {
for _, key := range []string{
"query_payer_paid_amount_scale", "query_cash_paid_amount_scale", "query_point_paid_amount_scale",
"query_discount_amount_scale", "query_provider_discount_amount_scale", "query_merchant_discount_amount_scale",
"query_settlement_amount_scale",
} {
if _, exists := values[key]; exists && configuredInt64(values, key, 0) <= 0 {
return fmt.Errorf("%s 必须是正整数", key)
}
}
for _, key := range []string{
"query_payer_paid_amount_field", "query_cash_paid_amount_field", "query_point_paid_amount_field",
"query_discount_amount_field", "query_provider_discount_amount_field", "query_merchant_discount_amount_field",
"query_settlement_amount_field", "query_payer_currency_field",
} {
if value, exists := values[key]; exists && strings.TrimSpace(fmt.Sprint(value)) == "" {
return fmt.Errorf("%s 不能为空", key)
}
}
return nil
}
func validatePaymentCallbackAckConfig(values map[string]any) error {
for _, prefix := range []string{"callback_success_", "callback_failure_"} {
if value := strings.TrimSpace(text(values, prefix+"status")); value != "" {
status, err := strconv.Atoi(value)
if err != nil || status < 200 || status > 599 {
return fmt.Errorf("%sstatus 必须是 200-599", prefix)
}
}
if contentType := text(values, prefix+"content_type"); strings.ContainsAny(contentType, "\r\n") || len(contentType) > 200 {
return fmt.Errorf("%scontent_type 非法", prefix)
}
if body, exists := values[prefix+"body"]; exists && len(fmt.Sprint(body)) > 64<<10 {
return fmt.Errorf("%sbody 超过 64KiB", prefix)
}
}
return nil
}
+55
View File
@@ -0,0 +1,55 @@
package payment
import (
"context"
"fmt"
"kra/internal/biz"
)
// Adapter is the provider boundary used by the payment repository. Provider
// SDK types stay in this package and are normalized to biz.PaymentResult.
type Adapter interface {
Create(context.Context, *biz.PaymentRequest, map[string]any) (*biz.PaymentResult, error)
Query(context.Context, string, map[string]any) (*biz.PaymentResult, error)
Refund(context.Context, *biz.PaymentRefundRequest, map[string]any) (*biz.PaymentResult, error)
Callback(context.Context, *biz.PaymentCallback, map[string]any) (*biz.PaymentResult, error)
}
// New constructs the SDK-backed adapter for a configured provider.
func New(provider string) (Adapter, error) {
switch provider {
case biz.PaymentAlipay:
return &alipayAdapter{}, nil
case biz.PaymentWechatV2:
return &wechatV2Adapter{}, nil
case biz.PaymentWechatV3:
return &wechatV3Adapter{}, nil
case biz.PaymentApple:
return &appleAdapter{}, nil
case biz.PaymentDouyin:
return &douyinAdapter{}, nil
case biz.PaymentQQ:
return &qqAdapter{}, nil
case biz.PaymentAllinPay:
return &allinpayAdapter{}, nil
case biz.PaymentLakala:
return &lakalaAdapter{}, nil
case biz.PaymentPayPal:
return &paypalAdapter{}, nil
case biz.PaymentSaobei:
return &saobeiAdapter{}, nil
case biz.PaymentChinaums:
return newVendorAdapter(provider, vendorChinaums), nil
case biz.PaymentSFT:
return newVendorAdapter(provider, vendorSFT), nil
case biz.PaymentSuperPay:
return newVendorAdapter(provider, vendorSupperPay), nil
case biz.PaymentWechatGame:
return newVendorAdapter(provider, vendorWechatGame), nil
case biz.PaymentDouyinGame:
return newVendorAdapter(provider, vendorDouyinGame), nil
default:
return nil, fmt.Errorf("支付渠道 %s 没有适配器", provider)
}
}
+21
View File
@@ -0,0 +1,21 @@
package payment
import (
"testing"
"kra/internal/biz"
)
func TestEverySupportedProviderHasAdapter(t *testing.T) {
for _, provider := range biz.SupportedPaymentProviders {
t.Run(provider, func(t *testing.T) {
adapter, err := New(provider)
if err != nil {
t.Fatalf("New(%q): %v", provider, err)
}
if adapter == nil {
t.Fatalf("New(%q) returned a nil adapter", provider)
}
})
}
}
+551
View File
@@ -0,0 +1,551 @@
package payment
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strings"
"time"
"kra/internal/biz"
"github.com/go-pay/gopay"
gopayAlipay "github.com/go-pay/gopay/alipay"
"github.com/go-pay/gopay/pkg/xhttp"
)
type alipayAdapter struct{}
type alipayClient struct {
client *gopayAlipay.Client
gateway *url.URL
response bytes.Buffer
}
type alipayTransport struct {
base http.RoundTripper
gateway *url.URL
response *bytes.Buffer
}
type alipayResponseBody struct {
io.Reader
io.Closer
}
func (a *alipayAdapter) Create(ctx context.Context, req *biz.PaymentRequest, c map[string]any) (*biz.PaymentResult, error) {
if req == nil {
return nil, errors.New("支付宝下单参数为空")
}
amount, err := formatDecimalAmount(req.Amount, 100)
if err != nil {
return nil, err
}
bm := gopay.BodyMap{"out_trade_no": req.TradeNo, "total_amount": amount, "subject": req.Subject}
mergeMap(bm, req.Extra,
"out_trade_no", "total_amount", "subject", "notify_url", "return_url",
"method", "pay_method", "trade_type", "channel",
)
sdk, err := newAlipayClient(c)
if err != nil {
return nil, err
}
if req.NotifyURL != "" {
sdk.client.SetNotifyUrl(req.NotifyURL)
}
if req.ReturnURL != "" {
sdk.client.SetReturnUrl(req.ReturnURL)
}
method, err := alipayCreateMethod(req.Extra, c)
if err != nil {
return nil, err
}
switch method {
case "alipay.trade.precreate":
rsp, callErr := sdk.client.TradePrecreate(ctx, bm)
if err = sdk.finish(method, rsp != nil && rsp.Response != nil, callErr, c); err != nil {
return nil, err
}
response := rsp.Response
queryID := strings.TrimSpace(response.OutTradeNo)
if queryID == "" {
queryID = req.TradeNo
} else if queryID != req.TradeNo {
return nil, errors.New("支付宝预创建响应的 out_trade_no 不匹配")
}
if strings.TrimSpace(response.QrCode) == "" {
return nil, errors.New("支付宝预创建响应缺少 qr_code")
}
// Precreate has no Alipay trade number yet. Keep the QR code in Payload
// and query later by the merchant out_trade_no.
return &biz.PaymentResult{
Provider: biz.PaymentAlipay, Status: "created", TradeNo: req.TradeNo,
QueryID: queryID, Payload: sdk.payload(rsp),
}, nil
case "alipay.trade.app.pay":
orderString, callErr := sdk.client.TradeAppPay(ctx, bm)
if callErr != nil {
return nil, callErr
}
if strings.TrimSpace(orderString) == "" {
return nil, errors.New("支付宝 APP 支付参数为空")
}
return &biz.PaymentResult{
Provider: biz.PaymentAlipay, Status: "created", TradeNo: req.TradeNo,
Payload: mustMarshalAlipayPayload(map[string]string{"order_string": orderString}),
}, nil
case "alipay.trade.page.pay":
payURL, callErr := sdk.client.TradePagePay(ctx, bm)
if callErr != nil {
return nil, callErr
}
if strings.TrimSpace(payURL) == "" {
return nil, errors.New("支付宝电脑网站支付 URL 为空")
}
payURL, err = sdk.rewriteGeneratedURL(payURL)
if err != nil {
return nil, err
}
return &biz.PaymentResult{
Provider: biz.PaymentAlipay, Status: "created", TradeNo: req.TradeNo,
Payload: mustMarshalAlipayPayload(map[string]string{"pay_url": payURL}),
}, nil
case "alipay.trade.wap.pay":
payURL, callErr := sdk.client.TradeWapPay(ctx, bm)
if callErr != nil {
return nil, callErr
}
if strings.TrimSpace(payURL) == "" {
return nil, errors.New("支付宝手机网站支付 URL 为空")
}
payURL, err = sdk.rewriteGeneratedURL(payURL)
if err != nil {
return nil, err
}
return &biz.PaymentResult{
Provider: biz.PaymentAlipay, Status: "created", TradeNo: req.TradeNo,
Payload: mustMarshalAlipayPayload(map[string]string{"pay_url": payURL}),
}, nil
default:
rsp, callErr := sdk.client.TradeCreate(ctx, bm)
if err = sdk.finish(method, rsp != nil && rsp.Response != nil, callErr, c); err != nil {
return nil, err
}
response := rsp.Response
if response.OutTradeNo != "" && strings.TrimSpace(response.OutTradeNo) != req.TradeNo {
return nil, errors.New("支付宝创建响应的 out_trade_no 不匹配")
}
providerTradeNo := strings.TrimSpace(response.TradeNo)
if providerTradeNo == "" {
return nil, errors.New("支付宝创建响应缺少 trade_no")
}
return &biz.PaymentResult{
Provider: biz.PaymentAlipay, Status: "created", TradeNo: req.TradeNo,
ProviderTradeNo: providerTradeNo, Payload: sdk.payload(rsp),
}, nil
}
}
func (a *alipayAdapter) Query(ctx context.Context, tradeNo string, c map[string]any) (*biz.PaymentResult, error) {
tradeNo = strings.TrimSpace(tradeNo)
if tradeNo == "" {
return nil, errors.New("支付宝查单缺少 out_trade_no")
}
sdk, err := newAlipayClient(c)
if err != nil {
return nil, err
}
const method = "alipay.trade.query"
rsp, callErr := sdk.client.TradeQuery(ctx, gopay.BodyMap{"out_trade_no": tradeNo})
if err = sdk.finish(method, rsp != nil && rsp.Response != nil, callErr, c); err != nil {
return nil, err
}
response := rsp.Response
result := &biz.PaymentResult{
Provider: biz.PaymentAlipay, Status: normalizePaymentStatus(response.TradeStatus, "pending"),
TradeNo: strings.TrimSpace(response.OutTradeNo), ProviderTradeNo: strings.TrimSpace(response.TradeNo),
Payload: sdk.payload(rsp),
}
if result.TradeNo == "" {
result.TradeNo = tradeNo
} else if result.TradeNo != tradeNo {
return nil, errors.New("支付宝查询响应的 out_trade_no 不匹配")
}
if result.Status != "success" {
return result, nil
}
if result.ProviderTradeNo == "" {
return nil, errors.New("支付宝查询响应缺少 trade_no")
}
result.Amount, err = parseDecimalAmount(response.TotalAmount, 100)
if err != nil {
return nil, fmt.Errorf("解析支付宝订单金额: %w", err)
}
result.Currency = strings.ToUpper(firstNonEmptyAlipay(response.TransCurrency, response.PayCurrency))
if result.Currency == "" {
result.Currency = "CNY"
}
values, err := alipayResponseValues(response)
if err != nil {
return nil, err
}
if err = populateAlipayBreakdown(result, values); err != nil {
return nil, err
}
return result, nil
}
func (a *alipayAdapter) Refund(ctx context.Context, req *biz.PaymentRefundRequest, c map[string]any) (*biz.PaymentResult, error) {
if req == nil {
return nil, errors.New("支付宝退款请求为空")
}
tradeNo := strings.TrimSpace(req.TradeNo)
refundNo := strings.TrimSpace(req.RefundNo)
if tradeNo == "" || refundNo == "" {
return nil, errors.New("支付宝退款缺少 out_trade_no 或 out_request_no")
}
formatted, err := formatDecimalAmount(req.Amount, 100)
if err != nil {
return nil, err
}
sdk, err := newAlipayClient(c)
if err != nil {
return nil, err
}
bm := gopay.BodyMap{"out_trade_no": tradeNo, "out_request_no": refundNo, "refund_amount": formatted}
mergeGoPayConfigExtras(bm, c, "refund_extra", "out_trade_no", "out_request_no", "refund_amount")
const method = "alipay.trade.refund"
rsp, callErr := sdk.client.TradeRefund(ctx, bm)
if err = sdk.finish(method, rsp != nil && rsp.Response != nil, callErr, c); err != nil {
return nil, err
}
response := rsp.Response
if response.OutTradeNo != "" && strings.TrimSpace(response.OutTradeNo) != tradeNo {
return nil, errors.New("支付宝退款响应的 out_trade_no 不匹配")
}
providerTradeNo := strings.TrimSpace(response.TradeNo)
if providerTradeNo == "" {
return nil, errors.New("支付宝退款响应缺少 trade_no")
}
return &biz.PaymentResult{
Provider: biz.PaymentAlipay, Status: "created", TradeNo: tradeNo,
ProviderTradeNo: providerTradeNo, Payload: sdk.payload(rsp),
}, nil
}
func (a *alipayAdapter) Callback(_ context.Context, callback *biz.PaymentCallback, c map[string]any) (*biz.PaymentResult, error) {
fields := callbackFields(callback)
if err := verifyAlipay(fields, c); err != nil {
return nil, err
}
if fields["app_id"] != text(c, "app_id") {
return nil, errors.New("支付宝回调 app_id 不匹配")
}
status := "pending"
if fields["trade_status"] == "TRADE_SUCCESS" || fields["trade_status"] == "TRADE_FINISHED" {
status = "success"
}
payload, _ := json.Marshal(fields)
return &biz.PaymentResult{
Provider: biz.PaymentAlipay, Status: status,
TradeNo: first(fields, "out_trade_no"), ProviderTradeNo: first(fields, "trade_no"),
Payload: payload,
}, nil
}
func newAlipayClient(c map[string]any) (*alipayClient, error) {
appID := strings.TrimSpace(text(c, "app_id"))
privateKey := strings.TrimSpace(text(c, "private_key"))
if appID == "" || privateKey == "" {
return nil, errors.New("支付宝缺少 app_id 或 private_key")
}
production := !strings.EqualFold(text(c, "environment"), "sandbox")
client, err := gopayAlipay.NewClient(appID, goPayAlipayKey(privateKey, false), production)
if err != nil {
return nil, err
}
if charset := strings.TrimSpace(text(c, "charset")); charset != "" {
client.SetCharset(charset)
}
if signType := strings.TrimSpace(text(c, "sign_type")); signType != "" {
client.SetSignType(signType)
}
if token := strings.TrimSpace(text(c, "app_auth_token")); token != "" {
client.SetAppAuthToken(token)
}
if location := strings.TrimSpace(text(c, "location")); location != "" {
client.SetLocation(location)
}
sdk := &alipayClient{client: client}
if endpoint := strings.TrimSpace(text(c, "gateway_url")); endpoint != "" {
sdk.gateway, err = url.Parse(endpoint)
if err != nil || sdk.gateway.Scheme == "" || sdk.gateway.Host == "" {
return nil, fmt.Errorf("支付宝 gateway_url 无效: %s", endpoint)
}
}
httpClient := xhttp.NewClient().SetTimeout(20 * time.Second).SetBodySize(8)
httpClient.SetTransport(&alipayTransport{
base: httpClient.HttpClient.Transport, gateway: sdk.gateway, response: &sdk.response,
})
client.SetHttpClient(httpClient)
return sdk, nil
}
func (c *alipayClient) finish(method string, hasResponse bool, callErr error, config map[string]any) error {
if !hasResponse {
if callErr != nil {
return callErr
}
return fmt.Errorf("支付宝 %s 响应为空", method)
}
if c.response.Len() == 0 {
return fmt.Errorf("支付宝 %s 响应原文为空", method)
}
if err := verifyAlipayResponse(c.response.Bytes(), method, config); err != nil {
return err
}
return callErr
}
func (c *alipayClient) payload(fallback any) []byte {
if c.response.Len() > 0 {
return append([]byte(nil), c.response.Bytes()...)
}
return mustMarshalAlipayPayload(fallback)
}
func (c *alipayClient) rewriteGeneratedURL(value string) (string, error) {
if c.gateway == nil {
return value, nil
}
source, err := url.Parse(value)
if err != nil {
return "", fmt.Errorf("解析支付宝支付 URL: %w", err)
}
return alipayTargetURL(source, c.gateway).String(), nil
}
func (t *alipayTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if t.base == nil {
t.base = http.DefaultTransport
}
target := req
if t.gateway != nil {
clone := req.Clone(req.Context())
clone.URL = alipayTargetURL(req.URL, t.gateway)
clone.Host = ""
target = clone
}
if t.response != nil {
t.response.Reset()
}
resp, err := t.base.RoundTrip(target)
if err != nil || resp == nil || resp.Body == nil || t.response == nil {
return resp, err
}
resp.Body = &alipayResponseBody{Reader: io.TeeReader(resp.Body, t.response), Closer: resp.Body}
return resp, nil
}
func alipayTargetURL(source, gateway *url.URL) *url.URL {
target := *gateway
switch {
case target.RawQuery == "":
target.RawQuery = source.RawQuery
case source.RawQuery != "":
target.RawQuery += "&" + source.RawQuery
}
return &target
}
func alipayCreateMethod(extra, config map[string]any) (string, error) {
value := firstAny(extra, "method", "pay_method", "trade_type", "channel")
if value == "" {
value = firstAny(config, "method", "pay_method", "trade_type", "channel")
}
normalized := strings.NewReplacer(".", "_", "-", "_", " ", "_").Replace(strings.ToLower(strings.TrimSpace(value)))
switch normalized {
case "", "create", "trade_create", "alipay_trade_create", "jsapi", "miniapp", "mini_program":
return "alipay.trade.create", nil
case "precreate", "trade_precreate", "alipay_trade_precreate", "native", "qr", "qrcode", "scan":
return "alipay.trade.precreate", nil
case "app", "app_pay", "trade_app_pay", "alipay_trade_app_pay":
return "alipay.trade.app.pay", nil
case "page", "page_pay", "trade_page_pay", "alipay_trade_page_pay", "web", "pc", "desktop":
return "alipay.trade.page.pay", nil
case "wap", "wap_pay", "trade_wap_pay", "alipay_trade_wap_pay", "h5", "mobile":
return "alipay.trade.wap.pay", nil
default:
return "", fmt.Errorf("支付宝不支持的下单方式: %s", value)
}
}
func alipayResponseValues(value any) (map[string]any, error) {
raw, err := json.Marshal(value)
if err != nil {
return nil, err
}
result := map[string]any{}
if err = json.Unmarshal(raw, &result); err != nil {
return nil, err
}
return result, nil
}
func mustMarshalAlipayPayload(value any) []byte {
raw, _ := json.Marshal(value)
return raw
}
func firstNonEmptyAlipay(values ...string) string {
for _, value := range values {
if value = strings.TrimSpace(value); value != "" {
return value
}
}
return ""
}
func populateAlipayBreakdown(result *biz.PaymentResult, response map[string]any) error {
if result == nil || result.Status != "success" {
return nil
}
// Alipay returns these values in major currency units. Keep all arithmetic
// integer-only and only mark the breakdown complete when the core fields are
// present and internally consistent.
total, err := parseDecimalAmount(strings.TrimSpace(fmt.Sprint(response["total_amount"])), 100)
if err != nil {
return fmt.Errorf("解析支付宝 total_amount: %w", err)
}
result.Amount = total
payer, payerOK, err := parseAlipayDecimal(response, "buyer_pay_amount")
if err != nil {
return err
}
if !payerOK {
payer, payerOK, err = parseAlipayDecimal(response, "receipt_amount")
if err != nil {
return err
}
}
point, pointOK, err := parseAlipayDecimal(response, "point_amount")
if err != nil {
return err
}
if !payerOK {
return nil
}
if pointOK && point > payer {
return errors.New("支付宝 point_amount 大于用户实付金额")
}
result.PayerPaidAmount = payer
result.PointPaidAmount = point
result.CashPaidAmount = payer - point
result.DiscountAmount = total - payer
if result.DiscountAmount < 0 {
return errors.New("支付宝用户实付金额超过订单总额")
}
// receipt_amount is the merchant receipt amount. It is not always present;
// when it is, use it as settlement amount. Alipay's detailed voucher list
// is retained in Payload for audit and is intentionally not guessed here.
if receipt, ok, receiptErr := parseAlipayDecimal(response, "receipt_amount"); receiptErr != nil {
return receiptErr
} else if ok {
result.SettlementAmount = receipt
}
result.AmountBreakdownKnown = true
return nil
}
func parseAlipayDecimal(values map[string]any, key string) (int64, bool, error) {
value, exists := values[key]
if !exists || strings.TrimSpace(fmt.Sprint(value)) == "" || fmt.Sprint(value) == "<nil>" {
return 0, false, nil
}
amount, err := parseDecimalAmount(strings.TrimSpace(fmt.Sprint(value)), 100)
return amount, true, err
}
func verifyAlipayResponse(raw []byte, method string, c map[string]any) error {
publicKeyValue := text(c, "public_key")
if publicKeyValue == "" {
return errors.New("支付宝缺少 public_key")
}
var envelope map[string]json.RawMessage
if err := json.Unmarshal(raw, &envelope); err != nil {
return err
}
responseKey := strings.ReplaceAll(method, ".", "_") + "_response"
responseRaw := envelope[responseKey]
if len(responseRaw) == 0 {
return errors.New("支付宝响应缺少 " + responseKey)
}
var sign string
if err := json.Unmarshal(envelope["sign"], &sign); err != nil || sign == "" {
return errors.New("支付宝响应缺少 sign")
}
ok, err := gopayAlipay.VerifySyncSign(goPayAlipayKey(publicKeyValue, true), string(responseRaw), sign)
if err != nil {
return err
}
if !ok {
return errors.New("支付宝响应签名校验失败")
}
return nil
}
func alipaySignContent(v url.Values) string {
keys := make([]string, 0, len(v))
for key := range v {
if key != "sign" && v.Get(key) != "" {
keys = append(keys, key)
}
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, key := range keys {
parts = append(parts, key+"="+v.Get(key))
}
return strings.Join(parts, "&")
}
func verifyAlipay(fields map[string]string, c map[string]any) error {
if text(c, "app_id") == "" || text(c, "public_key") == "" || fields["sign"] == "" {
return errors.New("支付宝回调验签配置或 sign 缺失")
}
values := url.Values{}
for key, value := range fields {
values.Set(key, value)
}
body, err := gopayAlipay.ParseNotifyByURLValues(values)
if err != nil {
return err
}
ok, err := gopayAlipay.VerifySign(goPayAlipayKey(text(c, "public_key"), true), cloneGoPayBodyMap(body))
if err != nil {
return err
}
if !ok {
return errors.New("支付宝回调签名校验失败")
}
return nil
}
func mergeMap(dst map[string]any, src map[string]any, protected ...string) {
protectedKeys := make(map[string]struct{}, len(protected))
for _, key := range protected {
protectedKeys[key] = struct{}{}
}
for key, value := range src {
if _, exists := protectedKeys[key]; exists {
continue
}
dst[key] = value
}
}
+139
View File
@@ -0,0 +1,139 @@
package payment
import (
"context"
"errors"
"strings"
"kra/internal/biz"
"github.com/go-pay/gopay"
"github.com/go-pay/gopay/allinpay"
)
type allinpayAdapter struct{}
func (a *allinpayAdapter) client(c map[string]any) (*allinpay.Client, error) {
client, err := allinpay.NewClient(text(c, "cus_id"), text(c, "app_id"), text(c, "private_key"), text(c, "public_key"), !strings.EqualFold(text(c, "environment"), "sandbox"))
if err != nil {
return nil, err
}
if org := text(c, "org_id"); org != "" {
client.SetOrgId(org)
}
return client, nil
}
func (a *allinpayAdapter) Create(ctx context.Context, req *biz.PaymentRequest, c map[string]any) (*biz.PaymentResult, error) {
client, err := a.client(c)
if err != nil {
return nil, err
}
bm := gopay.BodyMap{"reqsn": req.TradeNo, "trxamt": req.Amount, "body": req.Subject}
mergeGoPayExtras(bm, req.Extra, "reqsn", "trxamt", "body", "method", "pay_method", "trade_type")
method := strings.ToLower(firstAny(req.Extra, "method", "pay_method", "trade_type"))
if method == "scan" || method == "micropay" || method == "barcode" {
bm.Set("authcode", firstAny(req.Extra, "authcode", "auth_code"))
bm.SetBodyMap("terminfo", func(info gopay.BodyMap) {
info.Set("devicetype", firstAnyOr(req.Extra, "10", "device_type", "devicetype"))
info.Set("termno", firstAnyOr(req.Extra, "00000001", "termno", "terminal_no"))
})
rsp, callErr := client.ScanPay(ctx, bm)
if callErr != nil {
return nil, callErr
}
if rsp == nil {
return nil, errors.New("通联支付扫码下单响应为空")
}
return &biz.PaymentResult{Provider: biz.PaymentAllinPay, Status: normalizeAllinState(rsp.TrxStatus), TradeNo: req.TradeNo, ProviderTradeNo: rsp.Trxid, Payload: mustJSON(rsp)}, nil
}
if method == "native" || method == "qrcode" {
bm.Set("expiretime", firstAny(req.Extra, "expiretime", "expire_time"))
rsp, callErr := client.NativePay(ctx, bm)
if callErr != nil {
return nil, callErr
}
if rsp == nil {
return nil, errors.New("通联支付下单响应为空")
}
return &biz.PaymentResult{Provider: biz.PaymentAllinPay, Status: "created", TradeNo: req.TradeNo, ProviderTradeNo: rsp.ReqSn, QueryID: rsp.PayInfo, Payload: mustJSON(rsp)}, nil
}
payType := firstAny(req.Extra, "paytype", "pay_type")
if payType == "" {
payType = firstAny(c, "paytype", "pay_type")
}
if payType == "" {
payType = allinpay.PayTypeWXJS
}
bm.Set("paytype", payType)
rsp, err := client.Pay(ctx, bm)
if err != nil {
return nil, err
}
if rsp == nil {
return nil, errors.New("通联支付下单响应为空")
}
return &biz.PaymentResult{Provider: biz.PaymentAllinPay, Status: "created", TradeNo: req.TradeNo, ProviderTradeNo: rsp.Trxid, QueryID: rsp.PayInfo, Payload: mustJSON(rsp)}, nil
}
func (a *allinpayAdapter) Query(ctx context.Context, tradeNo string, c map[string]any) (*biz.PaymentResult, error) {
client, err := a.client(c)
if err != nil {
return nil, err
}
orderType := firstAny(c, "query_order_type", "order_type")
if orderType == "" {
orderType = allinpay.OrderTypeReqSN
}
rsp, err := client.Query(ctx, orderType, tradeNo)
if err != nil {
return nil, err
}
if rsp == nil {
return nil, errors.New("通联支付查单响应为空")
}
result := &biz.PaymentResult{Provider: biz.PaymentAllinPay, Status: normalizeAllinState(rsp.TrxStatus), TradeNo: rsp.Reqsn, ProviderTradeNo: rsp.Trxid, Currency: strings.ToUpper(firstAny(c, "currency")), Payload: mustJSON(rsp)}
if result.TradeNo == "" {
result.TradeNo = tradeNo
}
if rsp.TrxAmt != "" {
result.Amount, err = parseIntegerAmount(rsp.TrxAmt)
if err != nil {
if scale := configuredInt64(c, "amount_scale", 100); scale != 1 {
result.Amount, err = parseDecimalAmount(rsp.TrxAmt, scale)
}
}
}
if result.Currency == "" {
result.Currency = "CNY"
}
return result, err
}
func (a *allinpayAdapter) Refund(ctx context.Context, req *biz.PaymentRefundRequest, c map[string]any) (*biz.PaymentResult, error) {
if req == nil {
return nil, errors.New("通联支付退款请求为空")
}
client, err := a.client(c)
if err != nil {
return nil, err
}
bm := gopay.BodyMap{"reqsn": req.RefundNo, "trxamt": req.Amount, "oldreqsn": req.TradeNo, "remark": firstAny(c, "refund_remark", "remark")}
mergeGoPayConfigExtras(bm, c, "refund_extra", "reqsn", "trxamt", "oldreqsn", "remark")
rsp, err := client.Refund(ctx, bm)
if err != nil {
return nil, err
}
if rsp == nil {
return nil, errors.New("通联支付退款响应为空")
}
refundAmount, _ := parseIntegerAmount(rsp.Fee)
if refundAmount == 0 {
refundAmount = req.Amount
}
return &biz.PaymentResult{Provider: biz.PaymentAllinPay, Status: normalizeRefundState(rsp.TrxStatus), TradeNo: req.TradeNo, ProviderTradeNo: rsp.Trxid, Amount: refundAmount, Currency: strings.ToUpper(firstAny(c, "currency")), Payload: mustJSON(rsp)}, nil
}
func (a *allinpayAdapter) Callback(_ context.Context, _ *biz.PaymentCallback, _ map[string]any) (*biz.PaymentResult, error) {
return nil, errors.New("通联支付回调没有可复用的 GoPay 验签器,请改用主动查单")
}
+213
View File
@@ -0,0 +1,213 @@
package payment
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"kra/internal/biz"
gopayApple "github.com/go-pay/gopay/apple"
"github.com/google/uuid"
)
type appleAdapter struct{}
func (a *appleAdapter) Create(_ context.Context, req *biz.PaymentRequest, _ map[string]any) (*biz.PaymentResult, error) {
productID := ""
if req.Extra != nil {
productID, _ = req.Extra["product_id"].(string)
}
if productID == "" {
return nil, errors.New("Apple 内购缺少 extra.product_id")
}
if _, err := uuid.Parse(req.TradeNo); err != nil {
return nil, errors.New("Apple 内购 tradeNo 必须是用于 appAccountToken 的 UUID")
}
payload, _ := json.Marshal(map[string]any{"product_id": productID, "app_account_token": req.TradeNo, "client_only": true, "trade_no": req.TradeNo})
return &biz.PaymentResult{Provider: biz.PaymentApple, Status: "client_pending", TradeNo: req.TradeNo, Payload: payload}, nil
}
func (a *appleAdapter) Query(ctx context.Context, transactionID string, c map[string]any) (*biz.PaymentResult, error) {
transactionID = strings.TrimSpace(transactionID)
if transactionID == "" {
return nil, errors.New("Apple 查询缺少 transactionId")
}
client, err := gopayApple.NewClient(
text(c, "issuer_id"),
text(c, "bundle_id"),
text(c, "key_id"),
text(c, "private_key"),
!strings.EqualFold(text(c, "environment"), "sandbox"),
)
if err != nil {
return nil, err
}
response, err := client.GetTransactionInfo(ctx, transactionID)
if err != nil {
return nil, err
}
if response == nil || response.SignedTransactionInfo == "" {
return nil, errors.New("Apple 查询响应缺少 signedTransactionInfo")
}
transaction, err := response.DecodeSignedTransaction()
if err != nil {
return nil, err
}
claims, err := appleClaims(transaction)
if err != nil {
return nil, err
}
if bundleID := text(c, "bundle_id"); bundleID != "" && fmt.Sprint(claims["bundleId"]) != bundleID {
return nil, errors.New("Apple 交易 bundleId 不匹配")
}
if err = validateAppleEnvironment(claims, c); err != nil {
return nil, err
}
payload, _ := json.Marshal(claims)
result, err := applePaymentResult(transactionID, claims, payload, c)
if err != nil {
return nil, err
}
if result.ProviderTradeNo == "" {
return nil, errors.New("Apple 交易缺少 transactionId")
}
return result, nil
}
func (a *appleAdapter) Refund(context.Context, *biz.PaymentRefundRequest, map[string]any) (*biz.PaymentResult, error) {
return nil, errors.New("Apple 内购退款由 App Store 管理,服务端不提供主动退款接口")
}
func (a *appleAdapter) Callback(_ context.Context, callback *biz.PaymentCallback, c map[string]any) (*biz.PaymentResult, error) {
if callback == nil {
return nil, errors.New("Apple 回调为空")
}
var body struct {
SignedPayload string `json:"signedPayload"`
}
if err := json.Unmarshal(callback.Body, &body); err != nil {
return nil, err
}
if body.SignedPayload == "" {
return nil, errors.New("Apple 回调缺少 signedPayload")
}
notification, err := gopayApple.DecodeSignedPayload(body.SignedPayload)
if err != nil {
return nil, err
}
if notificationID := strings.TrimSpace(notification.NotificationUUID); notificationID != "" {
if callback.Query == nil {
callback.Query = map[string]string{}
}
callback.Query["event_id"] = notificationID
}
if notification.Data == nil {
return nil, errors.New("Apple 回调缺少 data")
}
transaction, err := notification.DecodeTransactionInfo()
if err != nil {
return nil, err
}
claims, err := appleClaims(transaction)
if err != nil {
return nil, err
}
if bundleID := text(c, "bundle_id"); bundleID != "" && fmt.Sprint(claims["bundleId"]) != bundleID {
return nil, errors.New("Apple 回调 bundleId 不匹配")
}
if err = validateAppleEnvironment(claims, c); err != nil {
return nil, err
}
payload, _ := json.Marshal(claims)
tradeNo := strings.TrimSpace(fmt.Sprint(claims["appAccountToken"]))
transactionID := strings.TrimSpace(fmt.Sprint(claims["transactionId"]))
if tradeNo == "" || tradeNo == "<nil>" || transactionID == "" || transactionID == "<nil>" {
return nil, errors.New("Apple 回调缺少 appAccountToken 或 transactionId")
}
if _, parseErr := uuid.Parse(tradeNo); parseErr != nil {
return nil, errors.New("Apple 回调 appAccountToken 不是有效 UUID")
}
return &biz.PaymentResult{Provider: biz.PaymentApple, Status: "pending", TradeNo: tradeNo, ProviderTradeNo: transactionID, QueryID: transactionID, Payload: payload}, nil
}
func appleClaims(value any) (map[string]any, error) {
payload, err := json.Marshal(value)
if err != nil {
return nil, err
}
claims := map[string]any{}
if err := json.Unmarshal(payload, &claims); err != nil {
return nil, err
}
return claims, nil
}
func applePaymentResult(transactionID string, claims map[string]any, payload []byte, c map[string]any) (*biz.PaymentResult, error) {
status := "success"
if fmt.Sprint(claims["revocationDate"]) != "" && fmt.Sprint(claims["revocationDate"]) != "<nil>" {
status = "failed"
}
providerTradeNo := strings.TrimSpace(fmt.Sprint(claims["transactionId"]))
if providerTradeNo == "" || providerTradeNo == "<nil>" || providerTradeNo != transactionID {
return nil, errors.New("Apple 查询返回的 transactionId 不匹配")
}
tradeNo := strings.TrimSpace(fmt.Sprint(claims["appAccountToken"]))
if tradeNo == "" || tradeNo == "<nil>" {
return nil, errors.New("Apple 交易缺少 appAccountToken")
}
if _, err := uuid.Parse(tradeNo); err != nil {
return nil, errors.New("Apple 交易 appAccountToken 不是有效 UUID")
}
result := &biz.PaymentResult{Provider: biz.PaymentApple, Status: status, TradeNo: tradeNo, ProviderTradeNo: providerTradeNo, QueryID: providerTradeNo, Payload: payload}
if status != "success" {
return result, nil
}
result.Currency = strings.ToUpper(strings.TrimSpace(fmt.Sprint(claims["currency"])))
if result.Currency == "<nil>" {
result.Currency = ""
}
if result.Currency == "" {
return nil, errors.New("Apple 交易缺少 currency")
}
price, err := parseIntegerAmount(strings.TrimSpace(fmt.Sprint(claims["price"])))
if err != nil {
return nil, fmt.Errorf("解析 Apple 交易 price: %w", err)
}
divisor := applePriceDivisor(c, result.Currency)
if divisor <= 0 || price%divisor != 0 {
return nil, errors.New("Apple price_divisor 未配置或交易金额无法精确换算到最小货币单位")
}
result.Amount = price / divisor
result.PayerPaidAmount = result.Amount
result.CashPaidAmount = result.Amount
result.SettlementAmount = result.Amount
result.PayerCurrency = result.Currency
result.AmountBreakdownKnown = true
return result, nil
}
func applePriceDivisor(c map[string]any, currency string) int64 {
if values, ok := c["price_divisors"].(map[string]any); ok {
for key, value := range values {
if strings.EqualFold(strings.TrimSpace(key), currency) {
return configuredInt64(map[string]any{"value": value}, "value", 0)
}
}
}
return configuredInt64(c, "price_divisor", 0)
}
func validateAppleEnvironment(claims map[string]any, c map[string]any) error {
actual := strings.ToLower(strings.TrimSpace(fmt.Sprint(claims["environment"])))
if actual == "" || actual == "<nil>" {
return errors.New("Apple 交易缺少 environment")
}
expected := "production"
if strings.EqualFold(text(c, "environment"), "sandbox") {
expected = "sandbox"
}
if actual != expected {
return errors.New("Apple 交易 environment 不匹配")
}
return nil
}
+230
View File
@@ -0,0 +1,230 @@
package payment
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"kra/internal/biz"
"github.com/go-pay/gopay"
"github.com/go-pay/gopay/douyin"
)
type douyinAdapter struct{}
func (a *douyinAdapter) client(c map[string]any) (*douyin.Client, error) {
client, err := douyin.NewClient(text(c, "merchant_id"), text(c, "serial_no"), text(c, "api_key"), text(c, "private_key"))
if err != nil {
return nil, err
}
platformSerial := firstAny(c, "platform_serial_no", "platform_cert_serial")
if cert := text(c, "platform_cert"); cert != "" {
if platformSerial == "" {
return nil, errors.New("抖音支付缺少 platform_serial_no")
}
if err := client.SetPlatformCert([]byte(cert), platformSerial); err != nil {
return nil, err
}
}
if proxy := text(c, "proxy_url"); proxy != "" {
client.SetProxyHost(proxy)
}
return client, nil
}
func (a *douyinAdapter) Create(ctx context.Context, req *biz.PaymentRequest, c map[string]any) (*biz.PaymentResult, error) {
client, err := a.client(c)
if err != nil {
return nil, err
}
bm := gopay.BodyMap{}
bm.Set("appid", text(c, "app_id"))
bm.Set("description", req.Subject)
bm.Set("out_trade_no", req.TradeNo)
bm.Set("notify_url", req.NotifyURL)
bm.SetBodyMap("amount", func(amount gopay.BodyMap) {
amount.Set("total", req.Amount).Set("currency", strings.ToUpper(req.Currency))
})
mergeGoPayExtras(bm, req.Extra, "appid", "description", "out_trade_no", "notify_url", "amount", "mchid", "method", "pay_method", "trade_type", "channel")
tradeType := strings.ToLower(firstAny(req.Extra, "trade_type", "pay_type", "channel"))
if tradeType == "" {
tradeType = strings.ToLower(text(c, "trade_type"))
}
switch tradeType {
case "app", "app_pay", "apporder":
rsp, callErr := client.AppOrder(ctx, bm)
if callErr != nil {
return nil, callErr
}
if rsp == nil || rsp.Response == nil || rsp.Code != douyin.Success {
return nil, douyinError(rsp)
}
payload, _ := json.Marshal(rsp.Response)
return &biz.PaymentResult{Provider: biz.PaymentDouyin, Status: "client_pending", TradeNo: req.TradeNo, QueryID: rsp.Response.PrepayId, Payload: payload}, nil
case "h5", "h5_pay":
rsp, callErr := client.H5Order(ctx, bm)
if callErr != nil {
return nil, callErr
}
if rsp == nil || rsp.Response == nil || rsp.Code != douyin.Success {
return nil, douyinError(rsp)
}
payload, _ := json.Marshal(rsp.Response)
return &biz.PaymentResult{Provider: biz.PaymentDouyin, Status: "client_pending", TradeNo: req.TradeNo, QueryID: rsp.Response.H5Url, Payload: payload}, nil
case "native", "qr", "qrcode":
rsp, callErr := client.NativeOrder(ctx, bm)
if callErr != nil {
return nil, callErr
}
if rsp == nil || rsp.Response == nil || rsp.Code != douyin.Success {
return nil, douyinError(rsp)
}
payload, _ := json.Marshal(rsp.Response)
return &biz.PaymentResult{Provider: biz.PaymentDouyin, Status: "client_pending", TradeNo: req.TradeNo, QueryID: rsp.Response.CodeUrl, Payload: payload}, nil
default:
rsp, callErr := client.JsapiOrder(ctx, bm)
if callErr != nil {
return nil, callErr
}
if rsp == nil || rsp.Response == nil || rsp.Code != douyin.Success {
return nil, douyinError(rsp)
}
payload, _ := json.Marshal(rsp.Response)
return &biz.PaymentResult{Provider: biz.PaymentDouyin, Status: "client_pending", TradeNo: req.TradeNo, QueryID: rsp.Response.PrepayId, Payload: payload}, nil
}
}
func (a *douyinAdapter) Query(ctx context.Context, tradeNo string, c map[string]any) (*biz.PaymentResult, error) {
client, err := a.client(c)
if err != nil {
return nil, err
}
rsp, err := client.OrderQueryByOutTradeNo(ctx, tradeNo)
if err != nil {
return nil, err
}
if rsp == nil || rsp.Response == nil || rsp.Code != douyin.Success {
return nil, douyinError(rsp)
}
order := rsp.Response
result := &biz.PaymentResult{Provider: biz.PaymentDouyin, Status: normalizeDouyinState(order.TradeState), TradeNo: order.OutTradeNo, ProviderTradeNo: order.TransactionId, Payload: mustJSON(order)}
if result.TradeNo == "" {
result.TradeNo = tradeNo
}
if order.Amount != nil {
result.Amount = int64(order.Amount.Total)
result.Currency = strings.ToUpper(order.Amount.Currency)
result.PayerPaidAmount = int64(order.Amount.PayerTotal)
result.PayerCurrency = strings.ToUpper(order.Amount.PayerCurrency)
if result.PayerCurrency == "" {
result.PayerCurrency = result.Currency
}
if result.PayerPaidAmount > 0 && result.Amount >= result.PayerPaidAmount {
result.CashPaidAmount = result.PayerPaidAmount
result.DiscountAmount = result.Amount - result.PayerPaidAmount
result.AmountBreakdownKnown = true
}
}
return result, nil
}
func (a *douyinAdapter) Refund(ctx context.Context, req *biz.PaymentRefundRequest, c map[string]any) (*biz.PaymentResult, error) {
if req == nil {
return nil, errors.New("抖音支付退款请求为空")
}
client, err := a.client(c)
if err != nil {
return nil, err
}
bm := gopay.BodyMap{}
bm.Set("out_trade_no", req.TradeNo).Set("out_refund_no", req.RefundNo)
bm.SetBodyMap("amount", func(m gopay.BodyMap) {
m.Set("refund", req.Amount).Set("total", req.TotalAmount).Set("currency", strings.ToUpper(req.Currency))
})
mergeGoPayConfigExtras(bm, c, "refund_extra", "out_trade_no", "out_refund_no", "amount", "mchid")
rsp, err := client.Refund(ctx, bm)
if err != nil {
return nil, err
}
if rsp == nil || rsp.Response == nil || rsp.Code != douyin.Success {
return nil, douyinError(rsp)
}
r := rsp.Response
result := &biz.PaymentResult{Provider: biz.PaymentDouyin, Status: normalizeRefundState(r.Status), TradeNo: r.OutTradeNo, ProviderTradeNo: r.RefundId, Payload: mustJSON(r)}
if result.TradeNo == "" {
result.TradeNo = req.TradeNo
}
if r.Amount != nil {
result.Amount = int64(r.Amount.Refund)
result.Currency = strings.ToUpper(r.Amount.Currency)
}
return result, nil
}
func (a *douyinAdapter) Callback(ctx context.Context, callback *biz.PaymentCallback, c map[string]any) (*biz.PaymentResult, error) {
if callback == nil {
return nil, errors.New("抖音支付回调为空")
}
request, err := callbackRequest(callback)
if err != nil {
return nil, err
}
notify, err := douyin.ParseNotify(request)
if err != nil {
return nil, err
}
client, err := a.client(c)
if err != nil {
return nil, err
}
if cert := text(c, "platform_cert"); cert == "" {
return nil, errors.New("抖音支付回调缺少 platform_cert")
}
if err := notify.VerifySignByPKMap(client.PlatformCertMap()); err != nil {
return nil, err
}
if strings.EqualFold(notify.EventType, douyin.EventTransactionSuccess) {
pay, err := notify.DecryptPayCipherText(text(c, "api_key"))
if err != nil {
return nil, err
}
result := &biz.PaymentResult{Provider: biz.PaymentDouyin, Status: normalizeDouyinState(pay.TradeState), TradeNo: pay.OutTradeNo, ProviderTradeNo: pay.TransactionId, Payload: mustJSON(pay), EventID: notify.Id}
if pay.Amount != nil {
result.Amount = int64(pay.Amount.Total)
result.Currency = strings.ToUpper(pay.Amount.Currency)
result.PayerPaidAmount = int64(pay.Amount.PayerTotal)
result.PayerCurrency = strings.ToUpper(pay.Amount.PayerCurrency)
if result.PayerCurrency == "" {
result.PayerCurrency = result.Currency
}
if result.PayerPaidAmount > 0 && result.Amount >= result.PayerPaidAmount {
result.CashPaidAmount = result.PayerPaidAmount
result.DiscountAmount = result.Amount - result.PayerPaidAmount
result.AmountBreakdownKnown = true
}
}
return result, nil
}
if strings.HasPrefix(strings.ToUpper(notify.EventType), "REFUND.") {
refund, err := notify.DecryptRefundCipherText(text(c, "api_key"))
if err != nil {
return nil, err
}
status := "pending"
if strings.EqualFold(refund.RefundStatus, douyin.RefundStatusSuccess) {
status = "success"
} else if strings.EqualFold(refund.RefundStatus, douyin.RefundStatusClosed) || strings.EqualFold(refund.RefundStatus, douyin.RefundStatusAbnormal) {
status = "failed"
}
result := &biz.PaymentResult{Provider: biz.PaymentDouyin, Status: status, TradeNo: refund.OutTradeNo, ProviderTradeNo: refund.RefundId, Payload: mustJSON(refund), EventID: notify.Id}
if refund.Amount != nil {
result.Amount = int64(refund.Amount.Refund)
result.Currency = strings.ToUpper(firstAny(c, "currency"))
}
return result, nil
}
return nil, fmt.Errorf("抖音支付不支持回调事件 %q", notify.EventType)
}
+55
View File
@@ -0,0 +1,55 @@
package payment
import (
"crypto/x509"
"encoding/base64"
"encoding/pem"
"strings"
"github.com/go-pay/gopay"
)
// toGoPayBodyMap keeps provider protocol types inside the data layer while
// allowing GoPay's signing and notification helpers to be reused.
func toGoPayBodyMap(values map[string]string) gopay.BodyMap {
bm := make(gopay.BodyMap, len(values))
for key, value := range values {
bm[key] = value
}
return bm
}
func cloneGoPayBodyMap(values gopay.BodyMap) gopay.BodyMap {
clone := make(gopay.BodyMap, len(values))
for key, value := range values {
clone[key] = value
}
return clone
}
func fromGoPayBodyMap(values gopay.BodyMap) map[string]string {
result := make(map[string]string, len(values))
for key := range values {
result[key] = values.GetString(key)
}
return result
}
// goPayAlipayKey converts the PEM-friendly configuration accepted by this
// service into GoPay's raw base64 key format. GoPay's Alipay helpers add their
// own PEM envelope internally.
func goPayAlipayKey(value string, public bool) string {
value = strings.TrimSpace(value)
if block, _ := pem.Decode([]byte(value)); block != nil {
der := block.Bytes
if public && block.Type == "CERTIFICATE" {
if cert, err := x509.ParseCertificate(block.Bytes); err == nil {
if pub, err := x509.MarshalPKIXPublicKey(cert.PublicKey); err == nil {
der = pub
}
}
}
return base64.StdEncoding.EncodeToString(der)
}
return strings.Join(strings.Fields(value), "")
}
+157
View File
@@ -0,0 +1,157 @@
package payment
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"strings"
"kra/internal/biz"
"github.com/go-pay/gopay"
"github.com/go-pay/gopay/douyin"
)
// The adapters in this package intentionally keep GoPay request/response
// models below the data boundary. The business layer only sees PaymentResult.
func callbackRequest(callback *biz.PaymentCallback) (*http.Request, error) {
request, err := http.NewRequest(http.MethodPost, "http://payment-callback.local", bytes.NewReader(callback.Body))
if err != nil {
return nil, err
}
for key, value := range callback.Headers {
request.Header.Set(key, value)
}
return request, nil
}
func mergeGoPayExtras(dst gopay.BodyMap, src map[string]any, protected ...string) {
protectedKeys := make(map[string]struct{}, len(protected))
for _, key := range protected {
protectedKeys[key] = struct{}{}
}
for key, value := range src {
if _, ok := protectedKeys[key]; ok {
continue
}
dst[key] = value
}
}
func mergeGoPayConfigExtras(dst gopay.BodyMap, config map[string]any, key string, protected ...string) {
extra, ok := config[key].(map[string]any)
if !ok || extra == nil {
return
}
mergeGoPayExtras(dst, extra, protected...)
}
func anyConfig(c map[string]any, keys ...string) any {
for _, key := range keys {
if value, ok := c[key]; ok && fmt.Sprint(value) != "" {
return value
}
}
return nil
}
func firstAnyOr(values map[string]any, fallback string, keys ...string) string {
if value := firstAny(values, keys...); value != "" {
return value
}
return fallback
}
func mustJSON(value any) []byte {
encoded, _ := json.Marshal(value)
return encoded
}
func douyinError(rsp any) error {
return fmt.Errorf("抖音支付请求失败: %v", rsp)
}
func qqOK(returnCode, resultCode string) bool {
return strings.EqualFold(returnCode, "SUCCESS") && (resultCode == "" || strings.EqualFold(resultCode, "SUCCESS"))
}
func rspMap(value any) map[string]string {
encoded, _ := json.Marshal(value)
var object map[string]any
_ = json.Unmarshal(encoded, &object)
result := make(map[string]string, len(object))
for key, val := range object {
result[key] = fmt.Sprint(val)
}
return result
}
func normalizeDouyinState(state string) string {
switch strings.ToUpper(strings.TrimSpace(state)) {
case douyin.TradeStateSuccess:
return "success"
case douyin.TradeStatePayErr:
return "failed"
case douyin.TradeStateClosed:
return "failed"
default:
return "pending"
}
}
func normalizeRefundState(state string) string {
switch strings.ToUpper(strings.TrimSpace(state)) {
case douyin.RefundStatusSuccess, "REFUNDED":
return "success"
case douyin.RefundStatusClosed, douyin.RefundStatusAbnormal, "FAILED":
return "failed"
default:
return "pending"
}
}
func normalizeQQState(state string) string {
switch strings.ToUpper(strings.TrimSpace(state)) {
case "SUCCESS", "REFUND":
return "success"
case "CLOSED", "PAYERROR", "REVOKED", "NOPAY":
return "failed"
default:
return "pending"
}
}
func normalizeAllinState(state string) string {
switch strings.ToUpper(strings.TrimSpace(state)) {
case "SUCCESS", "0000", "2000", "3040":
return "success"
case "FAIL", "FAILED", "3000", "3045":
return "failed"
default:
return "pending"
}
}
func normalizeLakalaState(state string) string {
switch strings.ToUpper(strings.TrimSpace(state)) {
case "SUCCESS", "PAID", "PAY_SUCCESS", "1":
return "success"
case "CLOSED", "FAILED", "FAIL", "CANCELLED":
return "failed"
default:
return "pending"
}
}
func normalizeSaobeiState(state string) string {
switch strings.ToUpper(strings.TrimSpace(state)) {
case "SUCCESS":
return "success"
case "CLOSED", "PAYERROR", "REVOKED", "FAILED", "NOPAY":
return "failed"
default:
return "pending"
}
}
+349
View File
@@ -0,0 +1,349 @@
package payment
import (
"context"
"crypto"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"kra/internal/biz"
"kra/pkg/paymentkit"
gopayAlipay "github.com/go-pay/gopay/alipay"
gopayWechatV3 "github.com/go-pay/gopay/wechat/v3"
)
func testRSAKeyPair(t *testing.T) (*rsa.PrivateKey, string, string) {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
privatePEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
publicDER, err := x509.MarshalPKIXPublicKey(&key.PublicKey)
if err != nil {
t.Fatal(err)
}
publicPEM := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: publicDER})
return key, string(privatePEM), string(publicPEM)
}
func testRSA2Sign(t *testing.T, key *rsa.PrivateKey, data []byte) string {
t.Helper()
digest := sha256.Sum256(data)
signature, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, digest[:])
if err != nil {
t.Fatal(err)
}
return base64.StdEncoding.EncodeToString(signature)
}
func TestAlipayGoPayRequestParamHonorsGatewayAndVerifiesResponse(t *testing.T) {
platformKey, privatePEM, publicPEM := testRSAKeyPair(t)
var requestForm url.Values
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("read request: %v", err)
return
}
requestForm, err = url.ParseQuery(string(body))
if err != nil {
t.Errorf("parse request: %v", err)
return
}
if requestForm.Get("method") != "alipay.trade.query" {
t.Errorf("method = %q", requestForm.Get("method"))
}
if !strings.Contains(requestForm.Get("biz_content"), `"out_trade_no":"T-ALIPAY"`) {
t.Errorf("biz_content = %q", requestForm.Get("biz_content"))
}
ok, err := gopayAlipay.VerifySyncSign(goPayAlipayKey(publicPEM, true), alipaySignContent(requestForm), requestForm.Get("sign"))
if err != nil || !ok {
t.Errorf("request signature valid = %v, err = %v", ok, err)
}
response := []byte(`{"code":"10000","msg":"Success","trade_no":"P-ALIPAY","out_trade_no":"T-ALIPAY","trade_status":"TRADE_SUCCESS","total_amount":"10.00","buyer_pay_amount":"9.00","point_amount":"1.00","receipt_amount":"9.00","trans_currency":"CNY"}`)
payload := fmt.Sprintf(`{"alipay_trade_query_response":%s,"sign":%q}`, response, testRSA2Sign(t, platformKey, response))
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, payload)
}))
defer server.Close()
result, err := (&alipayAdapter{}).Query(context.Background(), "T-ALIPAY", map[string]any{
"app_id": "app-test",
"private_key": privatePEM,
"public_key": publicPEM,
"gateway_url": server.URL,
})
if err != nil {
t.Fatal(err)
}
if requestForm == nil || result.TradeNo != "T-ALIPAY" || result.ProviderTradeNo != "P-ALIPAY" || result.Amount != 1000 || result.Currency != "CNY" {
t.Fatalf("unexpected result: %+v, request: %v", result, requestForm)
}
if !result.AmountBreakdownKnown || result.PayerPaidAmount != 900 || result.CashPaidAmount != 800 || result.PointPaidAmount != 100 || result.DiscountAmount != 100 || result.SettlementAmount != 900 {
t.Fatalf("unexpected amount breakdown: %+v", result)
}
}
func TestAlipayGoPayCallbackVerificationDoesNotMutateFields(t *testing.T) {
key, _, publicPEM := testRSAKeyPair(t)
fields := map[string]string{
"app_id": "app-test",
"trade_no": "P-ALIPAY",
"out_trade_no": "T-ALIPAY",
"trade_status": "TRADE_SUCCESS",
"sign_type": "RSA2",
}
body := toGoPayBodyMap(fields)
body.Remove("sign_type")
sign, err := gopayAlipay.GetRsaSign(body, gopayAlipay.RSA2, key)
if err != nil {
t.Fatal(err)
}
fields["sign"] = sign
config := map[string]any{"app_id": "app-test", "public_key": publicPEM}
if err = verifyAlipay(fields, config); err != nil {
t.Fatal(err)
}
if fields["sign"] == "" || fields["sign_type"] != "RSA2" {
t.Fatalf("verification mutated source fields: %#v", fields)
}
fields["trade_no"] = "tampered"
if err = verifyAlipay(fields, config); err == nil {
t.Fatal("tampered callback unexpectedly verified")
}
}
func TestWechatV2GoPayCallbackVerificationAndSandboxAmount(t *testing.T) {
const key = "wechat-secret"
var requestValues map[string]string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("read request: %v", err)
return
}
requestValues, err = paymentkit.XMLValues(body)
if err != nil {
t.Errorf("parse request: %v", err)
return
}
if requestValues["total_fee"] != "123" {
t.Errorf("total_fee = %q, want 123", requestValues["total_fee"])
}
if err = verifyWechatV2(requestValues, key); err != nil {
t.Errorf("request signature: %v", err)
}
response := map[string]string{"return_code": "SUCCESS", "return_msg": "OK", "result_code": "SUCCESS", "prepay_id": "prepay"}
response["sign"] = wechatV2Sign(response, key, "MD5")
_, _ = w.Write(paymentkit.XMLEncode(response))
}))
defer server.Close()
result, err := (&wechatV2Adapter{}).Create(context.Background(), &biz.PaymentRequest{
TradeNo: "T-WECHAT", Subject: "subject", Amount: 123, Currency: "CNY", NotifyURL: "http://notify.local",
}, map[string]any{
"app_id": "wx-test", "merchant_id": "mch-test", "mch_key": key,
"environment": "sandbox", "create_url": server.URL,
})
if err != nil {
t.Fatal(err)
}
if result.Status != "created" || requestValues["total_fee"] != "123" {
t.Fatalf("unexpected create result: %+v, request: %#v", result, requestValues)
}
callbackValues := map[string]string{
"return_code": "SUCCESS", "result_code": "SUCCESS", "appid": "wx-test", "mch_id": "mch-test",
"out_trade_no": "T-WECHAT", "transaction_id": "P-WECHAT", "trade_state": "SUCCESS",
}
callbackValues["sign"] = wechatV2Sign(callbackValues, key, "MD5")
originalSign := callbackValues["sign"]
if err = verifyWechatV2(callbackValues, key); err != nil {
t.Fatal(err)
}
if callbackValues["sign"] != originalSign {
t.Fatal("verification mutated source values")
}
callbackBody := paymentkit.XMLEncode(callbackValues)
callbackResult, err := (&wechatV2Adapter{}).Callback(context.Background(), &biz.PaymentCallback{Body: callbackBody}, map[string]any{
"app_id": "wx-test", "merchant_id": "mch-test", "mch_key": key,
})
if err != nil {
t.Fatal(err)
}
if callbackResult.Status != "success" || callbackResult.ProviderTradeNo != "P-WECHAT" {
t.Fatalf("unexpected callback result: %+v", callbackResult)
}
}
type roundTripperFunc func(*http.Request) (*http.Response, error)
func (f roundTripperFunc) RoundTrip(request *http.Request) (*http.Response, error) {
return f(request)
}
func TestWechatV3EndpointTransportPreservesSignedPathAndQuery(t *testing.T) {
endpoint, err := url.Parse("https://payments.test.local")
if err != nil {
t.Fatal(err)
}
request, err := http.NewRequest(http.MethodGet, "https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/T-1?mchid=M-1", nil)
if err != nil {
t.Fatal(err)
}
request.Header.Set("Authorization", `WECHATPAY2-SHA256-RSA2048 mchid="M-1"`)
transport := &wechatV3EndpointTransport{
endpoint: endpoint,
base: roundTripperFunc(func(got *http.Request) (*http.Response, error) {
if got.URL.Scheme != "https" || got.URL.Host != "payments.test.local" {
t.Fatalf("endpoint = %s", got.URL)
}
if got.URL.EscapedPath() != "/v3/pay/transactions/out-trade-no/T-1" || got.URL.RawQuery != "mchid=M-1" {
t.Fatalf("signed target changed to %s", got.URL.RequestURI())
}
if got.Header.Get("Authorization") != request.Header.Get("Authorization") {
t.Fatal("authorization header changed while rewriting endpoint")
}
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody, Header: make(http.Header), Request: got}, nil
}),
}
if _, err = transport.RoundTrip(request); err != nil {
t.Fatal(err)
}
if request.URL.Host != "api.mch.weixin.qq.com" {
t.Fatal("transport mutated the original request")
}
}
func TestWechatV3PrepayResultUsesGoPayClientSigning(t *testing.T) {
_, privatePEM, _ := testRSAKeyPair(t)
client, err := gopayWechatV3.NewClientV3(
"mch-test",
"merchant-serial",
"01234567890123456789012345678901",
privatePEM,
)
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
name string
tradeType string
assert func(*testing.T, map[string]any)
}{
{
name: "jsapi", tradeType: "jsapi",
assert: func(t *testing.T, payload map[string]any) {
if payload["appId"] != "wx-test" || payload["package"] != "prepay_id=PREPAY-1" || strings.TrimSpace(fmt.Sprint(payload["paySign"])) == "" {
t.Fatalf("JSAPI payload = %#v", payload)
}
},
},
{
name: "applet", tradeType: "mini_program",
assert: func(t *testing.T, payload map[string]any) {
if payload["appId"] != "wx-test" || payload["package"] != "prepay_id=PREPAY-1" || strings.TrimSpace(fmt.Sprint(payload["paySign"])) == "" {
t.Fatalf("applet payload = %#v", payload)
}
},
},
{
name: "app", tradeType: "app",
assert: func(t *testing.T, payload map[string]any) {
if payload["appid"] != "wx-test" || payload["partnerid"] != "mch-test" || payload["prepayid"] != "PREPAY-1" || strings.TrimSpace(fmt.Sprint(payload["sign"])) == "" {
t.Fatalf("App payload = %#v", payload)
}
},
},
} {
t.Run(tc.name, func(t *testing.T) {
result, err := wechatV3PrepayCreateResult(client, "wx-test", tc.tradeType, "T-WECHAT-V3", &gopayWechatV3.PrepayRsp{
Code: gopayWechatV3.Success,
Response: &gopayWechatV3.Prepay{PrepayId: "PREPAY-1"},
})
if err != nil {
t.Fatal(err)
}
if result.TradeNo != "T-WECHAT-V3" || result.QueryID != "" || result.Status != "created" {
t.Fatalf("result = %+v", result)
}
var payload map[string]any
if err = json.Unmarshal(result.Payload, &payload); err != nil {
t.Fatal(err)
}
tc.assert(t, payload)
})
}
}
func TestWechatV3GoPayCallbackVerificationAndDecryption(t *testing.T) {
platformKey, merchantPrivatePEM, platformPublicPEM := testRSAKeyPair(t)
const (
apiV3Key = "01234567890123456789012345678901"
resourceNonce = "0123456789ab"
headerNonce = "callback-nonce"
timestamp = "1787068800"
serial = "ABCDEF123456"
)
plain := []byte(`{"appid":"wx-test","mchid":"mch-test","out_trade_no":"T-WECHAT-V3","transaction_id":"P-WECHAT-V3","trade_state":"SUCCESS"}`)
block, err := aes.NewCipher([]byte(apiV3Key))
if err != nil {
t.Fatal(err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
t.Fatal(err)
}
ciphertext := base64.StdEncoding.EncodeToString(gcm.Seal(nil, []byte(resourceNonce), plain, []byte("transaction")))
body, err := json.Marshal(map[string]any{
"id": "EVENT-WECHAT-V3", "event_type": "TRANSACTION.SUCCESS", "resource_type": "encrypt-resource", "summary": "payment success",
"resource": map[string]any{"algorithm": "AEAD_AES_256_GCM", "ciphertext": ciphertext, "associated_data": "transaction", "original_type": "transaction", "nonce": resourceNonce},
})
if err != nil {
t.Fatal(err)
}
signature := testRSA2Sign(t, platformKey, []byte(timestamp+"\n"+headerNonce+"\n"+string(body)+"\n"))
callback := &biz.PaymentCallback{
Body: body,
Headers: map[string]string{
gopayWechatV3.HeaderTimestamp: timestamp,
gopayWechatV3.HeaderNonce: headerNonce,
gopayWechatV3.HeaderSerial: serial,
gopayWechatV3.HeaderSignature: signature,
},
}
result, err := (&wechatV3Adapter{}).Callback(context.Background(), callback, map[string]any{
"api_v3_key": apiV3Key, "platform_cert": platformPublicPEM, "private_key": merchantPrivatePEM,
"platform_serial_no": strings.ToLower(serial), "serial_no": "merchant-serial", "app_id": "wx-test", "merchant_id": "mch-test",
})
if err != nil {
t.Fatal(err)
}
if result.Status != "success" || result.TradeNo != "T-WECHAT-V3" || result.ProviderTradeNo != "P-WECHAT-V3" {
t.Fatalf("unexpected callback result: %+v", result)
}
if callback.Query["event_id"] != "EVENT-WECHAT-V3" {
t.Fatalf("event id = %q", callback.Query["event_id"])
}
}
+138
View File
@@ -0,0 +1,138 @@
package payment
import (
"context"
"errors"
"fmt"
"strings"
"kra/internal/biz"
"github.com/go-pay/gopay"
"github.com/go-pay/gopay/lakala"
)
type lakalaAdapter struct{}
func (a *lakalaAdapter) client(c map[string]any) (*lakala.Client, error) {
return lakala.NewClient(text(c, "partner_code"), text(c, "credential_code"), !strings.EqualFold(text(c, "environment"), "sandbox"))
}
func (a *lakalaAdapter) Create(ctx context.Context, req *biz.PaymentRequest, c map[string]any) (*biz.PaymentResult, error) {
client, err := a.client(c)
if err != nil {
return nil, err
}
bm := gopay.BodyMap{"description": req.Subject, "price": req.Amount, "channel": firstAny(req.Extra, "channel", "pay_channel")}
if bm.GetString("channel") == gopay.NULL {
bm.Set("channel", firstAnyOr(c, "Wechat", "channel", "pay_channel"))
}
mergeGoPayExtras(bm, req.Extra, "description", "price", "channel", "pay_channel", "method", "pay_method", "trade_type")
method := strings.ToLower(firstAny(req.Extra, "method", "pay_method", "trade_type"))
if method == "h5" {
rsp, callErr := client.CreateH5PayOrder(ctx, req.TradeNo, bm)
if callErr != nil {
return nil, callErr
}
return lakalaCreateResult(req.TradeNo, rsp)
}
rsp, err := client.CreateJSAPIOrder(ctx, req.TradeNo, bm)
if err != nil {
return nil, err
}
return lakalaCreateResult(req.TradeNo, rsp)
}
func lakalaCreateResult(tradeNo string, rsp *lakala.PaymentRsp) (*biz.PaymentResult, error) {
if rsp == nil || !strings.EqualFold(rsp.ReturnCode, "SUCCESS") && !strings.EqualFold(rsp.ResultCode, "SUCCESS") {
return nil, fmt.Errorf("拉卡拉支付下单失败: %v", rsp)
}
return &biz.PaymentResult{Provider: biz.PaymentLakala, Status: "created", TradeNo: tradeNo, ProviderTradeNo: rsp.OrderId, QueryID: first(rspMap(rsp), "code_url", "pay_url", "sdk_params"), Payload: mustJSON(rsp)}, nil
}
func (a *lakalaAdapter) Query(ctx context.Context, tradeNo string, c map[string]any) (*biz.PaymentResult, error) {
client, err := a.client(c)
if err != nil {
return nil, err
}
orderID := firstAny(c, "order_id", "provider_order_id")
if orderID == "" {
orderID = tradeNo
}
rsp, err := client.OrderStatus(ctx, orderID)
if err != nil {
return nil, err
}
if rsp == nil || !strings.EqualFold(rsp.ReturnCode, "SUCCESS") && !strings.EqualFold(rsp.ResultCode, "SUCCESS") {
return nil, fmt.Errorf("拉卡拉支付查单失败: %v", rsp)
}
status := normalizeLakalaState(first(rspMap(rsp), "status", "channel_error_code"))
if rsp.ChannelOrderId != "" && status == "pending" {
status = "success"
}
returnedTradeNo := rsp.PartnerOrderId
if returnedTradeNo == "" {
returnedTradeNo = tradeNo
}
result := &biz.PaymentResult{Provider: biz.PaymentLakala, Status: status, TradeNo: returnedTradeNo, ProviderTradeNo: rsp.ChannelOrderId, Amount: int64(rsp.TotalFee), Currency: strings.ToUpper(rsp.Currency), Payload: mustJSON(rsp)}
if result.Currency == "" {
result.Currency = strings.ToUpper(firstAny(c, "currency", "default_currency"))
}
if result.Currency == "" {
result.Currency = "JPY"
}
if rsp.RealFee > 0 {
result.PayerPaidAmount = int64(rsp.RealFee)
result.CashPaidAmount = result.PayerPaidAmount
result.DiscountAmount = result.Amount - result.PayerPaidAmount
if result.DiscountAmount >= 0 {
result.AmountBreakdownKnown = true
}
}
return result, nil
}
func (a *lakalaAdapter) Refund(ctx context.Context, req *biz.PaymentRefundRequest, c map[string]any) (*biz.PaymentResult, error) {
if req == nil {
return nil, errors.New("拉卡拉支付退款请求为空")
}
client, err := a.client(c)
if err != nil {
return nil, err
}
orderID := firstAny(c, "order_id", "provider_order_id")
if orderID == "" {
orderID = req.TradeNo
}
bm := gopay.BodyMap{"fee": req.Amount}
if req.Currency != "" {
bm.Set("currency", strings.ToUpper(req.Currency))
}
mergeGoPayConfigExtras(bm, c, "refund_extra", "fee", "currency")
rsp, err := client.ApplyRefund(ctx, orderID, req.RefundNo, bm)
if err != nil {
return nil, err
}
if rsp == nil || !strings.EqualFold(rsp.ReturnCode, "SUCCESS") && !strings.EqualFold(rsp.ResultCode, "SUCCESS") {
return nil, fmt.Errorf("拉卡拉支付退款失败: %v", rsp)
}
return &biz.PaymentResult{Provider: biz.PaymentLakala, Status: "pending", TradeNo: req.TradeNo, ProviderTradeNo: rsp.RefundId, Amount: int64(rsp.Amount), Currency: strings.ToUpper(rsp.Currency), Payload: mustJSON(rsp)}, nil
}
func (a *lakalaAdapter) Callback(_ context.Context, callback *biz.PaymentCallback, c map[string]any) (*biz.PaymentResult, error) {
if callback == nil {
return nil, errors.New("拉卡拉支付回调为空")
}
request, err := callbackRequest(callback)
if err != nil {
return nil, err
}
notify, err := lakala.ParseNotify(request)
if err != nil {
return nil, err
}
if err := lakala.VerifySign(notify, text(c, "partner_code"), text(c, "credential_code")); err != nil {
return nil, err
}
return &biz.PaymentResult{Provider: biz.PaymentLakala, Status: "success", TradeNo: notify.PartnerOrderId, ProviderTradeNo: notify.ChannelOrderId, Amount: int64(notify.TotalFee), Currency: strings.ToUpper(notify.Currency), Payload: mustJSON(notify)}, nil
}
+129
View File
@@ -0,0 +1,129 @@
package payment
import (
"crypto/aes"
"crypto/cipher"
"encoding/base64"
"net/url"
"strings"
"testing"
"kra/internal/biz"
)
func TestWechatV2SignOfficialStyle(t *testing.T) {
values := map[string]string{
"appid": "wxd930ea5d5a258f4f", "mch_id": "10000100", "device_info": "1000",
"body": "test", "nonce_str": "ibuaiVcKdpRxkhJA",
}
got := wechatV2Sign(values, "192006250b4c09247ec02edce69f6a2d", "MD5")
if got != strings.ToUpper(got) || len(got) != 32 {
t.Fatalf("invalid WeChat v2 MD5 signature %q", got)
}
values["sign"] = got
if err := verifyWechatV2(values, "192006250b4c09247ec02edce69f6a2d"); err != nil {
t.Fatal(err)
}
}
func TestApplePaymentResultSeparatesMerchantAndProviderTradeNumbers(t *testing.T) {
result, err := applePaymentResult("1000000000001", map[string]any{
"transactionId": "1000000000001",
"appAccountToken": "123e4567-e89b-12d3-a456-426614174000",
"currency": "CNY",
"price": "1000",
}, nil, map[string]any{"price_divisor": "10"})
if err != nil {
t.Fatal(err)
}
if result.TradeNo != "123e4567-e89b-12d3-a456-426614174000" || result.ProviderTradeNo != "1000000000001" || result.QueryID != "1000000000001" {
t.Fatalf("unexpected Apple order identifiers: %+v", result)
}
}
func TestWechatV3Decrypt(t *testing.T) {
key := "01234567890123456789012345678901"
nonce := "0123456789ab"
aad := "transaction"
plain := []byte(`{"out_trade_no":"T1","trade_state":"SUCCESS"}`)
block, err := aes.NewCipher([]byte(key))
if err != nil {
t.Fatal(err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
t.Fatal(err)
}
ciphertext := base64.StdEncoding.EncodeToString(gcm.Seal(nil, []byte(nonce), plain, []byte(aad)))
got, err := decryptWechatV3(ciphertext, nonce, aad, key)
if err != nil {
t.Fatal(err)
}
if string(got) != string(plain) {
t.Fatalf("plaintext = %q", got)
}
}
func TestAlipaySignContentSortedAndExcludesSign(t *testing.T) {
values := url.Values{"method": {"alipay.trade.query"}, "app_id": {"123"}, "sign": {"ignored"}, "empty": {""}}
if got := alipaySignContent(values); got != "app_id=123&method=alipay.trade.query" {
t.Fatalf("sign content = %q", got)
}
}
func TestAlipayBreakdownWithPointPayment(t *testing.T) {
result := &biz.PaymentResult{Status: "success", Amount: 1000}
err := populateAlipayBreakdown(result, map[string]any{
"total_amount": "10.00", "buyer_pay_amount": "8.00", "point_amount": "2.00", "receipt_amount": "8.00",
})
if err != nil {
t.Fatal(err)
}
if !result.AmountBreakdownKnown || result.PayerPaidAmount != 800 || result.CashPaidAmount != 600 || result.PointPaidAmount != 200 || result.DiscountAmount != 200 || result.SettlementAmount != 800 {
t.Fatalf("unexpected Alipay breakdown: %+v", result)
}
}
func TestWechatV2BreakdownWithCoupon(t *testing.T) {
result := &biz.PaymentResult{Status: "success", Amount: 1000}
err := populateWechatV2Breakdown(result, map[string]string{"cash_fee": "900", "coupon_fee": "100", "settlement_total_fee": "950"})
if err != nil {
t.Fatal(err)
}
if !result.AmountBreakdownKnown || result.PayerPaidAmount != 900 || result.DiscountAmount != 100 || result.SettlementAmount != 950 {
t.Fatalf("unexpected WeChat v2 breakdown: %+v", result)
}
}
func TestWechatV3BreakdownWithPromotion(t *testing.T) {
result := &biz.PaymentResult{Status: "success", Amount: 1000}
err := populateWechatV3Breakdown(result, map[string]any{
"amount": map[string]any{"payer_total": float64(800), "settlement_amount": float64(780)},
"promotion_detail": []any{map[string]any{"amount": float64(200)}},
})
if err != nil {
t.Fatal(err)
}
if !result.AmountBreakdownKnown || result.PayerPaidAmount != 800 || result.CashPaidAmount != 800 || result.DiscountAmount != 200 || result.SettlementAmount != 780 {
t.Fatalf("unexpected WeChat v3 breakdown: %+v", result)
}
}
func TestVendorBreakdownUsesConfiguredUnits(t *testing.T) {
result := &biz.PaymentResult{Status: "success", Amount: 1000, Currency: "CNY"}
object := map[string]any{"data": map[string]any{
"payer": "8.00", "cash": "6.00", "points": "2.00", "discount": "2.00", "settlement": "7.80",
}}
config := map[string]any{
"query_amount_scale": "100", "query_payer_paid_amount_field": "data.payer", "query_cash_paid_amount_field": "data.cash",
"query_point_paid_amount_field": "data.points", "query_discount_amount_field": "data.discount",
"query_settlement_amount_field": "data.settlement", "query_payer_currency_field": "data.currency",
}
err := populateVendorBreakdown(result, object, config)
if err != nil {
t.Fatal(err)
}
if !result.AmountBreakdownKnown || result.PayerPaidAmount != 800 || result.CashPaidAmount != 600 || result.PointPaidAmount != 200 || result.DiscountAmount != 200 || result.SettlementAmount != 780 {
t.Fatalf("unexpected vendor breakdown: %+v", result)
}
}
+596
View File
@@ -0,0 +1,596 @@
package payment
import (
"context"
"encoding/json"
"errors"
"fmt"
"math"
"strconv"
"strings"
"kra/internal/biz"
"github.com/go-pay/gopay"
"github.com/go-pay/gopay/paypal"
)
type paypalAdapter struct{}
func (a *paypalAdapter) client(c map[string]any) (*paypal.Client, error) {
environment := strings.ToLower(strings.TrimSpace(text(c, "environment")))
isProd := environment != "sandbox"
options := []paypal.Option{paypal.WithoutAutoRefreshToken()}
if baseURL := strings.TrimRight(firstAny(c, "api_base_url", "base_url", "proxy_url"), "/"); baseURL != "" {
options = append(options, paypal.WithProxyUrl(baseURL, baseURL))
}
client, err := paypal.NewClient(text(c, "client_id"), text(c, "client_secret"), isProd, options...)
if err != nil {
return nil, fmt.Errorf("PayPal 客户端初始化失败: %w", err)
}
client.SetRequestHeader("Prefer", "return=representation")
return client, nil
}
func (a *paypalAdapter) Create(ctx context.Context, req *biz.PaymentRequest, c map[string]any) (*biz.PaymentResult, error) {
if req == nil {
return nil, errors.New("PayPal 下单参数为空")
}
client, err := a.client(c)
if err != nil {
return nil, err
}
currency := strings.ToUpper(strings.TrimSpace(req.Currency))
value, err := paypalFormatAmount(req.Amount, currency, c)
if err != nil {
return nil, err
}
purchaseUnit := &paypal.PurchaseUnit{
ReferenceId: req.TradeNo,
Description: req.Subject,
CustomId: req.TradeNo,
InvoiceId: req.TradeNo,
Amount: &paypal.Amount{CurrencyCode: currency, Value: value},
}
bm := gopay.BodyMap{
"intent": "CAPTURE",
"purchase_units": []*paypal.PurchaseUnit{purchaseUnit},
}
if applicationContext := paypalApplicationContext(req, c); len(applicationContext) > 0 {
bm["application_context"] = applicationContext
}
mergeGoPayExtras(bm, req.Extra, "intent", "purchase_units", "application_context")
client.SetRequestHeader("PayPal-Request-Id", req.TradeNo)
rsp, err := client.CreateOrder(ctx, bm)
if err != nil {
return nil, err
}
if rsp == nil || rsp.Code != paypal.Success || rsp.Response == nil {
return nil, paypalResponseError("下单", paypalResponseCode(rsp), paypalCreateError(rsp))
}
order := rsp.Response
if strings.TrimSpace(order.Id) == "" {
return nil, errors.New("PayPal 下单成功但缺少 order ID")
}
result, err := paypalOrderResult(order, order.Id, req.TradeNo, c)
if err != nil {
return nil, err
}
result.TradeNo = req.TradeNo
return result, nil
}
func (a *paypalAdapter) Query(ctx context.Context, orderID string, c map[string]any) (*biz.PaymentResult, error) {
orderID = strings.TrimSpace(orderID)
if orderID == "" {
return nil, errors.New("PayPal 查单缺少 order ID")
}
client, err := a.client(c)
if err != nil {
return nil, err
}
rsp, err := client.OrderDetail(ctx, orderID, nil)
if err != nil {
return nil, err
}
if rsp == nil || rsp.Code != paypal.Success || rsp.Response == nil {
return nil, paypalResponseError("查单", paypalResponseCode(rsp), paypalOrderError(rsp))
}
if rsp.Response.Id != "" && rsp.Response.Id != orderID {
return nil, errors.New("PayPal 查单响应的 order ID 不匹配")
}
return paypalOrderResult(rsp.Response, orderID, "", c)
}
func (a *paypalAdapter) Refund(ctx context.Context, req *biz.PaymentRefundRequest, c map[string]any) (*biz.PaymentResult, error) {
if req == nil {
return nil, errors.New("PayPal 退款请求为空")
}
client, err := a.client(c)
if err != nil {
return nil, err
}
captureID, err := paypalRefundCaptureID(ctx, client, req)
if err != nil {
return nil, err
}
currency := strings.ToUpper(strings.TrimSpace(req.Currency))
value, err := paypalFormatAmount(req.Amount, currency, c)
if err != nil {
return nil, err
}
bm := gopay.BodyMap{
"invoice_id": req.RefundNo,
"amount": &paypal.Amount{
CurrencyCode: currency,
Value: value,
},
}
if note := strings.TrimSpace(text(c, "refund_note")); note != "" {
bm["note_to_payer"] = note
}
mergeGoPayConfigExtras(bm, c, "refund_extra", "amount", "invoice_id", "capture_id", "order_id", "query_id", "provider_trade_no")
client.SetRequestHeader("PayPal-Request-Id", req.RefundNo)
rsp, err := client.PaymentCaptureRefund(ctx, captureID, bm)
if err != nil {
return nil, err
}
if rsp == nil || rsp.Code != paypal.Success || rsp.Response == nil {
return nil, paypalResponseError("退款", paypalResponseCode(rsp), paypalRefundError(rsp))
}
refund := rsp.Response
result := &biz.PaymentResult{
Provider: biz.PaymentPayPal,
Status: normalizePayPalRefundState(refund.Status),
TradeNo: req.TradeNo,
ProviderTradeNo: refund.Id,
Amount: req.Amount,
Currency: currency,
Payload: mustJSON(refund),
}
if refund.Amount != nil {
parsed, parseErr := paypalParseAmount(refund.Amount, c)
if parseErr != nil {
return nil, parseErr
}
result.Amount = parsed
result.Currency = strings.ToUpper(refund.Amount.CurrencyCode)
}
return result, nil
}
func (a *paypalAdapter) Callback(ctx context.Context, callback *biz.PaymentCallback, c map[string]any) (*biz.PaymentResult, error) {
if callback == nil || len(callback.Body) == 0 {
return nil, errors.New("PayPal 回调为空")
}
webhookID := strings.TrimSpace(text(c, "webhook_id"))
if webhookID == "" {
return nil, errors.New("PayPal 回调验签缺少 webhook_id")
}
var event paypal.WebhookEvent
if err := json.Unmarshal(callback.Body, &event); err != nil {
return nil, fmt.Errorf("PayPal 回调 JSON 无效: %w", err)
}
if strings.TrimSpace(event.Id) == "" || len(event.Resource) == 0 {
return nil, errors.New("PayPal 回调事件不完整")
}
var webhookEvent any
if err := json.Unmarshal(callback.Body, &webhookEvent); err != nil {
return nil, err
}
verifyBody := gopay.BodyMap{
"auth_algo": paypalCallbackHeader(callback, "Paypal-Auth-Algo"),
"cert_url": paypalCallbackHeader(callback, "Paypal-Cert-Url"),
"transmission_id": paypalCallbackHeader(callback, "Paypal-Transmission-Id"),
"transmission_sig": paypalCallbackHeader(callback, "Paypal-Transmission-Sig"),
"transmission_time": paypalCallbackHeader(callback, "Paypal-Transmission-Time"),
"webhook_id": webhookID,
"webhook_event": webhookEvent,
}
client, err := a.client(c)
if err != nil {
return nil, err
}
verified, err := client.VerifyWebhookSignature(ctx, verifyBody)
if err != nil {
return nil, err
}
if verified == nil || !strings.EqualFold(verified.VerificationStatus, "SUCCESS") {
return nil, errors.New("PayPal 回调签名校验失败")
}
result, err := paypalWebhookResult(&event)
if err != nil {
return nil, err
}
result.Payload = append([]byte(nil), callback.Body...)
result.EventID = event.Id
return result, nil
}
func paypalApplicationContext(req *biz.PaymentRequest, c map[string]any) map[string]any {
applicationContext := map[string]any{}
if configured, ok := req.Extra["application_context"].(map[string]any); ok {
for key, value := range configured {
applicationContext[key] = value
}
}
for _, key := range []string{"brand_name", "locale", "landing_page", "shipping_preference", "user_action"} {
if _, exists := applicationContext[key]; !exists {
if value := strings.TrimSpace(text(c, key)); value != "" {
applicationContext[key] = value
}
}
}
if returnURL := strings.TrimSpace(req.ReturnURL); returnURL != "" {
applicationContext["return_url"] = returnURL
}
cancelURL := firstAny(c, "cancel_url", "return_url")
if cancelURL == "" {
cancelURL = strings.TrimSpace(req.ReturnURL)
}
if cancelURL != "" {
applicationContext["cancel_url"] = cancelURL
}
return applicationContext
}
func paypalOrderResult(order *paypal.OrderDetail, queryID, fallbackTradeNo string, c map[string]any) (*biz.PaymentResult, error) {
if order == nil {
return nil, errors.New("PayPal 订单结果为空")
}
tradeNo := paypalOrderTradeNo(order)
if tradeNo == "" {
tradeNo = fallbackTradeNo
}
capture := paypalOrderCapture(order)
result := &biz.PaymentResult{
Provider: biz.PaymentPayPal,
Status: normalizePayPalOrderState(order.Status),
TradeNo: tradeNo,
QueryID: queryID,
Payload: mustJSON(order),
}
if capture != nil {
result.ProviderTradeNo = capture.Id
}
amount, currency, found, err := paypalOrderAmount(order, c)
if err != nil {
return nil, err
}
if found {
result.Amount = amount
result.Currency = currency
}
return result, nil
}
func paypalOrderTradeNo(order *paypal.OrderDetail) string {
if order == nil {
return ""
}
for _, unit := range order.PurchaseUnits {
if unit == nil {
continue
}
if value := firstNonEmpty(unit.InvoiceId, unit.CustomId, unit.ReferenceId); value != "" {
return value
}
}
return ""
}
func paypalOrderCapture(order *paypal.OrderDetail) *paypal.Capture {
var firstCapture *paypal.Capture
if order == nil {
return nil
}
for _, unit := range order.PurchaseUnits {
if unit == nil || unit.Payments == nil {
continue
}
for _, capture := range unit.Payments.Captures {
if capture == nil || strings.TrimSpace(capture.Id) == "" {
continue
}
if firstCapture == nil {
firstCapture = capture
}
if strings.EqualFold(capture.Status, "COMPLETED") {
return capture
}
}
}
return firstCapture
}
func paypalOrderAmount(order *paypal.OrderDetail, c map[string]any) (int64, string, bool, error) {
var total int64
var currency string
found := false
for _, unit := range order.PurchaseUnits {
if unit == nil || unit.Amount == nil || strings.TrimSpace(unit.Amount.Value) == "" {
continue
}
unitCurrency := strings.ToUpper(strings.TrimSpace(unit.Amount.CurrencyCode))
amount, err := paypalParseAmount(unit.Amount, c)
if err != nil {
return 0, "", false, err
}
if found && currency != unitCurrency {
return 0, "", false, errors.New("PayPal 订单包含不同币种的 purchase unit")
}
if amount > math.MaxInt64-total {
return 0, "", false, errors.New("PayPal 订单金额超出范围")
}
currency = unitCurrency
total += amount
found = true
}
return total, currency, found, nil
}
func paypalRefundCaptureID(ctx context.Context, client *paypal.Client, req *biz.PaymentRefundRequest) (string, error) {
if req == nil {
return "", errors.New("PayPal 退款请求为空")
}
if captureID := strings.TrimSpace(req.ProviderTradeNo); captureID != "" {
return captureID, nil
}
orderID := strings.TrimSpace(req.QueryID)
if orderID == "" {
return "", errors.New("PayPal 退款缺少已持久化的 capture ID")
}
rsp, err := client.OrderDetail(ctx, orderID, nil)
if err != nil {
return "", err
}
if rsp == nil || rsp.Code != paypal.Success || rsp.Response == nil {
return "", paypalResponseError("退款前查单", paypalResponseCode(rsp), paypalOrderError(rsp))
}
if rsp.Response.Id != "" && rsp.Response.Id != orderID {
return "", errors.New("PayPal 退款查单响应的 order ID 不匹配")
}
if returnedTradeNo := paypalOrderTradeNo(rsp.Response); returnedTradeNo == "" || returnedTradeNo != req.TradeNo {
return "", errors.New("PayPal 退款订单与本地商户订单号不匹配")
}
capture := paypalOrderCapture(rsp.Response)
if capture == nil || strings.TrimSpace(capture.Id) == "" {
return "", errors.New("PayPal 退款订单缺少 capture ID")
}
return capture.Id, nil
}
func paypalWebhookResult(event *paypal.WebhookEvent) (*biz.PaymentResult, error) {
var resource map[string]any
if err := json.Unmarshal(event.Resource, &resource); err != nil {
return nil, fmt.Errorf("PayPal 回调 resource 无效: %w", err)
}
eventType := strings.ToUpper(strings.TrimSpace(event.EventType))
tradeNo := firstNonEmpty(paypalMapString(resource, "invoice_id"), paypalMapString(resource, "custom_id"))
queryID := paypalNestedMapString(resource, "supplementary_data", "related_ids", "order_id")
providerTradeNo := ""
status := normalizePayPalCaptureState(paypalMapString(resource, "status"))
if strings.HasPrefix(eventType, "CHECKOUT.ORDER.") {
var order paypal.OrderDetail
if err := json.Unmarshal(event.Resource, &order); err != nil {
return nil, err
}
tradeNo = paypalOrderTradeNo(&order)
queryID = order.Id
if capture := paypalOrderCapture(&order); capture != nil {
providerTradeNo = capture.Id
}
status = normalizePayPalOrderState(order.Status)
} else {
providerTradeNo = paypalMapString(resource, "id")
}
if tradeNo == "" {
tradeNo = paypalPurchaseUnitTradeNo(resource)
}
if tradeNo == "" || queryID == "" {
return nil, errors.New("PayPal 回调缺少商户订单号或 order ID")
}
return &biz.PaymentResult{
Provider: biz.PaymentPayPal,
Status: status,
TradeNo: tradeNo,
ProviderTradeNo: providerTradeNo,
QueryID: queryID,
}, nil
}
func paypalPurchaseUnitTradeNo(resource map[string]any) string {
units, _ := resource["purchase_units"].([]any)
for _, value := range units {
unit, _ := value.(map[string]any)
if tradeNo := firstNonEmpty(paypalMapString(unit, "invoice_id"), paypalMapString(unit, "custom_id"), paypalMapString(unit, "reference_id")); tradeNo != "" {
return tradeNo
}
}
return ""
}
func paypalCallbackHeader(callback *biz.PaymentCallback, name string) string {
for key, value := range callback.Headers {
if strings.EqualFold(key, name) {
return strings.TrimSpace(value)
}
}
return ""
}
func paypalFormatAmount(amount int64, currency string, c map[string]any) (string, error) {
scale, err := paypalAmountScale(currency, c)
if err != nil {
return "", err
}
return formatDecimalAmount(amount, scale)
}
func paypalParseAmount(amount *paypal.Amount, c map[string]any) (int64, error) {
if amount == nil {
return 0, errors.New("PayPal 金额为空")
}
scale, err := paypalAmountScale(amount.CurrencyCode, c)
if err != nil {
return 0, err
}
return parseDecimalAmount(amount.Value, scale)
}
func paypalAmountScale(currency string, c map[string]any) (int64, error) {
currency = strings.ToUpper(strings.TrimSpace(currency))
for _, key := range []string{"amount_scales", "currency_scales"} {
if scales, ok := c[key].(map[string]any); ok {
for configuredCurrency, raw := range scales {
if !strings.EqualFold(configuredCurrency, currency) {
continue
}
scale, err := strconv.ParseInt(strings.TrimSpace(fmt.Sprint(raw)), 10, 64)
if err != nil || scale <= 0 {
return 0, fmt.Errorf("PayPal %s 金额换算比例无效", currency)
}
if _, err = formatDecimalAmount(scale, scale); err != nil {
return 0, fmt.Errorf("PayPal %s 金额换算比例无效: %w", currency, err)
}
return scale, nil
}
}
}
if scale := configuredInt64(c, "amount_scale", 0); scale > 0 {
if _, err := formatDecimalAmount(scale, scale); err != nil {
return 0, fmt.Errorf("PayPal 金额换算比例无效: %w", err)
}
return scale, nil
}
switch currency {
case "HUF", "JPY", "TWD":
return 1, nil
default:
return 100, nil
}
}
func normalizePayPalOrderState(state string) string {
switch strings.ToUpper(strings.TrimSpace(state)) {
case "COMPLETED":
return "success"
case "VOIDED":
return "failed"
default:
return "pending"
}
}
func normalizePayPalCaptureState(state string) string {
switch strings.ToUpper(strings.TrimSpace(state)) {
case "COMPLETED", "PARTIALLY_REFUNDED", "REFUNDED":
return "success"
case "DECLINED", "FAILED", "DENIED", "VOIDED":
return "failed"
default:
return "pending"
}
}
func normalizePayPalRefundState(state string) string {
switch strings.ToUpper(strings.TrimSpace(state)) {
case "COMPLETED":
return "success"
case "CANCELLED", "FAILED":
return "failed"
default:
return "pending"
}
}
func paypalMapString(values map[string]any, key string) string {
value := values[key]
if value == nil {
return ""
}
return strings.TrimSpace(fmt.Sprint(value))
}
func paypalNestedMapString(values map[string]any, keys ...string) string {
var current any = values
for _, key := range keys {
object, ok := current.(map[string]any)
if !ok {
return ""
}
current = object[key]
}
if current == nil {
return ""
}
return strings.TrimSpace(fmt.Sprint(current))
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if value = strings.TrimSpace(value); value != "" && value != "<nil>" {
return value
}
}
return ""
}
func paypalResponseError(operation string, code int, detail string) error {
detail = strings.TrimSpace(detail)
if detail == "" {
detail = "未知错误"
}
if code == 0 {
return fmt.Errorf("PayPal %s失败: %s", operation, detail)
}
return fmt.Errorf("PayPal %s失败(%d): %s", operation, code, detail)
}
func paypalResponseCode(value any) int {
switch rsp := value.(type) {
case *paypal.CreateOrderRsp:
if rsp != nil {
return rsp.Code
}
case *paypal.OrderDetailRsp:
if rsp != nil {
return rsp.Code
}
case *paypal.PaymentCaptureRefundRsp:
if rsp != nil {
return rsp.Code
}
}
return 0
}
func paypalCreateError(rsp *paypal.CreateOrderRsp) string {
if rsp == nil {
return "响应为空"
}
return paypalErrorDetail(rsp.Error, rsp.ErrorResponse)
}
func paypalOrderError(rsp *paypal.OrderDetailRsp) string {
if rsp == nil {
return "响应为空"
}
return paypalErrorDetail(rsp.Error, rsp.ErrorResponse)
}
func paypalRefundError(rsp *paypal.PaymentCaptureRefundRsp) string {
if rsp == nil {
return "响应为空"
}
return paypalErrorDetail(rsp.Error, rsp.ErrorResponse)
}
func paypalErrorDetail(raw string, response *paypal.ErrorResponse) string {
if response != nil {
if value := firstNonEmpty(response.Message, response.Name); value != "" {
return value
}
}
return raw
}
+241
View File
@@ -0,0 +1,241 @@
package payment
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"kra/internal/biz"
)
func TestPayPalCreateAndQueryKeepOrderAndCaptureIDsSeparate(t *testing.T) {
var createBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && r.URL.Path == "/v1/oauth2/token":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"access_token":"TOKEN","token_type":"Bearer","expires_in":3600}`))
case r.Method == http.MethodPost && r.URL.Path == "/v2/checkout/orders":
if got := r.Header.Get("PayPal-Request-Id"); got != "LOCAL-1" {
t.Errorf("PayPal-Request-Id = %q, want LOCAL-1", got)
}
if err := json.NewDecoder(r.Body).Decode(&createBody); err != nil {
t.Errorf("decode create body: %v", err)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{
"id":"ORDER-1","status":"CREATED",
"purchase_units":[{"reference_id":"LOCAL-1","invoice_id":"LOCAL-1","custom_id":"LOCAL-1","amount":{"currency_code":"USD","value":"10.99"}}],
"links":[{"rel":"approve","href":"https://example.test/approve","method":"GET"}]
}`))
case r.Method == http.MethodGet && r.URL.Path == "/v2/checkout/orders/ORDER-1":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"id":"ORDER-1","status":"COMPLETED",
"purchase_units":[{
"reference_id":"LOCAL-1","invoice_id":"LOCAL-1","custom_id":"LOCAL-1",
"amount":{"currency_code":"USD","value":"10.99"},
"payments":{"captures":[{"id":"CAPTURE-1","status":"COMPLETED","amount":{"currency_code":"USD","value":"10.99"}}]}
}]
}`))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
adapter := &paypalAdapter{}
config := paypalTestConfig(server.URL)
created, err := adapter.Create(context.Background(), &biz.PaymentRequest{
TradeNo: "LOCAL-1",
Subject: "Order one",
Amount: 1099,
Currency: "USD",
ReturnURL: "https://merchant.test/return",
Extra: map[string]any{"payment_source": map[string]any{"paypal": map[string]any{}}},
}, config)
if err != nil {
t.Fatalf("Create() error = %v", err)
}
if created.TradeNo != "LOCAL-1" || created.QueryID != "ORDER-1" || created.ProviderTradeNo != "" || created.Status != "pending" {
t.Fatalf("Create() result = %+v", created)
}
units, _ := createBody["purchase_units"].([]any)
if len(units) != 1 {
t.Fatalf("purchase_units = %#v", createBody["purchase_units"])
}
unit, _ := units[0].(map[string]any)
amount, _ := unit["amount"].(map[string]any)
if unit["invoice_id"] != "LOCAL-1" || amount["value"] != "10.99" {
t.Fatalf("purchase unit = %#v", unit)
}
if _, ok := createBody["payment_source"]; !ok {
t.Fatalf("payment_source was not forwarded: %#v", createBody)
}
queried, err := adapter.Query(context.Background(), created.QueryID, config)
if err != nil {
t.Fatalf("Query() error = %v", err)
}
if queried.Status != "success" || queried.TradeNo != "LOCAL-1" || queried.QueryID != "ORDER-1" || queried.ProviderTradeNo != "CAPTURE-1" || queried.Amount != 1099 || queried.Currency != "USD" {
t.Fatalf("Query() result = %+v", queried)
}
}
func TestPayPalRefundUsesPersistedCaptureID(t *testing.T) {
var refundBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && r.URL.Path == "/v1/oauth2/token":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"access_token":"TOKEN","token_type":"Bearer","expires_in":3600}`))
case r.Method == http.MethodPost && r.URL.Path == "/v2/payments/captures/CAPTURE-1/refund":
if got := r.Header.Get("PayPal-Request-Id"); got != "REFUND-1" {
t.Errorf("PayPal-Request-Id = %q, want REFUND-1", got)
}
if err := json.NewDecoder(r.Body).Decode(&refundBody); err != nil {
t.Errorf("decode refund body: %v", err)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"id":"PAYPAL-REFUND-1","status":"PENDING","amount":{"currency_code":"USD","value":"1.23"}}`))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
config := paypalTestConfig(server.URL)
config["provider_trade_no"] = "WRONG-CONFIG-CAPTURE"
result, err := (&paypalAdapter{}).Refund(context.Background(), &biz.PaymentRefundRequest{
Provider: biz.PaymentPayPal, TradeNo: "LOCAL-1", ProviderTradeNo: "CAPTURE-1",
QueryID: "ORDER-1", RefundNo: "REFUND-1", Amount: 123, TotalAmount: 1099, Currency: "USD",
}, config)
if err != nil {
t.Fatalf("Refund() error = %v", err)
}
if result.TradeNo != "LOCAL-1" || result.ProviderTradeNo != "PAYPAL-REFUND-1" || result.Amount != 123 || result.Currency != "USD" || result.Status != "pending" {
t.Fatalf("Refund() result = %+v", result)
}
amount, _ := refundBody["amount"].(map[string]any)
if refundBody["invoice_id"] != "REFUND-1" || amount["value"] != "1.23" {
t.Fatalf("refund body = %#v", refundBody)
}
}
func TestPayPalRefundResolvesCaptureFromPersistedQueryID(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && r.URL.Path == "/v1/oauth2/token":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"access_token":"TOKEN","token_type":"Bearer","expires_in":3600}`))
case r.Method == http.MethodGet && r.URL.Path == "/v2/checkout/orders/ORDER-1":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"id":"ORDER-1","status":"COMPLETED",
"purchase_units":[{
"invoice_id":"LOCAL-1",
"amount":{"currency_code":"USD","value":"10.99"},
"payments":{"captures":[{"id":"CAPTURE-FROM-ORDER","status":"COMPLETED"}]}
}]
}`))
case r.Method == http.MethodPost && r.URL.Path == "/v2/payments/captures/CAPTURE-FROM-ORDER/refund":
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"id":"PAYPAL-REFUND-2","status":"COMPLETED","amount":{"currency_code":"USD","value":"1.23"}}`))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
config := paypalTestConfig(server.URL)
config["capture_id"] = "WRONG-CONFIG-CAPTURE"
result, err := (&paypalAdapter{}).Refund(context.Background(), &biz.PaymentRefundRequest{
Provider: biz.PaymentPayPal, TradeNo: "LOCAL-1", QueryID: "ORDER-1",
RefundNo: "REFUND-2", Amount: 123, TotalAmount: 1099, Currency: "USD",
}, config)
if err != nil {
t.Fatalf("Refund() error = %v", err)
}
if result.ProviderTradeNo != "PAYPAL-REFUND-2" || result.Status != "success" {
t.Fatalf("Refund() result = %+v", result)
}
}
func TestPayPalRefundRejectsStaticCaptureIDWithoutDurableIdentity(t *testing.T) {
config := paypalTestConfig("https://paypal.invalid")
config["capture_id"] = "CONFIG-CAPTURE"
_, err := (&paypalAdapter{}).Refund(context.Background(), &biz.PaymentRefundRequest{
Provider: biz.PaymentPayPal, TradeNo: "LOCAL-1", RefundNo: "REFUND-1", Amount: 123, Currency: "USD",
}, config)
if err == nil {
t.Fatal("Refund() accepted a static config capture ID")
}
}
func TestPayPalCallbackUsesGoPayWebhookVerification(t *testing.T) {
var verifyBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && r.URL.Path == "/v1/oauth2/token":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"access_token":"TOKEN","token_type":"Bearer","expires_in":3600}`))
case r.Method == http.MethodPost && r.URL.Path == "/v1/notifications/verify-webhook-signature":
if err := json.NewDecoder(r.Body).Decode(&verifyBody); err != nil {
t.Errorf("decode verify body: %v", err)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"verification_status":"SUCCESS"}`))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
body := []byte(`{
"id":"WEBHOOK-1","event_type":"PAYMENT.CAPTURE.COMPLETED",
"resource":{
"id":"CAPTURE-1","status":"COMPLETED","invoice_id":"LOCAL-1",
"supplementary_data":{"related_ids":{"order_id":"ORDER-1"}}
}
}`)
config := paypalTestConfig(server.URL)
config["webhook_id"] = "HOOK-1"
result, err := (&paypalAdapter{}).Callback(context.Background(), &biz.PaymentCallback{
Body: body,
Headers: map[string]string{
"Paypal-Auth-Algo": "SHA256withRSA",
"Paypal-Cert-Url": "https://api.paypal.test/cert",
"Paypal-Transmission-Id": "TRANSMISSION-1",
"Paypal-Transmission-Sig": "SIGNATURE",
"Paypal-Transmission-Time": "2026-08-19T00:00:00Z",
},
}, config)
if err != nil {
t.Fatalf("Callback() error = %v", err)
}
if result.TradeNo != "LOCAL-1" || result.QueryID != "ORDER-1" || result.ProviderTradeNo != "CAPTURE-1" || result.EventID != "WEBHOOK-1" || result.Status != "success" {
t.Fatalf("Callback() result = %+v", result)
}
if verifyBody["webhook_id"] != "HOOK-1" || verifyBody["transmission_id"] != "TRANSMISSION-1" {
t.Fatalf("verify body = %#v", verifyBody)
}
if _, ok := verifyBody["webhook_event"].(map[string]any); !ok {
t.Fatalf("webhook_event was not sent as an object: %#v", verifyBody["webhook_event"])
}
}
func paypalTestConfig(baseURL string) map[string]any {
return map[string]any{
"client_id": "client-id",
"client_secret": "client-secret",
"environment": "sandbox",
"api_base_url": baseURL,
"cancel_url": "https://merchant.test/cancel",
"notify_url": "https://merchant.test/paypal/webhook",
}
}
+160
View File
@@ -0,0 +1,160 @@
package payment
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"kra/internal/biz"
"github.com/go-pay/gopay"
gopayQQ "github.com/go-pay/gopay/qq"
)
type qqAdapter struct{}
func (a *qqAdapter) client(c map[string]any) (*gopayQQ.Client, error) {
mch := firstAny(c, "merchant_id", "mch_id")
if mch == "" || text(c, "api_key") == "" {
return nil, errors.New("QQ 支付缺少 merchant_id 或 api_key")
}
client := gopayQQ.NewClient(mch, text(c, "api_key"))
return client, nil
}
func (a *qqAdapter) Create(ctx context.Context, req *biz.PaymentRequest, c map[string]any) (*biz.PaymentResult, error) {
client, err := a.client(c)
if err != nil {
return nil, err
}
bm := gopay.BodyMap{}
bm.Set("nonce_str", nonce()).Set("body", req.Subject).Set("out_trade_no", req.TradeNo).Set("total_fee", req.Amount).Set("spbill_create_ip", req.ClientIP).Set("notify_url", req.NotifyURL)
tradeType := firstAny(req.Extra, "trade_type", "pay_type")
if tradeType == "" {
tradeType = firstAny(c, "trade_type", "pay_type")
}
if tradeType == "" {
tradeType = gopayQQ.TradeType_Native
}
bm.Set("trade_type", tradeType)
mergeGoPayExtras(bm, req.Extra, "nonce_str", "body", "out_trade_no", "total_fee", "spbill_create_ip", "notify_url", "trade_type", "mch_id")
rsp, err := client.UnifiedOrder(ctx, bm)
if err != nil {
return nil, err
}
if rsp == nil || !qqOK(rsp.ReturnCode, rsp.ResultCode) {
return nil, fmt.Errorf("QQ 支付下单失败: %s", first(rspMap(rsp), "return_msg", "ret_msg", "err_code_des"))
}
result := &biz.PaymentResult{Provider: biz.PaymentQQ, Status: "created", TradeNo: req.TradeNo, QueryID: first(rspMap(rsp), "prepay_id", "code_url"), Payload: mustJSON(rsp)}
return result, nil
}
func (a *qqAdapter) Query(ctx context.Context, tradeNo string, c map[string]any) (*biz.PaymentResult, error) {
client, err := a.client(c)
if err != nil {
return nil, err
}
bm := gopay.BodyMap{"nonce_str": nonce(), "out_trade_no": tradeNo}
mergeGoPayConfigExtras(bm, c, "query_extra", "nonce_str", "out_trade_no", "mch_id")
rsp, err := client.OrderQuery(ctx, bm)
if err != nil {
return nil, err
}
if rsp == nil || !qqOK(rsp.ReturnCode, rsp.ResultCode) {
return nil, fmt.Errorf("QQ 支付查单失败: %s", first(rspMap(rsp), "return_msg", "ret_msg", "err_code_des"))
}
result := &biz.PaymentResult{Provider: biz.PaymentQQ, Status: normalizeQQState(rsp.TradeState), TradeNo: rsp.OutTradeNo, ProviderTradeNo: rsp.TransactionId, Payload: mustJSON(rsp)}
if result.TradeNo == "" {
result.TradeNo = tradeNo
}
result.Currency = strings.ToUpper(rsp.FeeType)
if result.Currency == "" {
result.Currency = "CNY"
}
if rsp.TotalFee != "" {
result.Amount, err = parseIntegerAmount(rsp.TotalFee)
if err != nil {
return nil, err
}
}
if rsp.CashFee != "" {
result.PayerPaidAmount, _ = parseIntegerAmount(rsp.CashFee)
result.CashPaidAmount = result.PayerPaidAmount
result.AmountBreakdownKnown = true
}
return result, nil
}
func (a *qqAdapter) Refund(ctx context.Context, req *biz.PaymentRefundRequest, c map[string]any) (*biz.PaymentResult, error) {
if req == nil {
return nil, errors.New("QQ 支付退款请求为空")
}
client, err := a.client(c)
if err != nil {
return nil, err
}
bm := gopay.BodyMap{"nonce_str": nonce(), "out_trade_no": req.TradeNo, "out_refund_no": req.RefundNo, "refund_fee": req.Amount, "op_user_id": firstAny(c, "op_user_id", "merchant_id", "mch_id"), "op_user_passwd": text(c, "op_user_passwd")}
if req.TotalAmount > 0 {
bm.Set("total_fee", req.TotalAmount)
}
if req.Currency != "" {
bm.Set("refund_fee_type", strings.ToUpper(req.Currency))
}
mergeGoPayConfigExtras(bm, c, "refund_extra", "nonce_str", "out_trade_no", "out_refund_no", "refund_fee", "total_fee", "op_user_id", "op_user_passwd", "mch_id")
cert, key, p12 := anyConfig(c, "cert_file", "cert_path"), anyConfig(c, "key_file", "key_path"), anyConfig(c, "pkcs12_file", "p12_file")
if (cert == nil || key == nil) && p12 == nil {
if text(c, "cert_content") != "" || text(c, "key_content") != "" || text(c, "pkcs12_content") != "" {
cert, key, p12 = []byte(text(c, "cert_content")), []byte(text(c, "key_content")), []byte(text(c, "pkcs12_content"))
}
}
if (cert == nil || key == nil) && p12 == nil {
return nil, errors.New("QQ 支付退款缺少 cert_file、key_file、pkcs12_file(或 *_content)")
}
rsp, err := client.Refund(ctx, bm, cert, key, p12)
if err != nil {
return nil, err
}
if rsp == nil || !qqOK(rsp.ReturnCode, rsp.ResultCode) {
return nil, fmt.Errorf("QQ 支付退款失败: %s", first(rspMap(rsp), "return_msg", "ret_msg", "err_code_des"))
}
refundAmount, _ := parseIntegerAmount(rsp.RefundFee)
returnedTradeNo := rsp.OutTradeNo
if returnedTradeNo == "" {
returnedTradeNo = req.TradeNo
}
return &biz.PaymentResult{Provider: biz.PaymentQQ, Status: "pending", TradeNo: returnedTradeNo, ProviderTradeNo: rsp.RefundId, Amount: refundAmount, Currency: strings.ToUpper(req.Currency), Payload: mustJSON(rsp)}, nil
}
func (a *qqAdapter) Callback(_ context.Context, callback *biz.PaymentCallback, c map[string]any) (*biz.PaymentResult, error) {
if callback == nil {
return nil, errors.New("QQ 支付回调为空")
}
request, err := callbackRequest(callback)
if err != nil {
return nil, err
}
bm, err := gopayQQ.ParseNotifyToBodyMap(request)
if err != nil {
return nil, err
}
signType := firstAny(c, "sign_type", "signature_type", "signType")
if signType == "" {
signType = gopayQQ.SignType_MD5
}
ok, err := gopayQQ.VerifySign(text(c, "api_key"), signType, cloneGoPayBodyMap(bm))
if err != nil {
return nil, err
}
if !ok {
return nil, errors.New("QQ 支付回调签名校验失败")
}
values := fromGoPayBodyMap(bm)
status := "pending"
if strings.EqualFold(values["return_code"], "SUCCESS") && strings.EqualFold(values["result_code"], "SUCCESS") && strings.EqualFold(values["trade_state"], "SUCCESS") {
status = "success"
}
payload, _ := json.Marshal(values)
return &biz.PaymentResult{Provider: biz.PaymentQQ, Status: status, TradeNo: values["out_trade_no"], ProviderTradeNo: values["transaction_id"], Payload: payload}, nil
}
+149
View File
@@ -0,0 +1,149 @@
package payment
import (
"encoding/json"
"errors"
"fmt"
"net/url"
"strings"
"kra/internal/biz"
"kra/pkg/paymentkit"
)
func callbackFields(callback *biz.PaymentCallback) map[string]string {
fields := map[string]string{}
if callback == nil {
return fields
}
for key, value := range callback.Query {
fields[key] = value
}
contentType := strings.ToLower(callback.Headers["Content-Type"])
if strings.Contains(contentType, "application/x-www-form-urlencoded") {
if values, err := url.ParseQuery(string(callback.Body)); err == nil {
for key, value := range values {
if len(value) > 0 {
fields[key] = value[0]
}
}
}
}
var object map[string]any
if json.Unmarshal(callback.Body, &object) == nil {
for key, value := range object {
if stringValue, ok := value.(string); ok {
fields[key] = stringValue
}
}
}
return fields
}
func first(values map[string]string, keys ...string) string {
for _, key := range keys {
if values[key] != "" {
return values[key]
}
}
return ""
}
func text(values map[string]any, key string) string {
value, _ := values[key].(string)
return value
}
func firstAny(values map[string]any, keys ...string) string {
for _, key := range keys {
if value, ok := values[key].(string); ok && value != "" {
return value
}
}
return ""
}
// Compatibility shims keep channel adapters focused on protocol and
// persistence concerns while stateless parsing rules live in paymentkit.
func normalizePaymentStatus(value, fallback string) string {
return paymentkit.NormalizeStatus(value, fallback)
}
func parseIntegerAmount(value string) (int64, error) {
return paymentkit.ParseIntegerAmount(value)
}
func parseDecimalAmount(value string, scale int64) (int64, error) {
return paymentkit.ParseDecimalAmount(value, scale)
}
func formatDecimalAmount(amount, scale int64) (string, error) {
return paymentkit.FormatDecimalAmount(amount, scale)
}
func configuredInt64(values map[string]any, key string, fallback int64) int64 {
return paymentkit.ConfiguredInt64(values, key, fallback)
}
func jsonObject(raw []byte) map[string]any {
return paymentkit.JSONObject(raw)
}
func nestedString(value any, keys ...string) string {
return paymentkit.NestedString(value, keys...)
}
func valueAtPath(value any, path string) any {
return paymentkit.ValueAtPath(value, path)
}
func stringAtPath(value any, path string) string {
return paymentkit.StringAtPath(value, path)
}
func configuredValues(values map[string]any, key string) []string {
return paymentkit.ConfiguredValues(values, key)
}
func containsFold(values []string, value string) bool {
return paymentkit.ContainsFold(values, value)
}
// parseConfiguredAmount reads a provider amount field using an explicit unit
// conversion. Missing fields are reported separately so callers can avoid
// manufacturing a complete breakdown from incomplete provider responses.
func parseConfiguredAmount(object any, config map[string]any, fieldKey, scaleKey string, fallbackScale int64) (int64, bool, error) {
path := strings.TrimSpace(text(config, fieldKey))
if path == "" {
return 0, false, nil
}
raw := valueAtPath(object, path)
if raw == nil || strings.TrimSpace(fmt.Sprint(raw)) == "" || strings.EqualFold(strings.TrimSpace(fmt.Sprint(raw)), "<nil>") {
return 0, false, nil
}
value := strings.TrimSpace(fmt.Sprint(raw))
scale := configuredInt64(config, scaleKey, fallbackScale)
if scale <= 0 {
return 0, false, errors.New("支付金额换算比例无效")
}
amount, err := parseIntegerAmount(value)
if scale != 1 {
amount, err = parseDecimalAmount(value, scale)
}
if err != nil {
return 0, true, err
}
if amount < 0 {
return 0, true, errors.New("支付金额不能为负数")
}
return amount, true, nil
}
func amountFromDecimalField(values map[string]any, key string, fallback int64) (int64, bool, error) {
value := strings.TrimSpace(fmt.Sprint(values[key]))
if value == "" || value == "<nil>" {
return 0, false, nil
}
amount, err := parseDecimalAmount(value, fallback)
return amount, true, err
}
+96
View File
@@ -0,0 +1,96 @@
package payment
import (
"context"
"errors"
"strings"
"time"
"kra/internal/biz"
"github.com/go-pay/gopay"
"github.com/go-pay/gopay/saobei"
)
type saobeiAdapter struct{}
func (a *saobeiAdapter) client(c map[string]any) (*saobei.Client, error) {
return saobei.NewClient(text(c, "inst_no"), text(c, "key"), text(c, "merchant_no"), text(c, "terminal_id"), text(c, "access_token"), !strings.EqualFold(text(c, "environment"), "sandbox"))
}
func (a *saobeiAdapter) Create(ctx context.Context, req *biz.PaymentRequest, c map[string]any) (*biz.PaymentResult, error) {
client, err := a.client(c)
if err != nil {
return nil, err
}
now := time.Now().Format("20060102150405")
bm := gopay.BodyMap{"pay_type": firstAnyOr(req.Extra, "010", "pay_type", "payment_type"), "terminal_ip": req.ClientIP, "terminal_trace": req.TradeNo, "terminal_time": now, "total_fee": req.Amount}
mergeGoPayExtras(bm, req.Extra, "pay_type", "payment_type", "terminal_ip", "terminal_trace", "terminal_time", "total_fee", "method", "pay_method", "trade_type")
method := strings.ToLower(firstAny(req.Extra, "method", "pay_method", "trade_type"))
if method == "barcode" || method == "micropay" {
rsp, callErr := client.BarcodePay(ctx, bm)
if callErr != nil {
return nil, callErr
}
return &biz.PaymentResult{Provider: biz.PaymentSaobei, Status: "created", TradeNo: req.TradeNo, ProviderTradeNo: rsp.OutTradeNo, Payload: mustJSON(rsp)}, nil
}
rsp, err := client.MiniPay(ctx, bm)
if err != nil {
return nil, err
}
return &biz.PaymentResult{Provider: biz.PaymentSaobei, Status: "client_pending", TradeNo: req.TradeNo, ProviderTradeNo: rsp.OutTradeNo, QueryID: rsp.PackageStr, Payload: mustJSON(rsp)}, nil
}
func (a *saobeiAdapter) Query(ctx context.Context, tradeNo string, c map[string]any) (*biz.PaymentResult, error) {
client, err := a.client(c)
if err != nil {
return nil, err
}
bm := gopay.BodyMap{"pay_type": firstAnyOr(c, "010", "pay_type", "payment_type"), "terminal_trace": tradeNo, "terminal_time": time.Now().Format("20060102150405"), "out_trade_no": tradeNo}
mergeGoPayConfigExtras(bm, c, "query_extra", "pay_type", "terminal_trace", "terminal_time", "out_trade_no")
rsp, err := client.Query(ctx, bm)
if err != nil {
return nil, err
}
result := &biz.PaymentResult{Provider: biz.PaymentSaobei, Status: normalizeSaobeiState(rsp.TradeState), TradeNo: rsp.OutTradeNo, ProviderTradeNo: rsp.ChannelTradeNo, Payload: mustJSON(rsp), Currency: strings.ToUpper(firstAnyOr(c, "CNY", "currency", "default_currency"))}
if result.TradeNo == "" {
result.TradeNo = tradeNo
}
if rsp.TotalFee != "" {
result.Amount, err = parseIntegerAmount(rsp.TotalFee)
}
if rsp.BuyerPayFee != "" {
result.PayerPaidAmount, _ = parseIntegerAmount(rsp.BuyerPayFee)
result.CashPaidAmount = result.PayerPaidAmount
if result.Amount >= result.PayerPaidAmount {
result.DiscountAmount = result.Amount - result.PayerPaidAmount
result.AmountBreakdownKnown = true
}
}
return result, err
}
func (a *saobeiAdapter) Refund(ctx context.Context, req *biz.PaymentRefundRequest, c map[string]any) (*biz.PaymentResult, error) {
if req == nil {
return nil, errors.New("扫呗支付退款请求为空")
}
client, err := a.client(c)
if err != nil {
return nil, err
}
bm := gopay.BodyMap{"pay_type": firstAnyOr(c, "010", "pay_type", "payment_type"), "terminal_trace": req.TradeNo, "terminal_time": time.Now().Format("20060102150405"), "refund_fee": req.Amount, "out_trade_no": req.TradeNo, "out_refund_no": req.RefundNo}
mergeGoPayConfigExtras(bm, c, "refund_extra", "pay_type", "terminal_trace", "terminal_time", "refund_fee", "out_trade_no", "out_refund_no")
rsp, err := client.Refund(ctx, bm)
if err != nil {
return nil, err
}
if rsp == nil {
return nil, errors.New("扫呗支付退款响应为空")
}
refundAmount, _ := parseIntegerAmount(rsp.RefundFee)
return &biz.PaymentResult{Provider: biz.PaymentSaobei, Status: "pending", TradeNo: req.TradeNo, ProviderTradeNo: rsp.OutTradeNo, Amount: refundAmount, Currency: strings.ToUpper(req.Currency), Payload: mustJSON(rsp)}, nil
}
func (a *saobeiAdapter) Callback(_ context.Context, _ *biz.PaymentCallback, _ map[string]any) (*biz.PaymentResult, error) {
return nil, errors.New("扫呗支付回调没有可复用的 GoPay 验签器,请改用主动查单")
}
+289
View File
@@ -0,0 +1,289 @@
package payment
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"kra/internal/biz"
"kra/pkg/paymentkit"
)
type vendorProfile string
const (
vendorChinaums vendorProfile = "chinaums"
vendorSFT vendorProfile = "sft"
vendorSupperPay vendorProfile = "supper-pay"
vendorWechatGame vendorProfile = "wechat-game"
vendorDouyinGame vendorProfile = "douyin-game"
)
type vendorPaymentAdapter struct {
provider string
profile vendorProfile
}
func newVendorAdapter(provider string, profile vendorProfile) Adapter {
return &vendorPaymentAdapter{provider: provider, profile: profile}
}
func (a *vendorPaymentAdapter) Create(ctx context.Context, req *biz.PaymentRequest, c map[string]any) (*biz.PaymentResult, error) {
payload := map[string]any{"merchant_id": text(c, "merchant_id"), "app_id": text(c, "app_id"), "trade_no": req.TradeNo, "subject": req.Subject, "amount": req.Amount, "currency": req.Currency, "notify_url": req.NotifyURL, "client_ip": req.ClientIP, "timestamp": time.Now().Unix(), "nonce": nonce()}
mergeMap(payload, req.Extra, "merchant_id", "app_id", "trade_no", "subject", "amount", "currency", "notify_url", "client_ip", "timestamp", "nonce")
return a.call(ctx, "create_url", payload, req.TradeNo, c)
}
func (a *vendorPaymentAdapter) Query(ctx context.Context, tradeNo string, c map[string]any) (*biz.PaymentResult, error) {
return a.call(ctx, "query_url", map[string]any{"merchant_id": text(c, "merchant_id"), "app_id": text(c, "app_id"), "trade_no": tradeNo, "timestamp": time.Now().Unix(), "nonce": nonce()}, tradeNo, c)
}
func (a *vendorPaymentAdapter) Refund(ctx context.Context, req *biz.PaymentRefundRequest, c map[string]any) (*biz.PaymentResult, error) {
if req == nil {
return nil, errors.New("支付退款请求为空")
}
return a.call(ctx, "refund_url", map[string]any{"merchant_id": text(c, "merchant_id"), "app_id": text(c, "app_id"), "trade_no": req.TradeNo, "refund_no": req.RefundNo, "amount": req.Amount, "total_amount": req.TotalAmount, "currency": req.Currency, "timestamp": time.Now().Unix(), "nonce": nonce()}, req.TradeNo, c)
}
func (a *vendorPaymentAdapter) Callback(_ context.Context, callback *biz.PaymentCallback, c map[string]any) (*biz.PaymentResult, error) {
fields := callbackFields(callback)
if err := a.verify(fields, callback.Body, callback.Headers, c); err != nil {
return nil, err
}
object := jsonObject(callback.Body)
statusField := text(c, "callback_status_field")
if statusField == "" {
statusField = text(c, "query_status_field")
}
tradeNoField := text(c, "callback_trade_no_field")
if tradeNoField == "" {
tradeNoField = text(c, "query_trade_no_field")
}
providerTradeNoField := text(c, "callback_provider_trade_no_field")
if providerTradeNoField == "" {
providerTradeNoField = text(c, "query_provider_trade_no_field")
}
state := first(fields, statusField, "status", "trade_status", "order_status", "pay_status")
tradeNo := first(fields, tradeNoField, "trade_no", "out_trade_no", "merchant_order_no", "cp_order_id")
providerTradeNo := first(fields, providerTradeNoField, "transaction_id", "platform_trade_no", "order_no")
if object != nil {
if value := stringAtPath(object, statusField); value != "" {
state = value
}
if value := stringAtPath(object, tradeNoField); value != "" {
tradeNo = value
}
if value := stringAtPath(object, providerTradeNoField); value != "" {
providerTradeNo = value
}
}
status := "pending"
successValues := configuredValues(c, "callback_success_values")
if len(successValues) == 0 {
successValues = configuredValues(c, "query_success_values")
}
if containsFold(successValues, state) {
status = "success"
}
payload, _ := json.Marshal(fields)
return &biz.PaymentResult{Provider: a.provider, Status: status, TradeNo: tradeNo, ProviderTradeNo: providerTradeNo, Payload: payload}, nil
}
func (a *vendorPaymentAdapter) call(ctx context.Context, endpointKey string, payload map[string]any, tradeNo string, c map[string]any) (*biz.PaymentResult, error) {
endpoint := text(c, endpointKey)
if endpoint == "" {
return nil, fmt.Errorf("%s 未配置 %s", a.provider, endpointKey)
}
secret := firstAny(c, "app_key", "merchant_key", "signing_secret", "token")
if secret == "" {
return nil, fmt.Errorf("%s 未配置签名密钥", a.provider)
}
raw, err := json.Marshal(payload)
if err != nil {
return nil, err
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(raw))
if err != nil {
return nil, err
}
request.Header.Set("Content-Type", "application/json")
a.signRequest(request, payload, raw, secret, c)
response, err := (&http.Client{Timeout: 20 * time.Second}).Do(request)
if err != nil {
return nil, err
}
defer response.Body.Close()
body, err := io.ReadAll(io.LimitReader(response.Body, 8<<20))
if err != nil {
return nil, err
}
if response.StatusCode >= 300 {
return nil, fmt.Errorf("%s HTTP %d", a.provider, response.StatusCode)
}
result := &biz.PaymentResult{Provider: a.provider, Status: "created", TradeNo: tradeNo, Payload: ensureJSON(body)}
if endpointKey != "query_url" {
return result, nil
}
object := jsonObject(body)
if object == nil {
return nil, fmt.Errorf("%s 查询响应不是 JSON 对象", a.provider)
}
state := stringAtPath(object, text(c, "query_status_field"))
if !containsFold(configuredValues(c, "query_success_values"), state) {
result.Status = normalizePaymentStatus(state, "pending")
return result, nil
}
result.Status = "success"
result.TradeNo = stringAtPath(object, text(c, "query_trade_no_field"))
result.ProviderTradeNo = stringAtPath(object, text(c, "query_provider_trade_no_field"))
result.Currency = strings.ToUpper(stringAtPath(object, text(c, "query_currency_field")))
amountText := stringAtPath(object, text(c, "query_amount_field"))
amountScale := configuredInt64(c, "query_amount_scale", 0)
if amountScale == 1 {
result.Amount, err = parseIntegerAmount(amountText)
} else {
result.Amount, err = parseDecimalAmount(amountText, amountScale)
}
if err != nil {
return nil, fmt.Errorf("解析 %s 查询金额: %w", a.provider, err)
}
if result.TradeNo == "" || result.ProviderTradeNo == "" || result.Currency == "" {
return nil, fmt.Errorf("%s 查询响应缺少订单号、平台单号或币种", a.provider)
}
if err = populateVendorBreakdown(result, object, c); err != nil {
return nil, fmt.Errorf("解析 %s 查询金额拆分: %w", a.provider, err)
}
return result, nil
}
func populateVendorBreakdown(result *biz.PaymentResult, object map[string]any, c map[string]any) error {
if result == nil || result.Status != "success" {
return nil
}
defaultScale := configuredInt64(c, "query_amount_scale", 0)
payer, payerOK, err := parseConfiguredAmount(object, c, "query_payer_paid_amount_field", "query_payer_paid_amount_scale", defaultScale)
if err != nil {
return fmt.Errorf("payer paid amount: %w", err)
}
if !payerOK {
return nil
}
cash, cashOK, err := parseConfiguredAmount(object, c, "query_cash_paid_amount_field", "query_cash_paid_amount_scale", defaultScale)
if err != nil {
return fmt.Errorf("cash paid amount: %w", err)
}
point, pointOK, err := parseConfiguredAmount(object, c, "query_point_paid_amount_field", "query_point_paid_amount_scale", defaultScale)
if err != nil {
return fmt.Errorf("point paid amount: %w", err)
}
discount, discountOK, err := parseConfiguredAmount(object, c, "query_discount_amount_field", "query_discount_amount_scale", defaultScale)
if err != nil {
return fmt.Errorf("discount amount: %w", err)
}
providerDiscount, _, err := parseConfiguredAmount(object, c, "query_provider_discount_amount_field", "query_provider_discount_amount_scale", defaultScale)
if err != nil {
return fmt.Errorf("provider discount amount: %w", err)
}
merchantDiscount, _, err := parseConfiguredAmount(object, c, "query_merchant_discount_amount_field", "query_merchant_discount_amount_scale", defaultScale)
if err != nil {
return fmt.Errorf("merchant discount amount: %w", err)
}
settlement, _, err := parseConfiguredAmount(object, c, "query_settlement_amount_field", "query_settlement_amount_scale", defaultScale)
if err != nil {
return fmt.Errorf("settlement amount: %w", err)
}
if !cashOK && !pointOK {
cash = payer
cashOK = true
}
if !cashOK {
cash = payer - point
cashOK = cash >= 0
}
if !pointOK {
point = payer - cash
pointOK = point >= 0
}
if !discountOK {
discount = result.Amount - payer
discountOK = discount >= 0
}
if !cashOK || !pointOK || !discountOK || payer > result.Amount || cash+point != payer || payer+discount != result.Amount {
return errors.New("总额、用户实付、现金/积分和优惠金额不守恒")
}
result.PayerPaidAmount = payer
result.CashPaidAmount = cash
result.PointPaidAmount = point
result.DiscountAmount = discount
result.ProviderDiscountAmount = providerDiscount
result.MerchantDiscountAmount = merchantDiscount
result.SettlementAmount = settlement
result.PayerCurrency = strings.ToUpper(stringAtPath(object, text(c, "query_payer_currency_field")))
if result.PayerCurrency == "" {
result.PayerCurrency = result.Currency
}
result.AmountBreakdownKnown = true
return nil
}
func (a *vendorPaymentAdapter) signRequest(request *http.Request, payload map[string]any, raw []byte, secret string, c map[string]any) {
switch a.profile {
case vendorChinaums:
timestamp, nonceValue := fmt.Sprint(payload["timestamp"]), fmt.Sprint(payload["nonce"])
appID := text(c, "app_id")
digest := sha256.Sum256([]byte(appID + timestamp + nonceValue + string(raw) + secret))
request.Header.Set("Authorization", "OPEN-BODY-SIG AppId="+appID+", Timestamp="+timestamp+", Nonce="+nonceValue+", Signature="+hex.EncodeToString(digest[:]))
case vendorSFT:
request.Header.Set("X-SFT-Sign", paymentkit.MD5Canonical(payload, secret))
case vendorWechatGame:
request.Header.Set("X-Wechat-Game-Sign", paymentkit.HMACSHA256Hex(raw, secret, false))
if token := text(c, "access_token"); token != "" {
query := request.URL.Query()
query.Set("access_token", token)
request.URL.RawQuery = query.Encode()
}
case vendorDouyinGame:
request.Header.Set("X-TT-Pay-Sign", paymentkit.MD5Canonical(payload, secret))
default:
request.Header.Set("X-Payment-Sign", paymentkit.HMACSHA256Hex(raw, secret, false))
}
}
func (a *vendorPaymentAdapter) verify(fields map[string]string, raw []byte, headers map[string]string, c map[string]any) error {
secret := firstAny(c, "app_key", "merchant_key", "signing_secret", "token")
if secret == "" {
return errors.New("支付回调未配置签名密钥")
}
expected := first(fields, "sign", "signature")
if expected == "" {
expected = first(headers, "X-Payment-Sign", "X-SFT-Sign", "X-Wechat-Game-Sign", "X-TT-Pay-Sign")
}
var actual string
switch a.profile {
case vendorSFT, vendorDouyinGame:
values := map[string]any{}
for key, value := range fields {
if key != "sign" && key != "signature" {
values[key] = value
}
}
actual = paymentkit.MD5Canonical(values, secret)
default:
actual = paymentkit.HMACSHA256Hex(raw, secret, false)
}
if !hmac.Equal([]byte(strings.ToLower(expected)), []byte(strings.ToLower(actual))) {
return errors.New("支付回调签名校验失败")
}
return nil
}
func ensureJSON(raw []byte) []byte {
if json.Valid(raw) {
return raw
}
encoded, _ := json.Marshal(string(raw))
return encoded
}
+435
View File
@@ -0,0 +1,435 @@
package payment
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"kra/internal/biz"
"github.com/go-pay/gopay"
"github.com/go-pay/gopay/pkg/xhttp"
gopayWechat "github.com/go-pay/gopay/wechat"
)
type wechatV2Adapter struct{}
type wechatV2EndpointTransport struct {
endpoint *url.URL
next http.RoundTripper
}
func (t *wechatV2EndpointTransport) RoundTrip(request *http.Request) (*http.Response, error) {
clone := request.Clone(request.Context())
target := *t.endpoint
clone.URL = &target
clone.Host = target.Host
clone.RequestURI = ""
return t.next.RoundTrip(clone)
}
func wechatV2SignType(value string) string {
if strings.EqualFold(strings.TrimSpace(value), gopayWechat.SignType_HMAC_SHA256) {
return gopayWechat.SignType_HMAC_SHA256
}
return gopayWechat.SignType_MD5
}
func wechatV2Sign(values map[string]string, key, signType string) string {
body := toGoPayBodyMap(values)
body.Remove("sign")
return gopayWechat.GetReleaseSign(key, wechatV2SignType(signType), body)
}
func (a *wechatV2Adapter) Create(ctx context.Context, req *biz.PaymentRequest, c map[string]any) (*biz.PaymentResult, error) {
client, err := newWechatV2Client(c, firstAny(c, "create_url", "unified_order_url"))
if err != nil {
return nil, err
}
values := map[string]string{
"appid": client.AppId,
"mch_id": client.MchId,
"nonce_str": nonce(),
"body": req.Subject,
"out_trade_no": req.TradeNo,
"total_fee": fmt.Sprint(req.Amount),
"fee_type": strings.ToUpper(req.Currency),
"spbill_create_ip": req.ClientIP,
"notify_url": req.NotifyURL,
"trade_type": stringOr(req.Extra, "trade_type", firstAny(c, "trade_type", "pay_type")),
"sign_type": wechatV2SignType(firstAny(c, "sign_type", "signType")),
}
if values["spbill_create_ip"] == "" {
values["spbill_create_ip"] = firstAny(c, "client_ip", "spbill_create_ip")
}
if values["spbill_create_ip"] == "" {
values["spbill_create_ip"] = "127.0.0.1"
}
if values["trade_type"] == "" {
values["trade_type"] = "JSAPI"
}
mergeWechatV2Extras(values, req.Extra)
rsp, err := client.UnifiedOrder(ctx, toGoPayBodyMap(values))
if err != nil {
return nil, err
}
resultValues, err := wechatV2ResponseValues(rsp)
if err != nil {
return nil, err
}
if err = validateWechatV2Response(resultValues); err != nil {
return nil, err
}
if err = verifyWechatV2Response(resultValues, client.ApiKey, values["sign_type"]); err != nil {
return nil, err
}
return &biz.PaymentResult{
Provider: biz.PaymentWechatV2,
Status: "created",
TradeNo: req.TradeNo,
Payload: wechatV2Payload(resultValues),
}, nil
}
func (a *wechatV2Adapter) Query(ctx context.Context, tradeNo string, c map[string]any) (*biz.PaymentResult, error) {
client, err := newWechatV2Client(c, firstAny(c, "query_url", "order_query_url"))
if err != nil {
return nil, err
}
body := gopay.BodyMap{
"nonce_str": nonce(),
"out_trade_no": tradeNo,
"sign_type": wechatV2SignType(firstAny(c, "sign_type", "signType")),
}
_, response, err := client.QueryOrder(ctx, body)
if err != nil {
return nil, err
}
values := fromGoPayBodyMap(response)
if err = validateWechatV2Response(values); err != nil {
return nil, err
}
if err = verifyWechatV2Response(values, client.ApiKey, body.GetString("sign_type")); err != nil {
return nil, err
}
result := &biz.PaymentResult{
Provider: biz.PaymentWechatV2,
Status: normalizePaymentStatus(values["trade_state"], "pending"),
TradeNo: first(values, "out_trade_no"),
Payload: wechatV2Payload(values),
}
if result.TradeNo == "" {
result.TradeNo = tradeNo
}
if result.Status != "success" {
return result, nil
}
result.ProviderTradeNo = values["transaction_id"]
if result.ProviderTradeNo == "" {
return nil, errors.New("微信支付 v2 查询响应缺少 transaction_id")
}
result.Amount, err = parseIntegerAmount(values["total_fee"])
if err != nil {
return nil, fmt.Errorf("解析微信支付 v2 订单金额: %w", err)
}
result.Currency = strings.ToUpper(values["fee_type"])
if result.Currency == "" {
result.Currency = "CNY"
}
if err = populateWechatV2Breakdown(result, values); err != nil {
return nil, err
}
return result, nil
}
func (a *wechatV2Adapter) Refund(ctx context.Context, req *biz.PaymentRefundRequest, c map[string]any) (*biz.PaymentResult, error) {
if req == nil {
return nil, errors.New("微信支付 v2 退款请求为空")
}
client, err := newWechatV2Client(c, firstAny(c, "refund_url", "refund_apply_url"))
if err != nil {
return nil, err
}
values := map[string]string{
"appid": client.AppId,
"mch_id": client.MchId,
"nonce_str": nonce(),
"out_trade_no": req.TradeNo,
"out_refund_no": req.RefundNo,
"total_fee": fmt.Sprint(req.TotalAmount),
"refund_fee": fmt.Sprint(req.Amount),
"sign_type": wechatV2SignType(firstAny(c, "sign_type", "signType")),
}
currency := strings.ToUpper(strings.TrimSpace(req.Currency))
if currency != "" {
values["refund_fee_type"] = currency
}
rsp, response, err := client.Refund(ctx, toGoPayBodyMap(values))
if err != nil {
return nil, err
}
resultValues := fromGoPayBodyMap(response)
if rsp == nil {
return nil, errors.New("微信支付 v2 退款响应为空")
}
if err = validateWechatV2Response(resultValues); err != nil {
return nil, err
}
if err = verifyWechatV2Response(resultValues, client.ApiKey, values["sign_type"]); err != nil {
return nil, err
}
result := &biz.PaymentResult{
Provider: biz.PaymentWechatV2,
Status: "created",
TradeNo: req.TradeNo,
ProviderTradeNo: rsp.RefundId,
Currency: currency,
Payload: wechatV2Payload(resultValues),
}
if rsp.RefundFee != "" {
result.Amount, err = parseIntegerAmount(rsp.RefundFee)
if err != nil {
return nil, fmt.Errorf("解析微信支付 v2 退款金额: %w", err)
}
}
return result, nil
}
func (a *wechatV2Adapter) Callback(_ context.Context, callback *biz.PaymentCallback, c map[string]any) (*biz.PaymentResult, error) {
request, err := http.NewRequest(http.MethodPost, "http://payment-callback.local", bytes.NewReader(callback.Body))
if err != nil {
return nil, err
}
body, err := gopayWechat.ParseNotifyToBodyMap(request)
if err != nil {
return nil, err
}
values := fromGoPayBodyMap(body)
if err = verifyWechatV2(values, firstAny(c, "mch_key", "api_key", "merchant_key")); err != nil {
return nil, err
}
if values["appid"] != firstAny(c, "app_id", "appid") || values["mch_id"] != firstAny(c, "merchant_id", "mch_id") {
return nil, errors.New("微信支付 v2 回调商户配置不匹配")
}
status := "pending"
if values["return_code"] == "SUCCESS" && values["result_code"] == "SUCCESS" && values["trade_state"] != "REFUND" {
status = "success"
}
payload, _ := json.Marshal(values)
return &biz.PaymentResult{Provider: biz.PaymentWechatV2, Status: status, TradeNo: first(values, "out_trade_no"), ProviderTradeNo: values["transaction_id"], Payload: payload}, nil
}
func newWechatV2Client(c map[string]any, endpoint string) (*gopayWechat.Client, error) {
appID := firstAny(c, "app_id", "appid")
merchantID := firstAny(c, "merchant_id", "mch_id")
mchKey := firstAny(c, "mch_key", "api_key", "merchant_key")
if appID == "" || merchantID == "" || mchKey == "" {
return nil, errors.New("微信支付 v2 缺少 app_id、merchant_id 或 mch_key")
}
// The previous adapter sent the configured amount unchanged, including to
// sandbox or test endpoints. GoPay's sandbox mode rewrites total_fee, so use
// its production signing flow and redirect the high-level call when a custom
// endpoint is configured.
client := gopayWechat.NewClient(appID, merchantID, mchKey, true)
regular := xhttp.NewClient().SetTimeout(20 * time.Second).SetBodySize(4)
if err := setWechatV2Endpoint(regular, endpoint); err != nil {
return nil, err
}
client.SetHttpClient(regular)
tlsHTTPClient, err := paymentHTTPClient(c)
if err != nil {
return nil, err
}
tlsClient := xhttp.NewClient().SetBodySize(4)
tlsClient.HttpClient = tlsHTTPClient
if err = setWechatV2Endpoint(tlsClient, endpoint); err != nil {
return nil, err
}
client.SetTLSHttpClient(tlsClient)
return client, nil
}
func setWechatV2Endpoint(client *xhttp.Client, endpoint string) error {
endpoint = strings.TrimSpace(endpoint)
if endpoint == "" {
return nil
}
target, err := url.Parse(endpoint)
if err != nil || target.Scheme == "" || target.Host == "" {
return fmt.Errorf("微信支付 v2 接口地址无效: %s", endpoint)
}
next := client.HttpClient.Transport
if next == nil {
next = http.DefaultTransport
}
client.SetTransport(&wechatV2EndpointTransport{endpoint: target, next: next})
return nil
}
func wechatV2ResponseValues(value any) (map[string]string, error) {
raw, err := json.Marshal(value)
if err != nil {
return nil, err
}
var body gopay.BodyMap
if err = json.Unmarshal(raw, &body); err != nil {
return nil, err
}
return fromGoPayBodyMap(body), nil
}
func validateWechatV2Response(values map[string]string) error {
if values["return_code"] == "SUCCESS" && (values["result_code"] == "" || values["result_code"] == "SUCCESS") {
return nil
}
message := first(values, "err_code_des", "return_msg", "err_code")
if message == "" {
message = "未知错误"
}
return fmt.Errorf("微信支付 v2 错误: %s", message)
}
func verifyWechatV2Response(values map[string]string, key, fallbackSignType string) error {
if values["sign"] == "" {
return nil
}
signType := values["sign_type"]
if signType == "" {
signType = fallbackSignType
}
return verifyWechatV2WithSignType(values, key, signType)
}
func wechatV2Payload(values map[string]string) []byte {
return []byte(gopayWechat.GenerateXml(toGoPayBodyMap(values)))
}
func populateWechatV2Breakdown(result *biz.PaymentResult, values map[string]string) error {
if result == nil || result.Status != "success" {
return nil
}
// WeChat v2 reports total_fee/cash_fee/coupon_fee in the smallest currency
// unit. cash_fee is the payer cash amount; coupon_fee is the total coupon
// discount. v2 does not reliably identify coupon funding parties, so those
// fields remain zero rather than being misclassified.
cash, cashOK, err := parseWechatInteger(values, "cash_fee")
if err != nil {
return fmt.Errorf("解析微信支付 v2 cash_fee: %w", err)
}
if !cashOK {
return nil
}
coupon, couponOK, err := parseWechatInteger(values, "coupon_fee")
if err != nil {
return fmt.Errorf("解析微信支付 v2 coupon_fee: %w", err)
}
if !couponOK {
coupon = result.Amount - cash
}
if cash < 0 || coupon < 0 || cash > result.Amount || cash+coupon != result.Amount {
return errors.New("微信支付 v2 总额、现金实付和优惠金额不守恒")
}
result.PayerPaidAmount = cash
result.CashPaidAmount = cash
result.DiscountAmount = coupon
if settlement, ok, settlementErr := parseWechatInteger(values, "settlement_total_fee"); settlementErr != nil {
return fmt.Errorf("解析微信支付 v2 settlement_total_fee: %w", settlementErr)
} else if ok {
result.SettlementAmount = settlement
}
// coupon_fee_n is a per-coupon detail in v2. Its aggregate still has no
// authoritative merchant/provider funding split, so preserve the raw list
// in Payload instead of assigning it to either party.
_ = couponOK
result.AmountBreakdownKnown = true
return nil
}
func parseWechatInteger(values map[string]string, key string) (int64, bool, error) {
value, exists := values[key]
if !exists || strings.TrimSpace(value) == "" {
return 0, false, nil
}
amount, err := parseIntegerAmount(value)
return amount, true, err
}
func mergeWechatV2Extras(dst map[string]string, extra map[string]any) {
protected := map[string]struct{}{
"appid": {}, "mch_id": {}, "nonce_str": {}, "body": {}, "out_trade_no": {},
"total_fee": {}, "fee_type": {}, "spbill_create_ip": {}, "notify_url": {}, "sign": {},
}
for key, value := range stringMap(extra) {
if _, exists := protected[key]; exists {
continue
}
dst[key] = value
}
}
func verifyWechatV2(values map[string]string, key string) error {
return verifyWechatV2WithSignType(values, key, values["sign_type"])
}
func verifyWechatV2WithSignType(values map[string]string, key, signType string) error {
if key == "" {
return errors.New("微信支付 v2 未配置 mch_key")
}
ok, err := gopayWechat.VerifySign(key, wechatV2SignType(signType), cloneGoPayBodyMap(toGoPayBodyMap(values)))
if err != nil {
return err
}
if !ok {
return errors.New("微信支付 v2 回调签名校验失败")
}
return nil
}
func paymentHTTPClient(c map[string]any) (*http.Client, error) {
certPEM := firstAny(c, "client_cert", "cert_pem", "apiclient_cert")
keyPEM := firstAny(c, "client_key", "key_pem", "apiclient_key")
if certPEM == "" && keyPEM == "" {
return &http.Client{Timeout: 20 * time.Second}, nil
}
if certPEM == "" || keyPEM == "" {
return nil, errors.New("微信支付 v2 client_cert 和 client_key 必须同时配置")
}
cert, err := tls.X509KeyPair([]byte(certPEM), []byte(keyPEM))
if err != nil {
return nil, err
}
var pool *x509.CertPool
if ca := firstAny(c, "ca_cert", "root_cert"); ca != "" {
pool = x509.NewCertPool()
if !pool.AppendCertsFromPEM([]byte(ca)) {
return nil, errors.New("微信支付 v2 ca_cert 格式错误")
}
}
return &http.Client{Timeout: 20 * time.Second, Transport: &http.Transport{TLSClientConfig: &tls.Config{Certificates: []tls.Certificate{cert}, RootCAs: pool, MinVersion: tls.VersionTLS12}}}, nil
}
func nonce() string { return fmt.Sprintf("%d", time.Now().UnixNano()) }
func stringOr(extra map[string]any, key, fallback string) string {
if value, ok := extra[key].(string); ok {
return value
}
return fallback
}
func stringMap(values map[string]any) map[string]string {
result := map[string]string{}
for key, value := range values {
if text, ok := value.(string); ok {
result[key] = text
}
}
return result
}
+461
View File
@@ -0,0 +1,461 @@
package payment
import (
"bytes"
"context"
"crypto/x509"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"kra/internal/biz"
"github.com/go-pay/gopay"
gopayWechatV3 "github.com/go-pay/gopay/wechat/v3"
)
type wechatV3Adapter struct{}
func (a *wechatV3Adapter) Create(ctx context.Context, req *biz.PaymentRequest, c map[string]any) (*biz.PaymentResult, error) {
client, err := newWechatV3Client(c, "create_url")
if err != nil {
return nil, err
}
bm := gopay.BodyMap{
"appid": firstAny(c, "app_id", "appid"),
"mchid": firstAny(c, "merchant_id", "mch_id", "mchid"),
"description": req.Subject,
"out_trade_no": req.TradeNo,
"notify_url": req.NotifyURL,
}
bm.SetBodyMap("amount", func(amount gopay.BodyMap) {
amount.Set("total", req.Amount).Set("currency", strings.ToUpper(req.Currency))
})
mergeGoPayExtras(bm, req.Extra,
"appid", "mchid", "description", "out_trade_no", "notify_url", "amount", "payer",
"trade_type", "pay_type", "method", "openid", "open_id",
)
tradeType := strings.ToLower(firstAny(req.Extra, "trade_type", "pay_type", "method"))
if tradeType == "" {
tradeType = strings.ToLower(firstAny(c, "trade_type", "pay_type", "method"))
}
switch tradeType {
case "", "jsapi", "mini", "miniprogram", "mini_program", "applet":
if openID := firstAny(req.Extra, "openid", "open_id"); openID != "" {
bm.SetBodyMap("payer", func(payer gopay.BodyMap) { payer.Set("openid", openID) })
}
rsp, callErr := client.V3TransactionJsapi(ctx, bm)
if callErr != nil {
return nil, callErr
}
if rsp == nil {
return nil, errors.New("微信支付 v3 下单响应为空")
}
return wechatV3PrepayCreateResult(client, firstAny(c, "app_id", "appid"), tradeType, req.TradeNo, rsp)
case "app":
rsp, callErr := client.V3TransactionApp(ctx, bm)
if callErr != nil {
return nil, callErr
}
if rsp == nil {
return nil, errors.New("微信支付 v3 下单响应为空")
}
return wechatV3PrepayCreateResult(client, firstAny(c, "app_id", "appid"), "app", req.TradeNo, rsp)
case "native", "qr", "qrcode":
rsp, callErr := client.V3TransactionNative(ctx, bm)
if callErr != nil {
return nil, callErr
}
if rsp == nil {
return nil, errors.New("微信支付 v3 下单响应为空")
}
return wechatV3CreateResult(req.TradeNo, rsp.Code, rsp.SignInfo, rsp.Response, rsp.ErrResponse, rsp.Error)
case "h5", "mweb":
rsp, callErr := client.V3TransactionH5(ctx, bm)
if callErr != nil {
return nil, callErr
}
if rsp == nil {
return nil, errors.New("微信支付 v3 下单响应为空")
}
return wechatV3CreateResult(req.TradeNo, rsp.Code, rsp.SignInfo, rsp.Response, rsp.ErrResponse, rsp.Error)
default:
return nil, fmt.Errorf("微信支付 v3 不支持交易类型 %q", tradeType)
}
}
func (a *wechatV3Adapter) Query(ctx context.Context, tradeNo string, c map[string]any) (*biz.PaymentResult, error) {
client, err := newWechatV3Client(c, "query_url")
if err != nil {
return nil, err
}
rsp, err := client.V3TransactionQueryOrder(ctx, gopayWechatV3.OutTradeNo, tradeNo)
if err != nil {
return nil, err
}
if rsp == nil {
return nil, errors.New("微信支付 v3 查单响应为空")
}
if rsp.Code != gopayWechatV3.Success {
return nil, wechatV3APIError("查单", rsp.Code, rsp.ErrResponse, rsp.Error)
}
if rsp.Response == nil {
return nil, errors.New("微信支付 v3 查单响应缺少订单")
}
order := rsp.Response
payload := wechatV3ResponsePayload(rsp.SignInfo, order)
result := &biz.PaymentResult{
Provider: biz.PaymentWechatV3,
Status: normalizePaymentStatus(order.TradeState, "pending"),
TradeNo: order.OutTradeNo,
ProviderTradeNo: order.TransactionId,
Payload: payload,
}
if result.TradeNo == "" {
result.TradeNo = tradeNo
}
if result.Status != "success" {
return result, nil
}
if result.ProviderTradeNo == "" {
return nil, errors.New("微信支付 v3 查询响应缺少 transaction_id")
}
if order.Amount == nil || order.Amount.Total <= 0 {
return nil, errors.New("微信支付 v3 查询响应缺少 amount.total")
}
result.Amount = int64(order.Amount.Total)
result.Currency = strings.ToUpper(order.Amount.Currency)
if result.Currency == "" {
return nil, errors.New("微信支付 v3 查询响应缺少 amount.currency")
}
if err := populateWechatV3Breakdown(result, jsonObject(payload)); err != nil {
return nil, err
}
return result, nil
}
func (a *wechatV3Adapter) Refund(ctx context.Context, req *biz.PaymentRefundRequest, c map[string]any) (*biz.PaymentResult, error) {
if req == nil {
return nil, errors.New("微信支付 v3 退款请求为空")
}
client, err := newWechatV3Client(c, "refund_url")
if err != nil {
return nil, err
}
bm := gopay.BodyMap{"out_trade_no": req.TradeNo, "out_refund_no": req.RefundNo}
bm.SetBodyMap("amount", func(values gopay.BodyMap) {
values.Set("refund", req.Amount).Set("total", req.TotalAmount).Set("currency", strings.ToUpper(req.Currency))
})
mergeGoPayConfigExtras(bm, c, "refund_extra", "out_trade_no", "out_refund_no", "amount")
rsp, err := client.V3Refund(ctx, bm)
if err != nil {
return nil, err
}
if rsp == nil {
return nil, errors.New("微信支付 v3 退款响应为空")
}
if rsp.Code != gopayWechatV3.Success {
return nil, wechatV3APIError("退款", rsp.Code, rsp.ErrResponse, rsp.Error)
}
if rsp.Response == nil {
return nil, errors.New("微信支付 v3 退款响应缺少退款单")
}
refund := rsp.Response
result := &biz.PaymentResult{
Provider: biz.PaymentWechatV3,
Status: normalizeRefundState(refund.Status),
TradeNo: refund.OutTradeNo,
ProviderTradeNo: refund.RefundId,
Payload: wechatV3ResponsePayload(rsp.SignInfo, refund),
}
if result.TradeNo == "" {
result.TradeNo = req.TradeNo
}
if refund.Amount != nil {
result.Amount = int64(refund.Amount.Refund)
result.Currency = strings.ToUpper(refund.Amount.Currency)
}
if result.Amount == 0 {
result.Amount = req.Amount
}
if result.Currency == "" {
result.Currency = strings.ToUpper(req.Currency)
}
return result, nil
}
func (a *wechatV3Adapter) Callback(ctx context.Context, callback *biz.PaymentCallback, c map[string]any) (*biz.PaymentResult, error) {
if callback == nil {
return nil, errors.New("微信支付 v3 回调为空")
}
apiV3Key := firstAny(c, "api_v3_key", "api_v3key", "apiv3_key")
if apiV3Key == "" {
return nil, errors.New("微信支付 v3 缺少 api_v3_key")
}
client, err := newWechatV3Client(c, "")
if err != nil {
return nil, err
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://callback.local", bytes.NewReader(callback.Body))
if err != nil {
return nil, err
}
for key, value := range callback.Headers {
request.Header.Set(key, value)
}
notify, err := gopayWechatV3.V3ParseNotify(request)
if err != nil {
return nil, err
}
if notify.SignInfo == nil {
return nil, errors.New("微信支付 v3 回调缺少签名信息")
}
platformKeys := client.WxPublicKeyMap()
if _, exists := platformKeys[notify.SignInfo.HeaderSerial]; !exists {
for serial, key := range platformKeys {
if strings.EqualFold(serial, notify.SignInfo.HeaderSerial) {
platformKeys[notify.SignInfo.HeaderSerial] = key
break
}
}
}
if err = notify.VerifySignByPKMap(platformKeys); err != nil {
return nil, err
}
pay, err := notify.DecryptPayCipherText(apiV3Key)
if err != nil {
return nil, err
}
if callback.Query == nil {
callback.Query = map[string]string{}
}
callback.Query["event_id"] = notify.Id
status := "pending"
if pay.TradeState == gopayWechatV3.TradeStateSuccess {
status = "success"
}
merchantID := firstAny(c, "merchant_id", "mch_id", "mchid")
if pay.Mchid != "" && pay.Mchid != merchantID {
return nil, errors.New("微信支付 v3 回调商户号不匹配")
}
appID := firstAny(c, "app_id", "appid")
if pay.Appid != "" && pay.Appid != appID {
return nil, errors.New("微信支付 v3 回调 appid 不匹配")
}
plain, _ := json.Marshal(pay)
return &biz.PaymentResult{
Provider: biz.PaymentWechatV3,
Status: status,
TradeNo: pay.OutTradeNo,
ProviderTradeNo: pay.TransactionId,
Payload: plain,
}, nil
}
func newWechatV3Client(c map[string]any, endpointKey string) (*gopayWechatV3.ClientV3, error) {
merchantID := firstAny(c, "merchant_id", "mch_id", "mchid")
serialNo := firstAny(c, "serial_no", "merchant_serial_no", "certificate_serial_no")
apiV3Key := firstAny(c, "api_v3_key", "api_v3key", "apiv3_key")
privateKey := firstAny(c, "private_key", "merchant_private_key")
if merchantID == "" || serialNo == "" || apiV3Key == "" || privateKey == "" {
return nil, errors.New("微信支付 v3 缺少 merchant_id、serial_no、api_v3_key 或 private_key")
}
client, err := gopayWechatV3.NewClientV3(merchantID, serialNo, apiV3Key, privateKey)
if err != nil {
return nil, err
}
platformCert := firstAny(c, "platform_cert", "platform_certificate", "wechatpay_platform_cert")
if platformCert == "" {
return nil, errors.New("微信支付 v3 缺少 platform_cert")
}
platformSerial := firstAny(c, "platform_serial_no", "platform_cert_serial", "wechatpay_serial_no")
if platformSerial == "" {
platformSerial, err = wechatV3PlatformSerial(platformCert)
if err != nil {
return nil, err
}
}
if err = client.AutoVerifySignByCert([]byte(platformCert), platformSerial); err != nil {
return nil, err
}
client.SetBodySize(8)
client.GetHttpClient().SetTimeout(20 * time.Second)
if endpointKey != "" {
if endpoint := strings.TrimSpace(text(c, endpointKey)); endpoint != "" {
parsed, parseErr := url.Parse(endpoint)
if parseErr != nil || parsed.Scheme == "" || parsed.Host == "" {
return nil, fmt.Errorf("微信支付 v3 %s 配置无效", endpointKey)
}
if parsed.Path != "" && parsed.Path != "/" || parsed.RawQuery != "" || parsed.Fragment != "" {
return nil, fmt.Errorf("微信支付 v3 %s 只能配置 scheme 和 host", endpointKey)
}
httpClient := client.GetHttpClient()
httpClient.SetTransport(&wechatV3EndpointTransport{base: httpClient.HttpClient.Transport, endpoint: parsed})
return client, nil
}
}
if baseURL := strings.TrimRight(firstAny(c, "base_url", "api_base_url", "proxy_url"), "/"); baseURL != "" {
parsed, parseErr := url.Parse(baseURL)
if parseErr != nil || parsed.Scheme == "" || parsed.Host == "" {
return nil, errors.New("微信支付 v3 base_url 配置无效")
}
client.SetProxyHost(baseURL)
}
return client, nil
}
type wechatV3EndpointTransport struct {
base http.RoundTripper
endpoint *url.URL
}
func (t *wechatV3EndpointTransport) RoundTrip(request *http.Request) (*http.Response, error) {
base := t.base
if base == nil {
base = http.DefaultTransport
}
clone := request.Clone(request.Context())
target := *request.URL
target.Scheme = t.endpoint.Scheme
target.Host = t.endpoint.Host
target.User = t.endpoint.User
clone.URL = &target
clone.Host = ""
return base.RoundTrip(clone)
}
func wechatV3PlatformSerial(certificate string) (string, error) {
block, _ := pem.Decode([]byte(certificate))
if block == nil || block.Type != "CERTIFICATE" {
return "", errors.New("微信支付 v3 platform_cert 格式错误")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return "", fmt.Errorf("解析微信支付 v3 platform_cert: %w", err)
}
return strings.ToUpper(cert.SerialNumber.Text(16)), nil
}
func wechatV3CreateResult(tradeNo string, code int, signInfo *gopayWechatV3.SignInfo, response any, errResponse gopayWechatV3.ErrResponse, rawError string) (*biz.PaymentResult, error) {
if code != gopayWechatV3.Success {
return nil, wechatV3APIError("下单", code, errResponse, rawError)
}
if response == nil {
return nil, errors.New("微信支付 v3 下单响应缺少预支付信息")
}
return &biz.PaymentResult{
Provider: biz.PaymentWechatV3,
Status: "created",
TradeNo: tradeNo,
Payload: wechatV3ResponsePayload(signInfo, response),
}, nil
}
func wechatV3PrepayCreateResult(client *gopayWechatV3.ClientV3, appID, tradeType, tradeNo string, rsp *gopayWechatV3.PrepayRsp) (*biz.PaymentResult, error) {
if rsp == nil {
return nil, errors.New("微信支付 v3 下单响应为空")
}
result, err := wechatV3CreateResult(tradeNo, rsp.Code, rsp.SignInfo, rsp.Response, rsp.ErrResponse, rsp.Error)
if err != nil {
return nil, err
}
if rsp.Response == nil || strings.TrimSpace(rsp.Response.PrepayId) == "" {
return nil, errors.New("微信支付 v3 下单响应缺少 prepay_id")
}
var payParams any
switch strings.ToLower(strings.TrimSpace(tradeType)) {
case "app":
payParams, err = client.PaySignOfApp(appID, rsp.Response.PrepayId)
case "mini", "miniprogram", "mini_program", "applet":
payParams, err = client.PaySignOfApplet(appID, rsp.Response.PrepayId)
default:
payParams, err = client.PaySignOfJSAPI(appID, rsp.Response.PrepayId)
}
if err != nil {
return nil, fmt.Errorf("生成微信支付 v3 客户端调起参数: %w", err)
}
result.Payload = mustJSON(payParams)
return result, nil
}
func wechatV3APIError(operation string, code int, response gopayWechatV3.ErrResponse, raw string) error {
message := strings.TrimSpace(response.Message)
if message == "" {
message = strings.TrimSpace(raw)
}
if message == "" {
return fmt.Errorf("微信支付 v3 %s HTTP %d", operation, code)
}
return fmt.Errorf("微信支付 v3 %s HTTP %d: %s", operation, code, message)
}
func wechatV3ResponsePayload(signInfo *gopayWechatV3.SignInfo, response any) []byte {
if signInfo != nil && strings.TrimSpace(signInfo.SignBody) != "" {
return []byte(signInfo.SignBody)
}
payload, _ := json.Marshal(response)
return payload
}
func populateWechatV3Breakdown(result *biz.PaymentResult, object map[string]any) error {
if result == nil || result.Status != "success" {
return nil
}
amount, ok := valueAtPath(object, "amount").(map[string]any)
if !ok {
return nil
}
payerTotal, payerOK, err := parseJSONInteger(amount, "payer_total")
if err != nil {
return fmt.Errorf("解析微信支付 v3 amount.payer_total: %w", err)
}
if !payerOK {
return nil
}
if payerTotal < 0 || payerTotal > result.Amount {
return errors.New("微信支付 v3 用户实付金额无效")
}
result.PayerPaidAmount = payerTotal
result.CashPaidAmount = payerTotal
result.DiscountAmount = result.Amount - payerTotal
if settlement, ok, settlementErr := parseJSONInteger(amount, "settlement_amount"); settlementErr != nil {
return fmt.Errorf("解析微信支付 v3 amount.settlement_amount: %w", settlementErr)
} else if ok {
result.SettlementAmount = settlement
}
// promotion_detail contains the authoritative promotion breakdown, but its
// funding fields vary by promotion type. We only expose the aggregate
// discount here; the original JSON remains in Payload for audit.
if result.DiscountAmount < 0 {
return errors.New("微信支付 v3 优惠金额无效")
}
result.AmountBreakdownKnown = true
return nil
}
func parseJSONInteger(object map[string]any, key string) (int64, bool, error) {
raw, exists := object[key]
if !exists || raw == nil || strings.TrimSpace(fmt.Sprint(raw)) == "" || fmt.Sprint(raw) == "<nil>" {
return 0, false, nil
}
amount, err := parseIntegerAmount(fmt.Sprint(raw))
return amount, true, err
}
func decryptWechatV3(ciphertext, nonce, associatedData, key string) ([]byte, error) {
if len(key) != 32 {
return nil, errors.New("微信支付 v3 api_v3_key 必须为 32 字节")
}
return gopayWechatV3.V3DecryptNotifyCipherTextToBytes(ciphertext, nonce, associatedData, key)
}
+23
View File
@@ -0,0 +1,23 @@
package data
import "kra/pkg/paymentkit"
func text(values map[string]any, key string) string {
value, _ := values[key].(string)
return value
}
func firstAny(values map[string]any, keys ...string) string {
for _, key := range keys {
if value, ok := values[key].(string); ok && value != "" {
return value
}
}
return ""
}
// configuredInt64 keeps repository-side validation independent from the
// provider adapter package while sharing the canonical paymentkit parser.
func configuredInt64(values map[string]any, key string, fallback int64) int64 {
return paymentkit.ConfiguredInt64(values, key, fallback)
}
+42
View File
@@ -0,0 +1,42 @@
package data
import (
"testing"
"kra/internal/biz"
)
func TestMigrateSeedsPaymentProviders(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)
}
var count int64
if err = db.Model(&integrationConfigPO{}).Where("kind = ?", integrationKindPayment).Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != int64(len(biz.SupportedPaymentProviders)) {
t.Fatalf("payment providers = %d", count)
}
if !db.Migrator().HasTable("pay_orders") {
t.Fatal("payment migration must create pay_orders")
}
if db.Migrator().HasTable("pay_callback_events") {
t.Fatal("payment migration must not create pay_callback_events")
}
}
func TestPaymentSecretMaskAndMerge(t *testing.T) {
values := map[string]any{"app_id": "a", "private_key": "private", "nested": map[string]any{"api_v3_key": "key"}}
maskPaymentSecrets(values)
if values["private_key"] != "******" || values["nested"].(map[string]any)["api_v3_key"] != "******" {
t.Fatalf("not masked: %#v", values)
}
mergePaymentSecrets(values, map[string]any{"private_key": "private", "nested": map[string]any{"api_v3_key": "key"}})
if values["private_key"] != "private" || values["nested"].(map[string]any)["api_v3_key"] != "key" {
t.Fatalf("not merged: %#v", values)
}
}
+488
View File
@@ -0,0 +1,488 @@
package data
import (
"context"
"encoding/json"
"errors"
"strings"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"kra/internal/biz"
)
type paymentOrderPO struct {
ID uint64 `gorm:"primaryKey"`
CreatedAt time.Time
UpdatedAt time.Time
TradeNo string `gorm:"size:128;not null;uniqueIndex:idx_pay_orders_provider_trade"`
Provider string `gorm:"size:64;not null;uniqueIndex:idx_pay_orders_provider_trade;uniqueIndex:idx_pay_orders_provider_provider_trade;index"`
ProviderTradeNo *string `gorm:"size:128;uniqueIndex:idx_pay_orders_provider_provider_trade"`
QueryID string `gorm:"size:128"`
BusinessType string `gorm:"size:64;not null;index:idx_pay_orders_business"`
BusinessID string `gorm:"size:128;not null;index:idx_pay_orders_business"`
Subject string `gorm:"size:256;not null"`
PaymentMode string `gorm:"size:32;not null;default:external;index"`
OriginalAmount int64 `gorm:"not null;default:0"`
Amount int64 `gorm:"not null"`
PaidAmount int64 `gorm:"not null;default:0"`
PayerPaidAmount int64 `gorm:"not null;default:0"`
CashPaidAmount int64 `gorm:"not null;default:0"`
PointPaidAmount int64 `gorm:"not null;default:0"`
DiscountAmount int64 `gorm:"not null;default:0"`
ProviderDiscountAmount int64 `gorm:"not null;default:0"`
MerchantDiscountAmount int64 `gorm:"not null;default:0"`
SettlementAmount int64 `gorm:"not null;default:0"`
Currency string `gorm:"size:16;not null"`
PayerCurrency string `gorm:"size:16"`
AmountBreakdownKnown bool `gorm:"not null;default:false"`
PaymentStatus string `gorm:"size:32;not null;index"`
ProviderStatus string `gorm:"size:64"`
FulfillmentStatus string `gorm:"size:32;not null;index"`
RefundStatus string `gorm:"size:32;not null;index"`
RefundedAmount int64 `gorm:"not null;default:0"`
RefundRequestedAmount int64 `gorm:"not null;default:0"`
RefundNo string `gorm:"size:128;index"`
ConfirmationID string `gorm:"size:36;not null;uniqueIndex"`
RequestFingerprint string `gorm:"size:64;not null;index"`
CreatePayload string `gorm:"type:text"`
Extra string `gorm:"type:text"`
LastEventID string `gorm:"size:128"`
LastPayloadHash string `gorm:"size:64"`
LastError string `gorm:"size:512"`
FulfillmentToken string `gorm:"size:36"`
RefundToken string `gorm:"size:36"`
Version uint64 `gorm:"not null;default:1"`
PaidAt *time.Time
FulfilledAt *time.Time
RefundedAt *time.Time
FulfillmentLeaseUntil *time.Time
RefundLeaseUntil *time.Time
}
func (paymentOrderPO) TableName() string { return "pay_orders" }
type paymentOrderRepo struct{ data *Data }
func NewPaymentOrderRepo(data *Data) biz.PaymentOrderRepo {
return &paymentOrderRepo{data: data}
}
func newPaymentOrderPO(order *biz.PaymentOrder) (*paymentOrderPO, error) {
if order == nil {
return nil, errors.New("支付订单为空")
}
extra := string(order.Extra)
if extra == "" {
extra = "{}"
}
createPayload := string(order.CreatePayload)
return &paymentOrderPO{
ID: order.ID, TradeNo: order.TradeNo, Provider: order.Provider,
ProviderTradeNo: optionalString(order.ProviderTradeNo), QueryID: order.QueryID,
BusinessType: order.BusinessType, BusinessID: order.BusinessID, Subject: order.Subject,
PaymentMode: defaultString(order.PaymentMode, biz.PaymentModeExternal), OriginalAmount: order.OriginalAmount,
Amount: order.Amount, PaidAmount: order.PaidAmount, PayerPaidAmount: order.PayerPaidAmount,
CashPaidAmount: order.CashPaidAmount, PointPaidAmount: order.PointPaidAmount, DiscountAmount: order.DiscountAmount,
ProviderDiscountAmount: order.ProviderDiscountAmount, MerchantDiscountAmount: order.MerchantDiscountAmount,
SettlementAmount: order.SettlementAmount, Currency: order.Currency, PayerCurrency: order.PayerCurrency,
AmountBreakdownKnown: order.AmountBreakdownKnown,
PaymentStatus: defaultString(order.PaymentStatus, biz.PaymentStatusInitialized),
ProviderStatus: order.ProviderStatus,
FulfillmentStatus: defaultString(order.FulfillmentStatus, biz.FulfillmentStatusPending),
RefundStatus: defaultString(order.RefundStatus, biz.RefundStatusNone),
RefundedAmount: order.RefundedAmount, RefundRequestedAmount: order.RefundRequestedAmount, RefundNo: order.RefundNo,
ConfirmationID: order.ConfirmationID, RequestFingerprint: order.RequestFingerprint,
CreatePayload: createPayload, Extra: extra, LastEventID: order.LastEventID,
LastPayloadHash: order.LastPayloadHash, LastError: order.LastError,
FulfillmentToken: order.FulfillmentToken, RefundToken: order.RefundToken, Version: defaultVersion(order.Version),
PaidAt: order.PaidAt, FulfilledAt: order.FulfilledAt, RefundedAt: order.RefundedAt,
FulfillmentLeaseUntil: order.FulfillmentLeaseUntil, RefundLeaseUntil: order.RefundLeaseUntil,
}, nil
}
func toBizPaymentOrder(po *paymentOrderPO) *biz.PaymentOrder {
if po == nil {
return nil
}
return &biz.PaymentOrder{
ID: po.ID, TradeNo: po.TradeNo, Provider: po.Provider,
ProviderTradeNo: dereferenceString(po.ProviderTradeNo), QueryID: po.QueryID,
BusinessType: po.BusinessType, BusinessID: po.BusinessID, Subject: po.Subject,
PaymentMode: defaultString(po.PaymentMode, biz.PaymentModeExternal), OriginalAmount: po.OriginalAmount,
Amount: po.Amount, PaidAmount: po.PaidAmount, PayerPaidAmount: po.PayerPaidAmount,
CashPaidAmount: po.CashPaidAmount, PointPaidAmount: po.PointPaidAmount, DiscountAmount: po.DiscountAmount,
ProviderDiscountAmount: po.ProviderDiscountAmount, MerchantDiscountAmount: po.MerchantDiscountAmount,
SettlementAmount: po.SettlementAmount, Currency: po.Currency, PayerCurrency: po.PayerCurrency,
AmountBreakdownKnown: po.AmountBreakdownKnown,
PaymentStatus: po.PaymentStatus, ProviderStatus: po.ProviderStatus,
FulfillmentStatus: po.FulfillmentStatus, RefundStatus: po.RefundStatus,
RefundedAmount: po.RefundedAmount, RefundRequestedAmount: po.RefundRequestedAmount, RefundNo: po.RefundNo,
ConfirmationID: po.ConfirmationID, RequestFingerprint: po.RequestFingerprint,
CreatePayload: json.RawMessage(po.CreatePayload), Extra: json.RawMessage(po.Extra),
LastEventID: po.LastEventID, LastPayloadHash: po.LastPayloadHash, LastError: po.LastError,
FulfillmentToken: po.FulfillmentToken, RefundToken: po.RefundToken, Version: po.Version, CreatedAt: po.CreatedAt,
UpdatedAt: po.UpdatedAt, PaidAt: po.PaidAt, FulfilledAt: po.FulfilledAt,
RefundedAt: po.RefundedAt, FulfillmentLeaseUntil: po.FulfillmentLeaseUntil, RefundLeaseUntil: po.RefundLeaseUntil,
}
}
func (r *paymentOrderRepo) CreatePaymentOrder(ctx context.Context, order *biz.PaymentOrder) (*biz.PaymentOrder, bool, error) {
po, err := newPaymentOrderPO(order)
if err != nil {
return nil, false, err
}
db := r.data.gormDB.WithContext(ctx)
var current paymentOrderPO
err = db.Where("provider = ? AND trade_no = ?", po.Provider, po.TradeNo).First(&current).Error
if err == nil {
return toBizPaymentOrder(&current), false, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, false, err
}
if err = db.Create(po).Error; err == nil {
return toBizPaymentOrder(po), true, nil
}
if lookupErr := db.Where("provider = ? AND trade_no = ?", po.Provider, po.TradeNo).First(&current).Error; lookupErr == nil {
return toBizPaymentOrder(&current), false, nil
}
return nil, false, err
}
func (r *paymentOrderRepo) FindPaymentOrder(ctx context.Context, provider, tradeNo string) (*biz.PaymentOrder, error) {
var po paymentOrderPO
if err := r.data.gormDB.WithContext(ctx).Where("provider = ? AND trade_no = ?", provider, tradeNo).First(&po).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, biz.ErrPaymentOrderNotFound
}
return nil, err
}
return toBizPaymentOrder(&po), nil
}
func (r *paymentOrderRepo) RecordPaymentCreate(ctx context.Context, provider, tradeNo string, update *biz.PaymentProviderUpdate) (*biz.PaymentOrder, error) {
return r.withLockedOrder(ctx, provider, tradeNo, func(tx *gorm.DB, po *paymentOrderPO) error {
if update == nil {
return errors.New("支付下单结果为空")
}
if err := applyProviderIdentity(tx, po, update); err != nil {
return err
}
if len(update.CreatePayload) > 0 {
po.CreatePayload = string(update.CreatePayload)
}
po.ProviderStatus = trimTo(update.ProviderStatus, 64)
po.LastEventID = trimTo(update.EventID, 128)
po.LastPayloadHash = trimTo(update.PayloadHash, 64)
status := normalizeOrderPaymentStatus(update.Status)
if status == biz.PaymentStatusPaid {
// Provider create responses are never sufficient proof of payment.
status = biz.PaymentStatusPending
}
if po.PaymentStatus != biz.PaymentStatusPaid && po.PaymentStatus != biz.PaymentStatusPartiallyRefunded && po.PaymentStatus != biz.PaymentStatusRefunded && status != "" {
po.PaymentStatus = status
}
po.Version++
return tx.Save(po).Error
})
}
func (r *paymentOrderRepo) ApplyPaymentResult(ctx context.Context, provider, tradeNo string, update *biz.PaymentProviderUpdate) (*biz.PaymentOrder, error) {
return r.withLockedOrder(ctx, provider, tradeNo, func(tx *gorm.DB, po *paymentOrderPO) error {
if update == nil {
return errors.New("支付查单结果为空")
}
if err := applyProviderIdentity(tx, po, update); err != nil {
return err
}
if update.Amount > 0 && update.Amount != po.Amount {
return biz.ErrPaymentOrderConflict
}
if update.PayerPaidAmount < 0 || update.CashPaidAmount < 0 || update.PointPaidAmount < 0 || update.DiscountAmount < 0 || update.ProviderDiscountAmount < 0 || update.MerchantDiscountAmount < 0 || update.SettlementAmount < 0 {
return biz.ErrPaymentOrderConflict
}
if update.Currency != "" && !strings.EqualFold(update.Currency, po.Currency) {
return biz.ErrPaymentOrderConflict
}
po.ProviderStatus = trimTo(update.ProviderStatus, 64)
po.LastEventID = trimTo(update.EventID, 128)
po.LastPayloadHash = trimTo(update.PayloadHash, 64)
if update.AmountBreakdownKnown {
po.PayerPaidAmount = update.PayerPaidAmount
po.CashPaidAmount = update.CashPaidAmount
po.PointPaidAmount = update.PointPaidAmount
po.DiscountAmount = update.DiscountAmount
po.ProviderDiscountAmount = update.ProviderDiscountAmount
po.MerchantDiscountAmount = update.MerchantDiscountAmount
po.SettlementAmount = update.SettlementAmount
po.PayerCurrency = trimTo(update.PayerCurrency, 16)
po.AmountBreakdownKnown = true
}
switch normalizeOrderPaymentStatus(update.Status) {
case biz.PaymentStatusPaid:
if po.PaymentStatus != biz.PaymentStatusPartiallyRefunded && po.PaymentStatus != biz.PaymentStatusRefunded {
po.PaymentStatus = biz.PaymentStatusPaid
}
po.PaidAmount = po.Amount
if po.PaidAt == nil {
now := time.Now().UTC()
po.PaidAt = &now
}
case biz.PaymentStatusPending:
if po.PaymentStatus != biz.PaymentStatusPaid && po.PaymentStatus != biz.PaymentStatusPartiallyRefunded && po.PaymentStatus != biz.PaymentStatusRefunded {
po.PaymentStatus = biz.PaymentStatusPending
}
case biz.PaymentStatusFailed:
if po.PaymentStatus != biz.PaymentStatusPaid && po.PaymentStatus != biz.PaymentStatusPartiallyRefunded && po.PaymentStatus != biz.PaymentStatusRefunded {
po.PaymentStatus = biz.PaymentStatusFailed
}
}
po.Version++
return tx.Save(po).Error
})
}
func (r *paymentOrderRepo) BeginPaymentFulfillment(ctx context.Context, provider, tradeNo string, lease time.Duration) (*biz.PaymentOrder, string, bool, error) {
var token string
var duplicate bool
order, err := r.withLockedOrder(ctx, provider, tradeNo, func(tx *gorm.DB, po *paymentOrderPO) error {
if po.FulfillmentStatus == biz.FulfillmentStatusSucceeded {
duplicate = true
return nil
}
if po.PaymentStatus != biz.PaymentStatusPaid {
return biz.ErrPaymentOrderState
}
now := time.Now().UTC()
if po.FulfillmentStatus == biz.FulfillmentStatusProcessing && po.FulfillmentLeaseUntil != nil && po.FulfillmentLeaseUntil.After(now) {
return biz.ErrPaymentOrderBusy
}
if lease <= 0 {
lease = 10 * time.Minute
}
token = uuid.NewString()
until := now.Add(lease)
po.FulfillmentStatus = biz.FulfillmentStatusProcessing
po.FulfillmentToken = token
po.FulfillmentLeaseUntil = &until
po.LastError = ""
po.Version++
return tx.Save(po).Error
})
return order, token, duplicate, err
}
func (r *paymentOrderRepo) CompletePaymentFulfillment(ctx context.Context, provider, tradeNo, token string, success bool, message string) (*biz.PaymentOrder, error) {
return r.withLockedOrder(ctx, provider, tradeNo, func(tx *gorm.DB, po *paymentOrderPO) error {
if po.FulfillmentStatus != biz.FulfillmentStatusProcessing || po.FulfillmentToken != token {
return biz.ErrPaymentOrderBusy
}
po.FulfillmentToken = ""
po.FulfillmentLeaseUntil = nil
po.LastError = trimTo(message, 512)
if success {
po.FulfillmentStatus = biz.FulfillmentStatusSucceeded
now := time.Now().UTC()
po.FulfilledAt = &now
} else {
po.FulfillmentStatus = biz.FulfillmentStatusFailed
}
po.Version++
return tx.Save(po).Error
})
}
func (r *paymentOrderRepo) BeginPaymentRefund(ctx context.Context, provider, tradeNo string, amount int64, lease time.Duration) (*biz.PaymentOrder, string, error) {
var token string
order, err := r.withLockedOrder(ctx, provider, tradeNo, func(tx *gorm.DB, po *paymentOrderPO) error {
if po.PaymentStatus != biz.PaymentStatusPaid && po.PaymentStatus != biz.PaymentStatusPartiallyRefunded {
return biz.ErrPaymentOrderState
}
if amount <= 0 {
return biz.ErrPaymentOrderConflict
}
now := time.Now().UTC()
if po.RefundStatus == biz.RefundStatusProcessing && po.RefundLeaseUntil != nil && po.RefundLeaseUntil.After(now) {
return biz.ErrPaymentOrderBusy
}
if po.RefundStatus == biz.RefundStatusProcessing && po.RefundRequestedAmount != amount {
return biz.ErrPaymentOrderConflict
}
if po.RefundStatus == biz.RefundStatusPending {
return biz.ErrPaymentOrderBusy
}
// An expired processing lease means the provider outcome is unknown.
// Retry the same refund amount with the same durable refund number. A
// different amount must never reuse that operation identity.
reserved := int64(0)
if po.RefundStatus == biz.RefundStatusProcessing && po.RefundLeaseUntil != nil && !po.RefundLeaseUntil.After(now) {
reserved = 0
} else {
reserved = po.RefundRequestedAmount
}
if amount > po.Amount-po.RefundedAmount-reserved {
return biz.ErrPaymentOrderConflict
}
if lease <= 0 {
lease = 10 * time.Minute
}
token = uuid.NewString()
if po.RefundNo == "" {
po.RefundNo = uuid.NewString()
}
until := now.Add(lease)
po.RefundStatus = biz.RefundStatusProcessing
po.RefundRequestedAmount = amount
po.RefundToken = token
po.RefundLeaseUntil = &until
po.LastError = ""
po.Version++
return tx.Save(po).Error
})
return order, token, err
}
func (r *paymentOrderRepo) CompletePaymentRefundRequest(ctx context.Context, provider, tradeNo, token string, accepted bool, message string) (*biz.PaymentOrder, error) {
return r.withLockedOrder(ctx, provider, tradeNo, func(tx *gorm.DB, po *paymentOrderPO) error {
if po.RefundStatus != biz.RefundStatusProcessing || po.RefundToken != token {
return biz.ErrPaymentOrderBusy
}
po.RefundToken = ""
po.RefundLeaseUntil = nil
po.LastError = trimTo(message, 512)
if accepted {
po.RefundStatus = biz.RefundStatusPending
} else {
po.RefundStatus = biz.RefundStatusFailed
po.RefundRequestedAmount = 0
po.RefundNo = ""
}
po.Version++
return tx.Save(po).Error
})
}
func (r *paymentOrderRepo) ConfirmPaymentRefund(ctx context.Context, provider, tradeNo, refundNo string, amount int64, success bool, message string) (*biz.PaymentOrder, error) {
return r.withLockedOrder(ctx, provider, tradeNo, func(tx *gorm.DB, po *paymentOrderPO) error {
if po.RefundStatus != biz.RefundStatusPending || po.RefundNo == "" || po.RefundNo != refundNo || po.RefundRequestedAmount != amount {
return biz.ErrPaymentOrderState
}
po.LastError = trimTo(message, 512)
po.RefundRequestedAmount = 0
if !success {
po.RefundStatus = biz.RefundStatusFailed
po.RefundNo = ""
po.Version++
return tx.Save(po).Error
}
po.RefundedAmount += amount
if po.RefundedAmount >= po.Amount {
po.PaymentStatus = biz.PaymentStatusRefunded
po.RefundStatus = biz.RefundStatusSucceeded
} else {
po.PaymentStatus = biz.PaymentStatusPartiallyRefunded
po.RefundStatus = biz.RefundStatusPartial
}
po.RefundNo = ""
now := time.Now().UTC()
po.RefundedAt = &now
po.Version++
return tx.Save(po).Error
})
}
func (r *paymentOrderRepo) withLockedOrder(ctx context.Context, provider, tradeNo string, fn func(*gorm.DB, *paymentOrderPO) error) (*biz.PaymentOrder, error) {
var result *biz.PaymentOrder
err := r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var po paymentOrderPO
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("provider = ? AND trade_no = ?", provider, tradeNo).First(&po).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return biz.ErrPaymentOrderNotFound
}
return err
}
if err := fn(tx, &po); err != nil {
return err
}
result = toBizPaymentOrder(&po)
return nil
})
return result, err
}
func applyProviderIdentity(tx *gorm.DB, po *paymentOrderPO, update *biz.PaymentProviderUpdate) error {
if update.ProviderTradeNo != "" {
if po.ProviderTradeNo != nil && *po.ProviderTradeNo != update.ProviderTradeNo {
return biz.ErrPaymentProviderConflict
}
var other paymentOrderPO
// This check is repeated under the order transaction so a platform
// transaction cannot be attached to a different merchant order.
if err := tx.Where("provider = ? AND provider_trade_no = ? AND id <> ?", po.Provider, update.ProviderTradeNo, po.ID).First(&other).Error; err == nil {
return biz.ErrPaymentProviderConflict
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
value := update.ProviderTradeNo
po.ProviderTradeNo = &value
}
if update.QueryID != "" {
if po.QueryID != "" && po.QueryID != update.QueryID {
return biz.ErrPaymentProviderConflict
}
po.QueryID = update.QueryID
}
return nil
}
func normalizeOrderPaymentStatus(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "success", "paid", "fulfilled":
return biz.PaymentStatusPaid
case "pending", "created", "client_pending", "processing":
return biz.PaymentStatusPending
case "failed", "closed", "cancelled", "canceled":
return biz.PaymentStatusFailed
default:
return ""
}
}
func optionalString(value string) *string {
if value == "" {
return nil
}
return &value
}
func dereferenceString(value *string) string {
if value == nil {
return ""
}
return *value
}
func defaultString(value, fallback string) string {
if value == "" {
return fallback
}
return value
}
func defaultVersion(value uint64) uint64 {
if value == 0 {
return 1
}
return value
}
func trimTo(value string, max int) string {
value = strings.TrimSpace(value)
if len(value) > max {
return value[:max]
}
return value
}
+93
View File
@@ -0,0 +1,93 @@
package data
import (
"context"
"testing"
"time"
"kra/internal/biz"
)
func newPaymentOrderRepoForTest(t *testing.T) *paymentOrderRepo {
t.Helper()
db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared")
if err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&paymentOrderPO{}); err != nil {
t.Fatal(err)
}
return &paymentOrderRepo{data: &Data{gormDB: newReloadableDB(db, nil)}}
}
func testPaymentOrder() *biz.PaymentOrder {
return &biz.PaymentOrder{
TradeNo: "order-1", Provider: biz.PaymentAlipay, BusinessType: "game_item", BusinessID: "item-1",
Subject: "item", Amount: 100, Currency: "CNY", PaymentStatus: biz.PaymentStatusInitialized,
FulfillmentStatus: biz.FulfillmentStatusPending, RefundStatus: biz.RefundStatusNone,
ConfirmationID: "11111111-1111-1111-1111-111111111111", RequestFingerprint: "fingerprint",
}
}
func TestPaymentOrderRepositoryPersistsPaymentFulfillmentAndRefundState(t *testing.T) {
repo := newPaymentOrderRepoForTest(t)
ctx := context.Background()
order, created, err := repo.CreatePaymentOrder(ctx, testPaymentOrder())
if err != nil || !created {
t.Fatalf("create order = %#v created=%v err=%v", order, created, err)
}
update := &biz.PaymentProviderUpdate{Status: "success", ProviderStatus: "TRADE_SUCCESS", ProviderTradeNo: "provider-1", Amount: 100, Currency: "CNY", EventID: "event-1"}
order, err = repo.ApplyPaymentResult(ctx, biz.PaymentAlipay, "order-1", update)
if err != nil || order.PaymentStatus != biz.PaymentStatusPaid || order.PaidAmount != 100 {
t.Fatalf("apply payment = %#v err=%v", order, err)
}
order, token, duplicate, err := repo.BeginPaymentFulfillment(ctx, biz.PaymentAlipay, "order-1", time.Minute)
if err != nil || duplicate || token == "" || order.FulfillmentStatus != biz.FulfillmentStatusProcessing {
t.Fatalf("begin fulfillment = %#v token=%q duplicate=%v err=%v", order, token, duplicate, err)
}
if _, _, _, err = repo.BeginPaymentFulfillment(ctx, biz.PaymentAlipay, "order-1", time.Minute); err == nil {
t.Fatal("concurrent fulfillment was accepted")
}
order, err = repo.CompletePaymentFulfillment(ctx, biz.PaymentAlipay, "order-1", token, true, "")
if err != nil || order.FulfillmentStatus != biz.FulfillmentStatusSucceeded {
t.Fatalf("complete fulfillment = %#v err=%v", order, err)
}
_, _, duplicate, err = repo.BeginPaymentFulfillment(ctx, biz.PaymentAlipay, "order-1", time.Minute)
if err != nil || !duplicate {
t.Fatalf("duplicate fulfillment = duplicate=%v err=%v", duplicate, err)
}
order, token, err = repo.BeginPaymentRefund(ctx, biz.PaymentAlipay, "order-1", 40, time.Minute)
if err != nil || token == "" || order.RefundStatus != biz.RefundStatusProcessing {
t.Fatalf("begin refund = %#v token=%q err=%v", order, token, err)
}
order, err = repo.CompletePaymentRefundRequest(ctx, biz.PaymentAlipay, "order-1", token, true, "")
if err != nil || order.RefundStatus != biz.RefundStatusPending || order.RefundRequestedAmount != 40 {
t.Fatalf("accept refund = %#v err=%v", order, err)
}
order, err = repo.ConfirmPaymentRefund(ctx, biz.PaymentAlipay, "order-1", order.RefundNo, 40, true, "")
if err != nil || order.RefundedAmount != 40 || order.PaymentStatus != biz.PaymentStatusPartiallyRefunded {
t.Fatalf("confirm refund = %#v err=%v", order, err)
}
}
func TestPaymentOrderRepositoryRejectsProviderTradeReuse(t *testing.T) {
repo := newPaymentOrderRepoForTest(t)
ctx := context.Background()
first := testPaymentOrder()
if _, _, err := repo.CreatePaymentOrder(ctx, first); err != nil {
t.Fatal(err)
}
second := testPaymentOrder()
second.TradeNo = "order-2"
second.ConfirmationID = "22222222-2222-2222-2222-222222222222"
if _, _, err := repo.CreatePaymentOrder(ctx, second); err != nil {
t.Fatal(err)
}
update := &biz.PaymentProviderUpdate{Status: "success", ProviderTradeNo: "provider-1", Amount: 100, Currency: "CNY"}
if _, err := repo.ApplyPaymentResult(ctx, biz.PaymentAlipay, "order-1", update); err != nil {
t.Fatal(err)
}
if _, err := repo.ApplyPaymentResult(ctx, biz.PaymentAlipay, "order-2", update); err != biz.ErrPaymentProviderConflict {
t.Fatalf("provider trade reuse err = %v", err)
}
}
+8 -33
View File
@@ -2,8 +2,6 @@ package storage
import (
"context"
"crypto/md5"
"encoding/hex"
"fmt"
"io"
"mime"
@@ -14,6 +12,7 @@ import (
"kra/internal/biz"
"kra/internal/conf"
"kra/pkg/osskit"
)
type fileStorage struct {
@@ -66,40 +65,16 @@ func New(config *conf.AdminBackend) (biz.FileStorage, error) {
}
func composeFiles(ctx context.Context, storage biz.FileStorage, names []string, destination string) (*biz.StoredFile, string, error) {
reader, writer := io.Pipe()
hash := md5.New()
errCh := make(chan error, 1)
go func() {
defer writer.Close()
for _, name := range names {
if ctx.Err() != nil {
errCh <- ctx.Err()
return
}
file, err := storage.Open(ctx, name)
if err != nil {
errCh <- err
return
}
_, err = io.Copy(io.MultiWriter(writer, hash), file)
_ = file.Close()
if err != nil {
errCh <- err
return
}
}
errCh <- nil
}()
stored, err := storage.Put(ctx, destination, reader)
composeErr := <-errCh
var stored *biz.StoredFile
hash, err := osskit.ComposeStreams(ctx, names, storage.Open, func(ctx context.Context, destination string, reader io.Reader) error {
var putErr error
stored, putErr = storage.Put(ctx, destination, reader)
return putErr
}, storage.Delete, destination)
if err != nil {
return nil, "", err
}
if composeErr != nil {
_ = storage.Delete(ctx, destination)
return nil, "", composeErr
}
return stored, hex.EncodeToString(hash.Sum(nil)), nil
return stored, hash, nil
}
func (s *fileStorage) resolve(name string) (string, error) {
+1
View File
@@ -63,6 +63,7 @@ func NewGinEngine(runtime *conf.Runtime, access *service.AccessControlService, h
serverrouter.RegisterMedia(private, handlers.Media)
serverrouter.RegisterAnnouncement(private, public, handlers.Announcement)
serverrouter.RegisterEmail(private, handlers.Email)
serverrouter.RegisterPayment(private, public, handlers.Payment)
registerSwagger(engine, prefix, version, logger)
registerLocalStorage(engine, runtime)
+9 -2
View File
@@ -21,7 +21,7 @@ func emptyHandlers() *handler.Set {
return &handler.Set{
Authority: &handler.Authority{}, Menu: &handler.Menu{}, API: &handler.API{},
Permission: &handler.Permission{}, Organization: &handler.Organization{},
Announcement: &handler.Announcement{}, Email: &handler.Email{}, Task: &handler.Task{},
Announcement: &handler.Announcement{}, Email: &handler.Email{}, Payment: &handler.Payment{}, Task: &handler.Task{},
Media: &handler.Media{}, Audit: &handler.Audit{}, Export: &handler.Export{},
Version: &handler.Version{}, Dictionary: &handler.Dictionary{}, Parameter: &handler.Parameter{},
APIToken: &handler.APIToken{}, SystemConfig: &handler.SystemConfig{}, Public: &handler.Public{},
@@ -56,7 +56,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":178`) {
if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":185`) {
t.Fatalf("startup route summary is missing: %s", text)
}
}
@@ -256,6 +256,7 @@ GET /logViewer/content
GET /logViewer/dates
GET /logViewer/files
GET /menu/getMenuRoles
GET /payment/configs
GET /position/findPosition
GET /position/getPositionUsers
GET /securityConfig/getSecurityConfig
@@ -348,6 +349,12 @@ POST /menu/getMenuAuthority
POST /menu/getMenuList
POST /menu/setMenuRoles
POST /menu/updateBaseMenu
POST /payment/callback/:provider
POST /payment/config
POST /payment/create
POST /payment/order
POST /payment/query
POST /payment/refund
POST /position/createPosition
POST /position/getPositionList
POST /position/setPositionUsers
+137
View File
@@ -0,0 +1,137 @@
package handler
import (
"io"
"kra/internal/biz"
"kra/internal/server/httpx"
"kra/internal/service"
"kra/internal/service/dto"
"net/http"
"github.com/gin-gonic/gin"
)
type Payment struct{ service *service.PaymentService }
func NewPayment(service *service.PaymentService) *Payment { return &Payment{service: service} }
func (h *Payment) Configs(c *gin.Context) {
v, err := h.service.Configs(c.Request.Context())
if err != nil {
httpx.Fail(c, err.Error())
return
}
httpx.OKWithData(c, v)
}
func (h *Payment) SaveConfig(c *gin.Context) {
var req dto.PaymentConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
httpx.Fail(c, err.Error())
return
}
if err := h.service.SaveConfig(c.Request.Context(), &biz.PaymentConfig{Provider: req.Provider, Enabled: req.Enabled, Values: req.Config}); err != nil {
httpx.Fail(c, err.Error())
return
}
httpx.OK(c)
}
func (h *Payment) Order(c *gin.Context) {
var req dto.PaymentQueryRequest
if err := c.ShouldBindJSON(&req); err != nil {
httpx.Fail(c, err.Error())
return
}
order, err := h.service.Order(c.Request.Context(), req.Provider, req.TradeNo)
if err != nil {
httpx.Fail(c, err.Error())
return
}
httpx.OKWithData(c, order)
}
func (h *Payment) Create(c *gin.Context) {
var req dto.PaymentRequest
if err := c.ShouldBindJSON(&req); err != nil {
httpx.Fail(c, err.Error())
return
}
req.ClientIP = c.ClientIP()
result, err := h.service.Create(c.Request.Context(), &biz.PaymentRequest{
Provider: req.Provider, TradeNo: req.TradeNo, Subject: req.Subject,
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,
})
if err != nil {
httpx.Fail(c, err.Error())
return
}
httpx.OKWithData(c, result)
}
func (h *Payment) Query(c *gin.Context) {
var req dto.PaymentQueryRequest
if err := c.ShouldBindJSON(&req); err != nil {
httpx.Fail(c, err.Error())
return
}
result, err := h.service.Query(c.Request.Context(), req.Provider, req.TradeNo)
if err != nil {
httpx.Fail(c, err.Error())
return
}
httpx.OKWithData(c, result)
}
func (h *Payment) Refund(c *gin.Context) {
var req dto.PaymentRefundRequest
if err := c.ShouldBindJSON(&req); err != nil {
httpx.Fail(c, err.Error())
return
}
result, err := h.service.Refund(c.Request.Context(), req.Provider, req.TradeNo, req.Amount)
if err != nil {
httpx.Fail(c, err.Error())
return
}
httpx.OKWithData(c, result)
}
func (h *Payment) Callback(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 8<<20)
raw, err := io.ReadAll(c.Request.Body)
if err != nil {
writePaymentCallbackAck(c, biz.DefaultPaymentCallbackAck(c.Param("provider"), false))
return
}
headers := map[string]string{}
for k, v := range c.Request.Header {
if len(v) > 0 {
headers[k] = v[0]
}
}
query := map[string]string{}
for k, v := range c.Request.URL.Query() {
if len(v) > 0 {
query[k] = v[0]
}
}
result, err := h.service.Callback(c.Request.Context(), &biz.PaymentCallback{Provider: c.Param("provider"), Headers: headers, Body: raw, Query: query})
if err != nil {
writePaymentCallbackAck(c, biz.CallbackFailure(err, biz.DefaultPaymentCallbackAck(c.Param("provider"), false)))
return
}
ack := biz.DefaultPaymentCallbackAck(c.Param("provider"), true)
if result != nil && result.SuccessAck.StatusCode != 0 {
ack = result.SuccessAck
}
writePaymentCallbackAck(c, ack)
}
func writePaymentCallbackAck(c *gin.Context, ack biz.PaymentCallbackAck) {
if ack.StatusCode == 0 {
ack.StatusCode = http.StatusInternalServerError
}
if ack.ContentType != "" {
c.Header("Content-Type", ack.ContentType)
}
c.Status(ack.StatusCode)
if len(ack.Body) > 0 {
_, _ = c.Writer.Write(ack.Body)
}
}
+29
View File
@@ -0,0 +1,29 @@
package handler
import (
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"kra/internal/biz"
)
func TestWritePaymentCallbackAckDoesNotExposeError(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
writePaymentCallbackAck(context, biz.PaymentCallbackAck{
StatusCode: 500,
ContentType: "text/plain; charset=utf-8",
Body: []byte("failure"),
})
if recorder.Code != 500 {
t.Fatalf("status = %d, want 500", recorder.Code)
}
if recorder.Body.String() != "failure" {
t.Fatalf("body = %q, want fixed failure ack", recorder.Body.String())
}
if recorder.Body.String() == "internal order resolver details" {
t.Fatal("internal error was exposed in callback response")
}
}
+3 -2
View File
@@ -8,6 +8,7 @@ type Set struct {
Organization *Organization
Announcement *Announcement
Email *Email
Payment *Payment
Task *Task
Media *Media
Audit *Audit
@@ -23,6 +24,6 @@ type Set struct {
Session *Session
}
func NewSet(authority *Authority, menu *Menu, api *API, permission *Permission, organization *Organization, announcement *Announcement, email *Email, task *Task, media *Media, audit *Audit, export *Export, version *Version, dictionary *Dictionary, parameter *Parameter, apiToken *APIToken, systemConfig *SystemConfig, public *Public, user *User, navigation *Navigation, session *Session) *Set {
return &Set{Authority: authority, Menu: menu, API: api, Permission: permission, Organization: organization, Announcement: announcement, Email: email, Task: task, Media: media, Audit: audit, Export: export, Version: version, Dictionary: dictionary, Parameter: parameter, APIToken: apiToken, SystemConfig: systemConfig, Public: public, User: user, Navigation: navigation, Session: session}
func NewSet(authority *Authority, menu *Menu, api *API, permission *Permission, organization *Organization, announcement *Announcement, email *Email, payment *Payment, task *Task, media *Media, audit *Audit, export *Export, version *Version, dictionary *Dictionary, parameter *Parameter, apiToken *APIToken, systemConfig *SystemConfig, public *Public, user *User, navigation *Navigation, session *Session) *Set {
return &Set{Authority: authority, Menu: menu, API: api, Permission: permission, Organization: organization, Announcement: announcement, Email: email, Payment: payment, Task: task, Media: media, Audit: audit, Export: export, Version: version, Dictionary: dictionary, Parameter: parameter, APIToken: apiToken, SystemConfig: systemConfig, Public: public, User: user, Navigation: navigation, Session: session}
}
+17
View File
@@ -0,0 +1,17 @@
package router
import (
"github.com/gin-gonic/gin"
"kra/internal/server/handler"
)
func RegisterPayment(group, public *gin.RouterGroup, h *handler.Payment) {
payment := group.Group("/payment")
payment.GET("/configs", h.Configs)
payment.POST("/config", h.SaveConfig)
payment.POST("/order", h.Order)
payment.POST("/create", h.Create)
payment.POST("/query", h.Query)
payment.POST("/refund", h.Refund)
public.POST("/payment/callback/:provider", h.Callback)
}
+1 -1
View File
@@ -7,4 +7,4 @@ import (
)
// ProviderSet is server providers.
var ProviderSet = wire.NewSet(NewGinEngine, NewGinServer, handler.NewAuthority, handler.NewMenu, handler.NewAPI, handler.NewPermission, handler.NewOrganization, handler.NewAnnouncement, handler.NewEmail, handler.NewTask, handler.NewMedia, handler.NewAudit, handler.NewExport, handler.NewVersion, handler.NewDictionary, handler.NewParameter, handler.NewAPIToken, handler.NewSystemConfig, handler.NewPublic, handler.NewUser, handler.NewNavigation, handler.NewSession, handler.NewSet)
var ProviderSet = wire.NewSet(NewGinEngine, NewGinServer, handler.NewAuthority, handler.NewMenu, handler.NewAPI, handler.NewPermission, handler.NewOrganization, handler.NewAnnouncement, handler.NewEmail, handler.NewPayment, handler.NewTask, handler.NewMedia, handler.NewAudit, handler.NewExport, handler.NewVersion, handler.NewDictionary, handler.NewParameter, handler.NewAPIToken, handler.NewSystemConfig, handler.NewPublic, handler.NewUser, handler.NewNavigation, handler.NewSession, handler.NewSet)
+69
View File
@@ -0,0 +1,69 @@
package dto
import (
"encoding/json"
"time"
)
type PaymentConfigRequest struct {
Provider string `json:"provider"`
Enabled bool `json:"enabled"`
Config json.RawMessage `json:"config"`
}
type PaymentRequest struct {
Provider string `json:"provider"`
TradeNo string `json:"tradeNo"`
Subject string `json:"subject"`
Amount int64 `json:"amount"`
OriginalAmount int64 `json:"originalAmount"`
Currency string `json:"currency"`
NotifyURL string `json:"notifyUrl"`
ReturnURL string `json:"returnUrl"`
Extra map[string]any `json:"extra"`
ClientIP string `json:"-"`
BusinessType string `json:"businessType"`
BusinessID string `json:"businessId"`
PaymentMode string `json:"paymentMode"`
}
type PaymentQueryRequest struct {
Provider string `json:"provider"`
TradeNo string `json:"tradeNo"`
}
type PaymentRefundRequest struct {
Provider string `json:"provider"`
TradeNo string `json:"tradeNo"`
Amount int64 `json:"amount"`
}
type PaymentOrderResponse struct {
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"`
FulfillmentStatus string `json:"fulfillmentStatus"`
RefundStatus string `json:"refundStatus"`
RefundedAmount int64 `json:"refundedAmount"`
RefundNo string `json:"refundNo,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"`
}
+49
View File
@@ -0,0 +1,49 @@
package service
import (
"context"
"kra/internal/biz"
"kra/internal/service/dto"
)
type PaymentService struct{ uc *biz.PaymentUsecase }
func NewPaymentService(uc *biz.PaymentUsecase) *PaymentService { return &PaymentService{uc: uc} }
func (s *PaymentService) Configs(ctx context.Context) ([]*biz.PaymentConfig, error) {
return s.uc.Configs(ctx)
}
func (s *PaymentService) SaveConfig(ctx context.Context, c *biz.PaymentConfig) error {
return s.uc.SaveConfig(ctx, c)
}
func (s *PaymentService) Order(ctx context.Context, provider, tradeNo string) (*dto.PaymentOrderResponse, error) {
order, err := s.uc.Order(ctx, provider, tradeNo)
if err != nil {
return nil, err
}
return &dto.PaymentOrderResponse{
Provider: order.Provider, TradeNo: order.TradeNo, ProviderTradeNo: order.ProviderTradeNo,
BusinessType: order.BusinessType, BusinessID: order.BusinessID, Subject: order.Subject,
PaymentMode: order.PaymentMode, OriginalAmount: order.OriginalAmount,
Amount: order.Amount, PaidAmount: order.PaidAmount, PayerPaidAmount: order.PayerPaidAmount,
CashPaidAmount: order.CashPaidAmount, PointPaidAmount: order.PointPaidAmount,
DiscountAmount: order.DiscountAmount, ProviderDiscountAmount: order.ProviderDiscountAmount,
MerchantDiscountAmount: order.MerchantDiscountAmount, SettlementAmount: order.SettlementAmount,
Currency: order.Currency, PayerCurrency: order.PayerCurrency, AmountBreakdownKnown: order.AmountBreakdownKnown,
PaymentStatus: order.PaymentStatus, FulfillmentStatus: order.FulfillmentStatus,
RefundStatus: order.RefundStatus, RefundedAmount: order.RefundedAmount, RefundNo: order.RefundNo,
CreatedAt: order.CreatedAt, UpdatedAt: order.UpdatedAt, PaidAt: order.PaidAt,
FulfilledAt: order.FulfilledAt, RefundedAt: order.RefundedAt,
}, nil
}
func (s *PaymentService) Create(ctx context.Context, req *biz.PaymentRequest) (*biz.PaymentResult, error) {
return s.uc.Create(ctx, req)
}
func (s *PaymentService) Query(ctx context.Context, provider, tradeNo string) (*biz.PaymentResult, error) {
return s.uc.Query(ctx, provider, tradeNo)
}
func (s *PaymentService) Refund(ctx context.Context, provider, tradeNo string, amount int64) (*biz.PaymentResult, error) {
return s.uc.Refund(ctx, provider, tradeNo, amount)
}
func (s *PaymentService) Callback(ctx context.Context, callback *biz.PaymentCallback) (*biz.PaymentResult, error) {
return s.uc.Callback(ctx, callback)
}
+1 -1
View File
@@ -3,4 +3,4 @@ package service
import "github.com/google/wire"
// ProviderSet is service providers.
var ProviderSet = wire.NewSet(NewAuthService, NewUserService, NewSystemConfigService, NewAuthorityService, NewPermissionService, NewAccessControlService, NewAPIService, NewMenuService, NewDepartmentService, NewPositionService, NewDictionaryService, NewParameterService, NewTokenService, NewSecurityService, NewVersionService, NewExportService, NewAuditService, NewAuditRecorder, NewLogViewerService, NewTaskService, NewMediaService, NewAnnouncementService, NewEmailService)
var ProviderSet = wire.NewSet(NewAuthService, NewUserService, NewSystemConfigService, NewAuthorityService, NewPermissionService, NewAccessControlService, NewAPIService, NewMenuService, NewDepartmentService, NewPositionService, NewDictionaryService, NewParameterService, NewTokenService, NewSecurityService, NewVersionService, NewExportService, NewAuditService, NewAuditRecorder, NewLogViewerService, NewTaskService, NewMediaService, NewAnnouncementService, NewEmailService, NewPaymentService)