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/repository"
|
||||
"kra/internal/initialize"
|
||||
"kra/internal/integration"
|
||||
"kra/internal/integration/cache"
|
||||
"kra/internal/integration/email"
|
||||
"kra/internal/integration/mq"
|
||||
|
|
@ -147,21 +148,23 @@ func wireApp(confServer *conf.Server, runtime *conf.Runtime, logger *slog.Logger
|
|||
navigation := handler.NewNavigation(userService)
|
||||
session := handler.NewSession(tokenService)
|
||||
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)
|
||||
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)
|
||||
routes := router.NewRoutes(v)
|
||||
taskMethods := worker.NewTaskMethods(taskUsecase, mediaUsecase, runtime)
|
||||
moduleRuntime := app.Runtime(routes, taskMethods, registry)
|
||||
websocketServer, cleanup2, err := websocket.New(runtime)
|
||||
websocketServer, cleanup2, err := websocket.New(store)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return nil, nil, err
|
||||
}
|
||||
engine := server.NewGinEngineWithRuntime(runtime, accessControlService, authService, securityService, auditRecorder, logger, string2, moduleRuntime, websocketServer)
|
||||
httpServer := server.NewGinServer(confServer, engine)
|
||||
mqReloadable, cleanup3, err := mq.New(runtime, logger)
|
||||
mqReloadable, cleanup3, err := mq.New(store, logger)
|
||||
if err != nil {
|
||||
cleanup2()
|
||||
cleanup()
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -13,7 +13,7 @@
|
|||
| 支付 provider/mode 标识 | `pkg/paymentkit` | provider 常量、支持列表、金额/签名/JSON 等跨模块协议;system `biz` 只保留兼容别名。 |
|
||||
| 支付回调 ACK | `pkg/paymentkit` | 回调应答、失败包装和默认 provider 应答;具体渠道 SDK 仍留在 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 业务语义。 |
|
||||
|
||||
## system 内部保留边界
|
||||
|
|
@ -23,7 +23,7 @@
|
|||
- `conf`:system 配置 proto、运行时快照和生成代码。
|
||||
- `data`:数据库连接、PO、仓储、system 表、支付持久化和配置 watcher。
|
||||
- `initialize`:首次安装、配置迁移、种子编排和运行时重载。
|
||||
- `integration`:Redis、邮件、存储、支付、WebSocket 和 EMQX 的 provider 生命周期。
|
||||
- `integration`:Redis、邮件、存储、支付、WebSocket、EMQX 和 RabbitMQ 的 provider 生命周期。
|
||||
- `security`:JWT claims、签发/解析和后台安全实现。
|
||||
- `service`:HTTP DTO(`service/dto`)、DTO 与 DO 转换、应用服务和路由元数据。
|
||||
- `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/olahol/melody v1.4.0
|
||||
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/robfig/cron/v3 v3.0.1
|
||||
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/go.mod h1:dmKtJ2ahhPWFVi9o1D5GemmWoh/ctuB9peqTowyTO8o=
|
||||
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/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
|
|
|
|||
|
|
@ -5,10 +5,10 @@
|
|||
|
||||
- `app`:组合根、system 模块定义和任务/路由运行时组合
|
||||
- `biz`:系统领域对象、用例和仓储接口
|
||||
- `conf`:配置 proto 与运行时配置解析
|
||||
- `conf`:基础配置 proto 与运行时配置解析
|
||||
- `data`:数据库生命周期、系统仓储、系统表和支付持久化
|
||||
- `initialize`:数据库首次初始化和系统种子数据编排
|
||||
- `integration`:Redis、邮件、对象存储、支付、WebSocket 和 EMQX 适配器
|
||||
- `integration`:Redis、邮件、对象存储、支付、WebSocket、EMQX 和 RabbitMQ 适配器
|
||||
- `security`:后台 JWT 等安全实现
|
||||
- `server`:Gin server 组合与生命周期;横切 HTTP 代码按子包维护:
|
||||
`server/handler`、`server/middleware`、`server/router`、`server/httpx`
|
||||
|
|
|
|||
|
|
@ -9,10 +9,23 @@ import (
|
|||
// Definition describes the built-in system contribution to the application
|
||||
// catalog. Other business modules can expose the same shape independently.
|
||||
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{
|
||||
Name: "system",
|
||||
Migrations: append(datasystem.Migrations(), datapayment.Migrations()...),
|
||||
Surface: datapayment.AdminSurface(),
|
||||
Surface: surface,
|
||||
TimedTasks: []module.TimedTask{
|
||||
{Name: "ClearDB", Description: "定时清理数据库过期日志(操作记录/JWT黑名单/定时任务执行日志)", Spec: "@daily", MethodName: "ClearDB", 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"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const IntegrationKindPayment = "payment"
|
||||
const (
|
||||
IntegrationKindPayment = "payment"
|
||||
IntegrationKindMQ = "mq"
|
||||
IntegrationKindWebSocket = "websocket"
|
||||
)
|
||||
|
||||
type IntegrationConfig struct {
|
||||
Kind string
|
||||
|
|
@ -51,10 +56,17 @@ type IntegrationConfigRepo interface {
|
|||
DeleteIntegrationConfig(context.Context, string, string) error
|
||||
}
|
||||
|
||||
type IntegrationConfigUsecase struct{ repo IntegrationConfigRepo }
|
||||
type IntegrationConnectionTester interface {
|
||||
TestIntegration(context.Context, *IntegrationConfig) error
|
||||
}
|
||||
|
||||
func NewIntegrationConfigUsecase(repo IntegrationConfigRepo) *IntegrationConfigUsecase {
|
||||
return &IntegrationConfigUsecase{repo: repo}
|
||||
type IntegrationConfigUsecase struct {
|
||||
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) {
|
||||
|
|
@ -101,6 +113,42 @@ func (uc *IntegrationConfigUsecase) Save(ctx context.Context, config *Integratio
|
|||
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 {
|
||||
kind, provider = normalizeIntegrationPart(kind), normalizeIntegrationPart(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 {
|
||||
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 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 {
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
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: {
|
||||
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"),
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -93,8 +93,6 @@ message AdminBackend {
|
|||
Zap zap = 11;
|
||||
CORS cors = 12;
|
||||
App app = 13;
|
||||
WebSocket websocket = 14;
|
||||
MQ mq = 15;
|
||||
|
||||
message JWT {
|
||||
string signing_key = 1;
|
||||
|
|
@ -181,31 +179,6 @@ message AdminBackend {
|
|||
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 {
|
||||
string type = 1;
|
||||
Qiniu qiniu = 2;
|
||||
|
|
|
|||
|
|
@ -182,8 +182,6 @@ func (d *Data) persistConfigValuesLocked(dataConfig *conf.Data, adminConfig *con
|
|||
fileAdmin := cloneAdminConfig(adminConfig)
|
||||
fileAdmin.Storage = nil
|
||||
fileAdmin.Email = nil
|
||||
fileAdmin.Websocket = nil
|
||||
fileAdmin.Mq = nil
|
||||
adminValue, err := protoMap(fileAdmin)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -196,8 +194,6 @@ func (d *Data) persistConfigValuesLocked(dataConfig *conf.Data, adminConfig *con
|
|||
}
|
||||
deleteYAMLMapping(&document, "admin", "storage")
|
||||
deleteYAMLMapping(&document, "admin", "email")
|
||||
deleteYAMLMapping(&document, "admin", "websocket")
|
||||
deleteYAMLMapping(&document, "admin", "mq")
|
||||
if adminConfig.System != nil {
|
||||
if err = setServerHTTPPort(&document, adminConfig.System.Addr); err != nil {
|
||||
return err
|
||||
|
|
@ -257,8 +253,6 @@ func (d *Data) persistDatabaseConfig(database *conf.Data_Database, signingKey st
|
|||
}
|
||||
deleteYAMLMapping(&document, "admin", "storage")
|
||||
deleteYAMLMapping(&document, "admin", "email")
|
||||
deleteYAMLMapping(&document, "admin", "websocket")
|
||||
deleteYAMLMapping(&document, "admin", "mq")
|
||||
return writeConfigDocument(configPath, &document)
|
||||
}
|
||||
|
||||
|
|
@ -277,13 +271,11 @@ func (d *Data) removeIntegrationConfigFromFile() error {
|
|||
if err = yaml.Unmarshal(raw, &document); err != nil {
|
||||
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
|
||||
}
|
||||
deleteYAMLMapping(&document, "admin", "storage")
|
||||
deleteYAMLMapping(&document, "admin", "email")
|
||||
deleteYAMLMapping(&document, "admin", "websocket")
|
||||
deleteYAMLMapping(&document, "admin", "mq")
|
||||
return writeConfigDocument(configPath, &document)
|
||||
}
|
||||
|
||||
|
|
@ -365,8 +357,6 @@ func (d *Data) reloadConfig(ctx context.Context) error {
|
|||
}
|
||||
legacyStorage := next.Admin.Storage
|
||||
legacyEmail := next.Admin.Email
|
||||
legacyWebSocket := next.Admin.Websocket
|
||||
legacyMQ := next.Admin.Mq
|
||||
currentAdmin := d.runtime.Admin()
|
||||
if legacyStorage == nil {
|
||||
if currentAdmin != nil {
|
||||
|
|
@ -376,12 +366,6 @@ func (d *Data) reloadConfig(ctx context.Context) error {
|
|||
if legacyEmail == nil && currentAdmin != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reload storage: %w", err)
|
||||
|
|
@ -436,6 +410,9 @@ func (d *Data) reloadConfig(ctx context.Context) error {
|
|||
mongoAccepted = true
|
||||
}
|
||||
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 {
|
||||
d.storage.Replace(candidateStorage)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,8 +58,6 @@ func (d *Data) watchConfig() func() {
|
|||
if current := d.runtime.Admin(); current != nil {
|
||||
next.Admin.Storage = current.Storage
|
||||
next.Admin.Email = current.Email
|
||||
next.Admin.Mq = current.Mq
|
||||
next.Admin.Websocket = current.Websocket
|
||||
}
|
||||
next.Admin.ConfigPath = absolute
|
||||
d.runtime.Replace(next.Data, next.Admin)
|
||||
|
|
|
|||
|
|
@ -14,12 +14,14 @@ import (
|
|||
"kra/internal/conf"
|
||||
datapayment "kra/internal/data/payment"
|
||||
datasystem "kra/internal/data/repository"
|
||||
"kra/internal/integration/runtimeconfig"
|
||||
"kra/internal/integration/storage"
|
||||
"kra/pkg/module"
|
||||
)
|
||||
|
||||
var ProviderSet = wire.NewSet(
|
||||
NewData,
|
||||
NewIntegrationRuntime,
|
||||
wire.Bind(new(datasystem.Provider), new(*Data)),
|
||||
wire.Bind(new(datasystem.DatabaseProvider), new(*Data)),
|
||||
wire.Bind(new(datapayment.Provider), new(*Data)),
|
||||
|
|
@ -33,6 +35,13 @@ var ProviderSet = wire.NewSet(
|
|||
datasystem.NewIntegrationConfigRepo,
|
||||
)
|
||||
|
||||
func NewIntegrationRuntime(data *Data) *runtimeconfig.Store {
|
||||
if data == nil {
|
||||
return runtimeconfig.NewStore()
|
||||
}
|
||||
return data.IntegrationRuntime()
|
||||
}
|
||||
|
||||
type Data struct {
|
||||
initMu sync.Mutex
|
||||
configMu sync.Mutex
|
||||
|
|
@ -41,6 +50,7 @@ type Data struct {
|
|||
redis *reloadableRedis
|
||||
mongo *reloadableMongo
|
||||
runtime *conf.Runtime
|
||||
integrations *runtimeconfig.Store
|
||||
storage *storage.Reloadable
|
||||
dbListMu sync.RWMutex
|
||||
dbList map[string]*gorm.DB
|
||||
|
|
@ -73,6 +83,15 @@ func (d *Data) Runtime() *conf.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
|
||||
// the system export module.
|
||||
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.
|
||||
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)
|
||||
var db *gorm.DB
|
||||
var err error
|
||||
|
|
@ -208,17 +227,10 @@ func NewData(runtime *conf.Runtime, appLogger *slog.Logger, storageManager *stor
|
|||
}
|
||||
admin.Storage = storageConfig
|
||||
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)
|
||||
if err = d.loadIntegrationRuntime(db); err != nil {
|
||||
return nil, nil, fmt.Errorf("load integration runtime: %w", err)
|
||||
}
|
||||
activeStorage, storageErr := storage.New(admin)
|
||||
if storageErr != nil {
|
||||
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 {
|
||||
next.Email = currentAdmin.Email
|
||||
}
|
||||
if next.Websocket == nil {
|
||||
next.Websocket = currentAdmin.Websocket
|
||||
}
|
||||
if next.Mq == nil {
|
||||
next.Mq = currentAdmin.Mq
|
||||
}
|
||||
next.ConfigPath = currentAdmin.ConfigPath
|
||||
candidateStorage, err := storage.New(next)
|
||||
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 {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
|
|
@ -120,12 +108,6 @@ func (d *Data) PersistRuntimeConfig(ctx context.Context, dataRaw, adminRaw []byt
|
|||
if nextAdmin.Email == nil {
|
||||
nextAdmin.Email = currentAdmin.Email
|
||||
}
|
||||
if nextAdmin.Websocket == nil {
|
||||
nextAdmin.Websocket = currentAdmin.Websocket
|
||||
}
|
||||
if nextAdmin.Mq == nil {
|
||||
nextAdmin.Mq = currentAdmin.Mq
|
||||
}
|
||||
nextAdmin.ConfigPath = currentAdmin.ConfigPath
|
||||
candidateStorage, err := storage.New(nextAdmin)
|
||||
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 {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
|
|
@ -229,14 +205,6 @@ func (d *Data) InitializeDatabase(ctx context.Context, input *biz.DatabaseConfig
|
|||
if err != nil {
|
||||
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()
|
||||
if err := d.persistDatabaseConfig(config, signingKey); err != nil {
|
||||
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.Storage = storageConfig
|
||||
currentAdmin.Websocket = websocketConfig
|
||||
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)
|
||||
if err = d.loadIntegrationRuntime(candidate); err != nil {
|
||||
return fmt.Errorf("initialize integration runtime: %w", err)
|
||||
}
|
||||
activated = true
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ const (
|
|||
integrationKindStorage = "storage"
|
||||
integrationKindEmail = "email"
|
||||
integrationKindPayment = "payment"
|
||||
integrationKindMQ = "mq"
|
||||
)
|
||||
|
||||
// integrationConfigPO stores credentials and provider-specific options for
|
||||
|
|
@ -35,88 +34,6 @@ type integrationConfigPO struct {
|
|||
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" }
|
||||
|
||||
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) {
|
||||
db := openIntegrationConfigTestDB(t)
|
||||
legacy := &conf.AdminBackend_Storage{
|
||||
|
|
@ -205,7 +186,7 @@ func TestResolveEmailIntegrationConfigMigratesLegacyOnlyOnce(t *testing.T) {
|
|||
|
||||
func TestPersistConfigValuesRemovesStorageFromYAML(t *testing.T) {
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
|
@ -218,7 +199,6 @@ func TestPersistConfigValuesRemovesStorageFromYAML(t *testing.T) {
|
|||
Qiniu: &conf.AdminBackend_Qiniu{SecretKey: "database-only-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 {
|
||||
t.Fatal(err)
|
||||
|
|
@ -242,9 +222,6 @@ func TestPersistConfigValuesRemovesStorageFromYAML(t *testing.T) {
|
|||
if _, exists := adminValue["email"]; exists {
|
||||
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" {
|
||||
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 {
|
||||
return []migration.Step{{
|
||||
ID: "202608200001_data_infrastructure",
|
||||
Migrate: func(db *gorm.DB) error {
|
||||
return migration.CreateMissingTables(db, &integrationConfigPO{})
|
||||
return []migration.Step{
|
||||
{
|
||||
ID: "202608200001_data_infrastructure",
|
||||
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
|
||||
|
|
|
|||
|
|
@ -26,8 +26,20 @@ func TestMigrateAllRunsModuleSchemasWithoutBootstrapSeed(t *testing.T) {
|
|||
if err = db.Table(migration.TableName).Count(&versions).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if versions != 4 {
|
||||
t.Fatalf("migration versions = %d, want 4", versions)
|
||||
if versions != 7 {
|
||||
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"} {
|
||||
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},
|
||||
},
|
||||
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/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"
|
||||
|
||||
"kra/internal/biz"
|
||||
"kra/internal/integration/runtimeconfig"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
|
@ -26,6 +27,10 @@ func (integrationConfigPO) TableName() string { return "sys_integration_configs"
|
|||
|
||||
type integrationConfigRepo struct{ data Provider }
|
||||
|
||||
type integrationRuntimeProvider interface {
|
||||
IntegrationRuntime() *runtimeconfig.Store
|
||||
}
|
||||
|
||||
func NewIntegrationConfigRepo(data Provider) biz.IntegrationConfigRepo {
|
||||
return &integrationConfigRepo{data: data}
|
||||
}
|
||||
|
|
@ -65,7 +70,11 @@ func (r *integrationConfigRepo) SaveIntegrationConfig(ctx context.Context, confi
|
|||
}
|
||||
}
|
||||
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 {
|
||||
return err
|
||||
|
|
@ -77,11 +86,34 @@ func (r *integrationConfigRepo) SaveIntegrationConfig(ctx context.Context, confi
|
|||
}
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"kra/pkg/database/migration"
|
||||
|
||||
"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 {
|
||||
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
|
||||
// read-only operation; mutating dataConfig here would replace the actual
|
||||
|
|
@ -115,9 +105,6 @@ func (r *Repo) ConfigurationJSON() (json.RawMessage, error) {
|
|||
safeAdmin.Email.Secret = "******"
|
||||
}
|
||||
maskStorageSecrets(safeAdmin.Storage)
|
||||
if safeAdmin.Mq != nil && safeAdmin.Mq.Password != "" {
|
||||
safeAdmin.Mq.Password = "******"
|
||||
}
|
||||
}
|
||||
dataMap := map[string]any{}
|
||||
if safeData != nil {
|
||||
|
|
@ -320,9 +307,6 @@ func preserveAdminSecrets(next, current *conf.AdminBackend) {
|
|||
next.Email.Secret = current.Email.Secret
|
||||
}
|
||||
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 == "******" }
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ state, or translate provider-specific protocols into `biz` interfaces.
|
|||
|
||||
- `cache`: Redis-backed cache with an in-memory fallback.
|
||||
- `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.
|
||||
- `storage`: local and object-storage implementations of `biz.FileStorage`.
|
||||
- `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
|
||||
Melody or Paho clients and do not read system configuration directly. The
|
||||
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.
|
||||
|
||||
```go
|
||||
|
|
@ -45,7 +45,8 @@ Integration configuration is stored in `sys_integration_configs`:
|
|||
|
||||
- WebSocket: `kind=websocket`, `provider=melody`
|
||||
- EMQX: `kind=mq`, `provider=emqx`
|
||||
- RabbitMQ: `kind=mq`, `provider=rabbitmq`
|
||||
|
||||
The YAML values are only migration/bootstrap inputs. After the integration
|
||||
table exists, the database is authoritative and runtime updates are applied
|
||||
without restarting the process. The WebSocket public path defaults to `/ws`.
|
||||
These three integrations are stored only in `sys_integration_configs`; they do
|
||||
not come from `config.yaml`. Runtime updates are applied without restarting
|
||||
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 (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"kra/internal/conf"
|
||||
"kra/pkg/mq"
|
||||
"kra/internal/integration/runtimeconfig"
|
||||
platformmq "kra/pkg/mq"
|
||||
)
|
||||
|
||||
// Reloadable follows the runtime snapshot and keeps a single shared EMQX
|
||||
// connection for all modules in this process.
|
||||
const (
|
||||
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 {
|
||||
mu sync.RWMutex
|
||||
opMu sync.Mutex
|
||||
current mq.Client
|
||||
subscriptions map[string]subscription
|
||||
stop func()
|
||||
clients map[string]platformmq.Client
|
||||
configs map[string]runtimeconfig.Config
|
||||
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
|
||||
closed bool
|
||||
}
|
||||
|
||||
type subscription struct {
|
||||
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 {
|
||||
logger = slog.Default()
|
||||
}
|
||||
r := &Reloadable{logger: logger, subscriptions: make(map[string]subscription)}
|
||||
if runtime != nil {
|
||||
var config *conf.AdminBackend_MQ
|
||||
if admin := runtime.Admin(); admin != nil {
|
||||
config = admin.GetMq()
|
||||
}
|
||||
r.replace(config)
|
||||
r.stop = runtime.Subscribe(func(_ *conf.Data, admin *conf.AdminBackend) {
|
||||
if admin != nil {
|
||||
r.replace(admin.GetMq())
|
||||
}
|
||||
})
|
||||
r := &Reloadable{
|
||||
clients: make(map[string]platformmq.Client),
|
||||
configs: make(map[string]runtimeconfig.Config),
|
||||
subscriptions: make(map[string]map[string]map[string]subscription),
|
||||
bindings: make(map[string]map[string]byte),
|
||||
pending: make(map[string]bool),
|
||||
nextRetry: make(map[string]time.Time),
|
||||
retryStop: make(chan struct{}),
|
||||
retryDone: make(chan struct{}),
|
||||
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() {
|
||||
if r.stop != nil {
|
||||
r.stop()
|
||||
for _, stop := range r.stop {
|
||||
stop()
|
||||
}
|
||||
_ = r.Close()
|
||||
}
|
||||
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()
|
||||
defer r.opMu.Unlock()
|
||||
if r.closed {
|
||||
return
|
||||
}
|
||||
if config == nil {
|
||||
config = &conf.AdminBackend_MQ{}
|
||||
}
|
||||
cfg := mq.Config{Enabled: config.Enabled, Broker: config.Broker, ClientID: config.ClientId, Username: config.Username, Password: config.Password, CleanSession: config.CleanSession}
|
||||
if config.KeepAlive > 0 {
|
||||
cfg.KeepAlive = time.Duration(config.KeepAlive) * time.Second
|
||||
}
|
||||
if config.ConnectTimeout > 0 {
|
||||
cfg.ConnectTimeout = time.Duration(config.ConnectTimeout) * time.Second
|
||||
}
|
||||
client, err := mq.NewMQTT(cfg)
|
||||
if err != nil {
|
||||
r.logger.Warn("emqx unavailable", "mod", "mq", "error", err)
|
||||
config.Provider = provider
|
||||
config.Values = append(json.RawMessage(nil), config.Values...)
|
||||
r.configs[provider] = config
|
||||
if !config.Enabled {
|
||||
delete(r.pending, provider)
|
||||
delete(r.nextRetry, provider)
|
||||
r.replaceClientLocked(provider, nil)
|
||||
return
|
||||
}
|
||||
if config.Enabled {
|
||||
if err = r.restoreSubscriptions(context.Background(), client); err != nil {
|
||||
_ = client.Close()
|
||||
r.logger.Warn("restore emqx subscriptions failed", "mod", "mq", "error", err)
|
||||
return
|
||||
}
|
||||
if err := r.activateLocked(provider, config); err != nil {
|
||||
r.pending[provider] = true
|
||||
r.nextRetry[provider] = time.Now().Add(configRetryInterval(config.Values))
|
||||
r.logger.Warn("message integration unavailable", "mod", "mq", "provider", provider, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
old := r.current
|
||||
r.current = client
|
||||
r.bindings[provider] = bindings
|
||||
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()
|
||||
if old != nil {
|
||||
_ = old.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Reloadable) restoreSubscriptions(ctx context.Context, client mq.Client) error {
|
||||
for topic, item := range r.subscriptions {
|
||||
if err := client.Subscribe(ctx, topic, item.qos, item.handler); err != nil {
|
||||
return err
|
||||
func (r *Reloadable) restoreSubscriptionsLocked(provider string, client platformmq.Client) (map[string]byte, error) {
|
||||
desired := r.desiredSubscriptions(provider)
|
||||
bindings := make(map[string]byte, len(desired))
|
||||
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
|
||||
}
|
||||
|
||||
func (r *Reloadable) client() mq.Client { r.mu.RLock(); defer r.mu.RUnlock(); return r.current }
|
||||
func (r *Reloadable) Publish(ctx context.Context, topic string, payload []byte, qos byte, retain bool) error {
|
||||
c := r.client()
|
||||
if c == nil {
|
||||
return mq.ErrUnavailable
|
||||
}
|
||||
return c.Publish(ctx, topic, payload, qos, retain)
|
||||
func (r *Reloadable) Connected() bool { return r.ConnectedTo(ProviderEMQX) }
|
||||
|
||||
func (r *Reloadable) ConnectedTo(provider string) bool {
|
||||
provider = strings.ToLower(strings.TrimSpace(provider))
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
client := r.clients[provider]
|
||||
return client != nil && client.Connected()
|
||||
}
|
||||
func (r *Reloadable) 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 {
|
||||
r.opMu.Lock()
|
||||
defer r.opMu.Unlock()
|
||||
|
|
@ -144,11 +393,16 @@ func (r *Reloadable) Close() error {
|
|||
}
|
||||
r.closed = true
|
||||
r.mu.Lock()
|
||||
old := r.current
|
||||
r.current = nil
|
||||
clients := make([]platformmq.Client, 0, len(r.clients))
|
||||
for provider, client := range r.clients {
|
||||
clients = append(clients, client)
|
||||
delete(r.clients, provider)
|
||||
}
|
||||
r.mu.Unlock()
|
||||
if old != nil {
|
||||
return old.Close()
|
||||
for _, client := range clients {
|
||||
if client != nil {
|
||||
_ = client.Close()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,28 +26,33 @@ func (*fakeClient) Close() error { return nil }
|
|||
|
||||
func TestReloadableTracksSubscriptions(t *testing.T) {
|
||||
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) {}
|
||||
if err := r.Subscribe(context.Background(), "orders/+/paid", platformmq.AtLeastOnce, handler); err != nil {
|
||||
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")
|
||||
}
|
||||
if err := r.Unsubscribe(context.Background(), "orders/+/paid"); err != nil {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReloadableRestoresSubscriptions(t *testing.T) {
|
||||
client := &fakeClient{}
|
||||
r := &Reloadable{subscriptions: map[string]subscription{
|
||||
"orders/+/paid": {qos: platformmq.AtLeastOnce, handler: func(context.Context, platformmq.Message) {}},
|
||||
r := &Reloadable{subscriptions: map[string]map[string]subscription{
|
||||
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)
|
||||
}
|
||||
if len(client.subscribed) != 1 || client.subscribed[0] != "orders/+/paid" {
|
||||
|
|
|
|||
|
|
@ -20,8 +20,11 @@ var ProviderSet = wire.NewSet(
|
|||
cache.New,
|
||||
email.NewEmailRepo,
|
||||
storage.NewFileStorage,
|
||||
NewConnectivityTester,
|
||||
wire.Bind(new(biz.IntegrationConnectionTester), new(*ConnectivityTester)),
|
||||
mqintegration.New,
|
||||
wire.Bind(new(mq.Client), new(*mqintegration.Reloadable)),
|
||||
wire.Bind(new(mq.Registry), new(*mqintegration.Reloadable)),
|
||||
websocketintegration.New,
|
||||
wire.Bind(new(platformws.Hub), new(*websocketintegration.Server)),
|
||||
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
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
gorillawebsocket "github.com/gorilla/websocket"
|
||||
melody "github.com/olahol/melody"
|
||||
"kra/internal/conf"
|
||||
"kra/internal/integration/runtimeconfig"
|
||||
platformws "kra/pkg/websocket"
|
||||
)
|
||||
|
||||
// Server is the system-owned WebSocket endpoint. Business modules can attach
|
||||
// handlers and publish messages without depending on Gin or Melody directly.
|
||||
const ProviderMelody = "melody"
|
||||
|
||||
// Server owns the database-configured WebSocket endpoint.
|
||||
type Server struct {
|
||||
mu sync.RWMutex
|
||||
current *platformws.Server
|
||||
|
|
@ -22,24 +29,25 @@ type Server struct {
|
|||
binaryHandlers []func(*melody.Session, []byte)
|
||||
connectHandlers []func(*melody.Session)
|
||||
disconnectHandlers []func(*melody.Session)
|
||||
stop func()
|
||||
closed bool
|
||||
}
|
||||
|
||||
func New(runtime *conf.Runtime) (*Server, func(), error) {
|
||||
func New(store *runtimeconfig.Store) (*Server, func(), error) {
|
||||
s := &Server{}
|
||||
s.Replace(runtime)
|
||||
var unsubscribe func()
|
||||
if runtime != nil {
|
||||
unsubscribe = runtime.Subscribe(func(_ *conf.Data, _ *conf.AdminBackend) { s.Replace(runtime) })
|
||||
if store != nil {
|
||||
s.apply(storeConfig(store))
|
||||
s.stop = store.Subscribe("websocket", ProviderMelody, func(config runtimeconfig.Config) { s.apply(config) })
|
||||
}
|
||||
return s, func() {
|
||||
if unsubscribe != nil {
|
||||
unsubscribe()
|
||||
if s.stop != nil {
|
||||
s.stop()
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.closed = true
|
||||
current := s.current
|
||||
s.current = nil
|
||||
s.path = ""
|
||||
s.mu.Unlock()
|
||||
if current != nil {
|
||||
_ = current.Close()
|
||||
|
|
@ -47,13 +55,85 @@ func New(runtime *conf.Runtime) (*Server, func(), error) {
|
|||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) Replace(runtime *conf.Runtime) {
|
||||
var config *conf.AdminBackend_WebSocket
|
||||
if runtime != nil && runtime.Admin() != nil {
|
||||
config = runtime.Admin().Websocket
|
||||
func storeConfig(store *runtimeconfig.Store) runtimeconfig.Config {
|
||||
config, _ := store.Get("websocket", ProviderMelody)
|
||||
return config
|
||||
}
|
||||
|
||||
// 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 {
|
||||
config = &conf.AdminBackend_WebSocket{}
|
||||
path := text(values, "path")
|
||||
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()
|
||||
if s.closed {
|
||||
|
|
@ -70,14 +150,21 @@ func (s *Server) Replace(runtime *conf.Runtime) {
|
|||
}
|
||||
return
|
||||
}
|
||||
path := text(values, "path")
|
||||
if path == "" {
|
||||
path = "/ws"
|
||||
}
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
next := platformws.New(platformws.Config{
|
||||
WriteWait: duration(config.WriteWait, 10*time.Second),
|
||||
PongWait: duration(config.PongWait, 60*time.Second),
|
||||
PingPeriod: duration(config.PingPeriod, 54*time.Second),
|
||||
MaxMessageSize: config.MaxMessageSize,
|
||||
MessageBufferSize: int(config.MessageBufferSize),
|
||||
ConcurrentMessageHandling: config.ConcurrentMessageHandling,
|
||||
AllowOrigins: config.AllowOrigins,
|
||||
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"),
|
||||
})
|
||||
for _, handler := range s.messageHandlers {
|
||||
next.OnMessage(handler)
|
||||
|
|
@ -91,27 +178,65 @@ func (s *Server) Replace(runtime *conf.Runtime) {
|
|||
for _, handler := range s.disconnectHandlers {
|
||||
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.path = path
|
||||
s.mu.Unlock()
|
||||
if previous != nil {
|
||||
_ = previous.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func duration(value interface{ AsDuration() time.Duration }, fallback time.Duration) time.Duration {
|
||||
if value == nil {
|
||||
return fallback
|
||||
func text(values map[string]any, key string) string {
|
||||
value, ok := values[key]
|
||||
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 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 {
|
||||
|
|
@ -131,14 +256,9 @@ func (s *Server) Path() string {
|
|||
return s.path
|
||||
}
|
||||
func (s *Server) HandleRequest(w http.ResponseWriter, r *http.Request) error {
|
||||
if s == nil {
|
||||
return errors.New("websocket server is disabled")
|
||||
}
|
||||
s.mu.RLock()
|
||||
current := s.current
|
||||
s.mu.RUnlock()
|
||||
if current == nil {
|
||||
return errors.New("websocket server is disabled")
|
||||
current, err := s.active()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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)
|
||||
}
|
||||
func (s *Server) Broadcast(message []byte) error {
|
||||
if s == nil {
|
||||
return errors.New("websocket server is disabled")
|
||||
}
|
||||
s.mu.RLock()
|
||||
current := s.current
|
||||
s.mu.RUnlock()
|
||||
if current == nil {
|
||||
return errors.New("websocket server is disabled")
|
||||
current, err := s.active()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return current.Broadcast(message)
|
||||
}
|
||||
func (s *Server) BroadcastBinary(message []byte) error {
|
||||
if s == nil {
|
||||
return errors.New("websocket server is disabled")
|
||||
}
|
||||
s.mu.RLock()
|
||||
current := s.current
|
||||
s.mu.RUnlock()
|
||||
if current == nil {
|
||||
return errors.New("websocket server is disabled")
|
||||
current, err := s.active()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return current.BroadcastBinary(message)
|
||||
}
|
||||
|
|
@ -231,8 +341,9 @@ func (s *Server) OnBinaryMessage(handler func(*melody.Session, []byte)) {
|
|||
}
|
||||
s.mu.Lock()
|
||||
s.binaryHandlers = append(s.binaryHandlers, handler)
|
||||
if s.current != nil {
|
||||
s.current.OnBinaryMessage(handler)
|
||||
current := s.current
|
||||
if current != nil {
|
||||
current.OnBinaryMessage(handler)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
|
@ -242,8 +353,9 @@ func (s *Server) OnConnect(handler func(*melody.Session)) {
|
|||
}
|
||||
s.mu.Lock()
|
||||
s.connectHandlers = append(s.connectHandlers, handler)
|
||||
if s.current != nil {
|
||||
s.current.OnConnect(handler)
|
||||
current := s.current
|
||||
if current != nil {
|
||||
current.OnConnect(handler)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
|
@ -253,8 +365,9 @@ func (s *Server) OnDisconnect(handler func(*melody.Session)) {
|
|||
}
|
||||
s.mu.Lock()
|
||||
s.disconnectHandlers = append(s.disconnectHandlers, handler)
|
||||
if s.current != nil {
|
||||
s.current.OnDisconnect(handler)
|
||||
current := s.current
|
||||
if current != nil {
|
||||
current.OnDisconnect(handler)
|
||||
}
|
||||
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)
|
||||
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) {
|
||||
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/:provider",
|
||||
"PUT /integration/configs/:kind/:provider",
|
||||
"POST /integration/configs/:kind/:provider/test",
|
||||
"DELETE /integration/configs/:kind/:provider",
|
||||
"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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -353,6 +354,7 @@ POST /fileUploadAndDownload/upload
|
|||
POST /info/createInfo
|
||||
POST /init/checkdb
|
||||
POST /init/initdb
|
||||
POST /integration/configs/:kind/:provider/test
|
||||
POST /jwt/jsonInBlacklist
|
||||
POST /mediaUpload/chunk
|
||||
POST /mediaUpload/complete
|
||||
|
|
|
|||
|
|
@ -46,6 +46,19 @@ func (h *IntegrationConfig) Save(c *gin.Context) {
|
|||
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) {
|
||||
if err := h.service.Delete(c.Request.Context(), c.Param("kind"), c.Param("provider")); err != nil {
|
||||
Fail(c, err.Error())
|
||||
|
|
|
|||
|
|
@ -2,10 +2,14 @@ package middleware
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"mime"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"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 {
|
||||
logLimit = int(config.Zap.AccessLogMaxBytes)
|
||||
}
|
||||
paymentCallback := isPaymentCallbackPath(c.Request.URL.Path)
|
||||
paymentConfigWrite := isPaymentIntegrationConfigWrite(c.Request.Method, c.Request.URL.Path)
|
||||
requestText := ""
|
||||
if multipart {
|
||||
if paymentCallback {
|
||||
requestText = paymentCallbackSummary(requestBody, c.GetHeader("Content-Type"))
|
||||
} else if paymentConfigWrite {
|
||||
requestText = paymentConfigSummary(requestBody)
|
||||
} else if multipart {
|
||||
requestText = "[文件]"
|
||||
} else {
|
||||
requestText = redactJSON(requestBody, c.GetHeader("Content-Type"), logLimit)
|
||||
}
|
||||
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 {
|
||||
c.Next()
|
||||
}
|
||||
|
|
@ -82,6 +98,9 @@ func AccessLog(runtime *conf.Runtime, logger *slog.Logger, version string) gin.H
|
|||
return
|
||||
}
|
||||
responseText := redactJSON(writer.body.Bytes(), c.Writer.Header().Get("Content-Type"), logLimit)
|
||||
if paymentCallback {
|
||||
responseText = "[支付回调响应已省略]"
|
||||
}
|
||||
userID, authorityID := uint(0), uint(0)
|
||||
if claims := Claims(c); claims != nil {
|
||||
userID, authorityID = claims.ID, claims.AuthorityID
|
||||
|
|
@ -95,22 +114,36 @@ func AccessLog(runtime *conf.Runtime, logger *slog.Logger, version string) gin.H
|
|||
bytesOut = 0
|
||||
}
|
||||
privateErrors := strings.TrimRight(c.Errors.ByType(gin.ErrorTypePrivate).String(), "\n")
|
||||
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 config != nil && config.Zap != nil && config.Zap.AccessReqHeaders {
|
||||
var attributes []any
|
||||
if paymentCallback {
|
||||
attributes = []any{
|
||||
"mod", "payment-callback", "payment_provider", paymentCallbackProvider(c.Request.URL.Path),
|
||||
"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,
|
||||
"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))
|
||||
}
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if privateErrors != "" {
|
||||
if !paymentCallback && privateErrors != "" {
|
||||
attributes = append(attributes, "error_msg", privateErrors)
|
||||
}
|
||||
logger.InfoContext(c.Request.Context(), "请求完成", attributes...)
|
||||
|
|
@ -122,6 +155,56 @@ func isMediaUploadRoute(route string) bool {
|
|||
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 {
|
||||
out := make(map[string]string, len(headers))
|
||||
for key, values := range headers {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
|
|
@ -63,3 +65,60 @@ func TestAccessLogAllowsMediaLimitOnlyOnUploadRoute(t *testing.T) {
|
|||
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 = "[超出记录长度]"
|
||||
}
|
||||
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
|
||||
// to the global access/error logging pipeline.
|
||||
c.Set(ctxOperationAuditPersistFailedKey, true)
|
||||
|
|
@ -128,7 +132,7 @@ func maskOperationBody(value any) {
|
|||
case map[string]any:
|
||||
for key, item := range current {
|
||||
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] = "***"
|
||||
continue
|
||||
}
|
||||
|
|
@ -157,17 +161,49 @@ func isDownloadResponse(c *gin.Context) bool {
|
|||
// recordsOperation mirrors the routes on which operation records are enabled.
|
||||
// Matching by suffix keeps the behavior stable when router-prefix is configured.
|
||||
func recordsOperation(method, path string) bool {
|
||||
_, ok := operationRoutes[method+" "+routeSuffix(path)]
|
||||
return ok
|
||||
for route := range operationRoutes {
|
||||
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 {
|
||||
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 {
|
||||
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{} {
|
||||
|
|
@ -189,6 +225,8 @@ var operationRoutes = func() map[string]struct{} {
|
|||
"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 /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))
|
||||
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/:provider", handler.Find)
|
||||
configs.PUT("/:kind/:provider", handler.Save)
|
||||
configs.POST("/:kind/:provider/test", handler.Test)
|
||||
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})
|
||||
}
|
||||
|
||||
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 {
|
||||
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/:provider": {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: "测试支付渠道配置与沙箱交易链路"},
|
||||
"GET /securityConfig/getSecurityConfig": {group: "安全配置", description: "获取安全配置"},
|
||||
"GET /sysDictionary/exportSysDictionary": {group: "系统字典", description: "导出字典JSON"},
|
||||
|
|
@ -129,6 +136,7 @@ var apiMetadata = map[string]apiMetadataValue{
|
|||
"POST /menu/getMenuList": {group: "菜单", description: "分页获取基础menu列表"},
|
||||
"POST /menu/setMenuRoles": {group: "菜单", description: "全量覆盖菜单关联角色列表"},
|
||||
"POST /menu/updateBaseMenu": {group: "菜单", description: "更新菜单"},
|
||||
"POST /integration/configs/:kind/:provider/test": {group: "集成配置", description: "测试通信集成连接"},
|
||||
"PUT /integration/configs/:kind/:provider": {group: "集成配置", description: "保存集成配置"},
|
||||
"DELETE /integration/configs/:kind/:provider": {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")
|
||||
|
||||
const (
|
||||
ProviderEMQX = "emqx"
|
||||
ProviderRabbitMQ = "rabbitmq"
|
||||
)
|
||||
|
||||
const (
|
||||
AtMostOnce byte = 0
|
||||
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)
|
||||
|
||||
// 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 {
|
||||
Publish(context.Context, string, []byte, byte, bool) error
|
||||
Subscribe(context.Context, string, byte, Handler) error
|
||||
|
|
@ -35,6 +71,17 @@ type Client interface {
|
|||
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 {
|
||||
Enabled bool
|
||||
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
|
||||
})
|
||||
|
||||
export const testIntegrationConfig = (kind, provider, data) => service({
|
||||
url: `/integration/configs/${encodeURIComponent(kind)}/${encodeURIComponent(provider)}/test`,
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
|
||||
export const deleteIntegrationConfig = (kind, provider) => service({
|
||||
url: `/integration/configs/${encodeURIComponent(kind)}/${encodeURIComponent(provider)}`,
|
||||
method: 'delete'
|
||||
|
|
|
|||
|
|
@ -3,25 +3,36 @@ import service from '@/utils/request'
|
|||
export const getPaymentOrders = (params) => service({
|
||||
url: '/payment/orders',
|
||||
method: 'get',
|
||||
params
|
||||
params,
|
||||
donNotShowLoading: true
|
||||
})
|
||||
|
||||
export const getPaymentOrder = (data) => service({
|
||||
url: '/payment/order',
|
||||
method: 'post',
|
||||
data
|
||||
data,
|
||||
donNotShowLoading: true
|
||||
})
|
||||
|
||||
export const queryPaymentOrder = (data) => service({
|
||||
url: '/payment/query',
|
||||
method: 'post',
|
||||
data
|
||||
data,
|
||||
donNotShowLoading: true
|
||||
})
|
||||
|
||||
export const refundPaymentOrder = (data) => service({
|
||||
url: '/payment/refund',
|
||||
method: 'post',
|
||||
data
|
||||
data,
|
||||
donNotShowLoading: true
|
||||
})
|
||||
|
||||
export const fulfillPaymentOrder = (data) => service({
|
||||
url: '/payment/fulfill',
|
||||
method: 'post',
|
||||
data,
|
||||
donNotShowLoading: true
|
||||
})
|
||||
|
||||
export const testPaymentProvider = (provider) => service({
|
||||
|
|
@ -34,5 +45,6 @@ export const testPaymentProvider = (provider) => service({
|
|||
? { baseURL: `${window.location.origin}/` }
|
||||
: {}),
|
||||
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/index.vue": "SecurityConfig",
|
||||
"/src/view/system/state.vue": "State",
|
||||
"/src/view/systemTools/integration/config.vue": "IntegrationConfig",
|
||||
"/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/system/system.vue": "Config",
|
||||
"/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>
|
||||
<div class="integration-config-page">
|
||||
<div class="page-heading">
|
||||
<div><h2>支付渠道配置</h2><p>统一管理支付渠道凭证、接口地址和默认交易参数</p></div>
|
||||
<el-button :loading="loading" :icon="Refresh" @click="load">刷新</el-button>
|
||||
</div>
|
||||
<div class="kra-table-box payment-config-page">
|
||||
<header class="page-heading">
|
||||
<div>
|
||||
<h2>支付渠道配置</h2>
|
||||
<p>管理渠道凭证、回调地址和测试交易参数。</p>
|
||||
</div>
|
||||
<el-button :icon="Refresh" :loading="loading" :disabled="busy" @click="refreshConfigs">刷新</el-button>
|
||||
</header>
|
||||
|
||||
<div v-loading="loading" class="config-layout">
|
||||
<aside class="provider-panel">
|
||||
<div class="panel-title">渠道 <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)">
|
||||
<span class="provider-copy"><strong>{{ item.name || item.provider }}</strong><small>{{ item.provider }}</small></span>
|
||||
<el-tag :type="item.enabled ? 'success' : 'info'" size="small">{{ item.enabled ? '启用' : '停用' }}</el-tag>
|
||||
<aside class="provider-panel" aria-label="支付渠道列表">
|
||||
<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: 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>
|
||||
</aside>
|
||||
|
||||
<section v-if="selected" class="editor-panel">
|
||||
<div class="editor-heading">
|
||||
<div><div class="editor-title">{{ selected.name || selected.provider }}</div><div class="editor-subtitle">{{ selected.description || `provider: ${selected.provider}` }}</div></div>
|
||||
<el-switch v-model="selected.enabled" active-text="启用渠道" @change="save" />
|
||||
</div>
|
||||
<el-alert v-if="selected.configured" title="密钥字段已脱敏,保留 ****** 表示继续使用当前密钥。" type="info" :closable="false" class="editor-alert" />
|
||||
<el-alert v-else title="该渠道尚未保存,填写字段后保存即可创建配置。" type="warning" :closable="false" class="editor-alert" />
|
||||
<el-form label-position="top" class="config-form">
|
||||
<header class="editor-heading">
|
||||
<div class="editor-title-group">
|
||||
<div class="editor-title-row">
|
||||
<h3>{{ selected.name || providerText(selected.provider) }}</h3>
|
||||
<el-tag v-if="isDirty(selected)" type="warning" effect="plain">未保存</el-tag>
|
||||
<el-tag v-else-if="selected.enabled" type="success" effect="plain">运行中</el-tag>
|
||||
</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">
|
||||
<el-form-item v-for="field in selected.fields" :key="field.key" :label="field.label" :required="field.required">
|
||||
<template #label><span>{{ field.label }}</span><span class="field-key">{{ field.key }}</span></template>
|
||||
<el-select v-if="field.type === 'select'" v-model="selected.config[field.key]" class="field-control" filterable>
|
||||
<el-option v-for="option in field.options" :key="String(option.value)" :label="option.label" :value="option.value" />
|
||||
<el-form-item
|
||||
v-for="field in selected.fields || []"
|
||||
:key="field.key"
|
||||
: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-switch v-else-if="field.type === 'switch'" v-model="selected.config[field.key]" />
|
||||
<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" />
|
||||
<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" />
|
||||
<el-switch
|
||||
v-else-if="field.type === 'switch'"
|
||||
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>
|
||||
</div>
|
||||
</el-form>
|
||||
<div class="editor-actions">
|
||||
<el-button type="primary" :loading="saving" :icon="Check" @click="save">保存配置</el-button>
|
||||
<el-button :loading="testing" :icon="Connection" @click="testProvider">测试渠道</el-button>
|
||||
<el-button v-if="selected.configured" type="danger" plain :icon="Delete" @click="remove">删除配置</el-button>
|
||||
<el-button text :icon="DocumentCopy" @click="copyConfig">复制 JSON</el-button>
|
||||
</div>
|
||||
|
||||
<footer class="editor-actions">
|
||||
<span class="save-state">{{ saveStateText }}</span>
|
||||
<el-button
|
||||
:icon="Connection"
|
||||
: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>
|
||||
|
||||
<el-empty v-else description="暂无支付渠道" />
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
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 { testPaymentProvider } from '@/api/payment'
|
||||
|
||||
const configs = ref([]); const selectedProvider = ref(''); const loading = ref(false); const saving = ref(false); const testing = ref(false)
|
||||
const selected = computed(() => configs.value.find((item) => item.provider === selectedProvider.value) || configs.value[0])
|
||||
const select = (item) => { selectedProvider.value = item.provider }
|
||||
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 } }
|
||||
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 } }
|
||||
const testProvider = async () => {
|
||||
if (!selected.value) return
|
||||
const environment = String(selected.value.config?.environment || '').toLowerCase()
|
||||
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 }
|
||||
defineOptions({ name: 'PaymentConfig' })
|
||||
|
||||
const PROVIDER_NAMES = {
|
||||
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': '抖音小游戏支付'
|
||||
}
|
||||
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 copyConfig = async () => { if (!selected.value) return; await navigator.clipboard.writeText(JSON.stringify(selected.value.config || {}, null, 2)); ElMessage.success('JSON 已复制') }
|
||||
load()
|
||||
const STAGE_NAMES = { config: '配置校验', test_settings: '测试参数', adapter: '渠道适配器', local_order: '本地测试订单', create: '渠道下单', query: '渠道查单', refund: '渠道退款' }
|
||||
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
.integration-config-page { padding: 4px 0 24px; }
|
||||
.page-heading { display: flex; align-items: center; justify-content: space-between; margin-bottom: 18px; }
|
||||
.page-heading h2 { margin: 0; color: var(--el-text-color-primary); font-size: 20px; font-weight: 600; }
|
||||
.page-heading p { margin: 6px 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); }
|
||||
.provider-panel { border-right: 1px solid var(--el-border-color-lighter); padding: 14px 10px; }
|
||||
.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-title span { color: var(--el-text-color-secondary); font-weight: 400; }
|
||||
.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; }
|
||||
.payment-config-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; }
|
||||
.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 { 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: 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: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-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 small { color: var(--el-text-color-secondary); font-size: 11px; }
|
||||
.editor-panel { min-width: 0; padding: 22px 28px 24px; }
|
||||
.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); }
|
||||
.editor-title { color: var(--el-text-color-primary); font-size: 18px; font-weight: 600; }
|
||||
.editor-subtitle { margin-top: 5px; color: var(--el-text-color-secondary); font-size: 13px; }
|
||||
.editor-alert { margin: 18px 0; }
|
||||
.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; }
|
||||
.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-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%; }
|
||||
.editor-actions { display: flex; align-items: center; gap: 10px; padding-top: 8px; border-top: 1px solid var(--el-border-color-lighter); }
|
||||
@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-control { width: 100%; }
|
||||
.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>
|
||||
|
|
|
|||
|
|
@ -1,68 +1,525 @@
|
|||
<template>
|
||||
<div class="payment-orders">
|
||||
<div class="kra-search-box">
|
||||
<el-form :inline="true" :model="searchInfo" @submit.prevent="reload">
|
||||
<el-form-item label="渠道"><el-input v-model="searchInfo.provider" placeholder="alipay / wechat-v3" clearable /></el-form-item>
|
||||
<el-form-item label="商户订单号"><el-input v-model="searchInfo.tradeNo" clearable /></el-form-item>
|
||||
<el-form-item label="业务类型"><el-input v-model="searchInfo.businessType" clearable /></el-form-item>
|
||||
<el-form-item label="业务 ID"><el-input v-model="searchInfo.businessId" clearable /></el-form-item>
|
||||
<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>
|
||||
<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>
|
||||
<header class="page-heading">
|
||||
<div>
|
||||
<h2>支付订单</h2>
|
||||
<p>核对收款、发货与退款状态,处理需要人工介入的订单。</p>
|
||||
</div>
|
||||
<el-button :icon="Refresh" :loading="loading" @click="load()">刷新</el-button>
|
||||
</header>
|
||||
|
||||
<section class="status-summary" aria-label="当前页订单概览">
|
||||
<div v-for="item in pageSummary" :key="item.label" class="summary-item">
|
||||
<span>{{ item.label }}</span>
|
||||
<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>
|
||||
</div>
|
||||
<div class="kra-table-box">
|
||||
<el-table v-loading="loading" :data="rows" row-key="ID" stripe>
|
||||
<el-table-column prop="tradeNo" label="商户订单号" min-width="190" show-overflow-tooltip />
|
||||
<el-table-column prop="provider" label="渠道" width="120" />
|
||||
<el-table-column prop="subject" label="商品/标题" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column label="金额" width="130"><template #default="scope">{{ formatAmount(scope.row.amount, scope.row.currency) }}</template></el-table-column>
|
||||
<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-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="创建时间" width="180"><template #default="scope">{{ formatDate(scope.row.createdAt) }}</template></el-table-column>
|
||||
<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="kra-table-box order-table-band">
|
||||
<div class="table-heading">
|
||||
<div><strong>订单明细</strong><span>共 {{ total }} 笔</span></div>
|
||||
<span v-if="issueCount" class="issue-count">{{ issueCount }} 笔需要关注</span>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="rows" row-key="ID" stripe :row-class-name="rowClassName">
|
||||
<el-table-column label="订单" min-width="250">
|
||||
<template #default="scope">
|
||||
<div class="order-cell">
|
||||
<button type="button" class="order-link" @click="openDetail(scope.row)">{{ scope.row.tradeNo || '-' }}</button>
|
||||
<span>{{ scope.row.subject || '未提供商品标题' }}</span>
|
||||
</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>
|
||||
<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>
|
||||
|
||||
<el-drawer v-model="detailVisible" title="订单详情" size="560px" destroy-on-close>
|
||||
<el-descriptions v-if="detail" :column="2" border>
|
||||
<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>
|
||||
</el-descriptions>
|
||||
<el-drawer v-model="detailVisible" title="支付订单详情" :size="drawerSize" destroy-on-close>
|
||||
<div v-loading="detailLoading" class="order-detail">
|
||||
<template v-if="detail">
|
||||
<div class="detail-toolbar">
|
||||
<div class="detail-statuses">
|
||||
<el-tag :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-dialog v-model="refundVisible" title="申请退款" width="440px" destroy-on-close>
|
||||
<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>
|
||||
<template #footer><el-button @click="refundVisible = false">取消</el-button><el-button type="warning" :loading="refundLoading" @click="submitRefund">确认退款</el-button></template>
|
||||
<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">
|
||||
<div class="refund-summary">
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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 { 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'
|
||||
|
||||
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 })
|
||||
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 refundRules = { amount: [{ required: true, message: '请输入退款金额', trigger: 'blur' }, { validator: (_rule, value, callback) => value > 0 && value <= refundForm.value.maxAmount ? callback() : callback(new Error('退款金额超出可退范围')), trigger: 'change' }] }
|
||||
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')
|
||||
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'
|
||||
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 } }
|
||||
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 refreshOrder = async (row) => { const res = await queryPaymentOrder({ provider: row.provider, tradeNo: row.tradeNo }); if (res.code === 0) { ElMessage.success('已同步支付状态'); await load() } }
|
||||
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 }
|
||||
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('发货接口已预留,待业务模块注册发货处理器后启用。')
|
||||
load()
|
||||
defineOptions({ name: 'PaymentOrders' })
|
||||
|
||||
const PAYMENT_STATUS_META = {
|
||||
initialized: { label: '初始化', type: 'info' }, pending: { label: '待支付', type: 'warning' },
|
||||
paid: { label: '已支付', type: 'success' }, failed: { label: '支付失败', type: 'danger' },
|
||||
closed: { label: '已关闭', type: 'info' }, partially_refunded: { label: '部分退款', type: 'warning' },
|
||||
refunded: { label: '已退款', type: 'info' }
|
||||
}
|
||||
const FULFILLMENT_STATUS_META = {
|
||||
pending: { label: '待发货', type: 'info' }, processing: { label: '发货中', type: 'warning' },
|
||||
succeeded: { label: '已发货', type: 'success' }, failed: { label: '发货失败', type: 'danger' }
|
||||
}
|
||||
const REFUND_STATUS_META = {
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
.payment-orders { min-width: 980px; }
|
||||
.error-text { color: var(--el-color-danger); word-break: break-word; }
|
||||
.form-tip { color: var(--el-text-color-secondary); font-size: 12px; line-height: 1.4; margin-top: 4px; }
|
||||
.payment-orders { min-width: 0; padding-bottom: 24px; }
|
||||
.page-heading { display: flex; align-items: center; justify-content: space-between; gap: 18px; margin-bottom: 16px; }
|
||||
.page-heading h2 { margin: 0; color: var(--el-text-color-primary); font-size: 20px; font-weight: 600; letter-spacing: 0; }
|
||||
.page-heading p { margin: 5px 0 0; color: var(--el-text-color-secondary); font-size: 13px; }
|
||||
.status-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px; overflow: hidden; margin-bottom: 16px; border: 1px solid var(--el-border-color-lighter); border-radius: 6px; background: var(--el-border-color-lighter); }
|
||||
.summary-item { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 3px 10px; min-width: 0; padding: 14px 16px; background: var(--el-bg-color); }
|
||||
.summary-item span { color: var(--el-text-color-regular); font-size: 13px; }
|
||||
.summary-item strong { grid-row: span 2; color: var(--el-text-color-primary); font-size: 24px; font-weight: 600; line-height: 1; }
|
||||
.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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue