Files
yunzerwebsiteallinone/docs/关于租户官网服务器端nginx的配置.md
T
2026-08-20 01:11:43 +08:00

475 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 关于租户官网服务器端 nginx 的配置
> 适用场景:多租户官网系统。访客访问租户域名(如 `ceshi.yunzer.com.cn`)时,由 nginx 转发给 Go 服务,Go 按请求 Host 识别租户,用标签引擎解析 `themes/{模板编码}/` 模板后直出 HTML。
>
> 本文给出宝塔环境下两个主域名(`dh2.fun`、`yunzer.com.cn`)的完整 nginx 配置、设计原则与注意事项。
---
## 一、架构原理
```
访客浏览器
│ https://ceshi.yunzer.com.cn/
▼
nginx(宝塔)
│ 全部请求 proxy_pass → Go 服务(upstream 统一管理地址)
│ 透传 Host 头(Go 靠它识别租户)
▼
Go 服务(beego,默认 8081 端口)
│ resolveTid:按 Host 查库识别租户
│ tagengine:渲染 themes/{模板编码}/ 下的模板
▼
直出 HTML(无需前端 SPA)
```
### 1.1 Go 侧提供的站点路由
| 路径 | 说明 |
|---|---|
| `GET /` | 租户官网首页(渲染 `index.html` 模板) |
| `GET /news` | 新闻列表(`?page=N` 分页) |
| `GET /news/:id` | 文章详情 |
| `GET /page/:path` | 单页(如 `/page/about`) |
| `/themes/*` | 模板静态资源(beego `SetStaticPath("/themes", "themes")`,对应 `go/themes/` 目录) |
| `/uploads/*` | 上传文件静态访问 |
| `/backend/*`、`/platform/*` 等 | 管理端接口(nginx 以 `/api/` 前缀转发并剥掉前缀) |
> Go 的官网渲染路由在 `APP_MODE=all`(默认值)或 `APP_MODE=index` 时注册。
### 1.2 Go 按 Host 识别租户的规则(tenant_site.go)
按顺序匹配:
1. dev 环境:支持 `X-Tenant-ID` 请求头切换租户(生产无效);
2. `localhost` / `127.0.0.1` → 租户 1;
3. `yz_system_domain_pool.main_domain` 命中(status=1)→ 租户 1(平台主站);
4. `yz_system_tenant_domain.full_domain` 命中(status=1、未软删)→ 对应租户;
5. 全部未命中 → 返回"站点不存在"404 页(**不再默认回落租户 1**)。
所以平台端"域名发放"时必须保证 `yz_system_tenant_domain.full_domain` 精确等于实际访问域名(如 `ceshi.yunzer.com.cn`)。
---
## 二、配置核心策略(为什么这么写)
### 2.1 upstream 统一管理后端地址 —— 迁移只改一行
所有 `proxy_pass` 不写死 IP,统一引用 upstream。迁移服务器时只改 upstream 里的 `server` 一行。
**upstream 写在哪里**:直接写在宝塔对应站点的配置文件里、**整个文件最顶部(第一个 `server {` 块之前)**,不需要配置到其他地方。宝塔站点配置文件会被 include 进主配置的 `http {}` 上下文,`upstream` 定义在 server 块之外即合法。常见报错:`unknown "go_backend_dh2" upstream`(顶部漏了 upstream 定义)、`"upstream" directive is not allowed here`(误写进了 server 块内)。
**注意:两个站点配置文件都会被 include 进同一个 http 上下文,upstream 重名会导致 `duplicate upstream` 启动失败,必须各起各的名字**(本文用 `go_backend_dh2` / `go_backend_yunzer`)。
### 2.2 默认全量转发 —— 以后加功能零配置
不采用"白名单式"逐个转发(`location /news`、`location /page/`……),而是:
- nginx 只保留**必须拦截**的规则:敏感文件/目录返回 404、`/api/` 剥前缀;
- 其余一切请求走兜底 `location /` 转发给 Go;
- Go 对不认识的路径返回自己的 404 页,行为闭环。
这样以后 Go 增加任何路由(新功能页面、新接口页),**nginx 一行都不用改**。
### 2.3 管理端 SPA 的放置建议
主域名与所有子域名都转发给 Go 后,这些域名上就不能再跑 history 模式的 SPA 了。建议:
- platform / backend 管理端部署在**专属子域名**(如 `admin.yunzer.com.cn`)或独立站点;
- nginx 里为它单独建一个 `server_name` 精确匹配的 server 块。nginx 匹配顺序是**精确 server_name 优先于正则**,不会被"所有子域名"的正则块抢走;
- 若确实需要静态文件与 Go 共存(纯静态、无 SPA 深链接),可用折中写法:
```nginx
location / {
try_files $uri @go; # 本地存在的文件先出静态,其余转 Go
}
location @go {
proxy_pass http://go_backend_yunzer;
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;
}
```
### 2.4 `/api/` 的尾部斜杠不能删
```nginx
location /api/ {
proxy_pass http://go_backend_xxx/; # 尾部 / 负责把 /api/backend/login 映射成 /backend/login
...
}
```
Go 的路由注册在 `/backend/*`、`/platform/*`(没有 `/api` 前缀),靠这个尾部斜杠剥前缀。
---
## 三、完整配置 —— 宝塔站点 `dh2.fun`
文件位置:宝塔 → 网站 → dh2.fun → 设置 → 配置文件。整份替换。
```nginx
# ===== 后端 Go 服务地址统一定义,迁移时只改这一行 =====
upstream go_backend_dh2 {
server 10.31.100.2:8081; # 若 nginx 与 Go 同机,建议改为 127.0.0.1:8081(迁移零改动)
}
# ==================== 主域名 dh2.fun(全量托管给 Go)====================
server {
listen 80;
listen 443 ssl;
listen 443 quic;
http2 on;
# 只匹配主域名(子域名由下方独立 server 块处理)
server_name dh2.fun;
#CERT-APPLY-CHECK--START
include /www/server/panel/vhost/nginx/well-known/dh2.fun.conf;
#CERT-APPLY-CHECK--END
include /www/server/panel/vhost/nginx/extension/dh2.fun/*.conf;
#SSL-START SSL相关配置,请勿删除或修改下一行带注释的404规则
#error_page 404/404.html;
#HTTP_TO_HTTPS_START
set $isRedcert 1;
if ($server_port != 443) {
set $isRedcert 2;
}
if ( $uri ~ /\.well-known/ ) {
set $isRedcert 1;
}
if ($isRedcert != 1) {
rewrite ^(/.*)$ https://$host$1 permanent;
}
#HTTP_TO_HTTPS_END
ssl_certificate /www/server/panel/vhost/cert/dh2.fun/fullchain.pem;
ssl_certificate_key /www/server/panel/vhost/cert/dh2.fun/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers EECDH+CHACHA20:EECDH+CHACHA20-draft:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:EECDH+3DES:RSA+3DES:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_tickets on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
add_header Strict-Transport-Security "max-age=31536000";
error_page 497 https://$host$request_uri;
#SSL-END
# --- 安全拦截:敏感文件 ---
location ~* (\.user.ini|\.htaccess|\.htpasswd|\.env.*|\.project|\.bashrc|\.bash_profile|\.bash_logout|\.DS_Store|\.gitignore|\.gitattributes|LICENSE|README\.md|CLAUDE\.md|CHANGELOG\.md|CHANGELOG|CONTRIBUTING\.md|TODO\.md|FAQ\.md|composer\.json|composer\.lock|package(-lock)?\.json|yarn\.lock|pnpm-lock\.yaml|\.\w+~|\.swp|\.swo|\.bak(up)?|\.old|\.tmp|\.temp|\.log|\.sql(\.gz)?|docker-compose\.yml|docker\.env|Dockerfile|\.csproj|\.sln|Cargo\.toml|Cargo\.lock|go\.mod|go\.sum|phpunit\.xml|phpunit\.xml|pom\.xml|build\.gradl|pyproject\.toml|requirements\.txt|application(-\w+)?\.(ya?ml|properties))$ {
return 404;
}
# --- 安全拦截:敏感目录 ---
location ~* /(\.git|\.svn|\.bzr|\.vscode|\.claude|\.idea|\.ssh|\.github|\.npm|\.yarn|\.pnpm|\.cache|\.husky|\.turbo|\.next|\.nuxt|node_modules|runtime)/ {
return 404;
}
# SSL 证书验证目录
location ~ \.well-known {
allow all;
}
if ( $uri ~ "^/\.well-known/.*\.(php|jsp|py|js|css|lua|ts|go|zip|tar\.gz|rar|7z|sql|bak)$" ) {
return 403;
}
# /api/ 转发并剥掉 /api 前缀(尾部斜杠不能删)
location /api/ {
proxy_pass http://go_backend_dh2/;
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;
}
# 其余一切请求全部交给 Go:首页、/news、/page/xxx、/themes/ 静态资源、
# /uploads/ 以及以后新增的任何路由,自动生效,nginx 永远不用再改
location / {
proxy_pass http://go_backend_dh2;
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;
}
access_log /www/wwwlogs/dh2.fun.log;
error_log /www/wwwlogs/dh2.fun.error.log;
}
# ==================== 租户官网渲染:*.dh2.fun 所有子域名 ====================
server {
listen 80;
listen 443 ssl;
http2 on;
# 匹配所有子域名,如 ceshi.dh2.fun(租户域名发放后无需再改 nginx)
server_name ~^(?<subdomain>.+)\.dh2\.fun$;
#CERT-APPLY-CHECK--START
include /www/server/panel/vhost/nginx/well-known/dh2.fun.conf;
#CERT-APPLY-CHECK--END
#HTTP_TO_HTTPS_START
set $isRedcert 1;
if ($server_port != 443) {
set $isRedcert 2;
}
if ( $uri ~ /\.well-known/ ) {
set $isRedcert 1;
}
if ($isRedcert != 1) {
rewrite ^(/.*)$ https://$host$1 permanent;
}
#HTTP_TO_HTTPS_END
# 注意:子域名 HTTPS 要求泛域名证书(*.dh2.fun),见文档"注意事项"
ssl_certificate /www/server/panel/vhost/cert/dh2.fun/fullchain.pem;
ssl_certificate_key /www/server/panel/vhost/cert/dh2.fun/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers EECDH+CHACHA20:EECDH+CHACHA20-draft:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:EECDH+3DES:RSA+3DES:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
add_header Strict-Transport-Security "max-age=31536000";
error_page 497 https://$host$request_uri;
location ~ \.well-known {
allow all;
}
# 全部请求转发给 Go,由 Go 按 Host(如 ceshi.dh2.fun)识别租户并渲染官网
location / {
proxy_pass http://go_backend_dh2;
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;
}
access_log /www/wwwlogs/dh2.fun.tenant.log;
error_log /www/wwwlogs/dh2.fun.tenant.error.log;
}
```
---
## 四、完整配置 —— 宝塔站点 `yunzer.com.cn`
文件位置:宝塔 → 网站 → yunzer.com.cn → 设置 → 配置文件。整份替换。
```nginx
# ===== 后端 Go 服务地址统一定义,迁移时只改这一行 =====
# 注意:不能与 dh2.fun 配置里的 upstream 同名,否则 nginx 报 duplicate upstream
upstream go_backend_yunzer {
server 10.31.100.2:8081; # 若 nginx 与 Go 同机,建议改为 127.0.0.1:8081(迁移零改动)
}
# ==================== 主域名 yunzer.com.cn(全量托管给 Go)====================
server {
listen 80;
listen 443 ssl;
listen 443 quic;
http2 on;
# 只匹配主域名(子域名由下方独立 server 块处理)
server_name yunzer.com.cn;
#CERT-APPLY-CHECK--START
include /www/server/panel/vhost/nginx/well-known/yunzer.com.cn.conf;
#CERT-APPLY-CHECK--END
include /www/server/panel/vhost/nginx/extension/yunzer.com.cn/*.conf;
#SSL-START SSL相关配置,请勿删除或修改下一行带注释的404规则
#error_page 404/404.html;
#HTTP_TO_HTTPS_START
set $isRedcert 1;
if ($server_port != 443) {
set $isRedcert 2;
}
if ( $uri ~ /\.well-known/ ) {
set $isRedcert 1;
}
if ($isRedcert != 1) {
rewrite ^(/.*)$ https://$host$1 permanent;
}
#HTTP_TO_HTTPS_END
ssl_certificate /www/server/panel/vhost/cert/yunzer.com.cn/fullchain.pem;
ssl_certificate_key /www/server/panel/vhost/cert/yunzer.com.cn/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers EECDH+CHACHA20:EECDH+CHACHA20-draft:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:EECDH+3DES:RSA+3DES:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_tickets on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
add_header Strict-Transport-Security "max-age=31536000";
error_page 497 https://$host$request_uri;
#SSL-END
# --- 安全拦截:敏感文件 ---
location ~* (\.user.ini|\.htaccess|\.htpasswd|\.env.*|\.project|\.bashrc|\.bash_profile|\.bash_logout|\.DS_Store|\.gitignore|\.gitattributes|LICENSE|README\.md|CLAUDE\.md|CHANGELOG\.md|CHANGELOG|CONTRIBUTING\.md|TODO\.md|FAQ\.md|composer\.json|composer\.lock|package(-lock)?\.json|yarn\.lock|pnpm-lock\.yaml|\.\w+~|\.swp|\.swo|\.bak(up)?|\.old|\.tmp|\.temp|\.log|\.sql(\.gz)?|docker-compose\.yml|docker\.env|Dockerfile|\.csproj|\.sln|Cargo\.toml|Cargo\.lock|go\.mod|go\.sum|phpunit\.xml|phpunit\.xml|pom\.xml|build\.gradl|pyproject\.toml|requirements\.txt|application(-\w+)?\.(ya?ml|properties))$ {
return 404;
}
# --- 安全拦截:敏感目录 ---
location ~* /(\.git|\.svn|\.bzr|\.vscode|\.claude|\.idea|\.ssh|\.github|\.npm|\.yarn|\.pnpm|\.cache|\.husky|\.turbo|\.next|\.nuxt|node_modules|runtime)/ {
return 404;
}
# SSL 证书验证目录
location ~ \.well-known {
allow all;
}
if ( $uri ~ "^/\.well-known/.*\.(php|jsp|py|js|css|lua|ts|go|zip|tar\.gz|rar|7z|sql|bak)$" ) {
return 403;
}
# /api/ 转发并剥掉 /api 前缀(尾部斜杠不能删)
location /api/ {
proxy_pass http://go_backend_yunzer/;
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;
}
# 其余一切请求全部交给 Go:首页、/news、/page/xxx、/themes/ 静态资源、
# /uploads/ 以及以后新增的任何路由,自动生效,nginx 永远不用再改
location / {
proxy_pass http://go_backend_yunzer;
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;
}
access_log /www/wwwlogs/yunzer.com.cn.log;
error_log /www/wwwlogs/yunzer.com.cn.error.log;
}
# ==================== 租户官网渲染:*.yunzer.com.cn 所有子域名 ====================
server {
listen 80;
listen 443 ssl;
http2 on;
# 匹配所有子域名,如 ceshi.yunzer.com.cn(租户域名发放后无需再改 nginx)
server_name ~^(?<subdomain>.+)\.yunzer\.com\.cn$;
#CERT-APPLY-CHECK--START
include /www/server/panel/vhost/nginx/well-known/yunzer.com.cn.conf;
#CERT-APPLY-CHECK--END
#HTTP_TO_HTTPS_START
set $isRedcert 1;
if ($server_port != 443) {
set $isRedcert 2;
}
if ( $uri ~ /\.well-known/ ) {
set $isRedcert 1;
}
if ($isRedcert != 1) {
rewrite ^(/.*)$ https://$host$1 permanent;
}
#HTTP_TO_HTTPS_END
# 注意:子域名 HTTPS 要求泛域名证书(*.yunzer.com.cn),见文档"注意事项"
ssl_certificate /www/server/panel/vhost/cert/yunzer.com.cn/fullchain.pem;
ssl_certificate_key /www/server/panel/vhost/cert/yunzer.com.cn/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers EECDH+CHACHA20:EECDH+CHACHA20-draft:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:EECDH+3DES:RSA+3DES:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
add_header Strict-Transport-Security "max-age=31536000";
error_page 497 https://$host$request_uri;
location ~ \.well-known {
allow all;
}
# 全部请求转发给 Go,由 Go 按 Host(如 ceshi.yunzer.com.cn)识别租户并渲染官网
location / {
proxy_pass http://go_backend_yunzer;
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;
}
access_log /www/wwwlogs/yunzer.com.cn.tenant.log;
error_log /www/wwwlogs/yunzer.com.cn.tenant.error.log;
}
```
> 若管理端(platform/backend 前端)部署在专属子域名(如 `admin.yunzer.com.cn`),在对应站点再加一个 `server_name admin.yunzer.com.cn;` **精确匹配**的 server 块指向其静态目录即可——精确匹配优先于子域名正则块,互不干扰。
---
## 五、重要注意事项
1. **子域名必须用泛域名证书**。租户域名是二级域名(`ceshi.yunzer.com.cn`),单域名证书会导致 HTTPS 证书错误。需在宝塔为 `*.yunzer.com.cn`、`*.dh2.fun` 申请泛域名证书(DNS 验证),并替换配置中的证书路径。
2. **upstream 不能重名**。两个站点配置都在同一 http 上下文,本文分别用 `go_backend_dh2`、`go_backend_yunzer`;重名会 `duplicate upstream` 启动失败。
3. **`/api/` 的 `proxy_pass` 尾部斜杠不能删**,它负责剥掉 `/api` 前缀(Go 路由注册在 `/backend/*`、`/platform/*`)。兜底 `location /` 的 `proxy_pass` 则**不带**尾部斜杠(原样转发 URI)。
4. **不要再给官网域名加 `js|css` 正则缓存 location**。正则 location 优先级高于兜底前缀 location,会拦截 `/themes/xxx/static/main.js` 去本地 root 找文件导致 404。模板静态资源由 Go(beego)直接提供。
5. **`proxy_set_header Host $host;` 必须保留**。Go 完全依赖 Host 识别租户;若透传成 upstream 地址,所有请求都会被识别失败。
6. **Go 侧前置条件**:
- 启动模式 `APP_MODE` 为 `all`(默认)或 `index`,否则官网渲染路由未注册;
- `go/themes/` 目录与 Go 服务启动目录同级(模板文件在 **Go 项目目录** `go/themes/{编码}/`,与 platform/backend 前端项目无关);
- 数据库已执行 `sql/yz_cms_frontend_tables.sql`。
7. **域名发放数据必须精确**。`yz_system_tenant_domain.full_domain` 要与实际访问域名完全一致(不带协议、不带端口、不带路径),且 `status=1`、`delete_time` 为 NULL。
8. **DNS 泛解析**。`*.yunzer.com.cn`、`*.dh2.fun` 需要配置泛解析 A 记录指向服务器,否则新发放的子域名无法访问(与 nginx 无关)。
9. **HSTS 提醒**。配置了 `Strict-Transport-Security`,一旦浏览器记住,证书出问题期间该域名将无法用 HTTP 访问;测试阶段可先注释掉该行。
10. **宝塔面板操作提示**。直接编辑站点配置文件后,用 `nginx -t` 校验再重载;宝塔后续"站点设置"的某些操作可能重写配置文件,改前建议备份。
---
## 六、验证与排查
### 6.1 部署后验证顺序
```bash
# 1. 绕过 nginx 直接验证 Go(在服务器上执行)
curl -H "Host: ceshi.yunzer.com.cn" http://10.31.100.2:8081/
# 2. 验证 nginx → Go 链路
curl -k -H "Host: ceshi.yunzer.com.cn" https://127.0.0.1/
# 3. 浏览器访问 https://ceshi.yunzer.com.cn
```
### 6.2 常见现象对照表
| 现象 | 原因 | 处理 |
|---|---|---|
| Go 页面显示"站点不存在" | Host 在 `yz_system_tenant_domain` / 域名池中查不到 | 核对 `full_domain` 拼写、`status`、`delete_time` |
| beego 默认 404 | `APP_MODE` 不含 `all`/`index`,渲染路由未注册 | 检查 Go 启动环境变量 |
| nginx 404(未到 Go) | 请求被本地静态/正则规则拦截 | 检查是否残留 `try_files /index.html`、`js|css` 正则 location |
| 浏览器证书错误 | 非泛域名证书 | 申请 `*.域名` 泛域名证书并替换路径 |
| 502 Bad Gateway | Go 服务未启动或 upstream 地址错误 | `curl http://127.0.0.1:8081/` 验证服务存活,改 upstream |
| 首页正常但样式丢失 | `/themes/` 未到达 Go | 确认兜底转发存在且无 js/css 正则拦截 |
---
## 七、迁移服务器清单(以迁移到 192.168.1.101 为例)
1. **新机器部署 Go**:复制 `go/` 目录(含 `themes/`、`conf/app.conf`),修改 `app.conf` 数据库连接为新库地址,启动服务;
2. **新库初始化**:导入业务数据,执行 `sql/` 下全部建表/变更 SQL(尤其 `yz_cms_frontend_tables.sql`);
3. **nginx**:
- nginx 与 Go 同机 → upstream 用 `127.0.0.1:8081`,配置零改动;
- 跨机 → 只改两个 upstream 块中的 `server` 一行;
4. **证书**:新机器宝塔上为两个域名重新申请/导入证书(含泛域名),路径与配置保持一致;
5. **DNS**:将 `dh2.fun`、`yunzer.com.cn` 及泛解析 `*` 的 A 记录改指向新服务器 IP;
6. **验证**:按第六节顺序逐一验证;
7. 确认无误后到域名服务商处调低旧解析 TTL、观察切换。
---
*本文档对应代码:`go/controllers/tenant_site.go`(Host 识别与渲染)、`go/pkg/tagengine/`(标签引擎)、`go/themes/default/`(内置默认模板)、`sql/yz_cms_frontend_tables.sql`(建站数据表)。*