优化结构

This commit is contained in:
Yvan
2026-08-21 10:29:14 +08:00
parent 56980c8087
commit 055f6811ca
340 changed files with 382 additions and 382 deletions
+37
View File
@@ -0,0 +1,37 @@
package payment
import (
"kra/pkg/database/migration"
platformmodule "kra/pkg/module"
"gorm.io/gorm"
)
func Migrations() []migration.Step {
return []migration.Step{
{ID: "202608200003_payment_schema", Migrate: func(db *gorm.DB) error {
return migration.CreateMissingTables(db, &paymentOrderPO{})
}},
{ID: "202608200004_payment_defaults", Migrate: ensurePaymentIntegrationConfigs},
}
}
// AdminSurface describes the payment-owned entries shown in the system
// administration UI. The system module persists these records because it owns
// the menu/API/policy tables.
func AdminSurface() platformmodule.Surface {
return platformmodule.Surface{
Menus: []platformmodule.Menu{
{Name: "paymentOrders", Path: "paymentOrders", ParentName: "extensions", Component: "view/systemTools/payment/orders.vue", Title: "支付订单", Icon: "wallet", Sort: 6},
{Name: "paymentConfig", Path: "paymentConfig", ParentName: "extensions", Component: "view/systemTools/payment/config.vue", Title: "支付配置", Icon: "credit-card", Sort: 7},
},
APIs: []platformmodule.API{
{Path: "/integration/configs/:kind", Method: "GET", Group: "集成配置", Description: "按类型获取集成配置"},
{Path: "/integration/configs/:kind/:provider", Method: "GET", Group: "集成配置", Description: "获取指定集成配置"},
{Path: "/integration/configs/:kind/:provider", Method: "PUT", Group: "集成配置", Description: "保存集成配置"},
{Path: "/integration/configs/:kind/:provider", Method: "DELETE", Group: "集成配置", Description: "删除集成配置"},
{Path: "/payment/orders", Method: "GET", Group: "支付", Description: "分页查询支付订单"},
{Path: "/payment/order", Method: "POST", Group: "支付", Description: "查询支付订单"},
},
}
}
+17
View File
@@ -0,0 +1,17 @@
package payment
import "time"
const integrationKindPayment = "payment"
type integrationConfigPO struct {
ID uint `gorm:"primaryKey"`
CreatedAt time.Time
UpdatedAt time.Time
Kind string `gorm:"size:32;not null;uniqueIndex:idx_integration_kind_provider"`
Provider string `gorm:"size:64;not null;uniqueIndex:idx_integration_kind_provider"`
Enabled bool `gorm:"not null;default:false;index"`
Config string `gorm:"type:text;not null"`
}
func (integrationConfigPO) TableName() string { return "sys_integration_configs" }
+278
View File
@@ -0,0 +1,278 @@
package payment
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/url"
"strconv"
"strings"
"kra/internal/biz"
datapayment "kra/internal/integration/payment"
"gorm.io/gorm"
)
type paymentRepo struct{ data Provider }
func NewPaymentRepo(data Provider) biz.PaymentRepo { return &paymentRepo{data: data} }
func ensurePaymentIntegrationConfigs(db *gorm.DB) error {
for _, provider := range biz.SupportedPaymentProviders {
var row integrationConfigPO
err := db.Where("kind = ? AND provider = ?", integrationKindPayment, provider).First(&row).Error
defaults := biz.DefaultIntegrationConfig(integrationKindPayment, provider)
if errors.Is(err, gorm.ErrRecordNotFound) {
encoded, _ := json.Marshal(defaults)
if err := db.Create(&integrationConfigPO{Kind: integrationKindPayment, Provider: provider, Enabled: false, Config: string(encoded)}).Error; err != nil {
return err
}
continue
}
if err != nil {
return err
}
values := map[string]any{}
_ = json.Unmarshal([]byte(row.Config), &values)
changed := false
for key, value := range defaults {
if _, exists := values[key]; !exists {
values[key] = value
changed = true
}
}
if changed {
encoded, _ := json.Marshal(values)
if err := db.Model(&row).Update("config", string(encoded)).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.DB().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) 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) {
if req == nil {
return nil, errors.New("支付下单请求为空")
}
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 paymentCreateRequiresNotifyURL(req.Provider, req.Extra, c) && effective.NotifyURL == "" {
return nil, fmt.Errorf("支付渠道 %s 未配置服务端 notify_url", req.Provider)
}
return a.Create(ctx, &effective, c)
}
func paymentProviderRequiresNotifyURL(provider string) bool {
switch provider {
case biz.PaymentApple, biz.PaymentAllinPay, biz.PaymentSaobei, biz.PaymentPayPal:
return false
default:
return true
}
}
// paymentCreateRequiresNotifyURL keeps the repository-level URL guard aligned
// with the selected provider operation. Synchronous barcode/retail APIs return
// their execution result directly and do not consume notify_url; redirect and
// client-side prepay APIs still require the configured server callback URL.
func paymentCreateRequiresNotifyURL(provider string, extra, config map[string]any) bool {
if !paymentProviderRequiresNotifyURL(provider) {
return false
}
keys := []string{"method", "pay_method", "trade_type", "pay_type", "channel"}
switch provider {
case biz.PaymentAlipay, biz.PaymentAlipayV3:
keys = []string{"method", "pay_method", "trade_type", "channel"}
case biz.PaymentWechatV2:
keys = []string{"trade_type", "pay_type", "method", "pay_method", "channel"}
case biz.PaymentWechatV3:
keys = []string{"trade_type", "pay_type", "method"}
case biz.PaymentQQ:
keys = []string{"trade_type", "pay_type", "method", "pay_method"}
case biz.PaymentLakala:
keys = []string{"method", "pay_method", "trade_type"}
}
value := firstAny(extra, keys...)
if value == "" {
value = firstAny(config, keys...)
}
normalized := strings.NewReplacer(".", "_", "-", "_", " ", "_").Replace(strings.ToLower(strings.TrimSpace(value)))
switch provider {
case biz.PaymentAlipay, biz.PaymentAlipayV3:
return !contains([]string{"pay", "trade_pay", "alipay_trade_pay", "barcode", "barcode_pay", "micropay", "face_to_face"}, normalized)
case biz.PaymentWechatV2:
return !contains([]string{"micropay", "micro_pay", "barcode", "barcode_pay", "pay_code", "payment_code"}, normalized)
case biz.PaymentWechatV3:
return !contains([]string{"micropay", "micro_pay", "codepay", "code_pay", "barcode", "barcode_pay", "facepay", "face_pay"}, normalized)
case biz.PaymentQQ:
return !contains([]string{"micropay", "micro_pay", "barcode", "barcode_pay"}, normalized)
case biz.PaymentLakala:
return !contains([]string{"retail", "retail_pay", "micropay", "barcode"}, normalized)
default:
return true
}
}
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}
}
result.EventID = paymentCallbackEventID(callback, result)
return result, nil
}
func paymentCallbackEventID(callback *biz.PaymentCallback, result *biz.PaymentResult) string {
if result != nil {
if eventID := strings.TrimSpace(result.EventID); eventID != "" {
return eventID
}
}
if callback == nil {
return ""
}
fields := callbackFields(callback)
if eventID := strings.TrimSpace(first(fields, "event_id", "notify_id", "notificationUUID", "id")); eventID != "" {
return eventID
}
hash := sha256.Sum256(append([]byte(callback.Provider+"\x00"), callback.Body...))
return hex.EncodeToString(hash[:])
}
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 ""
}
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 {
return biz.ValidateIntegrationConfig(biz.IntegrationKindPayment, provider, values)
}
@@ -0,0 +1,64 @@
package payment
import (
"strings"
"testing"
"kra/internal/biz"
)
func TestValidatePaymentConfigRequiresDouyinAppIDWhenEnabled(t *testing.T) {
values := map[string]any{
"merchant_id": "merchant-douyin", "serial_no": "merchant-serial", "api_key": "01234567890123456789012345678901",
"private_key": "merchant-private-key", "platform_cert": "platform-public-key", "platform_serial_no": "platform-serial",
}
err := biz.ValidateIntegrationConfig(biz.IntegrationKindPayment, biz.PaymentDouyin, values)
if err == nil || !strings.Contains(err.Error(), "app_id") {
t.Fatalf("missing app_id error = %v", err)
}
values["app_id"] = "douyin-app"
if err = biz.ValidateIntegrationConfig(biz.IntegrationKindPayment, biz.PaymentDouyin, values); err != nil {
t.Fatalf("valid Douyin configuration rejected: %v", err)
}
}
func TestValidatePaymentConfigAcceptsProviderAliases(t *testing.T) {
err := validatePaymentConfig(biz.PaymentDouyin, map[string]any{
"app_id": "douyin-app", "merchant_id": "merchant-douyin", "serial_no": "merchant-serial", "api_key": "01234567890123456789012345678901",
"private_key": "merchant-private-key", "platform_cert": "platform-public-key", "platform_cert_serial": "platform-serial",
})
if err != nil {
t.Fatalf("platform_cert_serial alias rejected: %v", err)
}
}
func TestValidatePaymentConfigProviderRules(t *testing.T) {
tests := []struct {
name, provider, want string
values map[string]any
}{
{"allinpay order type", biz.PaymentAllinPay, "reqsn", map[string]any{"cus_id": "customer", "app_id": "app", "private_key": "private-key", "public_key": "public-key", "query_order_type": "payinfo"}},
{"paypal webhook", biz.PaymentPayPal, "webhook_id", map[string]any{"client_id": "client-id", "client_secret": "client-secret"}},
{"wechat v2 refund cert", biz.PaymentWechatV2, "client_cert", map[string]any{"app_id": "app", "merchant_id": "merchant", "mch_key": "key"}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
err := biz.ValidateIntegrationConfig(biz.IntegrationKindPayment, test.provider, test.values)
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("error = %v, want %q", err, test.want)
}
})
}
}
func TestValidatePaymentConfigGenericRequiresRuntimeFields(t *testing.T) {
values := biz.DefaultIntegrationConfig(biz.IntegrationKindPayment, biz.PaymentChinaums)
if err := biz.ValidateIntegrationConfig(biz.IntegrationKindPayment, biz.PaymentChinaums, values); err == nil {
t.Fatal("empty generic payment config unexpectedly accepted")
}
values["app_id"], values["merchant_id"] = "app", "merchant"
values["create_url"], values["query_url"], values["refund_url"], values["app_key"] = "https://pay.test/create", "https://pay.test/query", "https://pay.test/refund", "secret"
if err := biz.ValidateIntegrationConfig(biz.IntegrationKindPayment, biz.PaymentChinaums, values); err == nil {
t.Fatal("generic config with only identity/endpoints unexpectedly accepted")
}
}
+23
View File
@@ -0,0 +1,23 @@
package payment
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 payment parser.
func configuredInt64(values map[string]any, key string, fallback int64) int64 {
return paymentutil.ConfiguredInt64(values, key, fallback)
}
@@ -0,0 +1,30 @@
package payment
import (
"strings"
"testing"
"kra/internal/biz"
)
func TestPaymentDefinitionsProvideNonEmptyDefaults(t *testing.T) {
definitions := biz.IntegrationDefinitions(biz.IntegrationKindPayment)
if len(definitions) != len(biz.SupportedPaymentProviders) {
t.Fatalf("payment definitions = %d, want %d", len(definitions), len(biz.SupportedPaymentProviders))
}
for _, definition := range definitions {
if definition.Provider == "" || definition.Name == "" || len(definition.Fields) == 0 {
t.Fatalf("incomplete payment definition: %+v", definition)
}
if len(definition.Defaults) == 0 {
t.Fatalf("payment provider %s still has empty defaults", definition.Provider)
}
}
}
func TestPaymentValidationRejectsUnknownProvider(t *testing.T) {
err := validatePaymentConfig("unknown-provider", map[string]any{})
if err == nil || !strings.Contains(err.Error(), "不支持") {
t.Fatalf("error = %v", err)
}
}
+524
View File
@@ -0,0 +1,524 @@
package payment
import (
"context"
"encoding/json"
"errors"
"strings"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"kra/internal/biz"
"kra/pkg/database/pagination"
)
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 Provider }
func NewPaymentOrderRepo(data Provider) 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.DB().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.DB().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) ListPaymentOrders(ctx context.Context, page, pageSize int, filter biz.PaymentOrderFilter) ([]*biz.PaymentOrder, int64, error) {
db := r.data.DB().WithContext(ctx).Model(&paymentOrderPO{})
if value := strings.TrimSpace(filter.Provider); value != "" {
db = db.Where("provider = ?", value)
}
if value := strings.TrimSpace(filter.TradeNo); value != "" {
db = db.Where("trade_no LIKE ?", "%"+value+"%")
}
if value := strings.TrimSpace(filter.BusinessType); value != "" {
db = db.Where("business_type = ?", value)
}
if value := strings.TrimSpace(filter.BusinessID); value != "" {
db = db.Where("business_id LIKE ?", "%"+value+"%")
}
if value := strings.TrimSpace(filter.PaymentStatus); value != "" {
db = db.Where("payment_status = ?", value)
}
if value := strings.TrimSpace(filter.RefundStatus); value != "" {
db = db.Where("refund_status = ?", value)
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
var rows []paymentOrderPO
if err := pagination.ApplyRequired(db.Order("id desc"), page, pageSize, 100).Find(&rows).Error; err != nil {
return nil, 0, err
}
items := make([]*biz.PaymentOrder, 0, len(rows))
for i := range rows {
items = append(items, toBizPaymentOrder(&rows[i]))
}
return items, total, 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.DB().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
}
+117
View File
@@ -0,0 +1,117 @@
package payment
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)
}
}
func TestPaymentOrderRepositoryListsWithFilters(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 = "wechat-order-2"
second.Provider = biz.PaymentWechatV3
second.BusinessID = "item-2"
second.ConfirmationID = "33333333-3333-3333-3333-333333333333"
if _, _, err := repo.CreatePaymentOrder(ctx, second); err != nil {
t.Fatal(err)
}
items, total, err := repo.ListPaymentOrders(ctx, 1, 10, biz.PaymentOrderFilter{Provider: biz.PaymentWechatV3, TradeNo: "wechat", BusinessID: "item-2"})
if err != nil {
t.Fatal(err)
}
if total != 1 || len(items) != 1 || items[0].TradeNo != second.TradeNo {
t.Fatalf("filtered orders = %#v total=%d", items, total)
}
}
+11
View File
@@ -0,0 +1,11 @@
package payment
import (
"gorm.io/gorm"
)
// Provider is the narrow persistence seam required by payment repositories.
// Keeping it here lets payment remain an independent data module.
type Provider interface {
DB() *gorm.DB
}
@@ -0,0 +1,60 @@
package payment
import (
"fmt"
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
type Data struct{ gormDB *reloadableDB }
type reloadableDB struct{ db *gorm.DB }
func newReloadableDB(db *gorm.DB, _ any) *reloadableDB { return &reloadableDB{db: db} }
func (r *reloadableDB) close() {
if r == nil || r.db == nil {
return
}
if sqlDB, err := r.db.DB(); err == nil {
_ = sqlDB.Close()
}
}
func (d *Data) DB() *gorm.DB { return d.gormDB.db }
func openWithDriver(driver, dsn string) (*gorm.DB, error) {
if driver != "sqlite" {
return nil, fmt.Errorf("unsupported test database driver %q", driver)
}
return gorm.Open(sqlite.Open(dsn), &gorm.Config{})
}
func openIntegrationConfigTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared")
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&integrationConfigPO{}); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if sqlDB, err := db.DB(); err == nil {
_ = sqlDB.Close()
}
})
return db
}
func migrateAll(db *gorm.DB) error {
if err := db.AutoMigrate(&integrationConfigPO{}); err != nil {
return err
}
for _, step := range Migrations() {
if err := step.Migrate(db); err != nil {
return err
}
}
return nil
}