diff --git a/API.md b/API.md index ad05541..89ea4f4 100644 --- a/API.md +++ b/API.md @@ -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`。 -扫码链路返回 `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 进度文案包含快速登录、密码登录、验证码、新设备二维码、已扫码、确认中、二维码兜底、登录成功/失败。 同一 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 diff --git a/README.md b/README.md index de5fa59..0739a96 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,7 @@ API 暴露 `GET /health/runtime` 作为本地 smoke、Jenkins/K8s 和 ktWorkflow - WordPress 自动登录失败不会阻断 Admin 主登录,会通过菜单和权限码过滤不可用的 Blog 管理入口。 - 系统日志由 pino 输出,Loki 查询统一通过后端 `/system/logs/*` 代理,前端不直连 Loki。 - 日志级站内信只承接运行期事件:接口 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 在线命令和自动回复规则都有运行时保底冷却:默认命令 `5000ms`、规则 `30000ms`;即使数据库里旧数据冷却值更低,也按保底值判定,降低频繁触发风控的概率。 - QQBot 复读机默认阈值为 4,同一会话默认 10 分钟只复读一次,默认只复读 120 字以内普通文本,避免群聊重复内容导致机器人过于频繁地模拟真人发言。 @@ -168,7 +168,7 @@ API 暴露 `GET /health/runtime` 作为本地 smoke、Jenkins/K8s 和 ktWorkflow - 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` 等必要 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 设备身份按账号持久化到 `napcat_device_identity`:同一账号重建容器会复用数据目录、`pc-<8hex>` hostname、machine-id 和 `02:42:*` MAC;新增账号首次扫码会先用预留容器 id 创建临时设备身份并应用到第一次 Docker run,扫码成功后归属到真实账号并同步 runtime/protocol profile;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` 关闭)定时巡检在线账号,使掉线/被踢无需管理员打开列表页即可及时发现;检测到离线后只写入离线原因并复用 `super` 站内信告警,恢复登录必须由管理员在 Admin 手动触发「更新登录」。 - BangDream 当前源码根目录是 `src/modules/qqbot/plugins/bangdream/src`;按第三期插件结构放置真实职责代码:业务在 `domain/*`,编排在 `application`,操作在 `operations`,外部 API 在 `infrastructure/integration`,缓存/静态修正在 `infrastructure/storage`,字典和静态配置在 `config`,视觉渲染公共件在 `theme`;不要恢复旧 `tsugu` 层级、旧大桶目录、纯 re-export 转接文件或空 `.gitkeep` 目录壳。 diff --git a/src/modules/qqbot/core/contract/qqbot.types.ts b/src/modules/qqbot/core/contract/qqbot.types.ts index 4c70a1e..e11a989 100644 --- a/src/modules/qqbot/core/contract/qqbot.types.ts +++ b/src/modules/qqbot/core/contract/qqbot.types.ts @@ -337,6 +337,7 @@ export type QqbotLoginScanSession = { newDeviceQrcode?: string; newDeviceStatus?: QqbotLoginNewDeviceStatus; passwordMd5?: string; + preparingContainer?: boolean; preparingRelogin?: boolean; qrcode?: string; runtimeRebuildCount?: number; 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 e09f493..d8cdb62 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 @@ -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() { - const container = await this.containerService.prepareCreateContainer(); - return this.startScan({ mode: 'create' }, container); + await this.cleanupSessions(); + 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 正在尝试快速登录,请稍后', ); } + 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); let loginStatus: NapcatLoginStatus; @@ -299,6 +329,15 @@ export class QqbotNapcatLoginService { if (Date.now() > session.expiresAt) { 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); if (session.newDeviceStatus && session.newDeviceStatus !== 'verified') { @@ -555,7 +594,7 @@ export class QqbotNapcatLoginService { options.loginPassword, options.hasExistingPrimaryBinding, ); - void reloginTask; + void reloginTask.catch(() => undefined); 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 登录运行态流程。 * @param session - session 输入;使用 `expectedSelfId`、`accountId`、`containerId`、`captchaUrl` 字段生成结果。 @@ -691,12 +822,17 @@ export class QqbotNapcatLoginService { name: this.toolsService.pickNapcatNickname(loginInfo), selfId, }); - await this.containerService.bindAccount(accountId, session.containerId); + await this.containerService.bindAccount( + accountId, + session.containerId, + selfId, + ); session.accountId = accountId; session.captchaUrl = undefined; session.status = 'success'; session.errorMessage = undefined; session.passwordMd5 = undefined; + session.preparingContainer = false; session.preparingRelogin = false; this.persistLoginSession(session); const result = { @@ -723,6 +859,7 @@ export class QqbotNapcatLoginService { container: QqbotNapcatRuntime; expectedSelfId?: string; mode: QqbotLoginScanMode; + preparingContainer?: boolean; preparingRelogin?: boolean; qrcode?: string; runtimeRebuildCount?: number; @@ -739,6 +876,7 @@ export class QqbotNapcatLoginService { expiresAt: now + this.getSessionTtlMs(), id: randomUUID(), mode: input.mode, + preparingContainer: input.preparingContainer, preparingRelogin: input.preparingRelogin, qrcode: input.qrcode, runtimeRebuildCount: @@ -1280,6 +1418,7 @@ export class QqbotNapcatLoginService { if (message.includes('快速')) return 'quick-login-start'; return 'relogin-preparing'; } + if (session.preparingContainer) return 'container-starting'; if (session.passwordMd5) return 'password-login'; return 'scan-status'; } @@ -1299,6 +1438,7 @@ export class QqbotNapcatLoginService { if (session.newDeviceStatus) return '新设备二维码待扫码'; if (session.captchaUrl) return '密码登录需要完成 QQ 安全验证'; if (session.qrcode) return '登录二维码已生成'; + if (session.preparingContainer) return 'NapCat 正在创建登录容器,请稍后'; return '登录处理中'; } @@ -1319,6 +1459,38 @@ export class QqbotNapcatLoginService { 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 登录运行态条件。 * @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 登录运行态数据。 */ @@ -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 登录运行态状态。 * @param sessionId - NapCat ID;定位本次读取、更新、删除或关联的NapCat。 @@ -1461,6 +1659,7 @@ export class QqbotNapcatLoginService { session.captchaUrl = undefined; session.errorMessage = errorMessage; session.passwordMd5 = undefined; + session.preparingContainer = false; session.preparingRelogin = false; this.persistLoginSession(session); this.publishScanEvent(session, { @@ -1607,12 +1806,17 @@ export class QqbotNapcatLoginService { * @param session - session 输入;使用 `containerId`、`containerName`、`webuiPort`、`errorMessage` 字段生成结果。 */ private async cleanupSessionContainer(session: QqbotLoginScanSession) { - const cleanupError = await this.cleanupRuntimeContainer({ - baseUrl: '', - id: session.containerId, - name: session.containerName || '', - webuiPort: session.webuiPort, - }); + const cleanupError = await this.cleanupRuntimeContainer( + { + baseUrl: '', + id: session.containerId, + name: session.containerName || '', + webuiPort: session.webuiPort, + }, + { + includeDeletedCreateContainer: session.mode === 'create', + }, + ); if (cleanupError) { session.errorMessage = session.errorMessage ? `${session.errorMessage};清理未绑定容器失败:${cleanupError}` @@ -1659,9 +1863,17 @@ export class QqbotNapcatLoginService { /** * 清理 NapCat 登录运行态状态。 * @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 { + if (options.includeDeletedCreateContainer) { + await this.containerService.removeUnboundCreateContainer(container.id); + return null; + } await this.containerService.removeUnboundContainer(container.id); return null; } catch (err) { @@ -2202,10 +2414,7 @@ export class QqbotNapcatLoginService { } return true; } - this.publishPasswordLoginFallback( - session, - errorMessage, - ); + this.publishPasswordLoginFallback(session, errorMessage); return false; } @@ -2318,10 +2527,7 @@ export class QqbotNapcatLoginService { this.keepPasswordCaptchaPending(session, proofWaterUrl); return true; } - const captchaUrl = await this.waitForPasswordCaptchaUrl( - container, - sinceMs, - ); + const captchaUrl = await this.waitForPasswordCaptchaUrl(container, sinceMs); if (captchaUrl) { this.keepPasswordCaptchaPending(session, captchaUrl); return true; diff --git a/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts b/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts index ca91e73..4e919e7 100644 --- a/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts +++ b/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts @@ -31,6 +31,13 @@ type RecordPlannedProfilesInput = { runtimeProfile: NapcatRuntimeProfileSnapshot; }; +type AdoptPlannedProfilesInput = { + containerId?: string; + deviceIdentityId?: string; + fromAccountId?: string; + toAccountId: string; +}; + @Injectable() 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. * @param key - Environment key that controls NapCat runtime profile generation. 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 9acb1f0..3ed2be0 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 @@ -6,7 +6,7 @@ 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 { ensureSnowflakeId, throwVbenError, ToolsService } from '@/common'; import { NapcatConfigWriterService } from '../../../application/runtime/napcat-config-writer.service'; import { NapcatRuntimeProfileService } from '../../../application/runtime/napcat-runtime-profile.service'; import type { NapcatConfigFile } from '../../../domain/runtime/napcat-profile.types'; @@ -45,6 +45,10 @@ type NapcatLoginEnvUpdateResult = { ok: boolean; }; +type CreateManagedContainerOptions = { + startRemote?: boolean; +}; + @Injectable() export class QqbotNapcatContainerService { private readonly configWriterService: NapcatConfigWriterService; @@ -86,7 +90,87 @@ export class QqbotNapcatContainerService { 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); } + /** + * 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 { + 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. * @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 selfId - QQ self id observed from NapCat after scanning; stored as adoption evidence only. * @returns Device identity id when the identity service is available. */ private async resolveBindingDeviceIdentityId( accountId: string, containerId: string, + selfId?: string, ) { if (!this.deviceIdentityService) return undefined; - const identity = await this.deviceIdentityService.resolveForAccount({ - accountId, - containerId, - }); + const identity = + typeof this.deviceIdentityService.adoptContainerIdentity === 'function' + ? await this.deviceIdentityService.adoptContainerIdentity({ + accountId, + containerId, + selfId, + }) + : await this.deviceIdentityService.resolveForAccount({ + accountId, + containerId, + selfId, + }); return identity.id; } @@ -397,8 +509,9 @@ docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$NAME" * 执行 NapCat 登录运行态流程。 * @param accountId - 账号 ID;定位本次读取、更新、删除或关联的账号。 * @param containerId - NapCat ID;定位本次读取、更新、删除或关联的NapCat。 + * @param selfId - QQ 号;用于把创建期临时设备身份归属到真实账号时留下登录证据。 */ - async bindAccount(accountId: string, containerId?: string) { + async bindAccount(accountId: string, containerId?: string, selfId?: string) { if (!containerId) return; await this.bindingRepository.update( @@ -408,7 +521,14 @@ docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$NAME" const deviceIdentityId = await this.resolveBindingDeviceIdentityId( accountId, containerId, + selfId, ); + await this.runtimeProfileService.adoptPlannedProfiles({ + containerId, + deviceIdentityId, + fromAccountId: containerId, + toAccountId: accountId, + }); await this.containerRepository.update( { id: containerId, isDeleted: false }, { accountId }, @@ -506,9 +626,55 @@ docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$NAME" }); 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); } + /** + * 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 登录运行态流程。 * @param runtime - runtime 输入;使用 `id`、`name` 字段生成结果。 @@ -1062,6 +1228,7 @@ docker logs --since "$SINCE" --tail 300 "$NAME" 2>&1 || true selfId?: string, loginPassword?: string, accountId?: string, + options: CreateManagedContainerOptions = {}, ) { const mode = this.getManagedMode(); if (mode !== 'ssh') { @@ -1073,75 +1240,83 @@ docker logs --since "$SINCE" --tail 300 "$NAME" 2>&1 || true throwVbenError('NapCat 镜像未配置,请先设置 QQBOT_NAPCAT_IMAGE'); } const port = await this.allocatePort(); - const name = this.buildContainerName(selfId); 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 deviceIdentity: NapcatDockerDeviceOptions | undefined; - if (accountId && this.deviceIdentityService) { + if (identityAccountId && this.deviceIdentityService) { const identity = await this.deviceIdentityService.resolveForAccount({ - accountId, + accountId: identityAccountId, + containerId: container.id, selfId, }); dataDir = identity.dataDir || dataDir; deviceIdentity = toNapcatDockerDeviceOptions(identity); } - const baseUrl = this.buildBaseUrl(port); - const reverseWsUrl = this.buildReverseWsUrl(); + const baseUrl = container.baseUrl; + const reverseWsUrl = container.reverseWsUrl; + Object.assign(container, { + dataDir, + name, + }); - const container = await this.containerRepository.save( - this.containerRepository.create({ - baseUrl, - accountId: accountId || null, - dataDir, - image, - isDeleted: false, - lastError: null, - name, - remark: '', - reverseWsUrl, - status: 'creating', - webuiPort: port, - webuiToken: token, - }), - ); + const savedContainer = await this.containerRepository.save(container); try { - await this.createRemoteDockerContainer({ - account: selfId, - accountId, - containerId: container.id, - dataDir, - deviceIdentity, - image, - loginPassword, - name, - port, - reverseWsUrl, - token, - }); - await this.containerRepository.update( - { id: container.id }, - { - lastError: null, - lastStartedAt: new Date(), - status: 'running', - }, - ); + if (options.startRemote !== false) { + await this.createRemoteDockerContainer({ + account: selfId, + accountId: identityAccountId, + containerId: savedContainer.id, + dataDir, + deviceIdentity, + image, + loginPassword, + name, + port, + reverseWsUrl, + token, + }); + await this.containerRepository.update( + { id: savedContainer.id }, + { + lastError: null, + lastStartedAt: new Date(), + status: 'running', + }, + ); + } if (accountId && this.deviceIdentityService) { const identity = await this.deviceIdentityService.resolveForAccount({ accountId, - containerId: container.id, + containerId: savedContainer.id, selfId, }); await this.bindingRepository.update( - { accountId, containerId: container.id, isDeleted: false }, + { accountId, containerId: savedContainer.id, isDeleted: false }, { deviceIdentityId: identity.id }, ); } return { baseUrl, dataDir, - id: container.id, + id: savedContainer.id, name, webuiPort: port, webuiToken: token, @@ -1149,7 +1324,7 @@ docker logs --since "$SINCE" --tail 300 "$NAME" 2>&1 || true } catch (err) { const message = this.toolsService.getErrorMessage(err); await this.containerRepository.update( - { id: container.id }, + { id: savedContainer.id }, { lastError: this.toolsService.toColumnText(message, 500), status: 'error', diff --git a/src/modules/qqbot/napcat/infrastructure/integration/device/napcat-device-identity.service.ts b/src/modules/qqbot/napcat/infrastructure/integration/device/napcat-device-identity.service.ts index 9746778..0699cfd 100644 --- a/src/modules/qqbot/napcat/infrastructure/integration/device/napcat-device-identity.service.ts +++ b/src/modules/qqbot/napcat/infrastructure/integration/device/napcat-device-identity.service.ts @@ -15,6 +15,12 @@ type ResolveNapcatDeviceIdentityInput = { selfId?: string; }; +type AdoptNapcatDeviceIdentityInput = { + accountId: string; + containerId: string; + selfId?: string; +}; + @Injectable() export class NapcatDeviceIdentityService { /** @@ -76,6 +82,130 @@ export class NapcatDeviceIdentityService { 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; + 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. * @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, ) { const hash = createHash('sha256') - .update(`${accountId}:${containerName}:${QQNT_DOCKER_BRIDGE_MAC_STRATEGY}`) + .update( + `${accountId}:${containerName}:${QQNT_DOCKER_BRIDGE_MAC_STRATEGY}`, + ) .digest('hex'); const suffix = [ hash.slice(0, 2), @@ -145,9 +277,7 @@ export class NapcatDeviceIdentityService { identity.hostnameStrategy !== QQNT_VISIBLE_HOSTNAME_STRATEGY || identity.macStrategy !== QQNT_DOCKER_BRIDGE_MAC_STRATEGY || !/^pc-[a-f0-9]{8}$/.test(identity.hostname || '') || - !/^02:42:([0-9a-f]{2}:){3}[0-9a-f]{2}$/i.test( - identity.macAddress || '', - ); + !/^02:42:([0-9a-f]{2}:){3}[0-9a-f]{2}$/i.test(identity.macAddress || ''); if (!needsMigration) { return; diff --git a/test/modules/qqbot/napcat/device-identity.spec.ts b/test/modules/qqbot/napcat/device-identity.spec.ts index cc0b039..bd30c59 100644 --- a/test/modules/qqbot/napcat/device-identity.spec.ts +++ b/test/modules/qqbot/napcat/device-identity.spec.ts @@ -44,8 +44,31 @@ const createIdentityRepository = () => { create: jest.fn((input: Partial) => ({ ...input, })), - findOne: jest.fn(async ({ where }: { where: { accountId: string } }) => { - return identities.get(where.accountId) || null; + findOne: jest.fn( + 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) => { identities.set(identity.accountId, identity); @@ -215,8 +238,7 @@ describe('NapCat device identity persistence', () => { repository.seedIdentity({ accountId: 'account-10001', containerId: 'container-first', - dataDir: - '/vol1/docker/kt-qqbot/napcat-instances/kt-qqbot-napcat-10001', + dataDir: '/vol1/docker/kt-qqbot/napcat-instances/kt-qqbot-napcat-10001', hostname: 'kt-qqbot-napcat-10001', id: 'identity-1', lastLoginEvidence: null, @@ -411,7 +433,219 @@ describe('NapCat device identity persistence', () => { 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('-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 = { + 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 = { + 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 () => { diff --git a/test/qqbot/account/qqbot-napcat-login.service.spec.ts b/test/qqbot/account/qqbot-napcat-login.service.spec.ts index a99353b..6711e00 100644 --- a/test/qqbot/account/qqbot-napcat-login.service.spec.ts +++ b/test/qqbot/account/qqbot-napcat-login.service.spec.ts @@ -37,11 +37,9 @@ describe('QqbotNapcatLoginService', () => { quickResult?: null | Record; } = {}, ) => - jest.spyOn(target as any, 'postNapcat').mockImplementation( - async ( - _container: unknown, - path: string, - ) => { + 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; @@ -51,8 +49,7 @@ describe('QqbotNapcatLoginService', () => { return options.passwordResult ?? null; } return null; - }, - ); + }); const toolsService = new ToolsService(); const service = new QqbotNapcatLoginService( @@ -621,6 +618,7 @@ describe('QqbotNapcatLoginService', () => { expect(containerService.bindAccount).toHaveBeenCalledWith( 'account-1', 'container-quick', + '10001', ); expect(session.status).toBe('success'); const events = (refreshService as any).sessionEventLogs.get(session.id); @@ -693,6 +691,7 @@ describe('QqbotNapcatLoginService', () => { expect(containerService.bindAccount).toHaveBeenCalledWith( 'account-1', 'container-quick-cleanup-failed', + '10001', ); expect(session.status).toBe('success'); 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(() => 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(() => 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(() => 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 () => { const container = { baseUrl: 'http://127.0.0.1:6103/', @@ -1022,23 +1156,16 @@ describe('QqbotNapcatLoginService', () => { .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: true, + .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: true, + }); jest.spyOn(refreshService as any, 'getLoginInfo').mockResolvedValue({ nickname: 'Kwi', online: true, @@ -2706,6 +2833,7 @@ describe('QqbotNapcatLoginService', () => { expect(containerService.bindAccount).toHaveBeenCalledWith( 'account-1', 'container-password-cleanup-failed', + '10001', ); expect(session.status).toBe('success'); const steps = ( diff --git a/test/qqbot/napcat/qqbot-napcat-container.service.spec.ts b/test/qqbot/napcat/qqbot-napcat-container.service.spec.ts index cb02a19..82773b0 100644 --- a/test/qqbot/napcat/qqbot-napcat-container.service.spec.ts +++ b/test/qqbot/napcat/qqbot-napcat-container.service.spec.ts @@ -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', () => { const service = new QqbotNapcatContainerService( { get: jest.fn() } as any,