Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,10 @@ EMAIL_USE_SSL=true
EMAIL_SEND_PARTIAL_REPORT=true

# --- 自动任务 ---
# 业务调度统一由后台 APScheduler 管理:每日 00:15 生成前一日群报,08:30 起按群发送。
# 业务调度统一由后台 APScheduler 管理;发送时间可在首页“发送时间”按钮修改,保存值优先于环境默认值。
SCHEDULE_SEND_TIME=08:30
# 按运行日期暂停日报发送,逗号分隔;重启、自动恢复和手动发送均不会补发这些日期。
SCHEDULE_SEND_SKIP_DATES=
# 正式环境只能有一个 owner。默认 fastapi;切为 external 前必须先停止内部 scheduler。
SCHEDULER_OWNER=fastapi
SCHEDULE_GENERATE_TIME=00:15
Expand Down
13 changes: 13 additions & 0 deletions app/api/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

from __future__ import annotations

import re

from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlmodel import Session
Expand Down Expand Up @@ -80,6 +82,7 @@
"email_use_ssl",
"email_send_partial_report",
"schedule_generate_time",
"schedule_send_time",
"schedule_email_time",
"schedule_startup_catchup_enabled",
}
Expand Down Expand Up @@ -112,6 +115,8 @@ def get_settings(session: Session = Depends(repo.get_session)):
data[key] = "******" if value else ""
else:
data[key] = value
data["schedule_send_time"] = get_runtime_settings().schedule_send_time
data["schedule_send_skip_dates"] = get_runtime_settings().schedule_send_skip_dates
return data


Expand All @@ -133,6 +138,10 @@ def update_settings(payload: SettingsPayload, session: Session = Depends(repo.ge
raise HTTPException(status_code=422, detail=f"设置值类型无效:{', '.join(rejected)}")
changed = set(requested)
try:
if "schedule_send_time" in changed and not re.fullmatch(
r"(?:[01]\d|2[0-3]):[0-5]\d", candidate.schedule_send_time
):
raise ValueError("发送时间必须是有效的 HH:MM(00:00—23:59)")
if changed & {'knowledge_memory_call_budget','knowledge_memory_input_budget','knowledge_memory_output_budget'}:
if not (0<=candidate.knowledge_memory_call_budget<=20 and 0<=candidate.knowledge_memory_input_budget<=200000 and 0<=candidate.knowledge_memory_output_budget<=40000):
raise ValueError('记忆预算超出允许范围:每日调用 0—20,输入 0—200000,输出 0—40000')
Expand All @@ -151,4 +160,8 @@ def update_settings(payload: SettingsPayload, session: Session = Depends(repo.ge
for key, value in requested.items():
repo.set_setting_value(session, key, value)
runtime_settings.apply_runtime_values(requested)
if "schedule_send_time" in requested:
from app.scheduler.manager import reschedule_send_batch

reschedule_send_batch(runtime_settings)
return {"ok": True}
2 changes: 2 additions & 0 deletions app/api/v2_ui_read.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ def dashboard(
for group in groups:
name = group.display_name or group.wechat_group_name
run = store.load_run(name, selected_run_date)
if settings.is_send_skipped(selected_run_date) and not run.get("sent_at"):
run = {**run, "send_hold": True, "send_hold_reason": "USER_SKIPPED_SEND_DATE"}
runtime_run = dict(run)
runtime_run.setdefault("group_id", str(group.id or ""))
runtime_run.setdefault("group_name", name)
Expand Down
7 changes: 6 additions & 1 deletion app/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"allow_test_providers",
"legacy_v1_write_mode",
"scheduler_owner",
"schedule_send_time",
"schedule_send_skip_dates",
"reliability_watchdog_enabled",
"reliability_lookback_days",
"reliability_watchdog_interval_minutes",
Expand Down Expand Up @@ -191,6 +191,8 @@ class Settings(BaseSettings):
schedule_generate_time: str = "00:15"
# 日报微信发送采用唯一全局批次时间;群级 send_time 仅保留数据库兼容。
schedule_send_time: str = "08:30"
# 运维按运行日期暂停发送;保留日期可防止次日恢复补发。
schedule_send_skip_dates: str = ""
schedule_email_time: str = "after_generate"
schedule_startup_catchup_enabled: bool = True
# 无人值守恢复只在进程启动时检查一次,不再注册固定频率 Watchdog。
Expand All @@ -211,6 +213,9 @@ class Settings(BaseSettings):
output_root_override: str = ""

# 路径
def is_send_skipped(self, run_date: str) -> bool:
return run_date in {value.strip() for value in self.schedule_send_skip_dates.split(",")}

@property
def data_dir(self) -> Path:
return PROJECT_ROOT / "data"
Expand Down
5 changes: 5 additions & 0 deletions app/pipeline/daily_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -617,6 +617,9 @@ def send_due_for_dates(
if now.tzinfo is not None:
now = now.astimezone(ZoneInfo(self.settings.app_timezone))
normalized_dates = sorted({validate_run_date(value) for value in run_dates})
normalized_dates = [value for value in normalized_dates if not self.settings.is_send_skipped(value)]
if not normalized_dates:
return []
results: list[dict] = []
groups = self._load_groups()
due_group_ids: list[int] = []
Expand Down Expand Up @@ -1512,6 +1515,8 @@ def force_send(
"error": "run_date 必须是有效的 YYYY-MM-DD 日期",
}
run_date = parsed_run_date.isoformat()
if self.settings.is_send_skipped(run_date):
return {"status": "failed", "error_type": "USER_SKIPPED_SEND_DATE", "error": "该日期已按用户要求暂停发送"}
self._last_name_sync_report = self._sync_group_names_safe([group_id])
group = self._get_group(group_id)
if not group:
Expand Down
19 changes: 17 additions & 2 deletions app/scheduler/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ def allowed(run_date: str, run: dict) -> bool:
)
scheduled.append(job_id)

if today not in selected_dates:
if today not in selected_dates or settings.is_send_skipped(today):
return scheduled

send_clock = _parse_send_time(settings.schedule_send_time)
Expand Down Expand Up @@ -268,7 +268,7 @@ def run_scheduled_daily_v2_job(


def run_scheduled_send_batch(run_date: str | None = None) -> dict:
"""08:30 核心批次和按需补偿共用的串行发送入口。"""
"""定时批次和按需补偿共用的串行发送入口。"""
settings = get_settings()
now = _normalize_now(settings)
target_date = run_date or now.date().isoformat()
Expand Down Expand Up @@ -462,5 +462,20 @@ def stop_scheduler() -> None:
logger.info("调度已停止")


def reschedule_send_batch(settings: Settings) -> None:
"""更新下一次定时触发;保存设置本身不会触发发送或补发。"""
scheduler = get_scheduler()
if scheduler is None:
return
send_time = _parse_send_time(settings.schedule_send_time)
scheduler.reschedule_job(
"daily_wechat_send_batch",
trigger=CronTrigger(
hour=send_time.hour, minute=send_time.minute, second=0,
timezone=ZoneInfo(settings.app_timezone),
),
)


def get_scheduler() -> BackgroundScheduler | None:
return _scheduler
38 changes: 38 additions & 0 deletions frontend/e2e/send-schedule.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { expect, test } from "@playwright/test";

test("首页可保存发送时间,失败保留输入且暂停日期不变", async ({ page }) => {
let time = "08:30";
let fail = true;
const writes: unknown[] = [];
await page.route("**/api/**", async (route) => {
const req = route.request();
const path = new URL(req.url()).pathname;
let body: unknown = {};
if (path === "/api/settings") {
if (req.method() === "PUT") {
writes.push(req.postDataJSON());
if (fail) return route.fulfill({ status: 500, body: "failed" });
time = req.postDataJSON().values.schedule_send_time;
body = { ok: true };
} else body = { schedule_send_time: time, schedule_send_skip_dates: "2026-09-23" };
} else if (path === "/api/v2/dashboard") {
body = { cards: [], counts: { pending: 0, generated: 0, sent: 0, failed: 0, held: 0 }, should_run: true };
} else if (path.includes("health")) body = { checks: {}, warnings: [] };
else if (path.includes("ready")) body = { ready: true, checks: {} };
await route.fulfill({ contentType: "application/json", body: JSON.stringify(body) });
});
await page.goto("/#/");
await page.getByRole("button", { name: "发送时间", exact: true }).click();
await expect(page.getByLabel("每日发送时间")).toHaveValue("08:30");
await page.getByLabel("每日发送时间").fill("10:00");
await page.getByRole("button", { name: "保存发送时间", exact: true }).click();
await expect(page.getByRole("alert")).toContainText("保存失败");
await expect(page.getByLabel("每日发送时间")).toHaveValue("10:00");
fail = false;
await page.getByRole("button", { name: "保存发送时间", exact: true }).click();
await expect(page.getByRole("dialog")).toHaveCount(0);
expect(writes).toEqual([{ values: { schedule_send_time: "10:00" } }, { values: { schedule_send_time: "10:00" } }]);
await page.getByRole("button", { name: "发送时间", exact: true }).click();
await expect(page.getByLabel("每日发送时间")).toHaveValue("10:00");
await expect(page.getByText("已暂停发送的日报日期:", { exact: false })).toContainText("2026-09-23");
});
2 changes: 2 additions & 0 deletions frontend/src/pages/v2/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
} from "@phosphor-icons/react";
import { shanghaiDateInputValue } from "../../date";
import { runtimeRefreshDelay } from "./dashboardRuntime";
import SendScheduleButton from "./SendScheduleButton";

const STATUS_META: Record<
string,
Expand Down Expand Up @@ -275,6 +276,7 @@ export default function Dashboard() {
description="让群聊里的灵感与热闹,成为值得收藏的日报。"
actions={
<>
<SendScheduleButton onSaved={refresh} />
<label className="studio-date">
<span>运行日期</span>
<input
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/pages/v2/ReportActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,8 @@ function TaskActions({
微信发送未启用
</Button>
)}
{card.send_hold && card.image_delivery_eligible !== false && (
{card.send_hold_reason === "USER_SKIPPED_SEND_DATE" && <span>该日已暂停发送</span>}
{card.send_hold && card.send_hold_reason !== "USER_SKIPPED_SEND_DATE" && card.image_delivery_eligible !== false && (
<Button
tone="secondary"
className="ui-button-compact"
Expand Down
60 changes: 60 additions & 0 deletions frontend/src/pages/v2/SendScheduleButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { useState } from "react";
import { Clock } from "@phosphor-icons/react";
import { getSettings, saveSettings } from "../../api";
import { Button, ConfirmDialog, Toast } from "../../components/common";
import { useToast } from "../../components/ui";

export default function SendScheduleButton({ onSaved }: { onSaved: () => void }) {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [time, setTime] = useState("");
const [skipped, setSkipped] = useState("");
const [error, setError] = useState("");
const { msg, toast } = useToast();

const edit = async () => {
setLoading(true);
setError("");
try {
const values = await getSettings();
setTime(values.schedule_send_time);
setSkipped(values.schedule_send_skip_dates || "");
setOpen(true);
} catch {
toast("读取发送时间失败,请重试");
} finally {
setLoading(false);
}
};

const save = async () => {
if (!/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(time)) {
setError("请选择有效的发送时间");
return;
}
setSaving(true);
setError("");
try {
await saveSettings({ schedule_send_time: time });
const current = await getSettings();
setOpen(false);
toast(`发送时间已保存为每天 ${current.schedule_send_time}(北京时间)`);
onSaved();
} catch {
setError("保存失败,请重试");
} finally {
setSaving(false);
}
};

return <>
<Button tone="secondary" busy={loading} onClick={() => void edit()}><Clock size={17} />发送时间</Button>
<ConfirmDialog open={open} title="自定义发送时间" description="所有群统一使用北京时间,按顺序发送。保存后无需重启;若所选时间今天已过,从明天开始,不会立即补发。" confirmLabel="保存发送时间" busy={saving} onConfirm={() => void save()} onCancel={() => setOpen(false)}>
<label className="settings-field">每日发送时间<input type="time" aria-label="每日发送时间" value={time} disabled={saving} onChange={(event) => setTime(event.target.value)} /></label>
{skipped && <p>已暂停发送的日报日期:{skipped}。修改时间不会解除暂停。</p>}
{error && <p role="alert">{error}</p>}
</ConfirmDialog>
<Toast message={msg} />
</>;
}
7 changes: 4 additions & 3 deletions frontend/src/pages/v2/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ const LABELS: Record<string, string> = {
email_use_ssl: "SMTP 使用 SSL(V1 兼容)",
email_send_partial_report: "允许部分群邮件(V1 兼容)",
schedule_generate_time: "每日生成时间",
schedule_send_time: "每日微信发送时间(北京时间)",
schedule_email_time: "邮件时间(V1 兼容)",
};

Expand Down Expand Up @@ -110,9 +111,9 @@ const SETTING_GROUPS = [
{
id: "legacy",
title: "邮件与每日调度",
description: "每日 00:15 生成前一日群报,08:30 按稳定群 ID 串行发送;邮件字段保留兼容。",
description: "在指定时间按群串行发送,修改发送时间后立即更新后续调度,无需重启。邮件字段保留兼容。",
icon: Clock,
keys: ["email_enabled", "email_recipient", "email_from", "email_smtp_host", "email_smtp_port", "email_smtp_user", "email_smtp_password", "email_use_ssl", "email_send_partial_report", "schedule_generate_time", "schedule_email_time"],
keys: ["schedule_send_time", "schedule_generate_time", "email_enabled", "email_recipient", "email_from", "email_smtp_host", "email_smtp_port", "email_smtp_user", "email_smtp_password", "email_use_ssl", "email_send_partial_report", "schedule_email_time"],
},
] as const;

Expand Down Expand Up @@ -154,7 +155,7 @@ function SettingField({
}) {
const sensitive = SENSITIVE_KEYS.has(name);
const boolean = BOOLEAN_KEYS.has(name) || value === "true" || value === "false";
const inputType = sensitive ? "password" : NUMBER_KEYS.has(name) ? "number" : "text";
const inputType = sensitive ? "password" : name === "schedule_send_time" ? "time" : NUMBER_KEYS.has(name) ? "number" : "text";
return (
<div className={`settings-field ${boolean ? "is-switch" : ""}`}>
{boolean ? (
Expand Down
63 changes: 63 additions & 0 deletions tests/test_send_schedule_settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo

import pytest
from fastapi import HTTPException
from sqlmodel import Session

from app.api import settings as api
from app.config.settings import Settings
from app.db import repository as repo
from app.pipeline.daily_pipeline import DailyPipeline
from app.scheduler import manager


@pytest.mark.parametrize("value", ["", "24:00", "09:60", "9:30", "10:00:00", " 10:00"])
def test_invalid_send_time_does_not_write(monkeypatch, value):
runtime = Settings(_env_file=None)
monkeypatch.setattr(api, "get_runtime_settings", lambda: runtime)
monkeypatch.setattr(repo, "set_setting_value", lambda *args: pytest.fail("invalid write"))
with pytest.raises(HTTPException) as error:
api.update_settings(api.SettingsPayload(values={"schedule_send_time": value}), object())
assert error.value.status_code == 422
assert runtime.schedule_send_time == "08:30"


def test_save_reschedules_and_survives_restart(monkeypatch):
runtime = Settings(_env_file=None, reliability_watchdog_enabled=False)
repo.init_db(runtime)
monkeypatch.setattr(api, "get_runtime_settings", lambda: runtime)
scheduler = manager.start_scheduler(runtime)
scheduler.pause()
# A time already passed today must become tomorrow's trigger, without a send.
now = datetime.now(ZoneInfo(runtime.app_timezone))
selected = (now - timedelta(minutes=1)).strftime("%H:%M")
try:
with Session(repo.engine) as session:
api.update_settings(api.SettingsPayload(values={"schedule_send_time": selected}), session)
assert api.get_settings(session)["schedule_send_time"] == selected
job = scheduler.get_job("daily_wechat_send_batch")
assert job.next_run_time > now
assert job.next_run_time.strftime("%H:%M") == selected
assert len(scheduler.get_jobs()) == 2
restored = Settings(_env_file=None)
repo.apply_db_settings(restored)
assert restored.schedule_send_time == selected
finally:
manager.stop_scheduler()
with Session(repo.engine) as session:
repo.set_setting_value(session, "schedule_send_time", "08:30")


def test_skipped_date_blocks_auto_recovery_and_manual_send(monkeypatch):
runtime = Settings(_env_file=None, schedule_send_skip_dates="2026-09-23")
pipeline = object.__new__(DailyPipeline)
pipeline.settings = runtime
monkeypatch.setattr(pipeline, "_load_groups", lambda: pytest.fail("must stop before loading groups"))
monkeypatch.setattr(pipeline, "_sync_group_names_safe", lambda *args: pytest.fail("must not contact WeChat"))
assert pipeline.send_due_for_dates(["2026-09-23"], recovery=True) == []
assert pipeline.force_send(23, "2026-09-23", confirm_late_send=True, confirm_regenerated=True)["error_type"] == "USER_SKIPPED_SEND_DATE"
assert runtime.is_send_skipped("2026-09-23")
assert not runtime.is_send_skipped("2026-09-24")
runtime.apply_runtime_values({"schedule_send_time": "10:00", "schedule_send_skip_dates": ""})
assert runtime.is_send_skipped("2026-09-23")