1176 lines
36 KiB
TypeScript
1176 lines
36 KiB
TypeScript
import { ToolsService } from '@/common';
|
||
import type { QqbotAccountNapcatRuntimePort } from '@/modules/qqbot/core/application/account/qqbot-account-napcat-runtime.port';
|
||
import { QqbotAccountService } from '@/modules/qqbot/core/application/account/qqbot-account.service';
|
||
import { QqbotNapcatAccountRuntimeService } from '@/modules/qqbot/napcat/application/account-runtime/qqbot-napcat-account-runtime.service';
|
||
|
||
const createAccountService = (input: {
|
||
accountAbilityRepository?: any;
|
||
accountRepository: any;
|
||
configService?: any;
|
||
napcatRuntime?: QqbotAccountNapcatRuntimePort;
|
||
passwordCryptoService?: any;
|
||
systemNoticePublisher?: any;
|
||
toolsService?: ToolsService;
|
||
}) =>
|
||
new QqbotAccountService(
|
||
input.accountRepository,
|
||
input.accountAbilityRepository || {},
|
||
input.toolsService || new ToolsService(),
|
||
input.napcatRuntime,
|
||
input.systemNoticePublisher,
|
||
input.configService,
|
||
input.passwordCryptoService,
|
||
);
|
||
|
||
const createNapcatRuntime = (input: {
|
||
bindingRepository: any;
|
||
containerRepository: any;
|
||
containerService: any;
|
||
toolsService?: ToolsService;
|
||
}) =>
|
||
new QqbotNapcatAccountRuntimeService(
|
||
input.bindingRepository,
|
||
input.containerRepository,
|
||
input.containerService,
|
||
input.toolsService || new ToolsService(),
|
||
);
|
||
|
||
const createAccountServiceWithNapcatRuntime = (input: {
|
||
accountRepository: any;
|
||
bindingRepository: any;
|
||
configService?: any;
|
||
containerRepository: any;
|
||
containerService: any;
|
||
passwordCryptoService?: any;
|
||
systemNoticePublisher?: any;
|
||
toolsService?: ToolsService;
|
||
}) => {
|
||
const toolsService = input.toolsService || new ToolsService();
|
||
return createAccountService({
|
||
accountRepository: input.accountRepository,
|
||
configService: input.configService,
|
||
napcatRuntime: createNapcatRuntime({
|
||
bindingRepository: input.bindingRepository,
|
||
containerRepository: input.containerRepository,
|
||
containerService: input.containerService,
|
||
toolsService,
|
||
}),
|
||
passwordCryptoService: input.passwordCryptoService,
|
||
systemNoticePublisher: input.systemNoticePublisher,
|
||
toolsService,
|
||
});
|
||
};
|
||
|
||
describe('QqbotAccountService', () => {
|
||
it('stores NapCat login password as encrypted secret and never persists the transport field', async () => {
|
||
const toolsService = new ToolsService();
|
||
const accountRepository = {
|
||
create: jest.fn((input) => input),
|
||
findOne: jest.fn().mockResolvedValue(null),
|
||
save: jest.fn(async (input) => ({ ...input, id: 'account-1' })),
|
||
};
|
||
const service = createAccountService({
|
||
accountRepository,
|
||
configService: {
|
||
get: jest.fn((key: string) =>
|
||
key === 'QQBOT_ACCOUNT_SECRET_KEY' ? 'unit-secret' : '',
|
||
),
|
||
},
|
||
passwordCryptoService: {
|
||
decryptPassword: jest.fn().mockReturnValue('qq-login-password'),
|
||
},
|
||
toolsService,
|
||
});
|
||
|
||
await service.save({
|
||
encryptedLoginPassword: 'encrypted-payload',
|
||
selfId: '1914728559',
|
||
});
|
||
|
||
const payload = accountRepository.create.mock.calls[0][0];
|
||
expect(payload.encryptedLoginPassword).toBeUndefined();
|
||
expect(payload.napcatLoginPasswordSecret).toBeTruthy();
|
||
expect(payload.napcatLoginPasswordSecret).not.toContain(
|
||
'qq-login-password',
|
||
);
|
||
expect(
|
||
toolsService.decryptSecretText(
|
||
payload.napcatLoginPasswordSecret,
|
||
'unit-secret',
|
||
),
|
||
).toBe('qq-login-password');
|
||
});
|
||
|
||
it('requires an explicit secret key before storing NapCat login password', async () => {
|
||
const accountRepository = {
|
||
create: jest.fn((input) => input),
|
||
findOne: jest.fn().mockResolvedValue(null),
|
||
save: jest.fn(async (input) => ({ ...input, id: 'account-1' })),
|
||
};
|
||
const service = createAccountService({
|
||
accountRepository,
|
||
configService: {
|
||
get: jest.fn().mockReturnValue(''),
|
||
},
|
||
passwordCryptoService: {
|
||
decryptPassword: jest.fn().mockReturnValue('qq-login-password'),
|
||
},
|
||
});
|
||
|
||
await expect(
|
||
service.save({
|
||
encryptedLoginPassword: 'encrypted-payload',
|
||
selfId: '1914728559',
|
||
}),
|
||
).rejects.toMatchObject({
|
||
response: expect.objectContaining({
|
||
msg: 'QQBot 账号登录密码密钥未配置,请设置 QQBOT_ACCOUNT_SECRET_KEY 或 ADMIN_TOKEN_SECRET',
|
||
}),
|
||
});
|
||
expect(accountRepository.save).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('rejects public placeholder secret before storing NapCat login password', async () => {
|
||
const accountRepository = {
|
||
create: jest.fn((input) => input),
|
||
findOne: jest.fn().mockResolvedValue(null),
|
||
save: jest.fn(async (input) => ({ ...input, id: 'account-1' })),
|
||
};
|
||
const service = createAccountService({
|
||
accountRepository,
|
||
configService: {
|
||
get: jest.fn((key: string) =>
|
||
key === 'ADMIN_TOKEN_SECRET' ? 'change-me' : '',
|
||
),
|
||
},
|
||
passwordCryptoService: {
|
||
decryptPassword: jest.fn().mockReturnValue('qq-login-password'),
|
||
},
|
||
});
|
||
|
||
await expect(
|
||
service.save({
|
||
encryptedLoginPassword: 'encrypted-payload',
|
||
selfId: '1914728559',
|
||
}),
|
||
).rejects.toMatchObject({
|
||
response: expect.objectContaining({
|
||
msg: 'QQBot 账号登录密码密钥未配置,请设置 QQBOT_ACCOUNT_SECRET_KEY 或 ADMIN_TOKEN_SECRET',
|
||
}),
|
||
});
|
||
expect(accountRepository.save).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('preserves NapCat login password whitespace after decryption', async () => {
|
||
const toolsService = new ToolsService();
|
||
const accountRepository = {
|
||
create: jest.fn((input) => input),
|
||
findOne: jest.fn().mockResolvedValue(null),
|
||
save: jest.fn(async (input) => ({ ...input, id: 'account-1' })),
|
||
};
|
||
const service = createAccountService({
|
||
accountRepository,
|
||
configService: {
|
||
get: jest.fn((key: string) =>
|
||
key === 'QQBOT_ACCOUNT_SECRET_KEY' ? 'unit-secret' : '',
|
||
),
|
||
},
|
||
passwordCryptoService: {
|
||
decryptPassword: jest.fn().mockReturnValue(' qq-login-password '),
|
||
},
|
||
toolsService,
|
||
});
|
||
|
||
await service.save({
|
||
encryptedLoginPassword: 'encrypted-payload',
|
||
selfId: '1914728559',
|
||
});
|
||
|
||
const payload = accountRepository.create.mock.calls[0][0];
|
||
expect(
|
||
toolsService.decryptSecretText(
|
||
payload.napcatLoginPasswordSecret,
|
||
'unit-secret',
|
||
),
|
||
).toBe(' qq-login-password ');
|
||
});
|
||
|
||
it('does not update NapCat login password when edit leaves the password blank', async () => {
|
||
const accountRepository = {
|
||
findOne: jest.fn().mockResolvedValue({ id: 'account-1' }),
|
||
update: jest.fn(),
|
||
};
|
||
const service = createAccountService({
|
||
accountAbilityRepository: { update: jest.fn() },
|
||
accountRepository,
|
||
});
|
||
|
||
await service.update({
|
||
id: 'account-1',
|
||
name: 'Mirror',
|
||
selfId: '1914728559',
|
||
});
|
||
|
||
expect(accountRepository.update).toHaveBeenCalledWith(
|
||
{ id: 'account-1' },
|
||
expect.not.objectContaining({
|
||
encryptedLoginPassword: expect.anything(),
|
||
napcatLoginPasswordSecret: expect.anything(),
|
||
}),
|
||
);
|
||
});
|
||
|
||
it('preserves previous offline reason when later disconnect has no explicit error', async () => {
|
||
const accountRepository = {
|
||
update: jest.fn(),
|
||
};
|
||
const service = createAccountService({ accountRepository });
|
||
|
||
await service.markOffline('1914728559');
|
||
|
||
expect(accountRepository.update).toHaveBeenCalledWith(
|
||
{ selfId: '1914728559' },
|
||
{
|
||
connectStatus: 'offline',
|
||
},
|
||
);
|
||
});
|
||
|
||
it('preserves QQ login error when OneBot connection comes online', async () => {
|
||
const accountRepository = {
|
||
update: jest.fn(),
|
||
};
|
||
const service = createAccountService({ accountRepository });
|
||
|
||
await service.markOnline('1914728559', 'Universal');
|
||
|
||
const updatePayload = accountRepository.update.mock.calls[0][1];
|
||
expect(updatePayload).toEqual(
|
||
expect.objectContaining({
|
||
clientRole: 'Universal',
|
||
connectStatus: 'online',
|
||
lastConnectedAt: expect.any(Date),
|
||
}),
|
||
);
|
||
expect(updatePayload).not.toHaveProperty('lastError');
|
||
});
|
||
|
||
it('clears QQ login error only when online state is explicitly confirmed', async () => {
|
||
const accountRepository = {
|
||
update: jest.fn(),
|
||
};
|
||
const service = createAccountService({ accountRepository });
|
||
|
||
await service.markOnline('1914728559', 'Universal', null);
|
||
|
||
expect(accountRepository.update).toHaveBeenCalledWith(
|
||
{ selfId: '1914728559' },
|
||
expect.objectContaining({
|
||
clientRole: 'Universal',
|
||
connectStatus: 'online',
|
||
lastConnectedAt: expect.any(Date),
|
||
lastError: null,
|
||
}),
|
||
);
|
||
});
|
||
|
||
it('truncates offline reason before writing lastError column', async () => {
|
||
const accountRepository = {
|
||
update: jest.fn(),
|
||
};
|
||
const service = createAccountService({ accountRepository });
|
||
|
||
await service.markOffline('1914728559', '错误'.repeat(300));
|
||
|
||
expect(accountRepository.update).toHaveBeenCalledWith(
|
||
{ selfId: '1914728559' },
|
||
{
|
||
connectStatus: 'offline',
|
||
lastError: `${'错误'.repeat(248)}错...`,
|
||
},
|
||
);
|
||
});
|
||
|
||
it('syncs NapCat runtime offline logs back to account list status', async () => {
|
||
const account = {
|
||
connectStatus: 'online',
|
||
enabled: true,
|
||
id: 'account-1',
|
||
isDeleted: false,
|
||
lastError: null,
|
||
name: '主账号',
|
||
selfId: '1914728559',
|
||
};
|
||
const binding = {
|
||
accountId: 'account-1',
|
||
bindStatus: 'bound',
|
||
containerId: 'container-1',
|
||
isDeleted: false,
|
||
isPrimary: true,
|
||
lastLoginAt: new Date('2026-06-10T12:00:00.000Z'),
|
||
};
|
||
const container = {
|
||
id: 'container-1',
|
||
isDeleted: false,
|
||
lastError: null,
|
||
name: 'kt-qqbot-napcat-1914728559',
|
||
status: 'running',
|
||
webuiPort: 6101,
|
||
};
|
||
const createAccountBuilder = () => ({
|
||
andWhere: jest.fn().mockReturnThis(),
|
||
getManyAndCount: jest.fn().mockResolvedValue([[account], 1]),
|
||
orderBy: jest.fn().mockReturnThis(),
|
||
skip: jest.fn().mockReturnThis(),
|
||
take: jest.fn().mockReturnThis(),
|
||
where: jest.fn().mockReturnThis(),
|
||
});
|
||
const createBindingBuilder = () => ({
|
||
addOrderBy: jest.fn().mockReturnThis(),
|
||
andWhere: jest.fn().mockReturnThis(),
|
||
getMany: jest.fn().mockResolvedValue([binding]),
|
||
orderBy: jest.fn().mockReturnThis(),
|
||
where: jest.fn().mockReturnThis(),
|
||
});
|
||
const createContainerBuilder = () => ({
|
||
andWhere: jest.fn().mockReturnThis(),
|
||
getMany: jest.fn().mockResolvedValue([container]),
|
||
where: jest.fn().mockReturnThis(),
|
||
});
|
||
const accountRepository = {
|
||
createQueryBuilder: jest.fn(createAccountBuilder),
|
||
update: jest.fn(),
|
||
};
|
||
const napcatContainerService = {
|
||
detectRuntimeOffline: jest
|
||
.fn()
|
||
.mockResolvedValue('NapCat 账号状态变更为离线'),
|
||
};
|
||
const systemNoticePublisher = {
|
||
publishSystemNotice: jest.fn().mockResolvedValue('notice-1'),
|
||
};
|
||
const service = createAccountServiceWithNapcatRuntime({
|
||
accountRepository,
|
||
bindingRepository: { createQueryBuilder: jest.fn(createBindingBuilder) },
|
||
containerRepository: {
|
||
createQueryBuilder: jest.fn(createContainerBuilder),
|
||
},
|
||
containerService: napcatContainerService,
|
||
systemNoticePublisher,
|
||
});
|
||
|
||
const page = await service.page({});
|
||
|
||
expect(accountRepository.update).toHaveBeenCalledWith(
|
||
{ selfId: '1914728559' },
|
||
{
|
||
lastError: 'NapCat 账号状态变更为离线',
|
||
},
|
||
);
|
||
expect(page.list[0]).toEqual(
|
||
expect.objectContaining({
|
||
connectStatus: 'online',
|
||
lastError: 'NapCat 账号状态变更为离线',
|
||
napcat: expect.objectContaining({
|
||
oneBotOnline: true,
|
||
qqLoginStatus: 'offline',
|
||
}),
|
||
}),
|
||
);
|
||
expect(systemNoticePublisher.publishSystemNotice).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
content: 'NapCat 账号状态变更为离线',
|
||
dedupeKey: 'qqbot:offline:1914728559',
|
||
eventType: 'qqbot.account.offline',
|
||
notifyRoleCode: 'super',
|
||
severity: 'error',
|
||
source: 'qqbot',
|
||
title: 'QQBot 账号已下线:1914728559',
|
||
}),
|
||
);
|
||
});
|
||
|
||
it('does not re-read NapCat logs when the container was checked recently', async () => {
|
||
const account = {
|
||
connectStatus: 'online',
|
||
enabled: true,
|
||
id: 'account-1',
|
||
isDeleted: false,
|
||
lastError: null,
|
||
name: '主账号',
|
||
selfId: '1914728559',
|
||
};
|
||
const binding = {
|
||
accountId: 'account-1',
|
||
bindStatus: 'bound',
|
||
containerId: 'container-1',
|
||
isDeleted: false,
|
||
isPrimary: true,
|
||
lastLoginAt: new Date('2026-06-10T12:00:00.000Z'),
|
||
};
|
||
const container = {
|
||
id: 'container-1',
|
||
isDeleted: false,
|
||
lastCheckedAt: new Date(),
|
||
lastError: null,
|
||
name: 'kt-qqbot-napcat-1914728559',
|
||
status: 'running',
|
||
webuiPort: 6101,
|
||
};
|
||
const accountRepository = {
|
||
createQueryBuilder: jest.fn(() => ({
|
||
andWhere: jest.fn().mockReturnThis(),
|
||
getManyAndCount: jest.fn().mockResolvedValue([[account], 1]),
|
||
orderBy: jest.fn().mockReturnThis(),
|
||
skip: jest.fn().mockReturnThis(),
|
||
take: jest.fn().mockReturnThis(),
|
||
where: jest.fn().mockReturnThis(),
|
||
})),
|
||
update: jest.fn(),
|
||
};
|
||
const napcatContainerService = {
|
||
detectRuntimeOffline: jest.fn(),
|
||
};
|
||
const service = createAccountServiceWithNapcatRuntime({
|
||
accountRepository,
|
||
bindingRepository: {
|
||
createQueryBuilder: jest.fn(() => ({
|
||
addOrderBy: jest.fn().mockReturnThis(),
|
||
andWhere: jest.fn().mockReturnThis(),
|
||
getMany: jest.fn().mockResolvedValue([binding]),
|
||
orderBy: jest.fn().mockReturnThis(),
|
||
where: jest.fn().mockReturnThis(),
|
||
})),
|
||
},
|
||
containerRepository: {
|
||
createQueryBuilder: jest.fn(() => ({
|
||
andWhere: jest.fn().mockReturnThis(),
|
||
getMany: jest.fn().mockResolvedValue([container]),
|
||
where: jest.fn().mockReturnThis(),
|
||
})),
|
||
},
|
||
containerService: napcatContainerService,
|
||
});
|
||
|
||
const page = await service.page({});
|
||
|
||
expect(napcatContainerService.detectRuntimeOffline).not.toHaveBeenCalled();
|
||
expect(accountRepository.update).not.toHaveBeenCalled();
|
||
expect(page.list[0]).toEqual(
|
||
expect.objectContaining({
|
||
connectStatus: 'online',
|
||
napcat: expect.objectContaining({
|
||
lastCheckedAt: container.lastCheckedAt,
|
||
}),
|
||
}),
|
||
);
|
||
});
|
||
|
||
it('separates OneBot, container, WebUI and QQ login status for offline accounts', async () => {
|
||
const account = {
|
||
connectStatus: 'offline',
|
||
enabled: true,
|
||
id: 'account-1',
|
||
isDeleted: false,
|
||
lastError: null,
|
||
name: '主账号',
|
||
selfId: '1914728559',
|
||
};
|
||
const binding = {
|
||
accountId: 'account-1',
|
||
bindStatus: 'bound',
|
||
containerId: 'container-1',
|
||
isDeleted: false,
|
||
isPrimary: true,
|
||
lastLoginAt: new Date('2026-06-10T12:00:00.000Z'),
|
||
};
|
||
const container = {
|
||
id: 'container-1',
|
||
isDeleted: false,
|
||
lastCheckedAt: new Date('2026-06-11T02:00:00.000Z'),
|
||
lastError: null,
|
||
name: 'kt-qqbot-napcat-1914728559',
|
||
status: 'running',
|
||
webuiPort: 6101,
|
||
};
|
||
const accountRepository = {
|
||
createQueryBuilder: jest.fn(() => ({
|
||
andWhere: jest.fn().mockReturnThis(),
|
||
getManyAndCount: jest.fn().mockResolvedValue([[account], 1]),
|
||
orderBy: jest.fn().mockReturnThis(),
|
||
skip: jest.fn().mockReturnThis(),
|
||
take: jest.fn().mockReturnThis(),
|
||
where: jest.fn().mockReturnThis(),
|
||
})),
|
||
update: jest.fn(),
|
||
};
|
||
const napcatContainerService = {
|
||
inspectRuntimeStatus: jest.fn().mockResolvedValue({
|
||
checkedAt: new Date('2026-06-12T12:00:00.000Z'),
|
||
containerOnline: true,
|
||
lastError: '二维码已过期,请刷新',
|
||
qqLoginMessage: '二维码已过期,请刷新',
|
||
qqLoginStatus: 'qrcode_expired',
|
||
webuiOnline: true,
|
||
}),
|
||
};
|
||
const service = createAccountServiceWithNapcatRuntime({
|
||
accountRepository,
|
||
bindingRepository: {
|
||
createQueryBuilder: jest.fn(() => ({
|
||
addOrderBy: jest.fn().mockReturnThis(),
|
||
andWhere: jest.fn().mockReturnThis(),
|
||
getMany: jest.fn().mockResolvedValue([binding]),
|
||
orderBy: jest.fn().mockReturnThis(),
|
||
where: jest.fn().mockReturnThis(),
|
||
})),
|
||
},
|
||
containerRepository: {
|
||
createQueryBuilder: jest.fn(() => ({
|
||
addSelect: jest.fn().mockReturnThis(),
|
||
andWhere: jest.fn().mockReturnThis(),
|
||
getMany: jest.fn().mockResolvedValue([container]),
|
||
where: jest.fn().mockReturnThis(),
|
||
})),
|
||
},
|
||
containerService: napcatContainerService,
|
||
});
|
||
|
||
const page = await service.page({});
|
||
|
||
expect(napcatContainerService.inspectRuntimeStatus).toHaveBeenCalledWith(
|
||
container,
|
||
);
|
||
expect(accountRepository.update).not.toHaveBeenCalled();
|
||
expect(page.list[0]).toEqual(
|
||
expect.objectContaining({
|
||
connectStatus: 'offline',
|
||
napcat: expect.objectContaining({
|
||
containerOnline: true,
|
||
oneBotOnline: false,
|
||
qqLoginMessage: '二维码已过期,请刷新',
|
||
qqLoginStatus: 'qrcode_expired',
|
||
webuiOnline: true,
|
||
}),
|
||
}),
|
||
);
|
||
});
|
||
|
||
it('does not expose WebUI errors as QQ login messages in cached runtime status', async () => {
|
||
const checkedAt = new Date();
|
||
const account = {
|
||
connectStatus: 'offline',
|
||
enabled: true,
|
||
id: 'account-1',
|
||
isDeleted: false,
|
||
lastError: null,
|
||
name: '主账号',
|
||
selfId: '1914728559',
|
||
};
|
||
const binding = {
|
||
accountId: 'account-1',
|
||
bindStatus: 'bound',
|
||
containerId: 'container-1',
|
||
isDeleted: false,
|
||
isPrimary: true,
|
||
lastLoginAt: checkedAt,
|
||
};
|
||
const container = {
|
||
id: 'container-1',
|
||
isDeleted: false,
|
||
lastCheckedAt: checkedAt,
|
||
lastError: 'NapCat WebUI 配置缺失',
|
||
name: 'kt-qqbot-napcat-1914728559',
|
||
status: 'running',
|
||
webuiPort: 6101,
|
||
};
|
||
const accountRepository = {
|
||
createQueryBuilder: jest.fn(() => ({
|
||
andWhere: jest.fn().mockReturnThis(),
|
||
getManyAndCount: jest.fn().mockResolvedValue([[account], 1]),
|
||
orderBy: jest.fn().mockReturnThis(),
|
||
skip: jest.fn().mockReturnThis(),
|
||
take: jest.fn().mockReturnThis(),
|
||
where: jest.fn().mockReturnThis(),
|
||
})),
|
||
update: jest.fn(),
|
||
};
|
||
const service = createAccountServiceWithNapcatRuntime({
|
||
accountRepository,
|
||
bindingRepository: {
|
||
createQueryBuilder: jest.fn(() => ({
|
||
addOrderBy: jest.fn().mockReturnThis(),
|
||
andWhere: jest.fn().mockReturnThis(),
|
||
getMany: jest.fn().mockResolvedValue([binding]),
|
||
orderBy: jest.fn().mockReturnThis(),
|
||
where: jest.fn().mockReturnThis(),
|
||
})),
|
||
},
|
||
containerRepository: {
|
||
createQueryBuilder: jest.fn(() => ({
|
||
addSelect: jest.fn().mockReturnThis(),
|
||
andWhere: jest.fn().mockReturnThis(),
|
||
getMany: jest.fn().mockResolvedValue([container]),
|
||
where: jest.fn().mockReturnThis(),
|
||
})),
|
||
},
|
||
containerService: {},
|
||
});
|
||
|
||
const page = await service.page({});
|
||
|
||
expect(page.list[0].napcat).toEqual(
|
||
expect.objectContaining({
|
||
containerOnline: true,
|
||
lastError: 'NapCat WebUI 配置缺失',
|
||
qqLoginMessage: null,
|
||
qqLoginStatus: 'unknown',
|
||
webuiOnline: null,
|
||
}),
|
||
);
|
||
});
|
||
|
||
it('does not derive QQ login online status from OneBot heartbeat cache', async () => {
|
||
const checkedAt = new Date();
|
||
const account = {
|
||
connectStatus: 'online',
|
||
enabled: true,
|
||
id: 'account-1',
|
||
isDeleted: false,
|
||
lastError: null,
|
||
lastHeartbeatAt: new Date(),
|
||
name: '主账号',
|
||
selfId: '1914728559',
|
||
};
|
||
const binding = {
|
||
accountId: 'account-1',
|
||
bindStatus: 'bound',
|
||
containerId: 'container-1',
|
||
isDeleted: false,
|
||
isPrimary: true,
|
||
lastLoginAt: checkedAt,
|
||
};
|
||
const container = {
|
||
id: 'container-1',
|
||
isDeleted: false,
|
||
lastCheckedAt: checkedAt,
|
||
lastError: null,
|
||
name: 'kt-qqbot-napcat-1914728559',
|
||
status: 'running',
|
||
webuiPort: 6101,
|
||
};
|
||
const accountRepository = {
|
||
createQueryBuilder: jest.fn(() => ({
|
||
andWhere: jest.fn().mockReturnThis(),
|
||
getManyAndCount: jest.fn().mockResolvedValue([[account], 1]),
|
||
orderBy: jest.fn().mockReturnThis(),
|
||
skip: jest.fn().mockReturnThis(),
|
||
take: jest.fn().mockReturnThis(),
|
||
where: jest.fn().mockReturnThis(),
|
||
})),
|
||
update: jest.fn(),
|
||
};
|
||
const service = createAccountServiceWithNapcatRuntime({
|
||
accountRepository,
|
||
bindingRepository: {
|
||
createQueryBuilder: jest.fn(() => ({
|
||
addOrderBy: jest.fn().mockReturnThis(),
|
||
andWhere: jest.fn().mockReturnThis(),
|
||
getMany: jest.fn().mockResolvedValue([binding]),
|
||
orderBy: jest.fn().mockReturnThis(),
|
||
where: jest.fn().mockReturnThis(),
|
||
})),
|
||
},
|
||
containerRepository: {
|
||
createQueryBuilder: jest.fn(() => ({
|
||
addSelect: jest.fn().mockReturnThis(),
|
||
andWhere: jest.fn().mockReturnThis(),
|
||
getMany: jest.fn().mockResolvedValue([container]),
|
||
where: jest.fn().mockReturnThis(),
|
||
})),
|
||
},
|
||
containerService: {},
|
||
});
|
||
|
||
const page = await service.page({});
|
||
|
||
expect(page.list[0].napcat).toEqual(
|
||
expect.objectContaining({
|
||
oneBotOnline: true,
|
||
qqLoginStatus: 'unknown',
|
||
webuiOnline: null,
|
||
}),
|
||
);
|
||
});
|
||
|
||
it('ignores cached NapCat offline reason after the account reconnects', async () => {
|
||
const now = Date.now();
|
||
const checkedAt = new Date(now - 10_000);
|
||
const account = {
|
||
connectStatus: 'online',
|
||
enabled: true,
|
||
id: 'account-1',
|
||
isDeleted: false,
|
||
lastConnectedAt: new Date(now - 5_000),
|
||
lastError: null,
|
||
name: '主账号',
|
||
selfId: '1914728559',
|
||
};
|
||
const binding = {
|
||
accountId: 'account-1',
|
||
bindStatus: 'bound',
|
||
containerId: 'container-1',
|
||
isDeleted: false,
|
||
isPrimary: true,
|
||
lastLoginAt: checkedAt,
|
||
};
|
||
const container = {
|
||
id: 'container-1',
|
||
isDeleted: false,
|
||
lastCheckedAt: checkedAt,
|
||
lastError: '账号状态变更为离线',
|
||
name: 'kt-qqbot-napcat-1914728559',
|
||
status: 'running',
|
||
webuiPort: 6101,
|
||
};
|
||
const accountRepository = {
|
||
createQueryBuilder: jest.fn(() => ({
|
||
andWhere: jest.fn().mockReturnThis(),
|
||
getManyAndCount: jest.fn().mockResolvedValue([[account], 1]),
|
||
orderBy: jest.fn().mockReturnThis(),
|
||
skip: jest.fn().mockReturnThis(),
|
||
take: jest.fn().mockReturnThis(),
|
||
where: jest.fn().mockReturnThis(),
|
||
})),
|
||
update: jest.fn(),
|
||
};
|
||
const napcatContainerService = {
|
||
detectRuntimeOffline: jest.fn(),
|
||
};
|
||
const systemNoticePublisher = {
|
||
publishSystemNotice: jest.fn().mockResolvedValue(undefined),
|
||
};
|
||
const service = createAccountServiceWithNapcatRuntime({
|
||
accountRepository,
|
||
bindingRepository: {
|
||
createQueryBuilder: jest.fn(() => ({
|
||
addOrderBy: jest.fn().mockReturnThis(),
|
||
andWhere: jest.fn().mockReturnThis(),
|
||
getMany: jest.fn().mockResolvedValue([binding]),
|
||
orderBy: jest.fn().mockReturnThis(),
|
||
where: jest.fn().mockReturnThis(),
|
||
})),
|
||
},
|
||
containerRepository: {
|
||
createQueryBuilder: jest.fn(() => ({
|
||
andWhere: jest.fn().mockReturnThis(),
|
||
getMany: jest.fn().mockResolvedValue([container]),
|
||
where: jest.fn().mockReturnThis(),
|
||
})),
|
||
},
|
||
containerService: napcatContainerService,
|
||
systemNoticePublisher,
|
||
});
|
||
|
||
const page = await service.page({});
|
||
|
||
expect(napcatContainerService.detectRuntimeOffline).not.toHaveBeenCalled();
|
||
expect(accountRepository.update).not.toHaveBeenCalled();
|
||
expect(systemNoticePublisher.publishSystemNotice).not.toHaveBeenCalled();
|
||
expect(page.list[0]).toEqual(
|
||
expect.objectContaining({
|
||
connectStatus: 'online',
|
||
lastError: null,
|
||
}),
|
||
);
|
||
});
|
||
|
||
it('does not let heartbeat bypass stale NapCat offline inspection', async () => {
|
||
const checkedAt = new Date(Date.now() - 60_000);
|
||
const account = {
|
||
connectStatus: 'online',
|
||
enabled: true,
|
||
id: 'account-1',
|
||
isDeleted: false,
|
||
lastConnectedAt: new Date(Date.now() - 120_000),
|
||
lastError: null,
|
||
lastHeartbeatAt: new Date(),
|
||
name: '主账号',
|
||
selfId: '1914728559',
|
||
};
|
||
const binding = {
|
||
accountId: 'account-1',
|
||
bindStatus: 'bound',
|
||
containerId: 'container-1',
|
||
isDeleted: false,
|
||
isPrimary: true,
|
||
lastLoginAt: checkedAt,
|
||
};
|
||
const container = {
|
||
id: 'container-1',
|
||
isDeleted: false,
|
||
lastCheckedAt: checkedAt,
|
||
lastError: null,
|
||
name: 'kt-qqbot-napcat-1914728559',
|
||
status: 'running',
|
||
webuiPort: 6101,
|
||
};
|
||
const accountRepository = {
|
||
createQueryBuilder: jest.fn(() => ({
|
||
andWhere: jest.fn().mockReturnThis(),
|
||
getManyAndCount: jest.fn().mockResolvedValue([[account], 1]),
|
||
orderBy: jest.fn().mockReturnThis(),
|
||
skip: jest.fn().mockReturnThis(),
|
||
take: jest.fn().mockReturnThis(),
|
||
where: jest.fn().mockReturnThis(),
|
||
})),
|
||
update: jest.fn(),
|
||
};
|
||
const napcatContainerService = {
|
||
inspectRuntimeStatus: jest.fn().mockResolvedValue({
|
||
checkedAt: new Date(),
|
||
containerOnline: true,
|
||
lastError: '账号状态变更为离线',
|
||
qqLoginMessage: '账号状态变更为离线',
|
||
qqLoginStatus: 'offline',
|
||
webuiOnline: true,
|
||
}),
|
||
};
|
||
const service = createAccountServiceWithNapcatRuntime({
|
||
accountRepository,
|
||
bindingRepository: {
|
||
createQueryBuilder: jest.fn(() => ({
|
||
addOrderBy: jest.fn().mockReturnThis(),
|
||
andWhere: jest.fn().mockReturnThis(),
|
||
getMany: jest.fn().mockResolvedValue([binding]),
|
||
orderBy: jest.fn().mockReturnThis(),
|
||
where: jest.fn().mockReturnThis(),
|
||
})),
|
||
},
|
||
containerRepository: {
|
||
createQueryBuilder: jest.fn(() => ({
|
||
addSelect: jest.fn().mockReturnThis(),
|
||
andWhere: jest.fn().mockReturnThis(),
|
||
getMany: jest.fn().mockResolvedValue([container]),
|
||
where: jest.fn().mockReturnThis(),
|
||
})),
|
||
},
|
||
containerService: napcatContainerService,
|
||
});
|
||
|
||
const page = await service.page({});
|
||
|
||
expect(napcatContainerService.inspectRuntimeStatus).toHaveBeenCalledWith(
|
||
container,
|
||
);
|
||
expect(accountRepository.update).toHaveBeenCalledWith(
|
||
{ selfId: '1914728559' },
|
||
{
|
||
lastError: '账号状态变更为离线',
|
||
},
|
||
);
|
||
expect(page.list[0]).toEqual(
|
||
expect.objectContaining({
|
||
connectStatus: 'online',
|
||
lastError: '账号状态变更为离线',
|
||
napcat: expect.objectContaining({
|
||
oneBotOnline: true,
|
||
qqLoginStatus: 'offline',
|
||
}),
|
||
}),
|
||
);
|
||
});
|
||
|
||
it('clears previous QQ login error when NapCat WebUI confirms QQ is online', async () => {
|
||
const checkedAt = new Date(Date.now() - 60_000);
|
||
const account = {
|
||
connectStatus: 'online',
|
||
enabled: true,
|
||
id: 'account-1',
|
||
isDeleted: false,
|
||
lastConnectedAt: new Date(Date.now() - 120_000),
|
||
lastError: '账号状态变更为离线',
|
||
name: '主账号',
|
||
selfId: '1914728559',
|
||
};
|
||
const binding = {
|
||
accountId: 'account-1',
|
||
bindStatus: 'bound',
|
||
containerId: 'container-1',
|
||
isDeleted: false,
|
||
isPrimary: true,
|
||
lastLoginAt: checkedAt,
|
||
};
|
||
const container = {
|
||
id: 'container-1',
|
||
isDeleted: false,
|
||
lastCheckedAt: checkedAt,
|
||
lastError: '账号状态变更为离线',
|
||
name: 'kt-qqbot-napcat-1914728559',
|
||
status: 'running',
|
||
webuiPort: 6101,
|
||
};
|
||
const accountRepository = {
|
||
createQueryBuilder: jest.fn(() => ({
|
||
andWhere: jest.fn().mockReturnThis(),
|
||
getManyAndCount: jest.fn().mockResolvedValue([[account], 1]),
|
||
orderBy: jest.fn().mockReturnThis(),
|
||
skip: jest.fn().mockReturnThis(),
|
||
take: jest.fn().mockReturnThis(),
|
||
where: jest.fn().mockReturnThis(),
|
||
})),
|
||
update: jest.fn(),
|
||
};
|
||
const napcatContainerService = {
|
||
inspectRuntimeStatus: jest.fn().mockResolvedValue({
|
||
checkedAt: new Date(),
|
||
containerOnline: true,
|
||
lastError: null,
|
||
qqLoginMessage: null,
|
||
qqLoginStatus: 'online',
|
||
webuiOnline: true,
|
||
}),
|
||
};
|
||
const service = createAccountServiceWithNapcatRuntime({
|
||
accountRepository,
|
||
bindingRepository: {
|
||
createQueryBuilder: jest.fn(() => ({
|
||
addOrderBy: jest.fn().mockReturnThis(),
|
||
andWhere: jest.fn().mockReturnThis(),
|
||
getMany: jest.fn().mockResolvedValue([binding]),
|
||
orderBy: jest.fn().mockReturnThis(),
|
||
where: jest.fn().mockReturnThis(),
|
||
})),
|
||
},
|
||
containerRepository: {
|
||
createQueryBuilder: jest.fn(() => ({
|
||
addSelect: jest.fn().mockReturnThis(),
|
||
andWhere: jest.fn().mockReturnThis(),
|
||
getMany: jest.fn().mockResolvedValue([container]),
|
||
where: jest.fn().mockReturnThis(),
|
||
})),
|
||
},
|
||
containerService: napcatContainerService,
|
||
});
|
||
|
||
const page = await service.page({});
|
||
|
||
expect(accountRepository.update).toHaveBeenCalledWith(
|
||
{ selfId: '1914728559' },
|
||
{ lastError: null },
|
||
);
|
||
expect(page.list[0]).toEqual(
|
||
expect.objectContaining({
|
||
lastError: null,
|
||
napcat: expect.objectContaining({
|
||
qqLoginStatus: 'online',
|
||
}),
|
||
}),
|
||
);
|
||
});
|
||
|
||
it('lets watchdog auto-login before marking an account offline', async () => {
|
||
const toolsService = new ToolsService();
|
||
const account = {
|
||
connectStatus: 'online',
|
||
enabled: true,
|
||
id: 'account-1',
|
||
isDeleted: false,
|
||
lastError: null,
|
||
name: '主账号',
|
||
napcatLoginPasswordSecret: toolsService.encryptSecretText(
|
||
'qq-login-password',
|
||
'unit-secret',
|
||
),
|
||
selfId: '1914728559',
|
||
};
|
||
const binding = {
|
||
accountId: 'account-1',
|
||
bindStatus: 'bound',
|
||
containerId: 'container-1',
|
||
isDeleted: false,
|
||
isPrimary: true,
|
||
lastLoginAt: new Date('2026-06-10T12:00:00.000Z'),
|
||
};
|
||
const container = {
|
||
id: 'container-1',
|
||
isDeleted: false,
|
||
lastError: null,
|
||
name: 'kt-qqbot-napcat-1914728559',
|
||
status: 'running',
|
||
webuiPort: 6101,
|
||
};
|
||
const accountRepository = {
|
||
createQueryBuilder: jest.fn(() => ({
|
||
addSelect: jest.fn().mockReturnThis(),
|
||
andWhere: jest.fn().mockReturnThis(),
|
||
getMany: jest.fn().mockResolvedValue([account]),
|
||
where: jest.fn().mockReturnThis(),
|
||
})),
|
||
update: jest.fn(),
|
||
};
|
||
const napcatContainerService = {
|
||
detectRuntimeOffline: jest
|
||
.fn()
|
||
.mockResolvedValue('NapCat 账号状态变更为离线'),
|
||
tryAutoLogin: jest.fn().mockResolvedValue({
|
||
method: 'password',
|
||
success: true,
|
||
}),
|
||
};
|
||
const systemNoticePublisher = {
|
||
publishSystemNotice: jest.fn().mockResolvedValue(undefined),
|
||
};
|
||
const service = createAccountServiceWithNapcatRuntime({
|
||
accountRepository,
|
||
bindingRepository: {
|
||
createQueryBuilder: jest.fn(() => ({
|
||
addOrderBy: jest.fn().mockReturnThis(),
|
||
andWhere: jest.fn().mockReturnThis(),
|
||
getMany: jest.fn().mockResolvedValue([binding]),
|
||
orderBy: jest.fn().mockReturnThis(),
|
||
where: jest.fn().mockReturnThis(),
|
||
})),
|
||
},
|
||
configService: {
|
||
get: jest.fn((key: string) =>
|
||
key === 'QQBOT_ACCOUNT_SECRET_KEY' ? 'unit-secret' : '',
|
||
),
|
||
},
|
||
containerRepository: {
|
||
createQueryBuilder: jest.fn(() => ({
|
||
andWhere: jest.fn().mockReturnThis(),
|
||
getMany: jest.fn().mockResolvedValue([container]),
|
||
where: jest.fn().mockReturnThis(),
|
||
})),
|
||
},
|
||
containerService: napcatContainerService,
|
||
systemNoticePublisher,
|
||
toolsService,
|
||
});
|
||
|
||
const result = await service.runOfflineWatchdog();
|
||
|
||
expect(result).toEqual({ checked: 1 });
|
||
expect(napcatContainerService.tryAutoLogin).toHaveBeenCalledWith(
|
||
container,
|
||
{
|
||
loginPassword: 'qq-login-password',
|
||
selfId: '1914728559',
|
||
},
|
||
);
|
||
expect(accountRepository.update).toHaveBeenCalledWith(
|
||
{ selfId: '1914728559' },
|
||
expect.objectContaining({
|
||
clientRole: 'Universal',
|
||
connectStatus: 'online',
|
||
lastConnectedAt: expect.any(Date),
|
||
lastError: null,
|
||
}),
|
||
);
|
||
expect(systemNoticePublisher.publishSystemNotice).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('records a cleanup failure when watchdog auto-login leaves runtime password env uncertain', async () => {
|
||
const toolsService = new ToolsService();
|
||
const account = {
|
||
connectStatus: 'online',
|
||
enabled: true,
|
||
id: 'account-1',
|
||
isDeleted: false,
|
||
lastError: null,
|
||
name: '主账号',
|
||
napcatLoginPasswordSecret: toolsService.encryptSecretText(
|
||
'qq-login-password',
|
||
'unit-secret',
|
||
),
|
||
selfId: '1914728559',
|
||
};
|
||
const binding = {
|
||
accountId: 'account-1',
|
||
bindStatus: 'bound',
|
||
containerId: 'container-1',
|
||
isDeleted: false,
|
||
isPrimary: true,
|
||
lastLoginAt: new Date('2026-06-10T12:00:00.000Z'),
|
||
};
|
||
const container = {
|
||
id: 'container-1',
|
||
isDeleted: false,
|
||
lastError: null,
|
||
name: 'kt-qqbot-napcat-1914728559',
|
||
status: 'running',
|
||
webuiPort: 6101,
|
||
};
|
||
const accountRepository = {
|
||
createQueryBuilder: jest.fn(() => ({
|
||
addSelect: jest.fn().mockReturnThis(),
|
||
andWhere: jest.fn().mockReturnThis(),
|
||
getMany: jest.fn().mockResolvedValue([account]),
|
||
where: jest.fn().mockReturnThis(),
|
||
})),
|
||
update: jest.fn(),
|
||
};
|
||
const napcatContainerService = {
|
||
detectRuntimeOffline: jest
|
||
.fn()
|
||
.mockResolvedValue('NapCat 账号状态变更为离线'),
|
||
tryAutoLogin: jest.fn().mockResolvedValue({
|
||
cleanupFailed: true,
|
||
success: false,
|
||
}),
|
||
};
|
||
const systemNoticePublisher = {
|
||
publishSystemNotice: jest.fn().mockResolvedValue(undefined),
|
||
};
|
||
const service = createAccountServiceWithNapcatRuntime({
|
||
accountRepository,
|
||
bindingRepository: {
|
||
createQueryBuilder: jest.fn(() => ({
|
||
addOrderBy: jest.fn().mockReturnThis(),
|
||
andWhere: jest.fn().mockReturnThis(),
|
||
getMany: jest.fn().mockResolvedValue([binding]),
|
||
orderBy: jest.fn().mockReturnThis(),
|
||
where: jest.fn().mockReturnThis(),
|
||
})),
|
||
},
|
||
configService: {
|
||
get: jest.fn((key: string) =>
|
||
key === 'QQBOT_ACCOUNT_SECRET_KEY' ? 'unit-secret' : '',
|
||
),
|
||
},
|
||
containerRepository: {
|
||
createQueryBuilder: jest.fn(() => ({
|
||
andWhere: jest.fn().mockReturnThis(),
|
||
getMany: jest.fn().mockResolvedValue([container]),
|
||
where: jest.fn().mockReturnThis(),
|
||
})),
|
||
},
|
||
containerService: napcatContainerService,
|
||
systemNoticePublisher,
|
||
toolsService,
|
||
});
|
||
|
||
await service.runOfflineWatchdog();
|
||
|
||
expect(accountRepository.update).toHaveBeenCalledTimes(1);
|
||
expect(accountRepository.update).toHaveBeenCalledWith(
|
||
{ selfId: '1914728559' },
|
||
{
|
||
lastError: 'NapCat 自动登录后运行态密码清理失败,请手动更新登录',
|
||
},
|
||
);
|
||
expect(systemNoticePublisher.publishSystemNotice).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
content: 'NapCat 自动登录后运行态密码清理失败,请手动更新登录',
|
||
eventType: 'qqbot.account.offline',
|
||
severity: 'error',
|
||
title: 'QQBot 账号已下线:1914728559',
|
||
}),
|
||
);
|
||
expect(
|
||
JSON.stringify(accountRepository.update.mock.calls),
|
||
).not.toContain('NapCat 账号状态变更为离线');
|
||
});
|
||
});
|