feat: add url prefix
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/`
|
||||
@@ -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")
|
||||
|
||||
@@ -25,6 +25,7 @@ type Server struct {
|
||||
WriteTimeout time.Duration
|
||||
|
||||
EmbedHtml string
|
||||
UrlPrefix string
|
||||
}
|
||||
|
||||
var ServerSetting = &Server{}
|
||||
|
||||
+68
-10
@@ -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 - 使用模板发送消息
|
||||
|
||||
@@ -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
@@ -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
@@ -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');
|
||||
|
||||
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
declare global {
|
||||
interface Window {
|
||||
__URL_PATH_PREFIX__?: string;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
Reference in New Issue
Block a user