cursor-Playwright自动化框架相关的skills

我爱海鲸 2026-09-04 18:29:59 暂无标签

简介nodejs、node、Playwright、pw

可以把技能放到本机用户目录的 ~/.cursor/skills/<技能目录>/(Windows 一般是 C:\Users\你的用户名\.cursor\skills\)。这样任意项目打开后,只要 Cursor 会加载用户级 skills,就不必每个仓库复制一份。


注意:~/.cursor/skills-cursor/ 是 Cursor 内置技能目录,不要把自己的技能放进去;自定义技能用 项目 .cursor/skills/ 或 用户 ~/.cursor/skills/。

在项目的根目录下创建:.cursor\skills\playwright-h5-api-automation

SKILL.md

---
name: playwright-h5-api-automation
description: Scaffolds Playwright + Express services that automate H5 landing pages (send-code, submit-order) via browser replay and downstream API capture. Use when building H5 order automation, Playwright API proxy, browser pool, h5Param device simulation, console tracking log capture, channel/touchpoint param injection, multi-worker concurrency, or Docker headless browser services for similar requirements.
---

# Playwright H5 自动化 API 服务

将「打开 H5 → 填表 → 点击 → 捕获下游接口 + 插码 console」封装为 HTTP API。适用于落地页链接固定、触点参数由调用方传入、需服务端无头运行的场景。

## 何时使用

- 需求是:**发码/办理** 必须走真实 H5 + 页面 SDK,不能直接调下游(或需兜底)
- 对外提供 REST API,入参为手机号、验证码、自定义 `orderId` 等
- 落地页 URL **不能** 挂业务参数,触点需 API 注入
- 需要 H5 设备指纹模拟(UA / 视口 / 网络)、插码 SDK console 回传
- 需要 Docker 部署、文件日志、单容器多 Worker 或水平扩容

## 架构(五层)

```
Express API (server/index.js)
    ↓
Automation core (scripts/*-automation.js)  — 发码/办理、响应组装
    ↓
Browser pool (scripts/browser-pool.js)     — 多 Worker + 会话亲和 + 页面缓存
    ↓
h5-param / console-capture                 — 设备模拟 + 插码日志采集
    ↓
Playwright Chromium                          — 打开 H5、点击、拦截 XHR
```

| 能力 | 实现要点 |
|------|----------|
| 发码 | 打开页 → 填手机号 → 点发送 → 捕获 `POST .../sms/send` |
| 办理 | 按 `orderId` 路由到发码 Worker → 填验证码 → 点办理 → 捕获 verify 接口 |
| 兜底 | 无会话时用页面内 `fetch` 直调办理接口(内部补全下游 orderId/serialId) |
| 会话 | 发码成功后保存 `{ clientOrderId, phone, pageUrl, downstreamOrderId, serialId, h5Param }`,TTL 10 分钟 |
| 并发 | `WORKER_COUNT` 个 Worker 并行;每 Worker 独立 page + 会话;校验 `preferWorkerId` 亲和 |
| 插码 | `console-capture.js` 监听 `page.on('console')`,响应顶层追加 `consoleLogs` |

## 推荐目录结构

```
{project}/
├── server/
│   ├── index.js            # Express 路由、入参解析、日志块
│   └── logger.js           # 控制台 + 挂载目录双写
├── scripts/
│   ├── browser-pool.js     # 多 Worker、acquire/release、会话、warmup
│   ├── h5-param.js         # 设备模拟参数合并、配置目录随机抽取
│   ├── console-capture.js  # 页面 console 插码日志采集
│   ├── *-automation.js     # 发码/办理、route 注入、兜底 fetch
│   ├── channel-params.js   # 触点字段白名单与别名
│   └── *-cli.js            # 本地调试 CLI(可选)
├── config/                 # 默认 h5Param 配置(*.json),Docker 复制到挂载目录
├── docker/dockerfile
├── doc/README.md           # 含请求/响应完整示例
├── package.json
└── playwright.config.js
```

## API 设计规范

### 必备接口

| 方法 | 路径 | 作用 |
|------|------|------|
| GET | `/health` | 存活探针 + `pool` 状态(busyWorkers / activeSessions) |
| GET/POST | `/api/sms/send` | 发码 |
| GET/POST | `/api/order/submit` | 办理(需 `orderId` + `verifyCode`) |

### 入参分层(重要)

| 类型 | 字段示例 | 是否拼落地页 URL | 是否注入下游 POST |
|------|----------|------------------|-------------------|
| 基础 | `phone`, `productCode`, `orderId`, `headless` | 否 | 否 |
| 会话 | `orderId`(`sid` 同义) | 否 | 否(发码/校验**必填且一致**) |
| 模拟器 | `h5Param` / `userAgent` | 否 | 否 |
| 页面追踪 | `cpparam`、`package_name` 等 | **是**(如 goodUrl 内链) | 否 |
| 触点 | `channelParams` / `touchpoint` | **否** | **是** |

解析逻辑:

1. 保留字段(`phone`、`orderId`、`h5Param`、`verifyCode` 等)不进 URL、不进触点
2. `orderId` 为调用方自定义业务单号,发码与校验**必须相同**,用于绑定 Worker 会话
3. 下游 `serialId` 等服务端内部缓存,**对外不暴露、不要求调用方传递**
4. 触点字段名与 H5 项目白名单一致;支持短别名
5. 落地页 URL 仅保留 `productCode` + 页面追踪类 query

### orderId 会话模型

```javascript
// 发码成功后每个 Worker 保存
saveSmsSession(worker, {
  clientOrderId,      // 调用方传入的 orderId
  phone,
  pageUrl,
  orderId,            // 下游 orderId(内部)
  serialId,           // 下游 serialId(内部,校验时自动带上)
  h5Param,
  h5ParamKey,
  goodsUrl,
});

// 校验时按 clientOrderId + phone 查找 Worker
const boundWorker = findSessionWorker({ clientOrderId, phone, pageUrl });
return runExclusive(task, { preferWorkerId: boundWorker?.id });
```

### 对外返回

发码/办理均返回:**下游原始 JSON** + 顶层追加字段:

| 追加字段 | 说明 |
|----------|------|
| `orderId` | 始终等于请求传入的业务单号(`sid` 仅作入参别名,响应不再重复返回) |
| `consoleLogs` | 本次流程页面插码 SDK 的 console 输出数组 |

`consoleLogs` 单项结构:

```json
{ "type": "log", "text": "步骤描述: 获取验证码", "timestamp": 1725420000123, "location": { ... } }
```

内部服务日志分块:`API 请求/响应`、`下游请求/响应`、`页面 Console 插码日志`。

## h5Param 设备模拟

模块:`scripts/h5-param.js`

| 字段 | 说明 |
|------|------|
| `userAgent` | 浏览器 UA |
| `deviceModel` | 设备型号 |
| `viewport` / `screen` | `{ width, height }` |
| `deviceScaleFactor` | 像素比 |
| `isMobile` / `hasTouch` | 移动端 / 触摸 |
| `locale` | 语言 |
| `networkType` | `wifi` / `4g` / `3g` / `slow-3g`(CDP 网络模拟) |

**优先级:** 请求 `h5Param` > 顶层 `userAgent` > 配置目录随机抽取 > 内置默认值。

**配置目录:** 容器内 `/opt/docker/{service}/config`,每个 `.json` 为一台设备;发码未传 `h5Param` 时 `pickRandomH5Param()` 随机选一份。支持单文件内 JSON 数组。

环境变量:

- `H5_PARAM_CONFIG_DIR` — 配置目录路径
- 校验接口默认**不**重新随机,复用发码会话中的 `h5Param`(显式传入则覆盖)

`buildContextOptions(h5Param)` 供 `browser.newContext()`;`applyPageEmulation(page, h5Param)` 做 CDP 网络模拟。

## 插码 console 采集

模块:`scripts/console-capture.js`

```javascript
attachConsoleCapture(page, worker);  // 创建/重建 page 时挂载
resetConsoleLogs(worker);          // 每次发码/办理流程开始前清空
const consoleLogs = getConsoleLogs(worker);  // 点击完成后短暂 wait 再读取
```

- 默认按关键词过滤:`插码`、`步骤`、`埋点`、`办理`、`验证码` 等
- `CONSOLE_CAPTURE_ALL=true` 采集全部 console
- 响应通过 `buildSmsApiResponse(body, orderId, consoleLogs)` / `buildVerifyApiResponse(...)` 合并

## 触点注入(链接不能带参时)

**禁止**把触点拼进落地页 URL(易触发风控)。

使用 Playwright **路由拦截**,在下游 POST 发出前合并参数:

```javascript
const routeHook = await installPostOverride(page, '**/.../sms/send', overrides);
try {
  const responsePromise = page.waitForResponse(matchSmsPost);
  await page.locator(SEND_BTN).click();
  const response = await responsePromise;
  const body = routeHook.getMergedBody() || parseRequestBody(response.request().postData());
} finally {
  await routeHook.teardown(); // 必须在收到 response 之后
}
```

| 规则 | 说明 |
|------|------|
| 时机 | **点击前注册,响应后卸载** |
| 方式 | 优先 `route.fetch({ postData })` + `route.fulfill({ response })` |
| 日志 | 记录 `getMergedBody()`,勿仅信 `request.postData()` |

## Browser Pool 规范(多 Worker)

```javascript
const WORKER_COUNT = Math.min(Number(process.env.WORKER_COUNT) || 1, 16);

async function runExclusive(task, { preferWorkerId = null } = {}) {
  const worker = await acquireWorker(preferWorkerId);
  try {
    return await task(worker);
  } finally {
    releaseWorker(worker);
  }
}
```

- **共享**一个 `browser` 实例,**每 Worker** 独立 `context` + `page` + `smsSession`
- URL 或 `h5Param` fingerprint 变化时重建 page
- `pageUsed` 标记:办理/失败后 reload 重置表单
- 启动 `warmup(pageUrl)` 为每个 Worker 预热
- `getPoolStats()` 供 `/health` 返回
- `SIGINT`/`SIGTERM` 时 `shutdown()` 关浏览器

### 会话限制(务必写进 doc)

- 每个 Worker 仅 **1 份** 发码会话,10 分钟 TTL
- 多 Worker 可并行不同 `orderId`;同一 Worker 上新发码覆盖旧会话
- 校验必须传与发码相同的 `orderId`,自动路由到对应 Worker
- 跨容器需负载均衡粘滞(发码/校验打同一实例),或接受 fetch 兜底

## Docker 清单

**挂载目录规范(所有 `*-order-pj` 必须遵守):**

| 用途 | 宿主机 | 容器内 |
|------|--------|--------|
| 日志 | `/data/opt/docker/{service}/logs` | `/opt/docker/{service}/logs` |
| h5Param 配置 | `/data/opt/docker/{service}/config` | `/opt/docker/{service}/config` |

- Playwright 官方镜像用户 `pwuser`(uid **1000**),宿主机 `chown 1000:1000`

```dockerfile
FROM mcr.microsoft.com/playwright:v{VERSION}-jammy
WORKDIR /app

ENV TZ=Asia/Shanghai
ENV PORT={PORT}
ENV HEADLESS=true
ENV LOG_PATH=/opt/docker/{service}/logs
ENV H5_PARAM_CONFIG_DIR=/opt/docker/{service}/config
ENV WORKER_COUNT=1

COPY package.json package-lock.json ./
RUN NPM_CONFIG_PRODUCTION=false npm ci && node -e "require('playwright')"
ENV NODE_ENV=production

COPY server ./server
COPY scripts ./scripts
COPY config ./config
COPY playwright.config.js ./

RUN mkdir -p /opt/docker/{service}/logs /opt/docker/{service}/config && \
    cp -r config/. /opt/docker/{service}/config/ && \
    chmod -R 755 /opt/docker/{service} && \
    chown -R pwuser:pwuser /app /opt/docker/{service}

USER pwuser
EXPOSE {PORT}

HEALTHCHECK --interval=30s --timeout=5s --start-period=120s --retries=3 \
  CMD curl -f http://localhost:{PORT}/health || exit 1

CMD ["node", "server/index.js"]
```

**启动示例:**

```bash
mkdir -p /data/opt/docker/{service}/logs /data/opt/docker/{service}/config
chown -R 1000:1000 /data/opt/docker/{service}

docker run -d \
  --name {service} \
  -p {PORT}:{PORT} \
  -v /data/opt/docker/{service}/logs:/opt/docker/{service}/logs \
  -v /data/opt/docker/{service}/config:/opt/docker/{service}/config \
  -e HEADLESS=true \
  -e WORKER_COUNT=3 \
  -e CONSOLE_CAPTURE_ALL=false \
  {image}:{tag}
```

| 环境变量 | 说明 |
|----------|------|
| `WORKER_COUNT` | 并行 Worker 数,默认 1,最大 16 |
| `H5_PARAM_CONFIG_DIR` | h5Param JSON 配置目录 |
| `CONSOLE_CAPTURE_ALL` | `true` 采集全部 console,默认按关键词过滤 |
| `LOG_PATH` | 文件日志目录 |

| 坑 | 处理 |
|----|------|
| `Cannot find module 'playwright'` | `playwright` 放 **dependencies**;`npm ci` 在 `NODE_ENV=production` **之前** |
| 构建上下文 | `docker build -f docker/dockerfile -t {tag} .` 在项目根目录 |
| 校验找不到会话 | `orderId` 是否与发码一致;是否超过 TTL;是否打到不同容器实例 |
| 插码日志为空 | SDK 是否 `console.log`;关键词是否匹配;试 `CONSOLE_CAPTURE_ALL=true` |

## 文件日志

- `logger.js`:production 双写;路径 `/opt/docker/{service}/logs/{service}.log`
- 环境变量:`LOG_PATH`、`LOG_TO_FILE`

## 实施顺序

1. 手工在浏览器走通 H5,记录 Network 下发码/办理 Request Payload + 插码 console 输出格式
2. Playwright CLI 脚本复现点击与捕获(无 API)
3. 抽 `browser-pool`(多 Worker)+ `h5-param` + `console-capture` + automation core
4. Express API + `orderId` 会话 + 响应组装(`orderId` + `consoleLogs`)
5. 加触点 route 注入(若链接不能带参)
6. Docker(logs + config 双挂载)+ doc/README(含请求/响应示例)
7. 对比浏览器与 API 的下游字段、插码步骤逐项 diff

## 常见故障

| 现象 | 排查 |
|------|------|
| 触点参数未生效 | route 是否过早 unroute;日志是否用 merged body |
| 下游风控 | 对比 h5Param / 触点字段;检查设备指纹与会话是否一致 |
| 校验会话失效 | `orderId` 不一致、超时、Worker 被覆盖、跨实例漂移 |
| consoleLogs 缺失 | 过滤关键词;点击后是否 wait;是否在新 page 上重新 attach |
| 首请求慢 | warmup;`waitUntil: 'load'` |
| 内存不足 | 降低 `WORKER_COUNT`;每 Context 约 200–400MB |

## 脱敏要求

编写 skill、doc、注释时:

- 用 `{BASE_URL}`、`{PRODUCT_CODE}`、`{DOWNSTREAM_SMS_PATH}` 占位
- 不写真实域名、产品编码、素材编号、运营商名称
- 触点字段名、插码步骤名可保留(通常为对接标准字段)

## 参考实现

- 完整示例项目:`front-pj/cqtf-order-pj`(含 doc/README.md 请求/响应样例)

reference.md

# Playwright H5 API 自动化 — 参考实现

本仓库可参考 `front-pj/cqtf-order-pj/` 及同类 `*-order-pj` 项目(实施时替换 H5 与下游路径,勿复制业务常量)。

## 模块职责

| 文件 | 职责 |
|------|------|
| `server/index.js` | Express、`parseSendParams` / `parseVerifyParams`、`logBlock`、响应组装、graceful shutdown |
| `server/logger.js` | 双写、轮转、`LOG_PATH` |
| `scripts/browser-pool.js` | 多 Worker、`acquireWorker` / `runExclusive`、`preparePage`、`saveSmsSession`、`findSessionWorker`、`warmup`、`getPoolStats` |
| `scripts/h5-param.js` | `mergeH5Param`、`pickRandomH5Param`、`buildContextOptions`、`applyPageEmulation` |
| `scripts/console-capture.js` | `attachConsoleCapture`、`resetConsoleLogs`、`getConsoleLogs` |
| `scripts/*-automation.js` | `sendSmsViaPage`、`submitVerifyViaPage`、`installPostOverride`、`verifyViaFetch`、`buildSmsApiResponse`、`buildVerifyApiResponse` |
| `scripts/channel-params.js` | 触点/追踪参数白名单、别名、`collect*Params` |
| `config/*.json` | 默认 h5Param 设备配置,Docker 复制到挂载目录 |

## orderId 会话模板

对外仅暴露一个 `orderId`(请求可传 `sid` 作同义别名)。下游 `serialId` 等服务端内部缓存。

```javascript
// server/index.js — 发码必填
function parseSendParams(source) {
  const common = parseCommonParams(source);
  return {
    phone: source.phone,
    orderId: source.orderId ?? source.sid,
    ...common,
  };
}

// server/index.js — 校验不重新随机 h5Param
function parseVerifyParams(source) {
  const common = parseCommonParams(source, { pickRandomH5: false });
  return {
    phone: source.phone,
    orderId: source.orderId ?? source.sid,
    verifyCode: source.verifyCode ?? source.code,
    ...common,
  };
}
```

```javascript
// browser-pool.js — 发码成功后保存
saveSmsSession(worker, {
  clientOrderId,   // 调用方 orderId
  phone,
  pageUrl,
  orderId,         // 下游 orderId(内部)
  serialId,        // 下游 serialId(内部)
  goodsUrl,
  h5Param,
  h5ParamKey: fingerprint(h5Param),
});

// automation — 校验路由到发码 Worker
const boundWorker = findSessionWorker({ phone, pageUrl, clientOrderId });
return runExclusive(async (worker) => {
  const session = getSmsSession(worker);
  const downstreamOrderId = session?.orderId ?? clientOrderId;
  const downstreamSerialId = session?.serialId ?? clientOrderId;
  // ...
}, { preferWorkerId: boundWorker?.id });
```

## 响应组装模板

```javascript
function buildSmsApiResponse(body, clientOrderId, consoleLogs = []) {
  if (!body || typeof body !== 'object') {
    return { data: body, orderId: clientOrderId, consoleLogs };
  }
  return {
    ...body,
    orderId: clientOrderId,
    consoleLogs,
  };
}

function buildVerifyApiResponse(body, clientOrderId, consoleLogs = []) {
  if (!body || typeof body !== 'object') {
    return { data: body, orderId: clientOrderId, consoleLogs };
  }
  return {
    ...body,
    orderId: clientOrderId ?? body.orderId,
    consoleLogs,
  };
}
```

响应示例(发码成功):

```json
{
  "success": true,
  "message": "发送成功",
  "code": "200",
  "data": {
    "orderId": "{DOWNSTREAM_ORDER_ID}",
    "serialId": "{DOWNSTREAM_SERIAL_ID}"
  },
  "orderId": "{CLIENT_ORDER_ID}",
  "consoleLogs": [
    { "type": "log", "text": "步骤描述: 获取验证码", "timestamp": 1725420000567 }
  ]
}
```

## h5-param 模板

### config 单文件示例(`config/device-a.json`)

```json
{
  "profileName": "iPhone 14",
  "userAgent": "Mozilla/5.0 (iPhone; ...)",
  "deviceModel": "iPhone 14",
  "viewport": { "width": 390, "height": 844 },
  "screen": { "width": 390, "height": 844 },
  "deviceScaleFactor": 3,
  "isMobile": true,
  "hasTouch": true,
  "locale": "zh-CN",
  "networkType": "4g"
}
```

### 合并与随机抽取

```javascript
// 发码:未传 h5Param 时随机;显式传入则覆盖
function parseCommonParams(source, { pickRandomH5 = true } = {}) {
  const h5Param =
    pickRandomH5 || hasExplicitH5Override(source)
      ? mergeH5Param(source)
      : undefined;
  // ...
}

// mergeH5Param 优先级:请求 h5Param > userAgent > 配置目录随机 > 内置默认
```

### 应用到 Playwright

```javascript
// browser-pool.js — 创建 page 时
cachedContext = await browserInstance.newContext(buildContextOptions(h5Param));
cachedPage = await cachedContext.newPage();
await applyPageEmulation(cachedPage, h5Param);  // CDP 网络模拟
attachConsoleCapture(cachedPage, worker);

// page 缓存 key 含 h5Param fingerprint
const needNewPage =
  cachedPageUrl !== pageUrl || cachedH5ParamKey !== fingerprint(h5Param);
```

## console-capture 模板

```javascript
// 每次发码/办理流程开始前
resetConsoleLogs(worker);

// 点击完成后短暂等待 SDK 输出
await page.locator(SEND_BTN).click();
const response = await responsePromise;
await page.waitForTimeout(800);
const consoleLogs = getConsoleLogs(worker);
```

过滤规则:

- 默认:含 `插码` / `步骤` / `埋点` / `办理` / `验证码` 等关键词
- `CONSOLE_CAPTURE_ALL=true`:采集全部 console

## 多 Worker browser-pool 模板

```javascript
const WORKER_COUNT = Math.max(1, Math.min(Number(process.env.WORKER_COUNT) || 1, 16));

function createWorker(id) {
  return {
    id,
    busy: false,
    cachedContext: null,
    cachedPage: null,
    cachedPageUrl: null,
    cachedH5ParamKey: null,
    pageUsed: false,
    smsSession: null,
    consoleLogs: [],
  };
}

async function runExclusive(task, { preferWorkerId = null } = {}) {
  const worker = await acquireWorker(preferWorkerId);
  try {
    return await task(worker);
  } finally {
    releaseWorker(worker);
  }
}

function getPoolStats() {
  return {
    workerCount: WORKER_COUNT,
    busyWorkers: workers.filter((w) => w.busy).length,
    idleWorkers: WORKER_COUNT - workers.filter((w) => w.busy).length,
    waitingTasks: waitQueue.length,
    activeSessions: workers.filter((w) => getSmsSession(w)).length,
  };
}
```

`/health` 返回:

```json
{
  "ok": true,
  "service": "{service}",
  "workerCount": 3,
  "pool": { "busyWorkers": 0, "idleWorkers": 3, "activeSessions": 1 }
}
```

## channel-params 白名单模板

与 H5 前端 `productThemes`(或等价)保持同步:

```javascript
const CHANNEL_PARAM_KEYS = new Set([
  'equityProductCode',
  'accessChannel',
  'packageName',
  'reportMaterialsNo',
  'fixedPositionCode',
  'pageName',
  'proxyOrderUrl',
  // 按项目扩展
]);

const CHANNEL_PARAM_ALIASES = {
  rmn: 'reportMaterialsNo',
  fpc: 'fixedPositionCode',
  pgn: 'pageName',
};
```

`parseCommonParams` 保留字段须包含:

```javascript
const reserved = new Set([
  'phone', 'productCode', 'orderId', 'sid',
  'userAgent', 'headless', 'h5Param',
  'verifyCode', 'code', 'smsCode', 'sendCode',
  'goodUrl', 'goodsUrl', 'channelParams', 'touchpoint',
]);
```

## installPostOverride 模板

```javascript
async function installPostOverride(page, routeGlob, overrides) {
  const payload = overrides && Object.keys(overrides).length > 0 ? overrides : null;
  if (!payload) {
    return { teardown: async () => {}, getMergedBody: () => null };
  }

  let mergedBody = null;
  const handler = async (route) => {
    const request = route.request();
    if (request.method() !== 'POST') {
      await route.continue();
      return;
    }
    let body = {};
    try {
      body = request.postDataJSON() || {};
    } catch {
      await route.continue();
      return;
    }
    mergedBody = { ...body, ...payload };
    const response = await route.fetch({ postData: JSON.stringify(mergedBody) });
    await route.fulfill({ response });
  };

  await page.route(routeGlob, handler);
  return {
    teardown: async () => page.unroute(routeGlob, handler),
    getMergedBody: () => mergedBody,
  };
}
```

## POST 调用示例(脱敏)

发码:

```bash
curl -X POST "http://localhost:{PORT}/api/sms/send" \
  -H "Content-Type: application/json" \
  -d '{
    "phone": "1XXXXXXXXXX",
    "orderId": "{CLIENT_ORDER_ID}",
    "productCode": "PROD_XXX",
    "package_name": "{PACKAGE_NAME}",
    "app_name": "{APP_NAME}",
    "h5Param": {
      "deviceModel": "iPhone 14",
      "viewport": { "width": 390, "height": 844 },
      "networkType": "4g"
    }
  }'
```

办理(`orderId` 与发码一致):

```bash
curl -X POST "http://localhost:{PORT}/api/order/submit" \
  -H "Content-Type: application/json" \
  -d '{
    "phone": "1XXXXXXXXXX",
    "orderId": "{CLIENT_ORDER_ID}",
    "verifyCode": "123456",
    "productCode": "PROD_XXX"
  }'
```

## 下游 ID 提取(内部用)

```javascript
function extractOrderIds(body) {
  if (!body?.data) return { orderId: null, serialId: null };
  const { orderId, serialId } = body.data;
  return { orderId: orderId ?? null, serialId: serialId ?? null };
}
```

- 顶层响应 `orderId` = 调用方传入的 `{CLIENT_ORDER_ID}`
- `data.orderId` / `data.serialId` = 下游真实值,校验时由会话自动带出,调用方无需传递

## 容量规划

| 指标 | 单 Worker | 3 Worker 单容器 |
|------|-----------|-----------------|
| 有效并发 | 1 | 3 |
| 预热后发码 | ~2s/次 | ~2s/次(并行) |
| 会话 TTL | 10 分钟 / Worker | 同上 |
| 内存 | ~400MB/Context | ~1.2GB(3 Context) |

- 单容器:`WORKER_COUNT` 按内存调整(4C8G 可先试 3)
- 更高 QPS:多容器 + 负载均衡,发码/校验须粘滞同一实例

## Docker 挂载与 logger 模板

**宿主机 `/data/opt/docker/{service}/`,容器内 `/opt/docker/{service}/`**

### logger.js 生产默认路径

```javascript
const defaultLogPath =
  process.env.NODE_ENV === 'production'
    ? '/opt/docker/{service}/logs'
    : path.resolve(__dirname, '../logs');
```

### doc/README.md 挂载片段

```bash
mkdir -p /data/opt/docker/{service}/logs /data/opt/docker/{service}/config
chown -R 1000:1000 /data/opt/docker/{service}

docker run -d \
  --name {service} \
  -p {PORT}:{PORT} \
  -v /data/opt/docker/{service}/logs:/opt/docker/{service}/logs \
  -v /data/opt/docker/{service}/config:/opt/docker/{service}/config \
  -e LOG_PATH=/opt/docker/{service}/logs \
  -e H5_PARAM_CONFIG_DIR=/opt/docker/{service}/config \
  -e HEADLESS=true \
  -e WORKER_COUNT=3 \
  -e CONSOLE_CAPTURE_ALL=false \
  {image}:{tag}
```

## 实施检查清单

- [ ] 发码/校验均校验 `orderId` 必填
- [ ] 校验 `findSessionWorker` + `preferWorkerId` 会话亲和
- [ ] 发码 `pickRandomH5Param`,校验复用会话 `h5Param`
- [ ] 新建/重建 page 时 `attachConsoleCapture`
- [ ] 响应含 `orderId` + `consoleLogs`
- [ ] `/health` 含 `pool` 状态
- [ ] Docker 双挂载 logs + config
- [ ] doc/README 含请求与响应 JSON 示例

 

你好:我的2025

上一篇:Playwright自动化框架

下一篇:最好的告别