提交 1147566e authored 作者: 陈泽健's avatar 陈泽健

fix(service-monitor): 定时任务时区固定为 Asia/Shanghai

根因:5.60 服务器系统时区为 UTC,CronTrigger.from_crontab()
默认使用本地时区解析 cron,导致用户配的'每天 18:15'实际
在 UTC 18:15(北京时间凌晨 02:15)才触发。

修复:
- schedule_service.py 新增 SHANGHAI_TZ 常量
- _add_job() 的 CronTrigger 显式指定 Asia/Shanghai 时区
- _calc_next_run() 用北京时间作为基准计算下次执行
- start_date/end_date 用 SHANGHAI_TZ.localize() 转换
- 三处依赖同步声明 pytz(requirements / skill/requirements / upload_to_server)

验证:_calc_next_run('30 18 * * *') 返回北京时间 18:30;
5.60 next_run_at 现显示北京时间;51 用例全绿。
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 0d84df9f
# 计划执行:定时任务时区问题修复
## 文档信息
- 创建时间:2026-07-21
- 关联问题文档:`Docs/需求文档/服务监测/PRD_问题处理_定时任务时区问题.md`
- 预计改动文件:1 个
- 预计新增依赖:pytz
---
## 执行步骤
### Step 1:检查依赖
**操作**:确认 `pytz` 是否已在 requirements.txt 中声明。
**文件**
- `requirements.txt`
- `skill/code/requirements.txt`
- `deploy/upload_to_server.py``_REQUIRED_PACKAGES`
**预期结果**:如未声明,补充 `pytz>=2024.1`
---
### Step 2:修改 schedule_service.py
**文件**`skill/code/web/service_monitor/services/schedule_service.py`
#### 2.1 文件顶部导入
在现有导入后添加:
```python
from pytz import timezone as pytz_timezone
# 北京时区常量(全局使用)
SHANGHAI_TZ = pytz_timezone("Asia/Shanghai")
```
**位置**:L20 附近(在 `logger` 定义后)
---
#### 2.2 修改 `_calc_next_run()` 函数
**位置**:L144-154
**改前**
```python
def _calc_next_run(cron_expr: str) -> Optional[str]:
"""计算下次执行时间。"""
try:
from croniter import croniter
base = datetime.now()
iter = croniter(cron_expr, base)
next_dt = iter.get_next(datetime)
return next_dt.strftime("%Y-%m-%dT%H:%M:%S")
except Exception as e:
logger.warning("计算下次执行时间失败: %s", e)
return None
```
**改后**
```python
def _calc_next_run(cron_expr: str) -> Optional[str]:
"""计算下次执行时间(北京时间)。"""
try:
from croniter import croniter
base = datetime.now(SHANGHAI_TZ)
iter = croniter(cron_expr, base)
next_dt = iter.get_next(datetime)
# 确保返回北京时间字符串(无时区后缀)
if next_dt.tzinfo:
next_dt = next_dt.astimezone(SHANGHAI_TZ)
return next_dt.strftime("%Y-%m-%dT%H:%M:%S")
except Exception as e:
logger.warning("计算下次执行时间失败: %s", e)
return None
```
---
#### 2.3 修改 `_add_job()` 函数
**位置**:L412-446
**改前**
```python
def _add_job(sched: dict) -> None:
"""添加或更新单个 APScheduler job。"""
if _scheduler is None:
return
from apscheduler.triggers.cron import CronTrigger
job_id = f"schedule_{sched['id']}"
try:
trigger = CronTrigger.from_crontab(sched["cron"])
# 支持起止日期(APScheduler 使用 UTC 时区,需要统一)
start_date = sched.get("start_date")
end_date = sched.get("end_date")
if start_date:
# 转为 UTC 时区的 datetime
from datetime import timezone
trigger.start_date = datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
if end_date:
# end_date 当天也要执行,设为当天 23:59:59 UTC
from datetime import timezone
trigger.end_date = datetime.strptime(end_date, "%Y-%m-%d").replace(
hour=23, minute=59, second=59, tzinfo=timezone.utc
)
_scheduler.add_job(
func=_execute_scheduled_job,
trigger=trigger,
id=job_id,
args=[sched["id"]],
replace_existing=True,
)
logger.info("定时任务已注册: %s (%s)", sched["name"], sched["cron"])
except Exception as e:
logger.error("注册定时任务失败: %s - %s", sched["id"], e)
```
**改后**
```python
def _add_job(sched: dict) -> None:
"""添加或更新单个 APScheduler job(使用北京时区)。"""
if _scheduler is None:
return
from apscheduler.triggers.cron import CronTrigger
job_id = f"schedule_{sched['id']}"
try:
# 使用北京时区解析 cron 表达式
trigger = CronTrigger.from_crontab(sched["cron"], timezone=SHANGHAI_TZ)
# 支持起止日期(使用北京时区)
start_date = sched.get("start_date")
end_date = sched.get("end_date")
if start_date:
# 转为北京时区的 datetime
trigger.start_date = SHANGHAI_TZ.localize(
datetime.strptime(start_date, "%Y-%m-%d")
)
if end_date:
# end_date 当天也要执行,设为当天 23:59:59 北京时间
trigger.end_date = SHANGHAI_TZ.localize(
datetime.strptime(end_date, "%Y-%m-%d").replace(
hour=23, minute=59, second=59
)
)
_scheduler.add_job(
func=_execute_scheduled_job,
trigger=trigger,
id=job_id,
args=[sched["id"]],
replace_existing=True,
)
logger.info("定时任务已注册: %s (%s) [Asia/Shanghai]", sched["name"], sched["cron"])
except Exception as e:
logger.error("注册定时任务失败: %s - %s", sched["id"], e)
```
---
### Step 3:更新依赖声明
**文件**`requirements.txt`(根目录)
在现有依赖后添加:
```
pytz>=2024.1
```
**文件**`skill/code/requirements.txt`
在现有依赖后添加:
```
pytz>=2024.1
```
**文件**`deploy/upload_to_server.py`
`_REQUIRED_PACKAGES` 列表中添加 `'pytz'`
```python
_REQUIRED_PACKAGES = ['paramiko', 'cryptography', 'apscheduler', 'croniter', 'pytz']
```
---
### Step 4:本地测试
**命令**
```bash
cd skill/code && python -m pytest -v
cd skill/code && python -m pytest web/service_monitor/tests/ -v
```
**预期结果**:所有测试通过。
---
### Step 5:部署验证
**命令**
```bash
! cd deploy && SSH_PASSWORD='***' python upload_to_server.py
```
**验证**
1. 访问 `/api/health` 确认 scheduler.running=True
2. 创建测试任务"每天 18:15"
3. 检查 `next_run_at` 显示为 18:15(北京时间)
4. 等待触发时间验证实际执行
---
## 回滚方案
如出现异常,回滚步骤:
1. 还原 `schedule_service.py`
```bash
git checkout HEAD~1 -- skill/code/web/service_monitor/services/schedule_service.py
```
2. 重新部署
---
## 验收标准
| 标准 | 验证方法 |
|------|---------|
| 服务正常启动 | `/api/health` 返回 ok |
| scheduler 初始化成功 | `scheduler.running=True` |
| next_run_at 正确计算 | 创建任务后显示北京时间 |
| 定时任务按时触发 | 等待触发时间验证 |
---
## 注意事项
1. **已有任务**:修改部署后,已有定时任务会重新注册,触发时间会按北京时区重新计算。用户需确认时间是否正确。
2. **时区硬编码**:本文档采用硬编码 `Asia/Shanghai`,适用于中国地区部署。如需支持多时区,后续可扩展为配置项。
3. **夏令时**:中国无夏令时,无需考虑。
---
## 执行记录
| 步骤 | 状态 | 时间 | 备注 |
|------|------|------|------|
| Step 1: 检查依赖 | 待执行 | - | - |
| Step 2: 修改代码 | 待执行 | - | - |
| Step 3: 更新依赖 | 待执行 | - | - |
| Step 4: 本地测试 | 待执行 | - | - |
| Step 5: 部署验证 | 待执行 | - | - |
\ No newline at end of file
# 问题处理:定时任务时区问题
## 问题描述
用户配置定时任务"每天 18:15 执行",但实际触发时间是 UTC 18:15(北京时间凌晨 02:15),导致任务在预期时间之外触发。
## 问题现象
| 任务 | 配置时间 | 预期触发时间 | 实际触发时间 |
|------|---------|-------------|-------------|
| 5.44服务器全量巡检 | 每天 18:15 | 北京时间 18:15 | UTC 18:15 = 北京时间 02:15(次日凌晨)|
## 根因分析
### 1. 服务器时区
```
Time zone: Etc/UTC (UTC, +0000)
```
5.60 服务器系统时区为 UTC,无时区偏移。
### 2. CronTrigger 时区处理
`schedule_service.py``_add_job()` 函数:
```python
trigger = CronTrigger.from_crontab(sched["cron"])
```
`CronTrigger.from_crontab()` **默认使用本地系统时区**解析 cron 表达式。当服务器时区为 UTC 时:
- cron `15 18 * * *` 被解析为 UTC 18:15
- 北京时间(UTC+8)对应的实际触发时间是 02:15
### 3. next_run_at 计算
`_calc_next_run()` 使用 `datetime.now()` 作为基准计算下次执行时间,返回的是本地时间字符串(UTC),但页面展示时没有时区提示,用户误以为是北京时间。
## 影响范围
- **所有定时任务**都受影响
- 用户配置的"工作时间执行"(如工作日 09:30)实际会在北京时间 17:30 才触发
- 页面显示的"下次执行"时间无时区标识,容易误导
## 解决方案
### 方案 A:修改配置时间(临时方案)
用户手动计算 UTC 时间:北京时间 18:15 = UTC 10:15,配置为"每天 10:15"。
**缺点**:每个任务都要手动换算,不直观,易出错。
### 方案 B:固定使用 Asia/Shanghai 时区(根本方案)
修改 `schedule_service.py`,让 `CronTrigger``croniter` 统一使用 `Asia/Shanghai` 时区:
1. `CronTrigger.from_crontab()` 添加 `timezone` 参数
2. `_calc_next_run()``croniter` 使用 `Asia/Shanghai` 时区
3. `next_run_at` 显示时保持北京时间格式
**优点**:用户配置什么时间就什么时间执行,无需换算。
---
## 修复计划
### 改动文件
| 文件 | 改动内容 |
|------|---------|
| `skill/code/web/service_monitor/services/schedule_service.py` | `_add_job()``_calc_next_run()` 使用 `Asia/Shanghai` 时区 |
### 具体改动
#### 1. `_add_job()` 函数(L412-446)
```python
# 改前
trigger = CronTrigger.from_crontab(sched["cron"])
# 改后
from apscheduler.triggers.cron import CronTrigger
from pytz import timezone as pytz_timezone
SHANGHAI_TZ = pytz_timezone("Asia/Shanghai")
trigger = CronTrigger.from_crontab(sched["cron"], timezone=SHANGHAI_TZ)
```
#### 2. `_calc_next_run()` 函数(L144-154)
```python
# 改前
base = datetime.now()
iter = croniter(cron_expr, base)
# 改后
from pytz import timezone as pytz_timezone
SHANGHAI_TZ = pytz_timezone("Asia/Shanghai")
base = datetime.now(SHANGHAI_TZ)
iter = croniter(cron_expr, base)
```
#### 3. `start_date`/`end_date` 时区(L428-434)
```python
# 改前
trigger.start_date = datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
# 改后
trigger.start_date = SHANGHAI_TZ.localize(
datetime.strptime(start_date, "%Y-%m-%d")
)
```
### 依赖检查
- `pytz` 是否在 requirements.txt 中?—— 需检查并补充
- APScheduler 3.x 支持 `timezone` 参数 ✅
### 验证步骤
1. 本地启动服务,创建定时任务"每天 18:15"
2. 检查 `next_run_at` 是否为 18:15(北京时间)
3. 部署到 5.60,验证 scheduler 状态
4. 等待触发时间,确认实际执行时间与配置一致
---
## 风险评估
| 风险 | 影响 | 缓解措施 |
|------|------|---------|
| pytz 未安装 | 服务启动失败 | 检查依赖,部署脚本补充 |
| 已有任务时区变化 | 已注册任务触发时间改变 | 重新注册后生效,用户需确认 |
| 夏令时问题 | 无 | 中国无夏令时 |
---
## 文档信息
- 创建时间:2026-07-21
- 创建人:Claude
- 关联任务:定时任务执行状态展示 + 立即执行功能
- 关联提交:d10c77e4, 0d84df9f
\ No newline at end of file
......@@ -68,7 +68,7 @@ _EXCLUDE_SUFFIXES = ('.pyc', '.pyo')
_UNIX_LINE_END_SUFFIXES = ('.sh', '.template')
# 服务监测模块需要的 Python 依赖(SSH 远程执行 + 定时调度)
_REQUIRED_PACKAGES = ['paramiko', 'cryptography', 'apscheduler', 'croniter']
_REQUIRED_PACKAGES = ['paramiko', 'cryptography', 'apscheduler', 'croniter', 'pytz']
def _check_and_install_deps(ssh):
......
......@@ -17,3 +17,4 @@ cryptography==46.0.7
paramiko==4.0.0
apscheduler==3.11.0
croniter==6.2.4
pytz>=2024.1
......@@ -17,6 +17,7 @@ paramiko>=3.4.0
cryptography>=42.0.0
apscheduler>=3.10.0
croniter>=2.0.0
pytz>=2024.1
# 测试依赖(P1-2 单元测试)
pytest>=7.0.0
......
......@@ -19,6 +19,10 @@ from ..utils.paths import SCHEDULES_FILE, ensure_dirs
logger = logging.getLogger("service_monitor.schedule")
# 北京时区常量(定时任务统一使用此时区,不受服务器系统时区影响)
from pytz import timezone as _pytz_timezone
SHANGHAI_TZ = _pytz_timezone("Asia/Shanghai")
# 全局调度器(延迟初始化)
_scheduler = None
_scheduler_lock = threading.Lock()
......@@ -142,12 +146,15 @@ def _now() -> str:
def _calc_next_run(cron_expr: str) -> Optional[str]:
"""计算下次执行时间。"""
"""计算下次执行时间(北京时间)。"""
try:
from croniter import croniter
base = datetime.now()
base = datetime.now(SHANGHAI_TZ)
iter = croniter(cron_expr, base)
next_dt = iter.get_next(datetime)
# 确保返回北京时间字符串
if next_dt.tzinfo:
next_dt = next_dt.astimezone(SHANGHAI_TZ)
return next_dt.strftime("%Y-%m-%dT%H:%M:%S")
except Exception as e:
logger.warning("计算下次执行时间失败: %s", e)
......@@ -421,19 +428,23 @@ def _add_job(sched: dict) -> None:
job_id = f"schedule_{sched['id']}"
try:
trigger = CronTrigger.from_crontab(sched["cron"])
# 支持起止日期(APScheduler 使用 UTC 时区,需要统一)
# 使用北京时区解析 cron 表达式(不受服务器系统时区影响)
trigger = CronTrigger.from_crontab(sched["cron"], timezone=SHANGHAI_TZ)
# 支持起止日期(使用北京时区)
start_date = sched.get("start_date")
end_date = sched.get("end_date")
if start_date:
# 转为 UTC 时区的 datetime
from datetime import timezone
trigger.start_date = datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
# 转为北京时区的 datetime
trigger.start_date = SHANGHAI_TZ.localize(
datetime.strptime(start_date, "%Y-%m-%d")
)
if end_date:
# end_date 当天也要执行,设为当天 23:59:59 UTC
from datetime import timezone
trigger.end_date = datetime.strptime(end_date, "%Y-%m-%d").replace(
hour=23, minute=59, second=59, tzinfo=timezone.utc
# end_date 当天也要执行,设为当天 23:59:59 北京时间
trigger.end_date = SHANGHAI_TZ.localize(
datetime.strptime(end_date, "%Y-%m-%d").replace(
hour=23, minute=59, second=59
)
)
_scheduler.add_job(
......@@ -443,7 +454,7 @@ def _add_job(sched: dict) -> None:
args=[sched["id"]],
replace_existing=True,
)
logger.info("定时任务已注册: %s (%s)", sched["name"], sched["cron"])
logger.info("定时任务已注册: %s (%s) [Asia/Shanghai]", sched["name"], sched["cron"])
except Exception as e:
logger.error("注册定时任务失败: %s - %s", sched["id"], e)
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论