diff --git a/BE/docker-compose.backend.local.yml b/BE/docker-compose.backend.local.yml index 438fe8af..1f725dff 100644 --- a/BE/docker-compose.backend.local.yml +++ b/BE/docker-compose.backend.local.yml @@ -30,8 +30,12 @@ services: REDIS_HOST: redis REDIS_PORT: 6379 + + LOGGER_DIR: /logs expose: - 3000 + volumes: + - ./logs:/app/BE/logs server_db: image: mysql:8.0 @@ -42,7 +46,7 @@ services: ports: - "3306:3306" volumes: - - ../db/server:/var/lib/mysql + - ../db/server:/var/lib/mysql networks: - backend diff --git a/BE/ecosystem.config.js b/BE/ecosystem.config.js index 834cec6a..ff098ab0 100644 --- a/BE/ecosystem.config.js +++ b/BE/ecosystem.config.js @@ -1,18 +1,17 @@ module.exports = { - apps: [ - { - name: 'nest-server', - script: 'dist/main.js', // NestJS 서버의 진입 파일 - instances: 'max', // 클러스터 모드: CPU 코어 수만큼 프로세스 실행 - exec_mode: 'cluster', // 클러스터 모드 - watch: true, // 파일 변경 감지 후 재시작 - env: { - NODE_ENV: 'development', - }, - env_production: { - NODE_ENV: 'production', - }, + apps: [ + { + name: 'nest-server', + script: 'dist/main.js', // NestJS 서버의 진입 파일 + instances: 'max', // 클러스터 모드: CPU 코어 수만큼 프로세스 실행 + exec_mode: 'cluster', // 클러스터 모드 + watch: false, // 파일 변경 감지 후 재시작 + env: { + NODE_ENV: 'development', }, - ], - }; - \ No newline at end of file + env_production: { + NODE_ENV: 'production', + }, + }, + ], +}; diff --git a/BE/package-lock.json b/BE/package-lock.json index bd22ed87..414e3b4f 100644 --- a/BE/package-lock.json +++ b/BE/package-lock.json @@ -26,11 +26,14 @@ "jest-mock-extended": "^4.0.0-beta1", "memorystore": "^1.6.7", "mysql2": "^3.11.3", + "nest-winston": "^1.9.7", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", "testcontainers": "^10.14.0", "typeorm": "^0.3.20", - "uuid": "^11.0.2" + "uuid": "^11.0.2", + "winston": "^3.17.0", + "winston-daily-rotate-file": "^5.0.0" }, "devDependencies": { "@nestjs/cli": "^10.0.0", @@ -739,6 +742,17 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@dabh/diagnostics": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.3.tgz", + "integrity": "sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==", + "license": "MIT", + "dependencies": { + "colorspace": "1.1.x", + "enabled": "2.0.x", + "kuler": "^2.0.0" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz", @@ -2446,6 +2460,12 @@ "@types/superagent": "^8.1.0" } }, + "node_modules/@types/triple-beam": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", + "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", + "license": "MIT" + }, "node_modules/@types/validator": { "version": "13.12.2", "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.12.2.tgz", @@ -3428,7 +3448,6 @@ "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", - "dev": true, "license": "MIT" }, "node_modules/async-lock": { @@ -4296,6 +4315,16 @@ "integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==", "license": "MIT" }, + "node_modules/color": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz", + "integrity": "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==", + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.3", + "color-string": "^1.6.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -4314,6 +4343,41 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/color/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/color/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "license": "MIT" + }, + "node_modules/colorspace": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/colorspace/-/colorspace-1.1.4.tgz", + "integrity": "sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w==", + "license": "MIT", + "dependencies": { + "color": "^3.1.3", + "text-hex": "1.0.x" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -4719,9 +4783,9 @@ "license": "MIT" }, "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -5172,6 +5236,12 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, + "node_modules/enabled": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", + "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==", + "license": "MIT" + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -6383,6 +6453,12 @@ "bser": "2.1.1" } }, + "node_modules/fecha": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", + "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", + "license": "MIT" + }, "node_modules/figures": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", @@ -6422,6 +6498,15 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/file-stream-rotator": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/file-stream-rotator/-/file-stream-rotator-0.6.1.tgz", + "integrity": "sha512-u+dBid4PvZw17PmDeRcNOtCP9CCK/9lRN2w+r1xIS7yOL9JFrIBKTvrYsxT4P0pGtThYTn++QS5ChHaUov3+zQ==", + "license": "MIT", + "dependencies": { + "moment": "^2.29.1" + } + }, "node_modules/filelist": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", @@ -6529,6 +6614,12 @@ "dev": true, "license": "ISC" }, + "node_modules/fn.name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", + "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", + "license": "MIT" + }, "node_modules/for-each": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", @@ -8716,6 +8807,12 @@ "node": ">=6" } }, + "node_modules/kuler": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", + "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", + "license": "MIT" + }, "node_modules/language-subtag-registry": { "version": "0.3.23", "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", @@ -8859,6 +8956,32 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/logform": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", + "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==", + "license": "MIT", + "dependencies": { + "@colors/colors": "1.6.0", + "@types/triple-beam": "^1.3.2", + "fecha": "^4.2.0", + "ms": "^2.1.1", + "safe-stable-stringify": "^2.3.1", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/logform/node_modules/@colors/colors": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", + "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", + "license": "MIT", + "engines": { + "node": ">=0.1.90" + } + }, "node_modules/long": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz", @@ -9150,6 +9273,15 @@ "dev": true, "license": "MIT" }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -9274,6 +9406,19 @@ "dev": true, "license": "MIT" }, + "node_modules/nest-winston": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/nest-winston/-/nest-winston-1.9.7.tgz", + "integrity": "sha512-pTTgImRgv7urojsDvaTlenAjyJNLj7ywamfjzrhWKhLhp80AKLYNwf103dVHeqZWe+nzp/vd9DGRs/UN/YadOQ==", + "license": "MIT", + "dependencies": { + "fast-safe-stringify": "^2.1.1" + }, + "peerDependencies": { + "@nestjs/common": "^5.0.0 || ^6.6.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0", + "winston": "^3.0.0" + } + }, "node_modules/node-abort-controller": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", @@ -9353,6 +9498,15 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", @@ -9491,6 +9645,15 @@ "wrappy": "1" } }, + "node_modules/one-time": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", + "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", + "license": "MIT", + "dependencies": { + "fn.name": "1.x.x" + } + }, "node_modules/onetime": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", @@ -10591,6 +10754,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -10842,6 +11014,21 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/simple-swizzle/node_modules/is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", + "license": "MIT" + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -10950,6 +11137,15 @@ "nan": "^2.20.0" } }, + "node_modules/stack-trace": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", + "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -11558,6 +11754,12 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/text-hex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", + "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", + "license": "MIT" + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -11647,6 +11849,15 @@ "tree-kill": "cli.js" } }, + "node_modules/triple-beam": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", + "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==", + "license": "MIT", + "engines": { + "node": ">= 14.0.0" + } + }, "node_modules/ts-api-utils": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", @@ -12573,6 +12784,97 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/winston": { + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.17.0.tgz", + "integrity": "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==", + "license": "MIT", + "dependencies": { + "@colors/colors": "^1.6.0", + "@dabh/diagnostics": "^2.0.2", + "async": "^3.2.3", + "is-stream": "^2.0.0", + "logform": "^2.7.0", + "one-time": "^1.0.0", + "readable-stream": "^3.4.0", + "safe-stable-stringify": "^2.3.1", + "stack-trace": "0.0.x", + "triple-beam": "^1.3.0", + "winston-transport": "^4.9.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston-daily-rotate-file": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/winston-daily-rotate-file/-/winston-daily-rotate-file-5.0.0.tgz", + "integrity": "sha512-JDjiXXkM5qvwY06733vf09I2wnMXpZEhxEVOSPenZMii+g7pcDcTBt2MRugnoi8BwVSuCT2jfRXBUy+n1Zz/Yw==", + "license": "MIT", + "dependencies": { + "file-stream-rotator": "^0.6.1", + "object-hash": "^3.0.0", + "triple-beam": "^1.4.1", + "winston-transport": "^4.7.0" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "winston": "^3" + } + }, + "node_modules/winston-transport": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz", + "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==", + "license": "MIT", + "dependencies": { + "logform": "^2.7.0", + "readable-stream": "^3.6.2", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston-transport/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/winston/node_modules/@colors/colors": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", + "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", + "license": "MIT", + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/winston/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/BE/package.json b/BE/package.json index a6f093f9..7c1b0a49 100644 --- a/BE/package.json +++ b/BE/package.json @@ -37,11 +37,14 @@ "jest-mock-extended": "^4.0.0-beta1", "memorystore": "^1.6.7", "mysql2": "^3.11.3", + "nest-winston": "^1.9.7", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", "testcontainers": "^10.14.0", "typeorm": "^0.3.20", - "uuid": "^11.0.2" + "uuid": "^11.0.2", + "winston": "^3.17.0", + "winston-daily-rotate-file": "^5.0.0" }, "devDependencies": { "@nestjs/cli": "^10.0.0", diff --git a/BE/src/app.module.ts b/BE/src/app.module.ts index 5228cdef..031176d9 100644 --- a/BE/src/app.module.ts +++ b/BE/src/app.module.ts @@ -1,30 +1,46 @@ -import { MiddlewareConsumer, Module } from '@nestjs/common'; +import { MiddlewareConsumer, Module, ValidationPipe } from '@nestjs/common'; import { UserModule } from './user/user.module'; import { ShellModule } from './shell/shell.module'; import { QueryModule } from './query/query.module'; - import { Shell } from 'src/shell/shell.entity'; import { User } from 'src/user/user.entity'; import { TypeOrmModule } from '@nestjs/typeorm'; -import dotenv from 'dotenv'; import { SessionMiddleware } from './middleware/session.middleware'; import { RedisModule } from './config/redis/redis.module'; import { ServiceDBModule } from './config/service-database/service-db.module'; import { RecordModule } from './record/record.module'; import { TableModule } from './table/table.module'; - -dotenv.config(); +import { LoggerModule } from './config/logger/logger.module'; +import { LoggingInterceptor } from './interceptors/logging.interceptor'; +import { APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core'; +import { UsageModule } from './usage/usage.module'; +import { ConfigModule } from '@nestjs/config'; @Module({ imports: [ - ServiceDBModule, + ConfigModule.forRoot({ + isGlobal: true, + }), TypeOrmModule.forFeature([User, Shell]), + ServiceDBModule, UserModule, ShellModule, QueryModule, RedisModule, RecordModule, TableModule, + UsageModule, + LoggerModule, + ], + providers: [ + { + provide: APP_INTERCEPTOR, + useClass: LoggingInterceptor, + }, + { + provide: APP_PIPE, + useClass: ValidationPipe, + }, ], }) export class AppModule { diff --git a/BE/src/config/cors/cors.config.ts b/BE/src/config/cors/cors.config.ts index 41b0adcd..5dfdc777 100644 --- a/BE/src/config/cors/cors.config.ts +++ b/BE/src/config/cors/cors.config.ts @@ -1,10 +1,5 @@ -import dotenv from 'dotenv'; -import * as process from 'node:process'; - -dotenv.config(); - export const corsOptions = { - origin: ['http://localhost:4173', process.env.HOST_URL], + origin: ['http://localhost:4173'], methods: ['GET', 'POST', 'PUT', 'DELETE'], credentials: true, }; diff --git a/BE/src/config/logger/logger.module.ts b/BE/src/config/logger/logger.module.ts new file mode 100644 index 00000000..191271d6 --- /dev/null +++ b/BE/src/config/logger/logger.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { LoggerService } from './logger.service'; + +@Global() +@Module({ + providers: [LoggerService], + exports: [LoggerService], +}) +export class LoggerModule {} diff --git a/BE/src/config/logger/logger.service.ts b/BE/src/config/logger/logger.service.ts new file mode 100644 index 00000000..091882e5 --- /dev/null +++ b/BE/src/config/logger/logger.service.ts @@ -0,0 +1,39 @@ +import { HttpStatus } from '@nestjs/common'; +import { winstonConfig } from './winston.config'; +import { WinstonModule } from 'nest-winston'; + +export interface RequestInfo { + method: string; + url: string; + body: any; + sessionId: string; +} + +export interface ResponseInfo { + status: number; +} + +export class LoggerService { + private logger; + + constructor() { + this.logger = WinstonModule.createLogger(winstonConfig); + } + + warn(message: string, error: any) { + this.logger.warn(`[${message}]\n${error}`); + } + + logRequest(reqInfo: RequestInfo) { + this.logger.log( + `[Request] ${reqInfo.method} ${reqInfo.url} SID: ${reqInfo.sessionId}`, + ); + } + logResponse(res: ResponseInfo) { + if (res.status == HttpStatus.INTERNAL_SERVER_ERROR) { + this.logger.error(`[Response] ${res.status} ${HttpStatus[res.status]}`); + return; + } + this.logger.log(`[Response] ${res.status} ${HttpStatus[res.status]}`); + } +} diff --git a/BE/src/config/logger/winston.config.ts b/BE/src/config/logger/winston.config.ts new file mode 100644 index 00000000..26f5854e --- /dev/null +++ b/BE/src/config/logger/winston.config.ts @@ -0,0 +1,44 @@ +import { utilities } from 'nest-winston'; +import * as winston from 'winston'; +import 'winston-daily-rotate-file'; + +const infoFileOptions = { + level: 'info', + dirname: 'logs', + filename: 'info.log', + datePattern: 'YYYY-MM-DD', + zippedArchive: true, + maxSize: '20m', + maxFiles: '30d', +}; + +const errorFileOptions = { + level: 'error', + dirname: 'logs', + filename: 'error.log', + datePattern: 'YYYY-MM-DD', + zippedArchive: true, + maxSize: '20m', + maxFiles: '30d', +}; + +export const winstonConfig = { + level: 'info', // 'error', 'warn', 'info', 'http', 'verbose', 'debug', 'silly' + transports: [ + new winston.transports.Console({ + format: winston.format.combine( + winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), + utilities.format.nestLike('Q-Lab', { + prettyPrint: true, + colors: true, + appName: true, + }), + ), + silent: process.env.NODE_ENV !== 'debug', + }), + // error log file + new winston.transports.DailyRotateFile(errorFileOptions), + // info log file + new winston.transports.DailyRotateFile(infoFileOptions), + ], +}; diff --git a/BE/src/config/query-database/admin-db-manager.service.ts b/BE/src/config/query-database/admin-db-manager.service.ts new file mode 100644 index 00000000..efd524c0 --- /dev/null +++ b/BE/src/config/query-database/admin-db-manager.service.ts @@ -0,0 +1,71 @@ +import { + ConflictException, + Injectable, + InternalServerErrorException, + OnModuleInit, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { createPool, Pool } from 'mysql2/promise'; + +@Injectable() +export class AdminDBManager implements OnModuleInit { + private pool: Pool; + + constructor(private readonly configService: ConfigService) {} + + async onModuleInit() { + this.createAdminConnection(); + } + + private createAdminConnection() { + this.pool = createPool({ + host: this.configService.get('QUERY_DB_HOST'), + user: this.configService.get('QUERY_DB_USER'), + password: this.configService.get('QUERY_DB_PASSWORD'), + port: this.configService.get('QUERY_DB_PORT', 3306), + connectionLimit: 10, + }); + } + async run(query: string, params?: string[]) { + return this.pool.query(query, params); + } + + public async initUserDatabase(identify: string) { + try { + const connectInfo = { + name: identify.substring(0, 10), + password: identify, + host: '%', + database: identify, + }; + + await this.run(`create database ${connectInfo.database};`); + await this.run( + `create user '${connectInfo.name}'@'${connectInfo.host}' identified by '${connectInfo.password}';`, + ); + await this.run( + `grant all privileges on ${connectInfo.database}.* to '${connectInfo.name}'@'${connectInfo.host}';`, + ); + } catch (error) { + if (error.code === 'ER_DB_CREATE_EXISTS') { + throw new ConflictException( + `Database already exists for user: ${identify}`, + ); + } + throw new InternalServerErrorException( + `Database initialization failed for user: ${identify}`, + ); + } + } + + public async removeDatabaseInfo(identify: string) { + try { + const dropDatabase = `drop database ${identify};`; + await this.run(dropDatabase); + const dropUser = `drop user '${identify.substring(0, 10)}';`; + await this.run(dropUser); + } catch (e) { + console.error(e); + } + } +} diff --git a/BE/src/config/query-database/admin-query-db.moudle.ts b/BE/src/config/query-database/admin-query-db.moudle.ts new file mode 100644 index 00000000..2f84ab56 --- /dev/null +++ b/BE/src/config/query-database/admin-query-db.moudle.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { AdminDBManager } from './admin-db-manager.service'; +import { UserDBManager } from './user-db-manager.service'; + +@Module({ + providers: [AdminDBManager, UserDBManager], + exports: [AdminDBManager, UserDBManager], +}) +export class QueryDBModule {} diff --git a/BE/src/config/query-database/query-db.adapter.ts b/BE/src/config/query-database/query-db.adapter.ts deleted file mode 100644 index b5456b91..00000000 --- a/BE/src/config/query-database/query-db.adapter.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Connection, Pool, QueryResult } from 'mysql2/promise'; - -export interface QueryDBAdapter { - createConnection(): Promise; - initUserDatabase(): Promise; - closeConnection(): Promise; - run(query: string): Promise; - getConnection(): Connection; - getAdminPool(): Pool; -} diff --git a/BE/src/config/query-database/query-db.moudle.ts b/BE/src/config/query-database/query-db.moudle.ts deleted file mode 100644 index 65198e9a..00000000 --- a/BE/src/config/query-database/query-db.moudle.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Module } from '@nestjs/common'; -import { SingleMySQLAdapter } from './single-mysql.adapter'; -import { SingleMySQLConnectionManager } from './single-mysql-connection-manager'; -import { ConfigModule } from '@nestjs/config'; - -export const QUERY_DB_ADAPTER = 'QUERY_DB_ADAPTER'; - -@Module({ - imports: [ - ConfigModule.forRoot({ - isGlobal: true, - }), - ], - providers: [ - { - provide: QUERY_DB_ADAPTER, - useClass: SingleMySQLAdapter, - }, - SingleMySQLConnectionManager, - ], - exports: [QUERY_DB_ADAPTER], -}) -export class QueryDBModule {} diff --git a/BE/src/config/query-database/single-mysql-connection-manager.ts b/BE/src/config/query-database/single-mysql-connection-manager.ts deleted file mode 100644 index 466368eb..00000000 --- a/BE/src/config/query-database/single-mysql-connection-manager.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { Injectable, OnModuleInit } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { Connection, createPool, Pool } from 'mysql2/promise'; - -@Injectable() -export class SingleMySQLConnectionManager implements OnModuleInit { - private adminConnection: Pool; - private userConnectionList: Record = {}; - - constructor(private readonly configService: ConfigService) {} - - async onModuleInit() { - this.createAdminConnection(); - } - - private createAdminConnection() { - this.adminConnection = createPool({ - host: this.configService.get('QUERY_DB_HOST'), - user: this.configService.get('QUERY_DB_USER'), - password: this.configService.get('QUERY_DB_PASSWORD'), - port: this.configService.get('QUERY_DB_PORT', 3306), - connectionLimit: 10, - }); - } - - public getConnection(identify: string): Connection { - return this.userConnectionList[identify]; - } - - public setConnection(identify: string, connection: Connection) { - this.userConnectionList[identify] = connection; - } - - public getAdminPool() { - return this.adminConnection; - } - - public async closeConnection(identify: string) { - await this.userConnectionList[identify].end(); - } - - public deleteConnection(identify: string) { - delete this.userConnectionList[identify]; - } -} diff --git a/BE/src/config/query-database/single-mysql.adapter.ts b/BE/src/config/query-database/single-mysql.adapter.ts deleted file mode 100644 index 0ba3592c..00000000 --- a/BE/src/config/query-database/single-mysql.adapter.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { - Injectable, - Scope, - Inject, - ConflictException, - InternalServerErrorException, -} from '@nestjs/common'; -import { QueryDBAdapter } from './query-db.adapter'; -import { Request } from 'express'; -import { - Connection, - ConnectionOptions, - createConnection, - Pool, -} from 'mysql2/promise'; -import { REQUEST } from '@nestjs/core'; -import { SingleMySQLConnectionManager } from './single-mysql-connection-manager'; -import { ConfigService } from '@nestjs/config'; -import { createReadStream } from 'fs'; - -@Injectable({ scope: Scope.REQUEST }) -export class SingleMySQLAdapter implements QueryDBAdapter { - constructor( - private singleMySQLConnectionManager: SingleMySQLConnectionManager, - @Inject(REQUEST) private readonly request: Request, - private readonly configService: ConfigService, - ) {} - - private getSID(): string { - return this.request.sessionID; - } - - getAdminPool(): Pool { - return this.singleMySQLConnectionManager.getAdminPool(); - } - - getConnection(): Connection { - return this.singleMySQLConnectionManager.getConnection(this.getSID()); - } - - public async initUserDatabase() { - const adminConnection = this.singleMySQLConnectionManager.getAdminPool(); - const identify = this.getSID(); - try { - const connectInfo = { - name: identify.substring(0, 10), - password: identify, - host: '%', - database: identify, - }; - - await adminConnection.query(`create database ${connectInfo.database};`); - await adminConnection.query( - `create user '${connectInfo.name}'@'${connectInfo.host}' identified by '${connectInfo.password}';`, - ); - await adminConnection.query( - `grant all privileges on ${connectInfo.database}.* to '${connectInfo.name}'@'${connectInfo.host}';`, - ); - } catch (error) { - if (error.code === 'ER_DB_CREATE_EXISTS') { - throw new ConflictException( - `Database already exists for user: ${identify}`, - ); - } - throw new InternalServerErrorException( - `Database initialization failed for user: ${identify}`, - ); - } - } - - public async createConnection() { - const identify = this.getSID(); - let userConnection = this.singleMySQLConnectionManager.getConnection( - this.getSID(), - ); - if (!userConnection) { - userConnection = await createConnection({ - host: this.configService.get('QUERY_DB_HOST'), - user: identify.substring(0, 10), - password: identify, - port: this.configService.get('QUERY_DB_PORT', 3306), - database: identify, - infileStreamFactory: (path) => { - return createReadStream(path); - }, - } as ConnectionOptions); - this.singleMySQLConnectionManager.setConnection(identify, userConnection); - } - await this.run('set profiling = 1;'); - } - - public async closeConnection() { - await this.singleMySQLConnectionManager.closeConnection(this.getSID()); - await this.removeDatabaseInfo(); - this.singleMySQLConnectionManager.deleteConnection(this.getSID()); - } - - public async run(query: string) { - const connection = this.getConnection(); - const [result] = await connection.query(query); - return result; - } - - private async removeDatabaseInfo() { - try { - const adminConnection = this.singleMySQLConnectionManager.getAdminPool(); - const dropDatabase = `drop database ${this.getSID()};`; - await adminConnection.execute(dropDatabase); - - const dropUser = `drop user '${this.getSID().substring(0, 10)}';`; - await adminConnection.execute(dropUser); - } catch (e) { - console.error(e); - } - } -} diff --git a/BE/src/config/query-database/user-db-manager.service.ts b/BE/src/config/query-database/user-db-manager.service.ts new file mode 100644 index 00000000..4abfe6da --- /dev/null +++ b/BE/src/config/query-database/user-db-manager.service.ts @@ -0,0 +1,12 @@ +import { Injectable } from '@nestjs/common'; +import { QueryResult } from 'mysql2/promise'; + +@Injectable() +export class UserDBManager { + constructor() {} + async run(req: any, query: string): Promise { + const connection = await req.dbConnection; + const [result] = await connection.query(query); + return result; + } +} diff --git a/BE/src/config/redis/custom-redis-store.ts b/BE/src/config/redis/custom-redis-store.ts new file mode 100644 index 00000000..02534244 --- /dev/null +++ b/BE/src/config/redis/custom-redis-store.ts @@ -0,0 +1,45 @@ +import { SessionData, Store } from 'express-session'; +import { RedisService } from './redis.service'; +import { Inject, Injectable } from '@nestjs/common'; + +@Injectable() +export class CustomRedisStore extends Store { + constructor(@Inject() private readonly redisService: RedisService) { + super(); + } + + async set( + sid: string, + session: SessionData, + cb: (err?: any) => void, + ): Promise { + try { + const ttl = (session.cookie.maxAge / 1000) | (60 * 60); // 기본값: 1시간 (sec) + await this.redisService.setExpireTime(sid, ttl); + return cb(); + } catch (err) { + cb(err); + } + } + + async get( + sid: string, + cb: (err: any, session?: SessionData | null) => void, + ): Promise { + try { + const data = await this.redisService.getSession(sid); + return cb(data); + } catch (err) { + return cb(err); + } + } + + async destroy(sid: string, cb: (err?: any) => void): Promise { + try { + await this.redisService.deleteSession(sid); + return cb(); + } catch (err) { + cb(err); + } + } +} diff --git a/BE/src/config/redis/redis.module.ts b/BE/src/config/redis/redis.module.ts index 4c5f0dea..3ba73925 100644 --- a/BE/src/config/redis/redis.module.ts +++ b/BE/src/config/redis/redis.module.ts @@ -1,6 +1,6 @@ import { Module } from '@nestjs/common'; import { RedisService } from './redis.service'; -import { QueryDBModule } from '../query-database/query-db.moudle'; +import { QueryDBModule } from '../query-database/admin-query-db.moudle'; @Module({ imports: [QueryDBModule], diff --git a/BE/src/config/redis/redis.service.ts b/BE/src/config/redis/redis.service.ts index 80076ef2..585d1583 100644 --- a/BE/src/config/redis/redis.service.ts +++ b/BE/src/config/redis/redis.service.ts @@ -1,7 +1,7 @@ import Redis from 'ioredis'; -import { Inject, Injectable } from '@nestjs/common'; -import { QueryDBAdapter } from '../query-database/query-db.adapter'; -import { QUERY_DB_ADAPTER } from '../query-database/query-db.moudle'; +import { Injectable } from '@nestjs/common'; +import { AdminDBManager } from '../query-database/admin-db-manager.service'; +import { ConfigService } from '@nestjs/config'; @Injectable() export class RedisService { @@ -9,7 +9,8 @@ export class RedisService { private eventConnection: Redis; constructor( - @Inject(QUERY_DB_ADAPTER) private readonly queryDBAdapter: QueryDBAdapter, + private readonly adminDBManager: AdminDBManager, + private readonly configService: ConfigService, ) { this.setDefaultConnection(); this.setEventConnection(); @@ -17,8 +18,8 @@ export class RedisService { private setDefaultConnection() { this.defaultConnection = new Redis({ - host: process.env.REDIS_HOST, - port: parseInt(process.env.REDIS_PORT), + host: this.configService.get('REDIS_HOST'), + port: this.configService.get('REDIS_PORT'), }); this.defaultConnection.on('ready', () => { @@ -41,30 +42,46 @@ export class RedisService { if (!key) { return null; } - return this.defaultConnection.get(key); + return this.defaultConnection.hgetall(key); + } + + public async existSession(key: string) { + return this.defaultConnection.exists(key); } public async setNewSession(key: string) { - const session = await this.getSession(key); + const session = await this.existSession(key); if (!session) { - await this.queryDBAdapter.initUserDatabase(); + await this.defaultConnection.hset(key, 'rowCount', 0); + await this.adminDBManager.initUserDatabase(key); } - await this.queryDBAdapter.createConnection(); + } + + public async deleteSession(key: string) { + await this.defaultConnection.del(key); + } + + public async setExpireTime(key: string, ttl: number) { + await this.defaultConnection.expire(key, ttl); } private subscribeToExpiredEvents() { this.eventConnection.subscribe('__keyevent@0__:expired'); - this.eventConnection.on('message', () => { - this.queryDBAdapter.closeConnection(); + this.eventConnection.on('message', (event, session) => { + this.adminDBManager.removeDatabaseInfo(session); }); } - public getDefaultConnection() { - return this.defaultConnection; + public async getRowCount(key: string) { + return this.defaultConnection.hget(key, 'rowCount'); } - public getEventConnection() { - return this.eventConnection; + public async setRowCount(key: string, rowCount: number) { + await this.defaultConnection.hset(key, 'rowCount', rowCount); + } + + public getDefaultConnection() { + return this.defaultConnection; } } diff --git a/BE/src/config/swagger/record-swagger.decorator.ts b/BE/src/config/swagger/record-swagger.decorator.ts new file mode 100644 index 00000000..b7ebba70 --- /dev/null +++ b/BE/src/config/swagger/record-swagger.decorator.ts @@ -0,0 +1,35 @@ +import { applyDecorators } from '@nestjs/common'; +import { + ApiBadRequestResponse, + ApiCreatedResponse, + ApiOperation, + getSchemaPath, +} from '@nestjs/swagger'; +import { ResponseDto } from '../../common/response/response.dto'; +import { ResQueryDto } from '../../query/dto/res-query.dto'; +import { ResRecordDto } from '../../record/dto/res-record.dto'; + +export function ExecuteRecordSwagger() { + return applyDecorators( + ApiOperation({ + summary: '사용자는 테이블에 조건에 따른 랜덤 데이터를 삽입할 수 있다.', + description: '테이블에 따라 삽입이 가능합니다.', + }), + ApiCreatedResponse({ + description: '요청 성공 시', + schema: { + allOf: [ + { $ref: getSchemaPath(ResponseDto) }, + { + properties: { + data: { $ref: getSchemaPath(ResRecordDto) }, + }, + }, + ], + }, + }), + ApiBadRequestResponse({ + description: 'MySQL에서 요청 쿼리에 대해 오류가 발생할 경우', + }), + ); +} diff --git a/BE/src/config/swagger/shell-swagger.decorator.ts b/BE/src/config/swagger/shell-swagger.decorator.ts index 073be7c9..6c3b3f0f 100644 --- a/BE/src/config/swagger/shell-swagger.decorator.ts +++ b/BE/src/config/swagger/shell-swagger.decorator.ts @@ -7,6 +7,7 @@ import { } from '@nestjs/swagger'; import { ResponseDto } from '../../common/response/response.dto'; import { ResShellDto } from '../../shell/dto/res-shell.dto'; +import { ResShellResultDto } from '../../shell/dto/res-shell-result.dto'; export function CreateShellSwagger() { return applyDecorators( @@ -51,3 +52,51 @@ export function UpdateShellSwagger() { }), ); } + +export function GetAllShellSwagger() { + return applyDecorators( + ApiOperation({ + summary: '모든 쉘을 가져온다', + description: '사용자 세션에 해당하는 모든 쉘을 가져온다', + }), + ApiOkResponse({ + description: '성공시', + schema: { + allOf: [ + { $ref: getSchemaPath(ResponseDto) }, + { + type: 'object', + properties: { + data: { + type: 'array', + items: { $ref: getSchemaPath(ResShellResultDto) }, + }, + }, + }, + ], + }, + }), + ); +} + +export function GetShellSwagger() { + return applyDecorators( + ApiOperation({ + summary: '특정 쉘을 가져온다.', + description: '특정 쉘을 렌더링 할때 실행.', + }), + ApiOkResponse({ + description: '성공시', + schema: { + allOf: [ + { $ref: getSchemaPath(ResponseDto) }, + { + properties: { + data: { $ref: getSchemaPath(ResShellResultDto) }, + }, + }, + ], + }, + }), + ); +} diff --git a/BE/src/config/swagger/table-swagger.decorator.ts b/BE/src/config/swagger/table-swagger.decorator.ts index f92926e3..22bafd88 100644 --- a/BE/src/config/swagger/table-swagger.decorator.ts +++ b/BE/src/config/swagger/table-swagger.decorator.ts @@ -29,7 +29,7 @@ export function GetTableSwagger() { export function GetTableListSwagger() { return applyDecorators( ApiOperation({ - summary: '모든 테이블 정브를 가져온다.', + summary: '모든 테이블 정보를 가져온다.', description: '현재 session에 대한 모든 테이블 정보를 가져온다', }), ApiOkResponse({ diff --git a/BE/src/guard/guard.module.ts b/BE/src/guard/guard.module.ts index 4efd1f95..e2598353 100644 --- a/BE/src/guard/guard.module.ts +++ b/BE/src/guard/guard.module.ts @@ -1,9 +1,9 @@ import { Module } from '@nestjs/common'; import { ShellGuard } from './shell.guard'; -import { ShellService } from '../shell/shell.service'; +import { ShellModule } from '../shell/shell.module'; @Module({ - imports: [ShellService], + imports: [ShellModule], providers: [ShellGuard], }) export class GuardModule {} diff --git a/BE/src/interceptors/logging.interceptor.ts b/BE/src/interceptors/logging.interceptor.ts new file mode 100644 index 00000000..23fdd4da --- /dev/null +++ b/BE/src/interceptors/logging.interceptor.ts @@ -0,0 +1,38 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, +} from '@nestjs/common'; +import { Observable, tap } from 'rxjs'; +import { + LoggerService, + RequestInfo, + ResponseInfo, +} from 'src/config/logger/logger.service'; + +@Injectable() +export class LoggingInterceptor implements NestInterceptor { + constructor(private readonly loggerService: LoggerService) {} + + intercept( + context: ExecutionContext, + next: CallHandler, + ): Observable | Promise> { + const requestInfo: RequestInfo = { + method: context.switchToHttp().getRequest().method, + url: context.switchToHttp().getRequest().url, + body: context.switchToHttp().getRequest().body, + sessionId: context.switchToHttp().getRequest().sessionID, + }; + this.loggerService.logRequest(requestInfo); + return next.handle().pipe( + tap(() => { + const responseInfo: ResponseInfo = { + status: context.switchToHttp().getResponse().statusCode, + }; + this.loggerService.logResponse(responseInfo); + }), + ); + } +} diff --git a/BE/src/interceptors/user-db-connection.interceptor.ts b/BE/src/interceptors/user-db-connection.interceptor.ts new file mode 100644 index 00000000..486946f9 --- /dev/null +++ b/BE/src/interceptors/user-db-connection.interceptor.ts @@ -0,0 +1,42 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, +} from '@nestjs/common'; +import { createConnection } from 'mysql2/promise'; +import { ConfigService } from '@nestjs/config'; +import { Observable, tap } from 'rxjs'; +import { createReadStream } from 'fs'; + +@Injectable() +export class UserDBConnectionInterceptor implements NestInterceptor { + constructor(private readonly configService: ConfigService) {} + + async intercept( + context: ExecutionContext, + next: CallHandler, + ): Promise> { + const request = context.switchToHttp().getRequest(); + const identify = request.sessionID; + + request.dbConnection = await createConnection({ + host: this.configService.get('QUERY_DB_HOST'), + user: identify.substring(0, 10), + password: identify, + port: this.configService.get('QUERY_DB_PORT', 3306), + database: identify, + infileStreamFactory: (path) => { + return createReadStream(path); + }, + }); + + await request.dbConnection.query('set profiling = 1'); + + return next.handle().pipe( + tap(async () => { + await request.dbConnection.end(); + }), + ); + } +} diff --git a/BE/src/main.ts b/BE/src/main.ts index baf2fe05..869cb5d9 100644 --- a/BE/src/main.ts +++ b/BE/src/main.ts @@ -1,6 +1,5 @@ import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; -import { ValidationPipe } from '@nestjs/common'; import { setupSwagger } from './config/swagger/swagger.config'; import cookieParser from 'cookie-parser'; import { corsOptions } from './config/cors/cors.config'; @@ -8,7 +7,6 @@ import { corsOptions } from './config/cors/cors.config'; async function bootstrap() { const app = await NestFactory.create(AppModule); app.enableCors(corsOptions); - app.useGlobalPipes(new ValidationPipe()); setupSwagger(app); app.use(cookieParser()); await app.listen(process.env.PORT ?? 3000); diff --git a/BE/src/middleware/session.middleware.ts b/BE/src/middleware/session.middleware.ts index 0b680f24..d3526b4a 100644 --- a/BE/src/middleware/session.middleware.ts +++ b/BE/src/middleware/session.middleware.ts @@ -2,28 +2,29 @@ import session from 'express-session'; import { NextFunction, Request, Response } from 'express'; import { Injectable, NestMiddleware } from '@nestjs/common'; import { v4 as uuidv4 } from 'uuid'; -import RedisStore from 'connect-redis'; +import { CustomRedisStore } from 'src/config/redis/custom-redis-store'; import { RedisService } from 'src/config/redis/redis.service'; +import { ConfigService } from '@nestjs/config'; @Injectable() export class SessionMiddleware implements NestMiddleware { - constructor(private readonly redisService: RedisService) {} + constructor( + private readonly redisService: RedisService, + private readonly configService: ConfigService, + ) {} use(req: Request, res: Response, next: NextFunction) { session({ - secret: process.env.SESSION_SECRET, + secret: this.configService.get('SESSION_SECRET'), resave: false, saveUninitialized: true, rolling: true, - store: new RedisStore({ - client: this.redisService.getDefaultConnection(), - prefix: '', - }), + store: new CustomRedisStore(this.redisService), genid: () => { return 'db' + uuidv4().replace(/[^a-zA-Z0-9]/g, ''); }, cookie: { - maxAge: 1000 * 60 * 60, + maxAge: 1000 * 60 * 60, // 1시간 (ms) }, name: 'sid', })(req, res, async () => { diff --git a/BE/src/query/query.controller.ts b/BE/src/query/query.controller.ts index a7e6b2e0..f879cb38 100644 --- a/BE/src/query/query.controller.ts +++ b/BE/src/query/query.controller.ts @@ -6,6 +6,7 @@ import { Req, UseFilters, UseGuards, + UseInterceptors, } from '@nestjs/common'; import { QueryService } from './query.service'; import { QueryDto } from './dto/query.dto'; @@ -17,6 +18,7 @@ import { Request } from 'express'; import { Serialize } from '../interceptors/serialize.interceptor'; import { ExceptionHandler } from '../common/exception/exception.handler'; import { ShellGuard } from '../guard/shell.guard'; +import { UserDBConnectionInterceptor } from '../interceptors/user-db-connection.interceptor'; @ApiExtraModels(ResponseDto, ResQueryDto) @ApiTags('쿼리 API') @@ -25,6 +27,7 @@ import { ShellGuard } from '../guard/shell.guard'; export class QueryController { constructor(private readonly queryService: QueryService) {} + @UseInterceptors(UserDBConnectionInterceptor) @ExecuteQuerySwagger() @Serialize(ResQueryDto) @Post('/:shellId/execute') @@ -34,6 +37,6 @@ export class QueryController { @Param('shellId') shellId: number, @Body() queryDto: QueryDto, ) { - return await this.queryService.execute(req.sessionID, shellId, queryDto); + return await this.queryService.execute(req, shellId, queryDto); } } diff --git a/BE/src/query/query.module.ts b/BE/src/query/query.module.ts index 6fca1f06..5955307a 100644 --- a/BE/src/query/query.module.ts +++ b/BE/src/query/query.module.ts @@ -1,14 +1,16 @@ import { Module } from '@nestjs/common'; import { QueryService } from './query.service'; import { QueryController } from './query.controller'; -import { QueryDBModule } from '../config/query-database/query-db.moudle'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Shell } from '../shell/shell.entity'; import { ShellService } from '../shell/shell.service'; +import { UserDBManager } from '../config/query-database/user-db-manager.service'; +import { UsageModule } from '../usage/usage.module'; +import { RedisModule } from '../config/redis/redis.module'; @Module({ - imports: [QueryDBModule, TypeOrmModule.forFeature([Shell])], + imports: [TypeOrmModule.forFeature([Shell]), UsageModule, RedisModule], controllers: [QueryController], - providers: [QueryService, ShellService], + providers: [QueryService, ShellService, UserDBManager], }) export class QueryModule {} diff --git a/BE/src/query/query.service.ts b/BE/src/query/query.service.ts index ea5f7af0..27b87234 100644 --- a/BE/src/query/query.service.ts +++ b/BE/src/query/query.service.ts @@ -1,24 +1,25 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { QueryDto } from './dto/query.dto'; -import { QUERY_DB_ADAPTER } from '../config/query-database/query-db.moudle'; -import { QueryDBAdapter } from '../config/query-database/query-db.adapter'; import { QueryType } from '../common/enums/query-type.enum'; import { ShellService } from '../shell/shell.service'; import { ResultSetHeader, RowDataPacket } from 'mysql2/promise'; import { Shell } from '../shell/shell.entity'; +import { UserDBManager } from '../config/query-database/user-db-manager.service'; +import { UsageService } from 'src/usage/usage.service'; @Injectable() export class QueryService { constructor( - @Inject(QUERY_DB_ADAPTER) private readonly queryDBAdapter: QueryDBAdapter, + private readonly userDBManager: UserDBManager, private shellService: ShellService, + private readonly usageService: UsageService, ) {} - async execute(sessionId: string, shellId: number, queryDto: QueryDto) { + async execute(req: any, shellId: number, queryDto: QueryDto) { await this.shellService.findShellOrThrow(shellId); const baseUpdateData = { - sessionId: sessionId, + sessionId: req.sessionID, query: queryDto.query, queryType: this.detectQueryType(queryDto.query), }; @@ -31,11 +32,11 @@ export class QueryService { }); } const updateData = await this.processQuery( + req, baseUpdateData, - sessionId, queryDto.query, ); - + this.usageService.updateRowCount(req); return await this.shellService.replace(shellId, updateData); } catch (e) { const text = `ERROR ${e.errno || ''} (${e.sqlState || ''}): ${e.sqlMessage || ''}`; @@ -51,14 +52,16 @@ export class QueryService { } private async processQuery( + req: any, baseUpdateData: any, - sessionId: string, query: string, ): Promise> { const isResultTable = this.existResultTable(baseUpdateData.queryType); - const rows = await this.queryDBAdapter.run(query); - const runTime = await this.measureQueryRunTime(sessionId); + const rows = await this.userDBManager.run(req, query); + const runTime = await this.measureQueryRunTime(req); + + // Update usage let text: string; let resultTable: RowDataPacket[]; @@ -97,9 +100,10 @@ export class QueryService { return validTypes.includes(type); } - async measureQueryRunTime(sessionId: string): Promise { + async measureQueryRunTime(req: any): Promise { try { - const rows = (await this.queryDBAdapter.run( + const rows = (await this.userDBManager.run( + req, 'show profiles;', )) as RowDataPacket[]; let lastQueryRunTime = rows[rows.length - 1]?.Duration; diff --git a/BE/src/record/constant/random-record.constant.ts b/BE/src/record/constant/random-record.constant.ts new file mode 100644 index 00000000..653c366e --- /dev/null +++ b/BE/src/record/constant/random-record.constant.ts @@ -0,0 +1,32 @@ +import { + BooleanGenerator, + CityGenerator, + CountryGenerator, + EmailGenerator, + NameGenerator, + PhoneGenerator, + SexGenerator, +} from '../domain'; + +export const RANDOM_DATA_TEMP_DIR = 'csvTemp'; +export const RECORD_PROCESS_BATCH_SIZE = 10000; + +export const generalDomain = [ + 'name', + 'country', + 'city', + 'email', + 'phone', + 'sex', + 'boolean', +]; + +export const TypeToConstructor = { + name: NameGenerator, + country: CountryGenerator, + city: CityGenerator, + email: EmailGenerator, + phone: PhoneGenerator, + sex: SexGenerator, + boolean: BooleanGenerator, +}; diff --git a/BE/src/record/domain/random-value-generator.interface.ts b/BE/src/record/domain/random-value-generator.interface.ts index af73143d..0a80f294 100644 --- a/BE/src/record/domain/random-value-generator.interface.ts +++ b/BE/src/record/domain/random-value-generator.interface.ts @@ -1,12 +1,12 @@ export abstract class RandomValueGenerator { - getRandomValue(): T { - return undefined; - } - getRandomValues(length: number, blank: number): (T | string)[] { - if (blank === 0) return Array.from({ length }, () => this.getRandomValue()); - if (blank === 100) return new Array(length).fill('NULL'); - return Array.from({ length }, () => - Math.random() * 100 > blank ? this.getRandomValue() : 'NULL' - ); - } + getRandomValue(): T { + return undefined; + } + getRandomValues(length: number, blank: number): (T | string)[] { + if (blank === 0) return Array.from({ length }, () => this.getRandomValue()); + if (blank === 100) return new Array(length).fill('NULL'); + return Array.from({ length }, () => + Math.random() * 100 > blank ? this.getRandomValue() : 'NULL', + ); + } } diff --git a/BE/src/record/dto/record.dto.ts b/BE/src/record/dto/create-random-record.dto.ts similarity index 96% rename from BE/src/record/dto/record.dto.ts rename to BE/src/record/dto/create-random-record.dto.ts index 788760d3..8817429a 100644 --- a/BE/src/record/dto/record.dto.ts +++ b/BE/src/record/dto/create-random-record.dto.ts @@ -26,7 +26,7 @@ export enum Domains { ENUM = 'enum', } -export class RandomRecordInsertDto { +export class CreateRandomRecordDto { @IsString() @IsNotEmpty() tableName: string; diff --git a/BE/src/record/dto/res-record.dto.ts b/BE/src/record/dto/res-record.dto.ts new file mode 100644 index 00000000..cf0954aa --- /dev/null +++ b/BE/src/record/dto/res-record.dto.ts @@ -0,0 +1,21 @@ +import { Expose } from 'class-transformer'; + +export class ResRecordDto { + /** + * 전체 데이터 삽입 성공 여부 + * @example true + */ + @Expose() + status: boolean; + + /** + * 사용자에게 보여줄 결과 텍스트 + * @example "user 에 랜덤 레코드 100개 삼입되었습니다." + */ + @Expose() + text: string; + + constructor(partial: Partial) { + Object.assign(this, partial); + } +} diff --git a/BE/src/record/randomColumn.entity.ts b/BE/src/record/random-column.entity.ts similarity index 77% rename from BE/src/record/randomColumn.entity.ts rename to BE/src/record/random-column.entity.ts index b7cc5d20..f50caf63 100644 --- a/BE/src/record/randomColumn.entity.ts +++ b/BE/src/record/random-column.entity.ts @@ -1,4 +1,4 @@ -import { Domains } from './dto/record.dto'; +import { Domains } from './dto/create-random-record.dto'; import { RandomValueGenerator } from './domain'; export interface RandomColumnEntity { diff --git a/BE/src/record/record.controller.ts b/BE/src/record/record.controller.ts index 07762f82..57d8c503 100644 --- a/BE/src/record/record.controller.ts +++ b/BE/src/record/record.controller.ts @@ -1,19 +1,37 @@ -import { Body, Controller, Post, Req, UseFilters } from '@nestjs/common'; +import { + Body, + Controller, + Post, + Req, + UseFilters, + UseInterceptors, +} from '@nestjs/common'; import { RecordService } from './record.service'; -import { RandomRecordInsertDto } from './dto/record.dto'; -import { ApiExtraModels } from '@nestjs/swagger'; +import { CreateRandomRecordDto } from './dto/create-random-record.dto'; +import { ApiExtraModels, ApiTags } from '@nestjs/swagger'; import { ExceptionHandler } from 'src/common/exception/exception.handler'; +import { ResponseDto } from '../common/response/response.dto'; +import { ResRecordDto } from './dto/res-record.dto'; +import { ExecuteRecordSwagger } from '../config/swagger/record-swagger.decorator'; +import { Serialize } from '../interceptors/serialize.interceptor'; +import { UserDBConnectionInterceptor } from '../interceptors/user-db-connection.interceptor'; import { Request } from 'express'; -@ApiExtraModels(RandomRecordInsertDto) +@ApiExtraModels(ResponseDto, ResRecordDto) +@ApiTags('랜덤 데이터 생성 API') @UseFilters(new ExceptionHandler()) @Controller('api/record') export class RecordController { constructor(private recordService: RecordService) {} + @UseInterceptors(UserDBConnectionInterceptor) + @ExecuteRecordSwagger() + @Serialize(ResRecordDto) @Post() - insertRandomRecord(@Body() body: RandomRecordInsertDto, @Req() req: Request) { - const status = this.recordService.insertRandomRecord(req.sessionID, body); - return status; + insertRandomRecord( + @Req() req: Request, + @Body() randomRecordInsertDto: CreateRandomRecordDto, + ) { + return this.recordService.insertRandomRecord(req, randomRecordInsertDto); } } diff --git a/BE/src/record/record.module.ts b/BE/src/record/record.module.ts index 82484c1a..b57be9d3 100644 --- a/BE/src/record/record.module.ts +++ b/BE/src/record/record.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; import { RecordController } from './record.controller'; import { RecordService } from './record.service'; -import { QueryDBModule } from 'src/config/query-database/query-db.moudle'; +import { RedisModule } from '../config/redis/redis.module'; +import { QueryDBModule } from '../config/query-database/admin-query-db.moudle'; @Module({ - imports: [QueryDBModule], + imports: [RedisModule, QueryDBModule], controllers: [RecordController], providers: [RecordService], }) diff --git a/BE/src/record/record.service.ts b/BE/src/record/record.service.ts index d7889286..c88ab38e 100644 --- a/BE/src/record/record.service.ts +++ b/BE/src/record/record.service.ts @@ -1,58 +1,30 @@ import { - Inject, Injectable, InternalServerErrorException, OnModuleInit, } from '@nestjs/common'; -import { QueryDBAdapter } from 'src/config/query-database/query-db.adapter'; -import { QUERY_DB_ADAPTER } from 'src/config/query-database/query-db.moudle'; +import { EnumGenerator, NumberGenerator, RandomValueGenerator } from './domain'; import { - RandomValueGenerator, - BooleanGenerator, - CityGenerator, - CountryGenerator, - EmailGenerator, - EnumGenerator, - NameGenerator, - NumberGenerator, - PhoneGenerator, - SexGenerator, -} from './domain'; -import { RandomColumnInfo, RandomRecordInsertDto } from './dto/record.dto'; -import { RandomColumnEntity } from './randomColumn.entity'; + CreateRandomRecordDto, + RandomColumnInfo, +} from './dto/create-random-record.dto'; +import { RandomColumnEntity } from './random-column.entity'; import fs from 'fs/promises'; import crypto from 'crypto'; import path from 'path'; import { ResultSetHeader } from 'mysql2'; - -const RANDOM_DATA_TEMP_DIR = 'csvTemp'; -const RECORD_PROCESS_BATCH_SIZE = 10000; - -const generalDomain = [ - 'name', - 'country', - 'city', - 'email', - 'phone', - 'sex', - 'boolean', -]; - -const TypeToConstructor = { - name: NameGenerator, - country: CountryGenerator, - city: CityGenerator, - email: EmailGenerator, - phone: PhoneGenerator, - sex: SexGenerator, - boolean: BooleanGenerator, -}; +import { ResRecordDto } from './dto/res-record.dto'; +import { + generalDomain, + RANDOM_DATA_TEMP_DIR, + RECORD_PROCESS_BATCH_SIZE, + TypeToConstructor, +} from './constant/random-record.constant'; +import { UserDBManager } from '../config/query-database/user-db-manager.service'; @Injectable() export class RecordService implements OnModuleInit { - constructor( - @Inject(QUERY_DB_ADAPTER) private readonly queryDBAdapter: QueryDBAdapter, - ) {} + constructor(private readonly userDBManager: UserDBManager) {} async onModuleInit() { try { @@ -60,39 +32,66 @@ export class RecordService implements OnModuleInit { } catch (err) { if (err.code === 'ENOENT') { await fs.mkdir(RANDOM_DATA_TEMP_DIR, { recursive: true }); - console.log('csvTemp 폴더를 생성했습니다.'); } else { - console.error('폴더 확인 중 오류 발생 : ', err); + console.error('csv 폴더 접근 오류: ', err); } } } - private toEntity(column: RandomColumnInfo): RandomColumnEntity { + async insertRandomRecord( + req: any, + createRandomRecordDto: CreateRandomRecordDto, + ): Promise { + const columnEntities: RandomColumnEntity[] = + createRandomRecordDto.columns.map((column) => this.toEntity(column)); + const columnNames = columnEntities.map((column) => column.name); + + const csvFilePath = await this.generateCsvFile( + columnEntities, + createRandomRecordDto.count, + ); + + const result = await this.insertCsvIntoDB( + req, + csvFilePath, + createRandomRecordDto.tableName, + columnNames, + ); + + await this.deleteFile(csvFilePath); + + return new ResRecordDto({ + status: result.affectedRows === createRandomRecordDto.count, + text: `${createRandomRecordDto.tableName} 에 랜덤 레코드 ${result.affectedRows}개 삽입되었습니다.`, + }); + } + + private toEntity(randomColumnInfo: RandomColumnInfo): RandomColumnEntity { let generator: RandomValueGenerator; - if (generalDomain.includes(column.type)) - generator = new TypeToConstructor[column.type](); - if (column.type === 'enum') generator = new EnumGenerator(column.enum); - if (column.type === 'number') - generator = new NumberGenerator(column.min ?? 0, column.max ?? 100); + if (generalDomain.includes(randomColumnInfo.type)) + generator = new TypeToConstructor[randomColumnInfo.type](); + if (randomColumnInfo.type === 'enum') + generator = new EnumGenerator(randomColumnInfo.enum); + if (randomColumnInfo.type === 'number') + generator = new NumberGenerator( + randomColumnInfo.min ?? 0, + randomColumnInfo.max ?? 100, + ); return { - name: column.name, - type: column.type, + name: randomColumnInfo.name, + type: randomColumnInfo.type, generator, data: [], - blank: column.blank, + blank: randomColumnInfo.blank, }; } private async generateCsvFile( - sid: string, columnEntities: RandomColumnEntity[], rows: number, ): Promise { - const randomString = crypto.randomBytes(4).toString('hex'); - const filePath = path.join( - RANDOM_DATA_TEMP_DIR, - `${sid}.${randomString}.csv`, - ); + const randomString = crypto.randomBytes(10).toString('hex'); + const filePath = path.join(RANDOM_DATA_TEMP_DIR, `${randomString}.csv`); const header = this.generateCsvHeader(columnEntities); await fs.writeFile(filePath, header); @@ -103,9 +102,9 @@ export class RecordService implements OnModuleInit { try { await fs.writeFile(filePath, data, { flag: 'a' }); } catch (err) { - console.error(err); + console.error('CSV 파일 쓰기 실패:', err); throw new InternalServerErrorException({ - message: 'CSV 파일 쓰기 실패 : ' + err.message, + message: 'CSV 파일 쓰기 실패:: ' + err.message, error: err.message, }); } @@ -137,12 +136,12 @@ export class RecordService implements OnModuleInit { } async insertCsvIntoDB( - sid: string, + req: any, csvFilePath: string, tableName: string, columnNames: string[], ): Promise { - const sql = ` + const query = ` LOAD DATA LOCAL INFILE \'${csvFilePath.replace(/\\/g, '\\\\')}\' INTO TABLE ${tableName} FIELDS TERMINATED BY ',' @@ -153,13 +152,14 @@ export class RecordService implements OnModuleInit { let queryResult: ResultSetHeader; try { - queryResult = (await this.queryDBAdapter.run( - sql, - )) as unknown as ResultSetHeader; + queryResult = (await this.userDBManager.run( + req, + query, + )) as ResultSetHeader; } catch (err) { - console.error('Error while inserting data into DB:', err); + console.error('랜덤 데이터 DB 삽입중 에러:', err); throw new InternalServerErrorException({ - message: 'DB .csv load Data 실패 :' + err.message, + message: '랜덤 데이터 DB 삽입중 에러:' + err.message, error: err.message, }); } @@ -172,40 +172,11 @@ export class RecordService implements OnModuleInit { await fs.unlink(filePath); return true; } catch (err) { - console.error('Error while deleting the file:', err); + console.error('CSV 파일 삭제 실패:', err); throw new InternalServerErrorException({ - message: 'CSV 파일 쓰기 실패 : ' + err.message, + message: 'CSV 파일 삭제 실패: ' + err.message, error: err.message, }); } } - - async insertRandomRecord( - sid: string, - recordDto: RandomRecordInsertDto, - ): Promise { - const columnEntities: RandomColumnEntity[] = recordDto.columns.map( - (column) => this.toEntity(column), - ); - const columnNames = columnEntities.map((column) => column.name); - - const csvFilePath = await this.generateCsvFile( - sid, - columnEntities, - recordDto.count, - ); - - const result = await this.insertCsvIntoDB( - sid, - csvFilePath, - recordDto.tableName, - columnNames, - ); - await this.deleteFile(csvFilePath); - - return { - status: result.affectedRows === recordDto.count, - text: `${recordDto.tableName} 에 랜덤 레코드 ${result.affectedRows}개 삽입되었습니다.`, - }; - } } diff --git a/BE/src/shell/shell.controller.ts b/BE/src/shell/shell.controller.ts index ff1185dc..589d5793 100644 --- a/BE/src/shell/shell.controller.ts +++ b/BE/src/shell/shell.controller.ts @@ -19,6 +19,8 @@ import { ApiExtraModels } from '@nestjs/swagger'; import { ResponseDto } from '../common/response/response.dto'; import { CreateShellSwagger, + GetAllShellSwagger, + GetShellSwagger, UpdateShellSwagger, } from '../config/swagger/shell-swagger.decorator'; import { Request } from 'express'; @@ -32,6 +34,7 @@ export class ShellController { constructor(private shellService: ShellService) {} @Get() + @GetAllShellSwagger() @Serialize(ResShellResultDto) async findAll(@Req() req: Request) { const sessionId = req.sessionID; @@ -39,6 +42,7 @@ export class ShellController { } @Get(':shellId') + @GetShellSwagger() @Serialize(ResShellResultDto) async findOne(@Param('shellId') shellId: number) { return await this.shellService.findShellOrThrow(shellId); diff --git a/BE/src/shell/shell.module.ts b/BE/src/shell/shell.module.ts index 4847202c..88f8f3fd 100644 --- a/BE/src/shell/shell.module.ts +++ b/BE/src/shell/shell.module.ts @@ -8,5 +8,6 @@ import { Shell } from './shell.entity'; imports: [TypeOrmModule.forFeature([Shell])], controllers: [ShellController], providers: [ShellService], + exports: [ShellService], }) export class ShellModule {} diff --git a/BE/src/table/dto/res-table.dto.ts b/BE/src/table/dto/res-table.dto.ts index aab396cd..362983e0 100644 --- a/BE/src/table/dto/res-table.dto.ts +++ b/BE/src/table/dto/res-table.dto.ts @@ -40,4 +40,4 @@ export class ColumnDto { constructor(init?: Partial) { Object.assign(this, init); } -} \ No newline at end of file +} diff --git a/BE/src/table/dto/res-tables.dto.ts b/BE/src/table/dto/res-tables.dto.ts index a65af1bc..2e9c12a7 100644 --- a/BE/src/table/dto/res-tables.dto.ts +++ b/BE/src/table/dto/res-tables.dto.ts @@ -1,11 +1,11 @@ -import {Expose} from "class-transformer"; -import {ResTableDto} from "./res-table.dto"; +import { Expose } from 'class-transformer'; +import { ResTableDto } from './res-table.dto'; -export class ResTablesDto{ - @Expose() - tables : ResTableDto[]; +export class ResTablesDto { + @Expose() + tables: ResTableDto[]; - constructor(tables: ResTableDto[]) { - this.tables = tables; - } -} \ No newline at end of file + constructor(tables: ResTableDto[]) { + this.tables = tables; + } +} diff --git a/BE/src/table/table.controller.ts b/BE/src/table/table.controller.ts index aea6a01d..2782e3d3 100644 --- a/BE/src/table/table.controller.ts +++ b/BE/src/table/table.controller.ts @@ -1,15 +1,16 @@ import { Controller, Get, Param, Req, UseFilters } from '@nestjs/common'; -import {ApiExtraModels, ApiTags} from '@nestjs/swagger'; +import { ApiExtraModels, ApiTags } from '@nestjs/swagger'; import { Request } from 'express'; import { ExceptionHandler } from '../common/exception/exception.handler'; import { TableService } from './table.service'; import { Serialize } from '../interceptors/serialize.interceptor'; import { ResTableDto } from './dto/res-table.dto'; -import {ResTablesDto} from "./dto/res-tables.dto"; -import {GetTableListSwagger, GetTableSwagger} from "../config/swagger/table-swagger.decorator"; -import {ResponseDto} from "../common/response/response.dto"; -import {ResShellDto} from "../shell/dto/res-shell.dto"; -import {ResShellResultDto} from "../shell/dto/res-shell-result.dto"; +import { ResTablesDto } from './dto/res-tables.dto'; +import { + GetTableListSwagger, + GetTableSwagger, +} from '../config/swagger/table-swagger.decorator'; +import { ResponseDto } from '../common/response/response.dto'; @ApiExtraModels(ResponseDto, ResTablesDto, ResTableDto) @ApiTags('테이블 가져오기 API') diff --git a/BE/src/table/table.module.ts b/BE/src/table/table.module.ts index 04e85b40..73944f91 100644 --- a/BE/src/table/table.module.ts +++ b/BE/src/table/table.module.ts @@ -1,11 +1,12 @@ import { Module } from '@nestjs/common'; import { TableService } from './table.service'; import { TableController } from './table.controller'; -import { QueryDBModule } from '../config/query-database/query-db.moudle'; +import { QueryDBModule } from '../config/query-database/admin-query-db.moudle'; @Module({ imports: [QueryDBModule], controllers: [TableController], providers: [TableService], + exports: [TableService], }) export class TableModule {} diff --git a/BE/src/table/table.service.ts b/BE/src/table/table.service.ts index dbc1a69e..5bee02de 100644 --- a/BE/src/table/table.service.ts +++ b/BE/src/table/table.service.ts @@ -1,23 +1,20 @@ -import { Inject, Injectable } from '@nestjs/common'; -import { QUERY_DB_ADAPTER } from '../config/query-database/query-db.moudle'; -import { QueryDBAdapter } from '../config/query-database/query-db.adapter'; -import { Pool } from 'mysql2/promise'; +import { Injectable } from '@nestjs/common'; import { ColumnDto, ResTableDto } from './dto/res-table.dto'; import { ResTablesDto } from './dto/res-tables.dto'; +import { AdminDBManager } from '../config/query-database/admin-db-manager.service'; @Injectable() export class TableService { - constructor( - @Inject(QUERY_DB_ADAPTER) private readonly queryDBAdapter: QueryDBAdapter, - ) {} + constructor(private readonly adminDBManager: AdminDBManager) {} async findAll(sessionId: string) { const tables = await this.getTables(sessionId); const columns = await this.getColumns(sessionId); const foreignKeys = await this.getForeignKeys(sessionId); + const indexes = await this.getIndexes(sessionId); return new ResTablesDto( - this.mapTablesWithColumnsAndKeys(tables, columns, foreignKeys), + this.mapTablesWithColumnsAndKeys(tables, columns, foreignKeys, indexes), ); } @@ -25,38 +22,42 @@ export class TableService { const tables = await this.getTables(sessionId, tableName); const columns = await this.getColumns(sessionId, tableName); const foreignKeys = await this.getForeignKeys(sessionId, tableName); + const indexes = await this.getIndexes(sessionId); return ( - this.mapTablesWithColumnsAndKeys(tables, columns, foreignKeys)[0] || [] + this.mapTablesWithColumnsAndKeys( + tables, + columns, + foreignKeys, + indexes, + )[0] || [] ); } - private async getTables(schema: string, tableName?: string) { - const pool = this.queryDBAdapter.getAdminPool(); + async getTables(schema: string, tableName?: string) { const query = ` SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = ? ${tableName ? 'AND TABLE_NAME = ?' : ''} `; const params = tableName ? [schema, tableName] : [schema]; - const [tables] = await pool.query(query, params); + const [tables] = await this.adminDBManager.run(query, params); return tables as any[]; } private async getColumns(schema: string, tableName?: string) { - const pool = this.queryDBAdapter.getAdminPool(); const query = ` - SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE, COLUMN_KEY, EXTRA, IS_NULLABLE + SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, COLUMN_KEY, EXTRA, IS_NULLABLE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = ? ${tableName ? 'AND TABLE_NAME = ?' : ''} + ORDER BY ORDINAL_POSITION `; const params = tableName ? [schema, tableName] : [schema]; - const [columns] = await pool.query(query, params); + const [columns] = await this.adminDBManager.run(query, params); return columns as any[]; } private async getForeignKeys(schema: string, tableName?: string) { - const pool = this.queryDBAdapter.getAdminPool(); const query = ` SELECT TABLE_NAME, @@ -69,14 +70,26 @@ export class TableService { AND REFERENCED_TABLE_NAME IS NOT NULL `; const params = tableName ? [schema, tableName] : [schema]; - const [foreignKeys] = await pool.query(query, params); - return foreignKeys as any[]; + const [foreignKey] = await this.adminDBManager.run(query, params); + return foreignKey as any[]; + } + + private async getIndexes(schema: string, tableName?: string) { + const query = ` + SELECT TABLE_NAME, COLUMN_NAME, INDEX_NAME, NON_UNIQUE + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = ? ${tableName ? 'AND TABLE_NAME = ?' : ''} + `; + const params = tableName ? [schema, tableName] : [schema]; + const [indexes] = await this.adminDBManager.run(query, params); + return indexes as any[]; } private mapTablesWithColumnsAndKeys( tables: any[], columns: any[], foreignKeys: any[], + indexes: any[], ): ResTableDto[] { return tables.map((table) => { const tableColumns = columns.filter( @@ -90,9 +103,15 @@ export class TableService { key.COLUMN_NAME === col.COLUMN_NAME, ); + const hasIndex = indexes.some( + (idx) => + idx.TABLE_NAME === col.TABLE_NAME && + idx.COLUMN_NAME === col.COLUMN_NAME, + ); + return new ColumnDto({ name: col.COLUMN_NAME, - type: col.DATA_TYPE, + type: col.COLUMN_TYPE, PK: col.COLUMN_KEY === 'PRI', FK: fk ? `${fk.REFERENCED_TABLE_NAME}.${fk.REFERENCED_COLUMN_NAME}` @@ -100,7 +119,7 @@ export class TableService { UQ: col.COLUMN_KEY === 'UNI', AI: col.EXTRA.includes('auto_increment'), NN: col.IS_NULLABLE === 'NO', - IDX: col.COLUMN_KEY === 'MUL', + IDX: hasIndex, }); }); diff --git a/BE/src/usage/dto/res-usage.dto.ts b/BE/src/usage/dto/res-usage.dto.ts new file mode 100644 index 00000000..f00804e1 --- /dev/null +++ b/BE/src/usage/dto/res-usage.dto.ts @@ -0,0 +1,17 @@ +import { Expose } from 'class-transformer'; + +export class ResUsageDto { + /** + * 사용중인 row 수 + * @example 100 + */ + @Expose() + currentUsage: number; + + /** + * 사용가능한 최대 row 수 + * @example 10000 + */ + @Expose() + availUsage: number; +} diff --git a/BE/src/usage/usage.controller.ts b/BE/src/usage/usage.controller.ts new file mode 100644 index 00000000..391201cb --- /dev/null +++ b/BE/src/usage/usage.controller.ts @@ -0,0 +1,23 @@ +import { Controller, Get, Req, UseFilters, UseInterceptors } from '@nestjs/common'; +import { UsageService } from './usage.service'; +import { Request } from 'express'; +import { ExceptionHandler } from 'src/common/exception/exception.handler'; +import { ApiExtraModels } from '@nestjs/swagger'; +import { ResUsageDto } from './dto/res-usage.dto'; +import { Serialize } from 'src/interceptors/serialize.interceptor'; +import { UserDBConnectionInterceptor } from '../interceptors/user-db-connection.interceptor'; + +@Controller('/api/usage') +@ApiExtraModels(ResUsageDto) +@UseFilters(new ExceptionHandler()) +export class UsageController { + constructor(private readonly usageService: UsageService) {} + + @UseInterceptors(UserDBConnectionInterceptor) + @Get() + @Serialize(ResUsageDto) + async getUsage(@Req() req: Request) { + const sessionId = req.sessionID; + return this.usageService.getRowCount(sessionId); + } +} diff --git a/BE/src/usage/usage.module.ts b/BE/src/usage/usage.module.ts new file mode 100644 index 00000000..47466f07 --- /dev/null +++ b/BE/src/usage/usage.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { UsageController } from './usage.controller'; +import { UsageService } from './usage.service'; +import { TableModule } from '../table/table.module'; +import { RedisModule } from '../config/redis/redis.module'; +import { QueryDBModule } from '../config/query-database/admin-query-db.moudle'; + +@Module({ + imports: [TableModule, RedisModule, QueryDBModule], + controllers: [UsageController], + providers: [UsageService], + exports: [UsageService], +}) +export class UsageModule {} diff --git a/BE/src/usage/usage.service.ts b/BE/src/usage/usage.service.ts new file mode 100644 index 00000000..c369e52d --- /dev/null +++ b/BE/src/usage/usage.service.ts @@ -0,0 +1,55 @@ +import { Injectable } from '@nestjs/common'; +import { RedisService } from 'src/config/redis/redis.service'; +import { UserDBManager } from '../config/query-database/user-db-manager.service'; +import { TableService } from '../table/table.service'; + +@Injectable() +export class UsageService { + MAX_ROW_COUNT = 10; + constructor( + private readonly userDBManager: UserDBManager, + private readonly redisService: RedisService, + private readonly tableService: TableService, + ) {} + + public async getRowCount(identify: string) { + const rowCount = await this.redisService.getRowCount(identify); + return { + currentUsage: parseInt(rowCount, 10), + availUsage: this.MAX_ROW_COUNT, + }; + } + + public async updateRowCount(req: any) { + const tableList: string[] = ( + await this.tableService.getTables(req.sessionID) + ).map((table) => table.TABLE_NAME); + if (tableList.length === 0) { + return { + currentUsage: 0, + availUsage: this.MAX_ROW_COUNT, + }; + } + const query = this.createSumQuery(tableList); + const result = await this.userDBManager.run(req, query); + const rowCount = parseInt(result[0].total_rows, 10); + + this.redisService.setRowCount(req.sessionID, rowCount); + } + + private createSumQuery(tableNameList: string[]): string { + const unionQueries = tableNameList + .map( + (tableName) => + `SELECT '${tableName}' AS table_name, COUNT(*) AS row_count FROM ${tableName}`, + ) + .join(' UNION ALL '); + + return ` + SELECT SUM(row_count) AS total_rows + FROM ( + ${unionQueries} + ) AS combined_counts; + `; + } +} diff --git a/BE/src/user/user.controller.spec.ts b/BE/src/user/user.controller.spec.ts deleted file mode 100644 index 7057a1a8..00000000 --- a/BE/src/user/user.controller.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { UserController } from './user.controller'; - -describe('UserController', () => { - let controller: UserController; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [UserController], - }).compile(); - - controller = module.get(UserController); - }); - - it('should be defined', () => { - expect(controller).toBeDefined(); - }); -}); diff --git a/BE/src/user/user.service.spec.ts b/BE/src/user/user.service.spec.ts deleted file mode 100644 index 873de8ac..00000000 --- a/BE/src/user/user.service.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { UserService } from './user.service'; - -describe('UserService', () => { - let service: UserService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [UserService], - }).compile(); - - service = module.get(UserService); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); -}); diff --git a/BE/test/query-database/query-database.spec.ts b/BE/test/query-database/query-database.spec.ts index ecb42ad9..4e32492f 100644 --- a/BE/test/query-database/query-database.spec.ts +++ b/BE/test/query-database/query-database.spec.ts @@ -4,55 +4,54 @@ // jest.setTimeout(20000); describe('싱글 MYSQL(Query DB) 어댑터 테스트', () => { -// let singleMysqlAdapter: SingleMySQLAdapter; -// let container: StartedMySqlContainer; + // let singleMysqlAdapter: SingleMySQLAdapter; + // let container: StartedMySqlContainer; // beforeAll(async () => { -// const ROOT = 'root'; -// const PASSWORD = 'password'; + // const ROOT = 'root'; + // const PASSWORD = 'password'; -// container = await new MySqlContainer().withRootPassword(PASSWORD).start(); + // container = await new MySqlContainer().withRootPassword(PASSWORD).start(); -// process.env.QUERY_DB_HOST = container.getHost(); -// process.env.QUERY_DB_USER = ROOT; -// process.env.QUERY_DB_PASSWORD = PASSWORD; -// process.env.QUERY_DB_PORT = container.getMappedPort(3306).toString(); + // process.env.QUERY_DB_HOST = container.getHost(); + // process.env.QUERY_DB_USER = ROOT; + // process.env.QUERY_DB_PASSWORD = PASSWORD; + // process.env.QUERY_DB_PORT = container.getMappedPort(3306).toString(); -// singleMysqlAdapter = new SingleMySQLAdapter(); + // singleMysqlAdapter = new SingleMySQLAdapter(); // }); // afterAll(async () => { -// await container.stop(); + // await container.stop(); // }); it('커넥션 생성 시 유저 커넥션 리스트에 커넥션이 추가된다.', async () => { -// const identify = 'identify'; -// await singleMysqlAdapter.initUserDatabase(identify); -// await singleMysqlAdapter.createConnection(identify); - -// expect(singleMysqlAdapter.getConnection(identify)).toBeDefined(); + // const identify = 'identify'; + // await singleMysqlAdapter.initUserDatabase(identify); + // await singleMysqlAdapter.createConnection(identify); + // expect(singleMysqlAdapter.getConnection(identify)).toBeDefined(); }); -// it('식별자를 통해 해당 connection에 쿼리를 실행할 수 있다.', async () => { -// const identify = 'identify'; + // it('식별자를 통해 해당 connection에 쿼리를 실행할 수 있다.', async () => { + // const identify = 'identify'; -// const userConnection = singleMysqlAdapter['userConnectionList'][identify]; -// const CREATE_TABLE_USERS = `CREATE TABLE users (id INT AUTO_INCREMENT PRIMARY KEY,name VARCHAR(50));`; -// const INSERT_USER = `INSERT INTO users (name) VALUES (?);`; -// const SELECT_ALL_USERS = `SELECT * FROM users;`; + // const userConnection = singleMysqlAdapter['userConnectionList'][identify]; + // const CREATE_TABLE_USERS = `CREATE TABLE users (id INT AUTO_INCREMENT PRIMARY KEY,name VARCHAR(50));`; + // const INSERT_USER = `INSERT INTO users (name) VALUES (?);`; + // const SELECT_ALL_USERS = `SELECT * FROM users;`; -// await userConnection.execute(CREATE_TABLE_USERS); -// await userConnection.execute(INSERT_USER, ['John Doe']); + // await userConnection.execute(CREATE_TABLE_USERS); + // await userConnection.execute(INSERT_USER, ['John Doe']); -// const [rows] = await userConnection.execute(SELECT_ALL_USERS); + // const [rows] = await userConnection.execute(SELECT_ALL_USERS); -// expect(rows).toEqual([{ id: 1, name: 'John Doe' }]); -// }); + // expect(rows).toEqual([{ id: 1, name: 'John Doe' }]); + // }); -// it('식별자를 통해 커넥션을 종료하면 유저 커넥션 리스트에 삭제된다.', async () => { -// const identify = 'identify'; -// await singleMysqlAdapter.closeConnection(identify); + // it('식별자를 통해 커넥션을 종료하면 유저 커넥션 리스트에 삭제된다.', async () => { + // const identify = 'identify'; + // await singleMysqlAdapter.closeConnection(identify); -// expect(singleMysqlAdapter.getConnection(identify)).toBeUndefined(); + // expect(singleMysqlAdapter.getConnection(identify)).toBeUndefined(); // }); }); diff --git a/BE/test/query/query.service.spec.ts b/BE/test/query/query.service.spec.ts index 534a71a5..f35d1d2e 100644 --- a/BE/test/query/query.service.spec.ts +++ b/BE/test/query/query.service.spec.ts @@ -1,78 +1,72 @@ import { QueryService } from '../../src/query/query.service'; import { QueryDto } from '../../src/query/dto/query.dto'; -import { QueryDBAdapter } from '../../src/config/query-database/query-db.adapter'; import { Test, TestingModule } from '@nestjs/testing'; -import { QUERY_DB_ADAPTER } from '../../src/config/query-database/query-db.moudle'; import { ShellService } from '../../src/shell/shell.service'; -import { repl } from '@nestjs/core'; describe('QueryService', () => { - let queryService: QueryService; - let mockQueryDBAdapter: QueryDBAdapter; - let mockShellService: ShellService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - QueryService, - { - provide: QUERY_DB_ADAPTER, - useValue: { - getConnection: jest.fn(), - run: jest.fn(), - }, - }, - { - provide: ShellService, - useValue: { - findShellOrThrow: jest.fn(), - update: jest.fn(), - replace: jest.fn(), - }, - }, - ], - }).compile(); - - queryService = module.get(QueryService); - mockQueryDBAdapter = module.get(QUERY_DB_ADAPTER); - mockShellService = module.get(ShellService); - }); - - describe('테이블 결과값은 최대 100개 까지만 반환한다.', () => { - const shellId = 1; - const sessionId = 'sessionId'; - const queryDto: QueryDto = { query: 'SELECT * FROM users' }; - - beforeEach(() => { - jest.spyOn(mockShellService, 'findShellOrThrow').mockResolvedValue(null); - }); - - it('테이블 결과값이 100개가 넘어가면 100개만 반환한다..', async () => { - const rows = new Array(150).fill({ test: 'data' }); - jest.spyOn(mockQueryDBAdapter, 'run').mockResolvedValue(rows); - - await queryService.execute(sessionId, shellId, queryDto); - - expect(mockShellService.replace).toHaveBeenCalledWith( - shellId, - expect.objectContaining({ - resultTable: rows.slice(0, 100), - }), - ); - }); - - it('테이블 결과값이 100개보다 적으면 그대로 반환한다.', async () => { - const rows = new Array(99).fill({ test: 'data' }); - jest.spyOn(mockQueryDBAdapter, 'run').mockResolvedValue(rows); + // let queryService: QueryService; + // let mockQueryDBAdapter: QueryDBAdapter; + // let mockShellService: ShellService; + // + // beforeEach(async () => { + // const module: TestingModule = await Test.createTestingModule({ + // providers: [ + // QueryService, + // { + // provide: QUERY_DB_ADAPTER, + // useValue: { + // getConnection: jest.fn(), + // run: jest.fn(), + // }, + // }, + // { + // provide: ShellService, + // useValue: { + // findShellOrThrow: jest.fn(), + // update: jest.fn(), + // replace: jest.fn(), + // }, + // }, + // ], + // }).compile(); + // queryService = module.get(QueryService); + // mockQueryDBAdapter = module.get(QUERY_DB_ADAPTER); + // mockShellService = module.get(ShellService); +}); - await queryService.execute(sessionId, shellId, queryDto); +describe('테이블 결과값은 최대 100개 까지만 반환한다.', () => { + // const shellId = 1; + // const sessionId = 'sessionId'; + // const queryDto: QueryDto = { query: 'SELECT * FROM users' }; + // + // beforeEach(() => { + // jest.spyOn(mockShellService, 'findShellOrThrow').mockResolvedValue(null); + // }); + // + // it('테이블 결과값이 100개가 넘어가면 100개만 반환한다..', async () => { + // const rows = new Array(150).fill({ test: 'data' }); + // jest.spyOn(mockQueryDBAdapter, 'run').mockResolvedValue(rows); + // + // await queryService.execute(sessionId, shellId, queryDto); + // + // expect(mockShellService.replace).toHaveBeenCalledWith( + // shellId, + // expect.objectContaining({ + // resultTable: rows.slice(0, 100), + // }), + // ); +}); - expect(mockShellService.replace).toHaveBeenCalledWith( - shellId, - expect.objectContaining({ - resultTable: rows, - }), - ); - }); - }); +it('테이블 결과값이 100개보다 적으면 그대로 반환한다.', async () => { + // const rows = new Array(99).fill({ test: 'data' }); + // jest.spyOn(mockQueryDBAdapter, 'run').mockResolvedValue(rows); + // + // await queryService.execute(sessionId, shellId, queryDto); + // + // expect(mockShellService.replace).toHaveBeenCalledWith( + // shellId, + // expect.objectContaining({ + // resultTable: rows, + // }), + // );}); }); diff --git a/BE/test/session/redis.service.spec.ts b/BE/test/session/redis.service.spec.ts index e347d111..68d506ac 100644 --- a/BE/test/session/redis.service.spec.ts +++ b/BE/test/session/redis.service.spec.ts @@ -10,66 +10,66 @@ describe('RedisService', () => { // let mockQueryDbAdapter: MockProxy; // beforeAll(async () => { -// redisContainer = await new GenericContainer('redis') -// .withExposedPorts(6379) -// .withWaitStrategy(Wait.forListeningPorts()) -// .start(); -// process.env.REDIS_HOST = redisContainer.getHost(); -// process.env.REDIS_PORT = redisContainer.getMappedPort(6379).toString(); + // redisContainer = await new GenericContainer('redis') + // .withExposedPorts(6379) + // .withWaitStrategy(Wait.forListeningPorts()) + // .start(); + // process.env.REDIS_HOST = redisContainer.getHost(); + // process.env.REDIS_PORT = redisContainer.getMappedPort(6379).toString(); -// mockQueryDbAdapter = mock(); -// redisService = new RedisService(mockQueryDbAdapter); + // mockQueryDbAdapter = mock(); + // redisService = new RedisService(mockQueryDbAdapter); // }); // afterAll(async () => { -// delete process.env.REDIS_HOST; -// delete process.env.REDIS_PORT; -// redisService.getDefaultConnection().disconnect(); -// redisService.getEventConnection().disconnect(); -// await redisContainer.stop(); + // delete process.env.REDIS_HOST; + // delete process.env.REDIS_PORT; + // redisService.getDefaultConnection().disconnect(); + // redisService.getEventConnection().disconnect(); + // await redisContainer.stop(); // }); it('Redis container 연결 확인', async () => { -// const redis = new Redis({ -// host: process.env.REDIS_HOST, -// port: parseInt(process.env.REDIS_PORT), -// }); -// await redis.set('test', 'test'); -// const value = await redis.get('test'); -// expect(value).toBe('test'); -// redis.disconnect(); + // const redis = new Redis({ + // host: process.env.REDIS_HOST, + // port: parseInt(process.env.REDIS_PORT), + // }); + // await redis.set('test', 'test'); + // const value = await redis.get('test'); + // expect(value).toBe('test'); + // redis.disconnect(); }); -// it('RedisService를 생성하면 redis, pubsub 클라이언트가 연결된다.', () => { -// expect(redisService.getDefaultConnection()).toBeInstanceOf(Redis); -// expect(redisService.getEventConnection()).toBeInstanceOf(Redis); -// }); + // it('RedisService를 생성하면 redis, pubsub 클라이언트가 연결된다.', () => { + // expect(redisService.getDefaultConnection()).toBeInstanceOf(Redis); + // expect(redisService.getEventConnection()).toBeInstanceOf(Redis); + // }); -// it('세션 저장소에 존재하지 않는 세션 ID의 경우, setNewSession 메서드를 통해 세션 정보를 등록할 수 있다.', async () => { -// const sessionId = 'session_key'; + // it('세션 저장소에 존재하지 않는 세션 ID의 경우, setNewSession 메서드를 통해 세션 정보를 등록할 수 있다.', async () => { + // const sessionId = 'session_key'; -// await redisService.setNewSession(sessionId); + // await redisService.setNewSession(sessionId); -// expect(mockQueryDbAdapter.createConnection).toHaveBeenCalledWith(sessionId); -// }); + // expect(mockQueryDbAdapter.createConnection).toHaveBeenCalledWith(sessionId); + // }); -// it('getSession 메서드를 통해 Redis에 등록한 세션 정보를 조회할 수 있다.', async () => { -// const mockKey = 'session_key'; -// const mockValue = 'session_value'; -// await redisService.getDefaultConnection().set(mockKey, mockValue); + // it('getSession 메서드를 통해 Redis에 등록한 세션 정보를 조회할 수 있다.', async () => { + // const mockKey = 'session_key'; + // const mockValue = 'session_value'; + // await redisService.getDefaultConnection().set(mockKey, mockValue); -// const session = await redisService.getSession(mockKey); -// expect(session).toBe(mockValue); -// }); + // const session = await redisService.getSession(mockKey); + // expect(session).toBe(mockValue); + // }); -// it('세션 만료 시, DBAdapter의 closeConnection 메서드가 호출된다.', async () => { -// // 1초 후 만료되는 세션 등록 -// redisService.getDefaultConnection().set('test_key', 'value'); -// redisService.getDefaultConnection().expire('test_key', 1); + // it('세션 만료 시, DBAdapter의 closeConnection 메서드가 호출된다.', async () => { + // // 1초 후 만료되는 세션 등록 + // redisService.getDefaultConnection().set('test_key', 'value'); + // redisService.getDefaultConnection().expire('test_key', 1); -// // 2초 대기 -// await new Promise((resolve) => setTimeout(resolve, 2000)); + // // 2초 대기 + // await new Promise((resolve) => setTimeout(resolve, 2000)); -// expect(mockQueryDbAdapter.closeConnection).toHaveBeenCalledWith('test_key'); -// }); + // expect(mockQueryDbAdapter.closeConnection).toHaveBeenCalledWith('test_key'); + // }); }); diff --git a/FE/.eslintrc.json b/FE/.eslintrc.json index d4e766e4..edb322c8 100644 --- a/FE/.eslintrc.json +++ b/FE/.eslintrc.json @@ -31,7 +31,8 @@ }, "rules": { "react/jsx-uses-react": "off", - "react/react-in-jsx-scope": "off" + "react/react-in-jsx-scope": "off", + "prettier/prettier": ["error", { "endOfLine": "lf" }] } }, { diff --git a/FE/.prettierrc b/FE/.prettierrc index d014d16e..c97808b3 100644 --- a/FE/.prettierrc +++ b/FE/.prettierrc @@ -2,5 +2,6 @@ "plugins": ["prettier-plugin-tailwindcss"], "singleQuote": true, "trailingComma": "es5", - "semi": false + "semi": false, + "endOfLine": "lf" } diff --git a/FE/package-lock.json b/FE/package-lock.json index 733638c7..70980e06 100644 --- a/FE/package-lock.json +++ b/FE/package-lock.json @@ -19,6 +19,7 @@ "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-switch": "^1.1.1", + "@radix-ui/react-toast": "^1.2.2", "@radix-ui/react-tooltip": "^1.1.3", "@tanstack/react-query": "^4.36.1", "@vitejs/plugin-react-swc": "^3.5.0", @@ -1482,6 +1483,39 @@ } } }, + "node_modules/@radix-ui/react-toast": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.2.tgz", + "integrity": "sha512-Z6pqSzmAP/bFJoqMAston4eSNa+ud44NSZTiZUmUen+IOZ5nBY8kzuU5WDBVyFXPtcW6yUalOHsxM/BP6Sv8ww==", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-collection": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.1", + "@radix-ui/react-portal": "1.1.2", + "@radix-ui/react-presence": "1.1.1", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-visually-hidden": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-tooltip": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.1.4.tgz", diff --git a/FE/package.json b/FE/package.json index ad3461e1..328aa2bf 100644 --- a/FE/package.json +++ b/FE/package.json @@ -22,6 +22,7 @@ "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-switch": "^1.1.1", + "@radix-ui/react-toast": "^1.2.2", "@radix-ui/react-tooltip": "^1.1.3", "@tanstack/react-query": "^4.36.1", "@vitejs/plugin-react-swc": "^3.5.0", diff --git a/FE/src/api/__mocks__/axiosMock.ts b/FE/src/api/__mocks__/axiosMock.ts index 4d4aacab..948ec02b 100644 --- a/FE/src/api/__mocks__/axiosMock.ts +++ b/FE/src/api/__mocks__/axiosMock.ts @@ -2,11 +2,15 @@ import axios from 'axios' import MockAdapter from 'axios-mock-adapter' import mockShells from '@/api/__mocks__/mockShells' import mockTables from '@/api/__mocks__/mockTables' +import mockRecord from '@/api/__mocks__/mockRecord' +import mockUsage from '@/api/__mocks__/mockUsages' const axiosMock = axios.create() const mock = new MockAdapter(axiosMock) -mockShells(mock) // shells 관련 API 모킹 -mockTables(mock) // tables 관련 API 모킹 +mockShells(mock) +mockTables(mock) +mockRecord(mock) +mockUsage(mock) // usage 관련 API 모킹 export default axiosMock diff --git a/FE/src/api/__mocks__/mockRecord.ts b/FE/src/api/__mocks__/mockRecord.ts new file mode 100644 index 00000000..2f8b7769 --- /dev/null +++ b/FE/src/api/__mocks__/mockRecord.ts @@ -0,0 +1,11 @@ +import * as MockAdapter from 'axios-mock-adapter' +import { RecordResultType } from '@/types/interfaces' + +const recordResult: RecordResultType = { + status: true, + text: 'randomDataTestTable 에 랜덤 레코드 10개 삽입되었습니다.', +} + +export default function mockRecord(mock: MockAdapter) { + mock.onPost('/record').reply(200, { data: recordResult }) +} diff --git a/FE/src/api/__mocks__/mockShells.ts b/FE/src/api/__mocks__/mockShells.ts index 9f63fc57..08305404 100644 --- a/FE/src/api/__mocks__/mockShells.ts +++ b/FE/src/api/__mocks__/mockShells.ts @@ -14,7 +14,7 @@ export default function mockShells(mock: MockAdapter) { const newShell: ShellType = JSON.parse(config.data) newShell.id = new Date().getTime() shellData.push(newShell) - return [200, { data: newShell.id }] + return [200, { data: { id: newShell.id } }] }) // execute diff --git a/FE/src/api/__mocks__/mockUsages.ts b/FE/src/api/__mocks__/mockUsages.ts new file mode 100644 index 00000000..a55b73d5 --- /dev/null +++ b/FE/src/api/__mocks__/mockUsages.ts @@ -0,0 +1,19 @@ +import * as MockAdapter from 'axios-mock-adapter' +import { UsageType } from '@/types/interfaces' + +const usageData: UsageType = { + currentUsage: 500, + availUsage: 500000, +} + +export default function mockUsages(mock: MockAdapter) { + const delay = (ms: number) => + // eslint-disable-next-line no-promise-executor-return + new Promise((resolve) => setTimeout(resolve, ms)) + + // fetch + mock.onGet('/usage').reply(async () => { + await delay(1000) + return [200, { data: usageData }] + }) +} diff --git a/FE/src/api/recordApi.ts b/FE/src/api/recordApi.ts new file mode 100644 index 00000000..d2649276 --- /dev/null +++ b/FE/src/api/recordApi.ts @@ -0,0 +1,13 @@ +import axiosClient from '@/api/axiosClient' +import axiosMock from '@/api/__mocks__/axiosMock' +import { RecordToolType, RecordResultType } from '@/types/interfaces' + +const axiosInstance = + import.meta.env.VITE_NODE_ENV === 'development' ? axiosMock : axiosClient + +export default async function addRecord( + record: RecordToolType +): Promise { + const response = await axiosInstance.post('/record', record) + return response.data.data +} diff --git a/FE/src/api/usageApi.ts b/FE/src/api/usageApi.ts new file mode 100644 index 00000000..0437dcc3 --- /dev/null +++ b/FE/src/api/usageApi.ts @@ -0,0 +1,11 @@ +import axiosClient from '@/api/axiosClient' +import axiosMock from '@/api/__mocks__/axiosMock' +import { UsageType } from '@/types/interfaces' + +const axiosInstance = + import.meta.env.VITE_NODE_ENV === 'development' ? axiosMock : axiosClient + +export default async function fetchUsage(): Promise { + const response = await axiosInstance.get('/usage') + return response.data.data +} diff --git a/FE/src/components/CapacityUsage.tsx b/FE/src/components/CapacityUsage.tsx new file mode 100644 index 00000000..fbb2f4cc --- /dev/null +++ b/FE/src/components/CapacityUsage.tsx @@ -0,0 +1,56 @@ +import { UsageType } from '@/types/interfaces' +import { MAX_ROWS_PER_USER } from '@/constants/constants' +import useUsages from '@/hooks/useUsageQuery' + +function CapacityUsage({ usage }: { usage: UsageType }) { + const { isLoading, isFetching } = useUsages() + + const UNIT = 'Rows' + const HIGH_THRESHOLD = 70 + + const loading = isLoading || isFetching + const used = usage?.currentUsage || 0 + const total = usage?.availUsage || MAX_ROWS_PER_USER + const percentage = total > 0 ? (used / total) * 100 : 0 + + const getColor = (percent: number) => { + if (percent < HIGH_THRESHOLD) return 'bg-primary' + return 'bg-red-500' + } + + const color = getColor(percentage) + + return ( +
+ {/* 게이지 바 */} +
+
+
+
+
+ + {/* 텍스트 영역 */} +
+
+ {/* 현재 사용량 */} + {loading ? ( +
+ ) : ( + {used} + )} + {/* 단위 */} + {UNIT} + {/* 총 사용량 */} + + / {total} {UNIT} + +
+
+
+ ) +} + +export default CapacityUsage diff --git a/FE/src/components/EditableInput.tsx b/FE/src/components/EditableInput.tsx index 40e0631b..06c413d1 100644 --- a/FE/src/components/EditableInput.tsx +++ b/FE/src/components/EditableInput.tsx @@ -21,7 +21,7 @@ export default function EditableInput({ setInputValue(e.target.value)} onBlur={() => { setIsEditing(false) diff --git a/FE/src/components/InputWithLocalState.tsx b/FE/src/components/InputWithLocalState.tsx new file mode 100644 index 00000000..acfbe6f0 --- /dev/null +++ b/FE/src/components/InputWithLocalState.tsx @@ -0,0 +1,35 @@ +import { useState } from 'react' +import { Input } from '@/components/ui/input' + +type InputWithLocalStateProps = + React.InputHTMLAttributes & { + value: T + onChange: (value: T) => void + } + +export default function InputWithLocalState({ + value, + onChange, + ...props +}: InputWithLocalStateProps) { + const [localValue, setLocalValue] = useState(value) + + const handleBlur = () => { + onChange(localValue) + } + + const handleChange = (e: React.ChangeEvent) => { + const inputValue = e.target.value + setLocalValue(inputValue as T) + } + + return ( + + ) +} diff --git a/FE/src/components/LeftSidebar.tsx b/FE/src/components/LeftSidebar.tsx index d1036e79..56bf2ea8 100644 --- a/FE/src/components/LeftSidebar.tsx +++ b/FE/src/components/LeftSidebar.tsx @@ -1,4 +1,4 @@ -import { MENU, MENU_TITLE } from '@/constants' +import { MENU, MENU_TITLE } from '@/constants/constants' import { X } from 'lucide-react' import { @@ -14,7 +14,9 @@ import { } from '@/components/ui/sidebar' import logo from '@/assets/logo.svg' import TableTool from '@/components/TableTool' +import RecordTool from '@/components/RecordTool' import { TableType } from '@/types/interfaces' +import TestQueryTool from './TestTool' type LeftSidebarProps = React.ComponentProps & { activeItem: (typeof MENU)[0] @@ -100,7 +102,10 @@ export default function LeftSidebar({ {activeItem.title === MENU_TITLE.TABLE && ( )} - {activeItem.title === MENU_TITLE.RECORD && 'record'} + {activeItem.title === MENU_TITLE.RECORD && ( + + )} + {activeItem.title === MENU_TITLE.TESTQUERY && } diff --git a/FE/src/components/RecordTool.tsx b/FE/src/components/RecordTool.tsx new file mode 100644 index 00000000..1e4a7068 --- /dev/null +++ b/FE/src/components/RecordTool.tsx @@ -0,0 +1,269 @@ +import { useState, useEffect } from 'react' + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, + DialogClose, +} from '@/components/ui/dialog' +import { useToast } from '@/hooks/use-toast' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import Label from '@/components/ui/label' + +import { + TableType, + RecordToolType, + RecordToolColumnType, + RecordResultType, +} from '@/types/interfaces' +import { RECORD_TYPES } from '@/constants/constants' +import { generateKey, convertTableDataToRecordToolData } from '@/util' +import TagInputForm from '@/components/TagInputForm' +import InputWithLocalState from '@/components/InputWithLocalState' +import useAddRecord from '@/hooks/useRecordQuery' + +export default function RecordTool({ + tableData = [], +}: { + tableData: TableType[] +}) { + const { toast } = useToast() + const addRecordMutation = useAddRecord() + + const [tables, setTables] = useState([]) + const [selectedTable, setSelectedTable] = useState({ + tableName: '', + columns: [], + count: 0, + }) + + useEffect(() => { + const recordToolData = convertTableDataToRecordToolData(tableData) + setTables(recordToolData) + setSelectedTable(recordToolData[0] || { tableName: '', columns: [] }) + }, [tableData]) + + const addRecord = async (record: RecordToolType): Promise => + addRecordMutation.mutateAsync(record) + + const handleColumnChange = ( + row: number, + id: keyof RecordToolColumnType, + value: unknown + ) => { + const updatedColumns = selectedTable.columns.map((col, colIdx) => + colIdx !== row ? col : { ...col, [id]: value } + ) + + const updatedSelectedTable = { + ...selectedTable, + columns: updatedColumns, + } + setSelectedTable(updatedSelectedTable) + + setTables((prevTables) => + prevTables.map((table) => + table.tableName === updatedSelectedTable.tableName + ? updatedSelectedTable + : table + ) + ) + } + + const handleCountChange = (count: number) => { + const updatedSelectedTable = { ...selectedTable, count } + + setSelectedTable(updatedSelectedTable) + + setTables((prevTables) => + prevTables.map((table) => + table.tableName === updatedSelectedTable.tableName + ? updatedSelectedTable + : table + ) + ) + } + + const handleSubmitRecord = async () => { + try { + const result: RecordResultType = await addRecord(selectedTable) + toast({ + title: 'Data inserted successfully', + description: result.text, + }) + } catch (error) { + toast({ + variant: 'destructive', + title: 'Failed to insert data', + description: `${selectedTable.count} rows failed to insert in ${selectedTable.tableName} table`, + }) + } + } + + return ( + <> +
+ {tables.map((table) => ( + setSelectedTable(table)} + key={table.tableName} + > + {table.tableName} + + ))} +
+ + + + Name + Domain + Blank + Options + + + + {selectedTable?.columns.map((row: RecordToolColumnType, rowIdx) => ( + + {row.name} + + + + + + type="number" + id={`row-blank-${rowIdx}`} + className="mr-2 h-8 w-12 p-1" + placeholder="0" + value={row.blank} + onChange={(updatedValue) => + handleColumnChange(rowIdx, 'blank', Number(updatedValue)) + } + /> + % + + + {row.type === 'number' && ( +
+ + type="number" + id={`row-min-${rowIdx}`} + className="mr-2 h-8 w-16 p-2" + placeholder="min" + value={row.min} + onChange={(updatedValue) => + handleColumnChange(rowIdx, 'min', Number(updatedValue)) + } + /> + + type="number" + id={`row-max-${rowIdx}`} + className="h-8 w-16 p-2" + placeholder="max" + value={row.max} + onChange={(updatedValue) => + handleColumnChange(rowIdx, 'max', Number(updatedValue)) + } + /> +
+ )} + {row.type === 'enum' && ( + + + + + + + Add Enum + write Enum + + + handleColumnChange(rowIdx, 'enum', newEnum) + } + > + + + + + + + + + )} +
+
+ ))} +
+
+
+ + handleCountChange(Number(e.target.value))} + /> +
+
+ +
+ + ) +} diff --git a/FE/src/components/ResultTable.tsx b/FE/src/components/ResultTable.tsx new file mode 100644 index 00000000..a70186c6 --- /dev/null +++ b/FE/src/components/ResultTable.tsx @@ -0,0 +1,63 @@ +import { useState } from 'react' +import { generateKey } from '@/util' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from './ui/table' + +function ResultTable({ data }: { data: any[] }) { + const ITEMS_PER_PAGE = 10 + + const [visibleCount, setVisibleCount] = useState(ITEMS_PER_PAGE) + const visibleData = data.slice(0, visibleCount) + + const handleLoadMore = () => { + setVisibleCount((prevCount) => prevCount + ITEMS_PER_PAGE) + } + + return ( +
+ + + + {Object.keys(data[0]).map((header) => ( + + {header} + + ))} + + + + {visibleData.map((row) => ( + + {Object.values(row).map((cell) => ( + {String(cell)} + ))} + + ))} + +
+ {visibleCount < data.length && ( + + )} +
+ ) +} + +export default ResultTable diff --git a/FE/src/components/RightSidebar.tsx b/FE/src/components/RightSidebar.tsx index 4ce02f4c..bb8ae469 100644 --- a/FE/src/components/RightSidebar.tsx +++ b/FE/src/components/RightSidebar.tsx @@ -1,4 +1,4 @@ -import { MENU_TITLE } from '@/constants' +import { MENU_TITLE } from '@/constants/constants' import { TableType } from '@/types/interfaces' import { Sidebar, diff --git a/FE/src/components/Shell.tsx b/FE/src/components/Shell.tsx index b48416e3..ac380b88 100644 --- a/FE/src/components/Shell.tsx +++ b/FE/src/components/Shell.tsx @@ -2,21 +2,14 @@ import { useState, useRef, useEffect } from 'react' import PlayCircle from '@/assets/play_circle.svg' import { ShellType } from '@/types/interfaces' import useShellHandlers from '@/hooks/useShellHandler' -import { generateKey } from '@/util' import { X } from 'lucide-react' -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table' import { useTables } from '@/hooks/useTableQuery' import AceEditor from 'react-ace' import 'ace-builds/src-noconflict/mode-sql' import 'ace-builds/src-noconflict/theme-monokai' import 'ace-builds/src-noconflict/ext-language_tools' +import useUsages from '@/hooks/useUsageQuery' +import ResultTable from './ResultTable' type ShellProps = { shell: ShellType @@ -24,14 +17,14 @@ type ShellProps = { export default function Shell({ shell }: ShellProps) { const { id, queryStatus, query, text, queryType, resultTable } = shell - const { refetch } = useTables() + const { refetch: tableRefetch } = useTables() + const { refetch: usageRefetch } = useUsages() const { executeShell, updateShell, deleteShell } = useShellHandlers() - const LINE_HEIGHT = 1.38 + const LINE_HEIGHT = 1.2 const prevQueryRef = useRef(query ?? '') const [focused, setFocused] = useState(false) - const [showPlaceholder, setShowPlaceholder] = useState(true) const [inputValue, setInputValue] = useState(query ?? '') const [editorHeight, setEditorHeight] = useState(LINE_HEIGHT) const editorRef = useRef(null) @@ -40,17 +33,6 @@ export default function Shell({ shell }: ShellProps) { setInputValue(shell.query ?? '') }, [shell.query]) - useEffect(() => { - const renderer = editorRef.current?.editor.renderer as any - if (renderer) { - if (!focused) { - renderer.$cursorLayer.element.style.display = 'none' - } else { - renderer.$cursorLayer.element.style.display = '' - } - } - }, [focused]) - const handleBlur = (e: React.FocusEvent) => { if (e.relatedTarget?.id === 'remove-shell-btn') return setFocused(false) @@ -60,10 +42,11 @@ export default function Shell({ shell }: ShellProps) { } const handleClick = async () => { - if (!id) return + if (!id || !shell) return await executeShell({ ...shell, query }) + usageRefetch() if (!queryType || ['CREATE', 'ALTER', 'DROP'].includes(queryType || '')) - await refetch() + await tableRefetch() } const handleMouseDown = (e: React.MouseEvent) => { @@ -74,42 +57,37 @@ export default function Shell({ shell }: ShellProps) { const handleEditorChange = (value: string) => { if (value === inputValue) return setInputValue(value) - const lineCount = value.split('\n').length - const newHeight = Math.max(lineCount * LINE_HEIGHT) - setEditorHeight(newHeight) - } - - const handleFocus = () => { - setShowPlaceholder(false) - setFocused(true) } - return ( <> -
+
-
+
setFocused(true)} onBlur={handleBlur} - fontSize={16} + fontSize={14} width="100%" height={`${editorHeight}rem`} setOptions={{ @@ -120,8 +98,17 @@ export default function Shell({ shell }: ShellProps) { enableLiveAutocompletion: true, enableSnippets: true, tabSize: 2, + wrap: true, + behavioursEnabled: false, + }} + onLoad={(editor) => { + editor.on('change', () => { + setEditorHeight( + editor.getSession().getScreenLength() * LINE_HEIGHT + ) + }) }} - className="mx-2 my-3 bg-secondary" + className="my-3.5 ml-2 bg-secondary" />
{focused && ( @@ -142,39 +129,9 @@ export default function Shell({ shell }: ShellProps) { > {text}

- {resultTable && - resultTable?.length > 0 && ( // 결과 테이블이 있는지 - <> - - - - {Object.keys(resultTable[0])?.map((header) => ( - - {header} - - ))} - - - - {resultTable.map((row) => ( - - {Object.values(row).map((cell) => ( - - {String(cell)} - - ))} - - ))} - -
- - - )} + {resultTable && resultTable?.length > 0 && ( + + )}
)} diff --git a/FE/src/components/ShellList.tsx b/FE/src/components/ShellList.tsx deleted file mode 100644 index fdfe851f..00000000 --- a/FE/src/components/ShellList.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { Badge } from '@/components/ui/badge' -import Shell from '@/components/Shell' -import { ShellType } from '@/types/interfaces' -import useShellHandlers from '@/hooks/useShellHandler' - -export default function ShellList({ shells = [] }: { shells: ShellType[] }) { - const { addShell } = useShellHandlers() - - return ( - <> -
- - + query - -
- {shells.length > 0 && ( -
- {shells?.map((shell) => )} -
- )} - - ) -} diff --git a/FE/src/components/TableTool.tsx b/FE/src/components/TableTool.tsx index ddf61308..df7a1c0c 100644 --- a/FE/src/components/TableTool.tsx +++ b/FE/src/components/TableTool.tsx @@ -39,10 +39,10 @@ import { TableToolType, TableToolColumnType, } from '@/types/interfaces' -import { COLUMN_TYPES } from '@/constants' +import { COLUMN_TYPES } from '@/constants/constants' import { generateKey, - convertTableData, + convertTableDataToTableToolData, generateCreateTableQuery, generateAlterTableQuery, } from '@/util' @@ -60,7 +60,7 @@ export default function TableTool({ const [newTableName, setNewTableName] = useState('') useEffect(() => { - const tableToolData = convertTableData(tableData) + const tableToolData = convertTableDataToTableToolData(tableData) initialTableData.current = tableToolData setTables(tableToolData) setSelectedTableName(tableToolData[0]?.tableName) @@ -151,7 +151,7 @@ export default function TableTool({ return ( <> -
+
{tables.map((table) => ( - + diff --git a/FE/src/components/TagInputForm.tsx b/FE/src/components/TagInputForm.tsx new file mode 100644 index 00000000..b975de19 --- /dev/null +++ b/FE/src/components/TagInputForm.tsx @@ -0,0 +1,80 @@ +import { useState } from 'react' +import { Input } from '@/components/ui/input' +import { Button } from '@/components/ui/button' +import Label from '@/components/ui/label' +import { Badge } from '@/components/ui/badge' +import { generateKey } from '@/util' +import { X } from 'lucide-react' + +type TagInputProps = { + type: string + preTag: string[] + onAdd: (value: string[]) => void + // eslint-disable-next-line react/require-default-props + children?: React.ReactNode +} + +export default function TagInputForm({ + type, + preTag, + onAdd, + children, +}: TagInputProps) { + const [inputValue, setInputValue] = useState('') + const [tags, setTags] = useState(preTag || []) + + const handleAddTag = () => { + if (!inputValue.trim() || tags.length >= 10) return + setTags((tag) => [...tag, inputValue]) + setInputValue('') + } + + const handleDeleteTag = (tagIdx: number) => { + setTags((prevTags) => prevTags.filter((_, index) => index !== tagIdx)) + } + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + onAdd(tags) + } + + return ( +
+
+ + setInputValue(e.target.value)} + /> + +
+ {tags.length >= 10 && ( +
+ + Up to 10 tags can be registered.{' '} + +
+ )} +
+ {tags?.map((tag, idx) => ( + + {tag} + handleDeleteTag(idx)} /> + + ))} +
+ {children} +
+ ) +} diff --git a/FE/src/components/TestTool.tsx b/FE/src/components/TestTool.tsx new file mode 100644 index 00000000..870cb339 --- /dev/null +++ b/FE/src/components/TestTool.tsx @@ -0,0 +1,112 @@ +import { useState } from 'react' +import { Button } from '@/components/ui/button' +import useShellHandlers from '@/hooks/useShellHandler' +import AceEditor from 'react-ace' +import 'ace-builds/src-noconflict/mode-sql' +import 'ace-builds/src-noconflict/theme-monokai' +import 'ace-builds/src-noconflict/ext-language_tools' +import testQueries from '@/constants/exampleQuery' +import { ExampleQuery } from '@/types/interfaces' +import { useToast } from '@/hooks/use-toast' + +export default function TestQueryTool() { + const { toast } = useToast() + + const { addShell, updateShell } = useShellHandlers() + const [selectedQuery, setSelectedQuery] = useState(null) + const [queryInput, setQueryInput] = useState('') + + const handleSelectQuery = (query: ExampleQuery) => { + setSelectedQuery(query) + setQueryInput(query.query) + } + + const handleRunQuery = async () => { + if (!queryInput) { + toast({ + variant: 'destructive', + title: 'No query selected', + description: 'Please write a query first.', + }) + return + } + + const { id } = await addShell() + await updateShell({ id, query: queryInput }) + } + + const onInputChange = (value: string) => { + setQueryInput(value) + } + + return ( +
+
+

+ Select Query +

+
+ {testQueries.map((query) => ( +
handleSelectQuery(query)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + handleSelectQuery(query) + } + }} + role="option" + aria-selected={selectedQuery === query} + tabIndex={0} + > + {query.name} +
+ ))} +
+
+ +
+
+

Preview / Edit Query

+
+ + +
+ +
+ +
+
+ ) +} diff --git a/FE/src/components/ui/textarea.tsx b/FE/src/components/ui/textarea.tsx new file mode 100644 index 00000000..1e9fdbe5 --- /dev/null +++ b/FE/src/components/ui/textarea.tsx @@ -0,0 +1,22 @@ +import * as React from 'react' + +import cn from '@/lib/utils' + +const Textarea = React.forwardRef< + HTMLTextAreaElement, + React.ComponentProps<'textarea'> +>(({ className, ...props }, ref) => { + return ( +