forked from boos/BoosAPI
fix desktop tray icon telegram topics and checkin intervals
This commit is contained in:
@@ -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([
|
||||||
sourcePath,
|
renderDesktopIconBuffer({
|
||||||
size,
|
sourcePath,
|
||||||
padding,
|
size,
|
||||||
cornerRadius,
|
padding,
|
||||||
});
|
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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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
@@ -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());
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 |
Reference in New Issue
Block a user