From 99bf52163f633b7e2ee9c476b094979d1a727417 Mon Sep 17 00:00:00 2001 From: sunlei Date: Mon, 22 Jun 2026 14:58:59 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=94=B6=E6=95=9BNapCat=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E7=99=BB=E5=BD=95=E6=81=A2=E5=A4=8D=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- API.md | 10 +- README.md | 6 +- .../qqbot-account-napcat-runtime.port.ts | 14 +- .../account/qqbot-account.service.ts | 26 +- .../qqbot/core/contract/qqbot.types.ts | 6 + .../qqbot-napcat-account-runtime.service.ts | 88 +-- .../login/qqbot-napcat-login.service.ts | 603 ++++++++-------- .../login/qqbot-napcat-watchdog.service.ts | 2 +- .../runtime/napcat-login-event.service.ts | 141 ---- src/modules/qqbot/napcat/index.ts | 1 - .../qqbot-napcat-container.service.ts | 412 +++-------- .../qqbot/napcat/qqbot-napcat.module.ts | 3 - .../qqbot/napcat/login-event-watchdog.spec.ts | 234 +------ .../account/qqbot-account.service.spec.ts | 44 +- .../qqbot-napcat-login.service.spec.ts | 645 +++++++++++++----- .../qqbot-napcat-container.service.spec.ts | 354 +++++----- 16 files changed, 1102 insertions(+), 1487 deletions(-) delete mode 100644 src/modules/qqbot/napcat/application/runtime/napcat-login-event.service.ts diff --git a/API.md b/API.md index 2866b80..ad05541 100644 --- a/API.md +++ b/API.md @@ -361,9 +361,9 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT 账号保存支持可选 `encryptedLoginPassword`,用于 NapCat 密码登录。前端必须先通过 `/auth/password-public-key` 获取公钥并使用 RSA-OAEP 加密,不传明文 `loginPassword`;后端必须使用显式配置的 `QQBOT_ACCOUNT_SECRET_KEY`(或非默认 `ADMIN_TOKEN_SECRET`)二次加密落库,空值和公开默认值会被拒绝,不在列表/详情中返回。账号列表里的 `connectStatus` 只表示 OneBot 反向 WS;`napcat.oneBotOnline`、`napcat.containerOnline`、`napcat.webuiOnline`、`napcat.qqLoginStatus`、`napcat.qqLoginMessage` 分别表示 OneBot、容器、WebUI 和 QQ 登录态,`webuiOnline=null` 表示本次使用缓存且未重新探测 WebUI;`qqLoginMessage` 只承载真实 QQ 登录态消息,WebUI 配置缺失或请求异常只放在 `lastError`。 -扫码链路返回 `sessionId`,前端应使用 SSE 查看步骤进度,而不是等待长 HTTP 请求完成。已有账号的更新登录会先重启目标 NapCat 容器尝试 `ACCOUNT`/`-q` 快速登录;目标账号在线则直接完成会话。没有历史登录态的新容器会跳过快速登录,优先尝试保存的登录密码;快速登录失败后,如果账号保存了登录密码,会临时注入 `NAPCAT_QUICK_PASSWORD` 并按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询密码登录结果,准备阶段的扫码会话会持续续期,避免后台密码登录未完成时前端先判过期。若 API Pod 在准备阶段重启,持久化的 `preparingRelogin` 超过 `QQBOT_NAPCAT_RELOGIN_PREPARING_STALE_MS`(留空使用密码等待窗口加缓冲)后,`/qqbot/account/scan/status` 会自动恢复普通登录态检测,不再永久停留在“正在尝试密码登录”;`/qqbot/account/scan/events` 在进程内事件缓存丢失时会先推送当前会话快照。密码登录触发 QQ 安全验证时,接口返回的 `captchaUrl` 只用于前端拉起腾讯验证码;前端必须把腾讯验证码返回的 `ticket`、`randstr`、`sid` 连同 `sessionId` 提交到 `/qqbot/account/scan/captcha/submit`,后端再代理到同一 NapCat 容器的 `/api/QQLogin/CaptchaLogin` 继续密码登录第二步。验证码和新设备验证这类真人交互态使用 `NAPCAT_LOGIN_HUMAN_VERIFY_EXPIRE_MS`(默认 15 分钟,且至少不短于普通二维码 TTL)续期;普通登录二维码仍使用 `NAPCAT_LOGIN_QR_EXPIRE_MS`。`/qqbot/account/scan/status` 遇到 NapCat 只返回“需要验证码/继续完成验证/安全验证”但不带 URL 时,会先从当前容器日志提取 `proofWaterUrl`,提取不到则保持验证码处理中而不切到二维码兜底;会话已有 `captchaUrl` 后,同类状态仍保持 `pending` 和原 `captchaUrl`。密码登录成功后会重建容器移除该运行态密码,清理失败则本次登录失败;密码登录仍失败、验证码未完成、离线、账号不匹配或缺少 QQ 号时,再进入重置登录态和二维码兜底流程。看门狗自动登录使用同样的 quick -> password 顺序,但不会自动进入扫码阶段。 +扫码链路返回 `sessionId`,前端应使用 SSE 查看步骤进度,而不是等待长 HTTP 请求完成。已有账号的更新登录不会通过 Docker 重建、重启或补 env 来刷新 QQ 登录态;如果目标容器仍在线,即使 QQ 账号已离线,也会保持同一容器并通过 NapCat WebUI 依次调用 `SetQuickLogin`、`PasswordLogin`、`RefreshQRcode` / `GetQQLoginQrcode` 推进原有弹窗流程。只有 Docker 容器离线或缺失时,容器准备阶段才会创建/重建容器,并在创建时一次性注入 `ACCOUNT` 和必要登录 env;已在线的源容器不补 env。快速登录失败后,如果账号保存了登录密码,后端使用解密后的密码计算 MD5 调用 `/api/QQLogin/PasswordLogin`,不会把密码写回运行态 env,也没有成功后的 env 清理步骤;密码登录结果按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询。准备阶段的扫码会话会持续续期,避免后台登录未完成时前端先判过期。同一账号已有 pending 更新登录会话时,重复调用 `/qqbot/account/scan/refresh` 会返回原 `sessionId`,不会再次启动 quick/password/二维码准备。若 API Pod 在准备阶段重启,持久化的 `preparingRelogin` 超过 `QQBOT_NAPCAT_RELOGIN_PREPARING_STALE_MS`(留空使用密码等待窗口加缓冲)后,`/qqbot/account/scan/status` 会自动恢复普通登录态检测,不再永久停留在“正在尝试密码登录”;`/qqbot/account/scan/events` 在进程内事件缓存丢失时会先推送当前会话快照。密码登录触发 QQ 安全验证时,接口返回的 `captchaUrl` 只用于前端拉起腾讯验证码;前端必须把腾讯验证码返回的 `ticket`、`randstr`、`sid` 连同 `sessionId` 提交到 `/qqbot/account/scan/captcha/submit`,后端再代理到同一 NapCat 容器的 `/api/QQLogin/CaptchaLogin` 继续密码登录第二步。验证码和新设备验证这类真人交互态使用 `NAPCAT_LOGIN_HUMAN_VERIFY_EXPIRE_MS`(默认 15 分钟,且至少不短于普通二维码 TTL)续期;普通登录二维码仍使用 `NAPCAT_LOGIN_QR_EXPIRE_MS`。`/qqbot/account/scan/status` 遇到 NapCat 只返回“需要验证码/继续完成验证/安全验证”但不带 URL 时,会先从当前容器日志提取 `proofWaterUrl`,提取不到则保持验证码处理中而不切到二维码兜底;会话已有 `captchaUrl` 后,同类状态仍保持 `pending` 和原 `captchaUrl`。密码登录仍失败、验证码未完成、离线、账号不匹配或缺少 QQ 号时,直接通过 WebUI 二维码接口进入扫码兜底,不 reset 登录态。看门狗只做离线巡检、账号错误写入和 `super` 站内信告警,不会触发 quick/password 登录或扫码登录。 -密码验证码通过后如果 NapCat 返回 `needNewDevice`,后端不会只把 `jumpUrl` 透给 Admin,而是在同一会话中继续调用 `/api/QQLogin/GetNewDeviceQRCode` 生成新设备验证二维码;`/qqbot/account/scan/status` 后续轮询会代理 `/api/QQLogin/PollNewDeviceQR`,状态映射为 `newDeviceStatus=qr-pending|scanned|confirming|verified|expired|failed`,进入确认态后再调用 `/api/QQLogin/NewDeviceLogin` 并回到密码登录完成检查。扫码会话结果新增 `newDeviceQrcode`、`newDeviceStatus`、`deviceVerifyUrl` 字段;`captchaUrl` 和 `newDeviceQrcode` 分别表示腾讯安全验证码和 QQ 新设备验证二维码,前端必须分开展示。SSE 进度文案包含快速登录、密码登录、验证码、新设备二维码、已扫码、确认中、登录成功/失败和运行态清理失败。 +密码验证码通过后如果 NapCat 返回 `needNewDevice`,后端不会只把 `jumpUrl` 透给 Admin,而是在同一会话中继续调用 `/api/QQLogin/GetNewDeviceQRCode` 生成新设备验证二维码;`/qqbot/account/scan/status` 后续轮询会代理 `/api/QQLogin/PollNewDeviceQR`,状态映射为 `newDeviceStatus=qr-pending|scanned|confirming|verified|expired|failed`,进入确认态后再调用 `/api/QQLogin/NewDeviceLogin` 并回到密码登录完成检查。扫码会话结果新增 `newDeviceQrcode`、`newDeviceStatus`、`deviceVerifyUrl` 字段;`captchaUrl` 和 `newDeviceQrcode` 分别表示腾讯安全验证码和 QQ 新设备验证二维码,前端必须分开展示。SSE 进度文案包含快速登录、密码登录、验证码、新设备二维码、已扫码、确认中、二维码兜底、登录成功/失败。 同一 QQ 账号只保留一个有效 NapCat 主容器。扫码后如果已有账号绑定到新容器,后端会释放旧绑定和未共享的旧容器,避免同账号多实例互相挤下线。OneBot notice 只有机器人下线、登录失效、`KickedOffLine` 等账号级信号才会记录 QQ 登录态异常并生成 `qqbot.account.offline` 站内信,普通群成员 kick 不属于账号离线信号。下线原因写入 `lastError` 前按 `last_error` 500 字符列宽截断;后续无错误的普通断连只更新 OneBot 连接状态,不清空该原因。账号列表会按近期缓存检查绑定 NapCat 容器的最新登录状态日志,日志检测默认 5 秒超时;`isOnline:false` 属于 QQ 登录态离线信号;心跳只代表 OneBot/容器通信,不能推导 QQ 登录态;近期连接只用于避免重连瞬间被旧缓存误伤,后续仍必须以 NapCat WebUI/日志检查判断 QQ 登录态。托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像。 @@ -373,9 +373,11 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT | 方法 | 路径 | 说明 | | ----- | ----------------------------------------- | ------------------------------------------------------------------------------------- | -| `GET` | `/qqbot/napcat/runtime/detail?accountId=` | 读取账号 NapCat runtime/protocol/session behavior profile、风险降载和最近登录事件证据 | +| `GET` | `/qqbot/napcat/runtime/detail?accountId=` | 读取账号 NapCat runtime/protocol/session behavior profile、风险降载和历史登录事件兼容表状态 | -该接口只返回脱敏后的运行态证据,供 Admin 排查镜像、locale、shm、配置 hash、漂移状态、风险模式和 watchdog 恢复状态;不会返回 WebUI token、reverse WS token、QQ 登录密码、SSH 私钥或运行态密码环境。账号列表只挂载 `napcat.profileStatus`、`napcat.runtimeProfile` 等摘要字段,不触发登录、重建或修复动作。watchdog 自动恢复只允许 quick -> password,遇到二维码、验证码或新设备验证会挂起并记录 `recovery_suspended`;session behavior profile 只做冷启动、housekeeping、presence 和自动能力分阶段降载,不实现账号级每小时/每日累计发送预算。 +该接口只返回脱敏后的运行态证据,供 Admin 排查镜像、locale、shm、配置 hash、漂移状态、风险模式和 watchdog 巡检告警状态;不会返回 WebUI token、reverse WS token、QQ 登录密码、SSH 私钥或运行态密码环境。账号列表只挂载 `napcat.profileStatus`、`napcat.runtimeProfile` 等摘要字段,不触发登录、重建或修复动作。watchdog 不执行登录恢复:遇到 QQ 登录态离线只记录离线原因并通知 `super`,登录恢复统一由 Admin 手动「更新登录」触发;session behavior profile 只做冷启动、housekeeping、presence 和自动能力分阶段降载,不实现账号级每小时/每日累计发送预算。 + +`napcat_login_event` 实体和表仅作为历史 schema 兼容保留;watchdog 不再写入 quick/password 恢复事件,也不再依赖该表判断是否恢复登录。 外发消息不直接抢发:后端会按 `QQBOT_SEND_GLOBAL_INTERVAL_MS`、`QQBOT_SEND_TARGET_INTERVAL_MS` 和 `QQBOT_SEND_JITTER_MS` 预约发送窗口,默认全局 2500ms、同会话 8000ms、抖动 0-800ms;如果等待超过 `QQBOT_SEND_MAX_QUEUE_WAIT_MS`,本次发送会在下发前被拒绝。在线命令和自动回复规则会叠加运行时保底冷却,默认命令 5000ms、规则 30000ms;复读机默认连续 4 次相同普通文本才触发,同一会话默认 10 分钟内只复读一次,并限制普通文本长度,减少自动行为被风控识别的概率。 diff --git a/README.md b/README.md index cc64eff..de5fa59 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ QQBot 插件定时任务由 manifest 的 `tasks` 声明,平台持久化到 `qq Admin 环境总览面板使用 `ENV_DASHBOARD_*` 只读配置聚合 local-dev、NAS 线上、腾讯云和 r4se 状态。`ENV_DASHBOARD_ADMIN_LOCAL_URL` / `ENV_DASHBOARD_ADMIN_PUBLIC_URL` 只用于展示 Admin 本机与线上入口证据。HTTP 快照提供当前拓扑,后端 local/MQTT 事件总线通过 SSE 推送增量事件给 Admin;前端不直连 MQTT,也不轮询刷新。Jenkins、K8s、Tencent Cloud、Caddy、WireGuard、Mihomo/OpenClash 未配置时会显示 `unwired` 证据,不能渲染成健康假象;第一版不暴露重启、部署、迁移、容器重建、插件启停或代理切换等写操作。 -NapCat Runtime/Protocol Profile 已完成本地 API/Admin 实施,线上发布和账号闭环按 `docs/superpowers/plans/2026-06-18-qqbot-napcat-runtime-protocol-profile-implementation-plan.md` 的 Task 10 执行。当前实现覆盖运行态/协议/会话行为/登录事件/风险模式表,真实物理设备风格 hostname/MAC,NapCat/OneBot 配置 hash,KT `zh_CN.UTF-8` 中国桌面派生镜像资产,只读 `/qqbot/napcat/runtime/detail` 证据接口,watchdog quick -> password 熔断,以及 Admin 账号页“运行态”抽屉;不绕过 QQ/Tencent 验证码、不修改 QQ/NTQQ 签名协议、不启用 privileged/host network,也不做账号级每小时/每日累计发送预算。 +NapCat Runtime/Protocol Profile 已完成本地 API/Admin 实施,线上发布和账号闭环按 `docs/superpowers/plans/2026-06-18-qqbot-napcat-runtime-protocol-profile-implementation-plan.md` 的 Task 10 执行。当前实现覆盖运行态/协议/会话行为/历史登录事件兼容表/风险模式表,真实物理设备风格 hostname/MAC,NapCat/OneBot 配置 hash,KT `zh_CN.UTF-8` 中国桌面派生镜像资产,只读 `/qqbot/napcat/runtime/detail` 证据接口,watchdog 离线巡检告警,以及 Admin 账号页“运行态”抽屉;不绕过 QQ/Tencent 验证码、不修改 QQ/NTQQ 签名协议、不启用 privileged/host network,也不做账号级每小时/每日累计发送预算。 ## 启动 @@ -167,10 +167,10 @@ API 暴露 `GET /health/runtime` 作为本地 smoke、Jenkins/K8s 和 ktWorkflow - QQBot 同一账号只允许一个有效 NapCat 主容器;绑定新容器时会释放旧绑定和不再共享的旧容器,机器人下线 notice、`isOnline:false` 和 NapCat 容器最新离线日志都会写入账号 `lastError`,普通群成员 kick 不属于账号离线信号;写入 `last_error` 前按 500 字符截断,后续无错误的普通断连不能清空该原因;账号列表拆开展示 OneBot、容器、WebUI 和 QQ 登录态,心跳只代表 OneBot/容器通信,不能推导 QQ 登录态;近期连接只用于避免重连瞬间被旧缓存误伤,后续仍必须以 NapCat WebUI/日志检查判断 QQ 登录态;`qqLoginMessage` 只展示 QQ 登录态消息,WebUI 配置或请求错误留在 `lastError`。 - NapCat 托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像;生产切换镜像前先 pin 明确版本或 digest 并单账号观察。 - NapCat 账号新增/编辑支持可选 QQ 登录密码:Admin 只提交 RSA-OAEP 加密后的 `encryptedLoginPassword`,后端解密后必须用显式配置的 `QQBOT_ACCOUNT_SECRET_KEY`(或非默认 `ADMIN_TOKEN_SECRET`)二次加密保存到 `qqbot_account.napcat_login_password_secret`;空值、`change-me` 和历史公开默认值会被拒绝;列表和详情不回显密码,日志会脱敏密码字段。 -- NapCat 容器为已知 `selfId` 创建/重建时会注入 `ACCOUNT` 环境变量启用 `-q` 快速登录:容器重启(崩溃/重启策略/宿主重启)能从持久化会话免扫码自动重登;硬踢 `登录已失效` 会话作废仍需扫码。已绑定但缺少 `ACCOUNT` 的旧容器在下一次「更新登录」时原地重建一次补齐(保留 QQ 数据卷),`docker inspect` 已带 `ACCOUNT` 则跳过,重建失败不阻断登录。`ACCOUNT` 只负责指定快速登录账号;如果数据卷内没有该 QQ 历史登录记录,后端会跳过快速登录并优先尝试账号保存的登录密码,密码登录会按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询结果,准备中的扫码会话会续期,避免后台密码登录未结束时前端先过期;如果 API Pod 在准备阶段重启,持久化的 `preparingRelogin` 超过 `QQBOT_NAPCAT_RELOGIN_PREPARING_STALE_MS`(留空使用密码登录等待窗口加缓冲)后会自动恢复为普通状态检测,不会永久卡在“正在尝试密码登录”。如果 NapCat 返回 `proofWaterUrl` 或日志出现“需要验证码”,会保持会话 pending 并把腾讯验证码结果 `ticket`/`randstr`/`sid` 通过 `/qqbot/account/scan/captcha/submit` 回交到同一容器的 `/api/QQLogin/CaptchaLogin`,不是让用户只打开外链;验证码和新设备验证这类真人交互态使用 `NAPCAT_LOGIN_HUMAN_VERIFY_EXPIRE_MS`(默认 15 分钟)续期,普通二维码仍使用 `NAPCAT_LOGIN_QR_EXPIRE_MS`;状态轮询遇到验证码文案但缺少 URL 时,会先从当前容器日志恢复 `proofWaterUrl`,没有 URL 也保持验证码处理中而不切到二维码兜底;密码登录成功后会移除运行态 `NAPCAT_QUICK_PASSWORD`,清理失败则本次登录失败,避免成功态残留明文 env;密码登录也失败、验证码未完成、离线、账号不匹配或缺少 QQ 号时才重置登录态并生成二维码。Admin「更新登录」的 SSE 步骤顺序按实际路径为 `quick-login-*`(已有历史会话)-> `password-login-*` / `password-login-captcha` -> `password-env-cleanup` -> `relogin-reset/qrcode/waiting-scan`;SSE 事件缓存因 Pod 重启丢失时,新订阅会收到当前会话快照。 +- NapCat 容器为已知 `selfId` 创建/重建时会一次性注入 `ACCOUNT` 等必要 env;容器重启(崩溃/重启策略/宿主重启)可复用持久化会话,但硬踢 `登录已失效` 仍需人工登录。Admin「更新登录」不通过 Docker 重建、重启或补 env 刷新登录态:只要源容器在线,就保持同一容器并通过 NapCat WebUI `SetQuickLogin -> PasswordLogin -> RefreshQRcode/GetQQLoginQrcode` 推进原弹窗流程;只有 Docker 容器离线或缺失时,容器准备阶段才创建/重建并一次性注入 env。快速登录失败后,如果账号保存了登录密码,后端使用解密密码计算 MD5 调 `/api/QQLogin/PasswordLogin`,不会把密码写入运行态 env,也没有成功后的 env 清理步骤;密码登录按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询结果。准备中的扫码会话会续期,重复调用更新登录会复用同一 pending `sessionId`,不会再次启动 quick/password/二维码准备。若 API Pod 在准备阶段重启,持久化的 `preparingRelogin` 超过 `QQBOT_NAPCAT_RELOGIN_PREPARING_STALE_MS` 后会自动恢复为普通状态检测。验证码和新设备验证保持同一会话 pending:腾讯验证码结果 `ticket`/`randstr`/`sid` 通过 `/qqbot/account/scan/captcha/submit` 回交到同一容器的 `/api/QQLogin/CaptchaLogin`;状态轮询遇到验证码文案但缺少 URL 时会先从当前容器日志恢复 `proofWaterUrl`,没有 URL 也保持验证码处理中而不切到二维码兜底。密码登录仍失败、验证码未完成、离线、账号不匹配或缺少 QQ 号时,直接通过 WebUI 二维码接口进入扫码兜底,不 reset 登录态。Admin SSE 步骤顺序按实际路径为 `quick-login-*` -> `password-login-*` / `password-login-captcha` / 新设备验证 -> `qrcode/waiting-scan` -> `login-success|login-failed`;SSE 事件缓存因 Pod 重启丢失时,新订阅会收到当前会话快照。 - NapCat 设备身份按账号持久化到 `napcat_device_identity`:同一账号重建容器会复用数据目录、`pc-<8hex>` hostname、machine-id 和 `02:42:*` MAC;Docker run 会注入 `--hostname`、`--mac-address`、只读 `/etc/machine-id`,并同步写入 QQNT Linux `machine-info`,使 `/etc/machine-id`、Docker MAC 和 QQNT 本地设备缓存保持一致。当前策略名为 `qqnt-visible-hostname-v1` / `docker-bridge-mac-v1`。 - NapCat 新设备验证走同一 scan session:`CaptchaLogin` 返回 `needNewDevice` 后,后端继续调用 `GetNewDeviceQRCode -> PollNewDeviceQR -> NewDeviceLogin`,Admin/SSE 分开展示 `captchaUrl`、`newDeviceQrcode`、已扫码、确认中、验证成功、登录成功/失败等中文进度,不把 `jumpUrl` 当作唯一完成入口。 -- NapCat 离线看门狗按 `QQBOT_NAPCAT_WATCHDOG_INTERVAL_MS`(默认 `120000`,最小 `30000`,`QQBOT_NAPCAT_WATCHDOG_ENABLED=false` 关闭)定时巡检在线账号,使掉线/被踢无需管理员打开列表页即可及时发现;检测到离线后先尝试 `ACCOUNT` 历史会话快速登录,再尝试账号保存的登录密码,仍失败时写入离线原因并复用 `super` 站内信告警;看门狗不自动进入扫码阶段。 +- NapCat 离线看门狗按 `QQBOT_NAPCAT_WATCHDOG_INTERVAL_MS`(默认 `120000`,最小 `30000`,`QQBOT_NAPCAT_WATCHDOG_ENABLED=false` 关闭)定时巡检在线账号,使掉线/被踢无需管理员打开列表页即可及时发现;检测到离线后只写入离线原因并复用 `super` 站内信告警,恢复登录必须由管理员在 Admin 手动触发「更新登录」。 - BangDream 当前源码根目录是 `src/modules/qqbot/plugins/bangdream/src`;按第三期插件结构放置真实职责代码:业务在 `domain/*`,编排在 `application`,操作在 `operations`,外部 API 在 `infrastructure/integration`,缓存/静态修正在 `infrastructure/storage`,字典和静态配置在 `config`,视觉渲染公共件在 `theme`;不要恢复旧 `tsugu` 层级、旧大桶目录、纯 re-export 转接文件或空 `.gitkeep` 目录壳。 - BangDream 在线命令以 `plugins/bangdream/plugin.json` 为单一来源,新增命令必须同步 SQL/在线命令表并跑 manifest/command-SQL 测试。 - BangDream event stage 大图必须保持分页拆图行为,线上 smoke 关注 `imageCount=5`,避免大 canvas OOM 回归。 diff --git a/src/modules/qqbot/core/application/account/qqbot-account-napcat-runtime.port.ts b/src/modules/qqbot/core/application/account/qqbot-account-napcat-runtime.port.ts index 37f1419..1afdd0e 100644 --- a/src/modules/qqbot/core/application/account/qqbot-account-napcat-runtime.port.ts +++ b/src/modules/qqbot/core/application/account/qqbot-account-napcat-runtime.port.ts @@ -1,8 +1,5 @@ import type { QqbotAccount } from '../../infrastructure/persistence/account/qqbot-account.entity'; -import type { - QqbotAccountListItem, - QqbotConnectionRole, -} from '../../contract/qqbot.types'; +import type { QqbotAccountListItem } from '../../contract/qqbot.types'; export const QQBOT_ACCOUNT_NAPCAT_RUNTIME_PORT = Symbol( 'QQBOT_ACCOUNT_NAPCAT_RUNTIME_PORT', @@ -10,14 +7,6 @@ export const QQBOT_ACCOUNT_NAPCAT_RUNTIME_PORT = Symbol( export type QqbotAccountNapcatRuntimeActions = { clearQqLoginError(selfId: string): Promise; - getLoginPassword( - account: Pick, - ): string; - markOnline( - selfId: string, - clientRole: QqbotConnectionRole, - lastError?: null | string, - ): Promise; markQqLoginOffline(selfId: string, lastError: string): Promise; publishOfflineNotice( selfId: string, @@ -29,7 +18,6 @@ export type QqbotAccountNapcatRuntimeActions = { export type QqbotAccountNapcatRuntimePort = { appendRuntime( accounts: QqbotAccount[], - options: { autoLogin?: boolean }, actions: QqbotAccountNapcatRuntimeActions, ): Promise; removeAccountContainers(accountId: string): Promise<{ diff --git a/src/modules/qqbot/core/application/account/qqbot-account.service.ts b/src/modules/qqbot/core/application/account/qqbot-account.service.ts index 9daec85..99901a8 100644 --- a/src/modules/qqbot/core/application/account/qqbot-account.service.ts +++ b/src/modules/qqbot/core/application/account/qqbot-account.service.ts @@ -475,7 +475,7 @@ export class QqbotAccountService { /** * 看门狗:主动巡检在线的已绑定账号,复用既有离线检测 + 站内信告警逻辑, * 让掉线/被踢能被及时发现并通知超管,而不必等管理员打开账号列表页。 - * 检测到离线后先尝试快速登录,再尝试密码登录;扫码登录仍只由管理员手动触发。 + * 风控下线后不再自动尝试快速/密码登录,只通知管理员手动重新登录。 */ async runOfflineWatchdog(): Promise<{ checked: number }> { const accounts = await this.accountRepository @@ -489,7 +489,7 @@ export class QqbotAccountService { .getMany(); if (accounts.length <= 0) return { checked: 0 }; - await this.appendNapcatRuntime(accounts, { autoLogin: true }); + await this.appendNapcatRuntime(accounts); return { checked: accounts.length }; } @@ -544,12 +544,10 @@ export class QqbotAccountService { /** * 执行 QQBot 核心流程。 * @param accounts - 账号列表;转换 QQBot列表项。 - * @param options - QQBot列表;驱动 `napcatRuntime.appendRuntime()` 的 QQBot步骤。 * @returns 异步完成后的 QQBot 核心结果。 */ private async appendNapcatRuntime( accounts: QqbotAccount[], - options: { autoLogin?: boolean } = {}, ): Promise { if (!this.napcatRuntime) { return accounts.map((account) => @@ -557,7 +555,7 @@ export class QqbotAccountService { ); } - return this.napcatRuntime.appendRuntime(accounts, options, { + return this.napcatRuntime.appendRuntime(accounts, { /** * 执行 QQBot回调。 * @param selfId - 账号 ID;定位本次读取、更新、删除或关联的账号。 @@ -565,19 +563,6 @@ export class QqbotAccountService { clearQqLoginError: async (selfId) => { await this.accountRepository.update({ selfId }, { lastError: null }); }, - /** - * 读取 QQBot回调数据。 - * @param account - account 输入;驱动 `this.getNapcatLoginPassword()` 的 QQBot步骤。 - */ - getLoginPassword: (account) => this.getNapcatLoginPassword(account), - /** - * 执行 QQBot回调。 - * @param selfId - 账号 ID;定位本次读取、更新、删除或关联的账号。 - * @param clientRole - clientRole 输入;驱动 `this.markOnline()` 的 QQBot步骤。 - * @param lastError - lastError 输入;驱动 `this.markOnline()` 的 QQBot步骤。 - */ - markOnline: (selfId, clientRole, lastError) => - this.markOnline(selfId, clientRole, lastError), /** * 执行 QQBot回调。 * @param selfId - 账号 ID;定位本次读取、更新、删除或关联的账号。 @@ -609,9 +594,10 @@ export class QqbotAccountService { ) { if (!this.systemNoticePublisher) return; + const noticeContent = `${offlineReason}\n请在 Admin 的 QQBot 账号页面手动点击「更新登录」重新登录。`; void this.systemNoticePublisher .publishSystemNotice({ - content: offlineReason, + content: noticeContent, dedupeKey: `qqbot:offline:${selfId}`, eventType: 'qqbot.account.offline', metadata: { @@ -621,7 +607,7 @@ export class QqbotAccountService { notifyRoleCode: 'super', severity: 'error', source: 'qqbot', - summary: offlineReason, + summary: noticeContent, title: `QQBot 账号已下线:${selfId}`, }) .catch(() => undefined); diff --git a/src/modules/qqbot/core/contract/qqbot.types.ts b/src/modules/qqbot/core/contract/qqbot.types.ts index ca635ff..4c70a1e 100644 --- a/src/modules/qqbot/core/contract/qqbot.types.ts +++ b/src/modules/qqbot/core/contract/qqbot.types.ts @@ -34,8 +34,10 @@ export type NapcatLoginStatus = { export type NapcatCaptchaLoginResult = { jumpUrl?: string; + needCaptcha?: boolean; needNewDevice?: boolean; newDevicePullQrCodeSig?: unknown; + proofWaterUrl?: string; }; export type NapcatQrcode = { @@ -337,6 +339,8 @@ export type QqbotLoginScanSession = { passwordMd5?: string; preparingRelogin?: boolean; qrcode?: string; + runtimeRebuildCount?: number; + sourceContainerOnline?: boolean; status: QqbotLoginScanStatus; webuiPort?: null | number; }; @@ -353,6 +357,8 @@ export type QqbotNapcatRuntime = { hasExistingPrimaryBinding?: boolean; id?: string; name: string; + runtimeRebuildCount?: number; + sourceContainerOnline?: boolean; webuiPort?: null | number; webuiToken?: null | string; }; diff --git a/src/modules/qqbot/napcat/application/account-runtime/qqbot-napcat-account-runtime.service.ts b/src/modules/qqbot/napcat/application/account-runtime/qqbot-napcat-account-runtime.service.ts index 18fcb7d..62812af 100644 --- a/src/modules/qqbot/napcat/application/account-runtime/qqbot-napcat-account-runtime.service.ts +++ b/src/modules/qqbot/napcat/application/account-runtime/qqbot-napcat-account-runtime.service.ts @@ -11,15 +11,12 @@ import type { QqbotAccountListItem, QqbotNapcatRuntimeStatusSnapshot, } from '@/modules/qqbot/core/contract/qqbot.types'; -import { NapcatLoginEventService } from '../runtime/napcat-login-event.service'; import { NapcatRuntimeProfileInspectorService } from '../runtime/napcat-runtime-profile-inspector.service'; import { QqbotNapcatContainerService } from '../../infrastructure/integration/container/qqbot-napcat-container.service'; import { NapcatAccountBinding } from '../../infrastructure/persistence/napcat-account-binding.entity'; import { NapcatContainer } from '../../infrastructure/persistence/napcat-container.entity'; const NAPCAT_RUNTIME_CHECK_TTL_MS = 30_000; -const NAPCAT_AUTO_LOGIN_CLEANUP_FAILED_MESSAGE = - 'NapCat 自动登录后运行态密码清理失败,请手动更新登录'; @Injectable() export class QqbotNapcatAccountRuntimeService implements QqbotAccountNapcatRuntimePort { @@ -27,10 +24,9 @@ export class QqbotNapcatAccountRuntimeService implements QqbotAccountNapcatRunti * Creates the account-list runtime adapter that joins persisted bindings, container status, and optional profile summaries. * @param accountNapcatRepository - Binding repository used to pick the primary NapCat container for each QQBot account. * @param napcatContainerRepository - Container repository used for cached runtime/WebUI status and non-secret metadata. - * @param napcatContainerService - Runtime integration service for bounded NapCat/WebUI status probes and auto-login. + * @param napcatContainerService - Runtime integration service for bounded NapCat/WebUI status probes. * @param toolsService - Shared helpers for status text normalization and NapCat offline-message classification. * @param runtimeProfileInspector - Optional profile reader that enriches list rows without changing login state. - * @param loginEventService - Optional login-event gate that prevents watchdog from repeating suspended recovery flows. */ constructor( @InjectRepository(NapcatAccountBinding) @@ -40,19 +36,16 @@ export class QqbotNapcatAccountRuntimeService implements QqbotAccountNapcatRunti private readonly napcatContainerService: QqbotNapcatContainerService, private readonly toolsService: ToolsService, private readonly runtimeProfileInspector?: NapcatRuntimeProfileInspectorService, - private readonly loginEventService?: NapcatLoginEventService, ) {} /** * 执行 NapCat 登录运行态流程。 * @param accounts - 账号列表;使用 `length` 字段生成结果。 - * @param options - NapCat列表;驱动 `this.syncNapcatRuntimeState()` 的 NapCat步骤。 * @param actions - NapCat列表;驱动 `this.syncNapcatRuntimeState()` 的 NapCat步骤。 * @returns 异步完成后的 NapCat 登录运行态结果。 */ async appendRuntime( accounts: QqbotAccount[], - options: { autoLogin?: boolean }, actions: QqbotAccountNapcatRuntimeActions, ): Promise { if (accounts.length <= 0) return []; @@ -104,7 +97,6 @@ export class QqbotNapcatAccountRuntimeService implements QqbotAccountNapcatRunti const runtimeStatus = await this.syncNapcatRuntimeState( account, container, - options, actions, ); return Object.assign(account, { @@ -144,13 +136,11 @@ export class QqbotNapcatAccountRuntimeService implements QqbotAccountNapcatRunti * 更新 NapCat 登录运行态状态。 * @param account - account 输入;使用 `connectStatus` 字段生成结果。 * @param container - container 输入;使用 `status`、`lastCheckedAt` 字段生成结果。 - * @param options - NapCat列表;使用 `autoLogin` 字段生成结果。 * @param actions - NapCat列表;驱动 `this.getNapcatRuntimeStatus()`、`this.applyNapcatOfflineState()` 的 NapCat步骤。 */ private async syncNapcatRuntimeState( account: QqbotAccount, container: NapcatContainer | undefined, - options: { autoLogin?: boolean }, actions: QqbotAccountNapcatRuntimeActions, ) { const runtimeStatus = await this.getNapcatRuntimeStatus( @@ -168,12 +158,6 @@ export class QqbotNapcatAccountRuntimeService implements QqbotAccountNapcatRunti const runtimeOfflineReason = this.getRuntimeStatusOfflineReason(runtimeStatus); if (runtimeOfflineReason) { - if ( - options.autoLogin && - (await this.tryAutoLogin(account, container, actions)) - ) { - return this.toCachedNapcatRuntimeStatus(container); - } await this.applyNapcatOfflineState( account, container, @@ -185,12 +169,6 @@ export class QqbotNapcatAccountRuntimeService implements QqbotAccountNapcatRunti const cachedOfflineReason = this.getFreshCachedOfflineReason(container); if (cachedOfflineReason) { - if ( - options.autoLogin && - (await this.tryAutoLogin(account, container, actions)) - ) { - return this.toCachedNapcatRuntimeStatus(container); - } await this.applyNapcatOfflineState( account, container, @@ -207,13 +185,6 @@ export class QqbotNapcatAccountRuntimeService implements QqbotAccountNapcatRunti await this.napcatContainerService.detectRuntimeOffline(container); if (!offlineReason) return runtimeStatus; - if ( - options.autoLogin && - (await this.tryAutoLogin(account, container, actions)) - ) { - return this.toCachedNapcatRuntimeStatus(container); - } - await this.applyNapcatOfflineState( account, container, @@ -362,63 +333,6 @@ export class QqbotNapcatAccountRuntimeService implements QqbotAccountNapcatRunti ); } - /** - * 执行 NapCat 登录运行态流程。 - * @param account - account 输入;使用 `selfId`、`clientRole`、`connectStatus`、`lastConnectedAt` 字段生成结果。 - * @param container - container 输入;驱动 `napcatContainerService.tryAutoLogin()`、`this.applyNapcatOfflineState()` 的 NapCat步骤。 - * @param actions - NapCat列表;执行 `actions.getLoginPassword()`、`actions.markOnline()` 对应的 NapCat步骤。 - */ - private async tryAutoLogin( - account: QqbotAccount, - container: NapcatContainer, - actions: QqbotAccountNapcatRuntimeActions, - ) { - try { - const recoveryGate = - await this.loginEventService?.canAttemptAutomaticRecovery({ - accountId: account.id, - containerId: container.id, - resetAfter: account.lastConnectedAt, - }); - if (recoveryGate && !recoveryGate.allowed) { - await this.loginEventService?.recordSuspended({ - accountId: account.id, - containerId: container.id, - evidence: { - reason: recoveryGate.reason, - }, - reason: recoveryGate.reason || 'recovery_suspended', - source: 'watchdog', - }); - return false; - } - - const result = await this.napcatContainerService.tryAutoLogin(container, { - loginPassword: actions.getLoginPassword(account), - selfId: account.selfId, - }); - if (result.cleanupFailed) { - await this.applyNapcatOfflineState( - account, - container, - NAPCAT_AUTO_LOGIN_CLEANUP_FAILED_MESSAGE, - actions, - ); - return true; - } - if (!result.success) return false; - - await actions.markOnline(account.selfId, 'Universal', null); - account.clientRole = 'Universal'; - account.connectStatus = 'online'; - account.lastConnectedAt = new Date() as any; - account.lastError = null; - return true; - } catch { - return false; - } - } - /** * 执行 NapCat 登录运行态流程。 * @param account - account 输入;使用 `selfId`、`lastError` 字段生成结果。 diff --git a/src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts b/src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts index c477a43..fa6ae8d 100644 --- a/src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts +++ b/src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts @@ -39,6 +39,10 @@ export class QqbotNapcatLoginService { string, Set<(event: QqbotLoginScanEvent) => void> > = {}; + private readonly refreshStartTasks: Record< + string, + Promise | undefined + > = {}; readonly sessions = { /** * 清理 NapCat回调状态。 @@ -131,26 +135,83 @@ export class QqbotNapcatLoginService { * @param accountId - 账号 ID;定位本次读取、更新、删除或关联的账号。 */ async startRefresh(accountId: string) { + const activeSession = this.findActiveRefreshSession(accountId); + if (activeSession) return this.toResult(activeSession); + + const runningTask = this.refreshStartTasks[accountId]; + if (runningTask) return runningTask; + + const task = this.createRefreshScan(accountId); + this.refreshStartTasks[accountId] = task; + try { + return await task; + } finally { + if (this.refreshStartTasks[accountId] === task) { + delete this.refreshStartTasks[accountId]; + } + } + } + + /** + * 启动账号更新登录会话。 + * @param accountId - 账号 ID;定位账号、主容器和可选登录密码。 + * @returns 创建后的扫码/更新登录会话快照。 + */ + private async createRefreshScan(accountId: string) { const account = await this.accountService.findByIdWithNapcatLoginSecret(accountId); if (!account) { throwVbenError('QQBot 账号不存在'); } const loginPassword = this.accountService.getNapcatLoginPassword(account); - const container = - await this.containerService.prepareAccountContainer(account); - - return this.startScan( - { - accountId: account.id, - expectedSelfId: account.selfId, - forceRelogin: true, - hasExistingPrimaryBinding: container.hasExistingPrimaryBinding, - loginPassword, - mode: 'refresh', - }, - container, + const container = await this.containerService.prepareAccountContainer( + account, + loginPassword, ); + + const scanOptions: { + accountId: string; + expectedSelfId: string; + forceRelogin: true; + hasExistingPrimaryBinding?: boolean; + loginPassword?: string; + mode: 'refresh'; + sourceContainerOnline?: boolean; + } = { + accountId: account.id, + expectedSelfId: account.selfId, + forceRelogin: true, + hasExistingPrimaryBinding: container.hasExistingPrimaryBinding, + loginPassword, + mode: 'refresh', + }; + if (container.sourceContainerOnline !== undefined) { + scanOptions.sourceContainerOnline = container.sourceContainerOnline; + } + + return this.startScan(scanOptions, container); + } + + /** + * 查找可复用的账号更新登录会话。 + * @param accountId - 账号 ID;限定同一账号的 pending refresh 会话。 + * @returns 当前仍有效的 pending refresh 会话;没有时返回 undefined。 + */ + private findActiveRefreshSession(accountId: string) { + const now = Date.now(); + let activeSession: QqbotLoginScanSession | undefined; + this.loginSessionStore.forEach((session) => { + if (activeSession) return; + if ( + session.accountId === accountId && + session.mode === 'refresh' && + session.status === 'pending' && + now <= session.expiresAt + ) { + activeSession = session; + } + }); + return activeSession; } /** @@ -175,33 +236,15 @@ export class QqbotNapcatLoginService { loginStatus = await this.getLoginStatus(container); } catch (err) { if (!this.toolsService.isNapcatTemporaryError(err)) throw err; - await this.restartNapcatForLogin(container, { waitForReady: false }); - session.lastRestartedAt = Date.now(); return this.keepSessionPending( session, - 'NapCat 通信超时,已尝试重启容器并重新生成二维码', + 'NapCat 通信超时,请稍后重试或确认 Docker 容器仍在线', true, ); } - if (loginStatus.isOffline) { - if (session.mode === 'refresh') { - this.publishScanResultEvent( - session, - 'relogin-reset-start', - 'processing', - '开始重置 NapCat 登录态', - ); - await this.resetNapcatForLogin( - container, - { waitForReady: false }, - (step, message) => { - this.publishScanResultEvent(session, step, 'processing', message); - }, - ); - } else { - await this.restartNapcatForLogin(container, { waitForReady: false }); - } + if (loginStatus.isOffline && session.mode !== 'refresh') { + await this.restartNapcatForLogin(container, { waitForReady: false }); session.lastRestartedAt = Date.now(); return this.keepSessionPending( session, @@ -473,7 +516,7 @@ export class QqbotNapcatLoginService { /** * 启动Scan。 * @param options - NapCat列表;使用 `forceRelogin`、`loginPassword`、`hasExistingPrimaryBinding` 字段生成结果。 - * @param container - container 输入;驱动 `this.prepareReloginQrcode()`、`this.getLoginStatus()`、`this.restartNapcatForLogin()`、`this.publishScanResultEvent()` 的 NapCat步骤。 + * @param container - NapCat WebUI 运行态;refresh 模式只通过 WebUI 登录接口推进状态。 * @returns 异步完成后的 NapCat 登录运行态结果。 */ private async startScan( @@ -484,6 +527,7 @@ export class QqbotNapcatLoginService { hasExistingPrimaryBinding?: boolean; loginPassword?: string; mode: QqbotLoginScanMode; + sourceContainerOnline?: boolean; }, container: QqbotNapcatRuntime, ): Promise { @@ -518,6 +562,34 @@ export class QqbotNapcatLoginService { try { const loginStatus = await this.getLoginStatus(container, true); if (loginStatus.isOffline) { + if (options.mode === 'refresh') { + const qrcode = await this.refreshOrGetQrcode(container, false, { + fallbackStatus: loginStatus, + requireFresh: true, + staleQrcode: loginStatus.qrcodeurl, + }); + const session = this.createSession({ + ...options, + container, + qrcode, + status: 'pending', + }); + this.persistLoginSession(session); + this.publishScanResultEvent( + session, + 'qrcode-ready', + 'success', + '登录二维码已生成', + ); + this.publishScanResultEvent( + session, + 'waiting-scan', + 'processing', + '等待扫码确认', + ); + return this.toResult(session); + } + await this.restartNapcatForLogin(container, { waitForReady: false }); const session = this.createSession({ ...options, @@ -653,6 +725,8 @@ export class QqbotNapcatLoginService { mode: QqbotLoginScanMode; preparingRelogin?: boolean; qrcode?: string; + runtimeRebuildCount?: number; + sourceContainerOnline?: boolean; status: QqbotLoginScanStatus; }): QqbotLoginScanSession { const now = Date.now(); @@ -667,6 +741,10 @@ export class QqbotNapcatLoginService { mode: input.mode, preparingRelogin: input.preparingRelogin, qrcode: input.qrcode, + runtimeRebuildCount: + input.runtimeRebuildCount ?? input.container.runtimeRebuildCount, + sourceContainerOnline: + input.sourceContainerOnline ?? input.container.sourceContainerOnline, status: input.status, webuiPort: input.container.webuiPort, }; @@ -1034,7 +1112,7 @@ export class QqbotNapcatLoginService { /** * 执行 NapCat 登录运行态流程。 * @param session - session 输入;使用 `expectedSelfId` 字段生成结果。 - * @param container - container 输入;驱动 `this.waitForPasswordLoginStatus()`、`this.failCaptchaLogin()`、`this.getLoginInfo()`、`this.clearRuntimeLoginPasswordAfterFailedPassword()` 的 NapCat步骤。 + * @param container - container 输入;驱动 `this.waitForPasswordLoginStatus()`、`this.failCaptchaLogin()`、`this.getLoginInfo()` 的 NapCat步骤。 * @param successMessage - successMessage 输入;影响 completePasswordLoginAfterChallenge 的返回值。 */ private async completePasswordLoginAfterChallenge( @@ -1071,26 +1149,12 @@ export class QqbotNapcatLoginService { ); } if (session.expectedSelfId && session.expectedSelfId !== selfId) { - await this.clearRuntimeLoginPasswordAfterFailedPassword( - session, - container, - selfId, - ); return this.failSession( session, `当前密码登录账号 ${selfId} 与目标账号 ${session.expectedSelfId} 不一致`, ); } - try { - await this.clearRuntimeLoginPasswordAfterSuccess( - session, - container, - selfId, - ); - } catch { - return this.toResult(session); - } return this.completeLogin(session, container, { loginInfo, successMessage, @@ -1422,47 +1486,13 @@ export class QqbotNapcatLoginService { cleanupFailureMessage?: string, ) { if (!session.passwordMd5 && !session.captchaUrl) return true; - const runtime = container || (await this.getSessionContainer(session)); - const failureMessage = - cleanupFailureMessage || - (session.status === 'success' - ? 'NapCat 密码登录已完成,但运行态密码清理失败,请重试更新登录' - : 'NapCat 密码登录未完成,且运行态密码清理失败,请重试更新登录'); - - this.publishScanResultEvent( - session, - 'password-env-cleanup', - 'processing', - '正在移除运行态登录密码', - ); - const cleaned = await this.containerService.ensureRuntimeLoginEnv(runtime, { - clearLoginPassword: true, - selfId: selfId || session.expectedSelfId, - }); - if (!cleaned.ok) { - session.status = 'error'; - session.captchaUrl = undefined; - session.errorMessage = failureMessage; - session.passwordMd5 = undefined; - session.preparingRelogin = false; - this.persistLoginSession(session); - this.persistRuntimeCleanup(session, { - errorMessage: failureMessage, - status: 'failed', - }); - this.publishScanEvent(session, { - message: failureMessage, - result: this.toResult(session), - status: 'error', - step: 'password-env-cleanup-failed', - }); - return false; - } + void container; + void selfId; + void cleanupFailureMessage; session.captchaUrl = undefined; session.passwordMd5 = undefined; this.persistLoginSession(session); - this.persistRuntimeCleanup(session, { status: 'success' }); return true; } @@ -1484,21 +1514,6 @@ export class QqbotNapcatLoginService { this.loginSessionStore.recordNewDeviceChallenge(session); } - /** - * 保存 NapCat 登录运行态数据。 - * @param session - session 输入;驱动 `loginSessionStore.recordRuntimeCleanup()` 的 NapCat步骤。 - * @param input - input 输入;影响 persistRuntimeCleanup 的返回值。 - */ - private persistRuntimeCleanup( - session: QqbotLoginScanSession, - input: { errorMessage?: string; status: 'failed' | 'pending' | 'success' }, - ) { - this.loginSessionStore.recordRuntimeCleanup(session, { - cleanupType: 'password-login-env', - ...input, - }); - } - /** * 查询 NapCat 登录运行态数据。 * @param sessionId - NapCat ID;定位本次读取、更新、删除或关联的NapCat。 @@ -1814,7 +1829,7 @@ export class QqbotNapcatLoginService { /** * 执行 NapCat 登录运行态流程。 - * @param container - container 输入;驱动 `this.restartNapcatForLogin()`、`this.callRefreshQrcode()`、`this.getQrcode()` 的 NapCat步骤。 + * @param container - NapCat WebUI 运行态;只用于调用二维码刷新/获取接口。 * @param retry - retry 输入;驱动 `this.callRefreshQrcode()`、`this.getQrcode()` 的 NapCat步骤。 * @param options - NapCat列表;使用 `fallbackStatus`、`requireFresh`、`staleQrcode` 字段生成结果。 */ @@ -1823,15 +1838,11 @@ export class QqbotNapcatLoginService { retry = false, options: QrcodeRefreshOptions = {}, ) { - let fallbackStatus = options.fallbackStatus; + const fallbackStatus = options.fallbackStatus; const lookupOptions: QrcodeLookupOptions = { requireFresh: options.requireFresh || fallbackStatus?.isOffline, staleQrcode: options.staleQrcode || fallbackStatus?.qrcodeurl, }; - if (fallbackStatus?.isOffline) { - await this.restartNapcatForLogin(container); - fallbackStatus = undefined; - } try { const refreshedQrcode = await this.callRefreshQrcode(container, retry); if (refreshedQrcode) { @@ -1896,8 +1907,8 @@ export class QqbotNapcatLoginService { /** * 执行 NapCat 登录运行态流程。 - * @param session - session 输入;使用 `lastRestartedAt`、`qrcode`、`errorMessage`、`expiresAt` 字段生成结果。 - * @param container - container 输入;驱动 `this.tryQuickRelogin()`、`this.tryPasswordRelogin()`、`this.resetNapcatForLogin()`、`this.refreshOrGetQrcode()` 的 NapCat步骤。 + * @param session - 更新登录会话;保存 WebUI 登录尝试、二维码和人工验证状态。 + * @param container - NapCat WebUI 运行态;所有刷新动作都通过 WebUI 登录接口完成。 * @param loginPassword - loginPassword 输入;驱动 `toolsService.toSecretText()` 的 NapCat步骤。 * @param hasExistingPrimaryBinding - hasExistingPrimaryBinding 输入;决定 NapCat条件分支。 */ @@ -1909,6 +1920,13 @@ export class QqbotNapcatLoginService { ) { try { const password = this.toolsService.toSecretText(loginPassword); + if (session.sourceContainerOnline === true) { + const completed = await this.completeOnlineSourceRefresh( + session, + container, + ); + if (completed) return; + } if (hasExistingPrimaryBinding) { const quickLoginCompleted = await this.tryQuickRelogin( session, @@ -1925,34 +1943,13 @@ export class QqbotNapcatLoginService { ); if (passwordLoginCompleted) return; - this.publishScanResultEvent( - session, - 'relogin-reset-start', - 'processing', - '开始重置 NapCat 登录态', - ); - await this.resetNapcatForLogin( - container, - { waitForReady: false }, - (step, message) => { - this.publishScanResultEvent(session, step, 'processing', message); - }, - ); - session.lastRestartedAt = Date.now(); - this.publishScanResultEvent( - session, - 'napcat-ready-wait', - 'processing', - '等待 NapCat WebUI 启动', - ); - await this.toolsService.sleep(this.getRestartDelayMs()); this.publishScanResultEvent( session, 'qrcode-fetch', 'processing', '正在获取登录二维码', ); - session.qrcode = await this.refreshOrGetQrcode(container, true, { + session.qrcode = await this.refreshOrGetQrcode(container, false, { requireFresh: true, }); session.errorMessage = undefined; @@ -2000,10 +1997,39 @@ export class QqbotNapcatLoginService { } } + /** + * 处理 Docker 源容器仍在线的更新登录流程。 + * @param session - 更新登录会话;若当前容器已经登录目标账号则直接完成。 + * @param container - NapCat 运行态;用于只读检查当前 QQ 登录态。 + * @returns 当前容器已登录目标账号并完成会话时返回 true,否则返回 false 继续 WebUI 登录流程。 + */ + private async completeOnlineSourceRefresh( + session: QqbotLoginScanSession, + container: QqbotNapcatRuntime, + ) { + const loginStatus = await this.getLoginStatus(container, true); + if (!loginStatus.isLogin) return false; + + const loginInfo = await this.getLoginInfo(container); + if (loginInfo.online === false) return false; + + const selfId = this.toolsService.pickNapcatSelfId(loginInfo); + if (!selfId) return false; + if (session.expectedSelfId && session.expectedSelfId !== selfId) { + return false; + } + + await this.completeLogin(session, container, { + loginInfo, + successMessage: '当前 NapCat 容器已在线,无需重建登录', + }); + return true; + } + /** * 执行 NapCat 登录运行态流程。 - * @param session - session 输入;使用 `errorMessage`、`lastRestartedAt`、`expectedSelfId` 字段生成结果。 - * @param container - container 输入;驱动 `this.clearRuntimeLoginPasswordBeforeQuick()`、`this.restartNapcatForLogin()`、`this.getLoginStatus()`、`this.getLoginInfo()` 的 NapCat步骤。 + * @param session - 更新登录会话;提供目标 QQ 号并保存 quick 登录进度。 + * @param container - NapCat WebUI 运行态;接收 SetQuickLogin 并返回登录状态。 * @param hasPasswordFallback - hasPasswordFallback 输入;驱动 `this.publishQuickLoginFallback()` 的 NapCat步骤。 */ private async tryQuickRelogin( @@ -2018,21 +2044,29 @@ export class QqbotNapcatLoginService { session, 'quick-login-start', 'processing', - '正在尝试 NapCat -q 快速登录', + '正在尝试 NapCat 快速登录', ); - await this.clearRuntimeLoginPasswordBeforeQuick(session, container); try { - await this.restartNapcatForLogin(container, { waitForReady: false }); - session.lastRestartedAt = Date.now(); + const uin = this.toolsService.toTrimmedString(session.expectedSelfId); + if (!uin) { + this.publishQuickLoginFallback( + session, + '缺少目标 QQ 号', + hasPasswordFallback, + ); + return false; + } + await this.postNapcat(container, '/api/QQLogin/SetQuickLogin', { + uin, + }); this.publishScanResultEvent( session, 'quick-login-wait', 'processing', '等待 NapCat 快速登录结果', ); - await this.toolsService.sleep(this.getRestartDelayMs()); - const loginStatus = await this.getLoginStatus(container, true); + const loginStatus = await this.waitForQuickLoginStatus(container); if (!loginStatus.isLogin) { this.publishQuickLoginFallback( session, @@ -2087,9 +2121,9 @@ export class QqbotNapcatLoginService { /** * 执行 NapCat 登录运行态流程。 - * @param session - session 输入;使用 `passwordMd5`、`errorMessage`、`expectedSelfId`、`lastRestartedAt` 字段生成结果。 - * @param container - container 输入;驱动 `containerService.ensureRuntimeLoginEnv()`、`this.restartNapcatForLogin()`、`this.waitForPasswordLoginStatus()`、`this.getLoginInfo()` 的 NapCat步骤。 - * @param loginPassword - loginPassword 输入;驱动 `toolsService.toSecretText()` 的 NapCat步骤。 + * @param session - 更新登录会话;保存密码 MD5、验证码和新设备验证上下文。 + * @param container - NapCat WebUI 运行态;接收 PasswordLogin 并返回后续人工验证要求。 + * @param loginPassword - 解密后的 QQ 密码明文;只用于本次 WebUI PasswordLogin 的 MD5 计算。 */ private async tryPasswordRelogin( session: QqbotLoginScanSession, @@ -2103,11 +2137,11 @@ export class QqbotNapcatLoginService { } let loginInfo: NapcatLoginInfo | undefined; - let loggedInSelfId = ''; const passwordLogSinceMs = Date.now(); session.passwordMd5 = createHash('md5') .update(password, 'utf8') .digest('hex'); + session.lastRestartedAt = passwordLogSinceMs; session.errorMessage = 'NapCat 正在尝试密码登录,请稍后'; this.persistLoginSession(session); this.publishScanResultEvent( @@ -2117,22 +2151,29 @@ export class QqbotNapcatLoginService { '正在尝试 NapCat 密码登录', ); - const passwordEnv = await this.containerService.ensureRuntimeLoginEnv( - container, - { - loginPassword: password, - selfId: session.expectedSelfId, - }, - ); - if (!passwordEnv.ok) { - this.publishPasswordLoginFallback(session, '运行态密码环境准备失败'); - return false; - } - let loginStatus: NapcatLoginStatus; try { - await this.restartNapcatForLogin(container, { waitForReady: false }); - session.lastRestartedAt = Date.now(); + const uin = this.toolsService.toTrimmedString(session.expectedSelfId); + if (!uin) { + this.publishPasswordLoginFallback(session, '缺少目标 QQ 号'); + return false; + } + const passwordResult = + await this.postNapcat( + container, + '/api/QQLogin/PasswordLogin', + { + passwordMd5: session.passwordMd5, + uin, + }, + ); + const passwordResultPending = await this.applyPasswordLoginResult( + session, + container, + passwordResult, + passwordLogSinceMs, + ); + if (passwordResultPending) return true; this.publishScanResultEvent( session, 'password-login-wait', @@ -2148,14 +2189,22 @@ export class QqbotNapcatLoginService { loginInfo = await this.getLoginInfo(container); } } catch (err) { - await this.clearRuntimeLoginPasswordAfterFailedPassword( - session, - container, - session.expectedSelfId || loggedInSelfId, - ); + const errorMessage = this.toolsService.getErrorMessage(err); + if (this.toolsService.isNapcatCaptchaRequiredMessage(errorMessage)) { + const captchaUrl = await this.waitForPasswordCaptchaUrl( + container, + passwordLogSinceMs, + ); + if (captchaUrl) { + this.keepPasswordCaptchaPending(session, captchaUrl, errorMessage); + } else { + this.keepPasswordCaptchaWaitingForUrl(session, errorMessage); + } + return true; + } this.publishPasswordLoginFallback( session, - this.toolsService.getErrorMessage(err), + errorMessage, ); return false; } @@ -2179,51 +2228,25 @@ export class QqbotNapcatLoginService { return true; } - await this.clearRuntimeLoginPasswordAfterFailedPassword( - session, - container, - session.expectedSelfId, - ); this.publishPasswordLoginFallback(session, loginStatus.loginError); return false; } if (loginInfo?.online === false) { - await this.clearRuntimeLoginPasswordAfterFailedPassword( - session, - container, - session.expectedSelfId, - ); this.publishPasswordLoginFallback(session, 'NapCat 当前账号已离线'); return false; } if (!loginInfo) { - await this.clearRuntimeLoginPasswordAfterFailedPassword( - session, - container, - session.expectedSelfId, - ); this.publishPasswordLoginFallback(session, 'NapCat 未返回登录信息'); return false; } const selfId = this.toolsService.pickNapcatSelfId(loginInfo); if (!selfId) { - await this.clearRuntimeLoginPasswordAfterFailedPassword( - session, - container, - session.expectedSelfId, - ); this.publishPasswordLoginFallback(session, 'NapCat 未返回 QQ 号'); return false; } - loggedInSelfId = selfId; if (session.expectedSelfId && session.expectedSelfId !== selfId) { - await this.clearRuntimeLoginPasswordAfterFailedPassword( - session, - container, - selfId, - ); this.publishPasswordLoginFallback( session, `当前密码登录账号 ${selfId} 与目标账号 ${session.expectedSelfId} 不一致`, @@ -2231,11 +2254,6 @@ export class QqbotNapcatLoginService { return false; } - await this.clearRuntimeLoginPasswordAfterSuccess( - session, - container, - loggedInSelfId, - ); await this.completeLogin(session, container, { loginInfo, successMessage: '密码登录成功', @@ -2243,10 +2261,82 @@ export class QqbotNapcatLoginService { return true; } + /** + * 轮询 NapCat WebUI 快速登录后的 QQ 登录态。 + * @param container - NapCat WebUI 运行态;用于调用 CheckLoginStatus。 + * @returns 最早出现的成功、失败或可继续二维码状态。 + */ + private async waitForQuickLoginStatus(container: QqbotNapcatRuntime) { + let latestStatus: NapcatLoginStatus = { isLogin: false }; + const attempts = this.getLoginPollAttempts( + this.getQuickLoginWaitMs(), + this.getLoginPollIntervalMs(), + ); + for (let index = 0; index < attempts; index += 1) { + if (index > 0) { + await this.toolsService.sleep(this.getLoginPollIntervalMs()); + } + latestStatus = await this.getLoginStatus(container, true); + if ( + latestStatus.isLogin || + latestStatus.isOffline || + latestStatus.loginError || + latestStatus.qrcodeurl + ) { + return latestStatus; + } + } + return latestStatus; + } + + /** + * 处理 NapCat WebUI PasswordLogin 的同步返回结果。 + * @param session - 更新登录会话;保存验证码、新设备二维码和密码 MD5 上下文。 + * @param container - NapCat WebUI 运行态;新设备二维码和验证码后续提交都回到同一容器。 + * @param result - PasswordLogin 返回体;官方用它声明验证码或新设备验证。 + * @param sinceMs - PasswordLogin 发起时间;限定容器日志验证码 URL 的读取窗口。 + * @returns 已进入人工验证 pending 态时返回 true;没有同步挑战时返回 false。 + */ + private async applyPasswordLoginResult( + session: QqbotLoginScanSession, + container: QqbotNapcatRuntime, + result: NapcatCaptchaLoginResult | null, + sinceMs: number, + ) { + if (!result) return false; + if (result.needNewDevice) { + await this.startNewDeviceVerification(session, container, result); + return true; + } + + const proofWaterUrl = this.toolsService.toTrimmedString( + result.proofWaterUrl, + ); + if (!result.needCaptcha && !proofWaterUrl) return false; + + if (proofWaterUrl) { + this.keepPasswordCaptchaPending(session, proofWaterUrl); + return true; + } + const captchaUrl = await this.waitForPasswordCaptchaUrl( + container, + sinceMs, + ); + if (captchaUrl) { + this.keepPasswordCaptchaPending(session, captchaUrl); + return true; + } + this.keepPasswordCaptchaWaitingForUrl( + session, + '密码登录需要完成 QQ 安全验证', + ); + return true; + } + /** * 执行 NapCat 登录运行态流程。 * @param session - session 输入;使用 `expectedSelfId`、`qrcode`、`captchaUrl`、`errorMessage` 字段生成结果。 - * @param container - container 输入;驱动 `this.clearRuntimeLoginPasswordAfterFailedPassword()`、`this.refreshOrGetQrcode()` 的 NapCat步骤。 + * @param container - container 输入;驱动 `this.refreshOrGetQrcode()` 的 NapCat步骤。 * @param loginStatus - NapCat列表;使用 `qrcodeurl` 字段生成结果。 */ private async keepPasswordQrcodePending( @@ -2254,11 +2344,6 @@ export class QqbotNapcatLoginService { container: QqbotNapcatRuntime, loginStatus: NapcatLoginStatus, ) { - await this.clearRuntimeLoginPasswordAfterFailedPassword( - session, - container, - session.expectedSelfId, - ); this.publishScanResultEvent( session, 'password-login-qrcode', @@ -2273,6 +2358,7 @@ export class QqbotNapcatLoginService { session.captchaUrl = undefined; session.errorMessage = undefined; session.expiresAt = Date.now() + this.getSessionTtlMs(); + session.passwordMd5 = undefined; this.persistLoginSession(session); this.publishScanResultEvent( session, @@ -2442,81 +2528,6 @@ export class QqbotNapcatLoginService { ); } - /** - * 清理Runtime Login Password Before Quick。 - * @param session - session 输入;使用 `expectedSelfId` 字段生成结果。 - * @param container - container 输入;驱动 `containerService.ensureRuntimeLoginEnv()` 的 NapCat步骤。 - */ - private async clearRuntimeLoginPasswordBeforeQuick( - session: QqbotLoginScanSession, - container: QqbotNapcatRuntime, - ) { - this.publishScanResultEvent( - session, - 'quick-login-cleanup', - 'processing', - '正在清理运行态登录密码', - ); - const cleaned = await this.containerService.ensureRuntimeLoginEnv( - container, - { - clearLoginPassword: true, - selfId: session.expectedSelfId, - }, - ); - if (!cleaned.ok) { - throw new Error('NapCat 快速登录前运行态密码清理失败,请重试更新登录'); - } - } - - /** - * 清理Runtime Login Password After Failed Password。 - * @param session - session 输入;驱动 `this.cleanupPasswordLoginContext()` 的 NapCat步骤。 - * @param container - container 输入;驱动 `this.cleanupPasswordLoginContext()` 的 NapCat步骤。 - * @param selfId - 账号 ID;定位本次读取、更新、删除或关联的账号。 - */ - private async clearRuntimeLoginPasswordAfterFailedPassword( - session: QqbotLoginScanSession, - container: QqbotNapcatRuntime, - selfId?: string, - ) { - const failureMessage = - 'NapCat 密码登录未完成,且运行态密码清理失败,请重试更新登录'; - const cleaned = await this.cleanupPasswordLoginContext( - session, - container, - selfId, - failureMessage, - ); - if (!cleaned) { - throw new Error(failureMessage); - } - } - - /** - * 清理Runtime Login Password After Success。 - * @param session - session 输入;驱动 `this.cleanupPasswordLoginContext()` 的 NapCat步骤。 - * @param container - container 输入;驱动 `this.cleanupPasswordLoginContext()` 的 NapCat步骤。 - * @param selfId - 账号 ID;定位本次读取、更新、删除或关联的账号。 - */ - private async clearRuntimeLoginPasswordAfterSuccess( - session: QqbotLoginScanSession, - container: QqbotNapcatRuntime, - selfId: string, - ) { - const failureMessage = - 'NapCat 密码登录已完成,但运行态密码清理失败,请重试更新登录'; - const cleaned = await this.cleanupPasswordLoginContext( - session, - container, - selfId, - failureMessage, - ); - if (!cleaned) { - throw new Error(failureMessage); - } - } - /** * 查询 NapCat 登录运行态数据。 * @param options - NapCat列表;使用 `hasExistingPrimaryBinding`、`loginPassword` 字段生成结果。 @@ -2580,35 +2591,6 @@ export class QqbotNapcatLoginService { ); } - /** - * 重置Napcat For Login。 - * @param container - container 输入;驱动 `containerService.resetRuntimeLoginState()`、`this.restartNapcatForLogin()`、`webuiClient.clearCredential()`、`this.getLoginStatus()` 的 NapCat步骤。 - * @param options - NapCat列表;使用 `waitForReady` 字段生成结果。 - * @param onProgress - NapCat列表;驱动 `containerService.resetRuntimeLoginState()` 的 NapCat步骤。 - */ - private async resetNapcatForLogin( - container: QqbotNapcatRuntime, - options: NapcatRestartOptions = {}, - onProgress?: (step: string, message: string) => void, - ) { - const resetByContainer = await this.containerService.resetRuntimeLoginState( - container, - onProgress, - ); - if (!resetByContainer) { - onProgress?.('napcat-restart-webui', '正在调用 NapCat 重启接口'); - await this.restartNapcatForLogin(container, options); - return; - } - - this.webuiClient.clearCredential(container); - if (options.waitForReady === false) return; - - onProgress?.('napcat-ready-wait', '等待 NapCat WebUI 启动'); - await this.toolsService.sleep(this.getRestartDelayMs()); - await this.getLoginStatus(container, true); - } - /** * 执行 NapCat 登录运行态流程。 * @param container - container 输入;驱动 `containerService.restartRuntimeContainer()`、`webuiClient.clearCredential()`、`this.getLoginStatus()` 的 NapCat步骤。 @@ -2789,6 +2771,17 @@ export class QqbotNapcatLoginService { ); } + /** + * 查询 NapCat 快速登录结果的等待窗口。 + * @returns WebUI SetQuickLogin 后最多等待 QQ 登录态变化的毫秒数。 + */ + private getQuickLoginWaitMs() { + return this.getPositiveConfigNumber( + 'QQBOT_NAPCAT_QUICK_LOGIN_WAIT_MS', + 15_000, + ); + } + /** * 查询 NapCat 登录运行态数据。 */ diff --git a/src/modules/qqbot/napcat/application/login/qqbot-napcat-watchdog.service.ts b/src/modules/qqbot/napcat/application/login/qqbot-napcat-watchdog.service.ts index f01d6a6..4d3381b 100644 --- a/src/modules/qqbot/napcat/application/login/qqbot-napcat-watchdog.service.ts +++ b/src/modules/qqbot/napcat/application/login/qqbot-napcat-watchdog.service.ts @@ -13,7 +13,7 @@ const MIN_INTERVAL_MS = 30_000; /** * NapCat 离线看门狗:定时主动巡检在线账号,及时发现掉线/被踢并触发既有站内信告警。 * 不依赖 @nestjs/schedule,采用 OnModuleInit + setInterval(与代码库既有定时器一致)。 - * 仅检测 + 告警,自动重登由容器运行时适配器负责。 + * 仅检测 + 告警,登录恢复必须由管理员在 Admin 手动触发更新登录。 */ @Injectable() export class QqbotNapcatWatchdogService diff --git a/src/modules/qqbot/napcat/application/runtime/napcat-login-event.service.ts b/src/modules/qqbot/napcat/application/runtime/napcat-login-event.service.ts deleted file mode 100644 index 1b8b53a..0000000 --- a/src/modules/qqbot/napcat/application/runtime/napcat-login-event.service.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { In, Repository } from 'typeorm'; -import { - NapcatLoginEvent, - type NapcatLoginEventKind, - type NapcatLoginEventSource, - type NapcatLoginEventStatus, -} from '../../infrastructure/persistence/napcat-login-event.entity'; - -const NAPCAT_AUTOMATIC_RECOVERY_BLOCKING_EVENTS: NapcatLoginEventKind[] = [ - 'captcha_required', - 'manual_qr_created', - 'new_device_required', - 'recovery_suspended', -]; - -export type NapcatAutomaticRecoveryGate = { - allowed: boolean; - reason?: NapcatLoginEventKind; -}; - -@Injectable() -export class NapcatLoginEventService { - /** - * Initializes login-event persistence. - * @param loginEventRepository - Repository used to append login-side audit and recovery-gating events. - */ - constructor( - @InjectRepository(NapcatLoginEvent) - private readonly loginEventRepository: Repository, - ) {} - - /** - * Records a login-side event for audit, automatic recovery gating, and Admin evidence. - * @param input - Event payload produced by Admin actions, watchdog, runtime checks, or system workflows. - * @returns Persisted login-event row created from the append-only payload. - */ - async record(input: { - accountId: string; - containerId?: null | string; - eventKind: NapcatLoginEventKind; - eventSource: NapcatLoginEventSource; - eventStatus: NapcatLoginEventStatus; - evidence?: Record; - }) { - return this.loginEventRepository.save( - this.loginEventRepository.create({ - accountId: input.accountId, - containerId: input.containerId || null, - eventKind: input.eventKind, - eventSource: input.eventSource, - eventStatus: input.eventStatus, - evidence: input.evidence || null, - }), - ); - } - - /** - * Records that automation stopped before QR, captcha, or new-device verification. - * @param input - Account, container, source, and domain reason explaining why watchdog recovery must stop. - * @returns Persisted `recovery_suspended` event carrying the blocking reason in evidence. - */ - recordSuspended(input: { - accountId: string; - containerId?: null | string; - evidence: Record; - reason: NapcatLoginEventKind; - source: NapcatLoginEventSource; - }) { - return this.record({ - accountId: input.accountId, - containerId: input.containerId, - eventKind: 'recovery_suspended', - eventSource: input.source, - eventStatus: 'blocked', - evidence: { - ...input.evidence, - reason: input.reason, - }, - }); - } - - /** - * Checks whether watchdog may run quick -> password recovery for an account/container pair. - * @param input - Account, optional container, and latest successful connection time used as manual reset evidence. - * @returns Recovery gate result; blocked reasons map to the latest blocking login-side event. - */ - async canAttemptAutomaticRecovery(input: { - accountId: string; - containerId?: null | string; - resetAfter?: Date | null; - }): Promise { - const where: Record = { - accountId: input.accountId, - eventKind: In(NAPCAT_AUTOMATIC_RECOVERY_BLOCKING_EVENTS), - }; - if (input.containerId) where.containerId = input.containerId; - - const latest = await this.loginEventRepository.findOne({ - order: { createTime: 'DESC' }, - where: where as any, - }); - if (!latest) return { allowed: true }; - - if (this.isResetAfterEvent(input.resetAfter, latest.createTime)) { - return { allowed: true }; - } - if (this.hasManualResetEvidence(latest.evidence)) { - return { allowed: true }; - } - - return { allowed: false, reason: latest.eventKind }; - } - - /** - * Compares a later successful connection against the blocking event timestamp. - * @param resetAfter - Successful connection or manual reset time from account state. - * @param eventTime - Blocking login-event creation time. - * @returns Whether the reset timestamp is newer than the blocking event. - */ - private isResetAfterEvent( - resetAfter: Date | null | undefined, - eventTime: Date, - ) { - if (!resetAfter) return false; - const resetAt = new Date(resetAfter).getTime(); - const blockedAt = new Date(eventTime).getTime(); - return Number.isFinite(resetAt) && resetAt > blockedAt; - } - - /** - * Detects explicit manual reset evidence embedded in a blocking event. - * @param evidence - Login event evidence object persisted with the blocking event. - * @returns Whether automation can continue because a human reset has been recorded. - */ - private hasManualResetEvidence(evidence: null | Record) { - if (!evidence) return false; - return Boolean(evidence.manualResetAt || evidence.manualReset); - } -} diff --git a/src/modules/qqbot/napcat/index.ts b/src/modules/qqbot/napcat/index.ts index 1535a77..9b117e3 100644 --- a/src/modules/qqbot/napcat/index.ts +++ b/src/modules/qqbot/napcat/index.ts @@ -15,7 +15,6 @@ export * from './infrastructure/integration/napcat-login-api.client'; export * from './domain/login/napcat-login-state-machine'; export * from './application/login/qqbot-napcat-login.service'; export * from './application/login/qqbot-napcat-watchdog.service'; -export * from './application/runtime/napcat-login-event.service'; export * from './application/runtime/napcat-runtime-profile-inspector.service'; export * from './application/runtime/napcat-session-behavior.service'; export * from './infrastructure/persistence'; diff --git a/src/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service.ts b/src/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service.ts index 207427b..9acb1f0 100644 --- a/src/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service.ts +++ b/src/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service.ts @@ -2,13 +2,12 @@ import * as http from 'http'; import * as https from 'https'; import { spawn } from 'child_process'; import { createHash, randomBytes, randomUUID } from 'crypto'; -import { Injectable, Optional } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { throwVbenError, ToolsService } from '@/common'; import { NapcatConfigWriterService } from '../../../application/runtime/napcat-config-writer.service'; -import { NapcatLoginEventService } from '../../../application/runtime/napcat-login-event.service'; import { NapcatRuntimeProfileService } from '../../../application/runtime/napcat-runtime-profile.service'; import type { NapcatConfigFile } from '../../../domain/runtime/napcat-profile.types'; import { @@ -19,7 +18,6 @@ import { NapcatDeviceIdentityService } from '../device/napcat-device-identity.se import { QqbotAccount } from '@/modules/qqbot/core/infrastructure/persistence/account/qqbot-account.entity'; import { NapcatAccountBinding } from '../../persistence/napcat-account-binding.entity'; import { NapcatContainer } from '../../persistence/napcat-container.entity'; -import type { NapcatLoginEventKind } from '../../persistence/napcat-login-event.entity'; import type { NapcatApiResponse, NapcatCredential, @@ -47,12 +45,6 @@ type NapcatLoginEnvUpdateResult = { ok: boolean; }; -type NapcatAutoLoginResult = { - cleanupFailed?: boolean; - method?: 'password' | 'quick'; - success: boolean; -}; - @Injectable() export class QqbotNapcatContainerService { private readonly configWriterService: NapcatConfigWriterService; @@ -68,7 +60,6 @@ export class QqbotNapcatContainerService { * @param deviceIdentityService - Device identity resolver that supplies stable hostname, MAC, data-dir, and machine-id values. * @param runtimeProfileService - Runtime profile resolver used to generate Docker env and mount settings. * @param configWriterService - Config writer used to generate NapCat and OneBot config files. - * @param loginEventService - Optional login-event recorder used by watchdog auto-login attempts. */ constructor( private readonly configService: ConfigService, @@ -80,8 +71,6 @@ export class QqbotNapcatContainerService { private readonly deviceIdentityService?: NapcatDeviceIdentityService, runtimeProfileService?: NapcatRuntimeProfileService, configWriterService?: NapcatConfigWriterService, - @Optional() - private readonly loginEventService?: NapcatLoginEventService, ) { this.runtimeProfileService = runtimeProfileService || new NapcatRuntimeProfileService(configService); @@ -101,26 +90,51 @@ export class QqbotNapcatContainerService { } /** - * 执行 NapCat 登录运行态流程。 - * @param account - account 输入;使用 `id`、`selfId` 字段生成结果。 + * 为账号更新登录准备 NapCat 容器。 + * @param account - QQBot 账号;提供主绑定容器查询条件和创建期 ACCOUNT env。 + * @param loginPassword - 已保存的 QQ 登录密码明文;仅在新建或 Docker 离线重建容器时注入创建期 env。 + * @returns 可用于登录流程的 NapCat 运行态,携带源 Docker 容器是否在线的门禁证据。 */ - async prepareAccountContainer(account: QqbotAccount) { + async prepareAccountContainer(account: QqbotAccount, loginPassword?: string) { if (!this.isManagedMode()) { return this.getLegacyRuntime(); } const existing = await this.getPrimaryRuntime(account.id); if (existing) { - await this.ensureRuntimeQuickLogin(existing, account.selfId); - return { ...existing, hasExistingPrimaryBinding: true }; + const sourceContainerOnline = existing.sourceContainerOnline !== false; + const quickLoginEnv = sourceContainerOnline + ? { changed: false, ok: true } + : await this.ensureRuntimeLoginEnv(existing, { + loginPassword, + selfId: account.selfId, + }); + if ( + !sourceContainerOnline && + quickLoginEnv.ok && + !quickLoginEnv.changed && + !(await this.startRuntimeContainer(existing)) + ) { + throwVbenError('NapCat Docker 容器未运行,启动失败'); + } + return { + ...existing, + hasExistingPrimaryBinding: true, + sourceContainerOnline, + runtimeRebuildCount: quickLoginEnv.changed ? 1 : 0, + }; } const created = await this.createManagedContainer( account.selfId, - undefined, + loginPassword, account.id, ); - return { ...created, hasExistingPrimaryBinding: false }; + return { + ...created, + hasExistingPrimaryBinding: false, + sourceContainerOnline: false, + }; } /** @@ -212,6 +226,30 @@ export class QqbotNapcatContainerService { } } + /** + * 只读检查托管容器当前登录环境是否已经符合目标状态。 + * @param runtime - NapCat 运行态;用 id 找到容器名和当前 Docker env。 + * @param options - 目标 ACCOUNT / 运行态密码状态;决定是否要求清除或保留密码 env。 + * @returns 当前 Docker env 已匹配时返回 true;不可检查或不匹配时返回 false。 + */ + async runtimeLoginEnvMatches( + runtime: QqbotNapcatRuntime, + options: NapcatLoginEnvOptions, + ) { + if (this.getManagedMode() !== 'ssh' || !runtime.id) return true; + + const account = this.toolsService.toTrimmedString(options.selfId); + if (!account) return false; + + const container = await this.findContainerWithToken(runtime.id); + if (!container?.name) return false; + + return this.runtimeMatchesLoginEnv(container.name, { + ...options, + selfId: account, + }); + } + /** * 解析Runtime Device Identity。 * @param container - container 输入;使用 `id` 字段生成结果。 @@ -266,270 +304,6 @@ export class QqbotNapcatContainerService { return identity.id; } - /** - * 执行 NapCat 登录运行态流程。 - * @param container - container 输入;使用 `id` 字段生成结果。 - * @param options - NapCat列表;使用 `selfId`、`loginPassword` 字段生成结果。 - * @returns 异步完成后的 NapCat 登录运行态结果。 - */ - async tryAutoLogin( - container: NapcatContainer, - options: NapcatLoginEnvOptions, - ): Promise { - const selfId = this.toolsService.toTrimmedString(options.selfId); - if (!selfId || this.getManagedMode() !== 'ssh' || !container.id) { - return { success: false }; - } - - const runtimeContainer = await this.findContainerWithToken(container.id); - if (!runtimeContainer?.name) return { success: false }; - const runtime = this.toRuntime(runtimeContainer); - const accountId = this.getAutoLoginAccountId(container, runtimeContainer); - - await this.recordAutoLoginEvent({ - accountId, - container, - eventKind: 'quick_attempt', - eventStatus: 'pending', - evidence: { containerName: runtime.name }, - }); - const quickCleanup = await this.ensureRuntimeLoginEnv(runtime, { - clearLoginPassword: true, - selfId, - }); - if (!quickCleanup.ok) { - await this.recordAutoLoginEvent({ - accountId, - container, - eventKind: 'quick_attempt', - eventStatus: 'failed', - evidence: { - containerName: runtime.name, - reason: 'login-env-cleanup-failed', - }, - }); - return { cleanupFailed: true, success: false }; - } - - const quickState = await this.restartAndDetectLoginState(runtime); - if (quickState.state === 'online') { - await this.recordAutoLoginEvent({ - accountId, - container, - eventKind: 'quick_attempt', - eventStatus: 'success', - evidence: { containerName: runtime.name }, - }); - return { method: 'quick', success: true }; - } - await this.recordAutoLoginEvent({ - accountId, - container, - eventKind: 'quick_attempt', - eventStatus: 'failed', - evidence: { - containerName: runtime.name, - offlineReason: quickState.offlineReason, - }, - }); - const quickBlockReason = this.toAutoLoginSuspendedReason( - quickState.offlineReason, - ); - if (quickBlockReason) { - await this.recordAutoLoginSuspended({ - accountId, - container, - offlineReason: quickState.offlineReason, - reason: quickBlockReason, - }); - return { success: false }; - } - - const loginPassword = this.toolsService.toSecretText(options.loginPassword); - if (!loginPassword) { - await this.recordAutoLoginSuspended({ - accountId, - container, - offlineReason: quickState.offlineReason, - reason: 'recovery_suspended', - suspendedReason: 'login-password-missing', - }); - return { success: false }; - } - - await this.recordAutoLoginEvent({ - accountId, - container, - eventKind: 'password_attempt', - eventStatus: 'pending', - evidence: { containerName: runtime.name }, - }); - const passwordEnv = await this.ensureRuntimeLoginEnv(runtime, { - loginPassword, - selfId, - }); - if (!passwordEnv.ok) { - await this.recordAutoLoginEvent({ - accountId, - container, - eventKind: 'password_attempt', - eventStatus: 'failed', - evidence: { - containerName: runtime.name, - reason: 'login-env-prepare-failed', - }, - }); - await this.recordAutoLoginSuspended({ - accountId, - container, - reason: 'recovery_suspended', - suspendedReason: 'login-env-prepare-failed', - }); - return { success: false }; - } - - const passwordState = await this.restartAndDetectLoginState(runtime); - if (passwordState.state !== 'online') { - const cleaned = await this.ensureRuntimeLoginEnv(runtime, { - clearLoginPassword: true, - selfId, - }); - await this.recordAutoLoginEvent({ - accountId, - container, - eventKind: 'password_attempt', - eventStatus: 'failed', - evidence: { - cleanupOk: cleaned.ok, - containerName: runtime.name, - offlineReason: passwordState.offlineReason, - }, - }); - await this.recordAutoLoginSuspended({ - accountId, - container, - offlineReason: passwordState.offlineReason, - reason: - this.toAutoLoginSuspendedReason(passwordState.offlineReason) || - 'recovery_suspended', - suspendedReason: 'password-auto-login-failed', - }); - return cleaned.ok - ? { success: false } - : { cleanupFailed: true, success: false }; - } - - const cleaned = await this.ensureRuntimeLoginEnv(runtime, { - clearLoginPassword: true, - selfId, - }); - if (!cleaned.ok) { - await this.recordAutoLoginEvent({ - accountId, - container, - eventKind: 'password_attempt', - eventStatus: 'failed', - evidence: { - containerName: runtime.name, - reason: 'login-env-cleanup-failed', - }, - }); - return { cleanupFailed: true, success: false }; - } - - await this.recordAutoLoginEvent({ - accountId, - container, - eventKind: 'password_attempt', - eventStatus: 'success', - evidence: { containerName: runtime.name }, - }); - return { method: 'password', success: true }; - } - - /** - * Chooses the account id used by watchdog login-event records. - * @param inputContainer - Container row passed by account runtime, usually carrying account ownership. - * @param runtimeContainer - Container row reloaded with WebUI token for runtime actions. - * @returns Account id when it can be derived without looking up additional state. - */ - private getAutoLoginAccountId( - inputContainer: NapcatContainer, - runtimeContainer: NapcatContainer, - ) { - return this.toolsService.toTrimmedString( - inputContainer.accountId || runtimeContainer.accountId, - ); - } - - /** - * Records a watchdog quick/password attempt when the owning account is known. - * @param input - Attempt kind, status, container, and non-secret runtime evidence. - */ - private async recordAutoLoginEvent(input: { - accountId: string; - container: NapcatContainer; - eventKind: 'password_attempt' | 'quick_attempt'; - eventStatus: 'failed' | 'pending' | 'success'; - evidence?: Record; - }) { - if (!this.loginEventService || !input.accountId) return; - await this.loginEventService.record({ - accountId: input.accountId, - containerId: input.container.id, - eventKind: input.eventKind, - eventSource: 'watchdog', - eventStatus: input.eventStatus, - evidence: input.evidence, - }); - } - - /** - * Records that watchdog auto-login must stop and wait for a human-visible login path. - * @param input - Account/container plus the reason automation cannot safely continue. - */ - private async recordAutoLoginSuspended(input: { - accountId: string; - container: NapcatContainer; - offlineReason?: null | string; - reason: NapcatLoginEventKind; - suspendedReason?: string; - }) { - if (!this.loginEventService || !input.accountId) return; - await this.loginEventService.recordSuspended({ - accountId: input.accountId, - containerId: input.container.id, - evidence: { - offlineReason: input.offlineReason || undefined, - reason: input.suspendedReason || input.reason, - }, - reason: input.reason, - source: 'watchdog', - }); - } - - /** - * Maps runtime login failure text to the first manual login path watchdog must not enter. - * @param offlineReason - NapCat log/status text produced after quick or password login attempt. - * @returns Blocking event kind when the next step requires captcha, new-device verification, or manual QR. - */ - private toAutoLoginSuspendedReason( - offlineReason?: null | string, - ): NapcatLoginEventKind | null { - const reason = this.toolsService.toTrimmedString(offlineReason); - if (!reason) return null; - if (/captcha|proofWater|验证码|安全验证/i.test(reason)) { - return 'captcha_required'; - } - if (/new.?device|新设备|设备验证/i.test(reason)) { - return 'new_device_required'; - } - if (/qr.?code|二维码/i.test(reason)) { - return 'manual_qr_created'; - } - return null; - } - /** * 执行 NapCat 登录运行态流程。 * @param name - 名称文本;驱动 `this.runProcess()` 的 NapCat步骤。 @@ -635,6 +409,10 @@ docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$NAME" accountId, containerId, ); + await this.containerRepository.update( + { id: containerId, isDeleted: false }, + { accountId }, + ); const existing = await this.bindingRepository.findOne({ where: { @@ -756,6 +534,32 @@ docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$NAME" return true; } + /** + * Starts an existing managed NapCat container without rebuilding or restarting it. + * @param runtime - Persisted runtime row whose Docker name and id identify the stopped container to start. + * @returns True when the Docker start command was issued and the persisted row was marked running. + */ + private async startRuntimeContainer(runtime: QqbotNapcatRuntime) { + if (this.getManagedMode() !== 'ssh' || !runtime.id || !runtime.name) { + return false; + } + + await this.runProcess( + 'ssh', + [...this.getSshArgs(), 'docker', 'start', runtime.name], + '', + ); + await this.containerRepository.update( + { id: runtime.id }, + { + lastError: null, + lastStartedAt: new Date(), + status: 'running', + }, + ); + return true; + } + /** * 重置Runtime Login State。 * @param runtime - runtime 输入;使用 `id`、`name` 字段生成结果。 @@ -1156,47 +960,6 @@ docker logs --since "$SINCE" --tail 300 "$NAME" 2>&1 || true `; } - /** - * 执行 NapCat 登录运行态流程。 - * @param runtime - runtime 输入;使用 `name` 字段生成结果。 - */ - private async restartAndDetectLoginState(runtime: QqbotNapcatRuntime) { - const since = new Date(Date.now() - 1000).toISOString(); - await this.restartRuntimeContainer(runtime); - const attempts = Number( - this.getConfig('QQBOT_NAPCAT_AUTO_LOGIN_RETRIES', '10'), - ); - const delayMs = Number( - this.getConfig('QQBOT_NAPCAT_AUTO_LOGIN_INTERVAL_MS', '2000'), - ); - let latest: NapcatLoginLogResult = { - offlineReason: null, - state: 'unknown', - }; - for (let index = 0; index < attempts; index += 1) { - await this.toolsService.sleep(Number.isFinite(delayMs) ? delayMs : 2000); - latest = await this.detectRuntimeLoginStateSince(runtime.name, since); - if (latest.state !== 'unknown') return latest; - } - return latest; - } - - /** - * 执行 NapCat 登录运行态流程。 - * @param name - 名称文本;驱动 `this.runProcess()` 的 NapCat步骤。 - * @param since - since 输入;驱动 `this.runProcess()` 的 NapCat步骤。 - */ - private async detectRuntimeLoginStateSince(name: string, since: string) { - const result = await this.runProcess( - 'ssh', - [...this.getSshArgs(), 'sh -s'], - this.buildRemoteRecentLogsSinceScript(name, since), - undefined, - this.getRuntimeCheckTimeoutMs(), - ); - return this.extractLoginState(result.stdout); - } - /** * 执行 NapCat 登录运行态流程。 * @param logs - NapCat列表;影响 extractLoginState 的返回值。 @@ -1733,6 +1496,7 @@ ${file.content}EOF`; baseUrl: this.normalizeBaseUrl(container.baseUrl), id: container.id, name: container.name, + sourceContainerOnline: container.status === 'running', webuiPort: container.webuiPort, webuiToken: container.webuiToken, }; diff --git a/src/modules/qqbot/napcat/qqbot-napcat.module.ts b/src/modules/qqbot/napcat/qqbot-napcat.module.ts index f9b9885..3310737 100644 --- a/src/modules/qqbot/napcat/qqbot-napcat.module.ts +++ b/src/modules/qqbot/napcat/qqbot-napcat.module.ts @@ -8,7 +8,6 @@ import { QqbotNapcatAccountRuntimeService } from './application/account-runtime/ import { QqbotNapcatLoginService } from './application/login/qqbot-napcat-login.service'; import { QqbotNapcatWatchdogService } from './application/login/qqbot-napcat-watchdog.service'; import { NapcatConfigWriterService } from './application/runtime/napcat-config-writer.service'; -import { NapcatLoginEventService } from './application/runtime/napcat-login-event.service'; import { NapcatRuntimeProfileInspectorService } from './application/runtime/napcat-runtime-profile-inspector.service'; import { NapcatRuntimeProfileService } from './application/runtime/napcat-runtime-profile.service'; import { NapcatSessionBehaviorService } from './application/runtime/napcat-session-behavior.service'; @@ -31,7 +30,6 @@ export const QQBOT_NAPCAT_PROVIDERS = [ NapcatConfigWriterService, NapcatDeviceIdentityService, NapcatLoginStateStoreService, - NapcatLoginEventService, NapcatRuntimeProfileInspectorService, NapcatRuntimeProfileInspectionScriptService, NapcatRuntimeProfileService, @@ -48,7 +46,6 @@ export const QQBOT_NAPCAT_PROVIDERS = [ export const QQBOT_NAPCAT_EXPORTS = [ NapcatDeviceIdentityService, - NapcatLoginEventService, NapcatLoginStateStoreService, NapcatSessionBehaviorService, QQBOT_ACCOUNT_NAPCAT_RUNTIME_PORT, diff --git a/test/modules/qqbot/napcat/login-event-watchdog.spec.ts b/test/modules/qqbot/napcat/login-event-watchdog.spec.ts index 3a70b89..909a86a 100644 --- a/test/modules/qqbot/napcat/login-event-watchdog.spec.ts +++ b/test/modules/qqbot/napcat/login-event-watchdog.spec.ts @@ -1,18 +1,5 @@ import { ToolsService } from '@/common'; import { QqbotNapcatAccountRuntimeService } from '../../../../src/modules/qqbot/napcat/application/account-runtime/qqbot-napcat-account-runtime.service'; -import { NapcatLoginEventService } from '../../../../src/modules/qqbot/napcat/application/runtime/napcat-login-event.service'; -import { QqbotNapcatContainerService } from '../../../../src/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service'; - -/** - * Creates a TypeORM-like repository mock for login-event service tests. - * @returns Repository mock that captures created and saved login-event payloads. - */ -const createRepository = () => ({ - create: jest.fn((input) => input), - findOne: jest.fn(), - save: jest.fn(async (input) => input), - update: jest.fn(), -}); /** * Creates the chained query builder shape used by account runtime joins. @@ -28,85 +15,8 @@ const createManyQueryBuilder = (rows: T[]) => ({ where: jest.fn().mockReturnThis(), }); -describe('NapCat login event and recovery lease', () => { - it('records quick and password attempts as login events, not send budgets', async () => { - const repository = createRepository(); - const service = new NapcatLoginEventService(repository as any); - - await service.record({ - accountId: 'account-1', - containerId: 'container-1', - eventKind: 'quick_attempt', - eventSource: 'watchdog', - eventStatus: 'success', - evidence: { method: 'quick' }, - }); - await service.record({ - accountId: 'account-1', - containerId: 'container-1', - eventKind: 'password_attempt', - eventSource: 'watchdog', - eventStatus: 'failed', - evidence: { method: 'password' }, - }); - - expect(repository.save).toHaveBeenCalledTimes(2); - expect(JSON.stringify(repository.save.mock.calls)).not.toMatch( - /daily|hour|quota|budget/i, - ); - }); - - it('suspends automatic recovery after captcha, new-device, or manual QR is required', async () => { - const repository = createRepository(); - const service = new NapcatLoginEventService(repository as any); - - await service.recordSuspended({ - accountId: 'account-1', - containerId: 'container-1', - evidence: { reason: 'new-device-required' }, - reason: 'new_device_required', - source: 'watchdog', - }); - - expect(repository.save).toHaveBeenCalledWith( - expect.objectContaining({ - eventKind: 'recovery_suspended', - eventSource: 'watchdog', - eventStatus: 'blocked', - }), - ); - }); - - it('blocks automatic recovery until a later successful connection exists', async () => { - const repository = createRepository(); - repository.findOne.mockResolvedValue({ - createTime: new Date('2026-06-18T08:00:00.000Z'), - eventKind: 'new_device_required', - }); - const service = new NapcatLoginEventService(repository as any); - - await expect( - service.canAttemptAutomaticRecovery({ - accountId: 'account-1', - containerId: 'container-1', - resetAfter: new Date('2026-06-18T07:59:00.000Z'), - }), - ).resolves.toEqual({ - allowed: false, - reason: 'new_device_required', - }); - await expect( - service.canAttemptAutomaticRecovery({ - accountId: 'account-1', - containerId: 'container-1', - resetAfter: new Date('2026-06-18T08:01:00.000Z'), - }), - ).resolves.toEqual({ allowed: true }); - }); -}); - -describe('NapCat watchdog auto-login boundaries', () => { - it('does not call container auto-login when recovery is suspended', async () => { +describe('NapCat watchdog notification boundaries', () => { + it('does not call container auto-login when watchdog detects QQ login offline', async () => { const accountNapcatRepository = { createQueryBuilder: jest.fn(() => createManyQueryBuilder([ @@ -124,8 +34,8 @@ describe('NapCat watchdog auto-login boundaries', () => { createManyQueryBuilder([ { id: 'container-1', - lastCheckedAt: new Date(), - lastError: '账号状态变更为离线', + lastCheckedAt: null, + lastError: null, name: 'kt-qqbot-napcat-10001', status: 'running', }, @@ -133,24 +43,21 @@ describe('NapCat watchdog auto-login boundaries', () => { ), }; const containerService = { - detectRuntimeOffline: jest.fn(), - tryAutoLogin: jest.fn(), - }; - const loginEventService = { - canAttemptAutomaticRecovery: jest.fn().mockResolvedValue({ - allowed: false, - reason: 'recovery_suspended', - }), - recordSuspended: jest.fn(), + detectRuntimeOffline: jest + .fn() + .mockResolvedValue('NapCat 账号状态变更为离线'), }; const service = new QqbotNapcatAccountRuntimeService( accountNapcatRepository as any, containerRepository as any, containerService as any, new ToolsService(), - undefined, - loginEventService as any, ); + const actions = { + clearQqLoginError: jest.fn(), + markQqLoginOffline: jest.fn(), + publishOfflineNotice: jest.fn(), + }; await service.appendRuntime( [ @@ -161,118 +68,19 @@ describe('NapCat watchdog auto-login boundaries', () => { selfId: '10001', } as any, ], - { autoLogin: true }, - { - clearQqLoginError: jest.fn(), - getLoginPassword: jest.fn(), - markOnline: jest.fn(), - markQqLoginOffline: jest.fn(), - publishOfflineNotice: jest.fn(), - }, + actions as any, ); - expect(containerService.tryAutoLogin).not.toHaveBeenCalled(); - expect(loginEventService.recordSuspended).toHaveBeenCalledWith( + expect(actions.markQqLoginOffline).toHaveBeenCalledWith( + '10001', + 'NapCat 账号状态变更为离线', + ); + expect(actions.publishOfflineNotice).toHaveBeenCalledWith( + '10001', + 'NapCat 账号状态变更为离线', expect.objectContaining({ - accountId: 'account-1', containerId: 'container-1', - reason: 'recovery_suspended', - source: 'watchdog', - }), - ); - }); - - it('does not create a manual QR session from watchdog auto-login', async () => { - const service = new QqbotNapcatContainerService( - { get: jest.fn().mockReturnValue('') } as any, - {} as any, - {} as any, - new ToolsService(), - ) as any; - service.getManagedMode = jest.fn().mockReturnValue('ssh'); - service.findContainerWithToken = jest.fn().mockResolvedValue({ - baseUrl: 'http://127.0.0.1:6100/', - id: 'container-1', - name: 'kt-qqbot-napcat-10001', - }); - service.ensureRuntimeLoginEnv = jest.fn().mockResolvedValue({ - changed: false, - ok: true, - }); - service.restartAndDetectLoginState = jest - .fn() - .mockResolvedValue({ offlineReason: '历史会话失效', state: 'offline' }); - - const result = await service.tryAutoLogin( - { id: 'container-1', name: 'kt-qqbot-napcat-10001' }, - { selfId: '10001' }, - ); - - expect(result.success).toBe(false); - expect(JSON.stringify(service.runProcess?.mock?.calls || [])).not.toContain( - 'qrcode', - ); - }); - - it('records password auto-login failure as suspended recovery', async () => { - const loginEventService = { - record: jest.fn(), - recordSuspended: jest.fn(), - }; - const service = new QqbotNapcatContainerService( - { get: jest.fn().mockReturnValue('') } as any, - {} as any, - {} as any, - new ToolsService(), - undefined, - undefined, - undefined, - loginEventService as any, - ) as any; - service.getManagedMode = jest.fn().mockReturnValue('ssh'); - service.findContainerWithToken = jest.fn().mockResolvedValue({ - accountId: 'account-1', - baseUrl: 'http://127.0.0.1:6100/', - id: 'container-1', - name: 'kt-qqbot-napcat-10001', - }); - service.ensureRuntimeLoginEnv = jest - .fn() - .mockResolvedValueOnce({ changed: false, ok: true }) - .mockResolvedValueOnce({ changed: true, ok: true }) - .mockResolvedValueOnce({ changed: true, ok: true }); - service.restartAndDetectLoginState = jest - .fn() - .mockResolvedValueOnce({ - offlineReason: '历史会话失效', - state: 'offline', - }) - .mockResolvedValueOnce({ - offlineReason: '密码登录失败', - state: 'offline', - }); - - const result = await service.tryAutoLogin( - { accountId: 'account-1', id: 'container-1' } as any, - { - loginPassword: 'qq-password', - selfId: '10001', - }, - ); - - expect(result).toEqual({ success: false }); - expect(loginEventService.record).toHaveBeenCalledWith( - expect.objectContaining({ - eventKind: 'password_attempt', - eventStatus: 'failed', - }), - ); - expect(loginEventService.recordSuspended).toHaveBeenCalledWith( - expect.objectContaining({ - accountId: 'account-1', - containerId: 'container-1', - reason: 'recovery_suspended', - source: 'watchdog', + containerName: 'kt-qqbot-napcat-10001', }), ); }); diff --git a/test/qqbot/account/qqbot-account.service.spec.ts b/test/qqbot/account/qqbot-account.service.spec.ts index e0b9451..e7a08ae 100644 --- a/test/qqbot/account/qqbot-account.service.spec.ts +++ b/test/qqbot/account/qqbot-account.service.spec.ts @@ -400,7 +400,8 @@ describe('QqbotAccountService', () => { ); expect(systemNoticePublisher.publishSystemNotice).toHaveBeenCalledWith( expect.objectContaining({ - content: 'NapCat 账号状态变更为离线', + content: + 'NapCat 账号状态变更为离线\n请在 Admin 的 QQBot 账号页面手动点击「更新登录」重新登录。', dedupeKey: 'qqbot:offline:1914728559', eventType: 'qqbot.account.offline', notifyRoleCode: 'super', @@ -990,7 +991,7 @@ describe('QqbotAccountService', () => { ); }); - it('lets watchdog auto-login before marking an account offline', async () => { + it('notifies manual re-login instead of auto-login when watchdog detects QQ login offline', async () => { const toolsService = new ToolsService(); const account = { connectStatus: 'online', @@ -1034,10 +1035,6 @@ describe('QqbotAccountService', () => { detectRuntimeOffline: jest .fn() .mockResolvedValue('NapCat 账号状态变更为离线'), - tryAutoLogin: jest.fn().mockResolvedValue({ - method: 'password', - success: true, - }), }; const systemNoticePublisher = { publishSystemNotice: jest.fn().mockResolvedValue(undefined), @@ -1073,26 +1070,24 @@ describe('QqbotAccountService', () => { const result = await service.runOfflineWatchdog(); expect(result).toEqual({ checked: 1 }); - expect(napcatContainerService.tryAutoLogin).toHaveBeenCalledWith( - container, - { - loginPassword: 'qq-login-password', - selfId: '1914728559', - }, - ); expect(accountRepository.update).toHaveBeenCalledWith( { selfId: '1914728559' }, + { + lastError: 'NapCat 账号状态变更为离线', + }, + ); + expect(systemNoticePublisher.publishSystemNotice).toHaveBeenCalledWith( expect.objectContaining({ - clientRole: 'Universal', - connectStatus: 'online', - lastConnectedAt: expect.any(Date), - lastError: null, + content: + 'NapCat 账号状态变更为离线\n请在 Admin 的 QQBot 账号页面手动点击「更新登录」重新登录。', + eventType: 'qqbot.account.offline', + severity: 'error', + title: 'QQBot 账号已下线:1914728559', }), ); - expect(systemNoticePublisher.publishSystemNotice).not.toHaveBeenCalled(); }); - it('records a cleanup failure when watchdog auto-login leaves runtime password env uncertain', async () => { + it('never reports auto-login cleanup failure from watchdog because watchdog no longer logs in', async () => { const toolsService = new ToolsService(); const account = { connectStatus: 'online', @@ -1136,10 +1131,6 @@ describe('QqbotAccountService', () => { detectRuntimeOffline: jest .fn() .mockResolvedValue('NapCat 账号状态变更为离线'), - tryAutoLogin: jest.fn().mockResolvedValue({ - cleanupFailed: true, - success: false, - }), }; const systemNoticePublisher = { publishSystemNotice: jest.fn().mockResolvedValue(undefined), @@ -1178,19 +1169,20 @@ describe('QqbotAccountService', () => { expect(accountRepository.update).toHaveBeenCalledWith( { selfId: '1914728559' }, { - lastError: 'NapCat 自动登录后运行态密码清理失败,请手动更新登录', + lastError: 'NapCat 账号状态变更为离线', }, ); expect(systemNoticePublisher.publishSystemNotice).toHaveBeenCalledWith( expect.objectContaining({ - content: 'NapCat 自动登录后运行态密码清理失败,请手动更新登录', + content: + 'NapCat 账号状态变更为离线\n请在 Admin 的 QQBot 账号页面手动点击「更新登录」重新登录。', eventType: 'qqbot.account.offline', severity: 'error', title: 'QQBot 账号已下线:1914728559', }), ); expect(JSON.stringify(accountRepository.update.mock.calls)).not.toContain( - 'NapCat 账号状态变更为离线', + 'NapCat 自动登录后运行态密码清理失败,请手动更新登录', ); }); }); diff --git a/test/qqbot/account/qqbot-napcat-login.service.spec.ts b/test/qqbot/account/qqbot-napcat-login.service.spec.ts index 9c0608a..a99353b 100644 --- a/test/qqbot/account/qqbot-napcat-login.service.spec.ts +++ b/test/qqbot/account/qqbot-napcat-login.service.spec.ts @@ -22,6 +22,38 @@ import { QqbotNapcatLoginService } from '@/modules/qqbot/napcat/application/logi import { QqbotNapcatContainerService } from '@/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service'; describe('QqbotNapcatLoginService', () => { + /** + * 为更新登录测试模拟 NapCat WebUI quick/password 登录接口。 + * @param target - 被测登录服务;其私有 postNapcat 会被替换为可控实现。 + * @param options - quick/password 分支的返回或错误,用来驱动目标状态机路径。 + * @returns Jest spy,供用例继续断言 WebUI 请求体。 + */ + const mockWebuiLoginPost = ( + target: QqbotNapcatLoginService, + options: { + passwordError?: string; + passwordResult?: null | Record; + quickError?: string; + quickResult?: null | Record; + } = {}, + ) => + jest.spyOn(target as any, 'postNapcat').mockImplementation( + async ( + _container: unknown, + path: string, + ) => { + if (path === '/api/QQLogin/SetQuickLogin') { + if (options.quickError) throw new Error(options.quickError); + return options.quickResult ?? null; + } + if (path === '/api/QQLogin/PasswordLogin') { + if (options.passwordError) throw new Error(options.passwordError); + return options.passwordResult ?? null; + } + return null; + }, + ); + const toolsService = new ToolsService(); const service = new QqbotNapcatLoginService( { get: jest.fn() } as unknown as ConfigService, @@ -101,6 +133,7 @@ describe('QqbotNapcatLoginService', () => { expect(accountService.getNapcatLoginPassword).toHaveBeenCalledWith(account); expect(containerService.prepareAccountContainer).toHaveBeenCalledWith( account, + 'qq-password', ); expect(startScan).toHaveBeenCalledWith( { @@ -160,6 +193,92 @@ describe('QqbotNapcatLoginService', () => { ); }); + it('reuses an active refresh session instead of starting repeated relogin during SSE retries', async () => { + const account = { + id: 'account-1', + selfId: '10001', + }; + const existingContainer = { + baseUrl: 'http://127.0.0.1:6103/', + id: 'container-current', + name: 'napcat-10001', + }; + const accountService = { + findByIdWithNapcatLoginSecret: jest.fn().mockResolvedValue(account), + getNapcatLoginPassword: jest.fn().mockReturnValue('qq-password'), + }; + const containerService = { + prepareAccountContainer: jest.fn().mockResolvedValue(existingContainer), + }; + const refreshService = new QqbotNapcatLoginService( + { get: jest.fn() } as unknown as ConfigService, + accountService as unknown as QqbotAccountService, + containerService as unknown as QqbotNapcatContainerService, + new ToolsService(), + ); + const prepareReloginQrcode = jest + .spyOn(refreshService as any, 'prepareReloginQrcode') + .mockResolvedValue(undefined); + + const first = await refreshService.startRefresh('account-1'); + const second = await refreshService.startRefresh('account-1'); + + expect(second.sessionId).toBe(first.sessionId); + expect(accountService.findByIdWithNapcatLoginSecret).toHaveBeenCalledTimes( + 1, + ); + expect(containerService.prepareAccountContainer).toHaveBeenCalledTimes(1); + expect(prepareReloginQrcode).toHaveBeenCalledTimes(1); + }); + + it('shares an in-flight refresh task before the first session is created', async () => { + const account = { + id: 'account-1', + selfId: '10001', + }; + const existingContainer = { + baseUrl: 'http://127.0.0.1:6103/', + id: 'container-current', + name: 'napcat-10001', + }; + const accountService = { + findByIdWithNapcatLoginSecret: jest.fn().mockResolvedValue(account), + getNapcatLoginPassword: jest.fn().mockReturnValue('qq-password'), + }; + let resolveContainer: (container: typeof existingContainer) => void = () => + undefined; + const containerPromise = new Promise( + (resolve) => { + resolveContainer = resolve; + }, + ); + const containerService = { + prepareAccountContainer: jest.fn().mockReturnValue(containerPromise), + }; + const refreshService = new QqbotNapcatLoginService( + { get: jest.fn() } as unknown as ConfigService, + accountService as unknown as QqbotAccountService, + containerService as unknown as QqbotNapcatContainerService, + new ToolsService(), + ); + const prepareReloginQrcode = jest + .spyOn(refreshService as any, 'prepareReloginQrcode') + .mockResolvedValue(undefined); + + const firstTask = refreshService.startRefresh('account-1'); + const secondTask = refreshService.startRefresh('account-1'); + resolveContainer(existingContainer); + + const [first, second] = await Promise.all([firstTask, secondTask]); + + expect(second.sessionId).toBe(first.sessionId); + expect(accountService.findByIdWithNapcatLoginSecret).toHaveBeenCalledTimes( + 1, + ); + expect(containerService.prepareAccountContainer).toHaveBeenCalledTimes(1); + expect(prepareReloginQrcode).toHaveBeenCalledTimes(1); + }); + it('keeps refresh login session alive while relogin is still preparing', async () => { const containerService = { removeUnboundContainer: jest.fn().mockResolvedValue(false), @@ -430,16 +549,10 @@ describe('QqbotNapcatLoginService', () => { expect(result.status).toBe('error'); expect(result.captchaUrl).toBeUndefined(); expect(result.errorMessage).toBe('安全验证失败,请重新登录'); - expect(containerService.ensureRuntimeLoginEnv).toHaveBeenCalledWith( - container, - { - clearLoginPassword: true, - selfId: '10001', - }, - ); + expect(containerService.ensureRuntimeLoginEnv).not.toHaveBeenCalled(); }); - it('uses NapCat -q quick login before generating qrcode for refresh login', async () => { + it('uses NapCat WebUI quick login before generating qrcode for refresh login', async () => { const container = { baseUrl: 'http://127.0.0.1:6103/', id: 'container-quick', @@ -482,22 +595,22 @@ describe('QqbotNapcatLoginService', () => { online: true, uin: '10001', }); + const postNapcat = jest + .spyOn(refreshService as any, 'postNapcat') + .mockResolvedValue(null); const refreshQrcode = jest .spyOn(refreshService as any, 'refreshOrGetQrcode') .mockResolvedValue('fallback-qrcode'); await (refreshService as any).prepareReloginQrcode(session, container); - expect(containerService.ensureRuntimeLoginEnv).toHaveBeenCalledWith( - container, - { - clearLoginPassword: true, - selfId: '10001', - }, - ); - expect(containerService.restartRuntimeContainer).toHaveBeenCalledWith( + expect(postNapcat).toHaveBeenCalledWith( container, + '/api/QQLogin/SetQuickLogin', + { uin: '10001' }, ); + expect(containerService.ensureRuntimeLoginEnv).not.toHaveBeenCalled(); + expect(containerService.restartRuntimeContainer).not.toHaveBeenCalled(); expect(containerService.resetRuntimeLoginState).not.toHaveBeenCalled(); expect(refreshQrcode).not.toHaveBeenCalled(); expect(accountService.ensureScannedAccount).toHaveBeenCalledWith({ @@ -522,7 +635,7 @@ describe('QqbotNapcatLoginService', () => { ); }); - it('does not complete quick login when runtime password env cleanup fails', async () => { + it('completes quick login without runtime password env cleanup', async () => { const container = { baseUrl: 'http://127.0.0.1:6103/', id: 'container-quick-cleanup-failed', @@ -565,23 +678,31 @@ describe('QqbotNapcatLoginService', () => { online: true, uin: '10001', }); + mockWebuiLoginPost(refreshService); await (refreshService as any).prepareReloginQrcode(session, container); - expect(accountService.ensureScannedAccount).not.toHaveBeenCalled(); - expect(containerService.bindAccount).not.toHaveBeenCalled(); - expect(session.status).toBe('error'); - expect(session.errorMessage).toBe( - 'NapCat 快速登录前运行态密码清理失败,请重试更新登录', + expect(containerService.ensureRuntimeLoginEnv).not.toHaveBeenCalled(); + expect(containerService.restartRuntimeContainer).not.toHaveBeenCalled(); + expect(containerService.resetRuntimeLoginState).not.toHaveBeenCalled(); + expect(accountService.ensureScannedAccount).toHaveBeenCalledWith({ + accountId: 'account-1', + name: 'Kwi', + selfId: '10001', + }); + expect(containerService.bindAccount).toHaveBeenCalledWith( + 'account-1', + 'container-quick-cleanup-failed', ); + expect(session.status).toBe('success'); const steps = ( (refreshService as any).sessionEventLogs.get(session.id) ?? [] ).map((event: { step: string }) => event.step); - expect(steps).toContain('quick-login-cleanup'); - expect(steps).not.toContain('login-success'); + expect(steps).not.toContain('quick-login-cleanup'); + expect(steps).toContain('login-success'); }); - it('falls back to fresh qrcode when NapCat -q quick login fails', async () => { + it('falls back to fresh qrcode without Docker reset when WebUI quick login fails', async () => { const container = { baseUrl: 'http://127.0.0.1:6103/', id: 'container-fallback', @@ -616,24 +737,17 @@ describe('QqbotNapcatLoginService', () => { isLogin: false, loginError: '快速登录未成功', }); + jest + .spyOn(refreshService as any, 'postNapcat') + .mockRejectedValue(new Error('快速登录未成功')); jest .spyOn(refreshService as any, 'refreshOrGetQrcode') .mockResolvedValue('fallback-qrcode'); await (refreshService as any).prepareReloginQrcode(session, container); - expect(containerService.restartRuntimeContainer).toHaveBeenCalledWith( - container, - ); - expect(containerService.resetRuntimeLoginState).toHaveBeenCalledWith( - container, - expect.any(Function), - ); - expect( - containerService.restartRuntimeContainer.mock.invocationCallOrder[0], - ).toBeLessThan( - containerService.resetRuntimeLoginState.mock.invocationCallOrder[0], - ); + expect(containerService.restartRuntimeContainer).not.toHaveBeenCalled(); + expect(containerService.resetRuntimeLoginState).not.toHaveBeenCalled(); expect(session.qrcode).toBe('fallback-qrcode'); const steps = ( (refreshService as any).sessionEventLogs.get(session.id) ?? [] @@ -642,10 +756,235 @@ describe('QqbotNapcatLoginService', () => { steps.indexOf('quick-login-fallback'), ); expect(steps.indexOf('quick-login-fallback')).toBeLessThan( - steps.indexOf('relogin-reset-start'), + steps.indexOf('qrcode-fetch'), ); }); + it('does not rebuild even when the refresh session already has a rebuild count', async () => { + const container = { + baseUrl: 'http://127.0.0.1:6103/', + id: 'container-rebuild-budget', + name: 'napcat-10001', + }; + const containerService = { + ensureRuntimeLoginEnv: jest + .fn() + .mockResolvedValue({ changed: false, ok: true }), + resetRuntimeLoginState: jest.fn().mockResolvedValue(true), + restartRuntimeContainer: jest.fn().mockResolvedValue(true), + }; + const refreshService = new QqbotNapcatLoginService( + { get: jest.fn() } as unknown as ConfigService, + {} as QqbotAccountService, + containerService as unknown as QqbotNapcatContainerService, + new ToolsService(), + ); + const session = (refreshService as any).createSession({ + accountId: 'account-1', + container, + expectedSelfId: '10001', + mode: 'refresh', + preparingRelogin: true, + runtimeRebuildCount: 1, + status: 'pending', + }); + (refreshService as any).sessions.set(session.id, session); + jest + .spyOn((refreshService as any).toolsService, 'sleep') + .mockResolvedValue(undefined); + jest.spyOn(refreshService as any, 'getLoginStatus').mockResolvedValue({ + isLogin: false, + loginError: '快速登录未成功', + }); + mockWebuiLoginPost(refreshService, { + quickError: '快速登录未成功', + }); + jest + .spyOn(refreshService as any, 'refreshOrGetQrcode') + .mockResolvedValue('budget-qrcode'); + + await (refreshService as any).prepareReloginQrcode(session, container); + + expect(containerService.resetRuntimeLoginState).not.toHaveBeenCalled(); + expect(containerService.restartRuntimeContainer).not.toHaveBeenCalled(); + expect(session.status).toBe('pending'); + expect(session.qrcode).toBe('budget-qrcode'); + }); + + it('refreshes qrcode through WebUI when the source Docker container is online but QQ is offline', async () => { + const container = { + baseUrl: 'http://127.0.0.1:6103/', + id: 'container-online-source', + name: 'napcat-10001', + sourceContainerOnline: true, + }; + const containerService = { + ensureRuntimeLoginEnv: jest + .fn() + .mockResolvedValue({ changed: false, ok: true }), + findRuntimeById: jest.fn().mockResolvedValue(container), + resetRuntimeLoginState: jest.fn().mockResolvedValue(true), + restartRuntimeContainer: jest.fn().mockResolvedValue(true), + }; + const accountService = { + ensureScannedAccount: jest.fn().mockResolvedValue('account-1'), + }; + const refreshService = new QqbotNapcatLoginService( + { get: jest.fn() } as unknown as ConfigService, + accountService as unknown as QqbotAccountService, + containerService as unknown as QqbotNapcatContainerService, + new ToolsService(), + ); + const session = (refreshService as any).createSession({ + accountId: 'account-1', + container, + expectedSelfId: '10001', + mode: 'refresh', + preparingRelogin: false, + sourceContainerOnline: true, + status: 'pending', + }); + (refreshService as any).sessions.set(session.id, session); + jest.spyOn(refreshService as any, 'getLoginStatus').mockResolvedValue({ + isOffline: true, + isLogin: false, + loginError: 'NapCat 账号状态变更为离线', + }); + const refreshOrGetQrcode = jest + .spyOn(refreshService as any, 'refreshOrGetQrcode') + .mockResolvedValue('online-source-qrcode'); + + const result = await refreshService.refreshQrcode(session.id); + + expect(containerService.resetRuntimeLoginState).not.toHaveBeenCalled(); + expect(containerService.restartRuntimeContainer).not.toHaveBeenCalled(); + expect(refreshOrGetQrcode).toHaveBeenCalledWith(container, false, { + fallbackStatus: expect.objectContaining({ isOffline: true }), + requireFresh: true, + staleQrcode: undefined, + }); + expect(result.status).toBe('pending'); + expect(result.qrcode).toBe('online-source-qrcode'); + }); + + it('does not perform runtime env rebuild inside the refresh session', async () => { + const container = { + baseUrl: 'http://127.0.0.1:6103/', + id: 'container-env-budget', + name: 'napcat-10001', + }; + const containerService = { + bindAccount: jest.fn().mockResolvedValue(undefined), + ensureRuntimeLoginEnv: jest.fn(), + resetRuntimeLoginState: jest.fn().mockResolvedValue(true), + restartRuntimeContainer: jest.fn().mockResolvedValue(true), + runtimeLoginEnvMatches: jest.fn().mockResolvedValue(false), + }; + const accountService = { + ensureScannedAccount: jest.fn().mockResolvedValue('account-1'), + }; + const refreshService = new QqbotNapcatLoginService( + { get: jest.fn() } as unknown as ConfigService, + accountService as unknown as QqbotAccountService, + containerService as unknown as QqbotNapcatContainerService, + new ToolsService(), + ); + const session = (refreshService as any).createSession({ + accountId: 'account-1', + container, + expectedSelfId: '10001', + mode: 'refresh', + preparingRelogin: true, + runtimeRebuildCount: 1, + status: 'pending', + }); + (refreshService as any).sessions.set(session.id, session); + jest + .spyOn(refreshService as any, 'getLoginStatus') + .mockResolvedValueOnce({ + isLogin: false, + loginError: '快速登录未找到历史会话', + }) + .mockResolvedValueOnce({ + isLogin: true, + }); + jest.spyOn(refreshService as any, 'getLoginInfo').mockResolvedValue({ + nickname: 'Kwi', + online: true, + uin: '10001', + }); + mockWebuiLoginPost(refreshService); + + await (refreshService as any).prepareReloginQrcode( + session, + container, + 'qq-password', + ); + + expect(containerService.runtimeLoginEnvMatches).not.toHaveBeenCalled(); + expect(containerService.ensureRuntimeLoginEnv).not.toHaveBeenCalled(); + expect(containerService.resetRuntimeLoginState).not.toHaveBeenCalled(); + expect(accountService.ensureScannedAccount).toHaveBeenCalledWith({ + accountId: 'account-1', + name: 'Kwi', + selfId: '10001', + }); + expect(session.status).toBe('success'); + }); + + it('prepares qrcode through WebUI when the source Docker container is online', async () => { + const container = { + baseUrl: 'http://127.0.0.1:6103/', + id: 'container-online-env', + name: 'napcat-10001', + sourceContainerOnline: true, + }; + const containerService = { + ensureRuntimeLoginEnv: jest.fn(), + resetRuntimeLoginState: jest.fn().mockResolvedValue(true), + restartRuntimeContainer: jest.fn().mockResolvedValue(true), + runtimeLoginEnvMatches: jest.fn().mockResolvedValue(false), + }; + const accountService = { + ensureScannedAccount: jest.fn().mockResolvedValue('account-1'), + }; + const refreshService = new QqbotNapcatLoginService( + { get: jest.fn() } as unknown as ConfigService, + accountService as unknown as QqbotAccountService, + containerService as unknown as QqbotNapcatContainerService, + new ToolsService(), + ); + const session = (refreshService as any).createSession({ + accountId: 'account-1', + container, + expectedSelfId: '10001', + mode: 'refresh', + preparingRelogin: true, + sourceContainerOnline: true, + status: 'pending', + }); + (refreshService as any).sessions.set(session.id, session); + jest.spyOn(refreshService as any, 'getLoginStatus').mockResolvedValue({ + isLogin: false, + loginError: 'Docker 容器在线但 QQ 账号离线', + }); + jest + .spyOn(refreshService as any, 'postNapcat') + .mockRejectedValue(new Error('快速登录未成功')); + jest + .spyOn(refreshService as any, 'refreshOrGetQrcode') + .mockResolvedValue('online-source-qrcode'); + + await (refreshService as any).prepareReloginQrcode(session, container); + + expect(containerService.ensureRuntimeLoginEnv).not.toHaveBeenCalled(); + expect(containerService.runtimeLoginEnvMatches).not.toHaveBeenCalled(); + expect(containerService.resetRuntimeLoginState).not.toHaveBeenCalled(); + expect(containerService.restartRuntimeContainer).not.toHaveBeenCalled(); + expect(session.status).toBe('pending'); + expect(session.qrcode).toBe('online-source-qrcode'); + }); + it('tries password login after quick login fails and before qrcode fallback', async () => { const container = { baseUrl: 'http://127.0.0.1:6103/', @@ -659,7 +998,7 @@ describe('QqbotNapcatLoginService', () => { bindAccount: jest.fn().mockResolvedValue(undefined), ensureRuntimeLoginEnv: jest .fn() - .mockResolvedValue({ changed: true, ok: true }), + .mockResolvedValue({ changed: false, ok: true }), resetRuntimeLoginState: jest.fn().mockResolvedValue(true), restartRuntimeContainer: jest.fn().mockResolvedValue(true), }; @@ -681,12 +1020,22 @@ describe('QqbotNapcatLoginService', () => { jest .spyOn((refreshService as any).toolsService, 'sleep') .mockResolvedValue(undefined); + const postNapcat = jest + .spyOn(refreshService as any, 'postNapcat') + .mockImplementation( + async ( + _container: unknown, + path: string, + ) => { + if (path === '/api/QQLogin/SetQuickLogin') { + throw new Error('快速登录未找到历史会话'); + } + if (path === '/api/QQLogin/PasswordLogin') return null; + return null; + }, + ); jest .spyOn(refreshService as any, 'getLoginStatus') - .mockResolvedValueOnce({ - isLogin: false, - loginError: '快速登录未找到历史会话', - }) .mockResolvedValueOnce({ isLogin: true, }); @@ -705,35 +1054,21 @@ describe('QqbotNapcatLoginService', () => { 'qq-password', ); - expect(containerService.ensureRuntimeLoginEnv).toHaveBeenCalledWith( + expect(postNapcat).toHaveBeenCalledWith( container, + '/api/QQLogin/SetQuickLogin', + { uin: '10001' }, + ); + expect(postNapcat).toHaveBeenCalledWith( + container, + '/api/QQLogin/PasswordLogin', { - clearLoginPassword: true, - selfId: '10001', - }, - ); - expect(containerService.ensureRuntimeLoginEnv).toHaveBeenCalledWith( - container, - { - loginPassword: 'qq-password', - selfId: '10001', - }, - ); - expect(containerService.ensureRuntimeLoginEnv).toHaveBeenCalledWith( - container, - { - clearLoginPassword: true, - selfId: '10001', - }, - ); - expect(containerService.ensureRuntimeLoginEnv).toHaveBeenNthCalledWith( - 3, - container, - { - clearLoginPassword: true, - selfId: '10001', + passwordMd5: '7fe1f9ae1130b64e9ca1441492c382c0', + uin: '10001', }, ); + expect(containerService.ensureRuntimeLoginEnv).not.toHaveBeenCalled(); + expect(containerService.restartRuntimeContainer).not.toHaveBeenCalled(); expect(containerService.resetRuntimeLoginState).not.toHaveBeenCalled(); expect(refreshQrcode).not.toHaveBeenCalled(); expect(accountService.ensureScannedAccount).toHaveBeenCalledWith({ @@ -775,7 +1110,7 @@ describe('QqbotNapcatLoginService', () => { bindAccount: jest.fn().mockResolvedValue(undefined), ensureRuntimeLoginEnv: jest .fn() - .mockResolvedValue({ changed: true, ok: true }), + .mockResolvedValue({ changed: false, ok: true }), resetRuntimeLoginState: jest.fn().mockResolvedValue(true), restartRuntimeContainer: jest.fn().mockResolvedValue(true), }; @@ -831,6 +1166,7 @@ describe('QqbotNapcatLoginService', () => { const refreshQrcode = jest .spyOn(refreshService as any, 'refreshOrGetQrcode') .mockResolvedValue('fallback-qrcode'); + mockWebuiLoginPost(refreshService); await (refreshService as any).prepareReloginQrcode( session, @@ -858,7 +1194,7 @@ describe('QqbotNapcatLoginService', () => { const containerService = { ensureRuntimeLoginEnv: jest .fn() - .mockResolvedValue({ changed: true, ok: true }), + .mockResolvedValue({ changed: false, ok: true }), resetRuntimeLoginState: jest.fn().mockResolvedValue(true), restartRuntimeContainer: jest.fn().mockResolvedValue(true), }; @@ -902,6 +1238,7 @@ describe('QqbotNapcatLoginService', () => { const refreshQrcode = jest .spyOn(refreshService as any, 'refreshOrGetQrcode') .mockResolvedValue('fallback-qrcode'); + mockWebuiLoginPost(refreshService); await (refreshService as any).prepareReloginQrcode( session, @@ -934,7 +1271,7 @@ describe('QqbotNapcatLoginService', () => { detectRuntimeCaptchaUrl: jest.fn().mockResolvedValue(captchaUrl), ensureRuntimeLoginEnv: jest .fn() - .mockResolvedValue({ changed: true, ok: true }), + .mockResolvedValue({ changed: false, ok: true }), resetRuntimeLoginState: jest.fn().mockResolvedValue(true), restartRuntimeContainer: jest.fn().mockResolvedValue(true), }; @@ -978,6 +1315,7 @@ describe('QqbotNapcatLoginService', () => { const refreshQrcode = jest .spyOn(refreshService as any, 'refreshOrGetQrcode') .mockResolvedValue('fallback-qrcode'); + mockWebuiLoginPost(refreshService); await (refreshService as any).prepareReloginQrcode( session, @@ -1061,6 +1399,7 @@ describe('QqbotNapcatLoginService', () => { const refreshQrcode = jest .spyOn(refreshService as any, 'refreshOrGetQrcode') .mockResolvedValue('fallback-qrcode'); + mockWebuiLoginPost(refreshService); await (refreshService as any).prepareReloginQrcode( session, @@ -1128,6 +1467,7 @@ describe('QqbotNapcatLoginService', () => { jest .spyOn(refreshService as any, 'resolvePasswordCaptchaUrl') .mockResolvedValue(captchaUrl); + mockWebuiLoginPost(refreshService); const result = await (refreshService as any).tryPasswordRelogin( session, @@ -1146,7 +1486,7 @@ describe('QqbotNapcatLoginService', () => { expect.objectContaining({ isLogin: false }), passwordLogSinceMs, ); - expect(session.lastRestartedAt).toBeGreaterThanOrEqual(envRebuildStartedAt); + expect(session.lastRestartedAt).toBe(passwordLogSinceMs); expect(session.captchaUrl).toBe(captchaUrl); }); @@ -1166,7 +1506,7 @@ describe('QqbotNapcatLoginService', () => { ), ensureRuntimeLoginEnv: jest .fn() - .mockResolvedValue({ changed: true, ok: true }), + .mockResolvedValue({ changed: false, ok: true }), resetRuntimeLoginState: jest.fn().mockResolvedValue(true), restartRuntimeContainer: jest.fn().mockResolvedValue(true), }; @@ -1210,6 +1550,7 @@ describe('QqbotNapcatLoginService', () => { jest .spyOn(refreshService as any, 'refreshOrGetQrcode') .mockResolvedValue('fallback-qrcode'); + mockWebuiLoginPost(refreshService); await (refreshService as any).prepareReloginQrcode( session, @@ -1326,6 +1667,7 @@ describe('QqbotNapcatLoginService', () => { const refreshQrcode = jest .spyOn(refreshService as any, 'refreshOrGetQrcode') .mockResolvedValue('fallback-qrcode'); + mockWebuiLoginPost(refreshService); await (refreshService as any).prepareReloginQrcode( session, @@ -1339,15 +1681,7 @@ describe('QqbotNapcatLoginService', () => { ); expect(refreshQrcode).not.toHaveBeenCalled(); expect(containerService.resetRuntimeLoginState).not.toHaveBeenCalled(); - expect(ensureRuntimeLoginEnv).toHaveBeenCalledTimes(2); - expect(ensureRuntimeLoginEnv).toHaveBeenNthCalledWith(1, container, { - clearLoginPassword: true, - selfId: '10001', - }); - expect(ensureRuntimeLoginEnv).toHaveBeenNthCalledWith(2, container, { - loginPassword: 'qq-password', - selfId: '10001', - }); + expect(ensureRuntimeLoginEnv).not.toHaveBeenCalled(); expect(session.status).toBe('pending'); expect(session.captchaUrl).toBe(captchaUrl); expect(session.passwordMd5).toBeTruthy(); @@ -1410,6 +1744,7 @@ describe('QqbotNapcatLoginService', () => { const refreshQrcode = jest .spyOn(refreshService as any, 'refreshOrGetQrcode') .mockResolvedValue('fresh-qrcode'); + mockWebuiLoginPost(refreshService); await (refreshService as any).prepareReloginQrcode( session, @@ -1556,10 +1891,6 @@ describe('QqbotNapcatLoginService', () => { online: true, uin: '10001', }); - jest - .spyOn(refreshService as any, 'clearRuntimeLoginPasswordAfterSuccess') - .mockResolvedValue(undefined); - const result = await refreshService.submitCaptcha(session.id, { randstr: '@rand', sid: 'sid-from-url', @@ -1728,10 +2059,6 @@ describe('QqbotNapcatLoginService', () => { online: true, uin: '10001', }); - jest - .spyOn(refreshService as any, 'clearRuntimeLoginPasswordAfterSuccess') - .mockResolvedValue(undefined); - const scanned = (await refreshService.status(session.id)) as any; const completed = await refreshService.status(session.id); @@ -1842,10 +2169,6 @@ describe('QqbotNapcatLoginService', () => { online: true, uin: '10001', }); - jest - .spyOn(refreshService as any, 'clearRuntimeLoginPasswordAfterSuccess') - .mockResolvedValue(undefined); - const result = await refreshService.status(session.id); expect(result.status).toBe('success'); @@ -1920,7 +2243,7 @@ describe('QqbotNapcatLoginService', () => { ); }); - it('cleans runtime password env when cancelling captcha pending login', async () => { + it('clears password challenge session state when cancelling captcha pending login', async () => { const container = { baseUrl: 'http://127.0.0.1:6103/', id: 'container-captcha-cancel', @@ -1954,18 +2277,12 @@ describe('QqbotNapcatLoginService', () => { await refreshService.cancel(session.id); - expect(containerService.ensureRuntimeLoginEnv).toHaveBeenCalledWith( - container, - { - clearLoginPassword: true, - selfId: '10001', - }, - ); + expect(containerService.ensureRuntimeLoginEnv).not.toHaveBeenCalled(); expect(session.passwordMd5).toBeUndefined(); expect(session.captchaUrl).toBeUndefined(); }); - it('marks captcha submit as error when password env cleanup after success fails', async () => { + it('does not block captcha success with runtime password env cleanup', async () => { const accountService = { ensureScannedAccount: jest.fn().mockResolvedValue('account-1'), }; @@ -2018,16 +2335,17 @@ describe('QqbotNapcatLoginService', () => { ticket: 'captcha-ticket', }); - expect(result.status).toBe('error'); - expect(result.errorMessage).toBe( - 'NapCat 密码登录已完成,但运行态密码清理失败,请重试更新登录', - ); - expect(accountService.ensureScannedAccount).not.toHaveBeenCalled(); + expect(result.status).toBe('success'); + expect(accountService.ensureScannedAccount).toHaveBeenCalledWith({ + accountId: 'account-1', + name: 'Kwi', + selfId: '10001', + }); const steps = ( (refreshService as any).sessionEventLogs.get(session.id) ?? [] ).map((event: { step: string }) => event.step); - expect(steps).toContain('password-env-cleanup-failed'); - expect(steps).not.toContain('login-success'); + expect(steps).not.toContain('password-env-cleanup-failed'); + expect(steps).toContain('login-success'); }); it('returns captcha status immediately without waiting full password window', async () => { @@ -2249,13 +2567,7 @@ describe('QqbotNapcatLoginService', () => { expect(result.status).toBe('error'); expect(result.captchaUrl).toBeUndefined(); expect(result.errorMessage).toBe('验证码登录未完成:密码错误'); - expect(containerService.ensureRuntimeLoginEnv).toHaveBeenCalledWith( - container, - { - clearLoginPassword: true, - selfId: '10001', - }, - ); + expect(containerService.ensureRuntimeLoginEnv).not.toHaveBeenCalled(); }); it('uses password before qrcode without historical NapCat session', async () => { @@ -2305,6 +2617,7 @@ describe('QqbotNapcatLoginService', () => { const refreshQrcode = jest .spyOn(refreshService as any, 'refreshOrGetQrcode') .mockResolvedValue('fallback-qrcode'); + mockWebuiLoginPost(refreshService); await (refreshService as any).prepareReloginQrcode( session, @@ -2313,14 +2626,7 @@ describe('QqbotNapcatLoginService', () => { false, ); - expect(containerService.ensureRuntimeLoginEnv).toHaveBeenNthCalledWith( - 1, - container, - { - loginPassword: 'qq-password', - selfId: '10001', - }, - ); + expect(containerService.ensureRuntimeLoginEnv).not.toHaveBeenCalled(); expect(refreshQrcode).not.toHaveBeenCalled(); expect(session.status).toBe('success'); const steps = ( @@ -2332,7 +2638,7 @@ describe('QqbotNapcatLoginService', () => { ); }); - it('fails password refresh when runtime password env cleanup fails', async () => { + it('completes password refresh without runtime password env cleanup', async () => { const container = { baseUrl: 'http://127.0.0.1:6103/', id: 'container-password-cleanup-failed', @@ -2383,6 +2689,7 @@ describe('QqbotNapcatLoginService', () => { online: true, uin: '10001', }); + mockWebuiLoginPost(refreshService); await (refreshService as any).prepareReloginQrcode( session, @@ -2390,25 +2697,22 @@ describe('QqbotNapcatLoginService', () => { 'qq-password', ); - expect(containerService.ensureRuntimeLoginEnv).toHaveBeenNthCalledWith( - 3, - container, - { - clearLoginPassword: true, - selfId: '10001', - }, - ); - expect(accountService.ensureScannedAccount).not.toHaveBeenCalled(); - expect(containerService.bindAccount).not.toHaveBeenCalled(); - expect(session.status).toBe('error'); - expect(session.errorMessage).toBe( - 'NapCat 密码登录已完成,但运行态密码清理失败,请重试更新登录', + expect(containerService.ensureRuntimeLoginEnv).not.toHaveBeenCalled(); + expect(accountService.ensureScannedAccount).toHaveBeenCalledWith({ + accountId: 'account-1', + name: 'Kwi', + selfId: '10001', + }); + expect(containerService.bindAccount).toHaveBeenCalledWith( + 'account-1', + 'container-password-cleanup-failed', ); + expect(session.status).toBe('success'); const steps = ( (refreshService as any).sessionEventLogs.get(session.id) ?? [] ).map((event: { step: string }) => event.step); - expect(steps).toContain('password-env-cleanup'); - expect(steps).not.toContain('login-success'); + expect(steps).not.toContain('password-env-cleanup'); + expect(steps).toContain('login-success'); }); it('falls back to qrcode only after quick login and password login both fail', async () => { @@ -2420,7 +2724,7 @@ describe('QqbotNapcatLoginService', () => { const containerService = { ensureRuntimeLoginEnv: jest .fn() - .mockResolvedValue({ changed: true, ok: true }), + .mockResolvedValue({ changed: false, ok: true }), resetRuntimeLoginState: jest.fn().mockResolvedValue(true), restartRuntimeContainer: jest.fn().mockResolvedValue(true), }; @@ -2448,13 +2752,14 @@ describe('QqbotNapcatLoginService', () => { isLogin: false, loginError: '快速登录未找到历史会话', }) - .mockResolvedValueOnce({ + .mockResolvedValue({ isLogin: false, loginError: '密码登录未完成', }); jest .spyOn(refreshService as any, 'refreshOrGetQrcode') .mockResolvedValue('fallback-qrcode'); + mockWebuiLoginPost(refreshService); await (refreshService as any).prepareReloginQrcode( session, @@ -2462,10 +2767,8 @@ describe('QqbotNapcatLoginService', () => { 'qq-password', ); - expect(containerService.resetRuntimeLoginState).toHaveBeenCalledWith( - container, - expect.any(Function), - ); + expect(containerService.resetRuntimeLoginState).not.toHaveBeenCalled(); + expect(containerService.restartRuntimeContainer).not.toHaveBeenCalled(); expect(session.qrcode).toBe('fallback-qrcode'); const steps = ( (refreshService as any).sessionEventLogs.get(session.id) ?? [] @@ -2474,11 +2777,11 @@ describe('QqbotNapcatLoginService', () => { steps.indexOf('password-login-start'), ); expect(steps.indexOf('password-login-fallback')).toBeLessThan( - steps.indexOf('relogin-reset-start'), + steps.indexOf('qrcode-fetch'), ); }); - it('does not enter qrcode fallback when password env cleanup after failed password login fails', async () => { + it('enters qrcode fallback when password login fails without runtime env cleanup', async () => { const container = { baseUrl: 'http://127.0.0.1:6103/', id: 'container-password-failed-cleanup-failed', @@ -2517,13 +2820,14 @@ describe('QqbotNapcatLoginService', () => { isLogin: false, loginError: '快速登录未找到历史会话', }) - .mockResolvedValueOnce({ + .mockResolvedValue({ isLogin: false, loginError: '密码登录未完成', }); const refreshQrcode = jest .spyOn(refreshService as any, 'refreshOrGetQrcode') .mockResolvedValue('fallback-qrcode'); + mockWebuiLoginPost(refreshService); await (refreshService as any).prepareReloginQrcode( session, @@ -2531,20 +2835,16 @@ describe('QqbotNapcatLoginService', () => { 'qq-password', ); - expect(containerService.ensureRuntimeLoginEnv).toHaveBeenNthCalledWith( - 3, - container, - { - clearLoginPassword: true, - selfId: '10001', - }, - ); + expect(containerService.ensureRuntimeLoginEnv).not.toHaveBeenCalled(); expect(containerService.resetRuntimeLoginState).not.toHaveBeenCalled(); - expect(refreshQrcode).not.toHaveBeenCalled(); - expect(session.status).toBe('error'); - expect(session.errorMessage).toBe( - 'NapCat 密码登录未完成,且运行态密码清理失败,请重试更新登录', + expect(containerService.restartRuntimeContainer).not.toHaveBeenCalled(); + expect(refreshQrcode).toHaveBeenCalledWith( + container, + false, + expect.objectContaining({ requireFresh: true }), ); + expect(session.status).toBe('pending'); + expect(session.qrcode).toBe('fallback-qrcode'); }); it('does not reset login state when quick login succeeds but binding fails', async () => { @@ -2595,6 +2895,7 @@ describe('QqbotNapcatLoginService', () => { const refreshQrcode = jest .spyOn(refreshService as any, 'refreshOrGetQrcode') .mockResolvedValue('fallback-qrcode'); + mockWebuiLoginPost(refreshService); await (refreshService as any).prepareReloginQrcode(session, container); @@ -2828,7 +3129,7 @@ describe('QqbotNapcatLoginService', () => { ); }); - it('restarts container and returns pending when refresh login status is effectively offline', async () => { + it('returns a refresh qrcode without restarting when refresh login status is effectively offline', async () => { const refreshService = new QqbotNapcatLoginService( { get: jest.fn() } as unknown as ConfigService, {} as QqbotAccountService, @@ -2847,6 +3148,9 @@ describe('QqbotNapcatLoginService', () => { const restartNapcatForLogin = jest .spyOn(refreshService as any, 'restartNapcatForLogin') .mockResolvedValue(undefined); + jest + .spyOn(refreshService as any, 'refreshOrGetQrcode') + .mockResolvedValue('offline-refresh-qrcode'); const result = await (refreshService as any).startScan( { @@ -2862,15 +3166,12 @@ describe('QqbotNapcatLoginService', () => { ); expect(result.status).toBe('pending'); - expect(result.qrcode).toBeUndefined(); - expect(result.errorMessage).toBe('NapCat 账号已离线'); - expect(restartNapcatForLogin).toHaveBeenCalledWith( - expect.objectContaining({ id: 'container-offline' }), - { waitForReady: false }, - ); + expect(result.qrcode).toBe('offline-refresh-qrcode'); + expect(result.errorMessage).toBeUndefined(); + expect(restartNapcatForLogin).not.toHaveBeenCalled(); }); - it('restarts the existing NapCat container before qrcode refresh when account is kicked offline', async () => { + it('refreshes qrcode through WebUI without restarting when account is kicked offline', async () => { const container = { baseUrl: 'http://127.0.0.1:6103/', id: 'container-3', @@ -2913,9 +3214,7 @@ describe('QqbotNapcatLoginService', () => { ); expect(result).toBe('new-qrcode'); - expect(containerService.restartRuntimeContainer).toHaveBeenCalledWith( - container, - ); + expect(containerService.restartRuntimeContainer).not.toHaveBeenCalled(); expect((refreshService as any).getQrcode).toHaveBeenCalledWith( container, true, diff --git a/test/qqbot/napcat/qqbot-napcat-container.service.spec.ts b/test/qqbot/napcat/qqbot-napcat-container.service.spec.ts index c830d3d..cb02a19 100644 --- a/test/qqbot/napcat/qqbot-napcat-container.service.spec.ts +++ b/test/qqbot/napcat/qqbot-napcat-container.service.spec.ts @@ -83,6 +83,51 @@ describe('QqbotNapcatContainerService', () => { ); }); + it('backfills container ownership when binding an account to a legacy container', async () => { + const bindingRepository = { + create: jest.fn((input) => input), + createQueryBuilder: jest.fn(() => ({ + andWhere: jest.fn().mockReturnThis(), + getCount: jest.fn().mockResolvedValue(0), + where: jest.fn().mockReturnThis(), + })), + find: jest.fn().mockResolvedValue([]), + findOne: jest.fn().mockResolvedValue({ + accountId: 'account-1', + containerId: 'container-old', + id: 'binding-1', + isDeleted: false, + }), + save: jest.fn(), + update: jest.fn(), + }; + const containerRepository = { + findOne: jest.fn(), + update: jest.fn(), + }; + const service = new QqbotNapcatContainerService( + { get: jest.fn().mockReturnValue('') } as unknown as ConfigService, + containerRepository as any, + bindingRepository as any, + new ToolsService(), + ); + + await service.bindAccount('account-1', 'container-1'); + + expect(containerRepository.update).toHaveBeenCalledWith( + { id: 'container-1', isDeleted: false }, + { accountId: 'account-1' }, + ); + expect(bindingRepository.update).toHaveBeenCalledWith( + { id: 'binding-1' }, + expect.objectContaining({ + bindStatus: 'bound', + containerId: 'container-1', + isPrimary: true, + }), + ); + }); + it('uses the latest NapCat account status line as runtime offline truth', () => { const service = new QqbotNapcatContainerService( { get: jest.fn() } as any, @@ -390,9 +435,7 @@ describe('QqbotNapcatContainerService', () => { expect(script).toContain('-e XDG_DATA_HOME=/app/.local/share'); expect(script).toContain('-e XDG_RUNTIME_DIR=/tmp/runtime-napcat'); expect(script).toContain('-v "$DATA_DIR/cache:/app/.cache"'); - expect(script).toContain( - '-v "$DATA_DIR/local-share:/app/.local/share"', - ); + expect(script).toContain('-v "$DATA_DIR/local-share:/app/.local/share"'); expect(script).toContain('-v "$DATA_DIR/logs:/app/napcat/logs"'); expect(script).toContain('cat > "$DATA_DIR/config/napcat_10001.json"'); expect(script).toContain('cat > "$DATA_DIR/config/onebot11_10001.json"'); @@ -447,6 +490,141 @@ describe('QqbotNapcatContainerService', () => { expect(containerRepository.update).not.toHaveBeenCalled(); }); + it('does not recreate quick-login env when the bound source container is already online', async () => { + const bindingRepository = { + findOne: jest.fn().mockResolvedValue({ + accountId: 'account-1', + bindStatus: 'bound', + containerId: 'container-online', + isDeleted: false, + isPrimary: true, + }), + }; + const containerRepository = { + createQueryBuilder: jest.fn(() => ({ + addSelect: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue({ + baseUrl: 'http://127.0.0.1:6100/', + dataDir: '/vol1/docker/kt-qqbot/napcat-instances/kt-qqbot-napcat-x', + id: 'container-online', + image: 'mlikiowa/napcat-docker:latest', + lastError: null, + name: 'kt-qqbot-napcat-x', + reverseWsUrl: 'ws://127.0.0.1:48085/qqbot/onebot/reverse', + status: 'running', + webuiPort: 6100, + webuiToken: 'token-x', + }), + where: jest.fn().mockReturnThis(), + })), + update: jest.fn(), + }; + const service = new QqbotNapcatContainerService( + { + get: jest.fn((key: string) => + key === 'QQBOT_NAPCAT_CONTAINER_MODE' ? 'ssh' : '', + ), + } as any, + containerRepository as any, + bindingRepository as any, + new ToolsService(), + ) as any; + const ensureQuickLogin = jest.spyOn(service, 'ensureRuntimeQuickLogin'); + jest.spyOn(service, 'inspectRuntimeStatus').mockResolvedValue({ + containerOnline: true, + qqLoginStatus: 'online', + webuiOnline: true, + }); + + const runtime = await service.prepareAccountContainer({ + id: 'account-1', + selfId: '2354598417', + }); + + expect(ensureQuickLogin).not.toHaveBeenCalled(); + expect(runtime).toEqual( + expect.objectContaining({ + hasExistingPrimaryBinding: true, + runtimeRebuildCount: 0, + sourceContainerOnline: true, + }), + ); + }); + + it('starts a stopped primary container when login env already matches', async () => { + const bindingRepository = { + findOne: jest.fn().mockResolvedValue({ + accountId: 'account-1', + bindStatus: 'bound', + containerId: 'container-stopped', + isDeleted: false, + isPrimary: true, + }), + }; + const containerRepository = { + createQueryBuilder: jest.fn(() => ({ + addSelect: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue({ + baseUrl: 'http://127.0.0.1:6100/', + dataDir: '/vol1/docker/kt-qqbot/napcat-instances/kt-qqbot-napcat-x', + id: 'container-stopped', + image: 'mlikiowa/napcat-docker:latest', + lastError: null, + name: 'kt-qqbot-napcat-x', + reverseWsUrl: 'ws://127.0.0.1:48085/qqbot/onebot/reverse', + status: 'stopped', + webuiPort: 6100, + webuiToken: 'token-x', + }), + where: jest.fn().mockReturnThis(), + })), + update: jest.fn(), + }; + const service = new QqbotNapcatContainerService( + { + get: jest.fn((key: string) => + key === 'QQBOT_NAPCAT_CONTAINER_MODE' ? 'ssh' : '', + ), + } as any, + containerRepository as any, + bindingRepository as any, + new ToolsService(), + ) as any; + service.runProcess = jest + .fn() + .mockResolvedValueOnce({ + stderr: '', + stdout: 'ACCOUNT=2354598417\nPATH=/x', + }) + .mockResolvedValueOnce({ stderr: '', stdout: 'kt-qqbot-napcat-x\n' }); + + const runtime = await service.prepareAccountContainer({ + id: 'account-1', + selfId: '2354598417', + }); + + expect(runtime).toEqual( + expect.objectContaining({ + hasExistingPrimaryBinding: true, + runtimeRebuildCount: 0, + sourceContainerOnline: false, + }), + ); + expect(service.runProcess).toHaveBeenCalledTimes(2); + expect(service.runProcess.mock.calls[1][1]).toEqual( + expect.arrayContaining(['docker', 'start', 'kt-qqbot-napcat-x']), + ); + expect(containerRepository.update).toHaveBeenCalledWith( + { id: 'container-stopped' }, + expect.objectContaining({ + lastError: null, + status: 'running', + }), + ); + }); + it('recreates login env when docker env inspection fails', async () => { const containerRepository = { createQueryBuilder: jest.fn(() => ({ @@ -662,176 +840,6 @@ describe('QqbotNapcatContainerService', () => { ); }); - it('does not report password auto-login success when password env cleanup fails', async () => { - const containerRepository = { - createQueryBuilder: jest.fn(() => ({ - addSelect: jest.fn().mockReturnThis(), - andWhere: jest.fn().mockReturnThis(), - getOne: jest.fn().mockResolvedValue({ - baseUrl: 'http://127.0.0.1:6100/', - id: 'container-1', - name: 'kt-qqbot-napcat-x', - webuiPort: 6100, - webuiToken: 'token-x', - }), - where: jest.fn().mockReturnThis(), - })), - }; - const service = new QqbotNapcatContainerService( - { - get: jest.fn((key: string) => - key === 'QQBOT_NAPCAT_CONTAINER_MODE' ? 'ssh' : '', - ), - } as any, - containerRepository as any, - {} as any, - new ToolsService(), - ) as any; - jest - .spyOn(service, 'ensureRuntimeLoginEnv') - .mockResolvedValueOnce({ changed: false, ok: true }) - .mockResolvedValueOnce({ changed: true, ok: true }) - .mockResolvedValueOnce({ changed: false, ok: false }); - jest - .spyOn(service, 'restartAndDetectLoginState') - .mockResolvedValueOnce({ - offlineReason: '历史会话失效', - state: 'offline', - }) - .mockResolvedValueOnce({ offlineReason: null, state: 'online' }); - - const result = await service.tryAutoLogin( - { id: 'container-1' }, - { - loginPassword: 'qq-password', - selfId: '2354598417', - }, - ); - - expect(result).toEqual({ - cleanupFailed: true, - success: false, - }); - expect(service.ensureRuntimeLoginEnv).toHaveBeenNthCalledWith( - 3, - expect.objectContaining({ id: 'container-1' }), - { - clearLoginPassword: true, - selfId: '2354598417', - }, - ); - }); - - it('does not leave password env untracked when password auto-login fails and cleanup fails', async () => { - const containerRepository = { - createQueryBuilder: jest.fn(() => ({ - addSelect: jest.fn().mockReturnThis(), - andWhere: jest.fn().mockReturnThis(), - getOne: jest.fn().mockResolvedValue({ - baseUrl: 'http://127.0.0.1:6100/', - id: 'container-1', - name: 'kt-qqbot-napcat-x', - webuiPort: 6100, - webuiToken: 'token-x', - }), - where: jest.fn().mockReturnThis(), - })), - }; - const service = new QqbotNapcatContainerService( - { - get: jest.fn((key: string) => - key === 'QQBOT_NAPCAT_CONTAINER_MODE' ? 'ssh' : '', - ), - } as any, - containerRepository as any, - {} as any, - new ToolsService(), - ) as any; - jest - .spyOn(service, 'ensureRuntimeLoginEnv') - .mockResolvedValueOnce({ changed: false, ok: true }) - .mockResolvedValueOnce({ changed: true, ok: true }) - .mockResolvedValueOnce({ changed: false, ok: false }); - jest - .spyOn(service, 'restartAndDetectLoginState') - .mockResolvedValueOnce({ - offlineReason: '历史会话失效', - state: 'offline', - }) - .mockResolvedValueOnce({ - offlineReason: '密码登录失败', - state: 'offline', - }); - - const result = await service.tryAutoLogin( - { id: 'container-1' }, - { - loginPassword: 'qq-password', - selfId: '2354598417', - }, - ); - - expect(result).toEqual({ - cleanupFailed: true, - success: false, - }); - expect(service.ensureRuntimeLoginEnv).toHaveBeenNthCalledWith( - 3, - expect.objectContaining({ id: 'container-1' }), - { - clearLoginPassword: true, - selfId: '2354598417', - }, - ); - }); - - it('does not restart quick auto-login when password env cleanup fails first', async () => { - const containerRepository = { - createQueryBuilder: jest.fn(() => ({ - addSelect: jest.fn().mockReturnThis(), - andWhere: jest.fn().mockReturnThis(), - getOne: jest.fn().mockResolvedValue({ - baseUrl: 'http://127.0.0.1:6100/', - id: 'container-1', - name: 'kt-qqbot-napcat-x', - webuiPort: 6100, - webuiToken: 'token-x', - }), - where: jest.fn().mockReturnThis(), - })), - }; - const service = new QqbotNapcatContainerService( - { - get: jest.fn((key: string) => - key === 'QQBOT_NAPCAT_CONTAINER_MODE' ? 'ssh' : '', - ), - } as any, - containerRepository as any, - {} as any, - new ToolsService(), - ) as any; - jest - .spyOn(service, 'ensureRuntimeLoginEnv') - .mockResolvedValueOnce({ changed: false, ok: false }); - jest - .spyOn(service, 'restartAndDetectLoginState') - .mockResolvedValueOnce({ offlineReason: null, state: 'online' }); - - const result = await service.tryAutoLogin( - { id: 'container-1' }, - { - loginPassword: 'qq-password', - selfId: '2354598417', - }, - ); - - expect(result).toEqual({ - cleanupFailed: true, - success: false, - }); - expect(service.restartAndDetectLoginState).not.toHaveBeenCalled(); - }); - it('requires an explicit NapCat image when creating a managed container', async () => { const containerRepository = { find: jest.fn().mockResolvedValue([]),