diff --git a/docs/gamification-server-plan.md b/docs/gamification-server-plan.md index 8170afa..5fb3b2f 100644 --- a/docs/gamification-server-plan.md +++ b/docs/gamification-server-plan.md @@ -123,7 +123,7 @@ | G6-2 | 更新 `docs/implementation-plan.md` | [x] | 将本计划作为 Phase 1d 或 Game Economy 阶段索引进去 | | G6-3 | 增加 Admin 配置或只读查看能力 | [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` 通过或记录明确环境阻塞 | ## 推荐执行顺序 diff --git a/src/routes/admin/index.ts b/src/routes/admin/index.ts index 39f4ea5..e10d3fd 100644 --- a/src/routes/admin/index.ts +++ b/src/routes/admin/index.ts @@ -9,6 +9,7 @@ import { adminUsersRoutes } from './users.js'; import { adminStatsRoutes } from './stats.js'; import { adminFeedbackRoutes } from './feedback.js'; import { adminGamificationRoutes } from './gamification.js'; +import { adminJobsRoutes } from './jobs.js'; export async function adminRoutes(app: FastifyInstance): Promise { app.register(adminAuthRoutes); @@ -21,4 +22,5 @@ export async function adminRoutes(app: FastifyInstance): Promise { app.register(adminStatsRoutes, { prefix: '/stats' }); app.register(adminFeedbackRoutes, { prefix: '/feedback' }); app.register(adminGamificationRoutes, { prefix: '/gamification' }); + app.register(adminJobsRoutes, { prefix: '/jobs' }); } diff --git a/src/routes/admin/jobs.ts b/src/routes/admin/jobs.ts new file mode 100644 index 0000000..16d3c8c --- /dev/null +++ b/src/routes/admin/jobs.ts @@ -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 { + // 列出所有可用定时任务 + 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 }; + }); +} diff --git a/src/services/scheduler/index.ts b/src/services/scheduler/index.ts new file mode 100644 index 0000000..eb29720 --- /dev/null +++ b/src/services/scheduler/index.ts @@ -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 Promise> = { + '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 { + 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); + }); +}