# 查价系统 技术设计 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](./查价系统-PRD.md)(产品需求、异常设计、验收标准)· [查价系统-UI设计.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`) ```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 X-Customer-Id: CUST_001 // 或由请求体 customer_id 携带,须与 token 租户绑定 ``` **管理端 API**: ``` Authorization: Bearer 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 ├── 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 ```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 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; } ``` ### 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; /** 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 次。 --- ## 第 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 - 嵌入私仓预报 - 邮件/企微通知(仅站内预警) --- ## 第 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 | ## 附录 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 实现 |