Skip to content

Commit a5b6c20

Browse files
committed
重复任务 cron 表达式支持六位(秒)
任务发送成功后弹出消息
1 parent eaa752f commit a5b6c20

5 files changed

Lines changed: 177 additions & 33 deletions

File tree

app.py

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
from flask import Flask, request, jsonify, send_from_directory
1+
from flask import Flask, request, jsonify, send_from_directory, Response
22
from flask_cors import CORS
33
from datetime import datetime
44
from models import init_db, get_db, NotifyTask, NotifyChannel, NotifyStatus, User, UserChannel
5-
from scheduler import scheduler
5+
from scheduler import scheduler, get_cron_trigger, event_manager
66
from auth import login_required, admin_required, user_login, user_register, update_user_profile
77
import json
88
import os
9+
import jwt
910

1011
app = Flask(__name__, static_folder='static')
1112
CORS(app) # 启用跨域支持
@@ -153,8 +154,7 @@ def create_task():
153154
return jsonify({'error': '重复任务必须提供 cron_expression'}), 400
154155
# 由 cron 计算下一次运行时间(用于列表展示与排序)
155156
try:
156-
from apscheduler.triggers.cron import CronTrigger
157-
trigger = CronTrigger.from_crontab(cron_expression)
157+
trigger = get_cron_trigger(cron_expression)
158158
next_run = trigger.get_next_fire_time(None, datetime.now())
159159
if not next_run:
160160
return jsonify({'error': '无法根据 cron_expression 计算下一次执行时间'}), 400
@@ -356,8 +356,7 @@ def update_task(task_id):
356356
# 恢复时重新计算下一次执行时间
357357
if task.is_recurring and task.cron_expression:
358358
try:
359-
from apscheduler.triggers.cron import CronTrigger
360-
trigger = CronTrigger.from_crontab(task.cron_expression)
359+
trigger = get_cron_trigger(task.cron_expression)
361360
next_run = trigger.get_next_fire_time(None, datetime.now())
362361
if next_run:
363362
task.scheduled_time = next_run
@@ -391,8 +390,7 @@ def update_task(task_id):
391390
# 关键:如果是重复任务,根据 cron 表达式重新计算下一次执行时间
392391
if task.is_recurring and task.cron_expression:
393392
try:
394-
from apscheduler.triggers.cron import CronTrigger
395-
trigger = CronTrigger.from_crontab(task.cron_expression)
393+
trigger = get_cron_trigger(task.cron_expression)
396394
# 以当前时间为基准,计算下一次执行时间
397395
next_run = trigger.get_next_fire_time(None, datetime.now())
398396
if next_run:
@@ -698,6 +696,34 @@ def health_check():
698696
})
699697

700698

699+
@app.route('/api/events')
700+
def sse_events():
701+
"""服务器发送事件 (SSE) 端点"""
702+
token = request.args.get('token')
703+
if not token:
704+
return jsonify({'error': 'Missing token'}), 401
705+
706+
try:
707+
payload = jwt.decode(token, app.config['SECRET_KEY'], algorithms=['HS256'])
708+
# 尝试获取 user_id,兼容常见的 payload key
709+
user_id = payload.get('user_id') or payload.get('id') or payload.get('sub')
710+
if not user_id:
711+
return jsonify({'error': 'Invalid token payload'}), 401
712+
except Exception:
713+
return jsonify({'error': 'Invalid token'}), 401
714+
715+
def stream():
716+
messages = event_manager.listen(user_id)
717+
try:
718+
while True:
719+
msg = messages.get()
720+
yield f"data: {json.dumps(msg, ensure_ascii=False)}\n\n"
721+
except GeneratorExit:
722+
pass
723+
724+
return Response(stream(), mimetype='text/event-stream')
725+
726+
701727
if __name__ == '__main__':
702728
try:
703729
app.run(host='0.0.0.0', port=8080, debug=True)

scheduler.py

Lines changed: 83 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,49 @@
55
from models import NotifyTask, NotifyStatus, get_db
66
from notifier import NotificationSender, parse_config
77
import logging
8+
import queue
89

910
logging.basicConfig(level=logging.INFO)
1011
logger = logging.getLogger(__name__)
1112

1213

14+
class EventManager:
15+
def __init__(self):
16+
# listeners: list of (queue, user_id)
17+
self.listeners = []
18+
19+
def listen(self, user_id):
20+
q = queue.Queue(maxsize=10)
21+
self.listeners.append((q, user_id))
22+
return q
23+
24+
def announce(self, user_id, msg):
25+
for i in range(len(self.listeners) - 1, -1, -1):
26+
q, uid = self.listeners[i]
27+
if uid == user_id:
28+
try:
29+
q.put_nowait(msg)
30+
except queue.Full:
31+
del self.listeners[i]
32+
33+
event_manager = EventManager()
34+
35+
36+
def get_cron_trigger(expression):
37+
"""根据 cron 表达式获取触发器,支持 5 位 (分时日月周) 和 6 位 (秒分时日月周)"""
38+
values = expression.strip().split()
39+
if len(values) == 6:
40+
return CronTrigger(
41+
second=values[0],
42+
minute=values[1],
43+
hour=values[2],
44+
day=values[3],
45+
month=values[4],
46+
day_of_week=values[5]
47+
)
48+
return CronTrigger.from_crontab(expression)
49+
50+
1351
class NotifyScheduler:
1452
"""通知调度器"""
1553

@@ -27,23 +65,36 @@ def add_task(self, task: NotifyTask):
2765
"""
2866
if task.is_recurring and task.cron_expression:
2967
# 重复任务,使用 cron 表达式
30-
trigger = CronTrigger.from_crontab(task.cron_expression)
31-
job_id = f"recurring_task_{task.id}"
68+
try:
69+
trigger = get_cron_trigger(task.cron_expression)
70+
job_id = f"recurring_task_{task.id}"
71+
72+
self.scheduler.add_job(
73+
func=self._execute_task,
74+
trigger=trigger,
75+
args=[task.id],
76+
id=job_id,
77+
replace_existing=True,
78+
misfire_grace_time=60 # 错过时间窗口60秒内仍执行
79+
)
80+
logger.info(f"任务 {task.id} 已添加到调度器,计划执行时间: {task.scheduled_time}")
81+
except Exception as e:
82+
logger.error(f"添加任务 {task.id} 失败,Cron 表达式无效: {e}")
3283
else:
3384
# 一次性任务,使用指定时间
3485
trigger = DateTrigger(run_date=task.scheduled_time)
3586
job_id = f"task_{task.id}"
3687

37-
self.scheduler.add_job(
38-
func=self._execute_task,
39-
trigger=trigger,
40-
args=[task.id],
41-
id=job_id,
42-
replace_existing=True,
43-
misfire_grace_time=60 # 错过时间窗口60秒内仍执行
44-
)
45-
46-
logger.info(f"任务 {task.id} 已添加到调度器,计划执行时间: {task.scheduled_time}")
88+
self.scheduler.add_job(
89+
func=self._execute_task,
90+
trigger=trigger,
91+
args=[task.id],
92+
id=job_id,
93+
replace_existing=True,
94+
misfire_grace_time=60 # 错过时间窗口60秒内仍执行
95+
)
96+
97+
logger.info(f"任务 {task.id} 已添加到调度器,计划执行时间: {task.scheduled_time}")
4798

4899
def remove_task(self, task_id: int, is_recurring: bool = False):
49100
"""
@@ -108,8 +159,7 @@ def _execute_task(self, task_id: int):
108159
# 关键:重复任务执行成功后,滚动更新下一次执行时间(用于列表展示)
109160
if task.is_recurring and task.cron_expression:
110161
try:
111-
from apscheduler.triggers.cron import CronTrigger
112-
trigger = CronTrigger.from_crontab(task.cron_expression)
162+
trigger = get_cron_trigger(task.cron_expression)
113163
# 以"本次实际执行时间"为基准,计算下一次
114164
base_time = datetime.now()
115165
next_run = trigger.get_next_fire_time(None, base_time)
@@ -120,12 +170,30 @@ def _execute_task(self, task_id: int):
120170
logger.warning(f"任务 {task_id} 更新下一次执行时间失败: {str(e)}")
121171

122172
logger.info(f"任务 {task_id} 执行成功")
173+
174+
# 通知前端
175+
event_manager.announce(task.user_id, {
176+
'type': 'task_executed',
177+
'task_id': task.id,
178+
'title': task.title,
179+
'status': 'sent',
180+
'message': '发送成功'
181+
})
123182

124183
except Exception as e:
125184
# 更新任务状态为失败
126185
task.status = NotifyStatus.FAILED
127186
task.error_msg = str(e)
128187
logger.error(f"任务 {task_id} 执行失败: {str(e)}")
188+
189+
# 通知前端
190+
event_manager.announce(task.user_id, {
191+
'type': 'task_executed',
192+
'task_id': task.id,
193+
'title': task.title,
194+
'status': 'failed',
195+
'message': str(e)
196+
})
129197

130198
db.commit()
131199

@@ -155,7 +223,7 @@ def load_pending_tasks(self):
155223
# 如果是重复任务且计划时间已过,重新计算下一次执行时间
156224
if task.is_recurring and task.cron_expression and task.scheduled_time < datetime.now():
157225
try:
158-
trigger = CronTrigger.from_crontab(task.cron_expression)
226+
trigger = get_cron_trigger(task.cron_expression)
159227
next_run = trigger.get_next_fire_time(None, datetime.now())
160228
if next_run:
161229
task.scheduled_time = next_run

static/index.html

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ <h2>创建通知任务</h2>
141141

142142
<div class="form-group">
143143
<label for="scheduledTime">计划发送时间 *</label>
144-
<input type="datetime-local" id="scheduledTime" name="scheduledTime" required>
144+
<input type="datetime-local" id="scheduledTime" name="scheduledTime" required step="1">
145145
</div>
146146

147147
<div class="form-group">
@@ -153,9 +153,9 @@ <h2>创建通知任务</h2>
153153

154154
<div class="form-group" id="cronGroup" style="display: none;">
155155
<label for="cronExpression">Cron 表达式</label>
156-
<input type="text" id="cronExpression" name="cronExpression" placeholder="如: 0 9 * * * (每天9点)">
156+
<input type="text" id="cronExpression" name="cronExpression" placeholder="如: 0 9 * * * (每天9点) 或 0/30 * * * * * (每30秒)">
157157
<small style="color: var(--text-muted); display: block; margin-top: 5px;">
158-
示例: 0 9 * * * (每天9点) | 0 */2 * * * (每2小时) | 0 9 * * 1 (每周一9点)
158+
示例: 0 9 * * * (每天9点) | 0 */2 * * * (每2小时) | */30 * * * * * (每30秒)
159159
</small>
160160
</div>
161161

@@ -319,7 +319,7 @@ <h2>编辑任务</h2>
319319

320320
<div class="form-group">
321321
<label for="editScheduledTime">计划发送时间 *</label>
322-
<input type="datetime-local" id="editScheduledTime" name="scheduledTime" required>
322+
<input type="datetime-local" id="editScheduledTime" name="scheduledTime" required step="1">
323323
</div>
324324

325325
<div class="form-group">

static/js/app.js

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ const API_BASE = '/api';
22
let channels = [];
33
let currentUser = null;
44
let userChannels = [];
5+
let eventSource = null;
56

67
// 初始化
78
document.addEventListener('DOMContentLoaded', function() {
@@ -54,6 +55,7 @@ function showMainApp() {
5455
loadTasks();
5556
initAppEvents();
5657
setDefaultTime();
58+
initSSE();
5759

5860
// 尝试加载日历(如果存在日历脚本且在主界面显示后)
5961
if (typeof window.loadCalendar === 'function') {
@@ -154,9 +156,53 @@ async function handleRegister(e) {
154156
function logout() {
155157
localStorage.removeItem('token');
156158
currentUser = null;
159+
if (eventSource) {
160+
eventSource.close();
161+
eventSource = null;
162+
}
157163
checkAuthStatus();
158164
}
159165

166+
// 初始化 SSE
167+
function initSSE() {
168+
if (eventSource) {
169+
eventSource.close();
170+
}
171+
172+
const token = localStorage.getItem('token');
173+
if (!token) return;
174+
175+
// 使用 query param 传递 token
176+
eventSource = new EventSource(`${API_BASE}/events?token=${token}`);
177+
178+
eventSource.onmessage = function(event) {
179+
try {
180+
const data = JSON.parse(event.data);
181+
if (data.type === 'task_executed') {
182+
const type = data.status === 'sent' ? 'success' : 'error';
183+
const msgPrefix = data.status === 'sent' ? '✅' : '❌';
184+
showNotification(`${msgPrefix} 任务 "${data.title}" 执行完成: ${data.message}`, type);
185+
186+
// 刷新列表
187+
loadTasks();
188+
189+
// 刷新日历
190+
if (typeof window.loadCalendar === 'function') {
191+
delete window.__TASKS_CACHE;
192+
window.loadCalendar();
193+
}
194+
}
195+
} catch (e) {
196+
console.error('SSE parse error', e);
197+
}
198+
};
199+
200+
eventSource.onerror = function(err) {
201+
// 连接错误时,EventSource 会自动重连,这里仅记录
202+
console.log('SSE connection error/closed');
203+
};
204+
}
205+
160206
// 初始化应用事件
161207
function initAppEvents() {
162208
document.getElementById('taskForm').addEventListener('submit', submitTaskForm);
@@ -924,7 +970,8 @@ async function submitTaskForm(e) {
924970
content: formData.get('content'),
925971
channel: channel,
926972
// 重复任务不提交 scheduled_time,由后端根据 cron_expression 计算
927-
scheduled_time: isRecurring ? undefined : (scheduledTimeValue ? `${scheduledTimeValue}:00` : null),
973+
// 如果有秒(长度19)则直接使用,否则(长度16)补:00
974+
scheduled_time: isRecurring ? undefined : (scheduledTimeValue ? (scheduledTimeValue.length === 16 ? `${scheduledTimeValue}:00` : scheduledTimeValue) : null),
928975
channel_config: channelConfig,
929976
is_recurring: isRecurring,
930977
cron_expression: isRecurring ? cronForBackend : null
@@ -1139,13 +1186,15 @@ async function toggleTaskPause(taskId, action) {
11391186
function setDefaultTime() {
11401187
const now = new Date();
11411188
now.setHours(now.getHours() + 1);
1189+
now.setSeconds(0);
1190+
now.setMilliseconds(0);
11421191
document.getElementById('scheduledTime').value = toLocalInputValue(now);
11431192
}
11441193

11451194
// 将 Date 转为 datetime-local 可用的本地时间字符串
11461195
function toLocalInputValue(date) {
11471196
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60000);
1148-
return local.toISOString().slice(0, 16);
1197+
return local.toISOString().slice(0, 19);
11491198
}
11501199

11511200
// 显示通知
@@ -1169,7 +1218,8 @@ function formatDateTime(dateString) {
11691218
month: '2-digit',
11701219
day: '2-digit',
11711220
hour: '2-digit',
1172-
minute: '2-digit'
1221+
minute: '2-digit',
1222+
second: '2-digit'
11731223
});
11741224
}
11751225

@@ -1516,7 +1566,7 @@ function showEditTaskModal(task) {
15161566
const scheduledTime = new Date(task.scheduled_time);
15171567
// 转换为本地时间并格式化
15181568
const localTime = new Date(scheduledTime.getTime() - scheduledTime.getTimezoneOffset() * 60000);
1519-
document.getElementById('editScheduledTime').value = localTime.toISOString().slice(0, 16);
1569+
document.getElementById('editScheduledTime').value = localTime.toISOString().slice(0, 19);
15201570

15211571
// 设置重复任务信息
15221572
const isRecurringCheckbox = document.getElementById('editIsRecurring');
@@ -1675,7 +1725,7 @@ async function handleEditTaskSubmit(e) {
16751725
title: formData.get('title'),
16761726
content: formData.get('content'),
16771727
channel: channelType,
1678-
scheduled_time: scheduledTimeValue ? `${scheduledTimeValue}:00` : null,
1728+
scheduled_time: scheduledTimeValue ? (scheduledTimeValue.length === 16 ? `${scheduledTimeValue}:00` : scheduledTimeValue) : null,
16791729
channel_config: channelConfig
16801730
// 注意:不包含 is_recurring 和 cron_expression,因为后端不允许修改这些字段
16811731
};

version.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
version: 0.4.0
1+
version: 0.4.1

0 commit comments

Comments
 (0)