# 查价系统 技术设计 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.6：QuotePageAdapter、多 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 Routes（Web）+ `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 已定 MySQL；Prisma 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.2–9.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 | 完整 QuoteResponse（PRD §5.2）；`valid_until < now` 时 `status=expired` |
| 错误 | 403 FORBIDDEN、404 QUOTE_NOT_FOUND |

#### GET /api/quotes/history?page=1&size=20

| 项 | 内容 |
|----|------|
| 权限 | 宿主 Service Token |
| 必填 | page、size（缺省 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 RPA（PRD §14）
│   │   ├── quote-page-adapter.ts # 多 URL 探测 + preCheck（PRD 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
# RPA（PRD v0.6 GD-15，必填：headed 录制后填入）
MOTHERSHIP_QUOTE_URLS=
RPA_STORAGE_STATE_PATH=.rpa/mothership-storage.json
RPA_MOCK_MODE=false
RPA_HEADLESS=true
# Selector（Playwright 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 Freight（PRD 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 400；Worker → 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 FlockFreightRPAProvider（PRD §14 / GD-16，Phase 扩展）

> **状态**：已落地（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` | Direct：bootstrap 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 仍匿名 bootstrap；DOM 保障路径优先使用 `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 展示，提交前换算）
- 结果区：四档 Tab（standard/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 技术栈

- UI：Tailwind 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 2：RPA + 加价

| 序号 | 任务 | 产出 | 验收 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 | pricing、validation、cargo_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 > L3，service_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.2~§7.5 |
| 第 9 章 数据模型 | §3 |
| 第 8.5 章 API | §4 |
| 第 11 章 异常场景 | §2.4、§7.3 |
| 第 12 章 验收标准 | §9、§11 |
| 第 13 章 交付计划 | §9 |
| 第 14 章 Flock Freight（GD-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。

## 附录 D：v1.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 Board（P1） |
| CHG-T11 | §9.2 | RPA 直接开发 | Week2 前置 headed 录制 → adapter → probe 3/3 |
| CHG-T12 | §12.1/12.2 | mothership 先于 env | 录制 env 先于 RPA 实现 |

## 附录 E：v1.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 实现（文档先行） |
