# 计划执行：定时任务时区问题修复

## 文档信息

- 创建时间：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: 部署验证 | 待执行 | - | - |