Compare commits

...
3 Commits
Author SHA1 Message Date
Yvan e0c35e4e9b 优化结构 2026-08-30 01:08:10 +08:00
Yvan 509df03d61 优化结构 2026-08-29 08:40:15 +08:00
Yvan b6d25c1a0f 优化结构 2026-08-29 02:58:00 +08:00
46 changed files with 3380 additions and 278 deletions
+2
View File
@@ -32,6 +32,7 @@ require (
github.com/rabbitmq/amqp091-go v1.14.0
github.com/redis/go-redis/v9 v9.7.0
github.com/robfig/cron/v3 v3.0.1
github.com/segmentio/kafka-go v0.4.51
github.com/shirou/gopsutil/v4 v4.25.7
github.com/spf13/viper v1.21.0
github.com/swaggo/files v1.0.1
@@ -143,6 +144,7 @@ require (
github.com/mozillazg/go-httpheader v0.2.1 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect
github.com/pierrec/lz4/v4 v4.1.15 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/richardlehane/mscfb v1.0.4 // indirect
+4
View File
@@ -319,6 +319,8 @@ github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY=
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pierrec/lz4/v4 v4.1.15 h1:MO0/ucJhngq7299dKLwIMtgTfbkoSPF6AoMYDd8Q4q0=
github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
@@ -357,6 +359,8 @@ github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/segmentio/kafka-go v0.4.51 h1:JgDPPG75tC1rWIS2Me6MwcvXJ6f49UQ4HjAOef71Hno=
github.com/segmentio/kafka-go v0.4.51/go.mod h1:Y1gn60kzLEEaW28YshXyk2+VCUKbJ3Qr6DrnT3i4+9E=
github.com/shirou/gopsutil/v4 v4.25.7 h1:bNb2JuqKuAu3tRlPv5piSmBZyMfecwQ+t/ILq+1JqVM=
github.com/shirou/gopsutil/v4 v4.25.7/go.mod h1:XV/egmwJtd3ZQjBpJVY5kndsiOO4IRqy9TQnmm6VP7U=
github.com/sijms/go-ora/v2 v2.7.17 h1:M/pYIqjaMUeBxyzOWp2oj4ntF6fHSBloJWGNH9vbmsU=
+1 -1
View File
@@ -11,7 +11,7 @@
- `config`:Viper 配置模型、加载、快照和热更新
- `data`:共享数据库生命周期;仓储按 `data/system`、`data/integration`、`data/task`、`data/payment` 隔离
- `initialize`:数据库首次初始化和系统种子数据编排
- `integration`:Redis、邮件、对象存储、支付、WebSocket、EMQX 和 RabbitMQ 适配器
- `integration`:Redis、邮件、对象存储、支付、WebSocket、EMQX、Kafka 和 RabbitMQ 适配器
- `routecatalog`:统一声明 HTTP 路由的公开性、操作审计、请求体策略和 API 元数据
- `server`:Gin server 组合与生命周期;横切 HTTP 代码按子包维护:
`server/handler`、`server/middleware`、`server/router`、`server/staticfiles`;
+138 -4
View File
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
bizpayment "kra/internal/biz/payment"
"net"
"sort"
"strconv"
"strings"
@@ -78,6 +79,25 @@ type IntegrationConnectionTester interface {
TestIntegration(context.Context, *IntegrationConfig) error
}
type ConnectionTestError struct {
Provider string
Err error
}
func (e *ConnectionTestError) Error() string {
if e == nil || strings.TrimSpace(e.Provider) == "" {
return "连接测试失败"
}
return strings.TrimSpace(e.Provider) + " 连接测试失败"
}
func (e *ConnectionTestError) Unwrap() error {
if e == nil {
return nil
}
return e.Err
}
type IntegrationConfigUsecase struct {
repo IntegrationConfigRepo
tester IntegrationConnectionTester
@@ -164,7 +184,14 @@ func (uc *IntegrationConfigUsecase) Test(ctx context.Context, config *Integratio
if uc.tester == nil {
return errors.New("集成连接测试器未初始化")
}
return uc.tester.TestIntegration(ctx, config)
if err := uc.tester.TestIntegration(ctx, config); err != nil {
provider := config.Provider
if definition, ok := IntegrationDefinition(config.Kind, config.Provider); ok && strings.TrimSpace(definition.Name) != "" {
provider = definition.Name
}
return &ConnectionTestError{Provider: provider, Err: err}
}
return nil
}
func (uc *IntegrationConfigUsecase) Delete(ctx context.Context, kind, provider string) error {
@@ -285,6 +312,44 @@ func validateCommunicationIntegrationConfig(kind, provider string, values map[st
if integrationInt64(values, "reconnect_interval", 0) <= 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":
path := integrationText(values, "path")
if !strings.HasPrefix(path, "/") {
@@ -391,6 +456,34 @@ func integrationText(values map[string]any, key string) string {
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 {
for _, key := range keys {
if value := integrationText(values, key); value != "" {
@@ -414,11 +507,52 @@ func IsIntegrationSecretKey(key string) bool {
}
func integrationInt64(values map[string]any, key string, fallback int64) int64 {
value := integrationText(values, key)
if value == "" {
value, exists := values[key]
if !exists || value == nil {
return fallback
}
parsed, err := strconv.ParseInt(value, 10, 64)
switch typed := value.(type) {
case int:
return int64(typed)
case int8:
return int64(typed)
case int16:
return int64(typed)
case int32:
return int64(typed)
case int64:
return typed
case uint:
return parseIntegrationInt64(strconv.FormatUint(uint64(typed), 10), fallback)
case uint8:
return int64(typed)
case uint16:
return int64(typed)
case uint32:
return int64(typed)
case uint64:
return parseIntegrationInt64(strconv.FormatUint(typed, 10), fallback)
case float32:
return parseIntegrationInt64(strconv.FormatFloat(float64(typed), 'f', -1, 32), fallback)
case float64:
return parseIntegrationInt64(strconv.FormatFloat(typed, 'f', -1, 64), fallback)
case json.Number:
if parsed, err := typed.Int64(); err == nil {
return parsed
}
if parsed, err := typed.Float64(); err == nil {
return parseIntegrationInt64(strconv.FormatFloat(parsed, 'f', -1, 64), fallback)
}
return fallback
case string:
return parseIntegrationInt64(typed, fallback)
default:
return parseIntegrationInt64(fmt.Sprint(typed), fallback)
}
}
func parseIntegrationInt64(value string, fallback int64) int64 {
parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
if err != nil {
return fallback
}
@@ -3,6 +3,7 @@ package integration
import (
"context"
"encoding/json"
"errors"
"testing"
)
@@ -27,17 +28,19 @@ func (*integrationConfigRepoTestDouble) DeleteIntegrationConfig(context.Context,
type integrationConnectionTesterDouble struct {
calls int
config *IntegrationConfig
err error
}
func (t *integrationConnectionTesterDouble) TestIntegration(_ context.Context, config *IntegrationConfig) error {
t.calls++
t.config = config
return nil
return t.err
}
func TestCommunicationIntegrationDefinitionsAndValidation(t *testing.T) {
for _, target := range []struct{ kind, provider string }{
{IntegrationKindMQ, "emqx"},
{IntegrationKindMQ, "kafka"},
{IntegrationKindMQ, "rabbitmq"},
{IntegrationKindWebSocket, "melody"},
} {
@@ -51,12 +54,49 @@ func TestCommunicationIntegrationDefinitionsAndValidation(t *testing.T) {
}
}
func TestKafkaDefaultConfigSurvivesJSONRoundTrip(t *testing.T) {
raw, err := json.Marshal(DefaultIntegrationConfig(IntegrationKindMQ, "kafka"))
if err != nil {
t.Fatal(err)
}
values, err := decodeIntegrationObject(raw)
if err != nil {
t.Fatal(err)
}
if got := integrationInt64(values, "max_bytes", 0); got != 10485760 {
t.Fatalf("max_bytes = %d, want 10485760; decoded=%#v", got, values["max_bytes"])
}
if err = ValidateIntegrationConfig(IntegrationKindMQ, "kafka", values); err != nil {
t.Fatalf("JSON-decoded kafka defaults are invalid: %v", err)
}
}
func TestIntegrationInt64RejectsFractionalAndOverflowValues(t *testing.T) {
values := map[string]any{"fractional": 1.5, "overflow": json.Number("9223372036854775808")}
if got := integrationInt64(values, "fractional", -1); got != -1 {
t.Fatalf("fractional value = %d, want fallback", got)
}
if got := integrationInt64(values, "overflow", -1); got != -1 {
t.Fatalf("overflow value = %d, want fallback", got)
}
}
func TestCommunicationIntegrationValidationRejectsInvalidValues(t *testing.T) {
rabbit := DefaultIntegrationConfig(IntegrationKindMQ, "rabbitmq")
rabbit["port"] = 0
if err := ValidateIntegrationConfig(IntegrationKindMQ, "rabbitmq", rabbit); err == nil {
t.Fatal("invalid rabbitmq port was accepted")
}
kafka := DefaultIntegrationConfig(IntegrationKindMQ, "kafka")
kafka["brokers"] = []string{"missing-port"}
if err := ValidateIntegrationConfig(IntegrationKindMQ, "kafka", kafka); err == nil {
t.Fatal("invalid kafka broker was accepted")
}
kafka = DefaultIntegrationConfig(IntegrationKindMQ, "kafka")
kafka["max_bytes"] = 0
if err := ValidateIntegrationConfig(IntegrationKindMQ, "kafka", kafka); err == nil {
t.Fatal("invalid kafka byte limits were accepted")
}
websocket := DefaultIntegrationConfig(IntegrationKindWebSocket, "melody")
websocket["path"] = "ws"
if err := ValidateIntegrationConfig(IntegrationKindWebSocket, "melody", websocket); err == nil {
@@ -97,6 +137,31 @@ func TestIntegrationConfigTestDoesNotPersistCandidate(t *testing.T) {
}
}
func TestIntegrationConfigTestReturnsSafeProviderFailure(t *testing.T) {
underlying := errors.New("dial tcp 127.0.0.1:9092: connection refused")
for _, test := range []struct {
provider string
name string
}{
{provider: "kafka", name: "Kafka"},
{provider: "emqx", name: "EMQX"},
{provider: "rabbitmq", name: "RabbitMQ"},
} {
t.Run(test.provider, func(t *testing.T) {
values := DefaultIntegrationConfig(IntegrationKindMQ, test.provider)
raw, _ := json.Marshal(values)
usecase := NewIntegrationConfigUsecase(&integrationConfigRepoTestDouble{}, &integrationConnectionTesterDouble{err: underlying})
err := usecase.Test(context.Background(), &IntegrationConfig{Kind: IntegrationKindMQ, Provider: test.provider, Values: raw})
if err == nil || err.Error() != test.name+" 连接测试失败" {
t.Fatalf("Test() error = %v", err)
}
if !errors.Is(err, underlying) {
t.Fatalf("Test() error does not wrap the provider failure: %v", err)
}
})
}
}
func TestIntegrationConfigRejectsJSONNull(t *testing.T) {
repo := &integrationConfigRepoTestDouble{}
usecase := NewIntegrationConfigUsecase(repo, &integrationConnectionTesterDouble{})
@@ -110,6 +110,26 @@ var integrationDefinitions = map[string][]IntegrationConfigDefinition{
{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", Description: "当前使用 SASL/PLAIN;请与密码同时配置。"},
{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", Description: "仅在生产消息时允许创建不存在的 Topic;订阅不会自动创建。"},
},
},
{
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},
+60 -9
View File
@@ -38,6 +38,7 @@ const (
PaymentModeExternal = "external"
PaymentModeInternal = "internal"
DemoBusinessType = "demo_subscription"
)
var SupportedPaymentProviders = []string{
@@ -72,6 +73,8 @@ type PaymentRequest struct {
BusinessID string `json:"businessId"`
PaymentMode string `json:"paymentMode"`
OriginalAmount int64 `json:"originalAmount"`
UserAgent string `json:"-"`
DeviceID string `json:"-"`
}
// PaymentRefundRequest carries the durable provider identities needed to
@@ -415,6 +418,23 @@ func (uc *PaymentUsecase) Orders(ctx context.Context, page, pageSize int, filter
return uc.orders.ListPaymentOrders(ctx, page, pageSize, filter)
}
func (uc *PaymentUsecase) OrderSummary(ctx context.Context, filter PaymentOrderFilter) (*PaymentOrderSummary, error) {
if uc.orders == nil {
return nil, errors.New("支付订单仓储未接入")
}
return uc.orders.SummarizePaymentOrders(ctx, filter)
}
func (uc *PaymentUsecase) OrderEvents(ctx context.Context, provider, tradeNo string, limit int) ([]*PaymentEvent, error) {
if uc.orders == nil {
return nil, errors.New("支付订单仓储未接入")
}
if !validPaymentText(provider, 64) || !validPaymentText(tradeNo, 128) {
return nil, errors.New("支付订单事件查询参数不完整")
}
return uc.orders.ListPaymentEvents(ctx, provider, tradeNo, limit)
}
func (uc *PaymentUsecase) Create(ctx context.Context, req *PaymentRequest) (*PaymentResult, error) {
if req == nil || !validPaymentText(req.Provider, 64) || !validPaymentText(req.TradeNo, 128) || !validPaymentText(req.BusinessType, 64) || !validPaymentText(req.BusinessID, 128) {
return nil, errors.New("支付参数不完整")
@@ -518,6 +538,10 @@ func supportedPaymentProvider(provider string) bool {
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) {
if !validPaymentText(provider, 64) || !validPaymentText(tradeNo, 128) {
return nil, errors.New("查询支付参数不完整")
@@ -529,6 +553,9 @@ func (uc *PaymentUsecase) Query(ctx context.Context, provider, tradeNo string) (
if err != nil {
return nil, err
}
if IsDemoPaymentOrder(order) {
return paymentResultFromOrder(order), nil
}
if provider == PaymentInternal || order.PaymentMode == PaymentModeInternal {
return paymentResultFromOrder(order), nil
}
@@ -563,7 +590,14 @@ func (uc *PaymentUsecase) Refund(ctx context.Context, provider, tradeNo string,
if uc.orders == nil {
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
@@ -595,6 +629,9 @@ func (uc *PaymentUsecase) Fulfill(ctx context.Context, provider, tradeNo string)
if err != nil {
return nil, err
}
if IsDemoPaymentOrder(order) {
return nil, errors.New("演示订单不执行真实发货")
}
result := paymentResultFromOrder(order)
if result == nil {
return nil, ErrPaymentOrderNotFound
@@ -691,7 +728,9 @@ func (uc *PaymentUsecase) createWithOrder(ctx context.Context, req *PaymentReque
if err = validatePaymentBreakdown(result); err != nil {
return nil, fmt.Errorf("内部支付金额拆分无效: %w", err)
}
if _, err = uc.orders.RecordPaymentCreate(ctx, req.Provider, req.TradeNo, paymentProviderUpdate(result)); err != nil {
update := paymentProviderUpdate(result)
update.EventSource = PaymentInternal
if _, err = uc.orders.RecordPaymentCreate(ctx, req.Provider, req.TradeNo, update); err != nil {
return nil, err
}
return uc.reconcilePaymentResult(ctx, result, nil)
@@ -707,7 +746,9 @@ func (uc *PaymentUsecase) createWithOrder(ctx context.Context, req *PaymentReque
uc.logger.Error(ctx, "payment.order.provider_create_invalid", "provider", req.Provider, "trade_no", req.TradeNo, "error", err)
return nil, err
}
order, err = uc.orders.RecordPaymentCreate(ctx, req.Provider, req.TradeNo, paymentProviderUpdate(result))
update := paymentProviderUpdate(result)
update.EventSource = req.Provider
order, err = uc.orders.RecordPaymentCreate(ctx, req.Provider, req.TradeNo, update)
if err != nil {
uc.logger.Error(ctx, "payment.order.provider_create_record_failed", "provider", req.Provider, "trade_no", req.TradeNo, "error", err)
return nil, err
@@ -773,7 +814,17 @@ func (uc *PaymentUsecase) reconcilePaymentResult(ctx context.Context, result, ca
return fail(err)
}
}
order, err = uc.orders.ApplyPaymentResult(ctx, result.Provider, result.TradeNo, paymentProviderUpdate(result))
update := paymentProviderUpdate(result)
operation := PaymentOperationFromContext(ctx)
update.EventSource = operation.Source
update.EventType = PaymentEventPaymentSynchronized
if callbackSource != nil {
update.EventType = PaymentEventPaymentCallback
if update.EventSource == "" {
update.EventSource = result.Provider
}
}
order, err = uc.orders.ApplyPaymentResult(ctx, result.Provider, result.TradeNo, update)
if err != nil {
uc.logger.Error(ctx, "payment.order.result_record_failed", "provider", result.Provider, "trade_no", result.TradeNo, "error", err)
return fail(err)
@@ -830,17 +881,17 @@ func (uc *PaymentUsecase) fulfillOrder(ctx context.Context, order *PaymentOrder,
return attachOrderResult(result, order), nil
}
func (uc *PaymentUsecase) refundWithOrder(ctx context.Context, provider, tradeNo string, amount int64) (*PaymentResult, error) {
current, err := uc.orders.FindPaymentOrder(ctx, provider, tradeNo)
if err != nil {
return nil, err
func (uc *PaymentUsecase) refundWithOrder(ctx context.Context, current *PaymentOrder, amount int64) (*PaymentResult, error) {
if current == nil {
return nil, ErrPaymentOrderNotFound
}
provider, tradeNo := current.Provider, current.TradeNo
handler := uc.fulfillments.Handler(current.BusinessType)
authorizer, ok := handler.(PaymentRefundAuthorizer)
if !ok {
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
}
order, token, err := uc.orders.BeginPaymentRefund(ctx, provider, tradeNo, amount, 10*time.Minute)
+94
View File
@@ -0,0 +1,94 @@
package payment
import (
"context"
"strings"
"time"
)
const (
PaymentEventOrderCreated = "order_created"
PaymentEventProviderCreated = "provider_created"
PaymentEventPaymentSynchronized = "payment_synchronized"
PaymentEventPaymentCallback = "payment_callback"
PaymentEventFulfillmentStarted = "fulfillment_started"
PaymentEventFulfillmentFinished = "fulfillment_finished"
PaymentEventRefundStarted = "refund_started"
PaymentEventRefundSubmitted = "refund_submitted"
PaymentEventRefundConfirmed = "refund_confirmed"
)
// PaymentOperation describes who or what initiated a payment state change.
// It is carried in context so repo method signatures remain focused on domain
// state while event persistence can still retain transport-level attribution.
type PaymentOperation struct {
Source string
Reason string
OperatorID uint
OperatorName string
ClientIP string
UserAgent string
DeviceID string
}
type paymentOperationContextKey struct{}
func WithPaymentOperation(ctx context.Context, operation PaymentOperation) context.Context {
if ctx == nil {
ctx = context.Background()
}
return context.WithValue(ctx, paymentOperationContextKey{}, operation)
}
func PaymentOperationFromContext(ctx context.Context) PaymentOperation {
if ctx == nil {
return PaymentOperation{}
}
operation, _ := ctx.Value(paymentOperationContextKey{}).(PaymentOperation)
operation.Source = strings.TrimSpace(operation.Source)
operation.Reason = strings.TrimSpace(operation.Reason)
operation.OperatorName = strings.TrimSpace(operation.OperatorName)
operation.ClientIP = strings.TrimSpace(operation.ClientIP)
operation.UserAgent = strings.TrimSpace(operation.UserAgent)
operation.DeviceID = strings.TrimSpace(operation.DeviceID)
return operation
}
type PaymentEvent struct {
ID uint64
Provider string
TradeNo string
Type string
Source string
Status string
ProviderStatus string
Message string
EventID string
PayloadHash string
Amount int64
Currency string
OperatorID uint
OperatorName string
ClientIP string
UserAgent string
DeviceID string
CreatedAt time.Time
}
type PaymentCurrencySummary struct {
Currency string
OrderAmount int64
PaidAmount int64
RefundedAmount int64
SettlementAmount int64
NetAmount int64
}
type PaymentOrderSummary struct {
OrderCount int64
PendingCount int64
PaidCount int64
RefundedCount int64
IssueCount int64
Currencies []PaymentCurrencySummary
}
+18 -6
View File
@@ -109,21 +109,33 @@ type PaymentProviderUpdate struct {
EventID string
PayloadHash string
CreatePayload json.RawMessage
EventType string
EventSource string
EventMessage string
}
type PaymentOrderFilter struct {
Provider string
TradeNo string
BusinessType string
BusinessID string
PaymentStatus string
RefundStatus string
Provider string
TradeNo string
ProviderTradeNo string
Keyword string
BusinessType string
BusinessID string
PaymentStatus string
RefundStatus string
CreatedFrom *time.Time
CreatedTo *time.Time
PaidFrom *time.Time
PaidTo *time.Time
IssueOnly bool
}
type PaymentOrderRepo interface {
CreatePaymentOrder(context.Context, *PaymentOrder) (order *PaymentOrder, created bool, err error)
FindPaymentOrder(context.Context, string, string) (*PaymentOrder, error)
ListPaymentOrders(context.Context, int, int, PaymentOrderFilter) ([]*PaymentOrder, int64, error)
SummarizePaymentOrders(context.Context, PaymentOrderFilter) (*PaymentOrderSummary, error)
ListPaymentEvents(context.Context, string, string, int) ([]*PaymentEvent, error)
RecordPaymentCreate(context.Context, string, string, *PaymentProviderUpdate) (*PaymentOrder, error)
ApplyPaymentResult(context.Context, string, string, *PaymentProviderUpdate) (*PaymentOrder, error)
BeginPaymentFulfillment(context.Context, string, string, time.Duration) (order *PaymentOrder, token string, duplicate bool, err error)
+37
View File
@@ -52,6 +52,16 @@ func (r *paymentOrderRepoStub) ListPaymentOrders(context.Context, int, int, Paym
}
return []*PaymentOrder{r.order}, 1, nil
}
func (r *paymentOrderRepoStub) SummarizePaymentOrders(context.Context, PaymentOrderFilter) (*PaymentOrderSummary, error) {
count := int64(0)
if r.order != nil {
count = 1
}
return &PaymentOrderSummary{OrderCount: count}, nil
}
func (r *paymentOrderRepoStub) ListPaymentEvents(context.Context, string, string, int) ([]*PaymentEvent, error) {
return []*PaymentEvent{}, nil
}
func (r *paymentOrderRepoStub) RecordPaymentCreate(_ context.Context, _, _ string, update *PaymentProviderUpdate) (*PaymentOrder, error) {
r.order.PaymentStatus = PaymentStatusPending
r.order.CreatePayload = append(r.order.CreatePayload[:0], update.CreatePayload...)
@@ -147,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"}
}
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 {
return &PaymentResult{
Provider: PaymentAlipay,
@@ -51,3 +51,93 @@ func TestIntegrationConfigSavePublishesUnmaskedRuntimeValues(t *testing.T) {
t.Fatalf("runtime password = %#v", stored["password"])
}
}
func TestCommunicationDefaultsIncludeKafka(t *testing.T) {
db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared")
if err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&ConfigPO{}); err != nil {
t.Fatal(err)
}
if err = ensureCommunicationIntegrationConfigs(db); err != nil {
t.Fatal(err)
}
var row ConfigPO
if err = db.Where("kind = ? AND provider = ?", integrationbiz.IntegrationKindMQ, "kafka").First(&row).Error; err != nil {
t.Fatal(err)
}
if row.Enabled || row.Config == "" {
t.Fatalf("kafka default row = %#v", row)
}
}
func TestKafkaDefaultMigrationDoesNotRecreateOtherProviders(t *testing.T) {
db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared")
if err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&ConfigPO{}); err != nil {
t.Fatal(err)
}
if err = ensureKafkaIntegrationConfig(db); err != nil {
t.Fatal(err)
}
var kafkaRows, otherRows int64
if err = db.Model(&ConfigPO{}).Where("kind = ? AND provider = ?", integrationbiz.IntegrationKindMQ, "kafka").Count(&kafkaRows).Error; err != nil {
t.Fatal(err)
}
if err = db.Model(&ConfigPO{}).Where("provider <> ?", "kafka").Count(&otherRows).Error; err != nil {
t.Fatal(err)
}
if kafkaRows != 1 || otherRows != 0 {
t.Fatalf("migration rows kafka=%d other=%d", kafkaRows, otherRows)
}
}
func TestCommunicationDefaultUpgradeRepairsRequiredEmptyFields(t *testing.T) {
db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared")
if err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&ConfigPO{}); err != nil {
t.Fatal(err)
}
legacy := `{"enabled":false,"broker":"","client_id":"","username":"","password":"","keep_alive":30,"clean_session":true,"connect_timeout":10}`
if err = db.Create(&ConfigPO{Kind: integrationbiz.IntegrationKindMQ, Provider: "emqx", Config: legacy}).Error; err != nil {
t.Fatal(err)
}
if err = upgradeCommunicationIntegrationDefaults(db); err != nil {
t.Fatal(err)
}
var row ConfigPO
if err = db.Where("kind = ? AND provider = ?", integrationbiz.IntegrationKindMQ, "emqx").First(&row).Error; err != nil {
t.Fatal(err)
}
values := map[string]any{}
if err = json.Unmarshal([]byte(row.Config), &values); err != nil {
t.Fatal(err)
}
if values["broker"] != "tcp://127.0.0.1:1883" || values["client_id"] != "kra" || values["reconnect_interval"] != float64(5) {
t.Fatalf("upgraded emqx values = %#v", values)
}
if values["username"] != "" || values["password"] != "" {
t.Fatalf("optional credentials were overwritten: %#v", values)
}
if _, exists := values["enabled"]; exists {
t.Fatalf("legacy enabled field remained: %#v", values)
}
if err = db.Delete(&row).Error; err != nil {
t.Fatal(err)
}
if err = upgradeCommunicationIntegrationDefaults(db); err != nil {
t.Fatal(err)
}
var count int64
if err = db.Model(&ConfigPO{}).Where("kind = ? AND provider = ?", integrationbiz.IntegrationKindMQ, "emqx").Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("upgrade recreated deleted emqx row: %d", count)
}
}
+82 -13
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"reflect"
"strings"
integrationbiz "kra/internal/biz/integration"
"kra/pkg/database/migration"
@@ -18,35 +19,103 @@ func Migrations() []migration.Step {
}},
{ID: "202608210001_communication_integration_defaults", Migrate: ensureCommunicationIntegrationConfigs},
{ID: "202608200004_payment_defaults", Migrate: ensurePaymentIntegrationConfigs},
{ID: "202608290004_kafka_integration_default", Migrate: ensureKafkaIntegrationConfig},
{ID: "202608290005_communication_integration_default_upgrade", Migrate: upgradeCommunicationIntegrationDefaults},
}
}
func ensureCommunicationIntegrationConfigs(db *gorm.DB) error {
defaults := []struct{ kind, provider string }{
{integrationbiz.IntegrationKindMQ, "emqx"},
{integrationbiz.IntegrationKindMQ, "kafka"},
{integrationbiz.IntegrationKindMQ, "rabbitmq"},
{integrationbiz.IntegrationKindWebSocket, "melody"},
}
for _, item := range defaults {
var row ConfigPO
err := db.Where("kind = ? AND provider = ?", item.kind, item.provider).First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
values, marshalErr := json.Marshal(integrationbiz.DefaultIntegrationConfig(item.kind, item.provider))
if marshalErr != nil {
return marshalErr
}
if err = db.Create(&ConfigPO{Kind: item.kind, Provider: item.provider, Enabled: false, Config: string(values)}).Error; err != nil {
return err
}
continue
}
if err != nil {
if err := ensureIntegrationConfig(db, item.kind, item.provider); err != nil {
return err
}
}
return nil
}
func ensureKafkaIntegrationConfig(db *gorm.DB) error {
return ensureIntegrationConfig(db, integrationbiz.IntegrationKindMQ, "kafka")
}
func ensureIntegrationConfig(db *gorm.DB, kind, provider string) error {
var row ConfigPO
err := db.Where("kind = ? AND provider = ?", kind, provider).First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
values, marshalErr := json.Marshal(integrationbiz.DefaultIntegrationConfig(kind, provider))
if marshalErr != nil {
return marshalErr
}
return db.Create(&ConfigPO{Kind: kind, Provider: provider, Enabled: false, Config: string(values)}).Error
}
return err
}
func upgradeCommunicationIntegrationDefaults(db *gorm.DB) error {
for _, kind := range []string{integrationbiz.IntegrationKindMQ, integrationbiz.IntegrationKindWebSocket} {
for _, definition := range integrationbiz.IntegrationDefinitions(kind) {
var row ConfigPO
err := db.Where("kind = ? AND provider = ?", kind, definition.Provider).First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
continue
}
if err != nil {
return err
}
values := integrationObject(json.RawMessage(row.Config))
changed := false
for key, value := range definition.Defaults {
if _, exists := values[key]; !exists {
values[key] = value
changed = true
}
}
for _, field := range definition.Fields {
if !field.Required || !emptyIntegrationConfigValue(values[field.Key]) || emptyIntegrationConfigValue(definition.Defaults[field.Key]) {
continue
}
values[field.Key] = definition.Defaults[field.Key]
changed = true
}
if _, exists := values["enabled"]; exists {
delete(values, "enabled")
changed = true
}
if !changed {
continue
}
encoded, marshalErr := json.Marshal(values)
if marshalErr != nil {
return marshalErr
}
if err := db.Model(&row).Update("config", string(encoded)).Error; err != nil {
return err
}
}
}
return nil
}
func emptyIntegrationConfigValue(value any) bool {
switch typed := value.(type) {
case nil:
return true
case string:
return strings.TrimSpace(typed) == ""
case []any:
return len(typed) == 0
case []string:
return len(typed) == 0
default:
return false
}
}
func ensurePaymentIntegrationConfigs(db *gorm.DB) error {
for _, definition := range integrationbiz.IntegrationDefinitions(integrationbiz.IntegrationKindPayment) {
provider := definition.Provider
+5 -5
View File
@@ -25,7 +25,7 @@ func TestMigrateAllRunsModuleSchemasWithoutBootstrapSeed(t *testing.T) {
if err = migrateAll(db, testCatalog()); err != nil {
t.Fatalf("second migration run: %v", err)
}
for _, table := range []string{"sys_integration_configs", "sys_users", "sys_base_menus", "pay_orders"} {
for _, table := range []string{"sys_integration_configs", "sys_users", "sys_base_menus", "pay_orders", "pay_events"} {
if !db.Migrator().HasTable(table) {
t.Fatalf("migration did not create %s", table)
}
@@ -34,15 +34,15 @@ func TestMigrateAllRunsModuleSchemasWithoutBootstrapSeed(t *testing.T) {
if err = db.Table(migration.TableName).Count(&versions).Error; err != nil {
t.Fatal(err)
}
if versions != 8 {
t.Fatalf("migration versions = %d, want 8", versions)
if versions != 13 {
t.Fatalf("migration versions = %d, want 13", versions)
}
var communicationRows []dataintegration.ConfigPO
if err = db.Where("kind IN ?", []string{"mq", "websocket"}).Order("kind, provider").Find(&communicationRows).Error; err != nil {
t.Fatal(err)
}
if len(communicationRows) != 3 {
t.Fatalf("communication integration rows = %d, want 3", len(communicationRows))
if len(communicationRows) != 4 {
t.Fatalf("communication integration rows = %d, want 4", len(communicationRows))
}
var paymentRows int64
if err = db.Model(&dataintegration.ConfigPO{}).Where("kind = ?", "payment").Count(&paymentRows).Error; err != nil {
+148
View File
@@ -0,0 +1,148 @@
package payment
import (
"fmt"
bizpayment "kra/internal/biz/payment"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
const DemoTradePrefix = "DEMO-PAY-"
type DemoSeedResult struct {
Orders int
Events int
}
// SeedDemoOrders replaces only DEMO-PAY-* records with a representative set
// of payment operations for administration-page review.
func SeedDemoOrders(db *gorm.DB, now time.Time) (*DemoSeedResult, error) {
if db == nil {
return nil, fmt.Errorf("demo payment database is nil")
}
if !db.Migrator().HasTable(&paymentOrderPO{}) || !db.Migrator().HasTable(&paymentEventPO{}) {
return nil, fmt.Errorf("payment tables are not migrated")
}
if now.IsZero() {
now = time.Now().UTC().Truncate(time.Second)
} else {
now = now.UTC().Truncate(time.Second)
}
type demoOrder struct {
order paymentOrderPO
events []paymentEventPO
}
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)
return paymentOrderPO{
TradeNo: tradeNo, Provider: provider, BusinessType: bizpayment.DemoBusinessType, BusinessID: fmt.Sprintf("DEMO-LICENSE-%02d", index),
Subject: subject, PaymentMode: bizpayment.PaymentModeExternal, OriginalAmount: amount, Amount: amount,
Currency: currency, PaymentStatus: status, FulfillmentStatus: fulfillment, RefundStatus: refund,
ConfirmationID: uuid.NewString(), RequestFingerprint: fmt.Sprintf("demo-fingerprint-%02d", index),
CreatePayload: "{}", Extra: "{}", Version: 1, CreatedAt: created, UpdatedAt: created,
}
}
makeEvent := func(order paymentOrderPO, eventType, source, status, providerStatus, message string, amount int64, at time.Time) paymentEventPO {
event := paymentEventPO{
Provider: order.Provider, TradeNo: order.TradeNo, Type: eventType, Source: source, Status: status,
ProviderStatus: providerStatus, Message: message, Amount: amount, Currency: order.Currency, CreatedAt: at,
}
if source == "client" {
event.ClientIP, event.UserAgent, event.DeviceID = "111.29.161.79", "Kra-Demo/1.2 Android/16", fmt.Sprintf("DEMO-DEVICE-%s", order.TradeNo)
}
if source == "admin" {
event.OperatorID, event.OperatorName = 1, "demo-admin"
}
return event
}
providerDate := now.Format("20060102")
created1, paid1, fulfilled1 := now.Add(-35*time.Minute), now.Add(-34*time.Minute), now.Add(-33*time.Minute)
paid := makeOrder(1, bizpayment.PaymentAlipay, "专业版 7 天授权", bizpayment.PaymentStatusPaid, bizpayment.FulfillmentStatusSucceeded, bizpayment.RefundStatusNone, 940, "CNY", created1)
providerTrade1 := fmt.Sprintf("DEMO-ALIPAY-%s%04d", providerDate, 1)
paid.ProviderTradeNo = &providerTrade1
paid.ProviderStatus, paid.PaidAmount, paid.PayerPaidAmount, paid.CashPaidAmount, paid.SettlementAmount = "TRADE_SUCCESS", 940, 940, 940, 934
paid.AmountBreakdownKnown, paid.PaidAt, paid.FulfilledAt, paid.UpdatedAt = true, &paid1, &fulfilled1, fulfilled1
pendingCreated := now.Add(-28 * time.Minute)
pending := makeOrder(2, bizpayment.PaymentWechatV3, "专业版 30 天授权", bizpayment.PaymentStatusPending, bizpayment.FulfillmentStatusPending, bizpayment.RefundStatusNone, 2990, "CNY", pendingCreated)
pending.ProviderStatus = "NOTPAY"
pending.UpdatedAt = now.Add(-27 * time.Minute)
failedCreated := now.Add(-24 * time.Minute)
failed := makeOrder(3, bizpayment.PaymentPayPal, "海外数字商品", bizpayment.PaymentStatusFailed, bizpayment.FulfillmentStatusPending, bizpayment.RefundStatusNone, 1299, "USD", failedCreated)
failed.ProviderStatus, failed.LastError = "DECLINED", "付款方式被渠道拒绝"
failed.UpdatedAt = now.Add(-23 * time.Minute)
fulfillCreated, fulfillPaid := now.Add(-20*time.Minute), now.Add(-19*time.Minute)
fulfillFailed := makeOrder(4, bizpayment.PaymentAlipayV3, "企业版 90 天授权", bizpayment.PaymentStatusPaid, bizpayment.FulfillmentStatusFailed, bizpayment.RefundStatusNone, 8990, "CNY", fulfillCreated)
providerTrade4 := fmt.Sprintf("DEMO-ALIPAY-%s%04d", providerDate, 4)
fulfillFailed.ProviderTradeNo, fulfillFailed.ProviderStatus = &providerTrade4, "TRADE_SUCCESS"
fulfillFailed.PaidAmount, fulfillFailed.SettlementAmount, fulfillFailed.PaidAt = 8990, 8936, &fulfillPaid
fulfillFailed.LastError, fulfillFailed.UpdatedAt = "业务授权服务暂时不可用,可人工重试发货", now.Add(-18*time.Minute)
partialCreated, partialPaid, partialRefunded := now.Add(-16*time.Minute), now.Add(-15*time.Minute), now.Add(-12*time.Minute)
partial := makeOrder(5, bizpayment.PaymentWechatV3, "团队版 30 天授权", bizpayment.PaymentStatusPartiallyRefunded, bizpayment.FulfillmentStatusSucceeded, bizpayment.RefundStatusPartial, 5000, "CNY", partialCreated)
providerTrade5 := fmt.Sprintf("DEMO-WECHAT-%s%04d", providerDate, 5)
partial.ProviderTradeNo, partial.ProviderStatus = &providerTrade5, "SUCCESS"
partial.PaidAmount, partial.SettlementAmount, partial.RefundedAmount = 5000, 4970, 1200
partial.PaidAt, partial.FulfilledAt, partial.RefundedAt, partial.UpdatedAt = &partialPaid, &partialPaid, &partialRefunded, partialRefunded
refundCreated, refundPaid := now.Add(-11*time.Minute), now.Add(-10*time.Minute)
refundPending := makeOrder(6, bizpayment.PaymentAlipay, "增值服务包", bizpayment.PaymentStatusPaid, bizpayment.FulfillmentStatusSucceeded, bizpayment.RefundStatusPending, 1880, "CNY", refundCreated)
providerTrade6 := fmt.Sprintf("DEMO-ALIPAY-%s%04d", providerDate, 6)
refundPending.ProviderTradeNo, refundPending.ProviderStatus = &providerTrade6, "TRADE_SUCCESS"
refundPending.PaidAmount, refundPending.SettlementAmount, refundPending.RefundRequestedAmount = 1880, 1869, 600
refundPending.RefundNo, refundPending.PaidAt, refundPending.FulfilledAt, refundPending.UpdatedAt = "DEMO-REFUND-06", &refundPaid, &refundPaid, now.Add(-8*time.Minute)
refundedCreated, refundedPaid, refundedAt := now.Add(-9*time.Minute), now.Add(-8*time.Minute), now.Add(-5*time.Minute)
refunded := makeOrder(7, bizpayment.PaymentWechatV3, "体验卡 7 天", bizpayment.PaymentStatusRefunded, bizpayment.FulfillmentStatusSucceeded, bizpayment.RefundStatusSucceeded, 500, "CNY", refundedCreated)
providerTrade7 := fmt.Sprintf("DEMO-WECHAT-%s%04d", providerDate, 7)
refunded.ProviderTradeNo, refunded.ProviderStatus = &providerTrade7, "REFUND_SUCCESS"
refunded.PaidAmount, refunded.SettlementAmount, refunded.RefundedAmount = 500, 497, 500
refunded.PaidAt, refunded.FulfilledAt, refunded.RefundedAt, refunded.UpdatedAt = &refundedPaid, &refundedPaid, &refundedAt, refundedAt
internalCreated, internalPaid := now.Add(-4*time.Minute), now.Add(-3*time.Minute)
internal := makeOrder(8, bizpayment.PaymentInternal, "积分兑换月卡", bizpayment.PaymentStatusPaid, bizpayment.FulfillmentStatusSucceeded, bizpayment.RefundStatusNone, 1200, "POINT", internalCreated)
internal.PaymentMode, internal.ProviderStatus = bizpayment.PaymentModeInternal, "AUTHORIZED"
internal.PaidAmount, internal.PointPaidAmount, internal.SettlementAmount, internal.AmountBreakdownKnown = 1200, 1200, 1200, true
internal.PaidAt, internal.FulfilledAt, internal.UpdatedAt = &internalPaid, &internalPaid, internalPaid
demos := []demoOrder{
{paid, []paymentEventPO{makeEvent(paid, bizpayment.PaymentEventOrderCreated, "client", bizpayment.PaymentStatusInitialized, "", "客户端创建订单", paid.Amount, created1), makeEvent(paid, bizpayment.PaymentEventPaymentCallback, bizpayment.PaymentAlipay, bizpayment.PaymentStatusPaid, paid.ProviderStatus, "支付宝异步通知验签并主动查单成功", paid.PaidAmount, paid1), makeEvent(paid, bizpayment.PaymentEventFulfillmentFinished, "system", bizpayment.FulfillmentStatusSucceeded, "", "授权自动激活成功,有效期 7 天", 0, fulfilled1)}},
{pending, []paymentEventPO{makeEvent(pending, bizpayment.PaymentEventOrderCreated, "client", bizpayment.PaymentStatusInitialized, "", "客户端创建订单", pending.Amount, pendingCreated), makeEvent(pending, bizpayment.PaymentEventProviderCreated, bizpayment.PaymentWechatV3, bizpayment.PaymentStatusPending, pending.ProviderStatus, "预支付订单已创建,等待买家付款", pending.Amount, pending.UpdatedAt)}},
{failed, []paymentEventPO{makeEvent(failed, bizpayment.PaymentEventOrderCreated, "client", bizpayment.PaymentStatusInitialized, "", "客户端创建订单", failed.Amount, failedCreated), makeEvent(failed, bizpayment.PaymentEventPaymentSynchronized, "admin", bizpayment.PaymentStatusFailed, failed.ProviderStatus, failed.LastError, 0, failed.UpdatedAt)}},
{fulfillFailed, []paymentEventPO{makeEvent(fulfillFailed, bizpayment.PaymentEventPaymentCallback, bizpayment.PaymentAlipayV3, bizpayment.PaymentStatusPaid, fulfillFailed.ProviderStatus, "支付成功", fulfillFailed.PaidAmount, fulfillPaid), makeEvent(fulfillFailed, bizpayment.PaymentEventFulfillmentFinished, "system", bizpayment.FulfillmentStatusFailed, "", fulfillFailed.LastError, 0, fulfillFailed.UpdatedAt)}},
{partial, []paymentEventPO{makeEvent(partial, bizpayment.PaymentEventPaymentCallback, bizpayment.PaymentWechatV3, bizpayment.PaymentStatusPaid, partial.ProviderStatus, "支付成功并完成发货", partial.PaidAmount, partialPaid), makeEvent(partial, bizpayment.PaymentEventRefundConfirmed, bizpayment.PaymentWechatV3, bizpayment.RefundStatusPartial, "SUCCESS", "售后部分退款 12.00 CNY", partial.RefundedAmount, partialRefunded)}},
{refundPending, []paymentEventPO{makeEvent(refundPending, bizpayment.PaymentEventPaymentCallback, bizpayment.PaymentAlipay, bizpayment.PaymentStatusPaid, refundPending.ProviderStatus, "支付成功并完成发货", refundPending.PaidAmount, refundPaid), makeEvent(refundPending, bizpayment.PaymentEventRefundSubmitted, "admin", bizpayment.RefundStatusPending, "", "客户重复购买,退款已提交渠道", refundPending.RefundRequestedAmount, refundPending.UpdatedAt)}},
{refunded, []paymentEventPO{makeEvent(refunded, bizpayment.PaymentEventPaymentCallback, bizpayment.PaymentWechatV3, bizpayment.PaymentStatusPaid, "SUCCESS", "支付成功并完成发货", refunded.PaidAmount, refundedPaid), makeEvent(refunded, bizpayment.PaymentEventRefundConfirmed, bizpayment.PaymentWechatV3, bizpayment.RefundStatusSucceeded, refunded.ProviderStatus, "原路全额退款成功", refunded.RefundedAmount, refundedAt)}},
{internal, []paymentEventPO{makeEvent(internal, bizpayment.PaymentEventOrderCreated, "client", bizpayment.PaymentStatusInitialized, "", "创建积分支付订单", internal.Amount, internalCreated), makeEvent(internal, bizpayment.PaymentEventPaymentSynchronized, bizpayment.PaymentInternal, bizpayment.PaymentStatusPaid, internal.ProviderStatus, "积分扣减与授权发放成功", internal.PaidAmount, internalPaid)}},
}
result := &DemoSeedResult{}
err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("trade_no LIKE ?", DemoTradePrefix+"%").Delete(&paymentEventPO{}).Error; err != nil {
return err
}
if err := tx.Where("trade_no LIKE ?", DemoTradePrefix+"%").Delete(&paymentOrderPO{}).Error; err != nil {
return err
}
for i := range demos {
if err := tx.Create(&demos[i].order).Error; err != nil {
return err
}
result.Orders++
if len(demos[i].events) > 0 {
if err := tx.Create(&demos[i].events).Error; err != nil {
return err
}
result.Events += len(demos[i].events)
}
}
return nil
})
return result, err
}
+59
View File
@@ -0,0 +1,59 @@
package payment
import (
"strings"
"testing"
"time"
)
func TestSeedDemoOrdersIsRepeatableAndCoversOperationalStates(t *testing.T) {
repo := newPaymentOrderRepoForTest(t)
db := repo.data.DB()
now := time.Date(2026, 8, 29, 2, 0, 0, 0, time.UTC)
for attempt := 0; attempt < 2; attempt++ {
result, err := SeedDemoOrders(db, now)
if err != nil {
t.Fatal(err)
}
if result.Orders != 8 || result.Events < 16 {
t.Fatalf("seed result = %#v", result)
}
}
var orders int64
if err := db.Model(&paymentOrderPO{}).Where("trade_no LIKE ?", DemoTradePrefix+"%").Count(&orders).Error; err != nil {
t.Fatal(err)
}
if orders != 8 {
t.Fatalf("demo orders = %d, want 8", orders)
}
for _, status := range []string{"pending", "failed", "paid", "partially_refunded", "refunded"} {
var count int64
if err := db.Model(&paymentOrderPO{}).Where("trade_no LIKE ? AND payment_status = ?", DemoTradePrefix+"%", status).Count(&count).Error; err != nil {
t.Fatal(err)
}
if count == 0 {
t.Fatalf("demo status %q is missing", status)
}
}
var partialRefund paymentEventPO
if err := db.Where("trade_no = ? AND type = ?", DemoTradePrefix+"05", "refund_confirmed").First(&partialRefund).Error; err != nil {
t.Fatal(err)
}
if partialRefund.Amount != 1200 {
t.Fatalf("partial refund event amount = %d, want 1200", partialRefund.Amount)
}
var created paymentEventPO
if err := db.Where("trade_no = ? AND type = ?", DemoTradePrefix+"01", "order_created").First(&created).Error; err != nil {
t.Fatal(err)
}
if created.ProviderStatus != "" || created.ClientIP == "" || created.DeviceID == "" {
t.Fatalf("created event metadata = %#v", created)
}
var paid paymentOrderPO
if err := db.Where("trade_no = ?", DemoTradePrefix+"01").First(&paid).Error; err != nil {
t.Fatal(err)
}
if paid.ProviderTradeNo == nil || !strings.Contains(*paid.ProviderTradeNo, "20260829") {
t.Fatalf("provider trade number = %#v", paid.ProviderTradeNo)
}
}
+3
View File
@@ -11,5 +11,8 @@ func Migrations() []migration.Step {
{ID: "202608200003_payment_schema", Migrate: func(db *gorm.DB) error {
return migration.CreateMissingTables(db, &paymentOrderPO{})
}},
{ID: "202608290001_payment_event_schema", Migrate: func(db *gorm.DB) error {
return migration.CreateMissingTables(db, &paymentEventPO{})
}},
}
}
+122
View File
@@ -0,0 +1,122 @@
package payment
import (
"context"
"errors"
bizpayment "kra/internal/biz/payment"
"time"
"gorm.io/gorm"
)
type paymentEventPO struct {
ID uint64 `gorm:"primaryKey"`
Provider string `gorm:"size:64;not null;index:idx_pay_events_order,priority:1"`
TradeNo string `gorm:"size:128;not null;index:idx_pay_events_order,priority:2"`
Type string `gorm:"size:64;not null;index"`
Source string `gorm:"size:64;not null;index"`
Status string `gorm:"size:64"`
ProviderStatus string `gorm:"size:64"`
Message string `gorm:"size:512"`
EventID string `gorm:"size:128;index"`
PayloadHash string `gorm:"size:64"`
Amount int64 `gorm:"not null;default:0"`
Currency string `gorm:"size:16"`
OperatorID uint `gorm:"index"`
OperatorName string `gorm:"size:128"`
ClientIP string `gorm:"size:64"`
UserAgent string `gorm:"size:512"`
DeviceID string `gorm:"size:128"`
CreatedAt time.Time `gorm:"index"`
}
func (paymentEventPO) TableName() string { return "pay_events" }
func toBizPaymentEvent(po *paymentEventPO) *bizpayment.PaymentEvent {
if po == nil {
return nil
}
return &bizpayment.PaymentEvent{
ID: po.ID, Provider: po.Provider, TradeNo: po.TradeNo, Type: po.Type, Source: po.Source,
Status: po.Status, ProviderStatus: po.ProviderStatus, Message: po.Message, EventID: po.EventID,
PayloadHash: po.PayloadHash, Amount: po.Amount, Currency: po.Currency, OperatorID: po.OperatorID,
OperatorName: po.OperatorName, ClientIP: po.ClientIP, UserAgent: po.UserAgent, DeviceID: po.DeviceID,
CreatedAt: po.CreatedAt,
}
}
func appendPaymentEvent(ctx context.Context, tx *gorm.DB, order *paymentOrderPO, event *bizpayment.PaymentEvent) error {
if tx == nil || order == nil || event == nil {
return nil
}
eventType := trimTo(event.Type, 64)
if eventType == "" {
return errors.New("支付事件类型为空")
}
operation := bizpayment.PaymentOperationFromContext(ctx)
source := trimTo(event.Source, 64)
if source == "" {
source = trimTo(operation.Source, 64)
}
if source == "" {
source = "system"
}
message := trimTo(event.Message, 512)
if message == "" {
message = trimTo(operation.Reason, 512)
}
operatorID := event.OperatorID
if operatorID == 0 {
operatorID = operation.OperatorID
}
operatorName := trimTo(event.OperatorName, 128)
if operatorName == "" {
operatorName = trimTo(operation.OperatorName, 128)
}
clientIP := trimTo(event.ClientIP, 64)
if clientIP == "" {
clientIP = trimTo(operation.ClientIP, 64)
}
userAgent := trimTo(event.UserAgent, 512)
if userAgent == "" {
userAgent = trimTo(operation.UserAgent, 512)
}
deviceID := trimTo(event.DeviceID, 128)
if deviceID == "" {
deviceID = trimTo(operation.DeviceID, 128)
}
return tx.Create(&paymentEventPO{
Provider: order.Provider, TradeNo: order.TradeNo, Type: eventType, Source: source,
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,
Currency: trimTo(event.Currency, 16), OperatorID: operatorID, OperatorName: operatorName,
ClientIP: clientIP, UserAgent: userAgent, DeviceID: deviceID,
}).Error
}
func savePaymentOrderWithEvent(ctx context.Context, tx *gorm.DB, order *paymentOrderPO, event *bizpayment.PaymentEvent) error {
if err := tx.Save(order).Error; err != nil {
return err
}
return appendPaymentEvent(ctx, tx, order, event)
}
func (r *paymentOrderRepo) ListPaymentEvents(ctx context.Context, provider, tradeNo string, limit int) ([]*bizpayment.PaymentEvent, error) {
if limit <= 0 {
limit = 100
}
if limit > 500 {
limit = 500
}
var rows []paymentEventPO
if err := r.data.DB().WithContext(ctx).
Where("provider = ? AND trade_no = ?", provider, tradeNo).
Order("id DESC").Limit(limit).Find(&rows).Error; err != nil {
return nil, err
}
items := make([]*bizpayment.PaymentEvent, 0, len(rows))
for i := range rows {
items = append(items, toBizPaymentEvent(&rows[i]))
}
return items, nil
}
+148 -30
View File
@@ -146,7 +146,16 @@ func (r *paymentOrderRepo) CreatePaymentOrder(ctx context.Context, order *bizpay
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, false, err
}
if err = db.Create(po).Error; err == nil {
err = db.Transaction(func(tx *gorm.DB) error {
if createErr := tx.Create(po).Error; createErr != nil {
return createErr
}
return appendPaymentEvent(ctx, tx, po, &bizpayment.PaymentEvent{
Type: bizpayment.PaymentEventOrderCreated, Source: "client", Status: po.PaymentStatus,
Amount: po.Amount, Currency: po.Currency,
})
})
if err == nil {
return toBizPaymentOrder(po), true, nil
}
if lookupErr := db.Where("provider = ? AND trade_no = ?", po.Provider, po.TradeNo).First(&current).Error; lookupErr == nil {
@@ -167,25 +176,7 @@ func (r *paymentOrderRepo) FindPaymentOrder(ctx context.Context, provider, trade
}
func (r *paymentOrderRepo) ListPaymentOrders(ctx context.Context, page, pageSize int, filter bizpayment.PaymentOrderFilter) ([]*bizpayment.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)
}
db := r.filteredPaymentOrders(ctx, filter)
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
@@ -201,6 +192,105 @@ func (r *paymentOrderRepo) ListPaymentOrders(ctx context.Context, page, pageSize
return items, total, nil
}
func (r *paymentOrderRepo) filteredPaymentOrders(ctx context.Context, filter bizpayment.PaymentOrderFilter) *gorm.DB {
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.ProviderTradeNo); value != "" {
db = db.Where("provider_trade_no LIKE ?", "%"+value+"%")
}
if value := strings.TrimSpace(filter.Keyword); value != "" {
like := "%" + value + "%"
db = db.Where("trade_no LIKE ? OR provider_trade_no LIKE ? OR business_id LIKE ? OR subject LIKE ?", like, like, like, like)
}
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)
}
if filter.CreatedFrom != nil {
db = db.Where("created_at >= ?", filter.CreatedFrom.UTC())
}
if filter.CreatedTo != nil {
db = db.Where("created_at < ?", filter.CreatedTo.UTC())
}
if filter.PaidFrom != nil {
db = db.Where("paid_at >= ?", filter.PaidFrom.UTC())
}
if filter.PaidTo != nil {
db = db.Where("paid_at < ?", filter.PaidTo.UTC())
}
if filter.IssueOnly {
db = db.Where("payment_status = ? OR fulfillment_status = ? OR refund_status = ? OR last_error <> ''",
bizpayment.PaymentStatusFailed, bizpayment.FulfillmentStatusFailed, bizpayment.RefundStatusFailed)
}
return db
}
func (r *paymentOrderRepo) SummarizePaymentOrders(ctx context.Context, filter bizpayment.PaymentOrderFilter) (*bizpayment.PaymentOrderSummary, error) {
type countRow struct {
OrderCount int64
PendingCount int64
PaidCount int64
RefundedCount int64
IssueCount int64
}
var counts countRow
if err := r.filteredPaymentOrders(ctx, filter).Select(`
COUNT(*) AS order_count,
COALESCE(SUM(CASE WHEN payment_status IN ? THEN 1 ELSE 0 END), 0) AS pending_count,
COALESCE(SUM(CASE WHEN payment_status IN ? THEN 1 ELSE 0 END), 0) AS paid_count,
COALESCE(SUM(CASE WHEN refunded_amount > 0 THEN 1 ELSE 0 END), 0) AS refunded_count,
COALESCE(SUM(CASE WHEN payment_status = ? OR fulfillment_status = ? OR refund_status = ? OR last_error <> '' THEN 1 ELSE 0 END), 0) AS issue_count`,
[]string{bizpayment.PaymentStatusInitialized, bizpayment.PaymentStatusPending},
[]string{bizpayment.PaymentStatusPaid, bizpayment.PaymentStatusPartiallyRefunded, bizpayment.PaymentStatusRefunded},
bizpayment.PaymentStatusFailed, bizpayment.FulfillmentStatusFailed, bizpayment.RefundStatusFailed,
).Scan(&counts).Error; err != nil {
return nil, err
}
type currencyRow struct {
Currency string
OrderAmount int64
PaidAmount int64
RefundedAmount int64
SettlementAmount int64
}
var rows []currencyRow
if err := r.filteredPaymentOrders(ctx, filter).
Select(`currency,
COALESCE(SUM(amount), 0) AS order_amount,
COALESCE(SUM(paid_amount), 0) AS paid_amount,
COALESCE(SUM(refunded_amount), 0) AS refunded_amount,
COALESCE(SUM(CASE WHEN settlement_amount > 0 THEN settlement_amount ELSE paid_amount END), 0) AS settlement_amount`).
Group("currency").Order("currency ASC").Scan(&rows).Error; err != nil {
return nil, err
}
summary := &bizpayment.PaymentOrderSummary{
OrderCount: counts.OrderCount, PendingCount: counts.PendingCount, PaidCount: counts.PaidCount,
RefundedCount: counts.RefundedCount, IssueCount: counts.IssueCount,
Currencies: make([]bizpayment.PaymentCurrencySummary, 0, len(rows)),
}
for _, row := range rows {
summary.Currencies = append(summary.Currencies, bizpayment.PaymentCurrencySummary{
Currency: row.Currency, OrderAmount: row.OrderAmount, PaidAmount: row.PaidAmount,
RefundedAmount: row.RefundedAmount, SettlementAmount: row.SettlementAmount,
NetAmount: row.SettlementAmount - row.RefundedAmount,
})
}
return summary, nil
}
func (r *paymentOrderRepo) RecordPaymentCreate(ctx context.Context, provider, tradeNo string, update *bizpayment.PaymentProviderUpdate) (*bizpayment.PaymentOrder, error) {
return r.withLockedOrder(ctx, provider, tradeNo, func(tx *gorm.DB, po *paymentOrderPO) error {
if update == nil {
@@ -224,7 +314,11 @@ func (r *paymentOrderRepo) RecordPaymentCreate(ctx context.Context, provider, tr
po.PaymentStatus = status
}
po.Version++
return tx.Save(po).Error
return savePaymentOrderWithEvent(ctx, tx, po, &bizpayment.PaymentEvent{
Type: bizpayment.PaymentEventProviderCreated, Source: update.EventSource, Status: po.PaymentStatus,
ProviderStatus: po.ProviderStatus, Message: update.EventMessage, EventID: update.EventID,
PayloadHash: update.PayloadHash, Amount: po.Amount, Currency: po.Currency,
})
})
}
@@ -279,7 +373,15 @@ func (r *paymentOrderRepo) ApplyPaymentResult(ctx context.Context, provider, tra
}
}
po.Version++
return tx.Save(po).Error
eventType := update.EventType
if eventType == "" {
eventType = bizpayment.PaymentEventPaymentSynchronized
}
return savePaymentOrderWithEvent(ctx, tx, po, &bizpayment.PaymentEvent{
Type: eventType, Source: update.EventSource, Status: po.PaymentStatus,
ProviderStatus: po.ProviderStatus, Message: update.EventMessage, EventID: update.EventID,
PayloadHash: update.PayloadHash, Amount: po.PaidAmount, Currency: po.Currency,
})
})
}
@@ -308,7 +410,9 @@ func (r *paymentOrderRepo) BeginPaymentFulfillment(ctx context.Context, provider
po.FulfillmentLeaseUntil = &until
po.LastError = ""
po.Version++
return tx.Save(po).Error
return savePaymentOrderWithEvent(ctx, tx, po, &bizpayment.PaymentEvent{
Type: bizpayment.PaymentEventFulfillmentStarted, Status: po.FulfillmentStatus,
})
})
return order, token, duplicate, err
}
@@ -329,7 +433,9 @@ func (r *paymentOrderRepo) CompletePaymentFulfillment(ctx context.Context, provi
po.FulfillmentStatus = bizpayment.FulfillmentStatusFailed
}
po.Version++
return tx.Save(po).Error
return savePaymentOrderWithEvent(ctx, tx, po, &bizpayment.PaymentEvent{
Type: bizpayment.PaymentEventFulfillmentFinished, Status: po.FulfillmentStatus, Message: message,
})
})
}
@@ -378,7 +484,9 @@ func (r *paymentOrderRepo) BeginPaymentRefund(ctx context.Context, provider, tra
po.RefundLeaseUntil = &until
po.LastError = ""
po.Version++
return tx.Save(po).Error
return savePaymentOrderWithEvent(ctx, tx, po, &bizpayment.PaymentEvent{
Type: bizpayment.PaymentEventRefundStarted, Status: po.RefundStatus, Amount: amount, Currency: po.Currency,
})
})
return order, token, err
}
@@ -399,7 +507,10 @@ func (r *paymentOrderRepo) CompletePaymentRefundRequest(ctx context.Context, pro
po.RefundNo = ""
}
po.Version++
return tx.Save(po).Error
return savePaymentOrderWithEvent(ctx, tx, po, &bizpayment.PaymentEvent{
Type: bizpayment.PaymentEventRefundSubmitted, Status: po.RefundStatus, Message: message,
Amount: po.RefundRequestedAmount, Currency: po.Currency,
})
})
}
@@ -414,7 +525,10 @@ func (r *paymentOrderRepo) ConfirmPaymentRefund(ctx context.Context, provider, t
po.RefundStatus = bizpayment.RefundStatusFailed
po.RefundNo = ""
po.Version++
return tx.Save(po).Error
return savePaymentOrderWithEvent(ctx, tx, po, &bizpayment.PaymentEvent{
Type: bizpayment.PaymentEventRefundConfirmed, Status: po.RefundStatus, Message: message,
Amount: amount, Currency: po.Currency,
})
}
po.RefundedAmount += amount
if po.RefundedAmount >= po.Amount {
@@ -428,7 +542,10 @@ func (r *paymentOrderRepo) ConfirmPaymentRefund(ctx context.Context, provider, t
now := time.Now().UTC()
po.RefundedAt = &now
po.Version++
return tx.Save(po).Error
return savePaymentOrderWithEvent(ctx, tx, po, &bizpayment.PaymentEvent{
Type: bizpayment.PaymentEventRefundConfirmed, Status: po.RefundStatus, Message: message,
Amount: amount, Currency: po.Currency,
})
})
}
@@ -519,8 +636,9 @@ func defaultVersion(value uint64) uint64 {
func trimTo(value string, max int) string {
value = strings.TrimSpace(value)
if len(value) > max {
return value[:max]
runes := []rune(value)
if len(runes) > max {
return string(runes[:max])
}
return value
}
+62 -2
View File
@@ -5,15 +5,17 @@ import (
bizpayment "kra/internal/biz/payment"
"testing"
"time"
"github.com/google/uuid"
)
func newPaymentOrderRepoForTest(t *testing.T) *paymentOrderRepo {
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 {
t.Fatal(err)
}
if err = db.AutoMigrate(&paymentOrderPO{}); err != nil {
if err = db.AutoMigrate(&paymentOrderPO{}, &paymentEventPO{}); err != nil {
t.Fatal(err)
}
return &paymentOrderRepo{data: &Data{gormDB: newReloadableDB(db, nil)}}
@@ -114,3 +116,61 @@ func TestPaymentOrderRepositoryListsWithFilters(t *testing.T) {
t.Fatalf("filtered orders = %#v total=%d", items, total)
}
}
func TestPaymentOrderRepositorySummarizesAndListsEvents(t *testing.T) {
repo := newPaymentOrderRepoForTest(t)
ctx := bizpayment.WithPaymentOperation(context.Background(), bizpayment.PaymentOperation{
Source: "admin", Reason: "客户重复购买", OperatorID: 7, OperatorName: "operator",
ClientIP: "127.0.0.1", UserAgent: "payment-test/1.0", DeviceID: "device-1",
})
order := testPaymentOrder()
if _, _, err := repo.CreatePaymentOrder(ctx, order); err != nil {
t.Fatal(err)
}
if _, err := repo.ApplyPaymentResult(ctx, bizpayment.PaymentAlipay, order.TradeNo, &bizpayment.PaymentProviderUpdate{
Status: "success", ProviderStatus: "TRADE_SUCCESS", ProviderTradeNo: "provider-summary-1",
Amount: 100, Currency: "CNY", EventType: bizpayment.PaymentEventPaymentSynchronized,
}); err != nil {
t.Fatal(err)
}
current, token, err := repo.BeginPaymentRefund(ctx, bizpayment.PaymentAlipay, order.TradeNo, 40, time.Minute)
if err != nil {
t.Fatal(err)
}
if current, err = repo.CompletePaymentRefundRequest(ctx, bizpayment.PaymentAlipay, order.TradeNo, token, true, ""); err != nil {
t.Fatal(err)
}
if _, err = repo.ConfirmPaymentRefund(ctx, bizpayment.PaymentAlipay, order.TradeNo, current.RefundNo, 40, true, ""); err != nil {
t.Fatal(err)
}
summary, err := repo.SummarizePaymentOrders(ctx, bizpayment.PaymentOrderFilter{ProviderTradeNo: "summary-1"})
if err != nil {
t.Fatal(err)
}
if summary.OrderCount != 1 || summary.PaidCount != 1 || summary.RefundedCount != 1 || len(summary.Currencies) != 1 || summary.Currencies[0].NetAmount != 60 {
t.Fatalf("unexpected summary: %#v", summary)
}
events, err := repo.ListPaymentEvents(ctx, bizpayment.PaymentAlipay, order.TradeNo, 100)
if err != nil {
t.Fatal(err)
}
if len(events) < 5 {
t.Fatalf("event count = %d, want at least 5", len(events))
}
var foundRefund bool
for _, event := range events {
if event.Type == bizpayment.PaymentEventRefundStarted {
foundRefund = event.Message == "客户重复购买" && event.OperatorID == 7 && event.DeviceID == "device-1"
}
}
if !foundRefund {
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, "退款处理")
}
}
+200
View File
@@ -0,0 +1,200 @@
package system
import (
"errors"
"strconv"
"strings"
platformmodule "kra/pkg/module"
"gorm.io/gorm"
)
// EnsureAdminSurface upgrades an already initialized installation with a
// module's current menus, API metadata, root menu links, and root policies.
// It intentionally does nothing before first-install seed data exists.
func EnsureAdminSurface(db *gorm.DB, surface platformmodule.Surface, rootAuthorityID uint) error {
if db == nil || !db.Migrator().HasTable(&menuPO{}) || !db.Migrator().HasTable(&apiPO{}) {
return nil
}
return db.Transaction(func(tx *gorm.DB) error {
parentIDs := map[string]uint{}
for _, item := range surface.Menus {
if item.ParentName == "" {
continue
}
if _, ok := parentIDs[item.ParentName]; ok {
continue
}
var parent menuPO
if err := tx.Where("name = ?", item.ParentName).First(&parent).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return err
}
parentIDs[item.ParentName] = parent.ID
}
menus := make([]menuPO, 0, len(surface.Menus))
for _, item := range surface.Menus {
menu := menuPO{
MenuLevel: 1, ParentID: parentIDs[item.ParentName], Path: item.Path, Name: item.Name,
Component: item.Component, Title: item.Title, Icon: item.Icon, Sort: item.Sort,
}
if item.ParentName == "" {
menu.MenuLevel = 0
}
var current menuPO
err := tx.Where("name = ?", item.Name).First(&current).Error
switch {
case errors.Is(err, gorm.ErrRecordNotFound):
if err = tx.Create(&menu).Error; err != nil {
return err
}
case err != nil:
return err
default:
if err = tx.Model(&current).Updates(map[string]any{
"menu_level": menu.MenuLevel, "parent_id": menu.ParentID, "path": menu.Path,
"component": menu.Component, "title": menu.Title, "icon": menu.Icon, "sort": menu.Sort,
}).Error; err != nil {
return err
}
menu.ID = current.ID
}
menus = append(menus, menu)
}
apis := make([]apiPO, 0, len(surface.APIs))
for _, item := range surface.APIs {
api := apiPO{Path: item.Path, Method: strings.ToUpper(item.Method), APIGroup: item.Group, Description: item.Description}
var current apiPO
err := tx.Where("path = ? AND method = ?", api.Path, api.Method).First(&current).Error
switch {
case errors.Is(err, gorm.ErrRecordNotFound):
if err = tx.Create(&api).Error; err != nil {
return err
}
case err != nil:
return err
default:
if err = tx.Model(&current).Updates(map[string]any{"api_group": api.APIGroup, "description": api.Description}).Error; err != nil {
return err
}
api.ID = current.ID
}
apis = append(apis, api)
}
if rootAuthorityID == 0 || !tx.Migrator().HasTable(&authorityPO{}) || !tx.Migrator().HasTable(&authorityMenuPO{}) || !tx.Migrator().HasTable(&casbinRulePO{}) {
return nil
}
var root authorityPO
if err := tx.Where("authority_id = ?", rootAuthorityID).First(&root).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return err
}
for _, menu := range menus {
link := authorityMenuPO{SysAuthorityAuthorityID: rootAuthorityID, SysBaseMenuID: menu.ID}
var count int64
if err := tx.Model(&authorityMenuPO{}).
Where("sys_authority_authority_id = ? AND sys_base_menu_id = ?", rootAuthorityID, menu.ID).
Count(&count).Error; err != nil {
return err
}
if count == 0 {
if err := tx.Create(&link).Error; err != nil {
return err
}
}
}
for _, api := range apis {
exists, err := policyExists(tx, rootAuthorityID, api.Path, api.Method)
if err != nil {
return err
}
if !exists {
rule := newPolicyRule(rootAuthorityID, api.Path, api.Method)
if err := tx.Create(&rule).Error; err != nil {
return err
}
}
}
return nil
})
}
// InheritAPIPolicy grants a new route only to authorities that already own
// the supplied predecessor route. This preserves existing read-access intent
// without broadening unrelated write permissions.
func InheritAPIPolicy(db *gorm.DB, sourcePath, sourceMethod, targetPath, targetMethod string) error {
if db == nil || !db.Migrator().HasTable(&casbinRulePO{}) {
return nil
}
sourceMethod = strings.ToUpper(sourceMethod)
targetMethod = strings.ToUpper(targetMethod)
return db.Transaction(func(tx *gorm.DB) error {
var rows []casbinRulePO
if err := policyScope(tx).Where("v1 = ? AND v2 = ?", sourcePath, sourceMethod).Find(&rows).Error; err != nil {
return err
}
for _, row := range rows {
parsed, err := strconv.ParseUint(row.V0, 10, 64)
if err != nil {
continue
}
authorityID := uint(parsed)
exists, err := policyExists(tx, authorityID, targetPath, targetMethod)
if err != nil {
return err
}
if !exists {
rule := newPolicyRule(authorityID, targetPath, targetMethod)
if err := tx.Create(&rule).Error; err != nil {
return err
}
}
}
return nil
})
}
// GrantMenuAPIs makes a visible administration menu usable by granting only
// its explicitly supplied API set to authorities that already own the menu.
func GrantMenuAPIs(db *gorm.DB, menuName string, apis []platformmodule.API) error {
if db == nil || !db.Migrator().HasTable(&menuPO{}) || !db.Migrator().HasTable(&authorityMenuPO{}) || !db.Migrator().HasTable(&casbinRulePO{}) {
return nil
}
return db.Transaction(func(tx *gorm.DB) error {
var menu menuPO
if err := tx.Where("name = ?", menuName).First(&menu).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return err
}
var links []authorityMenuPO
if err := tx.Where("sys_base_menu_id = ?", menu.ID).Find(&links).Error; err != nil {
return err
}
for _, link := range links {
for _, api := range apis {
method := strings.ToUpper(api.Method)
exists, err := policyExists(tx, link.SysAuthorityAuthorityID, api.Path, method)
if err != nil {
return err
}
if !exists {
rule := newPolicyRule(link.SysAuthorityAuthorityID, api.Path, method)
if err := tx.Create(&rule).Error; err != nil {
return err
}
}
}
}
return nil
})
}
@@ -0,0 +1,110 @@
package system
import (
"testing"
"github.com/google/uuid"
platformmodule "kra/pkg/module"
)
func TestEnsureAdminSurfaceAndPolicyInheritance(t *testing.T) {
db, err := openWithDriver("sqlite", "file:"+t.Name()+"-"+uuid.NewString()+"?mode=memory&cache=shared")
if err != nil {
t.Fatal(err)
}
if err = migrateAll(db); err != nil {
t.Fatal(err)
}
parent := menuPO{Name: "extensions", Path: "extensions", Title: "扩展功能"}
if err = db.Create(&parent).Error; err != nil {
t.Fatal(err)
}
rootParent := uint(0)
if err = db.Create(&[]authorityPO{
{AuthorityID: 888, AuthorityName: "root", ParentID: &rootParent},
{AuthorityID: 999, AuthorityName: "operator", ParentID: &rootParent},
}).Error; err != nil {
t.Fatal(err)
}
if err = db.Create(&[]casbinRulePO{
newPolicyRule(999, "/payment/orders", "GET"),
newPolicyRule(999, "/payment/orders/:provider/:tradeNo", "GET"),
}).Error; err != nil {
t.Fatal(err)
}
surface := platformmodule.Surface{
Menus: []platformmodule.Menu{
{Name: "paymentOrders", Path: "paymentOrders", ParentName: "extensions", Component: "view/payment/orders.vue", Title: "支付订单", Sort: 6},
{Name: "paymentConfig", Path: "paymentConfig", ParentName: "extensions", Component: "view/payment/config.vue", Title: "支付配置", Sort: 7},
},
APIs: []platformmodule.API{
{Path: "/payment/orders", Method: "GET", Group: "支付", Description: "订单列表"},
{Path: "/payment/orders/summary", Method: "GET", Group: "支付", Description: "订单汇总"},
{Path: "/payment/orders/:provider/:tradeNo", Method: "GET", Group: "支付", Description: "订单详情"},
{Path: "/payment/orders/:provider/:tradeNo/events", Method: "GET", Group: "支付", Description: "订单事件"},
{Path: "/payment/refund", Method: "POST", Group: "支付", Description: "退款"},
},
}
for attempt := 0; attempt < 2; attempt++ {
if err = EnsureAdminSurface(db, surface, 888); err != nil {
t.Fatal(err)
}
var paymentMenu menuPO
if err = db.Where("name = ?", "paymentOrders").First(&paymentMenu).Error; err != nil {
t.Fatal(err)
}
var linkCount int64
if err = db.Model(&authorityMenuPO{}).Where("sys_authority_authority_id = ? AND sys_base_menu_id = ?", 999, paymentMenu.ID).Count(&linkCount).Error; err != nil {
t.Fatal(err)
}
if linkCount == 0 {
if err = db.Create(&authorityMenuPO{SysAuthorityAuthorityID: 999, SysBaseMenuID: paymentMenu.ID}).Error; err != nil {
t.Fatal(err)
}
}
if err = InheritAPIPolicy(db, "/payment/orders", "GET", "/payment/orders/summary", "GET"); err != nil {
t.Fatal(err)
}
if err = InheritAPIPolicy(db, "/payment/orders/:provider/:tradeNo", "GET", "/payment/orders/:provider/:tradeNo/events", "GET"); err != nil {
t.Fatal(err)
}
if err = GrantMenuAPIs(db, "paymentOrders", surface.APIs[:4]); err != nil {
t.Fatal(err)
}
}
for _, test := range []struct {
authority uint
path string
method string
want bool
}{
{888, "/payment/orders", "GET", true},
{888, "/payment/refund", "POST", true},
{999, "/payment/orders/summary", "GET", true},
{999, "/payment/orders", "GET", true},
{999, "/payment/orders/:provider/:tradeNo/events", "GET", true},
{999, "/payment/refund", "POST", false},
} {
exists, checkErr := policyExists(db, test.authority, test.path, test.method)
if checkErr != nil {
t.Fatal(checkErr)
}
if exists != test.want {
t.Fatalf("policy %d %s %s = %v, want %v", test.authority, test.method, test.path, exists, test.want)
}
}
var apiCount int64
if err = db.Model(&apiPO{}).Where("path LIKE ?", "/payment/%").Count(&apiCount).Error; err != nil {
t.Fatal(err)
}
if apiCount != int64(len(surface.APIs)) {
t.Fatalf("payment APIs = %d, want %d", apiCount, len(surface.APIs))
}
var rootLinks int64
if err = db.Model(&authorityMenuPO{}).Where("sys_authority_authority_id = ?", 888).Count(&rootLinks).Error; err != nil {
t.Fatal(err)
}
if rootLinks != int64(len(surface.Menus)) {
t.Fatalf("root menu links = %d, want %d", rootLinks, len(surface.Menus))
}
}
+1 -1
View File
@@ -74,7 +74,7 @@ func (t *ConnectivityTester) restoreMaskedSecrets(kind, provider string, values
}
for key := range masked {
value, _ := values[key].(string)
if !config.IsMaskedSecret(strings.TrimSpace(value)) {
if strings.TrimSpace(value) != config.MaskedSecret {
continue
}
prior, _ := currentValues[key].(string)
@@ -0,0 +1,94 @@
package integration
import (
"context"
"encoding/json"
"fmt"
"net"
"os"
"strconv"
"strings"
"testing"
"time"
integrationbiz "kra/internal/biz/integration"
)
type kafkaConnectivityRepo struct{}
func (*kafkaConnectivityRepo) ListIntegrationConfigs(context.Context, string) ([]*integrationbiz.IntegrationConfig, error) {
return nil, nil
}
func (*kafkaConnectivityRepo) FindIntegrationConfig(context.Context, string, string) (*integrationbiz.IntegrationConfig, error) {
return nil, nil
}
func (*kafkaConnectivityRepo) SaveIntegrationConfig(context.Context, *integrationbiz.IntegrationConfig) error {
return nil
}
func (*kafkaConnectivityRepo) DeleteIntegrationConfig(context.Context, string, string) error {
return nil
}
func TestKafkaConnectivityIntegration(t *testing.T) {
rawBrokers := strings.TrimSpace(os.Getenv("KRA_KAFKA_TEST_BROKERS"))
if rawBrokers == "" {
t.Skip("KRA_KAFKA_TEST_BROKERS is not configured")
}
brokers := make([]string, 0)
for _, broker := range strings.Split(rawBrokers, ",") {
if broker = strings.TrimSpace(broker); broker != "" {
brokers = append(brokers, broker)
}
}
values := integrationbiz.DefaultIntegrationConfig(integrationbiz.IntegrationKindMQ, "kafka")
values["brokers"] = brokers
values["client_id"] = fmt.Sprintf("kra-connectivity-%d", time.Now().UnixNano())
values["group_id"] = fmt.Sprintf("kra-connectivity-%d", time.Now().UnixNano())
testMQConnectivityIntegration(t, "kafka", values)
}
func TestEMQXConnectivityIntegration(t *testing.T) {
broker := strings.TrimSpace(os.Getenv("KRA_EMQX_TEST_BROKER"))
if broker == "" {
t.Skip("KRA_EMQX_TEST_BROKER is not configured")
}
values := integrationbiz.DefaultIntegrationConfig(integrationbiz.IntegrationKindMQ, "emqx")
values["broker"] = broker
values["client_id"] = fmt.Sprintf("kra-connectivity-%d", time.Now().UnixNano())
testMQConnectivityIntegration(t, "emqx", values)
}
func TestRabbitMQConnectivityIntegration(t *testing.T) {
address := strings.TrimSpace(os.Getenv("KRA_RABBITMQ_TEST_ADDR"))
if address == "" {
t.Skip("KRA_RABBITMQ_TEST_ADDR is not configured")
}
host, portText, err := net.SplitHostPort(address)
if err != nil {
t.Fatal(err)
}
port, err := strconv.Atoi(portText)
if err != nil {
t.Fatal(err)
}
values := integrationbiz.DefaultIntegrationConfig(integrationbiz.IntegrationKindMQ, "rabbitmq")
values["host"] = host
values["port"] = port
testMQConnectivityIntegration(t, "rabbitmq", values)
}
func testMQConnectivityIntegration(t *testing.T, provider string, values map[string]any) {
t.Helper()
raw, err := json.Marshal(values)
if err != nil {
t.Fatal(err)
}
usecase := integrationbiz.NewIntegrationConfigUsecase(&kafkaConnectivityRepo{}, NewConnectivityTester(nil))
if err = usecase.Test(context.Background(), &integrationbiz.IntegrationConfig{
Kind: integrationbiz.IntegrationKindMQ,
Provider: provider,
Values: raw,
}); err != nil {
t.Fatalf("test JSON-decoded %s defaults: %v", provider, err)
}
}
+11
View File
@@ -46,6 +46,17 @@ func TestConnectivityTesterRestoreMaskedSecrets(t *testing.T) {
}
})
t.Run("keeps an empty optional secret", func(t *testing.T) {
values := map[string]any{"password": ""}
if err := tester.restoreMaskedSecrets("mq", "kafka", values); err != nil {
t.Fatalf("restoreMaskedSecrets() error = %v", err)
}
if got := values["password"]; got != "" {
t.Fatalf("password = %q, want empty value", got)
}
})
t.Run("does not treat non-secret fields as masked secrets", func(t *testing.T) {
values := map[string]any{
"username": "******",
+56 -9
View File
@@ -16,10 +16,13 @@ import (
const (
ProviderEMQX = platformmq.ProviderEMQX
ProviderKafka = platformmq.ProviderKafka
ProviderRabbitMQ = platformmq.ProviderRabbitMQ
retryTick = time.Second
)
var providers = []string{ProviderEMQX, ProviderKafka, ProviderRabbitMQ}
// Reloadable owns process-wide message clients and the logical subscription
// declarations used to restore them after reconnects or configuration reloads.
type Reloadable struct {
@@ -65,12 +68,11 @@ func New(store *runtimeconfig.Store, logger *slog.Logger) (*Reloadable, func(),
logger: logger,
}
if store != nil {
r.apply(ProviderEMQX, storeConfig(store, ProviderEMQX))
r.apply(ProviderRabbitMQ, storeConfig(store, ProviderRabbitMQ))
r.stop = append(r.stop,
store.Subscribe("mq", ProviderEMQX, func(config runtimeconfig.Config) { r.apply(ProviderEMQX, config) }),
store.Subscribe("mq", ProviderRabbitMQ, func(config runtimeconfig.Config) { r.apply(ProviderRabbitMQ, config) }),
)
for _, provider := range providers {
r.apply(provider, storeConfig(store, provider))
provider := provider
r.stop = append(r.stop, store.Subscribe("mq", provider, func(config runtimeconfig.Config) { r.apply(provider, config) }))
}
}
go r.retryLoop()
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.
// 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 {
if ctx != nil {
select {
@@ -218,6 +220,24 @@ func newProviderClient(provider string, raw json.RawMessage) (platformmq.Client,
ReconnectInterval: configSeconds(values, "reconnect_interval"),
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:
return nil, fmt.Errorf("unsupported message provider %q", provider)
}
@@ -246,6 +266,30 @@ 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 {
result := make([]string, 0, len(stringsValue))
for _, item := range stringsValue {
if value := strings.TrimSpace(item); value != "" {
result = append(result, value)
}
}
return result
}
return nil
}
func configSeconds(values map[string]any, key string) time.Duration {
seconds := configInt(values, key)
if seconds <= 0 {
@@ -281,7 +325,7 @@ func (r *Reloadable) retryOnce() {
}
r.ensureStateLocked()
now := time.Now()
for _, provider := range []string{ProviderEMQX, ProviderRabbitMQ} {
for _, provider := range providers {
config, exists := r.configs[provider]
if !exists || !config.Enabled {
continue
@@ -289,6 +333,9 @@ func (r *Reloadable) retryOnce() {
client := r.clientLocked(provider)
if client != nil && client.Connected() {
if r.pending[provider] {
if retryAt := r.nextRetry[provider]; !retryAt.IsZero() && now.Before(retryAt) {
continue
}
if err := r.reconcileProviderLocked(provider); err != nil {
r.scheduleRetryLocked(provider, config.Values)
}
@@ -631,7 +678,7 @@ func (r *Reloadable) UnsubscribeFrom(ctx context.Context, provider string, topic
func (r *Reloadable) Client(provider string) platformmq.Client {
provider = strings.ToLower(strings.TrimSpace(provider))
if provider != ProviderEMQX && provider != ProviderRabbitMQ {
if provider != ProviderEMQX && provider != ProviderKafka && provider != ProviderRabbitMQ {
return nil
}
return &namedClient{owner: r, provider: provider}
+67
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"log/slog"
"strings"
"testing"
"time"
@@ -209,3 +210,69 @@ func TestReloadableLegacySubscribeDoesNotLeaveOfflineDeclaration(t *testing.T) {
t.Fatalf("offline legacy declaration remained: %#v", r.subscriptions[ProviderEMQX])
}
}
func TestReloadableExposesKafkaNamedClient(t *testing.T) {
r := &Reloadable{}
client := r.Client(" KAFKA ")
if client == nil {
t.Fatal("kafka named client is nil")
}
if client.Connected() {
t.Fatal("unconfigured kafka named client reported connected")
}
}
func TestConfigStringsDecodesJSONArrays(t *testing.T) {
values := map[string]any{"brokers": []any{" kafka-1:9092 ", "", "kafka-2:9092"}}
brokers := configStrings(values, "brokers")
if len(brokers) != 2 || brokers[0] != "kafka-1:9092" || brokers[1] != "kafka-2:9092" {
t.Fatalf("brokers = %#v", brokers)
}
}
func TestConfigStringsNormalizesTypedSlices(t *testing.T) {
values := map[string]any{"brokers": []string{" kafka-1:9092 ", "", "kafka-2:9092"}}
brokers := configStrings(values, "brokers")
if len(brokers) != 2 || brokers[0] != "kafka-1:9092" || brokers[1] != "kafka-2:9092" {
t.Fatalf("brokers = %#v", brokers)
}
}
func TestReloadableRejectsUnsupportedKafkaQoS(t *testing.T) {
r := &Reloadable{}
err := r.Register(platformmq.SubscriptionSet{
Owner: "orders",
Provider: ProviderKafka,
Topics: []platformmq.TopicSubscription{{Topic: "orders.created", QoS: platformmq.ExactlyOnce, Handler: func(context.Context, platformmq.Message) {}}},
})
if err == nil || !strings.Contains(err.Error(), "supports qos 0 or 1") {
t.Fatalf("Register() error = %v", err)
}
}
func TestRetryOnceHonorsPendingSubscriptionDeadline(t *testing.T) {
client := &fakeClient{}
r := &Reloadable{
clients: map[string]platformmq.Client{ProviderKafka: client},
configs: map[string]runtimeconfig.Config{
ProviderKafka: {Kind: "mq", Provider: ProviderKafka, Enabled: true},
},
subscriptions: map[string]map[string]map[string]subscription{
ProviderKafka: {
"orders.created": {"orders": {qos: platformmq.AtLeastOnce, handler: func(context.Context, platformmq.Message) {}}},
},
},
bindings: map[string]map[string]byte{ProviderKafka: {}},
pending: map[string]bool{ProviderKafka: true},
nextRetry: map[string]time.Time{ProviderKafka: time.Now().Add(time.Minute)},
}
r.retryOnce()
if len(client.subscribed) != 0 {
t.Fatalf("subscription retried before deadline: %v", client.subscribed)
}
r.nextRetry[ProviderKafka] = time.Now().Add(-time.Second)
r.retryOnce()
if len(client.subscribed) != 1 || client.subscribed[0] != "orders.created" {
t.Fatalf("subscription was not retried after deadline: %v", client.subscribed)
}
}
+4 -4
View File
@@ -25,16 +25,16 @@ func TestCatalogContainsBuiltInModulesInDependencyOrder(t *testing.T) {
t.Fatalf("definition[%d] = %q, want %q", index, got, name)
}
}
if got := catalog.MigrationSteps(); len(got) != 8 {
t.Fatalf("module migrations = %d, want 8", len(got))
if got := catalog.MigrationSteps(); len(got) != 13 {
t.Fatalf("module migrations = %d, want 13", len(got))
}
var menus, apis int
for _, definition := range catalog.Definitions {
menus += len(definition.Surface.Menus)
apis += len(definition.Surface.APIs)
}
if menus != 3 || apis != 15 {
t.Fatalf("admin surface = %d menus/%d APIs, want 3/15", menus, apis)
if menus != 3 || apis != 17 {
t.Fatalf("admin surface = %d menus/%d APIs, want 3/17", menus, apis)
}
if got := catalog.DefaultTimedTasks(); len(got) != 2 {
t.Fatalf("default timed tasks = %d, want 2", len(got))
@@ -4,7 +4,7 @@ import "testing"
func TestDefinitionOwnsCommunicationSurface(t *testing.T) {
definition := Definition()
if definition.Name != "integration" || len(definition.Migrations) != 3 || len(definition.Surface.Menus) != 1 || len(definition.Surface.APIs) != 5 {
if definition.Name != "integration" || len(definition.Migrations) != 5 || len(definition.Surface.Menus) != 1 || len(definition.Surface.APIs) != 5 {
t.Fatalf("integration surface = %#v", definition.Surface)
}
}
+47 -8
View File
@@ -4,21 +4,60 @@ package payment
import (
datapayment "kra/internal/data/payment"
datasystem "kra/internal/data/system"
"kra/internal/modules/surface"
"kra/pkg/database/migration"
"kra/pkg/module"
"gorm.io/gorm"
)
// Definition declares the payment-owned schema and administration surface.
func Definition() module.Definition {
menus := []module.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},
}
adminSurface := module.Surface{
Menus: menus,
APIs: surface.APIsForPrefix("/payment", 12),
}
// Keep the contents of shipped migrations immutable. Future payment routes
// belong in a new migration instead of silently changing this snapshot.
upgradeSurface := module.Surface{Menus: menus, APIs: []module.API{
{Path: "/payment/orders", Method: "GET", Group: "支付", Description: "分页查询支付订单"},
{Path: "/payment/orders/summary", Method: "GET", Group: "支付", Description: "汇总支付订单运营指标"},
{Path: "/payment/orders/:provider/:tradeNo", Method: "GET", Group: "支付", Description: "按路径查询支付订单"},
{Path: "/payment/orders/:provider/:tradeNo/events", Method: "GET", Group: "支付", Description: "查询支付订单事件流水"},
{Path: "/payment/create", Method: "POST", Group: "支付", Description: "创建支付订单"},
{Path: "/payment/fulfill", Method: "POST", Group: "支付", Description: "重试支付订单发货"},
{Path: "/payment/order", Method: "POST", Group: "支付", Description: "查询支付订单"},
{Path: "/payment/orders/:provider/:tradeNo/fulfill", Method: "POST", Group: "支付", Description: "按路径重试支付订单发货"},
{Path: "/payment/orders/:provider/:tradeNo/refund", Method: "POST", Group: "支付", Description: "按路径申请支付订单退款"},
{Path: "/payment/providers/:provider/test", Method: "POST", Group: "支付", Description: "测试支付渠道配置与沙箱交易链路"},
{Path: "/payment/query", Method: "POST", Group: "支付", Description: "同步支付订单状态"},
{Path: "/payment/refund", Method: "POST", Group: "支付", Description: "申请支付订单退款"},
}}
orderReadAPIs := make([]module.API, 0, 4)
for _, api := range upgradeSurface.APIs {
if api.Method == "GET" && (api.Path == "/payment/orders" || api.Path == "/payment/orders/summary" || api.Path == "/payment/orders/:provider/:tradeNo" || api.Path == "/payment/orders/:provider/:tradeNo/events") {
orderReadAPIs = append(orderReadAPIs, api)
}
}
migrations := append(datapayment.Migrations(), migration.Step{ID: "202608290002_payment_admin_surface", Migrate: func(db *gorm.DB) error {
if err := datasystem.EnsureAdminSurface(db, upgradeSurface, 888); err != nil {
return err
}
if err := datasystem.InheritAPIPolicy(db, "/payment/orders", "GET", "/payment/orders/summary", "GET"); err != nil {
return err
}
return datasystem.InheritAPIPolicy(db, "/payment/orders/:provider/:tradeNo", "GET", "/payment/orders/:provider/:tradeNo/events", "GET")
}}, migration.Step{ID: "202608290003_payment_order_read_permissions", Migrate: func(db *gorm.DB) error {
return datasystem.GrantMenuAPIs(db, "paymentOrders", orderReadAPIs)
}})
return module.Definition{
Name: "payment",
Migrations: datapayment.Migrations(),
Surface: module.Surface{
Menus: []module.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: surface.APIsForPrefix("/payment", 10),
},
Migrations: migrations,
Surface: adminSurface,
}
}
+4 -4
View File
@@ -7,10 +7,10 @@ func TestDefinitionOwnsPaymentContributions(t *testing.T) {
if definition.Name != "payment" {
t.Fatalf("name = %q, want payment", definition.Name)
}
if len(definition.Migrations) != 1 {
t.Fatalf("migrations = %d, want 1", len(definition.Migrations))
if len(definition.Migrations) != 4 {
t.Fatalf("migrations = %d, want 4", len(definition.Migrations))
}
if len(definition.Surface.Menus) != 2 || len(definition.Surface.APIs) != 10 {
t.Fatalf("surface = %d menus/%d APIs, want 2/10", len(definition.Surface.Menus), len(definition.Surface.APIs))
if len(definition.Surface.Menus) != 2 || len(definition.Surface.APIs) != 12 {
t.Fatalf("surface = %d menus/%d APIs, want 2/12", len(definition.Surface.Menus), len(definition.Surface.APIs))
}
}
+2
View File
@@ -86,7 +86,9 @@ var routes = map[string]routeValue{
"GET /logViewer/files": {group: "文件日志", description: "获取日期下的日志文件"},
"GET /menu/getMenuRoles": {group: "菜单", description: "获取菜单关联角色列表"},
"GET /payment/orders": {group: "支付", description: "分页查询支付订单"},
"GET /payment/orders/summary": {group: "支付", description: "汇总支付订单运营指标"},
"GET /payment/orders/:provider/:tradeNo": {group: "支付", description: "按路径查询支付订单"},
"GET /payment/orders/:provider/:tradeNo/events": {group: "支付", description: "查询支付订单事件流水"},
"GET /position/findPosition": {group: "岗位", description: "根据ID获取岗位"},
"GET /position/getPositionUsers": {group: "岗位", description: "获取岗位成员ID列表"},
"GET /securityConfig/getSecurityConfig": {group: "安全配置", description: "获取安全配置"},
+1 -1
View File
@@ -97,7 +97,7 @@ func TestGinStartupLogsEveryRegisteredRoute(t *testing.T) {
if got, want := strings.Count(text, `"msg":"router registered"`), len(engine.Routes()); got != want {
t.Fatalf("registered route log count = %d, want %d", got, want)
}
if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":194`) {
if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":196`) {
t.Fatalf("startup route summary is missing: %s", text)
}
}
+57 -8
View File
@@ -39,15 +39,16 @@ func (h *Payment) OrderByPath(c *gin.Context) {
func (h *Payment) RefundByPath(c *gin.Context) {
var req struct {
Amount int64 `json:"amount"`
Amount int64 `json:"amount"`
Reason string `json:"reason"`
}
if err := c.ShouldBindJSON(&req); err != nil {
Fail(c, err.Error())
return
}
result, err := h.service.Refund(c.Request.Context(), &dto.PaymentRefundRequest{
Provider: c.Param("provider"), TradeNo: c.Param("tradeNo"), Amount: req.Amount,
})
request := &dto.PaymentRefundRequest{Provider: c.Param("provider"), TradeNo: c.Param("tradeNo"), Amount: req.Amount, Reason: req.Reason}
setPaymentRequestActor(c, &request.OperatorID, &request.OperatorName, &request.ClientIP, &request.UserAgent, &request.DeviceID)
result, err := h.service.Refund(c.Request.Context(), request)
if err != nil {
Fail(c, err.Error())
return
@@ -56,9 +57,9 @@ func (h *Payment) RefundByPath(c *gin.Context) {
}
func (h *Payment) FulfillByPath(c *gin.Context) {
result, err := h.service.Fulfill(c.Request.Context(), &dto.PaymentFulfillRequest{
Provider: c.Param("provider"), TradeNo: c.Param("tradeNo"),
})
request := &dto.PaymentFulfillRequest{Provider: c.Param("provider"), TradeNo: c.Param("tradeNo")}
setPaymentRequestActor(c, &request.OperatorID, &request.OperatorName, &request.ClientIP, &request.UserAgent, &request.DeviceID)
result, err := h.service.Fulfill(c.Request.Context(), request)
if err != nil {
Fail(c, err.Error())
return
@@ -80,6 +81,36 @@ func (h *Payment) Orders(c *gin.Context) {
Write(c, CodeSuccess, PageResult{List: items, Total: total, Page: page, PageSize: pageSize}, "获取成功")
}
func (h *Payment) OrderSummary(c *gin.Context) {
var req dto.PaymentOrderListRequest
if err := c.ShouldBindQuery(&req); err != nil {
Fail(c, err.Error())
return
}
result, err := h.service.OrderSummary(c.Request.Context(), &req)
if err != nil {
Fail(c, err.Error())
return
}
OKWithData(c, result)
}
func (h *Payment) OrderEvents(c *gin.Context) {
var req struct {
Limit int `form:"limit"`
}
if err := c.ShouldBindQuery(&req); err != nil {
Fail(c, err.Error())
return
}
items, err := h.service.OrderEvents(c.Request.Context(), c.Param("provider"), c.Param("tradeNo"), req.Limit)
if err != nil {
Fail(c, err.Error())
return
}
OKWithData(c, items)
}
func (h *Payment) TestProvider(c *gin.Context) {
result, err := h.service.TestProvider(c.Request.Context(), c.Param("provider"))
if err != nil {
@@ -99,6 +130,8 @@ func (h *Payment) Create(c *gin.Context) {
return
}
req.ClientIP = c.ClientIP()
req.UserAgent = c.Request.UserAgent()
req.DeviceID = c.GetHeader("X-Device-Id")
result, err := h.service.Create(c.Request.Context(), &req)
if err != nil {
Fail(c, err.Error())
@@ -112,6 +145,7 @@ func (h *Payment) Query(c *gin.Context) {
Fail(c, err.Error())
return
}
setPaymentRequestActor(c, &req.OperatorID, &req.OperatorName, &req.ClientIP, &req.UserAgent, &req.DeviceID)
result, err := h.service.Query(c.Request.Context(), &req)
if err != nil {
Fail(c, err.Error())
@@ -125,6 +159,7 @@ func (h *Payment) Refund(c *gin.Context) {
Fail(c, err.Error())
return
}
setPaymentRequestActor(c, &req.OperatorID, &req.OperatorName, &req.ClientIP, &req.UserAgent, &req.DeviceID)
result, err := h.service.Refund(c.Request.Context(), &req)
if err != nil {
Fail(c, err.Error())
@@ -139,6 +174,7 @@ func (h *Payment) Fulfill(c *gin.Context) {
Fail(c, err.Error())
return
}
setPaymentRequestActor(c, &req.OperatorID, &req.OperatorName, &req.ClientIP, &req.UserAgent, &req.DeviceID)
result, err := h.service.Fulfill(c.Request.Context(), &req)
if err != nil {
Fail(c, err.Error())
@@ -166,13 +202,26 @@ func (h *Payment) Callback(c *gin.Context) {
query[k] = v[0]
}
}
ack, err := h.service.Callback(c.Request.Context(), &dto.PaymentCallbackRequest{Provider: c.Param("provider"), Headers: headers, Body: raw, Query: query})
ack, err := h.service.Callback(c.Request.Context(), &dto.PaymentCallbackRequest{
Provider: c.Param("provider"), Headers: headers, Body: raw, Query: query,
ClientIP: c.ClientIP(), UserAgent: c.Request.UserAgent(),
})
writePaymentCallbackAck(c, ack)
if err != nil {
_ = c.Error(err)
}
}
func setPaymentRequestActor(c *gin.Context, operatorID *uint, operatorName, clientIP, userAgent, deviceID *string) {
if claims := Claims(c); claims != nil {
*operatorID = claims.ID
*operatorName = claims.Username
}
*clientIP = c.ClientIP()
*userAgent = c.Request.UserAgent()
*deviceID = c.GetHeader("X-Device-Id")
}
func writePaymentCallbackAck(c *gin.Context, ack dto.PaymentCallbackAck) {
if ack.StatusCode == 0 {
ack.StatusCode = http.StatusInternalServerError
+8
View File
@@ -33,6 +33,14 @@ func TestResponseHelpers(t *testing.T) {
}
}
func TestSanitizeFailureMessageKeepsSafeConnectionSummary(t *testing.T) {
for _, message := range []string{"Kafka 连接测试失败", "EMQX 连接测试失败", "RabbitMQ 连接测试失败"} {
if got := sanitizeFailureMessage(message); got != message {
t.Fatalf("sanitizeFailureMessage(%q) = %q", message, got)
}
}
}
func TestSetCookieUsesRequestedName(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
+2
View File
@@ -8,9 +8,11 @@ import (
func RegisterPayment(group, public *gin.RouterGroup, h *handler.Payment) {
payment := group.Group("/payment")
payment.GET("/orders", h.Orders)
payment.GET("/orders/summary", h.OrderSummary)
payment.POST("/providers/:provider/test", h.TestProvider)
payment.POST("/order", h.Order)
payment.GET("/orders/:provider/:tradeNo", h.OrderByPath)
payment.GET("/orders/:provider/:tradeNo/events", h.OrderEvents)
payment.POST("/create", h.Create)
payment.POST("/query", h.Query)
payment.POST("/refund", h.Refund)
+118 -53
View File
@@ -6,14 +6,21 @@ import (
)
type PaymentOrderListRequest struct {
Page int `form:"page"`
PageSize int `form:"pageSize"`
Provider string `form:"provider"`
TradeNo string `form:"tradeNo"`
BusinessType string `form:"businessType"`
BusinessID string `form:"businessId"`
PaymentStatus string `form:"paymentStatus"`
RefundStatus string `form:"refundStatus"`
Page int `form:"page"`
PageSize int `form:"pageSize"`
Provider string `form:"provider"`
TradeNo string `form:"tradeNo"`
ProviderTradeNo string `form:"providerTradeNo"`
Keyword string `form:"keyword"`
BusinessType string `form:"businessType"`
BusinessID string `form:"businessId"`
PaymentStatus string `form:"paymentStatus"`
RefundStatus string `form:"refundStatus"`
CreatedFrom string `form:"createdFrom"`
CreatedTo string `form:"createdTo"`
PaidFrom string `form:"paidFrom"`
PaidTo string `form:"paidTo"`
IssueOnly bool `form:"issueOnly"`
}
type PaymentRequest struct {
@@ -30,29 +37,49 @@ type PaymentRequest struct {
BusinessType string `json:"businessType"`
BusinessID string `json:"businessId"`
PaymentMode string `json:"paymentMode"`
UserAgent string `json:"-"`
DeviceID string `json:"-"`
}
type PaymentQueryRequest struct {
Provider string `json:"provider"`
TradeNo string `json:"tradeNo"`
Provider string `json:"provider"`
TradeNo string `json:"tradeNo"`
OperatorID uint `json:"-"`
OperatorName string `json:"-"`
ClientIP string `json:"-"`
UserAgent string `json:"-"`
DeviceID string `json:"-"`
}
type PaymentRefundRequest struct {
Provider string `json:"provider"`
TradeNo string `json:"tradeNo"`
Amount int64 `json:"amount"`
Provider string `json:"provider"`
TradeNo string `json:"tradeNo"`
Amount int64 `json:"amount"`
Reason string `json:"reason"`
OperatorID uint `json:"-"`
OperatorName string `json:"-"`
ClientIP string `json:"-"`
UserAgent string `json:"-"`
DeviceID string `json:"-"`
}
type PaymentFulfillRequest struct {
Provider string `json:"provider"`
TradeNo string `json:"tradeNo"`
Provider string `json:"provider"`
TradeNo string `json:"tradeNo"`
OperatorID uint `json:"-"`
OperatorName string `json:"-"`
ClientIP string `json:"-"`
UserAgent string `json:"-"`
DeviceID string `json:"-"`
}
// PaymentCallbackRequest is populated by the HTTP transport and converted to
// the biz callback object inside service.
type PaymentCallbackRequest struct {
Provider string
Headers map[string]string
Body []byte
Query map[string]string
Provider string
Headers map[string]string
Body []byte
Query map[string]string
ClientIP string
UserAgent string
}
type PaymentCallbackAck struct {
@@ -103,38 +130,76 @@ type PaymentTestResult struct {
}
type PaymentOrderResponse struct {
ID uint64 `json:"ID"`
Provider string `json:"provider"`
TradeNo string `json:"tradeNo"`
ProviderTradeNo string `json:"providerTradeNo"`
BusinessType string `json:"businessType"`
BusinessID string `json:"businessId"`
Subject string `json:"subject"`
PaymentMode string `json:"paymentMode"`
OriginalAmount int64 `json:"originalAmount"`
Amount int64 `json:"amount"`
PaidAmount int64 `json:"paidAmount"`
PayerPaidAmount int64 `json:"payerPaidAmount"`
CashPaidAmount int64 `json:"cashPaidAmount"`
PointPaidAmount int64 `json:"pointPaidAmount"`
DiscountAmount int64 `json:"discountAmount"`
ProviderDiscountAmount int64 `json:"providerDiscountAmount"`
MerchantDiscountAmount int64 `json:"merchantDiscountAmount"`
SettlementAmount int64 `json:"settlementAmount"`
Currency string `json:"currency"`
PayerCurrency string `json:"payerCurrency,omitempty"`
AmountBreakdownKnown bool `json:"amountBreakdownKnown"`
PaymentStatus string `json:"paymentStatus"`
ProviderStatus string `json:"providerStatus"`
FulfillmentStatus string `json:"fulfillmentStatus"`
RefundStatus string `json:"refundStatus"`
RefundedAmount int64 `json:"refundedAmount"`
RefundRequestedAmount int64 `json:"refundRequestedAmount"`
RefundNo string `json:"refundNo,omitempty"`
LastError string `json:"lastError,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
PaidAt *time.Time `json:"paidAt,omitempty"`
FulfilledAt *time.Time `json:"fulfilledAt,omitempty"`
RefundedAt *time.Time `json:"refundedAt,omitempty"`
ID uint64 `json:"ID"`
Provider string `json:"provider"`
TradeNo string `json:"tradeNo"`
ProviderTradeNo string `json:"providerTradeNo"`
BusinessType string `json:"businessType"`
BusinessID string `json:"businessId"`
Subject string `json:"subject"`
PaymentMode string `json:"paymentMode"`
OriginalAmount int64 `json:"originalAmount"`
Amount int64 `json:"amount"`
PaidAmount int64 `json:"paidAmount"`
PayerPaidAmount int64 `json:"payerPaidAmount"`
CashPaidAmount int64 `json:"cashPaidAmount"`
PointPaidAmount int64 `json:"pointPaidAmount"`
DiscountAmount int64 `json:"discountAmount"`
ProviderDiscountAmount int64 `json:"providerDiscountAmount"`
MerchantDiscountAmount int64 `json:"merchantDiscountAmount"`
SettlementAmount int64 `json:"settlementAmount"`
Currency string `json:"currency"`
PayerCurrency string `json:"payerCurrency,omitempty"`
AmountBreakdownKnown bool `json:"amountBreakdownKnown"`
PaymentStatus string `json:"paymentStatus"`
ProviderStatus string `json:"providerStatus"`
FulfillmentStatus string `json:"fulfillmentStatus"`
RefundStatus string `json:"refundStatus"`
RefundedAmount int64 `json:"refundedAmount"`
RefundRequestedAmount int64 `json:"refundRequestedAmount"`
RefundNo string `json:"refundNo,omitempty"`
LastError string `json:"lastError,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
PaidAt *time.Time `json:"paidAt,omitempty"`
FulfilledAt *time.Time `json:"fulfilledAt,omitempty"`
RefundedAt *time.Time `json:"refundedAt,omitempty"`
Events []*PaymentEventResponse `json:"events,omitempty"`
}
type PaymentEventResponse struct {
ID uint64 `json:"id"`
Type string `json:"type"`
Source string `json:"source"`
Status string `json:"status"`
ProviderStatus string `json:"providerStatus,omitempty"`
Message string `json:"message,omitempty"`
EventID string `json:"eventId,omitempty"`
PayloadHash string `json:"payloadHash,omitempty"`
Amount int64 `json:"amount,omitempty"`
Currency string `json:"currency,omitempty"`
OperatorID uint `json:"operatorId,omitempty"`
OperatorName string `json:"operatorName,omitempty"`
ClientIP string `json:"clientIp,omitempty"`
UserAgent string `json:"userAgent,omitempty"`
DeviceID string `json:"deviceId,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
type PaymentCurrencySummaryResponse struct {
Currency string `json:"currency"`
OrderAmount int64 `json:"orderAmount"`
PaidAmount int64 `json:"paidAmount"`
RefundedAmount int64 `json:"refundedAmount"`
SettlementAmount int64 `json:"settlementAmount"`
NetAmount int64 `json:"netAmount"`
}
type PaymentOrderSummaryResponse struct {
OrderCount int64 `json:"orderCount"`
PendingCount int64 `json:"pendingCount"`
PaidCount int64 `json:"paidCount"`
RefundedCount int64 `json:"refundedCount"`
IssueCount int64 `json:"issueCount"`
Currencies []*PaymentCurrencySummaryResponse `json:"currencies"`
}
+134 -6
View File
@@ -4,6 +4,8 @@ import (
"context"
"errors"
paymentbiz "kra/internal/biz/payment"
"strings"
"time"
"kra/internal/service/dto"
)
@@ -16,7 +18,16 @@ func (s *PaymentService) Order(ctx context.Context, provider, tradeNo string) (*
if err != nil {
return nil, err
}
return paymentOrderResponse(order)
response, err := paymentOrderResponse(order)
if err != nil {
return nil, err
}
events, err := s.uc.OrderEvents(ctx, provider, tradeNo, 100)
if err != nil {
return nil, err
}
response.Events = paymentEventResponses(events)
return response, nil
}
func (s *PaymentService) Orders(ctx context.Context, req *dto.PaymentOrderListRequest) ([]*dto.PaymentOrderResponse, int64, int, int, error) {
@@ -30,10 +41,11 @@ func (s *PaymentService) Orders(ctx context.Context, req *dto.PaymentOrderListRe
if pageSize <= 0 {
pageSize = 10
}
items, total, err := s.uc.Orders(ctx, page, pageSize, paymentbiz.PaymentOrderFilter{
Provider: req.Provider, TradeNo: req.TradeNo, BusinessType: req.BusinessType,
BusinessID: req.BusinessID, PaymentStatus: req.PaymentStatus, RefundStatus: req.RefundStatus,
})
filter, err := paymentOrderFilter(req)
if err != nil {
return nil, 0, page, pageSize, err
}
items, total, err := s.uc.Orders(ctx, page, pageSize, filter)
if err != nil {
return nil, 0, page, pageSize, err
}
@@ -48,6 +60,113 @@ func (s *PaymentService) Orders(ctx context.Context, req *dto.PaymentOrderListRe
return result, total, page, pageSize, nil
}
func (s *PaymentService) OrderSummary(ctx context.Context, req *dto.PaymentOrderListRequest) (*dto.PaymentOrderSummaryResponse, error) {
filter, err := paymentOrderFilter(req)
if err != nil {
return nil, err
}
summary, err := s.uc.OrderSummary(ctx, filter)
if err != nil {
return nil, err
}
response := &dto.PaymentOrderSummaryResponse{
OrderCount: summary.OrderCount, PendingCount: summary.PendingCount, PaidCount: summary.PaidCount,
RefundedCount: summary.RefundedCount, IssueCount: summary.IssueCount,
Currencies: make([]*dto.PaymentCurrencySummaryResponse, 0, len(summary.Currencies)),
}
for _, item := range summary.Currencies {
response.Currencies = append(response.Currencies, &dto.PaymentCurrencySummaryResponse{
Currency: item.Currency, OrderAmount: item.OrderAmount, PaidAmount: item.PaidAmount,
RefundedAmount: item.RefundedAmount, SettlementAmount: item.SettlementAmount, NetAmount: item.NetAmount,
})
}
return response, nil
}
func (s *PaymentService) OrderEvents(ctx context.Context, provider, tradeNo string, limit int) ([]*dto.PaymentEventResponse, error) {
events, err := s.uc.OrderEvents(ctx, provider, tradeNo, limit)
if err != nil {
return nil, err
}
return paymentEventResponses(events), nil
}
func paymentOrderFilter(req *dto.PaymentOrderListRequest) (paymentbiz.PaymentOrderFilter, error) {
if req == nil {
return paymentbiz.PaymentOrderFilter{}, errors.New("支付订单筛选请求为空")
}
createdFrom, err := parsePaymentFilterTime(req.CreatedFrom, false)
if err != nil {
return paymentbiz.PaymentOrderFilter{}, err
}
createdTo, err := parsePaymentFilterTime(req.CreatedTo, true)
if err != nil {
return paymentbiz.PaymentOrderFilter{}, err
}
paidFrom, err := parsePaymentFilterTime(req.PaidFrom, false)
if err != nil {
return paymentbiz.PaymentOrderFilter{}, err
}
paidTo, err := parsePaymentFilterTime(req.PaidTo, true)
if err != nil {
return paymentbiz.PaymentOrderFilter{}, err
}
if createdFrom != nil && createdTo != nil && !createdFrom.Before(*createdTo) {
return paymentbiz.PaymentOrderFilter{}, errors.New("订单创建时间范围无效")
}
if paidFrom != nil && paidTo != nil && !paidFrom.Before(*paidTo) {
return paymentbiz.PaymentOrderFilter{}, errors.New("支付完成时间范围无效")
}
return paymentbiz.PaymentOrderFilter{
Provider: req.Provider, TradeNo: req.TradeNo, ProviderTradeNo: req.ProviderTradeNo, Keyword: req.Keyword,
BusinessType: req.BusinessType, BusinessID: req.BusinessID, PaymentStatus: req.PaymentStatus,
RefundStatus: req.RefundStatus, CreatedFrom: createdFrom, CreatedTo: createdTo,
PaidFrom: paidFrom, PaidTo: paidTo, IssueOnly: req.IssueOnly,
}, nil
}
func parsePaymentFilterTime(value string, endOfDate bool) (*time.Time, error) {
value = strings.TrimSpace(value)
if value == "" {
return nil, nil
}
if parsed, err := time.Parse(time.RFC3339, value); err == nil {
return &parsed, nil
}
parsed, err := time.ParseInLocation("2006-01-02", value, time.Local)
if err != nil {
return nil, errors.New("支付订单时间格式无效")
}
if endOfDate {
parsed = parsed.AddDate(0, 0, 1)
}
return &parsed, nil
}
func paymentEventResponses(events []*paymentbiz.PaymentEvent) []*dto.PaymentEventResponse {
result := make([]*dto.PaymentEventResponse, 0, len(events))
for _, event := range events {
if event == nil {
continue
}
result = append(result, &dto.PaymentEventResponse{
ID: event.ID, Type: event.Type, Source: event.Source, Status: event.Status,
ProviderStatus: event.ProviderStatus, Message: event.Message, EventID: event.EventID,
PayloadHash: event.PayloadHash, Amount: event.Amount, Currency: event.Currency,
OperatorID: event.OperatorID, OperatorName: event.OperatorName, ClientIP: event.ClientIP,
UserAgent: event.UserAgent, DeviceID: event.DeviceID, CreatedAt: event.CreatedAt,
})
}
return result
}
func operationContext(ctx context.Context, source, reason string, operatorID uint, operatorName, clientIP, userAgent, deviceID string) context.Context {
return paymentbiz.WithPaymentOperation(ctx, paymentbiz.PaymentOperation{
Source: source, Reason: reason, OperatorID: operatorID, OperatorName: operatorName,
ClientIP: clientIP, UserAgent: userAgent, DeviceID: deviceID,
})
}
func paymentOrderResponse(order *paymentbiz.PaymentOrder) (*dto.PaymentOrderResponse, error) {
if order == nil {
return nil, errors.New("支付订单为空")
@@ -70,12 +189,13 @@ func (s *PaymentService) Create(ctx context.Context, req *dto.PaymentRequest) (*
if req == nil {
return nil, errors.New("支付请求为空")
}
ctx = operationContext(ctx, "client", "客户端创建支付订单", 0, "", req.ClientIP, req.UserAgent, req.DeviceID)
result, err := s.uc.Create(ctx, &paymentbiz.PaymentRequest{
Provider: req.Provider, TradeNo: req.TradeNo, Subject: req.Subject,
Amount: req.Amount, OriginalAmount: req.OriginalAmount, Currency: req.Currency,
NotifyURL: req.NotifyURL, ReturnURL: req.ReturnURL, ClientIP: req.ClientIP,
Extra: req.Extra, BusinessType: req.BusinessType, BusinessID: req.BusinessID,
PaymentMode: req.PaymentMode,
PaymentMode: req.PaymentMode, UserAgent: req.UserAgent, DeviceID: req.DeviceID,
})
return paymentResultResponse(result), err
}
@@ -83,6 +203,7 @@ func (s *PaymentService) Query(ctx context.Context, req *dto.PaymentQueryRequest
if req == nil {
return nil, errors.New("支付查询请求为空")
}
ctx = operationContext(ctx, "admin", "主动同步支付平台状态", req.OperatorID, req.OperatorName, req.ClientIP, req.UserAgent, req.DeviceID)
result, err := s.uc.Query(ctx, req.Provider, req.TradeNo)
return paymentResultResponse(result), err
}
@@ -90,6 +211,11 @@ func (s *PaymentService) Refund(ctx context.Context, req *dto.PaymentRefundReque
if req == nil {
return nil, errors.New("支付退款请求为空")
}
reason := strings.TrimSpace(req.Reason)
if reason == "" {
reason = "未填写退款原因"
}
ctx = operationContext(ctx, "admin", reason, req.OperatorID, req.OperatorName, req.ClientIP, req.UserAgent, req.DeviceID)
result, err := s.uc.Refund(ctx, req.Provider, req.TradeNo, req.Amount)
return paymentResultResponse(result), err
}
@@ -111,6 +237,7 @@ func (s *PaymentService) Fulfill(ctx context.Context, req *dto.PaymentFulfillReq
if req == nil {
return nil, errors.New("支付发货请求为空")
}
ctx = operationContext(ctx, "admin", "人工执行或重试业务发货", req.OperatorID, req.OperatorName, req.ClientIP, req.UserAgent, req.DeviceID)
result, err := s.uc.Fulfill(ctx, req.Provider, req.TradeNo)
return paymentResultResponse(result), err
}
@@ -118,6 +245,7 @@ func (s *PaymentService) Callback(ctx context.Context, req *dto.PaymentCallbackR
if req == nil {
return paymentCallbackAckResponse(paymentbiz.DefaultPaymentCallbackAck("", false)), errors.New("支付回调请求为空")
}
ctx = operationContext(ctx, req.Provider, "支付平台异步通知", 0, "", req.ClientIP, req.UserAgent, "")
result, err := s.uc.Callback(ctx, &paymentbiz.PaymentCallback{Provider: req.Provider, Headers: req.Headers, Body: req.Body, Query: req.Query})
if err != nil {
return paymentCallbackAckResponse(paymentbiz.CallbackFailure(err, paymentbiz.DefaultPaymentCallbackAck(req.Provider, false))), err
+592
View File
@@ -0,0 +1,592 @@
package mq
import (
"context"
"crypto/tls"
"errors"
"fmt"
"io"
"net"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
kafkago "github.com/segmentio/kafka-go"
"github.com/segmentio/kafka-go/sasl"
"github.com/segmentio/kafka-go/sasl/plain"
)
type KafkaConfig struct {
Enabled bool
Brokers []string
ClientID string
GroupID string
Username string
Password string
TLS bool
TLSSkipVerify bool
StartOffset string
MinBytes int
MaxBytes int
MaxWait time.Duration
ConnectTimeout time.Duration
ReconnectInterval time.Duration
AllowAutoTopicCreation bool
}
type kafkaSubscription struct {
reader *kafkago.Reader
cancel context.CancelFunc
qos byte
handler Handler
}
type kafkaTopicMetadataError struct {
err error
}
func (e *kafkaTopicMetadataError) Error() string { return e.err.Error() }
func (e *kafkaTopicMetadataError) Unwrap() error { return e.err }
// Kafka adapts Kafka topics and consumer groups to the shared messaging
// contract. QoS 0 commits before dispatch; QoS 1 commits after dispatch.
type Kafka struct {
opMu sync.Mutex
mu sync.RWMutex
config KafkaConfig
dialer *kafkago.Dialer
metadata *kafkago.Client
transport *kafkago.Transport
bestEffort *kafkago.Writer
acknowledged *kafkago.Writer
subscriptions map[string]*kafkaSubscription
connected atomic.Bool
reconnectAt atomic.Int64
closed bool
}
func NewKafka(config KafkaConfig) (*Kafka, error) {
client := &Kafka{subscriptions: make(map[string]*kafkaSubscription)}
if !config.Enabled {
return client, nil
}
config = defaultKafkaConfig(config)
if err := validateKafkaConfig(config); err != nil {
return nil, err
}
tlsConfig, mechanism := kafkaSecurity(config)
dialer := &kafkago.Dialer{
ClientID: config.ClientID,
Timeout: config.ConnectTimeout,
TLS: tlsConfig,
SASLMechanism: mechanism,
}
transport := &kafkago.Transport{
DialTimeout: config.ConnectTimeout,
ClientID: config.ClientID,
TLS: tlsConfig,
SASL: mechanism,
}
metadata := &kafkago.Client{
Addr: kafkago.TCP(config.Brokers...),
Timeout: config.ConnectTimeout,
Transport: transport,
}
if err := probeKafkaMetadata(context.Background(), metadata, ""); err != nil {
transport.CloseIdleConnections()
return nil, fmt.Errorf("connect kafka: %w", err)
}
newWriter := func(acks kafkago.RequiredAcks) *kafkago.Writer {
return &kafkago.Writer{
Addr: kafkago.TCP(config.Brokers...),
Balancer: &kafkago.LeastBytes{},
RequiredAcks: acks,
ReadTimeout: config.ConnectTimeout,
WriteTimeout: config.ConnectTimeout,
Transport: transport,
AllowAutoTopicCreation: config.AllowAutoTopicCreation,
}
}
client.config = config
client.dialer = dialer
client.metadata = metadata
client.transport = transport
client.bestEffort = newWriter(kafkago.RequireNone)
client.acknowledged = newWriter(kafkago.RequireAll)
client.markConnected()
return client, nil
}
func probeKafkaMetadata(ctx context.Context, client *kafkago.Client, topic string) error {
if client == nil {
return ErrUnavailable
}
request := &kafkago.MetadataRequest{Topics: []string{}}
if topic != "" {
request.Topics = []string{topic}
}
response, err := client.Metadata(nonNilContext(ctx), request)
if err != nil {
return err
}
if response == nil || len(response.Brokers) == 0 {
return errors.New("kafka metadata contains no brokers")
}
if topic == "" {
return nil
}
for _, item := range response.Topics {
if item.Name != topic {
continue
}
if item.Error != nil {
return &kafkaTopicMetadataError{err: item.Error}
}
if len(item.Partitions) == 0 {
return &kafkaTopicMetadataError{err: errors.New("topic has no partitions")}
}
return nil
}
return &kafkaTopicMetadataError{err: errors.New("topic metadata was not returned")}
}
func defaultKafkaConfig(config KafkaConfig) KafkaConfig {
brokers := make([]string, 0, len(config.Brokers))
for _, broker := range config.Brokers {
if broker = strings.TrimSpace(broker); broker != "" {
brokers = append(brokers, broker)
}
}
config.Brokers = brokers
config.ClientID = strings.TrimSpace(config.ClientID)
config.GroupID = strings.TrimSpace(config.GroupID)
config.StartOffset = strings.ToLower(strings.TrimSpace(config.StartOffset))
if config.StartOffset == "" {
config.StartOffset = "earliest"
}
if config.MinBytes <= 0 {
config.MinBytes = 1
}
if config.MaxBytes <= 0 {
config.MaxBytes = 10 << 20
}
if config.MaxWait <= 0 {
config.MaxWait = time.Second
}
if config.ConnectTimeout <= 0 {
config.ConnectTimeout = 10 * time.Second
}
if config.ReconnectInterval <= 0 {
config.ReconnectInterval = 5 * time.Second
}
return config
}
func validateKafkaConfig(config KafkaConfig) error {
if len(config.Brokers) == 0 {
return errors.New("kafka brokers are empty")
}
for _, broker := range config.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("invalid kafka broker %q", broker)
}
}
if config.ClientID == "" {
return errors.New("kafka client id is empty")
}
if config.GroupID == "" {
return errors.New("kafka group id is empty")
}
if (config.Username == "") != (config.Password == "") {
return errors.New("kafka username and password must be configured together")
}
if config.TLSSkipVerify && !config.TLS {
return errors.New("kafka tls skip verify requires tls")
}
if config.StartOffset != "earliest" && config.StartOffset != "latest" {
return fmt.Errorf("invalid kafka start offset %q", config.StartOffset)
}
if config.MinBytes < 1 || config.MaxBytes < config.MinBytes {
return errors.New("kafka byte limits are invalid")
}
return nil
}
func kafkaSecurity(config KafkaConfig) (*tls.Config, sasl.Mechanism) {
var tlsConfig *tls.Config
if config.TLS {
tlsConfig = &tls.Config{MinVersion: tls.VersionTLS12, InsecureSkipVerify: config.TLSSkipVerify} //nolint:gosec // explicit operator setting
}
var mechanism sasl.Mechanism
if config.Username != "" {
mechanism = plain.Mechanism{Username: config.Username, Password: config.Password}
}
return tlsConfig, mechanism
}
func (c *Kafka) Publish(ctx context.Context, topic string, payload []byte, qos byte, retain bool) error {
topic = strings.TrimSpace(topic)
if topic == "" {
return errors.New("kafka topic is empty")
}
if retain {
return errors.New("kafka does not support retained messages")
}
if qos > AtLeastOnce {
return fmt.Errorf("kafka supports qos 0 or 1, got %d", qos)
}
if c == nil {
return ErrUnavailable
}
ctx = nonNilContext(ctx)
c.mu.RLock()
if c.closed {
c.mu.RUnlock()
return ErrUnavailable
}
writer := c.bestEffort
if qos == AtLeastOnce {
writer = c.acknowledged
}
config := c.config
c.mu.RUnlock()
if writer == nil {
return ErrUnavailable
}
err := writeKafkaMessage(ctx, writer, kafkago.Message{Topic: topic, Value: append([]byte(nil), payload...)}, config)
if err == nil {
c.markConnected()
}
return err
}
func writeKafkaMessage(ctx context.Context, writer *kafkago.Writer, message kafkago.Message, config KafkaConfig) error {
if writer == nil {
return ErrUnavailable
}
ctx = nonNilContext(ctx)
if !config.AllowAutoTopicCreation {
return writer.WriteMessages(ctx, message)
}
attemptCtx, cancel := context.WithTimeout(ctx, config.ConnectTimeout)
defer cancel()
delay := 100 * time.Millisecond
var lastErr error
for {
lastErr = writer.WriteMessages(attemptCtx, message)
if lastErr == nil || !kafkaTopicNotReady(lastErr) {
return lastErr
}
timer := time.NewTimer(delay)
select {
case <-attemptCtx.Done():
timer.Stop()
if err := ctx.Err(); err != nil {
return err
}
return lastErr
case <-timer.C:
}
if delay < time.Second {
delay *= 2
if delay > time.Second {
delay = time.Second
}
}
}
}
func kafkaTopicNotReady(err error) bool {
if err == nil {
return false
}
if writeErrors, ok := err.(kafkago.WriteErrors); ok {
found := false
for _, item := range writeErrors {
if item == nil {
continue
}
found = true
if !kafkaTopicNotReady(item) {
return false
}
}
return found
}
var kafkaErr kafkago.Error
if !errors.As(err, &kafkaErr) {
return false
}
return kafkaErr == kafkago.UnknownTopicOrPartition || kafkaErr == kafkago.LeaderNotAvailable || kafkaErr == kafkago.NotLeaderForPartition
}
func (c *Kafka) Subscribe(ctx context.Context, topic string, qos byte, handler Handler) error {
topic = strings.TrimSpace(topic)
if topic == "" {
return errors.New("kafka topic is empty")
}
if qos > AtLeastOnce {
return fmt.Errorf("kafka supports qos 0 or 1, got %d", qos)
}
if handler == nil {
return errors.New("kafka handler is nil")
}
if c == nil {
return ErrUnavailable
}
ctx = nonNilContext(ctx)
select {
case <-ctx.Done():
return ctx.Err()
default:
}
c.opMu.Lock()
defer c.opMu.Unlock()
c.mu.RLock()
if c.closed || c.dialer == nil || c.metadata == nil {
c.mu.RUnlock()
return ErrUnavailable
}
config := c.config
dialer := c.dialer
metadata := c.metadata
c.mu.RUnlock()
if err := probeKafkaMetadata(ctx, metadata, topic); err != nil {
var topicErr *kafkaTopicMetadataError
if errors.As(err, &topicErr) {
c.markConnected()
} else if ctx.Err() == nil {
c.markUnavailable()
}
return fmt.Errorf("lookup kafka topic %q: %w", topic, err)
}
if old := c.removeSubscription(topic); old != nil {
closeKafkaSubscription(old)
}
reader := kafkago.NewReader(kafkago.ReaderConfig{
Brokers: append([]string(nil), config.Brokers...),
GroupID: config.GroupID,
Topic: topic,
Dialer: dialer,
MinBytes: config.MinBytes,
MaxBytes: config.MaxBytes,
MaxWait: config.MaxWait,
JoinGroupBackoff: config.ReconnectInterval,
ReadLagInterval: -1,
StartOffset: kafkaStartOffset(config.StartOffset),
})
consumeCtx, cancel := context.WithCancel(context.Background())
subscription := &kafkaSubscription{reader: reader, cancel: cancel, qos: qos, handler: handler}
c.mu.Lock()
if c.closed {
c.mu.Unlock()
cancel()
_ = reader.Close()
return ErrUnavailable
}
c.subscriptions[topic] = subscription
c.mu.Unlock()
c.markConnected()
go c.consume(consumeCtx, subscription)
return nil
}
func kafkaStartOffset(value string) int64 {
if value == "latest" {
return kafkago.LastOffset
}
return kafkago.FirstOffset
}
func (c *Kafka) consume(ctx context.Context, subscription *kafkaSubscription) {
for {
var message kafkago.Message
var err error
if subscription.qos == AtMostOnce {
message, err = subscription.reader.ReadMessage(ctx)
} else {
message, err = subscription.reader.FetchMessage(ctx)
}
if err != nil {
if ctx.Err() != nil || errors.Is(err, io.EOF) {
return
}
c.markUnavailable()
if !sleepContext(ctx, c.config.ReconnectInterval) {
return
}
continue
}
c.markConnected()
subscription.handler(ctx, Message{Topic: message.Topic, Payload: append([]byte(nil), message.Value...), QoS: subscription.qos})
if ctx.Err() != nil {
return
}
if subscription.qos == AtLeastOnce {
if err = subscription.reader.CommitMessages(ctx, message); err != nil {
c.markUnavailable()
}
}
}
}
func sleepContext(ctx context.Context, duration time.Duration) bool {
timer := time.NewTimer(duration)
defer timer.Stop()
select {
case <-ctx.Done():
return false
case <-timer.C:
return true
}
}
func (c *Kafka) Unsubscribe(ctx context.Context, topics ...string) error {
if len(topics) == 0 {
return errors.New("kafka topics are empty")
}
if c == nil {
return ErrUnavailable
}
ctx = nonNilContext(ctx)
normalized := make([]string, len(topics))
for index, topic := range topics {
normalized[index] = strings.TrimSpace(topic)
if normalized[index] == "" {
return errors.New("kafka topic is empty")
}
}
c.opMu.Lock()
defer c.opMu.Unlock()
for _, topic := range normalized {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
if subscription := c.removeSubscription(topic); subscription != nil {
closeKafkaSubscription(subscription)
}
}
return nil
}
func (c *Kafka) removeSubscription(topic string) *kafkaSubscription {
c.mu.Lock()
defer c.mu.Unlock()
subscription := c.subscriptions[topic]
delete(c.subscriptions, topic)
return subscription
}
func closeKafkaSubscription(subscription *kafkaSubscription) {
if subscription == nil {
return
}
if subscription.cancel != nil {
subscription.cancel()
}
if subscription.reader != nil {
_ = subscription.reader.Close()
}
}
func (c *Kafka) Connected() bool {
if c == nil {
return false
}
c.mu.RLock()
closed := c.closed
c.mu.RUnlock()
return !closed && c.connected.Load()
}
// Reconnecting lets the outer reloadable honor the configured retry delay
// before replacing a client after a runtime broker failure.
func (c *Kafka) Reconnecting() bool {
if c == nil {
return false
}
c.mu.RLock()
closed := c.closed
c.mu.RUnlock()
return !closed && time.Now().UnixNano() < c.reconnectAt.Load()
}
func (c *Kafka) markConnected() {
if c == nil {
return
}
c.mu.RLock()
closed := c.closed
c.mu.RUnlock()
if closed {
return
}
c.reconnectAt.Store(0)
c.connected.Store(true)
}
func (c *Kafka) markUnavailable() {
if c == nil {
return
}
c.mu.RLock()
closed := c.closed
c.mu.RUnlock()
if closed {
return
}
c.connected.Store(false)
c.reconnectAt.Store(time.Now().Add(c.config.ReconnectInterval).UnixNano())
}
func (c *Kafka) Close() error {
if c == nil {
return nil
}
c.opMu.Lock()
defer c.opMu.Unlock()
c.mu.Lock()
if c.closed {
c.mu.Unlock()
return nil
}
c.closed = true
c.connected.Store(false)
c.reconnectAt.Store(0)
subscriptions := make([]*kafkaSubscription, 0, len(c.subscriptions))
for topic, subscription := range c.subscriptions {
subscriptions = append(subscriptions, subscription)
delete(c.subscriptions, topic)
}
bestEffort := c.bestEffort
acknowledged := c.acknowledged
transport := c.transport
c.bestEffort = nil
c.acknowledged = nil
c.transport = nil
c.dialer = nil
c.metadata = nil
c.mu.Unlock()
for _, subscription := range subscriptions {
closeKafkaSubscription(subscription)
}
var result error
if bestEffort != nil {
result = errors.Join(result, bestEffort.Close())
}
if acknowledged != nil {
result = errors.Join(result, acknowledged.Close())
}
if transport != nil {
transport.CloseIdleConnections()
}
return result
}
+116
View File
@@ -0,0 +1,116 @@
package mq
import (
"context"
"fmt"
"os"
"strings"
"testing"
"time"
)
func TestKafkaRoundTripIntegration(t *testing.T) {
rawBrokers := strings.TrimSpace(os.Getenv("KRA_KAFKA_TEST_BROKERS"))
if rawBrokers == "" {
t.Skip("KRA_KAFKA_TEST_BROKERS is not configured")
}
brokers := []string{"127.0.0.1:1"}
for _, broker := range strings.Split(rawBrokers, ",") {
if broker = strings.TrimSpace(broker); broker != "" {
brokers = append(brokers, broker)
}
}
if len(brokers) == 1 {
t.Fatal("KRA_KAFKA_TEST_BROKERS contains no broker addresses")
}
suffix := fmt.Sprintf("%d", time.Now().UnixNano())
topics := []string{"kra-review-qos1-" + suffix, "kra-review-qos0-" + suffix}
groupID := "kra-review-" + suffix
newClient := func(clientID string) *Kafka {
client, err := NewKafka(KafkaConfig{
Enabled: true,
Brokers: brokers,
ClientID: clientID,
GroupID: groupID,
StartOffset: "earliest",
MinBytes: 1,
MaxBytes: 1 << 20,
MaxWait: 100 * time.Millisecond,
ConnectTimeout: 5 * time.Second,
ReconnectInterval: 500 * time.Millisecond,
AllowAutoTopicCreation: true,
})
if err != nil {
t.Fatalf("NewKafka(%s): %v", clientID, err)
}
return client
}
client := newClient("kra-review-producer-" + suffix)
t.Cleanup(func() { _ = client.Close() })
firstPayloads := [][]byte{[]byte("qos1-first"), []byte("qos0-first")}
qosValues := []byte{AtLeastOnce, AtMostOnce}
for index, topic := range topics {
if err := client.Publish(context.Background(), topic, firstPayloads[index], qosValues[index], false); err != nil {
t.Fatalf("publish first message to %s: %v", topic, err)
}
}
firstReceived := []chan Message{make(chan Message, 1), make(chan Message, 1)}
for index, topic := range topics {
index := index
if err := client.Subscribe(context.Background(), topic, qosValues[index], func(_ context.Context, message Message) {
firstReceived[index] <- message
}); err != nil {
t.Fatalf("subscribe to %s: %v", topic, err)
}
}
for index, received := range firstReceived {
message := waitKafkaMessage(t, received, 30*time.Second)
if string(message.Payload) != string(firstPayloads[index]) || message.QoS != qosValues[index] {
t.Fatalf("first message[%d] = payload %q qos %d", index, message.Payload, message.QoS)
}
}
// QoS 1 commits after the handler returns. Give the synchronous commit time
// to finish before closing the readers and rejoining the same group.
time.Sleep(500 * time.Millisecond)
if err := client.Unsubscribe(context.Background(), topics...); err != nil {
t.Fatal(err)
}
replayClient := newClient("kra-review-replay-" + suffix)
t.Cleanup(func() { _ = replayClient.Close() })
replayed := []chan Message{make(chan Message, 2), make(chan Message, 2)}
for index, topic := range topics {
index := index
if err := replayClient.Subscribe(context.Background(), topic, qosValues[index], func(_ context.Context, message Message) {
replayed[index] <- message
}); err != nil {
t.Fatalf("replay subscribe to %s: %v", topic, err)
}
}
secondPayloads := [][]byte{[]byte("qos1-second"), []byte("qos0-second")}
for index, topic := range topics {
if err := replayClient.Publish(context.Background(), topic, secondPayloads[index], qosValues[index], false); err != nil {
t.Fatalf("publish second message to %s: %v", topic, err)
}
}
for index, received := range replayed {
message := waitKafkaMessage(t, received, 30*time.Second)
if string(message.Payload) != string(secondPayloads[index]) {
t.Fatalf("message[%d] replayed uncommitted payload %q, want %q", index, message.Payload, secondPayloads[index])
}
}
}
func waitKafkaMessage(t *testing.T, messages <-chan Message, timeout time.Duration) Message {
t.Helper()
select {
case message := <-messages:
return message
case <-time.After(timeout):
t.Fatal("timed out waiting for kafka message")
return Message{}
}
}
+234
View File
@@ -0,0 +1,234 @@
package mq
import (
"context"
"errors"
"net"
"testing"
"time"
kafkago "github.com/segmentio/kafka-go"
metadataapi "github.com/segmentio/kafka-go/protocol/metadata"
)
type kafkaRoundTripperFunc func(context.Context, net.Addr, kafkago.Request) (kafkago.Response, error)
func (f kafkaRoundTripperFunc) RoundTrip(ctx context.Context, addr net.Addr, request kafkago.Request) (kafkago.Response, error) {
return f(ctx, addr, request)
}
func TestDisabledKafkaIsSafeAndUnavailable(t *testing.T) {
client, err := NewKafka(KafkaConfig{})
if err != nil {
t.Fatal(err)
}
if client.Connected() {
t.Fatal("disabled kafka reported connected")
}
if err = client.Publish(context.Background(), "orders", []byte("test"), AtLeastOnce, false); !errors.Is(err, ErrUnavailable) {
t.Fatalf("publish error = %v", err)
}
if err = client.Close(); err != nil {
t.Fatal(err)
}
}
func TestEnabledKafkaRequiresConnectionSettings(t *testing.T) {
if _, err := NewKafka(KafkaConfig{Enabled: true}); err == nil {
t.Fatal("enabled kafka without brokers should fail")
}
if _, err := NewKafka(KafkaConfig{Enabled: true, Brokers: []string{"localhost:9092"}}); err == nil {
t.Fatal("enabled kafka without client and group ids should fail")
}
}
func TestKafkaConfigRejectsInvalidBrokerAndTLSSettings(t *testing.T) {
config := KafkaConfig{Enabled: true, Brokers: []string{"missing-port"}, ClientID: "kra", GroupID: "kra"}
if _, err := NewKafka(config); err == nil {
t.Fatal("invalid kafka broker was accepted")
}
config.Brokers = []string{"localhost:9092"}
config.TLSSkipVerify = true
if _, err := NewKafka(config); err == nil {
t.Fatal("tls skip verify without tls was accepted")
}
}
func TestKafkaRejectsUnsupportedMessageSemantics(t *testing.T) {
client, err := NewKafka(KafkaConfig{})
if err != nil {
t.Fatal(err)
}
if err = client.Publish(context.Background(), "orders", nil, AtMostOnce, true); err == nil {
t.Fatal("retained kafka message was accepted")
}
if err = client.Publish(context.Background(), "orders", nil, ExactlyOnce, false); err == nil {
t.Fatal("qos 2 kafka message was accepted")
}
if err = client.Subscribe(context.Background(), "orders", ExactlyOnce, func(context.Context, Message) {}); err == nil {
t.Fatal("qos 2 kafka subscription was accepted")
}
}
func TestKafkaTopicNotReadyClassification(t *testing.T) {
for _, err := range []error{
kafkago.UnknownTopicOrPartition,
kafkago.LeaderNotAvailable,
kafkago.NotLeaderForPartition,
kafkago.WriteErrors{kafkago.UnknownTopicOrPartition},
} {
if !kafkaTopicNotReady(err) {
t.Fatalf("error %v was not classified as topic-not-ready", err)
}
}
for _, err := range []error{
kafkago.TopicAuthorizationFailed,
errors.New("network failed"),
kafkago.WriteErrors{kafkago.UnknownTopicOrPartition, kafkago.TopicAuthorizationFailed},
} {
if kafkaTopicNotReady(err) {
t.Fatalf("error %v was classified as topic-not-ready", err)
}
}
}
func TestKafkaStartOffset(t *testing.T) {
if kafkaStartOffset("latest") != -1 {
t.Fatal("latest offset was not mapped to kafka last offset")
}
if kafkaStartOffset("earliest") != -2 {
t.Fatal("earliest offset was not mapped to kafka first offset")
}
}
func TestNilKafkaClientIsSafe(t *testing.T) {
var client *Kafka
if err := client.Publish(context.Background(), "orders", nil, AtMostOnce, false); !errors.Is(err, ErrUnavailable) {
t.Fatalf("publish error = %v", err)
}
if err := client.Subscribe(context.Background(), "orders", AtMostOnce, func(context.Context, Message) {}); !errors.Is(err, ErrUnavailable) {
t.Fatalf("subscribe error = %v", err)
}
if err := client.Unsubscribe(context.Background(), "orders"); !errors.Is(err, ErrUnavailable) {
t.Fatalf("unsubscribe error = %v", err)
}
if err := client.Close(); err != nil {
t.Fatalf("close error = %v", err)
}
}
func TestKafkaReconnectDelayExpires(t *testing.T) {
client := &Kafka{config: KafkaConfig{ReconnectInterval: 20 * time.Millisecond}}
client.markUnavailable()
if client.Connected() || !client.Reconnecting() {
t.Fatalf("failure state connected=%t reconnecting=%t", client.Connected(), client.Reconnecting())
}
client.reconnectAt.Store(time.Now().Add(-time.Second).UnixNano())
if client.Reconnecting() {
t.Fatal("reconnect delay did not expire")
}
client.markConnected()
if !client.Connected() || client.Reconnecting() {
t.Fatalf("recovered state connected=%t reconnecting=%t", client.Connected(), client.Reconnecting())
}
}
func TestKafkaUnsubscribeDoesNotWaitForConsumerHandler(t *testing.T) {
consumeCtx, cancel := context.WithCancel(context.Background())
reader := kafkago.NewReader(kafkago.ReaderConfig{Brokers: []string{"127.0.0.1:9092"}, Topic: "orders", Partition: 0})
client := &Kafka{subscriptions: map[string]*kafkaSubscription{
"orders": {reader: reader, cancel: cancel},
}}
done := make(chan error, 1)
go func() { done <- client.Unsubscribe(context.Background(), "orders") }()
select {
case err := <-done:
if err != nil {
t.Fatal(err)
}
case <-time.After(time.Second):
t.Fatal("unsubscribe waited for the consumer handler")
}
if consumeCtx.Err() == nil {
t.Fatal("consumer context was not canceled")
}
}
func TestKafkaUnsubscribeRejectsBlankTopics(t *testing.T) {
client := &Kafka{subscriptions: make(map[string]*kafkaSubscription)}
if err := client.Unsubscribe(context.Background(), " "); err == nil {
t.Fatal("blank kafka topic was accepted")
}
}
func TestProbeKafkaMetadataDoesNotAutoCreateTopic(t *testing.T) {
var captured *metadataapi.Request
client := &kafkago.Client{
Addr: kafkago.TCP("kafka-1:9092", "kafka-2:9092"),
Transport: kafkaRoundTripperFunc(func(_ context.Context, _ net.Addr, request kafkago.Request) (kafkago.Response, error) {
captured = request.(*metadataapi.Request)
return &metadataapi.Response{
Brokers: []metadataapi.ResponseBroker{{NodeID: 1, Host: "kafka-1", Port: 9092}},
Topics: []metadataapi.ResponseTopic{{
Name: "orders",
Partitions: []metadataapi.ResponsePartition{{
PartitionIndex: 0,
LeaderID: 1,
}},
}},
}, nil
}),
}
if err := probeKafkaMetadata(context.Background(), client, "orders"); err != nil {
t.Fatal(err)
}
if captured == nil || len(captured.TopicNames) != 1 || captured.TopicNames[0] != "orders" {
t.Fatalf("metadata request = %#v", captured)
}
if captured.AllowAutoTopicCreation {
t.Fatal("subscription metadata lookup enabled automatic topic creation")
}
}
func TestProbeKafkaMetadataRequestsBrokersWithoutListingTopics(t *testing.T) {
var captured *metadataapi.Request
client := &kafkago.Client{
Addr: kafkago.TCP("kafka-1:9092"),
Transport: kafkaRoundTripperFunc(func(_ context.Context, _ net.Addr, request kafkago.Request) (kafkago.Response, error) {
captured = request.(*metadataapi.Request)
return &metadataapi.Response{Brokers: []metadataapi.ResponseBroker{{NodeID: 1, Host: "kafka-1", Port: 9092}}}, nil
}),
}
if err := probeKafkaMetadata(context.Background(), client, ""); err != nil {
t.Fatal(err)
}
if captured == nil || captured.TopicNames == nil || len(captured.TopicNames) != 0 {
t.Fatalf("broker metadata request topics = %#v", captured)
}
}
func TestKafkaTopicMetadataErrorDoesNotMarkClusterUnavailable(t *testing.T) {
metadata := &kafkago.Client{
Addr: kafkago.TCP("kafka-1:9092"),
Transport: kafkaRoundTripperFunc(func(_ context.Context, _ net.Addr, _ kafkago.Request) (kafkago.Response, error) {
return &metadataapi.Response{
Brokers: []metadataapi.ResponseBroker{{NodeID: 1, Host: "kafka-1", Port: 9092}},
Topics: []metadataapi.ResponseTopic{{Name: "missing", ErrorCode: int16(kafkago.UnknownTopicOrPartition)}},
}, nil
}),
}
client := &Kafka{
config: defaultKafkaConfig(KafkaConfig{}),
dialer: &kafkago.Dialer{},
metadata: metadata,
subscriptions: make(map[string]*kafkaSubscription),
}
client.markUnavailable()
err := client.Subscribe(context.Background(), "missing", AtLeastOnce, func(context.Context, Message) {})
if err == nil {
t.Fatal("missing kafka topic was accepted")
}
if !client.Connected() || client.Reconnecting() {
t.Fatalf("topic error changed cluster state: connected=%t reconnecting=%t", client.Connected(), client.Reconnecting())
}
}
+1
View File
@@ -12,6 +12,7 @@ var ErrUnavailable = errors.New("message broker unavailable")
const (
ProviderEMQX = "emqx"
ProviderKafka = "kafka"
ProviderRabbitMQ = "rabbitmq"
)
+4 -1
View File
@@ -12,7 +12,7 @@ func NormalizeSubscriptionSet(set SubscriptionSet) (SubscriptionSet, error) {
if set.Owner == "" {
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)
}
if len(set.Topics) == 0 {
@@ -27,6 +27,9 @@ func NormalizeSubscriptionSet(set SubscriptionSet) (SubscriptionSet, error) {
if set.Topics[index].QoS > ExactlyOnce {
return SubscriptionSet{}, fmt.Errorf("invalid mq qos %d for topic %q", set.Topics[index].QoS, set.Topics[index].Topic)
}
if set.Provider != ProviderEMQX && set.Topics[index].QoS > AtLeastOnce {
return SubscriptionSet{}, fmt.Errorf("mq provider %q supports qos 0 or 1 for topic %q", set.Provider, set.Topics[index].Topic)
}
if set.Topics[index].Handler == nil {
return SubscriptionSet{}, fmt.Errorf("mq subscription handler is nil for topic %q", set.Topics[index].Topic)
}
+51 -3
View File
@@ -16,7 +16,7 @@ func TestNormalizeSubscriptionSetValidDeclaration(t *testing.T) {
Topics: []TopicSubscription{
{Topic: " orders.created ", QoS: AtMostOnce, Handler: handler},
{Topic: "orders.updated", QoS: AtLeastOnce, Handler: handler},
{Topic: "orders.deleted", QoS: ExactlyOnce, Handler: handler},
{Topic: "orders.deleted", QoS: AtLeastOnce, Handler: handler},
},
}
@@ -31,7 +31,7 @@ func TestNormalizeSubscriptionSetValidDeclaration(t *testing.T) {
Topics: []TopicSubscription{
{Topic: "orders.created", QoS: AtMostOnce, Handler: handler},
{Topic: "orders.updated", QoS: AtLeastOnce, Handler: handler},
{Topic: "orders.deleted", QoS: ExactlyOnce, Handler: handler},
{Topic: "orders.deleted", QoS: AtLeastOnce, Handler: handler},
},
}
if got.Owner != want.Owner || got.Provider != want.Provider {
@@ -53,6 +53,34 @@ func TestNormalizeSubscriptionSetValidDeclaration(t *testing.T) {
}
}
func TestNormalizeSubscriptionSetAcceptsMQTTQoS2(t *testing.T) {
set, err := NormalizeSubscriptionSet(SubscriptionSet{
Owner: "devices",
Provider: ProviderEMQX,
Topics: []TopicSubscription{{Topic: "devices/status", QoS: ExactlyOnce, Handler: func(context.Context, Message) {}}},
})
if err != nil {
t.Fatal(err)
}
if set.Topics[0].QoS != ExactlyOnce {
t.Fatalf("qos = %d, want %d", set.Topics[0].QoS, ExactlyOnce)
}
}
func TestNormalizeSubscriptionSetAcceptsKafka(t *testing.T) {
set, err := NormalizeSubscriptionSet(SubscriptionSet{
Owner: "orders",
Provider: " KAFKA ",
Topics: []TopicSubscription{{Topic: "orders.created", QoS: AtLeastOnce, Handler: func(context.Context, Message) {}}},
})
if err != nil {
t.Fatal(err)
}
if set.Provider != ProviderKafka {
t.Fatalf("provider = %q, want %q", set.Provider, ProviderKafka)
}
}
func TestNormalizeSubscriptionSetRejectsInvalidDeclarations(t *testing.T) {
handler := func(context.Context, Message) {}
base := func() SubscriptionSet {
@@ -82,7 +110,7 @@ func TestNormalizeSubscriptionSetRejectsInvalidDeclarations(t *testing.T) {
},
{
name: "unsupported provider",
set: func() SubscriptionSet { set := base(); set.Provider = "kafka"; return set }(),
set: func() SubscriptionSet { set := base(); set.Provider = "pulsar"; return set }(),
wantErr: "unsupported mq provider",
},
{
@@ -128,6 +156,26 @@ func TestNormalizeSubscriptionSetRejectsInvalidDeclarations(t *testing.T) {
}(),
wantErr: "invalid mq qos",
},
{
name: "kafka qos 2",
set: func() SubscriptionSet {
set := base()
set.Provider = ProviderKafka
set.Topics[0].QoS = ExactlyOnce
return set
}(),
wantErr: "supports qos 0 or 1",
},
{
name: "rabbitmq qos 2",
set: func() SubscriptionSet {
set := base()
set.Provider = ProviderRabbitMQ
set.Topics[0].QoS = ExactlyOnce
return set
}(),
wantErr: "supports qos 0 or 1",
},
}
for _, test := range tests {
+17 -4
View File
@@ -7,10 +7,23 @@ export const getPaymentOrders = (params) => service({
donNotShowLoading: true
})
export const getPaymentOrder = (data) => service({
url: '/payment/order',
method: 'post',
data,
export const getPaymentOrderSummary = (params) => service({
url: '/payment/orders/summary',
method: 'get',
params,
donNotShowLoading: true
})
export const getPaymentOrderEvents = (provider, tradeNo, params = {}) => service({
url: `/payment/orders/${encodeURIComponent(provider)}/${encodeURIComponent(tradeNo)}/events`,
method: 'get',
params,
donNotShowLoading: true
})
export const getPaymentOrder = ({ provider, tradeNo }) => service({
url: `/payment/orders/${encodeURIComponent(provider)}/${encodeURIComponent(tradeNo)}`,
method: 'get',
donNotShowLoading: true
})
@@ -265,6 +265,12 @@ const TARGETS = {
description: 'RabbitMQ 消息队列',
icon: Promotion
},
'mq/kafka': {
name: 'Kafka',
protocol: 'Kafka',
description: 'Apache Kafka 分布式事件队列',
icon: Promotion
},
'websocket/melody': {
name: 'WebSocket',
protocol: 'WS',
@@ -276,7 +282,7 @@ const TARGETS = {
const TARGET_ORDER = Object.keys(TARGETS)
const RECONNECT_INTERVAL_KEY = 'reconnect_interval'
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 = {
key: RECONNECT_INTERVAL_KEY,
label: '重连间隔(秒)',
@@ -291,6 +297,9 @@ const NUMBER_CONSTRAINTS = {
reconnect_interval: { min: 1, integer: true },
prefetch_count: { min: 0 },
heartbeat: { min: 0 },
min_bytes: { min: 1, integer: true },
max_bytes: { min: 1, integer: true },
max_wait: { min: 1, integer: true },
max_message_size: { min: 0 },
message_buffer_size: { min: 0 }
}
@@ -560,7 +569,6 @@ const persist = async (item, operation) => {
config: item.config
})
if (res.code !== 0) {
ElMessage.error(res.msg || '保存失败')
return false
}
markSaved(item)
@@ -591,12 +599,11 @@ const testSelected = async () => {
config: item.config
})
if (res.code !== 0) {
ElMessage.error(res.msg || '连接测试失败')
return
}
ElMessage.success(`${item.name || providerMeta(item).name} 连接测试成功`)
} catch {
ElMessage.error('连接测试失败')
// The request layer owns transport and server error presentation.
} finally {
delete pending[key]
}
+278 -100
View File
@@ -1,5 +1,5 @@
<template>
<div class="payment-orders">
<div class="kra-table-box payment-orders">
<header class="page-heading">
<div>
<h2>支付订单</h2>
@@ -8,121 +8,174 @@
<el-button :icon="Refresh" :loading="loading" @click="load()">刷新</el-button>
</header>
<section class="status-summary" aria-label="当前页订单概览">
<div v-for="item in pageSummary" :key="item.label" class="summary-item">
<span>{{ item.label }}</span>
<section class="status-summary" aria-label="筛选范围订单概览">
<div v-for="item in systemSummary" :key="item.label" class="summary-item" :class="`is-${item.tone}`">
<div class="summary-copy"><span>{{ item.label }}</span><small>{{ item.hint }}</small></div>
<strong>{{ item.value }}</strong>
<small>{{ item.hint }}</small>
<el-icon class="summary-icon"><component :is="item.icon" /></el-icon>
</div>
</section>
<div class="kra-search-box filter-band">
<section class="filter-band">
<el-form :model="searchInfo" label-position="top" @submit.prevent="reload">
<div class="filter-grid">
<div class="primary-filter-grid">
<el-form-item label="订单搜索" class="keyword-filter">
<el-input v-model="searchInfo.keyword" clearable placeholder="订单号、渠道单号、业务 ID 或商品标题" :prefix-icon="Search" />
</el-form-item>
<el-form-item label="支付渠道">
<el-select v-model="searchInfo.provider" clearable filterable placeholder="全部渠道">
<el-option v-for="item in providerOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="商户订单号">
<el-input v-model="searchInfo.tradeNo" clearable placeholder="支持模糊查询" />
</el-form-item>
<el-form-item label="业务类型">
<el-input v-model="searchInfo.businessType" clearable placeholder="精确匹配" />
</el-form-item>
<el-form-item label="业务 ID">
<el-input v-model="searchInfo.businessId" clearable placeholder="支持模糊查询" />
</el-form-item>
<el-form-item label="支付状态">
<el-select v-model="searchInfo.paymentStatus" clearable placeholder="全部状态">
<el-option v-for="item in paymentStatusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="退款状态">
<el-select v-model="searchInfo.refundStatus" clearable placeholder="全部状态">
<el-option v-for="item in refundStatusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<div class="filter-actions">
<el-button type="primary" :icon="Search" @click="reload">查询</el-button>
<el-button :icon="Refresh" @click="reset">重置</el-button>
<el-button text :icon="Filter" @click="advancedVisible = !advancedVisible">{{ advancedVisible ? '收起' : '更多筛选' }}</el-button>
</div>
</div>
<el-collapse-transition>
<div v-show="advancedVisible" class="advanced-filter-grid">
<el-form-item label="商户订单号"><el-input v-model="searchInfo.tradeNo" clearable placeholder="支持模糊查询" /></el-form-item>
<el-form-item label="第三方交易号"><el-input v-model="searchInfo.providerTradeNo" clearable placeholder="支付宝、微信等渠道单号" /></el-form-item>
<el-form-item label="业务类型"><el-input v-model="searchInfo.businessType" clearable placeholder="精确匹配" /></el-form-item>
<el-form-item label="业务 ID"><el-input v-model="searchInfo.businessId" clearable placeholder="支持模糊查询" /></el-form-item>
<el-form-item label="退款状态">
<el-select v-model="searchInfo.refundStatus" clearable placeholder="全部状态">
<el-option v-for="item in refundStatusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="创建时间" class="date-filter">
<el-date-picker v-model="searchInfo.createdRange" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" clearable />
</el-form-item>
<el-form-item label="异常订单" class="issue-filter"><el-checkbox v-model="searchInfo.issueOnly">只看需要人工关注的订单</el-checkbox></el-form-item>
</div>
</el-collapse-transition>
<div class="quick-range">
<span>时间快捷筛选</span>
<el-radio-group v-model="quickRange" size="small" @change="applyQuickRange">
<el-radio-button value="today">今天</el-radio-button>
<el-radio-button value="7d">近 7 天</el-radio-button>
<el-radio-button value="30d">近 30 天</el-radio-button>
<el-radio-button value="90d">近 90 天</el-radio-button>
<el-radio-button value="all">全部</el-radio-button>
</el-radio-group>
</div>
</el-form>
</div>
</section>
<div class="kra-table-box order-table-band">
<section class="order-table-band">
<div class="table-heading">
<div><strong>订单明细</strong><span>共 {{ total }} 笔</span></div>
<span v-if="issueCount" class="issue-count">{{ issueCount }} 笔需要关注</span>
</div>
<el-table v-loading="loading" :data="rows" row-key="ID" stripe :row-class-name="rowClassName">
<el-table-column label="订单" min-width="250">
<el-table v-if="!isMobile" v-loading="loading" :data="rows" row-key="ID" :row-class-name="rowClassName">
<el-table-column label="订单与交易" min-width="285">
<template #default="scope">
<div class="order-cell">
<button type="button" class="order-link" @click="openDetail(scope.row)">{{ scope.row.tradeNo || '-' }}</button>
<span>{{ scope.row.subject || '未提供商品标题' }}</span>
<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>{{ scope.row.businessType || '-' }} · {{ scope.row.businessId || '-' }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="业务" min-width="165">
<el-table-column label="商品与业务" min-width="220">
<template #default="scope">
<div class="business-cell"><span>{{ scope.row.businessType || '-' }}</span><small>{{ scope.row.businessId || '-' }}</small></div>
<div class="business-cell"><span>{{ scope.row.subject || '未提供商品标题' }}</span><small>{{ scope.row.businessType || '-' }} · {{ scope.row.businessId || '-' }}</small></div>
</template>
</el-table-column>
<el-table-column label="渠道" width="135">
<template #default="scope">
<div class="provider-cell"><span>{{ providerText(scope.row.provider) }}</span><small>{{ scope.row.provider || '-' }}</small></div>
</template>
</el-table-column>
<el-table-column label="金额" width="145" align="right">
<el-table-column label="金额与结算" width="165" align="right">
<template #default="scope">
<div class="amount-cell">
<strong>{{ formatAmount(scope.row.amount, scope.row.currency) }}</strong>
<small v-if="scope.row.paidAmount">实付 {{ formatAmount(scope.row.paidAmount, scope.row.currency) }}</small>
<small v-if="scope.row.refundedAmount" class="refund-amount">已退 {{ formatAmount(scope.row.refundedAmount, scope.row.currency) }}</small>
</div>
</template>
</el-table-column>
<el-table-column label="支付" width="115" align="center">
<template #default="scope"><el-tag :type="paymentStatusType(scope.row.paymentStatus)">{{ paymentStatusText(scope.row.paymentStatus) }}</el-tag></template>
<el-table-column label="状态" width="230">
<template #default="scope">
<div class="status-stack">
<el-tag size="small" :type="paymentStatusType(scope.row.paymentStatus)">{{ paymentStatusText(scope.row.paymentStatus) }}</el-tag>
<el-tag size="small" :type="fulfillmentStatusType(scope.row.fulfillmentStatus)" effect="plain">{{ fulfillmentStatusText(scope.row.fulfillmentStatus, scope.row) }}</el-tag>
<el-tag v-if="scope.row.refundStatus !== 'none'" size="small" :type="refundStatusType(scope.row.refundStatus)" effect="plain">{{ refundStatusText(scope.row.refundStatus) }}</el-tag>
</div>
</template>
</el-table-column>
<el-table-column label="发货" width="115" align="center">
<template #default="scope"><el-tag :type="fulfillmentStatusType(scope.row.fulfillmentStatus)" effect="plain">{{ fulfillmentStatusText(scope.row.fulfillmentStatus, scope.row) }}</el-tag></template>
<el-table-column label="渠道与时间" width="190">
<template #default="scope">
<div class="provider-cell"><span>{{ providerText(scope.row.provider) }}</span><small>{{ formatDateValue(scope.row.createdAt) }}</small><small v-if="scope.row.paidAt">支付 {{ formatDateValue(scope.row.paidAt) }}</small></div>
</template>
</el-table-column>
<el-table-column label="退款" width="120" align="center">
<template #default="scope"><el-tag :type="refundStatusType(scope.row.refundStatus)" effect="plain">{{ refundStatusText(scope.row.refundStatus) }}</el-tag></template>
</el-table-column>
<el-table-column label="创建时间" width="170">
<template #default="scope">{{ formatDateValue(scope.row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" :fixed="operationFixed" width="310">
<el-table-column label="操作" :fixed="operationFixed" width="154" align="right">
<template #default="scope">
<div class="row-actions">
<el-button link type="primary" :icon="View" :disabled="isRowBusy(scope.row)" @click="openDetail(scope.row)">详情</el-button>
<el-button v-if="canSync(scope.row)" link type="primary" :icon="RefreshRight" :loading="rowAction(scope.row) === 'sync'" :disabled="isRowBusy(scope.row) && rowAction(scope.row) !== 'sync'" @click="syncOrder(scope.row)">同步</el-button>
<el-button v-if="canRefund(scope.row)" link type="warning" :icon="Money" :disabled="isRowBusy(scope.row)" @click="openRefund(scope.row)">退款</el-button>
<el-button v-if="canFulfill(scope.row)" link type="success" :icon="Promotion" :loading="rowAction(scope.row) === 'fulfill'" :disabled="isRowBusy(scope.row) && rowAction(scope.row) !== 'fulfill'" @click="retryFulfillment(scope.row)">{{ fulfillmentActionText(scope.row) }}</el-button>
<el-tooltip content="查看详情"><el-button circle :icon="View" aria-label="查看订单详情" :disabled="isRowBusy(scope.row)" @click="openDetail(scope.row)" /></el-tooltip>
<el-tooltip v-if="canSync(scope.row)" content="同步渠道状态"><el-button circle :icon="RefreshRight" aria-label="同步渠道状态" :loading="rowAction(scope.row) === 'sync'" :disabled="isRowBusy(scope.row) && rowAction(scope.row) !== 'sync'" @click="syncOrder(scope.row)" /></el-tooltip>
<el-dropdown v-if="canRefund(scope.row) || canFulfill(scope.row)" trigger="click" @command="(command) => handleRowCommand(command, scope.row)">
<el-button circle :icon="MoreFilled" :disabled="isRowBusy(scope.row)" aria-label="更多订单操作" />
<template #dropdown><el-dropdown-menu>
<el-dropdown-item command="refund" :disabled="!canRefund(scope.row)" :icon="Money">申请退款</el-dropdown-item>
<el-dropdown-item command="fulfill" :disabled="!canFulfill(scope.row)" :icon="Promotion">{{ fulfillmentActionText(scope.row) }}</el-dropdown-item>
</el-dropdown-menu></template>
</el-dropdown>
</div>
</template>
</el-table-column>
<template #empty><el-empty description="没有符合条件的支付订单" /></template>
</el-table>
<div v-else v-loading="loading" class="mobile-order-list">
<article v-for="order in rows" :key="order.ID" class="mobile-order" :class="{ 'is-payment-issue': isIssueOrder(order) }">
<button type="button" class="mobile-order-main" @click="openDetail(order)">
<span class="mobile-order-title"><strong>{{ order.subject || '未提供商品标题' }}</strong><small>{{ providerText(order.provider) }}</small></span>
<span class="mobile-order-amount">{{ formatAmount(order.amount, order.currency) }}</span>
</button>
<div class="mobile-order-identities"><span>{{ order.tradeNo }}</span><small v-if="order.providerTradeNo">{{ order.providerTradeNo }}</small></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="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="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>
</div>
<div class="mobile-order-actions">
<el-button link type="primary" :icon="View" :disabled="isRowBusy(order)" @click="openDetail(order)">详情</el-button>
<el-button v-if="canSync(order)" link type="primary" :icon="RefreshRight" :loading="rowAction(order) === 'sync'" :disabled="isRowBusy(order) && rowAction(order) !== 'sync'" @click="syncOrder(order)">同步</el-button>
<el-button v-if="canRefund(order)" link type="warning" :icon="Money" :disabled="isRowBusy(order)" @click="openRefund(order)">退款</el-button>
<el-button v-if="canFulfill(order)" link type="success" :icon="Promotion" :loading="rowAction(order) === 'fulfill'" :disabled="isRowBusy(order) && rowAction(order) !== 'fulfill'" @click="retryFulfillment(order)">{{ fulfillmentActionText(order) }}</el-button>
</div>
</div>
</article>
<el-empty v-if="!loading && !rows.length" description="没有符合条件的支付订单" />
</div>
<div class="kra-pagination">
<el-pagination :current-page="page" :page-size="pageSize" :page-sizes="[10, 30, 50, 100]" :total="total" :layout="paginationLayout" @current-change="changePage" @size-change="changeSize" />
</div>
</div>
</section>
<el-drawer v-model="detailVisible" title="支付订单详情" :size="drawerSize" destroy-on-close>
<div v-loading="detailLoading" class="order-detail">
<template v-if="detail">
<div class="detail-toolbar">
<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="fulfillmentStatusType(detail.fulfillmentStatus)" effect="plain">{{ fulfillmentStatusText(detail.fulfillmentStatus, detail) }}</el-tag>
<el-tag :type="refundStatusType(detail.refundStatus)" effect="plain">{{ refundStatusText(detail.refundStatus) }}</el-tag>
</div>
<div class="detail-navigation">
<el-tooltip content="上一笔"><el-button circle :icon="ArrowLeft" aria-label="上一笔订单" :disabled="detailIndex <= 0" @click="navigateDetail(-1)" /></el-tooltip>
<span>{{ detailIndex >= 0 ? `${detailIndex + 1} / ${rows.length}` : '-' }}</span>
<el-tooltip content="下一笔"><el-button circle :icon="ArrowRight" aria-label="下一笔订单" :disabled="detailIndex < 0 || detailIndex >= rows.length - 1" @click="navigateDetail(1)" /></el-tooltip>
</div>
<div class="detail-actions">
<el-button v-if="canSync(detail)" :icon="RefreshRight" :loading="rowAction(detail) === 'sync'" :disabled="isRowBusy(detail) && rowAction(detail) !== 'sync'" @click="syncOrder(detail)">同步状态</el-button>
<el-button v-if="canFulfill(detail)" type="success" plain :icon="Promotion" :loading="rowAction(detail) === 'fulfill'" :disabled="isRowBusy(detail) && rowAction(detail) !== 'fulfill'" @click="retryFulfillment(detail)">{{ fulfillmentActionText(detail) }}</el-button>
@@ -155,9 +208,13 @@
</div>
<el-alert v-if="!detail.amountBreakdownKnown && detail.paymentStatus === 'paid'" type="info" :closable="false" class="amount-alert" title="支付渠道未返回完整的实付、优惠与结算拆分。" />
<h3 class="section-heading">处理时间</h3>
<h3 class="section-heading">支付通知事件与审计日志</h3>
<el-timeline class="order-timeline">
<el-timeline-item v-for="item in detailTimeline" :key="item.label" :timestamp="item.time" :type="item.type">{{ item.label }}</el-timeline-item>
<el-timeline-item v-for="item in detailTimeline" :key="item.key" :timestamp="item.time" :type="item.type">
<div class="event-heading"><strong>{{ item.label }}</strong><el-tag size="small" effect="plain">{{ item.source }}</el-tag></div>
<p v-if="item.message" class="event-message">{{ item.message }}</p>
<div v-if="item.meta.length" class="event-meta"><span v-for="meta in item.meta" :key="meta">{{ meta }}</span></div>
</el-timeline-item>
</el-timeline>
</template>
</div>
@@ -175,6 +232,9 @@
</el-input>
<span class="form-tip">{{ refundPrecisionText }}</span>
</el-form-item>
<el-form-item label="退款原因" prop="reason">
<el-input v-model="refundForm.reason" type="textarea" :rows="3" maxlength="200" show-word-limit placeholder="用于订单事件审计和售后追踪" />
</el-form-item>
</el-form>
<template #footer>
<el-button :disabled="refundSubmitting" @click="refundVisible = false">取消</el-button>
@@ -185,11 +245,11 @@
</template>
<script setup>
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { useWindowSize } from '@vueuse/core'
import { ElMessage, ElMessageBox } from 'element-plus'
import { CopyDocument, Money, Promotion, Refresh, RefreshRight, Search, View } from '@element-plus/icons-vue'
import { fulfillPaymentOrder, getPaymentOrder, getPaymentOrders, queryPaymentOrder, refundPaymentOrder } from '@/api/payment'
import { ArrowLeft, ArrowRight, CircleCheck, CopyDocument, Filter, Money, MoreFilled, Promotion, Refresh, RefreshRight, Search, Tickets, View, Wallet, WarningFilled } from '@element-plus/icons-vue'
import { fulfillPaymentOrder, getPaymentOrder, getPaymentOrders, getPaymentOrderSummary, queryPaymentOrder, refundPaymentOrder } from '@/api/payment'
import { formatDate } from '@/utils/format'
defineOptions({ name: 'PaymentOrders' })
@@ -209,6 +269,17 @@ const REFUND_STATUS_META = {
pending: { label: '渠道处理中', type: 'warning' }, partial: { label: '部分退款', type: 'warning' },
succeeded: { label: '已退款', type: 'success' }, failed: { label: '退款失败', type: 'danger' }
}
const EVENT_META = {
order_created: { label: '订单创建', type: 'primary' },
provider_created: { label: '渠道下单完成', type: 'primary' },
payment_synchronized: { label: '支付状态同步', type: 'success' },
payment_callback: { label: '支付平台通知', type: 'success' },
fulfillment_started: { label: '开始业务发货', type: 'warning' },
fulfillment_finished: { label: '业务发货完成', type: 'success' },
refund_started: { label: '发起退款', type: 'warning' },
refund_submitted: { label: '退款已提交渠道', type: 'warning' },
refund_confirmed: { label: '退款结果确认', type: 'warning' }
}
const providerOptions = [
['alipay', '支付宝'], ['alipay-v3', '支付宝 V3'], ['wechat-v2', '微信支付 V2'], ['wechat-v3', '微信支付 V3'],
['apple-iap', 'Apple IAP'], ['douyin', '抖音支付'], ['qq', 'QQ 钱包'], ['allinpay', '通联支付'],
@@ -216,9 +287,10 @@ const providerOptions = [
['supper-pay', 'Supper Pay'], ['wechat-game-pay', '微信小游戏支付'], ['douyin-game-pay', '抖音小游戏支付'], ['internal', '内部支付']
].map(([value, label]) => ({ value, label }))
const providerNames = Object.fromEntries(providerOptions.map((item) => [item.value, item.label]))
Object.assign(providerNames, { admin: '后台人工操作', client: '客户端', system: '系统任务', internal: '内部支付' })
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 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 FOUR_DECIMAL_CURRENCIES = new Set(['CLF', 'UYW'])
const MAX_SAFE_MINOR_AMOUNT = Number.MAX_SAFE_INTEGER
@@ -228,6 +300,7 @@ const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
const rows = ref([])
const summary = ref({ orderCount: 0, pendingCount: 0, paidCount: 0, refundedCount: 0, issueCount: 0, currencies: [] })
const loading = ref(false)
const detail = ref(null)
const detailVisible = ref(false)
@@ -237,22 +310,32 @@ const refundLoading = ref(false)
const refundSubmitting = ref(false)
const refundFormRef = ref()
const rowActions = reactive({})
const searchInfo = reactive({ provider: '', tradeNo: '', businessType: '', businessId: '', paymentStatus: '', refundStatus: '' })
const refundForm = reactive({ provider: '', tradeNo: '', currency: 'CNY', maxAmount: 0, amountText: '' })
const advancedVisible = ref(width.value >= 720)
const quickRange = ref('7d')
const searchInfo = reactive({ keyword: '', provider: '', tradeNo: '', providerTradeNo: '', businessType: '', businessId: '', paymentStatus: '', refundStatus: '', createdRange: defaultDateRange(7), issueOnly: false })
const refundForm = reactive({ provider: '', tradeNo: '', currency: 'CNY', maxAmount: 0, amountText: '', reason: '' })
let loadRequestID = 0
let detailRequestID = 0
const issueCount = computed(() => rows.value.filter(isIssueOrder).length)
const pageSummary = computed(() => [
{ label: '本页订单', value: rows.value.length, hint: `全部 ${total.value} 笔` },
{ label: '待支付', value: rows.value.filter((row) => ['initialized', 'pending'].includes(row.paymentStatus)).length, hint: '尚未确认收款' },
{ label: '已确认收款', value: rows.value.filter((row) => ['paid', 'partially_refunded', 'refunded'].includes(row.paymentStatus)).length, hint: '含部分或全额退款' },
{ label: '需要关注', value: issueCount.value, hint: '支付、发货或退款异常' }
watch(width, (value, previous) => {
if (value < 720 && previous >= 720) advancedVisible.value = false
})
const issueCount = computed(() => Number(summary.value.issueCount || 0))
const primaryCurrency = computed(() => summary.value.currencies?.[0] || { currency: 'CNY', netAmount: 0, paidAmount: 0, refundedAmount: 0 })
const currencyHint = computed(() => summary.value.currencies?.length > 1 ? `另含 ${summary.value.currencies.length - 1} 种币种` : `已退款 ${formatAmount(primaryCurrency.value.refundedAmount, primaryCurrency.value.currency)}`)
const systemSummary = computed(() => [
{ label: '实际净到账', value: formatAmount(primaryCurrency.value.netAmount, primaryCurrency.value.currency), hint: currencyHint.value, tone: 'success', icon: Wallet },
{ label: '支付成功订单', value: Number(summary.value.paidCount || 0), hint: `待支付 ${Number(summary.value.pendingCount || 0)} 笔`, tone: 'primary', icon: CircleCheck },
{ label: '订单总数', value: Number(summary.value.orderCount || 0), hint: `退款订单 ${Number(summary.value.refundedCount || 0)} 笔`, tone: 'neutral', icon: Tickets },
{ label: '需要关注', value: issueCount.value, hint: '支付、发货或退款异常', tone: 'danger', icon: WarningFilled }
])
const paginationLayout = computed(() => width.value < 720 ? 'total, prev, pager, next' : 'total, sizes, prev, pager, next, jumper')
const operationFixed = computed(() => width.value >= 1180 ? 'right' : false)
const isMobile = computed(() => width.value < 720)
const drawerSize = computed(() => width.value < 720 ? '96%' : '720px')
const detailColumns = computed(() => width.value < 720 ? 1 : 2)
const detailIndex = computed(() => detail.value ? rows.value.findIndex((item) => item.provider === detail.value.provider && item.tradeNo === detail.value.tradeNo) : -1)
const refundPrecisionText = computed(() => currencyMinorDigits(refundForm.currency) === 0 ? '该币种仅支持整数金额' : `最多支持 ${currencyMinorDigits(refundForm.currency)} 位小数`)
const refundAmountPlaceholder = computed(() => currencyMinorDigits(refundForm.currency) === 0 ? '例如 100' : '例如 100.00')
const detailAmounts = computed(() => {
@@ -279,15 +362,27 @@ const detailAmounts = computed(() => {
})
const detailTimeline = computed(() => {
if (!detail.value) return []
if (detail.value.events?.length) return detail.value.events.map((event) => {
const meta = EVENT_META[event.type] || { label: event.type || '支付事件', type: 'info' }
const operator = event.operatorName || (event.operatorId ? `用户 #${event.operatorId}` : '')
return {
key: event.id || `${event.type}-${event.createdAt}`,
label: meta.label, type: meta.type, source: providerText(event.source), message: event.message,
time: formatDateValue(event.createdAt),
meta: [event.providerStatus ? `渠道状态 ${event.providerStatus}` : '', event.amount ? formatAmount(event.amount, event.currency || detail.value.currency) : '', operator, event.clientIp ? `IP ${event.clientIp}` : '', event.deviceId ? `设备 ${event.deviceId}` : ''].filter(Boolean)
}
})
return [
{ label: '订单创建', value: detail.value.createdAt, type: 'primary' },
{ label: '支付确认', value: detail.value.paidAt, type: 'success' },
{ label: '业务发货', value: detail.value.fulfilledAt, type: 'success' },
{ label: '退款确认', value: detail.value.refundedAt, type: 'warning' },
{ label: '最后更新', value: detail.value.updatedAt, type: 'info' }
].filter((item) => item.value).map((item) => ({ ...item, time: formatDateValue(item.value) }))
{ key: 'created', label: '订单创建', value: detail.value.createdAt, type: 'primary', source: 'system', meta: [] },
{ key: 'paid', label: '支付确认', value: detail.value.paidAt, type: 'success', source: detail.value.provider, meta: [] },
{ key: 'fulfilled', label: '业务发货', value: detail.value.fulfilledAt, type: 'success', source: 'system', meta: [] },
{ key: 'refunded', label: '退款确认', value: detail.value.refundedAt, type: 'warning', source: detail.value.provider, meta: [] }
].filter((item) => item.value).map((item) => ({ ...item, source: providerText(item.source), time: formatDateValue(item.value) }))
})
const refundRules = { amountText: [{ required: true, message: '请输入退款金额', trigger: 'blur' }, { validator: validateRefundAmount, trigger: ['blur', 'change'] }] }
const refundRules = {
amountText: [{ required: true, message: '请输入退款金额', trigger: 'blur' }, { validator: validateRefundAmount, trigger: ['blur', 'change'] }],
reason: [{ required: true, message: '请输入退款原因', trigger: 'blur' }, { min: 2, max: 200, message: '退款原因应为 2 至 200 个字符', trigger: 'blur' }]
}
function currencyMinorDigits(currency) {
const code = String(currency || 'CNY').trim().toUpperCase()
@@ -318,6 +413,18 @@ function formatAmountInput(value, currency) {
const whole = Math.floor(amount / scale)
return digits === 0 ? String(whole) : `${whole}.${String(amount % scale).padStart(digits, '0')}`
}
function formatLocalDate(value) {
const year = value.getFullYear()
const month = String(value.getMonth() + 1).padStart(2, '0')
const day = String(value.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
function defaultDateRange(days) {
const end = new Date()
const start = new Date()
start.setDate(start.getDate() - Math.max(0, days - 1))
return [formatLocalDate(start), formatLocalDate(end)]
}
function parseAmountToMinor(value, currency) {
const text = String(value || '').trim()
const digits = currencyMinorDigits(currency)
@@ -351,9 +458,10 @@ function refundStatusType(status) { return REFUND_STATUS_META[status]?.type || '
function paymentModeText(mode) { return mode === 'internal' ? '内部支付' : mode === 'external' ? '外部渠道' : mode || '-' }
function formatDateValue(value) { return value ? formatDate(value) || '-' : '-' }
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 canFulfill(order) { return order?.paymentStatus === 'paid' && ['pending', 'processing', 'failed'].includes(order?.fulfillmentStatus) }
function canSync(order) { return ['initialized', 'pending', 'paid', 'partially_refunded', 'refunded', 'failed'].includes(order?.paymentStatus) }
function isDemoOrder(order) { return order?.businessType === 'demo_subscription' && String(order?.tradeNo || '').startsWith('DEMO-PAY-') }
function canRefund(order) { return !isDemoOrder(order) && ['paid', 'partially_refunded'].includes(order?.paymentStatus) && ['none', 'partial', 'failed'].includes(order?.refundStatus) && remainingRefundAmount(order) > 0 }
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 isIssueOrder(order) { return order?.paymentStatus === 'failed' || order?.fulfillmentStatus === 'failed' || order?.refundStatus === 'failed' || Boolean(order?.lastError) }
function rowClassName({ row }) { return isIssueOrder(row) ? 'is-payment-issue' : '' }
@@ -362,19 +470,35 @@ function rowAction(order) { return rowActions[actionKey(order)] || '' }
function isRowBusy(order) { return Boolean(rowAction(order)) }
function setRowAction(order, action) { const key = actionKey(order); if (action) rowActions[key] = action; else delete rowActions[key] }
function requestFilters() {
return Object.fromEntries(Object.entries(searchInfo).filter(([, value]) => typeof value === 'string' && value.trim()).map(([key, value]) => [key, value.trim()]))
const filters = Object.fromEntries(Object.entries(searchInfo)
.filter(([key, value]) => key !== 'createdRange' && ((typeof value === 'string' && value.trim()) || value === true))
.map(([key, value]) => [key, typeof value === 'string' ? value.trim() : value]))
if (searchInfo.createdRange?.length === 2) {
filters.createdFrom = searchInfo.createdRange[0]
filters.createdTo = searchInfo.createdRange[1]
}
return filters
}
async function load(options = {}) {
const requestID = ++loadRequestID
if (!options.silent) loading.value = true
try {
const res = await getPaymentOrders({ page: page.value, pageSize: pageSize.value, ...requestFilters() })
if (requestID !== loadRequestID || res.code !== 0) return
rows.value = res.data?.list || []
total.value = Number(res.data?.total || 0)
page.value = Number(res.data?.page || page.value)
pageSize.value = Number(res.data?.pageSize || pageSize.value)
const filters = requestFilters()
const [ordersResult, summaryResult] = await Promise.allSettled([
getPaymentOrders({ page: page.value, pageSize: pageSize.value, ...filters }),
getPaymentOrderSummary(filters)
])
if (requestID !== loadRequestID) return
const ordersRes = ordersResult.status === 'fulfilled' ? ordersResult.value : null
const summaryRes = summaryResult.status === 'fulfilled' ? summaryResult.value : null
if (ordersRes?.code === 0) {
rows.value = ordersRes.data?.list || []
total.value = Number(ordersRes.data?.total || 0)
page.value = Number(ordersRes.data?.page || page.value)
pageSize.value = Number(ordersRes.data?.pageSize || pageSize.value)
}
if (summaryRes?.code === 0 && summaryRes.data) summary.value = summaryRes.data
} catch {
// The request layer already presents transport errors.
} finally {
@@ -382,7 +506,16 @@ async function load(options = {}) {
}
}
function reload() { page.value = 1; load() }
function reset() { Object.assign(searchInfo, { provider: '', tradeNo: '', businessType: '', businessId: '', paymentStatus: '', refundStatus: '' }); reload() }
function reset() {
quickRange.value = '7d'
Object.assign(searchInfo, { keyword: '', provider: '', tradeNo: '', providerTradeNo: '', businessType: '', businessId: '', paymentStatus: '', refundStatus: '', createdRange: defaultDateRange(7), issueOnly: false })
reload()
}
function applyQuickRange(value) {
if (value === 'all') searchInfo.createdRange = []
else searchInfo.createdRange = defaultDateRange(value === 'today' ? 1 : Number.parseInt(value, 10))
reload()
}
function changePage(value) { page.value = value; load() }
function changeSize(value) { pageSize.value = value; page.value = 1; load() }
async function loadDetail(order) {
@@ -401,11 +534,19 @@ async function loadDetail(order) {
}
}
function openDetail(order) { detail.value = { ...order }; detailVisible.value = true; loadDetail(order) }
function navigateDetail(offset) {
const next = rows.value[detailIndex.value + offset]
if (next) openDetail(next)
}
function handleRowCommand(command, order) {
if (command === 'refund' && canRefund(order)) openRefund(order)
if (command === 'fulfill' && canFulfill(order)) retryFulfillment(order)
}
async function refreshVisibleDetail(order) {
if (detailVisible.value && detail.value?.provider === order.provider && detail.value?.tradeNo === order.tradeNo) await loadDetail(order)
}
async function syncOrder(order) {
if (isRowBusy(order)) return
if (isRowBusy(order) || !canSync(order)) return
setRowAction(order, 'sync')
try {
const res = await queryPaymentOrder({ provider: order.provider, tradeNo: order.tradeNo })
@@ -435,8 +576,9 @@ async function retryFulfillment(order) {
} finally { setRowAction(order, '') }
}
function openRefund(order) {
if (isRowBusy(order) || !canRefund(order)) return
const maxAmount = remainingRefundAmount(order)
Object.assign(refundForm, { provider: order.provider, tradeNo: order.tradeNo, currency: order.currency || 'CNY', maxAmount, amountText: formatAmountInput(maxAmount, order.currency || 'CNY') })
Object.assign(refundForm, { provider: order.provider, tradeNo: order.tradeNo, currency: order.currency || 'CNY', maxAmount, amountText: formatAmountInput(maxAmount, order.currency || 'CNY'), reason: '' })
refundVisible.value = true
nextTick(() => refundFormRef.value?.clearValidate())
}
@@ -454,7 +596,7 @@ async function submitRefund() {
refundLoading.value = true
const order = { provider: refundForm.provider, tradeNo: refundForm.tradeNo }
try {
const res = await refundPaymentOrder({ provider: refundForm.provider, tradeNo: refundForm.tradeNo, amount })
const res = await refundPaymentOrder({ provider: refundForm.provider, tradeNo: refundForm.tradeNo, amount, reason: refundForm.reason.trim() })
if (res.code !== 0) return
ElMessage.success(res.data?.refundStatus === 'succeeded' ? '退款已完成' : '退款申请已提交,等待渠道确认')
refundVisible.value = false
@@ -477,35 +619,66 @@ onMounted(load)
.page-heading { display: flex; align-items: center; justify-content: space-between; gap: 18px; margin-bottom: 16px; }
.page-heading h2 { margin: 0; color: var(--el-text-color-primary); font-size: 20px; font-weight: 600; letter-spacing: 0; }
.page-heading p { margin: 5px 0 0; color: var(--el-text-color-secondary); font-size: 13px; }
.status-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px; overflow: hidden; margin-bottom: 16px; border: 1px solid var(--el-border-color-lighter); border-radius: 6px; background: var(--el-border-color-lighter); }
.summary-item { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 3px 10px; min-width: 0; padding: 14px 16px; background: var(--el-bg-color); }
.summary-item span { color: var(--el-text-color-regular); font-size: 13px; }
.summary-item strong { grid-row: span 2; color: var(--el-text-color-primary); font-size: 24px; font-weight: 600; line-height: 1; }
.status-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; margin-bottom: 16px; }
.summary-item { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; min-width: 0; min-height: 76px; overflow: hidden; padding: 13px 48px 13px 15px; border: 1px solid var(--el-border-color-lighter); border-radius: 6px; background: var(--el-bg-color); }
.summary-item::before { position: absolute; inset: 0 auto 0 0; width: 3px; background: var(--summary-color); content: ''; }
.summary-copy { display: grid; min-width: 0; gap: 5px; }
.summary-item span { color: var(--el-text-color-regular); font-size: 12px; }
.summary-item strong { color: var(--el-text-color-primary); font-size: 22px; font-weight: 650; line-height: 1; white-space: nowrap; }
.summary-item small { overflow: hidden; color: var(--el-text-color-secondary); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.summary-icon { position: absolute; top: 12px; right: 13px; width: 27px; height: 27px; border-radius: 6px; background: color-mix(in srgb, var(--summary-color) 12%, transparent); color: var(--summary-color); font-size: 16px; }
.summary-item.is-success { --summary-color: var(--el-color-success); }
.summary-item.is-primary { --summary-color: var(--el-color-primary); }
.summary-item.is-neutral { --summary-color: var(--el-color-info); }
.summary-item.is-danger { --summary-color: var(--el-color-danger); }
.filter-band { margin-bottom: 16px; }
.filter-grid { display: grid; grid-template-columns: repeat(3, minmax(160px, 1fr)); gap: 0 14px; }
.filter-grid :deep(.el-form-item) { margin-bottom: 12px; }
.filter-grid :deep(.el-select), .filter-grid :deep(.el-input) { width: 100%; }
.primary-filter-grid { display: grid; grid-template-columns: minmax(280px, 2fr) minmax(160px, 1fr) minmax(160px, 1fr) auto; gap: 0 14px; }
.advanced-filter-grid { display: grid; grid-template-columns: repeat(4, minmax(160px, 1fr)); gap: 0 14px; padding-top: 3px; border-top: 1px solid var(--el-border-color-lighter); }
.primary-filter-grid :deep(.el-form-item), .advanced-filter-grid :deep(.el-form-item) { margin-bottom: 12px; }
.primary-filter-grid :deep(.el-select), .primary-filter-grid :deep(.el-input), .advanced-filter-grid :deep(.el-select), .advanced-filter-grid :deep(.el-input) { width: 100%; }
.issue-filter { align-self: end; }
.date-filter :deep(.el-date-editor) { width: 100%; }
.filter-actions { display: flex; align-items: flex-end; gap: 8px; padding-bottom: 12px; }
.quick-range { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; padding-top: 10px; border-top: 1px solid var(--el-border-color-lighter); color: var(--el-text-color-secondary); font-size: 12px; }
.order-table-band { min-width: 0; overflow: hidden; }
.table-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding-bottom: 12px; }
.table-heading > div { display: flex; align-items: baseline; gap: 9px; }
.table-heading strong { color: var(--el-text-color-primary); font-size: 15px; font-weight: 600; }
.table-heading span { color: var(--el-text-color-secondary); font-size: 12px; }
.issue-count { color: var(--el-color-danger) !important; }
.order-cell, .business-cell, .provider-cell, .amount-cell { display: grid; min-width: 0; gap: 4px; }
.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-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; }
.business-cell > span, .provider-cell > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.amount-cell { justify-items: end; }
.amount-cell strong { color: var(--el-text-color-primary); font-weight: 600; white-space: nowrap; }
.row-actions { display: flex; align-items: center; min-height: 32px; white-space: nowrap; }
.row-actions :deep(.el-button + .el-button) { margin-left: 8px; }
.refund-amount { color: var(--el-color-danger) !important; }
.status-stack { display: flex; align-items: center; flex-wrap: wrap; gap: 5px; min-height: 24px; }
.row-actions { display: flex; align-items: center; justify-content: flex-end; gap: 6px; min-height: 32px; white-space: nowrap; }
.row-actions :deep(.el-button + .el-button) { margin-left: 0; }
.payment-orders :deep(.el-table__cell) { padding-top: 12px; padding-bottom: 12px; }
.payment-orders :deep(.el-table__row.is-payment-issue > td.el-table__cell) { background: var(--el-color-danger-light-9); }
.mobile-order-list { display: grid; gap: 10px; min-height: 160px; }
.mobile-order { overflow: hidden; border: 1px solid var(--el-border-color-lighter); border-radius: 6px; background: var(--el-bg-color); }
.mobile-order.is-payment-issue { border-color: var(--el-color-danger-light-5); background: var(--el-color-danger-light-9); }
.mobile-order-main { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: 12px; width: 100%; padding: 13px 13px 8px; border: 0; background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; }
.mobile-order-title { display: grid; min-width: 0; gap: 4px; }
.mobile-order-title strong { overflow: hidden; color: var(--el-text-color-primary); font-size: 14px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
.mobile-order-title small { color: var(--el-text-color-secondary); font-size: 11px; }
.mobile-order-amount { color: var(--el-text-color-primary); font-size: 15px; font-weight: 650; white-space: nowrap; }
.mobile-order-identities { display: grid; gap: 3px; padding: 0 13px 9px; color: var(--el-color-primary); font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 11px; word-break: break-all; }
.mobile-order-identities small { color: var(--el-text-color-secondary); font-size: 10px; }
.mobile-order-meta { display: flex; justify-content: space-between; gap: 10px; padding: 8px 13px; border-top: 1px solid var(--el-border-color-lighter); color: var(--el-text-color-secondary); font-size: 11px; }
.mobile-order-meta span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mobile-order-footer { display: grid; gap: 8px; padding: 9px 13px 10px; border-top: 1px solid var(--el-border-color-lighter); }
.mobile-order-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }
.mobile-order-actions :deep(.el-button + .el-button) { margin-left: 0; }
.kra-pagination { overflow-x: auto; }
.order-detail { min-height: 220px; }
.detail-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding-bottom: 16px; border-bottom: 1px solid var(--el-border-color-lighter); }
.detail-statuses, .detail-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.detail-statuses, .detail-actions, .detail-navigation { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.detail-navigation { margin-left: auto; color: var(--el-text-color-secondary); font-size: 12px; }
.detail-error { margin-top: 16px; }
.section-heading { margin: 22px 0 12px; color: var(--el-text-color-primary); font-size: 14px; font-weight: 600; letter-spacing: 0; }
.copy-value { display: inline-flex; align-items: center; gap: 4px; max-width: 100%; word-break: break-all; }
@@ -515,11 +688,16 @@ onMounted(load)
.amount-item strong { overflow: hidden; color: var(--el-text-color-primary); font-size: 14px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
.amount-alert { margin-top: 12px; }
.order-timeline { margin: 0; padding-top: 4px; }
.event-heading { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; color: var(--el-text-color-primary); }
.event-heading strong { font-size: 13px; font-weight: 600; }
.event-message { margin: 6px 0 0; color: var(--el-text-color-regular); font-size: 12px; line-height: 1.55; }
.event-meta { display: flex; flex-wrap: wrap; gap: 5px 12px; margin-top: 6px; color: var(--el-text-color-secondary); font-size: 11px; }
.refund-summary { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px 14px; margin-bottom: 18px; padding: 12px 14px; border: 1px solid var(--el-border-color-lighter); border-radius: 6px; background: var(--el-fill-color-lighter); }
.refund-summary span { color: var(--el-text-color-secondary); font-size: 12px; }
.refund-summary strong { overflow-wrap: anywhere; color: var(--el-text-color-primary); font-size: 13px; text-align: right; }
.form-tip { width: 100%; margin-top: 5px; color: var(--el-text-color-secondary); font-size: 12px; line-height: 1.4; }
@media (max-width: 1000px) { .filter-grid { grid-template-columns: repeat(2, minmax(160px, 1fr)); } .amount-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 720px) { .page-heading { align-items: flex-start; } .page-heading p { max-width: 250px; } .status-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); } .summary-item { padding: 12px; } .summary-item strong { font-size: 20px; } .filter-grid { grid-template-columns: 1fr; } .filter-actions { align-items: stretch; padding-bottom: 4px; } .filter-actions :deep(.el-button) { flex: 1; } .detail-toolbar { align-items: stretch; flex-direction: column; } .detail-actions :deep(.el-button) { margin-left: 0; } .amount-grid { grid-template-columns: 1fr; } }
@media (max-width: 440px) { .status-summary { grid-template-columns: 1fr; } .table-heading { align-items: flex-start; flex-direction: column; gap: 5px; } .refund-summary { grid-template-columns: 1fr; } .refund-summary strong { text-align: left; } }
@media (max-width: 1100px) { .primary-filter-grid { grid-template-columns: repeat(2, minmax(180px, 1fr)); } .advanced-filter-grid { grid-template-columns: repeat(2, minmax(160px, 1fr)); } .filter-actions { align-items: stretch; } .amount-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 720px) { .page-heading { align-items: flex-start; } .page-heading p { max-width: 250px; } .status-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } .summary-item { grid-template-columns: 1fr; align-content: center; gap: 7px; min-height: 82px; padding: 11px 40px 11px 12px; } .summary-copy { gap: 3px; } .summary-item strong { font-size: 19px; } .summary-icon { top: 10px; right: 10px; } .primary-filter-grid, .advanced-filter-grid { grid-template-columns: 1fr; } .filter-actions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); padding-bottom: 4px; } .filter-actions :deep(.el-button) { width: 100%; margin: 0; } .quick-range { align-items: stretch; flex-direction: column; } .quick-range :deep(.el-radio-group) { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); width: 100%; } .quick-range :deep(.el-radio-button__inner) { width: 100%; } .table-heading { padding-bottom: 10px; } .detail-toolbar { align-items: stretch; flex-direction: column; } .detail-navigation { justify-content: center; order: 3; margin-left: 0; } .detail-actions :deep(.el-button) { margin-left: 0; } .amount-grid { grid-template-columns: 1fr; } }
@media (max-width: 440px) { .summary-item { min-height: 64px; } .table-heading { align-items: flex-start; flex-direction: column; gap: 5px; } .mobile-order-meta { align-items: flex-start; flex-direction: column; gap: 4px; } .refund-summary { grid-template-columns: 1fr; } .refund-summary strong { text-align: left; } }
@media (max-width: 360px) { .status-summary { grid-template-columns: 1fr; } }
</style>