fix: 修复NapCat新增账号设备画像和状态持久化
This commit is contained in:
parent
7c7266ba8a
commit
a02f6a4250
2
API.md
2
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
|
||||
|
||||
|
||||
@ -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` 目录壳。
|
||||
|
||||
@ -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; \
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 suffix = [
|
||||
hash.slice(0, 2),
|
||||
hash.slice(2, 4),
|
||||
hash.slice(4, 6),
|
||||
hash.slice(6, 8),
|
||||
const prefix =
|
||||
NAPCAT_PHYSICAL_OUI_PREFIXES[
|
||||
parseInt(hash.slice(0, 8), 16) % NAPCAT_PHYSICAL_OUI_PREFIXES.length
|
||||
];
|
||||
return `02:42:${suffix.join(':')}`.toLowerCase();
|
||||
const suffix = [
|
||||
hash.slice(8, 10),
|
||||
hash.slice(10, 12),
|
||||
hash.slice(12, 14),
|
||||
];
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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',
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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({
|
||||
|
||||
Loading…
Reference in New Issue
Block a user