# 查价系统 PRD v0.6 | 版本 | 日期 | 作者 | 说明 | |------|------|------|------| | v0.1 | 2026-06-16 | 产品 | 背景与核心功能初稿(第 1–7 章) | | v0.2 | 2026-06-16 | 产品负责人/架构师/技术PM | 可开工版:矛盾收敛、全章节补齐(1–12 章) | | v0.3 | 2026-06-16 | QA负责人/SRE/架构师 | 补齐第 11 章异常韧性设计、第 12 章验收标准(可自动化) | | v0.4 | 2026-06-16 | 产品/架构 | 对齐 Mothership 实测:四档报价、地址联想、托盘数、单位换算、嵌入宿主、官方尺寸限制 | | v0.5 | 2026-06-17 | 产品/架构 | 明确 Mothership 公开报价页匿名查价;RPA 默认真实抓取,Mock 仅 CI/单测 | | v0.6 | 2026-06-17 | 产品/架构/SRE | RPA 入口 URL 多候选探测、Selector 录制规范、匿名 storageState、入口/抓取失败分类 | | v0.7 | 2026-07-13 | 产品/架构 | 新增 **Flock Freight** 报价源模块(§14);扩展点与硬限对齐官网实测 | > 本版业务权威为 **v0.7**(在 v0.6 基线上增量)。Mothership 相关规则无变更处仍以 v0.6 为准。技术栈与实现细节见 [查价系统-技术设计.md](./查价系统-技术设计.md);界面与交互见 [查价系统-UI设计.md](./查价系统-UI设计.md);RPA 解阻塞参考见 [参考-Mothership-RPA-开源借鉴与解阻塞.md](./参考-Mothership-RPA-开源借鉴与解阻塞.md)。异常处理见第 11 章;验收与上线门禁见第 12 章;Flock Freight 见第 14 章。 --- ## 工程收敛决策(全局生效) | 编号 | 决策 | 唯一规则 | |------|------|----------| | GD-1 | RPA 模型 | 一次询价 = 1 次 RPA;单次会话内抓取 **4 档报价**(standard/guaranteed × lowest/fastest);严禁多次 RPA | | GD-2 | 缓存模型 | 三层固定优先级:L1 `request_id`(24h 幂等) > L2 `cargo_hash`(3min 热缓存) > L3 stale(30min,仅 RPA 失败降级) | | GD-3 | service_level / rate_option | 均不进入 cache key;缓存仅按货物维度;四档一次返回 | | GD-4 | 成功率指标 | 拆分三指标:API success rate(含缓存)/ RPA success rate(真实外呼)/ realtime rate(真实 RPA 占比);不使用单一 99.5% | | GD-5 | 询价交互模型 | 异步:`POST /quotes` 立即返回 `quote_id` + 状态;客户端轮询 `GET /quotes/{quote_id}` | | GD-6 | 加价基准 | 仅对运费 `raw_freight` 按百分比加价;上限 30%;未配置客户按 0% | | GD-7 | 币种 | 本期仅 USD;多币种字段保留但不启用 | | GD-8 | 货物类型 | 本期枚举固定为 9 项(见 §4.3.6);不做「实施时再抓」 | | GD-9 | 地址校验 | Mothership 报价页为**地址联想下拉**,禁止仅填街道后 Enter;须携带**已选联想项**(`place_id` 或等效选中快照)方可询价 | | GD-15 | RPA 报价入口 | `MOTHERSHIP_QUOTE_URLS` 须为 **headed 录制验证** 的匿名公开报价 URL 列表(逗号分隔、按序探测);**禁止**默认或示例使用 `dashboard.mothership.com`、`www.mothership.com/quote` | | GD-11 | 嵌入形态 | 查价为**宿主系统内嵌能力**,不自建客户/运营登录;本期仅保留**管理端**(预警/RPA/开发进度) | | GD-12 | 货物计量 | 使用**托盘数**(`pallet_count`),对齐 Mothership `freight[].quantity`(type=Pallet);禁止「件数」 | | GD-13 | 单位换算 | 前端/API 可接收 kg/cm;**落库与 RPA 前强制换算为 lb/in**(GD-10 精度不变) | | GD-14 | 尺寸重量上限 | 仅以 [Mothership Help Center](https://help.mothership.com/en/articles/5690771-will-my-freight-fit) 公布值为硬校验;禁止自定义宽范围 | | GD-10 | 金额精度 | 一律 `DECIMAL(12,2)`,四舍五入 `ROUND_HALF_UP`,保留 2 位 | | GD-16 | Flock Freight | 第二报价源(Phase 扩展);入口 `https://app.flockfreight.com/get-a-quote`;硬限与结果档位见 **§14**;校验口径与 Mothership **分源独立**,禁止混用上限 | --- ## 第 1 章 背景与目标 ### 1.1 目的 为客户提供卡派(LTL)自助查价能力,以美美与共作为报价中台,通过 RPA 从 Mothership 获取报价,叠加运营加价后返回客户。本章定义业务动机、目标与边界。 ### 1.2 现状与痛点 | 痛点 | 表现 | 影响 | |------|------|------| | 人工查价慢 | 客服手工登录 Mothership 录入并回传 | 单次 3–10 分钟,高峰排队 | | 报价不一致 | 操作习惯差异、in/cm 换算错误 | 预算与实付偏差,客诉 | | 难规模化 | 无法并发响应 | 限制业务增长 | ### 1.3 建设目标与衡量 | 目标 | 定义 | 衡量指标(见 §10、§12) | |------|------|--------------------| | 提速 | 客户自助,无需人工 | 查价响应 P95 < 30s | | 降本 | 减少人工查价 | RPA 调用次数下降(缓存命中率) | | 减少偏差 | RPA 与人工一致 | RPA vs 人工偏差 ≤ 5% | ### 1.4 系统定位 ``` 宿主业务系统(美美与共等) ├─ 内嵌查价组件 / 调用查价 API(宿主鉴权,透传 customer_id) └─ 运营加价由宿主配置或 API 写入(本期无独立运营端) │ HTTP 美美与共 报价中台(本期建设主体) ├─ 查价编排服务 Quote Orchestrator ├─ 缓存层 Redis(L1/L2/L3) ├─ RPA 队列 BullMQ + Worker Pool(Playwright) ├─ 加价引擎 Pricing Engine ├─ 预警模块 Alert ├─ 管理端(仅管理员:预警/RPA/开发进度) └─ 持久层 MySQL(quote_record / idempotency / markup_config / alert_log / quote_cache_meta) │ RPA Mothership(外部报价源,仅 RPA) ``` - 美美与共:报价中台,负责编排/加价/缓存/预警/落库;**查价 UI 嵌入宿主,不单独面向终端客户开户**。 - CC:本期不接,Phase 2 通过 API 对接(接口与字段已预留)。 - Mothership:唯一报价源,仅 RPA;报价页地址须从联想列表点选,结果含 standard/guaranteed 各 **最低价格 / 最快递送** 选项。 ### 1.5 MVP 边界 **本期包含**:宿主内嵌查价 API/组件、Mothership RPA(四档报价)、运营加价(宿主/API 配置)、询价记录、三层缓存、缓存降级、偏差预警、**管理端**(预警中心 + RPA/开发进度,无独立客户/运营登录页)。 **本期不包含**:CC 对接、Mothership API、嵌入私仓预报、Priority1、**Flock Freight 实现(口径见 §14,本期仅文档)**、下单、对账、多币种、**独立客户查价 H5 账号体系**、**独立运营管理端登录**。 **交付约束**:3 周;团队 = 1 技术 + Cursor + 1 产品经理。 --- ## 第 2 章 用户与场景 ### 2.1 角色与鉴权 | 角色 | 来源 | 鉴权 | 权限 | |------|------|------|------| | 宿主系统 | 业务系统内嵌查价或服务端调用 | 宿主签发 **Service Token / API Key**(或宿主 JWT 经网关换票);请求体携带 `customer_id` | 代终端用户提交询价、查历史、配置加价(若宿主开放) | | 管理员 | 报价中台管理端 | JWT + RBAC `alert:read` `rpa:operate` | 查看/处理预警、RPA 状态、开发进度指标;**本期唯一需登录的角色** | 鉴权规则: - 查价类 API:`Authorization: Bearer <宿主 token>`;`customer_id` 由宿主传入,中台校验 token 与租户绑定关系,越权 403。 - 管理端 API:管理员 JWT;与宿主 token 隔离。 - **本期不提供** `customer_demo` / `operator_demo` 等独立业务账号;Demo/联调使用宿主模拟 `customer_id` + 管理端 `admin` 账号。 ### 2.2 场景一:宿主内嵌查价(主路径,异步模型) **前置**:用户在宿主业务系统内操作;宿主前端或 BFF 持有 Service Token。 1. 宿主页面加载内嵌查价组件(或跳转宿主路由)。 2. 填写提货/派送地址(**须从地址联想列表点选**,见 §4.2.1)、单托重量与尺寸(可用 kg/cm 录入)、**托盘数**、货物类型。 3. 宿主调用 `POST /api/quotes`(前端生成 `request_id`)。 4. 服务端换算为 lb/in → 校验 Mothership 官方范围 → 返回 `quote_id` + `processing`(或缓存命中 `done`)。 5. 轮询 `GET /api/quotes/{quote_id}`(2s,最长 30s)。 6. `status=done`:展示 **4 档报价**(standard/guaranteed × lowest/fastest),默认 standard + lowest;3 分钟倒计时。 7. 3 分钟内同货物重复查价:命中 L2,不触发 RPA。 8. 倒计时归零:宿主弹窗确认后以新 `request_id` 重询。 **示例输入**:LA 已选联想地址 → Dallas 已选联想地址,单托 500 lb,48×40×48 in,**2 托盘**,general_freight。 **示例输出**(节选 2 档,实际返回 4 档): | 项目 | standard · lowest | guaranteed · fastest | |------|-------------------|----------------------| | rate_option | 最低价格 | 最快递送 | | 运费 raw_freight | $320.00 | $410.00 | | 客户价 final_total(加价 10%) | $352.00 | $451.00 | | 时效 | 预计 5–7 天 | 保障 3 天内 | ### 2.3 场景二:宿主配置加价 1. 宿主运营后台或 `PUT /api/markup-configs/{customer_id}`(Service Token + `pricing:markup:write`)。 2. 设置运费加价比例 0–30%(步长 0.1)。 3. 仅影响该 `customer_id` 之后的新询价;历史不重算。 4. **报价中台管理端不提供运营加价 UI**(仅管理员查看预警/RPA)。 ### 2.4 场景三:管理员处理预警 1. 「预警中心」列表展示预警(站内)。 2. 查看详情:`quote_id`、类型、原始价、对比价、偏差%、时间。 3. 操作:标记已处理 / 暂停 RPA Worker / 触发人工兜底录入。 ### 2.5 场景四:RPA 失败降级(异常路径) **失败分类(v0.6)**: | 类型 | 典型原因 | 错误码/告警 | 是否走 L3 | |------|----------|-------------|-----------| | **入口错误** | URL 错误、404、登录页且无凭据、preCheck 无表单 | `STRUCT_CHANGE` / `QUOTE_ENTRY_UNAVAILABLE` | 是(若有 stale);**不消耗 RPA 重试配额** | | **抓取错误** | 超时、验证码、DOM 变更 mid-flow、四档不全、价格 ≤0 | `RPA_CAPTCHA` / `RPA_DATA_INVALID` / `QUOTE_TIMEOUT` 等 | 是 | | **地址未锁定** | RPA 补选联想失败 | `ADDRESS_SUGGESTION_NOT_FOUND` | 宿主路径 400;Worker 路径走 L3 | **主路径**: 1. RPA 超时 / 验证码 / 封禁 / 入口错误 / 抓取错误。 2. 按 GD-2 读取 L3 stale(≤30min)。 3. 命中:返回该报价,`is_realtime=false`,写 `STALE_FALLBACK` 预警。 4. 未命中:`status=failed`,错误 `QUOTE_UNAVAILABLE`,写 `RPA_FAILED` 预警,提示宿主稍后重试。 --- ## 第 3 章 范围与扩展点 ### 3.1 功能范围矩阵 | 模块 | 本期 | 说明 | |------|------|------| | 独立查价页 | ✅ | **宿主内嵌组件/API**,非独立 C 端账号 | | 询价输入校验 | ✅ | §4(含地址联想、托盘数、Mothership 官方尺寸) | | 四档输出(异步) | ✅ | §5、GD-1、GD-5 | | 运营加价(按客户、运费%) | ✅ | §6;由宿主/API 配置 | | 三层缓存 + 生命周期 | ✅ | §7、GD-2 | | 询价历史记录 | ✅ | §9,预留 CC 字段 | | Mothership RPA(四档) | ✅ | GD-1 | | 缓存降级 | ✅ | §7.6 | | 偏差/失败预警(站内) | ✅ | §7.5 | | 管理端预警 + RPA/开发进度 | ✅ | §2.4;**无客户/运营登录** | | Mothership API | ❌ | 不使用 | | CC 对接 | ❌ | Phase 2 | | 嵌入私仓预报 | ❌ | Phase 3 | | Priority1 | ❌ | Phase 4,保留 `QuoteProvider` | | Flock Freight RPA | ✅ | **§14**;默认关,`FLOCK_RPA_ENABLED` / Mock 启用 | | 多币种 / 下单 / 对账 | ❌ | 后续 | ### 3.2 扩展点 | 扩展点 | 预留方式 | |--------|----------| | Priority1 | `QuoteProvider` 接口,新增实现类 | | Flock Freight | `QuoteProvider` → `FlockFreightRPAProvider`;独立校验常量 `FLOCK_LIMITS`(§14.3);独立队列/env(技术设计 §7.6) | | CC 对接 | 询价 API 契约稳定;`quote_record` 预留 `cc_customer_id`、`forecast_id` | | 嵌入预报 | 查价为独立组件,`request_id` 可关联预报单 | --- ## 第 4 章 询价输入 ### 4.1 功能点 | ID | 描述 | |----|------| | F4.1 | 提货/派送地址录入(**须完成联想点选**,见 §4.2.1) | | F4.2 | 单托重量录入(lb/kg,提交前换算 lb) | | F4.3 | 单托尺寸录入(in/cm,提交前换算 in) | | F4.4 | **托盘数**录入(`pallet_count`,非件数) | | F4.5 | 货物类型选择(固定枚举) | | F4.6 | 默认展示档位:`service_level=standard` + `rate_option=lowest`(仅影响 UI 默认 Tab) | | F4.7 | 单位换算:API 可收 kg/cm,**服务端与 RPA 统一 lb/in** | | F4.8 | 输入校验(Mothership 官方上限,§4.2.8) | ### 4.2 字段规格 #### 4.2.1 地址(pickup_address / delivery_address) Mothership 报价页行为(实测):地址输入框为**联想搜索**,输入 `1234 Warehouse Blvd` 后按 Enter **不能**确认地址;须在输入框上方出现的候选列表中**点击**一项,表单才锁定为有效地址。 **字段结构**: ```json { "street": "1234 Warehouse Blvd", "city": "Los Angeles", "state": "CA", "zip": "90001", "place_id": "ChIJxxxx", "formatted_address": "1234 Warehouse Blvd, Los Angeles, CA 90001, USA", "selected_from_suggestions": true } ``` | 字段 | 必填 | 说明 | |------|------|------| | street, city, state, zip | 是 | `state` 2 字母美国州码;`zip` 正则 `^\d{5}(-\d{4})?$` | | place_id | 是(宿主/API 路径) | Google Place ID 或 Mothership 联想项 ID;对齐 [Mothership API](https://developers.mothership.com/reference/createquote) 的 `placeId` | | formatted_address | 是 | 用户点选后的完整展示串 | | selected_from_suggestions | 是 | 必须为 `true`;`false` 拒绝询价 | **解决方案(二选一,宿主择一实现)**: | 方案 | 说明 | 适用 | |------|------|------| | **A. 宿主侧地址组件** | 宿主复用与 Mothership 同源的 Places 联想(或自研等价组件),用户点选后把 `place_id` + 结构化地址传给中台 | **推荐**;嵌入形态默认 | | **B. RPA 侧补选** | 入参仅有街道文本时,Worker 输入触发联想 → `waitFor` 下拉 → 点击首条/匹配项 → 校验表单已锁定 | 仅当宿主无法提供 `place_id` 的过渡方案;成功率低于 A | 校验失败:`400 VALIDATION_FAILED`,提示「请从地址列表中选择有效地址」。 #### 4.2.2 重量(weight)— 单托重量 - `{ value: number, unit: "lb"|"kg" }`,必填,默认 lb - 表示**每一托盘**的重量(对齐 Mothership `freight[].weight`) - 换算:`lb = kg × 2.20462`,`ROUND_HALF_UP` 保留 2 位 - 范围:见 §4.2.8(**禁止**沿用 1–50000 lb 等自定义宽范围) #### 4.2.3 尺寸(dimensions)— 单托尺寸 - `{ length, width, height, unit: "in"|"cm" }`,必填,默认 in - 表示**每一托盘**外廓尺寸 - 换算:`in = cm ÷ 2.54`,保留 2 位 - 范围:见 §4.2.8 #### 4.2.4 托盘数(pallet_count) - 正整数,必填 - 语义:托盘数量;映射 Mothership `freight[].quantity` 且 `type: "Pallet"` - **禁止**使用「件数」「箱数」等非托盘字段 - 范围:见 §4.2.8 #### 4.2.5 货物类型(cargo_type)— 固定枚举(GD-8) | 枚举值 | 显示名 | |--------|--------| | general_freight | 普通货物 | | machinery | 机械设备 | | furniture | 家具 | | electronics | 电子产品 | | building_materials | 建材 | | auto_parts | 汽车配件 | | food_nonperishable | 食品(非生鲜) | | apparel | 服装 | | other | 其他 | 默认 `general_freight`。 #### 4.2.6 服务等级(service_level)与报价选项(rate_option) | 字段 | 枚举 | 默认 | 说明 | |------|------|------|------| | service_level | `standard` \| `guaranteed` | `standard` | Mothership 结果页两大服务类型 | | rate_option | `lowest` \| `fastest` | `lowest` | 各 service_level 下的子选项:**最低价格** / **最快递送** | - 仅决定结果页默认 Tab;**一次 RPA 仍返回 4 档**(GD-1) - `service_level`、`rate_option` **均不进入** cache key(GD-3) #### 4.2.7 单位偏好(display_unit) - `"imperial"|"metric"`,非必填,默认 `imperial` - **仅影响宿主 UI 展示**;`POST /quotes` 时服务端按 `weight.unit` / `dimensions.unit` 换算,**RPA 仅使用 lb/in** #### 4.2.8 Mothership 官方货物限制(硬校验,GD-14) > 来源:[Will my freight fit?](https://help.mothership.com/en/articles/5690771-will-my-freight-fit) · [Getting Rates](https://developers.mothership.com/docs/booking-rates)(`freight[].type=Pallet`) 本期查价货物详情硬校验口径(National LTL,单次最多 **25** 托): | 字段 | 换算后规则(lb/in) | 说明 | |------|---------------------|------| | pallet_count | 整数 **1–25** | 单次询价托盘数上限 | | length | **1–999** in | 单托长度上限 999 in | | width | **1–99** in | 单托宽度上限 99 in | | height | **1–99** in | 单托高度上限 99 in | | weight(单托) | **0.01–9,999** lb | 单托重量上限 9,999 lb | | 整票总重 | `weight_lb × pallet_count` ≤ **249,975** lb | 9,999 × 25 硬顶 | **非本期默认路径**(仅文档备案,不扩校验除非产品切换车型): | 车型 | 托盘上限 | 总重上限 | |------|----------|----------| | 26-foot box truck(同城) | 12 | 9,500–10,000 lb | | 53-foot dry van | 28 | 44,000 lb | 超限:`400 VALIDATION_FAILED`,提示「{字段}超出 Mothership 允许范围」。 ### 4.3 请求体(POST /quotes) ```json { "request_id": "550e8400-e29b-41d4-a716-446655440000", "customer_id": "CUST_001", "pickup_address": { "street": "1234 Warehouse Blvd", "city": "Los Angeles", "state": "CA", "zip": "90001", "place_id": "ChIJxxxx_pickup", "formatted_address": "1234 Warehouse Blvd, Los Angeles, CA 90001, USA", "selected_from_suggestions": true }, "delivery_address": { "street": "5678 Distribution Dr", "city": "Dallas", "state": "TX", "zip": "75201", "place_id": "ChIJxxxx_delivery", "formatted_address": "5678 Distribution Dr, Dallas, TX 75201, USA", "selected_from_suggestions": true }, "weight": { "value": 500, "unit": "lb" }, "dimensions": { "length": 48, "width": 40, "height": 48, "unit": "in" }, "pallet_count": 2, "cargo_type": "general_freight", "service_level": "standard", "rate_option": "lowest", "display_unit": "imperial" } ``` ### 4.4 业务规则 | ID | 规则 | |----|------| | BR-4.1 | 入参换算为 in/lb 后再生成 cargo_hash 与调用 RPA | | BR-4.2 | `request_id` 必填、UUID v4;服务端按 GD-2 L1 做 24h 幂等 | | BR-4.3 | 地址须 `selected_from_suggestions=true` 且含 `place_id`(方案 A);或 RPA 补选成功(方案 B) | | BR-4.4 | `cargo_hash = MD5(pickup+delivery+weight_lb+dims_in+pallet_count+cargo_type)`,不含 `service_level`、`rate_option` | | BR-4.5 | `pallet_count` 为托盘数;禁止 `quantity` 件数字段 | | BR-4.6 | 重量/尺寸为**单托**值;整票总重 = `weight_lb × pallet_count` | ### 4.5 异常 | 异常 | 触发 | 行为 | 提示 | |------|------|------|------| | E4.1 | 必填缺失 | 400 拒绝 | 「请填写{字段}」 | | E4.2 | 超范围 | 400 拒绝 | 「{字段}超出允许范围」 | | E4.3 | 地址未点选联想项 / 缺 place_id | 400 拒绝 | 「请从地址列表中选择有效地址」 | | E4.4 | 单位非法 | 400 拒绝 | 「请选择有效的单位」 | | E4.5 | request_id 非 UUID | 400 拒绝 | 「请求标识无效」 | | E4.6 | 超 Mothership 官方范围(§4.2.8) | 400 拒绝 | 「{字段}超出 Mothership 允许范围」 | | E4.7 | 使用 quantity/件数字段 | 400 拒绝 | 「请使用托盘数 pallet_count」 | --- ## 第 5 章 询价输出 ### 5.1 功能点 | ID | 描述 | |----|------| | F5.1 | 一次返回 **4 档报价**(standard/guaranteed × lowest/fastest,GD-1) | | F5.2 | 默认展示 standard + lowest | | F5.3 | 价格明细(运费/附加费/加价/总价) | | F5.4 | 加价后客户价 | | F5.5 | 3 分钟有效期与倒计时 | | F5.6 | 来源/实时性/可信度标识 | ### 5.2 查询响应(GET /quotes/{quote_id}) ```json { "quote_id": "QTE_20260616_0001", "request_id": "550e8400-e29b-41d4-a716-446655440000", "status": "done", "valid_until": "2026-06-16T14:32:00Z", "source_type": "rpa", "is_realtime": true, "confidence_score": 0.95, "currency": "USD", "quotes": [ { "service_level": "standard", "rate_option": "lowest", "carrier": "Mothership", "transit_days": "5-7", "transit_description": "最低价格 · 预计 5-7 天送达", "raw_freight": 320.00, "surcharges": 0.00, "raw_total": 320.00, "markup_percent": 10.0, "markup_amount": 32.00, "final_total": 352.00, "breakdown": [ { "item": "运费", "amount": 320.00 }, { "item": "附加费", "amount": 0.00 }, { "item": "运营加价(10.0%)", "amount": 32.00 } ] }, { "service_level": "standard", "rate_option": "fastest", "carrier": "Mothership", "transit_days": "3-4", "transit_description": "最快递送 · 预计 3-4 天送达", "raw_freight": 365.00, "surcharges": 0.00, "raw_total": 365.00, "markup_percent": 10.0, "markup_amount": 36.50, "final_total": 401.50, "breakdown": [ { "item": "运费", "amount": 365.00 }, { "item": "附加费", "amount": 0.00 }, { "item": "运营加价(10.0%)", "amount": 36.50 } ] }, { "service_level": "guaranteed", "rate_option": "lowest", "carrier": "Mothership", "transit_days": "4-5", "transit_description": "保证送达 · 最低价格", "raw_freight": 380.00, "surcharges": 0.00, "raw_total": 380.00, "markup_percent": 10.0, "markup_amount": 38.00, "final_total": 418.00, "breakdown": [ { "item": "运费", "amount": 380.00 }, { "item": "附加费", "amount": 0.00 }, { "item": "运营加价(10.0%)", "amount": 38.00 } ] }, { "service_level": "guaranteed", "rate_option": "fastest", "carrier": "Mothership", "transit_days": "3", "transit_description": "保证送达 · 最快递送", "raw_freight": 410.00, "surcharges": 0.00, "raw_total": 410.00, "markup_percent": 10.0, "markup_amount": 41.00, "final_total": 451.00, "breakdown": [ { "item": "运费", "amount": 410.00 }, { "item": "附加费", "amount": 0.00 }, { "item": "运营加价(10.0%)", "amount": 41.00 } ] } ] } ``` ### 5.3 字段说明 | 字段 | 类型 | 说明 | |------|------|------| | service_level | enum | `standard` / `guaranteed` | | rate_option | enum | `lowest`(最低价格)/ `fastest`(最快递送) | | status | enum | `processing` / `done` / `failed` / `expired` | | valid_until | ISO8601 | 报价生成时间 + 3 分钟 | | source_type | enum | `rpa` / `cache`(L2 命中)/ `stale`(L3 降级) | | is_realtime | bool | `rpa`/`cache`=true;`stale`=false | | confidence_score | decimal | rpa/cache=0.95;stale=0.70 | | currency | string | 固定 `USD` | | markup_percent | decimal | 应用比例(基于运费) | | final_total | decimal | `raw_total + raw_freight × markup% `,ROUND_HALF_UP | ### 5.4 展示规则 | ID | 规则 | |----|------| | BR-5.1 | 默认选中 standard + lowest;可切换 guaranteed / fastest | | BR-5.2 | 倒计时剩余 <30s 高亮 | | BR-5.3 | `is_realtime=false` 显示角标「非实时报价,仅供参考」 | | BR-5.4 | 仅展示 USD | | BR-5.5 | `quotes` 数组长度固定为 **4**(四档齐全才 `done`) | ### 5.5 异常输出 | 场景 | HTTP | 响应 | |------|------|------| | 处理中 | 200 | `status=processing` | | 完成 | 200 | `status=done` + quotes | | RPA 失败有 stale | 200 | `status=done` + `source_type=stale` + `is_realtime=false` | | RPA 失败无 stale | 200 | `status=failed` + `error_code=QUOTE_UNAVAILABLE` | | 超时(>30s 仍处理) | 200 | `status=failed` + `error_code=QUOTE_TIMEOUT` | | quote_id 不存在 | 404 | `error_code=QUOTE_NOT_FOUND` | | 越权访问 | 403 | `error_code=FORBIDDEN` | ### 5.6 错误码全集 | error_code | 含义 | 来源 | |------------|------|------| | VALIDATION_FAILED | 入参校验失败 | 输入 | | QUOTE_UNAVAILABLE | RPA 失败且无 stale | RPA | | QUOTE_TIMEOUT | 30s 内未完成 | RPA | | QUOTE_NOT_FOUND | quote_id 不存在 | 查询 | | FORBIDDEN | 越权/无权限 | 鉴权 | | RATE_LIMITED | 触发限流 | 网关 | | CARRIER_NO_CAPACITY | Mothership 返回无运力 | 业务 | | ADDRESS_NOT_SUPPORTED | 邮编不支持 | 业务 | | ADDRESS_SUGGESTION_NOT_FOUND | RPA 地址联想点选失败 | RPA / 输入 | | QUOTE_ENTRY_UNAVAILABLE | 所有报价入口 URL preCheck 失败 | RPA | | STRUCT_CHANGE | 页面结构变更 / 404 / 登录页且无凭据 | RPA | | INTERNAL_ERROR | 系统异常 | 系统 | --- ## 第 6 章 加价规则 ### 6.1 功能点 | ID | 描述 | |----|------| | F6.1 | 按客户独立配置 | | F6.2 | 仅按运费百分比加价(GD-6) | | F6.3 | 上限 30% | | F6.4 | 未配置默认 0% | | F6.5 | 配置审计(操作人、时间) | ### 6.2 配置项 | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| | customer_id | string | 是 | 客户唯一标识 | | markup_percent | decimal(5,1) | 是 | 0–30.0,步长 0.1 | | operator_id | string | 是 | 操作人 | | remark | string | 否 | 备注 | ### 6.3 计算(唯一公式) ``` markup_amount = ROUND_HALF_UP(raw_freight × markup_percent / 100, 2) final_total = ROUND_HALF_UP(raw_total + markup_amount, 2) ``` 示例:raw_freight=380.00,surcharges=0,raw_total=380.00,markup=10.0% → markup_amount=38.00,final_total=418.00。 含附加费示例:raw_freight=320,surcharges=15,raw_total=335,markup=10% → markup_amount=32.00,final_total=367.00。 ### 6.4 业务规则 | ID | 规则 | |----|------| | BR-6.1 | 仅 `pricing:markup:write` 可写 | | BR-6.2 | `markup_percent ∈ [0, 30.0]` | | BR-6.3 | 未配置客户按 0% | | BR-6.4 | 配置变更仅影响新询价,历史不重算 | ### 6.5 异常 | 异常 | 处理 | |------|------| | 比例 >30 | 400「加价比例不能超过 30%」 | | 客户不存在 | 400「客户不存在」 | | 无权限 | 403 FORBIDDEN | --- ## 第 7 章 报价生命周期与缓存 ### 7.1 状态机 ``` [创建 processing] → 命中 L1 → [done](原响应) → 命中 L2 → [done](source=cache) → 未命中 → 入 RPA 队列 → RPA 成功 → 写 L1/L2,落库 → [done source=rpa] → RPA 失败 → 查 L3 stale → 命中 → [done source=stale, is_realtime=false] + 预警 → 未命中 → [failed QUOTE_UNAVAILABLE] + 预警 → 30s 未完成 → [failed QUOTE_TIMEOUT] [done] --3 分钟到期--> [expired](客户端提示重询) ``` ### 7.2 三层缓存(GD-2,唯一规则) | 层 | Key | 存储 | TTL | 用途 | 优先级 | |----|-----|------|-----|------|--------| | L1 | `idem:{request_id}` | 完整响应快照 | 24h | 幂等:相同请求返同一结果 | 1(最高) | | L2 | `quote:{cargo_hash}` | 四档报价(未加价原始结果) | 3min | 去重 RPA | 2 | | L3 | `stale:{cargo_hash}` | 同 L2 内容 | 30min | 仅 RPA 失败时降级只读 | 3(最低) | 写入规则:RPA 成功后同时写 L1(带 request_id)、L2、L3。命中 L2 后仍按当前请求的客户加价比例重算 final_total 并写 L1。 读取顺序固定:L1 → L2 →(RPA)→ 失败时 L3。L3 永不用于正常路径,只在 RPA 失败时读取。 `cargo_hash` 定义见 BR-4.4,**不含 service_level、不含 customer_id**(加价在读出后按客户重算)。 ### 7.3 幂等规则 | 条件 | 行为 | |------|------| | 相同 `request_id` 24h 内再次 `POST /quotes` | 返回 L1 快照对应的同一 `quote_id`,不触发新 RPA | | L1 过期后相同 request_id | 视为新请求,重新走流程 | ### 7.4 有效期与复用 | 项 | 值 | |----|----| | 报价有效期 | 3 分钟(`valid_until`) | | 3min 内同 cargo 再询 | 命中 L2,无新 RPA | | 过期 | 客户端弹「是否重新询价」,确认后新 request_id | ### 7.5 偏差预警 | 条件 | 基准 | 动作 | |------|------|------| | 本次 RPA `raw_total` 与 L3 中上一次同 cargo 报价偏差 ≥ 5% | 上一次报价 | 写 `alert_log` 类型 `PRICE_DEVIATION`,站内通知管理员 | | 首次询价(无历史基准) | 无 | 不预警 | 偏差计算:`abs(new - prev) / prev ≥ 0.05`;恰好等于 5% 触发(`≥`)。 ### 7.6 降级 RPA 失败按 §2.5 / §7.1 执行:优先 L3 stale,写 `STALE_FALLBACK`;无 stale 则 `failed` 写 `RPA_FAILED`。 ### 7.7 预警类型 | type | 触发 | 通知 | |------|------|------| | PRICE_DEVIATION | 偏差 ≥5% | 管理员站内 | | STALE_FALLBACK | 使用 L3 降级 | 管理员站内 | | RPA_FAILED | RPA 失败无 stale | 管理员站内 | | RPA_CAPTCHA | 遇验证码 | 管理员站内 + 暂停该 Worker | --- ## 第 8 章 技术架构(可部署级) ### 8.1 架构组件 ``` [客户端/管理端] │ REST(JWT) [API 网关层] 限流(令牌桶) + 鉴权 + 路由 │ [Quote Orchestrator 查价编排服务] ├─ ValidationModule 入参校验 + 单位换算 ├─ CacheModule L1/L2/L3 读写(Redis) ├─ IdempotencyModule request_id 幂等 ├─ PricingEngine 按客户加价 ├─ AlertModule 预警写入 └─ QuoteProvider(接口) └─ MothershipRPAProvider(本期唯一实现) │ 入队 [BullMQ 队列(Redis)] ── [RPA Worker Pool(Playwright)] │ │ 1 Worker = 1 IP + 1 指纹 + 1 Session [MySQL] [Mothership] quote_record / idempotency_record markup_config / alert_log / quote_cache_meta ``` ### 8.2 RPA 执行流程(GD-1:单次会话四档报价) ``` 1. Worker 从 BullMQ 取 job(quote_id, cargo 标准化输入) 2. QuotePageAdapter.resolveQuoteEntry(): - 读取 env MOTHERSHIP_QUOTE_URLS(逗号分隔,按序) - 对每个 URL:goto → preCheck(非 login/captcha、报价表单 selector 可见) - 首个通过者写入 activeUrl;全部失败 → QUOTE_ENTRY_UNAVAILABLE / STRUCT_CHANGE - **禁止**内置默认 URL;禁止 `dashboard.mothership.com`、`www.mothership.com/quote`(已 404 或跳转登录) 3. 加载 RPA_STORAGE_STATE_PATH(若存在)匿名 Cookie;成功路径写回 storageState 4. 若页面为 login 且已配置 MOTHERSHIP_EMAIL/PASSWORD → 重登 1 次;若 login 且无凭据 → STRUCT_CHANGE(**非** SESSION_EXPIRED) 5. preCheck:404 文案 / 登录页且无凭据 → STRUCT_CHANGE;关键表单元素不可见 → STRUCT_CHANGE 6. 地址:输入街道 → wait 联想下拉 → click 含 zip 项 → 断言下拉消失且地址已锁定(GD-9);失败 → ADDRESS_SUGGESTION_NOT_FOUND 7. 填表:pickup/delivery、单托 weight(lb)/dims(in)、pallet_count、cargo_type 8. 提交一次;在结果页分别选择 standard/guaranteed,各切换 lowest/fastest,抓取 4 组价格与时效 9. 一致性校验:价格 >0、四档均存在 10. 标准化为 quotes[4],回写 Orchestrator 11. 异常:超时/验证码/结构变更/地址未锁定 → 抛分类错误 → 进入 fallback(§8.4) 重试:单 job 最多重试 1 次;连续失败 ≥3 触发熔断 10 分钟 ``` ### 8.2.1 RPA 环境变量(GD-15) | 变量 | 必填 | 说明 | |------|------|------| | `MOTHERSHIP_QUOTE_URLS` | **是** | 逗号分隔匿名报价入口 URL,按序探测 | | `RPA_STORAGE_STATE_PATH` | 否 | Playwright storageState 文件路径(默认 `.rpa/mothership-storage.json`) | | `RPA_SELECTOR_*` | **是** | 由 Playwright codegen 录制产出,见 §8.2.3 | | `MOTHERSHIP_EMAIL` / `MOTHERSHIP_PASSWORD` | 否 | 仅当入口跳转 login 且需账号时使用 | | `RPA_MOCK_MODE` | 否 | 仅 CI/单测为 `true`;验收与生产为 `false` | | `RPA_HEADLESS` | 否 | 默认 `true`;probe 失败时可设 `false` 对比 | ### 8.2.2 入口 URL 禁止清单 | URL | 原因 | |-----|------| | `https://dashboard.mothership.com/` | 非公开报价页,跳转 login | | `https://www.mothership.com/quote` | 2026-06 实测 404 | ### 8.2.3 RPA Selector 维护规范 1. 使用 **Playwright codegen(headed)** 录制人工匿名查价路径(LA→Dallas 标准样例)。 2. 从录制脚本提取 `goto` URL → 写入 `MOTHERSHIP_QUOTE_URLS`。 3. 从录制脚本提取 selector → 写入 `RPA_SELECTOR_*`(见技术设计 §7.2.3 清单)。 4. 每次 Mothership 页面改版后:重新录制 → 更新 env → 运行 `scripts/probe-rpa.ts` 连续 3 次 exit 0。 5. **禁止**手猜 selector 上线;禁止 LLM 运行时自愈替代生产热路径(GD-1 确定性要求)。 **验证命令**: ```bash RPA_MOCK_MODE=false RPA_HEADLESS=false npx tsx scripts/probe-rpa.ts ``` ### 8.3 Cache 流程 ``` POST /quotes(request_id, cargo): if L1[request_id] 存在: 返回其 quote_id (done) cargo_hash = MD5(标准化货物) if L2[cargo_hash] 存在: 按客户加价重算 → 落库 → 写 L1 → 返回 done(source=cache) else: 创建 quote_record(processing) → 入队 RPA → 返回 quote_id(processing) RPA 成功: 写 L2(3min)、L3(30min)、L1(24h) → 加价 → 落库 done(source=rpa) 与 L3 上次比对,偏差≥5% 写 PRICE_DEVIATION ``` ### 8.4 Fallback 流程 ``` RPA 失败: if L3[cargo_hash] 存在: 读出 → 按客户加价 → done(source=stale,is_realtime=false) 写 STALE_FALLBACK 预警 else: failed(QUOTE_UNAVAILABLE) + 写 RPA_FAILED 预警 验证码: 停止该 Worker 重试 → 标记 IP/指纹不可用 → 写 RPA_CAPTCHA → 走上面 L3 分支 ``` ### 8.5 接口清单 | 方法 | 路径 | 权限 | 说明 | |------|------|------|------| | POST | /api/quotes | 宿主 Service Token | 提交询价,返回 quote_id | | GET | /api/quotes/{quote_id} | 宿主(本人 customer_id) | 轮询结果 | | GET | /api/quotes/history | 宿主(本人 customer_id) | 分页历史(必传分页) | | PUT | /api/markup-configs/{customer_id} | 宿主(`pricing:markup:write`) | 新增/更新加价 | | GET | /api/alerts | 管理员 | 预警列表(分页) | | POST | /api/alerts/{id}/resolve | 管理员 | 标记处理 | 统一响应包络:`{ "code": 0, "message": "ok", "data": {...} }`;错误 `code` 取 §5.6。 --- ## 第 9 章 数据模型(可建表) > MySQL 8.0,InnoDB,utf8mb4。金额 `DECIMAL(12,2)`。所有删除为逻辑删除(`is_deleted`)。DDL 需人工确认后执行 migration。 ### 9.1 quote_record 询价记录 ```sql CREATE TABLE quote_record ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, quote_id VARCHAR(32) NOT NULL, request_id VARCHAR(36) NOT NULL, customer_id VARCHAR(32) NOT NULL, cargo_hash CHAR(32) NOT NULL, status VARCHAR(16) NOT NULL DEFAULT 'processing', source_type VARCHAR(16) NULL, is_realtime TINYINT(1) NOT NULL DEFAULT 1, confidence_score DECIMAL(3,2) NULL, currency VARCHAR(8) NOT NULL DEFAULT 'USD', -- 输入快照 pickup_json JSON NOT NULL, delivery_json JSON NOT NULL, weight_lb DECIMAL(12,2) NOT NULL, dim_l_in DECIMAL(8,2) NOT NULL, dim_w_in DECIMAL(8,2) NOT NULL, dim_h_in DECIMAL(8,2) NOT NULL, pallet_count INT NOT NULL COMMENT '托盘数,对齐 Mothership freight.quantity', cargo_type VARCHAR(32) NOT NULL, -- 四档结果快照 quotes_json JSON NULL, markup_percent DECIMAL(5,1) NOT NULL DEFAULT 0.0, valid_until DATETIME NULL, error_code VARCHAR(32) NULL, -- CC 预留 cc_customer_id VARCHAR(32) NULL, forecast_id VARCHAR(32) NULL, is_deleted TINYINT(1) NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_quote_id (quote_id), KEY idx_customer_created (customer_id, created_at), KEY idx_cargo_hash (cargo_hash), KEY idx_request (request_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ``` ### 9.2 idempotency_record 幂等表 ```sql CREATE TABLE idempotency_record ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, request_id VARCHAR(36) NOT NULL, customer_id VARCHAR(32) NOT NULL, quote_id VARCHAR(32) NOT NULL, expire_at DATETIME NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_request_id (request_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ``` > 说明:Redis L1 为热路径;本表为持久兜底,保证重启后 24h 内幂等仍成立。`request_id` 唯一键防并发重复写。 ### 9.3 quote_cache_meta 缓存元数据(偏差比对/审计) ```sql CREATE TABLE quote_cache_meta ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, cargo_hash CHAR(32) NOT NULL, raw_total_standard DECIMAL(12,2) NOT NULL, raw_total_guaranteed DECIMAL(12,2) NOT NULL, last_quotes_json JSON NOT NULL, refreshed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_cargo_hash (cargo_hash) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ``` > 用途:持久记录每个 cargo_hash 上一次原始报价,供 §7.5 偏差比对(即使 Redis L3 已过期)。 ### 9.4 markup_config 加价配置 ```sql CREATE TABLE markup_config ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, customer_id VARCHAR(32) NOT NULL, markup_percent DECIMAL(5,1) NOT NULL DEFAULT 0.0, operator_id VARCHAR(32) NOT NULL, remark VARCHAR(255) NULL, is_deleted TINYINT(1) NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_customer (customer_id), CONSTRAINT chk_markup CHECK (markup_percent >= 0 AND markup_percent <= 30.0) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ``` ### 9.5 alert_log 预警日志 ```sql CREATE TABLE alert_log ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, alert_type VARCHAR(32) NOT NULL, quote_id VARCHAR(32) NULL, cargo_hash CHAR(32) NULL, detail_json JSON NULL, status VARCHAR(16) NOT NULL DEFAULT 'open', resolver_id VARCHAR(32) NULL, resolved_at DATETIME NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_type_status (alert_type, status), KEY idx_created (created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ``` ### 9.6 字段映射(响应 ← 表) | 响应字段 | 来源 | |----------|------| | quote_id / status / source_type / is_realtime | quote_record | | quotes[] | quote_record.quotes_json | | markup_percent | markup_config(查询时) | | valid_until | quote_record.valid_until | --- ## 第 10 章 非功能需求 ### 10.1 性能 | 指标 | 目标 | |------|------| | 查价完成(done)P95 | < 30s(含 RPA) | | L1/L2 命中响应 P95 | < 300ms | | GET 轮询响应 P95 | < 200ms | | 加价配置/预警接口 P95 | < 500ms | ### 10.2 成功率指标(GD-4,拆分) | 指标 | 定义 | 目标 | |------|------|------| | API success rate | `done(含 cache/stale) / 总 POST` | ≥ 99.5% | | RPA success rate | `RPA done / 触发 RPA 的请求` | ≥ 85% | | realtime rate | `source∈{rpa,cache} / done` | ≥ 90% | | 缓存命中率 | `(L1+L2) 命中 / 总 POST` | ≥ 90%(3min 内重复场景) | | 重询率 | `过期后重新询价 / done` | ≤ 5% | | RPA vs 人工偏差 | 抽检 | ≤ 5% | ### 10.3 可用性 | 项 | 规则 | |----|------| | RPA 熔断 | 连续失败 ≥3 次熔断 10 分钟,期间全部走 L3/失败 | | 队列 | BullMQ 持久化;服务重启 job 不丢 | | 降级 | RPA 不可用时 L3 stale 兜底,标记非实时 | | 限流 | 单客户 60 次/分钟,超限 RATE_LIMITED | ### 10.4 幂等性 | 场景 | 保证 | |------|------| | 重复 POST 相同 request_id | L1 + idempotency_record 双保险,返回同一 quote_id | | 并发相同 request_id | 唯一键冲突 → 读已存在记录返回 | | RPA 重试 | 同 quote_id 不重复落库(按 quote_id 更新) | ### 10.5 一致性 | 项 | 规则 | |----|------| | 缓存与 DB | RPA 成功后先落库再写缓存;缓存失效不影响 DB 真值 | | 加价 | 命中 L2 时按当前客户配置实时重算,不缓存加价后价 | | 金额 | 全链路 DECIMAL(12,2),ROUND_HALF_UP | | 偏差基准 | quote_cache_meta 持久记录,避免 Redis 过期丢基准 | ### 10.6 安全 | 项 | 规则 | |----|------| | 鉴权 | 所有接口 JWT;客户仅访问本人资源 | | RBAC | 加价 `pricing:markup:write`;预警 `alert:read` | | 越权 | 403 FORBIDDEN | | 审计 | 加价变更、预警处理记录操作人与时间 | | 日志 | 所有 async 必须 log error,不得吞异常 | --- ## 第 11 章 异常场景与系统韧性设计 本章定义全链路失败点的唯一系统行为,与第 7 章生命周期、第 8 章 fallback 流程、第 12 章验收用例一一对应。每条均含:异常描述、系统行为、降级策略、告警、用户可见结果。 ### 11.1 输入层异常处理 **异常:必填字段为空或半空** 系统行为:400 VALIDATION_FAILED,拒绝入队,不生成 quote_id 是否允许降级:否 是否触发告警:否 用户看到的结果:「请填写{字段名}」 **异常:重量/尺寸/托盘数超 Mothership 官方范围(§4.2.8)** 系统行为:400 VALIDATION_FAILED,拒绝入队 是否允许降级:否 是否触发告警:否 用户看到的结果:「{字段}超出允许范围」 **异常:地址未从联想列表点选(selected_from_suggestions=false 或缺 place_id)** 系统行为:400 VALIDATION_FAILED,拒绝入队 是否允许降级:否 是否触发告警:否 用户看到的结果:「请从地址列表中选择有效地址」 **异常:地址邮编格式非法(非 5 位或 5+4)** 系统行为:400 VALIDATION_FAILED,拒绝入队 是否允许降级:否 是否触发告警:否 用户看到的结果:「地址无效,请检查州与邮编」 **异常:单位错配(数值与 unit 语义不一致,如 500kg 标为 lb)** 系统行为:按 BR-4.1 强制按声明 unit 换算为 in/lb 后处理,不报错 是否允许降级:否 是否触发告警:否 用户看到的结果:正常进入处理流程 **异常:地址含 SQL/脚本注入字符** 系统行为:400 VALIDATION_FAILED,拒绝入队,记录 audit log 是否允许降级:否 是否触发告警:是(安全类告警) 用户看到的结果:「地址无效,请检查」 **异常:货物类型不在 9 项枚举内** 系统行为:400 VALIDATION_FAILED,拒绝入队 是否允许降级:否 是否触发告警:否 用户看到的结果:「请选择有效的货物类型」 **异常:request_id 非 UUID v4** 系统行为:400 VALIDATION_FAILED,拒绝入队 是否允许降级:否 是否触发告警:否 用户看到的结果:「请求标识无效」 ### 11.2 行为与并发控制 **异常:重复点击提交按钮** 系统行为:前端 button disabled 3000ms;后端 L1 命中直接返回已有 quote_id 是否允许降级:是(L1) 是否触发告警:否 用户看到的结果:直接展示已有报价或 processing 状态 **异常:同一 request_id 并发 10 次提交** 系统行为:Redis L1 SETNX + DB 唯一键冲突,返回首次 quote_id,其余请求丢弃 是否允许降级:是(L1) 是否触发告警:否 用户看到的结果:所有请求返回同一 quote_id **异常:页面刷新后重复提交** 系统行为:request_id 不变,L1 24h 内幂等生效 是否允许降级:是(L1) 是否触发告警:否 用户看到的结果:返回原 quote_id **异常:多标签页同时对同一 request_id 操作** 系统行为:按 request_id 隔离,各自独立轮询 GET 是否允许降级:是(L1) 是否触发告警:否 用户看到的结果:各标签页状态一致 **异常:浏览器前进/后退导致状态错乱** 系统行为:客户端以 quote_id 为准,GET /quotes/{id} 实时拉取 是否允许降级:是(L3) 是否触发告警:否 用户看到的结果:显示当前真实状态 ### 11.3 网络与通信容错 **异常:客户端网络断开后恢复** 系统行为:服务端继续 RPA,客户端重连后轮询 GET 可取结果 是否允许降级:是(L3) 是否触发告警:否 用户看到的结果:轮询恢复后显示 done 或 failed **异常:请求已发送但响应丢失** 系统行为:客户端指数退避重试 GET,最多 15 次(30s 内) 是否允许降级:是(L1/L3) 是否触发告警:否 用户看到的结果:最终获得状态或超时提示 **异常:CDN 缓存脏数据** 系统行为:所有查价接口加 `Cache-Control: no-store`;API 网关不走 CDN 是否允许降级:否 是否触发告警:否 用户看到的结果:始终获取实时响应 **异常:客户端弱网(>500ms RTT)** 系统行为:客户端超时 30s 后提示「查询超时,请重试」 是否允许降级:是(L3) 是否触发告警:否 用户看到的结果:提示重试或展示 stale 报价 ### 11.4 后端 API 容错策略 **异常:API 处理超时(>30s 仍未 done)** 系统行为:定时任务标记 `status=failed` + `QUOTE_TIMEOUT`,不阻塞线程 是否允许降级:是(L3) 是否触发告警:是(RPA 超时类) 用户看到的结果:「查询超时,请重试」 **异常:HTTP 5xx(数据库/Redis 抖动)** 系统行为:网关 retry 1 次,仍失败返回 503 + INTERNAL_ERROR 是否允许降级:是(L3) 是否触发告警:是 用户看到的结果:「服务暂时不可用,请稍后重试」 **异常:响应字段缺失(quotes 数组为空)** 系统行为:按 RPA_DATA_INVALID 处理,走 L3 fallback 是否允许降级:是(L3) 是否触发告警:是 用户看到的结果:展示 stale 或「暂时无法获取报价」 **异常:响应结构变化(新增/删除关键字段)** 系统行为:Provider preCheck 失败,抛 STRUCT_CHANGE,走 L3 或 failed 是否允许降级:是(L3) 是否触发告警:是(STRUCT_CHANGE) 用户看到的结果:按 fallback 处理 **异常:幂等冲突(request_id 已存在但 cargo 不同)** 系统行为:L1 优先,返回原 quote_id,忽略新 cargo 是否允许降级:是(L1) 是否触发告警:否 用户看到的结果:返回原报价 ### 11.5 RPA 外部依赖容错 **异常:Mothership DOM 结构变化 / 报价入口不可用** 系统行为:preCheck 失败(404、login 且无凭据、表单 selector 不可见),抛 `STRUCT_CHANGE` 或 `QUOTE_ENTRY_UNAVAILABLE`,重试 0 次,走 L3 fallback 是否允许降级:是(L3) 是否触发告警:是(STRUCT_CHANGE) 用户看到的结果:展示 stale 报价或「暂时无法获取报价」 **异常:storageState 失效(Cookie 过期)** 系统行为:删除失效 storageState 文件,重新匿名访问;若仍 login 且无凭据 → STRUCT_CHANGE;若有凭据则重登 1 次 是否允许降级:是(L3) 是否触发告警:是 用户看到的结果:按 fallback 处理 **异常:地址联想点选失败** 系统行为:抛 `ADDRESS_SUGGESTION_NOT_FOUND`,不重试,Worker 路径走 L3;宿主 API 路径返回 400 是否允许降级:是(L3,Worker);否(400,宿主直调) 是否触发告警:是 用户看到的结果:提示修正地址或 stale 报价 **异常:验证码 / Cloudflare 拦截** 系统行为:抛 RPA_CAPTCHA,暂停该 IP+指纹 Worker 10 分钟,写告警,走 L3 是否允许降级:是(L3) 是否触发告警:是(RPA_CAPTCHA) 用户看到的结果:展示 stale 报价或「暂时无法获取报价」 **异常:登录失效 / session 过期(已登录账号场景)** 系统行为:若页面为 login 且**已配置** `MOTHERSHIP_EMAIL/PASSWORD`,Worker 自动重新登录 1 次,失败后抛 SESSION_EXPIRED,走 L3;若 login 且**无凭据** → STRUCT_CHANGE(**禁止**误报 SESSION_EXPIRED) 是否允许降级:是(L3) 是否触发告警:是 用户看到的结果:按 fallback 处理 **异常:RPA 返回不完整数据(standard 存在,guaranteed 缺失)** 系统行为:一致性校验失败,抛 RPA_DATA_INVALID,走 L3 是否允许降级:是(L3) 是否触发告警:是 用户看到的结果:展示 stale 报价 **异常:页面加载超时(>15s)** 系统行为:超时抛 PAGE_LOAD_TIMEOUT,走 L3 是否允许降级:是(L3) 是否触发告警:是 用户看到的结果:按 fallback 处理 **异常:RPA 抓取成功但数据错误(价格 ≤0 或负数)** 系统行为:一致性校验失败,抛 RPA_DATA_INVALID,走 L3,不落库错误价 是否允许降级:是(L3) 是否触发告警:是 用户看到的结果:展示 stale 报价 **异常:RPA 连续失败 ≥3 次** 系统行为:熔断 10 分钟,期间全部走 L3 或 failed 是否允许降级:是(L3) 是否触发告警:是 用户看到的结果:按 fallback 处理或「暂时无法获取报价」 ### 11.6 缓存一致性与防击穿设计 **异常:cache 击穿(同一 cargo_hash 同时 100+ miss)** 系统行为:L2 写操作加 Redis SETNX + 5s expire 锁,只允许 1 个 RPA,其余等待 2s 后读 L2 是否允许降级:是(L3) 是否触发告警:否 用户看到的结果:最终命中 L2 或 L3 **异常:cache 雪崩(3min TTL 同时到期)** 系统行为:L2 TTL 写入时加 0–30s 随机 jitter 是否允许降级:是(L3) 是否触发告警:否 用户看到的结果:平滑过渡 **异常:cache 不一致(L2 旧报价 vs 新 RPA 结果)** 系统行为:L2 3min 固定 TTL,不被新 RPA 覆盖;新 RPA 写新 L2 是否允许降级:是(L2) 是否触发告警:否 用户看到的结果:3min 内看到旧报价,之后看到新报价 **异常:stale 数据被错误展示为实时** 系统行为:所有 L3 命中必须返回 `is_realtime=false` + 角标「非实时报价,仅供参考」 是否允许降级:是(L3) 是否触发告警:是(STALE_FALLBACK) 用户看到的结果:带角标展示 **异常:request_id 与 cargo_hash 冲突** 系统行为:L1 优先级最高,request_id 命中直接返回,不再查 L2 是否允许降级:是(L1) 是否触发告警:否 用户看到的结果:返回原 quote_id ### 11.7 并发一致性与幂等设计 **异常:同一用户并发 10 次不同 request_id 询价** 系统行为:API 网关令牌桶限流 60 次/分钟,超限 429 RATE_LIMITED 是否允许降级:否 是否触发告警:是(限流) 用户看到的结果:「请求过于频繁,请稍后重试」 **异常:多用户同时请求同一货物** 系统行为:L2 共享,各自按自己 customer_id 加价重算 final_total,无覆盖写 是否允许降级:是(L2) 是否触发告警:否 用户看到的结果:各自看到自己加价后的价格 **异常:价格竞争导致覆盖写** 系统行为:quote_record 更新按 quote_id 主键,RPA 成功后先 UPDATE 再写缓存 是否允许降级:是(L2) 是否触发告警:否 用户看到的结果:最终以 DB 为准 **异常:幂等失效导致重复 RPA** 系统行为:Redis L1 + DB 唯一键双保险,冲突即返回已有 quote_id 是否允许降级:是(L1) 是否触发告警:否 用户看到的结果:不重复执行 RPA ### 11.8 数据计算安全 **异常:浮点精度误差(加价计算)** 系统行为:全程使用 DECIMAL(12,2),加价公式 `ROUND_HALF_UP(raw_freight × markup / 100, 2)` 是否允许降级:否 是否触发告警:否 用户看到的结果:金额精确到分,无误差 **异常:surcharge 缺失** 系统行为:按 0 处理,`raw_total = raw_freight` 是否允许降级:否 是否触发告警:否 用户看到的结果:正常展示 **异常:rounding 规则不一致** 系统行为:全局唯一 ROUND_HALF_UP,DB/缓存/响应一致 是否允许降级:否 是否触发告警:否 用户看到的结果:各端金额一致 ### 11.9 权限与安全控制 **异常:越权访问其他客户 quote_id** 系统行为:JWT customer_id 校验失败,403 FORBIDDEN,记录 audit log 是否允许降级:否 是否触发告警:是(安全) 用户看到的结果:403 FORBIDDEN **异常:伪造 customer_id(JWT 无效)** 系统行为:JWT 签名校验失败,401 UNAUTHORIZED 是否允许降级:否 是否触发告警:是(安全) 用户看到的结果:401 UNAUTHORIZED **异常:无权限修改加价规则** 系统行为:RBAC 校验失败,403 FORBIDDEN 是否允许降级:否 是否触发告警:是(安全) 用户看到的结果:403 FORBIDDEN **异常:API 被批量刷(>60 次/分钟/客户 或 >1000 次/分钟/IP)** 系统行为:网关限流 + IP 封禁 10 分钟,写安全告警 是否允许降级:否 是否触发告警:是(安全) 用户看到的结果:429 RATE_LIMITED 或封禁提示 ### 11.10 状态机与生命周期保护 **异常:询价已过期(valid_until 过去)仍被展示** 系统行为:客户端 GET 时检查 valid_until,过期返回 `status=expired`,不展示价格 是否允许降级:否 是否触发告警:否 用户看到的结果:「报价已过期,是否重新询价?」 **异常:cache 未过期但 RPA 更新更高优先级** 系统行为:L2 3min 固定 TTL,新 RPA 只写新 L2,不覆盖旧 L2 是否允许降级:是(L2) 是否触发告警:否 用户看到的结果:3min 内看到旧报价 **异常:fallback 状态未标识** 系统行为:所有 L3 命中强制返回 `is_realtime=false` + 角标 是否允许降级:是(L3) 是否触发告警:是(STALE_FALLBACK) 用户看到的结果:带角标展示 **异常:quote_id 重复使用(生成冲突)** 系统行为:DB quote_id 唯一键冲突,拒绝写入,抛 INTERNAL_ERROR 是否允许降级:否 是否触发告警:是 用户看到的结果:服务异常提示 **异常:processing 状态超过 30s 未更新** 系统行为:定时任务标记为 `failed` + QUOTE_TIMEOUT 是否允许降级:是(L3) 是否触发告警:是 用户看到的结果:轮询后得到 failed 状态 --- ## 第 12 章 验收标准(Acceptance Criteria) 本章所有 TC 可直接转换为自动化脚本,PASS 条件均为可量化判断。与第 11 章异常场景一一对应。 ### 12.1 功能验收 #### TC-101 正常询价流程(首次提交) - 类型:Functional - 前置条件:宿主 Service Token 有效,customer_id=CUST_001 未配置加价;地址含有效 place_id - 输入数据:request_id=uuid-v4,pickup/delivery 含 place_id,单托 weight=500lb,dims=48×40×48in,pallet_count=2,cargo_type=general_freight,service_level=standard,rate_option=lowest - 操作步骤:POST /quotes → 轮询 GET /quotes/{quote_id} 直至 done - 预期结果: - HTTP状态码:200 - 响应字段:status=done,source_type=rpa,is_realtime=true,quotes 数组长度=**4**,final_total=raw_total(markup=0) - 数据库变化:quote_record 新增 1 条,status=done,source_type=rpa;idempotency_record 新增 1 条 - 是否触发告警:否 - 是否命中cache:否 - PASS标准:响应 200,status=done,quotes 含 standard/guaranteed × lowest/fastest 共 4 档,DB 写入 1 条 quote_record + 1 条 idempotency_record,耗时 <30s #### TC-102 四档报价返回正确性 - 类型:Functional - 前置条件:RPA 已成功抓取 - 输入数据:同 TC-101 - 操作步骤:GET /quotes/{quote_id} - 预期结果: - HTTP状态码:200 - 响应字段:quotes 含 4 条;每条含 service_level + rate_option;standard+lowest 的 transit_description 含「最低价格」;guaranteed+fastest 含「最快递送」或「保障」 - 数据库变化:quotes_json 存储四档数据 - 是否触发告警:否 - 是否命中cache:首次不命中 - PASS标准:quotes 数组恰好 4 条,service_level × rate_option 组合齐全 #### TC-103 加价计算正确性(含 surcharge) - 类型:Functional - 前置条件:markup_config 中 CUST_002 配置 10.0% - 输入数据:raw_freight=320.00,surcharges=15.00,markup=10.0% - 操作步骤:RPA 返回后 PricingEngine 计算 - 预期结果: - HTTP状态码:200 - 响应字段:markup_amount=32.00,final_total=367.00(ROUND_HALF_UP) - 数据库变化:quote_record.markup_percent=10.0,quotes_json 中 breakdown 含 3 项 - 是否触发告警:否 - 是否命中cache:否 - PASS标准:markup_amount=32.00,final_total=367.00,金额精确到分,无浮点误差 #### TC-104 L2 cache 命中逻辑 - 类型:Functional - 前置条件:同一 cargo_hash 已存在 L2(3min 内) - 输入数据:相同货物,不同 request_id - 操作步骤:POST /quotes(新 request_id) - 预期结果: - HTTP状态码:200 - 响应字段:status=done,source_type=cache,is_realtime=true - 数据库变化:quote_record 新增 1 条(source_type=cache),idempotency_record 新增 - 是否触发告警:否 - 是否命中cache:是(L2) - PASS标准:source_type=cache,耗时 <300ms,无新 RPA 调用 #### TC-105 request_id 幂等(24h 内重复提交) - 类型:Functional - 前置条件:request_id 已使用过 - 输入数据:相同 request_id,相同或不同货物 - 操作步骤:POST /quotes - 预期结果: - HTTP状态码:200 - 响应字段:返回首次 quote_id,status=done - 数据库变化:quote_record 不新增,idempotency_record 不新增 - 是否触发告警:否 - 是否命中cache:是(L1) - PASS标准:返回原 quote_id,DB 无新写入,24h 内任意次数均返回同一 quote_id #### TC-106 单位换算(kg/cm) - 类型:Functional - 前置条件:无 - 输入数据:weight=227kg,dims=122×102×122cm - 操作步骤:POST /quotes → 轮询至 done - 预期结果: - HTTP状态码:200 - 响应字段:内部 weight_lb≈500.66,dims 换算为 in 后正常询价 - 数据库变化:quote_record 记录换算后 in/lb 值 - 是否触发告警:否 - 是否命中cache:否 - PASS标准:200 done,DB weight_lb 与 dims 为换算后 in/lb 值 #### TC-107 不同 service_level 同货物共享缓存 - 类型:Functional - 前置条件:L2 已存在(四档) - 输入数据:相同货物,service_level 分别为 standard / guaranteed 两次提交 - 操作步骤:两次 POST /quotes(不同 request_id) - 预期结果: - HTTP状态码:200 - 响应字段:两次均返回四档 quotes,第二次 source_type=cache - 数据库变化:第二次无新 RPA - 是否触发告警:否 - 是否命中cache:第二次命中 L2 - PASS标准:第二次 source_type=cache,quotes 仍含四档 #### TC-108 L2 过期后触发新 RPA - 类型:Functional - 前置条件:同 cargo_hash L2 已过期(>3min) - 输入数据:相同货物 - 操作步骤:POST /quotes - 预期结果: - HTTP状态码:200 - 响应字段:status=processing 后 done,source_type=rpa - 数据库变化:新 quote_record,触发 RPA - 是否触发告警:否 - 是否命中cache:否 - PASS标准:source_type=rpa,RPA 被调用 1 次 ### 12.2 异常场景验收(对齐第 11 章) #### TC-201 输入必填缺失 - 类型:Exception - 前置条件:无 - 输入数据:weight 字段缺失 - 操作步骤:POST /quotes - 预期结果: - HTTP状态码:400 - 响应字段:error_code=VALIDATION_FAILED - 数据库变化:无 - 是否触发告警:否 - 是否命中cache:否 - PASS标准:400 VALIDATION_FAILED,不生成 quote_id #### TC-202 数值超范围(weight=0) - 类型:Exception - 前置条件:无 - 输入数据:weight.value=0 - 操作步骤:POST /quotes - 预期结果: - HTTP状态码:400 - 响应字段:error_code=VALIDATION_FAILED - 数据库变化:无 - 是否触发告警:否 - 是否命中cache:否 - PASS标准:400,提示超范围 #### TC-203 地址邮编非法 - 类型:Exception - 前置条件:无 - 输入数据:zip="9001"(4 位) - 操作步骤:POST /quotes - 预期结果: - HTTP状态码:400 - 响应字段:error_code=VALIDATION_FAILED - 数据库变化:无 - 是否触发告警:否 - 是否命中cache:否 - PASS标准:400,地址无效 #### TC-204 request_id 非 UUID - 类型:Exception - 前置条件:无 - 输入数据:request_id="not-uuid" - 操作步骤:POST /quotes - 预期结果: - HTTP状态码:400 - 响应字段:error_code=VALIDATION_FAILED - 数据库变化:无 - 是否触发告警:否 - 是否命中cache:否 - PASS标准:400 VALIDATION_FAILED #### TC-205 RPA 失败 + L3 stale 命中 - 类型:Exception - 前置条件:cargo_hash 存在 L3(≤30min) - 输入数据:正常货物 - 操作步骤:RPA 触发失败(模拟验证码) - 预期结果: - HTTP状态码:200 - 响应字段:status=done,source_type=stale,is_realtime=false,角标「非实时报价,仅供参考」 - 数据库变化:quote_record 新增,source_type=stale - 是否触发告警:是(STALE_FALLBACK) - 是否命中cache:是(L3) - PASS标准:200,source_type=stale,is_realtime=false,alert_log 新增 1 条 STALE_FALLBACK #### TC-206 RPA 失败 + 无 L3 - 类型:Exception - 前置条件:cargo_hash 无 L3 - 输入数据:正常货物 - 操作步骤:RPA 触发失败 - 预期结果: - HTTP状态码:200 - 响应字段:status=failed,error_code=QUOTE_UNAVAILABLE - 数据库变化:quote_record 新增,status=failed - 是否触发告警:是(RPA_FAILED) - 是否命中cache:否 - PASS标准:200,status=failed,error_code=QUOTE_UNAVAILABLE,alert_log 新增 RPA_FAILED #### TC-207 并发重复提交(10 次相同 request_id) - 类型:Exception - 前置条件:无 - 输入数据:相同 request_id,10 并发 - 操作步骤:10 线程同时 POST /quotes - 预期结果: - HTTP状态码:200 - 响应字段:全部返回同一 quote_id - 数据库变化:quote_record 仅 1 条,idempotency_record 仅 1 条 - 是否触发告警:否 - 是否命中cache:是(L1) - PASS标准:10 次请求返回同一 quote_id,DB 仅写入 1 条 #### TC-208 网络超时(>30s) - 类型:Exception - 前置条件:无 - 输入数据:正常 - 操作步骤:RPA 模拟 >30s 未完成 - 预期结果: - HTTP状态码:200 - 响应字段:status=failed,error_code=QUOTE_TIMEOUT - 数据库变化:quote_record status=failed - 是否触发告警:是 - 是否命中cache:否 - PASS标准:200,status=failed,error_code=QUOTE_TIMEOUT #### TC-209 RPA 连续失败熔断 - 类型:Exception - 前置条件:无 - 输入数据:连续 3 次 RPA 失败 - 操作步骤:触发 3 次失败 - 预期结果: - HTTP状态码:200 - 响应字段:第 4 次起 10 分钟内走 L3 或 failed - 数据库变化:alert_log 记录熔断 - 是否触发告警:是 - 是否命中cache:按 L3 - PASS标准:连续失败 ≥3 次后 10 分钟内无新 RPA 调用 #### TC-210 偏差预警(≥5%) - 类型:Exception - 前置条件:quote_cache_meta 存在上次报价 raw_total_standard=352 - 输入数据:本次 RPA raw_total_standard=370(偏差 5.1%) - 操作步骤:RPA 成功 - 预期结果: - HTTP状态码:200 - 响应字段:status=done,正常返回报价 - 数据库变化:alert_log 新增 PRICE_DEVIATION - 是否触发告警:是 - 是否命中cache:否 - PASS标准:200 done,alert_log 新增 PRICE_DEVIATION #### TC-211 偏差未达阈值(4.9%) - 类型:Exception - 前置条件:上次 raw_total=352 - 输入数据:本次 raw_total=369.25(偏差 4.9%) - 操作步骤:RPA 成功 - 预期结果: - HTTP状态码:200 - 响应字段:status=done - 数据库变化:无 PRICE_DEVIATION 告警 - 是否触发告警:否 - 是否命中cache:否 - PASS标准:200 done,alert_log 无 PRICE_DEVIATION #### TC-212 首次询价不触发偏差预警 - 类型:Exception - 前置条件:cargo_hash 无历史基准 - 输入数据:正常货物 - 操作步骤:首次 RPA 成功 - 预期结果: - HTTP状态码:200 - 响应字段:status=done - 数据库变化:quote_cache_meta 写入基准 - 是否触发告警:否 - 是否命中cache:否 - PASS标准:200 done,无 PRICE_DEVIATION 告警 ### 12.3 性能验收 #### TC-301 RPA 路径 P95 响应时间 - 类型:Performance - 前置条件:RPA 正常 - 输入数据:标准测试货物 - 操作步骤:100 次连续 POST + 轮询 - 预期结果: - HTTP状态码:200 - 响应字段:status=done - 数据库变化:100 条 quote_record - 是否触发告警:否 - 是否命中cache:否 - PASS标准:P95 完成时间 <30s,P99 <45s #### TC-302 L2 cache 命中 P95 响应时间 - 类型:Performance - 前置条件:L2 已存在 - 输入数据:相同货物 - 操作步骤:100 次 POST - 预期结果: - HTTP状态码:200 - 响应字段:source_type=cache - 数据库变化:100 条 quote_record(cache 来源) - 是否触发告警:否 - 是否命中cache:是 - PASS标准:P95 响应 <300ms,P99 <500ms #### TC-303 并发 QPS 限流验证 - 类型:Performance - 前置条件:单客户账号 - 输入数据:不同 request_id - 操作步骤:60s 内发送 70 次请求 - 预期结果: - HTTP状态码:前 60 次 200,后 10 次 429 - 响应字段:429 响应 error_code=RATE_LIMITED - 数据库变化:60 条 quote_record - 是否触发告警:是(限流) - 是否命中cache:部分命中 - PASS标准:令牌桶 60 次/分钟生效,>60 次/分钟返回 429 #### TC-304 RPA 最大等待时间边界 - 类型:Performance - 前置条件:无 - 输入数据:正常 - 操作步骤:RPA 模拟 28s 完成 vs 31s 未完成 - 预期结果: - HTTP状态码:200 - 响应字段:28s→done;31s→failed QUOTE_TIMEOUT - 数据库变化:对应 status - 是否触发告警:31s 时触发 - 是否命中cache:否 - PASS标准:28s 内返回 done,>30s 返回 QUOTE_TIMEOUT ### 12.4 数据一致性验收 #### TC-401 cache vs RPA 一致性 - 类型:Consistency - 前置条件:L2 存在 - 输入数据:相同货物 - 操作步骤:L2 命中 vs 新 RPA 结果比对 - 预期结果: - HTTP状态码:200 - 响应字段:3min 内 L2 raw_total 与生成时 RPA 一致 - 数据库变化:quote_cache_meta 记录最新 RPA - 是否触发告警:偏差 ≥5% 触发 - 是否命中cache:L2 命中 - PASS标准:L2 内容与写入时 RPA 一致;新 RPA 偏差 ≥5% 触发 PRICE_DEVIATION #### TC-402 quote_id 唯一性 - 类型:Consistency - 前置条件:无 - 输入数据:100 次不同 request_id - 操作步骤:并发提交 - 预期结果: - HTTP状态码:200 - 响应字段:100 个不同 quote_id - 数据库变化:100 条 quote_record,quote_id 唯一 - 是否触发告警:否 - 是否命中cache:否 - PASS标准:DB quote_id 唯一键无冲突 #### TC-403 加价计算可复算 - 类型:Consistency - 前置条件:DB 已存储 raw_freight 与 markup_percent - 输入数据:quote_record 已存在 - 操作步骤:从 DB 读取并重算 final_total - 预期结果: - HTTP状态码:200 - 响应字段:重算值与 quotes_json.final_total 一致 - 数据库变化:无 - 是否触发告警:否 - 是否命中cache:否 - PASS标准:重算值 == 存储值,误差 ≤0.01 #### TC-404 request_id 幂等一致性(跨重启) - 类型:Consistency - 前置条件:Redis L1 已过期,DB idempotency_record 仍存在 - 输入数据:相同 request_id - 操作步骤:服务重启后再次 POST - 预期结果: - HTTP状态码:200 - 响应字段:返回原 quote_id - 数据库变化:无新写入 - 是否触发告警:否 - 是否命中cache:是(DB 兜底) - PASS标准:重启后 24h 内仍返回原 quote_id ### 12.5 安全与权限验收 #### TC-501 越权访问(customer_id 伪造) - 类型:Security - 前置条件:客户 A JWT - 输入数据:quote_id 属于客户 B - 操作步骤:GET /quotes/{B的quote_id}(带 A 的 JWT) - 预期结果: - HTTP状态码:403 - 响应字段:error_code=FORBIDDEN - 数据库变化:无 - 是否触发告警:是(安全类) - 是否命中cache:否 - PASS标准:403 FORBIDDEN,audit log 记录越权尝试 #### TC-502 运营权限控制(加价配置) - 类型:Security - 前置条件:客户角色 JWT(无 pricing:markup:write) - 输入数据:PUT /markup-configs/CUST_001 - 操作步骤:客户角色调用加价接口 - 预期结果: - HTTP状态码:403 - 响应字段:error_code=FORBIDDEN - 数据库变化:markup_config 不更新 - 是否触发告警:是(安全) - 是否命中cache:否 - PASS标准:403,markup_config 不变 #### TC-503 批量刷接口限流 - 类型:Security - 前置条件:无 - 输入数据:单 IP 120 次/分钟 - 操作步骤:高频 POST /quotes - 预期结果: - HTTP状态码:429 - 响应字段:error_code=RATE_LIMITED - 数据库变化:仅 60 条 - 是否触发告警:是(安全) - 是否命中cache:部分 - PASS标准:>60 次/分钟返回 429 #### TC-504 API 非法请求拦截 - 类型:Security - 前置条件:无 - 输入数据:payload 含 SQL 注入 - 操作步骤:POST /quotes(恶意 payload) - 预期结果: - HTTP状态码:400 - 响应字段:error_code=VALIDATION_FAILED - 数据库变化:无 - 是否触发告警:是(安全) - 是否命中cache:否 - PASS标准:400,不执行 RPA,不写入 DB #### TC-505 加价上限边界 - 类型:Security - 前置条件:运营角色 JWT - 输入数据:markup_percent=30.1 - 操作步骤:PUT /markup-configs/CUST_001 - 预期结果: - HTTP状态码:400 - 响应字段:拒绝保存 - 数据库变化:无 - 是否触发告警:否 - 是否命中cache:否 - PASS标准:400,markup_config 不变 #### TC-506 历史接口强制分页 - 类型:Security - 前置条件:客户 JWT - 输入数据:GET /quotes/history 不传 page/size - 操作步骤:调用历史接口 - 预期结果: - HTTP状态码:400 - 响应字段:VALIDATION_FAILED - 数据库变化:无 - 是否触发告警:否 - 是否命中cache:否 - PASS标准:400,强制分页参数 ### 12.6 RPA 与外部系统验收 #### TC-601 RPA 成功返回结构完整性 - 类型:Functional - 前置条件:`MOTHERSHIP_QUOTE_URLS` 已 headed 录制验证;`RPA_MOCK_MODE=false` - 输入数据:标准货物(LA 90001 → Dallas 75201,2 托,500lb/托,48×40×48in) - 操作步骤:`npx tsx scripts/probe-rpa.ts` 连续执行 3 次 - 预期结果: - exit code:0(3 次均成功) - 响应字段:`items.length === 4`(standard/guaranteed × lowest/fastest),每条 `raw_freight>0`,`transit_description` 非空 - 数据库变化(API 路径):quote_record `source_type=rpa` - 是否触发告警:否 - 是否命中cache:否 - PASS标准:3 次 probe 均 exit 0;四档齐全且 raw_freight>0 #### TC-602 RPA 失败 fallback 触发 - 类型:Exception - 前置条件:L3 存在 - 输入数据:正常 - 操作步骤:模拟验证码 - 预期结果: - HTTP状态码:200 - 响应字段:source_type=stale,is_realtime=false - 数据库变化:alert_log 新增 STALE_FALLBACK - 是否触发告警:是 - 是否命中cache:是(L3) - PASS标准:source_type=stale,alert_log 写入,is_realtime=false #### TC-603 验证码/封禁场景 - 类型:Exception - 前置条件:Worker 可用 - 输入数据:正常 - 操作步骤:RPA 触发验证码 - 预期结果: - HTTP状态码:200 - 响应字段:source_type=stale 或 failed - 数据库变化:alert_log 新增 RPA_CAPTCHA,Worker 暂停 10 分钟 - 是否触发告警:是(RPA_CAPTCHA) - 是否命中cache:是(L3) - PASS标准:alert_log RPA_CAPTCHA,Worker 10 分钟内不分配新 job #### TC-604 页面结构变化容错 - 类型:Exception - 前置条件:Mothership DOM 变更 - 输入数据:正常 - 操作步骤:RPA preCheck 失败 - 预期结果: - HTTP状态码:200 - 响应字段:source_type=stale 或 failed - 数据库变化:alert_log 新增 STRUCT_CHANGE - 是否触发告警:是 - 是否命中cache:是(L3) - PASS标准:alert_log STRUCT_CHANGE,Worker 停止,走 L3 #### TC-605 RPA 超时降级 - 类型:Exception - 前置条件:无 - 输入数据:正常 - 操作步骤:RPA 执行 >30s - 预期结果: - HTTP状态码:200 - 响应字段:status=failed,error_code=QUOTE_TIMEOUT - 数据库变化:quote_record status=failed - 是否触发告警:是 - 是否命中cache:否 - PASS标准:status=failed,error_code=QUOTE_TIMEOUT,alert_log 写入 ### 12.7 Go/No-Go 上线门禁 | 指标 | 目标值 | 测量方法 | Go 条件 | |------|--------|----------|---------| | API success rate(含 cache) | ≥99.5% | 1000 次请求统计 | 1000 次中 ≥995 次 200 done | | RPA success rate | ≥85% | 100 次真实 RPA 统计 | 100 次中 ≥85 次 source=rpa | | **RPA probe 门禁** | **3/3 成功** | `RPA_MOCK_MODE=false npx tsx scripts/probe-rpa.ts` 连续 3 次 | **3 次均 exit 0**(TC-601) | | realtime rate | ≥90% | 1000 次 done 统计 | 1000 次中 ≥900 次 is_realtime=true | | cache 命中率(L1+L2) | ≥90% | 1000 次请求统计 | 1000 次中 ≥900 次命中 L1 或 L2 | | RPA vs 人工偏差 | ≤5% | 人工抽检 ≥30 单 | 30 单中偏差 ≤5% 通过率 ≥95% | | P0 级别测试失败 | 0 | 全用例执行 §12.1–12.6 | 无 P0 失败 | | 性能 P95 | <30s | Load Test 100 次(TC-301) | P95 <30s | | 无高危安全漏洞 | 0 | 渗透测试 TC-501~506 | 无高危漏洞 | ### 12.8 测试分层策略 | 层级 | 范围 | 工具/框架 | 覆盖率目标 | 执行频率 | |------|------|-----------|------------|----------| | Unit Test | PricingEngine、CacheModule、ValidationModule | Jest / Vitest | 80% 行覆盖 | 每次提交 | | Integration Test | API + DB + Redis + BullMQ | Supertest + Testcontainers | 全部 API 契约 | 每次构建 | | E2E Test | 客户端轮询 + RPA 模拟 | Playwright | 核心 5 条用户路径 | 每日 | | Load Test | 60 QPS + 100 并发 | k6 / JMeter | P95 <30s,成功率 ≥99% | 每次发布前 | ### 12.9 测试数据设计 | 数据类型 | 模板/值 | 用途 | |----------|---------|------| | 标准测试货物 | LA(90001)→Dallas(75201),500lb,48×40×48in,2件,general_freight | 功能/性能基线 | | 极端托盘数 | 25 / 1 pallet | 边界校验 | | 极端尺寸 | 999×99×99 in(上限) | 边界校验 | | 超规尺寸 | 1000×100×100 in | 边界校验 400 | | 无效地址 | zip=9001,state=XX | 异常输入 TC-203 | | 高并发账号 | 100 个独立 customer_id | Load Test / 并发 | | 缓存命中货物 | 固定 3min 内重复使用 | Cache 命中率 TC-104 | | RPA 失败模拟 | 故意触发验证码 / DOM 变更 | 降级 TC-602~604 | | 加价配置 | CUST_001=0%,CUST_002=10.0%,CUST_003=30.0% | 加价计算 TC-103/505 | ### 12.10 用例与章节映射 | PRD 章节 | 对应用例 | |----------|----------| | §4 询价输入 | TC-101~108,TC-201~204 | | §6 加价规则 | TC-103,TC-505 | | §7 生命周期/缓存 | TC-104~105,TC-107~108,TC-401~404 | | §11 异常场景 | TC-205~212,TC-501~506,TC-602~605 | | §10 性能/安全 | TC-301~304,TC-503 | --- ## 第 13 章 3 周交付计划 ### Week 1 — 基础链路与数据层 | 任务 | 产出 | 验证 | |------|------|------| | DB migration(5 张表) | 表结构 | 空库双跑通过 | | API 骨架 + 鉴权 + 统一响应 | POST/GET/quotes | TC-101,TC-204 | | 输入校验 + 单位换算 | ValidationModule | TC-201~203,TC-106 | | Redis 三层缓存读写 | CacheModule | TC-104~105,TC-107 | | 幂等(L1 + 表) | IdempotencyModule | TC-105,TC-207,TC-404 | ### Week 2 — RPA 与加价 | 任务 | 产出 | 验证 | |------|------|------| | MothershipRPAProvider(四档) | RPA Worker | TC-601 | | BullMQ 队列 + Worker Pool | 异步链路 | TC-601,TC-605 | | Fallback(L3 stale)+ 熔断 | 降级逻辑 | TC-205~206,TC-209 | | 验证码处理 + 暂停 Worker | 异常处理 | TC-603 | | PricingEngine 加价 | 加价计算 | TC-103,TC-505 | | 加价配置 API + 管理端页 | 配置功能 | TC-502,TC-505 | ### Week 3 — 预警、前端、验收 | 任务 | 产出 | 验证 | |------|------|------| | 偏差预警 + alert_log | AlertModule | TC-210~212 | | 预警中心管理端页 | 预警列表/处理 | 冒烟 | | 宿主内嵌查价(轮询/倒计时/四档/非实时角标) | 前端 | TC-601,TC-602 UI | | 历史记录页(分页) | 前端 | TC-506 | | 限流 + 越权防护 | 安全 | TC-501,TC-503~506 | | 人工抽检一致性 ≥30 单 | 报告 | §12.7 | | 指标采集(三成功率/命中率) | 监控看板 | §10.2,§12.7 | | 全量 TC 回归 + Go/No-Go | 上线门禁 | §12.7 | ### 里程碑 | 时点 | 交付物 | |------|--------| | W1 末 | 缓存+幂等可用,缓存命中路径端到端通 | | W2 末 | RPA 四档 + 加价 + 降级全链路通 | | W3 末 | §12 全用例通过 + §12.7 指标达标 + 可上线 | --- ## 第 14 章 Flock Freight 报价源(GD-16,Phase 扩展) > 本章为 **第二报价源** 产品口径。MVP(Mothership)不实现;实现前须按技术设计 §7.6 录制 + probe 门禁。 > 实测入口:[`https://app.flockfreight.com/get-a-quote`](https://app.flockfreight.com/get-a-quote)(2026-07-13)。 ### 14.1 目标与边界 | 项 | 规则 | |----|------| | 产品定位 | Flock Freight 共享整车(FlockDirect)/ 标准 LTL 伙伴线路的公开查价 | | 接入方式 | 仅 Playwright RPA;**不使用** Flock 官方 API | | 与 Mothership 关系 | 独立 Provider、独立硬限、独立 session;同一编排层经 `QuoteProvider` 扩展 | | 本期实现 | ✅ 已落地(`FLOCK_RPA_ENABLED` / Mock 联调;真实 RPA 须 probe 3/3) | | 下单 / 支付 | ❌ 不在本期;结果页「Complete your order」仅作 RPA 停止边界 | ### 14.2 官网流程实测摘要 ``` 1. 打开 get-a-quote(可匿名填表,无需先登录) 2. 填写:取货日期、取件/投递 ZIP、托盘数、总重量(lb)、单托尺寸(in) 3. 「下一个」→ 结果页展示两档:FlockDirect® / Standard 4. 「Complete your order」→ 引导「Create your free account」注册后继续 ``` | 观察点 | 结论 | |--------|------| | 报价表单 | 字段少:日期 + ZIP×2 + 托盘数 + 总重 + L/W/H;无街道联想 | | 结果页 | 两档对比卡;含 Reference #、Quotes generated | | 免费注册 | First/Last name、Company、Phone、Work email、月均干货车次下拉;**实测随机邮箱/电话/名称可注册成功,站点无明显实名/邮箱域名校验** | | 注册下拉选项 | `I haul or dispatch freight` / `1-25` / `26-100` / `101-500` / `501-1000` / `1000+` / `I'm a logistics provider` | ### 14.3 货物硬限(官网提示,独立于 GD-14) | 字段 | 规则(英制) | 宿主提示文案(中文) | |------|--------------|----------------------| | 托盘数量 `pallet_count` | 整数 **4–20** | 托盘数量只能输入 4 到 20 | | 总运输重量 `total_weight_lb` | **≤ 45,000** lb(整票总重,非单托) | 总重量不能超过 45000 lb | | 单托长度 | **≤ 636** in(默认示例 48) | 托盘长度不能超过 636 in | | 单托宽度 | **≤ 102** in(默认示例 40) | 托盘宽度不能超过 102 in | | 单托高度 | **≤ 108** in(默认示例 48) | 托盘高度不能超过 108 in | | 取件/投递 ZIP | 必填;美国 5 位 ZIP | 需要提供取件/投递邮政编码 | | 取货日期 | 必填;不得早于当日策略以录制为准 | 请选择取货日期 | 超限:`400 VALIDATION_FAILED`,message 用上表中文提示;**禁止**套用 Mothership §4.2.8 上限。 **计量差异(相对 Mothership)**: | 维度 | Mothership | Flock Freight | |------|------------|---------------| | 重量 | 单托 `weight_lb`,总重 = 单托 × 托盘数 | 表单直接填**整票总重**(lb) | | 地址 | 街道 + 联想点选 `place_id` | **仅 ZIP**(取件/投递) | | 结果档位 | 4 档(standard/guaranteed × lowest/fastest) | **2 档**:FlockDirect®、Standard | ### 14.4 结果档位映射 | 官网卡片 | 映射 `service_level` | `rate_option` | 说明 | |----------|----------------------|---------------|------| | FlockDirect® | `guaranteed`(语义:premium / hubless) | `fastest` | 价格从 $X;含周末时效文案;终端免中转卖点 | | Standard | `standard` | `lowest` | 价格从 $X;工作日时效;走 LTL 伙伴 | 一次 RPA 须抓齐 **2 档**(价格 >0、时效文案非空)。不足 2 档 → `STRUCT_CHANGE` 或 `CARRIER_NO_CAPACITY`(按页面文案分类)。 宿主展示:两张对比卡即可,**不要求**补齐 Mothership 四档空位。 ### 14.5 账号与 Session(RPA) | 项 | 规则 | |----|------| | 匿名查价 | get-a-quote → 结果页:**优先走匿名路径**(与实测一致) | | 注册页 | 仅当流程强制登录/下单时触发;可用预置测试账号或按需自动注册 | | 注册字段强度 | 站点侧弱校验;中台仍须自有测试账号池,**禁止**把随机生成账号写入生产密钥库明文日志 | | storageState | 独立路径,如 `.rpa/flock-storage.json`;与 Mothership 文件隔离 | ### 14.6 功能点与异常 | ID | 描述 | |----|------| | F14.1 | 按 §14.3 校验 ZIP、托盘、总重、尺寸 | | F14.2 | RPA 填写 get-a-quote 并抓取两档价格/时效/Reference # | | F14.3 | 结果经 PricingEngine 加价后返回宿主(`carrier=Flock Freight`) | | F14.4 | Selector / URL 改版 → STRUCT_CHANGE;独立 probe | | 异常 | 处理 | |------|------| | ZIP 缺失/非法 | 400,中文提示 | | 托盘/重量/尺寸超 §14.3 | 400,中文提示 | | 入口不可用 / 表单 DOM 变更 | `QUOTE_ENTRY_UNAVAILABLE` / `STRUCT_CHANGE` | | 仅返回 1 档或价格为 0 | 失败,不写假档 | ### 14.7 验收门禁(实现阶段) | 项 | 标准 | |----|------| | Probe | `scripts/probe-flock-rpa.ts` 标准样例(如 CA ZIP→TX ZIP)连续 **3 次 exit 0** | | 档位 | items 含 FlockDirect + Standard 映射各 1 条 | | 校验 | 托盘 3 / 21、总重 45001、L/W/H 超限均 400 | | 隔离 | 关闭 Flock 开关时不影响 Mothership 主路径 | --- ## 附录 A:QuoteProvider 接口契约 ```typescript interface Address { street: string; city: string; state: string; zip: string; placeId: string; formattedAddress: string; selectedFromSuggestions: boolean; } interface QuoteRequest { cargoHash: string; pickup: Address; delivery: Address; weightLb: number; dimsIn: { l: number; w: number; h: number }; palletCount: number; cargoType: string; } interface QuoteItem { serviceLevel: 'standard' | 'guaranteed'; rateOption: 'lowest' | 'fastest'; carrier: string; transitDays: string; transitDescription: string; rawFreight: number; surcharges: number; rawTotal: number; } interface QuoteResult { items: QuoteItem[]; // 必含 4 档(standard/guaranteed × lowest/fastest) sourceType: 'rpa'; confidenceScore: number; } interface QuoteProvider { getQuote(req: QuoteRequest): Promise; // 一次 RPA 返回四档 healthCheck(): Promise; } ``` ## 附录 B:页面与状态清单 | 页面 | 状态 | 关键组件 | |------|------|----------| | 宿主内嵌查价 | idle / submitting / processing / done / stale / failed / expired | 地址联想点选、kg/cm 录入、托盘数、pallet_count、四档报价卡、倒计时 | | 管理端预警 | list / detail | 类型筛选、偏差%、quote_id、处理按钮 | | 管理端 RPA/开发进度 | dashboard | Worker 状态、成功率、队列深度 | ## 附录 C:状态/枚举字典 | 枚举 | 取值 | |------|------| | quote.status | processing / done / failed / expired | | quote.source_type | rpa / cache / stale | | service_level | standard / guaranteed | | rate_option | lowest / fastest | | alert_type | PRICE_DEVIATION / STALE_FALLBACK / RPA_FAILED / RPA_CAPTCHA / STRUCT_CHANGE / SESSION_EXPIRED / SECURITY | | cargo_type | 见 §4.2.5(9 项) | | currency | USD(本期唯一) | ## 附录 D:v0.5 → v0.6 变更追踪 | ID | 章节 | Before (v0.5) | After (v0.6) | |----|------|---------------|--------------| | CHG-P01 | 版本 | v0.5 | v0.6 | | CHG-P02 | GD | 无入口 URL 规范 | 新增 **GD-15**:`MOTHERSHIP_QUOTE_URLS` headed 录制;禁止 dashboard / `/quote` | | CHG-P03 | §2.5 | 仅「超时/验证码/封禁」 | 区分**入口错误** vs **抓取错误**;地址未锁定单独分类 | | CHG-P04 | §5.6 | 无 `QUOTE_ENTRY_UNAVAILABLE` / `ADDRESS_SUGGESTION_NOT_FOUND` | 新增两错误码 | | CHG-P05 | §8.2 | 默认 `dashboard.mothership.com` 单 URL | `QuotePageAdapter` 多 URL 探测 + storageState + login 无凭据→STRUCT_CHANGE | | CHG-P06 | §8.2 | 无 Selector 规范 | 新增 §8.2.1~§8.2.3(env、禁止清单、codegen 维护) | | CHG-P07 | §11.5 | login 一律 SESSION_EXPIRED | login 无凭据→STRUCT_CHANGE;新增 storageState 失效、地址联想失败 | | CHG-P08 | TC-601 | quotes 2 条 | items 4 档 + probe-rpa 连续 3 次 exit 0 | | CHG-P09 | §12.7 | 无 probe 门禁 | 新增 RPA probe 3/3 Go 条件 | ## 附录 E:v0.6 → v0.7 变更追踪 | ID | 章节 | Before (v0.6) | After (v0.7) | |----|------|---------------|--------------| | CHG-P10 | 版本 | v0.6 | v0.7 | | CHG-P11 | GD | GD-1~15 | 新增 **GD-16** Flock Freight 第二报价源 | | CHG-P12 | §3.1/3.2 | 仅 Priority1 扩展点 | 增加 Flock Freight 模块行与扩展点 | | CHG-P13 | §14 | 无 | 新增 Flock Freight:入口、硬限、两档映射、弱注册、验收 |