diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8d714a4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +# Only compiled H5 assets and image configuration enter the build context. +** +!deploy/ +!deploy/Dockerfile.h5 +!deploy/nginx.conf +!unpackage/ +!unpackage/dist/ +!unpackage/dist/build/ +!unpackage/dist/build/web/ +!unpackage/dist/build/web/** + +!deploy/Dockerfile.h5.production +!deploy/nginx.production.conf.template diff --git a/README.md b/README.md index 5a9293e..d741926 100644 --- a/README.md +++ b/README.md @@ -106,3 +106,33 @@ Android App 启动后会读取 `VITE_PDA_UPDATE_URL` 并检查国内 PDA 的独 2. wgt 发布至少递增 `versionName`,并使用与已安装原生包兼容的 uni-app 编译器生成;新增原生模块、权限或不兼容运行时能力时必须发布 APK,不能只发 wgt。 3. 更新清单与下载地址使用 HTTPS,先在测试设备验证可选更新、强制更新、断网重试、APK 覆盖安装、wgt 重启及扫码作业回归,再切换正式清单。 4. 国内和海外更新记录、文件目录及发布权限保持隔离,服务端必须按请求中的 `appid` 拒绝跨应用更新包。 + +## TEST H5 静态托管 + +发布构建使用 `/` 资源 base 与 hash 路由,部署到本应用独立域名根目录,浏览器入口为 `/#/`。开发服务仍使用根路径;不能将 Vite 开发代理当作静态服务器配置。 + +国内使用本机已安装 HBuilderX:`cli.exe project open --path <本仓库绝对路径>` 后执行 `cli.exe publish web --project <本仓库绝对路径> --webHosting false --sourceMap false`,仅导出本地 Web 静态文件;本次验证编译器 5.26,不上传云托管。 + +`deploy/Dockerfile.h5` 只复制已构建 `unpackage/dist/build/web/` 及静态 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-domestic-h5-test`,仅在外部 `firstleg-test-network` 暴露容器 80,不发布宿主端口。健康检查同时请求 `/healthz` 与 `/` 的实际首页。 + +独立域名入口将 `/` 代理至本应用静态容器根目录,或将构建目录内容直接发布到站点根目录;不再挂载 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/api/auth.js b/api/auth.js index 3e34e93..784ac4a 100644 --- a/api/auth.js +++ b/api/auth.js @@ -10,9 +10,10 @@ function authRequest(path, data, options = {}) { timeout: options.timeout || 15000, success(response) { const body = response.data || {} - if (String(body.code ?? response.statusCode) !== '200') { + if (response.statusCode !== 200 || String(body.code ?? response.statusCode) !== '200') { const error = new Error(body.message || '认证服务请求失败') error.code = body.code || response.statusCode + error.statusCode = response.statusCode error.requestId = body.requestId || response.header?.['X-Request-Id'] || response.header?.['x-request-id'] error.traceId = body.traceId || response.header?.['X-Trace-Id'] || response.header?.['x-trace-id'] reject(error) @@ -33,9 +34,17 @@ export function login(loginId, password) { return authRequest('/auth/login', { loginId, password }) } +const pendingRefreshes = new Map() export function refresh(refreshToken) { - return authRequest('/auth/refresh', { refreshToken }) + if (pendingRefreshes.has(refreshToken)) return pendingRefreshes.get(refreshToken) + const pending = authRequest('/auth/refresh', { refreshToken }).finally(() => { + if (pendingRefreshes.get(refreshToken) === pending) pendingRefreshes.delete(refreshToken) + }) + pendingRefreshes.set(refreshToken, pending) + return pending } +export function getPendingRefresh(refreshToken) { return pendingRefreshes.get(refreshToken) || null } + export function logout(refreshToken, accessToken) { return authRequest('/auth/logout', { refreshToken }, { diff --git a/api/http.js b/api/http.js index eb27260..d2c56d0 100644 --- a/api/http.js +++ b/api/http.js @@ -1,8 +1,9 @@ import { refresh as refreshRequest } from '@/api/auth.js' -import { isAccessTokenExpiring, session, signOut, updateTokenPair } from '@/stores/session.js' +import { isAccessTokenExpiring, session, signOut, updateTokenPair, getSessionRevision } from '@/stores/session.js' import { requireOnline } from '@/utils/network.js' let refreshPromise = null +let refreshRevision = null let redirectingToLogin = false function responseCode(response) { @@ -66,14 +67,21 @@ function rawRequest(options, accessToken) { }) } +function requireSameSession(revision) { + if (getSessionRevision() !== revision) throw new Error('登录会话已变更,请重新操作') +} + async function refreshAccessToken() { + const revision = getSessionRevision() const observedRefreshToken = session.refreshToken if (!observedRefreshToken) return expireSession(null, observedRefreshToken) - if (!refreshPromise) { + if (!refreshPromise || refreshRevision !== revision) { + refreshRevision = revision refreshPromise = (async () => { if (session.refreshToken !== observedRefreshToken && session.token) return session.token try { const tokens = await refreshRequest(observedRefreshToken) + requireSameSession(revision) if (session.refreshToken !== observedRefreshToken) { if (session.token) return session.token throw sessionExpiredError() @@ -82,10 +90,14 @@ async function refreshAccessToken() { redirectingToLogin = false return session.token } catch (error) { + requireSameSession(revision) if (isUnauthorized(error)) return expireSession(error, observedRefreshToken) throw error } - })().finally(() => { refreshPromise = null }) + })() + const pending = refreshPromise + const release = () => { if (refreshPromise === pending) { refreshPromise = null; refreshRevision = null } } + pending.then(release, release) // Original pending promise still propagates failures to callers. } return refreshPromise } @@ -101,12 +113,17 @@ export function ensureAccessToken() { async function runAuthenticated(execute) { requireOnline() + const revision = getSessionRevision() let accessToken = await validAccessToken() + requireSameSession(revision) let response = await execute(accessToken) + requireSameSession(revision) if (!isUnauthorized(response)) return response accessToken = session.token && session.token !== accessToken ? session.token : await refreshAccessToken() + requireSameSession(revision) const activeRefreshToken = session.refreshToken response = await execute(accessToken) + requireSameSession(revision) if (isUnauthorized(response)) expireSession(response, activeRefreshToken) return response } diff --git a/api/logout-session.js b/api/logout-session.js new file mode 100644 index 0000000..fdc8a6f --- /dev/null +++ b/api/logout-session.js @@ -0,0 +1,33 @@ +import { logout, refresh, getPendingRefresh } from './auth.js' +import { session, signOut, getSessionRevision, decodeAccessToken } from '../stores/session.js' + +function replacement(captured, tokens) { + if (!tokens?.accessToken || typeof tokens.refreshToken !== 'string' || !tokens.refreshToken.trim()) throw new Error('退出刷新响应缺少会话凭据') + const previous = decodeAccessToken(captured.accessToken), next = decodeAccessToken(tokens.accessToken) + if (!Number.isFinite(Number(next.exp)) || Number(next.exp) * 1000 <= Date.now()) throw new Error('退出刷新凭据无效或已过期') + for (const field of ['user_id', 'membership_id', 'tenant_id', 'app_code']) { + if (previous[field] == null || String(previous[field]) !== String(next[field])) throw new Error('退出刷新返回了不同作业主体,无法确认原会话撤销') + } + if (String(previous.domestic_warehouse_id ?? '') !== String(next.domestic_warehouse_id ?? '')) throw new Error('退出刷新返回了不同作业仓库,无法确认原会话撤销') + return { accessToken: tokens.accessToken, refreshToken: tokens.refreshToken } +} +export function logoutCurrentSession() { + const captured = { accessToken: session.token, refreshToken: session.refreshToken, pendingRefresh: getPendingRefresh(session.refreshToken) } + // Clear sensitive data and invalidate business requests while revocation is pending. + signOut() + const clearedRevision = getSessionRevision() + const revoke = async () => { + if (!captured.accessToken && !captured.refreshToken) return { revoked: false, reason: 'NO_CAPTURED_SESSION' } + if (!captured.accessToken || !captured.refreshToken) throw new Error('原会话凭据不完整,服务端退出未确认') + let credentials = captured, refreshed = false + if (captured.pendingRefresh) { refreshed = true; credentials = replacement(captured, await captured.pendingRefresh) } + try { await logout(credentials.refreshToken, credentials.accessToken) } + catch (error) { + if (error.network || error.statusCode !== 401 || refreshed) throw error + credentials = replacement(captured, await refresh(captured.refreshToken)) + await logout(credentials.refreshToken, credentials.accessToken) + } + return { revoked: true } + } + return revoke().then(result => ({ ...result, clearedRevision }), error => { error.logoutRevision = clearedRevision; throw error }) +} diff --git a/config/runtime.js b/config/runtime.js index c293d03..5610dc8 100644 --- a/config/runtime.js +++ b/config/runtime.js @@ -1,7 +1,9 @@ -const configuredApiRoot = import.meta.env.VITE_API_BASE_URL || '' -let selectedApiRoot = configuredApiRoot || '/api' +let selectedApiRoot = '' +// #ifndef H5 +selectedApiRoot = import.meta.env.VITE_API_BASE_URL || '/api' +// #endif -// H5 必须请求同源地址,由 Vite 开发服务器代理到国内 Gateway,避免浏览器 CORS。 +// H5 必须请求同源地址,由 Vite 开发服务器代理到统一 Gateway,避免浏览器 CORS。 // 前缀不能使用 /api,否则会截获本项目 api/ 目录下的前端模块请求。 // #ifdef H5 selectedApiRoot = import.meta.env.VITE_H5_API_BASE_URL || '/gateway-api' diff --git a/deploy/Dockerfile.h5 b/deploy/Dockerfile.h5 new file mode 100644 index 0000000..56d7173 --- /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 unpackage/dist/build/web/ /srv/pda-h5/ +EXPOSE 80 diff --git a/deploy/Dockerfile.h5.production b/deploy/Dockerfile.h5.production new file mode 100644 index 0000000..f08e893 --- /dev/null +++ b/deploy/Dockerfile.h5.production @@ -0,0 +1,5 @@ +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 unpackage/dist/build/web/ /srv/pda-h5/ +EXPOSE 80 diff --git a/deploy/docker-compose.h5.production.yml b/deploy/docker-compose.h5.production.yml new file mode 100644 index 0000000..60dbdfc --- /dev/null +++ b/deploy/docker-compose.h5.production.yml @@ -0,0 +1,29 @@ +name: firstleg-production-pda-domestic-h5 +services: + pda-domestic-h5: + build: + context: .. + dockerfile: deploy/Dockerfile.h5.production + args: + NGINX_BASE_IMAGE: "${NGINX_BASE_IMAGE:-nginx:1.27-alpine}" + image: pda-domestic-h5-prod:${IMAGE_TAG:?IMAGE_TAG is required} + container_name: pda-domestic-h5-prod + restart: unless-stopped + environment: + API_UPSTREAM: "http://gateway:9000" + ports: + - "127.0.0.1:${HOST_PORT:-29005}: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-domestic-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 new file mode 100644 index 0000000..5e9342c --- /dev/null +++ b/deploy/docker-compose.h5.test.yml @@ -0,0 +1,27 @@ +name: firstleg-test-pda-domestic-h5 +services: + pda-domestic-h5: + build: + context: .. + dockerfile: deploy/Dockerfile.h5 + args: + NGINX_BASE_IMAGE: "${NGINX_BASE_IMAGE:-nginx:1.27-alpine}" + image: pda-domestic-h5-test:${IMAGE_TAG:?IMAGE_TAG is required} + container_name: pda-domestic-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-domestic-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..bdef818 --- /dev/null +++ b/deploy/nginx.conf @@ -0,0 +1,16 @@ +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.production.conf.template b/deploy/nginx.production.conf.template new file mode 100644 index 0000000..4ce203e --- /dev/null +++ b/deploy/nginx.production.conf.template @@ -0,0 +1,25 @@ +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 /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/manifest.json b/manifest.json index 596ade2..e71d9b7 100644 --- a/manifest.json +++ b/manifest.json @@ -91,6 +91,9 @@ }, "vueVersion" : "3", "h5" : { + "router" : { + "mode" : "hash" + }, "devServer" : { "port" : 5175 } diff --git a/pages/home/home.vue b/pages/home/home.vue index 7727584..0b31b70 100644 --- a/pages/home/home.vue +++ b/pages/home/home.vue @@ -1,6 +1,6 @@