|
|
# 查价系统 技术设计 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 实现(文档先行) |
|