You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
chajia/docs/查价系统-技术设计.md

1188 lines
45 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 查价系统 技术设计 v1.2
| 版本 | 日期 | 作者 | 说明 |
|------|------|------|------|
| v1.0 | 2026-06-16 | 全栈架构师/Tech Lead | 可直接用于 Cursor 编码、建库、启动项目 |
| v1.1 | 2026-06-16 | 全栈架构师 | 对齐 PRD v0.4四档报价、地址联想、托盘数、嵌入宿主、Mothership 官方限制 |
| v1.2 | 2026-06-17 | 全栈架构师/SRE | 对齐 PRD v0.6QuotePageAdapter、多 URL 探测、storageState、Selector env、probe 门禁 |
| v1.3 | 2026-07-13 | 全栈架构师 | 对齐 PRD v0.7:新增 §7.6 Flock Freight RPA 模块(硬限、两档映射、独立 env |
> 配套文档:[查价系统-PRD.md](./查价系统-PRD.md)(产品需求、异常设计、验收标准)· [查价系统-UI设计.md](./查价系统-UI设计.md)(页面、交互、设计规范)
> 本文档为技术实现唯一依据;与 PRD 冲突时以 PRD 业务规则为准,技术实现细节以本文档为准。当前实现权威版本 **v1.3**(在 v1.2 基线上增量)。
---
## 文档约定
| 项 | 约定 |
|----|------|
| 技术栈 | Next.js 15 + MySQL 8.0 + Redis 7 + BullMQ + Playwright |
| 部署 | Docker Compose 一键启动 |
| 金额 | `DECIMAL(12,2)``ROUND_HALF_UP` |
| 缓存优先级 | L1 `request_id`(24h) > L2 `cargo_hash`(3min) > L3 stale(30min) |
| RPA | 一次询价 = 1 次 RPA单次返回 **4 档**standard/guaranteed × lowest/fastest |
| 嵌入 | 查价 API/组件由**宿主系统**调用;本期仅 **admin 管理端**登录 |
| 地址 | 须 `place_id` + `selected_from_suggestions`RPA 须点击联想项 |
| 计量 | `pallet_count`(托盘数);单托 weight/dims校验见 PRD §4.2.8 |
---
## 第 1 章 技术选型
### 1.1 前端框架
| 方案 | 优势 | 劣势 | 上手难度 | MVP 适配性 | Cursor AI 支持度 |
|------|------|------|----------|------------|------------------|
| React + Vite | 灵活、生态大 | 需单独配置路由/API | 中 | 中 | 高 |
| Vue 3 + Vite | 模板直观 | 全栈示例相对少 | 低 | 中 | 中 |
| **Next.js 15 (App Router)** | 前后端同仓、API Routes、类型安全、部署简单 | App Router 概念需熟悉 | 低-中 | **最高** | **最高** |
**最终推荐Next.js 15 (App Router)**
**理由**与后端同仓库Cursor 可一次生成页面 + API社区示例最多满足 PRD 客户端 + 管理端双入口。
### 1.2 后端技术
| 方案 | 优势 | 劣势 | 上手难度 | MVP 适配性 | Cursor AI 支持度 |
|------|------|------|----------|------------|------------------|
| Node.js + Express | 轻量 | 需手动搭中间件/分层 | 中 | 中 | 高 |
| Python + FastAPI | 文档自动生成 | 与 Next 异构,双仓维护 | 中 | 高 | 高 |
| Java + Spring Boot | 企业级稳定 | 配置重、启动慢 | 高 | 低 | 中 |
| **Next.js API Routes + 独立 Worker 进程** | 同语言、同类型、AI 生成率高 | 大规模需拆分 | **低** | **最高** | **最高** |
**最终推荐Next.js API RoutesWeb+ `workers/rpa` 独立 Node 进程BullMQ Consumer**
**理由**Web 层处理 HTTP/鉴权/编排RPA 长任务独立进程,避免 Serverless 超时;非程序员只需 `docker compose up`
### 1.3 数据库
| 方案 | 快速建模 | AI 生成 Schema | MVP 适配 | 最终 |
|------|----------|----------------|----------|------|
| PostgreSQL | 高 | 高 | 高 | |
| **MySQL 8.0** | 高 | **最高Prisma 示例最多)** | **最高** | **✅** |
| Supabase | 最高(托管) | 中 | 中vendor lock-in | |
**最终推荐MySQL 8.0 + Prisma ORM**
**理由**PRD 已定 MySQLPrisma migration 可由 Cursor 直接生成Docker 镜像小。
### 1.4 ORM / 队列 / 缓存
| 组件 | 候选 | 最终推荐 | 理由 |
|------|------|----------|------|
| ORM | Prisma / Drizzle / TypeORM | **Prisma** | Cursor 生成成功率最高 |
| 队列 | BullMQ / Bee-Queue | **BullMQ** | PRD 已定Redis 原生 |
| 缓存 | Redis / Memcached | **Redis** | 同时支撑队列 + L1/L2/L3 |
| RPA | Playwright / Puppeteer | **Playwright** | PRD 已定;维护性更好 |
### 1.5 部署方案
| 方案 | 非运维友好度 | 本地=生产一致性 | 最终 |
|------|--------------|-----------------|------|
| Vercel | 最高 | 低Worker/RPA 难部署) | |
| Railway | 高 | 中 | |
| **Docker Compose + VPS** | **高(一条命令)** | **最高** | **✅** |
| AWS ECS | 低 | 高 | |
**最终推荐Docker Compose**
**理由**Next.js + MySQL + Redis + RPA Worker 四容器本地与生产一致;`docker compose up -d` 即可启动。
---
## 第 2 章 系统架构设计
### 2.1 总体架构
```
┌─────────────────────────────────────────────────────────────┐
│ 宿主业务系统(内嵌查价组件 / 服务端调用) │
│ └─ Service Token + customer_id 透传 │
└───────────────────────────┬─────────────────────────────────┘
│ HTTPS /api/*
┌───────────────────────────▼─────────────────────────────────┐
│ next-app (Next.js 15) │
│ ├─ middleware.ts 宿主鉴权 + 管理员鉴权 + 限流 │
│ ├─ app/api/quotes/* 询价 API │
│ ├─ app/api/markup-configs/* (宿主调用,无运营登录页) │
│ ├─ app/api/alerts/* 管理员 │
│ └─ app/admin/* 管理端(预警/RPA/开发进度) │
└───────────────┬─────────────────────────┬─────────────────────┘
│ │
┌───────▼───────┐ ┌───────▼────────┐
│ MySQL 8.0 │ │ Redis 7 │
└───────────────┘ └───────┬────────┘
┌─────────▼──────────┐
│ rpa-worker │
│ Playwright │
│ MothershipRPA │
│ · 地址联想点选 │
│ · 四档抓取 │
└─────────┬──────────┘
│ RPA
┌─────────▼──────────┐
│ Mothership Web │
└────────────────────┘
```
### 2.2 模块划分
| 模块 | 职责 | 代码路径 |
|------|------|----------|
| 鉴权 | JWT 签发/校验、RBAC | `modules/auth/`、`middleware.ts` |
| 询价编排 | 校验→缓存→落库→入队→轮询查询 | `modules/quote/orchestrator.ts` |
| 缓存 | L1/L2/L3 读写、击穿锁 | `modules/cache/redis-cache.ts` |
| 幂等 | request_id 24h | `modules/quote/idempotency.ts` |
| 加价 | 运费百分比计算 | `modules/pricing/engine.ts` |
| 预警 | alert_log 写入、偏差检测 | `modules/alert/service.ts` |
| RPA | BullMQ 消费、Playwright 抓取 | `workers/rpa/` |
| 定时任务 | processing 超时、熔断恢复 | `workers/scheduler/` |
### 2.3 核心数据流(询价)
```
1. POST /api/quotes
2. ValidationModule格式校验 + in/lb 换算 + cargo_hash
3. IdempotencyModule查 L1(request_id) → 命中返回 quote_id
4. CacheModule查 L2(cargo_hash) → 命中PricingEngine 加价 → 落库 → 写 L1 → done
5. 未命中INSERT quote_record(processing) → BullMQ.add('quote', {quote_id})
6. 返回 { quote_id, status: processing }
--- 异步 ---
7. rpa-worker 消费 job
8. MothershipRPAProvider.getQuote() → 四档原始价standard/guaranteed × lowest/fastest
9. 一致性校验 → 写 L2(3min+jitter)/L3(30min) → UPDATE quote_record(done)
10. quote_cache_meta 更新 → 偏差≥5% 写 PRICE_DEVIATION
11. 写 L1(request_id)
--- 客户端 ---
12. GET /api/quotes/{quote_id} 每 2s 轮询,最长 30s
13. valid_until 过期 → status=expired查询时计算
```
### 2.4 Fallback 数据流
```
RPA 抛错(超时/验证码/STRUCT_CHANGE/DATA_INVALID
→ 读 L3 stale(cargo_hash)
→ 有done + source_type=stale + is_realtime=false + STALE_FALLBACK 告警
→ 无failed + QUOTE_UNAVAILABLE + RPA_FAILED 告警
验证码额外:暂停 Worker 10min + RPA_CAPTCHA 告警
连续失败≥3熔断 10min期间不分配 RPA job
```
### 2.5 第三方依赖
| 依赖 | 用途 | 版本 |
|------|------|------|
| Mothership Web | RPA 报价源 | 外部 |
| Playwright | 浏览器自动化 | ^1.44 |
| BullMQ | 任务队列 | ^5.x |
| ioredis | Redis 客户端 | ^5.x |
| Prisma | ORM | ^5.x |
| jose | JWT | ^5.x |
| zod | 入参校验 | ^3.x |
---
## 第 3 章 数据库设计
### 3.1 ER 关系
```
sys_user (1) ──< quote_record (N)
markup_config (1) ── (1) customer_id 逻辑关联
quote_record (N) ── (0..1) alert_log
quote_cache_meta (1) ── (1) cargo_hash
idempotency_record (1) ── (1) request_id
```
### 3.2 表sys_user鉴权 MVP
> **v1.1**:本期仅保留 **admin** 管理端登录;不提供 customer/operator 业务账号。查价 API 使用宿主 **Service Token**(见 §4.2)。
| 字段 | 类型 | 约束 | 说明 |
|------|------|------|------|
| id | BIGINT UNSIGNED | PK | |
| user_id | VARCHAR(32) | UNIQUE, NOT NULL | 对外 ID |
| customer_id | VARCHAR(32) | UNIQUE, NULL | **保留字段**;宿主 token 场景不使用 |
| username | VARCHAR(64) | UNIQUE, NOT NULL | 登录名 |
| password_hash | VARCHAR(255) | NOT NULL | bcrypt |
| role | VARCHAR(16) | NOT NULL | **admin**(本期唯一登录角色) |
| is_deleted | TINYINT(1) | DEFAULT 0 | |
| created_at | DATETIME | NOT NULL | |
| updated_at | DATETIME | NOT NULL | |
索引:`uk_user_id`、`uk_username`、`uk_customer_id`
### 3.3 表quote_record
与 PRD §9.1 一致,见下方 Prisma + SQL。
### 3.4 表idempotency_record / quote_cache_meta / markup_config / alert_log
与 PRD §9.29.5 一致。
### 3.5 Prisma Schema可直接 `prisma migrate dev`
```prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
model SysUser {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
userId String @unique @map("user_id") @db.VarChar(32)
customerId String? @unique @map("customer_id") @db.VarChar(32)
username String @unique @db.VarChar(64)
passwordHash String @map("password_hash") @db.VarChar(255)
role String @db.VarChar(16)
isDeleted Boolean @default(false) @map("is_deleted")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("sys_user")
}
model QuoteRecord {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
quoteId String @unique @map("quote_id") @db.VarChar(32)
requestId String @map("request_id") @db.VarChar(36)
customerId String @map("customer_id") @db.VarChar(32)
cargoHash String @map("cargo_hash") @db.Char(32)
status String @default("processing") @db.VarChar(16)
sourceType String? @map("source_type") @db.VarChar(16)
isRealtime Boolean @default(true) @map("is_realtime")
confidenceScore Decimal? @map("confidence_score") @db.Decimal(3, 2)
currency String @default("USD") @db.VarChar(8)
pickupJson Json @map("pickup_json")
deliveryJson Json @map("delivery_json")
weightLb Decimal @map("weight_lb") @db.Decimal(12, 2)
dimLIn Decimal @map("dim_l_in") @db.Decimal(8, 2)
dimWIn Decimal @map("dim_w_in") @db.Decimal(8, 2)
dimHIn Decimal @map("dim_h_in") @db.Decimal(8, 2)
palletCount Int @map("pallet_count")
cargoType String @map("cargo_type") @db.VarChar(32)
quotesJson Json? @map("quotes_json")
markupPercent Decimal @default(0.0) @map("markup_percent") @db.Decimal(5, 1)
validUntil DateTime? @map("valid_until")
errorCode String? @map("error_code") @db.VarChar(32)
ccCustomerId String? @map("cc_customer_id") @db.VarChar(32)
forecastId String? @map("forecast_id") @db.VarChar(32)
isDeleted Boolean @default(false) @map("is_deleted")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([customerId, createdAt], map: "idx_customer_created")
@@index([cargoHash], map: "idx_cargo_hash")
@@index([requestId], map: "idx_request")
@@map("quote_record")
}
model IdempotencyRecord {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
requestId String @unique @map("request_id") @db.VarChar(36)
customerId String @map("customer_id") @db.VarChar(32)
quoteId String @map("quote_id") @db.VarChar(32)
expireAt DateTime @map("expire_at")
createdAt DateTime @default(now()) @map("created_at")
@@map("idempotency_record")
}
model QuoteCacheMeta {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
cargoHash String @unique @map("cargo_hash") @db.Char(32)
rawTotalStandard Decimal @map("raw_total_standard") @db.Decimal(12, 2)
rawTotalGuaranteed Decimal @map("raw_total_guaranteed") @db.Decimal(12, 2)
lastQuotesJson Json @map("last_quotes_json")
refreshedAt DateTime @default(now()) @map("refreshed_at")
@@map("quote_cache_meta")
}
model MarkupConfig {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
customerId String @unique @map("customer_id") @db.VarChar(32)
markupPercent Decimal @map("markup_percent") @db.Decimal(5, 1)
operatorId String @map("operator_id") @db.VarChar(32)
remark String? @db.VarChar(255)
isDeleted Boolean @default(false) @map("is_deleted")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("markup_config")
}
model AlertLog {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
alertType String @map("alert_type") @db.VarChar(32)
quoteId String? @map("quote_id") @db.VarChar(32)
cargoHash String? @map("cargo_hash") @db.Char(32)
detailJson Json? @map("detail_json")
status String @default("open") @db.VarChar(16)
resolverId String? @map("resolver_id") @db.VarChar(32)
resolvedAt DateTime? @map("resolved_at")
createdAt DateTime @default(now()) @map("created_at")
@@index([alertType, status], map: "idx_type_status")
@@index([createdAt], map: "idx_created")
@@map("alert_log")
}
```
### 3.6 Redis Key 设计
| Key | 值 | TTL | 说明 |
|-----|-----|-----|------|
| `idem:{request_id}` | `{quote_id, response}` | 24h | L1 幂等 |
| `quote:{cargo_hash}` | 原始四档 JSON未加价 | 3min + 0~30s jitter | L2 热缓存 |
| `stale:{cargo_hash}` | 同 L2 结构 | 30min | L3 降级 |
| `lock:quote:{cargo_hash}` | `1` | 5s | 击穿锁 |
| `ratelimit:customer:{id}` | 计数 | 60s | 60次/分钟 |
| `circuit:rpa` | `open/closed` | 10min | 熔断状态 |
### 3.7 cargo_hash 算法
```typescript
import { createHash } from 'crypto';
export function buildCargoHash(input: {
pickup: string; // 含 place_id 的标准化 JSON
delivery: string;
weightLb: string; // 单托重量2 位小数
dimLIn: string;
dimWIn: string;
dimHIn: string;
palletCount: number;
cargoType: string;
}): string {
const raw = [
input.pickup,
input.delivery,
input.weightLb,
input.dimLIn,
input.dimWIn,
input.dimHIn,
String(input.palletCount),
input.cargoType,
].join('|');
return createHash('md5').update(raw).digest('hex');
}
```
**不含** `service_level`、`rate_option`、`customer_id`。
---
## 第 4 章 API 设计
### 4.1 统一响应
```typescript
// 成功
{ "code": 0, "message": "ok", "data": { ... } }
// 失败
{ "code": "VALIDATION_FAILED", "message": "请填写重量", "data": null }
```
所有查价接口响应头:`Cache-Control: no-store`
### 4.2 鉴权
**宿主查价 API**
```
Authorization: Bearer <host_service_token>
X-Customer-Id: CUST_001 // 或由请求体 customer_id 携带,须与 token 租户绑定
```
**管理端 API**
```
Authorization: Bearer <admin_jwt>
JWT payload: { sub: user_id, role: "admin", exp }
```
| 调用方 | 权限 |
|--------|------|
| 宿主 Service Token | POST/GET quotes、GET history、PUT markup-configs若授权 |
| admin | GET alerts、POST alerts resolve、GET rpa/status开发进度 |
> 本期**无** customer/operator 独立 JWT 登录;`POST /api/auth/login` 仅用于管理端 admin。
### 4.3 接口清单
#### POST /api/quotes
| 项 | 内容 |
|----|------|
| 权限 | 宿主 Service Token |
| 请求体 | 见 PRD §4.3 |
| 成功 200 | `{ quote_id, status: "processing" \| "done" }` |
| 错误 | 400 VALIDATION_FAILED、401 UNAUTHORIZED、429 RATE_LIMITED |
#### GET /api/quotes/{quote_id}
| 项 | 内容 |
|----|------|
| 权限 | 宿主(本人 customer_id |
| 成功 200 | 完整 QuoteResponsePRD §5.2`valid_until < now` `status=expired` |
| 错误 | 403 FORBIDDEN404 QUOTE_NOT_FOUND |
#### GET /api/quotes/history?page=1&size=20
| | 内容 |
|----|------|
| 权限 | 宿主 Service Token |
| 必填 | pagesize(缺省 400 |
| 成功 200 | `{ list: QuoteSummary[], total, page, size }` |
#### GET /api/markup-configs?page=1&size=20
| | 内容 |
|----|------|
| 权限 | 宿主(`pricing:markup:read` 可选) |
| 成功 200 | 分页列表 |
#### PUT /api/markup-configs/{customer_id}
| | 内容 |
|----|------|
| 权限 | 宿主(`pricing:markup:write` |
| 请求体 | `{ markup_percent, remark? }` |
| 成功 200 | 配置对象 |
| 错误 | 400>30%、403 FORBIDDEN |
#### GET /api/alerts?page=1&size=20&type=&status=open
| 项 | 内容 |
|----|------|
| 权限 | admin`alert:read` |
| 成功 200 | 分页列表 |
#### POST /api/alerts/{id}/resolve
| 项 | 内容 |
|----|------|
| 权限 | admin |
| 请求体 | `{ resolver_id }` |
| 成功 200 | `{ id, status: "resolved" }` |
#### POST /api/auth/login管理端 MVP
| 项 | 内容 |
|----|------|
| 请求体 | `{ username, password }` |
| 成功 200 | `{ token, user: { user_id, role: "admin" } }` |
| 说明 | **仅管理员**;不提供 customer/operator 登录 |
### 4.4 错误码
| code | HTTP | 说明 |
|------|------|------|
| VALIDATION_FAILED | 400 | 入参校验失败 |
| UNAUTHORIZED | 401 | JWT 无效 |
| FORBIDDEN | 403 | 越权/无 RBAC |
| QUOTE_NOT_FOUND | 404 | quote_id 不存在 |
| RATE_LIMITED | 429 | 限流 |
| QUOTE_UNAVAILABLE | 200* | status=failed |
| QUOTE_TIMEOUT | 200* | status=failed |
| INTERNAL_ERROR | 500 | 系统异常 |
\* 询价失败通过 `status=failed` + `error_code` 表达HTTP 仍为 200。
### 4.5 加价计算实现
```typescript
export function applyMarkup(
rawFreight: number,
rawTotal: number,
markupPercent: number,
): { markupAmount: number; finalTotal: number } {
const markupAmount = roundHalfUp(rawFreight * (markupPercent / 100), 2);
const finalTotal = roundHalfUp(rawTotal + markupAmount, 2);
return { markupAmount, finalTotal };
}
function roundHalfUp(value: number, decimals: number): number {
const factor = 10 ** decimals;
return Math.round((value + Number.EPSILON) * factor) / factor;
}
```
---
## 第 5 章 项目工程结构
```
chajia/
├── docker-compose.yml
├── .env.example
├── package.json
├── next.config.ts
├── tsconfig.json
├── middleware.ts # JWT + 限流
├── prisma/
│ ├── schema.prisma
│ └── seed.ts
├── app/
│ ├── layout.tsx
│ ├── page.tsx # 客户查价页
│ ├── login/page.tsx
│ ├── admin/
│ │ ├── markup/page.tsx
│ │ └── alerts/page.tsx
│ └── api/
│ ├── auth/login/route.ts
│ ├── quotes/route.ts
│ ├── quotes/[quoteId]/route.ts
│ ├── quotes/history/route.ts
│ ├── markup-configs/route.ts
│ ├── markup-configs/[customerId]/route.ts
│ └── alerts/
│ ├── route.ts
│ └── [id]/resolve/route.ts
├── modules/
│ ├── auth/jwt.ts
│ ├── quote/
│ │ ├── orchestrator.ts
│ │ ├── idempotency.ts
│ │ ├── validation.ts
│ │ └── types.ts
│ ├── cache/redis-cache.ts
│ ├── pricing/engine.ts
│ ├── alert/service.ts
│ └── providers/
│ ├── quote-provider.ts
│ ├── mothership-rpa-provider.ts
│ └── flock-freight-rpa-provider.ts # Phase 扩展PRD §14
├── workers/
│ ├── rpa/
│ │ ├── index.ts # BullMQ consumer 入口
│ │ ├── mothership.ts # Playwright 脚本
│ │ ├── flock-freight.ts # Flock get-a-quote RPAPRD §14
│ │ ├── quote-page-adapter.ts # 多 URL 探测 + preCheckPRD GD-15
│ │ └── session-manager.ts # BrowserContext + storageState
│ └── scheduler/
│ └── timeout-sweeper.ts # processing >30s 标记失败
├── lib/
│ ├── prisma.ts
│ ├── redis.ts
│ ├── response.ts
│ └── constants/
│ ├── mothership-limits.ts
│ └── flock-limits.ts # PRD §14.3
├── components/
│ ├── quote-form.tsx
│ ├── quote-result.tsx
│ └── countdown.tsx
├── types/
│ └── api.ts
└── scripts/
├── dev.sh
├── migrate.sh
├── probe-rpa.ts # RPA 探针TC-601 / Go/No-Go
├── probe-flock-rpa.ts # Flock 探针PRD §14.7
├── record-mothership.sh # Playwright codegen 录制入口
└── record-flock.sh # Flock codegen 录制入口
```
### 5.1 目录职责
| 目录 | 职责 | Cursor 提示词示例 |
|------|------|-------------------|
| `app/api/` | HTTP 路由薄层,只调 modules | 「在 app/api/quotes/route.ts 实现 POST调用 orchestrator」 |
| `modules/` | 业务逻辑 | 「在 modules/quote/orchestrator.ts 实现 L1→L2→入队」 |
| `workers/rpa/` | 长任务 | 「实现 MothershipRPAProvider一次 RPA 返回四档 + 地址联想点选」 |
| `prisma/` | 数据模型 | 「按技术设计 schema 生成 migration」 |
| `components/` | UI 组件 | 「实现查价表单,含单位切换与防重复提交」 |
---
## 第 6 章 Docker Compose 与启动
### 6.1 docker-compose.yml
```yaml
services:
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: chajia
ports:
- "3306:3306"
volumes:
- mysql_data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 5s
timeout: 5s
retries: 10
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis_data:/data
next-app:
build:
context: .
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
DATABASE_URL: mysql://root:${MYSQL_ROOT_PASSWORD}@mysql:3306/chajia
REDIS_URL: redis://redis:6379
JWT_SECRET: ${JWT_SECRET}
NODE_ENV: production
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_started
rpa-worker:
build:
context: .
dockerfile: Dockerfile.worker
environment:
DATABASE_URL: mysql://root:${MYSQL_ROOT_PASSWORD}@mysql:3306/chajia
REDIS_URL: redis://redis:6379
MOTHERSHIP_EMAIL: ${MOTHERSHIP_EMAIL}
MOTHERSHIP_PASSWORD: ${MOTHERSHIP_PASSWORD}
MOTHERSHIP_QUOTE_URLS: ${MOTHERSHIP_QUOTE_URLS}
RPA_STORAGE_STATE_PATH: ${RPA_STORAGE_STATE_PATH:-.rpa/mothership-storage.json}
RPA_MOCK_MODE: ${RPA_MOCK_MODE:-false}
RPA_HEADLESS: ${RPA_HEADLESS:-true}
volumes:
- rpa_state:/app/.rpa
depends_on:
- mysql
- redis
# Playwright 需要 shm
shm_size: "1gb"
volumes:
mysql_data:
redis_data:
rpa_state:
```
### 6.2 .env.example
```env
MYSQL_ROOT_PASSWORD=changeme
JWT_SECRET=your-256-bit-secret
# RPAPRD v0.6 GD-15必填headed 录制后填入)
MOTHERSHIP_QUOTE_URLS=
RPA_STORAGE_STATE_PATH=.rpa/mothership-storage.json
RPA_MOCK_MODE=false
RPA_HEADLESS=true
# SelectorPlaywright codegen 产出,见 §7.2.3
RPA_SELECTOR_PICKUP_STREET=
RPA_SELECTOR_DELIVERY_STREET=
RPA_SELECTOR_ADDRESS_SUGGESTION=
RPA_SELECTOR_SUBMIT=
RPA_SELECTOR_STANDARD_TAB=
RPA_SELECTOR_GUARANTEED_TAB=
RPA_SELECTOR_LOWEST_OPTION=
RPA_SELECTOR_FASTEST_OPTION=
RPA_SELECTOR_PRICE=
# 可选:仅当入口跳转 login 时使用
MOTHERSHIP_EMAIL=
MOTHERSHIP_PASSWORD=
# 可选Patchright 反检测P1
RPA_USE_PATCHRIGHT=false
# Flock FreightPRD v0.7 §14 / 技术设计 §7.6;默认关闭)
FLOCK_RPA_ENABLED=false
FLOCK_QUOTE_URL=https://app.flockfreight.com/get-a-quote
FLOCK_STORAGE_STATE_PATH=.rpa/flock-storage.json
# FLOCK_SELECTOR_* 由 record-flock.sh 录制后填入
DATABASE_URL=mysql://root:changeme@localhost:3306/chajia
REDIS_URL=redis://localhost:6379
```
### 6.3 启动命令
```bash
# 1. 复制环境变量
cp .env.example .env
# 2. 启动全部服务
docker compose up -d --build
# 3. 数据库迁移(首次)
docker compose exec next-app npx prisma migrate deploy
docker compose exec next-app npx prisma db seed
# 4. 访问
# 客户查价http://localhost:3000
# 管理端: http://localhost:3000/admin/markup
```
### 6.4 本地开发(非 Docker
```bash
npm install
docker compose up -d mysql redis
npx prisma migrate dev
npm run dev # Next.js :3000
npm run worker:rpa # RPA Worker
```
---
## 第 7 章 RPA 实现规格
### 7.1 QuoteProvider 接口
```typescript
export interface QuoteItem {
serviceLevel: 'standard' | 'guaranteed';
rateOption: 'lowest' | 'fastest';
carrier: string;
transitDays: string;
transitDescription: string;
rawFreight: number;
surcharges: number;
rawTotal: number;
}
export interface QuoteProvider {
getQuote(req: QuoteRequest): Promise<{ items: QuoteItem[] }>; // items.length === 4
healthCheck(): Promise<boolean>;
}
```
### 7.2 MothershipRPAProvider 执行步骤
> **匿名查价PRD v0.6**:通过 `QuotePageAdapter` 探测 `MOTHERSHIP_QUOTE_URLS`**禁止**默认 `dashboard.mothership.com` 或 `www.mothership.com/quote`。`session-manager` 加载/写回 `RPA_STORAGE_STATE_PATH`;仅在 login 页且已配置凭据时重登。`RPA_MOCK_MODE` 仅 CI/单测。
1. `QuotePageAdapter.resolveQuoteEntry()`:按序 goto + preCheck首个可用 URL 为 `activeUrl`;全失败 → `QUOTE_ENTRY_UNAVAILABLE`
2. `session-manager.createContext()`:若 storageState 存在则 `storageState: path`;成功后 `context.storageState({ path })`
3. **preCheck**404 / login 且无凭据 → `STRUCT_CHANGE`**非** SESSION_EXPIRED表单 selector 不可见 → `STRUCT_CHANGE`
4. **地址(关键)**
- 若入参含 `place_id`:优先用 Mothership 已选地址回填/校验
- 否则:`RPA_SELECTOR_PICKUP_STREET` / `DELIVERY_STREET` 键入 → `waitForSelector(RPA_SELECTOR_ADDRESS_SUGGESTION)`**click** 含 zip 项(禁止 Enter
- 断言:联想下拉消失;隐藏字段或展示文本已锁定;失败 → `ADDRESS_SUGGESTION_NOT_FOUND`
5. 填入单托 weight(lb)/dims(in)、`pallet_count`、`cargo_type`
6. `click(RPA_SELECTOR_SUBMIT)`,等待结果页
7.**standard** / **guaranteed** 区块:分别点选 lowest/fastest各抓取价格与时效
8. 校验:`rawFreight > 0`**四档均存在**
9. 返回 `items[4]`
### 7.2.3 QuotePageAdapter 接口
```typescript
export interface QuotePageAdapter {
/** 按 MOTHERSHIP_QUOTE_URLS 顺序探测,返回首个通过 preCheck 的 URL */
resolveQuoteEntry(page: Page): Promise<{ activeUrl: string }>;
/** 非 login/captcha、报价表单 selector 可见 */
preCheck(page: Page): Promise<boolean>;
/** login 页且无 MOTHERSHIP_EMAIL/PASSWORD */
isLoginWithoutCredentials(page: Page): boolean;
}
```
实现文件:`workers/rpa/quote-page-adapter.ts`
preCheck 最小检查项:`RPA_SELECTOR_PICKUP_STREET`、`RPA_SELECTOR_SUBMIT` 可见URL 不含 `/login`;无 Cloudflare challenge DOM。
| env | 用途 |
|-----|------|
| `RPA_SELECTOR_PICKUP_STREET` | 发货街道输入 |
| `RPA_SELECTOR_DELIVERY_STREET` | 收货街道输入 |
| `RPA_SELECTOR_ADDRESS_SUGGESTION` | 联想下拉项 |
| `RPA_SELECTOR_SUBMIT` | 提交按钮 |
| `RPA_SELECTOR_STANDARD_TAB` | standard 区块 |
| `RPA_SELECTOR_GUARANTEED_TAB` | guaranteed 区块 |
| `RPA_SELECTOR_LOWEST_OPTION` | 最低价格选项 |
| `RPA_SELECTOR_FASTEST_OPTION` | 最快递送选项 |
| `RPA_SELECTOR_PRICE` | 价格展示节点 |
### 7.2.1 地址联想 Selector 约定(实施时以 RPA 录制为准)
| 步骤 | Playwright 动作 |
|------|-----------------|
| 输入街道 | `fill(process.env.RPA_SELECTOR_PICKUP_STREET, street)` |
| 等待下拉 | `waitForSelector(process.env.RPA_SELECTOR_ADDRESS_SUGGESTION, { timeout: 5000 })` |
| 点选 | `click` 首条含 `zip` 匹配项 |
| 断言锁定 | 联想下拉消失;`fill-verify` 后 hidden place_id 或 formatted 文本非空 |
失败分类:`ADDRESS_SUGGESTION_NOT_FOUND` → 不重试 → 宿主 400 或 L3
### 7.2.2 货物校验validation.ts
实现 PRD §4.2.8 硬顶,**禁止**硬编码 50000 lb / 120 in
```typescript
export const MOTHERSHIP_LIMITS = {
palletCount: { min: 1, max: 25 },
dimIn: { lengthMax: 999, widthMax: 99, heightMax: 99, min: 1 },
weightLbPerPallet: { min: 0.01, max: 9_999 },
totalWeightLbMax: 249_975,
} as const;
```
### 7.3 错误分类与处理
| 错误类 | 处理 |
|--------|------|
| QUOTE_ENTRY_UNAVAILABLE | 不重试 → L3 + STRUCT_CHANGE 告警 |
| ADDRESS_SUGGESTION_NOT_FOUND | 不重试;宿主 API 400Worker → L3 |
| PAGE_LOAD_TIMEOUT | 重试 1 次 → L3 |
| RPA_CAPTCHA | 停 Worker 10min → L3 |
| STRUCT_CHANGE | 不重试(含 login 无凭据、404、selector 缺失)→ L3 + 告警 |
| RPA_DATA_INVALID | L3 |
| SESSION_EXPIRED | **仅**已配置凭据且曾成功登录时:重登 1 次 → 失败则 L3 |
### 7.5 storageState 生命周期
```typescript
// session-manager.ts 要点
const statePath = process.env.RPA_STORAGE_STATE_PATH ?? '.rpa/mothership-storage.json';
async function createContext(browser: Browser) {
const opts = fs.existsSync(statePath) ? { storageState: statePath } : {};
return browser.newContext(opts);
}
async function persistContext(context: BrowserContext) {
await context.storageState({ path: statePath });
}
async function invalidateStorageState() {
if (fs.existsSync(statePath)) fs.unlinkSync(statePath);
}
```
- 成功匿名查价后写回 storageState
- preCheck 命中 login 且无凭据:先 `invalidateStorageState()` 再重试一次 goto仍失败 → STRUCT_CHANGE
- Docker`rpa_state` volume 挂载 `/app/.rpa`
### 7.4 BullMQ Job 结构
```typescript
interface QuoteJobData {
quoteId: string;
requestId: string;
customerId: string;
cargoHash: string;
cargo: NormalizedCargo;
}
```
队列名:`quote-rpa`并发2单 job 超时25s重试1 次。
### 7.6 FlockFreightRPAProviderPRD §14 / GD-16Phase 扩展)
> **状态**已落地API / 校验 / 嵌入 UI / **Direct `user/quotes` JSON 优先** + DOM 回退)。生产开启须 `FLOCK_RPA_ENABLED=true`;探针 `probe:flock-rpa` / `batch:flock-direct-10`。
#### 7.6.1 职责与文件
| 文件 | 职责 |
|------|------|
| `modules/flock/orchestrator.ts` | 入队 `flock-quote` |
| `lib/flock/direct-quote.ts` | Directbootstrap cookie → `POST api.flockfreight.com/user/quotes` |
| `lib/flock/quote-payload.ts` / `map-fulfillment-options.ts` | 请求体与两档映射 |
| `workers/rpa/flock/run-quote.ts` | `FLOCK_QUOTE_MODE``direct` / `dom` / `direct_then_dom`(默认) |
| `workers/rpa/flock/visual-chain.ts` | DOM 填表回退路径 |
| `lib/constants/flock-limits.ts` | `FLOCK_LIMITS` 硬限常量 |
| `scripts/probe-flock-rpa.ts` / `batch:flock-direct-10` | 探针 |
#### 7.6.2 硬限常量
```typescript
/** 来源PRD §14.3,官网 get-a-quote 提示文案2026-07-13 */
export const FLOCK_LIMITS = {
palletCount: { min: 4, max: 20 },
totalWeightLbMax: 45_000, // 整票总重,非单托
dimIn: { lengthMax: 636, widthMax: 102, heightMax: 108, min: 1 },
zipPattern: /^\d{5}(-\d{4})?$/,
} as const;
```
校验入口:`validation.ts` 在 `provider=flock`(或独立 API 路径)时走 `FLOCK_LIMITS`**禁止**复用 `MOTHERSHIP_LIMITS`
计量Flock 表单重量为**整票总重**;若宿主仍传单托重,编排层换算 `totalWeightLb = weightLb * palletCount` 后再校验与填表。
#### 7.6.3 执行步骤
**主路径 Direct推荐**
1. 打开 `FLOCK_QUOTE_URL` 仅获取 `anonymous_token` / `ff_jwt`
2. `POST https://api.flockfreight.com/user/quotes`(契约见 `.rpa/flock-network-poc/`
3.`fulfillmentOptions` 映射:`GUARANTEED_HUBLESS`→FlockDirect®`STANDARD` 最低价→Standard
4. 经 PricingEngine 加价写库;**不填表、不注册**
**回退 DOM`FLOCK_QUOTE_MODE=direct_then_dom|dom`**
1. 配置了 `FLOCK_LOGIN_EMAIL` / `FLOCK_LOGIN_PASSWORD` 时:**先登录**`workers/rpa/flock/login.ts`),再填表抓价;禁止 random_qq / 盲填注册;成功可写回 `FLOCK_STORAGE_STATE_PATH`
2. 无登录凭据时:`goto(FLOCK_QUOTE_URL)`preCheck 失败 → `QUOTE_ENTRY_UNAVAILABLE`
3.**取货日期**`FLOCK_SELECTOR_PICKUP_DATE`
4.**取件 ZIP / 投递 ZIP**无街道联想ZIP 非法由中台先 400
5.**托盘数量**、**总运输重量(lb)**、**单托 L/W/H(in)**(默认官网 48/40/48可覆盖
6. `click(FLOCK_SELECTOR_NEXT)`等待结果页「Explore your custom quote options」
7. 解析两卡:
- FlockDirect® → `{ serviceLevel:'guaranteed', rateOption:'fastest', carrier:'Flock Freight', rawFreight, transitDays }`
- Standard → `{ serviceLevel:'standard', rateOption:'lowest', carrier:'Flock Freight', ... }`
8. 可选抓取 `reference`(如 `FRG-XXXX`)写入 `quote_record` 扩展字段或日志
9. 校验至少 `FLOCK_MIN_QUOTES` 档且价格 >0否则失败
10. **停止边界**不点击「Complete your order」避免进入注册/下单)
Direct 仍匿名 bootstrapDOM 保障路径优先使用 `FLOCK_LOGIN_*`。自动注册仅用于无登录凭据的探针脚本。
#### 7.6.4 环境变量
| env | 必填 | 说明 |
|-----|------|------|
| `FLOCK_QUOTE_URL` | 是(启用时) | 默认 `https://app.flockfreight.com/get-a-quote` |
| `FLOCK_QUOTE_MODE` | 否 | `direct` / `dom` / `direct_then_dom`默认Direct 优先) |
| `FLOCK_LOGIN_EMAIL` / `FLOCK_LOGIN_PASSWORD` | DOM 保障建议 | 账号密码登录;配置后禁用无痕随机注册 |
| `FLOCK_LOGIN_URL` | 否 | 默认 `https://app.flockfreight.com/login` |
| `FLOCK_STORAGE_STATE_PATH` | 否 | 默认 `.rpa/flock-storage.json` |
| `FLOCK_RPA_ENABLED` | 否 | 默认 `false``true` 才注册 Provider / 消费队列 |
| `FLOCK_SELECTOR_PICKUP_DATE` | 启用时是 | 取货日期 |
| `FLOCK_SELECTOR_PICKUP_ZIP` | 启用时是 | 取件 ZIP |
| `FLOCK_SELECTOR_DELIVERY_ZIP` | 启用时是 | 投递 ZIP |
| `FLOCK_SELECTOR_PALLET_COUNT` | 启用时是 | 托盘数 |
| `FLOCK_SELECTOR_TOTAL_WEIGHT` | 启用时是 | 总重量 lb |
| `FLOCK_SELECTOR_LENGTH` / `_WIDTH` / `_HEIGHT` | 启用时是 | 单托尺寸 |
| `FLOCK_SELECTOR_NEXT` | 启用时是 | 「下一个」 |
| `FLOCK_SELECTOR_CARD_DIRECT` / `_STANDARD` | 启用时是 | 结果卡根节点 |
| `FLOCK_SELECTOR_CARD_PRICE` / `_TRANSIT` | 启用时是 | 卡内价格/时效 |
| `FLOCK_SELECTOR_LOGIN_EMAIL` / `_PASSWORD` / `_SUBMIT` | 否 | 登录表单 selector 覆盖 |
#### 7.6.5 队列与隔离
| 项 | 规则 |
|----|------|
| 队列名 | `flock-quote-rpa`(与 `quote-rpa` 分离) |
| Worker | 可同进程分 consumer或独立容器concurrency 默认 1 |
| 缓存 key | `cargo_hash` 须含 `provider=flock` 前缀或独立命名空间,避免与 Mothership 串缓存 |
| 开关 | `FLOCK_RPA_ENABLED=false` 时零副作用 |
#### 7.6.6 验收
- `npm run probe:flock-rpa`(或等价)连续 3 次 exit 0
- 单测:`FLOCK_LIMITS` 边界3/21 托、45001 lb、637/103/109 in
- 回归:关闭开关后 Mothership probe / embed-demo 不受影响
---
## 第 8 章 前端实现规格
### 8.1 宿主内嵌查价组件
- 由宿主系统加载;地址组件须支持 **Places 联想点选**(见 PRD §4.2.1 方案 A
- 表单字段:`pallet_count`(托盘数)、单托 weight/dims支持 kg/cm 展示,提交前换算)
- 结果区:四档 Tabstandard/guaranteed × lowest/fastest
- 状态机见 [查价系统-UI设计.md](./查价系统-UI设计.md) §2.4(待 UI 文档同步 v1.1
### 8.2 管理端(本期唯一登录入口)
| 路由 | 功能 |
|------|------|
| `/admin/alerts` | 预警列表、标记已处理 |
| `/admin/rpa` | RPA Worker 状态、队列、成功率(开发进度) |
| `/admin/dashboard` | 汇总指标 |
| `/admin/queues` | Bull Board 队列监控P1开发/运维) |
> **移除** `/admin/markup` 运营登录页;加价由宿主 `PUT /api/markup-configs` 配置。
### 8.3 技术栈
- UITailwind CSS + shadcn/ui
- 表单react-hook-form + zod
- 请求fetch + 自定义 hook `useQuotePolling`
---
## 第 9 章 MVP 实现路径
### 9.1 Week 1基础链路
| 序号 | 任务 | 产出 | 验收 TC |
|------|------|------|---------|
| 1 | docker-compose + Prisma migrate | 5 表 + sys_user | - |
| 2 | POST/GET /api/quotes 骨架 | API 通 | TC-101 |
| 3 | validation + cargo_hash | 入参校验 | TC-201~204 |
| 4 | Redis L1/L2/L3 + 击穿锁 | 缓存模块 | TC-104, TC-105 |
| 5 | idempotency_record | 幂等 | TC-207, TC-404 |
### 9.2 Week 2RPA + 加价
| 序号 | 任务 | 产出 | 验收 TC |
|------|------|------|---------|
| 6 | **headed 录制 + env** | `record-mothership.sh``MOTHERSHIP_QUOTE_URLS` + `RPA_SELECTOR_*` | - |
| 7 | QuotePageAdapter + session-manager storageState | 多 URL 探测 + 匿名 Cookie | TC-601 probe 1 次 |
| 8 | BullMQ + rpa-worker | 异步询价 | TC-601 |
| 9 | MothershipRPAProvider | 四档 + 地址 fill-verify | TC-601 probe 3/3 |
| 10 | Fallback + 熔断 | 降级 | TC-205, TC-209 |
| 11 | PricingEngine + markup API | 加价 | TC-103, TC-505 |
| 12 | 验证码/STRUCT_CHANGE 处理 | 异常 | TC-603, TC-604 |
### 9.3 Week 3预警 + 前端 + 上线
| 序号 | 任务 | 产出 | 验收 TC |
|------|------|------|---------|
| 11 | AlertService + 偏差检测 | 预警 | TC-210~212 |
| 12 | 客户查价页 + 管理端 | UI | TC-101 UI |
| 13 | 限流 + RBAC | 安全 | TC-501~506 |
| 14 | 全量 TC + Go/No-Go | 上线 | PRD §12.7 |
### 9.4 MVP 不做的功能
- 多币种
- CC API 对接
- Priority1 Provider
- **Flock Freight Provider口径见 §7.6;开关默认关闭)**
- 嵌入私仓预报
- 邮件/企微通知(仅站内预警)
---
## 第 10 章 风险与工程约束
| 风险 | 影响 | 缓解 |
|------|------|------|
| RPA 被 Mothership 反爬 | RPA 成功率 <85% | L3 stale + 人工兜底;熔断 |
| Playwright 内存占用 | Worker OOM | `shm_size: 1gb`;并发=2 |
| Redis 单点 | 缓存/队列不可用 | 生产启用 AOF 持久化 |
| 非程序员维护 | 部署失败 | 仅暴露 `docker compose` 三条命令 |
| 3 周工期紧 | 功能砍减 | 严格按 §9.4 不扩展 |
---
## 第 11 章 测试与 CI
### 11.1 测试分层
| 层级 | 范围 | 工具 |
|------|------|------|
| Unit | pricingvalidationcargo_hash | Vitest |
| Integration | API + Prisma + Redis | Vitest + Testcontainers |
| E2E | 查价页轮询(RPA mock | Playwright |
| Load | 60 QPS 限流 | k6 |
### 11.2 CI 门禁GitHub Actions 示意)
```yaml
# .github/workflows/ci.yml
- npm run lint
- npm run test:unit
- npm run test:integration
- npm run build
```
上线前手动:PRD §12.7 Go/No-Go 指标。
---
## 第 12 章 Cursor 开发指引
### 12.1 推荐开发顺序
1. `prisma/schema.prisma` migrate
2. `modules/quote/validation.ts` + `types.ts`
3. `modules/cache/redis-cache.ts`
4. `modules/quote/orchestrator.ts`
5. `app/api/quotes/route.ts`
6. **`scripts/record-mothership.sh` `.env` RPA 变量**GD-15 前置)
7. `workers/rpa/quote-page-adapter.ts` + `session-manager.ts`
8. `workers/rpa/mothership.ts`
9. `scripts/probe-rpa.ts`TC-601
10. 宿主内嵌查价组件 + 管理端
### 12.2 Cursor Rules 建议(`.cursor/rules/chajia.mdc`
```
- 严格遵循 docs/查价系统-PRD.md v0.6 与 docs/查价系统-技术设计.md v1.2
- 金额使用 DECIMAL禁止 float 计算
- 缓存优先级 L1 > L2 > L3service_level 不入 cargo_hash
- 一次 RPA 返回四档standard/guaranteed × lowest/fastest禁止多次 RPA
- 地址必须联想点选cargo_hash 含 place_id
- MOTHERSHIP_QUOTE_URLS 必填;禁止 dashboard / /quote 默认 URL
- login 无凭据 → STRUCT_CHANGE禁止误报 SESSION_EXPIRED
- pallet_count 替代 quantity
- API 统一响应包络 { code, message, data }
- 所有 async 必须 catch 并 log禁止空 catch
```
### 12.3 单任务拆分示例(可直接建 Issue
| Issue | 描述 | 文件 |
|-------|------|------|
| CHJ-001 | Prisma schema + seed | `prisma/` |
| CHJ-002 | Redis 三层缓存 | `modules/cache/` |
| CHJ-003 | QuoteOrchestrator | `modules/quote/` |
| CHJ-004 | POST/GET quotes API | `app/api/quotes/` |
| CHJ-005 | QuotePageAdapter + RPA Worker | `workers/rpa/` |
| CHJ-005b | probe-rpa 探针 | `scripts/probe-rpa.ts` |
| CHJ-006 | 查价前端页 | `app/page.tsx` |
| CHJ-007 | 加价管理端 | `app/admin/markup/` |
| CHJ-008 | 预警中心 | `app/admin/alerts/` |
---
## 附录 A与 PRD 章节映射
| PRD 章节 | 技术设计章节 |
|----------|--------------|
| 工程收敛决策 GD-1~15 | §1、§3.7、§4.5、§7 |
| 8 技术架构 / §8.2 RPA | §7.27.5 |
| 9 数据模型 | §3 |
| 8.5 API | §4 |
| 11 异常场景 | §2.4、§7.3 |
| 12 验收标准 | §9、§11 |
| 13 交付计划 | §9 |
| 14 Flock FreightGD-16 | §7.6 |
## 附录 B货物类型枚举GD-8
`general_freight` | `machinery` | `furniture` | `electronics` | `building_materials` | `auto_parts` | `food_nonperishable` | `apparel` | `other`
## 附录 C种子数据seed.ts
| 用户 | 角色 | 用途 |
|------|------|------|
| admin_demo | admin | 管理端:预警/RPA/开发进度 |
默认密码:`Demo@123`(仅开发环境)
> 查价联调使用宿主模拟 `customer_id` + Service Token**不创建** customer_demo / operator_demo。
## 附录 Dv1.1 → v1.2 变更追踪(对齐 PRD v0.6
| ID | 章节 | Before (v1.1) | After (v1.2) |
|----|------|---------------|--------------|
| CHG-T01 | 版本 | v1.1 | v1.2 |
| CHG-T02 | §5 目录 | adapter/probe | 新增 `quote-page-adapter.ts`、`probe-rpa.ts`、`record-mothership.sh` |
| CHG-T03 | §6.2 env | EMAIL/PASSWORD | `MOTHERSHIP_QUOTE_URLS`、`RPA_STORAGE_STATE_PATH`、`RPA_SELECTOR_*`、`RPA_USE_PATCHRIGHT` |
| CHG-T04 | §6.1 compose | RPA env/volume | rpa-worker 注入 RPA env + `rpa_state` volume |
| CHG-T05 | §7.2 | URL preCheck 隐含 dashboard | QuotePageAdapter URL;禁止 dashboard 默认 |
| CHG-T06 | §7.2 | adapter 接口 | 新增 §7.2.3 QuotePageAdapter + selector env |
| CHG-T07 | §7.2.1 | 硬编码 data-testid | 全部改为 `RPA_SELECTOR_*` env |
| CHG-T08 | §7.3 | SESSION_EXPIRED login 无凭据 | login 无凭据→STRUCT_CHANGE;新增 QUOTE_ENTRY_UNAVAILABLE |
| CHG-T09 | §7.5 | | storageState 生命周期 |
| CHG-T10 | §8.2 | 无队列页 | `/admin/queues` Bull BoardP1 |
| CHG-T11 | §9.2 | RPA 直接开发 | Week2 前置 headed 录制 adapter probe 3/3 |
| CHG-T12 | §12.1/12.2 | mothership 先于 env | 录制 env 先于 RPA 实现 |
## 附录 Ev1.2 → v1.3 变更追踪(对齐 PRD v0.7
| ID | 章节 | Before (v1.2) | After (v1.3) |
|----|------|---------------|--------------|
| CHG-T13 | 版本 | v1.2 | v1.3 |
| CHG-T14 | §5 目录 | mothership provider | `flock-freight*`、`flock-limits`、`probe-flock` / `record-flock` |
| CHG-T15 | §7.6 | | 新增 FlockFreightRPAProvider:硬限、步骤、env、队列隔离 |
| CHG-T16 | §9.4 | 未列 Flock | MVP 明确不做 Flock 实现(文档先行) |