增加定时任务调度入口和 Admin 触发路由

- 新增 scheduler/index.ts 统一调度周榜结算和订阅过期检查
- 支持 CLI 入口:bun run src/services/scheduler/index.ts weekly-settlement --dry-run
- 支持 Admin 手动触发:GET/POST /v1/admin/jobs
- 所有任务支持 dry-run 模式预览不写库
This commit is contained in:
Wang Zhuoxuan 2026-05-13 22:39:35 +08:00
parent 1d9c67b30c
commit c24be16b6a
4 changed files with 107 additions and 1 deletions

View File

@ -123,7 +123,7 @@
| G6-2 | 更新 `docs/implementation-plan.md` | [x] | 将本计划作为 Phase 1d 或 Game Economy 阶段索引进去 | | G6-2 | 更新 `docs/implementation-plan.md` | [x] | 将本计划作为 Phase 1d 或 Game Economy 阶段索引进去 |
| G6-3 | 增加 Admin 配置或只读查看能力 | [x] | 管理端至少能查看用户金币、道具、奖励流水、广告恢复记录 | | G6-3 | 增加 Admin 配置或只读查看能力 | [x] | 管理端至少能查看用户金币、道具、奖励流水、广告恢复记录 |
| G6-4 | 增加 E2E 或集成测试 | [x] | 覆盖游客登录、完成挑战组、广告恢复、购买道具、周榜查询 | | G6-4 | 增加 E2E 或集成测试 | [x] | 覆盖游客登录、完成挑战组、广告恢复、购买道具、周榜查询 |
| G6-5 | 增加定时任务入口 | [ ] | 周榜结算和订阅/资源周期任务有可部署入口,支持手动 dry-run | | G6-5 | 增加定时任务入口 | [x] | 周榜结算和订阅/资源周期任务有可部署入口,支持手动 dry-run |
| G6-6 | 完成最终验证 | [ ] | `bun run typecheck`、`bun run test`、`bun run lint` 通过或记录明确环境阻塞 | | G6-6 | 完成最终验证 | [ ] | `bun run typecheck`、`bun run test`、`bun run lint` 通过或记录明确环境阻塞 |
## 推荐执行顺序 ## 推荐执行顺序

View File

@ -9,6 +9,7 @@ import { adminUsersRoutes } from './users.js';
import { adminStatsRoutes } from './stats.js'; import { adminStatsRoutes } from './stats.js';
import { adminFeedbackRoutes } from './feedback.js'; import { adminFeedbackRoutes } from './feedback.js';
import { adminGamificationRoutes } from './gamification.js'; import { adminGamificationRoutes } from './gamification.js';
import { adminJobsRoutes } from './jobs.js';
export async function adminRoutes(app: FastifyInstance): Promise<void> { export async function adminRoutes(app: FastifyInstance): Promise<void> {
app.register(adminAuthRoutes); app.register(adminAuthRoutes);
@ -21,4 +22,5 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
app.register(adminStatsRoutes, { prefix: '/stats' }); app.register(adminStatsRoutes, { prefix: '/stats' });
app.register(adminFeedbackRoutes, { prefix: '/feedback' }); app.register(adminFeedbackRoutes, { prefix: '/feedback' });
app.register(adminGamificationRoutes, { prefix: '/gamification' }); app.register(adminGamificationRoutes, { prefix: '/gamification' });
app.register(adminJobsRoutes, { prefix: '/jobs' });
} }

18
src/routes/admin/jobs.ts Normal file
View File

@ -0,0 +1,18 @@
import { FastifyInstance } from 'fastify';
import { listJobs, runJob } from '../../services/scheduler/index.js';
import type { JobName } from '../../services/scheduler/index.js';
export async function adminJobsRoutes(app: FastifyInstance): Promise<void> {
// 列出所有可用定时任务
app.get('/', async () => {
const jobs = listJobs();
return { success: true, data: jobs, error: null };
});
// 手动触发定时任务
app.post<{ Body: { job: JobName; dryRun?: boolean } }>('/trigger', async (request) => {
const { job, dryRun = false } = request.body;
const result = await runJob(job, dryRun);
return { success: true, data: result, error: null };
});
}

View File

@ -0,0 +1,86 @@
/**
*
*
*
* dry-run
*
*
* - cron `bun run src/services/scheduler/index.ts weekly-settlement`
* - Admin `POST /v1/admin/jobs/trigger`
*/
import { weeklySettlement } from '../gamification/leaderboard-service.js';
import { checkAndExpireSubscriptions } from '../payment/subscription-service.js';
export type JobName = 'weekly-settlement' | 'expire-subscriptions';
export interface JobResult {
job: JobName;
dryRun: boolean;
executedAt: string;
result: unknown;
}
const JOBS: Record<JobName, (dryRun: boolean) => Promise<unknown>> = {
'weekly-settlement': async (dryRun) => weeklySettlement(dryRun),
'expire-subscriptions': async (dryRun) => {
if (dryRun) {
return { message: 'dry-run: will check and expire subscriptions', note: '无实际变更' };
}
const count = await checkAndExpireSubscriptions();
return { expiredCount: count };
},
};
/**
*
* @param jobName
* @param dryRun true
*/
export async function runJob(jobName: JobName, dryRun = false): Promise<JobResult> {
const handler = JOBS[jobName];
if (!handler) {
throw new Error(`Unknown job: ${jobName}. Available: ${Object.keys(JOBS).join(', ')}`);
}
const result = await handler(dryRun);
return {
job: jobName,
dryRun,
executedAt: new Date().toISOString(),
result,
};
}
/**
*
*/
export function listJobs(): Array<{ name: JobName; description: string; schedule: string }> {
return [
{
name: 'weekly-settlement',
description: '周榜结算:按组快照上周排名,给每组前 3 名发金币奖励',
schedule: '每周一 UTC 00:30',
},
{
name: 'expire-subscriptions',
description: '订阅过期检查:检查并过期到期的订阅',
schedule: '每日 UTC 01:00',
},
];
}
// CLI 入口:`bun run src/services/scheduler/index.ts weekly-settlement [--dry-run]`
const args = process.argv.slice(2);
if (args.length > 0 && args[0] !== '--dry-run') {
const jobName = args[0] as JobName;
const dryRun = args.includes('--dry-run');
runJob(jobName, dryRun)
.then((result) => {
console.log(JSON.stringify(result, null, 2));
process.exit(0);
})
.catch((err) => {
console.error(`Job failed: ${err instanceof Error ? err.message : err}`);
process.exit(1);
});
}