diff --git a/backend/Dockerfile b/backend/Dockerfile index e19e8fd..f7176d6 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -28,8 +28,12 @@ RUN mvn -f backend/pom.xml package -DskipTests -q FROM eclipse-temurin:21-jre-alpine AS production WORKDIR /app +ENV TZ=Asia/Seoul + RUN addgroup -S spring && adduser -S spring -G spring \ - && apk add --no-cache su-exec + && apk add --no-cache su-exec tzdata \ + && ln -snf /usr/share/zoneinfo/$TZ /etc/localtime \ + && echo $TZ > /etc/timezone COPY --from=builder /build/backend/target/goldenchart-backend-*.jar app.jar COPY backend/docker-entrypoint.sh /docker-entrypoint.sh diff --git a/backend/src/main/java/com/goldenchart/entity/GcVerificationIssue.java b/backend/src/main/java/com/goldenchart/entity/GcVerificationIssue.java index fceaec0..d80ef88 100644 --- a/backend/src/main/java/com/goldenchart/entity/GcVerificationIssue.java +++ b/backend/src/main/java/com/goldenchart/entity/GcVerificationIssue.java @@ -3,6 +3,8 @@ package com.goldenchart.entity; import jakarta.persistence.*; import lombok.*; +import com.goldenchart.util.AppTime; + import java.time.LocalDateTime; @Entity @@ -42,12 +44,12 @@ public class GcVerificationIssue { @PrePersist protected void onCreate() { - createdAt = LocalDateTime.now(); - updatedAt = LocalDateTime.now(); + createdAt = AppTime.nowLocal(); + updatedAt = AppTime.nowLocal(); } @PreUpdate protected void onUpdate() { - updatedAt = LocalDateTime.now(); + updatedAt = AppTime.nowLocal(); } } diff --git a/backend/src/main/java/com/goldenchart/entity/GcVerificationIssueComment.java b/backend/src/main/java/com/goldenchart/entity/GcVerificationIssueComment.java index 10aac25..b87bf0b 100644 --- a/backend/src/main/java/com/goldenchart/entity/GcVerificationIssueComment.java +++ b/backend/src/main/java/com/goldenchart/entity/GcVerificationIssueComment.java @@ -3,6 +3,8 @@ package com.goldenchart.entity; import jakarta.persistence.*; import lombok.*; +import com.goldenchart.util.AppTime; + import java.time.LocalDateTime; @Entity @@ -37,12 +39,12 @@ public class GcVerificationIssueComment { @PrePersist protected void onCreate() { - createdAt = LocalDateTime.now(); - updatedAt = LocalDateTime.now(); + createdAt = AppTime.nowLocal(); + updatedAt = AppTime.nowLocal(); } @PreUpdate protected void onUpdate() { - updatedAt = LocalDateTime.now(); + updatedAt = AppTime.nowLocal(); } } diff --git a/backend/src/main/java/com/goldenchart/entity/GcVerificationIssueImage.java b/backend/src/main/java/com/goldenchart/entity/GcVerificationIssueImage.java index bc8e11f..d2fc2ac 100644 --- a/backend/src/main/java/com/goldenchart/entity/GcVerificationIssueImage.java +++ b/backend/src/main/java/com/goldenchart/entity/GcVerificationIssueImage.java @@ -3,6 +3,8 @@ package com.goldenchart.entity; import jakarta.persistence.*; import lombok.*; +import com.goldenchart.util.AppTime; + import java.time.LocalDateTime; @Entity @@ -38,6 +40,6 @@ public class GcVerificationIssueImage { @PrePersist protected void onCreate() { - createdAt = LocalDateTime.now(); + createdAt = AppTime.nowLocal(); } } diff --git a/backend/src/main/java/com/goldenchart/util/AppTime.java b/backend/src/main/java/com/goldenchart/util/AppTime.java new file mode 100644 index 0000000..cd34676 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/util/AppTime.java @@ -0,0 +1,16 @@ +package com.goldenchart.util; + +import java.time.LocalDateTime; +import java.time.ZoneId; + +/** 앱 기본 시간대(한국) — LocalDateTime 저장·표시용 */ +public final class AppTime { + + public static final ZoneId ZONE = ZoneId.of("Asia/Seoul"); + + private AppTime() {} + + public static LocalDateTime nowLocal() { + return LocalDateTime.now(ZONE); + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bda5bb0..2c586fa 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1653,10 +1653,13 @@ function App() { const lastKnownBar = useUpbit ? (latestBar ?? (bars.length > 0 ? bars[bars.length - 1] : null)) : (bars.length > 0 ? bars[bars.length - 1] : null); - const currentBar = legend ?? lastKnownBar; - const isUp = currentBar ? currentBar.close >= currentBar.open : true; - const prevClose = bars.length > 1 ? bars[bars.length - 2].close : currentBar?.open ?? 0; - const dailyChange = currentBar ? currentBar.close - prevClose : 0; + /** 크로스헤어 OHLC·지표 범례 */ + const hoverBar = legend ?? lastKnownBar; + /** 종목명 옆 시세 — 실시간 현재가만 */ + const quoteBar = lastKnownBar; + const isUp = quoteBar ? quoteBar.close >= quoteBar.open : true; + const prevClose = bars.length > 1 ? bars[bars.length - 2].close : quoteBar?.open ?? 0; + const dailyChange = quoteBar ? quoteBar.close - prevClose : 0; const dailyChangePct = prevClose > 0 ? dailyChange / prevClose * 100 : 0; const openNotificationsPage = useCallback(() => guardedSetMenuPage('notifications'), [guardedSetMenuPage]); @@ -2071,10 +2074,10 @@ function App() { {getKoreanName(symbol)} {symbol} {isLoading && 데이터 로딩 중...} {error && ⚠ {error}} - {!isLoading && !error && currentBar && ( + {!isLoading && !error && quoteBar && ( <> - {currentBar.close.toLocaleString()} KRW + {quoteBar.close.toLocaleString()} KRW {isUp ? '▲' : '▼'} {Math.abs(dailyChangePct).toFixed(2)}% @@ -2249,7 +2252,8 @@ function App() { useUpbit={useUpbit} wsStatus={wsStatus} mode={mode} - currentBar={currentBar} + currentBar={hoverBar} + quoteBar={quoteBar} isUp={isUp} dailyChange={dailyChange} dailyChangePct={dailyChangePct} @@ -2622,7 +2626,7 @@ function App() { )} {showAlert && ( { managerRef.current?.addAlert(price, label); setAlerts(prev => [...prev, { price, label }]); }} onRemove={price => { managerRef.current?.removeAlert(price); setAlerts(prev => prev.filter(a => Math.abs(a.price - price) > 0.0001)); }} onClose={() => setShowAlert(false)} diff --git a/frontend/src/components/ChartLegendBar.tsx b/frontend/src/components/ChartLegendBar.tsx index b8beb8b..65826e9 100644 --- a/frontend/src/components/ChartLegendBar.tsx +++ b/frontend/src/components/ChartLegendBar.tsx @@ -28,7 +28,10 @@ export interface ChartLegendBarProps { useUpbit: boolean; wsStatus: WsStatus; mode: ChartMode; + /** 크로스헤어·OHLC 범례용 (없으면 quoteBar) */ currentBar: LegendData | null; + /** 종목명 옆 시세·등락 — 실시간 현재가만 (크로스헤어 미반영) */ + quoteBar?: LegendData | null; isUp: boolean; dailyChange: number; dailyChangePct: number; @@ -44,13 +47,16 @@ export const ChartLegendBar: React.FC = ({ wsStatus, mode, currentBar, + quoteBar: quoteBarProp, isUp, dailyChange, dailyChangePct, indicators, legend, }) => { - const showOhlc = currentBar && (v.open || v.high || v.low || v.close || v.change); + const ohlcBar = currentBar; + const quoteBar = quoteBarProp ?? currentBar; + const showOhlc = ohlcBar && (v.open || v.high || v.low || v.close || v.change); const showAny = v.symbol || v.timeframe || (useUpbit && v.liveStatus) || (mode === 'trading' && v.tradingBadge) || showOhlc || (v.indicators && indicators.length > 0); @@ -70,17 +76,17 @@ export const ChartLegendBar: React.FC = ({ {mode === 'trading' && v.tradingBadge && } {showOhlc && ( - {v.open && ( - O {formatPrice(currentBar!.open)} + {v.open && ohlcBar && ( + O {formatPrice(ohlcBar.open)} )} - {v.high && ( - H {formatPrice(currentBar!.high)} + {v.high && ohlcBar && ( + H {formatPrice(ohlcBar.high)} )} - {v.low && ( - L {formatPrice(currentBar!.low)} + {v.low && ohlcBar && ( + L {formatPrice(ohlcBar.low)} )} - {v.close && ( - C {formatPrice(currentBar!.close)} + {v.close && quoteBar && ( + C {formatPrice(quoteBar.close)} )} {v.change && ( diff --git a/frontend/src/components/ChartSlot.tsx b/frontend/src/components/ChartSlot.tsx index 4c857c1..40c97d5 100644 --- a/frontend/src/components/ChartSlot.tsx +++ b/frontend/src/components/ChartSlot.tsx @@ -556,10 +556,10 @@ const ChartSlot = forwardRef(function ChartSlot const lastKnownBar = useUpbit ? (latestBar ?? (bars.length > 0 ? bars[bars.length - 1] : null)) : (bars.length > 0 ? bars[bars.length - 1] : null); - const currentBar = legend ?? lastKnownBar; - const isUp = currentBar ? currentBar.close >= currentBar.open : true; - const prevClose = bars.length > 1 ? bars[bars.length - 2].close : currentBar?.open ?? 0; - const dailyChange = currentBar ? currentBar.close - prevClose : 0; + const quoteBar = lastKnownBar; + const isUp = quoteBar ? quoteBar.close >= quoteBar.open : true; + const prevClose = bars.length > 1 ? bars[bars.length - 2].close : quoteBar?.open ?? 0; + const dailyChange = quoteBar ? quoteBar.close - prevClose : 0; const dailyChangePct = prevClose > 0 ? dailyChange / prevClose * 100 : 0; const receiveSignal = useMemo(() => { @@ -671,8 +671,8 @@ const ChartSlot = forwardRef(function ChartSlot }, [compactMode, onActivate, showMarket]); const displayKoName = compactKoName(symbol); - const trendUp = compactMode && currentBar != null && dailyChangePct >= 0; - const trendDown = compactMode && currentBar != null && dailyChangePct < 0; + const trendUp = compactMode && quoteBar != null && dailyChangePct >= 0; + const trendDown = compactMode && quoteBar != null && dailyChangePct < 0; return (
(function ChartSlot {displayKoName} - {currentBar && ( + {quoteBar && (
- {formatPrice(currentBar.close)} + {formatPrice(quoteBar.close)} (function ChartSlot ))}
- {currentBar && ( + {quoteBar && ( - {formatPrice(currentBar.close)} + {formatPrice(quoteBar.close)} {isUp ? '▲' : '▼'} {Math.abs(dailyChangePct).toFixed(2)}% diff --git a/frontend/src/components/SettingsPage.tsx b/frontend/src/components/SettingsPage.tsx index 3bcb23a..5dc8aeb 100644 --- a/frontend/src/components/SettingsPage.tsx +++ b/frontend/src/components/SettingsPage.tsx @@ -586,7 +586,7 @@ const GeneralPanel: React.FC<{ = ({ issueId }) => {
{c.authorName ?? '익명'} - {formatDateTime(c.updatedAt ?? c.createdAt)} + {formatIsoDateTime(c.updatedAt ?? c.createdAt, undefined, 'short')} {c.updatedAt && c.createdAt && c.updatedAt !== c.createdAt && ' (수정됨)'}
diff --git a/frontend/src/components/verificationBoard/VerificationIssueDetailPanel.tsx b/frontend/src/components/verificationBoard/VerificationIssueDetailPanel.tsx index 543560c..784f81f 100644 --- a/frontend/src/components/verificationBoard/VerificationIssueDetailPanel.tsx +++ b/frontend/src/components/verificationBoard/VerificationIssueDetailPanel.tsx @@ -11,6 +11,7 @@ import { import ReproductionPathInput from './ReproductionPathInput'; import VerificationIssueCommentsSection from './VerificationIssueCommentsSection'; import VerificationIssueAttachmentsSection from './VerificationIssueAttachmentsSection'; +import { formatIsoDateTime } from '../../utils/timezone'; interface Props { issue: VerificationIssueDto | null; @@ -23,13 +24,6 @@ interface Props { ) => void | Promise; } -function formatDateTime(iso?: string): string { - if (!iso) return '—'; - try { - return new Date(iso).toLocaleString('ko-KR'); - } catch { return iso; } -} - const VerificationIssueDetailPanel: React.FC = ({ issue, attachmentsRefreshKey = 0, @@ -137,8 +131,8 @@ const VerificationIssueDetailPanel: React.FC = ({
- 등록 {formatDateTime(issue.createdAt)} - 수정 {formatDateTime(issue.updatedAt)} + 등록 {formatIsoDateTime(issue.createdAt)} + 수정 {formatIsoDateTime(issue.updatedAt)}