查价系统 技术设计 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 门禁 |
配套文档:查价系统-PRD.md(产品需求、异常设计、验收标准)· 查价系统-UI设计.md(页面、交互、设计规范)
本文档为技术实现唯一依据;与 PRD 冲突时以 PRD 业务规则为准,技术实现细节以本文档为准。
文档约定
| 项 |
约定 |
| 技术栈 |
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)
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 算法
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 统一响应
// 成功
{ "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 加价计算实现
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
├── workers/
│ ├── rpa/
│ │ ├── index.ts # BullMQ consumer 入口
│ │ ├── mothership.ts # Playwright 脚本
│ │ ├── 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
├── 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)
└── record-mothership.sh # Playwright 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
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
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
DATABASE_URL=mysql://root:changeme@localhost:3306/chajia
REDIS_URL=redis://localhost:6379
6.3 启动命令
# 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)
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 接口
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/单测。
QuotePageAdapter.resolveQuoteEntry():按序 goto + preCheck,首个可用 URL 为 activeUrl;全失败 → QUOTE_ENTRY_UNAVAILABLE
session-manager.createContext():若 storageState 存在则 storageState: path;成功后 context.storageState({ path })
- preCheck:404 / login 且无凭据 →
STRUCT_CHANGE(非 SESSION_EXPIRED);表单 selector 不可见 → STRUCT_CHANGE
- 地址(关键):
- 若入参含
place_id:优先用 Mothership 已选地址回填/校验
- 否则:
RPA_SELECTOR_PICKUP_STREET / DELIVERY_STREET 键入 → waitForSelector(RPA_SELECTOR_ADDRESS_SUGGESTION) → click 含 zip 项(禁止 Enter)
- 断言:联想下拉消失;隐藏字段或展示文本已锁定;失败 →
ADDRESS_SUGGESTION_NOT_FOUND
- 填入单托 weight(lb)/dims(in)、
pallet_count、cargo_type
click(RPA_SELECTOR_SUBMIT),等待结果页
- 在 standard / guaranteed 区块:分别点选 lowest/fastest,各抓取价格与时效
- 校验:
rawFreight > 0;四档均存在
- 返回
items[4]
7.2.3 QuotePageAdapter 接口
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:
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 生命周期
// 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 结构
interface QuoteJobData {
quoteId: string;
requestId: string;
customerId: string;
cargoHash: string;
cargo: NormalizedCargo;
}
队列名:quote-rpa;并发:2;单 job 超时:25s;重试:1 次。
第 8 章 前端实现规格
8.1 宿主内嵌查价组件
- 由宿主系统加载;地址组件须支持 Places 联想点选(见 PRD §4.2.1 方案 A)
- 表单字段:
pallet_count(托盘数)、单托 weight/dims(支持 kg/cm 展示,提交前换算)
- 结果区:四档 Tab(standard/guaranteed × lowest/fastest)
- 状态机见 查价系统-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
- 嵌入私仓预报
- 邮件/企微通知(仅站内预警)
第 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 示意)
# .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 推荐开发顺序
prisma/schema.prisma → migrate
modules/quote/validation.ts + types.ts
modules/cache/redis-cache.ts
modules/quote/orchestrator.ts
app/api/quotes/route.ts
scripts/record-mothership.sh → .env RPA 变量(GD-15 前置)
workers/rpa/quote-page-adapter.ts + session-manager.ts
workers/rpa/mothership.ts
scripts/probe-rpa.ts(TC-601)
- 宿主内嵌查价组件 + 管理端
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 |
附录 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 实现 |