fix: 修复NapCat新增账号设备画像和状态持久化

This commit is contained in:
sunlei 2026-06-22 17:22:30 +08:00
parent 7c7266ba8a
commit a02f6a4250
14 changed files with 261 additions and 53 deletions

2
API.md
View File

@ -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

View File

@ -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 profileDocker 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 profileDocker 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` 目录壳。

View File

@ -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; \

View File

@ -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

View File

@ -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<QqbotAccount> = {
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<QqbotAccount> = {
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<string, unknown>;
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';
}
/**

View File

@ -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'

View File

@ -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;

View File

@ -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,

View File

@ -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()),
);
}

View File

@ -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,
});
}

View File

@ -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',
]),
);
});
});

View File

@ -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',

View File

@ -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: {

View File

@ -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({