优化结构
This commit is contained in:
parent
a2ce3ae218
commit
e4ba0dced9
|
|
@ -154,14 +154,15 @@ func wireApp(confServer *conf.Server, runtime *conf.Runtime, logger *slog.Logger
|
||||||
routes := router.NewRoutes(v)
|
routes := router.NewRoutes(v)
|
||||||
taskMethods := worker.NewTaskMethods(taskUsecase, mediaUsecase, runtime)
|
taskMethods := worker.NewTaskMethods(taskUsecase, mediaUsecase, runtime)
|
||||||
moduleRuntime := app.Runtime(routes, taskMethods, registry)
|
moduleRuntime := app.Runtime(routes, taskMethods, registry)
|
||||||
websocketServer, cleanup2, err := websocket.New(runtime)
|
store := data.NewIntegrationRuntime(dataData)
|
||||||
|
websocketServer, cleanup2, err := websocket.New(store)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cleanup()
|
cleanup()
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
engine := server.NewGinEngineWithRuntime(runtime, accessControlService, authService, securityService, auditRecorder, logger, string2, moduleRuntime, websocketServer)
|
engine := server.NewGinEngineWithRuntime(runtime, accessControlService, authService, securityService, auditRecorder, logger, string2, moduleRuntime, websocketServer)
|
||||||
httpServer := server.NewGinServer(confServer, engine)
|
httpServer := server.NewGinServer(confServer, engine)
|
||||||
mqReloadable, cleanup3, err := mq.New(runtime, logger)
|
mqReloadable, cleanup3, err := mq.New(store, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cleanup2()
|
cleanup2()
|
||||||
cleanup()
|
cleanup()
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -13,7 +13,7 @@
|
||||||
| 支付 provider/mode 标识 | `pkg/paymentkit` | provider 常量、支持列表、金额/签名/JSON 等跨模块协议;system `biz` 只保留兼容别名。 |
|
| 支付 provider/mode 标识 | `pkg/paymentkit` | provider 常量、支持列表、金额/签名/JSON 等跨模块协议;system `biz` 只保留兼容别名。 |
|
||||||
| 支付回调 ACK | `pkg/paymentkit` | 回调应答、失败包装和默认 provider 应答;具体渠道 SDK 仍留在 system integration。 |
|
| 支付回调 ACK | `pkg/paymentkit` | 回调应答、失败包装和默认 provider 应答;具体渠道 SDK 仍留在 system integration。 |
|
||||||
| WebSocket 通用收发 | `pkg/websocket` | Melody 的连接、事件、点对点发送、广播和会话查询封装;system integration 管理配置与生命周期。 |
|
| WebSocket 通用收发 | `pkg/websocket` | Melody 的连接、事件、点对点发送、广播和会话查询封装;system integration 管理配置与生命周期。 |
|
||||||
| 消息队列 | `pkg/mq` | Broker 无关的发布、订阅、JSON 和 QoS 接口;EMQX/Paho 客户端由 system integration 管理。 |
|
| 消息队列 | `pkg/mq` | Broker 无关的发布、订阅、JSON 和 QoS 接口;EMQX/Paho 与 RabbitMQ/AMQP 客户端由 system integration 管理。 |
|
||||||
| 模块、任务和迁移协议 | `pkg/module`、`pkg/task`、`pkg/database/migration` | 供不同业务模块注册贡献,不带 system 业务语义。 |
|
| 模块、任务和迁移协议 | `pkg/module`、`pkg/task`、`pkg/database/migration` | 供不同业务模块注册贡献,不带 system 业务语义。 |
|
||||||
|
|
||||||
## system 内部保留边界
|
## system 内部保留边界
|
||||||
|
|
@ -23,7 +23,7 @@
|
||||||
- `conf`:system 配置 proto、运行时快照和生成代码。
|
- `conf`:system 配置 proto、运行时快照和生成代码。
|
||||||
- `data`:数据库连接、PO、仓储、system 表、支付持久化和配置 watcher。
|
- `data`:数据库连接、PO、仓储、system 表、支付持久化和配置 watcher。
|
||||||
- `initialize`:首次安装、配置迁移、种子编排和运行时重载。
|
- `initialize`:首次安装、配置迁移、种子编排和运行时重载。
|
||||||
- `integration`:Redis、邮件、存储、支付、WebSocket 和 EMQX 的 provider 生命周期。
|
- `integration`:Redis、邮件、存储、支付、WebSocket、EMQX 和 RabbitMQ 的 provider 生命周期。
|
||||||
- `security`:JWT claims、签发/解析和后台安全实现。
|
- `security`:JWT claims、签发/解析和后台安全实现。
|
||||||
- `service`:HTTP DTO(`service/dto`)、DTO 与 DO 转换、应用服务和路由元数据。
|
- `service`:HTTP DTO(`service/dto`)、DTO 与 DO 转换、应用服务和路由元数据。
|
||||||
- `server`:Gin 生命周期;handler、middleware、router、HTTP 适配按子包维护。
|
- `server`:Gin 生命周期;handler、middleware、router、HTTP 适配按子包维护。
|
||||||
|
|
|
||||||
2
go.mod
2
go.mod
|
|
@ -28,6 +28,7 @@ require (
|
||||||
github.com/mojocn/base64Captcha v1.3.8
|
github.com/mojocn/base64Captcha v1.3.8
|
||||||
github.com/olahol/melody v1.4.0
|
github.com/olahol/melody v1.4.0
|
||||||
github.com/qiniu/go-sdk/v7 v7.25.2
|
github.com/qiniu/go-sdk/v7 v7.25.2
|
||||||
|
github.com/rabbitmq/amqp091-go v1.14.0
|
||||||
github.com/redis/go-redis/v9 v9.7.0
|
github.com/redis/go-redis/v9 v9.7.0
|
||||||
github.com/robfig/cron/v3 v3.0.1
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
github.com/shirou/gopsutil/v4 v4.25.7
|
github.com/shirou/gopsutil/v4 v4.25.7
|
||||||
|
|
@ -143,7 +144,6 @@ require (
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||||
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect
|
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect
|
||||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
||||||
github.com/rabbitmq/amqp091-go v1.14.0 // indirect
|
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/richardlehane/mscfb v1.0.4 // indirect
|
github.com/richardlehane/mscfb v1.0.4 // indirect
|
||||||
github.com/richardlehane/msoleps v1.0.4 // indirect
|
github.com/richardlehane/msoleps v1.0.4 // indirect
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,10 @@
|
||||||
|
|
||||||
- `app`:组合根、system 模块定义和任务/路由运行时组合
|
- `app`:组合根、system 模块定义和任务/路由运行时组合
|
||||||
- `biz`:系统领域对象、用例和仓储接口
|
- `biz`:系统领域对象、用例和仓储接口
|
||||||
- `conf`:配置 proto 与运行时配置解析
|
- `conf`:基础配置 proto 与运行时配置解析
|
||||||
- `data`:数据库生命周期、系统仓储、系统表和支付持久化
|
- `data`:数据库生命周期、系统仓储、系统表和支付持久化
|
||||||
- `initialize`:数据库首次初始化和系统种子数据编排
|
- `initialize`:数据库首次初始化和系统种子数据编排
|
||||||
- `integration`:Redis、邮件、对象存储、支付、WebSocket 和 EMQX 适配器
|
- `integration`:Redis、邮件、对象存储、支付、WebSocket、EMQX 和 RabbitMQ 适配器
|
||||||
- `security`:后台 JWT 等安全实现
|
- `security`:后台 JWT 等安全实现
|
||||||
- `server`:Gin server 组合与生命周期;横切 HTTP 代码按子包维护:
|
- `server`:Gin server 组合与生命周期;横切 HTTP 代码按子包维护:
|
||||||
`server/handler`、`server/middleware`、`server/router`、`server/httpx`
|
`server/handler`、`server/middleware`、`server/router`、`server/httpx`
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,22 @@ import (
|
||||||
// Definition describes the built-in system contribution to the application
|
// Definition describes the built-in system contribution to the application
|
||||||
// catalog. Other business modules can expose the same shape independently.
|
// catalog. Other business modules can expose the same shape independently.
|
||||||
func Definition() module.Definition {
|
func Definition() module.Definition {
|
||||||
|
surface := datapayment.AdminSurface()
|
||||||
|
communication := module.Surface{
|
||||||
|
Menus: []module.Menu{{Name: "integrationConfig", Path: "integrationConfig", ParentName: "extensions", Component: "view/systemTools/integration/config.vue", Title: "通信集成", Icon: "connection", Sort: 8}},
|
||||||
|
APIs: []module.API{
|
||||||
|
{Path: "/integration/configs/:kind", Method: "GET", Group: "集成配置", Description: "按类型获取集成配置"},
|
||||||
|
{Path: "/integration/configs/:kind/:provider", Method: "GET", Group: "集成配置", Description: "获取指定集成配置"},
|
||||||
|
{Path: "/integration/configs/:kind/:provider", Method: "PUT", Group: "集成配置", Description: "保存集成配置"},
|
||||||
|
{Path: "/integration/configs/:kind/:provider", Method: "DELETE", Group: "集成配置", Description: "删除集成配置"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
surface.Menus = append(surface.Menus, communication.Menus...)
|
||||||
|
surface.APIs = append(surface.APIs, communication.APIs...)
|
||||||
return module.Definition{
|
return module.Definition{
|
||||||
Name: "system",
|
Name: "system",
|
||||||
Migrations: append(datasystem.Migrations(), datapayment.Migrations()...),
|
Migrations: append(datasystem.Migrations(), datapayment.Migrations()...),
|
||||||
Surface: datapayment.AdminSurface(),
|
Surface: surface,
|
||||||
TimedTasks: []module.TimedTask{
|
TimedTasks: []module.TimedTask{
|
||||||
{Name: "ClearDB", Description: "定时清理数据库过期日志(操作记录/JWT黑名单/定时任务执行日志)", Spec: "@daily", MethodName: "ClearDB", Enabled: true},
|
{Name: "ClearDB", Description: "定时清理数据库过期日志(操作记录/JWT黑名单/定时任务执行日志)", Spec: "@daily", MethodName: "ClearDB", Enabled: true},
|
||||||
{Name: "CleanStaleUploads", Description: "定时清理过期大文件上传会话", Spec: "@hourly", MethodName: "CleanStaleUploads", Enabled: true},
|
{Name: "CleanStaleUploads", Description: "定时清理过期大文件上传会话", Spec: "@hourly", MethodName: "CleanStaleUploads", Enabled: true},
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
package app
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestDefinitionIncludesCommunicationIntegrationSurface(t *testing.T) {
|
||||||
|
surface := Definition().Surface
|
||||||
|
menuFound := false
|
||||||
|
for _, menu := range surface.Menus {
|
||||||
|
if menu.Name == "integrationConfig" {
|
||||||
|
menuFound = menu.ParentName == "extensions" && menu.Component == "view/systemTools/integration/config.vue"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !menuFound {
|
||||||
|
t.Fatal("communication integration menu is missing")
|
||||||
|
}
|
||||||
|
apiFound := false
|
||||||
|
for _, api := range surface.APIs {
|
||||||
|
if api.Method == "PUT" && api.Path == "/integration/configs/:kind/:provider" {
|
||||||
|
apiFound = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !apiFound {
|
||||||
|
t.Fatal("communication integration save API is missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
@ -195,8 +196,8 @@ func validateCommunicationIntegrationConfig(kind, provider string, values map[st
|
||||||
return errors.New("rabbitmq port 必须在 1-65535 之间")
|
return errors.New("rabbitmq port 必须在 1-65535 之间")
|
||||||
}
|
}
|
||||||
exchangeType := strings.ToLower(integrationText(values, "exchange_type"))
|
exchangeType := strings.ToLower(integrationText(values, "exchange_type"))
|
||||||
if exchangeType != "direct" && exchangeType != "fanout" && exchangeType != "topic" && exchangeType != "headers" {
|
if exchangeType != "direct" && exchangeType != "fanout" && exchangeType != "topic" {
|
||||||
return errors.New("rabbitmq exchange_type 必须是 direct、fanout、topic 或 headers")
|
return errors.New("rabbitmq exchange_type 必须是 direct、fanout 或 topic")
|
||||||
}
|
}
|
||||||
if integrationInt64(values, "prefetch_count", -1) < 0 {
|
if integrationInt64(values, "prefetch_count", -1) < 0 {
|
||||||
return errors.New("rabbitmq prefetch_count 不能小于 0")
|
return errors.New("rabbitmq prefetch_count 不能小于 0")
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
package biz
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestCommunicationIntegrationDefinitionsAndValidation(t *testing.T) {
|
||||||
|
for _, target := range []struct{ kind, provider string }{
|
||||||
|
{IntegrationKindMQ, "emqx"},
|
||||||
|
{IntegrationKindMQ, "rabbitmq"},
|
||||||
|
{IntegrationKindWebSocket, "melody"},
|
||||||
|
} {
|
||||||
|
values := DefaultIntegrationConfig(target.kind, target.provider)
|
||||||
|
if len(values) == 0 {
|
||||||
|
t.Fatalf("default config missing for %s/%s", target.kind, target.provider)
|
||||||
|
}
|
||||||
|
if err := ValidateIntegrationConfig(target.kind, target.provider, values); err != nil {
|
||||||
|
t.Fatalf("default config invalid for %s/%s: %v", target.kind, target.provider, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
websocket := DefaultIntegrationConfig(IntegrationKindWebSocket, "melody")
|
||||||
|
websocket["path"] = "ws"
|
||||||
|
if err := ValidateIntegrationConfig(IntegrationKindWebSocket, "melody", websocket); err == nil {
|
||||||
|
t.Fatal("invalid websocket path was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -113,7 +113,7 @@ var integrationDefinitions = map[string][]IntegrationConfigDefinition{
|
||||||
{Key: "password", Label: "密码", Type: "password", Required: true, Secret: true},
|
{Key: "password", Label: "密码", Type: "password", Required: true, Secret: true},
|
||||||
{Key: "vhost", Label: "Virtual Host", Type: "text", Required: true, Placeholder: "/"},
|
{Key: "vhost", Label: "Virtual Host", Type: "text", Required: true, Placeholder: "/"},
|
||||||
{Key: "exchange", Label: "Exchange", Type: "text", Required: true, Placeholder: "kra"},
|
{Key: "exchange", Label: "Exchange", Type: "text", Required: true, Placeholder: "kra"},
|
||||||
{Key: "exchange_type", Label: "Exchange 类型", Type: "select", Required: true, Options: []IntegrationConfigOption{{Label: "topic", Value: "topic"}, {Label: "direct", Value: "direct"}, {Label: "fanout", Value: "fanout"}, {Label: "headers", Value: "headers"}}},
|
{Key: "exchange_type", Label: "Exchange 类型", Type: "select", Required: true, Options: []IntegrationConfigOption{{Label: "topic", Value: "topic"}, {Label: "direct", Value: "direct"}, {Label: "fanout", Value: "fanout"}}},
|
||||||
{Key: "queue", Label: "Queue", Type: "text", Required: true, Placeholder: "kra"},
|
{Key: "queue", Label: "Queue", Type: "text", Required: true, Placeholder: "kra"},
|
||||||
{Key: "routing_key", Label: "默认 Routing Key", Type: "text", Required: true, Placeholder: "#", Description: "业务未指定订阅键时使用;topic 类型支持 * 和 #。"},
|
{Key: "routing_key", Label: "默认 Routing Key", Type: "text", Required: true, Placeholder: "#", Description: "业务未指定订阅键时使用;topic 类型支持 * 和 #。"},
|
||||||
{Key: "durable", Label: "持久化", Type: "switch"},
|
{Key: "durable", Label: "持久化", Type: "switch"},
|
||||||
|
|
|
||||||
|
|
@ -194,8 +194,6 @@ func (d *Data) persistConfigValuesLocked(dataConfig *conf.Data, adminConfig *con
|
||||||
}
|
}
|
||||||
deleteYAMLMapping(&document, "admin", "storage")
|
deleteYAMLMapping(&document, "admin", "storage")
|
||||||
deleteYAMLMapping(&document, "admin", "email")
|
deleteYAMLMapping(&document, "admin", "email")
|
||||||
deleteYAMLMapping(&document, "admin", "websocket")
|
|
||||||
deleteYAMLMapping(&document, "admin", "mq")
|
|
||||||
if adminConfig.System != nil {
|
if adminConfig.System != nil {
|
||||||
if err = setServerHTTPPort(&document, adminConfig.System.Addr); err != nil {
|
if err = setServerHTTPPort(&document, adminConfig.System.Addr); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -255,8 +253,6 @@ func (d *Data) persistDatabaseConfig(database *conf.Data_Database, signingKey st
|
||||||
}
|
}
|
||||||
deleteYAMLMapping(&document, "admin", "storage")
|
deleteYAMLMapping(&document, "admin", "storage")
|
||||||
deleteYAMLMapping(&document, "admin", "email")
|
deleteYAMLMapping(&document, "admin", "email")
|
||||||
deleteYAMLMapping(&document, "admin", "websocket")
|
|
||||||
deleteYAMLMapping(&document, "admin", "mq")
|
|
||||||
return writeConfigDocument(configPath, &document)
|
return writeConfigDocument(configPath, &document)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -275,13 +271,11 @@ func (d *Data) removeIntegrationConfigFromFile() error {
|
||||||
if err = yaml.Unmarshal(raw, &document); err != nil {
|
if err = yaml.Unmarshal(raw, &document); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if yamlMappingValue(&document, "admin", "storage") == nil && yamlMappingValue(&document, "admin", "email") == nil && yamlMappingValue(&document, "admin", "websocket") == nil && yamlMappingValue(&document, "admin", "mq") == nil {
|
if yamlMappingValue(&document, "admin", "storage") == nil && yamlMappingValue(&document, "admin", "email") == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
deleteYAMLMapping(&document, "admin", "storage")
|
deleteYAMLMapping(&document, "admin", "storage")
|
||||||
deleteYAMLMapping(&document, "admin", "email")
|
deleteYAMLMapping(&document, "admin", "email")
|
||||||
deleteYAMLMapping(&document, "admin", "websocket")
|
|
||||||
deleteYAMLMapping(&document, "admin", "mq")
|
|
||||||
return writeConfigDocument(configPath, &document)
|
return writeConfigDocument(configPath, &document)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,13 +14,14 @@ import (
|
||||||
"kra/internal/conf"
|
"kra/internal/conf"
|
||||||
datapayment "kra/internal/data/payment"
|
datapayment "kra/internal/data/payment"
|
||||||
datasystem "kra/internal/data/repository"
|
datasystem "kra/internal/data/repository"
|
||||||
|
"kra/internal/integration/runtimeconfig"
|
||||||
"kra/internal/integration/storage"
|
"kra/internal/integration/storage"
|
||||||
"kra/internal/integrationruntime"
|
|
||||||
"kra/pkg/module"
|
"kra/pkg/module"
|
||||||
)
|
)
|
||||||
|
|
||||||
var ProviderSet = wire.NewSet(
|
var ProviderSet = wire.NewSet(
|
||||||
NewData,
|
NewData,
|
||||||
|
NewIntegrationRuntime,
|
||||||
wire.Bind(new(datasystem.Provider), new(*Data)),
|
wire.Bind(new(datasystem.Provider), new(*Data)),
|
||||||
wire.Bind(new(datasystem.DatabaseProvider), new(*Data)),
|
wire.Bind(new(datasystem.DatabaseProvider), new(*Data)),
|
||||||
wire.Bind(new(datapayment.Provider), new(*Data)),
|
wire.Bind(new(datapayment.Provider), new(*Data)),
|
||||||
|
|
@ -34,6 +35,13 @@ var ProviderSet = wire.NewSet(
|
||||||
datasystem.NewIntegrationConfigRepo,
|
datasystem.NewIntegrationConfigRepo,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func NewIntegrationRuntime(data *Data) *runtimeconfig.Store {
|
||||||
|
if data == nil {
|
||||||
|
return runtimeconfig.NewStore()
|
||||||
|
}
|
||||||
|
return data.IntegrationRuntime()
|
||||||
|
}
|
||||||
|
|
||||||
type Data struct {
|
type Data struct {
|
||||||
initMu sync.Mutex
|
initMu sync.Mutex
|
||||||
configMu sync.Mutex
|
configMu sync.Mutex
|
||||||
|
|
@ -42,7 +50,7 @@ type Data struct {
|
||||||
redis *reloadableRedis
|
redis *reloadableRedis
|
||||||
mongo *reloadableMongo
|
mongo *reloadableMongo
|
||||||
runtime *conf.Runtime
|
runtime *conf.Runtime
|
||||||
integrations *integrationruntime.Store
|
integrations *runtimeconfig.Store
|
||||||
storage *storage.Reloadable
|
storage *storage.Reloadable
|
||||||
dbListMu sync.RWMutex
|
dbListMu sync.RWMutex
|
||||||
dbList map[string]*gorm.DB
|
dbList map[string]*gorm.DB
|
||||||
|
|
@ -77,7 +85,7 @@ func (d *Data) Runtime() *conf.Runtime {
|
||||||
|
|
||||||
// IntegrationRuntime exposes database-backed integration configuration to
|
// IntegrationRuntime exposes database-backed integration configuration to
|
||||||
// long-lived adapters without making config.yaml part of their lifecycle.
|
// long-lived adapters without making config.yaml part of their lifecycle.
|
||||||
func (d *Data) IntegrationRuntime() *integrationruntime.Store {
|
func (d *Data) IntegrationRuntime() *runtimeconfig.Store {
|
||||||
if d == nil {
|
if d == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -167,7 +175,7 @@ func NewData(runtime *conf.Runtime, appLogger *slog.Logger, storageManager *stor
|
||||||
// and /init/initdb remain available.
|
// and /init/initdb remain available.
|
||||||
c.Database = &conf.Data_Database{}
|
c.Database = &conf.Data_Database{}
|
||||||
}
|
}
|
||||||
d := &Data{runtime: runtime, integrations: integrationruntime.NewStore(), appLogger: appLogger, storage: storageManager, catalog: catalog}
|
d := &Data{runtime: runtime, integrations: runtimeconfig.NewStore(), appLogger: appLogger, storage: storageManager, catalog: catalog}
|
||||||
usingFallback := !databaseConnectionConfigured(c.Database)
|
usingFallback := !databaseConnectionConfigured(c.Database)
|
||||||
var db *gorm.DB
|
var db *gorm.DB
|
||||||
var err error
|
var err error
|
||||||
|
|
|
||||||
|
|
@ -123,25 +123,6 @@ func TestStorageIntegrationConfigRoundTrip(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMQIntegrationConfigRoundTrip(t *testing.T) {
|
|
||||||
db := openIntegrationConfigTestDB(t)
|
|
||||||
legacy := &conf.AdminBackend_MQ{Enabled: true, Broker: "mqtt://emqx.example.com:1883", ClientId: "system", Username: "app", Password: "secret", KeepAlive: 45, CleanSession: true, ConnectTimeout: 12}
|
|
||||||
if err := saveMQIntegrationConfig(db, legacy); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
loaded, found, err := loadMQIntegrationConfig(db)
|
|
||||||
if err != nil || !found {
|
|
||||||
t.Fatalf("found=%v err=%v", found, err)
|
|
||||||
}
|
|
||||||
if loaded.Broker != legacy.Broker || loaded.Password != legacy.Password || loaded.KeepAlive != 45 {
|
|
||||||
t.Fatalf("loaded mq = %#v", loaded)
|
|
||||||
}
|
|
||||||
loaded, err = resolveMQIntegrationConfig(db, &conf.AdminBackend_MQ{Broker: "must-not-replace"})
|
|
||||||
if err != nil || loaded.Broker != legacy.Broker {
|
|
||||||
t.Fatalf("database mq was replaced: %#v err=%v", loaded, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolveStorageIntegrationConfigMigratesLegacyOnlyOnce(t *testing.T) {
|
func TestResolveStorageIntegrationConfigMigratesLegacyOnlyOnce(t *testing.T) {
|
||||||
db := openIntegrationConfigTestDB(t)
|
db := openIntegrationConfigTestDB(t)
|
||||||
legacy := &conf.AdminBackend_Storage{
|
legacy := &conf.AdminBackend_Storage{
|
||||||
|
|
@ -205,7 +186,7 @@ func TestResolveEmailIntegrationConfigMigratesLegacyOnlyOnce(t *testing.T) {
|
||||||
|
|
||||||
func TestPersistConfigValuesRemovesStorageFromYAML(t *testing.T) {
|
func TestPersistConfigValuesRemovesStorageFromYAML(t *testing.T) {
|
||||||
path := filepath.Join(t.TempDir(), "config.yaml")
|
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||||
input := []byte("data: {}\nadmin:\n router_prefix: /old\n storage:\n type: qiniu\n qiniu:\n secret_key: legacy-secret\n email:\n host: smtp.legacy.example.com\n secret: legacy-email-secret\n mq:\n enabled: true\n broker: tcp://localhost:1883\n password: legacy-mq-secret\n extension_key: retained\n")
|
input := []byte("data: {}\nadmin:\n router_prefix: /old\n storage:\n type: qiniu\n qiniu:\n secret_key: legacy-secret\n email:\n host: smtp.legacy.example.com\n secret: legacy-email-secret\n extension_key: retained\n")
|
||||||
if err := os.WriteFile(path, input, 0o600); err != nil {
|
if err := os.WriteFile(path, input, 0o600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
@ -218,7 +199,6 @@ func TestPersistConfigValuesRemovesStorageFromYAML(t *testing.T) {
|
||||||
Qiniu: &conf.AdminBackend_Qiniu{SecretKey: "database-only-secret"},
|
Qiniu: &conf.AdminBackend_Qiniu{SecretKey: "database-only-secret"},
|
||||||
},
|
},
|
||||||
Email: &conf.AdminBackend_Email{Host: "smtp.database.example.com", Secret: "database-only-email-secret"},
|
Email: &conf.AdminBackend_Email{Host: "smtp.database.example.com", Secret: "database-only-email-secret"},
|
||||||
Mq: &conf.AdminBackend_MQ{Enabled: true, Broker: "tcp://emqx:1883", Password: "database-only-mq-secret"},
|
|
||||||
}
|
}
|
||||||
if err := d.persistConfigValues(&conf.Data{}, admin); err != nil {
|
if err := d.persistConfigValues(&conf.Data{}, admin); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|
@ -242,9 +222,6 @@ func TestPersistConfigValuesRemovesStorageFromYAML(t *testing.T) {
|
||||||
if _, exists := adminValue["email"]; exists {
|
if _, exists := adminValue["email"]; exists {
|
||||||
t.Fatalf("email remained in YAML: %s", raw)
|
t.Fatalf("email remained in YAML: %s", raw)
|
||||||
}
|
}
|
||||||
if _, exists := adminValue["mq"]; exists {
|
|
||||||
t.Fatalf("mq remained in YAML: %s", raw)
|
|
||||||
}
|
|
||||||
if adminValue["extension_key"] != "retained" {
|
if adminValue["extension_key"] != "retained" {
|
||||||
t.Fatalf("extension key was not retained: %#v", adminValue)
|
t.Fatalf("extension key was not retained: %#v", adminValue)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
package data
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"kra/internal/biz"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ensureCommunicationIntegrationConfigs(db *gorm.DB) error {
|
||||||
|
defaults := []struct {
|
||||||
|
kind string
|
||||||
|
provider string
|
||||||
|
}{
|
||||||
|
{kind: biz.IntegrationKindMQ, provider: "emqx"},
|
||||||
|
{kind: biz.IntegrationKindMQ, provider: "rabbitmq"},
|
||||||
|
{kind: biz.IntegrationKindWebSocket, provider: "melody"},
|
||||||
|
}
|
||||||
|
for _, item := range defaults {
|
||||||
|
var row integrationConfigPO
|
||||||
|
err := db.Where("kind = ? AND provider = ?", item.kind, item.provider).First(&row).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
values, marshalErr := json.Marshal(biz.DefaultIntegrationConfig(item.kind, item.provider))
|
||||||
|
if marshalErr != nil {
|
||||||
|
return marshalErr
|
||||||
|
}
|
||||||
|
if err = db.Create(&integrationConfigPO{Kind: item.kind, Provider: item.provider, Enabled: false, Config: string(values)}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
@ -3,12 +3,12 @@ package data
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
"kra/internal/integrationruntime"
|
"kra/internal/integration/runtimeconfig"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
func readIntegrationRuntime(db *gorm.DB) ([]integrationruntime.Config, error) {
|
func readIntegrationRuntime(db *gorm.DB) ([]runtimeconfig.Config, error) {
|
||||||
if db == nil || !db.Migrator().HasTable(&integrationConfigPO{}) {
|
if db == nil || !db.Migrator().HasTable(&integrationConfigPO{}) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
@ -19,9 +19,9 @@ func readIntegrationRuntime(db *gorm.DB) ([]integrationruntime.Config, error) {
|
||||||
Find(&rows).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
Find(&rows).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
configs := make([]integrationruntime.Config, 0, len(rows))
|
configs := make([]runtimeconfig.Config, 0, len(rows))
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
configs = append(configs, integrationruntime.Config{Kind: row.Kind, Provider: row.Provider, Enabled: row.Enabled, Values: []byte(row.Config)})
|
configs = append(configs, runtimeconfig.Config{Kind: row.Kind, Provider: row.Provider, Enabled: row.Enabled, Values: []byte(row.Config)})
|
||||||
}
|
}
|
||||||
return configs, nil
|
return configs, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,15 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func InfrastructureMigrations() []migration.Step {
|
func InfrastructureMigrations() []migration.Step {
|
||||||
return []migration.Step{{
|
return []migration.Step{
|
||||||
ID: "202608200001_data_infrastructure",
|
{
|
||||||
Migrate: func(db *gorm.DB) error {
|
ID: "202608200001_data_infrastructure",
|
||||||
return migration.CreateMissingTables(db, &integrationConfigPO{})
|
Migrate: func(db *gorm.DB) error {
|
||||||
|
return migration.CreateMissingTables(db, &integrationConfigPO{})
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}}
|
{ID: "202608210001_communication_integration_defaults", Migrate: ensureCommunicationIntegrationConfigs},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// migrateAll is the single data-layer migration entry point. Module-specific
|
// migrateAll is the single data-layer migration entry point. Module-specific
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,20 @@ func TestMigrateAllRunsModuleSchemasWithoutBootstrapSeed(t *testing.T) {
|
||||||
if err = db.Table(migration.TableName).Count(&versions).Error; err != nil {
|
if err = db.Table(migration.TableName).Count(&versions).Error; err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if versions != 4 {
|
if versions != 6 {
|
||||||
t.Fatalf("migration versions = %d, want 4", versions)
|
t.Fatalf("migration versions = %d, want 6", versions)
|
||||||
|
}
|
||||||
|
var communicationRows []integrationConfigPO
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
for _, row := range communicationRows {
|
||||||
|
if row.Enabled || row.Config == "" {
|
||||||
|
t.Fatalf("default communication integration = %#v", row)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for _, table := range []string{"sys_users", "sys_base_menus", "sys_apis"} {
|
for _, table := range []string{"sys_users", "sys_base_menus", "sys_apis"} {
|
||||||
var count int64
|
var count int64
|
||||||
|
|
|
||||||
|
|
@ -26,12 +26,16 @@ func AdminSurface() platformmodule.Surface {
|
||||||
{Name: "paymentConfig", Path: "paymentConfig", ParentName: "extensions", Component: "view/systemTools/payment/config.vue", Title: "支付配置", Icon: "credit-card", Sort: 7},
|
{Name: "paymentConfig", Path: "paymentConfig", ParentName: "extensions", Component: "view/systemTools/payment/config.vue", Title: "支付配置", Icon: "credit-card", Sort: 7},
|
||||||
},
|
},
|
||||||
APIs: []platformmodule.API{
|
APIs: []platformmodule.API{
|
||||||
{Path: "/integration/configs/:kind", Method: "GET", Group: "集成配置", Description: "按类型获取集成配置"},
|
|
||||||
{Path: "/integration/configs/:kind/:provider", Method: "GET", Group: "集成配置", Description: "获取指定集成配置"},
|
|
||||||
{Path: "/integration/configs/:kind/:provider", Method: "PUT", Group: "集成配置", Description: "保存集成配置"},
|
|
||||||
{Path: "/integration/configs/:kind/:provider", Method: "DELETE", Group: "集成配置", Description: "删除集成配置"},
|
|
||||||
{Path: "/payment/orders", Method: "GET", Group: "支付", Description: "分页查询支付订单"},
|
{Path: "/payment/orders", Method: "GET", Group: "支付", Description: "分页查询支付订单"},
|
||||||
{Path: "/payment/order", Method: "POST", Group: "支付", Description: "查询支付订单"},
|
{Path: "/payment/order", Method: "POST", Group: "支付", Description: "查询支付订单"},
|
||||||
|
{Path: "/payment/orders/:provider/:tradeNo", Method: "GET", Group: "支付", Description: "按路径查询支付订单"},
|
||||||
|
{Path: "/payment/create", Method: "POST", Group: "支付", Description: "创建支付订单"},
|
||||||
|
{Path: "/payment/query", Method: "POST", Group: "支付", Description: "同步支付订单状态"},
|
||||||
|
{Path: "/payment/refund", Method: "POST", Group: "支付", Description: "申请支付订单退款"},
|
||||||
|
{Path: "/payment/orders/:provider/:tradeNo/refund", Method: "POST", Group: "支付", Description: "按路径申请支付订单退款"},
|
||||||
|
{Path: "/payment/fulfill", Method: "POST", Group: "支付", Description: "重试支付订单发货"},
|
||||||
|
{Path: "/payment/orders/:provider/:tradeNo/fulfill", Method: "POST", Group: "支付", Description: "按路径重试支付订单发货"},
|
||||||
|
{Path: "/payment/providers/:provider/test", Method: "POST", Group: "支付", Description: "测试支付渠道"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"kra/internal/biz"
|
"kra/internal/biz"
|
||||||
"kra/internal/integrationruntime"
|
"kra/internal/integration/runtimeconfig"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
@ -27,6 +27,10 @@ func (integrationConfigPO) TableName() string { return "sys_integration_configs"
|
||||||
|
|
||||||
type integrationConfigRepo struct{ data Provider }
|
type integrationConfigRepo struct{ data Provider }
|
||||||
|
|
||||||
|
type integrationRuntimeProvider interface {
|
||||||
|
IntegrationRuntime() *runtimeconfig.Store
|
||||||
|
}
|
||||||
|
|
||||||
func NewIntegrationConfigRepo(data Provider) biz.IntegrationConfigRepo {
|
func NewIntegrationConfigRepo(data Provider) biz.IntegrationConfigRepo {
|
||||||
return &integrationConfigRepo{data: data}
|
return &integrationConfigRepo{data: data}
|
||||||
}
|
}
|
||||||
|
|
@ -93,18 +97,25 @@ func (r *integrationConfigRepo) DeleteIntegrationConfig(ctx context.Context, kin
|
||||||
if err := r.data.DB().WithContext(ctx).Where("kind = ? AND provider = ?", kind, provider).Delete(&integrationConfigPO{}).Error; err != nil {
|
if err := r.data.DB().WithContext(ctx).Where("kind = ? AND provider = ?", kind, provider).Delete(&integrationConfigPO{}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if runtime := r.data.IntegrationRuntime(); runtime != nil {
|
if runtime := integrationRuntime(r.data); runtime != nil {
|
||||||
runtime.Delete(kind, provider)
|
runtime.Delete(kind, provider)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *integrationConfigRepo) publish(kind, provider string, enabled bool, values []byte) {
|
func (r *integrationConfigRepo) publish(kind, provider string, enabled bool, values []byte) {
|
||||||
if runtime := r.data.IntegrationRuntime(); runtime != nil {
|
if runtime := integrationRuntime(r.data); runtime != nil {
|
||||||
runtime.Set(integrationruntime.Config{Kind: kind, Provider: provider, Enabled: enabled, Values: values})
|
runtime.Set(runtimeconfig.Config{Kind: kind, Provider: provider, Enabled: enabled, Values: values})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func integrationRuntime(provider Provider) *runtimeconfig.Store {
|
||||||
|
if value, ok := provider.(integrationRuntimeProvider); ok {
|
||||||
|
return value.IntegrationRuntime()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func integrationConfigFromPO(row integrationConfigPO) *biz.IntegrationConfig {
|
func integrationConfigFromPO(row integrationConfigPO) *biz.IntegrationConfig {
|
||||||
values := integrationObject(json.RawMessage(row.Config))
|
values := integrationObject(json.RawMessage(row.Config))
|
||||||
maskIntegrationSecrets(row.Kind, row.Provider, values)
|
maskIntegrationSecrets(row.Kind, row.Provider, values)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
package system
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"kra/internal/biz"
|
||||||
|
"kra/internal/integration/runtimeconfig"
|
||||||
|
)
|
||||||
|
|
||||||
|
type integrationRuntimeTestProvider struct {
|
||||||
|
*Data
|
||||||
|
store *runtimeconfig.Store
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *integrationRuntimeTestProvider) IntegrationRuntime() *runtimeconfig.Store { return p.store }
|
||||||
|
|
||||||
|
func TestIntegrationConfigSavePublishesUnmaskedRuntimeValues(t *testing.T) {
|
||||||
|
db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = db.AutoMigrate(&integrationConfigPO{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
provider := &integrationRuntimeTestProvider{Data: &Data{gormDB: newReloadableDB(db, nil)}, store: runtimeconfig.NewStore()}
|
||||||
|
repo := &integrationConfigRepo{data: provider}
|
||||||
|
|
||||||
|
values := biz.DefaultIntegrationConfig(biz.IntegrationKindMQ, "rabbitmq")
|
||||||
|
values["password"] = "runtime-secret"
|
||||||
|
raw, _ := json.Marshal(values)
|
||||||
|
if err = repo.SaveIntegrationConfig(context.Background(), &biz.IntegrationConfig{Kind: biz.IntegrationKindMQ, Provider: "rabbitmq", Enabled: true, Values: raw}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
values["password"] = "******"
|
||||||
|
raw, _ = json.Marshal(values)
|
||||||
|
if err = repo.SaveIntegrationConfig(context.Background(), &biz.IntegrationConfig{Kind: biz.IntegrationKindMQ, Provider: "rabbitmq", Enabled: true, Values: raw}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
active, ok := provider.store.Get(biz.IntegrationKindMQ, "rabbitmq")
|
||||||
|
if !ok || !active.Enabled {
|
||||||
|
t.Fatalf("runtime config = %#v, ok=%v", active, ok)
|
||||||
|
}
|
||||||
|
stored := map[string]any{}
|
||||||
|
if err = json.Unmarshal(active.Values, &stored); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if stored["password"] != "runtime-secret" {
|
||||||
|
t.Fatalf("runtime password = %#v", stored["password"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,6 @@ package system
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"kra/internal/conf"
|
"kra/internal/conf"
|
||||||
"kra/internal/integrationruntime"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
@ -14,5 +13,4 @@ type Provider interface {
|
||||||
Database(name string) (*gorm.DB, error)
|
Database(name string) (*gorm.DB, error)
|
||||||
DatabaseReady() bool
|
DatabaseReady() bool
|
||||||
Runtime() *conf.Runtime
|
Runtime() *conf.Runtime
|
||||||
IntegrationRuntime() *integrationruntime.Store
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ state, or translate provider-specific protocols into `biz` interfaces.
|
||||||
|
|
||||||
- `cache`: Redis-backed cache with an in-memory fallback.
|
- `cache`: Redis-backed cache with an in-memory fallback.
|
||||||
- `email`: SMTP email repository.
|
- `email`: SMTP email repository.
|
||||||
- `mq`: reloadable EMQX/MQTT client exposed through the shared `pkg/mq` interface.
|
- `mq`: reloadable EMQX/MQTT and RabbitMQ/AMQP clients exposed through the shared `pkg/mq` interface.
|
||||||
- `payment`: payment-channel SDKs and callback/signature handling.
|
- `payment`: payment-channel SDKs and callback/signature handling.
|
||||||
- `storage`: local and object-storage implementations of `biz.FileStorage`.
|
- `storage`: local and object-storage implementations of `biz.FileStorage`.
|
||||||
- `websocket`: reloadable Melody endpoint exposed through the shared
|
- `websocket`: reloadable Melody endpoint exposed through the shared
|
||||||
|
|
@ -28,7 +28,7 @@ Business modules depend on `websocket.Hub` and `mq.Client` from the shared
|
||||||
`pkg/websocket` and `pkg/mq` packages; they do not construct
|
`pkg/websocket` and `pkg/mq` packages; they do not construct
|
||||||
Melody or Paho clients and do not read system configuration directly. The
|
Melody or Paho clients and do not read system configuration directly. The
|
||||||
system integration packages own runtime refresh and shutdown. Registered
|
system integration packages own runtime refresh and shutdown. Registered
|
||||||
WebSocket handlers and MQTT subscriptions are retained when database-backed
|
WebSocket handlers and broker subscriptions are retained when database-backed
|
||||||
configuration replaces a live client.
|
configuration replaces a live client.
|
||||||
|
|
||||||
```go
|
```go
|
||||||
|
|
@ -45,7 +45,8 @@ Integration configuration is stored in `sys_integration_configs`:
|
||||||
|
|
||||||
- WebSocket: `kind=websocket`, `provider=melody`
|
- WebSocket: `kind=websocket`, `provider=melody`
|
||||||
- EMQX: `kind=mq`, `provider=emqx`
|
- EMQX: `kind=mq`, `provider=emqx`
|
||||||
|
- RabbitMQ: `kind=mq`, `provider=rabbitmq`
|
||||||
|
|
||||||
The YAML values are only migration/bootstrap inputs. After the integration
|
These three integrations are stored only in `sys_integration_configs`; they do
|
||||||
table exists, the database is authoritative and runtime updates are applied
|
not come from `config.yaml`. Runtime updates are applied without restarting
|
||||||
without restarting the process. The WebSocket public path defaults to `/ws`.
|
the process. The WebSocket public path defaults to `/ws`.
|
||||||
|
|
|
||||||
|
|
@ -2,140 +2,294 @@ package mq
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"kra/internal/conf"
|
"kra/internal/integration/runtimeconfig"
|
||||||
"kra/pkg/mq"
|
platformmq "kra/pkg/mq"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Reloadable follows the runtime snapshot and keeps a single shared EMQX
|
const (
|
||||||
// connection for all modules in this process.
|
ProviderEMQX = "emqx"
|
||||||
|
ProviderRabbitMQ = "rabbitmq"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reloadable owns the process-wide message clients. Configuration comes only
|
||||||
|
// from sys_integration_configs through runtimeconfig.Store.
|
||||||
type Reloadable struct {
|
type Reloadable struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
opMu sync.Mutex
|
opMu sync.Mutex
|
||||||
current mq.Client
|
clients map[string]platformmq.Client
|
||||||
subscriptions map[string]subscription
|
subscriptions map[string]map[string]subscription
|
||||||
stop func()
|
stop []func()
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
closed bool
|
closed bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type subscription struct {
|
type subscription struct {
|
||||||
qos byte
|
qos byte
|
||||||
handler mq.Handler
|
handler platformmq.Handler
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(runtime *conf.Runtime, logger *slog.Logger) (*Reloadable, func(), error) {
|
type namedClient struct {
|
||||||
|
owner *Reloadable
|
||||||
|
provider string
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(store *runtimeconfig.Store, logger *slog.Logger) (*Reloadable, func(), error) {
|
||||||
if logger == nil {
|
if logger == nil {
|
||||||
logger = slog.Default()
|
logger = slog.Default()
|
||||||
}
|
}
|
||||||
r := &Reloadable{logger: logger, subscriptions: make(map[string]subscription)}
|
r := &Reloadable{
|
||||||
if runtime != nil {
|
clients: make(map[string]platformmq.Client),
|
||||||
var config *conf.AdminBackend_MQ
|
subscriptions: make(map[string]map[string]subscription),
|
||||||
if admin := runtime.Admin(); admin != nil {
|
logger: logger,
|
||||||
config = admin.GetMq()
|
}
|
||||||
}
|
if store != nil {
|
||||||
r.replace(config)
|
r.apply(ProviderEMQX, storeConfig(store, ProviderEMQX))
|
||||||
r.stop = runtime.Subscribe(func(_ *conf.Data, admin *conf.AdminBackend) {
|
r.apply(ProviderRabbitMQ, storeConfig(store, ProviderRabbitMQ))
|
||||||
if admin != nil {
|
r.stop = append(r.stop,
|
||||||
r.replace(admin.GetMq())
|
store.Subscribe("mq", ProviderEMQX, func(config runtimeconfig.Config) { r.apply(ProviderEMQX, config) }),
|
||||||
}
|
store.Subscribe("mq", ProviderRabbitMQ, func(config runtimeconfig.Config) { r.apply(ProviderRabbitMQ, config) }),
|
||||||
})
|
)
|
||||||
}
|
}
|
||||||
cleanup := func() {
|
cleanup := func() {
|
||||||
if r.stop != nil {
|
for _, stop := range r.stop {
|
||||||
r.stop()
|
stop()
|
||||||
}
|
}
|
||||||
_ = r.Close()
|
_ = r.Close()
|
||||||
}
|
}
|
||||||
return r, cleanup, nil
|
return r, cleanup, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Reloadable) replace(config *conf.AdminBackend_MQ) {
|
func storeConfig(store *runtimeconfig.Store, provider string) runtimeconfig.Config {
|
||||||
|
config, _ := store.Get("mq", provider)
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Reloadable) apply(provider string, config runtimeconfig.Config) {
|
||||||
r.opMu.Lock()
|
r.opMu.Lock()
|
||||||
defer r.opMu.Unlock()
|
defer r.opMu.Unlock()
|
||||||
if r.closed {
|
if r.closed {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if config == nil {
|
if !config.Enabled {
|
||||||
config = &conf.AdminBackend_MQ{}
|
r.replaceClientLocked(provider, nil)
|
||||||
}
|
|
||||||
cfg := mq.Config{Enabled: config.Enabled, Broker: config.Broker, ClientID: config.ClientId, Username: config.Username, Password: config.Password, CleanSession: config.CleanSession}
|
|
||||||
if config.KeepAlive > 0 {
|
|
||||||
cfg.KeepAlive = time.Duration(config.KeepAlive) * time.Second
|
|
||||||
}
|
|
||||||
if config.ConnectTimeout > 0 {
|
|
||||||
cfg.ConnectTimeout = time.Duration(config.ConnectTimeout) * time.Second
|
|
||||||
}
|
|
||||||
client, err := mq.NewMQTT(cfg)
|
|
||||||
if err != nil {
|
|
||||||
r.logger.Warn("emqx unavailable", "mod", "mq", "error", err)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if config.Enabled {
|
client, err := newProviderClient(provider, config.Values)
|
||||||
if err = r.restoreSubscriptions(context.Background(), client); err != nil {
|
if err != nil {
|
||||||
_ = client.Close()
|
r.logger.Warn("message integration unavailable", "mod", "mq", "provider", provider, "error", err)
|
||||||
r.logger.Warn("restore emqx subscriptions failed", "mod", "mq", "error", err)
|
return
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if err = r.restoreSubscriptionsLocked(provider, client); err != nil {
|
||||||
|
_ = client.Close()
|
||||||
|
r.logger.Warn("restore message subscriptions failed", "mod", "mq", "provider", provider, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.replaceClientLocked(provider, client)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newProviderClient(provider string, raw json.RawMessage) (platformmq.Client, error) {
|
||||||
|
values := map[string]any{}
|
||||||
|
if err := json.Unmarshal(raw, &values); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode %s configuration: %w", provider, err)
|
||||||
|
}
|
||||||
|
switch provider {
|
||||||
|
case ProviderEMQX:
|
||||||
|
return platformmq.NewMQTT(platformmq.Config{
|
||||||
|
Enabled: true,
|
||||||
|
Broker: configText(values, "broker"),
|
||||||
|
ClientID: configText(values, "client_id"),
|
||||||
|
Username: configText(values, "username"),
|
||||||
|
Password: configText(values, "password"),
|
||||||
|
KeepAlive: configSeconds(values, "keep_alive"),
|
||||||
|
CleanSession: configBool(values, "clean_session"),
|
||||||
|
ConnectTimeout: configSeconds(values, "connect_timeout"),
|
||||||
|
})
|
||||||
|
case ProviderRabbitMQ:
|
||||||
|
return platformmq.NewRabbitMQ(platformmq.RabbitMQConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Host: configText(values, "host"),
|
||||||
|
Port: configInt(values, "port"),
|
||||||
|
Username: configText(values, "username"),
|
||||||
|
Password: configText(values, "password"),
|
||||||
|
VHost: configText(values, "vhost"),
|
||||||
|
Exchange: configText(values, "exchange"),
|
||||||
|
ExchangeType: configText(values, "exchange_type"),
|
||||||
|
Queue: configText(values, "queue"),
|
||||||
|
RoutingKey: configText(values, "routing_key"),
|
||||||
|
Durable: configBool(values, "durable"),
|
||||||
|
AutoDelete: configBool(values, "auto_delete"),
|
||||||
|
PrefetchCount: configInt(values, "prefetch_count"),
|
||||||
|
Heartbeat: configSeconds(values, "heartbeat"),
|
||||||
|
ConnectTimeout: configSeconds(values, "connect_timeout"),
|
||||||
|
TLS: configBool(values, "tls"),
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported message provider %q", provider)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func configText(values map[string]any, key string) string {
|
||||||
|
value, ok := values[key]
|
||||||
|
if !ok || value == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(fmt.Sprint(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
func configInt(values map[string]any, key string) int {
|
||||||
|
switch value := values[key].(type) {
|
||||||
|
case float64:
|
||||||
|
return int(value)
|
||||||
|
case int:
|
||||||
|
return value
|
||||||
|
case json.Number:
|
||||||
|
parsed, _ := strconv.Atoi(string(value))
|
||||||
|
return parsed
|
||||||
|
default:
|
||||||
|
parsed, _ := strconv.Atoi(configText(values, key))
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func configSeconds(values map[string]any, key string) time.Duration {
|
||||||
|
seconds := configInt(values, key)
|
||||||
|
if seconds <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return time.Duration(seconds) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
func configBool(values map[string]any, key string) bool {
|
||||||
|
value, _ := values[key].(bool)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Reloadable) replaceClientLocked(provider string, next platformmq.Client) {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
old := r.current
|
old := r.clients[provider]
|
||||||
r.current = client
|
if next == nil {
|
||||||
|
delete(r.clients, provider)
|
||||||
|
} else {
|
||||||
|
r.clients[provider] = next
|
||||||
|
}
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
if old != nil {
|
if old != nil {
|
||||||
_ = old.Close()
|
_ = old.Close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Reloadable) restoreSubscriptions(ctx context.Context, client mq.Client) error {
|
func (r *Reloadable) restoreSubscriptionsLocked(provider string, client platformmq.Client) error {
|
||||||
for topic, item := range r.subscriptions {
|
for topic, item := range r.subscriptions[provider] {
|
||||||
if err := client.Subscribe(ctx, topic, item.qos, item.handler); err != nil {
|
if err := client.Subscribe(context.Background(), topic, item.qos, item.handler); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Reloadable) client() mq.Client { r.mu.RLock(); defer r.mu.RUnlock(); return r.current }
|
func (r *Reloadable) client(provider string) platformmq.Client {
|
||||||
func (r *Reloadable) Publish(ctx context.Context, topic string, payload []byte, qos byte, retain bool) error {
|
provider = strings.ToLower(strings.TrimSpace(provider))
|
||||||
c := r.client()
|
r.mu.RLock()
|
||||||
if c == nil {
|
defer r.mu.RUnlock()
|
||||||
return mq.ErrUnavailable
|
return r.clients[provider]
|
||||||
}
|
|
||||||
return c.Publish(ctx, topic, payload, qos, retain)
|
|
||||||
}
|
}
|
||||||
func (r *Reloadable) Subscribe(ctx context.Context, topic string, qos byte, handler mq.Handler) error {
|
|
||||||
|
func (r *Reloadable) Client(provider string) platformmq.Client {
|
||||||
|
provider = strings.ToLower(strings.TrimSpace(provider))
|
||||||
|
if provider != ProviderEMQX && provider != ProviderRabbitMQ {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &namedClient{owner: r, provider: provider}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *namedClient) Publish(ctx context.Context, topic string, payload []byte, qos byte, retain bool) error {
|
||||||
|
return c.owner.PublishTo(ctx, c.provider, topic, payload, qos, retain)
|
||||||
|
}
|
||||||
|
func (c *namedClient) Subscribe(ctx context.Context, topic string, qos byte, handler platformmq.Handler) error {
|
||||||
|
return c.owner.SubscribeTo(ctx, c.provider, topic, qos, handler)
|
||||||
|
}
|
||||||
|
func (c *namedClient) Unsubscribe(ctx context.Context, topics ...string) error {
|
||||||
|
return c.owner.UnsubscribeFrom(ctx, c.provider, topics...)
|
||||||
|
}
|
||||||
|
func (c *namedClient) Connected() bool { return c.owner.ConnectedTo(c.provider) }
|
||||||
|
func (*namedClient) Close() error { return nil }
|
||||||
|
|
||||||
|
func (r *Reloadable) Publish(ctx context.Context, topic string, payload []byte, qos byte, retain bool) error {
|
||||||
|
return r.PublishTo(ctx, ProviderEMQX, topic, payload, qos, retain)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Reloadable) PublishTo(ctx context.Context, provider, topic string, payload []byte, qos byte, retain bool) error {
|
||||||
|
provider = strings.ToLower(strings.TrimSpace(provider))
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
client := r.clients[provider]
|
||||||
|
if client == nil {
|
||||||
|
return platformmq.ErrUnavailable
|
||||||
|
}
|
||||||
|
return client.Publish(ctx, topic, payload, qos, retain)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Reloadable) Subscribe(ctx context.Context, topic string, qos byte, handler platformmq.Handler) error {
|
||||||
|
return r.SubscribeTo(ctx, ProviderEMQX, topic, qos, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Reloadable) SubscribeTo(ctx context.Context, provider, topic string, qos byte, handler platformmq.Handler) error {
|
||||||
|
provider = strings.ToLower(strings.TrimSpace(provider))
|
||||||
r.opMu.Lock()
|
r.opMu.Lock()
|
||||||
defer r.opMu.Unlock()
|
defer r.opMu.Unlock()
|
||||||
c := r.client()
|
client := r.client(provider)
|
||||||
if c == nil {
|
if client == nil {
|
||||||
return mq.ErrUnavailable
|
return platformmq.ErrUnavailable
|
||||||
}
|
}
|
||||||
if err := c.Subscribe(ctx, topic, qos, handler); err != nil {
|
if err := client.Subscribe(ctx, topic, qos, handler); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
r.subscriptions[topic] = subscription{qos: qos, handler: handler}
|
if r.subscriptions[provider] == nil {
|
||||||
|
r.subscriptions[provider] = make(map[string]subscription)
|
||||||
|
}
|
||||||
|
r.subscriptions[provider][topic] = subscription{qos: qos, handler: handler}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Reloadable) Unsubscribe(ctx context.Context, topics ...string) error {
|
func (r *Reloadable) Unsubscribe(ctx context.Context, topics ...string) error {
|
||||||
|
return r.UnsubscribeFrom(ctx, ProviderEMQX, topics...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Reloadable) UnsubscribeFrom(ctx context.Context, provider string, topics ...string) error {
|
||||||
|
provider = strings.ToLower(strings.TrimSpace(provider))
|
||||||
r.opMu.Lock()
|
r.opMu.Lock()
|
||||||
defer r.opMu.Unlock()
|
defer r.opMu.Unlock()
|
||||||
c := r.client()
|
client := r.client(provider)
|
||||||
if c == nil {
|
if client == nil {
|
||||||
return mq.ErrUnavailable
|
return platformmq.ErrUnavailable
|
||||||
}
|
}
|
||||||
if err := c.Unsubscribe(ctx, topics...); err != nil {
|
if err := client.Unsubscribe(ctx, topics...); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, topic := range topics {
|
for _, topic := range topics {
|
||||||
delete(r.subscriptions, topic)
|
delete(r.subscriptions[provider], topic)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
func (r *Reloadable) Connected() bool { c := r.client(); return c != nil && c.Connected() }
|
|
||||||
|
func (r *Reloadable) Connected() bool { return r.ConnectedTo(ProviderEMQX) }
|
||||||
|
|
||||||
|
func (r *Reloadable) ConnectedTo(provider string) bool {
|
||||||
|
provider = strings.ToLower(strings.TrimSpace(provider))
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
client := r.clients[provider]
|
||||||
|
return client != nil && client.Connected()
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Reloadable) Close() error {
|
func (r *Reloadable) Close() error {
|
||||||
r.opMu.Lock()
|
r.opMu.Lock()
|
||||||
defer r.opMu.Unlock()
|
defer r.opMu.Unlock()
|
||||||
|
|
@ -144,11 +298,16 @@ func (r *Reloadable) Close() error {
|
||||||
}
|
}
|
||||||
r.closed = true
|
r.closed = true
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
old := r.current
|
clients := make([]platformmq.Client, 0, len(r.clients))
|
||||||
r.current = nil
|
for provider, client := range r.clients {
|
||||||
|
clients = append(clients, client)
|
||||||
|
delete(r.clients, provider)
|
||||||
|
}
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
if old != nil {
|
for _, client := range clients {
|
||||||
return old.Close()
|
if client != nil {
|
||||||
|
_ = client.Close()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,28 +26,33 @@ func (*fakeClient) Close() error { return nil }
|
||||||
|
|
||||||
func TestReloadableTracksSubscriptions(t *testing.T) {
|
func TestReloadableTracksSubscriptions(t *testing.T) {
|
||||||
client := &fakeClient{}
|
client := &fakeClient{}
|
||||||
r := &Reloadable{current: client, subscriptions: make(map[string]subscription)}
|
r := &Reloadable{
|
||||||
|
clients: map[string]platformmq.Client{ProviderEMQX: client},
|
||||||
|
subscriptions: make(map[string]map[string]subscription),
|
||||||
|
}
|
||||||
handler := func(context.Context, platformmq.Message) {}
|
handler := func(context.Context, platformmq.Message) {}
|
||||||
if err := r.Subscribe(context.Background(), "orders/+/paid", platformmq.AtLeastOnce, handler); err != nil {
|
if err := r.Subscribe(context.Background(), "orders/+/paid", platformmq.AtLeastOnce, handler); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if _, ok := r.subscriptions["orders/+/paid"]; !ok {
|
if _, ok := r.subscriptions[ProviderEMQX]["orders/+/paid"]; !ok {
|
||||||
t.Fatal("subscription was not retained for configuration reload")
|
t.Fatal("subscription was not retained for configuration reload")
|
||||||
}
|
}
|
||||||
if err := r.Unsubscribe(context.Background(), "orders/+/paid"); err != nil {
|
if err := r.Unsubscribe(context.Background(), "orders/+/paid"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if _, ok := r.subscriptions["orders/+/paid"]; ok {
|
if _, ok := r.subscriptions[ProviderEMQX]["orders/+/paid"]; ok {
|
||||||
t.Fatal("unsubscribed topic remained in the reload registry")
|
t.Fatal("unsubscribed topic remained in the reload registry")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReloadableRestoresSubscriptions(t *testing.T) {
|
func TestReloadableRestoresSubscriptions(t *testing.T) {
|
||||||
client := &fakeClient{}
|
client := &fakeClient{}
|
||||||
r := &Reloadable{subscriptions: map[string]subscription{
|
r := &Reloadable{subscriptions: map[string]map[string]subscription{
|
||||||
"orders/+/paid": {qos: platformmq.AtLeastOnce, handler: func(context.Context, platformmq.Message) {}},
|
ProviderEMQX: {
|
||||||
|
"orders/+/paid": {qos: platformmq.AtLeastOnce, handler: func(context.Context, platformmq.Message) {}},
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
if err := r.restoreSubscriptions(context.Background(), client); err != nil {
|
if err := r.restoreSubscriptionsLocked(ProviderEMQX, client); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(client.subscribed) != 1 || client.subscribed[0] != "orders/+/paid" {
|
if len(client.subscribed) != 1 || client.subscribed[0] != "orders/+/paid" {
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ var ProviderSet = wire.NewSet(
|
||||||
storage.NewFileStorage,
|
storage.NewFileStorage,
|
||||||
mqintegration.New,
|
mqintegration.New,
|
||||||
wire.Bind(new(mq.Client), new(*mqintegration.Reloadable)),
|
wire.Bind(new(mq.Client), new(*mqintegration.Reloadable)),
|
||||||
|
wire.Bind(new(mq.Registry), new(*mqintegration.Reloadable)),
|
||||||
websocketintegration.New,
|
websocketintegration.New,
|
||||||
wire.Bind(new(platformws.Hub), new(*websocketintegration.Server)),
|
wire.Bind(new(platformws.Hub), new(*websocketintegration.Server)),
|
||||||
wire.Bind(new(biz.FileStorage), new(*storage.Reloadable)),
|
wire.Bind(new(biz.FileStorage), new(*storage.Reloadable)),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Package integrationruntime keeps the active database-backed integration
|
// Package runtimeconfig keeps the active database-backed integration settings
|
||||||
// settings and notifies long-lived provider clients when they change.
|
// and notifies long-lived provider clients when they change.
|
||||||
package integrationruntime
|
package runtimeconfig
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package integrationruntime
|
package runtimeconfig
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
|
@ -1,19 +1,23 @@
|
||||||
package websocket
|
package websocket
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
melody "github.com/olahol/melody"
|
melody "github.com/olahol/melody"
|
||||||
"kra/internal/conf"
|
"kra/internal/integration/runtimeconfig"
|
||||||
platformws "kra/pkg/websocket"
|
platformws "kra/pkg/websocket"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Server is the system-owned WebSocket endpoint. Business modules can attach
|
const ProviderMelody = "melody"
|
||||||
// handlers and publish messages without depending on Gin or Melody directly.
|
|
||||||
|
// Server owns the database-configured WebSocket endpoint.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
current *platformws.Server
|
current *platformws.Server
|
||||||
|
|
@ -22,24 +26,25 @@ type Server struct {
|
||||||
binaryHandlers []func(*melody.Session, []byte)
|
binaryHandlers []func(*melody.Session, []byte)
|
||||||
connectHandlers []func(*melody.Session)
|
connectHandlers []func(*melody.Session)
|
||||||
disconnectHandlers []func(*melody.Session)
|
disconnectHandlers []func(*melody.Session)
|
||||||
|
stop func()
|
||||||
closed bool
|
closed bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(runtime *conf.Runtime) (*Server, func(), error) {
|
func New(store *runtimeconfig.Store) (*Server, func(), error) {
|
||||||
s := &Server{}
|
s := &Server{}
|
||||||
s.Replace(runtime)
|
if store != nil {
|
||||||
var unsubscribe func()
|
s.apply(storeConfig(store))
|
||||||
if runtime != nil {
|
s.stop = store.Subscribe("websocket", ProviderMelody, func(config runtimeconfig.Config) { s.apply(config) })
|
||||||
unsubscribe = runtime.Subscribe(func(_ *conf.Data, _ *conf.AdminBackend) { s.Replace(runtime) })
|
|
||||||
}
|
}
|
||||||
return s, func() {
|
return s, func() {
|
||||||
if unsubscribe != nil {
|
if s.stop != nil {
|
||||||
unsubscribe()
|
s.stop()
|
||||||
}
|
}
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.closed = true
|
s.closed = true
|
||||||
current := s.current
|
current := s.current
|
||||||
s.current = nil
|
s.current = nil
|
||||||
|
s.path = ""
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
if current != nil {
|
if current != nil {
|
||||||
_ = current.Close()
|
_ = current.Close()
|
||||||
|
|
@ -47,13 +52,20 @@ func New(runtime *conf.Runtime) (*Server, func(), error) {
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) Replace(runtime *conf.Runtime) {
|
func storeConfig(store *runtimeconfig.Store) runtimeconfig.Config {
|
||||||
var config *conf.AdminBackend_WebSocket
|
config, _ := store.Get("websocket", ProviderMelody)
|
||||||
if runtime != nil && runtime.Admin() != nil {
|
return config
|
||||||
config = runtime.Admin().Websocket
|
}
|
||||||
|
|
||||||
|
func (s *Server) apply(config runtimeconfig.Config) {
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if config == nil {
|
values := map[string]any{}
|
||||||
config = &conf.AdminBackend_WebSocket{}
|
if len(config.Values) > 0 {
|
||||||
|
if err := json.Unmarshal(config.Values, &values); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
if s.closed {
|
if s.closed {
|
||||||
|
|
@ -70,14 +82,21 @@ func (s *Server) Replace(runtime *conf.Runtime) {
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
path := text(values, "path")
|
||||||
|
if path == "" {
|
||||||
|
path = "/ws"
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(path, "/") {
|
||||||
|
path = "/" + path
|
||||||
|
}
|
||||||
next := platformws.New(platformws.Config{
|
next := platformws.New(platformws.Config{
|
||||||
WriteWait: duration(config.WriteWait, 10*time.Second),
|
WriteWait: durationValue(values, "write_wait", 10*time.Second),
|
||||||
PongWait: duration(config.PongWait, 60*time.Second),
|
PongWait: durationValue(values, "pong_wait", 60*time.Second),
|
||||||
PingPeriod: duration(config.PingPeriod, 54*time.Second),
|
PingPeriod: durationValue(values, "ping_period", 54*time.Second),
|
||||||
MaxMessageSize: config.MaxMessageSize,
|
MaxMessageSize: int64Value(values, "max_message_size"),
|
||||||
MessageBufferSize: int(config.MessageBufferSize),
|
MessageBufferSize: int(intValue(values, "message_buffer_size")),
|
||||||
ConcurrentMessageHandling: config.ConcurrentMessageHandling,
|
ConcurrentMessageHandling: boolValue(values, "concurrent_message_handling"),
|
||||||
AllowOrigins: config.AllowOrigins,
|
AllowOrigins: stringList(values, "allow_origins"),
|
||||||
})
|
})
|
||||||
for _, handler := range s.messageHandlers {
|
for _, handler := range s.messageHandlers {
|
||||||
next.OnMessage(handler)
|
next.OnMessage(handler)
|
||||||
|
|
@ -91,27 +110,65 @@ func (s *Server) Replace(runtime *conf.Runtime) {
|
||||||
for _, handler := range s.disconnectHandlers {
|
for _, handler := range s.disconnectHandlers {
|
||||||
next.OnDisconnect(handler)
|
next.OnDisconnect(handler)
|
||||||
}
|
}
|
||||||
s.path = strings.TrimSpace(config.Path)
|
|
||||||
if s.path == "" {
|
|
||||||
s.path = "/ws"
|
|
||||||
} else if !strings.HasPrefix(s.path, "/") {
|
|
||||||
s.path = "/" + s.path
|
|
||||||
}
|
|
||||||
s.current = next
|
s.current = next
|
||||||
|
s.path = path
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
if previous != nil {
|
if previous != nil {
|
||||||
_ = previous.Close()
|
_ = previous.Close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func duration(value interface{ AsDuration() time.Duration }, fallback time.Duration) time.Duration {
|
func text(values map[string]any, key string) string {
|
||||||
if value == nil {
|
value, ok := values[key]
|
||||||
return fallback
|
if !ok || value == nil {
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
if result := value.AsDuration(); result > 0 {
|
return strings.TrimSpace(fmt.Sprint(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
func intValue(values map[string]any, key string) int64 {
|
||||||
|
switch value := values[key].(type) {
|
||||||
|
case float64:
|
||||||
|
return int64(value)
|
||||||
|
case int:
|
||||||
|
return int64(value)
|
||||||
|
case json.Number:
|
||||||
|
parsed, _ := strconv.ParseInt(string(value), 10, 64)
|
||||||
|
return parsed
|
||||||
|
default:
|
||||||
|
parsed, _ := strconv.ParseInt(text(values, key), 10, 64)
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func int64Value(values map[string]any, key string) int64 { return intValue(values, key) }
|
||||||
|
func boolValue(values map[string]any, key string) bool { value, _ := values[key].(bool); return value }
|
||||||
|
func stringList(values map[string]any, key string) []string {
|
||||||
|
value, ok := values[key].([]any)
|
||||||
|
if ok {
|
||||||
|
result := make([]string, 0, len(value))
|
||||||
|
for _, item := range value {
|
||||||
|
if item != nil && strings.TrimSpace(fmt.Sprint(item)) != "" {
|
||||||
|
result = append(result, strings.TrimSpace(fmt.Sprint(item)))
|
||||||
|
}
|
||||||
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
return fallback
|
if value, ok := values[key].([]string); ok {
|
||||||
|
return append([]string(nil), value...)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func durationValue(values map[string]any, key string, fallback time.Duration) time.Duration {
|
||||||
|
value := text(values, key)
|
||||||
|
if value == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
parsed, err := time.ParseDuration(value)
|
||||||
|
if err != nil || parsed <= 0 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) Enabled() bool {
|
func (s *Server) Enabled() bool {
|
||||||
|
|
@ -131,14 +188,9 @@ func (s *Server) Path() string {
|
||||||
return s.path
|
return s.path
|
||||||
}
|
}
|
||||||
func (s *Server) HandleRequest(w http.ResponseWriter, r *http.Request) error {
|
func (s *Server) HandleRequest(w http.ResponseWriter, r *http.Request) error {
|
||||||
if s == nil {
|
current, err := s.active()
|
||||||
return errors.New("websocket server is disabled")
|
if err != nil {
|
||||||
}
|
return err
|
||||||
s.mu.RLock()
|
|
||||||
current := s.current
|
|
||||||
s.mu.RUnlock()
|
|
||||||
if current == nil {
|
|
||||||
return errors.New("websocket server is disabled")
|
|
||||||
}
|
}
|
||||||
return current.HandleRequest(w, r)
|
return current.HandleRequest(w, r)
|
||||||
}
|
}
|
||||||
|
|
@ -150,26 +202,16 @@ func (s *Server) HandleRequestWithKeys(w http.ResponseWriter, r *http.Request, k
|
||||||
return current.HandleRequestWithKeys(w, r, keys)
|
return current.HandleRequestWithKeys(w, r, keys)
|
||||||
}
|
}
|
||||||
func (s *Server) Broadcast(message []byte) error {
|
func (s *Server) Broadcast(message []byte) error {
|
||||||
if s == nil {
|
current, err := s.active()
|
||||||
return errors.New("websocket server is disabled")
|
if err != nil {
|
||||||
}
|
return err
|
||||||
s.mu.RLock()
|
|
||||||
current := s.current
|
|
||||||
s.mu.RUnlock()
|
|
||||||
if current == nil {
|
|
||||||
return errors.New("websocket server is disabled")
|
|
||||||
}
|
}
|
||||||
return current.Broadcast(message)
|
return current.Broadcast(message)
|
||||||
}
|
}
|
||||||
func (s *Server) BroadcastBinary(message []byte) error {
|
func (s *Server) BroadcastBinary(message []byte) error {
|
||||||
if s == nil {
|
current, err := s.active()
|
||||||
return errors.New("websocket server is disabled")
|
if err != nil {
|
||||||
}
|
return err
|
||||||
s.mu.RLock()
|
|
||||||
current := s.current
|
|
||||||
s.mu.RUnlock()
|
|
||||||
if current == nil {
|
|
||||||
return errors.New("websocket server is disabled")
|
|
||||||
}
|
}
|
||||||
return current.BroadcastBinary(message)
|
return current.BroadcastBinary(message)
|
||||||
}
|
}
|
||||||
|
|
@ -231,8 +273,9 @@ func (s *Server) OnBinaryMessage(handler func(*melody.Session, []byte)) {
|
||||||
}
|
}
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.binaryHandlers = append(s.binaryHandlers, handler)
|
s.binaryHandlers = append(s.binaryHandlers, handler)
|
||||||
if s.current != nil {
|
current := s.current
|
||||||
s.current.OnBinaryMessage(handler)
|
if current != nil {
|
||||||
|
current.OnBinaryMessage(handler)
|
||||||
}
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
@ -242,8 +285,9 @@ func (s *Server) OnConnect(handler func(*melody.Session)) {
|
||||||
}
|
}
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.connectHandlers = append(s.connectHandlers, handler)
|
s.connectHandlers = append(s.connectHandlers, handler)
|
||||||
if s.current != nil {
|
current := s.current
|
||||||
s.current.OnConnect(handler)
|
if current != nil {
|
||||||
|
current.OnConnect(handler)
|
||||||
}
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
@ -253,8 +297,9 @@ func (s *Server) OnDisconnect(handler func(*melody.Session)) {
|
||||||
}
|
}
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.disconnectHandlers = append(s.disconnectHandlers, handler)
|
s.disconnectHandlers = append(s.disconnectHandlers, handler)
|
||||||
if s.current != nil {
|
current := s.current
|
||||||
s.current.OnDisconnect(handler)
|
if current != nil {
|
||||||
|
current.OnDisconnect(handler)
|
||||||
}
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,12 @@ func NewGinEngineWithRuntime(runtime *conf.Runtime, access *service.AccessContro
|
||||||
}
|
}
|
||||||
registerSwagger(engine, prefix, version, logger)
|
registerSwagger(engine, prefix, version, logger)
|
||||||
registerLocalStorage(engine, runtime)
|
registerLocalStorage(engine, runtime)
|
||||||
|
if logger != nil {
|
||||||
|
for _, route := range engine.Routes() {
|
||||||
|
logger.Info("router registered", "method", route.Method, "path", route.Path)
|
||||||
|
}
|
||||||
|
logger.Info("router register success", "route_count", len(engine.Routes()))
|
||||||
|
}
|
||||||
|
|
||||||
engine.NoRoute(func(c *gin.Context) {
|
engine.NoRoute(func(c *gin.Context) {
|
||||||
if ws != nil && ws.Enabled() && c.Request.Method == http.MethodGet && c.Request.URL.Path == ws.Path() {
|
if ws != nil && ws.Enabled() && c.Request.Method == http.MethodGet && c.Request.URL.Path == ws.Path() {
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,14 @@ package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"mime"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -67,14 +71,26 @@ func AccessLog(runtime *conf.Runtime, logger *slog.Logger, version string) gin.H
|
||||||
if config != nil && config.Zap != nil && config.Zap.AccessLogMaxBytes > 0 {
|
if config != nil && config.Zap != nil && config.Zap.AccessLogMaxBytes > 0 {
|
||||||
logLimit = int(config.Zap.AccessLogMaxBytes)
|
logLimit = int(config.Zap.AccessLogMaxBytes)
|
||||||
}
|
}
|
||||||
|
paymentCallback := isPaymentCallbackPath(c.Request.URL.Path)
|
||||||
|
paymentConfigWrite := isPaymentIntegrationConfigWrite(c.Request.Method, c.Request.URL.Path)
|
||||||
requestText := ""
|
requestText := ""
|
||||||
if multipart {
|
if paymentCallback {
|
||||||
|
requestText = paymentCallbackSummary(requestBody, c.GetHeader("Content-Type"))
|
||||||
|
} else if paymentConfigWrite {
|
||||||
|
requestText = paymentConfigSummary(requestBody)
|
||||||
|
} else if multipart {
|
||||||
requestText = "[文件]"
|
requestText = "[文件]"
|
||||||
} else {
|
} else {
|
||||||
requestText = redactJSON(requestBody, c.GetHeader("Content-Type"), logLimit)
|
requestText = redactJSON(requestBody, c.GetHeader("Content-Type"), logLimit)
|
||||||
}
|
}
|
||||||
c.Set(ctxReqBodyKey, requestText)
|
c.Set(ctxReqBodyKey, requestText)
|
||||||
c.Set(ctxRespBufferKey, &writer.body)
|
if paymentCallback {
|
||||||
|
// Callback acknowledgements and provider payloads must not flow into
|
||||||
|
// the generic response/error audit pipeline.
|
||||||
|
c.Set(ctxRespBufferKey, &bytes.Buffer{})
|
||||||
|
} else {
|
||||||
|
c.Set(ctxRespBufferKey, &writer.body)
|
||||||
|
}
|
||||||
if !requestReadFailed {
|
if !requestReadFailed {
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
|
|
@ -82,6 +98,9 @@ func AccessLog(runtime *conf.Runtime, logger *slog.Logger, version string) gin.H
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
responseText := redactJSON(writer.body.Bytes(), c.Writer.Header().Get("Content-Type"), logLimit)
|
responseText := redactJSON(writer.body.Bytes(), c.Writer.Header().Get("Content-Type"), logLimit)
|
||||||
|
if paymentCallback {
|
||||||
|
responseText = "[支付回调响应已省略]"
|
||||||
|
}
|
||||||
userID, authorityID := uint(0), uint(0)
|
userID, authorityID := uint(0), uint(0)
|
||||||
if claims := Claims(c); claims != nil {
|
if claims := Claims(c); claims != nil {
|
||||||
userID, authorityID = claims.ID, claims.AuthorityID
|
userID, authorityID = claims.ID, claims.AuthorityID
|
||||||
|
|
@ -95,22 +114,36 @@ func AccessLog(runtime *conf.Runtime, logger *slog.Logger, version string) gin.H
|
||||||
bytesOut = 0
|
bytesOut = 0
|
||||||
}
|
}
|
||||||
privateErrors := strings.TrimRight(c.Errors.ByType(gin.ErrorTypePrivate).String(), "\n")
|
privateErrors := strings.TrimRight(c.Errors.ByType(gin.ErrorTypePrivate).String(), "\n")
|
||||||
attributes := []any{
|
var attributes []any
|
||||||
"mod", "http", "ip", c.ClientIP(), "method", c.Request.Method, "http_path", c.Request.URL.Path, "http_route", route,
|
if paymentCallback {
|
||||||
"http_status", c.Writer.Status(), "latency_ms", time.Since(started).Milliseconds(),
|
attributes = []any{
|
||||||
"request_id", stringValueFromContext(c, "request_id"), "trace_id", stringValueFromContext(c, "trace_id"),
|
"mod", "payment-callback", "payment_provider", paymentCallbackProvider(c.Request.URL.Path),
|
||||||
"bytes_in", bytesIn, "bytes_out", bytesOut, "user_id", userID, "authority_id", authorityID,
|
"http_status", c.Writer.Status(), "latency_ms", time.Since(started).Milliseconds(),
|
||||||
"error", c.Writer.Status() >= http.StatusInternalServerError || privateErrors != "", "ua", c.Request.UserAgent(), "req_query", c.Request.URL.RawQuery}
|
"request_id", stringValueFromContext(c, "request_id"), "trace_id", stringValueFromContext(c, "trace_id"),
|
||||||
if config != nil && config.Zap != nil && config.Zap.AccessReqHeaders {
|
"bytes_in", bytesIn, "bytes_out", bytesOut,
|
||||||
|
"error", c.Writer.Status() >= http.StatusInternalServerError || privateErrors != "",
|
||||||
|
"payment_callback", true, "payment_callback_summary", requestText,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
attributes = []any{
|
||||||
|
"mod", "http", "ip", c.ClientIP(), "method", c.Request.Method, "http_path", c.Request.URL.Path, "http_route", route,
|
||||||
|
"http_status", c.Writer.Status(), "latency_ms", time.Since(started).Milliseconds(),
|
||||||
|
"request_id", stringValueFromContext(c, "request_id"), "trace_id", stringValueFromContext(c, "trace_id"),
|
||||||
|
"bytes_in", bytesIn, "bytes_out", bytesOut, "user_id", userID, "authority_id", authorityID,
|
||||||
|
"error", c.Writer.Status() >= http.StatusInternalServerError || privateErrors != "", "ua", c.Request.UserAgent(),
|
||||||
|
"req_query", c.Request.URL.RawQuery,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !paymentCallback && config != nil && config.Zap != nil && config.Zap.AccessReqHeaders {
|
||||||
attributes = append(attributes, "req_headers", redactHeaders(c.Request.Header))
|
attributes = append(attributes, "req_headers", redactHeaders(c.Request.Header))
|
||||||
}
|
}
|
||||||
if config != nil && config.Zap != nil && config.Zap.AccessReqBody {
|
if !paymentCallback && config != nil && config.Zap != nil && config.Zap.AccessReqBody {
|
||||||
attributes = append(attributes, "req_body", requestText)
|
attributes = append(attributes, "req_body", requestText)
|
||||||
}
|
}
|
||||||
if config != nil && config.Zap != nil && config.Zap.AccessRespData {
|
if !paymentCallback && config != nil && config.Zap != nil && config.Zap.AccessRespData {
|
||||||
attributes = append(attributes, "resp_data", responseText)
|
attributes = append(attributes, "resp_data", responseText)
|
||||||
}
|
}
|
||||||
if privateErrors != "" {
|
if !paymentCallback && privateErrors != "" {
|
||||||
attributes = append(attributes, "error_msg", privateErrors)
|
attributes = append(attributes, "error_msg", privateErrors)
|
||||||
}
|
}
|
||||||
logger.InfoContext(c.Request.Context(), "请求完成", attributes...)
|
logger.InfoContext(c.Request.Context(), "请求完成", attributes...)
|
||||||
|
|
@ -122,6 +155,56 @@ func isMediaUploadRoute(route string) bool {
|
||||||
strings.HasSuffix(route, "/mediaUpload/chunk")
|
strings.HasSuffix(route, "/mediaUpload/chunk")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isPaymentCallbackPath(path string) bool {
|
||||||
|
parts := strings.Split(strings.Trim(path, "/"), "/")
|
||||||
|
for index := 0; index+1 < len(parts); index++ {
|
||||||
|
if parts[index] == "payment" && parts[index+1] == "callback" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func paymentCallbackProvider(path string) string {
|
||||||
|
parts := strings.Split(strings.Trim(path, "/"), "/")
|
||||||
|
for index := 0; index+2 < len(parts); index++ {
|
||||||
|
if parts[index] == "payment" && parts[index+1] == "callback" {
|
||||||
|
return parts[index+2]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
func paymentCallbackSummary(body []byte, contentType string) string {
|
||||||
|
mediaType, _, err := mime.ParseMediaType(contentType)
|
||||||
|
if err != nil || mediaType == "" {
|
||||||
|
mediaType = strings.TrimSpace(strings.SplitN(contentType, ";", 2)[0])
|
||||||
|
}
|
||||||
|
if mediaType == "" {
|
||||||
|
mediaType = "unknown"
|
||||||
|
}
|
||||||
|
digest := sha256.Sum256(body)
|
||||||
|
return "[支付回调正文已省略 body_bytes=" + strconv.Itoa(len(body)) + " body_sha256=" + hex.EncodeToString(digest[:]) + " content_type=" + mediaType + "]"
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPaymentIntegrationConfigWrite(method, path string) bool {
|
||||||
|
if method != http.MethodPut {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
parts := strings.Split(strings.Trim(path, "/"), "/")
|
||||||
|
for index := 0; index+3 < len(parts); index++ {
|
||||||
|
if parts[index] == "integration" && parts[index+1] == "configs" && parts[index+2] == "payment" && parts[index+3] != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func paymentConfigSummary(body []byte) string {
|
||||||
|
digest := sha256.Sum256(body)
|
||||||
|
return "[支付配置正文已省略 body_bytes=" + strconv.Itoa(len(body)) + " body_sha256=" + hex.EncodeToString(digest[:]) + "]"
|
||||||
|
}
|
||||||
|
|
||||||
func redactHeaders(headers map[string][]string) map[string]string {
|
func redactHeaders(headers map[string][]string) map[string]string {
|
||||||
out := make(map[string]string, len(headers))
|
out := make(map[string]string, len(headers))
|
||||||
for key, values := range headers {
|
for key, values := range headers {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"io"
|
"io"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -63,3 +65,60 @@ func TestAccessLogAllowsMediaLimitOnlyOnUploadRoute(t *testing.T) {
|
||||||
t.Fatalf("status=%d called=%v body=%s", response.Code, called, response.Body.String())
|
t.Fatalf("status=%d called=%v body=%s", response.Code, called, response.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAccessLogRedactsPaymentCallbackPayloadAndHeaders(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
var logs bytes.Buffer
|
||||||
|
logger := slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||||
|
runtime := conf.NewRuntime(nil, &conf.AdminBackend{Zap: &conf.AdminBackend_Zap{AccessReqBody: true, AccessReqHeaders: true, AccessRespData: true}})
|
||||||
|
engine := gin.New()
|
||||||
|
engine.Use(AccessLog(runtime, logger, "test"))
|
||||||
|
engine.POST("/api/payment/callback/:provider", func(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"body": "callback-response-secret"})
|
||||||
|
})
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/api/payment/callback/alipay?signature=query-secret", strings.NewReader("payment-body-secret"))
|
||||||
|
request.Header.Set("Content-Type", "application/json; boundary=credential-secret")
|
||||||
|
request.Header.Set("Authorization", "Bearer header-secret")
|
||||||
|
request.Header.Set("X-Alipay-Signature", "signature-secret")
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
|
||||||
|
engine.ServeHTTP(response, request)
|
||||||
|
logText := logs.String()
|
||||||
|
for _, secret := range []string{"payment-body-secret", "query-secret", "header-secret", "signature-secret", "callback-response-secret", "credential-secret"} {
|
||||||
|
if strings.Contains(logText, secret) {
|
||||||
|
t.Fatalf("payment callback secret leaked into access log: %q in %s", secret, logText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, marker := range []string{"payment_callback=true", "payment_provider=alipay", "body_sha256=", "http_status=200"} {
|
||||||
|
if !strings.Contains(logText, marker) {
|
||||||
|
t.Fatalf("payment callback access summary missing %q: %s", marker, logText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccessLogOmitsPaymentIntegrationConfigBody(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
var logs bytes.Buffer
|
||||||
|
logger := slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||||
|
runtime := conf.NewRuntime(nil, &conf.AdminBackend{Zap: &conf.AdminBackend_Zap{AccessReqBody: true, AccessReqHeaders: true, AccessRespData: true}})
|
||||||
|
engine := gin.New()
|
||||||
|
engine.Use(AccessLog(runtime, logger, "test"))
|
||||||
|
engine.PUT("/api/integration/configs/:kind/:provider", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"code": 0}) })
|
||||||
|
|
||||||
|
body := `{"enabled":true,"config":{"key":"saobei-secret","certificate_blob":"certificate-secret","unknown_credential":"credential-secret"}}`
|
||||||
|
request := httptest.NewRequest(http.MethodPut, "/api/integration/configs/payment/saobei", strings.NewReader(body))
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
|
||||||
|
engine.ServeHTTP(response, request)
|
||||||
|
logText := logs.String()
|
||||||
|
for _, secret := range []string{"saobei-secret", "certificate-secret", "credential-secret"} {
|
||||||
|
if strings.Contains(logText, secret) {
|
||||||
|
t.Fatalf("payment configuration secret leaked into access log: %q in %s", secret, logText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(logText, "支付配置正文已省略") || !strings.Contains(logText, "body_sha256=") {
|
||||||
|
t.Fatalf("payment configuration summary missing: %s", logText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,11 @@ func OperationAudit(runtime *conf.Runtime, recorder *service.AuditRecorder) gin.
|
||||||
responseBody = "[超出记录长度]"
|
responseBody = "[超出记录长度]"
|
||||||
}
|
}
|
||||||
errorMessage := c.Errors.ByType(gin.ErrorTypePrivate).String()
|
errorMessage := c.Errors.ByType(gin.ErrorTypePrivate).String()
|
||||||
if err := recorder.RecordOperationRequest(c.Request.Context(), &dto.OperationRecordRequest{IP: c.ClientIP(), Method: c.Request.Method, Path: path, Status: status, LatencyMS: time.Since(started).Milliseconds(), Agent: c.Request.UserAgent(), ErrorMessage: errorMessage, Body: operationRequestBody(requestBody, c.GetHeader("Content-Type"), maxBytes), Response: responseBody, UserID: userID, RequestID: stringValue(requestID), TraceID: stringValueFromContext(c, "trace_id"), DeviceID: c.GetHeader("X-Device-Id")}); err != nil {
|
operationBody := operationRequestBody(requestBody, c.GetHeader("Content-Type"), maxBytes)
|
||||||
|
if isPaymentIntegrationConfigWrite(c.Request.Method, path) {
|
||||||
|
operationBody = paymentConfigSummary(requestBody)
|
||||||
|
}
|
||||||
|
if err := recorder.RecordOperationRequest(c.Request.Context(), &dto.OperationRecordRequest{IP: c.ClientIP(), Method: c.Request.Method, Path: path, Status: status, LatencyMS: time.Since(started).Milliseconds(), Agent: c.Request.UserAgent(), ErrorMessage: errorMessage, Body: operationBody, Response: responseBody, UserID: userID, RequestID: stringValue(requestID), TraceID: stringValueFromContext(c, "trace_id"), DeviceID: c.GetHeader("X-Device-Id")}); err != nil {
|
||||||
// Preserve the business response, but expose audit persistence failures
|
// Preserve the business response, but expose audit persistence failures
|
||||||
// to the global access/error logging pipeline.
|
// to the global access/error logging pipeline.
|
||||||
c.Set(ctxOperationAuditPersistFailedKey, true)
|
c.Set(ctxOperationAuditPersistFailedKey, true)
|
||||||
|
|
@ -128,7 +132,7 @@ func maskOperationBody(value any) {
|
||||||
case map[string]any:
|
case map[string]any:
|
||||||
for key, item := range current {
|
for key, item := range current {
|
||||||
normalized := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(key, "_", ""), "-", ""))
|
normalized := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(key, "_", ""), "-", ""))
|
||||||
if normalized == "password" || normalized == "newpassword" || normalized == "oldpassword" || normalized == "confirmpassword" || normalized == "passwd" || normalized == "pwd" || normalized == "token" || normalized == "accesstoken" || normalized == "refreshtoken" || normalized == "secret" || normalized == "clientsecret" || normalized == "apikey" || normalized == "privatekey" || normalized == "idcard" {
|
if normalized == "password" || normalized == "newpassword" || normalized == "oldpassword" || normalized == "confirmpassword" || normalized == "passwd" || normalized == "pwd" || normalized == "token" || normalized == "accesstoken" || normalized == "refreshtoken" || normalized == "secret" || normalized == "clientsecret" || normalized == "apikey" || normalized == "privatekey" || normalized == "idcard" || normalized == "appkey" || normalized == "mchkey" || normalized == "apiv3key" || normalized == "clientcert" || normalized == "clientkey" || normalized == "platformcert" || normalized == "platformserialno" || normalized == "credentialcode" || normalized == "certfile" || normalized == "keyfile" || normalized == "publickey" || normalized == "rootcert" || normalized == "appcert" || normalized == "webhookid" {
|
||||||
current[key] = "***"
|
current[key] = "***"
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -157,17 +161,49 @@ func isDownloadResponse(c *gin.Context) bool {
|
||||||
// recordsOperation mirrors the routes on which operation records are enabled.
|
// recordsOperation mirrors the routes on which operation records are enabled.
|
||||||
// Matching by suffix keeps the behavior stable when router-prefix is configured.
|
// Matching by suffix keeps the behavior stable when router-prefix is configured.
|
||||||
func recordsOperation(method, path string) bool {
|
func recordsOperation(method, path string) bool {
|
||||||
_, ok := operationRoutes[method+" "+routeSuffix(path)]
|
for route := range operationRoutes {
|
||||||
return ok
|
parts := strings.SplitN(route, " ", 2)
|
||||||
|
if len(parts) != 2 || parts[0] != method || !operationPathMatches(parts[1], path) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func operationPathMatches(pattern, path string) bool {
|
||||||
|
patternParts := strings.Split(strings.Trim(pattern, "/"), "/")
|
||||||
|
pathParts := strings.Split(strings.Trim(path, "/"), "/")
|
||||||
|
if len(pathParts) < len(patternParts) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
pathParts = pathParts[len(pathParts)-len(patternParts):]
|
||||||
|
for index, patternPart := range patternParts {
|
||||||
|
if strings.HasPrefix(patternPart, "*") {
|
||||||
|
return index <= len(pathParts)
|
||||||
|
}
|
||||||
|
if index >= len(pathParts) || (strings.HasPrefix(patternPart, ":") == false && patternPart != pathParts[index]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return len(patternParts) == len(pathParts)
|
||||||
}
|
}
|
||||||
|
|
||||||
func routeSuffix(path string) string {
|
func routeSuffix(path string) string {
|
||||||
for _, marker := range []string{"/user/", "/api/", "/casbin/", "/authority/", "/menu/", "/department/", "/position/", "/sysDictionary/", "/sysDictionaryDetail/", "/sysParams/", "/securityConfig/", "/system/", "/sysApiToken/", "/sysVersion/", "/sysExportTemplate/", "/sysError/", "/sysLoginLog/", "/sysOperationRecord/", "/dataAccessLog/", "/timedTask/", "/info/", "/email/"} {
|
bestIndex := -1
|
||||||
|
bestPath := path
|
||||||
|
for _, marker := range []string{"/user/", "/api/", "/casbin/", "/authority/", "/menu/", "/department/", "/position/", "/sysDictionary/", "/sysDictionaryDetail/", "/sysParams/", "/securityConfig/", "/system/", "/sysApiToken/", "/sysVersion/", "/sysExportTemplate/", "/sysError/", "/sysLoginLog/", "/sysOperationRecord/", "/dataAccessLog/", "/timedTask/", "/info/", "/email/", "/integration/", "/payment/"} {
|
||||||
if index := strings.Index(path, marker); index >= 0 {
|
if index := strings.Index(path, marker); index >= 0 {
|
||||||
return path[index:]
|
// Router prefixes may themselves contain a registered route marker
|
||||||
|
// (for example /api/integration/...). Keep the deepest match so the
|
||||||
|
// policy and audit route remain the actual application endpoint.
|
||||||
|
if index > bestIndex {
|
||||||
|
bestIndex = index
|
||||||
|
bestPath = path[index:]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return path
|
return bestPath
|
||||||
}
|
}
|
||||||
|
|
||||||
var operationRoutes = func() map[string]struct{} {
|
var operationRoutes = func() map[string]struct{} {
|
||||||
|
|
@ -189,6 +225,8 @@ var operationRoutes = func() map[string]struct{} {
|
||||||
"DELETE /sysLoginLog/deleteLoginLog", "DELETE /sysLoginLog/deleteLoginLogByIds", "DELETE /dataAccessLog/deleteDataAccessLogByIds",
|
"DELETE /sysLoginLog/deleteLoginLog", "DELETE /sysLoginLog/deleteLoginLogByIds", "DELETE /dataAccessLog/deleteDataAccessLogByIds",
|
||||||
"POST /timedTask/createTimedTask", "PUT /timedTask/updateTimedTask", "DELETE /timedTask/deleteTimedTask", "POST /timedTask/toggleTimedTask", "POST /timedTask/triggerTimedTask",
|
"POST /timedTask/createTimedTask", "PUT /timedTask/updateTimedTask", "DELETE /timedTask/deleteTimedTask", "POST /timedTask/toggleTimedTask", "POST /timedTask/triggerTimedTask",
|
||||||
"POST /info/createInfo", "DELETE /info/deleteInfo", "DELETE /info/deleteInfoByIds", "PUT /info/updateInfo", "POST /email/emailTest", "POST /email/sendEmail",
|
"POST /info/createInfo", "DELETE /info/deleteInfo", "DELETE /info/deleteInfoByIds", "PUT /info/updateInfo", "POST /email/emailTest", "POST /email/sendEmail",
|
||||||
|
"PUT /integration/configs/:kind/:provider", "DELETE /integration/configs/:kind/:provider",
|
||||||
|
"POST /payment/create", "POST /payment/query", "POST /payment/refund", "POST /payment/orders/:provider/:tradeNo/refund", "POST /payment/fulfill", "POST /payment/orders/:provider/:tradeNo/fulfill", "POST /payment/providers/:provider/test",
|
||||||
}
|
}
|
||||||
out := make(map[string]struct{}, len(values))
|
out := make(map[string]struct{}, len(values))
|
||||||
for _, value := range values {
|
for _, value := range values {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPaymentIntegrationSecretsAreRedacted(t *testing.T) {
|
||||||
|
raw := []byte(`{"enabled":true,"config":{"app_id":"app","mch_key":"merchant-secret","api_v3_key":"v3-secret","client_cert":"certificate","client_key":"private-key","platform_cert":"platform-certificate","credential_code":"credential","webhook_id":"webhook"}}`)
|
||||||
|
redacted := redactJSON(raw, "application/json", 4096)
|
||||||
|
var payload struct {
|
||||||
|
Config map[string]string `json:"config"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(redacted), &payload); err != nil {
|
||||||
|
t.Fatalf("decode redacted payload: %v", err)
|
||||||
|
}
|
||||||
|
for _, key := range []string{"mch_key", "api_v3_key", "client_cert", "client_key", "platform_cert", "credential_code", "webhook_id"} {
|
||||||
|
if payload.Config[key] != "***" {
|
||||||
|
t.Fatalf("payment secret %q was not redacted: %s", key, redacted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(redacted, `"app_id":"app"`) {
|
||||||
|
t.Fatalf("non-secret integration field was removed: %s", redacted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPaymentOperationsAreAuditedWithRouterPrefix(t *testing.T) {
|
||||||
|
for _, route := range []struct {
|
||||||
|
method string
|
||||||
|
path string
|
||||||
|
}{
|
||||||
|
{method: "PUT", path: "/api/integration/configs/payment/alipay"},
|
||||||
|
{method: "POST", path: "/api/payment/refund"},
|
||||||
|
{method: "POST", path: "/api/payment/fulfill"},
|
||||||
|
{method: "POST", path: "/api/payment/providers/alipay/test"},
|
||||||
|
} {
|
||||||
|
if !recordsOperation(route.method, route.path) {
|
||||||
|
t.Fatalf("payment operation was not audited: %s %s", route.method, route.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPaymentIntegrationConfigUsesRouteLevelSummary(t *testing.T) {
|
||||||
|
raw := []byte(`{"enabled":true,"config":{"key":"secret","custom_certificate":"certificate"}}`)
|
||||||
|
summary := paymentConfigSummary(raw)
|
||||||
|
if strings.Contains(summary, "secret") || strings.Contains(summary, "certificate") {
|
||||||
|
t.Fatalf("payment configuration summary leaked payload: %s", summary)
|
||||||
|
}
|
||||||
|
if !isPaymentIntegrationConfigWrite("PUT", "/api/integration/configs/payment/saobei") {
|
||||||
|
t.Fatal("payment configuration write route was not recognized")
|
||||||
|
}
|
||||||
|
if isPaymentIntegrationConfigWrite("PUT", "/api/integration/configs/mq/emqx") {
|
||||||
|
t.Fatal("non-payment integration was treated as payment configuration")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -47,6 +47,13 @@ var apiMetadata = map[string]apiMetadataValue{
|
||||||
"GET /integration/configs/:kind": {group: "集成配置", description: "按类型获取集成配置"},
|
"GET /integration/configs/:kind": {group: "集成配置", description: "按类型获取集成配置"},
|
||||||
"GET /integration/configs/:kind/:provider": {group: "集成配置", description: "获取指定集成配置"},
|
"GET /integration/configs/:kind/:provider": {group: "集成配置", description: "获取指定集成配置"},
|
||||||
"GET /payment/orders": {group: "支付", description: "分页查询支付订单"},
|
"GET /payment/orders": {group: "支付", description: "分页查询支付订单"},
|
||||||
|
"GET /payment/orders/:provider/:tradeNo": {group: "支付", description: "按路径查询支付订单"},
|
||||||
|
"POST /payment/create": {group: "支付", description: "创建支付订单"},
|
||||||
|
"POST /payment/query": {group: "支付", description: "同步支付订单状态"},
|
||||||
|
"POST /payment/refund": {group: "支付", description: "申请支付订单退款"},
|
||||||
|
"POST /payment/orders/:provider/:tradeNo/refund": {group: "支付", description: "按路径申请支付订单退款"},
|
||||||
|
"POST /payment/fulfill": {group: "支付", description: "重试支付订单发货"},
|
||||||
|
"POST /payment/orders/:provider/:tradeNo/fulfill": {group: "支付", description: "按路径重试支付订单发货"},
|
||||||
"POST /payment/providers/:provider/test": {group: "支付", description: "测试支付渠道配置与沙箱交易链路"},
|
"POST /payment/providers/:provider/test": {group: "支付", description: "测试支付渠道配置与沙箱交易链路"},
|
||||||
"GET /securityConfig/getSecurityConfig": {group: "安全配置", description: "获取安全配置"},
|
"GET /securityConfig/getSecurityConfig": {group: "安全配置", description: "获取安全配置"},
|
||||||
"GET /sysDictionary/exportSysDictionary": {group: "系统字典", description: "导出字典JSON"},
|
"GET /sysDictionary/exportSysDictionary": {group: "系统字典", description: "导出字典JSON"},
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,447 @@
|
||||||
|
package mq
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
amqp "github.com/rabbitmq/amqp091-go"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RabbitMQConfig struct {
|
||||||
|
Enabled bool
|
||||||
|
Host string
|
||||||
|
Port int
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
VHost string
|
||||||
|
Exchange string
|
||||||
|
ExchangeType string
|
||||||
|
Queue string
|
||||||
|
RoutingKey string
|
||||||
|
Durable bool
|
||||||
|
AutoDelete bool
|
||||||
|
PrefetchCount int
|
||||||
|
Heartbeat time.Duration
|
||||||
|
ConnectTimeout time.Duration
|
||||||
|
TLS bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type rabbitSubscription struct {
|
||||||
|
qos byte
|
||||||
|
handler Handler
|
||||||
|
}
|
||||||
|
|
||||||
|
// RabbitMQ adapts AMQP exchanges and routing keys to the shared topic-based
|
||||||
|
// Client contract. All subscriptions share the configured queue and a single
|
||||||
|
// consumer; deliveries are dispatched to matching handlers locally.
|
||||||
|
type RabbitMQ struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
opMu sync.Mutex
|
||||||
|
publishMu sync.Mutex
|
||||||
|
consumeMu sync.Mutex
|
||||||
|
connection *amqp.Connection
|
||||||
|
publishChannel *amqp.Channel
|
||||||
|
consumeChannel *amqp.Channel
|
||||||
|
config RabbitMQConfig
|
||||||
|
subscriptions map[string]rabbitSubscription
|
||||||
|
consumerTag string
|
||||||
|
consuming bool
|
||||||
|
stop chan struct{}
|
||||||
|
closed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRabbitMQ(config RabbitMQConfig) (*RabbitMQ, error) {
|
||||||
|
client := &RabbitMQ{subscriptions: make(map[string]rabbitSubscription), stop: make(chan struct{})}
|
||||||
|
if !config.Enabled {
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
config = defaultRabbitMQConfig(config)
|
||||||
|
if err := validateRabbitMQConfig(config); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
scheme := "amqp"
|
||||||
|
if config.TLS {
|
||||||
|
scheme = "amqps"
|
||||||
|
}
|
||||||
|
address := amqp.URI{
|
||||||
|
Scheme: scheme, Host: config.Host, Port: config.Port,
|
||||||
|
Username: config.Username, Password: config.Password, Vhost: config.VHost,
|
||||||
|
ConnectionTimeout: int(config.ConnectTimeout.Milliseconds()),
|
||||||
|
}.String()
|
||||||
|
connection, err := amqp.DialConfig(address, amqp.Config{
|
||||||
|
Heartbeat: config.Heartbeat,
|
||||||
|
Recovery: &amqp.Recovery{},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("connect rabbitmq: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
publishChannel, err := connection.Channel()
|
||||||
|
if err != nil {
|
||||||
|
_ = connection.Close()
|
||||||
|
return nil, fmt.Errorf("open rabbitmq publish channel: %w", err)
|
||||||
|
}
|
||||||
|
consumeChannel, err := connection.Channel()
|
||||||
|
if err != nil {
|
||||||
|
_ = publishChannel.Close()
|
||||||
|
_ = connection.Close()
|
||||||
|
return nil, fmt.Errorf("open rabbitmq consume channel: %w", err)
|
||||||
|
}
|
||||||
|
closeOnError := func() {
|
||||||
|
_ = consumeChannel.Close()
|
||||||
|
_ = publishChannel.Close()
|
||||||
|
_ = connection.Close()
|
||||||
|
}
|
||||||
|
if err = consumeChannel.ExchangeDeclare(config.Exchange, config.ExchangeType, config.Durable, config.AutoDelete, false, false, nil); err != nil {
|
||||||
|
closeOnError()
|
||||||
|
return nil, fmt.Errorf("declare rabbitmq exchange: %w", err)
|
||||||
|
}
|
||||||
|
if _, err = consumeChannel.QueueDeclare(config.Queue, config.Durable, config.AutoDelete, false, false, nil); err != nil {
|
||||||
|
closeOnError()
|
||||||
|
return nil, fmt.Errorf("declare rabbitmq queue: %w", err)
|
||||||
|
}
|
||||||
|
if config.PrefetchCount > 0 {
|
||||||
|
if err = consumeChannel.Qos(config.PrefetchCount, 0, false); err != nil {
|
||||||
|
closeOnError()
|
||||||
|
return nil, fmt.Errorf("configure rabbitmq qos: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
client.connection = connection
|
||||||
|
client.publishChannel = publishChannel
|
||||||
|
client.consumeChannel = consumeChannel
|
||||||
|
client.config = config
|
||||||
|
client.consumerTag = fmt.Sprintf("kra-%d", time.Now().UnixNano())
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultRabbitMQConfig(config RabbitMQConfig) RabbitMQConfig {
|
||||||
|
if config.Port <= 0 {
|
||||||
|
if config.TLS {
|
||||||
|
config.Port = 5671
|
||||||
|
} else {
|
||||||
|
config.Port = 5672
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if config.Username == "" {
|
||||||
|
config.Username = "guest"
|
||||||
|
}
|
||||||
|
if config.Password == "" {
|
||||||
|
config.Password = "guest"
|
||||||
|
}
|
||||||
|
if config.VHost == "" {
|
||||||
|
config.VHost = "/"
|
||||||
|
}
|
||||||
|
if config.ExchangeType == "" {
|
||||||
|
config.ExchangeType = "topic"
|
||||||
|
}
|
||||||
|
if config.RoutingKey == "" {
|
||||||
|
config.RoutingKey = "#"
|
||||||
|
}
|
||||||
|
if config.Heartbeat <= 0 {
|
||||||
|
config.Heartbeat = 10 * time.Second
|
||||||
|
}
|
||||||
|
if config.ConnectTimeout <= 0 {
|
||||||
|
config.ConnectTimeout = 10 * time.Second
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateRabbitMQConfig(config RabbitMQConfig) error {
|
||||||
|
if strings.TrimSpace(config.Host) == "" {
|
||||||
|
return errors.New("rabbitmq host is empty")
|
||||||
|
}
|
||||||
|
if config.Port < 1 || config.Port > 65535 {
|
||||||
|
return fmt.Errorf("invalid rabbitmq port %d", config.Port)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(config.Exchange) == "" {
|
||||||
|
return errors.New("rabbitmq exchange is empty")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(config.Queue) == "" {
|
||||||
|
return errors.New("rabbitmq queue is empty")
|
||||||
|
}
|
||||||
|
switch strings.ToLower(config.ExchangeType) {
|
||||||
|
case "direct", "fanout", "topic":
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("invalid rabbitmq exchange type %q", config.ExchangeType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RabbitMQ) Publish(ctx context.Context, topic string, payload []byte, qos byte, _ bool) error {
|
||||||
|
if c == nil {
|
||||||
|
return ErrUnavailable
|
||||||
|
}
|
||||||
|
if c != nil && strings.TrimSpace(topic) == "" {
|
||||||
|
topic = c.config.RoutingKey
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(topic) == "" {
|
||||||
|
return errors.New("rabbitmq routing key is empty")
|
||||||
|
}
|
||||||
|
if qos > AtLeastOnce {
|
||||||
|
return fmt.Errorf("rabbitmq supports qos 0 or 1, got %d", qos)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
c.publishMu.Lock()
|
||||||
|
defer c.publishMu.Unlock()
|
||||||
|
c.mu.RLock()
|
||||||
|
if c.closed || c.publishChannel == nil || c.publishChannel.IsClosed() {
|
||||||
|
c.mu.RUnlock()
|
||||||
|
return ErrUnavailable
|
||||||
|
}
|
||||||
|
channel := c.publishChannel
|
||||||
|
exchange := c.config.Exchange
|
||||||
|
c.mu.RUnlock()
|
||||||
|
deliveryMode := amqp.Transient
|
||||||
|
if qos >= AtLeastOnce {
|
||||||
|
deliveryMode = amqp.Persistent
|
||||||
|
}
|
||||||
|
return channel.PublishWithContext(ctx, exchange, topic, false, false, amqp.Publishing{
|
||||||
|
ContentType: "application/octet-stream",
|
||||||
|
DeliveryMode: deliveryMode,
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
Body: append([]byte(nil), payload...),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RabbitMQ) Subscribe(ctx context.Context, topic string, qos byte, handler Handler) error {
|
||||||
|
if c == nil {
|
||||||
|
return ErrUnavailable
|
||||||
|
}
|
||||||
|
if c != nil && strings.TrimSpace(topic) == "" {
|
||||||
|
topic = c.config.RoutingKey
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(topic) == "" {
|
||||||
|
return errors.New("rabbitmq routing key is empty")
|
||||||
|
}
|
||||||
|
if qos > AtLeastOnce {
|
||||||
|
return fmt.Errorf("rabbitmq supports qos 0 or 1, got %d", qos)
|
||||||
|
}
|
||||||
|
if handler == nil {
|
||||||
|
return errors.New("rabbitmq handler is nil")
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
c.opMu.Lock()
|
||||||
|
defer c.opMu.Unlock()
|
||||||
|
c.consumeMu.Lock()
|
||||||
|
defer c.consumeMu.Unlock()
|
||||||
|
c.mu.RLock()
|
||||||
|
if c.closed || c.consumeChannel == nil || c.consumeChannel.IsClosed() {
|
||||||
|
c.mu.RUnlock()
|
||||||
|
return ErrUnavailable
|
||||||
|
}
|
||||||
|
channel := c.consumeChannel
|
||||||
|
config := c.config
|
||||||
|
_, exists := c.subscriptions[topic]
|
||||||
|
c.mu.RUnlock()
|
||||||
|
if !exists {
|
||||||
|
if err := channel.QueueBind(config.Queue, topic, config.Exchange, false, nil); err != nil {
|
||||||
|
return fmt.Errorf("bind rabbitmq queue: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
c.subscriptions[topic] = rabbitSubscription{qos: qos, handler: handler}
|
||||||
|
shouldStart := !c.consuming
|
||||||
|
c.mu.Unlock()
|
||||||
|
if !shouldStart {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
deliveries, err := channel.Consume(config.Queue, c.consumerTag, false, false, false, false, nil)
|
||||||
|
if err != nil {
|
||||||
|
c.mu.Lock()
|
||||||
|
delete(c.subscriptions, topic)
|
||||||
|
c.mu.Unlock()
|
||||||
|
if !exists {
|
||||||
|
_ = channel.QueueUnbind(config.Queue, topic, config.Exchange, nil)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("consume rabbitmq queue: %w", err)
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
c.consuming = true
|
||||||
|
c.mu.Unlock()
|
||||||
|
go c.consume(deliveries)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RabbitMQ) consume(deliveries <-chan amqp.Delivery) {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.stop:
|
||||||
|
return
|
||||||
|
case delivery, ok := <-deliveries:
|
||||||
|
if !ok {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.consuming = false
|
||||||
|
c.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
subscriptions := c.subscriptionSnapshot()
|
||||||
|
for pattern, item := range subscriptions {
|
||||||
|
if rabbitRoutingKeyMatches(c.config.ExchangeType, pattern, delivery.RoutingKey) {
|
||||||
|
item.handler(context.Background(), Message{Topic: delivery.RoutingKey, Payload: append([]byte(nil), delivery.Body...), QoS: item.qos})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.consumeMu.Lock()
|
||||||
|
_ = delivery.Ack(false)
|
||||||
|
c.consumeMu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RabbitMQ) subscriptionSnapshot() map[string]rabbitSubscription {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
result := make(map[string]rabbitSubscription, len(c.subscriptions))
|
||||||
|
for topic, item := range c.subscriptions {
|
||||||
|
result[topic] = item
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RabbitMQ) Unsubscribe(ctx context.Context, topics ...string) error {
|
||||||
|
if c == nil {
|
||||||
|
return ErrUnavailable
|
||||||
|
}
|
||||||
|
if len(topics) == 0 {
|
||||||
|
return errors.New("rabbitmq routing keys are empty")
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
c.opMu.Lock()
|
||||||
|
defer c.opMu.Unlock()
|
||||||
|
c.consumeMu.Lock()
|
||||||
|
defer c.consumeMu.Unlock()
|
||||||
|
c.mu.RLock()
|
||||||
|
if c.closed || c.consumeChannel == nil || c.consumeChannel.IsClosed() {
|
||||||
|
c.mu.RUnlock()
|
||||||
|
return ErrUnavailable
|
||||||
|
}
|
||||||
|
channel := c.consumeChannel
|
||||||
|
config := c.config
|
||||||
|
c.mu.RUnlock()
|
||||||
|
for _, topic := range topics {
|
||||||
|
c.mu.RLock()
|
||||||
|
_, exists := c.subscriptions[topic]
|
||||||
|
c.mu.RUnlock()
|
||||||
|
if !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := channel.QueueUnbind(config.Queue, topic, config.Exchange, nil); err != nil {
|
||||||
|
return fmt.Errorf("unbind rabbitmq queue: %w", err)
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
delete(c.subscriptions, topic)
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
c.mu.RLock()
|
||||||
|
empty := len(c.subscriptions) == 0
|
||||||
|
consuming := c.consuming
|
||||||
|
c.mu.RUnlock()
|
||||||
|
if empty && consuming {
|
||||||
|
if err := channel.Cancel(c.consumerTag, false); err != nil {
|
||||||
|
return fmt.Errorf("cancel rabbitmq consumer: %w", err)
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
c.consuming = false
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RabbitMQ) Connected() bool {
|
||||||
|
if c == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
return !c.closed && c.connection != nil && !c.connection.IsClosed()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RabbitMQ) Close() error {
|
||||||
|
if c == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
c.opMu.Lock()
|
||||||
|
defer c.opMu.Unlock()
|
||||||
|
c.publishMu.Lock()
|
||||||
|
defer c.publishMu.Unlock()
|
||||||
|
c.consumeMu.Lock()
|
||||||
|
defer c.consumeMu.Unlock()
|
||||||
|
c.mu.Lock()
|
||||||
|
if c.closed {
|
||||||
|
c.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
c.closed = true
|
||||||
|
close(c.stop)
|
||||||
|
publishChannel := c.publishChannel
|
||||||
|
consumeChannel := c.consumeChannel
|
||||||
|
connection := c.connection
|
||||||
|
c.publishChannel = nil
|
||||||
|
c.consumeChannel = nil
|
||||||
|
c.connection = nil
|
||||||
|
c.mu.Unlock()
|
||||||
|
var result error
|
||||||
|
if consumeChannel != nil {
|
||||||
|
result = errors.Join(result, consumeChannel.Close())
|
||||||
|
}
|
||||||
|
if publishChannel != nil {
|
||||||
|
result = errors.Join(result, publishChannel.Close())
|
||||||
|
}
|
||||||
|
if connection != nil {
|
||||||
|
result = errors.Join(result, connection.Close())
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func rabbitRoutingKeyMatches(exchangeType, pattern, routingKey string) bool {
|
||||||
|
switch strings.ToLower(exchangeType) {
|
||||||
|
case "fanout":
|
||||||
|
return true
|
||||||
|
case "direct":
|
||||||
|
return pattern == routingKey
|
||||||
|
}
|
||||||
|
patternParts := strings.Split(pattern, ".")
|
||||||
|
routingParts := strings.Split(routingKey, ".")
|
||||||
|
for len(patternParts) > 0 {
|
||||||
|
head := patternParts[0]
|
||||||
|
patternParts = patternParts[1:]
|
||||||
|
if head == "#" {
|
||||||
|
if len(patternParts) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for index := 0; index <= len(routingParts); index++ {
|
||||||
|
if rabbitTopicPartsMatch(patternParts, routingParts[index:]) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(routingParts) == 0 || (head != "*" && head != routingParts[0]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
routingParts = routingParts[1:]
|
||||||
|
}
|
||||||
|
return len(routingParts) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func rabbitTopicPartsMatch(patternParts, routingParts []string) bool {
|
||||||
|
return rabbitRoutingKeyMatches("topic", strings.Join(patternParts, "."), strings.Join(routingParts, "."))
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
package mq
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDisabledRabbitMQIsSafeAndUnavailable(t *testing.T) {
|
||||||
|
client, err := NewRabbitMQ(RabbitMQConfig{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if client.Connected() {
|
||||||
|
t.Fatal("disabled rabbitmq reported connected")
|
||||||
|
}
|
||||||
|
if err = client.Publish(context.Background(), "orders.paid", []byte("test"), AtLeastOnce, false); !errors.Is(err, ErrUnavailable) {
|
||||||
|
t.Fatalf("publish error = %v", err)
|
||||||
|
}
|
||||||
|
if err = client.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnabledRabbitMQRequiresTopology(t *testing.T) {
|
||||||
|
if _, err := NewRabbitMQ(RabbitMQConfig{Enabled: true}); err == nil {
|
||||||
|
t.Fatal("enabled rabbitmq without host and topology should fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRabbitRoutingKeyMatches(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
pattern string
|
||||||
|
key string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{pattern: "orders.*.paid", key: "orders.cn.paid", want: true},
|
||||||
|
{pattern: "orders.#", key: "orders.cn.created", want: true},
|
||||||
|
{pattern: "#.paid", key: "orders.cn.paid", want: true},
|
||||||
|
{pattern: "orders.*", key: "orders.cn.paid", want: false},
|
||||||
|
{pattern: "orders.created", key: "orders.paid", want: false},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
if got := rabbitRoutingKeyMatches("topic", test.pattern, test.key); got != test.want {
|
||||||
|
t.Fatalf("match(%q, %q) = %v, want %v", test.pattern, test.key, got, test.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,25 +3,36 @@ import service from '@/utils/request'
|
||||||
export const getPaymentOrders = (params) => service({
|
export const getPaymentOrders = (params) => service({
|
||||||
url: '/payment/orders',
|
url: '/payment/orders',
|
||||||
method: 'get',
|
method: 'get',
|
||||||
params
|
params,
|
||||||
|
donNotShowLoading: true
|
||||||
})
|
})
|
||||||
|
|
||||||
export const getPaymentOrder = (data) => service({
|
export const getPaymentOrder = (data) => service({
|
||||||
url: '/payment/order',
|
url: '/payment/order',
|
||||||
method: 'post',
|
method: 'post',
|
||||||
data
|
data,
|
||||||
|
donNotShowLoading: true
|
||||||
})
|
})
|
||||||
|
|
||||||
export const queryPaymentOrder = (data) => service({
|
export const queryPaymentOrder = (data) => service({
|
||||||
url: '/payment/query',
|
url: '/payment/query',
|
||||||
method: 'post',
|
method: 'post',
|
||||||
data
|
data,
|
||||||
|
donNotShowLoading: true
|
||||||
})
|
})
|
||||||
|
|
||||||
export const refundPaymentOrder = (data) => service({
|
export const refundPaymentOrder = (data) => service({
|
||||||
url: '/payment/refund',
|
url: '/payment/refund',
|
||||||
method: 'post',
|
method: 'post',
|
||||||
data
|
data,
|
||||||
|
donNotShowLoading: true
|
||||||
|
})
|
||||||
|
|
||||||
|
export const fulfillPaymentOrder = (data) => service({
|
||||||
|
url: '/payment/fulfill',
|
||||||
|
method: 'post',
|
||||||
|
data,
|
||||||
|
donNotShowLoading: true
|
||||||
})
|
})
|
||||||
|
|
||||||
export const testPaymentProvider = (provider) => service({
|
export const testPaymentProvider = (provider) => service({
|
||||||
|
|
@ -34,5 +45,6 @@ export const testPaymentProvider = (provider) => service({
|
||||||
? { baseURL: `${window.location.origin}/` }
|
? { baseURL: `${window.location.origin}/` }
|
||||||
: {}),
|
: {}),
|
||||||
url: `/payment/providers/${encodeURIComponent(provider)}/test`,
|
url: `/payment/providers/${encodeURIComponent(provider)}/test`,
|
||||||
method: 'post'
|
method: 'post',
|
||||||
|
donNotShowLoading: true
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,8 @@
|
||||||
"/src/view/system/state.vue": "State",
|
"/src/view/system/state.vue": "State",
|
||||||
"/src/view/systemTools/integration/config.vue": "IntegrationConfig",
|
"/src/view/systemTools/integration/config.vue": "IntegrationConfig",
|
||||||
"/src/view/systemTools/logViewer/index.vue": "LogViewer",
|
"/src/view/systemTools/logViewer/index.vue": "LogViewer",
|
||||||
|
"/src/view/systemTools/payment/config.vue": "PaymentConfig",
|
||||||
|
"/src/view/systemTools/payment/orders.vue": "PaymentOrders",
|
||||||
"/src/view/systemTools/sysError/sysError.vue": "SysError",
|
"/src/view/systemTools/sysError/sysError.vue": "SysError",
|
||||||
"/src/view/systemTools/system/system.vue": "Config",
|
"/src/view/systemTools/system/system.vue": "Config",
|
||||||
"/src/view/systemTools/timedTask/index.vue": "TimedTask",
|
"/src/view/systemTools/timedTask/index.vue": "TimedTask",
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@
|
||||||
<small>{{ providerMeta(item).protocol }}</small>
|
<small>{{ providerMeta(item).protocol }}</small>
|
||||||
</span>
|
</span>
|
||||||
<span class="provider-state" :class="{ enabled: item.enabled }">
|
<span class="provider-state" :class="{ enabled: item.enabled }">
|
||||||
{{ item.enabled ? '运行中' : '已停用' }}
|
{{ item.enabled ? '已启用' : '已停用' }}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
@ -390,6 +390,11 @@ const hasMaskedSecret = (item) =>
|
||||||
|
|
||||||
const markSaved = (item) => {
|
const markSaved = (item) => {
|
||||||
item.configured = true
|
item.configured = true
|
||||||
|
for (const field of item.fields || []) {
|
||||||
|
if (field.secret && !isMissing(item.config[field.key])) {
|
||||||
|
item.config[field.key] = '******'
|
||||||
|
}
|
||||||
|
}
|
||||||
item._savedEnabled = item.enabled
|
item._savedEnabled = item.enabled
|
||||||
item._savedConfig = cloneConfig(item.config)
|
item._savedConfig = cloneConfig(item.config)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,107 +1,432 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="integration-config-page">
|
<div class="kra-table-box payment-config-page">
|
||||||
<div class="page-heading">
|
<header class="page-heading">
|
||||||
<div><h2>支付渠道配置</h2><p>统一管理支付渠道凭证、接口地址和默认交易参数</p></div>
|
<div>
|
||||||
<el-button :loading="loading" :icon="Refresh" @click="load">刷新</el-button>
|
<h2>支付渠道配置</h2>
|
||||||
</div>
|
<p>管理渠道凭证、回调地址和测试交易参数。</p>
|
||||||
|
</div>
|
||||||
|
<el-button :icon="Refresh" :loading="loading" :disabled="busy" @click="refreshConfigs">刷新</el-button>
|
||||||
|
</header>
|
||||||
|
|
||||||
<div v-loading="loading" class="config-layout">
|
<div v-loading="loading" class="config-layout">
|
||||||
<aside class="provider-panel">
|
<aside class="provider-panel" aria-label="支付渠道列表">
|
||||||
<div class="panel-title">渠道 <span>{{ configs.length }}</span></div>
|
<div class="panel-heading"><span>渠道</span><span>{{ configs.length }}</span></div>
|
||||||
<button v-for="item in configs" :key="item.provider" type="button" class="provider-item" :class="{ active: selected?.provider === item.provider }" @click="select(item)">
|
<button
|
||||||
<span class="provider-copy"><strong>{{ item.name || item.provider }}</strong><small>{{ item.provider }}</small></span>
|
v-for="item in configs"
|
||||||
<el-tag :type="item.enabled ? 'success' : 'info'" size="small">{{ item.enabled ? '启用' : '停用' }}</el-tag>
|
:key="item.provider"
|
||||||
|
type="button"
|
||||||
|
class="provider-item"
|
||||||
|
:class="{ active: item.provider === selectedProvider }"
|
||||||
|
@click="selectProvider(item.provider)"
|
||||||
|
>
|
||||||
|
<span class="provider-copy">
|
||||||
|
<strong>{{ item.name || providerText(item.provider) }}</strong>
|
||||||
|
<small>{{ item.provider }}</small>
|
||||||
|
</span>
|
||||||
|
<span class="provider-state" :class="{ enabled: item.enabled }">
|
||||||
|
{{ item.enabled ? '已启用' : '已停用' }}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section v-if="selected" class="editor-panel">
|
<section v-if="selected" class="editor-panel">
|
||||||
<div class="editor-heading">
|
<header class="editor-heading">
|
||||||
<div><div class="editor-title">{{ selected.name || selected.provider }}</div><div class="editor-subtitle">{{ selected.description || `provider: ${selected.provider}` }}</div></div>
|
<div class="editor-title-group">
|
||||||
<el-switch v-model="selected.enabled" active-text="启用渠道" @change="save" />
|
<div class="editor-title-row">
|
||||||
</div>
|
<h3>{{ selected.name || providerText(selected.provider) }}</h3>
|
||||||
<el-alert v-if="selected.configured" title="密钥字段已脱敏,保留 ****** 表示继续使用当前密钥。" type="info" :closable="false" class="editor-alert" />
|
<el-tag v-if="isDirty(selected)" type="warning" effect="plain">未保存</el-tag>
|
||||||
<el-alert v-else title="该渠道尚未保存,填写字段后保存即可创建配置。" type="warning" :closable="false" class="editor-alert" />
|
<el-tag v-else-if="selected.enabled" type="success" effect="plain">运行中</el-tag>
|
||||||
<el-form label-position="top" class="config-form">
|
</div>
|
||||||
|
<p>{{ selected.description || selected.provider }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="enable-control">
|
||||||
|
<span>{{ selected.enabled ? '已启用' : '已停用' }}</span>
|
||||||
|
<el-switch
|
||||||
|
:model-value="selected.enabled"
|
||||||
|
:loading="operation === 'toggle'"
|
||||||
|
:disabled="busy"
|
||||||
|
aria-label="启用支付渠道"
|
||||||
|
@change="toggleProvider"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<el-alert
|
||||||
|
v-if="hasMaskedSecret(selected)"
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
class="editor-alert"
|
||||||
|
title="凭证已脱敏;保留 ****** 将继续使用当前凭证。"
|
||||||
|
/>
|
||||||
|
<el-alert
|
||||||
|
v-else-if="!selected.configured"
|
||||||
|
type="warning"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
class="editor-alert"
|
||||||
|
title="该渠道尚未保存。填写配置并保存后才能启用或测试。"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-form :model="selected.config" label-position="top" class="config-form" @submit.prevent>
|
||||||
<div class="field-grid">
|
<div class="field-grid">
|
||||||
<el-form-item v-for="field in selected.fields" :key="field.key" :label="field.label" :required="field.required">
|
<el-form-item
|
||||||
<template #label><span>{{ field.label }}</span><span class="field-key">{{ field.key }}</span></template>
|
v-for="field in selected.fields || []"
|
||||||
<el-select v-if="field.type === 'select'" v-model="selected.config[field.key]" class="field-control" filterable>
|
:key="field.key"
|
||||||
<el-option v-for="option in field.options" :key="String(option.value)" :label="option.label" :value="option.value" />
|
:required="field.required"
|
||||||
|
:error="fieldError(field.key)"
|
||||||
|
>
|
||||||
|
<template #label>
|
||||||
|
<span class="field-label"><span>{{ field.label }}</span><small>{{ field.key }}</small></span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-select
|
||||||
|
v-if="field.type === 'select'"
|
||||||
|
v-model="selected.config[field.key]"
|
||||||
|
class="field-control"
|
||||||
|
filterable
|
||||||
|
:placeholder="field.placeholder || '请选择'"
|
||||||
|
@update:model-value="clearFieldError(field.key)"
|
||||||
|
>
|
||||||
|
<el-option v-for="option in field.options || []" :key="String(option.value)" :label="option.label" :value="option.value" />
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-switch v-else-if="field.type === 'switch'" v-model="selected.config[field.key]" />
|
<el-switch
|
||||||
<el-input v-else-if="field.type === 'textarea'" v-model="selected.config[field.key]" class="field-control" type="textarea" :rows="4" :show-password="field.secret" spellcheck="false" />
|
v-else-if="field.type === 'switch'"
|
||||||
<el-input v-else v-model="selected.config[field.key]" class="field-control" :type="field.secret ? 'password' : field.type === 'number' ? 'number' : 'text'" :show-password="field.secret" spellcheck="false" />
|
v-model="selected.config[field.key]"
|
||||||
|
@update:model-value="clearFieldError(field.key)"
|
||||||
|
/>
|
||||||
|
<el-input-number
|
||||||
|
v-else-if="field.type === 'number'"
|
||||||
|
v-model="selected.config[field.key]"
|
||||||
|
class="field-control"
|
||||||
|
:min="field.key === 'test_amount' ? 1 : 0"
|
||||||
|
:step="1"
|
||||||
|
controls-position="right"
|
||||||
|
@update:model-value="clearFieldError(field.key)"
|
||||||
|
/>
|
||||||
|
<el-input
|
||||||
|
v-else-if="field.type === 'textarea'"
|
||||||
|
v-model="selected.config[field.key]"
|
||||||
|
class="field-control"
|
||||||
|
type="textarea"
|
||||||
|
:rows="field.secret ? 5 : 4"
|
||||||
|
:placeholder="field.placeholder"
|
||||||
|
spellcheck="false"
|
||||||
|
@update:model-value="clearFieldError(field.key)"
|
||||||
|
/>
|
||||||
|
<el-input
|
||||||
|
v-else
|
||||||
|
v-model="selected.config[field.key]"
|
||||||
|
class="field-control"
|
||||||
|
:type="field.secret ? 'password' : 'text'"
|
||||||
|
:show-password="field.secret"
|
||||||
|
:placeholder="field.placeholder"
|
||||||
|
spellcheck="false"
|
||||||
|
@update:model-value="clearFieldError(field.key)"
|
||||||
|
/>
|
||||||
|
<p v-if="fieldHint(field)" class="field-hint">{{ fieldHint(field) }}</p>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</div>
|
</div>
|
||||||
</el-form>
|
</el-form>
|
||||||
<div class="editor-actions">
|
|
||||||
<el-button type="primary" :loading="saving" :icon="Check" @click="save">保存配置</el-button>
|
<footer class="editor-actions">
|
||||||
<el-button :loading="testing" :icon="Connection" @click="testProvider">测试渠道</el-button>
|
<span class="save-state">{{ saveStateText }}</span>
|
||||||
<el-button v-if="selected.configured" type="danger" plain :icon="Delete" @click="remove">删除配置</el-button>
|
<el-button
|
||||||
<el-button text :icon="DocumentCopy" @click="copyConfig">复制 JSON</el-button>
|
:icon="Connection"
|
||||||
</div>
|
:loading="operation === 'test'"
|
||||||
|
:disabled="busy || !selected.configured || isDirty(selected)"
|
||||||
|
@click="testProvider"
|
||||||
|
>
|
||||||
|
测试渠道
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="selected.configured"
|
||||||
|
type="danger"
|
||||||
|
plain
|
||||||
|
:icon="Delete"
|
||||||
|
:loading="operation === 'delete'"
|
||||||
|
:disabled="busy"
|
||||||
|
@click="remove"
|
||||||
|
>
|
||||||
|
删除配置
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
:icon="Check"
|
||||||
|
:loading="operation === 'save'"
|
||||||
|
:disabled="busy || !isDirty(selected)"
|
||||||
|
@click="saveSelected"
|
||||||
|
>
|
||||||
|
保存配置
|
||||||
|
</el-button>
|
||||||
|
</footer>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<el-empty v-else description="暂无支付渠道" />
|
<el-empty v-else description="暂无支付渠道" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<el-dialog v-model="testVisible" title="渠道测试结果" width="min(620px, calc(100vw - 32px))" destroy-on-close>
|
||||||
|
<div v-if="testResult" class="test-result">
|
||||||
|
<el-result
|
||||||
|
:icon="testResult.passed ? 'success' : 'error'"
|
||||||
|
:title="testResultTitle"
|
||||||
|
:sub-title="testResult.tradeNo ? `测试订单:${testResult.tradeNo}` : undefined"
|
||||||
|
/>
|
||||||
|
<div class="test-stages">
|
||||||
|
<div v-for="stage in testResult.stages || []" :key="`${stage.name}-${stage.tradeNo || ''}`" class="test-stage">
|
||||||
|
<el-icon :class="`stage-${stage.status}`"><component :is="stageIcon(stage.status)" /></el-icon>
|
||||||
|
<div><strong>{{ stageName(stage.name) }}</strong><p>{{ stage.message || stageStatusText(stage.status) }}</p></div>
|
||||||
|
<span v-if="stage.durationMs != null">{{ stage.durationMs }} ms</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #footer><el-button @click="testVisible = false">关闭</el-button></template>
|
||||||
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Check, Connection, Delete, DocumentCopy, Refresh } from '@element-plus/icons-vue'
|
import { Check, CircleCheck, CircleClose, Connection, Delete, MoreFilled, Refresh } from '@element-plus/icons-vue'
|
||||||
import { deleteIntegrationConfig, getIntegrationConfigs, saveIntegrationConfig } from '@/api/integration'
|
import { deleteIntegrationConfig, getIntegrationConfigs, saveIntegrationConfig } from '@/api/integration'
|
||||||
import { testPaymentProvider } from '@/api/payment'
|
import { testPaymentProvider } from '@/api/payment'
|
||||||
|
|
||||||
const configs = ref([]); const selectedProvider = ref(''); const loading = ref(false); const saving = ref(false); const testing = ref(false)
|
defineOptions({ name: 'PaymentConfig' })
|
||||||
const selected = computed(() => configs.value.find((item) => item.provider === selectedProvider.value) || configs.value[0])
|
|
||||||
const select = (item) => { selectedProvider.value = item.provider }
|
const PROVIDER_NAMES = {
|
||||||
const load = async () => { loading.value = true; try { const res = await getIntegrationConfigs('payment'); if (res.code === 0) { configs.value = (res.data || []).map((item) => ({ ...item, config: { ...(item.config || {}) } })); if (!configs.value.some((item) => item.provider === selectedProvider.value)) selectedProvider.value = configs.value[0]?.provider || '' } } finally { loading.value = false } }
|
alipay: '支付宝', 'alipay-v3': '支付宝 V3', 'wechat-v2': '微信支付 V2',
|
||||||
const save = async () => { if (!selected.value) return; saving.value = true; try { const res = await saveIntegrationConfig('payment', selected.value.provider, { enabled: selected.value.enabled, config: selected.value.config }); if (res.code === 0) { ElMessage.success('支付渠道配置已保存'); await load() } } finally { saving.value = false } }
|
'wechat-v3': '微信支付 V3', 'apple-iap': 'Apple IAP', douyin: '抖音支付',
|
||||||
const testProvider = async () => {
|
qq: 'QQ 钱包', allinpay: '通联支付', lakala: '拉卡拉', paypal: 'PayPal',
|
||||||
if (!selected.value) return
|
saobei: '扫呗', chinaums: '银联商务', sft: '商福通', 'supper-pay': 'Supper Pay',
|
||||||
const environment = String(selected.value.config?.environment || '').toLowerCase()
|
'wechat-game-pay': '微信小游戏支付', 'douyin-game-pay': '抖音小游戏支付'
|
||||||
if (environment === 'production' || environment === 'prod') {
|
|
||||||
try { await ElMessageBox.confirm('当前渠道使用生产环境,测试会真实创建最小金额订单。确认使用专用测试商户执行吗?', '生产环境测试确认', { type: 'warning', confirmButtonText: '确认测试' }) } catch { return }
|
|
||||||
}
|
|
||||||
testing.value = true
|
|
||||||
try {
|
|
||||||
const res = await testPaymentProvider(selected.value.provider)
|
|
||||||
const result = res.data || {}
|
|
||||||
const stages = (result.stages || []).map((stage) => `${stage.status === 'passed' ? '通过' : stage.status === 'skipped' ? '跳过' : '失败'}:${stage.name}${stage.message ? ` - ${stage.message}` : ''}${stage.durationMs != null ? ` (${stage.durationMs}ms)` : ''}`).join('\n')
|
|
||||||
if (res.code === 0 && result.passed) {
|
|
||||||
const title = result.fullFlow ? '渠道完整链路测试通过' : '渠道连通性测试通过(完整支付链路未完成)'
|
|
||||||
ElMessage.success({ message: `${title}\n${stages}`, duration: 9000, showClose: true })
|
|
||||||
}
|
|
||||||
else ElMessage.error({ message: `渠道测试未通过\n${stages || res.msg || '未知错误'}`, duration: 9000, showClose: true })
|
|
||||||
} finally { testing.value = false }
|
|
||||||
}
|
}
|
||||||
const remove = async () => { if (!selected.value) return; await ElMessageBox.confirm(`确认删除 ${selected.value.name || selected.value.provider} 配置吗?`, '删除配置', { type: 'warning' }); const res = await deleteIntegrationConfig('payment', selected.value.provider); if (res.code === 0) { ElMessage.success('配置已删除'); await load() } }
|
const STAGE_NAMES = { config: '配置校验', test_settings: '测试参数', adapter: '渠道适配器', local_order: '本地测试订单', create: '渠道下单', query: '渠道查单', refund: '渠道退款' }
|
||||||
const copyConfig = async () => { if (!selected.value) return; await navigator.clipboard.writeText(JSON.stringify(selected.value.config || {}, null, 2)); ElMessage.success('JSON 已复制') }
|
|
||||||
load()
|
const configs = ref([])
|
||||||
|
const selectedProvider = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const operation = ref('')
|
||||||
|
const errors = reactive({})
|
||||||
|
const testVisible = ref(false)
|
||||||
|
const testResult = ref(null)
|
||||||
|
let loadRequestID = 0
|
||||||
|
|
||||||
|
const selected = computed(() => configs.value.find((item) => item.provider === selectedProvider.value) || configs.value[0])
|
||||||
|
const busy = computed(() => Boolean(operation.value))
|
||||||
|
const saveStateText = computed(() => {
|
||||||
|
if (!selected.value) return ''
|
||||||
|
if (!selected.value.configured) return '尚未创建配置'
|
||||||
|
if (isDirty(selected.value)) return '存在未保存的修改'
|
||||||
|
return selected.value.enabled ? '配置已保存,渠道已启用' : '配置已保存,渠道已停用'
|
||||||
|
})
|
||||||
|
const testResultTitle = computed(() => {
|
||||||
|
if (!testResult.value?.passed) return '渠道测试未通过'
|
||||||
|
const refundStage = testResult.value.stages?.find((stage) => stage.name === 'refund')
|
||||||
|
if (refundStage?.status === 'passed' && /受理|接受|等待|pending|processing/i.test(refundStage.message || '')) return '支付链路已受理,等待退款确认'
|
||||||
|
return testResult.value.fullFlow ? '完整支付链路测试通过' : '渠道连通性测试通过'
|
||||||
|
})
|
||||||
|
|
||||||
|
const cloneConfig = (value) => JSON.parse(JSON.stringify(value || {}))
|
||||||
|
const normalizeConfig = (item) => {
|
||||||
|
const normalized = { ...item, enabled: Boolean(item.enabled), configured: Boolean(item.configured), config: cloneConfig(item.config), fields: Array.isArray(item.fields) ? item.fields : [] }
|
||||||
|
normalized._savedEnabled = normalized.enabled
|
||||||
|
normalized._savedConfig = cloneConfig(normalized.config)
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
const providerText = (provider) => PROVIDER_NAMES[provider] || provider
|
||||||
|
const fieldError = (key) => errors[key] || ''
|
||||||
|
const clearFieldError = (key) => { delete errors[key] }
|
||||||
|
const isEmpty = (value) => value === null || typeof value === 'undefined' || (typeof value === 'string' && value.trim() === '')
|
||||||
|
const isDirty = (item) => JSON.stringify({ enabled: item.enabled, config: item.config }) !== JSON.stringify({ enabled: item._savedEnabled, config: item._savedConfig })
|
||||||
|
const hasMaskedSecret = (item) => (item.fields || []).some((field) => field.secret && item.config[field.key] === '******')
|
||||||
|
const fieldHint = (field) => {
|
||||||
|
if (field.key === 'test_mode') return '仅在需要执行真实渠道测试时开启。'
|
||||||
|
if (field.key === 'test_amount') return '使用最小货币单位,例如 CNY 1 表示 0.01 元。'
|
||||||
|
if (field.key === 'test_extra') return '必须是 JSON 对象;可传 openid、auth_code 等测试参数。'
|
||||||
|
if (field.key === 'notify_url') return '异步支付方式必须填写可被支付平台访问的 HTTPS 地址。'
|
||||||
|
return field.description || ''
|
||||||
|
}
|
||||||
|
const stageName = (name) => STAGE_NAMES[name] || name
|
||||||
|
const stageStatusText = (status) => status === 'passed' ? '通过' : status === 'skipped' ? '跳过' : '失败'
|
||||||
|
const stageIcon = (status) => status === 'passed' ? CircleCheck : status === 'failed' ? CircleClose : MoreFilled
|
||||||
|
|
||||||
|
function validate(item, enabled = item.enabled) {
|
||||||
|
Object.keys(errors).forEach((key) => delete errors[key])
|
||||||
|
let valid = true
|
||||||
|
for (const field of item.fields || []) {
|
||||||
|
const value = item.config[field.key]
|
||||||
|
let message = ''
|
||||||
|
if (enabled && field.required && isEmpty(value)) message = `请填写${field.label}`
|
||||||
|
else if (field.type === 'number' && !isEmpty(value) && (!Number.isFinite(Number(value)) || Number(value) < 0)) message = `${field.label}必须是非负数`
|
||||||
|
else if (field.key === 'test_amount' && !isEmpty(value) && Number(value) <= 0) message = '测试金额必须大于 0'
|
||||||
|
else if (field.key === 'test_extra' && String(value || '').trim()) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(String(value))
|
||||||
|
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') message = '测试扩展参数必须是 JSON 对象'
|
||||||
|
} catch { message = '测试扩展参数必须是合法 JSON' }
|
||||||
|
} else if ((field.type === 'url' || field.key.endsWith('_url')) && value) {
|
||||||
|
try {
|
||||||
|
const url = new URL(String(value))
|
||||||
|
if (!['http:', 'https:'].includes(url.protocol)) message = `${field.label}必须使用 HTTP 或 HTTPS`
|
||||||
|
} catch { message = `${field.label}格式无效` }
|
||||||
|
}
|
||||||
|
if (!item.configured && field.secret && value === '******') message = '请重新填写' + field.label
|
||||||
|
if (message) { errors[field.key] = message; valid = false }
|
||||||
|
}
|
||||||
|
if (!valid) ElMessage.warning('请先修正配置项')
|
||||||
|
return valid
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
if (loading.value) return
|
||||||
|
const requestID = ++loadRequestID
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getIntegrationConfigs('payment')
|
||||||
|
if (requestID !== loadRequestID || res.code !== 0) return
|
||||||
|
configs.value = (res.data || []).map(normalizeConfig)
|
||||||
|
if (!configs.value.some((item) => item.provider === selectedProvider.value)) selectedProvider.value = configs.value[0]?.provider || ''
|
||||||
|
} catch {
|
||||||
|
// The request layer already presents transport errors.
|
||||||
|
} finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshConfigs() {
|
||||||
|
if (busy.value) return
|
||||||
|
if (selected.value && isDirty(selected.value)) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('当前渠道有未保存修改,刷新会丢失这些修改。确认刷新吗?', '刷新支付配置', { type: 'warning', confirmButtonText: '确认刷新' })
|
||||||
|
} catch { return }
|
||||||
|
}
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectProvider(provider) {
|
||||||
|
if (provider === selectedProvider.value || busy.value) return
|
||||||
|
if (selected.value && isDirty(selected.value)) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('当前渠道有未保存修改,切换后会丢失这些修改。确认切换吗?', '切换支付渠道', { type: 'warning', confirmButtonText: '确认切换' })
|
||||||
|
} catch { return }
|
||||||
|
}
|
||||||
|
Object.keys(errors).forEach((key) => delete errors[key])
|
||||||
|
selectedProvider.value = provider
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persist(item, type) {
|
||||||
|
if (busy.value || !validate(item, item.enabled)) return false
|
||||||
|
operation.value = type
|
||||||
|
try {
|
||||||
|
const res = await saveIntegrationConfig('payment', item.provider, { enabled: item.enabled, config: item.config })
|
||||||
|
if (res.code !== 0) return false
|
||||||
|
const provider = item.provider
|
||||||
|
await load()
|
||||||
|
selectedProvider.value = provider
|
||||||
|
return true
|
||||||
|
} catch { return false }
|
||||||
|
finally { operation.value = '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSelected() {
|
||||||
|
if (!selected.value) return
|
||||||
|
if (await persist(selected.value, 'save')) ElMessage.success('支付渠道配置已保存')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleProvider(enabled) {
|
||||||
|
const item = selected.value
|
||||||
|
if (!item || busy.value) return
|
||||||
|
const previous = item.enabled
|
||||||
|
item.enabled = Boolean(enabled)
|
||||||
|
if (item.enabled && !validate(item, true)) { item.enabled = previous; return }
|
||||||
|
if (await persist(item, 'toggle')) {
|
||||||
|
ElMessage.success(`${item.name || providerText(item.provider)} 已${item.enabled ? '启用' : '停用'}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item.enabled = previous
|
||||||
|
ElMessage.warning('渠道状态未改变')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testProvider() {
|
||||||
|
const item = selected.value
|
||||||
|
if (!item || busy.value) return
|
||||||
|
if (isDirty(item)) { ElMessage.warning('请先保存当前配置'); return }
|
||||||
|
if (!item.config?.test_mode) { ElMessage.warning('请先开启“允许执行渠道测试”并保存'); return }
|
||||||
|
const environment = String(item.config?.environment || '').toLowerCase()
|
||||||
|
if (environment === 'production' || environment === 'prod') {
|
||||||
|
operation.value = 'test-confirm'
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('当前渠道使用生产环境,测试会真实创建最小金额订单。确认使用专用测试商户执行吗?', '生产环境测试确认', { type: 'warning', confirmButtonText: '确认测试' })
|
||||||
|
} catch { operation.value = ''; return }
|
||||||
|
}
|
||||||
|
operation.value = 'test'
|
||||||
|
try {
|
||||||
|
const res = await testPaymentProvider(item.provider)
|
||||||
|
testResult.value = res.data || { passed: false, stages: [{ name: 'test', status: 'failed', message: res.msg || '未知错误' }] }
|
||||||
|
testVisible.value = true
|
||||||
|
} catch {
|
||||||
|
// The request layer already presents transport errors.
|
||||||
|
} finally { operation.value = '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove() {
|
||||||
|
const item = selected.value
|
||||||
|
if (!item || busy.value) return
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`删除 ${item.name || providerText(item.provider)} 配置后,该渠道将立即不可用。确认继续吗?`, '删除支付配置', { type: 'warning', confirmButtonText: '确认删除' })
|
||||||
|
} catch { return }
|
||||||
|
operation.value = 'delete'
|
||||||
|
try {
|
||||||
|
const res = await deleteIntegrationConfig('payment', item.provider)
|
||||||
|
if (res.code !== 0) return
|
||||||
|
ElMessage.success('支付渠道配置已删除')
|
||||||
|
await load()
|
||||||
|
} catch {
|
||||||
|
// The request layer already presents transport errors.
|
||||||
|
} finally { operation.value = '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.integration-config-page { padding: 4px 0 24px; }
|
.payment-config-page { min-height: 640px; }
|
||||||
.page-heading { display: flex; align-items: center; justify-content: space-between; margin-bottom: 18px; }
|
.page-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 18px; }
|
||||||
.page-heading h2 { margin: 0; color: var(--el-text-color-primary); font-size: 20px; font-weight: 600; }
|
.page-heading h2 { margin: 0; color: var(--el-text-color-primary); font-size: 20px; font-weight: 600; letter-spacing: 0; }
|
||||||
.page-heading p { margin: 6px 0 0; color: var(--el-text-color-secondary); font-size: 13px; }
|
.page-heading p { margin: 5px 0 0; color: var(--el-text-color-secondary); font-size: 13px; }
|
||||||
.config-layout { display: grid; grid-template-columns: 250px minmax(0, 1fr); min-height: 620px; border: 1px solid var(--el-border-color-lighter); background: var(--el-bg-color); }
|
.config-layout { display: grid; grid-template-columns: 248px minmax(0, 1fr); min-height: 560px; overflow: hidden; border: 1px solid var(--el-border-color-lighter); background: var(--el-bg-color); }
|
||||||
.provider-panel { border-right: 1px solid var(--el-border-color-lighter); padding: 14px 10px; }
|
.provider-panel { padding: 12px 9px; border-right: 1px solid var(--el-border-color-lighter); background: var(--el-fill-color-blank); }
|
||||||
.panel-title { display: flex; justify-content: space-between; padding: 2px 10px 12px; color: var(--el-text-color-primary); font-size: 14px; font-weight: 600; }
|
.panel-heading { display: flex; justify-content: space-between; padding: 4px 10px 11px; color: var(--el-text-color-secondary); font-size: 12px; }
|
||||||
.panel-title span { color: var(--el-text-color-secondary); font-weight: 400; }
|
.provider-item { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 10px; width: 100%; min-height: 58px; padding: 8px 10px; border: 0; border-left: 3px solid transparent; border-radius: 4px; background: transparent; color: inherit; text-align: left; cursor: pointer; }
|
||||||
.provider-item { display: flex; align-items: center; justify-content: space-between; width: 100%; min-height: 54px; padding: 9px 10px; border: 0; border-left: 3px solid transparent; background: transparent; color: inherit; text-align: left; cursor: pointer; }
|
|
||||||
.provider-item:hover { background: var(--el-fill-color-light); }
|
.provider-item:hover { background: var(--el-fill-color-light); }
|
||||||
.provider-item.active { border-left-color: var(--el-color-primary); background: var(--el-color-primary-light-9); }
|
.provider-item.active { border-left-color: var(--el-color-primary); background: var(--el-color-primary-light-9); }
|
||||||
.provider-copy { display: grid; gap: 3px; min-width: 0; }
|
.provider-copy { display: grid; min-width: 0; gap: 3px; }
|
||||||
.provider-copy strong { overflow: hidden; color: var(--el-text-color-primary); font-size: 14px; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }
|
.provider-copy strong { overflow: hidden; color: var(--el-text-color-primary); font-size: 14px; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.provider-copy small { color: var(--el-text-color-secondary); font-size: 11px; }
|
.provider-copy small { color: var(--el-text-color-secondary); font-size: 11px; }
|
||||||
.editor-panel { min-width: 0; padding: 22px 28px 24px; }
|
.provider-state { color: var(--el-text-color-placeholder); font-size: 11px; white-space: nowrap; }
|
||||||
.editor-heading { display: flex; align-items: center; justify-content: space-between; gap: 18px; padding-bottom: 18px; border-bottom: 1px solid var(--el-border-color-lighter); }
|
.provider-state.enabled { color: var(--el-color-success); }
|
||||||
.editor-title { color: var(--el-text-color-primary); font-size: 18px; font-weight: 600; }
|
.editor-panel { display: flex; min-width: 0; flex-direction: column; padding: 22px 28px 20px; }
|
||||||
.editor-subtitle { margin-top: 5px; color: var(--el-text-color-secondary); font-size: 13px; }
|
.editor-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; padding-bottom: 18px; border-bottom: 1px solid var(--el-border-color-lighter); }
|
||||||
.editor-alert { margin: 18px 0; }
|
.editor-title-group { min-width: 0; }
|
||||||
|
.editor-title-row { display: flex; align-items: center; gap: 9px; }
|
||||||
|
.editor-title-row h3 { margin: 0; color: var(--el-text-color-primary); font-size: 18px; font-weight: 600; letter-spacing: 0; }
|
||||||
|
.editor-title-group p { margin: 6px 0 0; color: var(--el-text-color-secondary); font-size: 13px; }
|
||||||
|
.enable-control { display: flex; align-items: center; gap: 10px; min-height: 32px; color: var(--el-text-color-regular); font-size: 13px; white-space: nowrap; }
|
||||||
|
.editor-alert { margin-top: 18px; }
|
||||||
|
.config-form { flex: 1; padding-top: 20px; }
|
||||||
.field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 22px; }
|
.field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 22px; }
|
||||||
.field-key { margin-left: 8px; color: var(--el-text-color-placeholder); font-size: 11px; font-weight: 400; }
|
.field-label { display: inline-flex; align-items: baseline; gap: 7px; min-width: 0; }
|
||||||
|
.field-label small { overflow: hidden; color: var(--el-text-color-placeholder); font-size: 11px; font-weight: 400; text-overflow: ellipsis; }
|
||||||
.field-control { width: 100%; }
|
.field-control { width: 100%; }
|
||||||
.editor-actions { display: flex; align-items: center; gap: 10px; padding-top: 8px; border-top: 1px solid var(--el-border-color-lighter); }
|
.field-hint { width: 100%; margin: 5px 0 0; color: var(--el-text-color-secondary); font-size: 12px; line-height: 1.5; }
|
||||||
@media (max-width: 900px) { .config-layout { grid-template-columns: 1fr; } .provider-panel { border-right: 0; border-bottom: 1px solid var(--el-border-color-lighter); max-height: 260px; overflow-y: auto; } .field-grid { grid-template-columns: 1fr; } .editor-panel { padding: 18px; } }
|
.editor-actions { display: flex; align-items: center; justify-content: flex-end; gap: 10px; padding-top: 16px; border-top: 1px solid var(--el-border-color-lighter); }
|
||||||
|
.save-state { margin-right: auto; color: var(--el-text-color-secondary); font-size: 12px; }
|
||||||
|
.test-result :deep(.el-result) { padding: 8px 24px 20px; }
|
||||||
|
.test-stages { border-top: 1px solid var(--el-border-color-lighter); }
|
||||||
|
.test-stage { display: grid; grid-template-columns: 22px minmax(0, 1fr) auto; align-items: start; gap: 10px; padding: 13px 4px; border-bottom: 1px solid var(--el-border-color-lighter); }
|
||||||
|
.test-stage strong { color: var(--el-text-color-primary); font-size: 13px; font-weight: 600; }
|
||||||
|
.test-stage p { margin: 3px 0 0; color: var(--el-text-color-secondary); font-size: 12px; line-height: 1.5; }
|
||||||
|
.test-stage > span { color: var(--el-text-color-placeholder); font-size: 11px; white-space: nowrap; }
|
||||||
|
.stage-passed { color: var(--el-color-success); } .stage-failed { color: var(--el-color-danger); } .stage-skipped { color: var(--el-text-color-placeholder); }
|
||||||
|
@media (max-width: 900px) { .config-layout { grid-template-columns: 1fr; } .provider-panel { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; max-height: 270px; overflow-y: auto; border-right: 0; border-bottom: 1px solid var(--el-border-color-lighter); } .panel-heading { display: none; } .provider-state { display: none; } .editor-panel { padding: 20px; } .field-grid { grid-template-columns: 1fr; } }
|
||||||
|
@media (max-width: 560px) { .payment-config-page { min-height: 0; } .page-heading { align-items: flex-start; } .provider-panel { grid-template-columns: 1fr; } .provider-state { display: inline; } .editor-panel { padding: 18px 14px; } .editor-heading { align-items: stretch; flex-direction: column; gap: 14px; } .enable-control { justify-content: space-between; } .editor-actions { align-items: stretch; flex-direction: column; } .save-state { margin-right: 0; } .editor-actions :deep(.el-button) { margin-left: 0; } }
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,68 +1,524 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="payment-orders">
|
<div class="payment-orders">
|
||||||
<div class="kra-search-box">
|
<header class="page-heading">
|
||||||
<el-form :inline="true" :model="searchInfo" @submit.prevent="reload">
|
<div>
|
||||||
<el-form-item label="渠道"><el-input v-model="searchInfo.provider" placeholder="alipay / wechat-v3" clearable /></el-form-item>
|
<h2>支付订单</h2>
|
||||||
<el-form-item label="商户订单号"><el-input v-model="searchInfo.tradeNo" clearable /></el-form-item>
|
<p>核对收款、发货与退款状态,处理需要人工介入的订单。</p>
|
||||||
<el-form-item label="业务类型"><el-input v-model="searchInfo.businessType" clearable /></el-form-item>
|
</div>
|
||||||
<el-form-item label="业务 ID"><el-input v-model="searchInfo.businessId" clearable /></el-form-item>
|
<el-button :icon="Refresh" :loading="loading" @click="load()">刷新</el-button>
|
||||||
<el-form-item label="支付状态"><el-select v-model="searchInfo.paymentStatus" clearable placeholder="全部" style="width: 130px"><el-option v-for="item in paymentStatuses" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
|
</header>
|
||||||
<el-form-item label="退款状态"><el-select v-model="searchInfo.refundStatus" clearable placeholder="全部" style="width: 130px"><el-option v-for="item in refundStatuses" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
|
|
||||||
<el-form-item><el-button type="primary" icon="search" @click="reload">查询</el-button><el-button icon="refresh" @click="reset">重置</el-button></el-form-item>
|
<section class="status-summary" aria-label="当前页订单概览">
|
||||||
|
<div v-for="item in pageSummary" :key="item.label" class="summary-item">
|
||||||
|
<span>{{ item.label }}</span>
|
||||||
|
<strong>{{ item.value }}</strong>
|
||||||
|
<small>{{ item.hint }}</small>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="kra-search-box filter-band">
|
||||||
|
<el-form :model="searchInfo" label-position="top" @submit.prevent="reload">
|
||||||
|
<div class="filter-grid">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</el-form>
|
</el-form>
|
||||||
</div>
|
</div>
|
||||||
<div class="kra-table-box">
|
|
||||||
<el-table v-loading="loading" :data="rows" row-key="ID" stripe>
|
<div class="kra-table-box order-table-band">
|
||||||
<el-table-column prop="tradeNo" label="商户订单号" min-width="190" show-overflow-tooltip />
|
<div class="table-heading">
|
||||||
<el-table-column prop="provider" label="渠道" width="120" />
|
<div><strong>订单明细</strong><span>共 {{ total }} 笔</span></div>
|
||||||
<el-table-column prop="subject" label="商品/标题" min-width="180" show-overflow-tooltip />
|
<span v-if="issueCount" class="issue-count">{{ issueCount }} 笔需要关注</span>
|
||||||
<el-table-column label="金额" width="130"><template #default="scope">{{ formatAmount(scope.row.amount, scope.row.currency) }}</template></el-table-column>
|
</div>
|
||||||
<el-table-column label="支付状态" width="110"><template #default="scope"><el-tag :type="statusType(scope.row.paymentStatus)">{{ statusText(scope.row.paymentStatus) }}</el-tag></template></el-table-column>
|
|
||||||
<el-table-column label="发货" width="110"><template #default="scope"><el-tag :type="fulfillmentType(scope.row.fulfillmentStatus)">{{ fulfillmentText(scope.row.fulfillmentStatus) }}</el-tag></template></el-table-column>
|
<el-table v-loading="loading" :data="rows" row-key="ID" stripe :row-class-name="rowClassName">
|
||||||
<el-table-column label="退款" width="110"><template #default="scope"><el-tag :type="refundType(scope.row.refundStatus)">{{ refundText(scope.row.refundStatus) }}</el-tag></template></el-table-column>
|
<el-table-column label="订单" min-width="250">
|
||||||
<el-table-column label="创建时间" width="180"><template #default="scope">{{ formatDate(scope.row.createdAt) }}</template></el-table-column>
|
<template #default="scope">
|
||||||
<el-table-column label="操作" fixed="right" width="210"><template #default="scope"><el-button link type="primary" @click="openDetail(scope.row)">详情</el-button><el-button link type="primary" @click="refreshOrder(scope.row)">同步</el-button><el-button v-if="canRefund(scope.row)" link type="warning" @click="openRefund(scope.row)">退款</el-button><el-button v-if="canFulfill(scope.row)" link type="success" @click="showFulfillmentHint">发货</el-button></template></el-table-column>
|
<div class="order-cell">
|
||||||
|
<button type="button" class="order-link" @click="openDetail(scope.row)">{{ scope.row.tradeNo || '-' }}</button>
|
||||||
|
<span>{{ scope.row.subject || '未提供商品标题' }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="业务" min-width="165">
|
||||||
|
<template #default="scope">
|
||||||
|
<div class="business-cell"><span>{{ scope.row.businessType || '-' }}</span><small>{{ 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">
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
<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>
|
||||||
|
<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">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<template #empty><el-empty description="没有符合条件的支付订单" /></template>
|
||||||
</el-table>
|
</el-table>
|
||||||
<div class="kra-pagination"><el-pagination :current-page="page" :page-size="pageSize" :page-sizes="[10, 30, 50, 100]" :total="total" layout="total, sizes, prev, pager, next, jumper" @current-change="changePage" @size-change="changeSize" /></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>
|
</div>
|
||||||
|
|
||||||
<el-drawer v-model="detailVisible" title="订单详情" size="560px" destroy-on-close>
|
<el-drawer v-model="detailVisible" title="支付订单详情" :size="drawerSize" destroy-on-close>
|
||||||
<el-descriptions v-if="detail" :column="2" border>
|
<div v-loading="detailLoading" class="order-detail">
|
||||||
<el-descriptions-item label="商户订单号" :span="2">{{ detail.tradeNo || '-' }}</el-descriptions-item><el-descriptions-item label="渠道">{{ detail.provider || '-' }}</el-descriptions-item><el-descriptions-item label="支付方式">{{ detail.paymentMode || '-' }}</el-descriptions-item><el-descriptions-item label="业务类型">{{ detail.businessType || '-' }}</el-descriptions-item><el-descriptions-item label="业务 ID">{{ detail.businessId || '-' }}</el-descriptions-item><el-descriptions-item label="商品标题" :span="2">{{ detail.subject || '-' }}</el-descriptions-item><el-descriptions-item label="订单金额">{{ formatAmount(detail.amount, detail.currency) }}</el-descriptions-item><el-descriptions-item label="实付金额">{{ formatAmount(detail.paidAmount, detail.currency) }}</el-descriptions-item><el-descriptions-item label="支付状态"><el-tag :type="statusType(detail.paymentStatus)">{{ statusText(detail.paymentStatus) }}</el-tag></el-descriptions-item><el-descriptions-item label="发货状态"><el-tag :type="fulfillmentType(detail.fulfillmentStatus)">{{ fulfillmentText(detail.fulfillmentStatus) }}</el-tag></el-descriptions-item><el-descriptions-item label="退款状态"><el-tag :type="refundType(detail.refundStatus)">{{ refundText(detail.refundStatus) }}</el-tag></el-descriptions-item><el-descriptions-item label="已退款金额">{{ formatAmount(detail.refundedAmount, detail.currency) }}</el-descriptions-item><el-descriptions-item label="第三方订单号" :span="2">{{ detail.providerTradeNo || '-' }}</el-descriptions-item><el-descriptions-item label="创建时间">{{ formatDate(detail.createdAt) }}</el-descriptions-item><el-descriptions-item label="支付时间">{{ formatDate(detail.paidAt) }}</el-descriptions-item><el-descriptions-item v-if="detail.lastError" label="最近错误" :span="2"><span class="error-text">{{ detail.lastError }}</span></el-descriptions-item>
|
<template v-if="detail">
|
||||||
</el-descriptions>
|
<div class="detail-toolbar">
|
||||||
|
<div class="detail-statuses">
|
||||||
|
<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-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>
|
||||||
|
<el-button v-if="canRefund(detail)" type="warning" plain :icon="Money" :disabled="isRowBusy(detail)" @click="openRefund(detail)">申请退款</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-alert v-if="detail.lastError" type="error" :closable="false" show-icon class="detail-error" :title="detail.lastError" />
|
||||||
|
|
||||||
|
<h3 class="section-heading">订单信息</h3>
|
||||||
|
<el-descriptions :column="detailColumns" border>
|
||||||
|
<el-descriptions-item label="本地记录 ID">{{ detail.ID || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="支付方式">{{ paymentModeText(detail.paymentMode) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="商户订单号" :span="detailColumns">
|
||||||
|
<span class="copy-value"><span>{{ detail.tradeNo || '-' }}</span><el-button v-if="detail.tradeNo" link :icon="CopyDocument" aria-label="复制商户订单号" @click="copyText(detail.tradeNo)" /></span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="第三方交易号" :span="detailColumns">
|
||||||
|
<span class="copy-value"><span>{{ detail.providerTradeNo || '-' }}</span><el-button v-if="detail.providerTradeNo" link :icon="CopyDocument" aria-label="复制第三方交易号" @click="copyText(detail.providerTradeNo)" /></span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="支付渠道">{{ providerText(detail.provider) }}({{ detail.provider || '-' }})</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="平台状态">{{ detail.providerStatus || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="业务类型">{{ detail.businessType || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="业务 ID">{{ detail.businessId || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="商品标题" :span="detailColumns">{{ detail.subject || '-' }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
|
||||||
|
<h3 class="section-heading">金额明细</h3>
|
||||||
|
<div class="amount-grid">
|
||||||
|
<div v-for="item in detailAmounts" :key="item.label" class="amount-item"><span>{{ item.label }}</span><strong>{{ item.value }}</strong></div>
|
||||||
|
</div>
|
||||||
|
<el-alert v-if="!detail.amountBreakdownKnown && detail.paymentStatus === 'paid'" type="info" :closable="false" class="amount-alert" title="支付渠道未返回完整的实付、优惠与结算拆分。" />
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</el-drawer>
|
</el-drawer>
|
||||||
|
|
||||||
<el-dialog v-model="refundVisible" title="申请退款" width="440px" destroy-on-close>
|
<el-dialog v-model="refundVisible" title="申请退款" width="min(460px, calc(100vw - 32px))" destroy-on-close :close-on-click-modal="!refundSubmitting" :close-on-press-escape="!refundSubmitting">
|
||||||
<el-form ref="refundFormRef" :model="refundForm" :rules="refundRules" label-width="100px"><el-form-item label="订单号"><el-input :model-value="refundForm.tradeNo" disabled /></el-form-item><el-form-item label="可退金额"><span>{{ formatAmount(refundForm.maxAmount, refundForm.currency) }}</span></el-form-item><el-form-item label="退款金额" prop="amount"><el-input-number v-model="refundForm.amount" :min="1" :max="refundForm.maxAmount" :step="1" controls-position="right" style="width: 100%" /><div class="form-tip">单位为最小货币单位(例如人民币分)</div></el-form-item></el-form>
|
<div class="refund-summary">
|
||||||
<template #footer><el-button @click="refundVisible = false">取消</el-button><el-button type="warning" :loading="refundLoading" @click="submitRefund">确认退款</el-button></template>
|
<span>商户订单号</span><strong>{{ refundForm.tradeNo || '-' }}</strong>
|
||||||
|
<span>可退金额</span><strong>{{ formatAmount(refundForm.maxAmount, refundForm.currency) }}</strong>
|
||||||
|
</div>
|
||||||
|
<el-form ref="refundFormRef" :model="refundForm" :rules="refundRules" label-position="top" @submit.prevent="submitRefund">
|
||||||
|
<el-form-item label="退款金额" prop="amountText">
|
||||||
|
<el-input v-model="refundForm.amountText" inputmode="decimal" autocomplete="off" :placeholder="refundAmountPlaceholder">
|
||||||
|
<template #prepend>{{ refundForm.currency || 'CNY' }}</template>
|
||||||
|
</el-input>
|
||||||
|
<span class="form-tip">{{ refundPrecisionText }}</span>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button :disabled="refundSubmitting" @click="refundVisible = false">取消</el-button>
|
||||||
|
<el-button type="warning" :icon="Money" :loading="refundSubmitting" @click="submitRefund">确认退款</el-button>
|
||||||
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { useWindowSize } from '@vueuse/core'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { getPaymentOrders, getPaymentOrder, queryPaymentOrder, refundPaymentOrder } from '@/api/payment'
|
import { CopyDocument, Money, Promotion, Refresh, RefreshRight, Search, View } from '@element-plus/icons-vue'
|
||||||
|
import { fulfillPaymentOrder, getPaymentOrder, getPaymentOrders, queryPaymentOrder, refundPaymentOrder } from '@/api/payment'
|
||||||
import { formatDate } from '@/utils/format'
|
import { formatDate } from '@/utils/format'
|
||||||
|
|
||||||
const page = ref(1); const pageSize = ref(10); const total = ref(0); const rows = ref([]); const loading = ref(false); const searchInfo = ref({}); const detail = ref(null); const detailVisible = ref(false); const refundVisible = ref(false); const refundLoading = ref(false); const refundFormRef = ref(); const refundForm = ref({ amount: 0, maxAmount: 0 })
|
defineOptions({ name: 'PaymentOrders' })
|
||||||
const paymentStatuses = [{ value: 'initialized', label: '初始化' }, { value: 'pending', label: '待支付' }, { value: 'paid', label: '已支付' }, { value: 'failed', label: '失败' }, { value: 'closed', label: '已关闭' }]
|
|
||||||
const refundStatuses = [{ value: 'none', label: '未退款' }, { value: 'processing', label: '退款中' }, { value: 'partially_refunded', label: '部分退款' }, { value: 'refunded', label: '已退款' }, { value: 'failed', label: '退款失败' }]
|
const PAYMENT_STATUS_META = {
|
||||||
const refundRules = { amount: [{ required: true, message: '请输入退款金额', trigger: 'blur' }, { validator: (_rule, value, callback) => value > 0 && value <= refundForm.value.maxAmount ? callback() : callback(new Error('退款金额超出可退范围')), trigger: 'change' }] }
|
initialized: { label: '初始化', type: 'info' }, pending: { label: '待支付', type: 'warning' },
|
||||||
const formatAmount = (value, currency = 'CNY') => `${((Number(value) || 0) / 100).toFixed(2)} ${currency || ''}`.trim(); const statusText = (s) => ({ initialized: '初始化', pending: '待支付', paid: '已支付', failed: '失败', closed: '已关闭' }[s] || s || '-'); const statusType = (s) => ({ paid: 'success', failed: 'danger', closed: 'info' }[s] || 'warning'); const fulfillmentText = (s) => ({ pending: '待发货', processing: '发货中', succeeded: '已发货', failed: '发货失败' }[s] || s || '-'); const fulfillmentType = (s) => ({ succeeded: 'success', failed: 'danger', processing: 'warning' }[s] || 'info'); const refundText = (s) => ({ none: '未退款', processing: '退款中', partially_refunded: '部分退款', refunded: '已退款', failed: '退款失败' }[s] || s || '-'); const refundType = (s) => ({ refunded: 'success', failed: 'danger', processing: 'warning', partially_refunded: 'warning' }[s] || 'info')
|
paid: { label: '已支付', type: 'success' }, failed: { label: '支付失败', type: 'danger' },
|
||||||
const canRefund = (r) => r.paymentStatus === 'paid' && !['refunded', 'processing'].includes(r.refundStatus) && Number(r.amount || 0) > Number(r.refundedAmount || 0); const canFulfill = (r) => r.paymentStatus === 'paid' && r.fulfillmentStatus !== 'succeeded'
|
closed: { label: '已关闭', type: 'info' }, partially_refunded: { label: '部分退款', type: 'warning' },
|
||||||
const load = async () => { loading.value = true; try { const res = await getPaymentOrders({ page: page.value, pageSize: pageSize.value, ...searchInfo.value }); if (res.code === 0) { rows.value = res.data?.list || []; total.value = res.data?.total || 0; page.value = res.data?.page || page.value; pageSize.value = res.data?.pageSize || pageSize.value } } finally { loading.value = false } }
|
refunded: { label: '已退款', type: 'info' }
|
||||||
const reload = () => { page.value = 1; load() }; const reset = () => { searchInfo.value = {}; reload() }; const changePage = (v) => { page.value = v; load() }; const changeSize = (v) => { pageSize.value = v; page.value = 1; load() }
|
}
|
||||||
const openDetail = async (row) => { detail.value = row; detailVisible.value = true; const res = await getPaymentOrder({ provider: row.provider, tradeNo: row.tradeNo }); if (res.code === 0 && res.data) detail.value = res.data }
|
const FULFILLMENT_STATUS_META = {
|
||||||
const refreshOrder = async (row) => { const res = await queryPaymentOrder({ provider: row.provider, tradeNo: row.tradeNo }); if (res.code === 0) { ElMessage.success('已同步支付状态'); await load() } }
|
pending: { label: '待发货', type: 'info' }, processing: { label: '发货中', type: 'warning' },
|
||||||
const openRefund = (row) => { const maxAmount = Number(row.amount || 0) - Number(row.refundedAmount || 0); refundForm.value = { provider: row.provider, tradeNo: row.tradeNo, currency: row.currency, maxAmount, amount: maxAmount }; refundVisible.value = true }
|
succeeded: { label: '已发货', type: 'success' }, failed: { label: '发货失败', type: 'danger' }
|
||||||
const submitRefund = async () => { await refundFormRef.value?.validate(); await ElMessageBox.confirm('退款操作将调用支付渠道,确认继续吗?', '确认退款', { type: 'warning' }); refundLoading.value = true; try { const res = await refundPaymentOrder({ provider: refundForm.value.provider, tradeNo: refundForm.value.tradeNo, amount: refundForm.value.amount }); if (res.code === 0) { ElMessage.success('退款请求已提交'); refundVisible.value = false; await load() } } finally { refundLoading.value = false } }
|
}
|
||||||
const showFulfillmentHint = () => ElMessage.info('发货接口已预留,待业务模块注册发货处理器后启用。')
|
const REFUND_STATUS_META = {
|
||||||
load()
|
none: { label: '未退款', type: 'info' }, processing: { label: '请求处理中', type: 'warning' },
|
||||||
|
pending: { label: '渠道处理中', type: 'warning' }, partial: { label: '部分退款', type: 'warning' },
|
||||||
|
succeeded: { label: '已退款', type: 'success' }, failed: { label: '退款失败', type: 'danger' }
|
||||||
|
}
|
||||||
|
const providerOptions = [
|
||||||
|
['alipay', '支付宝'], ['alipay-v3', '支付宝 V3'], ['wechat-v2', '微信支付 V2'], ['wechat-v3', '微信支付 V3'],
|
||||||
|
['apple-iap', 'Apple IAP'], ['douyin', '抖音支付'], ['qq', 'QQ 钱包'], ['allinpay', '通联支付'],
|
||||||
|
['lakala', '拉卡拉'], ['paypal', 'PayPal'], ['saobei', '扫呗'], ['chinaums', '银联商务'], ['sft', '商福通'],
|
||||||
|
['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]))
|
||||||
|
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 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
|
||||||
|
|
||||||
|
const { width } = useWindowSize()
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
const total = ref(0)
|
||||||
|
const rows = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const detail = ref(null)
|
||||||
|
const detailVisible = ref(false)
|
||||||
|
const detailLoading = ref(false)
|
||||||
|
const refundVisible = ref(false)
|
||||||
|
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: '' })
|
||||||
|
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: '支付、发货或退款异常' }
|
||||||
|
])
|
||||||
|
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 drawerSize = computed(() => width.value < 720 ? '96%' : '720px')
|
||||||
|
const detailColumns = computed(() => width.value < 720 ? 1 : 2)
|
||||||
|
const refundPrecisionText = computed(() => currencyMinorDigits(refundForm.currency) === 0 ? '该币种仅支持整数金额' : `最多支持 ${currencyMinorDigits(refundForm.currency)} 位小数`)
|
||||||
|
const refundAmountPlaceholder = computed(() => currencyMinorDigits(refundForm.currency) === 0 ? '例如 100' : '例如 100.00')
|
||||||
|
const detailAmounts = computed(() => {
|
||||||
|
if (!detail.value) return []
|
||||||
|
const order = detail.value
|
||||||
|
const currency = order.currency
|
||||||
|
const items = [
|
||||||
|
{ label: '原始金额', value: formatAmount(order.originalAmount || order.amount, currency) },
|
||||||
|
{ label: '应付金额', value: formatAmount(order.amount, currency) },
|
||||||
|
{ label: '已付金额', value: formatAmount(order.paidAmount, currency) },
|
||||||
|
{ label: '已退款', value: formatAmount(order.refundedAmount, currency) }
|
||||||
|
]
|
||||||
|
if (order.refundRequestedAmount) items.push({ label: '退款处理中', value: formatAmount(order.refundRequestedAmount, currency) })
|
||||||
|
if (order.amountBreakdownKnown) items.push(
|
||||||
|
{ label: '付款人实付', value: formatAmount(order.payerPaidAmount, order.payerCurrency || currency) },
|
||||||
|
{ label: '现金支付', value: formatAmount(order.cashPaidAmount, order.payerCurrency || currency) },
|
||||||
|
{ label: '积分支付', value: formatAmount(order.pointPaidAmount, order.payerCurrency || currency) },
|
||||||
|
{ label: '优惠合计', value: formatAmount(order.discountAmount, currency) },
|
||||||
|
{ label: '渠道优惠', value: formatAmount(order.providerDiscountAmount, currency) },
|
||||||
|
{ label: '商户优惠', value: formatAmount(order.merchantDiscountAmount, currency) },
|
||||||
|
{ label: '结算金额', value: formatAmount(order.settlementAmount, currency) }
|
||||||
|
)
|
||||||
|
return items
|
||||||
|
})
|
||||||
|
const detailTimeline = computed(() => {
|
||||||
|
if (!detail.value) return []
|
||||||
|
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) }))
|
||||||
|
})
|
||||||
|
const refundRules = { amountText: [{ required: true, message: '请输入退款金额', trigger: 'blur' }, { validator: validateRefundAmount, trigger: ['blur', 'change'] }] }
|
||||||
|
|
||||||
|
function currencyMinorDigits(currency) {
|
||||||
|
const code = String(currency || 'CNY').trim().toUpperCase()
|
||||||
|
if (ZERO_DECIMAL_CURRENCIES.has(code)) return 0
|
||||||
|
if (THREE_DECIMAL_CURRENCIES.has(code)) return 3
|
||||||
|
if (FOUR_DECIMAL_CURRENCIES.has(code)) return 4
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
function toIntegerAmount(value) {
|
||||||
|
const numeric = Number(value)
|
||||||
|
if (!Number.isFinite(numeric)) return 0
|
||||||
|
return Math.trunc(numeric)
|
||||||
|
}
|
||||||
|
function formatAmount(value, currency = 'CNY') {
|
||||||
|
const code = String(currency || 'CNY').trim().toUpperCase()
|
||||||
|
const digits = currencyMinorDigits(code)
|
||||||
|
const scale = 10 ** digits
|
||||||
|
const amount = toIntegerAmount(value)
|
||||||
|
const absolute = Math.abs(amount)
|
||||||
|
const whole = Math.floor(absolute / scale).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
||||||
|
const fraction = digits > 0 ? `.${String(absolute % scale).padStart(digits, '0')}` : ''
|
||||||
|
return `${amount < 0 ? '-' : ''}${whole}${fraction} ${code}`
|
||||||
|
}
|
||||||
|
function formatAmountInput(value, currency) {
|
||||||
|
const digits = currencyMinorDigits(currency)
|
||||||
|
const scale = 10 ** digits
|
||||||
|
const amount = toIntegerAmount(value)
|
||||||
|
const whole = Math.floor(amount / scale)
|
||||||
|
return digits === 0 ? String(whole) : `${whole}.${String(amount % scale).padStart(digits, '0')}`
|
||||||
|
}
|
||||||
|
function parseAmountToMinor(value, currency) {
|
||||||
|
const text = String(value || '').trim()
|
||||||
|
const digits = currencyMinorDigits(currency)
|
||||||
|
const pattern = digits === 0 ? /^\d+$/ : new RegExp(`^\\d+(?:\\.\\d{1,${digits}})?$`)
|
||||||
|
if (!pattern.test(text)) throw new Error(digits === 0 ? '请输入整数金额' : `金额最多保留 ${digits} 位小数`)
|
||||||
|
const [whole, fraction = ''] = text.split('.')
|
||||||
|
const wholeAmount = Number(whole)
|
||||||
|
const fractionAmount = Number(fraction.padEnd(digits, '0') || '0')
|
||||||
|
const minor = wholeAmount * (10 ** digits) + fractionAmount
|
||||||
|
if (!Number.isSafeInteger(minor) || minor > MAX_SAFE_MINOR_AMOUNT) throw new Error('退款金额超出前端可安全处理范围')
|
||||||
|
if (minor <= 0) throw new Error('退款金额必须大于 0')
|
||||||
|
return minor
|
||||||
|
}
|
||||||
|
function validateRefundAmount(_rule, value, callback) {
|
||||||
|
try {
|
||||||
|
const amount = parseAmountToMinor(value, refundForm.currency)
|
||||||
|
if (amount > refundForm.maxAmount) return callback(new Error(`退款金额不能超过 ${formatAmount(refundForm.maxAmount, refundForm.currency)}`))
|
||||||
|
callback()
|
||||||
|
} catch (error) { callback(error) }
|
||||||
|
}
|
||||||
|
function providerText(provider) { return providerNames[provider] || provider || '-' }
|
||||||
|
function paymentStatusText(status) { return PAYMENT_STATUS_META[status]?.label || status || '-' }
|
||||||
|
function paymentStatusType(status) { return PAYMENT_STATUS_META[status]?.type || 'info' }
|
||||||
|
function fulfillmentStatusText(status, order) {
|
||||||
|
if (status === 'pending' && order?.paymentStatus !== 'paid') return '未触发'
|
||||||
|
return FULFILLMENT_STATUS_META[status]?.label || status || '-'
|
||||||
|
}
|
||||||
|
function fulfillmentStatusType(status) { return FULFILLMENT_STATUS_META[status]?.type || 'info' }
|
||||||
|
function refundStatusText(status) { return REFUND_STATUS_META[status]?.label || status || '-' }
|
||||||
|
function refundStatusType(status) { return REFUND_STATUS_META[status]?.type || 'info' }
|
||||||
|
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 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' : '' }
|
||||||
|
function actionKey(order) { return `${order?.provider || ''}\u0000${order?.tradeNo || ''}` }
|
||||||
|
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()]))
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
} catch {
|
||||||
|
// The request layer already presents transport errors.
|
||||||
|
} finally {
|
||||||
|
if (requestID === loadRequestID) loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function reload() { page.value = 1; load() }
|
||||||
|
function reset() { Object.assign(searchInfo, { provider: '', tradeNo: '', businessType: '', businessId: '', paymentStatus: '', refundStatus: '' }); reload() }
|
||||||
|
function changePage(value) { page.value = value; load() }
|
||||||
|
function changeSize(value) { pageSize.value = value; page.value = 1; load() }
|
||||||
|
async function loadDetail(order) {
|
||||||
|
if (!order?.provider || !order?.tradeNo) return
|
||||||
|
const requestID = ++detailRequestID
|
||||||
|
const provider = order.provider
|
||||||
|
const tradeNo = order.tradeNo
|
||||||
|
detailLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getPaymentOrder({ provider, tradeNo })
|
||||||
|
if (requestID === detailRequestID && detail.value?.provider === provider && detail.value?.tradeNo === tradeNo && res.code === 0 && res.data) detail.value = res.data
|
||||||
|
} catch {
|
||||||
|
// The request layer already presents transport errors.
|
||||||
|
} finally {
|
||||||
|
if (requestID === detailRequestID) detailLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function openDetail(order) { detail.value = { ...order }; detailVisible.value = true; loadDetail(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
|
||||||
|
setRowAction(order, 'sync')
|
||||||
|
try {
|
||||||
|
const res = await queryPaymentOrder({ provider: order.provider, tradeNo: order.tradeNo })
|
||||||
|
if (res.code !== 0) return
|
||||||
|
ElMessage.success(res.data?.orderStatus ? `订单状态已同步:${paymentStatusText(res.data.orderStatus)}` : '订单状态已同步')
|
||||||
|
await load({ silent: true })
|
||||||
|
await refreshVisibleDetail(order)
|
||||||
|
} catch {
|
||||||
|
// The request layer already presents transport errors.
|
||||||
|
} finally { setRowAction(order, '') }
|
||||||
|
}
|
||||||
|
async function retryFulfillment(order) {
|
||||||
|
if (isRowBusy(order)) return
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确认${fulfillmentActionText(order)}订单 ${order.tradeNo} 吗?`, fulfillmentActionText(order), { type: 'warning', confirmButtonText: '确认执行' })
|
||||||
|
} catch { return }
|
||||||
|
setRowAction(order, 'fulfill')
|
||||||
|
try {
|
||||||
|
const res = await fulfillPaymentOrder({ provider: order.provider, tradeNo: order.tradeNo })
|
||||||
|
if (res.code !== 0) return
|
||||||
|
ElMessage.success(res.data?.duplicate ? '该订单已完成发货,无需重复处理' : '发货处理已完成')
|
||||||
|
await load({ silent: true })
|
||||||
|
await refreshVisibleDetail(order)
|
||||||
|
} catch {
|
||||||
|
// The request layer already presents transport errors.
|
||||||
|
} finally { setRowAction(order, '') }
|
||||||
|
}
|
||||||
|
function openRefund(order) {
|
||||||
|
const maxAmount = remainingRefundAmount(order)
|
||||||
|
Object.assign(refundForm, { provider: order.provider, tradeNo: order.tradeNo, currency: order.currency || 'CNY', maxAmount, amountText: formatAmountInput(maxAmount, order.currency || 'CNY') })
|
||||||
|
refundVisible.value = true
|
||||||
|
nextTick(() => refundFormRef.value?.clearValidate())
|
||||||
|
}
|
||||||
|
async function submitRefund() {
|
||||||
|
if (refundSubmitting.value) return
|
||||||
|
refundSubmitting.value = true
|
||||||
|
const valid = await refundFormRef.value?.validate().catch(() => false)
|
||||||
|
if (!valid) { refundSubmitting.value = false; return }
|
||||||
|
let amount
|
||||||
|
try { amount = parseAmountToMinor(refundForm.amountText, refundForm.currency) }
|
||||||
|
catch (error) { ElMessage.warning(error.message); refundSubmitting.value = false; return }
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确认退款 ${formatAmount(amount, refundForm.currency)} 吗?提交后将调用支付渠道。`, '确认退款', { type: 'warning', confirmButtonText: '确认退款' })
|
||||||
|
} catch { refundSubmitting.value = false; return }
|
||||||
|
refundLoading.value = true
|
||||||
|
const order = { provider: refundForm.provider, tradeNo: refundForm.tradeNo }
|
||||||
|
try {
|
||||||
|
const res = await refundPaymentOrder({ provider: refundForm.provider, tradeNo: refundForm.tradeNo, amount })
|
||||||
|
if (res.code !== 0) return
|
||||||
|
ElMessage.success(res.data?.refundStatus === 'succeeded' ? '退款已完成' : '退款申请已提交,等待渠道确认')
|
||||||
|
refundVisible.value = false
|
||||||
|
await load({ silent: true })
|
||||||
|
await refreshVisibleDetail(order)
|
||||||
|
} catch {
|
||||||
|
// The request layer already presents transport errors.
|
||||||
|
} finally { refundLoading.value = false; refundSubmitting.value = false }
|
||||||
|
}
|
||||||
|
async function copyText(value) {
|
||||||
|
try { await navigator.clipboard.writeText(String(value)); ElMessage.success('已复制') }
|
||||||
|
catch { ElMessage.warning('复制失败,请手动选择文本') }
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.payment-orders { min-width: 980px; }
|
.payment-orders { min-width: 0; padding-bottom: 24px; }
|
||||||
.error-text { color: var(--el-color-danger); word-break: break-word; }
|
.page-heading { display: flex; align-items: center; justify-content: space-between; gap: 18px; margin-bottom: 16px; }
|
||||||
.form-tip { color: var(--el-text-color-secondary); font-size: 12px; line-height: 1.4; margin-top: 4px; }
|
.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; }
|
||||||
|
.summary-item small { overflow: hidden; color: var(--el-text-color-secondary); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.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%; }
|
||||||
|
.filter-actions { display: flex; align-items: flex-end; gap: 8px; padding-bottom: 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-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-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; }
|
||||||
|
.payment-orders :deep(.el-table__row.is-payment-issue > td.el-table__cell) { background: var(--el-color-danger-light-9); }
|
||||||
|
.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-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; }
|
||||||
|
.amount-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); border-top: 1px solid var(--el-border-color-lighter); border-left: 1px solid var(--el-border-color-lighter); }
|
||||||
|
.amount-item { display: grid; min-width: 0; gap: 5px; padding: 12px 14px; border-right: 1px solid var(--el-border-color-lighter); border-bottom: 1px solid var(--el-border-color-lighter); }
|
||||||
|
.amount-item span { color: var(--el-text-color-secondary); font-size: 12px; }
|
||||||
|
.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; }
|
||||||
|
.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; } }
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue