feat: add url prefix

This commit is contained in:
engigu
2026-01-15 20:05:00 +08:00
parent 18ad967df3
commit 4b15146cdb
10 changed files with 240 additions and 17 deletions
+1
View File
@@ -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
+2
View File
@@ -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
+141
View File
@@ -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/`
### 示例 3Docker 环境变量
```bash
docker run -d \
-p 8000:8000 \
-e URL_PREFIX=/message-nest \
-e DB_TYPE=sqlite \
message-nest:latest
```
访问地址:`http://localhost:8000/message-nest/`
+1
View File
@@ -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")
+1
View File
@@ -25,6 +25,7 @@ type Server struct {
WriteTimeout time.Duration
EmbedHtml string
UrlPrefix string
}
var ServerSetting = &Server{}
+68 -10
View File
@@ -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) {
// 根据是否有路径前缀来设置静态文件路由
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(`<script>window.__URL_PATH_PREFIX__ = '%s';</script>`, pathPrefix)
htmlContent := string(content)
// 在 </head> 标签前注入配置
htmlContent = strings.Replace(htmlContent, "</head>", configScript+"</head>", 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)
app.POST("/auth", api.GetAuth)
apiV1 := app.Group("/api/v1")
// 获取 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)
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 - 使用模板发送消息
+6
View File
@@ -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;
+6 -1
View File
@@ -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,
});
+5 -4
View File
@@ -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');
+7
View File
@@ -0,0 +1,7 @@
declare global {
interface Window {
__URL_PATH_PREFIX__?: string;
}
}
export {};