From 5ac11ccc2868548e00704cc6b9d43caba7867642 Mon Sep 17 00:00:00 2001 From: sunghwki <52474291+swkim12345@users.noreply.github.com> Date: Thu, 28 Nov 2024 20:49:57 +0900 Subject: [PATCH] Dev be to dev (#290) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ♻️ refactor: swagger url 변경 * ✨ feat: 이전 채팅 스크롤 조회 기능 구현 * ✨ feat: class dto 내부 타입 자동 변경 설정 * ✨ feat: 스크롤 크기를 100을 넘지 않도록 설정 * ✅ test: 100개 초과 메시지 조회에 대한 테스트 코드 작성 * ✨ feat: detail 항목 추가 및 구현 진행중 * 🐛 fix: kospiStock * ✨ feat: 주식 검색 기능 구현 * ✨ feat: 주식 검색 엔드포인트 구현 * ✨ feat: swagger 기본 경로 변경 * ✨ feat: 좋아요 엔티티 구현 * ♻️ refactor: typeORM 설정 파일 이름 변경 * ✨ feat: kospi stock entity 추가 * 🐛 fix: openapi detail 잠깐 추가 * ✨ feat: 좋아요 기능 구현 * ✨ feat: 좋아요 엔드 포인트 구현 * 💄 style: 안 쓰이는 bigint 데코레이터 및 관련 import 삭제 * ✨ feat: 좋아요 취소 기능 구현 * ✨ feat: 좋아요 이벤트를 참여중인 채팅방에 전파 * ✨ feat: 채팅 스크롤 시 좋아요 여부 출력 * ♻️ refactor: stock 내부 필드 타입 변경 * ♻️ refactor: chat 스크롤 쿼리 중복 dto 제거 * ✨ feat: 웹소켓 초기 접속 세션 인증 기능 구현 * ✨ feat: 채팅방 접속 시 좋아요 여부도 출력 * ✨ feat: cors 허용 설정 * ♻️ refactor: 잘못된 파일명 수정 * ✅ test: datasource mock 타입 에러 수정 * ♻️ refactor: 채팅 스크롤 중복 로직 제거 * ✨ feat: detail 완성 * ✨ feat: 좋아요 순으로 채팅 정렬 기능 구현 * ✨ feat: 좋아요 순 채팅 스크롤 엔드포인트 구현 * ✨ feat: chat likeCount 인덱스 설정 * ♻️ refactor: 채팅 스크롤 쿼리 빌더 중복 코드 제거 * ✨ feat: 주식 소유 확인 엔드포인트를 쿼리로 받도록 변경 * ♻️ refactor: tR_IDS로 리터럴 삭제 * 🐛 fix: production 모드일때에만 작동하게 변경 * ♻️ refactor: 리터럴 코드 제거 - tr_id * ✨ feat: token retry 로직 추가 * 💄 style: stock controller import 순서 수정 * ✨ feat: custom filter 추가, exception도 추가 * ✨ feat: 주식을 소유한 사용자만 채팅 가능하도록 구현 * ✨ feat: 유저 서브네임 생성 로직 구현 * ✨ feat: 멘션 엔티티 구현 * ✨ feat: 특정 유저를 멘션하는 기능 구현 * 🐛 fix: minute fix per config * 🐛 fix: minute data 시간 체크 추가 * ✨ feat: 웹소켓 게이트 경로 변경 * 🐛 fix: output 파일 삭제, DI, 로직 변경(isMarketOpenTime이 정반대로 구현되어 있었음) * 🐛 fix: 웹 소켓 비어있는 쿠키 에러 문제 해결 * ♻️ refactor: openapi scraper service 에서 안 쓰이는 클래스 삭제 * 🐛 fix: injectable하게 변경, 이전 websocket 코드 삭제ㅔ * ✨ feat: websocket 연결 추가 * ✨ feat: live data 추가 * 🐛 fix: 기존 코드에서 object 검사 로직 변경 * ♻️ refactor: eslint 준수 * ✨ feat: 주식 데이터 조회 엔드포인트 통합 * 💄 style: 큰 따옴표 제거 * 🐛 fix: kospi stock 데이터 추가, 코드 리팩토링 * ✨ feat: 쿠키 sameSite 옵션 수정 * ✨ feat: 쿠키 sameSite 옵션 재설정 * 🐛 fix: detail 항목 해결 * 🐛 fix: detail NaN 임시 해결 * 🐛 fix: type ws 추가 * 🐛 fix: try catch 추가 * 🐛 fix: 커스텀 필터에 try-catch 추가 * ✨ feat: 쿠키 sameSite 옵션 재설정 * ✨ feat: 채팅 사용자 닉네임 정보 추가 * ✨ feat: 쿠키 옵션 초기화 * 🐛 fix: websocket 임시 수정 * ✨ feat: 테스터 유저 생성 기능 구현 * 🚚 chore: passport local 패키지 설치 * ✨ feat: 테스터 유저 service 구현 * ✨ feat: 테스터 유저 strategy 구현 * ✨ feat: 테스터 유저 guard 구현 * ✨ feat: 테스터 유저 로그인 기능 구현 * ♻️ refactor: google strategy 더이상 사용하지 않는 의존성 제거 * 🐛 fix: openapiPeriodData 수정 - 불필요한 private, filter 삭제, 로직 변경 * 🐛 fix: openapiToken - custom filter 삭제, 불필요한 private 삭제, try-catch로 변경 * ✨ feat: priority queue 추가 * 🐛 fix: type 수정 - undefined 추가 및 확인 * ♻️ refactor: websocket return 값 parse 분리후 테스트 추가 * ♻️ refactor: parse stock data를 다른 함수로 분리 * ♻️ refactor: web socket client 리팩토링 * ♻️ refactor: websocket Client 리팩토링 - initOpen, close등으로 분리 * 🐛 fix: openapilive data 수정 * ♻️ refactor: websocket client service 수정 * ♻️ refactor: api 폴더 이동 * 🐛 fix: websocket 에러 해결, import, DI 문제 해결 * 🐛 fix: stock gateway 수정, websocket - client 서빙 구조 변경 * 🐛 fix: 주식 상세 데이터 name 추 * Revert "🐛 fix: stock gateway 수정, websocket - client 서빙 구조 변경" This reverts commit 441926fc41b31d480820b331ae84dad070713653. * ♻️ refactor: stock detail 기본쿼리가 아닌 left join 으로 변경 * ✨ feat: 유저 랜덤 닉네임 변경 * ✨ feat: 테스터 로그인 엔드포인트 swagger 설정 * ✨ feat: 랜덤 닉네임 상수 추가 * Bug/#235 websocket 버그 해결, token db에 저장 (#240) * ✨ feat: token entity 추가 * ✨ feat: entity 에 저장, expire 검사 로직 추가 * 🐛 fix: token 주입으로 로직 변경 * ♻️ refactor: token 주입으로 변경, 그로 인한 오류 수정 및 console.log 삭제 * 🐛 fix: live 데이터 수집 오류 해결, 데이터 없을 때 insert 오류 해결 * 🐛 fix: stock, livedata entity 수정 * ♻️ refactor: develop 환경시 logging 활성화 * 📦️ ci: production 환경일 때 작동되게 변경 * ✨ feat: 거래중인 종목만 검색 * 🐛 fix: 중복된 닉네임 테스터 에러 수정 * ✨ feat: 채팅으로 멘션을 진행 * ✨ feat: 멘션 연관관계 설정 * 🐛 fix: 잘못된 검증으로 멘션이 안되는 문제 해결 * ✨ feat: 채팅스크롤에서 멘션 필드 추가 * ✨ feat: 닉네임과 서브네임으로 유저 닉네임 검색 * ✨ feat: 서브네임 like 적용 * livedata 수집 추가 + openapi로 장시간,마감 변경 로직, token을 주입할 수 있게 변경 (#246) * ✨ feat: token entity 추가 * ✨ feat: entity 에 저장, expire 검사 로직 추가 * 🐛 fix: token 주입으로 로직 변경 * ♻️ refactor: token 주입으로 변경, 그로 인한 오류 수정 및 console.log 삭제 * 🐛 fix: live 데이터 수집 오류 해결, 데이터 없을 때 insert 오류 해결 * 🐛 fix: stock, livedata entity 수정 * ♻️ refactor: develop 환경시 logging 활성화 * 📦️ ci: production 환경일 때 작동되게 변경 * ♻️ refactor: websocket 모듈에서 liveData로 서비스 로직 분리 * ♻️ refactor: livedata stock module로 이동 * ✨ feat: 장 마감시 openapi로 부르는 로직 추가 * 🐛 fix: websocket logger 추가, client stock 저장되지 않는 오류 해결 * 🐛 fix: openapi는 저장이 필요 없음 롤 백 * 🐛 fix: open api로 데이터 받지 못하는 문제 해결 * 💄 style: 안 쓰이는 것 빼기 * 💄 style: console.log 삭제 * 🐛 fix: 주식 컨트롤러에서 잘못된 경로 매핑문제 해결 * Feature/#110 - 특정 사용자를 멘션한다. (#249) * ✨ feat: 유저 서브네임 생성 로직 구현 * ✨ feat: 멘션 엔티티 구현 * ✨ feat: 특정 유저를 멘션하는 기능 구현 * 🐛 fix: 중복된 닉네임 테스터 에러 수정 * ✨ feat: 채팅으로 멘션을 진행 * ✨ feat: 멘션 연관관계 설정 * 🐛 fix: 잘못된 검증으로 멘션이 안되는 문제 해결 * ✨ feat: 채팅스크롤에서 멘션 필드 추가 * ✨ feat: 닉네임과 서브네임으로 유저 닉네임 검색 * ✨ feat: 서브네임 like 적용 * Feature/#251 - 주식 컨트롤러 잘못된 경로 매핑 문제 해결 (#252) * ✨ feat: 유저 서브네임 생성 로직 구현 * ✨ feat: 멘션 엔티티 구현 * ✨ feat: 특정 유저를 멘션하는 기능 구현 * 🐛 fix: 중복된 닉네임 테스터 에러 수정 * ✨ feat: 채팅으로 멘션을 진행 * ✨ feat: 멘션 연관관계 설정 * 🐛 fix: 잘못된 검증으로 멘션이 안되는 문제 해결 * ✨ feat: 채팅스크롤에서 멘션 필드 추가 * ✨ feat: 닉네임과 서브네임으로 유저 닉네임 검색 * ✨ feat: 서브네임 like 적용 * 🐛 fix: 주식 컨트롤러에서 잘못된 경로 매핑문제 해결 * Bug/#250 stock data 문제 해결 (#256) * ✨ feat: token entity 추가 * ✨ feat: entity 에 저장, expire 검사 로직 추가 * 🐛 fix: token 주입으로 로직 변경 * ♻️ refactor: token 주입으로 변경, 그로 인한 오류 수정 및 console.log 삭제 * 🐛 fix: live 데이터 수집 오류 해결, 데이터 없을 때 insert 오류 해결 * 🐛 fix: stock, livedata entity 수정 * ♻️ refactor: develop 환경시 logging 활성화 * 📦️ ci: production 환경일 때 작동되게 변경 * ♻️ refactor: websocket 모듈에서 liveData로 서비스 로직 분리 * ♻️ refactor: livedata stock module로 이동 * ✨ feat: 장 마감시 openapi로 부르는 로직 추가 * 🐛 fix: websocket logger 추가, client stock 저장되지 않는 오류 해결 * 🐛 fix: openapi는 저장이 필요 없음 롤 백 * 🐛 fix: open api로 데이터 받지 못하는 문제 해결 * 💄 style: 안 쓰이는 것 빼기 * 💄 style: console.log 삭제 * 🐛 fix: pk가 아닌 곳에 unique 키 추가 * 💄 style: 불필요한 logger 삭제 * ♻️ refactor: error, disconnect function 분리 * ♻️ refactor: stock data에 indexing, unique 거릭 * 🐛 fix: 타입 가드 빠진 부분을 추가하고, detail이 ISSUES CLOSED: 작동되는 것을 확인함 * 🐛 fix: 유량 제어 제거, try-catch로 다시 시작 추가 * ♻️ refactor: cron 추가 * 🐛 fix: cron * ♻️ refactor: period data 수정 * ♻️ refactor: console.log 삭제 * 🐛 fix: token의 expired 먼저 확인하고 db 접근으로 변경 * 🐛 fix: settimeout 시간 조정 * ♻️ refactor: 확인용 getItemchartprice 제거 --------- Co-authored-by: kimminsu <83896846+xjfcnfw3@users.noreply.github.com> * ✨ feat: 변동률 랭킹 엔티티 구현 * ✨ feat: 변동률 랭킹 api 데이터 수집 * 💄 style: 변동 랭킹 주식 관련 코드 eslint 형식으로 수정 * ✨ feat: 랭킹 api 데이터를 스크롤 * Bug/#257 detail 로직 변경 (#258) * ✨ feat: token entity 추가 * ✨ feat: entity 에 저장, expire 검사 로직 추가 * 🐛 fix: token 주입으로 로직 변경 * ♻️ refactor: token 주입으로 변경, 그로 인한 오류 수정 및 console.log 삭제 * 🐛 fix: live 데이터 수집 오류 해결, 데이터 없을 때 insert 오류 해결 * 🐛 fix: stock, livedata entity 수정 * ♻️ refactor: develop 환경시 logging 활성화 * 📦️ ci: production 환경일 때 작동되게 변경 * ♻️ refactor: websocket 모듈에서 liveData로 서비스 로직 분리 * ♻️ refactor: livedata stock module로 이동 * ✨ feat: 장 마감시 openapi로 부르는 로직 추가 * 🐛 fix: websocket logger 추가, client stock 저장되지 않는 오류 해결 * 🐛 fix: openapi는 저장이 필요 없음 롤 백 * 🐛 fix: open api로 데이터 받지 못하는 문제 해결 * 💄 style: 안 쓰이는 것 빼기 * 💄 style: console.log 삭제 * 🐛 fix: pk가 아닌 곳에 unique 키 추가 * 💄 style: 불필요한 logger 삭제 * ♻️ refactor: error, disconnect function 분리 * ♻️ refactor: stock data에 indexing, unique 거릭 * 🐛 fix: 타입 가드 빠진 부분을 추가하고, detail이 ISSUES CLOSED: 작동되는 것을 확인함 * 🐛 fix: 유량 제어 제거, try-catch로 다시 시작 추가 * ♻️ refactor: cron 추가 * 🐛 fix: cron * ♻️ refactor: period data 수정 * ♻️ refactor: console.log 삭제 * 🐛 fix: token의 expired 먼저 확인하고 db 접근으로 변경 * 🐛 fix: settimeout 시간 조정 * ♻️ refactor: 확인용 getItemchartprice 제거 * ✨ feat: detail 구현 완료 * 🐛 fix: insert시 데이터 있으면 발생하는 오류 수정 * 💄 style: 테스트용 start 삭제, cron만 남겨놓음 * 🐛 fix: unique column 조건 rollback * 🐛 fix: detail data에 시범적으로 추상클래스 적용 --------- Co-authored-by: kimminsu <83896846+xjfcnfw3@users.noreply.github.com> * 🐛 fix: openApiToken 타입 변경으로 인한 발생한 오류 수정 * ✨ feat: 구글 로그인 리다이렉트 주소 임시로 변경 * ✨ feat: 사용자 주식 삭제를 stock id 값을 통해 진행하도록 변경 * ✨ feat: 사용자 소유 주식 리스트 제공 엔드포인트 구현 * ✨ feat: 변동률 랭킹 조회시 라이브 데이터 수집 * ✨ feat: 구글 로그인 후 리다이렉트 url 변경 * 🐛 fix: console log 제거 * ✨ feat: 변동률 라이브 데이터 수집 스케줄러 수정 * 🐛 fix: 배포 환경에서 발생하는 칼럼 에러 문제 해결 * Bug/#267 - typeorm 기능으로 exist 확인, token 발급주기 단축(12시간) (#269) * ✨ feat: token entity 추가 * ✨ feat: entity 에 저장, expire 검사 로직 추가 * 🐛 fix: token 주입으로 로직 변경 * ♻️ refactor: token 주입으로 변경, 그로 인한 오류 수정 및 console.log 삭제 * 🐛 fix: live 데이터 수집 오류 해결, 데이터 없을 때 insert 오류 해결 * 🐛 fix: stock, livedata entity 수정 * ♻️ refactor: develop 환경시 logging 활성화 * 📦️ ci: production 환경일 때 작동되게 변경 * ♻️ refactor: websocket 모듈에서 liveData로 서비스 로직 분리 * ♻️ refactor: livedata stock module로 이동 * ✨ feat: 장 마감시 openapi로 부르는 로직 추가 * 🐛 fix: websocket logger 추가, client stock 저장되지 않는 오류 해결 * 🐛 fix: openapi는 저장이 필요 없음 롤 백 * 🐛 fix: open api로 데이터 받지 못하는 문제 해결 * 💄 style: 안 쓰이는 것 빼기 * 💄 style: console.log 삭제 * 🐛 fix: pk가 아닌 곳에 unique 키 추가 * 💄 style: 불필요한 logger 삭제 * ♻️ refactor: error, disconnect function 분리 * ♻️ refactor: stock data에 indexing, unique 거릭 * 🐛 fix: 타입 가드 빠진 부분을 추가하고, detail이 ISSUES CLOSED: 작동되는 것을 확인함 * 🐛 fix: 유량 제어 제거, try-catch로 다시 시작 추가 * ♻️ refactor: cron 추가 * 🐛 fix: cron * ♻️ refactor: period data 수정 * ♻️ refactor: console.log 삭제 * 🐛 fix: token의 expired 먼저 확인하고 db 접근으로 변경 * 🐛 fix: settimeout 시간 조정 * ♻️ refactor: 확인용 getItemchartprice 제거 * ✨ feat: detail 구현 완료 * 🐛 fix: insert시 데이터 있으면 발생하는 오류 수정 * 💄 style: 테스트용 start 삭제, cron만 남겨놓음 * 🐛 fix: unique column 조건 rollback * 🐛 fix: detail data에 시범적으로 추상클래스 적용 * 🐛 fix: abstract에 넣을 함수 start, step는 추상이 아닌 function으로 정의 * ✨ feat: index type 추가 * ✨ feat: type 추가 = TR_ID * ✨ feat: index 기능 틀만 추가 * 💄 style: logger livedata에 추가 * 💄 style: 테스트용 데이터 삭제 * 💄 style: 토큰 만료시간을 20시간에서 12시간으로 단축 * ♻️ refactor: 데이터 존재여부를 내가 직접 하는 것이 아닌 typeorm 기능으로 변경 * ♻️ refactor: openapiIndex query 추가 --------- Co-authored-by: kimminsu <83896846+xjfcnfw3@users.noreply.github.com> * Feature/#259 코스피, 코스닥, 원달러 환율 받아오기 (#275) * ✨ feat: token entity 추가 * ✨ feat: entity 에 저장, expire 검사 로직 추가 * 🐛 fix: token 주입으로 로직 변경 * ♻️ refactor: token 주입으로 변경, 그로 인한 오류 수정 및 console.log 삭제 * 🐛 fix: live 데이터 수집 오류 해결, 데이터 없을 때 insert 오류 해결 * 🐛 fix: stock, livedata entity 수정 * ♻️ refactor: develop 환경시 logging 활성화 * 📦️ ci: production 환경일 때 작동되게 변경 * ♻️ refactor: websocket 모듈에서 liveData로 서비스 로직 분리 * ♻️ refactor: livedata stock module로 이동 * ✨ feat: 장 마감시 openapi로 부르는 로직 추가 * 🐛 fix: websocket logger 추가, client stock 저장되지 않는 오류 해결 * 🐛 fix: openapi는 저장이 필요 없음 롤 백 * 🐛 fix: open api로 데이터 받지 못하는 문제 해결 * 💄 style: 안 쓰이는 것 빼기 * 💄 style: console.log 삭제 * 🐛 fix: pk가 아닌 곳에 unique 키 추가 * 💄 style: 불필요한 logger 삭제 * ♻️ refactor: error, disconnect function 분리 * ♻️ refactor: stock data에 indexing, unique 거릭 * 🐛 fix: 타입 가드 빠진 부분을 추가하고, detail이 ISSUES CLOSED: 작동되는 것을 확인함 * 🐛 fix: 유량 제어 제거, try-catch로 다시 시작 추가 * ♻️ refactor: cron 추가 * 🐛 fix: cron * ♻️ refactor: period data 수정 * ♻️ refactor: console.log 삭제 * 🐛 fix: token의 expired 먼저 확인하고 db 접근으로 변경 * 🐛 fix: settimeout 시간 조정 * ♻️ refactor: 확인용 getItemchartprice 제거 * ✨ feat: detail 구현 완료 * 🐛 fix: insert시 데이터 있으면 발생하는 오류 수정 * 💄 style: 테스트용 start 삭제, cron만 남겨놓음 * 🐛 fix: unique column 조건 rollback * 🐛 fix: detail data에 시범적으로 추상클래스 적용 * 🐛 fix: abstract에 넣을 함수 start, step는 추상이 아닌 function으로 정의 * ✨ feat: index type 추가 * ✨ feat: type 추가 = TR_ID * ✨ feat: index 기능 틀만 추가 * 💄 style: logger livedata에 추가 * 💄 style: 테스트용 데이터 삭제 * 💄 style: 토큰 만료시간을 20시간에서 12시간으로 단축 * ♻️ refactor: 데이터 존재여부를 내가 직접 하는 것이 아닌 typeorm 기능으로 변경 * ♻️ refactor: openapiIndex query 추가 * ✨ feat: index, rate 수집 완료 * 💄 style: 경에러 발생시logging 형식 변경 * 💄 style: live data pingpong logger 제거 * 💄 style: decorator eslint 형식 맞추기 * ✨ feat: 주가 지표, 환율 지표 response dto 추가 * ✨ feat: 주가 지표, 환율 컨트롤러 추가 * ✨ feat: controller추가, module 의존성 추ㅏㄱ * ✨ feat: 이전에 했던 내용들 머지 * 💄 style: minute cron 삭제 * ♻️ refactor: index 엔드포인트 하나로 축약 * ✨ feat: 테스트 코드 추가 * 💄 style: cron필요하지 않는 거 삭제 * ♻️ refactor: 안 쓰이는 openapi util 함수 삭제 * 📝 docs: 지표 설명이 부족한 거 같아 추가 --------- Co-authored-by: kimminsu <83896846+xjfcnfw3@users.noreply.github.com> * ✨ feat: 로그아웃 엔드포인트 구현 * ✨ feat: 로그인 상태 확인 시 닉네임도 전송 * ✨ feat: 유저 정보 조회 엔드포인트 구현 * ✨ feat: 유저 닉네임 변경 엔드포인트 구현 * ✨ feat: 닉네임 변경 엔드포인트 스웨거 설정 * ✨ feat: cors를 로컬 환경에 허용 * ✨ feat: 상위 조회수 주식 라이브 데이터 수집 구현 * ✨ feat: 주식 가격 상승 및 하락률 조회 API 구현 * ✨ feat: 상위 조회 수 라이브 데이터 요청 큐 적용 * 🐛 fix: update at 갱신 문제 해결 * 🐛 fix: 누락된 키 다시 추가 * ✨ feat: 변동률 라이브 데이터 수집 스케줄러 재가동 * ✨ feat: 채팅 response id 필드 추가 * ✨ feat: 변동률 주가 데이터 큐 적용 * ♻️ refactor: 변동률 순위에 연관된 중복 코드 제거 * ♻️ refactor: 조회수 순위에 연관된 중복 코드 제거 * ✨ feat: 큐의 요청이 여러 계좌에 적절하게 분배할 수 있도록 구현 * 🐛 fix: 각 계좌마다 1초씩 block되는 문제 해결 * 🐛 fix: 변동률 데이터가 누적되는 현상 수정 * ✨ feat: 변동률 결과를 엔드포인트를 쿼리에 따라서 전송하도록 구현 * ✨ feat: 채팅에 서브네임 추가 * ✨ feat: 유저 로그인 상태 조회 시 subName도 전송 * ✨ feat: 개별 채팅 메시지 타입 수정 * 🐛 fix: 변동률 데이터가 정해진 길이를 받지 않는 버그 수정 --------- Co-authored-by: kimminsu Co-authored-by: kimminsu <83896846+xjfcnfw3@users.noreply.github.com> Co-authored-by: baegyeong <102566546+baegyeong@users.noreply.github.com> --- .gitignore | 3 + packages/backend/.gitignore | 4 + packages/backend/package.json | 13 +- packages/backend/src/app.module.ts | 2 +- packages/backend/src/auth/auth.controller.ts | 54 ++++ packages/backend/src/auth/auth.module.ts | 17 +- .../src/auth/google/googleAuth.controller.ts | 22 +- .../auth/google/googleAuth.service.spec.ts | 2 +- .../auth/google/strategy/google.strategy.ts | 5 +- packages/backend/src/auth/session.module.ts | 2 +- ...on.guard..ts => webSocketSession.guard.ts} | 2 +- .../auth/session/websocketSession.service.ts | 30 +++ .../src/auth/tester/guard/tester.guard.ts | 16 ++ .../auth/tester/strategy/tester.strategy.ts | 16 ++ .../src/auth/tester/testerAuth.controller.ts | 51 ++++ .../src/auth/tester/testerAuth.service.ts | 11 + packages/backend/src/chat/chat.controller.ts | 71 ++++- packages/backend/src/chat/chat.gateway.ts | 129 ++++++--- packages/backend/src/chat/chat.module.ts | 12 +- .../backend/src/chat/chat.service.spec.ts | 26 ++ packages/backend/src/chat/chat.service.ts | 179 ++++++++---- .../src/chat/decorator/like.decorator.ts | 31 +++ .../backend/src/chat/domain/chat.entity.ts | 11 + .../backend/src/chat/domain/like.entity.ts | 28 ++ .../backend/src/chat/domain/mention.entity.ts | 28 ++ packages/backend/src/chat/dto/chat.request.ts | 30 ++- .../backend/src/chat/dto/chat.response.ts | 14 +- packages/backend/src/chat/dto/like.request.ts | 13 + .../backend/src/chat/dto/like.response.ts | 69 +++++ .../backend/src/chat/like.service.spec.ts | 68 +++++ packages/backend/src/chat/like.service.ts | 60 +++++ packages/backend/src/chat/mention.service.ts | 35 +++ .../backend/src/configs/session.config.ts | 2 +- .../backend/src/configs/swagger.config.ts | 2 +- .../{devTypeormConfig.ts => typeormConfig.ts} | 1 - packages/backend/src/main.ts | 16 +- .../src/scraper/domain/openapiToken.entity.ts | 28 ++ .../korea-stock-info/entities/stock.entity.ts | 23 -- .../korea-stock-info.service.ts | 2 +- .../scraper/openapi/api/openapi.abstract.ts | 51 ++++ .../openapi/api/openapiDetailData.api.ts | 86 ++++++ .../openapi/api/openapiFluctuationData.api.ts | 101 +++++++ .../scraper/openapi/api/openapiIndex.api.ts | 233 ++++++++++++++++ .../openapi/api/openapiLiveData.api.ts | 147 ++++++++++ .../openapi/api/openapiMinuteData.api.ts | 117 ++++++-- .../openapi/api/openapiPeriodData.api.ts | 154 ++++++----- .../openapi/api/openapiRankView.api.ts | 45 ++++ .../scraper/openapi/api/openapiToken.api.ts | 171 ++++++++++-- .../scraper/openapi/config/openapi.config.ts | 2 + .../src/scraper/openapi/constants/query.ts | 26 ++ .../src/scraper/openapi/liveData.service.ts | 167 ++++++++++++ .../scraper/openapi/openapi-scraper.module.ts | 52 +++- .../openapi/openapi-scraper.service.ts | 4 +- .../openapi/parse/openapi.parser.spec.ts | 106 ++++++++ .../scraper/openapi/parse/openapi.parser.ts | 38 +++ .../scraper/openapi/queue/openapi.queue.ts | 102 +++++++ .../openapi/type/openapiDetailData.type.ts | 168 ++++++++++++ .../scraper/openapi/type/openapiIndex.type.ts | 141 ++++++++++ .../openapi/type/openapiLiveData.type.ts | 255 ++++++++++++++++++ .../scraper/openapi/type/openapiPeriodData.ts | 3 +- .../openapi/type/openapiPeriodData.type.ts | 45 ++++ .../scraper/openapi/type/openapiUtil.type.ts | 21 ++ .../openapi/util/openapiCustom.error.ts | 13 + .../openapi/{ => util}/openapiUtil.api.ts | 47 +++- .../src/scraper/openapi/util/priorityQueue.ts | 86 ++++++ .../websocket/websocketClient.websocket.ts | 58 ++++ .../src/stock/decorator/stock.decorator.ts | 8 +- .../stock/decorator/stockData.decorator.ts | 8 + .../domain/FluctuationRankStock.entity.ts | 31 +++ .../src/stock/domain/kospiStock.entity.ts | 15 ++ .../backend/src/stock/domain/stock.entity.ts | 33 ++- .../src/stock/domain/stockData.entity.ts | 21 +- .../src/stock/domain/stockDetail.entity.ts | 2 +- .../src/stock/domain/stockLiveData.entity.ts | 3 - .../src/stock/domain/userStock.entity.ts | 8 +- .../backend/src/stock/dto/stock.Response.ts | 64 ----- .../backend/src/stock/dto/stock.request.ts | 12 + .../backend/src/stock/dto/stock.response.ts | 177 ++++++++++++ .../src/stock/dto/stockDetail.response.ts | 16 ++ .../src/stock/dto/stockIndexRate.response.ts | 41 +++ .../src/stock/dto/userStock.request.ts | 12 +- .../src/stock/dto/userStock.response.ts | 64 ++++- .../backend/src/stock/stock.controller.ts | 239 +++++++++++----- packages/backend/src/stock/stock.gateway.ts | 16 +- packages/backend/src/stock/stock.module.ts | 10 + .../backend/src/stock/stock.service.spec.ts | 7 +- packages/backend/src/stock/stock.service.ts | 107 +++++--- .../backend/src/stock/stockDetail.service.ts | 18 +- .../src/stock/stockRateIndex.service.spec.ts | 70 +++++ .../src/stock/stockRateIndex.service.ts | 43 +++ .../src/user/constants/randomNickname.ts | 16 ++ .../backend/src/user/domain/user.entity.ts | 8 + packages/backend/src/user/dto/user.request.ts | 12 + .../backend/src/user/dto/user.response.ts | 74 +++++ packages/backend/src/user/user.controller.ts | 74 ++++- .../backend/src/user/user.service.spec.ts | 65 ++++- packages/backend/src/user/user.service.ts | 104 ++++++- packages/backend/tsconfig.json | 6 +- test.js | 5 - yarn.lock | 112 ++++---- 100 files changed, 4473 insertions(+), 595 deletions(-) create mode 100644 packages/backend/src/auth/auth.controller.ts rename packages/backend/src/auth/session/{webSocketSession.guard..ts => webSocketSession.guard.ts} (96%) create mode 100644 packages/backend/src/auth/session/websocketSession.service.ts create mode 100644 packages/backend/src/auth/tester/guard/tester.guard.ts create mode 100644 packages/backend/src/auth/tester/strategy/tester.strategy.ts create mode 100644 packages/backend/src/auth/tester/testerAuth.controller.ts create mode 100644 packages/backend/src/auth/tester/testerAuth.service.ts create mode 100644 packages/backend/src/chat/chat.service.spec.ts create mode 100644 packages/backend/src/chat/decorator/like.decorator.ts create mode 100644 packages/backend/src/chat/domain/like.entity.ts create mode 100644 packages/backend/src/chat/domain/mention.entity.ts create mode 100644 packages/backend/src/chat/dto/like.request.ts create mode 100644 packages/backend/src/chat/dto/like.response.ts create mode 100644 packages/backend/src/chat/like.service.spec.ts create mode 100644 packages/backend/src/chat/like.service.ts create mode 100644 packages/backend/src/chat/mention.service.ts rename packages/backend/src/configs/{devTypeormConfig.ts => typeormConfig.ts} (99%) create mode 100644 packages/backend/src/scraper/domain/openapiToken.entity.ts delete mode 100644 packages/backend/src/scraper/korea-stock-info/entities/stock.entity.ts create mode 100644 packages/backend/src/scraper/openapi/api/openapi.abstract.ts create mode 100644 packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts create mode 100644 packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts create mode 100644 packages/backend/src/scraper/openapi/api/openapiIndex.api.ts create mode 100644 packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts create mode 100644 packages/backend/src/scraper/openapi/api/openapiRankView.api.ts create mode 100644 packages/backend/src/scraper/openapi/constants/query.ts create mode 100644 packages/backend/src/scraper/openapi/liveData.service.ts create mode 100644 packages/backend/src/scraper/openapi/parse/openapi.parser.spec.ts create mode 100644 packages/backend/src/scraper/openapi/parse/openapi.parser.ts create mode 100644 packages/backend/src/scraper/openapi/queue/openapi.queue.ts create mode 100644 packages/backend/src/scraper/openapi/type/openapiDetailData.type.ts create mode 100644 packages/backend/src/scraper/openapi/type/openapiIndex.type.ts create mode 100644 packages/backend/src/scraper/openapi/type/openapiLiveData.type.ts create mode 100644 packages/backend/src/scraper/openapi/type/openapiPeriodData.type.ts create mode 100644 packages/backend/src/scraper/openapi/type/openapiUtil.type.ts create mode 100644 packages/backend/src/scraper/openapi/util/openapiCustom.error.ts rename packages/backend/src/scraper/openapi/{ => util}/openapiUtil.api.ts (55%) create mode 100644 packages/backend/src/scraper/openapi/util/priorityQueue.ts create mode 100644 packages/backend/src/scraper/openapi/websocket/websocketClient.websocket.ts create mode 100644 packages/backend/src/stock/domain/FluctuationRankStock.entity.ts create mode 100644 packages/backend/src/stock/domain/kospiStock.entity.ts delete mode 100644 packages/backend/src/stock/dto/stock.Response.ts create mode 100644 packages/backend/src/stock/dto/stock.request.ts create mode 100644 packages/backend/src/stock/dto/stock.response.ts create mode 100644 packages/backend/src/stock/dto/stockIndexRate.response.ts create mode 100644 packages/backend/src/stock/stockRateIndex.service.spec.ts create mode 100644 packages/backend/src/stock/stockRateIndex.service.ts create mode 100644 packages/backend/src/user/constants/randomNickname.ts create mode 100644 packages/backend/src/user/dto/user.request.ts create mode 100644 packages/backend/src/user/dto/user.response.ts delete mode 100644 test.js diff --git a/.gitignore b/.gitignore index 66b03b45..bdc0ede0 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,6 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json # vscode setting .vscode + +# remote +.remote diff --git a/packages/backend/.gitignore b/packages/backend/.gitignore index 2f6b899c..cd939265 100644 --- a/packages/backend/.gitignore +++ b/packages/backend/.gitignore @@ -54,3 +54,7 @@ pids # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# backup file +.backup +.bak diff --git a/packages/backend/package.json b/packages/backend/package.json index c41b6046..bdd206db 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -17,7 +17,8 @@ "test:watch": "jest --watch", "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "jest --config ./test/jest-e2e.json" + "test:e2e": "jest --config ./test/jest-e2e.json", + "mem": "node ../../dist/main --inspect" }, "dependencies": { "@nestjs/common": "^10.0.0", @@ -25,11 +26,11 @@ "@nestjs/core": "^10.0.0", "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.0.0", - "@nestjs/platform-socket.io": "^10.4.7", + "@nestjs/platform-socket.io": "^10.4.8", "@nestjs/schedule": "^4.1.1", "@nestjs/swagger": "^8.0.5", "@nestjs/typeorm": "^10.0.2", - "@nestjs/websockets": "^10.4.7", + "@nestjs/websockets": "^10.4.8", "axios": "^1.7.7", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", @@ -39,13 +40,15 @@ "nest-winston": "^1.9.7", "passport": "^0.7.0", "passport-google-oauth20": "^2.0.0", + "passport-local": "^1.0.0", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", "socket.io": "^4.8.1", "typeorm": "^0.3.20", "unzipper": "^0.12.3", "winston": "^3.17.0", - "winston-daily-rotate-file": "^5.0.0" + "winston-daily-rotate-file": "^5.0.0", + "ws": "^8.18.0" }, "devDependencies": { "@nestjs/cli": "^10.0.0", @@ -56,8 +59,10 @@ "@types/jest": "^29.5.2", "@types/node": "^20.3.1", "@types/passport-google-oauth20": "^2.0.16", + "@types/passport-local": "^1.0.38", "@types/supertest": "^6.0.0", "@types/unzipper": "^0.10.10", + "@types/ws": "^8.5.13", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "cz-emoji-conventional": "^1.1.0", diff --git a/packages/backend/src/app.module.ts b/packages/backend/src/app.module.ts index d0c5f81d..f4735ad2 100644 --- a/packages/backend/src/app.module.ts +++ b/packages/backend/src/app.module.ts @@ -10,7 +10,7 @@ import { ChatModule } from '@/chat/chat.module'; import { typeormDevelopConfig, typeormProductConfig, -} from '@/configs/devTypeormConfig'; +} from '@/configs/typeormConfig'; import { logger } from '@/configs/logger.config'; import { StockModule } from '@/stock/stock.module'; import { UserModule } from '@/user/user.module'; diff --git a/packages/backend/src/auth/auth.controller.ts b/packages/backend/src/auth/auth.controller.ts new file mode 100644 index 00000000..4efcceac --- /dev/null +++ b/packages/backend/src/auth/auth.controller.ts @@ -0,0 +1,54 @@ +import { Controller, Get, Post, Req, Res } from '@nestjs/common'; +import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { Request, Response } from 'express'; +import { sessionConfig } from '@/configs/session.config'; +import { User } from '@/user/domain/user.entity'; + +@ApiTags('Auth') +@Controller('auth') +export class AuthController { + @ApiOperation({ + summary: '로그아웃', + description: '로그아웃을 진행한다.', + }) + @Post('/logout') + logout(@Req() req: Request, @Res() res: Response) { + req.logout((err) => { + if (err) { + return res + .status(500) + .send({ message: 'Failed to logout', error: err }); + } + req.session.destroy((destroyErr) => { + if (destroyErr) { + return res + .status(500) + .send({ message: 'Failed to destroy session', error: destroyErr }); + } + res.clearCookie(sessionConfig.name || 'connect.sid'); + return res.status(200).send({ message: 'Logged out successfully' }); + }); + }); + } + + @ApiOperation({ + summary: '로그인 상태 확인', + description: '로그인 상태를 확인합니다.', + }) + @ApiOkResponse({ + description: '로그인된 상태', + example: { message: 'Authenticated' }, + }) + @Get('/status') + async user(@Req() request: Request) { + if (request.user) { + const user = request.user as User; + return { + message: 'Authenticated', + nickname: user.nickname, + subName: user.subName, + }; + } + return { message: 'Not Authenticated', nickname: null, subName: null }; + } +} diff --git a/packages/backend/src/auth/auth.module.ts b/packages/backend/src/auth/auth.module.ts index f513d613..bb43faf7 100644 --- a/packages/backend/src/auth/auth.module.ts +++ b/packages/backend/src/auth/auth.module.ts @@ -1,13 +1,24 @@ import { Module } from '@nestjs/common'; +import { PassportModule } from '@nestjs/passport'; import { GoogleAuthController } from '@/auth/google/googleAuth.controller'; import { GoogleAuthService } from '@/auth/google/googleAuth.service'; import { GoogleStrategy } from '@/auth/google/strategy/google.strategy'; +import { AuthController } from '@/auth/auth.controller'; import { SessionSerializer } from '@/auth/session/session.serializer'; +import { TesterStrategy } from '@/auth/tester/strategy/tester.strategy'; +import { TesterAuthController } from '@/auth/tester/testerAuth.controller'; +import { TesterAuthService } from '@/auth/tester/testerAuth.service'; import { UserModule } from '@/user/user.module'; @Module({ - imports: [UserModule], - controllers: [GoogleAuthController], - providers: [GoogleStrategy, GoogleAuthService, SessionSerializer], + imports: [UserModule, PassportModule.register({ session: true })], + controllers: [GoogleAuthController, TesterAuthController, AuthController], + providers: [ + GoogleStrategy, + GoogleAuthService, + SessionSerializer, + TesterAuthService, + TesterStrategy, + ], }) export class AuthModule {} diff --git a/packages/backend/src/auth/google/googleAuth.controller.ts b/packages/backend/src/auth/google/googleAuth.controller.ts index 1d460748..12c1adec 100644 --- a/packages/backend/src/auth/google/googleAuth.controller.ts +++ b/packages/backend/src/auth/google/googleAuth.controller.ts @@ -1,6 +1,6 @@ -import { Controller, Get, Req, Res, UseGuards } from '@nestjs/common'; -import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; -import { Request, Response } from 'express'; +import { Controller, Get, Res, UseGuards } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { Response } from 'express'; import { GoogleAuthGuard } from '@/auth/google/guard/google.guard'; @ApiTags('Auth') @@ -23,20 +23,4 @@ export class GoogleAuthController { async handleRedirect(@Res() response: Response) { response.redirect('/'); } - - @ApiOperation({ - summary: '로그인 상태 확인', - description: '로그인 상태를 확인합니다.', - }) - @ApiOkResponse({ - description: '로그인된 상태', - example: { message: 'Authenticated' }, - }) - @Get('/status') - async user(@Req() request: Request) { - if (request.user) { - return { message: 'Authenticated' }; - } - return { message: 'Not Authenticated' }; - } } diff --git a/packages/backend/src/auth/google/googleAuth.service.spec.ts b/packages/backend/src/auth/google/googleAuth.service.spec.ts index 3d3f49be..1e714846 100644 --- a/packages/backend/src/auth/google/googleAuth.service.spec.ts +++ b/packages/backend/src/auth/google/googleAuth.service.spec.ts @@ -15,7 +15,7 @@ describe('GoogleAuthService 테스트', () => { }; test('oauthId와 type에 맞는 유저가 있으면 해당 객체를 반환한다.', async () => { - const user: User = { + const user: Partial = { id: 1, role: Role.USER, type: OauthType.GOOGLE, diff --git a/packages/backend/src/auth/google/strategy/google.strategy.ts b/packages/backend/src/auth/google/strategy/google.strategy.ts index b87b7945..28295a2d 100644 --- a/packages/backend/src/auth/google/strategy/google.strategy.ts +++ b/packages/backend/src/auth/google/strategy/google.strategy.ts @@ -1,9 +1,8 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { Profile, Strategy, VerifyCallback } from 'passport-google-oauth20'; import { GoogleAuthService } from '@/auth/google/googleAuth.service'; import { OauthType } from '@/user/domain/ouathType'; -import { Logger } from 'winston'; export interface OauthUserInfo { type: OauthType; @@ -15,7 +14,7 @@ export interface OauthUserInfo { @Injectable() export class GoogleStrategy extends PassportStrategy(Strategy) { - constructor(private readonly googleAuthService: GoogleAuthService, @Inject('winston') private readonly logger: Logger) { + constructor(private readonly googleAuthService: GoogleAuthService) { super({ clientID: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET, diff --git a/packages/backend/src/auth/session.module.ts b/packages/backend/src/auth/session.module.ts index 07c36bba..ddd58c70 100644 --- a/packages/backend/src/auth/session.module.ts +++ b/packages/backend/src/auth/session.module.ts @@ -1,7 +1,7 @@ import { Module } from '@nestjs/common'; import { MemoryStore } from 'express-session'; -export const MEMORY_STORE = 'memoryStore'; +export const MEMORY_STORE = Symbol('memoryStore'); @Module({ providers: [ diff --git a/packages/backend/src/auth/session/webSocketSession.guard..ts b/packages/backend/src/auth/session/webSocketSession.guard.ts similarity index 96% rename from packages/backend/src/auth/session/webSocketSession.guard..ts rename to packages/backend/src/auth/session/webSocketSession.guard.ts index a19ff956..f2c766a3 100644 --- a/packages/backend/src/auth/session/webSocketSession.guard..ts +++ b/packages/backend/src/auth/session/webSocketSession.guard.ts @@ -15,7 +15,7 @@ export interface SessionSocket extends Socket { session?: User; } -interface PassportSession extends SessionData { +export interface PassportSession extends SessionData { passport: { user: User }; } diff --git a/packages/backend/src/auth/session/websocketSession.service.ts b/packages/backend/src/auth/session/websocketSession.service.ts new file mode 100644 index 00000000..c6c248cc --- /dev/null +++ b/packages/backend/src/auth/session/websocketSession.service.ts @@ -0,0 +1,30 @@ +import { MemoryStore } from 'express-session'; +import { Socket } from 'socket.io'; +import { websocketCookieParse } from '@/auth/session/cookieParser'; +import { PassportSession } from '@/auth/session/webSocketSession.guard'; + +export class WebsocketSessionService { + constructor(private readonly sessionStore: MemoryStore) {} + + async getAuthenticatedUser(socket: Socket) { + try { + const cookieValue = websocketCookieParse(socket); + const session = await this.getSession(cookieValue); + return session ? session.passport.user : null; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (e) { + return null; + } + } + + private getSession(cookieValue: string) { + return new Promise((resolve) => { + this.sessionStore.get(cookieValue, (err: Error, session) => { + if (err || !session) { + resolve(null); + } + resolve(session as PassportSession); + }); + }); + } +} diff --git a/packages/backend/src/auth/tester/guard/tester.guard.ts b/packages/backend/src/auth/tester/guard/tester.guard.ts new file mode 100644 index 00000000..46f461d3 --- /dev/null +++ b/packages/backend/src/auth/tester/guard/tester.guard.ts @@ -0,0 +1,16 @@ +import { ExecutionContext, Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class TestAuthGuard extends AuthGuard('local') { + constructor() { + super(); + } + + async canActivate(context: ExecutionContext) { + const isActivate = (await super.canActivate(context)) as boolean; + const request = context.switchToHttp().getRequest(); + await super.logIn(request); + return isActivate; + } +} diff --git a/packages/backend/src/auth/tester/strategy/tester.strategy.ts b/packages/backend/src/auth/tester/strategy/tester.strategy.ts new file mode 100644 index 00000000..2c6939ca --- /dev/null +++ b/packages/backend/src/auth/tester/strategy/tester.strategy.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { Strategy } from 'passport-local'; +import { TesterAuthService } from '@/auth/tester/testerAuth.service'; + +@Injectable() +export class TesterStrategy extends PassportStrategy(Strategy) { + constructor(private readonly testerAuthService: TesterAuthService) { + super(); + } + + async validate(username: string, password: string, done: CallableFunction) { + const user = await this.testerAuthService.attemptAuthentication(); + done(null, user); + } +} diff --git a/packages/backend/src/auth/tester/testerAuth.controller.ts b/packages/backend/src/auth/tester/testerAuth.controller.ts new file mode 100644 index 00000000..4ee02553 --- /dev/null +++ b/packages/backend/src/auth/tester/testerAuth.controller.ts @@ -0,0 +1,51 @@ +import { Controller, Get, Req, Res, UseGuards } from '@nestjs/common'; +import { + ApiOkResponse, + ApiOperation, + ApiQuery, + ApiTags, +} from '@nestjs/swagger'; +import { Request, Response } from 'express'; +import { TestAuthGuard } from '@/auth/tester/guard/tester.guard'; + +@ApiTags('Auth') +@Controller('auth/tester') +export class TesterAuthController { + constructor() {} + + @ApiOperation({ + summary: '테스터 로그인 api', + description: '테스터로 로그인합니다.', + }) + @ApiQuery({ + name: 'username', + required: true, + description: '테스터 아이디(값만 넣으면 됨)', + }) + @ApiQuery({ + name: 'password', + required: true, + description: '테스터 비밀번호(값만 넣으면 됨)', + }) + @Get('/login') + @UseGuards(TestAuthGuard) + async handleLogin(@Res() response: Response) { + response.redirect('/'); + } + + @ApiOperation({ + summary: '로그인 상태 확인', + description: '로그인 상태를 확인합니다.', + }) + @ApiOkResponse({ + description: '로그인된 상태', + example: { message: 'Authenticated' }, + }) + @Get('/status') + async user(@Req() request: Request) { + if (request.user) { + return { message: 'Authenticated' }; + } + return { message: 'Not Authenticated' }; + } +} diff --git a/packages/backend/src/auth/tester/testerAuth.service.ts b/packages/backend/src/auth/tester/testerAuth.service.ts new file mode 100644 index 00000000..0982c4b0 --- /dev/null +++ b/packages/backend/src/auth/tester/testerAuth.service.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@nestjs/common'; +import { UserService } from '@/user/user.service'; + +@Injectable() +export class TesterAuthService { + constructor(private readonly userService: UserService) {} + + async attemptAuthentication() { + return await this.userService.registerTester(); + } +} diff --git a/packages/backend/src/chat/chat.controller.ts b/packages/backend/src/chat/chat.controller.ts index f2a5452d..35ce3a3b 100644 --- a/packages/backend/src/chat/chat.controller.ts +++ b/packages/backend/src/chat/chat.controller.ts @@ -1,16 +1,35 @@ -import { Controller, Get, Query } from '@nestjs/common'; +import { + Body, + Controller, + Get, + Post, + Query, + Req, + UseGuards, +} from '@nestjs/common'; import { ApiBadRequestResponse, ApiOkResponse, ApiOperation, } from '@nestjs/swagger'; +import SessionGuard from '@/auth/session/session.guard'; +import { ChatGateway } from '@/chat/chat.gateway'; import { ChatService } from '@/chat/chat.service'; -import { ChatScrollRequest } from '@/chat/dto/chat.request'; +import { ToggleLikeApi } from '@/chat/decorator/like.decorator'; +import { ChatScrollQuery } from '@/chat/dto/chat.request'; import { ChatScrollResponse } from '@/chat/dto/chat.response'; +import { LikeRequest } from '@/chat/dto/like.request'; +import { LikeService } from '@/chat/like.service'; +import { GetUser } from '@/common/decorator/user.decorator'; +import { User } from '@/user/domain/user.entity'; @Controller('chat') export class ChatController { - constructor(private readonly chatService: ChatService) {} + constructor( + private readonly chatService: ChatService, + private readonly likeService: LikeService, + private readonly chatGateWay: ChatGateway, + ) {} @ApiOperation({ summary: '채팅 스크롤 조회 API', @@ -29,11 +48,45 @@ export class ChatController { }, }) @Get() - async findChatList(@Query() request: ChatScrollRequest) { - return await this.chatService.scrollNextChat( - request.stockId, - request.latestChatId, - request.pageSize, - ); + async findChatList( + @Query() request: ChatScrollQuery, + @Req() req: Express.Request, + ) { + const user = req.user as User; + return await this.chatService.scrollChat(request, user?.id); + } + + @UseGuards(SessionGuard) + @ToggleLikeApi() + @Post('like') + async toggleChatLike(@Body() request: LikeRequest, @GetUser() user: User) { + const result = await this.likeService.toggleLike(user.id, request.chatId); + this.chatGateWay.broadcastLike(result); + return result; + } + + @ApiOperation({ + summary: '채팅 스크롤 조회 API(좋아요 순)', + description: '좋아요 순으로 채팅을 스크롤하여 조회한다.', + }) + @ApiOkResponse({ + description: '스크롤 조회 성공', + type: ChatScrollResponse, + }) + @ApiBadRequestResponse({ + description: '스크롤 크기 100 초과', + example: { + message: 'pageSize should be less than 100', + error: 'Bad Request', + statusCode: 400, + }, + }) + @Get('/like') + async findChatListByLike( + @Query() request: ChatScrollQuery, + @Req() req: Express.Request, + ) { + const user = req.user as User; + return await this.chatService.scrollChatByLike(request, user?.id); } } diff --git a/packages/backend/src/chat/chat.gateway.ts b/packages/backend/src/chat/chat.gateway.ts index f5fc099e..cabd0046 100644 --- a/packages/backend/src/chat/chat.gateway.ts +++ b/packages/backend/src/chat/chat.gateway.ts @@ -7,47 +7,54 @@ import { WebSocketGateway, WebSocketServer, } from '@nestjs/websockets'; +import { MemoryStore } from 'express-session'; import { Server, Socket } from 'socket.io'; import { Logger } from 'winston'; import { SessionSocket, WebSocketSessionGuard, -} from '@/auth/session/webSocketSession.guard.'; +} from '@/auth/session/webSocketSession.guard'; +import { WebsocketSessionService } from '@/auth/session/websocketSession.service'; +import { MEMORY_STORE } from '@/auth/session.module'; import { ChatService } from '@/chat/chat.service'; import { Chat } from '@/chat/domain/chat.entity'; +import { + ChatMessage, + ChatScrollQuery, + isChatScrollQuery, +} from '@/chat/dto/chat.request'; +import { ChatResponse } from '@/chat/dto/chat.response'; +import { LikeResponse } from '@/chat/dto/like.response'; +import { MentionService } from '@/chat/mention.service'; import { WebSocketExceptionFilter } from '@/middlewares/filter/webSocketException.filter'; import { StockService } from '@/stock/stock.service'; +import { User } from '@/user/domain/user.entity'; -interface chatMessage { - room: string; - content: string; -} - -interface chatResponse { - likeCount: number; - message: string; - type: string; - createdAt: Date; -} - -@WebSocketGateway({ namespace: 'chat' }) +@WebSocketGateway({ namespace: '/api/chat/realtime' }) @UseFilters(WebSocketExceptionFilter) export class ChatGateway implements OnGatewayConnection { @WebSocketServer() - server: Server; + private server: Server; + private websocketSessionService: WebsocketSessionService; + private users = new Map(); + constructor( @Inject('winston') private readonly logger: Logger, private readonly stockService: StockService, private readonly chatService: ChatService, - ) {} + private readonly mentionService: MentionService, + @Inject(MEMORY_STORE) sessionStore: MemoryStore, + ) { + this.websocketSessionService = new WebsocketSessionService(sessionStore); + } @UseGuards(WebSocketSessionGuard) @SubscribeMessage('chat') async handleConnectStock( - @MessageBody() message: chatMessage, + @MessageBody() message: ChatMessage, @ConnectedSocket() client: SessionSocket, ) { - const { room, content } = message; + const { room, content, mention } = message; if (!client.rooms.has(room)) { client.emit('error', 'You are not in the room'); this.logger.warn(`client is not in the room ${room}`); @@ -62,35 +69,89 @@ export class ChatGateway implements OnGatewayConnection { stockId: room, message: content, }); - this.server.to(room).emit('chat', this.toResponse(savedChat)); + if (mention) { + await this.mentionService.createMention(savedChat.id, mention); + const mentionedSocket = this.users.get(Number(mention)); + if (mentionedSocket) { + const chatResponse = this.toResponse(savedChat, client.session); + this.server.to(room).except(mentionedSocket).emit('chat', chatResponse); + chatResponse.mentioned = true; + this.server.to(mentionedSocket).emit('chat', chatResponse); + return; + } + } + this.server + .to(room) + .emit('chat', this.toResponse(savedChat, client.session)); + } + + async broadcastLike(response: LikeResponse) { + this.server.to(response.stockId).emit('like', response); } async handleConnection(client: Socket) { - const room = client.handshake.query.stockId; - if ( - !this.isString(room) || - !(await this.stockService.checkStockExist(room)) - ) { - client.emit('error', 'Invalid stockId'); - this.logger.warn(`client connected with invalid stockId: ${room}`); + try { + const user = + await this.websocketSessionService.getAuthenticatedUser(client); + const { stockId, pageSize } = await this.getChatScrollQuery(client); + await this.validateExistStock(stockId); + client.join(stockId); + const messages = await this.scrollChat(stockId, user, pageSize); + this.logger.info(`client joined room ${stockId}`); + client.emit('chat', messages); + if (user) { + this.users.set(user.id, client.id); + } + } catch (e) { + const error = e as Error; + this.logger.warn(error.message); + client.emit('error', error.message); client.disconnect(); - return; } - client.join(room); - const messages = await this.chatService.scrollFirstChat(room); - this.logger.info(`client joined room ${room}`); - client.emit('chat', messages); } - private isString(value: string | string[] | undefined): value is string { - return typeof value === 'string'; + private async scrollChat( + stockId: string, + user: User | null, + pageSize?: number, + ) { + return await this.chatService.scrollChat( + { + stockId, + pageSize, + }, + user?.id, + ); + } + + private async validateExistStock(stockId: string): Promise { + if (!(await this.stockService.checkStockExist(stockId))) { + throw new Error(`Stock does not exist: ${stockId}`); + } + } + + private async getChatScrollQuery(client: Socket): Promise { + const query = client.handshake.query; + if (!isChatScrollQuery(query)) { + throw new Error('Invalid chat scroll query'); + } + return { + stockId: query.stockId, + latestChatId: query.latestChatId ? Number(query.latestChatId) : undefined, + pageSize: query.pageSize ? Number(query.pageSize) : undefined, + }; } - private toResponse(chat: Chat): chatResponse { + private toResponse(chat: Chat, user: User): ChatResponse { return { + id: chat.id, likeCount: chat.likeCount, message: chat.message, type: chat.type, + mentioned: false, + nickname: user.nickname, + subName: user.subName, + liked: false, createdAt: chat.date?.createdAt || new Date(), }; } diff --git a/packages/backend/src/chat/chat.module.ts b/packages/backend/src/chat/chat.module.ts index 089b37e4..b58dc484 100644 --- a/packages/backend/src/chat/chat.module.ts +++ b/packages/backend/src/chat/chat.module.ts @@ -5,11 +5,19 @@ import { ChatController } from '@/chat/chat.controller'; import { ChatGateway } from '@/chat/chat.gateway'; import { ChatService } from '@/chat/chat.service'; import { Chat } from '@/chat/domain/chat.entity'; +import { Like } from '@/chat/domain/like.entity'; +import { Mention } from '@/chat/domain/mention.entity'; +import { LikeService } from '@/chat/like.service'; +import { MentionService } from '@/chat/mention.service'; import { StockModule } from '@/stock/stock.module'; @Module({ - imports: [TypeOrmModule.forFeature([Chat]), StockModule, SessionModule], + imports: [ + TypeOrmModule.forFeature([Chat, Like, Mention]), + StockModule, + SessionModule, + ], controllers: [ChatController], - providers: [ChatGateway, ChatService], + providers: [ChatGateway, ChatService, LikeService, MentionService], }) export class ChatModule {} diff --git a/packages/backend/src/chat/chat.service.spec.ts b/packages/backend/src/chat/chat.service.spec.ts new file mode 100644 index 00000000..1057ffed --- /dev/null +++ b/packages/backend/src/chat/chat.service.spec.ts @@ -0,0 +1,26 @@ +import { DataSource } from 'typeorm'; +import { ChatService } from '@/chat/chat.service'; +import { createDataSourceMock } from '@/user/user.service.spec'; + +describe('ChatService 테스트', () => { + test('첫 스크롤을 조회시 100개 이상 조회하면 예외가 발생한다.', async () => { + const dataSource = createDataSourceMock({}); + const chatService = new ChatService(dataSource as DataSource); + + await expect(() => + chatService.scrollChat({ + stockId: 'A005930', + pageSize: 101, + }), + ).rejects.toThrow('pageSize should be less than 100'); + }); + + test('100개 이상의 채팅을 조회하려 하면 예외가 발생한다.', async () => { + const dataSource = createDataSourceMock({}); + const chatService = new ChatService(dataSource as DataSource); + + await expect(() => + chatService.scrollChat({ stockId: 'A005930', pageSize: 101 }), + ).rejects.toThrow('pageSize should be less than 100'); + }); +}); diff --git a/packages/backend/src/chat/chat.service.ts b/packages/backend/src/chat/chat.service.ts index bfdf5c28..a0a0df68 100644 --- a/packages/backend/src/chat/chat.service.ts +++ b/packages/backend/src/chat/chat.service.ts @@ -1,13 +1,23 @@ -import { Injectable } from '@nestjs/common'; -import { DataSource } from 'typeorm'; +import { BadRequestException, Injectable } from '@nestjs/common'; +import { WsException } from '@nestjs/websockets'; +import { DataSource, EntityManager, SelectQueryBuilder } from 'typeorm'; import { Chat } from '@/chat/domain/chat.entity'; +import { ChatScrollQuery } from '@/chat/dto/chat.request'; import { ChatScrollResponse } from '@/chat/dto/chat.response'; +import { UserStock } from '@/stock/domain/userStock.entity'; export interface ChatMessage { message: string; stockId: string; } +const ORDER = { + LIKE: 'like', + LATEST: 'latest', +} as const; + +export type Order = (typeof ORDER)[keyof typeof ORDER]; + const DEFAULT_PAGE_SIZE = 20; @Injectable() @@ -15,25 +25,56 @@ export class ChatService { constructor(private readonly dataSource: DataSource) {} async saveChat(userId: number, chatMessage: ChatMessage) { - return this.dataSource.manager.save(Chat, { - user: { id: userId }, - stock: { id: chatMessage.stockId }, - message: chatMessage.message, + return this.dataSource.transaction(async (manager) => { + if (!(await this.hasStock(userId, chatMessage.stockId, manager))) { + throw new WsException('not have stock'); + } + return manager.save(Chat, { + user: { id: userId }, + stock: { id: chatMessage.stockId }, + message: chatMessage.message, + }); }); } - async scrollFirstChat(stockId: string, scrollSize?: number) { - const result = await this.findFirstChatScroll(stockId, scrollSize); - return await this.toScrollResponse(result, scrollSize); + async scrollChat(chatScrollQuery: ChatScrollQuery, userId?: number) { + this.validatePageSize(chatScrollQuery); + const result = await this.findChatScroll(chatScrollQuery, userId); + return await this.toScrollResponse(result, chatScrollQuery.pageSize); } - async scrollNextChat( - stockId: string, - latestChatId?: number, - pageSize?: number, + async scrollChatByLike(chatScrollQuery: ChatScrollQuery, userId?: number) { + this.validatePageSize(chatScrollQuery); + const result = await this.findChatScrollOrderByLike( + chatScrollQuery, + userId, + ); + return await this.toScrollResponse(result, chatScrollQuery.pageSize); + } + + async findChatScrollOrderByLike( + chatScrollQuery: ChatScrollQuery, + userId?: number, ) { - const result = await this.findChatScroll(stockId, latestChatId, pageSize); - return await this.toScrollResponse(result, pageSize); + const queryBuilder = await this.buildChatScrollQuery( + chatScrollQuery, + userId, + ORDER.LIKE, + ); + return queryBuilder.getMany(); + } + + private hasStock(userId: number, stockId: string, manager: EntityManager) { + return manager.exists(UserStock, { + where: { user: { id: userId }, stock: { id: stockId } }, + }); + } + + private validatePageSize(chatScrollQuery: ChatScrollQuery) { + const { pageSize } = chatScrollQuery; + if (pageSize && pageSize > 100) { + throw new BadRequestException('pageSize should be less than 100'); + } } private async toScrollResponse(result: Chat[], pageSize: number | undefined) { @@ -46,45 +87,91 @@ export class ChatService { } private async findChatScroll( - stockId: string, - latestChatId?: number, - pageSize?: number, + chatScrollQuery: ChatScrollQuery, + userId?: number, ) { - if (!latestChatId) { - return await this.findFirstChatScroll(stockId, pageSize); - } else { - return await this.findNextChatScroll(stockId, latestChatId, pageSize); - } + const queryBuilder = await this.buildChatScrollQuery( + chatScrollQuery, + userId, + ); + return queryBuilder.getMany(); } - private async findFirstChatScroll(stockId: string, pageSize?: number) { - const queryBuilder = this.dataSource.createQueryBuilder(Chat, 'chat'); - if (!pageSize) { - pageSize = DEFAULT_PAGE_SIZE; + private async buildChatScrollQuery( + chatScrollQuery: ChatScrollQuery, + userId?: number, + order: Order = ORDER.LATEST, + ) { + const { stockId, latestChatId, pageSize } = chatScrollQuery; + const size = pageSize ? pageSize : DEFAULT_PAGE_SIZE; + const queryBuilder = await this.buildInitialChatScrollQuery( + stockId, + size, + userId, + ); + if (order === ORDER.LIKE) { + return this.buildLikeCountQuery(queryBuilder, latestChatId); } - return queryBuilder - .where('chat.stock_id = :stockId', { stockId }) - .orderBy('chat.id', 'DESC') - .limit(pageSize + 1) - .getMany(); + return this.buildLatestChatIdQuery(queryBuilder, latestChatId); } - private async findNextChatScroll( + private async buildInitialChatScrollQuery( stockId: string, - latestChatId: number, - pageSize?: number, + size: number, + userId?: number, ) { - const queryBuilder = this.dataSource.createQueryBuilder(Chat, 'chat'); - if (!pageSize) { - pageSize = DEFAULT_PAGE_SIZE; - } - return queryBuilder - .where('chat.stock_id = :stockId and chat.id < :latestChatId', { - stockId, - latestChatId, + return this.dataSource + .createQueryBuilder(Chat, 'chat') + .leftJoinAndSelect('chat.likes', 'like', 'like.user_id = :userId', { + userId, }) - .orderBy('chat.id', 'DESC') - .limit(pageSize + 1) - .getMany(); + .leftJoinAndSelect( + 'chat.mentions', + 'mention', + 'mention.user_id = :userId', + { + userId, + }, + ) + .leftJoinAndSelect('chat.user', 'user') + .where('chat.stock_id = :stockId', { stockId }) + .take(size + 1); + } + + private async buildLikeCountQuery( + queryBuilder: SelectQueryBuilder, + latestChatId?: number, + ) { + queryBuilder + .orderBy('chat.likeCount', 'DESC') + .addOrderBy('chat.id', 'DESC'); + if (latestChatId) { + const chat = await this.dataSource.manager.findOne(Chat, { + where: { id: latestChatId }, + select: ['likeCount'], + }); + if (chat) { + queryBuilder.andWhere( + 'chat.likeCount < :likeCount or' + + ' (chat.likeCount = :likeCount and chat.id < :latestChatId)', + { + likeCount: chat.likeCount, + latestChatId, + }, + ); + } + } + return queryBuilder; + } + + private async buildLatestChatIdQuery( + queryBuilder: SelectQueryBuilder, + latestChatId?: number, + ) { + queryBuilder.orderBy('chat.id', 'DESC'); + if (latestChatId) { + queryBuilder.andWhere('chat.id < :latestChatId', { latestChatId }); + } + return queryBuilder; } } diff --git a/packages/backend/src/chat/decorator/like.decorator.ts b/packages/backend/src/chat/decorator/like.decorator.ts new file mode 100644 index 00000000..e1a1ea6b --- /dev/null +++ b/packages/backend/src/chat/decorator/like.decorator.ts @@ -0,0 +1,31 @@ +import { applyDecorators } from '@nestjs/common'; +import { + ApiBadRequestResponse, + ApiCookieAuth, + ApiOkResponse, + ApiOperation, +} from '@nestjs/swagger'; +import { LikeResponse } from '@/chat/dto/like.response'; + +// eslint-disable-next-line @typescript-eslint/naming-convention +export function ToggleLikeApi() { + return applyDecorators( + ApiCookieAuth(), + ApiOperation({ + summary: '채팅 좋아요 토글 API', + description: '채팅 좋아요를 토글한다.', + }), + ApiOkResponse({ + description: '좋아요 성공', + type: LikeResponse, + }), + ApiBadRequestResponse({ + description: '채팅이 존재하지 않음', + example: { + message: 'Chat not found', + error: 'Bad Request', + statusCode: 400, + }, + }), + ); +} diff --git a/packages/backend/src/chat/domain/chat.entity.ts b/packages/backend/src/chat/domain/chat.entity.ts index 13800bff..ccaec4c8 100644 --- a/packages/backend/src/chat/domain/chat.entity.ts +++ b/packages/backend/src/chat/domain/chat.entity.ts @@ -1,14 +1,18 @@ import { Column, Entity, + Index, JoinColumn, ManyToOne, + OneToMany, PrimaryGeneratedColumn, } from 'typeorm'; import { ChatType } from '@/chat/domain/chatType.enum'; +import { Like } from '@/chat/domain/like.entity'; import { DateEmbedded } from '@/common/dateEmbedded.entity'; import { Stock } from '@/stock/domain/stock.entity'; import { User } from '@/user/domain/user.entity'; +import { Mention } from '@/chat/domain/mention.entity'; @Entity() export class Chat { @@ -23,15 +27,22 @@ export class Chat { @JoinColumn({ name: 'stock_id' }) stock: Stock; + @OneToMany(() => Like, (like) => like.chat) + likes?: Like[]; + @Column() message: string; @Column({ type: 'enum', enum: ChatType, default: ChatType.NORMAL }) type: ChatType = ChatType.NORMAL; + @Index() @Column({ name: 'like_count', default: 0 }) likeCount: number = 0; @Column(() => DateEmbedded, { prefix: '' }) date: DateEmbedded; + + @OneToMany(() => Mention, (mention) => mention.chat) + mentions: Mention[]; } diff --git a/packages/backend/src/chat/domain/like.entity.ts b/packages/backend/src/chat/domain/like.entity.ts new file mode 100644 index 00000000..261e96e8 --- /dev/null +++ b/packages/backend/src/chat/domain/like.entity.ts @@ -0,0 +1,28 @@ +import { + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { Chat } from '@/chat/domain/chat.entity'; +import { User } from '@/user/domain/user.entity'; + +@Index('chat_user_unique', ['chat', 'user'], { unique: true }) +@Entity() +export class Like { + @PrimaryGeneratedColumn() + id: number; + + @ManyToOne(() => Chat, (chat) => chat.id) + @JoinColumn({ name: 'chat_id' }) + chat: Chat; + + @ManyToOne(() => User, (user) => user.id) + @JoinColumn({ name: 'user_id' }) + user: User; + + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + createdAt: Date; +} diff --git a/packages/backend/src/chat/domain/mention.entity.ts b/packages/backend/src/chat/domain/mention.entity.ts new file mode 100644 index 00000000..c68ca8fb --- /dev/null +++ b/packages/backend/src/chat/domain/mention.entity.ts @@ -0,0 +1,28 @@ +import { + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { Chat } from '@/chat/domain/chat.entity'; +import { User } from '@/user/domain/user.entity'; + +@Index('chat_user_unique', ['chat', 'user']) +@Entity() +export class Mention { + @PrimaryGeneratedColumn() + id: number; + + @ManyToOne(() => Chat, (chat) => chat.id) + @JoinColumn({ name: 'chat_id' }) + chat: Chat; + + @ManyToOne(() => User, (user) => user.id) + @JoinColumn({ name: 'user_id' }) + user: User; + + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + createdAt: Date; +} diff --git a/packages/backend/src/chat/dto/chat.request.ts b/packages/backend/src/chat/dto/chat.request.ts index f3260509..3d970fdd 100644 --- a/packages/backend/src/chat/dto/chat.request.ts +++ b/packages/backend/src/chat/dto/chat.request.ts @@ -1,13 +1,13 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsNumber, IsOptional, IsString } from 'class-validator'; -export class ChatScrollRequest { +export class ChatScrollQuery { @ApiProperty({ description: '종목 주식 id(종목방 id)', example: 'A005930', }) @IsString() - readonly stockId: string; + stockId: string; @ApiProperty({ description: '최신 채팅 id', @@ -16,7 +16,7 @@ export class ChatScrollRequest { }) @IsOptional() @IsNumber() - readonly latestChatId?: number; + latestChatId?: number; @ApiProperty({ description: '페이지 크기', @@ -26,5 +26,27 @@ export class ChatScrollRequest { }) @IsOptional() @IsNumber() - readonly pageSize?: number; + pageSize?: number; +} + +export function isChatScrollQuery(object: unknown): object is ChatScrollQuery { + if (typeof object !== 'object' || object === null) { + return false; + } + if (!('stockId' in object) || typeof object.stockId !== 'string') { + return false; + } + if ( + 'latestChatId' in object && + !Number.isInteger(Number(object.latestChatId)) + ) { + return false; + } + return !('pageSize' in object && !Number.isInteger(Number(object.pageSize))); +} + +export interface ChatMessage { + room: string; + content: string; + mention?: number; } diff --git a/packages/backend/src/chat/dto/chat.response.ts b/packages/backend/src/chat/dto/chat.response.ts index 48aacb0d..c2b28b9e 100644 --- a/packages/backend/src/chat/dto/chat.response.ts +++ b/packages/backend/src/chat/dto/chat.response.ts @@ -2,11 +2,15 @@ import { ApiProperty } from '@nestjs/swagger'; import { Chat } from '@/chat/domain/chat.entity'; import { ChatType } from '@/chat/domain/chatType.enum'; -interface ChatResponse { +export interface ChatResponse { id: number; likeCount: number; message: string; type: string; + liked: boolean; + nickname: string; + subName: string; + mentioned: boolean; createdAt: Date; } @@ -24,8 +28,12 @@ export class ChatScrollResponse { id: 1, likeCount: 0, message: '안녕하세요', + nickname: '초보 주주', type: ChatType.NORMAL, + isLiked: true, createdAt: new Date(), + mentioned: false, + subName: '0001', }, ], }) @@ -38,6 +46,10 @@ export class ChatScrollResponse { message: chat.message, type: chat.type, createdAt: chat.date!.createdAt, + liked: !!(chat.likes && chat.likes.length > 0), + mentioned: chat.mentions && chat.mentions.length > 0, + nickname: chat.user.nickname, + subName: chat.user.subName, })); this.hasMore = hasMore; } diff --git a/packages/backend/src/chat/dto/like.request.ts b/packages/backend/src/chat/dto/like.request.ts new file mode 100644 index 00000000..02feec50 --- /dev/null +++ b/packages/backend/src/chat/dto/like.request.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNumber } from 'class-validator'; + +export class LikeRequest { + @ApiProperty({ + required: true, + type: Number, + description: '좋아요를 누를 채팅의 ID', + example: 1, + }) + @IsNumber() + chatId: number; +} diff --git a/packages/backend/src/chat/dto/like.response.ts b/packages/backend/src/chat/dto/like.response.ts new file mode 100644 index 00000000..5c6ff7fa --- /dev/null +++ b/packages/backend/src/chat/dto/like.response.ts @@ -0,0 +1,69 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Chat } from '@/chat/domain/chat.entity'; + +export class LikeResponse { + @ApiProperty({ + type: Number, + description: '좋아요를 누른 채팅의 ID', + example: 1, + }) + chatId: number; + + @ApiProperty({ + type: 'string', + description: '참여 중인 좀목 id', + example: 'A005930', + }) + stockId: string; + + @ApiProperty({ + type: Number, + description: '채팅의 좋아요 수', + example: 45, + }) + likeCount: number; + + @ApiProperty({ + type: String, + description: '결과 메시지', + example: 'like chat', + }) + message: string; + + @ApiProperty({ + type: Date, + description: '좋아요를 누른 시간', + example: '2021-08-01T00:00:00', + }) + date: Date; + + static createLikeResponse(chat: Chat): LikeResponse { + if (!isStockId(chat.stock.id)) { + throw new Error(`Stock id is undefined: ${chat.id}`); + } + return { + stockId: chat.stock.id, + chatId: chat.id, + likeCount: chat.likeCount, + message: 'like chat', + date: chat.date.updatedAt, + }; + } + + static createUnlikeResponse(chat: Chat): LikeResponse { + if (!isStockId(chat.stock.id)) { + throw new Error(`Stock id is undefined: ${chat.id}`); + } + return { + stockId: chat.stock.id, + chatId: chat.id, + likeCount: chat.likeCount, + message: 'like cancel', + date: chat.date.updatedAt, + }; + } +} + +function isStockId(stockId?: string): stockId is string { + return stockId !== undefined; +} \ No newline at end of file diff --git a/packages/backend/src/chat/like.service.spec.ts b/packages/backend/src/chat/like.service.spec.ts new file mode 100644 index 00000000..5df6fed8 --- /dev/null +++ b/packages/backend/src/chat/like.service.spec.ts @@ -0,0 +1,68 @@ +import { DataSource } from 'typeorm'; +import { Chat } from '@/chat/domain/chat.entity'; +import { Like } from '@/chat/domain/like.entity'; +import { LikeService } from '@/chat/like.service'; +import { Stock } from '@/stock/domain/stock.entity'; +import { User } from '@/user/domain/user.entity'; +import { createDataSourceMock } from '@/user/user.service.spec'; + +function createChat(): Chat { + return { + stock: new Stock(), + user: new User(), + id: 1, + likeCount: 1, + message: '안녕하세요', + type: 'NORMAL', + date: { + createdAt: new Date(), + updatedAt: new Date(), + }, + }; +} + +describe('LikeService 테스트', () => { + test('존재하지 않는 채팅을 좋아요를 시도하면 예외가 발생한다.', () => { + const managerMock = { + findOne: jest.fn().mockResolvedValue(null), + }; + const datasource = createDataSourceMock(managerMock); + const likeService = new LikeService(datasource as DataSource); + + expect(likeService.toggleLike(1, 1)).rejects.toThrow('Chat not found'); + }); + + test('특정 채팅에 좋아요를 한다.', async () => { + const chat = createChat(); + const managerMock = { + findOne: jest + .fn() + .mockResolvedValueOnce(chat) + .mockResolvedValueOnce(null), + save: jest.fn(), + }; + const datasource = createDataSourceMock(managerMock); + const likeService = new LikeService(datasource as DataSource); + + const response = await likeService.toggleLike(1, 1); + + expect(response.likeCount).toBe(2); + }); + + test('특정 채팅에 좋아요를 취소한다.', async () => { + const chat = createChat(); + const managerMock = { + findOne: jest + .fn() + .mockResolvedValueOnce(chat) + .mockResolvedValueOnce(new Like()), + remove: jest.fn(), + }; + const datasource = createDataSourceMock(managerMock); + const likeService = new LikeService(datasource as DataSource); + + const response = await likeService.toggleLike(1, 1); + + expect(response.likeCount).toBe(0); + }); +}); diff --git a/packages/backend/src/chat/like.service.ts b/packages/backend/src/chat/like.service.ts new file mode 100644 index 00000000..a1495905 --- /dev/null +++ b/packages/backend/src/chat/like.service.ts @@ -0,0 +1,60 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { DataSource, EntityManager } from 'typeorm'; +import { Chat } from '@/chat/domain/chat.entity'; +import { Like } from '@/chat/domain/like.entity'; +import { LikeResponse } from '@/chat/dto/like.response'; + +@Injectable() +export class LikeService { + constructor(private readonly dataSource: DataSource) {} + + async toggleLike(userId: number, chatId: number) { + return await this.dataSource.transaction(async (manager) => { + const chat = await this.findChat(chatId, manager); + const like = await manager.findOne(Like, { + where: { user: { id: userId }, chat: { id: chatId } }, + }); + if (like) { + return await this.deleteLike(manager, chat, like); + } + return await this.saveLike(manager, chat, userId); + }); + } + + private async findChat(chatId: number, manager: EntityManager) { + const chat = await manager.findOne(Chat, { + where: { id: chatId }, + relations: ['stock'], + }); + if (!chat) { + throw new BadRequestException('Chat not found'); + } + return chat; + } + + private async saveLike( + manager: EntityManager, + chat: Chat, + userId: number, + ): Promise { + chat.likeCount += 1; + await Promise.all([ + manager.save(Like, { + user: { id: userId }, + chat, + }), + manager.save(Chat, chat), + ]); + return LikeResponse.createLikeResponse(chat); + } + + private async deleteLike( + manager: EntityManager, + chat: Chat, + like: Like, + ): Promise { + chat.likeCount -= 1; + await Promise.all([manager.remove(like), manager.save(Chat, chat)]); + return LikeResponse.createUnlikeResponse(chat); + } +} diff --git a/packages/backend/src/chat/mention.service.ts b/packages/backend/src/chat/mention.service.ts new file mode 100644 index 00000000..f5e49133 --- /dev/null +++ b/packages/backend/src/chat/mention.service.ts @@ -0,0 +1,35 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource, EntityManager } from 'typeorm'; +import { Chat } from '@/chat/domain/chat.entity'; +import { Mention } from '@/chat/domain/mention.entity'; +import { User } from '@/user/domain/user.entity'; + +@Injectable() +export class MentionService { + constructor(private readonly dataSource: DataSource) {} + + async createMention(chatId: number, userId: number) { + return this.dataSource.transaction(async (manager) => { + if (!(await this.existsChatAndUser(chatId, userId, manager))) { + return null; + } + return await manager.save(Mention, { + chat: { id: chatId }, + user: { id: userId }, + }); + }); + } + + async existsChatAndUser( + chatId: number, + userId: number, + manager: EntityManager, + ) { + if (!(await manager.exists(User, { where: { id: userId } }))) { + return false; + } + return await manager.exists(Chat, { + where: { id: chatId }, + }); + } +} diff --git a/packages/backend/src/configs/session.config.ts b/packages/backend/src/configs/session.config.ts index 65c49b96..6cf984cd 100644 --- a/packages/backend/src/configs/session.config.ts +++ b/packages/backend/src/configs/session.config.ts @@ -2,7 +2,6 @@ import { randomUUID } from 'node:crypto'; import * as dotenv from 'dotenv'; dotenv.config(); - export const sessionConfig = { secret: process.env.COOKIE_SECRET || randomUUID().toString(), resave: false, @@ -10,5 +9,6 @@ export const sessionConfig = { name: process.env.COOKIE_NAME, cookie: { maxAge: Number(process.env.COOKIE_MAX_AGE), + httpOnly: true, }, }; diff --git a/packages/backend/src/configs/swagger.config.ts b/packages/backend/src/configs/swagger.config.ts index ad58834f..c9d9dfd5 100644 --- a/packages/backend/src/configs/swagger.config.ts +++ b/packages/backend/src/configs/swagger.config.ts @@ -9,5 +9,5 @@ export function useSwagger(app: INestApplication) { .build(); const documentFactory = () => SwaggerModule.createDocument(app, config); - SwaggerModule.setup('swagger', app, documentFactory); + SwaggerModule.setup('api', app, documentFactory); } diff --git a/packages/backend/src/configs/devTypeormConfig.ts b/packages/backend/src/configs/typeormConfig.ts similarity index 99% rename from packages/backend/src/configs/devTypeormConfig.ts rename to packages/backend/src/configs/typeormConfig.ts index 2641d8d9..a8c70a18 100644 --- a/packages/backend/src/configs/devTypeormConfig.ts +++ b/packages/backend/src/configs/typeormConfig.ts @@ -24,4 +24,3 @@ export const typeormDevelopConfig: TypeOrmModuleOptions = { //logging: true, synchronize: true, }; - diff --git a/packages/backend/src/main.ts b/packages/backend/src/main.ts index 0be16e68..d9c69898 100644 --- a/packages/backend/src/main.ts +++ b/packages/backend/src/main.ts @@ -1,4 +1,4 @@ -import { ValidationPipe } from '@nestjs/common'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; import * as session from 'express-session'; import * as passport from 'passport'; @@ -7,6 +7,19 @@ import { MEMORY_STORE } from '@/auth/session.module'; import { sessionConfig } from '@/configs/session.config'; import { useSwagger } from '@/configs/swagger.config'; +const setCors = (app: INestApplication) => { + app.enableCors({ + origin: [ + 'http://localhost:3000', + 'https://juchum.info', + 'http://localhost:5173', + ], + methods: '*', + allowedHeaders: '*', + credentials: true, + }); +}; + async function bootstrap() { const app = await NestFactory.create(AppModule); const store = app.get(MEMORY_STORE); @@ -19,6 +32,7 @@ async function bootstrap() { transformOptions: { enableImplicitConversion: true }, }), ); + setCors(app); useSwagger(app); app.use(passport.initialize()); app.use(passport.session()); diff --git a/packages/backend/src/scraper/domain/openapiToken.entity.ts b/packages/backend/src/scraper/domain/openapiToken.entity.ts new file mode 100644 index 00000000..613abcb5 --- /dev/null +++ b/packages/backend/src/scraper/domain/openapiToken.entity.ts @@ -0,0 +1,28 @@ +import { Column, Entity, PrimaryColumn } from 'typeorm'; + +@Entity({ name: 'openapi_token' }) +export class OpenapiToken { + @PrimaryColumn({ name: 'account' }) + account: string; + + @Column({ name: 'apiUrl' }) + api_url: string; + + @Column({ name: 'key' }) + api_key: string; + + @Column({ name: 'password' }) + api_password: string; + + @Column({ name: 'token', length: 512 }) + api_token?: string; + + @Column({ name: 'tokenExpire' }) + api_token_expire?: Date; + + @Column({ name: 'websocketKey' }) + websocket_key?: string; + + @Column({ name: 'websocketKeyExpire' }) + websocket_key_expire?: Date; +} diff --git a/packages/backend/src/scraper/korea-stock-info/entities/stock.entity.ts b/packages/backend/src/scraper/korea-stock-info/entities/stock.entity.ts deleted file mode 100644 index ad721147..00000000 --- a/packages/backend/src/scraper/korea-stock-info/entities/stock.entity.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm'; - -//TODO : entity update require -@Entity() -export class Master { - @PrimaryGeneratedColumn({ type: 'int', unsigned: true }) - id?: number; - - @Column() - shortCode?: string; - - @Column() - standardCode?: string; - - @Column() - koreanName?: string; - - @Column() - groupCode?: string; - - @Column() - marketCapSize?: string; -} diff --git a/packages/backend/src/scraper/korea-stock-info/korea-stock-info.service.ts b/packages/backend/src/scraper/korea-stock-info/korea-stock-info.service.ts index 618bb349..430b9f6a 100644 --- a/packages/backend/src/scraper/korea-stock-info/korea-stock-info.service.ts +++ b/packages/backend/src/scraper/korea-stock-info/korea-stock-info.service.ts @@ -21,7 +21,7 @@ export class KoreaStockInfoService { private readonly datasource: DataSource, @Inject('winston') private readonly logger: Logger, ) { - //this.initKoreaStockInfo(); + this.initKoreaStockInfo(); } private async existsStockInfo(stockId: string, manager: EntityManager) { diff --git a/packages/backend/src/scraper/openapi/api/openapi.abstract.ts b/packages/backend/src/scraper/openapi/api/openapi.abstract.ts new file mode 100644 index 00000000..f5b63bf9 --- /dev/null +++ b/packages/backend/src/scraper/openapi/api/openapi.abstract.ts @@ -0,0 +1,51 @@ +import { DataSource } from 'typeorm'; +import { openApiConfig } from '../config/openapi.config'; +import { OpenapiTokenApi } from './openapiToken.api'; +import { Stock } from '@/stock/domain/stock.entity'; + +export abstract class Openapi { + constructor( + protected readonly datasource: DataSource, + protected readonly config: OpenapiTokenApi, + protected readonly gapTime: number, + ) {} + protected abstract step(idx: number, stock: Stock): Promise; + + protected abstract getFromUrl( + config: typeof openApiConfig, + stockId: string, + ): object; + + protected abstract convertResToEntity(res: object, stockId: string): object; + + protected abstract save(entity: object): Promise; + + async start() { + const stock = await this.getStockId(); + const len = (await this.config.configs()).length; + const stockSize = Math.ceil(stock.length / len); + let i = 0; + while (i < len) { + this.interval(i, stock.slice(i * stockSize, (i + 1) * stockSize)); + i++; + } + } + + protected async interval(idx: number, stocks: Stock[]) { + let time = 0; + for (const stock of stocks) { + setTimeout(() => this.step(idx, stock), time); + time += this.gapTime; + } + } + + protected async getStockId() { + const entity = Stock; + const manager = this.datasource.manager; + const result = await manager.find(entity, { + select: { id: true }, + where: { isTrading: true }, + }); + return result; + } +} diff --git a/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts b/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts new file mode 100644 index 00000000..b653c13c --- /dev/null +++ b/packages/backend/src/scraper/openapi/api/openapiDetailData.api.ts @@ -0,0 +1,86 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { DataSource } from 'typeorm'; +import { Logger } from 'winston'; +import { openApiConfig } from '../config/openapi.config'; +import { DetailData, isDetailData } from '../type/openapiDetailData.type'; +import { TR_ID } from '../type/openapiUtil.type'; +import { getOpenApi } from '../util/openapiUtil.api'; +import { Openapi } from './openapi.abstract'; +import { OpenapiTokenApi } from './openapiToken.api'; +import { Stock } from '@/stock/domain/stock.entity'; +import { StockDetail } from '@/stock/domain/stockDetail.entity'; + +@Injectable() +export class OpenapiDetailData extends Openapi { + private readonly TR_ID: TR_ID = 'FHKST01010100'; + private readonly url: string = + '/uapi/domestic-stock/v1/quotations/inquire-price'; + constructor( + @Inject('winston') private readonly logger: Logger, + protected readonly datasource: DataSource, + protected readonly config: OpenapiTokenApi, + ) { + super(datasource, config, 100); + } + + @Cron('35 0 * * 1-5') + async start() { + super.start(); + } + + protected async step(idx: number, stock: Stock) { + try { + const config = (await this.config.configs())[idx]; + const res = await this.getFromUrl(config, stock.id); + if (res.output && isDetailData(res.output)) { + const entity = this.convertResToEntity(res.output, stock.id); + await this.save(entity); + } + } catch (error) { + this.logger.warn(`Error in detail data : ${error}`); + setTimeout(() => this.step(idx, stock), 100); + } + } + + protected async getFromUrl(config: typeof openApiConfig, stockId: string) { + const query = this.query(stockId); + const res = await getOpenApi(this.url, config, query, this.TR_ID); + if (res) return res; + else throw new Error(); + } + + protected convertResToEntity(res: DetailData, stockId: string): StockDetail { + const result = new StockDetail(); + result.eps = parseInt(res.eps); + result.high52w = parseInt(res.w52_hgpr); + result.low52w = parseInt(res.w52_lwpr); + result.marketCap = res.hts_avls; + result.per = parseFloat(res.per); + result.stock = { id: stockId } as Stock; + result.updatedAt = new Date(); + return result; + } + + protected query(stockId: string, code: 'J' = 'J') { + return { + fid_cond_mrkt_div_code: code, + fid_input_iscd: stockId, + }; + } + + protected async save(saveEntity: StockDetail) { + const entity = StockDetail; + const manager = this.datasource.manager; + await manager + .createQueryBuilder() + .insert() + .into(entity) + .values(saveEntity) + .orUpdate( + ['market_cap', 'eps', 'per', 'high52w', 'low52w', 'updated_at'], + ['stock_id'], + ) + .execute(); + } +} diff --git a/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts b/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts new file mode 100644 index 00000000..d6586173 --- /dev/null +++ b/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts @@ -0,0 +1,101 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { DataSource, EntityManager } from 'typeorm'; +import { Logger } from 'winston'; +import { + DECREASE_STOCK_QUERY, + INCREASE_STOCK_QUERY, +} from '@/scraper/openapi/constants/query'; +import { TR_IDS } from '@/scraper/openapi/type/openapiUtil.type'; +import { FluctuationRankStock } from '@/stock/domain/FluctuationRankStock.entity'; +import { Stock } from '@/stock/domain/stock.entity'; +import { Json, OpenapiQueue } from '@/scraper/openapi/queue/openapi.queue'; +import { OpenapiLiveData } from '@/scraper/openapi/api/openapiLiveData.api'; + +@Injectable() +export class OpenapiFluctuationData { + private readonly fluctuationUrl = + '/uapi/domestic-stock/v1/ranking/fluctuation'; + private readonly liveUrl = '/uapi/domestic-stock/v1/quotations/inquire-price'; + constructor( + private readonly datasource: DataSource, + private readonly openApiQueue: OpenapiQueue, + private readonly openApiLive: OpenapiLiveData, + @Inject('winston') private readonly logger: Logger, + ) { + setTimeout(() => this.getFluctuationRankStocks(), 1000); + } + + @Cron('* 9-15 * * 1-5') + @Cron('*/1 9-15 * * 1-5') + async getFluctuationRankStocks() { + await this.getFluctuationRankFromApi(true); + await this.getFluctuationRankFromApi(false); + } + + async getFluctuationRankFromApi(isRising: boolean) { + const query = isRising ? INCREASE_STOCK_QUERY : DECREASE_STOCK_QUERY; + await this.datasource.manager.delete(FluctuationRankStock, { isRising }); + this.openApiQueue.enqueue({ + url: this.fluctuationUrl, + query, + trId: TR_IDS.FLUCTUATION_DATA, + callback: this.getFluctuationRankStocksCallback(isRising), + }); + } + + private getFluctuationRankStocksCallback(isRising: boolean) { + return async (data: Json) => { + const save = this.convertToFluctuationRankStock(data, isRising); + await this.saveFluctuationRankStocks(save, this.datasource.manager); + + save.forEach((data) => { + const stockId = data.stock.id; + this.insertLiveDataRequest(stockId); + }); + }; + } + + private convertToFluctuationRankStock(data: Json, isRising: boolean) { + if (!Array.isArray(data.output)) + return [ + { + rank: Number(data.output.data_rank), + fluctuationRate: data.output.prdy_ctrt, + stock: { id: data.output.stck_shrn_iscd } as Stock, + isRising, + }, + ]; + return data.output.slice(0, 20).map((result: Record) => ({ + rank: Number(result.data_rank), + fluctuationRate: result.prdy_ctrt, + stock: { id: result.stck_shrn_iscd } as Stock, + isRising, + })); + } + + private insertLiveDataRequest(stockId: string) { + this.openApiQueue.enqueue({ + url: this.liveUrl, + query: { + fid_cond_mrkt_div_code: 'J', + fid_input_iscd: stockId, + }, + trId: TR_IDS.LIVE_DATA, + callback: this.openApiLive.getLiveDataSaveCallback(stockId), + }); + } + + private async saveFluctuationRankStocks( + result: Omit[], + manager: EntityManager, + ) { + await manager + .getRepository(FluctuationRankStock) + .createQueryBuilder() + .insert() + .into(FluctuationRankStock) + .values(result) + .execute(); + } +} diff --git a/packages/backend/src/scraper/openapi/api/openapiIndex.api.ts b/packages/backend/src/scraper/openapi/api/openapiIndex.api.ts new file mode 100644 index 00000000..d303f076 --- /dev/null +++ b/packages/backend/src/scraper/openapi/api/openapiIndex.api.ts @@ -0,0 +1,233 @@ +import { Inject } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { DataSource } from 'typeorm'; +import { Logger } from 'winston'; +import { Openapi } from '../api/openapi.abstract'; +import { OpenapiTokenApi } from '../api/openapiToken.api'; +import { openApiConfig } from '../config/openapi.config'; +import { + ExchangeRate, + ExchangeRateQuery, + IndexRateGroupCodeStock, + IndexRateId, + IndexRateStockId, + isExchangeRate, + isStockIndex, + StockIndex, + StockIndexQuery, +} from '../type/openapiIndex.type'; + +import { TR_ID } from '../type/openapiUtil.type'; +import { getOpenApi, getTodayDate } from '../util/openapiUtil.api'; +import { OpenapiLiveData } from './openapiLiveData.api'; +import { Stock } from '@/stock/domain/stock.entity'; +import { StockLiveData } from '@/stock/domain/stockLiveData.entity'; + +/** + * 국내 업종 현재 지수 - 코스피, 코스닥 + * 해외주식 종목/지수/환율기간별시세 - 환율 + */ +export class OpenapiIndex extends Openapi { + private readonly TR_ID_INDEX: TR_ID = 'FHPUP02100000'; + private readonly TR_ID_RATE: TR_ID = 'FHKST03030100'; + private readonly INDEX_URL: string = + '/uapi/domestic-stock/v1/quotations/inquire-index-price'; + private readonly RATE_URL: string = + '/uapi/overseas-price/v1/quotations/inquire-daily-chartprice'; + private KOSPI_ID: IndexRateId = IndexRateStockId.kospi; + private KOSDAQ_ID: IndexRateId = IndexRateStockId.kosdaq; + private USD_KRW_RATE: IndexRateId = IndexRateStockId.usd_krw; + private readonly INTERVAL: number; + constructor( + @Inject('winston') private readonly logger: Logger, + protected readonly datasource: DataSource, + protected readonly config: OpenapiTokenApi, + private readonly openapiLiveData: OpenapiLiveData, + ) { + const interval = 1000; + super(datasource, config, interval); + this.INTERVAL = interval; + this.initData().then(() => this.start()); + } + + @Cron('* 9-14 * * 1-5') + @Cron('0-30 15 * * 1-5') + async start() { + await this.step((await this.config.configs()).length - 1); + } + + private initKospiData() { + const name = '코스피'; + const initStockData = new Stock(); + initStockData.id = IndexRateStockId.kospi; + initStockData.groupCode = IndexRateGroupCodeStock.kospi; + initStockData.name = name; + return initStockData; + } + + private initKosdaqData() { + const name = '코스닥'; + const initStockData = new Stock(); + initStockData.id = IndexRateStockId.kosdaq; + initStockData.groupCode = IndexRateGroupCodeStock.kosdaq; + initStockData.name = name; + return initStockData; + } + + private initUsdKrwData() { + const name = '원 달러 환율'; + const initStockData = new Stock(); + initStockData.id = IndexRateStockId.usd_krw; + initStockData.groupCode = IndexRateGroupCodeStock.usd_krw; + initStockData.name = name; + return initStockData; + } + + private async initData() { + await this.saveStock(this.initKosdaqData()); + await this.saveStock(this.initKospiData()); + await this.saveStock(this.initUsdKrwData()); + } + + private async saveStock(data: Stock) { + const target = Stock; + + await this.datasource.manager + .getRepository(target) + .createQueryBuilder() + .insert() + .values(data) + .orUpdate(['is_trading'], ['id']) + .execute(); + } + + protected async step(idx: number) { + const config = (await this.config.configs())[idx]; + this.getFromUrl(config); + } + + protected async getFromUrl(config: typeof openApiConfig) { + const indexOutputKospi = await this.getFromIndex(config, this.KOSPI_ID); + const indexOutputKosdaq = await this.getFromIndex(config, this.KOSDAQ_ID); + const rateOutput = await this.getFromRate(config, this.USD_KRW_RATE); + if ( + isStockIndex(indexOutputKospi) && + isStockIndex(indexOutputKosdaq) && + isExchangeRate(rateOutput) + ) { + const liveData: StockLiveData[] = []; + liveData.push(this.convertResToEntity(indexOutputKospi, this.KOSPI_ID)); + liveData.push(this.convertResToEntity(indexOutputKosdaq, this.KOSDAQ_ID)); + liveData.push(this.convertResToEntity(rateOutput, this.USD_KRW_RATE)); + for await (const data of liveData) { + this.save(data); + } + } else { + this.logger.warn('Index data save failed'); + } + } + + protected async getFromIndex(config: typeof openApiConfig, stockId: string) { + const query = this.indexQuery(stockId); + + try { + const result = await getOpenApi( + this.INDEX_URL, + config, + query, + this.TR_ID_INDEX, + ); + if (result && result.output) return result.output; + } catch (error) { + this.logger.warn( + `Get index data failed : ${error}, try in ${this.INTERVAL / 1000} sec`, + ); + setTimeout(() => this.getFromIndex(config, stockId), this.INTERVAL); + } + } + + protected async getFromRate(config: typeof openApiConfig, stockId: string) { + const date = getTodayDate(); + + const query = this.rateQuery(date, date, stockId); + + try { + const result = await getOpenApi( + this.RATE_URL, + config, + query, + this.TR_ID_RATE, + ); + if (result && result.output1) return result.output1; + } catch (error) { + this.logger.warn( + `Get rate data failed : ${error}, try in ${this.INTERVAL / 1000} sec`, + ); + setTimeout(() => this.getFromRate(config, stockId), this.INTERVAL); + } + } + + private convertResToStockIndex(res: StockIndex, stockId: string) { + const result = new StockLiveData(); + result.currentPrice = parseFloat(res.bstp_nmix_prpr); + result.changeRate = parseFloat(res.bstp_nmix_prdy_ctrt); + result.high = parseFloat(res.bstp_nmix_hgpr); + result.low = parseFloat(res.bstp_nmix_lwpr); + result.open = parseFloat(res.bstp_nmix_oprc); + result.volume = parseInt(res.acml_vol); + result.updatedAt = new Date(); + result.stock = { id: stockId } as Stock; + return result; + } + + private convertResToExchangeRate(res: ExchangeRate, stockId: string) { + const result = new StockLiveData(); + result.currentPrice = parseFloat(res.ovrs_nmix_prpr); + result.changeRate = parseFloat(res.prdy_ctrt); + result.high = parseFloat(res.ovrs_prod_hgpr); + result.low = parseFloat(res.ovrs_prod_lwpr); + result.open = parseFloat(res.ovrs_prod_oprc); + result.volume = parseInt(res.acml_vol); + result.updatedAt = new Date(); + result.stock = { id: stockId } as Stock; + return result; + } + + protected convertResToEntity( + res: StockIndex | ExchangeRate, + stockId: string, + ): StockLiveData { + if (isStockIndex(res)) { + return this.convertResToStockIndex(res, stockId); + } else { + return this.convertResToExchangeRate(res, stockId); + } + } + + protected async save(entity: StockLiveData) { + await this.openapiLiveData.saveLiveData(entity); + } + + protected indexQuery(iscd: string, code: 'U' = 'U'): StockIndexQuery { + return { + fid_cond_mrkt_div_code: code, + fid_input_iscd: iscd, + }; + } + + protected rateQuery( + startDate: string, + endDate: string, + iscd: string, + period: 'D' | 'W' | 'M' | 'Y' = 'D', + code: 'N' | 'X' | 'I' | 'S' = 'X', + ): ExchangeRateQuery { + return { + fid_cond_mrkt_div_code: code, + fid_input_iscd: iscd, + fid_input_date_1: startDate, + fid_input_date_2: endDate, + fid_period_div_code: period, + }; + } +} diff --git a/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts b/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts new file mode 100644 index 00000000..d51a39b5 --- /dev/null +++ b/packages/backend/src/scraper/openapi/api/openapiLiveData.api.ts @@ -0,0 +1,147 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { Logger } from 'winston'; +import { openApiConfig } from '../config/openapi.config'; +import { isOpenapiLiveData } from '../type/openapiLiveData.type'; +import { TR_IDS } from '../type/openapiUtil.type'; +import { getOpenApi } from '../util/openapiUtil.api'; +import { Stock } from '@/stock/domain/stock.entity'; +import { StockLiveData } from '@/stock/domain/stockLiveData.entity'; +import { Json } from '@/scraper/openapi/queue/openapi.queue'; + +@Injectable() +export class OpenapiLiveData { + private readonly url: string = + '/uapi/domestic-stock/v1/quotations/inquire-ccnl'; + constructor( + private readonly datasource: DataSource, + @Inject('winston') private readonly logger: Logger, + ) {} + + async saveLiveData(data: StockLiveData) { + await this.datasource.manager + .getRepository(StockLiveData) + .createQueryBuilder() + .insert() + .values(data) + .orUpdate( + [ + 'current_price', + 'change_rate', + 'volume', + 'high', + 'low', + 'open', + 'updatedAt', + ], + ['stock_id'], + ) + .execute(); + } + + // 현재가 체결 + convertResponseToStockLiveData = ( + data: OpenapiLiveData, + stockId: string, + ): StockLiveData | undefined => { + const stockLiveData = new StockLiveData(); + if (isOpenapiLiveData(data)) { + stockLiveData.stock = { id: stockId } as Stock; + stockLiveData.currentPrice = parseFloat(data.stck_prpr); + stockLiveData.changeRate = parseFloat(data.prdy_ctrt); + stockLiveData.volume = parseInt(data.acml_vol); + stockLiveData.high = parseFloat(data.stck_hgpr); + stockLiveData.low = parseFloat(data.stck_lwpr); + stockLiveData.open = parseFloat(data.stck_oprc); + stockLiveData.updatedAt = new Date(); + return stockLiveData; + } + }; + + convertLiveData(messages: Record[]): StockLiveData[] { + const stockData: StockLiveData[] = []; + messages.map((message) => { + const stockLiveData = new StockLiveData(); + stockLiveData.stock = { id: message.STOCK_ID } as Stock; + stockLiveData.currentPrice = parseFloat(message.STCK_PRPR); + stockLiveData.changeRate = parseFloat(message.PRDY_CTRT); + stockLiveData.volume = parseInt(message.CNTG_VOL); + stockLiveData.high = parseFloat(message.STCK_HGPR); + stockLiveData.low = parseFloat(message.STCK_LWPR); + stockLiveData.open = parseFloat(message.STCK_OPRC); + stockLiveData.updatedAt = new Date(); + + stockData.push(stockLiveData); + }); + return stockData; + } + + async connectLiveData(stockId: string, config: typeof openApiConfig) { + const query = this.makeLiveDataQuery(stockId); + + try { + const result = await getOpenApi( + this.url, + config, + query, + TR_IDS.LIVE_DATA, + ); + return result; + } catch (error) { + this.logger.warn(`Connect live data error : ${error}`); + } + } + + getLiveDataSaveCallback(stockId: string) { + return async (data: Json) => { + if (Array.isArray(data.output)) return; + const stockLiveData = this.convertToStockLiveData(data.output, stockId); + await this.saveIndividualLiveData(stockLiveData); + }; + } + + private makeLiveDataQuery(stockId: string, code: 'J' = 'J') { + return { + fid_cond_mrkt_div_code: code, + fid_input_iscd: stockId, + }; + } + + private convertToStockLiveData( + stockData: Record, + stockId: string, + ): StockLiveData { + const stockLiveData = new StockLiveData(); + stockLiveData.stock = { id: stockId } as Stock; + stockLiveData.currentPrice = parseFloat(stockData.stck_prpr); + stockLiveData.changeRate = parseFloat(stockData.prdy_ctrt); + stockLiveData.volume = parseInt(stockData.acml_vol); + stockLiveData.high = parseFloat(stockData.stck_hgpr); + stockLiveData.low = parseFloat(stockData.stck_lwpr); + stockLiveData.open = parseFloat(stockData.stck_oprc); + stockLiveData.updatedAt = new Date(); + return stockLiveData; + } + + private async saveIndividualLiveData(data: StockLiveData) { + return await this.datasource.manager + .getRepository(StockLiveData) + .createQueryBuilder() + .insert() + .into(StockLiveData) + .values(data) + .orUpdate( + [ + 'current_price', + 'change_rate', + 'volume', + 'high', + 'low', + 'open', + 'updatedAt', + ], + ['stock_id'], + ) + .execute(); + } +} diff --git a/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts b/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts index cbc43334..608d39cc 100644 --- a/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiMinuteData.api.ts @@ -1,39 +1,64 @@ -import { Cron } from '@nestjs/schedule'; +import { Inject, Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm'; +import { Logger } from 'winston'; import { openApiConfig } from '../config/openapi.config'; -import { getCurrentTime, getOpenApi } from '../openapiUtil.api'; + import { isMinuteData, MinuteData, UpdateStockQuery, } from '../type/openapiMinuteData.type'; -import { openApiToken } from './openapiToken.api'; +import { TR_IDS } from '../type/openapiUtil.type'; +import { getCurrentTime, getOpenApi } from '../util/openapiUtil.api'; +import { OpenapiTokenApi } from './openapiToken.api'; import { Stock } from '@/stock/domain/stock.entity'; import { StockData, StockMinutely } from '@/stock/domain/stockData.entity'; -import { Injectable } from '@nestjs/common'; + +const STOCK_CUT = 4; @Injectable() export class OpenapiMinuteData { - private stock: Stock[]; + private stock: Stock[][] = []; private readonly entity = StockMinutely; private readonly url: string = '/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice'; - public constructor(private readonly datasourse: DataSource) {} + private readonly intervals: number = 130; + private flip: number = 0; + constructor( + private readonly datasource: DataSource, + private readonly openApiToken: OpenapiTokenApi, + @Inject('winston') private readonly logger: Logger, + ) { + //this.getStockData(); + } - @Cron('0 1 * * 1-5') - private async getStockData() { - this.stock = await this.datasourse.manager.findBy(Stock, { + async getStockData() { + if (process.env.NODE_ENV !== 'production') return; + const stock = await this.datasource.manager.findBy(Stock, { isTrading: true, }); + const stockSize = Math.ceil(stock.length / STOCK_CUT); + let i = 0; + this.stock = []; + while (i < STOCK_CUT) { + this.stock.push(stock.slice(i * stockSize, (i + 1) * stockSize)); + i++; + } } - private convertResToMinuteData(stockId: string, item: MinuteData) { + private convertResToMinuteData( + stockId: string, + item: MinuteData, + time: string, + ) { const stockPeriod = new StockData(); stockPeriod.stock = { id: stockId } as Stock; stockPeriod.startTime = new Date( parseInt(item.stck_bsop_date.slice(0, 4)), parseInt(item.stck_bsop_date.slice(4, 6)) - 1, parseInt(item.stck_bsop_date.slice(6, 8)), + parseInt(time.slice(0, 2)), + parseInt(time.slice(2, 4)), ); stockPeriod.close = parseInt(item.stck_prpr); stockPeriod.open = parseInt(item.stck_oprc); @@ -44,35 +69,71 @@ export class OpenapiMinuteData { return stockPeriod; } - private async saveMinuteData(stockPeriod: StockMinutely) { - const manager = this.datasourse.manager; - manager.create(this.entity, stockPeriod); + private isMarketOpenTime(time: string) { + const numberTime = parseInt(time); + return numberTime >= 90000 && numberTime <= 153000; + } + + private async saveMinuteData( + stockId: string, + item: MinuteData[], + time: string, + ) { + const manager = this.datasource.manager; + if (!this.isMarketOpenTime(time)) return; + const stockPeriod = item.map((val) => + this.convertResToMinuteData(stockId, val, time), + ); + manager.save(this.entity, stockPeriod); + } + + private async getMinuteDataInterval( + stockId: string, + time: string, + config: typeof openApiConfig, + ) { + const query = this.getUpdateStockQuery(stockId, time); + try { + const response = await getOpenApi( + this.url, + config, + query, + TR_IDS.MINUTE_DATA, + ); + let output; + if (response.output2) output = response.output2; + if (output && output[0] && isMinuteData(output[0])) { + this.saveMinuteData(stockId, output, time); + } + } catch (error) { + this.logger.warn(error); + } } private async getMinuteDataChunk( chunk: Stock[], config: typeof openApiConfig, ) { + const time = getCurrentTime(); + let interval = 0; for await (const stock of chunk) { - const time = getCurrentTime(); - const query = this.getUpdateStockQuery(stock.id!, time); - const response = await getOpenApi(this.url, config, query); - const output = (await response.data).output2[0] as MinuteData; - if (output && isMinuteData(output)) { - const stockPeriod = this.convertResToMinuteData(stock.id!, output); - this.saveMinuteData(stockPeriod); - } + setTimeout( + () => this.getMinuteDataInterval(stock.id!, time, config), + interval, + ); + interval += this.intervals; } } - @Cron('* 9-16 * * 1-5') - private getMinuteData() { - const configCount = openApiToken.configs.length; - const chunkSize = Math.ceil(this.stock.length / configCount); - + async getMinuteData() { + if (process.env.NODE_ENV !== 'production') return; + const configCount = (await this.openApiToken.configs()).length; + const stock = this.stock[this.flip % STOCK_CUT]; + this.flip++; + const chunkSize = Math.ceil(stock.length / configCount); for (let i = 0; i < configCount; i++) { - const chunk = this.stock.slice(i * chunkSize, (i + 1) * chunkSize); - this.getMinuteDataChunk(chunk, openApiToken.configs[i]); + const chunk = stock.slice(i * chunkSize, (i + 1) * chunkSize); + this.getMinuteDataChunk(chunk, (await this.openApiToken.configs())[i]); } } diff --git a/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts b/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts index 74526d54..778f82ee 100644 --- a/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiPeriodData.api.ts @@ -1,13 +1,20 @@ +import { Inject, Injectable } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; -import { DataSource, EntityManager } from 'typeorm'; -import { getOpenApi, getPreviousDate, getTodayDate } from '../openapiUtil.api'; +import { DataSource } from 'typeorm'; +import { Logger } from 'winston'; import { ChartData, isChartData, ItemChartPriceQuery, Period, -} from '../type/openapiPeriodData'; -import { openApiToken } from './openapiToken.api'; +} from '../type/openapiPeriodData.type'; +import { TR_IDS } from '../type/openapiUtil.type'; +import { + getOpenApi, + getPreviousDate, + getTodayDate, +} from '../util/openapiUtil.api'; +import { OpenapiTokenApi } from './openapiToken.api'; import { Stock } from '@/stock/domain/stock.entity'; import { StockData, @@ -16,7 +23,6 @@ import { StockMonthly, StockYearly, } from '@/stock/domain/stockData.entity'; -import { Injectable } from '@nestjs/common'; const DATE_TO_ENTITY = { D: StockDaily, @@ -26,50 +32,47 @@ const DATE_TO_ENTITY = { }; const DATE_TO_MONTH = { - D: 3, + D: 1, W: 6, - M: 12, - Y: 24, + M: 24, + Y: 120, }; -const INTERVALS = 4000; +const INTERVALS = 10000; @Injectable() export class OpenapiPeriodData { private readonly url: string = '/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice'; - public constructor(private readonly datasourse: DataSource) { - this.getItemChartPriceCheck(); - } + constructor( + private readonly datasource: DataSource, + private readonly openApiToken: OpenapiTokenApi, + @Inject('winston') private readonly logger: Logger, + ) {} @Cron('0 1 * * 1-5') - public async getItemChartPriceCheck() { - const entityManager = this.datasourse.manager; - const stocks = await entityManager.find(Stock); - const configCount = openApiToken.configs.length; - const chunkSize = Math.ceil(stocks.length / configCount); - - for (let i = 0; i < configCount; i++) { - const chunk = stocks.slice(i * chunkSize, (i + 1) * chunkSize); - this.getChartData(chunk, 'D'); - setTimeout(() => this.getChartData(chunk, 'W'), INTERVALS); - setTimeout(() => this.getChartData(chunk, 'M'), INTERVALS * 2); - setTimeout(() => this.getChartData(chunk, 'Y'), INTERVALS * 3); - } + async getItemChartPriceCheck() { + if (process.env.NODE_ENV !== 'production') return; + const stocks = await this.datasource.manager.find(Stock, { + where: { + isTrading: true, + }, + }); + + await this.getChartData(stocks, 'Y'); + await this.getChartData(stocks, 'M'); + await this.getChartData(stocks, 'W'); + await this.getChartData(stocks, 'D'); } private async getChartData(chunk: Stock[], period: Period) { - const baseTime = INTERVALS * 4; + const baseTime = INTERVALS; const entity = DATE_TO_ENTITY[period]; - const manager = this.datasourse.manager; let time = 0; for (const stock of chunk) { time += baseTime; - setTimeout( - () => this.processStockData(stock, period, entity, manager), - time, - ); + setTimeout(() => this.processStockData(stock, period, entity), time); } } @@ -77,27 +80,25 @@ export class OpenapiPeriodData { stock: Stock, period: Period, entity: typeof StockData, - manager: EntityManager, ) { const stockPeriod = new StockData(); let configIdx = 0; let end = getTodayDate(); - let start = getPreviousDate(end, 3); + let start = getPreviousDate(end, DATE_TO_MONTH[period]); let isFail = false; - while (isFail) { - configIdx = (configIdx + 1) % openApiToken.configs.length; + while (!isFail) { + await new Promise((resolve) => setTimeout(resolve, INTERVALS / 10)); + configIdx = (configIdx + 1) % (await this.openApiToken.configs()).length; this.setStockPeriod(stockPeriod, stock.id!, end); - if (await this.existsChartData(stockPeriod, manager, entity)) return; - const query = this.getItemChartPriceQuery(stock.id!, start, end, period); const output = await this.fetchChartData(query, configIdx); - if (output && isChartData(output[0])) { + if (output) { await this.saveChartData(entity, stock.id!, output); - ({ endDate: end, startDate: start } = this.updateDates(start, period)); + ({ endDate: end, startDate: start } = this.updateDates(end, period)); } else isFail = true; } } @@ -115,69 +116,74 @@ export class OpenapiPeriodData { ); } - private async fetchChartData( - query: ItemChartPriceQuery, - configIdx: number, - ): Promise { - const response = await getOpenApi( - this.url, - openApiToken.configs[configIdx], - query, - ); - return response.output2 as ChartData[]; + private async fetchChartData(query: ItemChartPriceQuery, configIdx: number) { + try { + const response = await getOpenApi( + this.url, + (await this.openApiToken.configs())[configIdx], + query, + TR_IDS.ITEM_CHART_PRICE, + ); + return response.output2 as ChartData[]; + } catch (error) { + this.logger.warn(error); + setTimeout(() => this.fetchChartData(query, configIdx), INTERVALS / 10); + } } private updateDates( - startDate: string, + endDate: string, period: Period, ): { endDate: string; startDate: string } { - const endDate = getPreviousDate(startDate, DATE_TO_MONTH[period]); - startDate = getPreviousDate(endDate, DATE_TO_MONTH[period]); + endDate = getPreviousDate(endDate, DATE_TO_MONTH[period]); + const startDate = getPreviousDate(endDate, DATE_TO_MONTH[period]); return { endDate, startDate }; } - private async existsChartData( - stock: StockData, - manager: EntityManager, - entity: typeof StockData, - ) { + private async existsChartData(stock: StockData, entity: typeof StockData) { + const manager = this.datasource.manager; return await manager.findOne(entity, { where: { stock: { id: stock.stock.id }, - createdAt: stock.startTime, + startTime: stock.startTime, }, }); } private async insertChartData(stock: StockData, entity: typeof StockData) { - const manager = this.datasourse.manager; - if (!(await this.existsChartData(stock, manager, entity))) { + const manager = this.datasource.manager; + if (!(await this.existsChartData(stock, entity))) { await manager.save(entity, stock); } } + private convertObjectToStockData(item: ChartData, stockId: string) { + const stockPeriod = new StockData(); + stockPeriod.stock = { id: stockId } as Stock; + stockPeriod.startTime = new Date( + parseInt(item.stck_bsop_date.slice(0, 4)), + parseInt(item.stck_bsop_date.slice(4, 6)) - 1, + parseInt(item.stck_bsop_date.slice(6, 8)), + ); + stockPeriod.close = parseInt(item.stck_clpr); + stockPeriod.open = parseInt(item.stck_oprc); + stockPeriod.high = parseInt(item.stck_hgpr); + stockPeriod.low = parseInt(item.stck_lwpr); + stockPeriod.volume = parseInt(item.acml_vol); + stockPeriod.createdAt = new Date(); + return stockPeriod; + } + private async saveChartData( entity: typeof StockData, stockId: string, data: ChartData[], ) { for (const item of data) { - if (!item || !item.stck_bsop_date) { + if (!isChartData(item)) { continue; } - const stockPeriod = new StockData(); - stockPeriod.stock = { id: stockId } as Stock; - stockPeriod.startTime = new Date( - parseInt(item.stck_bsop_date.slice(0, 4)), - parseInt(item.stck_bsop_date.slice(4, 6)) - 1, - parseInt(item.stck_bsop_date.slice(6, 8)), - ); - stockPeriod.close = parseInt(item.stck_clpr); - stockPeriod.open = parseInt(item.stck_oprc); - stockPeriod.high = parseInt(item.stck_hgpr); - stockPeriod.low = parseInt(item.stck_lwpr); - stockPeriod.volume = parseInt(item.acml_vol); - stockPeriod.createdAt = new Date(); + const stockPeriod = this.convertObjectToStockData(item, stockId); await this.insertChartData(stockPeriod, entity); } } diff --git a/packages/backend/src/scraper/openapi/api/openapiRankView.api.ts b/packages/backend/src/scraper/openapi/api/openapiRankView.api.ts new file mode 100644 index 00000000..15f3a202 --- /dev/null +++ b/packages/backend/src/scraper/openapi/api/openapiRankView.api.ts @@ -0,0 +1,45 @@ +import { Injectable } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { DataSource } from 'typeorm'; +import { OpenapiQueue } from '@/scraper/openapi/queue/openapi.queue'; +import { TR_IDS } from '@/scraper/openapi/type/openapiUtil.type'; +import { Stock } from '@/stock/domain/stock.entity'; +import { OpenapiLiveData } from '@/scraper/openapi/api/openapiLiveData.api'; + +@Injectable() +export class OpenapiRankViewApi { + private readonly liveUrl = '/uapi/domestic-stock/v1/quotations/inquire-price'; + + constructor( + private readonly datasource: DataSource, + private readonly openApiLiveData: OpenapiLiveData, + private readonly openApiQueue: OpenapiQueue, + ) { + setTimeout(() => this.getTopViewsStockLiveData(), 6000); + } + + @Cron('* 9-15 * * 1-5') + async getTopViewsStockLiveData() { + const date = await this.findTopViewsStocks(); + date.forEach((stock) => { + this.openApiQueue.enqueue({ + url: this.liveUrl, + query: { + fid_cond_mrkt_div_code: 'J', + fid_input_iscd: stock.id, + }, + trId: TR_IDS.LIVE_DATA, + callback: this.openApiLiveData.getLiveDataSaveCallback(stock.id), + }); + }); + } + + private async findTopViewsStocks() { + return await this.datasource.manager + .getRepository(Stock) + .createQueryBuilder('stock') + .orderBy('stock.views', 'DESC') + .limit(10) + .getMany(); + } +} diff --git a/packages/backend/src/scraper/openapi/api/openapiToken.api.ts b/packages/backend/src/scraper/openapi/api/openapiToken.api.ts index c4df3cdf..437283d3 100644 --- a/packages/backend/src/scraper/openapi/api/openapiToken.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiToken.api.ts @@ -1,13 +1,20 @@ -import { Inject, NotFoundException } from '@nestjs/common'; +import { Global, Inject, Injectable } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; +import { DataSource } from 'typeorm'; import { Logger } from 'winston'; +import { OpenapiToken } from '../../domain/openapiToken.entity'; import { openApiConfig } from '../config/openapi.config'; -import { postOpenApi } from '../openapiUtil.api'; -import { logger } from '@/configs/logger.config'; +import { OpenapiException } from '../util/openapiCustom.error'; +import { postOpenApi } from '../util/openapiUtil.api'; -class OpenapiTokenApi { +@Global() +@Injectable() +export class OpenapiTokenApi { private config: (typeof openApiConfig)[] = []; - public constructor(@Inject('winston') private readonly logger: Logger) { + constructor( + @Inject('winston') private readonly logger: Logger, + private readonly datasource: DataSource, + ) { const accounts = openApiConfig.STOCK_ACCOUNT!.split(','); const api_keys = openApiConfig.STOCK_API_KEY!.split(','); const api_passwords = openApiConfig.STOCK_API_PASSWORD!.split(','); @@ -26,19 +33,143 @@ class OpenapiTokenApi { STOCK_API_PASSWORD: api_passwords[i], }); } - this.initAuthenValue(); } - public get configs() { + async configs() { + await this.init(); return this.config; } + @Cron('30 0 * * 1-5') + async init() { + const expired_config = this.config.filter( + (val) => + this.isTokenExpired(val.STOCK_API_TIMEOUT) && + this.isTokenExpired(val.STOCK_WEBSOCKET_TIMEOUT), + ); + const isUndefined = this.config[0].STOCK_WEBSOCKET_TIMEOUT ? false : true; + if (!isUndefined && !expired_config.length) return; + const tokens = this.convertConfigToTokenEntity(this.config); + const config = await this.getPropertyFromDB(tokens); + const expired = config.filter( + (val) => + this.isTokenExpired(val.api_token_expire) && + this.isTokenExpired(val.websocket_key_expire), + ); + + if (expired.length || !config.length) { + await this.initAuthenValue(); + const newTokens = this.convertConfigToTokenEntity(this.config); + await this.savePropertyToDB(newTokens); + } else { + this.config = this.convertTokenEntityToConfig(config); + } + } + + private isTokenExpired(startDate?: Date) { + if (!startDate) return true; + const now = new Date(); + //실제 만료 시간은 24시간이지만, 문제가 발생할 여지를 줄이기 위해 12시간으로 설정 + const baseTimeToMilliSec = 12 * 60 * 60 * 1000; + const timeDiff = now.getTime() - startDate.getTime(); + + return timeDiff >= baseTimeToMilliSec; + } + + private convertTokenEntityToConfig(tokens: OpenapiToken[]) { + const result: (typeof openApiConfig)[] = []; + tokens.forEach((val) => { + const config: typeof openApiConfig = { + STOCK_ACCOUNT: val.account, + STOCK_API_KEY: val.api_key, + STOCK_API_PASSWORD: val.api_password, + STOCK_API_TOKEN: val.api_token, + STOCK_URL: val.api_url, + STOCK_WEBSOCKET_KEY: val.websocket_key, + STOCK_API_TIMEOUT: val.api_token_expire, + STOCK_WEBSOCKET_TIMEOUT: val.websocket_key_expire, + }; + result.push(config); + }); + return result; + } + + private convertConfigToTokenEntity(config: (typeof openApiConfig)[]) { + const result: OpenapiToken[] = []; + config.forEach((val) => { + const token = new OpenapiToken(); + if ( + val.STOCK_URL && + val.STOCK_ACCOUNT && + val.STOCK_API_KEY && + val.STOCK_API_PASSWORD + ) { + token.api_url = val.STOCK_URL; + token.account = val.STOCK_ACCOUNT; + token.api_key = val.STOCK_API_KEY; + token.api_password = val.STOCK_API_PASSWORD; + } + token.api_token = val.STOCK_API_TOKEN; + token.websocket_key = val.STOCK_WEBSOCKET_KEY; + token.api_token_expire = new Date(); + token.websocket_key_expire = new Date(); + result.push(token); + }); + return result; + } + + private async savePropertyToDB(tokens: OpenapiToken[]) { + tokens.forEach(async (val) => { + this.datasource.manager.save(OpenapiToken, val); + }); + } + + private async getPropertyFromDB(tokens: OpenapiToken[]) { + const result: OpenapiToken[] = []; + await Promise.all( + tokens.map(async (val) => { + const findByToken = await this.datasource.manager.findOne( + OpenapiToken, + { + where: { + account: val.account, + api_key: val.api_key, + api_password: val.api_password, + }, + }, + ); + if (findByToken) { + result.push(findByToken); + } + }), + ); + return result; + } + private async initAuthenValue() { - await this.initAccessToken(); - await this.initWebSocketKey(); + const delay = 60000; + const delayMinute = delay / 1000 / 60; + + try { + await this.initAccessToken(); + await this.initWebSocketKey(); + } catch (error) { + if (error instanceof Error) { + this.logger.warn( + `Request failed: ${error.message}. Retrying in ${delayMinute} minute...`, + ); + } else { + this.logger.warn( + `Request failed. Retrying in ${delayMinute} minute...`, + ); + setTimeout(async () => { + await this.initAccessToken(); + await this.initWebSocketKey(); + }, delay); + } + } } - @Cron('50 0 * * 1-5') private async initAccessToken() { const updatedConfig = await Promise.all( this.config.map(async (val) => { @@ -47,13 +178,18 @@ class OpenapiTokenApi { }), ); this.config = updatedConfig; + this.logger.info(`Init access token : ${this.config}`); } - @Cron('50 0 * * 1-5') private async initWebSocketKey() { - this.config.forEach(async (val) => { - val.STOCK_WEBSOCKET_KEY = await this.getWebSocketKey(val)!; - }); + const updatedConfig = await Promise.all( + this.config.map(async (val) => { + val.STOCK_WEBSOCKET_KEY = await this.getWebSocketKey(val)!; + return val; + }), + ); + this.config = updatedConfig; + this.logger.info(`Init websocket token : ${this.config}`); } private async getToken(config: typeof openApiConfig): Promise { @@ -64,7 +200,7 @@ class OpenapiTokenApi { }; const tmp = await postOpenApi('/oauth2/tokenP', config, body); if (!tmp.access_token) { - throw new NotFoundException('Access Token Failed'); + throw new OpenapiException('Access Token Failed', 403); } return tmp.access_token as string; } @@ -77,11 +213,8 @@ class OpenapiTokenApi { }; const tmp = await postOpenApi('/oauth2/Approval', config, body); if (!tmp.approval_key) { - throw new NotFoundException('WebSocket Key Failed'); + throw new OpenapiException('WebSocket Key Failed', 403); } return tmp.approval_key as string; } } - -const openApiToken = new OpenapiTokenApi(logger); -export { openApiToken }; diff --git a/packages/backend/src/scraper/openapi/config/openapi.config.ts b/packages/backend/src/scraper/openapi/config/openapi.config.ts index 8aa12ea3..85a66363 100644 --- a/packages/backend/src/scraper/openapi/config/openapi.config.ts +++ b/packages/backend/src/scraper/openapi/config/openapi.config.ts @@ -9,6 +9,8 @@ export const openApiConfig: { STOCK_API_PASSWORD: string | undefined; STOCK_API_TOKEN?: string; STOCK_WEBSOCKET_KEY?: string; + STOCK_API_TIMEOUT?: Date; + STOCK_WEBSOCKET_TIMEOUT?: Date; } = { STOCK_URL: process.env.STOCK_URL, STOCK_ACCOUNT: process.env.STOCK_ACCOUNT, diff --git a/packages/backend/src/scraper/openapi/constants/query.ts b/packages/backend/src/scraper/openapi/constants/query.ts new file mode 100644 index 00000000..7e0aa0e6 --- /dev/null +++ b/packages/backend/src/scraper/openapi/constants/query.ts @@ -0,0 +1,26 @@ +const BASE_QUERY = { + fid_cond_mrkt_div_code: 'J', + fid_cond_scr_div_code: '20170', + fid_input_iscd: '0000', + fid_input_cnt_1: '0', + fid_input_price_1: '', + fid_input_price_2: '', + fid_vol_cnt: '', + fid_trgt_cls_code: '0', + fid_trgt_exls_cls_code: '0', + fid_div_cls_code: '0', + fid_rsfl_rate1: '', + fid_rsfl_rate2: '', +}; + +export const DECREASE_STOCK_QUERY = { + ...BASE_QUERY, + fid_rank_sort_cls_code: '1', + fid_prc_cls_code: '1', +}; + +export const INCREASE_STOCK_QUERY = { + ...BASE_QUERY, + fid_rank_sort_cls_code: '0', + fid_prc_cls_code: '1', +}; diff --git a/packages/backend/src/scraper/openapi/liveData.service.ts b/packages/backend/src/scraper/openapi/liveData.service.ts new file mode 100644 index 00000000..26e8ee76 --- /dev/null +++ b/packages/backend/src/scraper/openapi/liveData.service.ts @@ -0,0 +1,167 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { Logger } from 'winston'; +import { RawData, WebSocket } from 'ws'; +import { OpenapiLiveData } from './api/openapiLiveData.api'; +import { OpenapiTokenApi } from './api/openapiToken.api'; +import { openApiConfig } from './config/openapi.config'; +import { parseMessage } from './parse/openapi.parser'; +import { WebsocketClient } from './websocket/websocketClient.websocket'; + +type TR_IDS = '0' | '1'; + +@Injectable() +export class LiveData { + private readonly clientStock: Set = new Set(); + private readonly reconnectInterval = 60 * 1000 * 1000; + + private readonly startTime: Date = new Date(2024, 0, 1, 9, 0, 0, 0); + private readonly endTime: Date = new Date(2024, 0, 1, 15, 30, 0, 0); + constructor( + private readonly openApiToken: OpenapiTokenApi, + private readonly webSocketClient: WebsocketClient, + private readonly openapiLiveData: OpenapiLiveData, + @Inject('winston') private readonly logger: Logger, + ) { + this.connect(); + } + + private async openapiSubscribe(stockId: string) { + const config = (await this.openApiToken.configs())[0]; + const result = await this.openapiLiveData.connectLiveData(stockId, config); + try { + const stockLiveData = this.openapiLiveData.convertResponseToStockLiveData( + result.output, + stockId, + ); + if (stockLiveData) { + this.openapiLiveData.saveLiveData(stockLiveData); + } + } catch (error) { + this.logger.warn(`Subscribe error in open api : ${error}`); + } + } + + async subscribe(stockId: string) { + if (this.isCloseTime(new Date(), this.startTime, this.endTime)) { + await this.openapiSubscribe(stockId); + } else { + // TODO : 하나의 config만 사용중. + this.clientStock.add(stockId); + const message = this.convertObjectToMessage( + (await this.openApiToken.configs())[1], + stockId, + '1', + ); + this.webSocketClient.subscribe(message); + } + } + + async discribe(stockId: string) { + if (this.clientStock.has(stockId)) { + this.clientStock.delete(stockId); + const message = this.convertObjectToMessage( + (await this.openApiToken.configs())[0], + stockId, + '0', + ); + this.webSocketClient.discribe(message); + } + } + + private initOpenCallback = + (sendMessage: (message: string) => void) => async () => { + this.logger.info('WebSocket connection established'); + for (const stockId of this.clientStock.keys()) { + const message = this.convertObjectToMessage( + (await this.openApiToken.configs())[0], + stockId, + '1', + ); + sendMessage(message); + } + }; + + private initMessageCallback = + (client: WebSocket) => async (data: RawData) => { + try { + const message = this.parseMessage(data); + if (message.header) { + if (message.header.tr_id === 'PINGPONG') { + client.pong(data); + } + return; + } + const liveData = this.openapiLiveData.convertLiveData(message); + await this.openapiLiveData.saveLiveData(liveData[0]); + } catch (error) { + this.logger.warn(error); + } + }; + + private initCloseCallback = () => { + this.logger.warn( + `WebSocket connection closed. Reconnecting in ${this.reconnectInterval / 60 / 1000} minute...`, + ); + }; + + private initErrorCallback = (error: unknown) => { + if (error instanceof Error) { + this.logger.error(`WebSocket error: ${error.message}`); + } else { + this.logger.error('WebSocket error: callback function'); + } + setTimeout(() => this.connect(), this.reconnectInterval); + }; + + private isCloseTime(date: Date, start: Date, end: Date): boolean { + const dateMinutes = date.getHours() * 60 + date.getMinutes(); + const startMinutes = start.getHours() * 60 + start.getMinutes(); + const endMinutes = end.getHours() * 60 + end.getMinutes(); + + return dateMinutes <= startMinutes || dateMinutes >= endMinutes; + } + + @Cron('0 2 * * 1-5') + connect() { + this.webSocketClient.connectPacade( + this.initOpenCallback, + this.initMessageCallback, + this.initCloseCallback, + this.initErrorCallback, + ); + } + + private convertObjectToMessage( + config: typeof openApiConfig, + stockId: string, + tr_type: TR_IDS, + ): string { + const message = { + header: { + approval_key: config.STOCK_WEBSOCKET_KEY!, + custtype: 'P', + tr_type, + 'content-type': 'utf-8', + }, + body: { + input: { + tr_id: 'H0STCNT0', + tr_key: stockId, + }, + }, + }; + return JSON.stringify(message); + } + + //TODO : type narrowing 필요 + private parseMessage(data: RawData) { + if (typeof data === 'object' && !(data instanceof Buffer)) { + return data; + } else if (typeof data === 'object') { + return parseMessage(data.toString()); + } else { + return parseMessage(data as string); + } + } +} diff --git a/packages/backend/src/scraper/openapi/openapi-scraper.module.ts b/packages/backend/src/scraper/openapi/openapi-scraper.module.ts index 00dca421..607f6a85 100644 --- a/packages/backend/src/scraper/openapi/openapi-scraper.module.ts +++ b/packages/backend/src/scraper/openapi/openapi-scraper.module.ts @@ -1,17 +1,57 @@ import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { OpenapiDetailData } from './api/openapiDetailData.api'; +import { OpenapiIndex } from './api/openapiIndex.api'; +import { OpenapiLiveData } from './api/openapiLiveData.api'; import { OpenapiMinuteData } from './api/openapiMinuteData.api'; import { OpenapiPeriodData } from './api/openapiPeriodData.api'; +import { OpenapiTokenApi } from './api/openapiToken.api'; import { OpenapiScraperService } from './openapi-scraper.service'; -import { DataSource } from 'typeorm'; -import { TypeOrmModule } from '@nestjs/typeorm'; +import { OpenapiFluctuationData } from '@/scraper/openapi/api/openapiFluctuationData.api'; +import { OpenapiRankViewApi } from '@/scraper/openapi/api/openapiRankView.api'; +import { + OpenapiConsumer, + OpenapiQueue, +} from '@/scraper/openapi/queue/openapi.queue'; +import { FluctuationRankStock } from '@/stock/domain/FluctuationRankStock.entity'; import { Stock } from '@/stock/domain/stock.entity'; -import { StockDaily, StockMinutely, StockMonthly, StockWeekly, StockYearly } from '@/stock/domain/stockData.entity'; -import { StockLiveData } from '@/stock/domain/stockLiveData.entity'; +import { + StockDaily, + StockMinutely, + StockMonthly, + StockWeekly, + StockYearly, +} from '@/stock/domain/stockData.entity'; import { StockDetail } from '@/stock/domain/stockDetail.entity'; +import { StockLiveData } from '@/stock/domain/stockLiveData.entity'; @Module({ - imports: [TypeOrmModule.forFeature([Stock, StockMinutely , StockDaily, StockWeekly, StockMonthly, StockYearly, StockLiveData, StockDetail])], + imports: [ + TypeOrmModule.forFeature([ + Stock, + StockMinutely, + StockDaily, + StockWeekly, + StockMonthly, + StockYearly, + StockLiveData, + StockDetail, + FluctuationRankStock, + ]), + ], controllers: [], - providers: [OpenapiPeriodData, OpenapiMinuteData, OpenapiScraperService], + providers: [ + OpenapiLiveData, + OpenapiTokenApi, + OpenapiPeriodData, + OpenapiMinuteData, + OpenapiDetailData, + OpenapiScraperService, + OpenapiFluctuationData, + OpenapiIndex, + OpenapiRankViewApi, + OpenapiQueue, + OpenapiConsumer, + ], }) export class OpenapiScraperModule {} diff --git a/packages/backend/src/scraper/openapi/openapi-scraper.service.ts b/packages/backend/src/scraper/openapi/openapi-scraper.service.ts index 98f27a34..52c90179 100644 --- a/packages/backend/src/scraper/openapi/openapi-scraper.service.ts +++ b/packages/backend/src/scraper/openapi/openapi-scraper.service.ts @@ -1,13 +1,15 @@ import { Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm'; +import { OpenapiDetailData } from './api/openapiDetailData.api'; import { OpenapiMinuteData } from './api/openapiMinuteData.api'; import { OpenapiPeriodData } from './api/openapiPeriodData.api'; @Injectable() export class OpenapiScraperService { public constructor( - private readonly datasourse: DataSource, + private datasource: DataSource, private readonly openapiPeriodData: OpenapiPeriodData, private readonly openapiMinuteData: OpenapiMinuteData, + private readonly openapiDetailData: OpenapiDetailData, ) {} } diff --git a/packages/backend/src/scraper/openapi/parse/openapi.parser.spec.ts b/packages/backend/src/scraper/openapi/parse/openapi.parser.spec.ts new file mode 100644 index 00000000..37d3deb6 --- /dev/null +++ b/packages/backend/src/scraper/openapi/parse/openapi.parser.spec.ts @@ -0,0 +1,106 @@ +/* eslint-disable max-lines-per-function */ +import { parseMessage } from './openapi.parser'; + +const answer = [ + { + STOCK_ID: '005930', + MKSC_SHRN_ISCD: 5930, + STCK_CNTG_HOUR: 93354, + STCK_PRPR: 71900, + PRDY_VRSS_SIGN: 5, + PRDY_VRSS: -100, + PRDY_CTRT: -0.14, + WGHN_AVRG_STCK_PRC: 72023.83, + STCK_OPRC: 72100, + STCK_HGPR: 72400, + STCK_LWPR: 71700, + ASKP1: 71900, + BIDP1: 71800, + CNTG_VOL: 1, + ACML_VOL: 3052507, + ACML_TR_PBMN: 219853241700, + SELN_CNTG_CSNU: 5105, + SHNU_CNTG_CSNU: 6937, + NTBY_CNTG_CSNU: 1832, + CTTR: 84.9, + SELN_CNTG_SMTN: 1366314, + SHNU_CNTG_SMTN: 1159996, + CCLD_DVSN: 1, + SHNU_RATE: 0.39, + PRDY_VOL_VRSS_ACML_VOL_RATE: 20.28, + OPRC_HOUR: 90020, + OPRC_VRSS_PRPR_SIGN: 5, + OPRC_VRSS_PRPR: -200, + HGPR_HOUR: 90820, + HGPR_VRSS_PRPR_SIGN: 5, + HGPR_VRSS_PRPR: -500, + LWPR_HOUR: 92619, + LWPR_VRSS_PRPR_SIGN: 2, + LWPR_VRSS_PRPR: 200, + BSOP_DATE: 20230612, + NEW_MKOP_CLS_CODE: 20, + TRHT_YN: 'N', + ASKP_RSQN1: 65945, + BIDP_RSQN1: 216924, + TOTAL_ASKP_RSQN: 1118750, + TOTAL_BIDP_RSQN: 2199206, + VOL_TNRT: 0.05, + PRDY_SMNS_HOUR_ACML_VOL: 2424142, + PRDY_SMNS_HOUR_ACML_VOL_RATE: 125.92, + HOUR_CLS_CODE: 0, + MRKT_TRTM_CLS_CODE: null, + VI_STND_PRC: 72100, + }, +]; + +describe('openapi parser test', () => { + test('parse json websocket data', () => { + const message = `{ + "header": { + "tr_id": "H0STCNT0", + "tr_key": "005930", + "encrypt": "N" + }, + "body": { + "rt_cd": "0", + "msg_cd": "OPSP0000", + "msg1": "SUBSCRIBE SUCCESS", + "output": { + "iv": "0123456789abcdef", + "key": "abcdefghijklmnopabcdefghijklmnop"} + } + }`; + + const result = parseMessage(message); + + expect(result).toEqual(JSON.parse(message)); + }); + + test('parse stockData', () => { + const message = + '0|H0STCNT0|001|005930^093354^71900^5^-100^-0.14^72023.83^72100^72400^71700^71900^71800^1^3052' + + '507^219853241700^5105^6937^1832^84.90^1366314^1159996^1^0.39^20.28^090020^5^-2' + + '00^090820^5^-500^092619^2^200^20230612^20^N^65945^216924^1118750^2199206^0.05^' + + '2424142^125.92^0^^72100'; + + const result = parseMessage(message); + + expect(result).toEqual(answer); + }); + + test('parse stockData', () => { + const message = + '0|H0STCNT0|002|005930^093354^71900^5^-100^-0.14^72023.83^72100^72400^71700^71900^71800^1^3052' + + '507^219853241700^5105^6937^1832^84.90^1366314^1159996^1^0.39^20.28^090020^5^-2' + + '00^090820^5^-500^092619^2^200^20230612^20^N^65945^216924^1118750^2199206^0.05^' + + '2424142^125.92^0^^72100^' + + '005930^093354^71900^5^-100^-0.14^72023.83^72100^72400^71700^71900^71800^1^3052' + + '507^219853241700^5105^6937^1832^84.90^1366314^1159996^1^0.39^20.28^090020^5^-2' + + '00^090820^5^-500^092619^2^200^20230612^20^N^65945^216924^1118750^2199206^0.05^' + + '2424142^125.92^0^^72100'; + + const result = parseMessage(message); + + expect(result).toEqual([answer[0], answer[0]]); + }); +}); diff --git a/packages/backend/src/scraper/openapi/parse/openapi.parser.ts b/packages/backend/src/scraper/openapi/parse/openapi.parser.ts new file mode 100644 index 00000000..fe3e7002 --- /dev/null +++ b/packages/backend/src/scraper/openapi/parse/openapi.parser.ts @@ -0,0 +1,38 @@ +import { stockDataKeys } from '../type/openapiLiveData.type'; + +export const parseMessage = (data: string) => { + try { + return JSON.parse(data); + //eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (e) { + return parseStockData(data); + } +}; +const FIELD_LENGTH: number = stockDataKeys.length; + +const parseStockData = (input: string) => { + const dataBlocks = input.split('|'); // 데이터 구분 + const results = []; + const size = parseInt(dataBlocks[2]); // 데이터 건수 + const rawData = dataBlocks[3]; + const values = rawData.split('^'); // 필드 구분자 '^' + + for (let i = 0; i < size; i++) { + //TODO : type narrowing require + const parsedData: Record = {}; + parsedData['STOCK_ID'] = values[i * FIELD_LENGTH]; + stockDataKeys.forEach((field: string, index: number) => { + const value = values[index + FIELD_LENGTH * i]; + if (!value) return (parsedData[field] = null); + + // 숫자형 필드 처리 + if (isNaN(parseInt(value))) { + parsedData[field] = value; // 문자열 그대로 저장 + } else { + parsedData[field] = parseFloat(value); // 숫자로 변환 + } + }); + results.push(parsedData); + } + return results; +}; diff --git a/packages/backend/src/scraper/openapi/queue/openapi.queue.ts b/packages/backend/src/scraper/openapi/queue/openapi.queue.ts new file mode 100644 index 00000000..051995a4 --- /dev/null +++ b/packages/backend/src/scraper/openapi/queue/openapi.queue.ts @@ -0,0 +1,102 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Logger } from 'winston'; +import { OpenapiTokenApi } from '@/scraper/openapi/api/openapiToken.api'; +import { TR_ID } from '@/scraper/openapi/type/openapiUtil.type'; +import { getOpenApi } from '@/scraper/openapi/util/openapiUtil.api'; +import { PriorityQueue } from '@/scraper/openapi/util/priorityQueue'; + +export interface Json { + output: Record | Record[]; +} + +export interface OpenapiQueueNodeValue { + url: string; + query: object; + trId: TR_ID; + callback: (value: T) => Promise; +} + +@Injectable() +export class OpenapiQueue { + private queue: PriorityQueue = new PriorityQueue(); + constructor() {} + + enqueue(value: OpenapiQueueNodeValue, priority?: number) { + if (!priority) { + priority = 2; + } + this.queue.enqueue(value, priority); + } + + dequeue(): OpenapiQueueNodeValue | undefined { + return this.queue.dequeue(); + } + + isEmpty(): boolean { + return this.queue.isEmpty(); + } +} + +@Injectable() +export class OpenapiConsumer { + private readonly REQUEST_COUNT_PER_SECOND = 20; + private isProcessing: boolean = false; + private currentTokenIndex = 0; + + constructor( + private readonly queue: OpenapiQueue, + private readonly openapiTokenApi: OpenapiTokenApi, + @Inject('winston') private readonly logger: Logger, + ) { + this.start(); + } + + async start() { + setInterval(() => this.consume(), 1000); + } + + async consume() { + if (this.isProcessing) { + return; + } + + while (!this.queue.isEmpty()) { + this.isProcessing = true; + await this.processQueueRequest(); + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + this.isProcessing = false; + } + + private async processQueueRequest() { + const tokenCount = (await this.openapiTokenApi.configs()).length; + for (let i = 0; i < tokenCount; i++) { + await this.processIndividualTokenRequest(this.currentTokenIndex); + if (!this.isProcessing) { + return; + } + this.currentTokenIndex = (this.currentTokenIndex + 1) % tokenCount; + } + } + + private async processIndividualTokenRequest(index: number) { + for (let i = 0; i < this.REQUEST_COUNT_PER_SECOND; i++) { + const node = this.queue.dequeue(); + if (!node) { + return (this.isProcessing = false); + } + try { + const data = await getOpenApi( + node.url, + (await this.openapiTokenApi.configs())[index], + node.query, + node.trId, + ); + await node.callback(data); + } catch (error) { + this.logger.warn(error); + this.queue.enqueue(node, 1); + } + } + } +} diff --git a/packages/backend/src/scraper/openapi/type/openapiDetailData.type.ts b/packages/backend/src/scraper/openapi/type/openapiDetailData.type.ts new file mode 100644 index 00000000..a8d21d02 --- /dev/null +++ b/packages/backend/src/scraper/openapi/type/openapiDetailData.type.ts @@ -0,0 +1,168 @@ +/* eslint-disable @typescript-eslint/no-explicit-any*/ +/* eslint-disable max-lines-per-function */ + +export type DetailData = { + iscd_stat_cls_code: string; + marg_rate: string; + rprs_mrkt_kor_name: string; + bstp_kor_isnm: string; + temp_stop_yn: string; + oprc_rang_cont_yn: string; + clpr_rang_cont_yn: string; + crdt_able_yn: string; + grmn_rate_cls_code: string; + elw_pblc_yn: string; + stck_prpr: string; + prdy_vrss: string; + prdy_vrss_sign: string; + prdy_ctrt: string; + acml_tr_pbmn: string; + acml_vol: string; + prdy_vrss_vol_rate: string; + stck_oprc: string; + stck_hgpr: string; + stck_lwpr: string; + stck_mxpr: string; + stck_llam: string; + stck_sdpr: string; + wghn_avrg_stck_prc: string; + hts_frgn_ehrt: string; + frgn_ntby_qty: string; + pgtr_ntby_qty: string; + pvt_scnd_dmrs_prc: string; + pvt_frst_dmrs_prc: string; + pvt_pont_val: string; + pvt_frst_dmsp_prc: string; + pvt_scnd_dmsp_prc: string; + dmrs_val: string; + dmsp_val: string; + cpfn: string; + rstc_wdth_prc: string; + stck_fcam: string; + stck_sspr: string; + aspr_unit: string; + hts_deal_qty_unit_val: string; + lstn_stcn: string; + hts_avls: string; + per: string; + pbr: string; + stac_month: string; + vol_tnrt: string; + eps: string; + bps: string; + d250_hgpr: string; + d250_hgpr_date: string; + d250_hgpr_vrss_prpr_rate: string; + d250_lwpr: string; + d250_lwpr_date: string; + d250_lwpr_vrss_prpr_rate: string; + stck_dryy_hgpr: string; + dryy_hgpr_vrss_prpr_rate: string; + dryy_hgpr_date: string; + stck_dryy_lwpr: string; + dryy_lwpr_vrss_prpr_rate: string; + dryy_lwpr_date: string; + w52_hgpr: string; + w52_hgpr_vrss_prpr_ctrt: string; + w52_hgpr_date: string; + w52_lwpr: string; + w52_lwpr_vrss_prpr_ctrt: string; + w52_lwpr_date: string; + whol_loan_rmnd_rate: string; + ssts_yn: string; + stck_shrn_iscd: string; + fcam_cnnm: string; + cpfn_cnnm: string; + frgn_hldn_qty: string; + vi_cls_code: string; + ovtm_vi_cls_code: string; + last_ssts_cntg_qty: string; + invt_caful_yn: string; + mrkt_warn_cls_code: string; + short_over_yn: string; + sltr_yn: string; +}; + +export function isDetailData(data: any): data is DetailData { + return ( + typeof data.iscd_stat_cls_code === 'string' && + typeof data.marg_rate === 'string' && + typeof data.rprs_mrkt_kor_name === 'string' && + typeof data.bstp_kor_isnm === 'string' && + typeof data.temp_stop_yn === 'string' && + typeof data.oprc_rang_cont_yn === 'string' && + typeof data.clpr_rang_cont_yn === 'string' && + typeof data.crdt_able_yn === 'string' && + typeof data.grmn_rate_cls_code === 'string' && + typeof data.elw_pblc_yn === 'string' && + typeof data.stck_prpr === 'string' && + typeof data.prdy_vrss === 'string' && + typeof data.prdy_vrss_sign === 'string' && + typeof data.prdy_ctrt === 'string' && + typeof data.acml_tr_pbmn === 'string' && + typeof data.acml_vol === 'string' && + typeof data.prdy_vrss_vol_rate === 'string' && + typeof data.stck_oprc === 'string' && + typeof data.stck_hgpr === 'string' && + typeof data.stck_lwpr === 'string' && + typeof data.stck_mxpr === 'string' && + typeof data.stck_llam === 'string' && + typeof data.stck_sdpr === 'string' && + typeof data.wghn_avrg_stck_prc === 'string' && + typeof data.hts_frgn_ehrt === 'string' && + typeof data.frgn_ntby_qty === 'string' && + typeof data.pgtr_ntby_qty === 'string' && + typeof data.pvt_scnd_dmrs_prc === 'string' && + typeof data.pvt_frst_dmrs_prc === 'string' && + typeof data.pvt_pont_val === 'string' && + typeof data.pvt_frst_dmsp_prc === 'string' && + typeof data.pvt_scnd_dmsp_prc === 'string' && + typeof data.dmrs_val === 'string' && + typeof data.dmsp_val === 'string' && + typeof data.cpfn === 'string' && + typeof data.rstc_wdth_prc === 'string' && + typeof data.stck_fcam === 'string' && + typeof data.stck_sspr === 'string' && + typeof data.aspr_unit === 'string' && + typeof data.hts_deal_qty_unit_val === 'string' && + typeof data.lstn_stcn === 'string' && + typeof data.hts_avls === 'string' && + typeof data.per === 'string' && + typeof data.pbr === 'string' && + typeof data.stac_month === 'string' && + typeof data.vol_tnrt === 'string' && + typeof data.eps === 'string' && + typeof data.bps === 'string' && + typeof data.d250_hgpr === 'string' && + typeof data.d250_hgpr_date === 'string' && + typeof data.d250_hgpr_vrss_prpr_rate === 'string' && + typeof data.d250_lwpr === 'string' && + typeof data.d250_lwpr_date === 'string' && + typeof data.d250_lwpr_vrss_prpr_rate === 'string' && + typeof data.stck_dryy_hgpr === 'string' && + typeof data.dryy_hgpr_vrss_prpr_rate === 'string' && + typeof data.dryy_hgpr_date === 'string' && + typeof data.stck_dryy_lwpr === 'string' && + typeof data.dryy_lwpr_vrss_prpr_rate === 'string' && + typeof data.dryy_lwpr_date === 'string' && + typeof data.w52_hgpr === 'string' && + typeof data.w52_hgpr_vrss_prpr_ctrt === 'string' && + typeof data.w52_hgpr_date === 'string' && + typeof data.w52_lwpr === 'string' && + typeof data.w52_lwpr_vrss_prpr_ctrt === 'string' && + typeof data.w52_lwpr_date === 'string' && + typeof data.whol_loan_rmnd_rate === 'string' && + typeof data.ssts_yn === 'string' && + typeof data.stck_shrn_iscd === 'string' && + typeof data.fcam_cnnm === 'string' && + typeof data.cpfn_cnnm === 'string' && + typeof data.frgn_hldn_qty === 'string' && + typeof data.vi_cls_code === 'string' && + typeof data.ovtm_vi_cls_code === 'string' && + typeof data.last_ssts_cntg_qty === 'string' && + typeof data.invt_caful_yn === 'string' && + typeof data.mrkt_warn_cls_code === 'string' && + typeof data.short_over_yn === 'string' && + typeof data.sltr_yn === 'string' + ); +} diff --git a/packages/backend/src/scraper/openapi/type/openapiIndex.type.ts b/packages/backend/src/scraper/openapi/type/openapiIndex.type.ts new file mode 100644 index 00000000..cf533114 --- /dev/null +++ b/packages/backend/src/scraper/openapi/type/openapiIndex.type.ts @@ -0,0 +1,141 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable max-lines-per-function */ + +export type IndexRateId = '0001' | '1001' | 'FX@KRW'; + +export const IndexRateStockId: { [key: string]: IndexRateId } = { + kospi: '0001', + kosdaq: '1001', + usd_krw: 'FX@KRW', +}; + +export type IndexRateGroupCode = 'INX' | 'RATE'; +export const IndexRateGroupCodeStock: { [key: string]: IndexRateGroupCode } = { + kospi: 'INX', + kosdaq: 'INX', + usd_krw: 'RATE', +}; + +export type ExchangeRateQuery = { + fid_cond_mrkt_div_code: string; + fid_input_date_1: string; + fid_input_date_2: string; + fid_input_iscd: string; + fid_period_div_code: string; +}; + +export type ExchangeRate = { + acml_vol: string; + ovrs_nmix_prpr: string; + ovrs_nmix_prdy_vrss: string; + prdy_vrss_sign: string; + prdy_ctrt: string; + ovrs_nmix_prdy_clpr: string; + hts_kor_isnm: string; + stck_shrn_iscd: string; + ovrs_prod_oprc: string; + ovrs_prod_hgpr: string; + ovrs_prod_lwpr: string; +}; + +export function isExchangeRate(data: any): data is ExchangeRate { + return ( + typeof data.acml_vol === 'string' && + typeof data.ovrs_nmix_prpr === 'string' && + typeof data.ovrs_nmix_prdy_vrss === 'string' && + typeof data.prdy_vrss_sign === 'string' && + typeof data.prdy_ctrt === 'string' && + typeof data.ovrs_nmix_prdy_clpr === 'string' && + typeof data.hts_kor_isnm === 'string' && + typeof data.stck_shrn_iscd === 'string' && + typeof data.ovrs_prod_oprc === 'string' && + typeof data.ovrs_prod_hgpr === 'string' && + typeof data.ovrs_prod_lwpr === 'string' + ); +} + +export type StockIndexQuery = { + fid_cond_mrkt_div_code: string; + fid_input_iscd: string; +}; + +export type StockIndex = { + bstp_nmix_prpr: string; + bstp_nmix_prdy_vrss: string; + prdy_vrss_sign: string; + bstp_nmix_prdy_ctrt: string; + acml_vol: string; + prdy_vol: string; + acml_tr_pbmn: string; + prdy_tr_pbmn: string; + bstp_nmix_oprc: string; + prdy_nmix_vrss_nmix_oprc: string; + oprc_vrss_prpr_sign: string; + bstp_nmix_oprc_prdy_ctrt: string; + bstp_nmix_hgpr: string; + prdy_nmix_vrss_nmix_hgpr: string; + hgpr_vrss_prpr_sign: string; + bstp_nmix_hgpr_prdy_ctrt: string; + bstp_nmix_lwpr: string; + prdy_clpr_vrss_lwpr: string; + lwpr_vrss_prpr_sign: string; + prdy_clpr_vrss_lwpr_rate: string; + ascn_issu_cnt: string; + uplm_issu_cnt: string; + stnr_issu_cnt: string; + down_issu_cnt: string; + lslm_issu_cnt: string; + dryy_bstp_nmix_hgpr: string; + dryy_hgpr_vrss_prpr_rate: string; + dryy_bstp_nmix_hgpr_date: string; + dryy_bstp_nmix_lwpr: string; + dryy_lwpr_vrss_prpr_rate: string; + dryy_bstp_nmix_lwpr_date: string; + total_askp_rsqn: string; + total_bidp_rsqn: string; + seln_rsqn_rate: string; + shnu_rsqn_rate: string; + ntby_rsqn: string; +}; + +export function isStockIndex(data: any): data is StockIndex { + return ( + data && + typeof data.bstp_nmix_prpr === 'string' && + typeof data.bstp_nmix_prdy_vrss === 'string' && + typeof data.prdy_vrss_sign === 'string' && + typeof data.bstp_nmix_prdy_ctrt === 'string' && + typeof data.acml_vol === 'string' && + typeof data.prdy_vol === 'string' && + typeof data.acml_tr_pbmn === 'string' && + typeof data.prdy_tr_pbmn === 'string' && + typeof data.bstp_nmix_oprc === 'string' && + typeof data.prdy_nmix_vrss_nmix_oprc === 'string' && + typeof data.oprc_vrss_prpr_sign === 'string' && + typeof data.bstp_nmix_oprc_prdy_ctrt === 'string' && + typeof data.bstp_nmix_hgpr === 'string' && + typeof data.prdy_nmix_vrss_nmix_hgpr === 'string' && + typeof data.hgpr_vrss_prpr_sign === 'string' && + typeof data.bstp_nmix_hgpr_prdy_ctrt === 'string' && + typeof data.bstp_nmix_lwpr === 'string' && + typeof data.prdy_clpr_vrss_lwpr === 'string' && + typeof data.lwpr_vrss_prpr_sign === 'string' && + typeof data.prdy_clpr_vrss_lwpr_rate === 'string' && + typeof data.ascn_issu_cnt === 'string' && + typeof data.uplm_issu_cnt === 'string' && + typeof data.stnr_issu_cnt === 'string' && + typeof data.down_issu_cnt === 'string' && + typeof data.lslm_issu_cnt === 'string' && + typeof data.dryy_bstp_nmix_hgpr === 'string' && + typeof data.dryy_hgpr_vrss_prpr_rate === 'string' && + typeof data.dryy_bstp_nmix_hgpr_date === 'string' && + typeof data.dryy_bstp_nmix_lwpr === 'string' && + typeof data.dryy_lwpr_vrss_prpr_rate === 'string' && + typeof data.dryy_bstp_nmix_lwpr_date === 'string' && + typeof data.total_askp_rsqn === 'string' && + typeof data.total_bidp_rsqn === 'string' && + typeof data.seln_rsqn_rate === 'string' && + typeof data.shnu_rsqn_rate === 'string' && + typeof data.ntby_rsqn === 'string' + ); +} diff --git a/packages/backend/src/scraper/openapi/type/openapiLiveData.type.ts b/packages/backend/src/scraper/openapi/type/openapiLiveData.type.ts new file mode 100644 index 00000000..18bead3e --- /dev/null +++ b/packages/backend/src/scraper/openapi/type/openapiLiveData.type.ts @@ -0,0 +1,255 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable max-lines-per-function */ + +export type StockData = { + MKSC_SHRN_ISCD: string; // 유가증권 단축 종목코드 + STCK_CNTG_HOUR: string; // 주식 체결 시간 + STCK_PRPR: string; // 주식 현재가 + PRDY_VRSS_SIGN: string; // 전일 대비 부호 + PRDY_VRSS: string; // 전일 대비 + PRDY_CTRT: string; // 전일 대비율 + WGHN_AVRG_STCK_PRC: string; // 가중 평균 주식 가격 + STCK_OPRC: string; // 주식 시가 + STCK_HGPR: string; // 주식 최고가 + STCK_LWPR: string; // 주식 최저가 + ASKP1: string; // 매도호가1 + BIDP1: string; // 매수호가1 + CNTG_VOL: string; // 체결 거래량 + ACML_VOL: string; // 누적 거래량 + ACML_TR_PBMN: string; // 누적 거래 대금 + SELN_CNTG_CSNU: string; // 매도 체결 건수 + SHNU_CNTG_CSNU: string; // 매수 체결 건수 + NTBY_CNTG_CSNU: string; // 순매수 체결 건수 + CTTR: string; // 체결강도 + SELN_CNTG_SMTN: string; // 총 매도 수량 + SHNU_CNTG_SMTN: string; // 총 매수 수량 + CCLD_DVSN: string; // 체결구분 + SHNU_RATE: string; // 매수비율 + PRDY_VOL_VRSS_ACML_VOL_RATE: string; // 전일 거래량 대비 등락율 + OPRC_HOUR: string; // 시가 시간 + OPRC_VRSS_PRPR_SIGN: string; // 시가대비구분 + OPRC_VRSS_PRPR: string; // 시가대비 + HGPR_HOUR: string; // 최고가 시간 + HGPR_VRSS_PRPR_SIGN: string; // 고가대비구분 + HGPR_VRSS_PRPR: string; // 고가대비 + LWPR_HOUR: string; // 최저가 시간 + LWPR_VRSS_PRPR_SIGN: string; // 저가대비구분 + LWPR_VRSS_PRPR: string; // 저가대비 + BSOP_DATE: string; // 영업 일자 + NEW_MKOP_CLS_CODE: string; // 신 장운영 구분 코드 + TRHT_YN: string; // 거래정지 여부 + ASKP_RSQN1: string; // 매도호가 잔량1 + BIDP_RSQN1: string; // 매수호가 잔량1 + TOTAL_ASKP_RSQN: string; // 총 매도호가 잔량 + TOTAL_BIDP_RSQN: string; // 총 매수호가 잔량 + VOL_TNRT: string; // 거래량 회전율 + PRDY_SMNS_HOUR_ACML_VOL: string; // 전일 동시간 누적 거래량 + PRDY_SMNS_HOUR_ACML_VOL_RATE: string; // 전일 동시간 누적 거래량 비율 + HOUR_CLS_CODE: string; // 시간 구분 코드 + MRKT_TRTM_CLS_CODE: string; // 임의종료구분코드 + VI_STND_PRC: string; // 정적VI발동기준가 +}; + +export type OpenApiMessage = { + header: { + approval_key: string; + custtype: string; + tr_type: string; + 'content-type': string; + }; + body: { + input: { + tr_id: string; + tr_key: string; + }; + }; +}; + +export type MessageResponse = { + header: { + tr_id: string; + tr_key: string; + encrypt: string; + }; + body: { + rt_cd: string; + msg_cd: string; + msg1: string; + output?: { + iv: string; + key: string; + }; + }; +}; + +export function isMessageResponse(data: any): data is MessageResponse { + return ( + typeof data === 'object' && + data !== null && + typeof data.header === 'object' && + data.header !== null && + typeof data.header.tr_id === 'object' && + typeof data.header.tr_key === 'object' && + typeof data.header.encrypt === 'object' && + typeof data.body === 'object' && + data.body !== null && + typeof data.body.rt_cd === 'object' && + typeof data.body.msg_cd === 'object' && + typeof data.body.msg1 === 'object' && + typeof data.body.output === 'object' + ); +} + +export const stockDataKeys = [ + 'MKSC_SHRN_ISCD', + 'STCK_CNTG_HOUR', + 'STCK_PRPR', + 'PRDY_VRSS_SIGN', + 'PRDY_VRSS', + 'PRDY_CTRT', + 'WGHN_AVRG_STCK_PRC', + 'STCK_OPRC', + 'STCK_HGPR', + 'STCK_LWPR', + 'ASKP1', + 'BIDP1', + 'CNTG_VOL', + 'ACML_VOL', + 'ACML_TR_PBMN', + 'SELN_CNTG_CSNU', + 'SHNU_CNTG_CSNU', + 'NTBY_CNTG_CSNU', + 'CTTR', + 'SELN_CNTG_SMTN', + 'SHNU_CNTG_SMTN', + 'CCLD_DVSN', + 'SHNU_RATE', + 'PRDY_VOL_VRSS_ACML_VOL_RATE', + 'OPRC_HOUR', + 'OPRC_VRSS_PRPR_SIGN', + 'OPRC_VRSS_PRPR', + 'HGPR_HOUR', + 'HGPR_VRSS_PRPR_SIGN', + 'HGPR_VRSS_PRPR', + 'LWPR_HOUR', + 'LWPR_VRSS_PRPR_SIGN', + 'LWPR_VRSS_PRPR', + 'BSOP_DATE', + 'NEW_MKOP_CLS_CODE', + 'TRHT_YN', + 'ASKP_RSQN1', + 'BIDP_RSQN1', + 'TOTAL_ASKP_RSQN', + 'TOTAL_BIDP_RSQN', + 'VOL_TNRT', + 'PRDY_SMNS_HOUR_ACML_VOL', + 'PRDY_SMNS_HOUR_ACML_VOL_RATE', + 'HOUR_CLS_CODE', + 'MRKT_TRTM_CLS_CODE', + 'VI_STND_PRC', +]; + +export type OpenapiLiveData = { + iscd_stat_cls_code: string; + marg_rate: string; + rprs_mrkt_kor_name: string; + bstp_kor_isnm: string; + temp_stop_yn: string; + oprc_rang_cont_yn: string; + clpr_rang_cont_yn: string; + crdt_able_yn: string; + grmn_rate_cls_code: string; + elw_pblc_yn: string; + stck_prpr: string; + prdy_vrss: string; + prdy_vrss_sign: string; + prdy_ctrt: string; + acml_tr_pbmn: string; + acml_vol: string; + prdy_vrss_vol_rate: string; + stck_oprc: string; + stck_hgpr: string; + stck_lwpr: string; + stck_mxpr: string; + stck_llam: string; + stck_sdpr: string; + wghn_avrg_stck_prc: string; + hts_frgn_ehrt: string; + frgn_ntby_qty: string; + pgtr_ntby_qty: string; + pvt_scnd_dmrs_prc: string; + pvt_frst_dmrs_prc: string; + pvt_pont_val: string; + pvt_frst_dmsp_prc: string; + pvt_scnd_dmsp_prc: string; + dmrs_val: string; + dmsp_val: string; + cpfn: string; + rstc_wdth_prc: string; + stck_fcam: string; + stck_sspr: string; + aspr_unit: string; + hts_deal_qty_unit_val: string; + lstn_stcn: string; + hts_avls: string; + per: string; + pbr: string; + stac_month: string; + vol_tnrt: string; + eps: string; + bps: string; + d250_hgpr: string; + d250_hgpr_date: string; + d250_hgpr_vrss_prpr_rate: string; + d250_lwpr: string; + d250_lwpr_date: string; + d250_lwpr_vrss_prpr_rate: string; + stck_dryy_hgpr: string; + dryy_hgpr_vrss_prpr_rate: string; + dryy_hgpr_date: string; + stck_dryy_lwpr: string; + dryy_lwpr_vrss_prpr_rate: string; + dryy_lwpr_date: string; + w52_hgpr: string; + w52_hgpr_vrss_prpr_ctrt: string; + w52_hgpr_date: string; + w52_lwpr: string; + w52_lwpr_vrss_prpr_ctrt: string; + w52_lwpr_date: string; + whol_loan_rmnd_rate: string; + ssts_yn: string; + stck_shrn_iscd: string; + fcam_cnnm: string; + cpfn_cnnm: string; + frgn_hldn_qty: string; + vi_cls_code: string; + ovtm_vi_cls_code: string; + last_ssts_cntg_qty: string; + invt_caful_yn: string; + mrkt_warn_cls_code: string; + short_over_yn: string; + sltr_yn: string; +}; + +export const isOpenapiLiveData = (data: any): data is OpenapiLiveData => { + return ( + typeof data === 'object' && + data !== null && + typeof data.iscd_stat_cls_code === 'string' && + typeof data.marg_rate === 'string' && + typeof data.rprs_mrkt_kor_name === 'string' && + typeof data.bstp_kor_isnm === 'string' && + typeof data.temp_stop_yn === 'string' && + typeof data.oprc_rang_cont_yn === 'string' && + typeof data.clpr_rang_cont_yn === 'string' && + typeof data.crdt_able_yn === 'string' && + typeof data.stck_prpr === 'string' && + typeof data.prdy_ctrt === 'string' && + typeof data.acml_vol === 'string' && + typeof data.stck_oprc === 'string' && + typeof data.stck_hgpr === 'string' && + typeof data.stck_lwpr === 'string' && + typeof data.wghn_avrg_stck_prc === 'string' && + typeof data.stck_shrn_iscd === 'string' + ); +}; diff --git a/packages/backend/src/scraper/openapi/type/openapiPeriodData.ts b/packages/backend/src/scraper/openapi/type/openapiPeriodData.ts index 4acc7f44..e4066f7c 100644 --- a/packages/backend/src/scraper/openapi/type/openapiPeriodData.ts +++ b/packages/backend/src/scraper/openapi/type/openapiPeriodData.ts @@ -25,8 +25,9 @@ export type ItemChartPriceQuery = { fid_org_adj_prc: number; }; -export const isChartData = (data: any) => { +export const isChartData = (data?: any) => { return ( + data && typeof data.stck_bsop_date === 'string' && typeof data.stck_clpr === 'string' && typeof data.stck_oprc === 'string' && diff --git a/packages/backend/src/scraper/openapi/type/openapiPeriodData.type.ts b/packages/backend/src/scraper/openapi/type/openapiPeriodData.type.ts new file mode 100644 index 00000000..e4066f7c --- /dev/null +++ b/packages/backend/src/scraper/openapi/type/openapiPeriodData.type.ts @@ -0,0 +1,45 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +export type Period = 'D' | 'W' | 'M' | 'Y'; +export type ChartData = { + stck_bsop_date: string; + stck_clpr: string; + stck_oprc: string; + stck_hgpr: string; + stck_lwpr: string; + acml_vol: string; + acml_tr_pbmn: string; + flng_cls_code: string; + prtt_rate: string; + mod_yn: string; + prdy_vrss_sign: string; + prdy_vrss: string; + revl_issu_reas: string; +}; + +export type ItemChartPriceQuery = { + fid_cond_mrkt_div_code: 'J' | 'W'; + fid_input_iscd: string; + fid_input_date_1: string; + fid_input_date_2: string; + fid_period_div_code: Period; + fid_org_adj_prc: number; +}; + +export const isChartData = (data?: any) => { + return ( + data && + typeof data.stck_bsop_date === 'string' && + typeof data.stck_clpr === 'string' && + typeof data.stck_oprc === 'string' && + typeof data.stck_hgpr === 'string' && + typeof data.stck_lwpr === 'string' && + typeof data.acml_vol === 'string' && + typeof data.acml_tr_pbmn === 'string' && + typeof data.flng_cls_code === 'string' && + typeof data.prtt_rate === 'string' && + typeof data.mod_yn === 'string' && + typeof data.prdy_vrss_sign === 'string' && + typeof data.prdy_vrss === 'string' && + typeof data.revl_issu_reas === 'string' + ); +}; diff --git a/packages/backend/src/scraper/openapi/type/openapiUtil.type.ts b/packages/backend/src/scraper/openapi/type/openapiUtil.type.ts new file mode 100644 index 00000000..6ccdeec1 --- /dev/null +++ b/packages/backend/src/scraper/openapi/type/openapiUtil.type.ts @@ -0,0 +1,21 @@ +export type TR_ID = + | 'FHKST03010100' + | 'FHKST03010200' + | 'FHKST66430300' + | 'HHKDB669107C0' + | 'FHPST01700000' + | 'FHKST01010100' + | 'FHPUP02100000' + | 'FHKST03030100' + | 'CTPF1002R'; + +export const TR_IDS: Record = { + ITEM_CHART_PRICE: 'FHKST03010100', + MINUTE_DATA: 'FHKST03010200', + FINANCIAL_DATA: 'FHKST66430300', + PRODUCTION_DETAIL: 'CTPF1002R', + LIVE_DATA: 'FHKST01010100', + INDEX_DATA: 'FHPUP02100000', + RATE_DATA: 'FHKST03030100', + FLUCTUATION_DATA: 'FHPST01700000', +}; diff --git a/packages/backend/src/scraper/openapi/util/openapiCustom.error.ts b/packages/backend/src/scraper/openapi/util/openapiCustom.error.ts new file mode 100644 index 00000000..1e0c3913 --- /dev/null +++ b/packages/backend/src/scraper/openapi/util/openapiCustom.error.ts @@ -0,0 +1,13 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; + +export class OpenapiException extends HttpException { + private error: unknown; + constructor(message: string, status: HttpStatus, error?: unknown) { + super(message, status); + this.error = error; + } + + public getError() { + return this.error; + } +} diff --git a/packages/backend/src/scraper/openapi/openapiUtil.api.ts b/packages/backend/src/scraper/openapi/util/openapiUtil.api.ts similarity index 55% rename from packages/backend/src/scraper/openapi/openapiUtil.api.ts rename to packages/backend/src/scraper/openapi/util/openapiUtil.api.ts index e09c181c..5fa93d7e 100644 --- a/packages/backend/src/scraper/openapi/openapiUtil.api.ts +++ b/packages/backend/src/scraper/openapi/util/openapiUtil.api.ts @@ -1,5 +1,26 @@ +/* eslint-disable @typescript-eslint/no-explicit-any*/ +import * as crypto from 'crypto'; +import { HttpStatus } from '@nestjs/common'; import axios from 'axios'; -import { openApiConfig } from './config/openapi.config'; +import { openApiConfig } from '../config/openapi.config'; +import { TR_ID } from '../type/openapiUtil.type'; +import { OpenapiException } from './openapiCustom.error'; + +const throwOpenapiException = (error: any, url: string) => { + if (error.message && error.response && error.response.status) { + throw new OpenapiException( + `${url} : ${error.message} `, + error.response.status, + error, + ); + } else { + throw new OpenapiException( + `Unknown error: ${error.message || 'No message'}`, + HttpStatus.INTERNAL_SERVER_ERROR, + error, + ); + } +}; const postOpenApi = async ( url: string, @@ -10,7 +31,7 @@ const postOpenApi = async ( const response = await axios.post(config.STOCK_URL + url, body); return response.data; } catch (error) { - throw new Error(`Request failed: ${error}`); + throwOpenapiException(error, url); } }; @@ -18,6 +39,7 @@ const getOpenApi = async ( url: string, config: typeof openApiConfig, query: object, + tr_id: TR_ID, ) => { try { const response = await axios.get(config.STOCK_URL + url, { @@ -26,12 +48,13 @@ const getOpenApi = async ( Authorization: `Bearer ${config.STOCK_API_TOKEN}`, appkey: config.STOCK_API_KEY, appsecret: config.STOCK_API_PASSWORD, - tr_id: 'FHKST03010100', + tr_id, + custtype: 'P', }, }); return response.data; } catch (error) { - throw new Error(`Request failed: ${error}`); + throwOpenapiException(error, url); } }; @@ -56,10 +79,26 @@ const getCurrentTime = () => { return `${hours}${minutes}${seconds}`; }; +const decryptAES256 = ( + encryptedText: string, + key: string, + iv: string, +): string => { + const decipher = crypto.createDecipheriv( + 'aes-256-cbc', + Buffer.from(key, 'hex'), + Buffer.from(iv, 'hex'), + ); + let decrypted = decipher.update(encryptedText, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + return decrypted; +}; + export { postOpenApi, getOpenApi, getTodayDate, getPreviousDate, getCurrentTime, + decryptAES256, }; diff --git a/packages/backend/src/scraper/openapi/util/priorityQueue.ts b/packages/backend/src/scraper/openapi/util/priorityQueue.ts new file mode 100644 index 00000000..c0abf838 --- /dev/null +++ b/packages/backend/src/scraper/openapi/util/priorityQueue.ts @@ -0,0 +1,86 @@ +export class PriorityQueue { + private heap: { value: T; priority: number }[]; + + constructor() { + this.heap = []; + } + + enqueue(value: T, priority: number) { + this.heap.push({ value, priority }); + this.heapifyUp(); + } + + dequeue(): T | undefined { + if (this.isEmpty()) { + return undefined; + } + + const root = this.heap[0]; + const last = this.heap.pop(); + + if (this.heap.length > 0 && last) { + this.heap[0] = last; + this.heapifyDown(); + } + + return root.value; + } + + peek(): T | undefined { + return this.heap.length > 0 ? this.heap[0].value : undefined; + } + + isEmpty(): boolean { + return this.heap.length === 0; + } + + private getParentIndex(index: number): number { + return Math.floor((index - 1) / 2); + } + + private getLeftChildIndex(index: number): number { + return index * 2 + 1; + } + + private getRightChildIndex(index: number): number { + return index * 2 + 2; + } + + private swap(i: number, j: number) { + [this.heap[i], this.heap[j]] = [this.heap[j], this.heap[i]]; + } + + private heapifyUp() { + let index = this.heap.length - 1; + while ( + index > 0 && + this.heap[index].priority < this.heap[this.getParentIndex(index)].priority + ) { + this.swap(index, this.getParentIndex(index)); + index = this.getParentIndex(index); + } + } + + private heapifyDown() { + let index = 0; + while (this.getLeftChildIndex(index) < this.heap.length) { + let smallerChildIndex = this.getLeftChildIndex(index); + const rightChildIndex = this.getRightChildIndex(index); + + if ( + rightChildIndex < this.heap.length && + this.heap[rightChildIndex].priority < + this.heap[smallerChildIndex].priority + ) { + smallerChildIndex = rightChildIndex; + } + + if (this.heap[index].priority <= this.heap[smallerChildIndex].priority) { + break; + } + + this.swap(index, smallerChildIndex); + index = smallerChildIndex; + } + } +} diff --git a/packages/backend/src/scraper/openapi/websocket/websocketClient.websocket.ts b/packages/backend/src/scraper/openapi/websocket/websocketClient.websocket.ts new file mode 100644 index 00000000..9661abde --- /dev/null +++ b/packages/backend/src/scraper/openapi/websocket/websocketClient.websocket.ts @@ -0,0 +1,58 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Inject, Injectable } from '@nestjs/common'; +import { Logger } from 'winston'; +import { RawData, WebSocket } from 'ws'; + +@Injectable() +export class WebsocketClient { + private readonly url = + process.env.WS_URL ?? 'ws://ops.koreainvestment.com:21000'; + private client: WebSocket = new WebSocket(this.url); + + constructor(@Inject('winston') private readonly logger: Logger) {} + + subscribe(message: string) { + this.sendMessage(message); + } + + discribe(message: string) { + this.sendMessage(message); + } + + private initOpen(fn: () => void) { + this.client.on('open', fn); + } + + private initMessage(fn: (data: RawData) => void) { + this.client.on('message', fn); + } + + private initDisconnect(initCloseCallback: () => void) { + this.client.on('close', initCloseCallback); + } + + private initError(initErrorCallback: (error: unknown) => void) { + this.client.on('error', initErrorCallback); + } + + connectPacade( + initOpenCallback: (fn: (message: string) => void) => () => void, + initMessageCallback: (client: WebSocket) => (data: RawData) => void, + initCloseCallback: () => void, + initErrorCallback: (error: unknown) => void, + ) { + this.initOpen(initOpenCallback(this.sendMessage)); + this.initMessage(initMessageCallback(this.client)); + this.initDisconnect(initCloseCallback); + this.initError(initErrorCallback); + } + + private sendMessage(message: string) { + if (this.client.readyState === WebSocket.OPEN) { + this.client.send(message); + this.logger.info(`Sent message: ${message}`); + } else { + this.logger.warn('WebSocket is not open. Message not sent.'); + } + } +} diff --git a/packages/backend/src/stock/decorator/stock.decorator.ts b/packages/backend/src/stock/decorator/stock.decorator.ts index 1412860e..0b80892e 100644 --- a/packages/backend/src/stock/decorator/stock.decorator.ts +++ b/packages/backend/src/stock/decorator/stock.decorator.ts @@ -1,12 +1,12 @@ /* eslint-disable @typescript-eslint/naming-convention */ import { - Query, - ParseIntPipe, - DefaultValuePipe, applyDecorators, + DefaultValuePipe, + ParseIntPipe, + Query, } from '@nestjs/common'; import { ApiOperation, ApiQuery, ApiResponse } from '@nestjs/swagger'; -import { StocksResponse } from '../dto/stock.Response'; +import { StocksResponse } from '../dto/stock.response'; export function LimitQuery(defaultValue = 5): ParameterDecorator { return Query('limit', new DefaultValuePipe(defaultValue), ParseIntPipe); diff --git a/packages/backend/src/stock/decorator/stockData.decorator.ts b/packages/backend/src/stock/decorator/stockData.decorator.ts index 19eb3969..4941fa61 100644 --- a/packages/backend/src/stock/decorator/stockData.decorator.ts +++ b/packages/backend/src/stock/decorator/stockData.decorator.ts @@ -22,6 +22,14 @@ export function ApiGetStockData(summary: string, type: string) { type: String, format: 'date-time', }), + ApiQuery({ + name: 'timeunit', + required: false, + description: '시간 단위', + example: 'minute', + type: String, + enum: ['minute', 'day', 'week', 'month', 'year'], + }), ApiResponse({ status: 200, description: `주식의 ${type} 단위 데이터 성공적으로 조회`, diff --git a/packages/backend/src/stock/domain/FluctuationRankStock.entity.ts b/packages/backend/src/stock/domain/FluctuationRankStock.entity.ts new file mode 100644 index 00000000..5ece2107 --- /dev/null +++ b/packages/backend/src/stock/domain/FluctuationRankStock.entity.ts @@ -0,0 +1,31 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { Stock } from '@/stock/domain/stock.entity'; + +@Entity() +export class FluctuationRankStock { + @PrimaryGeneratedColumn() + id: number; + + @ManyToOne(() => Stock, (stock) => stock.id) + @JoinColumn({ name: 'stock_id' }) + stock: Stock; + + @Column({ name: 'fluctuation_rate', type: 'decimal', precision: 5, scale: 2 }) + fluctuationRate: string; + + @Column() + isRising: boolean; + + @Column() + rank: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + createdAt: Date; +} diff --git a/packages/backend/src/stock/domain/kospiStock.entity.ts b/packages/backend/src/stock/domain/kospiStock.entity.ts new file mode 100644 index 00000000..8f45a87c --- /dev/null +++ b/packages/backend/src/stock/domain/kospiStock.entity.ts @@ -0,0 +1,15 @@ +import { Column, Entity, JoinColumn, OneToOne, PrimaryColumn } from 'typeorm'; +import { Stock } from './stock.entity'; + +@Entity() +export class KospiStock { + @PrimaryColumn({ name: 'stock_id' }) + id: string; + + @Column({ name: 'is_kospi' }) + isKospi: boolean; + + @OneToOne(() => Stock, (stock) => stock.id) + @JoinColumn({ name: 'stock_id' }) + stock: Stock; +} diff --git a/packages/backend/src/stock/domain/stock.entity.ts b/packages/backend/src/stock/domain/stock.entity.ts index 033833bf..eaa38aa7 100644 --- a/packages/backend/src/stock/domain/stock.entity.ts +++ b/packages/backend/src/stock/domain/stock.entity.ts @@ -1,6 +1,5 @@ -import { Column, Entity, OneToMany, PrimaryColumn } from 'typeorm'; -import { DateEmbedded } from '@/common/dateEmbedded.entity'; -import { UserStock } from '@/stock/domain/userStock.entity'; +import { Column, Entity, OneToMany, OneToOne, PrimaryColumn } from 'typeorm'; +import { KospiStock } from './kospiStock.entity'; import { StockDaily, StockMinutely, @@ -8,14 +7,19 @@ import { StockWeekly, StockYearly, } from './stockData.entity'; +import { StockLiveData } from './stockLiveData.entity'; +import { Like } from '@/chat/domain/like.entity'; +import { DateEmbedded } from '@/common/dateEmbedded.entity'; +import { FluctuationRankStock } from '@/stock/domain/FluctuationRankStock.entity'; +import { UserStock } from '@/stock/domain/userStock.entity'; @Entity() export class Stock { @PrimaryColumn({ name: 'stock_id' }) - id?: string; + id: string; @Column({ name: 'stock_name' }) - name?: string; + name: string; @Column({ default: 0 }) views: number = 0; @@ -24,10 +28,13 @@ export class Stock { isTrading: boolean = true; @Column({ name: 'group_code' }) - groupCode?: string; + groupCode: string; @Column(() => DateEmbedded, { prefix: '' }) - dare?: DateEmbedded; + date?: DateEmbedded; + + @OneToMany(() => Like, (like) => like.chat) + likes?: Like[]; @OneToMany(() => UserStock, (userStock) => userStock.stock) userStocks?: UserStock[]; @@ -46,4 +53,16 @@ export class Stock { @OneToMany(() => StockYearly, (stockYearly) => stockYearly.stock) stockYearly?: StockYearly[]; + + @OneToOne(() => StockLiveData, (stockLiveData) => stockLiveData.stock) + stockLive?: StockLiveData; + + @OneToOne(() => KospiStock, (kospiStock) => kospiStock.stock) + kospiStock?: KospiStock; + + @OneToMany( + () => FluctuationRankStock, + (fluctuationRankStock) => fluctuationRankStock.stock, + ) + fluctuationRankStocks?: FluctuationRankStock[]; } diff --git a/packages/backend/src/stock/domain/stockData.entity.ts b/packages/backend/src/stock/domain/stockData.entity.ts index e9c75407..fc28e77e 100644 --- a/packages/backend/src/stock/domain/stockData.entity.ts +++ b/packages/backend/src/stock/domain/stockData.entity.ts @@ -1,4 +1,3 @@ -import { applyDecorators } from '@nestjs/common'; import { Entity, PrimaryGeneratedColumn, @@ -6,27 +5,11 @@ import { CreateDateColumn, JoinColumn, ManyToOne, - ColumnOptions, + Index, } from 'typeorm'; import { Stock } from './stock.entity'; -export const GenerateBigintColumn = ( - options?: ColumnOptions, -): PropertyDecorator => { - return applyDecorators( - Column({ - ...options, - type: 'bigint', - transformer: { - to: (value: bigint): string => - typeof value === 'bigint' ? value.toString() : value, - from: (value: string): bigint => - typeof value === 'string' ? BigInt(value) : value, - }, - }), - ); -}; - +@Index('stock_id_start_time', ['stock.id', 'startTime'], { unique: true }) export class StockData { @PrimaryGeneratedColumn() id: number; diff --git a/packages/backend/src/stock/domain/stockDetail.entity.ts b/packages/backend/src/stock/domain/stockDetail.entity.ts index e11d1cd9..22a084e4 100644 --- a/packages/backend/src/stock/domain/stockDetail.entity.ts +++ b/packages/backend/src/stock/domain/stockDetail.entity.ts @@ -27,7 +27,7 @@ export class StockDetail { @Column({ type: 'integer' }) eps: number; - @Column({ type: 'decimal', precision: 6, scale: 3 }) + @Column({ type: 'decimal', precision: 15, scale: 2 }) per: number; @Column({ type: 'integer' }) diff --git a/packages/backend/src/stock/domain/stockLiveData.entity.ts b/packages/backend/src/stock/domain/stockLiveData.entity.ts index bf82f8d6..ea480d6b 100644 --- a/packages/backend/src/stock/domain/stockLiveData.entity.ts +++ b/packages/backend/src/stock/domain/stockLiveData.entity.ts @@ -31,9 +31,6 @@ export class StockLiveData { @Column({ type: 'decimal', precision: 15, scale: 2 }) open: number; - @Column({ type: 'decimal', precision: 15, scale: 2 }) - previousClose: number; - @UpdateDateColumn() @Column({ type: 'timestamp' }) updatedAt: Date; diff --git a/packages/backend/src/stock/domain/userStock.entity.ts b/packages/backend/src/stock/domain/userStock.entity.ts index cf4e8e8f..b0c8a7eb 100644 --- a/packages/backend/src/stock/domain/userStock.entity.ts +++ b/packages/backend/src/stock/domain/userStock.entity.ts @@ -13,14 +13,14 @@ import { User } from '@/user/domain/user.entity'; @Entity() export class UserStock { @PrimaryGeneratedColumn() - id?: number; + id: number; @ManyToOne(() => User) - user?: User; + user: User; @ManyToOne(() => Stock) - stock?: Stock; + stock: Stock; @Column(() => DateEmbedded, { prefix: '' }) - date?: DateEmbedded; + date: DateEmbedded; } diff --git a/packages/backend/src/stock/dto/stock.Response.ts b/packages/backend/src/stock/dto/stock.Response.ts deleted file mode 100644 index c5139450..00000000 --- a/packages/backend/src/stock/dto/stock.Response.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { Transform } from 'class-transformer'; - -export class StockViewsResponse { - @ApiProperty({ - description: '응답 메시지', - example: 'A005930', - }) - id: string; - - @ApiProperty({ - description: '응답 메시지', - example: '주식 조회수가 증가되었습니다.', - }) - message: string; - - @ApiProperty({ - description: '응답 date', - example: new Date(), - }) - date: Date; - - constructor(id: string, message: string) { - this.id = id; - this.message = message; - this.date = new Date(); - } -} - -export class StocksResponse { - @ApiProperty({ - description: '주식 종목 코드', - example: 'A005930', - }) - id: string; - @ApiProperty({ - description: '주식 종목 이름', - example: '삼성전자', - }) - name: string; - @ApiProperty({ - description: '주식 현재가', - example: 100000.0, - }) - @Transform(({ value }) => parseFloat(value)) - currentPrice: number; - @ApiProperty({ - description: '주식 변동률', - example: 2.5, - }) - @Transform(({ value }) => parseFloat(value)) - changeRate: number; - @ApiProperty({ - description: '주식 거래량', - example: 500000, - }) - @Transform(({ value }) => parseInt(value)) - volume: number; - @ApiProperty({ - description: '주식 시가 총액', - example: '500000000000.00', - }) - marketCap: string; -} diff --git a/packages/backend/src/stock/dto/stock.request.ts b/packages/backend/src/stock/dto/stock.request.ts new file mode 100644 index 00000000..255e4481 --- /dev/null +++ b/packages/backend/src/stock/dto/stock.request.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; + +export class StockSearchRequest { + @ApiProperty({ + description: '검색할 단어', + example: '삼성', + }) + @IsNotEmpty() + @IsString() + name: string; +} diff --git a/packages/backend/src/stock/dto/stock.response.ts b/packages/backend/src/stock/dto/stock.response.ts new file mode 100644 index 00000000..2d36f58b --- /dev/null +++ b/packages/backend/src/stock/dto/stock.response.ts @@ -0,0 +1,177 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { Stock } from '@/stock/domain/stock.entity'; + +export class StockViewsResponse { + @ApiProperty({ + description: '응답 메시지', + example: 'A005930', + }) + id: string; + + @ApiProperty({ + description: '응답 메시지', + example: '주식 조회수가 증가되었습니다.', + }) + message: string; + + @ApiProperty({ + description: '응답 date', + example: new Date(), + }) + date: Date; + + constructor(id: string, message: string) { + this.id = id; + this.message = message; + this.date = new Date(); + } +} + +export class StocksResponse { + @ApiProperty({ + description: '주식 종목 코드', + example: 'A005930', + }) + id: string; + + @ApiProperty({ + description: '주식 종목 이름', + example: '삼성전자', + }) + name: string; + + @ApiProperty({ + description: '주식 현재가', + example: 100000.0, + }) + @Transform(({ value }) => parseFloat(value)) + currentPrice: number; + + @ApiProperty({ + description: '주식 변동률', + example: 2.5, + }) + @Transform(({ value }) => parseFloat(value)) + changeRate: number; + + @ApiProperty({ + description: '주식 거래량', + example: 500000, + }) + @Transform(({ value }) => parseInt(value)) + volume: number; + + @ApiProperty({ + description: '주식 시가 총액', + example: '500000000000.00', + }) + marketCap: string; +} + +class StockSearchResult { + @ApiProperty({ + description: '주식 종목 코드', + example: 'A005930', + }) + id: string; + + @ApiProperty({ + description: '주식 종목 이름', + example: '삼성전자', + }) + name: string; +} + +export class StockSearchResponse { + @ApiProperty({ + description: '주식 검색 결과', + type: [StockSearchResult], + }) + searchResults: StockSearchResult[]; + + constructor(stocks?: Stock[]) { + if (!stocks) { + this.searchResults = []; + return; + } + this.searchResults = stocks.map((stock) => ({ + id: stock.id as string, + name: stock.name as string, + })); + } +} + +export class StockRankResponse { + @ApiProperty({ + description: '주식 종목 코드', + example: 'A005930', + }) + id: string; + + @ApiProperty({ + description: '주식 종목 이름', + example: '삼성전자', + }) + name: string; + + @ApiProperty({ + description: '주식 현재가', + example: 100000.0, + }) + @Transform(({ value }) => parseFloat(value)) + currentPrice: number; + + @ApiProperty({ + description: '주식 변동률', + example: 2.5, + }) + @Transform(({ value }) => parseFloat(value)) + changeRate: number; + + @ApiProperty({ + description: '주식 거래량', + example: 500000, + }) + @Transform(({ value }) => parseInt(value)) + volume: number; + + @ApiProperty({ + description: '주식 시가 총액', + example: '500000000000.00', + }) + marketCap: string; + + @ApiProperty({ + description: '랭킹', + example: 1, + }) + rank: number; + + @ApiProperty({ + description: '상승 하락 여부', + example: true, + }) + isRising: boolean; +} + +export class StockRankResponses { + @ApiProperty({ + description: '주식 랭킹 결과', + type: [StockRankResponse], + }) + result: StockRankResponse[]; + + constructor(stocks: Record[]) { + this.result = stocks.map((stock) => ({ + id: stock.id, + name: stock.name, + currentPrice: parseFloat(stock.currentPrice), + volume: parseInt(stock.volume), + marketCap: stock.marketCap, + changeRate: parseFloat(stock.rank_fluctuation_rate), + rank: parseInt(stock.rank_rank), + isRising: Number(stock.rank_isRising) === 1, + })); + } +} diff --git a/packages/backend/src/stock/dto/stockDetail.response.ts b/packages/backend/src/stock/dto/stockDetail.response.ts index cf692e58..248163fd 100644 --- a/packages/backend/src/stock/dto/stockDetail.response.ts +++ b/packages/backend/src/stock/dto/stockDetail.response.ts @@ -1,4 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; +import { StockDetail } from '../domain/stockDetail.entity'; export class StockDetailResponse { @ApiProperty({ @@ -7,6 +8,12 @@ export class StockDetailResponse { }) marketCap: number; + @ApiProperty({ + description: '주식의 이름', + example: '삼성전자', + }) + name: string; + @ApiProperty({ description: '주식의 EPS', example: 4091, @@ -30,4 +37,13 @@ export class StockDetailResponse { example: 53000, }) low52w: number; + + constructor(stockDetail: StockDetail) { + this.eps = stockDetail.eps; + this.per = stockDetail.per; + this.high52w = stockDetail.high52w; + this.low52w = stockDetail.low52w; + this.marketCap = Number(stockDetail.marketCap); + this.name = stockDetail.stock.name; + } } diff --git a/packages/backend/src/stock/dto/stockIndexRate.response.ts b/packages/backend/src/stock/dto/stockIndexRate.response.ts new file mode 100644 index 00000000..03f254af --- /dev/null +++ b/packages/backend/src/stock/dto/stockIndexRate.response.ts @@ -0,0 +1,41 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { StockLiveData } from '../domain/stockLiveData.entity'; +export class StockIndexRateResponse { + @ApiProperty({ description: '지표 이름', example: '원 달러 환율' }) + name: string; + + @ApiProperty({ description: '현재 가격', example: 1400 }) + currentPrice: number; + + @ApiProperty({ description: '거래량', example: 0 }) + changeRate: number; + + @ApiProperty({ description: '거래량', example: 10000 }) + volume: number; + + @ApiProperty({ description: '최고가', example: 1050 }) + high: number; + + @ApiProperty({ description: '최저가', example: 950 }) + low: number; + + @ApiProperty({ description: '시가', example: 980 }) + open: number; + + @ApiProperty({ + description: '마지막 업데이트 날짜', + example: '2023-10-01T00:00:00Z', + }) + updatedAt: Date; + + constructor(stockLiveData: StockLiveData) { + this.name = stockLiveData.stock.name; + this.currentPrice = stockLiveData.currentPrice; + this.changeRate = stockLiveData.changeRate; + this.volume = stockLiveData.volume; + this.high = stockLiveData.high; + this.low = stockLiveData.low; + this.open = stockLiveData.open; + this.updatedAt = stockLiveData.updatedAt; + } +} diff --git a/packages/backend/src/stock/dto/userStock.request.ts b/packages/backend/src/stock/dto/userStock.request.ts index 992f6465..0c3f33e2 100644 --- a/packages/backend/src/stock/dto/userStock.request.ts +++ b/packages/backend/src/stock/dto/userStock.request.ts @@ -1,6 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { Transform } from 'class-transformer'; -import { IsInt, IsString } from 'class-validator'; +import { IsString } from 'class-validator'; export class UserStockRequest { @ApiProperty({ @@ -13,10 +12,9 @@ export class UserStockRequest { export class UserStockDeleteRequest { @ApiProperty({ - example: 1, - description: '유저 소유 주식 id', + example: '005390', + description: '종목 id', }) - @IsInt() - @Transform(({ value }) => parseInt(value)) - userStockId: number; + @IsString() + stockId: string; } diff --git a/packages/backend/src/stock/dto/userStock.response.ts b/packages/backend/src/stock/dto/userStock.response.ts index 5e3c12fa..0e25e1e0 100644 --- a/packages/backend/src/stock/dto/userStock.response.ts +++ b/packages/backend/src/stock/dto/userStock.response.ts @@ -1,8 +1,9 @@ import { ApiProperty } from '@nestjs/swagger'; +import { UserStock } from '@/stock/domain/userStock.entity'; export class UserStockResponse { - @ApiProperty({ description: '사용자 주식 id', example: 1 }) - id: number; + @ApiProperty({ description: '소유 주식 id', example: '005930' }) + id: string; @ApiProperty({ description: '응답 메시지', @@ -16,7 +17,7 @@ export class UserStockResponse { }) date: Date; - constructor(id: number, message: string) { + constructor(id: string, message: string) { this.id = id; this.message = message; this.date = new Date(); @@ -38,3 +39,60 @@ export class UserStockOwnerResponse { this.date = new Date(); } } + +class UserStockResult { + @ApiProperty({ + description: '유저 주식 id', + example: 1, + }) + id: number; + + @ApiProperty({ + description: '종목 id', + example: '005930', + }) + stockId: string; + + @ApiProperty({ + description: '종목 이름', + example: '삼성전자', + }) + name: string; + + @ApiProperty({ + description: '거래 가능 여부', + example: true, + }) + isTrading: boolean; + + @ApiProperty({ + description: '그룹 코드', + example: 'A', + }) + groupCode: string; + + @ApiProperty({ + description: '생성일', + example: new Date(), + }) + createdAt: Date; +} + +export class UserStocksResponse { + @ApiProperty({ + description: '사용자 주식 정보', + type: [UserStockResult], + }) + userStocks: UserStockResult[]; + + constructor(userStocks: UserStock[]) { + this.userStocks = userStocks.map((userStock) => ({ + id: userStock.id, + stockId: userStock.stock.id, + name: userStock.stock.name, + isTrading: userStock.stock.isTrading, + groupCode: userStock.stock.groupCode, + createdAt: userStock.date.createdAt, + })); + } +} diff --git a/packages/backend/src/stock/stock.controller.ts b/packages/backend/src/stock/stock.controller.ts index 042353a3..08198b90 100644 --- a/packages/backend/src/stock/stock.controller.ts +++ b/packages/backend/src/stock/stock.controller.ts @@ -15,11 +15,13 @@ import { ApiOkResponse, ApiOperation, ApiParam, + ApiQuery, } from '@nestjs/swagger'; import { Request } from 'express'; import { ApiGetStocks, LimitQuery } from './decorator/stock.decorator'; import { ApiGetStockData } from './decorator/stockData.decorator'; import { StockDetailResponse } from './dto/stockDetail.response'; +import { StockIndexRateResponse } from './dto/stockIndexRate.response'; import { StockService } from './stock.service'; import { StockDataDailyService, @@ -29,10 +31,16 @@ import { StockDataYearlyService, } from './stockData.service'; import { StockDetailService } from './stockDetail.service'; +import { StockRateIndexService } from './stockRateIndex.service'; import SessionGuard from '@/auth/session/session.guard'; import { GetUser } from '@/common/decorator/user.decorator'; import { sessionConfig } from '@/configs/session.config'; -import { StockViewsResponse } from '@/stock/dto/stock.Response'; +import { StockSearchRequest } from '@/stock/dto/stock.request'; +import { + StockRankResponses, + StockSearchResponse, + StockViewsResponse, +} from '@/stock/dto/stock.response'; import { StockViewRequest } from '@/stock/dto/stockView.request'; import { UserStockDeleteRequest, @@ -41,9 +49,29 @@ import { import { UserStockOwnerResponse, UserStockResponse, + UserStocksResponse, } from '@/stock/dto/userStock.response'; import { User } from '@/user/domain/user.entity'; +const TIME_UNIT = { + MINUTE: 'minute', + DAY: 'day', + WEEK: 'week', + MONTH: 'month', + YEAR: 'year', +} as const; + +type TIME_UNIT = (typeof TIME_UNIT)[keyof typeof TIME_UNIT]; + +const FLUCTUATION_TYPE = { + INCREASE: 'increase', + DECREASE: 'decrease', + ALL: 'all', +} as const; + +type FLUCTUATION_TYPE = + (typeof FLUCTUATION_TYPE)[keyof typeof FLUCTUATION_TYPE]; + @Controller('stock') export class StockController { constructor( @@ -54,6 +82,7 @@ export class StockController { private readonly stockDataMonthlyService: StockDataMonthlyService, private readonly stockDataYearlyService: StockDataYearlyService, private readonly stockDetailService: StockDetailService, + private readonly stockRateIndexService: StockRateIndexService, ) {} @HttpCode(200) @@ -91,12 +120,9 @@ export class StockController { @Body() requestBody: UserStockRequest, @GetUser() user: User, ): Promise { - const stock = await this.stockService.createUserStock( - user.id, - requestBody.stockId, - ); + await this.stockService.createUserStock(user.id, requestBody.stockId); return new UserStockResponse( - Number(stock.identifiers[0].id), + requestBody.stockId, '사용자 소유 주식을 추가했습니다.', ); } @@ -119,9 +145,9 @@ export class StockController { @Body() request: UserStockDeleteRequest, @GetUser() user: User, ): Promise { - await this.stockService.deleteUserStock(user.id, request.userStockId); + await this.stockService.deleteUserStock(user.id, request.stockId); return new UserStockResponse( - request.userStockId, + request.stockId, '사용자 소유 주식을 삭제했습니다.', ); } @@ -136,7 +162,7 @@ export class StockController { }) @Get('user/ownership') async checkOwnership( - @Body() body: UserStockRequest, + @Query() body: UserStockRequest, @Req() request: Request, ) { const user = request.user as User; @@ -150,61 +176,82 @@ export class StockController { return new UserStockOwnerResponse(result); } - @Get(':stockId/minutely') - @ApiGetStockData('주식 분 단위 데이터 조회 API', '분') - async getStockDataMinutely( - @Param('stockId') stockId: string, - @Query('lastStartTime') lastStartTime?: string, - ) { - return this.stockDataMinutelyService.getStockDataMinutely( - stockId, - lastStartTime, - ); + @Get('/user') + @ApiOperation({ + summary: '유저 주식 조회 API', + description: '유저 주식을 조회', + }) + @ApiOkResponse({ + description: '유저 주식 조회 성공', + type: UserStocksResponse, + }) + async getUserStocks(@Req() request: Request) { + const user = request.user as User; + return await this.stockService.getUserStocks(user?.id); } - @Get(':stockId/daily') - @ApiGetStockData('주식 일 단위 데이터 조회 API', '일') - async getStockDataDaily( - @Param('stockId') stockId: string, - @Query('lastStartTime') lastStartTime?: string, - ) { - return this.stockDataDailyService.getStockDataDaily(stockId, lastStartTime); + @ApiOperation({ + summary: '주식 검색 API', + description: '주식 이름에 매칭되는 주식을 검색', + }) + @ApiOkResponse({ + description: '검색 완료', + type: StockSearchResponse, + }) + @Get() + async searchStock(@Query() request: StockSearchRequest) { + return await this.stockService.searchStock(request.name); } - @Get(':stockId/weekly') - @ApiGetStockData('주식 주 단위 데이터 조회 API', '주') - async getStockDataWeekly( - @Param('stockId') stockId: string, - @Query('lastStartTime') lastStartTime?: string, - ) { - return this.stockDataWeeklyService.getStockDataWeekly( - stockId, - lastStartTime, - ); + @Get('topViews') + @ApiGetStocks('조회수 기반 주식 리스트 조회 API') + async getTopStocksByViews(@LimitQuery(5) limit: number) { + return await this.stockService.getTopStocksByViews(limit); } - @Get(':stockId/mothly') - @ApiGetStockData('주식 월 단위 데이터 조회 API', '월') - async getStockDataMonthly( - @Param('stockId') stockId: string, - @Query('lastStartTime') lastStartTime?: string, - ) { - return this.stockDataMonthlyService.getStockDataMonthly( - stockId, - lastStartTime, - ); + @Get('topGainers') + @ApiGetStocks('가격 상승률 기반 주식 리스트 조회 API') + async getTopStocksByGainers(@LimitQuery(20) limit: number) { + return await this.stockService.getTopStocksByGainers(limit); } - @Get(':stockId/yearly') - @ApiGetStockData('주식 연 단위 데이터 조회 API', '연') - async getStockDataYearly( - @Param('stockId') stockId: string, - @Query('lastStartTime') lastStartTime?: string, + @Get('topLosers') + @ApiGetStocks('가격 하락률 기반 주식 리스트 조회 API') + async getTopStocksByLosers(@LimitQuery(20) limit: number) { + return await this.stockService.getTopStocksByLosers(limit); + } + + @Get('fluctuation') + @ApiOperation({ + summary: '등가, 등락률 기반 주식 리스트 조회 API', + description: '등가, 등락률 기반 주식 리스트를 조회합니다', + }) + @ApiQuery({ + name: 'limit', + required: false, + description: + '조회할 리스트 수(기본값: 20, 등가, 등락 모두 받으면 모든 데이터 전송)', + }) + @ApiQuery({ + name: 'type', + required: false, + description: '데이터 타입(기본값: increase, all, increase, decrease)', + enum: ['increase', 'decrease', 'all'], + }) + @ApiOkResponse({ + description: '', + type: [StockRankResponses], + }) + async getTopStocksByFluctuation( + @LimitQuery(20) limit: number, + @Query('type') type: FLUCTUATION_TYPE, ) { - return this.stockDataYearlyService.getStockDataYearly( - stockId, - lastStartTime, - ); + if (type === FLUCTUATION_TYPE.DECREASE) { + return await this.stockService.getTopStocksByLosers(limit); + } else if (type === FLUCTUATION_TYPE.ALL) { + return await this.stockService.getTopStocksByFluctuation(); + } + return await this.stockService.getTopStocksByGainers(limit); } @ApiOperation({ @@ -223,21 +270,79 @@ export class StockController { return await this.stockDetailService.getStockDetailByStockId(stockId); } - @Get('topViews') - @ApiGetStocks('조회수 기반 주식 리스트 조회 API') - async getTopStocksByViews(@LimitQuery(5) limit: number) { - return await this.stockService.getTopStocksByViews(limit); + @Get('index') + @ApiOperation({ + summary: '지표(코스피, 코스닥, 환율) API', + description: + '지표(코스피, 코스닥, 환율)의 최고, 최저, 현재, 변동률을 조회합니다.', + }) + @ApiOkResponse({ + description: '지표(코스피, 코스닥, 환율) 조회 성공', + type: [StockIndexRateResponse], + }) + async getIndexData() { + return await this.stockRateIndexService.getStockRateIndexDate(); } - @Get('topGainers') - @ApiGetStocks('가격 상승률 기반 주식 리스트 조회 API') - async getTopStocksByGainers(@LimitQuery(20) limit: number) { - return await this.stockService.getTopStocksByGainers(limit); + @Get('/:stockId') + @ApiGetStockData('주식 시간 단위 데이터 조회 API', '일') + async getStockDataDaily( + @Param('stockId') stockId: string, + @Query('lastStartTime') lastStartTime?: string, + @Query('timeunit') timeunit: TIME_UNIT = TIME_UNIT.MINUTE, + ) { + switch (timeunit) { + case TIME_UNIT.MINUTE: + return this.getMinutelyData(stockId, lastStartTime); + case TIME_UNIT.DAY: + return this.getDailyData(stockId, lastStartTime); + case TIME_UNIT.MONTH: + return this.getStockDataMonthly(stockId, lastStartTime); + case TIME_UNIT.WEEK: + return this.getStockDataWeekly(stockId, lastStartTime); + default: + return this.getStockDataYearly(stockId, lastStartTime); + } } - @Get('topLosers') - @ApiGetStocks('가격 하락률 기반 주식 리스트 조회 API') - async getTopStocksByLosers(@LimitQuery(20) limit: number) { - return await this.stockService.getTopStocksByLosers(limit); + private getStockDataYearly( + stockId: string, + lastStartTime: string | undefined, + ) { + return this.stockDataYearlyService.getStockDataYearly( + stockId, + lastStartTime, + ); + } + + private getStockDataWeekly( + stockId: string, + lastStartTime: string | undefined, + ) { + return this.stockDataWeeklyService.getStockDataWeekly( + stockId, + lastStartTime, + ); + } + + private getStockDataMonthly( + stockId: string, + lastStartTime: string | undefined, + ) { + return this.stockDataMonthlyService.getStockDataMonthly( + stockId, + lastStartTime, + ); + } + + private getMinutelyData(stockId: string, lastStartTime?: string) { + return this.stockDataMinutelyService.getStockDataMinutely( + stockId, + lastStartTime, + ); + } + + private getDailyData(stockId: string, lastStartTime?: string) { + return this.stockDataDailyService.getStockDataDaily(stockId, lastStartTime); } } diff --git a/packages/backend/src/stock/stock.gateway.ts b/packages/backend/src/stock/stock.gateway.ts index cf98907c..2674690d 100644 --- a/packages/backend/src/stock/stock.gateway.ts +++ b/packages/backend/src/stock/stock.gateway.ts @@ -1,28 +1,32 @@ import { + ConnectedSocket, + MessageBody, + SubscribeMessage, WebSocketGateway, WebSocketServer, - SubscribeMessage, - MessageBody, - ConnectedSocket, } from '@nestjs/websockets'; import { Server, Socket } from 'socket.io'; +import { LiveData } from '@/scraper/openapi/liveData.service'; @WebSocketGateway({ - namespace: '/stock/realtime', + namespace: '/api/stock/realtime', }) export class StockGateway { @WebSocketServer() server: Server; - constructor() {} + constructor(private readonly liveData: LiveData) {} @SubscribeMessage('connectStock') - handleConnectStock( + async handleConnectStock( @MessageBody() stockId: string, @ConnectedSocket() client: Socket, ) { client.join(stockId); + if ((await this.server.in(stockId).fetchSockets()).length === 0) { + this.liveData.subscribe(stockId); + } client.emit('connectionSuccess', { message: `Successfully connected to stock room: ${stockId}`, stockId, diff --git a/packages/backend/src/stock/stock.module.ts b/packages/backend/src/stock/stock.module.ts index 13df81b0..3d79014f 100644 --- a/packages/backend/src/stock/stock.module.ts +++ b/packages/backend/src/stock/stock.module.ts @@ -23,6 +23,11 @@ import { } from './stockData.service'; import { StockDetailService } from './stockDetail.service'; import { StockLiveDataSubscriber } from './stockLiveData.subscriber'; +import { StockRateIndexService } from './stockRateIndex.service'; +import { OpenapiLiveData } from '@/scraper/openapi/api/openapiLiveData.api'; +import { OpenapiTokenApi } from '@/scraper/openapi/api/openapiToken.api'; +import { LiveData } from '@/scraper/openapi/liveData.service'; +import { WebsocketClient } from '@/scraper/openapi/websocket/websocketClient.websocket'; @Module({ imports: [ @@ -40,6 +45,10 @@ import { StockLiveDataSubscriber } from './stockLiveData.subscriber'; controllers: [StockController], providers: [ StockService, + WebsocketClient, + OpenapiTokenApi, + OpenapiLiveData, + LiveData, StockGateway, StockLiveDataSubscriber, StockDataService, @@ -49,6 +58,7 @@ import { StockLiveDataSubscriber } from './stockLiveData.subscriber'; StockDataYearlyService, StockDataMonthlyService, StockDetailService, + StockRateIndexService, ], exports: [StockService], }) diff --git a/packages/backend/src/stock/stock.service.spec.ts b/packages/backend/src/stock/stock.service.spec.ts index 2675373e..36e4d77e 100644 --- a/packages/backend/src/stock/stock.service.spec.ts +++ b/packages/backend/src/stock/stock.service.spec.ts @@ -15,7 +15,6 @@ const logger: Logger = { describe('StockService 테스트', () => { const stockId = 'A005930'; const userId = 1; - const userStockId = 1; test('주식의 조회수를 증가시킨다.', async () => { const managerMock = { @@ -91,7 +90,7 @@ describe('StockService 테스트', () => { const dataSource = createDataSourceMock(managerMock); const stockService = new StockService(dataSource as DataSource, logger); - await stockService.deleteUserStock(userId, userStockId); + await stockService.deleteUserStock(userId, stockId); expect(managerMock.findOne).toHaveBeenCalled(); expect(managerMock.delete).toHaveBeenCalled(); @@ -104,7 +103,7 @@ describe('StockService 테스트', () => { const dataSource = createDataSourceMock(managerMock); const stockService = new StockService(dataSource as DataSource, logger); - await expect(() => stockService.deleteUserStock(userId, 2)).rejects.toThrow( + await expect(() => stockService.deleteUserStock(userId, "13")).rejects.toThrow( 'user stock not found', ); }); @@ -118,7 +117,7 @@ describe('StockService 테스트', () => { const stockService = new StockService(dataSource as DataSource, logger); await expect(() => - stockService.deleteUserStock(notOwnerUserId, userStockId), + stockService.deleteUserStock(notOwnerUserId, stockId), ).rejects.toThrow('you are not owner of user stock'); }); diff --git a/packages/backend/src/stock/stock.service.ts b/packages/backend/src/stock/stock.service.ts index 1f23e48f..6f089af2 100644 --- a/packages/backend/src/stock/stock.service.ts +++ b/packages/backend/src/stock/stock.service.ts @@ -3,8 +3,13 @@ import { plainToInstance } from 'class-transformer'; import { DataSource, EntityManager } from 'typeorm'; import { Logger } from 'winston'; import { Stock } from './domain/stock.entity'; -import { StocksResponse } from './dto/stock.Response'; +import { + StockRankResponses, + StockSearchResponse, + StocksResponse, +} from './dto/stock.response'; import { UserStock } from '@/stock/domain/userStock.entity'; +import { UserStocksResponse } from '@/stock/dto/userStock.response'; @Injectable() export class StockService { @@ -49,25 +54,51 @@ export class StockService { }); } + async getUserStocks(userId?: number) { + if (!userId) { + return new UserStocksResponse([]); + } + const result = await this.datasource.manager.find(UserStock, { + where: { user: { id: userId } }, + relations: ['stock'], + }); + return new UserStocksResponse(result); + } + async checkStockExist(stockId: string) { return await this.datasource.manager.exists(Stock, { where: { id: stockId }, }); } - async deleteUserStock(userId: number, userStockId: number) { + async deleteUserStock(userId: number, stockId: string) { await this.datasource.transaction(async (manager) => { const userStock = await manager.findOne(UserStock, { - where: { id: userStockId }, + where: { user: { id: userId }, stock: { id: stockId } }, relations: ['user'], }); this.validateUserStock(userId, userStock); - await manager.delete(UserStock, { - id: userStockId, - }); + if (userStock) { + await manager.delete(UserStock, { + id: userStock.id, + }); + } }); } + async searchStock(stockName: string) { + const result = await this.datasource + .getRepository(Stock) + .createQueryBuilder('stock') + .where('stock.is_trading = :isTrading and stock.stock_name LIKE :name', { + isTrading: true, + name: `%${stockName}%`, + }) + .limit(10) + .getMany(); + return new StockSearchResponse(result); + } + validateUserStock(userId: number, userStock: UserStock | null) { if (!userStock) { throw new BadRequestException('user stock not found'); @@ -80,6 +111,39 @@ export class StockService { } } + async getTopStocksByViews(limit: number) { + const rawData = await this.getStocksQuery() + .orderBy('stock.views', 'DESC') + .limit(limit) + .getRawMany(); + + return plainToInstance(StocksResponse, rawData); + } + + async getTopStocksByGainers(limit: number) { + const rawData = await this.getStockRankQuery(true) + .orderBy('rank.rank', 'ASC') + .limit(limit) + .getRawMany(); + + return new StockRankResponses(rawData); + } + + async getTopStocksByLosers(limit: number) { + const rawData = await this.getStockRankQuery(false) + .orderBy('rank.rank', 'ASC') + .limit(limit) + .getRawMany(); + return new StockRankResponses(rawData); + } + + async getTopStocksByFluctuation() { + const data = await this.getStocksQuery() + .innerJoinAndSelect('stock.fluctuationRankStocks', 'rank') + .getRawMany(); + return new StockRankResponses(data); + } + private async validateStockExists(stockId: string, manager: EntityManager) { if (!(await this.existsStock(stockId, manager))) { throw new BadRequestException('not exists stock'); @@ -113,7 +177,7 @@ export class StockService { return await manager.exists(Stock, { where: { id: stockId } }); } - private StocksQuery() { + private getStocksQuery() { return this.datasource .getRepository(Stock) .createQueryBuilder('stock') @@ -137,30 +201,9 @@ export class StockService { ]); } - async getTopStocksByViews(limit: number) { - const rawData = await this.StocksQuery() - .orderBy('stock.views', 'DESC') - .limit(limit) - .getRawMany(); - - return plainToInstance(StocksResponse, rawData); - } - - async getTopStocksByGainers(limit: number) { - const rawData = await this.StocksQuery() - .orderBy('stockLiveData.changeRate', 'DESC') - .limit(limit) - .getRawMany(); - - return plainToInstance(StocksResponse, rawData); - } - - async getTopStocksByLosers(limit: number) { - const rawData = await this.StocksQuery() - .orderBy('stockLiveData.changeRate', 'ASC') - .limit(limit) - .getRawMany(); - - return plainToInstance(StocksResponse, rawData); + private getStockRankQuery(isRising: boolean) { + return this.getStocksQuery() + .innerJoinAndSelect('stock.fluctuationRankStocks', 'rank') + .where('rank.isRising = :isRising', { isRising }); } } diff --git a/packages/backend/src/stock/stockDetail.service.ts b/packages/backend/src/stock/stockDetail.service.ts index 412a4745..828a40ee 100644 --- a/packages/backend/src/stock/stockDetail.service.ts +++ b/packages/backend/src/stock/stockDetail.service.ts @@ -1,5 +1,4 @@ import { Injectable, Inject, NotFoundException } from '@nestjs/common'; -import { plainToInstance } from 'class-transformer'; import { DataSource } from 'typeorm'; import { Logger } from 'winston'; import { StockDetail } from './domain/stockDetail.entity'; @@ -25,11 +24,20 @@ export class StockDetailService { ); } - const stockDetail = await manager.findBy(StockDetail, { - stock: { id: stockId }, - }); + const result = await this.datasource.manager + .getRepository(StockDetail) + .createQueryBuilder('stockDetail') + .leftJoinAndSelect('stockDetail.stock', 'stock') + .where('stockDetail.stock_id = :stockId', { stockId }) + .getOne(); + + if (!result) { + throw new NotFoundException( + `stock detail not found (stockId: ${stockId}`, + ); + } - return plainToInstance(StockDetailResponse, stockDetail[0]); + return new StockDetailResponse(result); }); } } diff --git a/packages/backend/src/stock/stockRateIndex.service.spec.ts b/packages/backend/src/stock/stockRateIndex.service.spec.ts new file mode 100644 index 00000000..7b20dbcb --- /dev/null +++ b/packages/backend/src/stock/stockRateIndex.service.spec.ts @@ -0,0 +1,70 @@ +/* eslint-disable max-lines-per-function */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { NotFoundException } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { Logger } from 'winston'; +import { Stock } from './domain/stock.entity'; +import { StockLiveData } from './domain/stockLiveData.entity'; +import { StockIndexRateResponse } from './dto/stockIndexRate.response'; +import { StockRateIndexService } from './stockRateIndex.service'; + +describe('StockRateIndexService', () => { + let stockRateIndexService: StockRateIndexService; + let dataSource: Partial; + const logger: Logger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + } as unknown as Logger; + + const createDataSourceMock = (managerMock: any): Partial => { + return { + manager: managerMock, + }; + }; + + beforeEach(() => { + const mockStockLiveData: Partial = { + volume: 1000, + high: 1200, + low: 800, + open: 1000, + stock: { id: 'KRW', name: 'KRW', groupCode: 'IDX' } as Stock, + }; + + const managerMock = { + find: jest.fn().mockResolvedValue([mockStockLiveData]), + }; + + dataSource = createDataSourceMock(managerMock); + stockRateIndexService = new StockRateIndexService( + dataSource as DataSource, + logger as Logger, + ); + }); + + it('존재하면 데이터를 리턴한다', async () => { + const response = await stockRateIndexService.getStockRateIndexDate(); + + expect(response).toBeInstanceOf(Array); + expect(response[0]).toBeInstanceOf(StockIndexRateResponse); + expect(response[0].name).toBe('KRW'); + expect(response[0].volume).toBe(1000); + }); + + it('존재하지 않으면 에러를 발생시킨다', async () => { + const managerMock = { + find: jest.fn().mockResolvedValue([]), + }; + + dataSource = createDataSourceMock(managerMock); + stockRateIndexService = new StockRateIndexService( + dataSource as DataSource, + logger as Logger, + ); + + await expect(stockRateIndexService.getStockRateIndexDate()).rejects.toThrow( + NotFoundException, + ); + }); +}); diff --git a/packages/backend/src/stock/stockRateIndex.service.ts b/packages/backend/src/stock/stockRateIndex.service.ts new file mode 100644 index 00000000..cbde49cb --- /dev/null +++ b/packages/backend/src/stock/stockRateIndex.service.ts @@ -0,0 +1,43 @@ +import { Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { Logger } from 'winston'; +import { StockLiveData } from './domain/stockLiveData.entity'; +import { StockIndexRateResponse } from './dto/stockIndexRate.response'; +import { IndexRateGroupCode } from '@/scraper/openapi/type/openapiIndex.type'; + +@Injectable() +export class StockRateIndexService { + constructor( + private readonly datasource: DataSource, + @Inject('winston') private readonly logger: Logger, + ) {} + + async getRateIndexData(groupCode: IndexRateGroupCode) { + const result = await this.datasource.manager.find(StockLiveData, { + where: { stock: { groupCode } }, + relations: ['stock'], + }); + + if (!result.length) { + this.logger.warn(`Rate data not found for group code: ${groupCode}`); + throw new NotFoundException('Rate data not found'); + } + return result; + } + + async getStockRateData() { + const groupCode: IndexRateGroupCode = 'RATE'; + const result = await this.getRateIndexData(groupCode); + return result.map((val) => new StockIndexRateResponse(val)); + } + async getStockIndexData() { + const groupCode: IndexRateGroupCode = 'INX'; + const result = await this.getRateIndexData(groupCode); + return result.map((val) => new StockIndexRateResponse(val)); + } + async getStockRateIndexDate(): Promise { + const index = await this.getStockIndexData(); + const rate = await this.getStockRateData(); + return [...index, ...rate]; + } +} diff --git a/packages/backend/src/user/constants/randomNickname.ts b/packages/backend/src/user/constants/randomNickname.ts new file mode 100644 index 00000000..fb55e86e --- /dev/null +++ b/packages/backend/src/user/constants/randomNickname.ts @@ -0,0 +1,16 @@ +export const status = [ + '신중한', + '과감한', + '공부하는', + '성장하는', + '주춤거리는', +]; +export const subject = [ + '병아리', + '햄스터', + '다람쥐', + '거북이', + '판다', + '주린이', + '투자자', +]; diff --git a/packages/backend/src/user/domain/user.entity.ts b/packages/backend/src/user/domain/user.entity.ts index 26cdb345..91a02c0b 100644 --- a/packages/backend/src/user/domain/user.entity.ts +++ b/packages/backend/src/user/domain/user.entity.ts @@ -5,11 +5,13 @@ import { OneToMany, PrimaryGeneratedColumn, } from 'typeorm'; +import { Mention } from '@/chat/domain/mention.entity'; import { DateEmbedded } from '@/common/dateEmbedded.entity'; import { UserStock } from '@/stock/domain/userStock.entity'; import { OauthType } from '@/user/domain/ouathType'; import { Role } from '@/user/domain/role'; +@Index('nickname_sub_name', ['nickname', 'subName'], { unique: true }) @Index('type_oauth_id', ['type', 'oauthId'], { unique: true }) @Entity({ name: 'users' }) export class User { @@ -19,6 +21,9 @@ export class User { @Column({ length: 50 }) nickname: string; + @Column({ length: 10, default: '0001' }) + subName: string; + @Column({ length: 50 }) email: string; @@ -39,4 +44,7 @@ export class User { @OneToMany(() => UserStock, (userStock) => userStock.user) userStocks: UserStock[]; + + @OneToMany(() => Mention, (mention) => mention.user) + mentions: Mention[]; } diff --git a/packages/backend/src/user/dto/user.request.ts b/packages/backend/src/user/dto/user.request.ts new file mode 100644 index 00000000..fde10d87 --- /dev/null +++ b/packages/backend/src/user/dto/user.request.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; + +export class ChangeNicknameRequest { + @ApiProperty({ + description: '변경할 닉네임', + example: '9만전자 개미', + }) + @IsString() + @IsNotEmpty() + nickname: string; +} diff --git a/packages/backend/src/user/dto/user.response.ts b/packages/backend/src/user/dto/user.response.ts new file mode 100644 index 00000000..f0f1e20a --- /dev/null +++ b/packages/backend/src/user/dto/user.response.ts @@ -0,0 +1,74 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { User } from '@/user/domain/user.entity'; +import { OauthType } from '@/user/domain/ouathType'; + +interface UserResponse { + id: number; + nickname: string; + subName: string; + createdAt: Date; +} + +export class UserSearchResult { + @ApiProperty({ + description: '유저 검색 결과', + example: [ + { + id: 1, + nickname: 'nickname', + subName: 'subName', + createdAt: new Date(), + }, + ], + }) + result: UserResponse[]; + + constructor(users: User[]) { + this.result = users.map((user) => ({ + id: user.id, + nickname: user.nickname, + subName: user.subName, + createdAt: user.date.createdAt, + })); + } +} + +export class UserInformationResponse { + @ApiProperty({ + description: '유저 닉네임', + example: 'nickname', + }) + nickname: string; + + @ApiProperty({ + description: '유저 서브 닉네임', + example: 'subName', + }) + subName: string; + + @ApiProperty({ + description: '유저 생성일', + example: new Date(), + }) + createdAt: Date; + + @ApiProperty({ + description: '유저 이메일', + example: 'test@nav.com', + }) + email: string; + + @ApiProperty({ + description: '유저타입 (google: 구글 로그인, local: 테스터)', + example: new Date(), + }) + type: OauthType; + + constructor(user: User) { + this.nickname = user.nickname; + this.subName = user.subName; + this.createdAt = user.date.createdAt; + this.email = user.email; + this.type = user.type; + } +} diff --git a/packages/backend/src/user/user.controller.ts b/packages/backend/src/user/user.controller.ts index 748ae2b7..6abc5b56 100644 --- a/packages/backend/src/user/user.controller.ts +++ b/packages/backend/src/user/user.controller.ts @@ -1,20 +1,84 @@ import { - Controller, - Patch, - Param, Body, + Controller, + ForbiddenException, + Get, HttpCode, HttpStatus, - Get, + Param, + Patch, + Post, + Query, + Req, } from '@nestjs/common'; -import { ApiBody, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; +import { + ApiBody, + ApiOkResponse, + ApiOperation, + ApiParam, + ApiResponse, +} from '@nestjs/swagger'; import { UpdateUserThemeResponse } from './dto/userTheme.response'; import { UserService } from './user.service'; +import { Request } from 'express'; +import { User } from '@/user/domain/user.entity'; +import { ChangeNicknameRequest } from '@/user/dto/user.request'; @Controller('user') export class UserController { constructor(private readonly userService: UserService) {} + @Get() + @ApiOperation({ + summary: '유저 닉네임과 서브 닉네임으로 유저 조회 API', + description: '유저 닉네임과 서브 닉네임으로 유저를 조회합니다.', + }) + @ApiParam({ name: 'nickname', type: 'string', description: '유저 닉네임' }) + @ApiParam({ name: 'subName', type: 'string', description: '유저 서브네임' }) + async searchUser( + @Query('nickname') nickname: string, + @Query('subName') subName: string, + ) { + return await this.userService.searchUserByNicknameAndSubName( + nickname, + subName, + ); + } + + @Get('info') + @ApiOperation({ + summary: '유저 정보를 조회한다.', + description: '유저 정보를 조회한다.', + }) + async getUserInfo(@Req() request: Request) { + if (!request.user) { + throw new ForbiddenException('Forbidden access to user info'); + } + const user = request.user as User; + return await this.userService.getUserInfo(user.id); + } + + @Post('info') + @ApiOperation({ + summary: '유저 닉네임을 변경한다.', + description: '유저 닉네임을 변경한다.', + }) + @ApiOkResponse({ + description: '닉네임 변경 완료', + example: { message: '닉네임 변경 완료', date: new Date() }, + }) + async updateNickname( + @Req() request: Request, + @Body() body: ChangeNicknameRequest, + ) { + if (!request.user) { + throw new ForbiddenException('Forbidden access to change nickname'); + } + const user = request.user as User; + await this.userService.updateNickname(user.id, body.nickname); + return { message: '닉네임 변경 완료', date: new Date() }; + } + @Patch(':id/theme') @HttpCode(HttpStatus.OK) @ApiOperation({ diff --git a/packages/backend/src/user/user.service.spec.ts b/packages/backend/src/user/user.service.spec.ts index a0a3f19d..e4907618 100644 --- a/packages/backend/src/user/user.service.spec.ts +++ b/packages/backend/src/user/user.service.spec.ts @@ -5,23 +5,31 @@ import { User } from './domain/user.entity'; import { OauthType } from '@/user/domain/ouathType'; import { UserService } from '@/user/user.service'; +const defaultManagerMock: Partial = { + findOne: jest.fn(), + save: jest.fn(), + exists: jest.fn(), +}; + export function createDataSourceMock( managerMock?: Partial, ): Partial { - const defaultManagerMock: Partial = { - findOne: jest.fn(), - save: jest.fn(), - exists: jest.fn(), - }; - return { - getRepository: managerMock.getRepository, + getRepository: managerMock?.getRepository, transaction: jest.fn().mockImplementation(async (work) => { return work({ ...defaultManagerMock, ...managerMock }); }), }; } +export function createManagerDataSourceMock( + managerMock?: Partial, +) { + return { + manager: managerMock, + }; +} + describe('UserService 테스트', () => { const registerRequest = { email: 'test@naver.com', @@ -58,6 +66,45 @@ describe('UserService 테스트', () => { ).rejects.toThrow('user already exists'); }); + test('같은 닉네임이 없을 때 기본 서브 닉네임을 생성한다.', async () => { + const managerMock = { + exists: jest.fn().mockResolvedValueOnce(false), + save: jest.fn().mockResolvedValue(registerRequest), + }; + const dataSource = createDataSourceMock(managerMock); + const userService = new UserService(dataSource as DataSource); + + const subName = await userService.createSubName('test'); + + expect(subName).toBe('0001'); + }); + + test.each([ + ['0001', '0002'], + ['0009', '0010'], + ['0099', '0100'], + ['0999', '1000'], + ])( + '같은 닉네임이 있을 때 현제 서브네임 최대 값에서 1을 더한 값이 생성', + async (maxSubName, newSubName) => { + const managerMock = { + exists: jest.fn().mockResolvedValue(true), + save: jest.fn().mockResolvedValue(registerRequest), + createQueryBuilder: jest.fn().mockReturnValue({ + select: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + getRawOne: jest.fn().mockResolvedValue({ max: maxSubName }), + }), + }; + const dataSource = createDataSourceMock(managerMock); + const userService = new UserService(dataSource as DataSource); + + const subName = await userService.createSubName('test'); + + expect(subName).toBe(newSubName); + }, + ); + test('유저 테마를 업데이트한다', async () => { const userId = 1; const isLight = false; @@ -117,7 +164,7 @@ describe('UserService 테스트', () => { const managerMock = { findOne: jest.fn().mockResolvedValue(mockUser), }; - const dataSource = createDataSourceMock(managerMock); + const dataSource = createManagerDataSourceMock(managerMock); const userService = new UserService(dataSource as DataSource); const result = await userService.getUserTheme(userId); @@ -135,7 +182,7 @@ describe('UserService 테스트', () => { const managerMock = { findOne: jest.fn().mockResolvedValue(null), }; - const dataSource = createDataSourceMock(managerMock); + const dataSource = createManagerDataSourceMock(managerMock); const userService = new UserService(dataSource as DataSource); await expect(userService.getUserTheme(userId)).rejects.toThrow( diff --git a/packages/backend/src/user/user.service.ts b/packages/backend/src/user/user.service.ts index c1eebd52..cc62193b 100644 --- a/packages/backend/src/user/user.service.ts +++ b/packages/backend/src/user/user.service.ts @@ -3,9 +3,14 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import { DataSource, EntityManager } from 'typeorm'; +import { DataSource, EntityManager, Like } from 'typeorm'; import { OauthType } from './domain/ouathType'; import { User } from './domain/user.entity'; +import { status, subject } from '@/user/constants/randomNickname'; +import { + UserInformationResponse, + UserSearchResult, +} from '@/user/dto/user.response'; type RegisterRequest = Required< Pick @@ -18,29 +23,80 @@ export class UserService { async register({ nickname, email, type, oauthId }: RegisterRequest) { return await this.dataSource.transaction(async (manager) => { await this.validateUserExists(type, oauthId, manager); + const subName = await this.createSubName(nickname); return await manager.save(User, { nickname, email, type, oauthId, + subName, }); }); } - async findUserByOauthIdAndType(oauthId: string, type: OauthType) { - return await this.dataSource.manager.findOne(User, { - where: { oauthId, type }, + async searchUserByNicknameAndSubName(nickname: string, subName?: string) { + const users = await this.dataSource.manager.find(User, { + where: { nickname: Like(`%${nickname}%`), subName: Like(`${subName}%`) }, + take: 10, }); + return new UserSearchResult(users); } - private async validateUserExists( - type: OauthType, - oauthId: string, - manager: EntityManager, - ) { - if (await manager.exists(User, { where: { oauthId, type } })) { - throw new BadRequestException('user already exists'); + async createSubName(nickname: string) { + return this.dataSource.transaction(async (manager) => { + if (!(await this.existsUserByNickname(nickname, manager))) { + return '0001'; + } + + const maxSubName = await manager + .createQueryBuilder(User, 'user') + .select('MAX(user.subName)', 'max') + .where('user.nickname = :nickname', { nickname }) + .getRawOne(); + + return (parseInt(maxSubName.max, 10) + 1).toString().padStart(4, '0'); + }); + } + + async getUserInfo(id: number) { + const user = await this.dataSource.manager.findOne(User, { where: { id } }); + if (!user) { + throw new BadRequestException('User not found'); } + return new UserInformationResponse(user); + } + + existsUserByNickname(nickname: string, manager: EntityManager) { + return manager.exists(User, { where: { nickname } }); + } + + async updateNickname(userId: number, nickname: string) { + const user = await this.dataSource.manager.findOne(User, { + where: { id: userId }, + }); + if (!user) { + throw new BadRequestException('User not found'); + } else if (user.nickname === nickname) { + throw new BadRequestException('Same nickname'); + } + user.nickname = nickname; + user.subName = await this.createSubName(nickname); + return await this.dataSource.manager.save(user); + } + + async registerTester() { + return this.register({ + nickname: this.generateRandomNickname(), + email: 'tester@nav', + type: OauthType.LOCAL, + oauthId: String((await this.getMaxOauthId(OauthType.LOCAL)) + 1), + }); + } + + async findUserByOauthIdAndType(oauthId: string, type: OauthType) { + return await this.dataSource.manager.findOne(User, { + where: { oauthId, type }, + }); } async updateUserTheme(userId: number, isLight?: boolean): Promise { @@ -72,4 +128,30 @@ export class UserService { return user.isLight; } + + private generateRandomNickname() { + const statusName = status[Math.floor(Math.random() * status.length)]; + const subjectName = subject[Math.floor(Math.random() * subject.length)]; + return `${statusName}${subjectName}`; + } + + private async getMaxOauthId(oauthType: OauthType) { + const result = await this.dataSource.manager + .createQueryBuilder(User, 'user') + .select('MAX(user.oauthId)', 'max') + .where('user.type = :oauthType', { oauthType }) + .getRawOne(); + + return result ? Number(result.max) : 1; + } + + private async validateUserExists( + type: OauthType, + oauthId: string, + manager: EntityManager, + ) { + if (await manager.exists(User, { where: { oauthId, type } })) { + throw new BadRequestException('user already exists'); + } + } } diff --git a/packages/backend/tsconfig.json b/packages/backend/tsconfig.json index f01a23e5..2eb3e242 100644 --- a/packages/backend/tsconfig.json +++ b/packages/backend/tsconfig.json @@ -8,5 +8,9 @@ }, "strictPropertyInitialization": false }, - "include": ["src/**/*", "test/**/*", "src/scraper/openapi/type/openapiPeriodData.ts"] + "include": [ + "src/**/*", + "test/**/*", + "src/scraper/openapi/type/openapiPeriodData.type.ts" + ] } diff --git a/test.js b/test.js deleted file mode 100644 index ec521fac..00000000 --- a/test.js +++ /dev/null @@ -1,5 +0,0 @@ -const getTodayDate = () => { - const today = new Date(); - return today.toISOString().split('T')[0].replace(/-/g, ''); -}; -console.log(getTodayDate()); diff --git a/yarn.lock b/yarn.lock index 10d4e991..5d71942f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1199,11 +1199,6 @@ resolved "https://registry.yarnpkg.com/@lukeed/csprng/-/csprng-1.1.0.tgz#1e3e4bd05c1cc7a0b2ddbd8a03f39f6e4b5e6cfe" integrity sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA== -"@microsoft/tsdoc@^0.15.0": - version "0.15.0" - resolved "https://registry.yarnpkg.com/@microsoft/tsdoc/-/tsdoc-0.15.0.tgz#f29a55df17cb6e87cfbabce33ff6a14a9f85076d" - integrity sha512-HZpPoABogPvjeJOdzCOSJsXeL/SMCBgBZMVC3X3d7YYp2gf31MfxhUoYUNwf1ERPJOnQc0wkFn9trqI6ZEdZuA== - "@mdx-js/react@^3.0.0": version "3.1.0" resolved "https://registry.yarnpkg.com/@mdx-js/react/-/react-3.1.0.tgz#c4522e335b3897b9a845db1dbdd2f966ae8fb0ed" @@ -1211,6 +1206,11 @@ dependencies: "@types/mdx" "^2.0.0" +"@microsoft/tsdoc@^0.15.0": + version "0.15.0" + resolved "https://registry.yarnpkg.com/@microsoft/tsdoc/-/tsdoc-0.15.0.tgz#f29a55df17cb6e87cfbabce33ff6a14a9f85076d" + integrity sha512-HZpPoABogPvjeJOdzCOSJsXeL/SMCBgBZMVC3X3d7YYp2gf31MfxhUoYUNwf1ERPJOnQc0wkFn9trqI6ZEdZuA== + "@nestjs/cli@^10.0.0": version "10.4.5" resolved "https://registry.yarnpkg.com/@nestjs/cli/-/cli-10.4.5.tgz#d6563b87e8ca1d0f256c19a7847dbcc96c76a88e" @@ -1287,10 +1287,10 @@ multer "1.4.4-lts.1" tslib "2.7.0" -"@nestjs/platform-socket.io@^10.4.7": - version "10.4.7" - resolved "https://registry.yarnpkg.com/@nestjs/platform-socket.io/-/platform-socket.io-10.4.7.tgz#0c22c204e72aba83dff5b517dcd4802fc0f17594" - integrity sha512-CpmrqswpD/O4SyF/IUzKj14BUf0eTLyDja9svPCRIJX8AdF47mKCMbz5vtU6vpJtxVnq1e1Xd+xcdZ6FIf6HtQ== +"@nestjs/platform-socket.io@^10.4.8": + version "10.4.8" + resolved "https://registry.yarnpkg.com/@nestjs/platform-socket.io/-/platform-socket.io-10.4.8.tgz#cf483794f3b1831d804a3ac3a3f7b999664489d4" + integrity sha512-KzCL+P037HiaW3iODueJ/vw5a8bSr6uIturgGSuRz6c8WR+SfqKC6jNXw0JTW5NVqEqX8tOunEVXoI3MFnWz/w== dependencies: socket.io "4.8.0" tslib "2.7.0" @@ -1340,10 +1340,10 @@ dependencies: uuid "9.0.1" -"@nestjs/websockets@^10.4.7": - version "10.4.7" - resolved "https://registry.yarnpkg.com/@nestjs/websockets/-/websockets-10.4.7.tgz#20d4da5e38a1f1dff866f780e694c907eaa23b8f" - integrity sha512-ajuoptYLYm+l3+KtaA9Ed+cO9yB34PtBE8UObavRT8Euh/f7QfeJiKcrU3+BQSAiTWM3nF2qfuV4CfEkP9uKuw== +"@nestjs/websockets@^10.4.8": + version "10.4.8" + resolved "https://registry.yarnpkg.com/@nestjs/websockets/-/websockets-10.4.8.tgz#9c2b982059e850a56999f56c87ac3a88acbce4ea" + integrity sha512-IpObWsZvjjUxmBuIF/AkcyXrFFzwNYNsw2reZXHy7C31wJsYAjwr6rHMSRGyqsxfqTA2DqjCczorewM6BAEXig== dependencies: iterare "1.2.1" object-hash "3.0.0" @@ -2000,7 +2000,6 @@ dependencies: "@types/node" "*" -"@types/estree@1.0.6", "@types/estree@^1.0.5", "@types/estree@^1.0.6": "@types/doctrine@^0.0.9": version "0.0.9" resolved "https://registry.yarnpkg.com/@types/doctrine/-/doctrine-0.0.9.tgz#d86a5f452a15e3e3113b99e39616a9baa0f9863f" @@ -2157,6 +2156,15 @@ "@types/passport" "*" "@types/passport-oauth2" "*" +"@types/passport-local@^1.0.38": + version "1.0.38" + resolved "https://registry.yarnpkg.com/@types/passport-local/-/passport-local-1.0.38.tgz#8073758188645dde3515808999b1c218a6fe7141" + integrity sha512-nsrW4A963lYE7lNTv9cr5WmiUD1ibYJvWrpE13oxApFsRt77b0RdtZvKbCdNIY4v/QZ6TRQWaDDEwV1kCTmcXg== + dependencies: + "@types/express" "*" + "@types/passport" "*" + "@types/passport-strategy" "*" + "@types/passport-oauth2@*": version "1.4.17" resolved "https://registry.yarnpkg.com/@types/passport-oauth2/-/passport-oauth2-1.4.17.tgz#d5d54339d44f6883d03e69dc0cc0e2114067abb4" @@ -2166,6 +2174,14 @@ "@types/oauth" "*" "@types/passport" "*" +"@types/passport-strategy@*": + version "0.2.38" + resolved "https://registry.yarnpkg.com/@types/passport-strategy/-/passport-strategy-0.2.38.tgz#482abba0b165cd4553ec8b748f30b022bd6c04d3" + integrity sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA== + dependencies: + "@types/express" "*" + "@types/passport" "*" + "@types/passport@*": version "1.0.17" resolved "https://registry.yarnpkg.com/@types/passport/-/passport-1.0.17.tgz#718a8d1f7000ebcf6bbc0853da1bc8c4bc7ea5e6" @@ -2260,15 +2276,22 @@ dependencies: "@types/node" "*" +"@types/uuid@^9.0.1": + version "9.0.8" + resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-9.0.8.tgz#7545ba4fc3c003d6c756f651f3bf163d8f0f29ba" + integrity sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA== + "@types/validator@^13.11.8": version "13.12.2" resolved "https://registry.yarnpkg.com/@types/validator/-/validator-13.12.2.tgz#760329e756e18a4aab82fc502b51ebdfebbe49f5" integrity sha512-6SlHBzUW8Jhf3liqrGGXyTJSIFe4nqlJ5A5KaMZ2l/vbM3Wh3KSybots/wfWVzNLK4D1NZluDlSQIbIEPx6oyA== -"@types/uuid@^9.0.1": - version "9.0.8" - resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-9.0.8.tgz#7545ba4fc3c003d6c756f651f3bf163d8f0f29ba" - integrity sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA== +"@types/ws@^8.5.13": + version "8.5.13" + resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.13.tgz#6414c280875e2691d0d1e080b05addbf5cb91e20" + integrity sha512-osM/gWBTPKgHV8XkTunnegTRIsvF6owmf5w+JtAfOw472dptdm0dlGv4xCt6GwQRcC2XVOvvRE/0bAoQcL2QkA== + dependencies: + "@types/node" "*" "@types/yargs-parser@*": version "21.0.3" @@ -3022,13 +3045,6 @@ base64-js@^1.3.1: resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== -better-opn@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/better-opn/-/better-opn-3.0.2.tgz#f96f35deaaf8f34144a4102651babcf00d1d8817" - integrity sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ== - dependencies: - open "^8.0.4" - base64id@2.0.0, base64id@~2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/base64id/-/base64id-2.0.0.tgz#2770ac6bc47d312af97a8bf9a634342e0cd25cb6" @@ -3039,6 +3055,13 @@ base64url@3.x.x: resolved "https://registry.yarnpkg.com/base64url/-/base64url-3.0.1.tgz#6399d572e2bc3f90a9a8b22d5dbb0a32d33f788d" integrity sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A== +better-opn@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/better-opn/-/better-opn-3.0.2.tgz#f96f35deaaf8f34144a4102651babcf00d1d8817" + integrity sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ== + dependencies: + open "^8.0.4" + binary-extensions@^2.0.0: version "2.3.0" resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.3.0.tgz#f6e14a97858d327252200242d4ccfe522c445522" @@ -6151,13 +6174,6 @@ js-yaml@^3.13.1: argparse "^1.0.7" esprima "^4.0.0" -js-yaml@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" - integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== - dependencies: - argparse "^2.0.1" - jsdoc-type-pratt-parser@^4.0.0: version "4.1.0" resolved "https://registry.yarnpkg.com/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-4.1.0.tgz#ff6b4a3f339c34a6c188cbf50a16087858d22113" @@ -6734,6 +6750,13 @@ neo-async@^2.6.2: resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== +nest-winston@^1.9.7: + version "1.9.7" + resolved "https://registry.yarnpkg.com/nest-winston/-/nest-winston-1.9.7.tgz#1ef6eb2459ce595655de37d5beb900d2e75b61d3" + integrity sha512-pTTgImRgv7urojsDvaTlenAjyJNLj7ywamfjzrhWKhLhp80AKLYNwf103dVHeqZWe+nzp/vd9DGRs/UN/YadOQ== + dependencies: + fast-safe-stringify "^2.1.1" + no-case@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/no-case/-/no-case-3.0.4.tgz#d361fd5c9800f558551a8369fc0dcd4662b6124d" @@ -6742,13 +6765,6 @@ no-case@^3.0.4: lower-case "^2.0.2" tslib "^2.0.3" -nest-winston@^1.9.7: - version "1.9.7" - resolved "https://registry.yarnpkg.com/nest-winston/-/nest-winston-1.9.7.tgz#1ef6eb2459ce595655de37d5beb900d2e75b61d3" - integrity sha512-pTTgImRgv7urojsDvaTlenAjyJNLj7ywamfjzrhWKhLhp80AKLYNwf103dVHeqZWe+nzp/vd9DGRs/UN/YadOQ== - dependencies: - fast-safe-stringify "^2.1.1" - node-abort-controller@^3.0.1: version "3.1.1" resolved "https://registry.yarnpkg.com/node-abort-controller/-/node-abort-controller-3.1.1.tgz#a94377e964a9a37ac3976d848cb5c765833b8548" @@ -7047,6 +7063,13 @@ passport-google-oauth20@^2.0.0: dependencies: passport-oauth2 "1.x.x" +passport-local@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/passport-local/-/passport-local-1.0.0.tgz#1fe63268c92e75606626437e3b906662c15ba6ee" + integrity sha512-9wCE6qKznvf9mQYYbgJ3sVOHmCWoUNMVFoZzNoznmISbhnNNPhN9xfY3sLmScHMetEJeoY7CXwfhCe7argfQow== + dependencies: + passport-strategy "1.x.x" + passport-oauth2@1.x.x: version "1.8.0" resolved "https://registry.yarnpkg.com/passport-oauth2/-/passport-oauth2-1.8.0.tgz#55725771d160f09bbb191828d5e3d559eee079c8" @@ -7485,7 +7508,6 @@ redent@^3.0.0: indent-string "^4.0.0" strip-indent "^3.0.0" -reflect-metadata@^0.2.0: reflect-metadata@^0.2.0, reflect-metadata@^0.2.1: version "0.2.2" resolved "https://registry.yarnpkg.com/reflect-metadata/-/reflect-metadata-0.2.2.tgz#400c845b6cba87a21f2c65c4aeb158f4fa4d9c5b" @@ -8705,7 +8727,6 @@ util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== -utils-merge@1.0.1, utils-merge@1.x.x, utils-merge@^1.0.1: util@^0.12.5: version "0.12.5" resolved "https://registry.yarnpkg.com/util/-/util-0.12.5.tgz#5f17a6059b73db61a875668781a1c2b136bd6fbc" @@ -8717,16 +8738,11 @@ util@^0.12.5: is-typed-array "^1.1.3" which-typed-array "^1.1.2" -utils-merge@1.0.1: +utils-merge@1.0.1, utils-merge@1.x.x, utils-merge@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== -uuid@^9.0.0: - version "9.0.1" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.1.tgz#e188d4c8853cc722220392c424cd637f32293f30" - integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA== - uuid@10.0.0: version "10.0.0" resolved "https://registry.yarnpkg.com/uuid/-/uuid-10.0.0.tgz#5a95aa454e6e002725c79055fd42aaba30ca6294" @@ -8986,7 +9002,7 @@ write-file-atomic@^4.0.2: imurmurhash "^0.1.4" signal-exit "^3.0.7" -ws@^8.2.3: +ws@^8.18.0, ws@^8.2.3: version "8.18.0" resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.0.tgz#0d7505a6eafe2b0e712d232b42279f53bc289bbc" integrity sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==