优化结构
This commit is contained in:
@@ -1,35 +0,0 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"kra/internal/adminsurface"
|
||||
"kra/internal/data/migration"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func Migrations() []migration.Step {
|
||||
return []migration.Step{
|
||||
{ID: "202608200003_payment_schema", Migrate: func(db *gorm.DB) error {
|
||||
return db.AutoMigrate(&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() adminsurface.Surface {
|
||||
return adminsurface.Surface{
|
||||
Menus: []adminsurface.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: []adminsurface.API{
|
||||
{Path: "/payment/configs", Method: "GET", APIGroup: "支付", Description: "获取支付渠道配置"},
|
||||
{Path: "/payment/config", Method: "POST", APIGroup: "支付", Description: "保存支付渠道配置"},
|
||||
{Path: "/payment/orders", Method: "GET", APIGroup: "支付", Description: "分页查询支付订单"},
|
||||
{Path: "/payment/order", Method: "POST", APIGroup: "支付", Description: "查询支付订单"},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
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" }
|
||||
@@ -1,535 +0,0 @@
|
||||
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 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.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) ListConfigs(ctx context.Context) ([]*biz.PaymentConfig, error) {
|
||||
var rows []integrationConfigPO
|
||||
if err := r.data.DB().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.DB().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) {
|
||||
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 ""
|
||||
}
|
||||
|
||||
var paymentSecretKeys = map[string]bool{
|
||||
"key": true, "secret": true, "secret_key": true, "private_key": true, "privatekey": true,
|
||||
"app_key": true, "client_key": true, "client_secret": true, "api_key": true,
|
||||
"api_v3_key": true, "api_v3key": true, "apiv3_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, "key_content": true, "key_pem": true, "apiclient_key": true,
|
||||
"app_secret": true, "appsecret": true, "token": true, "access_token": true, "app_auth_token": true,
|
||||
"credential_code": true, "cus_id": true, "op_user_passwd": true, "p12": true, "pkcs12": true,
|
||||
"pkcs12_content": 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{}
|
||||
configDriven := false
|
||||
switch provider {
|
||||
case biz.PaymentAlipay:
|
||||
required = []string{"app_id", "private_key", "public_key"}
|
||||
case biz.PaymentAlipayV3:
|
||||
required = []string{"app_id"}
|
||||
for _, group := range []struct {
|
||||
label string
|
||||
keys []string
|
||||
}{
|
||||
{label: "private_key", keys: []string{"private_key", "private_key_content", "private_key_path"}},
|
||||
{label: "app_cert", keys: []string{"app_cert", "app_cert_content", "app_cert_path", "app_public_cert", "app_public_cert_content", "app_public_cert_path"}},
|
||||
{label: "root_cert", keys: []string{"root_cert", "root_cert_content", "root_cert_path", "alipay_root_cert", "alipay_root_cert_content", "alipay_root_cert_path"}},
|
||||
{label: "public_cert", keys: []string{"public_cert", "public_cert_content", "public_cert_path", "alipay_public_cert", "alipay_public_cert_content", "alipay_public_cert_path"}},
|
||||
} {
|
||||
if firstAny(values, group.keys...) == "" {
|
||||
return fmt.Errorf("%s 缺少配置字段 %s", provider, group.label)
|
||||
}
|
||||
}
|
||||
case biz.PaymentWechatV2:
|
||||
required = []string{"app_id", "merchant_id", "mch_key"}
|
||||
if firstAny(values, "client_cert", "cert_pem", "apiclient_cert") == "" || firstAny(values, "client_key", "key_pem", "apiclient_key") == "" {
|
||||
return fmt.Errorf("%s 退款要求同时配置 client_cert 和 client_key", provider)
|
||||
}
|
||||
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{"app_id", "merchant_id", "serial_no", "api_key", "private_key", "platform_cert"}
|
||||
case biz.PaymentQQ:
|
||||
if firstAny(values, "mch_id", "merchant_id") == "" {
|
||||
return fmt.Errorf("%s 缺少配置字段 mch_id", provider)
|
||||
}
|
||||
required = []string{"api_key"}
|
||||
if !hasQQRefundCertificate(values) {
|
||||
return fmt.Errorf("%s 退款要求配置 cert_file + key_file、pkcs12_file 或对应的 *_content", provider)
|
||||
}
|
||||
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", "webhook_id"}
|
||||
case biz.PaymentSaobei:
|
||||
required = []string{"inst_no", "key", "merchant_no", "terminal_id", "access_token"}
|
||||
default:
|
||||
configDriven = true
|
||||
required = []string{
|
||||
"protocol_version", "app_id", "merchant_id", "create_url", "query_url", "refund_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 configDriven && key == "query_amount_scale" {
|
||||
if _, exists := values[key]; !exists {
|
||||
return fmt.Errorf("%s 缺少配置字段 %s", provider, key)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(text(values, key)) == "" {
|
||||
return fmt.Errorf("%s 缺少配置字段 %s", provider, key)
|
||||
}
|
||||
}
|
||||
if configDriven && !validPaymentAmountScale(configuredInt64(values, "query_amount_scale", 0)) {
|
||||
return fmt.Errorf("%s query_amount_scale 必须是正的 10 的幂", provider)
|
||||
}
|
||||
if provider == biz.PaymentDouyin && firstAny(values, "platform_serial_no", "platform_cert_serial") == "" {
|
||||
return fmt.Errorf("%s 缺少配置字段 platform_serial_no", provider)
|
||||
}
|
||||
if provider == biz.PaymentAllinPay {
|
||||
orderType := strings.ToLower(strings.TrimSpace(firstAny(values, "query_order_type", "order_type")))
|
||||
if orderType != "" && orderType != "reqsn" && orderType != "trxid" {
|
||||
return fmt.Errorf("%s query_order_type 必须是 reqsn 或 trxid", 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 hasQQRefundCertificate(values map[string]any) bool {
|
||||
if firstAny(values, "pkcs12_file", "p12_file", "pkcs12_content") != "" {
|
||||
return true
|
||||
}
|
||||
if firstAny(values, "cert_file", "cert_path") != "" && firstAny(values, "key_file", "key_path") != "" {
|
||||
return true
|
||||
}
|
||||
return firstAny(values, "cert_content") != "" && firstAny(values, "key_content") != ""
|
||||
}
|
||||
|
||||
func validPaymentAmountScale(scale int64) bool {
|
||||
if scale <= 0 {
|
||||
return false
|
||||
}
|
||||
for scale%10 == 0 {
|
||||
scale /= 10
|
||||
}
|
||||
return scale == 1
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -1,300 +0,0 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"kra/internal/biz"
|
||||
)
|
||||
|
||||
func TestSavePaymentConfigRequiresDouyinAppIDWhenEnabled(t *testing.T) {
|
||||
db := openIntegrationConfigTestDB(t)
|
||||
repo := &paymentRepo{data: &Data{gormDB: newReloadableDB(db, nil)}}
|
||||
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",
|
||||
}
|
||||
encode := func() json.RawMessage {
|
||||
raw, err := json.Marshal(values)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
err := repo.SaveConfig(context.Background(), &biz.PaymentConfig{
|
||||
Provider: biz.PaymentDouyin,
|
||||
Enabled: true,
|
||||
Values: encode(),
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "app_id") {
|
||||
t.Fatalf("missing app_id error = %v", err)
|
||||
}
|
||||
var count int64
|
||||
if err = db.Model(&integrationConfigPO{}).Where("kind = ? AND provider = ?", integrationKindPayment, biz.PaymentDouyin).Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("invalid enabled configuration was persisted: count=%d", count)
|
||||
}
|
||||
|
||||
values["app_id"] = "douyin-app"
|
||||
if err = repo.SaveConfig(context.Background(), &biz.PaymentConfig{
|
||||
Provider: biz.PaymentDouyin,
|
||||
Enabled: true,
|
||||
Values: encode(),
|
||||
}); err != nil {
|
||||
t.Fatalf("save complete Douyin configuration: %v", err)
|
||||
}
|
||||
var stored integrationConfigPO
|
||||
if err = db.Where("kind = ? AND provider = ?", integrationKindPayment, biz.PaymentDouyin).First(&stored).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !stored.Enabled || !strings.Contains(stored.Config, `"app_id":"douyin-app"`) {
|
||||
t.Fatalf("stored configuration = %+v", stored)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDouyinPaymentConfigAcceptsPlatformCertificateSerialAlias(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 TestValidateAllinPayPaymentConfigRejectsUnknownQueryOrderType(t *testing.T) {
|
||||
err := validatePaymentConfig(biz.PaymentAllinPay, map[string]any{
|
||||
"cus_id": "customer",
|
||||
"app_id": "app",
|
||||
"private_key": "private-key",
|
||||
"public_key": "public-key",
|
||||
"query_order_type": "payinfo",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "reqsn") || !strings.Contains(err.Error(), "trxid") {
|
||||
t.Fatalf("unknown query_order_type error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAlipayV3PaymentConfigRequiresCertificates(t *testing.T) {
|
||||
values := map[string]any{
|
||||
"app_id": "alipay-v3-app",
|
||||
"private_key": "private-key",
|
||||
}
|
||||
if err := validatePaymentConfig(biz.PaymentAlipayV3, values); err == nil || !strings.Contains(err.Error(), "app_cert") {
|
||||
t.Fatalf("missing app certificate error = %v", err)
|
||||
}
|
||||
|
||||
values["app_cert_content"] = "app-certificate"
|
||||
if err := validatePaymentConfig(biz.PaymentAlipayV3, values); err == nil || !strings.Contains(err.Error(), "root_cert") {
|
||||
t.Fatalf("missing root certificate error = %v", err)
|
||||
}
|
||||
|
||||
values["alipay_root_cert_path"] = "alipayRootCert.crt"
|
||||
if err := validatePaymentConfig(biz.PaymentAlipayV3, values); err == nil || !strings.Contains(err.Error(), "public_cert") {
|
||||
t.Fatalf("missing public certificate error = %v", err)
|
||||
}
|
||||
|
||||
values["alipay_public_cert_content"] = "alipay-public-certificate"
|
||||
if err := validatePaymentConfig(biz.PaymentAlipayV3, values); err != nil {
|
||||
t.Fatalf("valid Alipay V3 configuration rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePayPalPaymentConfigRequiresWebhookID(t *testing.T) {
|
||||
values := map[string]any{
|
||||
"client_id": "client-id",
|
||||
"client_secret": "client-secret",
|
||||
}
|
||||
if err := validatePaymentConfig(biz.PaymentPayPal, values); err == nil || !strings.Contains(err.Error(), "webhook_id") {
|
||||
t.Fatalf("missing webhook_id error = %v", err)
|
||||
}
|
||||
|
||||
values["webhook_id"] = "webhook-1"
|
||||
if err := validatePaymentConfig(biz.PaymentPayPal, values); err != nil {
|
||||
t.Fatalf("valid PayPal configuration rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWechatV2PaymentConfigRequiresRefundCertificatePair(t *testing.T) {
|
||||
base := map[string]any{
|
||||
"app_id": "wechat-app",
|
||||
"merchant_id": "wechat-merchant",
|
||||
"mch_key": "merchant-key",
|
||||
}
|
||||
if err := validatePaymentConfig(biz.PaymentWechatV2, base); err == nil || !strings.Contains(err.Error(), "client_cert") {
|
||||
t.Fatalf("missing refund certificate error = %v", err)
|
||||
}
|
||||
|
||||
base["client_cert"] = "certificate-pem"
|
||||
if err := validatePaymentConfig(biz.PaymentWechatV2, base); err == nil || !strings.Contains(err.Error(), "client_key") {
|
||||
t.Fatalf("missing refund private key error = %v", err)
|
||||
}
|
||||
|
||||
base["client_key"] = "private-key-pem"
|
||||
if err := validatePaymentConfig(biz.PaymentWechatV2, base); err != nil {
|
||||
t.Fatalf("valid WeChat v2 configuration rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateQQPaymentConfigRequiresRefundCertificate(t *testing.T) {
|
||||
base := map[string]any{
|
||||
"mch_id": "qq-merchant",
|
||||
"api_key": "merchant-key",
|
||||
}
|
||||
if err := validatePaymentConfig(biz.PaymentQQ, base); err == nil || !strings.Contains(err.Error(), "cert_file") {
|
||||
t.Fatalf("missing refund certificate error = %v", err)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
values map[string]any
|
||||
}{
|
||||
{name: "certificate files", values: map[string]any{"cert_file": "cert.pem", "key_file": "key.pem"}},
|
||||
{name: "pkcs12 file", values: map[string]any{"pkcs12_file": "merchant.p12"}},
|
||||
{name: "certificate content", values: map[string]any{"cert_content": "certificate-pem", "key_content": "private-key-pem"}},
|
||||
{name: "pkcs12 content", values: map[string]any{"pkcs12_content": "base64-pkcs12"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
values := map[string]any{}
|
||||
for key, value := range base {
|
||||
values[key] = value
|
||||
}
|
||||
for key, value := range tc.values {
|
||||
values[key] = value
|
||||
}
|
||||
if err := validatePaymentConfig(biz.PaymentQQ, values); err != nil {
|
||||
t.Fatalf("valid QQ configuration rejected: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentProviderNotifyURLRequirementMatchesCallbackSupport(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
provider string
|
||||
want bool
|
||||
}{
|
||||
{provider: biz.PaymentApple, want: false},
|
||||
{provider: biz.PaymentAllinPay, want: false},
|
||||
{provider: biz.PaymentSaobei, want: false},
|
||||
{provider: biz.PaymentAlipay, want: true},
|
||||
{provider: biz.PaymentAlipayV3, want: true},
|
||||
{provider: biz.PaymentWechatV3, want: true},
|
||||
} {
|
||||
t.Run(tc.provider, func(t *testing.T) {
|
||||
if got := paymentProviderRequiresNotifyURL(tc.provider); got != tc.want {
|
||||
t.Fatalf("requires notify_url = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentCreateNotifyURLRequirementMatchesSynchronousMethods(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
provider string
|
||||
extra map[string]any
|
||||
config map[string]any
|
||||
want bool
|
||||
}{
|
||||
{name: "paypal create", provider: biz.PaymentPayPal, want: false},
|
||||
{name: "alipay barcode", provider: biz.PaymentAlipay, extra: map[string]any{"method": "barcode"}, want: false},
|
||||
{name: "alipay v3 barcode", provider: biz.PaymentAlipayV3, extra: map[string]any{"method": "barcode"}, want: false},
|
||||
{name: "alipay ignores unsupported pay type selector", provider: biz.PaymentAlipay, extra: map[string]any{"pay_type": "barcode"}, want: true},
|
||||
{name: "wechat v2 micropay", provider: biz.PaymentWechatV2, config: map[string]any{"trade_type": "MICROPAY"}, want: false},
|
||||
{name: "wechat v3 codepay", provider: biz.PaymentWechatV3, extra: map[string]any{"method": "codepay"}, want: false},
|
||||
{name: "qq micropay", provider: biz.PaymentQQ, config: map[string]any{"trade_type": "MICROPAY"}, want: false},
|
||||
{name: "lakala retail", provider: biz.PaymentLakala, extra: map[string]any{"method": "retail"}, want: false},
|
||||
{name: "alipay native", provider: biz.PaymentAlipay, config: map[string]any{"method": "native"}, want: true},
|
||||
{name: "wechat v3 jsapi", provider: biz.PaymentWechatV3, extra: map[string]any{"trade_type": "jsapi"}, want: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := paymentCreateRequiresNotifyURL(tc.provider, tc.extra, tc.config); got != tc.want {
|
||||
t.Fatalf("paymentCreateRequiresNotifyURL() = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentCallbackEventIDPreservesVerifiedAdapterIdentity(t *testing.T) {
|
||||
callback := &biz.PaymentCallback{
|
||||
Provider: biz.PaymentDouyin,
|
||||
Body: []byte(`{"id":"untrusted-flat-id","data":{"event_id":"nested-id"}}`),
|
||||
}
|
||||
result := &biz.PaymentResult{EventID: " verified-sdk-event "}
|
||||
if got := paymentCallbackEventID(callback, result); got != "verified-sdk-event" {
|
||||
t.Fatalf("paymentCallbackEventID() = %q, want verified adapter ID", got)
|
||||
}
|
||||
result.EventID = ""
|
||||
if got := paymentCallbackEventID(callback, result); got != "untrusted-flat-id" {
|
||||
t.Fatalf("paymentCallbackEventID() fallback = %q, want flat callback ID", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGenericPaymentConfigRequiresRuntimeEndpointsAndIdentity(t *testing.T) {
|
||||
base := map[string]any{
|
||||
"protocol_version": "v1",
|
||||
"app_id": "app",
|
||||
"merchant_id": "merchant",
|
||||
"create_url": "https://pay.test/create",
|
||||
"query_url": "https://pay.test/query",
|
||||
"refund_url": "https://pay.test/refund",
|
||||
"app_key": "secret",
|
||||
"query_status_field": "data.status",
|
||||
"query_success_values": "SUCCESS",
|
||||
"query_trade_no_field": "data.trade_no",
|
||||
"query_provider_trade_no_field": "data.provider_trade_no",
|
||||
"query_amount_field": "data.amount",
|
||||
"query_currency_field": "data.currency",
|
||||
"query_amount_scale": "100",
|
||||
"callback_status_field": "data.status",
|
||||
"callback_success_values": "SUCCESS",
|
||||
"callback_trade_no_field": "data.trade_no",
|
||||
"callback_provider_trade_no_field": "data.provider_trade_no",
|
||||
}
|
||||
if err := validatePaymentConfig(biz.PaymentChinaums, base); err != nil {
|
||||
t.Fatalf("valid generic config rejected: %v", err)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
key string
|
||||
value any
|
||||
want string
|
||||
}{
|
||||
{name: "missing app id", key: "app_id", want: "app_id"},
|
||||
{name: "missing merchant id", key: "merchant_id", want: "merchant_id"},
|
||||
{name: "missing refund endpoint", key: "refund_url", want: "refund_url"},
|
||||
{name: "zero amount scale", key: "query_amount_scale", value: "0", want: "query_amount_scale"},
|
||||
{name: "non power amount scale", key: "query_amount_scale", value: "3", want: "query_amount_scale"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
values := map[string]any{}
|
||||
for key, value := range base {
|
||||
values[key] = value
|
||||
}
|
||||
if tc.value == nil {
|
||||
values[tc.key] = ""
|
||||
} else {
|
||||
values[tc.key] = tc.value
|
||||
}
|
||||
err := validatePaymentConfig(biz.PaymentChinaums, values)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("validation error = %v, want %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package payment
|
||||
|
||||
import "kra/internal/utils/paymentutil"
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentConfigListMasksAndSavePreservesSecretAliases(t *testing.T) {
|
||||
db := openIntegrationConfigTestDB(t)
|
||||
repo := &paymentRepo{data: &Data{gormDB: newReloadableDB(db, nil)}}
|
||||
secrets := map[string]string{
|
||||
"app_key": "app-key-secret",
|
||||
"token": "signing-token",
|
||||
"key_content": "private-key-content",
|
||||
"pkcs12_content": "pkcs12-content",
|
||||
"op_user_passwd": "operator-password",
|
||||
"api_v3key": "api-v3-key-alias",
|
||||
"apiv3_key": "api-v3-key-compact-alias",
|
||||
"key_pem": "key-pem-content",
|
||||
"apiclient_key": "api-client-key-content",
|
||||
}
|
||||
storedValues := map[string]any{"app_id": "public-app-id"}
|
||||
for key, value := range secrets {
|
||||
storedValues[key] = value
|
||||
}
|
||||
storedValues["nested"] = map[string]any{"token": "nested-token"}
|
||||
raw, err := json.Marshal(storedValues)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Create(&integrationConfigPO{
|
||||
Kind: integrationKindPayment, Provider: biz.PaymentQQ, Config: string(raw),
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
configs, err := repo.ListConfigs(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(configs) != 1 {
|
||||
t.Fatalf("payment configs = %d, want 1", len(configs))
|
||||
}
|
||||
masked := map[string]any{}
|
||||
if err = json.Unmarshal(configs[0].Values, &masked); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if masked["app_id"] != "public-app-id" {
|
||||
t.Fatalf("non-secret app_id = %#v, want public-app-id", masked["app_id"])
|
||||
}
|
||||
for key := range secrets {
|
||||
if masked[key] != "******" {
|
||||
t.Errorf("secret %s = %#v, want masked value", key, masked[key])
|
||||
}
|
||||
}
|
||||
if nested, ok := masked["nested"].(map[string]any); !ok || nested["token"] != "******" {
|
||||
t.Errorf("nested token was not masked: %#v", masked["nested"])
|
||||
}
|
||||
|
||||
if err = repo.SaveConfig(context.Background(), &biz.PaymentConfig{
|
||||
Provider: biz.PaymentQQ, Values: configs[0].Values,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var row integrationConfigPO
|
||||
if err = db.Where("kind = ? AND provider = ?", integrationKindPayment, biz.PaymentQQ).First(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
preserved := map[string]any{}
|
||||
if err = json.Unmarshal([]byte(row.Config), &preserved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for key, want := range secrets {
|
||||
if preserved[key] != want {
|
||||
t.Errorf("preserved secret %s = %#v, want %q", key, preserved[key], want)
|
||||
}
|
||||
}
|
||||
if nested, ok := preserved["nested"].(map[string]any); !ok || nested["token"] != "nested-token" {
|
||||
t.Errorf("nested token was not preserved: %#v", preserved["nested"])
|
||||
}
|
||||
}
|
||||
@@ -1,524 +0,0 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"kra/internal/biz"
|
||||
"kra/internal/data/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(¤t).Error
|
||||
if err == nil {
|
||||
return toBizPaymentOrder(¤t), 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(¤t).Error; lookupErr == nil {
|
||||
return toBizPaymentOrder(¤t), 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
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user