chore: 同步main到dev
This commit is contained in:
commit
616346ccbc
8
API.md
8
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/<pid>/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 <uin>` 快速登录参数,避免重启后退回无账号扫码;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。
|
||||
|
||||
|
||||
@ -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/<pid>/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 <uin>` 快速登录参数,避免重启后退回无账号扫码;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 手动触发「更新登录」。
|
||||
|
||||
@ -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 \
|
||||
|
||||
@ -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/<pid>/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.
|
||||
|
||||
266
ci/napcat-desktop-cn/entrypoint-device-profile.patch.sh
Normal file
266
ci/napcat-desktop-cn/entrypoint-device-profile.patch.sh
Normal file
@ -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' ' ' </proc/1/cmdline | grep -q '/sbin/init'"
|
||||
kt_require_device_profile proc1-sched grep -q 'systemd' /proc/1/sched
|
||||
kt_require_device_profile proc1-status grep -q '^NSpid:[[:space:]]*1' /proc/1/status
|
||||
kt_require_device_profile proc1-stat grep -q '(systemd)' /proc/1/stat
|
||||
kt_require_device_profile dmi-product grep -q "$NAPCAT_DMI_PRODUCT_NAME" /sys/class/dmi/id/product_name
|
||||
kt_require_device_profile dmi-product-uuid grep -q "$NAPCAT_DMI_PRODUCT_UUID" /sys/class/dmi/id/product_uuid
|
||||
kt_require_device_profile dmi-sys-vendor grep -q "$NAPCAT_DMI_SYS_VENDOR" /sys/class/dmi/id/sys_vendor
|
||||
kt_require_device_profile dmi-bios-vendor grep -q "$NAPCAT_DMI_BIOS_VENDOR" /sys/class/dmi/id/bios_vendor
|
||||
kt_require_device_profile dmi-bios-version grep -q "$NAPCAT_DMI_BIOS_VERSION" /sys/class/dmi/id/bios_version
|
||||
kt_require_device_profile dmi-modalias grep -q "$NAPCAT_DMI_MODALIAS" /sys/class/dmi/id/modalias
|
||||
kt_require_device_profile boot-id grep -q "$NAPCAT_DEVICE_BOOT_ID" /proc/sys/kernel/random/boot_id
|
||||
kt_require_device_profile kernel-version grep -q "$NAPCAT_DEVICE_KERNEL_VERSION" /proc/sys/kernel/version
|
||||
kt_require_device_profile proc-version grep -q "$NAPCAT_DEVICE_KERNEL_RELEASE" /proc/version
|
||||
kt_require_device_profile proc-uptime grep -q "$NAPCAT_DEVICE_UPTIME" /proc/uptime
|
||||
kt_require_device_profile proc-cpuinfo grep -q "$NAPCAT_DEVICE_CPU_MODEL" /proc/cpuinfo
|
||||
if grep -q 'hypervisor' /proc/cpuinfo; then
|
||||
echo "NapCat device profile check failed: cpuinfo-hypervisor-flag" >&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
|
||||
@ -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
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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
|
||||
),
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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<Record<string, any> | null>(
|
||||
|
||||
@ -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: '',
|
||||
};
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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<string> | 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<NapcatLoginStatus>(
|
||||
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<NapcatCredential>(
|
||||
@ -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<NapcatLoginStatus>(
|
||||
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<NapcatLoginStatus>(
|
||||
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` 字段生成结果。
|
||||
|
||||
@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@ -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)');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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: [],
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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)/);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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<NapcatLoginSession>();
|
||||
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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -37,6 +37,8 @@ describe('QqbotNapcatLoginService', () => {
|
||||
passwordResult?: null | Record<string, unknown>;
|
||||
quickError?: string;
|
||||
quickResult?: null | Record<string, unknown>;
|
||||
restartError?: string;
|
||||
restartResult?: null | Record<string, unknown>;
|
||||
} = {},
|
||||
) =>
|
||||
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')
|
||||
|
||||
@ -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<void>((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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user