提供独立路径的海外PDA测试H5部署
This commit is contained in:
@@ -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/**
|
||||||
@@ -74,3 +74,19 @@ Android App 启动后会读取 `VITE_PDA_UPDATE_URL` 并检查海外 PDA 的独
|
|||||||
2. wgt 发布至少递增 `versionName`,并使用与已安装原生包兼容的 uni-app 编译器生成;新增原生模块、权限或不兼容运行时能力时必须发布 APK,不能只发 wgt。
|
2. wgt 发布至少递增 `versionName`,并使用与已安装原生包兼容的 uni-app 编译器生成;新增原生模块、权限或不兼容运行时能力时必须发布 APK,不能只发 wgt。
|
||||||
3. 更新清单与下载地址使用 HTTPS,先在测试设备验证可选更新、强制更新、断网重试、APK 覆盖安装、wgt 重启及扫码作业回归,再切换正式清单。
|
3. 更新清单与下载地址使用 HTTPS,先在测试设备验证可选更新、强制更新、断网重试、APK 覆盖安装、wgt 重启及扫码作业回归,再切换正式清单。
|
||||||
4. 海外和国内更新记录、文件目录及发布权限保持隔离,服务端必须按请求中的 `appid` 拒绝跨应用更新包。
|
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 真机另行验收。
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
+8
-10
@@ -1,15 +1,13 @@
|
|||||||
const runtimeEnvironment = import.meta.env || {}
|
const development = Boolean(import.meta.env?.DEV)
|
||||||
const configuredApiRoot = runtimeEnvironment.VITE_API_BASE_URL || ''
|
let selectedApiRoot = ''
|
||||||
const development = Boolean(runtimeEnvironment.DEV)
|
// #ifndef H5
|
||||||
let selectedApiRoot = configuredApiRoot || '/api'
|
selectedApiRoot = import.meta.env?.VITE_API_BASE_URL || ''
|
||||||
|
// #endif
|
||||||
// #ifdef H5
|
// #ifdef H5
|
||||||
selectedApiRoot = runtimeEnvironment.VITE_H5_API_BASE_URL || '/api'
|
selectedApiRoot = import.meta.env?.VITE_H5_API_BASE_URL || '/api'
|
||||||
// #endif
|
// #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 API_ROOT = String(selectedApiRoot).replace(/\/$/, '')
|
||||||
export const DEV_LOGIN = Object.freeze({
|
export const DEV_LOGIN = Object.freeze({
|
||||||
username: development ? String(runtimeEnvironment.VITE_PDA_DEV_USERNAME || '') : '',
|
username: development ? String(import.meta.env?.VITE_PDA_DEV_USERNAME || '') : '',
|
||||||
password: development ? String(runtimeEnvironment.VITE_PDA_DEV_PASSWORD || '') : ''
|
password: development ? String(import.meta.env?.VITE_PDA_DEV_PASSWORD || '') : ''
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -80,6 +80,9 @@
|
|||||||
},
|
},
|
||||||
"vueVersion" : "3",
|
"vueVersion" : "3",
|
||||||
"h5" : {
|
"h5" : {
|
||||||
|
"router" : {
|
||||||
|
"mode" : "hash"
|
||||||
|
},
|
||||||
"devServer" : {
|
"devServer" : {
|
||||||
"port" : 5176
|
"port" : 5176
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -27,8 +27,12 @@ export default defineConfig(({ mode }) => {
|
|||||||
const env = loadEnv(mode, process.cwd(), '')
|
const env = loadEnv(mode, process.cwd(), '')
|
||||||
const apiTarget = env.API_PROXY_TARGET || 'http://127.0.0.1:9000'
|
const apiTarget = env.API_PROXY_TARGET || 'http://127.0.0.1:9000'
|
||||||
return {
|
return {
|
||||||
|
base: process.env.UNI_PLATFORM === 'h5' && mode === 'production' ? '/pda-overseas/' : undefined,
|
||||||
plugins: [uni()],
|
plugins: [uni()],
|
||||||
define: {
|
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_FULL_INSTALL__: true,
|
||||||
__VUE_I18N_LEGACY_API__: false,
|
__VUE_I18N_LEGACY_API__: false,
|
||||||
__VUE_I18N_PROD_DEVTOOLS__: false,
|
__VUE_I18N_PROD_DEVTOOLS__: false,
|
||||||
|
|||||||
Reference in New Issue
Block a user