diff --git a/apps/worker/src/env.ts b/apps/worker/src/env.ts index 65a0548..2085bd6 100644 --- a/apps/worker/src/env.ts +++ b/apps/worker/src/env.ts @@ -1,10 +1,14 @@ -import type { D1Database } from "@cloudflare/workers-types"; +import type { + D1Database, + DurableObjectNamespace, +} from "@cloudflare/workers-types"; export type Bindings = { DB: D1Database; CORS_ORIGIN?: string; PROXY_RETRY_ROUNDS?: string; PROXY_RETRY_DELAY_MS?: string; + CHECKIN_SCHEDULER: DurableObjectNamespace; }; export type Variables = { diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index e256f07..35c630b 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -148,3 +148,5 @@ app.notFound(async (c) => { }); export default app; + +export { CheckinScheduler } from "./services/checkin-scheduler"; diff --git a/apps/worker/src/routes/settings.ts b/apps/worker/src/routes/settings.ts index 51f1e00..c9dd19e 100644 --- a/apps/worker/src/routes/settings.ts +++ b/apps/worker/src/routes/settings.ts @@ -10,6 +10,10 @@ import { setRetentionDays, setSessionTtlHours, } from "../services/settings"; +import { + getCheckinSchedulerStub, + shouldResetLastRun, +} from "../services/checkin-scheduler"; import { sha256Hex } from "../utils/crypto"; import { jsonError } from "../utils/http"; @@ -42,6 +46,8 @@ settings.put("/", async (c) => { } let touched = false; + let scheduleTouched = false; + let scheduleReset = false; if (body.log_retention_days !== undefined) { const days = Number(body.log_retention_days); @@ -131,12 +137,25 @@ settings.put("/", async (c) => { } await setCheckinSchedule(c.env.DB, { enabled, time: timeValue }); touched = true; + scheduleTouched = true; + scheduleReset = shouldResetLastRun(current, { + enabled, + time: timeValue, + }); } if (!touched) { return jsonError(c, 400, "settings_empty", "settings_empty"); } + if (scheduleTouched) { + const scheduler = getCheckinSchedulerStub(c.env.CHECKIN_SCHEDULER); + await scheduler.fetch("https://checkin-scheduler/reschedule", { + method: "POST", + ...(scheduleReset ? { body: JSON.stringify({ reset: true }) } : {}), + }); + } + return c.json({ ok: true }); }); diff --git a/apps/worker/src/routes/sites.ts b/apps/worker/src/routes/sites.ts index 45ff642..151b521 100644 --- a/apps/worker/src/routes/sites.ts +++ b/apps/worker/src/routes/sites.ts @@ -10,13 +10,8 @@ import { insertChannel, listChannels, updateChannel, - updateChannelCheckinResult, } from "../services/channel-repo"; -import { - type CheckinResultItem, - runCheckin, - summarizeCheckin, -} from "../services/checkin"; +import { runCheckinAll } from "../services/checkin-runner"; import { buildSiteMetadata, parseSiteMetadata, @@ -24,7 +19,7 @@ import { } from "../services/site-metadata"; import { generateToken } from "../utils/crypto"; import { jsonError } from "../utils/http"; -import { beijingDateString, nowIso } from "../utils/time"; +import { nowIso } from "../utils/time"; import { normalizeBaseUrl } from "../utils/url"; const sites = new Hono(); @@ -429,59 +424,11 @@ sites.delete("/:id", async (c) => { }); sites.post("/checkin-all", async (c) => { - const channelRows = await listChannels(c.env.DB, { orderBy: "created_at" }); - const results: CheckinResultItem[] = []; - const today = beijingDateString(); - for (const channel of channelRows) { - if (channel.status !== "active") { - continue; - } - const metadata = parseSiteMetadata(channel.metadata_json); - if (metadata.site_type !== "new-api") { - continue; - } - const rawEnabled = channel.checkin_enabled ?? 0; - const checkinEnabled = - typeof rawEnabled === "boolean" ? rawEnabled : Number(rawEnabled) === 1; - if (!checkinEnabled) { - continue; - } - const alreadyChecked = - channel.last_checkin_date === today && - (channel.last_checkin_status === "success" || - channel.last_checkin_status === "skipped"); - if (alreadyChecked) { - results.push({ - id: channel.id, - name: channel.name, - status: "skipped", - message: channel.last_checkin_message ?? "今日已签到", - checkin_date: channel.last_checkin_date ?? today, - }); - continue; - } - const result = await runCheckin({ - id: channel.id, - name: channel.name, - base_url: String(channel.base_url), - checkin_url: channel.checkin_url ?? null, - system_token: channel.system_token ?? null, - system_userid: channel.system_userid ?? null, - }); - const checkinDate = result.checkin_date ?? today; - await updateChannelCheckinResult(c.env.DB, channel.id, { - last_checkin_date: checkinDate, - last_checkin_status: result.status, - last_checkin_message: result.message, - last_checkin_at: nowIso(), - }); - results.push({ ...result, checkin_date: checkinDate }); - } - const summary = summarizeCheckin(results); + const result = await runCheckinAll(c.env.DB, new Date()); return c.json({ - results, - summary, - runs_at: nowIso(), + results: result.results, + summary: result.summary, + runs_at: result.runsAt, }); }); diff --git a/apps/worker/src/services/checkin-runner.ts b/apps/worker/src/services/checkin-runner.ts new file mode 100644 index 0000000..10ceb1a --- /dev/null +++ b/apps/worker/src/services/checkin-runner.ts @@ -0,0 +1,77 @@ +import type { D1Database } from "@cloudflare/workers-types"; +import { listChannels, updateChannelCheckinResult } from "./channel-repo"; +import { + runCheckin, + summarizeCheckin, + type CheckinResultItem, + type CheckinSummary, +} from "./checkin"; +import { parseSiteMetadata } from "./site-metadata"; +import { beijingDateString } from "../utils/time"; + +export type CheckinRunResult = { + results: CheckinResultItem[]; + summary: CheckinSummary; + runsAt: string; +}; + +export async function runCheckinAll( + db: D1Database, + now: Date = new Date(), +): Promise { + const channelRows = await listChannels(db, { orderBy: "created_at" }); + const results: CheckinResultItem[] = []; + const today = beijingDateString(now); + + for (const channel of channelRows) { + if (channel.status !== "active") { + continue; + } + const metadata = parseSiteMetadata(channel.metadata_json); + if (metadata.site_type !== "new-api") { + continue; + } + const rawEnabled = channel.checkin_enabled ?? 0; + const checkinEnabled = + typeof rawEnabled === "boolean" ? rawEnabled : Number(rawEnabled) === 1; + if (!checkinEnabled) { + continue; + } + const alreadyChecked = + channel.last_checkin_date === today && + (channel.last_checkin_status === "success" || + channel.last_checkin_status === "skipped"); + if (alreadyChecked) { + results.push({ + id: channel.id, + name: channel.name, + status: "skipped", + message: channel.last_checkin_message ?? "今日已签到", + checkin_date: channel.last_checkin_date ?? today, + }); + continue; + } + const result = await runCheckin({ + id: channel.id, + name: channel.name, + base_url: String(channel.base_url), + checkin_url: channel.checkin_url ?? null, + system_token: channel.system_token ?? null, + system_userid: channel.system_userid ?? null, + }); + const checkinDate = result.checkin_date ?? today; + await updateChannelCheckinResult(db, channel.id, { + last_checkin_date: checkinDate, + last_checkin_status: result.status, + last_checkin_message: result.message, + last_checkin_at: now.toISOString(), + }); + results.push({ ...result, checkin_date: checkinDate }); + } + + return { + results, + summary: summarizeCheckin(results), + runsAt: now.toISOString(), + }; +} diff --git a/apps/worker/src/services/checkin-scheduler.ts b/apps/worker/src/services/checkin-scheduler.ts new file mode 100644 index 0000000..3506576 --- /dev/null +++ b/apps/worker/src/services/checkin-scheduler.ts @@ -0,0 +1,140 @@ +import type { + DurableObjectNamespace, + DurableObjectState, +} from "@cloudflare/workers-types"; +import type { Bindings } from "../env"; +import { getCheckinSchedule } from "./settings"; +import { runCheckinAll } from "./checkin-runner"; +import { + beijingDateString, + computeBeijingScheduleTime, + computeNextBeijingRun, +} from "../utils/time"; + +const SCHEDULER_NAME = "checkin-scheduler"; +const LAST_RUN_DATE_KEY = "last_run_date"; + +export const getCheckinSchedulerStub = (namespace: DurableObjectNamespace) => + namespace.get(namespace.idFromName(SCHEDULER_NAME)); + +export const shouldRunCheckin = ( + now: Date, + scheduleTime: string, + lastRunDate: string | null, +) => { + const today = beijingDateString(now); + if (lastRunDate && lastRunDate === today) { + return false; + } + const scheduledAt = computeBeijingScheduleTime(now, scheduleTime); + return now.getTime() >= scheduledAt.getTime(); +}; + +export const shouldResetLastRun = ( + current: { enabled: boolean; time: string }, + next: { enabled: boolean; time: string }, +) => { + if (!next.enabled) { + return false; + } + if (current.enabled !== next.enabled) { + return next.enabled; + } + return current.time !== next.time; +}; + +export const computeNextAlarmAt = ( + now: Date, + scheduleTime: string, + reset: boolean, +) => { + if (!reset) { + return computeNextBeijingRun(now, scheduleTime); + } + const scheduledAt = computeBeijingScheduleTime(now, scheduleTime); + if (now.getTime() >= scheduledAt.getTime()) { + return new Date(now.getTime() + 1000); + } + return scheduledAt; +}; + +type RescheduleResult = { + enabled: boolean; + nextRunAt: string | null; +}; + +export class CheckinScheduler { + private state: DurableObjectState; + private env: Bindings; + + constructor(state: DurableObjectState, env: Bindings) { + this.state = state; + this.env = env; + } + + async fetch(request: Request): Promise { + const url = new URL(request.url); + if (request.method === "POST" && url.pathname === "/reschedule") { + let reset = false; + try { + const payload = (await request.json()) as { reset?: boolean }; + reset = Boolean(payload?.reset); + } catch { + reset = false; + } + const result = await this.reschedule(new Date(), reset); + return new Response(JSON.stringify({ ok: true, ...result }), { + headers: { "Content-Type": "application/json" }, + }); + } + if (request.method === "GET" && url.pathname === "/status") { + const lastRunDate = + (await this.state.storage.get(LAST_RUN_DATE_KEY)) ?? null; + return new Response( + JSON.stringify({ + ok: true, + last_run_date: lastRunDate, + }), + { headers: { "Content-Type": "application/json" } }, + ); + } + return new Response("Not Found", { status: 404 }); + } + + async alarm(): Promise { + await this.handleAlarm(); + } + + private async handleAlarm(): Promise { + const now = new Date(); + const schedule = await getCheckinSchedule(this.env.DB); + if (!schedule.enabled) { + await this.state.storage.deleteAlarm(); + return; + } + const lastRunDate = + (await this.state.storage.get(LAST_RUN_DATE_KEY)) ?? null; + if (shouldRunCheckin(now, schedule.time, lastRunDate)) { + await runCheckinAll(this.env.DB, now); + await this.state.storage.put(LAST_RUN_DATE_KEY, beijingDateString(now)); + } + await this.reschedule(now); + } + + private async reschedule( + now: Date = new Date(), + reset = false, + ): Promise { + const schedule = await getCheckinSchedule(this.env.DB); + if (!schedule.enabled) { + await this.state.storage.deleteAlarm(); + return { enabled: false, nextRunAt: null }; + } + if (reset) { + await this.state.storage.delete(LAST_RUN_DATE_KEY); + } + const nextRun = computeNextAlarmAt(now, schedule.time, reset); + await this.state.storage.setAlarm(nextRun.getTime()); + return { enabled: true, nextRunAt: nextRun.toISOString() }; + } +} diff --git a/apps/worker/src/utils/time.ts b/apps/worker/src/utils/time.ts index 73a5e5b..ad8ccf8 100644 --- a/apps/worker/src/utils/time.ts +++ b/apps/worker/src/utils/time.ts @@ -5,6 +5,9 @@ export function nowIso(): string { return new Date().toISOString(); } +const BEIJING_OFFSET_MS = 8 * 60 * 60 * 1000; +const DEFAULT_SCHEDULE_TIME = "00:10"; + /** * Adds hours to a date and returns a new Date. */ @@ -18,10 +21,67 @@ export function addHours(date: Date, hours: number): Date { * Returns Beijing date string (YYYY-MM-DD). */ export function beijingDateString(date: Date = new Date()): string { - const offsetMs = 8 * 60 * 60 * 1000; - const beijing = new Date(date.getTime() + offsetMs); + const beijing = new Date(date.getTime() + BEIJING_OFFSET_MS); const year = beijing.getUTCFullYear(); const month = String(beijing.getUTCMonth() + 1).padStart(2, "0"); const day = String(beijing.getUTCDate()).padStart(2, "0"); return `${year}-${month}-${day}`; } + +export function parseScheduleTime(value: string): { hour: number; minute: number } | null { + if (!/^\d{2}:\d{2}$/.test(value)) { + return null; + } + const [hour, minute] = value.split(":").map((part) => Number(part)); + if ( + Number.isNaN(hour) || + Number.isNaN(minute) || + hour < 0 || + hour > 23 || + minute < 0 || + minute > 59 + ) { + return null; + } + return { hour, minute }; +} + +export function getBeijingDateParts(date: Date = new Date()) { + const beijing = new Date(date.getTime() + BEIJING_OFFSET_MS); + return { + year: beijing.getUTCFullYear(), + month: beijing.getUTCMonth() + 1, + day: beijing.getUTCDate(), + hour: beijing.getUTCHours(), + minute: beijing.getUTCMinutes(), + }; +} + +const resolveScheduleParts = (value: string) => + parseScheduleTime(value) ?? parseScheduleTime(DEFAULT_SCHEDULE_TIME)!; + +export function computeBeijingScheduleTime( + now: Date, + scheduleTime: string, +): Date { + const { year, month, day } = getBeijingDateParts(now); + const { hour, minute } = resolveScheduleParts(scheduleTime); + const utcMs = + Date.UTC(year, month - 1, day, hour, minute, 0) - BEIJING_OFFSET_MS; + return new Date(utcMs); +} + +export function computeNextBeijingRun( + now: Date, + scheduleTime: string, +): Date { + const scheduled = computeBeijingScheduleTime(now, scheduleTime); + if (now.getTime() < scheduled.getTime()) { + return scheduled; + } + const { year, month, day } = getBeijingDateParts(now); + const { hour, minute } = resolveScheduleParts(scheduleTime); + const utcMs = + Date.UTC(year, month - 1, day + 1, hour, minute, 0) - BEIJING_OFFSET_MS; + return new Date(utcMs); +} diff --git a/apps/worker/wrangler.toml b/apps/worker/wrangler.toml index a4fcb47..97b119e 100644 --- a/apps/worker/wrangler.toml +++ b/apps/worker/wrangler.toml @@ -18,5 +18,13 @@ binding = "ASSETS" not_found_handling = "single-page-application" run_worker_first = true +[[durable_objects.bindings]] +name = "CHECKIN_SCHEDULER" +class_name = "CheckinScheduler" + +[[migrations]] +tag = "v1" +new_classes = ["CheckinScheduler"] + diff --git a/helloagents/CHANGELOG.md b/helloagents/CHANGELOG.md index fa8081f..740d783 100644 --- a/helloagents/CHANGELOG.md +++ b/helloagents/CHANGELOG.md @@ -89,6 +89,15 @@ - 类型: 微调(无方案包) - 文件: apps/worker/src/routes/proxy.ts +## [0.8.3] - 2026-02-25 + +### 修复 +- **[checkin]**: 后端定时自动签到接入 Durable Object Alarm 调度 + - 方案: [202602251604_checkin-do-alarm](archive/2026-02/202602251604_checkin-do-alarm/) + - 决策: checkin-do-alarm#D001(采用 Durable Object Alarm) +- **[checkin]**: 修改签到时间后当日可再次触发自动签到 + - 方案: [202602251604_checkin-do-alarm](archive/2026-02/202602251604_checkin-do-alarm/) + ## [0.8.2] - 2026-02-23 ### 修复 diff --git a/helloagents/modules/checkin.md b/helloagents/modules/checkin.md index 3b24eec..81d0cc6 100644 --- a/helloagents/modules/checkin.md +++ b/helloagents/modules/checkin.md @@ -4,6 +4,7 @@ - 维护签到站点列表与状态 - 执行一键签到并汇总结果 - 提供手动签到跳转入口 +- 提供后端定时自动签到调度 - 写回今日签到结果到站点表 ## 接口定义 @@ -14,6 +15,9 @@ - 仅对 `site_type=new-api` 且 `checkin_enabled=1` 的站点执行签到 - `checkin_sites` CRUD 接口已移除,签到字段统一由 `channels` 承载 - 一键签到会跳过 `last_checkin_date` 已是北京时间当日且状态为成功/已签的站点 +- 定时签到由 `CheckinScheduler` Durable Object 触发,每天最多执行一次 +- 定时签到在 `settings.checkin_schedule_enabled=true` 且到达设定时间时执行 +- 当签到时间或开关变更且已到新时间时,允许当日再次执行一次 - 手动签到优先打开 `checkin_url`,为空则打开 `base_url` - 签到执行使用 `checkin_url`(若为空则回退 `base_url`)拼接 `/api/user/checkin` - 请求头必须携带 `New-Api-User`(值来自站点的 `userid` 配置) @@ -22,3 +26,4 @@ ## 依赖关系 - `worker` 路由与 D1 数据表 `channels` - `admin-ui` 负责前端交互与结果展示 +- `settings` 模块提供签到时间与开关配置 diff --git a/helloagents/modules/settings.md b/helloagents/modules/settings.md index f71e81b..3c5c27e 100644 --- a/helloagents/modules/settings.md +++ b/helloagents/modules/settings.md @@ -13,6 +13,8 @@ - 配置写入后即时生效 - 未配置时使用环境变量默认值 - 定时签到时间使用 `HH:mm` 格式(默认 `00:10`,中国时间) +- 更新签到时间或开关后会触发 `CheckinScheduler` 重新调度 +- 若更新后当前时间已超过新签到时间,当日可再次触发自动签到 ## 依赖关系 - `settings` 表