feat(api): 統一使用台北時區 UTC+8 (禁止 UTC)
- 新增 src/utils/timezone.py 時區工具函式 - 修改 11 個後端檔案,全部改用 now_taipei() - 更新 HARD_RULES.md 加入時區鐵律章節 - 更新 Skills 02/04 加入時區禁令 🔴 HARD RULE: 禁止 datetime.utcnow() / datetime.now(UTC) ✅ 正確做法: from src.utils.timezone import now_taipei Memory: feedback_timezone_taipei.md Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,9 +11,11 @@ Endpoints:
|
||||
提供 K8s 操作執行的完整審計軌跡。
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from src.utils.timezone import now_taipei
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, select
|
||||
@@ -233,7 +235,7 @@ async def get_audit_stats() -> AuditStatsResponse:
|
||||
by_namespace = {row[0]: row[1] for row in ns_result.fetchall()}
|
||||
|
||||
# Last 24 hours
|
||||
cutoff = datetime.now(UTC) - timedelta(hours=24)
|
||||
cutoff = now_taipei() - timedelta(hours=24)
|
||||
last24_result = await db.execute(
|
||||
select(func.count(AuditLog.id)).where(AuditLog.created_at >= cutoff)
|
||||
)
|
||||
|
||||
@@ -17,9 +17,10 @@ Phase 6.4 核心功能:
|
||||
- Proposal 必須關聯到 Incident
|
||||
"""
|
||||
|
||||
from datetime import UTC
|
||||
from typing import Any
|
||||
|
||||
from src.utils.timezone import now_taipei
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -456,7 +457,7 @@ async def submit_feedback(
|
||||
if request.learning_notes is not None:
|
||||
incident.outcome.learning_notes = request.learning_notes
|
||||
incident.outcome.should_remember = request.should_remember
|
||||
incident.updated_at = datetime.now(UTC)
|
||||
incident.updated_at = now_taipei()
|
||||
|
||||
# 3. 寫入 Redis
|
||||
try:
|
||||
@@ -488,7 +489,7 @@ async def submit_feedback(
|
||||
|
||||
if record:
|
||||
record.outcome = incident.outcome.model_dump(mode="json")
|
||||
record.updated_at = datetime.now(UTC)
|
||||
record.updated_at = now_taipei()
|
||||
await db.commit()
|
||||
logger.info(
|
||||
"feedback_db_updated",
|
||||
@@ -569,7 +570,7 @@ async def debug_resolve_incident(incident_id: str) -> dict[str, Any]:
|
||||
if data:
|
||||
incident = Incident.model_validate_json(data)
|
||||
incident.status = IncidentStatus.RESOLVED
|
||||
incident.updated_at = datetime.now(UTC)
|
||||
incident.updated_at = now_taipei()
|
||||
await redis_client.set(
|
||||
f"incident:{incident_id}",
|
||||
incident.model_dump_json(),
|
||||
@@ -588,7 +589,7 @@ async def debug_resolve_incident(incident_id: str) -> dict[str, Any]:
|
||||
record = result.scalar_one_or_none()
|
||||
if record:
|
||||
record.status = "resolved"
|
||||
record.updated_at = datetime.now(UTC)
|
||||
record.updated_at = now_taipei()
|
||||
await db.commit()
|
||||
db_updated = True
|
||||
except Exception as e:
|
||||
@@ -729,7 +730,7 @@ async def sync_incidents_from_approvals() -> SyncResult:
|
||||
affected_services=[resource_name],
|
||||
proposal_ids=[UUID(approval_id)],
|
||||
created_at=approval.created_at,
|
||||
updated_at=datetime.now(UTC),
|
||||
updated_at=now_taipei(),
|
||||
)
|
||||
|
||||
key = f"incident:{incident.incident_id}"
|
||||
|
||||
@@ -8,10 +8,15 @@ AWOOOI API - Sentry Webhook Handler
|
||||
2. 組裝錯誤上下文
|
||||
3. 呼叫 OpenClaw Error Analyzer Agent
|
||||
4. 結果回寫 Sentry Issue Comment
|
||||
5. 發送 Telegram 告警 (含截圖)
|
||||
6. 建立 Approval 供人工審核
|
||||
|
||||
🔴 HARD RULE: 時間顯示使用 Asia/Taipei (UTC+8)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from src.utils.timezone import now_taipei, now_taipei_iso
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request
|
||||
@@ -227,7 +232,7 @@ async def post_sentry_comment(
|
||||
{analysis.prevention}
|
||||
|
||||
---
|
||||
*分析信心度: {analysis.confidence:.0%} | 分析時間: {datetime.now().isoformat()}*
|
||||
*分析信心度: {analysis.confidence:.0%} | 分析時間: {now_taipei_iso()}*
|
||||
"""
|
||||
|
||||
try:
|
||||
|
||||
@@ -24,9 +24,10 @@ Endpoints:
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
from datetime import UTC, datetime
|
||||
from typing import Literal
|
||||
|
||||
from src.utils.timezone import now_taipei
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Header, HTTPException, Request, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -103,7 +104,7 @@ async def create_incident_for_approval(
|
||||
alert_name=alert_type,
|
||||
severity=severity,
|
||||
source=source,
|
||||
fired_at=datetime.now(UTC),
|
||||
fired_at=now_taipei(),
|
||||
labels={"namespace": namespace, "resource": target_resource},
|
||||
annotations={"message": message},
|
||||
)
|
||||
@@ -552,7 +553,7 @@ async def produce_signal_to_stream(signal: SignalPayload) -> str:
|
||||
"message": signal.message,
|
||||
"labels": str(signal.labels or {}),
|
||||
"annotations": str(signal.annotations or {}),
|
||||
"received_at": datetime.now(UTC).isoformat(),
|
||||
"received_at": now_taipei().isoformat(),
|
||||
}
|
||||
|
||||
# XADD 寫入 Stream
|
||||
@@ -803,7 +804,7 @@ async def receive_alert(
|
||||
detail=f"HMAC verification failed: {str(e)}",
|
||||
) from e
|
||||
|
||||
alert_id = f"alert-{datetime.utcnow().strftime('%Y%m%d%H%M%S')}"
|
||||
alert_id = f"alert-{now_taipei().strftime('%Y%m%d%H%M%S')}"
|
||||
|
||||
# ==========================================================================
|
||||
# 戰略 B Step 1: 生成告警指紋
|
||||
@@ -1154,7 +1155,7 @@ async def alertmanager_webhook(
|
||||
)
|
||||
|
||||
alert = firing_alerts[0]
|
||||
alert_id = f"alert-{datetime.utcnow().strftime('%Y%m%d%H%M%S')}"
|
||||
alert_id = f"alert-{now_taipei().strftime('%Y%m%d%H%M%S')}"
|
||||
|
||||
# ==========================================================================
|
||||
# Alert Normalizer: 轉換 Alertmanager 格式 → AWOOOI AlertPayload
|
||||
|
||||
@@ -14,7 +14,9 @@ Phase 3.3: 商業變現能力 - Day-1 ROI
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import timedelta
|
||||
|
||||
from src.utils.timezone import now_taipei
|
||||
from enum import Enum
|
||||
from typing import Literal
|
||||
|
||||
@@ -232,7 +234,7 @@ class IdleResourceScanner:
|
||||
CostReport 包含所有浪費資源與建議動作
|
||||
"""
|
||||
self._scan_counter += 1
|
||||
scan_id = f"scan-{self._scan_counter:04d}-{datetime.utcnow().strftime('%Y%m%d%H%M%S')}"
|
||||
scan_id = f"scan-{self._scan_counter:04d}-{now_taipei().strftime('%Y%m%d%H%M%S')}"
|
||||
|
||||
logger.info(f"[FinOps] Starting full scan: {scan_id}")
|
||||
|
||||
@@ -254,7 +256,7 @@ class IdleResourceScanner:
|
||||
|
||||
report = CostReport(
|
||||
scan_id=scan_id,
|
||||
scanned_at=datetime.utcnow(),
|
||||
scanned_at=now_taipei(),
|
||||
cluster_name=cluster_name,
|
||||
total_wasted_usd=total_wasted,
|
||||
total_resources_scanned=self._get_mock_total_resources(),
|
||||
@@ -288,23 +290,23 @@ class IdleResourceScanner:
|
||||
"namespace": "database",
|
||||
"size_gb": 500,
|
||||
"storage_class": "gp3",
|
||||
"created": datetime.utcnow() - timedelta(days=90),
|
||||
"last_used": datetime.utcnow() - timedelta(days=60),
|
||||
"created": now_taipei() - timedelta(days=90),
|
||||
"last_used": now_taipei() - timedelta(days=60),
|
||||
},
|
||||
{
|
||||
"name": "logs-elasticsearch-2023",
|
||||
"namespace": "logging",
|
||||
"size_gb": 200,
|
||||
"storage_class": "gp2",
|
||||
"created": datetime.utcnow() - timedelta(days=180),
|
||||
"last_used": datetime.utcnow() - timedelta(days=120),
|
||||
"created": now_taipei() - timedelta(days=180),
|
||||
"last_used": now_taipei() - timedelta(days=120),
|
||||
},
|
||||
{
|
||||
"name": "cache-redis-temp",
|
||||
"namespace": "default",
|
||||
"size_gb": 50,
|
||||
"storage_class": "gp3",
|
||||
"created": datetime.utcnow() - timedelta(days=30),
|
||||
"created": now_taipei() - timedelta(days=30),
|
||||
"last_used": None,
|
||||
},
|
||||
]
|
||||
@@ -348,8 +350,8 @@ class IdleResourceScanner:
|
||||
"cpu_request": 2.0, # vCPU
|
||||
"mem_request_gb": 4.0,
|
||||
"avg_cpu_percent": 0.3,
|
||||
"created": datetime.utcnow() - timedelta(days=120),
|
||||
"last_active": datetime.utcnow() - timedelta(days=45),
|
||||
"created": now_taipei() - timedelta(days=120),
|
||||
"last_active": now_taipei() - timedelta(days=45),
|
||||
},
|
||||
{
|
||||
"name": "test-worker-batch-xyz99",
|
||||
@@ -357,8 +359,8 @@ class IdleResourceScanner:
|
||||
"cpu_request": 1.0,
|
||||
"mem_request_gb": 2.0,
|
||||
"avg_cpu_percent": 0.1,
|
||||
"created": datetime.utcnow() - timedelta(days=60),
|
||||
"last_active": datetime.utcnow() - timedelta(days=30),
|
||||
"created": now_taipei() - timedelta(days=60),
|
||||
"last_active": now_taipei() - timedelta(days=30),
|
||||
},
|
||||
{
|
||||
"name": "debug-shell-admin",
|
||||
@@ -366,8 +368,8 @@ class IdleResourceScanner:
|
||||
"cpu_request": 0.5,
|
||||
"mem_request_gb": 1.0,
|
||||
"avg_cpu_percent": 0.0,
|
||||
"created": datetime.utcnow() - timedelta(days=14),
|
||||
"last_active": datetime.utcnow() - timedelta(days=10),
|
||||
"created": now_taipei() - timedelta(days=14),
|
||||
"last_active": now_taipei() - timedelta(days=10),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -420,7 +422,7 @@ class IdleResourceScanner:
|
||||
"requested_mem_gb": 48.0,
|
||||
"actual_cpu": 2.0,
|
||||
"actual_mem_gb": 8.0,
|
||||
"created": datetime.utcnow() - timedelta(days=200),
|
||||
"created": now_taipei() - timedelta(days=200),
|
||||
},
|
||||
{
|
||||
"name": "worker-gpu-unused",
|
||||
@@ -431,7 +433,7 @@ class IdleResourceScanner:
|
||||
"requested_mem_gb": 16.0,
|
||||
"actual_cpu": 0.5,
|
||||
"actual_mem_gb": 2.0,
|
||||
"created": datetime.utcnow() - timedelta(days=90),
|
||||
"created": now_taipei() - timedelta(days=90),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -468,7 +470,7 @@ class IdleResourceScanner:
|
||||
),
|
||||
monthly_cost_usd=monthly_cost,
|
||||
created_at=node["created"],
|
||||
last_used_at=datetime.utcnow(),
|
||||
last_used_at=now_taipei(),
|
||||
spec={
|
||||
"totalCpu": node["total_cpu"],
|
||||
"totalMemoryGb": node["total_mem_gb"],
|
||||
|
||||
@@ -18,6 +18,8 @@ import re
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
from src.utils.timezone import now_taipei
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
@@ -53,7 +55,7 @@ class MCPToolResult:
|
||||
output: Any | None = None
|
||||
error: str | None = None
|
||||
duration: float = 0.0
|
||||
timestamp: datetime = field(default_factory=datetime.utcnow)
|
||||
timestamp: datetime = field(default_factory=now_taipei)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
@@ -390,7 +392,7 @@ class MCPBridge:
|
||||
MCPToolResult (符合 ActionResult 介面)
|
||||
"""
|
||||
execution_id = str(uuid.uuid4())
|
||||
start_time = datetime.utcnow()
|
||||
start_time = now_taipei()
|
||||
|
||||
try:
|
||||
# ========================================
|
||||
@@ -494,7 +496,7 @@ class MCPBridge:
|
||||
|
||||
def _calc_duration(self, start_time: datetime) -> float:
|
||||
"""計算執行時間 (毫秒)"""
|
||||
return (datetime.utcnow() - start_time).total_seconds() * 1000
|
||||
return (now_taipei() - start_time).total_seconds() * 1000
|
||||
|
||||
# ==================== ActionExecutor 介面對齊 ====================
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ Supported Operations:
|
||||
import asyncio
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from src.utils.timezone import now_taipei, now_taipei_iso
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -376,7 +376,7 @@ class ActionExecutor:
|
||||
"template": {
|
||||
"metadata": {
|
||||
"annotations": {
|
||||
"kubectl.kubernetes.io/restartedAt": datetime.now(UTC).isoformat()
|
||||
"kubectl.kubernetes.io/restartedAt": now_taipei().isoformat()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1060,7 +1060,7 @@ async def execute_approved_proposal(approval_id: str) -> ExecutionResult:
|
||||
# Step 5: 更新狀態
|
||||
new_status = "executed" if result.success else "failed"
|
||||
execution_log = {
|
||||
"executed_at": datetime.now(UTC).isoformat(),
|
||||
"executed_at": now_taipei().isoformat(),
|
||||
"success": result.success,
|
||||
"stdout": result.k8s_response.get("stdout", "") if result.k8s_response else "",
|
||||
"stderr": result.error or "",
|
||||
|
||||
@@ -11,7 +11,9 @@ Phase 6: leWOOOgo Output Plugins
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from datetime import datetime
|
||||
|
||||
from src.utils.timezone import now_taipei
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
@@ -68,7 +70,7 @@ class NotificationMessage:
|
||||
confidence: float | None = None
|
||||
|
||||
# 時間戳
|
||||
timestamp: datetime = field(default_factory=lambda: datetime.now(UTC))
|
||||
timestamp: datetime = field(default_factory=lambda: now_taipei())
|
||||
|
||||
@property
|
||||
def status_emoji(self) -> str:
|
||||
@@ -117,7 +119,7 @@ class NotificationResult:
|
||||
message: str
|
||||
response_data: dict[str, Any] | None = None
|
||||
error: str | None = None
|
||||
timestamp: datetime = field(default_factory=lambda: datetime.now(UTC))
|
||||
timestamp: datetime = field(default_factory=lambda: now_taipei())
|
||||
|
||||
|
||||
class NotificationProvider(ABC):
|
||||
|
||||
@@ -23,9 +23,9 @@ import json
|
||||
import random
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
import httpx
|
||||
|
||||
from src.utils.timezone import now_taipei_iso
|
||||
import structlog
|
||||
|
||||
from src.core.config import settings
|
||||
@@ -789,7 +789,7 @@ class OpenClawService:
|
||||
cache_data = {
|
||||
"response": response,
|
||||
"provider": provider,
|
||||
"cached_at": datetime.now().isoformat(),
|
||||
"cached_at": now_taipei_iso(),
|
||||
}
|
||||
await redis_client.set(
|
||||
cache_key,
|
||||
|
||||
70
apps/api/src/utils/timezone.py
Normal file
70
apps/api/src/utils/timezone.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""
|
||||
AWOOOI - 時區工具
|
||||
==================
|
||||
統一使用 Asia/Taipei (UTC+8) 時區
|
||||
|
||||
🔴 HARD RULE: 全系統使用台北時區,禁止 UTC
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
# 台北時區 (UTC+8)
|
||||
TAIPEI_TZ = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
def now_taipei() -> datetime:
|
||||
"""
|
||||
取得台北時區當前時間
|
||||
|
||||
Returns:
|
||||
datetime: 帶有 +08:00 時區資訊的 datetime
|
||||
|
||||
Example:
|
||||
>>> now_taipei().isoformat()
|
||||
'2026-03-25T02:08:04+08:00'
|
||||
"""
|
||||
return datetime.now(TAIPEI_TZ)
|
||||
|
||||
|
||||
def to_taipei(dt: datetime) -> datetime:
|
||||
"""
|
||||
將 datetime 轉換為台北時區
|
||||
|
||||
Args:
|
||||
dt: 任何 datetime (可能是 UTC 或 naive)
|
||||
|
||||
Returns:
|
||||
datetime: 台北時區的 datetime
|
||||
"""
|
||||
if dt.tzinfo is None:
|
||||
# naive datetime,假設是 UTC
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(TAIPEI_TZ)
|
||||
|
||||
|
||||
def format_taipei(dt: datetime | None = None, fmt: str = "%Y-%m-%d %H:%M:%S") -> str:
|
||||
"""
|
||||
格式化為台北時區字串
|
||||
|
||||
Args:
|
||||
dt: datetime (預設為當前時間)
|
||||
fmt: strftime 格式
|
||||
|
||||
Returns:
|
||||
str: 格式化的時間字串
|
||||
"""
|
||||
if dt is None:
|
||||
dt = now_taipei()
|
||||
else:
|
||||
dt = to_taipei(dt)
|
||||
return dt.strftime(fmt)
|
||||
|
||||
|
||||
def now_taipei_iso() -> str:
|
||||
"""
|
||||
取得 ISO 格式的台北時區時間
|
||||
|
||||
Returns:
|
||||
str: ISO 格式,例如 '2026-03-25T02:08:04+08:00'
|
||||
"""
|
||||
return now_taipei().isoformat()
|
||||
Reference in New Issue
Block a user