Wujies 无界平台
部署

前端部署与 nginx

本文是 1.x 的归档文档(冻结于 1.x)。该分支只接收勘误修正, 内容基本不再变动;文中的源码路径、配置项与版本号均以 1.x 为准,不建议用于新项目。

新项目请查看 5.x 最新文档。已在 1.x 上运行的项目,本文仍是可靠依据。

Wujies 无界平台的宿主前端是一个 Vue 3 + Vite 单页应用(仓库 plus-ui),产物是一组静态文件,由 nginx 托管。同一个 nginx 还要做三件额外的事:反代后端接口(/prod-api/)、提供运行期落位的远程应用产物(/remote-apps/)、反代对象存储(/minio/)。

本页解决三件事:

  1. 宿主前端用哪种模式构建、产物落在哪个目录;
  2. 远程应用产物的物理路径在「本机直跑」与「容器」两种形态下分别是什么,怎么和后端 app-market.deploy.frontend-dir、nginx 对齐 —— 这是这一章最容易出错的一环;
  3. deploy/docker/frontend/nginx.conf 逐段在做什么。

远程应用为什么必须是独立 ESM 产物、宿主怎么在运行期加载它,见 /advanced/remote-app;插件前端工程怎么构建、产物目录名有什么约定,见 /plugin-dev/frontend/。

命名先对齐:三个 wujies-admin、两个 app-market-dist

wujies-admin 这个名字在三处含义不同,凡是路径都要先看清是哪一个:

写法是什么判据
plus-ui/宿主前端仓库(Vue 3 / Vite)有 vite.config.ts / src/;它的 package.json 里 name 写的是 wujies-admin,这就是混淆来源
wujies-platform/wujies-admin/后端启动模块(Java)有 pom.xml / src/main;产物是 wujies-admin.jar
工作区顶层 wujies-admin/只有 app-market-dist/ —— 前端产物的落位目录里面没有 package.json、没有 src/
wujies/wujies-plugins/frontend/<code>-app/远程应用前端工程(源码,pnpm workspace)构建配置走 frontend/build/vite.ts 的 defineRemoteApp
app-market-dist 也有两个,它们不是同一个目录
  • 后端落位写文件的位置是工作区顶层的 wujies-admin/app-market-dist/remote-apps/(application.yml 里 frontend-dir 的当前值);
  • 浏览器真正取到 JS 的位置是 plus-ui/app-market-dist/remote-apps/(plus-ui/vite/plugins/appMarketStatic.ts 的 sourceDir = <plus-ui 根>/app-market-dist)。

两者是独立的物理目录(都不是 junction / symlink),所以「后端落位成功」与「页面能打开」可能对不上。逐目录的实测对照见 /guide/structure。

三种构建模式

模式命令(在 plus-ui 下)baseAPI 前缀产物目录用途
生产npm run build:prod.env.production 的 VITE_APP_CONTEXT_PATH = /admin/.env.production 的 VITE_APP_BASE_API(指向某套线上环境的绝对地址)plus-ui/dist部署在 /admin/ 子路径下、接口在另一个域名的传统部署
开发构建npm run build:dev.env.development 的 VITE_APP_CONTEXT_PATH = //dev-apiplus-ui/dist本地联调产物,接口走 dev server 的代理
Docker / 1Panelvite build --mode docker(package.json 里没有对应的 npm script).env.docker 的 VITE_APP_CONTEXT_PATH = //prod-api(相对路径)plus-ui/dist容器部署:一个镜像跑在任何域名下

三种模式都靠 mode 决定加载哪个 .env.* 文件,plus-ui/vite.config.ts 里 base: env.VITE_APP_CONTEXT_PATH;dev server 的接口代理目标是 http://127.0.0.1:8086(server.port 的默认值,见 wujies-admin/src/main/resources/application.yml)。build-1panel.ps1 用的是第三种:

# build-1panel.ps1 内部(Push-Location 到 plus-ui 之后)
& node 'scripts/gen-runtime-shims.mjs'
& node 'node_modules/vite/bin/vite.js' build --mode docker

-SkipFrontend 会跳过这两条命令,复用已有 dist/;无论跳不跳,脚本都会检查 dist/index.html 是否存在,不存在直接抛错。

Docker 模式的两点区别,都是为了「一个镜像跑在任何域名下」
  1. VITE_APP_BASE_API 用相对路径 /prod-api,由 nginx 反代到后端容器,不写死任何域名 / IP;
  2. VITE_APP_CONTEXT_PATH 用 /,容器自己就是站点根,不再挂 /admin 子路径。 .env.production 里的接口地址指向某套线上环境,绝不能进镜像 —— 这正是要单独有一个 .env.docker 的原因。

产物目录就是 Vite 默认的 dist

工程里没有配置 build.outDir(plus-ui/vite.config.ts 里没有这一项),所以宿主前端的产物目录是 Vite 的默认值 plus-ui/dist。

plus-ui/vite/plugins/appMarketStatic.ts 在 writeBundle 阶段会把整棵 plus-ui/app-market-dist/(app-runtime/ 垫片 + remote-apps/ 各应用)复制进 dist/。所以 dist/remote-apps/ 里是构建那一刻的产物快照,随后 build-1panel.ps1 又把整棵 dist/ 拷进前端镜像的构建上下文 —— 镜像里因此也有一份 remote-apps/。这一点在下面的「坑 3」里会用到。

远程应用工程自己的产物目录由共享工厂决定,不是各工程手写 outDir(wujies/wujies-plugins/frontend/build/vite.ts 的 resolveOutDir):

  • 设了环境变量 WUJIES_FRONTEND_DIST → 产物落到 <该变量>/<appCode>/;
  • 没设 → 落到该工程自己的 dist/(自包含,可单独验证)。

目录名必须等于应用编码 appCode,这是四处共同的约定:vite 插件的 appCode、wujies/wujies-plugins/package.ps1 的 -FrontendDist 默认值、后端落位目录、GET /app/runtime/remote-apps 拼出的 URL。细节见 /plugin-dev/frontend/。

/remote-apps/ 的物理路径:谁写、谁读

远程应用的入口 URL 形如 /remote-apps/<appCode>/index.js?v=<版本号>,由 AppRuntimeController 拼出。这个前缀在三处约定,两处环境下的物理路径不同:

环境后端写到哪谁从哪读对齐情况
本机直跑(后端工作目录 wujies-platform/)../wujies-admin/app-market-dist/remote-apps/<appCode>/,即工作区顶层那个 wujies-admin/plus-ui dev server 读 plus-ui/app-market-dist/remote-apps/<appCode>/两个目录,直跑时可能「落位成功但页面打不开」
容器(1Panel / 手工 Docker)/app/remote-apps/<appCode>/nginx 容器读 /var/www/remote-apps/<appCode>/同一个宿主目录 ./data/remote-apps,一致

直跑:yml 的当前值与注解兜底值并不一样

# wujies-admin/src/main/resources/application.yml
app-market:
  deploy:
    # 前端远程应用落位目录,必须与宿主 /remote-apps/ 的物理路径一致
    frontend-dir: ../wujies-admin/app-market-dist/remote-apps

⚠️ 这是当前真实值,它指向工作区顶层那个只装 app-market-dist/ 的 wujies-admin/。而 Java 注解里的兜底默认值是 ../plus-ui/app-market-dist/remote-apps(AppPackageDeployService、PluginAppInstaller 的 @Value)—— 两者不一致,不要按兜底值理解实际行为。

想让直跑时的落位目录与 dev server 读的目录合一,有两条路:

  1. 把 frontend-dir 改成 ../plus-ui/app-market-dist/remote-apps —— 属于改配置,改完必须重建 wujies-admin.jar(yml 在 jar 里,改文件本身不生效);
  2. 不动配置,让构建直接落到 dev server 读的那份(本地联调常用,见下)。
本地联调:用 WUJIES_FRONTEND_DIST 让构建即落位
# 在插件仓库 wujies/wujies-plugins 下执行;路径按自己的仓库位置替换
$env:WUJIES_FRONTEND_DIST = '<宿主前端仓库根>\app-market-dist\remote-apps'
pnpm --filter ./frontend/exam-app run build     # 产物直接落到 <该目录>/exam/

工厂会自动追加 <appCode> 子目录,所以这一次构建同时满足了「目录名 = appCode」和「落到 dev server 读的目录」。不设这个变量时产物落在插件工程自己的 dist/,dev server 取不到,控制台会报 Failed to fetch dynamically imported module: /remote-apps/<code>/index.js。

容器:靠共享卷把两边接起来

1Panel 应用包内的 docker-compose.yml 用两行挂载把后端与 nginx 接到同一个宿主目录:

# 后端容器:读写同一个宿主目录(compose 里还有 APP_MARKET_DEPLOY_FRONTEND_DIR: /app/remote-apps)
volumes:
  - ./data/remote-apps:/app/remote-apps
# 前端容器:只读同一个宿主目录
volumes:
  - ./data/remote-apps:/var/www/remote-apps:ro

也就是:后端读写、nginx 只读,同一个卷。./data/remote-apps 在实例目录下(1Panel 的实例根是 /opt/1panel/apps/local/<包名>/<实例名>/),卷没挂上时产物只存在于容器文件系统里,容器一重建就丢。

容器里覆盖 frontend-dir 的是环境变量 APP_MARKET_DEPLOY_FRONTEND_DIR=/app/remote-apps(Spring 会把 APP_MARKET_DEPLOY_FRONTEND_DIR 换算成属性名 app-market.deploy.frontend-dir,优先级高于 jar 内 yml)。注意 entrypoint.sh 只显式传了 -Dapp-market.deploy.plugin-dir,没有传 frontend-dir。

只要有一处不一致,症状都是「装成功了但页面打不开」
错在哪表现
frontend-dir 与 nginx 的 alias 指向不同目录后端把产物写到了 A,nginx 从 B 找 → /remote-apps/<code>/index.js 404
frontend-dir 与容器实际挂载点不同后端在容器文件系统里写了一个没挂出去的目录,容器重建即丢
目录名不等于 appCode产物在,但 URL 拼出来是 /remote-apps/<code>/index.js,try_files 直接 404
直跑:落位目录与 dev server 读的目录不是同一个后端报告落位成功,浏览器报「远程视图不可用」

排查入口:浏览器控制台里那句 [app-market] 远程应用入口(后端):,加 Network 里那个 entry 请求的状态码。

deploy/docker/frontend/nginx.conf 全文

下面是与镜像构建上下文同目录的文件全文(deploy/docker/frontend/nginx.conf,逐字来自工程):

# ---------------------------------------------------------------------------
# 前端 nginx 配置(作为模板,容器启动时由官方 entrypoint 做 envsubst)
#
# 只替换**环境里真实存在**的变量(nginx 的 $host/$uri 等不受影响):
#   ${BACKEND_HOST} / ${BACKEND_PORT}  → 后端容器(compose 里给的是 ${CONTAINER_NAME}-backend,
#                                        这样同一张 1panel-network 上装两套(服务端+客户端)也不会串)
#   ${MINIO_HOST}   / ${MINIO_PORT}    → MinIO(对象存储,安装包/图片)
# ---------------------------------------------------------------------------
server {
    listen 80;
    server_name _;
    client_max_body_size 1000m;
    charset utf-8;

    root /usr/share/nginx/html;
    index index.html;

    gzip on;
    gzip_min_length 1k;
    gzip_types text/plain text/css application/json application/javascript text/javascript application/xml image/svg+xml;

    # 后端接口:/prod-api/xxx → http://backend:8080/xxx
    # (与 .env.docker 的 VITE_APP_BASE_API=/prod-api 对应;末尾斜杠保证前缀被剥掉)
    location /prod-api/ {
        proxy_pass http://${BACKEND_HOST}:${BACKEND_PORT}/;
        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;
        # SSE(消息推送)必须关缓冲、拉长超时,否则前端收不到事件
        proxy_buffering off;
        proxy_cache off;
        proxy_read_timeout 3600s;
        proxy_send_timeout 3600s;
    }

    # 远程应用前端产物:后端把插件前端释放到共享卷,这里按稳定 URL 提供
    # (应用市场运行期按 /remote-apps/<appCode>/index.js?v=<版本> 动态 import)
    # ⚠️ `^~` 不可省:末尾那条正则段会抢在普通前缀之前命中(见下面「坑 3」)
    location ^~ /remote-apps/ {
        alias /var/www/remote-apps/;
        add_header Cache-Control "no-cache";
        try_files $uri =404;
    }

    # 对象存储(MinIO)反代:这样 sys_oss_config.domain 可以写成相对路径 /minio,
    # 不用把 MinIO 端口暴露到公网、也不用写死域名/IP
    location /minio/ {
        proxy_pass http://${MINIO_HOST}:${MINIO_PORT}/;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }

    # 本地文件存储(sys_oss_config 里选 local 时)——留着备用
    location /upload/ {
        alias /var/www/upload/;
    }

    # 前端静态资源:带 hash 的资源长缓存,index.html 不缓存
    location ~* \.(js|css|png|jpg|jpeg|gif|svg|woff2?|ttf|eot|ico|webp|map)$ {
        expires 7d;
        add_header Cache-Control "public, max-age=604800";
        try_files $uri =404;
    }

    # SPA 回退:其余路径交给前端路由(history 模式)
    location / {
        add_header Cache-Control "no-cache";
        try_files $uri $uri/ /index.html;
    }
}

逐段解释

段落作用注意
文件头注释声明这是一个模板,容器启动时由官方 nginx 镜像的 entrypoint 做 envsubst只有「环境里真实存在」的变量会被替换,$host / $uri 这类 nginx 自己的变量不受影响
listen 80 / server_name _容器内单站点,不绑定具体域名一个镜像跑在任何域名下
client_max_body_size 1000m允许上传大文件(应用包、图片)调小会让上架「上传安装包」失败
gzip on压缩文本类响应Docker 模式的构建产物不做预压缩(.env.docker 的 VITE_BUILD_COMPRESS = none),交给 nginx
location /prod-api/把 /prod-api/xxx 反代到后端的 /xxxproxy_pass 末尾的 / 保证前缀被剥掉;少了它后端会收到 /prod-api/xxx 而 404
proxy_buffering off + 3600s 超时SSE 消息推送必需见下节
location ^~ /remote-apps/提供运行期落位的远程应用产物用 alias 不是 root;^~ 必需 —— 少了它就是普通前缀,会被末尾的正则段抢先命中,整条失效(见下面的坑 3)
location /minio/反代对象存储这样 sys_oss_config.domain 可以写成相对路径,不用暴露 MinIO 端口
location /upload/本地文件存储(sys_oss_config 选 local)对应 compose 里 ./data/upload:/var/www/upload:ro
末尾的静态资源正则段带 hash 的资源长缓存 7 天try_files $uri =404:找不到就 404,不要回退到 HTML
location /SPA 回退到 index.html(history 模式)否则刷新 /appmarket/list 会 404

四个必须知道的坑

1. 模板必须放 /etc/nginx/templates/*.template

deploy/docker/frontend/Dockerfile 里这几行是硬要求:

RUN mkdir -p /var/www/remote-apps /var/www/upload
COPY nginx.conf /etc/nginx/templates/default.conf.template
COPY dist/ /usr/share/nginx/html/

官方 nginx 镜像的 20-envsubst-on-templates.sh 会把 templates/*.template 用 envsubst 渲染后落到 conf.d/。直接放 conf.d/ 的话 nginx 会把 ${BACKEND_HOST} 当成自己的变量,启动即报:

unknown "backend_host" variable

容器起不来(实测踩到过)。

2. /remote-apps/ 用 alias,不是 root

location ^~ /remote-apps/ {
    alias /var/www/remote-apps/;   # ✅
}

(^~ 是必需的,理由见坑 3;少了它这一条会整个失效。)

用 root 的话,请求 /remote-apps/exam/index.js 会被映射到 <root>/remote-apps/exam/index.js,而实际文件在 /var/www/remote-apps/exam/index.js —— 多了一层 remote-apps,全是 404。

3. 静态资源正则段会抢在 /remote-apps/ 之前

nginx 的匹配顺序是「= 精确 → ^~ 前缀 → 正则 → 普通前缀」。配置里 location /remote-apps/ 是普通前缀,而末尾那条 location ~* \.(js|css|png|…)$ 是正则段 —— 所以 /remote-apps/<appCode>/index.js、assets/*.css 这类请求由正则段接走,按 root /usr/share/nginx/html 去找文件,alias /var/www/remote-apps/ 根本不参与。两种表现:

  • 构建镜像时 plus-ui/app-market-dist/ 里已经有这个应用(十个业务应用都在;demo-app 是历史遗留的产物目录,已从应用目录去掉,不该再进快照。appMarketStatic.ts 会把整棵 app-market-dist/ 复制进 dist/,build-1panel.ps1 再拷进镜像)→ 命中的是跟随镜像烘进去的那份快照,运行期落位到共享卷的新版本读不到;顺带那条 add_header Cache-Control "no-cache" 也不生效,改用正则段的 7 天缓存。
  • 构建镜像时没有它(例如以后装的第三方应用)→ 同一个 URL 命中的是正则段的 try_files $uri =404,浏览器拿到 404。

⚠️ 2026-10-03 线上实测复现(就是这一条打出来的故障):console.wujies.cn 报 TypeError: Failed to fetch dynamically imported module: https://console.wujies.cn/remote-apps/hello/index.js?v=1.0.0。 证据链:后端卷里 /app/remote-apps/hello/index.js 在(16721 B),nginx 容器也挂着同一个宿主目录 (./data/remote-apps:/var/www/remote-apps:ro),而 location /remote-apps/ 是普通前缀 ⇒ 请求被正则段接走、 按 root /usr/share/nginx/html 去找 ⇒ 404。同一份配置在一次性容器里对照复现:普通前缀 → HTTP/1.1 404; 改成 ^~ → HTTP/1.1 200 + Cache-Control: no-cache,且取到的是共享卷那份(而不是镜像里烘进去的快照)。

修法是给前缀 location 加 ^~(命中即停止,不再看正则),改完重建前端镜像:

location ^~ /remote-apps/ {
    alias /var/www/remote-apps/;
    add_header Cache-Control "no-cache";
    try_files $uri =404;
}

复核办法(在装了这套的主机上跑,不需要改任何东西):

# 容器名:前端 = 1Panel 实例名(主服务),后端 = <实例名>-backend
# 镜像里烘进去的那份 vs 卷里运行期落位的那份,比一下目录内容
docker exec <前端容器> ls /usr/share/nginx/html/remote-apps
docker exec <前端容器> ls /var/www/remote-apps
# 看这条请求实际带的是哪个缓存头:no-cache = 走了 /remote-apps/;max-age=604800 = 走了正则段
curl -sI http://<域名>/remote-apps/<code>/index.js | grep -i cache-control

4. try_files $uri =404 意味着纯后端应用访问该路径会 404

/remote-apps/<code>/ 下没有产物时(某应用是纯后端应用,清单里没有 assets.frontend),请求这个路径会得到 404。这是正常现象,不是部署漏了文件 —— 那个应用只提供后端接口与菜单,不提供远程视图。宿主也不会把它列进 GET /app/runtime/remote-apps。

只有当「清单声明了 assets.frontend,但目录里确实没有文件」时,才说明落位没成功。

宿主机 nginx(非容器)

不用容器时,把 nginx 的站点根指向前端 dist,并自己保证下面这些(路径按自己的部署根替换):

server {
    listen 80;
    server_name _;
    client_max_body_size 1000m;
    charset utf-8;

    root /srv/wujies/frontend;        # plus-ui/dist 拷到这里
    index index.html;

    location /prod-api/ {
        proxy_pass http://127.0.0.1:8086/;   # 直跑后端的监听地址(application.yml 的 server.port)
        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_buffering off;
        proxy_read_timeout 3600s;
    }

    location ^~ /remote-apps/ {
        # 必须与 app-market.deploy.frontend-dir 指向同一个物理目录
        alias /srv/wujies/remote-apps/;
        add_header Cache-Control "no-cache";
        try_files $uri =404;
    }

    location / {
        add_header Cache-Control "no-cache";
        try_files $uri $uri/ /index.html;
    }
}

三件事必须自己做:

  1. 后端配置要对齐:把 app-market.deploy.frontend-dir 改成上面 alias 的那个目录(或者反过来,把 alias 指到 frontend-dir)。改 yml 后要重建 wujies-admin.jar,因为 yml 在 jar 里。
  2. base 必须与访问前缀一致:前端构建时 base 取自 .env.* 的 VITE_APP_CONTEXT_PATH。站点跑在根路径就用 /(即 --mode docker);跑在 /admin/ 子路径就用 /admin/(即 build:prod),同时 nginx 的 location 也要挂在 /admin/ 下。构建时的 base 与实际的访问前缀不一致,表现是白屏 + 控制台里所有静态资源 404。
  3. 用了 build:prod 就顺带处理预压缩产物:VITE_BUILD_COMPRESS = gzip 会额外产出 .gz(不删原文件)。想用它们要在 nginx 里开 gzip_static on;(需要编译进该模块);不打算用就什么也不用做。容器的 nginx 配置没有开它,因为镜像里的产物是 --mode docker 出来的、本来就没有 .gz。

SSE 为什么要关 proxy_buffering

平台的消息推送走 SSE(VITE_APP_SSE = true)。SSE 是一条长连接、持续追加的响应流。nginx 默认开启 proxy_buffering,会把上游的响应攒在缓冲区里攒够一定体积再一起发给浏览器 —— 对普通请求是优化,对 SSE 就是「事件被攒住不发」。

表现是:连接建立成功(200),但前端一直收不到事件,直到缓冲被填满或连接结束才一次性收到。

所以 /prod-api/ 这段必须:

proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;    # 默认 60s,长连接会被掐断
proxy_send_timeout 3600s;

超时同样重要:默认 proxy_read_timeout 60 秒,没有事件推送时连接会被 nginx 主动关闭,前端表现为「每隔一分钟重连一次」。

相关阅读

目录