가상매매 시간봉 제거

This commit is contained in:
Macbook
2026-05-28 20:48:28 +09:00
parent 7e3644cb62
commit 15160f7d2c
45 changed files with 996 additions and 405 deletions
+2 -28
View File
@@ -7,7 +7,6 @@ import { NavigationProvider, useNavigation } from './contexts/NavigationContext'
import { TradeNotificationProvider, useTradeNotification } from './contexts/TradeNotificationContext';
import { useAppSettings, resolveAppDefaults } from './hooks/useAppSettings';
import TabBar, { type TabId } from './components/TabBar';
import { initFcmPush, type FcmPayload } from './services/fcm';
import LiveSignalBridge from './components/LiveSignalBridge';
import LoginScreen from './screens/LoginScreen';
import '@frontend/styles/splashScreen.css';
@@ -37,37 +36,12 @@ function ToastStack() {
function MainApp() {
const { tab, setTab, openVirtualFocus, goVirtualList, goNotifyList } = useNavigation();
const { addNotification, refreshHistory, unreadCount } = useTradeNotification();
const { unreadCount } = useTradeNotification();
const { sessionKey } = useAuth();
const { settings, isLoaded } = useAppSettings(sessionKey);
const defaults = resolveAppDefaults(settings);
useEffect(() => {
if (!isLoaded) return;
void initFcmPush({
onForeground: (_title, _body, data) => {
if (data?.market && data?.signalType) {
addNotification({
market: data.market,
signalType: data.signalType === 'SELL' ? 'SELL' : 'BUY',
price: Number(data.price) || 0,
candleTime: Math.floor(Date.now() / 1000),
dbId: data.signalId ? Number(data.signalId) : undefined,
});
} else {
void refreshHistory();
}
},
onTap: (data: FcmPayload) => {
if (data.market) {
setTab('virtual');
openVirtualFocus(data.market);
} else {
setTab('notifications');
}
},
});
}, [isLoaded, addNotification, refreshHistory, setTab, openVirtualFocus]);
/** FCM/알림 권한: 로그인·메인 진입 시 호출하지 않음 — 설정 버튼에서만 */
useEffect(() => {
document.documentElement.setAttribute('data-theme', defaults.theme ?? 'dark');
+10 -9
View File
@@ -20,6 +20,7 @@ import {
type LoginResponse,
} from '../lib/shared';
import { invalidateAppSettingsCache, reloadAppSettingsCache } from '../hooks/useAppSettings';
import { ensureMobileApiBase } from '../lib/ensureMobileApiBase';
function normalizeRole(role: string): AuthSession['role'] {
return role === 'ADMIN' ? 'ADMIN' : 'USER';
@@ -52,18 +53,20 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const [authUser, setAuthUser] = useState<AuthSession | null>(null);
const [guestMode, setGuestMode] = useState(false);
const [sessionKey, setSessionKey] = useState(0);
useEffect(() => {
let cancelled = false;
void (async () => {
await initStorage();
if (Capacitor.isNativePlatform()) {
const savedApi = storageGetSync('gc_api_base_url');
if (savedApi && /localhost|127\.0\.0\.1/i.test(savedApi)) {
if (
savedApi
&& (/localhost|127\.0\.0\.1/i.test(savedApi) || /^https:/i.test(savedApi))
) {
await storageRemove('gc_api_base_url');
}
}
refreshApiBaseFromStorage();
ensureMobileApiBase();
const stored = getAuthSession();
if (stored) {
try {
@@ -99,20 +102,18 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setAuthSession(session);
setAuthUser(session);
setGuestMode(false);
setSessionKey(k => k + 1);
invalidateAppSettingsCache();
void reloadAppSettingsCache().finally(() => {
setSessionKey(k => k + 1);
});
void reloadAppSettingsCache();
}, []);
const handleGuestEnter = useCallback(() => {
void clearAuthSession();
setAuthUser(null);
setGuestMode(true);
setSessionKey(k => k + 1);
invalidateAppSettingsCache();
void reloadAppSettingsCache().finally(() => {
setSessionKey(k => k + 1);
});
void reloadAppSettingsCache();
}, []);
const handleLogout = useCallback(async () => {
+28
View File
@@ -0,0 +1,28 @@
import { useEffect, useState } from 'react';
import { Capacitor } from '@capacitor/core';
import { App } from '@capacitor/app';
/** 네이티브: build.gradle versionName / versionCode · 웹: dev */
export function useAppVersion(): string {
const [label, setLabel] = useState('…');
useEffect(() => {
void (async () => {
if (Capacitor.isNativePlatform()) {
try {
const { version, build } = await App.getInfo();
setLabel(build ? `${version} (build ${build})` : version);
return;
} catch {
/* fallback */
}
}
setLabel(
(import.meta as ImportMeta & { env?: { VITE_APP_VERSION?: string } }).env
?.VITE_APP_VERSION ?? 'web',
);
})();
}, []);
return label;
}
+20
View File
@@ -0,0 +1,20 @@
import { Capacitor } from '@capacitor/core';
import {
normalizeApiBaseUrl,
PRODUCTION_API_BASE,
refreshApiBaseFromStorage,
setApiBase,
storageGetSync,
} from './shared';
/** 네이티브 APK — 잘못된 API(localhost·https) 제거 후 exdev 고정 */
export function ensureMobileApiBase(): string {
refreshApiBaseFromStorage();
if (!Capacitor.isNativePlatform()) {
return storageGetSync('gc_api_base_url') ?? PRODUCTION_API_BASE;
}
const saved = normalizeApiBaseUrl(storageGetSync('gc_api_base_url'));
const base = saved ?? PRODUCTION_API_BASE;
setApiBase(base);
return base;
}
+37 -10
View File
@@ -1,13 +1,18 @@
/**
* 모바일 로그인 — SplashScreen UI + 느린 서버 대응(타임아웃·안내 문구)
* 모바일 로그인 — SplashScreen UI + 느린 서버(exdev) 대응
*/
import React, { useEffect, useState } from 'react';
import { loginUser, type LoginResponse } from '../lib/shared';
import {
loginUser,
initStorage,
type LoginResponse,
} from '../lib/shared';
import { ensureMobileApiBase } from '../lib/ensureMobileApiBase';
import { useAppVersion } from '../hooks/useAppVersion';
import '@frontend/styles/splashScreen.css';
const DEFAULT_USERNAME = 'admin';
const DEFAULT_PASSWORD = 'admin';
const APP_VERSION = '1.2';
interface Props {
onLoginSuccess: (res: LoginResponse) => void;
@@ -15,23 +20,38 @@ interface Props {
}
export default function MobileLoginScreen({ onLoginSuccess, onGuest }: Props) {
const appVersion = useAppVersion();
const [username, setUsername] = useState(DEFAULT_USERNAME);
const [password, setPassword] = useState(DEFAULT_PASSWORD);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [slowHint, setSlowHint] = useState(false);
const [storageReady, setStorageReady] = useState(false);
const [apiBase, setApiBase] = useState('');
useEffect(() => {
void (async () => {
await initStorage();
setApiBase(ensureMobileApiBase());
setStorageReady(true);
})();
}, []);
useEffect(() => {
if (!loading) {
setSlowHint(false);
return;
}
const t = window.setTimeout(() => setSlowHint(true), 5000);
const t = window.setTimeout(() => setSlowHint(true), 4000);
return () => window.clearTimeout(t);
}, [loading]);
const submitLogin = async (e: React.FormEvent) => {
e.preventDefault();
if (!storageReady) {
setError('앱 초기화 중입니다. 잠시 후 다시 시도하세요.');
return;
}
setError(null);
setLoading(true);
try {
@@ -60,7 +80,7 @@ export default function MobileLoginScreen({ onLoginSuccess, onGuest }: Props) {
autoComplete="username"
value={username}
onChange={ev => setUsername(ev.target.value)}
disabled={loading}
disabled={loading || !storageReady}
placeholder="ID"
/>
<input
@@ -69,22 +89,29 @@ export default function MobileLoginScreen({ onLoginSuccess, onGuest }: Props) {
autoComplete="current-password"
value={password}
onChange={ev => setPassword(ev.target.value)}
disabled={loading}
disabled={loading || !storageReady}
placeholder="••••••••"
/>
{error && <p className="splash-error">{error}</p>}
{slowHint && loading && !error && (
<p className="splash-error" style={{ opacity: 0.85 }}>
. WiFi·
exdev . <strong> 3</strong> .
.
<br />
<span style={{ fontSize: 11 }}>API: {apiBase || '…'}</span>
</p>
)}
<button type="submit" className="splash-login-btn" disabled={loading}>
{loading ? '로그인 중…' : '로그인'}
<button
type="submit"
className="splash-login-btn"
disabled={loading || !storageReady}
>
{!storageReady ? '준비 중…' : loading ? '로그인 중…' : '로그인'}
</button>
</form>
<p className="splash-version">
: {APP_VERSION} | Core Engine: Ta4j &amp; Spring Boot
APK {appVersion} · API {apiBase.replace(/^https?:\/\//, '')}
</p>
</div>
+72 -7
View File
@@ -10,8 +10,7 @@ import {
import { useAuth } from '../../contexts/AuthContext';
import { useAppSettings, resolveAppDefaults } from '../../hooks/useAppSettings';
import { SettingGroup, SettingRow, Toggle } from '../../components/SettingsList';
import { initFcmPush, checkPushPermission } from '../../services/fcm';
import { API_BASE } from '../../lib/shared';
import { API_BASE, normalizeApiBaseUrl, PRODUCTION_API_BASE } from '../../lib/shared';
export default function SettingsScreen() {
const { authUser, guestMode, sessionKey, handleLogout } = useAuth();
@@ -19,11 +18,11 @@ export default function SettingsScreen() {
const defaults = resolveAppDefaults(settings);
const [apiUrl, setApiUrl] = useState(API_BASE);
const [fcmAvailable, setFcmAvailable] = useState(false);
const [pushPerm, setPushPerm] = useState<string>('prompt');
const [pushPerm, setPushPerm] = useState<string>('');
const [fcmBusy, setFcmBusy] = useState(false);
useEffect(() => {
void loadFcmStatus().then(s => setFcmAvailable(!!s?.available));
void checkPushPermission().then(setPushPerm);
}, []);
const patch = useCallback((p: AppSettingsDto) => save(p as unknown as Parameters<typeof save>[0]), [save]);
@@ -133,12 +132,17 @@ export default function SettingsScreen() {
</SettingGroup>
<SettingGroup title="FCM 푸시">
<SettingRow label="푸시 알림" description={fcmAvailable ? 'Firebase 연결됨' : 'Firebase 미설정'}>
<SettingRow
label="푸시 알림"
description={fcmAvailable ? 'Firebase 연결됨' : 'Firebase 미설정 · 1.9+ APK 필요'}
>
<Toggle
checked={!!defaults.fcmPushEnabled}
onChange={v => {
patch({ fcmPushEnabled: v });
if (v) void initFcmPush().then(() => checkPushPermission().then(setPushPerm));
if (!v) {
void import('../../services/fcm').then(m => m.unregisterFcmPush());
}
}}
label="FCM 푸시"
/>
@@ -146,6 +150,56 @@ export default function SettingsScreen() {
<SettingRow label="권한 상태">
<span className="text-muted">{pushPerm}</span>
</SettingRow>
{defaults.fcmPushEnabled && (
<>
<SettingRow label="① 알림 권한" description="허용 후 앱이 꺼지면 ②는 나중에">
<button
type="button"
className="btn-secondary"
style={{ padding: '8px 12px', minHeight: 36 }}
disabled={fcmBusy}
onClick={() => {
setFcmBusy(true);
void (async () => {
const fcm = await import('../../services/fcm');
const ok = await fcm.requestNotificationPermissionOnly();
setPushPerm(await fcm.checkPushPermission());
if (!ok) {
window.alert('알림 권한이 거부되었습니다.');
}
})().finally(() => setFcmBusy(false));
}}
>
</button>
</SettingRow>
<SettingRow label="② FCM 등록" description="① 완료 후 누르세요 (APK 2.5+)">
<button
type="button"
className="btn-secondary"
style={{ padding: '8px 12px', minHeight: 36 }}
disabled={fcmBusy}
onClick={() => {
setFcmBusy(true);
void (async () => {
const fcm = await import('../../services/fcm');
const ok = await fcm.registerFcmTokenOnly();
setPushPerm(await fcm.checkPushPermission());
if (!ok) {
window.alert(
'FCM 등록 실패.\n'
+ '① 알림 권한 허용 후\n'
+ '② APK 2.5 재설치',
);
}
})().finally(() => setFcmBusy(false));
}}
>
FCM
</button>
</SettingRow>
</>
)}
<SettingRow label="테스트 발송">
<button type="button" className="btn-secondary" style={{ padding: '8px 12px', minHeight: 36 }} onClick={() => void sendFcmTest()}></button>
</SettingRow>
@@ -160,7 +214,18 @@ export default function SettingsScreen() {
/>
</SettingRow>
<SettingRow label="적용">
<button type="button" className="btn-secondary" style={{ padding: '8px 12px' }} onClick={() => { setApiBase(apiUrl); window.location.reload(); }}></button>
<button
type="button"
className="btn-secondary"
style={{ padding: '8px 12px' }}
onClick={() => {
const next = normalizeApiBaseUrl(apiUrl) ?? PRODUCTION_API_BASE;
setApiBase(next);
window.location.reload();
}}
>
</button>
</SettingRow>
<SettingRow label="Device ID">
<span className="text-muted" style={{ fontSize: 10, wordBreak: 'break-all', maxWidth: 180 }}>{getStoredDeviceId()}</span>
@@ -11,6 +11,7 @@ interface Props {
session: VirtualSessionConfig;
strategies: StrategyDto[];
snapshot?: VirtualIndicatorSnapshot;
signalLoading?: boolean;
liveConnected: boolean;
onBack: () => void;
onTrade: (side: TradeSide) => void;
@@ -20,6 +21,7 @@ interface Props {
export default function VirtualFocusScreen({
target,
snapshot,
signalLoading,
liveConnected,
onBack,
onTrade,
@@ -72,6 +74,11 @@ export default function VirtualFocusScreen({
</div>
<h3 style={{ fontSize: 14, marginBottom: 8 }}> </h3>
{signalLoading && !(snapshot?.rows?.length) && (
<p className="text-muted" style={{ fontSize: 13, marginBottom: 12 }}>
( · )
</p>
)}
<div className="stack-list">
{(snapshot?.rows ?? []).map(row => (
<div key={row.id} className="card mobile-history-row">
@@ -11,6 +11,7 @@ interface Props {
globalStrategyId: number | null;
strategies: StrategyDto[];
snapshot?: VirtualIndicatorSnapshot;
signalLoading?: boolean;
liveStatus?: VirtualLiveStatus;
onDetail: () => void;
onTrade: (side: TradeSide) => void;
@@ -23,6 +24,7 @@ export default function VirtualTargetListRow({
globalStrategyId,
strategies,
snapshot,
signalLoading,
liveStatus,
onDetail,
onTrade,
@@ -42,7 +44,10 @@ export default function VirtualTargetListRow({
<span>{target.koreanName ?? target.market}</span>
</div>
<div className="text-muted mobile-list-row-meta">
{target.market} · {strategyName} · {matchPct.toFixed(0)}%
{target.market} · {strategyName}
{signalLoading && !snapshot?.rows?.length
? ' · 지표 수집 중…'
: ` · ${matchPct.toFixed(0)}%`}
</div>
</div>
<div className="mobile-list-row-pin">
@@ -32,6 +32,7 @@ export default function VirtualTradingScreen() {
trades,
loading,
snapshots,
snapshotLoadingByMarket,
liveStatusByMarket,
handleStart,
handleStop,
@@ -61,6 +62,7 @@ export default function VirtualTradingScreen() {
session={session}
strategies={strategies}
snapshot={snap}
signalLoading={snapshotLoadingByMarket[market]}
liveConnected={liveConnected}
onBack={goVirtualList}
onTrade={side => goVirtualTrade(market, side)}
@@ -175,6 +177,7 @@ export default function VirtualTradingScreen() {
const strategyId = resolveVirtualTargetStrategyId(t, session.globalStrategyId);
const snapKey = `${t.market}:${strategyId ?? ''}`;
const snap = snapshots[snapKey] ?? snapshots[t.market];
const signalLoading = snapshotLoadingByMarket[t.market];
return (
<VirtualTargetListRow
key={t.market}
@@ -182,6 +185,7 @@ export default function VirtualTradingScreen() {
globalStrategyId={session.globalStrategyId}
strategies={strategies}
snapshot={snap}
signalLoading={signalLoading}
liveStatus={liveStatusByMarket[t.market]}
onDetail={() => goVirtualDetail(t.market)}
onTrade={side => goVirtualTrade(t.market, side)}
+113 -58
View File
@@ -1,12 +1,15 @@
import {
registerFcmToken,
deleteFcmToken,
storageGet,
storageSet,
storageRemove,
} from '../lib/shared';
import { PushNotifications } from '@capacitor/push-notifications';
import { Capacitor } from '@capacitor/core';
import { Capacitor, registerPlugin } from '@capacitor/core';
/** AndroidManifest default_notification_channel_id 와 동일 */
export const FCM_CHANNEL_ID = 'goldenchart_trade_signals';
export const FCM_USER_OPT_IN_KEY = 'gc_fcm_user_opt_in';
export const FCM_REGISTERED_KEY = 'gc_fcm_registered';
export interface FcmPayload {
type?: string;
@@ -21,93 +24,145 @@ type FcmHandlers = {
onTap?: (data: FcmPayload) => void;
};
let initialized = false;
let listenersAttached = false;
async function ensureAndroidNotificationChannel(): Promise<void> {
if (Capacitor.getPlatform() !== 'android') return;
try {
await PushNotifications.createChannel({
id: FCM_CHANNEL_ID,
name: '매매 시그널 알림',
description: '전략 조건 일치 시 매수·매도 푸시',
importance: 5,
visibility: 1,
vibration: true,
});
} catch (e) {
console.warn('[FCM] createChannel failed', e);
}
interface GoldenFcmPlugin {
checkPermissions(): Promise<{ receive: string }>;
requestPermissions(): Promise<{ receive: string }>;
register(): Promise<{ value: string }>;
unregister(): Promise<void>;
addListener(
event: 'registration',
listener: (data: { value: string }) => void,
): Promise<{ remove: () => void }>;
addListener(
event: 'registrationError',
listener: (data: { error?: string }) => void,
): Promise<{ remove: () => void }>;
addListener(
event: 'pushNotificationReceived',
listener: (data: { title?: string; body?: string; data?: FcmPayload }) => void,
): Promise<{ remove: () => void }>;
addListener(
event: 'pushNotificationActionPerformed',
listener: (data: { notification?: { data?: FcmPayload } }) => void,
): Promise<{ remove: () => void }>;
}
function attachListeners(handlers: FcmHandlers): void {
if (listenersAttached) return;
const GoldenFcm = registerPlugin<GoldenFcmPlugin>('GoldenFcm');
let tokenRegistered = false;
let listenersAttached = false;
async function userOptedIn(): Promise<boolean> {
return (await storageGet(FCM_USER_OPT_IN_KEY)) === '1';
}
export async function markFcmUserOptIn(): Promise<void> {
await storageSet(FCM_USER_OPT_IN_KEY, '1');
}
async function attachListeners(handlers: {
onForeground?: (title: string, body: string, data?: FcmPayload) => void;
onTap?: (data: FcmPayload) => void;
}): Promise<void> {
if (listenersAttached || Capacitor.getPlatform() !== 'android') return;
listenersAttached = true;
void PushNotifications.addListener('registration', async token => {
await GoldenFcm.addListener('registration', async data => {
try {
await registerFcmToken(token.value);
console.info('[FCM] token registered with server');
await registerFcmToken(data.value);
await storageSet(FCM_REGISTERED_KEY, '1');
} catch (e) {
console.warn('[FCM] token register failed', e);
}
});
void PushNotifications.addListener('registrationError', err => {
await GoldenFcm.addListener('registrationError', err => {
console.warn('[FCM] registration error', err);
void storageRemove(FCM_REGISTERED_KEY);
});
void PushNotifications.addListener('pushNotificationReceived', notification => {
await GoldenFcm.addListener('pushNotificationReceived', notification => {
const title = notification.title ?? 'GoldenChart';
const body = notification.body ?? '';
handlers.onForeground?.(title, body, notification.data as FcmPayload);
});
void PushNotifications.addListener('pushNotificationActionPerformed', action => {
handlers.onTap?.(action.notification.data as FcmPayload);
await GoldenFcm.addListener('pushNotificationActionPerformed', action => {
handlers.onTap?.(action.notification?.data as FcmPayload);
});
}
/**
* FCM 토큰 등록 (로그인 후 호출 — 서버 fcm_push_enabled 와 별개로 토큰만 등록)
*/
export async function initFcmPush(handlers: FcmHandlers = {}): Promise<boolean> {
if (!Capacitor.isNativePlatform()) {
console.info('[FCM] Web dev — native push skipped');
/** ① 알림 권한만 (FCM 미호출 — 허용 직후 크래시 방지) */
export async function requestNotificationPermissionOnly(): Promise<boolean> {
if (Capacitor.getPlatform() !== 'android') return false;
await markFcmUserOptIn();
const perm = await GoldenFcm.checkPermissions();
if (perm.receive === 'granted') return true;
const req = await GoldenFcm.requestPermissions();
return req.receive === 'granted';
}
/** ② 권한 허용 후 FCM 토큰 등록 */
export async function registerFcmTokenOnly(): Promise<boolean> {
if (Capacitor.getPlatform() !== 'android') return false;
if (!(await userOptedIn())) return false;
const perm = await GoldenFcm.checkPermissions();
if (perm.receive !== 'granted') return false;
try {
await attachListeners({});
if (tokenRegistered) return true;
await new Promise(r => setTimeout(r, 800));
const result = await GoldenFcm.register();
tokenRegistered = true;
if (result?.value) {
await registerFcmToken(result.value);
await storageSet(FCM_REGISTERED_KEY, '1');
}
return !!result?.value;
} catch (e) {
console.warn('[FCM] register failed', e);
return false;
}
}
await ensureAndroidNotificationChannel();
attachListeners(handlers);
export async function isFcmRegisteredOnDevice(): Promise<boolean> {
return (await storageGet(FCM_REGISTERED_KEY)) === '1';
}
const perm = await PushNotifications.checkPermissions();
if (perm.receive !== 'granted') {
const req = await PushNotifications.requestPermissions();
if (req.receive !== 'granted') {
console.warn('[FCM] notification permission denied');
return false;
}
}
if (!initialized) {
await PushNotifications.register();
initialized = true;
}
return true;
/** @deprecated — requestNotificationPermissionOnly + registerFcmTokenOnly 사용 */
export async function initFcmPush(): Promise<boolean> {
const ok = await requestNotificationPermissionOnly();
if (!ok) return false;
await new Promise(r => setTimeout(r, 1000));
return registerFcmTokenOnly();
}
export async function unregisterFcmPush(): Promise<void> {
if (!Capacitor.isNativePlatform()) return;
try {
await deleteFcmToken();
if (Capacitor.getPlatform() === 'android' && (await userOptedIn())) {
await GoldenFcm.unregister();
}
} catch { /* ignore */ }
initialized = false;
tokenRegistered = false;
listenersAttached = false;
await storageRemove(FCM_REGISTERED_KEY);
await storageRemove(FCM_USER_OPT_IN_KEY);
}
export async function checkPushPermission(): Promise<'granted' | 'denied' | 'prompt'> {
if (!Capacitor.isNativePlatform()) return 'prompt';
const perm = await PushNotifications.checkPermissions();
if (perm.receive === 'granted') return 'granted';
if (perm.receive === 'denied') return 'denied';
return 'prompt';
export async function checkPushPermission(): Promise<'granted' | 'denied' | 'prompt' | 'unknown'> {
if (!Capacitor.isNativePlatform()) return 'unknown';
try {
if (Capacitor.getPlatform() !== 'android') return 'unknown';
const perm = await GoldenFcm.checkPermissions();
if (perm.receive === 'granted') return 'granted';
if (perm.receive === 'denied') return 'denied';
return 'prompt';
} catch {
return 'unknown';
}
}