# HTML转PDF服务 - 正式版设计方案(v2.1 - 补充版) > **文档版本**:v2.1(在 v2.0 基础上补充若干工程级要点) > **创建日期**:2025-12-10 > **说明**:本文件基于原 v2.0 文档(MVP→正式版设计)。此次 v2.1 主要在生产稳定性、队列与任务自愈、幂等性、回调安全与可重放、队列分离、以及预检(dry-run)能力上补充内容。**按用户要求未增加“文件加密 + 授权下载”相关内容。** --- ## 0 概览(本次补充要点) 本次 v2.1 补充的重点如下(已在文档相关位置直接加入实现建议、接口和配置示例): 1. 浏览器池自愈与进程隔离策略(BrowserPool 自愈) 2. 新增任务状态 `Stalled`(卡死)并补充自动恢复策略 3. 幂等性(Idempotency-Key)支持与重复请求处理 4. 回调签名(HMAC)以及增强的回调日志与回放(Replay)接口 5. 支持 Webhook 重放接口(/callback/replay)与回调审计 6. 多队列隔离:fast-queue / slow-queue 设计与调度策略 7. Dry-run(预检)API:预先验证 URL/HTML,可预估耗时并返回建议队列 --- ## 1 在原有 BrowserPool 的补充(5.x 资源池化层) ### 1.1 目标 - 减少长期运行导致的内存泄漏影响 - 当 Chromium / Page 崩溃时保证 Worker 能自动恢复并重新调度任务 - 保证每个任务的隔离性,避免数据跨任务泄露 ### 1.2 新增行为/实现要点 - **单任务单 Page(强隔离)**:每个 ConversionTask 在拿到 Browser 实例后,创建一个新的 Page;任务结束后立即 `page.CloseAsync()`。 - **实例生命周期限制**:对每个 Chromium 进程增加 `MaxTasksPerBrowserInstance` 配置(例如 100 次),达到阈值后优雅退出并重建新实例。 - **内存阈值 & 定期重启**:当进程 RSS 超过 `BrowserRestartMemoryMb`(例如 600MB)时强制重启该实例。 - **创建心跳/健康检查**:定期对每个实例做轻量探测(打开 about:blank 并执行简单脚本),若失败则标记为 unhealthy 并重启。 - **超时 & 中断保护**:对 Page 的主要操作(导航、等待、渲染、pdf 生成)都使用独立超时(navigationTimeout / renderTimeout),超时后强制 `page.Close()` 并将任务标记为 `Stalled`(参见第2节)。 ### 1.3 配置示例(appsettings) ```json "PdfService": { "BrowserPool": { "MaxInstances": 10, "MinInstances": 2, "MaxTasksPerBrowserInstance": 100, "BrowserRestartMemoryMb": 600, "AcquireTimeout": 30000, "HealthCheckIntervalMs": 30000 } } ``` ### 1.4 实现提示(伪代码) ```csharp // 在 BrowserPool 获取实例时 var browser = await _browserPool.AcquireAsync(); var page = await browser.NewPageAsync(); try { // 执行导航/渲染(带超时) await page.GoToAsync(url, options, TimeSpan.FromMilliseconds(navigationTimeout)); await page.PdfAsync(..., TimeSpan.FromMilliseconds(renderTimeout)); } catch (Exception ex) { // 如果是导航/渲染超时或 browser disconnect // 标记任务 Stalled,由 TaskOrchestrator 进行入队重试或重新分配 } finally { await page.CloseAsync(); _browserPool.Release(browser); } ``` --- ## 2 任务状态扩展:新增 `Stalled`(卡死) ### 2.1 为什么需要 生产环境常见问题:Worker/Browser 卡死、外部资源无限等待或内部未捕获异常,导致任务长期卡在 `Processing`。新增 `Stalled` 状态用于区分 "真实失败" 与 "需要重试/恢复" 的场景。 ### 2.2 状态机(补充) ``` Pending → Processing → Completed / Failed / Timeout / Stalled → (Stalled 可进入重试流程或直接 Failed) ↓ Cancelled ``` ### 2.3 Stalled 的判定规则(建议) - `Processing` 超过 `ProcessingMaxDurationMs`(例如 120000 ms)且无进度更新 - Page 操作出现 `BrowserDisconnected`、`NavigationTimeout` 等可恢复错误 - Worker 发现自身资源异常(如无法创建新 Page) ### 2.4 Stalled 的自动处理策略 - 自动标注 `Stalled` 并将任务入 `retry` 队列(遵循 RetryPolicy) - Retry 次数耗尽后标注 `Failed` 并发送回调 - 管理后台显示 `Stalled` 列表供人工干预 ### 2.5 任务模型更新示例 ```csharp public enum TaskStatus { Pending = 0, Processing = 1, Completed = 2, Failed = 3, Timeout = 4, Cancelled = 5, Stalled = 6 } ``` --- ## 3 幂等性(Idempotency)支持 ### 3.1 目标 防止客户端在网络重试或误操作下重复创建相同任务,节约资源并保证用户能获得单一结果。 ### 3.2 API 设计 - 客户端可在请求头中携带 `Idempotency-Key`(任意不重复字符串,由客户端在重试期间复用) - 服务端在接收到带 Idempotency-Key 的请求时: - 若 key 未见:创建新任务并记录 key -> taskId - 若 key 已存在且任务未完成:返回已存在 taskId 与当前状态 - 若 key 已存在且任务完成:返回对应结果或下载链接 ### 3.3 示例(请求/响应) ``` POST /api/tasks/pdf Headers: Idempotency-Key: abc-123 Body: { ... } // 响应(首次): 202 Accepted { taskId: "...", status: "pending" } // 响应(重复提交): 200 OK { taskId: "...", status: "processing" } ``` ### 3.4 存储建议 - 将 `IdempotencyKey -> TaskId` 存入 Redis (设置合理的过期时间,例如 24 小时或任务保留时间) - 若业务需要长期追踪,可持久化到数据库 --- ## 4 回调签名与回放(Replay) ### 4.1 回调签名(安全) - 在原有回调配置上强制或建议使用 HMAC 签名,回调 Header 示例: ``` X-Signature: sha256=HEX(HMAC_SHA256(secret, body)) X-Signature-Timestamp: 1700000000 ``` - 服务端在发送回调时:在 header 中带上 timestamp 与签名,用户服务可校验时效(例如允许 ±5 分钟)并验证签名 ### 4.2 回调审计日志 - 每次回调(成功/失败)都记录到 `conversion_callback_logs` 表或 ELK 中,包含:taskId、attempt、statusCode、responseBody(截断)、sentAt、responseAt、durationMs ### 4.3 回放接口(Webhook Replay) 用于在第三方未成功接收时,手动或自动重放回调。 **接口:** ``` POST /api/tasks/{taskId}/callback/replay Body: { "attempts": 3, "backoffMultiplier": 2 } ``` **行为:** - 从回调审计里读取最近一次回调 payload - 重新发送(遵循回调重试策略) - 记录 replay 日志,返回 replay 结果 --- ## 5 多队列隔离(fast-queue / slow-queue) ### 5.1 目标 把「短任务」与「长任务」隔离,避免大任务堵塞小任务,提高整体响应体验与吞吐。 ### 5.2 设计要点 - 定义两个(或多级)队列:`fast-queue`、`slow-queue`;优先 Worker 先消费 `fast-queue`。 - 提交任务时尝试由服务端根据 `source`(简单性检查)或通过 `dry-run` 返回建议队列;客户端也可显式指定 `priority`。 - Worker 池分组:部分 Worker 专注处理 fast, 另一组处理 slow;或同一 Worker 先尝试从 fast 获取任务,若空则从 slow 获取。 ### 5.3 配置示例 ```json "TaskQueue": { "Queues": ["fast","normal","slow"], "WorkerGroups": { "fast": { "WorkerCount": 4 }, "normal": { "WorkerCount": 2 }, "slow": { "WorkerCount": 4 } } } ``` ### 5.4 调度策略示例 - Worker 优先 poll fast queue - long-running task 超过阈值(例如 20s)自动迁移到 slow 组(或记录到 slow 队列) --- ## 6 Dry-run(预检)API ### 6.1 目的 在真正提交渲染任务前,提供轻量验证:URL 是否可访问、是否被 SSRF 阻断、HTML 大小、是否含有外部资源(fonts、images)、并对是否应该走 fast/slow 队列给出建议和预计时间。 ### 6.2 API 设计 **请求** ``` POST /api/tasks/validate Content-Type: application/json { "source": { "type": "url", "content": "https://..." }, "options": { ... } } ``` **响应** ```json { "ok": true, "canRender": true, "suggestedQueue": "fast", "estimatedRenderTimeMs": 1200, "issues": ["external-fonts", "images: 12"], "ssrfBlocked": false } ``` ### 6.3 实现要点 - Dry-run 仅做 HEAD/GET(受限超时)与内容大小/资源数量统计,不进行完整渲染 - 对于 HTML 内容,可计算大小并快速抓取子资源列表(但不要下载全部大资源) - 如果目标 URL 在内部网络或被列为 denied domain,直接返回 `ssrfBlocked: true` --- ## 7 监控 / 指标补充(与上文 Prometheus 指标并行) 建议新增以下监控项,以便观察 Stalled、Browser 自愈与队列分离效果: - `conversion_tasks_stalled_total`:累计 Stalled 数 - `browser_restart_total`:浏览器重启次数 - `browser_memory_rss_bytes`:各实例 RSS(histogram) - `idempotency_hits_total`:相同 Idempotency-Key 命中次数 - `callback_replay_total`:回放次数 - `queue_fast_length`、`queue_slow_length`:队列长度实时数 --- ## 8 数据库/Redis 结构补充 ### 8.1 Redis:Idempotency Key 存储(示例) - Key: `idempotency:{Idempotency-Key}` -> value: `{ taskId, createdAt }` (TTL 根据任务保留策略设置) ### 8.2 callback logs 表(示例) ```sql CREATE TABLE conversion_callback_logs ( id BIGINT AUTO_INCREMENT PRIMARY KEY, task_id VARCHAR(50), attempt INT, request_body TEXT, response_status INT, response_body TEXT, sent_at TIMESTAMP, response_at TIMESTAMP, duration_ms INT ); ``` ### 8.3 task 表字段补充 - `status` 支持 `Stalled` - `idempotency_key` 可选字段 - `suggested_queue`(fast/normal/slow) --- ## 9 操作手册补充(运维/排障) ### 9.1 发现大量 Stalled 任务时的快速排查步骤 1. 查看 `/metrics` 中 `conversion_tasks_stalled_total` 与 `task_queue_length`。 2. 检查 BrowserPool 健康:`browser_restart_total` 是否异常上升;查看内存占用。 3. 抓取一条 Stalled task 的最近日志(conversion_logs),查看是否为 `NavigationTimeout`、`BrowserDisconnected`。 4. 若为外部 URL 引起,建议通过 Dry-run 检查目标可达性;将该 URL 加入白名单/黑名单策略。 5. 若为 Browser 崩溃,重启服务或单个浏览器实例,并将受影响任务重新入队(或由系统自动重试)。 ### 9.2 回调回放(Replay)常用流程 1. 在管理后台选择某个 task,查看 callback logs,确认最后一次成功/失败原因。 2. 点击 `Replay`,选择 replay 次数与退避策略。 3. 系统会返回 replay 执行结果并写入审计日志。 --- ## 10 迁移与兼容性(与原 v2.0 同步说明) - `Stalled` 是向后兼容的新增状态:旧客户端不受影响;管理后台/监控面板需升级以展示该状态。 - Idempotency-Key 为可选 header;客户端可逐步接入以避免重复任务。 - Dry-run 为新可选接口,不改变原有流量路径。 --- ## 11 开发计划(补充分配) 建议把本次补充拆成小步落地: - Phase A(1周):BrowserPool 自愈 + Stalled 状态检测与自动重试 - Phase B(1周):Idempotency 支持 + Redis 存储策略 - Phase C(0.5周):回调签名 + 回放接口 + 审计日志 - Phase D(0.5周):Fast/Slow 队列实现(小规模验证) - Phase E(1周):Dry-run 接口实现 + 集成测试 --- ## 12 结语 本次 v2.1 补充重点聚焦工程可用性与生产自愈能力,目标是让系统在长期高并发运行下稳定可观测、易于排查与运维。若你同意,我可以把这些补充直接合并回原文档内指定章节(例如将 BrowserPool 补充放到 5.x 节,将 Stalled 放到 3.1 状态机处,等等),并生成带变更标注(diff)的版本,或输出一份可直接提交给开发的任务清单(Jira/Issue 模板)。 --- *注:按照你的要求,本版本**未**加入“文件加密 + 授权下载”相关内容。如需未来再加,可在安全设计章节追加详细方案。*