diff --git a/app/.env.example b/app/.env.example index dae98f2..7eebaaa 100644 --- a/app/.env.example +++ b/app/.env.example @@ -1 +1,5 @@ -VITE_API_BASE_URL=http://localhost:8080/api +# 웹 로컬 개발 +# VITE_API_BASE_URL=http://localhost:8080/api + +# 모바일 APK / exdev (HTTPS 443 미사용 — http 만) +VITE_API_BASE_URL=http://exdev.co.kr/api diff --git a/app/README.md b/app/README.md index 7ec1b07..c5a6003 100644 --- a/app/README.md +++ b/app/README.md @@ -34,11 +34,13 @@ npm run cap:android # or cap:ios Copy `.env.example` to `.env` and set `VITE_API_BASE_URL`. -### 로그인 시 "Failed to fetch" +### 로그인 시 "Failed to fetch" / "서버 응답이 느립니다" -- APK는 빌드 시 `.env`의 `VITE_API_BASE_URL`이 번들에 박힙니다 (기본: `http://exdev.co.kr/api`). -- `capacitor.config.ts`의 `androidScheme`은 **`http`** 여야 합니다. `https`이면 WebView가 `https://localhost`에서 뜨고 HTTP API 호출이 차단됩니다. -- 설정에 `localhost` API가 저장돼 있으면 앱 시작 시 자동으로 제거합니다. +- API는 **`http://exdev.co.kr/api`** 만 사용 (`https://exdev.co.kr` 은 443 미개방). +- exdev 로그인은 **10~30초** 걸릴 수 있음 — 2분 타임아웃까지 대기. +- APK는 `app/.env`의 `VITE_API_BASE_URL`이 빌드에 포함되어야 함. +- `androidScheme`은 **`http`**. `CapacitorHttp`는 비활성(로그인 fetch 안정화). +- 설정에 `localhost`·`https://` API가 저장돼 있으면 앱 시작 시 제거·`http`로 교정. ## FCM (Firebase) diff --git a/app/android/FCM_SETUP.md b/app/android/FCM_SETUP.md index 83b7a4a..3286a6e 100644 --- a/app/android/FCM_SETUP.md +++ b/app/android/FCM_SETUP.md @@ -12,11 +12,18 @@ ## 2. 프로젝트에 배치 ```bash +# 권장: repo 루트 app/google-services.json (com.goldenchart.app 포함) +cp ~/Downloads/google-services.json app/google-services.json +./scripts/ensure-android-google-services.sh + +# 또는 한 번에 ./scripts/setup-android-fcm.sh ~/Downloads/google-services.json -# 또는 -cp ~/Downloads/google-services.json app/android/app/google-services.json ``` +빌드 시 `ensure-android-google-services.sh` 가 `app/google-services.json` 을 +`app/android/app/google-services.json` 으로 복사합니다. **이 파일이 없으면** +알림 권한 허용 직후 `PushNotifications.register()` 에서 **앱이 종료**될 수 있습니다. + ## 3. 빌드 ```bash @@ -35,17 +42,21 @@ cd app/android && ./gradlew assembleRelease ## 5. 앱에서 확인 -1. 로그인 후 **설정 → FCM 푸시** ON -2. 알림 권한 허용 -3. **테스트 발송** 버튼 -4. 전략 시그널 발생 시 푸시 수신 +1. **2.4 이상 APK** 설치 (`google-services.json` 포함 빌드 필수) +2. 크래시가 반복되면 **앱 삭제 후 재설치** (이전에 허용한 알림 권한 초기화) +3. 로그인 → **설정 → FCM 푸시** ON +4. **「알림 허용 · FCM 등록」** 버튼 → 시스템 팝업에서 허용 (2초 후 등록) +5. **테스트 발송** 으로 확인 + +로그인·메인 진입 시에는 **알림 권한을 절대 요청하지 않습니다** (2.3+). +FCM 푸시 토글 ON → **「알림 허용 · FCM 등록」** 버튼을 눌렀을 때만 권한 팝업이 뜹니다. ## goldenApp 과의 차이 | 항목 | goldenApp | GoldenChart (Capacitor) | |------|-----------|-------------------------| | 패키지 | `com.golden.app` | `com.goldenchart.app` | -| FCM SDK | 직접 Firebase Messaging | `@capacitor/push-notifications` | +| FCM SDK | 직접 Firebase Messaging | 앱 내장 `GoldenFcm` 플러그인 (Capacitor Push 미사용) | | 토큰 등록 | `POST /api/fcm/token` | 동일 (`registerFcmToken`) | | 알림 채널 | `golden_alerts` | `goldenchart_trade_signals` | diff --git a/app/android/app/build.gradle b/app/android/app/build.gradle index 2fb6e28..f2b2d43 100644 --- a/app/android/app/build.gradle +++ b/app/android/app/build.gradle @@ -7,8 +7,8 @@ android { applicationId "com.goldenchart.app" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 7 - versionName "1.6" + versionCode 16 + versionName "2.5" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. @@ -40,15 +40,17 @@ dependencies { androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion" implementation project(':capacitor-cordova-android-plugins') + implementation platform('com.google.firebase:firebase-bom:33.7.0') + implementation 'com.google.firebase:firebase-messaging' + implementation 'com.google.android.gms:play-services-base:18.5.0' } apply from: 'capacitor.build.gradle' -try { - def servicesJSON = file('google-services.json') - if (servicesJSON.text) { - apply plugin: 'com.google.gms.google-services' - } -} catch(Exception e) { - logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work") +def servicesJSON = file('google-services.json') +if (!servicesJSON.exists()) { + throw new GradleException( + 'google-services.json 없음. 실행: ./scripts/ensure-android-google-services.sh' + ) } +apply plugin: 'com.google.gms.google-services' diff --git a/app/android/app/capacitor.build.gradle b/app/android/app/capacitor.build.gradle index 5664116..da3f341 100644 --- a/app/android/app/capacitor.build.gradle +++ b/app/android/app/capacitor.build.gradle @@ -12,7 +12,6 @@ dependencies { implementation project(':capacitor-app') implementation project(':capacitor-haptics') implementation project(':capacitor-preferences') - implementation project(':capacitor-push-notifications') implementation project(':capacitor-splash-screen') implementation project(':capacitor-status-bar') diff --git a/app/android/app/google-services.json b/app/android/app/google-services.json new file mode 100644 index 0000000..9bcab5c --- /dev/null +++ b/app/android/app/google-services.json @@ -0,0 +1,48 @@ +{ + "project_info": { + "project_number": "353782909389", + "project_id": "goldenanalysisapp", + "storage_bucket": "goldenanalysisapp.firebasestorage.app" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:353782909389:android:45e0218e69611efcc75c84", + "android_client_info": { + "package_name": "com.golden.app" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyBZWkVrPvG88_bYnIG94yiml3VXzht157Y" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:353782909389:android:62e71bbb669c64bbc75c84", + "android_client_info": { + "package_name": "com.goldenchart.app" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyBZWkVrPvG88_bYnIG94yiml3VXzht157Y" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/app/android/app/src/main/AndroidManifest.xml b/app/android/app/src/main/AndroidManifest.xml index a17ac3c..e720e04 100644 --- a/app/android/app/src/main/AndroidManifest.xml +++ b/app/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ - + @@ -18,6 +19,16 @@ + + + + + + + + + + + e : message.getData().entrySet()) { + data.put(e.getKey(), e.getValue()); + } + notification.put("data", data); + GoldenFcmPlugin.emitPushReceived(notification); + } +} diff --git a/app/android/app/src/main/java/com/goldenchart/app/GoldenFcmPlugin.java b/app/android/app/src/main/java/com/goldenchart/app/GoldenFcmPlugin.java new file mode 100644 index 0000000..4970538 --- /dev/null +++ b/app/android/app/src/main/java/com/goldenchart/app/GoldenFcmPlugin.java @@ -0,0 +1,196 @@ +package com.goldenchart.app; + +import android.Manifest; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; + +import com.getcapacitor.JSObject; +import com.getcapacitor.Plugin; +import com.getcapacitor.PluginCall; +import com.getcapacitor.PluginMethod; +import com.getcapacitor.annotation.CapacitorPlugin; +import com.getcapacitor.annotation.Permission; +import com.getcapacitor.annotation.PermissionCallback; +import com.google.android.gms.common.ConnectionResult; +import com.google.android.gms.common.GoogleApiAvailability; +import com.google.firebase.FirebaseApp; +import com.google.firebase.messaging.FirebaseMessaging; + +import java.util.concurrent.Executors; + +/** + * FCM — Capacitor PushNotifications 미사용 (크래시 방지) + */ +@CapacitorPlugin( + name = "GoldenFcm", + permissions = { + @Permission(strings = { Manifest.permission.POST_NOTIFICATIONS }, alias = GoldenFcmPlugin.PERM) + } +) +public class GoldenFcmPlugin extends Plugin { + + static final String PERM = "notifications"; + private static final String TAG = "GoldenFcm"; + private static GoldenFcmPlugin instance; + + @Override + public void load() { + instance = this; + } + + static void emitRegistration(String token) { + if (instance == null || token == null) return; + JSObject data = new JSObject(); + data.put("value", token); + instance.notifyListeners("registration", data); + } + + static void emitRegistrationError(String message) { + if (instance == null) return; + JSObject data = new JSObject(); + data.put("error", message != null ? message : "unknown"); + instance.notifyListeners("registrationError", data); + } + + static void emitPushReceived(JSObject notification) { + if (instance == null) return; + instance.notifyListeners("pushNotificationReceived", notification); + } + + static void emitPushAction(JSObject action) { + if (instance == null) return; + instance.notifyListeners("pushNotificationActionPerformed", action); + } + + private static String permissionStateToString(com.getcapacitor.PermissionState state) { + if (state == com.getcapacitor.PermissionState.GRANTED) return "granted"; + if (state == com.getcapacitor.PermissionState.DENIED) return "denied"; + return "prompt"; + } + + private boolean ensurePlayServices(PluginCall call) { + try { + int code = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(getContext()); + if (code != ConnectionResult.SUCCESS) { + call.reject("Google Play 서비스 필요 (코드 " + code + ")"); + return false; + } + } catch (Throwable t) { + call.reject("Play 서비스 확인 실패: " + t.getMessage()); + return false; + } + return true; + } + + @PluginMethod + public void checkPermissions(PluginCall call) { + JSObject r = new JSObject(); + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + r.put("receive", "granted"); + call.resolve(r); + return; + } + r.put("receive", permissionStateToString(getPermissionState(PERM))); + call.resolve(r); + } + + /** 알림 권한만 요청 — FCM register 호출 없음 */ + @PluginMethod + public void requestPermissions(PluginCall call) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + JSObject r = new JSObject(); + r.put("receive", "granted"); + call.resolve(r); + return; + } + if (getPermissionState(PERM) == com.getcapacitor.PermissionState.GRANTED) { + JSObject r = new JSObject(); + r.put("receive", "granted"); + call.resolve(r); + return; + } + call.setKeepAlive(true); + requestPermissionForAlias(PERM, call, "onPermResult"); + } + + @PermissionCallback + private void onPermResult(PluginCall call) { + new Handler(Looper.getMainLooper()).postDelayed(() -> { + try { + JSObject r = new JSObject(); + r.put("receive", permissionStateToString(getPermissionState(PERM))); + call.resolve(r); + } catch (Throwable t) { + Log.e(TAG, "onPermResult", t); + call.reject(t.getMessage()); + } + }, 600); + } + + /** 권한 허용 후 별도 호출 — Firebase getToken */ + @PluginMethod + public void register(PluginCall call) { + call.setKeepAlive(true); + + if (getActivity() == null || getActivity().isFinishing()) { + call.reject("Activity unavailable"); + return; + } + if (!ensurePlayServices(call)) return; + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU + && getPermissionState(PERM) != com.getcapacitor.PermissionState.GRANTED) { + call.reject("알림 권한이 필요합니다"); + return; + } + + Executors.newSingleThreadExecutor().execute(() -> { + try { + if (FirebaseApp.getApps(getContext()).isEmpty()) { + FirebaseApp.initializeApp(getContext()); + } + FirebaseMessaging.getInstance().setAutoInitEnabled(true); + FirebaseMessaging.getInstance() + .getToken() + .addOnCompleteListener(task -> { + new Handler(Looper.getMainLooper()).post(() -> { + if (!task.isSuccessful()) { + Exception ex = task.getException(); + String msg = ex != null ? ex.getMessage() : "getToken failed"; + Log.w(TAG, "getToken failed", ex); + emitRegistrationError(msg); + call.reject(msg); + return; + } + String token = task.getResult(); + Log.i(TAG, "FCM token ok"); + emitRegistration(token); + JSObject ret = new JSObject(); + ret.put("value", token); + call.resolve(ret); + }); + }); + } catch (Throwable t) { + Log.e(TAG, "register", t); + new Handler(Looper.getMainLooper()).post(() -> + call.reject(t.getMessage() != null ? t.getMessage() : "register failed") + ); + } + }); + } + + @PluginMethod + public void unregister(PluginCall call) { + try { + if (!FirebaseApp.getApps(getContext()).isEmpty()) { + FirebaseMessaging.getInstance().setAutoInitEnabled(false); + FirebaseMessaging.getInstance().deleteToken(); + } + } catch (Throwable t) { + Log.w(TAG, "unregister", t); + } + call.resolve(); + } +} diff --git a/app/android/app/src/main/java/com/goldenchart/app/MainActivity.java b/app/android/app/src/main/java/com/goldenchart/app/MainActivity.java index e6f2555..d745d92 100644 --- a/app/android/app/src/main/java/com/goldenchart/app/MainActivity.java +++ b/app/android/app/src/main/java/com/goldenchart/app/MainActivity.java @@ -4,20 +4,37 @@ import android.app.NotificationChannel; import android.app.NotificationManager; import android.os.Build; import android.os.Bundle; +import android.util.Log; import com.getcapacitor.BridgeActivity; +import com.google.firebase.FirebaseApp; /** - * Capacitor 메인 액티비티 — FCM 알림 채널 생성 (goldenApp / Android 8+) + * Capacitor 메인 액티비티 — GoldenFcm 플러그인 + 알림 채널 */ public class MainActivity extends BridgeActivity { + private static final String TAG = "GoldenChart"; + @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); + registerPlugin(GoldenFcmPlugin.class); + initFirebaseSafe(); createNotificationChannel(); } + private void initFirebaseSafe() { + try { + if (FirebaseApp.getApps(this).isEmpty()) { + FirebaseApp.initializeApp(this); + Log.i(TAG, "FirebaseApp initialized"); + } + } catch (Throwable t) { + Log.w(TAG, "FirebaseApp init failed", t); + } + } + private void createNotificationChannel() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return; String channelId = getString(R.string.default_notification_channel_id); diff --git a/app/android/capacitor.settings.gradle b/app/android/capacitor.settings.gradle index 7d5e659..11e7b73 100644 --- a/app/android/capacitor.settings.gradle +++ b/app/android/capacitor.settings.gradle @@ -11,9 +11,6 @@ project(':capacitor-haptics').projectDir = new File('../../node_modules/@capacit include ':capacitor-preferences' project(':capacitor-preferences').projectDir = new File('../../node_modules/@capacitor/preferences/android') -include ':capacitor-push-notifications' -project(':capacitor-push-notifications').projectDir = new File('../../node_modules/@capacitor/push-notifications/android') - include ':capacitor-splash-screen' project(':capacitor-splash-screen').projectDir = new File('../../node_modules/@capacitor/splash-screen/android') diff --git a/app/capacitor.config.ts b/app/capacitor.config.ts index 586f0b6..12ef4c3 100644 --- a/app/capacitor.config.ts +++ b/app/capacitor.config.ts @@ -10,17 +10,15 @@ const config: CapacitorConfig = { iosScheme: 'capacitor', }, plugins: { + // 네이티브 HTTP + AbortSignal 조합이 로그인 fetch 를 끊는 경우가 있어 WebView fetch 사용 CapacitorHttp: { - enabled: true, + enabled: false, }, SplashScreen: { launchAutoHide: true, backgroundColor: '#0f0f23', showSpinner: false, }, - PushNotifications: { - presentationOptions: ['badge', 'sound', 'alert'], - }, }, }; diff --git a/app/google-services.json b/app/google-services.json new file mode 100644 index 0000000..9bcab5c --- /dev/null +++ b/app/google-services.json @@ -0,0 +1,48 @@ +{ + "project_info": { + "project_number": "353782909389", + "project_id": "goldenanalysisapp", + "storage_bucket": "goldenanalysisapp.firebasestorage.app" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:353782909389:android:45e0218e69611efcc75c84", + "android_client_info": { + "package_name": "com.golden.app" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyBZWkVrPvG88_bYnIG94yiml3VXzht157Y" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:353782909389:android:62e71bbb669c64bbc75c84", + "android_client_info": { + "package_name": "com.goldenchart.app" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyBZWkVrPvG88_bYnIG94yiml3VXzht157Y" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/app/package.json b/app/package.json index 0e80320..1317de7 100644 --- a/app/package.json +++ b/app/package.json @@ -7,7 +7,7 @@ "dev": "vite", "build": "tsc && vite build", "preview": "vite preview", - "cap:sync": "npm run build && npx cap sync", + "cap:sync": "npm run build && bash ../scripts/ensure-android-google-services.sh && npx cap sync", "cap:open:android": "npx cap open android", "cap:open:ios": "npx cap open ios" }, @@ -18,7 +18,6 @@ "@capacitor/haptics": "^7.0.0", "@capacitor/ios": "^7.0.0", "@capacitor/preferences": "^7.0.0", - "@capacitor/push-notifications": "^7.0.0", "@capacitor/splash-screen": "^7.0.0", "@capacitor/status-bar": "^7.0.0", "@goldenchart/shared": "*", diff --git a/app/src/App.tsx b/app/src/App.tsx index 9310b87..4af0ce0 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -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'); diff --git a/app/src/contexts/AuthContext.tsx b/app/src/contexts/AuthContext.tsx index 267bb7c..1e13c3c 100644 --- a/app/src/contexts/AuthContext.tsx +++ b/app/src/contexts/AuthContext.tsx @@ -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(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 () => { diff --git a/app/src/hooks/useAppVersion.ts b/app/src/hooks/useAppVersion.ts new file mode 100644 index 0000000..9f9c29c --- /dev/null +++ b/app/src/hooks/useAppVersion.ts @@ -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; +} diff --git a/app/src/lib/ensureMobileApiBase.ts b/app/src/lib/ensureMobileApiBase.ts new file mode 100644 index 0000000..5ea7144 --- /dev/null +++ b/app/src/lib/ensureMobileApiBase.ts @@ -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; +} diff --git a/app/src/screens/MobileLoginScreen.tsx b/app/src/screens/MobileLoginScreen.tsx index c659d3a..e55d09c 100644 --- a/app/src/screens/MobileLoginScreen.tsx +++ b/app/src/screens/MobileLoginScreen.tsx @@ -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(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" /> setPassword(ev.target.value)} - disabled={loading} + disabled={loading || !storageReady} placeholder="••••••••" /> {error &&

{error}

} {slowHint && loading && !error && (

- 서버 응답이 느립니다. Wi‑Fi·모바일 데이터를 확인해 주세요… + exdev 서버가 느립니다. 첫 로그인은 최대 3분 걸릴 수 있습니다. + 버튼이 「로그인 중…」인 동안 기다려 주세요. +
+ API: {apiBase || '…'}

)} -

- 버전: {APP_VERSION} | Core Engine: Ta4j & Spring Boot + APK {appVersion} · API {apiBase.replace(/^https?:\/\//, '')}

diff --git a/app/src/screens/settings/SettingsScreen.tsx b/app/src/screens/settings/SettingsScreen.tsx index ac88944..ef57ce5 100644 --- a/app/src/screens/settings/SettingsScreen.tsx +++ b/app/src/screens/settings/SettingsScreen.tsx @@ -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('prompt'); + const [pushPerm, setPushPerm] = useState('—'); + 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[0]), [save]); @@ -133,12 +132,17 @@ export default function SettingsScreen() { - + { 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() { {pushPerm} + {defaults.fcmPushEnabled && ( + <> + + + + + + + + )} @@ -160,7 +214,18 @@ export default function SettingsScreen() { /> - + {getStoredDeviceId()} diff --git a/app/src/screens/virtual/VirtualFocusScreen.tsx b/app/src/screens/virtual/VirtualFocusScreen.tsx index ef3a400..f47f95f 100644 --- a/app/src/screens/virtual/VirtualFocusScreen.tsx +++ b/app/src/screens/virtual/VirtualFocusScreen.tsx @@ -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({

조건 목록

+ {signalLoading && !(snapshot?.rows?.length) && ( +

+ 지표 데이터 수집 중… (업비트 캔들 · 실시간 연결) +

+ )}
{(snapshot?.rows ?? []).map(row => (
diff --git a/app/src/screens/virtual/VirtualTargetListRow.tsx b/app/src/screens/virtual/VirtualTargetListRow.tsx index 51d4c7d..e077675 100644 --- a/app/src/screens/virtual/VirtualTargetListRow.tsx +++ b/app/src/screens/virtual/VirtualTargetListRow.tsx @@ -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({ {target.koreanName ?? target.market}
- {target.market} · {strategyName} · {matchPct.toFixed(0)}% + {target.market} · {strategyName} + {signalLoading && !snapshot?.rows?.length + ? ' · 지표 수집 중…' + : ` · ${matchPct.toFixed(0)}%`}
diff --git a/app/src/screens/virtual/VirtualTradingScreen.tsx b/app/src/screens/virtual/VirtualTradingScreen.tsx index be07dd3..c881eee 100644 --- a/app/src/screens/virtual/VirtualTradingScreen.tsx +++ b/app/src/screens/virtual/VirtualTradingScreen.tsx @@ -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 ( goVirtualDetail(t.market)} onTrade={side => goVirtualTrade(t.market, side)} diff --git a/app/src/services/fcm.ts b/app/src/services/fcm.ts index faea306..1c04318 100644 --- a/app/src/services/fcm.ts +++ b/app/src/services/fcm.ts @@ -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 { - 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; + 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('GoldenFcm'); + +let tokenRegistered = false; +let listenersAttached = false; + +async function userOptedIn(): Promise { + return (await storageGet(FCM_USER_OPT_IN_KEY)) === '1'; +} + +export async function markFcmUserOptIn(): Promise { + 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 { + 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 { - if (!Capacitor.isNativePlatform()) { - console.info('[FCM] Web dev — native push skipped'); +/** ① 알림 권한만 (FCM 미호출 — 허용 직후 크래시 방지) */ +export async function requestNotificationPermissionOnly(): Promise { + 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 { + 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 { + 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 { + const ok = await requestNotificationPermissionOnly(); + if (!ok) return false; + await new Promise(r => setTimeout(r, 1000)); + return registerFcmTokenOnly(); } export async function unregisterFcmPush(): Promise { 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'; + } } diff --git a/app/vite.config.ts b/app/vite.config.ts index 4f596bb..0ef1067 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -8,6 +8,7 @@ const frontendRoot = path.resolve(__dirname, '../frontend/src'); const sharedRoot = path.resolve(__dirname, '../packages/shared/src'); export default defineConfig({ + envDir: __dirname, plugins: [react()], resolve: { dedupe: ['react', 'react-dom', 'lightweight-charts'], diff --git a/frontend/src/components/TrendSearchPage.tsx b/frontend/src/components/TrendSearchPage.tsx index dbfc64b..4f87622 100644 --- a/frontend/src/components/TrendSearchPage.tsx +++ b/frontend/src/components/TrendSearchPage.tsx @@ -183,7 +183,6 @@ const TrendSearchPage: React.FC = ({ void add(row.market, { koreanName, englishName, - candleType: filtersRef.current.timeframe, }); }, [add]); diff --git a/frontend/src/components/VirtualTradingPage.tsx b/frontend/src/components/VirtualTradingPage.tsx index 704b94d..378076b 100644 --- a/frontend/src/components/VirtualTradingPage.tsx +++ b/frontend/src/components/VirtualTradingPage.tsx @@ -43,9 +43,7 @@ import { type VirtualSessionConfig, type VirtualTargetItem, type VirtualCardViewMode, - resolveTargetCandleType, } from '../utils/virtualTradingStorage'; -import { normalizeStartCandleType } from '../utils/strategyStartNodes'; import { useAppSettings, resolveAppDefaults } from '../hooks/useAppSettings'; import { coerceFiniteNumber } from '../utils/safeFormat'; import { @@ -179,7 +177,11 @@ const VirtualTradingPage: React.FC = ({ })), [targets, session.globalStrategyId]); - const snapshots = useVirtualIndicatorSnapshots(targetRefs, strategies, session.running); + const { snapshots, loadingByMarket: snapshotLoadingByMarket } = useVirtualIndicatorSnapshots( + targetRefs, + strategies, + session.running, + ); const { statusByMarket: liveStatusByMarket, lastTickAtByMarket } = useVirtualTargetLiveStatus( targetRefs, session.running, @@ -341,59 +343,6 @@ const VirtualTradingPage: React.FC = ({ } }, [session]); - const handleTargetCandleTypeChange = useCallback(async (market: string, candleType: string) => { - const normalized = normalizeStartCandleType(candleType); - setTargets(prev => prev.map(t => - t.market === market ? { ...t, candleType: normalized } : t, - )); - if (!session.running) return; - const target = targets.find(t => t.market === market); - const strategyId = target - ? resolveVirtualTargetStrategyId(target, session.globalStrategyId) - : session.globalStrategyId; - if (!strategyId) return; - try { - await saveLiveStrategySettings({ - market, - strategyId, - isLiveCheck: true, - executionType: session.executionType, - positionMode: session.positionMode, - skipWatchlistSync: true, - skipGlobalTemplate: true, - }); - if (strategyId) { - await pinStrategyEvaluationTimeframes(market, strategyId); - } - } catch { - window.alert('종목 평가 분봉 변경 저장에 실패했습니다.'); - } - }, [session, targets]); - - useEffect(() => { - let cancelled = false; - const missing = targets.filter(t => !t.candleType); - if (missing.length === 0) return; - void Promise.all( - missing.map(async t => { - try { - const s = await loadLiveStrategySettings(t.market); - return { market: t.market, candleType: s.candleType }; - } catch { - return { market: t.market, candleType: undefined }; - } - }), - ).then(rows => { - if (cancelled) return; - setTargets(prev => prev.map(t => { - const row = rows.find(r => r.market === t.market); - if (t.candleType || !row?.candleType) return t; - return { ...t, candleType: normalizeStartCandleType(row.candleType) }; - })); - }); - return () => { cancelled = true; }; - }, [targets.map(t => `${t.market}:${t.candleType ?? ''}`).join('|')]); - const resyncRunningSession = useCallback(async (nextSession: VirtualSessionConfig) => { if (!session.running || targets.length === 0) return; try { @@ -483,9 +432,9 @@ const VirtualTradingPage: React.FC = ({ targets={targets} strategies={strategies} snapshots={snapshots} + snapshotLoadingByMarket={snapshotLoadingByMarket} session={session} onTargetStrategyChange={(market, strategyId) => void handleTargetStrategyChange(market, strategyId)} - onTargetCandleTypeChange={(market, ct) => void handleTargetCandleTypeChange(market, ct)} liveStatusByMarket={mergedLiveStatus} lastTickAtByMarket={lastTickAtByMarket} selectedMarket={selectedMarket} diff --git a/frontend/src/components/virtual/VirtualTargetCard.tsx b/frontend/src/components/virtual/VirtualTargetCard.tsx index 7e87f12..9dc09e3 100644 --- a/frontend/src/components/virtual/VirtualTargetCard.tsx +++ b/frontend/src/components/virtual/VirtualTargetCard.tsx @@ -5,8 +5,7 @@ import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSn import { useLiveReceiveFlash } from '../../hooks/useLiveReceiveFlash'; import type { ChartRealtimeSource } from '../../hooks/useChartRealtimeData'; import { buildConditionMetrics } from '../../utils/virtualSignalMetrics'; -import { candleTypeToTimeframe } from '../../utils/strategyToChartIndicators'; -import { normalizeStartCandleType } from '../../utils/strategyStartNodes'; +import { resolveStrategyPrimaryTimeframe } from '../../utils/strategyToChartIndicators'; import type { Timeframe } from '../../types'; import VirtualLiveBadge from './VirtualLiveBadge'; import type { VirtualLiveStatus } from '../../hooks/useVirtualTargetLiveStatus'; @@ -27,9 +26,8 @@ interface Props { strategyId: number | null; globalStrategyId: number | null; onStrategyChange?: (strategyId: number | null) => void; - candleType: string; - onCandleTypeChange?: (candleType: string) => void; snapshot: VirtualIndicatorSnapshot | undefined; + signalLoading?: boolean; running: boolean; liveStatus?: VirtualLiveStatus; lastTickAt?: number; @@ -54,9 +52,8 @@ const VirtualTargetCard: React.FC = ({ strategyId, globalStrategyId, onStrategyChange, - candleType, - onCandleTypeChange, snapshot, + signalLoading = false, running, liveStatus = 'idle', lastTickAt, @@ -83,8 +80,10 @@ const VirtualTargetCard: React.FC = ({ const isDetail = viewMode === 'detail'; const isChart = displayMode === 'chart'; - const evalCandleType = normalizeStartCandleType(candleType); - const chartTimeframe = candleTypeToTimeframe(evalCandleType) as Timeframe; + const chartTimeframe = useMemo( + () => resolveStrategyPrimaryTimeframe(strategy) as Timeframe, + [strategy], + ); const receiveSignal = useMemo(() => { if (!running) return null; @@ -184,6 +183,7 @@ const VirtualTargetCard: React.FC = ({ snapshot={snapshot} viewMode={viewMode} receiving={highlightReceiving} + loading={signalLoading} /> )} @@ -192,9 +192,7 @@ const VirtualTargetCard: React.FC = ({ strategies={strategies} strategyId={strategyId} globalStrategyId={globalStrategyId} - candleType={evalCandleType} onStrategyChange={onStrategyChange} - onCandleTypeChange={onCandleTypeChange} />
); diff --git a/frontend/src/components/virtual/VirtualTargetCardChart.tsx b/frontend/src/components/virtual/VirtualTargetCardChart.tsx index 62fa3a8..4d067f0 100644 --- a/frontend/src/components/virtual/VirtualTargetCardChart.tsx +++ b/frontend/src/components/virtual/VirtualTargetCardChart.tsx @@ -34,7 +34,7 @@ interface Props { chartPaneSeparator?: import('../../types/chartPaneSeparator').ChartPaneSeparatorOptions; /** 전체보기 분할 pane — 캔버스 높이 100% */ fillHeight?: boolean; - /** 카드 푸터 평가 분봉과 동기화 */ + /** 미지정 시 전략 DSL 대표 분봉 사용 */ chartTimeframe?: Timeframe; } diff --git a/frontend/src/components/virtual/VirtualTargetCardFoot.tsx b/frontend/src/components/virtual/VirtualTargetCardFoot.tsx index 3bc2737..3440c1b 100644 --- a/frontend/src/components/virtual/VirtualTargetCardFoot.tsx +++ b/frontend/src/components/virtual/VirtualTargetCardFoot.tsx @@ -2,7 +2,6 @@ import React from 'react'; import type { StrategyDto } from '../../utils/backendApi'; import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots'; import { formatUpdatedTime } from '../../utils/virtualSignalMetrics'; -import { STRATEGY_CANDLE_TYPE_OPTIONS } from '../../utils/strategyStartNodes'; import { defaultStrategyOptionLabel, parseTargetStrategySelectValue, @@ -14,20 +13,16 @@ interface Props { strategies: StrategyDto[]; strategyId: number | null; globalStrategyId: number | null; - candleType: string; onStrategyChange?: (strategyId: number | null) => void; - onCandleTypeChange?: (candleType: string) => void; } -/** 카드 하단 — 갱신 시각(좌) · 전략·시간봉 드롭다운(우) */ +/** 카드 하단 — 갱신 시각(좌) · 전략 드롭다운(우) */ const VirtualTargetCardFoot: React.FC = ({ snapshot, strategies, strategyId, globalStrategyId, - candleType, onStrategyChange, - onCandleTypeChange, }) => (
갱신 {formatUpdatedTime(snapshot?.updatedAt)} @@ -49,18 +44,6 @@ const VirtualTargetCardFoot: React.FC = ({ ))} -
); diff --git a/frontend/src/components/virtual/VirtualTargetFocusView.tsx b/frontend/src/components/virtual/VirtualTargetFocusView.tsx index 192373b..40508e0 100644 --- a/frontend/src/components/virtual/VirtualTargetFocusView.tsx +++ b/frontend/src/components/virtual/VirtualTargetFocusView.tsx @@ -13,8 +13,7 @@ import VirtualTargetQuote from './VirtualTargetQuote'; import VirtualTargetCardChart from './VirtualTargetCardChart'; import VirtualTargetSignalPanel from './VirtualTargetSignalPanel'; import VirtualTargetCardFoot from './VirtualTargetCardFoot'; -import { candleTypeToTimeframe } from '../../utils/strategyToChartIndicators'; -import { normalizeStartCandleType } from '../../utils/strategyStartNodes'; +import { resolveStrategyPrimaryTimeframe } from '../../utils/strategyToChartIndicators'; import type { Timeframe } from '../../types'; interface Props { @@ -24,9 +23,8 @@ interface Props { strategyId: number | null; globalStrategyId: number | null; onStrategyChange?: (strategyId: number | null) => void; - candleType: string; - onCandleTypeChange?: (candleType: string) => void; snapshot: VirtualIndicatorSnapshot | undefined; + signalLoading?: boolean; running: boolean; liveStatus?: VirtualLiveStatus; lastTickAt?: number; @@ -48,9 +46,8 @@ const VirtualTargetFocusView: React.FC = ({ strategyId, globalStrategyId, onStrategyChange, - candleType, - onCandleTypeChange, snapshot, + signalLoading = false, running, liveStatus = 'idle', lastTickAt, @@ -72,8 +69,10 @@ const VirtualTargetFocusView: React.FC = ({ }, [running, lastTickAt, snapshot?.updatedAt]); const receiving = useLiveReceiveFlash(receiveSignal, running); const highlightReceiving = chartLiveReceiveHighlight && receiving; - const evalCandleType = normalizeStartCandleType(candleType); - const chartTimeframe = candleTypeToTimeframe(evalCandleType) as Timeframe; + const chartTimeframe = useMemo( + () => resolveStrategyPrimaryTimeframe(strategy) as Timeframe, + [strategy], + ); return (
@@ -129,6 +128,7 @@ const VirtualTargetFocusView: React.FC = ({ snapshot={snapshot} viewMode={viewMode} receiving={highlightReceiving} + loading={signalLoading} />
@@ -139,9 +139,7 @@ const VirtualTargetFocusView: React.FC = ({ strategies={strategies} strategyId={strategyId} globalStrategyId={globalStrategyId} - candleType={evalCandleType} onStrategyChange={onStrategyChange} - onCandleTypeChange={onCandleTypeChange} /> ); diff --git a/frontend/src/components/virtual/VirtualTargetGrid.tsx b/frontend/src/components/virtual/VirtualTargetGrid.tsx index 285779d..35bd2e5 100644 --- a/frontend/src/components/virtual/VirtualTargetGrid.tsx +++ b/frontend/src/components/virtual/VirtualTargetGrid.tsx @@ -8,7 +8,6 @@ import { type VirtualTargetItem, type VirtualCardViewMode, type VirtualSessionConfig, - resolveTargetCandleType, } from '../../utils/virtualTradingStorage'; import type { VirtualLiveStatus } from '../../hooks/useVirtualTargetLiveStatus'; import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots'; @@ -19,9 +18,9 @@ interface Props { targets: VirtualTargetItem[]; strategies: StrategyDto[]; snapshots: Record; + snapshotLoadingByMarket?: Record; session: Pick; onTargetStrategyChange: (market: string, strategyId: number | null) => void; - onTargetCandleTypeChange: (market: string, candleType: string) => void; liveStatusByMarket?: Record; lastTickAtByMarket?: Record; selectedMarket?: string; @@ -38,9 +37,8 @@ interface Props { } const VirtualTargetGrid: React.FC = ({ - targets, strategies, snapshots, session, + targets, strategies, snapshots, snapshotLoadingByMarket = {}, session, onTargetStrategyChange, - onTargetCandleTypeChange, liveStatusByMarket = {}, lastTickAtByMarket = {}, selectedMarket, onSelectMarket, theme = 'dark', @@ -119,9 +117,8 @@ const VirtualTargetGrid: React.FC = ({ strategyId={focusTarget.strategyId} globalStrategyId={session.globalStrategyId} onStrategyChange={id => onTargetStrategyChange(focusTarget.market, id)} - candleType={resolveTargetCandleType(focusTarget, snapshots[focusTarget.market]?.timeframe)} - onCandleTypeChange={ct => onTargetCandleTypeChange(focusTarget.market, ct)} snapshot={snapshots[focusTarget.market]} + signalLoading={snapshotLoadingByMarket[focusTarget.market]} running={session.running} liveStatus={liveStatusByMarket[focusTarget.market] ?? (session.running ? 'connecting' : 'idle')} lastTickAt={lastTickAtByMarket[focusTarget.market]} @@ -147,9 +144,8 @@ const VirtualTargetGrid: React.FC = ({ strategyId={t.strategyId} globalStrategyId={session.globalStrategyId} onStrategyChange={id => onTargetStrategyChange(t.market, id)} - candleType={resolveTargetCandleType(t, snapshots[t.market]?.timeframe)} - onCandleTypeChange={ct => onTargetCandleTypeChange(t.market, ct)} snapshot={snapshots[t.market]} + signalLoading={snapshotLoadingByMarket[t.market]} running={session.running} liveStatus={liveStatusByMarket[t.market] ?? (session.running ? 'connecting' : 'idle')} lastTickAt={lastTickAtByMarket[t.market]} diff --git a/frontend/src/components/virtual/VirtualTargetSignalPanel.tsx b/frontend/src/components/virtual/VirtualTargetSignalPanel.tsx index 65fbeab..c8b59ac 100644 --- a/frontend/src/components/virtual/VirtualTargetSignalPanel.tsx +++ b/frontend/src/components/virtual/VirtualTargetSignalPanel.tsx @@ -15,6 +15,8 @@ interface Props { snapshot: VirtualIndicatorSnapshot | undefined; viewMode?: VirtualCardViewMode; receiving?: boolean; + /** 업비트 캔들 warm-up · live-conditions 수집 중 */ + loading?: boolean; className?: string; } @@ -22,6 +24,7 @@ const VirtualTargetSignalPanel: React.FC = ({ snapshot, viewMode = 'summary', receiving = false, + loading = false, className = '', }) => { const rows = snapshot?.rows ?? []; @@ -36,8 +39,10 @@ const VirtualTargetSignalPanel: React.FC = ({ if (rows.length === 0) { return ( -

- 전략 조건·지표 데이터 없음 +

+ {loading + ? '지표 데이터 수집 중… (업비트 캔들 · 실시간 연결)' + : '전략 조건·지표 데이터 없음'}

); } diff --git a/frontend/src/hooks/useAppSettings.ts b/frontend/src/hooks/useAppSettings.ts index 86b80fb..af01f14 100644 --- a/frontend/src/hooks/useAppSettings.ts +++ b/frontend/src/hooks/useAppSettings.ts @@ -54,6 +54,8 @@ import { migrateLocalStorageToUiPreferences } from '../utils/uiPreferencesMigrat // 전역 캐시 — 여러 컴포넌트에서 공유하여 중복 요청 방지 let _cache: AppSettingsDto | null = null; let _loadPromise: Promise | null = null; +/** invalidate 이후 완료된 로드만 캐시에 반영 */ +let _loadGeneration = 0; type AppSettingsListener = () => void; const _listeners = new Set(); @@ -74,20 +76,31 @@ export function subscribeAppSettings(listener: AppSettingsListener): () => void function ensureLoaded(): Promise { if (_cache !== null) return Promise.resolve(_cache); if (_loadPromise) return _loadPromise; + const generation = _loadGeneration; _loadPromise = loadAppSettings().then(async data => { + if (generation !== _loadGeneration) { + _loadPromise = null; + return _cache ?? {}; + } _cache = data ?? {}; const migrated = migrateLocalStorageToUiPreferences(_cache); if (migrated?.changed) { try { const saved = await saveAppSettings({ uiPreferences: migrated.uiPreferences }); - _cache = { ..._cache, ...saved, uiPreferences: migrated.uiPreferences }; + if (generation === _loadGeneration) { + _cache = { ..._cache, ...saved, uiPreferences: migrated.uiPreferences }; + } } catch (e) { console.warn('[useAppSettings] uiPreferences 마이그레이션 저장 실패:', e); - _cache = { ..._cache, uiPreferences: migrated.uiPreferences }; + if (generation === _loadGeneration) { + _cache = { ..._cache, uiPreferences: migrated.uiPreferences }; + } } } _loadPromise = null; - notifyAppSettingsListeners(); + if (generation === _loadGeneration) { + notifyAppSettingsListeners(); + } return _cache; }); return _loadPromise; @@ -96,6 +109,7 @@ function ensureLoaded(): Promise { export function invalidateAppSettingsCache() { _cache = null; _loadPromise = null; + _loadGeneration += 1; } /** 서버에서 app-settings 재로드 후 캐시 갱신 (모바일 ↻ 동기화 등) */ @@ -167,13 +181,32 @@ export function resolveAppDefaults(s: AppSettingsDto) { /** sessionKey 변경 시(로그인/로그아웃) 설정을 DB에서 다시 로드 */ export function useAppSettings(sessionKey = 0) { const [settings, setSettings] = useState(_cache ?? {}); - const [isLoaded, setIsLoaded] = useState(false); + const [isLoaded, setIsLoaded] = useState(_cache !== null); const mountedRef = useRef(true); + const prevSessionKeyRef = useRef(sessionKey); useEffect(() => { mountedRef.current = true; - setIsLoaded(false); - invalidateAppSettingsCache(); + const sessionChanged = prevSessionKeyRef.current !== sessionKey; + prevSessionKeyRef.current = sessionKey; + + if (sessionChanged) { + if (_cache !== null) { + setSettings(_cache); + setDisplayTimezone(normalizeTimezone(_cache.displayTimezone ?? DEFAULT_DISPLAY_TIMEZONE)); + setIsLoaded(true); + } else { + setIsLoaded(false); + invalidateAppSettingsCache(); + } + } else if (_cache !== null) { + setSettings(_cache); + setDisplayTimezone(normalizeTimezone(_cache.displayTimezone ?? DEFAULT_DISPLAY_TIMEZONE)); + setIsLoaded(true); + } else { + setIsLoaded(false); + } + ensureLoaded().then(data => { if (mountedRef.current) { setSettings(data); diff --git a/frontend/src/hooks/useVirtualIndicatorSnapshots.ts b/frontend/src/hooks/useVirtualIndicatorSnapshots.ts index 151d67e..c750066 100644 --- a/frontend/src/hooks/useVirtualIndicatorSnapshots.ts +++ b/frontend/src/hooks/useVirtualIndicatorSnapshots.ts @@ -1,6 +1,7 @@ /** * 가상투자 카드 — 종목×전략별 지표·조건 스냅샷 - * running 시: candles/watch pin + 3초마다 백엔드 live-conditions API (종목별 독립) + * 전략 지정 시: candles/watch pin(업비트 warm-up) + live-conditions API + * running 시 3초 폴링, 미수집 시 재시도 */ import { useCallback, useEffect, useRef, useState } from 'react'; import type { StrategyDto } from '../utils/backendApi'; @@ -9,6 +10,7 @@ import { pinCandleWatch, type LiveConditionRowDto, } from '../utils/backendApi'; +import { pinStrategyEvaluationTimeframes } from '../utils/strategyTimeframePin'; import { formatIndicatorDisplayLabel } from '../utils/indicatorRegistry'; import { coerceFiniteNumber } from '../utils/safeFormat'; import { @@ -27,6 +29,12 @@ export interface VirtualIndicatorSnapshot { matchRate?: number; } +export interface VirtualIndicatorSnapshotsState { + snapshots: Record; + /** 업비트 캔들·live-conditions 수집 중 */ + loadingByMarket: Record; +} + function rowFallbackKey(row: Pick): string { return `${row.side}:${row.timeframe}:${row.indicatorType}:${row.conditionType}:${row.targetValue ?? ''}:${row.plotKey}`; } @@ -84,24 +92,38 @@ function mergeRows( }); } -async function buildSnapshot( +function staticSnapshot( + market: string, + strategyId: number, + baseRows: VirtualConditionRow[], +): VirtualIndicatorSnapshot { + return { + market, + strategyId, + timeframe: [...new Set(baseRows.map(r => r.timeframe))].join(', '), + rows: baseRows.map(r => normalizeConditionRow({ ...r, currentValue: null, satisfied: null })), + updatedAt: Date.now(), + matchRate: 0, + }; +} + +/** 백엔드가 업비트 REST·WS로 캔들 warm-up 후 Ta4j 평가 */ +async function fetchLiveSnapshot( market: string, strategyId: number, strategy: StrategyDto | undefined, - running: boolean, + pinFirst: boolean, ): Promise { const baseRows = strategy ? extractVirtualConditions(strategy) : []; - if (!running) { - if (baseRows.length === 0) return null; - return { - market, - strategyId, - timeframe: [...new Set(baseRows.map(r => r.timeframe))].join(', '), - rows: baseRows.map(r => normalizeConditionRow({ ...r, currentValue: null, satisfied: null })), - updatedAt: Date.now(), - matchRate: 0, - }; + if (pinFirst) { + try { + await pinStrategyEvaluationTimeframes(market, strategyId); + } catch { + try { + await pinCandleWatch(market, '1m'); + } catch { /* ignore */ } + } } let status: Awaited> = null; @@ -110,23 +132,20 @@ async function buildSnapshot( } catch { status = null; } + if (!status || status.market !== market || status.strategyId !== strategyId) { if (baseRows.length === 0) return null; - return { - market, - strategyId, - timeframe: [...new Set(baseRows.map(r => r.timeframe))].join(', '), - rows: baseRows.map(r => normalizeConditionRow({ ...r, currentValue: null, satisfied: null })), - updatedAt: Date.now(), - matchRate: 0, - }; + return staticSnapshot(market, strategyId, baseRows); } + const rows = mergeRows(baseRows, status.rows); + if (rows.length === 0) return null; + return { market, strategyId, timeframe: status.timeframe || [...new Set(baseRows.map(r => r.timeframe))].join(', '), - rows: mergeRows(baseRows, status.rows), + rows, updatedAt: status.updatedAt || Date.now(), matchRate: coerceFiniteNumber(status.matchRate) ?? 0, }; @@ -142,12 +161,14 @@ export function useVirtualIndicatorSnapshots( strategies: StrategyDto[], running: boolean, pollMs = 3000, -): Record { +): VirtualIndicatorSnapshotsState { const [snapshots, setSnapshots] = useState>({}); + const [loadingByMarket, setLoadingByMarket] = useState>({}); const strategiesRef = useRef(strategies); strategiesRef.current = strategies; const pinnedRef = useRef>(new Set()); const refreshGenRef = useRef>({}); + const emptyRetryRef = useRef>({}); const targetsKey = targets.map(t => `${t.market}:${t.strategyId ?? ''}`).join('|'); const pinTargets = useCallback(async () => { @@ -155,21 +176,25 @@ export function useVirtualIndicatorSnapshots( const pins = new Set(); for (const t of targets) { if (t.strategyId == null) continue; - const strat = strats.find(s => s.id === t.strategyId); - const conditions = strat ? extractVirtualConditions(strat) : []; - const tfs = conditions.length > 0 - ? [...new Set(conditions.map(c => c.timeframe))] - : ['1m']; - for (const tf of tfs) { - const key = `${t.market}:${tf}`; - pins.add(key); - if (!pinnedRef.current.has(key)) { - await pinCandleWatch(t.market, tf); - pinnedRef.current.add(key); + try { + const tfs = await pinStrategyEvaluationTimeframes(t.market, t.strategyId); + for (const tf of tfs) pins.add(`${t.market}:${tf}`); + } catch { + const strat = strats.find(s => s.id === t.strategyId); + const conditions = strat ? extractVirtualConditions(strat) : []; + const tfs = conditions.length > 0 + ? [...new Set(conditions.map(c => c.timeframe))] + : ['1m']; + for (const tf of tfs) { + const key = `${t.market}:${tf}`; + pins.add(key); + if (!pinnedRef.current.has(key)) { + await pinCandleWatch(t.market, tf); + pinnedRef.current.add(key); + } } - } - const k1 = `${t.market}:1m`; - if (!pins.has(k1) || !tfs.includes('1m')) { + const k1 = `${t.market}:1m`; + pins.add(k1); if (!pinnedRef.current.has(k1)) { await pinCandleWatch(t.market, '1m'); pinnedRef.current.add(k1); @@ -183,20 +208,50 @@ export function useVirtualIndicatorSnapshots( const refresh = useCallback(async () => { const strats = strategiesRef.current; - if (running) await pinTargets(); + const activeTargets = targets.filter(t => t.strategyId != null); + if (activeTargets.length === 0) { + setSnapshots({}); + setLoadingByMarket({}); + return; + } - const jobs = targets - .filter(t => t.strategyId != null) - .map(async t => { - const gen = (refreshGenRef.current[t.market] ?? 0) + 1; - refreshGenRef.current[t.market] = gen; - const strat = strats.find(s => s.id === t.strategyId); - const snap = await buildSnapshot(t.market, t.strategyId!, strat, running); - if (!snap || refreshGenRef.current[t.market] !== gen) return snap; - if (snap.strategyId !== t.strategyId) return snap; - setSnapshots(prev => ({ ...prev, [snap.market]: snap })); - return snap; - }); + setLoadingByMarket(prev => { + const next = { ...prev }; + for (const t of activeTargets) next[t.market] = true; + return next; + }); + + await pinTargets(); + + const jobs = activeTargets.map(async t => { + const gen = (refreshGenRef.current[t.market] ?? 0) + 1; + refreshGenRef.current[t.market] = gen; + const strat = strats.find(s => s.id === t.strategyId); + const baseRows = strat ? extractVirtualConditions(strat) : []; + const needsLiveApi = running || baseRows.length === 0; + + let snap: VirtualIndicatorSnapshot | null = null; + if (needsLiveApi) { + const retry = emptyRetryRef.current[t.market] ?? 0; + snap = await fetchLiveSnapshot(t.market, t.strategyId!, strat, retry === 0); + } else { + snap = staticSnapshot(t.market, t.strategyId!, baseRows); + } + + if (!snap || refreshGenRef.current[t.market] !== gen) return snap; + if (snap.strategyId !== t.strategyId) return snap; + + if (snap.rows.length === 0) { + const n = (emptyRetryRef.current[t.market] ?? 0) + 1; + emptyRetryRef.current[t.market] = n; + } else { + emptyRetryRef.current[t.market] = 0; + } + + setSnapshots(prev => ({ ...prev, [snap.market]: snap })); + setLoadingByMarket(prev => ({ ...prev, [t.market]: false })); + return snap; + }); await Promise.all(jobs); const activeMarkets = new Set(targets.map(t => t.market)); @@ -211,20 +266,36 @@ export function useVirtualIndicatorSnapshots( } return changed ? next : prev; }); + setLoadingByMarket(prev => { + const next = { ...prev }; + let changed = false; + for (const key of Object.keys(next)) { + if (!activeMarkets.has(key)) { + delete next[key]; + changed = true; + } + } + return changed ? next : prev; + }); }, [targets, running, pinTargets]); + const hasStrategyTargets = targets.some(t => t.strategyId != null); + useEffect(() => { if (targets.length === 0) { setSnapshots({}); + setLoadingByMarket({}); pinnedRef.current.clear(); refreshGenRef.current = {}; + emptyRetryRef.current = {}; return; } void refresh(); - if (!running) return; - const id = window.setInterval(() => void refresh(), pollMs); + if (!running && !hasStrategyTargets) return; + const intervalMs = running ? pollMs : 5000; + const id = window.setInterval(() => void refresh(), intervalMs); return () => clearInterval(id); - }, [targetsKey, running, pollMs, refresh]); + }, [targetsKey, running, hasStrategyTargets, pollMs, refresh]); - return snapshots; + return { snapshots, loadingByMarket }; } diff --git a/frontend/src/hooks/useVirtualTargetLiveStatus.ts b/frontend/src/hooks/useVirtualTargetLiveStatus.ts index 03f52ab..5204e90 100644 --- a/frontend/src/hooks/useVirtualTargetLiveStatus.ts +++ b/frontend/src/hooks/useVirtualTargetLiveStatus.ts @@ -39,6 +39,8 @@ export interface VirtualTargetLiveState { export function useVirtualTargetLiveStatus( targets: TargetRef[], running: boolean, + /** 전략 지정만 된 상태에서도 STOMP·pin으로 업비트 틱 수신 (지표 warm-up 보조) */ + eagerConnect = true, ): VirtualTargetLiveState { const [byMarket, setByMarket] = useState>({}); const [lastTickAtByMarket, setLastTickAtByMarket] = useState>({}); @@ -57,7 +59,7 @@ export function useVirtualTargetLiveStatus( const recomputeStatus = (market: string) => { const s = stateRef.current[market]; - if (!s || !running) return; + if (!s) return; const now = Date.now(); if (s.lastTickAt != null && now - s.lastTickAt <= STALE_MS) { if (s.status !== 'live') patchMarket(market, { status: 'live' }); @@ -71,17 +73,18 @@ export function useVirtualTargetLiveStatus( }; useEffect(() => { - if (!running) { + const activeMarkets = targets + .filter(t => t.strategyId != null) + .map(t => t.market); + + const active = running || (eagerConnect && activeMarkets.length > 0); + if (!active) { stateRef.current = {}; setByMarket({}); setLastTickAtByMarket({}); return; } - const activeMarkets = targets - .filter(t => t.strategyId != null) - .map(t => t.market); - for (const market of activeMarkets) { if (!stateRef.current[market]) { stateRef.current[market] = { status: 'connecting', lastTickAt: null }; @@ -129,7 +132,7 @@ export function useVirtualTargetLiveStatus( offConn(); clearInterval(staleId); }; - }, [targets, running]); + }, [targets, running, eagerConnect]); return { statusByMarket: byMarket, lastTickAtByMarket }; } diff --git a/frontend/src/hooks/useVirtualTradingCore.ts b/frontend/src/hooks/useVirtualTradingCore.ts index 9b30e35..fec001d 100644 --- a/frontend/src/hooks/useVirtualTradingCore.ts +++ b/frontend/src/hooks/useVirtualTradingCore.ts @@ -27,7 +27,6 @@ import { type VirtualTargetItem, type VirtualCardViewMode, } from '../utils/virtualTradingStorage'; -import { normalizeStartCandleType } from '../utils/strategyStartNodes'; import { syncVirtualTargetsToBackend, stopVirtualLiveOnBackend, @@ -197,7 +196,11 @@ export function useVirtualTradingCore(options: UseVirtualTradingCoreOptions = {} [targets, session.globalStrategyId], ); - const snapshots = useVirtualIndicatorSnapshots(targetRefs, strategies, session.running); + const { snapshots, loadingByMarket: snapshotLoadingByMarket } = useVirtualIndicatorSnapshots( + targetRefs, + strategies, + session.running, + ); const { statusByMarket: liveStatusByMarket, lastTickAtByMarket } = useVirtualTargetLiveStatus( targetRefs, session.running, @@ -338,57 +341,6 @@ export function useVirtualTradingCore(options: UseVirtualTradingCoreOptions = {} } }, [session]); - const handleTargetCandleTypeChange = useCallback(async (market: string, candleType: string) => { - const normalized = normalizeStartCandleType(candleType); - setTargets(prev => prev.map(t => - t.market === market ? { ...t, candleType: normalized } : t, - )); - if (!session.running) return; - const target = targets.find(t => t.market === market); - const strategyId = target - ? resolveVirtualTargetStrategyId(target, session.globalStrategyId) - : session.globalStrategyId; - if (!strategyId) return; - try { - await saveLiveStrategySettings({ - market, - strategyId, - isLiveCheck: true, - executionType: session.executionType, - positionMode: session.positionMode, - skipWatchlistSync: true, - skipGlobalTemplate: true, - }); - await pinStrategyEvaluationTimeframes(market, strategyId); - } catch { - window.alert('종목 평가 분봉 변경 저장에 실패했습니다.'); - } - }, [session, targets]); - - useEffect(() => { - let cancelled = false; - const missing = targets.filter(t => !t.candleType); - if (missing.length === 0) return; - void Promise.all( - missing.map(async t => { - try { - const s = await loadLiveStrategySettings(t.market); - return { market: t.market, candleType: s.candleType }; - } catch { - return { market: t.market, candleType: undefined }; - } - }), - ).then(rows => { - if (cancelled) return; - setTargets(prev => prev.map(t => { - const row = rows.find(r => r.market === t.market); - if (t.candleType || !row?.candleType) return t; - return { ...t, candleType: normalizeStartCandleType(row.candleType) }; - })); - }); - return () => { cancelled = true; }; - }, [targets.map(t => `${t.market}:${t.candleType ?? ''}`).join('|')]); - const resyncRunningSession = useCallback(async (nextSession: VirtualSessionConfig) => { if (!session.running || targets.length === 0) return; try { @@ -448,6 +400,7 @@ export function useVirtualTradingCore(options: UseVirtualTradingCoreOptions = {} setViewMode, refreshPaperData, snapshots, + snapshotLoadingByMarket, liveStatusByMarket: mergedLiveStatus, lastTickAtByMarket, strategyNames, @@ -460,7 +413,6 @@ export function useVirtualTradingCore(options: UseVirtualTradingCoreOptions = {} handleExecutionTypeChange, handlePositionModeChange, handleTargetStrategyChange, - handleTargetCandleTypeChange, reloadTargetsFromStorage, appChartDefaults, }; diff --git a/frontend/src/utils/trendSearchAutoAddTargets.ts b/frontend/src/utils/trendSearchAutoAddTargets.ts index 42b82dc..b53f17e 100644 --- a/frontend/src/utils/trendSearchAutoAddTargets.ts +++ b/frontend/src/utils/trendSearchAutoAddTargets.ts @@ -32,7 +32,7 @@ export async function autoAddTrendSearchTargets( const { koreanName, englishName } = resolveVirtualTargetNames(row.market, row.koreanName); targets = await addVirtualTarget( row.market, - { koreanName, englishName, candleType: timeframe }, + { koreanName, englishName }, { showLimitAlert: false }, ); } diff --git a/frontend/src/utils/virtualTargetMutations.ts b/frontend/src/utils/virtualTargetMutations.ts index 83643be..0ac5a90 100644 --- a/frontend/src/utils/virtualTargetMutations.ts +++ b/frontend/src/utils/virtualTargetMutations.ts @@ -4,7 +4,6 @@ import { isVirtualTargetAddAllowed, virtualTargetLimitMessage, } from '../utils/virtualTargetLimits'; -import { normalizeStartCandleType } from './strategyStartNodes'; import { syncVirtualTargetsToBackend } from './virtualLiveStrategySync'; import { resolveVirtualTargetStrategyId } from './virtualTargetStrategy'; import { @@ -18,8 +17,6 @@ import { export interface VirtualTargetMeta { koreanName?: string; englishName?: string; - /** 추세검색 캔들 주기 → 투자대상 평가 분봉 기본값 */ - candleType?: string; } export interface AddVirtualTargetOptions { @@ -51,7 +48,6 @@ export async function addVirtualTarget( strategyId: null, koreanName: meta.koreanName, englishName: en, - candleType: meta.candleType ? normalizeStartCandleType(meta.candleType) : undefined, }; const next = [...targets, item]; @@ -64,7 +60,6 @@ export async function addVirtualTarget( isPinned: false, executionType: session.executionType, positionMode: session.positionMode, - candleType: item.candleType ?? '1m', skipWatchlistSync: true, skipGlobalTemplate: true, }); diff --git a/frontend/src/utils/virtualTargetsHydrate.ts b/frontend/src/utils/virtualTargetsHydrate.ts index 432c478..6519a93 100644 --- a/frontend/src/utils/virtualTargetsHydrate.ts +++ b/frontend/src/utils/virtualTargetsHydrate.ts @@ -29,7 +29,6 @@ function fromLiveSettings( return { market: s.market, strategyId: s.strategyId ?? globalStrategyId, - candleType: s.candleType, koreanName, englishName, pinned: !!s.isPinned, diff --git a/frontend/src/utils/virtualTradingStorage.ts b/frontend/src/utils/virtualTradingStorage.ts index 0a6e764..05d9e06 100644 --- a/frontend/src/utils/virtualTradingStorage.ts +++ b/frontend/src/utils/virtualTradingStorage.ts @@ -1,4 +1,3 @@ -import { normalizeStartCandleType } from './strategyStartNodes'; import { resolveVirtualTargetNames } from './virtualTargetNames'; import { getUiPreferences, patchUiPreferences } from './uiPreferencesDb'; @@ -7,8 +6,6 @@ import { getUiPreferences, patchUiPreferences } from './uiPreferencesDb'; export interface VirtualTargetItem { market: string; strategyId: number | null; - /** 종목별 전략 평가 분봉 (gc_live_strategy_settings.candle_type) */ - candleType?: string; koreanName?: string; englishName?: string; /** 투자대상 고정 — ON 이면 목록·추세검색에서 삭제 불가 (gc_live_strategy_settings.is_pinned) */ @@ -35,6 +32,7 @@ function defaultSession(): VirtualSessionConfig { }; } +/** 레거시 ui_preferences.virtual.targets.candleType 제거 */ function normalizeTargets(items: VirtualTargetItem[]): VirtualTargetItem[] { return items.map(t => { const { koreanName, englishName } = resolveVirtualTargetNames( @@ -42,7 +40,13 @@ function normalizeTargets(items: VirtualTargetItem[]): VirtualTargetItem[] { t.koreanName, t.englishName, ); - return { ...t, koreanName, englishName }; + return { + market: t.market, + strategyId: t.strategyId ?? null, + koreanName, + englishName, + pinned: t.pinned, + }; }); } @@ -53,7 +57,7 @@ export function loadVirtualTargets(): VirtualTargetItem[] { } export function saveVirtualTargets(items: VirtualTargetItem[]): void { - patchUiPreferences({ virtual: { targets: items } }); + patchUiPreferences({ virtual: { targets: normalizeTargets(items) } }); notifyVirtualSessionChanged(); } @@ -87,14 +91,3 @@ export function loadVirtualCardViewMode(): VirtualCardViewMode { export function saveVirtualCardViewMode(mode: VirtualCardViewMode): void { patchUiPreferences({ virtual: { cardViewMode: mode } }); } - -/** 카드 시간봉 드롭다운 기본값 — 저장값 → 스냅샷 → 1m */ -export function resolveTargetCandleType( - item: Pick, - snapshotTimeframe?: string | null, -): string { - if (item.candleType) return normalizeStartCandleType(item.candleType); - const raw = snapshotTimeframe?.split(',')[0]?.trim(); - if (raw) return normalizeStartCandleType(raw); - return '1m'; -} diff --git a/packages/shared/src/api/backendApi.ts b/packages/shared/src/api/backendApi.ts index 4d34551..be05b89 100644 --- a/packages/shared/src/api/backendApi.ts +++ b/packages/shared/src/api/backendApi.ts @@ -10,12 +10,34 @@ import { storageGetSync, storageSetSync } from '../storage/index.js'; /** Flow layout stored with strategy (matches frontend StrategyFlowLayoutStore). */ export type StrategyFlowLayoutStore = Record; -function resolveApiBase(): string { - const fromEnv = typeof import.meta !== 'undefined' - ? (import.meta as ImportMeta & { env?: Record }).env?.VITE_API_BASE_URL +/** 배포 APK·exdev 기본 (HTTPS 443 미개방 — 반드시 http) */ +export const PRODUCTION_API_BASE = 'http://exdev.co.kr/api'; + +function viteEnv(): Record | undefined { + return typeof import.meta !== 'undefined' + ? (import.meta as ImportMeta & { env?: Record }).env : undefined; - const fromStorage = storageGetSync('gc_api_base_url'); - return fromStorage || fromEnv || 'http://localhost:8080/api'; +} + +/** 저장된 API URL 정규화 — https exdev·localhost 제거 */ +export function normalizeApiBaseUrl(url: string | null | undefined): string | null { + if (!url?.trim()) return null; + let u = url.trim().replace(/\/$/, ''); + if (/localhost|127\.0\.0\.1/i.test(u)) return null; + if (/^https:\/\/exdev\.co\.kr/i.test(u)) { + u = u.replace(/^https:/i, 'http:'); + } + return u; +} + +function resolveApiBase(): string { + const env = viteEnv(); + const fromStorage = normalizeApiBaseUrl(storageGetSync('gc_api_base_url')); + const fromEnv = normalizeApiBaseUrl(env?.VITE_API_BASE_URL); + if (fromStorage) return fromStorage; + if (fromEnv) return fromEnv; + if (env?.PROD) return PRODUCTION_API_BASE; + return 'http://localhost:8080/api'; } /** 백엔드 REST base */ @@ -82,8 +104,9 @@ function authHeaders(): Record { return headers; } -const DEFAULT_FETCH_TIMEOUT_MS = 30_000; -const LOGIN_FETCH_TIMEOUT_MS = 60_000; +const DEFAULT_FETCH_TIMEOUT_MS = 45_000; +/** exdev 첫 로그인 응답이 60초 이상 걸리는 경우 있음 */ +const LOGIN_FETCH_TIMEOUT_MS = 180_000; function wrapNetworkError(e: unknown): Error { if (e instanceof TypeError || (e instanceof Error && /failed to fetch/i.test(e.message))) { diff --git a/scripts/ensure-android-google-services.sh b/scripts/ensure-android-google-services.sh new file mode 100755 index 0000000..e6d6246 --- /dev/null +++ b/scripts/ensure-android-google-services.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# app/google-services.json → app/android/app/ (Gradle google-services 플러그인) +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +SRC="$ROOT/app/google-services.json" +DEST="$ROOT/app/android/app/google-services.json" + +if [[ ! -f "$SRC" ]]; then + echo "[FCM] google-services.json 없음: $SRC" + echo " Firebase Console → com.goldenchart.app → json 다운로드 후 app/ 에 두세요." + exit 1 +fi + +if ! grep -q '"package_name": "com.goldenchart.app"' "$SRC" 2>/dev/null; then + echo "[FCM] $SRC 에 com.goldenchart.app 패키지가 없습니다." + exit 1 +fi + +mkdir -p "$(dirname "$DEST")" +cp "$SRC" "$DEST" +echo "[FCM] ✓ $DEST" diff --git a/scripts/setup-android-fcm.sh b/scripts/setup-android-fcm.sh index 3895c69..cf80458 100755 --- a/scripts/setup-android-fcm.sh +++ b/scripts/setup-android-fcm.sh @@ -24,8 +24,9 @@ if [[ -f "$DEST" ]]; then fi if [[ -n "${1:-}" && -f "$1" ]]; then + cp "$1" "$ROOT/app/google-services.json" cp "$1" "$DEST" - echo "✓ 복사: $1 → $DEST" + echo "✓ 복사: $1 → app/google-services.json 및 $DEST" exit 0 fi diff --git a/scripts/upload-android-apk.sh b/scripts/upload-android-apk.sh index 21d5b88..768f45d 100755 --- a/scripts/upload-android-apk.sh +++ b/scripts/upload-android-apk.sh @@ -166,6 +166,13 @@ build_apk_locally() { exit 1 fi + echo -e "${CYAN}[빌드] google-services.json → android/app${NC}" + "$ROOT/scripts/ensure-android-google-services.sh" + + if [[ ! -f "$ROOT/app/.env" ]] || ! grep -q 'VITE_API_BASE_URL=http' "$ROOT/app/.env" 2>/dev/null; then + echo -e "${YELLOW}⚠ app/.env 에 VITE_API_BASE_URL=http://exdev.co.kr/api 확인 (HTTPS 아님)${NC}" + fi + echo -e "${CYAN}[빌드] npm run cap:sync${NC}" npm run cap:sync