diff --git a/AGENTS.md b/AGENTS.md index ce0f91d..da6bea4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,7 +56,7 @@ | 客户资金账户、授信与充值申请 | `src/modules/finance/views/FinanceAccountView.vue`、`api/account.ts` | 显示当前企业 CNY 实际余额、冻结金额、授信额度、授信有效期、已用授信、冻结授信、总可用余额和不可变流水;数据加载前显示“—”,不得先伪装为零。授信到期日当天仍有效,次日已用授信并入实际余额,负数实际余额会阻止提交新订单。充值只展示服务端启用的微信/支付宝静态收款码,客户上传 JPG/PNG 付款截图后进入待审核。客户范围只取登录态,无收款码时禁止提交,不模拟支付成功 | | 海外采购、美国本土卡与美本土散板 | `src/modules/cross-border/routes.ts`、`components/BusinessOrdersView.vue`、`api/businessOrders.ts` | 三个路由均为 `active`,客户范围只取 Token;采购创建的 `customerName` 只取当前认证会话企业名称,不允许自由输入或冒用其他客户。Backend `lastleg-service/` 的 `procurement/`、`trucking/`、`looseboard/` 客户接口 | | 海外仓入库订单 | `src/modules/order-management/routes.ts`、`views/InboundOrdersView.vue`、`api/inboundPlans.ts` | `orders/inbound` 为只读列表/详情,权限 `merchant:warehouse:inbound:list`;不展示或提交内部 ID | -| 海外仓贴标、仓储转运、货物废弃 | `src/modules/overseas-warehouse/routes.ts`、`views/WarehouseServiceView.vue`、`api/warehouseServices.ts` | 三个路由均为 `active`;退货预报另见本节独立入口;贴标申请先选国外中转仓和有库存历史入仓单,换标申请必须提交正数 CNY `chargeAmount`,换标类型为只换 SKU、只换箱唛、SKU+箱唛。箱唛换标明细一行固定代表一箱,箱数不可编辑,提交也固定为 1。货物明细外层始终提供“SKU 关联”“箱号关联”“SKU 混箱换标”三套模板的下载、上传,不随换标类型切换;上传只进入待导入关系区,客户可勾选部分或全选后导入本次明细,只有进入明细的库存才随申请提交和冻结。明细也支持手动新增编辑,组合换标以旧箱唛连接旧/新 SKU 和旧箱唛/新 FBA 箱唛关系。涉及箱唛换标时必须选择卡车、自提或快递;快递可选择打印面单并上传 PDF/图片。PDA 逐箱换标、上传照片并由仓库复核后,客户详情展示完工照片和“换标完成确认”;客户确认前不得继续出库,确认后卡车进入预排车,自提/快递进入换标出库待创建。历史库存位置不能解释成箱唛与具体托盘快照,客户页面不暴露托盘、库位和内部库存 ID | +| 海外仓贴标、仓储转运、货物废弃 | `src/modules/overseas-warehouse/routes.ts`、`views/WarehouseServiceView.vue`、`api/warehouseServices.ts` | 三个路由均为 `active`;退货预报另见本节独立入口;贴标申请先选国外中转仓和有库存历史入仓单,换标类型为只换 SKU、只换箱唛、SKU+箱唛。涉及箱唛换标时,整张贴标申请只选择一个 FBA 目的仓,全部箱级明细统一提交该目的仓,模板和明细不再逐行选择目的仓。箱唛换标明细一行固定代表一箱,箱数不可编辑,提交也固定为 1。货物明细外层始终提供“SKU 关联”“箱号关联”“SKU 混箱换标”三套模板的下载、上传,不随换标类型切换;上传只进入待导入关系区,客户可勾选部分或全选后导入本次明细,只有进入明细的库存才随申请提交和冻结。明细也支持手动新增编辑,组合换标以仓库唛头连接旧/新 SKU 和新 FBA 箱唛关系。涉及箱唛换标时必须选择卡车、自提或快递;快递可选择打印面单并上传 PDF/图片。PDA 逐箱换标、上传照片并由仓库复核后,客户详情展示完工照片和“换标完成确认”;客户确认前不得继续出库,确认后卡车进入预排车,自提/快递进入换标出库待创建。历史库存位置不能解释成箱唛与具体托盘快照,客户页面不暴露托盘、库位和内部库存 ID | ## 租户、认证与数据安全 diff --git a/Jenkinsfile b/Jenkinsfile index 3b003a9..8cc4b3f 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,3 +1,37 @@ +def sendProductionWeComNotification(String status, String color, String detail) { + withCredentials([string(credentialsId: 'wecom-prod-webhook', variable: 'WECOM_WEBHOOK')]) { + String sourceVersion = env.SOURCE_TAG ?: env.GIT_COMMIT ?: '—' + String content = """## 生产部署通知 +> 项目:${env.JOB_NAME} +> 构建:[#${env.BUILD_NUMBER}](${env.BUILD_URL}) +> 提交:${sourceVersion} +> 状态:${status} +> 详情:${detail} +> 时间:${new Date().format('yyyy-MM-dd HH:mm:ss', TimeZone.getTimeZone('Asia/Shanghai'))} +""" + String payloadFile = 'wecom-production-notification.json' + writeFile( + file: payloadFile, + text: groovy.json.JsonOutput.toJson([ + msgtype: 'markdown', + markdown: [content: content] + ]), + encoding: 'UTF-8' + ) + sh ''' + set -eu + payload_file='wecom-production-notification.json' + trap 'rm -f "$payload_file"' EXIT HUP INT TERM + response="$(curl --fail --silent --show-error \ + -H 'Content-Type: application/json' \ + --data-binary @"$payload_file" \ + "$WECOM_WEBHOOK")" + printf '%s\n' "$response" + printf '%s' "$response" | grep -Eq '"errcode"[[:space:]]*:[[:space:]]*0([[:space:]]*[,}])' + ''' + } +} + pipeline { agent { label "${env.JOB_BASE_NAME?.endsWith('-prod') ? 'java-builder' : (env.JOB_BASE_NAME?.endsWith('-dev') ? 'java-dev' : 'java-test')}" @@ -161,6 +195,17 @@ pipeline { } post { + success { + script { + if (env.JOB_BASE_NAME?.endsWith('-prod')) { + try { + sendProductionWeComNotification('部署成功', 'info', 'Client 生产前端已完成发布和部署检查') + } catch (Exception error) { + echo "企业微信通知失败,不影响原构建结果:${error.message}" + } + } + } + } failure { sh ''' if [ "${IS_PRODUCTION:-false}" = "true" ]; then @@ -171,6 +216,26 @@ pipeline { 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}" + } + } + } } } } diff --git a/docker-compose.test.yml b/docker-compose.test.yml index c3d40ea..697c93c 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -12,6 +12,8 @@ services: restart: unless-stopped ports: - "${HOST_PORT:-9002}:80" + extra_hosts: + - "host.docker.internal:host-gateway" environment: - API_UPSTREAM: "http://test-api-cn.buggoglobal.com" - OVERSEAS_API_UPSTREAM: "http://test-api-global.buggoglobal.com" + API_UPSTREAM: "${API_UPSTREAM:-http://host.docker.internal:9000}" + OVERSEAS_API_UPSTREAM: "${OVERSEAS_API_UPSTREAM:-http://host.docker.internal:9300}" diff --git a/src/api/http.ts b/src/api/http.ts index 0d619ee..03d6a18 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -144,6 +144,7 @@ export async function apiRequest(config: AxiosRequestConfig) { export async function apiBlobRequest(config: AxiosRequestConfig) { try { const response = await transport.request({ ...config, responseType: 'blob' }) + await throwBlobApiError(response.data, response.status) return response.data } catch (error) { await throwBlobApiError(error) @@ -154,6 +155,7 @@ export async function apiBlobRequest(config: AxiosRequestConfig) { export async function apiBlobResponseRequest(config: AxiosRequestConfig) { try { const response = await transport.request({ ...config, responseType: 'blob' }) + await throwBlobApiError(response.data, response.status) return { blob: response.data, headers: response.headers } } catch (error) { await throwBlobApiError(error) @@ -161,16 +163,19 @@ export async function apiBlobResponseRequest(config: AxiosRequestConfig) { } } -async function throwBlobApiError(error: unknown) { - if (!axios.isAxiosError(error) || !(error.response?.data instanceof Blob)) return +async function throwBlobApiError(error: unknown, responseStatus?: number) { + const blob = error instanceof Blob + ? error + : axios.isAxiosError(error) && error.response?.data instanceof Blob ? error.response.data : null + if (!blob || !blob.type.toLowerCase().includes('json')) return try { - const body = JSON.parse(await error.response.data.text()) as unknown + const body = JSON.parse(await blob.text()) as unknown if (!isApiEnvelope(body)) return const fieldErrors = asFieldErrors(body.data) throw new ApiError( apiErrorMessage(body.message, fieldErrors), body.code, - error.response.status, + responseStatus ?? (axios.isAxiosError(error) ? error.response?.status : undefined), fieldErrors, typeof body.traceId === 'string' ? body.traceId : null, typeof body.requestId === 'string' ? body.requestId : null, diff --git a/src/api/overseasHttp.ts b/src/api/overseasHttp.ts index 3abc261..e567603 100644 --- a/src/api/overseasHttp.ts +++ b/src/api/overseasHttp.ts @@ -65,3 +65,27 @@ export async function overseasApiRequest(config: AxiosRequestConfig) { throw new ApiError(error instanceof Error ? error.message : '海外接口请求失败') } } + +export async function overseasBlobRequest(config: AxiosRequestConfig) { + try { + const response = await transport.request({ ...config, responseType: 'blob' }) + return response.data + } catch (error) { + if (error instanceof ApiError) throw error + if (axios.isAxiosError(error)) { + const body = error.response?.data + if (body instanceof Blob && body.type.includes('json')) { + try { + const parsed: unknown = JSON.parse(await body.text()) + if (isApiEnvelope(parsed) || isApiFailure(parsed)) { + throw new ApiError(parsed.message, parsed.code, error.response?.status, undefined, parsed.traceId, parsed.requestId) + } + } catch (parseError) { + if (parseError instanceof ApiError) throw parseError + } + } + throw new ApiError(error.code === 'ERR_NETWORK' ? '无法连接海外服务' : error.message, error.code || 'OVERSEAS_HTTP_ERROR', error.response?.status) + } + throw new ApiError(error instanceof Error ? error.message : '海外文件下载失败') + } +} diff --git a/src/components.d.ts b/src/components.d.ts index befd71f..f15b3f0 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -33,6 +33,7 @@ declare module 'vue' { ElEmpty: typeof import('element-plus/es')['ElEmpty'] ElForm: typeof import('element-plus/es')['ElForm'] ElFormItem: typeof import('element-plus/es')['ElFormItem'] + ElImage: typeof import('element-plus/es')['ElImage'] ElInput: typeof import('element-plus/es')['ElInput'] ElInputNumber: typeof import('element-plus/es')['ElInputNumber'] ElOption: typeof import('element-plus/es')['ElOption'] diff --git a/src/components/common/DataTable.vue b/src/components/common/DataTable.vue index 438411b..815b751 100644 --- a/src/components/common/DataTable.vue +++ b/src/components/common/DataTable.vue @@ -163,7 +163,7 @@ function setPage(value: number) {