可以把技能放到本机用户目录的 ~/.cursor/skills/<技能目录>/(Windows 一般是 C:\Users\你的用户名\.cursor\skills\)。这样任意项目打开后,只要 Cursor 会加载用户级 skills,就不必每个仓库复制一份。
注意:~/.cursor/skills-cursor/ 是 Cursor 内置技能目录,不要把自己的技能放进去;自定义技能用 项目 .cursor/skills/ 或 用户 ~/.cursor/skills/。
在项目的根目录下创建:.cursor\skills\playwright-h5-api-automation
---
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 请求/响应样例)
# 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 示例