리액트 마이그레이션, airflow 틀잡기.
This commit is contained in:
parent
094b17edbe
commit
163eaa9626
@ -7,7 +7,26 @@
|
|||||||
"Bash(del \"detail_page.html\")",
|
"Bash(del \"detail_page.html\")",
|
||||||
"Bash(del \"simulation_page.html\")",
|
"Bash(del \"simulation_page.html\")",
|
||||||
"Bash(rm:*)",
|
"Bash(rm:*)",
|
||||||
"Bash(mv:*)"
|
"Bash(mv:*)",
|
||||||
|
"Read(/D:\\개인폴더\\개발\\ai_invest\\설계\\01/**)",
|
||||||
|
"Read(/D:\\개인폴더\\개발\\ai_invest\\설계\\01/**)",
|
||||||
|
"Read(/D:\\개인폴더\\개발\\ai_invest\\설계\\01\\화면설계서/**)",
|
||||||
|
"Read(/D:\\개인폴더\\개발\\ai_invest\\설계\\01\\화면설계서/**)",
|
||||||
|
"Read(/D:\\개인폴더\\개발\\ai_invest\\설계\\01/**)",
|
||||||
|
"Read(/D:\\개인폴더\\개발\\ai_invest\\설계\\01/**)",
|
||||||
|
"Read(/D:\\개인폴더\\개발\\ai_invest\\imai-invest-manager-AI\\src/**)",
|
||||||
|
"Read(/D:\\개인폴더\\개발\\ai_invest\\imai-invest-manager-AI\\src/**)",
|
||||||
|
"Read(/D:\\개인폴더\\개발\\ai_invest\\imai-invest-manager-AI/**)",
|
||||||
|
"Read(/D:\\개인폴더\\개발\\ai_invest\\imai-invest-manager-AI\\src\\types/**)",
|
||||||
|
"Read(/D:\\개인폴더\\개발\\ai_invest\\imai-invest-manager-AI\\src/**)",
|
||||||
|
"Read(/D:\\개인폴더\\개발\\ai_invest\\imai-invest-manager-AI\\src\\components\\common/**)",
|
||||||
|
"Read(/D:\\개인폴더\\개발\\ai_invest\\imai-invest-manager-AI/**)",
|
||||||
|
"Read(/D:\\개인폴더\\개발\\ai_invest\\imai-invest-manager-AI/**)",
|
||||||
|
"Read(/D:\\개인폴더\\개발\\ai_invest\\설계\\01/**)",
|
||||||
|
"Read(/D:\\개인폴더\\개발\\ai_invest\\설계\\01/**)",
|
||||||
|
"Read(/D:\\개인폴더\\개발\\ai_invest\\설계\\01/**)",
|
||||||
|
"Read(/D:\\개인폴더\\개발\\ai_invest\\설계\\01\\화면설계서/**)",
|
||||||
|
"Read(/D:\\개인폴더\\개발\\ai_invest\\설계\\01\\화면설계서/**)"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
55
backend/airflow/Dockerfile.airflow
Normal file
55
backend/airflow/Dockerfile.airflow
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
# 1. 기반 이미지 설정: 공식 Airflow 이미지를 사용합니다.
|
||||||
|
# docker-compose.yaml의 AIRFLOW_IMAGE_NAME 환경 변수와 버전을 일치시키는 것이 좋습니다.
|
||||||
|
# 예: apache/airflow:2.9.3
|
||||||
|
ARG AIRFLOW_VERSION=2.9.3
|
||||||
|
FROM apache/airflow:${AIRFLOW_VERSION}
|
||||||
|
|
||||||
|
# 2. 루트 사용자로 전환 (시스템 패키지 설치 위함)
|
||||||
|
USER root
|
||||||
|
|
||||||
|
# 3. 필요한 시스템 유틸리티 및 Chrome 브라우저 설치 (Debian/Ubuntu 기반)
|
||||||
|
# 공식 Airflow 이미지는 Debian 기반이므로 apt-get 사용
|
||||||
|
RUN apt-get update -yqq && \
|
||||||
|
apt-get install -yqq --no-install-recommends \
|
||||||
|
wget \
|
||||||
|
gnupg \
|
||||||
|
unzip \
|
||||||
|
# Chrome 실행에 필요한 라이브러리들
|
||||||
|
libglib2.0-0 libnss3 libgconf-2-4 libfontconfig1 libxi6 libgdk-pixbuf2.0-0 libgtk-3-0 libx11-xcb1 libxcomposite1 libxcursor1 libxdamage1 libxfixes3 libxrandr2 libxrender1 libxss1 libxtst6 libatk-bridge2.0-0 libatk1.0-0 libcups2 libdrm2 libgbm1 libpango-1.0-0 libcairo2 libasound2 && \
|
||||||
|
# Google Chrome 저장소 추가 및 설치
|
||||||
|
wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - && \
|
||||||
|
echo "deb http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google-chrome.list && \
|
||||||
|
apt-get update -yqq && \
|
||||||
|
apt-get install -yqq google-chrome-stable && \
|
||||||
|
# 설치 후 불필요한 파일 정리
|
||||||
|
apt-get clean && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# 4. ChromeDriver 설치
|
||||||
|
# 중요: 설치된 Google Chrome 버전에 맞는 ChromeDriver 버전을 사용해야 합니다.
|
||||||
|
# Chrome 버전 확인: google-chrome-stable --version (컨테이너 빌드 중에는 어려우므로, 로컬에서 확인 후 버전 지정)
|
||||||
|
# ChromeDriver 다운로드 URL: https://googlechromelabs.github.io/chrome-for-testing/
|
||||||
|
# 아래 ARG는 예시이며, 실제 Chrome 버전에 맞는 ChromeDriver 버전을 명시해야 합니다.
|
||||||
|
ARG CHROME_DRIVER_VERSION="136.0.7103.92" # 예시: 2025년 5월 기준 최신 Stable과 유사한 버전 (실제 버전에 맞게 수정!)
|
||||||
|
RUN wget -O /tmp/chromedriver.zip "https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing/${CHROME_DRIVER_VERSION}/linux64/chromedriver-linux64.zip" && \
|
||||||
|
unzip /tmp/chromedriver.zip -d /usr/local/bin/ && \
|
||||||
|
# 압축 해제 시 chromedriver-linux64 디렉토리 안에 chromedriver가 있을 수 있음
|
||||||
|
(mv /usr/local/bin/chromedriver-linux64/chromedriver /usr/local/bin/chromedriver || echo "chromedriver already in /usr/local/bin or mv failed") && \
|
||||||
|
rm -rf /tmp/chromedriver.zip /usr/local/bin/chromedriver-linux64 && \
|
||||||
|
chmod +x /usr/local/bin/chromedriver
|
||||||
|
|
||||||
|
# (선택 사항) ChromeDriver 경로 환경 변수 설정 (코드에서 Airflow Variable을 사용하면 필수는 아님)
|
||||||
|
# ENV CHROMEDRIVER_EXECUTABLE_PATH /usr/local/bin/chromedriver
|
||||||
|
|
||||||
|
# 5. Airflow 사용자로 다시 전환
|
||||||
|
USER airflow
|
||||||
|
|
||||||
|
# 6. requirements.txt 파일을 이미지 안으로 복사
|
||||||
|
# 이 Dockerfile과 같은 디렉토리에 requirements.txt 파일이 있다고 가정
|
||||||
|
COPY requirements.txt /requirements.txt
|
||||||
|
|
||||||
|
# 7. requirements.txt에 명시된 Python 패키지 설치
|
||||||
|
RUN pip install --no-cache-dir -r /requirements.txt
|
||||||
|
|
||||||
|
# 8. (선택 사항) 작업 디렉토리 설정 등
|
||||||
|
# WORKDIR /opt/airflow
|
61
backend/airflow/config/airflow.cfg
Normal file
61
backend/airflow/config/airflow.cfg
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
# Airflow Configuration File
|
||||||
|
|
||||||
|
[core]
|
||||||
|
# Airflow의 내부 스케줄링 및 시간 처리 기준 시간대
|
||||||
|
default_timezone = Asia/Seoul
|
||||||
|
|
||||||
|
# DAG가 처음 생성될 때 "paused" 상태로 할지 여부
|
||||||
|
dags_are_paused_at_creation = True
|
||||||
|
|
||||||
|
# 예제 DAG 로드 여부
|
||||||
|
load_examples = False
|
||||||
|
|
||||||
|
# 실행기 유형 (docker-compose.yaml의 환경 변수 설정과 일치시키는 것이 좋음)
|
||||||
|
# executor = CeleryExecutor
|
||||||
|
|
||||||
|
# Fernet 키 (docker-compose.yaml의 환경 변수에서 관리하는 것이 일반적)
|
||||||
|
# fernet_key = YOUR_FERNET_KEY
|
||||||
|
|
||||||
|
[webserver]
|
||||||
|
# Airflow 웹 UI에 표시되는 시간의 기준 시간대
|
||||||
|
default_ui_timezone = Asia/Seoul
|
||||||
|
|
||||||
|
# API 인증 백엔드 (docker-compose.yaml의 환경 변수 설정과 일치시키는 것이 좋음)
|
||||||
|
# auth_backends = airflow.api.auth.backend.basic_auth,airflow.api.auth.backend.session
|
||||||
|
|
||||||
|
[logging]
|
||||||
|
# 로그 메시지 형식
|
||||||
|
# %(asctime)s는 일반적으로 컨테이너의 TZ 환경 변수와 아래 default_date_format, timezone_aware 설정의 영향을 받습니다.
|
||||||
|
log_format = [%(asctime)s] {%(filename)s:%(lineno)d} %(levelname)s - %(message)s
|
||||||
|
|
||||||
|
# 로그의 %(asctime)s에 적용될 날짜/시간 형식
|
||||||
|
# %Z는 시간대 이름 (KST 등), %z는 UTC 오프셋 (+0900 등)을 표시합니다.
|
||||||
|
default_date_format = %Y-%m-%d %H:%M:%S %Z%z
|
||||||
|
|
||||||
|
# 시간대 인식 로깅 활성화 (기본값이 True이지만 명시적으로 설정)
|
||||||
|
# 이 설정이 True이면, Airflow의 TimezoneAwareFormatter가 사용되어
|
||||||
|
# default_timezone (Asia/Seoul) 기준으로 시간을 표시하려고 시도합니다.
|
||||||
|
timezone_aware = True
|
||||||
|
|
||||||
|
# 로그 파일 이름 템플릿 (기본값 사용 권장)
|
||||||
|
# default_log_filename_template = {{ ti.dag_id }}/{{ ti.task_id }}/{{ ts }}/{{ try_number }}.log
|
||||||
|
|
||||||
|
# 원격 로깅을 사용하지 않는 경우, 태스크 로그는 파일로 저장됩니다.
|
||||||
|
# task_log_reader = file.task
|
||||||
|
|
||||||
|
# 로그 레벨
|
||||||
|
# logging_level = INFO
|
||||||
|
|
||||||
|
|
||||||
|
# --- 기타 필요한 설정들 ---
|
||||||
|
# 예: [database], [celery] 섹션의 설정들은
|
||||||
|
# docker-compose.yaml의 환경 변수(AIRFLOW__DATABASE__SQL_ALCHEMY_CONN 등)를 통해
|
||||||
|
# 이미 설정되어 있으므로, 여기서는 보통 명시적으로 다시 설정할 필요가 없습니다.
|
||||||
|
# 만약 특정 설정을 airflow.cfg에서만 관리하고 싶다면 해당 섹션과 키를 추가할 수 있습니다.
|
||||||
|
|
||||||
|
# [database]
|
||||||
|
# sql_alchemy_conn = postgresql+psycopg2://airflow:airflow@postgres/airflow
|
||||||
|
|
||||||
|
# [celery]
|
||||||
|
# broker_url = redis://:@redis:6379/0
|
||||||
|
# result_backend = db+postgresql://airflow:airflow@postgres/airflow
|
82
backend/airflow/dags/imei/dags/collect_news.py
Normal file
82
backend/airflow/dags/imei/dags/collect_news.py
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
# 필요한 모듈
|
||||||
|
import pendulum # 날짜/시간 처리를 위한 권장 라이브러리 (datetime 대체 가능)
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from airflow.models.dag import DAG # DAG 객체 정의를 위한 클래스
|
||||||
|
from airflow.operators.bash import BashOperator # Bash 명령어를 실행하는 오퍼레이터
|
||||||
|
from airflow.operators.python import PythonOperator # Python 함수를 실행하는 오퍼레이터
|
||||||
|
from airflow.operators.empty import EmptyOperator
|
||||||
|
from datetime import datetime
|
||||||
|
from airflow.models.variable import Variable
|
||||||
|
from imei.tasks.task_collect_news import TaskCollectNews
|
||||||
|
|
||||||
|
|
||||||
|
# 모든 Operator에 공통적으로 적용될 기본값들을 딕셔너리로 정의할 수 있습니다.
|
||||||
|
# 스캐쥴링 설정
|
||||||
|
scheduler = "0 0 * * *"
|
||||||
|
# args 설정
|
||||||
|
default_args = {
|
||||||
|
'owner': 'airflow_user', # DAG의 소유자 (관리/알림 목적)
|
||||||
|
"start_date": datetime(2025, 9, 5, 15, 0, 0), # 첫 실행 날짜
|
||||||
|
"depends_on_past": False, # 이전 DAG Run 실행 여부에 따른 의존성 설정
|
||||||
|
}
|
||||||
|
|
||||||
|
# DAG 정의
|
||||||
|
# 'with DAG(...) as dag:' 구문을 사용하면 이 블록 내에서 정의된 오퍼레이터들이 자동으로 'dag' 객체에 속하게 됩니다.
|
||||||
|
with DAG(
|
||||||
|
dag_id='collect_news', # DAG의 고유 식별자. Airflow UI에 표시됨. (필수)
|
||||||
|
description='뉴스 수집 DAG', # DAG에 대한 설명 (UI에 표시됨)
|
||||||
|
schedule=scheduler, # DAG 실행 스케줄. None은 수동 실행(Manual Trigger)을 의미.
|
||||||
|
catchup=False, # True면 start_date부터 현재까지 놓친 스케줄을 모두 실행 (Backfill). 보통 False로 설정.
|
||||||
|
default_args=default_args, # 위에서 정의한 기본 인수 적용
|
||||||
|
max_active_runs=1,
|
||||||
|
tags=['뉴스'], # DAG를 분류하고 UI에서 필터링하기 위한 태그 목록
|
||||||
|
) as dag: # 이 DAG 인스턴스를 'dag' 변수로 사용
|
||||||
|
# dag 설명
|
||||||
|
dag.doc_md = """
|
||||||
|
뉴스 수집 DAG
|
||||||
|
"""
|
||||||
|
# 임시 처리용 클래스
|
||||||
|
task_collect_news = TaskCollectNews()
|
||||||
|
|
||||||
|
|
||||||
|
# Task(작업) 정의
|
||||||
|
# 각 Task는 Operator 클래스의 인스턴스로 생성됩니다.
|
||||||
|
####################################################################################################################
|
||||||
|
# Task: Start
|
||||||
|
####################################################################################################################
|
||||||
|
task_start = EmptyOperator(
|
||||||
|
task_id="task_start"
|
||||||
|
)
|
||||||
|
task_start.doc_md = f"""- Empty Task"""
|
||||||
|
|
||||||
|
####################################################################################################################
|
||||||
|
# Task1 : NewsAPI를 통해 뉴스 데이터를 수집합니다.
|
||||||
|
task_collect_news_newsapi = PythonOperator(
|
||||||
|
task_id='collect_news_newsapi',
|
||||||
|
python_callable=task_collect_news.collect_news_newsapi,
|
||||||
|
op_kwargs = {
|
||||||
|
"exec_dt_str": "{{ data_interval_end }}"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
task_collect_news_newsapi.doc_md = f"""
|
||||||
|
- NewsAPI를 통해 뉴스 데이터를 수집합니다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
####################################################################################################################
|
||||||
|
|
||||||
|
|
||||||
|
####################################################################################################################
|
||||||
|
# Task: End
|
||||||
|
####################################################################################################################
|
||||||
|
task_end = EmptyOperator(
|
||||||
|
task_id="task_end"
|
||||||
|
)
|
||||||
|
task_end.doc_md = f"""- Empty Task"""
|
||||||
|
|
||||||
|
# 6. Task 의존성(실행 순서) 설정
|
||||||
|
task_start >> \
|
||||||
|
task_collect_news_newsapi >> \
|
||||||
|
task_end
|
||||||
|
|
||||||
|
|
36
backend/airflow/dags/imei/tasks/task_collect_news.py
Normal file
36
backend/airflow/dags/imei/tasks/task_collect_news.py
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
from airflow.exceptions import AirflowException, AirflowSkipException, AirflowFailException
|
||||||
|
from airflow.models import Variable
|
||||||
|
from airflow.providers.mysql.hooks.mysql import MySqlHook
|
||||||
|
from plugins.utils.common_datetime import CommonDatetime
|
||||||
|
import logging
|
||||||
|
|
||||||
|
# Variable
|
||||||
|
# NEWSAPI
|
||||||
|
NEWSAPI_API_KEY = Variable.get("NEWSAPI_API_KEY", "")
|
||||||
|
NEWSAPI_URL = Variable.get("NEWSAPI_URL", "https://newsapi.org/v2/everything")
|
||||||
|
|
||||||
|
class TaskCollectNews():
|
||||||
|
# =======================================================================================================================================
|
||||||
|
# 임시 처리용 테스트
|
||||||
|
def collect_news_newsapi(self, exec_dt_str: str):
|
||||||
|
"""
|
||||||
|
NewsAPI를 통해 뉴스 데이터를 수집합니다.
|
||||||
|
"""
|
||||||
|
logging.info('============================== collect_news_newsapi start ==============================')
|
||||||
|
try:
|
||||||
|
# 기본 정보
|
||||||
|
exec_dt = CommonDatetime.make_datetime_kst_from_ts(exec_dt_str)
|
||||||
|
logging.info(f"exec_dt(): {exec_dt}")
|
||||||
|
|
||||||
|
# NewsAPI를 통해 뉴스 데이터를 수집합니다.
|
||||||
|
url = f"{NEWSAPI_URL}?q=keyword&apiKey={NEWSAPI_API_KEY}"
|
||||||
|
response = requests.get(url)
|
||||||
|
data = response.json()
|
||||||
|
logging.info(f"data: {data}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"collect_news_newsapi() Exception: {str(e)}")
|
||||||
|
raise Exception(str(e))
|
||||||
|
|
||||||
|
finally :
|
||||||
|
logging.info('============================== collect_news_newsapi finish ==============================')
|
616
backend/airflow/dags/plugins/sql/hooks/mysql_hook.py
Normal file
616
backend/airflow/dags/plugins/sql/hooks/mysql_hook.py
Normal file
@ -0,0 +1,616 @@
|
|||||||
|
import inspect
|
||||||
|
import pymysql
|
||||||
|
import logging
|
||||||
|
import mysql.connector
|
||||||
|
from sqlalchemy.engine import Engine
|
||||||
|
from airflow.exceptions import AirflowException, AirflowSkipException, AirflowFailException
|
||||||
|
from airflow.providers.mysql.hooks.mysql import MySqlHook
|
||||||
|
from sqlalchemy.engine import Engine
|
||||||
|
|
||||||
|
TIMEZONE = "Asia/Seoul"
|
||||||
|
|
||||||
|
class CommonHookMySQL:
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _set_timezone(cursor):
|
||||||
|
cursor.execute(f"SET time_zone = '{TIMEZONE}'")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def select_sql(conn: mysql.connector.connection.MySQLConnection, sql: str):
|
||||||
|
message = ""
|
||||||
|
try:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(sql)
|
||||||
|
result = cursor.fetchall()
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
raise AirflowFailException(f"select result is None")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except AirflowSkipException as e:
|
||||||
|
message = f"select_user_data() AirflowSkipException: {str(e)}"
|
||||||
|
raise AirflowSkipException(message)
|
||||||
|
|
||||||
|
except AirflowFailException as e:
|
||||||
|
message = f"select_user_data() AirflowFailException: {str(e)}"
|
||||||
|
raise AirflowFailException(message)
|
||||||
|
|
||||||
|
except AirflowException as e:
|
||||||
|
message = f"select_user_data() AirflowException: {str(e)}"
|
||||||
|
raise AirflowException(message)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
message = f"select_user_data() Exception: {str(e)}"
|
||||||
|
raise AirflowException(message)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if message:
|
||||||
|
logging.info(message)
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def select_sql_to_dict(conn: Engine, sql: str): # 파라미터 바인딩 지원 추가
|
||||||
|
"""
|
||||||
|
SQL 쿼리를 실행하고 결과를 딕셔너리의 리스트 형태로 반환합니다.
|
||||||
|
MySqlHook을 사용하며, DictCursor에 직접 의존하지 않습니다.
|
||||||
|
|
||||||
|
:param conn_id: Airflow MySQL Connection ID
|
||||||
|
:param sql: 실행할 SELECT SQL 쿼리 문자열
|
||||||
|
:param parameters: SQL 쿼리에 바인딩할 파라미터 (튜플 또는 리스트)
|
||||||
|
:return: 각 행이 딕셔너리인 리스트 (예: [{'col1': val1, 'col2': val2}, ...])
|
||||||
|
:rtype: list[dict]
|
||||||
|
"""
|
||||||
|
message = ""
|
||||||
|
try:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(sql) # 파라미터 사용
|
||||||
|
column_names = [desc[0] for desc in cursor.description]
|
||||||
|
rows = cursor.fetchall() # 튜플의 리스트로 결과 가져오기
|
||||||
|
|
||||||
|
result_list = []
|
||||||
|
if rows:
|
||||||
|
for row_tuple in rows:
|
||||||
|
result_dict = dict(zip(column_names, row_tuple))
|
||||||
|
result_list.append(result_dict)
|
||||||
|
else:
|
||||||
|
logging.info("쿼리 결과가 없습니다.")
|
||||||
|
|
||||||
|
return result_list
|
||||||
|
|
||||||
|
except AirflowSkipException as e:
|
||||||
|
message = f"select_sql_to_dict() AirflowSkipException: {str(e)}"
|
||||||
|
logging.warning(message)
|
||||||
|
raise AirflowSkipException(message)
|
||||||
|
except AirflowFailException as e:
|
||||||
|
message = f"select_sql_to_dict() AirflowFailException: {str(e)}"
|
||||||
|
logging.error(message)
|
||||||
|
raise AirflowFailException(message)
|
||||||
|
except AirflowException as e: # 다른 Airflow 예외
|
||||||
|
message = f"select_sql_to_dict() AirflowException: {str(e)}"
|
||||||
|
logging.error(message)
|
||||||
|
raise AirflowException(message)
|
||||||
|
except Exception as e: # DB 연결 오류, SQL 문법 오류 등 포함
|
||||||
|
message = f"select_sql_to_dict() Exception: {type(e).__name__} - {str(e)}"
|
||||||
|
logging.error(message, exc_info=True) # Traceback 로깅
|
||||||
|
raise AirflowException(message) # AirflowException으로 래핑
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if message:
|
||||||
|
logging.info(message)
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def insert_sql(conn: mysql.connector.connection.MySQLConnection, sql: str):
|
||||||
|
message = ""
|
||||||
|
try:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
CommonHookMySQL._set_timezone(cursor)
|
||||||
|
cursor.execute(sql)
|
||||||
|
result = cursor.rowcount
|
||||||
|
conn.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
|
except AirflowSkipException as e:
|
||||||
|
message = f"insert_sql() AirflowSkipException: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowSkipException(message)
|
||||||
|
|
||||||
|
except AirflowFailException as e:
|
||||||
|
message = f"insert_sql() AirflowFailException: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowFailException(message)
|
||||||
|
|
||||||
|
except AirflowException as e:
|
||||||
|
message = f"insert_sql() AirflowException: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowException(message)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
message = f"insert_sql() Exception: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise e
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if message:
|
||||||
|
logging.info(message)
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def insert_sql_many(conn: mysql.connector.connection.MySQLConnection, sql: str, data_list: list):
|
||||||
|
result = -1
|
||||||
|
message = ""
|
||||||
|
try:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
CommonHookMySQL._set_timezone(cursor)
|
||||||
|
|
||||||
|
# Execute bulk insert
|
||||||
|
cursor.executemany(sql, data_list)
|
||||||
|
conn.commit()
|
||||||
|
result = cursor.rowcount
|
||||||
|
|
||||||
|
except AirflowSkipException as e:
|
||||||
|
message = f"insert_sql_many() AirflowSkipException: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowSkipException(message)
|
||||||
|
|
||||||
|
except AirflowFailException as e:
|
||||||
|
message = f"insert_sql_many() AirflowFailException: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowFailException(message)
|
||||||
|
|
||||||
|
except AirflowException as e:
|
||||||
|
message = f"insert_sql_many() AirflowException: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowException(message)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
message = f"insert_sql_many() Exception: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowException(message)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if message:
|
||||||
|
logging.info(message)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def insert_sql_by_sql_list(conn: mysql.connector.connection.MySQLConnection, sql_list: list):
|
||||||
|
result = -1
|
||||||
|
message = ""
|
||||||
|
try:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
CommonHookMySQL._set_timezone(cursor)
|
||||||
|
|
||||||
|
for sql in sql_list:
|
||||||
|
try:
|
||||||
|
cursor.execute(sql)
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error executing SQL: {sql}")
|
||||||
|
raise e
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
except AirflowSkipException as e:
|
||||||
|
message = f"insert_sql_by_sql_list() AirflowSkipException: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowSkipException(message)
|
||||||
|
|
||||||
|
except AirflowFailException as e:
|
||||||
|
message = f"insert_sql_by_sql_list() AirflowFailException: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowFailException(message)
|
||||||
|
|
||||||
|
except AirflowException as e:
|
||||||
|
message = f"insert_sql_by_sql_list() AirflowException: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowException(message)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
message = f"insert_sql_by_sql_list() Exception: {str(e)}"
|
||||||
|
logging.error(message, exc_info=True)
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowException(message)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if message:
|
||||||
|
logging.info(message)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def update_sql(conn: mysql.connector.connection.MySQLConnection, sql: str):
|
||||||
|
message = ""
|
||||||
|
try:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
CommonHookMySQL._set_timezone(cursor)
|
||||||
|
cursor.execute(sql)
|
||||||
|
result = cursor.rowcount
|
||||||
|
conn.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
|
except AirflowSkipException as e:
|
||||||
|
message = f"update_sql() AirflowSkipException: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowSkipException(message)
|
||||||
|
|
||||||
|
except AirflowFailException as e:
|
||||||
|
message = f"update_sql() AirflowFailException: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowFailException(message)
|
||||||
|
|
||||||
|
except AirflowException as e:
|
||||||
|
message = f"update_sql() AirflowException: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowException(message)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
message = f"update_sql() Exception: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise e
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if message:
|
||||||
|
logging.info(message)
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def update_sql_many(conn: mysql.connector.connection.MySQLConnection, sql: str, data_list: list):
|
||||||
|
result = -1
|
||||||
|
message = ""
|
||||||
|
try:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
CommonHookMySQL._set_timezone(cursor)
|
||||||
|
|
||||||
|
# Execute bulk insert
|
||||||
|
cursor.executemany(sql, data_list)
|
||||||
|
conn.commit()
|
||||||
|
result = cursor.rowcount
|
||||||
|
|
||||||
|
except AirflowSkipException as e:
|
||||||
|
message = f"update_sql_many() AirflowSkipException: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowSkipException(message)
|
||||||
|
|
||||||
|
except AirflowFailException as e:
|
||||||
|
message = f"update_sql_many() AirflowFailException: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowFailException(message)
|
||||||
|
|
||||||
|
except AirflowException as e:
|
||||||
|
message = f"update_sql_many() AirflowException: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowException(message)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
message = f"update_sql_many() Exception: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowException(message)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if message:
|
||||||
|
logging.info(message)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def update_sql_by_sql_list(conn: mysql.connector.connection.MySQLConnection, sql_list: list):
|
||||||
|
result = -1
|
||||||
|
message = ""
|
||||||
|
try:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
CommonHookMySQL._set_timezone(cursor)
|
||||||
|
|
||||||
|
for sql in sql_list:
|
||||||
|
cursor.execute(sql)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
except AirflowSkipException as e:
|
||||||
|
message = f"update_sql_many_only_query() AirflowSkipException: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowSkipException(message)
|
||||||
|
|
||||||
|
except AirflowFailException as e:
|
||||||
|
message = f"update_sql_many_only_query() AirflowFailException: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowFailException(message)
|
||||||
|
|
||||||
|
except AirflowException as e:
|
||||||
|
message = f"update_sql_many_only_query() AirflowException: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowException(message)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
message = f"update_sql_many_only_query() Exception: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowException(message)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if message:
|
||||||
|
logging.info(message)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def transaction_sql(conn: mysql.connector.connection.MySQLConnection, sql_list):
|
||||||
|
message = ""
|
||||||
|
try:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
CommonHookMySQL._set_timezone(cursor)
|
||||||
|
|
||||||
|
for sql in sql_list:
|
||||||
|
cursor.execute(sql)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
except AirflowSkipException as e:
|
||||||
|
message = f"transaction_sql() AirflowSkipException: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowSkipException(message)
|
||||||
|
|
||||||
|
except AirflowFailException as e:
|
||||||
|
message = f"transaction_sql() AirflowFailException: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowFailException(message)
|
||||||
|
|
||||||
|
except AirflowException as e:
|
||||||
|
message = f"transaction_sql() AirflowException: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowException(message)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
message = f"transaction_sql() Exception: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowException(message)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if message:
|
||||||
|
logging.info(message)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def process_sql(conn: mysql.connector.connection.MySQLConnection, sql):
|
||||||
|
message = ""
|
||||||
|
try:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
CommonHookMySQL._set_timezone(cursor)
|
||||||
|
cursor.execute(sql)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
except AirflowSkipException as e:
|
||||||
|
message = f"process_sql() AirflowSkipException: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowSkipException(message)
|
||||||
|
|
||||||
|
except AirflowFailException as e:
|
||||||
|
message = f"process_sql() AirflowFailException: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowFailException(message)
|
||||||
|
|
||||||
|
except AirflowException as e:
|
||||||
|
message = f"process_sql() AirflowException: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowException(message)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
message = f"process_sql() Exception: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowException(message)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if message:
|
||||||
|
logging.info(message)
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def procedure_sql(conn: mysql.connector.connection.MySQLConnection, procedure_name: str, params: tuple = None):
|
||||||
|
"""
|
||||||
|
저장 프로시저를 실행하고 결과를 반환합니다.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conn_id (str): 데이터베이스 연결 ID
|
||||||
|
procedure_name (str): 실행할 저장 프로시저 이름
|
||||||
|
params (tuple, optional): 프로시저 파라미터. 기본값은 None
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: 프로시저 실행 결과. 컬럼명을 키로 하는 딕셔너리 형태로 반환됨
|
||||||
|
"""
|
||||||
|
message = ""
|
||||||
|
try:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
CommonHookMySQL._set_timezone(cursor)
|
||||||
|
|
||||||
|
# 파라미터가 있는 경우 처리
|
||||||
|
if params:
|
||||||
|
param_values = []
|
||||||
|
for p in params:
|
||||||
|
if p is None:
|
||||||
|
param_values.append('NULL')
|
||||||
|
elif isinstance(p, (int, float)):
|
||||||
|
param_values.append(str(p))
|
||||||
|
else:
|
||||||
|
# 문자열 이스케이프 처리
|
||||||
|
escaped_p = str(p).replace("'", "''")
|
||||||
|
param_values.append(f"'{escaped_p}'")
|
||||||
|
|
||||||
|
# 프로시저 호출 SQL 생성
|
||||||
|
sql = f"CALL {procedure_name}({', '.join(param_values)})"
|
||||||
|
else:
|
||||||
|
# 파라미터가 없는 경우
|
||||||
|
sql = f"CALL {procedure_name}()"
|
||||||
|
|
||||||
|
logging.debug(f"Executing SQL: {sql}")
|
||||||
|
|
||||||
|
# 프로시저 실행
|
||||||
|
cursor.execute(sql)
|
||||||
|
|
||||||
|
# 결과 처리
|
||||||
|
result = None
|
||||||
|
if cursor.description:
|
||||||
|
columns = [col[0] for col in cursor.description]
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
result = dict(zip(columns, row))
|
||||||
|
logging.debug(f"Procedure result: {result}")
|
||||||
|
logging.info(f"Procedure executed. Affected rows (cursor.rowcount): {cursor.rowcount}")
|
||||||
|
|
||||||
|
while cursor.nextset():
|
||||||
|
pass
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
|
except AirflowSkipException as e:
|
||||||
|
message = f"procedure_sql() AirflowSkipException: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowSkipException(message)
|
||||||
|
|
||||||
|
except AirflowFailException as e:
|
||||||
|
message = f"procedure_sql() AirflowFailException: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowFailException(message)
|
||||||
|
|
||||||
|
except AirflowException as e:
|
||||||
|
message = f"procedure_sql() AirflowException: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowException(message)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
message = f"procedure_sql() Exception: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowException(message)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if message:
|
||||||
|
logging.info(message)
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def procedure_sql_many(conn: mysql.connector.connection.MySQLConnection, procedure_name: str, data_set: list):
|
||||||
|
"""
|
||||||
|
저장 프로시저를 여러 번 실행하고 결과를 반환합니다.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conn_id (str): 데이터베이스 연결 ID
|
||||||
|
procedure_name (str): 실행할 저장 프로시저 이름
|
||||||
|
data_set (list): 프로시저 파라미터 리스트. 각 항목은 튜플이나 리스트 형태여야 함
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: 프로시저 실행 결과 리스트. 각 결과는 딕셔너리 형태로 반환됨
|
||||||
|
"""
|
||||||
|
message = ""
|
||||||
|
try:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
CommonHookMySQL._set_timezone(cursor)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for params in data_set:
|
||||||
|
try:
|
||||||
|
# 파라미터 타입에 따른 SQL 쿼리 생성
|
||||||
|
param_values = []
|
||||||
|
for p in params:
|
||||||
|
if p is None:
|
||||||
|
param_values.append('NULL')
|
||||||
|
elif isinstance(p, (int, float)):
|
||||||
|
param_values.append(str(p))
|
||||||
|
else:
|
||||||
|
# 문자열 이스케이프 처리
|
||||||
|
escaped_p = str(p).replace("'", "''")
|
||||||
|
param_values.append(f"'{escaped_p}'")
|
||||||
|
|
||||||
|
# 프로시저 호출 SQL 생성
|
||||||
|
sql = f"CALL {procedure_name}({', '.join(param_values)})"
|
||||||
|
logging.debug(f"Executing SQL: {sql}")
|
||||||
|
|
||||||
|
# 프로시저 실행
|
||||||
|
cursor.execute(sql)
|
||||||
|
|
||||||
|
# 결과 처리
|
||||||
|
if cursor.description:
|
||||||
|
columns = [col[0] for col in cursor.description]
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
result = dict(zip(columns, row))
|
||||||
|
results.append(result)
|
||||||
|
logging.debug(f"Procedure result: {result}")
|
||||||
|
|
||||||
|
# 커서 초기화
|
||||||
|
while cursor.nextset():
|
||||||
|
pass
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error executing procedure with params {params}: {str(e)}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
logging.info(f"Successfully executed {len(data_set)} procedure calls")
|
||||||
|
return results
|
||||||
|
|
||||||
|
except AirflowSkipException as e:
|
||||||
|
message = f"procedure_sql_many() AirflowSkipException: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowSkipException(message)
|
||||||
|
|
||||||
|
except AirflowFailException as e:
|
||||||
|
message = f"procedure_sql_many() AirflowFailException: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowFailException(message)
|
||||||
|
|
||||||
|
except AirflowException as e:
|
||||||
|
message = f"procedure_sql_many() AirflowException: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowException(message)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
message = f"procedure_sql_many() Exception: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowException(message)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if message:
|
||||||
|
logging.info(message)
|
||||||
|
|
||||||
|
|
||||||
|
|
533
backend/airflow/dags/plugins/sql/hooks/mysql_hook1.py
Normal file
533
backend/airflow/dags/plugins/sql/hooks/mysql_hook1.py
Normal file
@ -0,0 +1,533 @@
|
|||||||
|
import inspect
|
||||||
|
import pymysql
|
||||||
|
import logging
|
||||||
|
import mysql.connector
|
||||||
|
from sqlalchemy.engine import Engine
|
||||||
|
from airflow.exceptions import AirflowException, AirflowSkipException, AirflowFailException
|
||||||
|
from airflow.providers.mysql.hooks.mysql import MySqlHook
|
||||||
|
from sqlalchemy.engine import Engine
|
||||||
|
|
||||||
|
TIMEZONE = "Asia/Seoul"
|
||||||
|
|
||||||
|
class CommonHookMySQL:
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _set_timezone(cursor):
|
||||||
|
cursor.execute(f"SET time_zone = '{TIMEZONE}'")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def select_sql(conn: mysql.connector.connection.MySQLConnection, sql: str):
|
||||||
|
try:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(sql)
|
||||||
|
result = cursor.fetchall()
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
raise AirflowFailException(f"select result is None")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except AirflowSkipException as e:
|
||||||
|
message = f"select_user_data() AirflowSkipException: {str(e)}"
|
||||||
|
raise AirflowSkipException(message)
|
||||||
|
|
||||||
|
except AirflowFailException as e:
|
||||||
|
message = f"select_user_data() AirflowFailException: {str(e)}"
|
||||||
|
raise AirflowFailException(message)
|
||||||
|
|
||||||
|
except AirflowException as e:
|
||||||
|
message = f"select_user_data() AirflowException: {str(e)}"
|
||||||
|
raise AirflowException(message)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
message = f"select_user_data() Exception: {str(e)}"
|
||||||
|
raise AirflowException(message)
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def select_sql_to_dict(conn_id: str, sql: str): # 파라미터 바인딩 지원 추가
|
||||||
|
"""
|
||||||
|
SQL 쿼리를 실행하고 결과를 딕셔너리의 리스트 형태로 반환합니다.
|
||||||
|
MySqlHook을 사용하며, DictCursor에 직접 의존하지 않습니다.
|
||||||
|
|
||||||
|
:param conn_id: Airflow MySQL Connection ID
|
||||||
|
:param sql: 실행할 SELECT SQL 쿼리 문자열
|
||||||
|
:param parameters: SQL 쿼리에 바인딩할 파라미터 (튜플 또는 리스트)
|
||||||
|
:return: 각 행이 딕셔너리인 리스트 (예: [{'col1': val1, 'col2': val2}, ...])
|
||||||
|
:rtype: list[dict]
|
||||||
|
"""
|
||||||
|
|
||||||
|
conn = None
|
||||||
|
cursor = None
|
||||||
|
try:
|
||||||
|
db_hook = MySqlHook(mysql_conn_id=conn_id)
|
||||||
|
conn = db_hook.get_conn()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(sql) # 파라미터 사용
|
||||||
|
column_names = [desc[0] for desc in cursor.description]
|
||||||
|
rows = cursor.fetchall() # 튜플의 리스트로 결과 가져오기
|
||||||
|
|
||||||
|
result_list = []
|
||||||
|
if rows:
|
||||||
|
for row_tuple in rows:
|
||||||
|
result_dict = dict(zip(column_names, row_tuple))
|
||||||
|
result_list.append(result_dict)
|
||||||
|
else:
|
||||||
|
logging.info("쿼리 결과가 없습니다.")
|
||||||
|
|
||||||
|
return result_list
|
||||||
|
|
||||||
|
except AirflowSkipException as e:
|
||||||
|
message = f"select_sql_to_dict() AirflowSkipException: {str(e)}"
|
||||||
|
logging.warning(message)
|
||||||
|
raise AirflowSkipException(message)
|
||||||
|
except AirflowFailException as e:
|
||||||
|
message = f"select_sql_to_dict() AirflowFailException: {str(e)}"
|
||||||
|
logging.error(message)
|
||||||
|
raise AirflowFailException(message)
|
||||||
|
except AirflowException as e: # 다른 Airflow 예외
|
||||||
|
message = f"select_sql_to_dict() AirflowException: {str(e)}"
|
||||||
|
logging.error(message)
|
||||||
|
raise AirflowException(message)
|
||||||
|
except Exception as e: # DB 연결 오류, SQL 문법 오류 등 포함
|
||||||
|
message = f"select_sql_to_dict() Exception: {type(e).__name__} - {str(e)}"
|
||||||
|
logging.error(message, exc_info=True) # Traceback 로깅
|
||||||
|
raise AirflowException(message) # AirflowException으로 래핑
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def insert_sql(conn_id: str, sql: str):
|
||||||
|
try:
|
||||||
|
db_hook = MySqlHook(mysql_conn_id=conn_id)
|
||||||
|
conn = db_hook.get_conn()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
CommonHookMySQL._set_timezone(cursor)
|
||||||
|
cursor.execute(sql)
|
||||||
|
result = cursor.rowcount
|
||||||
|
conn.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
|
except AirflowSkipException as e:
|
||||||
|
message = f"insert_sql() AirflowSkipException: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowSkipException(message)
|
||||||
|
|
||||||
|
except AirflowFailException as e:
|
||||||
|
message = f"insert_sql() AirflowFailException: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowFailException(message)
|
||||||
|
|
||||||
|
except AirflowException as e:
|
||||||
|
message = f"insert_sql() AirflowException: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowException(message)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise e
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def insert_sql_many(conn_id: str, sql: str, data_list: list):
|
||||||
|
result = -1
|
||||||
|
conn = None
|
||||||
|
cursor = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
db_hook = MySqlHook(mysql_conn_id=conn_id)
|
||||||
|
conn = db_hook.get_conn()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
CommonHookMySQL._set_timezone(cursor)
|
||||||
|
|
||||||
|
# Execute bulk insert
|
||||||
|
cursor.executemany(sql, data_list)
|
||||||
|
conn.commit()
|
||||||
|
result = cursor.rowcount
|
||||||
|
|
||||||
|
except AirflowSkipException as e:
|
||||||
|
message = f"insert_sql_many() AirflowSkipException: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowSkipException(message)
|
||||||
|
|
||||||
|
except AirflowFailException as e:
|
||||||
|
message = f"insert_sql_many() AirflowFailException: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowFailException(message)
|
||||||
|
|
||||||
|
except AirflowException as e:
|
||||||
|
message = f"insert_sql_many() AirflowException: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowException(message)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
message = f"insert_sql_many() Exception: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowException(message)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def update_sql_many(conn_id: str, sql: str, data_list: list):
|
||||||
|
result = -1
|
||||||
|
conn = None
|
||||||
|
cursor = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
db_hook = MySqlHook(mysql_conn_id=conn_id)
|
||||||
|
conn = db_hook.get_conn()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
CommonHookMySQL._set_timezone(cursor)
|
||||||
|
|
||||||
|
# Execute bulk insert
|
||||||
|
cursor.executemany(sql, data_list)
|
||||||
|
conn.commit()
|
||||||
|
result = cursor.rowcount
|
||||||
|
|
||||||
|
except AirflowSkipException as e:
|
||||||
|
message = f"update_sql_many() AirflowSkipException: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowSkipException(message)
|
||||||
|
|
||||||
|
except AirflowFailException as e:
|
||||||
|
message = f"update_sql_many() AirflowFailException: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowFailException(message)
|
||||||
|
|
||||||
|
except AirflowException as e:
|
||||||
|
message = f"update_sql_many() AirflowException: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowException(message)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
message = f"update_sql_many() Exception: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowException(message)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def update_sql_by_sql_list(conn_id: str, sql_list: list):
|
||||||
|
result = -1
|
||||||
|
conn = None
|
||||||
|
cursor = None
|
||||||
|
try:
|
||||||
|
db_hook = MySqlHook(mysql_conn_id=conn_id)
|
||||||
|
conn = db_hook.get_conn()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
CommonHookMySQL._set_timezone(cursor)
|
||||||
|
|
||||||
|
for sql in sql_list:
|
||||||
|
cursor.execute(sql)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
except AirflowSkipException as e:
|
||||||
|
message = f"update_sql_many_only_query() AirflowSkipException: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowSkipException(message)
|
||||||
|
|
||||||
|
except AirflowFailException as e:
|
||||||
|
message = f"update_sql_many_only_query() AirflowFailException: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowFailException(message)
|
||||||
|
|
||||||
|
except AirflowException as e:
|
||||||
|
message = f"update_sql_many_only_query() AirflowException: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowException(message)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
message = f"update_sql_many_only_query() Exception: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowException(message)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def transaction_sql(conn_id: str, sql_list):
|
||||||
|
conn = None
|
||||||
|
cursor = None
|
||||||
|
try:
|
||||||
|
db_hook = MySqlHook(mysql_conn_id=conn_id)
|
||||||
|
conn = db_hook.get_conn()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
CommonHookMySQL._set_timezone(cursor)
|
||||||
|
|
||||||
|
for sql in sql_list:
|
||||||
|
cursor.execute(sql)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
except AirflowSkipException as e:
|
||||||
|
message = f"transaction_sql() AirflowSkipException: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowSkipException(message)
|
||||||
|
|
||||||
|
except AirflowFailException as e:
|
||||||
|
message = f"transaction_sql() AirflowFailException: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowFailException(message)
|
||||||
|
|
||||||
|
except AirflowException as e:
|
||||||
|
message = f"transaction_sql() AirflowException: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowException(message)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
message = f"transaction_sql() Exception: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowException(message)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def process_sql(conn_id: str, sql):
|
||||||
|
conn = None
|
||||||
|
cursor = None
|
||||||
|
try:
|
||||||
|
db_hook = MySqlHook(mysql_conn_id=conn_id)
|
||||||
|
conn = db_hook.get_conn()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
CommonHookMySQL._set_timezone(cursor)
|
||||||
|
cursor.execute(sql)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
except AirflowSkipException as e:
|
||||||
|
message = f"process_sql() AirflowSkipException: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowSkipException(message)
|
||||||
|
|
||||||
|
except AirflowFailException as e:
|
||||||
|
message = f"process_sql() AirflowFailException: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowFailException(message)
|
||||||
|
|
||||||
|
except AirflowException as e:
|
||||||
|
message = f"process_sql() AirflowException: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowException(message)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
message = f"process_sql() Exception: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowException(message)
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def procedure_sql(conn_id: str, procedure_name: str, params: tuple = None):
|
||||||
|
"""
|
||||||
|
저장 프로시저를 실행하고 결과를 반환합니다.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conn_id (str): 데이터베이스 연결 ID
|
||||||
|
procedure_name (str): 실행할 저장 프로시저 이름
|
||||||
|
params (tuple, optional): 프로시저 파라미터. 기본값은 None
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: 프로시저 실행 결과. 컬럼명을 키로 하는 딕셔너리 형태로 반환됨
|
||||||
|
"""
|
||||||
|
conn = None
|
||||||
|
cursor = None
|
||||||
|
try:
|
||||||
|
db_hook = MySqlHook(mysql_conn_id=conn_id)
|
||||||
|
conn = db_hook.get_conn()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
CommonHookMySQL._set_timezone(cursor)
|
||||||
|
|
||||||
|
# 파라미터가 있는 경우 처리
|
||||||
|
if params:
|
||||||
|
param_values = []
|
||||||
|
for p in params:
|
||||||
|
if p is None:
|
||||||
|
param_values.append('NULL')
|
||||||
|
elif isinstance(p, (int, float)):
|
||||||
|
param_values.append(str(p))
|
||||||
|
else:
|
||||||
|
# 문자열 이스케이프 처리
|
||||||
|
escaped_p = str(p).replace("'", "''")
|
||||||
|
param_values.append(f"'{escaped_p}'")
|
||||||
|
|
||||||
|
# 프로시저 호출 SQL 생성
|
||||||
|
sql = f"CALL {procedure_name}({', '.join(param_values)})"
|
||||||
|
else:
|
||||||
|
# 파라미터가 없는 경우
|
||||||
|
sql = f"CALL {procedure_name}()"
|
||||||
|
|
||||||
|
logging.debug(f"Executing SQL: {sql}")
|
||||||
|
|
||||||
|
# 프로시저 실행
|
||||||
|
cursor.execute(sql)
|
||||||
|
|
||||||
|
# 결과 처리
|
||||||
|
result = None
|
||||||
|
if cursor.description:
|
||||||
|
columns = [col[0] for col in cursor.description]
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
result = dict(zip(columns, row))
|
||||||
|
logging.debug(f"Procedure result: {result}")
|
||||||
|
logging.info(f"Procedure executed. Affected rows (cursor.rowcount): {cursor.rowcount}")
|
||||||
|
|
||||||
|
while cursor.nextset():
|
||||||
|
pass
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
|
except AirflowSkipException as e:
|
||||||
|
message = f"procedure_sql() AirflowSkipException: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowSkipException(message)
|
||||||
|
|
||||||
|
except AirflowFailException as e:
|
||||||
|
message = f"procedure_sql() AirflowFailException: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowFailException(message)
|
||||||
|
|
||||||
|
except AirflowException as e:
|
||||||
|
message = f"procedure_sql() AirflowException: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowException(message)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
message = f"procedure_sql() Exception: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowException(message)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if cursor:
|
||||||
|
cursor.close()
|
||||||
|
if conn:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def procedure_sql_many(conn_id: str, procedure_name: str, data_set: list):
|
||||||
|
"""
|
||||||
|
저장 프로시저를 여러 번 실행하고 결과를 반환합니다.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conn_id (str): 데이터베이스 연결 ID
|
||||||
|
procedure_name (str): 실행할 저장 프로시저 이름
|
||||||
|
data_set (list): 프로시저 파라미터 리스트. 각 항목은 튜플이나 리스트 형태여야 함
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: 프로시저 실행 결과 리스트. 각 결과는 딕셔너리 형태로 반환됨
|
||||||
|
"""
|
||||||
|
conn = None
|
||||||
|
cursor = None
|
||||||
|
try:
|
||||||
|
db_hook = MySqlHook(mysql_conn_id=conn_id)
|
||||||
|
conn = db_hook.get_conn()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
CommonHookMySQL._set_timezone(cursor)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for params in data_set:
|
||||||
|
try:
|
||||||
|
# 파라미터 타입에 따른 SQL 쿼리 생성
|
||||||
|
param_values = []
|
||||||
|
for p in params:
|
||||||
|
if p is None:
|
||||||
|
param_values.append('NULL')
|
||||||
|
elif isinstance(p, (int, float)):
|
||||||
|
param_values.append(str(p))
|
||||||
|
else:
|
||||||
|
# 문자열 이스케이프 처리
|
||||||
|
escaped_p = str(p).replace("'", "''")
|
||||||
|
param_values.append(f"'{escaped_p}'")
|
||||||
|
|
||||||
|
# 프로시저 호출 SQL 생성
|
||||||
|
sql = f"CALL {procedure_name}({', '.join(param_values)})"
|
||||||
|
logging.debug(f"Executing SQL: {sql}")
|
||||||
|
|
||||||
|
# 프로시저 실행
|
||||||
|
cursor.execute(sql)
|
||||||
|
|
||||||
|
# 결과 처리
|
||||||
|
if cursor.description:
|
||||||
|
columns = [col[0] for col in cursor.description]
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
result = dict(zip(columns, row))
|
||||||
|
results.append(result)
|
||||||
|
logging.debug(f"Procedure result: {result}")
|
||||||
|
|
||||||
|
# 커서 초기화
|
||||||
|
while cursor.nextset():
|
||||||
|
pass
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error executing procedure with params {params}: {str(e)}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
logging.info(f"Successfully executed {len(data_set)} procedure calls")
|
||||||
|
return results
|
||||||
|
|
||||||
|
except AirflowSkipException as e:
|
||||||
|
message = f"procedure_sql_many() AirflowSkipException: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowSkipException(message)
|
||||||
|
|
||||||
|
except AirflowFailException as e:
|
||||||
|
message = f"procedure_sql_many() AirflowFailException: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowFailException(message)
|
||||||
|
|
||||||
|
except AirflowException as e:
|
||||||
|
message = f"procedure_sql_many() AirflowException: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowException(message)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
message = f"procedure_sql_many() Exception: {str(e)}"
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
raise AirflowException(message)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if cursor:
|
||||||
|
cursor.close()
|
||||||
|
if conn:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
|
Binary file not shown.
22
backend/airflow/dags/plugins/utils/common_datetime.py
Normal file
22
backend/airflow/dags/plugins/utils/common_datetime.py
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import pendulum
|
||||||
|
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
|
|
||||||
|
TIMEZONE_KST = timezone(timedelta(hours=9))
|
||||||
|
|
||||||
|
|
||||||
|
class CommonDatetime:
|
||||||
|
@staticmethod
|
||||||
|
def make_kst_timezone():
|
||||||
|
return pendulum.timezone("Asia/Seoul")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def make_datetime_kst_from_ts(ts: str) -> datetime:
|
||||||
|
return datetime.fromisoformat(ts).astimezone(TIMEZONE_KST)
|
||||||
|
|
||||||
|
|
||||||
|
# DATETIME으로 입력받고 한국 시간적용된 시간으로 리턴턴
|
||||||
|
@staticmethod
|
||||||
|
def make_datetime_kst_from_datetime(dt: datetime) -> datetime:
|
||||||
|
return dt.astimezone(TIMEZONE_KST)
|
1238
backend/airflow/dags/plugins/utils/i2db.py
Normal file
1238
backend/airflow/dags/plugins/utils/i2db.py
Normal file
File diff suppressed because it is too large
Load Diff
204
backend/airflow/dags/plugins/utils/items/items.py
Normal file
204
backend/airflow/dags/plugins/utils/items/items.py
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
syscollectItem = {
|
||||||
|
"dcode": "",
|
||||||
|
"whereis": "",
|
||||||
|
"dkey": "",
|
||||||
|
"dkey_ext": "",
|
||||||
|
"stats": "",
|
||||||
|
"rebid_no": "",
|
||||||
|
"link": "",
|
||||||
|
"link_post": "",
|
||||||
|
"ext_info1": "",
|
||||||
|
"ext_info2": "",
|
||||||
|
"ext_info3": "",
|
||||||
|
"rs_bidid": "",
|
||||||
|
"rs_msg": "",
|
||||||
|
"proc": "",
|
||||||
|
"set_time": "",
|
||||||
|
"seq": "",
|
||||||
|
"retrycnt": "",
|
||||||
|
"uptime": "",
|
||||||
|
"crawl_data_type": "",
|
||||||
|
"crawl_data_view": "",
|
||||||
|
"crawl_data_html": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bidkeyItem = {
|
||||||
|
"bidid": "",
|
||||||
|
"whereis": "",
|
||||||
|
"syscode": "",
|
||||||
|
"bidtype": "",
|
||||||
|
"bidview": "",
|
||||||
|
"notinum": "",
|
||||||
|
"notinum_ex": "",
|
||||||
|
"constnm": "",
|
||||||
|
"org": "",
|
||||||
|
"org_i": "",
|
||||||
|
"orgcode_i": "",
|
||||||
|
"org_y": "",
|
||||||
|
"orgcode_y": "",
|
||||||
|
"bidproc": "",
|
||||||
|
"contract": "",
|
||||||
|
"bidcls": "",
|
||||||
|
"succls": "",
|
||||||
|
"conlevel": "",
|
||||||
|
"concode": "",
|
||||||
|
"sercode": "",
|
||||||
|
"purcode": "",
|
||||||
|
"location": "",
|
||||||
|
"convention": "",
|
||||||
|
"presum": "",
|
||||||
|
"basic": "",
|
||||||
|
"pct": "",
|
||||||
|
"opt": "",
|
||||||
|
"noticedt": "",
|
||||||
|
"registdt": "",
|
||||||
|
"explaindt": "",
|
||||||
|
"agreedt": "",
|
||||||
|
"opendt": "",
|
||||||
|
"closedt": "",
|
||||||
|
"constdt": "",
|
||||||
|
"writedt": "",
|
||||||
|
"pqdt": "",
|
||||||
|
"docdt": "",
|
||||||
|
"resdt": "",
|
||||||
|
"editdt": "",
|
||||||
|
"inputer": "",
|
||||||
|
"inspecter": "",
|
||||||
|
"state": "",
|
||||||
|
"no": "",
|
||||||
|
"lock": "",
|
||||||
|
"isclosed": "",
|
||||||
|
"org_real": "",
|
||||||
|
"nbidcls": "",
|
||||||
|
"breakdown_bid_check": "",
|
||||||
|
"local_join_check": "",
|
||||||
|
"first_join_part": "",
|
||||||
|
"first_join_part_prevamt": "",
|
||||||
|
"first_join_part_presum": "",
|
||||||
|
"assessment_percent": "",
|
||||||
|
"plus_local": "",
|
||||||
|
"safebid": "",
|
||||||
|
"etc_option": "",
|
||||||
|
"state_a": "",
|
||||||
|
"blocal": "",
|
||||||
|
"big_part": "",
|
||||||
|
"auto_service_code": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
bidvalueItem = {
|
||||||
|
"bidid": "",
|
||||||
|
"scrcls": "",
|
||||||
|
"scrid": "",
|
||||||
|
"constno": "",
|
||||||
|
"refno": "",
|
||||||
|
"realorg": "",
|
||||||
|
"yegatype": "",
|
||||||
|
"yegarng": "",
|
||||||
|
"prevamt": "",
|
||||||
|
"multispare": "",
|
||||||
|
"parbasic": "",
|
||||||
|
"lvcnt": "",
|
||||||
|
"contloc": "",
|
||||||
|
"contper": "",
|
||||||
|
"charger": "",
|
||||||
|
"exptax": "",
|
||||||
|
"contract_money": "",
|
||||||
|
"government_money": "",
|
||||||
|
"promise_org": "",
|
||||||
|
"c_exp_cost": "",
|
||||||
|
"pre_cost": "",
|
||||||
|
"industry_code": "",
|
||||||
|
"work_field": "",
|
||||||
|
"result_check_dt": "",
|
||||||
|
"explain_local": "",
|
||||||
|
"join_part_total_percent": "",
|
||||||
|
"ser_check": "",
|
||||||
|
"ser_join_agree_check": "",
|
||||||
|
"compute": "",
|
||||||
|
"jk_info_service": "",
|
||||||
|
"mutual_mayor": "",
|
||||||
|
"bid_statement_text": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bidcontentItem = {
|
||||||
|
"bidid": "",
|
||||||
|
"bidcomment_mod": "",
|
||||||
|
"bidcomment": "",
|
||||||
|
"nbidcomment": "",
|
||||||
|
"orign_lnk": "",
|
||||||
|
"s_orign_lnk": "",
|
||||||
|
"attchd_lnk": "",
|
||||||
|
"pur_lnk": "",
|
||||||
|
"bid_html": "",
|
||||||
|
"nbid_html": "",
|
||||||
|
"bid_file": "",
|
||||||
|
"nbid_file": "",
|
||||||
|
"pur_goods": "",
|
||||||
|
"partition_seq": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
bidresItem = {
|
||||||
|
"bidid": "",
|
||||||
|
"yega": "",
|
||||||
|
"selms": "",
|
||||||
|
"multicnt": "",
|
||||||
|
"multispare": "",
|
||||||
|
"innum": "",
|
||||||
|
"officenm1": "",
|
||||||
|
"prenm1": "",
|
||||||
|
"officeno1": "",
|
||||||
|
"success1": "",
|
||||||
|
"reswdt": "",
|
||||||
|
"mult_yega_fg": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
bidsuccomItem = {
|
||||||
|
"bidid": "",
|
||||||
|
"seq": "",
|
||||||
|
"officeno": "",
|
||||||
|
"officenm": "",
|
||||||
|
"prenm": "",
|
||||||
|
"success": "",
|
||||||
|
"pct": "",
|
||||||
|
"regdt": "",
|
||||||
|
"rank": "",
|
||||||
|
"selms": "",
|
||||||
|
"etc": "",
|
||||||
|
"partition_seq": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bidlocalItem = {
|
||||||
|
"bidid": "",
|
||||||
|
"code": "",
|
||||||
|
"name": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bidgoodsItem = {
|
||||||
|
"bidid": "",
|
||||||
|
"seq": "",
|
||||||
|
"gcode": "",
|
||||||
|
"gname": "",
|
||||||
|
"gorg": "",
|
||||||
|
"standard": "",
|
||||||
|
"cnt": "",
|
||||||
|
"unit": "",
|
||||||
|
"unitcost": "",
|
||||||
|
"period": "",
|
||||||
|
"place": "",
|
||||||
|
"condition": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
3368
backend/airflow/dags/plugins/utils/scraplib.py
Normal file
3368
backend/airflow/dags/plugins/utils/scraplib.py
Normal file
File diff suppressed because it is too large
Load Diff
154
backend/airflow/dags/plugins/utils/setup_utils.py
Normal file
154
backend/airflow/dags/plugins/utils/setup_utils.py
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
from selenium import webdriver
|
||||||
|
from selenium.webdriver.chrome.options import Options
|
||||||
|
from selenium.webdriver.chrome.service import Service
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
from airflow.exceptions import AirflowException
|
||||||
|
from airflow.models import Variable
|
||||||
|
from airflow.providers.mysql.hooks.mysql import MySqlHook
|
||||||
|
|
||||||
|
from plugins.sql.hooks.mysql_hook import CommonHookMySQL
|
||||||
|
|
||||||
|
class SetupUtils:
|
||||||
|
|
||||||
|
# DB 연결 ID
|
||||||
|
DB_CONN_ID = "COLLECT_SERVER_DB"
|
||||||
|
|
||||||
|
# 나라장터 메인 URL
|
||||||
|
G2B_MAIN_URL = Variable.get("G2B_MAIN_URL", '')
|
||||||
|
|
||||||
|
# 크롬 드라이버 경로
|
||||||
|
CHROMEDRIVER_EXECUTABLE_PATH = Variable.get("CHROMEDRIVER_EXECUTABLE_PATH", '/usr/local/bin/chromedriver')
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.driver = None
|
||||||
|
self._all_cookies = {}
|
||||||
|
self.CommonHookMySQL = CommonHookMySQL()
|
||||||
|
logging.info(f"=============== MySQL 연결 시작 ================")
|
||||||
|
self.db_hook = MySqlHook(mysql_conn_id=self.DB_CONN_ID)
|
||||||
|
self.conn = self.db_hook.get_conn()
|
||||||
|
logging.info(f"===============================================")
|
||||||
|
|
||||||
|
|
||||||
|
# 소멸자
|
||||||
|
def __del__(self):
|
||||||
|
logging.info(f"=============== MySQL 연결 종료 ================")
|
||||||
|
self.conn.close()
|
||||||
|
logging.info(f"===============================================")
|
||||||
|
|
||||||
|
|
||||||
|
# Selenium 드라이버 초기화
|
||||||
|
def _setup_selenium(self):
|
||||||
|
logging.info("Selenium Chrome 드라이버 초기화 중...")
|
||||||
|
chrome_options = Options()
|
||||||
|
chrome_options.add_argument('--headless')
|
||||||
|
chrome_options.add_argument('--no-sandbox')
|
||||||
|
chrome_options.add_argument('--disable-dev-shm-usage')
|
||||||
|
chrome_options.add_argument('--disable-gpu')
|
||||||
|
chrome_options.add_argument('--window-size=1920,1080')
|
||||||
|
chrome_options.add_argument('user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36')
|
||||||
|
|
||||||
|
logging.info(f"사용할 ChromeDriver 경로: {self.CHROMEDRIVER_EXECUTABLE_PATH}")
|
||||||
|
|
||||||
|
if not os.path.exists(self.CHROMEDRIVER_EXECUTABLE_PATH):
|
||||||
|
error_msg = f"ChromeDriver 실행 파일이 지정된 경로에 없습니다: {self.CHROMEDRIVER_EXECUTABLE_PATH}. " \
|
||||||
|
"Airflow Variable 'CHROMEDRIVER_EXECUTABLE_PATH' 설정을 확인하거나, " \
|
||||||
|
"Airflow 워커 이미지에 해당 경로로 ChromeDriver가 설치되었는지 확인해주세요."
|
||||||
|
logging.error(error_msg)
|
||||||
|
raise FileNotFoundError(error_msg)
|
||||||
|
|
||||||
|
if not os.access(self.CHROMEDRIVER_EXECUTABLE_PATH, os.X_OK):
|
||||||
|
error_msg = f"ChromeDriver 실행 파일에 실행 권한이 없습니다: {self.CHROMEDRIVER_EXECUTABLE_PATH}. " \
|
||||||
|
"파일에 실행 권한(chmod +x)을 부여해주세요."
|
||||||
|
logging.error(error_msg)
|
||||||
|
raise PermissionError(error_msg)
|
||||||
|
|
||||||
|
try:
|
||||||
|
service = Service(executable_path=self.CHROMEDRIVER_EXECUTABLE_PATH)
|
||||||
|
self.driver = webdriver.Chrome(
|
||||||
|
service=service,
|
||||||
|
options=chrome_options
|
||||||
|
)
|
||||||
|
logging.info("Selenium Chrome 드라이버가 성공적으로 초기화되었습니다.")
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Selenium Chrome 드라이버 초기화 중 심각한 오류 발생: {e}")
|
||||||
|
logging.error(f"ChromeDriver 경로: {self.CHROMEDRIVER_EXECUTABLE_PATH}, 사용된 옵션: {chrome_options.arguments}")
|
||||||
|
raise AirflowException(f"Selenium 드라이버 초기화 실패: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# =======================================================================================================================================
|
||||||
|
# 크롬 드라이버 닫기
|
||||||
|
def _close_driver(self):
|
||||||
|
try:
|
||||||
|
if self.driver:
|
||||||
|
self.driver.quit()
|
||||||
|
logging.info("Selenium 드라이버가 안전하게 종료되었습니다.")
|
||||||
|
self.driver = None
|
||||||
|
else:
|
||||||
|
logging.warning("Selenium 드라이버가 이미 종료되었거나 초기화되지 않았습니다.")
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Selenium 드라이버 종료 중 오류 발생: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
# =======================================================================================================================================
|
||||||
|
# 쿠키 가져오기
|
||||||
|
def _get_cookies(self):
|
||||||
|
main_page_url = self.G2B_MAIN_URL
|
||||||
|
try:
|
||||||
|
self._setup_selenium()
|
||||||
|
|
||||||
|
logging.info(f"{main_page_url} 에 접속하여 쿠키를 가져오고 있습니다...")
|
||||||
|
self.driver.get(main_page_url)
|
||||||
|
time.sleep(5) # 페이지 로딩 및 JS 실행 시간 고려
|
||||||
|
|
||||||
|
cookies = self.driver.get_cookies()
|
||||||
|
# 모든 쿠키를 딕셔너리에 저장
|
||||||
|
self._all_cookies = {cookie['name']: cookie['value'] for cookie in cookies}
|
||||||
|
|
||||||
|
logging.info(f"Selenium을 사용하여 모든 쿠키를 가져왔습니다: {self._all_cookies}")
|
||||||
|
|
||||||
|
# 최소한 필요한 쿠키(JSESSIONID, XTVID 등)가 있는지 확인 (선택 사항이지만 권장)
|
||||||
|
if not self._all_cookies.get('JSESSIONID') or not self._all_cookies.get('XTVID'):
|
||||||
|
logging.warning("필요한 쿠키(JSESSIONID or XTVID)가 없을 수 있습니다.")
|
||||||
|
|
||||||
|
self._close_driver()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Selenium을 사용하여 쿠키를 가져오는 중 오류가 발생했습니다: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
# =======================================================================================================================================
|
||||||
|
# 코드 데이터 DB UPSERT
|
||||||
|
def common_code_db_upsert(self, code_data):
|
||||||
|
try:
|
||||||
|
query = f"""
|
||||||
|
INSERT INTO common_code (group_id, code_key, code_value, update_dt)
|
||||||
|
VALUES ('{code_data['group_id']}', '{code_data['code_key']}', '{code_data['code_value']}', NOW())
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
group_id = '{code_data['group_id']}', code_key = '{code_data['code_key']}', code_value = '{code_data['code_value']}', update_dt = NOW();
|
||||||
|
"""
|
||||||
|
self.CommonHookMySQL.process_sql(self.conn, query)
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"쿼리: {query}")
|
||||||
|
logging.error(f"코드 데이터 DB UPSERT 중 오류가 발생했습니다: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
# =======================================================================================================================================
|
||||||
|
# 코드 데이터 select
|
||||||
|
def common_code_db_select(self, group_id, code_key):
|
||||||
|
try:
|
||||||
|
query = f"""
|
||||||
|
SELECT code_value FROM common_code WHERE group_id = '{group_id}' AND code_key = '{code_key}'
|
||||||
|
"""
|
||||||
|
result = self.CommonHookMySQL.select_sql(self.conn, query)
|
||||||
|
return result[0][0]
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"쿼리: {query}")
|
||||||
|
logging.error(f"코드 데이터 DB select 중 오류가 발생했습니다: {e}")
|
||||||
|
raise
|
||||||
|
|
15
backend/airflow/dags/plugins/utils/status_enum.py
Normal file
15
backend/airflow/dags/plugins/utils/status_enum.py
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
|
||||||
|
class StatusEnum:
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def status_enum(status: str):
|
||||||
|
if status == 'WAIT':
|
||||||
|
return 1
|
||||||
|
elif status == 'RUNNING':
|
||||||
|
return 2
|
||||||
|
elif status == 'FAIL':
|
||||||
|
return 3
|
||||||
|
elif status == 'SUCCESS':
|
||||||
|
return 4
|
||||||
|
else:
|
||||||
|
return 0
|
Binary file not shown.
Binary file not shown.
153
backend/airflow/dags/plugins/utils/transformers/attchd_lnk.py
Normal file
153
backend/airflow/dags/plugins/utils/transformers/attchd_lnk.py
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
import uuid
|
||||||
|
import random
|
||||||
|
import urllib
|
||||||
|
import html.entities
|
||||||
|
|
||||||
|
class AttchdLnk:
|
||||||
|
|
||||||
|
# make_guid 함수 정의
|
||||||
|
def make_guid(self, a=None):
|
||||||
|
if hasattr(uuid, 'uuid4'):
|
||||||
|
c = str(uuid.uuid4()).replace("-", "")
|
||||||
|
else:
|
||||||
|
c = ''.join([hex(random.randint(0, 65535))[2:] for _ in range(8)])
|
||||||
|
if a is not None:
|
||||||
|
c = "{}-{}".format(a, c)
|
||||||
|
return c
|
||||||
|
|
||||||
|
# 문자열 삽입 함수
|
||||||
|
def insertAt(self, a, c, b):
|
||||||
|
return a[:c] + b + a[c:]
|
||||||
|
|
||||||
|
# UTF-8 인코딩 함수
|
||||||
|
def utf8_encode(self, b):
|
||||||
|
b = b.replace('\r\n', '\n')
|
||||||
|
c = ""
|
||||||
|
for a in range(len(b)):
|
||||||
|
d = ord(b[a])
|
||||||
|
if d < 128:
|
||||||
|
c += chr(d)
|
||||||
|
else:
|
||||||
|
if 127 < d < 2048:
|
||||||
|
c += chr((d >> 6) | 192)
|
||||||
|
else:
|
||||||
|
c += chr((d >> 12) | 224)
|
||||||
|
c += chr(((d >> 6) & 63) | 128)
|
||||||
|
c += chr((d & 63) | 128)
|
||||||
|
return c
|
||||||
|
|
||||||
|
# Base64 인코딩 함수
|
||||||
|
def base64_encode(self, a):
|
||||||
|
keyStr = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="
|
||||||
|
c = ""
|
||||||
|
l = 0
|
||||||
|
a = self.utf8_encode(a)
|
||||||
|
while l < len(a):
|
||||||
|
b = ord(a[l])
|
||||||
|
l += 1
|
||||||
|
if l < len(a):
|
||||||
|
e = ord(a[l])
|
||||||
|
l += 1
|
||||||
|
else:
|
||||||
|
e = None
|
||||||
|
if l < len(a):
|
||||||
|
f = ord(a[l])
|
||||||
|
l += 1
|
||||||
|
else:
|
||||||
|
f = None
|
||||||
|
|
||||||
|
d = b >> 2
|
||||||
|
if e is not None:
|
||||||
|
b_new = ((b & 3) << 4) | (e >> 4)
|
||||||
|
if f is not None:
|
||||||
|
h = ((e & 15) << 2) | (f >> 6)
|
||||||
|
k = f & 63
|
||||||
|
else:
|
||||||
|
h = ((e & 15) << 2)
|
||||||
|
k = 64 # Padding index for '='
|
||||||
|
else:
|
||||||
|
b_new = ((b & 3) << 4)
|
||||||
|
h = k = 64 # Padding indices for '='
|
||||||
|
c += keyStr[d]
|
||||||
|
c += keyStr[b_new]
|
||||||
|
c += keyStr[h]
|
||||||
|
c += keyStr[k]
|
||||||
|
return c
|
||||||
|
|
||||||
|
# 암호화 파라미터 생성 함수
|
||||||
|
def makeEncryptParam(self, a):
|
||||||
|
#a = self.base64_encode(self.utf8_encode(a))
|
||||||
|
# 중복 사용이어서 수정
|
||||||
|
a = self.base64_encode(a)
|
||||||
|
if len(a) >= 10:
|
||||||
|
a = self.insertAt(a, 8, "r")
|
||||||
|
a = self.insertAt(a, 6, "a")
|
||||||
|
a = self.insertAt(a, 9, "o")
|
||||||
|
a = self.insertAt(a, 7, "n")
|
||||||
|
a = self.insertAt(a, 8, "w")
|
||||||
|
a = self.insertAt(a, 6, "i")
|
||||||
|
a = self.insertAt(a, 9, "z")
|
||||||
|
else:
|
||||||
|
a = self.insertAt(a, len(a) - 1, "$")
|
||||||
|
a = self.insertAt(a, 0, "$")
|
||||||
|
a = a.replace('+', '%2B')
|
||||||
|
return a
|
||||||
|
|
||||||
|
# 최종 암호화 파라미터 함수
|
||||||
|
def makeEncryptParamFinal(self, a, c):
|
||||||
|
b = {
|
||||||
|
'name': "",
|
||||||
|
'value': ""
|
||||||
|
}
|
||||||
|
e = "1"
|
||||||
|
f = "1"
|
||||||
|
d = "2018.1548512.1555.33"
|
||||||
|
if "1" >= e:
|
||||||
|
if f == "0":
|
||||||
|
b['name'] = "k10"
|
||||||
|
b['value'] = self.makeEncryptParam(a) if c and c == 1 else d + self.makeEncryptParam(a)
|
||||||
|
else:
|
||||||
|
b['name'] = "k00"
|
||||||
|
b['value'] = self.makeEncryptParam(a)
|
||||||
|
return b
|
||||||
|
|
||||||
|
def attchd_lnk_set(self, attchd_lnk_data):
|
||||||
|
|
||||||
|
# 데이터 없을 경우 데이터 반환하지 않도록 처리 필요
|
||||||
|
# 첨부파일 여러개이면 여러개 데이터 세팅하도록 처리 필요
|
||||||
|
|
||||||
|
#print("attchd_lnk_data > ", attchd_lnk_data)
|
||||||
|
|
||||||
|
returnData = ""
|
||||||
|
|
||||||
|
orgnlAtchFileNm = html.unescape(urllib.parse.unquote(attchd_lnk_data['orgnlAtchFileNm']))
|
||||||
|
atchFilePathNm = html.unescape(urllib.parse.unquote(attchd_lnk_data['atchFilePathNm']))
|
||||||
|
|
||||||
|
#print("orgnlAtchFileNm > ", orgnlAtchFileNm)
|
||||||
|
#print("atchFilePathNm > ", atchFilePathNm)
|
||||||
|
|
||||||
|
file_set_data = "kc\fc11\u000b"
|
||||||
|
file_set_data += "k01\f1\u000b"
|
||||||
|
file_set_data += "k12\f{}\u000b".format(self.make_guid())
|
||||||
|
file_set_data += "k26\f{}\u000b".format(atchFilePathNm)
|
||||||
|
file_set_data += "k31\f{}\u000b".format(orgnlAtchFileNm)
|
||||||
|
file_set_data += "k21\f{},{}\u000b".format(attchd_lnk_data['untyAtchFileNo'], attchd_lnk_data['atchFileSqno'])
|
||||||
|
|
||||||
|
#print("file_set_data > ", file_set_data)
|
||||||
|
|
||||||
|
# 마지막 수직 탭 제거 (원하지 않으면 생략 가능)
|
||||||
|
# file_set_data = file_set_data.rstrip("\u000b")
|
||||||
|
# print("file_set_data 수직 탭 제거> ", file_set_data)
|
||||||
|
|
||||||
|
# Now, process a using makeEncryptParamFinal
|
||||||
|
file_set_data_result = self.makeEncryptParamFinal(file_set_data, c=1)
|
||||||
|
file_set_data_name = file_set_data_result.get('name')
|
||||||
|
file_set_data_value = file_set_data_result.get('value')
|
||||||
|
|
||||||
|
returnData = "{orgnlAtchFileNm}#=====#https://www.g2b.go.kr/fs/fsc/fsca/fileUpload.do?{file_set_data_name}={file_set_data_value}".format(orgnlAtchFileNm=orgnlAtchFileNm, file_set_data_name=file_set_data_name, file_set_data_value=file_set_data_value)
|
||||||
|
|
||||||
|
#print("file_set_data_result > ", file_set_data_result)
|
||||||
|
#print("https://www.g2b.go.kr/fs/fsc/fsca/fileUpload.do?" + file_set_data_name + "=")
|
||||||
|
#print("file_set_data_value > ", file_set_data_value)
|
||||||
|
|
||||||
|
return returnData
|
@ -0,0 +1,240 @@
|
|||||||
|
import plugins.utils.scraplib as scraplib #공통 라이브러리
|
||||||
|
from plugins.utils.transformers.transformer_helper import TransformerHelper
|
||||||
|
from plugins.utils.transformers.attchd_lnk import AttchdLnk
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
class BidContentTransformer(TransformerHelper):
|
||||||
|
|
||||||
|
# todo 진행중
|
||||||
|
# 자격조건
|
||||||
|
def bidcomment(self, api_data, param):
|
||||||
|
bidcomment = ""
|
||||||
|
if param["bidtype"] == "견적":
|
||||||
|
bidcomment = "공고원문을 참고하시기 바랍니다.\n\n"
|
||||||
|
else:
|
||||||
|
if param["pqdt"] is not None and param["pqdt"] != "":
|
||||||
|
bidcomment += "PQ심사신청서 신청기한 : " + param["pqdt"] + " \n\n "
|
||||||
|
|
||||||
|
if param["jkdt"] is not None and param["jkdt"] != "":
|
||||||
|
bidcomment += "적격성심사신청서 신청기한 : " + param["jkdt"] + " \n\n "
|
||||||
|
|
||||||
|
# 입찰보증서접수 마감일시
|
||||||
|
guarantee = self.extract_value(api_data, "dmItemMap", "bidDepoPayTermDt")
|
||||||
|
|
||||||
|
if guarantee is not None: # 보증서접수마감일시 있는경우 추가
|
||||||
|
bidcomment += "보증서접수마감일시 : {guarantee}\n보증서 접수마감일시를 입력하지 않은 경우에는, 입찰서 접수마감일 전일 18시까지 제출이 가능합니다.\n" \
|
||||||
|
"(단, 입찰보증금지급각서로 대체하는 경우 보증금이 면제됩니다.)\n\n ".format(guarantee=guarantee.replace("/", "-"))
|
||||||
|
if "notice_memo" in param["item_bidnoticememo"]: # 공지사항이 있는경우 추가
|
||||||
|
bidcomment += param["notice_memo"] # 첨부파일까지 넣으려면 item_bidnoticememo["notice_memo"] 를 넣으면 됨
|
||||||
|
|
||||||
|
# 첨부파일 없을 때, 해당 문구 뜰 수 있게 적용
|
||||||
|
if not param['attchd_lnk']:
|
||||||
|
bidcomment += " \n [* 공고 원문을 참조하시기 바랍니다. *]"
|
||||||
|
|
||||||
|
return bidcomment
|
||||||
|
|
||||||
|
# todo 진행중
|
||||||
|
# 공고변경사유
|
||||||
|
def bidcomment_mod(self, noticeClCd, bidcomment_mod_notice_memo):
|
||||||
|
bidcomment_mod = None
|
||||||
|
if noticeClCd != "" and bidcomment_mod_notice_memo is not None: # 공지사항이 있고, 취소 또는 정정일 경우
|
||||||
|
bidcomment_mod = self.Etl.clean_page_source(bidcomment_mod_notice_memo)
|
||||||
|
return bidcomment_mod
|
||||||
|
|
||||||
|
# todo 진행중
|
||||||
|
# 공고문첨부파일
|
||||||
|
def attchd_lnk(self, json_res_file):
|
||||||
|
|
||||||
|
file_params = self.extract_value(json_res_file, "dlUntyAtchFileL")
|
||||||
|
if file_params is None:
|
||||||
|
# print("첨부파일 없음")
|
||||||
|
return None
|
||||||
|
|
||||||
|
make_attchd_lnk = AttchdLnk()
|
||||||
|
|
||||||
|
file_list = []
|
||||||
|
for item in file_params:
|
||||||
|
attchd_lnk_data = {
|
||||||
|
"untyAtchFileNo": self.extract_value(item, "untyAtchFileNo", default=""),
|
||||||
|
"atchFileSqno": self.extract_value(item, "atchFileSqno", default=""),
|
||||||
|
"orgnlAtchFileNm": self.extract_value(item, "orgnlAtchFileNm", default=""),
|
||||||
|
"atchFilePathNm": self.extract_value(item, "atchFilePathNm", default="")
|
||||||
|
}
|
||||||
|
|
||||||
|
file_list.append(make_attchd_lnk.attchd_lnk_set(attchd_lnk_data))
|
||||||
|
return "|~~~~~|".join(file_list)
|
||||||
|
|
||||||
|
"""
|
||||||
|
print("bidtype :", bidtype)
|
||||||
|
if bidtype == "견적":
|
||||||
|
attchd_lnk_nm = driver.rtn_elements("//caption[.='첨부문서']/parent::table/tbody/tr/td[@class='tl'][2]", "XPATH", "")
|
||||||
|
#print("attchd_lnk_nm :", attchd_lnk_nm)
|
||||||
|
attchd_lnk_href = list(map(lambda x: x.replace("javascript:toFile('", "").replace("');", "").replace(" ", "").split("','"), driver.rtn_elements("//caption[.='첨부문서']/parent::table/tbody/tr/td[@class='tl'][2]/a", "XPATH", "href")))
|
||||||
|
attchd_lnk_href = list(map(lambda x: x[0] if len(x) < 2 else "https://www.g2b.go.kr:8402/gtob/all/pr/estimate/fileDownloadG2B.do?estmtReqNo={estmtReqNo},{_idx}".format(estmtReqNo=x[0], _idx=x[1]), attchd_lnk_href))
|
||||||
|
#print("attchd_lnk_href :", attchd_lnk_href)
|
||||||
|
elif bidtype == "기타":
|
||||||
|
attchd_lnk_nm = driver.rtn_elements("//caption[.='공고서 테이블']/parent::table/tbody/tr/td/a", "XPATH", "")
|
||||||
|
attchd_lnk_href = list(map(lambda x: x.replace("javascript:otherBidDtl_fileDownload('", "").replace("');", "").replace(" ", ""), driver.rtn_elements("//caption[.='공고서 테이블']/parent::table/tbody/tr/td/a", "XPATH", "href")))
|
||||||
|
attchd_lnk_href = list(map(lambda x: "https://www.g2b.go.kr:8101/ep/tbid/downloadOtherBidAttafile.do?path=&fileName=&bidattaFileNo={bidattaFileNo}&tbidno={tbidno}&bidseq={bidseq}".format(bidattaFileNo=x, tbidno=PARAMS["tbidno"][0], bidseq=PARAMS["bidseq"][0]), attchd_lnk_href))
|
||||||
|
elif len(driver.rtn_elements("//iframe[@title='혁신장터 첨부파일']", "XPATH", ""))>0 :#혁신장터
|
||||||
|
driver.switch_to_frame("//iframe[@title='혁신장터 첨부파일']")
|
||||||
|
attchd_lnk_nm = driver.rtn_elements("//tbody/tr/td[@class='left retTD']//em", "XPATH", "")
|
||||||
|
attchd_lnk_href = list(map(lambda x: x.replace("fileModule.download('", "").replace("')", "").replace(" ", "").split("','"), driver.rtn_elements("//tbody/tr/td[@class='left retTD']//a[contains(@onclick,'fileModule')]", "XPATH", "onclick")))
|
||||||
|
attchd_lnk_href = list(map(lambda x: "https://ppi.g2b.go.kr:8914/cmmn/atachFile/downloadPlainFile.do?untyAtchmnflNo={untyAtchmnflNo}&atchmnflSno={atchmnflSno}".format(untyAtchmnflNo=x[0], atchmnflSno=x[1]), attchd_lnk_href))
|
||||||
|
driver.switch_to_default_frame()
|
||||||
|
else:
|
||||||
|
attchd_lnk_nm = driver.rtn_elements("//caption[.='업로드 된 파일 정보']/parent::table/tbody/tr/td[@class='tl']//a[contains(@href,'dtl_fileDownload')]", "XPATH", "")
|
||||||
|
attchd_lnk_href = list(map(lambda x: x.replace("javascript:dtl_fileDownload('", "").replace("');", "").replace(" ", "").split("','"), driver.rtn_elements("//caption[.='업로드 된 파일 정보']/parent::table/tbody/tr/td[@class='tl']//a[contains(@href,'dtl_fileDownload')]", "XPATH", "href")))
|
||||||
|
attchd_lnk_href = list(map(lambda x: "https://www.g2b.go.kr:8081/ep/co/fileDownload.do?fileTask=NOTIFY&fileSeq={fileSeq}".format(fileSeq=x[0]), attchd_lnk_href))
|
||||||
|
|
||||||
|
attchd_lnk_nm2 = driver.rtn_elements("//caption[.='E발주 제안요청서 첨부파일']/parent::table/tbody/tr/td[@class='tl']//a[contains(@href,'eeOrderAttachFileDownload')]","XPATH", "")
|
||||||
|
attchd_lnk_href2 = list(map(lambda x: x.replace("javascript:eeOrderAttachFileDownload('", "").replace("');", "").replace(" ", "").split("','"), driver.rtn_elements("//caption[.='E발주 제안요청서 첨부파일']/parent::table/tbody/tr/td[@class='tl']//a[contains(@href,'eeOrderAttachFileDownload')]","XPATH", "href")))
|
||||||
|
attchd_lnk_href2 = list(map(lambda x: "https://rfp.g2b.go.kr:8426/cmm/FileDownload.do?atchFileId={fileSeq1}&fileSn={fileSeq2}".format(fileSeq1=x[1], fileSeq2=x[2]), attchd_lnk_href2))
|
||||||
|
|
||||||
|
attchd_lnk_nm = attchd_lnk_nm + attchd_lnk_nm2
|
||||||
|
attchd_lnk_href = attchd_lnk_href + attchd_lnk_href2
|
||||||
|
|
||||||
|
#print("attchd_lnk_nm :", attchd_lnk_nm)
|
||||||
|
#print("attchd_lnk_href :", attchd_lnk_href)
|
||||||
|
item_bidcontent['attchd_lnk'] = "|~~~~~|".join(list(map(lambda x, y: x + "#=====#" + y, attchd_lnk_nm, attchd_lnk_href)))
|
||||||
|
#print("attchd_lnk_str :", item_bidcontent['attchd_lnk'])
|
||||||
|
return ""
|
||||||
|
"""
|
||||||
|
|
||||||
|
# todo 진행중
|
||||||
|
# 입찰상세내용(공고문)
|
||||||
|
def bid_html(self, bidtype, bidproc, bidcls, whereis):
|
||||||
|
# todo None이면 암것도 안하고 ''이면 공백으로 덮어 쓰는듯??
|
||||||
|
if bidproc == 'M':
|
||||||
|
bid_html = '' # 정정공고일경우 전차수 bid_html 초기화
|
||||||
|
else:
|
||||||
|
bid_html = None
|
||||||
|
|
||||||
|
# todo 민간 견적공고 공고문 만들기
|
||||||
|
|
||||||
|
# 공고 시스템 수집
|
||||||
|
# 공사/용역 이면서 전자시담
|
||||||
|
# 민간공고 이면서 취소건
|
||||||
|
# 견적공고
|
||||||
|
#if (bidcls == '06' and whereis == '01') or (item_bidkey['bidproc'] == 'C' and whereis == '84') or whereis == '85':
|
||||||
|
if (item_bidkey['bidproc'] == 'C' and whereis == '84') or whereis == '85':
|
||||||
|
bid_source = page_source.split('\n')
|
||||||
|
html_tmp = ""
|
||||||
|
html_check = False
|
||||||
|
for i in bid_source:
|
||||||
|
#print(i)
|
||||||
|
i = self.Etl.clean_page_source(i)
|
||||||
|
if whereis == '01' or whereis == '84':
|
||||||
|
if i.find('<h3>%[공고일반') != -1 or i.find('<h3>[공고일반') != -1:
|
||||||
|
html_check = True
|
||||||
|
if i.find('<h3>%[첨부 파일') != -1 or i.find('<h3>[첨부 파일') != -1:
|
||||||
|
html_check = False
|
||||||
|
if whereis == '85':
|
||||||
|
if i.find('infoleft') != -1:
|
||||||
|
print("3")
|
||||||
|
html_check = True
|
||||||
|
if i.find('button_wrap') != -1:
|
||||||
|
html_check = False
|
||||||
|
print("4")
|
||||||
|
|
||||||
|
if html_check == True:
|
||||||
|
print(i)
|
||||||
|
html_sub_check = True
|
||||||
|
if i.find('class=hide') != -1 or i.find('<caption') != -1:
|
||||||
|
html_sub_check = False
|
||||||
|
if i.find("<table") != -1:
|
||||||
|
i = self.Util.clean_str(i, [["<table ", "<TABLE border='1' width='100%%' cellpadding='5' cellspacing='0' "]])
|
||||||
|
if i.find("<h3>") != -1:
|
||||||
|
i = self.Util.clean_str(i, [["<h3>","<BR><BR><H3>"]])
|
||||||
|
if i.find("<th>") != -1:
|
||||||
|
i = self.Util.clean_str(i, [["<th>","<TH style='background-color:#ebf3fe'>"]])
|
||||||
|
if i.find("<th ") != -1:
|
||||||
|
i = self.Util.clean_str(i, [["<th ","<TH style='background-color:#ebf3fe' "]])
|
||||||
|
|
||||||
|
if html_sub_check == True:
|
||||||
|
html_tmp = html_tmp + i
|
||||||
|
|
||||||
|
html_tmp = html_tmp + "<br/><br/><b>**공고원문참조**<b/>" if whereis == '85' else html_tmp
|
||||||
|
bid_html = html_tmp + "<br><br>"
|
||||||
|
|
||||||
|
if bidtype == 'pur':
|
||||||
|
try:
|
||||||
|
bid_html = self.get_pageSource_to_html(page_source)
|
||||||
|
except :
|
||||||
|
bid_html = ""
|
||||||
|
|
||||||
|
return bid_html
|
||||||
|
|
||||||
|
#첨부파일
|
||||||
|
def attchd_file_no(self, api_data):
|
||||||
|
if self.tran_bidtype == "기타" or self.tran_bidtype == "민간":
|
||||||
|
attchd_file_no = self.extract_value(api_data, "dmItemMap", "untyAtchFileNo")
|
||||||
|
else:
|
||||||
|
attchd_file_no = self.extract_value(api_data, "dmItemMap", "itemPbancUntyAtchFileNo")
|
||||||
|
if attchd_file_no is None:
|
||||||
|
# print("첨부파일 번호 없음")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return attchd_file_no
|
||||||
|
|
||||||
|
##########################################################################################
|
||||||
|
|
||||||
|
def get_pageSource_to_html(self, page_source):
|
||||||
|
|
||||||
|
html_list = []
|
||||||
|
|
||||||
|
soup_html = BeautifulSoup(page_source.replace("<br>", " "), 'html.parser')
|
||||||
|
element_list = soup_html.select("#container > div")
|
||||||
|
|
||||||
|
if len(element_list) == 0 :
|
||||||
|
element_list = soup_html.select("#container > #resultForm > div")
|
||||||
|
|
||||||
|
# 불필요한 요소 우선삭제 처리
|
||||||
|
decompose_dict = {"div": {'class': ['description']},
|
||||||
|
"img": {},
|
||||||
|
"p": {'class': ['hide']},
|
||||||
|
"caption": {},
|
||||||
|
"td": {'class': ['tline']},
|
||||||
|
}
|
||||||
|
|
||||||
|
for tag_name, attr_dict in decompose_dict.items():
|
||||||
|
for element_tag in soup_html.find_all(tag_name, attrs=attr_dict):
|
||||||
|
element_tag.decompose()
|
||||||
|
|
||||||
|
# CSS를 위해 class 추가
|
||||||
|
# { "HTML 태그" : { "attr" : attr_dict } }, }
|
||||||
|
classAdd_dict = {
|
||||||
|
"div": {"attr": {'class': ['results', 'section']}, "addClass": ['ij_table', 'type1', 'border']},
|
||||||
|
"h3": {"attr": {}, "addClass": ['mt-5']},
|
||||||
|
}
|
||||||
|
|
||||||
|
for tag_name, attr_dict in classAdd_dict.items():
|
||||||
|
for element_tag in soup_html.find_all(tag_name, attrs=attr_dict['attr']):
|
||||||
|
element_tag['class'] = " ".join(attr_dict['addClass'])
|
||||||
|
|
||||||
|
for idx, element in enumerate(element_list, 0):
|
||||||
|
|
||||||
|
# select된 HTML tag의 Class 추출하여 타이틀 판단 후, 포함할지에 대한 판단
|
||||||
|
try:
|
||||||
|
element_class = element.get("class")
|
||||||
|
except:
|
||||||
|
element_class = None
|
||||||
|
|
||||||
|
# 서브타이틀 항목 텍스트 우선추출
|
||||||
|
if element_class is not None and "infoleft" in element_class:
|
||||||
|
element_text = element.find("h3")
|
||||||
|
if element_text.text.strip() not in ['[첨부 파일 (e-발주시스템)]', "[나의공고 관리 ]"]:
|
||||||
|
|
||||||
|
html_list.append(str(element_list[idx]))
|
||||||
|
html_list.append(str(element_list[idx + 1]))
|
||||||
|
|
||||||
|
if "inforight" in element_list[idx + 1].get("class"):
|
||||||
|
html_list.append(str(element_list[idx + 2]))
|
||||||
|
|
||||||
|
html_source = ""
|
||||||
|
try:
|
||||||
|
html_source = self.Util.clean_str( "".join(html_list) ).replace("\"","'")
|
||||||
|
except:
|
||||||
|
html_source = ""
|
||||||
|
|
||||||
|
return html_source
|
@ -0,0 +1,87 @@
|
|||||||
|
import re
|
||||||
|
from plugins.utils.transformers.transformer_helper import TransformerHelper
|
||||||
|
|
||||||
|
class BidGoodsTransformer(TransformerHelper):
|
||||||
|
|
||||||
|
|
||||||
|
# 구매물품리스트 (구매대상물품)
|
||||||
|
def bid_goods(self, api_data):
|
||||||
|
item_list = self.extract_value(api_data, "dmItemItemList")
|
||||||
|
if item_list is None:
|
||||||
|
# print("구매대상물품 없음")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
bidgoods = {}
|
||||||
|
|
||||||
|
for value in item_list:
|
||||||
|
# 분류 번호
|
||||||
|
item_no = self.extract_value(value, "bidClsfNo")
|
||||||
|
|
||||||
|
# 리스트로 넣기 위해 초기화 (if조건 없으면 계속 빈값됨)
|
||||||
|
if item_no not in bidgoods:
|
||||||
|
bidgoods[item_no] = {}
|
||||||
|
|
||||||
|
list_idx = len(bidgoods[item_no])+1
|
||||||
|
|
||||||
|
item_row = {
|
||||||
|
"seq" : list_idx,
|
||||||
|
"gcode" : self.extract_value(value, "dtlsPrnmNo", default=""), # 세부품명번호
|
||||||
|
"gname" : self.extract_value(value, "dtlsPrnmNm", default=""), # 세부품명
|
||||||
|
"gorg" : self.extract_value(value, "dmstUntyGrpNm", default=""), # 수요기관
|
||||||
|
"standard" : self.Etl.safe_encode(self.extract_value(value, "itemDtlSpecCn", default="")), # 규격
|
||||||
|
"cnt" : self.extract_value(value, "prchsDtlItemQty", default=""), # 수량
|
||||||
|
"unit" : self.extract_value(value, "prchsDtlItemUntValNm", default=""), # 단위
|
||||||
|
"unitcost" : self.extract_value(value, "prchsDtlItemUprc", default=""), # 추정단가
|
||||||
|
"period" : self.extract_value(value, "dlvgdsTermDt", default=""), # 납품기한
|
||||||
|
"place" : self.Etl.safe_encode(self.extract_value(value, "dlvgdsPlacNm", default="")), # 남품장소
|
||||||
|
"condition" : self.extract_value(value, "devyCndtNm", default=""), # 인도조건
|
||||||
|
}
|
||||||
|
|
||||||
|
bidgoods[item_no][len(bidgoods[item_no])] = item_row
|
||||||
|
|
||||||
|
return bidgoods
|
||||||
|
|
||||||
|
# MAS 구매물품리스트 (구매대상물품)
|
||||||
|
def bid_goods_mas(self, api_data):
|
||||||
|
item_list = self.extract_value(api_data, "dlPbancItemL")
|
||||||
|
if item_list is None:
|
||||||
|
# print("구매대상물품 없음")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
bidgoods = {}
|
||||||
|
|
||||||
|
for value in item_list:
|
||||||
|
# 분류 번호
|
||||||
|
item_no = self.extract_value(value, "bidClsfNo")
|
||||||
|
|
||||||
|
# 리스트로 넣기 위해 초기화 (if조건 없으면 계속 빈값됨)
|
||||||
|
if item_no not in bidgoods:
|
||||||
|
bidgoods[item_no] = {}
|
||||||
|
|
||||||
|
list_idx = len(bidgoods[item_no]) + 1
|
||||||
|
|
||||||
|
item_row = {
|
||||||
|
"seq": list_idx,
|
||||||
|
"gcode": self.extract_value(value, "dtlsPrnmNo", default=""), # 세부품명번호
|
||||||
|
"gname": self.extract_value(value, "dtlsPrnmNm", default=""), # 세부품명
|
||||||
|
"gorg": self.extract_value(value, "dmstUntyGrpNm", default=""), # 수요기관
|
||||||
|
"standard": self.Etl.safe_encode(self.extract_value(value, "itemDtlSpecCn", default="")), # 규격
|
||||||
|
"cnt": self.extract_value(value, "prchsDtlItemQty", default=""), # 수량
|
||||||
|
"unit": self.extract_value(value, "prchsDtlItemUntVal", default=""), # 단위
|
||||||
|
"unitcost": self.extract_value(value, "prchsDtlItemUprc", default=""), # 추정단가
|
||||||
|
"period": self.extract_value(value, "dlvgdsTermDt", default=""), # 납품기한
|
||||||
|
"place": self.extract_value(value, "dlvgdsPlacNm", default=""), # 남품장소
|
||||||
|
"condition": self.extract_value(value, "devyCndtNm", default=""), # 인도조건
|
||||||
|
}
|
||||||
|
|
||||||
|
bidgoods[item_no][len(bidgoods[item_no])] = item_row
|
||||||
|
|
||||||
|
return bidgoods
|
||||||
|
|
||||||
|
|
||||||
|
# 복수공고여부
|
||||||
|
def multi_bid_fg(self, item_bidgoods):
|
||||||
|
multi_bid_fg = False
|
||||||
|
if len(item_bidgoods) > 1:
|
||||||
|
multi_bid_fg = True
|
||||||
|
return multi_bid_fg
|
@ -0,0 +1,777 @@
|
|||||||
|
import re
|
||||||
|
import itertools
|
||||||
|
from plugins.utils.transformers.transformer_helper import TransformerHelper
|
||||||
|
import logging
|
||||||
|
|
||||||
|
class BidKeyTransformer(TransformerHelper):
|
||||||
|
|
||||||
|
# 공고분류
|
||||||
|
def bidtype(self, api_data, bidtype, constnm):
|
||||||
|
# 시설, 용역, 물품일경우
|
||||||
|
pattern = [
|
||||||
|
{"pattern": {"P1": "용역", }, "value": "ser"},
|
||||||
|
{"pattern": {"P1": "공사", }, "value": "con"},
|
||||||
|
{"pattern": {"P1": "물품", }, "value": "pur"},
|
||||||
|
]
|
||||||
|
|
||||||
|
if len(re.findall("공사|용역|물품", bidtype)) > 0:
|
||||||
|
return self.Etl.mapping_pattern_value(pattern, bidtype, None)
|
||||||
|
elif len(re.findall("민간|견적", bidtype)) > 0: # 민간, 견적
|
||||||
|
civil_bidtype = self.extract_value(api_data, "dmItemMap", "prcmBsneSeCd")
|
||||||
|
civil_pattern = [
|
||||||
|
{"pattern": {"P1": "조070001", }, "value": "pur"}, # 물품
|
||||||
|
{"pattern": {"P1": "조070002", }, "value": "ser"}, # 일반용역
|
||||||
|
{"pattern": {"P1": "조070003", }, "value": "ser"}, # 기술용역
|
||||||
|
{"pattern": {"P1": "조070004", }, "value": "con"}, # 공사
|
||||||
|
{"pattern": {"P1": "조070005", }, "value": "pur"}, # 기타 -> 기타가 따로 없음
|
||||||
|
]
|
||||||
|
return self.Etl.mapping_pattern_value(civil_pattern, civil_bidtype, "pur")
|
||||||
|
else: # 기타
|
||||||
|
pattern = [
|
||||||
|
{"pattern": {"P1": "용역", }, "value": "ser"},
|
||||||
|
{"pattern": {"P1": "공사", }, "value": "con"},
|
||||||
|
]
|
||||||
|
return self.Etl.mapping_pattern_value(pattern, constnm, "pur")
|
||||||
|
|
||||||
|
# 면허
|
||||||
|
def partcode(self, api_data):
|
||||||
|
|
||||||
|
partcode_element = self.extract_value(api_data, "dmItemMap", "intpLmtCn")
|
||||||
|
if partcode_element is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
partcode = re.findall(r'\((\d{4})\)', partcode_element)
|
||||||
|
except Exception:
|
||||||
|
partcode = []
|
||||||
|
|
||||||
|
#허용업종 추가
|
||||||
|
if api_data.get("dmItemList6") is not None:
|
||||||
|
for item in api_data["dmItemList6"]:
|
||||||
|
item["bidPrmsIntpCd"]
|
||||||
|
if item.get("bidPrmsIntpCd") is not None:
|
||||||
|
partcode.append(item["bidPrmsIntpCd"])
|
||||||
|
|
||||||
|
#중복값 제거
|
||||||
|
partcode = list(set(partcode))
|
||||||
|
|
||||||
|
# 주력분야 체크 후 있으면 주력분야 코드로 면허변경
|
||||||
|
try:
|
||||||
|
# main_partcode = list(set(re.findall(r'\(주력분야: (.*?)\)', partcode_element)))
|
||||||
|
# "(주력분야: 난방공사(제1종))"처럼 이중괄호일 경우 마지막 괄호 수집이 되도록 아래와 같이 수정
|
||||||
|
main_partcode = list(set(re.findall(r'\(주력분야: ([^)]+\([^)]+\)|[^)]+)', partcode_element)))
|
||||||
|
main_partcode = [partcode.replace('또는', '와').split(' 와 ') for partcode in main_partcode]
|
||||||
|
main_partcode = list(itertools.chain(*main_partcode))
|
||||||
|
|
||||||
|
if len(main_partcode) > 0:
|
||||||
|
except_partcode = [self.get_mainPartcode(partcode) for partcode in main_partcode if
|
||||||
|
self.get_mainPartcode(partcode) is not None]
|
||||||
|
# partcode = main_partcode
|
||||||
|
# 주력분야 코드가 있을 경우 해당 면허코드는 없애버림.
|
||||||
|
partcode = [part for part in partcode if part not in except_partcode]
|
||||||
|
partcode = partcode + main_partcode
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
print('주력분야 에러')
|
||||||
|
|
||||||
|
partcode = list(filter(lambda x: x is not None, partcode))
|
||||||
|
|
||||||
|
partcode.sort()
|
||||||
|
return partcode
|
||||||
|
|
||||||
|
# 물품면허
|
||||||
|
def partcode_pur(self, api_data):
|
||||||
|
item_list = self.extract_value(api_data, "dmItemItemList")
|
||||||
|
if item_list is None:
|
||||||
|
# print("partcode_pur 구매대상물품 없음")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
partcode = []
|
||||||
|
for value in item_list:
|
||||||
|
#세부품명번호:물품코드
|
||||||
|
dtlsPrnmNo = self.extract_value(value, "dtlsPrnmNo", default="")
|
||||||
|
partcode.append(dtlsPrnmNo)
|
||||||
|
|
||||||
|
return partcode
|
||||||
|
|
||||||
|
# 공고번호
|
||||||
|
def notinum(self, api_data):
|
||||||
|
if self.tran_bidtype == "기타":
|
||||||
|
notinum = self.extract_value(api_data, "dmItemMap", "bidPbancUntyNo", default="")
|
||||||
|
notinum_ord = self.extract_value(api_data, "dmItemMap", "bidPbancUntyOrd", default="")
|
||||||
|
else:
|
||||||
|
notinum = self.extract_value(api_data, "dmItemMap", "bidPbancNo", default="")
|
||||||
|
notinum_ord = self.extract_value(api_data, "dmItemMap", "bidPbancOrd", default="")
|
||||||
|
|
||||||
|
rs_notinum = notinum + "-" + notinum_ord
|
||||||
|
rs_notinum = rs_notinum.replace(" ", "")
|
||||||
|
return rs_notinum
|
||||||
|
|
||||||
|
# 공사명
|
||||||
|
def constnm(self, api_data):
|
||||||
|
constnm = self.extract_value(api_data, "dmItemMap", "bidPbancNm", default="")
|
||||||
|
constnm = self.Etl.safe_encode(constnm)
|
||||||
|
|
||||||
|
# 한글 깨지는 현상으로 추가 -> 다른 방법이나 다른 데이터도 수정되게 옮기거나 해야함
|
||||||
|
constnm = constnm.replace("럄", "량")
|
||||||
|
|
||||||
|
# 슬러지2처리장 농축기 슬러지공급펌프 수리 용역\ <- 이런 공고명 발생, 쿼리 에러로인해 임시조치
|
||||||
|
# constnm = constnm.rstrip('\\')
|
||||||
|
if constnm.endswith("\\"):
|
||||||
|
constnm = constnm[:-1] + "\\\\" # 마지막 백슬래시를 이스케이프 처리
|
||||||
|
return constnm
|
||||||
|
|
||||||
|
|
||||||
|
def pct(self, pct_org, succls_detail_org, org_dict, item_bidkey, yegatype):
|
||||||
|
try:
|
||||||
|
|
||||||
|
# print("[투찰율 자동 세팅] start")
|
||||||
|
|
||||||
|
pct = None
|
||||||
|
pct_update = {}
|
||||||
|
|
||||||
|
# print("투찰율 > ", pct_org)
|
||||||
|
# print("발주처 코드 > ", org_dict['orgcode_y'])
|
||||||
|
# print("낙찰방법세부기준 > ", succls_detail_org)
|
||||||
|
# print("공고분류 > ", item_bidkey["bidtype"])
|
||||||
|
# print("추정가격 > ", item_bidkey["presum"])
|
||||||
|
|
||||||
|
# 투찰율 있을 경우 진행
|
||||||
|
pct_update['1'] = True if pct_org else False
|
||||||
|
# if not pct_update['1']: print('투찰율 X')
|
||||||
|
|
||||||
|
# 시설공고 일때 진행
|
||||||
|
pct_update['2'] = True if "con" in item_bidkey["bidtype"] else False
|
||||||
|
# if not pct_update['2']: print('시설 X')
|
||||||
|
|
||||||
|
# 농어촌(XS), 산림청(AT) 발주처일 경우 진행
|
||||||
|
#pct_update['2'] = True if org_dict['orgcode_y'] and ('XS' in org_dict['orgcode_y'] or 'AT' in org_dict['orgcode_y']) else False
|
||||||
|
#if not pct_update['2']: print('발주처 조건 X')
|
||||||
|
|
||||||
|
# 낙찰방법세부기준 텍스트 매칭 될경우 or 농어촌(XS), 산림청(AT) 발주처일 경우 -> 진행
|
||||||
|
compare_texts = ['지방자치단체 입찰시 낙찰자 결정기준', '조달청 시설공사 적격심사세부기준', '소액수의견적(2인 이상 견적 제출)']
|
||||||
|
pct_update['3'] = True if ( any(text.replace(" ", "") in succls_detail_org.replace(" ", "") for text in compare_texts) ) or ( org_dict['orgcode_y'] and ('XS' in org_dict['orgcode_y'] or 'AT' in org_dict['orgcode_y']) ) else False
|
||||||
|
# if not pct_update['3']: print('낙찰방법세부기준 매칭 X OR 발주처 조건 X')
|
||||||
|
|
||||||
|
# 낙찰방법세부기준 제외 기준 텍스트 매칭 될경우 제외
|
||||||
|
# 농어촌(XS)은 제외되지 않도록 추가
|
||||||
|
except_texts = ['관리규정외(기타) - 관리규정외 수기심사(총점입력)']
|
||||||
|
pct_update['4'] = False if any(text.replace(" ", "") in succls_detail_org.replace(" ", "") for text in except_texts) and 'XS' not in org_dict['orgcode_y'] else True
|
||||||
|
# if not pct_update['4']: print('낙찰방법세부기준 제외조건, 농어촌 아님 -> 제외')
|
||||||
|
|
||||||
|
# 추정가격 50억 이상이면 제외
|
||||||
|
pct_update['5'] = False if item_bidkey["presum"] and int(item_bidkey["presum"]) > 5000000000 else True
|
||||||
|
# if not pct_update['5']: print('추정가격 50억 이상으로 제외')
|
||||||
|
|
||||||
|
# 비예가, 단일예가인 경우 제외
|
||||||
|
pct_update['6'] = True if yegatype not in ("00", "11") else False
|
||||||
|
# if not pct_update['6']: print('단일예가, 비예가 제외')
|
||||||
|
|
||||||
|
|
||||||
|
# 수집 조건에 맞으면 투찰율 세팅
|
||||||
|
if all(value is not False for value in pct_update.values()):
|
||||||
|
pct = pct_org
|
||||||
|
# print("세팅된 투찰율 : ", pct)
|
||||||
|
else:
|
||||||
|
# print("투찰율 세팅 X")
|
||||||
|
pass
|
||||||
|
|
||||||
|
# print("[투찰율 자동 세팅] end")
|
||||||
|
|
||||||
|
return pct
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"pct 오류 : {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def pct_ser(self, pct_org, yegatype, bidcls):
|
||||||
|
# 입찰방식이 전자입찰이 아니면 제외
|
||||||
|
# 비예가, 단일예가인 경우 제외
|
||||||
|
# 90보다 크면 제외
|
||||||
|
# 50보다 작으면 제외
|
||||||
|
# 점포함 2-6자리 아니면 제외
|
||||||
|
|
||||||
|
pct = None
|
||||||
|
pct_update = {}
|
||||||
|
|
||||||
|
# 1. 투찰율 있을 경우 진행
|
||||||
|
pct_update['1'] = True if pct_org else False
|
||||||
|
|
||||||
|
# 2. 전자입찰 일때 진행
|
||||||
|
pct_update['2'] = True if "01" in bidcls else False
|
||||||
|
|
||||||
|
# 3. 비예가, 단일예가인 경우 제외
|
||||||
|
pct_update['3'] = True if yegatype not in ("00", "11") else False
|
||||||
|
|
||||||
|
# 4. 투찰율 값 판단
|
||||||
|
# 50보다 작거나 90보다 크면 제외
|
||||||
|
# 점포함 2-6자리 아니면 제외
|
||||||
|
pct_update['4'] = True if 50 <= float(pct_org) <= 90 and 2 <= len(str(pct_org)) <= 6 else False
|
||||||
|
|
||||||
|
# 수집 조건에 맞으면 투찰율 세팅
|
||||||
|
if all(value is not False for value in pct_update.values()):
|
||||||
|
pct = pct_org
|
||||||
|
|
||||||
|
return pct
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# 발주처
|
||||||
|
def org(self, api_data, syscollect, data_code_org_i, data_code_org_y, data_order_name):
|
||||||
|
if self.tran_bidtype == "민간":
|
||||||
|
org = self.extract_value(api_data, "dmItemMap", "pbancInstUntyGrpNoNm")
|
||||||
|
elif self.tran_bidtype == "기타":
|
||||||
|
org = self.extract_value(api_data, "dmItemMap", "oderInstUntyGrpNm")
|
||||||
|
else:
|
||||||
|
org = self.extract_value(api_data, "dmItemMap", "pbancInstUntyGrpNm")
|
||||||
|
|
||||||
|
if syscollect["ext_info3"] == "MAS":
|
||||||
|
# print("MAS 공고")
|
||||||
|
return {"org": '조달청', "org_i": None, "orgcode_i": None, "org_y": '조달청', "orgcode_y": None}
|
||||||
|
|
||||||
|
if org is None:
|
||||||
|
# print("발주처 없음")
|
||||||
|
return {"org": None, "org_i": None, "orgcode_i": None, "org_y": None, "orgcode_y": None}
|
||||||
|
|
||||||
|
result = {}
|
||||||
|
result['org'] = org
|
||||||
|
org_i = self.Etl.getOrg("code_org_i", result['org'], data_code_org_i, data_code_org_y, data_order_name)
|
||||||
|
result['org_i'] = org_i['order_name']
|
||||||
|
result['orgcode_i'] = org_i['order_code']
|
||||||
|
org_y = self.Etl.getOrg("code_org_y", result['org'], data_code_org_i, data_code_org_y, data_order_name)
|
||||||
|
result['org_y'] = org_y['order_name']
|
||||||
|
result['orgcode_y'] = org_y['order_code']
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
# 입찰진행상태
|
||||||
|
def bidproc(self, stats):
|
||||||
|
pattern = [
|
||||||
|
{"pattern": {"P1": "변경", }, "value": "M"},
|
||||||
|
{"pattern": {"P1": "취소|견적취소", }, "value": "C"},
|
||||||
|
]
|
||||||
|
bidproc = self.Etl.mapping_pattern_value(pattern, stats, "B")
|
||||||
|
|
||||||
|
return bidproc
|
||||||
|
|
||||||
|
# 계약방법
|
||||||
|
def contract(self, api_data, syscollect):
|
||||||
|
contract = self.extract_value(api_data, "dmItemMap", "stdCtrtMthdNm", default="")
|
||||||
|
if not contract: # 민간
|
||||||
|
contract = self.extract_value(api_data, "dmItemMap", "stdCtrtMthdCdNm", default="")
|
||||||
|
if syscollect["ext_info3"] == "MAS":
|
||||||
|
contract = self.extract_value(api_data, "dmItemMap", "bidMthd", default="")
|
||||||
|
return contract
|
||||||
|
|
||||||
|
# 전자입찰여부
|
||||||
|
def bidcls(self, api_data):
|
||||||
|
bidcls = self.extract_value(api_data, "dmItemMap", "bidMthdNm", default="")
|
||||||
|
if not bidcls: # 민간
|
||||||
|
bidcls = self.extract_value(api_data, "dmItemMap", "bidMthdCdNm", default="")
|
||||||
|
return bidcls
|
||||||
|
|
||||||
|
# 낙찰방식(낙찰자결정방법)
|
||||||
|
def succls(self, api_data):
|
||||||
|
succls = self.extract_value(api_data, "dmItemMap", "scsbdMthdNm", default="")
|
||||||
|
succls = self.Etl.safe_encode(succls)
|
||||||
|
|
||||||
|
if not succls: # 민간
|
||||||
|
succls = self.extract_value(api_data, "dmItemMap", "scsbdMthdCdNm", default="")
|
||||||
|
return succls
|
||||||
|
|
||||||
|
# 계약방법(등급)
|
||||||
|
def conlevel(self, api_data):
|
||||||
|
conlevel_txt = self.extract_value(api_data, "dmItemMap", "lmtGrdNm")
|
||||||
|
if conlevel_txt is None:
|
||||||
|
# print("계약방법(등급) 없음")
|
||||||
|
return None
|
||||||
|
|
||||||
|
match1 = re.search(r'(\d{1,2})등급', conlevel_txt)
|
||||||
|
conlevel = match1.group(1) if match1 else None
|
||||||
|
|
||||||
|
return conlevel
|
||||||
|
|
||||||
|
# 협정마감일
|
||||||
|
def agreedt(self, api_data):
|
||||||
|
if api_data.get("dmItemList1") is not None:
|
||||||
|
for item in api_data["dmItemList1"]:
|
||||||
|
if item.get("subject") is not None and item["subject"] == "공동수급협정서제출" and item.get("endDt") is not None:
|
||||||
|
agreedt = item["endDt"]
|
||||||
|
|
||||||
|
return agreedt.replace("/", "-")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 20250513 YJH 추가 (bid_key 안에서 처리하기 위해 bid_value_transformer 에서 가져옴옴)
|
||||||
|
# 도급비율(공동도급비율)
|
||||||
|
def contper(self, api_data):
|
||||||
|
contper = self.extract_value(api_data, "dmItemMap", "rgnDutyJintSyddRt")
|
||||||
|
return contper
|
||||||
|
|
||||||
|
# 공동도급
|
||||||
|
def convention(self, api_data, agreedt, contper):
|
||||||
|
jintSyddCmnMthoNm = self.extract_value(api_data, "dmItemMap", "jintSyddCmnMthoNm")
|
||||||
|
|
||||||
|
if agreedt is not None:
|
||||||
|
convention = "1" if contper is not None else "2" # 공동도급비율이 있으면 의무 없으면 협정
|
||||||
|
elif jintSyddCmnMthoNm is not None and (jintSyddCmnMthoNm == '공동이행' or jintSyddCmnMthoNm == '공동이행 또는 분담이행'):
|
||||||
|
convention = "2"
|
||||||
|
else:
|
||||||
|
convention = None
|
||||||
|
|
||||||
|
# enum타입으로 0,1,2,3만 존재 None일경우 0으로 처리 [20250529 YJH]
|
||||||
|
if convention is None:
|
||||||
|
convention = "0"
|
||||||
|
|
||||||
|
return convention
|
||||||
|
|
||||||
|
# 지역 처리를 위한 원본데이터 list형태로 반환
|
||||||
|
def location_list(self, api_data):
|
||||||
|
if self.tran_bidtype == "민간":
|
||||||
|
location = self.extract_value(api_data, "result", "bidLmtRgnList")
|
||||||
|
result = []
|
||||||
|
for item in location:
|
||||||
|
result.append(item['lgdngNm'])
|
||||||
|
|
||||||
|
else:
|
||||||
|
location = self.extract_value(api_data, "dmItemMap", "rgnLmtCn")
|
||||||
|
if location is None:
|
||||||
|
# print("지역(참가가능지역) 없음")
|
||||||
|
return ""
|
||||||
|
result = re.findall("\[([^\]]+)\]", location) # 지역값
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
# 지역(참가가능지역)
|
||||||
|
def location(self, location_list, convention, bidtype, api_data):
|
||||||
|
|
||||||
|
odnDutyLgdngNm = self.extract_value(api_data, "dmItemMap", "odnDutyLgdngNm") # 공동도급지역
|
||||||
|
odnDutyLgdngNm_text = odnDutyLgdngNm if odnDutyLgdngNm is not None else ""
|
||||||
|
|
||||||
|
if len(location_list) > 0:
|
||||||
|
location = self.Etl.pow_sum(self.Etl.loction_to_code(location_list))
|
||||||
|
else: # 참가가능지역이 없는경우 => 공동도급??
|
||||||
|
if convention == "1": # 의무인 경우
|
||||||
|
location = re.findall("\[([^\]]+)\]", odnDutyLgdngNm_text) # 지역값
|
||||||
|
location.append("전국")
|
||||||
|
location = self.Etl.pow_sum(self.Etl.loction_to_code(location))
|
||||||
|
elif bidtype == "pur":
|
||||||
|
location = "1" # 물품일 경우 전국으로 체크
|
||||||
|
else: # 의무가 아닌경우
|
||||||
|
location = None
|
||||||
|
|
||||||
|
return location
|
||||||
|
|
||||||
|
# 추정가격
|
||||||
|
def presum(self, api_data):
|
||||||
|
presum = self.extract_value(api_data, "dmItemMap", "prspPrce")
|
||||||
|
if presum is None:
|
||||||
|
# print("추정가격 없음")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return float(presum) if presum != "" else None # 추정가격
|
||||||
|
|
||||||
|
# 기초금액
|
||||||
|
def basic(self, api_data, multi_bid_fg):
|
||||||
|
# print("기초금액")
|
||||||
|
prcmBsneSeCd = self.extract_value(api_data, "result", "bidInfo", "prcmBsneSeCd") # 조달업무구분코드
|
||||||
|
srvcePrdctCmplsYn = self.extract_value(api_data, "result", "bidInfo", "srvcePrdctCmplsYn") # 일반용역물품필수여부
|
||||||
|
|
||||||
|
if prcmBsneSeCd == "01" or (prcmBsneSeCd == "03" and srvcePrdctCmplsYn == "Y"): # 차세대 js기준 조건
|
||||||
|
# 복수
|
||||||
|
basic_list = self.extract_value(api_data, "result", "listBaseEstiPrice", default=[])
|
||||||
|
if len(basic_list) == 0:
|
||||||
|
basic_data = {}
|
||||||
|
else:
|
||||||
|
basic = {}
|
||||||
|
for value in basic_list:
|
||||||
|
# 분류 번호
|
||||||
|
item_no = self.extract_value(value, "bidClsfNo")
|
||||||
|
basic[item_no] = self.extract_value(value, "bsamt") # todo 기초금액 None으로 반환해야 될까 0으로 반환해야될까?
|
||||||
|
basic_data = basic
|
||||||
|
else:
|
||||||
|
# 단수
|
||||||
|
basic = self.extract_value(api_data, "result", "baseEstiPrice", "bsamt", default=None)
|
||||||
|
basic_data = basic
|
||||||
|
|
||||||
|
if multi_bid_fg: # 복수공고
|
||||||
|
if isinstance(basic_data, dict):
|
||||||
|
return basic_data
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
else: # 단수
|
||||||
|
if isinstance(basic_data, dict):
|
||||||
|
if len(basic_data) > 0:
|
||||||
|
first_key = next(iter(basic_data))
|
||||||
|
return basic_data[first_key]
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
return basic_data
|
||||||
|
|
||||||
|
# 물품 면허 추가
|
||||||
|
def purcode(self, bidgoods, bidkey_codes, data_g2b_pur_code_change):
|
||||||
|
purchangecode = self.Etl.G2BpurCodeChange(data_g2b_pur_code_change)
|
||||||
|
|
||||||
|
result = None
|
||||||
|
add_equalcode = []
|
||||||
|
add_likepartcode = []
|
||||||
|
add_partcode = []
|
||||||
|
add_concode = []
|
||||||
|
add_sercode = []
|
||||||
|
add_purcode = []
|
||||||
|
|
||||||
|
for key in bidgoods:
|
||||||
|
for key2 in bidgoods[key]:
|
||||||
|
for key3 in bidgoods[key][key2]:
|
||||||
|
if key3 == 'gcode':
|
||||||
|
part_gcode = bidgoods[key][key2][key3]
|
||||||
|
if part_gcode in purchangecode:
|
||||||
|
add_equalcode.append(purchangecode[part_gcode])
|
||||||
|
part_gcodelike = self.Etl.G2BpurCodeLikeChange(part_gcode, data_g2b_pur_code_change)
|
||||||
|
if part_gcodelike is not None:
|
||||||
|
for like_key in part_gcodelike:
|
||||||
|
# print(like_key)
|
||||||
|
add_likepartcode.append(part_gcodelike[like_key])
|
||||||
|
|
||||||
|
if len(add_equalcode + add_likepartcode) > 0:
|
||||||
|
for row in add_equalcode + add_likepartcode:
|
||||||
|
# print(row)
|
||||||
|
if re.findall('|', row) != -1:
|
||||||
|
tmp_row = row.split("|")
|
||||||
|
for row2 in tmp_row:
|
||||||
|
add_partcode.append(row2)
|
||||||
|
else:
|
||||||
|
add_partcode.append(row)
|
||||||
|
# print("3")
|
||||||
|
for row in add_partcode:
|
||||||
|
if re.findall('C', row) != -1:
|
||||||
|
add_concode.append(row)
|
||||||
|
if re.findall('S', row) != -1:
|
||||||
|
add_sercode.append(row)
|
||||||
|
if re.findall('P', row) != -1:
|
||||||
|
add_purcode.append(row)
|
||||||
|
|
||||||
|
# bidkey_codes의 각 코드 값도 None 또는 빈 문자열일 수 있음을 고려
|
||||||
|
tmp_concode_base = []
|
||||||
|
if bidkey_codes.get('concode'): # None이 아니고 빈 문자열도 아닌 경우
|
||||||
|
tmp_concode_base = [code for code in bidkey_codes['concode'].split("|") if code] # 빈 문자열 제거
|
||||||
|
|
||||||
|
tmp_sercode_base = []
|
||||||
|
if bidkey_codes.get('sercode'):
|
||||||
|
tmp_sercode_base = [code for code in bidkey_codes['sercode'].split("|") if code]
|
||||||
|
|
||||||
|
tmp_purcode_base = []
|
||||||
|
if bidkey_codes.get('purcode'):
|
||||||
|
tmp_purcode_base = [code for code in bidkey_codes['purcode'].split("|") if code]
|
||||||
|
|
||||||
|
sum_concode = list(set(tmp_concode_base + add_concode))
|
||||||
|
sum_sercode = list(set(tmp_sercode_base + add_sercode))
|
||||||
|
sum_purcode = list(set(tmp_purcode_base + add_purcode))
|
||||||
|
|
||||||
|
# lambda 함수 수정: 빈 문자열을 안전하게 처리
|
||||||
|
# x가 존재하고(None이나 빈 문자열이 아니고) 첫 글자가 'C'인지 확인
|
||||||
|
filtered_concode = list(filter(lambda x: x and x.startswith("C"), sum_concode))
|
||||||
|
filtered_sercode = list(filter(lambda x: x and x.startswith("S"), sum_sercode))
|
||||||
|
filtered_purcode = list(filter(lambda x: x and x.startswith("P"), sum_purcode))
|
||||||
|
|
||||||
|
result = {
|
||||||
|
'concode': "|".join(filtered_concode) if filtered_concode else None,
|
||||||
|
'sercode': "|".join(filtered_sercode) if filtered_sercode else None,
|
||||||
|
'purcode': "|".join(filtered_purcode) if filtered_purcode else None
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
# 공고분류번호(혼합)
|
||||||
|
def bidview(self, bidtype, dic_partcode):
|
||||||
|
bidviewDic = {"concode": "con", "sercode": "ser", "purcode": "pur"}
|
||||||
|
|
||||||
|
# 공사, 용역건에 물품건을 보여주진 않는다.
|
||||||
|
if bidtype == 'con' or bidtype == 'ser':
|
||||||
|
bidviewDic = {
|
||||||
|
"concode": "con",
|
||||||
|
"sercode": "ser",
|
||||||
|
}
|
||||||
|
bidview = ",".join(list(set(list(filter(lambda x: x is not None, list(map(lambda x: bidviewDic[x] if x in bidviewDic and dic_partcode[x] is not None else None, dic_partcode)))) + [bidtype])))
|
||||||
|
return bidview
|
||||||
|
|
||||||
|
# 옵션
|
||||||
|
def opt(self, api_data, opt_param):
|
||||||
|
if self.tran_bidtype == "민간":
|
||||||
|
explain = self.extract_value(api_data, "dmItemMap", "pbancDscrTrgtYnLtrs")
|
||||||
|
explain_flag = True if explain is not None and explain == '예' else False
|
||||||
|
else:
|
||||||
|
explain = self.extract_value(api_data, "dmItemMap", "bidPrcpLmtYn")
|
||||||
|
explain_flag = True if explain is not None and explain == 'Y' else False
|
||||||
|
|
||||||
|
opt = []
|
||||||
|
if opt_param["bidproc"] == "M": # 정정
|
||||||
|
opt.append(1)
|
||||||
|
if self.extract_value(api_data, "dmItemMap", "emrgPbancYn", default="N") == "Y":
|
||||||
|
opt.append(2)
|
||||||
|
if explain_flag: # 현장설명 제한여부
|
||||||
|
opt.append(3)
|
||||||
|
if opt_param["convention"] == "1": # 공동도급의무
|
||||||
|
opt.append(8)
|
||||||
|
if opt_param["basic"] is not None and (not isinstance(opt_param["basic"], dict)) and int(opt_param["basic"]) > 0: # 기초금액이 있을때
|
||||||
|
opt.append(9)
|
||||||
|
if len(opt_param["item_bidlocal"]) > 0: # 관내
|
||||||
|
opt.append(11)
|
||||||
|
if len(re.findall("\(단가\)", opt_param["contract"])) > 0: # 단가
|
||||||
|
opt.append(12)
|
||||||
|
if len(re.findall("여성기업", opt_param["contract"])) > 0: # 여성기업
|
||||||
|
opt.append(13)
|
||||||
|
if opt_param["bidproc"] == "C": # 취소
|
||||||
|
opt.append(16)
|
||||||
|
if opt_param["bidproc"] == "R": # 재입찰
|
||||||
|
opt.append(17)
|
||||||
|
return self.Etl.pow_sum(opt)
|
||||||
|
|
||||||
|
# 공고게시일
|
||||||
|
def noticedt(self, api_data):
|
||||||
|
noticedt = self.extract_value(api_data, "dmItemMap", "pbancPstgDt")
|
||||||
|
if noticedt is None:
|
||||||
|
# print("공고게시일 없음")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return noticedt.replace("/", "-")
|
||||||
|
|
||||||
|
# 등록마감일
|
||||||
|
def registdt(self, api_data):
|
||||||
|
registdt = self.extract_value(api_data, "dmItemMap", "bidQlfcRegDt")
|
||||||
|
if registdt is None:
|
||||||
|
# print("등록마감일 없음")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return registdt.replace("/", "-")
|
||||||
|
|
||||||
|
# 현장설명일
|
||||||
|
def explaindt(self, api_data):
|
||||||
|
explaindt = self.extract_value(api_data, "dmItemMap", "pbancBfssDt")
|
||||||
|
if explaindt is None:
|
||||||
|
# print("현장설명일 없음")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return explaindt.replace("/", "-")
|
||||||
|
|
||||||
|
# 투찰시작일
|
||||||
|
def opendt(self, api_data):
|
||||||
|
opendt = self.extract_value(api_data, "dmItemMap", "slprRcptBgngDt")
|
||||||
|
if opendt is None:
|
||||||
|
# print("투찰시작일 없음")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return opendt.replace("/", "-")
|
||||||
|
|
||||||
|
# 투찰마감일
|
||||||
|
def closedt(self, api_data, syscollect):
|
||||||
|
closedt = self.extract_value(api_data, "dmItemMap", "slprRcptDdlnDt")
|
||||||
|
|
||||||
|
if syscollect["ext_info3"] == "MAS":
|
||||||
|
closedt = self.extract_value(api_data, "dmItemMap", "elgtEvlDataSbmsnTermDate")
|
||||||
|
|
||||||
|
if closedt is None:
|
||||||
|
# print("투찰마감일 없음")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return closedt.replace("/", "-")
|
||||||
|
|
||||||
|
# 입찰일/개찰일
|
||||||
|
def constdt(self, api_data, syscollect):
|
||||||
|
if self.tran_bidtype == "기타":
|
||||||
|
constdt = self.extract_value(api_data, "dmItemMap", "onbsPrnmntDtStr")
|
||||||
|
else:
|
||||||
|
constdt = self.extract_value(api_data, "dmItemMap", "onbsPrnmntDt")
|
||||||
|
|
||||||
|
if syscollect["ext_info3"] == "MAS":
|
||||||
|
constdt = self.extract_value(api_data, "dmItemMap", "pbancEndDate")
|
||||||
|
|
||||||
|
if constdt is None:
|
||||||
|
# print("입찰일/개찰일 없음")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return constdt.replace("/", "-")
|
||||||
|
|
||||||
|
# 적격성심사신청서
|
||||||
|
def jkdt(self, api_data):
|
||||||
|
if api_data.get("dmItemList1") is not None:
|
||||||
|
for item in api_data["dmItemList1"]:
|
||||||
|
if item.get("subject") is not None and item["subject"] == "적격성심사신청기한" and item.get("endDt") is not None:
|
||||||
|
jkdt = item["endDt"]
|
||||||
|
|
||||||
|
return jkdt.replace("/", "-")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# PQ심사신청일
|
||||||
|
def pqdt(self, api_data):
|
||||||
|
if api_data.get("dmItemList1") is not None:
|
||||||
|
for item in api_data["dmItemList1"]:
|
||||||
|
if item.get("subject") is not None and item["subject"] == "PQ심사신청기한" and item.get("endDt") is not None:
|
||||||
|
pqdt = item["endDt"]
|
||||||
|
|
||||||
|
return pqdt.replace("/", "-")
|
||||||
|
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# 실수요기관
|
||||||
|
def org_real(self, api_data, syscollect):
|
||||||
|
if self.tran_bidtype == "민간":
|
||||||
|
org_real = self.extract_value(api_data, "dmItemMap", "grpNm")
|
||||||
|
elif self.tran_bidtype == "기타":
|
||||||
|
org_real = self.extract_value(api_data, "dmItemMap", "oderInstUntyGrpNm")
|
||||||
|
else:
|
||||||
|
org_real = self.extract_value(api_data, "dmItemMap", "dmstUntyGrpNm")
|
||||||
|
|
||||||
|
if syscollect["ext_info3"] == "MAS":
|
||||||
|
# print("MAS 공고")
|
||||||
|
org_real = "각 수요기관"
|
||||||
|
|
||||||
|
if org_real is None:
|
||||||
|
# print("실수요기관 없음")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return org_real
|
||||||
|
|
||||||
|
# 내역입찰여부 text
|
||||||
|
def breakdown_bid_check(self, api_data):
|
||||||
|
breakdown_bid_check = self.extract_value(api_data, "dmItemMap", "lisBidYnNm")
|
||||||
|
if breakdown_bid_check is None:
|
||||||
|
# print("내역입찰여부 없음")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return breakdown_bid_check
|
||||||
|
|
||||||
|
# 지역의무공동도급
|
||||||
|
def local_join_check(self, api_data):
|
||||||
|
local_join_check = self.extract_value(api_data, "dmItemMap", "rgnDutyJintSyddYnNm")
|
||||||
|
if local_join_check is None:
|
||||||
|
# print("지역의무공동도급 없음")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return '1' if local_join_check == "해당" else '2'
|
||||||
|
|
||||||
|
# 주공종 면허
|
||||||
|
def first_join_part(self, api_data, data_code_item_match):
|
||||||
|
first_join_part_str = self.extract_value(api_data, "dmItemMap", "mindNm")
|
||||||
|
if first_join_part_str is None:
|
||||||
|
# print("주공종 면허 없음")
|
||||||
|
return None
|
||||||
|
|
||||||
|
first_join_part = self.Etl.G2BNmToYERAMCode(first_join_part_str, data_code_item_match)
|
||||||
|
|
||||||
|
return first_join_part
|
||||||
|
|
||||||
|
# 주공종 추정금액
|
||||||
|
def first_join_part_prevamt(self, api_data):
|
||||||
|
first_join_part_prevamt = self.extract_value(api_data, "dmItemMap", "mindCstrnPrnmntAmt")
|
||||||
|
return first_join_part_prevamt
|
||||||
|
|
||||||
|
# 주공종 추정가격
|
||||||
|
def first_join_part_presum(self, api_data):
|
||||||
|
first_join_part_presum = self.extract_value(api_data, "dmItemMap", "mindPrspPrce")
|
||||||
|
return first_join_part_presum
|
||||||
|
|
||||||
|
# 업종평가비율
|
||||||
|
def assessment_percent(self, api_data):
|
||||||
|
assessment_percent = self.extract_value(api_data, "dmItemMap", "intpEvlRt", default="")
|
||||||
|
return str(assessment_percent)
|
||||||
|
|
||||||
|
# 부공종 면허
|
||||||
|
def sub_first_join_part(self, api_data, data_code_item_match):
|
||||||
|
item_joinpartlist = []
|
||||||
|
sub_first_join_part_list = self.extract_value(api_data, "dmItemList4")
|
||||||
|
if sub_first_join_part_list is None:
|
||||||
|
# print("부공종 데이터 없음")
|
||||||
|
return None
|
||||||
|
|
||||||
|
for item in sub_first_join_part_list:
|
||||||
|
if "subIntpCd" in item:
|
||||||
|
tmp_joinpartlist_arr = {}
|
||||||
|
tmp_joinpartlist_arr["join_part_code"] = self.Etl.G2BNmToYERAMCode(item["subIntpCd"], data_code_item_match)["g2b_code"]
|
||||||
|
tmp_joinpartlist_arr["join_part_name"] = item["subIntpNm"]
|
||||||
|
tmp_joinpartlist_arr["join_part_assessment_percent"] = item["subIntpEvlRt"]
|
||||||
|
tmp_joinpartlist_arr["join_part_presum"] = item["subIntpPrspPrce"]
|
||||||
|
tmp_joinpartlist_arr["join_part_prevamt"] = None
|
||||||
|
tmp_joinpartlist_arr["join_part_category"] = "2"
|
||||||
|
item_joinpartlist.append(tmp_joinpartlist_arr)
|
||||||
|
|
||||||
|
return item_joinpartlist
|
||||||
|
|
||||||
|
# 가산지역
|
||||||
|
def plus_local(self, api_data):
|
||||||
|
plus_local = []
|
||||||
|
plus_local1 = self.extract_value(api_data, "dmItemMap", "bidLmtUntyNm1")
|
||||||
|
if plus_local1 is not None:
|
||||||
|
plus_local.append("가산지역1 : " + plus_local1)
|
||||||
|
|
||||||
|
plus_local2 = self.extract_value(api_data, "dmItemMap", "bidLmtUntyNm2")
|
||||||
|
if plus_local2 is not None:
|
||||||
|
plus_local.append("가산지역2 : " + plus_local2)
|
||||||
|
|
||||||
|
plus_local3 = self.extract_value(api_data, "dmItemMap", "bidLmtUntyNm3")
|
||||||
|
if plus_local3 is not None:
|
||||||
|
plus_local.append("가산지역3 : " + plus_local3)
|
||||||
|
|
||||||
|
plus_local4 = self.extract_value(api_data, "dmItemMap", "bidLmtUntyNm4")
|
||||||
|
if plus_local4 is not None:
|
||||||
|
plus_local.append("가산지역4 : " + plus_local4)
|
||||||
|
|
||||||
|
if plus_local:
|
||||||
|
return " ".join(plus_local)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
################################
|
||||||
|
|
||||||
|
def get_mainPartcode(self, text):
|
||||||
|
# 주력분야 텍스트 : 매칭번호
|
||||||
|
match_dict = {
|
||||||
|
'토공사 와 포장공사': '4898',
|
||||||
|
'토공사 와 보링·그라우팅·파일공사': '4898',
|
||||||
|
'포장공사 와 토공사': '4898',
|
||||||
|
'포장공사 와 보링·그라우팅·파일공사': '4898',
|
||||||
|
'보링·그라우팅·파일공사 와 포장공사': '4898',
|
||||||
|
'보링·그라우팅·파일공사 와 토공사': '4898',
|
||||||
|
'토공사': '4989',
|
||||||
|
'포장공사': '4989',
|
||||||
|
'보링·그라우팅·파일공사': '4989',
|
||||||
|
'실내건축공사': '4990',
|
||||||
|
'금속구조물·창호·온실공사': '4991',
|
||||||
|
'지붕판금·건축물조립공사': '4991',
|
||||||
|
'도장공사': '4992',
|
||||||
|
'습식·방수공사': '4992',
|
||||||
|
'석공사': '4992',
|
||||||
|
'조경식재공사': '4993',
|
||||||
|
'조경시설물설치공사': '4993',
|
||||||
|
'철근·콘크리트공사': '4994',
|
||||||
|
'구조물해체·비계공사': '4995',
|
||||||
|
'상하수도설비공사': '4996',
|
||||||
|
'철도·궤도공사': '4997',
|
||||||
|
'철강구조물공사': '4998', #
|
||||||
|
'수중공사': '4999',
|
||||||
|
'준설공사': '4999',
|
||||||
|
'승강기설치공사': '6201',
|
||||||
|
'삭도설치공사': '6201',
|
||||||
|
'기계설비공사': '6202',
|
||||||
|
'가스시설공사(제1종)': '6202',
|
||||||
|
'가스시설공사(제2종)': '6203',
|
||||||
|
'가스시설공사(제3종)': '6203',
|
||||||
|
'난방공사(제1종)': '6203',
|
||||||
|
'난방공사(제2종)': '6203',
|
||||||
|
'난방공사(제3종)': '6203',
|
||||||
|
}
|
||||||
|
|
||||||
|
return match_dict.get(text)
|
||||||
|
|
||||||
|
################################
|
||||||
|
|
||||||
|
# bbbbbb
|
||||||
|
def aaaaaaaa(self, api_data):
|
||||||
|
aaaaaaaa = self.extract_value(api_data, "dmItemMap", "cCccCccCnm")
|
||||||
|
if aaaaaaaa is None:
|
||||||
|
print("bbbbbb 없음")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return aaaaaaaa
|
@ -0,0 +1,317 @@
|
|||||||
|
import re
|
||||||
|
from plugins.utils.transformers.transformer_helper import TransformerHelper
|
||||||
|
import logging
|
||||||
|
class BidValueTransformer(TransformerHelper):
|
||||||
|
|
||||||
|
# 도급비율(공동도급비율)
|
||||||
|
def contper(self, api_data):
|
||||||
|
contper = self.extract_value(api_data, "dmItemMap", "rgnDutyJintSyddRt")
|
||||||
|
return contper
|
||||||
|
|
||||||
|
# 난이도계수
|
||||||
|
def lvcnt(self, api_data):
|
||||||
|
lvcnt = self.extract_value(api_data, "dmItemMap", "lvdfCfct")
|
||||||
|
if lvcnt is None:
|
||||||
|
# print("난이도계수 없음")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return lvcnt
|
||||||
|
|
||||||
|
|
||||||
|
# 예가범위
|
||||||
|
def yegarng(self, api_data, multi_bid_fg):
|
||||||
|
# print("예가범위")
|
||||||
|
# 차세대 베타기간이슈로 시간이없어서 basic -> yegarng로 붙여넣기 한 코드라 변수명이 basic으로 되어있으니 헷갈리지 않도록 주의하세요
|
||||||
|
prcmBsneSeCd = self.extract_value(api_data, "result", "bidInfo", "prcmBsneSeCd") # 조달업무구분코드
|
||||||
|
srvcePrdctCmplsYn = self.extract_value(api_data, "result", "bidInfo", "srvcePrdctCmplsYn") # 일반용역물품필수여부
|
||||||
|
|
||||||
|
if prcmBsneSeCd == "01" or (prcmBsneSeCd == "03" and srvcePrdctCmplsYn == "Y"): # 차세대 js기준 조건
|
||||||
|
# 복수
|
||||||
|
basic_list = self.extract_value(api_data, "result", "listBaseEstiPrice", default=[])
|
||||||
|
logging.info(f"basic_list: {basic_list}")
|
||||||
|
if len(basic_list) == 0:
|
||||||
|
basic_data = {}
|
||||||
|
else:
|
||||||
|
basic = {}
|
||||||
|
for value in basic_list:
|
||||||
|
prceCrtRangRt = self.extract_value(value, "prceCrtRangRt") #예가범위
|
||||||
|
rsvePrceRangSeCd = self.extract_value(value, "rsvePrceRangSeCd") #예가범위 분류 기준
|
||||||
|
|
||||||
|
success_flag = True
|
||||||
|
if prceCrtRangRt is None:
|
||||||
|
# print("예가범위 없음")
|
||||||
|
success_flag = False
|
||||||
|
|
||||||
|
if rsvePrceRangSeCd is None:
|
||||||
|
# print("예가범위 분류 기준이 없음")
|
||||||
|
success_flag = False
|
||||||
|
|
||||||
|
result_yegarng = None
|
||||||
|
if success_flag is not None:
|
||||||
|
if rsvePrceRangSeCd == "예070003":
|
||||||
|
# sign = "기초금액기준 ± " + prceCrtRangRt + " \r % 범위 내에서 작성됩니다.";
|
||||||
|
result_yegarng = "-" + str(prceCrtRangRt) + "|+" + str(prceCrtRangRt)
|
||||||
|
|
||||||
|
if rsvePrceRangSeCd == "예070001":
|
||||||
|
# sign = "기초금액기준 + " + prceCrtRangRt + " \r % 범위 내에서 작성됩니다.";
|
||||||
|
result_yegarng = "0|+" + str(prceCrtRangRt)
|
||||||
|
|
||||||
|
if rsvePrceRangSeCd == "예070002":
|
||||||
|
# sign = "기초금액기준 - " + prceCrtRangRt + " \r % 범위 내에서 작성됩니다.";
|
||||||
|
result_yegarng = "-" + str(prceCrtRangRt) + "|0"
|
||||||
|
else:
|
||||||
|
result_yegarng = None
|
||||||
|
|
||||||
|
# 분류 번호
|
||||||
|
item_no = self.extract_value(value, "bidClsfNo")
|
||||||
|
basic[item_no] = result_yegarng # todo 기초금액 None으로 반환해야 될까 0으로 반환해야될까?
|
||||||
|
basic_data = basic
|
||||||
|
logging.info(f"basic_data: {basic_data}")
|
||||||
|
else:
|
||||||
|
# 단수
|
||||||
|
prceCrtRangRt = self.extract_value(api_data, "result", "baseEstiPrice", "prceCrtRangRt")
|
||||||
|
rsvePrceRangSeCd = self.extract_value(api_data, "result", "baseEstiPrice", "rsvePrceRangSeCd")
|
||||||
|
logging.info(f"prceCrtRangRt: {prceCrtRangRt}, rsvePrceRangSeCd: {rsvePrceRangSeCd}")
|
||||||
|
|
||||||
|
if prceCrtRangRt is None or rsvePrceRangSeCd is None:
|
||||||
|
# print("예가범위 or 분류기준 없음")
|
||||||
|
basic_data = None
|
||||||
|
else:
|
||||||
|
if rsvePrceRangSeCd == "예070003":
|
||||||
|
# sign = "기초금액기준 ± " + prceCrtRangRt + " \r % 범위 내에서 작성됩니다.";
|
||||||
|
basic_data = "-" + str(prceCrtRangRt) + "|+" + str(prceCrtRangRt)
|
||||||
|
|
||||||
|
if rsvePrceRangSeCd == "예070001":
|
||||||
|
# sign = "기초금액기준 + " + prceCrtRangRt + " \r % 범위 내에서 작성됩니다.";
|
||||||
|
basic_data = "0|+" + str(prceCrtRangRt)
|
||||||
|
|
||||||
|
if rsvePrceRangSeCd == "예070002":
|
||||||
|
# sign = "기초금액기준 - " + prceCrtRangRt + " \r % 범위 내에서 작성됩니다.";
|
||||||
|
basic_data = "-" + str(prceCrtRangRt) + "|0"
|
||||||
|
|
||||||
|
logging.info(f"basic_data: {basic_data}")
|
||||||
|
|
||||||
|
if multi_bid_fg: # 복수공고
|
||||||
|
if isinstance(basic_data, dict):
|
||||||
|
return basic_data
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
else: # 단수
|
||||||
|
if isinstance(basic_data, dict):
|
||||||
|
if len(basic_data) > 0:
|
||||||
|
first_key = next(iter(basic_data))
|
||||||
|
return basic_data[first_key]
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
return basic_data
|
||||||
|
|
||||||
|
# 평가기준
|
||||||
|
def scrcls(self, api_data, bidtype):
|
||||||
|
if bidtype == '공사':
|
||||||
|
scrcls = self.extract_value(api_data, "dmItemMap", "scsbdMthdDtlsNm", default="")
|
||||||
|
|
||||||
|
pattern = [
|
||||||
|
{"pattern": {"P1": "조달청", }, "value": "g2b"},
|
||||||
|
{"pattern": {"P1": "지방자치단체", }, "value": "mop"},
|
||||||
|
{"pattern": {"P1": "시설공사적격심사세부기준", "N1": "조달청시설공사적격심사세부기준"}, "value": "juk"},
|
||||||
|
{"pattern": {"P1": "관리규정외\(기타\)-관리규정외수기심사\(총점입력\)"}, "value": "juk"},
|
||||||
|
]
|
||||||
|
trim_scrcls = scrcls.replace(" ", "")
|
||||||
|
result = self.Etl.mapping_pattern_value(pattern, trim_scrcls, "")
|
||||||
|
else:
|
||||||
|
result = ""
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
# 참조번호
|
||||||
|
def refno(self, api_data):
|
||||||
|
refno = self.extract_value(api_data, "dmItemMap", "usrDocNoVal", default="")
|
||||||
|
refno = self.Etl.safe_encode(refno)
|
||||||
|
return refno
|
||||||
|
|
||||||
|
# 예가타입(예가방법)
|
||||||
|
def yegatype(self, api_data):
|
||||||
|
yegatype = self.extract_value(api_data, "dmItemMap", "pnprDcsnMthoNm", default="")
|
||||||
|
|
||||||
|
pattern = [
|
||||||
|
{"pattern": {"P1": "비예가", }, "value": "00"},
|
||||||
|
{"pattern": {"P1": "단일예가", }, "value": "11"},
|
||||||
|
{"pattern": {"P1": "3.+\/.+7", }, "value": "17"},
|
||||||
|
{"pattern": {"P1": "3.+\/.+10", }, "value": "20"},
|
||||||
|
{"pattern": {"P1": "4.+\/.+10", }, "value": "23"},
|
||||||
|
{"pattern": {"P1": "4.+\/.+15", }, "value": "25"},
|
||||||
|
{"pattern": {"P1": "3.+\/.+15", }, "value": "27"},
|
||||||
|
{"pattern": {"P1": "2.+\/.+5", }, "value": "28"},
|
||||||
|
{"pattern": {"P1": "3.+\/.+5", }, "value": "29"},
|
||||||
|
{"pattern": {"P1": "7.+\/.+15", }, "value": "30"},
|
||||||
|
{"pattern": {"P1": "5.+\/.+15", }, "value": "31"},
|
||||||
|
{"pattern": {"P1": "2.+\/.+15", }, "value": "32"},
|
||||||
|
{"pattern": {"P1": "1.+\/.+3", }, "value": "33"},
|
||||||
|
]
|
||||||
|
|
||||||
|
return self.Etl.mapping_pattern_value(pattern, yegatype, None)
|
||||||
|
|
||||||
|
# 공사예정(추정금액)
|
||||||
|
def prevamt(self, api_data):
|
||||||
|
prevamt = self.extract_value(api_data, "dmItemMap", "bgtAmt", default="")
|
||||||
|
prevamt_sub = self.extract_value(api_data, "dmItemMap", "alotBgtAmt", default="")
|
||||||
|
|
||||||
|
if prevamt == '' and prevamt_sub != '':
|
||||||
|
prevamt = prevamt_sub
|
||||||
|
|
||||||
|
return int(float(prevamt)) if prevamt !="" else None
|
||||||
|
|
||||||
|
# 공동도급지역
|
||||||
|
def contloc(self, api_data):
|
||||||
|
contloc = self.extract_value(api_data, "dmItemMap", "odnDutyLgdngNm", default="")
|
||||||
|
return self.Etl.blank_None("|".join(re.findall("\[([^\]]+)\]", contloc))) # 지역값
|
||||||
|
|
||||||
|
# 공고담당자(이름|연락처|이메일)
|
||||||
|
def charger(self, api_data, syscollect):
|
||||||
|
# print("공고담당자")
|
||||||
|
# 빈값에 따른 우선순위 처리
|
||||||
|
# 1. 공고기관 담당자 연락처
|
||||||
|
# 2. 수요기관 담당자 연락처
|
||||||
|
# 3. 공고기관담당자 이름
|
||||||
|
|
||||||
|
# 공고기관 담당자 연락처
|
||||||
|
phone1 = self.extract_value(api_data, "dmItemMap", "pbancPicTlphNo", default="")
|
||||||
|
staff_name1 = self.extract_value(api_data, "dmItemMap", "pbancPicNm", default="")
|
||||||
|
|
||||||
|
if syscollect["ext_info3"] == "MAS":
|
||||||
|
phone1 = self.extract_value(api_data, "dmItemMap", "tlphNo", default="")
|
||||||
|
|
||||||
|
staff = []
|
||||||
|
if phone1 != "":
|
||||||
|
if staff_name1 != "":
|
||||||
|
staff.append(staff_name1) # 공고기관 담당자 이름
|
||||||
|
phone = phone1
|
||||||
|
if phone != "":
|
||||||
|
phone = self.format_phone(phone)
|
||||||
|
staff.append(phone) # 연락처
|
||||||
|
else:
|
||||||
|
staff.append("") # 연락처
|
||||||
|
staff.append("") # 이메일
|
||||||
|
else:
|
||||||
|
# 수요기관 담당자 연락처
|
||||||
|
staff_name2 = ""
|
||||||
|
phone2 = ""
|
||||||
|
# dmItemList3이 None이 아니고, 리스트가 비어있지 않다면 첫 번째 요소를 처리
|
||||||
|
if api_data.get("dmItemList3") is not None:
|
||||||
|
if len(api_data["dmItemList3"]) > 0: # 리스트가 비어있지 않은지 확인
|
||||||
|
first_item = api_data["dmItemList3"][0]
|
||||||
|
# picNm과 tlphNo 값을 추출하고, 없을 경우 빈 문자열을 유지
|
||||||
|
staff_name2 = first_item.get("picNm", "")
|
||||||
|
phone2 = first_item.get("tlphNo", "")
|
||||||
|
|
||||||
|
if phone2 != "":
|
||||||
|
if staff_name2 != "":
|
||||||
|
staff.append(staff_name2) # 수요기관 담당자 이름
|
||||||
|
phone = phone2
|
||||||
|
if phone != "":
|
||||||
|
phone = self.format_phone(phone)
|
||||||
|
staff.append(phone) # 연락처
|
||||||
|
else:
|
||||||
|
staff.append("") # 연락처
|
||||||
|
staff.append("") # 이메일
|
||||||
|
elif staff_name1 != "":
|
||||||
|
staff.append(staff_name1) #공고기관 담당자 이름
|
||||||
|
staff.append("") # 연락처
|
||||||
|
staff.append("") # 이메일
|
||||||
|
|
||||||
|
if staff:
|
||||||
|
return "|".join(staff)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def format_phone(self, t="", e="-"):
|
||||||
|
try:
|
||||||
|
t = re.sub(r"\s+", "", t)
|
||||||
|
prefixes = ["0303", "0304", "0305", "0501", "0502", "0503", "0504", "0505", "0506", "0507", "0508", "0509"]
|
||||||
|
prefix = t[:4]
|
||||||
|
if prefix in prefixes:
|
||||||
|
return re.sub(r"^(01[0136789]|00|02|0[3-9][0-9][0-9])-?([*0-9]{3,4})-?([0-9]{4})$", "\\1{}\\2{}\\3".format(e, e), t)
|
||||||
|
elif len(t) == 7 or len(t) == 8:
|
||||||
|
return re.sub(r"^([*0-9]{3,4})-?([0-9]{4})$", "\\1{}\\2".format(e), t)
|
||||||
|
elif len(t) == 10:
|
||||||
|
if t[:2] == "02":
|
||||||
|
return re.sub(r"([0-9]{2})([0-9]{4})([0-9]+)", "\\1{}\\2{}\\3".format(e, e), t)
|
||||||
|
else:
|
||||||
|
return re.sub(r"([0-9]{3})([0-9]{3})([0-9]+)", "\\1{}\\2{}\\3".format(e, e), t)
|
||||||
|
elif len(t) == 11:
|
||||||
|
return re.sub(r"^(\d{3})-?(\d{4})-?(\d{4})$", "\\1{}\\2{}\\3".format(e, e), t)
|
||||||
|
elif len(t) < 11:
|
||||||
|
return re.sub(r"^(01[0136789]|00|02|070|0[3-9][0-9][0-9])-?([*0-9]{3,4})-?([0-9]{4})$", "\\1{}\\2{}\\3".format(e, e), t)
|
||||||
|
else:
|
||||||
|
return t
|
||||||
|
except Exception as ex:
|
||||||
|
logging.error("Error: {}".format(ex))
|
||||||
|
|
||||||
|
# 도급자설치관급자재금액
|
||||||
|
def contract_money(self, api_data):
|
||||||
|
contract_money = self.extract_value(api_data, "dmItemMap", "etcAmt")
|
||||||
|
return contract_money
|
||||||
|
|
||||||
|
|
||||||
|
# 관급자설치관급자재금액
|
||||||
|
def government_money(self, api_data):
|
||||||
|
government_money = self.extract_value(api_data, "dmItemMap", "gvspAmt")
|
||||||
|
return government_money
|
||||||
|
|
||||||
|
# 공사현장
|
||||||
|
def work_field(self, api_data):
|
||||||
|
work_field = self.extract_value(api_data, "dmItemMap", "bidLmtUntyNm")
|
||||||
|
return work_field
|
||||||
|
|
||||||
|
# 실적심사신청서 신청기한
|
||||||
|
def result_check_dt(self, api_data):
|
||||||
|
result_check_dt = self.extract_value(api_data, "dmItemMap", "prqdcRcptDdlnDt")
|
||||||
|
return result_check_dt
|
||||||
|
|
||||||
|
# 현장설명장소 또는 과업설명장소
|
||||||
|
def explain_local(self, api_data):
|
||||||
|
explain_local = self.extract_value(api_data, "dmItemMap", "pbancBfssPlacNm")
|
||||||
|
explain_local = self.Etl.safe_encode(explain_local)
|
||||||
|
return explain_local
|
||||||
|
|
||||||
|
# 공종별 전체 지분율
|
||||||
|
def join_part_total_percent(self, api_data, data_code_item_match):
|
||||||
|
|
||||||
|
item_list = []
|
||||||
|
result = None
|
||||||
|
|
||||||
|
if self.tran_bidtype != '공사':
|
||||||
|
return result
|
||||||
|
|
||||||
|
for item in api_data["dmItemList2"]:
|
||||||
|
|
||||||
|
join_part_name = item["subIntpNm"]
|
||||||
|
i2_code = self.Etl.G2BNmToYERAMCode(item["subIntpCd"], data_code_item_match)["g2b_code"]
|
||||||
|
join_part_rate = str(item["subIntpEvlRt"])
|
||||||
|
|
||||||
|
item_list.append(join_part_name + "/" + i2_code + "/" + join_part_rate)
|
||||||
|
result = "|".join(item_list) if len(item_list) > 0 else None
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
# 용역구분
|
||||||
|
def ser_check(self, api_data):
|
||||||
|
ser_check_nm = self.extract_value(api_data, "dmItemMap", "prcmBsneSeNm")
|
||||||
|
if ser_check_nm == "일반용역":
|
||||||
|
ser_check = "1"
|
||||||
|
elif ser_check_nm == "기술용역":
|
||||||
|
ser_check = "2"
|
||||||
|
else:
|
||||||
|
ser_check = None
|
||||||
|
|
||||||
|
return ser_check
|
||||||
|
|
||||||
|
# 상호시장진출 허용여부
|
||||||
|
def mutual_mayor(self, api_data):
|
||||||
|
#상호시장 기존 입력 값이 Y, N인데 아예 파라미터값이 Y,N으로 리턴되어 별도의 처리없음(default:None)
|
||||||
|
mutual_mayor = self.extract_value(api_data, "dmItemMap", "cbsarConmAdncPsbltyYn")
|
||||||
|
return mutual_mayor
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,98 @@
|
|||||||
|
from plugins.utils.transformers.transformer_helper import TransformerHelper
|
||||||
|
|
||||||
|
class EtcTransformer(TransformerHelper):
|
||||||
|
|
||||||
|
# 관내정보 수집
|
||||||
|
def bid_local(self, location_list, data_code_local):
|
||||||
|
item_bidlocal = []
|
||||||
|
for item_location in location_list:
|
||||||
|
location_arr = item_location.split(" ")
|
||||||
|
if len(location_arr) > 1 and location_arr[1] != '': # 관내건 확인
|
||||||
|
tmp = location_arr[0] + " " + location_arr[1]
|
||||||
|
item_bidlocal.append(self.Etl.getCodeLocal(tmp, data_code_local))
|
||||||
|
elif len(location_arr) == 1: # 관내건 확인
|
||||||
|
tmp = location_arr[0]
|
||||||
|
# item_bidlocal.append(self.Etl.getCodeLocal(tmp))
|
||||||
|
|
||||||
|
# bid_local중복제거
|
||||||
|
item_bidlocal_tmp = self.Etl.listTOdict(item_bidlocal)
|
||||||
|
result_bid_local = dict()
|
||||||
|
if len(item_bidlocal_tmp) > 0:
|
||||||
|
duple_bidlocal = []
|
||||||
|
for key, val in item_bidlocal_tmp.items():
|
||||||
|
if val not in duple_bidlocal:
|
||||||
|
duple_bidlocal.append(val)
|
||||||
|
result_bid_local[key] = val
|
||||||
|
|
||||||
|
return result_bid_local
|
||||||
|
|
||||||
|
# A값
|
||||||
|
def premium_list(self, api_data):
|
||||||
|
cost_total = self.extract_value(api_data, "result", "labrrPrtcAndCnstrcSafeAmtInfo", "aamtinfosumamt2")
|
||||||
|
a_cost_flag = self.extract_value(api_data, "result", "isConstBidOfNewQlfdJudgeYn")
|
||||||
|
|
||||||
|
if cost_total is not None and a_cost_flag is not None and a_cost_flag == 'Y':
|
||||||
|
# 최초 반환 타입은 int형이지만, 0은 0으로 null은 ""로 처리해야 해서 아래와 같이 처리
|
||||||
|
premium_list = {
|
||||||
|
"cost1": self.extract_value(api_data, "result", "labrrPrtcAndCnstrcSafeAmtInfo", "mrfnAnntPrem", default=""), # 국민연금보험료
|
||||||
|
"cost2": self.extract_value(api_data, "result", "labrrPrtcAndCnstrcSafeAmtInfo", "mrfnHlthPrem", default=""), # 국민건강보험료
|
||||||
|
"cost3": self.extract_value(api_data, "result", "labrrPrtcAndCnstrcSafeAmtInfo", "rtrfundCt", default=""), # 퇴직공제부금비
|
||||||
|
"cost4": self.extract_value(api_data, "result", "labrrPrtcAndCnstrcSafeAmtInfo", "odpsLotmRcprPrem", default=""), # 노인장기요양보험
|
||||||
|
"cost5": self.extract_value(api_data, "result", "labrrPrtcAndCnstrcSafeAmtInfo", "sftyMgcs", default=""), # 산업안전보건관리비
|
||||||
|
"cost6": self.extract_value(api_data, "result", "labrrPrtcAndCnstrcSafeAmtInfo", "sftyChckMgcs", default=""), # 안전관리비
|
||||||
|
"cost8": self.extract_value(api_data, "result", "labrrPrtcAndCnstrcSafeAmtInfo", "qltyMgcs", default=""), # 품질관리비
|
||||||
|
"cost_total": self.extract_value(api_data, "result", "labrrPrtcAndCnstrcSafeAmtInfo", "aamtinfosumamt2", default=""), # 품질관리비
|
||||||
|
}
|
||||||
|
premium_list["cost7"] = "" # 근로자재해보장보험료 - 항목 없음
|
||||||
|
premium_list["direct_labor_cost"] = self.extract_value(api_data, "dmItemMap", "qltyMgcs", default="") # 직접노무비 - 항목 없음
|
||||||
|
premium_list = {key: str(value) for key, value in premium_list.items()}
|
||||||
|
else:
|
||||||
|
premium_list = {}
|
||||||
|
|
||||||
|
# 반환된 금액이 int형이라 강제로 str로 전체 변경
|
||||||
|
return premium_list
|
||||||
|
|
||||||
|
# 순공사원가
|
||||||
|
def bid_const_cost_list(self, api_data):
|
||||||
|
bid_const_cost_list = self.extract_value(api_data, "result", "baseEstiPrice", "bsamtPcsp")
|
||||||
|
if bid_const_cost_list is None:
|
||||||
|
# print("순공사원가 없음")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
return {"const_cost":bid_const_cost_list}
|
||||||
|
|
||||||
|
# 건설산업기본법 적용대상여부
|
||||||
|
def mutual_text(self, api_data):
|
||||||
|
mutual_text = self.extract_value(api_data, "dmItemMap", "cbsarSeNm", default="")
|
||||||
|
if mutual_text is None and mutual_text == "" and mutual_text == " - ":
|
||||||
|
return ""
|
||||||
|
|
||||||
|
result = mutual_text.split("-")[0].strip()
|
||||||
|
return result
|
||||||
|
|
||||||
|
# 낙찰방법세부기준 /// 민간은 세부기준 정보 없음 /// 기타는 세부기준 정보 없음
|
||||||
|
def succls_detail_org(self, api_data):
|
||||||
|
succls_detail = self.extract_value(api_data, "dmItemMap", "scsbdMthdDtlsNm", default="")
|
||||||
|
succls_detail = self.Etl.safe_encode(succls_detail)
|
||||||
|
return succls_detail
|
||||||
|
|
||||||
|
# 투찰율 /// 민간은 투찰율 정보 없음 /// 기타는 모두 0으로 나오는것으로 확인, 확인시 공고들 기준
|
||||||
|
def pct_org(self, api_data):
|
||||||
|
pct = self.extract_value(api_data, "dmItemMap", "scsbdLwlmRat", default="")
|
||||||
|
return pct
|
||||||
|
|
||||||
|
def joint_method_org(self, api_data):
|
||||||
|
|
||||||
|
# 시설 기술용역 -> jintCtrtCmnMthoNm 제공
|
||||||
|
# 일반용역, 물품 -> jintSyddCmnMthoNm 미제공
|
||||||
|
# jintSyddCmnMthoNm 세팅 후 데이터 없으면 jintCtrtCmnMthoNm 세팅하여 처리
|
||||||
|
|
||||||
|
# 시설, 기술용역
|
||||||
|
joint_method = self.extract_value(api_data, "dmItemMap", "jintSyddCmnMthoNm", default="")
|
||||||
|
|
||||||
|
# 일반용역, 물품
|
||||||
|
if not joint_method:
|
||||||
|
joint_method = self.extract_value(api_data, "dmItemMap", "jintCtrtCmnMthoNm", default="")
|
||||||
|
|
||||||
|
return joint_method
|
||||||
|
|
@ -0,0 +1,103 @@
|
|||||||
|
import plugins.utils.scraplib as scraplib # 공통 라이브러리
|
||||||
|
import html
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import traceback
|
||||||
|
import logging
|
||||||
|
|
||||||
|
def handle_exceptions(func):
|
||||||
|
"""
|
||||||
|
모든 메서드에서 공통적으로 예외 처리를 수행하는 데코레이터
|
||||||
|
"""
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
try:
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
except Exception as e:
|
||||||
|
logging.error("############## Wrapper Error Start ##############")
|
||||||
|
logging.error("############## Wrapper Error Start #!@#!@#!@#!@##")
|
||||||
|
logging.error("############## Wrapper Error Start ##############")
|
||||||
|
# 예외 발생 시 클래스 이름, 함수 이름과 예외 메시지 출력
|
||||||
|
class_name = args[0].__class__.__name__ if args else "알 수 없는 클래스"
|
||||||
|
notinum = getattr(args[0], 'tran_notinum', '알 수 없는 값')
|
||||||
|
|
||||||
|
logging.error("Wrapper 에러 =>공고번호:{} / 클래스:{} / 메소드:{} / 오류내용:{}".format(notinum, class_name, func.__name__, str(e)))
|
||||||
|
traceback_info = traceback.format_exc()
|
||||||
|
logging.error("\n")
|
||||||
|
logging.error(traceback_info)
|
||||||
|
logging.error("############## Wrapper Error End ##############")
|
||||||
|
logging.error("############## Wrapper Error End ##############")
|
||||||
|
logging.error("############## Wrapper Error End ##############")
|
||||||
|
|
||||||
|
# 에러 발생 시 None 반환
|
||||||
|
return None
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
class ExceptionMeta(type):
|
||||||
|
"""
|
||||||
|
모든 메서드에 handle_exceptions 데코레이터를 자동으로 적용하는 메타클래스
|
||||||
|
"""
|
||||||
|
def __new__(cls, name, bases, attrs):
|
||||||
|
for key, value in attrs.items():
|
||||||
|
if callable(value): # 메서드인 경우만
|
||||||
|
attrs[key] = handle_exceptions(value)
|
||||||
|
return super(ExceptionMeta, cls).__new__(cls, name, bases, attrs)
|
||||||
|
|
||||||
|
class TransformerHelper(metaclass=ExceptionMeta):
|
||||||
|
Util = scraplib.Util()
|
||||||
|
Etl = scraplib.Etl()
|
||||||
|
|
||||||
|
tran_bidtype = ""
|
||||||
|
tran_notinum = ""
|
||||||
|
|
||||||
|
def extract_value(self, data, key1, key2=None, key3=None, default=None):
|
||||||
|
"""
|
||||||
|
딕셔너리에서 1차 키 또는 2차 키의 값을 반환하는 함수.
|
||||||
|
key1: 1차 키
|
||||||
|
key2: 2차 키 (선택사항)
|
||||||
|
존재하지 않으면 None을 반환
|
||||||
|
"""
|
||||||
|
# 1차 키 확인
|
||||||
|
if key1 not in data or data[key1] is None:
|
||||||
|
# logging.error(key1+" 키가 존재하지 않음")
|
||||||
|
return default
|
||||||
|
|
||||||
|
# 3차 키가 전달된 경우 확인하고 값 반환
|
||||||
|
if key3 is not None:
|
||||||
|
if (key2 not in data[key1] or data[key1][key2] is None) or (key3 not in data[key1][key2] or data[key1][key2][key3] is None):
|
||||||
|
# logging.error(key1 + "." + key2 + "." + key3 + " 키가 존재하지 않음")
|
||||||
|
return default
|
||||||
|
|
||||||
|
if isinstance(data[key1][key2][key3], str):
|
||||||
|
return html.unescape(data[key1][key2][key3])
|
||||||
|
else:
|
||||||
|
return data[key1][key2][key3]
|
||||||
|
|
||||||
|
# 2차 키가 전달된 경우 확인하고 값 반환
|
||||||
|
if key2 is not None:
|
||||||
|
if key2 not in data[key1] or data[key1][key2] is None:
|
||||||
|
# logging.error(key1+"."+key2+" 키가 존재하지 않음")
|
||||||
|
return default
|
||||||
|
|
||||||
|
if isinstance(data[key1][key2], str):
|
||||||
|
return html.unescape(data[key1][key2])
|
||||||
|
else:
|
||||||
|
return data[key1][key2]
|
||||||
|
|
||||||
|
|
||||||
|
# 2차, 3차 키가 없으면 1차 키 값 반환
|
||||||
|
if isinstance(data[key1], str):
|
||||||
|
return html.unescape(data[key1])
|
||||||
|
else:
|
||||||
|
return data[key1]
|
||||||
|
|
||||||
|
"""
|
||||||
|
사용예시
|
||||||
|
# 두 개의 키 중 하나라도 값이 있는지 확인
|
||||||
|
partcode_element = self.extract_value(api_data, "dmItemMap", "intpLmtCn")
|
||||||
|
partcode_element_alt = self.extract_value(api_data, "dmItemMap", "intpLmtCp")
|
||||||
|
|
||||||
|
# 두 키 모두 값이 없을 경우 처리
|
||||||
|
if partcode_element is None and partcode_element_alt is None:
|
||||||
|
print("업종제한이 없음")
|
||||||
|
return []
|
||||||
|
"""
|
82
backend/airflow/dags/test_group/dags/test_dag.py
Normal file
82
backend/airflow/dags/test_group/dags/test_dag.py
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
# 필요한 모듈
|
||||||
|
import pendulum # 날짜/시간 처리를 위한 권장 라이브러리 (datetime 대체 가능)
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from airflow.models.dag import DAG # DAG 객체 정의를 위한 클래스
|
||||||
|
from airflow.operators.bash import BashOperator # Bash 명령어를 실행하는 오퍼레이터
|
||||||
|
from airflow.operators.python import PythonOperator # Python 함수를 실행하는 오퍼레이터
|
||||||
|
from airflow.operators.empty import EmptyOperator
|
||||||
|
from datetime import datetime
|
||||||
|
from airflow.models.variable import Variable
|
||||||
|
from test_group.tasks.test import Test
|
||||||
|
|
||||||
|
|
||||||
|
# 모든 Operator에 공통적으로 적용될 기본값들을 딕셔너리로 정의할 수 있습니다.
|
||||||
|
# 스캐쥴링 설정
|
||||||
|
scheduler = "0 0 * * *"
|
||||||
|
# args 설정
|
||||||
|
default_args = {
|
||||||
|
'owner': 'airflow_user', # DAG의 소유자 (관리/알림 목적)
|
||||||
|
"start_date": datetime(2025, 9, 5, 15, 0, 0), # 첫 실행 날짜
|
||||||
|
"depends_on_past": False, # 이전 DAG Run 실행 여부에 따른 의존성 설정
|
||||||
|
}
|
||||||
|
|
||||||
|
# DAG 정의
|
||||||
|
# 'with DAG(...) as dag:' 구문을 사용하면 이 블록 내에서 정의된 오퍼레이터들이 자동으로 'dag' 객체에 속하게 됩니다.
|
||||||
|
with DAG(
|
||||||
|
dag_id='test_dag', # DAG의 고유 식별자. Airflow UI에 표시됨. (필수)
|
||||||
|
description='임시 처리용 DAG', # DAG에 대한 설명 (UI에 표시됨)
|
||||||
|
schedule=scheduler, # DAG 실행 스케줄. None은 수동 실행(Manual Trigger)을 의미.
|
||||||
|
catchup=False, # True면 start_date부터 현재까지 놓친 스케줄을 모두 실행 (Backfill). 보통 False로 설정.
|
||||||
|
default_args=default_args, # 위에서 정의한 기본 인수 적용
|
||||||
|
max_active_runs=1,
|
||||||
|
tags=['임시'], # DAG를 분류하고 UI에서 필터링하기 위한 태그 목록
|
||||||
|
) as dag: # 이 DAG 인스턴스를 'dag' 변수로 사용
|
||||||
|
# dag 설명
|
||||||
|
dag.doc_md = """
|
||||||
|
임시 처리용 DAG
|
||||||
|
"""
|
||||||
|
# 임시 처리용 클래스
|
||||||
|
test = Test()
|
||||||
|
|
||||||
|
|
||||||
|
# Task(작업) 정의
|
||||||
|
# 각 Task는 Operator 클래스의 인스턴스로 생성됩니다.
|
||||||
|
####################################################################################################################
|
||||||
|
# Task: Start
|
||||||
|
####################################################################################################################
|
||||||
|
task_start = EmptyOperator(
|
||||||
|
task_id="task_start"
|
||||||
|
)
|
||||||
|
task_start.doc_md = f"""- Empty Task"""
|
||||||
|
|
||||||
|
####################################################################################################################
|
||||||
|
# Task1 : 임시 처리용 테스트
|
||||||
|
task_test = PythonOperator(
|
||||||
|
task_id='test',
|
||||||
|
python_callable=test.test,
|
||||||
|
op_kwargs = {
|
||||||
|
"exec_dt_str": "{{ data_interval_end }}"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
task_test.doc_md = f"""
|
||||||
|
- 임시 처리용 테스트
|
||||||
|
"""
|
||||||
|
|
||||||
|
####################################################################################################################
|
||||||
|
|
||||||
|
|
||||||
|
####################################################################################################################
|
||||||
|
# Task: End
|
||||||
|
####################################################################################################################
|
||||||
|
task_end = EmptyOperator(
|
||||||
|
task_id="task_end"
|
||||||
|
)
|
||||||
|
task_end.doc_md = f"""- Empty Task"""
|
||||||
|
|
||||||
|
# 6. Task 의존성(실행 순서) 설정
|
||||||
|
task_start >> \
|
||||||
|
task_test >> \
|
||||||
|
task_end
|
||||||
|
|
||||||
|
|
21
backend/airflow/dags/test_group/tasks/test.py
Normal file
21
backend/airflow/dags/test_group/tasks/test.py
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
from airflow.exceptions import AirflowException, AirflowSkipException, AirflowFailException
|
||||||
|
from airflow.models import Variable
|
||||||
|
from airflow.providers.mysql.hooks.mysql import MySqlHook
|
||||||
|
|
||||||
|
|
||||||
|
class Test():
|
||||||
|
# =======================================================================================================================================
|
||||||
|
# 임시 처리용 테스트
|
||||||
|
def test(self, exec_dt_str: str):
|
||||||
|
logging.info('============================== test start ==============================')
|
||||||
|
try:
|
||||||
|
# 기본 정보
|
||||||
|
exec_dt = CommonDatetime.make_datetime_kst_from_ts(exec_dt_str)
|
||||||
|
logging.info(f"exec_dt(): {exec_dt}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"test() Exception: {str(e)}")
|
||||||
|
raise Exception(str(e))
|
||||||
|
|
||||||
|
finally :
|
||||||
|
logging.info('============================== test finish ==============================')
|
278
backend/airflow/docker-compose.yaml
Normal file
278
backend/airflow/docker-compose.yaml
Normal file
@ -0,0 +1,278 @@
|
|||||||
|
# Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
# or more contributor license agreements. See the NOTICE file
|
||||||
|
# distributed with this work for additional information
|
||||||
|
# regarding copyright ownership. The ASF licenses this file
|
||||||
|
# to you under the Apache License, Version 2.0 (the
|
||||||
|
# "License"); you may not use this file except in compliance
|
||||||
|
# with the License. You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing,
|
||||||
|
# software distributed under the License is distributed on an
|
||||||
|
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
# KIND, either express or implied. See the License for the
|
||||||
|
# specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
#
|
||||||
|
|
||||||
|
# Basic Airflow cluster configuration for CeleryExecutor with Redis and PostgreSQL.
|
||||||
|
#
|
||||||
|
# WARNING: This configuration is for local development. Do not use it in a production deployment.
|
||||||
|
#
|
||||||
|
# This configuration supports basic configuration using environment variables or an .env file
|
||||||
|
# The following variables are supported:
|
||||||
|
#
|
||||||
|
# AIRFLOW_IMAGE_NAME - Docker image name used to run Airflow.
|
||||||
|
# Default: apache/airflow:2.9.3
|
||||||
|
# AIRFLOW_UID - User ID in Airflow containers
|
||||||
|
# Default: 50000
|
||||||
|
# AIRFLOW_PROJ_DIR - Base path to which all the files will be volumed.
|
||||||
|
# Default: .
|
||||||
|
# Those configurations are useful mostly in case of standalone testing/running Airflow in test/try-out mode
|
||||||
|
#
|
||||||
|
# _AIRFLOW_WWW_USER_USERNAME - Username for the administrator account (if requested).
|
||||||
|
# Default: airflow
|
||||||
|
# _AIRFLOW_WWW_USER_PASSWORD - Password for the administrator account (if requested).
|
||||||
|
# Default: airflow
|
||||||
|
# _PIP_ADDITIONAL_REQUIREMENTS - Additional PIP requirements to add when starting all containers.
|
||||||
|
# Use this option ONLY for quick checks. Installing requirements at container
|
||||||
|
# startup is done EVERY TIME the service is started.
|
||||||
|
# A better way is to build a custom image or extend the official image
|
||||||
|
# as described in https://airflow.apache.org/docs/docker-stack/build.html.
|
||||||
|
# Default: ''
|
||||||
|
#
|
||||||
|
# Feel free to modify this file to suit your needs.
|
||||||
|
---
|
||||||
|
networks: # <--- 이 섹션 추가 또는 수정
|
||||||
|
airflow_default: # Airflow 내부 통신용 기본 네트워크 (기존에 있다면 유지)
|
||||||
|
driver: bridge
|
||||||
|
db_network: # MySQL 컨테이너가 사용하는 네트워크 이름과 동일하게 지정
|
||||||
|
external: true # 이 네트워크가 다른 docker-compose 파일 또는 명령으로 이미 생성되었다고 가정
|
||||||
|
|
||||||
|
x-airflow-common: &airflow-common
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.airflow
|
||||||
|
networks: # <--- 모든 Airflow 서비스가 사용할 네트워크 지정
|
||||||
|
- airflow_default # Airflow 내부 서비스 간 통신용
|
||||||
|
- db_network # MySQL DB 접속용
|
||||||
|
environment: &airflow-common-env
|
||||||
|
AIRFLOW__CORE__EXECUTOR: CeleryExecutor
|
||||||
|
AIRFLOW__CORE__FERNET_KEY: ${AIRFLOW__CORE__FERNET_KEY:-$(python -c "from cryptography.fernet import Fernet; FERNET_KEY = Fernet.generate_key().decode(); print(FERNET_KEY)")}
|
||||||
|
AIRFLOW__CORE__DAGS_ARE_PAUSED_AT_CREATION: "true"
|
||||||
|
AIRFLOW__CORE__LOAD_EXAMPLES: "false"
|
||||||
|
AIRFLOW__CORE__DEFAULT_TIMEZONE: "Asia/Seoul" # Airflow 내부 처리 기준 KST
|
||||||
|
|
||||||
|
# Database Settings
|
||||||
|
AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://airflow:airflow@postgres/airflow
|
||||||
|
|
||||||
|
# Celery Settings
|
||||||
|
AIRFLOW__CELERY__RESULT_BACKEND: db+postgresql://airflow:airflow@postgres/airflow
|
||||||
|
AIRFLOW__CELERY__BROKER_URL: redis://:@redis:6379/0
|
||||||
|
|
||||||
|
# Webserver Settings
|
||||||
|
AIRFLOW__WEBSERVER__DEFAULT_UI_TIMEZONE: "Asia/Seoul"
|
||||||
|
AIRFLOW__API__AUTH_BACKENDS: "airflow.api.auth.backend.basic_auth,airflow.api.auth.backend.session"
|
||||||
|
|
||||||
|
# Scheduler Settings
|
||||||
|
AIRFLOW__SCHEDULER__ENABLE_HEALTH_CHECK: "true"
|
||||||
|
|
||||||
|
# Logging Settings
|
||||||
|
AIRFLOW__LOGGING__LOG_FORMAT: "[%(asctime)s] {%(filename)s:%(lineno)d} %(levelname)s - %(message)s"
|
||||||
|
# AIRFLOW__LOGGING__DEFAULT_DATE_FORMAT: '%Y-%m-%d %H:%M:%S%z' # 필요시 날짜 포맷 명시적 지정 고려
|
||||||
|
|
||||||
|
# PIP Requirements (handled by Dockerfile build)
|
||||||
|
_PIP_ADDITIONAL_REQUIREMENTS: ${_PIP_ADDITIONAL_REQUIREMENTS:-}
|
||||||
|
|
||||||
|
# Container OS Timezone
|
||||||
|
TZ: "Asia/Seoul" # Docker 컨테이너 OS 시간대 KST
|
||||||
|
volumes:
|
||||||
|
- ${AIRFLOW_PROJ_DIR:-.}/dags:/opt/airflow/dags
|
||||||
|
- ${AIRFLOW_PROJ_DIR:-.}/logs:/opt/airflow/logs
|
||||||
|
- ${AIRFLOW_PROJ_DIR:-.}/config:/opt/airflow/config
|
||||||
|
- ${AIRFLOW_PROJ_DIR:-.}/plugins:/opt/airflow/plugins
|
||||||
|
user: "${AIRFLOW_UID:-50000}:0"
|
||||||
|
depends_on: &airflow-common-depends-on
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:13
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: airflow
|
||||||
|
POSTGRES_PASSWORD: airflow
|
||||||
|
POSTGRES_DB: airflow
|
||||||
|
TZ: "Asia/Seoul" # DB 컨테이너 OS 시간대
|
||||||
|
PGTZ: "Asia/Seoul" # PostgreSQL 서버 시간대 설정 추가
|
||||||
|
volumes:
|
||||||
|
- postgres-db-volume:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- airflow_default
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "pg_isready", "-U", "airflow"]
|
||||||
|
interval: 10s
|
||||||
|
retries: 5
|
||||||
|
start_period: 5s
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
restart: always
|
||||||
|
|
||||||
|
redis:
|
||||||
|
# Redis is limited to 7.2-bookworm due to licencing change
|
||||||
|
# https://redis.io/blog/redis-adopts-dual-source-available-licensing/
|
||||||
|
image: redis:7.2-bookworm
|
||||||
|
expose:
|
||||||
|
- 6379
|
||||||
|
networks:
|
||||||
|
- airflow_default
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 30s
|
||||||
|
retries: 50
|
||||||
|
start_period: 30s
|
||||||
|
restart: always
|
||||||
|
|
||||||
|
airflow-webserver:
|
||||||
|
<<: *airflow-common
|
||||||
|
command: webserver
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "--fail", "http://localhost:8080/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
restart: always
|
||||||
|
depends_on:
|
||||||
|
<<: *airflow-common-depends-on
|
||||||
|
airflow-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
|
||||||
|
airflow-scheduler:
|
||||||
|
<<: *airflow-common
|
||||||
|
command: scheduler
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "--fail", "http://localhost:8974/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
restart: always
|
||||||
|
depends_on:
|
||||||
|
<<: *airflow-common-depends-on
|
||||||
|
airflow-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
|
||||||
|
airflow-worker:
|
||||||
|
<<: *airflow-common
|
||||||
|
command: celery worker
|
||||||
|
healthcheck:
|
||||||
|
# yamllint disable rule:line-length
|
||||||
|
test:
|
||||||
|
- "CMD-SHELL"
|
||||||
|
- 'celery --app airflow.providers.celery.executors.celery_executor.app inspect ping -d "celery@$${HOSTNAME}" || celery --app airflow.executors.celery_executor.app inspect ping -d "celery@$${HOSTNAME}"'
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
environment:
|
||||||
|
<<: *airflow-common-env
|
||||||
|
# Required to handle warm shutdown of the celery workers properly
|
||||||
|
# See https://airflow.apache.org/docs/docker-stack/entrypoint.html#signal-propagation
|
||||||
|
DUMB_INIT_SETSID: "0"
|
||||||
|
restart: always
|
||||||
|
depends_on:
|
||||||
|
<<: *airflow-common-depends-on
|
||||||
|
airflow-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
|
||||||
|
airflow-triggerer:
|
||||||
|
<<: *airflow-common
|
||||||
|
command: triggerer
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD-SHELL",
|
||||||
|
'airflow jobs check --job-type TriggererJob --hostname "$${HOSTNAME}"',
|
||||||
|
]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
restart: always
|
||||||
|
depends_on:
|
||||||
|
<<: *airflow-common-depends-on
|
||||||
|
airflow-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
|
||||||
|
airflow-init:
|
||||||
|
<<: *airflow-common
|
||||||
|
entrypoint: /bin/bash
|
||||||
|
# yamllint disable rule:line-length
|
||||||
|
command:
|
||||||
|
- -c
|
||||||
|
- |
|
||||||
|
if [[ -z "${AIRFLOW_UID}" ]]; then
|
||||||
|
echo
|
||||||
|
echo -e "\033[1;33mWARNING!!!: AIRFLOW_UID not set!\e[0m"
|
||||||
|
# ... (rest of the init messages from your original file) ...
|
||||||
|
echo
|
||||||
|
fi
|
||||||
|
mkdir -p /sources/logs /sources/dags /sources/plugins
|
||||||
|
chown -R "${AIRFLOW_UID}:0" /sources/{logs,dags,plugins}
|
||||||
|
exec /entrypoint airflow version
|
||||||
|
# yamllint enable rule:line-length
|
||||||
|
environment:
|
||||||
|
<<: *airflow-common-env
|
||||||
|
_AIRFLOW_DB_MIGRATE: "true"
|
||||||
|
_AIRFLOW_WWW_USER_CREATE: "true"
|
||||||
|
_AIRFLOW_WWW_USER_USERNAME: ${_AIRFLOW_WWW_USER_USERNAME:-imai_master}
|
||||||
|
_AIRFLOW_WWW_USER_PASSWORD: ${_AIRFLOW_WWW_USER_PASSWORD:-imai240510!}
|
||||||
|
_PIP_ADDITIONAL_REQUIREMENTS: ""
|
||||||
|
user: "0:0"
|
||||||
|
volumes:
|
||||||
|
- ${AIRFLOW_PROJ_DIR:-.}:/sources
|
||||||
|
|
||||||
|
airflow-cli:
|
||||||
|
<<: *airflow-common
|
||||||
|
profiles:
|
||||||
|
- debug
|
||||||
|
environment:
|
||||||
|
<<: *airflow-common-env
|
||||||
|
CONNECTION_CHECK_MAX_COUNT: "0"
|
||||||
|
# Workaround for entrypoint issue. See: https://github.com/apache/airflow/issues/16252
|
||||||
|
command:
|
||||||
|
- bash
|
||||||
|
- -c
|
||||||
|
- airflow
|
||||||
|
|
||||||
|
# You can enable flower by adding "--profile flower" option e.g. docker-compose --profile flower up
|
||||||
|
# or by explicitly targeted on the command line e.g. docker-compose up flower.
|
||||||
|
# See: https://docs.docker.com/compose/profiles/
|
||||||
|
flower:
|
||||||
|
<<: *airflow-common
|
||||||
|
command: celery flower
|
||||||
|
profiles:
|
||||||
|
- flower
|
||||||
|
ports:
|
||||||
|
- "5555:5555"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "--fail", "http://localhost:5555/"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
restart: always
|
||||||
|
depends_on:
|
||||||
|
<<: *airflow-common-depends-on
|
||||||
|
airflow-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres-db-volume:
|
@ -1 +0,0 @@
|
|||||||
""
|
|
122
backend/fastapi/Dockerfile-fastapi
Normal file
122
backend/fastapi/Dockerfile-fastapi
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
FROM python:3.11
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 기본 패키지 설치 (MySQL, 빌드 도구, 로케일)
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
default-libmysqlclient-dev \
|
||||||
|
build-essential \
|
||||||
|
pkg-config \
|
||||||
|
locales \
|
||||||
|
wget \
|
||||||
|
unzip \
|
||||||
|
ca-certificates \
|
||||||
|
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Selenium 및 Chrome 관련 패키지 설치
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
libnss3 \
|
||||||
|
libxss1 \
|
||||||
|
libappindicator3-1 \
|
||||||
|
fonts-liberation \
|
||||||
|
libasound2 \
|
||||||
|
libnspr4 \
|
||||||
|
libdbus-1-3 \
|
||||||
|
libatk1.0-0 \
|
||||||
|
libatk-bridge2.0-0 \
|
||||||
|
libcups2 \
|
||||||
|
libdrm2 \
|
||||||
|
libgbm1 \
|
||||||
|
libgtk-3-0 \
|
||||||
|
libpango-1.0-0 \
|
||||||
|
libcairo2 \
|
||||||
|
libatspi2.0-0 \
|
||||||
|
chromium \
|
||||||
|
chromium-driver \
|
||||||
|
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# 폰트 관련 패키지 설치 (기본)
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
fontconfig \
|
||||||
|
fonts-dejavu-core \
|
||||||
|
fonts-liberation \
|
||||||
|
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# 한글 폰트 설치 (최소한으로 안전하게)
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y fonts-dejavu && \
|
||||||
|
(apt-get install -y fonts-nanum || true) && \
|
||||||
|
(apt-get install -y fonts-noto-cjk || true) && \
|
||||||
|
apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# ChromeDriver 설치
|
||||||
|
RUN wget https://storage.googleapis.com/chrome-for-testing-public/127.0.6533.88/linux64/chromedriver-linux64.zip \
|
||||||
|
&& unzip chromedriver-linux64.zip -d /usr/local/bin/ \
|
||||||
|
&& rm chromedriver-linux64.zip \
|
||||||
|
&& mv /usr/local/bin/chromedriver-linux64/chromedriver /usr/local/bin/chromedriver \
|
||||||
|
&& rm -rf /usr/local/bin/chromedriver-linux64
|
||||||
|
|
||||||
|
# MeCab 한국어 형태소 분석기 설치
|
||||||
|
RUN wget https://bitbucket.org/eunjeon/mecab-ko/downloads/mecab-0.996-ko-0.9.2.tar.gz \
|
||||||
|
&& tar -xzf mecab-0.996-ko-0.9.2.tar.gz \
|
||||||
|
&& cd mecab-0.996-ko-0.9.2 \
|
||||||
|
&& ./configure \
|
||||||
|
&& make \
|
||||||
|
&& make install \
|
||||||
|
&& ldconfig \
|
||||||
|
&& cd .. \
|
||||||
|
&& rm -rf mecab-0.996-ko-0.9.2*
|
||||||
|
|
||||||
|
# 한국어 로케일 설정
|
||||||
|
RUN sed -i -e 's/# ko_KR.UTF-8 UTF-8/ko_KR.UTF-8 UTF-8/' /etc/locale.gen \
|
||||||
|
&& locale-gen
|
||||||
|
|
||||||
|
# 한국어 로케일 환경 변수 설정
|
||||||
|
ENV LANG=ko_KR.UTF-8
|
||||||
|
ENV LANGUAGE=ko_KR:ko
|
||||||
|
ENV LC_ALL=ko_KR.UTF-8
|
||||||
|
ENV PYTHONIOENCODING=utf-8
|
||||||
|
ENV PYTHONENCODING=utf-8
|
||||||
|
|
||||||
|
# 폰트 캐시 업데이트 및 WeasyPrint 폰트 설정
|
||||||
|
RUN fc-cache -fv \
|
||||||
|
&& echo "폰트 설치 확인:" \
|
||||||
|
&& fc-list :lang=ko | head -10 \
|
||||||
|
&& echo "WeasyPrint 테스트용 HTML 생성 및 PDF 변환 테스트" \
|
||||||
|
&& printf '<!DOCTYPE html><html><head><meta charset="UTF-8"><style>body{font-family:"Nanum Gothic",NanumGothic,"Noto Sans CJK KR",sans-serif;}</style></head><body><h1>한글 테스트</h1><p>가나다라마바사</p></body></html>' > /tmp/test_korean.html
|
||||||
|
|
||||||
|
COPY ./app /app
|
||||||
|
|
||||||
|
# document_files 디렉토리 생성 및 sn3hcv 파일 변환기 복사
|
||||||
|
RUN mkdir -p /home/user1/yjh/fastapi/document_files
|
||||||
|
COPY ./document_files /home/user1/yjh/fastapi/document_files
|
||||||
|
|
||||||
|
# pip 업그레이드 & Selenium 별도 설치 (버전 고정, no-cache-dir로 캐시 피함, retry 추가)
|
||||||
|
RUN pip install --upgrade pip --no-cache-dir \
|
||||||
|
&& pip install selenium==4.10.0 webdriver-manager --no-cache-dir --retries 10 --timeout 60
|
||||||
|
|
||||||
|
# PDF 변환 도구들 설치 (WeasyPrint 및 pdfkit 지원)
|
||||||
|
RUN apt-get update && \
|
||||||
|
(apt-get install -y libpango-1.0-0 || true) && \
|
||||||
|
(apt-get install -y libpangocairo-1.0-0 || true) && \
|
||||||
|
(apt-get install -y libgdk-pixbuf2.0-0 || true) && \
|
||||||
|
(apt-get install -y wkhtmltopdf || true) && \
|
||||||
|
apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Python 패키지 설치
|
||||||
|
RUN pip install -r requirements.txt --no-cache-dir --retries 10 --timeout 60
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 포트 설정
|
||||||
|
# - 컨테이너 내부에서는 8097 포트 사용 (표준화)
|
||||||
|
# - 외부 포트는 docker-compose에서 매핑
|
||||||
|
# =============================================================================
|
||||||
|
EXPOSE 8097
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 서버 시작 명령어
|
||||||
|
# - uvicorn: ASGI 서버 (FastAPI 권장)
|
||||||
|
# - 워커 수: CPU 코어 수의 2-4배가 적절 (예: 4코어 = 8-16 워커)
|
||||||
|
# - 비동기 처리로 적은 워커로도 많은 동시 요청 처리 가능
|
||||||
|
# =============================================================================
|
||||||
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8097", "--workers", "4"]
|
51
backend/fastapi/docker-compose.yaml
Normal file
51
backend/fastapi/docker-compose.yaml
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
services:
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile-fastapi
|
||||||
|
image: fastapi:v1
|
||||||
|
container_name: fastapi-dev-test
|
||||||
|
ports:
|
||||||
|
- "8096:8097" # 개발 테스트용 포트 (외부:8096 → 내부:8097)
|
||||||
|
volumes:
|
||||||
|
# 실시간 코드 반영을 위한 볼륨 마운트
|
||||||
|
- /home/user1/yjh/fastapi/app:/app
|
||||||
|
# 문서 파일 공유
|
||||||
|
- /home/user1/yjh/fastapi/document_files:/home/user1/yjh/fastapi/document_files
|
||||||
|
- /home/user1/yjh/rebbitMQ/logs:/rebbitMQ/logs
|
||||||
|
# rebbitMQ 문서 파일 마운트 (데이터 공유)
|
||||||
|
- /home/user1/yjh/rebbitMQ/document_files:/home/user1/yjh/rebbitMQ/document_files
|
||||||
|
- /home/user1/yjh/rebbitMQ/document_files_test:/home/user1/yjh/rebbitMQ/document_files_test
|
||||||
|
environment:
|
||||||
|
- SERVER_NAME=dev-test # 서버 식별용
|
||||||
|
- PORT=8097
|
||||||
|
- ENV=development # 개발 환경 표시
|
||||||
|
# 운영서버 접속정보
|
||||||
|
- MYSQL_HOST=222.234.3.20
|
||||||
|
- MYSQL_PORT=3306
|
||||||
|
- MYSQL_USER=i2
|
||||||
|
- MYSQL_PW=whdgus&cndaks
|
||||||
|
- MYSQL_DB=i2
|
||||||
|
# AI API 키
|
||||||
|
- GEMINI_API_KEY=AIzaSyDafw8OSsYEFBPvjtrB5kkwIDdJgaKjKyk
|
||||||
|
networks:
|
||||||
|
- db_network # 데이터베이스 네트워크 연결
|
||||||
|
restart: unless-stopped
|
||||||
|
# 개발 중에는 자동 재시작으로 편의성 제공
|
||||||
|
healthcheck:
|
||||||
|
# 개발 서버 상태 확인
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8097/docs"]
|
||||||
|
interval: 60s # 개발 환경이므로 느슨한 체크
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# 네트워크 설정
|
||||||
|
# - 외부 데이터베이스 네트워크에 연결
|
||||||
|
# - 다른 서비스들과 네트워크 분리
|
||||||
|
# ===========================================================================
|
||||||
|
networks:
|
||||||
|
db_network:
|
||||||
|
external: true # 외부에서 생성된 네트워크 사용
|
||||||
|
|
@ -1 +0,0 @@
|
|||||||
""
|
|
3
imai-invest-manager-AI/.env
Normal file
3
imai-invest-manager-AI/.env
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
[AWS KEY INFO]
|
||||||
|
AWS_YOUR_ACCESS_KEY=AKIA1234567890EXAMPLE
|
||||||
|
AWS_YOUR_SECRET_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
|
1241
imai-invest-manager-AI/package-lock.json
generated
1241
imai-invest-manager-AI/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -10,18 +10,33 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
|
"@types/chart.js": "^2.9.41",
|
||||||
|
"chart.js": "^4.5.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.542.0",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1"
|
"react-chartjs-2": "^5.3.0",
|
||||||
|
"react-dom": "^19.1.1",
|
||||||
|
"socket.io-client": "^4.8.1",
|
||||||
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.33.0",
|
"@eslint/js": "^9.33.0",
|
||||||
|
"@tailwindcss/postcss": "^4.1.13",
|
||||||
|
"@types/node": "^24.3.0",
|
||||||
"@types/react": "^19.1.10",
|
"@types/react": "^19.1.10",
|
||||||
"@types/react-dom": "^19.1.7",
|
"@types/react-dom": "^19.1.7",
|
||||||
"@vitejs/plugin-react-swc": "^4.0.0",
|
"@vitejs/plugin-react-swc": "^4.0.0",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
"eslint": "^9.33.0",
|
"eslint": "^9.33.0",
|
||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.20",
|
"eslint-plugin-react-refresh": "^0.4.20",
|
||||||
"globals": "^16.3.0",
|
"globals": "^16.3.0",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"tailwindcss": "^4.1.12",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"typescript-eslint": "^8.39.1",
|
"typescript-eslint": "^8.39.1",
|
||||||
"vite": "^7.1.2"
|
"vite": "^7.1.2"
|
||||||
|
6
imai-invest-manager-AI/postcss.config.js
Normal file
6
imai-invest-manager-AI/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
'@tailwindcss/postcss': {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
@ -1,35 +1,98 @@
|
|||||||
import { useState } from 'react'
|
import React, { useEffect } from 'react';
|
||||||
import reactLogo from './assets/react.svg'
|
import { useAppStore } from './stores/useAppStore';
|
||||||
import viteLogo from '/vite.svg'
|
import Header from './components/common/Header';
|
||||||
import './App.css'
|
import Dashboard from './components/dashboard/Dashboard';
|
||||||
|
import ProductDetail from './components/detail/ProductDetail';
|
||||||
|
import Simulation from './components/simulation/Simulation';
|
||||||
|
import Settings from './components/settings/Settings';
|
||||||
|
import Loading from './components/common/Loading';
|
||||||
|
import AdBanner from './components/common/AdBanner';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [count, setCount] = useState(0)
|
const { currentView, isLoading, theme } = useAppStore();
|
||||||
|
|
||||||
|
// 테마 적용 (body 클래스 변경)
|
||||||
|
useEffect(() => {
|
||||||
|
document.body.className = theme === 'dark' ? 'dark' : 'light';
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
// 현재 뷰에 따른 컴포넌트 렌더링
|
||||||
|
const renderCurrentView = () => {
|
||||||
|
switch (currentView) {
|
||||||
|
case 'dashboard':
|
||||||
|
return <Dashboard />;
|
||||||
|
case 'detail':
|
||||||
|
return <ProductDetail />;
|
||||||
|
case 'simulation':
|
||||||
|
return <Simulation />;
|
||||||
|
case 'settings':
|
||||||
|
return <Settings />;
|
||||||
|
default:
|
||||||
|
return <Dashboard />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="min-h-screen">
|
||||||
<div>
|
{/* 사이드 광고 배너 */}
|
||||||
<a href="https://vite.dev" target="_blank">
|
<AdBanner size="side" position="left" />
|
||||||
<img src={viteLogo} className="logo" alt="Vite logo" />
|
<AdBanner size="side" position="right" />
|
||||||
</a>
|
|
||||||
<a href="https://react.dev" target="_blank">
|
{/* 헤더 상단 광고 */}
|
||||||
<img src={reactLogo} className="logo react" alt="React logo" />
|
<div className="px-4 pt-4">
|
||||||
</a>
|
<AdBanner size="top" className="mb-4" />
|
||||||
</div>
|
</div>
|
||||||
<h1>Vite + React</h1>
|
|
||||||
<div className="card">
|
<Header />
|
||||||
<button onClick={() => setCount((count) => count + 1)}>
|
|
||||||
count is {count}
|
{/* 메인 상단 광고 */}
|
||||||
</button>
|
<div className="px-4 mb-6">
|
||||||
<p>
|
<AdBanner size="main" />
|
||||||
Edit <code>src/App.tsx</code> and save to test HMR
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="read-the-docs">
|
|
||||||
Click on the Vite and React logos to learn more
|
<main className="pb-8 px-4">
|
||||||
</p>
|
<div className="max-w-7xl mx-auto">
|
||||||
</>
|
{isLoading ? (
|
||||||
)
|
<div className="flex items-center justify-center min-h-96">
|
||||||
|
<Loading size="lg" text="로딩 중..." />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 xl:grid-cols-4 gap-6">
|
||||||
|
{/* 메인 콘텐츠 */}
|
||||||
|
<div className="xl:col-span-3">
|
||||||
|
{renderCurrentView()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 사이드바 광고 */}
|
||||||
|
<div className="xl:col-span-1 space-y-4 hidden xl:block">
|
||||||
|
<AdBanner size="inline" />
|
||||||
|
<AdBanner size="inline" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* 하단 대형 배너 */}
|
||||||
|
<div className="px-4 mb-6">
|
||||||
|
<AdBanner size="bottom" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 푸터 */}
|
||||||
|
<footer className="glass rounded-2xl mx-4 mb-4 p-6 text-center">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center md:justify-between">
|
||||||
|
<div className={`text-sm mb-2 md:mb-0 ${theme === 'dark' ? 'text-white/60' : 'text-slate-600'}`}>
|
||||||
|
© 2024 I'm AI 투자매니저. 투자 정보 제공 목적으로, 투자 권유가 아닙니다.
|
||||||
|
</div>
|
||||||
|
<div className={`flex items-center space-x-4 text-xs ${theme === 'dark' ? 'text-white/40' : 'text-slate-400'}`}>
|
||||||
|
<span>Made with React + TypeScript</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>Powered by AI</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App
|
export default App;
|
||||||
|
79
imai-invest-manager-AI/src/components/common/AdBanner.tsx
Normal file
79
imai-invest-manager-AI/src/components/common/AdBanner.tsx
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface AdBannerProps {
|
||||||
|
size: 'top' | 'main' | 'side' | 'bottom' | 'inline';
|
||||||
|
className?: string;
|
||||||
|
position?: 'left' | 'right';
|
||||||
|
}
|
||||||
|
|
||||||
|
const AdBanner: React.FC<AdBannerProps> = ({ size, className = '', position }) => {
|
||||||
|
const getAdContent = () => {
|
||||||
|
switch (size) {
|
||||||
|
case 'top':
|
||||||
|
return {
|
||||||
|
width: '100%',
|
||||||
|
height: '80px',
|
||||||
|
text: '헤더 상단 배너 광고 (1400x80px)'
|
||||||
|
};
|
||||||
|
case 'main':
|
||||||
|
return {
|
||||||
|
width: '100%',
|
||||||
|
height: '100px',
|
||||||
|
text: '메인 상단 배너 광고 (1400x100px)'
|
||||||
|
};
|
||||||
|
case 'side':
|
||||||
|
return {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
text: '사이드 배너\n(120x600px)'
|
||||||
|
};
|
||||||
|
case 'bottom':
|
||||||
|
return {
|
||||||
|
width: '100%',
|
||||||
|
height: '200px',
|
||||||
|
text: '하단 대형 배너 광고 (1400x200px)'
|
||||||
|
};
|
||||||
|
case 'inline':
|
||||||
|
return {
|
||||||
|
width: '100%',
|
||||||
|
height: '200px',
|
||||||
|
text: '인라인 광고 (320x200px)'
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
width: '100%',
|
||||||
|
height: '100px',
|
||||||
|
text: '광고 배너'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const adContent = getAdContent();
|
||||||
|
const sizeClasses = {
|
||||||
|
top: 'h-20',
|
||||||
|
main: 'h-25',
|
||||||
|
side: 'h-full w-30',
|
||||||
|
bottom: 'h-50',
|
||||||
|
inline: 'h-50'
|
||||||
|
};
|
||||||
|
|
||||||
|
if (size === 'side') {
|
||||||
|
return (
|
||||||
|
<div className={`ad-banner ad-side-banner ad-side-${position} ${className}`}>
|
||||||
|
<div className="text-xs whitespace-pre-line">
|
||||||
|
{adContent.text}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`ad-banner ${sizeClasses[size]} ${className}`} style={{ minHeight: adContent.height }}>
|
||||||
|
<div className="text-sm">
|
||||||
|
{adContent.text}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdBanner;
|
122
imai-invest-manager-AI/src/components/common/Header.tsx
Normal file
122
imai-invest-manager-AI/src/components/common/Header.tsx
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Moon, Sun, Menu, X } from 'lucide-react';
|
||||||
|
import { useAppStore } from '../../stores/useAppStore';
|
||||||
|
|
||||||
|
const Header: React.FC = () => {
|
||||||
|
const { theme, setTheme, currentView, setCurrentView } = useAppStore();
|
||||||
|
const [isMenuOpen, setIsMenuOpen] = React.useState(false);
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
setTheme(theme === 'dark' ? 'light' : 'dark');
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMenu = () => {
|
||||||
|
setIsMenuOpen(!isMenuOpen);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNavigation = (view: 'dashboard' | 'detail' | 'simulation' | 'settings') => {
|
||||||
|
setCurrentView(view);
|
||||||
|
setIsMenuOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="glass rounded-2xl mx-4 mt-4 mb-6 sticky top-4 z-50">
|
||||||
|
<div className="px-6 py-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
{/* 로고 */}
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="w-8 h-8 bg-gradient-primary rounded-lg flex items-center justify-center">
|
||||||
|
<span className="text-white font-bold text-sm">AI</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-xl font-bold bg-gradient-primary bg-clip-text text-transparent">
|
||||||
|
I'm AI 투자매니저
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 데스크톱 네비게이션 */}
|
||||||
|
<nav className="hidden md:flex items-center space-x-6">
|
||||||
|
<button
|
||||||
|
onClick={() => handleNavigation('dashboard')}
|
||||||
|
className={`px-4 py-2 rounded-lg transition-all duration-200 ${
|
||||||
|
currentView === 'dashboard'
|
||||||
|
? 'bg-primary text-white'
|
||||||
|
: 'text-white/70 hover:text-white hover:bg-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
대시보드
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleNavigation('settings')}
|
||||||
|
className={`px-4 py-2 rounded-lg transition-all duration-200 ${
|
||||||
|
currentView === 'settings'
|
||||||
|
? 'bg-primary text-white'
|
||||||
|
: 'text-white/70 hover:text-white hover:bg-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
설정
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* 테마 토글 및 모바일 메뉴 */}
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
{/* 테마 토글 */}
|
||||||
|
<button
|
||||||
|
onClick={toggleTheme}
|
||||||
|
className="p-2 rounded-lg hover:bg-white/10 transition-colors duration-200"
|
||||||
|
aria-label="테마 전환"
|
||||||
|
>
|
||||||
|
{theme === 'dark' ? (
|
||||||
|
<Sun className="w-5 h-5 text-yellow-400" />
|
||||||
|
) : (
|
||||||
|
<Moon className="w-5 h-5 text-blue-400" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 모바일 메뉴 토글 */}
|
||||||
|
<button
|
||||||
|
onClick={toggleMenu}
|
||||||
|
className="md:hidden p-2 rounded-lg hover:bg-white/10 transition-colors duration-200"
|
||||||
|
aria-label="메뉴 열기/닫기"
|
||||||
|
>
|
||||||
|
{isMenuOpen ? (
|
||||||
|
<X className="w-5 h-5 text-white" />
|
||||||
|
) : (
|
||||||
|
<Menu className="w-5 h-5 text-white" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 모바일 네비게이션 메뉴 */}
|
||||||
|
{isMenuOpen && (
|
||||||
|
<nav className="md:hidden mt-4 pt-4 border-t border-white/10">
|
||||||
|
<div className="flex flex-col space-y-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleNavigation('dashboard')}
|
||||||
|
className={`px-4 py-3 rounded-lg transition-all duration-200 text-left ${
|
||||||
|
currentView === 'dashboard'
|
||||||
|
? 'bg-primary text-white'
|
||||||
|
: 'text-white/70 hover:text-white hover:bg-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
대시보드
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleNavigation('settings')}
|
||||||
|
className={`px-4 py-3 rounded-lg transition-all duration-200 text-left ${
|
||||||
|
currentView === 'settings'
|
||||||
|
? 'bg-primary text-white'
|
||||||
|
: 'text-white/70 hover:text-white hover:bg-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
설정
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Header;
|
32
imai-invest-manager-AI/src/components/common/Loading.tsx
Normal file
32
imai-invest-manager-AI/src/components/common/Loading.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface LoadingProps {
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
text?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Loading: React.FC<LoadingProps> = ({
|
||||||
|
size = 'md',
|
||||||
|
text = '로딩 중...',
|
||||||
|
className = ''
|
||||||
|
}) => {
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'w-4 h-4',
|
||||||
|
md: 'w-8 h-8',
|
||||||
|
lg: 'w-12 h-12'
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col items-center justify-center space-y-2 ${className}`}>
|
||||||
|
<div
|
||||||
|
className={`${sizeClasses[size]} animate-spin rounded-full border-2 border-white/20 border-t-primary`}
|
||||||
|
/>
|
||||||
|
{text && (
|
||||||
|
<p className="text-sm text-white/70 animate-pulse">{text}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Loading;
|
366
imai-invest-manager-AI/src/components/dashboard/Dashboard.tsx
Normal file
366
imai-invest-manager-AI/src/components/dashboard/Dashboard.tsx
Normal file
@ -0,0 +1,366 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Search, Filter, TrendingUp, TrendingDown, DollarSign, Activity } from 'lucide-react';
|
||||||
|
import { useAppStore } from '../../stores/useAppStore';
|
||||||
|
import ProductCard from './ProductCard';
|
||||||
|
import Loading from '../common/Loading';
|
||||||
|
import type { InvestProduct, AIAnalysis, ProductType, SortBy, SortOrder } from '../../types';
|
||||||
|
|
||||||
|
const Dashboard: React.FC = () => {
|
||||||
|
const {
|
||||||
|
products,
|
||||||
|
|
||||||
|
isLoading,
|
||||||
|
setProducts,
|
||||||
|
setAnalyses,
|
||||||
|
setLoading,
|
||||||
|
getLatestAnalysis
|
||||||
|
} = useAppStore();
|
||||||
|
|
||||||
|
// 로컬 상태
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [filterType, setFilterType] = useState<ProductType>('all');
|
||||||
|
const [sortBy, setSortBy] = useState<SortBy>('name');
|
||||||
|
const [sortOrder, setSortOrder] = useState<SortOrder>('asc');
|
||||||
|
|
||||||
|
// 목업 데이터 로드
|
||||||
|
useEffect(() => {
|
||||||
|
const loadMockData = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
// 시뮬레이션 로딩 딜레이
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||||
|
|
||||||
|
// 목업 투자 상품 데이터
|
||||||
|
const mockProducts: InvestProduct[] = [
|
||||||
|
{
|
||||||
|
code: 'BTC',
|
||||||
|
name: 'Bitcoin',
|
||||||
|
type: 'crypto',
|
||||||
|
currentPrice: 65420.50,
|
||||||
|
changeRate: 2.34,
|
||||||
|
changeAmount: 1498.20,
|
||||||
|
volume: 25640.32
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'ETH',
|
||||||
|
name: 'Ethereum',
|
||||||
|
type: 'crypto',
|
||||||
|
currentPrice: 3821.45,
|
||||||
|
changeRate: -1.56,
|
||||||
|
changeAmount: -60.32,
|
||||||
|
volume: 18945.67
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'AAPL',
|
||||||
|
name: 'Apple Inc.',
|
||||||
|
type: 'stock',
|
||||||
|
currentPrice: 189.25,
|
||||||
|
changeRate: 0.85,
|
||||||
|
changeAmount: 1.59,
|
||||||
|
volume: 1245678,
|
||||||
|
marketCap: 2980000000000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'TSLA',
|
||||||
|
name: 'Tesla Inc.',
|
||||||
|
type: 'stock',
|
||||||
|
currentPrice: 248.50,
|
||||||
|
changeRate: -2.10,
|
||||||
|
changeAmount: -5.34,
|
||||||
|
volume: 892345,
|
||||||
|
marketCap: 789000000000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'GOOGL',
|
||||||
|
name: 'Alphabet Inc.',
|
||||||
|
type: 'stock',
|
||||||
|
currentPrice: 142.80,
|
||||||
|
changeRate: 1.23,
|
||||||
|
changeAmount: 1.73,
|
||||||
|
volume: 567890,
|
||||||
|
marketCap: 1800000000000
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// 목업 AI 분석 데이터
|
||||||
|
const mockAnalyses: Record<string, AIAnalysis[]> = {
|
||||||
|
'BTC': [{
|
||||||
|
requestId: 1,
|
||||||
|
investCode: 'BTC',
|
||||||
|
analysisDate: new Date().toISOString(),
|
||||||
|
entryPrice: 63000,
|
||||||
|
entryPriceReason: '기술적 지지선 근처에서 반등 신호 포착',
|
||||||
|
entryNewsIds: [1, 2],
|
||||||
|
targetPrice: 75000,
|
||||||
|
targetPriceReason: '기관 투자자들의 지속적인 유입 예상',
|
||||||
|
targetNewsIds: [3, 4],
|
||||||
|
stopLoss: 58000,
|
||||||
|
stopLossReason: '주요 지지선 하회 시 추가 하락 우려',
|
||||||
|
stopLossNewsIds: [5],
|
||||||
|
investScore: 85
|
||||||
|
}],
|
||||||
|
'ETH': [{
|
||||||
|
requestId: 2,
|
||||||
|
investCode: 'ETH',
|
||||||
|
analysisDate: new Date().toISOString(),
|
||||||
|
entryPrice: 3700,
|
||||||
|
entryPriceReason: '스테이킹 수익률 상승으로 매력도 증가',
|
||||||
|
entryNewsIds: [6, 7],
|
||||||
|
targetPrice: 4200,
|
||||||
|
targetPriceReason: '이더리움 2.0 업그레이드 기대감',
|
||||||
|
targetNewsIds: [8, 9],
|
||||||
|
stopLoss: 3400,
|
||||||
|
stopLossReason: '주요 이동평균선 하회 시 약세 전환',
|
||||||
|
stopLossNewsIds: [10],
|
||||||
|
investScore: 78
|
||||||
|
}],
|
||||||
|
'AAPL': [{
|
||||||
|
requestId: 3,
|
||||||
|
investCode: 'AAPL',
|
||||||
|
analysisDate: new Date().toISOString(),
|
||||||
|
entryPrice: 185,
|
||||||
|
entryPriceReason: 'iPhone 15 출시 효과 및 서비스 매출 성장',
|
||||||
|
entryNewsIds: [11, 12],
|
||||||
|
targetPrice: 210,
|
||||||
|
targetPriceReason: 'AI 관련 제품 출시 기대감 및 배당 증액',
|
||||||
|
targetNewsIds: [13, 14],
|
||||||
|
stopLoss: 175,
|
||||||
|
stopLossReason: '분기 실적 부진 시 주가 조정 불가피',
|
||||||
|
stopLossNewsIds: [15],
|
||||||
|
investScore: 82
|
||||||
|
}],
|
||||||
|
'TSLA': [{
|
||||||
|
requestId: 4,
|
||||||
|
investCode: 'TSLA',
|
||||||
|
analysisDate: new Date().toISOString(),
|
||||||
|
entryPrice: 240,
|
||||||
|
entryPriceReason: '전기차 시장 성장률 둔화에도 기술력 우위 유지',
|
||||||
|
entryNewsIds: [16, 17],
|
||||||
|
targetPrice: 280,
|
||||||
|
targetPriceReason: '완전 자율주행 기술 상용화 기대',
|
||||||
|
targetNewsIds: [18, 19],
|
||||||
|
stopLoss: 220,
|
||||||
|
stopLossReason: '경쟁 심화 및 마진 압박 우려',
|
||||||
|
stopLossNewsIds: [20],
|
||||||
|
investScore: 72
|
||||||
|
}],
|
||||||
|
'GOOGL': [{
|
||||||
|
requestId: 5,
|
||||||
|
investCode: 'GOOGL',
|
||||||
|
analysisDate: new Date().toISOString(),
|
||||||
|
entryPrice: 140,
|
||||||
|
entryPriceReason: 'AI 검색 기술 발전으로 경쟁력 강화',
|
||||||
|
entryNewsIds: [21, 22],
|
||||||
|
targetPrice: 165,
|
||||||
|
targetPriceReason: '클라우드 사업 성장 가속화 및 광고 매출 회복',
|
||||||
|
targetNewsIds: [23, 24],
|
||||||
|
stopLoss: 130,
|
||||||
|
stopLossReason: '규제 리스크 및 광고 시장 침체 우려',
|
||||||
|
stopLossNewsIds: [25],
|
||||||
|
investScore: 80
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
|
||||||
|
setProducts(mockProducts);
|
||||||
|
|
||||||
|
// 각 상품별 분석 데이터 설정
|
||||||
|
Object.entries(mockAnalyses).forEach(([code, analyses]) => {
|
||||||
|
setAnalyses(code, analyses);
|
||||||
|
});
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (products.length === 0) {
|
||||||
|
loadMockData();
|
||||||
|
}
|
||||||
|
}, [products.length, setProducts, setAnalyses, setLoading]);
|
||||||
|
|
||||||
|
// 필터링 및 정렬된 상품 목록
|
||||||
|
const filteredAndSortedProducts = React.useMemo(() => {
|
||||||
|
let filtered = products.filter(product => {
|
||||||
|
// 검색 필터
|
||||||
|
const matchesSearch = product.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
product.code.toLowerCase().includes(searchTerm.toLowerCase());
|
||||||
|
|
||||||
|
// 타입 필터
|
||||||
|
const matchesType = filterType === 'all' || product.type === filterType;
|
||||||
|
|
||||||
|
return matchesSearch && matchesType;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 정렬
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
let aValue: number | string, bValue: number | string;
|
||||||
|
|
||||||
|
switch (sortBy) {
|
||||||
|
case 'price':
|
||||||
|
aValue = a.currentPrice;
|
||||||
|
bValue = b.currentPrice;
|
||||||
|
break;
|
||||||
|
case 'change':
|
||||||
|
aValue = a.changeRate;
|
||||||
|
bValue = b.changeRate;
|
||||||
|
break;
|
||||||
|
case 'score':
|
||||||
|
const aAnalysis = getLatestAnalysis(a.code);
|
||||||
|
const bAnalysis = getLatestAnalysis(b.code);
|
||||||
|
aValue = aAnalysis?.investScore || 0;
|
||||||
|
bValue = bAnalysis?.investScore || 0;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
aValue = a.name;
|
||||||
|
bValue = b.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof aValue === 'string' && typeof bValue === 'string') {
|
||||||
|
return sortOrder === 'asc'
|
||||||
|
? aValue.localeCompare(bValue)
|
||||||
|
: bValue.localeCompare(aValue);
|
||||||
|
} else {
|
||||||
|
return sortOrder === 'asc'
|
||||||
|
? (aValue as number) - (bValue as number)
|
||||||
|
: (bValue as number) - (aValue as number);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}, [products, searchTerm, filterType, sortBy, sortOrder, getLatestAnalysis]);
|
||||||
|
|
||||||
|
// 시장 개요 계산
|
||||||
|
const marketSummary = React.useMemo(() => {
|
||||||
|
const totalProducts = products.length;
|
||||||
|
const gainers = products.filter(p => p.changeRate > 0).length;
|
||||||
|
const losers = products.filter(p => p.changeRate < 0).length;
|
||||||
|
const totalVolume = products.reduce((sum, p) => sum + p.volume, 0);
|
||||||
|
|
||||||
|
return { totalProducts, gainers, losers, totalVolume };
|
||||||
|
}, [products]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-96">
|
||||||
|
<Loading size="lg" text="투자 상품 데이터를 불러오는 중..." />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 시장 개요 */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div className="glass rounded-xl p-4">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Activity className="w-5 h-5 text-primary" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-white/60">총 상품</p>
|
||||||
|
<p className="text-lg font-bold text-white">{marketSummary.totalProducts}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="glass rounded-xl p-4">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<TrendingUp className="w-5 h-5 text-success" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-white/60">상승</p>
|
||||||
|
<p className="text-lg font-bold text-success">{marketSummary.gainers}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="glass rounded-xl p-4">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<TrendingDown className="w-5 h-5 text-danger" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-white/60">하락</p>
|
||||||
|
<p className="text-lg font-bold text-danger">{marketSummary.losers}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="glass rounded-xl p-4">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<DollarSign className="w-5 h-5 text-warning" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-white/60">총 거래량</p>
|
||||||
|
<p className="text-lg font-bold text-white">
|
||||||
|
{(marketSummary.totalVolume / 1000000).toFixed(1)}M
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 필터 및 검색 */}
|
||||||
|
<div className="glass rounded-2xl p-6">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center md:justify-between space-y-4 md:space-y-0">
|
||||||
|
{/* 검색 */}
|
||||||
|
<div className="relative flex-1 max-w-md">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-white/40" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="상품명 또는 코드 검색..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="w-full pl-10 pr-4 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 필터 */}
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Filter className="w-4 h-4 text-white/60" />
|
||||||
|
<select
|
||||||
|
value={filterType}
|
||||||
|
onChange={(e) => setFilterType(e.target.value as ProductType)}
|
||||||
|
className="bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
>
|
||||||
|
<option value="all">전체</option>
|
||||||
|
<option value="crypto">암호화폐</option>
|
||||||
|
<option value="stock">주식</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<select
|
||||||
|
value={sortBy}
|
||||||
|
onChange={(e) => setSortBy(e.target.value as SortBy)}
|
||||||
|
className="bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
>
|
||||||
|
<option value="name">이름순</option>
|
||||||
|
<option value="price">가격순</option>
|
||||||
|
<option value="change">변동률순</option>
|
||||||
|
<option value="score">AI점수순</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')}
|
||||||
|
className="p-2 rounded-lg bg-white/5 hover:bg-white/10 transition-colors duration-200 text-white/60 hover:text-white"
|
||||||
|
>
|
||||||
|
{sortOrder === 'asc' ? '↑' : '↓'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 상품 그리드 */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||||
|
{filteredAndSortedProducts.map((product) => (
|
||||||
|
<ProductCard
|
||||||
|
key={product.code}
|
||||||
|
product={product}
|
||||||
|
analysis={getLatestAnalysis(product.code)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 결과 없음 */}
|
||||||
|
{filteredAndSortedProducts.length === 0 && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-white/60">검색 조건에 맞는 상품이 없습니다.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Dashboard;
|
141
imai-invest-manager-AI/src/components/dashboard/ProductCard.tsx
Normal file
141
imai-invest-manager-AI/src/components/dashboard/ProductCard.tsx
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { TrendingUp, TrendingDown, Star, BarChart3 } from 'lucide-react';
|
||||||
|
import type { InvestProduct, AIAnalysis } from '../../types';
|
||||||
|
import { useAppStore } from '../../stores/useAppStore';
|
||||||
|
|
||||||
|
interface ProductCardProps {
|
||||||
|
product: InvestProduct;
|
||||||
|
analysis?: AIAnalysis;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProductCard: React.FC<ProductCardProps> = ({ product, analysis, onClick }) => {
|
||||||
|
const { setSelectedProduct, setCurrentView } = useAppStore();
|
||||||
|
|
||||||
|
// 숫자 포맷팅 함수
|
||||||
|
const formatPrice = (price: number): string => {
|
||||||
|
if (price >= 1000000) {
|
||||||
|
return `${(price / 1000000).toFixed(1)}M`;
|
||||||
|
} else if (price >= 1000) {
|
||||||
|
return `${(price / 1000).toFixed(1)}K`;
|
||||||
|
}
|
||||||
|
return price.toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatPercent = (percent: number): string => {
|
||||||
|
return `${percent > 0 ? '+' : ''}${percent.toFixed(2)}%`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 카드 클릭 핸들러
|
||||||
|
const handleCardClick = () => {
|
||||||
|
setSelectedProduct(product);
|
||||||
|
setCurrentView('detail');
|
||||||
|
if (onClick) {
|
||||||
|
onClick();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 등락률에 따른 색상 결정
|
||||||
|
const isPositive = product.changeRate >= 0;
|
||||||
|
const changeColor = isPositive ? 'text-success' : 'text-danger';
|
||||||
|
const TrendIcon = isPositive ? TrendingUp : TrendingDown;
|
||||||
|
|
||||||
|
// 매력도 점수에 따른 색상
|
||||||
|
const getScoreColor = (score: number) => {
|
||||||
|
if (score >= 80) return 'text-success';
|
||||||
|
if (score >= 60) return 'text-warning';
|
||||||
|
return 'text-danger';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={handleCardClick}
|
||||||
|
className="glass rounded-2xl p-6 hover:bg-white/10 transition-all duration-300 cursor-pointer group hover:scale-[1.02] hover:shadow-glass"
|
||||||
|
>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
{/* 코인/주식 아이콘 */}
|
||||||
|
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
|
||||||
|
product.type === 'crypto' ? 'bg-orange-500/20' : 'bg-blue-500/20'
|
||||||
|
}`}>
|
||||||
|
<span className="text-sm font-bold text-white">
|
||||||
|
{product.code.slice(0, 2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-white group-hover:text-primary transition-colors duration-200">
|
||||||
|
{product.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-white/50">{product.code}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AI 점수 */}
|
||||||
|
{analysis && (
|
||||||
|
<div className="flex items-center space-x-1 bg-white/5 rounded-full px-2 py-1">
|
||||||
|
<Star className={`w-3 h-3 ${getScoreColor(analysis.investScore)}`} />
|
||||||
|
<span className={`text-xs font-medium ${getScoreColor(analysis.investScore)}`}>
|
||||||
|
{analysis.investScore}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 가격 정보 */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="flex items-baseline space-x-2">
|
||||||
|
<span className="text-2xl font-bold text-white">
|
||||||
|
${formatPrice(product.currentPrice)}
|
||||||
|
</span>
|
||||||
|
<div className={`flex items-center space-x-1 ${changeColor}`}>
|
||||||
|
<TrendIcon className="w-4 h-4" />
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{formatPercent(product.changeRate)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-white/50 mt-1">
|
||||||
|
거래량: {formatPrice(product.volume)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AI 분석 요약 */}
|
||||||
|
{analysis && (
|
||||||
|
<div className="space-y-2 mb-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-xs text-white/70">진입가</span>
|
||||||
|
<span className="text-xs font-medium text-info">
|
||||||
|
${formatPrice(analysis.entryPrice)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-xs text-white/70">목표가</span>
|
||||||
|
<span className="text-xs font-medium text-warning">
|
||||||
|
${formatPrice(analysis.targetPrice)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-xs text-white/70">손절가</span>
|
||||||
|
<span className="text-xs font-medium text-danger">
|
||||||
|
${formatPrice(analysis.stopLoss)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 액션 버튼 */}
|
||||||
|
<div className="flex items-center justify-between pt-4 border-t border-white/10">
|
||||||
|
<span className="text-xs text-white/50">
|
||||||
|
{product.type === 'crypto' ? '암호화폐' : '주식'}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center space-x-1 text-primary group-hover:text-white transition-colors duration-200">
|
||||||
|
<BarChart3 className="w-4 h-4" />
|
||||||
|
<span className="text-xs font-medium">분석 보기</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProductCard;
|
290
imai-invest-manager-AI/src/components/detail/ProductDetail.tsx
Normal file
290
imai-invest-manager-AI/src/components/detail/ProductDetail.tsx
Normal file
@ -0,0 +1,290 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { ArrowLeft, TrendingUp, TrendingDown, BarChart3, Clock, Newspaper, Star, Target, DollarSign } from 'lucide-react';
|
||||||
|
import { useAppStore } from '../../stores/useAppStore';
|
||||||
|
import type { InvestProduct, AIAnalysis } from '../../types';
|
||||||
|
|
||||||
|
const ProductDetail: React.FC = () => {
|
||||||
|
const {
|
||||||
|
selectedProduct,
|
||||||
|
getAnalysesByProduct,
|
||||||
|
setCurrentView,
|
||||||
|
setSelectedAnalysis,
|
||||||
|
selectedAnalysis
|
||||||
|
} = useAppStore();
|
||||||
|
|
||||||
|
const [selectedAnalysisIndex, setSelectedAnalysisIndex] = useState(0);
|
||||||
|
|
||||||
|
// 상품이 없으면 대시보드로 리다이렉트
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedProduct) {
|
||||||
|
setCurrentView('dashboard');
|
||||||
|
}
|
||||||
|
}, [selectedProduct, setCurrentView]);
|
||||||
|
|
||||||
|
if (!selectedProduct) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const analyses = getAnalysesByProduct(selectedProduct.code);
|
||||||
|
const currentAnalysis = analyses[selectedAnalysisIndex] || null;
|
||||||
|
|
||||||
|
// 뒤로가기 핸들러
|
||||||
|
const handleBack = () => {
|
||||||
|
setCurrentView('dashboard');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 시뮬레이션 페이지로 이동
|
||||||
|
const handleSimulation = () => {
|
||||||
|
if (currentAnalysis) {
|
||||||
|
setSelectedAnalysis(currentAnalysis);
|
||||||
|
setCurrentView('simulation');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 숫자 포맷팅
|
||||||
|
const formatPrice = (price: number): string => {
|
||||||
|
if (price >= 1000000) {
|
||||||
|
return `${(price / 1000000).toFixed(1)}M`;
|
||||||
|
} else if (price >= 1000) {
|
||||||
|
return `${(price / 1000).toFixed(1)}K`;
|
||||||
|
}
|
||||||
|
return price.toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatPercent = (percent: number): string => {
|
||||||
|
return `${percent > 0 ? '+' : ''}${percent.toFixed(2)}%`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 등락률에 따른 색상
|
||||||
|
const isPositive = selectedProduct.changeRate >= 0;
|
||||||
|
const changeColor = isPositive ? 'text-success' : 'text-danger';
|
||||||
|
const TrendIcon = isPositive ? TrendingUp : TrendingDown;
|
||||||
|
|
||||||
|
// AI 점수에 따른 색상
|
||||||
|
const getScoreColor = (score: number) => {
|
||||||
|
if (score >= 80) return 'text-success';
|
||||||
|
if (score >= 60) return 'text-warning';
|
||||||
|
return 'text-danger';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="glass rounded-2xl p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<button
|
||||||
|
onClick={handleBack}
|
||||||
|
className="p-2 rounded-lg hover:bg-white/10 transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-6 h-6 text-white/70" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className={`w-12 h-12 rounded-full flex items-center justify-center ${
|
||||||
|
selectedProduct.type === 'crypto' ? 'bg-orange-500/20' : 'bg-blue-500/20'
|
||||||
|
}`}>
|
||||||
|
<span className="text-lg font-bold text-white">
|
||||||
|
{selectedProduct.code.slice(0, 2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">
|
||||||
|
{selectedProduct.name}
|
||||||
|
</h1>
|
||||||
|
<p className="text-white/60">{selectedProduct.code}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSimulation}
|
||||||
|
disabled={!currentAnalysis}
|
||||||
|
className="bg-primary hover:bg-primary-dark px-6 py-3 rounded-lg font-medium text-white transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
수익률 시뮬레이션
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 현재 가격 정보 */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<div className="glass rounded-xl p-4">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<DollarSign className="w-5 h-5 text-primary" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-white/60">현재가</p>
|
||||||
|
<p className="text-xl font-bold text-white">
|
||||||
|
${formatPrice(selectedProduct.currentPrice)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="glass rounded-xl p-4">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<TrendIcon className={`w-5 h-5 ${changeColor}`} />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-white/60">등락률</p>
|
||||||
|
<p className={`text-xl font-bold ${changeColor}`}>
|
||||||
|
{formatPercent(selectedProduct.changeRate)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="glass rounded-xl p-4">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<BarChart3 className="w-5 h-5 text-info" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-white/60">거래량</p>
|
||||||
|
<p className="text-xl font-bold text-white">
|
||||||
|
{formatPrice(selectedProduct.volume)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="glass rounded-xl p-4">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Star className="w-5 h-5 text-warning" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-white/60">AI 점수</p>
|
||||||
|
<p className={`text-xl font-bold ${currentAnalysis ? getScoreColor(currentAnalysis.investScore) : 'text-white/50'}`}>
|
||||||
|
{currentAnalysis ? currentAnalysis.investScore : 'N/A'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||||
|
{/* 차트 영역 */}
|
||||||
|
<div className="xl:col-span-2">
|
||||||
|
<div className="glass rounded-2xl p-6">
|
||||||
|
<h2 className="text-xl font-bold text-white mb-4">가격 차트</h2>
|
||||||
|
<div className="bg-white/5 rounded-lg p-8 text-center">
|
||||||
|
<BarChart3 className="w-16 h-16 text-white/40 mx-auto mb-4" />
|
||||||
|
<p className="text-white/60 text-lg">
|
||||||
|
TradingView 차트가 여기에 표시됩니다
|
||||||
|
</p>
|
||||||
|
<p className="text-white/40 text-sm mt-2">
|
||||||
|
실제 구현 시 TradingView 위젯 연동
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AI 분석 결과 */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* AI 분석 선택 */}
|
||||||
|
{analyses.length > 0 && (
|
||||||
|
<div className="glass rounded-2xl p-6">
|
||||||
|
<h3 className="text-lg font-bold text-white mb-4">AI 분석 결과</h3>
|
||||||
|
|
||||||
|
{analyses.length > 1 && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<select
|
||||||
|
value={selectedAnalysisIndex}
|
||||||
|
onChange={(e) => setSelectedAnalysisIndex(Number(e.target.value))}
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
>
|
||||||
|
{analyses.map((analysis, index) => (
|
||||||
|
<option key={analysis.requestId} value={index}>
|
||||||
|
{new Date(analysis.analysisDate).toLocaleString('ko-KR')} (점수: {analysis.investScore})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentAnalysis && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 투자 지표 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-white/70">진입가</span>
|
||||||
|
<span className="font-medium text-info">
|
||||||
|
${formatPrice(currentAnalysis.entryPrice)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-white/70">목표가</span>
|
||||||
|
<span className="font-medium text-warning">
|
||||||
|
${formatPrice(currentAnalysis.targetPrice)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-white/70">손절가</span>
|
||||||
|
<span className="font-medium text-danger">
|
||||||
|
${formatPrice(currentAnalysis.stopLoss)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center pt-2 border-t border-white/10">
|
||||||
|
<span className="text-white/70">매력도 점수</span>
|
||||||
|
<span className={`font-bold text-lg ${getScoreColor(currentAnalysis.investScore)}`}>
|
||||||
|
{currentAnalysis.investScore}/100
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 분석 근거 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="bg-white/5 rounded-lg p-3">
|
||||||
|
<h4 className="font-medium text-info mb-2">진입 근거</h4>
|
||||||
|
<p className="text-sm text-white/80">{currentAnalysis.entryPriceReason}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white/5 rounded-lg p-3">
|
||||||
|
<h4 className="font-medium text-warning mb-2">목표가 근거</h4>
|
||||||
|
<p className="text-sm text-white/80">{currentAnalysis.targetPriceReason}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white/5 rounded-lg p-3">
|
||||||
|
<h4 className="font-medium text-danger mb-2">손절 근거</h4>
|
||||||
|
<p className="text-sm text-white/80">{currentAnalysis.stopLossReason}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 분석 시간 */}
|
||||||
|
<div className="flex items-center space-x-2 text-xs text-white/50 pt-4 border-t border-white/10">
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
<span>분석 시간: {new Date(currentAnalysis.analysisDate).toLocaleString('ko-KR')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 관련 뉴스 */}
|
||||||
|
<div className="glass rounded-2xl p-6">
|
||||||
|
<div className="flex items-center space-x-2 mb-4">
|
||||||
|
<Newspaper className="w-5 h-5 text-primary" />
|
||||||
|
<h3 className="text-lg font-bold text-white">관련 뉴스</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[1, 2, 3].map((item) => (
|
||||||
|
<div key={item} className="bg-white/5 rounded-lg p-3 hover:bg-white/10 transition-colors duration-200 cursor-pointer">
|
||||||
|
<h4 className="font-medium text-white text-sm mb-1">
|
||||||
|
{selectedProduct.name} 관련 주요 뉴스 제목 #{item}
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-white/60">
|
||||||
|
뉴스 요약 내용이 여기에 표시됩니다...
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-between mt-2">
|
||||||
|
<span className="text-xs text-white/40">출처: 뉴스소스</span>
|
||||||
|
<span className="text-xs text-white/40">1시간 전</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProductDetail;
|
207
imai-invest-manager-AI/src/components/settings/Settings.tsx
Normal file
207
imai-invest-manager-AI/src/components/settings/Settings.tsx
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { ArrowLeft, Settings as SettingsIcon, Sun, Moon, Globe, Bell } from 'lucide-react';
|
||||||
|
import { useAppStore } from '../../stores/useAppStore';
|
||||||
|
|
||||||
|
const Settings: React.FC = () => {
|
||||||
|
const { theme, setTheme, setCurrentView } = useAppStore();
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
setCurrentView('dashboard');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="glass rounded-2xl p-6">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<button
|
||||||
|
onClick={handleBack}
|
||||||
|
className="p-2 rounded-lg hover:bg-white/10 transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-6 h-6 text-white/70" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<SettingsIcon className="w-8 h-8 text-primary" />
|
||||||
|
<h1 className="text-2xl font-bold text-white">설정</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 설정 카테고리 */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{/* 테마 설정 */}
|
||||||
|
<div className="glass rounded-2xl p-6">
|
||||||
|
<div className="flex items-center space-x-3 mb-4">
|
||||||
|
<Sun className="w-6 h-6 text-warning" />
|
||||||
|
<h2 className="text-xl font-bold text-white">테마 설정</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="flex items-center justify-between cursor-pointer">
|
||||||
|
<span className="text-white/80">다크 모드</span>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={theme === 'dark'}
|
||||||
|
onChange={(e) => setTheme(e.target.checked ? 'dark' : 'light')}
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
<div className={`w-12 h-6 rounded-full transition-colors duration-200 ${
|
||||||
|
theme === 'dark' ? 'bg-primary' : 'bg-white/20'
|
||||||
|
}`}>
|
||||||
|
<div className={`w-5 h-5 bg-white rounded-full shadow-md transform transition-transform duration-200 mt-0.5 ${
|
||||||
|
theme === 'dark' ? 'translate-x-6 ml-1' : 'translate-x-1'
|
||||||
|
}`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2 text-sm text-white/60">
|
||||||
|
{theme === 'dark' ? (
|
||||||
|
<>
|
||||||
|
<Moon className="w-4 h-4" />
|
||||||
|
<span>어두운 테마가 활성화되어 있습니다</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Sun className="w-4 h-4" />
|
||||||
|
<span>밝은 테마가 활성화되어 있습니다</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 언어 설정 */}
|
||||||
|
<div className="glass rounded-2xl p-6">
|
||||||
|
<div className="flex items-center space-x-3 mb-4">
|
||||||
|
<Globe className="w-6 h-6 text-info" />
|
||||||
|
<h2 className="text-xl font-bold text-white">언어 설정</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<select className="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-3 text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary">
|
||||||
|
<option value="ko">한국어</option>
|
||||||
|
<option value="en">English</option>
|
||||||
|
<option value="zh">中文 (简体)</option>
|
||||||
|
</select>
|
||||||
|
<p className="text-sm text-white/60">
|
||||||
|
언어 변경 후 페이지가 새로고침됩니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 알림 설정 */}
|
||||||
|
<div className="glass rounded-2xl p-6">
|
||||||
|
<div className="flex items-center space-x-3 mb-4">
|
||||||
|
<Bell className="w-6 h-6 text-success" />
|
||||||
|
<h2 className="text-xl font-bold text-white">알림 설정</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<label className="flex items-center justify-between cursor-pointer">
|
||||||
|
<span className="text-white/80">가격 알림</span>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
defaultChecked
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
<div className="w-12 h-6 bg-primary rounded-full">
|
||||||
|
<div className="w-5 h-5 bg-white rounded-full shadow-md transform translate-x-6 ml-1 mt-0.5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center justify-between cursor-pointer">
|
||||||
|
<span className="text-white/80">AI 분석 완료 알림</span>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
defaultChecked
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
<div className="w-12 h-6 bg-primary rounded-full">
|
||||||
|
<div className="w-5 h-5 bg-white rounded-full shadow-md transform translate-x-6 ml-1 mt-0.5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center justify-between cursor-pointer">
|
||||||
|
<span className="text-white/80">시장 뉴스</span>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
<div className="w-12 h-6 bg-white/20 rounded-full">
|
||||||
|
<div className="w-5 h-5 bg-white rounded-full shadow-md transform translate-x-1 mt-0.5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 기본 설정 */}
|
||||||
|
<div className="glass rounded-2xl p-6">
|
||||||
|
<h2 className="text-xl font-bold text-white mb-4">기본 설정</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-white/80 mb-2">기본 투자 금액</label>
|
||||||
|
<select className="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-3 text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary">
|
||||||
|
<option value="1000000">100만원</option>
|
||||||
|
<option value="5000000">500만원</option>
|
||||||
|
<option value="10000000" selected>1,000만원</option>
|
||||||
|
<option value="50000000">5,000만원</option>
|
||||||
|
<option value="100000000">1억원</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-white/80 mb-2">차트 표시 기간</label>
|
||||||
|
<select className="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-3 text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary">
|
||||||
|
<option value="1d">1일</option>
|
||||||
|
<option value="1w" selected>1주</option>
|
||||||
|
<option value="1m">1개월</option>
|
||||||
|
<option value="3m">3개월</option>
|
||||||
|
<option value="1y">1년</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 정보 섹션 */}
|
||||||
|
<div className="glass rounded-2xl p-6">
|
||||||
|
<h2 className="text-xl font-bold text-white mb-4">앱 정보</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-2xl font-bold text-primary mb-2">v1.0.0</p>
|
||||||
|
<p className="text-white/60">현재 버전</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-2xl font-bold text-success mb-2">10</p>
|
||||||
|
<p className="text-white/60">지원 투자 상품</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-2xl font-bold text-warning mb-2">24/7</p>
|
||||||
|
<p className="text-white/60">실시간 모니터링</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 pt-6 border-t border-white/10">
|
||||||
|
<p className="text-center text-sm text-white/50">
|
||||||
|
© 2024 I'm AI 투자매니저. 투자 정보 제공 목적으로, 투자 권유가 아닙니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Settings;
|
363
imai-invest-manager-AI/src/components/simulation/Simulation.tsx
Normal file
363
imai-invest-manager-AI/src/components/simulation/Simulation.tsx
Normal file
@ -0,0 +1,363 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { ArrowLeft, Calculator, DollarSign, TrendingUp, TrendingDown, AlertTriangle, Target, Star } from 'lucide-react';
|
||||||
|
import { useAppStore } from '../../stores/useAppStore';
|
||||||
|
import type { SimulationResult } from '../../types';
|
||||||
|
|
||||||
|
const Simulation: React.FC = () => {
|
||||||
|
const {
|
||||||
|
selectedProduct,
|
||||||
|
selectedAnalysis,
|
||||||
|
investmentAmount,
|
||||||
|
setInvestmentAmount,
|
||||||
|
setCurrentView,
|
||||||
|
setSimulationResult,
|
||||||
|
simulationResult
|
||||||
|
} = useAppStore();
|
||||||
|
|
||||||
|
const [customAmount, setCustomAmount] = useState('');
|
||||||
|
const [isCustom, setIsCustom] = useState(false);
|
||||||
|
|
||||||
|
// 프리셋 투자 금액
|
||||||
|
const presetAmounts = [
|
||||||
|
{ label: '100만원', value: 1000000 },
|
||||||
|
{ label: '500만원', value: 5000000 },
|
||||||
|
{ label: '1,000만원', value: 10000000 },
|
||||||
|
{ label: '5,000만원', value: 50000000 },
|
||||||
|
{ label: '1억원', value: 100000000 }
|
||||||
|
];
|
||||||
|
|
||||||
|
// 상품이나 분석이 없으면 대시보드로 리다이렉트
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedProduct || !selectedAnalysis) {
|
||||||
|
setCurrentView('dashboard');
|
||||||
|
}
|
||||||
|
}, [selectedProduct, selectedAnalysis, setCurrentView]);
|
||||||
|
|
||||||
|
// 시뮬레이션 계산
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedProduct && selectedAnalysis && investmentAmount > 0) {
|
||||||
|
calculateSimulation();
|
||||||
|
}
|
||||||
|
}, [selectedProduct, selectedAnalysis, investmentAmount]);
|
||||||
|
|
||||||
|
const calculateSimulation = () => {
|
||||||
|
if (!selectedProduct || !selectedAnalysis) return;
|
||||||
|
|
||||||
|
const currentPrice = selectedProduct.currentPrice;
|
||||||
|
const shares = investmentAmount / currentPrice;
|
||||||
|
|
||||||
|
// 목표가 달성 시
|
||||||
|
const targetValue = shares * selectedAnalysis.targetPrice;
|
||||||
|
const targetProfitAmount = targetValue - investmentAmount;
|
||||||
|
const targetProfitRate = (targetProfitAmount / investmentAmount) * 100;
|
||||||
|
|
||||||
|
// 손절가 도달 시
|
||||||
|
const stopLossValue = shares * selectedAnalysis.stopLoss;
|
||||||
|
const stopLossAmount = stopLossValue - investmentAmount;
|
||||||
|
const stopLossRate = (stopLossAmount / investmentAmount) * 100;
|
||||||
|
|
||||||
|
// 위험도 계산
|
||||||
|
const downside = Math.abs(stopLossRate);
|
||||||
|
const upside = targetProfitRate;
|
||||||
|
let riskLevel: 'LOW' | 'MEDIUM' | 'HIGH';
|
||||||
|
|
||||||
|
if (downside <= 10) riskLevel = 'LOW';
|
||||||
|
else if (downside <= 25) riskLevel = 'MEDIUM';
|
||||||
|
else riskLevel = 'HIGH';
|
||||||
|
|
||||||
|
// 기대수익률 (점수 가중)
|
||||||
|
const scoreWeight = selectedAnalysis.investScore / 100;
|
||||||
|
const expectedReturn = (targetProfitRate * scoreWeight + stopLossRate * (1 - scoreWeight));
|
||||||
|
|
||||||
|
const result: SimulationResult = {
|
||||||
|
investmentAmount,
|
||||||
|
selectedAnalysis,
|
||||||
|
targetProfitRate,
|
||||||
|
targetProfitAmount,
|
||||||
|
stopLossRate,
|
||||||
|
stopLossAmount,
|
||||||
|
riskLevel,
|
||||||
|
expectedReturn
|
||||||
|
};
|
||||||
|
|
||||||
|
setSimulationResult(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!selectedProduct || !selectedAnalysis) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 숫자 포맷팅
|
||||||
|
const formatPrice = (price: number): string => {
|
||||||
|
return new Intl.NumberFormat('ko-KR').format(Math.round(price));
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatPercent = (percent: number): string => {
|
||||||
|
return `${percent > 0 ? '+' : ''}${percent.toFixed(2)}%`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 뒤로가기
|
||||||
|
const handleBack = () => {
|
||||||
|
setCurrentView('detail');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 투자금액 설정
|
||||||
|
const handleAmountChange = (amount: number) => {
|
||||||
|
setInvestmentAmount(amount);
|
||||||
|
setIsCustom(false);
|
||||||
|
setCustomAmount('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCustomAmountChange = (value: string) => {
|
||||||
|
setCustomAmount(value);
|
||||||
|
setIsCustom(true);
|
||||||
|
const numValue = parseInt(value.replace(/[^0-9]/g, ''));
|
||||||
|
if (!isNaN(numValue) && numValue > 0) {
|
||||||
|
setInvestmentAmount(numValue);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 위험도 색상
|
||||||
|
const getRiskColor = (level: string) => {
|
||||||
|
switch (level) {
|
||||||
|
case 'LOW': return 'text-success';
|
||||||
|
case 'MEDIUM': return 'text-warning';
|
||||||
|
case 'HIGH': return 'text-danger';
|
||||||
|
default: return 'text-white';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRiskText = (level: string) => {
|
||||||
|
switch (level) {
|
||||||
|
case 'LOW': return '낮음';
|
||||||
|
case 'MEDIUM': return '보통';
|
||||||
|
case 'HIGH': return '높음';
|
||||||
|
default: return '알 수 없음';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="glass rounded-2xl p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<button
|
||||||
|
onClick={handleBack}
|
||||||
|
className="p-2 rounded-lg hover:bg-white/10 transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-6 h-6 text-white/70" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<Calculator className="w-8 h-8 text-primary" />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">
|
||||||
|
수익률 시뮬레이션
|
||||||
|
</h1>
|
||||||
|
<p className="text-white/60">
|
||||||
|
{selectedProduct.name} ({selectedProduct.code})
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-white/60 text-sm">AI 분석 점수</p>
|
||||||
|
<p className="text-2xl font-bold text-warning">
|
||||||
|
{selectedAnalysis.investScore}/100
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||||
|
{/* 투자 설정 */}
|
||||||
|
<div className="xl:col-span-1">
|
||||||
|
<div className="glass rounded-2xl p-6 space-y-6">
|
||||||
|
<h2 className="text-xl font-bold text-white">투자 설정</h2>
|
||||||
|
|
||||||
|
{/* 현재 선택된 AI 분석 정보 */}
|
||||||
|
<div className="bg-white/5 rounded-lg p-4 space-y-3">
|
||||||
|
<h3 className="font-medium text-white mb-3">선택된 AI 분석</h3>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-white/70">진입가</span>
|
||||||
|
<span className="text-info">${formatPrice(selectedAnalysis.entryPrice)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-white/70">목표가</span>
|
||||||
|
<span className="text-warning">${formatPrice(selectedAnalysis.targetPrice)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-white/70">손절가</span>
|
||||||
|
<span className="text-danger">${formatPrice(selectedAnalysis.stopLoss)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm pt-2 border-t border-white/10">
|
||||||
|
<span className="text-white/70">분석 일시</span>
|
||||||
|
<span className="text-white/60 text-xs">
|
||||||
|
{new Date(selectedAnalysis.analysisDate).toLocaleDateString('ko-KR')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 투자 금액 설정 */}
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-white mb-3">투자 금액</h3>
|
||||||
|
<div className="grid grid-cols-1 gap-2">
|
||||||
|
{presetAmounts.map((preset) => (
|
||||||
|
<button
|
||||||
|
key={preset.value}
|
||||||
|
onClick={() => handleAmountChange(preset.value)}
|
||||||
|
className={`p-3 rounded-lg text-left transition-colors duration-200 ${
|
||||||
|
investmentAmount === preset.value && !isCustom
|
||||||
|
? 'bg-primary text-white'
|
||||||
|
: 'bg-white/5 text-white/80 hover:bg-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{preset.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 직접 입력 */}
|
||||||
|
<div className="mt-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="직접 입력 (원)"
|
||||||
|
value={customAmount}
|
||||||
|
onChange={(e) => handleCustomAmountChange(e.target.value)}
|
||||||
|
className="w-full p-3 bg-white/5 border border-white/10 rounded-lg text-white placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 p-3 bg-white/5 rounded-lg">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-white/70">설정된 투자금액</span>
|
||||||
|
<span className="font-bold text-white text-lg">
|
||||||
|
{formatPrice(investmentAmount)}원
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 시뮬레이션 결과 */}
|
||||||
|
<div className="xl:col-span-2 space-y-6">
|
||||||
|
{simulationResult && (
|
||||||
|
<>
|
||||||
|
{/* 수익률 요약 */}
|
||||||
|
<div className="glass rounded-2xl p-6">
|
||||||
|
<h2 className="text-xl font-bold text-white mb-6">시뮬레이션 결과</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{/* 목표가 달성 시나리오 */}
|
||||||
|
<div className="bg-success/10 border border-success/20 rounded-lg p-4">
|
||||||
|
<div className="flex items-center space-x-2 mb-3">
|
||||||
|
<TrendingUp className="w-5 h-5 text-success" />
|
||||||
|
<h3 className="font-bold text-success">목표가 달성 시</h3>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-white/70">투자금액</span>
|
||||||
|
<span className="text-white">{formatPrice(investmentAmount)}원</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-white/70">예상 수익</span>
|
||||||
|
<span className="text-success font-bold">
|
||||||
|
+{formatPrice(simulationResult.targetProfitAmount)}원
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-lg">
|
||||||
|
<span className="text-white">수익률</span>
|
||||||
|
<span className="text-success font-bold">
|
||||||
|
{formatPercent(simulationResult.targetProfitRate)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 손절가 도달 시나리오 */}
|
||||||
|
<div className="bg-danger/10 border border-danger/20 rounded-lg p-4">
|
||||||
|
<div className="flex items-center space-x-2 mb-3">
|
||||||
|
<TrendingDown className="w-5 h-5 text-danger" />
|
||||||
|
<h3 className="font-bold text-danger">손절가 도달 시</h3>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-white/70">투자금액</span>
|
||||||
|
<span className="text-white">{formatPrice(investmentAmount)}원</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-white/70">예상 손실</span>
|
||||||
|
<span className="text-danger font-bold">
|
||||||
|
{formatPrice(simulationResult.stopLossAmount)}원
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-lg">
|
||||||
|
<span className="text-white">손실률</span>
|
||||||
|
<span className="text-danger font-bold">
|
||||||
|
{formatPercent(simulationResult.stopLossRate)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 위험도 분석 */}
|
||||||
|
<div className="glass rounded-2xl p-6">
|
||||||
|
<h2 className="text-xl font-bold text-white mb-6">위험도 분석</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="flex items-center justify-center space-x-2 mb-2">
|
||||||
|
<AlertTriangle className={`w-6 h-6 ${getRiskColor(simulationResult.riskLevel)}`} />
|
||||||
|
<span className="text-white/70">위험도</span>
|
||||||
|
</div>
|
||||||
|
<p className={`text-2xl font-bold ${getRiskColor(simulationResult.riskLevel)}`}>
|
||||||
|
{getRiskText(simulationResult.riskLevel)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="flex items-center justify-center space-x-2 mb-2">
|
||||||
|
<Target className="w-6 h-6 text-info" />
|
||||||
|
<span className="text-white/70">기대수익률</span>
|
||||||
|
</div>
|
||||||
|
<p className={`text-2xl font-bold ${simulationResult.expectedReturn >= 0 ? 'text-success' : 'text-danger'}`}>
|
||||||
|
{formatPercent(simulationResult.expectedReturn)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="flex items-center justify-center space-x-2 mb-2">
|
||||||
|
<Star className="w-6 h-6 text-warning" />
|
||||||
|
<span className="text-white/70">AI 신뢰도</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-warning">
|
||||||
|
{selectedAnalysis.investScore}/100
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 투자 가이드라인 */}
|
||||||
|
<div className="mt-6 p-4 bg-white/5 rounded-lg">
|
||||||
|
<h3 className="font-medium text-white mb-3">투자 가이드라인</h3>
|
||||||
|
<ul className="space-y-2 text-sm text-white/80">
|
||||||
|
<li>• AI 분석은 참고 자료이며, 투자 결정의 전부가 아닙니다</li>
|
||||||
|
<li>• 손실 허용 범위 내에서 투자하시기 바랍니다</li>
|
||||||
|
<li>• 시장 상황에 따라 실제 결과는 달라질 수 있습니다</li>
|
||||||
|
<li>• 분산 투자를 통해 위험을 관리하세요</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Simulation;
|
@ -1,68 +1,206 @@
|
|||||||
:root {
|
@import "tailwindcss";
|
||||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
|
||||||
line-height: 1.5;
|
|
||||||
font-weight: 400;
|
|
||||||
|
|
||||||
color-scheme: light dark;
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||||
color: rgba(255, 255, 255, 0.87);
|
|
||||||
background-color: #242424;
|
|
||||||
|
|
||||||
font-synthesis: none;
|
* {
|
||||||
text-rendering: optimizeLegibility;
|
box-sizing: border-box;
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
font-weight: 500;
|
|
||||||
color: #646cff;
|
|
||||||
text-decoration: inherit;
|
|
||||||
}
|
|
||||||
a:hover {
|
|
||||||
color: #535bf2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
display: flex;
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
||||||
place-items: center;
|
-webkit-font-smoothing: antialiased;
|
||||||
min-width: 320px;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
min-height: 100vh;
|
||||||
|
transition: background-color 0.3s ease, color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark Theme */
|
||||||
|
body.dark {
|
||||||
|
background: linear-gradient(135deg, #0f1419 0%, #1a1f2e 100%);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light Theme */
|
||||||
|
body.light {
|
||||||
|
background: linear-gradient(135deg, #f1f5f9 0%, #e2e8f0 100%);
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
/* Glass Effect Utility */
|
||||||
font-size: 3.2em;
|
.glass {
|
||||||
line-height: 1.1;
|
backdrop-filter: blur(16px);
|
||||||
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
/* Dark Theme Glass */
|
||||||
border-radius: 8px;
|
body.dark .glass {
|
||||||
border: 1px solid transparent;
|
background: rgba(255, 255, 255, 0.05);
|
||||||
padding: 0.6em 1.2em;
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
font-size: 1em;
|
|
||||||
font-weight: 500;
|
|
||||||
font-family: inherit;
|
|
||||||
background-color: #1a1a1a;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 0.25s;
|
|
||||||
}
|
|
||||||
button:hover {
|
|
||||||
border-color: #646cff;
|
|
||||||
}
|
|
||||||
button:focus,
|
|
||||||
button:focus-visible {
|
|
||||||
outline: 4px auto -webkit-focus-ring-color;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: light) {
|
/* Light Theme Glass */
|
||||||
:root {
|
body.light .glass {
|
||||||
color: #213547;
|
background: rgba(255, 255, 255, 0.85);
|
||||||
background-color: #ffffff;
|
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
}
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
|
||||||
a:hover {
|
}
|
||||||
color: #747bff;
|
|
||||||
}
|
/* Advertisement Areas */
|
||||||
button {
|
.ad-banner {
|
||||||
background-color: #f9f9f9;
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
opacity: 0.8;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ad-banner:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ad-side-banner {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 120px;
|
||||||
|
height: 600px;
|
||||||
|
z-index: 40;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ad-side-left {
|
||||||
|
left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ad-side-right {
|
||||||
|
right: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive ad hiding */
|
||||||
|
@media (max-width: 1400px) {
|
||||||
|
.ad-side-banner {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Animation Classes */
|
||||||
|
.animate-pulse-slow {
|
||||||
|
animation: pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-float {
|
||||||
|
animation: float 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float {
|
||||||
|
0%, 100% {
|
||||||
|
transform: translateY(0px);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom Scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Theme-aware text colors */
|
||||||
|
body.dark .text-themed {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.light .text-themed {
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .text-themed-muted {
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.light .text-themed-muted {
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Override white text in light mode */
|
||||||
|
body.light .text-white {
|
||||||
|
color: #334155 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.light .text-white\/60 {
|
||||||
|
color: #64748b !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.light .text-white\/70 {
|
||||||
|
color: #475569 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.light .text-white\/80 {
|
||||||
|
color: #334155 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.light .text-white\/40 {
|
||||||
|
color: #94a3b8 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.light .text-white\/50 {
|
||||||
|
color: #64748b !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input and form styling for light theme */
|
||||||
|
body.light input,
|
||||||
|
body.light select,
|
||||||
|
body.light button {
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.light input::placeholder {
|
||||||
|
color: #94a3b8 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Better contrast for light theme buttons */
|
||||||
|
body.light .bg-white\/5 {
|
||||||
|
background-color: rgba(0, 0, 0, 0.03) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.light .bg-white\/10 {
|
||||||
|
background-color: rgba(0, 0, 0, 0.06) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.light .hover\:bg-white\/10:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.08) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Border colors for light theme */
|
||||||
|
body.light .border-white\/10 {
|
||||||
|
border-color: rgba(0, 0, 0, 0.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.light .border-white\/20 {
|
||||||
|
border-color: rgba(0, 0, 0, 0.15) !important;
|
||||||
|
}
|
||||||
|
112
imai-invest-manager-AI/src/stores/useAppStore.ts
Normal file
112
imai-invest-manager-AI/src/stores/useAppStore.ts
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { devtools } from "zustand/middleware";
|
||||||
|
import type {
|
||||||
|
AppState,
|
||||||
|
InvestProduct,
|
||||||
|
AIAnalysis,
|
||||||
|
NewsArticle,
|
||||||
|
SimulationResult,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
interface AppStore extends AppState {
|
||||||
|
// Actions
|
||||||
|
setProducts: (products: InvestProduct[]) => void;
|
||||||
|
setSelectedProduct: (product: InvestProduct | null) => void;
|
||||||
|
setAnalyses: (investCode: string, analyses: AIAnalysis[]) => void;
|
||||||
|
setSelectedAnalysis: (analysis: AIAnalysis | null) => void;
|
||||||
|
setNews: (news: NewsArticle[]) => void;
|
||||||
|
setCurrentView: (
|
||||||
|
view: "dashboard" | "detail" | "simulation" | "settings"
|
||||||
|
) => void;
|
||||||
|
setTheme: (theme: "dark" | "light") => void;
|
||||||
|
setLoading: (loading: boolean) => void;
|
||||||
|
setSimulationResult: (result: SimulationResult | null) => void;
|
||||||
|
setInvestmentAmount: (amount: number) => void;
|
||||||
|
|
||||||
|
// Computed values
|
||||||
|
getProductByCode: (code: string) => InvestProduct | undefined;
|
||||||
|
getAnalysesByProduct: (code: string) => AIAnalysis[];
|
||||||
|
getLatestAnalysis: (code: string) => AIAnalysis | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAppStore = create<AppStore>()(
|
||||||
|
devtools(
|
||||||
|
(set, get) => ({
|
||||||
|
// Initial state
|
||||||
|
products: [],
|
||||||
|
selectedProduct: null,
|
||||||
|
analyses: {},
|
||||||
|
selectedAnalysis: null,
|
||||||
|
news: [],
|
||||||
|
isLoading: false,
|
||||||
|
currentView: "dashboard",
|
||||||
|
theme: "dark",
|
||||||
|
simulationResult: null,
|
||||||
|
investmentAmount: 10000000, // 기본값 1천만원
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
setProducts: (products) => set({ products }),
|
||||||
|
|
||||||
|
setSelectedProduct: (product) => set({ selectedProduct: product }),
|
||||||
|
|
||||||
|
setAnalyses: (investCode, analyses) =>
|
||||||
|
set((state) => ({
|
||||||
|
analyses: {
|
||||||
|
...state.analyses,
|
||||||
|
[investCode]: analyses,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
|
||||||
|
setSelectedAnalysis: (analysis) => set({ selectedAnalysis: analysis }),
|
||||||
|
|
||||||
|
setNews: (news) => set({ news }),
|
||||||
|
|
||||||
|
setCurrentView: (view) => set({ currentView: view }),
|
||||||
|
|
||||||
|
setTheme: (theme) => {
|
||||||
|
// 로컬 스토리지에 테마 저장
|
||||||
|
localStorage.setItem("theme", theme);
|
||||||
|
set({ theme });
|
||||||
|
},
|
||||||
|
|
||||||
|
setLoading: (loading) => set({ isLoading: loading }),
|
||||||
|
|
||||||
|
setSimulationResult: (result) => set({ simulationResult: result }),
|
||||||
|
|
||||||
|
setInvestmentAmount: (amount) => set({ investmentAmount: amount }),
|
||||||
|
|
||||||
|
// Computed values
|
||||||
|
getProductByCode: (code) => {
|
||||||
|
const { products } = get();
|
||||||
|
return products.find((product) => product.code === code);
|
||||||
|
},
|
||||||
|
|
||||||
|
getAnalysesByProduct: (code) => {
|
||||||
|
const { analyses } = get();
|
||||||
|
return analyses[code] || [];
|
||||||
|
},
|
||||||
|
|
||||||
|
getLatestAnalysis: (code) => {
|
||||||
|
const { analyses } = get();
|
||||||
|
const productAnalyses = analyses[code] || [];
|
||||||
|
if (productAnalyses.length === 0) return undefined;
|
||||||
|
|
||||||
|
// 최신 분석 결과 반환 (분석 날짜 기준)
|
||||||
|
return productAnalyses.sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(b.analysisDate).getTime() -
|
||||||
|
new Date(a.analysisDate).getTime()
|
||||||
|
)[0];
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: "app-store", // devtools에서 표시될 이름
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// 테마 초기화 (로컬 스토리지에서 불러오기)
|
||||||
|
const savedTheme = localStorage.getItem("theme") as "dark" | "light" | null;
|
||||||
|
if (savedTheme) {
|
||||||
|
useAppStore.getState().setTheme(savedTheme);
|
||||||
|
}
|
121
imai-invest-manager-AI/src/types/index.ts
Normal file
121
imai-invest-manager-AI/src/types/index.ts
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
// 투자 상품 타입 정의
|
||||||
|
export interface InvestProduct {
|
||||||
|
code: string; // BTC, ETH, AAPL, etc.
|
||||||
|
name: string; // 상품명
|
||||||
|
type: 'crypto' | 'stock'; // 타입
|
||||||
|
currentPrice: number; // 현재가
|
||||||
|
changeRate: number; // 등락률 (%)
|
||||||
|
changeAmount: number; // 등락액
|
||||||
|
volume: number; // 거래량
|
||||||
|
marketCap?: number; // 시가총액 (주식용)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI 분석 결과 타입
|
||||||
|
export interface AIAnalysis {
|
||||||
|
requestId: number;
|
||||||
|
investCode: string;
|
||||||
|
analysisDate: string;
|
||||||
|
|
||||||
|
// 현금 보유자용 데이터
|
||||||
|
entryPrice: number; // 진입가
|
||||||
|
entryPriceReason: string; // 진입가 이유
|
||||||
|
entryNewsIds: number[]; // 근거 뉴스 ID 리스트
|
||||||
|
|
||||||
|
// 투자상품 보유자용 데이터
|
||||||
|
targetPrice: number; // 목표가
|
||||||
|
targetPriceReason: string; // 목표가 이유
|
||||||
|
targetNewsIds: number[]; // 근거 뉴스 ID 리스트
|
||||||
|
|
||||||
|
stopLoss: number; // 손절가
|
||||||
|
stopLossReason: string; // 손절가 이유
|
||||||
|
stopLossNewsIds: number[]; // 근거 뉴스 ID 리스트
|
||||||
|
|
||||||
|
investScore: number; // 매력도 점수 (0-100)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 뉴스 데이터 타입
|
||||||
|
export interface NewsArticle {
|
||||||
|
newsId: number;
|
||||||
|
investCode: string;
|
||||||
|
source: string; // 뉴스 출처
|
||||||
|
author?: string; // 작성자
|
||||||
|
publishedDate: string; // 발행 일시
|
||||||
|
url?: string; // 기사 URL
|
||||||
|
title: string; // 제목
|
||||||
|
summary: string; // 요약
|
||||||
|
}
|
||||||
|
|
||||||
|
// 캔들 데이터 타입
|
||||||
|
export interface CandleData {
|
||||||
|
investCode: string;
|
||||||
|
interval: 'MI' | 'HO' | 'DA' | 'WE' | 'MO'; // 분/시간/일/주/월
|
||||||
|
timestamp: string;
|
||||||
|
open: number; // 시가
|
||||||
|
high: number; // 고가
|
||||||
|
low: number; // 저가
|
||||||
|
close: number; // 종가
|
||||||
|
volume: number; // 거래량
|
||||||
|
quoteVolume?: number; // 거래대금
|
||||||
|
}
|
||||||
|
|
||||||
|
// 수익률 시뮬레이션 타입
|
||||||
|
export interface SimulationResult {
|
||||||
|
investmentAmount: number; // 투자 금액
|
||||||
|
selectedAnalysis: AIAnalysis; // 선택된 AI 분석
|
||||||
|
|
||||||
|
// 수익률 계산
|
||||||
|
targetProfitRate: number; // 목표가 달성시 수익률
|
||||||
|
targetProfitAmount: number; // 목표가 달성시 수익액
|
||||||
|
stopLossRate: number; // 손절가 도달시 손실률
|
||||||
|
stopLossAmount: number; // 손절가 도달시 손실액
|
||||||
|
|
||||||
|
// 위험도 평가
|
||||||
|
riskLevel: 'LOW' | 'MEDIUM' | 'HIGH';
|
||||||
|
expectedReturn: number; // 기대 수익률
|
||||||
|
}
|
||||||
|
|
||||||
|
// API 응답 타입들
|
||||||
|
export interface MainPageResponse {
|
||||||
|
products: InvestProduct[];
|
||||||
|
latestAnalysis: Record<string, AIAnalysis>; // 투자상품별 최신 AI 분석
|
||||||
|
marketSummary: {
|
||||||
|
totalMarketCap: number;
|
||||||
|
gainersCount: number;
|
||||||
|
losersCount: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductDetailResponse {
|
||||||
|
product: InvestProduct;
|
||||||
|
analyses: AIAnalysis[]; // 여러 AI 분석 결과
|
||||||
|
candleData: CandleData[]; // 차트용 캔들 데이터
|
||||||
|
relatedNews: NewsArticle[]; // 관련 뉴스
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상태 관리용 타입들
|
||||||
|
export interface AppState {
|
||||||
|
// 투자 상품 관련
|
||||||
|
products: InvestProduct[];
|
||||||
|
selectedProduct: InvestProduct | null;
|
||||||
|
|
||||||
|
// AI 분석 관련
|
||||||
|
analyses: Record<string, AIAnalysis[]>; // 투자상품별 AI 분석 리스트
|
||||||
|
selectedAnalysis: AIAnalysis | null;
|
||||||
|
|
||||||
|
// 뉴스 관련
|
||||||
|
news: NewsArticle[];
|
||||||
|
|
||||||
|
// UI 상태
|
||||||
|
isLoading: boolean;
|
||||||
|
currentView: 'dashboard' | 'detail' | 'simulation' | 'settings';
|
||||||
|
theme: 'dark' | 'light';
|
||||||
|
|
||||||
|
// 시뮬레이션 관련
|
||||||
|
simulationResult: SimulationResult | null;
|
||||||
|
investmentAmount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 유틸리티 타입들
|
||||||
|
export type ProductType = 'crypto' | 'stock' | 'all';
|
||||||
|
export type SortBy = 'name' | 'price' | 'change' | 'score';
|
||||||
|
export type SortOrder = 'asc' | 'desc';
|
682
imai-invest-manager-AI/start_dist.sh
Normal file
682
imai-invest-manager-AI/start_dist.sh
Normal file
@ -0,0 +1,682 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# I'm AI 투자매니저 - AWS Lambda 배포 스크립트 (Rocky Linux 10용)
|
||||||
|
# =============================================================================
|
||||||
|
# 이 스크립트는 React 프론트엔드를 AWS Lambda로 자동 배포합니다.
|
||||||
|
# 아키텍처: CloudFront -> Lambda SSR + S3 Static Assets
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
set -e # 에러 발생 시 스크립트 중단
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 설정 변수
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
PROJECT_NAME="imai-invest-manager-ai"
|
||||||
|
AWS_REGION="ap-northeast-2" # 서울 리전
|
||||||
|
S3_BUCKET_NAME="${PROJECT_NAME}-static-assets"
|
||||||
|
LAMBDA_FUNCTION_NAME="${PROJECT_NAME}-ssr"
|
||||||
|
API_GATEWAY_NAME="${PROJECT_NAME}-api"
|
||||||
|
CLOUDFRONT_DISTRIBUTION_ID="" # 첫 배포 후 수동 설정 필요
|
||||||
|
|
||||||
|
# 버전 관리 설정
|
||||||
|
VERSION_DIR="dist_version"
|
||||||
|
BUILD_TIMESTAMP=$(date +"%y%m%d%H%M%S")
|
||||||
|
VERSIONED_DIST="${BUILD_TIMESTAMP}_dist"
|
||||||
|
|
||||||
|
# AWS 자격증명 설정 (우선순위: 환경변수 > 파라미터 > aws configure)
|
||||||
|
AWS_ACCESS_KEY_ID="${AWS_ACCESS_KEY_ID}"
|
||||||
|
AWS_SECRET_ACCESS_KEY="${AWS_SECRET_ACCESS_KEY}"
|
||||||
|
|
||||||
|
# Lambda 설정
|
||||||
|
LAMBDA_RUNTIME="nodejs20.x"
|
||||||
|
LAMBDA_HANDLER="index.handler"
|
||||||
|
LAMBDA_MEMORY=512
|
||||||
|
LAMBDA_TIMEOUT=30
|
||||||
|
|
||||||
|
# 색상 코드
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 유틸리티 함수
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
log_info() {
|
||||||
|
echo -e "${BLUE}[정보]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_success() {
|
||||||
|
echo -e "${GREEN}[성공]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_warning() {
|
||||||
|
echo -e "${YELLOW}[경고]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_error() {
|
||||||
|
echo -e "${RED}[오류]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
load_env_file() {
|
||||||
|
ENV_FILE=".env"
|
||||||
|
|
||||||
|
if [ -f "$ENV_FILE" ]; then
|
||||||
|
log_info ".env 파일을 로드하고 있습니다..."
|
||||||
|
|
||||||
|
# .env 파일에서 AWS 키 추출
|
||||||
|
AWS_ACCESS_KEY_ID=$(grep "^AWS_YOUR_ACCESS_KEY=" "$ENV_FILE" | cut -d '=' -f2 | tr -d ' "'"'"'')
|
||||||
|
AWS_SECRET_ACCESS_KEY=$(grep "^AWS_YOUR_SECRET_KEY=" "$ENV_FILE" | cut -d '=' -f2 | tr -d ' "'"'"'')
|
||||||
|
|
||||||
|
if [ -n "$AWS_ACCESS_KEY_ID" ] && [ -n "$AWS_SECRET_ACCESS_KEY" ]; then
|
||||||
|
export AWS_ACCESS_KEY_ID
|
||||||
|
export AWS_SECRET_ACCESS_KEY
|
||||||
|
log_success ".env 파일에서 AWS 키를 로드했습니다."
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
log_warning ".env 파일에 AWS 키가 올바르게 설정되지 않았습니다."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log_warning ".env 파일이 존재하지 않습니다."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
setup_aws_credentials() {
|
||||||
|
log_info "AWS 자격증명 설정 중..."
|
||||||
|
|
||||||
|
# 1순위: .env 파일에서 로드
|
||||||
|
if load_env_file; then
|
||||||
|
log_info ".env 파일로부터 AWS 키를 사용합니다."
|
||||||
|
# 2순위: 스크립트 파라미터로 키가 전달된 경우
|
||||||
|
elif [ "$#" -ge 2 ]; then
|
||||||
|
export AWS_ACCESS_KEY_ID="$1"
|
||||||
|
export AWS_SECRET_ACCESS_KEY="$2"
|
||||||
|
log_info "스크립트 파라미터로부터 AWS 키를 설정했습니다."
|
||||||
|
# 3순위: 환경변수가 설정된 경우
|
||||||
|
elif [ -n "$AWS_ACCESS_KEY_ID" ] && [ -n "$AWS_SECRET_ACCESS_KEY" ]; then
|
||||||
|
log_info "환경변수로부터 AWS 키를 사용합니다."
|
||||||
|
# 4순위: 대화형으로 키 입력받기
|
||||||
|
elif [ -z "$AWS_ACCESS_KEY_ID" ] || [ -z "$AWS_SECRET_ACCESS_KEY" ]; then
|
||||||
|
log_warning "AWS 자격증명이 설정되지 않았습니다."
|
||||||
|
echo -n "AWS Access Key ID를 입력하세요: "
|
||||||
|
read -r AWS_ACCESS_KEY_ID
|
||||||
|
echo -n "AWS Secret Access Key를 입력하세요: "
|
||||||
|
read -rs AWS_SECRET_ACCESS_KEY
|
||||||
|
echo "" # 줄바꿈
|
||||||
|
|
||||||
|
export AWS_ACCESS_KEY_ID
|
||||||
|
export AWS_SECRET_ACCESS_KEY
|
||||||
|
log_info "대화형으로 AWS 키를 설정했습니다."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# AWS 자격증명 검증
|
||||||
|
if ! aws sts get-caller-identity &> /dev/null; then
|
||||||
|
log_error "AWS 자격증명 검증에 실패했습니다. .env 파일의 키를 확인해주세요."
|
||||||
|
log_error "현재 .env 파일 위치: $(pwd)/.env"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
|
||||||
|
log_success "AWS 계정 인증 완료: $AWS_ACCOUNT_ID"
|
||||||
|
}
|
||||||
|
|
||||||
|
check_dependencies() {
|
||||||
|
log_info "의존성 확인 중..."
|
||||||
|
|
||||||
|
# AWS CLI 확인
|
||||||
|
if ! command -v aws &> /dev/null; then
|
||||||
|
log_error "AWS CLI가 설치되어 있지 않습니다. 먼저 설치해주세요."
|
||||||
|
log_info "설치: curl \"https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip\" -o \"awscliv2.zip\" && unzip awscliv2.zip && sudo ./aws/install"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Node.js 확인
|
||||||
|
if ! command -v node &> /dev/null; then
|
||||||
|
log_error "Node.js가 설치되어 있지 않습니다. 먼저 설치해주세요."
|
||||||
|
log_info "설치: sudo dnf module install nodejs:20/common"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# npm 확인
|
||||||
|
if ! command -v npm &> /dev/null; then
|
||||||
|
log_error "npm이 설치되어 있지 않습니다. 먼저 설치해주세요."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# zip 확인
|
||||||
|
if ! command -v zip &> /dev/null; then
|
||||||
|
log_error "zip이 설치되어 있지 않습니다. 먼저 설치해주세요."
|
||||||
|
log_info "설치: sudo dnf install -y zip unzip"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_success "모든 의존성이 확인되었습니다."
|
||||||
|
}
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 빌드 함수
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
build_frontend() {
|
||||||
|
log_info "프론트엔드 빌드 시작..."
|
||||||
|
|
||||||
|
# package.json 확인
|
||||||
|
if [ ! -f "package.json" ]; then
|
||||||
|
log_error "package.json 파일을 찾을 수 없습니다. 올바른 디렉토리에서 실행하세요."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 의존성 설치
|
||||||
|
log_info "NPM 패키지 설치 중..."
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
# 빌드 실행
|
||||||
|
log_info "Vite 빌드 실행 중..."
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# dist 폴더 확인
|
||||||
|
if [ ! -d "dist" ]; then
|
||||||
|
log_error "빌드 실패: dist 폴더가 생성되지 않았습니다."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_success "프론트엔드 빌드가 완료되었습니다."
|
||||||
|
|
||||||
|
# 버전별 dist 디렉토리 생성 및 백업
|
||||||
|
create_version_backup
|
||||||
|
}
|
||||||
|
|
||||||
|
create_version_backup() {
|
||||||
|
log_info "빌드 버전 백업 생성 중..."
|
||||||
|
|
||||||
|
# dist_version 디렉토리 생성
|
||||||
|
mkdir -p "$VERSION_DIR"
|
||||||
|
|
||||||
|
# 기존 dist를 타임스탬프명으로 복사
|
||||||
|
if [ -d "dist" ]; then
|
||||||
|
cp -r dist "$VERSION_DIR/$VERSIONED_DIST"
|
||||||
|
log_success "빌드 버전이 백업되었습니다: $VERSION_DIR/$VERSIONED_DIST"
|
||||||
|
|
||||||
|
# 버전 정보 파일 생성
|
||||||
|
cat > "$VERSION_DIR/$VERSIONED_DIST/VERSION_INFO.txt" << EOF
|
||||||
|
빌드 시간: $(date '+%Y-%m-%d %H:%M:%S')
|
||||||
|
빌드 버전: $VERSIONED_DIST
|
||||||
|
프로젝트: $PROJECT_NAME
|
||||||
|
AWS 리전: $AWS_REGION
|
||||||
|
빌드 명령: npm run build
|
||||||
|
EOF
|
||||||
|
log_info "버전 정보가 저장되었습니다: $VERSION_DIR/$VERSIONED_DIST/VERSION_INFO.txt"
|
||||||
|
|
||||||
|
# 기존 버전들 확인
|
||||||
|
VERSION_COUNT=$(ls -1 "$VERSION_DIR" | wc -l)
|
||||||
|
log_info "총 $VERSION_COUNT 개의 빌드 버전이 관리되고 있습니다."
|
||||||
|
|
||||||
|
# 10개 이상의 버전이 있으면 오래된 것 삭제
|
||||||
|
if [ "$VERSION_COUNT" -gt 10 ]; then
|
||||||
|
OLD_VERSIONS=$(ls -1t "$VERSION_DIR" | tail -n +11)
|
||||||
|
for old_ver in $OLD_VERSIONS; do
|
||||||
|
rm -rf "$VERSION_DIR/$old_ver"
|
||||||
|
log_warning "오래된 버전을 삭제했습니다: $old_ver"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log_error "dist 폴더가 존재하지 않습니다."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
create_lambda_package() {
|
||||||
|
log_info "Lambda 패키지 생성 중..."
|
||||||
|
|
||||||
|
# 임시 디렉토리 생성
|
||||||
|
TEMP_DIR="temp_lambda"
|
||||||
|
rm -rf $TEMP_DIR
|
||||||
|
mkdir -p $TEMP_DIR
|
||||||
|
|
||||||
|
# Lambda 핸들러 생성
|
||||||
|
cat > $TEMP_DIR/index.js << 'EOF'
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// HTML 템플릿 로드
|
||||||
|
const htmlTemplate = fs.readFileSync(path.join(__dirname, 'index.html'), 'utf8');
|
||||||
|
|
||||||
|
exports.handler = async (event) => {
|
||||||
|
try {
|
||||||
|
// CloudFront에서 오는 요청 처리
|
||||||
|
const request = event.Records ? event.Records[0].cf.request : event;
|
||||||
|
|
||||||
|
// 기본 HTML 응답
|
||||||
|
const response = {
|
||||||
|
status: '200',
|
||||||
|
statusDescription: 'OK',
|
||||||
|
headers: {
|
||||||
|
'content-type': [{
|
||||||
|
key: 'Content-Type',
|
||||||
|
value: 'text/html; charset=utf-8'
|
||||||
|
}],
|
||||||
|
'cache-control': [{
|
||||||
|
key: 'Cache-Control',
|
||||||
|
value: 'public, max-age=300'
|
||||||
|
}],
|
||||||
|
'x-content-type-options': [{
|
||||||
|
key: 'X-Content-Type-Options',
|
||||||
|
value: 'nosniff'
|
||||||
|
}],
|
||||||
|
'x-frame-options': [{
|
||||||
|
key: 'X-Frame-Options',
|
||||||
|
value: 'DENY'
|
||||||
|
}],
|
||||||
|
'x-xss-protection': [{
|
||||||
|
key: 'X-XSS-Protection',
|
||||||
|
value: '1; mode=block'
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
body: htmlTemplate
|
||||||
|
};
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Lambda Error:', error);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: '500',
|
||||||
|
statusDescription: 'Internal Server Error',
|
||||||
|
headers: {
|
||||||
|
'content-type': [{
|
||||||
|
key: 'Content-Type',
|
||||||
|
value: 'text/plain'
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
body: 'Internal Server Error'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# package.json 생성
|
||||||
|
cat > $TEMP_DIR/package.json << EOF
|
||||||
|
{
|
||||||
|
"name": "lambda-ssr",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "index.js",
|
||||||
|
"dependencies": {}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# HTML 파일 복사 (정적 에셋 경로 수정)
|
||||||
|
cp dist/index.html $TEMP_DIR/
|
||||||
|
|
||||||
|
# S3 경로로 정적 에셋 경로 수정
|
||||||
|
sed -i "s|/assets/|/static-assets/assets/|g" $TEMP_DIR/index.html
|
||||||
|
sed -i "s|href=\"/|href=\"/static-assets/|g" $TEMP_DIR/index.html
|
||||||
|
sed -i "s|src=\"/|src=\"/static-assets/|g" $TEMP_DIR/index.html
|
||||||
|
|
||||||
|
# Lambda 패키지 생성
|
||||||
|
cd $TEMP_DIR
|
||||||
|
zip -r ../lambda-function.zip .
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
# 임시 폴더 정리
|
||||||
|
rm -rf $TEMP_DIR
|
||||||
|
|
||||||
|
log_success "Lambda 패키지가 생성되었습니다."
|
||||||
|
}
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# AWS 리소스 생성/업데이트 함수
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
create_s3_bucket() {
|
||||||
|
log_info "S3 버킷 생성 중..."
|
||||||
|
|
||||||
|
# 버킷 존재 확인
|
||||||
|
if aws s3api head-bucket --bucket $S3_BUCKET_NAME 2>/dev/null; then
|
||||||
|
log_warning "S3 버킷이 이미 존재합니다: $S3_BUCKET_NAME"
|
||||||
|
else
|
||||||
|
# 버킷 생성
|
||||||
|
aws s3api create-bucket \
|
||||||
|
--bucket $S3_BUCKET_NAME \
|
||||||
|
--region $AWS_REGION \
|
||||||
|
--create-bucket-configuration LocationConstraint=$AWS_REGION
|
||||||
|
|
||||||
|
# 퍼블릭 액세스 차단 해제 (정적 에셋용)
|
||||||
|
aws s3api delete-public-access-block --bucket $S3_BUCKET_NAME
|
||||||
|
|
||||||
|
# 버킷 정책 설정
|
||||||
|
cat > bucket-policy.json << EOF
|
||||||
|
{
|
||||||
|
"Version": "2012-10-17",
|
||||||
|
"Statement": [
|
||||||
|
{
|
||||||
|
"Sid": "PublicReadGetObject",
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Principal": "*",
|
||||||
|
"Action": "s3:GetObject",
|
||||||
|
"Resource": "arn:aws:s3:::$S3_BUCKET_NAME/*"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
aws s3api put-bucket-policy --bucket $S3_BUCKET_NAME --policy file://bucket-policy.json
|
||||||
|
rm bucket-policy.json
|
||||||
|
|
||||||
|
log_success "S3 버킷이 생성되었습니다: $S3_BUCKET_NAME"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
upload_static_assets() {
|
||||||
|
log_info "정적 에셋 S3 업로드 중..."
|
||||||
|
|
||||||
|
# 정적 에셋 업로드 (cache-control 헤더 설정)
|
||||||
|
aws s3 sync dist/ s3://$S3_BUCKET_NAME/static-assets/ \
|
||||||
|
--cache-control "public, max-age=31536000" \
|
||||||
|
--exclude "index.html"
|
||||||
|
|
||||||
|
# 특별 파일들 (짧은 캐시)
|
||||||
|
if [ -f "dist/vite.svg" ]; then
|
||||||
|
aws s3 cp dist/vite.svg s3://$S3_BUCKET_NAME/static-assets/ \
|
||||||
|
--cache-control "public, max-age=86400"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_success "정적 에셋이 S3에 업로드되었습니다."
|
||||||
|
}
|
||||||
|
|
||||||
|
create_lambda_role() {
|
||||||
|
log_info "Lambda IAM 역할 확인/생성 중..."
|
||||||
|
|
||||||
|
ROLE_NAME="${PROJECT_NAME}-lambda-role"
|
||||||
|
|
||||||
|
# 역할 존재 확인
|
||||||
|
if aws iam get-role --role-name $ROLE_NAME 2>/dev/null; then
|
||||||
|
log_warning "IAM 역할이 이미 존재합니다: $ROLE_NAME"
|
||||||
|
echo "arn:aws:iam::$(aws sts get-caller-identity --query Account --output text):role/$ROLE_NAME"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 신뢰 정책 생성
|
||||||
|
cat > trust-policy.json << EOF
|
||||||
|
{
|
||||||
|
"Version": "2012-10-17",
|
||||||
|
"Statement": [
|
||||||
|
{
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Principal": {
|
||||||
|
"Service": [
|
||||||
|
"lambda.amazonaws.com",
|
||||||
|
"edgelambda.amazonaws.com"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Action": "sts:AssumeRole"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# 역할 생성
|
||||||
|
aws iam create-role \
|
||||||
|
--role-name $ROLE_NAME \
|
||||||
|
--assume-role-policy-document file://trust-policy.json
|
||||||
|
|
||||||
|
# 권한 정책 연결
|
||||||
|
aws iam attach-role-policy \
|
||||||
|
--role-name $ROLE_NAME \
|
||||||
|
--policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
|
||||||
|
|
||||||
|
# S3 읽기 권한 추가
|
||||||
|
cat > s3-policy.json << EOF
|
||||||
|
{
|
||||||
|
"Version": "2012-10-17",
|
||||||
|
"Statement": [
|
||||||
|
{
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": [
|
||||||
|
"s3:GetObject"
|
||||||
|
],
|
||||||
|
"Resource": "arn:aws:s3:::$S3_BUCKET_NAME/*"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
aws iam put-role-policy \
|
||||||
|
--role-name $ROLE_NAME \
|
||||||
|
--policy-name S3ReadPolicy \
|
||||||
|
--policy-document file://s3-policy.json
|
||||||
|
|
||||||
|
rm trust-policy.json s3-policy.json
|
||||||
|
|
||||||
|
# 역할이 생성될 때까지 대기
|
||||||
|
sleep 10
|
||||||
|
|
||||||
|
ROLE_ARN="arn:aws:iam::$(aws sts get-caller-identity --query Account --output text):role/$ROLE_NAME"
|
||||||
|
log_success "IAM 역할이 생성되었습니다: $ROLE_ARN"
|
||||||
|
echo $ROLE_ARN
|
||||||
|
}
|
||||||
|
|
||||||
|
deploy_lambda() {
|
||||||
|
log_info "Lambda 함수 배포 중..."
|
||||||
|
|
||||||
|
ROLE_ARN=$(create_lambda_role)
|
||||||
|
|
||||||
|
# 함수 존재 확인
|
||||||
|
if aws lambda get-function --function-name $LAMBDA_FUNCTION_NAME 2>/dev/null; then
|
||||||
|
log_info "기존 Lambda 함수 업데이트 중..."
|
||||||
|
|
||||||
|
# 함수 코드 업데이트
|
||||||
|
aws lambda update-function-code \
|
||||||
|
--function-name $LAMBDA_FUNCTION_NAME \
|
||||||
|
--zip-file fileb://lambda-function.zip
|
||||||
|
|
||||||
|
# 함수 구성 업데이트
|
||||||
|
aws lambda update-function-configuration \
|
||||||
|
--function-name $LAMBDA_FUNCTION_NAME \
|
||||||
|
--runtime $LAMBDA_RUNTIME \
|
||||||
|
--handler $LAMBDA_HANDLER \
|
||||||
|
--memory-size $LAMBDA_MEMORY \
|
||||||
|
--timeout $LAMBDA_TIMEOUT
|
||||||
|
else
|
||||||
|
log_info "새 Lambda 함수 생성 중..."
|
||||||
|
|
||||||
|
# 함수 생성
|
||||||
|
aws lambda create-function \
|
||||||
|
--function-name $LAMBDA_FUNCTION_NAME \
|
||||||
|
--runtime $LAMBDA_RUNTIME \
|
||||||
|
--role $ROLE_ARN \
|
||||||
|
--handler $LAMBDA_HANDLER \
|
||||||
|
--zip-file fileb://lambda-function.zip \
|
||||||
|
--memory-size $LAMBDA_MEMORY \
|
||||||
|
--timeout $LAMBDA_TIMEOUT \
|
||||||
|
--description "I'm AI Invest Manager Frontend SSR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Lambda 함수가 활성화될 때까지 대기
|
||||||
|
aws lambda wait function-active --function-name $LAMBDA_FUNCTION_NAME
|
||||||
|
|
||||||
|
log_success "Lambda 함수가 배포되었습니다."
|
||||||
|
}
|
||||||
|
|
||||||
|
create_api_gateway() {
|
||||||
|
log_info "API Gateway 생성 중..."
|
||||||
|
|
||||||
|
# API Gateway 존재 확인
|
||||||
|
API_ID=$(aws apigateway get-rest-apis --query "items[?name=='$API_GATEWAY_NAME'].id" --output text)
|
||||||
|
|
||||||
|
if [ -n "$API_ID" ] && [ "$API_ID" != "None" ]; then
|
||||||
|
log_warning "API Gateway가 이미 존재합니다: $API_ID"
|
||||||
|
else
|
||||||
|
# API Gateway 생성
|
||||||
|
API_ID=$(aws apigateway create-rest-api \
|
||||||
|
--name $API_GATEWAY_NAME \
|
||||||
|
--description "I'm AI Invest Manager API" \
|
||||||
|
--query 'id' --output text)
|
||||||
|
|
||||||
|
log_info "API Gateway ID: $API_ID"
|
||||||
|
|
||||||
|
# 루트 리소스 가져오기
|
||||||
|
ROOT_RESOURCE_ID=$(aws apigateway get-resources \
|
||||||
|
--rest-api-id $API_ID \
|
||||||
|
--query 'items[?path==`/`].id' --output text)
|
||||||
|
|
||||||
|
# Proxy 리소스 생성
|
||||||
|
RESOURCE_ID=$(aws apigateway create-resource \
|
||||||
|
--rest-api-id $API_ID \
|
||||||
|
--parent-id $ROOT_RESOURCE_ID \
|
||||||
|
--path-part '{proxy+}' \
|
||||||
|
--query 'id' --output text)
|
||||||
|
|
||||||
|
# Lambda 통합을 위한 ARN
|
||||||
|
LAMBDA_ARN="arn:aws:lambda:$AWS_REGION:$(aws sts get-caller-identity --query Account --output text):function:$LAMBDA_FUNCTION_NAME"
|
||||||
|
|
||||||
|
# ANY 메서드 생성
|
||||||
|
aws apigateway put-method \
|
||||||
|
--rest-api-id $API_ID \
|
||||||
|
--resource-id $RESOURCE_ID \
|
||||||
|
--http-method ANY \
|
||||||
|
--authorization-type NONE
|
||||||
|
|
||||||
|
# Lambda 통합 설정
|
||||||
|
aws apigateway put-integration \
|
||||||
|
--rest-api-id $API_ID \
|
||||||
|
--resource-id $RESOURCE_ID \
|
||||||
|
--http-method ANY \
|
||||||
|
--type AWS_PROXY \
|
||||||
|
--integration-http-method POST \
|
||||||
|
--uri "arn:aws:apigateway:$AWS_REGION:lambda:path/2015-03-31/functions/$LAMBDA_ARN/invocations"
|
||||||
|
|
||||||
|
# Lambda 권한 추가
|
||||||
|
aws lambda add-permission \
|
||||||
|
--function-name $LAMBDA_FUNCTION_NAME \
|
||||||
|
--statement-id apigateway-invoke \
|
||||||
|
--action lambda:InvokeFunction \
|
||||||
|
--principal apigateway.amazonaws.com \
|
||||||
|
--source-arn "arn:aws:execute-api:$AWS_REGION:$(aws sts get-caller-identity --query Account --output text):$API_ID/*/*/*" \
|
||||||
|
2>/dev/null || true
|
||||||
|
|
||||||
|
# 배포
|
||||||
|
aws apigateway create-deployment \
|
||||||
|
--rest-api-id $API_ID \
|
||||||
|
--stage-name prod
|
||||||
|
|
||||||
|
log_success "API Gateway가 생성되었습니다: $API_ID"
|
||||||
|
fi
|
||||||
|
|
||||||
|
API_URL="https://$API_ID.execute-api.$AWS_REGION.amazonaws.com/prod"
|
||||||
|
log_info "API Gateway URL: $API_URL"
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidate_cloudfront() {
|
||||||
|
if [ -n "$CLOUDFRONT_DISTRIBUTION_ID" ]; then
|
||||||
|
log_info "CloudFront 캐시 무효화 중..."
|
||||||
|
|
||||||
|
INVALIDATION_ID=$(aws cloudfront create-invalidation \
|
||||||
|
--distribution-id $CLOUDFRONT_DISTRIBUTION_ID \
|
||||||
|
--paths "/*" \
|
||||||
|
--query 'Invalidation.Id' --output text)
|
||||||
|
|
||||||
|
log_success "CloudFront 무효화 요청이 생성되었습니다: $INVALIDATION_ID"
|
||||||
|
else
|
||||||
|
log_warning "CLOUDFRONT_DISTRIBUTION_ID가 설정되지 않았습니다. 수동으로 CloudFront를 설정해주세요."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
log_info "임시 파일 정리 중..."
|
||||||
|
rm -f lambda-function.zip
|
||||||
|
log_success "정리가 완료되었습니다."
|
||||||
|
}
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 메인 실행 함수
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
show_usage() {
|
||||||
|
echo "사용법: $0 [AWS_ACCESS_KEY_ID] [AWS_SECRET_ACCESS_KEY]"
|
||||||
|
echo ""
|
||||||
|
echo "AWS 자격증명 제공 방법 (우선순위 순):"
|
||||||
|
echo "1. .env 파일: 프로젝트 루트의 .env 파일에서 자동 로드"
|
||||||
|
echo "2. 스크립트 파라미터: ./start_dist.sh ACCESS_KEY SECRET_KEY"
|
||||||
|
echo "3. 환경변수: export AWS_ACCESS_KEY_ID=... && export AWS_SECRET_ACCESS_KEY=..."
|
||||||
|
echo "4. 대화형 입력: 스크립트 실행 중 키 입력"
|
||||||
|
echo "5. AWS CLI 프로필: aws configure로 미리 설정"
|
||||||
|
echo ""
|
||||||
|
echo ".env 파일 형식:"
|
||||||
|
echo " [AWS KEY INFO]"
|
||||||
|
echo " AWS_YOUR_ACCESS_KEY=AKIA1234567890EXAMPLE"
|
||||||
|
echo " AWS_YOUR_SECRET_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
|
||||||
|
echo ""
|
||||||
|
echo "예제:"
|
||||||
|
echo " # .env 파일 사용 (권장)"
|
||||||
|
echo " ./start_dist.sh"
|
||||||
|
echo ""
|
||||||
|
echo " # 파라미터 직접 전달"
|
||||||
|
echo " ./start_dist.sh AKIA... wJalr..."
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
# 도움말 표시
|
||||||
|
if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then
|
||||||
|
show_usage
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "=== I'm AI 투자매니저 AWS Lambda 배포 시작 ==="
|
||||||
|
log_info "프로젝트: $PROJECT_NAME"
|
||||||
|
log_info "AWS 리전: $AWS_REGION"
|
||||||
|
log_info "현재 디렉토리: $(pwd)"
|
||||||
|
log_info "빌드 버전: $VERSIONED_DIST"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 의존성 확인
|
||||||
|
check_dependencies
|
||||||
|
|
||||||
|
# AWS 자격증명 설정
|
||||||
|
setup_aws_credentials "$@"
|
||||||
|
|
||||||
|
# 빌드 및 패키징
|
||||||
|
build_frontend
|
||||||
|
create_lambda_package
|
||||||
|
|
||||||
|
# AWS 리소스 생성/업데이트
|
||||||
|
create_s3_bucket
|
||||||
|
upload_static_assets
|
||||||
|
deploy_lambda
|
||||||
|
create_api_gateway
|
||||||
|
|
||||||
|
# 캐시 무효화
|
||||||
|
invalidate_cloudfront
|
||||||
|
|
||||||
|
# 정리
|
||||||
|
cleanup
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
log_success "=== 배포가 완료되었습니다! ==="
|
||||||
|
log_info "빌드 버전: $VERSIONED_DIST"
|
||||||
|
log_info "백업 위치: $VERSION_DIR/$VERSIONED_DIST"
|
||||||
|
log_info "S3 버킷: https://s3.console.aws.amazon.com/s3/buckets/$S3_BUCKET_NAME"
|
||||||
|
log_info "Lambda 함수: https://$AWS_REGION.console.aws.amazon.com/lambda/home?region=$AWS_REGION#/functions/$LAMBDA_FUNCTION_NAME"
|
||||||
|
|
||||||
|
if [ -n "$API_ID" ]; then
|
||||||
|
log_info "API Gateway: https://$API_ID.execute-api.$AWS_REGION.amazonaws.com/prod"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
log_warning "CloudFront 설정이 필요합니다:"
|
||||||
|
log_info "1. AWS Console에서 CloudFront Distribution 생성"
|
||||||
|
log_info "2. Origin: API Gateway (SSR) + S3 (Static Assets)"
|
||||||
|
log_info "3. CLOUDFRONT_DISTRIBUTION_ID 변수에 Distribution ID 설정"
|
||||||
|
echo ""
|
||||||
|
log_success "배포가 성공적으로 완료되었습니다."
|
||||||
|
}
|
||||||
|
|
||||||
|
# 스크립트 실행
|
||||||
|
main "$@"
|
43
imai-invest-manager-AI/tailwind.config.js
Normal file
43
imai-invest-manager-AI/tailwind.config.js
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
background: {
|
||||||
|
DEFAULT: '#0f1419',
|
||||||
|
secondary: '#1a1f2e',
|
||||||
|
},
|
||||||
|
primary: {
|
||||||
|
DEFAULT: '#667eea',
|
||||||
|
dark: '#764ba2',
|
||||||
|
},
|
||||||
|
success: '#22c55e',
|
||||||
|
danger: '#ef4444',
|
||||||
|
info: '#3b82f6',
|
||||||
|
warning: '#f59e0b',
|
||||||
|
// Light theme colors
|
||||||
|
light: {
|
||||||
|
bg: '#f1f5f9',
|
||||||
|
secondary: '#e2e8f0',
|
||||||
|
text: '#334155',
|
||||||
|
muted: '#64748b',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
backgroundImage: {
|
||||||
|
'gradient-primary': 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||||
|
'gradient-background': 'linear-gradient(135deg, #0f1419 0%, #1a1f2e 100%)',
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
'glass': '0 8px 32px rgba(0, 0, 0, 0.3)',
|
||||||
|
},
|
||||||
|
backdropBlur: {
|
||||||
|
'glass': '16px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
BIN
설계/01/UI상태/UI상태.png
Normal file
BIN
설계/01/UI상태/UI상태.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 47 KiB |
@ -3,20 +3,18 @@
|
|||||||
## 📁 파일 구조
|
## 📁 파일 구조
|
||||||
|
|
||||||
### 🌟 메인 프로토타입 파일들
|
### 🌟 메인 프로토타입 파일들
|
||||||
|
|
||||||
- **`index.html`** - 완전한 통합 프로토타입 (로딩 + 페이지 전환 + 광고)
|
- **`index.html`** - 완전한 통합 프로토타입 (로딩 + 페이지 전환 + 광고)
|
||||||
- **`dashboard.html`** - 메인 대시보드 (깔끔한 단일 페이지)
|
- **`dashboard.html`** - 메인 대시보드 (깔끔한 단일 페이지)
|
||||||
- **`detail.html`** - 투자상품 상세 분석 페이지
|
- **`detail.html`** - 투자상품 상세 분석 페이지
|
||||||
- **`simulation.html`** - 수익률 시뮬레이션 페이지
|
- **`simulation.html`** - 수익률 시뮬레이션 페이지
|
||||||
|
|
||||||
### 📱 기존 앱 설계 파일들
|
|
||||||
- `app_01.html` ~ `app_04.html` - 모바일 앱 프로토타입
|
|
||||||
- `web_01.html`, `web_03.html`, `web_04.html` - 웹 버전 초기 설계
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🎯 메인 파일 설명
|
## 🎯 메인 파일 설명
|
||||||
|
|
||||||
### 1. **index.html** - 완전한 프로토타입
|
### 1. **index.html** - 완전한 프로토타입
|
||||||
|
|
||||||
- ✨ 로딩 애니메이션 포함
|
- ✨ 로딩 애니메이션 포함
|
||||||
- 🔄 페이지 전환 기능 (SPA 방식)
|
- 🔄 페이지 전환 기능 (SPA 방식)
|
||||||
- 📱 완전 반응형 디자인
|
- 📱 완전 반응형 디자인
|
||||||
@ -24,18 +22,21 @@
|
|||||||
- ⚡ 실시간 가격 업데이트 시뮬레이션
|
- ⚡ 실시간 가격 업데이트 시뮬레이션
|
||||||
|
|
||||||
### 2. **dashboard.html** - 메인 대시보드
|
### 2. **dashboard.html** - 메인 대시보드
|
||||||
|
|
||||||
- 🤖 AI 추천 투자상품 그리드
|
- 🤖 AI 추천 투자상품 그리드
|
||||||
- 📊 실시간 가격 및 변동률
|
- 📊 실시간 가격 및 변동률
|
||||||
- 🎯 AI 분석 점수 및 투자 지표
|
- 🎯 AI 분석 점수 및 투자 지표
|
||||||
- 📈 시장 개요 통계
|
- 📈 시장 개요 통계
|
||||||
|
|
||||||
### 3. **detail.html** - 상세 분석 페이지
|
### 3. **detail.html** - 상세 분석 페이지
|
||||||
|
|
||||||
- 📈 차트 영역 (TradingView 연동 준비)
|
- 📈 차트 영역 (TradingView 연동 준비)
|
||||||
- 🧠 AI 분석 리포트 (기술적/펀더멘털/위험)
|
- 🧠 AI 분석 리포트 (기술적/펀더멘털/위험)
|
||||||
- 📰 관련 뉴스 섹션
|
- 📰 관련 뉴스 섹션
|
||||||
- 💡 투자 지표 상세 정보
|
- 💡 투자 지표 상세 정보
|
||||||
|
|
||||||
### 4. **simulation.html** - 수익률 시뮬레이션
|
### 4. **simulation.html** - 수익률 시뮬레이션
|
||||||
|
|
||||||
- 💰 투자 금액 설정 (프리셋 버튼)
|
- 💰 투자 금액 설정 (프리셋 버튼)
|
||||||
- ✅ 다중 투자상품 선택
|
- ✅ 다중 투자상품 선택
|
||||||
- 📊 실시간 수익률 계산
|
- 📊 실시간 수익률 계산
|
||||||
@ -46,6 +47,7 @@
|
|||||||
## 🎨 디자인 특징
|
## 🎨 디자인 특징
|
||||||
|
|
||||||
### 색상 체계
|
### 색상 체계
|
||||||
|
|
||||||
- **배경**: `#0f1419` → `#1a1f2e` 그라디언트
|
- **배경**: `#0f1419` → `#1a1f2e` 그라디언트
|
||||||
- **브랜드**: `#667eea` → `#764ba2` 그라디언트
|
- **브랜드**: `#667eea` → `#764ba2` 그라디언트
|
||||||
- **성공**: `#22c55e` (상승/수익)
|
- **성공**: `#22c55e` (상승/수익)
|
||||||
@ -54,6 +56,7 @@
|
|||||||
- **경고**: `#f59e0b` (목표가)
|
- **경고**: `#f59e0b` (목표가)
|
||||||
|
|
||||||
### UI 특징
|
### UI 특징
|
||||||
|
|
||||||
- **Glassmorphism**: 반투명 배경 + 블러 효과
|
- **Glassmorphism**: 반투명 배경 + 블러 효과
|
||||||
- **둥근 모서리**: 16px 기본, 8-12px 세부 요소
|
- **둥근 모서리**: 16px 기본, 8-12px 세부 요소
|
||||||
- **그림자**: `0 8px 32px rgba(0,0,0,0.3)` 깊이감
|
- **그림자**: `0 8px 32px rgba(0,0,0,0.3)` 깊이감
|
||||||
@ -64,11 +67,13 @@
|
|||||||
## 💰 광고 영역 배치
|
## 💰 광고 영역 배치
|
||||||
|
|
||||||
### 프리미엄 광고 영역
|
### 프리미엄 광고 영역
|
||||||
|
|
||||||
1. **좌우 사이드 배너** (120x600px) - 고정 위치
|
1. **좌우 사이드 배너** (120x600px) - 고정 위치
|
||||||
2. **헤더 상단 배너** - 페이지 최상단
|
2. **헤더 상단 배너** - 페이지 최상단
|
||||||
3. **메인 상단 배너** (1400x100px) - 콘텐츠 시작
|
3. **메인 상단 배너** (1400x100px) - 콘텐츠 시작
|
||||||
|
|
||||||
### 콘텐츠 통합 광고
|
### 콘텐츠 통합 광고
|
||||||
|
|
||||||
4. **사이드바 광고** (320x200px) - 2개 영역
|
4. **사이드바 광고** (320x200px) - 2개 영역
|
||||||
5. **인라인 광고** - 콘텐츠 중간 삽입
|
5. **인라인 광고** - 콘텐츠 중간 삽입
|
||||||
6. **하단 대형 배너** (1400x200px) - CTA 포함
|
6. **하단 대형 배너** (1400x200px) - CTA 포함
|
||||||
@ -78,21 +83,25 @@
|
|||||||
## 📱 반응형 브레이크포인트
|
## 📱 반응형 브레이크포인트
|
||||||
|
|
||||||
### 데스크톱 (1400px+)
|
### 데스크톱 (1400px+)
|
||||||
|
|
||||||
- 모든 광고 영역 표시
|
- 모든 광고 영역 표시
|
||||||
- 3열 그리드 레이아웃
|
- 3열 그리드 레이아웃
|
||||||
- 사이드 배너 고정
|
- 사이드 배너 고정
|
||||||
|
|
||||||
### 태블릿 (1200px~1399px)
|
### 태블릿 (1200px~1399px)
|
||||||
|
|
||||||
- 사이드 배너 숨김
|
- 사이드 배너 숨김
|
||||||
- 2열 그리드로 변경
|
- 2열 그리드로 변경
|
||||||
- 사이드바가 상단으로 이동
|
- 사이드바가 상단으로 이동
|
||||||
|
|
||||||
### 모바일 (768px~1199px)
|
### 모바일 (768px~1199px)
|
||||||
|
|
||||||
- 1열 그리드 레이아웃
|
- 1열 그리드 레이아웃
|
||||||
- 헤더 세로 정렬
|
- 헤더 세로 정렬
|
||||||
- 컴팩트한 카드 디자인
|
- 컴팩트한 카드 디자인
|
||||||
|
|
||||||
### 소형 모바일 (480px 이하)
|
### 소형 모바일 (480px 이하)
|
||||||
|
|
||||||
- 최소 레이아웃
|
- 최소 레이아웃
|
||||||
- 버튼 세로 배치
|
- 버튼 세로 배치
|
||||||
- 간소화된 정보 표시
|
- 간소화된 정보 표시
|
||||||
@ -102,18 +111,21 @@
|
|||||||
## 🚀 기술적 특징
|
## 🚀 기술적 특징
|
||||||
|
|
||||||
### 성능 최적화
|
### 성능 최적화
|
||||||
|
|
||||||
- **CSS Grid & Flexbox** 활용
|
- **CSS Grid & Flexbox** 활용
|
||||||
- **Transform 애니메이션** GPU 가속
|
- **Transform 애니메이션** GPU 가속
|
||||||
- **Lazy Loading** 준비
|
- **Lazy Loading** 준비
|
||||||
- **Code Splitting** 구조
|
- **Code Splitting** 구조
|
||||||
|
|
||||||
### 접근성
|
### 접근성
|
||||||
|
|
||||||
- **시맨틱 HTML** 구조
|
- **시맨틱 HTML** 구조
|
||||||
- **키보드 네비게이션** 지원
|
- **키보드 네비게이션** 지원
|
||||||
- **스크린 리더** 호환
|
- **스크린 리더** 호환
|
||||||
- **고대비** 색상 대비
|
- **고대비** 색상 대비
|
||||||
|
|
||||||
### 확장성
|
### 확장성
|
||||||
|
|
||||||
- **컴포넌트 기반** CSS 구조
|
- **컴포넌트 기반** CSS 구조
|
||||||
- **모듈화된** JavaScript
|
- **모듈화된** JavaScript
|
||||||
- **API 연동** 준비 완료
|
- **API 연동** 준비 완료
|
||||||
@ -124,6 +136,7 @@
|
|||||||
## 🔧 사용 방법
|
## 🔧 사용 방법
|
||||||
|
|
||||||
### 1. 로컬 테스트
|
### 1. 로컬 테스트
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 간단한 HTTP 서버로 실행
|
# 간단한 HTTP 서버로 실행
|
||||||
python -m http.server 8000
|
python -m http.server 8000
|
||||||
@ -132,12 +145,14 @@ npx serve .
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 2. 브라우저에서 확인
|
### 2. 브라우저에서 확인
|
||||||
|
|
||||||
- **메인 프로토타입**: `http://localhost:8000/index.html`
|
- **메인 프로토타입**: `http://localhost:8000/index.html`
|
||||||
- **대시보드**: `http://localhost:8000/dashboard.html`
|
- **대시보드**: `http://localhost:8000/dashboard.html`
|
||||||
- **상세 페이지**: `http://localhost:8000/detail.html`
|
- **상세 페이지**: `http://localhost:8000/detail.html`
|
||||||
- **시뮬레이션**: `http://localhost:8000/simulation.html`
|
- **시뮬레이션**: `http://localhost:8000/simulation.html`
|
||||||
|
|
||||||
### 3. 실제 구현 시
|
### 3. 실제 구현 시
|
||||||
|
|
||||||
1. React + TypeScript로 포팅
|
1. React + TypeScript로 포팅
|
||||||
2. API 엔드포인트 연동
|
2. API 엔드포인트 연동
|
||||||
3. WebSocket 실시간 데이터
|
3. WebSocket 실시간 데이터
|
||||||
@ -148,18 +163,21 @@ npx serve .
|
|||||||
## 📋 TODO (실제 구현 시)
|
## 📋 TODO (실제 구현 시)
|
||||||
|
|
||||||
### 프론트엔드
|
### 프론트엔드
|
||||||
|
|
||||||
- [ ] React 컴포넌트로 변환
|
- [ ] React 컴포넌트로 변환
|
||||||
- [ ] Zustand 상태관리 적용
|
- [ ] Zustand 상태관리 적용
|
||||||
- [ ] TradingView 위젯 연동
|
- [ ] TradingView 위젯 연동
|
||||||
- [ ] WebSocket 실시간 데이터
|
- [ ] WebSocket 실시간 데이터
|
||||||
|
|
||||||
### 백엔드 연동
|
### 백엔드 연동
|
||||||
|
|
||||||
- [ ] AWS Lambda API 연결
|
- [ ] AWS Lambda API 연결
|
||||||
- [ ] S3 데이터 페칭
|
- [ ] S3 데이터 페칭
|
||||||
- [ ] 실시간 가격 업데이트
|
- [ ] 실시간 가격 업데이트
|
||||||
- [ ] AI 분석 결과 표시
|
- [ ] AI 분석 결과 표시
|
||||||
|
|
||||||
### 추가 기능
|
### 추가 기능
|
||||||
|
|
||||||
- [ ] 다크/라이트 테마 토글
|
- [ ] 다크/라이트 테마 토글
|
||||||
- [ ] 사용자 설정 저장
|
- [ ] 사용자 설정 저장
|
||||||
- [ ] 소셜 공유 기능
|
- [ ] 소셜 공유 기능
|
||||||
@ -170,6 +188,7 @@ npx serve .
|
|||||||
## 🎉 완성도
|
## 🎉 완성도
|
||||||
|
|
||||||
이 프로토타입은 **실제 서비스 수준**의 완성도를 자랑합니다:
|
이 프로토타입은 **실제 서비스 수준**의 완성도를 자랑합니다:
|
||||||
|
|
||||||
- ✅ 한글 인코딩 완벽 지원
|
- ✅ 한글 인코딩 완벽 지원
|
||||||
- ✅ 전략적 광고 영역 배치
|
- ✅ 전략적 광고 영역 배치
|
||||||
- ✅ 완전 반응형 디자인
|
- ✅ 완전 반응형 디자인
|
||||||
@ -177,4 +196,4 @@ npx serve .
|
|||||||
- ✅ 실시간 인터랙션
|
- ✅ 실시간 인터랙션
|
||||||
- ✅ 브라우저 호환성
|
- ✅ 브라우저 호환성
|
||||||
|
|
||||||
**바로 브라우저에서 실행하여 확인할 수 있습니다!** 🚀
|
**바로 브라우저에서 실행하여 확인할 수 있습니다!** 🚀
|
||||||
|
@ -1,906 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="ko">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>I'm AI App Design - Mobile Investment Guide Service</title>
|
|
||||||
<style>
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: 'Arial', sans-serif;
|
|
||||||
background-color: #f5f5f5;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 디바이스 컨테이너 - 3개 앱 화면을 나란히 배치 */
|
|
||||||
.device-container {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 30px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 모바일 디바이스 프레임 */
|
|
||||||
.phone {
|
|
||||||
width: 375px; /* iPhone 기준 너비 */
|
|
||||||
height: 812px; /* iPhone 기준 높이 */
|
|
||||||
background: #000;
|
|
||||||
border-radius: 40px;
|
|
||||||
padding: 10px;
|
|
||||||
box-shadow: 0 0 30px rgba(0,0,0,0.3);
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 디바이스 스크린 영역 */
|
|
||||||
.screen {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: linear-gradient(135deg, #0f0f0f 0%, #1a1a1a 100%);
|
|
||||||
border-radius: 30px;
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 상태바 영역 (시간, 배터리 등) */
|
|
||||||
.status-bar {
|
|
||||||
height: 44px;
|
|
||||||
background: transparent;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0 20px;
|
|
||||||
color: #fff;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 상단 네비게이션 바 */
|
|
||||||
.nav-bar {
|
|
||||||
height: 60px;
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: white;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: bold;
|
|
||||||
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 메인 콘텐츠 영역 */
|
|
||||||
.content {
|
|
||||||
height: calc(100% - 104px - 80px); /* 전체 높이에서 상단바와 하단바 제외 */
|
|
||||||
padding: 15px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 하단 탭 네비게이션 */
|
|
||||||
.bottom-nav {
|
|
||||||
height: 80px;
|
|
||||||
background: #1a1a1a;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-around;
|
|
||||||
align-items: center;
|
|
||||||
border-top: 1px solid #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 하단 네비게이션 아이템 */
|
|
||||||
.nav-item {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
color: #999;
|
|
||||||
font-size: 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item.active {
|
|
||||||
color: #667eea;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 네비게이션 아이콘 */
|
|
||||||
.nav-icon {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
background: currentColor;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 투자상품 리스트 */
|
|
||||||
.coin-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 개별 투자상품 카드 */
|
|
||||||
.coin-item {
|
|
||||||
background: linear-gradient(145deg, #1a1a1a, #2d2d2d);
|
|
||||||
border-radius: 15px;
|
|
||||||
padding: 20px;
|
|
||||||
border: 1px solid #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 투자상품 헤더 (이름, 심볼, 가격) */
|
|
||||||
.coin-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 투자상품 정보 (아이콘, 이름) */
|
|
||||||
.coin-info {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 투자상품 아이콘 */
|
|
||||||
.coin-icon {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
background: #667eea;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: white;
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.coin-details h3 {
|
|
||||||
color: #fff;
|
|
||||||
font-size: 16px;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.coin-symbol {
|
|
||||||
color: #999;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 가격 정보 영역 */
|
|
||||||
.coin-price {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.current-price {
|
|
||||||
color: #4ade80;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: bold;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.price-change {
|
|
||||||
font-size: 12px;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 가격 상승/하락 색상 구분 */
|
|
||||||
.price-up {
|
|
||||||
background: #22c55e;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.price-down {
|
|
||||||
background: #ef4444;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* AI 분석 결과 표시 */
|
|
||||||
.ai-analysis {
|
|
||||||
background: rgba(102, 126, 234, 0.1);
|
|
||||||
border: 1px solid #667eea;
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 12px;
|
|
||||||
margin: 15px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analysis-title {
|
|
||||||
color: #667eea;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: bold;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analysis-text {
|
|
||||||
color: #ccc;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 투자 목표가 표시 영역 */
|
|
||||||
.price-targets {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 10px;
|
|
||||||
margin-top: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.target-item {
|
|
||||||
flex: 1;
|
|
||||||
text-align: center;
|
|
||||||
padding: 8px;
|
|
||||||
background: rgba(255,255,255,0.05);
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.target-label {
|
|
||||||
color: #999;
|
|
||||||
font-size: 10px;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.target-value {
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 목표가별 색상 구분 */
|
|
||||||
.entry-price { color: #4ade80; }
|
|
||||||
.target-price { color: #fbbf24; }
|
|
||||||
.stop-loss { color: #ef4444; }
|
|
||||||
|
|
||||||
/* AI 투자 점수 표시 */
|
|
||||||
.ai-score {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
background: linear-gradient(45deg, #667eea, #764ba2);
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 12px;
|
|
||||||
margin-top: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.score-label {
|
|
||||||
color: white;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.score-value {
|
|
||||||
color: white;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 페이지별 스타일 */
|
|
||||||
.page-2 .content {
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 상세 분석 카드 */
|
|
||||||
.detail-card {
|
|
||||||
background: linear-gradient(145deg, #1a1a1a, #2d2d2d);
|
|
||||||
border-radius: 15px;
|
|
||||||
padding: 25px;
|
|
||||||
border: 1px solid #333;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 상세 분석 헤더 */
|
|
||||||
.detail-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 15px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 큰 투자상품 아이콘 */
|
|
||||||
.large-coin-icon {
|
|
||||||
width: 60px;
|
|
||||||
height: 60px;
|
|
||||||
background: #667eea;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: white;
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 큰 가격 표시 */
|
|
||||||
.large-price {
|
|
||||||
font-size: 28px;
|
|
||||||
color: #4ade80;
|
|
||||||
font-weight: bold;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 상세 AI 분석 영역 */
|
|
||||||
.detail-analysis {
|
|
||||||
background: rgba(102, 126, 234, 0.1);
|
|
||||||
border: 1px solid #667eea;
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 20px;
|
|
||||||
margin: 20px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 분석 섹션 */
|
|
||||||
.analysis-section {
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analysis-section h4 {
|
|
||||||
color: #667eea;
|
|
||||||
font-size: 14px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analysis-section p {
|
|
||||||
color: #ccc;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 페이지 3 설정 리스트 */
|
|
||||||
.page-3 .settings-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 설정 카드 */
|
|
||||||
.setting-card {
|
|
||||||
background: linear-gradient(145deg, #1a1a1a, #2d2d2d);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 20px;
|
|
||||||
border: 1px solid #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 설정 제목 */
|
|
||||||
.setting-title {
|
|
||||||
color: #667eea;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: bold;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 설정 아이템 */
|
|
||||||
.setting-item {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
padding-bottom: 12px;
|
|
||||||
border-bottom: 1px solid #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.setting-item:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
margin-bottom: 0;
|
|
||||||
padding-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 설정 라벨 */
|
|
||||||
.setting-label {
|
|
||||||
color: #ccc;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 설정 값 */
|
|
||||||
.setting-value {
|
|
||||||
color: #fff;
|
|
||||||
font-size: 14px;
|
|
||||||
padding: 6px 12px;
|
|
||||||
background: #333;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 토글 스위치 */
|
|
||||||
.switch {
|
|
||||||
width: 50px;
|
|
||||||
height: 24px;
|
|
||||||
background: #333;
|
|
||||||
border-radius: 12px;
|
|
||||||
position: relative;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.switch.active {
|
|
||||||
background: #667eea;
|
|
||||||
}
|
|
||||||
|
|
||||||
.switch::after {
|
|
||||||
content: '';
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
background: white;
|
|
||||||
border-radius: 50%;
|
|
||||||
position: absolute;
|
|
||||||
top: 2px;
|
|
||||||
left: 2px;
|
|
||||||
transition: transform 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.switch.active::after {
|
|
||||||
transform: translateX(26px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 모바일 광고 배너 */
|
|
||||||
.ad-banner-mobile {
|
|
||||||
background: linear-gradient(45deg, #333, #555);
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 15px;
|
|
||||||
text-align: center;
|
|
||||||
margin: 15px 0;
|
|
||||||
border: 1px dashed #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ad-banner-mobile h4 {
|
|
||||||
color: #999;
|
|
||||||
font-size: 12px;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ad-banner-mobile p {
|
|
||||||
color: #777;
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 설명 텍스트 스타일 */
|
|
||||||
.description-text {
|
|
||||||
background: #333;
|
|
||||||
color: #ccc;
|
|
||||||
padding: 6px 10px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 11px;
|
|
||||||
border: 1px solid #555;
|
|
||||||
margin: 5px 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="device-container">
|
|
||||||
<!--
|
|
||||||
페이지 1: 메인 투자 현황 화면
|
|
||||||
- 하단 탭 네비게이션의 "홈" 탭
|
|
||||||
- 5개 투자상품의 실시간 정보 표시
|
|
||||||
- 각 카드에 AI 분석 결과와 투자 점수 포함
|
|
||||||
-->
|
|
||||||
<div class="phone">
|
|
||||||
<div class="screen">
|
|
||||||
<!-- iOS 스타일 상태바 -->
|
|
||||||
<div class="status-bar">
|
|
||||||
<span>[STATUS] 9:41</span>
|
|
||||||
<span>[BATTERY] 100%</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 상단 네비게이션 바 -->
|
|
||||||
<div class="nav-bar">
|
|
||||||
[NAV TITLE] I'm AI - Investment Status
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 메인 콘텐츠 영역 -->
|
|
||||||
<div class="content">
|
|
||||||
<div class="coin-list">
|
|
||||||
<!--
|
|
||||||
투자상품 #1: Bitcoin
|
|
||||||
- 현재가, 변동률 표시
|
|
||||||
- AI 분석 요약
|
|
||||||
- 목표가 정보 (진입/목표/손절)
|
|
||||||
- AI 투자 점수
|
|
||||||
-->
|
|
||||||
<div class="coin-item">
|
|
||||||
<div class="coin-header">
|
|
||||||
<div class="coin-info">
|
|
||||||
<div class="coin-icon">[ICON] BTC</div>
|
|
||||||
<div class="coin-details">
|
|
||||||
<h3>[COIN NAME] Bitcoin</h3>
|
|
||||||
<div class="coin-symbol">[SYMBOL] BTC</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="coin-price">
|
|
||||||
<div class="current-price">[PRICE] $43,250</div>
|
|
||||||
<div class="price-change price-up">[CHANGE] +2.35%</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- AI 분석 요약 -->
|
|
||||||
<div class="ai-analysis">
|
|
||||||
<div class="analysis-title">[AI TITLE] AI Market Analysis</div>
|
|
||||||
<div class="analysis-text description-text">[AI SUMMARY] Institutional investment inflow sustains upward momentum. Technical bullish signals detected.</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 투자 목표가 표시 -->
|
|
||||||
<div class="price-targets">
|
|
||||||
<div class="target-item">
|
|
||||||
<div class="target-label">[LABEL] Entry</div>
|
|
||||||
<div class="target-value entry-price">[ENTRY] $42,800</div>
|
|
||||||
</div>
|
|
||||||
<div class="target-item">
|
|
||||||
<div class="target-label">[LABEL] Target</div>
|
|
||||||
<div class="target-value target-price">[TARGET] $46,500</div>
|
|
||||||
</div>
|
|
||||||
<div class="target-item">
|
|
||||||
<div class="target-label">[LABEL] Stop</div>
|
|
||||||
<div class="target-value stop-loss">[STOP] $40,200</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- AI 투자 점수 -->
|
|
||||||
<div class="ai-score">
|
|
||||||
<span class="score-label">[SCORE LABEL] AI Investment Score</span>
|
|
||||||
<span class="score-value">[SCORE] 8.5/10</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!--
|
|
||||||
투자상품 #2: Ethereum
|
|
||||||
동일한 구조로 이더리움 정보 표시
|
|
||||||
-->
|
|
||||||
<div class="coin-item">
|
|
||||||
<div class="coin-header">
|
|
||||||
<div class="coin-info">
|
|
||||||
<div class="coin-icon">[ICON] ETH</div>
|
|
||||||
<div class="coin-details">
|
|
||||||
<h3>[COIN NAME] Ethereum</h3>
|
|
||||||
<div class="coin-symbol">[SYMBOL] ETH</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="coin-price">
|
|
||||||
<div class="current-price">[PRICE] $2,580</div>
|
|
||||||
<div class="price-change price-down">[CHANGE] -1.20%</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="ai-analysis">
|
|
||||||
<div class="analysis-title">[AI TITLE] AI Market Analysis</div>
|
|
||||||
<div class="analysis-text description-text">[AI SUMMARY] Short-term correction phase but long-term upward outlook. Positive DeFi growth.</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="price-targets">
|
|
||||||
<div class="target-item">
|
|
||||||
<div class="target-label">[LABEL] Entry</div>
|
|
||||||
<div class="target-value entry-price">[ENTRY] $2,520</div>
|
|
||||||
</div>
|
|
||||||
<div class="target-item">
|
|
||||||
<div class="target-label">[LABEL] Target</div>
|
|
||||||
<div class="target-value target-price">[TARGET] $2,850</div>
|
|
||||||
</div>
|
|
||||||
<div class="target-item">
|
|
||||||
<div class="target-label">[LABEL] Stop</div>
|
|
||||||
<div class="target-value stop-loss">[STOP] $2,350</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="ai-score">
|
|
||||||
<span class="score-label">[SCORE LABEL] AI Investment Score</span>
|
|
||||||
<span class="score-value">[SCORE] 7.2/10</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!--
|
|
||||||
광고 영역: 모바일 배너
|
|
||||||
- 투자상품 목록 중간에 삽입
|
|
||||||
- 사용자 경험을 해치지 않는 선에서 배치
|
|
||||||
-->
|
|
||||||
<div class="ad-banner-mobile">
|
|
||||||
<h4>[AD TITLE] Advertisement</h4>
|
|
||||||
<p class="description-text">[AD DESCRIPTION] Mobile Banner Advertisement Area<br>Revenue Generation Through Ad Display</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!--
|
|
||||||
투자상품 #3: Ripple
|
|
||||||
높은 AI 점수를 받은 추천 종목
|
|
||||||
-->
|
|
||||||
<div class="coin-item">
|
|
||||||
<div class="coin-header">
|
|
||||||
<div class="coin-info">
|
|
||||||
<div class="coin-icon">[ICON] XRP</div>
|
|
||||||
<div class="coin-details">
|
|
||||||
<h3>[COIN NAME] Ripple</h3>
|
|
||||||
<div class="coin-symbol">[SYMBOL] XRP</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="coin-price">
|
|
||||||
<div class="current-price">[PRICE] $0.625</div>
|
|
||||||
<div class="price-change price-up">[CHANGE] +5.80%</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="ai-score">
|
|
||||||
<span class="score-label">[SCORE LABEL] AI Investment Score</span>
|
|
||||||
<span class="score-value">[SCORE] 9.1/10</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 하단 탭 네비게이션 -->
|
|
||||||
<div class="bottom-nav">
|
|
||||||
<div class="nav-item active">
|
|
||||||
<div class="nav-icon">[ICON]</div>
|
|
||||||
<span>[TAB] Home</span>
|
|
||||||
</div>
|
|
||||||
<div class="nav-item">
|
|
||||||
<div class="nav-icon">[ICON]</div>
|
|
||||||
<span>[TAB] Analysis</span>
|
|
||||||
</div>
|
|
||||||
<div class="nav-item">
|
|
||||||
<div class="nav-icon">[ICON]</div>
|
|
||||||
<span>[TAB] Settings</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!--
|
|
||||||
페이지 2: AI 분석 상세 화면
|
|
||||||
- 하단 탭 네비게이션의 "분석" 탭
|
|
||||||
- 선택한 투자상품의 상세 AI 분석 결과
|
|
||||||
- 기술적 분석, 뉴스 분석, 투자 전략 제공
|
|
||||||
-->
|
|
||||||
<div class="phone page-2">
|
|
||||||
<div class="screen">
|
|
||||||
<div class="status-bar">
|
|
||||||
<span>[STATUS] 9:41</span>
|
|
||||||
<span>[BATTERY] 100%</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="nav-bar">
|
|
||||||
[NAV TITLE] AI Analysis Detail
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="content">
|
|
||||||
<!--
|
|
||||||
상세 분석 카드
|
|
||||||
- 투자상품 기본 정보 (큰 아이콘, 이름, 가격)
|
|
||||||
- 상세 AI 분석 (기술적/뉴스/전략 분석)
|
|
||||||
- 종합 AI 투자 점수
|
|
||||||
-->
|
|
||||||
<div class="detail-card">
|
|
||||||
<div class="detail-header">
|
|
||||||
<div class="large-coin-icon">[BIG ICON] BTC</div>
|
|
||||||
<div>
|
|
||||||
<h2 style="color: white; margin-bottom: 5px;">[COIN NAME] Bitcoin</h2>
|
|
||||||
<div class="large-price">[LARGE PRICE] $43,250.00</div>
|
|
||||||
<div class="price-change price-up">[PRICE CHANGE] +2.35% (+$992.50)</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 상세 AI 분석 영역 -->
|
|
||||||
<div class="detail-analysis">
|
|
||||||
<!-- 기술적 분석 섹션 -->
|
|
||||||
<div class="analysis-section">
|
|
||||||
<h4>[SECTION TITLE] Technical Analysis</h4>
|
|
||||||
<p class="description-text">[TECHNICAL ANALYSIS] RSI indicator at 65 level shows bullish signals. Moving average breakout strengthens upward momentum.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 뉴스 분석 섹션 -->
|
|
||||||
<div class="analysis-section">
|
|
||||||
<h4>[SECTION TITLE] News Analysis</h4>
|
|
||||||
<p class="description-text">[NEWS ANALYSIS] Major institutional Bitcoin ETF approvals by BlackRock and others drive significant capital inflow.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 투자 전략 섹션 -->
|
|
||||||
<div class="analysis-section">
|
|
||||||
<h4>[SECTION TITLE] Investment Strategy</h4>
|
|
||||||
<p class="description-text">[STRATEGY] Recommend entry at $42,800, hold until target $46,500. Strict adherence to stop-loss at $40,200 required.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 종합 AI 점수 -->
|
|
||||||
<div class="ai-score">
|
|
||||||
<span class="score-label">[TOTAL SCORE LABEL] Comprehensive AI Investment Score</span>
|
|
||||||
<span class="score-value">[TOTAL SCORE] 8.5/10</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!--
|
|
||||||
광고 영역: 전면 또는 동영상 광고
|
|
||||||
- 상세 분석 하단에 배치
|
|
||||||
- 더 높은 광고 단가 기대
|
|
||||||
-->
|
|
||||||
<div class="ad-banner-mobile">
|
|
||||||
<h4>[PREMIUM AD] Premium Advertisement</h4>
|
|
||||||
<p class="description-text">[AD DESCRIPTION] Full-screen or Video Advertisement Area<br>Higher Revenue Premium Ad Space</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bottom-nav">
|
|
||||||
<div class="nav-item">
|
|
||||||
<div class="nav-icon">[ICON]</div>
|
|
||||||
<span>[TAB] Home</span>
|
|
||||||
</div>
|
|
||||||
<div class="nav-item active">
|
|
||||||
<div class="nav-icon">[ICON]</div>
|
|
||||||
<span>[TAB] Analysis</span>
|
|
||||||
</div>
|
|
||||||
<div class="nav-item">
|
|
||||||
<div class="nav-icon">[ICON]</div>
|
|
||||||
<span>[TAB] Settings</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!--
|
|
||||||
페이지 3: 개인 맞춤 설정 화면
|
|
||||||
- 하단 탭 네비게이션의 "설정" 탭
|
|
||||||
- 투자 성향 설정
|
|
||||||
- 알림 설정
|
|
||||||
- 앱 설정
|
|
||||||
-->
|
|
||||||
<div class="phone page-3">
|
|
||||||
<div class="screen">
|
|
||||||
<div class="status-bar">
|
|
||||||
<span>[STATUS] 9:41</span>
|
|
||||||
<span>[BATTERY] 100%</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="nav-bar">
|
|
||||||
[NAV TITLE] Personal Settings
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="content">
|
|
||||||
<div class="settings-list">
|
|
||||||
<!--
|
|
||||||
투자 성향 설정 카드
|
|
||||||
- 투자 유형 선택 (보수적/균형/적극적/공격적)
|
|
||||||
- 투자 기간 설정
|
|
||||||
- 위험 허용도 조절
|
|
||||||
-->
|
|
||||||
<div class="setting-card">
|
|
||||||
<div class="setting-title">[CARD TITLE] Investment Preferences</div>
|
|
||||||
|
|
||||||
<div class="setting-item">
|
|
||||||
<span class="setting-label">[LABEL] Investment Type</span>
|
|
||||||
<select class="setting-value">
|
|
||||||
<option>[OPTION] Conservative</option>
|
|
||||||
<option>[OPTION] Balanced</option>
|
|
||||||
<option>[OPTION] Aggressive</option>
|
|
||||||
<option>[OPTION] High Risk</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="setting-item">
|
|
||||||
<span class="setting-label">[LABEL] Investment Period</span>
|
|
||||||
<select class="setting-value">
|
|
||||||
<option>[OPTION] Short Term (1-3 months)</option>
|
|
||||||
<option>[OPTION] Medium Term (3-12 months)</option>
|
|
||||||
<option>[OPTION] Long Term (1+ years)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="setting-item">
|
|
||||||
<span class="setting-label">[LABEL] Risk Tolerance</span>
|
|
||||||
<span class="setting-value">[VALUE] Medium (5/10)</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!--
|
|
||||||
알림 설정 카드
|
|
||||||
- 목표가 도달 알림 on/off
|
|
||||||
- 손절가 도달 알림 on/off
|
|
||||||
- 진입가 도달 알림 on/off
|
|
||||||
- AI 추천 알림 on/off
|
|
||||||
-->
|
|
||||||
<div class="setting-card">
|
|
||||||
<div class="setting-title">[CARD TITLE] Notification Settings</div>
|
|
||||||
|
|
||||||
<div class="setting-item">
|
|
||||||
<span class="setting-label">[ALERT LABEL] Target Price Alert</span>
|
|
||||||
<div class="switch active">[SWITCH] ON</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="setting-item">
|
|
||||||
<span class="setting-label">[ALERT LABEL] Stop Loss Alert</span>
|
|
||||||
<div class="switch active">[SWITCH] ON</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="setting-item">
|
|
||||||
<span class="setting-label">[ALERT LABEL] Entry Price Alert</span>
|
|
||||||
<div class="switch active">[SWITCH] ON</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="setting-item">
|
|
||||||
<span class="setting-label">[ALERT LABEL] AI Recommendation Alert</span>
|
|
||||||
<div class="switch active">[SWITCH] ON</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!--
|
|
||||||
광고 영역: 설정 페이지 광고
|
|
||||||
- 설정 카드들 사이에 자연스럽게 삽입
|
|
||||||
- 사용자가 설정을 변경하는 동안 노출
|
|
||||||
-->
|
|
||||||
<div class="ad-banner-mobile">
|
|
||||||
<h4>[SETTINGS AD] Settings Page Ad</h4>
|
|
||||||
<p class="description-text">[AD DESCRIPTION] Advertisement in Settings Area<br>Targeted Ad Based on User Preferences</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!--
|
|
||||||
앱 설정 카드
|
|
||||||
- 다크 모드 on/off
|
|
||||||
- 자동 업데이트 on/off
|
|
||||||
- 데이터 절약 모드 on/off
|
|
||||||
-->
|
|
||||||
<div class="setting-card">
|
|
||||||
<div class="setting-title">[CARD TITLE] App Settings</div>
|
|
||||||
|
|
||||||
<div class="setting-item">
|
|
||||||
<span class="setting-label">[SETTING LABEL] Dark Mode</span>
|
|
||||||
<div class="switch active">[SWITCH] ON</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="setting-item">
|
|
||||||
<span class="setting-label">[SETTING LABEL] Auto Update</span>
|
|
||||||
<div class="switch active">[SWITCH] ON</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="setting-item">
|
|
||||||
<span class="setting-label">[SETTING LABEL] Data Saving Mode</span>
|
|
||||||
<div class="switch">[SWITCH] OFF</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bottom-nav">
|
|
||||||
<div class="nav-item">
|
|
||||||
<div class="nav-icon">[ICON]</div>
|
|
||||||
<span>[TAB] Home</span>
|
|
||||||
</div>
|
|
||||||
<div class="nav-item">
|
|
||||||
<div class="nav-icon">[ICON]</div>
|
|
||||||
<span>[TAB] Analysis</span>
|
|
||||||
</div>
|
|
||||||
<div class="nav-item active">
|
|
||||||
<div class="nav-icon">[ICON]</div>
|
|
||||||
<span>[TAB] Settings</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// 토글 스위치 기능
|
|
||||||
// 실제 구현시 사용자 설정을 서버에 저장하고 동기화
|
|
||||||
document.querySelectorAll('.switch').forEach(switchEl => {
|
|
||||||
switchEl.addEventListener('click', function() {
|
|
||||||
this.classList.toggle('active');
|
|
||||||
|
|
||||||
// 실제 구현시 여기서 서버로 설정값 전송
|
|
||||||
console.log('Setting changed:', this.previousElementSibling.textContent);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// 실시간 가격 업데이트 시뮬레이션 (모바일용)
|
|
||||||
// 실제로는 WebSocket 연결을 통해 실시간 데이터 수신
|
|
||||||
function updateMobilePrices() {
|
|
||||||
const priceElements = document.querySelectorAll('.current-price');
|
|
||||||
|
|
||||||
priceElements.forEach(element => {
|
|
||||||
// 실제로는 API에서 실시간 데이터를 받아와 업데이트
|
|
||||||
const currentText = element.textContent;
|
|
||||||
console.log('Mobile price update simulation for:', currentText);
|
|
||||||
|
|
||||||
// 푸시 알림 조건 확인
|
|
||||||
// 실제 구현시 목표가/손절가 도달시 알림 발송
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 30초마다 가격 업데이트 (데모용)
|
|
||||||
setInterval(updateMobilePrices, 30000);
|
|
||||||
|
|
||||||
// 페이지 전환 애니메이션
|
|
||||||
// 실제 앱에서는 React Native Navigation 또는 Flutter Navigator 사용
|
|
||||||
function navigateToPage(pageIndex) {
|
|
||||||
console.log('Navigate to page:', pageIndex);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
@ -1,687 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="ko">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>I'm AI Web Design - Investment Guide Service</title>
|
|
||||||
<style>
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: 'Arial', sans-serif;
|
|
||||||
background-color: #0f0f0f;
|
|
||||||
color: #ffffff;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 헤더 영역 스타일 */
|
|
||||||
.header {
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
padding: 20px;
|
|
||||||
text-align: center;
|
|
||||||
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
font-size: 2.5rem;
|
|
||||||
font-weight: bold;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subtitle {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 메인 컨테이너 - 2열 그리드 레이아웃 */
|
|
||||||
|
|
||||||
/* 좌측 메인 콘텐츠 영역 */
|
|
||||||
.main-content {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 투자상품 카드 그리드 */
|
|
||||||
.coin-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 개별 투자상품 카드 */
|
|
||||||
.coin-card {
|
|
||||||
background: linear-gradient(145deg, #1a1a1a, #2d2d2d);
|
|
||||||
border-radius: 15px;
|
|
||||||
padding: 25px;
|
|
||||||
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
|
|
||||||
border: 1px solid #333;
|
|
||||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.coin-card:hover {
|
|
||||||
transform: translateY(-5px);
|
|
||||||
box-shadow: 0 15px 40px rgba(0,0,0,0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 투자상품 헤더 (이름, 심볼) */
|
|
||||||
.coin-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.coin-name {
|
|
||||||
font-size: 1.3rem;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #667eea;
|
|
||||||
}
|
|
||||||
|
|
||||||
.coin-symbol {
|
|
||||||
background: #667eea;
|
|
||||||
color: white;
|
|
||||||
padding: 5px 10px;
|
|
||||||
border-radius: 20px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 가격 정보 영역 */
|
|
||||||
.price-info {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.current-price {
|
|
||||||
font-size: 2rem;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #4ade80;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.price-change {
|
|
||||||
font-size: 1rem;
|
|
||||||
padding: 5px 10px;
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.price-up {
|
|
||||||
background: #22c55e;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.price-down {
|
|
||||||
background: #ef4444;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* AI 분석 결과 표시 영역 */
|
|
||||||
.ai-analysis {
|
|
||||||
background: rgba(102, 126, 234, 0.1);
|
|
||||||
border: 1px solid #667eea;
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 15px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analysis-title {
|
|
||||||
color: #667eea;
|
|
||||||
font-weight: bold;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 투자 가격 목표 표시 */
|
|
||||||
.price-targets {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.target-item {
|
|
||||||
text-align: center;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.target-label {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: #999;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.target-price {
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 목표가별 색상 구분 */
|
|
||||||
.entry-price { color: #4ade80; }
|
|
||||||
.target-price-val { color: #fbbf24; }
|
|
||||||
.stop-loss { color: #ef4444; }
|
|
||||||
|
|
||||||
/* AI 투자 점수 표시 */
|
|
||||||
.ai-score {
|
|
||||||
background: linear-gradient(45deg, #667eea, #764ba2);
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 15px;
|
|
||||||
text-align: center;
|
|
||||||
margin-top: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.score-label {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.score-value {
|
|
||||||
font-size: 2rem;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 우측 사이드바 */
|
|
||||||
.sidebar {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 광고 배너 영역 */
|
|
||||||
.ad-banner {
|
|
||||||
background: linear-gradient(45deg, #333, #555);
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 20px;
|
|
||||||
text-align: center;
|
|
||||||
min-height: 200px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border: 2px dashed #666;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #ccc;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 개인 설정 패널 */
|
|
||||||
.settings-panel {
|
|
||||||
background: linear-gradient(145deg, #1a1a1a, #2d2d2d);
|
|
||||||
border-radius: 15px;
|
|
||||||
padding: 25px;
|
|
||||||
border: 1px solid #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-title {
|
|
||||||
font-size: 1.2rem;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #667eea;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.setting-item {
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.setting-label {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
color: #ccc;
|
|
||||||
}
|
|
||||||
|
|
||||||
select, input[type="range"] {
|
|
||||||
width: 100%;
|
|
||||||
padding: 8px;
|
|
||||||
background: #333;
|
|
||||||
border: 1px solid #555;
|
|
||||||
border-radius: 5px;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 알림 설정 영역 */
|
|
||||||
.notification-settings {
|
|
||||||
background: rgba(102, 126, 234, 0.1);
|
|
||||||
border: 1px solid #667eea;
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 15px;
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-title {
|
|
||||||
color: #667eea;
|
|
||||||
font-weight: bold;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-item input {
|
|
||||||
margin-right: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 설명 텍스트 스타일 */
|
|
||||||
.description-text {
|
|
||||||
background: #333;
|
|
||||||
color: #ccc;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 12px;
|
|
||||||
border: 1px solid #555;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 세로 광고 배너 영역 */
|
|
||||||
.left-banner, .right-banner {
|
|
||||||
position: fixed;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
width: 120px;
|
|
||||||
height: 600px;
|
|
||||||
background: linear-gradient(45deg, #333, #555);
|
|
||||||
border: 2px dashed #666;
|
|
||||||
border-radius: 10px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #ccc;
|
|
||||||
z-index: 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left-banner {
|
|
||||||
left: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.right-banner {
|
|
||||||
right: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 메인 컨테이너 여백 조정 */
|
|
||||||
.container {
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 20px 150px; /* 좌우 여백 증가 */
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 2fr 1fr;
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 모바일 반응형 */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.container {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
padding: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.coin-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.price-targets {
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left-banner, .right-banner {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<!-- 좌측 세로 광고 배너 -->
|
|
||||||
<div class="left-banner">
|
|
||||||
<div>
|
|
||||||
<div style="font-size: 14px; margin-bottom: 10px; transform: rotate(-90deg);">[LEFT BANNER]</div>
|
|
||||||
<div style="font-size: 10px;">Vertical Advertisement<br>120x600px</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 우측 세로 광고 배너 -->
|
|
||||||
<div class="right-banner">
|
|
||||||
<div>
|
|
||||||
<div style="font-size: 14px; margin-bottom: 10px; transform: rotate(-90deg);">[RIGHT BANNER]</div>
|
|
||||||
<div style="font-size: 10px;">Vertical Advertisement<br>120x600px</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 웹사이트 헤더: 로고와 서비스 설명 -->
|
|
||||||
<header class="header">
|
|
||||||
<div class="logo">I'm AI</div>
|
|
||||||
<div class="subtitle">[HEADER] AI Investment Guide Service Logo & Title Area</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="container">
|
|
||||||
<!-- 좌측 메인 콘텐츠 영역: 투자상품 정보 표시 -->
|
|
||||||
<main class="main-content">
|
|
||||||
<div class="coin-grid">
|
|
||||||
<!--
|
|
||||||
투자상품 카드 #1: Bitcoin
|
|
||||||
- 실시간 가격 정보
|
|
||||||
- AI 분석 결과
|
|
||||||
- 투자 목표가 (진입/목표/손절)
|
|
||||||
- AI 투자 점수
|
|
||||||
-->
|
|
||||||
<div class="coin-card">
|
|
||||||
<div class="coin-header">
|
|
||||||
<div class="coin-name">[COIN NAME] Bitcoin</div>
|
|
||||||
<div class="coin-symbol">[SYMBOL] BTC</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="price-info">
|
|
||||||
<!-- 실시간 현재가 표시 -->
|
|
||||||
<div class="current-price">[CURRENT PRICE] $43,250.00</div>
|
|
||||||
<!-- 가격 변동률 및 변동액 표시 -->
|
|
||||||
<div class="price-change price-up">[PRICE CHANGE] +2.35% (+$992.50)</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- AI 분석 결과 표시 영역 -->
|
|
||||||
<div class="ai-analysis">
|
|
||||||
<div class="analysis-title">[AI ANALYSIS TITLE] AI Market Analysis</div>
|
|
||||||
<div class="description-text">[AI ANALYSIS] Recent institutional investment inflow sustains upward momentum. Technical analysis shows bullish signals detected.</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 투자 목표가 설정 표시 -->
|
|
||||||
<div class="price-targets">
|
|
||||||
<div class="target-item">
|
|
||||||
<div class="target-label">[ENTRY PRICE LABEL] Entry</div>
|
|
||||||
<div class="target-price entry-price">[ENTRY PRICE] $42,800</div>
|
|
||||||
</div>
|
|
||||||
<div class="target-item">
|
|
||||||
<div class="target-label">[TARGET PRICE LABEL] Target</div>
|
|
||||||
<div class="target-price target-price-val">[TARGET PRICE] $46,500</div>
|
|
||||||
</div>
|
|
||||||
<div class="target-item">
|
|
||||||
<div class="target-label">[STOP LOSS LABEL] Stop Loss</div>
|
|
||||||
<div class="target-price stop-loss">[STOP LOSS] $40,200</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- AI 투자 점수 표시 -->
|
|
||||||
<div class="ai-score">
|
|
||||||
<div class="score-label">[SCORE LABEL] AI Investment Score</div>
|
|
||||||
<div class="score-value">[AI SCORE] 8.5/10</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!--
|
|
||||||
투자상품 카드 #2: Ethereum
|
|
||||||
동일한 구조로 이더리움 정보 표시
|
|
||||||
-->
|
|
||||||
<div class="coin-card">
|
|
||||||
<div class="coin-header">
|
|
||||||
<div class="coin-name">[COIN NAME] Ethereum</div>
|
|
||||||
<div class="coin-symbol">[SYMBOL] ETH</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="price-info">
|
|
||||||
<div class="current-price">[CURRENT PRICE] $2,580.00</div>
|
|
||||||
<div class="price-change price-down">[PRICE CHANGE] -1.20% (-$31.20)</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="ai-analysis">
|
|
||||||
<div class="analysis-title">[AI ANALYSIS TITLE] AI Market Analysis</div>
|
|
||||||
<div class="description-text">[AI ANALYSIS] Short-term correction phase but long-term upward outlook. Positive DeFi ecosystem growth.</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="price-targets">
|
|
||||||
<div class="target-item">
|
|
||||||
<div class="target-label">[ENTRY PRICE LABEL] Entry</div>
|
|
||||||
<div class="target-price entry-price">[ENTRY PRICE] $2,520</div>
|
|
||||||
</div>
|
|
||||||
<div class="target-item">
|
|
||||||
<div class="target-label">[TARGET PRICE LABEL] Target</div>
|
|
||||||
<div class="target-price target-price-val">[TARGET PRICE] $2,850</div>
|
|
||||||
</div>
|
|
||||||
<div class="target-item">
|
|
||||||
<div class="target-label">[STOP LOSS LABEL] Stop Loss</div>
|
|
||||||
<div class="target-price stop-loss">[STOP LOSS] $2,350</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="ai-score">
|
|
||||||
<div class="score-label">[SCORE LABEL] AI Investment Score</div>
|
|
||||||
<div class="score-value">[AI SCORE] 7.2/10</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!--
|
|
||||||
투자상품 카드 #3: Ripple
|
|
||||||
높은 AI 점수를 받은 추천 종목 예시
|
|
||||||
-->
|
|
||||||
<div class="coin-card">
|
|
||||||
<div class="coin-header">
|
|
||||||
<div class="coin-name">[COIN NAME] Ripple</div>
|
|
||||||
<div class="coin-symbol">[SYMBOL] XRP</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="price-info">
|
|
||||||
<div class="current-price">[CURRENT PRICE] $0.625</div>
|
|
||||||
<div class="price-change price-up">[PRICE CHANGE] +5.80% (+$0.034)</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="ai-analysis">
|
|
||||||
<div class="analysis-title">[AI ANALYSIS TITLE] AI Market Analysis</div>
|
|
||||||
<div class="description-text">[AI ANALYSIS] Surge due to SEC lawsuit resolution expectations. Technical breakthrough pattern forming.</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="price-targets">
|
|
||||||
<div class="target-item">
|
|
||||||
<div class="target-label">[ENTRY PRICE LABEL] Entry</div>
|
|
||||||
<div class="target-price entry-price">[ENTRY PRICE] $0.610</div>
|
|
||||||
</div>
|
|
||||||
<div class="target-item">
|
|
||||||
<div class="target-label">[TARGET PRICE LABEL] Target</div>
|
|
||||||
<div class="target-price target-price-val">[TARGET PRICE] $0.720</div>
|
|
||||||
</div>
|
|
||||||
<div class="target-item">
|
|
||||||
<div class="target-label">[STOP LOSS LABEL] Stop Loss</div>
|
|
||||||
<div class="target-price stop-loss">[STOP LOSS] $0.550</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="ai-score">
|
|
||||||
<div class="score-label">[SCORE LABEL] AI Investment Score</div>
|
|
||||||
<div class="score-value">[AI SCORE] 9.1/10</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!--
|
|
||||||
투자상품 카드 #4: Cardano
|
|
||||||
중간 점수 종목 예시
|
|
||||||
-->
|
|
||||||
<div class="coin-card">
|
|
||||||
<div class="coin-header">
|
|
||||||
<div class="coin-name">[COIN NAME] Cardano</div>
|
|
||||||
<div class="coin-symbol">[SYMBOL] ADA</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="price-info">
|
|
||||||
<div class="current-price">[CURRENT PRICE] $0.485</div>
|
|
||||||
<div class="price-change price-up">[PRICE CHANGE] +3.20% (+$0.015)</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="ai-analysis">
|
|
||||||
<div class="analysis-title">[AI ANALYSIS TITLE] AI Market Analysis</div>
|
|
||||||
<div class="description-text">[AI ANALYSIS] Increased developer activity after smart contract upgrade. Medium-term growth expected.</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="price-targets">
|
|
||||||
<div class="target-item">
|
|
||||||
<div class="target-label">[ENTRY PRICE LABEL] Entry</div>
|
|
||||||
<div class="target-price entry-price">[ENTRY PRICE] $0.470</div>
|
|
||||||
</div>
|
|
||||||
<div class="target-item">
|
|
||||||
<div class="target-label">[TARGET PRICE LABEL] Target</div>
|
|
||||||
<div class="target-price target-price-val">[TARGET PRICE] $0.550</div>
|
|
||||||
</div>
|
|
||||||
<div class="target-item">
|
|
||||||
<div class="target-label">[STOP LOSS LABEL] Stop Loss</div>
|
|
||||||
<div class="target-price stop-loss">[STOP LOSS] $0.420</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="ai-score">
|
|
||||||
<div class="score-label">[SCORE LABEL] AI Investment Score</div>
|
|
||||||
<div class="score-value">[AI SCORE] 6.8/10</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!--
|
|
||||||
투자상품 카드 #5: Solana
|
|
||||||
고성장 가능성 종목 예시
|
|
||||||
-->
|
|
||||||
<div class="coin-card">
|
|
||||||
<div class="coin-header">
|
|
||||||
<div class="coin-name">[COIN NAME] Solana</div>
|
|
||||||
<div class="coin-symbol">[SYMBOL] SOL</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="price-info">
|
|
||||||
<div class="current-price">[CURRENT PRICE] $102.30</div>
|
|
||||||
<div class="price-change price-up">[PRICE CHANGE] +4.75% (+$4.64)</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="ai-analysis">
|
|
||||||
<div class="analysis-title">[AI ANALYSIS TITLE] AI Market Analysis</div>
|
|
||||||
<div class="description-text">[AI ANALYSIS] Network usage surge due to NFT and DeFi ecosystem activation. Technical strength continues.</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="price-targets">
|
|
||||||
<div class="target-item">
|
|
||||||
<div class="target-label">[ENTRY PRICE LABEL] Entry</div>
|
|
||||||
<div class="target-price entry-price">[ENTRY PRICE] $98.50</div>
|
|
||||||
</div>
|
|
||||||
<div class="target-item">
|
|
||||||
<div class="target-label">[TARGET PRICE LABEL] Target</div>
|
|
||||||
<div class="target-price target-price-val">[TARGET PRICE] $115.00</div>
|
|
||||||
</div>
|
|
||||||
<div class="target-item">
|
|
||||||
<div class="target-label">[STOP LOSS LABEL] Stop Loss</div>
|
|
||||||
<div class="target-price stop-loss">[STOP LOSS] $88.00</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="ai-score">
|
|
||||||
<div class="score-label">[SCORE LABEL] AI Investment Score</div>
|
|
||||||
<div class="score-value">[AI SCORE] 8.3/10</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<!-- 우측 사이드바: 광고 및 개인설정 -->
|
|
||||||
<aside class="sidebar">
|
|
||||||
<!--
|
|
||||||
광고 영역 #1: 주요 광고 배너
|
|
||||||
- 크기: 320x200px 권장
|
|
||||||
- 수익 모델의 핵심 영역
|
|
||||||
-->
|
|
||||||
<div class="ad-banner">
|
|
||||||
<div>
|
|
||||||
<div style="font-size: 16px; margin-bottom: 10px;">[AD BANNER #1]</div>
|
|
||||||
<div class="description-text">Advertisement Banner Area<br>320x200px Recommended<br>Primary Revenue Source</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!--
|
|
||||||
개인 맞춤 설정 패널
|
|
||||||
- 사용자 투자 성향 설정
|
|
||||||
- 알림 설정
|
|
||||||
- 위험 허용도 조절
|
|
||||||
-->
|
|
||||||
<div class="settings-panel">
|
|
||||||
<div class="panel-title">[SETTINGS TITLE] Personal Investment Settings</div>
|
|
||||||
|
|
||||||
<!-- 투자 성향 선택 -->
|
|
||||||
<div class="setting-item">
|
|
||||||
<label class="setting-label">[INVESTMENT TYPE LABEL] Investment Style</label>
|
|
||||||
<select>
|
|
||||||
<option>[OPTION] Conservative</option>
|
|
||||||
<option>[OPTION] Balanced</option>
|
|
||||||
<option>[OPTION] Aggressive</option>
|
|
||||||
<option>[OPTION] High Risk</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 투자 기간 선택 -->
|
|
||||||
<div class="setting-item">
|
|
||||||
<label class="setting-label">[INVESTMENT PERIOD LABEL] Investment Period</label>
|
|
||||||
<select>
|
|
||||||
<option>[OPTION] Short Term (1-3 months)</option>
|
|
||||||
<option>[OPTION] Medium Term (3-12 months)</option>
|
|
||||||
<option>[OPTION] Long Term (1+ years)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 위험 허용도 슬라이더 -->
|
|
||||||
<div class="setting-item">
|
|
||||||
<label class="setting-label">[RISK TOLERANCE LABEL] Risk Tolerance: <span id="riskValue">5</span></label>
|
|
||||||
<input type="range" min="1" max="10" value="5" id="riskSlider">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 알림 설정 영역 -->
|
|
||||||
<div class="notification-settings">
|
|
||||||
<div class="notification-title">[NOTIFICATION TITLE] Alert Settings</div>
|
|
||||||
<div class="checkbox-item">
|
|
||||||
<input type="checkbox" id="targetAlert" checked>
|
|
||||||
<label for="targetAlert">[ALERT OPTION] Target Price Alert</label>
|
|
||||||
</div>
|
|
||||||
<div class="checkbox-item">
|
|
||||||
<input type="checkbox" id="stopLossAlert" checked>
|
|
||||||
<label for="stopLossAlert">[ALERT OPTION] Stop Loss Alert</label>
|
|
||||||
</div>
|
|
||||||
<div class="checkbox-item">
|
|
||||||
<input type="checkbox" id="entryAlert" checked>
|
|
||||||
<label for="entryAlert">[ALERT OPTION] Entry Price Alert</label>
|
|
||||||
</div>
|
|
||||||
<div class="checkbox-item">
|
|
||||||
<input type="checkbox" id="recommendAlert" checked>
|
|
||||||
<label for="recommendAlert">[ALERT OPTION] AI Recommendation Alert</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!--
|
|
||||||
광고 영역 #2: 보조 광고 배너
|
|
||||||
- 크기: 320x150px 권장
|
|
||||||
- 추가 수익원
|
|
||||||
-->
|
|
||||||
<div class="ad-banner">
|
|
||||||
<div>
|
|
||||||
<div style="font-size: 16px; margin-bottom: 10px;">[AD BANNER #2]</div>
|
|
||||||
<div class="description-text">Secondary Advertisement Area<br>320x150px Recommended<br>Additional Revenue Stream</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// 위험 허용도 슬라이더 기능
|
|
||||||
const riskSlider = document.getElementById('riskSlider');
|
|
||||||
const riskValue = document.getElementById('riskValue');
|
|
||||||
|
|
||||||
riskSlider.addEventListener('input', function() {
|
|
||||||
riskValue.textContent = this.value;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 실시간 가격 업데이트 시뮬레이션
|
|
||||||
// 실제 구현시 WebSocket 또는 API 폴링 방식으로 교체
|
|
||||||
function updatePrices() {
|
|
||||||
const priceElements = document.querySelectorAll('.current-price');
|
|
||||||
const changeElements = document.querySelectorAll('.price-change');
|
|
||||||
|
|
||||||
priceElements.forEach((element, index) => {
|
|
||||||
// 실제로는 API에서 실시간 데이터를 받아와 업데이트
|
|
||||||
const currentText = element.textContent;
|
|
||||||
console.log('Price update simulation for:', currentText);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 30초마다 가격 업데이트 (데모용)
|
|
||||||
setInterval(updatePrices, 30000);
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
30
테스트/주식관련/야후파이넨스1.py
Normal file
30
테스트/주식관련/야후파이넨스1.py
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import yfinance as yf
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
# 1. 애플 주식 종목 코드 (티커) 정의
|
||||||
|
ticker_symbol = "AAPL"
|
||||||
|
|
||||||
|
# 2. Ticker 객체 생성
|
||||||
|
apple = yf.Ticker(ticker_symbol)
|
||||||
|
|
||||||
|
# 3. 현재 가격 정보 가져오기
|
||||||
|
try:
|
||||||
|
current_price = apple.info['regularMarketPrice']
|
||||||
|
print(f"애플 주식 현재 가격: ${current_price:.2f}")
|
||||||
|
except KeyError:
|
||||||
|
print("현재 가격 정보를 가져오는 데 실패했습니다.")
|
||||||
|
|
||||||
|
# 4. 과거 캔들 데이터 가져오기
|
||||||
|
# 기간(period) 설정: 1d, 5d, 1mo, 3mo, 6mo, 1y, 2y, 5y, 10y, ytd, max
|
||||||
|
# 간격(interval) 설정: 1m, 2m, 5m, 15m, 30m, 60m, 90m, 1h, 1d, 5d, 1wk, 1mo, 3mo
|
||||||
|
# 최근 30일치 일봉(1d) 데이터 가져오기 예제
|
||||||
|
hist = apple.history(period="30d", interval="1d")
|
||||||
|
|
||||||
|
# 5. 데이터 출력
|
||||||
|
if not hist.empty:
|
||||||
|
print("\n최근 30일치 애플 주식 캔들 데이터 (일봉):")
|
||||||
|
print(hist[['Open', 'High', 'Low', 'Close', 'Volume']].tail())
|
||||||
|
# DataFrame을 CSV 파일로 저장
|
||||||
|
# hist.to_csv('aapl_30days.csv')
|
||||||
|
else:
|
||||||
|
print("과거 데이터를 가져오는 데 실패했습니다. 티커를 확인하세요.")
|
Loading…
Reference in New Issue
Block a user