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
+44 -3
View File
@@ -6,6 +6,7 @@ import sharp from 'sharp';
export const DESKTOP_ICON_SIZE = 512; export const DESKTOP_ICON_SIZE = 512;
export const DESKTOP_ICON_PADDING = 40; export const DESKTOP_ICON_PADDING = 40;
export const DESKTOP_ICON_RADIUS = 96; export const DESKTOP_ICON_RADIUS = 96;
export const DESKTOP_TRAY_TEMPLATE_PADDING = 152;
function createRoundedMask(size, cornerRadius) { function createRoundedMask(size, cornerRadius) {
return Buffer.from( return Buffer.from(
@@ -44,34 +45,73 @@ async function renderDesktopIconBuffer({
.toBuffer(); .toBuffer();
} }
async function renderTrayTemplateIconBuffer({
sourcePath,
size = DESKTOP_ICON_SIZE,
padding = DESKTOP_TRAY_TEMPLATE_PADDING,
}) {
const innerSize = Math.max(1, size - padding * 2);
const alphaMask = await sharp(sourcePath)
.resize(innerSize, innerSize, {
fit: 'contain',
background: { r: 0, g: 0, b: 0, alpha: 0 },
})
.ensureAlpha()
.extractChannel('alpha')
.png()
.toBuffer();
return sharp({
create: {
width: size,
height: size,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 1 },
},
})
.composite([{ input: alphaMask, left: padding, top: padding, blend: 'dest-in' }])
.png()
.toBuffer();
}
export async function generateDesktopIconAssets({ export async function generateDesktopIconAssets({
sourcePath = join(process.cwd(), 'src', 'web', 'public', 'logo.png'), sourcePath = join(process.cwd(), 'src', 'web', 'public', 'logo.png'),
buildOutputPath = join(process.cwd(), 'build', 'desktop-icon.png'), buildOutputPath = join(process.cwd(), 'build', 'desktop-icon.png'),
webOutputPath = join(process.cwd(), 'src', 'web', 'public', 'desktop-icon.png'), webOutputPath = join(process.cwd(), 'src', 'web', 'public', 'desktop-icon.png'),
trayTemplateOutputPath = join(process.cwd(), 'src', 'web', 'public', 'desktop-tray-template.png'),
size = DESKTOP_ICON_SIZE, size = DESKTOP_ICON_SIZE,
padding = DESKTOP_ICON_PADDING, padding = DESKTOP_ICON_PADDING,
cornerRadius = DESKTOP_ICON_RADIUS, cornerRadius = DESKTOP_ICON_RADIUS,
} = {}) { } = {}) {
const outputBuffer = await renderDesktopIconBuffer({ const [outputBuffer, trayTemplateBuffer] = await Promise.all([
renderDesktopIconBuffer({
sourcePath, sourcePath,
size, size,
padding, padding,
cornerRadius, cornerRadius,
}); }),
renderTrayTemplateIconBuffer({
sourcePath,
size,
}),
]);
await Promise.all([ await Promise.all([
mkdir(dirname(buildOutputPath), { recursive: true }), mkdir(dirname(buildOutputPath), { recursive: true }),
mkdir(dirname(webOutputPath), { recursive: true }), mkdir(dirname(webOutputPath), { recursive: true }),
mkdir(dirname(trayTemplateOutputPath), { recursive: true }),
]); ]);
await Promise.all([ await Promise.all([
sharp(outputBuffer).toFile(buildOutputPath), sharp(outputBuffer).toFile(buildOutputPath),
sharp(outputBuffer).toFile(webOutputPath), sharp(outputBuffer).toFile(webOutputPath),
sharp(trayTemplateBuffer).toFile(trayTemplateOutputPath),
]); ]);
return { return {
buildOutputPath, buildOutputPath,
webOutputPath, webOutputPath,
trayTemplateOutputPath,
}; };
} }
@@ -81,5 +121,6 @@ if (isDirectRun) {
const outputs = await generateDesktopIconAssets(); const outputs = await generateDesktopIconAssets();
console.log(`[metapi-desktop] Generated desktop icons: console.log(`[metapi-desktop] Generated desktop icons:
- ${outputs.buildOutputPath} - ${outputs.buildOutputPath}
- ${outputs.webOutputPath}`); - ${outputs.webOutputPath}
- ${outputs.trayTemplateOutputPath}`);
} }
+19 -1
View File
@@ -21,13 +21,14 @@ afterEach(async () => {
}); });
describe('generateDesktopIconAssets', () => { describe('generateDesktopIconAssets', () => {
it('writes rounded desktop icon outputs for build and runtime usage', async () => { it('writes rounded desktop icon outputs and a tray template asset', async () => {
const { generateDesktopIconAssets } = await import('./generate-icons.mjs'); const { generateDesktopIconAssets } = await import('./generate-icons.mjs');
const dir = await makeTempDir(); const dir = await makeTempDir();
const sourcePath = join(dir, 'logo.png'); const sourcePath = join(dir, 'logo.png');
const buildOutputPath = join(dir, 'build.png'); const buildOutputPath = join(dir, 'build.png');
const webOutputPath = join(dir, 'desktop-icon.png'); const webOutputPath = join(dir, 'desktop-icon.png');
const trayTemplateOutputPath = join(dir, 'desktop-tray-template.png');
await sharp({ await sharp({
create: { create: {
@@ -42,15 +43,19 @@ describe('generateDesktopIconAssets', () => {
sourcePath, sourcePath,
buildOutputPath, buildOutputPath,
webOutputPath, webOutputPath,
trayTemplateOutputPath,
}); });
const buildMeta = await sharp(buildOutputPath).metadata(); const buildMeta = await sharp(buildOutputPath).metadata();
const webMeta = await sharp(webOutputPath).metadata(); const webMeta = await sharp(webOutputPath).metadata();
const trayMeta = await sharp(trayTemplateOutputPath).metadata();
expect(buildMeta.width).toBe(512); expect(buildMeta.width).toBe(512);
expect(buildMeta.height).toBe(512); expect(buildMeta.height).toBe(512);
expect(webMeta.width).toBe(512); expect(webMeta.width).toBe(512);
expect(webMeta.height).toBe(512); expect(webMeta.height).toBe(512);
expect(trayMeta.width).toBe(512);
expect(trayMeta.height).toBe(512);
const { data, info } = await sharp(buildOutputPath) const { data, info } = await sharp(buildOutputPath)
.ensureAlpha() .ensureAlpha()
@@ -64,5 +69,18 @@ describe('generateDesktopIconAssets', () => {
expect(await sharp(buildOutputPath).png().toBuffer()) expect(await sharp(buildOutputPath).png().toBuffer())
.toEqual(await sharp(webOutputPath).png().toBuffer()); .toEqual(await sharp(webOutputPath).png().toBuffer());
const trayBuffer = await sharp(trayTemplateOutputPath)
.ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
expect(alphaAt(trayBuffer.data, trayBuffer.info.width, 0, 0)).toBe(0);
expect(alphaAt(trayBuffer.data, trayBuffer.info.width, Math.floor(trayBuffer.info.width / 2), Math.floor(trayBuffer.info.height / 2))).toBe(255);
const trayCenterOffset = (Math.floor(trayBuffer.info.height / 2) * trayBuffer.info.width + Math.floor(trayBuffer.info.width / 2)) * 4;
expect(trayBuffer.data[trayCenterOffset]).toBe(0);
expect(trayBuffer.data[trayCenterOffset + 1]).toBe(0);
expect(trayBuffer.data[trayCenterOffset + 2]).toBe(0);
}); });
}); });
+5
View File
@@ -7,12 +7,17 @@ describe('desktop icon assets', () => {
const { const {
DESKTOP_BUILD_ICON_RELATIVE_PATH, DESKTOP_BUILD_ICON_RELATIVE_PATH,
DESKTOP_RUNTIME_ICON_RELATIVE_PATH, DESKTOP_RUNTIME_ICON_RELATIVE_PATH,
DESKTOP_TRAY_TEMPLATE_ICON_RELATIVE_PATH,
getDesktopRuntimeIconPath, getDesktopRuntimeIconPath,
getDesktopTrayIconPath,
} = await import('./iconAssets.js'); } = await import('./iconAssets.js');
expect(DESKTOP_RUNTIME_ICON_RELATIVE_PATH).toBe(join('dist', 'web', 'desktop-icon.png')); 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_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(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 () => { 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_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_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 const DESKTOP_ICON_PUBLIC_PATH = '/desktop-icon.png';
export function getDesktopRuntimeIconPath(appPath: string) { export function getDesktopRuntimeIconPath(appPath: string) {
return join(appPath, DESKTOP_RUNTIME_ICON_RELATIVE_PATH); 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, resolveDesktopServerWorkingDir,
waitForServerReady, waitForServerReady,
} from './runtime.js'; } from './runtime.js';
import { getDesktopRuntimeIconPath } from './iconAssets.js'; import { getDesktopRuntimeIconPath, getDesktopTrayIconPath } from './iconAssets.js';
const { autoUpdater } = electronUpdater; const { autoUpdater } = electronUpdater;
@@ -52,7 +52,7 @@ function getServerEntryPath() {
} }
function getTrayIconPath() { function getTrayIconPath() {
return getDesktopRuntimeIconPath(app.getAppPath()); return getDesktopTrayIconPath(app.getAppPath(), process.platform);
} }
function getWindowIconPath() { function getWindowIconPath() {
@@ -121,7 +121,15 @@ function buildTrayMenu() {
function setupTray() { function setupTray() {
if (tray) return; 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 = new Tray(trayImage);
tray.setToolTip('Metapi'); tray.setToolTip('Metapi');
tray.setContextMenu(buildTrayMenu()); tray.setContextMenu(buildTrayMenu());
+9
View File
@@ -36,6 +36,15 @@ describe('buildConfig', () => {
const config = buildConfig({}); const config = buildConfig({});
expect(config.telegramApiBaseUrl).toBe('https://api.telegram.org'); 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', () => { it('ships CLI-aligned OAuth defaults', () => {
+5
View File
@@ -67,6 +67,10 @@ export function buildConfig(env: NodeJS.ProcessEnv) {
systemProxyUrl: env.SYSTEM_PROXY_URL || '', systemProxyUrl: env.SYSTEM_PROXY_URL || '',
accountCredentialSecret: env.ACCOUNT_CREDENTIAL_SECRET || env.AUTH_TOKEN || 'change-me-admin-token', accountCredentialSecret: env.ACCOUNT_CREDENTIAL_SECRET || env.AUTH_TOKEN || 'change-me-admin-token',
checkinCron: env.CHECKIN_CRON || '0 8 * * *', 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 * * * *', balanceRefreshCron: env.BALANCE_REFRESH_CRON || '0 * * * *',
logCleanupCron: env.LOG_CLEANUP_CRON || '0 6 * * *', logCleanupCron: env.LOG_CLEANUP_CRON || '0 6 * * *',
logCleanupConfigured: false, logCleanupConfigured: false,
@@ -84,6 +88,7 @@ export function buildConfig(env: NodeJS.ProcessEnv) {
telegramBotToken: env.TELEGRAM_BOT_TOKEN || '', telegramBotToken: env.TELEGRAM_BOT_TOKEN || '',
telegramChatId: env.TELEGRAM_CHAT_ID || '', telegramChatId: env.TELEGRAM_CHAT_ID || '',
telegramUseSystemProxy: parseBoolean(env.TELEGRAM_USE_SYSTEM_PROXY, false), telegramUseSystemProxy: parseBoolean(env.TELEGRAM_USE_SYSTEM_PROXY, false),
telegramMessageThreadId: (env.TELEGRAM_MESSAGE_THREAD_ID || '').trim(),
smtpEnabled: parseBoolean(env.SMTP_ENABLED, false), smtpEnabled: parseBoolean(env.SMTP_ENABLED, false),
smtpHost: env.SMTP_HOST || '', smtpHost: env.SMTP_HOST || '',
smtpPort: parseInt(env.SMTP_PORT || '587'), 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'); const checkinCron = parseSettingFromMap<string>(settingsMap, 'checkin_cron');
if (typeof checkinCron === 'string' && checkinCron) config.checkinCron = checkinCron; 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'); const balanceRefreshCron = parseSettingFromMap<string>(settingsMap, 'balance_refresh_cron');
if (typeof balanceRefreshCron === 'string' && balanceRefreshCron) config.balanceRefreshCron = balanceRefreshCron; 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'); const telegramChatId = parseSettingFromMap<string>(settingsMap, 'telegram_chat_id');
if (typeof telegramChatId === 'string') config.telegramChatId = telegramChatId; 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'); const smtpEnabled = parseSettingFromMap<boolean>(settingsMap, 'smtp_enabled');
if (typeof smtpEnabled === 'boolean') config.smtpEnabled = smtpEnabled; 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', () => ({ vi.mock('../../services/checkinScheduler.js', () => ({
updateCheckinCron: vi.fn(), updateCheckinSchedule: vi.fn(),
})); }));
vi.mock('../../db/index.js', () => { 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 new Promise((resolve) => setTimeout(resolve, 20));
await app.close(); 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 { FastifyInstance } from 'fastify';
import { config } from '../../config.js';
import { db, schema } from '../../db/index.js'; import { db, schema } from '../../db/index.js';
import { upsertSetting } from '../../db/upsertSetting.js'; import { upsertSetting } from '../../db/upsertSetting.js';
import { eq, desc } from 'drizzle-orm'; import { eq, desc } from 'drizzle-orm';
import { checkinAccount, checkinAll } from '../../services/checkinService.js'; 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 { startBackgroundTask, summarizeCheckinResults } from '../../services/backgroundTaskService.js';
import { classifyFailureReason } from '../../services/failureReasonService.js'; import { classifyFailureReason } from '../../services/failureReasonService.js';
@@ -84,7 +85,7 @@ export async function checkinRoutes(app: FastifyInstance) {
failureMessage: (currentTask) => `全部账号签到任务失败:${currentTask.error || 'unknown error'}`, failureMessage: (currentTask) => `全部账号签到任务失败:${currentTask.error || 'unknown error'}`,
}, },
async () => { async () => {
const results = await checkinAll(); const results = await checkinAll({ scheduleMode: config.checkinScheduleMode });
return { return {
summary: summarizeCheckinResults(results), summary: summarizeCheckinResults(results),
total: results.length, total: results.length,
@@ -108,7 +109,7 @@ export async function checkinRoutes(app: FastifyInstance) {
// Trigger check-in for a specific account // Trigger check-in for a specific account
app.post<{ Params: { id: string } }>('/api/checkin/trigger/:id', async (request) => { app.post<{ Params: { id: string } }>('/api/checkin/trigger/:id', async (request) => {
const id = parseInt(request.params.id, 10); const id = parseInt(request.params.id, 10);
const result = await checkinAccount(id); const result = await checkinAccount(id, { scheduleMode: config.checkinScheduleMode });
return result; return result;
}); });
@@ -142,11 +143,33 @@ export async function checkinRoutes(app: FastifyInstance) {
}); });
// Update check-in schedule // 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 { try {
updateCheckinCron(request.body.cron); const body = request.body || {};
await upsertSetting('checkin_cron', request.body.cron); const nextMode: 'cron' | 'interval' = body.mode === 'interval' ? 'interval' : 'cron';
return { success: true, cron: request.body.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) { } catch (err: any) {
return { error: err.message }; return { error: err.message };
} }
@@ -44,6 +44,8 @@ describe('settings and auth events', () => {
config.proxyToken = 'sk-old-proxy-token-123'; config.proxyToken = 'sk-old-proxy-token-123';
config.systemProxyUrl = ''; config.systemProxyUrl = '';
config.checkinCron = '0 8 * * *'; config.checkinCron = '0 8 * * *';
(config as any).checkinScheduleMode = 'cron';
(config as any).checkinIntervalHours = 6;
config.balanceRefreshCron = '0 * * * *'; config.balanceRefreshCron = '0 * * * *';
config.logCleanupConfigured = false; config.logCleanupConfigured = false;
config.logCleanupCron = '0 6 * * *'; config.logCleanupCron = '0 6 * * *';
@@ -56,6 +58,7 @@ describe('settings and auth events', () => {
(config as any).telegramBotToken = ''; (config as any).telegramBotToken = '';
(config as any).telegramChatId = ''; (config as any).telegramChatId = '';
(config as any).telegramUseSystemProxy = false; (config as any).telegramUseSystemProxy = false;
(config as any).telegramMessageThreadId = '';
}); });
afterAll(async () => { afterAll(async () => {
@@ -86,6 +89,28 @@ describe('settings and auth events', () => {
expect(events[0].message || '').toContain('签到 Cron'); 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 () => { it('returns current recognized admin IP in runtime settings response', async () => {
const response = await app.inject({ const response = await app.inject({
method: 'GET', method: 'GET',
@@ -203,6 +228,32 @@ describe('settings and auth events', () => {
expect(runtime.telegramApiBaseUrl).toBe('https://tg-proxy.example.com/custom'); 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 () => { it('rejects invalid telegram api base url when telegram is enabled', async () => {
const response = await app.inject({ const response = await app.inject({
method: 'PUT', method: 'PUT',
+114 -5
View File
@@ -4,7 +4,7 @@ import { config } from '../../config.js';
import { db, runtimeDbDialect, schema } from '../../db/index.js'; import { db, runtimeDbDialect, schema } from '../../db/index.js';
import { upsertSetting } from '../../db/upsertSetting.js'; import { upsertSetting } from '../../db/upsertSetting.js';
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.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 { sendNotification } from '../../services/notifyService.js';
import { exportBackup, importBackup, type BackupExportType } from '../../services/backupService.js'; import { exportBackup, importBackup, type BackupExportType } from '../../services/backupService.js';
import { startBackgroundTask } from '../../services/backgroundTaskService.js'; import { startBackgroundTask } from '../../services/backgroundTaskService.js';
@@ -28,6 +28,8 @@ interface RuntimeSettingsBody {
proxyToken?: string; proxyToken?: string;
systemProxyUrl?: string; systemProxyUrl?: string;
checkinCron?: string; checkinCron?: string;
checkinScheduleMode?: 'cron' | 'interval';
checkinIntervalHours?: number;
balanceRefreshCron?: string; balanceRefreshCron?: string;
logCleanupCron?: string; logCleanupCron?: string;
logCleanupUsageLogsEnabled?: boolean; logCleanupUsageLogsEnabled?: boolean;
@@ -44,6 +46,7 @@ interface RuntimeSettingsBody {
telegramBotToken?: string; telegramBotToken?: string;
telegramChatId?: string; telegramChatId?: string;
telegramUseSystemProxy?: boolean; telegramUseSystemProxy?: boolean;
telegramMessageThreadId?: string;
smtpEnabled?: boolean; smtpEnabled?: boolean;
smtpHost?: string; smtpHost?: string;
smtpPort?: number; smtpPort?: number;
@@ -172,12 +175,46 @@ function normalizeTelegramApiBaseUrl(raw: string): string {
return String(raw || '').trim().replace(/\/+$/, ''); 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) { function applyImportedSettingToRuntime(key: string, value: unknown) {
switch (key) { switch (key) {
case 'checkin_cron': { case 'checkin_cron': {
if (typeof value !== 'string' || !value || !cron.validate(value)) return; if (typeof value !== 'string' || !value || !cron.validate(value)) return;
config.checkinCron = value; 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; return;
} }
case 'balance_refresh_cron': { case 'balance_refresh_cron': {
@@ -293,6 +330,11 @@ function applyImportedSettingToRuntime(key: string, value: unknown) {
config.telegramUseSystemProxy = !!value; config.telegramUseSystemProxy = !!value;
return; return;
} }
case 'telegram_message_thread_id': {
if (typeof value !== 'string') return;
config.telegramMessageThreadId = value.trim();
return;
}
case 'smtp_enabled': { case 'smtp_enabled': {
config.smtpEnabled = !!value; config.smtpEnabled = !!value;
return; return;
@@ -368,6 +410,8 @@ function applyImportedSettingToRuntime(key: string, value: unknown) {
function getRuntimeSettingsResponse(currentAdminIp = '') { function getRuntimeSettingsResponse(currentAdminIp = '') {
return { return {
checkinCron: config.checkinCron, checkinCron: config.checkinCron,
checkinScheduleMode: config.checkinScheduleMode,
checkinIntervalHours: config.checkinIntervalHours,
balanceRefreshCron: config.balanceRefreshCron, balanceRefreshCron: config.balanceRefreshCron,
logCleanupCron: config.logCleanupCron, logCleanupCron: config.logCleanupCron,
logCleanupUsageLogsEnabled: config.logCleanupUsageLogsEnabled, logCleanupUsageLogsEnabled: config.logCleanupUsageLogsEnabled,
@@ -386,6 +430,7 @@ function getRuntimeSettingsResponse(currentAdminIp = '') {
telegramBotTokenMasked: maskSecret(config.telegramBotToken), telegramBotTokenMasked: maskSecret(config.telegramBotToken),
telegramChatId: config.telegramChatId, telegramChatId: config.telegramChatId,
telegramUseSystemProxy: config.telegramUseSystemProxy, telegramUseSystemProxy: config.telegramUseSystemProxy,
telegramMessageThreadId: config.telegramMessageThreadId,
smtpEnabled: config.smtpEnabled, smtpEnabled: config.smtpEnabled,
smtpHost: config.smtpHost, smtpHost: config.smtpHost,
smtpPort: config.smtpPort, smtpPort: config.smtpPort,
@@ -519,7 +564,8 @@ export async function settingsRoutes(app: FastifyInstance) {
|| body.telegramApiBaseUrl !== undefined || body.telegramApiBaseUrl !== undefined
|| body.telegramBotToken !== undefined || body.telegramBotToken !== undefined
|| body.telegramChatId !== undefined || body.telegramChatId !== undefined
|| body.telegramUseSystemProxy !== undefined; || body.telegramUseSystemProxy !== undefined
|| body.telegramMessageThreadId !== undefined;
const nextTelegramEnabled = body.telegramEnabled !== undefined const nextTelegramEnabled = body.telegramEnabled !== undefined
? !!body.telegramEnabled ? !!body.telegramEnabled
: config.telegramEnabled; : config.telegramEnabled;
@@ -532,6 +578,9 @@ export async function settingsRoutes(app: FastifyInstance) {
const nextTelegramChatId = body.telegramChatId !== undefined const nextTelegramChatId = body.telegramChatId !== undefined
? String(body.telegramChatId || '').trim() ? String(body.telegramChatId || '').trim()
: config.telegramChatId; : config.telegramChatId;
const nextTelegramMessageThreadId = body.telegramMessageThreadId !== undefined
? normalizeTelegramMessageThreadId(body.telegramMessageThreadId)
: config.telegramMessageThreadId;
if (telegramTouched && nextTelegramEnabled) { if (telegramTouched && nextTelegramEnabled) {
if (!nextTelegramBotToken) { if (!nextTelegramBotToken) {
return reply.code(400).send({ success: false, message: 'Telegram Bot Token 不能为空(启用 Telegram 时)' }); return reply.code(400).send({ success: false, message: 'Telegram Bot Token 不能为空(启用 Telegram 时)' });
@@ -542,13 +591,22 @@ export async function settingsRoutes(app: FastifyInstance) {
if (!nextTelegramChatId) { if (!nextTelegramChatId) {
return reply.code(400).send({ success: false, message: 'Telegram Chat ID 不能为空(启用 Telegram 时)' }); 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)) { if (nextTelegramApiBaseUrl && !isValidHttpUrl(nextTelegramApiBaseUrl)) {
return reply.code(400).send({ success: false, message: 'Telegram API Base URL 无效,请填写 http/https 地址' }); return reply.code(400).send({ success: false, message: 'Telegram API Base URL 无效,请填写 http/https 地址' });
} }
} else if (body.telegramApiBaseUrl !== undefined && nextTelegramApiBaseUrl && !isValidHttpUrl(nextTelegramApiBaseUrl)) { } else if (body.telegramApiBaseUrl !== undefined && nextTelegramApiBaseUrl && !isValidHttpUrl(nextTelegramApiBaseUrl)) {
return reply.code(400).send({ success: false, message: 'Telegram API Base URL 无效,请填写 http/https 地址' }); 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 (body.checkinCron !== undefined) {
if (!cron.validate(body.checkinCron)) { if (!cron.validate(body.checkinCron)) {
return reply.code(400).send({ success: false, message: '签到 Cron 表达式无效' }); return reply.code(400).send({ success: false, message: '签到 Cron 表达式无效' });
@@ -556,8 +614,50 @@ export async function settingsRoutes(app: FastifyInstance) {
if (body.checkinCron !== config.checkinCron) { if (body.checkinCron !== config.checkinCron) {
changedLabels.push(`签到 Cron(${config.checkinCron} -> ${body.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) { if (body.balanceRefreshCron !== undefined) {
@@ -783,6 +883,15 @@ export async function settingsRoutes(app: FastifyInstance) {
upsertSetting('telegram_use_system_proxy', config.telegramUseSystemProxy); 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 !== undefined) {
if (!!body.smtpEnabled !== config.smtpEnabled) { if (!!body.smtpEnabled !== config.smtpEnabled) {
changedLabels.push('SMTP 开关'); 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 { buildDailySummaryNotification, collectDailySummaryMetrics } from './dailySummaryService.js';
import { cleanupConfiguredLogs, normalizeLogCleanupRetentionDays } from './logCleanupService.js'; import { cleanupConfiguredLogs, normalizeLogCleanupRetentionDays } from './logCleanupService.js';
export type CheckinScheduleMode = 'cron' | 'interval';
let checkinTask: cron.ScheduledTask | null = null; let checkinTask: cron.ScheduledTask | null = null;
let checkinIntervalTimer: ReturnType<typeof setInterval> | null = null;
let balanceTask: cron.ScheduledTask | null = null; let balanceTask: cron.ScheduledTask | null = null;
let dailySummaryTask: cron.ScheduledTask | null = null; let dailySummaryTask: cron.ScheduledTask | null = null;
let logCleanupTask: cron.ScheduledTask | null = null; let logCleanupTask: cron.ScheduledTask | null = null;
const intervalAttemptByAccount = new Map<number, number>();
const DAILY_SUMMARY_DEFAULT_CRON = '58 23 * * *'; const DAILY_SUMMARY_DEFAULT_CRON = '58 23 * * *';
const LOG_CLEANUP_DEFAULT_CRON = '0 6 * * *'; const LOG_CLEANUP_DEFAULT_CRON = '0 6 * * *';
const CHECKIN_INTERVAL_POLL_MS = 60_000;
async function resolveJsonSetting<T>( async function resolveJsonSetting<T>(
settingKey: string, settingKey: string,
@@ -54,7 +59,7 @@ function createCheckinTask(cronExpr: string) {
return cron.schedule(cronExpr, async () => { return cron.schedule(cronExpr, async () => {
console.log(`[Scheduler] Running check-in at ${new Date().toISOString()}`); console.log(`[Scheduler] Running check-in at ${new Date().toISOString()}`);
try { try {
const results = await checkinAll(); const results = await checkinAll({ scheduleMode: 'cron' });
const success = results.filter((r) => r.result.success).length; const success = results.filter((r) => r.result.success).length;
const failed = results.length - success; const failed = results.length - success;
console.log(`[Scheduler] Check-in complete: ${success} success, ${failed} failed`); 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) { function createBalanceTask(cronExpr: string) {
return cron.schedule(cronExpr, async () => { return cron.schedule(cronExpr, async () => {
console.log(`[Scheduler] Refreshing balances at ${new Date().toISOString()}`); console.log(`[Scheduler] Refreshing balances at ${new Date().toISOString()}`);
@@ -119,6 +212,15 @@ function createLogCleanupTask(cronExpr: string) {
export async function startScheduler() { export async function startScheduler() {
const activeCheckinCron = await resolveCronSetting('checkin_cron', config.checkinCron); 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 activeBalanceCron = await resolveCronSetting('balance_refresh_cron', config.balanceRefreshCron);
const activeDailySummaryCron = await resolveCronSetting('daily_summary_cron', DAILY_SUMMARY_DEFAULT_CRON); const activeDailySummaryCron = await resolveCronSetting('daily_summary_cron', DAILY_SUMMARY_DEFAULT_CRON);
const activeLogCleanupCron = await resolveCronSetting('log_cleanup_cron', config.logCleanupCron || LOG_CLEANUP_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), normalizeLogCleanupRetentionDays(config.logCleanupRetentionDays),
); );
config.checkinCron = activeCheckinCron; config.checkinCron = activeCheckinCron;
config.checkinScheduleMode = activeCheckinScheduleMode;
config.checkinIntervalHours = Math.min(24, Math.max(1, activeCheckinIntervalHours));
config.balanceRefreshCron = activeBalanceCron; config.balanceRefreshCron = activeBalanceCron;
config.logCleanupCron = activeLogCleanupCron; config.logCleanupCron = activeLogCleanupCron;
config.logCleanupUsageLogsEnabled = activeLogCleanupUsageLogsEnabled; config.logCleanupUsageLogsEnabled = activeLogCleanupUsageLogsEnabled;
config.logCleanupProgramLogsEnabled = activeLogCleanupProgramLogsEnabled; config.logCleanupProgramLogsEnabled = activeLogCleanupProgramLogsEnabled;
config.logCleanupRetentionDays = activeLogCleanupRetentionDays; config.logCleanupRetentionDays = activeLogCleanupRetentionDays;
checkinTask?.stop(); stopCheckinSchedule();
balanceTask?.stop(); balanceTask?.stop();
dailySummaryTask?.stop(); dailySummaryTask?.stop();
logCleanupTask?.stop(); logCleanupTask?.stop();
checkinTask = createCheckinTask(activeCheckinCron); startCheckinSchedule();
balanceTask = createBalanceTask(activeBalanceCron); balanceTask = createBalanceTask(activeBalanceCron);
dailySummaryTask = createDailySummaryTask(activeDailySummaryCron); dailySummaryTask = createDailySummaryTask(activeDailySummaryCron);
logCleanupTask = createLogCleanupTask(activeLogCleanupCron); 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] Balance refresh cron: ${activeBalanceCron}`);
console.log(`[Scheduler] Daily summary cron: ${activeDailySummaryCron}`); console.log(`[Scheduler] Daily summary cron: ${activeDailySummaryCron}`);
console.log( console.log(
@@ -159,10 +263,35 @@ export async function startScheduler() {
} }
export function updateCheckinCron(cronExpr: string) { export function updateCheckinCron(cronExpr: string) {
if (!cron.validate(cronExpr)) throw new Error(`Invalid cron: ${cronExpr}`); updateCheckinSchedule({
config.checkinCron = cronExpr; mode: 'cron',
checkinTask?.stop(); cronExpr,
checkinTask = createCheckinTask(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) { export function updateBalanceRefreshCron(cronExpr: string) {
@@ -191,3 +320,14 @@ export function updateLogCleanupSettings(input: {
logCleanupTask?.stop(); logCleanupTask?.stop();
logCleanupTask = createLogCleanupTask(cronExpr); 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(); 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 () => { it('treats unsupported checkin endpoint responses as skipped', async () => {
selectAllMock.mockReturnValue([ selectAllMock.mockReturnValue([
{ {
+13 -6
View File
@@ -115,7 +115,7 @@ async function tryAutoRelogin(account: any, site: any): Promise<string | null> {
return result.accessToken; 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 const rows = await db
.select() .select()
.from(schema.accounts) .from(schema.accounts)
@@ -192,6 +192,7 @@ export async function checkinAccount(accountId: number, options?: { skipEvent?:
const effectiveSuccess = result.success || alreadyCheckedIn || unsupportedCheckin || manualVerificationRequired; const effectiveSuccess = result.success || alreadyCheckedIn || unsupportedCheckin || manualVerificationRequired;
const shouldRefreshBalance = result.success || alreadyCheckedIn; const shouldRefreshBalance = result.success || alreadyCheckedIn;
const directCheckinSuccess = result.success && !alreadyCheckedIn && !unsupportedCheckin; const directCheckinSuccess = result.success && !alreadyCheckedIn && !unsupportedCheckin;
const shouldAdvanceLastCheckinAt = directCheckinSuccess || (alreadyCheckedIn && options?.scheduleMode !== 'interval');
const normalizedStatus: CheckinExecutionStatus = effectiveSuccess const normalizedStatus: CheckinExecutionStatus = effectiveSuccess
? ((unsupportedCheckin || manualVerificationRequired) ? 'skipped' : 'success') ? ((unsupportedCheckin || manualVerificationRequired) ? 'skipped' : 'success')
: 'failed'; : 'failed';
@@ -211,9 +212,10 @@ export async function checkinAccount(accountId: number, options?: { skipEvent?:
source: 'checkin', source: 'checkin',
}); });
const updates: Record<string, unknown> = { const updates: Record<string, unknown> = {};
lastCheckinAt: new Date().toISOString(), if (shouldAdvanceLastCheckinAt) {
}; updates.lastCheckinAt = new Date().toISOString();
}
if (!storedPlatformUserId && guessedPlatformUserId) { if (!storedPlatformUserId && guessedPlatformUserId) {
updates.extraConfig = mergeAccountExtraConfig(account.extraConfig, { updates.extraConfig = mergeAccountExtraConfig(account.extraConfig, {
platformUserId: guessedPlatformUserId, 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 const rows = await db
.select() .select()
.from(schema.accounts) .from(schema.accounts)
@@ -321,10 +323,12 @@ export async function checkinAll() {
) )
.all(); .all();
const scopedAccountIds = options?.accountIds ? new Set(options.accountIds) : null;
const results: Array<{ accountId: number; username: string | null; site: string; result: any }> = []; const results: Array<{ accountId: number; username: string | null; site: string; result: any }> = [];
const grouped = new Map<number, typeof rows>(); const grouped = new Map<number, typeof rows>();
for (const row of rows) { for (const row of rows) {
if (scopedAccountIds && !scopedAccountIds.has(row.accounts.id)) continue;
const siteId = row.sites.id; const siteId = row.sites.id;
if (!grouped.has(siteId)) grouped.set(siteId, []); if (!grouped.has(siteId)) grouped.set(siteId, []);
grouped.get(siteId)!.push(row); grouped.get(siteId)!.push(row);
@@ -332,7 +336,10 @@ export async function checkinAll() {
const promises = Array.from(grouped.entries()).map(async ([_, siteRows]) => { const promises = Array.from(grouped.entries()).map(async ([_, siteRows]) => {
for (const row of 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({ results.push({
accountId: row.accounts.id, accountId: row.accounts.id,
username: row.accounts.username, username: row.accounts.username,
+26 -2
View File
@@ -49,6 +49,7 @@ describe('notifyService', () => {
(config as any).telegramChatId = ''; (config as any).telegramChatId = '';
(config as any).telegramUseSystemProxy = false; (config as any).telegramUseSystemProxy = false;
config.systemProxyUrl = ''; config.systemProxyUrl = '';
(config as any).telegramMessageThreadId = '';
config.smtpEnabled = true; config.smtpEnabled = true;
config.smtpHost = 'smtp.example.com'; config.smtpHost = 'smtp.example.com';
config.smtpPort = 465; config.smtpPort = 465;
@@ -181,11 +182,12 @@ describe('notifyService', () => {
expect(payload?.text || '').toContain('UTC Time:'); 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'); const { config } = await import('../config.js');
(config as any).telegramEnabled = true; (config as any).telegramEnabled = true;
(config as any).telegramBotToken = '123456:telegram-token'; (config as any).telegramBotToken = '123456:telegram-token';
(config as any).telegramChatId = '-1001234567890'; (config as any).telegramChatId = '-1001234567890';
(config as any).telegramMessageThreadId = '';
config.smtpEnabled = false; config.smtpEnabled = false;
fetchMock.mockResolvedValue({ fetchMock.mockResolvedValue({
@@ -206,13 +208,35 @@ describe('notifyService', () => {
); );
const rawBody = fetchMock.mock.calls[0]?.[1] as { body?: string }; 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.chat_id).toBe('-1001234567890');
expect(payload.message_thread_id).toBeUndefined();
expect(payload.text || '').toContain('Level: warning'); expect(payload.text || '').toContain('Level: warning');
expect(payload.text || '').toContain('Local Time:'); expect(payload.text || '').toContain('Local Time:');
expect(payload.text || '').toContain('UTC 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 () => { it('uses TELEGRAM_API_BASE_URL when configured', async () => {
const { config } = await import('../config.js'); const { config } = await import('../config.js');
(config as any).telegramEnabled = true; (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 telegramApiBaseUrl = String(config.telegramApiBaseUrl || 'https://api.telegram.org').replace(/\/+$/, '');
const telegramApiUrl = `${telegramApiBaseUrl}/bot${config.telegramBotToken}/sendMessage`; const telegramApiUrl = `${telegramApiBaseUrl}/bot${config.telegramBotToken}/sendMessage`;
const text = buildTelegramText(title, resolvedMessage, level, timeFootnote); const text = buildTelegramText(title, resolvedMessage, level, timeFootnote);
const telegramMessageThreadId = Number.parseInt(String(config.telegramMessageThreadId || '').trim(), 10);
tasks.push({ tasks.push({
channel: 'telegram', channel: 'telegram',
run: async () => { run: async () => {
@@ -232,6 +233,9 @@ export async function sendNotification(
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
chat_id: config.telegramChatId, chat_id: config.telegramChatId,
...(Number.isFinite(telegramMessageThreadId) && telegramMessageThreadId > 0
? { message_thread_id: telegramMessageThreadId }
: {}),
text, text,
disable_web_page_preview: true, disable_web_page_preview: true,
}), }),
+11 -1
View File
@@ -42,6 +42,7 @@ describe('NotificationSettings', () => {
telegramEnabled: true, telegramEnabled: true,
telegramApiBaseUrl: 'https://tg-proxy.example.com', telegramApiBaseUrl: 'https://tg-proxy.example.com',
telegramChatId: '-1001234567890', telegramChatId: '-1001234567890',
telegramMessageThreadId: '77',
telegramBotTokenMasked: '1234****token', telegramBotTokenMasked: '1234****token',
telegramUseSystemProxy: false, telegramUseSystemProxy: false,
smtpEnabled: false, smtpEnabled: false,
@@ -56,6 +57,7 @@ describe('NotificationSettings', () => {
apiMock.updateRuntimeSettings.mockResolvedValue({ apiMock.updateRuntimeSettings.mockResolvedValue({
success: true, success: true,
telegramApiBaseUrl: 'https://proxy.example.com/custom', telegramApiBaseUrl: 'https://proxy.example.com/custom',
telegramMessageThreadId: '88',
telegramBotTokenMasked: '1234****token', telegramBotTokenMasked: '1234****token',
}); });
apiMock.testNotification.mockResolvedValue({ success: true }); apiMock.testNotification.mockResolvedValue({ success: true });
@@ -65,7 +67,7 @@ describe('NotificationSettings', () => {
vi.clearAllMocks(); 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; let root: ReturnType<typeof create> | null = null;
try { try {
await act(async () => { await act(async () => {
@@ -85,8 +87,15 @@ describe('NotificationSettings', () => {
)); ));
expect(proxyInput.props.value).toBe('https://tg-proxy.example.com'); 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 () => { await act(async () => {
proxyInput.props.onChange({ target: { value: 'https://proxy.example.com/custom/' } }); proxyInput.props.onChange({ target: { value: 'https://proxy.example.com/custom/' } });
topicInput.props.onChange({ target: { value: '88' } });
}); });
const saveButton = root.root.find((node) => ( const saveButton = root.root.find((node) => (
@@ -101,6 +110,7 @@ describe('NotificationSettings', () => {
expect(apiMock.updateRuntimeSettings).toHaveBeenCalledWith(expect.objectContaining({ expect(apiMock.updateRuntimeSettings).toHaveBeenCalledWith(expect.objectContaining({
telegramApiBaseUrl: 'https://proxy.example.com/custom/', telegramApiBaseUrl: 'https://proxy.example.com/custom/',
telegramMessageThreadId: '88',
})); }));
} finally { } finally {
root?.unmount(); root?.unmount();
+14
View File
@@ -13,6 +13,7 @@ type RuntimeSettings = {
telegramApiBaseUrl: string; telegramApiBaseUrl: string;
telegramChatId: string; telegramChatId: string;
telegramUseSystemProxy: boolean; telegramUseSystemProxy: boolean;
telegramMessageThreadId: string;
smtpEnabled: boolean; smtpEnabled: boolean;
smtpHost: string; smtpHost: string;
smtpPort: number; smtpPort: number;
@@ -37,6 +38,7 @@ export default function NotificationSettings() {
telegramApiBaseUrl: 'https://api.telegram.org', telegramApiBaseUrl: 'https://api.telegram.org',
telegramChatId: '', telegramChatId: '',
telegramUseSystemProxy: false, telegramUseSystemProxy: false,
telegramMessageThreadId: '',
smtpEnabled: false, smtpEnabled: false,
smtpHost: '', smtpHost: '',
smtpPort: 587, smtpPort: 587,
@@ -81,6 +83,7 @@ export default function NotificationSettings() {
telegramApiBaseUrl: runtimeInfo.telegramApiBaseUrl || 'https://api.telegram.org', telegramApiBaseUrl: runtimeInfo.telegramApiBaseUrl || 'https://api.telegram.org',
telegramChatId: runtimeInfo.telegramChatId || '', telegramChatId: runtimeInfo.telegramChatId || '',
telegramUseSystemProxy: !!runtimeInfo.telegramUseSystemProxy, telegramUseSystemProxy: !!runtimeInfo.telegramUseSystemProxy,
telegramMessageThreadId: runtimeInfo.telegramMessageThreadId || '',
smtpEnabled: !!runtimeInfo.smtpEnabled, smtpEnabled: !!runtimeInfo.smtpEnabled,
smtpHost: runtimeInfo.smtpHost || '', smtpHost: runtimeInfo.smtpHost || '',
smtpPort: Number(runtimeInfo.smtpPort) || 587, smtpPort: Number(runtimeInfo.smtpPort) || 587,
@@ -119,6 +122,7 @@ export default function NotificationSettings() {
telegramApiBaseUrl: runtime.telegramApiBaseUrl, telegramApiBaseUrl: runtime.telegramApiBaseUrl,
telegramChatId: runtime.telegramChatId, telegramChatId: runtime.telegramChatId,
telegramUseSystemProxy: runtime.telegramUseSystemProxy, telegramUseSystemProxy: runtime.telegramUseSystemProxy,
telegramMessageThreadId: runtime.telegramMessageThreadId,
smtpEnabled: runtime.smtpEnabled, smtpEnabled: runtime.smtpEnabled,
smtpHost: runtime.smtpHost, smtpHost: runtime.smtpHost,
smtpPort: runtime.smtpPort, smtpPort: runtime.smtpPort,
@@ -367,6 +371,16 @@ export default function NotificationSettings() {
disabled={!runtime.telegramEnabled} disabled={!runtime.telegramEnabled}
/> />
</div> </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>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 8, color: 'var(--color-text-secondary)' }}> <div style={{ fontSize: 13, fontWeight: 500, marginBottom: 8, color: 'var(--color-text-secondary)' }}>
Telegram Bot Token Telegram Bot Token
+60
View File
@@ -26,6 +26,8 @@ type DbDialect = 'sqlite' | 'mysql' | 'postgres';
type RuntimeSettings = { type RuntimeSettings = {
checkinCron: string; checkinCron: string;
checkinScheduleMode: 'cron' | 'interval';
checkinIntervalHours: number;
balanceRefreshCron: string; balanceRefreshCron: string;
logCleanupCron: string; logCleanupCron: string;
logCleanupUsageLogsEnabled: boolean; logCleanupUsageLogsEnabled: boolean;
@@ -187,6 +189,8 @@ export default function Settings() {
const navigate = useNavigate(); const navigate = useNavigate();
const [runtime, setRuntime] = useState<RuntimeSettings>({ const [runtime, setRuntime] = useState<RuntimeSettings>({
checkinCron: '0 8 * * *', checkinCron: '0 8 * * *',
checkinScheduleMode: 'cron',
checkinIntervalHours: 6,
balanceRefreshCron: '0 * * * *', balanceRefreshCron: '0 * * * *',
logCleanupCron: '0 6 * * *', logCleanupCron: '0 6 * * *',
logCleanupUsageLogsEnabled: false, logCleanupUsageLogsEnabled: false,
@@ -203,6 +207,7 @@ export default function Settings() {
const [maskedToken, setMaskedToken] = useState(''); const [maskedToken, setMaskedToken] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [savingSchedule, setSavingSchedule] = useState(false); const [savingSchedule, setSavingSchedule] = useState(false);
const [testingCheckin, setTestingCheckin] = useState(false);
const [savingToken, setSavingToken] = useState(false); const [savingToken, setSavingToken] = useState(false);
const [savingSystemProxy, setSavingSystemProxy] = useState(false); const [savingSystemProxy, setSavingSystemProxy] = useState(false);
const [savingProxyFailureRules, setSavingProxyFailureRules] = useState(false); const [savingProxyFailureRules, setSavingProxyFailureRules] = useState(false);
@@ -397,6 +402,10 @@ export default function Settings() {
setMaskedToken(authInfo.masked || '****'); setMaskedToken(authInfo.masked || '****');
setRuntime({ setRuntime({
checkinCron: runtimeInfo.checkinCron || '0 8 * * *', 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 * * * *', balanceRefreshCron: runtimeInfo.balanceRefreshCron || '0 * * * *',
logCleanupCron: runtimeInfo.logCleanupCron || '0 6 * * *', logCleanupCron: runtimeInfo.logCleanupCron || '0 6 * * *',
logCleanupUsageLogsEnabled: !!runtimeInfo.logCleanupUsageLogsEnabled, logCleanupUsageLogsEnabled: !!runtimeInfo.logCleanupUsageLogsEnabled,
@@ -488,6 +497,8 @@ export default function Settings() {
try { try {
await api.updateRuntimeSettings({ await api.updateRuntimeSettings({
checkinCron: runtime.checkinCron, checkinCron: runtime.checkinCron,
checkinScheduleMode: runtime.checkinScheduleMode,
checkinIntervalHours: runtime.checkinIntervalHours,
balanceRefreshCron: runtime.balanceRefreshCron, balanceRefreshCron: runtime.balanceRefreshCron,
logCleanupCron: runtime.logCleanupCron, logCleanupCron: runtime.logCleanupCron,
logCleanupUsageLogsEnabled: runtime.logCleanupUsageLogsEnabled, 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 saveProxyToken = async () => {
const suffix = proxyTokenSuffix.trim(); const suffix = proxyTokenSuffix.trim();
if (!suffix) { if (!suffix) {
@@ -953,6 +976,42 @@ export default function Settings() {
<div className="card animate-slide-up stagger-2" style={{ padding: 20 }}> <div className="card animate-slide-up stagger-2" style={{ padding: 20 }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 12 }}>定时任务</div> <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 style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div> <div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>签到 Cron</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} value={runtime.checkinCron}
onChange={(e) => setRuntime((prev) => ({ ...prev, checkinCron: e.target.value }))} onChange={(e) => setRuntime((prev) => ({ ...prev, checkinCron: e.target.value }))}
style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }} style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }}
disabled={runtime.checkinScheduleMode !== 'cron'}
/> />
</div> </div>
<div> <div>
@@ -12,6 +12,7 @@ const { apiMock } = vi.hoisted(() => ({
getRoutesLite: vi.fn(), getRoutesLite: vi.fn(),
getRuntimeDatabaseConfig: vi.fn(), getRuntimeDatabaseConfig: vi.fn(),
updateRuntimeSettings: vi.fn(), updateRuntimeSettings: vi.fn(),
triggerCheckinAll: vi.fn(),
}, },
})); }));
@@ -46,6 +47,8 @@ describe('Settings log cleanup schedule', () => {
apiMock.getAuthInfo.mockResolvedValue({ masked: 'sk-****' }); apiMock.getAuthInfo.mockResolvedValue({ masked: 'sk-****' });
apiMock.getRuntimeSettings.mockResolvedValue({ apiMock.getRuntimeSettings.mockResolvedValue({
checkinCron: '0 8 * * *', checkinCron: '0 8 * * *',
checkinScheduleMode: 'interval',
checkinIntervalHours: 6,
balanceRefreshCron: '0 * * * *', balanceRefreshCron: '0 * * * *',
logCleanupCron: '15 4 * * *', logCleanupCron: '15 4 * * *',
logCleanupUsageLogsEnabled: true, logCleanupUsageLogsEnabled: true,
@@ -70,7 +73,7 @@ describe('Settings log cleanup schedule', () => {
vi.clearAllMocks(); 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; let root: ReturnType<typeof create> | null = null;
try { try {
await act(async () => { await act(async () => {
@@ -97,6 +100,8 @@ describe('Settings log cleanup schedule', () => {
expect(apiMock.updateRuntimeSettings).toHaveBeenCalledWith({ expect(apiMock.updateRuntimeSettings).toHaveBeenCalledWith({
checkinCron: '0 8 * * *', checkinCron: '0 8 * * *',
checkinScheduleMode: 'interval',
checkinIntervalHours: 6,
balanceRefreshCron: '0 * * * *', balanceRefreshCron: '0 * * * *',
logCleanupCron: '15 4 * * *', logCleanupCron: '15 4 * * *',
logCleanupUsageLogsEnabled: true, logCleanupUsageLogsEnabled: true,
@@ -107,4 +112,37 @@ describe('Settings log cleanup schedule', () => {
root?.unmount(); 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