diff --git a/.dockerignore b/.dockerignore
index 0241e51..34dd1ec 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -4,6 +4,8 @@
dist
node_modules
node_modules.broken-by-path
-.env.local
+.env
+.env.*
+!.env.example
npm-debug.log*
pnpm-debug.log*
diff --git a/.env.example b/.env.example
index 6144c14..4ed4181 100644
--- a/.env.example
+++ b/.env.example
@@ -1,5 +1,10 @@
VITE_API_BASE_URL=/api
-VITE_OVERSEAS_API_BASE_URL=/overseas-api
-OVERSEAS_API_PROXY_TARGET=http://127.0.0.1:9300
API_PROXY_TARGET=http://127.0.0.1:9000
VITE_ADMIN_WEB_ORIGIN=http://localhost:5173
+
+# Test container deployment (Jenkins environment or untracked .env).
+# API_UPSTREAM: unified Gateway origin, no /api suffix.
+# TEST_NETWORK: shared test Docker network.
+API_UPSTREAM=http://gateway:9000
+TEST_NETWORK=firstleg-test-network
+IMAGE_TAG=
diff --git a/AGENTS.md b/AGENTS.md
index 9b66236..d4655cf 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -4,7 +4,7 @@
- 本项目是入驻企业及其成员使用的客户工作台,所有业务数据都处于企业租户上下文中。
- 当前范围包括注册、业务总览、企业资料、企业成员、企业角色与权限、头程订单、客户资金账户、客户财务账单查询与客户确认,以及海外仓贴标、转运、废弃和退货换标自助服务。
-- 用户可见的“平台—客户”层级语义读取 `../../docs/平台客户层级与权限契约.md`;涉及海外 API、海外 Token 或跨区域事件时读取 `../../docs/跨区域认证与服务通信契约.md`。本文件中的 `tenant` 仅表示服务端隔离上下文,不扩展用户可见产品层级。
+- 用户可见的“平台—客户”层级语义读取 `../../docs/平台客户层级与权限契约.md`;涉及海外 API、海外 Token 或跨区域事件时读取 `../../docs/统一认证与服务通信契约.md`。本文件中的 `tenant` 仅表示服务端隔离上下文,不扩展用户可见产品层级。
- 不在本项目实现平台内部运营管理、品牌官网、仓库 PDA 作业或仅属于原生移动端的能力。
## 技术基线
@@ -26,15 +26,15 @@
- 模块内 `store/`:该业务跨页面状态;局部表单状态仍留在页面或组件内。跨模块使用 store 时只能导入其公开 store 文件,不得读取模块内部页面或组件实现。
- `src/api/`:只保留 HTTP、会话令牌、响应类型等全局请求基础设施;业务接口放入对应模块的 `api/`。页面不得直接创建独立 Axios 客户端。
- 模块之间不得通过相对路径或内部目录穿透;确需协作时只能从目标模块的 `public.ts` 导入。模块内稳定契约放在 `contracts.ts`,稳定的跨应用模型仍应上移到 `@chongchong/shared`。
-- `VITE_API_BASE_URL`、`VITE_OVERSEAS_API_BASE_URL` 分别控制国内和海外浏览器前缀;`API_PROXY_TARGET`、`OVERSEAS_API_PROXY_TARGET` 只由 Vite 开发代理读取,不进入客户端构建。新增或变更环境变量时同步更新 `.env.example`,不得提交真实密钥或环境专属凭据。
+- `VITE_API_BASE_URL` 控制唯一浏览器 API 前缀;全部业务共用 `src/api/http.ts` 和同一 Access/Refresh Token。`API_PROXY_TARGET` 仅供本地 Vite 代理连接测试入口,不进入客户端构建。独立海外 HTTP 客户端与代理已移除。新增或变更环境变量时同步更新 `.env.example`,不得提交真实密钥或环境专属凭据。
- `src/styles/`:全局设计令牌和布局;优先复用现有 `--primary`、`--navy`、`--border`、`--muted`。
- 本应用多个模块稳定复用的前端模型放到应用内 `shared/`,客户工作台专属类型保留在对应业务模块。不要链接应用目录之外的 TypeScript 包,服务端契约以 `baiyue-firstleg` Controller/OpenAPI 为准。
-## 开发环境样式验收
+## 测试环境样式验收
-- 开发环境验收使用本轮动态创建并已审核通过的客户管理员,或由用户显式提供的客户账号,不依赖固定业务客户或固定账号。
+- 测试环境验收使用本轮动态创建并已审核通过的客户管理员,或由用户显式提供的客户账号,不依赖固定业务客户或固定账号。
- 账号与密码不写入仓库;从本机未提交环境变量或 `C:/Users/BY-OY/.codex/chongchong-global-dev-acceptance.env` 读取。若显式验收凭据不存在,再向用户索取。
-- 该身份仅适用于开发环境。不要把密码、Token 或真实接口地址写入源码、测试快照、日志或 `AGENTS.md`;开发环境变更时只更新本机凭据。
+- 当前只有一个测试环境。不要把密码、Token 或真实接口地址写入源码、测试快照、日志或 `AGENTS.md`;测试环境变更时只更新本机凭据。
- 由人工使用该身份进入真实页面,按 `1920×1080` 视口和 `100%` 浏览器缩放完成操作与截图,再把截图发送给 AI 核对加载、空态、错误态、权限态和桌面布局;AI 默认不主动调用截图或视觉类技能取证。
## 模块快速查询
@@ -45,7 +45,7 @@
| 业务 | 模块入口 | 联查模块或服务端 |
|---|---|---|
-| 登录、注册与会话 | `src/modules/auth/`、`src/api/session.ts`、`src/api/http.ts` | Backend `auth/`、`iam/`;正常旋转的国内/海外 Token 会话统一持久化,“记住账号”只记登录标识;访问令牌失效但刷新令牌仍在时先恢复会话,公开认证接口不得携带旧 Token |
+| 登录、注册与会话 | `src/modules/auth/`、`src/api/session.ts`、`src/api/http.ts` | Backend `auth/`、`iam/`;正常轮换的统一 Access/Refresh Token 会话持久化,“记住账号”只记登录标识;访问令牌失效但刷新令牌仍在时先恢复会话,公开认证接口不得携带旧 Token |
| 企业资料、成员与角色 | `src/modules/tenant-management/`、`src/modules/account/` | Backend `iam/`、`auth/`、`file/`;公司联系电话/邮箱是企业资料,不作为登录标识;成员登录手机号可选,修改时同步 Auth、撤销该账号会话并审计。审核必填资质仅营业执照与统一社会信用代码;法人身份证选填,可随营业执照提交,也可在已有营业执照后单独补传或替换。前端角色树到服务端权限的映射统一维护在 `contracts.ts` 的 `FRONTEND_TO_MERCHANT_PERMISSION_CODES`,海外入仓、采购、本土卡车和散板权限必须显式进入该映射 |
| 工作台与系统入口 | `src/modules/dashboard/`、`src/modules/system/` | `src/navigation/clientNavigation.ts`、`src/router/index.ts` |
| 企业成员消息收件箱 | `src/modules/notifications/` | Backend `iam/src/main/java/com/baiyue/iam/notification/`;列表、详情、已读与未读数契约见 `iam/src/main/resources/openapi/notification-v1.yaml` |
@@ -65,7 +65,7 @@
- 任何查询和变更都必须以服务端确认的当前租户为边界;绝不从可篡改的前端参数推断授权租户。
- 企业成员、角色和权限只能管理当前企业范围。前端隐藏不是安全控制,服务端必须重复校验租户与权限。
- 注册时登录手机号同时初始化公司联系电话;注册完成后两者独立维护。企业资料只修改 `contactPhone`,个人资料或成员管理中的登录手机号由 IAM/Auth 同步并撤销该账号现有会话,不得在前端互相回填或联动提交。
-- 认证统一经过 `src/modules/auth/store/auth.ts`、`src/api/http.ts`、`src/api/session.ts`;国内请求使用国内 Access Token,海外请求统一由 `src/api/overseasHttp.ts` 读取短时海外 Token。启动时 Access Token 已过期但 Refresh Token 仍存在,必须先尝试恢复会话;并发或跨标签页 401 共用一次刷新编排,每个原请求最多重放一次,只有明确 401/会话失效才清理 Token 并保留原路径跳转登录页。登录页“记住账号”只保存登录 ID,不改变 Access/Refresh Token 的持久化与续期策略;页面不要复制令牌逻辑或持久化密码、敏感资质内容。
+- 认证统一经过 `src/modules/auth/store/auth.ts`、`src/api/http.ts`、`src/api/session.ts`;全部请求共用统一 Access Token。启动时 Access Token 已过期但 Refresh Token 仍存在,必须先尝试恢复会话;并发或跨标签页 401 共用一次刷新编排,每个原请求最多重放一次,只有明确 401/会话失效才清理 Token 并保留原路径跳转登录页。登录页“记住账号”只保存登录 ID,不改变 Access/Refresh Token 的持久化与续期策略;页面不要复制令牌逻辑或持久化密码、敏感资质内容。
- 当前认证仍包含模拟数据。接入真实 API 后移除对应模拟分支,生产环境不得回退为模拟成功。
- 头程订单统一使用当前正式读写契约;不得继续扩展 `LEGACY_MANUAL`、旧系统入仓码、C02 不可用回退旧 API、历史部分响应兼容或其他新旧并跑逻辑。触及该链路时按根无兼容规则同步删除生产分支、类型、文案、Mock 和测试,不以现有代码或测试仍覆盖旧路径作为保留依据。
- 注册、上传资质、成员邀请和订单操作必须提供明确校验、幂等保护、提交中状态和可恢复的失败反馈。
diff --git a/Jenkinsfile b/Jenkinsfile
index 88d2dda..19da01a 100644
--- a/Jenkinsfile
+++ b/Jenkinsfile
@@ -1,250 +1,60 @@
-def sendProductionWeComNotification(String title, String status, String color, String detail) {
- withCredentials([string(credentialsId: 'wecom-prod-webhook', variable: 'WECOM_WEBHOOK')]) {
- String sourceVersion = env.SOURCE_TAG ?: env.GIT_COMMIT ?: '未解析'
- String duration = currentBuild.durationString?.replace(' and counting', '') ?: '—'
- String content = """## ${title}
-> 应用:客户工作台
-> Job:${env.JOB_NAME}
-> 构建:[#${env.BUILD_NUMBER}](${env.BUILD_URL})
-> 提交:${sourceVersion}
-> 状态:${status}
-${detail}
-> 耗时:${duration}
-> 时间:${new Date().format('yyyy-MM-dd HH:mm:ss', TimeZone.getTimeZone('Asia/Shanghai'))}
-"""
- String payloadFile = 'wecom-production-notification.json'
- writeFile(
- file: payloadFile,
- text: groovy.json.JsonOutput.toJson([
- msgtype: 'markdown',
- markdown: [content: content]
- ]),
- encoding: 'UTF-8'
- )
- sh '''
- set -eu
- payload_file='wecom-production-notification.json'
- trap 'rm -f "$payload_file"' EXIT HUP INT TERM
- response="$(curl --fail --silent --show-error \
- -H 'Content-Type: application/json' \
- --data-binary @"$payload_file" \
- "$WECOM_WEBHOOK")"
- printf '%s\n' "$response"
- printf '%s' "$response" | grep -Eq '"errcode"[[:space:]]*:[[:space:]]*0([[:space:]]*[,}])'
- '''
- }
-}
-
pipeline {
- agent {
- label "${env.JOB_BASE_NAME?.endsWith('-prod') ? 'java-builder' : (env.JOB_BASE_NAME?.endsWith('-dev') ? 'java-dev' : 'java-test')}"
- }
-
- environment {
- DEVELOPMENT_COMPOSE_FILE = 'docker-compose.development.yml'
- DEVELOPMENT_COMPOSE_PROJECT = 'client-web'
- REGISTRY_HOST = '172.31.198.7:10082'
- REGISTRY_CREDENTIALS = 'gitea-registry-token'
- NODE_BASE_IMAGE = '172.31.198.7:10082/java-project/node:22-alpine'
- NGINX_BASE_IMAGE = '172.31.198.7:10082/java-project/nginx:1.27-alpine'
- PRODUCTION_IMAGE_REPOSITORY = 'git.buggoglobal.com/html/client-web'
- }
-
+ agent { label "${env.JOB_BASE_NAME?.endsWith('-prod') ? 'java-builder' : 'java-test'}" }
options {
timestamps()
disableConcurrentBuilds()
}
-
stages {
- stage('确认提交版本') {
+ stage('检查测试发布参数') {
+ when { expression { !env.JOB_BASE_NAME?.endsWith('-prod') } }
steps {
script {
- env.IS_DEVELOPMENT = env.JOB_BASE_NAME?.endsWith('-dev') ? 'true' : 'false'
- env.IS_PRODUCTION = env.JOB_BASE_NAME?.endsWith('-prod') ? 'true' : 'false'
- if (env.IS_PRODUCTION == 'true') {
- sh '''
- set -eu
- git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
- test "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" || {
- echo "生产部署只允许 origin/main 当前提交;请先合并代码到 main" >&2
- exit 1
- }
- '''
+ if (env.JOB_BASE_NAME?.endsWith('-dev')) {
+ error('此流水线仅支持 test 和本轮授权的 prod 灰度')
}
- env.SOURCE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim()
+ env.IMAGE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim()
}
- sh 'git log -1 --oneline'
+ sh '''set -eu
+ export API_UPSTREAM="${API_UPSTREAM:-http://gateway:9000}"
+ export TEST_NETWORK="${TEST_NETWORK:-firstleg-test-network}"
+ : "${VITE_ADMIN_WEB_ORIGIN:?VITE_ADMIN_WEB_ORIGIN 未设置}"
+ docker network inspect "$TEST_NETWORK" >/dev/null
+ docker compose -f docker-compose.test.yml config --quiet
+ '''
}
}
-
stage('构建并部署测试前端') {
- when { expression { env.IS_DEVELOPMENT != 'true' && env.IS_PRODUCTION != 'true' } }
+ when { expression { !env.JOB_BASE_NAME?.endsWith('-prod') } }
steps {
- withCredentials([usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')]) {
- sh '''
- set -eu
- export DOCKER_CONFIG="${WORKSPACE}@tmp/docker-auth"
- rm -rf "$DOCKER_CONFIG"
- mkdir -p "$DOCKER_CONFIG"
- cleanup_registry() {
- docker logout "$REGISTRY_HOST" >/dev/null 2>&1 || true
- rm -rf "$DOCKER_CONFIG"
- }
- trap cleanup_registry EXIT HUP INT TERM
- printf %s "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin "$REGISTRY_HOST"
- docker compose -f docker-compose.test.yml build --pull=false
- docker compose -f docker-compose.test.yml up -d --remove-orphans
- '''
- }
+ sh '''set -eu
+ docker compose -f docker-compose.test.yml build --pull=false
+ docker compose -f docker-compose.test.yml up -d --wait --wait-timeout 120
+ docker compose -f docker-compose.test.yml exec -T client-web wget -qO- http://127.0.0.1/healthz
+ '''
}
}
-
- stage('构建并发布生产镜像') {
- when { expression { env.IS_PRODUCTION == 'true' } }
+ stage('发布临时生产灰度') {
+ when { expression { env.JOB_BASE_NAME?.endsWith('-prod') } }
steps {
- withCredentials([usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')]) {
- sh '''
- set -eu
- export DOCKER_CONFIG="${WORKSPACE}@tmp/docker-auth"
- rm -rf "$DOCKER_CONFIG"
- mkdir -p "$DOCKER_CONFIG"
- cleanup_registry() {
- docker logout git.buggoglobal.com >/dev/null 2>&1 || true
- rm -rf "$DOCKER_CONFIG"
- }
- trap cleanup_registry EXIT HUP INT TERM
- printf %s "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin git.buggoglobal.com
- image="$PRODUCTION_IMAGE_REPOSITORY:$SOURCE_TAG"
- docker build \
- --build-arg NODE_IMAGE="$NODE_BASE_IMAGE" \
- --build-arg NGINX_IMAGE="$NGINX_BASE_IMAGE" \
- --build-arg VITE_ADMIN_WEB_ORIGIN=https://admin.buggoglobal.com \
- -t "$image" .
- docker push "$image"
- '''
- }
- }
- }
-
- stage('部署生产前端') {
- when { expression { env.IS_PRODUCTION == 'true' } }
- steps {
- lock(resource: 'firstleg-production-client-web-deploy', inversePrecedence: true) {
+ lock(resource: 'firstleg-production-client-web-deploy') {
withCredentials([
+ usernamePassword(credentialsId: 'gitea-registry-token', usernameVariable: 'BUILD_REGISTRY_USER', passwordVariable: 'BUILD_REGISTRY_PASSWORD'),
usernamePassword(credentialsId: 'gitea-production-pull-token', usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD'),
sshUserPrivateKey(credentialsId: 'firstleg-production-ssh', keyFileVariable: 'TARGET_SSH_KEY', usernameVariable: 'TARGET_SSH_USER'),
string(credentialsId: 'firstleg-production-host', variable: 'TARGET_SSH_HOST'),
file(credentialsId: 'firstleg-production-known-hosts', variable: 'TARGET_SSH_KNOWN_HOSTS')
]) {
- withEnv([
- 'TARGET_DEPLOY_DIR=/data/firstleg/deploy/frontend',
- 'TARGET_SSH_TARGET=' + env.TARGET_SSH_USER + '@' + env.TARGET_SSH_HOST
- ]) {
- sh "sh scripts/deploy-production.sh client-web '${env.PRODUCTION_IMAGE_REPOSITORY}' '${env.SOURCE_TAG}' docker-compose.production.yml"
- }
+ sh 'sh scripts/deploy-production.sh'
}
}
}
}
-
- stage('构建并部署开发前端') {
- when { expression { env.IS_DEVELOPMENT == 'true' } }
- steps {
- withCredentials([usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')]) {
- sh '''
- set -eu
- export DOCKER_CONFIG="${WORKSPACE}@tmp/docker-auth"
- rm -rf "$DOCKER_CONFIG"
- mkdir -p "$DOCKER_CONFIG"
- cleanup_registry() {
- docker logout "$REGISTRY_HOST" >/dev/null 2>&1 || true
- rm -rf "$DOCKER_CONFIG"
- }
- trap cleanup_registry EXIT HUP INT TERM
- printf %s "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin "$REGISTRY_HOST"
- image="client-web-development:${SOURCE_TAG}"
-
- docker build \
- --build-arg NODE_IMAGE="$NODE_BASE_IMAGE" \
- --build-arg NGINX_IMAGE="$NGINX_BASE_IMAGE" \
- --build-arg VITE_ADMIN_WEB_ORIGIN=http://8.163.49.32:9001 \
- -t "$image" .
- export IMAGE_TAG="$SOURCE_TAG"
- docker compose -p "$DEVELOPMENT_COMPOSE_PROJECT" -f "$DEVELOPMENT_COMPOSE_FILE" up -d --remove-orphans --pull never
- docker compose -p "$DEVELOPMENT_COMPOSE_PROJECT" -f "$DEVELOPMENT_COMPOSE_FILE" ps
- docker compose -p "$DEVELOPMENT_COMPOSE_PROJECT" -f "$DEVELOPMENT_COMPOSE_FILE" exec -T client-web wget -qO- http://127.0.0.1/healthz
- '''
- }
- }
- }
-
- stage('部署检查') {
- when { expression { env.IS_DEVELOPMENT != 'true' && env.IS_PRODUCTION != 'true' } }
- steps {
- sh '''
- set -eu
- docker compose -f docker-compose.test.yml ps
- docker compose -f docker-compose.test.yml exec -T client-web wget -qO- http://127.0.0.1/healthz
- '''
- }
- }
}
-
post {
- success {
- script {
- if (env.JOB_BASE_NAME?.endsWith('-prod')) {
- try {
- sendProductionWeComNotification(
- '✅ 生产部署成功',
- '成功',
- 'info',
- '> 结果:Client 生产前端已完成发布和部署流程'
- )
- } catch (Exception error) {
- echo "企业微信通知失败,不影响原构建结果:${error.message}"
- }
- }
- }
- }
failure {
- sh '''
- if [ "${IS_PRODUCTION:-false}" = "true" ]; then
- echo "生产部署失败,请查看上方远程部署日志"
- elif [ "${IS_DEVELOPMENT:-false}" = "true" ]; then
- docker logs --tail=100 client-web-development || true
- else
- docker compose -f docker-compose.test.yml logs --tail=100 || true
- fi
- '''
script {
- if (env.JOB_BASE_NAME?.endsWith('-prod')) {
- try {
- sendProductionWeComNotification(
- '❌ 生产发布失败',
- '失败',
- 'warning',
- '> 说明:请打开 Jenkins 构建日志查看失败阶段'
- )
- } catch (Exception error) {
- echo "企业微信通知失败,不影响原构建结果:${error.message}"
- }
- }
- }
- }
- aborted {
- script {
- if (env.JOB_BASE_NAME?.endsWith('-prod')) {
- try {
- sendProductionWeComNotification(
- '⏹ 生产部署已取消',
- '已取消',
- 'comment',
- '> 说明:生产部署流程被人工取消或中止'
- )
- } catch (Exception error) {
- echo "企业微信通知失败,不影响原构建结果:${error.message}"
- }
+ if (!env.JOB_BASE_NAME?.endsWith('-prod')) {
+ sh 'docker compose -f docker-compose.test.yml logs --tail=100 || true'
}
}
}
diff --git a/README.md b/README.md
index 74d02aa..36f3f95 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@ pnpm install
pnpm dev
```
-生产构建:
+发布资源构建:
```bash
pnpm typecheck
@@ -35,7 +35,7 @@ pnpm test
依赖和 `pnpm-lock.yaml` 均保存在当前前端项目中,不需要在仓库根目录安装 Node 依赖。
-订单与客户路由:登录、注册、工作台、我的信息、企业资料、企业成员、角色权限、头程订单、海外采购、美国本土卡、美本土散板以及海外仓入库订单。跨境下单支持真实列表、详情和新增,海外入库支持当前客户列表与详情;均经海外 Gateway 的客户接口访问,客户范围只取 Token,不提交客户 ID。开发代理通过 `API_PROXY_TARGET` 与 `OVERSEAS_API_PROXY_TARGET` 分别配置国内、海外 Gateway。登录使用后端约定的用户名和 8–72 位密码,注册成功后可立即登录,企业资料审核只影响订单提交资格。
+订单与客户路由:登录、注册、工作台、我的信息、企业资料、企业成员、角色权限、头程订单、海外采购、美国本土卡、美本土散板以及海外仓入库订单。跨境下单支持真实列表、详情和新增,海外入库支持当前客户列表与详情;均经统一 Gateway 的客户接口访问,客户范围只取 Token,不提交客户 ID。本地代理仅通过 `API_PROXY_TARGET` 配置统一 Gateway。登录使用后端约定的用户名和 8–72 位密码,注册成功后可立即登录,企业资料审核只影响订单提交资格。
认证会话保存 Access Token 和轮换后的 Refresh Token,并对并发 401 请求执行单次刷新。刷新或会话上下文加载遇到临时网络故障、超时等非认证错误时保留当前有效 Token,只有服务端明确返回 401/会话失效才清理登录状态。客户端只接受 `app_code` 为 `MERCHANT` 的 JWT;页面权限通过当前用户授权接口实时加载,不从 JWT 携带或解析角色权限。前端权限仅用于导航和交互控制,服务端仍须按用户、客户和权限编码完成最终授权。
@@ -47,4 +47,10 @@ pnpm test
头程订单列表、详情、仓库查询、创建/编辑草稿和提交已经接入真实 API,并显式映射后端 `transportMode`、`containerMode` 和订单状态。创建与提交请求携带幂等键,`merchant:order:create`、`merchant:order:edit`、`merchant:order:submit` 分别映射到独立前端操作权限。页面补齐渠道、提货方式、报关模式、目的仓类型、包装类型等后端必填字段;API 模式先清空本地记录,请求失败不会回退模拟成功。FBA 和海外仓按公共仓类型筛选,选择后由仓库主数据生成目的仓字段,不再要求客户重复选择收货单位;私人仓才填写并按当前用户缓存收件资料。只有“我司提货”需要填写提货联系人和地址,其他送货方式不发送发货方快照,订单也不再依赖往来单位 `customerId`。快递信息仍由平台维护、客户只读。订单取消不属于本轮本地订单与客户验收,客户端不暴露取消写操作。订单产品状态仍按已确认的七状态执行,不设置“已驳回”;若兼容旧接口数据收到 `REJECTED`,只按待审核展示且不开放编辑。
-联调远端 Gateway 时,进程环境变量优先于 `.env` 文件。浏览器国内业务统一请求同源 `/api`,海外业务请求同源 `/overseas-api`,由代理分别转发并确保最终路径只有一个 `/api` 前缀。联调时可只读检查同工作区的 `baiyue-firstleg/` Controller、DTO、Service、Repository 和回归脚本,用于发现后端拉取后的契约变化,但不修改后端仓库。
+联调远端 Gateway 时,进程环境变量优先于 `.env` 文件。浏览器全部业务统一请求同源 `/api`,由同一代理转发并确保最终路径只有一个 `/api` 前缀。联调时可只读检查同工作区的 `baiyue-firstleg/` Controller、DTO、Service、Repository 和回归脚本,用于发现后端拉取后的契约变化,但不修改后端仓库。
+
+## 唯一测试部署入口
+
+仅保留 `docker-compose.test.yml` 与 test Jenkins Job。发布前注入 `API_UPSTREAM`(统一 Gateway origin,不含 `/api`)、`TEST_NETWORK`(后端共享测试网络)、`VITE_ADMIN_WEB_ORIGIN`(管理后台 origin,构建时用于安全代登录)与 `IMAGE_TAG`(Jenkins 从当前提交生成)。`HOST_PORT` 默认 9002 且仅绑定 loopback;宿主机反向代理负责外部入口。可选 `NODE_BASE_IMAGE`/`NGINX_BASE_IMAGE`,私有镜像认证由构建节点管理。不得把本地构建结果描述为测试环境已发布。
+
+同机目标默认值:`API_UPSTREAM=http://gateway:9000`、`TEST_NETWORK=firstleg-test-network`;必须与后端同批 test Compose 配置一致。
diff --git a/docker-compose.development.yml b/docker-compose.development.yml
deleted file mode 100644
index d52a739..0000000
--- a/docker-compose.development.yml
+++ /dev/null
@@ -1,21 +0,0 @@
-services:
- client-web:
- image: client-web-development:${IMAGE_TAG:?IMAGE_TAG is required}
- container_name: client-web-development
- restart: unless-stopped
- ports:
- - "127.0.0.1:9002:80"
- environment:
- API_UPSTREAM: "http://firstleg-development-gateway:9000"
- OVERSEAS_API_UPSTREAM: "http://firstleg-development-gateway-overseas:9300"
- networks:
- - development-backend
- - development-overseas-backend
-
-networks:
- development-backend:
- external: true
- name: firstleg-development-network
- development-overseas-backend:
- external: true
- name: firstleg-development-overseas-network
diff --git a/docker-compose.production.yml b/docker-compose.production.yml
index d721c62..e7f339d 100644
--- a/docker-compose.production.yml
+++ b/docker-compose.production.yml
@@ -1,23 +1,22 @@
name: firstleg-prod-client-web
-
services:
client-web:
- image: ${IMAGE_REPOSITORY:?IMAGE_REPOSITORY is required}:${IMAGE_TAG:?IMAGE_TAG is required}
+ image: "${PRODUCTION_IMAGE_REPOSITORY:?PRODUCTION_IMAGE_REPOSITORY is required}:${IMAGE_TAG:?IMAGE_TAG is required}"
container_name: firstleg-prod-client-web
restart: unless-stopped
ports:
- - "127.0.0.1:${HOST_PORT:-9002}:80"
+ - "127.0.0.1:${HOST_PORT:-29012}:80"
environment:
- API_UPSTREAM: "http://gateway-active:9000"
- OVERSEAS_API_UPSTREAM: "http://gateway-overseas-active:9300"
+ API_UPSTREAM: "http://gateway:9000"
+ healthcheck:
+ test: ["CMD", "wget", "-qO-", "http://127.0.0.1/healthz"]
+ interval: 5s
+ timeout: 3s
+ retries: 12
+ start_period: 5s
networks:
- - infra-network
- - routing-network
-
+ - backend
networks:
- infra-network:
+ backend:
external: true
- name: firstleg-prod-infra-network
- routing-network:
- external: true
- name: firstleg-production-routing
+ name: firstleg-production-network
diff --git a/docker-compose.test.yml b/docker-compose.test.yml
index 697c93c..c490b5b 100644
--- a/docker-compose.test.yml
+++ b/docker-compose.test.yml
@@ -1,19 +1,29 @@
+name: firstleg-test-client-web
services:
client-web:
build:
context: .
dockerfile: Dockerfile
args:
- VITE_ADMIN_WEB_ORIGIN: "${VITE_ADMIN_WEB_ORIGIN:-https://test-admin.buggoglobal.com}"
NODE_IMAGE: "${NODE_BASE_IMAGE:-node:22-alpine}"
NGINX_IMAGE: "${NGINX_BASE_IMAGE:-nginx:1.27-alpine}"
- image: client-web-test:local
+ VITE_ADMIN_WEB_ORIGIN: "${VITE_ADMIN_WEB_ORIGIN:?VITE_ADMIN_WEB_ORIGIN is required}"
+ image: client-web-test:${IMAGE_TAG:?IMAGE_TAG is required}
container_name: client-web-test
restart: unless-stopped
+ healthcheck:
+ test: ["CMD", "wget", "-qO-", "http://127.0.0.1/healthz"]
+ interval: 5s
+ timeout: 3s
+ retries: 12
+ start_period: 5s
ports:
- - "${HOST_PORT:-9002}:80"
- extra_hosts:
- - "host.docker.internal:host-gateway"
+ - "127.0.0.1:${HOST_PORT:-9002}:80"
environment:
- API_UPSTREAM: "${API_UPSTREAM:-http://host.docker.internal:9000}"
- OVERSEAS_API_UPSTREAM: "${OVERSEAS_API_UPSTREAM:-http://host.docker.internal:9300}"
+ API_UPSTREAM: "${API_UPSTREAM:-http://gateway:9000}"
+ networks:
+ - test-backend
+networks:
+ test-backend:
+ external: true
+ name: "${TEST_NETWORK:-firstleg-test-network}"
diff --git a/nginx/default.conf.template b/nginx/default.conf.template
index 68ea42b..55da3bc 100644
--- a/nginx/default.conf.template
+++ b/nginx/default.conf.template
@@ -35,17 +35,6 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
- # 海外业务使用独立 Gateway;浏览器前缀只在此处改写一次为 /api。
- location /overseas-api/ {
- client_max_body_size 55m;
- rewrite ^/overseas-api/(.*)$ /api/$1 break;
- proxy_pass ${OVERSEAS_API_UPSTREAM};
- 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;
- }
# 支持 Vue Router history 路由直接刷新。
location / {
diff --git a/scripts/deploy-production.sh b/scripts/deploy-production.sh
index c27fb0b..ebcb234 100644
--- a/scripts/deploy-production.sh
+++ b/scripts/deploy-production.sh
@@ -1,150 +1,58 @@
-#!/usr/bin/env sh
+#!/bin/sh
set -eu
-
-if [ "$#" -ne 4 ]; then
- echo "用法: $0 <服务名> <镜像仓库> <镜像标签> " >&2
- exit 2
-fi
-
-SERVICE="$1"
-IMAGE_REPOSITORY="$2"
-IMAGE_TAG="$3"
-COMPOSE_FILE="$4"
-IMAGE="$IMAGE_REPOSITORY:$IMAGE_TAG"
-REGISTRY_HOST="${IMAGE_REPOSITORY%%/*}"
-SSH_TARGET="${TARGET_SSH_TARGET:?TARGET_SSH_TARGET 未设置}"
-SSH_KEY="${TARGET_SSH_KEY:?TARGET_SSH_KEY 未设置}"
-SSH_KNOWN_HOSTS="${TARGET_SSH_KNOWN_HOSTS:?TARGET_SSH_KNOWN_HOSTS 未设置}"
-REMOTE_DIR="${TARGET_DEPLOY_DIR:?TARGET_DEPLOY_DIR 未设置}"
-: "${REGISTRY_USER:?REGISTRY_USER 未设置}"
-: "${REGISTRY_PASSWORD:?REGISTRY_PASSWORD 未设置}"
-
+set +x
+: "${PUBLIC_WEB_ORIGIN:?PUBLIC_WEB_ORIGIN is required}"
+: "${PRODUCTION_IMAGE_REPOSITORY:?PRODUCTION_IMAGE_REPOSITORY is required}"
+: "${TARGET_SSH_KEY:?}"
+: "${TARGET_SSH_USER:?}"
+: "${TARGET_SSH_HOST:?}"
+: "${TARGET_SSH_KNOWN_HOSTS:?}"
+: "${BUILD_REGISTRY_USER:?}"
+: "${BUILD_REGISTRY_PASSWORD:?}"
+: "${REGISTRY_USER:?}"
+: "${REGISTRY_PASSWORD:?}"
+case "$PUBLIC_WEB_ORIGIN" in https://*) ;; *) echo 'prod requires HTTPS web origin' >&2; exit 2 ;; esac
+# Only an origin (optionally a trailing slash) is accepted; no shell/env-file metacharacters.
+printf '%s' "$PUBLIC_WEB_ORIGIN" | grep -Eq '^https://[A-Za-z0-9.-]+(:[0-9]+)?/?$'
+printf '%s' "$PRODUCTION_IMAGE_REPOSITORY" | grep -Eq '^[A-Za-z0-9.-]+(:[0-9]+)?/[a-z0-9._/-]+$'
+git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
+test "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)"
+IMAGE_TAG=$(git rev-parse --short=12 HEAD)
+registry=${PRODUCTION_IMAGE_REPOSITORY%%/*}
+image="$PRODUCTION_IMAGE_REPOSITORY:$IMAGE_TAG"
+TARGET_SSH_TARGET="$TARGET_SSH_USER@$TARGET_SSH_HOST"
+remote_dir=/data/firstleg/deploy/gray-client-web
run_ssh() {
- ssh -i "$SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes \
- -o UserKnownHostsFile="$SSH_KNOWN_HOSTS" -o ConnectTimeout=10 "$SSH_TARGET" "$@"
+ ssh -i "$TARGET_SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes \
+ -o UserKnownHostsFile="$TARGET_SSH_KNOWN_HOSTS" -o ConnectTimeout=12 "$TARGET_SSH_TARGET" "$@"
}
-
run_scp() {
- scp -i "$SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes \
- -o UserKnownHostsFile="$SSH_KNOWN_HOSTS" -o ConnectTimeout=10 "$@"
+ scp -i "$TARGET_SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes \
+ -o UserKnownHostsFile="$TARGET_SSH_KNOWN_HOSTS" -o ConnectTimeout=12 "$@"
}
-
-run_ssh install -d -m 750 "$REMOTE_DIR"
-REMOTE_COMPOSE="$REMOTE_DIR/docker-compose.$SERVICE.yml"
-run_scp "$COMPOSE_FILE" "$SSH_TARGET:$REMOTE_COMPOSE.tmp"
-run_ssh sh -s -- "$REMOTE_COMPOSE.tmp" "$REMOTE_COMPOSE" <<'REMOTE_PREPARE'
+export DOCKER_CONFIG
+DOCKER_CONFIG=$(mktemp -d)
+cleanup() { rm -f "$DOCKER_CONFIG/config.json"; rmdir "$DOCKER_CONFIG" 2>/dev/null || true; }
+trap cleanup EXIT HUP INT TERM
+printf '%s' "$BUILD_REGISTRY_PASSWORD" | docker login -u "$BUILD_REGISTRY_USER" --password-stdin "$registry"
+docker build --build-arg NODE_IMAGE="${NODE_BASE_IMAGE:-node:22-alpine}" \
+ --build-arg NGINX_IMAGE="${NGINX_BASE_IMAGE:-nginx:1.27-alpine}" \
+ --build-arg VITE_ADMIN_WEB_ORIGIN="$PUBLIC_WEB_ORIGIN" -t "$image" .
+docker push "$image"
+run_ssh install -d -m 750 "$remote_dir"
+run_scp docker-compose.production.yml "$TARGET_SSH_TARGET:$remote_dir/compose.yml"
+# The remote env file contains only image coordinates and public web origin, never credentials.
+printf 'PRODUCTION_IMAGE_REPOSITORY=%s\nIMAGE_TAG=%s\nPUBLIC_WEB_ORIGIN=%s\n' \
+ "$PRODUCTION_IMAGE_REPOSITORY" "$IMAGE_TAG" "$PUBLIC_WEB_ORIGIN" | run_ssh sh -c "'umask 077; cat > $remote_dir/release.env'"
+# Registry credentials exist only in an isolated temporary Docker config, removed after pull.
+printf '%s' "$REGISTRY_USER" | grep -Eq '^[A-Za-z0-9_.@-]+$'
+run_scp scripts/pull-production-image.sh "$TARGET_SSH_TARGET:$remote_dir/pull-image.sh"
+printf '%s' "$REGISTRY_PASSWORD" | run_ssh sh "$remote_dir/pull-image.sh" "$REGISTRY_USER" "$registry" "$image"
+run_ssh sh -s -- "$remote_dir" <<'REMOTE'
set -eu
-compose_tmp="$1"
-compose_target="$2"
-chmod 644 "$compose_tmp"
-mv -f "$compose_tmp" "$compose_target"
-REMOTE_PREPARE
-
-printf '%s' "$REGISTRY_PASSWORD" | run_ssh docker login -u "$REGISTRY_USER" --password-stdin "$REGISTRY_HOST"
-trap 'run_ssh docker logout "$REGISTRY_HOST" >/dev/null 2>&1 || true' EXIT HUP INT TERM
-run_ssh docker pull "$IMAGE"
-
-run_ssh sh -s -- "$REMOTE_DIR" "$SERVICE" "$IMAGE_REPOSITORY" "$IMAGE_TAG" <<'REMOTE_DEPLOY'
-set -eu
-remote_dir="$1"
-service="$2"
-image_repository="$3"
-image_tag="$4"
-compose_file="docker-compose.$service.yml"
-project_name="firstleg-prod-$service"
-cd "$remote_dir"
-export IMAGE_REPOSITORY="$image_repository" IMAGE_TAG="$image_tag"
-
-compose() {
- docker compose --project-name "$project_name" -f "$compose_file" "$@"
-}
-
-case "$service" in
- admin-web)
- base_port=9001
- nginx_config=/www/server/panel/vhost/nginx/00-blue-green-admin-web.conf
- legacy_container=firstleg-prod-admin-web
- ;;
- client-web)
- base_port=9002
- nginx_config=/www/server/panel/vhost/nginx/00-blue-green-client-web.conf
- legacy_container=firstleg-prod-client-web
- ;;
- *) echo "不支持蓝绿部署的前端服务: $service" >&2; exit 2 ;;
-esac
-
-state_root=/data/firstleg/deploy/blue-green-state
-state_file="$state_root/frontend-$service.active"
-routing_network=firstleg-production-routing
-mkdir -p "$state_root"
-chmod 750 "$state_root"
-docker network inspect "$routing_network" >/dev/null 2>&1 || docker network create "$routing_network" >/dev/null
-
-for pair in gateway-active:firstleg-production-gateway gateway-overseas-active:firstleg-production-gateway-overseas; do
- alias_name="${pair%%:*}"
- container="${pair#*:}"
- if docker inspect "$container" >/dev/null 2>&1; then
- docker network inspect "$routing_network" --format '{{json .Containers}}' | grep -q "\"$container\"" \
- || docker network connect --alias "$alias_name" "$routing_network" "$container"
- fi
-done
-
-active_slot=""
-active_container=""
-active_port=""
-if [ -s "$state_file" ]; then
- IFS='|' read -r active_slot active_container active_port active_image < "$state_file"
-elif docker inspect "$legacy_container" >/dev/null 2>&1; then
- active_slot=legacy
- active_container="$legacy_container"
- active_port="$base_port"
-fi
-
-if [ "$active_slot" = blue ]; then target_slot=green; else target_slot=blue; fi
-[ "$active_slot" = legacy ] && target_slot=green
-if [ "$target_slot" = blue ]; then target_port=$((base_port + 20000)); else target_port=$((base_port + 10000)); fi
-candidate="${legacy_container}-${target_slot}"
-docker rm -f "$candidate" >/dev/null 2>&1 || true
-
-if ! compose run -d --no-deps --name "$candidate" \
- -p "127.0.0.1:$target_port:80" "$service" >/dev/null; then
- docker rm -f "$candidate" >/dev/null 2>&1 || true
- exit 1
-fi
-
-healthy=false
-elapsed=0
-while [ "$elapsed" -lt 120 ]; do
- if docker exec "$candidate" wget -qO- http://127.0.0.1/healthz >/dev/null 2>&1; then
- healthy=true
- break
- fi
- sleep 2
- elapsed=$((elapsed + 2))
-done
-if [ "$healthy" != true ]; then
- docker logs --tail 100 "$candidate" >&2 || true
- docker rm -f "$candidate" >/dev/null 2>&1 || true
- exit 1
-fi
-
-backup="$nginx_config.blue-green-backup"
-cp -f "$nginx_config" "$backup"
-if ! sed -i "s#127\.0\.0\.1:${active_port:-$base_port}#127.0.0.1:$target_port#g" "$nginx_config" \
- || cmp -s "$nginx_config" "$backup" || ! nginx -t || ! nginx -s reload; then
- cp -f "$backup" "$nginx_config"
- nginx -t && nginx -s reload || true
- docker rm -f "$candidate" >/dev/null 2>&1 || true
- exit 1
-fi
-rm -f "$backup"
-
-if [ -n "$active_container" ] && docker inspect "$active_container" >/dev/null 2>&1; then
- docker stop --time 30 "$active_container" >/dev/null
- docker rm "$active_container" >/dev/null
-fi
-printf '%s|%s|%s|%s\n' "$target_slot" "$candidate" "$target_port" "$image_repository:$image_tag" > "$state_file.tmp"
-chmod 640 "$state_file.tmp"
-mv -f "$state_file.tmp" "$state_file"
-echo "生产前端蓝绿部署成功: service=$service slot=$target_slot port=$target_port image=$image_repository:$image_tag"
-REMOTE_DEPLOY
+cd "$1"
+docker network inspect firstleg-production-network >/dev/null
+docker compose --env-file release.env -f compose.yml config --quiet
+docker compose --env-file release.env -f compose.yml up -d --wait --wait-timeout 120
+docker compose --env-file release.env -f compose.yml exec -T client-web wget -qO- http://127.0.0.1/healthz
+REMOTE
diff --git a/scripts/pull-production-image.sh b/scripts/pull-production-image.sh
new file mode 100644
index 0000000..91562c3
--- /dev/null
+++ b/scripts/pull-production-image.sh
@@ -0,0 +1,9 @@
+#!/bin/sh
+set -eu
+set +x
+export DOCKER_CONFIG
+DOCKER_CONFIG=$(mktemp -d)
+cleanup() { rm -f "$DOCKER_CONFIG/config.json"; rmdir "$DOCKER_CONFIG" 2>/dev/null || true; }
+trap cleanup EXIT HUP INT TERM
+docker login -u "$1" --password-stdin "$2"
+docker pull "$3"
diff --git a/shared/src/index.ts b/shared/src/index.ts
index a1a89da..bbee384 100644
--- a/shared/src/index.ts
+++ b/shared/src/index.ts
@@ -92,6 +92,7 @@ export const FIRST_LEG_ORDER_STATUSES = [
{ code: 'LOADED', label: '装柜完成' },
{ code: 'DELIVERED', label: '派送完成' },
{ code: 'COMPLETED', label: '已完成' },
+ { code: 'CANCELLED', label: '已取消' },
] as const
export type FirstLegOrderStatus = (typeof FIRST_LEG_ORDER_STATUSES)[number]['code']
diff --git a/src/api/http.ts b/src/api/http.ts
index 03d6a18..9b9b4be 100644
--- a/src/api/http.ts
+++ b/src/api/http.ts
@@ -1,8 +1,8 @@
import axios, { AxiosError, type AxiosRequestConfig, type InternalAxiosRequestConfig } from 'axios'
import { parseApiJsonResponse } from '@chongchong/shared'
-import { clearSessionTokens, getAccessToken, getRefreshToken, persistTokenPair, type TokenPair } from '@/api/session'
+import { accessTokenIdentity, clearSessionTokens, getAccessToken, getRefreshToken, persistTokenPair, type TokenPair } from '@/api/session'
import { apiErrorMessage, asFieldErrors } from '@/api/errorDetails'
-import { ApiError, isApiEnvelope, type ApiEnvelope } from '@/api/types'
+import { ApiError, isApiEnvelope, isApiFailure, type ApiEnvelope } from '@/api/types'
import { coordinateCrossTabRefresh, CrossTabRefreshTimeoutError } from '@/api/crossTabRefresh'
const transport = axios.create({
@@ -13,9 +13,18 @@ const transport = axios.create({
interface RetryableRequest extends InternalAxiosRequestConfig {
_retriedAfterRefresh?: boolean
+ _sessionIdentity?: string | null
+ _logoutGeneration?: number
}
function unwrap(envelope: ApiEnvelope) {
+ if (!isApiEnvelope(envelope)) {
+ const invalid: unknown = envelope
+ if (isApiFailure(invalid)) {
+ throw new ApiError(invalid.message, invalid.code, undefined, undefined, invalid.traceId, invalid.requestId)
+ }
+ throw new ApiError('接口响应格式错误', 'INVALID_RESPONSE')
+ }
if (envelope.code !== '200') {
const fieldErrors = asFieldErrors(envelope.data)
throw new ApiError(apiErrorMessage(envelope.message, fieldErrors), envelope.code, undefined, fieldErrors, envelope.traceId, envelope.requestId)
@@ -28,8 +37,8 @@ function mapError(error: unknown) {
if (!axios.isAxiosError(error)) return new ApiError(error instanceof Error ? error.message : '请求失败')
const body = error.response?.data
- if (isApiEnvelope(body)) {
- const fieldErrors = asFieldErrors(body.data)
+ if (isApiEnvelope(body) || isApiFailure(body)) {
+ const fieldErrors = asFieldErrors(isApiEnvelope(body) ? body.data : undefined)
return new ApiError(
apiErrorMessage(body.message, fieldErrors),
body.code,
@@ -44,9 +53,12 @@ function mapError(error: unknown) {
return new ApiError(error.message || '请求失败', 'HTTP_ERROR', error.response?.status)
}
+let refreshExchange: { identity: string; refreshToken: string; promise: Promise } | null = null
+let logoutGeneration = 0
let refreshPromise: Promise | null = null
+let refreshIdentity: string | null = null
let redirectingToLogin = false
-const publicAuthPaths = ['/auth/login', '/auth/register', '/auth/refresh', '/auth/impersonations/exchange']
+const publicAuthPaths = ['/auth/login', '/auth/register', '/auth/refresh', '/auth/logout', '/auth/impersonations/exchange']
function isPublicAuthRequest(url?: string) {
return publicAuthPaths.some((path) => url?.endsWith(path))
@@ -63,29 +75,46 @@ function redirectToLoginAfterSessionExpiry() {
window.location.replace(`/login?redirect=${encodeURIComponent(redirect)}`)
}
+function requireCurrentIdentity(identity: string | null | undefined, accessToken = getAccessToken()) {
+ if (!identity || accessTokenIdentity(accessToken) !== identity) throw new ApiError('登录身份已变化,请重新操作', 'SESSION_CHANGED')
+}
+
export async function refreshAuthenticatedSession() {
+ const observedIdentity = accessTokenIdentity(getAccessToken())
+ requireCurrentIdentity(observedIdentity)
const observedRefreshToken = getRefreshToken()
if (!observedRefreshToken) throw new ApiError('登录已过期,请重新登录', 'A0401', 401)
if (!refreshPromise) {
+ refreshIdentity = observedIdentity
refreshPromise = coordinateCrossTabRefresh({
lockName: 'cc-client-auth-refresh',
observedRefreshToken,
currentRefreshToken: getRefreshToken,
reuseCurrentSession: async () => {
+ requireCurrentIdentity(observedIdentity)
const currentAccessToken = getAccessToken()
if (!currentAccessToken) throw new ApiError('登录已过期,请重新登录', 'A0401', 401)
return currentAccessToken
},
refresh: async (currentRefreshToken) => {
- const response = await transport.post>('/auth/refresh', { refreshToken: currentRefreshToken })
- const tokens = unwrap(response.data)
+ requireCurrentIdentity(observedIdentity)
+ const exchange = { identity: observedIdentity!, refreshToken: currentRefreshToken, promise: transport.post>('/auth/refresh', { refreshToken: currentRefreshToken }).then(response => unwrap(response.data)) }
+ refreshExchange = exchange
+ let tokens: TokenPair
+ try { tokens = await exchange.promise } finally { if (refreshExchange === exchange) refreshExchange = null }
+ requireCurrentIdentity(observedIdentity)
+ requireCurrentIdentity(observedIdentity, tokens.accessToken)
if (!getRefreshToken()) throw new ApiError('登录已在其他标签页退出', 'A0401', 401)
+ if (getRefreshToken() !== currentRefreshToken) throw new ApiError('登录状态已变化,请重新操作', 'SESSION_CHANGED')
persistTokenPair(tokens)
+ if (typeof window !== 'undefined') window.dispatchEvent(new CustomEvent('cc-client-session-updated'))
return tokens.accessToken
},
})
.catch((error) => {
+ requireCurrentIdentity(observedIdentity)
if (error instanceof CrossTabRefreshTimeoutError) {
+ if (getRefreshToken() && getRefreshToken() !== observedRefreshToken) throw new ApiError('登录状态已变化,请重新操作', 'SESSION_CHANGED')
const expiredError = new ApiError(error.message, 'A0401', 401)
clearSessionTokens()
window.dispatchEvent(new CustomEvent('cc-client-session-expired'))
@@ -96,33 +125,60 @@ export async function refreshAuthenticatedSession() {
})
.finally(() => {
refreshPromise = null
+ refreshIdentity = null
})
}
- return refreshPromise
+ if (refreshIdentity !== observedIdentity) throw new ApiError('登录身份已变化,请重新操作', 'SESSION_CHANGED')
+ const refreshed = await refreshPromise
+ requireCurrentIdentity(observedIdentity)
+ return refreshed
}
transport.interceptors.request.use((config) => {
const token = getAccessToken()
- if (token && !isPublicAuthRequest(config.url)) config.headers.Authorization = `Bearer ${token}`
+ const request = config as RetryableRequest
+ if (request._retriedAfterRefresh && !isPublicAuthRequest(config.url)) requireCurrentIdentity(request._sessionIdentity, token)
+ if (token && !isPublicAuthRequest(config.url)) {
+ if (!request._retriedAfterRefresh) {
+ request._sessionIdentity = accessTokenIdentity(token)
+ request._logoutGeneration = logoutGeneration
+ }
+ config.headers.Authorization = `Bearer ${token}`
+ }
return config
})
transport.interceptors.response.use(
- (response) => response,
+ (response) => {
+ const request = response.config as RetryableRequest
+ if (request._sessionIdentity) {
+ if (request._logoutGeneration !== logoutGeneration) throw new ApiError('登录身份已变化,请重新操作', 'SESSION_CHANGED')
+ requireCurrentIdentity(request._sessionIdentity)
+ }
+ return response
+ },
async (error: AxiosError) => {
const request = error.config as RetryableRequest | undefined
if (error.response?.status !== 401 || !request || request._retriedAfterRefresh || isPublicAuthRequest(request.url)) {
throw mapError(error)
}
+ if (request._logoutGeneration !== logoutGeneration) throw new ApiError('登录身份已变化,请重新操作', 'SESSION_CHANGED')
+ requireCurrentIdentity(request._sessionIdentity)
request._retriedAfterRefresh = true
+ const refreshTokenBeforeRetry = getRefreshToken()
try {
const accessToken = await refreshAuthenticatedSession()
+ if (request._logoutGeneration !== logoutGeneration) throw new ApiError('登录身份已变化,请重新操作', 'SESSION_CHANGED')
+ requireCurrentIdentity(request._sessionIdentity)
request.headers.Authorization = `Bearer ${accessToken}`
return await transport.request(request)
} catch (refreshError) {
+ if (request._logoutGeneration !== logoutGeneration) throw new ApiError('登录身份已变化,请重新操作', 'SESSION_CHANGED')
+ requireCurrentIdentity(request._sessionIdentity)
const mappedError = mapError(refreshError)
if (mappedError.status === 401 || mappedError.code === 'A0401') {
+ if (getRefreshToken() && getRefreshToken() !== refreshTokenBeforeRetry) throw mappedError
clearSessionTokens()
window.dispatchEvent(new CustomEvent('cc-client-session-expired'))
redirectToLoginAfterSessionExpiry()
@@ -132,6 +188,33 @@ transport.interceptors.response.use(
},
)
+// Captured revocation never persists credentials or restores local login state.
+export async function revokeCapturedSession(accessToken: string, refreshToken: string) {
+ logoutGeneration++
+ const identity = accessTokenIdentity(accessToken)
+ if (!identity || !refreshToken) throw new ApiError('服务器会话注销未确认', 'SESSION_CHANGED')
+ const pending = refreshExchange?.identity === identity && refreshExchange.refreshToken === refreshToken ? refreshExchange.promise : null
+ const revoke = (pair: Pick) => apiRequest({
+ method: 'POST', url: '/auth/logout', data: { refreshToken: pair.refreshToken },
+ headers: { Authorization: `Bearer ${pair.accessToken}` },
+ })
+ const validate = (pair: TokenPair) => {
+ if (accessTokenIdentity(pair.accessToken) !== identity || !pair.refreshToken) throw new ApiError('刷新身份不一致,服务器会话注销未确认', 'SESSION_CHANGED')
+ return pair
+ }
+ // An already sent refresh can rotate the pair. Consume its actual result
+ // without issuing a competing refresh or restoring it to local storage.
+ if (pending) {
+ await revoke(validate(await pending))
+ return
+ }
+ try { await revoke({ accessToken, refreshToken }) } catch (error) {
+ if (!(error instanceof ApiError) || error.status !== 401) throw error
+ const pair = await apiRequest({ method: 'POST', url: '/auth/refresh', data: { refreshToken } })
+ await revoke(validate(pair))
+ }
+}
+
export async function apiRequest(config: AxiosRequestConfig) {
try {
const response = await transport.request>(config)
@@ -170,8 +253,8 @@ async function throwBlobApiError(error: unknown, responseStatus?: number) {
if (!blob || !blob.type.toLowerCase().includes('json')) return
try {
const body = JSON.parse(await blob.text()) as unknown
- if (!isApiEnvelope(body)) return
- const fieldErrors = asFieldErrors(body.data)
+ if (!isApiEnvelope(body) && !isApiFailure(body)) return
+ const fieldErrors = asFieldErrors(isApiEnvelope(body) ? body.data : undefined)
throw new ApiError(
apiErrorMessage(body.message, fieldErrors),
body.code,
diff --git a/src/api/overseasFailure.test.mjs b/src/api/overseasFailure.test.mjs
index df61ce0..2941d4b 100644
--- a/src/api/overseasFailure.test.mjs
+++ b/src/api/overseasFailure.test.mjs
@@ -26,8 +26,8 @@ test('does not accept malformed or data-less successful responses', () => {
assert.equal(isApiEnvelope({ code: '200', message: 'OK', data: [] }), true)
})
-test('overseas transport handles business-code and HTTP security failures', async () => {
- const transport = await readFile(new URL('./overseasHttp.ts', import.meta.url), 'utf8')
- assert.match(transport, /if \(isApiFailure\(response.data\)\)/)
+test('unified transport handles business-code and HTTP security failures', async () => {
+ const transport = await readFile(new URL('./http.ts', import.meta.url), 'utf8')
+ assert.match(transport, /if \(isApiFailure\(invalid\)\)/)
assert.match(transport, /isApiEnvelope\(body\) \|\| isApiFailure\(body\)/)
})
diff --git a/src/api/overseasHttp.ts b/src/api/overseasHttp.ts
deleted file mode 100644
index e567603..0000000
--- a/src/api/overseasHttp.ts
+++ /dev/null
@@ -1,91 +0,0 @@
-import axios, { type AxiosRequestConfig, type InternalAxiosRequestConfig } from 'axios'
-import { parseApiJsonResponse } from '@chongchong/shared'
-import { ApiError, isApiEnvelope, isApiFailure } from '@/api/types'
-import { getOverseasAccessToken } from '@/api/session'
-import { refreshAuthenticatedSession } from '@/api/http'
-
-interface RetryableRequest extends InternalAxiosRequestConfig { _retriedOverseasAuth?: boolean }
-
-const transport = axios.create({
- baseURL: import.meta.env.VITE_OVERSEAS_API_BASE_URL || '/overseas-api',
- timeout: 15_000,
- transformResponse: [(data: unknown) => parseApiJsonResponse(data)],
-})
-
-async function requireToken() {
- const current = getOverseasAccessToken()
- if (current) return current
- await refreshAuthenticatedSession()
- const refreshed = getOverseasAccessToken()
- if (!refreshed) throw new ApiError('当前环境未签发海外访问令牌', 'OVERSEAS_TOKEN_UNAVAILABLE')
- return refreshed
-}
-
-transport.interceptors.request.use(async (config) => {
- config.headers.Authorization = `Bearer ${await requireToken()}`
- return config
-})
-
-transport.interceptors.response.use((response) => response, async (error) => {
- const request = error.config as RetryableRequest | undefined
- if (error.response?.status !== 401 || !request || request._retriedOverseasAuth) throw error
- request._retriedOverseasAuth = true
- await refreshAuthenticatedSession()
- request.headers.Authorization = `Bearer ${await requireToken()}`
- return transport.request(request)
-})
-
-export async function overseasApiRequest(config: AxiosRequestConfig) {
- try {
- const response = await transport.request(config)
- if (isApiFailure(response.data)) {
- throw new ApiError(response.data.message, response.data.code, response.status, undefined,
- response.data.traceId, response.data.requestId)
- }
- if (!isApiEnvelope(response.data) || response.data.code !== '200') {
- throw new ApiError(
- isApiEnvelope(response.data) ? response.data.message : '海外接口响应格式错误',
- isApiEnvelope(response.data) ? response.data.code : 'INVALID_RESPONSE',
- undefined,
- undefined,
- isApiEnvelope(response.data) ? response.data.traceId : null,
- isApiEnvelope(response.data) ? response.data.requestId : null,
- )
- }
- return response.data.data as T
- } catch (error) {
- if (error instanceof ApiError) throw error
- if (axios.isAxiosError(error)) {
- const body = error.response?.data
- if (isApiEnvelope(body) || isApiFailure(body)) {
- throw new ApiError(body.message, body.code, error.response?.status, undefined, body.traceId, body.requestId)
- }
- throw new ApiError(error.code === 'ERR_NETWORK' ? '无法连接海外服务' : error.message, error.code || 'OVERSEAS_HTTP_ERROR', error.response?.status)
- }
- throw new ApiError(error instanceof Error ? error.message : '海外接口请求失败')
- }
-}
-
-export async function overseasBlobRequest(config: AxiosRequestConfig) {
- try {
- const response = await transport.request({ ...config, responseType: 'blob' })
- return response.data
- } catch (error) {
- if (error instanceof ApiError) throw error
- if (axios.isAxiosError(error)) {
- const body = error.response?.data
- if (body instanceof Blob && body.type.includes('json')) {
- try {
- const parsed: unknown = JSON.parse(await body.text())
- if (isApiEnvelope(parsed) || isApiFailure(parsed)) {
- throw new ApiError(parsed.message, parsed.code, error.response?.status, undefined, parsed.traceId, parsed.requestId)
- }
- } catch (parseError) {
- if (parseError instanceof ApiError) throw parseError
- }
- }
- throw new ApiError(error.code === 'ERR_NETWORK' ? '无法连接海外服务' : error.message, error.code || 'OVERSEAS_HTTP_ERROR', error.response?.status)
- }
- throw new ApiError(error instanceof Error ? error.message : '海外文件下载失败')
- }
-}
diff --git a/src/api/session.test.mjs b/src/api/session.test.mjs
index ce48286..b305710 100644
--- a/src/api/session.test.mjs
+++ b/src/api/session.test.mjs
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
+ accessTokenIdentity,
ACCESS_TOKEN_KEY,
REFRESH_TOKEN_KEY,
REMEMBERED_LOGIN_ID_KEY,
@@ -56,3 +57,34 @@ test('remembers only the login id and keeps it when session tokens are cleared',
persistRememberedLoginId('merchant-user', false)
assert.equal(getRememberedLoginId(), '')
})
+
+test('normal token rotation never removes either shared token between writes', () => {
+ installStorage()
+ localStorage.setItem(ACCESS_TOKEN_KEY, 'old-access')
+ localStorage.setItem(REFRESH_TOKEN_KEY, 'old-refresh')
+ const removed = []
+ const remove = localStorage.removeItem
+ localStorage.removeItem = key => { removed.push(key); remove(key) }
+ persistTokenPair({ accessToken: 'new-access', refreshToken: 'new-refresh' })
+ assert.ok(!removed.includes(ACCESS_TOKEN_KEY))
+ assert.ok(!removed.includes(REFRESH_TOKEN_KEY))
+})
+
+function rawIdentity(overrides = '') {
+ return `test.${Buffer.from(`{"user_id":2107003446494334978,"tenant_id":2107003446494334978,"membership_id":2107003446494334978,"app_code":"MERCHANT"${overrides}}`).toString('base64url')}.test`
+}
+test('identity preserves low digits of numeric 19 digit user customer and membership',()=>{
+ const original=rawIdentity();const json=Buffer.from(original.split('.')[1],'base64url').toString();
+ for(const field of ['user_id','tenant_id','membership_id']){
+ const changed=json.replace(`"${field}":2107003446494334978`,`"${field}":2107003446494334979`)
+ assert.notEqual(accessTokenIdentity(original),accessTokenIdentity(`test.${Buffer.from(changed).toString('base64url')}.test`))
+ }
+})
+test('identity string and numeric integers are equivalent without changing other strings',()=>{
+ const a=rawIdentity();const raw=Buffer.from(a.split('.')[1],'base64url').toString();
+ const b=`test.${Buffer.from(raw.replaceAll(':2107003446494334978',':"2107003446494334978"')).toString('base64url')}.test`
+ assert.equal(accessTokenIdentity(a),accessTokenIdentity(b))
+ const decorated=rawIdentity(',"username":'+JSON.stringify('number 2107003446494334979 and "tenant_id":2107003446494334979')+',"note":"1234567890123456789"')
+ assert.equal(accessTokenIdentity(a),accessTokenIdentity(decorated))
+ assert.ok(accessTokenIdentity(a).includes('2107003446494334978'))
+})
diff --git a/src/api/session.ts b/src/api/session.ts
index 2f2dd3b..fb639ab 100644
--- a/src/api/session.ts
+++ b/src/api/session.ts
@@ -1,7 +1,5 @@
export const ACCESS_TOKEN_KEY = 'cc-client-access-token'
export const REFRESH_TOKEN_KEY = 'cc-client-refresh-token'
-export const OVERSEAS_ACCESS_TOKEN_KEY = 'cc-client-overseas-access-token'
-export const OVERSEAS_EXPIRES_AT_KEY = 'cc-client-overseas-expires-at'
export const REMEMBERED_LOGIN_ID_KEY = 'cc-client-remembered-login-id'
export interface TokenPair {
@@ -10,14 +8,8 @@ export interface TokenPair {
expiresIn: number
refreshToken: string
refreshExpiresIn: number
- overseasAccessToken?: string | null
- overseasExpiresIn?: number
}
-function activeStorage() {
- if (localStorage.getItem(ACCESS_TOKEN_KEY)) return localStorage
- return sessionStorage
-}
export function getAccessToken() {
return localStorage.getItem(ACCESS_TOKEN_KEY) || sessionStorage.getItem(ACCESS_TOKEN_KEY) || ''
@@ -27,21 +19,40 @@ export function getRefreshToken() {
return localStorage.getItem(REFRESH_TOKEN_KEY) || sessionStorage.getItem(REFRESH_TOKEN_KEY) || ''
}
-export function getOverseasAccessToken() {
- const storage = activeStorage()
- const expiresAt = Number(storage.getItem(OVERSEAS_EXPIRES_AT_KEY) || 0)
- if (expiresAt <= Date.now() + 30_000) return ''
- return storage.getItem(OVERSEAS_ACCESS_TOKEN_KEY) || ''
+// Only binds a retry to its original identity; never grants permissions. Expired
+// access tokens remain readable here because they are the normal refresh trigger.
+export function accessTokenIdentity(token: string): string | null {
+ try {
+ const encoded = token.split('.')[1]
+ if (!encoded) return null
+ const normalized = encoded.replace(/-/g, '+').replace(/_/g, '/')
+ const bytes = Uint8Array.from(atob(normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')), character => character.charCodeAt(0))
+ const json = new TextDecoder().decode(bytes)
+ // Match whole JSON strings first so digits inside arbitrary string values
+ // are never rewritten. Preserve integer identity claims before JSON.parse
+ // can round them; number/string representations then share one identity.
+ const exactIdentityJson = json.replace(/"(?:\\.|[^"\\])*"|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/g, (part, offset: number) => {
+ if (!/^-?\d+$/.test(part)) return part
+ const key = json.slice(0, offset).match(/"(user_id|tenant_id|membership_id|operator_tenant_id)"\s*:\s*$/)
+ return key ? JSON.stringify(part) : part
+ })
+ const value = JSON.parse(exactIdentityJson)
+ if (!value?.user_id || !value.tenant_id || !value.membership_id || value.app_code !== 'MERCHANT') return null
+ return JSON.stringify([value.user_id, value.tenant_id, value.membership_id, value.operator_tenant_id, value.app_code, value.client_type].map(item => item == null ? null : String(item)))
+ } catch {
+ return null
+ }
}
+
export function persistTokenPair(tokens: TokenPair) {
- clearSessionTokens()
+ // Replace the pair without publishing a transient logout to other tabs.
+ sessionStorage.removeItem(ACCESS_TOKEN_KEY)
+ sessionStorage.removeItem(REFRESH_TOKEN_KEY)
+ sessionStorage.removeItem('cc-client-token')
+ localStorage.removeItem('cc-client-token')
localStorage.setItem(ACCESS_TOKEN_KEY, tokens.accessToken)
localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refreshToken)
- if (tokens.overseasAccessToken && Number(tokens.overseasExpiresIn) > 0) {
- localStorage.setItem(OVERSEAS_ACCESS_TOKEN_KEY, tokens.overseasAccessToken)
- localStorage.setItem(OVERSEAS_EXPIRES_AT_KEY, String(Date.now() + Number(tokens.overseasExpiresIn) * 1000))
- }
}
export function getRememberedLoginId() {
@@ -62,8 +73,6 @@ export function clearSessionTokens() {
for (const storage of [localStorage, sessionStorage]) {
storage.removeItem(ACCESS_TOKEN_KEY)
storage.removeItem(REFRESH_TOKEN_KEY)
- storage.removeItem(OVERSEAS_ACCESS_TOKEN_KEY)
- storage.removeItem(OVERSEAS_EXPIRES_AT_KEY)
storage.removeItem('cc-client-token')
}
}
diff --git a/src/api/unifiedHttp.test.mjs b/src/api/unifiedHttp.test.mjs
new file mode 100644
index 0000000..b051b7f
--- /dev/null
+++ b/src/api/unifiedHttp.test.mjs
@@ -0,0 +1,222 @@
+import assert from 'node:assert/strict'
+import test from 'node:test'
+import { readFileSync } from 'node:fs'
+import ts from 'typescript'
+import axios from 'axios'
+
+function compile(name, dependencies = {}) {
+ const source = readFileSync(new URL(name, import.meta.url), 'utf8')
+ .replace('import.meta.env.VITE_API_BASE_URL', "'/api'")
+ const code = ts.transpileModule(source, {
+ compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true },
+ }).outputText
+ const exports = {}
+ new Function('require', 'exports', code)((id) => {
+ assert.ok(id in dependencies, `unexpected dependency ${id}`)
+ return dependencies[id]
+ }, exports)
+ return exports
+}
+
+function access(user, tenant = 1, membership = 1, nonce = 'original') {
+ return `test.${Buffer.from(JSON.stringify({ user_id: user, tenant_id: tenant, membership_id: membership, app_code: 'MERCHANT', nonce })).toString('base64url')}.test`
+}
+const oldAccess = access(1)
+const newAccess = access(1, 1, 1, 'rotated')
+const otherAccess = access(2, 2, 2)
+
+function harness(adapter) {
+ let token = oldAccess
+ let refreshToken = 'old-refresh'
+ const http = compile('./http.ts', {
+ axios: { ...axios, create: (config) => axios.create({ ...config, adapter }) },
+ '@chongchong/shared': { parseApiJsonResponse: (data) => data },
+ '@/api/session': {
+ accessTokenIdentity: compile('./session.ts').accessTokenIdentity,
+ getAccessToken: () => token, getRefreshToken: () => refreshToken,
+ persistTokenPair: (tokens) => { token = tokens.accessToken; refreshToken = tokens.refreshToken },
+ clearSessionTokens: () => { token = ''; refreshToken = '' },
+ },
+ '@/api/errorDetails': compile('./errorDetails.ts'),
+ '@/api/types': compile('./types.ts'),
+ '@/api/crossTabRefresh': compile('./crossTabRefresh.ts'),
+ })
+ return { ...http, setSession: (access, refresh) => { token = access; refreshToken = refresh }, session: () => ({ token, refreshToken }) }
+}
+
+test('order and lastleg 401s share one refresh and replay the unified access token', async () => {
+ let refreshes = 0
+ const calls = []
+ const http = harness(async (config) => {
+ calls.push({ url: config.url, base: config.baseURL, token: config.headers.Authorization })
+ if (config.url === '/auth/refresh') {
+ refreshes++
+ await new Promise((resolve) => setTimeout(resolve, 10))
+ return { config, status: 200, headers: {}, data: { code: '200', message: 'OK', data: { accessToken: newAccess, refreshToken: 'new-refresh' } } }
+ }
+ if (config.headers.Authorization === `Bearer ${oldAccess}`) {
+ throw new axios.AxiosError('Expired', 'ERR_BAD_REQUEST', config, null, { status: 401, data: { code: 'A0401', message: 'Expired' } })
+ }
+ return { config, status: 200, headers: {}, data: { code: '200', message: 'OK', data: config.url } }
+ })
+ const urls = ['/merchant/v1/orders', '/merchant/v1/last-leg/inbound-plans']
+ assert.deepEqual(await Promise.all(urls.map((url) => http.apiRequest({ url }))), urls)
+ assert.equal(refreshes, 1)
+ assert.ok(calls.every((call) => call.base === '/api'))
+ assert.equal(calls.find((call) => call.url === '/auth/refresh').token, undefined)
+ assert.equal(calls.filter((call) => call.token === `Bearer ${newAccess}`).length, 2)
+})
+
+test('successful order submit with explicit null data is accepted', async () => {
+ const http = harness(async (config) => ({
+ config, status: 200, headers: {}, data: { code: '200', message: 'success', data: null },
+ }))
+ assert.equal(await http.apiRequest({ method: 'POST', url: '/merchant/v1/orders/first-leg/order-1/submit' }), null)
+})
+
+test('data-less security failures preserve diagnostics and malformed success is rejected', async () => {
+ for (const data of [{ code: 'A0403', message: 'Denied', traceId: 'trace-1', requestId: 'request-1' }, { code: '200', message: 'OK' }]) {
+ const http = harness(async (config) => ({ config, status: 200, headers: {}, data }))
+ await assert.rejects(http.apiRequest({ url: '/merchant/v1/last-leg/inbound-plans' }), (error) => {
+ assert.equal(error.code, data.code === '200' ? 'INVALID_RESPONSE' : 'A0403')
+ if (data.code !== '200') { assert.equal(error.traceId, 'trace-1'); assert.equal(error.requestId, 'request-1') }
+ return true
+ })
+ }
+})
+
+test('late refresh response cannot overwrite a session changed in another tab', async () => {
+ let complete, started
+ const ready = new Promise(resolve => { started = resolve })
+ const response = new Promise(resolve => { complete = resolve })
+ const http = harness(async config => { started(); await response; return { config, status: 200, headers: {}, data: { code: '200', message: 'OK', data: { accessToken: oldAccess, refreshToken: 'stale-refresh' } } } })
+ const pending = http.refreshAuthenticatedSession()
+ await ready
+ http.setSession(otherAccess, 'other-refresh')
+ complete()
+ await pending.catch(error => assert.equal(error.code, 'SESSION_CHANGED'))
+ assert.deepEqual(http.session(), { token: otherAccess, refreshToken: 'other-refresh' })
+})
+
+function deferred() { let resolve; const promise = new Promise(yes => { resolve = yes }); return { promise, resolve } }
+
+function unauthorized(config) {
+ return new axios.AxiosError('Expired', 'ERR_BAD_REQUEST', config, null, { status: 401, data: { code: 'A0401', message: 'Expired' } })
+}
+
+test('late A write 401 after user, customer or membership switch never refreshes or replays under B', async () => {
+ for (const next of [access(2), access(1, 2), access(1, 1, 2)]) {
+ const sent = deferred(), release = deferred(), calls = []
+ const http = harness(async config => {
+ calls.push(config.url); sent.resolve(); await release.promise
+ throw unauthorized(config)
+ })
+ const pending = http.apiRequest({ method: 'POST', url: '/merchant/v1/orders/first-leg/one/submit', data: { expectedVersion: 1 } })
+ const rejected = assert.rejects(pending, error => error.code === 'SESSION_CHANGED')
+ await sent.promise; http.setSession(next, 'refresh-b'); release.resolve(); await rejected
+ assert.deepEqual(calls, ['/merchant/v1/orders/first-leg/one/submit'])
+ assert.deepEqual(http.session(), { token: next, refreshToken: 'refresh-b' })
+ }
+})
+
+test('switch during in-flight refresh cannot reuse B token to replay an A write', async () => {
+ const refreshing = deferred(), release = deferred(), calls = []
+ const http = harness(async config => {
+ calls.push(config.url)
+ if (config.url === '/auth/refresh') {
+ refreshing.resolve(); await release.promise
+ return { config, status: 200, headers: {}, data: { code: '200', message: 'OK', data: { accessToken: newAccess, refreshToken: 'new-refresh' } } }
+ }
+ throw unauthorized(config)
+ })
+ const pending = http.apiRequest({ method: 'POST', url: '/merchant/v1/orders/first-leg/one/submit' })
+ const rejected = assert.rejects(pending, error => error.code === 'SESSION_CHANGED')
+ await refreshing.promise; http.setSession(otherAccess, 'refresh-b'); release.resolve(); await rejected
+ assert.deepEqual(calls, ['/merchant/v1/orders/first-leg/one/submit', '/auth/refresh'])
+ assert.deepEqual(http.session(), { token: otherAccess, refreshToken: 'refresh-b' })
+})
+
+test('refresh lock wait reuses same-identity rotation but rejects changed-identity reuse', async t => {
+ const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator')
+ t.after(() => { if (originalNavigator) Object.defineProperty(globalThis, 'navigator', originalNavigator); else delete globalThis.navigator })
+ for (const next of [newAccess, otherAccess]) {
+ const waiting = deferred(), release = deferred(), calls = []
+ Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { locks: { request: async (_name, _options, run) => {
+ waiting.resolve(); await release.promise; return run()
+ } } } })
+ const http = harness(async config => {
+ calls.push({ url: config.url, authorization: config.headers.Authorization })
+ if (config.headers.Authorization === `Bearer ${oldAccess}`) throw unauthorized(config)
+ return { config, status: 200, headers: {}, data: { code: '200', message: 'OK', data: null } }
+ })
+ const pending = http.apiRequest({ method: 'POST', url: '/merchant/v1/orders/first-leg/one/submit' })
+ const outcome = next === otherAccess ? assert.rejects(pending, error => error.code === 'SESSION_CHANGED') : pending
+ await waiting.promise; http.setSession(next, 'rotated-refresh'); release.resolve(); await outcome
+ assert.equal(calls.some(call => call.url === '/auth/refresh'), false)
+ assert.equal(calls.length, next === otherAccess ? 1 : 2)
+ assert.deepEqual(http.session(), { token: next, refreshToken: 'rotated-refresh' })
+ }
+})
+
+function authApi(http) { return compile('../modules/auth/api/auth.ts', { '@/api/http': http }) }
+function ok(config, data = null) { return { config, status: 200, headers: {}, data: { code: '200', message: 'OK', data } } }
+test('logout after immediate local clear revokes captured Bearer and Refresh', async () => {
+ const calls=[]; const http=harness(async c=>{calls.push(c);return ok(c)})
+ http.setSession('', '');await authApi(http).logoutMerchant(oldAccess,'old-refresh')
+ assert.equal(calls.length,1);assert.equal(calls[0].headers.Authorization,`Bearer ${oldAccess}`)
+ assert.deepEqual(JSON.parse(calls[0].data),{refreshToken:'old-refresh'});assert.deepEqual(http.session(),{token:'',refreshToken:''})
+})
+test('only explicit logout401 refreshes once and revokes same-identity replacement without storing it', async () => {
+ const calls=[];const http=harness(async c=>{calls.push(c);if(c.url==='/auth/refresh')return ok(c,{accessToken:newAccess,refreshToken:'new-refresh'});if(c.headers.Authorization===`Bearer ${oldAccess}`)throw unauthorized(c);return ok(c)})
+ http.setSession('','');await authApi(http).logoutMerchant(oldAccess,'old-refresh')
+ assert.deepEqual(calls.map(x=>x.url),['/auth/logout','/auth/refresh','/auth/logout']);assert.equal(calls[2].headers.Authorization,`Bearer ${newAccess}`);assert.equal(JSON.parse(calls[2].data).refreshToken,'new-refresh');assert.deepEqual(http.session(),{token:'',refreshToken:''})
+})
+test('unknown logout failure never refreshes; changed identity cannot be revoked as replacement', async () => {
+ for(const mode of ['network','wrongIdentity','second401']){const calls=[];const http=harness(async c=>{calls.push(c);if(mode==='network')throw new axios.AxiosError('offline',axios.AxiosError.ERR_NETWORK,c);if(c.url==='/auth/refresh')return ok(c,{accessToken:mode==='wrongIdentity'?otherAccess:newAccess,refreshToken:'replacement'});throw unauthorized(c)})
+ http.setSession('','');await assert.rejects(authApi(http).logoutMerchant(oldAccess,'old-refresh'));assert.equal(calls.length,mode==='network'?1:mode==='wrongIdentity'?2:3);assert.deepEqual(http.session(),{token:'',refreshToken:''})}
+})
+test('logout consumes already inflight refresh once while old business caller cannot revive session',async()=>{
+ const started=deferred(),release=deferred(),calls=[];const http=harness(async c=>{calls.push(c);if(c.url==='/auth/refresh'){started.resolve();await release.promise;return ok(c,{accessToken:newAccess,refreshToken:'new-refresh'})}return ok(c)})
+ const refreshing=http.refreshAuthenticatedSession();const rejected=assert.rejects(refreshing,e=>e.code==='SESSION_CHANGED');await started.promise
+ const leaving=authApi(http).logoutMerchant(oldAccess,'old-refresh');http.setSession('','');release.resolve();await leaving;await rejected
+ assert.deepEqual(calls.map(x=>x.url),['/auth/refresh','/auth/logout']);assert.equal(calls[1].headers.Authorization,`Bearer ${newAccess}`);assert.deepEqual(http.session(),{token:'',refreshToken:''})
+})
+test('late successful business response after logout rejects even after same-identity new login',async()=>{
+ const started=deferred(),release=deferred();const http=harness(async c=>{if(c.url==='/merchant/v1/orders'){started.resolve();await release.promise}return ok(c,'old-data')})
+ const pending=http.apiRequest({url:'/merchant/v1/orders'});const rejected=assert.rejects(pending,e=>e.code==='SESSION_CHANGED');await started.promise
+ await authApi(http).logoutMerchant(oldAccess,'old-refresh');http.setSession(newAccess,'new-login-refresh');release.resolve();await rejected;assert.equal(http.session().refreshToken,'new-login-refresh')
+})
+
+test('inflight refresh unknown outcome stops logout without competing retry and preserves later login',async()=>{
+ const started=deferred(),release=deferred(),calls=[];const http=harness(async c=>{calls.push(c);started.resolve();await release.promise;throw new axios.AxiosError('offline',axios.AxiosError.ERR_NETWORK,c)})
+ const refreshing=http.refreshAuthenticatedSession();const rejected=assert.rejects(refreshing);await started.promise
+ const leaving=authApi(http).logoutMerchant(oldAccess,'old-refresh');const failure=assert.rejects(leaving,e=>e.code==='NETWORK_ERROR');http.setSession(otherAccess,'other-refresh');release.resolve();await failure;await rejected
+ assert.equal(calls.length,1);assert.deepEqual(http.session(),{token:otherAccess,refreshToken:'other-refresh'})
+})
+test('logout401 refresh and replacement revocation cannot overwrite later login',async()=>{
+ const started=deferred(),release=deferred(),calls=[];const http=harness(async c=>{calls.push(c);if(c.url==='/auth/refresh'){started.resolve();await release.promise;return ok(c,{accessToken:newAccess,refreshToken:'new-refresh'})}if(c.headers.Authorization===`Bearer ${oldAccess}`)throw unauthorized(c);return ok(c)})
+ const leaving=authApi(http).logoutMerchant(oldAccess,'old-refresh');http.setSession('','');await started.promise;http.setSession(otherAccess,'other-refresh');release.resolve();await leaving
+ assert.equal(calls[2].headers.Authorization,`Bearer ${newAccess}`);assert.deepEqual(http.session(),{token:otherAccess,refreshToken:'other-refresh'})
+})
+test('late old business401 after logout does not refresh or replay under same-identity new login',async()=>{
+ const started=deferred(),release=deferred(),calls=[];const http=harness(async c=>{calls.push(c);if(c.url==='/merchant/v1/orders/first-leg/one/submit'){started.resolve();await release.promise;throw unauthorized(c)}return ok(c)})
+ const request=http.apiRequest({method:'POST',url:'/merchant/v1/orders/first-leg/one/submit'});const rejected=assert.rejects(request,e=>e.code==='SESSION_CHANGED');await started.promise;await authApi(http).logoutMerchant(oldAccess,'old-refresh');http.setSession(newAccess,'new-login-refresh');release.resolve();await rejected
+ assert.deepEqual(calls.map(x=>x.url),['/merchant/v1/orders/first-leg/one/submit','/auth/logout']);assert.equal(http.session().refreshToken,'new-login-refresh')
+})
+
+test('same identity new pair logout does not adopt another session inflight refresh',async()=>{
+ const started=deferred(),release=deferred(),calls=[];const http=harness(async c=>{calls.push(c);if(c.url==='/auth/refresh'){started.resolve();await release.promise;return ok(c,{accessToken:newAccess,refreshToken:'old-session-rotated'})}return ok(c)})
+ const refreshing=http.refreshAuthenticatedSession();const rejected=assert.rejects(refreshing);await started.promise;http.setSession(newAccess,'new-login-refresh')
+ const leaving=authApi(http).logoutMerchant(newAccess,'new-login-refresh');http.setSession('','');await leaving
+ assert.equal(calls[1].headers.Authorization,`Bearer ${newAccess}`);assert.equal(JSON.parse(calls[1].data).refreshToken,'new-login-refresh');release.resolve();await rejected;assert.deepEqual(http.session(),{token:'',refreshToken:''})
+})
+
+test('logout rejects refreshed 19 digit identity differing only in low digit',async()=>{
+ const raw='{"user_id":2107003446494334978,"tenant_id":2107003446494334978,"membership_id":2107003446494334978,"app_code":"MERCHANT"}'
+ const original=`test.${Buffer.from(raw).toString('base64url')}.test`
+ for(const field of ['user_id','tenant_id','membership_id']){
+ const changed=raw.replace(`"${field}":2107003446494334978`,`"${field}":2107003446494334979`),calls=[]
+ const http=harness(async c=>{calls.push(c);if(c.url==='/auth/refresh')return ok(c,{accessToken:`test.${Buffer.from(changed).toString('base64url')}.test`,refreshToken:'replacement'});throw unauthorized(c)})
+ http.setSession('','');await assert.rejects(authApi(http).logoutMerchant(original,'original-refresh'),e=>e.code==='SESSION_CHANGED');assert.equal(calls.length,2)
+ }
+})
diff --git a/src/components/common/DataTable.test.mjs b/src/components/common/DataTable.test.mjs
index 4466aa9..47d7d96 100644
--- a/src/components/common/DataTable.test.mjs
+++ b/src/components/common/DataTable.test.mjs
@@ -1,9 +1,76 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
+import { parse, compileScript } from 'vue/compiler-sfc'
+import ts from 'typescript'
+import * as vue from 'vue'
const source = readFileSync(new URL('./DataTable.vue', import.meta.url), 'utf8')
+function paginationHarness(t) {
+ const descriptor = parse(source).descriptor
+ const compiled = compileScript(descriptor, { id: 'data-table-pagination-test' })
+ const code = ts.transpileModule(compiled.content, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText
+ const exports = {}
+ const deps = { vue, '@lucide/vue': { ChevronLeft: {}, ChevronRight: {} }, './tableActions': {} }
+ new Function('require', 'exports', code)(id => { assert.ok(id in deps, id); return deps[id] }, exports)
+ const component = exports.default
+ component.render = () => null
+ const state = vue.reactive({ page: 5, total: 5542, loading: false, error: false })
+ const emitted = []
+ const renderer = vue.createRenderer({
+ createElement: tag => ({ tag }), createText: text => ({ text }), createComment: text => ({ text }),
+ insert() {}, remove() {}, setElementText() {}, setText() {}, patchProp() {},
+ parentNode: () => null, nextSibling: () => null,
+ })
+ const app = renderer.createApp({ render: () => vue.h(component, {
+ data: [], columns: [], page: state.page, pageSize: 50, total: state.total, loading: state.loading,
+ 'onUpdate:page': value => { emitted.push(value); state.page = value },
+ }, state.error ? { error: () => vue.h('div', 'Request failed') } : {}) })
+ app.mount({})
+ t.after(() => app.unmount())
+ return { state, emitted }
+}
+
+test('server-page loading must not reset the requested page when total temporarily clears', async t => {
+ const { state, emitted } = paginationHarness(t)
+ state.loading = true; state.total = 0
+ await vue.nextTick()
+ assert.equal(state.page, 5)
+ assert.deepEqual(emitted, [])
+ state.total = 5542; state.loading = false
+ await vue.nextTick()
+ assert.equal(state.page, 5)
+ assert.deepEqual(emitted, [])
+})
+
+test('failed server page retains its page for explicit retry and emits no automatic fallback request', async t => {
+ const { state, emitted } = paginationHarness(t)
+ state.loading = true; state.total = 0
+ await vue.nextTick()
+ state.error = true; state.loading = false
+ await vue.nextTick()
+ assert.equal(state.page, 5)
+ assert.deepEqual(emitted, [])
+ state.error = false; state.loading = true
+ await vue.nextTick()
+ state.total = 5542; state.loading = false
+ await vue.nextTick()
+ assert.equal(state.page, 5)
+ assert.deepEqual(emitted, [])
+})
+
+test('settled successful totals still clamp out-of-range and empty pages', async t => {
+ const { state, emitted } = paginationHarness(t)
+ state.total = 120
+ await vue.nextTick()
+ assert.equal(state.page, 3)
+ state.total = 0
+ await vue.nextTick()
+ assert.equal(state.page, 1)
+ assert.deepEqual(emitted, [3, 1])
+})
+
test('keeps action-only table toolbars on the left', () => {
assert.match(source, /'data-table__toolbar--actions-only': !\$slots\.toolbar/)
assert.match(source, /v-if="\$slots\.toolbar" class="data-table__toolbar-heading"/)
diff --git a/src/components/common/DataTable.vue b/src/components/common/DataTable.vue
index 3ef382c..cc318d5 100644
--- a/src/components/common/DataTable.vue
+++ b/src/components/common/DataTable.vue
@@ -88,7 +88,9 @@ watch([pageSize, normalizedPageSizes], ([value, sizes]) => {
page.value = 1
}, { immediate: true })
-watch([page, pageCount], ([value, count]) => {
+watch([page, pageCount, () => props.loading, () => Boolean(slots.error)], ([value, count, loading, hasError]) => {
+ // A cleared total is not an authoritative page count while loading or showing a request error.
+ if (loading || hasError) return
const nextValue = normalizePage(value, count)
if (value !== nextValue) page.value = nextValue
}, { immediate: true })
diff --git a/src/deployment/blueGreenDeployment.test.mjs b/src/deployment/blueGreenDeployment.test.mjs
deleted file mode 100644
index 35a34f9..0000000
--- a/src/deployment/blueGreenDeployment.test.mjs
+++ /dev/null
@@ -1,28 +0,0 @@
-import assert from 'node:assert/strict'
-import { readFile } from 'node:fs/promises'
-import test from 'node:test'
-
-const script = (await readFile(new URL('../../scripts/deploy-production.sh', import.meta.url), 'utf8'))
- .replaceAll('\r\n', '\n')
-const compose = (await readFile(new URL('../../docker-compose.production.yml', import.meta.url), 'utf8'))
- .replaceAll('\r\n', '\n')
-
-test('client production deployment switches healthy blue-green slots atomically', () => {
- const start = script.indexOf('compose run -d --no-deps --name "$candidate"')
- const health = script.indexOf('docker exec "$candidate" wget')
- const nginx = script.indexOf('nginx -t || ! nginx -s reload')
- const stop = script.indexOf('docker stop --time 30 "$active_container"')
- assert.ok(start >= 0 && start < health && health < nginx && nginx < stop)
- assert.match(script, /target_port=\$\(\(base_port \+ 20000\)\)/)
- assert.match(script, /target_port=\$\(\(base_port \+ 10000\)\)/)
- assert.match(script, /00-blue-green-admin-web\.conf/)
- assert.match(script, /00-blue-green-client-web\.conf/)
- assert.doesNotMatch(script, /nginx_config=\/www\/server\/panel\/vhost\/nginx\/(?:admin|app)\.buggoglobal\.com\.conf/)
- assert.match(script, /cp -f "\$backup" "\$nginx_config"/)
-})
-
-test('client uses stable active gateway aliases on the routing network', () => {
- assert.match(compose, /API_UPSTREAM: "http:\/\/gateway-active:9000"/)
- assert.match(compose, /OVERSEAS_API_UPSTREAM: "http:\/\/gateway-overseas-active:9300"/)
- assert.match(compose, /name: firstleg-production-routing/)
-})
diff --git a/src/deployment/nginxConfig.test.mjs b/src/deployment/nginxConfig.test.mjs
index 1934f09..83548bf 100644
--- a/src/deployment/nginxConfig.test.mjs
+++ b/src/deployment/nginxConfig.test.mjs
@@ -1,67 +1,63 @@
import assert from 'node:assert/strict'
-import { readFileSync } from 'node:fs'
+import { readFileSync, existsSync } from 'node:fs'
import test from 'node:test'
-const config = readFileSync(new URL('../../nginx/default.conf.template', import.meta.url), 'utf8')
-const testCompose = readFileSync(new URL('../../docker-compose.test.yml', import.meta.url), 'utf8')
-const developmentCompose = readFileSync(new URL('../../docker-compose.development.yml', import.meta.url), 'utf8')
-const productionCompose = readFileSync(new URL('../../docker-compose.production.yml', import.meta.url), 'utf8')
-const jenkinsfile = readFileSync(new URL('../../Jenkinsfile', import.meta.url), 'utf8')
-const dockerfile = readFileSync(new URL('../../Dockerfile', import.meta.url), 'utf8')
+const root = new URL('../../', import.meta.url)
+const config = readFileSync(new URL('nginx/default.conf.template', root), 'utf8')
+const compose = readFileSync(new URL('docker-compose.test.yml', root), 'utf8')
+const jenkins = readFileSync(new URL('Jenkinsfile', root), 'utf8')
+const vite = readFileSync(new URL('vite.config.ts', root), 'utf8')
-test('allows 50 MiB uploads with multipart overhead through both API proxies', () => {
- assert.match(config, /location \/api\/ \{[\s\S]*?client_max_body_size 55m;/)
- assert.match(config, /location \/overseas-api\/ \{[\s\S]*?client_max_body_size 55m;/)
- assert.equal(config.match(/client_max_body_size 55m;/g)?.length, 2)
-})
-
-test('does not cache the SPA entry while keeping hashed assets immutable', () => {
- assert.match(config, /location = \/index\.html[\s\S]*no-store, no-cache, must-revalidate/)
- assert.match(config, /location \/assets\/[\s\S]*max-age=31536000, immutable/)
- assert.match(config, /location \/ \{[\s\S]*no-store, no-cache, must-revalidate[\s\S]*try_files \$uri \$uri\/ \/index\.html/)
-})
-
-test('routes domestic and overseas browser APIs to their test gateways without duplicate api prefixes', () => {
- assert.match(testCompose, /- "host\.docker\.internal:host-gateway"/)
- assert.match(testCompose, /API_UPSTREAM: "\$\{API_UPSTREAM:-http:\/\/host\.docker\.internal:9000\}"/)
- assert.match(testCompose, /OVERSEAS_API_UPSTREAM: "\$\{OVERSEAS_API_UPSTREAM:-http:\/\/host\.docker\.internal:9300\}"/)
- assert.doesNotMatch(testCompose, /http:\/\/test-api-(?:cn|global)\.buggoglobal\.com/)
- assert.match(testCompose, /VITE_ADMIN_WEB_ORIGIN: "\$\{VITE_ADMIN_WEB_ORIGIN:-https:\/\/test-admin\.buggoglobal\.com\}"/)
- assert.match(config, /location \/overseas-api\/ \{[\s\S]*?rewrite \^\/overseas-api\/\(\.\*\)\$ \/api\/\$1 break;[\s\S]*?proxy_pass \$\{OVERSEAS_API_UPSTREAM\};/)
+test('all business traffic uses one Gateway without rewriting API paths', () => {
+ assert.match(config, /location \/api\//)
+ assert.match(config, /proxy_pass \$\{API_UPSTREAM\};/)
+ assert.doesNotMatch(config, /overseas-api|OVERSEAS_API_UPSTREAM|rewrite/)
+ assert.match(config, /client_max_body_size 55m;/)
assert.match(config, /proxy_set_header Host \$proxy_host;/)
- assert.doesNotMatch(config, /\/api\/api/)
+ assert.doesNotMatch(vite, /OVERSEAS_API|AUTH_API_PROXY_TARGET|IAM_API_PROXY_TARGET|WAREHOUSE_API_PROXY_TARGET/)
})
-test('connects the development client proxy to both development gateways', () => {
- assert.match(developmentCompose, /API_UPSTREAM: "http:\/\/firstleg-development-gateway:9000"/)
- assert.match(developmentCompose, /OVERSEAS_API_UPSTREAM: "http:\/\/firstleg-development-gateway-overseas:9300"/)
- assert.match(developmentCompose, /- development-backend/)
- assert.match(developmentCompose, /- development-overseas-backend/)
- assert.match(developmentCompose, /development-overseas-backend:[\s\S]*?external: true[\s\S]*?name: firstleg-development-overseas-network/)
- assert.doesNotMatch(developmentCompose, /test-api|prod(?:uction)?/i)
+test('test deployment requires an explicit shared network, Gateway and version', () => {
+ for (const key of ['IMAGE_TAG']) {
+ assert.ok(compose.includes('${' + key + ':?'))
+ }
+ assert.ok(compose.includes('${API_UPSTREAM:-http://gateway:9000}'))
+ assert.ok(compose.includes('${TEST_NETWORK:-firstleg-test-network}'))
+ assert.match(compose, /external: true/)
+ assert.match(compose, /127\.0\.0\.1:/)
+ assert.doesNotMatch(compose, /overseas|host\.docker\.internal|production|development/)
+ assert.match(jenkins, /'java-builder' : 'java-test'/)
+ assert.match(jenkins, /docker compose -f docker-compose\.test\.yml config --quiet/)
+ assert.match(jenkins, /docker network inspect/)
+ assert.match(jenkins, /scripts\/deploy-production\.sh/)
+ assert.match(jenkins, /firstleg-production-ssh/)
+
})
-test('keeps local environments on target nodes and uses credential-managed SSH for production', () => {
- assert.match(jenkinsfile, /env\.JOB_BASE_NAME\?\.endsWith\('-prod'\) \? 'java-builder'/)
- assert.match(jenkinsfile, /DEVELOPMENT_COMPOSE_PROJECT = 'client-web'/)
- assert.match(jenkinsfile, /docker compose -p "\$DEVELOPMENT_COMPOSE_PROJECT" -f "\$DEVELOPMENT_COMPOSE_FILE" up -d/)
- assert.doesNotMatch(jenkinsfile, /\/var\/jenkins_home\/\.ssh|docker save/)
- assert.match(jenkinsfile, /credentialsId: 'firstleg-production-ssh'/)
- assert.match(jenkinsfile, /git rev-parse origin\/main/)
- assert.match(productionCompose, /127\.0\.0\.1:\$\{HOST_PORT:-9002\}:80/)
- assert.match(productionCompose, /API_UPSTREAM: "http:\/\/gateway-active:9000"/)
- assert.match(productionCompose, /OVERSEAS_API_UPSTREAM: "http:\/\/gateway-overseas-active:9300"/)
- assert.match(productionCompose, /name: firstleg-production-routing/)
- assert.match(productionCompose, /name: firstleg-prod-infra-network/)
+test('application shell is revalidated and missing chunks remain 404', () => {
+ assert.match(config, /location = \/index\.html/)
+ assert.match(config, /no-store/)
+ assert.match(config, /max-age=31536000, immutable/)
+ assert.match(config, /try_files \$uri =404;/)
+ assert.match(config, /try_files \$uri \$uri\/ \/index\.html;/)
})
-test('uses authenticated internal base images in CI', () => {
- assert.match(dockerfile, /ARG NODE_IMAGE=node:22-alpine/)
- assert.match(dockerfile, /ARG NGINX_IMAGE=nginx:1\.27-alpine/)
- assert.match(dockerfile, /FROM \$\{NODE_IMAGE\} AS build/)
- assert.match(dockerfile, /FROM \$\{NGINX_IMAGE\}/)
- assert.match(jenkinsfile, /NODE_BASE_IMAGE = '172\.31\.198\.7:10082\/java-project\/node:22-alpine'/)
- assert.match(jenkinsfile, /NGINX_BASE_IMAGE = '172\.31\.198\.7:10082\/java-project\/nginx:1\.27-alpine'/)
- assert.match(jenkinsfile, /withCredentials\(\[usernamePassword\(/)
- assert.match(jenkinsfile, /export DOCKER_CONFIG="\$\{WORKSPACE\}@tmp\/docker-auth"/)
+
+test('container build excludes local environment values and waits for Nginx health', () => {
+ const ignore = readFileSync(new URL('.dockerignore', root), 'utf8')
+ assert.match(ignore, /^\.env$/m)
+ assert.match(ignore, /^\.env\.\*$/m)
+ assert.match(compose, /healthcheck:/)
+ assert.match(compose, /http:\/\/127\.0\.0\.1\/healthz/)
+ assert.match(jenkins, /up -d --wait --wait-timeout 120/)
+})
+
+test('gray production is isolated from TEST and routes to the single Gateway', () => {
+ const production = readFileSync(new URL('docker-compose.production.yml', root), 'utf8')
+ assert.match(production, /name: firstleg-production-network/)
+ assert.match(production, /API_UPSTREAM: "http:\/\/gateway:9000"/)
+ assert.match(production, /127\.0\.0\.1:/)
+ assert.match(production, /healthcheck:/)
+ assert.doesNotMatch(production, /test-network|gateway-overseas|gateway-active|routing-network/)
+ assert.doesNotMatch(jenkins, /wecom|sendProductionWeComNotification/)
})
diff --git a/src/layouts/AppLayout.test.mjs b/src/layouts/AppLayout.test.mjs
index 35e8d25..272ee3e 100644
--- a/src/layouts/AppLayout.test.mjs
+++ b/src/layouts/AppLayout.test.mjs
@@ -1,6 +1,8 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
+import ts from 'typescript'
+import * as vue from 'vue'
const layoutSource = readFileSync(new URL('./AppLayout.vue', import.meta.url), 'utf8')
const shellStyles = readFileSync(new URL('../styles/shell.css', import.meta.url), 'utf8')
@@ -108,7 +110,7 @@ test('source contract: locks body scrolling while open and restores it on close
})
test('source contract: handles confirmation cancellation but rethrows unexpected logout errors', () => {
- assert.match(layoutSource, /async function handleLogout\(\) \{\s*try \{/)
+ assert.match(layoutSource, /async function handleLogout\(\) \{\s*const logoutToken = auth.token\s*try \{/)
assert.match(layoutSource, /\} catch \(error\) \{\s*if \(error === 'cancel' \|\| error === 'close'\) return\s*throw error\s*\}/)
})
@@ -148,3 +150,49 @@ test('source contract: routes denied pages to an explicit 403 state', () => {
assert.match(routerSource, /!auth\.hasPermission\(permission\)/)
assert.match(routerSource, /getFirstAccessiblePath\(auth\.permissionCodes\)/)
})
+
+function logoutHarness(t) {
+ const script = layoutSource.match(/
-