diff --git a/.gitignore b/.gitignore index 6f3146e..7248d27 100644 --- a/.gitignore +++ b/.gitignore @@ -6,10 +6,10 @@ !.env.*.example # ========================================== -# 도커 영속 데이터 +# 도커 영속 데이터 (루트 한정 — src 내부 data 폴더는 무시하지 않음) # ========================================== -data/ -!data/.gitkeep +/data/ +!/data/.gitkeep db-data/ redis-data/ diff --git a/backend/package-lock.json b/backend/package-lock.json index 88a106d..a84221d 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -183,10 +183,8 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, + "extraneous": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -208,9 +206,8 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, + "extraneous": true, "license": "MIT", - "optional": true, "engines": { "node": ">= 14.18.0" }, @@ -278,10 +275,8 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, + "extraneous": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -303,9 +298,8 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, + "extraneous": true, "license": "MIT", - "optional": true, "engines": { "node": ">= 14.18.0" }, @@ -355,7 +349,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -904,7 +897,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" @@ -917,7 +909,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -2368,6 +2359,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2385,6 +2379,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2402,6 +2399,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2419,6 +2419,9 @@ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2436,6 +2439,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2453,6 +2459,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2470,6 +2479,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2571,7 +2583,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-4.0.1.tgz", "integrity": "sha512-68pFJgu+/AZbWkGu65Z3r55bTsCPlgyKaV4BSG8yUAD72q1PPuyVRgUwFv6BxdnibTUHlyxm06FmYWNC+bjN7A==", "license": "MIT", - "peer": true, "peerDependencies": { "@nestjs/common": "^10.0.0 || ^11.0.0", "axios": "^1.3.1", @@ -2670,7 +2681,6 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -2701,7 +2711,6 @@ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -2858,7 +2867,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.26.tgz", "integrity": "sha512-0VARQyzuGbprvjO+slWq9Jtj1P0jYCSKAUSv9LWFNWD39ZbDzXXM1pMs35kReVXwchra0urMfTQxw4uAOfdSzA==", "license": "MIT", - "peer": true, "dependencies": { "file-type": "21.3.4", "iterare": "1.2.1", @@ -2905,7 +2913,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.26.tgz", "integrity": "sha512-K45zUwYpowEsVqm8qNIzsMcl4LJev0MK9zVhDnmym7YRTJ2/caslqVeKYhPRd5+Fh81IkvWUVu6vEo46uZ5mgQ==", "license": "MIT", - "peer": true, "dependencies": { "fast-safe-stringify": "2.1.1", "iterare": "1.2.1", @@ -2965,7 +2972,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.26.tgz", "integrity": "sha512-MJ5Kwe52Ag4nlIuLK2ekB6TVYu1a22uvDzc0Aq0wIzcLySIz4YK0fMcrDOKGdbGQWpfZtNu1PM3jhlf4hvf6Og==", "license": "MIT", - "peer": true, "dependencies": { "cors": "2.8.6", "express": "5.2.1", @@ -3086,10 +3092,8 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, + "extraneous": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -3111,9 +3115,8 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, + "extraneous": true, "license": "MIT", - "optional": true, "engines": { "node": ">= 14.18.0" }, @@ -3291,7 +3294,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-11.0.1.tgz", "integrity": "sha512-8rw/nKT0S+L+MkzgE9F2/mox7mAgsPlwfzmW9gsESN1lmQtIrVEfiiBwC2O8+guS1jBfQehJIdcdUj2OAp4VUQ==", "license": "MIT", - "peer": true, "peerDependencies": { "@nestjs/common": "^10.0.0 || ^11.0.0", "@nestjs/core": "^10.0.0 || ^11.0.0", @@ -3435,7 +3437,6 @@ "integrity": "sha512-L+ACCGHCiS0VqHVep/INLVnvRvJ2XooQFLZq4L8snhxw1jsqz+XRcY313UsyPVturPPE1shW3jic7rt3qEQTSQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@swc/counter": "^0.1.3", "@xhmikosr/bin-wrapper": "^14.0.0", @@ -3505,10 +3506,9 @@ "version": "1.15.41", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.41.tgz", "integrity": "sha512-03nQq/082QRJJiOvp3FGbgxTGyyxMxohPTjhk/W9bD2J0tk4ukITI7goOhOO2WbaHn/lsPmo/zf8+DIXhwpgYQ==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.26" @@ -3550,7 +3550,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3567,7 +3566,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3584,7 +3582,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3601,7 +3598,9 @@ "cpu": [ "arm64" ], - "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3618,7 +3617,9 @@ "cpu": [ "arm64" ], - "dev": true, + "libc": [ + "musl" + ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3635,7 +3636,9 @@ "cpu": [ "ppc64" ], - "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3652,7 +3655,9 @@ "cpu": [ "s390x" ], - "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3669,7 +3674,9 @@ "cpu": [ "x64" ], - "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3686,7 +3693,9 @@ "cpu": [ "x64" ], - "dev": true, + "libc": [ + "musl" + ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3703,7 +3712,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3720,7 +3728,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3737,7 +3744,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3751,14 +3757,14 @@ "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0" }, "node_modules/@swc/types": { "version": "0.1.26", "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.26.tgz", "integrity": "sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@swc/counter": "^0.1.3" @@ -4031,7 +4037,6 @@ "integrity": "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -4176,7 +4181,6 @@ "integrity": "sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.61.0", "@typescript-eslint/types": "8.61.0", @@ -4527,6 +4531,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4541,6 +4548,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -4555,6 +4565,9 @@ "loong64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4569,6 +4582,9 @@ "loong64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -4583,6 +4599,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4597,6 +4616,9 @@ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4611,6 +4633,9 @@ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -4625,6 +4650,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4639,6 +4667,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4653,6 +4684,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -5082,7 +5116,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "devOptional": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5144,7 +5177,6 @@ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -5390,7 +5422,6 @@ "resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz", "integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==", "license": "MIT", - "peer": true, "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", @@ -5857,7 +5888,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -5964,7 +5994,6 @@ "resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-6.4.3.tgz", "integrity": "sha512-VV5eq/QQ5rIVix7/aICO4JyvSeEv9eIQuKL5iFwgM2BrcYoE0A/D1mNsAHJAsB0WEbNdBlKkn6Tjz6fKzh/cKQ==", "license": "MIT", - "peer": true, "dependencies": { "keyv": "^5.3.3" } @@ -6068,9 +6097,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001797", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz", - "integrity": "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==", + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", "dev": true, "funding": [ { @@ -6176,10 +6205,8 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", - "dev": true, + "extraneous": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "readdirp": "^5.0.0" }, @@ -6227,15 +6254,13 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/class-validator": { "version": "0.14.4", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.4.tgz", "integrity": "sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw==", "license": "MIT", - "peer": true, "dependencies": { "@types/validator": "^13.15.3", "libphonenumber-js": "^1.11.1", @@ -7141,7 +7166,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7202,7 +7226,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -7441,7 +7464,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -8904,7 +8926,6 @@ "integrity": "sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.4.2", "@jest/types": "30.4.1", @@ -9830,7 +9851,6 @@ "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", "license": "MIT", - "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -10928,7 +10948,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz", "integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.13.0", "pg-pool": "^3.14.0", @@ -11196,7 +11215,6 @@ "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -11400,9 +11418,8 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", - "dev": true, + "extraneous": true, "license": "MIT", - "optional": true, "engines": { "node": ">= 20.19.0" }, @@ -11415,8 +11432,7 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/require-directory": { "version": "2.1.1", @@ -11535,7 +11551,6 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -12399,7 +12414,6 @@ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -12782,7 +12796,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -12959,7 +12972,6 @@ "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.30.tgz", "integrity": "sha512-8T35PzjefOdqc2ZR9mwLQj0pUGp6lQhMbK2EvVMwJVJWlaoHm0v/Q6dThNOZkFchD+0yMg8gwjKM28ePiLSXSQ==", "license": "MIT", - "peer": true, "dependencies": { "@sqltools/formatter": "^1.2.5", "ansis": "^4.2.0", @@ -13166,7 +13178,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13567,6 +13578,7 @@ "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ajv": "^8.0.0" }, @@ -13585,6 +13597,7 @@ "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3" }, @@ -13598,6 +13611,7 @@ "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -13612,6 +13626,7 @@ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "engines": { "node": ">=4.0" } @@ -13621,7 +13636,8 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/webpack/node_modules/schema-utils": { "version": "4.3.3", @@ -13629,6 +13645,7 @@ "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", @@ -13736,7 +13753,6 @@ "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", "license": "MIT", - "peer": true, "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.8", diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index d9d3b23..24d341a 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -11,6 +11,12 @@ import { AppService } from './app.service'; import { HealthModule } from './modules/health/health.module'; import { UserModule } from './modules/user/user.module'; import { EventsModule } from './modules/events/events.module'; +import { NewsModule } from './modules/news/news.module'; +import { RankingModule } from './modules/ranking/ranking.module'; +import { SaUserModule } from './modules/sa-user/sa-user.module'; +import { SaMetaModule } from './modules/sa-meta/sa-meta.module'; +import { SaMatchModule } from './modules/sa-match/sa-match.module'; +import { NexonModule } from './shared/nexon/nexon.module'; @Module({ imports: [ @@ -60,7 +66,13 @@ import { EventsModule } from './modules/events/events.module'; }), HealthModule, UserModule, + NexonModule, EventsModule, + NewsModule, + RankingModule, + SaUserModule, + SaMetaModule, + SaMatchModule, ], controllers: [AppController], providers: [ diff --git a/backend/src/modules/news/dto/news-item.dto.ts b/backend/src/modules/news/dto/news-item.dto.ts new file mode 100644 index 0000000..a4f7293 --- /dev/null +++ b/backend/src/modules/news/dto/news-item.dto.ts @@ -0,0 +1,19 @@ +import { ApiProperty } from '@nestjs/swagger'; + +// SA 소식 항목 DTO (공지사항/업데이트/GM이야기 공통) +export class NewsItemDto { + @ApiProperty({ description: '표시용 날짜 (MM.DD)', example: '06.11' }) + date!: string; + + @ApiProperty({ + description: '소식 제목', + example: '6/11(목) 서든어택 정기점검 안내(오전 3시)', + }) + title!: string; + + @ApiProperty({ + description: '원문 상세 페이지 링크', + example: 'https://sa.nexon.com/news/notice/view.aspx?ArticleSN=149280', + }) + link!: string; +} diff --git a/backend/src/modules/news/entities/sa-news.entity.ts b/backend/src/modules/news/entities/sa-news.entity.ts new file mode 100644 index 0000000..ec15ace --- /dev/null +++ b/backend/src/modules/news/entities/sa-news.entity.ts @@ -0,0 +1,36 @@ +import { + Column, + Entity, + Index, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +// SA 소식 저장 테이블 (공지사항/업데이트/GM이야기 — 1시간마다 크롤링 결과를 영속화) +@Entity('sa_news') +@Index(['category', 'sortOrder']) +export class SaNewsEntity { + @PrimaryGeneratedColumn() + id!: number; + + // 분류 — '공지사항' | '업데이트' | 'GM이야기' + @Column({ type: 'varchar', length: 20 }) + category!: string; + + // 표시용 날짜 (MM.DD 형식으로 정규화 저장) + @Column({ type: 'varchar', length: 10, default: '' }) + date!: string; + + @Column({ type: 'varchar', length: 300 }) + title!: string; + + @Column({ type: 'varchar', length: 1000, default: '' }) + link!: string; + + // 크롤링 순서(사이트 노출 순서, 최신순) 유지용 + @Column({ type: 'int', default: 0 }) + sortOrder!: number; + + @UpdateDateColumn() + updatedAt!: Date; +} diff --git a/backend/src/modules/news/news.controller.ts b/backend/src/modules/news/news.controller.ts new file mode 100644 index 0000000..705ce0c --- /dev/null +++ b/backend/src/modules/news/news.controller.ts @@ -0,0 +1,26 @@ +import { Controller, Get } from '@nestjs/common'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; + +import { NewsService } from './news.service'; +import { NewsItemDto } from './dto/news-item.dto'; + +@ApiTags('News') +@Controller('news') +export class NewsController { + constructor(private readonly newsService: NewsService) {} + + @Get('sections') + @ApiOperation({ + summary: 'SA 소식 카테고리별 조회', + description: + 'DB에 저장된 공지사항/업데이트/GM이야기 소식을 카테고리별로 반환합니다. 원본은 1시간마다 크롤링으로 갱신됩니다. (방문마다 크롤링하지 않음)', + }) + @ApiResponse({ + status: 200, + description: + '카테고리(공지사항/GM이야기/업데이트)별 소식 목록. 예: { "공지사항": [...], "GM이야기": [...], "업데이트": [...] }', + }) + getSections(): Promise> { + return this.newsService.getSections(); + } +} diff --git a/backend/src/modules/news/news.module.ts b/backend/src/modules/news/news.module.ts new file mode 100644 index 0000000..868a722 --- /dev/null +++ b/backend/src/modules/news/news.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { NewsController } from './news.controller'; +import { NewsService } from './news.service'; +import { SaNewsEntity } from './entities/sa-news.entity'; + +// SA 소식 모듈 — 1시간마다 크롤링(스케줄) + DB 영속 +@Module({ + imports: [TypeOrmModule.forFeature([SaNewsEntity])], + controllers: [NewsController], + providers: [NewsService], + exports: [NewsService], +}) +export class NewsModule {} diff --git a/backend/src/modules/news/news.service.ts b/backend/src/modules/news/news.service.ts new file mode 100644 index 0000000..b584697 --- /dev/null +++ b/backend/src/modules/news/news.service.ts @@ -0,0 +1,238 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { load } from 'cheerio'; + +import { curlFetchHtml } from '../../shared/utils/curl-fetch'; +import { NewsItemDto } from './dto/news-item.dto'; +import { SaNewsEntity } from './entities/sa-news.entity'; + +// ──────────────────────────────────────────────────────────── +// SA 소식(공지사항/업데이트/GM이야기) — 1시간마다 크롤링 후 DB 영속. +// 유저에겐 DB에서 카테고리별로 서빙(방문마다 크롤링 X). +// 출처: sa.nexon.com 각 목록 페이지(서버렌더 HTML) +// ──────────────────────────────────────────────────────────── + +// 크롤링 대상 정의 +interface NewsSource { + category: string; + url: string; + // 'table' : 게시판 테이블형(td.subject + 날짜 td) — 공지/업데이트 + // 'gmstory': 카드 리스트형(.singleBoardList) — GM이야기 + type: 'table' | 'gmstory'; +} + +// 파싱 중간 결과 +interface RawItem { + date: string; + title: string; + link: string; +} + +@Injectable() +export class NewsService implements OnModuleInit { + private readonly logger = new Logger(NewsService.name); + + private static readonly REFERER = 'https://sa.nexon.com/'; + // 카테고리별 보관 개수 (홈 소식 카드 노출용 — 최신순 상위 N개) + private static readonly PER_CATEGORY = 5; + + private static readonly SOURCES: NewsSource[] = [ + { + category: '공지사항', + url: 'https://sa.nexon.com/news/notice/list.aspx', + type: 'table', + }, + { + category: '업데이트', + url: 'https://sa.nexon.com/news/update/list.aspx', + type: 'table', + }, + { + category: 'GM이야기', + url: 'https://sa.nexon.com/InfoCenter/gmstory/list.aspx', + type: 'gmstory', + }, + ]; + + constructor( + @InjectRepository(SaNewsEntity) + private readonly repo: Repository, + ) {} + + // 최초 기동 시 데이터가 비어 있으면 1회 시드 크롤링 (부팅 비차단) + onModuleInit(): void { + void this.seedIfEmpty(); + } + + private async seedIfEmpty(): Promise { + try { + const count = await this.repo.count(); + if (count > 0) return; + this.logger.log('소식 데이터가 없어 초기 크롤링을 수행합니다.'); + await this.refresh(); + } catch (error) { + this.logger.warn(`초기 소식 시드 실패: ${(error as Error).message}`); + } + } + + // 매시 정각 — SA 소식 갱신 + @Cron(CronExpression.EVERY_HOUR, { + name: 'hourly-news-refresh', + timeZone: 'Asia/Seoul', + }) + async handleHourlyRefresh(): Promise { + this.logger.log('시간별 소식 갱신 시작'); + await this.refresh(); + } + + /** + * 카테고리별로 독립 크롤링 → 성공·비어있지 않은 카테고리만 해당 행 교체. + * 한 카테고리가 실패/빈 결과여도 다른 카테고리·기존 데이터는 유지된다. + */ + async refresh(): Promise { + let total = 0; + + for (const src of NewsService.SOURCES) { + try { + const html = await curlFetchHtml(src.url, { + referer: NewsService.REFERER, + }); + const items = ( + src.type === 'gmstory' + ? this.parseGmStory(html) + : this.parseTable(html) + ).slice(0, NewsService.PER_CATEGORY); + + if (!items.length) { + this.logger.warn( + `[${src.category}] 크롤링 결과가 비어 갱신을 건너뜁니다(기존 유지).`, + ); + continue; + } + + // 카테고리 단위 전체 교체 (트랜잭션) + await this.repo.manager.transaction(async (m) => { + await m.delete(SaNewsEntity, { category: src.category }); + const rows = items.map((it, i) => + m.create(SaNewsEntity, { + category: src.category, + date: it.date, + title: it.title, + link: it.link, + sortOrder: i, + }), + ); + await m.save(rows); + }); + + total += items.length; + this.logger.log(`[${src.category}] ${items.length}건 갱신 완료`); + } catch (error) { + this.logger.warn( + `[${src.category}] 갱신 실패(기존 유지): ${(error as Error).message}`, + ); + } + } + + return total; + } + + /** DB에서 카테고리별 소식 조회 (크롤링하지 않음). 키 순서는 화면 탭 순서. */ + async getSections(): Promise> { + const rows = await this.repo.find({ + order: { category: 'ASC', sortOrder: 'ASC' }, + }); + + // 탭 순서를 고정해 초기화 (데이터 없어도 빈 배열 보장) + const out: Record = { + 공지사항: [], + GM이야기: [], + 업데이트: [], + }; + for (const r of rows) { + (out[r.category] ??= []).push({ + date: r.date, + title: r.title, + link: r.link, + }); + } + return out; + } + + /** 게시판 테이블형 파싱 (공지/업데이트): td.subject(제목) + 다음 td(날짜) */ + private parseTable(html: string): RawItem[] { + const $ = load(html); + const out: RawItem[] = []; + + $('td.subject').each((_, td) => { + const cell = $(td); + const a = cell.find('a').first(); + const title = a.text().replace(/\s+/g, ' ').trim(); + if (!title) return; + + const dateRaw = cell.next('td').text().trim(); + out.push({ + title, + date: this.normalizeDate(dateRaw), + link: this.normalizeLink(a.attr('href')?.trim() ?? ''), + }); + }); + + return out; + } + + /** GM이야기 카드 리스트 파싱: .date .months(날짜) + a.subject(제목) */ + private parseGmStory(html: string): RawItem[] { + const $ = load(html); + const out: RawItem[] = []; + + $('.singleBoardList tr').each((_, tr) => { + const row = $(tr); + const a = row.find('a.subject').first(); + const title = a.text().replace(/\s+/g, ' ').trim(); + if (!title) return; + + // 날짜: '

06.05

' (연도는 별도 .years) + const md = row.find('.date .months b').first().text().trim(); + out.push({ + title, + date: this.normalizeDate(md), + link: this.normalizeLink(a.attr('href')?.trim() ?? ''), + }); + }); + + return out; + } + + /** http → https 정규화 (혼합 콘텐츠 방지) */ + private normalizeLink(link: string): string { + return link.replace(/^http:\/\//i, 'https://'); + } + + /** + * 다양한 날짜 표기를 'MM.DD' 로 정규화. + * - '2026.06.09' / '2026-06-09' → '06.09' + * - '06.05'(GM이야기 months) → '06.05' + * - 'AM 10:12' / 'PM 02:18'(당일) → 오늘 날짜 MM.DD + */ + private normalizeDate(raw: string): string { + const full = raw.match(/(\d{4})[.-](\d{1,2})[.-](\d{1,2})/); + if (full) { + return `${full[2].padStart(2, '0')}.${full[3].padStart(2, '0')}`; + } + const md = raw.match(/^(\d{1,2})\.(\d{1,2})$/); + if (md) { + return `${md[1].padStart(2, '0')}.${md[2].padStart(2, '0')}`; + } + // 시간만 표기된 당일 글 → 크롤링 시점의 오늘 날짜 + if (/\d{1,2}:\d{2}/.test(raw) || /AM|PM|오전|오후/i.test(raw)) { + const now = new Date(); + const mm = String(now.getMonth() + 1).padStart(2, '0'); + const dd = String(now.getDate()).padStart(2, '0'); + return `${mm}.${dd}`; + } + return raw; + } +} diff --git a/backend/src/modules/ranking/dto/ranking-item.dto.ts b/backend/src/modules/ranking/dto/ranking-item.dto.ts new file mode 100644 index 0000000..ec03a50 --- /dev/null +++ b/backend/src/modules/ranking/dto/ranking-item.dto.ts @@ -0,0 +1,40 @@ +import { ApiProperty } from '@nestjs/swagger'; + +// SA 랭킹 항목 DTO (카테고리별로 사용하는 필드만 채워진다) +export class RankingItemDto { + @ApiProperty({ description: '랭킹 순위', example: 1 }) + rankNo!: number; + + @ApiProperty({ description: '닉네임 또는 클랜명', example: 'Hack' }) + name!: string; + + @ApiProperty({ description: '계급 이미지 URL', example: '' }) + gradeImg!: string; + + @ApiProperty({ description: '티어(MMR) 이미지 URL', example: '' }) + tierImg!: string; + + @ApiProperty({ description: '클랜마크 이미지 URL', example: '' }) + clanMark!: string; + + @ApiProperty({ description: '승률', example: '73.9%' }) + winRate!: string; + + @ApiProperty({ description: '킬데스', example: '56.1%' }) + kd!: string; + + @ApiProperty({ description: '전적/참여판수', example: '118540승 32289패 24무' }) + record!: string; + + @ApiProperty({ description: 'RP/클랜RP', example: '5,063' }) + rp!: string; + + @ApiProperty({ description: '플레이 시간', example: '107:22' }) + playTime!: string; + + @ApiProperty({ description: '누적점수(공식클랜)', example: '2,440' }) + score!: string; + + @ApiProperty({ description: '클랜마스터 닉네임', example: 'vVv-vVv' }) + master!: string; +} diff --git a/backend/src/modules/ranking/entities/sa-ranking.entity.ts b/backend/src/modules/ranking/entities/sa-ranking.entity.ts new file mode 100644 index 0000000..f79dc97 --- /dev/null +++ b/backend/src/modules/ranking/entities/sa-ranking.entity.ts @@ -0,0 +1,77 @@ +import { + Column, + Entity, + Index, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +// SA 랭킹 저장 테이블 (계급/랭크전/클랜 — 매일 00시 크롤링 결과를 영속화) +// 카테고리마다 사용하는 필드가 다르므로 미사용 필드는 빈 문자열로 둔다. +@Entity('sa_ranking') +@Index(['category', 'sortOrder']) +export class SaRankingEntity { + @PrimaryGeneratedColumn() + id!: number; + + // 카테고리: + // total_class(통합계급) | season_class(시즌계급) + // ranked_solo(랭크전 솔로) | ranked_party(랭크전 파티) | ranked_clan(랭크전 클랜) + // official_clan(공식클랜) | clan(클랜) + @Column({ type: 'varchar', length: 20 }) + category!: string; + + @Column({ type: 'int' }) + rankNo!: number; + + // 닉네임(유저 랭킹) 또는 클랜명(클랜 랭킹) + @Column({ type: 'varchar', length: 100 }) + name!: string; + + // 계급 이미지 URL (유저 계급/랭크전) + @Column({ type: 'varchar', length: 500, default: '' }) + gradeImg!: string; + + // 티어(MMR) 이미지 URL (랭크전 유저) + @Column({ type: 'varchar', length: 500, default: '' }) + tierImg!: string; + + // 클랜마크 이미지 URL — 2개 레이어(배경 0_, 심볼 1_)를 '|'로 join (겹쳐서 표시) + @Column({ type: 'varchar', length: 1000, default: '' }) + clanMark!: string; + + // 승률 (예: '73.9%') + @Column({ type: 'varchar', length: 20, default: '' }) + winRate!: string; + + // 킬데스 (예: '62.6%') + @Column({ type: 'varchar', length: 20, default: '' }) + kd!: string; + + // 전적/참여판수 (예: '118540승 32289패 24무') + @Column({ type: 'varchar', length: 100, default: '' }) + record!: string; + + // RP/클랜RP (예: '5,063') + @Column({ type: 'varchar', length: 30, default: '' }) + rp!: string; + + // 플레이 시간 (랭크전 유저, 예: '107:22') + @Column({ type: 'varchar', length: 20, default: '' }) + playTime!: string; + + // 누적점수 (공식클랜, 예: '2,440') + @Column({ type: 'varchar', length: 30, default: '' }) + score!: string; + + // 클랜마스터 닉네임 (클랜 랭킹) + @Column({ type: 'varchar', length: 100, default: '' }) + master!: string; + + // 크롤링 순서(랭킹 순서) 유지용 + @Column({ type: 'int', default: 0 }) + sortOrder!: number; + + @UpdateDateColumn() + updatedAt!: Date; +} diff --git a/backend/src/modules/ranking/ranking.controller.ts b/backend/src/modules/ranking/ranking.controller.ts new file mode 100644 index 0000000..2066cba --- /dev/null +++ b/backend/src/modules/ranking/ranking.controller.ts @@ -0,0 +1,25 @@ +import { Controller, Get } from '@nestjs/common'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; + +import { RankingService } from './ranking.service'; +import { RankingItemDto } from './dto/ranking-item.dto'; + +@ApiTags('Ranking') +@Controller('ranking') +export class RankingController { + constructor(private readonly rankingService: RankingService) {} + + @Get('sections') + @ApiOperation({ + summary: 'SA 랭킹 카테고리별 조회', + description: + 'DB에 저장된 랭킹을 카테고리별로 반환합니다. 카테고리 키: total_class, season_class, ranked_solo, ranked_party, ranked_clan, official_clan, clan. 원본은 매일 00:00(KST) 크롤링으로 갱신됩니다.', + }) + @ApiResponse({ + status: 200, + description: '카테고리별 랭킹 목록 (카테고리 키 → RankingItemDto[])', + }) + getSections(): Promise> { + return this.rankingService.getSections(); + } +} diff --git a/backend/src/modules/ranking/ranking.module.ts b/backend/src/modules/ranking/ranking.module.ts new file mode 100644 index 0000000..c23192c --- /dev/null +++ b/backend/src/modules/ranking/ranking.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { RankingController } from './ranking.controller'; +import { RankingService } from './ranking.service'; +import { SaRankingEntity } from './entities/sa-ranking.entity'; + +// SA 랭킹 모듈 — 매일 00시 크롤링(스케줄) + DB 영속 +@Module({ + imports: [TypeOrmModule.forFeature([SaRankingEntity])], + controllers: [RankingController], + providers: [RankingService], + exports: [RankingService], +}) +export class RankingModule {} diff --git a/backend/src/modules/ranking/ranking.service.ts b/backend/src/modules/ranking/ranking.service.ts new file mode 100644 index 0000000..6519b8a --- /dev/null +++ b/backend/src/modules/ranking/ranking.service.ts @@ -0,0 +1,394 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { load, type CheerioAPI, type Cheerio } from 'cheerio'; +import type { Element } from 'domhandler'; + +import { curlFetchHtml } from '../../shared/utils/curl-fetch'; +import { RankingItemDto } from './dto/ranking-item.dto'; +import { SaRankingEntity } from './entities/sa-ranking.entity'; + +// ──────────────────────────────────────────────────────────── +// SA 랭킹(계급/랭크전/클랜) — 매일 00:00(KST) 크롤링 후 DB 영속. +// 유저에겐 DB에서 카테고리별로 서빙(방문마다 크롤링 X). +// 데이터는 AJAX 리스트 엔드포인트 또는 정적 페이지에서 수집한다. +// 시즌 파라미터(예: 20260102 / 2602)는 시즌마다 바뀌므로 list 페이지에서 추출한다. +// ──────────────────────────────────────────────────────────── + +// 파싱 중간 결과 (카테고리별로 일부 필드만 채워진다) +interface RankingRaw { + rankNo: number; + name: string; + gradeImg?: string; + tierImg?: string; + clanMark?: string; + winRate?: string; + kd?: string; + record?: string; + rp?: string; + playTime?: string; + score?: string; + master?: string; +} + +@Injectable() +export class RankingService implements OnModuleInit { + private readonly logger = new Logger(RankingService.name); + + private static readonly BASE = 'https://sa.nexon.com'; + private static readonly REFERER = 'https://sa.nexon.com/'; + // 카테고리별 보관 개수 (홈 랭킹 카드 TOP 10) + private static readonly PER_CATEGORY = 10; + // AJAX 엔드포인트 호출용 헤더 + private static readonly AJAX_HEADERS = { 'X-Requested-With': 'XMLHttpRequest' }; + + constructor( + @InjectRepository(SaRankingEntity) + private readonly repo: Repository, + ) {} + + onModuleInit(): void { + void this.seedIfEmpty(); + } + + private async seedIfEmpty(): Promise { + try { + const count = await this.repo.count(); + if (count > 0) return; + this.logger.log('랭킹 데이터가 없어 초기 크롤링을 수행합니다.'); + await this.refresh(); + } catch (error) { + this.logger.warn(`초기 랭킹 시드 실패: ${(error as Error).message}`); + } + } + + // 매일 00:00:00 (KST) — 랭킹 갱신 (전일 기록 기준 갱신 시점) + @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT, { + name: 'daily-ranking-refresh', + timeZone: 'Asia/Seoul', + }) + async handleDailyRefresh(): Promise { + this.logger.log('일일 랭킹 갱신(00:00 KST) 시작'); + await this.refresh(); + } + + /** + * 7개 데이터셋을 각각 크롤링 → 성공·비어있지 않은 카테고리만 교체. + * 한 카테고리 실패/빈 결과여도 다른 카테고리·기존 데이터는 유지된다. + */ + async refresh(): Promise { + let total = 0; + + // 각 카테고리를 (페치+파싱) 후 교체. 실패는 개별 격리. + const tasks: { category: string; run: () => Promise }[] = [ + { category: 'total_class', run: () => this.crawlTotalClass() }, + { category: 'season_class', run: () => this.crawlSeasonClass() }, + { category: 'ranked_solo', run: () => this.crawlRankMatch('S') }, + { category: 'ranked_party', run: () => this.crawlRankMatch('') }, + { category: 'ranked_clan', run: () => this.crawlClanRank() }, + { category: 'official_clan', run: () => this.crawlOfficialClan() }, + { category: 'clan', run: () => this.crawlClan() }, + ]; + + for (const task of tasks) { + try { + const items = (await task.run()).slice(0, RankingService.PER_CATEGORY); + if (!items.length) { + this.logger.warn( + `[${task.category}] 크롤링 결과가 비어 갱신을 건너뜁니다(기존 유지).`, + ); + continue; + } + await this.replaceCategory(task.category, items); + total += items.length; + this.logger.log(`[${task.category}] ${items.length}건 갱신 완료`); + } catch (error) { + this.logger.warn( + `[${task.category}] 갱신 실패(기존 유지): ${(error as Error).message}`, + ); + } + } + + return total; + } + + /** 카테고리 단위 전체 교체 (트랜잭션) */ + private async replaceCategory( + category: string, + items: RankingRaw[], + ): Promise { + await this.repo.manager.transaction(async (m) => { + await m.delete(SaRankingEntity, { category }); + const rows = items.map((it, i) => + m.create(SaRankingEntity, { + category, + rankNo: it.rankNo, + name: it.name, + gradeImg: it.gradeImg ?? '', + tierImg: it.tierImg ?? '', + clanMark: it.clanMark ?? '', + winRate: it.winRate ?? '', + kd: it.kd ?? '', + record: it.record ?? '', + rp: it.rp ?? '', + playTime: it.playTime ?? '', + score: it.score ?? '', + master: it.master ?? '', + sortOrder: i, + }), + ); + await m.save(rows); + }); + } + + /** DB에서 카테고리별 랭킹 조회 (크롤링하지 않음). 키 순서는 화면 카테고리 순서. */ + async getSections(): Promise> { + const rows = await this.repo.find({ + order: { category: 'ASC', sortOrder: 'ASC' }, + }); + + const out: Record = { + total_class: [], + season_class: [], + ranked_solo: [], + ranked_party: [], + ranked_clan: [], + official_clan: [], + clan: [], + }; + for (const r of rows) { + (out[r.category] ??= []).push({ + rankNo: r.rankNo, + name: r.name, + gradeImg: r.gradeImg, + tierImg: r.tierImg, + clanMark: r.clanMark, + winRate: r.winRate, + kd: r.kd, + record: r.record, + rp: r.rp, + playTime: r.playTime, + score: r.score, + master: r.master, + }); + } + return out; + } + + // ────────────────────────────────────────────────────────── + // 크롤링 (카테고리별) + // ────────────────────────────────────────────────────────── + + /** AJAX/정적 페이지 fetch (브라우저 + AJAX 헤더) */ + private fetch(url: string): Promise { + return curlFetchHtml(RankingService.BASE + url, { + referer: RankingService.REFERER, + headers: RankingService.AJAX_HEADERS, + }); + } + + /** list 페이지에서 정규식으로 현재 시즌값 추출 */ + private async extractSeason( + listPath: string, + regexes: RegExp[], + ): Promise { + const html = await this.fetch(listPath); + for (const re of regexes) { + const m = html.match(re); + if (m?.[1]) return m[1]; + } + throw new Error(`시즌값 추출 실패: ${listPath}`); + } + + /** 통합 계급 — 시즌 무관 */ + private async crawlTotalClass(): Promise { + const html = await this.fetch( + '/ranking/total/ranklist.aspx?n4PageNo=1&strSearch=&delay=false&rd=0.1', + ); + return this.parseUserClass(html); + } + + /** 시즌 계급 — seasonlevel list 에서 현재 시즌 추출 */ + private async crawlSeasonClass(): Promise { + const season = await this.extractSeason('/ranking/seasonlevel/list.aspx', [ + /id="Contents_hid_now_season_no"\s+value="(\d+)"/i, + /]*value="(\d+)"/i, + ]); + const html = await this.fetch( + `/ranking/seasonlevel/ranklist.aspx?strSeasonNo=${season}&n4PageNo=1&strSearch=&delay=false&now_season=${season}&rd=0.1`, + ); + return this.parseUserClass(html); + } + + /** 랭크전 솔로(party='S') / 파티(party='') — rankmatch list 에서 시즌 추출 */ + private async crawlRankMatch(party: 'S' | ''): Promise { + // 첫 옵션 value(예: 2602S)에서 숫자 부분만 시즌 번호로 사용 + const season = await this.extractSeason('/ranking/rankmatch/list.aspx', [ + /]*value="(\d+)S?"/i, + ]); + const html = await this.fetch( + `/ranking/rankmatch/rankinglist.aspx?n2SeasonNo=${season}&n4PageNo=1&strSeasonType=${party}&strSearch=&delay=false&now_season=${season}&rd=0.1`, + ); + return this.parseRankMatch(html); + } + + /** 랭크전 클랜 — clanrank list 에서 시즌 추출. + * ※ now_season 과 gubun(C=클랜) 파라미터가 없으면 "집계 중" 빈 결과가 반환된다. */ + private async crawlClanRank(): Promise { + const season = await this.extractSeason('/ranking/clanrank/list.aspx', [ + /id="Contents_hid_now_season_no"\s+value="(\d+)"/i, + /]*value="(\d+)"/i, + ]); + const html = await this.fetch( + `/ranking/clanrank/rank.aspx?strSeasonNo=${season}&n4PageNo=1&strSearch=&delay=false&now_season=${season}&gubun=C&rd=0.1`, + ); + return this.parseClanRank(html); + } + + /** 공식클랜 — 정적 페이지 */ + private async crawlOfficialClan(): Promise { + const html = await this.fetch('/ranking/official/rank.aspx'); + return this.parseOfficialClan(html); + } + + /** 클랜 — 정적 페이지 */ + private async crawlClan(): Promise { + const html = await this.fetch('/ranking/clan/rank.aspx'); + return this.parseClan(html); + } + + // ────────────────────────────────────────────────────────── + // 파싱 (응답 HTML → RankingRaw[]) + // ────────────────────────────────────────────────────────── + + /** boardList 의 데이터 행(tr) 수집 (no-data 행 제외) */ + private dataRows($: CheerioAPI): Cheerio { + return $('.boardList tbody tr').filter( + (_, tr) => $(tr).find('td.no-data, .no-data').length === 0, + ); + } + + private txt($el: Cheerio): string { + return $el.text().replace(/\s+/g, ' ').trim(); + } + + /** 클랜마크 이미지 src 들을 '|'로 join (배경 0_ + 심볼 1_ 2개 레이어) */ + private joinImgSrcs($imgs: Cheerio): string { + const out: string[] = []; + $imgs.each((_, el) => { + const src = el.attribs?.['src']; + if (src) out.push(src); + }); + return out.join('|'); + } + + /** 계급 랭킹(통합/시즌): [순위][등락][닉네임+계급][승률][킬데스][전적][클랜] */ + private parseUserClass(html: string): RankingRaw[] { + const $ = load(html); + const out: RankingRaw[] = []; + this.dataRows($).each((i, tr) => { + const row = $(tr); + const tds = row.find('> td'); + const name = this.txt(row.find('td.left a').first()); + if (!name) return; + out.push({ + rankNo: i + 1, + name, + gradeImg: row.find('span.grade img').first().attr('src') ?? '', + winRate: this.txt(tds.eq(3)), + kd: this.txt(tds.eq(4)), + record: this.txt(tds.eq(5)), + clanMark: this.joinImgSrcs(row.find('span.clanmark img')), + }); + }); + return out; + } + + /** 랭크전 유저(솔로/파티): [순위][등락][닉네임+계급][RP+티어][승률][킬데스][플레이시간][클랜] */ + private parseRankMatch(html: string): RankingRaw[] { + const $ = load(html); + const out: RankingRaw[] = []; + this.dataRows($).each((i, tr) => { + const row = $(tr); + const tds = row.find('> td'); + const name = this.txt(row.find('td.left a').first()); + if (!name) return; + const rpCell = tds.eq(3); + out.push({ + rankNo: i + 1, + name, + gradeImg: row.find('span.grade img').first().attr('src') ?? '', + tierImg: rpCell.find('span.mmr img').first().attr('src') ?? '', + // 'XX,XXX RP' → 숫자/콤마만 + rp: this.txt(rpCell).replace(/[^0-9,]/g, ''), + winRate: this.txt(tds.eq(4)), + kd: this.txt(tds.eq(5)), + playTime: this.txt(tds.eq(6)), + clanMark: this.joinImgSrcs(row.find('span.clanmark img')), + }); + }); + return out; + } + + /** 랭크전 클랜: [순위][등락][클랜명][클랜RP][승률][참여판수][마스터] */ + private parseClanRank(html: string): RankingRaw[] { + const $ = load(html); + const out: RankingRaw[] = []; + this.dataRows($).each((i, tr) => { + const row = $(tr); + const tds = row.find('> td'); + const name = this.txt(tds.eq(2).find('a').first()) || this.txt(tds.eq(2)); + if (!name) return; + out.push({ + rankNo: i + 1, + name, + clanMark: this.joinImgSrcs(tds.eq(2).find('span.clanmark img')), + rp: this.txt(tds.eq(3)).replace(/[^0-9,]/g, ''), + winRate: this.txt(tds.eq(4)), + record: this.txt(tds.eq(5)), + master: this.txt(tds.eq(6).find('a').first()) || this.txt(tds.eq(6)), + }); + }); + return out; + } + + /** 공식클랜: [순위][등락][클랜명][누적점수][클랜마스터] */ + private parseOfficialClan(html: string): RankingRaw[] { + const $ = load(html); + const out: RankingRaw[] = []; + this.dataRows($).each((i, tr) => { + const row = $(tr); + const tds = row.find('> td'); + const name = this.txt(tds.eq(2).find('a b').first()); + if (!name) return; + out.push({ + rankNo: i + 1, + name, + clanMark: this.joinImgSrcs(tds.eq(2).find('span.clanmark img')), + score: this.txt(tds.eq(3)), + master: this.txt(tds.eq(4).find('a').first()) || this.txt(tds.eq(4)), + }); + }); + return out; + } + + /** 클랜: [순위][등락][클랜명][클랜마스터] */ + private parseClan(html: string): RankingRaw[] { + const $ = load(html); + const out: RankingRaw[] = []; + this.dataRows($).each((i, tr) => { + const row = $(tr); + const tds = row.find('> td'); + const name = this.txt(tds.eq(2).find('a b').first()) || this.txt(tds.eq(2).find('a').first()); + if (!name) return; + out.push({ + rankNo: i + 1, + name, + clanMark: this.joinImgSrcs(tds.eq(2).find('span.clanmark img')), + master: this.txt(tds.eq(3).find('a').first()) || this.txt(tds.eq(3)), + }); + }); + return out; + } +} diff --git a/backend/src/modules/sa-match/dto/match-detail.dto.ts b/backend/src/modules/sa-match/dto/match-detail.dto.ts new file mode 100644 index 0000000..a81009c --- /dev/null +++ b/backend/src/modules/sa-match/dto/match-detail.dto.ts @@ -0,0 +1,24 @@ +import { ApiProperty } from '@nestjs/swagger'; + +import { ParticipantDto } from './participant.dto'; + +// 매치 상세 DTO (맵 + 참가자 전원) +export class MatchDetailDto { + @ApiProperty({ example: '260607145024007001' }) + matchId!: string; + + @ApiProperty({ example: '랭크전 솔로' }) + matchType!: string; + + @ApiProperty({ example: '폭파미션' }) + matchMode!: string; + + @ApiProperty({ description: '매치 일시(ISO)', example: '2026-06-07T05:50:24.532Z' }) + dateMatch!: string; + + @ApiProperty({ description: '매치 맵', example: '제3보급창고' }) + matchMap!: string; + + @ApiProperty({ type: [ParticipantDto], description: '양 팀 참가자 성적' }) + participants!: ParticipantDto[]; +} diff --git a/backend/src/modules/sa-match/dto/match-item.dto.ts b/backend/src/modules/sa-match/dto/match-item.dto.ts new file mode 100644 index 0000000..f993ab4 --- /dev/null +++ b/backend/src/modules/sa-match/dto/match-item.dto.ts @@ -0,0 +1,28 @@ +import { ApiProperty } from '@nestjs/swagger'; + +// 매치 목록 항목 DTO +export class MatchItemDto { + @ApiProperty({ example: '260607145024007001' }) + matchId!: string; + + @ApiProperty({ example: '랭크전 솔로' }) + matchType!: string; + + @ApiProperty({ example: '폭파미션' }) + matchMode!: string; + + @ApiProperty({ description: '매치 일시(ISO)', example: '2026-06-07T05:50:24.532Z' }) + dateMatch!: string; + + @ApiProperty({ description: '매치 결과 코드(본인 기준)', example: '1' }) + matchResult!: string; + + @ApiProperty({ example: 9 }) + kill!: number; + + @ApiProperty({ example: 6 }) + death!: number; + + @ApiProperty({ example: 3 }) + assist!: number; +} diff --git a/backend/src/modules/sa-match/dto/match-query.dto.ts b/backend/src/modules/sa-match/dto/match-query.dto.ts new file mode 100644 index 0000000..b01728c --- /dev/null +++ b/backend/src/modules/sa-match/dto/match-query.dto.ts @@ -0,0 +1,24 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; + +// 매치 목록 조회 쿼리 DTO +export class MatchQueryDto { + @ApiProperty({ description: '유저 OUID', example: '9b2852e25a85deebc5ce4173b1ccc1e6' }) + @IsString() + @IsNotEmpty({ message: 'ouid를 입력해 주세요.' }) + ouid!: string; + + @ApiProperty({ + description: '매치 타입', + example: '랭크전 솔로', + enum: ['퀵매치 클랜전', '클랜 랭크전', '랭크전 솔로', '랭크전 파티', '토너먼트'], + }) + @IsString() + @IsNotEmpty({ message: 'match_type을 입력해 주세요.' }) + match_type!: string; + + @ApiPropertyOptional({ description: '매치 모드', example: '폭파미션', default: '폭파미션' }) + @IsString() + @IsOptional() + match_mode: string = '폭파미션'; +} diff --git a/backend/src/modules/sa-match/dto/participant.dto.ts b/backend/src/modules/sa-match/dto/participant.dto.ts new file mode 100644 index 0000000..91349a3 --- /dev/null +++ b/backend/src/modules/sa-match/dto/participant.dto.ts @@ -0,0 +1,34 @@ +import { ApiProperty } from '@nestjs/swagger'; + +// 매치 상세 참가자 DTO +export class ParticipantDto { + @ApiProperty({ description: '팀 식별자', example: '1' }) + teamId!: string; + + @ApiProperty({ description: '매치 결과 코드', example: '1' }) + matchResult!: string; + + @ApiProperty({ example: 'Hack' }) + userName!: string; + + @ApiProperty({ description: '시즌 계급', example: '총사령관' }) + seasonGrade!: string; + + @ApiProperty({ description: '클랜명', example: '바디' }) + clanName!: string; + + @ApiProperty({ example: 18 }) + kill!: number; + + @ApiProperty({ example: 9 }) + death!: number; + + @ApiProperty({ example: 7 }) + headshot!: number; + + @ApiProperty({ example: 3200 }) + damage!: number; + + @ApiProperty({ example: 4 }) + assist!: number; +} diff --git a/backend/src/modules/sa-match/entities/sa-match-detail.entity.ts b/backend/src/modules/sa-match/entities/sa-match-detail.entity.ts new file mode 100644 index 0000000..210de3e --- /dev/null +++ b/backend/src/modules/sa-match/entities/sa-match-detail.entity.ts @@ -0,0 +1,26 @@ +import { Column, CreateDateColumn, Entity, PrimaryColumn } from 'typeorm'; + +// 매치 상세 헤더 (lazy) — 유저가 '더보기' 했을 때만 match-detail API 로 적재. +// 존재 여부로 상세 로드 완료를 판단한다(참가자는 sa_match_participants). +@Entity('sa_match_details') +export class SaMatchDetailEntity { + @PrimaryColumn({ type: 'varchar', length: 40 }) + matchId!: string; + + @Column({ type: 'varchar', length: 30, default: '' }) + matchType!: string; + + @Column({ type: 'varchar', length: 30, default: '' }) + matchMode!: string; + + @Column({ type: 'timestamptz', nullable: true }) + dateMatch!: Date | null; + + // 매치 맵 (상세에서만 제공) + @Column({ type: 'varchar', length: 50, default: '' }) + matchMap!: string; + + // 상세 적재 시각 + @CreateDateColumn() + detailLoadedAt!: Date; +} diff --git a/backend/src/modules/sa-match/entities/sa-match-participant.entity.ts b/backend/src/modules/sa-match/entities/sa-match-participant.entity.ts new file mode 100644 index 0000000..01d884f --- /dev/null +++ b/backend/src/modules/sa-match/entities/sa-match-participant.entity.ts @@ -0,0 +1,48 @@ +import { + Column, + Entity, + Index, + PrimaryGeneratedColumn, +} from 'typeorm'; + +// 매치 상세 참가자 (lazy) — 한 매치의 양 팀 전원 성적. match-detail API 로 적재. +@Entity('sa_match_participants') +@Index(['matchId']) +export class SaMatchParticipantEntity { + @PrimaryGeneratedColumn() + id!: number; + + @Column({ type: 'varchar', length: 40 }) + matchId!: string; + + @Column({ type: 'varchar', length: 20, default: '' }) + teamId!: string; + + @Column({ type: 'varchar', length: 10, default: '' }) + matchResult!: string; + + @Column({ type: 'varchar', length: 50, default: '' }) + userName!: string; + + @Column({ type: 'varchar', length: 30, default: '' }) + seasonGrade!: string; + + @Column({ type: 'varchar', length: 50, default: '' }) + clanName!: string; + + @Column({ type: 'int', default: 0 }) + kill!: number; + + @Column({ type: 'int', default: 0 }) + death!: number; + + @Column({ type: 'int', default: 0 }) + headshot!: number; + + // 데미지는 소수(float)로 제공됨 (예: 543.0, 819.0) + @Column({ type: 'float', default: 0 }) + damage!: number; + + @Column({ type: 'int', default: 0 }) + assist!: number; +} diff --git a/backend/src/modules/sa-match/entities/sa-match.entity.ts b/backend/src/modules/sa-match/entities/sa-match.entity.ts new file mode 100644 index 0000000..a976d6b --- /dev/null +++ b/backend/src/modules/sa-match/entities/sa-match.entity.ts @@ -0,0 +1,54 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, +} from 'typeorm'; + +// 매치 목록 (유저별 매치 참여 기록) — match API 결과를 누적. +// 같은 매치라도 유저(ouid)마다 본인 성적(k/d/a)이 다르므로 (matchId, ouid) 단위로 저장한다. +@Entity('sa_matches') +@Index(['ouid', 'matchType']) +@Index(['matchId', 'ouid'], { unique: true }) +export class SaMatchEntity { + @PrimaryGeneratedColumn() + id!: number; + + // 매치 식별자 + @Index() + @Column({ type: 'varchar', length: 40 }) + matchId!: string; + + // 매치를 조회한 유저(본인) 식별자 + @Column({ type: 'varchar', length: 64 }) + ouid!: string; + + // 매치 타입 (랭크전 솔로/파티, 퀵매치 클랜전, 클랜 랭크전, 토너먼트 등) + @Column({ type: 'varchar', length: 30 }) + matchType!: string; + + // 매치 모드 (폭파미션 등) + @Column({ type: 'varchar', length: 30 }) + matchMode!: string; + + // 매치 일시 + @Column({ type: 'timestamptz' }) + dateMatch!: Date; + + // 매치 결과 코드 (본인 기준) + @Column({ type: 'varchar', length: 10, default: '' }) + matchResult!: string; + + @Column({ type: 'int', default: 0 }) + kill!: number; + + @Column({ type: 'int', default: 0 }) + death!: number; + + @Column({ type: 'int', default: 0 }) + assist!: number; + + @CreateDateColumn() + createdAt!: Date; +} diff --git a/backend/src/modules/sa-match/sa-match.controller.ts b/backend/src/modules/sa-match/sa-match.controller.ts new file mode 100644 index 0000000..aaba3fd --- /dev/null +++ b/backend/src/modules/sa-match/sa-match.controller.ts @@ -0,0 +1,35 @@ +import { Controller, Get, Param, Query } from '@nestjs/common'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; + +import { SaMatchService } from './sa-match.service'; +import { MatchQueryDto } from './dto/match-query.dto'; +import { MatchItemDto } from './dto/match-item.dto'; +import { MatchDetailDto } from './dto/match-detail.dto'; + +@ApiTags('SA Match') +@Controller('sa-matches') +export class SaMatchController { + constructor(private readonly saMatchService: SaMatchService) {} + + @Get() + @ApiOperation({ + summary: '매치 목록 조회 (기본)', + description: + '유저(ouid)의 매치 목록을 넥슨 API에서 가져와 누적 저장 후 반환합니다. 상세 정보는 포함하지 않습니다.', + }) + @ApiResponse({ status: 200, description: '매치 목록', type: [MatchItemDto] }) + getMatches(@Query() dto: MatchQueryDto): Promise { + return this.saMatchService.getMatches(dto.ouid, dto.match_type, dto.match_mode); + } + + @Get(':matchId') + @ApiOperation({ + summary: '매치 상세 조회 (더보기)', + description: + '특정 매치의 맵/참가자 상세를 반환합니다. 유저가 더보기 했을 때만 호출되며, 최초 1회 API로 적재 후 캐시됩니다.', + }) + @ApiResponse({ status: 200, description: '매치 상세', type: MatchDetailDto }) + getMatchDetail(@Param('matchId') matchId: string): Promise { + return this.saMatchService.getMatchDetail(matchId); + } +} diff --git a/backend/src/modules/sa-match/sa-match.module.ts b/backend/src/modules/sa-match/sa-match.module.ts new file mode 100644 index 0000000..856c048 --- /dev/null +++ b/backend/src/modules/sa-match/sa-match.module.ts @@ -0,0 +1,26 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { SaMatchController } from './sa-match.controller'; +import { SaMatchService } from './sa-match.service'; +import { SaMatchEntity } from './entities/sa-match.entity'; +import { SaMatchDetailEntity } from './entities/sa-match-detail.entity'; +import { SaMatchParticipantEntity } from './entities/sa-match-participant.entity'; +import { SaUserEntity } from '../sa-user/entities/sa-user.entity'; + +// 매치 모듈 — 목록(기본 누적 + 일일 크론) + 상세(더보기 lazy). 넥슨 호출은 전역 NexonService 사용. +// 크론에서 등록 유저(sa_users)를 읽기 위해 SaUserEntity 를 함께 등록한다. +@Module({ + imports: [ + TypeOrmModule.forFeature([ + SaMatchEntity, + SaMatchDetailEntity, + SaMatchParticipantEntity, + SaUserEntity, + ]), + ], + controllers: [SaMatchController], + providers: [SaMatchService], + exports: [SaMatchService], +}) +export class SaMatchModule {} diff --git a/backend/src/modules/sa-match/sa-match.service.ts b/backend/src/modules/sa-match/sa-match.service.ts new file mode 100644 index 0000000..83defc5 --- /dev/null +++ b/backend/src/modules/sa-match/sa-match.service.ts @@ -0,0 +1,233 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { NexonService } from '../../shared/nexon/nexon.service'; +import { SaUserEntity } from '../sa-user/entities/sa-user.entity'; +import { MatchItemDto } from './dto/match-item.dto'; +import { MatchDetailDto } from './dto/match-detail.dto'; +import { ParticipantDto } from './dto/participant.dto'; +import { SaMatchEntity } from './entities/sa-match.entity'; +import { SaMatchDetailEntity } from './entities/sa-match-detail.entity'; +import { SaMatchParticipantEntity } from './entities/sa-match-participant.entity'; + +// 넥슨 API 응답 타입 +interface MatchApiItem { + match_id: string; + match_type: string; + match_mode: string; + date_match: string; + match_result: string; + kill: number; + death: number; + assist: number; +} +interface MatchListRes { + match: MatchApiItem[]; +} +interface DetailParticipant { + team_id: string; + match_result: string; + user_name: string; + season_grade: string; + // 실제 응답 필드는 clan_name 이 아니라 guild_name 이며 null 일 수 있음 + guild_name: string | null; + kill: number; + death: number; + headshot: number; + damage: number; + assist: number; +} +interface MatchDetailRes { + match_id: string; + match_type: string; + match_mode: string; + date_match: string; + match_map: string; + match_detail: DetailParticipant[]; +} + +// ──────────────────────────────────────────────────────────── +// 매치 — 목록(기본)은 넥슨 API에서 가져와 누적 저장하고, +// 상세(맵/참가자)는 유저가 '더보기' 했을 때만 lazy 적재한다. +// 매일 크론으로 등록 유저 전체의 매치 목록도 자동 누적한다(상세는 제외). +// 모든 넥슨 호출은 NexonService 의 전역 rate limiter 를 통과한다. +// ──────────────────────────────────────────────────────────── +@Injectable() +export class SaMatchService { + private readonly logger = new Logger(SaMatchService.name); + + // 기본 매치 모드 / 전체 매치 타입 + private static readonly DEFAULT_MODE = '폭파미션'; + private static readonly MATCH_TYPES = [ + '퀵매치 클랜전', + '클랜 랭크전', + '랭크전 솔로', + '랭크전 파티', + '토너먼트', + ]; + + constructor( + @InjectRepository(SaMatchEntity) + private readonly matchRepo: Repository, + @InjectRepository(SaMatchDetailEntity) + private readonly detailRepo: Repository, + @InjectRepository(SaMatchParticipantEntity) + private readonly participantRepo: Repository, + @InjectRepository(SaUserEntity) + private readonly userRepo: Repository, + private readonly nexon: NexonService, + ) {} + + /** 매치 목록 조회 — 최신분 누적 후 DB에서 반환. */ + async getMatches( + ouid: string, + matchType: string, + matchMode: string, + ): Promise { + await this.syncMatches(ouid, matchType, matchMode); + + const saved = await this.matchRepo.find({ + where: { ouid, matchType }, + order: { dateMatch: 'DESC' }, + }); + return saved.map((m) => ({ + matchId: m.matchId, + matchType: m.matchType, + matchMode: m.matchMode, + dateMatch: m.dateMatch.toISOString(), + matchResult: m.matchResult, + kill: m.kill, + death: m.death, + assist: m.assist, + })); + } + + /** 넥슨 매치 목록 API → 신규분 누적(upsert). 실패는 격리(기존 데이터 유지). */ + private async syncMatches( + ouid: string, + matchType: string, + matchMode: string, + ): Promise { + try { + const res = await this.nexon.get('/suddenattack/v1/match', { + ouid, + match_mode: matchMode, + match_type: matchType, + }); + const items = res?.match ?? []; + if (!items.length) return; + const rows = items.map((m) => ({ + matchId: m.match_id, + ouid, + matchType: m.match_type, + matchMode: m.match_mode, + dateMatch: new Date(m.date_match), + matchResult: m.match_result ?? '', + kill: m.kill ?? 0, + death: m.death ?? 0, + assist: m.assist ?? 0, + })); + await this.matchRepo.upsert(rows, ['matchId', 'ouid']); + } catch (error) { + this.logger.warn( + `매치 동기화 실패(ouid=${ouid}, type=${matchType}): ${(error as Error).message}`, + ); + } + } + + // 매일 03:00 (KST) — 등록 유저 전체의 매치 목록 자동 누적 (상세는 더보기 시에만) + @Cron('0 3 * * *', { + name: 'daily-match-sync', + timeZone: 'Asia/Seoul', + }) + async handleDailyMatchSync(): Promise { + const users = await this.userRepo.find(); + this.logger.log( + `일일 매치 동기화 시작 (대상 ${users.length}명 × ${SaMatchService.MATCH_TYPES.length}타입)`, + ); + for (const user of users) { + for (const type of SaMatchService.MATCH_TYPES) { + // 호출 간격은 NexonService rate limiter 가 전역으로 조절한다 + await this.syncMatches(user.ouid, type, SaMatchService.DEFAULT_MODE); + } + } + this.logger.log('일일 매치 동기화 완료'); + } + + /** 매치 상세 조회 — DB에 적재돼 있으면 반환, 없으면 더보기 시점에 1회 API 적재 후 반환. */ + async getMatchDetail(matchId: string): Promise { + const cached = await this.detailRepo.findOne({ where: { matchId } }); + if (cached) { + const parts = await this.participantRepo.find({ where: { matchId } }); + return this.toDetailDto(cached, parts); + } + + const res = await this.nexon.get( + '/suddenattack/v1/match-detail', + { match_id: matchId }, + ); + + // 헤더 + 참가자 적재 (재로드 대비 참가자는 교체) + await this.detailRepo.manager.transaction(async (m) => { + await m.delete(SaMatchParticipantEntity, { matchId }); + await m.save( + m.create(SaMatchDetailEntity, { + matchId: res.match_id, + matchType: res.match_type ?? '', + matchMode: res.match_mode ?? '', + dateMatch: res.date_match ? new Date(res.date_match) : null, + matchMap: res.match_map ?? '', + }), + ); + const parts = (res.match_detail ?? []).map((p) => + m.create(SaMatchParticipantEntity, { + matchId: res.match_id, + teamId: p.team_id ?? '', + matchResult: p.match_result ?? '', + userName: p.user_name ?? '', + seasonGrade: p.season_grade ?? '', + clanName: p.guild_name ?? '', + kill: p.kill ?? 0, + death: p.death ?? 0, + headshot: p.headshot ?? 0, + damage: p.damage ?? 0, + assist: p.assist ?? 0, + }), + ); + await m.save(parts); + }); + + const header = await this.detailRepo.findOne({ where: { matchId } }); + const parts = await this.participantRepo.find({ where: { matchId } }); + return this.toDetailDto(header!, parts); + } + + private toDetailDto( + header: SaMatchDetailEntity, + parts: SaMatchParticipantEntity[], + ): MatchDetailDto { + return { + matchId: header.matchId, + matchType: header.matchType, + matchMode: header.matchMode, + dateMatch: header.dateMatch ? header.dateMatch.toISOString() : '', + matchMap: header.matchMap, + participants: parts.map( + (p): ParticipantDto => ({ + teamId: p.teamId, + matchResult: p.matchResult, + userName: p.userName, + seasonGrade: p.seasonGrade, + clanName: p.clanName, + kill: p.kill, + death: p.death, + headshot: p.headshot, + damage: p.damage, + assist: p.assist, + }), + ), + }; + } +} diff --git a/backend/src/modules/sa-meta/dto/meta-item.dto.ts b/backend/src/modules/sa-meta/dto/meta-item.dto.ts new file mode 100644 index 0000000..391ffbe --- /dev/null +++ b/backend/src/modules/sa-meta/dto/meta-item.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; + +// 메타 항목 DTO (이름 → 이미지) +export class MetaItemDto { + @ApiProperty({ description: '항목 이름', example: 'LEGEND' }) + name!: string; + + @ApiProperty({ + description: '이미지 URL', + example: + 'https://open.api.nexon.com/static/suddenattack/img/2505b378734c223d6961dc336f3aee1f', + }) + image!: string; +} diff --git a/backend/src/modules/sa-meta/entities/sa-meta.entity.ts b/backend/src/modules/sa-meta/entities/sa-meta.entity.ts new file mode 100644 index 0000000..368cc5d --- /dev/null +++ b/backend/src/modules/sa-meta/entities/sa-meta.entity.ts @@ -0,0 +1,36 @@ +import { + Column, + Entity, + Index, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +// SA 메타데이터 (계급/시즌계급/티어 이름 → 이미지 URL 매핑) +// 넥슨 공식 정적 메타(/static/suddenattack/meta/*)를 영속화한다. +@Entity('sa_meta') +@Index(['type', 'sortOrder']) +@Index(['type', 'name']) +export class SaMetaEntity { + @PrimaryGeneratedColumn() + id!: number; + + // 메타 종류: 'grade'(통합계급) | 'season_grade'(시즌계급) | 'tier'(티어) + @Column({ type: 'varchar', length: 20 }) + type!: string; + + // 항목 이름 (예: '대원수', '특등이병', 'LEGEND') + @Column({ type: 'varchar', length: 50 }) + name!: string; + + // 이미지 URL + @Column({ type: 'varchar', length: 500, default: '' }) + image!: string; + + // 등급/티어 순서(낮을수록 하위) + @Column({ type: 'int', default: 0 }) + sortOrder!: number; + + @UpdateDateColumn() + updatedAt!: Date; +} diff --git a/backend/src/modules/sa-meta/sa-meta.controller.ts b/backend/src/modules/sa-meta/sa-meta.controller.ts new file mode 100644 index 0000000..befcbdc --- /dev/null +++ b/backend/src/modules/sa-meta/sa-meta.controller.ts @@ -0,0 +1,25 @@ +import { Controller, Get } from '@nestjs/common'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; + +import { SaMetaService } from './sa-meta.service'; +import { MetaItemDto } from './dto/meta-item.dto'; + +@ApiTags('SA Meta') +@Controller('sa-meta') +export class SaMetaController { + constructor(private readonly saMetaService: SaMetaService) {} + + @Get() + @ApiOperation({ + summary: 'SA 메타데이터 조회 (계급/시즌계급/티어 이미지)', + description: + '계급(grade)/시즌계급(season_grade)/티어(tier) 이름→이미지 매핑을 반환합니다. 넥슨 정적 메타를 주 1회 갱신합니다.', + }) + @ApiResponse({ + status: 200, + description: '종류별 메타 목록 { grade: [...], season_grade: [...], tier: [...] }', + }) + getAll(): Promise> { + return this.saMetaService.getAll(); + } +} diff --git a/backend/src/modules/sa-meta/sa-meta.module.ts b/backend/src/modules/sa-meta/sa-meta.module.ts new file mode 100644 index 0000000..7bcabcf --- /dev/null +++ b/backend/src/modules/sa-meta/sa-meta.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { SaMetaController } from './sa-meta.controller'; +import { SaMetaService } from './sa-meta.service'; +import { SaMetaEntity } from './entities/sa-meta.entity'; + +// SA 메타데이터 모듈 — 넥슨 정적 메타(계급/시즌계급/티어) 주 1회 동기화 + DB 영속 +@Module({ + imports: [TypeOrmModule.forFeature([SaMetaEntity])], + controllers: [SaMetaController], + providers: [SaMetaService], + exports: [SaMetaService], +}) +export class SaMetaModule {} diff --git a/backend/src/modules/sa-meta/sa-meta.service.ts b/backend/src/modules/sa-meta/sa-meta.service.ts new file mode 100644 index 0000000..a30ca80 --- /dev/null +++ b/backend/src/modules/sa-meta/sa-meta.service.ts @@ -0,0 +1,128 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { NexonService } from '../../shared/nexon/nexon.service'; +import { MetaItemDto } from './dto/meta-item.dto'; +import { SaMetaEntity } from './entities/sa-meta.entity'; + +// 메타 종류별 넥슨 정적 엔드포인트 정의 +interface MetaSource { + type: string; + path: string; + nameKey: string; + imageKey: string; +} + +// ──────────────────────────────────────────────────────────── +// SA 메타데이터(계급/시즌계급/티어 이미지) — 넥슨 정적 메타를 주 1회 동기화. +// 거의 불변이므로 부팅 시 비어 있으면 1회 시드 + 매주 갱신한다. +// ──────────────────────────────────────────────────────────── +@Injectable() +export class SaMetaService implements OnModuleInit { + private readonly logger = new Logger(SaMetaService.name); + + private static readonly SOURCES: MetaSource[] = [ + { + type: 'grade', + path: '/static/suddenattack/meta/grade', + nameKey: 'grade', + imageKey: 'grade_image', + }, + { + type: 'season_grade', + path: '/static/suddenattack/meta/season_grade', + nameKey: 'season_grade', + imageKey: 'season_grade_image', + }, + { + type: 'tier', + path: '/static/suddenattack/meta/tier', + nameKey: 'tier', + imageKey: 'tier_image', + }, + ]; + + constructor( + @InjectRepository(SaMetaEntity) + private readonly repo: Repository, + private readonly nexon: NexonService, + ) {} + + onModuleInit(): void { + void this.seedIfEmpty(); + } + + private async seedIfEmpty(): Promise { + try { + const count = await this.repo.count(); + if (count > 0) return; + this.logger.log('메타 데이터가 없어 초기 동기화를 수행합니다.'); + await this.refresh(); + } catch (error) { + this.logger.warn(`초기 메타 시드 실패: ${(error as Error).message}`); + } + } + + // 매주 목요일 10:00 (KST) — 메타 갱신 (정기점검 이후, 신규 계급/티어 추가 대비) + @Cron('0 10 * * 4', { + name: 'weekly-meta-refresh', + timeZone: 'Asia/Seoul', + }) + async handleWeeklyRefresh(): Promise { + this.logger.log('주간 메타 갱신 시작'); + await this.refresh(); + } + + /** 종류별 메타 크롤링 → 성공·비어있지 않은 종류만 교체 */ + async refresh(): Promise { + let total = 0; + for (const src of SaMetaService.SOURCES) { + try { + const list = await this.nexon.get[]>(src.path); + const items = (list ?? []) + .map((it, i) => ({ + type: src.type, + name: it[src.nameKey] ?? '', + image: it[src.imageKey] ?? '', + sortOrder: i, + })) + .filter((it) => it.name); + + if (!items.length) { + this.logger.warn(`[${src.type}] 메타 결과가 비어 건너뜁니다(기존 유지).`); + continue; + } + + await this.repo.manager.transaction(async (m) => { + await m.delete(SaMetaEntity, { type: src.type }); + await m.save(items.map((it) => m.create(SaMetaEntity, it))); + }); + total += items.length; + this.logger.log(`[${src.type}] 메타 ${items.length}건 갱신 완료`); + } catch (error) { + this.logger.warn( + `[${src.type}] 메타 갱신 실패(기존 유지): ${(error as Error).message}`, + ); + } + } + return total; + } + + /** 종류별 메타 조회 { grade: [...], season_grade: [...], tier: [...] } */ + async getAll(): Promise> { + const rows = await this.repo.find({ + order: { type: 'ASC', sortOrder: 'ASC' }, + }); + const out: Record = { + grade: [], + season_grade: [], + tier: [], + }; + for (const r of rows) { + (out[r.type] ??= []).push({ name: r.name, image: r.image }); + } + return out; + } +} diff --git a/backend/src/modules/sa-user/dto/ouid-response.dto.ts b/backend/src/modules/sa-user/dto/ouid-response.dto.ts new file mode 100644 index 0000000..83e723e --- /dev/null +++ b/backend/src/modules/sa-user/dto/ouid-response.dto.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from '@nestjs/swagger'; + +// 유저 OUID 응답 DTO +export class OuidResponseDto { + @ApiProperty({ + description: '넥슨 계정 식별자(OUID)', + example: 'fd9de3b7a6e3d0ab2f4b39c5e594671b', + }) + ouid!: string; + + @ApiProperty({ description: '조회된 닉네임', example: 'master' }) + userName!: string; +} diff --git a/backend/src/modules/sa-user/dto/profile-response.dto.ts b/backend/src/modules/sa-user/dto/profile-response.dto.ts new file mode 100644 index 0000000..f217569 --- /dev/null +++ b/backend/src/modules/sa-user/dto/profile-response.dto.ts @@ -0,0 +1,70 @@ +import { ApiProperty } from '@nestjs/swagger'; + +// 유저 종합 프로필 응답 DTO (basic + rank + tier + recent) +export class ProfileResponseDto { + @ApiProperty({ example: '9b2852e25a85deebc5ce4173b1ccc1e6' }) + ouid!: string; + + @ApiProperty({ example: 'Hack' }) + userName!: string; + + @ApiProperty({ description: '계정 생성일(ISO)', example: '2020-02-02T23:15:59.330Z' }) + dateCreate!: string; + + @ApiProperty({ description: '칭호', example: '짝' }) + titleName!: string; + + @ApiProperty({ description: '클랜명', example: '바디' }) + clanName!: string; + + @ApiProperty({ description: '매너 등급', example: '최고' }) + mannerGrade!: string; + + @ApiProperty({ description: '통합 계급', example: '대원수' }) + grade!: string; + + @ApiProperty({ description: '통합 계급 경험치', example: 2578812318 }) + gradeExp!: number; + + @ApiProperty({ description: '통합 계급 랭킹', example: 1 }) + gradeRanking!: number; + + @ApiProperty({ description: '시즌 계급', example: '총사령관' }) + seasonGrade!: string; + + @ApiProperty({ description: '시즌 계급 경험치', example: 317755457 }) + seasonGradeExp!: number; + + @ApiProperty({ description: '시즌 계급 랭킹', example: 10 }) + seasonGradeRanking!: number; + + @ApiProperty({ description: '솔로 랭크 티어', example: 'LEGEND' }) + soloTier!: string; + + @ApiProperty({ description: '솔로 랭크 점수', example: 4094 }) + soloScore!: number; + + @ApiProperty({ description: '파티 랭크 티어', example: 'GRAND MASTER II' }) + partyTier!: string; + + @ApiProperty({ description: '파티 랭크 점수', example: 3203 }) + partyScore!: number; + + @ApiProperty({ description: '최근 승률(%)', example: 46.67 }) + recentWinRate!: number; + + @ApiProperty({ description: '최근 킬데스(%)', example: 55.83 }) + recentKdRate!: number; + + @ApiProperty({ description: '최근 돌격(%)', example: 58.67 }) + recentAssaultRate!: number; + + @ApiProperty({ description: '최근 저격(%)', example: 47.22 }) + recentSniperRate!: number; + + @ApiProperty({ description: '최근 특수전(%)', example: 56.0 }) + recentSpecialRate!: number; + + @ApiProperty({ description: '마지막 동기화 시각' }) + lastSyncedAt!: Date; +} diff --git a/backend/src/modules/sa-user/dto/search-user.dto.ts b/backend/src/modules/sa-user/dto/search-user.dto.ts new file mode 100644 index 0000000..a61712e --- /dev/null +++ b/backend/src/modules/sa-user/dto/search-user.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; + +// 유저 검색 요청 DTO (쿼리 파라미터) +export class SearchUserDto { + @ApiProperty({ description: '검색할 유저 닉네임', example: 'master' }) + @IsString() + @IsNotEmpty({ message: '닉네임을 입력해 주세요.' }) + @MaxLength(50, { message: '닉네임이 너무 깁니다.' }) + user_name!: string; +} diff --git a/backend/src/modules/sa-user/entities/sa-user-profile.entity.ts b/backend/src/modules/sa-user/entities/sa-user-profile.entity.ts new file mode 100644 index 0000000..cfb8e31 --- /dev/null +++ b/backend/src/modules/sa-user/entities/sa-user-profile.entity.ts @@ -0,0 +1,85 @@ +import { Column, Entity, PrimaryColumn, UpdateDateColumn } from 'typeorm'; + +// bigint(계급 경험치) ↔ number 변환기 (Postgres bigint 는 기본 string 반환) +// 스냅샷 엔티티에서도 재사용한다. +export const bigintToNumber = { + to: (value: number): number => value, + from: (value: string | null): number => (value == null ? 0 : Number(value)), +}; + +// 서든어택 유저 종합 프로필 (basic + rank + tier + recent 통합 최신 스냅샷) +// ouid 기준 1행. 매일 크론으로 갱신(upsert)된다. 시계열 이력은 sa_user_snapshots 참조. +@Entity('sa_user_profiles') +export class SaUserProfileEntity { + @PrimaryColumn({ type: 'varchar', length: 64 }) + ouid!: string; + + // ── basic ── + @Column({ type: 'varchar', length: 50, default: '' }) + userName!: string; + + // 계정 생성일 (ISO 문자열 원본 보존) + @Column({ type: 'varchar', length: 40, default: '' }) + dateCreate!: string; + + @Column({ type: 'varchar', length: 50, default: '' }) + titleName!: string; + + @Column({ type: 'varchar', length: 50, default: '' }) + clanName!: string; + + @Column({ type: 'varchar', length: 20, default: '' }) + mannerGrade!: string; + + // ── rank (계급) ── + @Column({ type: 'varchar', length: 30, default: '' }) + grade!: string; + + @Column({ type: 'bigint', default: 0, transformer: bigintToNumber }) + gradeExp!: number; + + @Column({ type: 'int', default: 0 }) + gradeRanking!: number; + + @Column({ type: 'varchar', length: 30, default: '' }) + seasonGrade!: string; + + @Column({ type: 'bigint', default: 0, transformer: bigintToNumber }) + seasonGradeExp!: number; + + @Column({ type: 'int', default: 0 }) + seasonGradeRanking!: number; + + // ── tier (랭크전) ── + @Column({ type: 'varchar', length: 30, default: '' }) + soloTier!: string; + + @Column({ type: 'int', default: 0 }) + soloScore!: number; + + @Column({ type: 'varchar', length: 30, default: '' }) + partyTier!: string; + + @Column({ type: 'int', default: 0 }) + partyScore!: number; + + // ── recent (최근 동향, %) ── + @Column({ type: 'float', default: 0 }) + recentWinRate!: number; + + @Column({ type: 'float', default: 0 }) + recentKdRate!: number; + + @Column({ type: 'float', default: 0 }) + recentAssaultRate!: number; + + @Column({ type: 'float', default: 0 }) + recentSniperRate!: number; + + @Column({ type: 'float', default: 0 }) + recentSpecialRate!: number; + + // 마지막 동기화 시각 + @UpdateDateColumn() + lastSyncedAt!: Date; +} diff --git a/backend/src/modules/sa-user/entities/sa-user-snapshot.entity.ts b/backend/src/modules/sa-user/entities/sa-user-snapshot.entity.ts new file mode 100644 index 0000000..069bb19 --- /dev/null +++ b/backend/src/modules/sa-user/entities/sa-user-snapshot.entity.ts @@ -0,0 +1,90 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, +} from 'typeorm'; + +import { bigintToNumber } from './sa-user-profile.entity'; + +// 유저 일별 스냅샷 (시계열 이력) — 타임라인으로 추적·컨텐츠화 가능한 모든 가변 데이터를 기록. +// 유저가 직접 조회하지 않아도 크론이 매일 기록하므로 닉네임/클랜 변경, 티어/계급 변동, +// 시즌 종료 시점의 마지막 값 등이 모두 보존된다. (불변값 dateCreate/ouid 는 프로필에만 보관) +@Entity('sa_user_snapshots') +@Index(['ouid', 'snapshotDate'], { unique: true }) +export class SaUserSnapshotEntity { + @PrimaryGeneratedColumn() + id!: number; + + @Column({ type: 'varchar', length: 64 }) + ouid!: string; + + // 스냅샷 일자 (YYYY-MM-DD) — ouid 당 하루 1행 + @Column({ type: 'date' }) + snapshotDate!: string; + + // ── basic (가변) — 닉네임/클랜 변경 타임라인 ── + @Column({ type: 'varchar', length: 50, default: '' }) + userName!: string; + + @Column({ type: 'varchar', length: 50, default: '' }) + titleName!: string; + + @Column({ type: 'varchar', length: 50, default: '' }) + clanName!: string; + + @Column({ type: 'varchar', length: 20, default: '' }) + mannerGrade!: string; + + // ── rank (계급) ── + @Column({ type: 'varchar', length: 30, default: '' }) + grade!: string; + + @Column({ type: 'bigint', default: 0, transformer: bigintToNumber }) + gradeExp!: number; + + @Column({ type: 'int', default: 0 }) + gradeRanking!: number; + + @Column({ type: 'varchar', length: 30, default: '' }) + seasonGrade!: string; + + @Column({ type: 'bigint', default: 0, transformer: bigintToNumber }) + seasonGradeExp!: number; + + @Column({ type: 'int', default: 0 }) + seasonGradeRanking!: number; + + // ── tier (랭크전) ── + @Column({ type: 'varchar', length: 30, default: '' }) + soloTier!: string; + + @Column({ type: 'int', default: 0 }) + soloScore!: number; + + @Column({ type: 'varchar', length: 30, default: '' }) + partyTier!: string; + + @Column({ type: 'int', default: 0 }) + partyScore!: number; + + // ── recent (최근 동향, %) ── + @Column({ type: 'float', default: 0 }) + recentWinRate!: number; + + @Column({ type: 'float', default: 0 }) + recentKdRate!: number; + + @Column({ type: 'float', default: 0 }) + recentAssaultRate!: number; + + @Column({ type: 'float', default: 0 }) + recentSniperRate!: number; + + @Column({ type: 'float', default: 0 }) + recentSpecialRate!: number; + + @CreateDateColumn() + createdAt!: Date; +} diff --git a/backend/src/modules/sa-user/entities/sa-user.entity.ts b/backend/src/modules/sa-user/entities/sa-user.entity.ts new file mode 100644 index 0000000..d7b6c69 --- /dev/null +++ b/backend/src/modules/sa-user/entities/sa-user.entity.ts @@ -0,0 +1,33 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +// 추적 대상 서든어택 유저 (ouid 기준 1행 = 1계정). +// 닉네임은 가변이므로 ouid 를 유일 키로 두고, userName 은 "현재 닉네임"으로 크론이 갱신한다. +// 닉네임 변경/양도가 일어나도 행이 중복되지 않으며, 변경 타임라인은 sa_user_snapshots 로 추적한다. +@Entity('sa_users') +export class SaUserEntity { + @PrimaryGeneratedColumn() + id!: number; + + // 넥슨 계정 식별자 (불변, 유일) + @Index({ unique: true }) + @Column({ type: 'varchar', length: 64 }) + ouid!: string; + + // 현재 닉네임 (가변 — 검색 캐시 및 표시용. 크론이 최신값으로 갱신) + @Index() + @Column({ type: 'varchar', length: 50 }) + userName!: string; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; +} diff --git a/backend/src/modules/sa-user/sa-user.controller.ts b/backend/src/modules/sa-user/sa-user.controller.ts new file mode 100644 index 0000000..99aafe4 --- /dev/null +++ b/backend/src/modules/sa-user/sa-user.controller.ts @@ -0,0 +1,36 @@ +import { Controller, Get, Param, Query } from '@nestjs/common'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; + +import { SaUserService } from './sa-user.service'; +import { SearchUserDto } from './dto/search-user.dto'; +import { OuidResponseDto } from './dto/ouid-response.dto'; +import { ProfileResponseDto } from './dto/profile-response.dto'; + +@ApiTags('SA User') +@Controller('sa-users') +export class SaUserController { + constructor(private readonly saUserService: SaUserService) {} + + @Get('search') + @ApiOperation({ + summary: '닉네임으로 유저 OUID 검색', + description: + '닉네임으로 넥슨 오픈 API에서 OUID를 조회합니다. 결과는 DB에 저장되며, 신규 유저는 프로필도 즉시 동기화됩니다. 이후 매일 크론으로 자동 갱신됩니다.', + }) + @ApiResponse({ status: 200, description: '조회된 OUID', type: OuidResponseDto }) + @ApiResponse({ status: 404, description: '해당 닉네임의 유저를 찾을 수 없음' }) + search(@Query() dto: SearchUserDto): Promise { + return this.saUserService.searchOuid(dto.user_name); + } + + @Get(':ouid/profile') + @ApiOperation({ + summary: '유저 종합 프로필 조회', + description: + 'OUID로 종합 프로필(기본/계급/티어/최근동향)을 반환합니다. DB에 없으면 1회 동기화 후 반환합니다.', + }) + @ApiResponse({ status: 200, description: '유저 프로필', type: ProfileResponseDto }) + getProfile(@Param('ouid') ouid: string): Promise { + return this.saUserService.getProfile(ouid); + } +} diff --git a/backend/src/modules/sa-user/sa-user.module.ts b/backend/src/modules/sa-user/sa-user.module.ts new file mode 100644 index 0000000..a87316f --- /dev/null +++ b/backend/src/modules/sa-user/sa-user.module.ts @@ -0,0 +1,24 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { SaUserController } from './sa-user.controller'; +import { SaUserService } from './sa-user.service'; +import { SaUserEntity } from './entities/sa-user.entity'; +import { SaUserProfileEntity } from './entities/sa-user-profile.entity'; +import { SaUserSnapshotEntity } from './entities/sa-user-snapshot.entity'; + +// 서든어택 유저 모듈 — OUID 검색 + 종합 프로필 동기화(매일 크론) + 일별 이력 +// 넥슨 호출은 전역 NexonModule(NexonService)을 주입해 사용한다. +@Module({ + imports: [ + TypeOrmModule.forFeature([ + SaUserEntity, + SaUserProfileEntity, + SaUserSnapshotEntity, + ]), + ], + controllers: [SaUserController], + providers: [SaUserService], + exports: [SaUserService], +}) +export class SaUserModule {} diff --git a/backend/src/modules/sa-user/sa-user.service.ts b/backend/src/modules/sa-user/sa-user.service.ts new file mode 100644 index 0000000..a4d8d65 --- /dev/null +++ b/backend/src/modules/sa-user/sa-user.service.ts @@ -0,0 +1,249 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { NexonService } from '../../shared/nexon/nexon.service'; +import { OuidResponseDto } from './dto/ouid-response.dto'; +import { ProfileResponseDto } from './dto/profile-response.dto'; +import { SaUserEntity } from './entities/sa-user.entity'; +import { SaUserProfileEntity } from './entities/sa-user-profile.entity'; +import { SaUserSnapshotEntity } from './entities/sa-user-snapshot.entity'; + +// 넥슨 API 응답 타입 +interface IdRes { + ouid: string; +} +interface BasicRes { + user_name: string; + user_date_create: string; + title_name: string; + clan_name: string; + manner_grade: string; +} +interface RankRes { + user_name: string; + grade: string; + grade_exp: number; + grade_ranking: number; + season_grade: string; + season_grade_exp: number; + season_grade_ranking: number; +} +interface TierRes { + user_name: string; + solo_rank_match_tier: string; + solo_rank_match_score: number; + party_rank_match_tier: string; + party_rank_match_score: number; +} +interface RecentRes { + user_name: string; + recent_win_rate: number; + recent_kill_death_rate: number; + recent_assault_rate: number; + recent_sniper_rate: number; + recent_special_rate: number; +} + +const sleep = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); + +// ──────────────────────────────────────────────────────────── +// 서든어택 유저 — 닉네임→OUID 검색 + 종합 프로필(basic/rank/tier/recent) 동기화. +// 등록된 유저는 매일 크론으로 자동 갱신되며, 일별 스냅샷으로 시계열 이력을 보존한다. +// (유저가 직접 조회하지 않아도 데이터가 누적되어 시즌 종료 시점 등 연속성이 유지됨) +// ──────────────────────────────────────────────────────────── +@Injectable() +export class SaUserService { + private readonly logger = new Logger(SaUserService.name); + + constructor( + @InjectRepository(SaUserEntity) + private readonly userRepo: Repository, + @InjectRepository(SaUserProfileEntity) + private readonly profileRepo: Repository, + @InjectRepository(SaUserSnapshotEntity) + private readonly snapshotRepo: Repository, + private readonly nexon: NexonService, + ) {} + + /** + * 닉네임으로 OUID 조회. ouid 기준으로 정규화하므로 닉네임 변경/양도가 일어나도 + * 행이 중복되지 않는다. 신규 유저는 프로필도 즉시 동기화한다. + */ + async searchOuid(rawName: string): Promise { + const userName = rawName.trim(); + + // 1. 현재 닉네임으로 캐시 확인 + const byName = await this.userRepo.findOne({ where: { userName } }); + if (byName) { + return { ouid: byName.ouid, userName: byName.userName }; + } + + // 2. 넥슨 API로 ouid 조회 + const { ouid } = await this.nexon.get('/suddenattack/v1/id', { + user_name: userName, + }); + + // 3. ouid 기준 정규화 — 닉네임을 바꾼 기존 유저면 행을 재사용(현재 닉네임만 갱신) + const byOuid = await this.userRepo.findOne({ where: { ouid } }); + if (byOuid) { + if (byOuid.userName !== userName) { + byOuid.userName = userName; + await this.userRepo.save(byOuid); + } + return { ouid, userName }; + } + + // 4. 신규 유저 등록 + 프로필 즉시 1회 동기화 (이후는 크론이 자동 갱신) + try { + await this.userRepo.save(this.userRepo.create({ ouid, userName })); + } catch { + // 동시 요청으로 인한 ouid unique 충돌은 무시 (이미 등록됨) + } + try { + await this.syncProfile(ouid); + } catch (error) { + this.logger.warn( + `신규 유저 프로필 동기화 실패(ouid=${ouid}): ${(error as Error).message}`, + ); + } + + return { ouid, userName }; + } + + /** 종합 프로필 조회 (DB 우선, 없으면 1회 동기화). */ + async getProfile(ouid: string): Promise { + const profile = + (await this.profileRepo.findOne({ where: { ouid } })) ?? + (await this.syncProfile(ouid)); + return this.toDto(profile); + } + + /** basic/rank/tier/recent 4종을 병렬 조회 → 최신 프로필 upsert + 일별 스냅샷 기록 */ + async syncProfile(ouid: string): Promise { + const [basic, rank, tier, recent] = await Promise.all([ + this.nexon.get('/suddenattack/v1/user/basic', { ouid }), + this.nexon.get('/suddenattack/v1/user/rank', { ouid }), + this.nexon.get('/suddenattack/v1/user/tier', { ouid }), + this.nexon.get('/suddenattack/v1/user/recent-info', { ouid }), + ]); + + const profile: SaUserProfileEntity = { + ouid, + userName: basic.user_name, + dateCreate: basic.user_date_create, + titleName: basic.title_name ?? '', + clanName: basic.clan_name ?? '', + mannerGrade: basic.manner_grade ?? '', + grade: rank.grade ?? '', + gradeExp: rank.grade_exp ?? 0, + gradeRanking: rank.grade_ranking ?? 0, + seasonGrade: rank.season_grade ?? '', + seasonGradeExp: rank.season_grade_exp ?? 0, + seasonGradeRanking: rank.season_grade_ranking ?? 0, + soloTier: tier.solo_rank_match_tier ?? '', + soloScore: tier.solo_rank_match_score ?? 0, + partyTier: tier.party_rank_match_tier ?? '', + partyScore: tier.party_rank_match_score ?? 0, + recentWinRate: recent.recent_win_rate ?? 0, + recentKdRate: recent.recent_kill_death_rate ?? 0, + recentAssaultRate: recent.recent_assault_rate ?? 0, + recentSniperRate: recent.recent_sniper_rate ?? 0, + recentSpecialRate: recent.recent_special_rate ?? 0, + lastSyncedAt: new Date(), + }; + + // 최신 프로필 upsert (ouid PK) + await this.profileRepo.upsert(profile, ['ouid']); + + // 일별 스냅샷 upsert (ouid + 오늘 날짜) — 타임라인 추적용 전체 가변 필드 기록 + const snapshotDate = this.todayKst(); + await this.snapshotRepo.upsert( + { + ouid, + snapshotDate, + userName: profile.userName, + titleName: profile.titleName, + clanName: profile.clanName, + mannerGrade: profile.mannerGrade, + grade: profile.grade, + gradeExp: profile.gradeExp, + gradeRanking: profile.gradeRanking, + seasonGrade: profile.seasonGrade, + seasonGradeExp: profile.seasonGradeExp, + seasonGradeRanking: profile.seasonGradeRanking, + soloTier: profile.soloTier, + soloScore: profile.soloScore, + partyTier: profile.partyTier, + partyScore: profile.partyScore, + recentWinRate: profile.recentWinRate, + recentKdRate: profile.recentKdRate, + recentAssaultRate: profile.recentAssaultRate, + recentSniperRate: profile.recentSniperRate, + recentSpecialRate: profile.recentSpecialRate, + }, + ['ouid', 'snapshotDate'], + ); + + return profile; + } + + // 매일 03:00 (KST) — 등록된 모든 유저의 프로필을 자동 갱신 (조회 의존 없이 이력 누적) + @Cron('0 3 * * *', { + name: 'daily-user-profile-sync', + timeZone: 'Asia/Seoul', + }) + async handleDailySync(): Promise { + const users = await this.userRepo.find(); + this.logger.log(`일일 유저 프로필 동기화 시작 (대상 ${users.length}명)`); + let ok = 0; + for (const user of users) { + try { + await this.syncProfile(user.ouid); + ok++; + } catch (error) { + this.logger.warn( + `[${user.userName}] 프로필 동기화 실패: ${(error as Error).message}`, + ); + } + // 넥슨 API rate limit 완화를 위한 호출 간 간격 + await sleep(120); + } + this.logger.log(`일일 유저 프로필 동기화 완료 (${ok}/${users.length})`); + } + + /** KST 기준 오늘 날짜 (YYYY-MM-DD) */ + private todayKst(): string { + const kst = new Date(Date.now() + 9 * 60 * 60 * 1000); + return kst.toISOString().slice(0, 10); + } + + private toDto(p: SaUserProfileEntity): ProfileResponseDto { + return { + ouid: p.ouid, + userName: p.userName, + dateCreate: p.dateCreate, + titleName: p.titleName, + clanName: p.clanName, + mannerGrade: p.mannerGrade, + grade: p.grade, + gradeExp: p.gradeExp, + gradeRanking: p.gradeRanking, + seasonGrade: p.seasonGrade, + seasonGradeExp: p.seasonGradeExp, + seasonGradeRanking: p.seasonGradeRanking, + soloTier: p.soloTier, + soloScore: p.soloScore, + partyTier: p.partyTier, + partyScore: p.partyScore, + recentWinRate: p.recentWinRate, + recentKdRate: p.recentKdRate, + recentAssaultRate: p.recentAssaultRate, + recentSniperRate: p.recentSniperRate, + recentSpecialRate: p.recentSpecialRate, + lastSyncedAt: p.lastSyncedAt, + }; + } +} diff --git a/backend/src/shared/nexon/nexon.module.ts b/backend/src/shared/nexon/nexon.module.ts new file mode 100644 index 0000000..3512d45 --- /dev/null +++ b/backend/src/shared/nexon/nexon.module.ts @@ -0,0 +1,13 @@ +import { Global, Module } from '@nestjs/common'; +import { HttpModule } from '@nestjs/axios'; + +import { NexonService } from './nexon.service'; + +// 넥슨 오픈 API 공통 클라이언트 모듈 (전역) — 모든 SA 데이터 모듈에서 주입 사용 +@Global() +@Module({ + imports: [HttpModule], + providers: [NexonService], + exports: [NexonService], +}) +export class NexonModule {} diff --git a/backend/src/shared/nexon/nexon.service.ts b/backend/src/shared/nexon/nexon.service.ts new file mode 100644 index 0000000..5077d69 --- /dev/null +++ b/backend/src/shared/nexon/nexon.service.ts @@ -0,0 +1,115 @@ +import { + HttpException, + Injectable, + Logger, + NotFoundException, + ServiceUnavailableException, +} from '@nestjs/common'; +import { HttpService } from '@nestjs/axios'; +import { ConfigService } from '@nestjs/config'; +import { firstValueFrom } from 'rxjs'; +import axios from 'axios'; + +// ──────────────────────────────────────────────────────────── +// 넥슨 오픈 API 공통 클라이언트 +// - 모든 SA 데이터 모듈(sa-user / sa-meta / sa-match)이 재사용 +// - x-nxopen-api-key 인증 헤더 자동 부착, 표준 에러 변환 +// ──────────────────────────────────────────────────────────── +@Injectable() +export class NexonService { + private readonly logger = new Logger(NexonService.name); + + // rate limiter (1초 슬라이딩 윈도우) — env NEXON_API_RATE_LIMIT_PER_SEC (개발 5/s, 운영 500/s) + // 모든 넥슨 호출이 get() 을 거치므로 전역으로 초당 호출 수가 제한된다. + private readonly maxPerSecond: number; + // 최근 1초 이내 호출 시각들 + private readonly callTimestamps: number[] = []; + + constructor( + private readonly http: HttpService, + private readonly config: ConfigService, + ) { + const rps = Number(this.config.get('NEXON_API_RATE_LIMIT_PER_SEC')); + this.maxPerSecond = Number.isFinite(rps) && rps > 0 ? rps : 5; + } + + private get baseUrl(): string { + const url = this.config.get('NEXON_API_URL'); + if (!url) { + this.logger.error('NEXON_API_URL 설정이 누락되었습니다.'); + throw new ServiceUnavailableException( + '요청하신 작업을 완료하지 못했습니다.', + ); + } + return url.replace(/\/$/, ''); + } + + private get apiKey(): string { + const key = this.config.get('NEXON_API_KEY'); + if (!key) { + this.logger.error('NEXON_API_KEY 설정이 누락되었습니다.'); + throw new ServiceUnavailableException( + '요청하신 작업을 완료하지 못했습니다.', + ); + } + return key; + } + + /** + * 호출 허가를 받을 때까지 대기 (1초 슬라이딩 윈도우, 초당 maxPerSecond 제한). + * 타임스탬프 정리·검사·추가 구간에는 await 가 없어 동시 호출이 몰려도 한도를 넘지 않는다. + */ + private async acquire(): Promise { + for (;;) { + const now = Date.now(); + // 1초가 지난 호출 기록 제거 + while ( + this.callTimestamps.length > 0 && + this.callTimestamps[0] <= now - 1000 + ) { + this.callTimestamps.shift(); + } + if (this.callTimestamps.length < this.maxPerSecond) { + this.callTimestamps.push(now); + return; + } + // 가장 오래된 호출이 1초 경과할 때까지 대기 + const waitMs = this.callTimestamps[0] + 1000 - now; + await new Promise((resolve) => setTimeout(resolve, Math.max(5, waitMs))); + } + } + + /** + * 넥슨 오픈 API GET 호출. (호출 전 rate limiter 통과) + * @param path '/suddenattack/v1/...' 또는 '/static/suddenattack/meta/...' + * @param params 쿼리 파라미터 + * 닉네임/식별자 미존재(400·404)는 NotFoundException, 그 외 장애는 ServiceUnavailableException 으로 변환한다. + */ + async get(path: string, params?: Record): Promise { + await this.acquire(); + try { + const { data } = await firstValueFrom( + this.http.get(`${this.baseUrl}${path}`, { + params, + headers: { 'x-nxopen-api-key': this.apiKey }, + timeout: 10_000, + }), + ); + return data; + } catch (error) { + if (error instanceof HttpException) throw error; + if (axios.isAxiosError(error)) { + const status = error.response?.status; + this.logger.warn( + `넥슨 API 오류(path=${path}, status=${status}): ${JSON.stringify(error.response?.data)}`, + ); + if (status === 400 || status === 404) { + throw new NotFoundException('요청하신 정보를 찾을 수 없습니다.'); + } + } + throw new ServiceUnavailableException( + '요청하신 작업을 완료하지 못했습니다.', + ); + } + } +} diff --git a/backend/src/shared/utils/curl-fetch.ts b/backend/src/shared/utils/curl-fetch.ts index a9d613d..c18e5e7 100644 --- a/backend/src/shared/utils/curl-fetch.ts +++ b/backend/src/shared/utils/curl-fetch.ts @@ -14,6 +14,8 @@ const DEFAULT_UA = export interface CurlFetchOptions { /** Referer 헤더 (안티봇 우회에 필요한 경우) */ referer?: string; + /** 추가 요청 헤더 (예: AJAX 엔드포인트용 'X-Requested-With') */ + headers?: Record; /** 타임아웃(초) — 기본 12초 */ timeoutSec?: number; /** 최대 응답 버퍼(MB) — 기본 10MB */ @@ -25,7 +27,7 @@ export function curlFetchHtml( url: string, opts: CurlFetchOptions = {}, ): Promise { - const { referer, timeoutSec = 12, maxBufferMb = 10 } = opts; + const { referer, headers, timeoutSec = 12, maxBufferMb = 10 } = opts; // 넥슨 WAF 우회 핵심: 반드시 HTTP/2(--http2)로 요청하고, 브라우저(Chrome) 헤더를 // 함께 보낸다. 단 `--compressed`(Accept-Encoding: gzip,deflate,br,zstd)는 차단을 @@ -61,6 +63,11 @@ export function curlFetchHtml( if (referer) { args.push('-H', `Referer: ${referer}`); } + if (headers) { + for (const [key, value] of Object.entries(headers)) { + args.push('-H', `${key}: ${value}`); + } + } args.push(url); return new Promise((resolve, reject) => { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b1e6106..f066a2f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -119,7 +119,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -645,7 +644,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -694,7 +692,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } @@ -706,7 +703,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" @@ -719,7 +715,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -1280,6 +1275,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1297,6 +1295,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1314,6 +1315,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1331,6 +1335,9 @@ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1348,6 +1355,9 @@ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1365,6 +1375,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1382,6 +1395,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1399,6 +1415,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1587,6 +1606,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1604,6 +1626,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1621,6 +1646,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1638,6 +1666,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1655,6 +1686,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1672,6 +1706,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1897,7 +1934,6 @@ "integrity": "sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.61.0", "@typescript-eslint/types": "8.61.0", @@ -2370,7 +2406,6 @@ "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.38.tgz", "integrity": "sha512-JTqp25l8aFfJYF7/KmsXZjAxJz7T+SjmTJLoXVjHtc2BrSgSiW2n9Aem/cWq1OPe68A8JL06B3eVdhlP0H4TVw==", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-core": "3.5.38", "@vue/shared": "3.5.38" @@ -2568,7 +2603,6 @@ "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.38.tgz", "integrity": "sha512-vue8vbf2QlV4quHqzwmJy6dWfmRhP1J8l4wtZg60CL6VoKqcPY2oe7may3+1d9qfpedjK5PRLFqd5k3Isj9mUw==", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-ssr": "3.5.38", "@vue/shared": "3.5.38" @@ -2639,7 +2673,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2862,7 +2895,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -2917,9 +2949,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001797", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz", - "integrity": "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==", + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", "dev": true, "funding": [ { @@ -3404,7 +3436,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3465,7 +3496,6 @@ "integrity": "sha512-174lJKuNsuDIlLpjeXc5E2Tss8P44uIimAfGD0b90k0NoirJqpG7stLuU9Vp/9ioTOrQdWVREc4mRd1BD+CvGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "globals": "^13.24.0", @@ -4706,6 +4736,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4727,6 +4760,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4748,6 +4784,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4769,6 +4808,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6013,7 +6055,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6163,7 +6204,6 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6297,7 +6337,6 @@ "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -6699,7 +6738,6 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.38.tgz", "integrity": "sha512-vAMKHfImQlYSy0C+PBue4s3ERZ2xGKfgZg5GXAsLInq1dyh2H78ILVP5sK0KPFPVW4kv+OGCIvBEondcjpZp7A==", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.38", "@vue/compiler-sfc": "3.5.38", diff --git a/frontend/src/composables/useBreakpoint.ts b/frontend/src/composables/useBreakpoint.ts new file mode 100644 index 0000000..4d09184 --- /dev/null +++ b/frontend/src/composables/useBreakpoint.ts @@ -0,0 +1,20 @@ +import { computed, onMounted, onUnmounted, ref } from 'vue' + +// 반응형 뷰포트 훅 — 원본 sa-shared.jsx useBP() 포팅. +// CSS 미디어쿼리로 처리하기 어려운 JS 레벨 분기(엘리먼트 크기/prop 등)에 사용한다. +// breakpoint 체계: mobile < 760px, narrow < 600px (CSS @media 와 동일 기준) +export function useBreakpoint() { + const w = ref(typeof window !== 'undefined' ? window.innerWidth : 1200) + + function onResize() { + w.value = window.innerWidth + } + + onMounted(() => window.addEventListener('resize', onResize)) + onUnmounted(() => window.removeEventListener('resize', onResize)) + + const mobile = computed(() => w.value < 760) + const narrow = computed(() => w.value < 600) + + return { w, mobile, narrow } +} diff --git a/frontend/src/composables/useNews.ts b/frontend/src/composables/useNews.ts new file mode 100644 index 0000000..43a14c2 --- /dev/null +++ b/frontend/src/composables/useNews.ts @@ -0,0 +1,34 @@ +import { ref } from 'vue' +import { useApi } from './useApi' + +// SA 소식 항목 (백엔드 /api/news/sections 크롤링 결과) +export interface NewsItem { + date: string + title: string + link: string +} + +// 카테고리(공지사항/GM이야기/업데이트)별 소식 맵 +export type NewsSections = Record + +export function useNews() { + const api = useApi() + const loading = ref(false) + const error = ref(null) + + async function getSections(): Promise { + loading.value = true + error.value = null + try { + // 인터셉터에서 success/data 언래핑 후 객체만 반환 + return (await api.get('/news/sections')) as unknown as NewsSections + } catch (e) { + error.value = (e as Error)?.message ?? 'unknown error' + return {} + } finally { + loading.value = false + } + } + + return { loading, error, getSections } +} diff --git a/frontend/src/composables/useRanking.ts b/frontend/src/composables/useRanking.ts new file mode 100644 index 0000000..5ebb24c --- /dev/null +++ b/frontend/src/composables/useRanking.ts @@ -0,0 +1,44 @@ +import { ref } from 'vue' +import { useApi } from './useApi' + +// SA 랭킹 항목 (백엔드 /api/ranking/sections 크롤링 결과) +// 카테고리별로 사용하는 필드만 채워진다. +export interface RankingItem { + rankNo: number + name: string + gradeImg: string + tierImg: string + clanMark: string + winRate: string + kd: string + record: string + rp: string + playTime: string + score: string + master: string +} + +// 카테고리 키: total_class | season_class | ranked_solo | ranked_party | ranked_clan | official_clan | clan +export type RankingSections = Record + +export function useRanking() { + const api = useApi() + const loading = ref(false) + const error = ref(null) + + async function getSections(): Promise { + loading.value = true + error.value = null + try { + // 인터셉터에서 success/data 언래핑 후 객체만 반환 + return (await api.get('/ranking/sections')) as unknown as RankingSections + } catch (e) { + error.value = (e as Error)?.message ?? 'unknown error' + return {} + } finally { + loading.value = false + } + } + + return { loading, error, getSections } +} diff --git a/frontend/src/composables/useSaMatch.ts b/frontend/src/composables/useSaMatch.ts new file mode 100644 index 0000000..7c9447c --- /dev/null +++ b/frontend/src/composables/useSaMatch.ts @@ -0,0 +1,76 @@ +import { useApi } from './useApi' + +// 매치 목록 항목 (백엔드 MatchItemDto) +export interface SaMatchItem { + matchId: string + matchType: string + matchMode: string + dateMatch: string + matchResult: string + kill: number + death: number + assist: number +} + +// 매치 상세 참가자 +export interface SaParticipant { + teamId: string + matchResult: string + userName: string + seasonGrade: string + clanName: string + kill: number + death: number + headshot: number + damage: number + assist: number +} + +// 매치 상세 (맵 + 참가자) +export interface SaMatchDetail { + matchId: string + matchType: string + matchMode: string + dateMatch: string + matchMap: string + participants: SaParticipant[] +} + +// 매치 타입 (넥슨 match_type 과 동일 문자열) +export const MATCH_TYPES = [ + '랭크전 솔로', + '랭크전 파티', + '퀵매치 클랜전', + '클랜 랭크전', + '토너먼트', +] as const + +export function useSaMatch() { + const api = useApi() + + // 매치 목록 (타입별). 실패 시 빈 배열 + async function getMatches( + ouid: string, + matchType: string, + matchMode = '폭파미션', + ): Promise { + try { + return (await api.get('/sa-matches', { + params: { ouid, match_type: matchType, match_mode: matchMode }, + })) as unknown as SaMatchItem[] + } catch { + return [] + } + } + + // 매치 상세 (더보기 시점에만 호출). 실패 시 null + async function getMatchDetail(matchId: string): Promise { + try { + return (await api.get(`/sa-matches/${matchId}`)) as unknown as SaMatchDetail + } catch { + return null + } + } + + return { getMatches, getMatchDetail } +} diff --git a/frontend/src/composables/useSaMeta.ts b/frontend/src/composables/useSaMeta.ts new file mode 100644 index 0000000..9d308f7 --- /dev/null +++ b/frontend/src/composables/useSaMeta.ts @@ -0,0 +1,30 @@ +import { useApi } from './useApi' + +// 메타 항목 (이름 → 이미지) +export interface SaMetaItem { + name: string + image: string +} + +// 종류별 메타 { grade, season_grade, tier } +export type SaMetaSections = Record + +export function useSaMeta() { + const api = useApi() + + async function getMeta(): Promise { + try { + return (await api.get('/sa-meta')) as unknown as SaMetaSections + } catch { + return {} + } + } + + // 종류 + 이름으로 이미지 URL 조회 (없으면 빈 문자열) + function imageOf(meta: SaMetaSections, type: string, name: string): string { + const list = meta[type] ?? [] + return list.find((m) => m.name === name)?.image ?? '' + } + + return { getMeta, imageOf } +} diff --git a/frontend/src/composables/useSaUser.ts b/frontend/src/composables/useSaUser.ts new file mode 100644 index 0000000..115f4ea --- /dev/null +++ b/frontend/src/composables/useSaUser.ts @@ -0,0 +1,68 @@ +import { ref } from 'vue' +import { useApi } from './useApi' + +// 유저 OUID 검색 결과 +export interface SaOuid { + ouid: string + userName: string +} + +// 유저 종합 프로필 (백엔드 ProfileResponseDto) +export interface SaProfile { + ouid: string + userName: string + dateCreate: string + titleName: string + clanName: string + mannerGrade: string + grade: string + gradeExp: number + gradeRanking: number + seasonGrade: string + seasonGradeExp: number + seasonGradeRanking: number + soloTier: string + soloScore: number + partyTier: string + partyScore: number + recentWinRate: number + recentKdRate: number + recentAssaultRate: number + recentSniperRate: number + recentSpecialRate: number + lastSyncedAt: string +} + +export function useSaUser() { + const api = useApi() + const loading = ref(false) + const error = ref(null) + + // 닉네임 → OUID 검색 (실패 시 null, 인터셉터가 에러 알림 처리) + async function searchOuid(userName: string): Promise { + loading.value = true + error.value = null + try { + return (await api.get('/sa-users/search', { + params: { user_name: userName }, + })) as unknown as SaOuid + } catch (e) { + error.value = (e as Error)?.message ?? 'unknown error' + return null + } finally { + loading.value = false + } + } + + // OUID → 종합 프로필 + async function getProfile(ouid: string): Promise { + try { + return (await api.get(`/sa-users/${ouid}/profile`)) as unknown as SaProfile + } catch (e) { + error.value = (e as Error)?.message ?? 'unknown error' + return null + } + } + + return { loading, error, searchOuid, getProfile } +} diff --git a/frontend/src/pages/ClanHomePage.vue b/frontend/src/pages/ClanHomePage.vue index 0e0c424..9a63f5b 100644 --- a/frontend/src/pages/ClanHomePage.vue +++ b/frontend/src/pages/ClanHomePage.vue @@ -2,8 +2,9 @@ // 클랜 홈 — 배너 + 스탯 스트립 + 소개 + 2단 섹션. sa-clan.jsx ClanHome 포팅. import { computed } from 'vue' import { useRoute, useRouter } from 'vue-router' -import { SA } from '@/shared/data/sa' -import { ClanDB } from '@/shared/data/clan' +import { useBreakpoint } from '@/composables/useBreakpoint' +import { SA } from '@/shared/mock/sa' +import { ClanDB } from '@/shared/mock/clan' import AppIcon from '@/shared/components/AppIcon.vue' import ImageSlot from '@/shared/components/ImageSlot.vue' import ClanEmblem from '@/shared/components/ClanEmblem.vue' @@ -17,6 +18,8 @@ import ClanMembers from '@/pages/clan/ClanMembers.vue' const route = useRoute() const router = useRouter() +// 반응형 분기 — 모바일(<760)에서 히어로 엠블럼 size 축소(원본 sa-clan.jsx) +const { mobile } = useBreakpoint() const clan = computed(() => ClanDB(route.params.name as string)) const stats = computed(() => [ @@ -59,7 +62,7 @@ function back() {
@@ -334,4 +337,48 @@ function back() { gap: 18px; min-width: 0; } + +/* ───────────────────────────────────────────── + 반응형 (데스크톱 레이아웃 유지, 좁은 화면만 재배치) + 원본 searchgg 메인 홈.html @media 및 sa-clan.jsx mobile 분기 대응 + ───────────────────────────────────────────── */ + +/* 모바일(<760): 히어로 제목/슬로건/버튼 축소 — 엠블럼 size는 JS(mobile)로 분기 */ +@media (max-width: 760px) { + /* 좌우 패딩 축소 (원본 mobile: '0 16px 14px') */ + .clan__identity { + padding: 0 1rem 0.875rem; + gap: 0.75rem; + } + /* 제목 30 → 21px */ + .clan__name { + font-size: 1.3125rem; + } + /* 슬로건 14.5 → 12.5px */ + .clan__slogan { + font-size: 0.78125rem; + } + /* 가입 버튼 padding/fontSize 축소 (원본 '8px 14px' / 12px) */ + .clan__join { + padding: 0.5rem 0.875rem; + font-size: 0.75rem; + } +} + +/* 태블릿(<960): 2단 본문 그리드 → 1단 (원본 sg-grid-main) */ +@media (max-width: 960px) { + .clan__grid { + grid-template-columns: 1fr; + } +} + +/* 모바일(<600): wrap 좌우 패딩 16px(1rem) 축소 (원본 sg-wrap) */ +@media (max-width: 600px) { + .clan__topbar, + .clan__statstrip-inner, + .clan__body { + padding-left: 1rem; + padding-right: 1rem; + } +} diff --git a/frontend/src/pages/ClanPlazaPage.vue b/frontend/src/pages/ClanPlazaPage.vue index 0c8deea..6777e11 100644 --- a/frontend/src/pages/ClanPlazaPage.vue +++ b/frontend/src/pages/ClanPlazaPage.vue @@ -2,8 +2,8 @@ // 클랜 모집 광장 — 히어로 + 필터 + 카드 그리드 + 상세/등록 모달. sa-clan-plaza.jsx ClanPlaza 포팅. import { computed, ref } from 'vue' import { useRouter } from 'vue-router' -import { SA } from '@/shared/data/sa' -import { buildRecruits, RECRUIT_TYPES, type Recruit } from '@/shared/data/clan' +import { SA } from '@/shared/mock/sa' +import { buildRecruits, RECRUIT_TYPES, type Recruit } from '@/shared/mock/clan' import AppIcon from '@/shared/components/AppIcon.vue' import ClanEmblem from '@/shared/components/ClanEmblem.vue' import RecruitCard from '@/pages/clan/RecruitCard.vue' @@ -692,4 +692,23 @@ function goMyClan() { background: var(--point); color: var(--point-ink); } + +/* 반응형 — 카드 그리드(sg-plaza-cards 대응) 760px 이하 1열 */ +@media (max-width: 760px) { + .plaza__grid { + grid-template-columns: 1fr; + } +} + +/* 반응형 — 600px 이하 페이지 좌우 패딩 축소(28px → 16px=1rem) */ +@media (max-width: 600px) { + .plaza__hero-inner, + .plaza__filterbar-inner, + .plaza__detailrow-inner, + .plaza__mywrap, + .plaza__grid-wrap { + padding-left: 1rem; + padding-right: 1rem; + } +} diff --git a/frontend/src/pages/CommunityPage.vue b/frontend/src/pages/CommunityPage.vue index 3ab489a..dc56456 100644 --- a/frontend/src/pages/CommunityPage.vue +++ b/frontend/src/pages/CommunityPage.vue @@ -1,7 +1,7 @@ @@ -107,6 +311,12 @@ const tab = ref('전적') margin: 0 auto; padding: 24px 28px 44px; } +.pf__state { + padding: 60px 0; + text-align: center; + color: var(--t3); + font-size: 0.875rem; +} .pf__tabs { display: flex; gap: 4px; @@ -152,4 +362,18 @@ const tab = ref('전적') flex-direction: column; gap: 18px; } + +/* ── 반응형 ── */ +@media (max-width: 960px) { + .pf__grid { + grid-template-columns: 1fr; + } +} +@media (max-width: 600px) { + .pf__subhead-inner, + .pf__body { + padding-left: 1rem; + padding-right: 1rem; + } +} diff --git a/frontend/src/pages/clan/ClanActivity.vue b/frontend/src/pages/clan/ClanActivity.vue index 4bcec1b..aa4a7f6 100644 --- a/frontend/src/pages/clan/ClanActivity.vue +++ b/frontend/src/pages/clan/ClanActivity.vue @@ -1,7 +1,7 @@