优化结构
This commit is contained in:
parent
a2ce3ae218
commit
e4ba0dced9
|
|
@ -154,14 +154,15 @@ func wireApp(confServer *conf.Server, runtime *conf.Runtime, logger *slog.Logger
|
|||
routes := router.NewRoutes(v)
|
||||
taskMethods := worker.NewTaskMethods(taskUsecase, mediaUsecase, runtime)
|
||||
moduleRuntime := app.Runtime(routes, taskMethods, registry)
|
||||
websocketServer, cleanup2, err := websocket.New(runtime)
|
||||
store := data.NewIntegrationRuntime(dataData)
|
||||
websocketServer, cleanup2, err := websocket.New(store)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return nil, nil, err
|
||||
}
|
||||
engine := server.NewGinEngineWithRuntime(runtime, accessControlService, authService, securityService, auditRecorder, logger, string2, moduleRuntime, websocketServer)
|
||||
httpServer := server.NewGinServer(confServer, engine)
|
||||
mqReloadable, cleanup3, err := mq.New(runtime, logger)
|
||||
mqReloadable, cleanup3, err := mq.New(store, logger)
|
||||
if err != nil {
|
||||
cleanup2()
|
||||
cleanup()
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -13,7 +13,7 @@
|
|||
| 支付 provider/mode 标识 | `pkg/paymentkit` | provider 常量、支持列表、金额/签名/JSON 等跨模块协议;system `biz` 只保留兼容别名。 |
|
||||
| 支付回调 ACK | `pkg/paymentkit` | 回调应答、失败包装和默认 provider 应答;具体渠道 SDK 仍留在 system integration。 |
|
||||
| WebSocket 通用收发 | `pkg/websocket` | Melody 的连接、事件、点对点发送、广播和会话查询封装;system integration 管理配置与生命周期。 |
|
||||
| 消息队列 | `pkg/mq` | Broker 无关的发布、订阅、JSON 和 QoS 接口;EMQX/Paho 客户端由 system integration 管理。 |
|
||||
| 消息队列 | `pkg/mq` | Broker 无关的发布、订阅、JSON 和 QoS 接口;EMQX/Paho 与 RabbitMQ/AMQP 客户端由 system integration 管理。 |
|
||||
| 模块、任务和迁移协议 | `pkg/module`、`pkg/task`、`pkg/database/migration` | 供不同业务模块注册贡献,不带 system 业务语义。 |
|
||||
|
||||
## system 内部保留边界
|
||||
|
|
@ -23,7 +23,7 @@
|
|||
- `conf`:system 配置 proto、运行时快照和生成代码。
|
||||
- `data`:数据库连接、PO、仓储、system 表、支付持久化和配置 watcher。
|
||||
- `initialize`:首次安装、配置迁移、种子编排和运行时重载。
|
||||
- `integration`:Redis、邮件、存储、支付、WebSocket 和 EMQX 的 provider 生命周期。
|
||||
- `integration`:Redis、邮件、存储、支付、WebSocket、EMQX 和 RabbitMQ 的 provider 生命周期。
|
||||
- `security`:JWT claims、签发/解析和后台安全实现。
|
||||
- `service`:HTTP DTO(`service/dto`)、DTO 与 DO 转换、应用服务和路由元数据。
|
||||
- `server`:Gin 生命周期;handler、middleware、router、HTTP 适配按子包维护。
|
||||
|
|
|
|||
2
go.mod
2
go.mod
|
|
@ -28,6 +28,7 @@ require (
|
|||
github.com/mojocn/base64Captcha v1.3.8
|
||||
github.com/olahol/melody v1.4.0
|
||||
github.com/qiniu/go-sdk/v7 v7.25.2
|
||||
github.com/rabbitmq/amqp091-go v1.14.0
|
||||
github.com/redis/go-redis/v9 v9.7.0
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/shirou/gopsutil/v4 v4.25.7
|
||||
|
|
@ -143,7 +144,6 @@ require (
|
|||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
||||
github.com/rabbitmq/amqp091-go v1.14.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/richardlehane/mscfb v1.0.4 // indirect
|
||||
github.com/richardlehane/msoleps v1.0.4 // indirect
|
||||
|
|
|
|||
|
|
@ -5,10 +5,10 @@
|
|||
|
||||
- `app`:组合根、system 模块定义和任务/路由运行时组合
|
||||
- `biz`:系统领域对象、用例和仓储接口
|
||||
- `conf`:配置 proto 与运行时配置解析
|
||||
- `conf`:基础配置 proto 与运行时配置解析
|
||||
- `data`:数据库生命周期、系统仓储、系统表和支付持久化
|
||||
- `initialize`:数据库首次初始化和系统种子数据编排
|
||||
- `integration`:Redis、邮件、对象存储、支付、WebSocket 和 EMQX 适配器
|
||||
- `integration`:Redis、邮件、对象存储、支付、WebSocket、EMQX 和 RabbitMQ 适配器
|
||||
- `security`:后台 JWT 等安全实现
|
||||
- `server`:Gin server 组合与生命周期;横切 HTTP 代码按子包维护:
|
||||
`server/handler`、`server/middleware`、`server/router`、`server/httpx`
|
||||
|
|
|
|||
|
|
@ -9,10 +9,22 @@ 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", Method: "DELETE", Group: "集成配置", Description: "删除集成配置"},
|
||||
},
|
||||
}
|
||||
surface.Menus = append(surface.Menus, communication.Menus...)
|
||||
surface.APIs = append(surface.APIs, communication.APIs...)
|
||||
return module.Definition{
|
||||
Name: "system",
|
||||
Migrations: append(datasystem.Migrations(), datapayment.Migrations()...),
|
||||
Surface: datapayment.AdminSurface(),
|
||||
Surface: surface,
|
||||
TimedTasks: []module.TimedTask{
|
||||
{Name: "ClearDB", Description: "定时清理数据库过期日志(操作记录/JWT黑名单/定时任务执行日志)", Spec: "@daily", MethodName: "ClearDB", Enabled: true},
|
||||
{Name: "CleanStaleUploads", Description: "定时清理过期大文件上传会话", Spec: "@hourly", MethodName: "CleanStaleUploads", Enabled: true},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
package app
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDefinitionIncludesCommunicationIntegrationSurface(t *testing.T) {
|
||||
surface := Definition().Surface
|
||||
menuFound := false
|
||||
for _, menu := range surface.Menus {
|
||||
if menu.Name == "integrationConfig" {
|
||||
menuFound = menu.ParentName == "extensions" && menu.Component == "view/systemTools/integration/config.vue"
|
||||
break
|
||||
}
|
||||
}
|
||||
if !menuFound {
|
||||
t.Fatal("communication integration menu is missing")
|
||||
}
|
||||
apiFound := false
|
||||
for _, api := range surface.APIs {
|
||||
if api.Method == "PUT" && api.Path == "/integration/configs/:kind/:provider" {
|
||||
apiFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !apiFound {
|
||||
t.Fatal("communication integration save API is missing")
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
@ -195,8 +196,8 @@ func validateCommunicationIntegrationConfig(kind, provider string, values map[st
|
|||
return errors.New("rabbitmq port 必须在 1-65535 之间")
|
||||
}
|
||||
exchangeType := strings.ToLower(integrationText(values, "exchange_type"))
|
||||
if exchangeType != "direct" && exchangeType != "fanout" && exchangeType != "topic" && exchangeType != "headers" {
|
||||
return errors.New("rabbitmq exchange_type 必须是 direct、fanout、topic 或 headers")
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
package biz
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCommunicationIntegrationDefinitionsAndValidation(t *testing.T) {
|
||||
for _, target := range []struct{ kind, provider string }{
|
||||
{IntegrationKindMQ, "emqx"},
|
||||
{IntegrationKindMQ, "rabbitmq"},
|
||||
{IntegrationKindWebSocket, "melody"},
|
||||
} {
|
||||
values := DefaultIntegrationConfig(target.kind, target.provider)
|
||||
if len(values) == 0 {
|
||||
t.Fatalf("default config missing for %s/%s", target.kind, target.provider)
|
||||
}
|
||||
if err := ValidateIntegrationConfig(target.kind, target.provider, values); err != nil {
|
||||
t.Fatalf("default config invalid for %s/%s: %v", target.kind, target.provider, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommunicationIntegrationValidationRejectsInvalidValues(t *testing.T) {
|
||||
rabbit := DefaultIntegrationConfig(IntegrationKindMQ, "rabbitmq")
|
||||
rabbit["port"] = 0
|
||||
if err := ValidateIntegrationConfig(IntegrationKindMQ, "rabbitmq", rabbit); err == nil {
|
||||
t.Fatal("invalid rabbitmq port was accepted")
|
||||
}
|
||||
websocket := DefaultIntegrationConfig(IntegrationKindWebSocket, "melody")
|
||||
websocket["path"] = "ws"
|
||||
if err := ValidateIntegrationConfig(IntegrationKindWebSocket, "melody", websocket); err == nil {
|
||||
t.Fatal("invalid websocket path was accepted")
|
||||
}
|
||||
}
|
||||
|
|
@ -113,7 +113,7 @@ var integrationDefinitions = map[string][]IntegrationConfigDefinition{
|
|||
{Key: "password", Label: "密码", Type: "password", Required: true, Secret: true},
|
||||
{Key: "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"}, {Label: "headers", Value: "headers"}}},
|
||||
{Key: "exchange_type", Label: "Exchange 类型", Type: "select", Required: true, Options: []IntegrationConfigOption{{Label: "topic", Value: "topic"}, {Label: "direct", Value: "direct"}, {Label: "fanout", Value: "fanout"}}},
|
||||
{Key: "queue", Label: "Queue", Type: "text", Required: true, Placeholder: "kra"},
|
||||
{Key: "routing_key", Label: "默认 Routing Key", Type: "text", Required: true, Placeholder: "#", Description: "业务未指定订阅键时使用;topic 类型支持 * 和 #。"},
|
||||
{Key: "durable", Label: "持久化", Type: "switch"},
|
||||
|
|
|
|||
|
|
@ -194,8 +194,6 @@ func (d *Data) persistConfigValuesLocked(dataConfig *conf.Data, adminConfig *con
|
|||
}
|
||||
deleteYAMLMapping(&document, "admin", "storage")
|
||||
deleteYAMLMapping(&document, "admin", "email")
|
||||
deleteYAMLMapping(&document, "admin", "websocket")
|
||||
deleteYAMLMapping(&document, "admin", "mq")
|
||||
if adminConfig.System != nil {
|
||||
if err = setServerHTTPPort(&document, adminConfig.System.Addr); err != nil {
|
||||
return err
|
||||
|
|
@ -255,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)
|
||||
}
|
||||
|
||||
|
|
@ -275,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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,13 +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/internal/integrationruntime"
|
||||
"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)),
|
||||
|
|
@ -34,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
|
||||
|
|
@ -42,7 +50,7 @@ type Data struct {
|
|||
redis *reloadableRedis
|
||||
mongo *reloadableMongo
|
||||
runtime *conf.Runtime
|
||||
integrations *integrationruntime.Store
|
||||
integrations *runtimeconfig.Store
|
||||
storage *storage.Reloadable
|
||||
dbListMu sync.RWMutex
|
||||
dbList map[string]*gorm.DB
|
||||
|
|
@ -77,7 +85,7 @@ func (d *Data) Runtime() *conf.Runtime {
|
|||
|
||||
// IntegrationRuntime exposes database-backed integration configuration to
|
||||
// long-lived adapters without making config.yaml part of their lifecycle.
|
||||
func (d *Data) IntegrationRuntime() *integrationruntime.Store {
|
||||
func (d *Data) IntegrationRuntime() *runtimeconfig.Store {
|
||||
if d == nil {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -167,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, integrations: integrationruntime.NewStore(), appLogger: appLogger, storage: storageManager, catalog: catalog}
|
||||
d := &Data{runtime: runtime, integrations: runtimeconfig.NewStore(), appLogger: appLogger, storage: storageManager, catalog: catalog}
|
||||
usingFallback := !databaseConnectionConfigured(c.Database)
|
||||
var db *gorm.DB
|
||||
var err error
|
||||
|
|
|
|||
|
|
@ -123,25 +123,6 @@ func TestStorageIntegrationConfigRoundTrip(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestMQIntegrationConfigRoundTrip(t *testing.T) {
|
||||
db := openIntegrationConfigTestDB(t)
|
||||
legacy := &conf.AdminBackend_MQ{Enabled: true, Broker: "mqtt://emqx.example.com:1883", ClientId: "system", Username: "app", Password: "secret", KeepAlive: 45, CleanSession: true, ConnectTimeout: 12}
|
||||
if err := saveMQIntegrationConfig(db, legacy); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, found, err := loadMQIntegrationConfig(db)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("found=%v err=%v", found, err)
|
||||
}
|
||||
if loaded.Broker != legacy.Broker || loaded.Password != legacy.Password || loaded.KeepAlive != 45 {
|
||||
t.Fatalf("loaded mq = %#v", loaded)
|
||||
}
|
||||
loaded, err = resolveMQIntegrationConfig(db, &conf.AdminBackend_MQ{Broker: "must-not-replace"})
|
||||
if err != nil || loaded.Broker != legacy.Broker {
|
||||
t.Fatalf("database mq was replaced: %#v err=%v", loaded, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveStorageIntegrationConfigMigratesLegacyOnlyOnce(t *testing.T) {
|
||||
db := openIntegrationConfigTestDB(t)
|
||||
legacy := &conf.AdminBackend_Storage{
|
||||
|
|
@ -205,7 +186,7 @@ func TestResolveEmailIntegrationConfigMigratesLegacyOnlyOnce(t *testing.T) {
|
|||
|
||||
func TestPersistConfigValuesRemovesStorageFromYAML(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||
input := []byte("data: {}\nadmin:\n router_prefix: /old\n storage:\n type: qiniu\n qiniu:\n secret_key: legacy-secret\n email:\n host: smtp.legacy.example.com\n secret: legacy-email-secret\n mq:\n enabled: true\n broker: tcp://localhost:1883\n password: legacy-mq-secret\n extension_key: retained\n")
|
||||
input := []byte("data: {}\nadmin:\n router_prefix: /old\n storage:\n type: qiniu\n qiniu:\n secret_key: legacy-secret\n email:\n host: smtp.legacy.example.com\n secret: legacy-email-secret\n extension_key: retained\n")
|
||||
if err := os.WriteFile(path, input, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
|
@ -218,7 +199,6 @@ func TestPersistConfigValuesRemovesStorageFromYAML(t *testing.T) {
|
|||
Qiniu: &conf.AdminBackend_Qiniu{SecretKey: "database-only-secret"},
|
||||
},
|
||||
Email: &conf.AdminBackend_Email{Host: "smtp.database.example.com", Secret: "database-only-email-secret"},
|
||||
Mq: &conf.AdminBackend_MQ{Enabled: true, Broker: "tcp://emqx:1883", Password: "database-only-mq-secret"},
|
||||
}
|
||||
if err := d.persistConfigValues(&conf.Data{}, admin); err != nil {
|
||||
t.Fatal(err)
|
||||
|
|
@ -242,9 +222,6 @@ func TestPersistConfigValuesRemovesStorageFromYAML(t *testing.T) {
|
|||
if _, exists := adminValue["email"]; exists {
|
||||
t.Fatalf("email remained in YAML: %s", raw)
|
||||
}
|
||||
if _, exists := adminValue["mq"]; exists {
|
||||
t.Fatalf("mq remained in YAML: %s", raw)
|
||||
}
|
||||
if adminValue["extension_key"] != "retained" {
|
||||
t.Fatalf("extension key was not retained: %#v", adminValue)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
package data
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"kra/internal/biz"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func ensureCommunicationIntegrationConfigs(db *gorm.DB) error {
|
||||
defaults := []struct {
|
||||
kind string
|
||||
provider string
|
||||
}{
|
||||
{kind: biz.IntegrationKindMQ, provider: "emqx"},
|
||||
{kind: biz.IntegrationKindMQ, provider: "rabbitmq"},
|
||||
{kind: biz.IntegrationKindWebSocket, provider: "melody"},
|
||||
}
|
||||
for _, item := range defaults {
|
||||
var row integrationConfigPO
|
||||
err := db.Where("kind = ? AND provider = ?", item.kind, item.provider).First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
values, marshalErr := json.Marshal(biz.DefaultIntegrationConfig(item.kind, item.provider))
|
||||
if marshalErr != nil {
|
||||
return marshalErr
|
||||
}
|
||||
if err = db.Create(&integrationConfigPO{Kind: item.kind, Provider: item.provider, Enabled: false, Config: string(values)}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -3,12 +3,12 @@ package data
|
|||
import (
|
||||
"errors"
|
||||
|
||||
"kra/internal/integrationruntime"
|
||||
"kra/internal/integration/runtimeconfig"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func readIntegrationRuntime(db *gorm.DB) ([]integrationruntime.Config, error) {
|
||||
func readIntegrationRuntime(db *gorm.DB) ([]runtimeconfig.Config, error) {
|
||||
if db == nil || !db.Migrator().HasTable(&integrationConfigPO{}) {
|
||||
return nil, nil
|
||||
}
|
||||
|
|
@ -19,9 +19,9 @@ func readIntegrationRuntime(db *gorm.DB) ([]integrationruntime.Config, error) {
|
|||
Find(&rows).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
configs := make([]integrationruntime.Config, 0, len(rows))
|
||||
configs := make([]runtimeconfig.Config, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
configs = append(configs, integrationruntime.Config{Kind: row.Kind, Provider: row.Provider, Enabled: row.Enabled, Values: []byte(row.Config)})
|
||||
configs = append(configs, runtimeconfig.Config{Kind: row.Kind, Provider: row.Provider, Enabled: row.Enabled, Values: []byte(row.Config)})
|
||||
}
|
||||
return configs, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,12 +10,15 @@ import (
|
|||
)
|
||||
|
||||
func InfrastructureMigrations() []migration.Step {
|
||||
return []migration.Step{{
|
||||
ID: "202608200001_data_infrastructure",
|
||||
Migrate: func(db *gorm.DB) error {
|
||||
return migration.CreateMissingTables(db, &integrationConfigPO{})
|
||||
return []migration.Step{
|
||||
{
|
||||
ID: "202608200001_data_infrastructure",
|
||||
Migrate: func(db *gorm.DB) error {
|
||||
return migration.CreateMissingTables(db, &integrationConfigPO{})
|
||||
},
|
||||
},
|
||||
}}
|
||||
{ID: "202608210001_communication_integration_defaults", Migrate: ensureCommunicationIntegrationConfigs},
|
||||
}
|
||||
}
|
||||
|
||||
// migrateAll is the single data-layer migration entry point. Module-specific
|
||||
|
|
|
|||
|
|
@ -26,8 +26,20 @@ func TestMigrateAllRunsModuleSchemasWithoutBootstrapSeed(t *testing.T) {
|
|||
if err = db.Table(migration.TableName).Count(&versions).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if versions != 4 {
|
||||
t.Fatalf("migration versions = %d, want 4", versions)
|
||||
if versions != 6 {
|
||||
t.Fatalf("migration versions = %d, want 6", versions)
|
||||
}
|
||||
var communicationRows []integrationConfigPO
|
||||
if err = db.Where("kind IN ?", []string{"mq", "websocket"}).Order("kind, provider").Find(&communicationRows).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(communicationRows) != 3 {
|
||||
t.Fatalf("communication integration rows = %d, want 3", len(communicationRows))
|
||||
}
|
||||
for _, row := range communicationRows {
|
||||
if row.Enabled || row.Config == "" {
|
||||
t.Fatalf("default communication integration = %#v", row)
|
||||
}
|
||||
}
|
||||
for _, table := range []string{"sys_users", "sys_base_menus", "sys_apis"} {
|
||||
var count int64
|
||||
|
|
|
|||
|
|
@ -26,12 +26,16 @@ func AdminSurface() platformmodule.Surface {
|
|||
{Name: "paymentConfig", Path: "paymentConfig", ParentName: "extensions", Component: "view/systemTools/payment/config.vue", Title: "支付配置", Icon: "credit-card", Sort: 7},
|
||||
},
|
||||
APIs: []platformmodule.API{
|
||||
{Path: "/integration/configs/:kind", Method: "GET", Group: "集成配置", Description: "按类型获取集成配置"},
|
||||
{Path: "/integration/configs/:kind/:provider", Method: "GET", Group: "集成配置", Description: "获取指定集成配置"},
|
||||
{Path: "/integration/configs/:kind/:provider", Method: "PUT", Group: "集成配置", Description: "保存集成配置"},
|
||||
{Path: "/integration/configs/:kind/:provider", Method: "DELETE", Group: "集成配置", Description: "删除集成配置"},
|
||||
{Path: "/payment/orders", Method: "GET", Group: "支付", Description: "分页查询支付订单"},
|
||||
{Path: "/payment/order", Method: "POST", Group: "支付", Description: "查询支付订单"},
|
||||
{Path: "/payment/orders/:provider/:tradeNo", Method: "GET", Group: "支付", Description: "按路径查询支付订单"},
|
||||
{Path: "/payment/create", Method: "POST", Group: "支付", Description: "创建支付订单"},
|
||||
{Path: "/payment/query", Method: "POST", Group: "支付", Description: "同步支付订单状态"},
|
||||
{Path: "/payment/refund", Method: "POST", Group: "支付", Description: "申请支付订单退款"},
|
||||
{Path: "/payment/orders/:provider/:tradeNo/refund", Method: "POST", Group: "支付", Description: "按路径申请支付订单退款"},
|
||||
{Path: "/payment/fulfill", Method: "POST", Group: "支付", Description: "重试支付订单发货"},
|
||||
{Path: "/payment/orders/:provider/:tradeNo/fulfill", Method: "POST", Group: "支付", Description: "按路径重试支付订单发货"},
|
||||
{Path: "/payment/providers/:provider/test", Method: "POST", Group: "支付", Description: "测试支付渠道"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import (
|
|||
"time"
|
||||
|
||||
"kra/internal/biz"
|
||||
"kra/internal/integrationruntime"
|
||||
"kra/internal/integration/runtimeconfig"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
|
@ -27,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}
|
||||
}
|
||||
|
|
@ -93,18 +97,25 @@ func (r *integrationConfigRepo) DeleteIntegrationConfig(ctx context.Context, kin
|
|||
if err := r.data.DB().WithContext(ctx).Where("kind = ? AND provider = ?", kind, provider).Delete(&integrationConfigPO{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime := r.data.IntegrationRuntime(); runtime != nil {
|
||||
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 := r.data.IntegrationRuntime(); runtime != nil {
|
||||
runtime.Set(integrationruntime.Config{Kind: kind, Provider: provider, Enabled: enabled, Values: values})
|
||||
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 {
|
||||
values := integrationObject(json.RawMessage(row.Config))
|
||||
maskIntegrationSecrets(row.Kind, row.Provider, values)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
package system
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"kra/internal/biz"
|
||||
"kra/internal/integration/runtimeconfig"
|
||||
)
|
||||
|
||||
type integrationRuntimeTestProvider struct {
|
||||
*Data
|
||||
store *runtimeconfig.Store
|
||||
}
|
||||
|
||||
func (p *integrationRuntimeTestProvider) IntegrationRuntime() *runtimeconfig.Store { return p.store }
|
||||
|
||||
func TestIntegrationConfigSavePublishesUnmaskedRuntimeValues(t *testing.T) {
|
||||
db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&integrationConfigPO{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
provider := &integrationRuntimeTestProvider{Data: &Data{gormDB: newReloadableDB(db, nil)}, store: runtimeconfig.NewStore()}
|
||||
repo := &integrationConfigRepo{data: provider}
|
||||
|
||||
values := biz.DefaultIntegrationConfig(biz.IntegrationKindMQ, "rabbitmq")
|
||||
values["password"] = "runtime-secret"
|
||||
raw, _ := json.Marshal(values)
|
||||
if err = repo.SaveIntegrationConfig(context.Background(), &biz.IntegrationConfig{Kind: biz.IntegrationKindMQ, Provider: "rabbitmq", Enabled: true, Values: raw}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
values["password"] = "******"
|
||||
raw, _ = json.Marshal(values)
|
||||
if err = repo.SaveIntegrationConfig(context.Background(), &biz.IntegrationConfig{Kind: biz.IntegrationKindMQ, Provider: "rabbitmq", Enabled: true, Values: raw}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
active, ok := provider.store.Get(biz.IntegrationKindMQ, "rabbitmq")
|
||||
if !ok || !active.Enabled {
|
||||
t.Fatalf("runtime config = %#v, ok=%v", active, ok)
|
||||
}
|
||||
stored := map[string]any{}
|
||||
if err = json.Unmarshal(active.Values, &stored); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored["password"] != "runtime-secret" {
|
||||
t.Fatalf("runtime password = %#v", stored["password"])
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,6 @@ package system
|
|||
|
||||
import (
|
||||
"kra/internal/conf"
|
||||
"kra/internal/integrationruntime"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
|
@ -14,5 +13,4 @@ type Provider interface {
|
|||
Database(name string) (*gorm.DB, error)
|
||||
DatabaseReady() bool
|
||||
Runtime() *conf.Runtime
|
||||
IntegrationRuntime() *integrationruntime.Store
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ state, or translate provider-specific protocols into `biz` interfaces.
|
|||
|
||||
- `cache`: Redis-backed cache with an in-memory fallback.
|
||||
- `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`.
|
||||
|
|
|
|||
|
|
@ -2,140 +2,294 @@ 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 = "emqx"
|
||||
ProviderRabbitMQ = "rabbitmq"
|
||||
)
|
||||
|
||||
// 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
|
||||
subscriptions map[string]map[string]subscription
|
||||
stop []func()
|
||||
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),
|
||||
subscriptions: make(map[string]map[string]subscription),
|
||||
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) }),
|
||||
)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
func (r *Reloadable) apply(provider string, config runtimeconfig.Config) {
|
||||
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)
|
||||
if !config.Enabled {
|
||||
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
|
||||
}
|
||||
client, err := newProviderClient(provider, config.Values)
|
||||
if err != nil {
|
||||
r.logger.Warn("message integration unavailable", "mod", "mq", "provider", provider, "error", err)
|
||||
return
|
||||
}
|
||||
if err = r.restoreSubscriptionsLocked(provider, client); err != nil {
|
||||
_ = client.Close()
|
||||
r.logger.Warn("restore message subscriptions failed", "mod", "mq", "provider", provider, "error", err)
|
||||
return
|
||||
}
|
||||
r.replaceClientLocked(provider, client)
|
||||
}
|
||||
|
||||
func newProviderClient(provider string, raw json.RawMessage) (platformmq.Client, error) {
|
||||
values := map[string]any{}
|
||||
if err := json.Unmarshal(raw, &values); err != nil {
|
||||
return nil, fmt.Errorf("decode %s configuration: %w", provider, err)
|
||||
}
|
||||
switch provider {
|
||||
case ProviderEMQX:
|
||||
return platformmq.NewMQTT(platformmq.Config{
|
||||
Enabled: true,
|
||||
Broker: configText(values, "broker"),
|
||||
ClientID: configText(values, "client_id"),
|
||||
Username: configText(values, "username"),
|
||||
Password: configText(values, "password"),
|
||||
KeepAlive: configSeconds(values, "keep_alive"),
|
||||
CleanSession: configBool(values, "clean_session"),
|
||||
ConnectTimeout: configSeconds(values, "connect_timeout"),
|
||||
})
|
||||
case ProviderRabbitMQ:
|
||||
return platformmq.NewRabbitMQ(platformmq.RabbitMQConfig{
|
||||
Enabled: true,
|
||||
Host: configText(values, "host"),
|
||||
Port: configInt(values, "port"),
|
||||
Username: configText(values, "username"),
|
||||
Password: configText(values, "password"),
|
||||
VHost: configText(values, "vhost"),
|
||||
Exchange: configText(values, "exchange"),
|
||||
ExchangeType: configText(values, "exchange_type"),
|
||||
Queue: configText(values, "queue"),
|
||||
RoutingKey: configText(values, "routing_key"),
|
||||
Durable: configBool(values, "durable"),
|
||||
AutoDelete: configBool(values, "auto_delete"),
|
||||
PrefetchCount: configInt(values, "prefetch_count"),
|
||||
Heartbeat: configSeconds(values, "heartbeat"),
|
||||
ConnectTimeout: configSeconds(values, "connect_timeout"),
|
||||
TLS: configBool(values, "tls"),
|
||||
})
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported message provider %q", provider)
|
||||
}
|
||||
}
|
||||
|
||||
func configText(values map[string]any, key string) string {
|
||||
value, ok := values[key]
|
||||
if !ok || value == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(fmt.Sprint(value))
|
||||
}
|
||||
|
||||
func configInt(values map[string]any, key string) int {
|
||||
switch value := values[key].(type) {
|
||||
case float64:
|
||||
return int(value)
|
||||
case int:
|
||||
return value
|
||||
case json.Number:
|
||||
parsed, _ := strconv.Atoi(string(value))
|
||||
return parsed
|
||||
default:
|
||||
parsed, _ := strconv.Atoi(configText(values, key))
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
|
||||
func configSeconds(values map[string]any, key string) time.Duration {
|
||||
seconds := configInt(values, key)
|
||||
if seconds <= 0 {
|
||||
return 0
|
||||
}
|
||||
return time.Duration(seconds) * time.Second
|
||||
}
|
||||
|
||||
func configBool(values map[string]any, key string) bool {
|
||||
value, _ := values[key].(bool)
|
||||
return value
|
||||
}
|
||||
|
||||
func (r *Reloadable) replaceClientLocked(provider string, next platformmq.Client) {
|
||||
r.mu.Lock()
|
||||
old := r.current
|
||||
r.current = client
|
||||
old := r.clients[provider]
|
||||
if next == nil {
|
||||
delete(r.clients, 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 {
|
||||
func (r *Reloadable) restoreSubscriptionsLocked(provider string, client platformmq.Client) error {
|
||||
for topic, item := range r.subscriptions[provider] {
|
||||
if err := client.Subscribe(context.Background(), topic, item.qos, item.handler); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
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) 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) Subscribe(ctx context.Context, topic string, qos byte, handler mq.Handler) error {
|
||||
|
||||
func (r *Reloadable) Client(provider string) platformmq.Client {
|
||||
provider = strings.ToLower(strings.TrimSpace(provider))
|
||||
if provider != ProviderEMQX && provider != ProviderRabbitMQ {
|
||||
return nil
|
||||
}
|
||||
return &namedClient{owner: r, provider: provider}
|
||||
}
|
||||
|
||||
func (c *namedClient) Publish(ctx context.Context, topic string, payload []byte, qos byte, retain bool) error {
|
||||
return c.owner.PublishTo(ctx, c.provider, topic, payload, qos, retain)
|
||||
}
|
||||
func (c *namedClient) Subscribe(ctx context.Context, topic string, qos byte, handler platformmq.Handler) error {
|
||||
return c.owner.SubscribeTo(ctx, c.provider, topic, qos, handler)
|
||||
}
|
||||
func (c *namedClient) Unsubscribe(ctx context.Context, topics ...string) error {
|
||||
return c.owner.UnsubscribeFrom(ctx, c.provider, topics...)
|
||||
}
|
||||
func (c *namedClient) Connected() bool { return c.owner.ConnectedTo(c.provider) }
|
||||
func (*namedClient) Close() error { return nil }
|
||||
|
||||
func (r *Reloadable) Publish(ctx context.Context, topic string, payload []byte, qos byte, retain bool) error {
|
||||
return r.PublishTo(ctx, ProviderEMQX, topic, payload, qos, retain)
|
||||
}
|
||||
|
||||
func (r *Reloadable) PublishTo(ctx context.Context, provider, topic string, payload []byte, qos byte, retain bool) error {
|
||||
provider = strings.ToLower(strings.TrimSpace(provider))
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
client := r.clients[provider]
|
||||
if client == nil {
|
||||
return platformmq.ErrUnavailable
|
||||
}
|
||||
return client.Publish(ctx, topic, payload, qos, retain)
|
||||
}
|
||||
|
||||
func (r *Reloadable) Subscribe(ctx context.Context, topic string, qos byte, handler platformmq.Handler) error {
|
||||
return r.SubscribeTo(ctx, ProviderEMQX, topic, qos, handler)
|
||||
}
|
||||
|
||||
func (r *Reloadable) SubscribeTo(ctx context.Context, provider, topic string, qos byte, handler platformmq.Handler) error {
|
||||
provider = strings.ToLower(strings.TrimSpace(provider))
|
||||
r.opMu.Lock()
|
||||
defer r.opMu.Unlock()
|
||||
c := r.client()
|
||||
if c == nil {
|
||||
return mq.ErrUnavailable
|
||||
client := r.client(provider)
|
||||
if client == nil {
|
||||
return platformmq.ErrUnavailable
|
||||
}
|
||||
if err := c.Subscribe(ctx, topic, qos, handler); err != nil {
|
||||
if err := client.Subscribe(ctx, topic, qos, handler); err != nil {
|
||||
return err
|
||||
}
|
||||
r.subscriptions[topic] = subscription{qos: qos, handler: handler}
|
||||
if r.subscriptions[provider] == nil {
|
||||
r.subscriptions[provider] = make(map[string]subscription)
|
||||
}
|
||||
r.subscriptions[provider][topic] = subscription{qos: qos, handler: handler}
|
||||
return nil
|
||||
}
|
||||
|
||||
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()
|
||||
c := r.client()
|
||||
if c == nil {
|
||||
return mq.ErrUnavailable
|
||||
client := r.client(provider)
|
||||
if client == nil {
|
||||
return platformmq.ErrUnavailable
|
||||
}
|
||||
if err := c.Unsubscribe(ctx, topics...); err != nil {
|
||||
if err := client.Unsubscribe(ctx, topics...); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, topic := range topics {
|
||||
delete(r.subscriptions, topic)
|
||||
delete(r.subscriptions[provider], topic)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (r *Reloadable) Connected() bool { c := r.client(); return c != nil && c.Connected() }
|
||||
|
||||
func (r *Reloadable) Connected() bool { return r.ConnectedTo(ProviderEMQX) }
|
||||
|
||||
func (r *Reloadable) ConnectedTo(provider string) bool {
|
||||
provider = strings.ToLower(strings.TrimSpace(provider))
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
client := r.clients[provider]
|
||||
return client != nil && client.Connected()
|
||||
}
|
||||
|
||||
func (r *Reloadable) Close() error {
|
||||
r.opMu.Lock()
|
||||
defer r.opMu.Unlock()
|
||||
|
|
@ -144,11 +298,16 @@ func (r *Reloadable) Close() error {
|
|||
}
|
||||
r.closed = true
|
||||
r.mu.Lock()
|
||||
old := r.current
|
||||
r.current = nil
|
||||
clients := make([]platformmq.Client, 0, len(r.clients))
|
||||
for provider, client := range r.clients {
|
||||
clients = append(clients, client)
|
||||
delete(r.clients, provider)
|
||||
}
|
||||
r.mu.Unlock()
|
||||
if old != nil {
|
||||
return old.Close()
|
||||
for _, client := range clients {
|
||||
if client != nil {
|
||||
_ = client.Close()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,28 +26,33 @@ func (*fakeClient) Close() error { return nil }
|
|||
|
||||
func TestReloadableTracksSubscriptions(t *testing.T) {
|
||||
client := &fakeClient{}
|
||||
r := &Reloadable{current: client, subscriptions: make(map[string]subscription)}
|
||||
r := &Reloadable{
|
||||
clients: map[string]platformmq.Client{ProviderEMQX: client},
|
||||
subscriptions: make(map[string]map[string]subscription),
|
||||
}
|
||||
handler := func(context.Context, platformmq.Message) {}
|
||||
if err := r.Subscribe(context.Background(), "orders/+/paid", platformmq.AtLeastOnce, handler); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := r.subscriptions["orders/+/paid"]; !ok {
|
||||
if _, ok := r.subscriptions[ProviderEMQX]["orders/+/paid"]; !ok {
|
||||
t.Fatal("subscription was not retained for configuration reload")
|
||||
}
|
||||
if err := r.Unsubscribe(context.Background(), "orders/+/paid"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := r.subscriptions["orders/+/paid"]; ok {
|
||||
if _, ok := r.subscriptions[ProviderEMQX]["orders/+/paid"]; ok {
|
||||
t.Fatal("unsubscribed topic remained in the reload registry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReloadableRestoresSubscriptions(t *testing.T) {
|
||||
client := &fakeClient{}
|
||||
r := &Reloadable{subscriptions: map[string]subscription{
|
||||
"orders/+/paid": {qos: platformmq.AtLeastOnce, handler: func(context.Context, platformmq.Message) {}},
|
||||
r := &Reloadable{subscriptions: map[string]map[string]subscription{
|
||||
ProviderEMQX: {
|
||||
"orders/+/paid": {qos: platformmq.AtLeastOnce, handler: func(context.Context, platformmq.Message) {}},
|
||||
},
|
||||
}}
|
||||
if err := r.restoreSubscriptions(context.Background(), client); err != nil {
|
||||
if err := r.restoreSubscriptionsLocked(ProviderEMQX, client); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(client.subscribed) != 1 || client.subscribed[0] != "orders/+/paid" {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ var ProviderSet = wire.NewSet(
|
|||
storage.NewFileStorage,
|
||||
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)),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// Package integrationruntime keeps the active database-backed integration
|
||||
// settings and notifies long-lived provider clients when they change.
|
||||
package integrationruntime
|
||||
// Package runtimeconfig keeps the active database-backed integration settings
|
||||
// and notifies long-lived provider clients when they change.
|
||||
package runtimeconfig
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package integrationruntime
|
||||
package runtimeconfig
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
|
@ -1,19 +1,23 @@
|
|||
package websocket
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
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 +26,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 +52,20 @@ 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
|
||||
}
|
||||
|
||||
func (s *Server) apply(config runtimeconfig.Config) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
if config == nil {
|
||||
config = &conf.AdminBackend_WebSocket{}
|
||||
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 +82,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 +110,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 +188,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 +202,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 +273,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 +285,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 +297,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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -2,10 +2,14 @@ package middleware
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"mime"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
|
@ -67,14 +71,26 @@ func AccessLog(runtime *conf.Runtime, logger *slog.Logger, version string) gin.H
|
|||
if config != nil && config.Zap != nil && config.Zap.AccessLogMaxBytes > 0 {
|
||||
logLimit = int(config.Zap.AccessLogMaxBytes)
|
||||
}
|
||||
paymentCallback := isPaymentCallbackPath(c.Request.URL.Path)
|
||||
paymentConfigWrite := isPaymentIntegrationConfigWrite(c.Request.Method, c.Request.URL.Path)
|
||||
requestText := ""
|
||||
if multipart {
|
||||
if paymentCallback {
|
||||
requestText = paymentCallbackSummary(requestBody, c.GetHeader("Content-Type"))
|
||||
} else if paymentConfigWrite {
|
||||
requestText = paymentConfigSummary(requestBody)
|
||||
} else if multipart {
|
||||
requestText = "[文件]"
|
||||
} else {
|
||||
requestText = redactJSON(requestBody, c.GetHeader("Content-Type"), logLimit)
|
||||
}
|
||||
c.Set(ctxReqBodyKey, requestText)
|
||||
c.Set(ctxRespBufferKey, &writer.body)
|
||||
if paymentCallback {
|
||||
// Callback acknowledgements and provider payloads must not flow into
|
||||
// the generic response/error audit pipeline.
|
||||
c.Set(ctxRespBufferKey, &bytes.Buffer{})
|
||||
} else {
|
||||
c.Set(ctxRespBufferKey, &writer.body)
|
||||
}
|
||||
if !requestReadFailed {
|
||||
c.Next()
|
||||
}
|
||||
|
|
@ -82,6 +98,9 @@ func AccessLog(runtime *conf.Runtime, logger *slog.Logger, version string) gin.H
|
|||
return
|
||||
}
|
||||
responseText := redactJSON(writer.body.Bytes(), c.Writer.Header().Get("Content-Type"), logLimit)
|
||||
if paymentCallback {
|
||||
responseText = "[支付回调响应已省略]"
|
||||
}
|
||||
userID, authorityID := uint(0), uint(0)
|
||||
if claims := Claims(c); claims != nil {
|
||||
userID, authorityID = claims.ID, claims.AuthorityID
|
||||
|
|
@ -95,22 +114,36 @@ func AccessLog(runtime *conf.Runtime, logger *slog.Logger, version string) gin.H
|
|||
bytesOut = 0
|
||||
}
|
||||
privateErrors := strings.TrimRight(c.Errors.ByType(gin.ErrorTypePrivate).String(), "\n")
|
||||
attributes := []any{
|
||||
"mod", "http", "ip", c.ClientIP(), "method", c.Request.Method, "http_path", c.Request.URL.Path, "http_route", route,
|
||||
"http_status", c.Writer.Status(), "latency_ms", time.Since(started).Milliseconds(),
|
||||
"request_id", stringValueFromContext(c, "request_id"), "trace_id", stringValueFromContext(c, "trace_id"),
|
||||
"bytes_in", bytesIn, "bytes_out", bytesOut, "user_id", userID, "authority_id", authorityID,
|
||||
"error", c.Writer.Status() >= http.StatusInternalServerError || privateErrors != "", "ua", c.Request.UserAgent(), "req_query", c.Request.URL.RawQuery}
|
||||
if config != nil && config.Zap != nil && config.Zap.AccessReqHeaders {
|
||||
var attributes []any
|
||||
if paymentCallback {
|
||||
attributes = []any{
|
||||
"mod", "payment-callback", "payment_provider", paymentCallbackProvider(c.Request.URL.Path),
|
||||
"http_status", c.Writer.Status(), "latency_ms", time.Since(started).Milliseconds(),
|
||||
"request_id", stringValueFromContext(c, "request_id"), "trace_id", stringValueFromContext(c, "trace_id"),
|
||||
"bytes_in", bytesIn, "bytes_out", bytesOut,
|
||||
"error", c.Writer.Status() >= http.StatusInternalServerError || privateErrors != "",
|
||||
"payment_callback", true, "payment_callback_summary", requestText,
|
||||
}
|
||||
} else {
|
||||
attributes = []any{
|
||||
"mod", "http", "ip", c.ClientIP(), "method", c.Request.Method, "http_path", c.Request.URL.Path, "http_route", route,
|
||||
"http_status", c.Writer.Status(), "latency_ms", time.Since(started).Milliseconds(),
|
||||
"request_id", stringValueFromContext(c, "request_id"), "trace_id", stringValueFromContext(c, "trace_id"),
|
||||
"bytes_in", bytesIn, "bytes_out", bytesOut, "user_id", userID, "authority_id", authorityID,
|
||||
"error", c.Writer.Status() >= http.StatusInternalServerError || privateErrors != "", "ua", c.Request.UserAgent(),
|
||||
"req_query", c.Request.URL.RawQuery,
|
||||
}
|
||||
}
|
||||
if !paymentCallback && config != nil && config.Zap != nil && config.Zap.AccessReqHeaders {
|
||||
attributes = append(attributes, "req_headers", redactHeaders(c.Request.Header))
|
||||
}
|
||||
if config != nil && config.Zap != nil && config.Zap.AccessReqBody {
|
||||
if !paymentCallback && config != nil && config.Zap != nil && config.Zap.AccessReqBody {
|
||||
attributes = append(attributes, "req_body", requestText)
|
||||
}
|
||||
if config != nil && config.Zap != nil && config.Zap.AccessRespData {
|
||||
if !paymentCallback && config != nil && config.Zap != nil && config.Zap.AccessRespData {
|
||||
attributes = append(attributes, "resp_data", responseText)
|
||||
}
|
||||
if privateErrors != "" {
|
||||
if !paymentCallback && privateErrors != "" {
|
||||
attributes = append(attributes, "error_msg", privateErrors)
|
||||
}
|
||||
logger.InfoContext(c.Request.Context(), "请求完成", attributes...)
|
||||
|
|
@ -122,6 +155,56 @@ func isMediaUploadRoute(route string) bool {
|
|||
strings.HasSuffix(route, "/mediaUpload/chunk")
|
||||
}
|
||||
|
||||
func isPaymentCallbackPath(path string) bool {
|
||||
parts := strings.Split(strings.Trim(path, "/"), "/")
|
||||
for index := 0; index+1 < len(parts); index++ {
|
||||
if parts[index] == "payment" && parts[index+1] == "callback" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func paymentCallbackProvider(path string) string {
|
||||
parts := strings.Split(strings.Trim(path, "/"), "/")
|
||||
for index := 0; index+2 < len(parts); index++ {
|
||||
if parts[index] == "payment" && parts[index+1] == "callback" {
|
||||
return parts[index+2]
|
||||
}
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func paymentCallbackSummary(body []byte, contentType string) string {
|
||||
mediaType, _, err := mime.ParseMediaType(contentType)
|
||||
if err != nil || mediaType == "" {
|
||||
mediaType = strings.TrimSpace(strings.SplitN(contentType, ";", 2)[0])
|
||||
}
|
||||
if mediaType == "" {
|
||||
mediaType = "unknown"
|
||||
}
|
||||
digest := sha256.Sum256(body)
|
||||
return "[支付回调正文已省略 body_bytes=" + strconv.Itoa(len(body)) + " body_sha256=" + hex.EncodeToString(digest[:]) + " content_type=" + mediaType + "]"
|
||||
}
|
||||
|
||||
func isPaymentIntegrationConfigWrite(method, path string) bool {
|
||||
if method != http.MethodPut {
|
||||
return false
|
||||
}
|
||||
parts := strings.Split(strings.Trim(path, "/"), "/")
|
||||
for index := 0; index+3 < len(parts); index++ {
|
||||
if parts[index] == "integration" && parts[index+1] == "configs" && parts[index+2] == "payment" && parts[index+3] != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func paymentConfigSummary(body []byte) string {
|
||||
digest := sha256.Sum256(body)
|
||||
return "[支付配置正文已省略 body_bytes=" + strconv.Itoa(len(body)) + " body_sha256=" + hex.EncodeToString(digest[:]) + "]"
|
||||
}
|
||||
|
||||
func redactHeaders(headers map[string][]string) map[string]string {
|
||||
out := make(map[string]string, len(headers))
|
||||
for key, values := range headers {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
|
|
@ -63,3 +65,60 @@ func TestAccessLogAllowsMediaLimitOnlyOnUploadRoute(t *testing.T) {
|
|||
t.Fatalf("status=%d called=%v body=%s", response.Code, called, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessLogRedactsPaymentCallbackPayloadAndHeaders(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
var logs bytes.Buffer
|
||||
logger := slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
runtime := conf.NewRuntime(nil, &conf.AdminBackend{Zap: &conf.AdminBackend_Zap{AccessReqBody: true, AccessReqHeaders: true, AccessRespData: true}})
|
||||
engine := gin.New()
|
||||
engine.Use(AccessLog(runtime, logger, "test"))
|
||||
engine.POST("/api/payment/callback/:provider", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"body": "callback-response-secret"})
|
||||
})
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/payment/callback/alipay?signature=query-secret", strings.NewReader("payment-body-secret"))
|
||||
request.Header.Set("Content-Type", "application/json; boundary=credential-secret")
|
||||
request.Header.Set("Authorization", "Bearer header-secret")
|
||||
request.Header.Set("X-Alipay-Signature", "signature-secret")
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
engine.ServeHTTP(response, request)
|
||||
logText := logs.String()
|
||||
for _, secret := range []string{"payment-body-secret", "query-secret", "header-secret", "signature-secret", "callback-response-secret", "credential-secret"} {
|
||||
if strings.Contains(logText, secret) {
|
||||
t.Fatalf("payment callback secret leaked into access log: %q in %s", secret, logText)
|
||||
}
|
||||
}
|
||||
for _, marker := range []string{"payment_callback=true", "payment_provider=alipay", "body_sha256=", "http_status=200"} {
|
||||
if !strings.Contains(logText, marker) {
|
||||
t.Fatalf("payment callback access summary missing %q: %s", marker, logText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessLogOmitsPaymentIntegrationConfigBody(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
var logs bytes.Buffer
|
||||
logger := slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
runtime := conf.NewRuntime(nil, &conf.AdminBackend{Zap: &conf.AdminBackend_Zap{AccessReqBody: true, AccessReqHeaders: true, AccessRespData: true}})
|
||||
engine := gin.New()
|
||||
engine.Use(AccessLog(runtime, logger, "test"))
|
||||
engine.PUT("/api/integration/configs/:kind/:provider", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"code": 0}) })
|
||||
|
||||
body := `{"enabled":true,"config":{"key":"saobei-secret","certificate_blob":"certificate-secret","unknown_credential":"credential-secret"}}`
|
||||
request := httptest.NewRequest(http.MethodPut, "/api/integration/configs/payment/saobei", strings.NewReader(body))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
engine.ServeHTTP(response, request)
|
||||
logText := logs.String()
|
||||
for _, secret := range []string{"saobei-secret", "certificate-secret", "credential-secret"} {
|
||||
if strings.Contains(logText, secret) {
|
||||
t.Fatalf("payment configuration secret leaked into access log: %q in %s", secret, logText)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(logText, "支付配置正文已省略") || !strings.Contains(logText, "body_sha256=") {
|
||||
t.Fatalf("payment configuration summary missing: %s", logText)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,7 +81,11 @@ func OperationAudit(runtime *conf.Runtime, recorder *service.AuditRecorder) gin.
|
|||
responseBody = "[超出记录长度]"
|
||||
}
|
||||
errorMessage := c.Errors.ByType(gin.ErrorTypePrivate).String()
|
||||
if err := recorder.RecordOperationRequest(c.Request.Context(), &dto.OperationRecordRequest{IP: c.ClientIP(), Method: c.Request.Method, Path: path, Status: status, LatencyMS: time.Since(started).Milliseconds(), Agent: c.Request.UserAgent(), ErrorMessage: errorMessage, Body: operationRequestBody(requestBody, c.GetHeader("Content-Type"), maxBytes), Response: responseBody, UserID: userID, RequestID: stringValue(requestID), TraceID: stringValueFromContext(c, "trace_id"), DeviceID: c.GetHeader("X-Device-Id")}); err != nil {
|
||||
operationBody := operationRequestBody(requestBody, c.GetHeader("Content-Type"), maxBytes)
|
||||
if isPaymentIntegrationConfigWrite(c.Request.Method, path) {
|
||||
operationBody = paymentConfigSummary(requestBody)
|
||||
}
|
||||
if err := recorder.RecordOperationRequest(c.Request.Context(), &dto.OperationRecordRequest{IP: c.ClientIP(), Method: c.Request.Method, Path: path, Status: status, LatencyMS: time.Since(started).Milliseconds(), Agent: c.Request.UserAgent(), ErrorMessage: errorMessage, Body: operationBody, Response: responseBody, UserID: userID, RequestID: stringValue(requestID), TraceID: stringValueFromContext(c, "trace_id"), DeviceID: c.GetHeader("X-Device-Id")}); err != nil {
|
||||
// Preserve the business response, but expose audit persistence failures
|
||||
// to the global access/error logging pipeline.
|
||||
c.Set(ctxOperationAuditPersistFailedKey, true)
|
||||
|
|
@ -128,7 +132,7 @@ func maskOperationBody(value any) {
|
|||
case map[string]any:
|
||||
for key, item := range current {
|
||||
normalized := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(key, "_", ""), "-", ""))
|
||||
if normalized == "password" || normalized == "newpassword" || normalized == "oldpassword" || normalized == "confirmpassword" || normalized == "passwd" || normalized == "pwd" || normalized == "token" || normalized == "accesstoken" || normalized == "refreshtoken" || normalized == "secret" || normalized == "clientsecret" || normalized == "apikey" || normalized == "privatekey" || normalized == "idcard" {
|
||||
if normalized == "password" || normalized == "newpassword" || normalized == "oldpassword" || normalized == "confirmpassword" || normalized == "passwd" || normalized == "pwd" || normalized == "token" || normalized == "accesstoken" || normalized == "refreshtoken" || normalized == "secret" || normalized == "clientsecret" || normalized == "apikey" || normalized == "privatekey" || normalized == "idcard" || normalized == "appkey" || normalized == "mchkey" || normalized == "apiv3key" || normalized == "clientcert" || normalized == "clientkey" || normalized == "platformcert" || normalized == "platformserialno" || normalized == "credentialcode" || normalized == "certfile" || normalized == "keyfile" || normalized == "publickey" || normalized == "rootcert" || normalized == "appcert" || normalized == "webhookid" {
|
||||
current[key] = "***"
|
||||
continue
|
||||
}
|
||||
|
|
@ -157,17 +161,49 @@ func isDownloadResponse(c *gin.Context) bool {
|
|||
// recordsOperation mirrors the routes on which operation records are enabled.
|
||||
// Matching by suffix keeps the behavior stable when router-prefix is configured.
|
||||
func recordsOperation(method, path string) bool {
|
||||
_, ok := operationRoutes[method+" "+routeSuffix(path)]
|
||||
return ok
|
||||
for route := range operationRoutes {
|
||||
parts := strings.SplitN(route, " ", 2)
|
||||
if len(parts) != 2 || parts[0] != method || !operationPathMatches(parts[1], path) {
|
||||
continue
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func operationPathMatches(pattern, path string) bool {
|
||||
patternParts := strings.Split(strings.Trim(pattern, "/"), "/")
|
||||
pathParts := strings.Split(strings.Trim(path, "/"), "/")
|
||||
if len(pathParts) < len(patternParts) {
|
||||
return false
|
||||
}
|
||||
pathParts = pathParts[len(pathParts)-len(patternParts):]
|
||||
for index, patternPart := range patternParts {
|
||||
if strings.HasPrefix(patternPart, "*") {
|
||||
return index <= len(pathParts)
|
||||
}
|
||||
if index >= len(pathParts) || (strings.HasPrefix(patternPart, ":") == false && patternPart != pathParts[index]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return len(patternParts) == len(pathParts)
|
||||
}
|
||||
|
||||
func routeSuffix(path string) string {
|
||||
for _, marker := range []string{"/user/", "/api/", "/casbin/", "/authority/", "/menu/", "/department/", "/position/", "/sysDictionary/", "/sysDictionaryDetail/", "/sysParams/", "/securityConfig/", "/system/", "/sysApiToken/", "/sysVersion/", "/sysExportTemplate/", "/sysError/", "/sysLoginLog/", "/sysOperationRecord/", "/dataAccessLog/", "/timedTask/", "/info/", "/email/"} {
|
||||
bestIndex := -1
|
||||
bestPath := path
|
||||
for _, marker := range []string{"/user/", "/api/", "/casbin/", "/authority/", "/menu/", "/department/", "/position/", "/sysDictionary/", "/sysDictionaryDetail/", "/sysParams/", "/securityConfig/", "/system/", "/sysApiToken/", "/sysVersion/", "/sysExportTemplate/", "/sysError/", "/sysLoginLog/", "/sysOperationRecord/", "/dataAccessLog/", "/timedTask/", "/info/", "/email/", "/integration/", "/payment/"} {
|
||||
if index := strings.Index(path, marker); index >= 0 {
|
||||
return path[index:]
|
||||
// Router prefixes may themselves contain a registered route marker
|
||||
// (for example /api/integration/...). Keep the deepest match so the
|
||||
// policy and audit route remain the actual application endpoint.
|
||||
if index > bestIndex {
|
||||
bestIndex = index
|
||||
bestPath = path[index:]
|
||||
}
|
||||
}
|
||||
}
|
||||
return path
|
||||
return bestPath
|
||||
}
|
||||
|
||||
var operationRoutes = func() map[string]struct{} {
|
||||
|
|
@ -189,6 +225,8 @@ var operationRoutes = func() map[string]struct{} {
|
|||
"DELETE /sysLoginLog/deleteLoginLog", "DELETE /sysLoginLog/deleteLoginLogByIds", "DELETE /dataAccessLog/deleteDataAccessLogByIds",
|
||||
"POST /timedTask/createTimedTask", "PUT /timedTask/updateTimedTask", "DELETE /timedTask/deleteTimedTask", "POST /timedTask/toggleTimedTask", "POST /timedTask/triggerTimedTask",
|
||||
"POST /info/createInfo", "DELETE /info/deleteInfo", "DELETE /info/deleteInfoByIds", "PUT /info/updateInfo", "POST /email/emailTest", "POST /email/sendEmail",
|
||||
"PUT /integration/configs/:kind/:provider", "DELETE /integration/configs/:kind/:provider",
|
||||
"POST /payment/create", "POST /payment/query", "POST /payment/refund", "POST /payment/orders/:provider/:tradeNo/refund", "POST /payment/fulfill", "POST /payment/orders/:provider/:tradeNo/fulfill", "POST /payment/providers/:provider/test",
|
||||
}
|
||||
out := make(map[string]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
package middleware
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPaymentIntegrationSecretsAreRedacted(t *testing.T) {
|
||||
raw := []byte(`{"enabled":true,"config":{"app_id":"app","mch_key":"merchant-secret","api_v3_key":"v3-secret","client_cert":"certificate","client_key":"private-key","platform_cert":"platform-certificate","credential_code":"credential","webhook_id":"webhook"}}`)
|
||||
redacted := redactJSON(raw, "application/json", 4096)
|
||||
var payload struct {
|
||||
Config map[string]string `json:"config"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(redacted), &payload); err != nil {
|
||||
t.Fatalf("decode redacted payload: %v", err)
|
||||
}
|
||||
for _, key := range []string{"mch_key", "api_v3_key", "client_cert", "client_key", "platform_cert", "credential_code", "webhook_id"} {
|
||||
if payload.Config[key] != "***" {
|
||||
t.Fatalf("payment secret %q was not redacted: %s", key, redacted)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(redacted, `"app_id":"app"`) {
|
||||
t.Fatalf("non-secret integration field was removed: %s", redacted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentOperationsAreAuditedWithRouterPrefix(t *testing.T) {
|
||||
for _, route := range []struct {
|
||||
method string
|
||||
path string
|
||||
}{
|
||||
{method: "PUT", path: "/api/integration/configs/payment/alipay"},
|
||||
{method: "POST", path: "/api/payment/refund"},
|
||||
{method: "POST", path: "/api/payment/fulfill"},
|
||||
{method: "POST", path: "/api/payment/providers/alipay/test"},
|
||||
} {
|
||||
if !recordsOperation(route.method, route.path) {
|
||||
t.Fatalf("payment operation was not audited: %s %s", route.method, route.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentIntegrationConfigUsesRouteLevelSummary(t *testing.T) {
|
||||
raw := []byte(`{"enabled":true,"config":{"key":"secret","custom_certificate":"certificate"}}`)
|
||||
summary := paymentConfigSummary(raw)
|
||||
if strings.Contains(summary, "secret") || strings.Contains(summary, "certificate") {
|
||||
t.Fatalf("payment configuration summary leaked payload: %s", summary)
|
||||
}
|
||||
if !isPaymentIntegrationConfigWrite("PUT", "/api/integration/configs/payment/saobei") {
|
||||
t.Fatal("payment configuration write route was not recognized")
|
||||
}
|
||||
if isPaymentIntegrationConfigWrite("PUT", "/api/integration/configs/mq/emqx") {
|
||||
t.Fatal("non-payment integration was treated as payment configuration")
|
||||
}
|
||||
}
|
||||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,447 @@
|
|||
package mq
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
type RabbitMQConfig struct {
|
||||
Enabled bool
|
||||
Host string
|
||||
Port int
|
||||
Username string
|
||||
Password string
|
||||
VHost string
|
||||
Exchange string
|
||||
ExchangeType string
|
||||
Queue string
|
||||
RoutingKey string
|
||||
Durable bool
|
||||
AutoDelete bool
|
||||
PrefetchCount int
|
||||
Heartbeat time.Duration
|
||||
ConnectTimeout time.Duration
|
||||
TLS bool
|
||||
}
|
||||
|
||||
type rabbitSubscription struct {
|
||||
qos byte
|
||||
handler Handler
|
||||
}
|
||||
|
||||
// RabbitMQ adapts AMQP exchanges and routing keys to the shared topic-based
|
||||
// Client contract. All subscriptions share the configured queue and a single
|
||||
// consumer; deliveries are dispatched to matching handlers locally.
|
||||
type RabbitMQ struct {
|
||||
mu sync.RWMutex
|
||||
opMu sync.Mutex
|
||||
publishMu sync.Mutex
|
||||
consumeMu sync.Mutex
|
||||
connection *amqp.Connection
|
||||
publishChannel *amqp.Channel
|
||||
consumeChannel *amqp.Channel
|
||||
config RabbitMQConfig
|
||||
subscriptions map[string]rabbitSubscription
|
||||
consumerTag string
|
||||
consuming bool
|
||||
stop chan struct{}
|
||||
closed bool
|
||||
}
|
||||
|
||||
func NewRabbitMQ(config RabbitMQConfig) (*RabbitMQ, error) {
|
||||
client := &RabbitMQ{subscriptions: make(map[string]rabbitSubscription), stop: make(chan struct{})}
|
||||
if !config.Enabled {
|
||||
return client, nil
|
||||
}
|
||||
config = defaultRabbitMQConfig(config)
|
||||
if err := validateRabbitMQConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
scheme := "amqp"
|
||||
if config.TLS {
|
||||
scheme = "amqps"
|
||||
}
|
||||
address := amqp.URI{
|
||||
Scheme: scheme, Host: config.Host, Port: config.Port,
|
||||
Username: config.Username, Password: config.Password, Vhost: config.VHost,
|
||||
ConnectionTimeout: int(config.ConnectTimeout.Milliseconds()),
|
||||
}.String()
|
||||
connection, err := amqp.DialConfig(address, amqp.Config{
|
||||
Heartbeat: config.Heartbeat,
|
||||
Recovery: &amqp.Recovery{},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect rabbitmq: %w", err)
|
||||
}
|
||||
|
||||
publishChannel, err := connection.Channel()
|
||||
if err != nil {
|
||||
_ = connection.Close()
|
||||
return nil, fmt.Errorf("open rabbitmq publish channel: %w", err)
|
||||
}
|
||||
consumeChannel, err := connection.Channel()
|
||||
if err != nil {
|
||||
_ = publishChannel.Close()
|
||||
_ = connection.Close()
|
||||
return nil, fmt.Errorf("open rabbitmq consume channel: %w", err)
|
||||
}
|
||||
closeOnError := func() {
|
||||
_ = consumeChannel.Close()
|
||||
_ = publishChannel.Close()
|
||||
_ = connection.Close()
|
||||
}
|
||||
if err = consumeChannel.ExchangeDeclare(config.Exchange, config.ExchangeType, config.Durable, config.AutoDelete, false, false, nil); err != nil {
|
||||
closeOnError()
|
||||
return nil, fmt.Errorf("declare rabbitmq exchange: %w", err)
|
||||
}
|
||||
if _, err = consumeChannel.QueueDeclare(config.Queue, config.Durable, config.AutoDelete, false, false, nil); err != nil {
|
||||
closeOnError()
|
||||
return nil, fmt.Errorf("declare rabbitmq queue: %w", err)
|
||||
}
|
||||
if config.PrefetchCount > 0 {
|
||||
if err = consumeChannel.Qos(config.PrefetchCount, 0, false); err != nil {
|
||||
closeOnError()
|
||||
return nil, fmt.Errorf("configure rabbitmq qos: %w", err)
|
||||
}
|
||||
}
|
||||
client.connection = connection
|
||||
client.publishChannel = publishChannel
|
||||
client.consumeChannel = consumeChannel
|
||||
client.config = config
|
||||
client.consumerTag = fmt.Sprintf("kra-%d", time.Now().UnixNano())
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func defaultRabbitMQConfig(config RabbitMQConfig) RabbitMQConfig {
|
||||
if config.Port <= 0 {
|
||||
if config.TLS {
|
||||
config.Port = 5671
|
||||
} else {
|
||||
config.Port = 5672
|
||||
}
|
||||
}
|
||||
if config.Username == "" {
|
||||
config.Username = "guest"
|
||||
}
|
||||
if config.Password == "" {
|
||||
config.Password = "guest"
|
||||
}
|
||||
if config.VHost == "" {
|
||||
config.VHost = "/"
|
||||
}
|
||||
if config.ExchangeType == "" {
|
||||
config.ExchangeType = "topic"
|
||||
}
|
||||
if config.RoutingKey == "" {
|
||||
config.RoutingKey = "#"
|
||||
}
|
||||
if config.Heartbeat <= 0 {
|
||||
config.Heartbeat = 10 * time.Second
|
||||
}
|
||||
if config.ConnectTimeout <= 0 {
|
||||
config.ConnectTimeout = 10 * time.Second
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
func validateRabbitMQConfig(config RabbitMQConfig) error {
|
||||
if strings.TrimSpace(config.Host) == "" {
|
||||
return errors.New("rabbitmq host is empty")
|
||||
}
|
||||
if config.Port < 1 || config.Port > 65535 {
|
||||
return fmt.Errorf("invalid rabbitmq port %d", config.Port)
|
||||
}
|
||||
if strings.TrimSpace(config.Exchange) == "" {
|
||||
return errors.New("rabbitmq exchange is empty")
|
||||
}
|
||||
if strings.TrimSpace(config.Queue) == "" {
|
||||
return errors.New("rabbitmq queue is empty")
|
||||
}
|
||||
switch strings.ToLower(config.ExchangeType) {
|
||||
case "direct", "fanout", "topic":
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("invalid rabbitmq exchange type %q", config.ExchangeType)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *RabbitMQ) Publish(ctx context.Context, topic string, payload []byte, qos byte, _ bool) error {
|
||||
if c == nil {
|
||||
return ErrUnavailable
|
||||
}
|
||||
if c != nil && strings.TrimSpace(topic) == "" {
|
||||
topic = c.config.RoutingKey
|
||||
}
|
||||
if strings.TrimSpace(topic) == "" {
|
||||
return errors.New("rabbitmq routing key is empty")
|
||||
}
|
||||
if qos > AtLeastOnce {
|
||||
return fmt.Errorf("rabbitmq supports qos 0 or 1, got %d", qos)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
c.publishMu.Lock()
|
||||
defer c.publishMu.Unlock()
|
||||
c.mu.RLock()
|
||||
if c.closed || c.publishChannel == nil || c.publishChannel.IsClosed() {
|
||||
c.mu.RUnlock()
|
||||
return ErrUnavailable
|
||||
}
|
||||
channel := c.publishChannel
|
||||
exchange := c.config.Exchange
|
||||
c.mu.RUnlock()
|
||||
deliveryMode := amqp.Transient
|
||||
if qos >= AtLeastOnce {
|
||||
deliveryMode = amqp.Persistent
|
||||
}
|
||||
return channel.PublishWithContext(ctx, exchange, topic, false, false, amqp.Publishing{
|
||||
ContentType: "application/octet-stream",
|
||||
DeliveryMode: deliveryMode,
|
||||
Timestamp: time.Now(),
|
||||
Body: append([]byte(nil), payload...),
|
||||
})
|
||||
}
|
||||
|
||||
func (c *RabbitMQ) Subscribe(ctx context.Context, topic string, qos byte, handler Handler) error {
|
||||
if c == nil {
|
||||
return ErrUnavailable
|
||||
}
|
||||
if c != nil && strings.TrimSpace(topic) == "" {
|
||||
topic = c.config.RoutingKey
|
||||
}
|
||||
if strings.TrimSpace(topic) == "" {
|
||||
return errors.New("rabbitmq routing key is empty")
|
||||
}
|
||||
if qos > AtLeastOnce {
|
||||
return fmt.Errorf("rabbitmq supports qos 0 or 1, got %d", qos)
|
||||
}
|
||||
if handler == nil {
|
||||
return errors.New("rabbitmq handler is nil")
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
c.opMu.Lock()
|
||||
defer c.opMu.Unlock()
|
||||
c.consumeMu.Lock()
|
||||
defer c.consumeMu.Unlock()
|
||||
c.mu.RLock()
|
||||
if c.closed || c.consumeChannel == nil || c.consumeChannel.IsClosed() {
|
||||
c.mu.RUnlock()
|
||||
return ErrUnavailable
|
||||
}
|
||||
channel := c.consumeChannel
|
||||
config := c.config
|
||||
_, exists := c.subscriptions[topic]
|
||||
c.mu.RUnlock()
|
||||
if !exists {
|
||||
if err := channel.QueueBind(config.Queue, topic, config.Exchange, false, nil); err != nil {
|
||||
return fmt.Errorf("bind rabbitmq queue: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.subscriptions[topic] = rabbitSubscription{qos: qos, handler: handler}
|
||||
shouldStart := !c.consuming
|
||||
c.mu.Unlock()
|
||||
if !shouldStart {
|
||||
return nil
|
||||
}
|
||||
deliveries, err := channel.Consume(config.Queue, c.consumerTag, false, false, false, false, nil)
|
||||
if err != nil {
|
||||
c.mu.Lock()
|
||||
delete(c.subscriptions, topic)
|
||||
c.mu.Unlock()
|
||||
if !exists {
|
||||
_ = channel.QueueUnbind(config.Queue, topic, config.Exchange, nil)
|
||||
}
|
||||
return fmt.Errorf("consume rabbitmq queue: %w", err)
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.consuming = true
|
||||
c.mu.Unlock()
|
||||
go c.consume(deliveries)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *RabbitMQ) consume(deliveries <-chan amqp.Delivery) {
|
||||
for {
|
||||
select {
|
||||
case <-c.stop:
|
||||
return
|
||||
case delivery, ok := <-deliveries:
|
||||
if !ok {
|
||||
c.mu.Lock()
|
||||
c.consuming = false
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
subscriptions := c.subscriptionSnapshot()
|
||||
for pattern, item := range subscriptions {
|
||||
if rabbitRoutingKeyMatches(c.config.ExchangeType, pattern, delivery.RoutingKey) {
|
||||
item.handler(context.Background(), Message{Topic: delivery.RoutingKey, Payload: append([]byte(nil), delivery.Body...), QoS: item.qos})
|
||||
}
|
||||
}
|
||||
c.consumeMu.Lock()
|
||||
_ = delivery.Ack(false)
|
||||
c.consumeMu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *RabbitMQ) subscriptionSnapshot() map[string]rabbitSubscription {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
result := make(map[string]rabbitSubscription, len(c.subscriptions))
|
||||
for topic, item := range c.subscriptions {
|
||||
result[topic] = item
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (c *RabbitMQ) Unsubscribe(ctx context.Context, topics ...string) error {
|
||||
if c == nil {
|
||||
return ErrUnavailable
|
||||
}
|
||||
if len(topics) == 0 {
|
||||
return errors.New("rabbitmq routing keys are empty")
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
c.opMu.Lock()
|
||||
defer c.opMu.Unlock()
|
||||
c.consumeMu.Lock()
|
||||
defer c.consumeMu.Unlock()
|
||||
c.mu.RLock()
|
||||
if c.closed || c.consumeChannel == nil || c.consumeChannel.IsClosed() {
|
||||
c.mu.RUnlock()
|
||||
return ErrUnavailable
|
||||
}
|
||||
channel := c.consumeChannel
|
||||
config := c.config
|
||||
c.mu.RUnlock()
|
||||
for _, topic := range topics {
|
||||
c.mu.RLock()
|
||||
_, exists := c.subscriptions[topic]
|
||||
c.mu.RUnlock()
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
if err := channel.QueueUnbind(config.Queue, topic, config.Exchange, nil); err != nil {
|
||||
return fmt.Errorf("unbind rabbitmq queue: %w", err)
|
||||
}
|
||||
c.mu.Lock()
|
||||
delete(c.subscriptions, topic)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
c.mu.RLock()
|
||||
empty := len(c.subscriptions) == 0
|
||||
consuming := c.consuming
|
||||
c.mu.RUnlock()
|
||||
if empty && consuming {
|
||||
if err := channel.Cancel(c.consumerTag, false); err != nil {
|
||||
return fmt.Errorf("cancel rabbitmq consumer: %w", err)
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.consuming = false
|
||||
c.mu.Unlock()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *RabbitMQ) Connected() bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return !c.closed && c.connection != nil && !c.connection.IsClosed()
|
||||
}
|
||||
|
||||
func (c *RabbitMQ) Close() error {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
c.opMu.Lock()
|
||||
defer c.opMu.Unlock()
|
||||
c.publishMu.Lock()
|
||||
defer c.publishMu.Unlock()
|
||||
c.consumeMu.Lock()
|
||||
defer c.consumeMu.Unlock()
|
||||
c.mu.Lock()
|
||||
if c.closed {
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
c.closed = true
|
||||
close(c.stop)
|
||||
publishChannel := c.publishChannel
|
||||
consumeChannel := c.consumeChannel
|
||||
connection := c.connection
|
||||
c.publishChannel = nil
|
||||
c.consumeChannel = nil
|
||||
c.connection = nil
|
||||
c.mu.Unlock()
|
||||
var result error
|
||||
if consumeChannel != nil {
|
||||
result = errors.Join(result, consumeChannel.Close())
|
||||
}
|
||||
if publishChannel != nil {
|
||||
result = errors.Join(result, publishChannel.Close())
|
||||
}
|
||||
if connection != nil {
|
||||
result = errors.Join(result, connection.Close())
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func rabbitRoutingKeyMatches(exchangeType, pattern, routingKey string) bool {
|
||||
switch strings.ToLower(exchangeType) {
|
||||
case "fanout":
|
||||
return true
|
||||
case "direct":
|
||||
return pattern == routingKey
|
||||
}
|
||||
patternParts := strings.Split(pattern, ".")
|
||||
routingParts := strings.Split(routingKey, ".")
|
||||
for len(patternParts) > 0 {
|
||||
head := patternParts[0]
|
||||
patternParts = patternParts[1:]
|
||||
if head == "#" {
|
||||
if len(patternParts) == 0 {
|
||||
return true
|
||||
}
|
||||
for index := 0; index <= len(routingParts); index++ {
|
||||
if rabbitTopicPartsMatch(patternParts, routingParts[index:]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if len(routingParts) == 0 || (head != "*" && head != routingParts[0]) {
|
||||
return false
|
||||
}
|
||||
routingParts = routingParts[1:]
|
||||
}
|
||||
return len(routingParts) == 0
|
||||
}
|
||||
|
||||
func rabbitTopicPartsMatch(patternParts, routingParts []string) bool {
|
||||
return rabbitRoutingKeyMatches("topic", strings.Join(patternParts, "."), strings.Join(routingParts, "."))
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
package mq
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDisabledRabbitMQIsSafeAndUnavailable(t *testing.T) {
|
||||
client, err := NewRabbitMQ(RabbitMQConfig{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if client.Connected() {
|
||||
t.Fatal("disabled rabbitmq reported connected")
|
||||
}
|
||||
if err = client.Publish(context.Background(), "orders.paid", []byte("test"), AtLeastOnce, false); !errors.Is(err, ErrUnavailable) {
|
||||
t.Fatalf("publish error = %v", err)
|
||||
}
|
||||
if err = client.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnabledRabbitMQRequiresTopology(t *testing.T) {
|
||||
if _, err := NewRabbitMQ(RabbitMQConfig{Enabled: true}); err == nil {
|
||||
t.Fatal("enabled rabbitmq without host and topology should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRabbitRoutingKeyMatches(t *testing.T) {
|
||||
tests := []struct {
|
||||
pattern string
|
||||
key string
|
||||
want bool
|
||||
}{
|
||||
{pattern: "orders.*.paid", key: "orders.cn.paid", want: true},
|
||||
{pattern: "orders.#", key: "orders.cn.created", want: true},
|
||||
{pattern: "#.paid", key: "orders.cn.paid", want: true},
|
||||
{pattern: "orders.*", key: "orders.cn.paid", want: false},
|
||||
{pattern: "orders.created", key: "orders.paid", want: false},
|
||||
}
|
||||
for _, test := range tests {
|
||||
if got := rabbitRoutingKeyMatches("topic", test.pattern, test.key); got != test.want {
|
||||
t.Fatalf("match(%q, %q) = %v, want %v", test.pattern, test.key, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,25 +3,36 @@ import service from '@/utils/request'
|
|||
export const getPaymentOrders = (params) => service({
|
||||
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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -57,6 +57,8 @@
|
|||
"/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",
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@
|
|||
<small>{{ providerMeta(item).protocol }}</small>
|
||||
</span>
|
||||
<span class="provider-state" :class="{ enabled: item.enabled }">
|
||||
{{ item.enabled ? '运行中' : '已停用' }}
|
||||
{{ item.enabled ? '已启用' : '已停用' }}
|
||||
</span>
|
||||
</button>
|
||||
</aside>
|
||||
|
|
@ -390,6 +390,11 @@ const hasMaskedSecret = (item) =>
|
|||
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,107 +1,432 @@
|
|||
<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)"
|
||||
/>
|
||||
<el-input
|
||||
v-else-if="field.type === 'textarea'"
|
||||
v-model="selected.config[field.key]"
|
||||
class="field-control"
|
||||
type="textarea"
|
||||
:rows="field.secret ? 5 : 4"
|
||||
:placeholder="field.placeholder"
|
||||
spellcheck="false"
|
||||
@update:model-value="clearFieldError(field.key)"
|
||||
/>
|
||||
<el-input
|
||||
v-else
|
||||
v-model="selected.config[field.key]"
|
||||
class="field-control"
|
||||
:type="field.secret ? 'password' : 'text'"
|
||||
:show-password="field.secret"
|
||||
:placeholder="field.placeholder"
|
||||
spellcheck="false"
|
||||
@update:model-value="clearFieldError(field.key)"
|
||||
/>
|
||||
<p v-if="fieldHint(field)" class="field-hint">{{ fieldHint(field) }}</p>
|
||||
</el-form-item>
|
||||
</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.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, MoreFilled, Refresh } 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 testVisible = ref(false)
|
||||
const testResult = ref(null)
|
||||
let loadRequestID = 0
|
||||
|
||||
const selected = computed(() => configs.value.find((item) => item.provider === selectedProvider.value) || configs.value[0])
|
||||
const busy = computed(() => Boolean(operation.value))
|
||||
const saveStateText = computed(() => {
|
||||
if (!selected.value) return ''
|
||||
if (!selected.value.configured) return '尚未创建配置'
|
||||
if (isDirty(selected.value)) return '存在未保存的修改'
|
||||
return selected.value.enabled ? '配置已保存,渠道已启用' : '配置已保存,渠道已停用'
|
||||
})
|
||||
const testResultTitle = computed(() => {
|
||||
if (!testResult.value?.passed) return '渠道测试未通过'
|
||||
const refundStage = testResult.value.stages?.find((stage) => stage.name === 'refund')
|
||||
if (refundStage?.status === 'passed' && /受理|接受|等待|pending|processing/i.test(refundStage.message || '')) return '支付链路已受理,等待退款确认'
|
||||
return testResult.value.fullFlow ? '完整支付链路测试通过' : '渠道连通性测试通过'
|
||||
})
|
||||
|
||||
const cloneConfig = (value) => JSON.parse(JSON.stringify(value || {}))
|
||||
const normalizeConfig = (item) => {
|
||||
const normalized = { ...item, enabled: Boolean(item.enabled), configured: Boolean(item.configured), config: cloneConfig(item.config), fields: Array.isArray(item.fields) ? item.fields : [] }
|
||||
normalized._savedEnabled = normalized.enabled
|
||||
normalized._savedConfig = cloneConfig(normalized.config)
|
||||
return normalized
|
||||
}
|
||||
const providerText = (provider) => PROVIDER_NAMES[provider] || provider
|
||||
const fieldError = (key) => errors[key] || ''
|
||||
const clearFieldError = (key) => { delete errors[key] }
|
||||
const isEmpty = (value) => value === null || typeof value === 'undefined' || (typeof value === 'string' && value.trim() === '')
|
||||
const isDirty = (item) => JSON.stringify({ enabled: item.enabled, config: item.config }) !== JSON.stringify({ enabled: item._savedEnabled, config: item._savedConfig })
|
||||
const hasMaskedSecret = (item) => (item.fields || []).some((field) => field.secret && item.config[field.key] === '******')
|
||||
const fieldHint = (field) => {
|
||||
if (field.key === 'test_mode') return '仅在需要执行真实渠道测试时开启。'
|
||||
if (field.key === 'test_amount') return '使用最小货币单位,例如 CNY 1 表示 0.01 元。'
|
||||
if (field.key === 'test_extra') return '必须是 JSON 对象;可传 openid、auth_code 等测试参数。'
|
||||
if (field.key === 'notify_url') return '异步支付方式必须填写可被支付平台访问的 HTTPS 地址。'
|
||||
return field.description || ''
|
||||
}
|
||||
const stageName = (name) => STAGE_NAMES[name] || name
|
||||
const stageStatusText = (status) => status === 'passed' ? '通过' : status === 'skipped' ? '跳过' : '失败'
|
||||
const stageIcon = (status) => status === 'passed' ? CircleCheck : status === 'failed' ? CircleClose : MoreFilled
|
||||
|
||||
function validate(item, enabled = item.enabled) {
|
||||
Object.keys(errors).forEach((key) => delete errors[key])
|
||||
let valid = true
|
||||
for (const field of item.fields || []) {
|
||||
const value = item.config[field.key]
|
||||
let message = ''
|
||||
if (enabled && field.required && isEmpty(value)) message = `请填写${field.label}`
|
||||
else if (field.type === 'number' && !isEmpty(value) && (!Number.isFinite(Number(value)) || Number(value) < 0)) message = `${field.label}必须是非负数`
|
||||
else if (field.key === 'test_amount' && !isEmpty(value) && Number(value) <= 0) message = '测试金额必须大于 0'
|
||||
else if (field.key === 'test_extra' && String(value || '').trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(String(value))
|
||||
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') message = '测试扩展参数必须是 JSON 对象'
|
||||
} catch { message = '测试扩展参数必须是合法 JSON' }
|
||||
} else if ((field.type === 'url' || field.key.endsWith('_url')) && value) {
|
||||
try {
|
||||
const url = new URL(String(value))
|
||||
if (!['http:', 'https:'].includes(url.protocol)) message = `${field.label}必须使用 HTTP 或 HTTPS`
|
||||
} catch { message = `${field.label}格式无效` }
|
||||
}
|
||||
if (!item.configured && field.secret && value === '******') message = '请重新填写' + field.label
|
||||
if (message) { errors[field.key] = message; valid = false }
|
||||
}
|
||||
if (!valid) ElMessage.warning('请先修正配置项')
|
||||
return valid
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (loading.value) return
|
||||
const requestID = ++loadRequestID
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getIntegrationConfigs('payment')
|
||||
if (requestID !== loadRequestID || res.code !== 0) return
|
||||
configs.value = (res.data || []).map(normalizeConfig)
|
||||
if (!configs.value.some((item) => item.provider === selectedProvider.value)) selectedProvider.value = configs.value[0]?.provider || ''
|
||||
} catch {
|
||||
// The request layer already presents transport errors.
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function refreshConfigs() {
|
||||
if (busy.value) return
|
||||
if (selected.value && isDirty(selected.value)) {
|
||||
try {
|
||||
await ElMessageBox.confirm('当前渠道有未保存修改,刷新会丢失这些修改。确认刷新吗?', '刷新支付配置', { type: 'warning', confirmButtonText: '确认刷新' })
|
||||
} catch { return }
|
||||
}
|
||||
await load()
|
||||
}
|
||||
|
||||
async function selectProvider(provider) {
|
||||
if (provider === selectedProvider.value || busy.value) return
|
||||
if (selected.value && isDirty(selected.value)) {
|
||||
try {
|
||||
await ElMessageBox.confirm('当前渠道有未保存修改,切换后会丢失这些修改。确认切换吗?', '切换支付渠道', { type: 'warning', confirmButtonText: '确认切换' })
|
||||
} catch { return }
|
||||
}
|
||||
Object.keys(errors).forEach((key) => delete errors[key])
|
||||
selectedProvider.value = provider
|
||||
}
|
||||
|
||||
async function persist(item, type) {
|
||||
if (busy.value || !validate(item, item.enabled)) return false
|
||||
operation.value = type
|
||||
try {
|
||||
const res = await saveIntegrationConfig('payment', item.provider, { enabled: item.enabled, config: item.config })
|
||||
if (res.code !== 0) return false
|
||||
const provider = item.provider
|
||||
await load()
|
||||
selectedProvider.value = provider
|
||||
return true
|
||||
} catch { return false }
|
||||
finally { operation.value = '' }
|
||||
}
|
||||
|
||||
async function saveSelected() {
|
||||
if (!selected.value) return
|
||||
if (await persist(selected.value, 'save')) ElMessage.success('支付渠道配置已保存')
|
||||
}
|
||||
|
||||
async function toggleProvider(enabled) {
|
||||
const item = selected.value
|
||||
if (!item || busy.value) return
|
||||
const previous = item.enabled
|
||||
item.enabled = Boolean(enabled)
|
||||
if (item.enabled && !validate(item, true)) { item.enabled = previous; return }
|
||||
if (await persist(item, 'toggle')) {
|
||||
ElMessage.success(`${item.name || providerText(item.provider)} 已${item.enabled ? '启用' : '停用'}`)
|
||||
return
|
||||
}
|
||||
item.enabled = previous
|
||||
ElMessage.warning('渠道状态未改变')
|
||||
}
|
||||
|
||||
async function testProvider() {
|
||||
const item = selected.value
|
||||
if (!item || busy.value) return
|
||||
if (isDirty(item)) { ElMessage.warning('请先保存当前配置'); return }
|
||||
if (!item.config?.test_mode) { ElMessage.warning('请先开启“允许执行渠道测试”并保存'); return }
|
||||
const environment = String(item.config?.environment || '').toLowerCase()
|
||||
if (environment === 'production' || environment === 'prod') {
|
||||
operation.value = 'test-confirm'
|
||||
try {
|
||||
await ElMessageBox.confirm('当前渠道使用生产环境,测试会真实创建最小金额订单。确认使用专用测试商户执行吗?', '生产环境测试确认', { type: 'warning', confirmButtonText: '确认测试' })
|
||||
} catch { operation.value = ''; return }
|
||||
}
|
||||
operation.value = 'test'
|
||||
try {
|
||||
const res = await testPaymentProvider(item.provider)
|
||||
testResult.value = res.data || { passed: false, stages: [{ name: 'test', status: 'failed', message: res.msg || '未知错误' }] }
|
||||
testVisible.value = true
|
||||
} catch {
|
||||
// The request layer already presents transport errors.
|
||||
} finally { operation.value = '' }
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
const item = selected.value
|
||||
if (!item || busy.value) return
|
||||
try {
|
||||
await ElMessageBox.confirm(`删除 ${item.name || providerText(item.provider)} 配置后,该渠道将立即不可用。确认继续吗?`, '删除支付配置', { type: 'warning', confirmButtonText: '确认删除' })
|
||||
} catch { return }
|
||||
operation.value = 'delete'
|
||||
try {
|
||||
const res = await deleteIntegrationConfig('payment', item.provider)
|
||||
if (res.code !== 0) return
|
||||
ElMessage.success('支付渠道配置已删除')
|
||||
await load()
|
||||
} catch {
|
||||
// The request layer already presents transport errors.
|
||||
} finally { operation.value = '' }
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<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; } }
|
||||
.field-hint { width: 100%; margin: 5px 0 0; color: var(--el-text-color-secondary); font-size: 12px; line-height: 1.5; }
|
||||
.editor-actions { display: flex; align-items: center; justify-content: flex-end; gap: 10px; padding-top: 16px; border-top: 1px solid var(--el-border-color-lighter); }
|
||||
.save-state { margin-right: auto; color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.test-result :deep(.el-result) { padding: 8px 24px 20px; }
|
||||
.test-stages { border-top: 1px solid var(--el-border-color-lighter); }
|
||||
.test-stage { display: grid; grid-template-columns: 22px minmax(0, 1fr) auto; align-items: start; gap: 10px; padding: 13px 4px; border-bottom: 1px solid var(--el-border-color-lighter); }
|
||||
.test-stage strong { color: var(--el-text-color-primary); font-size: 13px; font-weight: 600; }
|
||||
.test-stage p { margin: 3px 0 0; color: var(--el-text-color-secondary); font-size: 12px; line-height: 1.5; }
|
||||
.test-stage > span { color: var(--el-text-color-placeholder); font-size: 11px; white-space: nowrap; }
|
||||
.stage-passed { color: var(--el-color-success); } .stage-failed { color: var(--el-color-danger); } .stage-skipped { color: var(--el-text-color-placeholder); }
|
||||
@media (max-width: 900px) { .config-layout { grid-template-columns: 1fr; } .provider-panel { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; max-height: 270px; overflow-y: auto; border-right: 0; border-bottom: 1px solid var(--el-border-color-lighter); } .panel-heading { display: none; } .provider-state { display: none; } .editor-panel { padding: 20px; } .field-grid { grid-template-columns: 1fr; } }
|
||||
@media (max-width: 560px) { .payment-config-page { min-height: 0; } .page-heading { align-items: flex-start; } .provider-panel { grid-template-columns: 1fr; } .provider-state { display: inline; } .editor-panel { padding: 18px 14px; } .editor-heading { align-items: stretch; flex-direction: column; gap: 14px; } .enable-control { justify-content: space-between; } .editor-actions { align-items: stretch; flex-direction: column; } .save-state { margin-right: 0; } .editor-actions :deep(.el-button) { margin-left: 0; } }
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,68 +1,524 @@
|
|||
<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
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认${fulfillmentActionText(order)}订单 ${order.tradeNo} 吗?`, fulfillmentActionText(order), { type: 'warning', confirmButtonText: '确认执行' })
|
||||
} catch { return }
|
||||
setRowAction(order, 'fulfill')
|
||||
try {
|
||||
const res = await fulfillPaymentOrder({ provider: order.provider, tradeNo: order.tradeNo })
|
||||
if (res.code !== 0) return
|
||||
ElMessage.success(res.data?.duplicate ? '该订单已完成发货,无需重复处理' : '发货处理已完成')
|
||||
await load({ silent: true })
|
||||
await refreshVisibleDetail(order)
|
||||
} catch {
|
||||
// The request layer already presents transport errors.
|
||||
} finally { setRowAction(order, '') }
|
||||
}
|
||||
function openRefund(order) {
|
||||
const maxAmount = remainingRefundAmount(order)
|
||||
Object.assign(refundForm, { provider: order.provider, tradeNo: order.tradeNo, currency: order.currency || 'CNY', maxAmount, amountText: formatAmountInput(maxAmount, order.currency || 'CNY') })
|
||||
refundVisible.value = true
|
||||
nextTick(() => refundFormRef.value?.clearValidate())
|
||||
}
|
||||
async function submitRefund() {
|
||||
if (refundSubmitting.value) return
|
||||
refundSubmitting.value = true
|
||||
const valid = await refundFormRef.value?.validate().catch(() => false)
|
||||
if (!valid) { refundSubmitting.value = false; return }
|
||||
let amount
|
||||
try { amount = parseAmountToMinor(refundForm.amountText, refundForm.currency) }
|
||||
catch (error) { ElMessage.warning(error.message); refundSubmitting.value = false; return }
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认退款 ${formatAmount(amount, refundForm.currency)} 吗?提交后将调用支付渠道。`, '确认退款', { type: 'warning', confirmButtonText: '确认退款' })
|
||||
} catch { refundSubmitting.value = false; return }
|
||||
refundLoading.value = true
|
||||
const order = { provider: refundForm.provider, tradeNo: refundForm.tradeNo }
|
||||
try {
|
||||
const res = await refundPaymentOrder({ provider: refundForm.provider, tradeNo: refundForm.tradeNo, amount })
|
||||
if (res.code !== 0) return
|
||||
ElMessage.success(res.data?.refundStatus === 'succeeded' ? '退款已完成' : '退款申请已提交,等待渠道确认')
|
||||
refundVisible.value = false
|
||||
await load({ silent: true })
|
||||
await refreshVisibleDetail(order)
|
||||
} catch {
|
||||
// The request layer already presents transport errors.
|
||||
} finally { refundLoading.value = false; refundSubmitting.value = false }
|
||||
}
|
||||
async function copyText(value) {
|
||||
try { await navigator.clipboard.writeText(String(value)); ElMessage.success('已复制') }
|
||||
catch { ElMessage.warning('复制失败,请手动选择文本') }
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.payment-orders { min-width: 980px; }
|
||||
.error-text { color: var(--el-color-danger); word-break: break-word; }
|
||||
.form-tip { color: var(--el-text-color-secondary); font-size: 12px; line-height: 1.4; margin-top: 4px; }
|
||||
.payment-orders { min-width: 0; padding-bottom: 24px; }
|
||||
.page-heading { display: flex; align-items: center; justify-content: space-between; gap: 18px; margin-bottom: 16px; }
|
||||
.page-heading h2 { margin: 0; color: var(--el-text-color-primary); font-size: 20px; font-weight: 600; letter-spacing: 0; }
|
||||
.page-heading p { margin: 5px 0 0; color: var(--el-text-color-secondary); font-size: 13px; }
|
||||
.status-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px; overflow: hidden; margin-bottom: 16px; border: 1px solid var(--el-border-color-lighter); border-radius: 6px; background: var(--el-border-color-lighter); }
|
||||
.summary-item { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 3px 10px; min-width: 0; padding: 14px 16px; background: var(--el-bg-color); }
|
||||
.summary-item span { color: var(--el-text-color-regular); font-size: 13px; }
|
||||
.summary-item strong { grid-row: span 2; color: var(--el-text-color-primary); font-size: 24px; font-weight: 600; line-height: 1; }
|
||||
.summary-item small { overflow: hidden; color: var(--el-text-color-secondary); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.filter-band { margin-bottom: 16px; }
|
||||
.filter-grid { display: grid; grid-template-columns: repeat(3, minmax(160px, 1fr)); gap: 0 14px; }
|
||||
.filter-grid :deep(.el-form-item) { margin-bottom: 12px; }
|
||||
.filter-grid :deep(.el-select), .filter-grid :deep(.el-input) { width: 100%; }
|
||||
.filter-actions { display: flex; align-items: flex-end; gap: 8px; padding-bottom: 12px; }
|
||||
.order-table-band { min-width: 0; overflow: hidden; }
|
||||
.table-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding-bottom: 12px; }
|
||||
.table-heading > div { display: flex; align-items: baseline; gap: 9px; }
|
||||
.table-heading strong { color: var(--el-text-color-primary); font-size: 15px; font-weight: 600; }
|
||||
.table-heading span { color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.issue-count { color: var(--el-color-danger) !important; }
|
||||
.order-cell, .business-cell, .provider-cell, .amount-cell { display: grid; min-width: 0; gap: 4px; }
|
||||
.order-link { overflow: hidden; padding: 0; border: 0; background: transparent; color: var(--el-color-primary); font: inherit; font-weight: 500; text-align: left; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; }
|
||||
.order-cell > span, .business-cell small, .provider-cell small, .amount-cell small { overflow: hidden; color: var(--el-text-color-secondary); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.business-cell > span, .provider-cell > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.amount-cell { justify-items: end; }
|
||||
.amount-cell strong { color: var(--el-text-color-primary); font-weight: 600; white-space: nowrap; }
|
||||
.row-actions { display: flex; align-items: center; min-height: 32px; white-space: nowrap; }
|
||||
.row-actions :deep(.el-button + .el-button) { margin-left: 8px; }
|
||||
.payment-orders :deep(.el-table__row.is-payment-issue > td.el-table__cell) { background: var(--el-color-danger-light-9); }
|
||||
.kra-pagination { overflow-x: auto; }
|
||||
.order-detail { min-height: 220px; }
|
||||
.detail-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding-bottom: 16px; border-bottom: 1px solid var(--el-border-color-lighter); }
|
||||
.detail-statuses, .detail-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
|
||||
.detail-error { margin-top: 16px; }
|
||||
.section-heading { margin: 22px 0 12px; color: var(--el-text-color-primary); font-size: 14px; font-weight: 600; letter-spacing: 0; }
|
||||
.copy-value { display: inline-flex; align-items: center; gap: 4px; max-width: 100%; word-break: break-all; }
|
||||
.amount-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); border-top: 1px solid var(--el-border-color-lighter); border-left: 1px solid var(--el-border-color-lighter); }
|
||||
.amount-item { display: grid; min-width: 0; gap: 5px; padding: 12px 14px; border-right: 1px solid var(--el-border-color-lighter); border-bottom: 1px solid var(--el-border-color-lighter); }
|
||||
.amount-item span { color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.amount-item strong { overflow: hidden; color: var(--el-text-color-primary); font-size: 14px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.amount-alert { margin-top: 12px; }
|
||||
.order-timeline { margin: 0; padding-top: 4px; }
|
||||
.refund-summary { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px 14px; margin-bottom: 18px; padding: 12px 14px; border: 1px solid var(--el-border-color-lighter); border-radius: 6px; background: var(--el-fill-color-lighter); }
|
||||
.refund-summary span { color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.refund-summary strong { overflow-wrap: anywhere; color: var(--el-text-color-primary); font-size: 13px; text-align: right; }
|
||||
.form-tip { width: 100%; margin-top: 5px; color: var(--el-text-color-secondary); font-size: 12px; line-height: 1.4; }
|
||||
@media (max-width: 1000px) { .filter-grid { grid-template-columns: repeat(2, minmax(160px, 1fr)); } .amount-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@media (max-width: 720px) { .page-heading { align-items: flex-start; } .page-heading p { max-width: 250px; } .status-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); } .summary-item { padding: 12px; } .summary-item strong { font-size: 20px; } .filter-grid { grid-template-columns: 1fr; } .filter-actions { align-items: stretch; padding-bottom: 4px; } .filter-actions :deep(.el-button) { flex: 1; } .detail-toolbar { align-items: stretch; flex-direction: column; } .detail-actions :deep(.el-button) { margin-left: 0; } .amount-grid { grid-template-columns: 1fr; } }
|
||||
@media (max-width: 440px) { .status-summary { grid-template-columns: 1fr; } .table-heading { align-items: flex-start; flex-direction: column; gap: 5px; } .refund-summary { grid-template-columns: 1fr; } .refund-summary strong { text-align: left; } }
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Reference in New Issue