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

45 KiB

查价系统 技术设计 v1.2

版本 日期 作者 说明
v1.0 2026-06-16 全栈架构师/Tech Lead 可直接用于 Cursor 编码、建库、启动项目
v1.1 2026-06-16 全栈架构师 对齐 PRD v0.4四档报价、地址联想、托盘数、嵌入宿主、Mothership 官方限制
v1.2 2026-06-17 全栈架构师/SRE 对齐 PRD v0.6QuotePageAdapter、多 URL 探测、storageState、Selector env、probe 门禁
v1.3 2026-07-13 全栈架构师 对齐 PRD v0.7:新增 §7.6 Flock Freight RPA 模块(硬限、两档映射、独立 env

配套文档:查价系统-PRD.md(产品需求、异常设计、验收标准)· 查价系统-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_suggestionsRPA 须点击联想项
计量 pallet_count(托盘数);单托 weight/dims校验见 PRD §4.2.8

第 1 章 技术选型

1.1 前端框架

方案 优势 劣势 上手难度 MVP 适配性 Cursor AI 支持度
React + Vite 灵活、生态大 需单独配置路由/API
Vue 3 + Vite 模板直观 全栈示例相对少
Next.js 15 (App Router) 前后端同仓、API Routes、类型安全、部署简单 App Router 概念需熟悉 低-中 最高 最高

最终推荐Next.js 15 (App Router)
理由与后端同仓库Cursor 可一次生成页面 + API社区示例最多满足 PRD 客户端 + 管理端双入口。

1.2 后端技术

方案 优势 劣势 上手难度 MVP 适配性 Cursor AI 支持度
Node.js + Express 轻量 需手动搭中间件/分层
Python + FastAPI 文档自动生成 与 Next 异构,双仓维护
Java + Spring Boot 企业级稳定 配置重、启动慢
Next.js API Routes + 独立 Worker 进程 同语言、同类型、AI 生成率高 大规模需拆分 最高 最高

最终推荐Next.js API RoutesWeb+ workers/rpa 独立 Node 进程BullMQ Consumer
理由Web 层处理 HTTP/鉴权/编排RPA 长任务独立进程,避免 Serverless 超时;非程序员只需 docker compose up

1.3 数据库

方案 快速建模 AI 生成 Schema MVP 适配 最终
PostgreSQL
MySQL 8.0 最高Prisma 示例最多) 最高
Supabase 最高(托管) vendor lock-in

最终推荐MySQL 8.0 + Prisma ORM
理由PRD 已定 MySQLPrisma migration 可由 Cursor 直接生成Docker 镜像小。

1.4 ORM / 队列 / 缓存

组件 候选 最终推荐 理由
ORM Prisma / Drizzle / TypeORM Prisma Cursor 生成成功率最高
队列 BullMQ / Bee-Queue BullMQ PRD 已定Redis 原生
缓存 Redis / Memcached Redis 同时支撑队列 + L1/L2/L3
RPA Playwright / Puppeteer Playwright PRD 已定;维护性更好

1.5 部署方案

方案 非运维友好度 本地=生产一致性 最终
Vercel 最高 Worker/RPA 难部署)
Railway
Docker Compose + VPS 高(一条命令) 最高
AWS ECS

最终推荐Docker Compose
理由Next.js + MySQL + Redis + RPA Worker 四容器本地与生产一致;docker compose up -d 即可启动。


第 2 章 系统架构设计

2.1 总体架构

┌─────────────────────────────────────────────────────────────┐
│  宿主业务系统(内嵌查价组件 / 服务端调用)                      │
│  └─ Service Token + customer_id 透传                          │
└───────────────────────────┬─────────────────────────────────┘
                            │ HTTPS /api/*
┌───────────────────────────▼─────────────────────────────────┐
│  next-app (Next.js 15)                                        │
│  ├─ middleware.ts        宿主鉴权 + 管理员鉴权 + 限流           │
│  ├─ app/api/quotes/*     询价 API                             │
│  ├─ app/api/markup-configs/*  (宿主调用,无运营登录页)       │
│  ├─ app/api/alerts/*     管理员                               │
│  └─ app/admin/*          管理端(预警/RPA/开发进度)           │
└───────────────┬─────────────────────────┬─────────────────────┘
                │                         │
        ┌───────▼───────┐         ┌───────▼────────┐
        │  MySQL 8.0    │         │  Redis 7        │
        └───────────────┘         └───────┬────────┘
                                          │
                                ┌─────────▼──────────┐
                                │  rpa-worker        │
                                │  Playwright        │
                                │  MothershipRPA     │
                                │  · 地址联想点选     │
                                │  · 四档抓取         │
                                └─────────┬──────────┘
                                          │ RPA
                                ┌─────────▼──────────┐
                                │  Mothership Web    │
                                └────────────────────┘

2.2 模块划分

模块 职责 代码路径
鉴权 JWT 签发/校验、RBAC modules/auth/middleware.ts
询价编排 校验→缓存→落库→入队→轮询查询 modules/quote/orchestrator.ts
缓存 L1/L2/L3 读写、击穿锁 modules/cache/redis-cache.ts
幂等 request_id 24h modules/quote/idempotency.ts
加价 运费百分比计算 modules/pricing/engine.ts
预警 alert_log 写入、偏差检测 modules/alert/service.ts
RPA BullMQ 消费、Playwright 抓取 workers/rpa/
定时任务 processing 超时、熔断恢复 workers/scheduler/

2.3 核心数据流(询价)

1. POST /api/quotes
2. ValidationModule格式校验 + in/lb 换算 + cargo_hash
3. IdempotencyModule查 L1(request_id) → 命中返回 quote_id
4. CacheModule查 L2(cargo_hash) → 命中PricingEngine 加价 → 落库 → 写 L1 → done
5. 未命中INSERT quote_record(processing) → BullMQ.add('quote', {quote_id})
6. 返回 { quote_id, status: processing }

--- 异步 ---

7. rpa-worker 消费 job
8. MothershipRPAProvider.getQuote() → 四档原始价standard/guaranteed × lowest/fastest
9. 一致性校验 → 写 L2(3min+jitter)/L3(30min) → UPDATE quote_record(done)
10. quote_cache_meta 更新 → 偏差≥5% 写 PRICE_DEVIATION
11. 写 L1(request_id)

--- 客户端 ---

12. GET /api/quotes/{quote_id} 每 2s 轮询,最长 30s
13. valid_until 过期 → status=expired查询时计算

2.4 Fallback 数据流

RPA 抛错(超时/验证码/STRUCT_CHANGE/DATA_INVALID
  → 读 L3 stale(cargo_hash)
  → 有done + source_type=stale + is_realtime=false + STALE_FALLBACK 告警
  → 无failed + QUOTE_UNAVAILABLE + RPA_FAILED 告警
验证码额外:暂停 Worker 10min + RPA_CAPTCHA 告警
连续失败≥3熔断 10min期间不分配 RPA job

2.5 第三方依赖

依赖 用途 版本
Mothership Web RPA 报价源 外部
Playwright 浏览器自动化 ^1.44
BullMQ 任务队列 ^5.x
ioredis Redis 客户端 ^5.x
Prisma ORM ^5.x
jose JWT ^5.x
zod 入参校验 ^3.x

第 3 章 数据库设计

3.1 ER 关系

sys_user (1) ──< quote_record (N)
markup_config (1) ── (1) customer_id 逻辑关联
quote_record (N) ── (0..1) alert_log
quote_cache_meta (1) ── (1) cargo_hash
idempotency_record (1) ── (1) request_id

3.2 表sys_user鉴权 MVP

v1.1:本期仅保留 admin 管理端登录;不提供 customer/operator 业务账号。查价 API 使用宿主 Service Token(见 §4.2)。

字段 类型 约束 说明
id BIGINT UNSIGNED PK
user_id VARCHAR(32) UNIQUE, NOT NULL 对外 ID
customer_id VARCHAR(32) UNIQUE, NULL 保留字段;宿主 token 场景不使用
username VARCHAR(64) UNIQUE, NOT NULL 登录名
password_hash VARCHAR(255) NOT NULL bcrypt
role VARCHAR(16) NOT NULL admin(本期唯一登录角色)
is_deleted TINYINT(1) DEFAULT 0
created_at DATETIME NOT NULL
updated_at DATETIME NOT NULL

索引:uk_user_iduk_usernameuk_customer_id

3.3 表quote_record

与 PRD §9.1 一致,见下方 Prisma + SQL。

3.4 表idempotency_record / quote_cache_meta / markup_config / alert_log

与 PRD §9.29.5 一致。

3.5 Prisma Schema可直接 prisma migrate dev

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_levelrate_optioncustomer_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 完整 QuoteResponsePRD §5.2valid_until < nowstatus=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

内容
权限 adminalert: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
│       └── flock-freight-rpa-provider.ts   # Phase 扩展PRD §14
├── workers/
│   ├── rpa/
│   │   ├── index.ts              # BullMQ consumer 入口
│   │   ├── mothership.ts         # Playwright 脚本
│   │   ├── flock-freight.ts      # Flock get-a-quote RPAPRD §14
│   │   ├── quote-page-adapter.ts # 多 URL 探测 + preCheckPRD GD-15
│   │   └── session-manager.ts    # BrowserContext + storageState
│   └── scheduler/
│       └── timeout-sweeper.ts    # processing >30s 标记失败
├── lib/
│   ├── prisma.ts
│   ├── redis.ts
│   ├── response.ts
│   └── constants/
│       ├── mothership-limits.ts
│       └── flock-limits.ts       # PRD §14.3
├── components/
│   ├── quote-form.tsx
│   ├── quote-result.tsx
│   └── countdown.tsx
├── types/
│   └── api.ts
└── scripts/
    ├── dev.sh
    ├── migrate.sh
    ├── probe-rpa.ts              # RPA 探针TC-601 / Go/No-Go
    ├── probe-flock-rpa.ts        # Flock 探针PRD §14.7
    ├── record-mothership.sh      # Playwright codegen 录制入口
    └── record-flock.sh           # Flock codegen 录制入口

5.1 目录职责

目录 职责 Cursor 提示词示例
app/api/ HTTP 路由薄层,只调 modules 「在 app/api/quotes/route.ts 实现 POST调用 orchestrator」
modules/ 业务逻辑 「在 modules/quote/orchestrator.ts 实现 L1→L2→入队」
workers/rpa/ 长任务 「实现 MothershipRPAProvider一次 RPA 返回四档 + 地址联想点选」
prisma/ 数据模型 「按技术设计 schema 生成 migration」
components/ UI 组件 「实现查价表单,含单位切换与防重复提交」

第 6 章 Docker Compose 与启动

6.1 docker-compose.yml

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
# RPAPRD v0.6 GD-15必填headed 录制后填入)
MOTHERSHIP_QUOTE_URLS=
RPA_STORAGE_STATE_PATH=.rpa/mothership-storage.json
RPA_MOCK_MODE=false
RPA_HEADLESS=true
# SelectorPlaywright codegen 产出,见 §7.2.3
RPA_SELECTOR_PICKUP_STREET=
RPA_SELECTOR_DELIVERY_STREET=
RPA_SELECTOR_ADDRESS_SUGGESTION=
RPA_SELECTOR_SUBMIT=
RPA_SELECTOR_STANDARD_TAB=
RPA_SELECTOR_GUARANTEED_TAB=
RPA_SELECTOR_LOWEST_OPTION=
RPA_SELECTOR_FASTEST_OPTION=
RPA_SELECTOR_PRICE=
# 可选:仅当入口跳转 login 时使用
MOTHERSHIP_EMAIL=
MOTHERSHIP_PASSWORD=
# 可选Patchright 反检测P1
RPA_USE_PATCHRIGHT=false
# Flock FreightPRD v0.7 §14 / 技术设计 §7.6;默认关闭)
FLOCK_RPA_ENABLED=false
FLOCK_QUOTE_URL=https://app.flockfreight.com/get-a-quote
FLOCK_STORAGE_STATE_PATH=.rpa/flock-storage.json
# FLOCK_SELECTOR_* 由 record-flock.sh 录制后填入
DATABASE_URL=mysql://root:changeme@localhost:3306/chajia
REDIS_URL=redis://localhost:6379

6.3 启动命令

# 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.comwww.mothership.com/quotesession-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. preCheck404 / 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_countcargo_type
  6. click(RPA_SELECTOR_SUBMIT),等待结果页
  7. standard / guaranteed 区块:分别点选 lowest/fastest各抓取价格与时效
  8. 校验:rawFreight > 0四档均存在
  9. 返回 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_STREETRPA_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 400Worker → L3
PAGE_LOAD_TIMEOUT 重试 1 次 → L3
RPA_CAPTCHA 停 Worker 10min → L3
STRUCT_CHANGE 不重试(含 login 无凭据、404、selector 缺失)→ L3 + 告警
RPA_DATA_INVALID L3
SESSION_EXPIRED 已配置凭据且曾成功登录时:重登 1 次 → 失败则 L3

7.5 storageState 生命周期

// 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
  • Dockerrpa_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 次。

7.6 FlockFreightRPAProviderPRD §14 / GD-16Phase 扩展)

状态已落地API / 校验 / 嵌入 UI / Direct user/quotes JSON 优先 + DOM 回退)。生产开启须 FLOCK_RPA_ENABLED=true;探针 probe:flock-rpa / batch:flock-direct-10

7.6.1 职责与文件

文件 职责
modules/flock/orchestrator.ts 入队 flock-quote
lib/flock/direct-quote.ts Directbootstrap cookie → POST api.flockfreight.com/user/quotes
lib/flock/quote-payload.ts / map-fulfillment-options.ts 请求体与两档映射
workers/rpa/flock/run-quote.ts FLOCK_QUOTE_MODEdirect / 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 硬限常量

/** 来源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.tsprovider=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 加价写库;不填表、不注册

回退 DOMFLOCK_QUOTE_MODE=direct_then_dom|dom

  1. 配置了 FLOCK_LOGIN_EMAIL / FLOCK_LOGIN_PASSWORD 时:先登录workers/rpa/flock/login.ts),再填表抓价;禁止 random_qq / 盲填注册;成功可写回 FLOCK_STORAGE_STATE_PATH
  2. 无登录凭据时:goto(FLOCK_QUOTE_URL)preCheck 失败 → QUOTE_ENTRY_UNAVAILABLE
  3. 取货日期FLOCK_SELECTOR_PICKUP_DATE
  4. 取件 ZIP / 投递 ZIP无街道联想ZIP 非法由中台先 400
  5. 托盘数量总运输重量(lb)单托 L/W/H(in)(默认官网 48/40/48可覆盖
  6. click(FLOCK_SELECTOR_NEXT)等待结果页「Explore your custom quote options」
  7. 解析两卡:
    • FlockDirect® → { serviceLevel:'guaranteed', rateOption:'fastest', carrier:'Flock Freight', rawFreight, transitDays }
    • Standard → { serviceLevel:'standard', rateOption:'lowest', carrier:'Flock Freight', ... }
  8. 可选抓取 reference(如 FRG-XXXX)写入 quote_record 扩展字段或日志
  9. 校验至少 FLOCK_MIN_QUOTES 档且价格 >0否则失败
  10. 停止边界不点击「Complete your order」避免进入注册/下单)

Direct 仍匿名 bootstrapDOM 保障路径优先使用 FLOCK_LOGIN_*。自动注册仅用于无登录凭据的探针脚本。

7.6.4 环境变量

env 必填 说明
FLOCK_QUOTE_URL 是(启用时) 默认 https://app.flockfreight.com/get-a-quote
FLOCK_QUOTE_MODE direct / dom / direct_then_dom默认Direct 优先)
FLOCK_LOGIN_EMAIL / FLOCK_LOGIN_PASSWORD DOM 保障建议 账号密码登录;配置后禁用无痕随机注册
FLOCK_LOGIN_URL 默认 https://app.flockfreight.com/login
FLOCK_STORAGE_STATE_PATH 默认 .rpa/flock-storage.json
FLOCK_RPA_ENABLED 默认 falsetrue 才注册 Provider / 消费队列
FLOCK_SELECTOR_PICKUP_DATE 启用时是 取货日期
FLOCK_SELECTOR_PICKUP_ZIP 启用时是 取件 ZIP
FLOCK_SELECTOR_DELIVERY_ZIP 启用时是 投递 ZIP
FLOCK_SELECTOR_PALLET_COUNT 启用时是 托盘数
FLOCK_SELECTOR_TOTAL_WEIGHT 启用时是 总重量 lb
FLOCK_SELECTOR_LENGTH / _WIDTH / _HEIGHT 启用时是 单托尺寸
FLOCK_SELECTOR_NEXT 启用时是 「下一个」
FLOCK_SELECTOR_CARD_DIRECT / _STANDARD 启用时是 结果卡根节点
FLOCK_SELECTOR_CARD_PRICE / _TRANSIT 启用时是 卡内价格/时效
FLOCK_SELECTOR_LOGIN_EMAIL / _PASSWORD / _SUBMIT 登录表单 selector 覆盖

7.6.5 队列与隔离

规则
队列名 flock-quote-rpa(与 quote-rpa 分离)
Worker 可同进程分 consumer或独立容器concurrency 默认 1
缓存 key cargo_hash 须含 provider=flock 前缀或独立命名空间,避免与 Mothership 串缓存
开关 FLOCK_RPA_ENABLED=false 时零副作用

7.6.6 验收

  • npm run probe:flock-rpa(或等价)连续 3 次 exit 0
  • 单测:FLOCK_LIMITS 边界3/21 托、45001 lb、637/103/109 in
  • 回归:关闭开关后 Mothership probe / embed-demo 不受影响

第 8 章 前端实现规格

8.1 宿主内嵌查价组件

  • 由宿主系统加载;地址组件须支持 Places 联想点选(见 PRD §4.2.1 方案 A
  • 表单字段:pallet_count(托盘数)、单托 weight/dims支持 kg/cm 展示,提交前换算)
  • 结果区:四档 Tabstandard/guaranteed × lowest/fastest
  • 状态机见 查价系统-UI设计.md §2.4(待 UI 文档同步 v1.1

8.2 管理端(本期唯一登录入口)

路由 功能
/admin/alerts 预警列表、标记已处理
/admin/rpa RPA Worker 状态、队列、成功率(开发进度)
/admin/dashboard 汇总指标
/admin/queues Bull Board 队列监控P1开发/运维)

移除 /admin/markup 运营登录页;加价由宿主 PUT /api/markup-configs 配置。

8.3 技术栈

  • UITailwind CSS + shadcn/ui
  • 表单react-hook-form + zod
  • 请求fetch + 自定义 hook useQuotePolling

第 9 章 MVP 实现路径

9.1 Week 1基础链路

序号 任务 产出 验收 TC
1 docker-compose + Prisma migrate 5 表 + sys_user -
2 POST/GET /api/quotes 骨架 API 通 TC-101
3 validation + cargo_hash 入参校验 TC-201~204
4 Redis L1/L2/L3 + 击穿锁 缓存模块 TC-104, TC-105
5 idempotency_record 幂等 TC-207, TC-404

9.2 Week 2RPA + 加价

序号 任务 产出 验收 TC
6 headed 录制 + env record-mothership.shMOTHERSHIP_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 示意)

# .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.tsTC-601
  10. 宿主内嵌查价组件 + 管理端

12.2 Cursor Rules 建议(.cursor/rules/chajia.mdc

- 严格遵循 docs/查价系统-PRD.md v0.6 与 docs/查价系统-技术设计.md v1.2
- 金额使用 DECIMAL禁止 float 计算
- 缓存优先级 L1 > L2 > L3service_level 不入 cargo_hash
- 一次 RPA 返回四档standard/guaranteed × lowest/fastest禁止多次 RPA
- 地址必须联想点选cargo_hash 含 place_id
- MOTHERSHIP_QUOTE_URLS 必填;禁止 dashboard / /quote 默认 URL
- login 无凭据 → STRUCT_CHANGE禁止误报 SESSION_EXPIRED
- pallet_count 替代 quantity
- API 统一响应包络 { code, message, data }
- 所有 async 必须 catch 并 log禁止空 catch

12.3 单任务拆分示例(可直接建 Issue

Issue 描述 文件
CHJ-001 Prisma schema + seed prisma/
CHJ-002 Redis 三层缓存 modules/cache/
CHJ-003 QuoteOrchestrator modules/quote/
CHJ-004 POST/GET quotes API app/api/quotes/
CHJ-005 QuotePageAdapter + RPA Worker workers/rpa/
CHJ-005b probe-rpa 探针 scripts/probe-rpa.ts
CHJ-006 查价前端页 app/page.tsx
CHJ-007 加价管理端 app/admin/markup/
CHJ-008 预警中心 app/admin/alerts/

附录 A与 PRD 章节映射

PRD 章节 技术设计章节
工程收敛决策 GD-1~15 §1、§3.7、§4.5、§7
第 8 章 技术架构 / §8.2 RPA §7.2~§7.5
第 9 章 数据模型 §3
第 8.5 章 API §4
第 11 章 异常场景 §2.4、§7.3
第 12 章 验收标准 §9、§11
第 13 章 交付计划 §9
第 14 章 Flock FreightGD-16 §7.6

附录 B货物类型枚举GD-8

general_freight | machinery | furniture | electronics | building_materials | auto_parts | food_nonperishable | apparel | other

附录 C种子数据seed.ts

用户 角色 用途
admin_demo admin 管理端:预警/RPA/开发进度

默认密码:Demo@123(仅开发环境)

查价联调使用宿主模拟 customer_id + Service Token不创建 customer_demo / operator_demo。

附录 Dv1.1 → v1.2 变更追踪(对齐 PRD v0.6

ID 章节 Before (v1.1) After (v1.2)
CHG-T01 版本 v1.1 v1.2
CHG-T02 §5 目录 无 adapter/probe 新增 quote-page-adapter.tsprobe-rpa.tsrecord-mothership.sh
CHG-T03 §6.2 env 仅 EMAIL/PASSWORD MOTHERSHIP_QUOTE_URLSRPA_STORAGE_STATE_PATHRPA_SELECTOR_*RPA_USE_PATCHRIGHT
CHG-T04 §6.1 compose 无 RPA env/volume rpa-worker 注入 RPA env + rpa_state volume
CHG-T05 §7.2 单 URL preCheck 隐含 dashboard QuotePageAdapter 多 URL禁止 dashboard 默认
CHG-T06 §7.2 无 adapter 接口 新增 §7.2.3 QuotePageAdapter + selector env 表
CHG-T07 §7.2.1 硬编码 data-testid 全部改为 RPA_SELECTOR_* env
CHG-T08 §7.3 SESSION_EXPIRED 含 login 无凭据 login 无凭据→STRUCT_CHANGE新增 QUOTE_ENTRY_UNAVAILABLE
CHG-T09 §7.5 storageState 生命周期
CHG-T10 §8.2 无队列页 /admin/queues Bull BoardP1
CHG-T11 §9.2 RPA 直接开发 Week2 前置 headed 录制 → adapter → probe 3/3
CHG-T12 §12.1/12.2 mothership 先于 env 录制 env 先于 RPA 实现

附录 Ev1.2 → v1.3 变更追踪(对齐 PRD v0.7

ID 章节 Before (v1.2) After (v1.3)
CHG-T13 版本 v1.2 v1.3
CHG-T14 §5 目录 仅 mothership provider flock-freight*flock-limitsprobe-flock / record-flock
CHG-T15 §7.6 新增 FlockFreightRPAProvider硬限、步骤、env、队列隔离
CHG-T16 §9.4 未列 Flock MVP 明确不做 Flock 实现(文档先行)