Compare commits
3
Commits
3d108ebdfe
...
e0c35e4e9b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0c35e4e9b | ||
|
|
509df03d61 | ||
|
|
b6d25c1a0f |
@@ -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
|
||||
|
||||
@@ -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
@@ -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`;
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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{})
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(¤t).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
|
||||
}
|
||||
|
||||
@@ -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, "退款处理")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(¤t).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(¤t).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(¤t).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(¤t).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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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": "******",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: "获取安全配置"},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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{}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ var ErrUnavailable = errors.New("message broker unavailable")
|
||||
|
||||
const (
|
||||
ProviderEMQX = "emqx"
|
||||
ProviderKafka = "kafka"
|
||||
ProviderRabbitMQ = "rabbitmq"
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user