Compare commits

...

5 Commits

Author SHA1 Message Date
Yvan 626f505b5d 优化结构 2026-08-21 23:29:28 +08:00
Yvan 5d5784f535 优化结构 2026-08-21 22:54:52 +08:00
Yvan 430feaaff6 优化结构 2026-08-21 22:54:40 +08:00
Yvan e4ba0dced9 优化结构 2026-08-21 21:37:30 +08:00
Yvan a2ce3ae218 优化结构 2026-08-21 19:11:14 +08:00
60 changed files with 5803 additions and 1098 deletions

9
cmd/wire_gen.go generated
View File

@ -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()

1355
docs/OA_MIGRATION_PLAN.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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
View File

@ -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
View File

@ -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=

View File

@ -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`

View File

@ -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},

View File

@ -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")
}
}

View File

@ -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 {

View File

@ -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)
}
}

View File

@ -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"),

513
internal/conf/conf.pb.go generated

File diff suppressed because it is too large Load Diff

View File

@ -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;

View File

@ -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)
}

View File

@ -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)

View File

@ -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)

View File

@ -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
}

View File

@ -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{

View File

@ -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)
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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

View File

@ -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

View File

@ -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: "测试支付渠道"},
},
}
}

View File

@ -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 {

View File

@ -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"])
}
}

View File

@ -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(&current).Error
switch {
case errors.Is(err, gorm.ErrRecordNotFound):
if err = tx.Create(&menu).Error; err != nil {
return err
}
case err != nil:
return err
default:
if err = tx.Model(&current).Updates(map[string]any{
"menu_level": menu.MenuLevel,
"parent_id": menu.ParentID,
"path": menu.Path,
"component": menu.Component,
"title": menu.Title,
"icon": menu.Icon,
"sort": menu.Sort,
}).Error; err != nil {
return err
}
menu.ID = current.ID
}
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
})
}

View File

@ -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(&current).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(&current).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)
}

View File

@ -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)
}
}

View File

@ -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 == "******" }

View File

@ -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`.

View File

@ -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, &currentValues)
}
}
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
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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" {

View File

@ -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)),

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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()
}

View File

@ -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)
}
}

View File

@ -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() {

View File

@ -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

View File

@ -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())

View File

@ -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 {

View File

@ -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)
}
}

View File

@ -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 {

View File

@ -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")
}
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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: "查询支付订单"},

View File

@ -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

447
pkg/mq/rabbitmq.go Normal file
View File

@ -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, "."))
}

48
pkg/mq/rabbitmq_test.go Normal file
View File

@ -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)
}
}
}

55
pkg/mq/subscription.go Normal file
View File

@ -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
}

View File

@ -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'

View File

@ -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
})

View File

@ -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",

View File

@ -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>

View File

@ -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>

View File

@ -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>