This commit is contained in:
2026-09-29 18:47:41 +08:00
parent 2bf4353b1b
commit a3005d1c02
22 changed files with 147 additions and 669 deletions
@@ -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/)
})
+51 -81
View File
@@ -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/)
})
@@ -5,7 +5,7 @@ withDefaults(defineProps<{ domain: string; detail?: boolean }>(), { detail: fals
<template>
<el-alert class="contract-pending" type="warning" :closable="false" show-icon>
<template #title>{{ domain }}海外区域接口待接入</template>
<p>{{ detail ? '当前详情没有可查询的真实记录。' : '页面已按四期原型冻结字段与布局。' }}海外请求统一经 gateway-overseas;在统一 Base/Token 选择及本页面端点、DTO 与权限适配完成前,不展示演示数据,也不会模拟查询或写操作成功。</p>
<p>当前功能尚未完成接口接入,暂不可操作。</p>
</el-alert>
</template>
+1 -1
View File
@@ -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` 代替代码与服务端契约。
## 页面与业务快速索引
@@ -5,7 +5,7 @@ defineProps<{ domain: string }>()
<template>
<el-alert class="contract-pending" type="warning" :closable="false" show-icon>
<template #title>{{ domain }}海外区域接口待接入</template>
<p>后端边界已固定为 gateway-overseas → lastleg-service / warehouse-overseas-service;统一海外 Base/Token 选择及本页面端点、DTO 与权限适配完成前,不展示演示数据,也不会模拟查询或写操作成功。</p>
<p>当前功能尚未完成接口接入,暂不可操作。</p>
</el-alert>
</template>
+6 -23
View File
@@ -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`、完整测试与构建。