diff --git a/API.md b/API.md index 89ea4f4..05252fb 100644 --- a/API.md +++ b/API.md @@ -367,7 +367,7 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT 同一 QQ 账号只保留一个有效 NapCat 主容器。扫码后如果已有账号绑定到新容器,后端会释放旧绑定和未共享的旧容器,避免同账号多实例互相挤下线。OneBot notice 只有机器人下线、登录失效、`KickedOffLine` 等账号级信号才会记录 QQ 登录态异常并生成 `qqbot.account.offline` 站内信,普通群成员 kick 不属于账号离线信号。下线原因写入 `lastError` 前按 `last_error` 500 字符列宽截断;后续无错误的普通断连只更新 OneBot 连接状态,不清空该原因。账号列表会按近期缓存检查绑定 NapCat 容器的最新登录状态日志,日志检测默认 5 秒超时;`isOnline:false` 属于 QQ 登录态离线信号;心跳只代表 OneBot/容器通信,不能推导 QQ 登录态;近期连接只用于避免重连瞬间被旧缓存误伤,后续仍必须以 NapCat WebUI/日志检查判断 QQ 登录态。托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像。 -托管 NapCat 容器按账号持久化设备身份,`napcat_device_identity` 保存账号对应的数据目录、hostname、machine-id 路径、MAC 地址、验证状态和最近登录证据。重建同一账号容器时会复用 `pc-<8hex>` hostname、`02:42:*` MAC 和 machine-id;新增账号创建期在真实 QQ selfId 未知时使用预留容器 id 创建临时设备身份,第一次 Docker run 就注入完整拟真参数,扫码成功后再把该身份和 runtime/protocol profile 归属到真实账号。Docker run 会注入 `--hostname`、`--mac-address` 和只读 `/etc/machine-id` 挂载;后端还会同步写入 QQNT Linux `machine-info`,让 QQNT 计算 GUID 时使用的 MAC 与 Docker 网卡一致。当前策略名为 `qqnt-visible-hostname-v1` / `docker-bridge-mac-v1`,绑定关系会回填 `napcat_account_binding.device_identity_id`。 +托管 NapCat 容器按账号持久化设备身份,`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 Runtime Profile diff --git a/README.md b/README.md index 0739a96..3bf0df0 100644 --- a/README.md +++ b/README.md @@ -168,7 +168,7 @@ API 暴露 `GET /health/runtime` 作为本地 smoke、Jenkins/K8s 和 ktWorkflow - NapCat 托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像;生产切换镜像前先 pin 明确版本或 digest 并单账号观察。 - NapCat 账号新增/编辑支持可选 QQ 登录密码:Admin 只提交 RSA-OAEP 加密后的 `encryptedLoginPassword`,后端解密后必须用显式配置的 `QQBOT_ACCOUNT_SECRET_KEY`(或非默认 `ADMIN_TOKEN_SECRET`)二次加密保存到 `qqbot_account.napcat_login_password_secret`;空值、`change-me` 和历史公开默认值会被拒绝;列表和详情不回显密码,日志会脱敏密码字段。 - NapCat 容器为已知 `selfId` 创建/重建时会一次性注入 `ACCOUNT` 等必要 env;容器重启(崩溃/重启策略/宿主重启)可复用持久化会话,但硬踢 `登录已失效` 仍需人工登录。Admin「更新登录」不通过 Docker 重建、重启或补 env 刷新登录态:只要源容器在线,就保持同一容器并通过 NapCat WebUI `SetQuickLogin -> PasswordLogin -> RefreshQRcode/GetQQLoginQrcode` 推进原弹窗流程;只有 Docker 容器离线或缺失时,容器准备阶段才创建/重建并一次性注入 env。快速登录失败后,如果账号保存了登录密码,后端使用解密密码计算 MD5 调 `/api/QQLogin/PasswordLogin`,不会把密码写入运行态 env,也没有成功后的 env 清理步骤;密码登录按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询结果。准备中的扫码会话会续期,重复调用更新登录会复用同一 pending `sessionId`,不会再次启动 quick/password/二维码准备。若 API Pod 在准备阶段重启,持久化的 `preparingRelogin` 超过 `QQBOT_NAPCAT_RELOGIN_PREPARING_STALE_MS` 后会自动恢复为普通状态检测。验证码和新设备验证保持同一会话 pending:腾讯验证码结果 `ticket`/`randstr`/`sid` 通过 `/qqbot/account/scan/captcha/submit` 回交到同一容器的 `/api/QQLogin/CaptchaLogin`;状态轮询遇到验证码文案但缺少 URL 时会先从当前容器日志恢复 `proofWaterUrl`,没有 URL 也保持验证码处理中而不切到二维码兜底。密码登录仍失败、验证码未完成、离线、账号不匹配或缺少 QQ 号时,直接通过 WebUI 二维码接口进入扫码兜底,不 reset 登录态。Admin SSE 步骤顺序按实际路径为 `quick-login-*` -> `password-login-*` / `password-login-captcha` / 新设备验证 -> `qrcode/waiting-scan` -> `login-success|login-failed`;SSE 事件缓存因 Pod 重启丢失时,新订阅会收到当前会话快照。 -- NapCat 设备身份按账号持久化到 `napcat_device_identity`:同一账号重建容器会复用数据目录、`pc-<8hex>` hostname、machine-id 和 `02:42:*` MAC;新增账号首次扫码会先用预留容器 id 创建临时设备身份并应用到第一次 Docker run,扫码成功后归属到真实账号并同步 runtime/protocol profile;Docker run 会注入 `--hostname`、`--mac-address`、只读 `/etc/machine-id`,并同步写入 QQNT Linux `machine-info`,使 `/etc/machine-id`、Docker MAC 和 QQNT 本地设备缓存保持一致。当前策略名为 `qqnt-visible-hostname-v1` / `docker-bridge-mac-v1`。 +- NapCat 设备身份按账号持久化到 `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 手动触发「更新登录」。 - BangDream 当前源码根目录是 `src/modules/qqbot/plugins/bangdream/src`;按第三期插件结构放置真实职责代码:业务在 `domain/*`,编排在 `application`,操作在 `operations`,外部 API 在 `infrastructure/integration`,缓存/静态修正在 `infrastructure/storage`,字典和静态配置在 `config`,视觉渲染公共件在 `theme`;不要恢复旧 `tsugu` 层级、旧大桶目录、纯 re-export 转接文件或空 `.gitkeep` 目录壳。 diff --git a/ci/napcat-desktop-cn/Dockerfile b/ci/napcat-desktop-cn/Dockerfile index 57ae97b..2f77162 100644 --- a/ci/napcat-desktop-cn/Dockerfile +++ b/ci/napcat-desktop-cn/Dockerfile @@ -1,4 +1,4 @@ -ARG NAPCAT_BASE_IMAGE= +ARG NAPCAT_BASE_IMAGE=mlikiowa/napcat-docker:latest FROM ${NAPCAT_BASE_IMAGE} USER root @@ -11,7 +11,8 @@ RUN set -eux; \ fonts-noto-cjk \ fonts-wqy-microhei \ locales \ - tzdata; \ + tzdata \ + xdg-utils; \ sed -i 's/^# *zh_CN.UTF-8 UTF-8/zh_CN.UTF-8 UTF-8/' /etc/locale.gen; \ locale-gen zh_CN.UTF-8; \ ln -snf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime; \ diff --git a/k8s/prod/api.yaml b/k8s/prod/api.yaml index f54bf7b..511956c 100644 --- a/k8s/prod/api.yaml +++ b/k8s/prod/api.yaml @@ -30,6 +30,10 @@ spec: value: production - name: TZ value: Asia/Shanghai + - name: DB_TIMEZONE + value: "+08:00" + - name: QQBOT_NAPCAT_IMAGE + value: kt-napcat-desktop-cn:desktop-cn-v1 - name: QQBOT_NAPCAT_SSH_KEY_PATH value: /app/secrets/napcat-ssh/id_rsa - name: QQBOT_PLUGIN_TASK_QUEUE_REDIS_PREFIX diff --git a/src/modules/qqbot/core/application/account/qqbot-account.service.ts b/src/modules/qqbot/core/application/account/qqbot-account.service.ts index 99901a8..84097f1 100644 --- a/src/modules/qqbot/core/application/account/qqbot-account.service.ts +++ b/src/modules/qqbot/core/application/account/qqbot-account.service.ts @@ -28,6 +28,8 @@ import type { QqbotAccountAbilityType, QqbotAccountListItem, QqbotConnectionRole, + QqbotNapcatContainerStatus, + QqbotNapcatWebuiStatus, } from '../../contract/qqbot.types'; const INSECURE_ACCOUNT_SECRET_VALUES = new Set([ @@ -291,13 +293,17 @@ export class QqbotAccountService { const payload: Partial = { accessToken: null, clientRole: null, + containerStatus: 'unknown', connectStatus: 'offline', connectionMode: 'reverse-ws', enabled: true, isDeleted: false, lastError: null, name: input.name || existing?.name || `QQ ${selfId}`, + oneBotStatus: 'offline', + qqLoginStatus: 'unknown', selfId, + webuiStatus: 'unknown', }; if (existing) { @@ -337,11 +343,15 @@ export class QqbotAccountService { await this.accountRepository.update( { id: existing.id }, { + containerStatus: 'unknown', connectStatus: 'offline', enabled: true, isDeleted: false, lastError: null, name: existing.name || `QQ ${normalizedSelfId}`, + oneBotStatus: 'offline', + qqLoginStatus: 'unknown', + webuiStatus: 'unknown', }, ); return; @@ -350,11 +360,15 @@ export class QqbotAccountService { await this.accountRepository.save( this.accountRepository.create({ connectionMode: 'reverse-ws', + containerStatus: 'unknown', connectStatus: 'offline', enabled: true, name: `QQ ${normalizedSelfId}`, + oneBotStatus: 'offline', + qqLoginStatus: 'unknown', remark: '', selfId: normalizedSelfId, + webuiStatus: 'unknown', }), ); } @@ -420,9 +434,13 @@ export class QqbotAccountService { await this.accountRepository.update( { id }, { + containerStatus: 'unknown', connectStatus: 'offline', enabled: false, isDeleted: true, + oneBotStatus: 'offline', + qqLoginStatus: 'unknown', + webuiStatus: 'unknown', }, ); await this.accountAbilityRepository.update( @@ -449,6 +467,7 @@ export class QqbotAccountService { clientRole, connectStatus: 'online', lastConnectedAt: new Date(), + oneBotStatus: 'online', }; if (lastError !== undefined) { payload.lastError = lastError @@ -468,6 +487,7 @@ export class QqbotAccountService { { connectStatus: 'online', lastHeartbeatAt: new Date(), + oneBotStatus: 'online', }, ); } @@ -501,6 +521,7 @@ export class QqbotAccountService { async markOffline(selfId: string, lastError?: string) { const payload: Partial = { connectStatus: 'offline', + oneBotStatus: 'offline', }; if (lastError !== undefined) { payload.lastError = lastError @@ -520,6 +541,7 @@ export class QqbotAccountService { { selfId }, { lastError: this.toolsService.toColumnText(lastError, 500), + qqLoginStatus: 'offline', }, ); } @@ -555,7 +577,7 @@ export class QqbotAccountService { ); } - return this.napcatRuntime.appendRuntime(accounts, { + const list = await this.napcatRuntime.appendRuntime(accounts, { /** * 执行 QQBot回调。 * @param selfId - 账号 ID;定位本次读取、更新、删除或关联的账号。 @@ -579,6 +601,90 @@ export class QqbotAccountService { publishOfflineNotice: (selfId, offlineReason, metadata) => this.publishOfflineNotice(selfId, offlineReason, metadata), }); + await Promise.all( + list.map((account) => this.syncPersistedNapcatSplitStatus(account)), + ); + return list; + } + + /** + * Persists computed NapCat split statuses back to qqbot_account when the entity exposes the v3 status columns. + * @param account - Enriched account list row; its `napcat` snapshot is the latest runtime evidence produced for Admin. + */ + private async syncPersistedNapcatSplitStatus( + account: QqbotAccountListItem, + ) { + if (!this.hasPersistedNapcatSplitStatus(account)) return; + + const payload = this.buildNapcatSplitStatusPayload(account); + const current = account as unknown as Record; + const changed = Object.entries(payload).some( + ([key, value]) => current[key] !== value, + ); + if (!changed) return; + + await this.accountRepository.update({ id: account.id }, payload); + Object.assign(account, payload); + } + + /** + * Checks whether a hydrated account row includes the v3 split-status properties that should be kept in sync. + * @param account - Account row or test double; legacy test doubles without these properties skip persistence. + * @returns True when status synchronization can write meaningful column updates. + */ + private hasPersistedNapcatSplitStatus(account: QqbotAccountListItem) { + return [ + 'oneBotStatus', + 'containerStatus', + 'webuiStatus', + 'qqLoginStatus', + ].some((key) => Object.prototype.hasOwnProperty.call(account, key)); + } + + /** + * Converts the latest account-list runtime evidence into qqbot_account split-status column values. + * @param account - Account row plus optional NapCat runtime evidence returned from the runtime adapter. + * @returns Partial account payload containing only the status columns owned by the split-status contract. + */ + private buildNapcatSplitStatusPayload( + account: QqbotAccountListItem, + ): Pick< + QqbotAccount, + 'containerStatus' | 'oneBotStatus' | 'qqLoginStatus' | 'webuiStatus' + > { + const napcat = account.napcat || null; + return { + containerStatus: this.toPersistedContainerStatus(napcat), + oneBotStatus: account.connectStatus === 'online' ? 'online' : 'offline', + qqLoginStatus: napcat?.qqLoginStatus || 'unknown', + webuiStatus: this.toPersistedWebuiStatus(napcat?.webuiOnline), + }; + } + + /** + * Normalizes container evidence for the qqbot_account.container_status cache column. + * @param napcat - Optional NapCat runtime info attached to the account-list row. + * @returns Running/stopped/error/creating when known, otherwise unknown. + */ + private toPersistedContainerStatus( + napcat?: null | QqbotAccountListItem['napcat'], + ): QqbotNapcatContainerStatus { + if (napcat?.containerStatus) return napcat.containerStatus; + if (napcat?.containerOnline) return 'running'; + return 'unknown'; + } + + /** + * Normalizes WebUI probe evidence for the qqbot_account.webui_status cache column. + * @param webuiOnline - Runtime probe value; null/undefined means no fresh WebUI probe was performed. + * @returns Persisted WebUI status suitable for filtering and raw DB inspection. + */ + private toPersistedWebuiStatus( + webuiOnline?: boolean | null, + ): QqbotNapcatWebuiStatus { + if (webuiOnline === true) return 'online'; + if (webuiOnline === false) return 'offline'; + return 'unknown'; } /** diff --git a/src/modules/qqbot/core/contract/qqbot.types.ts b/src/modules/qqbot/core/contract/qqbot.types.ts index e11a989..c9c0160 100644 --- a/src/modules/qqbot/core/contract/qqbot.types.ts +++ b/src/modules/qqbot/core/contract/qqbot.types.ts @@ -76,10 +76,13 @@ export type QqbotNapcatContainerStatus = | 'creating' | 'error' | 'running' - | 'stopped'; + | 'stopped' + | 'unknown'; export type QqbotAccountNapcatBindStatus = 'bound' | 'disabled' | 'pending'; +export type QqbotNapcatWebuiStatus = 'offline' | 'online' | 'unknown'; + export type QqbotNapcatRuntimeLoginStatus = | 'offline' | 'online' diff --git a/src/modules/qqbot/core/infrastructure/persistence/account/qqbot-account.entity.ts b/src/modules/qqbot/core/infrastructure/persistence/account/qqbot-account.entity.ts index d0cde8f..d97bc23 100644 --- a/src/modules/qqbot/core/infrastructure/persistence/account/qqbot-account.entity.ts +++ b/src/modules/qqbot/core/infrastructure/persistence/account/qqbot-account.entity.ts @@ -10,6 +10,9 @@ import type { QqbotConnectionMode, QqbotConnectionRole, QqbotConnectionStatus, + QqbotNapcatContainerStatus, + QqbotNapcatRuntimeLoginStatus, + QqbotNapcatWebuiStatus, } from '../../../contract/qqbot.types'; @Entity('qqbot_account') @@ -41,6 +44,18 @@ export class QqbotAccount { @Column({ default: 'offline', length: 32, name: 'connect_status' }) connectStatus: QqbotConnectionStatus; + @Column({ default: 'offline', length: 32, name: 'onebot_status' }) + oneBotStatus: QqbotConnectionStatus; + + @Column({ default: 'unknown', length: 32, name: 'container_status' }) + containerStatus: QqbotNapcatContainerStatus; + + @Column({ default: 'unknown', length: 32, name: 'webui_status' }) + webuiStatus: QqbotNapcatWebuiStatus; + + @Column({ default: 'unknown', length: 32, name: 'qq_login_status' }) + qqLoginStatus: QqbotNapcatRuntimeLoginStatus; + @Column({ default: null, length: 32, name: 'client_role', nullable: true }) clientRole: null | QqbotConnectionRole; diff --git a/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts b/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts index add3418..b95651a 100644 --- a/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts +++ b/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts @@ -127,7 +127,7 @@ export class NapcatRuntimeProfileService { lastCheckedAt: null, locale: input.runtimeProfile.locale, localeAvailable: false, - macStrategy: input.deviceIdentity?.macStrategy || 'managed-mac-v1', + macStrategy: input.deviceIdentity?.macStrategy || 'physical-oui-mac-v1', migrateDeviceIdentity: !!input.deviceIdentity, persistCache: input.runtimeProfile.persistCache, persistLocalShare: input.runtimeProfile.persistLocalShare, diff --git a/src/modules/qqbot/napcat/domain/runtime/napcat-physical-oui-catalog.ts b/src/modules/qqbot/napcat/domain/runtime/napcat-physical-oui-catalog.ts index d472083..84a3fed 100644 --- a/src/modules/qqbot/napcat/domain/runtime/napcat-physical-oui-catalog.ts +++ b/src/modules/qqbot/napcat/domain/runtime/napcat-physical-oui-catalog.ts @@ -38,3 +38,15 @@ export function isRejectedVirtualMacPrefix(macAddress: string) { normalized.startsWith(prefix.toUpperCase()), ); } + +/** + * Checks whether a generated MAC starts with one of the allowed physical-device OUIs. + * @param macAddress - Stable MAC candidate generated for a NapCat device identity. + * @returns True when the MAC prefix belongs to the curated physical OUI catalog. + */ +export function hasPhysicalOuiMacPrefix(macAddress: string) { + const normalized = macAddress.toUpperCase(); + return NAPCAT_PHYSICAL_OUI_PREFIXES.some((prefix) => + normalized.startsWith(prefix.toUpperCase()), + ); +} diff --git a/src/modules/qqbot/napcat/infrastructure/integration/device/napcat-device-identity.service.ts b/src/modules/qqbot/napcat/infrastructure/integration/device/napcat-device-identity.service.ts index 0699cfd..8d2f5c9 100644 --- a/src/modules/qqbot/napcat/infrastructure/integration/device/napcat-device-identity.service.ts +++ b/src/modules/qqbot/napcat/infrastructure/integration/device/napcat-device-identity.service.ts @@ -5,9 +5,14 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { ensureSnowflakeId } from '@/common'; import { NapcatDeviceIdentity } from '../../persistence/napcat-device-identity.entity'; +import { + hasPhysicalOuiMacPrefix, + isRejectedVirtualMacPrefix, + NAPCAT_PHYSICAL_OUI_PREFIXES, +} from '../../../domain/runtime/napcat-physical-oui-catalog'; const QQNT_VISIBLE_HOSTNAME_STRATEGY = 'qqnt-visible-hostname-v1'; -const QQNT_DOCKER_BRIDGE_MAC_STRATEGY = 'docker-bridge-mac-v1'; +const QQNT_PHYSICAL_OUI_MAC_STRATEGY = 'physical-oui-mac-v1'; type ResolveNapcatDeviceIdentityInput = { accountId: string; @@ -72,8 +77,8 @@ export class NapcatDeviceIdentityService { ), hostnameStrategy: QQNT_VISIBLE_HOSTNAME_STRATEGY, lastLoginEvidence: null, - macAddress: this.buildDockerBridgeMacAddress(accountId, containerName), - macStrategy: QQNT_DOCKER_BRIDGE_MAC_STRATEGY, + macAddress: this.buildPhysicalOuiMacAddress(accountId, containerName), + macStrategy: QQNT_PHYSICAL_OUI_MAC_STRATEGY, machineIdPath: `${dataDir}/machine-id`, verificationStatus: 'pending', }); @@ -231,26 +236,30 @@ export class NapcatDeviceIdentityService { } /** - * Builds a stable Docker bridge MAC that can be mirrored into QQNT machine-info. + * Builds a stable physical-OUI MAC that can be mirrored into QQNT machine-info without using VM/container prefixes. * @param accountId - Account id used as a deterministic seed, not as visible output. * @param containerName - Container name mixed into the deterministic seed. + * @returns Lower-case MAC address with a curated physical-device OUI prefix. */ - private buildDockerBridgeMacAddress( + private buildPhysicalOuiMacAddress( accountId: string, containerName: string, ) { const hash = createHash('sha256') .update( - `${accountId}:${containerName}:${QQNT_DOCKER_BRIDGE_MAC_STRATEGY}`, + `${accountId}:${containerName}:${QQNT_PHYSICAL_OUI_MAC_STRATEGY}`, ) .digest('hex'); + const prefix = + NAPCAT_PHYSICAL_OUI_PREFIXES[ + parseInt(hash.slice(0, 8), 16) % NAPCAT_PHYSICAL_OUI_PREFIXES.length + ]; const suffix = [ - hash.slice(0, 2), - hash.slice(2, 4), - hash.slice(4, 6), - hash.slice(6, 8), + hash.slice(8, 10), + hash.slice(10, 12), + hash.slice(12, 14), ]; - return `02:42:${suffix.join(':')}`.toLowerCase(); + return `${prefix}:${suffix.join(':')}`.toLowerCase(); } /** @@ -269,15 +278,16 @@ export class NapcatDeviceIdentityService { const nextHostname = this.buildQqntVisibleHostname( `${input.accountId}:${input.selfId}`, ); - const nextMacAddress = this.buildDockerBridgeMacAddress( + const nextMacAddress = this.buildPhysicalOuiMacAddress( input.accountId, input.containerName, ); const needsMigration = identity.hostnameStrategy !== QQNT_VISIBLE_HOSTNAME_STRATEGY || - identity.macStrategy !== QQNT_DOCKER_BRIDGE_MAC_STRATEGY || + identity.macStrategy !== QQNT_PHYSICAL_OUI_MAC_STRATEGY || !/^pc-[a-f0-9]{8}$/.test(identity.hostname || '') || - !/^02:42:([0-9a-f]{2}:){3}[0-9a-f]{2}$/i.test(identity.macAddress || ''); + !hasPhysicalOuiMacPrefix(identity.macAddress || '') || + isRejectedVirtualMacPrefix(identity.macAddress || ''); if (!needsMigration) { return; @@ -287,7 +297,7 @@ export class NapcatDeviceIdentityService { migration: { fromHostname: identity.hostname, fromMacAddress: identity.macAddress, - strategy: QQNT_DOCKER_BRIDGE_MAC_STRATEGY, + strategy: QQNT_PHYSICAL_OUI_MAC_STRATEGY, toHostname: nextHostname, toMacAddress: nextMacAddress, trigger: 'qqnt-device-name-regression-repair', @@ -301,7 +311,7 @@ export class NapcatDeviceIdentityService { hostnameStrategy: QQNT_VISIBLE_HOSTNAME_STRATEGY, lastLoginEvidence: migrationEvidence, macAddress: nextMacAddress, - macStrategy: QQNT_DOCKER_BRIDGE_MAC_STRATEGY, + macStrategy: QQNT_PHYSICAL_OUI_MAC_STRATEGY, }, ); Object.assign(identity, { @@ -309,7 +319,7 @@ export class NapcatDeviceIdentityService { hostnameStrategy: QQNT_VISIBLE_HOSTNAME_STRATEGY, lastLoginEvidence: migrationEvidence, macAddress: nextMacAddress, - macStrategy: QQNT_DOCKER_BRIDGE_MAC_STRATEGY, + macStrategy: QQNT_PHYSICAL_OUI_MAC_STRATEGY, }); } diff --git a/test/modules/qqbot/core/qqbot-core-status-contract.spec.ts b/test/modules/qqbot/core/qqbot-core-status-contract.spec.ts index 0f909d3..47ce88b 100644 --- a/test/modules/qqbot/core/qqbot-core-status-contract.spec.ts +++ b/test/modules/qqbot/core/qqbot-core-status-contract.spec.ts @@ -1,5 +1,7 @@ import { QQBOT_CORE_DOMAIN_CONTRACT } from '../../../../src/modules/qqbot/core/contract/qqbot-core.contract'; +import { QqbotAccount } from '../../../../src/modules/qqbot/core/infrastructure/persistence/account/qqbot-account.entity'; import { readRefactorV3SqlSchema } from '../../../helpers/sql-schema.helper'; +import { getMetadataArgsStorage } from 'typeorm'; describe('QQBot core status contract', () => { const schema = readRefactorV3SqlSchema(); @@ -36,4 +38,19 @@ describe('QQBot core status contract', () => { 'close_reason', ]); }); + + it('maps split account status columns on the QqbotAccount entity', () => { + const columns = getMetadataArgsStorage() + .columns.filter((column) => column.target === QqbotAccount) + .map((column) => `${column.options.name || column.propertyName}`); + + expect(columns).toEqual( + expect.arrayContaining([ + 'onebot_status', + 'container_status', + 'webui_status', + 'qq_login_status', + ]), + ); + }); }); diff --git a/test/modules/qqbot/napcat/device-identity.spec.ts b/test/modules/qqbot/napcat/device-identity.spec.ts index bd30c59..c875c92 100644 --- a/test/modules/qqbot/napcat/device-identity.spec.ts +++ b/test/modules/qqbot/napcat/device-identity.spec.ts @@ -10,6 +10,10 @@ import { } from '../../../../src/modules/qqbot/napcat'; import { toNapcatDockerDeviceOptions } from '../../../../src/modules/qqbot/napcat/infrastructure/integration/container/napcat-docker-device-options'; import { QqbotNapcatContainerService } from '../../../../src/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service'; +import { + hasPhysicalOuiMacPrefix, + isRejectedVirtualMacPrefix, +} from '../../../../src/modules/qqbot/napcat/domain/runtime/napcat-physical-oui-catalog'; import { readRefactorV3SqlSchema } from '../../../helpers/sql-schema.helper'; type EntityClass = new (...args: never[]) => unknown; @@ -212,7 +216,7 @@ describe('NapCat device identity persistence', () => { expect(identity.hostname).not.toMatch(/10001|qq|bot|napcat|docker/i); }); - it('generates a stable Docker bridge MAC that matches QQNT machine-info expectations', async () => { + it('generates a stable physical-OUI MAC that rejects virtual adapter prefixes', async () => { const repository = createIdentityRepository(); const service = new NapcatDeviceIdentityService( repository as any, @@ -225,12 +229,10 @@ describe('NapCat device identity persistence', () => { selfId: '10001', }); - expect(identity.macAddress).toMatch(/^02:42:([0-9a-f]{2}:){3}[0-9a-f]{2}$/); - expect(identity.macAddress).not.toMatch(/^52:54:00/i); - expect(identity.macAddress).not.toMatch( - /^(00:05:69|00:0c:29|00:1c:14|00:50:56)/i, - ); - expect(identity.macStrategy).toBe('docker-bridge-mac-v1'); + expect(identity.macAddress).toMatch(/^([0-9a-f]{2}:){5}[0-9a-f]{2}$/); + expect(hasPhysicalOuiMacPrefix(identity.macAddress)).toBe(true); + expect(isRejectedVirtualMacPrefix(identity.macAddress)).toBe(false); + expect(identity.macStrategy).toBe('physical-oui-mac-v1'); }); it('records regression-repair evidence when an existing identity is realigned for QQNT', async () => { @@ -258,13 +260,14 @@ describe('NapCat device identity persistence', () => { selfId: '10001', }); - expect(identity.macAddress).toMatch(/^02:42:/i); + expect(hasPhysicalOuiMacPrefix(identity.macAddress)).toBe(true); + expect(isRejectedVirtualMacPrefix(identity.macAddress)).toBe(false); expect(identity.macAddress).not.toBe('02:42:aa:bb:cc:dd'); expect(identity.hostname).not.toBe('kt-qqbot-napcat-10001'); expect(identity.lastLoginEvidence).toMatchObject({ migration: { fromMacAddress: '02:42:aa:bb:cc:dd', - strategy: 'docker-bridge-mac-v1', + strategy: 'physical-oui-mac-v1', trigger: 'qqnt-device-name-regression-repair', }, }); @@ -536,8 +539,8 @@ describe('NapCat device identity persistence', () => { hostnameStrategy: 'qqnt-visible-hostname-v1', id: 'identity-created', lastLoginEvidence: null, - macAddress: '02:42:aa:bb:cc:dd', - macStrategy: 'docker-bridge-mac-v1', + macAddress: '3c:97:0e:aa:bb:cc', + macStrategy: 'physical-oui-mac-v1', machineIdPath: '/vol1/docker/kt-qqbot/napcat-instances/kt-qqbot-napcat-container-created/machine-id', verificationStatus: 'pending', @@ -608,8 +611,8 @@ describe('NapCat device identity persistence', () => { hostnameStrategy: 'qqnt-visible-hostname-v1', id: 'identity-created', lastLoginEvidence: null, - macAddress: '02:42:aa:bb:cc:dd', - macStrategy: 'docker-bridge-mac-v1', + macAddress: '3c:97:0e:aa:bb:cc', + macStrategy: 'physical-oui-mac-v1', machineIdPath: '/vol1/docker/kt-qqbot/napcat-instances/kt-qqbot-napcat-container-created/machine-id', verificationStatus: 'pending', diff --git a/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts b/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts index 8a8f101..5527de6 100644 --- a/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts +++ b/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts @@ -175,8 +175,8 @@ describe('NapCat runtime profile generation', () => { hostnameStrategy: 'qqnt-visible-hostname-v1', machineInfoPath: '/vol1/docker/kt-qqbot/napcat-instances/pc-a1b2c3d4/QQ/nt_qq/global/nt_data/msf/machine-info', - macAddress: '02:42:aa:bb:cc:dd', - macStrategy: 'docker-bridge-mac-v1', + macAddress: '3c:97:0e:aa:bb:cc', + macStrategy: 'physical-oui-mac-v1', }, protocolProfile: { napcatConfigHash: 'napcat-hash', @@ -201,9 +201,9 @@ describe('NapCat runtime profile generation', () => { dataDir: runtimeProfile.dataDir, machineInfoPath: '/vol1/docker/kt-qqbot/napcat-instances/pc-a1b2c3d4/QQ/nt_qq/global/nt_data/msf/machine-info', - macAddress: '02:42:aa:bb:cc:dd', + macAddress: '3c:97:0e:aa:bb:cc', }), - macStrategy: 'docker-bridge-mac-v1', + macStrategy: 'physical-oui-mac-v1', profileStatus: 'pending', profileVersion: 'napcat-runtime-profile-v1', timezoneEvidence: { diff --git a/test/qqbot/account/qqbot-account.service.spec.ts b/test/qqbot/account/qqbot-account.service.spec.ts index e7a08ae..a53af26 100644 --- a/test/qqbot/account/qqbot-account.service.spec.ts +++ b/test/qqbot/account/qqbot-account.service.spec.ts @@ -242,9 +242,10 @@ describe('QqbotAccountService', () => { expect(accountRepository.update).toHaveBeenCalledWith( { selfId: '1914728559' }, - { + expect.objectContaining({ connectStatus: 'offline', - }, + oneBotStatus: 'offline', + }), ); }); @@ -286,6 +287,24 @@ describe('QqbotAccountService', () => { ); }); + it('persists split OneBot online status when heartbeat arrives', async () => { + const accountRepository = { + update: jest.fn(), + }; + const service = createAccountService({ accountRepository }); + + await service.markHeartbeat('1914728559'); + + expect(accountRepository.update).toHaveBeenCalledWith( + { selfId: '1914728559' }, + expect.objectContaining({ + connectStatus: 'online', + lastHeartbeatAt: expect.any(Date), + oneBotStatus: 'online', + }), + ); + }); + it('truncates offline reason before writing lastError column', async () => { const accountRepository = { update: jest.fn(), @@ -296,10 +315,11 @@ describe('QqbotAccountService', () => { expect(accountRepository.update).toHaveBeenCalledWith( { selfId: '1914728559' }, - { + expect.objectContaining({ connectStatus: 'offline', lastError: `${'错误'.repeat(248)}错...`, - }, + oneBotStatus: 'offline', + }), ); }); @@ -384,9 +404,10 @@ describe('QqbotAccountService', () => { expect(accountRepository.update).toHaveBeenCalledWith( { selfId: '1914728559' }, - { + expect.objectContaining({ lastError: 'NapCat 账号状态变更为离线', - }, + qqLoginStatus: 'offline', + }), ); expect(page.list[0]).toEqual( expect.objectContaining({ @@ -887,9 +908,10 @@ describe('QqbotAccountService', () => { ); expect(accountRepository.update).toHaveBeenCalledWith( { selfId: '1914728559' }, - { + expect.objectContaining({ lastError: '账号状态变更为离线', - }, + qqLoginStatus: 'offline', + }), ); expect(page.list[0]).toEqual( expect.objectContaining({ @@ -906,6 +928,7 @@ describe('QqbotAccountService', () => { it('clears previous QQ login error when NapCat WebUI confirms QQ is online', async () => { const checkedAt = new Date(Date.now() - 60_000); const account = { + containerStatus: 'unknown', connectStatus: 'online', enabled: true, id: 'account-1', @@ -913,7 +936,10 @@ describe('QqbotAccountService', () => { lastConnectedAt: new Date(Date.now() - 120_000), lastError: '账号状态变更为离线', name: '主账号', + oneBotStatus: 'offline', + qqLoginStatus: 'unknown', selfId: '1914728559', + webuiStatus: 'unknown', }; const binding = { accountId: 'account-1', @@ -981,6 +1007,15 @@ describe('QqbotAccountService', () => { { selfId: '1914728559' }, { lastError: null }, ); + expect(accountRepository.update).toHaveBeenCalledWith( + { id: 'account-1' }, + expect.objectContaining({ + containerStatus: 'running', + oneBotStatus: 'online', + qqLoginStatus: 'online', + webuiStatus: 'online', + }), + ); expect(page.list[0]).toEqual( expect.objectContaining({ lastError: null, @@ -1072,9 +1107,10 @@ describe('QqbotAccountService', () => { expect(result).toEqual({ checked: 1 }); expect(accountRepository.update).toHaveBeenCalledWith( { selfId: '1914728559' }, - { + expect.objectContaining({ lastError: 'NapCat 账号状态变更为离线', - }, + qqLoginStatus: 'offline', + }), ); expect(systemNoticePublisher.publishSystemNotice).toHaveBeenCalledWith( expect.objectContaining({ @@ -1168,9 +1204,10 @@ describe('QqbotAccountService', () => { expect(accountRepository.update).toHaveBeenCalledTimes(1); expect(accountRepository.update).toHaveBeenCalledWith( { selfId: '1914728559' }, - { + expect.objectContaining({ lastError: 'NapCat 账号状态变更为离线', - }, + qqLoginStatus: 'offline', + }), ); expect(systemNoticePublisher.publishSystemNotice).toHaveBeenCalledWith( expect.objectContaining({