fix: 延长NapCat人工验证会话TTL

This commit is contained in:
sunlei 2026-06-19 04:14:47 +08:00
parent d75240459b
commit aa8d4117fb
7 changed files with 104 additions and 7 deletions

View File

@ -82,6 +82,7 @@ NAPCAT_WEBUI_BASE_URL=http://127.0.0.1:6099
NAPCAT_WEBUI_TOKEN=
NAPCAT_WEBUI_TIMEOUT_MS=8000
NAPCAT_LOGIN_QR_EXPIRE_MS=120000
NAPCAT_LOGIN_HUMAN_VERIFY_EXPIRE_MS=900000
NAPCAT_WEBUI_READY_RETRIES=10
NAPCAT_WEBUI_RESTART_DELAY_MS=3000
# 留空时使用密码登录等待窗口 + WebUI 重启/请求缓冲 + 2 个轮询间隔。

2
API.md
View File

@ -346,7 +346,7 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT
账号保存支持可选 `encryptedLoginPassword`,用于 NapCat 密码登录。前端必须先通过 `/auth/password-public-key` 获取公钥并使用 RSA-OAEP 加密,不传明文 `loginPassword`;后端必须使用显式配置的 `QQBOT_ACCOUNT_SECRET_KEY`(或非默认 `ADMIN_TOKEN_SECRET`)二次加密落库,空值和公开默认值会被拒绝,不在列表/详情中返回。账号列表里的 `connectStatus` 只表示 OneBot 反向 WS`napcat.oneBotOnline`、`napcat.containerOnline`、`napcat.webuiOnline`、`napcat.qqLoginStatus`、`napcat.qqLoginMessage` 分别表示 OneBot、容器、WebUI 和 QQ 登录态,`webuiOnline=null` 表示本次使用缓存且未重新探测 WebUI`qqLoginMessage` 只承载真实 QQ 登录态消息WebUI 配置缺失或请求异常只放在 `lastError`
扫码链路返回 `sessionId`,前端应使用 SSE 查看步骤进度,而不是等待长 HTTP 请求完成。已有账号的更新登录会先重启目标 NapCat 容器尝试 `ACCOUNT`/`-q` 快速登录;目标账号在线则直接完成会话。没有历史登录态的新容器会跳过快速登录,优先尝试保存的登录密码;快速登录失败后,如果账号保存了登录密码,会临时注入 `NAPCAT_QUICK_PASSWORD` 并按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询密码登录结果,准备阶段的扫码会话会持续续期,避免后台密码登录未完成时前端先判过期。若 API Pod 在准备阶段重启,持久化的 `preparingRelogin` 超过 `QQBOT_NAPCAT_RELOGIN_PREPARING_STALE_MS`(留空使用密码等待窗口加缓冲)后,`/qqbot/account/scan/status` 会自动恢复普通登录态检测,不再永久停留在“正在尝试密码登录”;`/qqbot/account/scan/events` 在进程内事件缓存丢失时会先推送当前会话快照。密码登录触发 QQ 安全验证时,接口返回的 `captchaUrl` 只用于前端拉起腾讯验证码;前端必须把腾讯验证码返回的 `ticket`、`randstr`、`sid` 连同 `sessionId` 提交到 `/qqbot/account/scan/captcha/submit`,后端再代理到同一 NapCat 容器的 `/api/QQLogin/CaptchaLogin` 继续密码登录第二步。`/qqbot/account/scan/status` 遇到 NapCat 只返回“需要验证码/继续完成验证/安全验证”但不带 URL 时,会先从当前容器日志提取 `proofWaterUrl`,提取不到则保持验证码处理中而不切到二维码兜底;会话已有 `captchaUrl` 后,同类状态仍保持 `pending` 和原 `captchaUrl`。密码登录成功后会重建容器移除该运行态密码,清理失败则本次登录失败;密码登录仍失败、验证码未完成、离线、账号不匹配或缺少 QQ 号时,再进入重置登录态和二维码兜底流程。看门狗自动登录使用同样的 quick -> password 顺序,但不会自动进入扫码阶段。
扫码链路返回 `sessionId`,前端应使用 SSE 查看步骤进度,而不是等待长 HTTP 请求完成。已有账号的更新登录会先重启目标 NapCat 容器尝试 `ACCOUNT`/`-q` 快速登录;目标账号在线则直接完成会话。没有历史登录态的新容器会跳过快速登录,优先尝试保存的登录密码;快速登录失败后,如果账号保存了登录密码,会临时注入 `NAPCAT_QUICK_PASSWORD` 并按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询密码登录结果,准备阶段的扫码会话会持续续期,避免后台密码登录未完成时前端先判过期。若 API Pod 在准备阶段重启,持久化的 `preparingRelogin` 超过 `QQBOT_NAPCAT_RELOGIN_PREPARING_STALE_MS`(留空使用密码等待窗口加缓冲)后,`/qqbot/account/scan/status` 会自动恢复普通登录态检测,不再永久停留在“正在尝试密码登录”;`/qqbot/account/scan/events` 在进程内事件缓存丢失时会先推送当前会话快照。密码登录触发 QQ 安全验证时,接口返回的 `captchaUrl` 只用于前端拉起腾讯验证码;前端必须把腾讯验证码返回的 `ticket`、`randstr`、`sid` 连同 `sessionId` 提交到 `/qqbot/account/scan/captcha/submit`,后端再代理到同一 NapCat 容器的 `/api/QQLogin/CaptchaLogin` 继续密码登录第二步。验证码和新设备验证这类真人交互态使用 `NAPCAT_LOGIN_HUMAN_VERIFY_EXPIRE_MS`(默认 15 分钟,且至少不短于普通二维码 TTL续期普通登录二维码仍使用 `NAPCAT_LOGIN_QR_EXPIRE_MS``/qqbot/account/scan/status` 遇到 NapCat 只返回“需要验证码/继续完成验证/安全验证”但不带 URL 时,会先从当前容器日志提取 `proofWaterUrl`,提取不到则保持验证码处理中而不切到二维码兜底;会话已有 `captchaUrl` 后,同类状态仍保持 `pending` 和原 `captchaUrl`。密码登录成功后会重建容器移除该运行态密码,清理失败则本次登录失败;密码登录仍失败、验证码未完成、离线、账号不匹配或缺少 QQ 号时,再进入重置登录态和二维码兜底流程。看门狗自动登录使用同样的 quick -> password 顺序,但不会自动进入扫码阶段。
密码验证码通过后如果 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 进度文案包含快速登录、密码登录、验证码、新设备二维码、已扫码、确认中、登录成功/失败和运行态清理失败。

View File

@ -162,7 +162,7 @@ API 暴露 `GET /health/runtime` 作为本地 smoke、Jenkins/K8s 和 ktWorkflow
- QQBot 同一账号只允许一个有效 NapCat 主容器;绑定新容器时会释放旧绑定和不再共享的旧容器,机器人下线 notice、`isOnline:false` 和 NapCat 容器最新离线日志都会写入账号 `lastError`,普通群成员 kick 不属于账号离线信号;写入 `last_error` 前按 500 字符截断,后续无错误的普通断连不能清空该原因;账号列表拆开展示 OneBot、容器、WebUI 和 QQ 登录态,心跳只代表 OneBot/容器通信,不能推导 QQ 登录态;近期连接只用于避免重连瞬间被旧缓存误伤,后续仍必须以 NapCat WebUI/日志检查判断 QQ 登录态;`qqLoginMessage` 只展示 QQ 登录态消息WebUI 配置或请求错误留在 `lastError`
- NapCat 托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像;生产切换镜像前先 pin 明确版本或 digest 并单账号观察。
- NapCat 账号新增/编辑支持可选 QQ 登录密码Admin 只提交 RSA-OAEP 加密后的 `encryptedLoginPassword`,后端解密后必须用显式配置的 `QQBOT_ACCOUNT_SECRET_KEY`(或非默认 `ADMIN_TOKEN_SECRET`)二次加密保存到 `qqbot_account.napcat_login_password_secret`;空值、`change-me` 和历史公开默认值会被拒绝;列表和详情不回显密码,日志会脱敏密码字段。
- NapCat 容器为已知 `selfId` 创建/重建时会注入 `ACCOUNT` 环境变量启用 `-q` 快速登录:容器重启(崩溃/重启策略/宿主重启)能从持久化会话免扫码自动重登;硬踢 `登录已失效` 会话作废仍需扫码。已绑定但缺少 `ACCOUNT` 的旧容器在下一次「更新登录」时原地重建一次补齐(保留 QQ 数据卷),`docker inspect` 已带 `ACCOUNT` 则跳过,重建失败不阻断登录。`ACCOUNT` 只负责指定快速登录账号;如果数据卷内没有该 QQ 历史登录记录,后端会跳过快速登录并优先尝试账号保存的登录密码,密码登录会按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询结果,准备中的扫码会话会续期,避免后台密码登录未结束时前端先过期;如果 API Pod 在准备阶段重启,持久化的 `preparingRelogin` 超过 `QQBOT_NAPCAT_RELOGIN_PREPARING_STALE_MS`(留空使用密码登录等待窗口加缓冲)后会自动恢复为普通状态检测,不会永久卡在“正在尝试密码登录”。如果 NapCat 返回 `proofWaterUrl` 或日志出现“需要验证码”,会保持会话 pending 并把腾讯验证码结果 `ticket`/`randstr`/`sid` 通过 `/qqbot/account/scan/captcha/submit` 回交到同一容器的 `/api/QQLogin/CaptchaLogin`,不是让用户只打开外链;状态轮询遇到验证码文案但缺少 URL 时,会先从当前容器日志恢复 `proofWaterUrl`,没有 URL 也保持验证码处理中而不切到二维码兜底;密码登录成功后会移除运行态 `NAPCAT_QUICK_PASSWORD`,清理失败则本次登录失败,避免成功态残留明文 env密码登录也失败、验证码未完成、离线、账号不匹配或缺少 QQ 号时才重置登录态并生成二维码。Admin「更新登录」的 SSE 步骤顺序按实际路径为 `quick-login-*`(已有历史会话)-> `password-login-*` / `password-login-captcha` -> `password-env-cleanup` -> `relogin-reset/qrcode/waiting-scan`SSE 事件缓存因 Pod 重启丢失时,新订阅会收到当前会话快照。
- NapCat 容器为已知 `selfId` 创建/重建时会注入 `ACCOUNT` 环境变量启用 `-q` 快速登录:容器重启(崩溃/重启策略/宿主重启)能从持久化会话免扫码自动重登;硬踢 `登录已失效` 会话作废仍需扫码。已绑定但缺少 `ACCOUNT` 的旧容器在下一次「更新登录」时原地重建一次补齐(保留 QQ 数据卷),`docker inspect` 已带 `ACCOUNT` 则跳过,重建失败不阻断登录。`ACCOUNT` 只负责指定快速登录账号;如果数据卷内没有该 QQ 历史登录记录,后端会跳过快速登录并优先尝试账号保存的登录密码,密码登录会按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询结果,准备中的扫码会话会续期,避免后台密码登录未结束时前端先过期;如果 API Pod 在准备阶段重启,持久化的 `preparingRelogin` 超过 `QQBOT_NAPCAT_RELOGIN_PREPARING_STALE_MS`(留空使用密码登录等待窗口加缓冲)后会自动恢复为普通状态检测,不会永久卡在“正在尝试密码登录”。如果 NapCat 返回 `proofWaterUrl` 或日志出现“需要验证码”,会保持会话 pending 并把腾讯验证码结果 `ticket`/`randstr`/`sid` 通过 `/qqbot/account/scan/captcha/submit` 回交到同一容器的 `/api/QQLogin/CaptchaLogin`,不是让用户只打开外链;验证码和新设备验证这类真人交互态使用 `NAPCAT_LOGIN_HUMAN_VERIFY_EXPIRE_MS`(默认 15 分钟)续期,普通二维码仍使用 `NAPCAT_LOGIN_QR_EXPIRE_MS`状态轮询遇到验证码文案但缺少 URL 时,会先从当前容器日志恢复 `proofWaterUrl`,没有 URL 也保持验证码处理中而不切到二维码兜底;密码登录成功后会移除运行态 `NAPCAT_QUICK_PASSWORD`,清理失败则本次登录失败,避免成功态残留明文 env密码登录也失败、验证码未完成、离线、账号不匹配或缺少 QQ 号时才重置登录态并生成二维码。Admin「更新登录」的 SSE 步骤顺序按实际路径为 `quick-login-*`(已有历史会话)-> `password-login-*` / `password-login-captcha` -> `password-env-cleanup` -> `relogin-reset/qrcode/waiting-scan`SSE 事件缓存因 Pod 重启丢失时,新订阅会收到当前会话快照。
- NapCat 设备身份按账号持久化到 `napcat_device_identity`:同一账号重建容器会复用数据目录、`pc-<8hex>` hostname、machine-id 和 `02:42:*` MACDocker run 会注入 `--hostname`、`--mac-address`、只读 `/etc/machine-id`,并同步写入 QQNT Linux `machine-info`,使 `/etc/machine-id`、Docker MAC 和 QQNT 本地设备缓存保持一致。当前策略名为 `qqnt-visible-hostname-v1` / `docker-bridge-mac-v1`
- NapCat 新设备验证走同一 scan session`CaptchaLogin` 返回 `needNewDevice` 后,后端继续调用 `GetNewDeviceQRCode -> PollNewDeviceQR -> NewDeviceLogin`Admin/SSE 分开展示 `captchaUrl`、`newDeviceQrcode`、已扫码、确认中、验证成功、登录成功/失败等中文进度,不把 `jumpUrl` 当作唯一完成入口。
- NapCat 离线看门狗按 `QQBOT_NAPCAT_WATCHDOG_INTERVAL_MS`(默认 `120000`,最小 `30000``QQBOT_NAPCAT_WATCHDOG_ENABLED=false` 关闭)定时巡检在线账号,使掉线/被踢无需管理员打开列表页即可及时发现;检测到离线后先尝试 `ACCOUNT` 历史会话快速登录,再尝试账号保存的登录密码,仍失败时写入离线原因并复用 `super` 站内信告警;看门狗不自动进入扫码阶段。

View File

@ -1004,7 +1004,7 @@ export class QqbotNapcatLoginService {
session.qrcode = undefined;
session.newDeviceStatus = status;
session.errorMessage = message;
session.expiresAt = Date.now() + this.getSessionTtlMs();
this.renewSessionExpiry(session);
this.persistLoginSession(session);
if (shouldPublish) {
this.publishScanResultEvent(session, step, 'processing', message);
@ -1132,7 +1132,7 @@ export class QqbotNapcatLoginService {
message: string,
) {
if (session.status === 'pending') {
session.expiresAt = Date.now() + this.getSessionTtlMs();
this.renewSessionExpiry(session);
this.persistLoginSession(session);
}
this.publishScanEvent(session, {
@ -1300,7 +1300,7 @@ export class QqbotNapcatLoginService {
) {
session.status = 'pending';
session.errorMessage = errorMessage;
session.expiresAt = Date.now() + this.getSessionTtlMs();
this.renewSessionExpiry(session);
if (clearQrcode) session.qrcode = undefined;
this.persistLoginSession(session);
return this.toResult(session);
@ -1331,7 +1331,7 @@ export class QqbotNapcatLoginService {
session.preparingRelogin = false;
session.qrcode = undefined;
session.errorMessage = message;
session.expiresAt = Date.now() + this.getSessionTtlMs();
this.renewSessionExpiry(session);
this.persistLoginSession(session);
if (shouldPublish) {
this.publishScanResultEvent(
@ -1362,7 +1362,7 @@ export class QqbotNapcatLoginService {
session.preparingRelogin = false;
session.qrcode = undefined;
session.errorMessage = message;
session.expiresAt = Date.now() + this.getSessionTtlMs();
this.renewSessionExpiry(session);
this.persistLoginSession(session);
if (shouldPublish) {
this.publishScanResultEvent(
@ -2647,6 +2647,45 @@ export class QqbotNapcatLoginService {
);
}
/**
* NapCat
* @returns
*/
private getHumanVerificationSessionTtlMs() {
const configured = Number(
this.configService.get('NAPCAT_LOGIN_HUMAN_VERIFY_EXPIRE_MS') ||
15 * 60 * 1000,
);
const fallback = 15 * 60 * 1000;
const ttl =
Number.isFinite(configured) && configured > 0 ? configured : fallback;
return Math.max(ttl, this.getSessionTtlMs());
}
/**
* NapCat
* @param session -
*/
private getSessionRenewalTtlMs(session: QqbotLoginScanSession) {
if (
session.captchaUrl ||
session.deviceVerifyUrl ||
session.newDeviceQrcode ||
session.newDeviceStatus
) {
return this.getHumanVerificationSessionTtlMs();
}
return this.getSessionTtlMs();
}
/**
* NapCat
* @param session - pending
*/
private renewSessionExpiry(session: QqbotLoginScanSession) {
session.expiresAt = Date.now() + this.getSessionRenewalTtlMs(session);
}
/**
* NapCat
*/

View File

@ -57,6 +57,7 @@ const OPTIONAL_CONFIG_CHECKS: ReadonlyArray<string | readonly string[]> = [
'QQBOT_NAPCAT_SSH_TARGET',
'QQBOT_NAPCAT_SSH_PORT',
'QQBOT_NAPCAT_SSH_KEY_PATH',
'NAPCAT_LOGIN_HUMAN_VERIFY_EXPIRE_MS',
['QQBOT_NAPCAT_REVERSE_WS_URL', 'QQBOT_NAPCAT_REVERSE_WS_BASE'],
['NAPCAT_WEBUI_BASE_URL', 'QQBOT_NAPCAT_WEBUI_URL'],
['NAPCAT_WEBUI_TOKEN', 'QQBOT_NAPCAT_WEBUI_TOKEN'],

View File

@ -198,6 +198,54 @@ describe('QqbotNapcatLoginService', () => {
expect((refreshService as any).sessions.has(session.id)).toBe(true);
});
it('uses a longer TTL for human captcha and new-device verification states', () => {
const now = new Date('2026-06-19T04:10:00+08:00').getTime();
jest.spyOn(Date, 'now').mockReturnValue(now);
const refreshService = new QqbotNapcatLoginService(
{
get: jest.fn((key: string) => {
if (key === 'NAPCAT_LOGIN_QR_EXPIRE_MS') return '120000';
if (key === 'NAPCAT_LOGIN_HUMAN_VERIFY_EXPIRE_MS') return '900000';
return '';
}),
} as unknown as ConfigService,
{} as QqbotAccountService,
{} as QqbotNapcatContainerService,
new ToolsService(),
);
const qrcodeSession = (refreshService as any).createSession({
container: { id: 'container-qr', name: 'napcat-qr' },
mode: 'refresh',
status: 'pending',
});
const captchaSession = (refreshService as any).createSession({
container: { id: 'container-captcha', name: 'napcat-captcha' },
mode: 'refresh',
status: 'pending',
});
const newDeviceSession = (refreshService as any).createSession({
container: { id: 'container-device', name: 'napcat-device' },
mode: 'refresh',
status: 'pending',
});
const captcha = (refreshService as any).keepPasswordCaptchaPending(
captchaSession,
'https://ti.qq.com/safe/tools/captcha/sms-verify-login',
);
const newDevice = (refreshService as any).keepNewDevicePending(
newDeviceSession,
'qr-pending',
'新设备二维码待扫码',
'new-device-qrcode-ready',
);
expect(qrcodeSession.expiresAt).toBe(now + 120_000);
expect(captcha.expiresAt).toBe(now + 900_000);
expect(newDevice.expiresAt).toBe(now + 900_000);
});
it('recovers stale refresh preparation left by a restarted API pod', async () => {
const container = {
baseUrl: 'http://127.0.0.1:6103/',

View File

@ -132,6 +132,7 @@ describe('RuntimeConfigService', () => {
QQBOT_NAPCAT_SSH_TARGET: 'nas',
QQBOT_NAPCAT_SSH_PORT: '2202',
QQBOT_NAPCAT_SSH_KEY_PATH: '/home/kt/.ssh/napcat',
NAPCAT_LOGIN_HUMAN_VERIFY_EXPIRE_MS: '900000',
QQBOT_NAPCAT_REVERSE_WS_BASE: 'ws://api.example.test/onebot',
QQBOT_REVERSE_WS_PATH: '/qqbot/reverse',
QQBOT_REVERSE_WS_TOKEN: 'qq-reverse-token',
@ -195,6 +196,13 @@ describe('RuntimeConfigService', () => {
present: true,
}),
);
expect(checks).toContainEqual(
expect.objectContaining({
key: 'NAPCAT_LOGIN_HUMAN_VERIFY_EXPIRE_MS',
level: 'optional',
present: true,
}),
);
expect(checks).toContainEqual(
expect.objectContaining({
key: 'NAPCAT_WEBUI_BASE_URL|QQBOT_NAPCAT_WEBUI_URL',