diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..54ab655 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +# 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/** diff --git a/README.md b/README.md index 7b23cde..b56524d 100644 --- a/README.md +++ b/README.md @@ -74,3 +74,19 @@ Android App 启动后会读取 `VITE_PDA_UPDATE_URL` 并检查海外 PDA 的独 2. wgt 发布至少递增 `versionName`,并使用与已安装原生包兼容的 uni-app 编译器生成;新增原生模块、权限或不兼容运行时能力时必须发布 APK,不能只发 wgt。 3. 更新清单与下载地址使用 HTTPS,先在测试设备验证可选更新、强制更新、断网重试、APK 覆盖安装、wgt 重启及扫码作业回归,再切换正式清单。 4. 海外和国内更新记录、文件目录及发布权限保持隔离,服务端必须按请求中的 `appid` 拒绝跨应用更新包。 + +## TEST H5 静态托管 + +发布构建使用 `/pda-overseas/` 资源 base 与 hash 路由;开发服务仍使用根路径。浏览器入口为 `/pda-overseas/#/`,不能将 Vite 开发代理当作静态服务器配置。 + +执行 `npm run build:h5`。无需启动 Vite 开发服务器;`npm run build:app` 不属于 H5 发布。 + +`deploy/Dockerfile.h5` 只复制已构建 `dist/build/h5/` 及静态 Nginx 配置,不在镜像中复制源码、依赖或重新编译。`.dockerignore` 只放行这两类输入。容器静态目录为 `/srv/pda-h5/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` 与 `/pda-overseas/` 的实际首页。 + +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 源码。 + +部署配置检查:`node --test tests/h5-deployment.test.mjs`。没有 Docker 运行时的本地合同检查不能替代目标主机 `docker compose config`、Nginx 启动与真实 HTTP 验收。 + +构建后执行 `node scripts/verify-h5.mjs`,核对 HTML/JS/CSS 资源路径、同源 API 和无本机/旧海外配置泄漏。该检查与本地构建仅证明产物;TEST 页面登录、仓权限、业务操作和 Android 真机另行验收。 diff --git a/deploy/Dockerfile.h5 b/deploy/Dockerfile.h5 new file mode 100644 index 0000000..104a9ca --- /dev/null +++ b/deploy/Dockerfile.h5 @@ -0,0 +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/ +EXPOSE 80 diff --git a/deploy/docker-compose.h5.test.yml b/deploy/docker-compose.h5.test.yml new file mode 100644 index 0000000..47d19a8 --- /dev/null +++ b/deploy/docker-compose.h5.test.yml @@ -0,0 +1,27 @@ +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/pda-overseas/"] + 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 new file mode 100644 index 0000000..4321354 --- /dev/null +++ b/deploy/nginx.conf @@ -0,0 +1,18 @@ +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 = /pda-overseas { return 302 /pda-overseas/; } + location ^~ /pda-overseas/ { + 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 new file mode 100644 index 0000000..e834805 --- /dev/null +++ b/scripts/verify-h5.mjs @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict' +import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs' +import { resolve, dirname, relative } from 'node:path' +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/' +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() +const html = readFileSync(resolve(output, 'index.html'), 'utf8') +const refs = [...html.matchAll(/(?:src|href)="([^"]+)"/g)].map(match => match[1]) +assert.ok(refs.length >= 2, 'HTML must reference compiled assets') +for (const ref of refs) { + assert.ok(ref.startsWith(base), `Asset must remain under ${base}: ${ref}`) + 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(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') +for (const path of files.filter(path => /\.(?:js|css)$/.test(path))) { + const source = readFileSync(path, 'utf8') + for (const match of source.matchAll(/(?:from\s*|import\s*\(\s*)["'](\.[^"']+\.(?:js|css))["']/g)) { + assert.ok(existsSync(resolve(dirname(path), match[1])), `Missing chunk ${match[1]}`) + } +} +const digest = createHash('sha256') +for (const path of files) { digest.update(relative(output,path).replaceAll('\\','/')); digest.update('\0'); digest.update(readFileSync(path)) } +console.log(JSON.stringify({output, base, files: files.length, bytes: files.reduce((sum,path)=>sum+statSync(path).size,0), sha256: digest.digest('hex')}, null, 2)) diff --git a/src/config/runtime.js b/src/config/runtime.js index 05aa5e3..8cbd744 100644 --- a/src/config/runtime.js +++ b/src/config/runtime.js @@ -1,15 +1,13 @@ -const runtimeEnvironment = import.meta.env || {} -const configuredApiRoot = runtimeEnvironment.VITE_API_BASE_URL || '' -const development = Boolean(runtimeEnvironment.DEV) -let selectedApiRoot = configuredApiRoot || '/api' +const development = Boolean(import.meta.env?.DEV) +let selectedApiRoot = '' +// #ifndef H5 +selectedApiRoot = import.meta.env?.VITE_API_BASE_URL || '' +// #endif // #ifdef H5 -selectedApiRoot = runtimeEnvironment.VITE_H5_API_BASE_URL || '/api' +selectedApiRoot = import.meta.env?.VITE_H5_API_BASE_URL || '/api' // #endif -const nativeRuntime = typeof plus !== 'undefined' - || (typeof location !== 'undefined' && location.protocol === 'file:') -if (nativeRuntime) selectedApiRoot = configuredApiRoot || '' export const API_ROOT = String(selectedApiRoot).replace(/\/$/, '') export const DEV_LOGIN = Object.freeze({ - username: development ? String(runtimeEnvironment.VITE_PDA_DEV_USERNAME || '') : '', - password: development ? String(runtimeEnvironment.VITE_PDA_DEV_PASSWORD || '') : '' + username: development ? String(import.meta.env?.VITE_PDA_DEV_USERNAME || '') : '', + password: development ? String(import.meta.env?.VITE_PDA_DEV_PASSWORD || '') : '' }) diff --git a/src/manifest.json b/src/manifest.json index 69a2632..f15f42a 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -80,6 +80,9 @@ }, "vueVersion" : "3", "h5" : { + "router" : { + "mode" : "hash" + }, "devServer" : { "port" : 5176 } diff --git a/tests/h5-deployment.test.mjs b/tests/h5-deployment.test.mjs new file mode 100644 index 0000000..a148dd4 --- /dev/null +++ b/tests/h5-deployment.test.mjs @@ -0,0 +1,60 @@ +import test from 'node:test' +import { runInNewContext } from 'node:vm' +import assert from 'node:assert/strict' +import { readFile } 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/pda-overseas/']) + 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('static server preserves the app prefix 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.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.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/pda-overseas/')) + assert.doesNotMatch(compose, /\bports:|volumes:|API_UPSTREAM|privileged:|pid: host/) +}) + +test('only H5 production overrides the toolchain asset base', async () => { + const source = (await read('../vite.config.js')) + .replace(/^import .*$/gm, '') + .replace('export default ', '') + for (const platform of ['h5', 'app', 'app-plus', 'app-harmony', undefined]) { + for (const mode of ['production', 'development']) { + const factory = runInNewContext(source, { + defineConfig: value => value, + loadEnv: () => ({}), + uni: () => ({}), + process: { env: { UNI_PLATFORM: platform }, cwd: () => '/project' } + }) + assert.equal(factory({ mode }).base, + platform === 'h5' && mode === 'production' ? '/pda-overseas/' : undefined, + `${platform}:${mode}`) + } + } +}) diff --git a/vite.config.js b/vite.config.js index 51f2fe2..3cacb4e 100644 --- a/vite.config.js +++ b/vite.config.js @@ -27,8 +27,12 @@ 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, plugins: [uni()], define: { + 'import.meta.env.VITE_PDA_UPDATE_URL': JSON.stringify(env.VITE_PDA_UPDATE_URL || ''), + 'import.meta.env.VITE_PDA_DEV_USERNAME': JSON.stringify(env.VITE_PDA_DEV_USERNAME || ''), + 'import.meta.env.VITE_PDA_DEV_PASSWORD': JSON.stringify(env.VITE_PDA_DEV_PASSWORD || ''), __VUE_I18N_FULL_INSTALL__: true, __VUE_I18N_LEGACY_API__: false, __VUE_I18N_PROD_DEVTOOLS__: false,