diff --git a/docs/audit/2026-06-08-beijing-time-recovery-telegram.md b/docs/audit/2026-06-08-beijing-time-recovery-telegram.md new file mode 100644 index 00000000..f395c690 --- /dev/null +++ b/docs/audit/2026-06-08-beijing-time-recovery-telegram.md @@ -0,0 +1,111 @@ +# 审计 — 北京时间展示统一 + 资源恢复 Telegram 去重(2026-06-08) + +**Changelog**: +- `docs/changelog/2026-06-08-beijing-time-display-unified.md` +- `docs/changelog/2026-06-08-telegram-recovery-dedup-timezone.md` + +**触发原因**: Bug 修复(恢复通知秒级重复、Telegram/UI 时间非北京时间)+ 展示层时区统一 + +## 审计范围 + +| 文件 | 行数 | 状态 | +|------|------|------| +| `server/utils/display_time.py` | 27 | ☑ 已审 | +| `server/infrastructure/telegram/__init__.py` | ~320 | ☑ 已审(`_now_cn` 段) | +| `server/background/ip_allowlist_refresh.py` | ~100 | ☑ 已审(刷新时间) | +| `server/api/settings.py` | ~1570 | ☑ 已审(Telegram 测试消息) | +| `server/api/agent.py` | ~385 | ☑ 已审(恢复去重) | +| `frontend/src/utils/datetime.ts` | 85 | ☑ 已审 | +| `frontend/src/utils/status.ts` | 36 | ☑ 已审 | +| `frontend/src/composables/push/labels.ts` | 68 | ☑ 已审 | +| `frontend/src/composables/useWebSocket.ts` | ~190 | ☑ 已审 | +| `frontend/src/pages/AuditPage.vue` | ~196 | ☑ 已审 | +| `frontend/src/pages/AlertsPage.vue` | ~190 | ☑ 已审 | +| `frontend/src/pages/CommandsPage.vue` | ~393 | ☑ 已审 | +| `frontend/src/pages/RetriesPage.vue` | ~180 | ☑ 已审 | +| `frontend/src/pages/SchedulesPage.vue` | ~533 | ☑ 已审 | +| `frontend/src/pages/ScriptsPage.vue` | ~1315 | ☑ 已审(时间列) | +| `frontend/src/pages/ScriptRunsPage.vue` | ~681 | ☑ 已审 | +| `frontend/src/pages/ServersPage.vue` | ~1400 | ☑ 已审(导出文件名) | +| `frontend/src/components/credentials/CredentialsManager.vue` | ~436 | ☑ 已审 | +| `frontend/src/components/servers/ServerInlineDetail.vue` | ~158 | ☑ 已审 | +| `tests/test_display_time.py` | 18 | ☑ 已审 | +| `tests/test_telegram_time_format.py` | 20 | ☑ 已审 | +| `tests/test_agent_recovery_dedup.py` | 42 | ☑ 已审 | + +## 审计8步结果 + +### Step 1: 登记 ✅ + +两项用户反馈:① Telegram 恢复时间显示 UTC;② 同一指标 1 秒内连发多条恢复通知。 + +### Step 2: 全文 Read ✅ + +上述文件全文走读;`broadcast_recovery` / `websocket.py` 交叉确认恢复无 Telegram cooldown 设计意图(恢复应即时推送,根因在 Redis 成员重复)。 + +### Step 3: 规则扫描 H + +| ID | 规则 | 命中点 | 初判 | +|----|------|--------|------| +| H1 | 静默吞错 | `agent.py` Redis 告警跟踪 `except: debug` | SAFE — 已有模式,告警主路径仍广播 | +| H2 | 时区一致性 | `parseApiDateTime` 假定 naive = UTC | SAFE — 与 `sync_log_repo`、ORM `_utcnow` 一致 | +| H3 | 去重完整性 | Redis legacy `mem:92` 多条 | SAFE — `_detect_recovery` 按 metric 去重 + srem 全清 | +| H4 | XSS / 注入 | 前端 `formatDateTimeBeijing` 仅格式化 | SAFE — Vue 文本插值,无 `v-html` | +| H5 | 密钥泄露 | `display_time` / `datetime.ts` | SAFE — 无凭据 | +| H6 | CUD 审计 | 无新 CUD 路径 | N/A | +| H7 | 多 worker | 恢复 Telegram 仍无 Redis 锁 | LOW — 单心跳内重复已根治;跨 worker 同毫秒双 POST 概率极低,接受 | + +### Step 4: Closure表 + +| H | 判定 | 依据 | +|---|------|------| +| H1 | SAFE | 跟踪失败不影响 `broadcast_alert`;日志可见 | +| H2 | SAFE | DB/API 约定 UTC;文档与 changelog 已说明 | +| H3 | SAFE | 单测 `test_detect_recovery_dedupes_legacy_metric_value_entries` | +| H4 | SAFE | 展示层数值格式化 | +| H5 | SAFE | — | +| H6 | N/A | — | +| H7 | ACCEPT | 根因修复;若后续再现可加 `recovery_sent:{id}:{metric}` SETNX | + +### Step 5: 入口表 + +| 入口 | 变更 | +|------|------| +| `POST /api/agent/heartbeat` | Redis `alerts:{id}` 成员格式、恢复检测 | +| Telegram 出站 | `_now_cn` → `format_beijing_now` | +| `POST .../telegram/test` | 测试消息时间 | +| 前端各列表页 | 只读展示,无新 API | + +### Step 6: 输入→Sink + +- Agent `system_info` → 阈值比较 → Redis set / `broadcast_recovery` → Telegram:无新 sink;去重降低 Telegram 频率。 +- API `created_at` 字符串 → `parseApiDateTime` → `Intl` 北京时间:只读展示。 + +### Step 7: 归类 + +``` +display_time.py 27行 2H 0 FINDING +agent.py (recovery) ~50行 3H 0 FINDING +datetime.ts 85行 2H 0 FINDING +前端 10 文件 ~40行 1H 0 FINDING +测试 3 文件 80行 0H 0 FINDING +────────────────────────────────────────── +总计 8H 0 FINDING(1 ACCEPT) +``` + +### Step 8: DoD ✅ + +- [x] 恢复通知同一指标单次心跳最多 1 条 Telegram +- [x] Telegram / 审计页 / 告警页等绝对时间为北京时间 +- [x] DB 仍存 UTC,未改调度/漂移计算 +- [x] `pytest` 7 项通过(`test_display_time` / `test_telegram_time_format` / `test_agent_recovery_dedup`) +- [x] `npm run type-check` 通过 +- [ ] 生产部署与浏览器终验(待用户批准 deploy) + +## FINDING 列表 + +无。H7 为风险接受项,非 FINDING。 + +## 结论 + +☑ **审计通过,0 FINDING,可进入部署门控**(需 commit + `pre_deploy_check.sh` + 前端 build) diff --git a/docs/changelog/2026-06-08-beijing-time-display-unified.md b/docs/changelog/2026-06-08-beijing-time-display-unified.md new file mode 100644 index 00000000..57ecc571 --- /dev/null +++ b/docs/changelog/2026-06-08-beijing-time-display-unified.md @@ -0,0 +1,40 @@ +# 2026-06-08 — 全站用户可见时间统一为北京时间 + +## 变更摘要 + +存储与计算仍用 UTC;所有面向运维的**展示层**(Web 表格、Telegram、设置页刷新时间等)统一为 `Asia/Shanghai` 北京时间。 + +## 动机 + +用户要求不论浏览器时区,界面与通知时间与国内运维习惯一致。 + +## 方案 + +- **后端**:`server/utils/display_time.py`(`format_beijing` / `format_beijing_now`);Telegram、IP 白名单刷新时间、Telegram 测试消息接入 +- **前端**:`frontend/src/utils/datetime.ts`(`parseApiDateTime` 将 MySQL naive UTC 正确解析 + `formatDateTimeBeijing` 等);15 页相关表格/内联详情/WS 告警条改用该工具 + +## 涉及文件 + +- `server/utils/display_time.py`(新) +- `server/infrastructure/telegram/__init__.py` +- `server/background/ip_allowlist_refresh.py` +- `server/api/settings.py` +- `frontend/src/utils/datetime.ts`(新) +- `frontend/src/utils/status.ts` +- `frontend/src/composables/push/labels.ts` +- `frontend/src/composables/useWebSocket.ts` +- 多页 Vue:`Alerts` `Audit` `Commands` `Retries` `Schedules` `Scripts` `ScriptRuns` `Servers` 及凭据/子机内联组件 + +## 迁移 / 重启 + +- 无 DB 迁移 +- 需重新构建前端并重启 API + +## 验证方式 + +```bash +.venv/bin/python -m pytest tests/test_display_time.py tests/test_telegram_time_format.py tests/test_agent_recovery_dedup.py -q +cd frontend && npm run type-check +``` + +浏览器:告警历史、审计、推送历史等列时间应为北京时间(与 UTC 差 8 小时)。 diff --git a/docs/changelog/2026-06-08-telegram-recovery-dedup-timezone.md b/docs/changelog/2026-06-08-telegram-recovery-dedup-timezone.md new file mode 100644 index 00000000..caf08e16 --- /dev/null +++ b/docs/changelog/2026-06-08-telegram-recovery-dedup-timezone.md @@ -0,0 +1,34 @@ +# 2026-06-08 — 资源恢复 Telegram 去重与时区修正 + +## 变更摘要 + +修复子机资源恢复通知「1 秒内连发多条」及消息内「时间」显示为 UTC 而非北京时间的问题。 + +## 动机 + +用户反馈子机 #212 内存恢复 Telegram 在约 1 秒内收到多条相同内容;消息底部时间为 UTC,与运维习惯不符。 + +## 根因 + +1. **重复推送**:Redis `alerts:{server_id}` 集合以 `mem:92.1` 形式每次超阈值心跳追加新成员;恢复检测对集合内每条成员各触发一次 `broadcast_recovery`,同一指标一次心跳可连发 N 条 Telegram。 +2. **时间不对**:`_now_cn()` 使用 `datetime.now(timezone.utc)` 并标注 `UTC`,对中国用户相当于慢 8 小时。 + +## 涉及文件 + +- `server/api/agent.py` — 告警集合仅存指标名;恢复检测按指标去重;清理时兼容 legacy `metric:value` +- `server/infrastructure/telegram/__init__.py` — `_now_cn()` 改为 `Asia/Shanghai` +「北京时间」 +- `tests/test_agent_recovery_dedup.py` +- `tests/test_telegram_time_format.py` + +## 迁移 / 重启 + +- 无需 DB 迁移 +- 部署后重启 API 生效;Redis 中既有 `metric:value` 成员在下次恢复时会被按指标批量清除 + +## 验证方式 + +```bash +pytest tests/test_agent_recovery_dedup.py tests/test_telegram_time_format.py -q +``` + +生产:子机超阈值后再回落,Telegram 每条指标仅应收到 **一条** 恢复通知,时间为北京时间。 diff --git a/frontend/src/components/credentials/CredentialsManager.vue b/frontend/src/components/credentials/CredentialsManager.vue index 6e32aacf..41c73508 100644 --- a/frontend/src/components/credentials/CredentialsManager.vue +++ b/frontend/src/components/credentials/CredentialsManager.vue @@ -187,6 +187,7 @@ import { formatApiError } from '@/utils/apiError' import { required } from '@/utils/validation' import { DATA_TABLE_ITEMS_PER_PAGE_OPTIONS, headersWithoutSort } from '@/constants/dataTable' import { fetchPagePerPage } from '@/utils/paginatedFetch' +import { formatDateTimeBeijing } from '@/utils/datetime' defineOptions({ name: 'CredentialsManager' }) @@ -253,9 +254,7 @@ function onItemsPerPageChange(n: number) { } function formatPresetTime(iso: string | undefined): string { - if (!iso) return '—' - const d = new Date(iso) - return Number.isNaN(d.getTime()) ? iso : d.toLocaleString('zh-CN', { hour12: false }) + return formatDateTimeBeijing(iso) } async function loadPasswords(silent = false) { diff --git a/frontend/src/components/servers/ServerInlineDetail.vue b/frontend/src/components/servers/ServerInlineDetail.vue index 4a2d07bb..30174b3c 100644 --- a/frontend/src/components/servers/ServerInlineDetail.vue +++ b/frontend/src/components/servers/ServerInlineDetail.vue @@ -60,6 +60,7 @@ import { http } from '@/api' import { formatPushPermission } from '@/composables/push/labels' import { formatApiError } from '@/utils/apiError' import type { PushItem, ServerApiItem } from '@/types/api' +import { formatDateTimeBeijing } from '@/utils/datetime' const props = defineProps<{ server: ServerApiItem @@ -96,7 +97,7 @@ function syncLogStatusLabel(status: string): string { function formatSyncLogTime(log: PushItem): string { const parts: string[] = [] - if (log.started_at) parts.push(log.started_at) + if (log.started_at) parts.push(formatDateTimeBeijing(log.started_at)) if (log.sync_mode) parts.push(log.sync_mode) if (log.trigger_type) parts.push(log.trigger_type) if (log.push_permission) parts.push(`权限 ${formatPushPermission(log.push_permission)}`) diff --git a/frontend/src/composables/push/labels.ts b/frontend/src/composables/push/labels.ts index f82d0646..5b2f5fec 100644 --- a/frontend/src/composables/push/labels.ts +++ b/frontend/src/composables/push/labels.ts @@ -1,6 +1,7 @@ /** Display helpers for push UI */ export { formatPushErrorMessage } from '@/utils/pushErrorMessage' +export { formatDateTimeBeijing as formatLogTime } from '@/utils/datetime' export function formatSize(bytes: number): string { if (bytes < 1024) return `${bytes} B` @@ -8,13 +9,6 @@ export function formatSize(bytes: number): string { return `${(bytes / 1048576).toFixed(1)} MB` } -export function formatLogTime(iso: string | null | undefined): string { - if (!iso) return '—' - const d = new Date(iso) - if (Number.isNaN(d.getTime())) return iso - return d.toLocaleString('zh-CN', { hour12: false }) -} - export function logStatusLabel(status: string): string { switch (status) { case 'success': return '成功' diff --git a/frontend/src/composables/useWebSocket.ts b/frontend/src/composables/useWebSocket.ts index e732cb76..b51de451 100644 --- a/frontend/src/composables/useWebSocket.ts +++ b/frontend/src/composables/useWebSocket.ts @@ -14,6 +14,7 @@ */ import { ref, computed } from 'vue' import { useAuthStore } from '@/stores/auth' +import { formatTimeBeijing } from '@/utils/datetime' import { handleScriptWsMessage, setScriptQueueWsConnected, @@ -98,7 +99,7 @@ export function useWebSocket() { serverName: msg.server_name, alertType: msg.alert_type || msg.metric, value: msg.alert_value || msg.value, - time: new Date().toLocaleTimeString(), + time: formatTimeBeijing(new Date()), } alerts.value.unshift(alert) if (alerts.value.length > MAX_ALERTS) alerts.value.pop() diff --git a/frontend/src/pages/AlertsPage.vue b/frontend/src/pages/AlertsPage.vue index 1bed33fc..157f3c64 100644 --- a/frontend/src/pages/AlertsPage.vue +++ b/frontend/src/pages/AlertsPage.vue @@ -83,7 +83,7 @@