diff --git a/API.md b/API.md index dc993f0..d3dd9fc 100644 --- a/API.md +++ b/API.md @@ -149,6 +149,8 @@ QQBot 插件 worker 队列依赖 Redis。K8s 生产清单提供内部 Redis Serv | `PUT` | `/system/user/:id` | 更新用户 | | `DELETE` | `/system/user/:id` | 删除用户 | +系统菜单实体包含 `sort` 字段;菜单树输出按 `meta.order` 优先,其次按 `sort` 升序排列。Admin 菜单管理页面维护 `sort`,不要把普通菜单排序写进隐藏的 route meta。 + ### Dict | 方法 | 路径 | 说明 | @@ -361,13 +363,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 请求完成;新增账号扫码会先预留容器和临时设备身份后立即返回 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 登录或扫码登录。 +扫码链路返回 `sessionId`,前端应使用 SSE 查看步骤进度,而不是等待长 HTTP 请求完成;新增账号扫码会先预留容器和临时设备身份后立即返回 pending,会话后台再启动远端 Docker 和生成二维码。`CheckLoginStatus.isLogin=true` 只表示 NapCat 登录阳性,新增账号必须继续等 `GetQQLoginInfo` 返回 `uin/selfId` 后才允许创建和绑定真实 QQ 号;短暂缺号时 `/qqbot/account/scan/status` 保持同一会话 pending 并显示正在读取 QQ 号,等待 `NAPCAT_LOGIN_SELF_ID_WAIT_MS`,不得重建容器、补 env 或从容器元数据猜号。已有账号的更新登录不会通过 Docker 重建、重启或补 env 来刷新 QQ 登录态;如果目标容器仍在线,即使 QQ 账号已离线,也会保持同一容器并通过 NapCat WebUI 推进原有弹窗流程。若 WebUI 明确返回 QQ 离线,API 会先调用同容器 `/api/QQLogin/RestartNapCat` 重启 NapCat worker 以重建 QQCore login service,再继续 `SetQuickLogin`、`PasswordLogin`、`RefreshQRcode` / `GetQQLoginQrcode`;这不是 Docker 容器重建/重启,设备身份、env 和 dataDir 不变,同一个更新登录 session 只消费一次 worker restart 预算,后续轮询继续刷新二维码但不得反复重启 worker。只有 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/二维码准备;但当这条 pending 会话创建时账号还没有保存登录密码、且会话尚未进入密码验证码或新设备验证上下文,而账号后来通过编辑维护了登录密码时,API 必须退役旧无密码会话并新建 refresh session,重新读取最新密码后进入 `PasswordLogin`。取消扫码会话必须在接口返回前把持久化 `napcat_login_session` 落到非 pending 终态并写入完成时间,避免已取消的测试二维码从 DB 恢复成可轮询会话。若 API Pod 在准备阶段重启,持久化的 `preparingRelogin` 超过 `QQBOT_NAPCAT_RELOGIN_PREPARING_STALE_MS`(留空使用密码等待窗口加缓冲)后,`/qqbot/account/scan/status` 会自动恢复普通登录态检测,不再永久停留在“正在尝试密码登录”;`/qqbot/account/scan/events` 在进程内事件缓存丢失时会先推送当前会话快照。pending refresh 会话如果没有二维码、验证码或新设备挑战,`/qqbot/account/scan/status` 会按 `NAPCAT_LOGIN_QR_AUTO_REFRESH_COOLDOWN_MS` 冷却在同一容器自动重试 `RefreshQRcode/GetQQLoginQrcode`,避免 SSE 长时间卡在“二维码生成中”。密码登录触发 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、实体 OUI 风格 MAC 和 machine-id,并明确排除 Docker `02:42`、QEMU/KVM `52:54:00`、VMware、Hyper-V 等虚拟化前缀;新增账号创建期在真实 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` / `physical-oui-mac-v1`,绑定关系会回填 `napcat_account_binding.device_identity_id`。 +托管 NapCat 容器按账号持久化设备身份,`napcat_device_identity` 保存账号对应的数据目录、hostname、machine-id 路径、MAC 地址、验证状态和最近登录证据。重建同一账号容器时会复用 `pc-<8hex>` hostname、实体 OUI 风格 MAC 和 machine-id,并明确排除 Docker `02:42`、QEMU/KVM `52:54:00`、VMware、Hyper-V 等虚拟化前缀;新增账号创建期在真实 QQ selfId 未知时使用预留容器 id 创建临时设备身份,第一次 Docker run 就注入完整拟真参数,扫码成功后再把该身份和 runtime/protocol profile 归属到真实账号。Docker run 会注入 `--hostname`、`--mac-address`、只读 `/etc/machine-id` 挂载、`SYS_ADMIN`、`apparmor=unconfined`、`seccomp=unconfined` 和 `NAPCAT_REQUIRE_DEVICE_PROFILE=1`;后端还会同步写入 QQNT Linux `machine-info`,让 QQNT 计算 GUID 时使用的 MAC 与 Docker 网卡一致。派生镜像 entrypoint 会用同一设备 profile 覆盖 QQCore 实际打开的 DMI、boot_id、kernel release/version/proc version、CPU model、uptime、TTY active、mountinfo、`/etc/hosts` 和 `/proc/devices` 等探针;NapCat fork native login 和 core session config 的 `machineId` 与 `systemVersion` 也从该 profile 读取,避免 QQ native 入参和 Docker 可见探针不一致。当前策略名为 `qqnt-visible-hostname-v1` / `physical-oui-mac-v1`,绑定关系会回填 `napcat_account_binding.device_identity_id`。 ### NapCat Runtime Profile @@ -377,7 +379,7 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT 该接口只返回脱敏后的运行态证据,供 Admin 排查镜像、locale、shm、配置 hash、漂移状态、风险模式和 watchdog 巡检告警状态;不会返回 WebUI token、reverse WS token、QQ 登录密码、SSH 私钥或运行态密码环境。账号列表只挂载 `napcat.profileStatus`、`napcat.runtimeProfile` 等摘要字段,不触发登录、重建或修复动作。watchdog 不执行登录恢复:遇到 QQ 登录态离线只记录离线原因并通知 `super`,登录恢复统一由 Admin 手动「更新登录」触发;session behavior profile 只做冷启动、housekeeping、presence 和自动能力分阶段降载,不实现账号级每小时/每日累计发送预算。 -NapCat Chinese Desktop Runtime v8 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact,并在 QQ `KickedOffLine` 后重置 native login service 再请求二维码;同一次踢下线事件只消费一次 reset,且只有明确二维码过期或扫码确认窗口失效的 QR session failure 才自动换码,避免扫码确认期间被 `ErrType=1/ErrCode=1` 抢刷新打断。构建前必须运行 `scripts/napcat-desktop-cn-stage-build.mjs` 生成 Docker build context;生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:desktop-cn-v8` digest。生产 K8s manifest 保留 `kt-napcat-desktop-cn:desktop-cn-v8` / `desktop-cn-v8` 安全默认值;Jenkins `QQBOT_NAPCAT_IMAGE_OVERRIDE` 与 `QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE` 只有非空时才覆盖 API Deployment env,空值会保持 manifest/default env。运行时回滚应重新运行 Jenkins 并填入上一版镜像 digest/profile,或清空两个 override 后重新部署回 manifest 默认值。 +NapCat Chinese Desktop Runtime 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact,并在 QQ `KickedOffLine` 后重置 native login service 再请求二维码;同一次踢下线事件只消费一次 reset,明确二维码过期或扫码确认窗口失效的 QR session failure 会自动换码,其他非自动重试 QR failure 会标记下次 WebUI 登录动作先重置 native login service。v14 起运行时会为 QQ/NapCat/Xvfb 长期进程做 PID 级 `/proc//mountinfo` 遮蔽,避免 QQCore 通过 `/proc/self/mountinfo` 看到 `overlay`、`/vol1/docker`、`docker-init`、`/docker/containers`、`napcat-instances`、`btrfs`、`/dev/mapper/trim` 等宿主路径;v15 在扫码登录成功回调中先写入 `QQLoginInfo` 再写登录态,避免 API 读到 `isLogin=true` 但 QQ 号为空的短暂不一致;v16 在 native reset 缺少 `offline()` 时改用 `destroy()` 硬重置半登录服务,并让镜像 verify 等待 mountinfo guard 收敛;v17/v18 增加 WebUI 鉴权的 `/api/Debug/RuntimeViewProbe` 同进程诊断并修正 native maps 截断导致的 hook 证据假阴性;v19 保留 WebUI `RestartNapCat` 重启 worker 时的 `-q ` 快速登录参数,避免重启后退回无账号扫码;v20 保护 API 预写的 `/app/napcat/config`,避免上游首次解包 `NapCat.Shell/*` 覆盖 `bypass.*=true` 与 `o3HookMode=0`。构建前必须运行 `scripts/napcat-desktop-cn-stage-build.mjs` 生成 Docker build context;生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:*` digest。生产 K8s manifest 保留 `kt-napcat-desktop-cn:desktop-cn-v20` / `desktop-cn-v20` 稳定默认值;Jenkins `QQBOT_NAPCAT_IMAGE_OVERRIDE` 与 `QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE` 只有非空时才覆盖 API Deployment env,空值会保持 manifest/default env。运行时回滚应重新运行 Jenkins 并填入上一版镜像 digest/profile,或清空两个 override 后重新部署回 manifest 默认值。 API 仓库不提交 `NapCat.Shell.zip`;生产镜像必须从 staged context 构建,且 `fork-artifact.json` 必须包含完整 marker metadata:upstream release tag/commit、fork commit、base image digest、Jenkins URL 和 artifact hashes。NapCat base image 在 release evidence 中必须 pin 到 digest。API Jenkins 只做显式参数推广,不负责自动合并上游、自动构建运行时镜像或在 override 为空时隐式改写 NapCat env。 diff --git a/README.md b/README.md index 4f0582f..e0b30aa 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ QQBot 插件定时任务由 manifest 的 `tasks` 声明,平台持久化到 `qq Admin 环境总览面板使用 `ENV_DASHBOARD_*` 只读配置聚合 local-dev、NAS 线上、腾讯云和 r4se 状态。`ENV_DASHBOARD_ADMIN_LOCAL_URL` / `ENV_DASHBOARD_ADMIN_PUBLIC_URL` 只用于展示 Admin 本机与线上入口证据。HTTP 快照提供当前拓扑,后端 local/MQTT 事件总线通过 SSE 推送增量事件给 Admin;前端不直连 MQTT,也不轮询刷新。Jenkins、K8s、Tencent Cloud、Caddy、WireGuard、Mihomo/OpenClash 未配置时会显示 `unwired` 证据,不能渲染成健康假象;第一版不暴露重启、部署、迁移、容器重建、插件启停或代理切换等写操作。 -NapCat Runtime/Protocol Profile 已完成本地 API/Admin 实施,线上发布和账号闭环按 `docs/superpowers/plans/2026-06-18-qqbot-napcat-runtime-protocol-profile-implementation-plan.md` 的 Task 10 执行。当前实现覆盖运行态/协议/会话行为/历史登录事件兼容表/风险模式表,真实物理设备风格 hostname/MAC,NapCat/OneBot 配置 hash,KT `zh_CN.UTF-8` 中国桌面派生镜像资产,只读 `/qqbot/napcat/runtime/detail` 证据接口,watchdog 离线巡检告警,以及 Admin 账号页“运行态”抽屉;不绕过 QQ/Tencent 验证码、不修改 QQ/NTQQ 签名协议、不启用 privileged/host network,也不做账号级每小时/每日累计发送预算。NapCat Chinese Desktop Runtime v8 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact,并在 QQ `KickedOffLine` 后重置 native login service 再请求二维码;同一次踢下线事件只消费一次 reset,且只有明确二维码过期或扫码确认窗口失效的 QR session failure 才自动换码,避免扫码确认期间被 `ErrType=1/ErrCode=1` 抢刷新打断。镜像必须先用 `scripts/napcat-desktop-cn-stage-build.mjs` staged build context,生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:desktop-cn-v8` digest。`k8s/prod/api.yaml` 保留 `desktop-cn-v8` 安全默认值;Jenkins `QQBOT_NAPCAT_IMAGE_OVERRIDE` 和 `QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE` 仅在填写时通过 `kubectl set env` 推广已验证运行时镜像/profile,空值会继续使用 manifest/default env。回滚时重新运行 Jenkins 并填入上一版 digest/profile,或清空两个 override 后重新部署 manifest 默认值。 +NapCat Runtime/Protocol Profile 已完成本地 API/Admin 实施,线上发布和账号闭环按 `docs/superpowers/plans/2026-06-18-qqbot-napcat-runtime-protocol-profile-implementation-plan.md` 的 Task 10 执行。当前实现覆盖运行态/协议/会话行为/历史登录事件兼容表/风险模式表,真实物理设备风格 hostname/MAC,NapCat/OneBot 配置 hash,KT `zh_CN.UTF-8` 中国桌面派生镜像资产,只读 `/qqbot/napcat/runtime/detail` 证据接口,watchdog 离线巡检告警,以及 Admin 账号页“运行态”抽屉;不绕过 QQ/Tencent 验证码、不修改 QQ/NTQQ 签名协议、不启用 privileged/host network,也不做账号级每小时/每日累计发送预算。NapCat Chinese Desktop Runtime v20 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact,并在 QQ `KickedOffLine` 后标记 native login service stale;API 在源 Docker 容器在线但 WebUI 明确 QQ 离线时会同容器调用 `RestartNapCat` 重启 NapCat worker,重建 QQCore login service 后再推进 quick/password/qrcode,不做 Docker 重建、补 env 或设备身份迁移,且同一个更新登录 session 只消费一次 worker restart 预算;v14 起还会对 QQ/NapCat/Xvfb 长期进程的 `/proc//mountinfo` 做 PID 级遮蔽,防止 `overlay`、`/vol1/docker`、`docker-init`、`/docker/containers`、`napcat-instances` 等宿主路径泄露;v15 修复扫码成功时 `QQLoginInfo` 晚于登录态写入造成的 QQ 号回读空窗;v16 在 native reset 缺少 `offline()` 时改用 `destroy()` 硬重置半登录服务,并让镜像 verify 等待 mountinfo guard 收敛;v17/v18 增加 WebUI 鉴权的 `/api/Debug/RuntimeViewProbe` 同进程诊断并修正 native maps 截断导致的 hook 证据假阴性;v19 保留 WebUI `RestartNapCat` 重启 worker 时的 `-q ` 快速登录参数,避免重启后退回无账号扫码;v20 保护 API 预写的 `/app/napcat/config`,避免上游首次解包 `NapCat.Shell/*` 覆盖 `bypass.*=true` 与 `o3HookMode=0`。镜像必须先用 `scripts/napcat-desktop-cn-stage-build.mjs` staged build context,生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:desktop-cn-v20` digest。`k8s/prod/api.yaml` 保留 `desktop-cn-v20` 稳定默认值;Jenkins `QQBOT_NAPCAT_IMAGE_OVERRIDE` 和 `QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE` 仅在填写时通过 `kubectl set env` 推广已验证运行时镜像/profile,空值会继续使用 manifest/default env。回滚时重新运行 Jenkins 并填入上一版 digest/profile,或清空两个 override 后重新部署 manifest 默认值。 运行时发布时,API 仓库不提交 `NapCat.Shell.zip`;生产镜像必须从 staged context 构建,`fork-artifact.json` 必须带完整 marker metadata,包括 upstream release tag/commit、fork commit、base image digest、Jenkins URL 和 artifact hashes。release evidence 里的 NapCat base image 必须用 digest pin。API Jenkins 只消费人工确认后的运行时推广参数,不自动合并上游、不自动构建隐藏镜像,也不在 override 为空时覆盖 K8s manifest 中的默认 env。 @@ -171,16 +171,16 @@ 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 响应;新增账号扫码会先返回 pending `sessionId`,后台再创建 NapCat 容器并生成二维码。 +- QQBot 扫码登录通过 SSE `/qqbot/account/scan/events` 暴露进度,耗时链路不应阻塞普通 HTTP 响应;新增账号扫码会先返回 pending `sessionId`,后台再创建 NapCat 容器并生成二维码。`CheckLoginStatus.isLogin=true` 只代表 NapCat 登录阳性,创建账号必须继续等 `GetQQLoginInfo` 返回 `uin/selfId` 后才绑定真实 QQ 号;短暂缺号时会话保持 pending 并显示正在读取 QQ 号,不能重建容器或猜号。 - QQBot 外发统一走发送排队:默认全局间隔 `2500ms`、同会话间隔 `8000ms`、排队抖动 `0-800ms`,超过 `QQBOT_SEND_MAX_QUEUE_WAIT_MS` 时拒绝本次发送,避免高频自动回复形成突发流量。 - QQBot 在线命令和自动回复规则都有运行时保底冷却:默认命令 `5000ms`、规则 `30000ms`;即使数据库里旧数据冷却值更低,也按保底值判定,降低频繁触发风控的概率。 - QQBot 复读机默认阈值为 4,同一会话默认 10 分钟只复读一次,默认只复读 120 字以内普通文本,避免群聊重复内容导致机器人过于频繁地模拟真人发言。 - QQBot 插件平台统一使用 `plugin.json` manifest 描述插件 key、版本、操作、事件、权限、运行预算和包入口;CLI 负责 create/validate/pack/install-local,后端只暴露受控 SDK 能力并通过插件维度记录安装、配置、账号绑定和运行事件。 - Bilibili Card 是事件型内置插件:`bilibili-card.message` 只在账号绑定后监听 QQ/NapCat `share/json/xml/lightapp` 卡片或文本里的 Bilibili 链接,`b23.tv` 短链通过平台 `resolveRedirect` 受控 host 能力解析,视频信息从 Bilibili `x/web-interface/view` 获取后回复纯文本摘要。 - QQBot 同一账号只允许一个有效 NapCat 主容器;绑定新容器时会释放旧绑定和不再共享的旧容器,机器人下线 notice、`isOnline:false` 和 NapCat 容器最新离线日志都会写入账号 `lastError`,普通群成员 kick 不属于账号离线信号;写入 `last_error` 前按 500 字符截断,后续无错误的普通断连不能清空该原因;账号列表拆开展示 OneBot、容器、WebUI 和 QQ 登录态,心跳只代表 OneBot/容器通信,不能推导 QQ 登录态;近期连接只用于避免重连瞬间被旧缓存误伤,后续仍必须以 NapCat WebUI/日志检查判断 QQ 登录态;`qqLoginMessage` 只展示 QQ 登录态消息,WebUI 配置或请求错误留在 `lastError`。 -- NapCat 托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像;生产切换镜像前先 pin 明确版本或 digest 并单账号观察。`desktop-cn-v8` 镜像从 KT `NapCatQQ` fork 的 source-built `NapCat.Shell` 构建,不再在镜像内对上游 bundle 做字符串 patch,并修复踢下线后原 native login service 不产新二维码、失败回调反复重置以及扫码确认期间非过期 QR failure 抢刷新打断登录的问题。 +- NapCat 托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像;生产切换镜像前先 pin 明确版本或 digest 并单账号观察。`desktop-cn-v20` 镜像从 KT `NapCatQQ` fork 的 source-built `NapCat.Shell` 构建,不再在镜像内对上游 bundle 做字符串 patch,并修复非自动重试 QR failure 后下次 WebUI 登录动作不重置、QQCore 通过进程级 mountinfo 探针看到 Docker/宿主路径、扫码登录成功后 API 立即读不到 QQ 号、生产 native reset 缺少 `offline()` 时半登录态无法清理、runtime view native maps 取证假阴性、WebUI `RestartNapCat` 重启 worker 丢失快速登录账号参数,以及首次解包覆盖 API 预写 NapCat config 导致 bypass 开关回落默认关闭的问题。踢下线后的半登录态不能只靠旧 native reset 兜底;源 Docker 容器在线时 API 会先同容器 `RestartNapCat` 重建 NapCat worker,再继续登录流程,同一个更新登录 session 不能反复重启 worker。 - 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 刷新登录态:只要源容器在线,就保持同一 Docker 容器;若 WebUI 明确返回 QQ 离线,会先调用同容器 `/api/QQLogin/RestartNapCat` 重启 NapCat worker,随后通过 NapCat WebUI `SetQuickLogin -> PasswordLogin -> RefreshQRcode/GetQQLoginQrcode` 推进原弹窗流程;同一个更新登录 session 只允许一次 worker restart,后续状态轮询只能继续 WebUI 登录/二维码刷新,不能反复重启 worker。只有 Docker 容器离线或缺失时,容器准备阶段才创建/重建并一次性注入 env。快速登录失败后,如果账号保存了登录密码,后端使用解密密码计算 MD5 调 `/api/QQLogin/PasswordLogin`,不会把密码写入运行态 env,也没有成功后的 env 清理步骤;密码登录按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询结果。准备中的扫码会话会续期,重复调用更新登录会复用同一 pending `sessionId`,不会再次启动 quick/password/二维码准备;但如果该 pending 会话是在账号维护登录密码前创建、且尚未进入密码/验证码/新设备上下文,后续更新登录必须退役这条无密码会话并新建 refresh session,以重新读取账号表中的最新密码。取消扫码会话会在接口返回前把持久化 session 标成 `error/cancelled` 并写完成时间,避免已取消二维码从 DB 恢复为 active pending。若 API Pod 在准备阶段重启,持久化的 `preparingRelogin` 超过 `QQBOT_NAPCAT_RELOGIN_PREPARING_STALE_MS` 后会自动恢复为普通状态检测。pending refresh 会话没有二维码、验证码或新设备挑战时,状态轮询会按 `NAPCAT_LOGIN_QR_AUTO_REFRESH_COOLDOWN_MS` 冷却在同一容器自动重试二维码刷新,避免 SSE 长时间停在生成中。验证码和新设备验证保持同一会话 pending:腾讯验证码结果 `ticket`/`randstr`/`sid` 通过 `/qqbot/account/scan/captcha/submit` 回交到同一容器的 `/api/QQLogin/CaptchaLogin`;状态轮询遇到验证码文案但缺少 URL 时会先从当前容器日志恢复 `proofWaterUrl`,没有 URL 也保持验证码处理中而不切到二维码兜底。扫码或密码链路拿到 NapCat 登录阳性但 QQ 号暂不可读时,会按 `NAPCAT_LOGIN_SELF_ID_WAIT_MS` 保持 pending,超过窗口才失败。密码登录仍失败、验证码未完成、离线、账号不匹配或缺少 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 和实体 OUI 风格 MAC,明确排除 Docker `02:42`、QEMU/KVM `52:54:00`、VMware、Hyper-V 等虚拟化前缀;新增账号首次扫码会先用预留容器 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` / `physical-oui-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 手动触发「更新登录」。 diff --git a/ci/napcat-desktop-cn/Dockerfile b/ci/napcat-desktop-cn/Dockerfile index e85a088..728433f 100644 --- a/ci/napcat-desktop-cn/Dockerfile +++ b/ci/napcat-desktop-cn/Dockerfile @@ -30,7 +30,11 @@ RUN set -eux; \ sha256sum /app/NapCat.Shell.zip | awk '{print $1}' > /ci/napcat-desktop-cn/NapCat.Shell.zip.sha256; \ rm -rf /tmp/NapCat.Shell COPY ci/napcat-desktop-cn/verify.sh /ci/napcat-desktop-cn/verify.sh -RUN sed -i 's/\r$//' /ci/napcat-desktop-cn/verify.sh && chmod +x /ci/napcat-desktop-cn/verify.sh +COPY ci/napcat-desktop-cn/entrypoint-device-profile.patch.sh /ci/napcat-desktop-cn/entrypoint-device-profile.patch.sh +RUN set -eux; \ + sed -i 's/\r$//' /ci/napcat-desktop-cn/verify.sh /ci/napcat-desktop-cn/entrypoint-device-profile.patch.sh; \ + chmod +x /ci/napcat-desktop-cn/verify.sh /ci/napcat-desktop-cn/entrypoint-device-profile.patch.sh; \ + /ci/napcat-desktop-cn/entrypoint-device-profile.patch.sh ENV LANG=zh_CN.UTF-8 \ LC_ALL=zh_CN.UTF-8 \ diff --git a/ci/napcat-desktop-cn/README.md b/ci/napcat-desktop-cn/README.md index 52e15cb..2297e6a 100644 --- a/ci/napcat-desktop-cn/README.md +++ b/ci/napcat-desktop-cn/README.md @@ -43,14 +43,21 @@ Build and verify: ```powershell docker build ` --build-arg NAPCAT_BASE_IMAGE=$napcatBaseImageDigest ` - -t kt-napcat-desktop-cn:desktop-cn-v8 ` + -t kt-napcat-desktop-cn:desktop-cn-v16 ` -f .kt-workspace/napcat-desktop-cn-build/ci/napcat-desktop-cn/Dockerfile ` .kt-workspace/napcat-desktop-cn-build -$name = "kt-napcat-v8-verify-$([DateTimeOffset]::UtcNow.ToUnixTimeSeconds())" -docker run -d --name $name kt-napcat-desktop-cn:desktop-cn-v8 +$name = "kt-napcat-v16-verify-$([DateTimeOffset]::UtcNow.ToUnixTimeSeconds())" +docker run -d --name $name ` + --cap-add SYS_ADMIN ` + --security-opt apparmor=unconfined ` + --security-opt seccomp=unconfined ` + -e NAPCAT_REQUIRE_DEVICE_PROFILE=1 ` + kt-napcat-desktop-cn:desktop-cn-v16 docker exec $name sh /ci/napcat-desktop-cn/verify.sh docker rm -f $name ``` +`verify.sh` checks long-lived QQ/NapCat/Xvfb process `/proc//mountinfo` after the entrypoint guard has had a bounded convergence window. If a target process still exposes host paths after `MOUNTINFO_GUARD_TIMEOUT_SECONDS`, verification exits with 78 and prints the target PID, `comm`, `cmdline`, and first leaked mountinfo line. + Record the final image digest in `QQBOT_NAPCAT_IMAGE`, and keep the matching `fork-artifact.json` with the release evidence. For local Windows rehearsal, placeholder `$upstreamReleaseTag`, `$upstreamReleaseCommit`, and `$jenkinsBuildUrl` values are acceptable only if the image is not promoted. diff --git a/ci/napcat-desktop-cn/entrypoint-device-profile.patch.sh b/ci/napcat-desktop-cn/entrypoint-device-profile.patch.sh new file mode 100644 index 0000000..bc7afd0 --- /dev/null +++ b/ci/napcat-desktop-cn/entrypoint-device-profile.patch.sh @@ -0,0 +1,266 @@ +#!/bin/sh +set -eu + +ENTRYPOINT=/app/entrypoint.sh + +if grep -q 'KT device profile patch defaults' "$ENTRYPOINT" \ + && grep -q 'KT preserve mounted NapCat config' "$ENTRYPOINT"; then + exit 0 +fi + +if ! grep -q 'KT preserve mounted NapCat config' "$ENTRYPOINT"; then + grep -Fq 'cp -rf NapCat.Shell/* napcat/' "$ENTRYPOINT" + awk ' + /cp -rf NapCat\.Shell\/\* napcat\// { + print " # KT preserve mounted NapCat config." + print " find NapCat.Shell -mindepth 1 -maxdepth 1 ! -name config -exec cp -rf {} napcat/ \\;" + print " if [ ! -f \"napcat/config/napcat.json\" ]; then" + print " mkdir -p napcat/config" + print " cp -rf NapCat.Shell/config/* napcat/config/" + print " fi" + next + } + { print } + ' "$ENTRYPOINT" > "$ENTRYPOINT.tmp" + mv "$ENTRYPOINT.tmp" "$ENTRYPOINT" + chmod +x "$ENTRYPOINT" +fi + +if grep -q 'KT device profile patch defaults' "$ENTRYPOINT"; then + exit 0 +fi + +grep -q 'mount --bind "$FAKE_CMDLINE" /proc/1/cmdline' "$ENTRYPOINT" + +cat >/tmp/kt-device-profile-defaults.sh <<'EOF' + +# KT device profile patch defaults. +: "${NAPCAT_REQUIRE_DEVICE_PROFILE:=0}" +: "${NAPCAT_DMI_PRODUCT_NAME:=imini Pro}" +: "${NAPCAT_DMI_PRODUCT_UUID:=00000000-0000-0000-0000-000000000000}" +: "${NAPCAT_DMI_SYS_VENDOR:=MECHREVO}" +: "${NAPCAT_DMI_BOARD_VENDOR:=MECHREVO}" +: "${NAPCAT_DMI_BOARD_NAME:=imini Pro}" +: "${NAPCAT_DMI_BIOS_VENDOR:=American Megatrends International, LLC.}" +: "${NAPCAT_DMI_BIOS_VERSION:=imini Pro 1.10}" +: "${NAPCAT_DMI_MODALIAS:=dmi:bvnAmericanMegatrendsInternational,LLC.:bvriminiPro1.10:bd03/31/2024:br1.10:efr1.10:svnMECHREVO:pniminiPro:pvrStandard:rvnMECHREVO:rniminiPro:rvrStandard:cvnMECHREVO:ct3:cvrDefaultstring:skuStandard:}" +: "${NAPCAT_DEVICE_BOOT_ID:=00000000-0000-4000-8000-000000000000}" +: "${NAPCAT_DEVICE_KERNEL_RELEASE:=6.8.0-60-generic}" +: "${NAPCAT_DEVICE_KERNEL_VERSION:=#63-Ubuntu SMP PREEMPT_DYNAMIC Tue Apr 15 19:04:15 UTC 2025}" +: "${NAPCAT_DEVICE_PROC_VERSION:=Linux version 6.8.0-60-generic (buildd@lcy02-amd64-001) (x86_64-linux-gnu-gcc (Ubuntu 13.3.0-6ubuntu2~24.04) 13.3.0, GNU ld (GNU Binutils for Ubuntu) 2.42) #63-Ubuntu SMP PREEMPT_DYNAMIC Tue Apr 15 19:04:15 UTC 2025}" +: "${NAPCAT_DEVICE_CPU_MODEL:=AMD Ryzen 7 8845H w/ Radeon 780M Graphics}" +: "${NAPCAT_DEVICE_UPTIME:=7200.00 14400.00}" +: "${NAPCAT_DEVICE_TTY_ACTIVE:=tty1}" +: "${NAPCAT_DEVICE_MOUNTINFO_GUARD_ENABLED:=1}" +: "${NAPCAT_DEVICE_MOUNTINFO_GUARD_INTERVAL:=1}" + +kt_fake_file() { + kt_target="$1" + kt_content="$2" + kt_name="$(echo "$kt_target" | tr '/' '_')" + kt_fake="$FAKE_CGROUP_DIR/kt_device_profile_$kt_name" + printf '%s\n' "$kt_content" > "$kt_fake" + mount --bind "$kt_fake" "$kt_target" 2>/dev/null || true +} +EOF + +cat >/tmp/kt-device-profile-dmi.sh <<'EOF' + +# KT device profile DMI overrides for files QQCore opens. +kt_fake_file /sys/class/dmi/id/product_name "$NAPCAT_DMI_PRODUCT_NAME" +kt_fake_file /sys/class/dmi/id/product_uuid "$NAPCAT_DMI_PRODUCT_UUID" +kt_fake_file /sys/class/dmi/id/sys_vendor "$NAPCAT_DMI_SYS_VENDOR" +kt_fake_file /sys/class/dmi/id/board_vendor "$NAPCAT_DMI_BOARD_VENDOR" +kt_fake_file /sys/class/dmi/id/board_name "$NAPCAT_DMI_BOARD_NAME" +kt_fake_file /sys/class/dmi/id/bios_vendor "$NAPCAT_DMI_BIOS_VENDOR" +kt_fake_file /sys/class/dmi/id/bios_version "$NAPCAT_DMI_BIOS_VERSION" +kt_fake_file /sys/class/dmi/id/modalias "$NAPCAT_DMI_MODALIAS" +EOF + +cat >/tmp/kt-device-profile-proc.sh <<'EOF' + +# KT device profile proc/sys files QQCore opens. +kt_fake_file /proc/sys/kernel/random/boot_id "$NAPCAT_DEVICE_BOOT_ID" +kt_fake_file /proc/sys/kernel/version "$NAPCAT_DEVICE_KERNEL_VERSION" +kt_fake_file /proc/version "$NAPCAT_DEVICE_PROC_VERSION" +kt_fake_file /proc/uptime "$NAPCAT_DEVICE_UPTIME" +if [ -f /proc/cpuinfo ]; then + KT_FAKE_CPUINFO="$FAKE_CGROUP_DIR/kt_device_profile_proc_cpuinfo" + awk -v model="$NAPCAT_DEVICE_CPU_MODEL" ' + /^model name[[:space:]]*:/ { $0 = "model name\t: " model } + /^flags[[:space:]]*:/ { + gsub(/(^| )hypervisor( |$)/, " ") + gsub(/[[:space:]]+/, " ") + } + { print } + ' /proc/cpuinfo > "$KT_FAKE_CPUINFO" 2>/dev/null || true + mount --bind "$KT_FAKE_CPUINFO" /proc/cpuinfo 2>/dev/null || true +fi +if [ -f /proc/devices ]; then + KT_FAKE_DEVICES="$FAKE_CGROUP_DIR/kt_device_profile_proc_devices" + grep -Ev 'trim-trashbin|zvol' /proc/devices > "$KT_FAKE_DEVICES" 2>/dev/null || true + mount --bind "$KT_FAKE_DEVICES" /proc/devices 2>/dev/null || true +fi +if [ -f /sys/devices/virtual/tty/tty0/active ]; then + kt_fake_file /sys/devices/virtual/tty/tty0/active "$NAPCAT_DEVICE_TTY_ACTIVE" +fi +if [ -f /etc/hosts ]; then + KT_FAKE_HOSTS="$FAKE_CGROUP_DIR/kt_device_profile_hosts" + { + printf '127.0.0.1\tlocalhost\n' + printf '::1\tlocalhost ip6-localhost ip6-loopback\n' + printf 'fe00::\tip6-localnet\n' + printf 'ff00::\tip6-mcastprefix\n' + printf 'ff02::1\tip6-allnodes\n' + printf 'ff02::2\tip6-allrouters\n' + } > "$KT_FAKE_HOSTS" + mount --bind "$KT_FAKE_HOSTS" /etc/hosts 2>/dev/null || true +fi + +# KT device profile mountinfo guard for long-lived QQ/NapCat processes. +KT_MOUNTINFO_HOST_LEAK_PATTERN='overlay|/vol1/docker|docker-init|/docker/containers|napcat-instances|btrfs|/dev/mapper/trim' +KT_FAKE_MOUNTINFO="$FAKE_CGROUP_DIR/kt_device_profile_mountinfo" +cat > "$KT_FAKE_MOUNTINFO" <<'KT_MOUNTINFO_EOF' +22 1 259:2 / / rw,relatime - ext4 /dev/nvme0n1p2 rw,errors=remount-ro +23 22 0:6 / /proc rw,nosuid,nodev,noexec,relatime - proc proc rw +24 22 0:22 / /dev rw,nosuid,relatime - tmpfs udev rw,size=4096000k,nr_inodes=1024000,mode=755,inode64 +25 24 0:23 / /dev/pts rw,nosuid,noexec,relatime - devpts devpts rw,gid=5,mode=620,ptmxmode=000 +26 22 0:24 / /sys rw,nosuid,nodev,noexec,relatime - sysfs sysfs rw +27 22 0:25 / /run rw,nosuid,nodev,relatime - tmpfs tmpfs rw,mode=755,inode64 +28 22 259:2 /home/napcat/.config /app/.config rw,relatime - ext4 /dev/nvme0n1p2 rw,errors=remount-ro +29 22 259:2 /home/napcat/.cache /app/.cache rw,relatime - ext4 /dev/nvme0n1p2 rw,errors=remount-ro +30 22 259:2 /home/napcat/.local/share /app/.local/share rw,relatime - ext4 /dev/nvme0n1p2 rw,errors=remount-ro +KT_MOUNTINFO_EOF + +kt_mask_mountinfo_target() { + kt_mountinfo="$1" + if [ -f "$kt_mountinfo" ] && grep -E "$KT_MOUNTINFO_HOST_LEAK_PATTERN" "$kt_mountinfo" >/dev/null 2>&1; then + mount --bind "$KT_FAKE_MOUNTINFO" "$kt_mountinfo" 2>/dev/null || true + fi +} + +kt_is_mountinfo_target() { + kt_target_comm="$1" + kt_target_cmdline="$2" + kt_target_argv0="${kt_target_cmdline%% *}" + kt_target_rest="${kt_target_cmdline#* }" + if [ "$kt_target_rest" = "$kt_target_cmdline" ]; then + kt_target_rest="" + fi + kt_target_argv1="${kt_target_rest%% *}" + kt_target_argv0_base="${kt_target_argv0##*/}" + + case "$kt_target_comm" in + qq|QQ|NapCat|napcat|Xvfb) + return 0 + ;; + esac + case "$kt_target_argv0_base" in + qq|QQ|NapCat|napcat|Xvfb) + return 0 + ;; + esac + case "$kt_target_argv0" in + /opt/QQ/*|/app/napcat/*|/app/NapCat*) + return 0 + ;; + esac + case "$kt_target_argv1" in + /app/napcat/*|/app/NapCat*|*/NapCat.Shell*|*/napcat.mjs*) + return 0 + ;; + esac + return 1 +} + +kt_mountinfo_guard_once() { + for kt_mountinfo_pid_dir in /proc/[0-9]*; do + [ -d "$kt_mountinfo_pid_dir" ] || continue + kt_mountinfo_pid="${kt_mountinfo_pid_dir#/proc/}" + kt_mountinfo_comm="$(cat "$kt_mountinfo_pid_dir/comm" 2>/dev/null || true)" + kt_mountinfo_cmdline="$(tr '\000' ' ' < "$kt_mountinfo_pid_dir/cmdline" 2>/dev/null || true)" + if kt_is_mountinfo_target "$kt_mountinfo_comm" "$kt_mountinfo_cmdline"; then + kt_mask_mountinfo_target "/proc/$kt_mountinfo_pid/mountinfo" + fi + done +} + +kt_mountinfo_guard_loop() { + while :; do + kt_mountinfo_guard_once + sleep "${NAPCAT_DEVICE_MOUNTINFO_GUARD_INTERVAL:-1}" + done +} + +kt_mask_mountinfo_target /proc/1/mountinfo +kt_mask_mountinfo_target /proc/self/mountinfo +if [ "${NAPCAT_DEVICE_MOUNTINFO_GUARD_ENABLED:-1}" = "1" ]; then + kt_mountinfo_guard_loop >/dev/null 2>&1 & +fi +EOF + +cat >/tmp/kt-device-profile-verify.sh <<'EOF' + +if [ "${NAPCAT_REQUIRE_DEVICE_PROFILE:-0}" = "1" ]; then + kt_require_device_profile() { + kt_device_profile_label="$1" + shift + "$@" || { + echo "NapCat device profile check failed: $kt_device_profile_label" >&2 + exit 78 + } + } + + kt_require_device_profile proc1-cmdline sh -c "tr '\\000' ' ' &2 + exit 78 + fi + if [ -f /sys/devices/virtual/tty/tty0/active ]; then + kt_require_device_profile tty-active grep -q "$NAPCAT_DEVICE_TTY_ACTIVE" /sys/devices/virtual/tty/tty0/active + else + echo "NapCat device profile check failed: tty0-active-missing" >&2 + exit 78 + fi + KT_MOUNTINFO_HOST_LEAK_PATTERN='overlay|/vol1/docker|docker-init|/docker/containers|napcat-instances|btrfs|/dev/mapper/trim' + for kt_mountinfo in /proc/1/mountinfo; do + if grep -E "$KT_MOUNTINFO_HOST_LEAK_PATTERN" "$kt_mountinfo" >/dev/null 2>&1; then + echo "NapCat device profile check failed: mountinfo-host-leak:$kt_mountinfo" >&2 + exit 78 + fi + done + if [ "$(hostname)" != "localhost" ] && grep -q "$(hostname)" /etc/hosts; then + echo "NapCat device profile check failed: hosts-still-contains-hostname" >&2 + exit 78 + fi + if grep -E 'trim-trashbin|zvol' /proc/devices >/dev/null 2>&1; then + echo "NapCat device profile check failed: proc-devices-host-leak" >&2 + exit 78 + fi +fi +EOF + +sed -i '/FAKE_CGROUP_DIR="\/tmp\/fake_cgroup"/r /tmp/kt-device-profile-defaults.sh' "$ENTRYPOINT" +sed -i '/# Docker Socket/r /tmp/kt-device-profile-dmi.sh' "$ENTRYPOINT" +sed -i '/# \/proc\/1\/sched/r /tmp/kt-device-profile-proc.sh' "$ENTRYPOINT" +sed -i '/: ${NAPCAT_UID:=0}/r /tmp/kt-device-profile-verify.sh' "$ENTRYPOINT" + +rm -f \ + /tmp/kt-device-profile-defaults.sh \ + /tmp/kt-device-profile-dmi.sh \ + /tmp/kt-device-profile-proc.sh \ + /tmp/kt-device-profile-verify.sh diff --git a/ci/napcat-desktop-cn/verify.sh b/ci/napcat-desktop-cn/verify.sh index 511f7f2..d4aba09 100644 --- a/ci/napcat-desktop-cn/verify.sh +++ b/ci/napcat-desktop-cn/verify.sh @@ -43,4 +43,116 @@ test "$EXPECTED_MJS_SHA" = "$ACTUAL_MJS_SHA" grep -R -q 'getQQLoginRuntimeState' "$TMP_DIR" grep -R -q 'qrcodeRevision' "$TMP_DIR" grep -R -q 'needsLoginServiceReset' "$TMP_DIR" -grep -R -q '重置已失效登录服务后重新生成二维码' "$TMP_DIR" + +grep -q 'KT device profile patch defaults' /app/entrypoint.sh +grep -q 'NAPCAT_REQUIRE_DEVICE_PROFILE' /app/entrypoint.sh +grep -q '/proc/1/cmdline' /app/entrypoint.sh +grep -q '/proc/1/sched' /app/entrypoint.sh +grep -q '/proc/1/status' /app/entrypoint.sh +grep -q '/proc/1/stat' /app/entrypoint.sh +grep -q '/proc/1/mountinfo' /app/entrypoint.sh +grep -q '/proc/self/mountinfo' /app/entrypoint.sh +grep -q '/proc/$kt_mountinfo_pid/mountinfo' /app/entrypoint.sh +grep -q 'kt_is_mountinfo_target' /app/entrypoint.sh +grep -q 'kt_mountinfo_guard_loop' /app/entrypoint.sh +grep -q 'overlay|/vol1/docker|docker-init|/docker/containers|napcat-instances|btrfs|/dev/mapper/trim' /app/entrypoint.sh +grep -q '/sys/class/dmi/id/product_name' /app/entrypoint.sh +grep -q '/sys/class/dmi/id/bios_vendor' /app/entrypoint.sh +grep -q '/sys/class/dmi/id/bios_version' /app/entrypoint.sh +grep -q '/etc/hosts' /app/entrypoint.sh +grep -q 'KT preserve mounted NapCat config' /app/entrypoint.sh +grep -q 'find NapCat.Shell -mindepth 1 -maxdepth 1 ! -name config' /app/entrypoint.sh +! grep -q 'cp -rf NapCat.Shell/\* napcat/' /app/entrypoint.sh + +MOUNTINFO_HOST_LEAK_PATTERN='overlay|/vol1/docker|docker-init|/docker/containers|napcat-instances|btrfs|/dev/mapper/trim' +MOUNTINFO_GUARD_TIMEOUT_SECONDS="${MOUNTINFO_GUARD_TIMEOUT_SECONDS:-12}" + +mountinfo_has_host_leak() { + kt_mountinfo="$1" + if [ -r "$kt_mountinfo" ] && grep -E "$MOUNTINFO_HOST_LEAK_PATTERN" "$kt_mountinfo" >/dev/null 2>&1; then + return 0 + fi + return 1 +} + +assert_no_mountinfo_host_leak() { + kt_mountinfo="$1" + if mountinfo_has_host_leak "$kt_mountinfo"; then + echo "NapCat desktop-cn verify failed: mountinfo-host-leak:$kt_mountinfo" >&2 + exit 78 + fi +} + +wait_for_mountinfo_guard_converged() { + kt_mountinfo_pid="$1" + kt_mountinfo_guard_deadline="$MOUNTINFO_GUARD_TIMEOUT_SECONDS" + kt_mountinfo_guard_elapsed=0 + while [ "$kt_mountinfo_guard_elapsed" -le "$kt_mountinfo_guard_deadline" ]; do + if [ ! -d "/proc/$kt_mountinfo_pid" ]; then + return 0 + fi + if ! mountinfo_has_host_leak "/proc/$kt_mountinfo_pid/mountinfo"; then + return 0 + fi + sleep 1 + kt_mountinfo_guard_elapsed=$((kt_mountinfo_guard_elapsed + 1)) + done + return 1 +} + +assert_target_mountinfo_guard_converged() { + kt_mountinfo_pid="$1" + if wait_for_mountinfo_guard_converged "$kt_mountinfo_pid"; then + return 0 + fi + kt_mountinfo_comm="$(cat "/proc/$kt_mountinfo_pid/comm" 2>/dev/null || true)" + kt_mountinfo_cmdline="$(tr '\000' ' ' < "/proc/$kt_mountinfo_pid/cmdline" 2>/dev/null || true)" + echo "NapCat desktop-cn verify failed: mountinfo-host-leak:/proc/$kt_mountinfo_pid/mountinfo comm=$kt_mountinfo_comm cmdline=$kt_mountinfo_cmdline" >&2 + grep -E "$MOUNTINFO_HOST_LEAK_PATTERN" "/proc/$kt_mountinfo_pid/mountinfo" 2>/dev/null | head -n 1 >&2 || true + exit 78 +} + +kt_is_mountinfo_target() { + kt_target_comm="$1" + kt_target_cmdline="$2" + kt_target_argv0="${kt_target_cmdline%% *}" + kt_target_rest="${kt_target_cmdline#* }" + if [ "$kt_target_rest" = "$kt_target_cmdline" ]; then + kt_target_rest="" + fi + kt_target_argv1="${kt_target_rest%% *}" + kt_target_argv0_base="${kt_target_argv0##*/}" + + case "$kt_target_comm" in + qq|QQ|NapCat|napcat|Xvfb) + return 0 + ;; + esac + case "$kt_target_argv0_base" in + qq|QQ|NapCat|napcat|Xvfb) + return 0 + ;; + esac + case "$kt_target_argv0" in + /opt/QQ/*|/app/napcat/*|/app/NapCat*) + return 0 + ;; + esac + case "$kt_target_argv1" in + /app/napcat/*|/app/NapCat*|*/NapCat.Shell*|*/napcat.mjs*) + return 0 + ;; + esac + return 1 +} + +assert_no_mountinfo_host_leak /proc/1/mountinfo +for kt_mountinfo_pid_dir in /proc/[0-9]*; do + [ -d "$kt_mountinfo_pid_dir" ] || continue + kt_mountinfo_pid="${kt_mountinfo_pid_dir#/proc/}" + kt_mountinfo_comm="$(cat "$kt_mountinfo_pid_dir/comm" 2>/dev/null || true)" + kt_mountinfo_cmdline="$(tr '\000' ' ' < "$kt_mountinfo_pid_dir/cmdline" 2>/dev/null || true)" + if kt_is_mountinfo_target "$kt_mountinfo_comm" "$kt_mountinfo_cmdline"; then + assert_target_mountinfo_guard_converged "$kt_mountinfo_pid" + fi +done diff --git a/docs/superpowers/plans/2026-06-24-napcatqq-upstream-sync-runtime-release-pipeline-implementation-plan.md b/docs/superpowers/plans/2026-06-24-napcatqq-upstream-sync-runtime-release-pipeline-implementation-plan.md index 33588ab..6425b0b 100644 --- a/docs/superpowers/plans/2026-06-24-napcatqq-upstream-sync-runtime-release-pipeline-implementation-plan.md +++ b/docs/superpowers/plans/2026-06-24-napcatqq-upstream-sync-runtime-release-pipeline-implementation-plan.md @@ -843,8 +843,8 @@ describe('NapCat runtime promotion contract', () => { it('keeps a safe default runtime profile in the K8s manifest', () => { const manifest = readSource('k8s/prod/api.yaml'); - expect(manifest).toContain('value: kt-napcat-desktop-cn:desktop-cn-v8'); - expect(manifest).toContain('value: desktop-cn-v8'); + expect(manifest).toContain('value: kt-napcat-desktop-cn:desktop-cn-v10'); + expect(manifest).toContain('value: desktop-cn-v10'); }); }); ``` diff --git a/k8s/prod/api.yaml b/k8s/prod/api.yaml index fa2000e..6f3625f 100644 --- a/k8s/prod/api.yaml +++ b/k8s/prod/api.yaml @@ -33,9 +33,9 @@ spec: - name: DB_TIMEZONE value: "+08:00" - name: QQBOT_NAPCAT_IMAGE - value: kt-napcat-desktop-cn:desktop-cn-v8 + value: kt-napcat-desktop-cn:desktop-cn-v20 - name: QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION - value: desktop-cn-v8 + value: desktop-cn-v20 - name: QQBOT_NAPCAT_SSH_KEY_PATH value: /app/secrets/napcat-ssh/id_rsa - name: QQBOT_PLUGIN_TASK_QUEUE_REDIS_PREFIX diff --git a/scripts/napcat-desktop-cn-stage-build.mjs b/scripts/napcat-desktop-cn-stage-build.mjs index 13d150e..2d8d57c 100644 --- a/scripts/napcat-desktop-cn-stage-build.mjs +++ b/scripts/napcat-desktop-cn-stage-build.mjs @@ -161,6 +161,10 @@ copyIntoContext( resolve(apiRoot, 'ci/napcat-desktop-cn/verify.sh'), resolve(outputRoot, 'ci/napcat-desktop-cn/verify.sh'), ); +copyIntoContext( + resolve(apiRoot, 'ci/napcat-desktop-cn/entrypoint-device-profile.patch.sh'), + resolve(outputRoot, 'ci/napcat-desktop-cn/entrypoint-device-profile.patch.sh'), +); copyIntoContext(shellDist, resolve(outputRoot, 'NapCat.Shell')); const marker = { diff --git a/sql/fix-admin-menu-meta.sql b/sql/fix-admin-menu-meta.sql index db12944..533c263 100644 --- a/sql/fix-admin-menu-meta.sql +++ b/sql/fix-admin-menu-meta.sql @@ -7,7 +7,6 @@ UPDATE `admin_menu` SET `meta` = CASE `name` WHEN 'Dashboard' THEN '{"order":-1,"title":"page.dashboard.title"}' WHEN 'Analytics' THEN '{"affixTab":true,"title":"page.dashboard.analytics"}' - WHEN 'Workspace' THEN '{"icon":"carbon:workspace","title":"page.dashboard.workspace"}' WHEN 'System' THEN '{"badge":"new","badgeType":"normal","badgeVariants":"primary","icon":"carbon:settings","order":9997,"title":"system.title"}' WHEN 'SystemRole' THEN '{"icon":"carbon:user-role","title":"system.role.title"}' WHEN 'SystemRoleCreate' THEN '{"title":"common.create"}' @@ -34,7 +33,6 @@ END WHERE `name` IN ( 'Dashboard', 'Analytics', - 'Workspace', 'System', 'SystemRole', 'SystemRoleCreate', diff --git a/sql/refactor-v3/01-seed-core.sql b/sql/refactor-v3/01-seed-core.sql index fa5fd62..f731a84 100644 --- a/sql/refactor-v3/01-seed-core.sql +++ b/sql/refactor-v3/01-seed-core.sql @@ -47,16 +47,16 @@ INSERT INTO admin_user ( status ) VALUES ( 2041700000000000002, - 'admin', + 'kwitsukasa', '123456', - 'Admin', + 'KwiTsukasa', '', 2041700000000200001, - '/workspace', + '/analytics', 'Asia/Shanghai', 1 ) ON DUPLICATE KEY UPDATE - password = VALUES(password), + username = VALUES(username), real_name = VALUES(real_name), avatar = VALUES(avatar), dept_id = VALUES(dept_id), @@ -84,7 +84,7 @@ INSERT INTO admin_menu ( 'Dashboard', '/dashboard', NULL, - '/workspace', + '/analytics', NULL, 'catalog', '{"order":-1,"title":"page.dashboard.title"}', @@ -92,15 +92,15 @@ INSERT INTO admin_menu ( 0 ), ( - 2041700000000100102, + 2041700000000100101, 2041700000000100001, - 'Workspace', - '/workspace', - '/dashboard/workspace/index', + 'Analytics', + '/analytics', + '/dashboard/analytics/index', NULL, NULL, 'menu', - '{"icon":"carbon:workspace","title":"page.dashboard.workspace"}', + '{"affixTab":true,"title":"page.dashboard.analytics"}', 1, 0 ), diff --git a/sql/vben-admin-init.sql b/sql/vben-admin-init.sql index 4a9e559..07b1693 100644 --- a/sql/vben-admin-init.sql +++ b/sql/vben-admin-init.sql @@ -210,7 +210,6 @@ INSERT INTO `admin_menu` (`id`, `pid`, `name`, `path`, `component`, `redirect`, VALUES (2041700000000100001, 0, 'Dashboard', '/dashboard', NULL, '/analytics', NULL, 'catalog', '{"order":-1,"title":"page.dashboard.title"}', 1, 0), (2041700000000100101, 2041700000000100001, 'Analytics', '/analytics', '/dashboard/analytics/index', NULL, NULL, 'menu', '{"affixTab":true,"title":"page.dashboard.analytics"}', 1, 0), - (2041700000000100102, 2041700000000100001, 'Workspace', '/workspace', '/dashboard/workspace/index', NULL, NULL, 'menu', '{"icon":"carbon:workspace","title":"page.dashboard.workspace"}', 1, 0), (2041700000000100002, 0, 'System', '/system', NULL, NULL, NULL, 'catalog', '{"badge":"new","badgeType":"normal","badgeVariants":"primary","icon":"carbon:settings","order":9997,"title":"system.title"}', 1, 9997), (2041700000000100199, 2041700000000100002, 'SystemUser', '/system/user', '/system/user/list', NULL, 'System:User:List', 'menu', '{"icon":"carbon:user-profile","title":"system.user.title"}', 1, 0), (2041700000000120191, 2041700000000100199, 'SystemUserCreate', NULL, NULL, NULL, 'System:User:Create', 'button', '{"title":"common.create"}', 1, 0), @@ -277,7 +276,7 @@ ON DUPLICATE KEY UPDATE INSERT INTO `admin_role` (`id`, `role_code`, `name`, `remark`, `status`) VALUES (2041700000000010001, 'super', '超级管理员', '拥有所有后台权限', 1), - (2041700000000010002, 'admin', '管理员', '拥有系统管理与工作台权限', 1), + (2041700000000010002, 'admin', '管理员', '拥有系统管理与环境总览权限', 1), (2041700000000010003, 'user', '普通用户', '仅拥有基础查看权限', 1) ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), @@ -287,11 +286,9 @@ ON DUPLICATE KEY UPDATE INSERT INTO `admin_user` (`id`, `username`, `password`, `real_name`, `avatar`, `dept_id`, `home_path`, `timezone`, `status`) VALUES - (2041700000000000001, 'vben', '123456', 'Vben', '', 2041700000000200002, '/workspace', 'Asia/Shanghai', 1), - (2041700000000000002, 'admin', '123456', 'Admin', '', 2041700000000200001, '/workspace', 'Asia/Shanghai', 1), - (2041700000000000003, 'jack', '123456', 'Jack', '', 2041700000000200003, '/analytics', 'Asia/Shanghai', 1) + (2041700000000000002, 'kwitsukasa', '123456', 'KwiTsukasa', '', 2041700000000200001, '/analytics', 'Asia/Shanghai', 1) ON DUPLICATE KEY UPDATE - `password` = VALUES(`password`), + `username` = VALUES(`username`), `real_name` = VALUES(`real_name`), `dept_id` = VALUES(`dept_id`), `home_path` = VALUES(`home_path`), @@ -331,16 +328,12 @@ ON DUPLICATE KEY UPDATE DELETE FROM `admin_user_role` WHERE `user_id` IN ( - 2041700000000000001, - 2041700000000000002, - 2041700000000000003 + 2041700000000000002 ); INSERT INTO `admin_user_role` (`user_id`, `role_id`) VALUES - (2041700000000000001, 2041700000000010001), - (2041700000000000002, 2041700000000010002), - (2041700000000000003, 2041700000000010003); + (2041700000000000002, 2041700000000010002); DELETE FROM `admin_role_menu` WHERE `role_id` IN ( diff --git a/src/modules/admin/identity/menu/admin-menu.service.ts b/src/modules/admin/identity/menu/admin-menu.service.ts index c0c6826..07fdf8b 100644 --- a/src/modules/admin/identity/menu/admin-menu.service.ts +++ b/src/modules/admin/identity/menu/admin-menu.service.ts @@ -210,6 +210,7 @@ export class AdminMenuService { pid: data.pid || '0', redirect: data.redirect || null, status: data.status ?? 1, + sort: Number(data.sort ?? 0), type: data.type || 'menu', }; if (includeEmptyMeta || Object.keys(meta).length > 0) { @@ -321,6 +322,7 @@ export class AdminMenuService { pid: menu.pid || '0', redirect: menu.redirect, status: menu.status, + sort: menu.sort, type: menu.type, } as any; diff --git a/src/modules/admin/identity/user/admin-user.service.ts b/src/modules/admin/identity/user/admin-user.service.ts index 3056f81..0d8f014 100644 --- a/src/modules/admin/identity/user/admin-user.service.ts +++ b/src/modules/admin/identity/user/admin-user.service.ts @@ -10,6 +10,9 @@ import type { AdminUserListQuery, } from '../../contract/admin.types'; +const BUILTIN_ADMIN_USER_ID = '2041700000000000002'; +const DEFAULT_ADMIN_HOME_PATH = '/analytics'; + @Injectable() export class AdminUserService { /** @@ -97,7 +100,7 @@ export class AdminUserService { const user = this.userRepository.create({ deptId: data.deptId || null, - homePath: data.homePath || '/workspace', + homePath: data.homePath || DEFAULT_ADMIN_HOME_PATH, password: data.password || '123456', realName: data.realName, status: data.status ?? 1, @@ -156,7 +159,11 @@ export class AdminUserService { if (id === currentUserId) { throwVbenError('不能删除当前登录用户', HttpStatus.BAD_REQUEST); } - if (user.username === 'admin') { + if ( + user.id === BUILTIN_ADMIN_USER_ID || + user.username === 'admin' || + user.username === 'kwitsukasa' + ) { throwVbenError('不能删除内置管理员账号', HttpStatus.BAD_REQUEST); } @@ -183,7 +190,8 @@ export class AdminUserService { user.realName = realName; } if (data.homePath !== undefined) { - user.homePath = String(data.homePath || '').trim() || '/workspace'; + user.homePath = + String(data.homePath || '').trim() || DEFAULT_ADMIN_HOME_PATH; } if (data.avatar !== undefined) { user.avatar = String(data.avatar || '').trim(); diff --git a/src/modules/qqbot/core/contract/qqbot.types.ts b/src/modules/qqbot/core/contract/qqbot.types.ts index 5e64479..7dbe48e 100644 --- a/src/modules/qqbot/core/contract/qqbot.types.ts +++ b/src/modules/qqbot/core/contract/qqbot.types.ts @@ -49,6 +49,7 @@ export type NapcatQrcode = { }; export type NapcatRestartOptions = { + processOnly?: boolean; waitForReady?: boolean; }; @@ -337,12 +338,16 @@ export type QqbotLoginScanSession = { expectedSelfId?: string; id: string; lastCaptchaLookupAt?: number; + loginSelfIdMissingSince?: number; + lastQrcodeRefreshAt?: number; lastRestartedAt?: number; + loginPasswordAvailable?: boolean; mode: QqbotLoginScanMode; newDeviceBytesToken?: string; newDevicePullQrCodeSig?: unknown; newDeviceQrcode?: string; newDeviceStatus?: QqbotLoginNewDeviceStatus; + onlineSourceWorkerRestartAttempted?: boolean; passwordMd5?: string; preparingContainer?: boolean; preparingRelogin?: boolean; 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 cfe26ad..3da00d1 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 @@ -176,8 +176,15 @@ export class QqbotNapcatLoginService { async startRefresh(accountId: string) { const activeSession = this.findActiveRefreshSession(accountId); if (activeSession) { - if (activeSession.qrcode) return this.refreshQrcode(activeSession.id); - return this.toResult(activeSession); + if ( + !(await this.invalidatePasswordlessRefreshSessionWhenPasswordExists( + accountId, + activeSession, + )) + ) { + if (activeSession.qrcode) return this.refreshQrcode(activeSession.id); + return this.toResult(activeSession); + } } const runningTask = this.refreshStartTasks[accountId]; @@ -216,6 +223,7 @@ export class QqbotNapcatLoginService { expectedSelfId: string; forceRelogin: true; hasExistingPrimaryBinding?: boolean; + loginPasswordAvailable?: boolean; loginPassword?: string; mode: 'refresh'; sourceContainerOnline?: boolean; @@ -224,6 +232,7 @@ export class QqbotNapcatLoginService { expectedSelfId: account.selfId, forceRelogin: true, hasExistingPrimaryBinding: container.hasExistingPrimaryBinding, + loginPasswordAvailable: !!this.toolsService.toSecretText(loginPassword), loginPassword, mode: 'refresh', }; @@ -256,6 +265,80 @@ export class QqbotNapcatLoginService { return activeSession; } + /** + * 当账号后续维护了 QQ 登录密码时,废弃旧的无密码更新登录会话。 + * @param accountId - 正在更新登录态的账号 ID,用于重新读取账号表里的最新登录密码密文。 + * @param session - 可能早于密码维护动作创建的 pending 更新登录会话。 + * @returns 旧会话已退役且调用方应重新创建更新登录会话时返回 true。 + */ + private async invalidatePasswordlessRefreshSessionWhenPasswordExists( + accountId: string, + session: QqbotLoginScanSession, + ) { + if (this.hasRefreshPasswordContext(session)) return false; + + const accountService = this.accountService as Partial< + Pick< + QqbotAccountService, + 'findByIdWithNapcatLoginSecret' | 'getNapcatLoginPassword' + > + >; + if ( + !accountService.findByIdWithNapcatLoginSecret || + !accountService.getNapcatLoginPassword + ) { + return false; + } + + const account = + await accountService.findByIdWithNapcatLoginSecret(accountId); + if (!account) return false; + const loginPassword = accountService.getNapcatLoginPassword(account); + if (!this.toolsService.toSecretText(loginPassword)) return false; + + await this.retireRefreshSessionForPasswordReload(session); + return true; + } + + /** + * 判断更新登录会话是否已经携带或进入过密码登录相关上下文。 + * @param session - 待复用的 pending 更新登录会话。 + * @returns 复用该会话不会忽略账号已维护登录密码时返回 true。 + */ + private hasRefreshPasswordContext(session: QqbotLoginScanSession) { + return !!( + session.loginPasswordAvailable || + session.passwordMd5 || + session.captchaUrl || + session.deviceVerifyUrl || + session.newDeviceQrcode || + session.newDeviceStatus + ); + } + + /** + * 仅退役过期的更新登录会话记录,不移除已绑定的 NapCat 容器。 + * @param session - 不应继续提供旧二维码或旧 pending 状态的无密码更新登录会话。 + */ + private async retireRefreshSessionForPasswordReload( + session: QqbotLoginScanSession, + ) { + session.errorMessage = '账号登录密码已更新,重新创建更新登录会话'; + session.preparingRelogin = false; + session.qrcode = undefined; + session.status = 'expired'; + this.persistLoginSession(session); + this.publishScanResultEvent( + session, + 'session-recreated', + 'processing', + session.errorMessage, + ); + await this.loginSessionStore.flushSessionWrites(session.id); + this.loginSessionStore.delete(session.id); + this.cleanupSessionEvents(session.id); + } + /** * 执行 NapCat 登录运行态流程。 * @param sessionId - NapCat ID;定位本次读取、更新、删除或关联的NapCat。 @@ -303,7 +386,16 @@ export class QqbotNapcatLoginService { await this.syncSessionQqLoginStatus(session, loginStatus); } - if (loginStatus.isOffline && session.mode !== 'refresh') { + if ( + loginStatus.isOffline && + this.shouldRestartNapcatWorkerForOnlineRefresh(session) + ) { + loginStatus = await this.restartNapcatWorkerForOnlineRefresh( + session, + container, + loginStatus.loginError || 'NapCat 账号已离线,正在重启登录服务', + ); + } else if (loginStatus.isOffline && session.mode !== 'refresh') { await this.restartNapcatForLogin(container, { waitForReady: false }); session.lastRestartedAt = Date.now(); return this.keepSessionPending( @@ -435,10 +527,29 @@ export class QqbotNapcatLoginService { ); } + if ( + status.isOffline && + this.shouldRestartNapcatWorkerForOnlineRefresh(session) + ) { + status = await this.restartNapcatWorkerForOnlineRefresh( + session, + container, + status.loginError || 'NapCat 账号已离线,正在重启登录服务', + ); + if (status.isLogin) { + return this.completeLogin(session, container); + } + await this.syncSessionQqLoginStatus(session, status); + } + if (this.shouldRefreshNearlyExpiredQrcode(status)) { return this.refreshNearlyExpiredQrcode(session, container, status); } + if (this.shouldAutoRefreshPendingQrcode(session, status)) { + return this.refreshPendingQrcodeFromStatus(session, container, status); + } + session.errorMessage = status.loginError || undefined; if ( status.qrcodeurl && @@ -594,6 +705,7 @@ export class QqbotNapcatLoginService { step: 'session-cancelled', }); this.loginSessionStore.delete(sessionId); + await this.loginSessionStore.flushSessionWrites(sessionId); await this.cleanupSessionContainer(session); this.cleanupSessionEvents(sessionId); } @@ -612,6 +724,7 @@ export class QqbotNapcatLoginService { expectedSelfId?: string; forceRelogin?: boolean; hasExistingPrimaryBinding?: boolean; + loginPasswordAvailable?: boolean; loginPassword?: string; mode: QqbotLoginScanMode; sourceContainerOnline?: boolean; @@ -859,8 +972,9 @@ export class QqbotNapcatLoginService { const selfId = this.toolsService.pickNapcatSelfId(loginInfo); if (!selfId) { - return this.failSession(session, 'NapCat 已登录但未返回 QQ 号'); + return this.keepLoginSelfIdPending(session); } + session.loginSelfIdMissingSince = undefined; if (session.expectedSelfId && session.expectedSelfId !== selfId) { return this.failSession( session, @@ -910,6 +1024,7 @@ export class QqbotNapcatLoginService { container: QqbotNapcatRuntime; expectedSelfId?: string; mode: QqbotLoginScanMode; + loginPasswordAvailable?: boolean; preparingContainer?: boolean; preparingRelogin?: boolean; qrcode?: string; @@ -926,6 +1041,7 @@ export class QqbotNapcatLoginService { expectedSelfId: input.expectedSelfId, expiresAt: now + this.getSessionTtlMs(), id: randomUUID(), + loginPasswordAvailable: input.loginPasswordAvailable, mode: input.mode, preparingContainer: input.preparingContainer, preparingRelogin: input.preparingRelogin, @@ -2074,6 +2190,39 @@ export class QqbotNapcatLoginService { return this.toResult(session); } + /** + * Keeps a login-positive session alive while NapCat finishes exposing the logged-in QQ number. + * @param session - Scan session whose WebUI status is already logged in but whose `GetQQLoginInfo` payload lacks `uin`/`selfId`. + * @returns Pending result during the bounded wait window, or a terminal failure once the missing-self-id window is exhausted. + */ + private async keepLoginSelfIdPending(session: QqbotLoginScanSession) { + const now = Date.now(); + session.loginSelfIdMissingSince ??= now; + if (now - session.loginSelfIdMissingSince > this.getLoginSelfIdWaitMs()) { + return this.failSession(session, 'NapCat 已登录但未返回 QQ 号'); + } + + const message = 'NapCat 已登录,正在读取 QQ 号'; + const shouldPublish = session.errorMessage !== message; + session.status = 'pending'; + session.captchaUrl = undefined; + session.errorMessage = message; + session.preparingContainer = false; + session.preparingRelogin = false; + session.qrcode = undefined; + this.renewSessionExpiry(session); + this.persistLoginSession(session); + if (shouldPublish) { + this.publishScanResultEvent( + session, + 'login-self-id-wait', + 'processing', + message, + ); + } + return this.toResult(session); + } + /** * Stops delayed background login work from mutating a pending session after it expired or was replaced. * @param session - Pending scan session captured by an async relogin task; its cache identity and TTL decide whether writes are still valid. @@ -2105,6 +2254,8 @@ export class QqbotNapcatLoginService { session.errorMessage = errorMessage; session.passwordMd5 = undefined; session.preparingRelogin = false; + this.persistLoginSession(session); + await this.loginSessionStore.flushSessionWrites(session.id); this.publishScanResultEvent(session, 'login-error', 'error', errorMessage); this.loginSessionStore.delete(session.id); await this.closeSession(session); @@ -2348,6 +2499,82 @@ export class QqbotNapcatLoginService { return ageMs >= this.getNativeQrcodeTtlMs() - this.getQrcodeSafeScanMs(); } + /** + * Decides whether a pending scan status poll should actively ask NapCat for a QR again. + * @param session - Pending scan session; challenge states and recent refresh attempts suppress automatic retries. + * @param status - Latest WebUI status; existing, expired, or successful QR/login states are handled elsewhere. + * @returns True when the status poll should call the same-container RefreshQRcode path once per cooldown window. + */ + private shouldAutoRefreshPendingQrcode( + session: QqbotLoginScanSession, + status: NapcatLoginStatus, + ) { + if (session.preparingContainer || session.preparingRelogin) return false; + if (session.captchaUrl || session.newDeviceStatus) return false; + if (session.qrcode || status.qrcodeurl || status.isLogin) return false; + if (this.toolsService.isNapcatExpiredQrcodeStatus(status)) return false; + + const lastRefreshAt = Number(session.lastQrcodeRefreshAt || 0); + if (!Number.isFinite(lastRefreshAt) || lastRefreshAt <= 0) return true; + return Date.now() - lastRefreshAt >= this.getQrcodeAutoRefreshCooldownMs(); + } + + /** + * Requests a fresh QR during status polling so SSE can recover from a previous accepted-but-not-updated refresh. + * @param session - Pending scan session whose result is returned to Admin/SSE. + * @param container - Current NapCat WebUI runtime; the method never rebuilds or restarts it. + * @param status - Latest WebUI status used as fallback metadata for QR freshness checks. + * @returns Updated scan result, either with a fresh QR or a bounded pending message. + */ + private async refreshPendingQrcodeFromStatus( + session: QqbotLoginScanSession, + container: QqbotNapcatRuntime, + status: NapcatLoginStatus, + ) { + session.lastQrcodeRefreshAt = Date.now(); + this.persistLoginSession(session); + this.publishScanResultEvent( + session, + 'qrcode-fetch', + 'processing', + '正在重新生成登录二维码', + ); + + try { + session.qrcode = await this.refreshOrGetQrcode(container, false, { + fallbackStatus: status, + requireFresh: + session.mode === 'refresh' || + !!session.qrcode || + this.toolsService.isNapcatExpiredQrcodeStatus(status), + staleQrcode: session.qrcode || status.qrcodeurl, + }); + session.errorMessage = undefined; + session.expiresAt = Date.now() + this.getSessionTtlMs(); + this.persistLoginSession(session); + this.publishScanResultEvent( + session, + 'qrcode-ready', + 'success', + '登录二维码已生成', + ); + this.publishScanResultEvent( + session, + 'waiting-scan', + 'processing', + '等待扫码确认', + ); + return this.toResult(session); + } catch (err) { + if (!this.toolsService.isNapcatTemporaryError(err)) throw err; + session.qrcode = undefined; + session.errorMessage = + 'NapCat 正在重新生成二维码,请稍后刷新或等待自动更新'; + this.persistLoginSession(session); + return this.toResult(session); + } + } + /** * Reads the expected native QQ QR lifetime used only for safe-display decisions. * @returns Milliseconds before a QR is considered too old to show without refreshing. @@ -2370,6 +2597,28 @@ export class QqbotNapcatLoginService { ); } + /** + * Reads the cooldown between automatic QR refresh attempts during status polling. + * @returns Milliseconds to wait before SSE/status may request another QR regeneration. + */ + private getQrcodeAutoRefreshCooldownMs() { + return this.getPositiveConfigNumber( + 'NAPCAT_LOGIN_QR_AUTO_REFRESH_COOLDOWN_MS', + Math.max(5000, this.getLoginPollIntervalMs() * 2), + ); + } + + /** + * Reads the bounded wait window for NapCat to expose QQ number after WebUI already reports login-positive. + * @returns Milliseconds allowed for `GetQQLoginInfo` to start returning `uin`/`selfId` before treating the state as inconsistent. + */ + private getLoginSelfIdWaitMs() { + return this.getPositiveConfigNumber( + 'NAPCAT_LOGIN_SELF_ID_WAIT_MS', + 30_000, + ); + } + /** * 查询 NapCat 登录运行态数据。 * @param container - container 输入;限定 NapCat查询范围。 @@ -2632,6 +2881,17 @@ export class QqbotNapcatLoginService { container: QqbotNapcatRuntime, ) { const loginStatus = await this.getLoginStatus(container, true); + if ( + loginStatus.isOffline && + this.shouldRestartNapcatWorkerForOnlineRefresh(session) + ) { + await this.restartNapcatWorkerForOnlineRefresh( + session, + container, + loginStatus.loginError || 'NapCat 账号已离线,正在重启登录服务', + ); + return false; + } if (!loginStatus.isLogin) return false; const loginInfo = await this.getLoginInfo(container); @@ -2750,6 +3010,50 @@ export class QqbotNapcatLoginService { return true; } + /** + * Decides whether this refresh session may spend its single same-container worker restart budget. + * @param session - Login refresh session; its source-runtime flag proves the managed runtime is already alive and the attempt flag prevents restart storms. + * @returns True only before the first NapCat worker restart attempt in this online-source refresh session. + */ + private shouldRestartNapcatWorkerForOnlineRefresh( + session: QqbotLoginScanSession, + ) { + return ( + session.mode === 'refresh' && + session.sourceContainerOnline === true && + session.onlineSourceWorkerRestartAttempted !== true + ); + } + + /** + * Restarts only the NapCat worker when the managed runtime is alive but QQCore login service is stale. + * @param session - Refresh login session that owns progress messages and retry timestamps. + * @param container - Current online WebUI runtime; its device identity and environment must be preserved. + * @param reason - Latest QQ login-state evidence shown to Admin before the worker restart. + * @returns Fresh WebUI login status after the worker restart completes. + */ + private async restartNapcatWorkerForOnlineRefresh( + session: QqbotLoginScanSession, + container: QqbotNapcatRuntime, + reason: string, + ) { + session.lastRestartedAt = Date.now(); + session.onlineSourceWorkerRestartAttempted = true; + session.errorMessage = reason; + this.persistLoginSession(session); + this.publishScanResultEvent( + session, + 'napcat-worker-restart', + 'processing', + reason, + ); + await this.restartNapcatForLogin(container, { + processOnly: true, + waitForReady: true, + }); + return this.getLoginStatus(container, true); + } + /** * 在 NapCat WebUI 拒绝重复 quick 登录时读取真实 QQ 在线态。 * @param session - 当前更新登录会话;成功时直接完成会话,失败时写入 fallback 进度。 @@ -3300,8 +3604,9 @@ export class QqbotNapcatLoginService { container: QqbotNapcatRuntime, options: NapcatRestartOptions = {}, ) { - const restartedByContainer = - await this.containerService.restartRuntimeContainer(container); + const restartedByContainer = options.processOnly + ? false + : await this.containerService.restartRuntimeContainer(container); if (!restartedByContainer) { try { await this.postNapcat | null>( diff --git a/src/modules/qqbot/napcat/application/runtime/napcat-config-writer.service.ts b/src/modules/qqbot/napcat/application/runtime/napcat-config-writer.service.ts index b263973..903ce69 100644 --- a/src/modules/qqbot/napcat/application/runtime/napcat-config-writer.service.ts +++ b/src/modules/qqbot/napcat/application/runtime/napcat-config-writer.service.ts @@ -29,14 +29,14 @@ type OnebotConfig = { type NapcatConfig = { bypass: { - container: false; - hook: false; - js: false; - module: false; - process: false; - window: false; + container: true; + hook: true; + js: true; + module: true; + process: true; + window: true; }; - o3HookMode: 1; + o3HookMode: 0; packetBackend: 'auto'; packetServer: ''; }; @@ -68,14 +68,14 @@ export class NapcatConfigWriterService { }; const napcatConfig: NapcatConfig = { bypass: { - container: false, - hook: false, - js: false, - module: false, - process: false, - window: false, + container: true, + hook: true, + js: true, + module: true, + process: true, + window: true, }, - o3HookMode: 1, + o3HookMode: 0, packetBackend: 'auto', packetServer: '', }; 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 9dba3bd..b771d82 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 @@ -71,7 +71,7 @@ export class NapcatRuntimeProfileService { dataDir: input.dataDir, desktopProfileVersion: this.getString( 'QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION', - 'desktop-cn-v8', + 'desktop-cn-v20', ), deviceIdentityId: input.deviceIdentityId, imageRef: this.getString('QQBOT_NAPCAT_IMAGE', ''), @@ -162,7 +162,7 @@ export class NapcatRuntimeProfileService { napcatConfigHash: input.protocolProfile.napcatConfigHash || null, napcatConfigJson: input.protocolProfile.napcatConfigJson || null, o3HookGrayEnabled: !!input.protocolProfile.o3HookGrayEnabled, - o3HookMode: input.protocolProfile.o3HookMode ?? 1, + o3HookMode: input.protocolProfile.o3HookMode ?? 0, onebotConfigHash: input.protocolProfile.onebotConfigHash || null, onebotConfigJson: input.protocolProfile.onebotConfigJson || null, packetBackend: input.protocolProfile.packetBackend || 'auto', 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 67db6c9..86337b3 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 @@ -49,12 +49,29 @@ type CreateManagedContainerOptions = { startRemote?: boolean; }; +type NapcatWebuiCredentialCacheEntry = { + credential: string; + expiresAt: number; +}; + +const NAPCAT_WEBUI_CREDENTIAL_TTL_MS = 50 * 60 * 1000; + @Injectable() export class QqbotNapcatContainerService { private readonly configWriterService: NapcatConfigWriterService; private readonly runtimeProfileService: NapcatRuntimeProfileService; + private readonly webuiCredentials: Record< + string, + NapcatWebuiCredentialCacheEntry | undefined + > = {}; + + private readonly webuiCredentialRequests: Record< + string, + Promise | undefined + > = {}; + /** * 初始化 QqbotNapcatContainerService 实例。 * @param configService - Runtime configuration source for NAS SSH, Docker image, port pool, and profile defaults. @@ -894,13 +911,7 @@ docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$NAME" try { const runtime = this.toRuntime(container); - const credential = await this.getNapcatCredential(runtime); - const status = await this.requestNapcat( - runtime, - '/api/QQLogin/CheckLoginStatus', - {}, - credential, - ); + const status = await this.requestNapcatLoginStatus(runtime); const snapshot = this.toRuntimeStatusSnapshot( status, containerOnline, @@ -1427,6 +1438,31 @@ fi `DEVICE_ENV_PATH=${this.sh(input.deviceIdentity.deviceEnvPath)}`, ].join('\n') + '\n' : ''; + const deviceProfileHeader = [ + `NAPCAT_DMI_PRODUCT_NAME=${this.sh('imini Pro')}`, + `NAPCAT_DMI_SYS_VENDOR=${this.sh('MECHREVO')}`, + `NAPCAT_DMI_BOARD_VENDOR=${this.sh('MECHREVO')}`, + `NAPCAT_DMI_BOARD_NAME=${this.sh('imini Pro')}`, + `NAPCAT_DMI_BIOS_VENDOR=${this.sh( + 'American Megatrends International, LLC.', + )}`, + `NAPCAT_DMI_BIOS_VERSION=${this.sh('imini Pro 1.10')}`, + `NAPCAT_DMI_MODALIAS=${this.sh( + 'dmi:bvnAmericanMegatrendsInternational,LLC.:bvriminiPro1.10:bd03/31/2024:br1.10:efr1.10:svnMECHREVO:pniminiPro:pvrStandard:rvnMECHREVO:rniminiPro:rvrStandard:cvnMECHREVO:ct3:cvrDefaultstring:skuStandard:', + )}`, + `NAPCAT_DEVICE_KERNEL_RELEASE=${this.sh('6.8.0-60-generic')}`, + `NAPCAT_DEVICE_KERNEL_VERSION=${this.sh( + '#63-Ubuntu SMP PREEMPT_DYNAMIC Tue Apr 15 19:04:15 UTC 2025', + )}`, + `NAPCAT_DEVICE_PROC_VERSION=${this.sh( + 'Linux version 6.8.0-60-generic (buildd@lcy02-amd64-001) (x86_64-linux-gnu-gcc (Ubuntu 13.3.0-6ubuntu2~24.04) 13.3.0, GNU ld (GNU Binutils for Ubuntu) 2.42) #63-Ubuntu SMP PREEMPT_DYNAMIC Tue Apr 15 19:04:15 UTC 2025', + )}`, + `NAPCAT_DEVICE_CPU_MODEL=${this.sh( + 'AMD Ryzen 7 8845H w/ Radeon 780M Graphics', + )}`, + `NAPCAT_DEVICE_UPTIME=${this.sh('7200.00 14400.00')}`, + `NAPCAT_DEVICE_TTY_ACTIVE=${this.sh('tty1')}`, + ].join('\n'); const devicePrepareScript = input.deviceIdentity ? ` mkdir -p "$(dirname "$DEVICE_ENV_PATH")" "$(dirname "$MACHINE_INFO_PATH")" @@ -1453,9 +1489,33 @@ mv "$MACHINE_INFO_TMP" "$MACHINE_INFO_PATH" chmod 644 "$MACHINE_INFO_PATH" ` : ''; + const deviceProfilePrepareScript = ` +if [ -z "\${MACHINE_ID_PATH:-}" ]; then + MACHINE_ID_PATH="$DATA_DIR/machine-id" +fi +mkdir -p "$(dirname "$MACHINE_ID_PATH")" +if [ ! -s "$MACHINE_ID_PATH" ]; then + printf '%s' "$NAME" | sha256sum | cut -c 1-32 > "$MACHINE_ID_PATH" +fi +NAPCAT_DEVICE_MACHINE_ID="$(tr -d '\\r\\n' < "$MACHINE_ID_PATH" | cut -c 1-64)" +format_uuid_from_seed() { + uuid_hash="$(printf '%s' "$1" | sha256sum | awk '{print $1}')" + printf '%s-%s-%s-%s-%s' \\ + "$(printf '%s' "$uuid_hash" | cut -c 1-8)" \\ + "$(printf '%s' "$uuid_hash" | cut -c 9-12)" \\ + "$(printf '%s' "$uuid_hash" | cut -c 13-16)" \\ + "$(printf '%s' "$uuid_hash" | cut -c 17-20)" \\ + "$(printf '%s' "$uuid_hash" | cut -c 21-32)" +} +if [ ! -s "$DATA_DIR/device-boot-id" ]; then + format_uuid_from_seed "$NAPCAT_DEVICE_MACHINE_ID:boot" > "$DATA_DIR/device-boot-id" +fi +NAPCAT_DEVICE_BOOT_ID="$(tr -d '\\r\\n' < "$DATA_DIR/device-boot-id" | cut -c 1-36)" +NAPCAT_DMI_PRODUCT_UUID="$(format_uuid_from_seed "$NAPCAT_DEVICE_MACHINE_ID:dmi")" +`; const deviceRunFlags = input.deviceIdentity ? ' --hostname "$NAPCAT_HOSTNAME" \\\n --mac-address "$NAPCAT_MAC_ADDRESS" \\\n -v "$MACHINE_ID_PATH:/etc/machine-id:ro" \\\n' - : ''; + : ' -v "$MACHINE_ID_PATH:/etc/machine-id:ro" \\\n'; return ` set -eu @@ -1471,10 +1531,12 @@ NAPCAT_SHM_SIZE=${this.sh(runtimeProfile.shmSize)} ${accountHeader} ${passwordHeader} ${deviceHeader} +${deviceProfileHeader} mkdir -p "$DATA_DIR/QQ" "$DATA_DIR/config" "$DATA_DIR/plugins" "$DATA_DIR/logs" "$DATA_DIR/cache" "$DATA_DIR/local-share" "$DATA_DIR/runtime" chmod 700 "$DATA_DIR" chmod 700 "$DATA_DIR/runtime" ${devicePrepareScript} +${deviceProfilePrepareScript} ${configWriteScript} @@ -1483,9 +1545,29 @@ docker run -d \\ --name "$NAME" \\ --restart unless-stopped \\ --init \\ + --cap-add SYS_ADMIN \\ + --security-opt apparmor=unconfined \\ + --security-opt seccomp=unconfined \\ --shm-size "$NAPCAT_SHM_SIZE" \\ -e NAPCAT_UID="$NAPCAT_UID" \\ -e NAPCAT_GID="$NAPCAT_GID" \\ + -e NAPCAT_REQUIRE_DEVICE_PROFILE=1 \\ + -e NAPCAT_DEVICE_MACHINE_ID="$NAPCAT_DEVICE_MACHINE_ID" \\ + -e NAPCAT_DEVICE_BOOT_ID="$NAPCAT_DEVICE_BOOT_ID" \\ + -e NAPCAT_DEVICE_KERNEL_RELEASE="$NAPCAT_DEVICE_KERNEL_RELEASE" \\ + -e NAPCAT_DEVICE_KERNEL_VERSION="$NAPCAT_DEVICE_KERNEL_VERSION" \\ + -e NAPCAT_DEVICE_PROC_VERSION="$NAPCAT_DEVICE_PROC_VERSION" \\ + -e NAPCAT_DEVICE_CPU_MODEL="$NAPCAT_DEVICE_CPU_MODEL" \\ + -e NAPCAT_DEVICE_UPTIME="$NAPCAT_DEVICE_UPTIME" \\ + -e NAPCAT_DEVICE_TTY_ACTIVE="$NAPCAT_DEVICE_TTY_ACTIVE" \\ + -e NAPCAT_DMI_PRODUCT_NAME="$NAPCAT_DMI_PRODUCT_NAME" \\ + -e NAPCAT_DMI_PRODUCT_UUID="$NAPCAT_DMI_PRODUCT_UUID" \\ + -e NAPCAT_DMI_SYS_VENDOR="$NAPCAT_DMI_SYS_VENDOR" \\ + -e NAPCAT_DMI_BOARD_VENDOR="$NAPCAT_DMI_BOARD_VENDOR" \\ + -e NAPCAT_DMI_BOARD_NAME="$NAPCAT_DMI_BOARD_NAME" \\ + -e NAPCAT_DMI_BIOS_VENDOR="$NAPCAT_DMI_BIOS_VENDOR" \\ + -e NAPCAT_DMI_BIOS_VERSION="$NAPCAT_DMI_BIOS_VERSION" \\ + -e NAPCAT_DMI_MODALIAS="$NAPCAT_DMI_MODALIAS" \\ -e WEBUI_TOKEN="$WEBUI_TOKEN" \\ -e LANG=${runtimeProfile.locale} \\ -e LC_ALL=${runtimeProfile.locale} \\ @@ -1693,9 +1775,40 @@ ${file.content}EOF`; /** * 查询 NapCat 登录运行态数据。 - * @param runtime - runtime 输入;使用 `webuiToken` 字段生成结果。 + * @param runtime - NapCat runtime;使用容器身份、WebUI 地址和 token 边界复用短期 Credential。 + * @returns 可用于 NapCat WebUI Bearer 鉴权的 Credential。 */ private async getNapcatCredential(runtime: QqbotNapcatRuntime) { + const cacheKey = this.getNapcatCredentialCacheKey(runtime); + const cached = this.webuiCredentials[cacheKey]; + if (cached && Date.now() < cached.expiresAt) { + return cached.credential; + } + + const pending = this.webuiCredentialRequests[cacheKey]; + if (pending) { + return pending; + } + + const request = this.fetchNapcatCredential(runtime, cacheKey); + this.webuiCredentialRequests[cacheKey] = request; + try { + return await request; + } finally { + delete this.webuiCredentialRequests[cacheKey]; + } + } + + /** + * 从 NapCat WebUI 换取并缓存新的 Bearer Credential。 + * @param runtime - NapCat runtime;提供 WebUI 地址和 token 以调用 `/api/auth/login`。 + * @param cacheKey - 已由调用方按容器身份和 token 生成的缓存键,用于落入同一 single-flight 槽位。 + * @returns 可用于后续 NapCat WebUI 请求的 Credential。 + */ + private async fetchNapcatCredential( + runtime: QqbotNapcatRuntime, + cacheKey: string, + ) { const token = runtime.webuiToken || ''; const hash = createHash('sha256').update(`${token}.napcat`).digest('hex'); const data = await this.requestNapcat( @@ -1706,9 +1819,78 @@ ${file.content}EOF`; if (!data.Credential) { throwVbenError('NapCat WebUI 登录失败'); } + this.webuiCredentials[cacheKey] = { + credential: data.Credential, + expiresAt: Date.now() + NAPCAT_WEBUI_CREDENTIAL_TTL_MS, + }; return data.Credential; } + /** + * 请求 NapCat QQ 登录状态,并在 WebUI Credential 失效时刷新一次。 + * @param runtime - NapCat runtime;提供 WebUI 地址、token 和容器身份以完成鉴权与状态读取。 + * @returns NapCat WebUI 返回的 QQ 登录状态。 + */ + private async requestNapcatLoginStatus(runtime: QqbotNapcatRuntime) { + const credential = await this.getNapcatCredential(runtime); + try { + return await this.requestNapcat( + runtime, + '/api/QQLogin/CheckLoginStatus', + {}, + credential, + ); + } catch (err) { + if (!this.isNapcatCredentialRejected(err)) { + throw err; + } + this.clearNapcatCredential(runtime, credential); + const refreshedCredential = await this.getNapcatCredential(runtime); + return this.requestNapcat( + runtime, + '/api/QQLogin/CheckLoginStatus', + {}, + refreshedCredential, + ); + } + } + + /** + * 清理当前容器的 NapCat WebUI Credential 缓存。 + * @param runtime - NapCat runtime;`id/baseUrl/token` 决定需要失效的进程内缓存条目。 + * @param rejectedCredential - NapCat 刚拒绝的 Credential;有值时只清理仍等于它的缓存,避免旧请求删除已刷新的新缓存。 + */ + private clearNapcatCredential( + runtime: QqbotNapcatRuntime, + rejectedCredential?: string, + ) { + const cacheKey = this.getNapcatCredentialCacheKey(runtime); + const cached = this.webuiCredentials[cacheKey]; + if (rejectedCredential && cached?.credential !== rejectedCredential) { + return; + } + delete this.webuiCredentials[cacheKey]; + } + + /** + * 判断 NapCat WebUI 是否拒绝了当前 Credential。 + * @param err - NapCat 请求异常;来源可能是 WebUI `Unauthorized` 或旧 token 失效提示。 + * @returns 为 true 时调用方可安全刷新一次 Credential 后重试状态请求。 + */ + private isNapcatCredentialRejected(err: unknown) { + const message = this.toolsService.getErrorMessage(err); + return /Unauthorized|token is invalid/i.test(message); + } + + /** + * 生成 NapCat WebUI Credential 缓存键。 + * @param runtime - NapCat runtime;`id/baseUrl` 定位容器实例,`webuiToken` 区分重建或换密钥后的鉴权边界。 + * @returns 当前 API 进程内 credential 缓存使用的稳定键。 + */ + private getNapcatCredentialCacheKey(runtime: QqbotNapcatRuntime) { + return [runtime.id || runtime.baseUrl, runtime.webuiToken || ''].join('\n'); + } + /** * 执行 NapCat 登录运行态流程。 * @param status - NapCat列表;使用 `loginError` 字段生成结果。 diff --git a/src/modules/qqbot/napcat/infrastructure/persistence/napcat-login-state-store.service.ts b/src/modules/qqbot/napcat/infrastructure/persistence/napcat-login-state-store.service.ts index 2d7e3f7..80e361c 100644 --- a/src/modules/qqbot/napcat/infrastructure/persistence/napcat-login-state-store.service.ts +++ b/src/modules/qqbot/napcat/infrastructure/persistence/napcat-login-state-store.service.ts @@ -299,12 +299,33 @@ export class NapcatLoginStateStoreService { const current = await this.loginSessionRepository.findOne({ where: { sessionKey: sessionId }, }); - if (!current || current.status === 'pending') return; + if (!current) return; + + const completedAt = new Date() as NapcatLoginSession['completedAt']; + if (current.status === 'pending') { + await this.loginSessionRepository.update( + { sessionKey: sessionId }, + { + completedAt, + loginStage: 'cancelled', + progressMessage: '扫码会话已取消', + sessionPayload: current.sessionPayload + ? { + ...current.sessionPayload, + errorMessage: '扫码会话已取消', + status: 'error', + } + : current.sessionPayload, + status: 'error', + }, + ); + return; + } await this.loginSessionRepository.update( { sessionKey: sessionId }, { - completedAt: new Date() as NapcatLoginSession['completedAt'], + completedAt, }, ); } diff --git a/test/admin/admin-menu.service.spec.ts b/test/admin/admin-menu.service.spec.ts index e02e410..59161a5 100644 --- a/test/admin/admin-menu.service.spec.ts +++ b/test/admin/admin-menu.service.spec.ts @@ -65,6 +65,75 @@ describe('AdminMenuService', () => { expect.arrayContaining(['Blog:Theme:List', 'Blog:Theme:Import']), ); }); + + it('returns and orders menus by the persisted sort field', async () => { + const service = new AdminMenuService({ + find: jest.fn(async () => [ + createMenu({ + id: '2', + meta: { title: 'Second' }, + name: 'Second', + path: '/second', + sort: 20, + type: 'menu', + }), + createMenu({ + id: '1', + meta: { title: 'First' }, + name: 'First', + path: '/first', + sort: 10, + type: 'menu', + }), + ]), + } as any); + const user = { + roles: [ + { + isDeleted: false, + roleCode: 'super', + status: 1, + }, + ], + } as AdminUser; + + await expect(service.getRouteMenus(user)).resolves.toEqual([ + expect.objectContaining({ name: 'First', sort: 10 }), + expect.objectContaining({ name: 'Second', sort: 20 }), + ]); + }); + + it('persists menu sort values from create and update inputs', async () => { + const repository = { + create: jest.fn((input) => input), + save: jest.fn(), + update: jest.fn(), + }; + const service = new AdminMenuService(repository as any); + + await service.createMenu({ + name: 'SortedMenu', + path: '/sorted', + sort: 8, + type: 'menu', + } as any); + await service.updateMenu('menu-1', { + name: 'SortedMenu', + path: '/sorted', + sort: 9, + type: 'menu', + } as any); + + expect(repository.create).toHaveBeenCalledWith( + expect.objectContaining({ sort: 8 }), + ); + expect(repository.update).toHaveBeenCalledWith( + { + id: 'menu-1', + }, + expect.objectContaining({ sort: 9 }), + ); + }); }); /** diff --git a/test/admin/admin-user-avatar-sql.spec.ts b/test/admin/admin-user-avatar-sql.spec.ts index 887581b..d483b15 100644 --- a/test/admin/admin-user-avatar-sql.spec.ts +++ b/test/admin/admin-user-avatar-sql.spec.ts @@ -10,6 +10,10 @@ describe('admin user avatar sql', () => { join(process.cwd(), 'sql/vben-admin-init.sql'), 'utf8', ); + const refactorSeedSql = readFileSync( + join(process.cwd(), 'sql/refactor-v3/01-seed-core.sql'), + 'utf8', + ); it('adds avatar column for existing databases', () => { expect(avatarSql).toContain("COLUMN_NAME = 'avatar'"); @@ -25,4 +29,17 @@ describe('admin user avatar sql', () => { ); expect(initSql).not.toContain('`avatar` = VALUES(`avatar`)'); }); + + it('seeds the renamed administrator and removes the fake workspace menu', () => { + expect(initSql).toContain("'kwitsukasa', '123456', 'KwiTsukasa'"); + expect(initSql).toContain("'/analytics', 'Asia/Shanghai'"); + expect(initSql).toContain("'Analytics', '/analytics'"); + expect(initSql).not.toContain("'vben', '123456'"); + expect(initSql).not.toContain("'jack', '123456'"); + expect(initSql).not.toContain("'Workspace'"); + expect(initSql).not.toContain("'/workspace'"); + expect(initSql).toContain('`username` = VALUES(`username`)'); + expect(initSql).not.toContain('`password` = VALUES(`password`)'); + expect(refactorSeedSql).not.toContain('password = VALUES(password)'); + }); }); diff --git a/test/admin/admin-user-profile.service.spec.ts b/test/admin/admin-user-profile.service.spec.ts index b1a02ab..d50e942 100644 --- a/test/admin/admin-user-profile.service.spec.ts +++ b/test/admin/admin-user-profile.service.spec.ts @@ -2,8 +2,10 @@ import { AdminUserService } from '../../src/modules/admin/identity/user/admin-us describe('AdminUserService profile', () => { const userRepository = { + create: jest.fn((input) => ({ ...input })), findOne: jest.fn(), save: jest.fn(), + update: jest.fn(), }; const roleRepository = { find: jest.fn(), @@ -22,10 +24,33 @@ describe('AdminUserService profile', () => { jest.clearAllMocks(); }); + it('uses environment overview as the default home path for created users', async () => { + userRepository.findOne.mockResolvedValue(null); + roleRepository.find.mockResolvedValue([]); + + await service.createUser({ + realName: '新用户', + roleIds: [], + username: 'new-user', + }); + + expect(userRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + homePath: '/analytics', + username: 'new-user', + }), + ); + expect(userRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + homePath: '/analytics', + }), + ); + }); + it('updates current profile fields including uploaded avatar url', async () => { const user = { avatar: '', - homePath: '/workspace', + homePath: '/analytics', id: '2041700000000000001', realName: '旧姓名', roles: [], @@ -61,11 +86,51 @@ describe('AdminUserService profile', () => { expect(result).toEqual(user); }); + it('falls back to environment overview when current profile clears home path', async () => { + const user = { + avatar: '', + homePath: '/profile', + id: '2041700000000000002', + realName: 'KwiTsukasa', + roles: [], + timezone: 'Asia/Shanghai', + username: 'kwitsukasa', + }; + userRepository.findOne.mockResolvedValue(user); + + await service.updateCurrentProfile(user.id, { + homePath: '', + }); + + expect(userRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + homePath: '/analytics', + }), + ); + }); + + it('prevents deleting the renamed built-in administrator account', async () => { + userRepository.findOne.mockResolvedValue({ + id: '2041700000000000002', + isDeleted: false, + username: 'kwitsukasa', + }); + + await expect(service.deleteUser('2041700000000000002')).rejects.toMatchObject( + { + response: expect.objectContaining({ + msg: '不能删除内置管理员账号', + }), + }, + ); + expect(userRepository.update).not.toHaveBeenCalled(); + }); + it('serializes avatar and userId for Admin frontend user store', () => { expect( service.serializeUser({ avatar: '/api/minio/download?objectName=avatars%2Favatar.jpg', - homePath: '/workspace', + homePath: '/analytics', id: '2041700000000000001', realName: '管理员', roles: [], @@ -74,7 +139,7 @@ describe('AdminUserService profile', () => { } as any), ).toEqual({ avatar: '/api/minio/download?objectName=avatars%2Favatar.jpg', - homePath: '/workspace', + homePath: '/analytics', id: '2041700000000000001', realName: '管理员', roles: [], diff --git a/test/modules/qqbot/napcat/device-identity.spec.ts b/test/modules/qqbot/napcat/device-identity.spec.ts index c875c92..93d5510 100644 --- a/test/modules/qqbot/napcat/device-identity.spec.ts +++ b/test/modules/qqbot/napcat/device-identity.spec.ts @@ -436,6 +436,15 @@ 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('--cap-add SYS_ADMIN \\'); + expect(createScript).toContain('--security-opt apparmor=unconfined \\'); + expect(createScript).toContain('--security-opt seccomp=unconfined \\'); + expect(createScript).toContain( + '-e NAPCAT_DEVICE_MACHINE_ID="$NAPCAT_DEVICE_MACHINE_ID"', + ); + expect(createScript).toContain( + '-e NAPCAT_DEVICE_CPU_MODEL="$NAPCAT_DEVICE_CPU_MODEL"', + ); expect(createScript).toContain( '-v "$DATA_DIR/runtime:/tmp/runtime-napcat"', ); @@ -518,6 +527,15 @@ 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('--cap-add SYS_ADMIN \\'); + expect(createScript).toContain('--security-opt apparmor=unconfined \\'); + expect(createScript).toContain('--security-opt seccomp=unconfined \\'); + expect(createScript).toContain( + '-e NAPCAT_DMI_PRODUCT_NAME="$NAPCAT_DMI_PRODUCT_NAME"', + ); + expect(createScript).toContain( + '-e NAPCAT_DEVICE_TTY_ACTIVE="$NAPCAT_DEVICE_TTY_ACTIVE"', + ); 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'); diff --git a/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts b/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts index f40fd0c..bddb5b1 100644 --- a/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts +++ b/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts @@ -40,10 +40,110 @@ describe('NapCat Chinese Desktop Runtime image assets', () => { expect(verify).toContain('/.dockerenv'); expect(verify).toContain('wait_for_absent'); expect(verify).toContain('/proc/1/cgroup'); + expect(verify).toContain('/proc/1/cmdline'); + expect(verify).toContain('/proc/1/sched'); + expect(verify).toContain('/proc/1/status'); + expect(verify).toContain('/proc/1/stat'); + expect(verify).toContain('/proc/1/mountinfo'); + expect(verify).toContain('/proc/self/mountinfo'); + expect(verify).toContain('/proc/$kt_mountinfo_pid/mountinfo'); + expect(verify).toContain('/proc/[0-9]*'); + expect(verify).toContain('kt_is_mountinfo_target'); + expect(verify).toContain('kt_target_argv1'); + expect(verify).toContain( + 'overlay|/vol1/docker|docker-init|/docker/containers|napcat-instances|btrfs|/dev/mapper/trim', + ); + expect(verify).not.toContain('*qq*|*QQ*|*napcat*|*NapCat*|*Xvfb*'); + expect(verify).not.toContain('case "$kt_target_cmdline" in'); + expect(verify).toContain('/sys/class/dmi/id/product_name'); + expect(verify).toContain('/sys/class/dmi/id/bios_vendor'); + expect(verify).toContain('/sys/class/dmi/id/bios_version'); + expect(verify).toContain('/etc/hosts'); expect(verify).toContain('XDG_CONFIG_HOME=/app/.config'); expect(verify).toContain('Asia/Shanghai'); }); + it('waits for the mountinfo guard to converge before failing target process leaks', () => { + const verify = readSource('ci/napcat-desktop-cn/verify.sh'); + + expect(verify).toContain('MOUNTINFO_GUARD_TIMEOUT_SECONDS'); + expect(verify).toContain('wait_for_mountinfo_guard_converged'); + expect(verify).toContain('kt_mountinfo_guard_deadline='); + expect(verify).toContain('sleep 1'); + expect(verify).toContain( + 'assert_target_mountinfo_guard_converged "$kt_mountinfo_pid"', + ); + expect(verify).not.toContain( + 'assert_no_mountinfo_host_leak "/proc/$kt_mountinfo_pid/mountinfo"', + ); + }); + + it('patches entrypoint device profile probes that QQCore opens at runtime', () => { + const dockerfile = readSource('ci/napcat-desktop-cn/Dockerfile'); + const entrypointPatch = readSource( + 'ci/napcat-desktop-cn/entrypoint-device-profile.patch.sh', + ); + const source = `${dockerfile}\n${entrypointPatch}`; + + expect(source).toContain('entrypoint-device-profile.patch.sh'); + expect(source).toContain('NAPCAT_REQUIRE_DEVICE_PROFILE'); + expect(source).toContain('NAPCAT_DMI_BIOS_VENDOR'); + expect(source).toContain('NAPCAT_DMI_BIOS_VERSION'); + expect(source).toContain('NAPCAT_DMI_MODALIAS'); + expect(source).toContain('NAPCAT_DEVICE_BOOT_ID'); + expect(source).toContain('NAPCAT_DEVICE_KERNEL_RELEASE'); + expect(source).toContain('NAPCAT_DEVICE_CPU_MODEL'); + expect(source).toContain('NAPCAT_DEVICE_UPTIME'); + expect(source).toContain('NAPCAT_DEVICE_TTY_ACTIVE'); + expect(source).toContain('/proc/uptime'); + expect(source).toContain('/proc/cpuinfo'); + expect(source).toContain('/sys/devices/virtual/tty/tty0/active'); + expect(source).toContain('kt_mountinfo_guard_loop'); + expect(source).toContain('kt_is_mountinfo_target'); + expect(source).toContain('kt_target_argv1'); + expect(source).toContain('KT_FAKE_MOUNTINFO'); + expect(source).toContain('/proc/$kt_mountinfo_pid/mountinfo'); + expect(source).toContain( + 'mount --bind "$KT_FAKE_MOUNTINFO" "$kt_mountinfo"', + ); + expect(source).toContain('mountinfo-host-leak:$kt_mountinfo'); + expect(source).toContain( + 'overlay|/vol1/docker|docker-init|/docker/containers|napcat-instances|btrfs|/dev/mapper/trim', + ); + expect(source).not.toContain( + 'for mfile in /proc/self/mountinfo /proc/1/mountinfo', + ); + expect(source).not.toContain('*qq*|*QQ*|*napcat*|*NapCat*|*Xvfb*'); + expect(source).not.toContain('case "$kt_target_cmdline" in'); + expect(source).toContain('mount --bind "$FAKE_CMDLINE" /proc/1/cmdline'); + expect(source).toContain('kt_require_device_profile'); + expect(source).toContain('exit 78'); + expect(source).toContain('dmi-modalias'); + expect(source).toContain('proc-devices-host-leak'); + }); + + it('preserves API-written NapCat config during the first shell install', () => { + const entrypointPatch = readSource( + 'ci/napcat-desktop-cn/entrypoint-device-profile.patch.sh', + ); + const verify = readSource('ci/napcat-desktop-cn/verify.sh'); + + expect(entrypointPatch).toContain('KT preserve mounted NapCat config'); + expect(entrypointPatch).toContain( + "find NapCat.Shell -mindepth 1 -maxdepth 1 ! -name config", + ); + expect(entrypointPatch).toContain( + 'if [ ! -f \\"napcat/config/napcat.json\\" ]; then', + ); + expect(entrypointPatch).toContain( + 'cp -rf NapCat.Shell/config/* napcat/config/', + ); + expect(verify).toContain('KT preserve mounted NapCat config'); + expect(verify).toContain( + "! grep -q 'cp -rf NapCat.Shell/\\* napcat/' /app/entrypoint.sh", + ); + }); + it('stages source-built NapCat Shell artifacts for Docker build context', () => { const script = readSource('scripts/napcat-desktop-cn-stage-build.mjs'); @@ -56,6 +156,7 @@ describe('NapCat Chinese Desktop Runtime image assets', () => { expect(script).toContain('jenkinsBuildUrl'); expect(script).toContain('packages/napcat-shell/dist'); expect(script).toContain('fork-artifact.json'); + expect(script).toContain('entrypoint-device-profile.patch.sh'); expect(script).toContain('.kt-workspace/napcat-desktop-cn-build'); expect(script).toContain('assertSafeOutputRoot'); expect(script).toContain('workspaceRoot'); @@ -91,20 +192,28 @@ describe('NapCat Chinese Desktop Runtime image assets', () => { expect(verify).toContain('getQQLoginRuntimeState'); expect(verify).toContain('qrcodeRevision'); expect(verify).toContain('needsLoginServiceReset'); - expect(verify).toContain('重置已失效登录服务后重新生成二维码'); expect(verify).not.toContain('selfInfo?.online !== false'); }); - it('deploys the production API with the verified desktop-cn-v8 runtime profile', () => { + it('deploys the production API with the verified desktop-cn-v20 runtime profile', () => { const manifest = readSource('k8s/prod/api.yaml'); expect(manifest).toContain('name: QQBOT_NAPCAT_IMAGE'); - expect(manifest).toContain('value: kt-napcat-desktop-cn:desktop-cn-v8'); + expect(manifest).toContain('value: kt-napcat-desktop-cn:desktop-cn-v20'); expect(manifest).toContain('name: QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION'); - expect(manifest).toContain('value: desktop-cn-v8'); + expect(manifest).toContain('value: desktop-cn-v20'); + expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v19'); + expect(manifest).not.toContain('value: desktop-cn-v19'); + expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v18'); + expect(manifest).not.toContain('value: desktop-cn-v18'); + expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v16'); + expect(manifest).not.toContain('value: desktop-cn-v16'); + expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v15'); + expect(manifest).not.toContain('value: desktop-cn-v15'); + expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v8'); expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v7'); expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v4'); expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v3'); - expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v2'); + expect(manifest).not.toMatch(/kt-napcat-desktop-cn:desktop-cn-v2(?!\d)/); }); }); diff --git a/test/modules/qqbot/napcat/napcat-persistent-login-state.spec.ts b/test/modules/qqbot/napcat/napcat-persistent-login-state.spec.ts index f391261..34b1cc1 100644 --- a/test/modules/qqbot/napcat/napcat-persistent-login-state.spec.ts +++ b/test/modules/qqbot/napcat/napcat-persistent-login-state.spec.ts @@ -264,7 +264,7 @@ describe('NapCat persistent login state contract', () => { ); }); - it('does not mark a still-pending scan session completed when it is removed from the runtime cache', async () => { + it('marks a cancelled pending scan session completed instead of restoring it as active', async () => { const loginSessionRepository = createRepository(); const store = new NapcatLoginStateStoreService( loginSessionRepository as any, @@ -288,9 +288,17 @@ describe('NapCat persistent login state contract', () => { expect(loginSessionRepository.rows[0]).toEqual( expect.objectContaining({ - completedAt: null, + completedAt: expect.any(Date), + loginStage: 'cancelled', + progressMessage: '扫码会话已取消', sessionKey: 'pending-delete-session', - status: 'pending', + status: 'error', + }), + ); + expect(loginSessionRepository.rows[0].sessionPayload).toEqual( + expect.objectContaining({ + errorMessage: '扫码会话已取消', + status: 'error', }), ); }); diff --git a/test/modules/qqbot/napcat/napcat-runtime-promotion.spec.ts b/test/modules/qqbot/napcat/napcat-runtime-promotion.spec.ts index 5bc19b3..09c0b1d 100644 --- a/test/modules/qqbot/napcat/napcat-runtime-promotion.spec.ts +++ b/test/modules/qqbot/napcat/napcat-runtime-promotion.spec.ts @@ -30,10 +30,18 @@ describe('NapCat runtime promotion contract', () => { ); }); - it('keeps a safe default runtime profile in the K8s manifest', () => { + it('keeps the verified v20 runtime profile as the K8s manifest default', () => { const manifest = readSource('k8s/prod/api.yaml'); - expect(manifest).toContain('value: kt-napcat-desktop-cn:desktop-cn-v8'); - expect(manifest).toContain('value: desktop-cn-v8'); + expect(manifest).toContain('value: kt-napcat-desktop-cn:desktop-cn-v20'); + expect(manifest).toContain('value: desktop-cn-v20'); + expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v19'); + expect(manifest).not.toContain('value: desktop-cn-v19'); + expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v18'); + expect(manifest).not.toContain('value: desktop-cn-v18'); + expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v16'); + expect(manifest).not.toContain('value: desktop-cn-v16'); + expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v15'); + expect(manifest).not.toContain('value: desktop-cn-v15'); }); }); diff --git a/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts b/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts index cd68d42..68df625 100644 --- a/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts +++ b/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts @@ -123,7 +123,7 @@ describe('NapCat runtime profile generation', () => { }); expect(profile).toMatchObject({ - desktopProfileVersion: 'desktop-cn-v8', + desktopProfileVersion: 'desktop-cn-v20', imageRef: 'kt-napcat-desktop-cn@sha256:profiledigest', locale: 'zh_CN.UTF-8', runtimeGid: 1101, @@ -181,9 +181,9 @@ describe('NapCat runtime profile generation', () => { }, protocolProfile: { napcatConfigHash: 'napcat-hash', - napcatConfigJson: { o3HookMode: 1 }, + napcatConfigJson: { o3HookMode: 0 }, o3HookGrayEnabled: false, - o3HookMode: 1, + o3HookMode: 0, onebotConfigHash: 'onebot-hash', onebotConfigJson: { network: { websocketClients: [] } }, packetBackend: 'auto', @@ -253,6 +253,15 @@ describe('NapCat runtime profile generation', () => { reconnectInterval: 5000, reportSelfMessage: false, }); + expect(result.napcatConfig.bypass).toEqual({ + container: true, + hook: true, + js: true, + module: true, + process: true, + window: true, + }); + expect(result.napcatConfig.o3HookMode).toBe(0); expect( result.files.find((file) => file.path === 'webui.json')?.content, ).toContain(webuiAuthValue); diff --git a/test/qqbot/account/qqbot-napcat-login.service.spec.ts b/test/qqbot/account/qqbot-napcat-login.service.spec.ts index f35d74b..118d058 100644 --- a/test/qqbot/account/qqbot-napcat-login.service.spec.ts +++ b/test/qqbot/account/qqbot-napcat-login.service.spec.ts @@ -37,6 +37,8 @@ describe('QqbotNapcatLoginService', () => { passwordResult?: null | Record; quickError?: string; quickResult?: null | Record; + restartError?: string; + restartResult?: null | Record; } = {}, ) => jest @@ -50,6 +52,10 @@ describe('QqbotNapcatLoginService', () => { if (options.passwordError) throw new Error(options.passwordError); return options.passwordResult ?? null; } + if (path === '/api/QQLogin/RestartNapCat') { + if (options.restartError) throw new Error(options.restartError); + return options.restartResult ?? null; + } return null; }); @@ -181,13 +187,14 @@ describe('QqbotNapcatLoginService', () => { 'qq-password', ); expect(startScan).toHaveBeenCalledWith( - { + expect.objectContaining({ accountId: 'account-1', expectedSelfId: '10001', forceRelogin: true, + loginPasswordAvailable: true, loginPassword: 'qq-password', mode: 'refresh', - }, + }), existingContainer, ); }); @@ -276,9 +283,72 @@ describe('QqbotNapcatLoginService', () => { expect(prepareReloginQrcode).toHaveBeenCalledTimes(1); }); - it('does not return a stale qrcode when reusing an active refresh session', async () => { + it('starts a new refresh session after password is maintained on an account with an old pending session', async () => { + const account = { + id: 'account-1', + selfId: '10001', + }; + const existingContainer = { + baseUrl: 'http://127.0.0.1:6103/', + hasExistingPrimaryBinding: true, + id: 'container-current', + name: 'napcat-10001', + }; + const accountService = { + findByIdWithNapcatLoginSecret: jest.fn().mockResolvedValue(account), + getNapcatLoginPassword: jest.fn().mockReturnValue('qq-password'), + }; + const containerService = { + prepareAccountContainer: jest.fn().mockResolvedValue(existingContainer), + }; const refreshService = new QqbotNapcatLoginService( { get: jest.fn() } as unknown as ConfigService, + accountService as unknown as QqbotAccountService, + containerService as unknown as QqbotNapcatContainerService, + new ToolsService(), + ); + (refreshService as any).sessions.set('session-without-password', { + accountId: 'account-1', + containerId: 'container-current', + containerName: 'napcat-10001', + createdAt: Date.now(), + errorMessage: '密码登录未完成:未配置 QQ 登录密码,开始生成二维码', + expiresAt: Date.now() + 60_000, + id: 'session-without-password', + mode: 'refresh', + status: 'pending', + webuiPort: 6103, + }); + const prepareReloginQrcode = jest + .spyOn(refreshService as any, 'prepareReloginQrcode') + .mockResolvedValue(undefined); + + const result = await refreshService.startRefresh('account-1'); + + expect(result.sessionId).not.toBe('session-without-password'); + expect(accountService.findByIdWithNapcatLoginSecret).toHaveBeenCalledWith( + 'account-1', + ); + expect(accountService.getNapcatLoginPassword).toHaveBeenCalledWith(account); + expect(prepareReloginQrcode).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: 'account-1', + expectedSelfId: '10001', + status: 'pending', + }), + existingContainer, + 'qq-password', + existingContainer.hasExistingPrimaryBinding, + ); + }); + + it('does not return a stale qrcode when reusing an active refresh session', async () => { + const refreshService = new QqbotNapcatLoginService( + { + get: jest.fn((key: string) => + key === 'NAPCAT_WEBUI_RESTART_DELAY_MS' ? '1' : undefined, + ), + } as unknown as ConfigService, {} as QqbotAccountService, {} as QqbotNapcatContainerService, new ToolsService(), @@ -1234,6 +1304,116 @@ describe('QqbotNapcatLoginService', () => { ); }); + it('keeps scan pending when NapCat is login-positive before QQ number is readable', async () => { + const container = { + baseUrl: 'http://127.0.0.1:6103/', + id: 'container-login-wait-self-id', + name: 'napcat-login-wait-self-id', + }; + const accountService = { + ensureScannedAccount: jest.fn().mockResolvedValue('account-1'), + }; + const containerService = { + bindAccount: jest.fn().mockResolvedValue(undefined), + }; + const loginService = new QqbotNapcatLoginService( + { get: jest.fn() } as unknown as ConfigService, + accountService as unknown as QqbotAccountService, + containerService as unknown as QqbotNapcatContainerService, + new ToolsService(), + ); + const session = (loginService as any).createSession({ + container, + mode: 'create', + qrcode: 'https://txz.qq.com/p?k=ready&f=1600001615', + status: 'pending', + }); + (loginService as any).sessions.set(session.id, session); + jest + .spyOn(loginService as any, 'getSessionContainer') + .mockResolvedValue(container); + jest + .spyOn(loginService as any, 'getLoginStatus') + .mockResolvedValue({ isLogin: true }); + jest.spyOn(loginService as any, 'getLoginInfo').mockResolvedValue({ + nick: 'Mirror', + online: true, + }); + + const result = await loginService.status(session.id); + + expect(result.status).toBe('pending'); + expect(result.errorMessage).toBe('NapCat 已登录,正在读取 QQ 号'); + expect((loginService as any).sessions.get(session.id)).toEqual( + expect.objectContaining({ + errorMessage: 'NapCat 已登录,正在读取 QQ 号', + status: 'pending', + }), + ); + expect(accountService.ensureScannedAccount).not.toHaveBeenCalled(); + expect(containerService.bindAccount).not.toHaveBeenCalled(); + }); + + it('completes scan after a login-positive session later exposes QQ number', async () => { + const container = { + baseUrl: 'http://127.0.0.1:6103/', + id: 'container-login-late-self-id', + name: 'napcat-login-late-self-id', + }; + const accountService = { + ensureScannedAccount: jest.fn().mockResolvedValue('account-1'), + }; + const containerService = { + bindAccount: jest.fn().mockResolvedValue(undefined), + }; + const loginService = new QqbotNapcatLoginService( + { get: jest.fn() } as unknown as ConfigService, + accountService as unknown as QqbotAccountService, + containerService as unknown as QqbotNapcatContainerService, + new ToolsService(), + ); + const session = (loginService as any).createSession({ + container, + mode: 'create', + qrcode: 'https://txz.qq.com/p?k=ready&f=1600001615', + status: 'pending', + }); + (loginService as any).sessions.set(session.id, session); + jest + .spyOn(loginService as any, 'getSessionContainer') + .mockResolvedValue(container); + jest + .spyOn(loginService as any, 'getLoginStatus') + .mockResolvedValue({ isLogin: true }); + jest + .spyOn(loginService as any, 'getLoginInfo') + .mockResolvedValueOnce({ + nick: 'Mirror', + online: true, + }) + .mockResolvedValueOnce({ + nick: 'Mirror', + online: true, + uin: '1914728559', + }); + + const pending = await loginService.status(session.id); + const completed = await loginService.status(session.id); + + expect(pending.status).toBe('pending'); + expect(completed.status).toBe('success'); + expect(accountService.ensureScannedAccount).toHaveBeenCalledWith({ + name: 'Mirror', + selfId: '1914728559', + }); + expect(containerService.bindAccount).toHaveBeenCalledWith( + 'account-1', + 'container-login-late-self-id', + '1914728559', + ); + expect(session.loginSelfIdMissingSince).toBeUndefined(); + }); + it('does not let the server-side qrcode monitor extend the original qrcode deadline on temporary errors', async () => { const container = { baseUrl: 'http://127.0.0.1:6103/', @@ -1419,7 +1599,7 @@ describe('QqbotNapcatLoginService', () => { expect(session.qrcode).toBe('budget-qrcode'); }); - it('refreshes qrcode through WebUI when the source Docker container is online but QQ is offline', async () => { + it('restarts the NapCat worker before refreshing qrcode when source Docker is online but QQ is offline', async () => { const container = { baseUrl: 'http://127.0.0.1:6103/', id: 'container-online-source', @@ -1438,7 +1618,11 @@ describe('QqbotNapcatLoginService', () => { ensureScannedAccount: jest.fn().mockResolvedValue('account-1'), }; const refreshService = new QqbotNapcatLoginService( - { get: jest.fn() } as unknown as ConfigService, + { + get: jest.fn((key: string) => + key === 'NAPCAT_WEBUI_RESTART_DELAY_MS' ? '1' : undefined, + ), + } as unknown as ConfigService, accountService as unknown as QqbotAccountService, containerService as unknown as QqbotNapcatContainerService, new ToolsService(), @@ -1453,6 +1637,9 @@ describe('QqbotNapcatLoginService', () => { status: 'pending', }); (refreshService as any).sessions.set(session.id, session); + jest + .spyOn((refreshService as any).toolsService, 'sleep') + .mockResolvedValue(undefined); jest.spyOn(refreshService as any, 'getLoginStatus').mockResolvedValue({ isOffline: true, isLogin: false, @@ -1461,11 +1648,16 @@ describe('QqbotNapcatLoginService', () => { const refreshOrGetQrcode = jest .spyOn(refreshService as any, 'refreshOrGetQrcode') .mockResolvedValue('online-source-qrcode'); + const postNapcat = mockWebuiLoginPost(refreshService); const result = await refreshService.refreshQrcode(session.id); expect(containerService.resetRuntimeLoginState).not.toHaveBeenCalled(); expect(containerService.restartRuntimeContainer).not.toHaveBeenCalled(); + expect(postNapcat).toHaveBeenCalledWith( + container, + '/api/QQLogin/RestartNapCat', + ); expect(refreshOrGetQrcode).toHaveBeenCalledWith(container, false, { fallbackStatus: expect.objectContaining({ isOffline: true }), requireFresh: true, @@ -1475,6 +1667,68 @@ describe('QqbotNapcatLoginService', () => { expect(result.qrcode).toBe('online-source-qrcode'); }); + it('does not restart the NapCat worker repeatedly for the same online-source refresh session', async () => { + const container = { + baseUrl: 'http://127.0.0.1:6103/', + id: 'container-online-source', + name: 'napcat-10001', + sourceContainerOnline: true, + }; + const containerService = { + ensureRuntimeLoginEnv: jest + .fn() + .mockResolvedValue({ changed: false, ok: true }), + findRuntimeById: jest.fn().mockResolvedValue(container), + resetRuntimeLoginState: jest.fn().mockResolvedValue(true), + restartRuntimeContainer: jest.fn().mockResolvedValue(true), + }; + const accountService = { + ensureScannedAccount: jest.fn().mockResolvedValue('account-1'), + }; + const refreshService = new QqbotNapcatLoginService( + { + get: jest.fn((key: string) => + key === 'NAPCAT_WEBUI_RESTART_DELAY_MS' ? '1' : undefined, + ), + } as unknown as ConfigService, + accountService as unknown as QqbotAccountService, + containerService as unknown as QqbotNapcatContainerService, + new ToolsService(), + ); + const session = (refreshService as any).createSession({ + accountId: 'account-1', + container, + expectedSelfId: '10001', + mode: 'refresh', + preparingRelogin: false, + sourceContainerOnline: true, + status: 'pending', + }); + (refreshService as any).sessions.set(session.id, session); + jest + .spyOn((refreshService as any).toolsService, 'sleep') + .mockResolvedValue(undefined); + jest.spyOn(refreshService as any, 'getLoginStatus').mockResolvedValue({ + isOffline: true, + isLogin: false, + loginError: 'NapCat 账号状态变更为离线', + }); + jest + .spyOn(refreshService as any, 'refreshOrGetQrcode') + .mockResolvedValue('online-source-qrcode'); + const postNapcat = mockWebuiLoginPost(refreshService); + + await refreshService.refreshQrcode(session.id); + await refreshService.refreshQrcode(session.id); + + expect(containerService.restartRuntimeContainer).not.toHaveBeenCalled(); + expect( + postNapcat.mock.calls.filter( + ([, path]) => path === '/api/QQLogin/RestartNapCat', + ), + ).toHaveLength(1); + }); + it('does not perform runtime env rebuild inside the refresh session', async () => { const container = { baseUrl: 'http://127.0.0.1:6103/', @@ -1573,12 +1827,13 @@ describe('QqbotNapcatLoginService', () => { }); (refreshService as any).sessions.set(session.id, session); jest.spyOn(refreshService as any, 'getLoginStatus').mockResolvedValue({ + isOffline: true, isLogin: false, loginError: 'Docker 容器在线但 QQ 账号离线', }); - jest - .spyOn(refreshService as any, 'postNapcat') - .mockRejectedValue(new Error('快速登录未成功')); + const postNapcat = mockWebuiLoginPost(refreshService, { + quickError: '快速登录未成功', + }); jest .spyOn(refreshService as any, 'refreshOrGetQrcode') .mockResolvedValue('online-source-qrcode'); @@ -1589,6 +1844,10 @@ describe('QqbotNapcatLoginService', () => { expect(containerService.runtimeLoginEnvMatches).not.toHaveBeenCalled(); expect(containerService.resetRuntimeLoginState).not.toHaveBeenCalled(); expect(containerService.restartRuntimeContainer).not.toHaveBeenCalled(); + expect(postNapcat.mock.calls.map(([, path]) => path)).toEqual([ + '/api/QQLogin/RestartNapCat', + '/api/QQLogin/SetQuickLogin', + ]); expect(session.status).toBe('pending'); expect(session.qrcode).toBe('online-source-qrcode'); }); @@ -2875,10 +3134,15 @@ describe('QqbotNapcatLoginService', () => { 'https://ti.qq.com/safe/tools/captcha/sms-verify-login?uin=10001'; session.passwordMd5 = '0123456789abcdef0123456789abcdef'; (refreshService as any).sessions.set(session.id, session); + const flushSessionWrites = jest.spyOn( + (refreshService as any).loginSessionStore, + 'flushSessionWrites', + ); await refreshService.cancel(session.id); expect(containerService.ensureRuntimeLoginEnv).not.toHaveBeenCalled(); + expect(flushSessionWrites).toHaveBeenCalledWith(session.id); expect(session.passwordMd5).toBeUndefined(); expect(session.captchaUrl).toBeUndefined(); }); @@ -3629,6 +3893,62 @@ describe('QqbotNapcatLoginService', () => { ); }); + it('persists failed scan sessions before cleaning runtime state', async () => { + const loginSessionRepository = createLoginSessionRepository(); + const loginStateStore = new NapcatLoginStateStoreService( + loginSessionRepository as any, + ); + const failService = new QqbotNapcatLoginService( + { get: jest.fn() } as unknown as ConfigService, + { + ensureScannedAccount: jest.fn(), + } as unknown as QqbotAccountService, + { + removeUnboundCreateContainer: jest.fn().mockResolvedValue(undefined), + } as unknown as QqbotNapcatContainerService, + new ToolsService(), + loginStateStore, + ); + const session = (failService as any).createSession({ + container: { + id: 'container-fail-persist', + name: 'napcat-fail-persist', + webuiPort: 6110, + }, + mode: 'create', + qrcode: 'login-qrcode', + status: 'pending', + }); + (failService as any).sessions.set(session.id, session); + await loginStateStore.flushSessionWrites(session.id); + const flushSessionWrites = jest.spyOn( + loginStateStore, + 'flushSessionWrites', + ); + + const result = await (failService as any).failSession( + session, + 'NapCat 已登录但未返回 QQ 号', + ); + + expect(result.status).toBe('error'); + expect(result.errorMessage).toBe('NapCat 已登录但未返回 QQ 号'); + expect(flushSessionWrites).toHaveBeenCalledWith(session.id); + expect(loginSessionRepository.rows[0]).toEqual( + expect.objectContaining({ + progressMessage: 'NapCat 已登录但未返回 QQ 号', + sessionKey: session.id, + status: 'error', + }), + ); + expect(loginSessionRepository.rows[0].completedAt).toEqual( + expect.any(Date), + ); + expect(loginSessionRepository.rows[0].sessionPayload?.status).toBe( + 'error', + ); + }); + it('keeps an API-expired refresh session pending while NapCat still exposes the same live qrcode', async () => { const loginSessionRepository = createLoginSessionRepository(); const loginStateStore = new NapcatLoginStateStoreService( @@ -4052,6 +4372,127 @@ describe('QqbotNapcatLoginService', () => { expect(getQrcode).not.toHaveBeenCalled(); }); + it('auto refreshes a pending refresh session when WebUI has no cached qrcode', async () => { + (service as any).sessions.set('session-auto-refresh-qrcode', { + accountId: 'account-1', + containerId: 'container-auto-refresh-qrcode', + containerName: 'napcat-auto-refresh-qrcode', + createdAt: Date.now(), + errorMessage: 'NapCat 正在重新生成二维码,请稍后刷新或等待自动更新', + expiresAt: Date.now() + 60_000, + expectedSelfId: '10001', + id: 'session-auto-refresh-qrcode', + mode: 'refresh', + status: 'pending', + webuiPort: 6106, + }); + const container = { id: 'container-auto-refresh-qrcode' }; + const loginStatus = { + isLogin: false, + loginError: '网络连接异常!', + }; + jest + .spyOn(service as any, 'getSessionContainer') + .mockResolvedValue(container); + jest + .spyOn(service as any, 'getLoginStatus') + .mockResolvedValue(loginStatus); + const getQrcode = jest + .spyOn(service as any, 'getQrcode') + .mockRejectedValue(new Error('NapCat 未返回登录二维码')); + const refreshOrGetQrcode = jest + .spyOn(service as any, 'refreshOrGetQrcode') + .mockResolvedValue('auto-refresh-qrcode'); + + const result = await service.status('session-auto-refresh-qrcode'); + + expect(result.status).toBe('pending'); + expect(result.qrcode).toBe('auto-refresh-qrcode'); + expect(result.errorMessage).toBeUndefined(); + expect(getQrcode).not.toHaveBeenCalled(); + expect(refreshOrGetQrcode).toHaveBeenCalledWith(container, false, { + fallbackStatus: loginStatus, + requireFresh: true, + staleQrcode: undefined, + }); + expect( + (service as any).sessions.get('session-auto-refresh-qrcode') + .lastQrcodeRefreshAt, + ).toEqual(expect.any(Number)); + }); + + it('restarts the NapCat worker once before status auto-refresh for a restored online-source refresh session', async () => { + const container = { + baseUrl: 'http://127.0.0.1:6106/', + id: 'container-status-worker-restart', + name: 'napcat-10001', + sourceContainerOnline: true, + }; + const containerService = { + findRuntimeById: jest.fn().mockResolvedValue(container), + restartRuntimeContainer: jest.fn().mockResolvedValue(true), + }; + const accountService = { + markQqLoginStatus: jest.fn().mockResolvedValue(undefined), + }; + const refreshService = new QqbotNapcatLoginService( + { + get: jest.fn((key: string) => + key === 'NAPCAT_WEBUI_RESTART_DELAY_MS' ? '1' : undefined, + ), + } as unknown as ConfigService, + accountService as unknown as QqbotAccountService, + containerService as unknown as QqbotNapcatContainerService, + new ToolsService(), + ); + (refreshService as any).sessions.set('session-status-worker-restart', { + accountId: 'account-1', + containerId: container.id, + containerName: container.name, + createdAt: Date.now(), + errorMessage: 'NapCat 正在重新生成二维码,请稍后刷新或等待自动更新', + expiresAt: Date.now() + 60_000, + expectedSelfId: '10001', + id: 'session-status-worker-restart', + mode: 'refresh', + sourceContainerOnline: true, + status: 'pending', + webuiPort: 6106, + }); + const loginStatus = { + isLogin: false, + isOffline: true, + loginError: 'NapCat 账号状态变更为离线', + }; + jest + .spyOn(refreshService as any, 'getSessionContainer') + .mockResolvedValue(container); + jest + .spyOn(refreshService as any, 'getLoginStatus') + .mockResolvedValue(loginStatus); + jest + .spyOn(refreshService as any, 'refreshOrGetQrcode') + .mockResolvedValue('status-worker-restart-qrcode'); + const postNapcat = mockWebuiLoginPost(refreshService); + + const firstResult = await refreshService.status( + 'session-status-worker-restart', + ); + const secondResult = await refreshService.status( + 'session-status-worker-restart', + ); + + expect(firstResult.status).toBe('pending'); + expect(firstResult.qrcode).toBe('status-worker-restart-qrcode'); + expect(secondResult.status).toBe('pending'); + expect(containerService.restartRuntimeContainer).not.toHaveBeenCalled(); + expect( + postNapcat.mock.calls.filter( + ([, path]) => path === '/api/QQLogin/RestartNapCat', + ), + ).toHaveLength(1); + }); + it('normalizes login status to offline when login info reports offline', async () => { jest .spyOn(service as any, 'postNapcat') diff --git a/test/qqbot/napcat/qqbot-napcat-container-runtime-status.spec.ts b/test/qqbot/napcat/qqbot-napcat-container-runtime-status.spec.ts index c82a9c6..64742d1 100644 --- a/test/qqbot/napcat/qqbot-napcat-container-runtime-status.spec.ts +++ b/test/qqbot/napcat/qqbot-napcat-container-runtime-status.spec.ts @@ -82,4 +82,240 @@ describe('QqbotNapcatContainerService runtime status', () => { }), ); }); + + it('reuses WebUI credential when checking the same running container repeatedly', async () => { + const repository = { + update: jest.fn(), + }; + const service = createService(repository); + const requestNapcat = jest + .spyOn(service as any, 'requestNapcat') + .mockImplementation(async (_runtime, path: string) => { + if (path === '/api/auth/login') { + return { Credential: 'credential-1' }; + } + if (path === '/api/QQLogin/CheckLoginStatus') { + return { + isLogin: true, + isOffline: false, + loginError: '', + online: true, + qrcodeurl: '', + }; + } + throw new Error(`unexpected path ${path}`); + }); + + const container = { + baseUrl: 'http://127.0.0.1:6100/', + id: 'container-1', + lastError: null, + name: 'napcat-1', + status: 'running', + webuiPort: 6100, + webuiToken: 'token', + } as any; + + await service.inspectRuntimeStatus(container); + await service.inspectRuntimeStatus(container); + + expect( + requestNapcat.mock.calls.filter( + ([, path]: unknown[]) => path === '/api/auth/login', + ), + ).toHaveLength(1); + expect( + requestNapcat.mock.calls.filter( + ([, path]: unknown[]) => path === '/api/QQLogin/CheckLoginStatus', + ), + ).toHaveLength(2); + }); + + it('deduplicates concurrent WebUI credential requests for the same container', async () => { + const repository = { + update: jest.fn(), + }; + const service = createService(repository); + const requestNapcat = jest + .spyOn(service as any, 'requestNapcat') + .mockImplementation(async (_runtime, path: string) => { + if (path === '/api/auth/login') { + await new Promise((resolve) => setTimeout(resolve, 1)); + return { Credential: 'credential-1' }; + } + if (path === '/api/QQLogin/CheckLoginStatus') { + return { + isLogin: true, + isOffline: false, + loginError: '', + online: true, + qrcodeurl: '', + }; + } + throw new Error(`unexpected path ${path}`); + }); + + const container = { + baseUrl: 'http://127.0.0.1:6100/', + id: 'container-1', + lastError: null, + name: 'napcat-1', + status: 'running', + webuiPort: 6100, + webuiToken: 'token', + } as any; + + await Promise.all([ + service.inspectRuntimeStatus(container), + service.inspectRuntimeStatus(container), + ]); + + expect( + requestNapcat.mock.calls.filter( + ([, path]: unknown[]) => path === '/api/auth/login', + ), + ).toHaveLength(1); + expect( + requestNapcat.mock.calls.filter( + ([, path]: unknown[]) => path === '/api/QQLogin/CheckLoginStatus', + ), + ).toHaveLength(2); + }); + + it('refreshes cached WebUI credential once when NapCat rejects status auth', async () => { + const repository = { + update: jest.fn(), + }; + const service = createService(repository); + let credentialIndex = 0; + let statusChecks = 0; + const requestNapcat = jest + .spyOn(service as any, 'requestNapcat') + .mockImplementation( + async (_runtime, path: string, _body, credential?: string) => { + if (path === '/api/auth/login') { + credentialIndex += 1; + return { Credential: `credential-${credentialIndex}` }; + } + if (path === '/api/QQLogin/CheckLoginStatus') { + statusChecks += 1; + if (statusChecks > 1 && credential === 'credential-1') { + throw new Error('Unauthorized'); + } + return { + isLogin: true, + isOffline: false, + loginError: '', + online: true, + qrcodeurl: '', + }; + } + throw new Error(`unexpected path ${path}`); + }, + ); + + const container = { + baseUrl: 'http://127.0.0.1:6100/', + id: 'container-1', + lastError: null, + name: 'napcat-1', + status: 'running', + webuiPort: 6100, + webuiToken: 'token', + } as any; + + await service.inspectRuntimeStatus(container); + const snapshot = await service.inspectRuntimeStatus(container); + + expect(snapshot).toEqual( + expect.objectContaining({ + qqLoginStatus: 'online', + webuiOnline: true, + }), + ); + expect( + requestNapcat.mock.calls.filter( + ([, path]: unknown[]) => path === '/api/auth/login', + ), + ).toHaveLength(2); + expect( + requestNapcat.mock.calls.filter( + ([, path]: unknown[]) => path === '/api/QQLogin/CheckLoginStatus', + ), + ).toHaveLength(3); + }); + + it('keeps a refreshed credential when a later stale status request is rejected', async () => { + const repository = { + update: jest.fn(), + }; + const service = createService(repository); + let credentialIndex = 0; + let staleMode = false; + let staleStatusChecks = 0; + let releaseSecondStaleReject: (() => void) | undefined; + const secondStaleReject = new Promise((resolve) => { + releaseSecondStaleReject = resolve; + }); + const requestNapcat = jest + .spyOn(service as any, 'requestNapcat') + .mockImplementation( + async (_runtime, path: string, _body, credential?: string) => { + if (path === '/api/auth/login') { + credentialIndex += 1; + return { + Credential: + credentialIndex === 1 + ? 'credential-old' + : 'credential-new', + }; + } + if (path === '/api/QQLogin/CheckLoginStatus') { + if (staleMode && credential === 'credential-old') { + staleStatusChecks += 1; + if (staleStatusChecks === 1) { + throw new Error('Unauthorized'); + } + await secondStaleReject; + throw new Error('Unauthorized'); + } + if (staleMode && credential === 'credential-new') { + releaseSecondStaleReject?.(); + } + return { + isLogin: true, + isOffline: false, + loginError: '', + online: true, + qrcodeurl: '', + }; + } + throw new Error(`unexpected path ${path}`); + }, + ); + + const container = { + baseUrl: 'http://127.0.0.1:6100/', + id: 'container-1', + lastError: null, + name: 'napcat-1', + status: 'running', + webuiPort: 6100, + webuiToken: 'token', + } as any; + + await service.inspectRuntimeStatus(container); + staleMode = true; + + await Promise.all([ + service.inspectRuntimeStatus(container), + service.inspectRuntimeStatus(container), + ]); + + expect( + requestNapcat.mock.calls.filter( + ([, path]: unknown[]) => path === '/api/auth/login', + ), + ).toHaveLength(2); + }); }); diff --git a/test/qqbot/napcat/qqbot-napcat-container.service.spec.ts b/test/qqbot/napcat/qqbot-napcat-container.service.spec.ts index b3e0c5e..9234a1c 100644 --- a/test/qqbot/napcat/qqbot-napcat-container.service.spec.ts +++ b/test/qqbot/napcat/qqbot-napcat-container.service.spec.ts @@ -505,9 +505,32 @@ describe('QqbotNapcatContainerService', () => { expect(script).toContain("NAPCAT_GID='1101'"); expect(script).toContain("NAPCAT_SHM_SIZE='512m'"); expect(script).toContain('--init \\'); + expect(script).toContain('--cap-add SYS_ADMIN \\'); + expect(script).toContain('--security-opt apparmor=unconfined \\'); + expect(script).toContain('--security-opt seccomp=unconfined \\'); expect(script).toContain('--shm-size "$NAPCAT_SHM_SIZE"'); expect(script).toContain('-e NAPCAT_UID="$NAPCAT_UID"'); expect(script).toContain('-e NAPCAT_GID="$NAPCAT_GID"'); + expect(script).toContain('-e NAPCAT_REQUIRE_DEVICE_PROFILE=1'); + expect(script).toContain('-e NAPCAT_DMI_SYS_VENDOR="$NAPCAT_DMI_SYS_VENDOR"'); + expect(script).toContain( + '-e NAPCAT_DMI_PRODUCT_UUID="$NAPCAT_DMI_PRODUCT_UUID"', + ); + expect(script).toContain( + '-e NAPCAT_DEVICE_BOOT_ID="$NAPCAT_DEVICE_BOOT_ID"', + ); + expect(script).toContain( + '-e NAPCAT_DEVICE_KERNEL_RELEASE="$NAPCAT_DEVICE_KERNEL_RELEASE"', + ); + expect(script).toContain( + '-e NAPCAT_DEVICE_CPU_MODEL="$NAPCAT_DEVICE_CPU_MODEL"', + ); + expect(script).toContain( + '-e NAPCAT_DEVICE_UPTIME="$NAPCAT_DEVICE_UPTIME"', + ); + expect(script).toContain( + '-e NAPCAT_DEVICE_TTY_ACTIVE="$NAPCAT_DEVICE_TTY_ACTIVE"', + ); expect(script).toContain('-e LANG=zh_CN.UTF-8'); expect(script).toContain('-e LC_ALL=zh_CN.UTF-8'); expect(script).toContain('-e LANGUAGE=zh_CN:zh');