Merge branch 'release/test' of http://8.163.30.128:10082/html/client-web into release/test
This commit is contained in:
+3
-1
@@ -4,6 +4,8 @@
|
|||||||
dist
|
dist
|
||||||
node_modules
|
node_modules
|
||||||
node_modules.broken-by-path
|
node_modules.broken-by-path
|
||||||
.env.local
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
|
|||||||
+7
-2
@@ -1,5 +1,10 @@
|
|||||||
VITE_API_BASE_URL=/api
|
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
|
API_PROXY_TARGET=http://127.0.0.1:9000
|
||||||
VITE_ADMIN_WEB_ORIGIN=http://localhost:5173
|
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=
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
- 本项目是入驻企业及其成员使用的客户工作台,所有业务数据都处于企业租户上下文中。
|
- 本项目是入驻企业及其成员使用的客户工作台,所有业务数据都处于企业租户上下文中。
|
||||||
- 当前范围包括注册、业务总览、企业资料、企业成员、企业角色与权限、头程订单、客户资金账户、客户财务账单查询与客户确认,以及海外仓贴标、转运、废弃和退货换标自助服务。
|
- 当前范围包括注册、业务总览、企业资料、企业成员、企业角色与权限、头程订单、客户资金账户、客户财务账单查询与客户确认,以及海外仓贴标、转运、废弃和退货换标自助服务。
|
||||||
- 用户可见的“平台—客户”层级语义读取 `../../docs/平台客户层级与权限契约.md`;涉及海外 API、海外 Token 或跨区域事件时读取 `../../docs/跨区域认证与服务通信契约.md`。本文件中的 `tenant` 仅表示服务端隔离上下文,不扩展用户可见产品层级。
|
- 用户可见的“平台—客户”层级语义读取 `../../docs/平台客户层级与权限契约.md`;涉及海外 API、海外 Token 或跨区域事件时读取 `../../docs/统一认证与服务通信契约.md`。本文件中的 `tenant` 仅表示服务端隔离上下文,不扩展用户可见产品层级。
|
||||||
- 不在本项目实现平台内部运营管理、品牌官网、仓库 PDA 作业或仅属于原生移动端的能力。
|
- 不在本项目实现平台内部运营管理、品牌官网、仓库 PDA 作业或仅属于原生移动端的能力。
|
||||||
|
|
||||||
## 技术基线
|
## 技术基线
|
||||||
@@ -26,15 +26,15 @@
|
|||||||
- 模块内 `store/`:该业务跨页面状态;局部表单状态仍留在页面或组件内。跨模块使用 store 时只能导入其公开 store 文件,不得读取模块内部页面或组件实现。
|
- 模块内 `store/`:该业务跨页面状态;局部表单状态仍留在页面或组件内。跨模块使用 store 时只能导入其公开 store 文件,不得读取模块内部页面或组件实现。
|
||||||
- `src/api/`:只保留 HTTP、会话令牌、响应类型等全局请求基础设施;业务接口放入对应模块的 `api/`。页面不得直接创建独立 Axios 客户端。
|
- `src/api/`:只保留 HTTP、会话令牌、响应类型等全局请求基础设施;业务接口放入对应模块的 `api/`。页面不得直接创建独立 Axios 客户端。
|
||||||
- 模块之间不得通过相对路径或内部目录穿透;确需协作时只能从目标模块的 `public.ts` 导入。模块内稳定契约放在 `contracts.ts`,稳定的跨应用模型仍应上移到 `@chongchong/shared`。
|
- 模块之间不得通过相对路径或内部目录穿透;确需协作时只能从目标模块的 `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`。
|
- `src/styles/`:全局设计令牌和布局;优先复用现有 `--primary`、`--navy`、`--border`、`--muted`。
|
||||||
- 本应用多个模块稳定复用的前端模型放到应用内 `shared/`,客户工作台专属类型保留在对应业务模块。不要链接应用目录之外的 TypeScript 包,服务端契约以 `baiyue-firstleg` Controller/OpenAPI 为准。
|
- 本应用多个模块稳定复用的前端模型放到应用内 `shared/`,客户工作台专属类型保留在对应业务模块。不要链接应用目录之外的 TypeScript 包,服务端契约以 `baiyue-firstleg` Controller/OpenAPI 为准。
|
||||||
|
|
||||||
## 开发环境样式验收
|
## 测试环境样式验收
|
||||||
|
|
||||||
- 开发环境验收使用本轮动态创建并已审核通过的客户管理员,或由用户显式提供的客户账号,不依赖固定业务客户或固定账号。
|
- 测试环境验收使用本轮动态创建并已审核通过的客户管理员,或由用户显式提供的客户账号,不依赖固定业务客户或固定账号。
|
||||||
- 账号与密码不写入仓库;从本机未提交环境变量或 `C:/Users/BY-OY/.codex/chongchong-global-dev-acceptance.env` 读取。若显式验收凭据不存在,再向用户索取。
|
- 账号与密码不写入仓库;从本机未提交环境变量或 `C:/Users/BY-OY/.codex/chongchong-global-dev-acceptance.env` 读取。若显式验收凭据不存在,再向用户索取。
|
||||||
- 该身份仅适用于开发环境。不要把密码、Token 或真实接口地址写入源码、测试快照、日志或 `AGENTS.md`;开发环境变更时只更新本机凭据。
|
- 当前只有一个测试环境。不要把密码、Token 或真实接口地址写入源码、测试快照、日志或 `AGENTS.md`;测试环境变更时只更新本机凭据。
|
||||||
- 由人工使用该身份进入真实页面,按 `1920×1080` 视口和 `100%` 浏览器缩放完成操作与截图,再把截图发送给 AI 核对加载、空态、错误态、权限态和桌面布局;AI 默认不主动调用截图或视觉类技能取证。
|
- 由人工使用该身份进入真实页面,按 `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/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/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` |
|
| 企业成员消息收件箱 | `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 同步并撤销该账号现有会话,不得在前端互相回填或联动提交。
|
- 注册时登录手机号同时初始化公司联系电话;注册完成后两者独立维护。企业资料只修改 `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 后移除对应模拟分支,生产环境不得回退为模拟成功。
|
- 当前认证仍包含模拟数据。接入真实 API 后移除对应模拟分支,生产环境不得回退为模拟成功。
|
||||||
- 头程订单统一使用当前正式读写契约;不得继续扩展 `LEGACY_MANUAL`、旧系统入仓码、C02 不可用回退旧 API、历史部分响应兼容或其他新旧并跑逻辑。触及该链路时按根无兼容规则同步删除生产分支、类型、文案、Mock 和测试,不以现有代码或测试仍覆盖旧路径作为保留依据。
|
- 头程订单统一使用当前正式读写契约;不得继续扩展 `LEGACY_MANUAL`、旧系统入仓码、C02 不可用回退旧 API、历史部分响应兼容或其他新旧并跑逻辑。触及该链路时按根无兼容规则同步删除生产分支、类型、文案、Mock 和测试,不以现有代码或测试仍覆盖旧路径作为保留依据。
|
||||||
- 注册、上传资质、成员邀请和订单操作必须提供明确校验、幂等保护、提交中状态和可恢复的失败反馈。
|
- 注册、上传资质、成员邀请和订单操作必须提供明确校验、幂等保护、提交中状态和可恢复的失败反馈。
|
||||||
|
|||||||
Vendored
+15
-229
@@ -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}
|
|
||||||
> 状态:<font color="${color}">${status}</font>
|
|
||||||
${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 {
|
pipeline {
|
||||||
agent {
|
agent { label 'java-test' }
|
||||||
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'
|
|
||||||
}
|
|
||||||
|
|
||||||
options {
|
options {
|
||||||
timestamps()
|
timestamps()
|
||||||
disableConcurrentBuilds()
|
disableConcurrentBuilds()
|
||||||
}
|
}
|
||||||
|
|
||||||
stages {
|
stages {
|
||||||
stage('确认提交版本') {
|
stage('检查测试发布参数') {
|
||||||
steps {
|
steps {
|
||||||
script {
|
script {
|
||||||
env.IS_DEVELOPMENT = env.JOB_BASE_NAME?.endsWith('-dev') ? 'true' : 'false'
|
if (env.JOB_BASE_NAME?.endsWith('-dev') || env.JOB_BASE_NAME?.endsWith('-prod')) {
|
||||||
env.IS_PRODUCTION = env.JOB_BASE_NAME?.endsWith('-prod') ? 'true' : 'false'
|
error('当前仅支持 test,请使用测试环境 Job')
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
env.IMAGE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim()
|
||||||
|
}
|
||||||
|
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
|
||||||
'''
|
'''
|
||||||
}
|
}
|
||||||
env.SOURCE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim()
|
|
||||||
}
|
}
|
||||||
sh 'git log -1 --oneline'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stage('构建并部署测试前端') {
|
stage('构建并部署测试前端') {
|
||||||
when { expression { env.IS_DEVELOPMENT != 'true' && env.IS_PRODUCTION != 'true' } }
|
|
||||||
steps {
|
steps {
|
||||||
withCredentials([usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')]) {
|
sh '''set -eu
|
||||||
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 build --pull=false
|
||||||
docker compose -f docker-compose.test.yml up -d --remove-orphans
|
docker compose -f docker-compose.test.yml up -d --wait --wait-timeout 120
|
||||||
'''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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_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) {
|
|
||||||
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 client-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="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
|
docker compose -f docker-compose.test.yml exec -T client-web wget -qO- http://127.0.0.1/healthz
|
||||||
'''
|
'''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
post {
|
post {
|
||||||
success {
|
failure { sh 'docker compose -f docker-compose.test.yml logs --tail=100 || true' }
|
||||||
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}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ pnpm install
|
|||||||
pnpm dev
|
pnpm dev
|
||||||
```
|
```
|
||||||
|
|
||||||
生产构建:
|
发布资源构建:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm typecheck
|
pnpm typecheck
|
||||||
@@ -35,7 +35,7 @@ pnpm test
|
|||||||
|
|
||||||
依赖和 `pnpm-lock.yaml` 均保存在当前前端项目中,不需要在仓库根目录安装 Node 依赖。
|
依赖和 `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 携带或解析角色权限。前端权限仅用于导航和交互控制,服务端仍须按用户、客户和权限编码完成最终授权。
|
认证会话保存 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`,只按待审核展示且不开放编辑。
|
头程订单列表、详情、仓库查询、创建/编辑草稿和提交已经接入真实 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 配置一致。
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
name: firstleg-prod-client-web
|
|
||||||
|
|
||||||
services:
|
|
||||||
client-web:
|
|
||||||
image: ${IMAGE_REPOSITORY:?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"
|
|
||||||
environment:
|
|
||||||
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
|
|
||||||
+17
-7
@@ -1,19 +1,29 @@
|
|||||||
|
name: firstleg-test-client-web
|
||||||
services:
|
services:
|
||||||
client-web:
|
client-web:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
VITE_ADMIN_WEB_ORIGIN: "${VITE_ADMIN_WEB_ORIGIN:-https://test-admin.buggoglobal.com}"
|
|
||||||
NODE_IMAGE: "${NODE_BASE_IMAGE:-node:22-alpine}"
|
NODE_IMAGE: "${NODE_BASE_IMAGE:-node:22-alpine}"
|
||||||
NGINX_IMAGE: "${NGINX_BASE_IMAGE:-nginx:1.27-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
|
container_name: client-web-test
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://127.0.0.1/healthz"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
start_period: 5s
|
||||||
ports:
|
ports:
|
||||||
- "${HOST_PORT:-9002}:80"
|
- "127.0.0.1:${HOST_PORT:-9002}:80"
|
||||||
extra_hosts:
|
|
||||||
- "host.docker.internal:host-gateway"
|
|
||||||
environment:
|
environment:
|
||||||
API_UPSTREAM: "${API_UPSTREAM:-http://host.docker.internal:9000}"
|
API_UPSTREAM: "${API_UPSTREAM:-http://gateway:9000}"
|
||||||
OVERSEAS_API_UPSTREAM: "${OVERSEAS_API_UPSTREAM:-http://host.docker.internal:9300}"
|
networks:
|
||||||
|
- test-backend
|
||||||
|
networks:
|
||||||
|
test-backend:
|
||||||
|
external: true
|
||||||
|
name: "${TEST_NETWORK:-firstleg-test-network}"
|
||||||
|
|||||||
@@ -35,17 +35,6 @@ server {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
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 路由直接刷新。
|
# 支持 Vue Router history 路由直接刷新。
|
||||||
location / {
|
location / {
|
||||||
|
|||||||
@@ -1,150 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
if [ "$#" -ne 4 ]; then
|
|
||||||
echo "用法: $0 <服务名> <镜像仓库> <镜像标签> <Compose 文件>" >&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
|
|
||||||
+12
-5
@@ -2,7 +2,7 @@ import axios, { AxiosError, type AxiosRequestConfig, type InternalAxiosRequestCo
|
|||||||
import { parseApiJsonResponse } from '@chongchong/shared'
|
import { parseApiJsonResponse } from '@chongchong/shared'
|
||||||
import { clearSessionTokens, getAccessToken, getRefreshToken, persistTokenPair, type TokenPair } from '@/api/session'
|
import { clearSessionTokens, getAccessToken, getRefreshToken, persistTokenPair, type TokenPair } from '@/api/session'
|
||||||
import { apiErrorMessage, asFieldErrors } from '@/api/errorDetails'
|
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'
|
import { coordinateCrossTabRefresh, CrossTabRefreshTimeoutError } from '@/api/crossTabRefresh'
|
||||||
|
|
||||||
const transport = axios.create({
|
const transport = axios.create({
|
||||||
@@ -16,6 +16,13 @@ interface RetryableRequest extends InternalAxiosRequestConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function unwrap<T>(envelope: ApiEnvelope<T>) {
|
function unwrap<T>(envelope: ApiEnvelope<T>) {
|
||||||
|
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') {
|
if (envelope.code !== '200') {
|
||||||
const fieldErrors = asFieldErrors(envelope.data)
|
const fieldErrors = asFieldErrors(envelope.data)
|
||||||
throw new ApiError(apiErrorMessage(envelope.message, fieldErrors), envelope.code, undefined, fieldErrors, envelope.traceId, envelope.requestId)
|
throw new ApiError(apiErrorMessage(envelope.message, fieldErrors), envelope.code, undefined, fieldErrors, envelope.traceId, envelope.requestId)
|
||||||
@@ -28,8 +35,8 @@ function mapError(error: unknown) {
|
|||||||
if (!axios.isAxiosError(error)) return new ApiError(error instanceof Error ? error.message : '请求失败')
|
if (!axios.isAxiosError(error)) return new ApiError(error instanceof Error ? error.message : '请求失败')
|
||||||
|
|
||||||
const body = error.response?.data
|
const body = error.response?.data
|
||||||
if (isApiEnvelope(body)) {
|
if (isApiEnvelope(body) || isApiFailure(body)) {
|
||||||
const fieldErrors = asFieldErrors(body.data)
|
const fieldErrors = asFieldErrors(isApiEnvelope(body) ? body.data : undefined)
|
||||||
return new ApiError(
|
return new ApiError(
|
||||||
apiErrorMessage(body.message, fieldErrors),
|
apiErrorMessage(body.message, fieldErrors),
|
||||||
body.code,
|
body.code,
|
||||||
@@ -170,8 +177,8 @@ async function throwBlobApiError(error: unknown, responseStatus?: number) {
|
|||||||
if (!blob || !blob.type.toLowerCase().includes('json')) return
|
if (!blob || !blob.type.toLowerCase().includes('json')) return
|
||||||
try {
|
try {
|
||||||
const body = JSON.parse(await blob.text()) as unknown
|
const body = JSON.parse(await blob.text()) as unknown
|
||||||
if (!isApiEnvelope(body)) return
|
if (!isApiEnvelope(body) && !isApiFailure(body)) return
|
||||||
const fieldErrors = asFieldErrors(body.data)
|
const fieldErrors = asFieldErrors(isApiEnvelope(body) ? body.data : undefined)
|
||||||
throw new ApiError(
|
throw new ApiError(
|
||||||
apiErrorMessage(body.message, fieldErrors),
|
apiErrorMessage(body.message, fieldErrors),
|
||||||
body.code,
|
body.code,
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ test('does not accept malformed or data-less successful responses', () => {
|
|||||||
assert.equal(isApiEnvelope({ code: '200', message: 'OK', data: [] }), true)
|
assert.equal(isApiEnvelope({ code: '200', message: 'OK', data: [] }), true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('overseas transport handles business-code and HTTP security failures', async () => {
|
test('unified transport handles business-code and HTTP security failures', async () => {
|
||||||
const transport = await readFile(new URL('./overseasHttp.ts', import.meta.url), 'utf8')
|
const transport = await readFile(new URL('./http.ts', import.meta.url), 'utf8')
|
||||||
assert.match(transport, /if \(isApiFailure\(response.data\)\)/)
|
assert.match(transport, /if \(isApiFailure\(invalid\)\)/)
|
||||||
assert.match(transport, /isApiEnvelope\(body\) \|\| isApiFailure\(body\)/)
|
assert.match(transport, /isApiEnvelope\(body\) \|\| isApiFailure\(body\)/)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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<T>(config: AxiosRequestConfig) {
|
|
||||||
try {
|
|
||||||
const response = await transport.request<unknown>(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<Blob>({ ...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 : '海外文件下载失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
export const ACCESS_TOKEN_KEY = 'cc-client-access-token'
|
export const ACCESS_TOKEN_KEY = 'cc-client-access-token'
|
||||||
export const REFRESH_TOKEN_KEY = 'cc-client-refresh-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 const REMEMBERED_LOGIN_ID_KEY = 'cc-client-remembered-login-id'
|
||||||
|
|
||||||
export interface TokenPair {
|
export interface TokenPair {
|
||||||
@@ -10,14 +8,8 @@ export interface TokenPair {
|
|||||||
expiresIn: number
|
expiresIn: number
|
||||||
refreshToken: string
|
refreshToken: string
|
||||||
refreshExpiresIn: number
|
refreshExpiresIn: number
|
||||||
overseasAccessToken?: string | null
|
|
||||||
overseasExpiresIn?: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function activeStorage() {
|
|
||||||
if (localStorage.getItem(ACCESS_TOKEN_KEY)) return localStorage
|
|
||||||
return sessionStorage
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAccessToken() {
|
export function getAccessToken() {
|
||||||
return localStorage.getItem(ACCESS_TOKEN_KEY) || sessionStorage.getItem(ACCESS_TOKEN_KEY) || ''
|
return localStorage.getItem(ACCESS_TOKEN_KEY) || sessionStorage.getItem(ACCESS_TOKEN_KEY) || ''
|
||||||
@@ -27,21 +19,11 @@ export function getRefreshToken() {
|
|||||||
return localStorage.getItem(REFRESH_TOKEN_KEY) || sessionStorage.getItem(REFRESH_TOKEN_KEY) || ''
|
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) || ''
|
|
||||||
}
|
|
||||||
|
|
||||||
export function persistTokenPair(tokens: TokenPair) {
|
export function persistTokenPair(tokens: TokenPair) {
|
||||||
clearSessionTokens()
|
clearSessionTokens()
|
||||||
localStorage.setItem(ACCESS_TOKEN_KEY, tokens.accessToken)
|
localStorage.setItem(ACCESS_TOKEN_KEY, tokens.accessToken)
|
||||||
localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refreshToken)
|
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() {
|
export function getRememberedLoginId() {
|
||||||
@@ -62,8 +44,6 @@ export function clearSessionTokens() {
|
|||||||
for (const storage of [localStorage, sessionStorage]) {
|
for (const storage of [localStorage, sessionStorage]) {
|
||||||
storage.removeItem(ACCESS_TOKEN_KEY)
|
storage.removeItem(ACCESS_TOKEN_KEY)
|
||||||
storage.removeItem(REFRESH_TOKEN_KEY)
|
storage.removeItem(REFRESH_TOKEN_KEY)
|
||||||
storage.removeItem(OVERSEAS_ACCESS_TOKEN_KEY)
|
|
||||||
storage.removeItem(OVERSEAS_EXPIRES_AT_KEY)
|
|
||||||
storage.removeItem('cc-client-token')
|
storage.removeItem('cc-client-token')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
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 harness(adapter) {
|
||||||
|
let token = 'old-access'
|
||||||
|
let refreshToken = 'old-refresh'
|
||||||
|
const http = compile('./http.ts', {
|
||||||
|
axios: { ...axios, create: (config) => axios.create({ ...config, adapter }) },
|
||||||
|
'@chongchong/shared': { parseApiJsonResponse: (data) => data },
|
||||||
|
'@/api/session': {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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: 'new-access', refreshToken: 'new-refresh' } } }
|
||||||
|
}
|
||||||
|
if (config.headers.Authorization === 'Bearer old-access') {
|
||||||
|
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 new-access').length, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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/)
|
|
||||||
})
|
|
||||||
@@ -1,67 +1,54 @@
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import { readFileSync } from 'node:fs'
|
import { readFileSync, existsSync } from 'node:fs'
|
||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
|
|
||||||
const config = readFileSync(new URL('../../nginx/default.conf.template', import.meta.url), 'utf8')
|
const root = new URL('../../', import.meta.url)
|
||||||
const testCompose = readFileSync(new URL('../../docker-compose.test.yml', import.meta.url), 'utf8')
|
const config = readFileSync(new URL('nginx/default.conf.template', root), 'utf8')
|
||||||
const developmentCompose = readFileSync(new URL('../../docker-compose.development.yml', import.meta.url), 'utf8')
|
const compose = readFileSync(new URL('docker-compose.test.yml', root), 'utf8')
|
||||||
const productionCompose = readFileSync(new URL('../../docker-compose.production.yml', import.meta.url), 'utf8')
|
const jenkins = readFileSync(new URL('Jenkinsfile', root), 'utf8')
|
||||||
const jenkinsfile = readFileSync(new URL('../../Jenkinsfile', import.meta.url), 'utf8')
|
const vite = readFileSync(new URL('vite.config.ts', root), 'utf8')
|
||||||
const dockerfile = readFileSync(new URL('../../Dockerfile', import.meta.url), 'utf8')
|
|
||||||
|
|
||||||
test('allows 50 MiB uploads with multipart overhead through both API proxies', () => {
|
test('all business traffic uses one Gateway without rewriting API paths', () => {
|
||||||
assert.match(config, /location \/api\/ \{[\s\S]*?client_max_body_size 55m;/)
|
assert.match(config, /location \/api\//)
|
||||||
assert.match(config, /location \/overseas-api\/ \{[\s\S]*?client_max_body_size 55m;/)
|
assert.match(config, /proxy_pass \$\{API_UPSTREAM\};/)
|
||||||
assert.equal(config.match(/client_max_body_size 55m;/g)?.length, 2)
|
assert.doesNotMatch(config, /overseas-api|OVERSEAS_API_UPSTREAM|rewrite/)
|
||||||
})
|
assert.match(config, /client_max_body_size 55m;/)
|
||||||
|
|
||||||
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\};/)
|
|
||||||
assert.match(config, /proxy_set_header Host \$proxy_host;/)
|
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', () => {
|
test('test deployment requires an explicit shared network, Gateway and version', () => {
|
||||||
assert.match(developmentCompose, /API_UPSTREAM: "http:\/\/firstleg-development-gateway:9000"/)
|
for (const key of ['IMAGE_TAG']) {
|
||||||
assert.match(developmentCompose, /OVERSEAS_API_UPSTREAM: "http:\/\/firstleg-development-gateway-overseas:9300"/)
|
assert.ok(compose.includes('${' + key + ':?'))
|
||||||
assert.match(developmentCompose, /- development-backend/)
|
}
|
||||||
assert.match(developmentCompose, /- development-overseas-backend/)
|
assert.ok(compose.includes('${API_UPSTREAM:-http://gateway:9000}'))
|
||||||
assert.match(developmentCompose, /development-overseas-backend:[\s\S]*?external: true[\s\S]*?name: firstleg-development-overseas-network/)
|
assert.ok(compose.includes('${TEST_NETWORK:-firstleg-test-network}'))
|
||||||
assert.doesNotMatch(developmentCompose, /test-api|prod(?:uction)?/i)
|
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(new URL(file, root)), false)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test('keeps local environments on target nodes and uses credential-managed SSH for production', () => {
|
test('application shell is revalidated and missing chunks remain 404', () => {
|
||||||
assert.match(jenkinsfile, /env\.JOB_BASE_NAME\?\.endsWith\('-prod'\) \? 'java-builder'/)
|
assert.match(config, /location = \/index\.html/)
|
||||||
assert.match(jenkinsfile, /DEVELOPMENT_COMPOSE_PROJECT = 'client-web'/)
|
assert.match(config, /no-store/)
|
||||||
assert.match(jenkinsfile, /docker compose -p "\$DEVELOPMENT_COMPOSE_PROJECT" -f "\$DEVELOPMENT_COMPOSE_FILE" up -d/)
|
assert.match(config, /max-age=31536000, immutable/)
|
||||||
assert.doesNotMatch(jenkinsfile, /\/var\/jenkins_home\/\.ssh|docker save/)
|
assert.match(config, /try_files \$uri =404;/)
|
||||||
assert.match(jenkinsfile, /credentialsId: 'firstleg-production-ssh'/)
|
assert.match(config, /try_files \$uri \$uri\/ \/index\.html;/)
|
||||||
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('uses authenticated internal base images in CI', () => {
|
|
||||||
assert.match(dockerfile, /ARG NODE_IMAGE=node:22-alpine/)
|
test('container build excludes local environment values and waits for Nginx health', () => {
|
||||||
assert.match(dockerfile, /ARG NGINX_IMAGE=nginx:1\.27-alpine/)
|
const ignore = readFileSync(new URL('.dockerignore', root), 'utf8')
|
||||||
assert.match(dockerfile, /FROM \$\{NODE_IMAGE\} AS build/)
|
assert.match(ignore, /^\.env$/m)
|
||||||
assert.match(dockerfile, /FROM \$\{NGINX_IMAGE\}/)
|
assert.match(ignore, /^\.env\.\*$/m)
|
||||||
assert.match(jenkinsfile, /NODE_BASE_IMAGE = '172\.31\.198\.7:10082\/java-project\/node:22-alpine'/)
|
assert.match(compose, /healthcheck:/)
|
||||||
assert.match(jenkinsfile, /NGINX_BASE_IMAGE = '172\.31\.198\.7:10082\/java-project\/nginx:1\.27-alpine'/)
|
assert.match(compose, /http:\/\/127\.0\.0\.1\/healthz/)
|
||||||
assert.match(jenkinsfile, /withCredentials\(\[usernamePassword\(/)
|
assert.match(jenkins, /up -d --wait --wait-timeout 120/)
|
||||||
assert.match(jenkinsfile, /export DOCKER_CONFIG="\$\{WORKSPACE\}@tmp\/docker-auth"/)
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { overseasApiRequest } from '@/api/overseasHttp'
|
import { apiRequest } from '@/api/http'
|
||||||
|
|
||||||
export interface PageResult<T> {
|
export interface PageResult<T> {
|
||||||
records: T[]
|
records: T[]
|
||||||
@@ -141,23 +141,23 @@ function normalizePage<T>(data: PageResult<T>): PageResult<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function listBusinessOrders<T>(kind: CrossBorderBusinessKind, params: BusinessOrderQuery) {
|
export function listBusinessOrders<T>(kind: CrossBorderBusinessKind, params: BusinessOrderQuery) {
|
||||||
return overseasApiRequest<PageResult<T>>({ method: 'GET', url: endpointByKind[kind], params }).then(normalizePage)
|
return apiRequest<PageResult<T>>({ method: 'GET', url: endpointByKind[kind], params }).then(normalizePage)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBusinessOrder<T>(kind: CrossBorderBusinessKind, orderNo: string) {
|
export function getBusinessOrder<T>(kind: CrossBorderBusinessKind, orderNo: string) {
|
||||||
return overseasApiRequest<T>({ method: 'GET', url: `${endpointByKind[kind]}/${encodeURIComponent(orderNo)}` })
|
return apiRequest<T>({ method: 'GET', url: `${endpointByKind[kind]}/${encodeURIComponent(orderNo)}` })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createProcurement(payload: ProcurementCreate) {
|
export function createProcurement(payload: ProcurementCreate) {
|
||||||
return overseasApiRequest<ProcurementDetail>({ method: 'POST', url: endpointByKind.procurement, data: payload })
|
return apiRequest<ProcurementDetail>({ method: 'POST', url: endpointByKind.procurement, data: payload })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createTransport(kind: 'trucking' | 'looseboard', payload: TransportCreate) {
|
export function createTransport(kind: 'trucking' | 'looseboard', payload: TransportCreate) {
|
||||||
return overseasApiRequest<TransportDetail>({ method: 'POST', url: endpointByKind[kind], data: payload })
|
return apiRequest<TransportDetail>({ method: 'POST', url: endpointByKind[kind], data: payload })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateTransport(kind: 'trucking' | 'looseboard', orderNo: string, payload: TransportUpdate) {
|
export function updateTransport(kind: 'trucking' | 'looseboard', orderNo: string, payload: TransportUpdate) {
|
||||||
return overseasApiRequest<TransportDetail>({
|
return apiRequest<TransportDetail>({
|
||||||
method: kind === 'trucking' ? 'PATCH' : 'PUT',
|
method: kind === 'trucking' ? 'PATCH' : 'PUT',
|
||||||
url: `${endpointByKind[kind]}/${encodeURIComponent(orderNo)}`,
|
url: `${endpointByKind[kind]}/${encodeURIComponent(orderNo)}`,
|
||||||
data: payload,
|
data: payload,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ApiError, apiRequest } from '@/api/http'
|
import { ApiError, apiRequest } from '@/api/http'
|
||||||
import type { ApiContainerMode, ApiDestinationType, ApiFirstLegStatus, ApiPickupMethod, MerchantFirstLegOrderDetail, PageResult } from '@/modules/first-leg/api/firstLeg'
|
import type { ApiContainerMode, ApiDestinationType, ApiFirstLegStatus, ApiPickupMethod, ApiTaxMode, MerchantFirstLegOrderDetail, PageResult } from '@/modules/first-leg/api/firstLeg'
|
||||||
|
|
||||||
export type ApiUnifiedOrderMainStatus =
|
export type ApiUnifiedOrderMainStatus =
|
||||||
| 'PENDING_REVIEW'
|
| 'PENDING_REVIEW'
|
||||||
@@ -83,6 +83,8 @@ export interface MerchantUnifiedOrderRow {
|
|||||||
actualGrossWeightKg: number | null
|
actualGrossWeightKg: number | null
|
||||||
actualVolumeCbm: number | null
|
actualVolumeCbm: number | null
|
||||||
pickupMethod: ApiPickupMethod | null
|
pickupMethod: ApiPickupMethod | null
|
||||||
|
taxMode: ApiTaxMode | null
|
||||||
|
customsInspection: boolean | null
|
||||||
requestedPickupAt: string | null
|
requestedPickupAt: string | null
|
||||||
containerMode: ApiContainerMode | null
|
containerMode: ApiContainerMode | null
|
||||||
destinationType: ApiDestinationType | null
|
destinationType: ApiDestinationType | null
|
||||||
|
|||||||
@@ -303,7 +303,8 @@ function clearanceResultStatusLabel(value: MerchantClearanceResult) {
|
|||||||
|
|
||||||
<div v-if="locked" class="customs-locked" role="status">
|
<div v-if="locked" class="customs-locked" role="status">
|
||||||
<LockKeyhole :size="18" />
|
<LockKeyhole :size="18" />
|
||||||
<div><strong>{{ detail.brokerSubmittedAt ? '已发送报关行执行' : '报关资料已锁定' }}</strong><span>{{ detail.brokerSubmittedAt ? '平台已将客户确认后的回执发送给报关行,等待真实报关完税文件。' : '报关开始后仅可查看和下载,不提供退回或解锁。' }}</span></div>
|
<div v-if="detail.status === 'COMPLETED'"><strong>报关已完成</strong><span>真实报关完税文件已齐全,可查看和下载资料、回执及完税文件。</span></div>
|
||||||
|
<div v-else><strong>{{ detail.brokerSubmittedAt ? '已发送报关行执行' : '报关资料已锁定' }}</strong><span>{{ detail.brokerSubmittedAt ? '平台已将客户确认后的回执发送给报关行,等待真实报关完税文件。' : '客户原始资料已冻结,仍可确认或打回待确认的报关行回执。' }}</span></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="customs-section">
|
<section class="customs-section">
|
||||||
|
|||||||
@@ -107,6 +107,8 @@ test('declares the final C02 row and detail contract in the API adapter', () =>
|
|||||||
for (const field of ['pickupMethod', 'requestedPickupAt', 'containerMode', 'destinationType', 'destinationCode', 'skuNos', 'totalQuantity', 'totalNetWeightKg', 'chargeableVolumeCbm', 'inboundCompletedAt', 'shipmentId', 'referenceId']) {
|
for (const field of ['pickupMethod', 'requestedPickupAt', 'containerMode', 'destinationType', 'destinationCode', 'skuNos', 'totalQuantity', 'totalNetWeightKg', 'chargeableVolumeCbm', 'inboundCompletedAt', 'shipmentId', 'referenceId']) {
|
||||||
assert.match(unifiedApiSource, new RegExp(`${field}:`))
|
assert.match(unifiedApiSource, new RegExp(`${field}:`))
|
||||||
}
|
}
|
||||||
|
assert.match(unifiedApiSource, /taxMode: ApiTaxMode \| null/)
|
||||||
|
assert.match(unifiedApiSource, /customsInspection: boolean \| null/)
|
||||||
assert.match(unifiedApiSource, /loadingVersion: number/)
|
assert.match(unifiedApiSource, /loadingVersion: number/)
|
||||||
assert.match(unifiedApiSource, /pickingVersion: number/)
|
assert.match(unifiedApiSource, /pickingVersion: number/)
|
||||||
assert.match(unifiedApiSource, /summary: MerchantUnifiedOrderRow/)
|
assert.match(unifiedApiSource, /summary: MerchantUnifiedOrderRow/)
|
||||||
@@ -142,6 +144,8 @@ test('maps C02 authoritative status, transport, cargo and pickup snapshots', ()
|
|||||||
assert.match(storeSource, /id: String\(summary\.orderId\)/)
|
assert.match(storeSource, /id: String\(summary\.orderId\)/)
|
||||||
assert.match(storeSource, /transportMode: unifiedTransport\(summary\.containerMode\)/)
|
assert.match(storeSource, /transportMode: unifiedTransport\(summary\.containerMode\)/)
|
||||||
assert.match(storeSource, /status: summary\.mainStatus/)
|
assert.match(storeSource, /status: summary\.mainStatus/)
|
||||||
|
assert.match(storeSource, /taxMode: summary\.taxMode \?\? 'BUY_DECLARATION'/)
|
||||||
|
assert.match(storeSource, /customsInspection: summary\.customsInspection \?\? false/)
|
||||||
assert.match(storeSource, /fulfillmentType: summary\.fulfillmentType \?\? 'STANDARD'/)
|
assert.match(storeSource, /fulfillmentType: summary\.fulfillmentType \?\? 'STANDARD'/)
|
||||||
assert.match(storeSource, /lxCode: summary\.lxCode \?\? ''/)
|
assert.match(storeSource, /lxCode: summary\.lxCode \?\? ''/)
|
||||||
assert.match(storeSource, /mapped\.fulfillmentType = detail\.summary\.fulfillmentType \?\? mapped\.fulfillmentType/)
|
assert.match(storeSource, /mapped\.fulfillmentType = detail\.summary\.fulfillmentType \?\? mapped\.fulfillmentType/)
|
||||||
|
|||||||
@@ -343,7 +343,7 @@ function mapUnifiedOrderRow(summary: MerchantUnifiedOrderRow, warehouses: Platfo
|
|||||||
createdAt: summary.createdAt, updatedAt: summary.updatedAt, tradeTerm: '', destinationCountryCode: '',
|
createdAt: summary.createdAt, updatedAt: summary.updatedAt, tradeTerm: '', destinationCountryCode: '',
|
||||||
destinationWarehouseId: summary.destinationWarehouseId == null ? '' : String(summary.destinationWarehouseId),
|
destinationWarehouseId: summary.destinationWarehouseId == null ? '' : String(summary.destinationWarehouseId),
|
||||||
destinationWarehouseName: summary.destinationWarehouseName ?? '', pickupMethod: summary.pickupMethod ?? 'SELF_EXPRESS', channelCode: '',
|
destinationWarehouseName: summary.destinationWarehouseName ?? '', pickupMethod: summary.pickupMethod ?? 'SELF_EXPRESS', channelCode: '',
|
||||||
channelName: summary.channelName ?? '', pricingTaxMode: 'TAX_INCLUDED', carrierId: '', carrierName: '', taxMode: 'BUY_DECLARATION', customsInspection: false,
|
channelName: summary.channelName ?? '', pricingTaxMode: 'TAX_INCLUDED', carrierId: '', carrierName: '', taxMode: summary.taxMode ?? 'BUY_DECLARATION', customsInspection: summary.customsInspection ?? false,
|
||||||
destinationType: summary.destinationType ?? 'PRIVATE', amazonWarehouseCode: summary.destinationCode ?? '', shipmentId: summary.shipmentId ?? '',
|
destinationType: summary.destinationType ?? 'PRIVATE', amazonWarehouseCode: summary.destinationCode ?? '', shipmentId: summary.shipmentId ?? '',
|
||||||
requestedPickupAt: summary.requestedPickupAt ?? '', receivingTime: '', submittedAt: '', completedAt: '',
|
requestedPickupAt: summary.requestedPickupAt ?? '', receivingTime: '', submittedAt: '', completedAt: '',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { FileText, RefreshCcw } from '@lucide/vue'
|
import { RefreshCcw } from '@lucide/vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
|
||||||
|
import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue'
|
||||||
|
import type { TableAction } from '@/components/common/tableActions'
|
||||||
import { useFirstLegOrdersStore } from '../public'
|
import { useFirstLegOrdersStore } from '../public'
|
||||||
import { getMerchantCustomsForOrder, type MerchantCustomsContainer } from '../api/customs'
|
import { getMerchantCustomsForOrder, type MerchantCustomsContainer } from '../api/customs'
|
||||||
import CustomsDocumentsDialog from '../components/CustomsDocumentsDialog.vue'
|
import CustomsDocumentsDialog from '../components/CustomsDocumentsDialog.vue'
|
||||||
@@ -10,17 +12,59 @@ import type { FirstLegOrder } from '../types/firstLegOrder'
|
|||||||
const store = useFirstLegOrdersStore()
|
const store = useFirstLegOrdersStore()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
const keyword = ref('')
|
const queryModel = ref<QueryModel>({ orderNo: '', inboundCode: '', containerNo: '', status: '' })
|
||||||
|
const appliedQuery = ref<QueryModel>({ ...queryModel.value })
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(20)
|
||||||
|
const queryFields: QueryField[] = [
|
||||||
|
{ key: 'orderNo', label: '订单号', type: 'input', placeholder: '请输入订单号' },
|
||||||
|
{ key: 'inboundCode', label: '入仓码', type: 'input', placeholder: '请输入入仓码' },
|
||||||
|
{ key: 'containerNo', label: '柜号', type: 'input', placeholder: '请输入柜号' },
|
||||||
|
{ key: 'status', label: '状态', type: 'select', placeholder: '全部', options: [
|
||||||
|
{ label: '待上传资料', value: 'WAITING_CUSTOMER_DOCUMENTS' },
|
||||||
|
{ label: '待报关', value: 'READY_TO_START' },
|
||||||
|
{ label: '报关中', value: 'DECLARING' },
|
||||||
|
{ label: '报关完成', value: 'COMPLETED' },
|
||||||
|
] },
|
||||||
|
]
|
||||||
|
const columns: DataTableColumn[] = [
|
||||||
|
{ label: '操作', slotName: 'actions', width: 120, fixed: 'left' },
|
||||||
|
{ label: '订单号', slotName: 'orderNo', minWidth: 230 },
|
||||||
|
{ label: '入仓码', slotName: 'inboundCode', minWidth: 190 },
|
||||||
|
{ label: '柜号', slotName: 'containerNo', minWidth: 170 },
|
||||||
|
{ label: '实际箱数', prop: 'detail.actualBoxCount', width: 110, align: 'center' },
|
||||||
|
{ label: '资料覆盖', slotName: 'coverage', width: 110, align: 'center' },
|
||||||
|
{ label: '状态', slotName: 'status', width: 130 },
|
||||||
|
]
|
||||||
const records = ref<Array<{ order: FirstLegOrder; detail: MerchantCustomsContainer }>>([])
|
const records = ref<Array<{ order: FirstLegOrder; detail: MerchantCustomsContainer }>>([])
|
||||||
const dialogOpen = ref(false)
|
const dialogOpen = ref(false)
|
||||||
const selectedOrderId = ref('')
|
const selectedOrderId = ref('')
|
||||||
const selectedDetail = ref<MerchantCustomsContainer | null>(null)
|
const selectedDetail = ref<MerchantCustomsContainer | null>(null)
|
||||||
|
|
||||||
const filteredRecords = computed(() => {
|
const filteredRecords = computed(() => {
|
||||||
const query = keyword.value.trim().toLowerCase()
|
const query = appliedQuery.value
|
||||||
if (!query) return records.value
|
const matches = (value: string | null | undefined, key: string) => String(value ?? '').toLowerCase().includes(String(query[key] || '').trim().toLowerCase())
|
||||||
return records.value.filter(({ order, detail }) => [order.orderNo, order.businessSummary.inboundCode, detail.containerNo, detail.cabinetNo].some((value) => String(value ?? '').toLowerCase().includes(query)))
|
return records.value.filter(({ order, detail }) =>
|
||||||
|
matches(order.orderNo, 'orderNo')
|
||||||
|
&& matches(order.businessSummary.inboundCode, 'inboundCode')
|
||||||
|
&& (matches(detail.containerNo, 'containerNo') || matches(detail.cabinetNo, 'containerNo'))
|
||||||
|
&& (!query.status || detail.status === query.status),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
const pagedRecords = computed(() => filteredRecords.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
|
||||||
|
const emptyText = computed(() => Object.values(appliedQuery.value).some((value) => String(value || '').trim()) ? '暂无匹配的报关资料,请调整查询条件' : '暂无报关资料')
|
||||||
|
|
||||||
|
function search() {
|
||||||
|
appliedQuery.value = { ...queryModel.value }
|
||||||
|
page.value = 1
|
||||||
|
}
|
||||||
|
watch(pageSize, () => { page.value = 1 })
|
||||||
|
watch(() => filteredRecords.value.length, (total) => {
|
||||||
|
page.value = Math.min(page.value, Math.max(1, Math.ceil(total / pageSize.value)))
|
||||||
|
})
|
||||||
|
function rowActions(row: object): TableAction[] {
|
||||||
|
return [{ key: 'documents', label: '查看资料', disabled: loading.value, run: () => openDetail(row) }]
|
||||||
|
}
|
||||||
|
|
||||||
function statusLabel(detail: MerchantCustomsContainer) {
|
function statusLabel(detail: MerchantCustomsContainer) {
|
||||||
return ({ WAITING_CUSTOMER_DOCUMENTS: '待上传资料', READY_TO_START: '待报关', DECLARING: '报关中', COMPLETED: '报关完成' } as Record<string, string>)[detail.status] ?? detail.status
|
return ({ WAITING_CUSTOMER_DOCUMENTS: '待上传资料', READY_TO_START: '待报关', DECLARING: '报关中', COMPLETED: '报关完成' } as Record<string, string>)[detail.status] ?? detail.status
|
||||||
@@ -29,6 +73,7 @@ function statusType(detail: MerchantCustomsContainer) {
|
|||||||
return detail.status === 'COMPLETED' ? 'success' : detail.status === 'DECLARING' ? 'primary' : 'warning'
|
return detail.status === 'COMPLETED' ? 'success' : detail.status === 'DECLARING' ? 'primary' : 'warning'
|
||||||
}
|
}
|
||||||
async function load() {
|
async function load() {
|
||||||
|
if (loading.value) return
|
||||||
loading.value = true; errorMessage.value = ''
|
loading.value = true; errorMessage.value = ''
|
||||||
try {
|
try {
|
||||||
await store.load()
|
await store.load()
|
||||||
@@ -53,26 +98,25 @@ onMounted(() => { void load() })
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="customs-page">
|
<div class="customs-page">
|
||||||
<section class="customs-hero"><div><span class="eyebrow">CUSTOMS DOCUMENTS</span><h1>报关资料</h1><p>查看装柜后退税订单的资料清单、文件版本与报关行回执。</p></div><el-button :loading="loading" @click="load"><RefreshCcw :size="15" />刷新</el-button></section>
|
<QueryToolbar v-model="queryModel" layout="grid" ariaLabel="报关资料查询条件" :fields="queryFields" @search="search" />
|
||||||
<section class="customs-toolbar"><el-input v-model="keyword" clearable placeholder="搜索订单号、入仓码或柜号" /><span>共 {{ filteredRecords.length }} 个资料包</span></section>
|
|
||||||
<section class="customs-card">
|
<section class="customs-card">
|
||||||
<el-skeleton v-if="loading" :rows="5" animated />
|
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="pagedRecords" :columns="columns" :loading="loading" :total="filteredRecords.length" :page-sizes="[10, 20, 50, 100]" :row-actions="rowActions" row-key="order.id" :empty-text="emptyText">
|
||||||
<div v-else-if="errorMessage" class="state-error" role="alert"><p>{{ errorMessage }}</p><el-button @click="load">重试</el-button></div>
|
<template #toolbar><strong>资料列表</strong></template>
|
||||||
<el-empty v-else-if="!filteredRecords.length" description="暂无待处理的报关资料" />
|
<template #toolbar-actions><el-button :loading="loading" @click="load"><RefreshCcw :size="16" />刷新</el-button></template>
|
||||||
<el-table v-else :data="filteredRecords" row-key="order.id" stripe>
|
<template v-if="errorMessage" #error><div class="state-error" role="alert"><span>{{ errorMessage }}</span><el-button :loading="loading" @click="load">重新加载</el-button></div></template>
|
||||||
<el-table-column label="操作" width="120" fixed="left"><template #default="{ row }"><el-button link type="primary" @click="openDetail(row)"><FileText :size="15" />查看资料</el-button></template></el-table-column>
|
<template #orderNo="{ row }">{{ row.order.orderNo || '—' }}</template>
|
||||||
<el-table-column label="订单号" min-width="160"><template #default="{ row }">{{ row.order.orderNo || '—' }}</template></el-table-column>
|
<template #inboundCode="{ row }">{{ row.order.businessSummary.inboundCode || '—' }}</template>
|
||||||
<el-table-column label="入仓码" min-width="150"><template #default="{ row }">{{ row.order.businessSummary.inboundCode || '—' }}</template></el-table-column>
|
<template #containerNo="{ row }">{{ row.detail.containerNo || row.detail.cabinetNo || '—' }}</template>
|
||||||
<el-table-column label="柜号" min-width="140"><template #default="{ row }">{{ row.detail.containerNo || row.detail.cabinetNo || '—' }}</template></el-table-column>
|
<template #coverage="{ row }">{{ row.detail.coveredTaxRefundCount }} / {{ row.detail.taxRefundCount }}</template>
|
||||||
<el-table-column label="实际箱数" width="100" prop="detail.actualBoxCount" />
|
<template #status="{ row }"><el-tag :type="statusType(row.detail)" effect="light" size="small">{{ statusLabel(row.detail) }}</el-tag></template>
|
||||||
<el-table-column label="资料覆盖" width="110"><template #default="{ row }">{{ row.detail.coveredTaxRefundCount }} / {{ row.detail.taxRefundCount }}</template></el-table-column>
|
</DataTable>
|
||||||
<el-table-column label="状态" width="120"><template #default="{ row }"><el-tag :type="statusType(row.detail)">{{ statusLabel(row.detail) }}</el-tag></template></el-table-column>
|
|
||||||
</el-table>
|
|
||||||
</section>
|
</section>
|
||||||
<CustomsDocumentsDialog v-model="dialogOpen" :order-id="selectedOrderId" :can-edit="selectedDetail?.status !== 'COMPLETED'" :initial-detail="selectedDetail" @updated="updateDetail" />
|
<CustomsDocumentsDialog v-model="dialogOpen" :order-id="selectedOrderId" :can-edit="selectedDetail?.status !== 'COMPLETED'" :initial-detail="selectedDetail" @updated="updateDetail" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.customs-page { display:flex; flex-direction:column; gap:16px; }.customs-hero { display:flex; justify-content:space-between; align-items:flex-start; padding:0 2px 4px; }.eyebrow { color:#5b82c9; font-size:11px; font-weight:750; letter-spacing:.14em; }.customs-hero h1 { margin:6px 0 5px; color:#1f2f46; font-size:26px; line-height:1.25; letter-spacing:-.025em; }.customs-hero p { margin:0; color:#7d8da2; font-size:13px; }.customs-hero .el-button { margin-top:3px; border-color:#d9e3ef; border-radius:9px; background:#fff; color:#61738b; box-shadow:0 3px 8px rgba(26,55,91,.04); }.customs-toolbar { display:flex; min-height:82px; align-items:center; gap:16px; padding:16px 18px; border:1px solid #dce4ef; border-radius:16px; background:#fff; box-shadow:0 5px 16px rgba(31,56,88,.045); }.customs-toolbar .el-input { width:min(520px, 100%); }.customs-toolbar :deep(.el-input__wrapper) { min-height:42px; border-radius:10px; box-shadow:0 0 0 1px #d9e3ef inset; }.customs-toolbar span { color:#7d8da2; font-size:12px; }.customs-card { min-height:320px; padding:4px; overflow:hidden; border:1px solid #dce4ef; border-radius:16px; background:#fff; box-shadow:0 5px 16px rgba(31,56,88,.045); }.customs-card :deep(.el-table) { --el-table-header-bg-color:#f4f7fb; --el-table-row-hover-bg-color:#eaf2ff; --el-table-border-color:#edf1f6; color:#53647a; }.customs-card :deep(.el-table th.el-table__cell) { padding:13px 16px; color:#607089; font-size:12px; font-weight:750; }.customs-card :deep(.el-table td.el-table__cell) { height:58px; padding:13px 16px; font-size:13px; }.customs-card :deep(.el-table__row:nth-child(even)) { background:#f8fbff; }.state-error { display:flex; min-height:260px; flex-direction:column; align-items:center; justify-content:center; gap:10px; color:#b42318; }.state-error p { margin:0; }
|
.customs-page { display: grid; gap: var(--client-section-gap); }
|
||||||
|
.customs-card { overflow: hidden; border: 1px solid var(--client-border); border-radius: var(--client-radius); background: #fff; }
|
||||||
|
.state-error { display: flex; min-height: 84px; align-items: center; justify-content: center; gap: 12px; padding: 14px; color: #b42318; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -705,7 +705,9 @@ test('supports the frozen customs document workflow and customer-safe downloads'
|
|||||||
assert.match(customsApiSource, /MAX_CUSTOMS_FILE_SIZE = 50 \* 1024 \* 1024/)
|
assert.match(customsApiSource, /MAX_CUSTOMS_FILE_SIZE = 50 \* 1024 \* 1024/)
|
||||||
assert.match(customsApiSource, /data: \{ inboundIds, expectedVersion: document\.source\.version \}/)
|
assert.match(customsApiSource, /data: \{ inboundIds, expectedVersion: document\.source\.version \}/)
|
||||||
assert.match(customsApiSource, /requestId: error\.requestId/)
|
assert.match(customsApiSource, /requestId: error\.requestId/)
|
||||||
assert.match(customsDialogSource, /报关开始后仅可查看和下载,不提供退回或解锁/)
|
assert.match(customsDialogSource, /客户原始资料已冻结,仍可确认或打回待确认的报关行回执/)
|
||||||
|
assert.match(customsDialogSource, /v-if="detail.status === 'COMPLETED'"[^>]*><strong>报关已完成/)
|
||||||
|
assert.match(customsDialogSource, /真实报关完税文件已齐全/)
|
||||||
assert.match(customsDialogSource, /item\.finalDocument/)
|
assert.match(customsDialogSource, /item\.finalDocument/)
|
||||||
assert.match(customsDialogSource, /资料版本历史/)
|
assert.match(customsDialogSource, /资料版本历史/)
|
||||||
assert.match(customsDialogSource, /尚未上传退税报关资料/)
|
assert.match(customsDialogSource, /尚未上传退税报关资料/)
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ async function placeOrder(channelCode: string, channelName: string) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
watch(originWarehouseId, () => { void loadChannels() }); onMounted(() => { void loadBase() })
|
watch(originWarehouseId, (value, previous) => { if (!value || previous === '') return; void loadChannels() }); onMounted(() => { void loadBase() })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import { readFileSync } from 'node:fs'
|
import { readFileSync } from 'node:fs'
|
||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
|
import { runInNewContext } from 'node:vm'
|
||||||
|
import ts from 'typescript'
|
||||||
|
|
||||||
const firstLeg = readFileSync(new URL('./FirstLegOrdersView.vue', import.meta.url), 'utf8')
|
const firstLeg = readFileSync(new URL('./FirstLegOrdersView.vue', import.meta.url), 'utf8')
|
||||||
const quickOrder = readFileSync(new URL('./QuickOrderView.vue', import.meta.url), 'utf8')
|
const quickOrder = readFileSync(new URL('./QuickOrderView.vue', import.meta.url), 'utf8')
|
||||||
@@ -18,7 +20,22 @@ test('uses business identifiers and names without visible internal-ID fallbacks'
|
|||||||
assert.doesNotMatch(quickOrder, />\{\{ result\.orderNo/)
|
assert.doesNotMatch(quickOrder, />\{\{ result\.orderNo/)
|
||||||
assert.doesNotMatch(customs, /detail\.planNo \|\| detail\.loadingPlanId/)
|
assert.doesNotMatch(customs, /detail\.planNo \|\| detail\.loadingPlanId/)
|
||||||
assert.doesNotMatch(overseas, /overseasWarehouseName \|\| (?:item|candidate)\.overseasWarehouseId/)
|
assert.doesNotMatch(overseas, /overseasWarehouseName \|\| (?:item|candidate)\.overseasWarehouseId/)
|
||||||
assert.doesNotMatch(overseas, /row\(\[[^\]]*(?:item\.overseasWarehouseId|item\.operatingWarehouseId)/)
|
// Warehouse IDs may be inputs to a resolver, but must never be raw cells.
|
||||||
|
assert.doesNotMatch(overseas, /row\(\[[^\]]*[,[]\s*item\.(?:overseasWarehouseId|operatingWarehouseId)\s*[,\]]/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('warehouse labels never fall back to unknown internal IDs', () => {
|
||||||
|
const source = overseas.slice(overseas.indexOf('function warehouseDisplay('), overseas.indexOf('function statusLabel('))
|
||||||
|
const javascript = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText
|
||||||
|
const display = runInNewContext(`${javascript}; warehouseDisplay`, {
|
||||||
|
operationWarehouses: { value: [{ id: '1234567890123456789', warehouseCode: 'LAX', warehouseName: '洛杉矶仓' }] },
|
||||||
|
fbaWarehouses: { value: [] }, warehouseLabel: (warehouse) => warehouse.warehouseName,
|
||||||
|
})
|
||||||
|
assert.equal(display('1234567890123456789'), '洛杉矶仓')
|
||||||
|
assert.equal(display('LAX'), '洛杉矶仓')
|
||||||
|
assert.equal(display('9999999999999999999'), '—')
|
||||||
|
assert.equal(display('1234567890123456789,9999999999999999999'), '洛杉矶仓、—')
|
||||||
|
assert.equal(display(undefined), '—')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('keeps internal IDs only as non-rendered routing, selection, and request values', () => {
|
test('keeps internal IDs only as non-rendered routing, selection, and request values', () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { overseasApiRequest } from '@/api/overseasHttp'
|
import { apiRequest } from '@/api/http'
|
||||||
|
|
||||||
export interface PageResult<T> {
|
export interface PageResult<T> {
|
||||||
records: T[]
|
records: T[]
|
||||||
@@ -133,7 +133,7 @@ export interface DeliveryTransferPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function listDeliveryOrders(params: DeliveryQuery) {
|
export function listDeliveryOrders(params: DeliveryQuery) {
|
||||||
return overseasApiRequest<PageResult<DeliveryRow>>({
|
return apiRequest<PageResult<DeliveryRow>>({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
url: '/merchant/v1/last-leg/delivery-orders',
|
url: '/merchant/v1/last-leg/delivery-orders',
|
||||||
params,
|
params,
|
||||||
@@ -141,7 +141,7 @@ export function listDeliveryOrders(params: DeliveryQuery) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getDeliveryOrder(deliveryNo: string) {
|
export function getDeliveryOrder(deliveryNo: string) {
|
||||||
return overseasApiRequest<DeliveryOrderDetailResponse>({
|
return apiRequest<DeliveryOrderDetailResponse>({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
url: `/merchant/v1/last-leg/delivery-orders/${encodeURIComponent(deliveryNo)}`,
|
url: `/merchant/v1/last-leg/delivery-orders/${encodeURIComponent(deliveryNo)}`,
|
||||||
params: { page: 1, size: 200 },
|
params: { page: 1, size: 200 },
|
||||||
@@ -154,7 +154,7 @@ export function getDeliveryOrder(deliveryNo: string) {
|
|||||||
|
|
||||||
export function createDeliveryOrder(payload: DeliveryTransferPayload) {
|
export function createDeliveryOrder(payload: DeliveryTransferPayload) {
|
||||||
const { dispatchNo, ...request } = payload
|
const { dispatchNo, ...request } = payload
|
||||||
return overseasApiRequest<DeliveryTransferResult>({
|
return apiRequest<DeliveryTransferResult>({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: '/merchant/v1/last-leg/delivery-orders',
|
url: '/merchant/v1/last-leg/delivery-orders',
|
||||||
params: { dispatchNo },
|
params: { dispatchNo },
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { overseasApiRequest } from '@/api/overseasHttp'
|
import { apiRequest } from '@/api/http'
|
||||||
|
|
||||||
export interface PageResult<T> { records: T[]; page: number; size: number; total: number; pages: number }
|
export interface PageResult<T> { records: T[]; page: number; size: number; total: number; pages: number }
|
||||||
export interface InboundPlanQuery {
|
export interface InboundPlanQuery {
|
||||||
@@ -66,10 +66,10 @@ export interface InboundPlanDetail {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function listInboundPlans(params: InboundPlanQuery) {
|
export async function listInboundPlans(params: InboundPlanQuery) {
|
||||||
const result = await overseasApiRequest<PageResult<InboundPlanSummary>>({ method: 'GET', url: '/merchant/v1/last-leg/inbound-plans', params })
|
const result = await apiRequest<PageResult<InboundPlanSummary>>({ method: 'GET', url: '/merchant/v1/last-leg/inbound-plans', params })
|
||||||
return { ...result, page: Number(result.page), size: Number(result.size), total: Number(result.total), pages: Number(result.pages) }
|
return { ...result, page: Number(result.page), size: Number(result.size), total: Number(result.total), pages: Number(result.pages) }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getInboundPlan(planNo: string) {
|
export function getInboundPlan(planNo: string) {
|
||||||
return overseasApiRequest<InboundPlanDetail>({ method: 'GET', url: `/merchant/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}` })
|
return apiRequest<InboundPlanDetail>({ method: 'GET', url: `/merchant/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}` })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ test('uses only authenticated merchant delivery endpoints without customer scope
|
|||||||
assert.match(apiSource, /method: 'POST'[\s\S]*url: '\/merchant\/v1\/last-leg\/delivery-orders'/)
|
assert.match(apiSource, /method: 'POST'[\s\S]*url: '\/merchant\/v1\/last-leg\/delivery-orders'/)
|
||||||
assert.match(apiSource, /const \{ dispatchNo, \.\.\.request \} = payload/)
|
assert.match(apiSource, /const \{ dispatchNo, \.\.\.request \} = payload/)
|
||||||
assert.match(apiSource, /params: \{ dispatchNo \}/)
|
assert.match(apiSource, /params: \{ dispatchNo \}/)
|
||||||
assert.match(apiSource, /overseasApiRequest<DeliveryOrderDetailResponse>/)
|
assert.match(apiSource, /apiRequest<DeliveryOrderDetailResponse>/)
|
||||||
assert.match(apiSource, /\.then\(\(\{ order, skus, fees \}\) => \(\{[\s\S]*\.\.\.order,[\s\S]*fees,[\s\S]*items: skus/)
|
assert.match(apiSource, /\.then\(\(\{ order, skus, fees \}\) => \(\{[\s\S]*\.\.\.order,[\s\S]*fees,[\s\S]*items: skus/)
|
||||||
assert.match(apiSource, /overseasApiRequest<DeliveryTransferResult>/)
|
assert.match(apiSource, /apiRequest<DeliveryTransferResult>/)
|
||||||
assert.doesNotMatch(apiSource, /customerId|tenantId|Record<string, unknown>|mock|fallback/i)
|
assert.doesNotMatch(apiSource, /customerId|tenantId|Record<string, unknown>|mock|fallback/i)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { overseasWarehouseLabel } from './inboundPlanDisplay.ts'
|
|||||||
const api = readFileSync(new URL('./api/inboundPlans.ts', import.meta.url), 'utf8')
|
const api = readFileSync(new URL('./api/inboundPlans.ts', import.meta.url), 'utf8')
|
||||||
const routes = readFileSync(new URL('./routes.ts', import.meta.url), 'utf8')
|
const routes = readFileSync(new URL('./routes.ts', import.meta.url), 'utf8')
|
||||||
const view = readFileSync(new URL('./views/InboundOrdersView.vue', import.meta.url), 'utf8')
|
const view = readFileSync(new URL('./views/InboundOrdersView.vue', import.meta.url), 'utf8')
|
||||||
const overseasHttp = readFileSync(new URL('../../api/overseasHttp.ts', import.meta.url), 'utf8')
|
const overseasHttp = readFileSync(new URL('../../api/http.ts', import.meta.url), 'utf8')
|
||||||
|
|
||||||
test('activates the customer-scoped overseas inbound list and detail', () => {
|
test('activates the customer-scoped overseas inbound list and detail', () => {
|
||||||
assert.match(routes, /path: 'orders\/inbound'[\s\S]*?release: 'active'[\s\S]*?permission: 'merchant:warehouse:inbound:list'/)
|
assert.match(routes, /path: 'orders\/inbound'[\s\S]*?release: 'active'[\s\S]*?permission: 'merchant:warehouse:inbound:list'/)
|
||||||
@@ -48,9 +48,9 @@ test('renders a dash when either formal receiving warehouse field is absent', ()
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('preserves overseas API diagnostic metadata without rendering it as a business identifier', () => {
|
test('preserves overseas API diagnostic metadata without rendering it as a business identifier', () => {
|
||||||
assert.match(overseasHttp, /response\.data\.traceId/)
|
assert.match(overseasHttp, /envelope\.traceId/)
|
||||||
assert.match(overseasHttp, /response\.data\.requestId/)
|
assert.match(overseasHttp, /envelope\.requestId/)
|
||||||
assert.match(overseasHttp, /body\.traceId, body\.requestId/)
|
assert.match(overseasHttp, /body\.traceId/)
|
||||||
assert.doesNotMatch(view, /requestId|traceId/)
|
assert.doesNotMatch(view, /requestId|traceId/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { overseasApiRequest } from '@/api/overseasHttp'
|
import { apiRequest } from '@/api/http'
|
||||||
import type { PageResult } from './warehouseServices'
|
import type { PageResult } from './warehouseServices'
|
||||||
|
|
||||||
export interface ReturnInventoryItem {
|
export interface ReturnInventoryItem {
|
||||||
@@ -19,7 +19,7 @@ export interface ReturnInventoryCartonSummary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function listReturnInventoryCartons(params: Record<string, unknown>) {
|
export function listReturnInventoryCartons(params: Record<string, unknown>) {
|
||||||
return overseasApiRequest<PageResult<ReturnInventoryCartonSummary>>({ method: 'GET', url: '/merchant/v1/last-leg/return-inventory/cartons', params })
|
return apiRequest<PageResult<ReturnInventoryCartonSummary>>({ method: 'GET', url: '/merchant/v1/last-leg/return-inventory/cartons', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReturnInventoryCartonDetail {
|
export interface ReturnInventoryCartonDetail {
|
||||||
@@ -33,19 +33,19 @@ export interface ReturnInventoryLabel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function listReturnInventory(params: Record<string, unknown>) {
|
export function listReturnInventory(params: Record<string, unknown>) {
|
||||||
return overseasApiRequest<PageResult<ReturnInventoryItem>>({ method: 'GET', url: '/merchant/v1/last-leg/return-inventory', params })
|
return apiRequest<PageResult<ReturnInventoryItem>>({ method: 'GET', url: '/merchant/v1/last-leg/return-inventory', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listReturnInventorySummary(params: Record<string, unknown>) {
|
export function listReturnInventorySummary(params: Record<string, unknown>) {
|
||||||
return overseasApiRequest<PageResult<ReturnInventorySummaryItem>>({ method: 'GET', url: '/merchant/v1/last-leg/return-inventory/summary', params })
|
return apiRequest<PageResult<ReturnInventorySummaryItem>>({ method: 'GET', url: '/merchant/v1/last-leg/return-inventory/summary', params })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getReturnInventoryCarton(cartonMark: string) {
|
export function getReturnInventoryCarton(cartonMark: string) {
|
||||||
return overseasApiRequest<ReturnInventoryCartonDetail>({ method: 'GET', url: `/merchant/v1/last-leg/return-inventory/cartons/${encodeURIComponent(cartonMark)}` })
|
return apiRequest<ReturnInventoryCartonDetail>({ method: 'GET', url: `/merchant/v1/last-leg/return-inventory/cartons/${encodeURIComponent(cartonMark)}` })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getReturnInventoryLabel(cartonMark: string) {
|
export function getReturnInventoryLabel(cartonMark: string) {
|
||||||
return overseasApiRequest<ReturnInventoryLabel>({ method: 'GET', url: `/merchant/v1/last-leg/return-inventory/cartons/${encodeURIComponent(cartonMark)}/label` })
|
return apiRequest<ReturnInventoryLabel>({ method: 'GET', url: `/merchant/v1/last-leg/return-inventory/cartons/${encodeURIComponent(cartonMark)}/label` })
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReturnPackingPreview {
|
export interface ReturnPackingPreview {
|
||||||
@@ -55,14 +55,14 @@ export interface ReturnPackingPreview {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getReturnPackingPreview(returnNo?: string) {
|
export function getReturnPackingPreview(returnNo?: string) {
|
||||||
return overseasApiRequest<ReturnPackingPreview>({ method: 'GET', url: '/pda/v1/last-leg/return-inventory/packing/orders', params: { returnNo: returnNo || undefined } })
|
return apiRequest<ReturnPackingPreview>({ method: 'GET', url: '/pda/v1/last-leg/return-inventory/packing/orders', params: { returnNo: returnNo || undefined } })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mergeReturnInventory(payload: {
|
export function mergeReturnInventory(payload: {
|
||||||
returnNos: string[]; items: Array<{ skuCode: string; quantity: number }>
|
returnNos: string[]; items: Array<{ skuCode: string; quantity: number }>
|
||||||
length: number; width: number; height: number; weight: number; idempotencyKey: string
|
length: number; width: number; height: number; weight: number; idempotencyKey: string
|
||||||
}) {
|
}) {
|
||||||
return overseasApiRequest<{ cartonMark: string; skuCode: string; totalQuantity: number; label: ReturnInventoryLabel }>({
|
return apiRequest<{ cartonMark: string; skuCode: string; totalQuantity: number; label: ReturnInventoryLabel }>({
|
||||||
method: 'POST', url: '/pda/v1/last-leg/return-inventory/cartons/merge', data: payload,
|
method: 'POST', url: '/pda/v1/last-leg/return-inventory/cartons/merge', data: payload,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { overseasApiRequest, overseasBlobRequest } from '@/api/overseasHttp'
|
import { apiRequest, apiBlobRequest } from '@/api/http'
|
||||||
import type { PageResult } from './warehouseServices'
|
import type { PageResult } from './warehouseServices'
|
||||||
|
|
||||||
export interface ReturnOrderSummary {
|
export interface ReturnOrderSummary {
|
||||||
@@ -24,16 +24,16 @@ export interface ReturnOrderCreate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function listReturnOrders(params: Record<string, unknown>) {
|
export function listReturnOrders(params: Record<string, unknown>) {
|
||||||
return overseasApiRequest<PageResult<ReturnOrderSummary>>({ method: 'GET', url: '/merchant/v1/last-leg/return-orders', params })
|
return apiRequest<PageResult<ReturnOrderSummary>>({ method: 'GET', url: '/merchant/v1/last-leg/return-orders', params })
|
||||||
}
|
}
|
||||||
export function getReturnOrder(returnNo: string) {
|
export function getReturnOrder(returnNo: string) {
|
||||||
return overseasApiRequest<ReturnOrderDetail>({ method: 'GET', url: `/merchant/v1/last-leg/return-orders/${encodeURIComponent(returnNo)}` })
|
return apiRequest<ReturnOrderDetail>({ method: 'GET', url: `/merchant/v1/last-leg/return-orders/${encodeURIComponent(returnNo)}` })
|
||||||
}
|
}
|
||||||
export function createReturnOrder(payload: ReturnOrderCreate) {
|
export function createReturnOrder(payload: ReturnOrderCreate) {
|
||||||
return overseasApiRequest<ReturnOrderDetail>({ method: 'POST', url: '/merchant/v1/last-leg/return-orders', data: payload })
|
return apiRequest<ReturnOrderDetail>({ method: 'POST', url: '/merchant/v1/last-leg/return-orders', data: payload })
|
||||||
}
|
}
|
||||||
export function updateReturnOrder(returnNo: string, payload: ReturnOrderCreate) {
|
export function updateReturnOrder(returnNo: string, payload: ReturnOrderCreate) {
|
||||||
return overseasApiRequest<ReturnOrderDetail>({ method: 'PUT', url: `/merchant/v1/last-leg/return-orders/${encodeURIComponent(returnNo)}`, data: payload })
|
return apiRequest<ReturnOrderDetail>({ method: 'PUT', url: `/merchant/v1/last-leg/return-orders/${encodeURIComponent(returnNo)}`, data: payload })
|
||||||
}
|
}
|
||||||
export interface ReturnRelabelCreate {
|
export interface ReturnRelabelCreate {
|
||||||
fileScope: 'CARTON_TOTAL' | 'SKU_TOTAL'; destinationFbaWarehouseId: string; destinationFbaWarehouseCode: string; cartonRemark?: string; idempotencyKey: string
|
fileScope: 'CARTON_TOTAL' | 'SKU_TOTAL'; destinationFbaWarehouseId: string; destinationFbaWarehouseCode: string; cartonRemark?: string; idempotencyKey: string
|
||||||
@@ -41,12 +41,12 @@ export interface ReturnRelabelCreate {
|
|||||||
}
|
}
|
||||||
export interface ReturnRelabelCreated { relabelNo: string; relabelApplicationId: string; returnNo: string; skuCodes: string[]; status: string }
|
export interface ReturnRelabelCreated { relabelNo: string; relabelApplicationId: string; returnNo: string; skuCodes: string[]; status: string }
|
||||||
export function createReturnRelabel(returnNo: string, payload: ReturnRelabelCreate) {
|
export function createReturnRelabel(returnNo: string, payload: ReturnRelabelCreate) {
|
||||||
return overseasApiRequest<ReturnRelabelCreated>({ method: 'POST', url: `/merchant/v1/last-leg/return-orders/${encodeURIComponent(returnNo)}/relabel`, data: payload })
|
return apiRequest<ReturnRelabelCreated>({ method: 'POST', url: `/merchant/v1/last-leg/return-orders/${encodeURIComponent(returnNo)}/relabel`, data: payload })
|
||||||
}
|
}
|
||||||
export function uploadReturnRelabelFile(relabelNo: string, scope: 'CARTON_TOTAL' | 'SKU_TOTAL', file: File) {
|
export function uploadReturnRelabelFile(relabelNo: string, scope: 'CARTON_TOTAL' | 'SKU_TOTAL', file: File) {
|
||||||
const data = new FormData(); data.append('file', file)
|
const data = new FormData(); data.append('file', file)
|
||||||
return overseasApiRequest<unknown>({ method: 'POST', url: `/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/files`, params: { scope }, data, headers: { 'Content-Type': 'multipart/form-data' } })
|
return apiRequest<unknown>({ method: 'POST', url: `/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/files`, params: { scope }, data, headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
}
|
}
|
||||||
export function downloadReturnPhoto(returnNo: string, fileId: string | number) {
|
export function downloadReturnPhoto(returnNo: string, fileId: string | number) {
|
||||||
return overseasBlobRequest({ method: 'GET', url: `/merchant/v1/last-leg/return-orders/${encodeURIComponent(returnNo)}/photos/files/${encodeURIComponent(String(fileId))}/download` })
|
return apiBlobRequest({ method: 'GET', url: `/merchant/v1/last-leg/return-orders/${encodeURIComponent(returnNo)}/photos/files/${encodeURIComponent(String(fileId))}/download` })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { overseasApiRequest, overseasBlobRequest } from '@/api/overseasHttp'
|
import { apiRequest, apiBlobRequest } from '@/api/http'
|
||||||
import { createIdempotencyKey } from '@/utils/idempotencyKey'
|
import { createIdempotencyKey } from '@/utils/idempotencyKey'
|
||||||
|
|
||||||
export type RelabelDeliveryMethod = 'TRUCK' | 'EXPRESS' | 'TRUCK_PICKUP' | 'EXPRESS_PICKUP'
|
export type RelabelDeliveryMethod = 'TRUCK' | 'EXPRESS' | 'TRUCK_PICKUP' | 'EXPRESS_PICKUP'
|
||||||
@@ -55,78 +55,78 @@ export interface RelabelCreate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function listRelabelHistoricalOrders(operationWarehouseId: string, keyword?: string) {
|
export function listRelabelHistoricalOrders(operationWarehouseId: string, keyword?: string) {
|
||||||
return overseasApiRequest<HistoricalOrderSummary[]>({ method: 'GET', url: '/merchant/v1/last-leg/relabel-applications/historical-orders', params: { operationWarehouseId, keyword: keyword || undefined } })
|
return apiRequest<HistoricalOrderSummary[]>({ method: 'GET', url: '/merchant/v1/last-leg/relabel-applications/historical-orders', params: { operationWarehouseId, keyword: keyword || undefined } })
|
||||||
}
|
}
|
||||||
export function listTransfers(params: Record<string, unknown>) {
|
export function listTransfers(params: Record<string, unknown>) {
|
||||||
return overseasApiRequest<PageResult<TransferSummary>>({ method: 'GET', url: '/merchant/v1/last-leg/warehouse-transfers', params })
|
return apiRequest<PageResult<TransferSummary>>({ method: 'GET', url: '/merchant/v1/last-leg/warehouse-transfers', params })
|
||||||
}
|
}
|
||||||
export function getTransfer(transferNo: string) {
|
export function getTransfer(transferNo: string) {
|
||||||
return overseasApiRequest<Record<string, unknown>>({ method: 'GET', url: `/merchant/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferNo)}` })
|
return apiRequest<Record<string, unknown>>({ method: 'GET', url: `/merchant/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferNo)}` })
|
||||||
}
|
}
|
||||||
export function createTransfer(payload: TransferCreate) {
|
export function createTransfer(payload: TransferCreate) {
|
||||||
return overseasApiRequest<Record<string, unknown>>({ method: 'POST', url: '/merchant/v1/last-leg/warehouse-transfers', data: payload })
|
return apiRequest<Record<string, unknown>>({ method: 'POST', url: '/merchant/v1/last-leg/warehouse-transfers', data: payload })
|
||||||
}
|
}
|
||||||
export function updateTransfer(transferNo: string, payload: TransferCreate) {
|
export function updateTransfer(transferNo: string, payload: TransferCreate) {
|
||||||
return overseasApiRequest<Record<string, unknown>>({ method: 'POST', url: `/merchant/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferNo)}/save-draft`, data: payload })
|
return apiRequest<Record<string, unknown>>({ method: 'POST', url: `/merchant/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferNo)}/save-draft`, data: payload })
|
||||||
}
|
}
|
||||||
export function submitTransfer(transferNo: string) {
|
export function submitTransfer(transferNo: string) {
|
||||||
return overseasApiRequest<Record<string, unknown>>({ method: 'POST', url: `/merchant/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferNo)}/submit` })
|
return apiRequest<Record<string, unknown>>({ method: 'POST', url: `/merchant/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferNo)}/submit` })
|
||||||
}
|
}
|
||||||
export function listRelabels(params: Record<string, unknown>) {
|
export function listRelabels(params: Record<string, unknown>) {
|
||||||
return overseasApiRequest<PageResult<RelabelSummary>>({ method: 'GET', url: '/merchant/v1/last-leg/relabel-applications', params })
|
return apiRequest<PageResult<RelabelSummary>>({ method: 'GET', url: '/merchant/v1/last-leg/relabel-applications', params })
|
||||||
}
|
}
|
||||||
export function getRelabel(relabelNo: string) {
|
export function getRelabel(relabelNo: string) {
|
||||||
return overseasApiRequest<RelabelDetail>({ method: 'GET', url: `/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}` })
|
return apiRequest<RelabelDetail>({ method: 'GET', url: `/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}` })
|
||||||
}
|
}
|
||||||
export function confirmTransferMeasurement(transferNo: string, remark?: string) {
|
export function confirmTransferMeasurement(transferNo: string, remark?: string) {
|
||||||
return overseasApiRequest<Record<string, unknown>>({ method: 'POST', url: `/merchant/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferNo)}/confirm-measurement`, data: { idempotencyKey: createIdempotencyKey(), remark } })
|
return apiRequest<Record<string, unknown>>({ method: 'POST', url: `/merchant/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferNo)}/confirm-measurement`, data: { idempotencyKey: createIdempotencyKey(), remark } })
|
||||||
}
|
}
|
||||||
export interface TransferCartonLabel { transferNo: string; cartons: Array<{ cartonMark: string; customerCartonMark?: string; cartonIndex: number; totalCartons: number; status: string }>; printableHtml: string }
|
export interface TransferCartonLabel { transferNo: string; cartons: Array<{ cartonMark: string; customerCartonMark?: string; cartonIndex: number; totalCartons: number; status: string }>; printableHtml: string }
|
||||||
export function getTransferCartonLabel(transferNo: string) {
|
export function getTransferCartonLabel(transferNo: string) {
|
||||||
return overseasApiRequest<TransferCartonLabel>({ method: 'GET', url: `/merchant/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferNo)}/carton-label` })
|
return apiRequest<TransferCartonLabel>({ method: 'GET', url: `/merchant/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferNo)}/carton-label` })
|
||||||
}
|
}
|
||||||
export function downloadTransferMeasurementPhoto(transferNo: string, fileId: string | number) {
|
export function downloadTransferMeasurementPhoto(transferNo: string, fileId: string | number) {
|
||||||
return overseasBlobRequest({ method: 'GET', url: `/merchant/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferNo)}/measurement-photos/files/${encodeURIComponent(fileId)}/download` })
|
return apiBlobRequest({ method: 'GET', url: `/merchant/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferNo)}/measurement-photos/files/${encodeURIComponent(fileId)}/download` })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listRelabelInventoryCandidates(operationWarehouseId: string, inboundCode?: string) {
|
export function listRelabelInventoryCandidates(operationWarehouseId: string, inboundCode?: string) {
|
||||||
return overseasApiRequest<RelabelInventoryCandidate[]>({
|
return apiRequest<RelabelInventoryCandidate[]>({
|
||||||
method: 'GET', url: '/merchant/v1/last-leg/relabel-applications/inventory-candidates',
|
method: 'GET', url: '/merchant/v1/last-leg/relabel-applications/inventory-candidates',
|
||||||
params: { operationWarehouseId, inboundCode: inboundCode || undefined },
|
params: { operationWarehouseId, inboundCode: inboundCode || undefined },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
export function createRelabel(payload: RelabelCreate) {
|
export function createRelabel(payload: RelabelCreate) {
|
||||||
return overseasApiRequest<RelabelDetail>({ method: 'POST', url: '/merchant/v1/last-leg/relabel-applications', data: payload })
|
return apiRequest<RelabelDetail>({ method: 'POST', url: '/merchant/v1/last-leg/relabel-applications', data: payload })
|
||||||
}
|
}
|
||||||
export function resubmitRelabel(relabelNo: string, payload: { labelDeadline: string; contactName: string; contactPhone: string; remark?: string; idempotencyKey: string }) {
|
export function resubmitRelabel(relabelNo: string, payload: { labelDeadline: string; contactName: string; contactPhone: string; remark?: string; idempotencyKey: string }) {
|
||||||
return overseasApiRequest<RelabelDetail>({ method: 'PUT', url: `/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/resubmit`, data: payload })
|
return apiRequest<RelabelDetail>({ method: 'PUT', url: `/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/resubmit`, data: payload })
|
||||||
}
|
}
|
||||||
export function uploadRelabelShippingLabel(relabelNo: string, file: File) {
|
export function uploadRelabelShippingLabel(relabelNo: string, file: File) {
|
||||||
const data = new FormData(); data.append('file', file)
|
const data = new FormData(); data.append('file', file)
|
||||||
return overseasApiRequest<RelabelDetail>({ method: 'POST', url: `/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/shipping-label`, data, headers: { 'Content-Type': 'multipart/form-data' } })
|
return apiRequest<RelabelDetail>({ method: 'POST', url: `/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/shipping-label`, data, headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
}
|
}
|
||||||
export function uploadRelabelFile(relabelNo: string, scope: 'CARTON_TOTAL' | 'SKU_TOTAL' | 'ITEM', file: File, itemId?: string) {
|
export function uploadRelabelFile(relabelNo: string, scope: 'CARTON_TOTAL' | 'SKU_TOTAL' | 'ITEM', file: File, itemId?: string) {
|
||||||
const data = new FormData(); data.append('file', file)
|
const data = new FormData(); data.append('file', file)
|
||||||
return overseasApiRequest<RelabelDetail>({ method: 'POST', url: `/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/files`, params: { scope, itemId }, data, headers: { 'Content-Type': 'multipart/form-data' } })
|
return apiRequest<RelabelDetail>({ method: 'POST', url: `/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/files`, params: { scope, itemId }, data, headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
}
|
}
|
||||||
export function confirmRelabelCompletion(relabelNo: string, remark?: string) {
|
export function confirmRelabelCompletion(relabelNo: string, remark?: string) {
|
||||||
return overseasApiRequest<RelabelDetail>({ method: 'POST', url: `/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/confirm-completion`, data: { idempotencyKey: createIdempotencyKey(), remark } })
|
return apiRequest<RelabelDetail>({ method: 'POST', url: `/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/confirm-completion`, data: { idempotencyKey: createIdempotencyKey(), remark } })
|
||||||
}
|
}
|
||||||
export function downloadRelabelCompletionPhoto(relabelNo: string, fileId: string) {
|
export function downloadRelabelCompletionPhoto(relabelNo: string, fileId: string) {
|
||||||
return overseasBlobRequest({ method: 'GET', url: `/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/completion-photos/${encodeURIComponent(fileId)}/download` })
|
return apiBlobRequest({ method: 'GET', url: `/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/completion-photos/${encodeURIComponent(fileId)}/download` })
|
||||||
}
|
}
|
||||||
export function listDiscards(params: Record<string, unknown>) {
|
export function listDiscards(params: Record<string, unknown>) {
|
||||||
return overseasApiRequest<PageResult<DiscardSummary>>({ method: 'GET', url: '/merchant/v1/last-leg/cargo-discards', params })
|
return apiRequest<PageResult<DiscardSummary>>({ method: 'GET', url: '/merchant/v1/last-leg/cargo-discards', params })
|
||||||
}
|
}
|
||||||
export function getDiscard(discardNo: string) {
|
export function getDiscard(discardNo: string) {
|
||||||
return overseasApiRequest<Record<string, unknown>>({ method: 'GET', url: `/merchant/v1/last-leg/cargo-discards/${encodeURIComponent(discardNo)}` })
|
return apiRequest<Record<string, unknown>>({ method: 'GET', url: `/merchant/v1/last-leg/cargo-discards/${encodeURIComponent(discardNo)}` })
|
||||||
}
|
}
|
||||||
export function listDiscardCandidates(inboundCode?: string) {
|
export function listDiscardCandidates(inboundCode?: string) {
|
||||||
return overseasApiRequest<DiscardCandidate[]>({ method: 'GET', url: '/merchant/v1/last-leg/cargo-discards/inventory-candidates', params: { inboundCode: inboundCode || undefined } })
|
return apiRequest<DiscardCandidate[]>({ method: 'GET', url: '/merchant/v1/last-leg/cargo-discards/inventory-candidates', params: { inboundCode: inboundCode || undefined } })
|
||||||
}
|
}
|
||||||
export function createDiscard(payload: { items: Array<{ orderId?: string; returnCartonId?: string; skuCode: string; discardPackages: number }>; idempotencyKey: string; remark?: string }) {
|
export function createDiscard(payload: { items: Array<{ orderId?: string; returnCartonId?: string; skuCode: string; discardPackages: number }>; idempotencyKey: string; remark?: string }) {
|
||||||
return overseasApiRequest<Record<string, unknown>>({ method: 'POST', url: '/merchant/v1/last-leg/cargo-discards', data: payload })
|
return apiRequest<Record<string, unknown>>({ method: 'POST', url: '/merchant/v1/last-leg/cargo-discards', data: payload })
|
||||||
}
|
}
|
||||||
export function cancelDiscard(discardNo: string) {
|
export function cancelDiscard(discardNo: string) {
|
||||||
return overseasApiRequest<Record<string, unknown>>({ method: 'POST', url: `/merchant/v1/last-leg/cargo-discards/${encodeURIComponent(discardNo)}/cancel` })
|
return apiRequest<Record<string, unknown>>({ method: 'POST', url: `/merchant/v1/last-leg/cargo-discards/${encodeURIComponent(discardNo)}/cancel` })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const api = readFileSync(new URL('../api/returnOrders.ts', import.meta.url), 'ut
|
|||||||
|
|
||||||
test('退货签收照片通过客户鉴权下载接口加载并释放对象 URL', () => {
|
test('退货签收照片通过客户鉴权下载接口加载并释放对象 URL', () => {
|
||||||
assert.match(api, /\/return-orders\/\$\{encodeURIComponent\(returnNo\)\}\/photos\/files\/\$\{encodeURIComponent\(String\(fileId\)\)\}\/download/)
|
assert.match(api, /\/return-orders\/\$\{encodeURIComponent\(returnNo\)\}\/photos\/files\/\$\{encodeURIComponent\(String\(fileId\)\)\}\/download/)
|
||||||
assert.match(api, /overseasBlobRequest/)
|
assert.match(api, /apiBlobRequest/)
|
||||||
assert.match(view, /URL\.createObjectURL\(await downloadReturnPhoto\(detailReturnNo\.value, photo\.fileId\)\)/)
|
assert.match(view, /URL\.createObjectURL\(await downloadReturnPhoto\(detailReturnNo\.value, photo\.fileId\)\)/)
|
||||||
assert.match(view, /URL\.revokeObjectURL\(url\)/)
|
assert.match(view, /URL\.revokeObjectURL\(url\)/)
|
||||||
assert.match(view, /onBeforeUnmount\(clearPhotoUrls\)/)
|
assert.match(view, /onBeforeUnmount\(clearPhotoUrls\)/)
|
||||||
@@ -76,7 +76,7 @@ test('退货签收详情使用独立路由和紧凑连续页面', () => {
|
|||||||
|
|
||||||
test('待审核退货可编辑,完成入库后从独立新增页发起贴标', () => {
|
test('待审核退货可编辑,完成入库后从独立新增页发起贴标', () => {
|
||||||
assert.match(view, /row\.status === 'PENDING_REVIEW'.*key: 'edit'/)
|
assert.match(view, /row\.status === 'PENDING_REVIEW'.*key: 'edit'/)
|
||||||
assert.match(view, /await updateReturnOrder\(editingReturnNo\.value, payload\)/)
|
assert.match(view, /await \(editingReturnNo\.value \? updateReturnOrder\(editingReturnNo\.value, payload\) : createReturnOrder\(payload\)\)/)
|
||||||
assert.match(view, /order\.status === 'COMPLETED' && order\.needRelabel === true/)
|
assert.match(view, /order\.status === 'COMPLETED' && order\.needRelabel === true/)
|
||||||
assert.match(view, /name: 'overseas-labeling-create'/)
|
assert.match(view, /name: 'overseas-labeling-create'/)
|
||||||
assert.match(view, /query: \{ returnNo: order\.returnNo, warehouseId: String\(order\.overseasWarehouseId\) \}/)
|
assert.match(view, /query: \{ returnNo: order\.returnNo, warehouseId: String\(order\.overseasWarehouseId\) \}/)
|
||||||
|
|||||||
@@ -810,7 +810,7 @@ function warehouseDisplay(value?: string) {
|
|||||||
if (!value) return '—'
|
if (!value) return '—'
|
||||||
return value.split(',').map((code) => {
|
return value.split(',').map((code) => {
|
||||||
const warehouse = [...operationWarehouses.value, ...fbaWarehouses.value].find((item) => String(item.id) === code || item.warehouseCode === code)
|
const warehouse = [...operationWarehouses.value, ...fbaWarehouses.value].find((item) => String(item.id) === code || item.warehouseCode === code)
|
||||||
return warehouse ? warehouseLabel(warehouse) : code
|
return warehouse ? warehouseLabel(warehouse) : '—'
|
||||||
}).join('、')
|
}).join('、')
|
||||||
}
|
}
|
||||||
function statusLabel(value: string) {
|
function statusLabel(value: string) {
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ test('客户可在换标列表或详情确认完工且照片使用二进制下
|
|||||||
assert.match(view, /confirmRelabelFromList\(item\['0'\]\)/)
|
assert.match(view, /confirmRelabelFromList\(item\['0'\]\)/)
|
||||||
assert.match(view, /selectedRelabelDetail\?\.application\.status === 'CUSTOMER_CONFIRM_PENDING'/)
|
assert.match(view, /selectedRelabelDetail\?\.application\.status === 'CUSTOMER_CONFIRM_PENDING'/)
|
||||||
assert.match(view, /@click="confirmSelectedRelabel">确认换标完成/)
|
assert.match(view, /@click="confirmSelectedRelabel">确认换标完成/)
|
||||||
assert.match(api, /overseasBlobRequest/)
|
assert.match(api, /apiBlobRequest/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('客户详情进入页面后直接回显完工照片并支持大图预览', () => {
|
test('客户详情进入页面后直接回显完工照片并支持大图预览', () => {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
|||||||
export default defineConfig(({ mode }) => {
|
export default defineConfig(({ mode }) => {
|
||||||
const env = loadEnv(mode, process.cwd(), '')
|
const env = loadEnv(mode, process.cwd(), '')
|
||||||
const proxyTarget = process.env.API_PROXY_TARGET || env.API_PROXY_TARGET || 'http://127.0.0.1:9000'
|
const proxyTarget = process.env.API_PROXY_TARGET || env.API_PROXY_TARGET || 'http://127.0.0.1:9000'
|
||||||
const overseasProxyTarget = process.env.OVERSEAS_API_PROXY_TARGET || env.OVERSEAS_API_PROXY_TARGET || 'http://127.0.0.1:9300'
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cacheDir: 'tmp/vite-cache',
|
cacheDir: 'tmp/vite-cache',
|
||||||
@@ -40,16 +39,6 @@ export default defineConfig(({ mode }) => {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'/overseas-api': {
|
|
||||||
target: overseasProxyTarget,
|
|
||||||
changeOrigin: true,
|
|
||||||
rewrite: (path) => path.replace(/^\/overseas-api/, '/api'),
|
|
||||||
configure(proxy) {
|
|
||||||
proxy.on('proxyReq', (proxyRequest) => {
|
|
||||||
proxyRequest.removeHeader('origin')
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user