add:worker自动签到功能
This commit is contained in:
1 parent
a34f954c53
commit
71794ed7fd
11 files changed
+335
-62
No files matched your search
@@ -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 = {
|
||||
|
||||
@@ -148,3 +148,5 @@ app.notFound(async (c) => {
|
||||
});
|
||||
|
||||
export default app;
|
||||
|
||||
export { CheckinScheduler } from "./services/checkin-scheduler";
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -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() };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"]
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
### 修复
|
||||
|
||||
@@ -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` 模块提供签到时间与开关配置
|
||||
@@ -13,6 +13,8 @@
|
||||
- 配置写入后即时生效
|
||||
- 未配置时使用环境变量默认值
|
||||
- 定时签到时间使用 `HH:mm` 格式(默认 `00:10`,中国时间)
|
||||
- 更新签到时间或开关后会触发 `CheckinScheduler` 重新调度
|
||||
- 若更新后当前时间已超过新签到时间,当日可再次触发自动签到
|
||||
|
||||
## 依赖关系
|
||||
- `settings` 表
|
||||
Reference in new issue
Block a user