Files
LWLT-AIBOT/control-plane/README.md
2026-08-30 16:01:42 +08:00

156 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# LTJT 生产控制平面
这是围绕现有 LTJT 浏览器适配器增加的生产级控制层。它不新增 ERP 业务路线,也不替换 LTJT;它负责把现有任务、解析结果、确认、插件接管、执行回查和审计变成可持久化、可恢复的服务端状态。
## 服务边界
- PostgreSQL 是任务、事件、幂等、审计和回查状态的唯一事实源。
- 管理员使用原生账号登录;服务端会话使用 HttpOnly/Secure/SameSite Cookie。
- 登录会话默认跨浏览器重启持续有效,不按空闲时间或绝对时长自动失效;用户退出、修改密码、账号停用或管理员强制撤销时由服务端立即撤销。
- 业务页面通过 REST 创建任务;Agent 返回结构化结果后,管理员在选中任务上一次点击“确认并提交到 ERP 插件”,再通过 SSE 或轮询读取服务端状态。
- 手工与 AgentBus 的每个新任务都会按同一组织、同一 18 项业务路由固化解析策略及配置 revision;来源不能覆盖模式。其中两项名单 route 固定为 `program_only`,其余 16 项可配置 `ai / shadow / auto / program`。全消息中的唯一已登记指令可以确定 route;没有指令时,只有全部标签都属于唯一 route、至少两个不同标签且业务定位必填项完整的字段签名才可确定 route。未知、冲突或多 route 输入不猜测。后续补充轮次沿用原任务快照,设置变化只影响新任务和新会话。
- 名单 route 创建后先进入 `awaiting_attachment`,只接收一份 `.xls/.xlsx` 14 列模板。附件在内存中失败关闭地规范化为 13 列 canonical TSV;原始工作簿不入库,文件名和 canonical TSV 使用字段加密,解析通过或终止后清除 canonical 中间文本。附件到齐前不会领取解析任务,也不会进入 ERP。独立团初始 16 行、散拼子单初始 31 行仅为 ERP 动态扩行基线,5000 为技术上限。
- 平台的任务 ID、Agent 会话 ID、确认状态、重要摘要、事件和用户通讯内容只存在于平台任务/会话/结果信封中,不回写到 Agent `operation`,也不成为 ERP 业务字段。
- Agent `operation` 只保存解析态业务事实。插件领取已确认任务后先做无需 ERP 查询的前门禁,再在 ERP 内只读唯一解析对象、资源和当前状态,最后对内部 execution operation 执行严格写前门禁。
- Chrome 插件仍在管理员已登录的浏览器/ERP 会话中工作;`chrome.storage.local` 只是临时缓存。
- `confirmation_export` 先按对象范围选择 ERP 源:独立团/散拼具体子单的游客名单使用 `did+tid`,散拼母团的整团游客信息只使用 `tid`,且母团只开放这一单一文件类型。附件在 `TaskArtifactStore` 写入前按类型处理:游客信息保留原始 ERP `.xls` 作为源归档,同时由平台通过 LibreOffice 生成真实 `.xlsx`;AgentBus/微信只投递 `.xlsx`,若 XLSX 转换失败则阻断外部附件交付。其他类型由平台转换为 PDF,转换失败回退对应 ERP 源文件。生产附件写入 OSS;PostgreSQL 只保存任务归属、文件元数据和 OSS object key。OSS Bucket 按当前生产策略公共可读,但只有后端凭据可写/删;对象 URL 使用随机执行 UUID,不包含客户名或团号。
- ERP 写入继续遵循预检、管理员确认、单次提交、ERP 回查;不确定结果禁止自动重试。
- 由于插件不使用独立凭据,执行期间必须保持管理员业务页面和已登录 ERP 浏览器会话可用;页面断开不会触发自动补偿或重复提交。
- 服务端先创建唯一 ERP execution,再允许页面向插件下发;同一任务只有一个 `erp` attempt。刷新、重连、超时和迟到回执都不能创建第二次执行。
- 插件回执必须携带服务端 `execution_id` 和领取连接;完成、阻断及待回查状态不可被后续 `running` 回执覆盖。执行租约过期会进入待回查,不会重新入队。
- Chrome 插件最低兼容版本由正式操作台与根目录发布清单共同门禁。插件包含分段前门禁、ERP 只读唯一解析、严格写前门禁、当前窄生命周期适配、写入前 `write_started` 持久化和写后回查;扩展后台重启后也不会重跑同一任务。本次双轨解析不修改插件执行契约或扩展版本。
- 渠道 Adapter 由 AgentBus 负责;控制平面只作为 AgentBus Bot 连接到文档中的 WebSocket,不实现微信、个人微信或其他渠道协议。
- 微信桥接器把正文放在严格的 `New WeChat message` / `Conversation:` / `Text:` 三行传输信封中;AgentBus listener 会在任务快照前只解开这一已知信封,把 `Text:` 同行值及后续行作为业务正文,并在帧没有显式 `conversation_id` 时使用信封中的 `Conversation:` 值。显式字段仍优先;近似、缺失字段或空正文的包装保持原文,不能通过忽略任意未知标签来绕过 Program parser 的失败关闭。
- 微信侧的 `[WeChat attachment: 文件名]` 只是一段传输占位文字,不代表控制面已经收到文件。若同一帧没有符合契约的 `payload.attachments[]`,listener 会在进入任务服务前失败关闭、保留原名单任务的等待状态,并返回“附件内容未传到平台”;不会把占位文字创建成新业务任务。附件元数据、HTTPS URL、DNS、大小或摘要校验失败时返回对应的安全摘要,仍不回显 URL、文件字节或名单内容。
- AgentBus 入站消息会复用 `TaskService` 的任务/会话/解析队列,解析完成后通过同一 WebSocket 返回一次 `task.result`。组织级“全自动化”关闭时,手工与 AgentBus 新任务都要求管理员确认;开启后,两种来源的合法解析结果都自动进入 ERP 队列,不再按来源或创建、名单、安排、修改、取消/恢复、导出等业务类型保留人工例外。操作台在 EventSource 建连/重连、30 秒后台刷新以及页面重新可见或聚焦时重新读取数据库权威开关,避免后台变更后按钮仍显示旧值。缺资料、解析失败、歧义、插件校验失败或 ERP 回查不确定时仍会停止,不会绕过校验或重试不确定写入。
- 一个组织可以维护多个“用户渠道”。渠道代表外部 AgentBus 用户身份,不等同于平台管理员账号;管理员在独立根路径 `/channels` 的“AgentBus 渠道”目录中创建、停用、启用、轮换或删除渠道。删除会停止对应 listener、移除服务端保存的 key 和该渠道尚存的持久化回执;历史任务本体保留,其 `channel_id` 按数据库契约置空。每个渠道独立保存加密后的 AgentBus key,并建立独立 WebSocket listener;列表和日志都不会回显 key。`AGENTBUS_WS_URL`、重连策略和客户端类型仍是全局连接配置,`AGENTBUS_BOT_ADDRESS` 可作为渠道 bot address 的默认值。仍由完整旧环境变量托管的兼容渠道会自动重建,必须先移除环境配置并重启服务,才允许删除其数据库记录。
- `/history` 支持逐条彻底删除,以及勾选当前页后批量删除。单条使用 `DELETE /api/tasks/:taskId`,批量使用 `POST /api/tasks/bulk-delete`(一次 1–100 个且不能重复);两者都要求管理员 mutation 会话、same-origin 与 CSRF 门禁。批量删除会在同一数据库事务中按组织锁定并核对全部目标,任一任务不存在或不属于当前组织时整批回滚;成功后任务、生命周期、尝试、会话、附件元数据和 AgentBus 回执按外键级联删除,task-scoped audit/outbox 行显式删除。OSS 附件对象在事务提交后使用已冻结的 storage key 逐一清理,清理异常写入服务日志。对已确认或正在插件流程中的任务,页面会明确警告:停止插件只是尽力而为,已经发生的 ERP 写入及已投递到外部渠道的副本不会因删除平台历史而撤回。
- 使用数据库渠道时设置 `AGENTBUS_ENABLED=true`;此模式不要求 `AGENTBUS_WS_TOKEN` 或 `AGENTBUS_BOT_ADDRESS`,但启用的渠道仍需要全局 `AGENTBUS_WS_URL`,并可在渠道上覆盖 bot address。保留旧环境变量配置时,服务会按需创建“默认 AgentBus 渠道”兼容旧单渠道部署;`AGENTBUS_ENABLED=auto` 仅由完整的旧环境连接字段自动启用。
- `user_channels`、`tasks.channel_id` 和 `agentbus_deliveries` 共同保存入站归属、accepted 受理回执和最终 result 回执。回执以 `(channel_id, inbound_frame_id, delivery_kind)` 幂等,发送失败会重试,进程重启或 WebSocket 重连后仍会继续投递;因此不会因为超过原等待时长而丢掉最终回复。
- ERP 插件领取由组织级数据库锁和 FIFO confirmed 队列统一串行化:同一组织/同一 ERP 浏览器会话在任意时刻最多一个 ERP execution,其他任务留在服务端等待;已开始写入但结果不确定的任务会阻塞后续领取,直到人工回查收敛。
## 任务级会话续接
- `agent_sessions` 将一个业务任务绑定到一个 Superagent 会话;`agent_session_messages` 加密保存每一轮用户/Agent 消息并使用独立幂等键。
- 解析缺少可由用户补充的信息时,任务进入 `awaiting_user_input`,不会进入 ERP 或 Chrome 插件执行链。不带业务指令的补充信息通过同一任务重新排队,沿用原会话;新任务始终创建新会话。若 `/api/messages` 的新消息首个非空行命中已知业务指令,则按新任务处理,不会被旧的等待任务或旧会话吸收。
- 外部渠道或系统集成可使用已认证、CSRF 保护的 `POST /api/messages` 续接任务;操作台在 `awaiting_user_input` 时显示文字补充框,在 `awaiting_attachment` 时显示单文件 `.xls/.xlsx` 补交入口,提交时携带当前 `task_id`。文件字节只保留在浏览器内存到请求完成,不进入 `sessionStorage` 或日志;左侧输入区仍始终代表新任务。全消息中的唯一已登记业务指令会开启新任务;字段签名只为已经确定是新任务的输入选择 route,不会单独改变补充消息的会话归属。任务生命周期日志继续展示缺失信息、Agent 阶段、控制面事件和插件回执,原始 JSON 按事件缩进追加在黑框内:
```http
POST /api/messages
Content-Type: application/json
{
"message": "补充后的用户信息",
"task_id": "TASK-...",
"conversation_id": "channel-user-or-thread",
"idempotency_key": "..."
}
```
名单附件补交使用同一接口,正文可以为空:
```json
{
"message": "",
"task_id": "TASK-...",
"attachments": [{
"name": "passenger-list.xlsx",
"content_type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"size": 12345,
"content_base64": "..."
}]
}
```
不带业务指令时,有 `task_id` 才精确续接;没有 `task_id` 时只有同一 `conversation_id` 下唯一一个等待补充任务才会自动匹配,多任务返回 `task_selection_required`,没有待补充任务则创建新任务。首个非空行带已知业务指令时,无论是否携带旧 `task_id`/`conversation_id` 都创建新任务和新会话。普通接口和任务卡不会暴露原始外部 `session_id`。微信接入和线上 Profile 发布属于后续接入工作。
## 双轨解析与渐进切换
`business-routes.ts` 是 18 项路由的唯一机器注册表,手工与 AgentBus 共用同一个全消息 route resolver 和任务级模式快照;`program-parser.ts` 按“规范化 → 路由 → 字段拆分 → 类型解析 → 业务 builder → 统一 operation 校验”确定性编译输入。AI 和程序结果都经过 `external-agent-client.mjs` 导出的同一最终契约校验;operation Schema、插件执行契约和 ERP 写入逻辑不因解析模式或任务来源改变。
两项名单 route 是硬性例外:`allowed_modes=['program']`、`mode_locked=true`,组织设置、紧急全部切 AI 和一次性 AI 重解析都不能覆盖;紧急开关只作用于其余 16 项可配置业务。
- `ai`:只运行原 AI 路径;迁移后的设置和历史任务均从该模式开始。
- `shadow`:AI 是唯一权威结果,程序并行解析;两份候选只加密保存,任务详情只按差异路径解密展示 operation 字段,不公开完整候选或传输元数据。
- `auto`:程序优先;仅 `program_unsupported_syntax`、`program_unknown_field`、`program_internal_error`、`program_timeout`、`program_contract_invalid` 可整体切到 AI。缺字段、非法值、冲突、歧义、多动作和业务规则阻断不会调用 AI。
- `program`:只运行程序;失败时追问或阻断,不调用 AI。
Auto 一旦发生 AI fallback,任务会永久绑定原 AI 会话。每次解析只有一个完整权威结果,不拼接候选;任务进入确认、插件领取或 ERP 链路后不再自动重解析。尚未进入插件且明确 `no_plugin_dispatch=true / no_erp_write=true` 的解析失败任务,可由管理员授权一次 AI 重解析。
操作台根路径 `/parser-routing` 提供逐业务观察统计、即时切换检查、revision 乐观并发控制和事务级“全部切回 AI”。连续天数、任务数量和 fallback 比率不作为等待门槛:18 项已实现业务均可独立进入 Shadow,多个 Shadow 可并行测试且不受固定前序约束;已出现的差异全部判定且本业务程序关键、契约、运行错误为零即可进入 Auto;程序下游失败和上述错误为零即可进入 Program。同一时间最多一个业务处于 Auto,晋级仍只能 `AI → Shadow → Auto → Program` 逐级进行,回退可直接执行。7/30 日统计仅供观察。组织已开启全自动化时,切入 Auto/Program 不增加人工确认期。
主要接口:
- `GET /api/settings/parser-routing`
- `PUT /api/settings/parser-routing/:routeId`
- `POST /api/settings/parser-routing/emergency-ai`
- `POST /api/tasks/:taskId/reparse`
- `PUT /api/parser-decisions/:decisionId/review`
迁移 `013_business_parser_modes` 增加组织路由设置、任务快照和加密的 `parse_decisions`;迁移 `014_task_input_attachments` 增加名单输入附件元数据、加密 canonical TSV 与 `awaiting_attachment` 索引。原文、完整程序/AI 候选、名单 canonical 中间文本和人工说明使用字段加密保存;统计、审计和日志只使用代码、哈希、字段路径和计数。
## AgentBus Bot 接入
将 onboarding 文档中的 WebSocket 地址、WebSocket Token、Bot Address 和 Worker Address 写入服务端受保护的环境文件。配置 `AGENTBUS_ENABLED=auto` 时,只要填写 AgentBus 连接字段,控制平面就会自动启动长期监听;保持这些字段为空则不启动监听。
每个启用渠道会连接 `AGENTBUS_WS_URL?ready=1`,使用该渠道自己的 `Authorization: Bearer <channel-agentbus-key>`,等待 `session.ready` 后接收普通 `event` 消息。每条入站消息只发送一次持久化受理通知(`task.progress`,`status=accepted`)和一次持久化最终 `task.result`;不再发送解析完成、等待确认或进入 ERP 的中间进度。`GET /health/ready` 和 `GET /api/status` 的 `agentbus.channels` 字段可用于确认每个 listener 与 session 是否建立。
对微信来源,listener 在调用 `TaskService.ingestMessage()` 前执行上述严格信封解包,因此手工正文与 AgentBus 正文进入同一个业务 route resolver、任务级 mode snapshot 和 parser orchestrator;`Conversation` 只属于传输路由,不会再污染业务字段签名。
AgentBus 全链路日志使用控制平面 stdout/Pino 输出,同时带 `diagnostic_event=agentbus.<agentbus_event>` 与原有 `agentbus_event`。日志覆盖连接尝试、socket 生命周期、session.ready、每个收发帧、帧忽略原因、任务入队、解析队列、持久化回执出队、最终回复和发送错误。名单附件另外记录元数据存在性、DNS 开始/通过、公网地址数量与 IP family、HTTPS 状态、重定向次数、接收字节数、大小/摘要校验和各阶段耗时;不记录 URL、hostname、IP、文件名、附件字节或名单值。开发/测试环境可临时设置 `AGENTBUS_LOG_PAYLOADS=true` 记录最多 2,000 个字符的正文预览,生产环境会拒绝以该值启动。渠道 key、WebSocket Token、Invoke Token 和 Authorization header 永不写入日志。
监听器只使用每个渠道的 WebSocket key;文档中的 Invoke Token 仅用于另一服务通过 Function Call API 主动向 Bot 投递任务,本服务的监听链路不会使用它。
任务失败时,公共任务响应的 `failure` 字段和最终生命周期事件会同时给出 `error_code`、`failure_stage`、`failure_source`、`failure_message`、`agent_returned`、`plugin_dispatch_started`、`erp_write_started`、`no_plugin_dispatch` 与 `no_erp_write`。因此可以区分“Agent 未返回”“Agent 已返回但契约校验失败”“插件已接管但 ERP 未写入”和“ERP 已写入但回执待回查”等状态,不再只显示笼统的“外部解析失败”。
任务响应统一提供 `important_message`,作为“需返回/交互用户的重要消息”出口:`awaiting_user_input` 返回 Superagent 的 `reply`,错误优先返回 ERP 业务反馈、否则返回简短错误摘要,成功且有可验证 ERP 证据时按 [AgentBus 用户回复契约](../agent设计规范/agentbus-reply-contract.md) 生成业务回执。操作台保留完整结构供内部展示,AgentBus `task.result` 发往渠道时携带 `kind`、用户可读 `text` 和符合微信渠道适配器契约的 HTTPS URL-only 附件元数据;不发送 `content_base64` 或后台鉴权下载地址。外部文件投递要求生产附件使用 OSS 存储,且 URL 域名已加入适配器白名单。错误码、执行阶段、回执校验状态、隐藏 ID 和哈希不外发。`awaiting_confirmation` 不进入该栏。没有 `reply` 的 awaiting 结果会在解析阶段转为错误;没有可验证回执的 `completed` 执行会被归类为 `reconciliation_pending`,不会返回成功回执。
## 服务端诊断日志
控制面、迁移、数据保留、数据库异常和管理员命令都输出一行一个 JSON 的结构化日志。公共字段包括 `time`、`level`、`service`、`environment`、`deployment_revision`、`pid`、`hostname`、`diagnostic_event` 和 `diagnostic_stage`;链路字段按事件提供 `request_id`、`task_id`、`inbound_frame_id`、`conversation_id`、`channel_id`、状态、结果与 `duration_ms`。覆盖范围包括服务启动/关闭、HTTP 开始/完成/拒绝、任务状态事件、数据库 audit event、解析队列和 Worker、AgentBus、附件入口、OSS 清理、数据库连接/回滚以及未捕获进程错误。
未知异常只记录受限 `error_code`、`error_name`、稳定的 `error_fingerprint`、errno/syscall 和最多 12 个清理后的代码栈帧;不把第三方异常原文复制进日志。请求 query/body、Cookie、Authorization、CSRF、密码、API key、Token、附件 URL/字节和业务原文不进入生产日志。每个 HTTP 响应返回同一个 `x-request-id`,可直接用于跨开始、错误和完成事件检索。部署时应把 `DEPLOYMENT_REVISION` 设置为当前提交或发布编号,避免不同镜像的日志混淆。
Docker Compose 使用 `json-file`,默认每个容器保留 10 个、每个 20 MB 的轮转文件;可在 `.env.production` 用 `LOG_MAX_SIZE` 和 `LOG_MAX_FILES` 调整。服务端只读排障命令会显示容器状态、`/health/ready` 和最近日志,不读取或打印环境文件:
```bash
sh infra/diagnose-server.sh
sh infra/diagnose-server.sh --since 2h --match TASK-20260830071915-g7INIT0
sh infra/diagnose-server.sh --since 2h --match roster_attachment_metadata_missing
sh infra/diagnose-server.sh --since 30m --match request:example:12345678
sh infra/diagnose-server.sh --since 30m --all-services
```
`--match` 是字面量过滤,可使用 request/task/frame/conversation/channel ID、`diagnostic_event` 或 `error_code`。脚本默认最多读取 1,000 行控制面日志,`--tail` 上限为 50,000,且不在仓库生成日志文件。
## 本地命令
本地命令会自动读取项目根目录 `.env`,不需要先手动 `export` 环境变量。首次部署或换环境时,只需替换该文件;生产 Docker Compose 使用 `.env.production`。
```bash
npm install
npm run check
npm run build
npm run db:migrate
ADMIN_USERNAME=admin ADMIN_PASSWORD='replace-with-12-plus-chars' npm run admin -- bootstrap
npm run data:retention
npm run dev
```
`npm run dev` 和 `npm start` 会先执行数据库迁移,再启动控制平面;直接运行 `control-plane/src/server.ts` 或构建后的 `server.js` 时,服务也会在启动前检查必需迁移 `014_task_input_attachments`,缺失时拒绝监听端口。`db:migrate` 和管理员初始化需要可连接的 PostgreSQL。开发机没有数据库时,可以运行 `npm run test:control-plane` 完成无数据库静态/健康烟测。
`/health/ready` 同时检查 PostgreSQL 可用性和必需 schema 版本;迁移未完成时返回 503,并标明 `required_migration`,避免任务在数据库结构未升级时进入解析队列。
## 生产部署
1. 复制 `.env.production.example` 为部署机受保护的 `.env.production`,填入 `DEPLOYMENT_REVISION`、PostgreSQL URL、`DATABASE_SCHEMA`、字段加密密钥、外部解析 Key 和 OSS 凭据,并确认 `AGENTBUS_LOG_PAYLOADS=false`。生产数据库可使用现有 PostgreSQL 实例中的新 Schema;迁移程序会创建 Schema,不会触碰其他 Schema 的测试表。
2. 在正式数据库上线前执行并验证备份:`infra/backup-postgres.sh`。
3. 使用 `docker compose --env-file .env.production up -d --build` 启动;Compose 会先执行数据库迁移,再启动控制平面。Compose 中的本地 PostgreSQL 仅用于 `--profile local`,生产 `DATABASE_URL` 指向受保护的远程数据库。
4. 首次启动后在容器内通过 `docker compose exec -e ADMIN_USERNAME=admin -e ADMIN_PASSWORD='replace-with-12-plus-chars' control-plane node .build/control-plane/src/admin-cli.js bootstrap` 创建管理员;不要把密码写入镜像或 Git。
5. 配置 `infra/Caddyfile` 中的正式域名和 HTTPS,然后在 Chrome 插件中加载对应生产业务页面。
6. 启动后运行 `sh infra/diagnose-server.sh --since 10m`,确认容器、readiness、`service.listening`、AgentBus session 与当前 `deployment_revision`。
正式环境没有独立 staging。上线前必须完成离线测试、迁移预检查和迁移前备份;恢复检查使用 `infra/restore-check.sh` 指向一次性恢复数据库。
生产容器中的清理命令为:`docker compose exec control-plane node .build/control-plane/src/retention.js`。当前生产模板默认 `DATA_RETENTION_ENABLED=false`,命令只会返回零删除结果,待正式保留期限确认后再启用。本地开发则使用上面的 `npm run data:retention`。