feat(시간 표시): 화면 시각 = 보는 사람 PC 시간대 · 서버 응답 UTC Z · 서버가 만드는 파일 글 = 한국 시각 (PLAN 593-1 · 요구 456)
- 공용 `common_util_time.ts`(날짜 · 시각 · 오늘/어제/n 일 전 · Z 없는 옛 글 = UTC) · `common_util_time.py`(UTC Z 글 · 한국 시각) - 화면 바꿈: 대시보드(프로젝트 · 휴지통 · 시스템 로그 · 보관함 · 홈 카드) · B09 굳힘 · E03 · E05 · E07 · M02 호표 이력 · 목록 고친 날 · 지난 판 - 서버: 응답 datetime = UTC + Z(main.py) · 로컬 시각 저장 → UTC · 대시보드 경과일 = UTC 기준 · 엑셀 날짜 · 파일 이름 = 한국 시각 - 시험: test_593_time(서울 · 뉴욕 · Z 없는 옛 글 · 서버 글) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -22,7 +22,6 @@ import hashlib
|
||||
import json
|
||||
import subprocess
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
from uuid import uuid4
|
||||
@@ -32,6 +31,7 @@ import aiomysql
|
||||
from B05_Profile.B05_Profile_Engine_Sections_Names import find_cross_file
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
|
||||
from common_util.common_util_time import utc_now_text
|
||||
|
||||
Scope = Literal["full", "stability"]
|
||||
KIND_LABELS = {"ROAD": "B", "PLAN": "D", "STABILITY": "E"}
|
||||
@@ -390,7 +390,7 @@ async def clone_full(
|
||||
"kind": kind,
|
||||
"source_project_id": source_id,
|
||||
"project_id": new_id,
|
||||
"copied_at": datetime.now().isoformat(timespec="seconds"),
|
||||
"copied_at": utc_now_text(),
|
||||
"tables": {name: len(items) for name, items in rows.items()},
|
||||
"skipped_tables": skipped,
|
||||
"files": _tree_files(new_root),
|
||||
@@ -517,7 +517,7 @@ async def copy_stability(connection, source_id: str, new_id: str, project_root:
|
||||
"scope": "stability",
|
||||
"source_project_id": source_id,
|
||||
"project_id": new_id,
|
||||
"copied_at": datetime.now().isoformat(timespec="seconds"),
|
||||
"copied_at": utc_now_text(),
|
||||
"source_fingerprint": digest.hexdigest(),
|
||||
"route_id": dump["route"]["id"] if dump["route"] else None,
|
||||
"tables": {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""서버 시각 글 — 저장·전송은 UTC(끝에 Z) · 서버가 만드는 파일 글(엑셀 · PDF · 메일 · 파일 이름)은 한국 시각 고정.
|
||||
|
||||
화면 글은 `common_util_time.ts`(보는 PC 시간대) — 서버 PC 시간대에 기대지 않음(한국은 서머타임 없음 = 고정 +9).
|
||||
"""
|
||||
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
|
||||
KST = timezone(timedelta(hours=9), "KST")
|
||||
|
||||
|
||||
def utc_text(value: datetime | None) -> str | None:
|
||||
"""시간대 없는 값 = UTC 로 보고 「2026-10-30T01:02:03Z」 · 시간대 있는 값은 UTC 로 바꿈 · 없음 = None."""
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is not None:
|
||||
value = value.astimezone(timezone.utc)
|
||||
return value.replace(tzinfo=None, microsecond=0).isoformat() + "Z"
|
||||
|
||||
|
||||
def utc_now_text() -> str:
|
||||
"""지금 — UTC 글(저장용)."""
|
||||
return utc_text(datetime.now(timezone.utc)) or ""
|
||||
|
||||
|
||||
def utc_from_timestamp(seconds: float) -> str:
|
||||
"""파일 수정 시각 등 epoch 초 → UTC 글."""
|
||||
return utc_text(datetime.fromtimestamp(seconds, timezone.utc)) or ""
|
||||
|
||||
|
||||
def kst_now() -> datetime:
|
||||
"""지금 — 한국 시각(서버 PC 시간대와 무관)."""
|
||||
return datetime.now(KST)
|
||||
|
||||
|
||||
def kst_today() -> date:
|
||||
return kst_now().date()
|
||||
|
||||
|
||||
def kst_from_timestamp(seconds: float) -> datetime:
|
||||
return datetime.fromtimestamp(seconds, KST)
|
||||
|
||||
|
||||
def utc_now_naive() -> datetime:
|
||||
"""지금 — DB 칸(UTC, 시간대 표시 없음)과 견주는 값."""
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def install_json_utc() -> None:
|
||||
"""응답의 datetime(DB 값 = UTC) 을 끝에 Z 붙은 글로 — 화면이 보는 사람 시간대로 바꿈."""
|
||||
from fastapi.encoders import ENCODERS_BY_TYPE
|
||||
|
||||
ENCODERS_BY_TYPE[datetime] = lambda value: utc_text(value) or ""
|
||||
@@ -0,0 +1,72 @@
|
||||
/* =============================================================================
|
||||
* common_util_time.ts
|
||||
* 화면 시간 표시 — 저장·전송은 UTC 하나, 보일 때만 보는 사람 PC 시간대로 바꿈.
|
||||
* 서버 글(「2026-10-30 01:02:03」 · 「…T01:02:03」 = 시간대 표시 없음)은 UTC 로 읽음.
|
||||
* 짝: `common_util/common_util_time.py`(서버가 만드는 파일 글 = 한국 시각 고정).
|
||||
* ========================================================================== */
|
||||
|
||||
const DAY_MS = 24 * 3600 * 1000;
|
||||
const HAS_ZONE = /(Z|[+-]\d\d:?\d\d)$/;
|
||||
|
||||
/** 서버 시각 글 → 밀리초(UTC 기준) · 못 읽으면 null */
|
||||
export function parseUtcMs(value: string | number | null | undefined): number | null {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
if (typeof value === "number") return Number.isFinite(value) ? value : null;
|
||||
const iso = value.trim().replace(" ", "T");
|
||||
if (!iso.includes("T")) return null; // 날짜만 = 시각이 아니라 바꾸지 않음
|
||||
const ms = Date.parse(HAS_ZONE.test(iso) ? iso : `${iso}Z`);
|
||||
return Number.isNaN(ms) ? null : ms;
|
||||
}
|
||||
|
||||
const two = (n: number): string => String(n).padStart(2, "0");
|
||||
|
||||
function dateOf(d: Date): string {
|
||||
return `${d.getFullYear()}-${two(d.getMonth() + 1)}-${two(d.getDate())}`;
|
||||
}
|
||||
|
||||
/** 날짜만 「YYYY-MM-DD」(보는 PC 시간대) · 빈 값 · 못 읽음 = empty */
|
||||
export function dateText(value: string | null | undefined, empty = "-"): string {
|
||||
const ms = parseUtcMs(value);
|
||||
if (ms !== null) return dateOf(new Date(ms));
|
||||
return /^\d{4}-\d\d-\d\d$/.test(String(value ?? "").trim()) ? String(value).trim() : empty;
|
||||
}
|
||||
|
||||
/** 시:분 「HH:MM」(보는 PC 시간대) · 빈 값 · 못 읽음 = 빈 글 */
|
||||
export function timeText(value: string | null | undefined): string {
|
||||
const ms = parseUtcMs(value);
|
||||
if (ms === null) return "";
|
||||
const d = new Date(ms);
|
||||
return `${two(d.getHours())}:${two(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
/** 날짜 + 시각 「YYYY-MM-DD HH:MM」(보는 PC 시간대) · 빈 값 · 못 읽음 = empty */
|
||||
export function dateTimeText(value: string | null | undefined, empty = "—"): string {
|
||||
const ms = parseUtcMs(value);
|
||||
if (ms === null) return empty;
|
||||
const d = new Date(ms);
|
||||
return `${dateOf(d)} ${two(d.getHours())}:${two(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
/** 달력 날짜(보는 PC 시간대) 차이 — 오늘 0 · 어제 1 · 미래 음수 */
|
||||
export function calendarDaysAgo(value: string | null | undefined, now = Date.now()): number | null {
|
||||
const ms = parseUtcMs(value);
|
||||
if (ms === null) return null;
|
||||
const day = (t: number): number => {
|
||||
const d = new Date(t);
|
||||
return Date.UTC(d.getFullYear(), d.getMonth(), d.getDate());
|
||||
};
|
||||
return Math.round((day(now) - day(ms)) / DAY_MS);
|
||||
}
|
||||
|
||||
/** 「오늘」 · 「어제」 · 「n 일 전」(7 일 미만) · 그 밖 날짜 · 빈 값 = empty */
|
||||
export function agoText(value: string | null | undefined, empty = "-", now = Date.now()): string {
|
||||
const days = calendarDaysAgo(value, now);
|
||||
if (days === null) return empty;
|
||||
if (days <= 0) return "오늘";
|
||||
if (days === 1) return "어제";
|
||||
if (days < 7) return `${days} 일 전`;
|
||||
return dateText(value, empty);
|
||||
}
|
||||
|
||||
/** 옛 이름 — 확정 시각 등(`dateTimeText` 와 같음) */
|
||||
export const kstText = (utc: string | null | undefined): string => dateTimeText(utc);
|
||||
@@ -1,15 +1,2 @@
|
||||
/* =============================================================================
|
||||
* common_util_time_kst.ts
|
||||
* 서버 UTC 시각(DB 세션 time_zone +00:00) → 화면 KST(UTC+9) 글자 — 서버 값은 UTC 그대로.
|
||||
* ========================================================================== */
|
||||
|
||||
const KST_OFFSET_MS = 9 * 3600 * 1000;
|
||||
|
||||
/** "YYYY-MM-DD HH:MM:SS" · ISO(UTC) → "YYYY-MM-DD HH:MM" KST · 빈 값 = "—" · 못 읽으면 원글 */
|
||||
export function kstText(utc: string | null | undefined): string {
|
||||
if (!utc) return "—";
|
||||
const iso = utc.trim().replace(" ", "T");
|
||||
const ms = Date.parse(/(Z|[+-]\d\d:?\d\d)$/.test(iso) ? iso : `${iso}Z`);
|
||||
if (Number.isNaN(ms)) return utc;
|
||||
return new Date(ms + KST_OFFSET_MS).toISOString().slice(0, 16).replace("T", " ");
|
||||
}
|
||||
/* 옛 이름 — `common_util_time.ts` 로 옮김(보는 PC 시간대). 호출부가 바뀌면 이 파일을 지움. */
|
||||
export { kstText } from "./common_util_time";
|
||||
|
||||
@@ -6,6 +6,8 @@ from typing import Any, Dict
|
||||
|
||||
import aiomysql
|
||||
|
||||
from common_util.common_util_time import utc_text
|
||||
|
||||
# `project_workflow_stages.message` 는 varchar(255) — 넘치면 UPDATE 자체가 실패한다.
|
||||
_STAGE_MESSAGE_MAX_LENGTH = 200
|
||||
|
||||
@@ -492,7 +494,7 @@ async def analysis_lock_owner(
|
||||
"user_id": user_id,
|
||||
"name": name,
|
||||
"email": email,
|
||||
"started_at": started_at.isoformat() if started_at else None,
|
||||
"started_at": utc_text(started_at),
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user