From 554b0f119e818eacc9d96fffd812d4efd0695cc1 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 23 Oct 2026 15:25:45 +0800 Subject: [PATCH] fix(deploy): replace PDA H5 containers with static hosting --- .dockerignore | 12 ----- AGENTS.md | 1 + README.md | 42 +++++++++------- deploy/Dockerfile.h5 | 5 -- deploy/Dockerfile.h5.production | 5 -- deploy/docker-compose.h5.production.yml | 29 ----------- deploy/docker-compose.h5.test.yml | 27 ---------- deploy/nginx.conf | 16 ------ deploy/nginx.conf.template | 21 ++++++++ deploy/nginx.production.conf.template | 26 ---------- tests/h5-deployment.test.mjs | 67 ++++++++----------------- 11 files changed, 65 insertions(+), 186 deletions(-) delete mode 100644 .dockerignore delete mode 100644 deploy/Dockerfile.h5 delete mode 100644 deploy/Dockerfile.h5.production delete mode 100644 deploy/docker-compose.h5.production.yml delete mode 100644 deploy/docker-compose.h5.test.yml delete mode 100644 deploy/nginx.conf create mode 100644 deploy/nginx.conf.template delete mode 100644 deploy/nginx.production.conf.template diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index de0e642..0000000 --- a/.dockerignore +++ /dev/null @@ -1,12 +0,0 @@ -# Only compiled H5 assets and image configuration enter the build context. -** -!deploy/ -!deploy/Dockerfile.h5 -!deploy/nginx.conf -!dist/ -!dist/build/ -!dist/build/h5/ -!dist/build/h5/** - -!deploy/Dockerfile.h5.production -!deploy/nginx.production.conf.template diff --git a/AGENTS.md b/AGENTS.md index 2e8d627..68cc29b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,7 @@ ## 服务与发布边界 +- H5只上传Nginx站点,禁止容器发布及重跑旧灰度脚本;步骤见`README.md`。 - Auth 唯一签发账号/Token;认证/业务共用 Gateway,App 根地址只读 VITE_API_BASE_URL,H5 用 VITE_H5_API_BASE_URL。 - 登录/刷新/会话校验Token平台主体、成员、有效期、冻结仓;`OVERSEAS_WAREHOUSE_PDA_OPERATOR`、`lastleg:pda:operate` 仅读存Auth登录/刷新顶层快照,禁解析JWT权限。快照缺失/无效/过期/跨主体或撤权均fail-closed清会话,不能只藏入口。 - 并发401仅一次refresh,完整校验Token/快照再原子替换;主体/仓变化清会话重登录,原请求/照片上传最多重放一次。刷新失败清会话;业务/网络错留code、requestId、traceId或network诊断。 diff --git a/README.md b/README.md index 3211138..0829686 100644 --- a/README.md +++ b/README.md @@ -75,34 +75,38 @@ Android App 启动后会读取 `VITE_PDA_UPDATE_URL` 并检查海外 PDA 的独 3. 更新清单与下载地址使用 HTTPS,先在测试设备验证可选更新、强制更新、断网重试、APK 覆盖安装、wgt 重启及扫码作业回归,再切换正式清单。 4. 海外和国内更新记录、文件目录及发布权限保持隔离,服务端必须按请求中的 `appid` 拒绝跨应用更新包。 -## TEST H5 静态托管 +## H5 静态托管(TEST 与灰度) -发布构建使用 `/` 资源 base 与 hash 路由,部署到本应用独立域名根目录,浏览器入口为 `/#/`。开发服务仍使用根路径;不能将 Vite 开发代理当作静态服务器配置。 +国内、海外 PDA H5 统一由宿主机已有 Nginx/宝塔站点直接提供静态文件,不再构建或发布 PDA H5 Docker 镜像、Compose 服务或容器。正式部署约束见 [测试环境单机部署与配置契约](../../docs/测试环境单机部署与配置契约.md) 的“PDA H5 发布方式”;历史灰度报告不再作为发布入口。 -执行 `npm run build:h5`。无需启动 Vite 开发服务器;`npm run build:app` 不属于 H5 发布。 +发布构建使用 `/` 资源 base 与 hash 路由,部署到本应用独立域名根目录,浏览器入口为 `/#/`。不能放到子目录后继续共用根路径资源,也不能将 Vite 开发代理当作线上配置。 -`deploy/Dockerfile.h5` 只复制已构建 `dist/build/h5/` 及静态 Nginx 配置,不在镜像中复制源码、依赖或重新编译。`.dockerignore` 只放行这两类输入。容器静态目录为 `/srv/pda-h5/`。 +执行 `npm run build:h5` 导出 Web 静态文件;`npm run build:app` 不属于 H5 发布。 -从本仓库执行 `docker compose -f deploy/docker-compose.h5.test.yml build --pull=false`,再由获授权的部署流程执行 `docker compose -f deploy/docker-compose.h5.test.yml up -d --wait --wait-timeout 120`。必须设置 `IMAGE_TAG`,`NGINX_BASE_IMAGE` 可指定已缓存的兼容 Alpine Nginx 镜像。容器名及网络别名为 `pda-overseas-h5-test`,仅在外部 `firstleg-test-network` 暴露容器 80,不发布宿主端口。健康检查同时请求 `/healthz` 与 `/` 的实际首页。 +发布步骤: -独立域名入口将 `/` 代理至本应用静态容器根目录,或将构建目录内容直接发布到站点根目录;不再挂载 PDA 子路径。缺失资源返回 404,不回退首页,HTML 使用 `Cache-Control: no-store`。域名入口还须配置同源 API 代理:两端均将 `/gateway-api/` 转统一 Gateway `/api/`。入口须保留 Authorization,支持文件上传下载及至少 11m 请求体。域名与上游只在部署侧配置,不编入 PDA 源码。 +1. 构建后执行 `node scripts/verify-h5.mjs`,核对 HTML/JS/CSS 资源路径及同源 API。构建、测试仍按根规则经 `local-check.ps1` 执行。 +2. 将 `dist/build/h5/` **目录内的文件**上传到该 PDA 站点现有 `root`。先上传 `assets/`、`static/` 等资源,最后替换 `index.html`,保留站点证书验证目录和站点配置;不要把外层构建目录一起嵌套上传。 +3. 页面 `location /` 使用 `try_files $uri $uri/ =404` 直接读取静态文件,设置 `Cache-Control: no-store`;不得再转发到旧 PDA 容器。接口 `/gateway-api/` 独立转发到当前环境统一 Gateway `/api/`,保留 Authorization 和上传能力。 +4. 首次切换前备份实际生效站点配置,合并下述片段并执行 `nginx -t`,成功后才重载。已有独立 API 规则先核对并复用,不能直接追加重复 `location`。后续仅更新静态文件时无需新建容器或重载 Nginx。 +5. 检查域名首页及实际引用的 JS/CSS 均返回 200,并将响应内容的 SHA-256 与本次产物逐项比较;确认响应不是旧容器内容。再检查登录和受影响页面。首页 200 不等于接口或业务验收通过。 -部署配置检查:`node --test tests/h5-deployment.test.mjs`。没有 Docker 运行时的本地合同检查不能替代目标主机 `docker compose config`、Nginx 启动与真实 HTTP 验收。 +### 站点配置片段 -构建后执行 `node scripts/verify-h5.mjs`,核对 HTML/JS/CSS 资源路径、同源 API 和无本机/旧海外配置泄漏。该检查与本地构建仅证明产物;TEST 页面登录、仓权限、业务操作和 Android 真机另行验收。 +`deploy/nginx.conf.template` 是合并到已有 `server` 的参考片段,不是完整站点配置。保留现有域名、HTTPS、证书与宝塔 include;替换原有 root/index 及同名 location。环境地址、端口及网站目录只在部署侧配置,不提交到源码。 + +在部署机器上将 `PDA_SITE_ROOT` 设置为该站点真实根目录,`PDA_GATEWAY_ORIGIN` 设置为宿主机可达的当前环境 Gateway 源地址(协议、主机、端口,不含末尾 `/` 或 `/api`)。不要使用仅 Docker 网络可解析的服务名。显式只渲染这两个变量,保留 `$uri`、`$host` 和代理请求头变量: + +```bash +envsubst '${PDA_SITE_ROOT} ${PDA_GATEWAY_ORIGIN}' < deploy/nginx.conf.template +``` + +输出仅供核对和合并,不直接覆盖完整站点。若机器没有 `envsubst`,手工替换这两个占位符;未替换的模板不能直接加载。 ### 两端统一 API 配置 -两端的 `VITE_API_BASE_URL` 均为统一 Gateway 绝对地址(含 `/api`);`VITE_H5_API_BASE_URL=/gateway-api`;`API_PROXY_TARGET` 为同一 Gateway 源地址(不含 `/api`)。实际地址只放在未提交的 `.env.local`;修改后重启开发服务,发布前重新打包。 +App 的 `VITE_API_BASE_URL` 为统一 Gateway 绝对地址(含 `/api`);H5 使用 `VITE_H5_API_BASE_URL=/gateway-api`。`API_PROXY_TARGET` 仅供本地 Vite 开发代理使用,不随静态包发布。实际地址放在未提交环境文件;变更编译变量后须重新构建。 -两端独立域名入口均配置以下规则;`gateway:9000` 仅适用于同一 Docker 网络,其他部署须替换成实际统一 Gateway 上游。静态容器只提供页面文件,Vite 代理不会随静态包发布。 +站点 API 规则须使用 `location ^~ /gateway-api/`,防止驾驶证等包含 `LICENSE` 的业务路径被站点敏感文件正则截获。仅更新静态包不能修复代理配置;域名入口和每层代理均须核对。 -```nginx -location ^~ /gateway-api/ { - proxy_pass http://gateway:9000/api/; - proxy_set_header Authorization $http_authorization; - client_max_body_size 11m; -} -``` - -域名入口及承接 API 的容器均须保留 `^~`,使 API 前缀优先于站点的敏感文件正则。若站点存在匹配 `LICENSE` 结尾的文件拦截规则,普通 `location /gateway-api/` 会导致驾驶证绑定路径 `loading-documents/DRIVER_LICENSE` 返回 Nginx HTML 404,而同槽位的 `/uploads` 仍可上传成功。TEST 静态容器不代理 API,应修改实际域名入口配置;仅更新应用静态包不能修复入口规则。获授权应用配置前先执行 `nginx -t`,重载后确认驾驶证绑定路径能进入 Gateway(未登录应返回 `401` JSON),再使用 PDA 登录态验证保存。 +部署定向检查:`node --test tests/h5-deployment.test.mjs`,经 `local-check.ps1` 执行。该检查覆盖静态发布约束、资源 base 和开发代理重写,不替代目标主机 Nginx 校验、真实 HTTP、人工页面及 Android 真机验收。 diff --git a/deploy/Dockerfile.h5 b/deploy/Dockerfile.h5 deleted file mode 100644 index d20020b..0000000 --- a/deploy/Dockerfile.h5 +++ /dev/null @@ -1,5 +0,0 @@ -ARG NGINX_BASE_IMAGE=nginx:1.27-alpine -FROM ${NGINX_BASE_IMAGE} -COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf -COPY dist/build/h5/ /srv/pda-h5/ -EXPOSE 80 diff --git a/deploy/Dockerfile.h5.production b/deploy/Dockerfile.h5.production deleted file mode 100644 index 5ecc940..0000000 --- a/deploy/Dockerfile.h5.production +++ /dev/null @@ -1,5 +0,0 @@ -ARG NGINX_BASE_IMAGE=nginx:1.27-alpine -FROM ${NGINX_BASE_IMAGE} -COPY deploy/nginx.production.conf.template /etc/nginx/templates/default.conf.template -COPY dist/build/h5/ /srv/pda-h5/ -EXPOSE 80 diff --git a/deploy/docker-compose.h5.production.yml b/deploy/docker-compose.h5.production.yml deleted file mode 100644 index 3dc79ee..0000000 --- a/deploy/docker-compose.h5.production.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: firstleg-production-pda-overseas-h5 -services: - pda-overseas-h5: - build: - context: .. - dockerfile: deploy/Dockerfile.h5.production - args: - NGINX_BASE_IMAGE: "${NGINX_BASE_IMAGE:-nginx:1.27-alpine}" - image: pda-overseas-h5-prod:${IMAGE_TAG:?IMAGE_TAG is required} - container_name: pda-overseas-h5-prod - restart: unless-stopped - environment: - API_UPSTREAM: "http://gateway:9000" - ports: - - "127.0.0.1:${HOST_PORT:-29006}:80" - healthcheck: - test: ["CMD-SHELL", "wget -qO /dev/null http://127.0.0.1/healthz && wget -qO /dev/null http://127.0.0.1/"] - interval: 5s - timeout: 3s - retries: 12 - start_period: 5s - networks: - backend: - aliases: - - pda-overseas-h5-prod -networks: - backend: - external: true - name: firstleg-production-network diff --git a/deploy/docker-compose.h5.test.yml b/deploy/docker-compose.h5.test.yml deleted file mode 100644 index 1022898..0000000 --- a/deploy/docker-compose.h5.test.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: firstleg-test-pda-overseas-h5 -services: - pda-overseas-h5: - build: - context: .. - dockerfile: deploy/Dockerfile.h5 - args: - NGINX_BASE_IMAGE: "${NGINX_BASE_IMAGE:-nginx:1.27-alpine}" - image: pda-overseas-h5-test:${IMAGE_TAG:?IMAGE_TAG is required} - container_name: pda-overseas-h5-test - restart: unless-stopped - expose: - - "80" - healthcheck: - test: ["CMD-SHELL", "wget -qO /dev/null http://127.0.0.1/healthz && wget -qO /dev/null http://127.0.0.1/"] - interval: 5s - timeout: 3s - retries: 12 - start_period: 5s - networks: - test-backend: - aliases: - - pda-overseas-h5-test -networks: - test-backend: - external: true - name: firstleg-test-network diff --git a/deploy/nginx.conf b/deploy/nginx.conf deleted file mode 100644 index bdef818..0000000 --- a/deploy/nginx.conf +++ /dev/null @@ -1,16 +0,0 @@ -server { - listen 80; - server_name _; - root /srv/pda-h5; - - location = /healthz { - default_type text/plain; - add_header Cache-Control "no-store" always; - return 200 "ok\n"; - } - location / { - index index.html; - try_files $uri $uri/ =404; - add_header Cache-Control "no-store" always; - } -} diff --git a/deploy/nginx.conf.template b/deploy/nginx.conf.template new file mode 100644 index 0000000..b7a742d --- /dev/null +++ b/deploy/nginx.conf.template @@ -0,0 +1,21 @@ +# Merge into the existing site's server block; preserve TLS and panel includes. +# Render only PDA_SITE_ROOT and PDA_GATEWAY_ORIGIN (see README). +# Replace existing root/index and matching locations; do not append duplicates. +root ${PDA_SITE_ROOT}; +index index.html; + +location / { + try_files $uri $uri/ =404; + add_header Cache-Control "no-store" always; +} + +location ^~ /gateway-api/ { + proxy_pass ${PDA_GATEWAY_ORIGIN}/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header Authorization $http_authorization; + 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; + client_max_body_size 55m; +} diff --git a/deploy/nginx.production.conf.template b/deploy/nginx.production.conf.template deleted file mode 100644 index 0572696..0000000 --- a/deploy/nginx.production.conf.template +++ /dev/null @@ -1,26 +0,0 @@ -server { - listen 80; - server_name _; - root /srv/pda-h5; - - location = /healthz { - default_type text/plain; - add_header Cache-Control "no-store" always; - return 200 "ok\n"; - } - # API优先于站点敏感文件正则,避免DRIVER_LICENSE被LICENSE规则误拦截。 - location ^~ /gateway-api/ { - client_max_body_size 55m; - proxy_pass ${API_UPSTREAM}/api/; - proxy_http_version 1.1; - proxy_set_header Host $proxy_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; - } - location / { - index index.html; - try_files $uri $uri/ =404; - add_header Cache-Control "no-store" always; - } -} diff --git a/tests/h5-deployment.test.mjs b/tests/h5-deployment.test.mjs index b370328..101b376 100644 --- a/tests/h5-deployment.test.mjs +++ b/tests/h5-deployment.test.mjs @@ -1,43 +1,28 @@ import test from 'node:test' import { runInNewContext } from 'node:vm' import assert from 'node:assert/strict' -import { readFile } from 'node:fs/promises' +import { readFile, readdir } from 'node:fs/promises' const read = path => readFile(new URL(path, import.meta.url), 'utf8') -test('H5 image copies only compiled assets and static nginx configuration', async () => { - const dockerfile = await read('../deploy/Dockerfile.h5') - const copies = dockerfile.split(/\r?\n/).filter(line => line.startsWith('COPY ')) - assert.deepEqual(copies, ['COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf', 'COPY dist/build/h5/ /srv/pda-h5/']) - assert.match(dockerfile, /ARG NGINX_BASE_IMAGE=/) - assert.match(dockerfile, /FROM \$\{NGINX_BASE_IMAGE\}/) - assert.doesNotMatch(dockerfile, /RUN |npm|node_modules|COPY \. /) - const ignore = await read('../.dockerignore') - assert.ok(ignore.split(/\r?\n/).includes('**')) - assert.ok(ignore.includes('!dist/build/h5/**')) - assert.doesNotMatch(ignore, /!\.env|!node_modules|!src/) +test('PDA H5 has no container publishing entry', async () => { + const files = await readdir(new URL('../deploy/', import.meta.url)) + assert.deepEqual(files.filter(name => /docker|compose/i.test(name)), []) + await assert.rejects(read('../.dockerignore'), { code: 'ENOENT' }) }) -test('static server serves the domain root and returns genuine missing-resource errors', async () => { - const nginx = await read('../deploy/nginx.conf') - assert.match(nginx, /listen 80;/) - assert.ok(nginx.includes('location /')) - assert.match(nginx, /root \/srv\/pda-h5;/) - assert.match(nginx, /try_files \$uri \$uri\/ =404;/) - assert.match(nginx, /Cache-Control "no-store" always/) - assert.doesNotMatch(nginx, /pda-domestic|pda-overseas/) - assert.match(nginx, /location = \/healthz/) - assert.doesNotMatch(nginx, /proxy_pass|gateway-api|try_files[^;]*index\.html/) -}) -test('TEST H5 deployment has a required image tag and internal-only dual health checks', async () => { - const compose = await read('../deploy/docker-compose.h5.test.yml') - assert.ok(compose.includes('${IMAGE_TAG:?IMAGE_TAG is required}')) - assert.match(compose, /context: \.\./) - assert.match(compose, /dockerfile: deploy\/Dockerfile\.h5/) - assert.match(compose, /container_name: pda-overseas-h5-test/) - assert.match(compose, /aliases:\s*\n\s*- pda-overseas-h5-test/) - assert.match(compose, /external: true/) - assert.match(compose, /name: firstleg-test-network/) - assert.match(compose, /expose:\s*\n\s*- "80"/) - assert.ok(compose.includes('http://127.0.0.1/healthz && wget -qO /dev/null http://127.0.0.1/')) - assert.doesNotMatch(compose, /\bports:|volumes:|API_UPSTREAM|privileged:|pid: host/) + +test('host nginx serves uploaded files and proxies only the API prefix', async () => { + const nginx = await read('../deploy/nginx.conf.template') + assert.ok(nginx.includes('root ${PDA_SITE_ROOT};')) + const page = nginx.match(/location \/ \{([^{}]*)\}/)?.[1] + assert.ok(page) + assert.match(page, /try_files \$uri \$uri\/ =404;/) + assert.match(page, /Cache-Control "no-store" always/) + assert.doesNotMatch(page, /proxy_pass|try_files[^;]*index\.html/) + assert.match(nginx, /location \^~ \/gateway-api\//) + assert.ok(nginx.includes('proxy_pass ${PDA_GATEWAY_ORIGIN}/api/;')) + assert.equal((nginx.match(/proxy_pass/g) || []).length, 1) + assert.match(nginx, /proxy_set_header Authorization \$http_authorization;/) + assert.match(nginx, /client_max_body_size 55m;/) + assert.doesNotMatch(nginx, /29005|29006|\/srv\/pda-h5|gateway:9000/) }) test('only H5 production overrides the toolchain asset base', async () => { @@ -72,15 +57,3 @@ test('H5 proxy uses the shared gateway prefix and preserves API suffixes', async assert.equal(proxy['/gateway-api'].rewrite(`/gateway-api${suffix}`), `/api${suffix}`) } }) - -test('gray H5 runs on an isolated network with same-origin Gateway API', async () => { - const compose = await read('../deploy/docker-compose.h5.production.yml') - const nginx = await read('../deploy/nginx.production.conf.template') - const docker = await read('../deploy/Dockerfile.h5.production') - assert.match(compose, /name: firstleg-production-network/) - assert.match(compose, /127\.0\.0\.1:/) - assert.doesNotMatch(compose, /test-network|gateway-overseas/) - assert.match(nginx, /location \^~ \/gateway-api\//) - assert.ok(nginx.includes('proxy_pass ${API_UPSTREAM}/api/;')) - assert.match(docker, /COPY deploy\/nginx.production.conf.template \/etc\/nginx\/templates\/default.conf.template/) -})