fix: 修复NapCat WebUI文件管理与登录SSE状态监视
This commit is contained in:
parent
89f68f6db0
commit
dfca035749
2
API.md
2
API.md
@ -383,6 +383,8 @@ NapCat Chinese Desktop Runtime v8 使用 KT `NapCatQQ` fork 源码构建出的 `
|
||||
|
||||
NapCat WebUI Gateway 是独立部署的内部代理服务,生产镜像由 `dockerfile.gateway` 打包 `dist/apps/napcat-webui-gateway/main.js`,K8s 服务名为 `kt-napcat-webui-gateway`,端口 `48086`。API 侧只通过内部路由 `NAPCAT_WEBUI_GATEWAY_INTERNAL_BASE_URL` 创建、续期、撤销会话和交换一次性 ticket;浏览器只访问公开前缀 `NAPCAT_WEBUI_GATEWAY_PUBLIC_BASE_URL` 下的代理页面、静态资源和 WebSocket 转发,不能直连 NapCat 容器 WebUI。
|
||||
|
||||
Gateway 只改写 NapCat HTML/JS/CSS 中需要浏览器直连的绝对根路径:`/webui/*`、`/api/*`、`/files/*` 和 `/plugin/*`。NapCat 文件管理的 `File` 路由属于 axios `baseURL="/api"` 下的 API 子路径,页面源码里的 `"/File/list"` 必须保持原样,由浏览器最终请求 `/api/File/list`;不能把 `/File/*` 当作独立静态根路径改写到 Gateway session 前缀,否则会形成 `/webui/api/napcat-webui/session/.../File/list` 并让文件管理拿到 HTML。
|
||||
|
||||
必需环境变量:`NAPCAT_WEBUI_GATEWAY_INTERNAL_BASE_URL`、`NAPCAT_WEBUI_GATEWAY_PUBLIC_BASE_URL`、`NAPCAT_WEBUI_GATEWAY_INTERNAL_SECRET`、`NAPCAT_WEBUI_GATEWAY_REDIS_HOST`、`NAPCAT_WEBUI_GATEWAY_REDIS_PORT`、`NAPCAT_WEBUI_GATEWAY_SESSION_TTL_MS`、`NAPCAT_WEBUI_GATEWAY_TICKET_TTL_MS`、`NAPCAT_WEBUI_GATEWAY_UPSTREAM_TIMEOUT_MS`。生产 `NAPCAT_WEBUI_GATEWAY_INTERNAL_SECRET` 只来自 Jenkins 私有 `.env.production` 生成的 `kt-template-online-api-env` Secret,不写入 Git 或 manifest 字面量。
|
||||
|
||||
部署验收使用:`pnpm exec jest --runTestsByPath test/modules/qqbot/napcat-webui-gateway/gateway-deployment.spec.ts --runInBand`、`pnpm run typecheck`、`pnpm run build`、`Test-Path .\dist\apps\napcat-webui-gateway\main.js`、`git diff --check`。安全验收要求浏览器永远不接收 WebUI token、Credential、上游 URL/端口、Docker 拓扑、Redis 地址或内部 secret。
|
||||
|
||||
@ -141,7 +141,7 @@ export function rewriteNapcatTextResponse(input: RewriteTextResponseInput) {
|
||||
)}/webui`;
|
||||
|
||||
const rewritten = input.body.replace(
|
||||
/(^|[\s"'`(=,:])\/(webui|api|File|plugin)(?=\/|[?#"'`)]|$)/g,
|
||||
/(^|[\s"'`(=,:])\/(webui|api|files|plugin)(?=\/|[?#"'`)]|$)/g,
|
||||
(_match, leader: string, root: string) =>
|
||||
`${leader}${gatewayWebuiPrefix}/${root}`,
|
||||
);
|
||||
|
||||
@ -581,7 +581,13 @@ export class ToolsService {
|
||||
* @param err - 异常或失败对象;提取状态码、错误体、堆栈或失败原因。
|
||||
*/
|
||||
isNapcatAlreadyLoggedInError(err: unknown) {
|
||||
return this.getErrorMessage(err).includes('QQ Is Logined');
|
||||
const message = this.getErrorMessage(err);
|
||||
return (
|
||||
message.includes('QQ Is Logined') ||
|
||||
(message.includes('当前账号') &&
|
||||
message.includes('已登录') &&
|
||||
message.includes('无法重复登录'))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -34,6 +34,10 @@ type PendingQrcodeUpdateOptions = {
|
||||
clearStaleQrcode?: boolean;
|
||||
requireFresh?: boolean;
|
||||
};
|
||||
type ScanStatusMonitorDeadline = {
|
||||
expiresAt: number;
|
||||
qrcode: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class QqbotNapcatLoginService {
|
||||
@ -45,6 +49,14 @@ export class QqbotNapcatLoginService {
|
||||
string,
|
||||
Set<(event: QqbotLoginScanEvent) => void>
|
||||
> = {};
|
||||
private readonly scanStatusMonitorTimers: Record<
|
||||
string,
|
||||
NodeJS.Timeout | undefined
|
||||
> = {};
|
||||
private readonly scanStatusMonitorDeadlines: Record<
|
||||
string,
|
||||
ScanStatusMonitorDeadline | undefined
|
||||
> = {};
|
||||
private readonly refreshStartTasks: Record<
|
||||
string,
|
||||
Promise<QqbotLoginScanResult> | undefined
|
||||
@ -53,7 +65,10 @@ export class QqbotNapcatLoginService {
|
||||
/**
|
||||
* 清理 NapCat回调状态。
|
||||
*/
|
||||
clear: () => this.loginSessionStore.clear(),
|
||||
clear: () => {
|
||||
this.stopAllScanStatusMonitors();
|
||||
this.loginSessionStore.clear();
|
||||
},
|
||||
/**
|
||||
* 读取 NapCat回调数据。
|
||||
* @param sessionId - NapCat ID;定位本次读取、更新、删除或关联的NapCat。
|
||||
@ -1507,6 +1522,9 @@ export class QqbotNapcatLoginService {
|
||||
status,
|
||||
step,
|
||||
});
|
||||
if (this.shouldMonitorScanStatus(session)) {
|
||||
this.startScanStatusMonitor(session);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1709,10 +1727,130 @@ export class QqbotNapcatLoginService {
|
||||
* @param sessionId - NapCat ID;定位本次读取、更新、删除或关联的NapCat。
|
||||
*/
|
||||
private cleanupSessionEvents(sessionId: string) {
|
||||
this.stopScanStatusMonitor(sessionId);
|
||||
delete this.sessionEventLogCache[sessionId];
|
||||
delete this.sessionEventListenerCache[sessionId];
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether a pending QR session needs server-side polling so SSE can progress without browser polling.
|
||||
* @param session - Login session whose QR, preparation flags, and terminal status decide monitor ownership.
|
||||
* @returns True when the backend should keep reconciling NapCat status for this session.
|
||||
*/
|
||||
private shouldMonitorScanStatus(session: QqbotLoginScanSession) {
|
||||
return (
|
||||
session.status === 'pending' &&
|
||||
!!session.qrcode &&
|
||||
!session.preparingContainer &&
|
||||
!session.preparingRelogin
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts one bounded status monitor for a QR session and lets the timer avoid holding the Node process open.
|
||||
* @param session - Pending QR session whose id is used to drive later status reconciliation.
|
||||
*/
|
||||
private startScanStatusMonitor(session: QqbotLoginScanSession) {
|
||||
this.ensureScanStatusMonitorDeadline(session);
|
||||
if (this.hasScanStatusMonitorDeadlinePassed(session)) {
|
||||
void this.expireSession(session);
|
||||
return;
|
||||
}
|
||||
if (this.scanStatusMonitorTimers[session.id]) return;
|
||||
const timer = setTimeout(() => {
|
||||
this.scanStatusMonitorTimers[session.id] = undefined;
|
||||
void this.runScanStatusMonitor(session.id);
|
||||
}, this.getLoginPollIntervalMs());
|
||||
timer.unref?.();
|
||||
this.scanStatusMonitorTimers[session.id] = timer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops any server-side status monitor for a login session.
|
||||
* @param sessionId - Session key whose timer should no longer reconcile NapCat state.
|
||||
*/
|
||||
private stopScanStatusMonitor(sessionId: string) {
|
||||
const timer = this.scanStatusMonitorTimers[sessionId];
|
||||
if (timer) clearTimeout(timer);
|
||||
delete this.scanStatusMonitorTimers[sessionId];
|
||||
delete this.scanStatusMonitorDeadlines[sessionId];
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops every server-side QR status monitor before the backing session store is cleared.
|
||||
*/
|
||||
private stopAllScanStatusMonitors() {
|
||||
Object.keys(this.scanStatusMonitorTimers).forEach((sessionId) => {
|
||||
this.stopScanStatusMonitor(sessionId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures the active QR code deadline so monitor polling cannot extend the same QR session forever.
|
||||
* @param session - Pending QR session whose current QR URL and expiry form the monitor deadline snapshot.
|
||||
*/
|
||||
private ensureScanStatusMonitorDeadline(session: QqbotLoginScanSession) {
|
||||
if (!session.qrcode) return;
|
||||
const current = this.scanStatusMonitorDeadlines[session.id];
|
||||
if (current?.qrcode === session.qrcode) return;
|
||||
this.scanStatusMonitorDeadlines[session.id] = {
|
||||
expiresAt: session.expiresAt,
|
||||
qrcode: session.qrcode,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the monitor-owned QR deadline instead of the session expiry that status polling may renew.
|
||||
* @param session - Pending QR session whose monitor snapshot decides terminal expiry.
|
||||
* @returns True when the monitored QR should be expired by the backend monitor.
|
||||
*/
|
||||
private hasScanStatusMonitorDeadlinePassed(
|
||||
session: QqbotLoginScanSession,
|
||||
) {
|
||||
const deadline = this.scanStatusMonitorDeadlines[session.id];
|
||||
if (deadline && session.qrcode && deadline.qrcode !== session.qrcode) {
|
||||
this.ensureScanStatusMonitorDeadline(session);
|
||||
return false;
|
||||
}
|
||||
return !!deadline && Date.now() > deadline.expiresAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls the same status path used by Admin so SSE can emit success/expired events after QR generation.
|
||||
* @param sessionId - Pending QR session id to reload from the session store before each poll.
|
||||
*/
|
||||
private async runScanStatusMonitor(sessionId: string) {
|
||||
try {
|
||||
const session = await this.loginSessionStore.get(sessionId);
|
||||
if (!session || !this.shouldMonitorScanStatus(session)) return;
|
||||
if (this.hasScanStatusMonitorDeadlinePassed(session)) {
|
||||
await this.expireSession(session);
|
||||
return;
|
||||
}
|
||||
const result = await this.status(sessionId);
|
||||
if (result.status !== 'pending') return;
|
||||
const current =
|
||||
this.loginSessionStore.getCached(sessionId) ||
|
||||
(await this.loginSessionStore.get(sessionId));
|
||||
if (current && this.shouldMonitorScanStatus(current)) {
|
||||
if (this.hasScanStatusMonitorDeadlinePassed(current)) {
|
||||
await this.expireSession(current);
|
||||
return;
|
||||
}
|
||||
this.startScanStatusMonitor(current);
|
||||
}
|
||||
} catch {
|
||||
const current = this.loginSessionStore.getCached(sessionId);
|
||||
if (current && this.shouldMonitorScanStatus(current)) {
|
||||
if (this.hasScanStatusMonitorDeadlinePassed(current)) {
|
||||
await this.expireSession(current);
|
||||
return;
|
||||
}
|
||||
this.startScanStatusMonitor(current);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行 NapCat 登录运行态流程。
|
||||
* @param session - session 输入;使用 `status`、`errorMessage`、`expiresAt`、`qrcode` 字段生成结果。
|
||||
|
||||
@ -351,6 +351,11 @@ describe('NapcatWebuiProxyService redirect rewriting', () => {
|
||||
let credentialClient: {
|
||||
getCredential: jest.Mock;
|
||||
};
|
||||
let upstreamRequests: Array<{
|
||||
authorization?: string;
|
||||
method?: string;
|
||||
url?: string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Starts a tiny NapCat-like upstream that redirects `/webui` to `/webui/`.
|
||||
@ -358,6 +363,25 @@ describe('NapcatWebuiProxyService redirect rewriting', () => {
|
||||
*/
|
||||
async function startRedirectUpstream() {
|
||||
const server = createServer((req, res) => {
|
||||
upstreamRequests.push({
|
||||
authorization: req.headers.authorization,
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
});
|
||||
|
||||
if (req.url === '/api/File/list?path=%2F') {
|
||||
res.statusCode = HttpStatus.OK;
|
||||
res.setHeader('content-type', 'application/json; charset=UTF-8');
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
code: 0,
|
||||
data: [{ isDirectory: true, name: 'app', size: 0 }],
|
||||
message: 'success',
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url === '/webui') {
|
||||
res.statusCode = HttpStatus.MOVED_PERMANENTLY;
|
||||
res.setHeader('content-type', 'text/html; charset=UTF-8');
|
||||
@ -386,6 +410,7 @@ describe('NapcatWebuiProxyService redirect rewriting', () => {
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
upstreamRequests = [];
|
||||
const started = await startRedirectUpstream();
|
||||
upstream = started.server;
|
||||
upstreamBaseUrl = started.baseUrl;
|
||||
@ -446,4 +471,26 @@ describe('NapcatWebuiProxyService redirect rewriting', () => {
|
||||
`/napcat-webui/session/${SESSION_ID}/webui/webui/`,
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards NapCat File API requests under /api without duplicating the Gateway session prefix', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/napcat-webui/session/${SESSION_ID}/webui/api/File/list?path=%2F`)
|
||||
.expect(HttpStatus.OK);
|
||||
|
||||
expect(response.body).toEqual({
|
||||
code: 0,
|
||||
data: [{ isDirectory: true, name: 'app', size: 0 }],
|
||||
message: 'success',
|
||||
});
|
||||
expect(upstreamRequests).toContainEqual({
|
||||
authorization: 'Bearer credential-1',
|
||||
method: 'GET',
|
||||
url: '/api/File/list?path=%2F',
|
||||
});
|
||||
expect(
|
||||
upstreamRequests.some((item) =>
|
||||
item.url?.includes('/api/napcat-webui/session/'),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@ -17,7 +17,7 @@ describe('NapcatWebuiProxyService response rewriting', () => {
|
||||
body: [
|
||||
'<script type="module" src="/webui/assets/index.js"></script>',
|
||||
'<link rel="stylesheet" href="/webui/assets/index.css">',
|
||||
'<script>const baseURL="/api"; const file="/File/font/upload/webui";</script>',
|
||||
'<script>const baseURL="/api"; const file="/File/list?path=%2F"; const theme="/files/theme.css";</script>',
|
||||
'url("/webui/fonts/CustomFont.woff")',
|
||||
].join(''),
|
||||
sessionId: 'sess_1',
|
||||
@ -32,8 +32,10 @@ describe('NapcatWebuiProxyService response rewriting', () => {
|
||||
expect(rewritten).toContain(
|
||||
'baseURL="/napcat-webui/session/sess_1/webui/api"',
|
||||
);
|
||||
expect(rewritten).toContain('file="/File/list?path=%2F"');
|
||||
expect(rewritten).not.toContain('/webui/api/napcat-webui/session');
|
||||
expect(rewritten).toContain(
|
||||
'file="/napcat-webui/session/sess_1/webui/File/font/upload/webui"',
|
||||
'theme="/napcat-webui/session/sess_1/webui/files/theme.css"',
|
||||
);
|
||||
expect(rewritten).toContain(
|
||||
'url("/napcat-webui/session/sess_1/webui/webui/fonts/CustomFont.woff")',
|
||||
|
||||
@ -1054,6 +1054,66 @@ describe('QqbotNapcatLoginService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('confirms real login status when quick login reports already logged in using the NapCat Chinese message', async () => {
|
||||
const container = {
|
||||
baseUrl: 'http://127.0.0.1:6103/',
|
||||
id: 'container-quick-already-online-cn',
|
||||
name: 'napcat-1914728559',
|
||||
};
|
||||
const accountService = {
|
||||
ensureScannedAccount: jest.fn().mockResolvedValue('account-1'),
|
||||
};
|
||||
const containerService = {
|
||||
bindAccount: jest.fn().mockResolvedValue(undefined),
|
||||
resetRuntimeLoginState: jest.fn(),
|
||||
restartRuntimeContainer: jest.fn(),
|
||||
};
|
||||
const refreshService = new QqbotNapcatLoginService(
|
||||
{ get: jest.fn() } as unknown as ConfigService,
|
||||
accountService as unknown as QqbotAccountService,
|
||||
containerService as unknown as QqbotNapcatContainerService,
|
||||
new ToolsService(),
|
||||
);
|
||||
const session = (refreshService as any).createSession({
|
||||
accountId: 'account-1',
|
||||
container,
|
||||
expectedSelfId: '1914728559',
|
||||
mode: 'refresh',
|
||||
preparingRelogin: true,
|
||||
status: 'pending',
|
||||
});
|
||||
(refreshService as any).sessions.set(session.id, session);
|
||||
mockWebuiLoginPost(refreshService, {
|
||||
quickError: '当前账号(1914728559)已登录,无法重复登录',
|
||||
});
|
||||
const getLoginStatus = jest
|
||||
.spyOn(refreshService as any, 'getLoginStatus')
|
||||
.mockResolvedValue({ isLogin: true });
|
||||
jest.spyOn(refreshService as any, 'getLoginInfo').mockResolvedValue({
|
||||
nickname: 'Mirror',
|
||||
online: true,
|
||||
uin: '1914728559',
|
||||
});
|
||||
const refreshQrcode = jest
|
||||
.spyOn(refreshService as any, 'refreshOrGetQrcode')
|
||||
.mockResolvedValue('fallback-qrcode');
|
||||
|
||||
await (refreshService as any).prepareReloginQrcode(session, container);
|
||||
|
||||
expect(getLoginStatus).toHaveBeenCalledWith(container, true);
|
||||
expect(refreshQrcode).not.toHaveBeenCalled();
|
||||
expect(session.status).toBe('success');
|
||||
const events = (refreshService as any).sessionEventLogs.get(session.id);
|
||||
expect(events).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
message: 'NapCat 已登录,已确认真实在线状态',
|
||||
step: 'login-success',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps binding errors terminal after already-logged quick status is confirmed online', async () => {
|
||||
const container = {
|
||||
baseUrl: 'http://127.0.0.1:6103/',
|
||||
@ -1114,6 +1174,200 @@ describe('QqbotNapcatLoginService', () => {
|
||||
expect(steps).toContain('relogin-error');
|
||||
});
|
||||
|
||||
it('monitors a ready qrcode session and publishes login success without frontend polling', async () => {
|
||||
const container = {
|
||||
baseUrl: 'http://127.0.0.1:6103/',
|
||||
id: 'container-monitor-qrcode',
|
||||
name: 'napcat-monitor-qrcode',
|
||||
};
|
||||
const accountService = {
|
||||
ensureScannedAccount: jest.fn().mockResolvedValue('account-1'),
|
||||
};
|
||||
const containerService = {
|
||||
bindAccount: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const monitorService = new QqbotNapcatLoginService(
|
||||
{
|
||||
get: jest.fn((key: string) =>
|
||||
key === 'QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS' ? 10 : undefined,
|
||||
),
|
||||
} as unknown as ConfigService,
|
||||
accountService as unknown as QqbotAccountService,
|
||||
containerService as unknown as QqbotNapcatContainerService,
|
||||
new ToolsService(),
|
||||
);
|
||||
const session = (monitorService as any).createSession({
|
||||
accountId: 'account-1',
|
||||
container,
|
||||
expectedSelfId: '1914728559',
|
||||
mode: 'refresh',
|
||||
qrcode: 'https://txz.qq.com/p?k=ready&f=1600001615',
|
||||
status: 'pending',
|
||||
});
|
||||
(monitorService as any).sessions.set(session.id, session);
|
||||
jest
|
||||
.spyOn(monitorService as any, 'getSessionContainer')
|
||||
.mockResolvedValue(container);
|
||||
const getLoginStatus = jest
|
||||
.spyOn(monitorService as any, 'getLoginStatus')
|
||||
.mockResolvedValue({ isLogin: true });
|
||||
jest.spyOn(monitorService as any, 'getLoginInfo').mockResolvedValue({
|
||||
nick: 'Mirror',
|
||||
online: true,
|
||||
uin: '1914728559',
|
||||
});
|
||||
|
||||
(monitorService as any).publishScanResultEvent(
|
||||
session,
|
||||
'qrcode-ready',
|
||||
'success',
|
||||
'登录二维码已生成',
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
|
||||
expect(getLoginStatus).toHaveBeenCalledWith(container);
|
||||
expect(session.status).toBe('success');
|
||||
expect((monitorService as any).sessionEventLogs.get(session.id)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ step: 'login-success' }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not let the server-side qrcode monitor extend the original qrcode deadline on temporary errors', async () => {
|
||||
const container = {
|
||||
baseUrl: 'http://127.0.0.1:6103/',
|
||||
id: 'container-monitor-temporary-error',
|
||||
name: 'napcat-monitor-temporary-error',
|
||||
};
|
||||
const monitorService = new QqbotNapcatLoginService(
|
||||
{
|
||||
get: jest.fn((key: string) => {
|
||||
if (key === 'QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS') return 10;
|
||||
if (key === 'NAPCAT_LOGIN_QR_EXPIRE_MS') return 25;
|
||||
return undefined;
|
||||
}),
|
||||
} as unknown as ConfigService,
|
||||
{
|
||||
ensureScannedAccount: jest.fn().mockResolvedValue('account-1'),
|
||||
} as unknown as QqbotAccountService,
|
||||
{
|
||||
bindAccount: jest.fn().mockResolvedValue(undefined),
|
||||
} as unknown as QqbotNapcatContainerService,
|
||||
new ToolsService(),
|
||||
);
|
||||
const session = (monitorService as any).createSession({
|
||||
accountId: 'account-1',
|
||||
container,
|
||||
expectedSelfId: '1914728559',
|
||||
mode: 'refresh',
|
||||
qrcode: 'https://txz.qq.com/p?k=temporary&f=1600001615',
|
||||
status: 'pending',
|
||||
});
|
||||
(monitorService as any).sessions.set(session.id, session);
|
||||
jest
|
||||
.spyOn(monitorService as any, 'getSessionContainer')
|
||||
.mockResolvedValue(container);
|
||||
jest
|
||||
.spyOn(monitorService as any, 'getLoginStatus')
|
||||
.mockRejectedValue(new Error('NapCat 请求超时'));
|
||||
const publishResult = jest.spyOn(
|
||||
monitorService as any,
|
||||
'publishScanResultEvent',
|
||||
);
|
||||
|
||||
(monitorService as any).publishScanResultEvent(
|
||||
session,
|
||||
'qrcode-ready',
|
||||
'success',
|
||||
'登录二维码已生成',
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 80));
|
||||
|
||||
expect(session.status).toBe('expired');
|
||||
expect(publishResult).toHaveBeenCalledWith(
|
||||
session,
|
||||
'session-expired',
|
||||
'error',
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
|
||||
it('refreshes the monitor deadline when a new qrcode is published while a timer is already active', async () => {
|
||||
const container = {
|
||||
baseUrl: 'http://127.0.0.1:6103/',
|
||||
id: 'container-monitor-new-qrcode',
|
||||
name: 'napcat-monitor-new-qrcode',
|
||||
};
|
||||
let qrTtlReads = 0;
|
||||
const monitorService = new QqbotNapcatLoginService(
|
||||
{
|
||||
get: jest.fn((key: string) => {
|
||||
if (key === 'QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS') return 60;
|
||||
if (key === 'NAPCAT_LOGIN_QR_EXPIRE_MS') {
|
||||
qrTtlReads += 1;
|
||||
return qrTtlReads <= 2 ? 25 : 120;
|
||||
}
|
||||
return undefined;
|
||||
}),
|
||||
} as unknown as ConfigService,
|
||||
{
|
||||
ensureScannedAccount: jest.fn().mockResolvedValue('account-1'),
|
||||
} as unknown as QqbotAccountService,
|
||||
{
|
||||
bindAccount: jest.fn().mockResolvedValue(undefined),
|
||||
} as unknown as QqbotNapcatContainerService,
|
||||
new ToolsService(),
|
||||
);
|
||||
const firstQrcode = 'https://txz.qq.com/p?k=first&f=1600001615';
|
||||
const nextQrcode = 'https://txz.qq.com/p?k=next&f=1600001615';
|
||||
const session = (monitorService as any).createSession({
|
||||
accountId: 'account-1',
|
||||
container,
|
||||
expectedSelfId: '1914728559',
|
||||
mode: 'refresh',
|
||||
qrcode: firstQrcode,
|
||||
status: 'pending',
|
||||
});
|
||||
(monitorService as any).sessions.set(session.id, session);
|
||||
jest
|
||||
.spyOn(monitorService as any, 'getSessionContainer')
|
||||
.mockResolvedValue(container);
|
||||
jest.spyOn(monitorService as any, 'getLoginStatus').mockResolvedValue({
|
||||
isLogin: false,
|
||||
qrcodeurl: nextQrcode,
|
||||
});
|
||||
const publishResult = jest.spyOn(
|
||||
monitorService as any,
|
||||
'publishScanResultEvent',
|
||||
);
|
||||
|
||||
(monitorService as any).publishScanResultEvent(
|
||||
session,
|
||||
'qrcode-ready',
|
||||
'success',
|
||||
'登录二维码已生成',
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
session.qrcode = nextQrcode;
|
||||
(monitorService as any).publishScanResultEvent(
|
||||
session,
|
||||
'qrcode-ready',
|
||||
'success',
|
||||
'登录二维码已刷新',
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 80));
|
||||
|
||||
expect(session.status).toBe('pending');
|
||||
expect(
|
||||
(monitorService as any).scanStatusMonitorDeadlines[session.id]?.qrcode,
|
||||
).toBe(nextQrcode);
|
||||
expect(
|
||||
publishResult.mock.calls.some((call) => call[1] === 'session-expired'),
|
||||
).toBe(false);
|
||||
(monitorService as any).sessions.clear();
|
||||
});
|
||||
|
||||
it('does not rebuild even when the refresh session already has a rebuild count', async () => {
|
||||
const container = {
|
||||
baseUrl: 'http://127.0.0.1:6103/',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user