add:worker自动签到功能

This commit is contained in:
lsy committed 2026-02-25 16:49:23 +08:00
1 parent a34f954c53
commit 71794ed7fd
11 files changed
+335 -62

No files matched your search

+5 -1
View File
@@ -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 = {
+2
View File
@@ -148,3 +148,5 @@ app.notFound(async (c) => {
});
export default app;
export { CheckinScheduler } from "./services/checkin-scheduler";
+19
View File
@@ -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 });
});
+6 -59
View File
@@ -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<AppEnv>();
@@ -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,
});
});
@@ -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<CheckinRunResult> {
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(),
};
}
@@ -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<Response> {
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<string>(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<void> {
await this.handleAlarm();
}
private async handleAlarm(): Promise<void> {
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<string>(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<RescheduleResult> {
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() };
}
}
+62 -2
View File
@@ -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);
}
+8
View File
@@ -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"]
+9
View File
@@ -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
### 修复
+5
View File
@@ -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` 模块提供签到时间与开关配置
+2
View File
@@ -13,6 +13,8 @@
- 配置写入后即时生效
- 未配置时使用环境变量默认值
- 定时签到时间使用 `HH:mm` 格式(默认 `00:10`,中国时间)
- 更新签到时间或开关后会触发 `CheckinScheduler` 重新调度
- 若更新后当前时间已超过新签到时间,当日可再次触发自动签到
## 依赖关系
- `settings` 表