diff --git a/.env.example b/.env.example index 5186d59..6fb31a0 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,6 @@ VITE_API_BASE_URL=http://127.0.0.1:9000/api # H5 同源前缀,由 Vite 代理转发;App 构建使用上面的绝对域名直连。 -VITE_H5_API_BASE_URL=/api +VITE_H5_API_BASE_URL=/gateway-api # H5 开发代理上游;正式 App 构建请在本机环境文件中改为可访问的绝对 API 根地址。 API_PROXY_TARGET=http://127.0.0.1:9000 # 海外 PDA 独立更新清单地址;不得与国内 PDA 发布记录混用。 diff --git a/README.md b/README.md index b56524d..b9a03e9 100644 --- a/README.md +++ b/README.md @@ -77,16 +77,30 @@ Android App 启动后会读取 `VITE_PDA_UPDATE_URL` 并检查海外 PDA 的独 ## TEST H5 静态托管 -发布构建使用 `/pda-overseas/` 资源 base 与 hash 路由;开发服务仍使用根路径。浏览器入口为 `/pda-overseas/#/`,不能将 Vite 开发代理当作静态服务器配置。 +发布构建使用 `/` 资源 base 与 hash 路由,部署到本应用独立域名根目录,浏览器入口为 `/#/`。开发服务仍使用根路径;不能将 Vite 开发代理当作静态服务器配置。 执行 `npm run build:h5`。无需启动 Vite 开发服务器;`npm run build:app` 不属于 H5 发布。 -`deploy/Dockerfile.h5` 只复制已构建 `dist/build/h5/` 及静态 Nginx 配置,不在镜像中复制源码、依赖或重新编译。`.dockerignore` 只放行这两类输入。容器静态目录为 `/srv/pda-h5/pda-overseas/`。 +`deploy/Dockerfile.h5` 只复制已构建 `dist/build/h5/` 及静态 Nginx 配置,不在镜像中复制源码、依赖或重新编译。`.dockerignore` 只放行这两类输入。容器静态目录为 `/srv/pda-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-overseas/` 的实际首页。 +从本仓库执行 `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` 与 `/` 的实际首页。 -Admin Nginx 将 `/pda-overseas/` 原样代理至 `http://pda-overseas-h5-test:80`,不去掉路径前缀;无尾斜杠入口重定向为 `/pda-overseas/`。PDA 容器只提供本应用静态目录,其他路径返回 404,缺失资源不回退首页,HTML 使用 `Cache-Control: no-store`。API 全部由 Admin 入口代理:国内 `/gateway-api/` 转统一 Gateway `/api/`,海外同源 `/api/` 保持路径。入口须保留 Authorization,支持文件上传下载及至少 11m 请求体。环境域名与上游只在部署侧配置,不修改宿主 Nginx 权限,也不编入 PDA 源码。 +独立域名入口将 `/` 代理至本应用静态容器根目录,或将构建目录内容直接发布到站点根目录;不再挂载 PDA 子路径。缺失资源返回 404,不回退首页,HTML 使用 `Cache-Control: no-store`。域名入口还须配置同源 API 代理:两端均将 `/gateway-api/` 转统一 Gateway `/api/`。入口须保留 Authorization,支持文件上传下载及至少 11m 请求体。域名与上游只在部署侧配置,不编入 PDA 源码。 部署配置检查:`node --test tests/h5-deployment.test.mjs`。没有 Docker 运行时的本地合同检查不能替代目标主机 `docker compose config`、Nginx 启动与真实 HTTP 验收。 构建后执行 `node scripts/verify-h5.mjs`,核对 HTML/JS/CSS 资源路径、同源 API 和无本机/旧海外配置泄漏。该检查与本地构建仅证明产物;TEST 页面登录、仓权限、业务操作和 Android 真机另行验收。 + +### 两端统一 API 配置 + +两端的 `VITE_API_BASE_URL` 均为统一 Gateway 绝对地址(含 `/api`);`VITE_H5_API_BASE_URL=/gateway-api`;`API_PROXY_TARGET` 为同一 Gateway 源地址(不含 `/api`)。实际地址只放在未提交的 `.env.local`;修改后重启开发服务,发布前重新打包。 + +两端独立域名入口均配置以下规则;`gateway:9000` 仅适用于同一 Docker 网络,其他部署须替换成实际统一 Gateway 上游。静态容器只提供页面文件,Vite 代理不会随静态包发布。 + +```nginx +location ^~ /gateway-api/ { + proxy_pass http://gateway:9000/api/; + proxy_set_header Authorization $http_authorization; + client_max_body_size 11m; +} +``` diff --git a/deploy/Dockerfile.h5 b/deploy/Dockerfile.h5 index 104a9ca..d20020b 100644 --- a/deploy/Dockerfile.h5 +++ b/deploy/Dockerfile.h5 @@ -1,5 +1,5 @@ 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/pda-overseas/ +COPY dist/build/h5/ /srv/pda-h5/ EXPOSE 80 diff --git a/deploy/docker-compose.h5.test.yml b/deploy/docker-compose.h5.test.yml index 47d19a8..1022898 100644 --- a/deploy/docker-compose.h5.test.yml +++ b/deploy/docker-compose.h5.test.yml @@ -12,7 +12,7 @@ services: 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/pda-overseas/"] + 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 diff --git a/deploy/nginx.conf b/deploy/nginx.conf index 4321354..bdef818 100644 --- a/deploy/nginx.conf +++ b/deploy/nginx.conf @@ -8,11 +8,9 @@ server { add_header Cache-Control "no-store" always; return 200 "ok\n"; } - location = /pda-overseas { return 302 /pda-overseas/; } - location ^~ /pda-overseas/ { + location / { index index.html; try_files $uri $uri/ =404; add_header Cache-Control "no-store" always; } - location / { return 404; } } diff --git a/scripts/verify-h5.mjs b/scripts/verify-h5.mjs index e834805..510e674 100644 --- a/scripts/verify-h5.mjs +++ b/scripts/verify-h5.mjs @@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url' import { createHash } from 'node:crypto' const project = resolve(dirname(fileURLToPath(import.meta.url)), '..') const output = resolve(project, 'dist/build/h5') -const base = '/pda-overseas/' +const base = '/' function walk(dir) { return readdirSync(dir).flatMap(name => { const path = resolve(dir, name); return statSync(path).isDirectory() ? walk(path) : [path] }) } assert.ok(existsSync(resolve(output, 'index.html')), 'Build H5 before verifying') const files = walk(output).sort() @@ -17,7 +17,7 @@ for (const ref of refs) { assert.ok(existsSync(resolve(output, ref.slice(base.length))), `Missing asset: ${ref}`) } const js = files.filter(path => path.endsWith('.js')).map(path => readFileSync(path, 'utf8')).join('\n') -assert.ok(js.includes(base), 'Runtime router and assets must know the deployment base') +assert.doesNotMatch(html + js, /\/pda-(?:domestic|overseas)\//, 'Build must not reference the retired subdirectory deployment') assert.doesNotMatch(js, /VITE_ROOT_DIR|VITE_AUTH_API_BASE_URL|VITE_OVERSEAS_API_BASE_URL|overseas-api|overseasAccessToken/, 'Build contains broad local env or old overseas config') assert.doesNotMatch(js, /https?:\/\/(?:localhost|127\.0\.0\.1):\d+/, 'Build must not depend on a local dev server') assert.doesNotMatch(js, /https?:\/\/[^"'\s]+\/api["']/, 'API must be same-origin for this H5 deployment') diff --git a/src/api/overseas.js b/src/api/overseas.js index 363e0bc..19b049e 100644 --- a/src/api/overseas.js +++ b/src/api/overseas.js @@ -50,7 +50,7 @@ function overseasUrl(path) { throw error } const value = `/${String(path || '').replace(/^\/+/, '')}` - const normalized = /\/api$/.test(base) + const normalized = /\/(?:api|gateway-api)$/.test(base) ? value.replace(/^\/api(?=\/)/, '') : value return `${base}${normalized}` diff --git a/src/config/runtime.js b/src/config/runtime.js index 8cbd744..12a8ed0 100644 --- a/src/config/runtime.js +++ b/src/config/runtime.js @@ -4,7 +4,7 @@ let selectedApiRoot = '' selectedApiRoot = import.meta.env?.VITE_API_BASE_URL || '' // #endif // #ifdef H5 -selectedApiRoot = import.meta.env?.VITE_H5_API_BASE_URL || '/api' +selectedApiRoot = import.meta.env?.VITE_H5_API_BASE_URL || '/gateway-api' // #endif export const API_ROOT = String(selectedApiRoot).replace(/\/$/, '') export const DEV_LOGIN = Object.freeze({ diff --git a/tests/h5-deployment.test.mjs b/tests/h5-deployment.test.mjs index a148dd4..df619a7 100644 --- a/tests/h5-deployment.test.mjs +++ b/tests/h5-deployment.test.mjs @@ -6,7 +6,7 @@ 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/pda-overseas/']) + 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 \. /) @@ -15,14 +15,14 @@ test('H5 image copies only compiled assets and static nginx configuration', asyn assert.ok(ignore.includes('!dist/build/h5/**')) assert.doesNotMatch(ignore, /!\.env|!node_modules|!src/) }) -test('static server preserves the app prefix and returns genuine missing-resource errors', async () => { +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 ^~ /pda-overseas/')) + 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.match(nginx, /location \/ \{ return 404; \}/) + assert.doesNotMatch(nginx, /pda-domestic|pda-overseas/) assert.match(nginx, /location = \/healthz/) assert.doesNotMatch(nginx, /proxy_pass|gateway-api|try_files[^;]*index\.html/) }) @@ -36,7 +36,7 @@ test('TEST H5 deployment has a required image tag and internal-only dual health 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/pda-overseas/')) + 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/) }) @@ -53,8 +53,22 @@ test('only H5 production overrides the toolchain asset base', async () => { process: { env: { UNI_PLATFORM: platform }, cwd: () => '/project' } }) assert.equal(factory({ mode }).base, - platform === 'h5' && mode === 'production' ? '/pda-overseas/' : undefined, + platform === 'h5' && mode === 'production' ? '/' : undefined, `${platform}:${mode}`) } } }) + +test('H5 proxy uses the shared gateway prefix and preserves API suffixes', async () => { + const source = (await read('../vite.config.js')).replace(/^import .*$/gm, '').replace('export default ', '') + const factory = runInNewContext(source, { + defineConfig: value => value, loadEnv: () => ({ API_PROXY_TARGET: 'https://gateway.example.com' }), + uni: () => ({}), process: { env: { UNI_PLATFORM: 'h5' }, cwd: () => '/project' } + }) + const proxy = factory({ mode: 'development' }).server.proxy + assert.deepEqual(Object.keys(proxy), ['/gateway-api']) + assert.equal(proxy['/gateway-api'].target, 'https://gateway.example.com') + for (const suffix of ['/auth/login', '/pda/v1/last-leg/photos/1/download?preview=1']) { + assert.equal(proxy['/gateway-api'].rewrite(`/gateway-api${suffix}`), `/api${suffix}`) + } +}) diff --git a/tests/overseas-request.test.mjs b/tests/overseas-request.test.mjs index 683e452..95e0a19 100644 --- a/tests/overseas-request.test.mjs +++ b/tests/overseas-request.test.mjs @@ -42,7 +42,7 @@ test('H5 proxy and App API roots normalize request upload and download paths to globalThis.uni.uploadFile = (options) => { uploads.push(options.url); options.success({ statusCode: 200, data: JSON.stringify({ code: '200', data: { fileId: '1' } }), header: {} }) } globalThis.uni.downloadFile = (options) => { downloads.push(options.url); options.success({ statusCode: 200, tempFilePath: '/tmp/photo.jpg', header: {} }) } - session.apiBase = '/api' + session.apiBase = '/gateway-api' await resolvePallet('PLT-1') await overseasUpload('/api/pda/v1/last-leg/photos/files', '/tmp/photo.jpg') await overseasDownload('/api/pda/v1/last-leg/photos/files/1/download') @@ -53,15 +53,15 @@ test('H5 proxy and App API roots normalize request upload and download paths to await overseasDownload('/api/pda/v1/last-leg/photos/files/1/download') assert.deepEqual(requests, [ - '/api/pda/v1/last-leg/pallet-operations/pallets/resolve', + '/gateway-api/pda/v1/last-leg/pallet-operations/pallets/resolve', 'https://overseas.example.com/api/pda/v1/last-leg/pallet-operations/pallets/resolve' ]) assert.deepEqual(uploads, [ - '/api/pda/v1/last-leg/photos/files', + '/gateway-api/pda/v1/last-leg/photos/files', 'https://overseas.example.com/api/pda/v1/last-leg/photos/files' ]) assert.deepEqual(downloads, [ - '/api/pda/v1/last-leg/photos/files/1/download', + '/gateway-api/pda/v1/last-leg/photos/files/1/download', 'https://overseas.example.com/api/pda/v1/last-leg/photos/files/1/download' ]) for (const url of [...requests, ...uploads, ...downloads]) assert.doesNotMatch(url, /\/api\/api\//) @@ -175,10 +175,10 @@ test('H5 blob photo uses authenticated multipart fetch instead of uni.uploadFile return new Response(JSON.stringify({ code: '200', data: { fileId: 'h5-1' } }), { status: 200 }) } try { - session.apiBase = '/api' + session.apiBase = '/gateway-api' assert.deepEqual(await overseasUpload('/api/pda/v1/last-leg/photos/files', 'blob:http://localhost/photo'), { fileId: 'h5-1' }) assert.equal(uploadFileCalls, 0) - assert.equal(uploaded.url, '/api/pda/v1/last-leg/photos/files') + assert.equal(uploaded.url, '/gateway-api/pda/v1/last-leg/photos/files') assert.equal(uploaded.options.headers.Authorization, `Bearer ${value.accessToken}`) assert.ok(uploaded.options.body instanceof FormData) } finally { @@ -200,14 +200,14 @@ test('H5 File photo is appended directly without rereading a temporary blob URL' let calls = 0 globalThis.uni.uploadFile = (options) => { calls += 1 - assert.equal(options.url, '/api/pda/photo') + assert.equal(options.url, '/gateway-api/pda/photo') assert.equal(options.header.Authorization, `Bearer ${value.accessToken}`) assert.equal(options.files[0].file, selected) assert.equal(options.files[0].name, 'file') options.success({ statusCode: 200, data: JSON.stringify({ code: '200', data: { fileId: 'direct-file' } }), header: {} }) } try { - session.apiBase = '/api' + session.apiBase = '/gateway-api' assert.deepEqual(await overseasUpload('/pda/photo', selected), { fileId: 'direct-file' }) assert.equal(calls, 1) } finally { @@ -222,7 +222,7 @@ test('native file runtime rejects an H5-relative overseas root before upload wit prepareSession() const originalLocation = globalThis.location globalThis.location = { protocol: 'file:' } - session.apiBase = '/api' + session.apiBase = '/gateway-api' let uploadCalls = 0 globalThis.uni.uploadFile = () => { uploadCalls += 1 } try { @@ -267,7 +267,7 @@ test('authenticated photo download maps the public api path through the H5 overs } assert.equal(await overseasDownload('/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files/99/download'), '/tmp/restored.jpg') assert.equal(calls.length, 1) - assert.equal(calls[0].url, '/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files/99/download') + assert.equal(calls[0].url, '/gateway-api/pda/v1/last-leg/receiving-containers/current/unload-photos/files/99/download') assert.equal(calls[0].header.Authorization, `Bearer ${value.accessToken}`) }) diff --git a/tests/project-contract.test.mjs b/tests/project-contract.test.mjs index 4b1d40b..44cdf80 100644 --- a/tests/project-contract.test.mjs +++ b/tests/project-contract.test.mjs @@ -48,7 +48,7 @@ test('login has no committed demo credentials and inbound operation implements t assert.match(auth, /authRequest\('\/auth\/login', \{ loginId, password \}\)/) assert.doesNotMatch(auth, /authRequest\('\/auth\/login', \{ username, password \}\)/) assert.match(runtime, /runtimeEnvironment\.DEV|VITE_PDA_DEV_USERNAME|VITE_PDA_DEV_PASSWORD/) - assert.match(runtime, /'\/api'/) + assert.match(runtime, /'\/gateway-api'/) assert.match(runtime, /#ifdef H5[\s\S]*?VITE_H5_API_BASE_URL/) assert.match(runtime, /#ifdef H5[\s\S]*?VITE_H5_API_BASE_URL/) assert.match(operation, /startScanBroadcast/) diff --git a/vite.config.js b/vite.config.js index 3cacb4e..fd7cc0c 100644 --- a/vite.config.js +++ b/vite.config.js @@ -27,7 +27,7 @@ export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), '') const apiTarget = env.API_PROXY_TARGET || 'http://127.0.0.1:9000' return { - base: process.env.UNI_PLATFORM === 'h5' && mode === 'production' ? '/pda-overseas/' : undefined, + base: process.env.UNI_PLATFORM === 'h5' && mode === 'production' ? '/' : undefined, plugins: [uni()], define: { 'import.meta.env.VITE_PDA_UPDATE_URL': JSON.stringify(env.VITE_PDA_UPDATE_URL || ''), @@ -40,7 +40,10 @@ export default defineConfig(({ mode }) => { }, server: { proxy: { - '/api': loggedProxy('api', { target: apiTarget, changeOrigin: true }) + '/gateway-api': loggedProxy('api', { + target: apiTarget, changeOrigin: true, + rewrite: (path) => path.replace(/^\/gateway-api/, '/api') + }) } }, css: {