1
0
forked from boos/BoosAPI

fix desktop tray icon telegram topics and checkin intervals

This commit is contained in:
cita
2026-03-20 12:16:00 +08:00
parent 2de5eebf29
commit 220f786204
23 changed files with 808 additions and 42 deletions
+5
View File
@@ -7,12 +7,17 @@ describe('desktop icon assets', () => {
const {
DESKTOP_BUILD_ICON_RELATIVE_PATH,
DESKTOP_RUNTIME_ICON_RELATIVE_PATH,
DESKTOP_TRAY_TEMPLATE_ICON_RELATIVE_PATH,
getDesktopRuntimeIconPath,
getDesktopTrayIconPath,
} = await import('./iconAssets.js');
expect(DESKTOP_RUNTIME_ICON_RELATIVE_PATH).toBe(join('dist', 'web', 'desktop-icon.png'));
expect(DESKTOP_BUILD_ICON_RELATIVE_PATH).toBe(join('build', 'desktop-icon.png'));
expect(DESKTOP_TRAY_TEMPLATE_ICON_RELATIVE_PATH).toBe(join('dist', 'web', 'desktop-tray-template.png'));
expect(getDesktopRuntimeIconPath('/app')).toBe(join('/app', 'dist', 'web', 'desktop-icon.png'));
expect(getDesktopTrayIconPath('/app', 'darwin')).toBe(join('/app', 'dist', 'web', 'desktop-tray-template.png'));
expect(getDesktopTrayIconPath('/app', 'win32')).toBe(join('/app', 'dist', 'web', 'desktop-icon.png'));
});
it('points electron-builder at the generated desktop package icon', async () => {
+11
View File
@@ -2,8 +2,19 @@ import { join } from 'node:path';
export const DESKTOP_RUNTIME_ICON_RELATIVE_PATH = join('dist', 'web', 'desktop-icon.png');
export const DESKTOP_BUILD_ICON_RELATIVE_PATH = join('build', 'desktop-icon.png');
export const DESKTOP_TRAY_TEMPLATE_ICON_RELATIVE_PATH = join('dist', 'web', 'desktop-tray-template.png');
export const DESKTOP_ICON_PUBLIC_PATH = '/desktop-icon.png';
export function getDesktopRuntimeIconPath(appPath: string) {
return join(appPath, DESKTOP_RUNTIME_ICON_RELATIVE_PATH);
}
export function getDesktopTrayTemplateIconPath(appPath: string) {
return join(appPath, DESKTOP_TRAY_TEMPLATE_ICON_RELATIVE_PATH);
}
export function getDesktopTrayIconPath(appPath: string, platform = process.platform) {
return platform === 'darwin'
? getDesktopTrayTemplateIconPath(appPath)
: getDesktopRuntimeIconPath(appPath);
}
+11 -3
View File
@@ -21,7 +21,7 @@ import {
resolveDesktopServerWorkingDir,
waitForServerReady,
} from './runtime.js';
import { getDesktopRuntimeIconPath } from './iconAssets.js';
import { getDesktopRuntimeIconPath, getDesktopTrayIconPath } from './iconAssets.js';
const { autoUpdater } = electronUpdater;
@@ -52,7 +52,7 @@ function getServerEntryPath() {
}
function getTrayIconPath() {
return getDesktopRuntimeIconPath(app.getAppPath());
return getDesktopTrayIconPath(app.getAppPath(), process.platform);
}
function getWindowIconPath() {
@@ -121,7 +121,15 @@ function buildTrayMenu() {
function setupTray() {
if (tray) return;
const trayImage = nativeImage.createFromPath(getTrayIconPath());
let trayImage = nativeImage.createFromPath(getTrayIconPath());
if (process.platform === 'darwin' && !trayImage.isEmpty()) {
trayImage = trayImage.resize({
width: 18,
height: 18,
quality: 'best',
});
trayImage.setTemplateImage(true);
}
tray = new Tray(trayImage);
tray.setToolTip('Metapi');
tray.setContextMenu(buildTrayMenu());
+9
View File
@@ -36,6 +36,15 @@ describe('buildConfig', () => {
const config = buildConfig({});
expect(config.telegramApiBaseUrl).toBe('https://api.telegram.org');
expect(config.telegramMessageThreadId).toBe('');
});
it('accepts telegram message thread id from environment', () => {
const config = buildConfig({
TELEGRAM_MESSAGE_THREAD_ID: '77',
});
expect(config.telegramMessageThreadId).toBe('77');
});
it('ships CLI-aligned OAuth defaults', () => {
+5
View File
@@ -67,6 +67,10 @@ export function buildConfig(env: NodeJS.ProcessEnv) {
systemProxyUrl: env.SYSTEM_PROXY_URL || '',
accountCredentialSecret: env.ACCOUNT_CREDENTIAL_SECRET || env.AUTH_TOKEN || 'change-me-admin-token',
checkinCron: env.CHECKIN_CRON || '0 8 * * *',
checkinScheduleMode: (env.CHECKIN_SCHEDULE_MODE || 'cron').trim().toLowerCase() === 'interval'
? 'interval' as const
: 'cron' as const,
checkinIntervalHours: Math.min(24, Math.max(1, Math.trunc(parseNumber(env.CHECKIN_INTERVAL_HOURS, 6)))),
balanceRefreshCron: env.BALANCE_REFRESH_CRON || '0 * * * *',
logCleanupCron: env.LOG_CLEANUP_CRON || '0 6 * * *',
logCleanupConfigured: false,
@@ -84,6 +88,7 @@ export function buildConfig(env: NodeJS.ProcessEnv) {
telegramBotToken: env.TELEGRAM_BOT_TOKEN || '',
telegramChatId: env.TELEGRAM_CHAT_ID || '',
telegramUseSystemProxy: parseBoolean(env.TELEGRAM_USE_SYSTEM_PROXY, false),
telegramMessageThreadId: (env.TELEGRAM_MESSAGE_THREAD_ID || '').trim(),
smtpEnabled: parseBoolean(env.SMTP_ENABLED, false),
smtpHost: env.SMTP_HOST || '',
smtpPort: parseInt(env.SMTP_PORT || '587'),
+16
View File
@@ -162,6 +162,16 @@ function applyRuntimeSettings(settingsMap: Map<string, string>) {
const checkinCron = parseSettingFromMap<string>(settingsMap, 'checkin_cron');
if (typeof checkinCron === 'string' && checkinCron) config.checkinCron = checkinCron;
const checkinScheduleMode = parseSettingFromMap<string>(settingsMap, 'checkin_schedule_mode');
if (checkinScheduleMode === 'cron' || checkinScheduleMode === 'interval') {
config.checkinScheduleMode = checkinScheduleMode;
}
const checkinIntervalHours = parseSettingFromMap<number>(settingsMap, 'checkin_interval_hours');
if (typeof checkinIntervalHours === 'number' && Number.isFinite(checkinIntervalHours) && checkinIntervalHours >= 1 && checkinIntervalHours <= 24) {
config.checkinIntervalHours = Math.trunc(checkinIntervalHours);
}
const balanceRefreshCron = parseSettingFromMap<string>(settingsMap, 'balance_refresh_cron');
if (typeof balanceRefreshCron === 'string' && balanceRefreshCron) config.balanceRefreshCron = balanceRefreshCron;
@@ -219,6 +229,12 @@ function applyRuntimeSettings(settingsMap: Map<string, string>) {
const telegramChatId = parseSettingFromMap<string>(settingsMap, 'telegram_chat_id');
if (typeof telegramChatId === 'string') config.telegramChatId = telegramChatId;
const telegramUseSystemProxy = parseSettingFromMap<boolean>(settingsMap, 'telegram_use_system_proxy');
if (typeof telegramUseSystemProxy === 'boolean') config.telegramUseSystemProxy = telegramUseSystemProxy;
const telegramMessageThreadId = parseSettingFromMap<string>(settingsMap, 'telegram_message_thread_id');
if (typeof telegramMessageThreadId === 'string') config.telegramMessageThreadId = telegramMessageThreadId;
const smtpEnabled = parseSettingFromMap<boolean>(settingsMap, 'smtp_enabled');
if (typeof smtpEnabled === 'boolean') config.smtpEnabled = smtpEnabled;
+22 -1
View File
@@ -10,7 +10,7 @@ vi.mock('../../services/checkinService.js', () => ({
}));
vi.mock('../../services/checkinScheduler.js', () => ({
updateCheckinCron: vi.fn(),
updateCheckinSchedule: vi.fn(),
}));
vi.mock('../../db/index.js', () => {
@@ -87,4 +87,25 @@ describe('POST /api/checkin/trigger background task dedupe', () => {
await new Promise((resolve) => setTimeout(resolve, 20));
await app.close();
});
it('accepts the legacy cron-only schedule payload', async () => {
const { checkinRoutes } = await import('./checkin.js');
const schedulerModule = await import('../../services/checkinScheduler.js');
const app = Fastify();
await app.register(checkinRoutes);
const response = await app.inject({
method: 'PUT',
url: '/api/checkin/schedule',
payload: { cron: '0 8 * * *' },
});
expect(response.statusCode).toBe(200);
expect((schedulerModule as any).updateCheckinSchedule).toHaveBeenCalledWith({
mode: 'cron',
cronExpr: '0 8 * * *',
intervalHours: undefined,
});
await app.close();
});
});
+30 -7
View File
@@ -1,9 +1,10 @@
import { FastifyInstance } from 'fastify';
import { config } from '../../config.js';
import { db, schema } from '../../db/index.js';
import { upsertSetting } from '../../db/upsertSetting.js';
import { eq, desc } from 'drizzle-orm';
import { checkinAccount, checkinAll } from '../../services/checkinService.js';
import { updateCheckinCron } from '../../services/checkinScheduler.js';
import { updateCheckinSchedule } from '../../services/checkinScheduler.js';
import { startBackgroundTask, summarizeCheckinResults } from '../../services/backgroundTaskService.js';
import { classifyFailureReason } from '../../services/failureReasonService.js';
@@ -84,7 +85,7 @@ export async function checkinRoutes(app: FastifyInstance) {
failureMessage: (currentTask) => `全部账号签到任务失败:${currentTask.error || 'unknown error'}`,
},
async () => {
const results = await checkinAll();
const results = await checkinAll({ scheduleMode: config.checkinScheduleMode });
return {
summary: summarizeCheckinResults(results),
total: results.length,
@@ -108,7 +109,7 @@ export async function checkinRoutes(app: FastifyInstance) {
// Trigger check-in for a specific account
app.post<{ Params: { id: string } }>('/api/checkin/trigger/:id', async (request) => {
const id = parseInt(request.params.id, 10);
const result = await checkinAccount(id);
const result = await checkinAccount(id, { scheduleMode: config.checkinScheduleMode });
return result;
});
@@ -142,11 +143,33 @@ export async function checkinRoutes(app: FastifyInstance) {
});
// Update check-in schedule
app.put<{ Body: { cron: string } }>('/api/checkin/schedule', async (request) => {
app.put<{ Body: { mode?: 'cron' | 'interval'; cron?: string; intervalHours?: number } }>('/api/checkin/schedule', async (request) => {
try {
updateCheckinCron(request.body.cron);
await upsertSetting('checkin_cron', request.body.cron);
return { success: true, cron: request.body.cron };
const body = request.body || {};
const nextMode: 'cron' | 'interval' = body.mode === 'interval' ? 'interval' : 'cron';
const nextCron = typeof body.cron === 'string' ? body.cron : undefined;
const nextIntervalHours = body.intervalHours !== undefined ? Number(body.intervalHours) : undefined;
const normalizedIntervalHours = typeof nextIntervalHours === 'number' && Number.isFinite(nextIntervalHours)
? Math.trunc(nextIntervalHours)
: undefined;
updateCheckinSchedule({
mode: nextMode,
cronExpr: nextCron,
intervalHours: normalizedIntervalHours,
});
await upsertSetting('checkin_schedule_mode', nextMode);
if (nextCron !== undefined) await upsertSetting('checkin_cron', nextCron);
if (normalizedIntervalHours !== undefined) {
await upsertSetting('checkin_interval_hours', normalizedIntervalHours);
}
return {
success: true,
mode: nextMode,
cron: nextCron,
intervalHours: normalizedIntervalHours,
};
} catch (err: any) {
return { error: err.message };
}
@@ -44,6 +44,8 @@ describe('settings and auth events', () => {
config.proxyToken = 'sk-old-proxy-token-123';
config.systemProxyUrl = '';
config.checkinCron = '0 8 * * *';
(config as any).checkinScheduleMode = 'cron';
(config as any).checkinIntervalHours = 6;
config.balanceRefreshCron = '0 * * * *';
config.logCleanupConfigured = false;
config.logCleanupCron = '0 6 * * *';
@@ -56,6 +58,7 @@ describe('settings and auth events', () => {
(config as any).telegramBotToken = '';
(config as any).telegramChatId = '';
(config as any).telegramUseSystemProxy = false;
(config as any).telegramMessageThreadId = '';
});
afterAll(async () => {
@@ -86,6 +89,28 @@ describe('settings and auth events', () => {
expect(events[0].message || '').toContain('签到 Cron');
});
it('persists and returns checkin interval mode from runtime settings', async () => {
const updateResponse = await app.inject({
method: 'PUT',
url: '/api/settings/runtime',
payload: {
checkinScheduleMode: 'interval',
checkinIntervalHours: 8,
checkinCron: '0 8 * * *',
},
});
expect(updateResponse.statusCode).toBe(200);
const updated = updateResponse.json() as { checkinScheduleMode?: string; checkinIntervalHours?: number };
expect(updated.checkinScheduleMode).toBe('interval');
expect(updated.checkinIntervalHours).toBe(8);
const savedMode = await db.select().from(schema.settings).where(eq(schema.settings.key, 'checkin_schedule_mode')).get();
const savedInterval = await db.select().from(schema.settings).where(eq(schema.settings.key, 'checkin_interval_hours')).get();
expect(savedMode?.value).toBe(JSON.stringify('interval'));
expect(savedInterval?.value).toBe(JSON.stringify(8));
});
it('returns current recognized admin IP in runtime settings response', async () => {
const response = await app.inject({
method: 'GET',
@@ -203,6 +228,32 @@ describe('settings and auth events', () => {
expect(runtime.telegramApiBaseUrl).toBe('https://tg-proxy.example.com/custom');
});
it('persists and returns telegram message thread id from runtime settings', async () => {
const updateResponse = await app.inject({
method: 'PUT',
url: '/api/settings/runtime',
payload: {
telegramMessageThreadId: '77',
},
});
expect(updateResponse.statusCode).toBe(200);
const updated = updateResponse.json() as { telegramMessageThreadId?: string };
expect(updated.telegramMessageThreadId).toBe('77');
expect((config as any).telegramMessageThreadId).toBe('77');
const saved = await db.select().from(schema.settings).where(eq(schema.settings.key, 'telegram_message_thread_id')).get();
expect(saved?.value).toBe(JSON.stringify('77'));
const getResponse = await app.inject({
method: 'GET',
url: '/api/settings/runtime',
});
expect(getResponse.statusCode).toBe(200);
const runtime = getResponse.json() as { telegramMessageThreadId?: string };
expect(runtime.telegramMessageThreadId).toBe('77');
});
it('rejects invalid telegram api base url when telegram is enabled', async () => {
const response = await app.inject({
method: 'PUT',
+114 -5
View File
@@ -4,7 +4,7 @@ import { config } from '../../config.js';
import { db, runtimeDbDialect, schema } from '../../db/index.js';
import { upsertSetting } from '../../db/upsertSetting.js';
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
import { updateBalanceRefreshCron, updateCheckinCron, updateLogCleanupSettings } from '../../services/checkinScheduler.js';
import { updateBalanceRefreshCron, updateCheckinSchedule, updateLogCleanupSettings } from '../../services/checkinScheduler.js';
import { sendNotification } from '../../services/notifyService.js';
import { exportBackup, importBackup, type BackupExportType } from '../../services/backupService.js';
import { startBackgroundTask } from '../../services/backgroundTaskService.js';
@@ -28,6 +28,8 @@ interface RuntimeSettingsBody {
proxyToken?: string;
systemProxyUrl?: string;
checkinCron?: string;
checkinScheduleMode?: 'cron' | 'interval';
checkinIntervalHours?: number;
balanceRefreshCron?: string;
logCleanupCron?: string;
logCleanupUsageLogsEnabled?: boolean;
@@ -44,6 +46,7 @@ interface RuntimeSettingsBody {
telegramBotToken?: string;
telegramChatId?: string;
telegramUseSystemProxy?: boolean;
telegramMessageThreadId?: string;
smtpEnabled?: boolean;
smtpHost?: string;
smtpPort?: number;
@@ -172,12 +175,46 @@ function normalizeTelegramApiBaseUrl(raw: string): string {
return String(raw || '').trim().replace(/\/+$/, '');
}
function normalizeTelegramMessageThreadId(raw: unknown): string {
return String(raw || '').trim();
}
function isValidTelegramMessageThreadId(raw: string): boolean {
return /^[1-9]\d*$/.test(raw);
}
function applyImportedSettingToRuntime(key: string, value: unknown) {
switch (key) {
case 'checkin_cron': {
if (typeof value !== 'string' || !value || !cron.validate(value)) return;
config.checkinCron = value;
updateCheckinCron(value);
updateCheckinSchedule({
mode: config.checkinScheduleMode,
cronExpr: config.checkinCron,
intervalHours: config.checkinIntervalHours,
});
return;
}
case 'checkin_schedule_mode': {
if (value !== 'cron' && value !== 'interval') return;
const nextMode: 'cron' | 'interval' = value;
config.checkinScheduleMode = nextMode;
updateCheckinSchedule({
mode: config.checkinScheduleMode,
cronExpr: config.checkinCron,
intervalHours: config.checkinIntervalHours,
});
return;
}
case 'checkin_interval_hours': {
const intervalHours = Number(value);
if (!Number.isFinite(intervalHours) || intervalHours < 1 || intervalHours > 24) return;
config.checkinIntervalHours = Math.trunc(intervalHours);
updateCheckinSchedule({
mode: config.checkinScheduleMode,
cronExpr: config.checkinCron,
intervalHours: config.checkinIntervalHours,
});
return;
}
case 'balance_refresh_cron': {
@@ -293,6 +330,11 @@ function applyImportedSettingToRuntime(key: string, value: unknown) {
config.telegramUseSystemProxy = !!value;
return;
}
case 'telegram_message_thread_id': {
if (typeof value !== 'string') return;
config.telegramMessageThreadId = value.trim();
return;
}
case 'smtp_enabled': {
config.smtpEnabled = !!value;
return;
@@ -368,6 +410,8 @@ function applyImportedSettingToRuntime(key: string, value: unknown) {
function getRuntimeSettingsResponse(currentAdminIp = '') {
return {
checkinCron: config.checkinCron,
checkinScheduleMode: config.checkinScheduleMode,
checkinIntervalHours: config.checkinIntervalHours,
balanceRefreshCron: config.balanceRefreshCron,
logCleanupCron: config.logCleanupCron,
logCleanupUsageLogsEnabled: config.logCleanupUsageLogsEnabled,
@@ -386,6 +430,7 @@ function getRuntimeSettingsResponse(currentAdminIp = '') {
telegramBotTokenMasked: maskSecret(config.telegramBotToken),
telegramChatId: config.telegramChatId,
telegramUseSystemProxy: config.telegramUseSystemProxy,
telegramMessageThreadId: config.telegramMessageThreadId,
smtpEnabled: config.smtpEnabled,
smtpHost: config.smtpHost,
smtpPort: config.smtpPort,
@@ -519,7 +564,8 @@ export async function settingsRoutes(app: FastifyInstance) {
|| body.telegramApiBaseUrl !== undefined
|| body.telegramBotToken !== undefined
|| body.telegramChatId !== undefined
|| body.telegramUseSystemProxy !== undefined;
|| body.telegramUseSystemProxy !== undefined
|| body.telegramMessageThreadId !== undefined;
const nextTelegramEnabled = body.telegramEnabled !== undefined
? !!body.telegramEnabled
: config.telegramEnabled;
@@ -532,6 +578,9 @@ export async function settingsRoutes(app: FastifyInstance) {
const nextTelegramChatId = body.telegramChatId !== undefined
? String(body.telegramChatId || '').trim()
: config.telegramChatId;
const nextTelegramMessageThreadId = body.telegramMessageThreadId !== undefined
? normalizeTelegramMessageThreadId(body.telegramMessageThreadId)
: config.telegramMessageThreadId;
if (telegramTouched && nextTelegramEnabled) {
if (!nextTelegramBotToken) {
return reply.code(400).send({ success: false, message: 'Telegram Bot Token 不能为空(启用 Telegram 时)' });
@@ -542,13 +591,22 @@ export async function settingsRoutes(app: FastifyInstance) {
if (!nextTelegramChatId) {
return reply.code(400).send({ success: false, message: 'Telegram Chat ID 不能为空(启用 Telegram 时)' });
}
if (nextTelegramMessageThreadId && !isValidTelegramMessageThreadId(nextTelegramMessageThreadId)) {
return reply.code(400).send({ success: false, message: 'Telegram Topic ID 格式无效,需要正整数' });
}
if (nextTelegramApiBaseUrl && !isValidHttpUrl(nextTelegramApiBaseUrl)) {
return reply.code(400).send({ success: false, message: 'Telegram API Base URL 无效,请填写 http/https 地址' });
}
} else if (body.telegramApiBaseUrl !== undefined && nextTelegramApiBaseUrl && !isValidHttpUrl(nextTelegramApiBaseUrl)) {
return reply.code(400).send({ success: false, message: 'Telegram API Base URL 无效,请填写 http/https 地址' });
} else if (body.telegramMessageThreadId !== undefined && nextTelegramMessageThreadId && !isValidTelegramMessageThreadId(nextTelegramMessageThreadId)) {
return reply.code(400).send({ success: false, message: 'Telegram Topic ID 格式无效,需要正整数' });
}
const checkinScheduleTouched = body.checkinCron !== undefined
|| body.checkinScheduleMode !== undefined
|| body.checkinIntervalHours !== undefined;
if (body.checkinCron !== undefined) {
if (!cron.validate(body.checkinCron)) {
return reply.code(400).send({ success: false, message: '签到 Cron 表达式无效' });
@@ -556,8 +614,50 @@ export async function settingsRoutes(app: FastifyInstance) {
if (body.checkinCron !== config.checkinCron) {
changedLabels.push(`签到 Cron(${config.checkinCron} -> ${body.checkinCron})`);
}
updateCheckinCron(body.checkinCron);
upsertSetting('checkin_cron', body.checkinCron);
}
if (body.checkinScheduleMode !== undefined) {
if (body.checkinScheduleMode !== 'cron' && body.checkinScheduleMode !== 'interval') {
return reply.code(400).send({ success: false, message: '签到方式无效:仅支持 cron 或 interval' });
}
if (body.checkinScheduleMode !== config.checkinScheduleMode) {
changedLabels.push('签到方式');
}
config.checkinScheduleMode = body.checkinScheduleMode;
}
if (body.checkinIntervalHours !== undefined) {
const intervalHours = Number(body.checkinIntervalHours);
if (!Number.isFinite(intervalHours) || intervalHours < 1 || intervalHours > 24) {
return reply.code(400).send({ success: false, message: '签到间隔必须是 1 到 24 的整数小时' });
}
const nextIntervalHours = Math.trunc(intervalHours);
if (nextIntervalHours !== config.checkinIntervalHours) {
changedLabels.push(`签到间隔(${config.checkinIntervalHours}h -> ${nextIntervalHours}h)`);
}
config.checkinIntervalHours = nextIntervalHours;
}
if (checkinScheduleTouched) {
const nextCheckinCron = body.checkinCron !== undefined ? body.checkinCron : config.checkinCron;
const nextCheckinScheduleMode: 'cron' | 'interval' = body.checkinScheduleMode !== undefined
? body.checkinScheduleMode
: config.checkinScheduleMode;
const nextCheckinIntervalHours = body.checkinIntervalHours !== undefined
? Math.trunc(Number(body.checkinIntervalHours))
: config.checkinIntervalHours;
updateCheckinSchedule({
mode: nextCheckinScheduleMode,
cronExpr: nextCheckinCron,
intervalHours: nextCheckinIntervalHours,
});
config.checkinCron = nextCheckinCron;
config.checkinScheduleMode = nextCheckinScheduleMode;
config.checkinIntervalHours = nextCheckinIntervalHours;
upsertSetting('checkin_cron', config.checkinCron);
upsertSetting('checkin_schedule_mode', config.checkinScheduleMode);
upsertSetting('checkin_interval_hours', config.checkinIntervalHours);
}
if (body.balanceRefreshCron !== undefined) {
@@ -783,6 +883,15 @@ export async function settingsRoutes(app: FastifyInstance) {
upsertSetting('telegram_use_system_proxy', config.telegramUseSystemProxy);
}
if (body.telegramMessageThreadId !== undefined) {
const nextTelegramMessageThreadId = normalizeTelegramMessageThreadId(body.telegramMessageThreadId);
if (nextTelegramMessageThreadId !== config.telegramMessageThreadId) {
changedLabels.push('Telegram Topic ID');
}
config.telegramMessageThreadId = nextTelegramMessageThreadId;
upsertSetting('telegram_message_thread_id', config.telegramMessageThreadId);
}
if (body.smtpEnabled !== undefined) {
if (!!body.smtpEnabled !== config.smtpEnabled) {
changedLabels.push('SMTP 开关');
@@ -0,0 +1,95 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const cronStopMock = vi.fn();
const scheduleMock = vi.fn(() => ({
stop: cronStopMock,
}));
const validateMock = vi.fn(() => true);
const allMock = vi.fn();
vi.mock('node-cron', () => ({
default: {
schedule: (...args: unknown[]) => scheduleMock(...args),
validate: (...args: unknown[]) => validateMock(...args),
},
}));
vi.mock('../db/index.js', () => {
const queryChain = {
where: () => queryChain,
get: () => undefined,
all: () => [],
from: () => queryChain,
innerJoin: () => queryChain,
};
return {
db: {
select: () => queryChain,
},
schema: {
settings: { key: 'key' },
accounts: { checkinEnabled: 'checkinEnabled', status: 'status' },
sites: { id: 'id' },
},
};
});
vi.mock('./checkinService.js', () => ({
checkinAll: (...args: unknown[]) => allMock(...args),
}));
describe('checkinScheduler', () => {
beforeEach(() => {
vi.useFakeTimers();
cronStopMock.mockReset();
scheduleMock.mockClear();
validateMock.mockClear();
allMock.mockReset();
});
afterEach(async () => {
const scheduler = await import('./checkinScheduler.js');
scheduler.__resetCheckinSchedulerForTests();
vi.useRealTimers();
});
it('switches from cron mode to interval mode and back', async () => {
const setIntervalSpy = vi.spyOn(globalThis, 'setInterval');
const clearIntervalSpy = vi.spyOn(globalThis, 'clearInterval');
const scheduler = await import('./checkinScheduler.js');
scheduler.updateCheckinSchedule({
mode: 'cron',
cronExpr: '0 8 * * *',
intervalHours: 6,
});
expect(scheduleMock).toHaveBeenCalledTimes(1);
scheduler.updateCheckinSchedule({
mode: 'interval',
intervalHours: 6,
});
expect(cronStopMock).toHaveBeenCalledTimes(1);
expect(setIntervalSpy).toHaveBeenCalledTimes(1);
scheduler.updateCheckinSchedule({
mode: 'cron',
cronExpr: '5 9 * * *',
intervalHours: 6,
});
expect(clearIntervalSpy).toHaveBeenCalledTimes(1);
expect(scheduleMock).toHaveBeenCalledTimes(2);
});
it('selects due accounts from the last successful checkin time', async () => {
const scheduler = await import('./checkinScheduler.js');
const now = new Date('2026-03-20T12:00:00.000Z');
expect(scheduler.selectDueIntervalCheckinAccountIds([
{ id: 1, lastCheckinAt: null },
{ id: 2, lastCheckinAt: '2026-03-20T05:59:59.000Z' },
{ id: 3, lastCheckinAt: '2026-03-20T06:30:00.000Z' },
], 6, now)).toEqual([1, 2]);
});
});
+148 -8
View File
@@ -9,13 +9,18 @@ import { sendNotification } from './notifyService.js';
import { buildDailySummaryNotification, collectDailySummaryMetrics } from './dailySummaryService.js';
import { cleanupConfiguredLogs, normalizeLogCleanupRetentionDays } from './logCleanupService.js';
export type CheckinScheduleMode = 'cron' | 'interval';
let checkinTask: cron.ScheduledTask | null = null;
let checkinIntervalTimer: ReturnType<typeof setInterval> | null = null;
let balanceTask: cron.ScheduledTask | null = null;
let dailySummaryTask: cron.ScheduledTask | null = null;
let logCleanupTask: cron.ScheduledTask | null = null;
const intervalAttemptByAccount = new Map<number, number>();
const DAILY_SUMMARY_DEFAULT_CRON = '58 23 * * *';
const LOG_CLEANUP_DEFAULT_CRON = '0 6 * * *';
const CHECKIN_INTERVAL_POLL_MS = 60_000;
async function resolveJsonSetting<T>(
settingKey: string,
@@ -54,7 +59,7 @@ function createCheckinTask(cronExpr: string) {
return cron.schedule(cronExpr, async () => {
console.log(`[Scheduler] Running check-in at ${new Date().toISOString()}`);
try {
const results = await checkinAll();
const results = await checkinAll({ scheduleMode: 'cron' });
const success = results.filter((r) => r.result.success).length;
const failed = results.length - success;
console.log(`[Scheduler] Check-in complete: ${success} success, ${failed} failed`);
@@ -64,6 +69,94 @@ function createCheckinTask(cronExpr: string) {
});
}
type IntervalCheckinCandidate = {
id: number;
lastCheckinAt?: string | null;
};
export function selectDueIntervalCheckinAccountIds(
rows: IntervalCheckinCandidate[],
intervalHours: number,
now = new Date(),
attemptState = intervalAttemptByAccount,
) {
const nowMs = now.getTime();
const intervalMs = Math.max(1, intervalHours) * 60 * 60 * 1000;
return rows
.filter((row) => {
const lastCheckinMs = row.lastCheckinAt ? Date.parse(row.lastCheckinAt) : Number.NaN;
const lastAttemptMs = attemptState.get(row.id);
if (Number.isFinite(lastCheckinMs)) {
if (nowMs - lastCheckinMs < intervalMs) return false;
if (typeof lastAttemptMs === 'number' && lastAttemptMs >= lastCheckinMs && nowMs - lastAttemptMs < intervalMs) {
return false;
}
return true;
}
if (typeof lastAttemptMs === 'number' && nowMs - lastAttemptMs < intervalMs) return false;
return true;
})
.map((row) => row.id);
}
async function runIntervalCheckinPass(now = new Date()) {
const rows = await db
.select()
.from(schema.accounts)
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.all();
const dueAccountIds = selectDueIntervalCheckinAccountIds(
rows
.filter((row: any) => row.accounts?.checkinEnabled === true && row.accounts?.status === 'active' && row.sites?.status !== 'disabled')
.map((row: any) => ({
id: row.accounts.id,
lastCheckinAt: row.accounts.lastCheckinAt,
})),
config.checkinIntervalHours,
now,
);
if (dueAccountIds.length === 0) return;
try {
const results = await checkinAll({
accountIds: dueAccountIds,
scheduleMode: 'interval',
});
const nowMs = now.getTime();
for (const item of results) {
intervalAttemptByAccount.set(item.accountId, nowMs);
}
const success = results.filter((r) => r.result.success).length;
const failed = results.length - success;
console.log(`[Scheduler] Interval check-in complete: ${success} success, ${failed} failed`);
} catch (err) {
console.error('[Scheduler] Interval check-in error:', err);
}
}
function stopCheckinSchedule() {
checkinTask?.stop();
checkinTask = null;
if (checkinIntervalTimer) {
clearInterval(checkinIntervalTimer);
checkinIntervalTimer = null;
}
}
function startCheckinSchedule() {
stopCheckinSchedule();
if (config.checkinScheduleMode === 'interval') {
checkinIntervalTimer = setInterval(() => {
void runIntervalCheckinPass();
}, CHECKIN_INTERVAL_POLL_MS);
return;
}
checkinTask = createCheckinTask(config.checkinCron);
}
function createBalanceTask(cronExpr: string) {
return cron.schedule(cronExpr, async () => {
console.log(`[Scheduler] Refreshing balances at ${new Date().toISOString()}`);
@@ -119,6 +212,15 @@ function createLogCleanupTask(cronExpr: string) {
export async function startScheduler() {
const activeCheckinCron = await resolveCronSetting('checkin_cron', config.checkinCron);
const activeCheckinScheduleMode = await resolveJsonSetting<CheckinScheduleMode>(
'checkin_schedule_mode',
(value): value is CheckinScheduleMode => value === 'cron' || value === 'interval',
config.checkinScheduleMode as CheckinScheduleMode,
);
const activeCheckinIntervalHours = await resolvePositiveIntegerSetting(
'checkin_interval_hours',
config.checkinIntervalHours,
);
const activeBalanceCron = await resolveCronSetting('balance_refresh_cron', config.balanceRefreshCron);
const activeDailySummaryCron = await resolveCronSetting('daily_summary_cron', DAILY_SUMMARY_DEFAULT_CRON);
const activeLogCleanupCron = await resolveCronSetting('log_cleanup_cron', config.logCleanupCron || LOG_CLEANUP_DEFAULT_CRON);
@@ -135,22 +237,24 @@ export async function startScheduler() {
normalizeLogCleanupRetentionDays(config.logCleanupRetentionDays),
);
config.checkinCron = activeCheckinCron;
config.checkinScheduleMode = activeCheckinScheduleMode;
config.checkinIntervalHours = Math.min(24, Math.max(1, activeCheckinIntervalHours));
config.balanceRefreshCron = activeBalanceCron;
config.logCleanupCron = activeLogCleanupCron;
config.logCleanupUsageLogsEnabled = activeLogCleanupUsageLogsEnabled;
config.logCleanupProgramLogsEnabled = activeLogCleanupProgramLogsEnabled;
config.logCleanupRetentionDays = activeLogCleanupRetentionDays;
checkinTask?.stop();
stopCheckinSchedule();
balanceTask?.stop();
dailySummaryTask?.stop();
logCleanupTask?.stop();
checkinTask = createCheckinTask(activeCheckinCron);
startCheckinSchedule();
balanceTask = createBalanceTask(activeBalanceCron);
dailySummaryTask = createDailySummaryTask(activeDailySummaryCron);
logCleanupTask = createLogCleanupTask(activeLogCleanupCron);
console.log(`[Scheduler] Check-in cron: ${activeCheckinCron}`);
console.log(`[Scheduler] Check-in schedule: ${config.checkinScheduleMode} (${config.checkinScheduleMode === 'cron' ? activeCheckinCron : `${config.checkinIntervalHours}h`})`);
console.log(`[Scheduler] Balance refresh cron: ${activeBalanceCron}`);
console.log(`[Scheduler] Daily summary cron: ${activeDailySummaryCron}`);
console.log(
@@ -159,10 +263,35 @@ export async function startScheduler() {
}
export function updateCheckinCron(cronExpr: string) {
if (!cron.validate(cronExpr)) throw new Error(`Invalid cron: ${cronExpr}`);
config.checkinCron = cronExpr;
checkinTask?.stop();
checkinTask = createCheckinTask(cronExpr);
updateCheckinSchedule({
mode: 'cron',
cronExpr,
intervalHours: config.checkinIntervalHours,
});
}
export function updateCheckinSchedule(input: {
mode: CheckinScheduleMode;
cronExpr?: string;
intervalHours?: number;
}) {
const nextMode = input.mode;
if (nextMode !== 'cron' && nextMode !== 'interval') {
throw new Error(`Invalid checkin schedule mode: ${String(nextMode)}`);
}
const nextCronExpr = input.cronExpr ?? config.checkinCron;
if (!cron.validate(nextCronExpr)) throw new Error(`Invalid cron: ${nextCronExpr}`);
const nextIntervalHours = input.intervalHours ?? config.checkinIntervalHours;
if (!Number.isFinite(nextIntervalHours) || nextIntervalHours < 1 || nextIntervalHours > 24) {
throw new Error(`Invalid interval hours: ${String(nextIntervalHours)}`);
}
config.checkinScheduleMode = nextMode;
config.checkinCron = nextCronExpr;
config.checkinIntervalHours = Math.trunc(nextIntervalHours);
startCheckinSchedule();
}
export function updateBalanceRefreshCron(cronExpr: string) {
@@ -191,3 +320,14 @@ export function updateLogCleanupSettings(input: {
logCleanupTask?.stop();
logCleanupTask = createLogCleanupTask(cronExpr);
}
export function __resetCheckinSchedulerForTests() {
stopCheckinSchedule();
balanceTask?.stop();
dailySummaryTask?.stop();
logCleanupTask?.stop();
balanceTask = null;
dailySummaryTask = null;
logCleanupTask = null;
intervalAttemptByAccount.clear();
}
@@ -248,6 +248,63 @@ describe('checkinService auto relogin', () => {
expect(notifyMock).not.toHaveBeenCalled();
});
it('does not advance lastCheckinAt for already checked in responses in interval mode', async () => {
selectAllMock.mockReturnValue([
{
accounts: {
id: 16,
username: 'interval-user',
accessToken: 'token',
status: 'active',
extraConfig: null,
},
sites: {
id: 16,
name: 'demo',
url: 'https://example.com',
platform: 'new-api',
},
},
]);
adapterMock.checkin.mockResolvedValue({ success: false, message: '今天已经签到过啦' });
const { checkinAccount } = await import('./checkinService.js');
const result = await checkinAccount(16, { scheduleMode: 'interval' });
expect(result.success).toBe(true);
expect(result.status).toBe('success');
expect(updateSetMock).not.toHaveBeenCalledWith(expect.objectContaining({ lastCheckinAt: expect.any(String) }));
});
it('advances lastCheckinAt when interval mode gets a direct success', async () => {
selectAllMock.mockReturnValue([
{
accounts: {
id: 17,
username: 'interval-success',
accessToken: 'token',
status: 'active',
extraConfig: null,
},
sites: {
id: 17,
name: 'demo',
url: 'https://example.com',
platform: 'new-api',
},
},
]);
adapterMock.checkin.mockResolvedValue({ success: true, message: '签到成功' });
const { checkinAccount } = await import('./checkinService.js');
const result = await checkinAccount(17, { scheduleMode: 'interval' });
expect(result.success).toBe(true);
expect(updateSetMock).toHaveBeenCalledWith(expect.objectContaining({ lastCheckinAt: expect.any(String) }));
});
it('treats unsupported checkin endpoint responses as skipped', async () => {
selectAllMock.mockReturnValue([
{
+13 -6
View File
@@ -115,7 +115,7 @@ async function tryAutoRelogin(account: any, site: any): Promise<string | null> {
return result.accessToken;
}
export async function checkinAccount(accountId: number, options?: { skipEvent?: boolean }) {
export async function checkinAccount(accountId: number, options?: { skipEvent?: boolean; scheduleMode?: 'cron' | 'interval' }) {
const rows = await db
.select()
.from(schema.accounts)
@@ -192,6 +192,7 @@ export async function checkinAccount(accountId: number, options?: { skipEvent?:
const effectiveSuccess = result.success || alreadyCheckedIn || unsupportedCheckin || manualVerificationRequired;
const shouldRefreshBalance = result.success || alreadyCheckedIn;
const directCheckinSuccess = result.success && !alreadyCheckedIn && !unsupportedCheckin;
const shouldAdvanceLastCheckinAt = directCheckinSuccess || (alreadyCheckedIn && options?.scheduleMode !== 'interval');
const normalizedStatus: CheckinExecutionStatus = effectiveSuccess
? ((unsupportedCheckin || manualVerificationRequired) ? 'skipped' : 'success')
: 'failed';
@@ -211,9 +212,10 @@ export async function checkinAccount(accountId: number, options?: { skipEvent?:
source: 'checkin',
});
const updates: Record<string, unknown> = {
lastCheckinAt: new Date().toISOString(),
};
const updates: Record<string, unknown> = {};
if (shouldAdvanceLastCheckinAt) {
updates.lastCheckinAt = new Date().toISOString();
}
if (!storedPlatformUserId && guessedPlatformUserId) {
updates.extraConfig = mergeAccountExtraConfig(account.extraConfig, {
platformUserId: guessedPlatformUserId,
@@ -308,7 +310,7 @@ export async function checkinAccount(accountId: number, options?: { skipEvent?:
};
}
export async function checkinAll() {
export async function checkinAll(options?: { accountIds?: number[]; scheduleMode?: 'cron' | 'interval' }) {
const rows = await db
.select()
.from(schema.accounts)
@@ -321,10 +323,12 @@ export async function checkinAll() {
)
.all();
const scopedAccountIds = options?.accountIds ? new Set(options.accountIds) : null;
const results: Array<{ accountId: number; username: string | null; site: string; result: any }> = [];
const grouped = new Map<number, typeof rows>();
for (const row of rows) {
if (scopedAccountIds && !scopedAccountIds.has(row.accounts.id)) continue;
const siteId = row.sites.id;
if (!grouped.has(siteId)) grouped.set(siteId, []);
grouped.get(siteId)!.push(row);
@@ -332,7 +336,10 @@ export async function checkinAll() {
const promises = Array.from(grouped.entries()).map(async ([_, siteRows]) => {
for (const row of siteRows) {
const r = await checkinAccount(row.accounts.id, { skipEvent: true });
const r = await checkinAccount(row.accounts.id, {
skipEvent: true,
scheduleMode: options?.scheduleMode,
});
results.push({
accountId: row.accounts.id,
username: row.accounts.username,
+26 -2
View File
@@ -49,6 +49,7 @@ describe('notifyService', () => {
(config as any).telegramChatId = '';
(config as any).telegramUseSystemProxy = false;
config.systemProxyUrl = '';
(config as any).telegramMessageThreadId = '';
config.smtpEnabled = true;
config.smtpHost = 'smtp.example.com';
config.smtpPort = 465;
@@ -181,11 +182,12 @@ describe('notifyService', () => {
expect(payload?.text || '').toContain('UTC Time:');
});
it('sends telegram message when telegram channel is enabled', async () => {
it('sends telegram message without topic when telegram thread id is empty', async () => {
const { config } = await import('../config.js');
(config as any).telegramEnabled = true;
(config as any).telegramBotToken = '123456:telegram-token';
(config as any).telegramChatId = '-1001234567890';
(config as any).telegramMessageThreadId = '';
config.smtpEnabled = false;
fetchMock.mockResolvedValue({
@@ -206,13 +208,35 @@ describe('notifyService', () => {
);
const rawBody = fetchMock.mock.calls[0]?.[1] as { body?: string };
const payload = JSON.parse(rawBody?.body || '{}') as { chat_id?: string; text?: string };
const payload = JSON.parse(rawBody?.body || '{}') as { chat_id?: string; text?: string; message_thread_id?: number };
expect(payload.chat_id).toBe('-1001234567890');
expect(payload.message_thread_id).toBeUndefined();
expect(payload.text || '').toContain('Level: warning');
expect(payload.text || '').toContain('Local Time:');
expect(payload.text || '').toContain('UTC Time:');
});
it('sends telegram topic id when telegram thread id is configured', async () => {
const { config } = await import('../config.js');
(config as any).telegramEnabled = true;
(config as any).telegramBotToken = '123456:telegram-token';
(config as any).telegramChatId = '-1001234567890';
(config as any).telegramMessageThreadId = '77';
config.smtpEnabled = false;
fetchMock.mockResolvedValue({
ok: true,
status: 200,
});
const { sendNotification } = await import('./notifyService.js');
await sendNotification('测试通知', 'message', 'warning', { bypassThrottle: true, throwOnFailure: true });
const rawBody = fetchMock.mock.calls[0]?.[1] as { body?: string };
const payload = JSON.parse(rawBody?.body || '{}') as { message_thread_id?: number };
expect(payload.message_thread_id).toBe(77);
});
it('uses TELEGRAM_API_BASE_URL when configured', async () => {
const { config } = await import('../config.js');
(config as any).telegramEnabled = true;
+4
View File
@@ -222,6 +222,7 @@ export async function sendNotification(
const telegramApiBaseUrl = String(config.telegramApiBaseUrl || 'https://api.telegram.org').replace(/\/+$/, '');
const telegramApiUrl = `${telegramApiBaseUrl}/bot${config.telegramBotToken}/sendMessage`;
const text = buildTelegramText(title, resolvedMessage, level, timeFootnote);
const telegramMessageThreadId = Number.parseInt(String(config.telegramMessageThreadId || '').trim(), 10);
tasks.push({
channel: 'telegram',
run: async () => {
@@ -232,6 +233,9 @@ export async function sendNotification(
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: config.telegramChatId,
...(Number.isFinite(telegramMessageThreadId) && telegramMessageThreadId > 0
? { message_thread_id: telegramMessageThreadId }
: {}),
text,
disable_web_page_preview: true,
}),
+11 -1
View File
@@ -42,6 +42,7 @@ describe('NotificationSettings', () => {
telegramEnabled: true,
telegramApiBaseUrl: 'https://tg-proxy.example.com',
telegramChatId: '-1001234567890',
telegramMessageThreadId: '77',
telegramBotTokenMasked: '1234****token',
telegramUseSystemProxy: false,
smtpEnabled: false,
@@ -56,6 +57,7 @@ describe('NotificationSettings', () => {
apiMock.updateRuntimeSettings.mockResolvedValue({
success: true,
telegramApiBaseUrl: 'https://proxy.example.com/custom',
telegramMessageThreadId: '88',
telegramBotTokenMasked: '1234****token',
});
apiMock.testNotification.mockResolvedValue({ success: true });
@@ -65,7 +67,7 @@ describe('NotificationSettings', () => {
vi.clearAllMocks();
});
it('loads and saves telegram api base url', async () => {
it('loads and saves telegram api base url and topic id', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
@@ -85,8 +87,15 @@ describe('NotificationSettings', () => {
));
expect(proxyInput.props.value).toBe('https://tg-proxy.example.com');
const topicInput = root.root.find((node) => (
node.type === 'input'
&& node.props.placeholder === '例如: 77'
));
expect(topicInput.props.value).toBe('77');
await act(async () => {
proxyInput.props.onChange({ target: { value: 'https://proxy.example.com/custom/' } });
topicInput.props.onChange({ target: { value: '88' } });
});
const saveButton = root.root.find((node) => (
@@ -101,6 +110,7 @@ describe('NotificationSettings', () => {
expect(apiMock.updateRuntimeSettings).toHaveBeenCalledWith(expect.objectContaining({
telegramApiBaseUrl: 'https://proxy.example.com/custom/',
telegramMessageThreadId: '88',
}));
} finally {
root?.unmount();
+14
View File
@@ -13,6 +13,7 @@ type RuntimeSettings = {
telegramApiBaseUrl: string;
telegramChatId: string;
telegramUseSystemProxy: boolean;
telegramMessageThreadId: string;
smtpEnabled: boolean;
smtpHost: string;
smtpPort: number;
@@ -37,6 +38,7 @@ export default function NotificationSettings() {
telegramApiBaseUrl: 'https://api.telegram.org',
telegramChatId: '',
telegramUseSystemProxy: false,
telegramMessageThreadId: '',
smtpEnabled: false,
smtpHost: '',
smtpPort: 587,
@@ -81,6 +83,7 @@ export default function NotificationSettings() {
telegramApiBaseUrl: runtimeInfo.telegramApiBaseUrl || 'https://api.telegram.org',
telegramChatId: runtimeInfo.telegramChatId || '',
telegramUseSystemProxy: !!runtimeInfo.telegramUseSystemProxy,
telegramMessageThreadId: runtimeInfo.telegramMessageThreadId || '',
smtpEnabled: !!runtimeInfo.smtpEnabled,
smtpHost: runtimeInfo.smtpHost || '',
smtpPort: Number(runtimeInfo.smtpPort) || 587,
@@ -119,6 +122,7 @@ export default function NotificationSettings() {
telegramApiBaseUrl: runtime.telegramApiBaseUrl,
telegramChatId: runtime.telegramChatId,
telegramUseSystemProxy: runtime.telegramUseSystemProxy,
telegramMessageThreadId: runtime.telegramMessageThreadId,
smtpEnabled: runtime.smtpEnabled,
smtpHost: runtime.smtpHost,
smtpPort: runtime.smtpPort,
@@ -367,6 +371,16 @@ export default function NotificationSettings() {
disabled={!runtime.telegramEnabled}
/>
</div>
<div>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 8, color: 'var(--color-text-secondary)' }}>Telegram Topic ID</div>
<input
value={runtime.telegramMessageThreadId}
onChange={(e) => setRuntime((prev) => ({ ...prev, telegramMessageThreadId: e.target.value }))}
placeholder="例如: 77"
style={inputStyle}
disabled={!runtime.telegramEnabled}
/>
</div>
<div>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 8, color: 'var(--color-text-secondary)' }}>
Telegram Bot Token
+60
View File
@@ -26,6 +26,8 @@ type DbDialect = 'sqlite' | 'mysql' | 'postgres';
type RuntimeSettings = {
checkinCron: string;
checkinScheduleMode: 'cron' | 'interval';
checkinIntervalHours: number;
balanceRefreshCron: string;
logCleanupCron: string;
logCleanupUsageLogsEnabled: boolean;
@@ -187,6 +189,8 @@ export default function Settings() {
const navigate = useNavigate();
const [runtime, setRuntime] = useState<RuntimeSettings>({
checkinCron: '0 8 * * *',
checkinScheduleMode: 'cron',
checkinIntervalHours: 6,
balanceRefreshCron: '0 * * * *',
logCleanupCron: '0 6 * * *',
logCleanupUsageLogsEnabled: false,
@@ -203,6 +207,7 @@ export default function Settings() {
const [maskedToken, setMaskedToken] = useState('');
const [loading, setLoading] = useState(true);
const [savingSchedule, setSavingSchedule] = useState(false);
const [testingCheckin, setTestingCheckin] = useState(false);
const [savingToken, setSavingToken] = useState(false);
const [savingSystemProxy, setSavingSystemProxy] = useState(false);
const [savingProxyFailureRules, setSavingProxyFailureRules] = useState(false);
@@ -397,6 +402,10 @@ export default function Settings() {
setMaskedToken(authInfo.masked || '****');
setRuntime({
checkinCron: runtimeInfo.checkinCron || '0 8 * * *',
checkinScheduleMode: runtimeInfo.checkinScheduleMode === 'interval' ? 'interval' : 'cron',
checkinIntervalHours: Number(runtimeInfo.checkinIntervalHours) >= 1
? Math.min(24, Math.trunc(Number(runtimeInfo.checkinIntervalHours)))
: 6,
balanceRefreshCron: runtimeInfo.balanceRefreshCron || '0 * * * *',
logCleanupCron: runtimeInfo.logCleanupCron || '0 6 * * *',
logCleanupUsageLogsEnabled: !!runtimeInfo.logCleanupUsageLogsEnabled,
@@ -488,6 +497,8 @@ export default function Settings() {
try {
await api.updateRuntimeSettings({
checkinCron: runtime.checkinCron,
checkinScheduleMode: runtime.checkinScheduleMode,
checkinIntervalHours: runtime.checkinIntervalHours,
balanceRefreshCron: runtime.balanceRefreshCron,
logCleanupCron: runtime.logCleanupCron,
logCleanupUsageLogsEnabled: runtime.logCleanupUsageLogsEnabled,
@@ -502,6 +513,18 @@ export default function Settings() {
}
};
const triggerScheduleCheckin = async () => {
setTestingCheckin(true);
try {
await api.triggerCheckinAll();
toast.success('已开始全部签到,请稍后查看签到日志');
} catch (err: any) {
toast.error(err?.message || '触发签到失败');
} finally {
setTestingCheckin(false);
}
};
const saveProxyToken = async () => {
const suffix = proxyTokenSuffix.trim();
if (!suffix) {
@@ -953,6 +976,42 @@ export default function Settings() {
<div className="card animate-slide-up stagger-2" style={{ padding: 20 }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 12 }}>定时任务</div>
<div style={{ display: 'grid', gridTemplateColumns: '180px 180px auto', gap: 12, alignItems: 'end', marginBottom: 12 }}>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>签到方式</div>
<select
value={runtime.checkinScheduleMode}
onChange={(e) => setRuntime((prev) => ({
...prev,
checkinScheduleMode: e.target.value === 'interval' ? 'interval' : 'cron',
}))}
style={inputStyle}
>
<option value="cron">Cron</option>
<option value="interval">间隔签到</option>
</select>
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>签到间隔</div>
<select
value={String(runtime.checkinIntervalHours)}
onChange={(e) => setRuntime((prev) => ({
...prev,
checkinIntervalHours: Math.min(24, Math.max(1, Math.trunc(Number(e.target.value) || 1))),
}))}
style={inputStyle}
disabled={runtime.checkinScheduleMode !== 'interval'}
>
{Array.from({ length: 24 }, (_, index) => {
const hour = index + 1;
return <option key={hour} value={hour}>{hour} 小时</option>;
})}
</select>
</div>
<button onClick={triggerScheduleCheckin} disabled={testingCheckin} className="btn btn-secondary">
{testingCheckin ? '触发中...' : '测试一次签到'}
</button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>签到 Cron</div>
@@ -960,6 +1019,7 @@ export default function Settings() {
value={runtime.checkinCron}
onChange={(e) => setRuntime((prev) => ({ ...prev, checkinCron: e.target.value }))}
style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }}
disabled={runtime.checkinScheduleMode !== 'cron'}
/>
</div>
<div>
@@ -12,6 +12,7 @@ const { apiMock } = vi.hoisted(() => ({
getRoutesLite: vi.fn(),
getRuntimeDatabaseConfig: vi.fn(),
updateRuntimeSettings: vi.fn(),
triggerCheckinAll: vi.fn(),
},
}));
@@ -46,6 +47,8 @@ describe('Settings log cleanup schedule', () => {
apiMock.getAuthInfo.mockResolvedValue({ masked: 'sk-****' });
apiMock.getRuntimeSettings.mockResolvedValue({
checkinCron: '0 8 * * *',
checkinScheduleMode: 'interval',
checkinIntervalHours: 6,
balanceRefreshCron: '0 * * * *',
logCleanupCron: '15 4 * * *',
logCleanupUsageLogsEnabled: true,
@@ -70,7 +73,7 @@ describe('Settings log cleanup schedule', () => {
vi.clearAllMocks();
});
it('saves log cleanup fields together with other schedule settings', async () => {
it('saves schedule mode and interval fields together with other schedule settings', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
@@ -97,6 +100,8 @@ describe('Settings log cleanup schedule', () => {
expect(apiMock.updateRuntimeSettings).toHaveBeenCalledWith({
checkinCron: '0 8 * * *',
checkinScheduleMode: 'interval',
checkinIntervalHours: 6,
balanceRefreshCron: '0 * * * *',
logCleanupCron: '15 4 * * *',
logCleanupUsageLogsEnabled: true,
@@ -107,4 +112,37 @@ describe('Settings log cleanup schedule', () => {
root?.unmount();
}
});
it('triggers a one-off checkin from the schedule card', async () => {
apiMock.triggerCheckinAll.mockResolvedValue({ success: true });
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter>
<ToastProvider>
<Settings />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const triggerButton = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).trim() === '测试一次签到'
));
await act(async () => {
await triggerButton.props.onClick();
});
await flushMicrotasks();
expect(apiMock.triggerCheckinAll).toHaveBeenCalledTimes(1);
} finally {
root?.unmount();
}
});
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB