Compare commits
5 Commits
690a2d71ca
...
626f505b5d
| Author | SHA1 | Date |
|---|---|---|
|
|
626f505b5d | |
|
|
5d5784f535 | |
|
|
430feaaff6 | |
|
|
e4ba0dced9 | |
|
|
a2ce3ae218 |
|
|
@ -15,6 +15,7 @@ import (
|
||||||
"kra/internal/data/payment"
|
"kra/internal/data/payment"
|
||||||
"kra/internal/data/repository"
|
"kra/internal/data/repository"
|
||||||
"kra/internal/initialize"
|
"kra/internal/initialize"
|
||||||
|
"kra/internal/integration"
|
||||||
"kra/internal/integration/cache"
|
"kra/internal/integration/cache"
|
||||||
"kra/internal/integration/email"
|
"kra/internal/integration/email"
|
||||||
"kra/internal/integration/mq"
|
"kra/internal/integration/mq"
|
||||||
|
|
@ -147,21 +148,23 @@ func wireApp(confServer *conf.Server, runtime *conf.Runtime, logger *slog.Logger
|
||||||
navigation := handler.NewNavigation(userService)
|
navigation := handler.NewNavigation(userService)
|
||||||
session := handler.NewSession(tokenService)
|
session := handler.NewSession(tokenService)
|
||||||
integrationConfigRepo := system.NewIntegrationConfigRepo(dataData)
|
integrationConfigRepo := system.NewIntegrationConfigRepo(dataData)
|
||||||
integrationConfigUsecase := biz.NewIntegrationConfigUsecase(integrationConfigRepo)
|
store := data.NewIntegrationRuntime(dataData)
|
||||||
|
connectivityTester := integration.NewConnectivityTester(store)
|
||||||
|
integrationConfigUsecase := biz.NewIntegrationConfigUsecase(integrationConfigRepo, connectivityTester)
|
||||||
integrationConfigService := service.NewIntegrationConfigService(integrationConfigUsecase)
|
integrationConfigService := service.NewIntegrationConfigService(integrationConfigUsecase)
|
||||||
integrationConfig := handler.NewIntegrationConfig(integrationConfigService)
|
integrationConfig := handler.NewIntegrationConfig(integrationConfigService)
|
||||||
v := handler.NewSet(authority, menu, api, permission, organization, announcement, handlerEmail, handlerPayment, task, media, audit, export, version, dictionary, parameter, apiToken, systemConfig, public, user, navigation, session, integrationConfig)
|
v := handler.NewSet(authority, menu, api, permission, organization, announcement, handlerEmail, handlerPayment, task, media, audit, export, version, dictionary, parameter, apiToken, systemConfig, public, user, navigation, session, integrationConfig)
|
||||||
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)
|
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 适配按子包维护。
|
||||||
|
|
|
||||||
1
go.mod
1
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
|
||||||
|
|
|
||||||
2
go.sum
2
go.sum
|
|
@ -331,6 +331,8 @@ github.com/qiniu/dyn v1.3.0/go.mod h1:E8oERcm8TtwJiZvkQPbcAh0RL8jO1G0VXJMW3FAWdk
|
||||||
github.com/qiniu/go-sdk/v7 v7.25.2 h1:URwgZpxySdiwu2yQpHk93X4LXWHyFRp1x3Vmlk/YWvo=
|
github.com/qiniu/go-sdk/v7 v7.25.2 h1:URwgZpxySdiwu2yQpHk93X4LXWHyFRp1x3Vmlk/YWvo=
|
||||||
github.com/qiniu/go-sdk/v7 v7.25.2/go.mod h1:dmKtJ2ahhPWFVi9o1D5GemmWoh/ctuB9peqTowyTO8o=
|
github.com/qiniu/go-sdk/v7 v7.25.2/go.mod h1:dmKtJ2ahhPWFVi9o1D5GemmWoh/ctuB9peqTowyTO8o=
|
||||||
github.com/qiniu/x v1.10.5/go.mod h1:03Ni9tj+N2h2aKnAz+6N0Xfl8FwMEDRC2PAlxekASDs=
|
github.com/qiniu/x v1.10.5/go.mod h1:03Ni9tj+N2h2aKnAz+6N0Xfl8FwMEDRC2PAlxekASDs=
|
||||||
|
github.com/rabbitmq/amqp091-go v1.14.0 h1:RSaT7aOKt/OrkVUyswPDW29lnRz9psuGmfZFBmLqLek=
|
||||||
|
github.com/rabbitmq/amqp091-go v1.14.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
|
||||||
github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
|
github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
|
||||||
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
|
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
|
|
||||||
|
|
@ -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,23 @@ 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/test", Method: "POST", 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,9 +8,14 @@ import (
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const IntegrationKindPayment = "payment"
|
const (
|
||||||
|
IntegrationKindPayment = "payment"
|
||||||
|
IntegrationKindMQ = "mq"
|
||||||
|
IntegrationKindWebSocket = "websocket"
|
||||||
|
)
|
||||||
|
|
||||||
type IntegrationConfig struct {
|
type IntegrationConfig struct {
|
||||||
Kind string
|
Kind string
|
||||||
|
|
@ -51,10 +56,17 @@ type IntegrationConfigRepo interface {
|
||||||
DeleteIntegrationConfig(context.Context, string, string) error
|
DeleteIntegrationConfig(context.Context, string, string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type IntegrationConfigUsecase struct{ repo IntegrationConfigRepo }
|
type IntegrationConnectionTester interface {
|
||||||
|
TestIntegration(context.Context, *IntegrationConfig) error
|
||||||
|
}
|
||||||
|
|
||||||
func NewIntegrationConfigUsecase(repo IntegrationConfigRepo) *IntegrationConfigUsecase {
|
type IntegrationConfigUsecase struct {
|
||||||
return &IntegrationConfigUsecase{repo: repo}
|
repo IntegrationConfigRepo
|
||||||
|
tester IntegrationConnectionTester
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewIntegrationConfigUsecase(repo IntegrationConfigRepo, tester IntegrationConnectionTester) *IntegrationConfigUsecase {
|
||||||
|
return &IntegrationConfigUsecase{repo: repo, tester: tester}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (uc *IntegrationConfigUsecase) List(ctx context.Context, kind string) ([]*IntegrationConfig, error) {
|
func (uc *IntegrationConfigUsecase) List(ctx context.Context, kind string) ([]*IntegrationConfig, error) {
|
||||||
|
|
@ -101,6 +113,42 @@ func (uc *IntegrationConfigUsecase) Save(ctx context.Context, config *Integratio
|
||||||
return uc.repo.SaveIntegrationConfig(ctx, config)
|
return uc.repo.SaveIntegrationConfig(ctx, config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Test validates and probes a candidate configuration without persisting it.
|
||||||
|
// The adapter may resolve masked secret values from the active runtime store.
|
||||||
|
func (uc *IntegrationConfigUsecase) Test(ctx context.Context, config *IntegrationConfig) error {
|
||||||
|
if config == nil {
|
||||||
|
return errors.New("集成配置请求为空")
|
||||||
|
}
|
||||||
|
config.Kind = normalizeIntegrationPart(config.Kind)
|
||||||
|
config.Provider = normalizeIntegrationPart(config.Provider)
|
||||||
|
if config.Kind != IntegrationKindMQ && config.Kind != IntegrationKindWebSocket {
|
||||||
|
return errors.New("仅支持测试消息队列和 WebSocket 集成")
|
||||||
|
}
|
||||||
|
if config.Kind == "" || config.Provider == "" || len(config.Kind) > 32 || len(config.Provider) > 64 {
|
||||||
|
return errors.New("集成配置 kind 或 provider 无效")
|
||||||
|
}
|
||||||
|
if !json.Valid(config.Values) {
|
||||||
|
return errors.New("集成配置必须是合法 JSON")
|
||||||
|
}
|
||||||
|
values := map[string]any{}
|
||||||
|
if err := json.Unmarshal(config.Values, &values); err != nil {
|
||||||
|
return errors.New("集成配置必须是 JSON 对象")
|
||||||
|
}
|
||||||
|
if definition, ok := IntegrationDefinition(config.Kind, config.Provider); ok {
|
||||||
|
values = mergeIntegrationDefaults(definition.Defaults, values)
|
||||||
|
}
|
||||||
|
if err := ValidateIntegrationConfig(config.Kind, config.Provider, values); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
encoded, _ := json.Marshal(values)
|
||||||
|
config.Enabled = true
|
||||||
|
config.Values = encoded
|
||||||
|
if uc.tester == nil {
|
||||||
|
return errors.New("集成连接测试器未初始化")
|
||||||
|
}
|
||||||
|
return uc.tester.TestIntegration(ctx, config)
|
||||||
|
}
|
||||||
|
|
||||||
func (uc *IntegrationConfigUsecase) Delete(ctx context.Context, kind, provider string) error {
|
func (uc *IntegrationConfigUsecase) Delete(ctx context.Context, kind, provider string) error {
|
||||||
kind, provider = normalizeIntegrationPart(kind), normalizeIntegrationPart(provider)
|
kind, provider = normalizeIntegrationPart(kind), normalizeIntegrationPart(provider)
|
||||||
if kind == "" || provider == "" {
|
if kind == "" || provider == "" {
|
||||||
|
|
@ -150,10 +198,79 @@ func mergeIntegrationDefaults(defaults, values map[string]any) map[string]any {
|
||||||
}
|
}
|
||||||
|
|
||||||
func ValidateIntegrationConfig(kind, provider string, values map[string]any) error {
|
func ValidateIntegrationConfig(kind, provider string, values map[string]any) error {
|
||||||
if normalizeIntegrationPart(kind) != IntegrationKindPayment {
|
kind = normalizeIntegrationPart(kind)
|
||||||
|
provider = normalizeIntegrationPart(provider)
|
||||||
|
switch kind {
|
||||||
|
case IntegrationKindPayment:
|
||||||
|
return validatePaymentIntegrationConfig(provider, values)
|
||||||
|
case IntegrationKindMQ, IntegrationKindWebSocket:
|
||||||
|
return validateCommunicationIntegrationConfig(kind, provider, values)
|
||||||
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return validatePaymentIntegrationConfig(normalizeIntegrationPart(provider), values)
|
}
|
||||||
|
|
||||||
|
func validateCommunicationIntegrationConfig(kind, provider string, values map[string]any) error {
|
||||||
|
definition, ok := IntegrationDefinition(kind, provider)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("不支持的通信集成")
|
||||||
|
}
|
||||||
|
for _, field := range definition.Fields {
|
||||||
|
if field.Required && integrationText(values, field.Key) == "" {
|
||||||
|
return fmt.Errorf("%s 缺少配置字段 %s", provider, field.Key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch kind + "/" + provider {
|
||||||
|
case IntegrationKindMQ + "/emqx":
|
||||||
|
broker := strings.ToLower(integrationText(values, "broker"))
|
||||||
|
if !strings.HasPrefix(broker, "tcp://") && !strings.HasPrefix(broker, "ssl://") && !strings.HasPrefix(broker, "ws://") && !strings.HasPrefix(broker, "wss://") && !strings.HasPrefix(broker, "mqtt://") {
|
||||||
|
return errors.New("emqx broker 必须使用 tcp、ssl、ws、wss 或 mqtt 协议")
|
||||||
|
}
|
||||||
|
if keepAlive := integrationInt64(values, "keep_alive", 0); keepAlive <= 0 {
|
||||||
|
return errors.New("emqx keep_alive 必须大于 0")
|
||||||
|
}
|
||||||
|
if timeout := integrationInt64(values, "connect_timeout", 0); timeout <= 0 {
|
||||||
|
return errors.New("emqx connect_timeout 必须大于 0")
|
||||||
|
}
|
||||||
|
case IntegrationKindMQ + "/rabbitmq":
|
||||||
|
port := integrationInt64(values, "port", 0)
|
||||||
|
if port < 1 || port > 65535 {
|
||||||
|
return errors.New("rabbitmq port 必须在 1-65535 之间")
|
||||||
|
}
|
||||||
|
exchangeType := strings.ToLower(integrationText(values, "exchange_type"))
|
||||||
|
if exchangeType != "direct" && exchangeType != "fanout" && exchangeType != "topic" {
|
||||||
|
return errors.New("rabbitmq exchange_type 必须是 direct、fanout 或 topic")
|
||||||
|
}
|
||||||
|
if integrationInt64(values, "prefetch_count", -1) < 0 {
|
||||||
|
return errors.New("rabbitmq prefetch_count 不能小于 0")
|
||||||
|
}
|
||||||
|
if integrationInt64(values, "heartbeat", -1) < 0 {
|
||||||
|
return errors.New("rabbitmq heartbeat 不能小于 0")
|
||||||
|
}
|
||||||
|
if integrationInt64(values, "connect_timeout", 0) <= 0 {
|
||||||
|
return errors.New("rabbitmq connect_timeout 必须大于 0")
|
||||||
|
}
|
||||||
|
case IntegrationKindWebSocket + "/melody":
|
||||||
|
path := integrationText(values, "path")
|
||||||
|
if !strings.HasPrefix(path, "/") {
|
||||||
|
return errors.New("websocket path 必须以 / 开头")
|
||||||
|
}
|
||||||
|
for _, key := range []string{"write_wait", "pong_wait", "ping_period"} {
|
||||||
|
value := integrationText(values, key)
|
||||||
|
if value == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
duration, err := time.ParseDuration(value)
|
||||||
|
if err != nil || duration <= 0 {
|
||||||
|
return fmt.Errorf("websocket %s 必须是大于 0 的时长", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if integrationInt64(values, "max_message_size", -1) < 0 || integrationInt64(values, "message_buffer_size", -1) < 0 {
|
||||||
|
return errors.New("websocket 消息大小和缓冲区不能小于 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validatePaymentIntegrationConfig(provider string, values map[string]any) error {
|
func validatePaymentIntegrationConfig(provider string, values map[string]any) error {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
package biz
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type integrationConfigRepoTestDouble struct {
|
||||||
|
saves int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*integrationConfigRepoTestDouble) ListIntegrationConfigs(context.Context, string) ([]*IntegrationConfig, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (*integrationConfigRepoTestDouble) FindIntegrationConfig(context.Context, string, string) (*IntegrationConfig, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (r *integrationConfigRepoTestDouble) SaveIntegrationConfig(context.Context, *IntegrationConfig) error {
|
||||||
|
r.saves++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (*integrationConfigRepoTestDouble) DeleteIntegrationConfig(context.Context, string, string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type integrationConnectionTesterDouble struct {
|
||||||
|
calls int
|
||||||
|
config *IntegrationConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *integrationConnectionTesterDouble) TestIntegration(_ context.Context, config *IntegrationConfig) error {
|
||||||
|
t.calls++
|
||||||
|
t.config = config
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegrationConfigTestDoesNotPersistCandidate(t *testing.T) {
|
||||||
|
repo := &integrationConfigRepoTestDouble{}
|
||||||
|
tester := &integrationConnectionTesterDouble{}
|
||||||
|
usecase := NewIntegrationConfigUsecase(repo, tester)
|
||||||
|
raw, _ := json.Marshal(map[string]any{"path": "/candidate"})
|
||||||
|
|
||||||
|
err := usecase.Test(context.Background(), &IntegrationConfig{
|
||||||
|
Kind: " WebSocket ",
|
||||||
|
Provider: " Melody ",
|
||||||
|
Enabled: false,
|
||||||
|
Values: raw,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if repo.saves != 0 {
|
||||||
|
t.Fatalf("candidate config was persisted %d times", repo.saves)
|
||||||
|
}
|
||||||
|
if tester.calls != 1 || tester.config == nil {
|
||||||
|
t.Fatalf("connection tester calls = %d, config = %#v", tester.calls, tester.config)
|
||||||
|
}
|
||||||
|
if tester.config.Kind != IntegrationKindWebSocket || tester.config.Provider != "melody" || !tester.config.Enabled {
|
||||||
|
t.Fatalf("tested config = %#v", tester.config)
|
||||||
|
}
|
||||||
|
values := map[string]any{}
|
||||||
|
if err = json.Unmarshal(tester.config.Values, &values); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if values["path"] != "/candidate" || values["write_wait"] != "10s" {
|
||||||
|
t.Fatalf("tested values = %#v", values)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -89,6 +89,58 @@ func genericPaymentDefinition(provider, name, description string) IntegrationCon
|
||||||
}
|
}
|
||||||
|
|
||||||
var integrationDefinitions = map[string][]IntegrationConfigDefinition{
|
var integrationDefinitions = map[string][]IntegrationConfigDefinition{
|
||||||
|
IntegrationKindMQ: {
|
||||||
|
{
|
||||||
|
Kind: IntegrationKindMQ, Provider: "emqx", Name: "EMQX", Description: "EMQX MQTT 消息服务",
|
||||||
|
Defaults: map[string]any{"broker": "tcp://127.0.0.1:1883", "client_id": "kra", "username": "", "password": "", "keep_alive": 30, "clean_session": true, "connect_timeout": 10},
|
||||||
|
Fields: []IntegrationConfigField{
|
||||||
|
{Key: "broker", Label: "Broker 地址", Type: "text", Required: true, Placeholder: "tcp://127.0.0.1:1883"},
|
||||||
|
{Key: "client_id", Label: "客户端 ID", Type: "text", Required: true, Placeholder: "kra"},
|
||||||
|
{Key: "username", Label: "用户名", Type: "text"},
|
||||||
|
{Key: "password", Label: "密码", Type: "password", Secret: true},
|
||||||
|
{Key: "keep_alive", Label: "心跳间隔(秒)", Type: "number", Required: true},
|
||||||
|
{Key: "clean_session", Label: "清理会话", Type: "switch", Description: "连接时不恢复 Broker 端保存的旧会话。"},
|
||||||
|
{Key: "connect_timeout", Label: "连接超时(秒)", Type: "number", Required: true},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Kind: IntegrationKindMQ, Provider: "rabbitmq", Name: "RabbitMQ", Description: "RabbitMQ AMQP 消息队列",
|
||||||
|
Defaults: map[string]any{"host": "127.0.0.1", "port": 5672, "username": "guest", "password": "guest", "vhost": "/", "exchange": "kra", "exchange_type": "topic", "queue": "kra", "routing_key": "#", "durable": true, "auto_delete": false, "prefetch_count": 10, "heartbeat": 10, "connect_timeout": 10, "tls": false},
|
||||||
|
Fields: []IntegrationConfigField{
|
||||||
|
{Key: "host", Label: "主机", Type: "text", Required: true, Placeholder: "127.0.0.1"},
|
||||||
|
{Key: "port", Label: "端口", Type: "number", Required: true},
|
||||||
|
{Key: "username", Label: "用户名", Type: "text", Required: true},
|
||||||
|
{Key: "password", Label: "密码", Type: "password", Required: true, Secret: true},
|
||||||
|
{Key: "vhost", Label: "Virtual Host", Type: "text", Required: true, Placeholder: "/"},
|
||||||
|
{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"}}},
|
||||||
|
{Key: "queue", Label: "Queue", Type: "text", Required: true, Placeholder: "kra"},
|
||||||
|
{Key: "routing_key", Label: "默认 Routing Key", Type: "text", Required: true, Placeholder: "#", Description: "业务未指定订阅键时使用;topic 类型支持 * 和 #。"},
|
||||||
|
{Key: "durable", Label: "持久化", Type: "switch"},
|
||||||
|
{Key: "auto_delete", Label: "自动删除", Type: "switch"},
|
||||||
|
{Key: "prefetch_count", Label: "预取数量", Type: "number"},
|
||||||
|
{Key: "heartbeat", Label: "心跳间隔(秒)", Type: "number"},
|
||||||
|
{Key: "connect_timeout", Label: "连接超时(秒)", Type: "number", Required: true},
|
||||||
|
{Key: "tls", Label: "启用 TLS", Type: "switch"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
IntegrationKindWebSocket: {
|
||||||
|
{
|
||||||
|
Kind: IntegrationKindWebSocket, Provider: "melody", Name: "WebSocket", Description: "WebSocket 实时连接服务",
|
||||||
|
Defaults: map[string]any{"path": "/ws", "allow_origins": []string{}, "max_message_size": 0, "write_wait": "10s", "pong_wait": "60s", "ping_period": "54s", "message_buffer_size": 0, "concurrent_message_handling": false},
|
||||||
|
Fields: []IntegrationConfigField{
|
||||||
|
{Key: "path", Label: "访问路径", Type: "text", Required: true, Placeholder: "/ws"},
|
||||||
|
{Key: "allow_origins", Label: "允许的来源", Type: "string-list", Placeholder: "https://admin.example.com", Description: "每行一个 Origin;留空时沿用 WebSocket 组件默认策略。"},
|
||||||
|
{Key: "max_message_size", Label: "最大消息字节数", Type: "number", Description: "0 表示使用组件默认值。"},
|
||||||
|
{Key: "write_wait", Label: "写入超时", Type: "text", Required: true, Placeholder: "10s"},
|
||||||
|
{Key: "pong_wait", Label: "Pong 等待时间", Type: "text", Required: true, Placeholder: "60s"},
|
||||||
|
{Key: "ping_period", Label: "Ping 间隔", Type: "text", Required: true, Placeholder: "54s"},
|
||||||
|
{Key: "message_buffer_size", Label: "消息缓冲区", Type: "number", Description: "0 表示不额外缓冲。"},
|
||||||
|
{Key: "concurrent_message_handling", Label: "并发处理消息", Type: "switch"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
IntegrationKindPayment: {
|
IntegrationKindPayment: {
|
||||||
paymentDefinition(PaymentAlipay, "支付宝", "支付宝 OpenAPI RSA2 支付", map[string]any{"app_id": "", "private_key": "", "public_key": "", "environment": "production", "sign_type": "RSA2", "gateway_url": "https://openapi.alipay.com/gateway.do", "method": "alipay.trade.create"},
|
paymentDefinition(PaymentAlipay, "支付宝", "支付宝 OpenAPI RSA2 支付", map[string]any{"app_id": "", "private_key": "", "public_key": "", "environment": "production", "sign_type": "RSA2", "gateway_url": "https://openapi.alipay.com/gateway.do", "method": "alipay.trade.create"},
|
||||||
integrationField("app_id", "应用 ID", true, false, "text"), integrationField("private_key", "应用私钥", true, true, "textarea"), integrationField("public_key", "支付宝公钥", true, true, "textarea"),
|
integrationField("app_id", "应用 ID", true, false, "text"), integrationField("private_key", "应用私钥", true, true, "textarea"), integrationField("public_key", "支付宝公钥", true, true, "textarea"),
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -93,8 +93,6 @@ message AdminBackend {
|
||||||
Zap zap = 11;
|
Zap zap = 11;
|
||||||
CORS cors = 12;
|
CORS cors = 12;
|
||||||
App app = 13;
|
App app = 13;
|
||||||
WebSocket websocket = 14;
|
|
||||||
MQ mq = 15;
|
|
||||||
|
|
||||||
message JWT {
|
message JWT {
|
||||||
string signing_key = 1;
|
string signing_key = 1;
|
||||||
|
|
@ -181,31 +179,6 @@ message AdminBackend {
|
||||||
string env = 3;
|
string env = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
message WebSocket {
|
|
||||||
bool enabled = 1;
|
|
||||||
string path = 2;
|
|
||||||
repeated string allow_origins = 3;
|
|
||||||
int64 max_message_size = 4;
|
|
||||||
google.protobuf.Duration write_wait = 5;
|
|
||||||
google.protobuf.Duration pong_wait = 6;
|
|
||||||
google.protobuf.Duration ping_period = 7;
|
|
||||||
int32 message_buffer_size = 8;
|
|
||||||
bool concurrent_message_handling = 9;
|
|
||||||
}
|
|
||||||
|
|
||||||
// MQ config is persisted in sys_integration_configs (kind=mq/provider=emqx).
|
|
||||||
// The bootstrap fields are retained as a one-time migration source.
|
|
||||||
message MQ {
|
|
||||||
bool enabled = 1;
|
|
||||||
string broker = 2;
|
|
||||||
string client_id = 3;
|
|
||||||
string username = 4;
|
|
||||||
string password = 5;
|
|
||||||
int32 keep_alive = 6;
|
|
||||||
bool clean_session = 7;
|
|
||||||
int32 connect_timeout = 8;
|
|
||||||
}
|
|
||||||
|
|
||||||
message Storage {
|
message Storage {
|
||||||
string type = 1;
|
string type = 1;
|
||||||
Qiniu qiniu = 2;
|
Qiniu qiniu = 2;
|
||||||
|
|
|
||||||
|
|
@ -182,8 +182,6 @@ func (d *Data) persistConfigValuesLocked(dataConfig *conf.Data, adminConfig *con
|
||||||
fileAdmin := cloneAdminConfig(adminConfig)
|
fileAdmin := cloneAdminConfig(adminConfig)
|
||||||
fileAdmin.Storage = nil
|
fileAdmin.Storage = nil
|
||||||
fileAdmin.Email = nil
|
fileAdmin.Email = nil
|
||||||
fileAdmin.Websocket = nil
|
|
||||||
fileAdmin.Mq = nil
|
|
||||||
adminValue, err := protoMap(fileAdmin)
|
adminValue, err := protoMap(fileAdmin)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -196,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
|
||||||
|
|
@ -257,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -277,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -365,8 +357,6 @@ func (d *Data) reloadConfig(ctx context.Context) error {
|
||||||
}
|
}
|
||||||
legacyStorage := next.Admin.Storage
|
legacyStorage := next.Admin.Storage
|
||||||
legacyEmail := next.Admin.Email
|
legacyEmail := next.Admin.Email
|
||||||
legacyWebSocket := next.Admin.Websocket
|
|
||||||
legacyMQ := next.Admin.Mq
|
|
||||||
currentAdmin := d.runtime.Admin()
|
currentAdmin := d.runtime.Admin()
|
||||||
if legacyStorage == nil {
|
if legacyStorage == nil {
|
||||||
if currentAdmin != nil {
|
if currentAdmin != nil {
|
||||||
|
|
@ -376,12 +366,6 @@ func (d *Data) reloadConfig(ctx context.Context) error {
|
||||||
if legacyEmail == nil && currentAdmin != nil {
|
if legacyEmail == nil && currentAdmin != nil {
|
||||||
legacyEmail = currentAdmin.Email
|
legacyEmail = currentAdmin.Email
|
||||||
}
|
}
|
||||||
if legacyWebSocket == nil && currentAdmin != nil {
|
|
||||||
legacyWebSocket = currentAdmin.Websocket
|
|
||||||
}
|
|
||||||
if legacyMQ == nil && currentAdmin != nil {
|
|
||||||
legacyMQ = currentAdmin.Mq
|
|
||||||
}
|
|
||||||
storageConfig, err := resolveStorageIntegrationConfig(candidateDB.WithContext(ctx), legacyStorage)
|
storageConfig, err := resolveStorageIntegrationConfig(candidateDB.WithContext(ctx), legacyStorage)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("reload storage configuration: %w", err)
|
return fmt.Errorf("reload storage configuration: %w", err)
|
||||||
|
|
@ -392,16 +376,6 @@ func (d *Data) reloadConfig(ctx context.Context) error {
|
||||||
return fmt.Errorf("reload email configuration: %w", err)
|
return fmt.Errorf("reload email configuration: %w", err)
|
||||||
}
|
}
|
||||||
next.Admin.Email = emailConfig
|
next.Admin.Email = emailConfig
|
||||||
websocketConfig, err := resolveWebSocketIntegrationConfig(candidateDB.WithContext(ctx), legacyWebSocket)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("reload websocket configuration: %w", err)
|
|
||||||
}
|
|
||||||
next.Admin.Websocket = websocketConfig
|
|
||||||
mqConfig, err := resolveMQIntegrationConfig(candidateDB.WithContext(ctx), legacyMQ)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("reload mq configuration: %w", err)
|
|
||||||
}
|
|
||||||
next.Admin.Mq = mqConfig
|
|
||||||
candidateStorage, err := storage.New(next.Admin)
|
candidateStorage, err := storage.New(next.Admin)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("reload storage: %w", err)
|
return fmt.Errorf("reload storage: %w", err)
|
||||||
|
|
@ -436,6 +410,9 @@ func (d *Data) reloadConfig(ctx context.Context) error {
|
||||||
mongoAccepted = true
|
mongoAccepted = true
|
||||||
}
|
}
|
||||||
d.runtime.Replace(next.Data, next.Admin)
|
d.runtime.Replace(next.Data, next.Admin)
|
||||||
|
if err = d.loadIntegrationRuntime(candidateDB); err != nil {
|
||||||
|
return fmt.Errorf("reload integration runtime: %w", err)
|
||||||
|
}
|
||||||
if d.storage != nil {
|
if d.storage != nil {
|
||||||
d.storage.Replace(candidateStorage)
|
d.storage.Replace(candidateStorage)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,8 +58,6 @@ func (d *Data) watchConfig() func() {
|
||||||
if current := d.runtime.Admin(); current != nil {
|
if current := d.runtime.Admin(); current != nil {
|
||||||
next.Admin.Storage = current.Storage
|
next.Admin.Storage = current.Storage
|
||||||
next.Admin.Email = current.Email
|
next.Admin.Email = current.Email
|
||||||
next.Admin.Mq = current.Mq
|
|
||||||
next.Admin.Websocket = current.Websocket
|
|
||||||
}
|
}
|
||||||
next.Admin.ConfigPath = absolute
|
next.Admin.ConfigPath = absolute
|
||||||
d.runtime.Replace(next.Data, next.Admin)
|
d.runtime.Replace(next.Data, next.Admin)
|
||||||
|
|
|
||||||
|
|
@ -14,12 +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/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)),
|
||||||
|
|
@ -33,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
|
||||||
|
|
@ -41,6 +50,7 @@ type Data struct {
|
||||||
redis *reloadableRedis
|
redis *reloadableRedis
|
||||||
mongo *reloadableMongo
|
mongo *reloadableMongo
|
||||||
runtime *conf.Runtime
|
runtime *conf.Runtime
|
||||||
|
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
|
||||||
|
|
@ -73,6 +83,15 @@ func (d *Data) Runtime() *conf.Runtime {
|
||||||
return d.runtime
|
return d.runtime
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IntegrationRuntime exposes database-backed integration configuration to
|
||||||
|
// long-lived adapters without making config.yaml part of their lifecycle.
|
||||||
|
func (d *Data) IntegrationRuntime() *runtimeconfig.Store {
|
||||||
|
if d == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return d.integrations
|
||||||
|
}
|
||||||
|
|
||||||
// Database resolves the primary or a named database for repositories such as
|
// Database resolves the primary or a named database for repositories such as
|
||||||
// the system export module.
|
// the system export module.
|
||||||
func (d *Data) Database(name string) (*gorm.DB, error) {
|
func (d *Data) Database(name string) (*gorm.DB, error) {
|
||||||
|
|
@ -156,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, 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
|
||||||
|
|
@ -208,17 +227,10 @@ func NewData(runtime *conf.Runtime, appLogger *slog.Logger, storageManager *stor
|
||||||
}
|
}
|
||||||
admin.Storage = storageConfig
|
admin.Storage = storageConfig
|
||||||
admin.Email = emailConfig
|
admin.Email = emailConfig
|
||||||
websocketConfig, websocketErr := resolveWebSocketIntegrationConfig(db, admin.Websocket)
|
|
||||||
if websocketErr != nil {
|
|
||||||
return nil, nil, fmt.Errorf("load websocket integration configuration: %w", websocketErr)
|
|
||||||
}
|
|
||||||
admin.Websocket = websocketConfig
|
|
||||||
mqConfig, mqErr := resolveMQIntegrationConfig(db, admin.Mq)
|
|
||||||
if mqErr != nil {
|
|
||||||
return nil, nil, fmt.Errorf("load mq integration configuration: %w", mqErr)
|
|
||||||
}
|
|
||||||
admin.Mq = mqConfig
|
|
||||||
runtime.Replace(c, admin)
|
runtime.Replace(c, admin)
|
||||||
|
if err = d.loadIntegrationRuntime(db); err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("load integration runtime: %w", err)
|
||||||
|
}
|
||||||
activeStorage, storageErr := storage.New(admin)
|
activeStorage, storageErr := storage.New(admin)
|
||||||
if storageErr != nil {
|
if storageErr != nil {
|
||||||
return nil, nil, fmt.Errorf("initialize storage: %w", storageErr)
|
return nil, nil, fmt.Errorf("initialize storage: %w", storageErr)
|
||||||
|
|
|
||||||
|
|
@ -69,12 +69,6 @@ func (d *Data) PersistAdminConfig(ctx context.Context, raw []byte) error {
|
||||||
if next.Email == nil {
|
if next.Email == nil {
|
||||||
next.Email = currentAdmin.Email
|
next.Email = currentAdmin.Email
|
||||||
}
|
}
|
||||||
if next.Websocket == nil {
|
|
||||||
next.Websocket = currentAdmin.Websocket
|
|
||||||
}
|
|
||||||
if next.Mq == nil {
|
|
||||||
next.Mq = currentAdmin.Mq
|
|
||||||
}
|
|
||||||
next.ConfigPath = currentAdmin.ConfigPath
|
next.ConfigPath = currentAdmin.ConfigPath
|
||||||
candidateStorage, err := storage.New(next)
|
candidateStorage, err := storage.New(next)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -86,12 +80,6 @@ func (d *Data) PersistAdminConfig(ctx context.Context, raw []byte) error {
|
||||||
if err := d.persistEmailIntegrationConfig(ctx, next.Email); err != nil {
|
if err := d.persistEmailIntegrationConfig(ctx, next.Email); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := d.PersistWebSocketConfig(ctx, next.Websocket); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := d.persistMQIntegrationConfig(ctx, next.Mq); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := d.persistConfigValues(currentData, next); err != nil {
|
if err := d.persistConfigValues(currentData, next); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -120,12 +108,6 @@ func (d *Data) PersistRuntimeConfig(ctx context.Context, dataRaw, adminRaw []byt
|
||||||
if nextAdmin.Email == nil {
|
if nextAdmin.Email == nil {
|
||||||
nextAdmin.Email = currentAdmin.Email
|
nextAdmin.Email = currentAdmin.Email
|
||||||
}
|
}
|
||||||
if nextAdmin.Websocket == nil {
|
|
||||||
nextAdmin.Websocket = currentAdmin.Websocket
|
|
||||||
}
|
|
||||||
if nextAdmin.Mq == nil {
|
|
||||||
nextAdmin.Mq = currentAdmin.Mq
|
|
||||||
}
|
|
||||||
nextAdmin.ConfigPath = currentAdmin.ConfigPath
|
nextAdmin.ConfigPath = currentAdmin.ConfigPath
|
||||||
candidateStorage, err := storage.New(nextAdmin)
|
candidateStorage, err := storage.New(nextAdmin)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -137,12 +119,6 @@ func (d *Data) PersistRuntimeConfig(ctx context.Context, dataRaw, adminRaw []byt
|
||||||
if err := d.persistEmailIntegrationConfig(ctx, nextAdmin.Email); err != nil {
|
if err := d.persistEmailIntegrationConfig(ctx, nextAdmin.Email); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := d.PersistWebSocketConfig(ctx, nextAdmin.Websocket); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := d.persistMQIntegrationConfig(ctx, nextAdmin.Mq); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := d.persistConfigValues(nextData, nextAdmin); err != nil {
|
if err := d.persistConfigValues(nextData, nextAdmin); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -229,14 +205,6 @@ func (d *Data) InitializeDatabase(ctx context.Context, input *biz.DatabaseConfig
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("initialize email integration configuration: %w", err)
|
return fmt.Errorf("initialize email integration configuration: %w", err)
|
||||||
}
|
}
|
||||||
var legacyWebSocket *conf.AdminBackend_WebSocket
|
|
||||||
if currentAdmin != nil {
|
|
||||||
legacyWebSocket = currentAdmin.Websocket
|
|
||||||
}
|
|
||||||
websocketConfig, err := resolveWebSocketIntegrationConfig(candidate.WithContext(ctx), legacyWebSocket)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("initialize websocket integration configuration: %w", err)
|
|
||||||
}
|
|
||||||
signingKey := uuid.NewString()
|
signingKey := uuid.NewString()
|
||||||
if err := d.persistDatabaseConfig(config, signingKey); err != nil {
|
if err := d.persistDatabaseConfig(config, signingKey); err != nil {
|
||||||
return fmt.Errorf("persist database configuration: %w", err)
|
return fmt.Errorf("persist database configuration: %w", err)
|
||||||
|
|
@ -251,14 +219,11 @@ func (d *Data) InitializeDatabase(ctx context.Context, input *biz.DatabaseConfig
|
||||||
}
|
}
|
||||||
currentAdmin.Jwt.SigningKey = signingKey
|
currentAdmin.Jwt.SigningKey = signingKey
|
||||||
currentAdmin.Storage = storageConfig
|
currentAdmin.Storage = storageConfig
|
||||||
currentAdmin.Websocket = websocketConfig
|
|
||||||
currentAdmin.Email = emailConfig
|
currentAdmin.Email = emailConfig
|
||||||
mqConfig, err := resolveMQIntegrationConfig(candidate.WithContext(ctx), currentAdmin.Mq)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("initialize mq integration configuration: %w", err)
|
|
||||||
}
|
|
||||||
currentAdmin.Mq = mqConfig
|
|
||||||
d.runtime.Replace(currentData, currentAdmin)
|
d.runtime.Replace(currentData, currentAdmin)
|
||||||
|
if err = d.loadIntegrationRuntime(candidate); err != nil {
|
||||||
|
return fmt.Errorf("initialize integration runtime: %w", err)
|
||||||
|
}
|
||||||
activated = true
|
activated = true
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ const (
|
||||||
integrationKindStorage = "storage"
|
integrationKindStorage = "storage"
|
||||||
integrationKindEmail = "email"
|
integrationKindEmail = "email"
|
||||||
integrationKindPayment = "payment"
|
integrationKindPayment = "payment"
|
||||||
integrationKindMQ = "mq"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// integrationConfigPO stores credentials and provider-specific options for
|
// integrationConfigPO stores credentials and provider-specific options for
|
||||||
|
|
@ -35,88 +34,6 @@ type integrationConfigPO struct {
|
||||||
Config string `gorm:"type:text;not null"`
|
Config string `gorm:"type:text;not null"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func defaultMQIntegrationConfig() *conf.AdminBackend_MQ {
|
|
||||||
return &conf.AdminBackend_MQ{CleanSession: true, KeepAlive: 30, ConnectTimeout: 10}
|
|
||||||
}
|
|
||||||
|
|
||||||
func saveMQIntegrationConfig(db *gorm.DB, config *conf.AdminBackend_MQ) error {
|
|
||||||
if config == nil {
|
|
||||||
config = defaultMQIntegrationConfig()
|
|
||||||
}
|
|
||||||
raw, err := protojson.MarshalOptions{UseProtoNames: true, EmitDefaultValues: true}.Marshal(config)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("encode emqx integration configuration: %w", err)
|
|
||||||
}
|
|
||||||
enabled := config.Enabled && strings.TrimSpace(config.Broker) != ""
|
|
||||||
clean := db.Session(&gorm.Session{NewDB: true})
|
|
||||||
var row integrationConfigPO
|
|
||||||
err = clean.Where("kind = ? AND provider = ?", integrationKindMQ, "emqx").First(&row).Error
|
|
||||||
switch {
|
|
||||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
|
||||||
return clean.Create(&integrationConfigPO{Kind: integrationKindMQ, Provider: "emqx", Enabled: enabled, Config: string(raw)}).Error
|
|
||||||
case err != nil:
|
|
||||||
return err
|
|
||||||
default:
|
|
||||||
return clean.Model(&row).Updates(map[string]any{"enabled": enabled, "config": string(raw)}).Error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadMQIntegrationConfig(db *gorm.DB) (*conf.AdminBackend_MQ, bool, error) {
|
|
||||||
var row integrationConfigPO
|
|
||||||
err := db.Session(&gorm.Session{NewDB: true}).Where("kind = ? AND provider = ?", integrationKindMQ, "emqx").First(&row).Error
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, false, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, false, err
|
|
||||||
}
|
|
||||||
if !json.Valid([]byte(row.Config)) {
|
|
||||||
return nil, false, errors.New("invalid emqx integration configuration")
|
|
||||||
}
|
|
||||||
config := defaultMQIntegrationConfig()
|
|
||||||
if err = (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal([]byte(row.Config), config); err != nil {
|
|
||||||
return nil, false, fmt.Errorf("decode emqx integration configuration: %w", err)
|
|
||||||
}
|
|
||||||
config.Enabled = row.Enabled
|
|
||||||
return config, true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveMQIntegrationConfig(db *gorm.DB, legacy *conf.AdminBackend_MQ) (*conf.AdminBackend_MQ, error) {
|
|
||||||
clean := db.Session(&gorm.Session{NewDB: true})
|
|
||||||
if !clean.Migrator().HasTable(&integrationConfigPO{}) {
|
|
||||||
if legacy == nil {
|
|
||||||
return defaultMQIntegrationConfig(), nil
|
|
||||||
}
|
|
||||||
return proto.Clone(legacy).(*conf.AdminBackend_MQ), nil
|
|
||||||
}
|
|
||||||
loaded, found, err := loadMQIntegrationConfig(clean)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if found {
|
|
||||||
return loaded, nil
|
|
||||||
}
|
|
||||||
if legacy == nil {
|
|
||||||
legacy = defaultMQIntegrationConfig()
|
|
||||||
}
|
|
||||||
if err = saveMQIntegrationConfig(clean, legacy); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
loaded, _, err = loadMQIntegrationConfig(clean)
|
|
||||||
return loaded, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *Data) persistMQIntegrationConfig(ctx context.Context, config *conf.AdminBackend_MQ) error {
|
|
||||||
if !d.databaseReady.Load() {
|
|
||||||
return errors.New("database is not initialized")
|
|
||||||
}
|
|
||||||
db := d.gormDB.WithContext(ctx)
|
|
||||||
if !db.Migrator().HasTable(&integrationConfigPO{}) {
|
|
||||||
return errors.New("integration configuration table does not exist")
|
|
||||||
}
|
|
||||||
return saveMQIntegrationConfig(db, config)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (integrationConfigPO) TableName() string { return "sys_integration_configs" }
|
func (integrationConfigPO) TableName() string { return "sys_integration_configs" }
|
||||||
|
|
||||||
var storageProviderNames = []string{
|
var storageProviderNames = []string{
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
package data
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"kra/internal/integration/runtimeconfig"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func readIntegrationRuntime(db *gorm.DB) ([]runtimeconfig.Config, error) {
|
||||||
|
if db == nil || !db.Migrator().HasTable(&integrationConfigPO{}) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var rows []integrationConfigPO
|
||||||
|
if err := db.Session(&gorm.Session{NewDB: true}).
|
||||||
|
Where("kind IN ?", []string{"mq", "websocket"}).
|
||||||
|
Order("kind ASC, provider ASC").
|
||||||
|
Find(&rows).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
configs := make([]runtimeconfig.Config, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
configs = append(configs, runtimeconfig.Config{Kind: row.Kind, Provider: row.Provider, Enabled: row.Enabled, Values: []byte(row.Config)})
|
||||||
|
}
|
||||||
|
return configs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Data) loadIntegrationRuntime(db *gorm.DB) error {
|
||||||
|
configs, err := readIntegrationRuntime(db)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if d.integrations != nil {
|
||||||
|
d.integrations.Replace(configs)
|
||||||
|
}
|
||||||
|
return 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 != 7 {
|
||||||
t.Fatalf("migration versions = %d, want 4", versions)
|
t.Fatalf("migration versions = %d, want 7", 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,6 +8,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"kra/internal/biz"
|
"kra/internal/biz"
|
||||||
|
"kra/internal/integration/runtimeconfig"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
@ -26,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}
|
||||||
}
|
}
|
||||||
|
|
@ -65,7 +70,11 @@ func (r *integrationConfigRepo) SaveIntegrationConfig(ctx context.Context, confi
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
encoded, _ := json.Marshal(values)
|
encoded, _ := json.Marshal(values)
|
||||||
return db.Create(&integrationConfigPO{Kind: config.Kind, Provider: config.Provider, Enabled: config.Enabled, Config: string(encoded)}).Error
|
if err := db.Create(&integrationConfigPO{Kind: config.Kind, Provider: config.Provider, Enabled: config.Enabled, Config: string(encoded)}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.publish(config.Kind, config.Provider, config.Enabled, encoded)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -77,11 +86,34 @@ func (r *integrationConfigRepo) SaveIntegrationConfig(ctx context.Context, confi
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
encoded, _ := json.Marshal(values)
|
encoded, _ := json.Marshal(values)
|
||||||
return db.Model(&row).Updates(map[string]any{"enabled": config.Enabled, "config": string(encoded)}).Error
|
if err := db.Model(&row).Updates(map[string]any{"enabled": config.Enabled, "config": string(encoded)}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.publish(config.Kind, config.Provider, config.Enabled, encoded)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *integrationConfigRepo) DeleteIntegrationConfig(ctx context.Context, kind, provider string) error {
|
func (r *integrationConfigRepo) DeleteIntegrationConfig(ctx context.Context, kind, provider string) error {
|
||||||
return r.data.DB().WithContext(ctx).Where("kind = ? AND provider = ?", kind, provider).Delete(&integrationConfigPO{}).Error
|
if err := r.data.DB().WithContext(ctx).Where("kind = ? AND provider = ?", kind, provider).Delete(&integrationConfigPO{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if runtime := integrationRuntime(r.data); runtime != nil {
|
||||||
|
runtime.Delete(kind, provider)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *integrationConfigRepo) publish(kind, provider string, enabled bool, values []byte) {
|
||||||
|
if runtime := integrationRuntime(r.data); runtime != nil {
|
||||||
|
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 {
|
||||||
|
|
|
||||||
|
|
@ -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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
package system
|
package system
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
"kra/pkg/database/migration"
|
"kra/pkg/database/migration"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
@ -26,5 +28,147 @@ func Migrations() []migration.Step {
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{ID: "202608210002_communication_surface", Migrate: ensureCommunicationSurface},
|
||||||
|
{ID: "202608210003_communication_test_surface", Migrate: ensureCommunicationTestSurface},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ensureCommunicationSurface(db *gorm.DB) error {
|
||||||
|
if db == nil || !db.Migrator().HasTable(&menuPO{}) || !db.Migrator().HasTable(&apiPO{}) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
var parent menuPO
|
||||||
|
if err := tx.Where("name = ?", "extensions").First(&parent).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
menu := menuPO{
|
||||||
|
MenuLevel: 1,
|
||||||
|
ParentID: parent.ID,
|
||||||
|
Path: "integrationConfig",
|
||||||
|
Name: "integrationConfig",
|
||||||
|
Component: "view/systemTools/integration/config.vue",
|
||||||
|
Title: "通信集成",
|
||||||
|
Icon: "connection",
|
||||||
|
Sort: 8,
|
||||||
|
}
|
||||||
|
var current menuPO
|
||||||
|
err := tx.Where("name = ?", menu.Name).First(¤t).Error
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||||
|
if err = tx.Create(&menu).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case err != nil:
|
||||||
|
return err
|
||||||
|
default:
|
||||||
|
if err = tx.Model(¤t).Updates(map[string]any{
|
||||||
|
"menu_level": menu.MenuLevel,
|
||||||
|
"parent_id": menu.ParentID,
|
||||||
|
"path": menu.Path,
|
||||||
|
"component": menu.Component,
|
||||||
|
"title": menu.Title,
|
||||||
|
"icon": menu.Icon,
|
||||||
|
"sort": menu.Sort,
|
||||||
|
}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
menu.ID = current.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
apis := []apiPO{
|
||||||
|
{Path: "/integration/configs/:kind", Method: "GET", APIGroup: "集成配置", Description: "按类型获取集成配置"},
|
||||||
|
{Path: "/integration/configs/:kind/:provider", Method: "GET", APIGroup: "集成配置", Description: "获取指定集成配置"},
|
||||||
|
{Path: "/integration/configs/:kind/:provider", Method: "PUT", APIGroup: "集成配置", Description: "保存集成配置"},
|
||||||
|
{Path: "/integration/configs/:kind/:provider/test", Method: "POST", APIGroup: "集成配置", Description: "测试通信集成连接"},
|
||||||
|
{Path: "/integration/configs/:kind/:provider", Method: "DELETE", APIGroup: "集成配置", Description: "删除集成配置"},
|
||||||
|
}
|
||||||
|
for _, api := range apis {
|
||||||
|
if err := tx.Where("path = ? AND method = ?", api.Path, api.Method).FirstOrCreate(&api).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !tx.Migrator().HasTable(&authorityPO{}) || !tx.Migrator().HasTable(&authorityMenuPO{}) || !tx.Migrator().HasTable(&casbinRulePO{}) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var authority authorityPO
|
||||||
|
if err := tx.Where("authority_id = ?", 888).First(&authority).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var linkCount int64
|
||||||
|
if err := tx.Model(&authorityMenuPO{}).Where("sys_authority_authority_id = ? AND sys_base_menu_id = ?", authority.AuthorityID, menu.ID).Count(&linkCount).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if linkCount == 0 {
|
||||||
|
if err := tx.Create(&authorityMenuPO{SysAuthorityAuthorityID: authority.AuthorityID, SysBaseMenuID: menu.ID}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, api := range apis {
|
||||||
|
exists, err := policyExists(tx, authority.AuthorityID, api.Path, api.Method)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
rule := newPolicyRule(authority.AuthorityID, api.Path, api.Method)
|
||||||
|
if err := tx.Create(&rule).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureCommunicationTestSurface(db *gorm.DB) error {
|
||||||
|
if db == nil || !db.Migrator().HasTable(&menuPO{}) || !db.Migrator().HasTable(&apiPO{}) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
var existingMenus int64
|
||||||
|
if err := tx.Model(&menuPO{}).Where("name IN ?", []string{"extensions", "integrationConfig"}).Count(&existingMenus).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if existingMenus == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
api := apiPO{
|
||||||
|
Path: "/integration/configs/:kind/:provider/test",
|
||||||
|
Method: "POST",
|
||||||
|
APIGroup: "集成配置",
|
||||||
|
Description: "测试通信集成连接",
|
||||||
|
}
|
||||||
|
if err := tx.Where("path = ? AND method = ?", api.Path, api.Method).FirstOrCreate(&api).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !tx.Migrator().HasTable(&authorityPO{}) || !tx.Migrator().HasTable(&casbinRulePO{}) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var authority authorityPO
|
||||||
|
if err := tx.Where("authority_id = ?", 888).First(&authority).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
exists, err := policyExists(tx, authority.AuthorityID, api.Path, api.Method)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rule := newPolicyRule(authority.AuthorityID, api.Path, api.Method)
|
||||||
|
return tx.Create(&rule).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
package data
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"kra/internal/conf"
|
|
||||||
|
|
||||||
"google.golang.org/protobuf/encoding/protojson"
|
|
||||||
"google.golang.org/protobuf/proto"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
const integrationKindWebSocket = "websocket"
|
|
||||||
|
|
||||||
// saveWebSocketIntegrationConfig persists the Melody settings in the shared
|
|
||||||
// integration table. It intentionally lives separately from storage/payment
|
|
||||||
// persistence so adding another transport does not expand their API surface.
|
|
||||||
func saveWebSocketIntegrationConfig(db *gorm.DB, config *conf.AdminBackend_WebSocket) error {
|
|
||||||
if config == nil {
|
|
||||||
config = &conf.AdminBackend_WebSocket{}
|
|
||||||
}
|
|
||||||
raw, err := protojson.MarshalOptions{UseProtoNames: true, EmitDefaultValues: true}.Marshal(config)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("encode websocket integration configuration: %w", err)
|
|
||||||
}
|
|
||||||
clean := db.Session(&gorm.Session{NewDB: true})
|
|
||||||
var current integrationConfigPO
|
|
||||||
err = clean.Where("kind = ? AND provider = ?", integrationKindWebSocket, "melody").First(¤t).Error
|
|
||||||
switch {
|
|
||||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
|
||||||
return clean.Create(&integrationConfigPO{Kind: integrationKindWebSocket, Provider: "melody", Enabled: config.Enabled, Config: string(raw)}).Error
|
|
||||||
case err != nil:
|
|
||||||
return err
|
|
||||||
default:
|
|
||||||
return clean.Model(¤t).Updates(map[string]any{"enabled": config.Enabled, "config": string(raw)}).Error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadWebSocketIntegrationConfig(db *gorm.DB) (*conf.AdminBackend_WebSocket, bool, error) {
|
|
||||||
var row integrationConfigPO
|
|
||||||
err := db.Session(&gorm.Session{NewDB: true}).Where("kind = ? AND provider = ?", integrationKindWebSocket, "melody").First(&row).Error
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, false, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, false, err
|
|
||||||
}
|
|
||||||
if !json.Valid([]byte(row.Config)) {
|
|
||||||
return nil, false, errors.New("invalid websocket integration configuration")
|
|
||||||
}
|
|
||||||
config := &conf.AdminBackend_WebSocket{}
|
|
||||||
if err = (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal([]byte(row.Config), config); err != nil {
|
|
||||||
return nil, false, fmt.Errorf("decode websocket integration configuration: %w", err)
|
|
||||||
}
|
|
||||||
config.Enabled = row.Enabled
|
|
||||||
return config, true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveWebSocketIntegrationConfig(db *gorm.DB, legacy *conf.AdminBackend_WebSocket) (*conf.AdminBackend_WebSocket, error) {
|
|
||||||
clean := db.Session(&gorm.Session{NewDB: true})
|
|
||||||
if !clean.Migrator().HasTable(&integrationConfigPO{}) {
|
|
||||||
if legacy == nil {
|
|
||||||
return &conf.AdminBackend_WebSocket{}, nil
|
|
||||||
}
|
|
||||||
return proto.Clone(legacy).(*conf.AdminBackend_WebSocket), nil
|
|
||||||
}
|
|
||||||
config, found, err := loadWebSocketIntegrationConfig(clean)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if found {
|
|
||||||
return config, nil
|
|
||||||
}
|
|
||||||
if legacy == nil {
|
|
||||||
legacy = &conf.AdminBackend_WebSocket{}
|
|
||||||
}
|
|
||||||
if err = saveWebSocketIntegrationConfig(clean, legacy); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
config, _, err = loadWebSocketIntegrationConfig(clean)
|
|
||||||
return config, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *Data) PersistWebSocketConfig(ctx context.Context, config *conf.AdminBackend_WebSocket) error {
|
|
||||||
if !d.databaseReady.Load() {
|
|
||||||
return errors.New("database is not initialized")
|
|
||||||
}
|
|
||||||
db := d.gormDB.WithContext(ctx)
|
|
||||||
if !db.Migrator().HasTable(&integrationConfigPO{}) {
|
|
||||||
return errors.New("integration configuration table does not exist")
|
|
||||||
}
|
|
||||||
return saveWebSocketIntegrationConfig(db, config)
|
|
||||||
}
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
package data
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"kra/internal/conf"
|
|
||||||
|
|
||||||
"google.golang.org/protobuf/types/known/durationpb"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestWebSocketIntegrationConfigRoundTrip(t *testing.T) {
|
|
||||||
db := openIntegrationConfigTestDB(t)
|
|
||||||
want := &conf.AdminBackend_WebSocket{
|
|
||||||
Enabled: true, Path: "/events", AllowOrigins: []string{"https://admin.example.com"},
|
|
||||||
MaxMessageSize: 4096, WriteWait: durationpb.New(3 * time.Second),
|
|
||||||
PongWait: durationpb.New(20 * time.Second), PingPeriod: durationpb.New(15 * time.Second),
|
|
||||||
MessageBufferSize: 32, ConcurrentMessageHandling: true,
|
|
||||||
}
|
|
||||||
if err := saveWebSocketIntegrationConfig(db, want); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
got, found, err := loadWebSocketIntegrationConfig(db)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
t.Fatal("websocket integration configuration was not found")
|
|
||||||
}
|
|
||||||
if !got.Enabled || got.Path != want.Path || got.MaxMessageSize != want.MaxMessageSize || got.MessageBufferSize != want.MessageBufferSize {
|
|
||||||
t.Fatalf("loaded websocket config = %#v", got)
|
|
||||||
}
|
|
||||||
if len(got.AllowOrigins) != 1 || got.AllowOrigins[0] != want.AllowOrigins[0] {
|
|
||||||
t.Fatalf("allow origins = %v", got.AllowOrigins)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolveWebSocketIntegrationConfigPrefersDatabase(t *testing.T) {
|
|
||||||
db := openIntegrationConfigTestDB(t)
|
|
||||||
if err := saveWebSocketIntegrationConfig(db, &conf.AdminBackend_WebSocket{Enabled: true, Path: "/database"}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
got, err := resolveWebSocketIntegrationConfig(db, &conf.AdminBackend_WebSocket{Path: "/legacy"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got.Path != "/database" || !got.Enabled {
|
|
||||||
t.Fatalf("resolved websocket config = %#v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -89,16 +89,6 @@ func (r *Repo) ConfigurationJSON() (json.RawMessage, error) {
|
||||||
if adminConfig.App != nil {
|
if adminConfig.App != nil {
|
||||||
admin["app"] = adminConfig.App
|
admin["app"] = adminConfig.App
|
||||||
}
|
}
|
||||||
if adminConfig.Websocket != nil {
|
|
||||||
admin["websocket"] = adminConfig.Websocket
|
|
||||||
}
|
|
||||||
if adminConfig.Mq != nil {
|
|
||||||
mqConfig := proto.Clone(adminConfig.Mq).(*conf.AdminBackend_MQ)
|
|
||||||
if mqConfig.Password != "" {
|
|
||||||
mqConfig.Password = "******"
|
|
||||||
}
|
|
||||||
admin["mq"] = mqConfig
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Never mask secrets on the live runtime object. ConfigurationJSON is a
|
// Never mask secrets on the live runtime object. ConfigurationJSON is a
|
||||||
// read-only operation; mutating dataConfig here would replace the actual
|
// read-only operation; mutating dataConfig here would replace the actual
|
||||||
|
|
@ -115,9 +105,6 @@ func (r *Repo) ConfigurationJSON() (json.RawMessage, error) {
|
||||||
safeAdmin.Email.Secret = "******"
|
safeAdmin.Email.Secret = "******"
|
||||||
}
|
}
|
||||||
maskStorageSecrets(safeAdmin.Storage)
|
maskStorageSecrets(safeAdmin.Storage)
|
||||||
if safeAdmin.Mq != nil && safeAdmin.Mq.Password != "" {
|
|
||||||
safeAdmin.Mq.Password = "******"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
dataMap := map[string]any{}
|
dataMap := map[string]any{}
|
||||||
if safeData != nil {
|
if safeData != nil {
|
||||||
|
|
@ -320,9 +307,6 @@ func preserveAdminSecrets(next, current *conf.AdminBackend) {
|
||||||
next.Email.Secret = current.Email.Secret
|
next.Email.Secret = current.Email.Secret
|
||||||
}
|
}
|
||||||
preserveStorageSecrets(next.Storage, current.Storage)
|
preserveStorageSecrets(next.Storage, current.Storage)
|
||||||
if next.Mq != nil && current.Mq != nil && maskedSecret(next.Mq.Password) {
|
|
||||||
next.Mq.Password = current.Mq.Password
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func maskedSecret(value string) bool { return value == "" || value == "******" }
|
func maskedSecret(value string) bool { return value == "" || value == "******" }
|
||||||
|
|
|
||||||
|
|
@ -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`.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
package integration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"kra/internal/biz"
|
||||||
|
"kra/internal/integration/mq"
|
||||||
|
"kra/internal/integration/runtimeconfig"
|
||||||
|
websocketintegration "kra/internal/integration/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConnectivityTester probes candidate communication settings without changing
|
||||||
|
// the active clients or writing anything to sys_integration_configs.
|
||||||
|
type ConnectivityTester struct {
|
||||||
|
store *runtimeconfig.Store
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewConnectivityTester(store *runtimeconfig.Store) *ConnectivityTester {
|
||||||
|
return &ConnectivityTester{store: store}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ConnectivityTester) TestIntegration(ctx context.Context, config *biz.IntegrationConfig) error {
|
||||||
|
if config == nil {
|
||||||
|
return errors.New("集成配置请求为空")
|
||||||
|
}
|
||||||
|
values := map[string]any{}
|
||||||
|
if err := json.Unmarshal(config.Values, &values); err != nil {
|
||||||
|
return fmt.Errorf("解析集成配置失败: %w", err)
|
||||||
|
}
|
||||||
|
if err := t.restoreMaskedSecrets(config.Kind, config.Provider, values); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(values)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("编码集成配置失败: %w", err)
|
||||||
|
}
|
||||||
|
switch strings.ToLower(strings.TrimSpace(config.Kind)) {
|
||||||
|
case biz.IntegrationKindMQ:
|
||||||
|
return mq.TestConfig(ctx, config.Provider, raw)
|
||||||
|
case biz.IntegrationKindWebSocket:
|
||||||
|
if strings.ToLower(strings.TrimSpace(config.Provider)) != websocketintegration.ProviderMelody {
|
||||||
|
return fmt.Errorf("不支持的 WebSocket provider %q", config.Provider)
|
||||||
|
}
|
||||||
|
return websocketintegration.TestConfig(ctx, raw)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("不支持测试集成类型 %q", config.Kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ConnectivityTester) restoreMaskedSecrets(kind, provider string, values map[string]any) error {
|
||||||
|
definition, ok := biz.IntegrationDefinition(kind, provider)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("不支持的集成 %s/%s", kind, provider)
|
||||||
|
}
|
||||||
|
masked := make(map[string]struct{})
|
||||||
|
for _, field := range definition.Fields {
|
||||||
|
if field.Secret {
|
||||||
|
masked[field.Key] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(masked) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
currentValues := map[string]any{}
|
||||||
|
if t != nil && t.store != nil {
|
||||||
|
if current, exists := t.store.Get(kind, provider); exists {
|
||||||
|
_ = json.Unmarshal(current.Values, ¤tValues)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for key := range masked {
|
||||||
|
value, _ := values[key].(string)
|
||||||
|
if strings.TrimSpace(value) != "******" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
prior, _ := currentValues[key].(string)
|
||||||
|
if strings.TrimSpace(prior) == "" || strings.TrimSpace(prior) == "******" {
|
||||||
|
return fmt.Errorf("配置字段 %s 已脱敏,请重新填写后再测试", key)
|
||||||
|
}
|
||||||
|
values[key] = prior
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
package integration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"kra/internal/integration/runtimeconfig"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestConnectivityTesterRestoreMaskedSecrets(t *testing.T) {
|
||||||
|
store := runtimeconfig.NewStore()
|
||||||
|
store.Set(runtimeconfig.Config{
|
||||||
|
Kind: "mq",
|
||||||
|
Provider: "rabbitmq",
|
||||||
|
Enabled: true,
|
||||||
|
Values: json.RawMessage(`{"username":"stored-user","password":"stored-secret"}`),
|
||||||
|
})
|
||||||
|
tester := NewConnectivityTester(store)
|
||||||
|
|
||||||
|
t.Run("restores masked secret from active config", func(t *testing.T) {
|
||||||
|
values := map[string]any{
|
||||||
|
"username": "candidate-user",
|
||||||
|
"password": "******",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tester.restoreMaskedSecrets(" MQ ", " RabbitMQ ", values); err != nil {
|
||||||
|
t.Fatalf("restoreMaskedSecrets() error = %v", err)
|
||||||
|
}
|
||||||
|
if got := values["password"]; got != "stored-secret" {
|
||||||
|
t.Fatalf("password = %q, want stored secret", got)
|
||||||
|
}
|
||||||
|
if got := values["username"]; got != "candidate-user" {
|
||||||
|
t.Fatalf("username = %q, want candidate value", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("keeps newly entered secret", func(t *testing.T) {
|
||||||
|
values := map[string]any{"password": "new-secret"}
|
||||||
|
|
||||||
|
if err := tester.restoreMaskedSecrets("mq", "rabbitmq", values); err != nil {
|
||||||
|
t.Fatalf("restoreMaskedSecrets() error = %v", err)
|
||||||
|
}
|
||||||
|
if got := values["password"]; got != "new-secret" {
|
||||||
|
t.Fatalf("password = %q, want newly entered secret", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("does not treat non-secret fields as masked secrets", func(t *testing.T) {
|
||||||
|
values := map[string]any{
|
||||||
|
"username": "******",
|
||||||
|
"password": "new-secret",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tester.restoreMaskedSecrets("mq", "rabbitmq", values); err != nil {
|
||||||
|
t.Fatalf("restoreMaskedSecrets() error = %v", err)
|
||||||
|
}
|
||||||
|
if got := values["username"]; got != "******" {
|
||||||
|
t.Fatalf("username = %q, want unchanged masked-looking value", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConnectivityTesterRestoreMaskedSecretsRequiresStoredSecret(t *testing.T) {
|
||||||
|
tester := NewConnectivityTester(runtimeconfig.NewStore())
|
||||||
|
values := map[string]any{"password": "******"}
|
||||||
|
|
||||||
|
err := tester.restoreMaskedSecrets("mq", "rabbitmq", values)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("restoreMaskedSecrets() error = nil, want missing secret error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "password") || !strings.Contains(err.Error(), "重新填写") {
|
||||||
|
t.Fatalf("restoreMaskedSecrets() error = %q, want actionable password message", err)
|
||||||
|
}
|
||||||
|
if got := values["password"]; got != "******" {
|
||||||
|
t.Fatalf("password = %q, want masked value left unchanged after error", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,140 +2,389 @@ 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 = platformmq.ProviderEMQX
|
||||||
|
ProviderRabbitMQ = platformmq.ProviderRabbitMQ
|
||||||
|
retryTick = time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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
|
configs map[string]runtimeconfig.Config
|
||||||
stop func()
|
subscriptions map[string]map[string]map[string]subscription
|
||||||
|
bindings map[string]map[string]byte
|
||||||
|
pending map[string]bool
|
||||||
|
nextRetry map[string]time.Time
|
||||||
|
stop []func()
|
||||||
|
retryStop chan struct{}
|
||||||
|
retryDone chan struct{}
|
||||||
|
closeOnce sync.Once
|
||||||
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
|
configs: make(map[string]runtimeconfig.Config),
|
||||||
if admin := runtime.Admin(); admin != nil {
|
subscriptions: make(map[string]map[string]map[string]subscription),
|
||||||
config = admin.GetMq()
|
bindings: make(map[string]map[string]byte),
|
||||||
}
|
pending: make(map[string]bool),
|
||||||
r.replace(config)
|
nextRetry: make(map[string]time.Time),
|
||||||
r.stop = runtime.Subscribe(func(_ *conf.Data, admin *conf.AdminBackend) {
|
retryStop: make(chan struct{}),
|
||||||
if admin != nil {
|
retryDone: make(chan struct{}),
|
||||||
r.replace(admin.GetMq())
|
logger: logger,
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
if store != nil {
|
||||||
|
r.apply(ProviderEMQX, storeConfig(store, ProviderEMQX))
|
||||||
|
r.apply(ProviderRabbitMQ, storeConfig(store, ProviderRabbitMQ))
|
||||||
|
r.stop = append(r.stop,
|
||||||
|
store.Subscribe("mq", ProviderEMQX, func(config runtimeconfig.Config) { r.apply(ProviderEMQX, config) }),
|
||||||
|
store.Subscribe("mq", ProviderRabbitMQ, func(config runtimeconfig.Config) { r.apply(ProviderRabbitMQ, config) }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
go r.retryLoop()
|
||||||
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfig creates a short-lived provider client and closes it immediately.
|
||||||
|
// For RabbitMQ this also checks the configured exchange and queue topology.
|
||||||
|
func TestConfig(ctx context.Context, provider string, raw json.RawMessage) error {
|
||||||
|
if ctx != nil {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
provider = strings.ToLower(strings.TrimSpace(provider))
|
||||||
|
if provider == ProviderEMQX {
|
||||||
|
values := map[string]any{}
|
||||||
|
if err := json.Unmarshal(raw, &values); err != nil {
|
||||||
|
return fmt.Errorf("decode %s configuration: %w", provider, err)
|
||||||
|
}
|
||||||
|
baseID := configText(values, "client_id")
|
||||||
|
values["client_id"] = fmt.Sprintf("%s-test-%d", baseID, time.Now().UnixNano())
|
||||||
|
encoded, err := json.Marshal(values)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encode %s test configuration: %w", provider, err)
|
||||||
|
}
|
||||||
|
raw = encoded
|
||||||
|
}
|
||||||
|
client, err := newProviderClient(provider, raw)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if client == nil || !client.Connected() {
|
||||||
|
if client != nil {
|
||||||
|
_ = client.Close()
|
||||||
|
}
|
||||||
|
return platformmq.ErrUnavailable
|
||||||
|
}
|
||||||
|
closeErr := client.Close()
|
||||||
|
if ctx != nil {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return closeErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Reloadable) apply(provider string, config runtimeconfig.Config) {
|
||||||
|
provider = strings.ToLower(strings.TrimSpace(provider))
|
||||||
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 {
|
config.Provider = provider
|
||||||
config = &conf.AdminBackend_MQ{}
|
config.Values = append(json.RawMessage(nil), config.Values...)
|
||||||
}
|
r.configs[provider] = config
|
||||||
cfg := mq.Config{Enabled: config.Enabled, Broker: config.Broker, ClientID: config.ClientId, Username: config.Username, Password: config.Password, CleanSession: config.CleanSession}
|
if !config.Enabled {
|
||||||
if config.KeepAlive > 0 {
|
delete(r.pending, provider)
|
||||||
cfg.KeepAlive = time.Duration(config.KeepAlive) * time.Second
|
delete(r.nextRetry, provider)
|
||||||
}
|
r.replaceClientLocked(provider, nil)
|
||||||
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 {
|
if err := r.activateLocked(provider, config); err != nil {
|
||||||
if err = r.restoreSubscriptions(context.Background(), client); err != nil {
|
r.pending[provider] = true
|
||||||
_ = client.Close()
|
r.nextRetry[provider] = time.Now().Add(configRetryInterval(config.Values))
|
||||||
r.logger.Warn("restore emqx subscriptions failed", "mod", "mq", "error", err)
|
r.logger.Warn("message integration unavailable", "mod", "mq", "provider", provider, "error", err)
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Reloadable) activateLocked(provider string, config runtimeconfig.Config) error {
|
||||||
|
client, err := newProviderClient(provider, config.Values)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
bindings, err := r.restoreSubscriptionsLocked(provider, client)
|
||||||
|
if err != nil {
|
||||||
|
_ = client.Close()
|
||||||
|
return fmt.Errorf("restore message subscriptions: %w", err)
|
||||||
|
}
|
||||||
|
r.replaceClientLocked(provider, client)
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
old := r.current
|
r.bindings[provider] = bindings
|
||||||
r.current = client
|
r.mu.Unlock()
|
||||||
|
delete(r.pending, provider)
|
||||||
|
delete(r.nextRetry, provider)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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"),
|
||||||
|
ReconnectInterval: configSeconds(values, "reconnect_interval"),
|
||||||
|
})
|
||||||
|
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"),
|
||||||
|
ReconnectInterval: configSeconds(values, "reconnect_interval"),
|
||||||
|
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()
|
||||||
|
old := r.clients[provider]
|
||||||
|
if next == nil {
|
||||||
|
delete(r.clients, provider)
|
||||||
|
delete(r.bindings, 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) (map[string]byte, error) {
|
||||||
for topic, item := range r.subscriptions {
|
desired := r.desiredSubscriptions(provider)
|
||||||
if err := client.Subscribe(ctx, topic, item.qos, item.handler); err != nil {
|
bindings := make(map[string]byte, len(desired))
|
||||||
return err
|
for topic, qos := range desired {
|
||||||
|
if err := client.Subscribe(context.Background(), topic, qos, r.dispatcher(provider, topic)); err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
bindings[topic] = qos
|
||||||
|
}
|
||||||
|
return bindings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func configRetryInterval(raw json.RawMessage) time.Duration {
|
||||||
|
values := map[string]any{}
|
||||||
|
_ = json.Unmarshal(raw, &values)
|
||||||
|
interval := configSeconds(values, "reconnect_interval")
|
||||||
|
if interval <= 0 {
|
||||||
|
return 5 * time.Second
|
||||||
|
}
|
||||||
|
return interval
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Reloadable) client(provider string) platformmq.Client {
|
||||||
|
provider = strings.ToLower(strings.TrimSpace(provider))
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
return r.clients[provider]
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
defer r.opMu.Unlock()
|
||||||
|
client := r.client(provider)
|
||||||
|
if client == nil {
|
||||||
|
return platformmq.ErrUnavailable
|
||||||
|
}
|
||||||
|
if err := client.Subscribe(ctx, topic, qos, handler); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if r.subscriptions[provider] == nil {
|
||||||
|
r.subscriptions[provider] = make(map[string]subscription)
|
||||||
|
}
|
||||||
|
r.subscriptions[provider][topic] = subscription{qos: qos, handler: handler}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
defer r.opMu.Unlock()
|
||||||
|
client := r.client(provider)
|
||||||
|
if client == nil {
|
||||||
|
return platformmq.ErrUnavailable
|
||||||
|
}
|
||||||
|
if err := client.Unsubscribe(ctx, topics...); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, topic := range topics {
|
||||||
|
delete(r.subscriptions[provider], topic)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Reloadable) client() mq.Client { r.mu.RLock(); defer r.mu.RUnlock(); return r.current }
|
func (r *Reloadable) Connected() bool { return r.ConnectedTo(ProviderEMQX) }
|
||||||
func (r *Reloadable) Publish(ctx context.Context, topic string, payload []byte, qos byte, retain bool) error {
|
|
||||||
c := r.client()
|
func (r *Reloadable) ConnectedTo(provider string) bool {
|
||||||
if c == nil {
|
provider = strings.ToLower(strings.TrimSpace(provider))
|
||||||
return mq.ErrUnavailable
|
r.mu.RLock()
|
||||||
}
|
defer r.mu.RUnlock()
|
||||||
return c.Publish(ctx, topic, payload, qos, retain)
|
client := r.clients[provider]
|
||||||
|
return client != nil && client.Connected()
|
||||||
}
|
}
|
||||||
func (r *Reloadable) Subscribe(ctx context.Context, topic string, qos byte, handler mq.Handler) error {
|
|
||||||
r.opMu.Lock()
|
|
||||||
defer r.opMu.Unlock()
|
|
||||||
c := r.client()
|
|
||||||
if c == nil {
|
|
||||||
return mq.ErrUnavailable
|
|
||||||
}
|
|
||||||
if err := c.Subscribe(ctx, topic, qos, handler); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
r.subscriptions[topic] = subscription{qos: qos, handler: handler}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
func (r *Reloadable) Unsubscribe(ctx context.Context, topics ...string) error {
|
|
||||||
r.opMu.Lock()
|
|
||||||
defer r.opMu.Unlock()
|
|
||||||
c := r.client()
|
|
||||||
if c == nil {
|
|
||||||
return mq.ErrUnavailable
|
|
||||||
}
|
|
||||||
if err := c.Unsubscribe(ctx, topics...); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for _, topic := range topics {
|
|
||||||
delete(r.subscriptions, topic)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
func (r *Reloadable) Connected() bool { c := r.client(); return c != nil && c.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 +393,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" {
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,11 @@ var ProviderSet = wire.NewSet(
|
||||||
cache.New,
|
cache.New,
|
||||||
email.NewEmailRepo,
|
email.NewEmailRepo,
|
||||||
storage.NewFileStorage,
|
storage.NewFileStorage,
|
||||||
|
NewConnectivityTester,
|
||||||
|
wire.Bind(new(biz.IntegrationConnectionTester), new(*ConnectivityTester)),
|
||||||
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)),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,145 @@
|
||||||
|
// Package runtimeconfig keeps the active database-backed integration settings
|
||||||
|
// and notifies long-lived provider clients when they change.
|
||||||
|
package runtimeconfig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Kind string
|
||||||
|
Provider string
|
||||||
|
Enabled bool
|
||||||
|
Values json.RawMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
type listener struct {
|
||||||
|
kind string
|
||||||
|
provider string
|
||||||
|
callback func(Config)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Store struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
values map[string]Config
|
||||||
|
listeners map[uint64]listener
|
||||||
|
nextID uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStore() *Store {
|
||||||
|
return &Store{values: make(map[string]Config), listeners: make(map[uint64]listener)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func configKey(kind, provider string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(kind)) + "/" + strings.ToLower(strings.TrimSpace(provider))
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneConfig(config Config) Config {
|
||||||
|
config.Values = append(json.RawMessage(nil), config.Values...)
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Get(kind, provider string) (Config, bool) {
|
||||||
|
if s == nil {
|
||||||
|
return Config{}, false
|
||||||
|
}
|
||||||
|
s.mu.RLock()
|
||||||
|
config, ok := s.values[configKey(kind, provider)]
|
||||||
|
s.mu.RUnlock()
|
||||||
|
return cloneConfig(config), ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Set(config Config) {
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
config.Kind = strings.ToLower(strings.TrimSpace(config.Kind))
|
||||||
|
config.Provider = strings.ToLower(strings.TrimSpace(config.Provider))
|
||||||
|
config = cloneConfig(config)
|
||||||
|
key := configKey(config.Kind, config.Provider)
|
||||||
|
s.mu.Lock()
|
||||||
|
s.values[key] = config
|
||||||
|
callbacks := s.matchingListenersLocked(config.Kind, config.Provider)
|
||||||
|
s.mu.Unlock()
|
||||||
|
for _, callback := range callbacks {
|
||||||
|
callback(cloneConfig(config))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Delete(kind, provider string) {
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
kind = strings.ToLower(strings.TrimSpace(kind))
|
||||||
|
provider = strings.ToLower(strings.TrimSpace(provider))
|
||||||
|
s.mu.Lock()
|
||||||
|
delete(s.values, configKey(kind, provider))
|
||||||
|
callbacks := s.matchingListenersLocked(kind, provider)
|
||||||
|
s.mu.Unlock()
|
||||||
|
config := Config{Kind: kind, Provider: provider}
|
||||||
|
for _, callback := range callbacks {
|
||||||
|
callback(config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Replace(configs []Config) {
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next := make(map[string]Config, len(configs))
|
||||||
|
for _, config := range configs {
|
||||||
|
config.Kind = strings.ToLower(strings.TrimSpace(config.Kind))
|
||||||
|
config.Provider = strings.ToLower(strings.TrimSpace(config.Provider))
|
||||||
|
config = cloneConfig(config)
|
||||||
|
next[configKey(config.Kind, config.Provider)] = config
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
previous := s.values
|
||||||
|
s.values = next
|
||||||
|
listeners := make([]listener, 0, len(s.listeners))
|
||||||
|
for _, item := range s.listeners {
|
||||||
|
listeners = append(listeners, item)
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
changed := make(map[string]Config, len(previous)+len(next))
|
||||||
|
for key, config := range previous {
|
||||||
|
changed[key] = Config{Kind: config.Kind, Provider: config.Provider}
|
||||||
|
}
|
||||||
|
for key, config := range next {
|
||||||
|
changed[key] = config
|
||||||
|
}
|
||||||
|
for _, item := range listeners {
|
||||||
|
if config, ok := changed[configKey(item.kind, item.provider)]; ok {
|
||||||
|
item.callback(cloneConfig(config))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Subscribe(kind, provider string, callback func(Config)) func() {
|
||||||
|
if s == nil || callback == nil {
|
||||||
|
return func() {}
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
s.nextID++
|
||||||
|
id := s.nextID
|
||||||
|
s.listeners[id] = listener{kind: strings.ToLower(strings.TrimSpace(kind)), provider: strings.ToLower(strings.TrimSpace(provider)), callback: callback}
|
||||||
|
s.mu.Unlock()
|
||||||
|
return func() {
|
||||||
|
s.mu.Lock()
|
||||||
|
delete(s.listeners, id)
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) matchingListenersLocked(kind, provider string) []func(Config) {
|
||||||
|
callbacks := make([]func(Config), 0)
|
||||||
|
for _, item := range s.listeners {
|
||||||
|
if item.kind == kind && item.provider == provider {
|
||||||
|
callbacks = append(callbacks, item.callback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return callbacks
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
package runtimeconfig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStoreSetDeleteAndSubscribe(t *testing.T) {
|
||||||
|
store := NewStore()
|
||||||
|
updates := make(chan Config, 2)
|
||||||
|
stop := store.Subscribe("mq", "rabbitmq", func(config Config) { updates <- config })
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
store.Set(Config{Kind: "MQ", Provider: "RabbitMQ", Enabled: true, Values: json.RawMessage(`{"host":"localhost"}`)})
|
||||||
|
loaded, ok := store.Get("mq", "rabbitmq")
|
||||||
|
if !ok || !loaded.Enabled || string(loaded.Values) != `{"host":"localhost"}` {
|
||||||
|
t.Fatalf("loaded config = %#v, ok=%v", loaded, ok)
|
||||||
|
}
|
||||||
|
if update := <-updates; !update.Enabled {
|
||||||
|
t.Fatalf("set update = %#v", update)
|
||||||
|
}
|
||||||
|
|
||||||
|
store.Delete("mq", "rabbitmq")
|
||||||
|
if _, ok = store.Get("mq", "rabbitmq"); ok {
|
||||||
|
t.Fatal("deleted config remained in store")
|
||||||
|
}
|
||||||
|
if update := <-updates; update.Enabled {
|
||||||
|
t.Fatalf("delete update = %#v", update)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,19 +1,26 @@
|
||||||
package websocket
|
package websocket
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
gorillawebsocket "github.com/gorilla/websocket"
|
||||||
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 +29,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 +55,85 @@ 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
|
}
|
||||||
|
|
||||||
|
// TestConfig performs a local WebSocket handshake using a temporary server
|
||||||
|
// built from the candidate settings. It does not touch the live endpoint.
|
||||||
|
func TestConfig(ctx context.Context, raw json.RawMessage) error {
|
||||||
|
values := map[string]any{}
|
||||||
|
if err := json.Unmarshal(raw, &values); err != nil {
|
||||||
|
return fmt.Errorf("decode websocket configuration: %w", err)
|
||||||
}
|
}
|
||||||
if config == nil {
|
path := text(values, "path")
|
||||||
config = &conf.AdminBackend_WebSocket{}
|
if path == "" {
|
||||||
|
path = "/ws"
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(path, "/") {
|
||||||
|
path = "/" + path
|
||||||
|
}
|
||||||
|
temporary := platformws.New(platformws.Config{
|
||||||
|
WriteWait: durationValue(values, "write_wait", 10*time.Second),
|
||||||
|
PongWait: durationValue(values, "pong_wait", 60*time.Second),
|
||||||
|
PingPeriod: durationValue(values, "ping_period", 54*time.Second),
|
||||||
|
MaxMessageSize: int64Value(values, "max_message_size"),
|
||||||
|
MessageBufferSize: int(intValue(values, "message_buffer_size")),
|
||||||
|
ConcurrentMessageHandling: boolValue(values, "concurrent_message_handling"),
|
||||||
|
AllowOrigins: stringList(values, "allow_origins"),
|
||||||
|
})
|
||||||
|
defer temporary.Close()
|
||||||
|
httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != path {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := temporary.HandleRequest(w, r); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer httpServer.Close()
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
headers := http.Header{}
|
||||||
|
origins := stringList(values, "allow_origins")
|
||||||
|
if len(origins) > 0 {
|
||||||
|
origin := origins[0]
|
||||||
|
if origin == "*" {
|
||||||
|
origin = "http://localhost"
|
||||||
|
}
|
||||||
|
headers.Set("Origin", origin)
|
||||||
|
}
|
||||||
|
wsURL := "ws" + strings.TrimPrefix(httpServer.URL, "http") + path
|
||||||
|
connection, response, err := gorillawebsocket.DefaultDialer.DialContext(ctx, wsURL, headers)
|
||||||
|
if response != nil && response.Body != nil {
|
||||||
|
_ = response.Body.Close()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("websocket handshake failed: %w", err)
|
||||||
|
}
|
||||||
|
if connection == nil {
|
||||||
|
return errors.New("websocket handshake returned an empty connection")
|
||||||
|
}
|
||||||
|
return connection.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) apply(config runtimeconfig.Config) {
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
values := map[string]any{}
|
||||||
|
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 +150,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 +178,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 +256,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 +270,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 +341,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 +353,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 +365,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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
package websocket
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestConfigCompletesLocalTemporaryHandshake(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
values map[string]any
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "default origin policy",
|
||||||
|
values: map[string]any{
|
||||||
|
"path": "/connection-test",
|
||||||
|
"write_wait": "1s",
|
||||||
|
"pong_wait": "2s",
|
||||||
|
"ping_period": "1s",
|
||||||
|
"max_message_size": 1024,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "configured origin",
|
||||||
|
values: map[string]any{
|
||||||
|
"path": "/origin-test",
|
||||||
|
"allow_origins": []string{"https://admin.example.test"},
|
||||||
|
"write_wait": "1s",
|
||||||
|
"pong_wait": "2s",
|
||||||
|
"ping_period": "1s",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
raw, err := json.Marshal(tt.values)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("json.Marshal() error = %v", err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err = TestConfig(ctx, raw); err != nil {
|
||||||
|
t.Fatalf("TestConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigHonorsCanceledContextWithoutExternalAccess(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
err := TestConfig(ctx, json.RawMessage(`{"path":"/connection-test"}`))
|
||||||
|
if err != context.Canceled {
|
||||||
|
t.Fatalf("TestConfig() error = %v, want context.Canceled", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ func TestGinRouteContract(t *testing.T) {
|
||||||
"GET /integration/configs/:kind",
|
"GET /integration/configs/:kind",
|
||||||
"GET /integration/configs/:kind/:provider",
|
"GET /integration/configs/:kind/:provider",
|
||||||
"PUT /integration/configs/:kind/:provider",
|
"PUT /integration/configs/:kind/:provider",
|
||||||
|
"POST /integration/configs/:kind/:provider/test",
|
||||||
"DELETE /integration/configs/:kind/:provider",
|
"DELETE /integration/configs/:kind/:provider",
|
||||||
"POST /payment/providers/:provider/test",
|
"POST /payment/providers/:provider/test",
|
||||||
} {
|
} {
|
||||||
|
|
@ -71,7 +72,7 @@ func TestGinStartupLogsEveryRegisteredRoute(t *testing.T) {
|
||||||
if got, want := strings.Count(text, `"msg":"router registered"`), len(engine.Routes()); got != want {
|
if got, want := strings.Count(text, `"msg":"router registered"`), len(engine.Routes()); got != want {
|
||||||
t.Fatalf("registered route log count = %d, want %d", got, want)
|
t.Fatalf("registered route log count = %d, want %d", got, want)
|
||||||
}
|
}
|
||||||
if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":193`) {
|
if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":194`) {
|
||||||
t.Fatalf("startup route summary is missing: %s", text)
|
t.Fatalf("startup route summary is missing: %s", text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -353,6 +354,7 @@ POST /fileUploadAndDownload/upload
|
||||||
POST /info/createInfo
|
POST /info/createInfo
|
||||||
POST /init/checkdb
|
POST /init/checkdb
|
||||||
POST /init/initdb
|
POST /init/initdb
|
||||||
|
POST /integration/configs/:kind/:provider/test
|
||||||
POST /jwt/jsonInBlacklist
|
POST /jwt/jsonInBlacklist
|
||||||
POST /mediaUpload/chunk
|
POST /mediaUpload/chunk
|
||||||
POST /mediaUpload/complete
|
POST /mediaUpload/complete
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,19 @@ func (h *IntegrationConfig) Save(c *gin.Context) {
|
||||||
OK(c)
|
OK(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *IntegrationConfig) Test(c *gin.Context) {
|
||||||
|
var req dto.IntegrationConfigRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
Fail(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.service.Test(c.Request.Context(), c.Param("kind"), c.Param("provider"), &req); err != nil {
|
||||||
|
Fail(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
OK(c)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *IntegrationConfig) Delete(c *gin.Context) {
|
func (h *IntegrationConfig) Delete(c *gin.Context) {
|
||||||
if err := h.service.Delete(c.Request.Context(), c.Param("kind"), c.Param("provider")); err != nil {
|
if err := h.service.Delete(c.Request.Context(), c.Param("kind"), c.Param("provider")); err != nil {
|
||||||
Fail(c, err.Error())
|
Fail(c, err.Error())
|
||||||
|
|
|
||||||
|
|
@ -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", "POST /integration/configs/:kind/:provider/test", "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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,5 +7,6 @@ func RegisterIntegrationConfig(group *gin.RouterGroup, handler *IntegrationConfi
|
||||||
configs.GET("/:kind", handler.List)
|
configs.GET("/:kind", handler.List)
|
||||||
configs.GET("/:kind/:provider", handler.Find)
|
configs.GET("/:kind/:provider", handler.Find)
|
||||||
configs.PUT("/:kind/:provider", handler.Save)
|
configs.PUT("/:kind/:provider", handler.Save)
|
||||||
|
configs.POST("/:kind/:provider/test", handler.Test)
|
||||||
configs.DELETE("/:kind/:provider", handler.Delete)
|
configs.DELETE("/:kind/:provider", handler.Delete)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,13 @@ func (s *IntegrationConfigService) Save(ctx context.Context, kind, provider stri
|
||||||
return s.uc.Save(ctx, &biz.IntegrationConfig{Kind: kind, Provider: provider, Enabled: req.Enabled, Values: req.Config})
|
return s.uc.Save(ctx, &biz.IntegrationConfig{Kind: kind, Provider: provider, Enabled: req.Enabled, Values: req.Config})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *IntegrationConfigService) Test(ctx context.Context, kind, provider string, req *dto.IntegrationConfigRequest) error {
|
||||||
|
if req == nil {
|
||||||
|
return s.uc.Test(ctx, nil)
|
||||||
|
}
|
||||||
|
return s.uc.Test(ctx, &biz.IntegrationConfig{Kind: kind, Provider: provider, Enabled: true, Values: req.Config})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *IntegrationConfigService) Delete(ctx context.Context, kind, provider string) error {
|
func (s *IntegrationConfigService) Delete(ctx context.Context, kind, provider string) error {
|
||||||
return s.uc.Delete(ctx, kind, provider)
|
return s.uc.Delete(ctx, kind, provider)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"},
|
||||||
|
|
@ -129,6 +136,7 @@ var apiMetadata = map[string]apiMetadataValue{
|
||||||
"POST /menu/getMenuList": {group: "菜单", description: "分页获取基础menu列表"},
|
"POST /menu/getMenuList": {group: "菜单", description: "分页获取基础menu列表"},
|
||||||
"POST /menu/setMenuRoles": {group: "菜单", description: "全量覆盖菜单关联角色列表"},
|
"POST /menu/setMenuRoles": {group: "菜单", description: "全量覆盖菜单关联角色列表"},
|
||||||
"POST /menu/updateBaseMenu": {group: "菜单", description: "更新菜单"},
|
"POST /menu/updateBaseMenu": {group: "菜单", description: "更新菜单"},
|
||||||
|
"POST /integration/configs/:kind/:provider/test": {group: "集成配置", description: "测试通信集成连接"},
|
||||||
"PUT /integration/configs/:kind/:provider": {group: "集成配置", description: "保存集成配置"},
|
"PUT /integration/configs/:kind/:provider": {group: "集成配置", description: "保存集成配置"},
|
||||||
"DELETE /integration/configs/:kind/:provider": {group: "集成配置", description: "删除集成配置"},
|
"DELETE /integration/configs/:kind/:provider": {group: "集成配置", description: "删除集成配置"},
|
||||||
"POST /payment/order": {group: "支付", description: "查询支付订单"},
|
"POST /payment/order": {group: "支付", description: "查询支付订单"},
|
||||||
|
|
|
||||||
47
pkg/mq/mq.go
47
pkg/mq/mq.go
|
|
@ -10,6 +10,11 @@ import (
|
||||||
|
|
||||||
var ErrUnavailable = errors.New("message broker unavailable")
|
var ErrUnavailable = errors.New("message broker unavailable")
|
||||||
|
|
||||||
|
const (
|
||||||
|
ProviderEMQX = "emqx"
|
||||||
|
ProviderRabbitMQ = "rabbitmq"
|
||||||
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
AtMostOnce byte = 0
|
AtMostOnce byte = 0
|
||||||
AtLeastOnce byte = 1
|
AtLeastOnce byte = 1
|
||||||
|
|
@ -27,6 +32,37 @@ func (m Message) DecodeJSON(target any) error { return json.Unmarshal(m.Payload,
|
||||||
|
|
||||||
type Handler func(context.Context, Message)
|
type Handler func(context.Context, Message)
|
||||||
|
|
||||||
|
// TopicSubscription is one logical module subscription. The handler is kept
|
||||||
|
// by the runtime and is replayed after a client reconnects or is rebuilt.
|
||||||
|
type TopicSubscription struct {
|
||||||
|
Topic string
|
||||||
|
QoS byte
|
||||||
|
Handler Handler
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubscriptionSet is the complete subscription declaration for one module and
|
||||||
|
// provider. Registering the same owner/provider replaces its previous set.
|
||||||
|
// Owner must be a stable module name, not a request or goroutine identifier.
|
||||||
|
type SubscriptionSet struct {
|
||||||
|
Owner string
|
||||||
|
Provider string
|
||||||
|
Topics []TopicSubscription
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubscriptionRegistrar is the module-facing seam for durable-in-process
|
||||||
|
// subscription intent. Register does not require a live broker; the runtime
|
||||||
|
// will bind the declared topics when the provider becomes available.
|
||||||
|
type SubscriptionRegistrar interface {
|
||||||
|
Register(SubscriptionSet) error
|
||||||
|
Unregister(owner string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubscriptionContributor lets a module expose its broker subscriptions
|
||||||
|
// without depending on the concrete integration implementation.
|
||||||
|
type SubscriptionContributor interface {
|
||||||
|
RegisterSubscriptions(SubscriptionRegistrar) error
|
||||||
|
}
|
||||||
|
|
||||||
type Client interface {
|
type Client interface {
|
||||||
Publish(context.Context, string, []byte, byte, bool) error
|
Publish(context.Context, string, []byte, byte, bool) error
|
||||||
Subscribe(context.Context, string, byte, Handler) error
|
Subscribe(context.Context, string, byte, Handler) error
|
||||||
|
|
@ -35,6 +71,17 @@ type Client interface {
|
||||||
Close() error
|
Close() error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Registry exposes named broker clients while preserving Client as the
|
||||||
|
// default EMQX/MQTT boundary for existing modules.
|
||||||
|
type Registry interface {
|
||||||
|
SubscriptionRegistrar
|
||||||
|
Client(provider string) Client
|
||||||
|
PublishTo(context.Context, string, string, []byte, byte, bool) error
|
||||||
|
SubscribeTo(context.Context, string, string, byte, Handler) error
|
||||||
|
UnsubscribeFrom(context.Context, string, ...string) error
|
||||||
|
ConnectedTo(provider string) bool
|
||||||
|
}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Enabled bool
|
Enabled bool
|
||||||
Broker string
|
Broker string
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
package mq
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NormalizeSubscriptionSet validates and canonicalizes a module declaration.
|
||||||
|
func NormalizeSubscriptionSet(set SubscriptionSet) (SubscriptionSet, error) {
|
||||||
|
set.Owner = strings.TrimSpace(set.Owner)
|
||||||
|
set.Provider = strings.ToLower(strings.TrimSpace(set.Provider))
|
||||||
|
if set.Owner == "" {
|
||||||
|
return SubscriptionSet{}, fmt.Errorf("mq subscription owner is empty")
|
||||||
|
}
|
||||||
|
if set.Provider != ProviderEMQX && set.Provider != ProviderRabbitMQ {
|
||||||
|
return SubscriptionSet{}, fmt.Errorf("unsupported mq provider %q", set.Provider)
|
||||||
|
}
|
||||||
|
if len(set.Topics) == 0 {
|
||||||
|
return SubscriptionSet{}, fmt.Errorf("mq subscription topics are empty")
|
||||||
|
}
|
||||||
|
seen := make(map[string]struct{}, len(set.Topics))
|
||||||
|
for index := range set.Topics {
|
||||||
|
set.Topics[index].Topic = strings.TrimSpace(set.Topics[index].Topic)
|
||||||
|
if set.Topics[index].Topic == "" {
|
||||||
|
return SubscriptionSet{}, fmt.Errorf("mq subscription topic at index %d is empty", index)
|
||||||
|
}
|
||||||
|
if set.Topics[index].QoS > ExactlyOnce {
|
||||||
|
return SubscriptionSet{}, fmt.Errorf("invalid mq qos %d for topic %q", set.Topics[index].QoS, set.Topics[index].Topic)
|
||||||
|
}
|
||||||
|
if set.Topics[index].Handler == nil {
|
||||||
|
return SubscriptionSet{}, fmt.Errorf("mq subscription handler is nil for topic %q", set.Topics[index].Topic)
|
||||||
|
}
|
||||||
|
if _, exists := seen[set.Topics[index].Topic]; exists {
|
||||||
|
return SubscriptionSet{}, fmt.Errorf("duplicate mq subscription topic %q", set.Topics[index].Topic)
|
||||||
|
}
|
||||||
|
seen[set.Topics[index].Topic] = struct{}{}
|
||||||
|
}
|
||||||
|
return set, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplySubscriptions activates dependency-bearing module contributors.
|
||||||
|
func ApplySubscriptions(registrar SubscriptionRegistrar, contributors ...SubscriptionContributor) error {
|
||||||
|
if registrar == nil {
|
||||||
|
return fmt.Errorf("mq subscription registrar is nil")
|
||||||
|
}
|
||||||
|
for _, contributor := range contributors {
|
||||||
|
if contributor == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := contributor.RegisterSubscriptions(registrar); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,12 @@ export const saveIntegrationConfig = (kind, provider, data) => service({
|
||||||
data
|
data
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const testIntegrationConfig = (kind, provider, data) => service({
|
||||||
|
url: `/integration/configs/${encodeURIComponent(kind)}/${encodeURIComponent(provider)}/test`,
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
|
||||||
export const deleteIntegrationConfig = (kind, provider) => service({
|
export const deleteIntegrationConfig = (kind, provider) => service({
|
||||||
url: `/integration/configs/${encodeURIComponent(kind)}/${encodeURIComponent(provider)}`,
|
url: `/integration/configs/${encodeURIComponent(kind)}/${encodeURIComponent(provider)}`,
|
||||||
method: 'delete'
|
method: 'delete'
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,10 @@
|
||||||
"/src/view/system/security/forceChangePassword.vue": "ForceChangePassword",
|
"/src/view/system/security/forceChangePassword.vue": "ForceChangePassword",
|
||||||
"/src/view/system/security/index.vue": "SecurityConfig",
|
"/src/view/system/security/index.vue": "SecurityConfig",
|
||||||
"/src/view/system/state.vue": "State",
|
"/src/view/system/state.vue": "State",
|
||||||
|
"/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",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,799 @@
|
||||||
|
<template>
|
||||||
|
<div class="kra-table-box integration-page">
|
||||||
|
<header class="page-heading">
|
||||||
|
<div>
|
||||||
|
<h2>通信集成</h2>
|
||||||
|
<p>消息队列与实时连接</p>
|
||||||
|
</div>
|
||||||
|
<el-button :icon="Refresh" :loading="loading" @click="load">
|
||||||
|
刷新
|
||||||
|
</el-button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div v-loading="loading" class="integration-layout">
|
||||||
|
<aside class="provider-panel" aria-label="通信集成列表">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<span>服务</span>
|
||||||
|
<span>{{ integrations.length }}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-for="item in integrations"
|
||||||
|
:key="integrationKey(item)"
|
||||||
|
type="button"
|
||||||
|
class="provider-item"
|
||||||
|
:class="{ active: integrationKey(item) === selectedKey }"
|
||||||
|
@click="selectedKey = integrationKey(item)"
|
||||||
|
>
|
||||||
|
<span class="provider-icon" aria-hidden="true">
|
||||||
|
<el-icon><component :is="providerMeta(item).icon" /></el-icon>
|
||||||
|
</span>
|
||||||
|
<span class="provider-copy">
|
||||||
|
<strong>{{ item.name || providerMeta(item).name }}</strong>
|
||||||
|
<small>{{ providerMeta(item).protocol }}</small>
|
||||||
|
</span>
|
||||||
|
<span class="provider-state" :class="{ enabled: item.enabled }">
|
||||||
|
{{ item.enabled ? '已启用' : '已停用' }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section v-if="selected" class="editor-panel">
|
||||||
|
<header class="editor-heading">
|
||||||
|
<div class="editor-title-group">
|
||||||
|
<div class="editor-title-row">
|
||||||
|
<h3>{{ selected.name || providerMeta(selected).name }}</h3>
|
||||||
|
<el-tag v-if="isDirty(selected)" type="warning" effect="plain">
|
||||||
|
未保存
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
<p>{{ selected.description || providerMeta(selected).description }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="enable-control">
|
||||||
|
<span>{{ selected.enabled ? '已启用' : '已停用' }}</span>
|
||||||
|
<el-switch
|
||||||
|
:model-value="selected.enabled"
|
||||||
|
:loading="isToggling(selected)"
|
||||||
|
:disabled="isBusy(selected)"
|
||||||
|
aria-label="启用服务"
|
||||||
|
@change="(value) => toggleIntegration(selected, value)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<el-alert
|
||||||
|
v-if="hasMaskedSecret(selected)"
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
class="secret-alert"
|
||||||
|
title="密码已脱敏;保留 ****** 将继续使用当前密码。"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-form
|
||||||
|
:model="selected.config"
|
||||||
|
label-position="top"
|
||||||
|
class="config-form"
|
||||||
|
@submit.prevent
|
||||||
|
>
|
||||||
|
<div class="field-grid">
|
||||||
|
<el-form-item
|
||||||
|
v-for="field in selected.fields || []"
|
||||||
|
:key="field.key"
|
||||||
|
:required="field.required"
|
||||||
|
:error="fieldError(selected, 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"
|
||||||
|
:placeholder="field.placeholder || '请选择'"
|
||||||
|
@update:model-value="clearFieldError(selected, field.key)"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="option in field.options || []"
|
||||||
|
:key="String(option.value)"
|
||||||
|
:label="option.label"
|
||||||
|
:value="option.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
|
||||||
|
<el-switch
|
||||||
|
v-else-if="field.type === 'switch'"
|
||||||
|
v-model="selected.config[field.key]"
|
||||||
|
@update:model-value="clearFieldError(selected, field.key)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-input-number
|
||||||
|
v-else-if="field.type === 'number'"
|
||||||
|
v-model="selected.config[field.key]"
|
||||||
|
class="field-control"
|
||||||
|
:min="numberConstraint(field.key).min"
|
||||||
|
:max="numberConstraint(field.key).max"
|
||||||
|
:step="1"
|
||||||
|
controls-position="right"
|
||||||
|
@update:model-value="clearFieldError(selected, field.key)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-input
|
||||||
|
v-else-if="field.type === 'string-list'"
|
||||||
|
:model-value="stringListDraft(selected, field.key)"
|
||||||
|
class="field-control"
|
||||||
|
type="textarea"
|
||||||
|
:rows="4"
|
||||||
|
:placeholder="field.placeholder || '每行一个值'"
|
||||||
|
spellcheck="false"
|
||||||
|
@input="(value) => updateStringList(selected, field.key, value)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-input
|
||||||
|
v-else-if="field.type === 'textarea'"
|
||||||
|
v-model="selected.config[field.key]"
|
||||||
|
class="field-control"
|
||||||
|
type="textarea"
|
||||||
|
:rows="4"
|
||||||
|
:placeholder="field.placeholder"
|
||||||
|
:show-password="field.secret"
|
||||||
|
spellcheck="false"
|
||||||
|
@update:model-value="clearFieldError(selected, 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(selected, field.key)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p v-if="field.description" class="field-description">
|
||||||
|
{{ field.description }}
|
||||||
|
</p>
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<footer class="editor-actions">
|
||||||
|
<span class="save-state">
|
||||||
|
{{ selected.configured ? '配置已创建' : '尚未保存配置' }}
|
||||||
|
</span>
|
||||||
|
<el-button
|
||||||
|
:icon="Connection"
|
||||||
|
:loading="isTesting(selected)"
|
||||||
|
:disabled="isBusy(selected)"
|
||||||
|
@click="testSelected"
|
||||||
|
>
|
||||||
|
测试连接
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
:icon="Check"
|
||||||
|
:loading="isSaving(selected)"
|
||||||
|
:disabled="isBusy(selected) || !isDirty(selected)"
|
||||||
|
@click="saveSelected"
|
||||||
|
>
|
||||||
|
保存配置
|
||||||
|
</el-button>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<el-empty v-else description="暂无通信集成配置" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import {
|
||||||
|
ChatLineRound,
|
||||||
|
Check,
|
||||||
|
Connection,
|
||||||
|
Promotion,
|
||||||
|
Refresh
|
||||||
|
} from '@element-plus/icons-vue'
|
||||||
|
import {
|
||||||
|
getIntegrationConfigs,
|
||||||
|
saveIntegrationConfig,
|
||||||
|
testIntegrationConfig
|
||||||
|
} from '@/api/integration'
|
||||||
|
|
||||||
|
defineOptions({ name: 'IntegrationConfig' })
|
||||||
|
|
||||||
|
const TARGETS = {
|
||||||
|
'mq/emqx': {
|
||||||
|
name: 'EMQX',
|
||||||
|
protocol: 'MQTT',
|
||||||
|
description: 'EMQX MQTT 消息服务',
|
||||||
|
icon: Connection
|
||||||
|
},
|
||||||
|
'mq/rabbitmq': {
|
||||||
|
name: 'RabbitMQ',
|
||||||
|
protocol: 'AMQP',
|
||||||
|
description: 'RabbitMQ 消息队列',
|
||||||
|
icon: Promotion
|
||||||
|
},
|
||||||
|
'websocket/melody': {
|
||||||
|
name: 'WebSocket',
|
||||||
|
protocol: 'WS',
|
||||||
|
description: 'WebSocket 实时连接服务',
|
||||||
|
icon: ChatLineRound
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const TARGET_ORDER = Object.keys(TARGETS)
|
||||||
|
const NUMBER_CONSTRAINTS = {
|
||||||
|
port: { min: 1, max: 65535 },
|
||||||
|
keep_alive: { min: 1 },
|
||||||
|
connect_timeout: { min: 1 },
|
||||||
|
prefetch_count: { min: 0 },
|
||||||
|
heartbeat: { min: 0 },
|
||||||
|
max_message_size: { min: 0 },
|
||||||
|
message_buffer_size: { min: 0 }
|
||||||
|
}
|
||||||
|
const DURATION_FIELDS = new Set(['write_wait', 'pong_wait', 'ping_period'])
|
||||||
|
const DURATION_PATTERN = /^(?:\d+(?:\.\d+)?(?:ns|us|µs|ms|s|m|h))+$/i
|
||||||
|
|
||||||
|
const integrations = ref([])
|
||||||
|
const selectedKey = ref(TARGET_ORDER[0])
|
||||||
|
const loading = ref(false)
|
||||||
|
const pending = reactive({})
|
||||||
|
const errors = reactive({})
|
||||||
|
const listDrafts = reactive({})
|
||||||
|
|
||||||
|
const selected = computed(
|
||||||
|
() =>
|
||||||
|
integrations.value.find(
|
||||||
|
(item) => integrationKey(item) === selectedKey.value
|
||||||
|
) || integrations.value[0]
|
||||||
|
)
|
||||||
|
|
||||||
|
const integrationKey = (item) => `${item.kind}/${item.provider}`
|
||||||
|
const providerMeta = (item) => TARGETS[integrationKey(item)] || TARGETS[TARGET_ORDER[0]]
|
||||||
|
const operationKey = (item) => integrationKey(item)
|
||||||
|
const errorKey = (item, fieldKey) => `${integrationKey(item)}:${fieldKey}`
|
||||||
|
const listKey = (item, fieldKey) => `${integrationKey(item)}:${fieldKey}`
|
||||||
|
const cloneConfig = (value) => JSON.parse(JSON.stringify(value || {}))
|
||||||
|
|
||||||
|
const normalizeIntegration = (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)
|
||||||
|
for (const field of normalized.fields) {
|
||||||
|
if (field.type === 'string-list') {
|
||||||
|
const value = normalized.config[field.key]
|
||||||
|
listDrafts[listKey(normalized, field.key)] = Array.isArray(value)
|
||||||
|
? value.join('\n')
|
||||||
|
: ''
|
||||||
|
normalized.config[field.key] = Array.isArray(value) ? value : []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
if (loading.value) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const [mq, websocket] = await Promise.all([
|
||||||
|
getIntegrationConfigs('mq'),
|
||||||
|
getIntegrationConfigs('websocket')
|
||||||
|
])
|
||||||
|
if (mq.code !== 0 || websocket.code !== 0) return
|
||||||
|
|
||||||
|
Object.keys(listDrafts).forEach((key) => delete listDrafts[key])
|
||||||
|
Object.keys(errors).forEach((key) => delete errors[key])
|
||||||
|
const loaded = [...(mq.data || []), ...(websocket.data || [])]
|
||||||
|
.filter((item) => TARGETS[integrationKey(item)])
|
||||||
|
.map(normalizeIntegration)
|
||||||
|
.sort(
|
||||||
|
(left, right) =>
|
||||||
|
TARGET_ORDER.indexOf(integrationKey(left)) -
|
||||||
|
TARGET_ORDER.indexOf(integrationKey(right))
|
||||||
|
)
|
||||||
|
integrations.value = loaded
|
||||||
|
if (!loaded.some((item) => integrationKey(item) === selectedKey.value)) {
|
||||||
|
selectedKey.value = integrationKey(loaded[0] || { kind: '', provider: '' })
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// The request layer already presents transport errors.
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isBusy = (item) => Boolean(pending[operationKey(item)])
|
||||||
|
const isSaving = (item) => pending[operationKey(item)] === 'save'
|
||||||
|
const isToggling = (item) => pending[operationKey(item)] === 'toggle'
|
||||||
|
const isTesting = (item) => pending[operationKey(item)] === 'test'
|
||||||
|
const numberConstraint = (fieldKey) =>
|
||||||
|
NUMBER_CONSTRAINTS[fieldKey] || { min: undefined, max: undefined }
|
||||||
|
|
||||||
|
const fieldError = (item, fieldKey) => errors[errorKey(item, fieldKey)] || ''
|
||||||
|
const clearFieldError = (item, fieldKey) => {
|
||||||
|
delete errors[errorKey(item, fieldKey)]
|
||||||
|
}
|
||||||
|
|
||||||
|
const stringListDraft = (item, fieldKey) => listDrafts[listKey(item, fieldKey)] || ''
|
||||||
|
const updateStringList = (item, fieldKey, value) => {
|
||||||
|
listDrafts[listKey(item, fieldKey)] = value
|
||||||
|
item.config[fieldKey] = String(value)
|
||||||
|
.split('\n')
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
clearFieldError(item, fieldKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMissing = (value) => {
|
||||||
|
if (Array.isArray(value)) return value.length === 0
|
||||||
|
if (typeof value === 'string') return value.trim() === ''
|
||||||
|
return value === null || typeof value === 'undefined'
|
||||||
|
}
|
||||||
|
|
||||||
|
const validate = (item, enabled = item.enabled) => {
|
||||||
|
for (const field of item.fields || []) clearFieldError(item, field.key)
|
||||||
|
let firstInvalid = ''
|
||||||
|
|
||||||
|
for (const field of item.fields || []) {
|
||||||
|
const value = item.config[field.key]
|
||||||
|
let message = ''
|
||||||
|
if (enabled && field.required && isMissing(value)) {
|
||||||
|
message = `请填写${field.label}`
|
||||||
|
} else if (field.type === 'number' && !isMissing(value)) {
|
||||||
|
const number = Number(value)
|
||||||
|
const constraint = numberConstraint(field.key)
|
||||||
|
if (!Number.isFinite(number)) {
|
||||||
|
message = `${field.label}必须是数字`
|
||||||
|
} else if (constraint.min !== undefined && number < constraint.min) {
|
||||||
|
message = `${field.label}不能小于 ${constraint.min}`
|
||||||
|
} else if (constraint.max !== undefined && number > constraint.max) {
|
||||||
|
message = `${field.label}不能大于 ${constraint.max}`
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
DURATION_FIELDS.has(field.key) &&
|
||||||
|
!isMissing(value) &&
|
||||||
|
!DURATION_PATTERN.test(String(value).trim())
|
||||||
|
) {
|
||||||
|
message = `${field.label}格式无效,例如 10s 或 1m30s`
|
||||||
|
} else if (field.key === 'path' && value && !String(value).startsWith('/')) {
|
||||||
|
message = '访问路径必须以 / 开头'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message) {
|
||||||
|
errors[errorKey(item, field.key)] = message
|
||||||
|
firstInvalid ||= field.label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstInvalid) {
|
||||||
|
ElMessage.warning(`请检查${firstInvalid}等配置项`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedSnapshot = (item) =>
|
||||||
|
JSON.stringify({ enabled: item._savedEnabled, config: item._savedConfig })
|
||||||
|
const currentSnapshot = (item) =>
|
||||||
|
JSON.stringify({ enabled: item.enabled, config: item.config })
|
||||||
|
const isDirty = (item) => savedSnapshot(item) !== currentSnapshot(item)
|
||||||
|
|
||||||
|
const hasMaskedSecret = (item) =>
|
||||||
|
(item.fields || []).some(
|
||||||
|
(field) => field.secret && item.config[field.key] === '******'
|
||||||
|
)
|
||||||
|
|
||||||
|
const markSaved = (item) => {
|
||||||
|
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._savedConfig = cloneConfig(item.config)
|
||||||
|
}
|
||||||
|
|
||||||
|
const persist = async (item, operation) => {
|
||||||
|
const key = operationKey(item)
|
||||||
|
if (pending[key]) return false
|
||||||
|
pending[key] = operation
|
||||||
|
try {
|
||||||
|
const res = await saveIntegrationConfig(item.kind, item.provider, {
|
||||||
|
enabled: item.enabled,
|
||||||
|
config: item.config
|
||||||
|
})
|
||||||
|
if (res.code !== 0) {
|
||||||
|
ElMessage.error(res.msg || '保存失败')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
markSaved(item)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
delete pending[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveSelected = async () => {
|
||||||
|
const item = selected.value
|
||||||
|
if (!item || isBusy(item) || !validate(item)) return
|
||||||
|
if (await persist(item, 'save')) {
|
||||||
|
ElMessage.success(`${item.name || providerMeta(item).name} 配置已保存`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const testSelected = async () => {
|
||||||
|
const item = selected.value
|
||||||
|
if (!item || isBusy(item) || !validate(item, true)) return
|
||||||
|
const key = operationKey(item)
|
||||||
|
pending[key] = 'test'
|
||||||
|
try {
|
||||||
|
const res = await testIntegrationConfig(item.kind, item.provider, {
|
||||||
|
enabled: true,
|
||||||
|
config: item.config
|
||||||
|
})
|
||||||
|
if (res.code !== 0) {
|
||||||
|
ElMessage.error(res.msg || '连接测试失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ElMessage.success(`${item.name || providerMeta(item).name} 连接测试成功`)
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('连接测试失败')
|
||||||
|
} finally {
|
||||||
|
delete pending[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleIntegration = async (item, enabled) => {
|
||||||
|
if (isBusy(item)) 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 || providerMeta(item).name} 已${item.enabled ? '启用' : '停用'}`
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item.enabled = previous
|
||||||
|
ElMessage.warning('状态未改变')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.integration-page {
|
||||||
|
min-height: 640px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-heading p {
|
||||||
|
margin: 5px 0 0;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integration-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 {
|
||||||
|
padding: 12px 9px;
|
||||||
|
border-right: 1px solid var(--el-border-color-lighter);
|
||||||
|
background: var(--el-fill-color-blank);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-heading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 4px 10px 11px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 34px minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 58px;
|
||||||
|
padding: 8px 9px;
|
||||||
|
border: 0;
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-icon {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--el-fill-color);
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-item.active .provider-icon {
|
||||||
|
background: var(--el-color-primary-light-8);
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 small {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-state {
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
font-size: 11px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-state.enabled {
|
||||||
|
color: var(--el-color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-panel {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 22px 28px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secret-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-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-description {
|
||||||
|
width: 100%;
|
||||||
|
margin: 5px 0 0;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 14px;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.integration-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-panel {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-heading {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-item {
|
||||||
|
grid-template-columns: 30px minmax(0, 1fr);
|
||||||
|
min-height: 54px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-icon {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-state {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-panel {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.integration-page {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-heading {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-panel {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-item {
|
||||||
|
grid-template-columns: 30px minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,107 +1,496 @@
|
||||||
<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)"
|
||||||
|
/>
|
||||||
|
<div v-else-if="field.type === 'textarea' && field.secret" class="secret-textarea-control">
|
||||||
|
<el-input
|
||||||
|
v-if="isSecretVisible(selected, field.key)"
|
||||||
|
v-model="selected.config[field.key]"
|
||||||
|
class="field-control"
|
||||||
|
type="textarea"
|
||||||
|
:rows="5"
|
||||||
|
:placeholder="field.placeholder || '请输入凭证内容'"
|
||||||
|
autocomplete="new-password"
|
||||||
|
spellcheck="false"
|
||||||
|
@update:model-value="clearFieldError(field.key)"
|
||||||
|
/>
|
||||||
|
<el-input
|
||||||
|
v-else
|
||||||
|
:model-value="maskedTextareaValue(selected.config[field.key])"
|
||||||
|
class="field-control secret-textarea-display"
|
||||||
|
type="textarea"
|
||||||
|
:rows="5"
|
||||||
|
readonly
|
||||||
|
resize="none"
|
||||||
|
:placeholder="field.placeholder || '凭证已隐藏,点击“编辑凭证”后输入'"
|
||||||
|
:aria-label="`${field.label}(已隐藏)`"
|
||||||
|
/>
|
||||||
|
<el-button
|
||||||
|
class="secret-textarea-action"
|
||||||
|
text
|
||||||
|
type="primary"
|
||||||
|
:icon="isSecretVisible(selected, field.key) ? Hide : View"
|
||||||
|
:aria-label="isSecretVisible(selected, field.key) ? `隐藏${field.label}` : `编辑${field.label}`"
|
||||||
|
@click="toggleSecretVisibility(selected, field.key)"
|
||||||
|
>
|
||||||
|
{{ isSecretVisible(selected, field.key) ? '隐藏凭证' : '编辑凭证' }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<el-input
|
||||||
|
v-else-if="field.type === 'textarea'"
|
||||||
|
v-model="selected.config[field.key]"
|
||||||
|
class="field-control"
|
||||||
|
type="textarea"
|
||||||
|
:rows="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, selected)" class="field-hint">{{ fieldHint(field, selected) }}</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.enabled || !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, Hide, MoreFilled, Refresh, View } 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 secretVisibility = 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 CALLBACK_URL_KEYS = new Set(['notify_url', 'return_url', 'cancel_url', 'callback_url', 'webhook_url', 'redirect_url', 'success_url', 'failure_url'])
|
||||||
|
const callbackURLPattern = /(?:notify|callback|webhook|return|cancel|redirect|success|failure)_?url$/i
|
||||||
|
const isCallbackURLField = (field) => {
|
||||||
|
const key = String(field?.key || '').trim().toLowerCase()
|
||||||
|
return CALLBACK_URL_KEYS.has(key) || callbackURLPattern.test(key)
|
||||||
|
}
|
||||||
|
const environmentText = (item) => String(item?.config?.environment || '').trim().toLowerCase()
|
||||||
|
const isSandboxEnvironment = (item) => ['sandbox', 'test', 'testing', 'dev', 'development'].includes(environmentText(item))
|
||||||
|
const isProductionEnvironment = (item) => !isSandboxEnvironment(item)
|
||||||
|
const secretFieldKey = (item, fieldKey) => `${item?.provider || ''}:${fieldKey}`
|
||||||
|
const isSecretVisible = (item, fieldKey) => Boolean(secretVisibility[secretFieldKey(item, fieldKey)])
|
||||||
|
const toggleSecretVisibility = (item, fieldKey) => {
|
||||||
|
const key = secretFieldKey(item, fieldKey)
|
||||||
|
secretVisibility[key] = !secretVisibility[key]
|
||||||
|
}
|
||||||
|
const maskedTextareaValue = (value) => {
|
||||||
|
if (value === '******') return value
|
||||||
|
return isEmpty(value) ? '' : '********'
|
||||||
|
}
|
||||||
|
const hideSecrets = () => { Object.keys(secretVisibility).forEach((key) => delete secretVisibility[key]) }
|
||||||
|
const fieldHint = (field, item = selected.value) => {
|
||||||
|
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 (isCallbackURLField(field)) {
|
||||||
|
return isProductionEnvironment(item) ? '生产环境回调地址必须使用 HTTPS;沙箱环境可使用 HTTP。' : '沙箱环境可使用 HTTP;切换生产环境前请改为 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') || isCallbackURLField(field)) && value) {
|
||||||
|
try {
|
||||||
|
const url = new URL(String(value))
|
||||||
|
if (!['http:', 'https:'].includes(url.protocol)) message = `${field.label}必须使用 HTTP 或 HTTPS`
|
||||||
|
else if (isCallbackURLField(field) && isProductionEnvironment(item) && url.protocol !== 'https:') message = `${field.label}在生产环境必须使用 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
|
||||||
|
hideSecrets()
|
||||||
|
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])
|
||||||
|
hideSecrets()
|
||||||
|
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 (!item.enabled) { ElMessage.warning('当前支付渠道已停用,请先启用并保存渠道后再执行测试。'); return }
|
||||||
|
if (isDirty(item)) { ElMessage.warning('请先保存当前配置'); return }
|
||||||
|
if (!item.config?.test_mode) { ElMessage.warning('请先开启“允许执行渠道测试”并保存'); return }
|
||||||
|
const environment = environmentText(item)
|
||||||
|
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); }
|
.secret-textarea-control { width: 100%; }
|
||||||
@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; } }
|
.secret-textarea-display :deep(.el-textarea__inner) { color: var(--el-text-color-placeholder); font-family: monospace; letter-spacing: 0; }
|
||||||
|
.secret-textarea-action { margin: 4px 0 0; padding: 4px 0; }
|
||||||
|
.field-hint { width: 100%; margin: 5px 0 0; color: var(--el-text-color-secondary); font-size: 12px; line-height: 1.5; }
|
||||||
|
.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,525 @@
|
||||||
<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
|
||||||
|
setRowAction(order, 'fulfill-confirm')
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确认${fulfillmentActionText(order)}订单 ${order.tradeNo} 吗?`, fulfillmentActionText(order), { type: 'warning', confirmButtonText: '确认执行' })
|
||||||
|
} catch { setRowAction(order, ''); 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