diff --git a/AGENTS.md b/AGENTS.md index 7670800..ce0f91d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,8 @@ ## 模块快速查询 +退货预报与签收结果:`src/modules/overseas-warehouse/views/ReturnOrdersView.vue`、`api/returnOrders.ts`;菜单仍名为“退货换标”,正式路由 `return-relabel` 已使用独立页面,不再由 `WarehouseServiceView.vue` 承载。客户退货库存/箱号共用 `views/ReturnInventoryView.vue`、`api/returnInventory.ts`,`return-cartons` 传 `cartonOnly=true`。Backend 为 Lastleg `returnreceipt/`,退货单签收完成不等于换标或退货出库完成;后续换标、出库须按各自业务状态核对。 + 先从 `src/modules/registry.ts` 确认模块,再按目标模块的 `routes.ts → views/ → api/、store/、contracts.ts 或 public.ts` 查询。页面是否正式开放以 `routes.ts` 的 `release` 为准,不从任务或进度文档推断。 | 业务 | 模块入口 | 联查模块或服务端 | @@ -51,13 +53,15 @@ | 装柜完成后的退税报关资料 | `src/modules/first-leg/views/FirstLegOrdersView.vue` 的“报关资料”、`components/CustomsDocumentsDialog.vue`、`api/customs.ts` | 只显示登录客户在同柜的退税入仓码;报关开始前可上传、替换、调整同一资料覆盖的一个或多个入仓码及删除误传,全部写入携带幂等键和版本;开始后只读冻结资料,整柜完成后才能下载平台回传的一对一完成文件。Backend `firstleg/customs/container/`、`customs-v1.yaml`,二进制归 `file/` | | 统一订单展示 | `src/modules/order-management/` | `src/modules/first-leg/` 与对应后端订单契约 | | 客户财务账单、入仓码联查、收款、未收余额与客户确认 | `src/modules/finance/`;确认与明细入口 `views/FinanceBillsView.vue`、`api/finance.ts`,入仓码通过 `src/modules/first-leg/public.ts` 复用 `InboundCodeAssociationDialog` | Backend `finance/`;账单行只显示服务端 `inboundCode` 并按登录客户查询业务关系,缺失显示“—”,不得回退内部 ID。收款方式由服务端枚举映射中文。出账或已出账调整后为 `PENDING_CONFIRMATION`,客户确认提交登录成员、`expectedVersion` 和幂等键 | -| 客户资金账户、授信与充值申请 | `src/modules/finance/views/FinanceAccountView.vue`、`api/account.ts` | 显示当前企业 CNY 实际余额、冻结金额、授信额度、已用授信、冻结授信、总可用余额和不可变流水;数据加载前显示“—”,不得先伪装为零。充值只展示服务端启用的微信/支付宝静态收款码,客户上传 JPG/PNG 付款截图后进入待审核。客户范围只取登录态,无收款码时禁止提交,不模拟支付成功 | +| 客户资金账户、授信与充值申请 | `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`;退货预报另见本节独立入口;贴标申请先选国外中转仓和有库存历史入仓单,换标申请必须提交正数 CNY `chargeAmount`,换标类型为只换 SKU、只换箱唛、SKU+箱唛。箱唛换标明细一行固定代表一箱,箱数不可编辑,提交也固定为 1。货物明细外层始终提供“SKU 关联”“箱号关联”“SKU 混箱换标”三套模板的下载、上传,不随换标类型切换;上传只进入待导入关系区,客户可勾选部分或全选后导入本次明细,只有进入明细的库存才随申请提交和冻结。明细也支持手动新增编辑,组合换标以旧箱唛连接旧/新 SKU 和旧箱唛/新 FBA 箱唛关系。涉及箱唛换标时必须选择卡车、自提或快递;快递可选择打印面单并上传 PDF/图片。PDA 逐箱换标、上传照片并由仓库复核后,客户详情展示完工照片和“换标完成确认”;客户确认前不得继续出库,确认后卡车进入预排车,自提/快递进入换标出库待创建。历史库存位置不能解释成箱唛与具体托盘快照,客户页面不暴露托盘、库位和内部库存 ID | ## 租户、认证与数据安全 +- 换标申请必须提交正数 CNY `chargeAmount`;入口为 `src/modules/overseas-warehouse/views/WarehouseServiceView.vue` 与 `api/warehouseServices.ts`,金额由服务端再次校验,资金状态以服务端返回为准,不在客户端模拟冻结成功。 + - 任何查询和变更都必须以服务端确认的当前租户为边界;绝不从可篡改的前端参数推断授权租户。 - 企业成员、角色和权限只能管理当前企业范围。前端隐藏不是安全控制,服务端必须重复校验租户与权限。 - 注册时登录手机号同时初始化公司联系电话;注册完成后两者独立维护。企业资料只修改 `contactPhone`,个人资料或成员管理中的登录手机号由 IAM/Auth 同步并撤销该账号现有会话,不得在前端互相回填或联动提交。 diff --git a/src/api/overseasFailure.test.mjs b/src/api/overseasFailure.test.mjs new file mode 100644 index 0000000..df61ce0 --- /dev/null +++ b/src/api/overseasFailure.test.mjs @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' +import ts from 'typescript' + +const source = await readFile(new URL('./types.ts', import.meta.url), 'utf8') +const compiled = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 }, +}).outputText +const { isApiFailure, isApiEnvelope } = await import(`data:text/javascript;base64,${Buffer.from(compiled).toString('base64')}`) + +test('recognizes a security failure without data and preserves diagnostics', () => { + const failure = { code: 'A0403', message: 'Access denied', requestId: 'request-1', traceId: 'trace-1' } + assert.equal(isApiFailure(failure), true) + assert.equal(isApiEnvelope(failure), false) + assert.equal(failure.requestId, 'request-1') + assert.equal(failure.traceId, 'trace-1') +}) + +test('does not accept malformed or data-less successful responses', () => { + for (const value of [null, {}, { code: 403, message: 'Denied' }, { code: 'A0403' }, + { code: 'A0403', message: 'Denied', traceId: {} }, { code: '200', message: 'OK' }]) { + assert.equal(isApiFailure(value), false) + } + assert.equal(isApiEnvelope({ code: '200', message: 'OK' }), false) + 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\)\)/) + assert.match(transport, /isApiEnvelope\(body\) \|\| isApiFailure\(body\)/) +}) diff --git a/src/api/overseasHttp.ts b/src/api/overseasHttp.ts index 72e4a1c..3abc261 100644 --- a/src/api/overseasHttp.ts +++ b/src/api/overseasHttp.ts @@ -1,6 +1,6 @@ import axios, { type AxiosRequestConfig, type InternalAxiosRequestConfig } from 'axios' import { parseApiJsonResponse } from '@chongchong/shared' -import { ApiError, isApiEnvelope, type ApiEnvelope } from '@/api/types' +import { ApiError, isApiEnvelope, isApiFailure } from '@/api/types' import { getOverseasAccessToken } from '@/api/session' import { refreshAuthenticatedSession } from '@/api/http' @@ -37,7 +37,11 @@ transport.interceptors.response.use((response) => response, async (error) => { export async function overseasApiRequest(config: AxiosRequestConfig) { try { - const response = await transport.request>(config) + 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 : '海外接口响应格式错误', @@ -48,12 +52,12 @@ export async function overseasApiRequest(config: AxiosRequestConfig) { isApiEnvelope(response.data) ? response.data.requestId : null, ) } - return response.data.data + 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)) { + 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) diff --git a/src/api/types.ts b/src/api/types.ts index e262136..0a405f0 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -30,3 +30,13 @@ export function isApiEnvelope(value: unknown): value is ApiEnvelope { const record = value as Record return typeof record.code === 'string' && typeof record.message === 'string' && 'data' in record } + +// Security failures have no business data, unlike successful API responses. +export function isApiFailure(value: unknown): value is Omit, 'data'> { + if (!value || typeof value !== 'object') return false + const record = value as Record + return typeof record.code === 'string' && record.code !== '200' + && typeof record.message === 'string' + && (record.requestId == null || typeof record.requestId === 'string') + && (record.traceId == null || typeof record.traceId === 'string') +} diff --git a/src/layouts/AppLayout.test.mjs b/src/layouts/AppLayout.test.mjs index 4ad0ffd..eef11da 100644 --- a/src/layouts/AppLayout.test.mjs +++ b/src/layouts/AppLayout.test.mjs @@ -55,6 +55,13 @@ test('source contract: keeps route context and user identity in the compact head assert.doesNotMatch(routerSource, /showPageHeading|showTenantName/) }) +test('source contract: renders the complete route breadcrumb before page content', () => { + assert.match(layoutSource, /import \{ getFirstAccessiblePath, getRouteBreadcrumbs \} from '@\/navigation\/clientNavigation'/) + assert.match(layoutSource, /const breadcrumbs = computed\(\(\) => getRouteBreadcrumbs\(route\.path, String\(pageTitle\.value\)\)\)/) + assert.match(layoutSource, /