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

38 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 门禁

配套文档:查价系统-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_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
├── workers/
│   ├── rpa/
│   │   ├── index.ts              # BullMQ consumer 入口
│   │   ├── mothership.ts         # Playwright 脚本
│   │   ├── 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
├── 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
# 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
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 次。


第 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
  • 嵌入私仓预报
  • 邮件/企微通知(仅站内预警)

第 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

附录 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 实现