fix: 修复NapCat新增账号快返回和创建期设备拟真

This commit is contained in:
sunlei 2026-06-22 16:22:38 +08:00
parent c169f1312d
commit 4651b60beb
10 changed files with 1113 additions and 109 deletions

4
API.md
View File

@ -361,13 +361,13 @@ 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` 账号保存支持可选 `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 请求完成。已有账号的更新登录不会通过 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 登录或扫码登录。 扫码链路返回 `sessionId`,前端应使用 SSE 查看步骤进度,而不是等待长 HTTP 请求完成;新增账号扫码会先预留容器和临时设备身份后立即返回 pending会话后台再启动远端 Docker 和生成二维码。已有账号的更新登录不会通过 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` 默认镜像。 同一 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` 默认镜像。
托管 NapCat 容器按账号持久化设备身份,`napcat_device_identity` 保存账号对应的数据目录、hostname、machine-id 路径、MAC 地址、验证状态和最近登录证据。重建同一账号容器时会复用 `pc-<8hex>` hostname、`02:42:*` MAC 和 machine-id,并在 Docker run 中注入 `--hostname`、`--mac-address` 和只读 `/etc/machine-id` 挂载;后端还会同步写入 QQNT Linux `machine-info`,让 QQNT 计算 GUID 时使用的 MAC 与 Docker 网卡一致。当前策略名为 `qqnt-visible-hostname-v1` / `docker-bridge-mac-v1`,绑定关系会回填 `napcat_account_binding.device_identity_id` 托管 NapCat 容器按账号持久化设备身份,`napcat_device_identity` 保存账号对应的数据目录、hostname、machine-id 路径、MAC 地址、验证状态和最近登录证据。重建同一账号容器时会复用 `pc-<8hex>` hostname、`02:42:*` MAC 和 machine-id;新增账号创建期在真实 QQ selfId 未知时使用预留容器 id 创建临时设备身份,第一次 Docker run 就注入完整拟真参数,扫码成功后再把该身份和 runtime/protocol profile 归属到真实账号。Docker run 会注入 `--hostname`、`--mac-address` 和只读 `/etc/machine-id` 挂载;后端还会同步写入 QQNT Linux `machine-info`,让 QQNT 计算 GUID 时使用的 MAC 与 Docker 网卡一致。当前策略名为 `qqnt-visible-hostname-v1` / `docker-bridge-mac-v1`,绑定关系会回填 `napcat_account_binding.device_identity_id`
### NapCat Runtime Profile ### NapCat Runtime Profile

View File

@ -158,7 +158,7 @@ API 暴露 `GET /health/runtime` 作为本地 smoke、Jenkins/K8s 和 ktWorkflow
- WordPress 自动登录失败不会阻断 Admin 主登录,会通过菜单和权限码过滤不可用的 Blog 管理入口。 - WordPress 自动登录失败不会阻断 Admin 主登录,会通过菜单和权限码过滤不可用的 Blog 管理入口。
- 系统日志由 pino 输出Loki 查询统一通过后端 `/system/logs/*` 代理,前端不直连 Loki。 - 系统日志由 pino 输出Loki 查询统一通过后端 `/system/logs/*` 代理,前端不直连 Loki。
- 日志级站内信只承接运行期事件:接口 5xx、QQBot 下线 notice、NapCat 容器最新离线日志会自动聚合通知 `super` 角色;服务端强制 `super` 访问Admin 不再暴露人工新增/编辑入口;长路径接口错误会压缩 `dedupeKey/title` 到表字段长度内,避免通知入库失败。 - 日志级站内信只承接运行期事件:接口 5xx、QQBot 下线 notice、NapCat 容器最新离线日志会自动聚合通知 `super` 角色;服务端强制 `super` 访问Admin 不再暴露人工新增/编辑入口;长路径接口错误会压缩 `dedupeKey/title` 到表字段长度内,避免通知入库失败。
- QQBot 扫码登录通过 SSE `/qqbot/account/scan/events` 暴露进度,耗时链路不应阻塞普通 HTTP 响应。 - QQBot 扫码登录通过 SSE `/qqbot/account/scan/events` 暴露进度,耗时链路不应阻塞普通 HTTP 响应;新增账号扫码会先返回 pending `sessionId`,后台再创建 NapCat 容器并生成二维码
- QQBot 外发统一走发送排队:默认全局间隔 `2500ms`、同会话间隔 `8000ms`、排队抖动 `0-800ms`,超过 `QQBOT_SEND_MAX_QUEUE_WAIT_MS` 时拒绝本次发送,避免高频自动回复形成突发流量。 - QQBot 外发统一走发送排队:默认全局间隔 `2500ms`、同会话间隔 `8000ms`、排队抖动 `0-800ms`,超过 `QQBOT_SEND_MAX_QUEUE_WAIT_MS` 时拒绝本次发送,避免高频自动回复形成突发流量。
- QQBot 在线命令和自动回复规则都有运行时保底冷却:默认命令 `5000ms`、规则 `30000ms`;即使数据库里旧数据冷却值更低,也按保底值判定,降低频繁触发风控的概率。 - QQBot 在线命令和自动回复规则都有运行时保底冷却:默认命令 `5000ms`、规则 `30000ms`;即使数据库里旧数据冷却值更低,也按保底值判定,降低频繁触发风控的概率。
- QQBot 复读机默认阈值为 4同一会话默认 10 分钟只复读一次,默认只复读 120 字以内普通文本,避免群聊重复内容导致机器人过于频繁地模拟真人发言。 - QQBot 复读机默认阈值为 4同一会话默认 10 分钟只复读一次,默认只复读 120 字以内普通文本,避免群聊重复内容导致机器人过于频繁地模拟真人发言。
@ -168,7 +168,7 @@ API 暴露 `GET /health/runtime` 作为本地 smoke、Jenkins/K8s 和 ktWorkflow
- NapCat 托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像;生产切换镜像前先 pin 明确版本或 digest 并单账号观察。 - 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 账号新增/编辑支持可选 QQ 登录密码Admin 只提交 RSA-OAEP 加密后的 `encryptedLoginPassword`,后端解密后必须用显式配置的 `QQBOT_ACCOUNT_SECRET_KEY`(或非默认 `ADMIN_TOKEN_SECRET`)二次加密保存到 `qqbot_account.napcat_login_password_secret`;空值、`change-me` 和历史公开默认值会被拒绝;列表和详情不回显密码,日志会脱敏密码字段。
- 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 容器为已知 `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:*` MACDocker 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 设备身份按账号持久化到 `napcat_device_identity`:同一账号重建容器会复用数据目录、`pc-<8hex>` hostname、machine-id 和 `02:42:*` MAC新增账号首次扫码会先用预留容器 id 创建临时设备身份并应用到第一次 Docker run扫码成功后归属到真实账号并同步 runtime/protocol profileDocker 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 新设备验证走同一 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` 关闭)定时巡检在线账号,使掉线/被踢无需管理员打开列表页即可及时发现;检测到离线后只写入离线原因并复用 `super` 站内信告警,恢复登录必须由管理员在 Admin 手动触发「更新登录」。 - 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 当前源码根目录是 `src/modules/qqbot/plugins/bangdream/src`;按第三期插件结构放置真实职责代码:业务在 `domain/*`,编排在 `application`,操作在 `operations`,外部 API 在 `infrastructure/integration`,缓存/静态修正在 `infrastructure/storage`,字典和静态配置在 `config`,视觉渲染公共件在 `theme`;不要恢复旧 `tsugu` 层级、旧大桶目录、纯 re-export 转接文件或空 `.gitkeep` 目录壳。

View File

@ -337,6 +337,7 @@ export type QqbotLoginScanSession = {
newDeviceQrcode?: string; newDeviceQrcode?: string;
newDeviceStatus?: QqbotLoginNewDeviceStatus; newDeviceStatus?: QqbotLoginNewDeviceStatus;
passwordMd5?: string; passwordMd5?: string;
preparingContainer?: boolean;
preparingRelogin?: boolean; preparingRelogin?: boolean;
qrcode?: string; qrcode?: string;
runtimeRebuildCount?: number; runtimeRebuildCount?: number;

View File

@ -123,11 +123,29 @@ export class QqbotNapcatLoginService {
} }
/** /**
* Create * Starts a create-login session without waiting for remote Docker startup.
* @returns Pending scan session snapshot; the container startup and QR fetch continue in the background.
*/ */
async startCreate() { async startCreate() {
const container = await this.containerService.prepareCreateContainer(); await this.cleanupSessions();
return this.startScan({ mode: 'create' }, container); const container = await this.containerService.reserveCreateContainer();
const session = this.createSession({
container,
mode: 'create',
preparingContainer: true,
status: 'pending',
});
session.lastRestartedAt = Date.now();
session.errorMessage = 'NapCat 正在创建登录容器,请稍后';
this.persistLoginSession(session);
this.publishScanResultEvent(
session,
'container-starting',
'processing',
session.errorMessage,
);
void this.prepareCreateContainerQrcode(session, container);
return this.toResult(session);
} }
/** /**
@ -229,6 +247,18 @@ export class QqbotNapcatLoginService {
session.errorMessage || 'NapCat 正在尝试快速登录,请稍后', session.errorMessage || 'NapCat 正在尝试快速登录,请稍后',
); );
} }
if (session.preparingContainer) {
if (Date.now() > session.expiresAt) {
return this.expireSession(session);
}
if (this.recoverStaleCreateContainerPreparation(session)) {
return this.toResult(session);
}
return this.keepSessionPending(
session,
session.errorMessage || 'NapCat 正在创建登录容器,请稍后',
);
}
const container = await this.getSessionContainer(session); const container = await this.getSessionContainer(session);
let loginStatus: NapcatLoginStatus; let loginStatus: NapcatLoginStatus;
@ -299,6 +329,15 @@ export class QqbotNapcatLoginService {
if (Date.now() > session.expiresAt) { if (Date.now() > session.expiresAt) {
return this.expireSession(session); return this.expireSession(session);
} }
if (session.preparingContainer) {
if (this.recoverStaleCreateContainerPreparation(session)) {
return this.toResult(session);
}
return this.keepSessionPending(
session,
session.errorMessage || 'NapCat 正在创建登录容器,请稍后',
);
}
const container = await this.getSessionContainer(session); const container = await this.getSessionContainer(session);
if (session.newDeviceStatus && session.newDeviceStatus !== 'verified') { if (session.newDeviceStatus && session.newDeviceStatus !== 'verified') {
@ -555,7 +594,7 @@ export class QqbotNapcatLoginService {
options.loginPassword, options.loginPassword,
options.hasExistingPrimaryBinding, options.hasExistingPrimaryBinding,
); );
void reloginTask; void reloginTask.catch(() => undefined);
return this.toResult(session); return this.toResult(session);
} }
@ -658,6 +697,98 @@ export class QqbotNapcatLoginService {
} }
} }
/**
* Starts a reserved create-login container and publishes QR progress back to the existing session.
* @param session - Pending create-login session returned to Admin before the remote Docker operation starts.
* @param container - Reserved container runtime whose provisional device identity must be used for the first Docker run.
*/
private async prepareCreateContainerQrcode(
session: QqbotLoginScanSession,
container: QqbotNapcatRuntime,
) {
try {
await this.containerService.startCreateContainer(container);
if (!this.loginSessionStore.getCached(session.id)) {
await this.cleanupRuntimeContainer(container, {
includeDeletedCreateContainer: true,
});
return;
}
session.preparingContainer = false;
session.errorMessage = undefined;
this.publishScanResultEvent(
session,
'container-ready',
'processing',
'NapCat 登录容器已启动',
);
await this.prepareCreateQrcodeAfterContainerReady(session, container);
} catch (err) {
session.preparingContainer = false;
const cleanupError = await this.cleanupRuntimeContainer(container, {
includeDeletedCreateContainer: true,
});
const message = this.toolsService.getErrorMessage(err);
await this.failSession(
session,
cleanupError
? `${message};清理未绑定容器失败:${cleanupError}`
: message,
);
}
}
/**
* Reads login state from a running create-login container and updates the original session with a QR or success result.
* @param session - Pending create-login session whose id is already known by Admin and SSE listeners.
* @param container - Running container that should now answer NapCat WebUI login endpoints.
*/
private async prepareCreateQrcodeAfterContainerReady(
session: QqbotLoginScanSession,
container: QqbotNapcatRuntime,
) {
const loginStatus = await this.getLoginStatus(container, true);
if (loginStatus.isOffline) {
await this.restartNapcatForLogin(container, { waitForReady: false });
session.lastRestartedAt = Date.now();
session.errorMessage =
loginStatus.loginError || 'NapCat 账号已离线,已重新生成二维码';
this.publishScanResultEvent(
session,
'container-restarted',
'processing',
session.errorMessage,
);
return;
}
if (loginStatus.isLogin) {
await this.completeLogin(session, container);
return;
}
session.qrcode = await this.refreshOrGetQrcode(container, true, {
fallbackStatus: loginStatus,
requireFresh: this.toolsService.isNapcatExpiredQrcodeStatus(loginStatus),
staleQrcode: loginStatus.qrcodeurl,
});
session.errorMessage = undefined;
this.persistLoginSession(session);
this.publishScanResultEvent(
session,
'qrcode-ready',
'success',
'登录二维码已生成',
);
this.publishScanResultEvent(
session,
'waiting-scan',
'processing',
'等待扫码确认',
);
}
/** /**
* NapCat * NapCat
* @param session - session 使 `expectedSelfId``accountId``containerId``captchaUrl` * @param session - session 使 `expectedSelfId``accountId``containerId``captchaUrl`
@ -691,12 +822,17 @@ export class QqbotNapcatLoginService {
name: this.toolsService.pickNapcatNickname(loginInfo), name: this.toolsService.pickNapcatNickname(loginInfo),
selfId, selfId,
}); });
await this.containerService.bindAccount(accountId, session.containerId); await this.containerService.bindAccount(
accountId,
session.containerId,
selfId,
);
session.accountId = accountId; session.accountId = accountId;
session.captchaUrl = undefined; session.captchaUrl = undefined;
session.status = 'success'; session.status = 'success';
session.errorMessage = undefined; session.errorMessage = undefined;
session.passwordMd5 = undefined; session.passwordMd5 = undefined;
session.preparingContainer = false;
session.preparingRelogin = false; session.preparingRelogin = false;
this.persistLoginSession(session); this.persistLoginSession(session);
const result = { const result = {
@ -723,6 +859,7 @@ export class QqbotNapcatLoginService {
container: QqbotNapcatRuntime; container: QqbotNapcatRuntime;
expectedSelfId?: string; expectedSelfId?: string;
mode: QqbotLoginScanMode; mode: QqbotLoginScanMode;
preparingContainer?: boolean;
preparingRelogin?: boolean; preparingRelogin?: boolean;
qrcode?: string; qrcode?: string;
runtimeRebuildCount?: number; runtimeRebuildCount?: number;
@ -739,6 +876,7 @@ export class QqbotNapcatLoginService {
expiresAt: now + this.getSessionTtlMs(), expiresAt: now + this.getSessionTtlMs(),
id: randomUUID(), id: randomUUID(),
mode: input.mode, mode: input.mode,
preparingContainer: input.preparingContainer,
preparingRelogin: input.preparingRelogin, preparingRelogin: input.preparingRelogin,
qrcode: input.qrcode, qrcode: input.qrcode,
runtimeRebuildCount: runtimeRebuildCount:
@ -1280,6 +1418,7 @@ export class QqbotNapcatLoginService {
if (message.includes('快速')) return 'quick-login-start'; if (message.includes('快速')) return 'quick-login-start';
return 'relogin-preparing'; return 'relogin-preparing';
} }
if (session.preparingContainer) return 'container-starting';
if (session.passwordMd5) return 'password-login'; if (session.passwordMd5) return 'password-login';
return 'scan-status'; return 'scan-status';
} }
@ -1299,6 +1438,7 @@ export class QqbotNapcatLoginService {
if (session.newDeviceStatus) return '新设备二维码待扫码'; if (session.newDeviceStatus) return '新设备二维码待扫码';
if (session.captchaUrl) return '密码登录需要完成 QQ 安全验证'; if (session.captchaUrl) return '密码登录需要完成 QQ 安全验证';
if (session.qrcode) return '登录二维码已生成'; if (session.qrcode) return '登录二维码已生成';
if (session.preparingContainer) return 'NapCat 正在创建登录容器,请稍后';
return '登录处理中'; return '登录处理中';
} }
@ -1319,6 +1459,38 @@ export class QqbotNapcatLoginService {
return true; return true;
} }
/**
* Restarts a lost create-login background task after a persisted preparing session becomes stale.
* @param session - Pending create-login session restored from persistence or left behind by a lost async task.
* @returns True when a recovery task was launched and the session snapshot was updated.
*/
private recoverStaleCreateContainerPreparation(
session: QqbotLoginScanSession,
) {
if (!this.isStaleCreateContainerPreparation(session)) return false;
session.lastRestartedAt = Date.now();
session.errorMessage = 'NapCat 创建任务已恢复,继续创建登录容器';
this.publishScanResultEvent(
session,
'container-start-recovered',
'processing',
session.errorMessage,
);
void this.resumeCreateContainerPreparation(session).catch(() => undefined);
return true;
}
/**
* Reattaches a stale create-login session to its reserved container and continues the QR preparation flow.
* @param session - Pending create-login session whose original in-memory background promise may have been lost.
*/
private async resumeCreateContainerPreparation(
session: QqbotLoginScanSession,
) {
const container = await this.getSessionContainer(session);
await this.prepareCreateContainerQrcode(session, container);
}
/** /**
* NapCat * NapCat
* @param session - session 使 `preparingRelogin``lastRestartedAt` * @param session - session 使 `preparingRelogin``lastRestartedAt`
@ -1330,6 +1502,18 @@ export class QqbotNapcatLoginService {
); );
} }
/**
* Checks whether create-login container preparation is old enough to be recovered by a new background task.
* @param session - Create-login session; `lastRestartedAt` marks the last background task launch and `createdAt` is the fallback seed.
* @returns True when the session is still pending but the previous create task should be considered lost.
*/
private isStaleCreateContainerPreparation(session: QqbotLoginScanSession) {
if (!session.preparingContainer) return false;
const startedAt = session.lastRestartedAt || session.createdAt;
if (!startedAt) return false;
return Date.now() - startedAt > this.getCreateContainerPreparationStaleMs();
}
/** /**
* NapCat * NapCat
*/ */
@ -1342,6 +1526,20 @@ export class QqbotNapcatLoginService {
); );
} }
/**
* Reads the stale window for create-login container startup recovery.
* @returns Milliseconds to wait before assuming the original Docker-start background task was lost.
*/
private getCreateContainerPreparationStaleMs() {
return this.getPositiveConfigNumber(
'QQBOT_NAPCAT_CREATE_PREPARING_STALE_MS',
Math.max(
this.getSessionTtlMs(),
this.getTimeout() * 3 + this.getLoginPollIntervalMs() * 2,
),
);
}
/** /**
* NapCat * NapCat
* @param sessionId - NapCat IDNapCat * @param sessionId - NapCat IDNapCat
@ -1461,6 +1659,7 @@ export class QqbotNapcatLoginService {
session.captchaUrl = undefined; session.captchaUrl = undefined;
session.errorMessage = errorMessage; session.errorMessage = errorMessage;
session.passwordMd5 = undefined; session.passwordMd5 = undefined;
session.preparingContainer = false;
session.preparingRelogin = false; session.preparingRelogin = false;
this.persistLoginSession(session); this.persistLoginSession(session);
this.publishScanEvent(session, { this.publishScanEvent(session, {
@ -1607,12 +1806,17 @@ export class QqbotNapcatLoginService {
* @param session - session 使 `containerId``containerName``webuiPort``errorMessage` * @param session - session 使 `containerId``containerName``webuiPort``errorMessage`
*/ */
private async cleanupSessionContainer(session: QqbotLoginScanSession) { private async cleanupSessionContainer(session: QqbotLoginScanSession) {
const cleanupError = await this.cleanupRuntimeContainer({ const cleanupError = await this.cleanupRuntimeContainer(
baseUrl: '', {
id: session.containerId, baseUrl: '',
name: session.containerName || '', id: session.containerId,
webuiPort: session.webuiPort, name: session.containerName || '',
}); webuiPort: session.webuiPort,
},
{
includeDeletedCreateContainer: session.mode === 'create',
},
);
if (cleanupError) { if (cleanupError) {
session.errorMessage = session.errorMessage session.errorMessage = session.errorMessage
? `${session.errorMessage};清理未绑定容器失败:${cleanupError}` ? `${session.errorMessage};清理未绑定容器失败:${cleanupError}`
@ -1659,9 +1863,17 @@ export class QqbotNapcatLoginService {
/** /**
* NapCat * NapCat
* @param container - container 使 `id` * @param container - container 使 `id`
* @param options - Cleanup switches; create-login cleanup may revisit already-deleted provisional rows after async Docker races.
*/ */
private async cleanupRuntimeContainer(container: QqbotNapcatRuntime) { private async cleanupRuntimeContainer(
container: QqbotNapcatRuntime,
options: { includeDeletedCreateContainer?: boolean } = {},
) {
try { try {
if (options.includeDeletedCreateContainer) {
await this.containerService.removeUnboundCreateContainer(container.id);
return null;
}
await this.containerService.removeUnboundContainer(container.id); await this.containerService.removeUnboundContainer(container.id);
return null; return null;
} catch (err) { } catch (err) {
@ -2202,10 +2414,7 @@ export class QqbotNapcatLoginService {
} }
return true; return true;
} }
this.publishPasswordLoginFallback( this.publishPasswordLoginFallback(session, errorMessage);
session,
errorMessage,
);
return false; return false;
} }
@ -2318,10 +2527,7 @@ export class QqbotNapcatLoginService {
this.keepPasswordCaptchaPending(session, proofWaterUrl); this.keepPasswordCaptchaPending(session, proofWaterUrl);
return true; return true;
} }
const captchaUrl = await this.waitForPasswordCaptchaUrl( const captchaUrl = await this.waitForPasswordCaptchaUrl(container, sinceMs);
container,
sinceMs,
);
if (captchaUrl) { if (captchaUrl) {
this.keepPasswordCaptchaPending(session, captchaUrl); this.keepPasswordCaptchaPending(session, captchaUrl);
return true; return true;

View File

@ -31,6 +31,13 @@ type RecordPlannedProfilesInput = {
runtimeProfile: NapcatRuntimeProfileSnapshot; runtimeProfile: NapcatRuntimeProfileSnapshot;
}; };
type AdoptPlannedProfilesInput = {
containerId?: string;
deviceIdentityId?: string;
fromAccountId?: string;
toAccountId: string;
};
@Injectable() @Injectable()
export class NapcatRuntimeProfileService { export class NapcatRuntimeProfileService {
/** /**
@ -170,6 +177,57 @@ export class NapcatRuntimeProfileService {
} }
} }
/**
* Moves planned create-login runtime evidence from a provisional account seed to the scanned account.
* @param input - Target account id plus the reserved container/provisional account id used before QQ self id was known.
*/
async adoptPlannedProfiles(input: AdoptPlannedProfilesInput) {
const toAccountId = `${input.toAccountId || ''}`.trim();
const fromAccountId =
`${input.fromAccountId || input.containerId || ''}`.trim();
const containerId = `${input.containerId || ''}`.trim();
if (!toAccountId || !fromAccountId) return;
if (this.runtimeProfileRepository) {
await this.runtimeProfileRepository.update(
this.buildProfileAdoptionWhere(fromAccountId, containerId),
{
accountId: toAccountId,
containerId: containerId || null,
deviceIdentityId: input.deviceIdentityId || null,
},
);
}
if (this.protocolProfileRepository) {
await this.protocolProfileRepository.update(
this.buildProfileAdoptionWhere(fromAccountId, containerId),
{
accountId: toAccountId,
containerId: containerId || null,
},
);
}
}
/**
* Builds the narrow update condition for provisional profile adoption.
* @param fromAccountId - Temporary account id used during first Docker startup, usually the reserved container id.
* @param containerId - Reserved container id; included when present so unrelated historical rows are untouched.
* @returns TypeORM partial where object used by both runtime and protocol profile repositories.
*/
private buildProfileAdoptionWhere(
fromAccountId: string,
containerId: string,
) {
return containerId
? {
accountId: fromAccountId,
containerId,
}
: { accountId: fromAccountId };
}
/** /**
* Reads a trimmed string config value for profile generation. * Reads a trimmed string config value for profile generation.
* @param key - Environment key that controls NapCat runtime profile generation. * @param key - Environment key that controls NapCat runtime profile generation.

View File

@ -6,7 +6,7 @@ import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { throwVbenError, ToolsService } from '@/common'; import { ensureSnowflakeId, throwVbenError, ToolsService } from '@/common';
import { NapcatConfigWriterService } from '../../../application/runtime/napcat-config-writer.service'; import { NapcatConfigWriterService } from '../../../application/runtime/napcat-config-writer.service';
import { NapcatRuntimeProfileService } from '../../../application/runtime/napcat-runtime-profile.service'; import { NapcatRuntimeProfileService } from '../../../application/runtime/napcat-runtime-profile.service';
import type { NapcatConfigFile } from '../../../domain/runtime/napcat-profile.types'; import type { NapcatConfigFile } from '../../../domain/runtime/napcat-profile.types';
@ -45,6 +45,10 @@ type NapcatLoginEnvUpdateResult = {
ok: boolean; ok: boolean;
}; };
type CreateManagedContainerOptions = {
startRemote?: boolean;
};
@Injectable() @Injectable()
export class QqbotNapcatContainerService { export class QqbotNapcatContainerService {
private readonly configWriterService: NapcatConfigWriterService; private readonly configWriterService: NapcatConfigWriterService;
@ -86,7 +90,87 @@ export class QqbotNapcatContainerService {
return this.getLegacyRuntime(); return this.getLegacyRuntime();
} }
return this.createManagedContainer(); const runtime = await this.reserveCreateContainer();
await this.startCreateContainer(runtime);
return runtime;
}
/**
* Reserves a create-login container row and provisional device identity without waiting for NAS Docker startup.
* @returns Pending NapCat runtime metadata that Admin can use immediately for SSE/status tracking.
*/
async reserveCreateContainer() {
if (!this.isManagedMode()) {
return this.getLegacyRuntime();
}
return this.createManagedContainer(undefined, undefined, undefined, {
startRemote: false,
});
}
/**
* Starts the remote Docker container previously reserved for create-login.
* @param runtime - Reserved runtime row returned by `reserveCreateContainer`.
* @returns True after the remote container has been started and persistence marked running.
*/
async startCreateContainer(runtime: QqbotNapcatRuntime) {
if (this.getManagedMode() !== 'ssh' || !runtime.id) return true;
const container = await this.findContainerWithToken(runtime.id);
if (
!container ||
!container.name ||
!container.webuiPort ||
!container.webuiToken
) {
throwVbenError('NapCat 创建容器不存在或缺少 WebUI 配置');
}
if (container.status === 'running') return true;
try {
const deviceIdentity =
await this.resolveCreateContainerDeviceIdentity(container);
await this.createRemoteDockerContainer({
account: undefined,
accountId:
deviceIdentity?.accountId || container.accountId || container.id,
containerId: container.id,
dataDir:
deviceIdentity?.dataDir ||
container.dataDir ||
`${this.getRootDir()}/${container.name}`,
deviceIdentity,
image: container.image,
name: container.name,
port: container.webuiPort,
reverseWsUrl: container.reverseWsUrl || this.buildReverseWsUrl(),
token: container.webuiToken,
});
await this.containerRepository.update(
{ id: container.id },
{
dataDir:
deviceIdentity?.dataDir ||
container.dataDir ||
`${this.getRootDir()}/${container.name}`,
lastError: null,
lastStartedAt: new Date(),
status: 'running',
},
);
return true;
} catch (err) {
const message = this.toolsService.getErrorMessage(err);
await this.containerRepository.update(
{ id: container.id },
{
lastError: this.toolsService.toColumnText(message, 500),
status: 'error',
},
);
throw err;
}
} }
/** /**
@ -286,21 +370,49 @@ export class QqbotNapcatContainerService {
return toNapcatDockerDeviceOptions(identity); return toNapcatDockerDeviceOptions(identity);
} }
/**
* Resolves the provisional identity used by first-time QR login before the QQ self id exists.
* @param container - Reserved container row; its bigint id is the temporary account-level seed.
* @returns Docker device options that must be applied to the first remote `docker run`.
*/
private async resolveCreateContainerDeviceIdentity(
container: NapcatContainer,
): Promise<NapcatDockerDeviceOptions | undefined> {
if (!this.deviceIdentityService || !container.id) return undefined;
const accountId =
this.toolsService.toTrimmedString(container.accountId) || container.id;
const identity = await this.deviceIdentityService.resolveForAccount({
accountId,
containerId: container.id,
});
return toNapcatDockerDeviceOptions(identity);
}
/** /**
* Resolves the device identity id that belongs to an account/container binding. * Resolves the device identity id that belongs to an account/container binding.
* @param accountId - Internal QQBot account id used to select the persistent device identity. * @param accountId - Internal QQBot account id used to select the persistent device identity.
* @param containerId - Managed NapCat container id that should be stored on the identity row. * @param containerId - Managed NapCat container id that should be stored on the identity row.
* @param selfId - QQ self id observed from NapCat after scanning; stored as adoption evidence only.
* @returns Device identity id when the identity service is available. * @returns Device identity id when the identity service is available.
*/ */
private async resolveBindingDeviceIdentityId( private async resolveBindingDeviceIdentityId(
accountId: string, accountId: string,
containerId: string, containerId: string,
selfId?: string,
) { ) {
if (!this.deviceIdentityService) return undefined; if (!this.deviceIdentityService) return undefined;
const identity = await this.deviceIdentityService.resolveForAccount({ const identity =
accountId, typeof this.deviceIdentityService.adoptContainerIdentity === 'function'
containerId, ? await this.deviceIdentityService.adoptContainerIdentity({
}); accountId,
containerId,
selfId,
})
: await this.deviceIdentityService.resolveForAccount({
accountId,
containerId,
selfId,
});
return identity.id; return identity.id;
} }
@ -397,8 +509,9 @@ docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$NAME"
* NapCat * NapCat
* @param accountId - ID * @param accountId - ID
* @param containerId - NapCat IDNapCat * @param containerId - NapCat IDNapCat
* @param selfId - QQ
*/ */
async bindAccount(accountId: string, containerId?: string) { async bindAccount(accountId: string, containerId?: string, selfId?: string) {
if (!containerId) return; if (!containerId) return;
await this.bindingRepository.update( await this.bindingRepository.update(
@ -408,7 +521,14 @@ docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$NAME"
const deviceIdentityId = await this.resolveBindingDeviceIdentityId( const deviceIdentityId = await this.resolveBindingDeviceIdentityId(
accountId, accountId,
containerId, containerId,
selfId,
); );
await this.runtimeProfileService.adoptPlannedProfiles({
containerId,
deviceIdentityId,
fromAccountId: containerId,
toAccountId: accountId,
});
await this.containerRepository.update( await this.containerRepository.update(
{ id: containerId, isDeleted: false }, { id: containerId, isDeleted: false },
{ accountId }, { accountId },
@ -506,9 +626,55 @@ docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$NAME"
}); });
if (bindingCount > 0) return false; if (bindingCount > 0) return false;
const container = await this.containerRepository.findOne({
where: {
id: containerId,
isDeleted: false,
},
});
if (container?.accountId) return false;
return this.removeContainer(containerId); return this.removeContainer(containerId);
} }
/**
* Removes a create-login container even if its row was already marked deleted during an async startup race.
* @param containerId - Reserved create-login container id whose row must have no account owner and no live binding.
* @returns True when a matching create-login container row was cleaned up.
*/
async removeUnboundCreateContainer(containerId?: string) {
if (!containerId) return false;
const bindingCount = await this.bindingRepository.count({
where: {
containerId,
isDeleted: false,
},
});
if (bindingCount > 0) return false;
const container = await this.containerRepository.findOne({
where: {
id: containerId,
},
});
if (!container || container.accountId) return false;
if (this.getManagedMode() === 'ssh') {
await this.removeRemoteDockerContainer(container);
}
await this.containerRepository.update(
{ id: container.id },
{
isDeleted: true,
lastError: null,
status: 'stopped',
},
);
return true;
}
/** /**
* NapCat * NapCat
* @param runtime - runtime 使 `id``name` * @param runtime - runtime 使 `id``name`
@ -1062,6 +1228,7 @@ docker logs --since "$SINCE" --tail 300 "$NAME" 2>&1 || true
selfId?: string, selfId?: string,
loginPassword?: string, loginPassword?: string,
accountId?: string, accountId?: string,
options: CreateManagedContainerOptions = {},
) { ) {
const mode = this.getManagedMode(); const mode = this.getManagedMode();
if (mode !== 'ssh') { if (mode !== 'ssh') {
@ -1073,75 +1240,83 @@ docker logs --since "$SINCE" --tail 300 "$NAME" 2>&1 || true
throwVbenError('NapCat 镜像未配置,请先设置 QQBOT_NAPCAT_IMAGE'); throwVbenError('NapCat 镜像未配置,请先设置 QQBOT_NAPCAT_IMAGE');
} }
const port = await this.allocatePort(); const port = await this.allocatePort();
const name = this.buildContainerName(selfId);
const token = randomBytes(24).toString('hex'); const token = randomBytes(24).toString('hex');
const container = this.containerRepository.create({
baseUrl: this.buildBaseUrl(port),
accountId: accountId || null,
dataDir: '',
image,
isDeleted: false,
lastError: null,
name: '',
remark: '',
reverseWsUrl: this.buildReverseWsUrl(),
status: 'creating',
webuiPort: port,
webuiToken: token,
});
ensureSnowflakeId(container);
const identityAccountId = accountId || container.id;
const name = this.buildContainerName(selfId || identityAccountId);
let dataDir = `${this.getRootDir()}/${name}`; let dataDir = `${this.getRootDir()}/${name}`;
let deviceIdentity: NapcatDockerDeviceOptions | undefined; let deviceIdentity: NapcatDockerDeviceOptions | undefined;
if (accountId && this.deviceIdentityService) { if (identityAccountId && this.deviceIdentityService) {
const identity = await this.deviceIdentityService.resolveForAccount({ const identity = await this.deviceIdentityService.resolveForAccount({
accountId, accountId: identityAccountId,
containerId: container.id,
selfId, selfId,
}); });
dataDir = identity.dataDir || dataDir; dataDir = identity.dataDir || dataDir;
deviceIdentity = toNapcatDockerDeviceOptions(identity); deviceIdentity = toNapcatDockerDeviceOptions(identity);
} }
const baseUrl = this.buildBaseUrl(port); const baseUrl = container.baseUrl;
const reverseWsUrl = this.buildReverseWsUrl(); const reverseWsUrl = container.reverseWsUrl;
Object.assign(container, {
dataDir,
name,
});
const container = await this.containerRepository.save( const savedContainer = await this.containerRepository.save(container);
this.containerRepository.create({
baseUrl,
accountId: accountId || null,
dataDir,
image,
isDeleted: false,
lastError: null,
name,
remark: '',
reverseWsUrl,
status: 'creating',
webuiPort: port,
webuiToken: token,
}),
);
try { try {
await this.createRemoteDockerContainer({ if (options.startRemote !== false) {
account: selfId, await this.createRemoteDockerContainer({
accountId, account: selfId,
containerId: container.id, accountId: identityAccountId,
dataDir, containerId: savedContainer.id,
deviceIdentity, dataDir,
image, deviceIdentity,
loginPassword, image,
name, loginPassword,
port, name,
reverseWsUrl, port,
token, reverseWsUrl,
}); token,
await this.containerRepository.update( });
{ id: container.id }, await this.containerRepository.update(
{ { id: savedContainer.id },
lastError: null, {
lastStartedAt: new Date(), lastError: null,
status: 'running', lastStartedAt: new Date(),
}, status: 'running',
); },
);
}
if (accountId && this.deviceIdentityService) { if (accountId && this.deviceIdentityService) {
const identity = await this.deviceIdentityService.resolveForAccount({ const identity = await this.deviceIdentityService.resolveForAccount({
accountId, accountId,
containerId: container.id, containerId: savedContainer.id,
selfId, selfId,
}); });
await this.bindingRepository.update( await this.bindingRepository.update(
{ accountId, containerId: container.id, isDeleted: false }, { accountId, containerId: savedContainer.id, isDeleted: false },
{ deviceIdentityId: identity.id }, { deviceIdentityId: identity.id },
); );
} }
return { return {
baseUrl, baseUrl,
dataDir, dataDir,
id: container.id, id: savedContainer.id,
name, name,
webuiPort: port, webuiPort: port,
webuiToken: token, webuiToken: token,
@ -1149,7 +1324,7 @@ docker logs --since "$SINCE" --tail 300 "$NAME" 2>&1 || true
} catch (err) { } catch (err) {
const message = this.toolsService.getErrorMessage(err); const message = this.toolsService.getErrorMessage(err);
await this.containerRepository.update( await this.containerRepository.update(
{ id: container.id }, { id: savedContainer.id },
{ {
lastError: this.toolsService.toColumnText(message, 500), lastError: this.toolsService.toColumnText(message, 500),
status: 'error', status: 'error',

View File

@ -15,6 +15,12 @@ type ResolveNapcatDeviceIdentityInput = {
selfId?: string; selfId?: string;
}; };
type AdoptNapcatDeviceIdentityInput = {
accountId: string;
containerId: string;
selfId?: string;
};
@Injectable() @Injectable()
export class NapcatDeviceIdentityService { export class NapcatDeviceIdentityService {
/** /**
@ -76,6 +82,130 @@ export class NapcatDeviceIdentityService {
return this.identityRepository.save(identity); return this.identityRepository.save(identity);
} }
/**
* Reassigns a create-login provisional identity to the scanned account without changing visible device values.
* @param input - Final account id and container id; container id selects the provisional identity created before QQ self id was known.
* @returns Existing provisional identity after adoption, or a newly resolved account identity when no provisional row exists.
*/
async adoptContainerIdentity(input: AdoptNapcatDeviceIdentityInput) {
const accountId = `${input.accountId}`.trim();
const containerId = `${input.containerId}`.trim();
const provisionalIdentity = await this.identityRepository.findOne({
where: { containerId },
});
if (!provisionalIdentity) {
return this.resolveForAccount({
accountId,
containerId,
selfId: input.selfId,
});
}
if (provisionalIdentity.accountId === accountId) {
return provisionalIdentity;
}
const targetIdentity = await this.identityRepository.findOne({
where: { accountId },
});
if (targetIdentity && targetIdentity.id !== provisionalIdentity.id) {
return this.mergeProvisionalIdentityIntoTarget({
accountId,
containerId,
provisionalIdentity,
selfId: input.selfId,
targetIdentity,
});
}
const lastLoginEvidence = this.buildAdoptionEvidence({
existingEvidence: provisionalIdentity.lastLoginEvidence,
fromAccountId: provisionalIdentity.accountId,
selfId: input.selfId,
toAccountId: accountId,
});
await this.identityRepository.update(
{ id: provisionalIdentity.id },
{
accountId,
containerId,
lastLoginEvidence,
},
);
Object.assign(provisionalIdentity, {
accountId,
containerId,
lastLoginEvidence,
});
return provisionalIdentity;
}
/**
* Copies first-run device values into an existing account identity and removes the temporary row.
* @param input - Existing target row plus the provisional row created before the scanned QQ account was known.
* @returns Target account identity after it has adopted the running container's visible device values.
*/
private async mergeProvisionalIdentityIntoTarget(input: {
accountId: string;
containerId: string;
provisionalIdentity: NapcatDeviceIdentity;
selfId?: string;
targetIdentity: NapcatDeviceIdentity;
}) {
const lastLoginEvidence = this.buildAdoptionEvidence({
existingEvidence: input.targetIdentity.lastLoginEvidence,
fromAccountId: input.provisionalIdentity.accountId,
replacedIdentityId: input.provisionalIdentity.id,
selfId: input.selfId,
toAccountId: input.accountId,
});
const nextIdentity = {
accountId: input.accountId,
containerId: input.containerId,
dataDir: input.provisionalIdentity.dataDir,
hostname: input.provisionalIdentity.hostname,
hostnameStrategy: input.provisionalIdentity.hostnameStrategy,
lastLoginEvidence,
machineIdPath: input.provisionalIdentity.machineIdPath,
macAddress: input.provisionalIdentity.macAddress,
macStrategy: input.provisionalIdentity.macStrategy,
verificationStatus: input.provisionalIdentity.verificationStatus,
};
await this.identityRepository.update(
{ id: input.targetIdentity.id },
nextIdentity,
);
await this.identityRepository.delete({ id: input.provisionalIdentity.id });
Object.assign(input.targetIdentity, nextIdentity);
return input.targetIdentity;
}
/**
* Builds evidence for the one-time provisional identity adoption after a create-login scan succeeds.
* @param input - Source/target ids and optional QQ self id used to make later audits explain the ownership change.
* @returns JSON-safe evidence merged into the device identity row before returning it to the container binding.
*/
private buildAdoptionEvidence(input: {
existingEvidence: null | Record<string, unknown>;
fromAccountId: string;
replacedIdentityId?: string;
selfId?: string;
toAccountId: string;
}) {
return {
...(input.existingEvidence || {}),
adoption: {
fromAccountId: input.fromAccountId,
replacedIdentityId: input.replacedIdentityId || null,
selfId: input.selfId || null,
strategy: 'create-login-provisional-identity-adoption-v1',
toAccountId: input.toAccountId,
},
};
}
/** /**
* Builds the stable container directory name used for data-dir ownership. * Builds the stable container directory name used for data-dir ownership.
* @param seed - QQ self id or account id used in container path compatibility, not in the public hostname. * @param seed - QQ self id or account id used in container path compatibility, not in the public hostname.
@ -110,7 +240,9 @@ export class NapcatDeviceIdentityService {
containerName: string, containerName: string,
) { ) {
const hash = createHash('sha256') const hash = createHash('sha256')
.update(`${accountId}:${containerName}:${QQNT_DOCKER_BRIDGE_MAC_STRATEGY}`) .update(
`${accountId}:${containerName}:${QQNT_DOCKER_BRIDGE_MAC_STRATEGY}`,
)
.digest('hex'); .digest('hex');
const suffix = [ const suffix = [
hash.slice(0, 2), hash.slice(0, 2),
@ -145,9 +277,7 @@ export class NapcatDeviceIdentityService {
identity.hostnameStrategy !== QQNT_VISIBLE_HOSTNAME_STRATEGY || identity.hostnameStrategy !== QQNT_VISIBLE_HOSTNAME_STRATEGY ||
identity.macStrategy !== QQNT_DOCKER_BRIDGE_MAC_STRATEGY || identity.macStrategy !== QQNT_DOCKER_BRIDGE_MAC_STRATEGY ||
!/^pc-[a-f0-9]{8}$/.test(identity.hostname || '') || !/^pc-[a-f0-9]{8}$/.test(identity.hostname || '') ||
!/^02:42:([0-9a-f]{2}:){3}[0-9a-f]{2}$/i.test( !/^02:42:([0-9a-f]{2}:){3}[0-9a-f]{2}$/i.test(identity.macAddress || '');
identity.macAddress || '',
);
if (!needsMigration) { if (!needsMigration) {
return; return;

View File

@ -44,8 +44,31 @@ const createIdentityRepository = () => {
create: jest.fn((input: Partial<NapcatDeviceIdentity>) => ({ create: jest.fn((input: Partial<NapcatDeviceIdentity>) => ({
...input, ...input,
})), })),
findOne: jest.fn(async ({ where }: { where: { accountId: string } }) => { findOne: jest.fn(
return identities.get(where.accountId) || null; async ({
where,
}: {
where: { accountId?: string; containerId?: string };
}) => {
if (where.accountId) return identities.get(where.accountId) || null;
if (where.containerId) {
return (
[...identities.values()].find(
(identity) => identity.containerId === where.containerId,
) || null
);
}
return null;
},
),
delete: jest.fn(async ({ id }: { id: string }) => {
for (const [accountId, identity] of identities.entries()) {
if (identity.id === id) {
identities.delete(accountId);
return { affected: 1 };
}
}
return { affected: 0 };
}), }),
save: jest.fn(async (identity: NapcatDeviceIdentity) => { save: jest.fn(async (identity: NapcatDeviceIdentity) => {
identities.set(identity.accountId, identity); identities.set(identity.accountId, identity);
@ -215,8 +238,7 @@ describe('NapCat device identity persistence', () => {
repository.seedIdentity({ repository.seedIdentity({
accountId: 'account-10001', accountId: 'account-10001',
containerId: 'container-first', containerId: 'container-first',
dataDir: dataDir: '/vol1/docker/kt-qqbot/napcat-instances/kt-qqbot-napcat-10001',
'/vol1/docker/kt-qqbot/napcat-instances/kt-qqbot-napcat-10001',
hostname: 'kt-qqbot-napcat-10001', hostname: 'kt-qqbot-napcat-10001',
id: 'identity-1', id: 'identity-1',
lastLoginEvidence: null, lastLoginEvidence: null,
@ -411,7 +433,219 @@ describe('NapCat device identity persistence', () => {
expect(createScript).toContain('--hostname "$NAPCAT_HOSTNAME"'); expect(createScript).toContain('--hostname "$NAPCAT_HOSTNAME"');
expect(createScript).toContain('--mac-address "$NAPCAT_MAC_ADDRESS"'); expect(createScript).toContain('--mac-address "$NAPCAT_MAC_ADDRESS"');
expect(createScript).toContain('-v "$MACHINE_ID_PATH:/etc/machine-id:ro"'); expect(createScript).toContain('-v "$MACHINE_ID_PATH:/etc/machine-id:ro"');
expect(createScript).toContain('-v "$DATA_DIR/runtime:/tmp/runtime-napcat"'); expect(createScript).toContain(
'-v "$DATA_DIR/runtime:/tmp/runtime-napcat"',
);
});
it('reserves create-login containers with a provisional device identity before remote startup', async () => {
const identityRepository = createIdentityRepository();
const identityService = new NapcatDeviceIdentityService(
identityRepository as any,
createIdentityConfig(),
);
let savedContainer: any;
const queryBuilder = {
addSelect: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getOne: jest.fn(async () => savedContainer),
where: jest.fn().mockReturnThis(),
};
const containerRepository = {
create: jest.fn((input) => ({ ...input })),
createQueryBuilder: jest.fn(() => queryBuilder),
find: jest.fn().mockResolvedValue([]),
save: jest.fn(async (input) => {
savedContainer = { ...input };
return savedContainer;
}),
update: jest.fn(async ({ id }: { id: string }, input) => {
if (savedContainer?.id === id) Object.assign(savedContainer, input);
return { affected: savedContainer?.id === id ? 1 : 0 };
}),
};
const bindingRepository = {
update: jest.fn(),
};
const containerService = new QqbotNapcatContainerService(
{
get: jest.fn((key: string, defaultValue?: string) => {
const values: Record<string, string> = {
QQBOT_NAPCAT_CONTAINER_MODE: 'ssh',
QQBOT_NAPCAT_CONTAINER_PREFIX: 'kt-qqbot-napcat',
QQBOT_NAPCAT_IMAGE: 'kt-napcat-desktop-cn@sha256:profiledigest',
QQBOT_NAPCAT_PORT_START: '6100',
QQBOT_NAPCAT_ROOT: '/vol1/docker/kt-qqbot/napcat-instances',
QQBOT_NAPCAT_SSH_TARGET: 'nas',
};
return values[key] || defaultValue || '';
}),
} as any,
containerRepository as any,
bindingRepository as any,
new ToolsService(),
identityService,
) as any;
containerService.runProcess = jest.fn().mockResolvedValue({
stderr: '',
stdout: '',
});
const runtime = await containerService.reserveCreateContainer();
expect(containerService.runProcess).not.toHaveBeenCalled();
expect(runtime).toEqual(
expect.objectContaining({
dataDir: expect.stringContaining(`kt-qqbot-napcat-${runtime.id}`),
id: expect.any(String),
name: expect.stringContaining(`kt-qqbot-napcat-${runtime.id}`),
}),
);
expect(identityRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
accountId: runtime.id,
containerId: runtime.id,
dataDir: runtime.dataDir,
}),
);
await containerService.startCreateContainer(runtime);
const createScript = containerService.runProcess.mock.calls[0][2];
expect(createScript).toContain('--hostname "$NAPCAT_HOSTNAME"');
expect(createScript).toContain('--mac-address "$NAPCAT_MAC_ADDRESS"');
expect(createScript).toContain('-v "$MACHINE_ID_PATH:/etc/machine-id:ro"');
expect(createScript).toContain('-e LANG=zh_CN.UTF-8');
expect(createScript).toContain('-e LC_ALL=zh_CN.UTF-8');
expect(createScript).toContain('-e TZ=Asia/Shanghai');
expect(createScript).toContain('-e XDG_RUNTIME_DIR=/tmp/runtime-napcat');
});
it('adopts the provisional create-login identity when binding the scanned account', async () => {
const identityRepository = createIdentityRepository();
const identityService = new NapcatDeviceIdentityService(
identityRepository as any,
createIdentityConfig(),
);
const provisionalIdentity = {
accountId: 'container-created',
containerId: 'container-created',
dataDir:
'/vol1/docker/kt-qqbot/napcat-instances/kt-qqbot-napcat-container-created',
hostname: 'pc-a1b2c3d4',
hostnameStrategy: 'qqnt-visible-hostname-v1',
id: 'identity-created',
lastLoginEvidence: null,
macAddress: '02:42:aa:bb:cc:dd',
macStrategy: 'docker-bridge-mac-v1',
machineIdPath:
'/vol1/docker/kt-qqbot/napcat-instances/kt-qqbot-napcat-container-created/machine-id',
verificationStatus: 'pending',
} as NapcatDeviceIdentity;
identityRepository.seedIdentity(provisionalIdentity);
const bindingRepository = {
create: jest.fn((input) => ({ ...input })),
find: jest.fn().mockResolvedValue([]),
findOne: jest.fn().mockResolvedValue(null),
save: jest.fn(async (input) => input),
update: jest.fn(),
};
const containerRepository = {
update: jest.fn(),
};
const containerService = new QqbotNapcatContainerService(
{
get: jest.fn((key: string, defaultValue?: string) => {
const values: Record<string, string> = {
QQBOT_NAPCAT_CONTAINER_MODE: 'ssh',
QQBOT_NAPCAT_CONTAINER_PREFIX: 'kt-qqbot-napcat',
QQBOT_NAPCAT_ROOT: '/vol1/docker/kt-qqbot/napcat-instances',
};
return values[key] || defaultValue || '';
}),
} as any,
containerRepository as any,
bindingRepository as any,
new ToolsService(),
identityService,
);
await (containerService as any).bindAccount(
'account-final',
'container-created',
'10001',
);
expect(identityRepository.update).toHaveBeenCalledWith(
{ id: 'identity-created' },
expect.objectContaining({
accountId: 'account-final',
containerId: 'container-created',
}),
);
expect(bindingRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
accountId: 'account-final',
containerId: 'container-created',
deviceIdentityId: 'identity-created',
}),
);
expect(identityRepository.save).toHaveBeenCalledTimes(0);
});
it('adopts provisional runtime profiles when binding a create-login container', async () => {
const identityRepository = createIdentityRepository();
const identityService = new NapcatDeviceIdentityService(
identityRepository as any,
createIdentityConfig(),
);
identityRepository.seedIdentity({
accountId: 'container-created',
containerId: 'container-created',
dataDir:
'/vol1/docker/kt-qqbot/napcat-instances/kt-qqbot-napcat-container-created',
hostname: 'pc-a1b2c3d4',
hostnameStrategy: 'qqnt-visible-hostname-v1',
id: 'identity-created',
lastLoginEvidence: null,
macAddress: '02:42:aa:bb:cc:dd',
macStrategy: 'docker-bridge-mac-v1',
machineIdPath:
'/vol1/docker/kt-qqbot/napcat-instances/kt-qqbot-napcat-container-created/machine-id',
verificationStatus: 'pending',
} as NapcatDeviceIdentity);
const runtimeProfileService = {
adoptPlannedProfiles: jest.fn(),
};
const containerService = new QqbotNapcatContainerService(
createIdentityConfig(),
{
update: jest.fn(),
} as any,
{
create: jest.fn((input) => ({ ...input })),
find: jest.fn().mockResolvedValue([]),
findOne: jest.fn().mockResolvedValue(null),
save: jest.fn(async (input) => input),
update: jest.fn(),
} as any,
new ToolsService(),
identityService,
runtimeProfileService as any,
);
await containerService.bindAccount(
'account-final',
'container-created',
'10001',
);
expect(runtimeProfileService.adoptPlannedProfiles).toHaveBeenCalledWith({
containerId: 'container-created',
deviceIdentityId: 'identity-created',
fromAccountId: 'container-created',
toAccountId: 'account-final',
});
}); });
it('reuses persisted device identity when rebuilding an existing account container login env', async () => { it('reuses persisted device identity when rebuilding an existing account container login env', async () => {

View File

@ -37,11 +37,9 @@ describe('QqbotNapcatLoginService', () => {
quickResult?: null | Record<string, unknown>; quickResult?: null | Record<string, unknown>;
} = {}, } = {},
) => ) =>
jest.spyOn(target as any, 'postNapcat').mockImplementation( jest
async ( .spyOn(target as any, 'postNapcat')
_container: unknown, .mockImplementation(async (_container: unknown, path: string) => {
path: string,
) => {
if (path === '/api/QQLogin/SetQuickLogin') { if (path === '/api/QQLogin/SetQuickLogin') {
if (options.quickError) throw new Error(options.quickError); if (options.quickError) throw new Error(options.quickError);
return options.quickResult ?? null; return options.quickResult ?? null;
@ -51,8 +49,7 @@ describe('QqbotNapcatLoginService', () => {
return options.passwordResult ?? null; return options.passwordResult ?? null;
} }
return null; return null;
}, });
);
const toolsService = new ToolsService(); const toolsService = new ToolsService();
const service = new QqbotNapcatLoginService( const service = new QqbotNapcatLoginService(
@ -621,6 +618,7 @@ describe('QqbotNapcatLoginService', () => {
expect(containerService.bindAccount).toHaveBeenCalledWith( expect(containerService.bindAccount).toHaveBeenCalledWith(
'account-1', 'account-1',
'container-quick', 'container-quick',
'10001',
); );
expect(session.status).toBe('success'); expect(session.status).toBe('success');
const events = (refreshService as any).sessionEventLogs.get(session.id); const events = (refreshService as any).sessionEventLogs.get(session.id);
@ -693,6 +691,7 @@ describe('QqbotNapcatLoginService', () => {
expect(containerService.bindAccount).toHaveBeenCalledWith( expect(containerService.bindAccount).toHaveBeenCalledWith(
'account-1', 'account-1',
'container-quick-cleanup-failed', 'container-quick-cleanup-failed',
'10001',
); );
expect(session.status).toBe('success'); expect(session.status).toBe('success');
const steps = ( const steps = (
@ -760,6 +759,141 @@ describe('QqbotNapcatLoginService', () => {
); );
}); });
it('returns create login scan before remote container startup finishes', async () => {
const container = {
baseUrl: 'http://127.0.0.1:6104/',
id: 'container-create',
name: 'napcat-create',
webuiPort: 6104,
};
const never = new Promise<never>(() => undefined);
const containerService = {
prepareCreateContainer: jest.fn().mockReturnValue(never),
reserveCreateContainer: jest.fn().mockResolvedValue(container),
startCreateContainer: jest.fn().mockReturnValue(never),
};
const createService = new QqbotNapcatLoginService(
{ get: jest.fn() } as unknown as ConfigService,
{} as QqbotAccountService,
containerService as unknown as QqbotNapcatContainerService,
new ToolsService(),
);
const result = await Promise.race([
createService.startCreate(),
new Promise((resolve) => {
setTimeout(() => resolve({ timedOut: true }), 20);
}),
]);
expect(result).toEqual(
expect.objectContaining({
containerId: 'container-create',
errorMessage: 'NapCat 正在创建登录容器,请稍后',
mode: 'create',
qrcode: undefined,
status: 'pending',
}),
);
expect(containerService.prepareCreateContainer).not.toHaveBeenCalled();
expect(containerService.reserveCreateContainer).toHaveBeenCalledTimes(1);
expect(containerService.startCreateContainer).toHaveBeenCalledWith(
container,
);
});
it('keeps create login pending while the remote container is still starting', async () => {
const container = {
baseUrl: 'http://127.0.0.1:6104/',
id: 'container-create',
name: 'napcat-create',
webuiPort: 6104,
};
const never = new Promise<never>(() => undefined);
const containerService = {
reserveCreateContainer: jest.fn().mockResolvedValue(container),
startCreateContainer: jest.fn().mockReturnValue(never),
};
const createService = new QqbotNapcatLoginService(
{ get: jest.fn() } as unknown as ConfigService,
{} as QqbotAccountService,
containerService as unknown as QqbotNapcatContainerService,
new ToolsService(),
);
const getSessionContainer = jest.spyOn(
createService as any,
'getSessionContainer',
);
const result = await createService.startCreate();
const status = await createService.status(result.sessionId || '');
expect(status).toEqual(
expect.objectContaining({
errorMessage: 'NapCat 正在创建登录容器,请稍后',
mode: 'create',
qrcode: undefined,
status: 'pending',
}),
);
expect(getSessionContainer).not.toHaveBeenCalled();
});
it('recovers stale create login preparation after the background task was lost', async () => {
const container = {
baseUrl: 'http://127.0.0.1:6105/',
id: 'container-create-stale',
name: 'napcat-create-stale',
webuiPort: 6105,
};
const never = new Promise<never>(() => undefined);
const containerService = {
findRuntimeById: jest.fn().mockResolvedValue(container),
startCreateContainer: jest.fn().mockReturnValue(never),
};
const createService = new QqbotNapcatLoginService(
{
get: jest.fn((key: string) => {
if (key === 'QQBOT_NAPCAT_CREATE_PREPARING_STALE_MS') return '1';
return undefined;
}),
} as unknown as ConfigService,
{} as QqbotAccountService,
containerService as unknown as QqbotNapcatContainerService,
new ToolsService(),
);
const session = (createService as any).createSession({
container,
mode: 'create',
preparingContainer: true,
status: 'pending',
});
const old = Date.now() - 10_000;
session.createdAt = old;
session.errorMessage = 'NapCat 正在创建登录容器,请稍后';
session.expiresAt = Date.now() + 60_000;
session.lastRestartedAt = old;
(createService as any).sessions.set(session.id, session);
const status = await createService.status(session.id);
await Promise.resolve();
await Promise.resolve();
expect(status).toEqual(
expect.objectContaining({
errorMessage: 'NapCat 创建任务已恢复,继续创建登录容器',
mode: 'create',
status: 'pending',
}),
);
expect(containerService.findRuntimeById).toHaveBeenCalledWith(
'container-create-stale',
);
expect(containerService.startCreateContainer).toHaveBeenCalledWith(
container,
);
});
it('does not rebuild even when the refresh session already has a rebuild count', async () => { it('does not rebuild even when the refresh session already has a rebuild count', async () => {
const container = { const container = {
baseUrl: 'http://127.0.0.1:6103/', baseUrl: 'http://127.0.0.1:6103/',
@ -1022,23 +1156,16 @@ describe('QqbotNapcatLoginService', () => {
.mockResolvedValue(undefined); .mockResolvedValue(undefined);
const postNapcat = jest const postNapcat = jest
.spyOn(refreshService as any, 'postNapcat') .spyOn(refreshService as any, 'postNapcat')
.mockImplementation( .mockImplementation(async (_container: unknown, path: string) => {
async ( if (path === '/api/QQLogin/SetQuickLogin') {
_container: unknown, throw new Error('快速登录未找到历史会话');
path: string, }
) => { if (path === '/api/QQLogin/PasswordLogin') return null;
if (path === '/api/QQLogin/SetQuickLogin') { return null;
throw new Error('快速登录未找到历史会话');
}
if (path === '/api/QQLogin/PasswordLogin') return null;
return null;
},
);
jest
.spyOn(refreshService as any, 'getLoginStatus')
.mockResolvedValueOnce({
isLogin: true,
}); });
jest.spyOn(refreshService as any, 'getLoginStatus').mockResolvedValueOnce({
isLogin: true,
});
jest.spyOn(refreshService as any, 'getLoginInfo').mockResolvedValue({ jest.spyOn(refreshService as any, 'getLoginInfo').mockResolvedValue({
nickname: 'Kwi', nickname: 'Kwi',
online: true, online: true,
@ -2706,6 +2833,7 @@ describe('QqbotNapcatLoginService', () => {
expect(containerService.bindAccount).toHaveBeenCalledWith( expect(containerService.bindAccount).toHaveBeenCalledWith(
'account-1', 'account-1',
'container-password-cleanup-failed', 'container-password-cleanup-failed',
'10001',
); );
expect(session.status).toBe('success'); expect(session.status).toBe('success');
const steps = ( const steps = (

View File

@ -128,6 +128,78 @@ describe('QqbotNapcatContainerService', () => {
); );
}); });
it('removes an unbound create-login container even after the row was marked deleted', async () => {
const container = {
accountId: null,
dataDir: '/vol1/docker/kt-qqbot/napcat-instances/kt-qqbot-napcat-create',
id: 'container-create',
isDeleted: true,
name: 'kt-qqbot-napcat-create',
};
const bindingRepository = {
count: jest.fn().mockResolvedValue(0),
};
const containerRepository = {
findOne: jest.fn().mockResolvedValue(container),
update: jest.fn(),
};
const service = new QqbotNapcatContainerService(
{ get: jest.fn().mockReturnValue('') } as unknown as ConfigService,
containerRepository as any,
bindingRepository as any,
new ToolsService(),
) as any;
jest.spyOn(service, 'getManagedMode').mockReturnValue('ssh');
service.runProcess = jest.fn().mockResolvedValue({
stderr: '',
stdout: '',
});
const removed =
await service.removeUnboundCreateContainer('container-create');
expect(removed).toBe(true);
expect(service.runProcess.mock.calls[0][2]).toContain(
'docker rm -f "$NAME"',
);
expect(containerRepository.update).toHaveBeenCalledWith(
{ id: 'container-create' },
expect.objectContaining({
isDeleted: true,
status: 'stopped',
}),
);
});
it('does not remove a container that already has an account owner during binding', async () => {
const bindingRepository = {
count: jest.fn().mockResolvedValue(0),
};
const containerRepository = {
findOne: jest.fn().mockResolvedValue({
accountId: 'account-1',
dataDir: '/vol1/docker/kt-qqbot/napcat-instances/kt-qqbot-napcat-bound',
id: 'container-binding',
isDeleted: false,
name: 'kt-qqbot-napcat-bound',
}),
update: jest.fn(),
};
const service = new QqbotNapcatContainerService(
{ get: jest.fn().mockReturnValue('') } as unknown as ConfigService,
containerRepository as any,
bindingRepository as any,
new ToolsService(),
) as any;
service.runProcess = jest.fn();
const removed = await service.removeUnboundContainer('container-binding');
expect(removed).toBe(false);
expect(service.runProcess).not.toHaveBeenCalled();
expect(containerRepository.update).not.toHaveBeenCalled();
});
it('uses the latest NapCat account status line as runtime offline truth', () => { it('uses the latest NapCat account status line as runtime offline truth', () => {
const service = new QqbotNapcatContainerService( const service = new QqbotNapcatContainerService(
{ get: jest.fn() } as any, { get: jest.fn() } as any,