From a427aca5c3b5259da686861bbb6f5d3d64cea001 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 29 Sep 2026 17:56:03 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 2 - AGENTS.md | 12 +-- src/api/http.ts | 17 +++- src/api/overseasFailure.test.mjs | 6 +- src/api/overseasHttp.ts | 91 ------------------- src/api/session.ts | 20 ---- src/api/unifiedHttp.test.mjs | 71 +++++++++++++++ .../cross-border/api/businessOrders.ts | 12 +-- .../first-leg/views/CustomsDocumentsView.vue | 88 +++++++++++++----- .../order-management/api/deliveryOrders.ts | 8 +- .../order-management/api/inboundPlans.ts | 6 +- .../order-management/deliveryOrders.test.mjs | 4 +- .../order-management/inboundOrders.test.mjs | 8 +- .../overseas-warehouse/api/returnInventory.ts | 16 ++-- .../overseas-warehouse/api/returnOrders.ts | 16 ++-- .../api/warehouseServices.ts | 48 +++++----- .../views/ReturnOrdersView.test.mjs | 2 +- .../views/relabelAttachments.test.mjs | 2 +- vite.config.ts | 11 --- 19 files changed, 219 insertions(+), 221 deletions(-) delete mode 100644 src/api/overseasHttp.ts create mode 100644 src/api/unifiedHttp.test.mjs diff --git a/.env.example b/.env.example index 6144c14..1e1a903 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,3 @@ VITE_API_BASE_URL=/api -VITE_OVERSEAS_API_BASE_URL=/overseas-api -OVERSEAS_API_PROXY_TARGET=http://127.0.0.1:9300 API_PROXY_TARGET=http://127.0.0.1:9000 VITE_ADMIN_WEB_ORIGIN=http://localhost:5173 diff --git a/AGENTS.md b/AGENTS.md index 9b66236..7135e88 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ - 本项目是入驻企业及其成员使用的客户工作台,所有业务数据都处于企业租户上下文中。 - 当前范围包括注册、业务总览、企业资料、企业成员、企业角色与权限、头程订单、客户资金账户、客户财务账单查询与客户确认,以及海外仓贴标、转运、废弃和退货换标自助服务。 -- 用户可见的“平台—客户”层级语义读取 `../../docs/平台客户层级与权限契约.md`;涉及海外 API、海外 Token 或跨区域事件时读取 `../../docs/跨区域认证与服务通信契约.md`。本文件中的 `tenant` 仅表示服务端隔离上下文,不扩展用户可见产品层级。 +- 用户可见的“平台—客户”层级语义读取 `../../docs/平台客户层级与权限契约.md`;涉及海外 API、海外 Token 或跨区域事件时读取 `../../docs/统一认证与服务通信契约.md`。本文件中的 `tenant` 仅表示服务端隔离上下文,不扩展用户可见产品层级。 - 不在本项目实现平台内部运营管理、品牌官网、仓库 PDA 作业或仅属于原生移动端的能力。 ## 技术基线 @@ -26,15 +26,15 @@ - 模块内 `store/`:该业务跨页面状态;局部表单状态仍留在页面或组件内。跨模块使用 store 时只能导入其公开 store 文件,不得读取模块内部页面或组件实现。 - `src/api/`:只保留 HTTP、会话令牌、响应类型等全局请求基础设施;业务接口放入对应模块的 `api/`。页面不得直接创建独立 Axios 客户端。 - 模块之间不得通过相对路径或内部目录穿透;确需协作时只能从目标模块的 `public.ts` 导入。模块内稳定契约放在 `contracts.ts`,稳定的跨应用模型仍应上移到 `@chongchong/shared`。 -- `VITE_API_BASE_URL`、`VITE_OVERSEAS_API_BASE_URL` 分别控制国内和海外浏览器前缀;`API_PROXY_TARGET`、`OVERSEAS_API_PROXY_TARGET` 只由 Vite 开发代理读取,不进入客户端构建。新增或变更环境变量时同步更新 `.env.example`,不得提交真实密钥或环境专属凭据。 +- `VITE_API_BASE_URL` 控制唯一浏览器 API 前缀;全部业务共用 `src/api/http.ts` 和同一 Access/Refresh Token。`API_PROXY_TARGET` 仅供本地 Vite 代理连接测试入口,不进入客户端构建。独立海外 HTTP 客户端与代理已移除。新增或变更环境变量时同步更新 `.env.example`,不得提交真实密钥或环境专属凭据。 - `src/styles/`:全局设计令牌和布局;优先复用现有 `--primary`、`--navy`、`--border`、`--muted`。 - 本应用多个模块稳定复用的前端模型放到应用内 `shared/`,客户工作台专属类型保留在对应业务模块。不要链接应用目录之外的 TypeScript 包,服务端契约以 `baiyue-firstleg` Controller/OpenAPI 为准。 -## 开发环境样式验收 +## 测试环境样式验收 -- 开发环境验收使用本轮动态创建并已审核通过的客户管理员,或由用户显式提供的客户账号,不依赖固定业务客户或固定账号。 +- 测试环境验收使用本轮动态创建并已审核通过的客户管理员,或由用户显式提供的客户账号,不依赖固定业务客户或固定账号。 - 账号与密码不写入仓库;从本机未提交环境变量或 `C:/Users/BY-OY/.codex/chongchong-global-dev-acceptance.env` 读取。若显式验收凭据不存在,再向用户索取。 -- 该身份仅适用于开发环境。不要把密码、Token 或真实接口地址写入源码、测试快照、日志或 `AGENTS.md`;开发环境变更时只更新本机凭据。 +- 当前只有一个测试环境。不要把密码、Token 或真实接口地址写入源码、测试快照、日志或 `AGENTS.md`;测试环境变更时只更新本机凭据。 - 由人工使用该身份进入真实页面,按 `1920×1080` 视口和 `100%` 浏览器缩放完成操作与截图,再把截图发送给 AI 核对加载、空态、错误态、权限态和桌面布局;AI 默认不主动调用截图或视觉类技能取证。 ## 模块快速查询 @@ -45,7 +45,7 @@ | 业务 | 模块入口 | 联查模块或服务端 | |---|---|---| -| 登录、注册与会话 | `src/modules/auth/`、`src/api/session.ts`、`src/api/http.ts` | Backend `auth/`、`iam/`;正常旋转的国内/海外 Token 会话统一持久化,“记住账号”只记登录标识;访问令牌失效但刷新令牌仍在时先恢复会话,公开认证接口不得携带旧 Token | +| 登录、注册与会话 | `src/modules/auth/`、`src/api/session.ts`、`src/api/http.ts` | Backend `auth/`、`iam/`;正常轮换的统一 Access/Refresh Token 会话持久化,“记住账号”只记登录标识;访问令牌失效但刷新令牌仍在时先恢复会话,公开认证接口不得携带旧 Token | | 企业资料、成员与角色 | `src/modules/tenant-management/`、`src/modules/account/` | Backend `iam/`、`auth/`、`file/`;公司联系电话/邮箱是企业资料,不作为登录标识;成员登录手机号可选,修改时同步 Auth、撤销该账号会话并审计。审核必填资质仅营业执照与统一社会信用代码;法人身份证选填,可随营业执照提交,也可在已有营业执照后单独补传或替换。前端角色树到服务端权限的映射统一维护在 `contracts.ts` 的 `FRONTEND_TO_MERCHANT_PERMISSION_CODES`,海外入仓、采购、本土卡车和散板权限必须显式进入该映射 | | 工作台与系统入口 | `src/modules/dashboard/`、`src/modules/system/` | `src/navigation/clientNavigation.ts`、`src/router/index.ts` | | 企业成员消息收件箱 | `src/modules/notifications/` | Backend `iam/src/main/java/com/baiyue/iam/notification/`;列表、详情、已读与未读数契约见 `iam/src/main/resources/openapi/notification-v1.yaml` | diff --git a/src/api/http.ts b/src/api/http.ts index 03d6a18..eb0b66e 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -2,7 +2,7 @@ import axios, { AxiosError, type AxiosRequestConfig, type InternalAxiosRequestCo import { parseApiJsonResponse } from '@chongchong/shared' import { clearSessionTokens, getAccessToken, getRefreshToken, persistTokenPair, type TokenPair } from '@/api/session' import { apiErrorMessage, asFieldErrors } from '@/api/errorDetails' -import { ApiError, isApiEnvelope, type ApiEnvelope } from '@/api/types' +import { ApiError, isApiEnvelope, isApiFailure, type ApiEnvelope } from '@/api/types' import { coordinateCrossTabRefresh, CrossTabRefreshTimeoutError } from '@/api/crossTabRefresh' const transport = axios.create({ @@ -16,6 +16,13 @@ interface RetryableRequest extends InternalAxiosRequestConfig { } function unwrap(envelope: ApiEnvelope) { + if (!isApiEnvelope(envelope)) { + const invalid: unknown = envelope + if (isApiFailure(invalid)) { + throw new ApiError(invalid.message, invalid.code, undefined, undefined, invalid.traceId, invalid.requestId) + } + throw new ApiError('接口响应格式错误', 'INVALID_RESPONSE') + } if (envelope.code !== '200') { const fieldErrors = asFieldErrors(envelope.data) throw new ApiError(apiErrorMessage(envelope.message, fieldErrors), envelope.code, undefined, fieldErrors, envelope.traceId, envelope.requestId) @@ -28,8 +35,8 @@ function mapError(error: unknown) { if (!axios.isAxiosError(error)) return new ApiError(error instanceof Error ? error.message : '请求失败') const body = error.response?.data - if (isApiEnvelope(body)) { - const fieldErrors = asFieldErrors(body.data) + if (isApiEnvelope(body) || isApiFailure(body)) { + const fieldErrors = asFieldErrors(isApiEnvelope(body) ? body.data : undefined) return new ApiError( apiErrorMessage(body.message, fieldErrors), body.code, @@ -170,8 +177,8 @@ async function throwBlobApiError(error: unknown, responseStatus?: number) { if (!blob || !blob.type.toLowerCase().includes('json')) return try { const body = JSON.parse(await blob.text()) as unknown - if (!isApiEnvelope(body)) return - const fieldErrors = asFieldErrors(body.data) + if (!isApiEnvelope(body) && !isApiFailure(body)) return + const fieldErrors = asFieldErrors(isApiEnvelope(body) ? body.data : undefined) throw new ApiError( apiErrorMessage(body.message, fieldErrors), body.code, diff --git a/src/api/overseasFailure.test.mjs b/src/api/overseasFailure.test.mjs index df61ce0..2941d4b 100644 --- a/src/api/overseasFailure.test.mjs +++ b/src/api/overseasFailure.test.mjs @@ -26,8 +26,8 @@ test('does not accept malformed or data-less successful responses', () => { assert.equal(isApiEnvelope({ code: '200', message: 'OK', data: [] }), true) }) -test('overseas transport handles business-code and HTTP security failures', async () => { - const transport = await readFile(new URL('./overseasHttp.ts', import.meta.url), 'utf8') - assert.match(transport, /if \(isApiFailure\(response.data\)\)/) +test('unified transport handles business-code and HTTP security failures', async () => { + const transport = await readFile(new URL('./http.ts', import.meta.url), 'utf8') + assert.match(transport, /if \(isApiFailure\(invalid\)\)/) assert.match(transport, /isApiEnvelope\(body\) \|\| isApiFailure\(body\)/) }) diff --git a/src/api/overseasHttp.ts b/src/api/overseasHttp.ts deleted file mode 100644 index e567603..0000000 --- a/src/api/overseasHttp.ts +++ /dev/null @@ -1,91 +0,0 @@ -import axios, { type AxiosRequestConfig, type InternalAxiosRequestConfig } from 'axios' -import { parseApiJsonResponse } from '@chongchong/shared' -import { ApiError, isApiEnvelope, isApiFailure } from '@/api/types' -import { getOverseasAccessToken } from '@/api/session' -import { refreshAuthenticatedSession } from '@/api/http' - -interface RetryableRequest extends InternalAxiosRequestConfig { _retriedOverseasAuth?: boolean } - -const transport = axios.create({ - baseURL: import.meta.env.VITE_OVERSEAS_API_BASE_URL || '/overseas-api', - timeout: 15_000, - transformResponse: [(data: unknown) => parseApiJsonResponse(data)], -}) - -async function requireToken() { - const current = getOverseasAccessToken() - if (current) return current - await refreshAuthenticatedSession() - const refreshed = getOverseasAccessToken() - if (!refreshed) throw new ApiError('当前环境未签发海外访问令牌', 'OVERSEAS_TOKEN_UNAVAILABLE') - return refreshed -} - -transport.interceptors.request.use(async (config) => { - config.headers.Authorization = `Bearer ${await requireToken()}` - return config -}) - -transport.interceptors.response.use((response) => response, async (error) => { - const request = error.config as RetryableRequest | undefined - if (error.response?.status !== 401 || !request || request._retriedOverseasAuth) throw error - request._retriedOverseasAuth = true - await refreshAuthenticatedSession() - request.headers.Authorization = `Bearer ${await requireToken()}` - return transport.request(request) -}) - -export async function overseasApiRequest(config: AxiosRequestConfig) { - try { - const response = await transport.request(config) - if (isApiFailure(response.data)) { - throw new ApiError(response.data.message, response.data.code, response.status, undefined, - response.data.traceId, response.data.requestId) - } - if (!isApiEnvelope(response.data) || response.data.code !== '200') { - throw new ApiError( - isApiEnvelope(response.data) ? response.data.message : '海外接口响应格式错误', - isApiEnvelope(response.data) ? response.data.code : 'INVALID_RESPONSE', - undefined, - undefined, - isApiEnvelope(response.data) ? response.data.traceId : null, - isApiEnvelope(response.data) ? response.data.requestId : null, - ) - } - return response.data.data as T - } catch (error) { - if (error instanceof ApiError) throw error - if (axios.isAxiosError(error)) { - const body = error.response?.data - if (isApiEnvelope(body) || isApiFailure(body)) { - throw new ApiError(body.message, body.code, error.response?.status, undefined, body.traceId, body.requestId) - } - throw new ApiError(error.code === 'ERR_NETWORK' ? '无法连接海外服务' : error.message, error.code || 'OVERSEAS_HTTP_ERROR', error.response?.status) - } - throw new ApiError(error instanceof Error ? error.message : '海外接口请求失败') - } -} - -export async function overseasBlobRequest(config: AxiosRequestConfig) { - try { - const response = await transport.request({ ...config, responseType: 'blob' }) - return response.data - } catch (error) { - if (error instanceof ApiError) throw error - if (axios.isAxiosError(error)) { - const body = error.response?.data - if (body instanceof Blob && body.type.includes('json')) { - try { - const parsed: unknown = JSON.parse(await body.text()) - if (isApiEnvelope(parsed) || isApiFailure(parsed)) { - throw new ApiError(parsed.message, parsed.code, error.response?.status, undefined, parsed.traceId, parsed.requestId) - } - } catch (parseError) { - if (parseError instanceof ApiError) throw parseError - } - } - throw new ApiError(error.code === 'ERR_NETWORK' ? '无法连接海外服务' : error.message, error.code || 'OVERSEAS_HTTP_ERROR', error.response?.status) - } - throw new ApiError(error instanceof Error ? error.message : '海外文件下载失败') - } -} diff --git a/src/api/session.ts b/src/api/session.ts index 2f2dd3b..dbdcdd1 100644 --- a/src/api/session.ts +++ b/src/api/session.ts @@ -1,7 +1,5 @@ export const ACCESS_TOKEN_KEY = 'cc-client-access-token' export const REFRESH_TOKEN_KEY = 'cc-client-refresh-token' -export const OVERSEAS_ACCESS_TOKEN_KEY = 'cc-client-overseas-access-token' -export const OVERSEAS_EXPIRES_AT_KEY = 'cc-client-overseas-expires-at' export const REMEMBERED_LOGIN_ID_KEY = 'cc-client-remembered-login-id' export interface TokenPair { @@ -10,14 +8,8 @@ export interface TokenPair { expiresIn: number refreshToken: string refreshExpiresIn: number - overseasAccessToken?: string | null - overseasExpiresIn?: number } -function activeStorage() { - if (localStorage.getItem(ACCESS_TOKEN_KEY)) return localStorage - return sessionStorage -} export function getAccessToken() { return localStorage.getItem(ACCESS_TOKEN_KEY) || sessionStorage.getItem(ACCESS_TOKEN_KEY) || '' @@ -27,21 +19,11 @@ export function getRefreshToken() { return localStorage.getItem(REFRESH_TOKEN_KEY) || sessionStorage.getItem(REFRESH_TOKEN_KEY) || '' } -export function getOverseasAccessToken() { - const storage = activeStorage() - const expiresAt = Number(storage.getItem(OVERSEAS_EXPIRES_AT_KEY) || 0) - if (expiresAt <= Date.now() + 30_000) return '' - return storage.getItem(OVERSEAS_ACCESS_TOKEN_KEY) || '' -} export function persistTokenPair(tokens: TokenPair) { clearSessionTokens() localStorage.setItem(ACCESS_TOKEN_KEY, tokens.accessToken) localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refreshToken) - if (tokens.overseasAccessToken && Number(tokens.overseasExpiresIn) > 0) { - localStorage.setItem(OVERSEAS_ACCESS_TOKEN_KEY, tokens.overseasAccessToken) - localStorage.setItem(OVERSEAS_EXPIRES_AT_KEY, String(Date.now() + Number(tokens.overseasExpiresIn) * 1000)) - } } export function getRememberedLoginId() { @@ -62,8 +44,6 @@ export function clearSessionTokens() { for (const storage of [localStorage, sessionStorage]) { storage.removeItem(ACCESS_TOKEN_KEY) storage.removeItem(REFRESH_TOKEN_KEY) - storage.removeItem(OVERSEAS_ACCESS_TOKEN_KEY) - storage.removeItem(OVERSEAS_EXPIRES_AT_KEY) storage.removeItem('cc-client-token') } } diff --git a/src/api/unifiedHttp.test.mjs b/src/api/unifiedHttp.test.mjs new file mode 100644 index 0000000..63e5da8 --- /dev/null +++ b/src/api/unifiedHttp.test.mjs @@ -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 + }) + } +}) diff --git a/src/modules/cross-border/api/businessOrders.ts b/src/modules/cross-border/api/businessOrders.ts index 91db22d..3f1a894 100644 --- a/src/modules/cross-border/api/businessOrders.ts +++ b/src/modules/cross-border/api/businessOrders.ts @@ -1,4 +1,4 @@ -import { overseasApiRequest } from '@/api/overseasHttp' +import { apiRequest } from '@/api/http' export interface PageResult { records: T[] @@ -141,23 +141,23 @@ function normalizePage(data: PageResult): PageResult { } export function listBusinessOrders(kind: CrossBorderBusinessKind, params: BusinessOrderQuery) { - return overseasApiRequest>({ method: 'GET', url: endpointByKind[kind], params }).then(normalizePage) + return apiRequest>({ method: 'GET', url: endpointByKind[kind], params }).then(normalizePage) } export function getBusinessOrder(kind: CrossBorderBusinessKind, orderNo: string) { - return overseasApiRequest({ method: 'GET', url: `${endpointByKind[kind]}/${encodeURIComponent(orderNo)}` }) + return apiRequest({ method: 'GET', url: `${endpointByKind[kind]}/${encodeURIComponent(orderNo)}` }) } export function createProcurement(payload: ProcurementCreate) { - return overseasApiRequest({ method: 'POST', url: endpointByKind.procurement, data: payload }) + return apiRequest({ method: 'POST', url: endpointByKind.procurement, data: payload }) } export function createTransport(kind: 'trucking' | 'looseboard', payload: TransportCreate) { - return overseasApiRequest({ method: 'POST', url: endpointByKind[kind], data: payload }) + return apiRequest({ method: 'POST', url: endpointByKind[kind], data: payload }) } export function updateTransport(kind: 'trucking' | 'looseboard', orderNo: string, payload: TransportUpdate) { - return overseasApiRequest({ + return apiRequest({ method: kind === 'trucking' ? 'PATCH' : 'PUT', url: `${endpointByKind[kind]}/${encodeURIComponent(orderNo)}`, data: payload, diff --git a/src/modules/first-leg/views/CustomsDocumentsView.vue b/src/modules/first-leg/views/CustomsDocumentsView.vue index 7fd5dee..4d8c585 100644 --- a/src/modules/first-leg/views/CustomsDocumentsView.vue +++ b/src/modules/first-leg/views/CustomsDocumentsView.vue @@ -1,7 +1,9 @@