优化结构

This commit is contained in:
Yvan 2026-08-29 08:40:15 +08:00
parent b6d25c1a0f
commit 509df03d61
17 changed files with 234 additions and 40 deletions

View File

@ -6,6 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
bizpayment "kra/internal/biz/payment" bizpayment "kra/internal/biz/payment"
"net"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@ -285,6 +286,44 @@ func validateCommunicationIntegrationConfig(kind, provider string, values map[st
if integrationInt64(values, "reconnect_interval", 0) <= 0 { if integrationInt64(values, "reconnect_interval", 0) <= 0 {
return errors.New("rabbitmq reconnect_interval 必须大于 0") return errors.New("rabbitmq reconnect_interval 必须大于 0")
} }
case IntegrationKindMQ + "/kafka":
brokers := integrationStrings(values, "brokers")
if len(brokers) == 0 {
return errors.New("kafka brokers 不能为空")
}
for _, broker := range brokers {
host, portText, err := net.SplitHostPort(broker)
port, parseErr := strconv.Atoi(portText)
if err != nil || parseErr != nil || strings.TrimSpace(host) == "" || port < 1 || port > 65535 {
return fmt.Errorf("kafka broker %q 必须是有效的 host:port 地址", broker)
}
}
username := integrationText(values, "username")
password := integrationText(values, "password")
if (username == "") != (password == "") {
return errors.New("kafka username 和 password 必须同时配置")
}
startOffset := strings.ToLower(integrationText(values, "start_offset"))
if startOffset != "earliest" && startOffset != "latest" {
return errors.New("kafka start_offset 必须是 earliest 或 latest")
}
minBytes := integrationInt64(values, "min_bytes", 0)
maxBytes := integrationInt64(values, "max_bytes", 0)
if minBytes <= 0 || maxBytes < minBytes {
return errors.New("kafka max_bytes 必须大于等于 min_bytes且二者必须大于 0")
}
if integrationInt64(values, "max_wait", 0) <= 0 {
return errors.New("kafka max_wait 必须大于 0")
}
if integrationInt64(values, "connect_timeout", 0) <= 0 {
return errors.New("kafka connect_timeout 必须大于 0")
}
if integrationInt64(values, "reconnect_interval", 0) <= 0 {
return errors.New("kafka reconnect_interval 必须大于 0")
}
if integrationBool(values, "tls_skip_verify") && !integrationBool(values, "tls") {
return errors.New("kafka tls_skip_verify 仅能在启用 TLS 时使用")
}
case IntegrationKindWebSocket + "/melody": case IntegrationKindWebSocket + "/melody":
path := integrationText(values, "path") path := integrationText(values, "path")
if !strings.HasPrefix(path, "/") { if !strings.HasPrefix(path, "/") {
@ -391,6 +430,34 @@ func integrationText(values map[string]any, key string) string {
return strings.TrimSpace(fmt.Sprint(value)) return strings.TrimSpace(fmt.Sprint(value))
} }
func integrationStrings(values map[string]any, key string) []string {
switch items := values[key].(type) {
case []string:
result := make([]string, 0, len(items))
for _, item := range items {
if item = strings.TrimSpace(item); item != "" {
result = append(result, item)
}
}
return result
case []any:
result := make([]string, 0, len(items))
for _, item := range items {
if value := strings.TrimSpace(fmt.Sprint(item)); value != "" {
result = append(result, value)
}
}
return result
default:
return nil
}
}
func integrationBool(values map[string]any, key string) bool {
value, _ := values[key].(bool)
return value
}
func integrationFirst(values map[string]any, keys ...string) string { func integrationFirst(values map[string]any, keys ...string) string {
for _, key := range keys { for _, key := range keys {
if value := integrationText(values, key); value != "" { if value := integrationText(values, key); value != "" {

View File

@ -110,6 +110,26 @@ var integrationDefinitions = map[string][]IntegrationConfigDefinition{
{Key: "reconnect_interval", Label: "重连退避上限(秒)", Type: "number", Required: true, Description: "网络中断后自动重连的最大退避间隔。"}, {Key: "reconnect_interval", Label: "重连退避上限(秒)", Type: "number", Required: true, Description: "网络中断后自动重连的最大退避间隔。"},
}, },
}, },
{
Kind: IntegrationKindMQ, Provider: "kafka", Name: "Kafka", Description: "Apache Kafka 分布式事件队列",
Defaults: map[string]any{"brokers": []string{"127.0.0.1:9092"}, "client_id": "kra", "group_id": "kra", "username": "", "password": "", "tls": false, "tls_skip_verify": false, "start_offset": "earliest", "min_bytes": 1, "max_bytes": 10485760, "max_wait": 1, "connect_timeout": 10, "reconnect_interval": 5, "allow_auto_topic_creation": false},
Fields: []IntegrationConfigField{
{Key: "brokers", Label: "Broker 地址", Type: "string-list", Required: true, Placeholder: "127.0.0.1:9092", Description: "每行一个 host:port 地址。"},
{Key: "client_id", Label: "客户端 ID", Type: "text", Required: true, Placeholder: "kra"},
{Key: "group_id", Label: "消费组 ID", Type: "text", Required: true, Placeholder: "kra"},
{Key: "username", Label: "SASL 用户名", Type: "text"},
{Key: "password", Label: "SASL 密码", Type: "password", Secret: true},
{Key: "tls", Label: "启用 TLS", Type: "switch"},
{Key: "tls_skip_verify", Label: "跳过 TLS 证书校验", Type: "switch", Description: "仅用于受控测试环境。"},
{Key: "start_offset", Label: "初始消费位置", Type: "select", Required: true, Options: []IntegrationConfigOption{{Label: "earliest", Value: "earliest"}, {Label: "latest", Value: "latest"}}},
{Key: "min_bytes", Label: "最小拉取字节数", Type: "number", Required: true},
{Key: "max_bytes", Label: "最大拉取字节数", Type: "number", Required: true},
{Key: "max_wait", Label: "最大拉取等待(秒)", Type: "number", Required: true},
{Key: "connect_timeout", Label: "连接超时(秒)", Type: "number", Required: true},
{Key: "reconnect_interval", Label: "重连间隔(秒)", Type: "number", Required: true},
{Key: "allow_auto_topic_creation", Label: "允许自动创建 Topic", Type: "switch"},
},
},
{ {
Kind: IntegrationKindMQ, Provider: "rabbitmq", Name: "RabbitMQ", Description: "RabbitMQ AMQP 消息队列", Kind: IntegrationKindMQ, Provider: "rabbitmq", Name: "RabbitMQ", Description: "RabbitMQ AMQP 消息队列",
Defaults: map[string]any{"host": "127.0.0.1", "port": 5672, "username": "guest", "password": "guest", "vhost": "/", "exchange": "kra", "exchange_type": "topic", "queue": "kra", "routing_key": "#", "durable": true, "auto_delete": false, "prefetch_count": 10, "heartbeat": 10, "connect_timeout": 10, "reconnect_interval": 5, "tls": false}, Defaults: map[string]any{"host": "127.0.0.1", "port": 5672, "username": "guest", "password": "guest", "vhost": "/", "exchange": "kra", "exchange_type": "topic", "queue": "kra", "routing_key": "#", "durable": true, "auto_delete": false, "prefetch_count": 10, "heartbeat": 10, "connect_timeout": 10, "reconnect_interval": 5, "tls": false},

View File

@ -38,6 +38,7 @@ const (
PaymentModeExternal = "external" PaymentModeExternal = "external"
PaymentModeInternal = "internal" PaymentModeInternal = "internal"
DemoBusinessType = "demo_subscription"
) )
var SupportedPaymentProviders = []string{ var SupportedPaymentProviders = []string{
@ -537,6 +538,10 @@ func supportedPaymentProvider(provider string) bool {
return false return false
} }
func IsDemoPaymentOrder(order *PaymentOrder) bool {
return order != nil && order.BusinessType == DemoBusinessType && strings.HasPrefix(strings.TrimSpace(order.TradeNo), "DEMO-PAY-")
}
func (uc *PaymentUsecase) Query(ctx context.Context, provider, tradeNo string) (*PaymentResult, error) { func (uc *PaymentUsecase) Query(ctx context.Context, provider, tradeNo string) (*PaymentResult, error) {
if !validPaymentText(provider, 64) || !validPaymentText(tradeNo, 128) { if !validPaymentText(provider, 64) || !validPaymentText(tradeNo, 128) {
return nil, errors.New("查询支付参数不完整") return nil, errors.New("查询支付参数不完整")
@ -548,6 +553,9 @@ func (uc *PaymentUsecase) Query(ctx context.Context, provider, tradeNo string) (
if err != nil { if err != nil {
return nil, err return nil, err
} }
if IsDemoPaymentOrder(order) {
return paymentResultFromOrder(order), nil
}
if provider == PaymentInternal || order.PaymentMode == PaymentModeInternal { if provider == PaymentInternal || order.PaymentMode == PaymentModeInternal {
return paymentResultFromOrder(order), nil return paymentResultFromOrder(order), nil
} }
@ -582,7 +590,14 @@ func (uc *PaymentUsecase) Refund(ctx context.Context, provider, tradeNo string,
if uc.orders == nil { if uc.orders == nil {
return nil, errors.New("支付订单仓储未接入") return nil, errors.New("支付订单仓储未接入")
} }
return uc.refundWithOrder(ctx, provider, tradeNo, amount) order, err := uc.orders.FindPaymentOrder(ctx, provider, tradeNo)
if err != nil {
return nil, err
}
if IsDemoPaymentOrder(order) {
return nil, errors.New("演示订单不执行真实退款")
}
return uc.refundWithOrder(ctx, order, amount)
} }
// TestProvider exercises the configured provider without requiring a business // TestProvider exercises the configured provider without requiring a business
@ -614,6 +629,9 @@ func (uc *PaymentUsecase) Fulfill(ctx context.Context, provider, tradeNo string)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if IsDemoPaymentOrder(order) {
return nil, errors.New("演示订单不执行真实发货")
}
result := paymentResultFromOrder(order) result := paymentResultFromOrder(order)
if result == nil { if result == nil {
return nil, ErrPaymentOrderNotFound return nil, ErrPaymentOrderNotFound
@ -663,9 +681,6 @@ func (uc *PaymentUsecase) createWithOrder(ctx context.Context, req *PaymentReque
ConfirmationID: paymentConfirmationID(req.Provider, req.TradeNo), ConfirmationID: paymentConfirmationID(req.Provider, req.TradeNo),
RequestFingerprint: paymentOrderFingerprint(req, extra), RequestFingerprint: paymentOrderFingerprint(req, extra),
Extra: extra, Extra: extra,
ClientIP: req.ClientIP,
UserAgent: req.UserAgent,
DeviceID: req.DeviceID,
} }
order, created, err := uc.orders.CreatePaymentOrder(ctx, order) order, created, err := uc.orders.CreatePaymentOrder(ctx, order)
if err != nil { if err != nil {
@ -866,17 +881,17 @@ func (uc *PaymentUsecase) fulfillOrder(ctx context.Context, order *PaymentOrder,
return attachOrderResult(result, order), nil return attachOrderResult(result, order), nil
} }
func (uc *PaymentUsecase) refundWithOrder(ctx context.Context, provider, tradeNo string, amount int64) (*PaymentResult, error) { func (uc *PaymentUsecase) refundWithOrder(ctx context.Context, current *PaymentOrder, amount int64) (*PaymentResult, error) {
current, err := uc.orders.FindPaymentOrder(ctx, provider, tradeNo) if current == nil {
if err != nil { return nil, ErrPaymentOrderNotFound
return nil, err
} }
provider, tradeNo := current.Provider, current.TradeNo
handler := uc.fulfillments.Handler(current.BusinessType) handler := uc.fulfillments.Handler(current.BusinessType)
authorizer, ok := handler.(PaymentRefundAuthorizer) authorizer, ok := handler.(PaymentRefundAuthorizer)
if !ok { if !ok {
return nil, fmt.Errorf("支付业务未实现退款授权: %s", current.BusinessType) return nil, fmt.Errorf("支付业务未实现退款授权: %s", current.BusinessType)
} }
if err = authorizer.AuthorizeRefund(ctx, current, amount); err != nil { if err := authorizer.AuthorizeRefund(ctx, current, amount); err != nil {
return nil, err return nil, err
} }
order, token, err := uc.orders.BeginPaymentRefund(ctx, provider, tradeNo, amount, 10*time.Minute) order, token, err := uc.orders.BeginPaymentRefund(ctx, provider, tradeNo, amount, 10*time.Minute)

View File

@ -88,9 +88,6 @@ type PaymentOrder struct {
RefundedAt *time.Time RefundedAt *time.Time
FulfillmentLeaseUntil *time.Time FulfillmentLeaseUntil *time.Time
RefundLeaseUntil *time.Time RefundLeaseUntil *time.Time
ClientIP string
UserAgent string
DeviceID string
} }
type PaymentProviderUpdate struct { type PaymentProviderUpdate struct {

View File

@ -157,6 +157,33 @@ func testBizPaymentOrder() *PaymentOrder {
return &PaymentOrder{Provider: PaymentAlipay, TradeNo: "order-1", BusinessType: "game_item", BusinessID: "item-1", Subject: "item", Amount: 100, Currency: "CNY", PaymentStatus: PaymentStatusPending, FulfillmentStatus: FulfillmentStatusPending, RefundStatus: RefundStatusNone, ConfirmationID: "11111111-1111-1111-1111-111111111111"} return &PaymentOrder{Provider: PaymentAlipay, TradeNo: "order-1", BusinessType: "game_item", BusinessID: "item-1", Subject: "item", Amount: 100, Currency: "CNY", PaymentStatus: PaymentStatusPending, FulfillmentStatus: FulfillmentStatusPending, RefundStatus: RefundStatusNone, ConfirmationID: "11111111-1111-1111-1111-111111111111"}
} }
func TestDemoPaymentOperationsNeverReachExternalProviderOrFulfillment(t *testing.T) {
repo := &paymentRepoStub{query: successPaymentResult(), refund: successPaymentResult()}
order := testBizPaymentOrder()
order.TradeNo = "DEMO-PAY-01"
order.BusinessType = DemoBusinessType
order.PaymentStatus = PaymentStatusPaid
orders := &paymentOrderRepoStub{order: order}
registry := NewPaymentFulfillmentRegistry()
handler := &paymentFulfillmentStub{}
if err := registry.Register(handler); err != nil {
t.Fatal(err)
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
uc := NewPaymentUsecase(repo, orders, nil, nil, registry, logger)
result, err := uc.Query(context.Background(), order.Provider, order.TradeNo)
if err != nil || result == nil || repo.queryCalls != 0 {
t.Fatalf("demo query result=%#v calls=%d err=%v", result, repo.queryCalls, err)
}
if _, err = uc.Refund(context.Background(), order.Provider, order.TradeNo, 10); err == nil || repo.refundedReq != nil {
t.Fatalf("demo refund reached provider: req=%#v err=%v", repo.refundedReq, err)
}
if _, err = uc.Fulfill(context.Background(), order.Provider, order.TradeNo); err == nil || handler.calls != 0 {
t.Fatalf("demo fulfillment reached handler: calls=%d err=%v", handler.calls, err)
}
}
func successPaymentResult() *PaymentResult { func successPaymentResult() *PaymentResult {
return &PaymentResult{ return &PaymentResult{
Provider: PaymentAlipay, Provider: PaymentAlipay,

View File

@ -18,12 +18,14 @@ func Migrations() []migration.Step {
}}, }},
{ID: "202608210001_communication_integration_defaults", Migrate: ensureCommunicationIntegrationConfigs}, {ID: "202608210001_communication_integration_defaults", Migrate: ensureCommunicationIntegrationConfigs},
{ID: "202608200004_payment_defaults", Migrate: ensurePaymentIntegrationConfigs}, {ID: "202608200004_payment_defaults", Migrate: ensurePaymentIntegrationConfigs},
{ID: "202608290004_kafka_integration_default", Migrate: ensureCommunicationIntegrationConfigs},
} }
} }
func ensureCommunicationIntegrationConfigs(db *gorm.DB) error { func ensureCommunicationIntegrationConfigs(db *gorm.DB) error {
defaults := []struct{ kind, provider string }{ defaults := []struct{ kind, provider string }{
{integrationbiz.IntegrationKindMQ, "emqx"}, {integrationbiz.IntegrationKindMQ, "emqx"},
{integrationbiz.IntegrationKindMQ, "kafka"},
{integrationbiz.IntegrationKindMQ, "rabbitmq"}, {integrationbiz.IntegrationKindMQ, "rabbitmq"},
{integrationbiz.IntegrationKindWebSocket, "melody"}, {integrationbiz.IntegrationKindWebSocket, "melody"},
} }

View File

@ -25,9 +25,10 @@ func SeedDemoOrders(db *gorm.DB, now time.Time) (*DemoSeedResult, error) {
if !db.Migrator().HasTable(&paymentOrderPO{}) || !db.Migrator().HasTable(&paymentEventPO{}) { if !db.Migrator().HasTable(&paymentOrderPO{}) || !db.Migrator().HasTable(&paymentEventPO{}) {
return nil, fmt.Errorf("payment tables are not migrated") return nil, fmt.Errorf("payment tables are not migrated")
} }
now = now.UTC().Truncate(time.Second)
if now.IsZero() { if now.IsZero() {
now = time.Now().UTC().Truncate(time.Second) now = time.Now().UTC().Truncate(time.Second)
} else {
now = now.UTC().Truncate(time.Second)
} }
type demoOrder struct { type demoOrder struct {
@ -37,7 +38,7 @@ func SeedDemoOrders(db *gorm.DB, now time.Time) (*DemoSeedResult, error) {
makeOrder := func(index int, provider, subject, status, fulfillment, refund string, amount int64, currency string, created time.Time) paymentOrderPO { makeOrder := func(index int, provider, subject, status, fulfillment, refund string, amount int64, currency string, created time.Time) paymentOrderPO {
tradeNo := fmt.Sprintf("%s%02d", DemoTradePrefix, index) tradeNo := fmt.Sprintf("%s%02d", DemoTradePrefix, index)
return paymentOrderPO{ return paymentOrderPO{
TradeNo: tradeNo, Provider: provider, BusinessType: "demo_subscription", BusinessID: fmt.Sprintf("DEMO-LICENSE-%02d", index), TradeNo: tradeNo, Provider: provider, BusinessType: bizpayment.DemoBusinessType, BusinessID: fmt.Sprintf("DEMO-LICENSE-%02d", index),
Subject: subject, PaymentMode: bizpayment.PaymentModeExternal, OriginalAmount: amount, Amount: amount, Subject: subject, PaymentMode: bizpayment.PaymentModeExternal, OriginalAmount: amount, Amount: amount,
Currency: currency, PaymentStatus: status, FulfillmentStatus: fulfillment, RefundStatus: refund, Currency: currency, PaymentStatus: status, FulfillmentStatus: fulfillment, RefundStatus: refund,
ConfirmationID: uuid.NewString(), RequestFingerprint: fmt.Sprintf("demo-fingerprint-%02d", index), ConfirmationID: uuid.NewString(), RequestFingerprint: fmt.Sprintf("demo-fingerprint-%02d", index),

View File

@ -2,6 +2,7 @@ package payment
import ( import (
"context" "context"
"errors"
bizpayment "kra/internal/biz/payment" bizpayment "kra/internal/biz/payment"
"time" "time"
@ -48,6 +49,10 @@ func appendPaymentEvent(ctx context.Context, tx *gorm.DB, order *paymentOrderPO,
if tx == nil || order == nil || event == nil { if tx == nil || order == nil || event == nil {
return nil return nil
} }
eventType := trimTo(event.Type, 64)
if eventType == "" {
return errors.New("支付事件类型为空")
}
operation := bizpayment.PaymentOperationFromContext(ctx) operation := bizpayment.PaymentOperationFromContext(ctx)
source := trimTo(event.Source, 64) source := trimTo(event.Source, 64)
if source == "" { if source == "" {
@ -81,7 +86,7 @@ func appendPaymentEvent(ctx context.Context, tx *gorm.DB, order *paymentOrderPO,
deviceID = trimTo(operation.DeviceID, 128) deviceID = trimTo(operation.DeviceID, 128)
} }
return tx.Create(&paymentEventPO{ return tx.Create(&paymentEventPO{
Provider: order.Provider, TradeNo: order.TradeNo, Type: trimTo(event.Type, 64), Source: source, Provider: order.Provider, TradeNo: order.TradeNo, Type: eventType, Source: source,
Status: trimTo(event.Status, 64), ProviderStatus: trimTo(event.ProviderStatus, 64), Message: message, Status: trimTo(event.Status, 64), ProviderStatus: trimTo(event.ProviderStatus, 64), Message: message,
EventID: trimTo(event.EventID, 128), PayloadHash: trimTo(event.PayloadHash, 64), Amount: event.Amount, EventID: trimTo(event.EventID, 128), PayloadHash: trimTo(event.PayloadHash, 64), Amount: event.Amount,
Currency: trimTo(event.Currency, 16), OperatorID: operatorID, OperatorName: operatorName, Currency: trimTo(event.Currency, 16), OperatorID: operatorID, OperatorName: operatorName,

View File

@ -152,8 +152,7 @@ func (r *paymentOrderRepo) CreatePaymentOrder(ctx context.Context, order *bizpay
} }
return appendPaymentEvent(ctx, tx, po, &bizpayment.PaymentEvent{ return appendPaymentEvent(ctx, tx, po, &bizpayment.PaymentEvent{
Type: bizpayment.PaymentEventOrderCreated, Source: "client", Status: po.PaymentStatus, Type: bizpayment.PaymentEventOrderCreated, Source: "client", Status: po.PaymentStatus,
Amount: po.Amount, Currency: po.Currency, ClientIP: order.ClientIP, Amount: po.Amount, Currency: po.Currency,
UserAgent: order.UserAgent, DeviceID: order.DeviceID,
}) })
}) })
if err == nil { if err == nil {
@ -637,8 +636,9 @@ func defaultVersion(value uint64) uint64 {
func trimTo(value string, max int) string { func trimTo(value string, max int) string {
value = strings.TrimSpace(value) value = strings.TrimSpace(value)
if len(value) > max { runes := []rune(value)
return value[:max] if len(runes) > max {
return string(runes[:max])
} }
return value return value
} }

View File

@ -5,11 +5,13 @@ import (
bizpayment "kra/internal/biz/payment" bizpayment "kra/internal/biz/payment"
"testing" "testing"
"time" "time"
"github.com/google/uuid"
) )
func newPaymentOrderRepoForTest(t *testing.T) *paymentOrderRepo { func newPaymentOrderRepoForTest(t *testing.T) *paymentOrderRepo {
t.Helper() t.Helper()
db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared") db, err := openWithDriver("sqlite", "file:"+t.Name()+"-"+uuid.NewString()+"?mode=memory&cache=shared")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -119,11 +121,9 @@ func TestPaymentOrderRepositorySummarizesAndListsEvents(t *testing.T) {
repo := newPaymentOrderRepoForTest(t) repo := newPaymentOrderRepoForTest(t)
ctx := bizpayment.WithPaymentOperation(context.Background(), bizpayment.PaymentOperation{ ctx := bizpayment.WithPaymentOperation(context.Background(), bizpayment.PaymentOperation{
Source: "admin", Reason: "客户重复购买", OperatorID: 7, OperatorName: "operator", Source: "admin", Reason: "客户重复购买", OperatorID: 7, OperatorName: "operator",
ClientIP: "127.0.0.1", DeviceID: "device-1", ClientIP: "127.0.0.1", UserAgent: "payment-test/1.0", DeviceID: "device-1",
}) })
order := testPaymentOrder() order := testPaymentOrder()
order.ClientIP = "10.0.0.8"
order.UserAgent = "payment-client/1.0"
if _, _, err := repo.CreatePaymentOrder(ctx, order); err != nil { if _, _, err := repo.CreatePaymentOrder(ctx, order); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -168,3 +168,9 @@ func TestPaymentOrderRepositorySummarizesAndListsEvents(t *testing.T) {
t.Fatalf("refund audit event missing operation context: %#v", events) t.Fatalf("refund audit event missing operation context: %#v", events)
} }
} }
func TestTrimToPreservesUTF8(t *testing.T) {
if got := trimTo(" 退款处理失败 ", 4); got != "退款处理" {
t.Fatalf("trimTo() = %q, want %q", got, "退款处理")
}
}

View File

@ -3,11 +3,12 @@ package system
import ( import (
"testing" "testing"
"github.com/google/uuid"
platformmodule "kra/pkg/module" platformmodule "kra/pkg/module"
) )
func TestEnsureAdminSurfaceAndPolicyInheritance(t *testing.T) { func TestEnsureAdminSurfaceAndPolicyInheritance(t *testing.T) {
db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared") db, err := openWithDriver("sqlite", "file:"+t.Name()+"-"+uuid.NewString()+"?mode=memory&cache=shared")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }

View File

@ -16,10 +16,13 @@ import (
const ( const (
ProviderEMQX = platformmq.ProviderEMQX ProviderEMQX = platformmq.ProviderEMQX
ProviderKafka = platformmq.ProviderKafka
ProviderRabbitMQ = platformmq.ProviderRabbitMQ ProviderRabbitMQ = platformmq.ProviderRabbitMQ
retryTick = time.Second retryTick = time.Second
) )
var providers = []string{ProviderEMQX, ProviderKafka, ProviderRabbitMQ}
// Reloadable owns process-wide message clients and the logical subscription // Reloadable owns process-wide message clients and the logical subscription
// declarations used to restore them after reconnects or configuration reloads. // declarations used to restore them after reconnects or configuration reloads.
type Reloadable struct { type Reloadable struct {
@ -65,12 +68,11 @@ func New(store *runtimeconfig.Store, logger *slog.Logger) (*Reloadable, func(),
logger: logger, logger: logger,
} }
if store != nil { if store != nil {
r.apply(ProviderEMQX, storeConfig(store, ProviderEMQX)) for _, provider := range providers {
r.apply(ProviderRabbitMQ, storeConfig(store, ProviderRabbitMQ)) r.apply(provider, storeConfig(store, provider))
r.stop = append(r.stop, provider := provider
store.Subscribe("mq", ProviderEMQX, func(config runtimeconfig.Config) { r.apply(ProviderEMQX, config) }), r.stop = append(r.stop, store.Subscribe("mq", provider, func(config runtimeconfig.Config) { r.apply(provider, config) }))
store.Subscribe("mq", ProviderRabbitMQ, func(config runtimeconfig.Config) { r.apply(ProviderRabbitMQ, config) }), }
)
} }
go r.retryLoop() go r.retryLoop()
cleanup := func() { cleanup := func() {
@ -88,7 +90,7 @@ func storeConfig(store *runtimeconfig.Store, provider string) runtimeconfig.Conf
} }
// TestConfig creates a short-lived provider client and closes it immediately. // TestConfig creates a short-lived provider client and closes it immediately.
// For RabbitMQ this also checks the configured exchange and queue topology. // RabbitMQ also checks topology; Kafka reads cluster metadata.
func TestConfig(ctx context.Context, provider string, raw json.RawMessage) error { func TestConfig(ctx context.Context, provider string, raw json.RawMessage) error {
if ctx != nil { if ctx != nil {
select { select {
@ -218,6 +220,24 @@ func newProviderClient(provider string, raw json.RawMessage) (platformmq.Client,
ReconnectInterval: configSeconds(values, "reconnect_interval"), ReconnectInterval: configSeconds(values, "reconnect_interval"),
TLS: configBool(values, "tls"), TLS: configBool(values, "tls"),
}) })
case ProviderKafka:
return platformmq.NewKafka(platformmq.KafkaConfig{
Enabled: true,
Brokers: configStrings(values, "brokers"),
ClientID: configText(values, "client_id"),
GroupID: configText(values, "group_id"),
Username: configText(values, "username"),
Password: configText(values, "password"),
TLS: configBool(values, "tls"),
TLSSkipVerify: configBool(values, "tls_skip_verify"),
StartOffset: configText(values, "start_offset"),
MinBytes: configInt(values, "min_bytes"),
MaxBytes: configInt(values, "max_bytes"),
MaxWait: configSeconds(values, "max_wait"),
ConnectTimeout: configSeconds(values, "connect_timeout"),
ReconnectInterval: configSeconds(values, "reconnect_interval"),
AllowAutoTopicCreation: configBool(values, "allow_auto_topic_creation"),
})
default: default:
return nil, fmt.Errorf("unsupported message provider %q", provider) return nil, fmt.Errorf("unsupported message provider %q", provider)
} }
@ -246,6 +266,24 @@ func configInt(values map[string]any, key string) int {
} }
} }
func configStrings(values map[string]any, key string) []string {
items, ok := values[key].([]any)
if ok {
result := make([]string, 0, len(items))
for _, item := range items {
if value := strings.TrimSpace(fmt.Sprint(item)); value != "" {
result = append(result, value)
}
}
return result
}
stringsValue, ok := values[key].([]string)
if ok {
return append([]string(nil), stringsValue...)
}
return nil
}
func configSeconds(values map[string]any, key string) time.Duration { func configSeconds(values map[string]any, key string) time.Duration {
seconds := configInt(values, key) seconds := configInt(values, key)
if seconds <= 0 { if seconds <= 0 {
@ -281,7 +319,7 @@ func (r *Reloadable) retryOnce() {
} }
r.ensureStateLocked() r.ensureStateLocked()
now := time.Now() now := time.Now()
for _, provider := range []string{ProviderEMQX, ProviderRabbitMQ} { for _, provider := range providers {
config, exists := r.configs[provider] config, exists := r.configs[provider]
if !exists || !config.Enabled { if !exists || !config.Enabled {
continue continue
@ -631,7 +669,7 @@ func (r *Reloadable) UnsubscribeFrom(ctx context.Context, provider string, topic
func (r *Reloadable) Client(provider string) platformmq.Client { func (r *Reloadable) Client(provider string) platformmq.Client {
provider = strings.ToLower(strings.TrimSpace(provider)) provider = strings.ToLower(strings.TrimSpace(provider))
if provider != ProviderEMQX && provider != ProviderRabbitMQ { if provider != ProviderEMQX && provider != ProviderKafka && provider != ProviderRabbitMQ {
return nil return nil
} }
return &namedClient{owner: r, provider: provider} return &namedClient{owner: r, provider: provider}

View File

@ -189,6 +189,7 @@ func (s *PaymentService) Create(ctx context.Context, req *dto.PaymentRequest) (*
if req == nil { if req == nil {
return nil, errors.New("支付请求为空") return nil, errors.New("支付请求为空")
} }
ctx = operationContext(ctx, "client", "客户端创建支付订单", 0, "", req.ClientIP, req.UserAgent, req.DeviceID)
result, err := s.uc.Create(ctx, &paymentbiz.PaymentRequest{ result, err := s.uc.Create(ctx, &paymentbiz.PaymentRequest{
Provider: req.Provider, TradeNo: req.TradeNo, Subject: req.Subject, Provider: req.Provider, TradeNo: req.TradeNo, Subject: req.Subject,
Amount: req.Amount, OriginalAmount: req.OriginalAmount, Currency: req.Currency, Amount: req.Amount, OriginalAmount: req.OriginalAmount, Currency: req.Currency,

View File

@ -12,6 +12,7 @@ var ErrUnavailable = errors.New("message broker unavailable")
const ( const (
ProviderEMQX = "emqx" ProviderEMQX = "emqx"
ProviderKafka = "kafka"
ProviderRabbitMQ = "rabbitmq" ProviderRabbitMQ = "rabbitmq"
) )

View File

@ -12,7 +12,7 @@ func NormalizeSubscriptionSet(set SubscriptionSet) (SubscriptionSet, error) {
if set.Owner == "" { if set.Owner == "" {
return SubscriptionSet{}, fmt.Errorf("mq subscription owner is empty") return SubscriptionSet{}, fmt.Errorf("mq subscription owner is empty")
} }
if set.Provider != ProviderEMQX && set.Provider != ProviderRabbitMQ { if set.Provider != ProviderEMQX && set.Provider != ProviderKafka && set.Provider != ProviderRabbitMQ {
return SubscriptionSet{}, fmt.Errorf("unsupported mq provider %q", set.Provider) return SubscriptionSet{}, fmt.Errorf("unsupported mq provider %q", set.Provider)
} }
if len(set.Topics) == 0 { if len(set.Topics) == 0 {

View File

@ -265,6 +265,12 @@ const TARGETS = {
description: 'RabbitMQ 消息队列', description: 'RabbitMQ 消息队列',
icon: Promotion icon: Promotion
}, },
'mq/kafka': {
name: 'Kafka',
protocol: 'Kafka',
description: 'Apache Kafka 分布式事件队列',
icon: Promotion
},
'websocket/melody': { 'websocket/melody': {
name: 'WebSocket', name: 'WebSocket',
protocol: 'WS', protocol: 'WS',
@ -276,7 +282,7 @@ const TARGETS = {
const TARGET_ORDER = Object.keys(TARGETS) const TARGET_ORDER = Object.keys(TARGETS)
const RECONNECT_INTERVAL_KEY = 'reconnect_interval' const RECONNECT_INTERVAL_KEY = 'reconnect_interval'
const RECONNECT_DEFAULT_SECONDS = 5 const RECONNECT_DEFAULT_SECONDS = 5
const MQ_RECONNECT_TARGETS = new Set(['mq/emqx', 'mq/rabbitmq']) const MQ_RECONNECT_TARGETS = new Set(['mq/emqx', 'mq/kafka', 'mq/rabbitmq'])
const RECONNECT_INTERVAL_FIELD = { const RECONNECT_INTERVAL_FIELD = {
key: RECONNECT_INTERVAL_KEY, key: RECONNECT_INTERVAL_KEY,
label: '重连间隔(秒)', label: '重连间隔(秒)',
@ -291,6 +297,9 @@ const NUMBER_CONSTRAINTS = {
reconnect_interval: { min: 1, integer: true }, reconnect_interval: { min: 1, integer: true },
prefetch_count: { min: 0 }, prefetch_count: { min: 0 },
heartbeat: { min: 0 }, heartbeat: { min: 0 },
min_bytes: { min: 1, integer: true },
max_bytes: { min: 1, integer: true },
max_wait: { min: 1 },
max_message_size: { min: 0 }, max_message_size: { min: 0 },
message_buffer_size: { min: 0 } message_buffer_size: { min: 0 }
} }

View File

@ -78,7 +78,7 @@
<el-table-column label="订单与交易" min-width="285"> <el-table-column label="订单与交易" min-width="285">
<template #default="scope"> <template #default="scope">
<div class="order-cell"> <div class="order-cell">
<button type="button" class="order-link" @click="openDetail(scope.row)">{{ scope.row.tradeNo || '-' }}</button> <span class="order-title-line"><button type="button" class="order-link" @click="openDetail(scope.row)">{{ scope.row.tradeNo || '-' }}</button><el-tag v-if="isDemoOrder(scope.row)" size="small" type="info" effect="plain">演示</el-tag></span>
<span v-if="scope.row.providerTradeNo">渠道单号 {{ scope.row.providerTradeNo }}</span> <span v-if="scope.row.providerTradeNo">渠道单号 {{ scope.row.providerTradeNo }}</span>
<span>{{ scope.row.businessType || '-' }} · {{ scope.row.businessId || '-' }}</span> <span>{{ scope.row.businessType || '-' }} · {{ scope.row.businessId || '-' }}</span>
</div> </div>
@ -140,6 +140,7 @@
<div class="mobile-order-meta"><span>{{ order.businessType || '-' }} · {{ order.businessId || '-' }}</span><span>{{ formatDateValue(order.createdAt) }}</span></div> <div class="mobile-order-meta"><span>{{ order.businessType || '-' }} · {{ order.businessId || '-' }}</span><span>{{ formatDateValue(order.createdAt) }}</span></div>
<div class="mobile-order-footer"> <div class="mobile-order-footer">
<div class="status-stack"> <div class="status-stack">
<el-tag v-if="isDemoOrder(order)" size="small" type="info" effect="plain">演示</el-tag>
<el-tag size="small" :type="paymentStatusType(order.paymentStatus)">{{ paymentStatusText(order.paymentStatus) }}</el-tag> <el-tag size="small" :type="paymentStatusType(order.paymentStatus)">{{ paymentStatusText(order.paymentStatus) }}</el-tag>
<el-tag size="small" :type="fulfillmentStatusType(order.fulfillmentStatus)" effect="plain">{{ fulfillmentStatusText(order.fulfillmentStatus, order) }}</el-tag> <el-tag size="small" :type="fulfillmentStatusType(order.fulfillmentStatus)" effect="plain">{{ fulfillmentStatusText(order.fulfillmentStatus, order) }}</el-tag>
<el-tag v-if="order.refundStatus !== 'none'" size="small" :type="refundStatusType(order.refundStatus)" effect="plain">{{ refundStatusText(order.refundStatus) }}</el-tag> <el-tag v-if="order.refundStatus !== 'none'" size="small" :type="refundStatusType(order.refundStatus)" effect="plain">{{ refundStatusText(order.refundStatus) }}</el-tag>
@ -165,6 +166,7 @@
<template v-if="detail"> <template v-if="detail">
<div class="detail-toolbar"> <div class="detail-toolbar">
<div class="detail-statuses"> <div class="detail-statuses">
<el-tag v-if="isDemoOrder(detail)" type="info" effect="plain">演示订单</el-tag>
<el-tag :type="paymentStatusType(detail.paymentStatus)">{{ paymentStatusText(detail.paymentStatus) }}</el-tag> <el-tag :type="paymentStatusType(detail.paymentStatus)">{{ paymentStatusText(detail.paymentStatus) }}</el-tag>
<el-tag :type="fulfillmentStatusType(detail.fulfillmentStatus)" effect="plain">{{ fulfillmentStatusText(detail.fulfillmentStatus, detail) }}</el-tag> <el-tag :type="fulfillmentStatusType(detail.fulfillmentStatus)" effect="plain">{{ fulfillmentStatusText(detail.fulfillmentStatus, detail) }}</el-tag>
<el-tag :type="refundStatusType(detail.refundStatus)" effect="plain">{{ refundStatusText(detail.refundStatus) }}</el-tag> <el-tag :type="refundStatusType(detail.refundStatus)" effect="plain">{{ refundStatusText(detail.refundStatus) }}</el-tag>
@ -288,7 +290,7 @@ const providerNames = Object.fromEntries(providerOptions.map((item) => [item.val
Object.assign(providerNames, { admin: '后台人工操作', client: '客户端', system: '系统任务', internal: '内部支付' }) Object.assign(providerNames, { admin: '后台人工操作', client: '客户端', system: '系统任务', internal: '内部支付' })
const paymentStatusOptions = Object.entries(PAYMENT_STATUS_META).map(([value, item]) => ({ value, label: item.label })) const paymentStatusOptions = Object.entries(PAYMENT_STATUS_META).map(([value, item]) => ({ value, label: item.label }))
const refundStatusOptions = Object.entries(REFUND_STATUS_META).map(([value, item]) => ({ value, label: item.label })) const refundStatusOptions = Object.entries(REFUND_STATUS_META).map(([value, item]) => ({ value, label: item.label }))
const ZERO_DECIMAL_CURRENCIES = new Set(['BIF', 'CLP', 'DJF', 'GNF', 'ISK', 'JPY', 'KMF', 'KRW', 'PYG', 'RWF', 'UGX', 'UYI', 'VND', 'VUV', 'XAF', 'XOF', 'XPF']) const ZERO_DECIMAL_CURRENCIES = new Set(['BIF', 'CLP', 'DJF', 'GNF', 'ISK', 'JPY', 'KMF', 'KRW', 'POINT', 'PYG', 'RWF', 'UGX', 'UYI', 'VND', 'VUV', 'XAF', 'XOF', 'XPF'])
const THREE_DECIMAL_CURRENCIES = new Set(['BHD', 'IQD', 'JOD', 'KWD', 'LYD', 'OMR', 'TND']) const THREE_DECIMAL_CURRENCIES = new Set(['BHD', 'IQD', 'JOD', 'KWD', 'LYD', 'OMR', 'TND'])
const FOUR_DECIMAL_CURRENCIES = new Set(['CLF', 'UYW']) const FOUR_DECIMAL_CURRENCIES = new Set(['CLF', 'UYW'])
const MAX_SAFE_MINOR_AMOUNT = Number.MAX_SAFE_INTEGER const MAX_SAFE_MINOR_AMOUNT = Number.MAX_SAFE_INTEGER
@ -456,9 +458,10 @@ function refundStatusType(status) { return REFUND_STATUS_META[status]?.type || '
function paymentModeText(mode) { return mode === 'internal' ? '内部支付' : mode === 'external' ? '外部渠道' : mode || '-' } function paymentModeText(mode) { return mode === 'internal' ? '内部支付' : mode === 'external' ? '外部渠道' : mode || '-' }
function formatDateValue(value) { return value ? formatDate(value) || '-' : '-' } function formatDateValue(value) { return value ? formatDate(value) || '-' : '-' }
function remainingRefundAmount(order) { return Math.max(0, Number(order?.amount || 0) - Number(order?.refundedAmount || 0)) } function remainingRefundAmount(order) { return Math.max(0, Number(order?.amount || 0) - Number(order?.refundedAmount || 0)) }
function canRefund(order) { return ['paid', 'partially_refunded'].includes(order?.paymentStatus) && ['none', 'partial', 'failed'].includes(order?.refundStatus) && remainingRefundAmount(order) > 0 } function isDemoOrder(order) { return order?.businessType === 'demo_subscription' && String(order?.tradeNo || '').startsWith('DEMO-PAY-') }
function canFulfill(order) { return order?.paymentStatus === 'paid' && ['pending', 'processing', 'failed'].includes(order?.fulfillmentStatus) } function canRefund(order) { return !isDemoOrder(order) && ['paid', 'partially_refunded'].includes(order?.paymentStatus) && ['none', 'partial', 'failed'].includes(order?.refundStatus) && remainingRefundAmount(order) > 0 }
function canSync(order) { return ['initialized', 'pending', 'paid', 'partially_refunded', 'refunded', 'failed'].includes(order?.paymentStatus) } function canFulfill(order) { return !isDemoOrder(order) && order?.paymentStatus === 'paid' && ['pending', 'processing', 'failed'].includes(order?.fulfillmentStatus) }
function canSync(order) { return !isDemoOrder(order) && ['initialized', 'pending', 'paid', 'partially_refunded', 'refunded', 'failed'].includes(order?.paymentStatus) }
function fulfillmentActionText(order) { return ['processing', 'failed'].includes(order?.fulfillmentStatus) ? '重试发货' : '执行发货' } function fulfillmentActionText(order) { return ['processing', 'failed'].includes(order?.fulfillmentStatus) ? '重试发货' : '执行发货' }
function isIssueOrder(order) { return order?.paymentStatus === 'failed' || order?.fulfillmentStatus === 'failed' || order?.refundStatus === 'failed' || Boolean(order?.lastError) } function isIssueOrder(order) { return order?.paymentStatus === 'failed' || order?.fulfillmentStatus === 'failed' || order?.refundStatus === 'failed' || Boolean(order?.lastError) }
function rowClassName({ row }) { return isIssueOrder(row) ? 'is-payment-issue' : '' } function rowClassName({ row }) { return isIssueOrder(row) ? 'is-payment-issue' : '' }
@ -645,6 +648,7 @@ onMounted(load)
.issue-count { color: var(--el-color-danger) !important; } .issue-count { color: var(--el-color-danger) !important; }
.order-cell, .business-cell, .provider-cell, .amount-cell { display: grid; min-width: 0; gap: 5px; } .order-cell, .business-cell, .provider-cell, .amount-cell { display: grid; min-width: 0; gap: 5px; }
.order-link { overflow: hidden; padding: 0; border: 0; background: transparent; color: var(--el-color-primary); font: inherit; font-weight: 500; text-align: left; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; } .order-link { overflow: hidden; padding: 0; border: 0; background: transparent; color: var(--el-color-primary); font: inherit; font-weight: 500; text-align: left; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; }
.order-title-line { display: flex; align-items: center; gap: 6px; min-width: 0; }
.order-cell > span, .business-cell small, .provider-cell small, .amount-cell small { overflow: hidden; color: var(--el-text-color-secondary); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } .order-cell > span, .business-cell small, .provider-cell small, .amount-cell small { overflow: hidden; color: var(--el-text-color-secondary); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.business-cell > span, .provider-cell > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .business-cell > span, .provider-cell > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.amount-cell { justify-items: end; } .amount-cell { justify-items: end; }