优化结构
This commit is contained in:
parent
8d290f2954
commit
ac09f38940
|
|
@ -432,6 +432,8 @@ func routedLogPaths(module string, level zapcore.Level) []string {
|
||||||
paths = append(paths, filepath.Join("http", "access.log"))
|
paths = append(paths, filepath.Join("http", "access.log"))
|
||||||
case "timedTask":
|
case "timedTask":
|
||||||
paths = append(paths, filepath.Join("timedTask", "task.log"))
|
paths = append(paths, filepath.Join("timedTask", "task.log"))
|
||||||
|
case "websocket":
|
||||||
|
paths = append(paths, filepath.Join("websocket", "websocket.log"))
|
||||||
case "error":
|
case "error":
|
||||||
paths = append(paths, filepath.Join("error", "error.log"))
|
paths = append(paths, filepath.Join("error", "error.log"))
|
||||||
default:
|
default:
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ func TestZapHandlerRoutesHTTPAndErrorLogs(t *testing.T) {
|
||||||
handler, cleanup := newZapHandler(root, "application.log", Options{Level: "info", Format: "json"}, nil)
|
handler, cleanup := newZapHandler(root, "application.log", Options{Level: "info", Format: "json"}, nil)
|
||||||
logger := slog.New(handler)
|
logger := slog.New(handler)
|
||||||
logger.Info("request", "mod", "http", "request_id", "req-1")
|
logger.Info("request", "mod", "http", "request_id", "req-1")
|
||||||
|
logger.Info("handshake", "mod", "websocket")
|
||||||
logger.Error("failed", "mod", "users")
|
logger.Error("failed", "mod", "users")
|
||||||
cleanup()
|
cleanup()
|
||||||
|
|
||||||
|
|
@ -52,6 +53,7 @@ func TestZapHandlerRoutesHTTPAndErrorLogs(t *testing.T) {
|
||||||
paths := []string{
|
paths := []string{
|
||||||
filepath.Join(root, date, "application.log"),
|
filepath.Join(root, date, "application.log"),
|
||||||
filepath.Join(root, date, "http", "access.log"),
|
filepath.Join(root, date, "http", "access.log"),
|
||||||
|
filepath.Join(root, date, "websocket", "websocket.log"),
|
||||||
filepath.Join(root, date, "users", "application.log"),
|
filepath.Join(root, date, "users", "application.log"),
|
||||||
filepath.Join(root, date, "error", "error.log"),
|
filepath.Join(root, date, "error", "error.log"),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import (
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
kratoshttp "github.com/go-kratos/kratos/v3/transport/http"
|
kratoshttp "github.com/go-kratos/kratos/v3/transport/http"
|
||||||
|
melody "github.com/olahol/melody"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewGinEngineWithRuntime(runtime *config.Store, access *systemservice.AccessControlService, auth middleware.TokenAuthenticator, security *systemservice.SecurityService, audit *systemservice.AuditRecorder, logger *slog.Logger, version string, routes platformmodule.RouteRegistrar, ws *websocket.Server) *gin.Engine {
|
func NewGinEngineWithRuntime(runtime *config.Store, access *systemservice.AccessControlService, auth middleware.TokenAuthenticator, security *systemservice.SecurityService, audit *systemservice.AuditRecorder, logger *slog.Logger, version string, routes platformmodule.RouteRegistrar, ws *websocket.Server) *gin.Engine {
|
||||||
|
|
@ -27,7 +28,7 @@ func NewGinEngineWithRuntime(runtime *config.Store, access *systemservice.Access
|
||||||
if err := engine.SetTrustedProxies(nil); err != nil && logger != nil {
|
if err := engine.SetTrustedProxies(nil); err != nil && logger != nil {
|
||||||
logger.Error("disable trusted proxies failed", "error", err)
|
logger.Error("disable trusted proxies failed", "error", err)
|
||||||
}
|
}
|
||||||
engine.Use(middleware.RequestMeta(), middleware.Recovery(logger), middleware.AccessLog(runtime, logger, version), middleware.CORS(runtime), middleware.ErrorAudit(logger), middleware.SecurityRateLimit(security))
|
engine.Use(middleware.RequestMeta(), middleware.Recovery(logger), websocketHandshakeLogger(logger), middleware.AccessLog(runtime, logger, version), middleware.CORS(runtime), middleware.ErrorAudit(logger), middleware.SecurityRateLimit(security))
|
||||||
|
|
||||||
prefix := ""
|
prefix := ""
|
||||||
snapshot := runtime.Snapshot()
|
snapshot := runtime.Snapshot()
|
||||||
|
|
@ -46,16 +47,58 @@ func NewGinEngineWithRuntime(runtime *config.Store, access *systemservice.Access
|
||||||
if routes != nil {
|
if routes != nil {
|
||||||
routes.RegisterRoutes(public, private, engine)
|
routes.RegisterRoutes(public, private, engine)
|
||||||
}
|
}
|
||||||
|
if ws != nil && logger != nil {
|
||||||
|
ws.OnConnect(func(session *melody.Session) {
|
||||||
|
request := session.Request
|
||||||
|
logger.InfoContext(request.Context(), "websocket connection established",
|
||||||
|
"mod", "websocket", "path", request.URL.Path, "remote_addr", request.RemoteAddr,
|
||||||
|
"origin", request.Header.Get("Origin"), "user_id", session.Keys["user_id"],
|
||||||
|
"request_id", session.Keys["request_id"])
|
||||||
|
})
|
||||||
|
ws.OnDisconnect(func(session *melody.Session) {
|
||||||
|
request := session.Request
|
||||||
|
logger.InfoContext(request.Context(), "websocket connection closed",
|
||||||
|
"mod", "websocket", "path", request.URL.Path, "remote_addr", request.RemoteAddr,
|
||||||
|
"user_id", session.Keys["user_id"], "request_id", session.Keys["request_id"])
|
||||||
|
})
|
||||||
|
}
|
||||||
handleWebSocket := func(c *gin.Context) {
|
handleWebSocket := func(c *gin.Context) {
|
||||||
|
wsLogger := logger
|
||||||
|
hasToken := middleware.RequestToken(c, true) != ""
|
||||||
if ws == nil || !ws.Enabled() || c.Request.URL.Path != ws.Path() {
|
if ws == nil || !ws.Enabled() || c.Request.URL.Path != ws.Path() {
|
||||||
|
if wsLogger != nil {
|
||||||
|
wsLogger.WarnContext(c.Request.Context(), "websocket handshake rejected: endpoint unavailable",
|
||||||
|
"mod", "websocket", "path", c.Request.URL.Path, "expected_path", websocketPath(ws),
|
||||||
|
"enabled", ws != nil && ws.Enabled(), "has_token", hasToken)
|
||||||
|
}
|
||||||
c.Status(http.StatusNotFound)
|
c.Status(http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !middleware.AuthenticateWebSocket(c, auth) {
|
if !middleware.AuthenticateWebSocket(c, auth) {
|
||||||
|
if wsLogger != nil {
|
||||||
|
wsLogger.WarnContext(c.Request.Context(), "websocket handshake rejected: authentication failed",
|
||||||
|
"mod", "websocket", "path", c.Request.URL.Path, "status", c.Writer.Status(),
|
||||||
|
"has_token", hasToken, "origin", c.GetHeader("Origin"))
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := ws.HandleRequest(c.Writer, c.Request); err != nil && logger != nil {
|
claims := middleware.Claims(c)
|
||||||
logger.Warn("websocket request failed", "mod", "websocket", "error", err)
|
keys := map[string]any{
|
||||||
|
"request_id": contextString(c, "request_id"),
|
||||||
|
}
|
||||||
|
if claims != nil {
|
||||||
|
keys["user_id"] = claims.ID
|
||||||
|
}
|
||||||
|
if wsLogger != nil {
|
||||||
|
wsLogger.InfoContext(c.Request.Context(), "websocket authentication accepted; upgrading connection",
|
||||||
|
"mod", "websocket", "path", c.Request.URL.Path, "origin", c.GetHeader("Origin"),
|
||||||
|
"remote_addr", c.Request.RemoteAddr, "user_id", keys["user_id"],
|
||||||
|
"request_id", keys["request_id"])
|
||||||
|
}
|
||||||
|
if err := ws.HandleRequestWithKeys(c.Writer, c.Request, keys); err != nil && wsLogger != nil {
|
||||||
|
wsLogger.WarnContext(c.Request.Context(), "websocket upgrade failed", "mod", "websocket",
|
||||||
|
"path", c.Request.URL.Path, "origin", c.GetHeader("Origin"), "error", err,
|
||||||
|
"request_id", keys["request_id"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ws != nil && ws.Enabled() {
|
if ws != nil && ws.Enabled() {
|
||||||
|
|
@ -89,6 +132,33 @@ func NewGinEngineWithRuntime(runtime *config.Store, access *systemservice.Access
|
||||||
return engine
|
return engine
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func websocketHandshakeLogger(logger *slog.Logger) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if logger != nil && strings.EqualFold(strings.TrimSpace(c.GetHeader("Upgrade")), "websocket") {
|
||||||
|
logger.InfoContext(c.Request.Context(), "websocket handshake packet received",
|
||||||
|
"mod", "websocket", "path", c.Request.URL.Path, "host", c.Request.Host,
|
||||||
|
"remote_addr", c.Request.RemoteAddr, "origin", c.GetHeader("Origin"),
|
||||||
|
"has_token", middleware.RequestToken(c, true) != "",
|
||||||
|
"connection", c.GetHeader("Connection"), "user_agent", c.Request.UserAgent(),
|
||||||
|
"request_id", contextString(c, "request_id"))
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contextString(c *gin.Context, key string) string {
|
||||||
|
value, _ := c.Get(key)
|
||||||
|
text, _ := value.(string)
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
func websocketPath(ws *websocket.Server) string {
|
||||||
|
if ws == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return ws.Path()
|
||||||
|
}
|
||||||
|
|
||||||
func NewGinServer(c *config.Server, engine *gin.Engine) *kratoshttp.Server {
|
func NewGinServer(c *config.Server, engine *gin.Engine) *kratoshttp.Server {
|
||||||
network, address := "tcp", ":8000"
|
network, address := "tcp", ":8000"
|
||||||
if c != nil && c.HTTP != nil {
|
if c != nil && c.HTTP != nil {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ package websocket
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -80,13 +79,10 @@ func New(config Config) *Server {
|
||||||
allowed := append([]string(nil), c.AllowOrigins...)
|
allowed := append([]string(nil), c.AllowOrigins...)
|
||||||
m.Upgrader.CheckOrigin = func(r *http.Request) bool {
|
m.Upgrader.CheckOrigin = func(r *http.Request) bool {
|
||||||
if len(allowed) == 0 {
|
if len(allowed) == 0 {
|
||||||
origin := r.Header.Get("Origin")
|
// An empty allowlist means no origin restriction. Deployments that
|
||||||
if origin == "" {
|
// need a restriction can provide allow_origins explicitly.
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
parsed, err := url.Parse(origin)
|
|
||||||
return err == nil && strings.EqualFold(parsed.Host, r.Host)
|
|
||||||
}
|
|
||||||
origin := r.Header.Get("Origin")
|
origin := r.Header.Get("Origin")
|
||||||
for _, value := range allowed {
|
for _, value := range allowed {
|
||||||
if value == "*" || strings.EqualFold(strings.TrimSpace(value), origin) {
|
if value == "*" || strings.EqualFold(strings.TrimSpace(value), origin) {
|
||||||
|
|
|
||||||
|
|
@ -48,3 +48,15 @@ func TestServerReceivesAndSendsTextMessages(t *testing.T) {
|
||||||
t.Fatal("first message handler was overwritten")
|
t.Fatal("first message handler was overwritten")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServerAllowsAnyOriginByDefault(t *testing.T) {
|
||||||
|
server := New(Config{})
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "http://127.0.0.1:8000/ws", nil)
|
||||||
|
request.Host = "127.0.0.1:8000"
|
||||||
|
request.Header.Set("Origin", "http://evil.example")
|
||||||
|
if !server.Melody().Upgrader.CheckOrigin(request) {
|
||||||
|
t.Fatal("origin was rejected when allow_origins is empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -167,6 +167,14 @@
|
||||||
<span class="save-state">
|
<span class="save-state">
|
||||||
{{ selected.configured ? '配置已创建' : '尚未保存配置' }}
|
{{ selected.configured ? '配置已创建' : '尚未保存配置' }}
|
||||||
</span>
|
</span>
|
||||||
|
<el-button
|
||||||
|
v-if="isWebSocket(selected)"
|
||||||
|
:icon="DocumentCopy"
|
||||||
|
:disabled="isBusy(selected)"
|
||||||
|
@click="openConnectionInfo"
|
||||||
|
>
|
||||||
|
连接信息
|
||||||
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
:icon="Connection"
|
:icon="Connection"
|
||||||
:loading="isTesting(selected)"
|
:loading="isTesting(selected)"
|
||||||
|
|
@ -189,6 +197,38 @@
|
||||||
|
|
||||||
<el-empty v-else description="暂无通信集成配置" />
|
<el-empty v-else description="暂无通信集成配置" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<el-dialog v-model="connectionInfoVisible" title="WebSocket 连接信息" width="560px">
|
||||||
|
<el-form label-position="top">
|
||||||
|
<el-form-item label="连接地址">
|
||||||
|
<el-input :model-value="connectionInfo.url" readonly>
|
||||||
|
<template #append>
|
||||||
|
<el-button :icon="DocumentCopy" @click="copyText(connectionInfo.url)">复制</el-button>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="JavaScript 示例">
|
||||||
|
<el-input :model-value="connectionInfo.example" type="textarea" :rows="4" readonly />
|
||||||
|
<el-button class="copy-example" :icon="DocumentCopy" @click="copyText(connectionInfo.example)">复制示例</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<el-alert
|
||||||
|
:type="connectionTestAlertType"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
:title="connectionTestMessage"
|
||||||
|
/>
|
||||||
|
<template #footer>
|
||||||
|
<el-button
|
||||||
|
:icon="Connection"
|
||||||
|
:loading="connectionTestStatus === 'testing'"
|
||||||
|
@click="testConnectionInfo"
|
||||||
|
>
|
||||||
|
测试此地址
|
||||||
|
</el-button>
|
||||||
|
<el-button type="primary" @click="connectionInfoVisible = false">关闭</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -199,9 +239,11 @@ import {
|
||||||
ChatLineRound,
|
ChatLineRound,
|
||||||
Check,
|
Check,
|
||||||
Connection,
|
Connection,
|
||||||
|
DocumentCopy,
|
||||||
Promotion,
|
Promotion,
|
||||||
Refresh
|
Refresh
|
||||||
} from '@element-plus/icons-vue'
|
} from '@element-plus/icons-vue'
|
||||||
|
import { useUserStore } from '@/pinia/modules/user'
|
||||||
import {
|
import {
|
||||||
getIntegrationConfigs,
|
getIntegrationConfigs,
|
||||||
saveIntegrationConfig,
|
saveIntegrationConfig,
|
||||||
|
|
@ -261,6 +303,9 @@ const loading = ref(false)
|
||||||
const pending = reactive({})
|
const pending = reactive({})
|
||||||
const errors = reactive({})
|
const errors = reactive({})
|
||||||
const listDrafts = reactive({})
|
const listDrafts = reactive({})
|
||||||
|
const connectionInfoVisible = ref(false)
|
||||||
|
const connectionTestStatus = ref('idle')
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
const selected = computed(
|
const selected = computed(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -271,6 +316,45 @@ const selected = computed(
|
||||||
|
|
||||||
const integrationKey = (item) => `${item.kind}/${item.provider}`
|
const integrationKey = (item) => `${item.kind}/${item.provider}`
|
||||||
const providerMeta = (item) => TARGETS[integrationKey(item)] || TARGETS[TARGET_ORDER[0]]
|
const providerMeta = (item) => TARGETS[integrationKey(item)] || TARGETS[TARGET_ORDER[0]]
|
||||||
|
const isWebSocket = (item) => integrationKey(item) === 'websocket/melody'
|
||||||
|
const websocketOrigin = () => {
|
||||||
|
if (import.meta.env.DEV && import.meta.env.VITE_SERVER_PORT) {
|
||||||
|
const configuredBase = String(import.meta.env.VITE_BASE_PATH || window.location.origin)
|
||||||
|
const backend = new URL(configuredBase, window.location.origin)
|
||||||
|
if (
|
||||||
|
['127.0.0.1', 'localhost'].includes(backend.hostname) &&
|
||||||
|
!['127.0.0.1', 'localhost'].includes(window.location.hostname)
|
||||||
|
) {
|
||||||
|
backend.hostname = window.location.hostname
|
||||||
|
}
|
||||||
|
backend.port = String(import.meta.env.VITE_SERVER_PORT)
|
||||||
|
backend.protocol = backend.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
|
return `${backend.protocol}//${backend.host}`
|
||||||
|
}
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
|
return `${protocol}//${window.location.host}`
|
||||||
|
}
|
||||||
|
const connectionInfo = computed(() => {
|
||||||
|
const path = String(selected.value?.config?.path || '/ws').trim() || '/ws'
|
||||||
|
const normalizedPath = path.startsWith('/') ? path : `/${path}`
|
||||||
|
const token = String(userStore.token || '').trim()
|
||||||
|
const url = `${websocketOrigin()}${normalizedPath}${token ? `?token=${encodeURIComponent(token)}` : ''}`
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
example: `const socket = new WebSocket(${JSON.stringify(url)})\n\nsocket.onopen = () => console.log('connected')\nsocket.onmessage = (event) => console.log(event.data)`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const connectionTestMessage = computed(() => {
|
||||||
|
if (connectionTestStatus.value === 'testing') return '等待服务器握手包...'
|
||||||
|
if (connectionTestStatus.value === 'success') return '握手成功,WebSocket 已连接。'
|
||||||
|
if (connectionTestStatus.value === 'error') return '握手失败,请查看 websocket/websocket.log。'
|
||||||
|
return '点击“测试此地址”验证浏览器到服务器的真实握手。'
|
||||||
|
})
|
||||||
|
const connectionTestAlertType = computed(() => {
|
||||||
|
if (connectionTestStatus.value === 'success') return 'success'
|
||||||
|
if (connectionTestStatus.value === 'error') return 'error'
|
||||||
|
return 'info'
|
||||||
|
})
|
||||||
const operationKey = (item) => integrationKey(item)
|
const operationKey = (item) => integrationKey(item)
|
||||||
const errorKey = (item, fieldKey) => `${integrationKey(item)}:${fieldKey}`
|
const errorKey = (item, fieldKey) => `${integrationKey(item)}:${fieldKey}`
|
||||||
const listKey = (item, fieldKey) => `${integrationKey(item)}:${fieldKey}`
|
const listKey = (item, fieldKey) => `${integrationKey(item)}:${fieldKey}`
|
||||||
|
|
@ -518,6 +602,50 @@ const testSelected = async () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openConnectionInfo = () => {
|
||||||
|
connectionTestStatus.value = 'idle'
|
||||||
|
connectionInfoVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const testConnectionInfo = () => {
|
||||||
|
if (connectionTestStatus.value === 'testing') return
|
||||||
|
connectionTestStatus.value = 'testing'
|
||||||
|
let settled = false
|
||||||
|
let socket
|
||||||
|
const finish = (status) => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
window.clearTimeout(timeout)
|
||||||
|
connectionTestStatus.value = status
|
||||||
|
if (status === 'success') {
|
||||||
|
ElMessage.success('WebSocket 握手成功')
|
||||||
|
socket?.close(1000, 'connection test complete')
|
||||||
|
} else {
|
||||||
|
ElMessage.error('WebSocket 握手失败,请查看独立日志')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const timeout = window.setTimeout(() => finish('error'), 8000)
|
||||||
|
try {
|
||||||
|
socket = new WebSocket(connectionInfo.value.url)
|
||||||
|
socket.onopen = () => finish('success')
|
||||||
|
socket.onerror = () => finish('error')
|
||||||
|
socket.onclose = () => {
|
||||||
|
if (!settled) finish('error')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
finish('error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyText = async (value) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value)
|
||||||
|
ElMessage.success('已复制到剪贴板')
|
||||||
|
} catch {
|
||||||
|
ElMessage.warning('复制失败,请手动复制')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const toggleIntegration = async (item, enabled) => {
|
const toggleIntegration = async (item, enabled) => {
|
||||||
if (isBusy(item)) return
|
if (isBusy(item)) return
|
||||||
const previous = item.enabled
|
const previous = item.enabled
|
||||||
|
|
@ -754,6 +882,10 @@ onMounted(load)
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.copy-example {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.editor-actions {
|
.editor-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue