diff --git a/.dockerignore b/.dockerignore index b59e8c23..2b5cc55a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,6 +3,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 5086bd91..f0d7ce14 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,13 @@ VITE_API_BASE_URL=/api -# 按本机联调环境填写国内 Gateway origin,不提交真实环境地址。 +# 按本机联调环境填写统一 Gateway origin,不提交真实环境地址。 API_PROXY_TARGET=http://localhost:9000 -# 可选:本地分别直连 Auth、IAM、Warehouse;未设置时复用 API_PROXY_TARGET。 -# AUTH_API_PROXY_TARGET=http://localhost:9001 -# IAM_API_PROXY_TARGET=http://localhost:9002 -# WAREHOUSE_API_PROXY_TARGET=http://localhost:9003 -# 按本机联调环境填写海外 Gateway origin,不提交真实环境地址。 # Local Vite fallback. Container deployments should use CLIENT_WEB_URL instead. VITE_CLIENT_WEB_URL=http://localhost:5174 + +# 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= +CLIENT_WEB_URL= diff --git a/AGENTS.md b/AGENTS.md index b39a57b2..29825dce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ 2. 视觉还原、布局/交互承载方式变更或真实页面验收时读取 `../../docs/Admin-Client前端原型还原验收规则.md`;需要原型依据才读取 `../../docs/悦/货代SAAS平台后台/AGENTS.md` 并定位目标片段。纯文案、逻辑或单元测试任务不自动读取。 Admin 样式优化还须读取 `../../docs/Admin高密度界面规范.md` 的“样式维护边界”和“新模块接入规则”;以已确认密度与彩色页签为视觉基线,逐操作保留业务语义和承载方式。国内仓、客户管理是样板;新增菜单按本轮范围显式接入,货柜管理入口见 `../../docs/Admin货柜管理样式核对记录.md`。 3. 标准列表或公共组件任务读取 `src/shared/components/AGENTS.md`;国内/海外 HTTP、Token 或刷新任务读取 `src/shared/api/AGENTS.md`;Finance 任务读取 `src/modules/finance/AGENTS.md`;海外仓全流程任务先读 `../../docs/海外仓业务/README.md`。 -4. 平台/客户层级与权限公共语义读取 `../../docs/平台客户层级与权限契约.md`;双区域 API、海外 Token 和跨区事件读取 `../../docs/跨区域认证与服务通信契约.md`。 +4. 平台/客户层级与权限公共语义读取 `../../docs/平台客户层级与权限契约.md`;双区域 API、海外 Token 和跨区事件读取 `../../docs/统一认证与服务通信契约.md`。 普通单端任务的最小读取集是本文件、目标模块最近的 `AGENTS.md` 和目标代码命中段;只有跨服务状态、权限或接口边界无法判断时才增加后端契约。累计超过约 250 行时改用定向检索,不全文加载无关专题。 @@ -31,8 +31,8 @@ - 依赖方向为 `app → modules/core/shared`、`modules → core/shared`、`core → shared`、`shared → 无上层依赖`。模块间只通过对方公开入口或契约协作,不引用其他模块的页面和内部目录。 - 路由级页面放 `modules/*/pages/`,模块路由放 `modules/*/routes.ts` 并由 `app/router/index.ts` 装配;新增模块同步维护 `core/navigation/adminNavigation.ts`。 -- 领域接口放 `modules/*/api/`;国内、海外公共客户端分别为 `shared/api/http.ts`、`shared/api/overseasHttp.ts`,具体 Token 与刷新规则见 `shared/api/AGENTS.md`。 -- `VITE_API_BASE_URL`、`VITE_OVERSEAS_API_BASE_URL` 分别控制国内、海外浏览器前缀;开发代理上游只由 Vite 读取。环境变量变化同步 `.env.example`,不提交真实地址或凭据。 +- 领域接口放 `modules/*/api/`;全部业务共用 `shared/api/http.ts`,具体 Token 与刷新规则见 `shared/api/AGENTS.md`。 +- `VITE_API_BASE_URL` 控制唯一浏览器前缀;`API_PROXY_TARGET` 由 Vite 读取并指向统一 Gateway。环境变量变化同步 `.env.example`,不提交真实地址或凭据。 ## 模块快速路由 @@ -40,7 +40,7 @@ | 业务 | Admin 入口 | 联查 | |---|---|---| -| 登录、会话与双区域 Token | `modules/auth/`、`core/auth/` | 国内 `auth/`、`iam/`;海外请求客户端再读 `shared/api/AGENTS.md` | +| 登录、会话与统一 Token | `modules/auth/`、`core/auth/` | 国内 `auth/`、`iam/`;海外请求客户端再读 `shared/api/AGENTS.md` | | 启动与懒加载资源恢复 | `main.ts`、`core/routing/navigationRecovery.ts` | 仅识别动态模块/Chunk 资源过期;30 秒内同目标最多自动恢复一次,存在未保存编辑时必须先确认 | | 工作台与指标 | `modules/dashboard/` | 指标所属模块与服务端,不复制领域契约 | | 船期日历 | `modules/sailing-calendar/` | 当前日历入口覆盖渠道、周模板、单次船期、收货截止时间和按渠道货量统计;后端按 `firstleg/` 船期接口定位,不读取已归档的货柜开发规划 | @@ -68,8 +68,8 @@ | 目的港码头拖车与还空预警 | 海外清关提柜工作台/详情 `modules/container-loading/pages/OverseasContainersView.vue`、`OverseasContainerDetailView.vue`,API `modules/container-loading/api/terminalDrayages.ts`、`containerArrivalTimes.ts` | 与订舱级拖车预约是不同生命周期;按 `containerBookingId` 读取服务端状态、`allowedActions` 和历史,不能复用订舱级拖车表单或自行推进状态。列表“预计到仓”列直接提供人工日期时间选择器,保存时通过 Lastleg 柜级版本和幂等键原子更新该柜全部尾程订单,已有入库计划后禁止修改;“实际到仓”只读 `ll_receiving_container_session.receiving_at`,即海外 PDA 首次开始收货时间。还空截止、超时天/小时只展示服务端基于权威卸柜完成时间加 72 小时计算的结果;Backend `firstleg/terminaldrayage/`、`lastleg-service/`,邮件记录通过 `terminalDrayages.ts` 查询与重试。 | | 历史报关协同与国内出仓 | `modules/customs/`、`modules/outbound-dispatch/` | `modules/customs/` 是历史报关案件/清单/代理提交与回执入口;装柜完成后的整柜退税/买单资料从货柜详情进入,不在旧模块复制;Backend `firstleg/outbound/dispatch/`、`firstleg/customs/`、`file/` | | 主数据、下载、退避任务重试、审计 | `modules/master-data/`、`modules/downloads/pages/DownloadCenterView.vue`、`modules/audit/` | File 导出任务返回 `attemptCount/maxAttempts/nextRetryAt`;仅失败或已进入自动退避的等待任务显示重试,等待任务重试用于立即执行当前投递,不创建新任务;联查 `warehouse/`、`file/`、`audit-service/` | -| 海外接柜、入库、库存与库内服务 | `modules/overseas-warehouse/` | `gateway-overseas/`、`lastleg-service/`、`warehouse-overseas-service/`;与排车、出库、派送、异常退回和一件代发共同组成海外仓全流程 | -| 海外采购、卡车、散板 | `modules/overseas-business/` | `gateway-overseas/`、`lastleg-service/` | +| 海外接柜、入库、库存与库内服务 | `modules/overseas-warehouse/` | `gateway/`、`lastleg-service/`、`warehouse-overseas-service/`;与排车、出库、派送、异常退回和一件代发共同组成海外仓全流程 | +| 海外采购、卡车、散板 | `modules/overseas-business/` | `gateway/`、`lastleg-service/` | | 海外仓业务合作仓与通用资料 | 合作仓 `modules/warehouses/`;报价/汇率/车辆 `modules/overseas-master-data/` | 国内 `warehouse/`、海外实际资源与服务 | | 海外仓业务国内出仓后船节点 | `modules/port-schedules/`、`modules/ocean-bookings/`、货柜详情 | `firstleg/portschedule/`、`integration-service/`;预计节点只展示不推进实际进度,手工实际节点必须严格向后追加并防重复,十节点标准见 `../../docs/海外仓业务/船节点标准化契约.md` | | 海外排车、出库与派送 | `modules/overseas-warehouse/AGENTS.md` | 按模块表定位排车、出库、派送页面及 API;履约边界在模块内维护 | @@ -98,7 +98,7 @@ - 国内出仓以真实货柜为原子边界:货柜详情只在装柜完成、全部入仓绑定门禁通过且有写权限时显示确认离仓;成功后由服务端同事务推进全部技术行并幂等创建船节点查询。前端不得逐入仓确认、把拖车状态当阻断门禁或重复追加实际船节点。 - `core/navigation/adminNavigation.ts`、`app/router/index.ts` 和模块 `routes.ts` 是共享装配文件;并行任务不得覆盖总控已确认语义,重叠时保留现有改动并回传总控。 - `core/routing/navigationRecovery.ts` 只处理已识别的动态 import/Chunk 资源过期;普通路由错误直接提示。自动恢复前保护未保存表单,同一目标 30 秒内不得重复刷新或形成重载循环。 -- 认证统一经过 `core/auth/auth.ts`、`shared/api/http.ts` 和 `shared/api/overseasHttp.ts`。登录/刷新原子替换国内、海外 Token,海外 401 统一刷新并最多重放一次;页面不读写 Token 或手工拼 `Authorization`。 +- 认证统一经过 `core/auth/auth.ts` 和 `shared/api/http.ts`。登录/刷新替换统一 Access/Refresh Token,全部 401 共用刷新并最多重放一次;页面不读写 Token 或手工拼 `Authorization`。 - 接入真实接口时删除对应模拟路径,不允许生产逻辑静默回退假数据。客户专属写操作显式选择客户,长整型 ID 按字符串透传,不从本地存储推断。 - 订舱代理、拖车承运商和报关代理提交服务端供应商 ID,从公共供应商列表选择 `ACTIVE` 且类别/范围匹配的记录;参数变化同步检查 API、类型、表单、展示、Mock/Fixture、测试和 OpenAPI。 - 删除、停用、改权限等高风险操作必须有确认、处理中、成功和失败反馈;失败保留用户上下文。日志、提示和示例不得包含真实账号、Token、隐私或生产数据。 diff --git a/Jenkinsfile b/Jenkinsfile index 8cec3b3b..695afd69 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,252 +1,38 @@ -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 = 'admin-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/admin-web' - } - + agent { label 'java-test' } options { timestamps() disableConcurrentBuilds() } - stages { - stage('确认提交版本') { + stage('检查测试发布参数') { 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') || env.JOB_BASE_NAME?.endsWith('-prod')) { + error('当前仅支持 test,请使用测试环境 Job') } - 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}" + : "${CLIENT_WEB_URL:?CLIENT_WEB_URL 未设置}" + 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' } } 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 - ''' - } - } - } - - stage('构建并发布生产镜像') { - when { expression { env.IS_PRODUCTION == '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 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_CLIENT_WEB_URL=https://app.buggoglobal.com/ \ - -t "$image" . - docker push "$image" - ''' - } - } - } - - stage('部署生产前端') { - when { expression { env.IS_PRODUCTION == 'true' } } - steps { - lock(resource: 'firstleg-production-admin-web-deploy', inversePrecedence: true) { - withCredentials([ - 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 admin-web '${env.PRODUCTION_IMAGE_REPOSITORY}' '${env.SOURCE_TAG}' docker-compose.production.yml" - } - } - } - } - } - - 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="admin-web-development:${SOURCE_TAG}" - - docker build \ - --build-arg NODE_IMAGE="$NODE_BASE_IMAGE" \ - --build-arg NGINX_IMAGE="$NGINX_BASE_IMAGE" \ - --build-arg VITE_CLIENT_WEB_URL="${CLIENT_WEB_URL:-https://dev-app.buggoglobal.com/}" \ - -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 admin-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 + 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 admin-web wget -qO- http://127.0.0.1/healthz ''' } } } - post { - success { - script { - if (env.JOB_BASE_NAME?.endsWith('-prod')) { - try { - sendProductionWeComNotification( - '✅ 生产部署成功', - '成功', - 'info', - '> 结果:Admin 生产前端已完成发布和部署流程' - ) - } 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 admin-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}" - } - } - } - } + failure { sh 'docker compose -f docker-compose.test.yml logs --tail=100 || true' } } } diff --git a/README.md b/README.md index 6384a94d..2f7f5136 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ pnpm install pnpm dev ``` -生产构建: +发布资源构建: ```bash pnpm typecheck @@ -54,16 +54,16 @@ pnpm test ```dotenv VITE_API_BASE_URL=/api API_PROXY_TARGET=http://localhost:9000 -VITE_OVERSEAS_API_BASE_URL=/overseas-api -OVERSEAS_API_PROXY_TARGET=http://localhost:9300 VITE_CLIENT_WEB_URL=http://localhost:5174 ``` -国内业务继续使用 `http` 和 `/api` 代理;海外业务统一使用 `overseasHttp` 和 `/overseas-api` 代理。Vite 会把 `/overseas-api/platform/...` 重写为海外 Gateway 的 `/api/platform/...`。海外 Token 的签发、缓存和会话刷新由统一认证层维护,业务模块不得读取 Token、手拼 `Authorization` 或复用国内 `http`。 +全部业务使用共享 `http` 和同源 `/api`,通过统一 Gateway 访问。Access/Refresh Token 与并发刷新由认证层维护,业务模块不得读取 Token 或手拼 `Authorization`。 -切换开发或测试环境时,只修改未提交的 `.env.local` 中 `API_PROXY_TARGET`、`OVERSEAS_API_PROXY_TARGET` 和 `VITE_CLIENT_WEB_URL`,然后重启 `pnpm dev`;无需修改业务代码。真实环境地址不写入受版本控制的配置模板或说明。 +本地工具只连接同一测试环境:在未提交 `.env.local` 配置 `API_PROXY_TARGET` 和 `VITE_CLIENT_WEB_URL`;不提供业务服务直连或独立海外代理。 -客户代登录打开的工作台地址是运行时配置,不需要为每个环境重新构建镜像。Docker 部署时为 Admin 容器设置当前环境的 `CLIENT_WEB_URL`;容器启动时会生成 `/runtime-config.js`。开发、测试和生产分别在各自部署任务中注入自己的 Client 根地址,禁止复用其他环境的域名。本地 Vite 开发使用 `VITE_CLIENT_WEB_URL` 作为兜底。部署后可读取 `/runtime-config.js` 核对最终生效值。 +唯一部署入口是 `docker-compose.test.yml` 和 test Jenkins Job。发布前在 Job/未提交环境文件配置 `API_UPSTREAM`(统一 Gateway origin,不含 `/api`)、`TEST_NETWORK`(后端共享测试网络)、`CLIENT_WEB_URL`(客户工作台地址)及 `IMAGE_TAG`。可选 `HOST_PORT` 默认 9001,`NODE_BASE_IMAGE`/`NGINX_BASE_IMAGE` 可指定已授权拉取的基础镜像;私有镜像仓库认证由构建节点配置,不在源码存放凭据。Jenkins 从当前提交生成 IMAGE_TAG,不再按 Job 后缀切换 dev/prod。宿主机反向代理连接 loopback 端口。 + +容器启动通过 `CLIENT_WEB_URL` 生成 `/runtime-config.js`;本地 Vite 使用 `VITE_CLIENT_WEB_URL`。本地构建通过不代表容器或测试环境已发布。真实 HTTP 验收入口为 `pnpm test:phase4:test --environment test --gateway <统一入口>`,凭据通过环境变量传入,仅在取得对应测试写入授权后执行。 认证请求使用 `/api/auth/**`,平台 IAM 请求使用 `/api/platform/v1/iam/**`。登录后只从 JWT 读取平台身份,菜单和操作权限通过当前用户授权接口实时加载,不从 JWT 携带或解析角色权限。企业审核调用 `/reviews`,打开审核详情后按租户调用 `/tenants/{tenantId}/qualifications`;营业执照通过 `/api/platform/v1/files/qualifications/{tenantId}/{fileName}` 携带平台 Token 临时读取,关闭预览后释放本地对象地址。客户端自主注册企业审核通过后继续使用注册账号作为管理员,不展示新凭据;Admin 代建企业并由后端创建管理员时,页面必须展示不可通过遮罩、Esc 或关闭按钮跳过的一次性凭据弹窗,运营人员确认保存后才清除账号密码。平台角色查询、新增、编辑、启停、删除和权限全量保存均已按 `RbacController` 接入真实 `/roles` 契约,角色返回的 `permissionIds` 用于授权回显;系统内置角色及仍被成员使用的角色继续由服务端保护。权限资源查询和新增调用 `/permissions`,尚未提供的权限编辑、删除和启停不会模拟成功。订单与客户真实权限资源只创建 `MENU` 与 `BUTTON`,不创建 `PAGE` 或 `API` 类型。其余业务页面需在对应服务契约确认后逐项替换,生产逻辑不得静默回退到模拟数据。 @@ -74,3 +74,5 @@ VITE_CLIENT_WEB_URL=http://localhost:5174 真实接口错误会保留后端返回的业务错误码、`requestId` 和 `traceId`。登录、企业审核、平台角色及权限资源的主要失败态支持直接复制追踪标识,便于与后端日志关联。 登录表单在开发和生产环境均默认留空,不包含联调账号、密码或演示提示。菜单、路由和操作权限只使用真实登录会话返回的服务端权限快照;前端控制仅改善体验,服务端仍须独立校验身份、角色权限和数据范围。接口失败时页面展示真实错误及追踪标识,不回退到假数据或模拟成功。 + +同机目标默认值:`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 a677b495..00000000 --- a/docker-compose.development.yml +++ /dev/null @@ -1,22 +0,0 @@ -services: - admin-web: - image: admin-web-development:${IMAGE_TAG:?IMAGE_TAG is required} - container_name: admin-web-development - restart: unless-stopped - ports: - - "127.0.0.1:9001:80" - environment: - CLIENT_WEB_URL: "${CLIENT_WEB_URL:-https://dev-app.buggoglobal.com/}" - 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 deleted file mode 100644 index 96c9dbe6..00000000 --- a/docker-compose.production.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: firstleg-prod-admin-web - -services: - admin-web: - image: ${IMAGE_REPOSITORY:?IMAGE_REPOSITORY is required}:${IMAGE_TAG:?IMAGE_TAG is required} - container_name: firstleg-prod-admin-web - restart: unless-stopped - ports: - - "127.0.0.1:${HOST_PORT:-9001}:80" - environment: - CLIENT_WEB_URL: "https://app.buggoglobal.com/" - API_UPSTREAM: "http://gateway-active:9000" - OVERSEAS_API_UPSTREAM: "http://gateway-overseas-active:9300" - networks: - - infra-network - - routing-network - -networks: - infra-network: - external: true - name: firstleg-prod-infra-network - routing-network: - external: true - name: firstleg-production-routing diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 3f75c718..6b00b25b 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -1,3 +1,4 @@ +name: firstleg-test-admin-web services: admin-web: build: @@ -6,16 +7,23 @@ services: args: NODE_IMAGE: "${NODE_BASE_IMAGE:-node:22-alpine}" NGINX_IMAGE: "${NGINX_BASE_IMAGE:-nginx:1.27-alpine}" - image: admin-web-test:local + image: admin-web-test:${IMAGE_TAG:?IMAGE_TAG is required} container_name: admin-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:-9001}:80" - extra_hosts: - - "host.docker.internal:host-gateway" + - "127.0.0.1:${HOST_PORT:-9001}:80" environment: - # Runtime URL used by the secure customer-workbench handoff. Each - # environment may override it without rebuilding the Admin image. - CLIENT_WEB_URL: "${CLIENT_WEB_URL:-https://test-app.buggoglobal.com/}" - API_UPSTREAM: "${API_UPSTREAM:-http://host.docker.internal:9000}" - OVERSEAS_API_UPSTREAM: "${OVERSEAS_API_UPSTREAM:-http://host.docker.internal:9300}" + CLIENT_WEB_URL: "${CLIENT_WEB_URL:?CLIENT_WEB_URL is required}" + 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 24d470dc..1ab97e09 100644 --- a/nginx/default.conf.template +++ b/nginx/default.conf.template @@ -30,7 +30,7 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } - # 墨家测量设备使用独立 OpenAPI 前缀,直接转发到国内 Gateway。 + # 墨家测量设备使用独立 OpenAPI 前缀,直接转发到统一 Gateway。 # 该入口不经过 Admin 前端路由,供设备以固定 AppKey/HMAC 调用。 location /openapi/ { client_max_body_size 55m; @@ -42,17 +42,6 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } - # 海外业务使用独立 Gateway;将浏览器前缀改写为 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; - } # HTML must be revalidated on every visit so a new deployment cannot keep # referencing hashed chunks that were removed with the previous image. diff --git a/package.json b/package.json index c5494404..bc5bbdb5 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "build": "vue-tsc --noEmit && vite build", "typecheck": "vue-tsc --noEmit", "test": "vitest run", - "test:phase4:development": "node scripts/phase4-development-frontend-acceptance.mjs", + "test:phase4:test": "node scripts/phase4-test-frontend-acceptance.mjs", "preview": "vite preview --port 4173" }, "dependencies": { diff --git a/scripts/deploy-production.sh b/scripts/deploy-production.sh deleted file mode 100644 index c27fb0b1..00000000 --- a/scripts/deploy-production.sh +++ /dev/null @@ -1,150 +0,0 @@ -#!/usr/bin/env 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 未设置}" - -run_ssh() { - ssh -i "$SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes \ - -o UserKnownHostsFile="$SSH_KNOWN_HOSTS" -o ConnectTimeout=10 "$SSH_TARGET" "$@" -} - -run_scp() { - scp -i "$SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes \ - -o UserKnownHostsFile="$SSH_KNOWN_HOSTS" -o ConnectTimeout=10 "$@" -} - -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' -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 diff --git a/scripts/phase4-development-frontend-acceptance.mjs b/scripts/phase4-test-frontend-acceptance.mjs similarity index 52% rename from scripts/phase4-development-frontend-acceptance.mjs rename to scripts/phase4-test-frontend-acceptance.mjs index 7321b557..708fd814 100644 --- a/scripts/phase4-development-frontend-acceptance.mjs +++ b/scripts/phase4-test-frontend-acceptance.mjs @@ -1,4 +1,3 @@ -import { existsSync, readFileSync } from 'node:fs' import { spawnSync } from 'node:child_process' function option(name) { @@ -15,22 +14,13 @@ function normalizeOrigin(value, name) { return url.origin } -function acceptancePassword() { - if (process.env.CHONGCHONG_ADMIN_ACCEPTANCE_PASSWORD) return process.env.CHONGCHONG_ADMIN_ACCEPTANCE_PASSWORD - const file = 'C:/Users/BY-OY/.codex/chongchong-global-dev-acceptance.env' - if (!existsSync(file)) return '' - const match = readFileSync(file, 'utf8').match(/^CHONGCHONG_ADMIN_ACCEPTANCE_PASSWORD=(.*)$/m) - return match?.[1]?.trim() || '' -} - const environment = option('--environment') -if (environment !== 'development') throw new Error('本入口仅允许显式指定 --environment development') -const gateway = normalizeOrigin(option('--gateway') || process.env.ADMIN_DEVELOPMENT_GATEWAY, '--gateway') -const overseasGateway = normalizeOrigin(option('--overseas-gateway') || process.env.ADMIN_DEVELOPMENT_OVERSEAS_GATEWAY, '--overseas-gateway') -const password = acceptancePassword() -if (!password) throw new Error('缺少 CHONGCHONG_ADMIN_ACCEPTANCE_PASSWORD 或本机开发验收凭据文件') +if (environment !== 'test') throw new Error('本入口仅允许显式指定 --environment test') +const gateway = normalizeOrigin(option('--gateway') || process.env.ADMIN_TEST_GATEWAY, '--gateway') +const password = process.env.CHONGCHONG_ADMIN_ACCEPTANCE_PASSWORD +if (!password) throw new Error('缺少 CHONGCHONG_ADMIN_ACCEPTANCE_PASSWORD') -const result = spawnSync('pnpm', ['exec', 'vitest', 'run', '--config', 'vitest.phase4-development.config.ts'], { +const result = spawnSync('pnpm', ['exec', 'vitest', 'run', '--config', 'vitest.phase4-test.config.ts'], { cwd: process.cwd(), stdio: 'inherit', shell: process.platform === 'win32', @@ -39,10 +29,8 @@ const result = spawnSync('pnpm', ['exec', 'vitest', 'run', '--config', 'vitest.p ADMIN_PHASE4_LIVE: '1', ADMIN_ACCEPTANCE_USERNAME: process.env.ADMIN_ACCEPTANCE_USERNAME || 'platform_admin', ADMIN_ACCEPTANCE_PASSWORD: password, - ADMIN_DEVELOPMENT_GATEWAY: gateway, - ADMIN_DEVELOPMENT_OVERSEAS_GATEWAY: overseasGateway, + ADMIN_TEST_GATEWAY: gateway, VITE_API_BASE_URL: `${gateway}/api`, - VITE_OVERSEAS_API_BASE_URL: `${overseasGateway}/api`, }, }) if (result.error) throw result.error diff --git a/scripts/phase4-development-frontend-contract.test.mjs b/scripts/phase4-test-frontend-contract.test.mjs similarity index 69% rename from scripts/phase4-development-frontend-contract.test.mjs rename to scripts/phase4-test-frontend-contract.test.mjs index 04f14f9e..683c7bac 100644 --- a/scripts/phase4-development-frontend-contract.test.mjs +++ b/scripts/phase4-test-frontend-contract.test.mjs @@ -2,14 +2,14 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' -const runner = readFileSync(new URL('./phase4-development-frontend-acceptance.mjs', import.meta.url), 'utf8') -const suite = readFileSync(new URL('./phase4-development-frontend.acceptance.ts', import.meta.url), 'utf8') +const runner = readFileSync(new URL('./phase4-test-frontend-acceptance.mjs', import.meta.url), 'utf8') +const suite = readFileSync(new URL('./phase4-test-frontend.acceptance.ts', import.meta.url), 'utf8') -test('development runner requires an explicit development environment and gateways', () => { +test('test runner requires an explicit test environment and unified gateway', () => { assert.match(runner, /--environment/) - assert.match(runner, /environment !== 'development'/) + assert.match(runner, /environment !== 'test'/) assert.match(runner, /--gateway/) - assert.match(runner, /--overseas-gateway/) + assert.doesNotMatch(runner, /--overseas-gateway|VITE_OVERSEAS/) assert.match(runner, /shell: process\.platform === 'win32'/) assert.match(runner, /if \(result\.error\) throw result\.error/) assert.doesNotMatch(runner, /ADMIN_ACCEPTANCE_PASSWORD\s*[:=]\s*['"][^'"]+['"]/) @@ -17,7 +17,7 @@ test('development runner requires an explicit development environment and gatewa test('live suite uses production auth clients and domain adapters', () => { assert.match(suite, /useAuthStore/) - assert.match(suite, /clearOverseasAccessToken/) + assert.match(suite, /refreshAuthenticatedSession/) assert.match(suite, /createQuote/) assert.match(suite, /createUsTruckOrder/) assert.match(suite, /createLocalLooseboardOrder/) diff --git a/scripts/phase4-development-frontend.acceptance.ts b/scripts/phase4-test-frontend.acceptance.ts similarity index 97% rename from scripts/phase4-development-frontend.acceptance.ts rename to scripts/phase4-test-frontend.acceptance.ts index 2a9bb088..faf84320 100644 --- a/scripts/phase4-development-frontend.acceptance.ts +++ b/scripts/phase4-test-frontend.acceptance.ts @@ -8,7 +8,7 @@ import { defineComponent, nextTick } from 'vue' import { mount, type VueWrapper } from '@vue/test-utils' import ElementPlus from 'element-plus' import { http, ApiResponseError } from '@/shared/api/http' -import { clearOverseasAccessToken, overseasHttp } from '@/shared/api/overseasHttp' +import { refreshAuthenticatedSession } from '@/shared/api/http' import { useAuthStore } from '@/core/auth/auth' import { listPlatformTenants } from '@/modules/iam/api/platformIam' import { @@ -45,7 +45,7 @@ import InventoryTransactionsView from '@/modules/overseas-warehouse/pages/Invent import PalletsView from '@/modules/overseas-warehouse/pages/PalletsView.vue' import QueryToolbar from '@/shared/components/QueryToolbar.vue' -type Region = 'domestic' | 'overseas' +type Region = 'unified' interface TraceStep { region: Region; method: string; path: string; params: unknown; body: unknown; httpStatus: number; code?: string; requestId?: string; traceId?: string; authorization: boolean } interface CoverageGap { area: string; reason: string } @@ -150,7 +150,7 @@ async function masterDataLifecycle() { } async function cleanupTransport(path: string, customerId: string, version: number) { - await overseasHttp.patch(`${path}/status`, { version, status: 'CANCELLED', remark: `${marker} cleanup` }, { params: { customerId } }) + await http.patch(`${path}/status`, { version, status: 'CANCELLED', remark: `${marker} cleanup` }, { params: { customerId } }) } async function overseasBusinessLifecycle() { const customers = (await listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })).records.filter(item => item.status === 'ACTIVE') @@ -355,25 +355,25 @@ async function pageRequestCoverage() { { occurredDate: ['2098-01-01', '2098-01-02'], customerName: `${marker}-CUSTOMER`, warehouse: `${marker}-WAREHOUSE`, sku: `${marker}-SKU`, businessType: 'ADJUST', businessOrderNo: `${marker}-BUSINESS`, inboundCode: `${marker}-INBOUND`, inboundOrderNo: `${marker}-INBOUND-ORDER`, outboundOrderNo: `${marker}-OUTBOUND` }, { customerName: `${marker}-CUSTOMER`, destinationWarehouseCode: `${marker}-WAREHOUSE`, skuCode: `${marker}-SKU`, businessType: 'ADJUST', businessOrderNo: `${marker}-BUSINESS`, inboundCode: `${marker}-INBOUND`, inboundOrderNo: `${marker}-INBOUND-ORDER`, outboundOrderNo: `${marker}-OUTBOUND`, occurredFrom: '2098-01-01T00:00:00', occurredTo: '2098-01-02T23:59:59' }) - const pallets = await mountPage(PalletsView, 'GET', '/platform/v1/warehouse-admin/pallets') - await exerciseListSearch(pallets, '/platform/v1/warehouse-admin/pallets', + const pallets = await mountPage(PalletsView, 'GET', '/platform/v1/overseas-warehouse-resources/pallets') + await exerciseListSearch(pallets, '/platform/v1/overseas-warehouse-resources/pallets', { warehouseId: '', code: `${marker}-PALLET`, type: 'WOOD', status: 'IDLE' }, { keyword: `${marker}-PALLET`, palletType: 'WOOD', status: 'IDLE' }) } -describe.skipIf(!enabled).sequential('phase4 development frontend acceptance', () => { +describe.skipIf(!enabled).sequential('phase4 test frontend acceptance', () => { beforeAll(async () => { Object.defineProperty(window, 'matchMedia', { writable: true, value: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }) }) globalThis.ResizeObserver = class { observe() {}; unobserve() {}; disconnect() {} } - installTracingAdapter(http, 'domestic'); installTracingAdapter(overseasHttp, 'overseas') + installTracingAdapter(http, 'unified') const pinia = createPinia(); setActivePinia(pinia) const auth = useAuthStore(pinia) await auth.login(process.env.ADMIN_ACCEPTANCE_USERNAME || 'platform_admin', process.env.ADMIN_ACCEPTANCE_PASSWORD || '') expect(localStorage.getItem('cc-admin-token')).toBeTruthy() - clearOverseasAccessToken() + await refreshAuthenticatedSession() await listVehicles({ page: 1, size: 1 }) - expect(traces.some(item => item.region === 'domestic' && item.path === '/auth/refresh')).toBe(true) - expect(traces.some(item => item.region === 'overseas' && item.authorization)).toBe(true) + expect(traces.some(item => item.region === 'unified' && item.path === '/auth/refresh')).toBe(true) + expect(traces.some(item => item.region === 'unified' && item.authorization)).toBe(true) }) it('runs marker-owned adapter lifecycles and real page requests', async () => { await masterDataLifecycle() @@ -388,7 +388,7 @@ describe.skipIf(!enabled).sequential('phase4 development frontend acceptance', ( const reportDir = resolve(process.cwd(), 'reports') mkdirSync(reportDir, { recursive: true }) const report = { generatedAt: new Date().toISOString(), marker, summary: { requests: traces.length, coverageGaps: coverageGaps.length }, traces, coverageGaps, createdResources } - const file = resolve(reportDir, `phase4-frontend-development-${marker.replace(/[^A-Za-z0-9-]/g, '')}.json`) + const file = resolve(reportDir, `phase4-frontend-test-${marker.replace(/[^A-Za-z0-9-]/g, '')}.json`) writeFileSync(file, `${JSON.stringify(report, null, 2)}\n`, 'utf8') console.info(`PHASE4_FRONTEND_REPORT=${file}`) }) diff --git a/src/deployment/blueGreenDeployment.test.mjs b/src/deployment/blueGreenDeployment.test.mjs deleted file mode 100644 index ab0275c3..00000000 --- a/src/deployment/blueGreenDeployment.test.mjs +++ /dev/null @@ -1,27 +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') - -test('production frontend starts and validates the candidate before switching nginx', () => { - 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"') - const state = script.indexOf('mv -f "$state_file.tmp" "$state_file"') - assert.ok(start >= 0 && start < health && health < nginx && nginx < stop && stop < state) -}) - -test('production frontend supports both slots and restores nginx on failure', () => { - 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 "\$nginx_config" "\$backup"/) - assert.match(script, /cp -f "\$backup" "\$nginx_config"/) - assert.match(script, /docker rm -f "\$candidate"/) - assert.match(script, /gateway-active:firstleg-production-gateway/) -}) diff --git a/src/deployment/nginxConfig.test.ts b/src/deployment/nginxConfig.test.ts index 6a8f0f47..de37fa7b 100644 --- a/src/deployment/nginxConfig.test.ts +++ b/src/deployment/nginxConfig.test.ts @@ -1,85 +1,55 @@ -import { readFileSync } from 'node:fs' +import assert from 'node:assert/strict' +import { readFileSync, existsSync } from 'node:fs' import { resolve } from 'node:path' -import { describe, expect, it } from 'vitest' +import { test } from 'vitest' -const config = readFileSync(resolve(process.cwd(), 'nginx/default.conf.template'), 'utf8').replace(/\r\n/g, '\n') -const testCompose = readFileSync(resolve(process.cwd(), 'docker-compose.test.yml'), 'utf8') -const developmentCompose = readFileSync(resolve(process.cwd(), 'docker-compose.development.yml'), 'utf8') -const productionCompose = readFileSync(resolve(process.cwd(), 'docker-compose.production.yml'), 'utf8') -const jenkinsfile = readFileSync(resolve(process.cwd(), 'Jenkinsfile'), 'utf8') -const dockerfile = readFileSync(resolve(process.cwd(), 'Dockerfile'), 'utf8') +const root = process.cwd() +const config = readFileSync(resolve(root, 'nginx/default.conf.template'), 'utf8') +const compose = readFileSync(resolve(root, 'docker-compose.test.yml'), 'utf8') +const jenkins = readFileSync(resolve(root, 'Jenkinsfile'), 'utf8') +const vite = readFileSync(resolve(root, 'vite.config.ts'), 'utf8') -describe('admin nginx deployment config', () => { - it('allows 50 MiB files plus multipart overhead through both bounded API proxies', () => { - expect(config.match(/client_max_body_size 55m;/g)).toHaveLength(3) - expect(config).not.toContain('client_max_body_size 12m;') - expect(config).not.toContain('client_max_body_size 0;') - }) - - it('prevents the application shell from retaining stale chunk references', () => { - expect(config).toContain('location = /index.html') - expect(config).toContain('Cache-Control "no-cache, no-store, must-revalidate"') - }) - - it('serves hashed assets immutably and never falls back missing chunks to HTML', () => { - expect(config).toContain('location /assets/') - expect(config).toContain('try_files $uri =404;') - expect(config).toContain('Cache-Control "public, max-age=31536000, immutable"') - }) - - it('keeps Vue history routes on the application shell without caching them', () => { - expect(config).toContain('try_files $uri $uri/ /index.html;') - expect(config).toContain('Cache-Control "no-cache"') - }) - - it('proxies overseas API requests to the gateway API prefix', () => { - expect(config).toContain('location /overseas-api/') - expect(config).toContain('rewrite ^/overseas-api/(.*)$ /api/$1 break;') - expect(config).toContain('proxy_pass ${OVERSEAS_API_UPSTREAM};') - }) - - it('declares an explicit overseas gateway upstream in container deployments', () => { - expect(testCompose).toContain('- "host.docker.internal:host-gateway"') - expect(testCompose).toContain('API_UPSTREAM: "${API_UPSTREAM:-http://host.docker.internal:9000}"') - expect(testCompose).toContain('OVERSEAS_API_UPSTREAM: "${OVERSEAS_API_UPSTREAM:-http://host.docker.internal:9300}"') - expect(testCompose).not.toContain('http://test-api-cn.buggoglobal.com') - expect(testCompose).not.toContain('http://test-api-global.buggoglobal.com') - expect(testCompose).toContain('CLIENT_WEB_URL: "${CLIENT_WEB_URL:-https://test-app.buggoglobal.com/}"') - expect(testCompose).not.toContain('CLIENT_WEB_URL: "http://8.163.') - expect(developmentCompose).toContain('CLIENT_WEB_URL: "${CLIENT_WEB_URL:-https://dev-app.buggoglobal.com/}"') - expect(developmentCompose).toContain('OVERSEAS_API_UPSTREAM: "http://firstleg-development-gateway-overseas:9300"') - expect(config).toContain('proxy_set_header Host $proxy_host;') - }) - - it('keeps local environments on target nodes and uses credential-managed SSH for production', () => { - expect(jenkinsfile).toContain("env.JOB_BASE_NAME?.endsWith('-prod') ? 'java-builder'") - expect(jenkinsfile).toContain("DEVELOPMENT_COMPOSE_PROJECT = 'admin-web'") - expect(jenkinsfile).toContain('docker compose -p "$DEVELOPMENT_COMPOSE_PROJECT" -f "$DEVELOPMENT_COMPOSE_FILE" up -d') - expect(jenkinsfile).not.toContain('/var/jenkins_home/.ssh') - expect(jenkinsfile).not.toContain('docker save') - expect(jenkinsfile).toContain("credentialsId: 'firstleg-production-ssh'") - expect(jenkinsfile).toContain('git rev-parse origin/main') - expect(productionCompose).toContain('127.0.0.1:${HOST_PORT:-9001}:80') - expect(productionCompose).toContain('API_UPSTREAM: "http://gateway-active:9000"') - expect(productionCompose).toContain('OVERSEAS_API_UPSTREAM: "http://gateway-overseas-active:9300"') - expect(productionCompose).toContain('name: firstleg-production-routing') - expect(productionCompose).toContain('name: firstleg-prod-infra-network') - }) - - it('uses authenticated internal base images in CI instead of pulling from Docker Hub', () => { - expect(dockerfile).toContain('ARG NODE_IMAGE=node:22-alpine') - expect(dockerfile).toContain('ARG NGINX_IMAGE=nginx:1.27-alpine') - expect(dockerfile).toContain('FROM ${NODE_IMAGE} AS build') - expect(dockerfile).toContain('FROM ${NGINX_IMAGE}') - expect(jenkinsfile).toContain("NODE_BASE_IMAGE = '172.31.198.7:10082/java-project/node:22-alpine'") - expect(jenkinsfile).toContain("NGINX_BASE_IMAGE = '172.31.198.7:10082/java-project/nginx:1.27-alpine'") - expect(jenkinsfile).toContain('withCredentials([usernamePassword(') - expect(jenkinsfile).toContain('export DOCKER_CONFIG="${WORKSPACE}@tmp/docker-auth"') - expect(jenkinsfile).toContain('--build-arg VITE_CLIENT_WEB_URL="${CLIENT_WEB_URL:-https://dev-app.buggoglobal.com/}"') - }) - - it('proxies Mojia measurement OpenAPI requests to the domestic gateway', () => { - expect(config).toContain('location /openapi/') - expect(config).toContain('location /openapi/ {\n client_max_body_size 55m;\n proxy_pass ${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(vite, /OVERSEAS_API|AUTH_API_PROXY_TARGET|IAM_API_PROXY_TARGET|WAREHOUSE_API_PROXY_TARGET/) +}) + +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, /label 'java-test'/) + assert.match(jenkins, /docker compose -f docker-compose\.test\.yml config --quiet/) + assert.match(jenkins, /docker network inspect/) + assert.doesNotMatch(jenkins, /docker-compose\.(production|development)|deploy-production|sshUserPrivateKey/) + for (const file of ['docker-compose.production.yml', 'docker-compose.development.yml', 'scripts/deploy-production.sh']) { + assert.equal(existsSync(resolve(root, file)), false) + } +}) + +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('container build excludes local environment values and waits for Nginx health', () => { + const ignore = readFileSync(resolve(root, '.dockerignore'), '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/) }) diff --git a/src/modules/overseas-master-data/components/ContractPendingNotice.vue b/src/modules/overseas-master-data/components/ContractPendingNotice.vue index 208fdf3a..e1e33dbf 100644 --- a/src/modules/overseas-master-data/components/ContractPendingNotice.vue +++ b/src/modules/overseas-master-data/components/ContractPendingNotice.vue @@ -5,7 +5,7 @@ withDefaults(defineProps<{ domain: string; detail?: boolean }>(), { detail: fals diff --git a/src/modules/overseas-warehouse/AGENTS.md b/src/modules/overseas-warehouse/AGENTS.md index caa948ee..0aae33fb 100644 --- a/src/modules/overseas-warehouse/AGENTS.md +++ b/src/modules/overseas-warehouse/AGENTS.md @@ -9,7 +9,7 @@ - 本文件适用于 `src/modules/overseas-warehouse/`,路径默认相对此目录;其他模块以 `../` 开头。应用公共规则见 `../../../AGENTS.md`。 - 先按下表定位页面,再沿 `routes.ts`、页面 import 与 `api/` 追踪;页面是否开放以正式路由、权限和测试为准。后台接口与状态进入 `../../../../../baiyue-firstleg/lastleg-service/AGENTS.md`,不扩大扫描其他业务模块。 -- 请求统一使用 `../../shared/api/overseasHttp.ts`;不以 `PROTOTYPE_MATRIX.md` 或 `CONTRACT_GAPS.md` 代替代码与服务端契约。 +- 请求统一使用 `../../shared/api/http.ts`;不以 `PROTOTYPE_MATRIX.md` 或 `CONTRACT_GAPS.md` 代替代码与服务端契约。 ## 页面与业务快速索引 diff --git a/src/modules/overseas-warehouse/components/ContractPendingNotice.vue b/src/modules/overseas-warehouse/components/ContractPendingNotice.vue index b42eb653..f061ac67 100644 --- a/src/modules/overseas-warehouse/components/ContractPendingNotice.vue +++ b/src/modules/overseas-warehouse/components/ContractPendingNotice.vue @@ -5,7 +5,7 @@ defineProps<{ domain: string }>() diff --git a/src/shared/api/AGENTS.md b/src/shared/api/AGENTS.md index 315b232b..e0293f1d 100644 --- a/src/shared/api/AGENTS.md +++ b/src/shared/api/AGENTS.md @@ -1,25 +1,8 @@ # Admin Shared API Instructions -## 适用范围与入口 - -- 本文件适用于 `src/shared/api/` 及其测试。业务模块只通过领域 `api/` 适配层调用这里的客户端,不在页面和组件直接创建 Axios 实例。 -- 国内 API 客户端为 `http.ts`;海外 API 客户端为 `overseasHttp.ts`。认证会话编排在 `../../core/auth/auth.ts`,不得在本目录复制登录状态。 - -## 国内与海外请求 - -- `http` 使用 `VITE_API_BASE_URL`,默认走国内代理;国内登录、刷新和退出仍由统一 Auth 处理。 -- `overseasHttp` 使用 `VITE_OVERSEAS_API_BASE_URL`,默认 `/overseas-api`;Vite 将该前缀改写为海外 Gateway 的 `/api`。代理上游由开发环境配置,不在源码或指引中记录真实地址。 -- 登录和刷新响应一次性返回国内 Token、`overseasAccessToken`、`overseasExpiresIn`;`core/auth/auth.ts` 必须原子替换两套访问凭据。不得恢复 Token 兑换端点或让业务模块单独刷新海外 Token。 -- 海外 Token 只由 `replaceOverseasAccessToken`、`clearOverseasAccessToken` 管理;业务模块不得读写缓存、LocalStorage 或手工拼装 `Authorization`。 -- 国内、海外 401 共用统一会话刷新编排。海外请求最多统一刷新一次并重放一次;刷新失败按会话失效处理,不循环重试或回退假数据。 - -## 领域调用边界 - -- 国内业务领域 API 使用 `http`;海外尾程、海外仓资源和海外本土业务使用 `overseasHttp`,不得经国内 Gateway 中转。 -- 页面只调用所属模块的 API 封装;模块 API 负责路径、DTO 和错误适配,不把 Axios 响应或区域选择泄漏到页面。 -- 跨区域 Token、Audience、JWKS 和服务通信公共契约读取 `../../../../../docs/跨区域认证与服务通信契约.md`。 - -## 修改与验证 - -- 修改客户端、拦截器、刷新、Token 生命周期或 Base URL 时,联查 `http.test.ts`、`overseasHttp.test.ts`、`core/auth/auth.test.ts`、Vite 代理、`.env.example` 和全部实际消费者。 -- 至少运行相关 API/Auth 测试和 `pnpm typecheck`;交付前按项目指引运行完整测试与构建。 +- 本目录及测试承载统一请求基础设施。全部领域 API 使用 `http.ts`,业务模块不得创建区域客户端或自行持久化 Token。 +- 会话编排唯一入口为 `../../core/auth/auth.ts`;统一 Access/Refresh Token 由 Auth 签发,并发 401 共用一次刷新,每个原请求最多重放一次。 +- `VITE_API_BASE_URL` 默认 `/api`,Vite 只通过 `API_PROXY_TARGET` 代理到统一 Gateway,不直连业务服务。 +- 页面只调用领域 API 封装;模块适配路径、DTO 与错误,不暴露客户端选择或手工 Authorization。 +- 正式契约见 `../../../../../docs/统一认证与服务通信契约.md`;业务服务仍负责权限和客户隔离。 +- 修改拦截器、刷新或 Base URL 后联查 `http.test.ts`、`../../core/auth/auth.test.ts`、Vite、`.env.example` 及直接消费者,运行定向测试、`pnpm typecheck`、完整测试与构建。 diff --git a/vite.config.ts b/vite.config.ts index fcce35f4..abe5eab4 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -7,10 +7,7 @@ import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), '') - const proxyTarget = process.env.API_PROXY_TARGET || env.API_PROXY_TARGET || 'http://localhost:8080' - const authProxyTarget = process.env.AUTH_API_PROXY_TARGET || env.AUTH_API_PROXY_TARGET || proxyTarget - const iamProxyTarget = process.env.IAM_API_PROXY_TARGET || env.IAM_API_PROXY_TARGET || proxyTarget - const warehouseProxyTarget = process.env.WAREHOUSE_API_PROXY_TARGET || env.WAREHOUSE_API_PROXY_TARGET || proxyTarget + const proxyTarget = process.env.API_PROXY_TARGET || env.API_PROXY_TARGET || 'http://localhost:9000' return { plugins: [ @@ -32,33 +29,6 @@ export default defineConfig(({ mode }) => { server: { strictPort: true, proxy: { - '/api/auth': { - target: authProxyTarget, - changeOrigin: true, - configure(proxy) { - proxy.on('proxyReq', (proxyRequest) => { - proxyRequest.removeHeader('origin') - }) - }, - }, - '/api/platform/v1/iam': { - target: iamProxyTarget, - changeOrigin: true, - configure(proxy) { - proxy.on('proxyReq', (proxyRequest) => { - proxyRequest.removeHeader('origin') - }) - }, - }, - '/api/platform/v1/warehouse-admin': { - target: warehouseProxyTarget, - changeOrigin: true, - configure(proxy) { - proxy.on('proxyReq', (proxyRequest) => { - proxyRequest.removeHeader('origin') - }) - }, - }, '/api': { target: proxyTarget, changeOrigin: true, diff --git a/vitest.phase4-development.config.ts b/vitest.phase4-test.config.ts similarity index 84% rename from vitest.phase4-development.config.ts rename to vitest.phase4-test.config.ts index d97a8405..fe9a2c75 100644 --- a/vitest.phase4-development.config.ts +++ b/vitest.phase4-test.config.ts @@ -7,7 +7,7 @@ export default defineConfig({ resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } }, test: { environment: 'jsdom', - include: ['scripts/phase4-development-frontend.acceptance.ts'], + include: ['scripts/phase4-test-frontend.acceptance.ts'], fileParallelism: false, testTimeout: 120_000, hookTimeout: 120_000,