diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy-production.yml similarity index 58% rename from .github/workflows/deploy.yml rename to .github/workflows/deploy-production.yml index f1ca50c3..fea0130d 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy-production.yml @@ -2,9 +2,9 @@ name: deploy on: push: - branches: [main, alpha] + branches: [dev] pull_request: - branches: [main, alpha] + branches: [dev] env: DOCKER_IMAGE: ${{ vars.DOCKERHUB_USERNAME }}/juga-docker @@ -13,38 +13,48 @@ env: jobs: build-and-deploy: runs-on: ubuntu-latest + strategy: + max-parallel: 1 + matrix: + app: + [ + { name: 'be', dir: 'BE', port: 3000, container: 'juga-docker-be' }, + { name: 'fe', dir: 'FE', port: 5173, container: 'juga-docker-fe' }, + ] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: '20' cache: 'npm' - cache-dependency-path: ./BE/package-lock.json + cache-dependency-path: ./${{matrix.app.dir}}/package-lock.json - name: Create .env file run: | - touch ./BE/.env - echo "${{ secrets.ENV }}" > ./BE/.env + touch ./${{ matrix.app.dir }}/.env + echo "${{ secrets.ENV }}" > ./${{matrix.app.dir}}/.env - name: Install dependencies - working-directory: ./BE + working-directory: ./${{matrix.app.dir}} + continue-on-error: true run: npm ci - name: Run tests - working-directory: ./BE + if: ${{ matrix.app.name == 'be' }} + working-directory: ./${{matrix.app.dir}} run: npm test env: CI: true - name: Run linter - working-directory: ./BE + working-directory: ./${{matrix.app.dir}} run: npm run lint - name: Build application - working-directory: ./BE + working-directory: ./${{matrix.app.dir}} run: npm run build - name: Login to Docker Hub @@ -54,16 +64,16 @@ jobs: password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and Push Docker Image - working-directory: ./BE + working-directory: ./${{ matrix.app.dir }} env: NCP_ACCESS_KEY: ${{ secrets.NCP_ACCESS_KEY }} NCP_SECRET_KEY: ${{ secrets.NCP_SECRET_KEY }} run: | - docker build -t ${{ env.DOCKER_IMAGE }}:${{ env.DOCKER_TAG }} . - docker tag ${{ env.DOCKER_IMAGE }}:${{ env.DOCKER_TAG }} ${{ env.DOCKER_IMAGE }}:latest + docker build -t ${{ env.DOCKER_IMAGE }}-${{ matrix.app.name }}:${{ env.DOCKER_TAG }} . + docker tag ${{ env.DOCKER_IMAGE }}-${{ matrix.app.name }}:${{ env.DOCKER_TAG }} ${{ env.DOCKER_IMAGE }}-${{ matrix.app.name }}:latest - docker push ${{ env.DOCKER_IMAGE }}:${{ env.DOCKER_TAG }} - docker push ${{ env.DOCKER_IMAGE }}:latest + docker push ${{ env.DOCKER_IMAGE }}-${{ matrix.app.name }}:${{ env.DOCKER_TAG }} + docker push ${{ env.DOCKER_IMAGE }}-${{ matrix.app.name }}:latest - name: Get Github Actions IP id: ip @@ -95,15 +105,25 @@ jobs: script: | docker system prune -af echo "${{ secrets.ENV }}" > .env - - docker pull ${{ env.DOCKER_IMAGE }}:${{ env.DOCKER_TAG }} - docker stop juga-docker || true - docker rm juga-docker || true + + docker network create juga-network || true + + docker run -d \ + --name redis \ + --network juga-network \ + -p 6379:6379 \ + -v redis_data:/data \ + redis:latest redis-server --appendonly yes + + docker pull ${{ env.DOCKER_IMAGE }}-${{ matrix.app.name }}:${{ env.DOCKER_TAG }} + docker stop ${{ matrix.app.container }} || true + docker rm ${{ matrix.app.container }} || true docker run -d \ - --name juga-docker \ - -p 3000:3000 \ + --name ${{ matrix.app.container }} \ + --network juga-network \ + -p ${{ matrix.app.port }}:${{ matrix.app.port }} \ --env-file .env \ - ${{ env.DOCKER_IMAGE }}:${{ env.DOCKER_TAG }} + ${{ env.DOCKER_IMAGE }}-${{ matrix.app.name }}:${{ env.DOCKER_TAG }} - name: Remove Github Action Ip to Security group run: | diff --git a/.github/workflows/deply-alpha.yml b/.github/workflows/deply-alpha.yml new file mode 100644 index 00000000..496c289a --- /dev/null +++ b/.github/workflows/deply-alpha.yml @@ -0,0 +1,132 @@ +name: deploy + +on: + push: + branches: [alpha] + pull_request: + branches: [alpha] + +env: + DOCKER_IMAGE: ${{ vars.DOCKERHUB_USERNAME }}/juga-docker + DOCKER_TAG: ${{ github.sha }} + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + strategy: + max-parallel: 1 + matrix: + app: + [ + { name: 'be', dir: 'BE', port: 3000, container: 'juga-docker-be' }, + { name: 'fe', dir: 'FE', port: 5173, container: 'juga-docker-fe' }, + ] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: ./${{matrix.app.dir}}/package-lock.json + + - name: Create .env file + run: | + touch ./${{ matrix.app.dir }}/.env + echo "${{ secrets.ENV }}" > ./${{matrix.app.dir}}/.env + + - name: Install dependencies + working-directory: ./${{matrix.app.dir}} + continue-on-error: true + run: npm ci + + - name: Run tests + if: ${{ matrix.app.name == 'be' }} + working-directory: ./${{matrix.app.dir}} + run: npm test + env: + CI: true + + - name: Run linter + working-directory: ./${{matrix.app.dir}} + run: npm run lint + + - name: Build application + working-directory: ./${{matrix.app.dir}} + run: npm run build + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and Push Docker Image + working-directory: ./${{ matrix.app.dir }} + env: + NCP_ACCESS_KEY: ${{ secrets.NCP_ACCESS_KEY }} + NCP_SECRET_KEY: ${{ secrets.NCP_SECRET_KEY }} + run: | + docker build -t ${{ env.DOCKER_IMAGE }}-${{ matrix.app.name }}:${{ env.DOCKER_TAG }} . + docker tag ${{ env.DOCKER_IMAGE }}-${{ matrix.app.name }}:${{ env.DOCKER_TAG }} ${{ env.DOCKER_IMAGE }}-${{ matrix.app.name }}:latest + + docker push ${{ env.DOCKER_IMAGE }}-${{ matrix.app.name }}:${{ env.DOCKER_TAG }} + docker push ${{ env.DOCKER_IMAGE }}-${{ matrix.app.name }}:latest + + - name: Get Github Actions IP + id: ip + uses: haythem/public-ip@v1.2 + + - name: Setting NCP CLI & Credentials + run: | + cd ~ + wget https://www.ncloud.com/api/support/download/5/65 + unzip 65 + mkdir ~/.ncloud + echo -e "[DEFAULT]\nncloud_access_key_id = ${{ secrets.NCP_ACCESS_KEY }}\nncloud_secret_access_key = ${{ secrets.NCP_SECRET_KEY }}\nncloud_api_url = ${{ secrets.NCP_API_URI }}" >> ~/.ncloud/configure + + - name: Add Github Action Ip to Security group + run: | + cd ~ + ls -la + chmod -R 777 ~/cli_linux + cd ~/cli_linux + ./ncloud vserver addAccessControlGroupInboundRule --regionCode KR --vpcNo ${{ secrets.NCP_VPC_ID }} --accessControlGroupNo ${{ secrets.NCP_ACG_ID }} --accessControlGroupRuleList "protocolTypeCode='TCP', ipBlock='${{ steps.ip.outputs.ipv4 }}/32', portRange='${{ secrets.SSH_PORT }}'" + + - name: Deploy to NCP Server + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.NCP_ALPHA_SERVER_HOST }} + username: ${{ secrets.NCP_ALPHA_SERVER_USERNAME }} + key: ${{ secrets.NCP_SERVER_SSH_KEY }} + port: 22 + script: | + docker system prune -af + echo "${{ secrets.ENV }}" > .env + + docker network create juga-network || true + + docker run -d \ + --name redis \ + --network juga-network \ + -p 6379:6379 \ + -v redis_data:/data \ + redis:latest redis-server --appendonly yes + + docker pull ${{ env.DOCKER_IMAGE }}-${{ matrix.app.name }}:${{ env.DOCKER_TAG }} + docker stop ${{ matrix.app.container }} || true + docker rm ${{ matrix.app.container }} || true + docker run -d \ + --name ${{ matrix.app.container }} \ + --network juga-network \ + -p ${{ matrix.app.port }}:${{ matrix.app.port }} \ + --env-file .env \ + ${{ env.DOCKER_IMAGE }}-${{ matrix.app.name }}:${{ env.DOCKER_TAG }} + + - name: Remove Github Action Ip to Security group + run: | + chmod -R 777 ~/cli_linux + cd ~/cli_linux + ./ncloud vserver removeAccessControlGroupInboundRule --regionCode KR --vpcNo ${{ secrets.NCP_VPC_ID }} --accessControlGroupNo ${{ secrets.NCP_ACG_ID }} --accessControlGroupRuleList "protocolTypeCode='TCP', ipBlock='${{ steps.ip.outputs.ipv4 }}/32', portRange='${{ secrets.SSH_PORT }}'" diff --git a/BE/.dockerignore b/BE/.dockerignore index 6ccb2fdd..7bd5f175 100644 --- a/BE/.dockerignore +++ b/BE/.dockerignore @@ -2,3 +2,4 @@ Dockerfile node_modules dist +.env* \ No newline at end of file diff --git a/BE/.eslintrc.js b/BE/.eslintrc.js index 0cea1c7c..b54cc5a8 100644 --- a/BE/.eslintrc.js +++ b/BE/.eslintrc.js @@ -34,5 +34,6 @@ module.exports = { 'class-methods-use-this': 'off', '@typescript-eslint/no-unsafe-assignment': 'off', '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/naming-convention': 'off', }, }; diff --git a/BE/Dockerfile b/BE/Dockerfile index e23c0848..60079503 100644 --- a/BE/Dockerfile +++ b/BE/Dockerfile @@ -1,8 +1,17 @@ -FROM node:20 -RUN mkdir -p /var/app -WORKDIR /var/app -COPY . . +# 빌드 스테이지 +FROM node:20-slim as builder +WORKDIR /app +COPY package*.json ./ RUN npm install +COPY . . RUN npm run build + +# 실행 스테이지 +FROM node:20-slim +WORKDIR /var/app +COPY package*.json ./ +RUN npm install --only=production +COPY --from=builder /app/dist ./dist + EXPOSE 3000 -CMD [ "node", "dist/main.js" ] \ No newline at end of file +CMD ["node", "dist/main.js"] \ No newline at end of file diff --git a/BE/docker-compose.yml b/BE/docker-compose.yml new file mode 100644 index 00000000..37d586f9 --- /dev/null +++ b/BE/docker-compose.yml @@ -0,0 +1,27 @@ +version: '3' +services: + api: + build: + context: . + dockerfile: Dockerfile + ports: + - '3000:3000' + environment: + - NODE_ENV=production + - REDIS_HOST=redis + - REDIS_PORT=6379 + depends_on: + - redis + restart: unless-stopped + + redis: + image: redis:latest + ports: + - '6379:6379' + volumes: + - redis_data:/data + command: redis-server --appendonly yes + restart: unless-stopped + +volumes: + redis_data: diff --git a/BE/package-lock.json b/BE/package-lock.json index cb72a103..b443ce76 100644 --- a/BE/package-lock.json +++ b/BE/package-lock.json @@ -20,18 +20,23 @@ "@nestjs/swagger": "^8.0.1", "@nestjs/typeorm": "^10.0.2", "@nestjs/websockets": "^10.4.7", + "@types/cookie-parser": "^1.4.7", "@types/passport-jwt": "^4.0.1", "axios": "^1.7.7", "bcrypt": "^5.1.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "cookie-parser": "^1.4.7", "cross-env": "^7.0.3", "docker": "^1.0.0", "dotenv": "^16.4.5", + "express": "^4.21.1", "fastify-swagger": "^5.1.1", + "ioredis": "^5.4.1", "mysql2": "^3.11.3", "passport": "^0.7.0", "passport-jwt": "^4.0.1", + "passport-kakao": "^1.0.1", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", "socket.io": "^4.8.1", @@ -818,6 +823,12 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/@ioredis/commands": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz", + "integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==", + "license": "MIT" + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "license": "ISC", @@ -1961,6 +1972,15 @@ "version": "0.4.1", "license": "MIT" }, + "node_modules/@types/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-Fvuyi354Z+uayxzIGCwYTayFKocfV7TuDYZClCdIP9ckhvAu/ixDtCB6qx2TT0FKjPLf1f3P/J1rgf6lPs64mw==", + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/cookiejar": { "version": "2.1.5", "dev": true, @@ -3617,6 +3637,15 @@ "node": ">=0.8" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/co": { "version": "4.6.0", "devOptional": true, @@ -3782,6 +3811,28 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cookie-signature": { "version": "1.0.6", "license": "MIT" @@ -5099,6 +5150,8 @@ }, "node_modules/express": { "version": "4.21.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.1.tgz", + "integrity": "sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==", "license": "MIT", "dependencies": { "accepts": "~1.3.8", @@ -6370,6 +6423,30 @@ "node": ">= 0.4" } }, + "node_modules/ioredis": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.4.1.tgz", + "integrity": "sha512-2YZsvl7jopIa1gaePkeMtd9rAcSjOOjPtpcLlOeusyO+XH2SK5ZcT+UCrElPP+WVIInh2TzeI4XW9ENaSLVVHA==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "^1.1.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "license": "MIT", @@ -7776,11 +7853,23 @@ "version": "4.17.21", "license": "MIT" }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, "node_modules/lodash.isboolean": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", @@ -8299,6 +8388,12 @@ "set-blocking": "^2.0.0" } }, + "node_modules/oauth": { + "version": "0.9.15", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz", + "integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==", + "license": "MIT" + }, "node_modules/oauth-sign": { "version": "0.8.2", "license": "Apache-2.0", @@ -8611,6 +8706,29 @@ "passport-strategy": "^1.0.0" } }, + "node_modules/passport-kakao": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/passport-kakao/-/passport-kakao-1.0.1.tgz", + "integrity": "sha512-uItaYRVrTHL6iGPMnMZvPa/O1GrAdh/V6EMjOHcFlQcVroZ9wgG7BZ5PonMNJCxfHQ3L2QVNRnzhKWUzSsumbw==", + "license": "MIT", + "dependencies": { + "passport-oauth2": "~1.1.2", + "pkginfo": "~0.3.0" + } + }, + "node_modules/passport-oauth2": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.1.2.tgz", + "integrity": "sha512-wpsGtJDHHQUjyc9WcV9FFB0bphFExpmKtzkQrxpH1vnSr6RcWa3ZEGHx/zGKAh2PN7Po9TKYB1fJeOiIBspNPA==", + "dependencies": { + "oauth": "0.9.x", + "passport-strategy": "1.x.x", + "uid2": "0.0.x" + }, + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/passport-strategy": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", @@ -8769,6 +8887,15 @@ "node": ">=8" } }, + "node_modules/pkginfo": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/pkginfo/-/pkginfo-0.3.1.tgz", + "integrity": "sha512-yO5feByMzAp96LtP58wvPKSbaKAi/1C4kV9XpTctr6EepnP6F33RBNOiVrdz9BrPA98U2BMFsTNHo44TWcbQ2A==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/pluralize": { "version": "8.0.0", "dev": true, @@ -9009,6 +9136,27 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/reflect-metadata": { "version": "0.2.2", "license": "Apache-2.0" @@ -9836,6 +9984,12 @@ "node": ">=8" } }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.1", "license": "MIT", @@ -10901,6 +11055,12 @@ "node": ">=8" } }, + "node_modules/uid2": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz", + "integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==", + "license": "MIT" + }, "node_modules/unbounded": { "version": "1.3.0", "license": "MIT", diff --git a/BE/package.json b/BE/package.json index 224dc019..9bff210b 100644 --- a/BE/package.json +++ b/BE/package.json @@ -30,19 +30,24 @@ "@nestjs/schedule": "^4.1.1", "@nestjs/swagger": "^8.0.1", "@nestjs/typeorm": "^10.0.2", - "@types/passport-jwt": "^4.0.1", "@nestjs/websockets": "^10.4.7", + "@types/cookie-parser": "^1.4.7", + "@types/passport-jwt": "^4.0.1", "axios": "^1.7.7", "bcrypt": "^5.1.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "cookie-parser": "^1.4.7", "cross-env": "^7.0.3", "docker": "^1.0.0", "dotenv": "^16.4.5", + "express": "^4.21.1", "fastify-swagger": "^5.1.1", + "ioredis": "^5.4.1", "mysql2": "^3.11.3", "passport": "^0.7.0", "passport-jwt": "^4.0.1", + "passport-kakao": "^1.0.1", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", "socket.io": "^4.8.1", diff --git a/BE/src/app.module.ts b/BE/src/app.module.ts index fbef051f..2b2a14d5 100644 --- a/BE/src/app.module.ts +++ b/BE/src/app.module.ts @@ -2,36 +2,45 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ConfigModule } from '@nestjs/config'; import { ScheduleModule } from '@nestjs/schedule'; +import { APP_FILTER } from '@nestjs/core'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { AuthModule } from './auth/auth.module'; -import { User } from './auth/user.entity'; -import { StockIndexModule } from './stock/index/stock.index.module'; -import { StockTopfiveModule } from './stock/topfive/stock.topfive.module'; -import { KoreaInvestmentModule } from './koreaInvestment/korea.investment.module'; +import { StockIndexModule } from './stock/index/stock-index.module'; +import { StockTopfiveModule } from './stock/topfive/stock-topfive.module'; +import { KoreaInvestmentModule } from './koreaInvestment/korea-investment.module'; import { SocketModule } from './websocket/socket.module'; +import { StockOrderModule } from './stock/order/stock-order.module'; +import { StockDetailModule } from './stock/detail/stock-detail.module'; +import { typeOrmConfig } from './configs/typeorm.config'; +import { StockListModule } from './stock/list/stock-list.module'; +import { StockTradeHistoryModule } from './stock/trade/history/stock-trade-history.module'; +import { RedisModule } from './common/redis/redis.module'; +import { HTTPExceptionFilter } from './common/filters/http-exception.filter'; @Module({ imports: [ ScheduleModule.forRoot(), ConfigModule.forRoot(), - TypeOrmModule.forRoot({ - type: 'mysql', // 데이터베이스 타입 - host: process.env.DB_HOST, - port: 3306, - username: process.env.DB_USERNAME, - password: process.env.DB_PASSWD, - database: process.env.DB_DATABASE, - entities: [User], - synchronize: true, - }), + TypeOrmModule.forRoot(typeOrmConfig), KoreaInvestmentModule, AuthModule, StockIndexModule, StockTopfiveModule, SocketModule, + StockDetailModule, + StockOrderModule, + StockListModule, + StockTradeHistoryModule, + RedisModule, ], controllers: [AppController], - providers: [AppService], + providers: [ + AppService, + { + provide: APP_FILTER, + useClass: HTTPExceptionFilter, + }, + ], }) export class AppModule {} diff --git a/BE/src/asset/asset.controller.ts b/BE/src/asset/asset.controller.ts new file mode 100644 index 00000000..3bca562e --- /dev/null +++ b/BE/src/asset/asset.controller.ts @@ -0,0 +1,6 @@ +import { Controller } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; + +@Controller('/api/assets') +@ApiTags('자산 API') +export class AssetController {} diff --git a/BE/src/asset/asset.entity.ts b/BE/src/asset/asset.entity.ts new file mode 100644 index 00000000..b3504463 --- /dev/null +++ b/BE/src/asset/asset.entity.ts @@ -0,0 +1,30 @@ +import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; + +const INIT_ASSET = 10000000; + +@Entity('assets') +export class Asset { + @PrimaryGeneratedColumn() + id: number; + + @Column({ nullable: false }) + user_id: number; + + @Column({ nullable: false, default: INIT_ASSET }) + cash_balance: number; + + @Column({ nullable: false, default: 0 }) + stock_balance: number; + + @Column({ nullable: false, default: INIT_ASSET }) + total_asset: number; + + @Column({ nullable: false, default: 0 }) + total_profit: number; + + @Column('decimal', { nullable: false, default: 0, precision: 10, scale: 5 }) + total_profit_rate: number; + + @Column({ nullable: true }) + last_updated?: Date; +} diff --git a/BE/src/asset/asset.module.ts b/BE/src/asset/asset.module.ts new file mode 100644 index 00000000..5ddf8874 --- /dev/null +++ b/BE/src/asset/asset.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AssetController } from './asset.controller'; +import { AssetService } from './asset.service'; +import { AssetRepository } from './asset.repository'; +import { Asset } from './asset.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([Asset])], + controllers: [AssetController], + providers: [AssetService, AssetRepository], + exports: [AssetRepository], +}) +export class AssetModule {} diff --git a/BE/src/asset/asset.repository.ts b/BE/src/asset/asset.repository.ts new file mode 100644 index 00000000..cfc05c92 --- /dev/null +++ b/BE/src/asset/asset.repository.ts @@ -0,0 +1,11 @@ +import { DataSource, Repository } from 'typeorm'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { Injectable } from '@nestjs/common'; +import { Asset } from './asset.entity'; + +@Injectable() +export class AssetRepository extends Repository { + constructor(@InjectDataSource() dataSource: DataSource) { + super(Asset, dataSource.createEntityManager()); + } +} diff --git a/BE/src/asset/asset.service.ts b/BE/src/asset/asset.service.ts new file mode 100644 index 00000000..f424a0d8 --- /dev/null +++ b/BE/src/asset/asset.service.ts @@ -0,0 +1,4 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class AssetService {} diff --git a/BE/src/auth/auth.controller.ts b/BE/src/auth/auth.controller.ts index 8411eb48..8fcdf300 100644 --- a/BE/src/auth/auth.controller.ts +++ b/BE/src/auth/auth.controller.ts @@ -3,18 +3,25 @@ import { Post, Get, Body, - Req, ValidationPipe, UseGuards, + Req, + Res, + UnauthorizedException, } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { ApiOperation } from '@nestjs/swagger'; +import { Request, Response } from 'express'; +import { ConfigService } from '@nestjs/config'; import { AuthService } from './auth.service'; -import { AuthCredentialsDto } from './dto/authCredentials.dto'; +import { AuthCredentialsDto } from './dto/auth-credentials.dto'; -@Controller('auth') +@Controller('/api/auth') export class AuthController { - constructor(private authService: AuthService) {} + constructor( + private authService: AuthService, + private configService: ConfigService, + ) {} @ApiOperation({ summary: '회원 가입 API' }) @Post('/signup') @@ -24,16 +31,59 @@ export class AuthController { @ApiOperation({ summary: '로그인 API' }) @Post('/login') - loginWithCredentials( + async loginWithCredentials( @Body(ValidationPipe) authCredentialsDto: AuthCredentialsDto, + @Res() res: Response, ) { - return this.authService.loginUser(authCredentialsDto); + const { accessToken, refreshToken } = + await this.authService.loginUser(authCredentialsDto); + + res.cookie('refreshToken', refreshToken, { httpOnly: true }); + res.cookie('isRefreshToken', true, { httpOnly: true }); + return res.status(200).json({ accessToken }); } @ApiOperation({ summary: 'Token 인증 테스트 API' }) @Get('/test') - @UseGuards(AuthGuard()) + @UseGuards(AuthGuard('jwt')) test(@Req() req: Request) { return req; } + + @ApiOperation({ summary: 'Kakao 로그인 API' }) + @Get('/kakao') + @UseGuards(AuthGuard('kakao')) + async kakaoLogin(@Req() req: Request, @Res() res: Response) { + const authCredentialsDto: AuthCredentialsDto = { + email: req.user.email, + kakaoId: req.user.kakaoId, + }; + const { accessToken, refreshToken } = + await this.authService.kakaoLoginUser(authCredentialsDto); + + res.cookie('accessToken', accessToken, { httpOnly: true }); + res.cookie('refreshToken', refreshToken, { httpOnly: true }); + res.cookie('isRefreshToken', true, { httpOnly: true }); + return res.redirect(this.configService.get('FRONTEND_URL')); + } + + @ApiOperation({ summary: 'Refresh Token 요청 API' }) + @Get('/refresh') + async refresh(@Req() req: Request, @Res() res: Response) { + if ( + typeof req.cookies.refreshToken !== 'string' || + typeof req.cookies.accessToken !== 'string' + ) { + throw new UnauthorizedException('Invalid refresh token'); + } + + const { refreshToken } = req.cookies; + + const newAccessToken = await this.authService.refreshToken(refreshToken); + + res.cookie('accessToken', newAccessToken, { httpOnly: true }); + res.cookie('refreshToken', refreshToken, { httpOnly: true }); + res.cookie('isRefreshToken', true, { httpOnly: true }); + return res.redirect(this.configService.get('FRONTEND_URL')); + } } diff --git a/BE/src/auth/auth.module.ts b/BE/src/auth/auth.module.ts index a3ba6f04..581b401c 100644 --- a/BE/src/auth/auth.module.ts +++ b/BE/src/auth/auth.module.ts @@ -2,25 +2,34 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { JwtModule } from '@nestjs/jwt'; import { PassportModule } from '@nestjs/passport'; +import { ConfigModule, ConfigService } from '@nestjs/config'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { User } from './user.entity'; import { UserRepository } from './user.repository'; -import { JwtStrategy } from './jwt.strategy'; +import { JwtStrategy } from './strategy/jwt.strategy'; +import { KakaoStrategy } from './strategy/kakao.strategy'; +import { AssetModule } from '../asset/asset.module'; @Module({ imports: [ TypeOrmModule.forFeature([User]), + ConfigModule, PassportModule.register({ defaultStrategy: 'jwt' }), - JwtModule.register({ - secret: 'Juga16', - signOptions: { - expiresIn: 3600, - }, + JwtModule.registerAsync({ + imports: [ConfigModule], + useFactory: (configService: ConfigService) => ({ + secret: configService.get('JWT_SECRET'), + signOptions: { + expiresIn: configService.get('JWT_ACCESS_EXPIRATION_TIME'), + }, + }), + inject: [ConfigService], }), + AssetModule, ], controllers: [AuthController], - providers: [AuthService, UserRepository, JwtStrategy], + providers: [AuthService, UserRepository, JwtStrategy, KakaoStrategy], exports: [JwtStrategy, PassportModule], }) export class AuthModule {} diff --git a/BE/src/auth/auth.service.ts b/BE/src/auth/auth.service.ts index cbc4a69f..54320e87 100644 --- a/BE/src/auth/auth.service.ts +++ b/BE/src/auth/auth.service.ts @@ -2,8 +2,9 @@ import { Injectable, UnauthorizedException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcrypt'; +import { ConfigService } from '@nestjs/config'; import { UserRepository } from './user.repository'; -import { AuthCredentialsDto } from './dto/authCredentials.dto'; +import { AuthCredentialsDto } from './dto/auth-credentials.dto'; @Injectable() export class AuthService { @@ -11,6 +12,7 @@ export class AuthService { @InjectRepository(UserRepository) private userRepository: UserRepository, private jwtService: JwtService, + private readonly configService: ConfigService, ) {} async signUp(authCredentialsDto: AuthCredentialsDto): Promise { @@ -19,15 +21,118 @@ export class AuthService { async loginUser( authCredentialsDto: AuthCredentialsDto, - ): Promise<{ accessToken: string }> { + ): Promise<{ accessToken: string; refreshToken: string }> { const { email, password } = authCredentialsDto; const user = await this.userRepository.findOne({ where: { email } }); if (user && (await bcrypt.compare(password, user.password))) { - const payload = { email }; - const accessToken = this.jwtService.sign(payload); - return { accessToken }; + const { accessToken, refreshToken } = + await this.getJWTToken(authCredentialsDto); + + await this.setCurrentRefreshToken(refreshToken, user.id); + + return { accessToken, refreshToken }; } throw new UnauthorizedException('Please check your login credentials'); } + + async kakaoLoginUser( + authCredentialsDto: AuthCredentialsDto, + ): Promise<{ accessToken: string; refreshToken: string }> { + const user = await this.userRepository.findOne({ + where: { kakaoId: authCredentialsDto.kakaoId }, + }); + + if (!user) { + await this.userRepository.registerKakaoUser(authCredentialsDto); + } + return this.getJWTToken(authCredentialsDto); + } + + async getJWTToken(authCredentialsDto: AuthCredentialsDto) { + const accessToken = await this.generateAccessToken(authCredentialsDto); + const refreshToken = await this.generateRefreshToken(authCredentialsDto); + return { accessToken, refreshToken }; + } + + async generateAccessToken( + authCredentialsDto: AuthCredentialsDto, + ): Promise { + return authCredentialsDto.email + ? this.jwtService.signAsync({ email: authCredentialsDto.email }) + : this.jwtService.signAsync({ kakaoId: authCredentialsDto.kakaoId }); + } + + async generateRefreshToken( + authCredentialsDto: AuthCredentialsDto, + ): Promise { + if (authCredentialsDto.email) { + return this.jwtService.signAsync( + { email: authCredentialsDto.email }, + { + secret: this.configService.get('JWT_REFRESH_SECRET'), + expiresIn: this.configService.get( + 'JWT_REFRESH_EXPIRATION_TIME', + ), + }, + ); + } + return this.jwtService.signAsync( + { kakaoId: authCredentialsDto.kakaoId }, + { + secret: this.configService.get('JWT_REFRESH_SECRET'), + expiresIn: this.configService.get( + 'JWT_REFRESH_EXPIRATION_TIME', + ), + }, + ); + } + + async setCurrentRefreshToken(refreshToken: string, userId: number) { + const currentDate = new Date(); + const salt = await bcrypt.genSalt(); + const currentRefreshToken = await bcrypt.hash(refreshToken, salt); + const currentRefreshTokenExpiresAt = new Date( + currentDate.getTime() + + parseInt( + this.configService.get('JWT_REFRESH_EXPIRATION_TIME'), + 10, + ), + ); + + await this.userRepository.update(userId, { + currentRefreshToken, + currentRefreshTokenExpiresAt, + }); + } + + async refreshToken(refreshToken: string): Promise { + try { + const decodedRefreshToken = this.jwtService.verify(refreshToken, { + secret: this.configService.get('JWT_REFRESH_SECRET'), + }); + + const user = decodedRefreshToken.email + ? await this.userRepository.findOne({ + where: { email: decodedRefreshToken.email }, + }) + : await this.userRepository.findOne({ + where: { kakaoId: decodedRefreshToken.kakaoId }, + }); + + const isRefreshTokenMatching = await bcrypt.compare( + refreshToken, + user.currentRefreshToken, + ); + + if (!isRefreshTokenMatching) { + throw new UnauthorizedException('Invalid Token'); + } + + const accessToken = this.generateAccessToken(user.toAuthCredentialsDto()); + return await accessToken; + } catch (error) { + throw new UnauthorizedException('Invalid Token'); + } + } } diff --git a/BE/src/auth/dto/auth-credentials.dto.ts b/BE/src/auth/dto/auth-credentials.dto.ts new file mode 100644 index 00000000..90b81254 --- /dev/null +++ b/BE/src/auth/dto/auth-credentials.dto.ts @@ -0,0 +1,43 @@ +import { + IsString, + Matches, + MaxLength, + MinLength, + IsOptional, +} from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class AuthCredentialsDto { + @ApiProperty({ + description: '유저 이메일', + }) + @IsString() + email?: string; + + @ApiProperty({ + description: '유저 비밀번호', + }) + @IsString() + @MinLength(4) + @MaxLength(20) + @Matches(/^[a-zA-Z0-9]*$/, { + message: '비밀번호는 영문과 숫자만 사용가능합니다', + }) + password?: string; + + @ApiProperty({ + description: '카카오 ID', + required: false, + }) + @IsString() + @IsOptional() + kakaoId?: string; + + @ApiProperty({ + description: '카카오 액세스 토큰', + required: false, + }) + @IsString() + @IsOptional() + kakaoAccessToken?: string; +} diff --git a/BE/src/auth/dto/authCredentials.dto.ts b/BE/src/auth/dto/authCredentials.dto.ts deleted file mode 100644 index fb1199ed..00000000 --- a/BE/src/auth/dto/authCredentials.dto.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { IsString, Matches, MaxLength, MinLength } from 'class-validator'; -import { ApiProperty } from '@nestjs/swagger'; - -export class AuthCredentialsDto { - @ApiProperty({ - description: '유저 이메일', - minLength: 4, - maxLength: 20, - type: 'string', - }) - @IsString() - @MinLength(4) - @MaxLength(20) - email: string; - - @ApiProperty({ - description: '유저 비밀번호', - minLength: 4, - maxLength: 20, - type: 'string', - }) - @IsString() - @MinLength(4) - @MaxLength(20) - @Matches(/^[a-zA-Z0-9]*$/) - password: string; -} diff --git a/BE/src/auth/jwt-auth-guard.ts b/BE/src/auth/jwt-auth-guard.ts new file mode 100644 index 00000000..0aa58fd8 --- /dev/null +++ b/BE/src/auth/jwt-auth-guard.ts @@ -0,0 +1,5 @@ +import { AuthGuard } from '@nestjs/passport'; +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class JwtAuthGuard extends AuthGuard('jwt') {} diff --git a/BE/src/auth/jwt.strategy.ts b/BE/src/auth/strategy/jwt.strategy.ts similarity index 64% rename from BE/src/auth/jwt.strategy.ts rename to BE/src/auth/strategy/jwt.strategy.ts index 350d622d..7f8f5d2a 100644 --- a/BE/src/auth/jwt.strategy.ts +++ b/BE/src/auth/strategy/jwt.strategy.ts @@ -2,16 +2,18 @@ import { PassportStrategy } from '@nestjs/passport'; import { InjectRepository } from '@nestjs/typeorm'; import { ExtractJwt, Strategy } from 'passport-jwt'; import { Injectable, UnauthorizedException } from '@nestjs/common'; -import { UserRepository } from './user.repository'; -import { User } from './user.entity'; +import { ConfigService } from '@nestjs/config'; +import { UserRepository } from '../user.repository'; +import { User } from '../user.entity'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { constructor( @InjectRepository(UserRepository) private userRepository: UserRepository, + private readonly configService: ConfigService, ) { super({ - secretOrKey: 'Juga16', + secretOrKey: configService.get('JWT_SECRET'), jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), }); } @@ -21,6 +23,11 @@ export class JwtStrategy extends PassportStrategy(Strategy) { const user: User = await this.userRepository.findOne({ where: { email } }); if (!user) throw new UnauthorizedException(); - return user; + return { + userId: user.id, + email: user.email, + tutorial: user.tutorial, + kakaoId: user.kakaoId, + }; } } diff --git a/BE/src/auth/strategy/kakao.strategy.ts b/BE/src/auth/strategy/kakao.strategy.ts new file mode 100644 index 00000000..1e4e0c85 --- /dev/null +++ b/BE/src/auth/strategy/kakao.strategy.ts @@ -0,0 +1,61 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PassportStrategy } from '@nestjs/passport'; +import { Profile, Strategy } from 'passport-kakao'; + +interface KakaoStrategyOptions { + clientID: string; + clientSecret: string; + callbackURL: string; +} + +interface KakaoProfile extends Profile { + id: number; + _json: { + id: number; + kakao_account: { + email: string; + }; + }; +} + +interface KakaoUser { + kakaoId: number; +} + +@Injectable() +export class KakaoStrategy extends PassportStrategy( + Strategy, + 'kakao', +) { + constructor(private readonly configService: ConfigService) { + const options: KakaoStrategyOptions = { + clientID: configService.get('KAKAO_CLIENT_ID') || '', + clientSecret: '', + callbackURL: `${configService.get('BACKEND_URL') || ''}/api/auth/kakao`, + }; + + super(options); + } + + validate( + accessToken: string, + refreshToken: string, + profile: KakaoProfile, + done: (error: Error, user?: KakaoUser) => void, + ) { + try { + // eslint-disable-next-line no-underscore-dangle + const kakaoId = profile._json.id; + // eslint-disable-next-line no-underscore-dangle + const { email } = profile._json.kakao_account; + const user = { + email, + kakaoId, + }; + done(null, user); + } catch (error) { + done(error instanceof Error ? error : new Error(String(error))); + } + } +} diff --git a/BE/src/auth/user.entity.ts b/BE/src/auth/user.entity.ts index cf6b130a..16773177 100644 --- a/BE/src/auth/user.entity.ts +++ b/BE/src/auth/user.entity.ts @@ -1,4 +1,5 @@ import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; +import { AuthCredentialsDto } from './dto/auth-credentials.dto'; @Entity() export class User extends BaseEntity { @@ -14,6 +15,23 @@ export class User extends BaseEntity { @Column({ default: false }) tutorial: boolean; - @Column({ default: -1 }) - kakaoId: number; + @Column({ default: '' }) + kakaoId: string; + + @Column({ default: '' }) + currentRefreshToken: string; + + @Column({ type: 'datetime', nullable: true }) + currentRefreshTokenExpiresAt: Date; + + toAuthCredentialsDto(): AuthCredentialsDto { + if (this.kakaoId === '') { + return { + email: this.email, + password: this.password, + }; + } + + throw new Error('Cannot convert Kakao user to auth credentials'); + } } diff --git a/BE/src/auth/user.repository.ts b/BE/src/auth/user.repository.ts index 0a23f980..c17ce75f 100644 --- a/BE/src/auth/user.repository.ts +++ b/BE/src/auth/user.repository.ts @@ -1,21 +1,75 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, InternalServerErrorException } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource, Repository } from 'typeorm'; import * as bcrypt from 'bcrypt'; import { User } from './user.entity'; -import { AuthCredentialsDto } from './dto/authCredentials.dto'; +import { AuthCredentialsDto } from './dto/auth-credentials.dto'; +import { AssetRepository } from '../asset/asset.repository'; @Injectable() export class UserRepository extends Repository { - constructor(@InjectDataSource() dataSource: DataSource) { + constructor( + @InjectDataSource() private dataSource: DataSource, + private readonly assetRepository: AssetRepository, + ) { super(User, dataSource.createEntityManager()); } async registerUser(authCredentialsDto: AuthCredentialsDto) { const { email, password } = authCredentialsDto; - const salt: string = await bcrypt.genSalt(); - const hashedPassword: string = await bcrypt.hash(password, salt); - const user = this.create({ email, password: hashedPassword }); + + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.startTransaction(); + + try { + const salt: string = await bcrypt.genSalt(); + const hashedPassword: string = await bcrypt.hash(password, salt); + const user = this.create({ email, password: hashedPassword }); + await queryRunner.manager.save(user); + const asset = this.assetRepository.create({ user_id: user.id }); + await queryRunner.manager.save(asset); + + await queryRunner.commitTransaction(); + } catch (err) { + await queryRunner.rollbackTransaction(); + throw new InternalServerErrorException(); + } finally { + await queryRunner.release(); + } + } + + async registerKakaoUser(authCredentialsDto: AuthCredentialsDto) { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.startTransaction(); + + try { + const { kakaoId, email } = authCredentialsDto; + const salt: string = await bcrypt.genSalt(); + const hashedPassword: string = await bcrypt.hash(String(kakaoId), salt); + const user = this.create({ email, kakaoId, password: hashedPassword }); + await this.save(user); + const asset = this.assetRepository.create({ user_id: user.id }); + await queryRunner.manager.save(asset); + + await queryRunner.commitTransaction(); + } catch (err) { + await queryRunner.rollbackTransaction(); + throw new InternalServerErrorException(); + } finally { + await queryRunner.release(); + } + } + + async updateUserWithRefreshToken( + id: number, + { + refreshToken, + refreshTokenExpiresAt, + }: { refreshToken: string; refreshTokenExpiresAt: Date }, + ) { + const user = await this.findOne({ where: { id } }); + user.currentRefreshToken = refreshToken; + user.currentRefreshTokenExpiresAt = refreshTokenExpiresAt; await this.save(user); } } diff --git a/BE/src/common/filters/http-exception.filter.ts b/BE/src/common/filters/http-exception.filter.ts new file mode 100644 index 00000000..a9007911 --- /dev/null +++ b/BE/src/common/filters/http-exception.filter.ts @@ -0,0 +1,44 @@ +import { + ArgumentsHost, + Catch, + ExceptionFilter, + HttpException, + HttpStatus, + Logger, +} from '@nestjs/common'; + +import { Request, Response } from 'express'; + +@Catch() +export class HTTPExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger(HTTPExceptionFilter.name); + + catch(exception: Error, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const request = ctx.getRequest(); + const response = ctx.getResponse(); + + const status = + exception instanceof HttpException + ? exception.getStatus() + : HttpStatus.INTERNAL_SERVER_ERROR; + + const message = + exception instanceof HttpException + ? exception.message + : 'Internal Server Error'; + + const errorResponse = { + statusCode: status, + message, + timestamp: new Date().toISOString(), + path: request.url, + }; + + this.logger.error( + `[${request.method}] ${request.url} - Status: ${status} - Error: ${exception.message}`, + ); + + response.status(status).json(errorResponse); + } +} diff --git a/BE/src/common/redis/redis.domain-service.ts b/BE/src/common/redis/redis.domain-service.ts new file mode 100644 index 00000000..ce636d6e --- /dev/null +++ b/BE/src/common/redis/redis.domain-service.ts @@ -0,0 +1,57 @@ +import { Injectable, Inject } from '@nestjs/common'; +import Redis from 'ioredis'; + +@Injectable() +export class RedisDomainService { + constructor( + @Inject('REDIS_CLIENT') + private readonly redis: Redis, + ) {} + + async get(key: string): Promise { + return this.redis.get(key); + } + + async set(key: string, value: string, expires?: number): Promise<'OK'> { + if (expires) { + return this.redis.set(key, value, 'EX', expires); + } + return this.redis.set(key, value); + } + + async del(key: string): Promise { + return this.redis.del(key); + } + + async zadd(key: string, score: number, member: string): Promise { + return this.redis.zadd(key, score, member); + } + + async zcard(key: string): Promise { + return this.redis.zcard(key); + } + + async zrange(key: string, start: number, stop: number): Promise { + return this.redis.zrange(key, start, stop); + } + + async zremrangebyrank( + key: string, + start: number, + stop: number, + ): Promise { + return this.redis.zremrangebyrank(key, start, stop); + } + + async zrevrange(key: string, start: number, stop: number): Promise { + return this.redis.zrevrange(key, start, stop); + } + + async zrem(key: string, member: string): Promise { + return this.redis.zrem(key, member); + } + + async expire(key: string, seconds: number): Promise { + return this.redis.expire(key, seconds); + } +} diff --git a/BE/src/common/redis/redis.module.ts b/BE/src/common/redis/redis.module.ts new file mode 100644 index 00000000..863812b5 --- /dev/null +++ b/BE/src/common/redis/redis.module.ts @@ -0,0 +1,22 @@ +// src/common/redis/redis.module.ts +import { Global, Module } from '@nestjs/common'; +import Redis from 'ioredis'; +import { RedisDomainService } from './redis.domain-service'; + +@Global() +@Module({ + providers: [ + { + provide: 'REDIS_CLIENT', + useFactory: () => { + return new Redis({ + host: process.env.REDIS_HOST || 'redis', + port: Number(process.env.REDIS_PORT || 6379), + }); + }, + }, + RedisDomainService, + ], + exports: [RedisDomainService, 'REDIS_CLIENT'], +}) +export class RedisModule {} diff --git a/BE/src/common/redis/redis.provider.ts b/BE/src/common/redis/redis.provider.ts new file mode 100644 index 00000000..28e1471d --- /dev/null +++ b/BE/src/common/redis/redis.provider.ts @@ -0,0 +1,14 @@ +import { Provider } from '@nestjs/common'; +import Redis from 'ioredis'; +import dotenv from 'dotenv'; + +dotenv.config(); +export const RedisProvider: Provider = { + provide: 'REDIS_CLIENT', + useFactory: () => { + return new Redis({ + host: process.env.REDIS_HOST, + port: Number(process.env.REDIS_PORT), + }); + }, +}; diff --git a/BE/src/configs/typeorm.config.ts b/BE/src/configs/typeorm.config.ts new file mode 100644 index 00000000..c10d56ef --- /dev/null +++ b/BE/src/configs/typeorm.config.ts @@ -0,0 +1,15 @@ +import { TypeOrmModuleOptions } from '@nestjs/typeorm'; +import * as dotenv from 'dotenv'; + +dotenv.config(); + +export const typeOrmConfig: TypeOrmModuleOptions = { + type: 'mysql', + host: process.env.DB_HOST, + port: 3306, + username: process.env.DB_USERNAME, + password: process.env.DB_PASSWD, + database: process.env.DB_DATABASE, + entities: [`${__dirname}/../**/*.entity{.js,.ts}`], + synchronize: true, +}; diff --git a/BE/src/koreaInvestment/interface/korea-investment.interface.ts b/BE/src/koreaInvestment/interface/korea-investment.interface.ts new file mode 100644 index 00000000..e290a32c --- /dev/null +++ b/BE/src/koreaInvestment/interface/korea-investment.interface.ts @@ -0,0 +1,6 @@ +export interface AccessTokenInterface { + access_token: string; + access_token_token_expired: string; + token_type: string; + expires_in: number; +} diff --git a/BE/src/koreaInvestment/korea.investment.module.ts b/BE/src/koreaInvestment/korea-investment.module.ts similarity index 76% rename from BE/src/koreaInvestment/korea.investment.module.ts rename to BE/src/koreaInvestment/korea-investment.module.ts index 69679c6b..8e7dd10a 100644 --- a/BE/src/koreaInvestment/korea.investment.module.ts +++ b/BE/src/koreaInvestment/korea-investment.module.ts @@ -1,5 +1,5 @@ import { Module } from '@nestjs/common'; -import { KoreaInvestmentService } from './korea.investment.service'; +import { KoreaInvestmentService } from './korea-investment.service'; @Module({ imports: [], diff --git a/BE/src/koreaInvestment/korea.investment.service.ts b/BE/src/koreaInvestment/korea-investment.service.ts similarity index 53% rename from BE/src/koreaInvestment/korea.investment.service.ts rename to BE/src/koreaInvestment/korea-investment.service.ts index 57458372..29dc323c 100644 --- a/BE/src/koreaInvestment/korea.investment.service.ts +++ b/BE/src/koreaInvestment/korea-investment.service.ts @@ -1,4 +1,7 @@ import axios from 'axios'; +import { UnauthorizedException } from '@nestjs/common'; +import { getFullURL } from '../util/get-full-URL'; +import { AccessTokenInterface } from './interface/korea-investment.interface'; export class KoreaInvestmentService { private accessToken: string; @@ -9,19 +12,20 @@ export class KoreaInvestmentService { if (this.accessToken && this.tokenExpireTime > new Date()) { return this.accessToken; } - const response = await axios.post( - `${process.env.KOREA_INVESTMENT_BASE_URL}/oauth2/tokenP`, - { + const response = await axios + .post(getFullURL('/oauth2/tokenP'), { grant_type: 'client_credentials', appkey: process.env.KOREA_INVESTMENT_APP_KEY, appsecret: process.env.KOREA_INVESTMENT_APP_SECRET, - }, - ); + }) + .catch(() => { + throw new UnauthorizedException('액세스 토큰을 조회하지 못했습니다.'); + }); const { data } = response; this.accessToken = data.access_token; - this.tokenExpireTime = new Date(Date.now() + +data.expires_in); + this.tokenExpireTime = new Date(data.access_token_token_expired); return this.accessToken; } diff --git a/BE/src/main.ts b/BE/src/main.ts index bbc099aa..ed080e3c 100644 --- a/BE/src/main.ts +++ b/BE/src/main.ts @@ -1,5 +1,6 @@ import { NestFactory } from '@nestjs/core'; import { Logger } from '@nestjs/common'; +import * as cookieParser from 'cookie-parser'; import { AppModule } from './app.module'; import { setupSwagger } from './util/swagger'; @@ -8,12 +9,19 @@ async function bootstrap() { setupSwagger(app); app.enableCors({ - origin: ['http://localhost:5173', 'http://223.130.151.42:3000'], + origin: [ + 'http://localhost:5173', + 'http://223.130.151.42:5173', + 'http://juga.kro.kr:5173', + 'http://juga.kro.kr:3000', + 'http://223.130.151.42:3000', + ], methods: 'GET, HEAD, PUT, PATH, POST, DELETE', preflightContinue: false, optionsSuccessStatus: 204, }); + app.use(cookieParser()); await app.listen(process.env.PORT ?? 3000); } diff --git a/BE/src/stock/detail/dto/stock-detail-chart-data.dto.ts b/BE/src/stock/detail/dto/stock-detail-chart-data.dto.ts new file mode 100644 index 00000000..de68279f --- /dev/null +++ b/BE/src/stock/detail/dto/stock-detail-chart-data.dto.ts @@ -0,0 +1,24 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class InquirePriceChartDataDto { + @ApiProperty({ description: '주식 영업 일자' }) + stck_bsop_date: string; + + @ApiProperty({ description: '주식 종가' }) + stck_clpr: string; + + @ApiProperty({ description: '주식 시가' }) + stck_oprc: string; + + @ApiProperty({ description: '주식 최고가' }) + stck_hgpr: string; + + @ApiProperty({ description: '주식 최저가' }) + stck_lwpr: string; + + @ApiProperty({ description: '누적 거래량' }) + acml_vol: string; + + @ApiProperty({ description: '전일 대비 부호' }) + prdy_vrss_sign: string; +} diff --git a/BE/src/stock/detail/dto/stock-detail-chart-request.dto.ts b/BE/src/stock/detail/dto/stock-detail-chart-request.dto.ts new file mode 100644 index 00000000..4a21bbc3 --- /dev/null +++ b/BE/src/stock/detail/dto/stock-detail-chart-request.dto.ts @@ -0,0 +1,17 @@ +import { ApiProperty } from '@nestjs/swagger'; + +/** + * 국내주식기간별시세(일/주/월/년) API를 이용할 때 필요한 요청 데이터를 담고 있는 DTO + */ +export class StockDetailChartRequestDto { + @ApiProperty({ description: '조회 시작일자 (ex) 20220501' }) + fid_input_date_1: string; + + @ApiProperty({ description: '조회 종료일자 (ex) 20220530' }) + fid_input_date_2: string; + + @ApiProperty({ + description: '기간 분류 코드 (ex) D(일봉) W(주봉) M(월봉) Y(년봉)', + }) + fid_period_div_code: string; +} diff --git a/BE/src/stock/detail/dto/stock-detail-chart-response.dto.ts b/BE/src/stock/detail/dto/stock-detail-chart-response.dto.ts new file mode 100644 index 00000000..3c580851 --- /dev/null +++ b/BE/src/stock/detail/dto/stock-detail-chart-response.dto.ts @@ -0,0 +1,5 @@ +import { InquirePriceChartDataDto } from './stock-detail-chart-data.dto'; + +export class InquirePriceChartResponseDto { + output: InquirePriceChartDataDto[]; +} diff --git a/BE/src/stock/detail/dto/stock-detail-response.dto.ts b/BE/src/stock/detail/dto/stock-detail-response.dto.ts new file mode 100644 index 00000000..96e4ba52 --- /dev/null +++ b/BE/src/stock/detail/dto/stock-detail-response.dto.ts @@ -0,0 +1,27 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class InquirePriceResponseDto { + @ApiProperty({ description: 'HTS 한글 종목명' }) + hts_kor_isnm: string; + + @ApiProperty({ description: '종목코드' }) + stck_shrn_iscd: string; + + @ApiProperty({ description: '주식 현재가' }) + stck_prpr: string; + + @ApiProperty({ description: '전일 대비' }) + prdy_vrss: string; + + @ApiProperty({ description: '전일 대비 부호' }) + prdy_vrss_sign: string; + + @ApiProperty({ description: '전일 대비율' }) + prdy_ctrt: string; + + @ApiProperty({ description: 'HTS 시가총액' }) + hts_avls: string; + + @ApiProperty({ description: 'PER' }) + per: string; +} diff --git a/BE/src/stock/detail/interface/stock-detail-chart.interface.ts b/BE/src/stock/detail/interface/stock-detail-chart.interface.ts new file mode 100644 index 00000000..e49746e9 --- /dev/null +++ b/BE/src/stock/detail/interface/stock-detail-chart.interface.ts @@ -0,0 +1,56 @@ +export interface InquirePriceOutput1Data { + prdy_vrss: string; + prdy_vrss_sign: string; + prdy_ctrt: string; + stck_prdy_clpr: string; + acml_vol: string; + acml_tr_pbmn: string; + hts_kor_isnm: string; + stck_prpr: string; + stck_shrn_iscd: string; + prdy_vol: string; + stck_mxpr: string; + stck_llam: string; + stck_oprc: string; + stck_hgpr: string; + stck_lwpr: string; + stck_prdy_oprc: string; + stck_prdy_hgpr: string; + stck_prdy_lwpr: string; + askp: string; + bidp: string; + prdy_vrss_vol: string; + vol_tnrt: string; + stck_fcam: string; + lstn_stcn: string; + cpfn: string; + hts_avls: string; + per: string; + eps: string; + pbr: string; + itewhol_loan_rmnd_ratem_name: string; +} + +export interface InquirePriceOutput2Data { + 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 interface InquirePriceChartApiResponse { + output1: InquirePriceOutput1Data; + output2: InquirePriceOutput2Data[]; + rt_cd: string; + msg_cd: string; + msg1: string; +} diff --git a/BE/src/stock/detail/interface/stock-detail.interface.ts b/BE/src/stock/detail/interface/stock-detail.interface.ts new file mode 100644 index 00000000..1cef7848 --- /dev/null +++ b/BE/src/stock/detail/interface/stock-detail.interface.ts @@ -0,0 +1,84 @@ +export interface InquirePriceOutputData { + iscd_stat_cls_code: string; + marg_rate: string; + rprs_mrkt_kor_name: string; + new_hgpr_lwpr_cls_code: string; + btsp_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; + 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; + apprch_rate: 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 interface InquirePriceApiResponse { + output: InquirePriceOutputData; + rt_cd: string; + msg_cd: string; + msg1: string; +} diff --git a/BE/src/stock/detail/stock-detail.controller.ts b/BE/src/stock/detail/stock-detail.controller.ts new file mode 100644 index 00000000..5df8d6c7 --- /dev/null +++ b/BE/src/stock/detail/stock-detail.controller.ts @@ -0,0 +1,71 @@ +import { Body, Controller, Get, Param, Post } from '@nestjs/common'; +import { + ApiBody, + ApiOperation, + ApiParam, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { StockDetailService } from './stock-detail.service'; +import { InquirePriceResponseDto } from './dto/stock-detail-response.dto'; +import { StockDetailChartRequestDto } from './dto/stock-detail-chart-request.dto'; +import { InquirePriceChartResponseDto } from './dto/stock-detail-chart-response.dto'; + +@ApiTags('특정 주식 종목에 대한 detail 페이지 조회 API') +@Controller('/api/stocks/detail') +export class StockDetailController { + constructor(private readonly stockDetailService: StockDetailService) {} + + @Get(':stockCode') + @ApiOperation({ summary: '단일 주식 종목 detail 페이지 상단부 조회 API' }) + @ApiParam({ + name: 'stockCode', + required: true, + description: + '종목 코드\n' + + '(ex) 005930 삼성전자 / 005380 현대차 / 001500 현대차증권', + }) + @ApiResponse({ + status: 200, + description: '단일 주식 종목 기본값 조회 성공', + type: InquirePriceResponseDto, + }) + getStockDetail(@Param('stockCode') stockCode: string) { + return this.stockDetailService.getInquirePrice(stockCode); + } + + @Post(':stockCode') + @ApiOperation({ summary: '국내주식기간별시세(일/주/월/년) 조회 API' }) + @ApiParam({ + name: 'stockCode', + required: true, + description: + '종목 코드\n\n' + + '(ex) 005930 삼성전자 / 005380 현대차 / 001500 현대차증권', + }) + @ApiBody({ + description: + '주식 상세 조회에 필요한 데이터\n\n' + + 'fid_input_date_1: 조회 시작일자 (ex) 20240505\n\n' + + 'fid_input_date_2: 조회 종료일자 (ex) 20241111\n\n' + + 'fid_period_div_code: 기간 분류 코드 (ex) D(일봉), W(주봉), M(월봉), Y(년봉)', + type: StockDetailChartRequestDto, + }) + @ApiResponse({ + status: 201, + description: '국내주식기간별시세(일/주/월/년) 조회 성공', + type: InquirePriceChartResponseDto, + }) + getStockDetailChart( + @Param('stockCode') stockCode: string, + @Body() body: StockDetailChartRequestDto, + ) { + const { fid_input_date_1, fid_input_date_2, fid_period_div_code } = body; + return this.stockDetailService.getInquirePriceChart( + stockCode, + fid_input_date_1, + fid_input_date_2, + fid_period_div_code, + ); + } +} diff --git a/BE/src/stock/detail/stock-detail.entity.ts b/BE/src/stock/detail/stock-detail.entity.ts new file mode 100644 index 00000000..37182a41 --- /dev/null +++ b/BE/src/stock/detail/stock-detail.entity.ts @@ -0,0 +1,13 @@ +import { BaseEntity, Column, Entity, PrimaryColumn } from 'typeorm'; + +@Entity() +export class Stocks extends BaseEntity { + @PrimaryColumn() + code: string; + + @Column() + name: string; + + @Column() + market: string; +} diff --git a/BE/src/stock/detail/stock-detail.module.ts b/BE/src/stock/detail/stock-detail.module.ts new file mode 100644 index 00000000..b6447a76 --- /dev/null +++ b/BE/src/stock/detail/stock-detail.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { KoreaInvestmentModule } from '../../koreaInvestment/korea-investment.module'; +import { StockDetailController } from './stock-detail.controller'; +import { StockDetailService } from './stock-detail.service'; +import { StockDetailRepository } from './stock-detail.repository'; +import { Stocks } from './stock-detail.entity'; + +@Module({ + imports: [KoreaInvestmentModule, TypeOrmModule.forFeature([Stocks])], + controllers: [StockDetailController], + providers: [StockDetailService, StockDetailRepository], +}) +export class StockDetailModule {} diff --git a/BE/src/stock/detail/stock-detail.repository.ts b/BE/src/stock/detail/stock-detail.repository.ts new file mode 100644 index 00000000..d9143719 --- /dev/null +++ b/BE/src/stock/detail/stock-detail.repository.ts @@ -0,0 +1,15 @@ +import { InjectDataSource } from '@nestjs/typeorm'; +import { Injectable } from '@nestjs/common'; +import { DataSource, Repository } from 'typeorm'; +import { Stocks } from './stock-detail.entity'; + +@Injectable() +export class StockDetailRepository extends Repository { + constructor(@InjectDataSource() dataSource: DataSource) { + super(Stocks, dataSource.createEntityManager()); + } + + async findOneByCode(code: string) { + return this.findOne({ where: { code } }); + } +} diff --git a/BE/src/stock/detail/stock-detail.service.ts b/BE/src/stock/detail/stock-detail.service.ts new file mode 100644 index 00000000..8114a394 --- /dev/null +++ b/BE/src/stock/detail/stock-detail.service.ts @@ -0,0 +1,198 @@ +import axios from 'axios'; +import { Injectable, Logger } from '@nestjs/common'; +import { KoreaInvestmentService } from '../../koreaInvestment/korea-investment.service'; +import { getHeader } from '../../util/get-header'; +import { getFullURL } from '../../util/get-full-URL'; +import { InquirePriceChartApiResponse } from './interface/stock-detail-chart.interface'; +import { InquirePriceChartDataDto } from './dto/stock-detail-chart-data.dto'; +import { + InquirePriceApiResponse, + InquirePriceOutputData, +} from './interface/stock-detail.interface'; +import { InquirePriceResponseDto } from './dto/stock-detail-response.dto'; +import { StockDetailRepository } from './stock-detail.repository'; + +@Injectable() +export class StockDetailService { + private readonly logger = new Logger(); + + constructor( + private readonly koreaInvestmentService: KoreaInvestmentService, + private readonly stockDetailRepository: StockDetailRepository, + ) {} + + /** + * 주식현재가 시세 데이터를 반환하는 함수 + * @param {string} stockCode - 종목코드 + * @returns - 주식현재가 시세 데이터 객체 반환 + * + * @author uuuo3o + */ + async getInquirePrice(stockCode: string) { + try { + const queryParams = { + fid_cond_mrkt_div_code: 'J', + fid_input_iscd: stockCode, + }; + + const response = await this.requestApi( + 'FHKST01010100', + '/uapi/domestic-stock/v1/quotations/inquire-price', + queryParams, + ); + + return await this.formatStockData(response.output); + } catch (error) { + this.logger.error('API Error Details:', { + status: error.response?.status, + statusText: error.response?.statusText, + data: error.response?.data, + headers: error.response?.config?.headers, // 실제 요청 헤더 + message: error.message, + }); + throw error; + } + } + + /** + * @private API에서 받은 주식현재가 시세 데이터를 필요한 정보로 정제하는 함수 + * @param {InquirePriceOutputData} stock - API 응답에서 받은 원시 데이터 + * @returns - 필요한 정보만 추출한 데이터 배열 + * + * @author uuuo3o + */ + private async formatStockData( + stock: InquirePriceOutputData, + ): Promise { + const { name } = await this.stockDetailRepository.findOneByCode( + stock.stck_shrn_iscd, + ); + + return { + hts_kor_isnm: name, + stck_shrn_iscd: stock.stck_shrn_iscd, + stck_prpr: stock.stck_prpr, + prdy_vrss: stock.prdy_vrss, + prdy_vrss_sign: stock.prdy_vrss_sign, + prdy_ctrt: stock.prdy_ctrt, + hts_avls: stock.hts_avls, + per: stock.per, + }; + } + + /** + * 특정 주식의 기간별시세 데이터를 반환하는 함수 + * @param {string} stockCode - 종목코드 + * @param {string} date1 - 조회 시작일자 + * @param {string} date2 - 조회 종료일자 + * @param {string} periodDivCode - 기간 분류 코드 + * @returns - 특정 주식의 기간별시세 데이터 객체 반환 + * + * @author uuuo3o + */ + async getInquirePriceChart( + stockCode: string, + date1: string, + date2: string, + periodDivCode: string, + ) { + try { + const queryParams = { + fid_cond_mrkt_div_code: 'J', + fid_input_iscd: stockCode, + fid_input_date_1: date1, + fid_input_date_2: date2, + fid_period_div_code: periodDivCode, + fid_org_adj_prc: '0', + }; + + const response = await this.requestApi( + 'FHKST03010100', + '/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice', + queryParams, + ); + + return this.formatStockInquirePriceData(response); + } catch (error) { + this.logger.error('API Error Details:', { + status: error.response?.status, + statusText: error.response?.statusText, + data: error.response?.data, + headers: error.response?.config?.headers, // 실제 요청 헤더 + message: error.message, + }); + throw error; + } + } + + /** + * @private API에서 받은 국내주식기간별시세(일/주/월/년) 데이터를 필요한 정보로 정제하는 함수 + * @param {InquirePriceApiResponse} response - API 응답에서 받은 원시 데이터 + * @returns - 필요한 정보만 추출한 데이터 배열 + * + * @author uuuo3o + */ + private formatStockInquirePriceData(response: InquirePriceChartApiResponse) { + const { output2 } = response; + + return output2.map((info) => { + const stockData = new InquirePriceChartDataDto(); + const { + stck_bsop_date, + stck_clpr, + stck_oprc, + stck_hgpr, + stck_lwpr, + acml_vol, + prdy_vrss_sign, + } = info; + + stockData.stck_bsop_date = stck_bsop_date; + stockData.stck_clpr = stck_clpr; + stockData.stck_oprc = stck_oprc; + stockData.stck_hgpr = stck_hgpr; + stockData.stck_lwpr = stck_lwpr; + stockData.acml_vol = acml_vol; + stockData.prdy_vrss_sign = prdy_vrss_sign; + + return stockData; + }); + } + + /** + * @private 한국투자 Open API - API 호출용 공통 함수 + * @param {string} trId - API 호출에 사용할 tr_id + * @param {string} apiURL - API 호출에 사용할 URL + * @param {Record} params - API 요청 시 필요한 쿼리 파라미터 DTO + * @returns - API 호출에 대한 응답 데이터 + * + * @author uuuo3o + */ + private async requestApi( + trId: string, + apiURL: string, + params: Record, + ): Promise { + try { + const accessToken = await this.koreaInvestmentService.getAccessToken(); + const headers = getHeader(accessToken, trId); + const url = getFullURL(apiURL); + + const response = await axios.get(url, { + headers, + params, + }); + + return response.data; + } catch (error) { + this.logger.error('API Error Details:', { + status: error.response?.status, + statusText: error.response?.statusText, + data: error.response?.data, + headers: error.response?.config?.headers, + message: error.message, + }); + throw error; + } + } +} diff --git a/BE/src/stock/enum/MarketType.ts b/BE/src/stock/enum/market-type.ts similarity index 100% rename from BE/src/stock/enum/MarketType.ts rename to BE/src/stock/enum/market-type.ts diff --git a/BE/src/stock/index/dto/stock.index.list.chart.element.dto.ts b/BE/src/stock/index/dto/stock-index-list-chart.element.dto.ts similarity index 64% rename from BE/src/stock/index/dto/stock.index.list.chart.element.dto.ts rename to BE/src/stock/index/dto/stock-index-list-chart.element.dto.ts index 1f2abdce..ba6ce1f2 100644 --- a/BE/src/stock/index/dto/stock.index.list.chart.element.dto.ts +++ b/BE/src/stock/index/dto/stock-index-list-chart.element.dto.ts @@ -1,9 +1,10 @@ import { ApiProperty } from '@nestjs/swagger'; export class StockIndexListChartElementDto { - constructor(time: string, value: string) { + constructor(time: string, value: string, diff: string) { this.time = time; this.value = value; + this.diff = diff; } @ApiProperty({ description: 'HHMMSS', example: '130500' }) @@ -11,4 +12,7 @@ export class StockIndexListChartElementDto { @ApiProperty({ description: '주가 지수' }) value: string; + + @ApiProperty({ description: '전일 대비 주가 지수' }) + diff: string; } diff --git a/BE/src/stock/index/dto/stock-index-response-element.dto.ts b/BE/src/stock/index/dto/stock-index-response-element.dto.ts new file mode 100644 index 00000000..79cf6af5 --- /dev/null +++ b/BE/src/stock/index/dto/stock-index-response-element.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { StockIndexValueElementDto } from './stock-index-value-element.dto'; +import { StockIndexListChartElementDto } from './stock-index-list-chart.element.dto'; + +export class StockIndexResponseElementDto { + @ApiProperty({ description: '실시간 값', type: StockIndexValueElementDto }) + value: StockIndexValueElementDto; + + @ApiProperty({ + description: '실시간 차트', + type: [StockIndexListChartElementDto], + }) + chart: StockIndexListChartElementDto[]; +} diff --git a/BE/src/stock/index/dto/stock.index.response.dto.ts b/BE/src/stock/index/dto/stock-index-response.dto.ts similarity index 88% rename from BE/src/stock/index/dto/stock.index.response.dto.ts rename to BE/src/stock/index/dto/stock-index-response.dto.ts index 7f26af32..026fecf3 100644 --- a/BE/src/stock/index/dto/stock.index.response.dto.ts +++ b/BE/src/stock/index/dto/stock-index-response.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { StockIndexResponseElementDto } from './stock.index.response.element.dto'; +import { StockIndexResponseElementDto } from './stock-index-response-element.dto'; export class StockIndexResponseDto { @ApiProperty({ diff --git a/BE/src/stock/index/dto/stock.index.value.element.dto.ts b/BE/src/stock/index/dto/stock-index-value-element.dto.ts similarity index 54% rename from BE/src/stock/index/dto/stock.index.value.element.dto.ts rename to BE/src/stock/index/dto/stock-index-value-element.dto.ts index 88ca4949..1eb73a50 100644 --- a/BE/src/stock/index/dto/stock.index.value.element.dto.ts +++ b/BE/src/stock/index/dto/stock-index-value-element.dto.ts @@ -1,33 +1,21 @@ import { ApiProperty } from '@nestjs/swagger'; export class StockIndexValueElementDto { - constructor( - code: string, - value: string, - diff: string, - diffRate: string, - sign: string, - ) { - this.code = code; - this.value = value; + constructor(value: string, diff: string, diffRate: string, sign: string) { + this.curr_value = value; this.diff = diff; - this.diffRate = diffRate; + this.diff_rate = diffRate; this.sign = sign; } - @ApiProperty({ - description: '코스피: 0001, 코스닥: 1001, 코스피200: 2001, KSQ150: 3003', - }) - code: string; - @ApiProperty({ description: '주가 지수' }) - value: string; + curr_value: string; @ApiProperty({ description: '전일 대비 등락' }) diff: string; @ApiProperty({ description: '전일 대비 등락률' }) - diffRate: string; + diff_rate: string; @ApiProperty({ description: '부호... 인데 추후에 알아봐야 함' }) sign: string; diff --git a/BE/src/stock/index/dto/stock.index.list.element.dto.ts b/BE/src/stock/index/dto/stock.index.list.element.dto.ts deleted file mode 100644 index 03ec0176..00000000 --- a/BE/src/stock/index/dto/stock.index.list.element.dto.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { StockIndexListChartElementDto } from './stock.index.list.chart.element.dto'; - -export class StockIndexListElementDto { - constructor(code: string, chart: StockIndexListChartElementDto[]) { - this.code = code; - this.chart = chart; - } - - @ApiProperty({ - description: '코스피: 0001, 코스닥: 1001, 코스피200: 2001, KSQ150: 3003', - }) - code: string; - - @ApiProperty({ type: [StockIndexListChartElementDto] }) - chart: StockIndexListChartElementDto[]; -} diff --git a/BE/src/stock/index/dto/stock.index.response.element.dto.ts b/BE/src/stock/index/dto/stock.index.response.element.dto.ts deleted file mode 100644 index 7ce0ef1a..00000000 --- a/BE/src/stock/index/dto/stock.index.response.element.dto.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { StockIndexValueElementDto } from './stock.index.value.element.dto'; -import { StockIndexListElementDto } from './stock.index.list.element.dto'; - -export class StockIndexResponseElementDto { - @ApiProperty({ - description: '코스피: 0001, 코스닥: 1001, 코스피200: 2001, KSQ150: 3003', - }) - code: string; - - @ApiProperty({ description: '실시간 값', type: StockIndexValueElementDto }) - value: StockIndexValueElementDto; - - @ApiProperty({ description: '실시간 차트', type: StockIndexListElementDto }) - chart: StockIndexListElementDto; -} diff --git a/BE/src/stock/index/interface/stock.index.interface.ts b/BE/src/stock/index/interface/stock-index.interface.ts similarity index 100% rename from BE/src/stock/index/interface/stock.index.interface.ts rename to BE/src/stock/index/interface/stock-index.interface.ts diff --git a/BE/src/stock/index/stock-index-socket.service.ts b/BE/src/stock/index/stock-index-socket.service.ts new file mode 100644 index 00000000..f82ba9a2 --- /dev/null +++ b/BE/src/stock/index/stock-index-socket.service.ts @@ -0,0 +1,42 @@ +import { Injectable } from '@nestjs/common'; +import { StockIndexValueElementDto } from './dto/stock-index-value-element.dto'; +import { BaseSocketService } from '../../websocket/base-socket.service'; +import { SocketGateway } from '../../websocket/socket.gateway'; + +@Injectable() +export class StockIndexSocketService { + private TR_ID = 'H0UPCNT0'; + private STOCK_CODE = { + '0001': 'KOSPI', + '1001': 'KOSDAQ', + '2001': 'KOSPI200', + '3003': 'KSQ150', + }; + + constructor( + private readonly socketGateway: SocketGateway, + private readonly baseSocketService: BaseSocketService, + ) { + baseSocketService.registerSocketOpenHandler(() => { + this.baseSocketService.registerCode(this.TR_ID, '0001'); // 코스피 + this.baseSocketService.registerCode(this.TR_ID, '1001'); // 코스닥 + this.baseSocketService.registerCode(this.TR_ID, '2001'); // 코스피200 + this.baseSocketService.registerCode(this.TR_ID, '3003'); // KSQ150 + }); + + baseSocketService.registerSocketDataHandler( + this.TR_ID, + (data: string[]) => { + this.socketGateway.sendStockIndexValueToClient( + this.STOCK_CODE[data[0]], + new StockIndexValueElementDto( + data[2], // 주가 지수 + data[4], // 전일 대비 등락 + data[9], // 전일 대비 등락률 + data[3], // 부호 + ), + ); + }, + ); + } +} diff --git a/BE/src/stock/index/stock.index.controller.ts b/BE/src/stock/index/stock-index.controller.ts similarity index 91% rename from BE/src/stock/index/stock.index.controller.ts rename to BE/src/stock/index/stock-index.controller.ts index 949df5a5..330685e2 100644 --- a/BE/src/stock/index/stock.index.controller.ts +++ b/BE/src/stock/index/stock-index.controller.ts @@ -1,9 +1,9 @@ import { Controller, Get } from '@nestjs/common'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { Cron } from '@nestjs/schedule'; -import { StockIndexService } from './stock.index.service'; -import { StockIndexResponseDto } from './dto/stock.index.response.dto'; -import { KoreaInvestmentService } from '../../koreaInvestment/korea.investment.service'; +import { StockIndexService } from './stock-index.service'; +import { StockIndexResponseDto } from './dto/stock-index-response.dto'; +import { KoreaInvestmentService } from '../../koreaInvestment/korea-investment.service'; import { SocketGateway } from '../../websocket/socket.gateway'; @Controller('/api/stocks/index') @@ -70,22 +70,18 @@ export class StockIndexController { const stockIndexResponse = new StockIndexResponseDto(); stockIndexResponse.KOSPI = { - code: '0001', value: kospiValue, chart: kospiChart, }; stockIndexResponse.KOSDAQ = { - code: '1001', value: kosdaqValue, chart: kosdaqChart, }; stockIndexResponse.KOSPI200 = { - code: '2001', value: kospi200Value, chart: kospi200Chart, }; stockIndexResponse.KSQ150 = { - code: '3003', value: ksq150Value, chart: ksq150Chart, }; @@ -115,6 +111,11 @@ export class StockIndexController { ), // KSQ150 ]); - this.socketGateway.sendStockIndexListToClient(stockLists); + this.socketGateway.sendStockIndexListToClient({ + KOSPI: stockLists[0], + KOSDAQ: stockLists[1], + KOSPI200: stockLists[2], + KSQ150: stockLists[3], + }); } } diff --git a/BE/src/stock/index/stock.index.module.ts b/BE/src/stock/index/stock-index.module.ts similarity index 52% rename from BE/src/stock/index/stock.index.module.ts rename to BE/src/stock/index/stock-index.module.ts index 1227e4f7..d763d972 100644 --- a/BE/src/stock/index/stock.index.module.ts +++ b/BE/src/stock/index/stock-index.module.ts @@ -1,13 +1,13 @@ import { Module } from '@nestjs/common'; -import { StockIndexController } from './stock.index.controller'; -import { StockIndexService } from './stock.index.service'; -import { KoreaInvestmentModule } from '../../koreaInvestment/korea.investment.module'; +import { StockIndexController } from './stock-index.controller'; +import { StockIndexService } from './stock-index.service'; +import { KoreaInvestmentModule } from '../../koreaInvestment/korea-investment.module'; import { SocketModule } from '../../websocket/socket.module'; +import { StockIndexSocketService } from './stock-index-socket.service'; @Module({ imports: [KoreaInvestmentModule, SocketModule], controllers: [StockIndexController], - providers: [StockIndexService], - exports: [StockIndexService], + providers: [StockIndexService, StockIndexSocketService], }) export class StockIndexModule {} diff --git a/BE/src/stock/index/stock-index.service.ts b/BE/src/stock/index/stock-index.service.ts new file mode 100644 index 00000000..fa05ed4c --- /dev/null +++ b/BE/src/stock/index/stock-index.service.ts @@ -0,0 +1,95 @@ +import { Injectable, InternalServerErrorException } from '@nestjs/common'; +import axios from 'axios'; +import { StockIndexListChartElementDto } from './dto/stock-index-list-chart.element.dto'; +import { StockIndexValueElementDto } from './dto/stock-index-value-element.dto'; +import { + StockIndexChartInterface, + StockIndexValueInterface, +} from './interface/stock-index.interface'; +import { getFullURL } from '../../util/get-full-URL'; +import { getHeader } from '../../util/get-header'; + +@Injectable() +export class StockIndexService { + async getDomesticStockIndexListByCode(code: string, accessToken: string) { + const result = await this.requestDomesticStockIndexListApi( + code, + accessToken, + ); + + return result.output.map((element) => { + return new StockIndexListChartElementDto( + element.bsop_hour, + element.bstp_nmix_prpr, + element.bstp_nmix_prdy_vrss, + ); + }); + } + + async getDomesticStockIndexValueByCode(code: string, accessToken: string) { + const result = await this.requestDomesticStockIndexValueApi( + code, + accessToken, + ); + + const data = result.output; + + return new StockIndexValueElementDto( + data.bstp_nmix_prpr, + data.bstp_nmix_prdy_vrss, + data.bstp_nmix_prdy_ctrt, + data.prdy_vrss_sign, + ); + } + + private async requestDomesticStockIndexListApi( + code: string, + accessToken: string, + ) { + const response = await axios + .get( + getFullURL( + '/uapi/domestic-stock/v1/quotations/inquire-index-timeprice', + ), + { + headers: getHeader(accessToken, 'FHPUP02110200'), + params: { + fid_input_hour_1: 300, + fid_cond_mrkt_div_code: 'U', + fid_input_iscd: code, + }, + }, + ) + .catch(() => { + throw new InternalServerErrorException( + '주가 지수 차트 정보를 조회하지 못했습니다.', + ); + }); + + return response.data; + } + + private async requestDomesticStockIndexValueApi( + code: string, + accessToken: string, + ) { + const response = await axios + .get( + getFullURL('/uapi/domestic-stock/v1/quotations/inquire-index-price'), + { + headers: getHeader(accessToken, 'FHPUP02100000'), + params: { + fid_cond_mrkt_div_code: 'U', + fid_input_iscd: code, + }, + }, + ) + .catch(() => { + throw new InternalServerErrorException( + '주가 지수 값 정보를 조회하지 못했습니다.', + ); + }); + + return response.data; + } +} diff --git a/BE/src/stock/index/stock.index.service.ts b/BE/src/stock/index/stock.index.service.ts deleted file mode 100644 index 875be12d..00000000 --- a/BE/src/stock/index/stock.index.service.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import axios from 'axios'; -import { StockIndexListChartElementDto } from './dto/stock.index.list.chart.element.dto'; -import { StockIndexListElementDto } from './dto/stock.index.list.element.dto'; -import { StockIndexValueElementDto } from './dto/stock.index.value.element.dto'; -import { - StockIndexChartInterface, - StockIndexValueInterface, -} from './interface/stock.index.interface'; - -@Injectable() -export class StockIndexService { - async getDomesticStockIndexListByCode(code: string, accessToken: string) { - const result = await this.requestDomesticStockIndexListApi( - code, - accessToken, - ); - - if (result.rt_cd !== '0') - throw new Error('데이터를 정상적으로 조회하지 못했습니다.'); - - return new StockIndexListElementDto( - code, - result.output.map((element) => { - return new StockIndexListChartElementDto( - element.bsop_hour, - element.bstp_nmix_prpr, - ); - }), - ); - } - - async getDomesticStockIndexValueByCode(code: string, accessToken: string) { - const result = await this.requestDomesticStockIndexValueApi( - code, - accessToken, - ); - - if (result.rt_cd !== '0') - throw new Error('데이터를 정상적으로 조회하지 못했습니다.'); - - return new StockIndexValueElementDto( - code, - result.output.bstp_nmix_prpr, - result.output.bstp_nmix_prdy_vrss, - result.output.bstp_nmix_prdy_ctrt, - result.output.prdy_vrss_sign, - ); - } - - private async requestDomesticStockIndexListApi( - code: string, - accessToken: string, - ) { - const response = await axios.get( - `${process.env.KOREA_INVESTMENT_BASE_URL}/uapi/domestic-stock/v1/quotations/inquire-index-timeprice`, - { - headers: { - 'content-type': 'application/json; charset=utf-8', - authorization: `Bearer ${accessToken}`, - appkey: process.env.KOREA_INVESTMENT_APP_KEY, - appsecret: process.env.KOREA_INVESTMENT_APP_SECRET, - tr_id: 'FHPUP02110200', - custtype: 'P', - }, - params: { - fid_input_hour_1: 300, - fid_cond_mrkt_div_code: 'U', - fid_input_iscd: code, - }, - }, - ); - - return response.data; - } - - private async requestDomesticStockIndexValueApi( - code: string, - accessToken: string, - ) { - const response = await axios.get( - `${process.env.KOREA_INVESTMENT_BASE_URL}/uapi/domestic-stock/v1/quotations/inquire-index-price`, - { - headers: { - 'content-type': 'application/json; charset=utf-8', - authorization: `Bearer ${accessToken}`, - appkey: process.env.KOREA_INVESTMENT_APP_KEY, - appsecret: process.env.KOREA_INVESTMENT_APP_SECRET, - tr_id: 'FHPUP02100000', - custtype: 'P', - }, - params: { - fid_cond_mrkt_div_code: 'U', - fid_input_iscd: code, - }, - }, - ); - - return response.data; - } -} diff --git a/BE/src/stock/list/dto/stock-list-response.dto.ts b/BE/src/stock/list/dto/stock-list-response.dto.ts new file mode 100644 index 00000000..d7df39b4 --- /dev/null +++ b/BE/src/stock/list/dto/stock-list-response.dto.ts @@ -0,0 +1,22 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class StockListResponseDto { + @ApiProperty({ example: '005930', description: '종목 코드', required: false }) + code: string; + + @ApiProperty({ + example: '삼성전자', + description: '종목 이름', + required: false, + }) + name: string; + + @ApiProperty({ example: 'KOSPI', description: '시장', required: false }) + market: string; + + constructor(code: string, name: string, market: string) { + this.code = code; + this.name = name; + this.market = market; + } +} diff --git a/BE/src/stock/list/interface/search-params.interface.ts b/BE/src/stock/list/interface/search-params.interface.ts new file mode 100644 index 00000000..2f0b205b --- /dev/null +++ b/BE/src/stock/list/interface/search-params.interface.ts @@ -0,0 +1,6 @@ +export interface SearchParams { + name?: string; + market?: string; + code?: string; + userId?: string; +} diff --git a/BE/src/stock/list/stock-list.controller.ts b/BE/src/stock/list/stock-list.controller.ts new file mode 100644 index 00000000..4db69192 --- /dev/null +++ b/BE/src/stock/list/stock-list.controller.ts @@ -0,0 +1,101 @@ +import { Controller, Get, Post, Query, Req, UseGuards } from '@nestjs/common'; +import { + ApiBearerAuth, + ApiBody, + ApiOperation, + ApiQuery, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { Request } from 'express'; +import { JwtAuthGuard } from 'src/auth/jwt-auth-guard'; +import { StockListService } from './stock-list.service'; +import { StockListResponseDto } from './dto/stock-list-response.dto'; + +@ApiTags('주식 리스트 API') +@Controller('/api/stocks/list') +export class StockListController { + constructor(private readonly stockListService: StockListService) {} + + @ApiOperation({ + summary: '전체 주식 종목 조회 API', + description: '모든 주식 종목 리스트를 조회한다.', + }) + @Get() + async findAll(): Promise { + return this.stockListService.findAll(); + } + + @ApiOperation({ + summary: '주식 목록 검색 API', + description: + '주식 종목을 검색한다. name, market, code로 검색을 진행할 수 있다.', + }) + @ApiResponse({ + status: 200, + description: '주식 검색 성공', + type: StockListResponseDto, + isArray: true, + }) + @ApiQuery({ name: 'name', required: false }) + @ApiQuery({ name: 'market', required: false }) + @ApiQuery({ name: 'code', required: false }) + @Get('/search') + async searchWithQuery( + @Query('name') name?: string, + @Query('market') market?: string, + @Query('code') code?: string, + ): Promise { + return this.stockListService.search({ name, market, code }); + } + + @ApiOperation({ + summary: '특정 주식 종목 조회 API', + description: 'code를 이용해 특정 주식 정보를 조회한다.', + }) + @ApiResponse({ + status: 200, + description: 'code를 이용한 주식 조회 성공', + type: StockListResponseDto, + }) + @Get('/:code') + async findOne(@Query('code') code: string): Promise { + return this.stockListService.findOne(code); + } + + @Post('/search/addHistory') + @UseGuards(JwtAuthGuard) + @ApiOperation({ + summary: '검색어 히스토리 추가 API', + description: '특정 유저의 검색어 히스토리를 추가한다.', + }) + @ApiBody({ + schema: { + type: 'object', + properties: { + searchTerm: { type: 'string' }, + }, + }, + }) + @ApiBearerAuth() + async addSearchHistory(@Req() req: Request) { + const { searchTerm } = req.body; + const userId = parseInt(req.user.userId, 10); + await this.stockListService.addSearchTermToRedis({ + searchTerm, + userId, + }); + } + + @Get('/search/getHistory') + @UseGuards(JwtAuthGuard) + @ApiOperation({ + summary: '검색어 히스토리 조회 API', + description: '특정 유저의 검색어 히스토리를 조회한다.', + }) + @ApiBearerAuth() + async getSearchHistory(@Req() req: Request) { + const { userId } = req.user; + return this.stockListService.getSearchTermFromRedis(userId); + } +} diff --git a/BE/src/stock/list/stock-list.entity.ts b/BE/src/stock/list/stock-list.entity.ts new file mode 100644 index 00000000..37182a41 --- /dev/null +++ b/BE/src/stock/list/stock-list.entity.ts @@ -0,0 +1,13 @@ +import { BaseEntity, Column, Entity, PrimaryColumn } from 'typeorm'; + +@Entity() +export class Stocks extends BaseEntity { + @PrimaryColumn() + code: string; + + @Column() + name: string; + + @Column() + market: string; +} diff --git a/BE/src/stock/list/stock-list.module.ts b/BE/src/stock/list/stock-list.module.ts new file mode 100644 index 00000000..73243bb9 --- /dev/null +++ b/BE/src/stock/list/stock-list.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { RedisDomainService } from 'src/common/redis/redis.domain-service'; +import { RedisModule } from 'src/common/redis/redis.module'; +import { StockListRepository } from './stock-list.repostiory'; +import { StockListService } from './stock-list.service'; +import { StockListController } from './stock-list.controller'; +import { Stocks } from './stock-list.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([Stocks]), RedisModule], + controllers: [StockListController], + providers: [StockListRepository, StockListService, RedisDomainService], + exports: [], +}) +export class StockListModule {} diff --git a/BE/src/stock/list/stock-list.repostiory.ts b/BE/src/stock/list/stock-list.repostiory.ts new file mode 100644 index 00000000..2bad5fa9 --- /dev/null +++ b/BE/src/stock/list/stock-list.repostiory.ts @@ -0,0 +1,36 @@ +import { Injectable } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource, Repository } from 'typeorm'; +import { Stocks } from './stock-list.entity'; +import { SearchParams } from './interface/search-params.interface'; + +@Injectable() +export class StockListRepository extends Repository { + constructor(@InjectDataSource() dataSource: DataSource) { + super(Stocks, dataSource.createEntityManager()); + } + + async findAllStocks() { + return this.find(); + } + + async findOneStock(code: string): Promise { + return this.findOne({ where: { code } }); + } + + async search(params: SearchParams): Promise { + const queryBuilder = this.createQueryBuilder(); + if (params.name) { + queryBuilder.where('name LIKE :name', { name: `%${params.name}%` }); + } + if (params.market) { + queryBuilder.andWhere('market LIKE :market', { + market: `%${params.market}%`, + }); + } + if (params.code) { + queryBuilder.andWhere('code LIKE :code', { code: `%${params.code}%` }); + } + return queryBuilder.getMany(); + } +} diff --git a/BE/src/stock/list/stock-list.service.ts b/BE/src/stock/list/stock-list.service.ts new file mode 100644 index 00000000..7e4c4956 --- /dev/null +++ b/BE/src/stock/list/stock-list.service.ts @@ -0,0 +1,65 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { RedisDomainService } from 'src/common/redis/redis.domain-service'; +import { StockListRepository } from './stock-list.repostiory'; +import { Stocks } from './stock-list.entity'; +import { StockListResponseDto } from './dto/stock-list-response.dto'; +import { SearchParams } from './interface/search-params.interface'; + +@Injectable() +export class StockListService { + private readonly SearchHistoryLimit = 10; + + constructor( + private readonly stockListRepository: StockListRepository, + private readonly redisDomainService: RedisDomainService, + ) {} + + private toResponseDto(stock: Stocks): StockListResponseDto { + return new StockListResponseDto(stock.code, stock.name, stock.market); + } + + async findAll() { + const stocks = await this.stockListRepository.findAllStocks(); + return stocks.map((stock) => this.toResponseDto(stock)); + } + + async findOne(code: string) { + const stock = await this.stockListRepository.findOneStock(code); + + if (!stock) { + throw new NotFoundException(`Stock with code ${code} not found`); + } + return this.toResponseDto(stock); + } + + async search(params: SearchParams): Promise { + const stocks = await this.stockListRepository.search(params); + return stocks.map((stock) => this.toResponseDto(stock)); + } + + async addSearchTermToRedis(searchInfo: { + userId: number; + searchTerm: string; + }) { + const { userId, searchTerm } = searchInfo; + const key = `search:${userId}`; + const timeStamp = Date.now(); + + await this.redisDomainService.zadd(key, timeStamp, searchTerm); + + const searchHistoryCount = await this.redisDomainService.zcard(key); + if (searchHistoryCount > this.SearchHistoryLimit) { + await this.redisDomainService.zremrangebyrank(key, 0, 0); + } + } + + async getSearchTermFromRedis(userId: string): Promise { + const key = `search:${userId}`; + + return this.redisDomainService.zrevrange( + key, + 0, + this.SearchHistoryLimit - 1, + ); + } +} diff --git a/BE/src/stock/order/dto/stock-order-request.dto.ts b/BE/src/stock/order/dto/stock-order-request.dto.ts new file mode 100644 index 00000000..407d05ad --- /dev/null +++ b/BE/src/stock/order/dto/stock-order-request.dto.ts @@ -0,0 +1,17 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsInt, IsNumber, IsPositive } from 'class-validator'; + +export class StockOrderRequestDto { + @ApiProperty({ description: '주식 id', example: '005930' }) + stock_code: string; + + @ApiProperty({ description: '매수/매도 희망 가격' }) + @IsNumber() + @IsPositive() + price: number; + + @ApiProperty({ description: '매수/매도 희망 수량' }) + @IsInt() + @IsPositive() + amount: number; +} diff --git a/BE/src/stock/order/enum/status-type.ts b/BE/src/stock/order/enum/status-type.ts new file mode 100644 index 00000000..04b3caeb --- /dev/null +++ b/BE/src/stock/order/enum/status-type.ts @@ -0,0 +1,4 @@ +export enum StatusType { + PENDING = 'PENDING', + COMPLETE = 'COMPLETE', +} diff --git a/BE/src/stock/order/enum/trade-type.ts b/BE/src/stock/order/enum/trade-type.ts new file mode 100644 index 00000000..c07270e3 --- /dev/null +++ b/BE/src/stock/order/enum/trade-type.ts @@ -0,0 +1,4 @@ +export enum TradeType { + SELL = 'SELL', + BUY = 'BUY', +} diff --git a/BE/src/stock/order/interface/request.interface.ts b/BE/src/stock/order/interface/request.interface.ts new file mode 100644 index 00000000..d7616da0 --- /dev/null +++ b/BE/src/stock/order/interface/request.interface.ts @@ -0,0 +1,9 @@ +export interface RequestInterface { + user: { + id: number; + email: string; + password: string; + tutorial: boolean; + kakaoId: number; + }; +} diff --git a/BE/src/stock/order/stock-order-socket.service.ts b/BE/src/stock/order/stock-order-socket.service.ts new file mode 100644 index 00000000..6e2bec3b --- /dev/null +++ b/BE/src/stock/order/stock-order-socket.service.ts @@ -0,0 +1,119 @@ +import { + Injectable, + InternalServerErrorException, + Logger, +} from '@nestjs/common'; +import { LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; +import { BaseSocketService } from '../../websocket/base-socket.service'; +import { SocketGateway } from '../../websocket/socket.gateway'; +import { Order } from './stock-order.entity'; +import { TradeType } from './enum/trade-type'; +import { StatusType } from './enum/status-type'; +import { StockOrderRepository } from './stock-order.repository'; + +@Injectable() +export class StockOrderSocketService { + private TR_ID = 'H0STCNT0'; + + private readonly logger = new Logger(); + + constructor( + private readonly socketGateway: SocketGateway, + private readonly baseSocketService: BaseSocketService, + private readonly stockOrderRepository: StockOrderRepository, + ) { + baseSocketService.registerSocketOpenHandler(async () => { + const orders: Order[] = + await this.stockOrderRepository.findAllCodeByStatus(); + orders.forEach((order) => { + baseSocketService.registerCode(this.TR_ID, order.stock_code); + }); + }); + + baseSocketService.registerSocketDataHandler( + this.TR_ID, + (data: string[]) => { + this.checkExecutableOrder( + data[0], // 주식 코드 + data[2], // 주식 체결가 + ).catch(() => { + throw new InternalServerErrorException(); + }); + }, + ); + } + + subscribeByCode(trKey: string) { + this.baseSocketService.registerCode(this.TR_ID, trKey); + } + + unsubscribeByCode(trKey: string) { + this.baseSocketService.unregisterCode(this.TR_ID, trKey); + } + + private async checkExecutableOrder(stockCode: string, value) { + const buyOrders = await this.stockOrderRepository.find({ + where: { + stock_code: stockCode, + trade_type: TradeType.BUY, + status: StatusType.PENDING, + price: MoreThanOrEqual(value), + }, + }); + + const sellOrders = await this.stockOrderRepository.find({ + where: { + stock_code: stockCode, + trade_type: TradeType.SELL, + status: StatusType.PENDING, + price: LessThanOrEqual(value), + }, + }); + + await Promise.all(buyOrders.map((buyOrder) => this.executeBuy(buyOrder))); + await Promise.all( + sellOrders.map((sellOrder) => this.executeSell(sellOrder)), + ); + + if ( + !(await this.stockOrderRepository.existsBy({ + stock_code: stockCode, + status: StatusType.PENDING, + })) + ) + this.unsubscribeByCode(stockCode); + } + + private async executeBuy(order) { + this.logger.log(`${order.id}번 매수 예약이 체결되었습니다.`, 'BUY'); + + const totalPrice = order.price * order.amount; + const fee = this.calculateFee(totalPrice); + await this.stockOrderRepository.updateOrderAndAssetAndUserStockWhenBuy( + order, + totalPrice + fee, + ); + } + + private async executeSell(order) { + this.logger.log(`${order.id}번 매도 예약이 체결되었습니다.`, 'SELL'); + + const totalPrice = order.price * order.amount; + const fee = this.calculateFee(totalPrice); + await this.stockOrderRepository.updateOrderAndAssetAndUserStockWhenSell( + order, + totalPrice - fee, + ); + } + + private calculateFee(totalPrice: number) { + if (totalPrice <= 10000000) return totalPrice * 0.16; + if (totalPrice > 10000000 && totalPrice <= 50000000) + return totalPrice * 0.14; + if (totalPrice > 50000000 && totalPrice <= 100000000) + return totalPrice * 0.12; + if (totalPrice > 100000000 && totalPrice <= 300000000) + return totalPrice * 0.1; + return totalPrice * 0.08; + } +} diff --git a/BE/src/stock/order/stock-order.controller.ts b/BE/src/stock/order/stock-order.controller.ts new file mode 100644 index 00000000..31960590 --- /dev/null +++ b/BE/src/stock/order/stock-order.controller.ts @@ -0,0 +1,80 @@ +import { + Body, + Controller, + Delete, + Param, + Post, + Req, + UseGuards, + ValidationPipe, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOperation, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { StockOrderService } from './stock-order.service'; +import { StockOrderRequestDto } from './dto/stock-order-request.dto'; +import { JwtAuthGuard } from '../../auth/jwt-auth-guard'; +import { RequestInterface } from './interface/request.interface'; + +@Controller('/api/stocks/trade') +@ApiTags('주식 매수/매도 API') +export class StockOrderController { + constructor(private readonly stockOrderService: StockOrderService) {} + + @Post('/buy') + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @ApiOperation({ + summary: '주식 매수 API', + description: '주식 id, 매수 가격, 수량으로 주식을 매수한다.', + }) + @ApiResponse({ + status: 201, + description: '주식 매수 예약 등록 성공', + }) + async buy( + @Req() request: RequestInterface, + @Body(ValidationPipe) stockOrderRequest: StockOrderRequestDto, + ) { + await this.stockOrderService.buy(request.user.id, stockOrderRequest); + } + + @Post('/sell') + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @ApiOperation({ + summary: '주식 매도 API', + description: '주식 id, 매도 가격, 수량으로 주식을 매도한다.', + }) + @ApiResponse({ + status: 201, + description: '주식 매도 예약 등록 성공', + }) + async sell( + @Req() request: RequestInterface, + @Body(ValidationPipe) stockOrderRequest: StockOrderRequestDto, + ) { + await this.stockOrderService.sell(request.user.id, stockOrderRequest); + } + + @Delete('/:order_id') + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @ApiOperation({ + summary: '주식 매도/매수 취소 API', + description: '주문 id로 미체결된 주문을 취소한다.', + }) + @ApiResponse({ + status: 200, + description: '주식 매도/매수 취소 성공', + }) + async cancel( + @Req() request: RequestInterface, + @Param('order_id') orderId: number, + ) { + await this.stockOrderService.cancel(request.user.id, orderId); + } +} diff --git a/BE/src/stock/order/stock-order.entity.ts b/BE/src/stock/order/stock-order.entity.ts new file mode 100644 index 00000000..7498b72a --- /dev/null +++ b/BE/src/stock/order/stock-order.entity.ts @@ -0,0 +1,46 @@ +import { + Column, + CreateDateColumn, + Entity, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { TradeType } from './enum/trade-type'; +import { StatusType } from './enum/status-type'; + +@Entity('orders') +export class Order { + @PrimaryGeneratedColumn() + id: number; + + @Column({ nullable: false }) + user_id: number; + + @Column({ nullable: false }) + stock_code: string; + + @Column({ + type: 'enum', + enum: TradeType, + nullable: false, + }) + trade_type: TradeType; + + @Column({ nullable: false }) + amount: number; + + @Column({ nullable: false }) + price: number; + + @Column({ + type: 'enum', + enum: StatusType, + nullable: false, + }) + status: StatusType; + + @CreateDateColumn() + created_at: Date; + + @Column({ nullable: true }) + completed_at?: Date; +} diff --git a/BE/src/stock/order/stock-order.module.ts b/BE/src/stock/order/stock-order.module.ts new file mode 100644 index 00000000..155c0855 --- /dev/null +++ b/BE/src/stock/order/stock-order.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { StockOrderController } from './stock-order.controller'; +import { StockOrderService } from './stock-order.service'; +import { Order } from './stock-order.entity'; +import { StockOrderRepository } from './stock-order.repository'; +import { SocketModule } from '../../websocket/socket.module'; +import { AssetModule } from '../../asset/asset.module'; +import { StockOrderSocketService } from './stock-order-socket.service'; +import { UserStockModule } from '../../userStock/user-stock.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Order]), + SocketModule, + AssetModule, + UserStockModule, + ], + controllers: [StockOrderController], + providers: [StockOrderService, StockOrderRepository, StockOrderSocketService], +}) +export class StockOrderModule {} diff --git a/BE/src/stock/order/stock-order.repository.ts b/BE/src/stock/order/stock-order.repository.ts new file mode 100644 index 00000000..6cd70c39 --- /dev/null +++ b/BE/src/stock/order/stock-order.repository.ts @@ -0,0 +1,114 @@ +import { DataSource, Repository } from 'typeorm'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { Injectable, InternalServerErrorException } from '@nestjs/common'; +import { Order } from './stock-order.entity'; +import { StatusType } from './enum/status-type'; +import { Asset } from '../../asset/asset.entity'; +import { UserStock } from '../../userStock/user-stock.entity'; + +@Injectable() +export class StockOrderRepository extends Repository { + constructor(@InjectDataSource() private dataSource: DataSource) { + super(Order, dataSource.createEntityManager()); + } + + async findAllCodeByStatus() { + return this.createQueryBuilder('orders') + .select('DISTINCT orders.stock_code') + .getRawMany(); + } + + async updateOrderAndAssetAndUserStockWhenBuy(order, realPrice) { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.startTransaction(); + + try { + await queryRunner.manager.update( + Order, + { id: order.id }, + { status: StatusType.COMPLETE, completed_at: new Date() }, + ); + // TODO: stock_balance와 total_asset은 실시간 주가에 따라 변동하도록 따로 구현해야 함 + await queryRunner.manager + .createQueryBuilder() + .update(Asset) + .set({ + cash_balance: () => `cash_balance - ${realPrice}`, + total_asset: () => `total_asset - ${realPrice}`, + total_profit: () => `total_profit - ${realPrice}`, + total_profit_rate: () => `total_profit / 10000000`, + last_updated: new Date(), + }) + .where({ user_id: order.user_id }) + .execute(); + + await queryRunner.manager + .createQueryBuilder() + .insert() + .into(UserStock) + .values({ + user_id: order.user_id, + stock_code: order.stock_code, + quantity: order.amount, + avg_price: order.price, + }) + .orUpdate( + [ + `quantity = quantity + ${order.amount}`, + `avg_price = ((avg_price * quantity + ${order.price} * ${order.amount}) / (quantity + ${order.amount}))`, + ], + ['user_id', 'stock_code'], + ) + .execute(); + + await queryRunner.commitTransaction(); + } catch (err) { + await queryRunner.rollbackTransaction(); + throw new InternalServerErrorException(); + } finally { + await queryRunner.release(); + } + } + + async updateOrderAndAssetAndUserStockWhenSell(order, realPrice) { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.startTransaction(); + + try { + await queryRunner.manager.update( + Order, + { id: order.id }, + { status: StatusType.COMPLETE, completed_at: new Date() }, + ); + // TODO: stock_balance와 total_asset은 실시간 주가에 따라 변동하도록 따로 구현해야 함 + await queryRunner.manager + .createQueryBuilder() + .update(Asset) + .set({ + cash_balance: () => `cash_balance + ${realPrice}`, + total_asset: () => `total_asset + ${realPrice}`, + total_profit: () => `total_profit + ${realPrice}`, + total_profit_rate: () => `total_profit / 10000000`, + last_updated: new Date(), + }) + .where({ user_id: order.user_id }) + .execute(); + + await queryRunner.manager + .createQueryBuilder() + .update(UserStock) + .set({ + quantity: () => `quantity - ${order.amount}`, + }) + .where({ user_id: order.user_id, stock_code: order.stock_code }) + .execute(); + + await queryRunner.commitTransaction(); + } catch (err) { + await queryRunner.rollbackTransaction(); + throw new InternalServerErrorException(); + } finally { + await queryRunner.release(); + } + } +} diff --git a/BE/src/stock/order/stock-order.service.ts b/BE/src/stock/order/stock-order.service.ts new file mode 100644 index 00000000..bdbfe8ce --- /dev/null +++ b/BE/src/stock/order/stock-order.service.ts @@ -0,0 +1,80 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, +} from '@nestjs/common'; +import { NotFoundError } from 'rxjs'; +import { StockOrderRequestDto } from './dto/stock-order-request.dto'; +import { StockOrderRepository } from './stock-order.repository'; +import { TradeType } from './enum/trade-type'; +import { StatusType } from './enum/status-type'; +import { StockOrderSocketService } from './stock-order-socket.service'; +import { UserStockRepository } from '../../userStock/user-stock.repository'; + +@Injectable() +export class StockOrderService { + constructor( + private readonly stockOrderRepository: StockOrderRepository, + private readonly stockOrderSocketService: StockOrderSocketService, + private readonly userStockRepository: UserStockRepository, + ) {} + + async buy(userId: number, stockOrderRequest: StockOrderRequestDto) { + const order = this.stockOrderRepository.create({ + user_id: userId, + stock_code: stockOrderRequest.stock_code, + trade_type: TradeType.BUY, + amount: stockOrderRequest.amount, + price: stockOrderRequest.price, + status: StatusType.PENDING, + }); + + await this.stockOrderRepository.save(order); + this.stockOrderSocketService.subscribeByCode(stockOrderRequest.stock_code); + } + + async sell(userId: number, stockOrderRequest: StockOrderRequestDto) { + const userStock = await this.userStockRepository.findOneBy({ + user_id: userId, + stock_code: stockOrderRequest.stock_code, + }); + + if (!userStock || userStock.quantity === 0) + throw new BadRequestException('주식을 매도 수만큼 가지고 있지 않습니다.'); + + const order = this.stockOrderRepository.create({ + user_id: userId, + stock_code: stockOrderRequest.stock_code, + trade_type: TradeType.SELL, + amount: stockOrderRequest.amount, + price: stockOrderRequest.price, + status: StatusType.PENDING, + }); + + await this.stockOrderRepository.save(order); + this.stockOrderSocketService.subscribeByCode(stockOrderRequest.stock_code); + } + + async cancel(userId: number, orderId: number) { + const order = await this.stockOrderRepository.findOneBy({ id: orderId }); + + if (!order) throw new NotFoundError('주문을 찾을 수 없습니다.'); + + if (order.user_id !== userId) + throw new ForbiddenException('다른 사용자의 주문은 취소할 수 없습니다.'); + + if (order.status === StatusType.COMPLETE) + throw new ConflictException('이미 체결된 주문은 취소할 수 없습니다.'); + + await this.stockOrderRepository.remove(order); + + if ( + !(await this.stockOrderRepository.existsBy({ + stock_code: order.stock_code, + status: StatusType.PENDING, + })) + ) + this.stockOrderSocketService.unsubscribeByCode(order.stock_code); + } +} diff --git a/BE/src/stock/topfive/interface/stock.topfive.interface.ts b/BE/src/stock/topfive/interface/stock-topfive.interface.ts similarity index 100% rename from BE/src/stock/topfive/interface/stock.topfive.interface.ts rename to BE/src/stock/topfive/interface/stock-topfive.interface.ts diff --git a/BE/src/stock/topfive/stock.topfive.controller.ts b/BE/src/stock/topfive/stock-topfive.controller.ts similarity index 73% rename from BE/src/stock/topfive/stock.topfive.controller.ts rename to BE/src/stock/topfive/stock-topfive.controller.ts index f90d300a..3d6258e0 100644 --- a/BE/src/stock/topfive/stock.topfive.controller.ts +++ b/BE/src/stock/topfive/stock-topfive.controller.ts @@ -1,14 +1,15 @@ -import { ApiOperation, ApiQuery, ApiResponse } from '@nestjs/swagger'; +import { ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger'; import { Controller, Get, Query } from '@nestjs/common'; -import { StockTopfiveService } from './stock.topfive.service'; +import { StockTopfiveService } from './stock-topfive.service'; import { StockRankingResponseDto } from './dto/stock-ranking-response.dto'; -import { MarketType } from '../enum/MarketType'; +import { MarketType } from '../enum/market-type'; -@Controller('/api/stocks') +@ApiTags('오늘의 상/하위 종목 조회 API') +@Controller('/api/stocks/topfive') export class StockTopfiveController { constructor(private readonly topFiveService: StockTopfiveService) {} - @Get('topfive') + @Get() @ApiOperation({ summary: '오늘의 상/하위 종목 조회 API' }) @ApiQuery({ name: 'market', diff --git a/BE/src/stock/topfive/stock.topfive.module.ts b/BE/src/stock/topfive/stock-topfive.module.ts similarity index 68% rename from BE/src/stock/topfive/stock.topfive.module.ts rename to BE/src/stock/topfive/stock-topfive.module.ts index 33a63aae..5be62a4f 100644 --- a/BE/src/stock/topfive/stock.topfive.module.ts +++ b/BE/src/stock/topfive/stock-topfive.module.ts @@ -1,8 +1,8 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; -import { StockTopfiveController } from './stock.topfive.controller'; -import { StockTopfiveService } from './stock.topfive.service'; -import { KoreaInvestmentModule } from '../../koreaInvestment/korea.investment.module'; +import { StockTopfiveController } from './stock-topfive.controller'; +import { StockTopfiveService } from './stock-topfive.service'; +import { KoreaInvestmentModule } from '../../koreaInvestment/korea-investment.module'; @Module({ imports: [ConfigModule, KoreaInvestmentModule], diff --git a/BE/src/stock/topfive/stock.topfive.service.ts b/BE/src/stock/topfive/stock-topfive.service.ts similarity index 95% rename from BE/src/stock/topfive/stock.topfive.service.ts rename to BE/src/stock/topfive/stock-topfive.service.ts index d8da1643..82f659d5 100644 --- a/BE/src/stock/topfive/stock.topfive.service.ts +++ b/BE/src/stock/topfive/stock-topfive.service.ts @@ -3,14 +3,14 @@ import { Injectable, Logger } from '@nestjs/common'; import { StockRankingQueryParameterDto } from './dto/stock-ranking-request.dto'; import { StockRankingResponseDto } from './dto/stock-ranking-response.dto'; import { StockRankingDataDto } from './dto/stock-ranking-data.dto'; -import { MarketType } from '../enum/MarketType'; +import { MarketType } from '../enum/market-type'; import { StockApiOutputData, StockApiResponse, -} from './interface/stock.topfive.interface'; -import { getHeader } from '../../util/getHeader'; -import { getFullURL } from '../../util/getFullURL'; -import { KoreaInvestmentService } from '../../koreaInvestment/korea.investment.service'; +} from './interface/stock-topfive.interface'; +import { getHeader } from '../../util/get-header'; +import { getFullURL } from '../../util/get-full-URL'; +import { KoreaInvestmentService } from '../../koreaInvestment/korea-investment.service'; @Injectable() export class StockTopfiveService { diff --git a/BE/src/stock/trade/history/dto/daily-stock-trade-history-data.dto.ts b/BE/src/stock/trade/history/dto/daily-stock-trade-history-data.dto.ts new file mode 100644 index 00000000..5cd38693 --- /dev/null +++ b/BE/src/stock/trade/history/dto/daily-stock-trade-history-data.dto.ts @@ -0,0 +1,27 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class DailyStockTradeHistoryDataDto { + @ApiProperty({ description: '주식 영업 일자' }) + stck_bsop_date: string; + + @ApiProperty({ description: '주식 시가' }) + stck_oprc: string; + + @ApiProperty({ description: '주식 최고가' }) + stck_hgpr: string; + + @ApiProperty({ description: '주식 최저가' }) + stck_lwpr: string; + + @ApiProperty({ description: '주식 종가' }) + stck_clpr: string; + + @ApiProperty({ description: '누적 거래량' }) + acml_vol: string; + + @ApiProperty({ description: '전일 대비 부호' }) + prdy_vrss_sign: string; + + @ApiProperty({ description: '전일 대비율' }) + prdy_ctrt: string; +} diff --git a/BE/src/stock/trade/history/dto/daily-stock-trade-history-ouput.dto.ts b/BE/src/stock/trade/history/dto/daily-stock-trade-history-ouput.dto.ts new file mode 100644 index 00000000..99427dd6 --- /dev/null +++ b/BE/src/stock/trade/history/dto/daily-stock-trade-history-ouput.dto.ts @@ -0,0 +1,45 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class DailyStockTradeHistoryOutputDto { + @ApiProperty({ description: '주식 영업 일자' }) + stck_bsop_date: string; + + @ApiProperty({ description: '주식 시가' }) + stck_oprc: string; + + @ApiProperty({ description: '주식 최고가' }) + stck_hgpr: string; + + @ApiProperty({ description: '주식 최저가' }) + stck_lwpr: string; + + @ApiProperty({ description: '주식 종가' }) + stck_clpr: string; + + @ApiProperty({ description: '누적 거래량' }) + acml_vol: string; + + @ApiProperty({ description: '전일 대비 거래량 비율' }) + prdy_vrss_vol_rate: string; + + @ApiProperty({ description: '전일 대비' }) + prdy_vrss: string; + + @ApiProperty({ description: '전일 대비 부호' }) + prdy_vrss_sign: string; + + @ApiProperty({ description: '전일 대비율' }) + prdy_ctrt: string; + + @ApiProperty({ description: 'HTS 외국인 소진율' }) + hts_frgn_ehrt: string; + + @ApiProperty({ description: '외국인 순매수 수량' }) + frgn_ntby_qty: string; + + @ApiProperty({ description: '락 구분 코드' }) + flng_cls_code: string; + + @ApiProperty({ description: '누적 분할 비율' }) + acml_prtt_rate: string; +} diff --git a/BE/src/stock/trade/history/dto/daily-stock-trade-history-response.dto.ts b/BE/src/stock/trade/history/dto/daily-stock-trade-history-response.dto.ts new file mode 100644 index 00000000..dd9bc0e1 --- /dev/null +++ b/BE/src/stock/trade/history/dto/daily-stock-trade-history-response.dto.ts @@ -0,0 +1,8 @@ +import { DailyStockTradeHistoryOutputDto } from './daily-stock-trade-history-ouput.dto'; + +/** + * 주식현재가 일자별 API 응답값 정제 후 FE에 보낼 DTO + */ +export class DailyStockTradeHistoryResponseDto { + output: DailyStockTradeHistoryOutputDto[]; +} diff --git a/BE/src/stock/trade/history/dto/today-stock-trade-history-data.dto.ts b/BE/src/stock/trade/history/dto/today-stock-trade-history-data.dto.ts new file mode 100644 index 00000000..7842a29b --- /dev/null +++ b/BE/src/stock/trade/history/dto/today-stock-trade-history-data.dto.ts @@ -0,0 +1,18 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class TodayStockTradeHistoryDataDto { + @ApiProperty({ description: '주식 체결 시간' }) + stck_cntg_hour: string; + + @ApiProperty({ description: '주식 현재가' }) + stck_prpr: string; + + @ApiProperty({ description: '전일 대비 부호' }) + prdy_vrss_sign: string; + + @ApiProperty({ description: '체결 거래량' }) + cntg_vol: string; + + @ApiProperty({ description: '전일 대비율' }) + prdy_ctrt: string; +} diff --git a/BE/src/stock/trade/history/dto/today-stock-trade-history-output.dto.ts b/BE/src/stock/trade/history/dto/today-stock-trade-history-output.dto.ts new file mode 100644 index 00000000..ab41d377 --- /dev/null +++ b/BE/src/stock/trade/history/dto/today-stock-trade-history-output.dto.ts @@ -0,0 +1,24 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class TodayStockTradeHistoryOutputDto { + @ApiProperty({ description: '주식 체결 시간' }) + stck_cntg_hour: string; + + @ApiProperty({ description: '주식 현재가' }) + stck_prpr: string; + + @ApiProperty({ description: '전일 대비' }) + prdy_vrss: string; + + @ApiProperty({ description: '전일 대비 부호' }) + prdy_vrss_sign: string; + + @ApiProperty({ description: '체결 거래량' }) + cntg_vol: string; + + @ApiProperty({ description: '당일 체결강도' }) + tday_rltv: string; + + @ApiProperty({ description: '전일 대비율' }) + prdy_ctrt: string; +} diff --git a/BE/src/stock/trade/history/dto/today-stock-trade-history-response.dto.ts b/BE/src/stock/trade/history/dto/today-stock-trade-history-response.dto.ts new file mode 100644 index 00000000..91981143 --- /dev/null +++ b/BE/src/stock/trade/history/dto/today-stock-trade-history-response.dto.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { TodayStockTradeHistoryOutputDto } from './today-stock-trade-history-output.dto'; + +/** + * 주식현재가 체결 API 응답값 정제 후 FE에 보낼 DTO + */ +export class TodayStockTradeHistoryResponseDto { + @ApiProperty({ + type: TodayStockTradeHistoryOutputDto, + description: '오늘 체결된 거래 내역 배열', + }) + output: TodayStockTradeHistoryOutputDto[]; +} diff --git a/BE/src/stock/trade/history/interface/Inquire-ccnl.interface.ts b/BE/src/stock/trade/history/interface/Inquire-ccnl.interface.ts new file mode 100644 index 00000000..94a19e5e --- /dev/null +++ b/BE/src/stock/trade/history/interface/Inquire-ccnl.interface.ts @@ -0,0 +1,16 @@ +export interface InquireCCNLOutputData { + stck_cntg_hour: string; + stck_prpr: string; + prdy_vrss: string; + prdy_vrss_sign: string; + cntg_vol: string; + tday_rltv: string; + prdy_ctrt: string; +} + +export interface InquireCCNLApiResponse { + output: InquireCCNLOutputData[]; + rt_cd: string; + msg_cd: string; + msg1: string; +} diff --git a/BE/src/stock/trade/history/interface/inquire-daily-price.interface.ts b/BE/src/stock/trade/history/interface/inquire-daily-price.interface.ts new file mode 100644 index 00000000..2546ade6 --- /dev/null +++ b/BE/src/stock/trade/history/interface/inquire-daily-price.interface.ts @@ -0,0 +1,23 @@ +export interface InquireDailyPriceOutputData { + stck_bsop_date: string; + stck_oprc: string; + stck_hgpr: string; + stck_lwpr: string; + stck_clpr: string; + acml_vol: string; + prdy_vrss_vol_rate: string; + prdy_vrss: string; + prdy_vrss_sign: string; + prdy_ctrt: string; + hts_frgn_ehrt: string; + frgn_ntby_qty: string; + flng_cls_code: string; + acml_prtt_rate: string; +} + +export interface InquireDailyPriceApiResponse { + output: InquireDailyPriceOutputData[]; + rt_cd: string; + msg_cd: string; + msg1: string; +} diff --git a/BE/src/stock/trade/history/stock-trade-history.controller.ts b/BE/src/stock/trade/history/stock-trade-history.controller.ts new file mode 100644 index 00000000..1b4c1533 --- /dev/null +++ b/BE/src/stock/trade/history/stock-trade-history.controller.ts @@ -0,0 +1,49 @@ +import { Controller, Get, Param } from '@nestjs/common'; +import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { StockTradeHistoryService } from './stock-trade-history.service'; +import { TodayStockTradeHistoryResponseDto } from './dto/today-stock-trade-history-response.dto'; +import { DailyStockTradeHistoryDataDto } from './dto/daily-stock-trade-history-data.dto'; + +@ApiTags('주식현재가 체결 조회 API') +@Controller('/api/stocks/trade-history') +export class StockTradeHistoryController { + constructor( + private readonly stockTradeHistoryService: StockTradeHistoryService, + ) {} + + @Get(':stockCode/today') + @ApiOperation({ summary: '단일 주식 종목에 대한 주식현재가 체결 API' }) + @ApiParam({ + name: 'stockCode', + required: true, + description: + '종목 코드\n\n' + + '(ex) 005930 삼성전자 / 005380 현대차 / 001500 현대차증권', + }) + @ApiResponse({ + status: 200, + description: '단일 주식 종목에 대한 주식현재가 체결값 조회 성공', + type: TodayStockTradeHistoryResponseDto, + }) + getTodayStockTradeHistory(@Param('stockCode') stockCode: string) { + return this.stockTradeHistoryService.getTodayStockTradeHistory(stockCode); + } + + @Get(':stockCode/daily') + @ApiOperation({ summary: '단일 주식 종목에 대한 일자별 주식현재가 API' }) + @ApiParam({ + name: 'stockCode', + required: true, + description: + '종목 코드\n\n' + + '(ex) 005930 삼성전자 / 005380 현대차 / 001500 현대차증권', + }) + @ApiResponse({ + status: 200, + description: '단일 주식 종목에 대한 일자별 주식현재가 조회 성공', + type: DailyStockTradeHistoryDataDto, + }) + getDailyStockTradeHistory(@Param('stockCode') stockCode: string) { + return this.stockTradeHistoryService.getDailyStockTradeHistory(stockCode); + } +} diff --git a/BE/src/stock/trade/history/stock-trade-history.module.ts b/BE/src/stock/trade/history/stock-trade-history.module.ts new file mode 100644 index 00000000..965889a1 --- /dev/null +++ b/BE/src/stock/trade/history/stock-trade-history.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { KoreaInvestmentModule } from '../../../koreaInvestment/korea-investment.module'; +import { StockTradeHistoryController } from './stock-trade-history.controller'; +import { StockTradeHistoryService } from './stock-trade-history.service'; + +@Module({ + imports: [KoreaInvestmentModule], + controllers: [StockTradeHistoryController], + providers: [StockTradeHistoryService], +}) +export class StockTradeHistoryModule {} diff --git a/BE/src/stock/trade/history/stock-trade-history.service.ts b/BE/src/stock/trade/history/stock-trade-history.service.ts new file mode 100644 index 00000000..449b9451 --- /dev/null +++ b/BE/src/stock/trade/history/stock-trade-history.service.ts @@ -0,0 +1,172 @@ +import axios from 'axios'; +import { Injectable, Logger } from '@nestjs/common'; +import { KoreaInvestmentService } from '../../../koreaInvestment/korea-investment.service'; +import { getHeader } from '../../../util/get-header'; +import { getFullURL } from '../../../util/get-full-URL'; +import { InquireCCNLApiResponse } from './interface/Inquire-ccnl.interface'; +import { TodayStockTradeHistoryOutputDto } from './dto/today-stock-trade-history-output.dto'; +import { TodayStockTradeHistoryDataDto } from './dto/today-stock-trade-history-data.dto'; +import { InquireDailyPriceApiResponse } from './interface/inquire-daily-price.interface'; +import { DailyStockTradeHistoryOutputDto } from './dto/daily-stock-trade-history-ouput.dto'; +import { DailyStockTradeHistoryDataDto } from './dto/daily-stock-trade-history-data.dto'; + +@Injectable() +export class StockTradeHistoryService { + private readonly logger = new Logger(); + + constructor( + private readonly koreaInvestmentService: KoreaInvestmentService, + ) {} + + /** + * 특정 주식의 현재가 체결 데이터를 반환하는 함수 + * @param {string} stockCode - 종목코드 + * @returns - 특정 주식의 현재가 체결 데이터 객체 반환 + * + * @author uuuo3o + */ + async getTodayStockTradeHistory(stockCode: string) { + try { + const queryParams = { + fid_cond_mrkt_div_code: 'J', + fid_input_iscd: stockCode, + }; + + const response = await this.requestApi( + 'FHKST01010300', + '/uapi/domestic-stock/v1/quotations/inquire-ccnl', + queryParams, + ); + + return this.formatTodayStockTradeHistoryData(response.output); + } catch (error) { + this.logger.error('API Error Details:', { + status: error.response?.status, + statusText: error.response?.statusText, + data: error.response?.data, + headers: error.response?.config?.headers, // 실제 요청 헤더 + message: error.message, + }); + throw error; + } + } + + /** + * @private API에서 받은 주식현재가 체결 데이터를 필요한 정보로 정제하는 함수 + * @param {TodayStockTradeHistoryOutputDto} infos - API 응답에서 받은 원시 데이터 + * @returns - 필요한 정보만 추출한 데이터 배열 + * + * @author uuuo3o + */ + private formatTodayStockTradeHistoryData( + infos: TodayStockTradeHistoryOutputDto[], + ) { + return infos.map((info) => { + const infoData = new TodayStockTradeHistoryDataDto(); + infoData.stck_cntg_hour = info.stck_cntg_hour; + infoData.stck_prpr = info.stck_prpr; + infoData.prdy_vrss_sign = info.prdy_vrss_sign; + infoData.cntg_vol = info.cntg_vol; + infoData.prdy_ctrt = info.prdy_ctrt; + + return infoData; + }); + } + + /** + * 특정 주식의 일자별 체결 데이터를 반환하는 함수 + * @param {string} stockCode - 종목코드 + * @returns - 특정 주식의 현재가 체결 데이터 객체 반환 + * + * @author uuuo3o + */ + async getDailyStockTradeHistory(stockCode: string) { + try { + const queryParams = { + fid_cond_mrkt_div_code: 'J', + fid_input_iscd: stockCode, + fid_period_div_code: 'D', + fid_org_adj_prc: '0', + }; + + const response = await this.requestApi( + 'FHKST01010400', + '/uapi/domestic-stock/v1/quotations/inquire-daily-price', + queryParams, + ); + + return this.formatDailyStockTradeHistoryData(response.output); + } catch (error) { + this.logger.error('API Error Details:', { + status: error.response?.status, + statusText: error.response?.statusText, + data: error.response?.data, + headers: error.response?.config?.headers, // 실제 요청 헤더 + message: error.message, + }); + throw error; + } + } + + /** + * @private API에서 받은 주식현재가 일자별 데이터를 필요한 정보로 정제하는 함수 + * @param {DailyStockTradeHistoryOutputDto} datas - API 응답에서 받은 원시 데이터 + * @returns - 필요한 정보만 추출한 데이터 배열 + * + * @author uuuo3o + */ + private formatDailyStockTradeHistoryData( + datas: DailyStockTradeHistoryOutputDto[], + ) { + return datas.map((data) => { + const historyData = new DailyStockTradeHistoryDataDto(); + historyData.stck_bsop_date = data.stck_bsop_date; + historyData.stck_oprc = data.stck_oprc; + historyData.stck_hgpr = data.stck_hgpr; + historyData.stck_lwpr = data.stck_lwpr; + historyData.stck_clpr = data.stck_clpr; + historyData.acml_vol = data.acml_vol; + historyData.prdy_vrss_sign = data.prdy_vrss_sign; + historyData.prdy_ctrt = data.prdy_ctrt; + + return historyData; + }); + } + + /** + * @private 한국투자 Open API - API 호출용 공통 함수 + * @param {string} trId - API 호출에 사용할 tr_id + * @param {string} apiURL - API 호출에 사용할 URL + * @param {Record} params - API 요청 시 필요한 쿼리 파라미터 DTO + * @returns - API 호출에 대한 응답 데이터 + * + * @author uuuo3o + */ + private async requestApi( + trId: string, + apiURL: string, + params: Record, + ): Promise { + try { + const accessToken = await this.koreaInvestmentService.getAccessToken(); + const headers = getHeader(accessToken, trId); + const url = getFullURL(apiURL); + + const response = await axios.get(url, { + headers, + params, + }); + + return response.data; + } catch (error) { + this.logger.error('API Error Details:', { + status: error.response?.status, + statusText: error.response?.statusText, + data: error.response?.data, + headers: error.response?.config?.headers, + message: error.message, + }); + throw error; + } + } +} diff --git a/BE/src/types/express.d.ts b/BE/src/types/express.d.ts new file mode 100644 index 00000000..2060c079 --- /dev/null +++ b/BE/src/types/express.d.ts @@ -0,0 +1,12 @@ +import { Request as Req } from 'express'; +import { UUID } from 'crypto'; + +declare module 'express' { + interface Request extends Req { + user: { + kakaoId?: string; + userId?: UUID; + email?: string; + }; + } +} diff --git a/BE/src/userStock/user-stock.controller.ts b/BE/src/userStock/user-stock.controller.ts new file mode 100644 index 00000000..e1e4c209 --- /dev/null +++ b/BE/src/userStock/user-stock.controller.ts @@ -0,0 +1,6 @@ +import { Controller } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; + +@Controller('/api/userStock') +@ApiTags('사용자 보유 주식 API') +export class UserStockController {} diff --git a/BE/src/userStock/user-stock.entity.ts b/BE/src/userStock/user-stock.entity.ts new file mode 100644 index 00000000..60a1b169 --- /dev/null +++ b/BE/src/userStock/user-stock.entity.ts @@ -0,0 +1,27 @@ +import { + Column, + Entity, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +@Entity('user_stocks') +export class UserStock { + @PrimaryGeneratedColumn() + id: number; + + @Column({ nullable: false }) + user_id: number; + + @Column({ nullable: false }) + stock_code: string; + + @Column({ nullable: false }) + quantity: number; + + @Column('decimal', { nullable: false, precision: 10, scale: 5 }) + avg_price: number; + + @UpdateDateColumn() + last_updated: Date; +} diff --git a/BE/src/userStock/user-stock.module.ts b/BE/src/userStock/user-stock.module.ts new file mode 100644 index 00000000..a6466976 --- /dev/null +++ b/BE/src/userStock/user-stock.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { UserStock } from './user-stock.entity'; +import { UserStockController } from './user-stock.controller'; +import { UserStockRepository } from './user-stock.repository'; +import { UserStockService } from './user-stock.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([UserStock])], + controllers: [UserStockController], + providers: [UserStockRepository, UserStockService], + exports: [UserStockRepository], +}) +export class UserStockModule {} diff --git a/BE/src/userStock/user-stock.repository.ts b/BE/src/userStock/user-stock.repository.ts new file mode 100644 index 00000000..e945a667 --- /dev/null +++ b/BE/src/userStock/user-stock.repository.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource, Repository } from 'typeorm'; +import { UserStock } from './user-stock.entity'; + +@Injectable() +export class UserStockRepository extends Repository { + constructor(@InjectDataSource() private dataSource: DataSource) { + super(UserStock, dataSource.createEntityManager()); + } +} diff --git a/BE/src/userStock/user-stock.service.ts b/BE/src/userStock/user-stock.service.ts new file mode 100644 index 00000000..1c69dcaf --- /dev/null +++ b/BE/src/userStock/user-stock.service.ts @@ -0,0 +1,4 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class UserStockService {} diff --git a/BE/src/util/getFullURL.ts b/BE/src/util/get-full-URL.ts similarity index 100% rename from BE/src/util/getFullURL.ts rename to BE/src/util/get-full-URL.ts diff --git a/BE/src/util/getHeader.ts b/BE/src/util/get-header.ts similarity index 100% rename from BE/src/util/getHeader.ts rename to BE/src/util/get-header.ts diff --git a/BE/src/util/swagger.ts b/BE/src/util/swagger.ts index 44de0c96..e820d85e 100644 --- a/BE/src/util/swagger.ts +++ b/BE/src/util/swagger.ts @@ -13,6 +13,7 @@ export function setupSwagger(app: INestApplication): void { .setTitle('Juga API') .setDescription('Juga API 문서입니다.') .setVersion('1.0.0') + .addBearerAuth() .build(); const document = SwaggerModule.createDocument(app, options); diff --git a/BE/src/websocket/base-socket.service.ts b/BE/src/websocket/base-socket.service.ts new file mode 100644 index 00000000..66bc6a1d --- /dev/null +++ b/BE/src/websocket/base-socket.service.ts @@ -0,0 +1,114 @@ +import { WebSocket } from 'ws'; +import { + Injectable, + InternalServerErrorException, + Logger, + OnModuleInit, +} from '@nestjs/common'; +import { SocketTokenService } from './socket-token.service'; + +@Injectable() +export class BaseSocketService implements OnModuleInit { + private socket: WebSocket; + private socketConnectionKey: string; + private socketOpenHandlers: (() => void | Promise)[] = []; + private socketDataHandlers: { + [key: string]: (data) => void; + } = {}; + + private readonly logger = new Logger(); + + constructor(private readonly socketTokenService: SocketTokenService) {} + + async onModuleInit() { + this.socketConnectionKey = + await this.socketTokenService.getSocketConnectionKey(); + this.socket = new WebSocket(process.env.KOREA_INVESTMENT_SOCKET_URL); + + this.socket.onopen = () => { + Promise.all( + this.socketOpenHandlers.map(async (socketOpenHandler) => { + await socketOpenHandler(); + }), + ).catch(() => { + throw new InternalServerErrorException(); + }); + }; + + this.socket.onmessage = (event) => { + const data = + typeof event.data === 'string' + ? event.data.split('|') + : JSON.stringify(event.data); + + if (data.length < 2) { + const json = JSON.parse(data[0]); + if (json.body) + this.logger.log( + `한국투자증권 웹소켓 연결: ${json.body.msg1}`, + json.header.tr_id, + ); + if (json.header.tr_id === 'PINGPONG') + this.socket.pong(JSON.stringify(json)); + return; + } + + const dataList = data[3].split('^'); + + if (Number(dataList[1]) % 500 === 0) + this.logger.log(`한국투자증권 데이터 수신 성공 (5분 단위)`, data[1]); + + this.socketDataHandlers[data[1]](dataList); + }; + + this.socket.onclose = () => { + this.logger.warn(`한국투자증권 소켓 연결 종료`); + }; + } + + registerCode(trId: string, trKey: string) { + this.socket.send( + JSON.stringify({ + header: { + approval_key: this.socketConnectionKey, + custtype: 'P', + tr_type: '1', + 'content-type': 'utf-8', + }, + body: { + input: { + tr_id: trId, + tr_key: trKey, + }, + }, + }), + ); + } + + unregisterCode(trId: string, trKey: string) { + this.socket.send( + JSON.stringify({ + header: { + approval_key: this.socketConnectionKey, + custtype: 'P', + tr_type: '2', + 'content-type': 'utf-8', + }, + body: { + input: { + tr_id: trId, + tr_key: trKey, + }, + }, + }), + ); + } + + registerSocketOpenHandler(handler: () => void | Promise) { + this.socketOpenHandlers.push(handler); + } + + registerSocketDataHandler(tradeCode: string, handler: (data) => void) { + this.socketDataHandlers[tradeCode] = handler; + } +} diff --git a/BE/src/websocket/socket-token.service.ts b/BE/src/websocket/socket-token.service.ts new file mode 100644 index 00000000..f4ff9e3c --- /dev/null +++ b/BE/src/websocket/socket-token.service.ts @@ -0,0 +1,25 @@ +import axios from 'axios'; +import { SocketConnectTokenInterface } from './interface/socket.interface'; +import { getFullURL } from '../util/get-full-URL'; + +export class SocketTokenService { + private approvalKey: string; + + async getSocketConnectionKey() { + if (this.approvalKey) { + return this.approvalKey; + } + + const response = await axios.post( + getFullURL('/oauth2/Approval'), + { + grant_type: 'client_credentials', + appkey: process.env.KOREA_INVESTMENT_APP_KEY, + secretkey: process.env.KOREA_INVESTMENT_APP_SECRET, + }, + ); + + this.approvalKey = response.data.approval_key; + return this.approvalKey; + } +} diff --git a/BE/src/websocket/socket.gateway.ts b/BE/src/websocket/socket.gateway.ts index 921d9503..d533c3f0 100644 --- a/BE/src/websocket/socket.gateway.ts +++ b/BE/src/websocket/socket.gateway.ts @@ -1,16 +1,23 @@ import { WebSocketGateway, WebSocketServer } from '@nestjs/websockets'; import { Server } from 'socket.io'; +import { Logger } from '@nestjs/common'; @WebSocketGateway({ namespace: 'socket', cors: { origin: '*' } }) export class SocketGateway { @WebSocketServer() private server: Server; - sendStockIndexListToClient(stockIndex) { - this.server.emit('index', stockIndex); + private readonly logger = new Logger(); + + sendStockIndexListToClient(stockChart) { + this.server.emit('chart', stockChart); } - sendStockIndexValueToClient(stockIndexValue) { - this.server.emit('indexValue', stockIndexValue); + sendStockIndexValueToClient(event, stockIndexValue) { + const now = new Date(); + if (now.getMinutes() % 5 === 0 && now.getSeconds() === 0) + this.logger.log('한국투자증권 데이터 발신 성공 (5분 단위)', event); + + this.server.emit(event, stockIndexValue); } } diff --git a/BE/src/websocket/socket.module.ts b/BE/src/websocket/socket.module.ts index 4e9e2eb2..e7a2dcc7 100644 --- a/BE/src/websocket/socket.module.ts +++ b/BE/src/websocket/socket.module.ts @@ -1,11 +1,10 @@ import { Module } from '@nestjs/common'; -import { SocketService } from './socket.service'; import { SocketGateway } from './socket.gateway'; +import { SocketTokenService } from './socket-token.service'; +import { BaseSocketService } from './base-socket.service'; @Module({ - imports: [], - controllers: [], - providers: [SocketService, SocketGateway], - exports: [SocketGateway], + providers: [SocketTokenService, SocketGateway, BaseSocketService], + exports: [SocketGateway, BaseSocketService], }) export class SocketModule {} diff --git a/BE/src/websocket/socket.service.ts b/BE/src/websocket/socket.service.ts deleted file mode 100644 index 0c25bf03..00000000 --- a/BE/src/websocket/socket.service.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { Injectable, OnModuleInit } from '@nestjs/common'; -import { WebSocket } from 'ws'; -import axios from 'axios'; -import { SocketGateway } from './socket.gateway'; -import { StockIndexValueElementDto } from '../stock/index/dto/stock.index.value.element.dto'; -import { SocketConnectTokenInterface } from './interface/socket.interface'; - -@Injectable() -export class SocketService implements OnModuleInit { - private socket: WebSocket; - private tradeHandler = { - H0UPCNT0: this.handleStockIndexValue.bind(this), - }; - - constructor(private readonly socketGateway: SocketGateway) {} - - async onModuleInit() { - const socketConnectionKey = await this.getSocketConnectionKey(); - - const url = 'ws://ops.koreainvestment.com:21000'; - this.socket = new WebSocket(url); - - this.socket.onopen = () => { - this.registerStockIndexByCode('0001', socketConnectionKey); // 코스피 - this.registerStockIndexByCode('1001', socketConnectionKey); // 코스닥 - this.registerStockIndexByCode('2001', socketConnectionKey); // 코스피200 - this.registerStockIndexByCode('3003', socketConnectionKey); // KSQ150 - }; - - this.socket.onmessage = (event) => { - const data = - typeof event.data === 'string' - ? event.data.split('|') - : JSON.stringify(event.data); - if (data.length < 2) return; - - (this.tradeHandler[data[1]] as (data) => void)(data[3]); - }; - } - - private handleStockIndexValue(responseData: string) { - const responseList = responseData.split('^'); - this.socketGateway.sendStockIndexValueToClient( - new StockIndexValueElementDto( - responseList[0], - responseList[2], - responseList[4], - responseList[9], - responseList[3], - ), - ); - } - - private async getSocketConnectionKey() { - const response = await axios.post( - `${process.env.KOREA_INVESTMENT_BASE_URL}/oauth2/Approval`, - { - grant_type: 'client_credentials', - appkey: process.env.KOREA_INVESTMENT_APP_KEY, - secretkey: process.env.KOREA_INVESTMENT_APP_SECRET, - }, - ); - - const result = response.data; - return result.approval_key; - } - - private registerStockIndexByCode(code, socketConnectionKey) { - this.socket.send( - JSON.stringify({ - header: { - approval_key: socketConnectionKey, - custtype: 'P', - tr_type: '1', - 'content-type': 'utf-8', - }, - body: { - input: { - tr_id: 'H0UPCNT0', - tr_key: code, - }, - }, - }), - ); - } -} diff --git a/BE/test/stock/index/stock.index.list.e2e-spec.ts b/BE/test/stock/index/stock.index.list.e2e-spec.ts index 7518b75d..9a844581 100644 --- a/BE/test/stock/index/stock.index.list.e2e-spec.ts +++ b/BE/test/stock/index/stock.index.list.e2e-spec.ts @@ -1,6 +1,6 @@ import { Test } from '@nestjs/testing'; import axios from 'axios'; -import { StockIndexService } from '../../../src/stock/index/stock.index.service'; +import { StockIndexService } from '../../../src/stock/index/stock-index.service'; import { STOCK_INDEX_LIST_MOCK } from './mockdata/stock.index.list.mockdata'; jest.mock('axios'); diff --git a/BE/test/stock/index/stock.index.value.e2e-spec.ts b/BE/test/stock/index/stock.index.value.e2e-spec.ts index 12ff2590..2008f2e4 100644 --- a/BE/test/stock/index/stock.index.value.e2e-spec.ts +++ b/BE/test/stock/index/stock.index.value.e2e-spec.ts @@ -1,6 +1,6 @@ import { Test } from '@nestjs/testing'; import axios from 'axios'; -import { StockIndexService } from '../../../src/stock/index/stock.index.service'; +import { StockIndexService } from '../../../src/stock/index/stock-index.service'; import { STOCK_INDEX_VALUE_MOCK } from './mockdata/stock.index.value.mockdata'; jest.mock('axios'); diff --git a/BE/tsconfig.json b/BE/tsconfig.json index 95f5641c..52a4d6a7 100644 --- a/BE/tsconfig.json +++ b/BE/tsconfig.json @@ -16,6 +16,7 @@ "noImplicitAny": false, "strictBindCallApply": false, "forceConsistentCasingInFileNames": false, - "noFallthroughCasesInSwitch": false + "noFallthroughCasesInSwitch": false, + "typeRoots": ["node_modules/@types", "./src/types"] } } diff --git a/FE/.dockerignore b/FE/.dockerignore new file mode 100644 index 00000000..7bd5f175 --- /dev/null +++ b/FE/.dockerignore @@ -0,0 +1,5 @@ +.git +Dockerfile +node_modules +dist +.env* \ No newline at end of file diff --git a/FE/Dockerfile b/FE/Dockerfile new file mode 100644 index 00000000..c4389e8c --- /dev/null +++ b/FE/Dockerfile @@ -0,0 +1,16 @@ +# 빌드 스테이지 +FROM node:20-slim as builder +WORKDIR /app +COPY package*.json ./ +RUN npm install +COPY . . +RUN npm run build + +# 실행 스테이지 +FROM node:20-slim +WORKDIR /app +RUN npm install -g serve +COPY --from=builder /app/dist ./dist + +EXPOSE 5173 +CMD ["serve", "-s", "dist", "-l", "5173"] \ No newline at end of file diff --git a/FE/tsconfig.app.tsbuildinfo b/FE/tsconfig.app.tsbuildinfo new file mode 100644 index 00000000..bbaf2949 --- /dev/null +++ b/FE/tsconfig.app.tsbuildinfo @@ -0,0 +1 @@ +{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/header.tsx","./src/page/home.tsx"],"version":"5.6.3"} \ No newline at end of file diff --git a/FE/tsconfig.node.tsbuildinfo b/FE/tsconfig.node.tsbuildinfo new file mode 100644 index 00000000..75ea0011 --- /dev/null +++ b/FE/tsconfig.node.tsbuildinfo @@ -0,0 +1 @@ +{"root":["./vite.config.ts"],"version":"5.6.3"} \ No newline at end of file diff --git a/FE/vite.config.ts b/FE/vite.config.ts index 539e2463..be05188f 100644 --- a/FE/vite.config.ts +++ b/FE/vite.config.ts @@ -6,6 +6,7 @@ import tsconfigPaths from 'vite-tsconfig-paths'; export default defineConfig({ plugins: [react(), tsconfigPaths()], server: { + host: true, open: true, }, }); diff --git a/scripts/stock-list.script.py b/scripts/stock-list.script.py new file mode 100644 index 00000000..c33e1366 --- /dev/null +++ b/scripts/stock-list.script.py @@ -0,0 +1,56 @@ +import FinanceDataReader as fdr +import os +import pymysql +from dotenv import load_dotenv +from pathlib import Path +from sshtunnel import SSHTunnelForwarder + +root_dir = Path(__file__).parent +env_path = os.path.join(root_dir, '.env') +# .env 파일 로드 +load_dotenv(env_path) + +db_config = { + 'host' : os.getenv('DB_HOST'), + 'user' : os.getenv('DB_USERNAME'), + 'password' : os.getenv('DB_PASSWD'), + 'database' : os.getenv('DB_DATABASE'), +} + +if __name__ == '__main__': + + with SSHTunnelForwarder( + (os.getenv('SSH_HOST'), 22), + ssh_username=os.getenv('SSH_USERNAME'), + ssh_password=os.getenv('SSH_PASSWD'), + remote_bind_address=(os.getenv('DB_HOST'), 3306) + ) as tunnel: + + with pymysql.connect( + host='127.0.0.1', + user=os.getenv('DB_USERNAME'), + password=os.getenv('DB_PASSWD'), + db=os.getenv('DB_DATABASE'), + charset='utf8', + port=tunnel.local_bind_port, + cursorclass=pymysql.cursors.DictCursor) as conn: + + with conn.cursor() as cursor: + try : + df_krx = fdr.StockListing('KRX') + df_selected = df_krx[['Code','Name','Market']] + stockData = df_selected + insert_query = """INSERT INTO stocks (code, name, market) VALUES (%s, %s, %s) + ON DUPLICATE KEY UPDATE name = VALUES(name), market = VALUES(market)""" + + records = stockData.to_dict('records') + for record in records: + values = (record['Code'], record['Name'], record['Market']) + cursor.execute(insert_query, values) + conn.commit() + + except Exception as e : + print(e) + conn.rollback() + + \ No newline at end of file