From 4b15146cdbbdee2bbf81d6022678080f29c1d507 Mon Sep 17 00:00:00 2001 From: engigu Date: Thu, 15 Jan 2026 20:05:00 +0800 Subject: [PATCH] feat: add url prefix --- .release_log | 1 + conf/app.example.ini | 2 + docs/guide/url-prefix.md | 141 ++++++++++++++++++++++++++++++++++++++ pkg/setting/env.go | 1 + pkg/setting/setting.go | 1 + routers/router.go | 82 ++++++++++++++++++---- web/config.js | 6 ++ web/src/api/api.js | 7 +- web/src/main.ts | 9 +-- web/src/types/global.d.ts | 7 ++ 10 files changed, 240 insertions(+), 17 deletions(-) create mode 100644 docs/guide/url-prefix.md create mode 100644 web/src/types/global.d.ts diff --git a/.release_log b/.release_log index ca8917e..ecef19c 100644 --- a/.release_log +++ b/.release_log @@ -28,3 +28,4 @@ 28. [2025.12.06] 增加模板功能:集成预览到编辑器、优化ID生成规则、增强发送API日志记录 29. [2025.12.17] 新增飞书机器人渠道、阿里云短信动态接收者、代码架构优化(注册模式重构、常量统一管理) 30. [2026.01.14] 新增 Telegram 机器人渠道、支持 HTTP/HTTPS/SOCKS5 代理配置、修复渠道测试类型断言错误、优化首页图表跨年显示 +31. [2026.01.15] 新增 URL 路径前缀配置,支持将应用部署在子路径下(如 /message-nest) diff --git a/conf/app.example.ini b/conf/app.example.ini index c8c2644..8116dc3 100644 --- a/conf/app.example.ini +++ b/conf/app.example.ini @@ -14,6 +14,8 @@ ReadTimeout = 60 WriteTimeout = 60 ; use embed html static file ; EmbedHtml = disable +; URL prefix for sub-path deployment (e.g., /message-nest) +; UrlPrefix = /message-nest [database] Type = mysql diff --git a/docs/guide/url-prefix.md b/docs/guide/url-prefix.md new file mode 100644 index 0000000..933aab8 --- /dev/null +++ b/docs/guide/url-prefix.md @@ -0,0 +1,141 @@ +# URL 前缀配置 + +Message Nest 支持配置 URL 前缀,允许您将应用部署在子路径下(例如 `/message-nest`),而不是根路径。 + +## 工作原理 + +配置 URL 前缀后: +1. 后端会在所有路由前添加指定的前缀 +2. 前端页面加载时,后端会在 HTML 中自动注入前缀配置 +3. 前端的所有 API 请求会自动使用该前缀 + +**无需手动配置前端**,前后端会自动同步! + +## 使用场景 + +- 在同一域名下部署多个应用 +- 通过反向代理(如 Nginx)将应用映射到子路径 +- 在企业内网中统一管理多个服务 + +## 配置方式 + +### 方式一:配置文件 + +编辑 `conf/app.ini` 文件,在 `[server]` 部分添加: + +```ini +[server] +RunMode = release +HttpPort = 8000 +UrlPrefix = /message-nest +``` + +### 方式二:环境变量 + +设置环境变量: + +```bash +export URL_PREFIX=/message-nest +``` + +或在 Docker 中: + +```bash +docker run -e URL_PREFIX=/message-nest ... +``` + +## 配置说明 + +- **路径格式**:可以带或不带前导斜杠 `/`,系统会自动处理 +- **示例值**: + - `/message-nest` + - `/api/message-nest` + - `message-nest`(会自动转换为 `/message-nest`) +- **默认值**:空字符串(部署在根路径) + +## 访问方式 + +配置 URL 前缀后,访问地址会变为: + +- **原地址**:`http://your-domain.com/` +- **新地址**:`http://your-domain.com/message-nest/` + +所有 API 端点也会自动添加前缀: + +- **原 API**:`http://your-domain.com/api/v1/sendways/list` +- **新 API**:`http://your-domain.com/message-nest/api/v1/sendways/list` + +## Nginx 反向代理配置示例 + +如果使用 Nginx 反向代理,配置示例: + +```nginx +location /message-nest/ { + proxy_pass http://localhost:8000/message-nest/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; +} +``` + +## 注意事项 + +::: warning 注意 +1. **修改配置后需要重启服务**才能生效 +2. **前后端会自动同步**前缀配置,后端会在 HTML 中注入配置 +3. **已有的 API 调用**会自动适配新的前缀 +4. **静态资源**(CSS、JS、图片等)也会自动使用新的前缀 +5. **开发模式**下,如果前端单独运行,需要确保后端 API 的前缀配置正确 +::: + +## 常见问题 + +### Q: 修改前缀后无法访问? + +**A:** 请检查: +1. 是否已重启服务 +2. 前缀格式是否正确 +3. 如果使用反向代理,检查代理配置是否正确 + +### Q: 可以使用多级路径吗? + +**A:** 可以,例如:`/api/message-nest` 或 `/services/messaging/nest` + +### Q: URL 前缀会影响性能吗? + +**A:** 不会,URL 前缀只是路径的一部分,不会影响应用性能。 + +## 示例 + +### 示例 1:部署在子路径 + +```ini +# conf/app.ini +[server] +UrlPrefix = /message-nest +``` + +访问地址:`http://your-domain.com/message-nest/` + +### 示例 2:多级子路径 + +```ini +# conf/app.ini +[server] +UrlPrefix = /api/services/message-nest +``` + +访问地址:`http://your-domain.com/api/services/message-nest/` + +### 示例 3:Docker 环境变量 + +```bash +docker run -d \ + -p 8000:8000 \ + -e URL_PREFIX=/message-nest \ + -e DB_TYPE=sqlite \ + message-nest:latest +``` + +访问地址:`http://localhost:8000/message-nest/` diff --git a/pkg/setting/env.go b/pkg/setting/env.go index 6a77852..2157d8c 100644 --- a/pkg/setting/env.go +++ b/pkg/setting/env.go @@ -48,6 +48,7 @@ func loadConfigFromEnv() { ServerSetting.HttpPort = 8000 ServerSetting.ReadTimeout = 60 ServerSetting.WriteTimeout = 60 + ServerSetting.UrlPrefix = getOptionEnvValue("URL_PREFIX", "") DatabaseSetting.Type = getOptionEnvValue("DB_TYPE", "sqlite") DatabaseSetting.Ssl = getOptionEnvValue("SSL", "false") diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index dfe8e2f..4c6618c 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -25,6 +25,7 @@ type Server struct { WriteTimeout time.Duration EmbedHtml string + UrlPrefix string } var ServerSetting = &Server{} diff --git a/routers/router.go b/routers/router.go index c647aac..9e78eb2 100644 --- a/routers/router.go +++ b/routers/router.go @@ -2,7 +2,9 @@ package routers import ( "embed" + "fmt" "github.com/gin-gonic/gin" + "io" "io/fs" "message-nest/middleware" "message-nest/pkg/setting" @@ -10,6 +12,7 @@ import ( "message-nest/routers/api/v1" "message-nest/routers/api/v2" "net/http" + "strings" ) // AppendCors 添加是否跨域(debug模式开启) @@ -19,22 +22,62 @@ func AppendCors(app *gin.Engine) { } } -// AppendServerStaticHtml 启用返回打包的静态文件 -func AppendServerStaticHtml(app *gin.Engine, f embed.FS) { +// AppendServerStaticHtmlWithPrefix 启用返回打包的静态文件(支持路径前缀) +func AppendServerStaticHtmlWithPrefix(router gin.IRouter, f embed.FS, pathPrefix string) { if setting.ServerSetting.EmbedHtml == "disable" { return } - app.Use(middleware.StaticCacheMiddleware()) - assets, _ := fs.Sub(f, "web/dist/assets") dist, _ := fs.Sub(f, "web/dist") - app.StaticFS("assets/", http.FS(assets)) - app.GET("/", func(ctx *gin.Context) { - ctx.FileFromFS("/", http.FS(dist)) - }) + // 根据是否有路径前缀来设置静态文件路由 + if pathPrefix != "" { + // 有路径前缀时,使用相对路径 + if r, ok := router.(*gin.RouterGroup); ok { + r.Use(middleware.StaticCacheMiddleware()) + r.StaticFS("/assets", http.FS(assets)) + r.GET("/", func(ctx *gin.Context) { + // 读取 index.html + indexFile, err := dist.Open("index.html") + if err != nil { + ctx.String(http.StatusInternalServerError, "Failed to load index.html") + return + } + defer indexFile.Close() + // 读取文件内容 + content, err := io.ReadAll(indexFile) + if err != nil { + ctx.String(http.StatusInternalServerError, "Failed to read index.html") + return + } + + // 注入配置脚本 + configScript := fmt.Sprintf(``, pathPrefix) + htmlContent := string(content) + // 在 标签前注入配置 + htmlContent = strings.Replace(htmlContent, "", configScript+"", 1) + + ctx.Header("Content-Type", "text/html; charset=utf-8") + ctx.String(http.StatusOK, htmlContent) + }) + } + } else { + // 无路径前缀时,使用原有逻辑 + if r, ok := router.(*gin.Engine); ok { + r.Use(middleware.StaticCacheMiddleware()) + r.StaticFS("assets/", http.FS(assets)) + r.GET("/", func(ctx *gin.Context) { + ctx.FileFromFS("/", http.FS(dist)) + }) + } + } +} + +// AppendServerStaticHtml 启用返回打包的静态文件(保留向后兼容) +func AppendServerStaticHtml(app *gin.Engine, f embed.FS) { + AppendServerStaticHtmlWithPrefix(app, f, "") } // InitRouter 初始化路由 @@ -44,10 +87,25 @@ func InitRouter(f embed.FS) *gin.Engine { app.Use(gin.Recovery()) AppendCors(app) - AppendServerStaticHtml(app, f) + + // 获取 URL 前缀 + pathPrefix := setting.ServerSetting.UrlPrefix + if pathPrefix != "" && pathPrefix[0] != '/' { + pathPrefix = "/" + pathPrefix + } + + // 如果有路径前缀,创建路由组 + var router gin.IRouter + if pathPrefix != "" { + router = app.Group(pathPrefix) + } else { + router = app + } + + AppendServerStaticHtmlWithPrefix(router, f, pathPrefix) - app.POST("/auth", api.GetAuth) - apiV1 := app.Group("/api/v1") + router.POST("/auth", api.GetAuth) + apiV1 := router.Group("/api/v1") apiV1.Use(middleware.JWT()) { // sendways @@ -115,7 +173,7 @@ func InitRouter(f embed.FS) *gin.Engine { } // API v2 - apiV2 := app.Group("/api/v2") + apiV2 := router.Group("/api/v2") apiV2.Use(middleware.JWT()) { // message/send - 使用模板发送消息 diff --git a/web/config.js b/web/config.js index 24f9f5f..31c63ac 100644 --- a/web/config.js +++ b/web/config.js @@ -2,8 +2,14 @@ const isProduction = process.env.NODE_ENV === 'prod'; +// 从 window 对象获取路径前缀(由后端注入或通过 API 获取) +const getPathPrefix = () => { + return window.__URL_PATH_PREFIX__ || ''; +}; + const config = { apiUrl: isProduction ? '' : 'http://localhost:8000', + pathPrefix: getPathPrefix(), }; export default config; diff --git a/web/src/api/api.js b/web/src/api/api.js index 5fd9deb..8c5b601 100644 --- a/web/src/api/api.js +++ b/web/src/api/api.js @@ -10,8 +10,13 @@ import { toast } from "vue-sonner" const ERR_NETWORK = "ERR_NETWORK"; +// 获取路径前缀 +const getPathPrefix = () => { + return config.pathPrefix || ''; +}; + const request = axios.create({ - baseURL: config.apiUrl, + baseURL: config.apiUrl + getPathPrefix(), timeout: 50000, withCredentials: true, }); diff --git a/web/src/main.ts b/web/src/main.ts index a25c0ce..810d5db 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -20,7 +20,8 @@ import router from './router'; } catch (_) {} })(); -const app = createApp(App) -app.use(router) -app.use(pinia) -app.mount('#app') +const app = createApp(App); +app.use(router); +app.use(pinia); +app.mount('#app'); + diff --git a/web/src/types/global.d.ts b/web/src/types/global.d.ts new file mode 100644 index 0000000..e7914b4 --- /dev/null +++ b/web/src/types/global.d.ts @@ -0,0 +1,7 @@ +declare global { + interface Window { + __URL_PATH_PREFIX__?: string; + } +} + +export {};