优化结构

This commit is contained in:
Yvan 2026-08-21 21:37:30 +08:00
parent a2ce3ae218
commit e4ba0dced9
41 changed files with 3646 additions and 364 deletions

5
cmd/wire_gen.go generated
View File

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

1355
docs/OA_MIGRATION_PLAN.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -13,7 +13,7 @@
| 支付 provider/mode 标识 | `pkg/paymentkit` | provider 常量、支持列表、金额/签名/JSON 等跨模块协议system `biz` 只保留兼容别名。 |
| 支付回调 ACK | `pkg/paymentkit` | 回调应答、失败包装和默认 provider 应答;具体渠道 SDK 仍留在 system integration。 |
| WebSocket 通用收发 | `pkg/websocket` | Melody 的连接、事件、点对点发送、广播和会话查询封装system integration 管理配置与生命周期。 |
| 消息队列 | `pkg/mq` | Broker 无关的发布、订阅、JSON 和 QoS 接口EMQX/Paho 客户端由 system integration 管理。 |
| 消息队列 | `pkg/mq` | Broker 无关的发布、订阅、JSON 和 QoS 接口EMQX/Paho 与 RabbitMQ/AMQP 客户端由 system integration 管理。 |
| 模块、任务和迁移协议 | `pkg/module`、`pkg/task`、`pkg/database/migration` | 供不同业务模块注册贡献,不带 system 业务语义。 |
## system 内部保留边界
@ -23,7 +23,7 @@
- `conf`system 配置 proto、运行时快照和生成代码。
- `data`数据库连接、PO、仓储、system 表、支付持久化和配置 watcher。
- `initialize`:首次安装、配置迁移、种子编排和运行时重载。
- `integration`Redis、邮件、存储、支付、WebSocket 和 EMQX 的 provider 生命周期。
- `integration`Redis、邮件、存储、支付、WebSocket、EMQX 和 RabbitMQ 的 provider 生命周期。
- `security`JWT claims、签发/解析和后台安全实现。
- `service`HTTP DTO`service/dto`、DTO 与 DO 转换、应用服务和路由元数据。
- `server`Gin 生命周期handler、middleware、router、HTTP 适配按子包维护。

2
go.mod
View File

@ -28,6 +28,7 @@ require (
github.com/mojocn/base64Captcha v1.3.8
github.com/olahol/melody v1.4.0
github.com/qiniu/go-sdk/v7 v7.25.2
github.com/rabbitmq/amqp091-go v1.14.0
github.com/redis/go-redis/v9 v9.7.0
github.com/robfig/cron/v3 v3.0.1
github.com/shirou/gopsutil/v4 v4.25.7
@ -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

View File

@ -5,10 +5,10 @@
- `app`组合根、system 模块定义和任务/路由运行时组合
- `biz`:系统领域对象、用例和仓储接口
- `conf`:配置 proto 与运行时配置解析
- `conf`基础配置 proto 与运行时配置解析
- `data`:数据库生命周期、系统仓储、系统表和支付持久化
- `initialize`:数据库首次初始化和系统种子数据编排
- `integration`Redis、邮件、对象存储、支付、WebSocket 和 EMQX 适配器
- `integration`Redis、邮件、对象存储、支付、WebSocket、EMQX 和 RabbitMQ 适配器
- `security`:后台 JWT 等安全实现
- `server`Gin server 组合与生命周期;横切 HTTP 代码按子包维护:
`server/handler`、`server/middleware`、`server/router`、`server/httpx`

View File

@ -9,10 +9,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},

View File

@ -0,0 +1,27 @@
package app
import "testing"
func TestDefinitionIncludesCommunicationIntegrationSurface(t *testing.T) {
surface := Definition().Surface
menuFound := false
for _, menu := range surface.Menus {
if menu.Name == "integrationConfig" {
menuFound = menu.ParentName == "extensions" && menu.Component == "view/systemTools/integration/config.vue"
break
}
}
if !menuFound {
t.Fatal("communication integration menu is missing")
}
apiFound := false
for _, api := range surface.APIs {
if api.Method == "PUT" && api.Path == "/integration/configs/:kind/:provider" {
apiFound = true
break
}
}
if !apiFound {
t.Fatal("communication integration save API is missing")
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -123,25 +123,6 @@ func TestStorageIntegrationConfigRoundTrip(t *testing.T) {
}
}
func TestMQIntegrationConfigRoundTrip(t *testing.T) {
db := openIntegrationConfigTestDB(t)
legacy := &conf.AdminBackend_MQ{Enabled: true, Broker: "mqtt://emqx.example.com:1883", ClientId: "system", Username: "app", Password: "secret", KeepAlive: 45, CleanSession: true, ConnectTimeout: 12}
if err := saveMQIntegrationConfig(db, legacy); err != nil {
t.Fatal(err)
}
loaded, found, err := loadMQIntegrationConfig(db)
if err != nil || !found {
t.Fatalf("found=%v err=%v", found, err)
}
if loaded.Broker != legacy.Broker || loaded.Password != legacy.Password || loaded.KeepAlive != 45 {
t.Fatalf("loaded mq = %#v", loaded)
}
loaded, err = resolveMQIntegrationConfig(db, &conf.AdminBackend_MQ{Broker: "must-not-replace"})
if err != nil || loaded.Broker != legacy.Broker {
t.Fatalf("database mq was replaced: %#v err=%v", loaded, err)
}
}
func TestResolveStorageIntegrationConfigMigratesLegacyOnlyOnce(t *testing.T) {
db := openIntegrationConfigTestDB(t)
legacy := &conf.AdminBackend_Storage{
@ -205,7 +186,7 @@ func TestResolveEmailIntegrationConfigMigratesLegacyOnlyOnce(t *testing.T) {
func TestPersistConfigValuesRemovesStorageFromYAML(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
input := []byte("data: {}\nadmin:\n router_prefix: /old\n storage:\n type: qiniu\n qiniu:\n secret_key: legacy-secret\n email:\n host: smtp.legacy.example.com\n secret: legacy-email-secret\n mq:\n enabled: true\n broker: tcp://localhost:1883\n password: legacy-mq-secret\n extension_key: retained\n")
input := []byte("data: {}\nadmin:\n router_prefix: /old\n storage:\n type: qiniu\n qiniu:\n secret_key: legacy-secret\n email:\n host: smtp.legacy.example.com\n secret: legacy-email-secret\n extension_key: retained\n")
if err := os.WriteFile(path, input, 0o600); err != nil {
t.Fatal(err)
}
@ -218,7 +199,6 @@ func TestPersistConfigValuesRemovesStorageFromYAML(t *testing.T) {
Qiniu: &conf.AdminBackend_Qiniu{SecretKey: "database-only-secret"},
},
Email: &conf.AdminBackend_Email{Host: "smtp.database.example.com", Secret: "database-only-email-secret"},
Mq: &conf.AdminBackend_MQ{Enabled: true, Broker: "tcp://emqx:1883", Password: "database-only-mq-secret"},
}
if err := d.persistConfigValues(&conf.Data{}, admin); err != nil {
t.Fatal(err)
@ -242,9 +222,6 @@ func TestPersistConfigValuesRemovesStorageFromYAML(t *testing.T) {
if _, exists := adminValue["email"]; exists {
t.Fatalf("email remained in YAML: %s", raw)
}
if _, exists := adminValue["mq"]; exists {
t.Fatalf("mq remained in YAML: %s", raw)
}
if adminValue["extension_key"] != "retained" {
t.Fatalf("extension key was not retained: %#v", adminValue)
}

View File

@ -0,0 +1,39 @@
package data
import (
"encoding/json"
"errors"
"kra/internal/biz"
"gorm.io/gorm"
)
func ensureCommunicationIntegrationConfigs(db *gorm.DB) error {
defaults := []struct {
kind string
provider string
}{
{kind: biz.IntegrationKindMQ, provider: "emqx"},
{kind: biz.IntegrationKindMQ, provider: "rabbitmq"},
{kind: biz.IntegrationKindWebSocket, provider: "melody"},
}
for _, item := range defaults {
var row integrationConfigPO
err := db.Where("kind = ? AND provider = ?", item.kind, item.provider).First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
values, marshalErr := json.Marshal(biz.DefaultIntegrationConfig(item.kind, item.provider))
if marshalErr != nil {
return marshalErr
}
if err = db.Create(&integrationConfigPO{Kind: item.kind, Provider: item.provider, Enabled: false, Config: string(values)}).Error; err != nil {
return err
}
continue
}
if err != nil {
return err
}
}
return nil
}

View File

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

View File

@ -10,12 +10,15 @@ import (
)
func InfrastructureMigrations() []migration.Step {
return []migration.Step{{
ID: "202608200001_data_infrastructure",
Migrate: func(db *gorm.DB) error {
return migration.CreateMissingTables(db, &integrationConfigPO{})
return []migration.Step{
{
ID: "202608200001_data_infrastructure",
Migrate: func(db *gorm.DB) error {
return migration.CreateMissingTables(db, &integrationConfigPO{})
},
},
}}
{ID: "202608210001_communication_integration_defaults", Migrate: ensureCommunicationIntegrationConfigs},
}
}
// migrateAll is the single data-layer migration entry point. Module-specific

View File

@ -26,8 +26,20 @@ func TestMigrateAllRunsModuleSchemasWithoutBootstrapSeed(t *testing.T) {
if err = db.Table(migration.TableName).Count(&versions).Error; err != nil {
t.Fatal(err)
}
if versions != 4 {
t.Fatalf("migration versions = %d, want 4", versions)
if versions != 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

View File

@ -26,12 +26,16 @@ func AdminSurface() platformmodule.Surface {
{Name: "paymentConfig", Path: "paymentConfig", ParentName: "extensions", Component: "view/systemTools/payment/config.vue", Title: "支付配置", Icon: "credit-card", Sort: 7},
},
APIs: []platformmodule.API{
{Path: "/integration/configs/:kind", Method: "GET", Group: "集成配置", Description: "按类型获取集成配置"},
{Path: "/integration/configs/:kind/:provider", Method: "GET", Group: "集成配置", Description: "获取指定集成配置"},
{Path: "/integration/configs/:kind/:provider", Method: "PUT", Group: "集成配置", Description: "保存集成配置"},
{Path: "/integration/configs/:kind/:provider", Method: "DELETE", Group: "集成配置", Description: "删除集成配置"},
{Path: "/payment/orders", Method: "GET", Group: "支付", Description: "分页查询支付订单"},
{Path: "/payment/order", Method: "POST", Group: "支付", Description: "查询支付订单"},
{Path: "/payment/orders/:provider/:tradeNo", Method: "GET", Group: "支付", Description: "按路径查询支付订单"},
{Path: "/payment/create", Method: "POST", Group: "支付", Description: "创建支付订单"},
{Path: "/payment/query", Method: "POST", Group: "支付", Description: "同步支付订单状态"},
{Path: "/payment/refund", Method: "POST", Group: "支付", Description: "申请支付订单退款"},
{Path: "/payment/orders/:provider/:tradeNo/refund", Method: "POST", Group: "支付", Description: "按路径申请支付订单退款"},
{Path: "/payment/fulfill", Method: "POST", Group: "支付", Description: "重试支付订单发货"},
{Path: "/payment/orders/:provider/:tradeNo/fulfill", Method: "POST", Group: "支付", Description: "按路径重试支付订单发货"},
{Path: "/payment/providers/:provider/test", Method: "POST", Group: "支付", Description: "测试支付渠道"},
},
}
}

View File

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

View File

@ -0,0 +1,53 @@
package system
import (
"context"
"encoding/json"
"testing"
"kra/internal/biz"
"kra/internal/integration/runtimeconfig"
)
type integrationRuntimeTestProvider struct {
*Data
store *runtimeconfig.Store
}
func (p *integrationRuntimeTestProvider) IntegrationRuntime() *runtimeconfig.Store { return p.store }
func TestIntegrationConfigSavePublishesUnmaskedRuntimeValues(t *testing.T) {
db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared")
if err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&integrationConfigPO{}); err != nil {
t.Fatal(err)
}
provider := &integrationRuntimeTestProvider{Data: &Data{gormDB: newReloadableDB(db, nil)}, store: runtimeconfig.NewStore()}
repo := &integrationConfigRepo{data: provider}
values := biz.DefaultIntegrationConfig(biz.IntegrationKindMQ, "rabbitmq")
values["password"] = "runtime-secret"
raw, _ := json.Marshal(values)
if err = repo.SaveIntegrationConfig(context.Background(), &biz.IntegrationConfig{Kind: biz.IntegrationKindMQ, Provider: "rabbitmq", Enabled: true, Values: raw}); err != nil {
t.Fatal(err)
}
values["password"] = "******"
raw, _ = json.Marshal(values)
if err = repo.SaveIntegrationConfig(context.Background(), &biz.IntegrationConfig{Kind: biz.IntegrationKindMQ, Provider: "rabbitmq", Enabled: true, Values: raw}); err != nil {
t.Fatal(err)
}
active, ok := provider.store.Get(biz.IntegrationKindMQ, "rabbitmq")
if !ok || !active.Enabled {
t.Fatalf("runtime config = %#v, ok=%v", active, ok)
}
stored := map[string]any{}
if err = json.Unmarshal(active.Values, &stored); err != nil {
t.Fatal(err)
}
if stored["password"] != "runtime-secret" {
t.Fatalf("runtime password = %#v", stored["password"])
}
}

View File

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

View File

@ -8,7 +8,7 @@ state, or translate provider-specific protocols into `biz` interfaces.
- `cache`: Redis-backed cache with an in-memory fallback.
- `email`: SMTP email repository.
- `mq`: reloadable EMQX/MQTT client exposed through the shared `pkg/mq` interface.
- `mq`: reloadable EMQX/MQTT and RabbitMQ/AMQP clients exposed through the shared `pkg/mq` interface.
- `payment`: payment-channel SDKs and callback/signature handling.
- `storage`: local and object-storage implementations of `biz.FileStorage`.
- `websocket`: reloadable Melody endpoint exposed through the shared
@ -28,7 +28,7 @@ Business modules depend on `websocket.Hub` and `mq.Client` from the shared
`pkg/websocket` and `pkg/mq` packages; they do not construct
Melody or Paho clients and do not read system configuration directly. The
system integration packages own runtime refresh and shutdown. Registered
WebSocket handlers and MQTT subscriptions are retained when database-backed
WebSocket handlers and broker subscriptions are retained when database-backed
configuration replaces a live client.
```go
@ -45,7 +45,8 @@ Integration configuration is stored in `sys_integration_configs`:
- WebSocket: `kind=websocket`, `provider=melody`
- EMQX: `kind=mq`, `provider=emqx`
- RabbitMQ: `kind=mq`, `provider=rabbitmq`
The YAML values are only migration/bootstrap inputs. After the integration
table exists, the database is authoritative and runtime updates are applied
without restarting the process. The WebSocket public path defaults to `/ws`.
These three integrations are stored only in `sys_integration_configs`; they do
not come from `config.yaml`. Runtime updates are applied without restarting
the process. The WebSocket public path defaults to `/ws`.

View File

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

View File

@ -26,28 +26,33 @@ func (*fakeClient) Close() error { return nil }
func TestReloadableTracksSubscriptions(t *testing.T) {
client := &fakeClient{}
r := &Reloadable{current: client, subscriptions: make(map[string]subscription)}
r := &Reloadable{
clients: map[string]platformmq.Client{ProviderEMQX: client},
subscriptions: make(map[string]map[string]subscription),
}
handler := func(context.Context, platformmq.Message) {}
if err := r.Subscribe(context.Background(), "orders/+/paid", platformmq.AtLeastOnce, handler); err != nil {
t.Fatal(err)
}
if _, ok := r.subscriptions["orders/+/paid"]; !ok {
if _, ok := r.subscriptions[ProviderEMQX]["orders/+/paid"]; !ok {
t.Fatal("subscription was not retained for configuration reload")
}
if err := r.Unsubscribe(context.Background(), "orders/+/paid"); err != nil {
t.Fatal(err)
}
if _, ok := r.subscriptions["orders/+/paid"]; ok {
if _, ok := r.subscriptions[ProviderEMQX]["orders/+/paid"]; ok {
t.Fatal("unsubscribed topic remained in the reload registry")
}
}
func TestReloadableRestoresSubscriptions(t *testing.T) {
client := &fakeClient{}
r := &Reloadable{subscriptions: map[string]subscription{
"orders/+/paid": {qos: platformmq.AtLeastOnce, handler: func(context.Context, platformmq.Message) {}},
r := &Reloadable{subscriptions: map[string]map[string]subscription{
ProviderEMQX: {
"orders/+/paid": {qos: platformmq.AtLeastOnce, handler: func(context.Context, platformmq.Message) {}},
},
}}
if err := r.restoreSubscriptions(context.Background(), client); err != nil {
if err := r.restoreSubscriptionsLocked(ProviderEMQX, client); err != nil {
t.Fatal(err)
}
if len(client.subscribed) != 1 || client.subscribed[0] != "orders/+/paid" {

View File

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

View File

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

View File

@ -1,4 +1,4 @@
package integrationruntime
package runtimeconfig
import (
"encoding/json"

View File

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

View File

@ -65,6 +65,12 @@ func NewGinEngineWithRuntime(runtime *conf.Runtime, access *service.AccessContro
}
registerSwagger(engine, prefix, version, logger)
registerLocalStorage(engine, runtime)
if logger != nil {
for _, route := range engine.Routes() {
logger.Info("router registered", "method", route.Method, "path", route.Path)
}
logger.Info("router register success", "route_count", len(engine.Routes()))
}
engine.NoRoute(func(c *gin.Context) {
if ws != nil && ws.Enabled() && c.Request.Method == http.MethodGet && c.Request.URL.Path == ws.Path() {

View File

@ -2,10 +2,14 @@ package middleware
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"log/slog"
"mime"
"net/http"
"strconv"
"strings"
"time"
@ -67,14 +71,26 @@ func AccessLog(runtime *conf.Runtime, logger *slog.Logger, version string) gin.H
if config != nil && config.Zap != nil && config.Zap.AccessLogMaxBytes > 0 {
logLimit = int(config.Zap.AccessLogMaxBytes)
}
paymentCallback := isPaymentCallbackPath(c.Request.URL.Path)
paymentConfigWrite := isPaymentIntegrationConfigWrite(c.Request.Method, c.Request.URL.Path)
requestText := ""
if multipart {
if paymentCallback {
requestText = paymentCallbackSummary(requestBody, c.GetHeader("Content-Type"))
} else if paymentConfigWrite {
requestText = paymentConfigSummary(requestBody)
} else if multipart {
requestText = "[文件]"
} else {
requestText = redactJSON(requestBody, c.GetHeader("Content-Type"), logLimit)
}
c.Set(ctxReqBodyKey, requestText)
c.Set(ctxRespBufferKey, &writer.body)
if paymentCallback {
// Callback acknowledgements and provider payloads must not flow into
// the generic response/error audit pipeline.
c.Set(ctxRespBufferKey, &bytes.Buffer{})
} else {
c.Set(ctxRespBufferKey, &writer.body)
}
if !requestReadFailed {
c.Next()
}
@ -82,6 +98,9 @@ func AccessLog(runtime *conf.Runtime, logger *slog.Logger, version string) gin.H
return
}
responseText := redactJSON(writer.body.Bytes(), c.Writer.Header().Get("Content-Type"), logLimit)
if paymentCallback {
responseText = "[支付回调响应已省略]"
}
userID, authorityID := uint(0), uint(0)
if claims := Claims(c); claims != nil {
userID, authorityID = claims.ID, claims.AuthorityID
@ -95,22 +114,36 @@ func AccessLog(runtime *conf.Runtime, logger *slog.Logger, version string) gin.H
bytesOut = 0
}
privateErrors := strings.TrimRight(c.Errors.ByType(gin.ErrorTypePrivate).String(), "\n")
attributes := []any{
"mod", "http", "ip", c.ClientIP(), "method", c.Request.Method, "http_path", c.Request.URL.Path, "http_route", route,
"http_status", c.Writer.Status(), "latency_ms", time.Since(started).Milliseconds(),
"request_id", stringValueFromContext(c, "request_id"), "trace_id", stringValueFromContext(c, "trace_id"),
"bytes_in", bytesIn, "bytes_out", bytesOut, "user_id", userID, "authority_id", authorityID,
"error", c.Writer.Status() >= http.StatusInternalServerError || privateErrors != "", "ua", c.Request.UserAgent(), "req_query", c.Request.URL.RawQuery}
if config != nil && config.Zap != nil && config.Zap.AccessReqHeaders {
var attributes []any
if paymentCallback {
attributes = []any{
"mod", "payment-callback", "payment_provider", paymentCallbackProvider(c.Request.URL.Path),
"http_status", c.Writer.Status(), "latency_ms", time.Since(started).Milliseconds(),
"request_id", stringValueFromContext(c, "request_id"), "trace_id", stringValueFromContext(c, "trace_id"),
"bytes_in", bytesIn, "bytes_out", bytesOut,
"error", c.Writer.Status() >= http.StatusInternalServerError || privateErrors != "",
"payment_callback", true, "payment_callback_summary", requestText,
}
} else {
attributes = []any{
"mod", "http", "ip", c.ClientIP(), "method", c.Request.Method, "http_path", c.Request.URL.Path, "http_route", route,
"http_status", c.Writer.Status(), "latency_ms", time.Since(started).Milliseconds(),
"request_id", stringValueFromContext(c, "request_id"), "trace_id", stringValueFromContext(c, "trace_id"),
"bytes_in", bytesIn, "bytes_out", bytesOut, "user_id", userID, "authority_id", authorityID,
"error", c.Writer.Status() >= http.StatusInternalServerError || privateErrors != "", "ua", c.Request.UserAgent(),
"req_query", c.Request.URL.RawQuery,
}
}
if !paymentCallback && config != nil && config.Zap != nil && config.Zap.AccessReqHeaders {
attributes = append(attributes, "req_headers", redactHeaders(c.Request.Header))
}
if config != nil && config.Zap != nil && config.Zap.AccessReqBody {
if !paymentCallback && config != nil && config.Zap != nil && config.Zap.AccessReqBody {
attributes = append(attributes, "req_body", requestText)
}
if config != nil && config.Zap != nil && config.Zap.AccessRespData {
if !paymentCallback && config != nil && config.Zap != nil && config.Zap.AccessRespData {
attributes = append(attributes, "resp_data", responseText)
}
if privateErrors != "" {
if !paymentCallback && privateErrors != "" {
attributes = append(attributes, "error_msg", privateErrors)
}
logger.InfoContext(c.Request.Context(), "请求完成", attributes...)
@ -122,6 +155,56 @@ func isMediaUploadRoute(route string) bool {
strings.HasSuffix(route, "/mediaUpload/chunk")
}
func isPaymentCallbackPath(path string) bool {
parts := strings.Split(strings.Trim(path, "/"), "/")
for index := 0; index+1 < len(parts); index++ {
if parts[index] == "payment" && parts[index+1] == "callback" {
return true
}
}
return false
}
func paymentCallbackProvider(path string) string {
parts := strings.Split(strings.Trim(path, "/"), "/")
for index := 0; index+2 < len(parts); index++ {
if parts[index] == "payment" && parts[index+1] == "callback" {
return parts[index+2]
}
}
return "unknown"
}
func paymentCallbackSummary(body []byte, contentType string) string {
mediaType, _, err := mime.ParseMediaType(contentType)
if err != nil || mediaType == "" {
mediaType = strings.TrimSpace(strings.SplitN(contentType, ";", 2)[0])
}
if mediaType == "" {
mediaType = "unknown"
}
digest := sha256.Sum256(body)
return "[支付回调正文已省略 body_bytes=" + strconv.Itoa(len(body)) + " body_sha256=" + hex.EncodeToString(digest[:]) + " content_type=" + mediaType + "]"
}
func isPaymentIntegrationConfigWrite(method, path string) bool {
if method != http.MethodPut {
return false
}
parts := strings.Split(strings.Trim(path, "/"), "/")
for index := 0; index+3 < len(parts); index++ {
if parts[index] == "integration" && parts[index+1] == "configs" && parts[index+2] == "payment" && parts[index+3] != "" {
return true
}
}
return false
}
func paymentConfigSummary(body []byte) string {
digest := sha256.Sum256(body)
return "[支付配置正文已省略 body_bytes=" + strconv.Itoa(len(body)) + " body_sha256=" + hex.EncodeToString(digest[:]) + "]"
}
func redactHeaders(headers map[string][]string) map[string]string {
out := make(map[string]string, len(headers))
for key, values := range headers {

View File

@ -1,7 +1,9 @@
package middleware
import (
"bytes"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
@ -63,3 +65,60 @@ func TestAccessLogAllowsMediaLimitOnlyOnUploadRoute(t *testing.T) {
t.Fatalf("status=%d called=%v body=%s", response.Code, called, response.Body.String())
}
}
func TestAccessLogRedactsPaymentCallbackPayloadAndHeaders(t *testing.T) {
gin.SetMode(gin.TestMode)
var logs bytes.Buffer
logger := slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelInfo}))
runtime := conf.NewRuntime(nil, &conf.AdminBackend{Zap: &conf.AdminBackend_Zap{AccessReqBody: true, AccessReqHeaders: true, AccessRespData: true}})
engine := gin.New()
engine.Use(AccessLog(runtime, logger, "test"))
engine.POST("/api/payment/callback/:provider", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"body": "callback-response-secret"})
})
request := httptest.NewRequest(http.MethodPost, "/api/payment/callback/alipay?signature=query-secret", strings.NewReader("payment-body-secret"))
request.Header.Set("Content-Type", "application/json; boundary=credential-secret")
request.Header.Set("Authorization", "Bearer header-secret")
request.Header.Set("X-Alipay-Signature", "signature-secret")
response := httptest.NewRecorder()
engine.ServeHTTP(response, request)
logText := logs.String()
for _, secret := range []string{"payment-body-secret", "query-secret", "header-secret", "signature-secret", "callback-response-secret", "credential-secret"} {
if strings.Contains(logText, secret) {
t.Fatalf("payment callback secret leaked into access log: %q in %s", secret, logText)
}
}
for _, marker := range []string{"payment_callback=true", "payment_provider=alipay", "body_sha256=", "http_status=200"} {
if !strings.Contains(logText, marker) {
t.Fatalf("payment callback access summary missing %q: %s", marker, logText)
}
}
}
func TestAccessLogOmitsPaymentIntegrationConfigBody(t *testing.T) {
gin.SetMode(gin.TestMode)
var logs bytes.Buffer
logger := slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelInfo}))
runtime := conf.NewRuntime(nil, &conf.AdminBackend{Zap: &conf.AdminBackend_Zap{AccessReqBody: true, AccessReqHeaders: true, AccessRespData: true}})
engine := gin.New()
engine.Use(AccessLog(runtime, logger, "test"))
engine.PUT("/api/integration/configs/:kind/:provider", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"code": 0}) })
body := `{"enabled":true,"config":{"key":"saobei-secret","certificate_blob":"certificate-secret","unknown_credential":"credential-secret"}}`
request := httptest.NewRequest(http.MethodPut, "/api/integration/configs/payment/saobei", strings.NewReader(body))
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
engine.ServeHTTP(response, request)
logText := logs.String()
for _, secret := range []string{"saobei-secret", "certificate-secret", "credential-secret"} {
if strings.Contains(logText, secret) {
t.Fatalf("payment configuration secret leaked into access log: %q in %s", secret, logText)
}
}
if !strings.Contains(logText, "支付配置正文已省略") || !strings.Contains(logText, "body_sha256=") {
t.Fatalf("payment configuration summary missing: %s", logText)
}
}

View File

@ -81,7 +81,11 @@ func OperationAudit(runtime *conf.Runtime, recorder *service.AuditRecorder) gin.
responseBody = "[超出记录长度]"
}
errorMessage := c.Errors.ByType(gin.ErrorTypePrivate).String()
if err := recorder.RecordOperationRequest(c.Request.Context(), &dto.OperationRecordRequest{IP: c.ClientIP(), Method: c.Request.Method, Path: path, Status: status, LatencyMS: time.Since(started).Milliseconds(), Agent: c.Request.UserAgent(), ErrorMessage: errorMessage, Body: operationRequestBody(requestBody, c.GetHeader("Content-Type"), maxBytes), Response: responseBody, UserID: userID, RequestID: stringValue(requestID), TraceID: stringValueFromContext(c, "trace_id"), DeviceID: c.GetHeader("X-Device-Id")}); err != nil {
operationBody := operationRequestBody(requestBody, c.GetHeader("Content-Type"), maxBytes)
if isPaymentIntegrationConfigWrite(c.Request.Method, path) {
operationBody = paymentConfigSummary(requestBody)
}
if err := recorder.RecordOperationRequest(c.Request.Context(), &dto.OperationRecordRequest{IP: c.ClientIP(), Method: c.Request.Method, Path: path, Status: status, LatencyMS: time.Since(started).Milliseconds(), Agent: c.Request.UserAgent(), ErrorMessage: errorMessage, Body: operationBody, Response: responseBody, UserID: userID, RequestID: stringValue(requestID), TraceID: stringValueFromContext(c, "trace_id"), DeviceID: c.GetHeader("X-Device-Id")}); err != nil {
// Preserve the business response, but expose audit persistence failures
// to the global access/error logging pipeline.
c.Set(ctxOperationAuditPersistFailedKey, true)
@ -128,7 +132,7 @@ func maskOperationBody(value any) {
case map[string]any:
for key, item := range current {
normalized := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(key, "_", ""), "-", ""))
if normalized == "password" || normalized == "newpassword" || normalized == "oldpassword" || normalized == "confirmpassword" || normalized == "passwd" || normalized == "pwd" || normalized == "token" || normalized == "accesstoken" || normalized == "refreshtoken" || normalized == "secret" || normalized == "clientsecret" || normalized == "apikey" || normalized == "privatekey" || normalized == "idcard" {
if normalized == "password" || normalized == "newpassword" || normalized == "oldpassword" || normalized == "confirmpassword" || normalized == "passwd" || normalized == "pwd" || normalized == "token" || normalized == "accesstoken" || normalized == "refreshtoken" || normalized == "secret" || normalized == "clientsecret" || normalized == "apikey" || normalized == "privatekey" || normalized == "idcard" || normalized == "appkey" || normalized == "mchkey" || normalized == "apiv3key" || normalized == "clientcert" || normalized == "clientkey" || normalized == "platformcert" || normalized == "platformserialno" || normalized == "credentialcode" || normalized == "certfile" || normalized == "keyfile" || normalized == "publickey" || normalized == "rootcert" || normalized == "appcert" || normalized == "webhookid" {
current[key] = "***"
continue
}
@ -157,17 +161,49 @@ func isDownloadResponse(c *gin.Context) bool {
// recordsOperation mirrors the routes on which operation records are enabled.
// Matching by suffix keeps the behavior stable when router-prefix is configured.
func recordsOperation(method, path string) bool {
_, ok := operationRoutes[method+" "+routeSuffix(path)]
return ok
for route := range operationRoutes {
parts := strings.SplitN(route, " ", 2)
if len(parts) != 2 || parts[0] != method || !operationPathMatches(parts[1], path) {
continue
}
return true
}
return false
}
func operationPathMatches(pattern, path string) bool {
patternParts := strings.Split(strings.Trim(pattern, "/"), "/")
pathParts := strings.Split(strings.Trim(path, "/"), "/")
if len(pathParts) < len(patternParts) {
return false
}
pathParts = pathParts[len(pathParts)-len(patternParts):]
for index, patternPart := range patternParts {
if strings.HasPrefix(patternPart, "*") {
return index <= len(pathParts)
}
if index >= len(pathParts) || (strings.HasPrefix(patternPart, ":") == false && patternPart != pathParts[index]) {
return false
}
}
return len(patternParts) == len(pathParts)
}
func routeSuffix(path string) string {
for _, marker := range []string{"/user/", "/api/", "/casbin/", "/authority/", "/menu/", "/department/", "/position/", "/sysDictionary/", "/sysDictionaryDetail/", "/sysParams/", "/securityConfig/", "/system/", "/sysApiToken/", "/sysVersion/", "/sysExportTemplate/", "/sysError/", "/sysLoginLog/", "/sysOperationRecord/", "/dataAccessLog/", "/timedTask/", "/info/", "/email/"} {
bestIndex := -1
bestPath := path
for _, marker := range []string{"/user/", "/api/", "/casbin/", "/authority/", "/menu/", "/department/", "/position/", "/sysDictionary/", "/sysDictionaryDetail/", "/sysParams/", "/securityConfig/", "/system/", "/sysApiToken/", "/sysVersion/", "/sysExportTemplate/", "/sysError/", "/sysLoginLog/", "/sysOperationRecord/", "/dataAccessLog/", "/timedTask/", "/info/", "/email/", "/integration/", "/payment/"} {
if index := strings.Index(path, marker); index >= 0 {
return path[index:]
// Router prefixes may themselves contain a registered route marker
// (for example /api/integration/...). Keep the deepest match so the
// policy and audit route remain the actual application endpoint.
if index > bestIndex {
bestIndex = index
bestPath = path[index:]
}
}
}
return path
return bestPath
}
var operationRoutes = func() map[string]struct{} {
@ -189,6 +225,8 @@ var operationRoutes = func() map[string]struct{} {
"DELETE /sysLoginLog/deleteLoginLog", "DELETE /sysLoginLog/deleteLoginLogByIds", "DELETE /dataAccessLog/deleteDataAccessLogByIds",
"POST /timedTask/createTimedTask", "PUT /timedTask/updateTimedTask", "DELETE /timedTask/deleteTimedTask", "POST /timedTask/toggleTimedTask", "POST /timedTask/triggerTimedTask",
"POST /info/createInfo", "DELETE /info/deleteInfo", "DELETE /info/deleteInfoByIds", "PUT /info/updateInfo", "POST /email/emailTest", "POST /email/sendEmail",
"PUT /integration/configs/:kind/:provider", "DELETE /integration/configs/:kind/:provider",
"POST /payment/create", "POST /payment/query", "POST /payment/refund", "POST /payment/orders/:provider/:tradeNo/refund", "POST /payment/fulfill", "POST /payment/orders/:provider/:tradeNo/fulfill", "POST /payment/providers/:provider/test",
}
out := make(map[string]struct{}, len(values))
for _, value := range values {

View File

@ -0,0 +1,56 @@
package middleware
import (
"encoding/json"
"strings"
"testing"
)
func TestPaymentIntegrationSecretsAreRedacted(t *testing.T) {
raw := []byte(`{"enabled":true,"config":{"app_id":"app","mch_key":"merchant-secret","api_v3_key":"v3-secret","client_cert":"certificate","client_key":"private-key","platform_cert":"platform-certificate","credential_code":"credential","webhook_id":"webhook"}}`)
redacted := redactJSON(raw, "application/json", 4096)
var payload struct {
Config map[string]string `json:"config"`
}
if err := json.Unmarshal([]byte(redacted), &payload); err != nil {
t.Fatalf("decode redacted payload: %v", err)
}
for _, key := range []string{"mch_key", "api_v3_key", "client_cert", "client_key", "platform_cert", "credential_code", "webhook_id"} {
if payload.Config[key] != "***" {
t.Fatalf("payment secret %q was not redacted: %s", key, redacted)
}
}
if !strings.Contains(redacted, `"app_id":"app"`) {
t.Fatalf("non-secret integration field was removed: %s", redacted)
}
}
func TestPaymentOperationsAreAuditedWithRouterPrefix(t *testing.T) {
for _, route := range []struct {
method string
path string
}{
{method: "PUT", path: "/api/integration/configs/payment/alipay"},
{method: "POST", path: "/api/payment/refund"},
{method: "POST", path: "/api/payment/fulfill"},
{method: "POST", path: "/api/payment/providers/alipay/test"},
} {
if !recordsOperation(route.method, route.path) {
t.Fatalf("payment operation was not audited: %s %s", route.method, route.path)
}
}
}
func TestPaymentIntegrationConfigUsesRouteLevelSummary(t *testing.T) {
raw := []byte(`{"enabled":true,"config":{"key":"secret","custom_certificate":"certificate"}}`)
summary := paymentConfigSummary(raw)
if strings.Contains(summary, "secret") || strings.Contains(summary, "certificate") {
t.Fatalf("payment configuration summary leaked payload: %s", summary)
}
if !isPaymentIntegrationConfigWrite("PUT", "/api/integration/configs/payment/saobei") {
t.Fatal("payment configuration write route was not recognized")
}
if isPaymentIntegrationConfigWrite("PUT", "/api/integration/configs/mq/emqx") {
t.Fatal("non-payment integration was treated as payment configuration")
}
}

View File

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

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

@ -0,0 +1,447 @@
package mq
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"time"
amqp "github.com/rabbitmq/amqp091-go"
)
type RabbitMQConfig struct {
Enabled bool
Host string
Port int
Username string
Password string
VHost string
Exchange string
ExchangeType string
Queue string
RoutingKey string
Durable bool
AutoDelete bool
PrefetchCount int
Heartbeat time.Duration
ConnectTimeout time.Duration
TLS bool
}
type rabbitSubscription struct {
qos byte
handler Handler
}
// RabbitMQ adapts AMQP exchanges and routing keys to the shared topic-based
// Client contract. All subscriptions share the configured queue and a single
// consumer; deliveries are dispatched to matching handlers locally.
type RabbitMQ struct {
mu sync.RWMutex
opMu sync.Mutex
publishMu sync.Mutex
consumeMu sync.Mutex
connection *amqp.Connection
publishChannel *amqp.Channel
consumeChannel *amqp.Channel
config RabbitMQConfig
subscriptions map[string]rabbitSubscription
consumerTag string
consuming bool
stop chan struct{}
closed bool
}
func NewRabbitMQ(config RabbitMQConfig) (*RabbitMQ, error) {
client := &RabbitMQ{subscriptions: make(map[string]rabbitSubscription), stop: make(chan struct{})}
if !config.Enabled {
return client, nil
}
config = defaultRabbitMQConfig(config)
if err := validateRabbitMQConfig(config); err != nil {
return nil, err
}
scheme := "amqp"
if config.TLS {
scheme = "amqps"
}
address := amqp.URI{
Scheme: scheme, Host: config.Host, Port: config.Port,
Username: config.Username, Password: config.Password, Vhost: config.VHost,
ConnectionTimeout: int(config.ConnectTimeout.Milliseconds()),
}.String()
connection, err := amqp.DialConfig(address, amqp.Config{
Heartbeat: config.Heartbeat,
Recovery: &amqp.Recovery{},
})
if err != nil {
return nil, fmt.Errorf("connect rabbitmq: %w", err)
}
publishChannel, err := connection.Channel()
if err != nil {
_ = connection.Close()
return nil, fmt.Errorf("open rabbitmq publish channel: %w", err)
}
consumeChannel, err := connection.Channel()
if err != nil {
_ = publishChannel.Close()
_ = connection.Close()
return nil, fmt.Errorf("open rabbitmq consume channel: %w", err)
}
closeOnError := func() {
_ = consumeChannel.Close()
_ = publishChannel.Close()
_ = connection.Close()
}
if err = consumeChannel.ExchangeDeclare(config.Exchange, config.ExchangeType, config.Durable, config.AutoDelete, false, false, nil); err != nil {
closeOnError()
return nil, fmt.Errorf("declare rabbitmq exchange: %w", err)
}
if _, err = consumeChannel.QueueDeclare(config.Queue, config.Durable, config.AutoDelete, false, false, nil); err != nil {
closeOnError()
return nil, fmt.Errorf("declare rabbitmq queue: %w", err)
}
if config.PrefetchCount > 0 {
if err = consumeChannel.Qos(config.PrefetchCount, 0, false); err != nil {
closeOnError()
return nil, fmt.Errorf("configure rabbitmq qos: %w", err)
}
}
client.connection = connection
client.publishChannel = publishChannel
client.consumeChannel = consumeChannel
client.config = config
client.consumerTag = fmt.Sprintf("kra-%d", time.Now().UnixNano())
return client, nil
}
func defaultRabbitMQConfig(config RabbitMQConfig) RabbitMQConfig {
if config.Port <= 0 {
if config.TLS {
config.Port = 5671
} else {
config.Port = 5672
}
}
if config.Username == "" {
config.Username = "guest"
}
if config.Password == "" {
config.Password = "guest"
}
if config.VHost == "" {
config.VHost = "/"
}
if config.ExchangeType == "" {
config.ExchangeType = "topic"
}
if config.RoutingKey == "" {
config.RoutingKey = "#"
}
if config.Heartbeat <= 0 {
config.Heartbeat = 10 * time.Second
}
if config.ConnectTimeout <= 0 {
config.ConnectTimeout = 10 * time.Second
}
return config
}
func validateRabbitMQConfig(config RabbitMQConfig) error {
if strings.TrimSpace(config.Host) == "" {
return errors.New("rabbitmq host is empty")
}
if config.Port < 1 || config.Port > 65535 {
return fmt.Errorf("invalid rabbitmq port %d", config.Port)
}
if strings.TrimSpace(config.Exchange) == "" {
return errors.New("rabbitmq exchange is empty")
}
if strings.TrimSpace(config.Queue) == "" {
return errors.New("rabbitmq queue is empty")
}
switch strings.ToLower(config.ExchangeType) {
case "direct", "fanout", "topic":
return nil
default:
return fmt.Errorf("invalid rabbitmq exchange type %q", config.ExchangeType)
}
}
func (c *RabbitMQ) Publish(ctx context.Context, topic string, payload []byte, qos byte, _ bool) error {
if c == nil {
return ErrUnavailable
}
if c != nil && strings.TrimSpace(topic) == "" {
topic = c.config.RoutingKey
}
if strings.TrimSpace(topic) == "" {
return errors.New("rabbitmq routing key is empty")
}
if qos > AtLeastOnce {
return fmt.Errorf("rabbitmq supports qos 0 or 1, got %d", qos)
}
select {
case <-ctx.Done():
return ctx.Err()
default:
}
c.publishMu.Lock()
defer c.publishMu.Unlock()
c.mu.RLock()
if c.closed || c.publishChannel == nil || c.publishChannel.IsClosed() {
c.mu.RUnlock()
return ErrUnavailable
}
channel := c.publishChannel
exchange := c.config.Exchange
c.mu.RUnlock()
deliveryMode := amqp.Transient
if qos >= AtLeastOnce {
deliveryMode = amqp.Persistent
}
return channel.PublishWithContext(ctx, exchange, topic, false, false, amqp.Publishing{
ContentType: "application/octet-stream",
DeliveryMode: deliveryMode,
Timestamp: time.Now(),
Body: append([]byte(nil), payload...),
})
}
func (c *RabbitMQ) Subscribe(ctx context.Context, topic string, qos byte, handler Handler) error {
if c == nil {
return ErrUnavailable
}
if c != nil && strings.TrimSpace(topic) == "" {
topic = c.config.RoutingKey
}
if strings.TrimSpace(topic) == "" {
return errors.New("rabbitmq routing key is empty")
}
if qos > AtLeastOnce {
return fmt.Errorf("rabbitmq supports qos 0 or 1, got %d", qos)
}
if handler == nil {
return errors.New("rabbitmq handler is nil")
}
select {
case <-ctx.Done():
return ctx.Err()
default:
}
c.opMu.Lock()
defer c.opMu.Unlock()
c.consumeMu.Lock()
defer c.consumeMu.Unlock()
c.mu.RLock()
if c.closed || c.consumeChannel == nil || c.consumeChannel.IsClosed() {
c.mu.RUnlock()
return ErrUnavailable
}
channel := c.consumeChannel
config := c.config
_, exists := c.subscriptions[topic]
c.mu.RUnlock()
if !exists {
if err := channel.QueueBind(config.Queue, topic, config.Exchange, false, nil); err != nil {
return fmt.Errorf("bind rabbitmq queue: %w", err)
}
}
c.mu.Lock()
c.subscriptions[topic] = rabbitSubscription{qos: qos, handler: handler}
shouldStart := !c.consuming
c.mu.Unlock()
if !shouldStart {
return nil
}
deliveries, err := channel.Consume(config.Queue, c.consumerTag, false, false, false, false, nil)
if err != nil {
c.mu.Lock()
delete(c.subscriptions, topic)
c.mu.Unlock()
if !exists {
_ = channel.QueueUnbind(config.Queue, topic, config.Exchange, nil)
}
return fmt.Errorf("consume rabbitmq queue: %w", err)
}
c.mu.Lock()
c.consuming = true
c.mu.Unlock()
go c.consume(deliveries)
return nil
}
func (c *RabbitMQ) consume(deliveries <-chan amqp.Delivery) {
for {
select {
case <-c.stop:
return
case delivery, ok := <-deliveries:
if !ok {
c.mu.Lock()
c.consuming = false
c.mu.Unlock()
return
}
subscriptions := c.subscriptionSnapshot()
for pattern, item := range subscriptions {
if rabbitRoutingKeyMatches(c.config.ExchangeType, pattern, delivery.RoutingKey) {
item.handler(context.Background(), Message{Topic: delivery.RoutingKey, Payload: append([]byte(nil), delivery.Body...), QoS: item.qos})
}
}
c.consumeMu.Lock()
_ = delivery.Ack(false)
c.consumeMu.Unlock()
}
}
}
func (c *RabbitMQ) subscriptionSnapshot() map[string]rabbitSubscription {
c.mu.RLock()
defer c.mu.RUnlock()
result := make(map[string]rabbitSubscription, len(c.subscriptions))
for topic, item := range c.subscriptions {
result[topic] = item
}
return result
}
func (c *RabbitMQ) Unsubscribe(ctx context.Context, topics ...string) error {
if c == nil {
return ErrUnavailable
}
if len(topics) == 0 {
return errors.New("rabbitmq routing keys are empty")
}
select {
case <-ctx.Done():
return ctx.Err()
default:
}
c.opMu.Lock()
defer c.opMu.Unlock()
c.consumeMu.Lock()
defer c.consumeMu.Unlock()
c.mu.RLock()
if c.closed || c.consumeChannel == nil || c.consumeChannel.IsClosed() {
c.mu.RUnlock()
return ErrUnavailable
}
channel := c.consumeChannel
config := c.config
c.mu.RUnlock()
for _, topic := range topics {
c.mu.RLock()
_, exists := c.subscriptions[topic]
c.mu.RUnlock()
if !exists {
continue
}
if err := channel.QueueUnbind(config.Queue, topic, config.Exchange, nil); err != nil {
return fmt.Errorf("unbind rabbitmq queue: %w", err)
}
c.mu.Lock()
delete(c.subscriptions, topic)
c.mu.Unlock()
}
c.mu.RLock()
empty := len(c.subscriptions) == 0
consuming := c.consuming
c.mu.RUnlock()
if empty && consuming {
if err := channel.Cancel(c.consumerTag, false); err != nil {
return fmt.Errorf("cancel rabbitmq consumer: %w", err)
}
c.mu.Lock()
c.consuming = false
c.mu.Unlock()
}
return nil
}
func (c *RabbitMQ) Connected() bool {
if c == nil {
return false
}
c.mu.RLock()
defer c.mu.RUnlock()
return !c.closed && c.connection != nil && !c.connection.IsClosed()
}
func (c *RabbitMQ) Close() error {
if c == nil {
return nil
}
c.opMu.Lock()
defer c.opMu.Unlock()
c.publishMu.Lock()
defer c.publishMu.Unlock()
c.consumeMu.Lock()
defer c.consumeMu.Unlock()
c.mu.Lock()
if c.closed {
c.mu.Unlock()
return nil
}
c.closed = true
close(c.stop)
publishChannel := c.publishChannel
consumeChannel := c.consumeChannel
connection := c.connection
c.publishChannel = nil
c.consumeChannel = nil
c.connection = nil
c.mu.Unlock()
var result error
if consumeChannel != nil {
result = errors.Join(result, consumeChannel.Close())
}
if publishChannel != nil {
result = errors.Join(result, publishChannel.Close())
}
if connection != nil {
result = errors.Join(result, connection.Close())
}
return result
}
func rabbitRoutingKeyMatches(exchangeType, pattern, routingKey string) bool {
switch strings.ToLower(exchangeType) {
case "fanout":
return true
case "direct":
return pattern == routingKey
}
patternParts := strings.Split(pattern, ".")
routingParts := strings.Split(routingKey, ".")
for len(patternParts) > 0 {
head := patternParts[0]
patternParts = patternParts[1:]
if head == "#" {
if len(patternParts) == 0 {
return true
}
for index := 0; index <= len(routingParts); index++ {
if rabbitTopicPartsMatch(patternParts, routingParts[index:]) {
return true
}
}
return false
}
if len(routingParts) == 0 || (head != "*" && head != routingParts[0]) {
return false
}
routingParts = routingParts[1:]
}
return len(routingParts) == 0
}
func rabbitTopicPartsMatch(patternParts, routingParts []string) bool {
return rabbitRoutingKeyMatches("topic", strings.Join(patternParts, "."), strings.Join(routingParts, "."))
}

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

@ -0,0 +1,48 @@
package mq
import (
"context"
"errors"
"testing"
)
func TestDisabledRabbitMQIsSafeAndUnavailable(t *testing.T) {
client, err := NewRabbitMQ(RabbitMQConfig{})
if err != nil {
t.Fatal(err)
}
if client.Connected() {
t.Fatal("disabled rabbitmq reported connected")
}
if err = client.Publish(context.Background(), "orders.paid", []byte("test"), AtLeastOnce, false); !errors.Is(err, ErrUnavailable) {
t.Fatalf("publish error = %v", err)
}
if err = client.Close(); err != nil {
t.Fatal(err)
}
}
func TestEnabledRabbitMQRequiresTopology(t *testing.T) {
if _, err := NewRabbitMQ(RabbitMQConfig{Enabled: true}); err == nil {
t.Fatal("enabled rabbitmq without host and topology should fail")
}
}
func TestRabbitRoutingKeyMatches(t *testing.T) {
tests := []struct {
pattern string
key string
want bool
}{
{pattern: "orders.*.paid", key: "orders.cn.paid", want: true},
{pattern: "orders.#", key: "orders.cn.created", want: true},
{pattern: "#.paid", key: "orders.cn.paid", want: true},
{pattern: "orders.*", key: "orders.cn.paid", want: false},
{pattern: "orders.created", key: "orders.paid", want: false},
}
for _, test := range tests {
if got := rabbitRoutingKeyMatches("topic", test.pattern, test.key); got != test.want {
t.Fatalf("match(%q, %q) = %v, want %v", test.pattern, test.key, got, test.want)
}
}
}

View File

@ -3,25 +3,36 @@ import service from '@/utils/request'
export const getPaymentOrders = (params) => service({
url: '/payment/orders',
method: 'get',
params
params,
donNotShowLoading: true
})
export const getPaymentOrder = (data) => service({
url: '/payment/order',
method: 'post',
data
data,
donNotShowLoading: true
})
export const queryPaymentOrder = (data) => service({
url: '/payment/query',
method: 'post',
data
data,
donNotShowLoading: true
})
export const refundPaymentOrder = (data) => service({
url: '/payment/refund',
method: 'post',
data
data,
donNotShowLoading: true
})
export const fulfillPaymentOrder = (data) => service({
url: '/payment/fulfill',
method: 'post',
data,
donNotShowLoading: true
})
export const testPaymentProvider = (provider) => service({
@ -34,5 +45,6 @@ export const testPaymentProvider = (provider) => service({
? { baseURL: `${window.location.origin}/` }
: {}),
url: `/payment/providers/${encodeURIComponent(provider)}/test`,
method: 'post'
method: 'post',
donNotShowLoading: true
})

View File

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

View File

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

View File

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

View File

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