UI 작업

This commit is contained in:
2026-06-12 01:54:26 +09:00
parent eaa30192cf
commit b8d636ba9d
122 changed files with 5683 additions and 5769 deletions
+3 -3
View File
@@ -6,10 +6,10 @@
!.env.*.example
# ==========================================
# 도커 영속 데이터
# 도커 영속 데이터 (루트 한정 — src 내부 data 폴더는 무시하지 않음)
# ==========================================
data/
!data/.gitkeep
/data/
!/data/.gitkeep
db-data/
redis-data/
+92 -76
View File
@@ -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",
+12
View File
@@ -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: [
@@ -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;
}
@@ -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;
}
@@ -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<Record<string, NewsItemDto[]>> {
return this.newsService.getSections();
}
}
+15
View File
@@ -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 {}
+238
View File
@@ -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<SaNewsEntity>,
) {}
// 최초 기동 시 데이터가 비어 있으면 1회 시드 크롤링 (부팅 비차단)
onModuleInit(): void {
void this.seedIfEmpty();
}
private async seedIfEmpty(): Promise<void> {
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<void> {
this.logger.log('시간별 소식 갱신 시작');
await this.refresh();
}
/**
* 카테고리별로 독립 크롤링 → 성공·비어있지 않은 카테고리만 해당 행 교체.
* 한 카테고리가 실패/빈 결과여도 다른 카테고리·기존 데이터는 유지된다.
*/
async refresh(): Promise<number> {
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<Record<string, NewsItemDto[]>> {
const rows = await this.repo.find({
order: { category: 'ASC', sortOrder: 'ASC' },
});
// 탭 순서를 고정해 초기화 (데이터 없어도 빈 배열 보장)
const out: Record<string, NewsItemDto[]> = {
: [],
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;
// 날짜: '<p class="months"><b>06.05</b></p>' (연도는 별도 .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;
}
}
@@ -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;
}
@@ -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;
}
@@ -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<Record<string, RankingItemDto[]>> {
return this.rankingService.getSections();
}
}
@@ -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 {}
@@ -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<SaRankingEntity>,
) {}
onModuleInit(): void {
void this.seedIfEmpty();
}
private async seedIfEmpty(): Promise<void> {
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<void> {
this.logger.log('일일 랭킹 갱신(00:00 KST) 시작');
await this.refresh();
}
/**
* 7개 데이터셋을 각각 크롤링 → 성공·비어있지 않은 카테고리만 교체.
* 한 카테고리 실패/빈 결과여도 다른 카테고리·기존 데이터는 유지된다.
*/
async refresh(): Promise<number> {
let total = 0;
// 각 카테고리를 (페치+파싱) 후 교체. 실패는 개별 격리.
const tasks: { category: string; run: () => Promise<RankingRaw[]> }[] = [
{ 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<void> {
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<Record<string, RankingItemDto[]>> {
const rows = await this.repo.find({
order: { category: 'ASC', sortOrder: 'ASC' },
});
const out: Record<string, RankingItemDto[]> = {
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<string> {
return curlFetchHtml(RankingService.BASE + url, {
referer: RankingService.REFERER,
headers: RankingService.AJAX_HEADERS,
});
}
/** list 페이지에서 정규식으로 현재 시즌값 추출 */
private async extractSeason(
listPath: string,
regexes: RegExp[],
): Promise<string> {
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<RankingRaw[]> {
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<RankingRaw[]> {
const season = await this.extractSeason('/ranking/seasonlevel/list.aspx', [
/id="Contents_hid_now_season_no"\s+value="(\d+)"/i,
/<option\s+selected[^>]*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<RankingRaw[]> {
// 첫 옵션 value(예: 2602S)에서 숫자 부분만 시즌 번호로 사용
const season = await this.extractSeason('/ranking/rankmatch/list.aspx', [
/<option[^>]*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<RankingRaw[]> {
const season = await this.extractSeason('/ranking/clanrank/list.aspx', [
/id="Contents_hid_now_season_no"\s+value="(\d+)"/i,
/<option\s+selected[^>]*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<RankingRaw[]> {
const html = await this.fetch('/ranking/official/rank.aspx');
return this.parseOfficialClan(html);
}
/** 클랜 — 정적 페이지 */
private async crawlClan(): Promise<RankingRaw[]> {
const html = await this.fetch('/ranking/clan/rank.aspx');
return this.parseClan(html);
}
// ──────────────────────────────────────────────────────────
// 파싱 (응답 HTML → RankingRaw[])
// ──────────────────────────────────────────────────────────
/** boardList 의 데이터 행(tr) 수집 (no-data 행 제외) */
private dataRows($: CheerioAPI): Cheerio<Element> {
return $('.boardList tbody tr').filter(
(_, tr) => $(tr).find('td.no-data, .no-data').length === 0,
);
}
private txt($el: Cheerio<Element>): string {
return $el.text().replace(/\s+/g, ' ').trim();
}
/** 클랜마크 이미지 src 들을 '|'로 join (배경 0_ + 심볼 1_ 2개 레이어) */
private joinImgSrcs($imgs: Cheerio<Element>): 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;
}
}
@@ -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[];
}
@@ -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;
}
@@ -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 = '폭파미션';
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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<MatchItemDto[]> {
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<MatchDetailDto> {
return this.saMatchService.getMatchDetail(matchId);
}
}
@@ -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 {}
@@ -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<SaMatchEntity>,
@InjectRepository(SaMatchDetailEntity)
private readonly detailRepo: Repository<SaMatchDetailEntity>,
@InjectRepository(SaMatchParticipantEntity)
private readonly participantRepo: Repository<SaMatchParticipantEntity>,
@InjectRepository(SaUserEntity)
private readonly userRepo: Repository<SaUserEntity>,
private readonly nexon: NexonService,
) {}
/** 매치 목록 조회 — 최신분 누적 후 DB에서 반환. */
async getMatches(
ouid: string,
matchType: string,
matchMode: string,
): Promise<MatchItemDto[]> {
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<void> {
try {
const res = await this.nexon.get<MatchListRes>('/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<void> {
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<MatchDetailDto> {
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<MatchDetailRes>(
'/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,
}),
),
};
}
}
@@ -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;
}
@@ -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;
}
@@ -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<Record<string, MetaItemDto[]>> {
return this.saMetaService.getAll();
}
}
@@ -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 {}
@@ -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<SaMetaEntity>,
private readonly nexon: NexonService,
) {}
onModuleInit(): void {
void this.seedIfEmpty();
}
private async seedIfEmpty(): Promise<void> {
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<void> {
this.logger.log('주간 메타 갱신 시작');
await this.refresh();
}
/** 종류별 메타 크롤링 → 성공·비어있지 않은 종류만 교체 */
async refresh(): Promise<number> {
let total = 0;
for (const src of SaMetaService.SOURCES) {
try {
const list = await this.nexon.get<Record<string, string>[]>(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<Record<string, MetaItemDto[]>> {
const rows = await this.repo.find({
order: { type: 'ASC', sortOrder: 'ASC' },
});
const out: Record<string, MetaItemDto[]> = {
grade: [],
season_grade: [],
tier: [],
};
for (const r of rows) {
(out[r.type] ??= []).push({ name: r.name, image: r.image });
}
return out;
}
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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<OuidResponseDto> {
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<ProfileResponseDto> {
return this.saUserService.getProfile(ouid);
}
}
@@ -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 {}
@@ -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<void> =>
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<SaUserEntity>,
@InjectRepository(SaUserProfileEntity)
private readonly profileRepo: Repository<SaUserProfileEntity>,
@InjectRepository(SaUserSnapshotEntity)
private readonly snapshotRepo: Repository<SaUserSnapshotEntity>,
private readonly nexon: NexonService,
) {}
/**
* 닉네임으로 OUID 조회. ouid 기준으로 정규화하므로 닉네임 변경/양도가 일어나도
* 행이 중복되지 않는다. 신규 유저는 프로필도 즉시 동기화한다.
*/
async searchOuid(rawName: string): Promise<OuidResponseDto> {
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<IdRes>('/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<ProfileResponseDto> {
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<SaUserProfileEntity> {
const [basic, rank, tier, recent] = await Promise.all([
this.nexon.get<BasicRes>('/suddenattack/v1/user/basic', { ouid }),
this.nexon.get<RankRes>('/suddenattack/v1/user/rank', { ouid }),
this.nexon.get<TierRes>('/suddenattack/v1/user/tier', { ouid }),
this.nexon.get<RecentRes>('/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<void> {
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,
};
}
}
+13
View File
@@ -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 {}
+115
View File
@@ -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<string>('NEXON_API_RATE_LIMIT_PER_SEC'));
this.maxPerSecond = Number.isFinite(rps) && rps > 0 ? rps : 5;
}
private get baseUrl(): string {
const url = this.config.get<string>('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<string>('NEXON_API_KEY');
if (!key) {
this.logger.error('NEXON_API_KEY 설정이 누락되었습니다.');
throw new ServiceUnavailableException(
'요청하신 작업을 완료하지 못했습니다.',
);
}
return key;
}
/**
* 호출 허가를 받을 때까지 대기 (1초 슬라이딩 윈도우, 초당 maxPerSecond 제한).
* 타임스탬프 정리·검사·추가 구간에는 await 가 없어 동시 호출이 몰려도 한도를 넘지 않는다.
*/
private async acquire(): Promise<void> {
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<T>(path: string, params?: Record<string, unknown>): Promise<T> {
await this.acquire();
try {
const { data } = await firstValueFrom(
this.http.get<T>(`${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(
'요청하신 작업을 완료하지 못했습니다.',
);
}
}
}
+8 -1
View File
@@ -14,6 +14,8 @@ const DEFAULT_UA =
export interface CurlFetchOptions {
/** Referer 헤더 (안티봇 우회에 필요한 경우) */
referer?: string;
/** 추가 요청 헤더 (예: AJAX 엔드포인트용 'X-Requested-With') */
headers?: Record<string, string>;
/** 타임아웃(초) — 기본 12초 */
timeoutSec?: number;
/** 최대 응답 버퍼(MB) — 기본 10MB */
@@ -25,7 +27,7 @@ export function curlFetchHtml(
url: string,
opts: CurlFetchOptions = {},
): Promise<string> {
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<string>((resolve, reject) => {
+57 -19
View File
@@ -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",
+20
View File
@@ -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<number>(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 }
}
+34
View File
@@ -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<string, NewsItem[]>
export function useNews() {
const api = useApi()
const loading = ref<boolean>(false)
const error = ref<string | null>(null)
async function getSections(): Promise<NewsSections> {
loading.value = true
error.value = null
try {
// 인터셉터에서 success/data 언래핑 후 객체만 반환
return (await api.get<NewsSections>('/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 }
}
+44
View File
@@ -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<string, RankingItem[]>
export function useRanking() {
const api = useApi()
const loading = ref<boolean>(false)
const error = ref<string | null>(null)
async function getSections(): Promise<RankingSections> {
loading.value = true
error.value = null
try {
// 인터셉터에서 success/data 언래핑 후 객체만 반환
return (await api.get<RankingSections>('/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 }
}
+76
View File
@@ -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<SaMatchItem[]> {
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<SaMatchDetail | null> {
try {
return (await api.get(`/sa-matches/${matchId}`)) as unknown as SaMatchDetail
} catch {
return null
}
}
return { getMatches, getMatchDetail }
}
+30
View File
@@ -0,0 +1,30 @@
import { useApi } from './useApi'
// 메타 항목 (이름 → 이미지)
export interface SaMetaItem {
name: string
image: string
}
// 종류별 메타 { grade, season_grade, tier }
export type SaMetaSections = Record<string, SaMetaItem[]>
export function useSaMeta() {
const api = useApi()
async function getMeta(): Promise<SaMetaSections> {
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 }
}
+68
View File
@@ -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<boolean>(false)
const error = ref<string | null>(null)
// 닉네임 → OUID 검색 (실패 시 null, 인터셉터가 에러 알림 처리)
async function searchOuid(userName: string): Promise<SaOuid | null> {
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<SaProfile | null> {
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 }
}
+50 -3
View File
@@ -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() {
<div class="clan__emblem">
<ClanEmblem
:cfg="clan.emblem"
:size="104"
:size="mobile ? 64 : 104"
/>
</div>
<div class="clan__title">
@@ -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;
}
}
</style>
+21 -2
View File
@@ -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;
}
}
</style>
+21 -2
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
// 커뮤니티 — 게시판 목록 + 검색/필터 + 상세/글쓰기 + 사이드. sa-community.jsx CommunityScreen 포팅.
import { computed, ref } from 'vue'
import { SA } from '@/shared/data/sa'
import { SA } from '@/shared/mock/sa'
import {
CM_BOARDS,
CM_NOTICES,
@@ -9,7 +9,7 @@ import {
CM_POSTS,
cmBody,
type CmPost,
} from '@/shared/data/community'
} from '@/shared/mock/community'
import AppIcon from '@/shared/components/AppIcon.vue'
import CmRow from '@/pages/community/CmRow.vue'
import CmSidePanel from '@/pages/community/CmSidePanel.vue'
@@ -380,6 +380,10 @@ function hotView(p: CmPost) {
gap: 18px;
align-items: start;
}
/* 그리드 자식이 좁아질 때 내용이 넘치지 않도록 (sg-grid-main > * { min-width: 0 }) */
.cm__grid > * {
min-width: 0;
}
.cm__board {
background: var(--surf);
border: 1px solid var(--line);
@@ -559,4 +563,19 @@ function hotView(p: CmPost) {
flex-direction: column;
gap: 18px;
}
/* ===== 반응형 (원본 sg-grid-main / sg-wrap @media 대응) ===== */
/* 태블릿 이하: 메인 2단 그리드 → 1단 (sg-grid-main: 1fr) */
@media (max-width: 960px) {
.cm__grid {
grid-template-columns: 1fr;
}
}
/* 모바일: 페이지 좌우 패딩 축소 16px(1rem) (sg-wrap) */
@media (max-width: 600px) {
.cm__inner {
padding-left: 1rem;
padding-right: 1rem;
}
}
</style>
+22 -2
View File
@@ -1,5 +1,7 @@
<script setup lang="ts">
// 홈 페이지 — 히어로 + 이벤트 + 좌(콘텐츠)/우(사이드) 2단 그리드. sa-home.jsx Home 포팅.
import { storeToRefs } from 'pinia'
import { useUserStore } from '@/stores/user.store'
import HomeHero from '@/pages/home/HomeHero.vue'
import EventStrip from '@/pages/home/EventStrip.vue'
import StreamLive from '@/pages/home/StreamLive.vue'
@@ -13,6 +15,10 @@ import SeasonCard from '@/pages/home/SeasonCard.vue'
import SaRanking from '@/pages/home/SaRanking.vue'
import SearchRank from '@/pages/home/SearchRank.vue'
import LiveStats from '@/pages/home/LiveStats.vue'
// 내 전적/즐겨찾기 카드는 로그인 상태에서만 노출
const userStore = useUserStore()
const { isLoggedIn } = storeToRefs(userStore)
</script>
<template>
@@ -29,8 +35,8 @@ import LiveStats from '@/pages/home/LiveStats.vue'
<CommunityPreview />
</div>
<div class="home__col">
<MeCard />
<FavoritesPanel />
<MeCard v-if="isLoggedIn" />
<FavoritesPanel v-if="isLoggedIn" />
<SeasonCard />
<SaRanking />
<SearchRank />
@@ -62,4 +68,18 @@ import LiveStats from '@/pages/home/LiveStats.vue'
gap: 18px;
min-width: 0;
}
/* 반응형 — 태블릿 이하: 우측 사이드 컬럼을 본문 아래로 1단 배치 */
@media (max-width: 960px) {
.home__grid {
grid-template-columns: 1fr;
}
}
/* 반응형 — 모바일: 좌우 패딩 축소(16px=1rem), 세로 패딩 유지 */
@media (max-width: 600px) {
.home__inner {
padding: 1.25rem 1rem 2.5rem;
}
}
</style>
+271 -47
View File
@@ -1,9 +1,13 @@
<script setup lang="ts">
// 전적검색(프로필) 페이지 — 검색 서브헤더 + 요약 + 전적/통계 탭. sa-profile.jsx ProfileScreen 포팅.
import { computed, ref } from 'vue'
// UI 구조는 기존(mock 디자인) 그대로 유지하고, 데이터만 넥슨 실데이터를 어댑터로 변환해 주입한다.
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { SA, findPlayer } from '@/shared/data/sa'
import type { Player } from '@/shared/data/types'
import { useSaUser, type SaProfile } from '@/composables/useSaUser'
import { useSaMatch, MATCH_TYPES, type SaMatchItem } from '@/composables/useSaMatch'
import { useSaMeta, type SaMetaSections } from '@/composables/useSaMeta'
import { makeRank } from '@/shared/utils/adapt'
import type { Player, MatchRecord, RankedRecord, ClanRecord } from '@/shared/mock/types'
import SearchBar from '@/shared/components/SearchBar.vue'
import ProfileSummary from '@/pages/profile/ProfileSummary.vue'
import RankedSeasons from '@/pages/profile/RankedSeasons.vue'
@@ -16,13 +20,198 @@ import ModeRecords from '@/pages/profile/ModeRecords.vue'
import MapRecords from '@/pages/profile/MapRecords.vue'
const route = useRoute()
// 라우트 파라미터로 유저 조회. 없으면 로그인 유저(나) 폴백.
const p = computed<Player>(() => {
const name = route.params.name as string
return findPlayer(name) ?? SA.me
})
const { searchOuid, getProfile } = useSaUser()
const { getMatches } = useSaMatch()
const { getMeta, imageOf } = useSaMeta()
const loading = ref(false)
const notFound = ref(false)
const p = ref<Player | null>(null)
const tab = ref('전적')
// 넥슨 매치타입 → 기존 UI 탭 라벨
const TYPE_LABEL: Record<string, string> = {
'랭크전 솔로': '솔로 랭크전',
'랭크전 파티': '파티 랭크전',
'퀵매치 클랜전': '클랜전',
'클랜 랭크전': '클랜 랭크전',
토너먼트: '토너먼트',
}
// ISO → 'MM.DD HH:mm' (KST)
function fmtAgo(iso: string): string {
const kst = new Date(new Date(iso).getTime() + 9 * 3600 * 1000)
const mm = String(kst.getUTCMonth() + 1).padStart(2, '0')
const dd = String(kst.getUTCDate()).padStart(2, '0')
const hh = String(kst.getUTCHours()).padStart(2, '0')
const mi = String(kst.getUTCMinutes()).padStart(2, '0')
return `${mm}.${dd} ${hh}:${mi}`
}
// 킬뎃 %(recentKdRate)를 kda 값으로 역산 (ProfileSummary 가 SA.kdPct 로 다시 %로 환산하므로)
function kdaFromPct(pct: number): number {
const r = Math.min(99, Math.max(0, pct))
return r / (100 - r)
}
// 랭크전 매치(솔로/파티)를 집계해 시즌 전적(RankedRecord) 생성.
// 티어/점수는 프로필 tier API 값, 전적/승률/킬뎃은 매치 목록 집계.
function aggRanked(
items: SaMatchItem[],
type: string,
tier: string,
points: number,
tierImage: string,
): RankedRecord {
const f = items.filter((m) => m.matchType === type)
const matches = f.length
const w = f.filter((x) => x.matchResult === '1').length
const l = matches - w
let kdSum = 0
f.forEach((x) => {
const t = x.kill + x.death
kdSum += t ? (x.kill / t) * 100 : 0
})
return {
matches,
w,
l,
wr: matches ? Math.round((w / matches) * 100) : 0,
kd: matches ? Math.round(kdSum / matches) : 0,
hs: 0,
tier: tier || 'UNRANK',
div: null,
points,
tierImage,
}
}
// 클랜전 전적 — 클랜이 있으면 클랜전(퀵매치 클랜전 + 클랜 랭크전) 매치를 집계.
// 전적이 없으면 0으로 표기, 클랜이 없으면 빈 목록.
function aggClanRecords(items: SaMatchItem[], clanName: string): ClanRecord[] {
if (!clanName) return []
const clanTypes = ['퀵매치 클랜전', '클랜 랭크전']
const f = items.filter((m) => clanTypes.includes(m.matchType))
const matches = f.length
const w = f.filter((x) => x.matchResult === '1').length
const l = matches - w
let kdSum = 0
f.forEach((x) => {
const t = x.kill + x.death
kdSum += t ? (x.kill / t) * 100 : 0
})
// 현재 가입된 클랜이므로 가입 1회로 카운트. 가입일은 알 수 없어 '????',
// 종료는 현재(활동중) 년월(yyyy-mm)로 표기한다.
const kst = new Date(Date.now() + 9 * 60 * 60 * 1000)
const nowYm = `${kst.getUTCFullYear()}-${String(kst.getUTCMonth() + 1).padStart(2, '0')}`
return [
{
clan: clanName,
tag: clanName.slice(0, 4),
matches,
w,
l,
wr: matches ? Math.round((w / matches) * 100) : 0,
kd: matches ? Math.round(kdSum / matches) : 0,
periods: [{ from: '????', to: nowYm, months: 0 }],
},
]
}
function adaptMatches(items: SaMatchItem[]): MatchRecord[] {
return items.map((m) => ({
matchId: m.matchId,
mode: TYPE_LABEL[m.matchType] ?? m.matchType,
win: m.matchResult === '1',
map: '',
k: m.kill,
d: m.death,
a: m.assist,
ago: fmtAgo(m.dateMatch),
winSide: 'blue',
teams: { blue: [], red: [] },
}))
}
// 넥슨 프로필/매치 → 기존 UI가 기대하는 mock Player 형태로 변환.
// 넥슨이 제공하지 않는 항목(집계/추세/함께한 유저 등)은 빈 값으로 두고 2차에서 채운다.
function adaptPlayer(pr: SaProfile, matches: SaMatchItem[], meta: SaMetaSections): Player {
const gradeImg = imageOf(meta, 'grade', pr.grade)
const seasonImg = imageOf(meta, 'season_grade', pr.seasonGrade)
return {
name: pr.userName,
clan: pr.clanName || null,
rank: makeRank(pr.grade, gradeImg),
rankTotal: makeRank(pr.grade, gradeImg),
rankSeason: makeRank(pr.seasonGrade, seasonImg),
rankNoTotal: pr.gradeRanking,
rankNoSeason: pr.seasonGradeRanking,
level: 0,
wins: 0,
losses: 0,
wr: Math.round(pr.recentWinRate * 10) / 10,
kda: kdaFromPct(pr.recentKdRate),
hs: 0,
rifle: Math.round(pr.recentAssaultRate),
sniper: Math.round(pr.recentSniperRate),
special: Math.round(pr.recentSpecialRate),
manner: 0,
mannerGrade: pr.mannerGrade,
connRate: 0,
views: 0,
contrib: 0,
matches: adaptMatches(matches),
rankedSeasons: [
{
season: '현재 시즌',
solo: aggRanked(matches, '랭크전 솔로', pr.soloTier, pr.soloScore, imageOf(meta, 'tier', pr.soloTier)),
party: aggRanked(matches, '랭크전 파티', pr.partyTier, pr.partyScore, imageOf(meta, 'tier', pr.partyTier)),
},
],
clanRecords: aggClanRecords(matches, pr.clanName),
modeRecords: [],
mapRecords: [],
trend: { daily: [], weekly: [] },
together: { games: 0, w: 0, l: 0 },
}
}
async function load() {
const name = (route.params.name as string)?.trim()
if (!name) return
loading.value = true
notFound.value = false
p.value = null
const res = await searchOuid(name)
if (!res) {
notFound.value = true
loading.value = false
return
}
const results = await Promise.all([
getProfile(res.ouid),
getMeta(),
...MATCH_TYPES.map((t) => getMatches(res.ouid, t)),
])
const profile = results[0] as SaProfile | null
const meta = results[1] as SaMetaSections
const matchLists = results.slice(2) as SaMatchItem[][]
if (!profile) {
notFound.value = true
loading.value = false
return
}
const allMatches = matchLists
.flat()
.sort((a, b) => b.dateMatch.localeCompare(a.dateMatch))
p.value = adaptPlayer(profile, allMatches, meta)
loading.value = false
}
watch(() => route.params.name, load, { immediate: true })
</script>
<template>
@@ -37,49 +226,64 @@ const tab = ref('전적')
</div>
<div class="pf__body">
<ProfileSummary :p="p" />
<div
v-if="loading"
class="pf__state"
>
불러오는 ...
</div>
<div
v-else-if="notFound"
class="pf__state"
>
요청하신 유저를 찾을 없습니다.
</div>
<!-- 서브탭 -->
<div class="pf__tabs">
<button
v-for="t in ['전적', '통계']"
:key="t"
class="pf__tab"
:class="{ 'pf__tab--on': tab === t }"
@click="tab = t"
<template v-else-if="p">
<ProfileSummary :p="p" />
<!-- 서브탭 -->
<div class="pf__tabs">
<button
v-for="t in ['전적', '통계']"
:key="t"
class="pf__tab"
:class="{ 'pf__tab--on': tab === t }"
@click="tab = t"
>
{{ t }}
<span
v-if="tab === t"
class="pf__tab-line"
:style="{ background: p.rank.color }"
/>
</button>
</div>
<div
v-if="tab === '전적'"
class="pf__grid"
>
{{ t }}
<span
v-if="tab === t"
class="pf__tab-line"
:style="{ background: p.rank.color }"
/>
</button>
</div>
<div
v-if="tab === '전적'"
class="pf__grid"
>
<div class="pf__col">
<RankedSeasons :p="p" />
<ClanRecords :p="p" />
<MatchHistory :p="p" />
<div class="pf__col">
<RankedSeasons :p="p" />
<ClanRecords :p="p" />
<MatchHistory :p="p" />
</div>
<div class="pf__col pf__col--side">
<RecentTrend :p="p" />
<Companions :p="p" />
</div>
</div>
<div class="pf__col pf__col--side">
<RecentTrend :p="p" />
<Companions :p="p" />
</div>
</div>
<div
v-else
class="pf__stack"
>
<TrendChart :p="p" />
<ModeRecords :p="p" />
<MapRecords :p="p" />
</div>
<div
v-else
class="pf__stack"
>
<TrendChart :p="p" />
<ModeRecords :p="p" />
<MapRecords :p="p" />
</div>
</template>
</div>
</div>
</template>
@@ -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;
}
}
</style>
+1 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
// 주간 활동 — 요일별 접속률 막대. sa-clan-sections.jsx ClanActivity 포팅.
import { computed } from 'vue'
import type { Clan } from '@/shared/data/clan'
import type { Clan } from '@/shared/mock/clan'
import UiPanel from '@/shared/components/UiPanel.vue'
interface Props {
+2 -2
View File
@@ -1,8 +1,8 @@
<script setup lang="ts">
// 클랜 피드 — 작성(내 클랜) + 페이지네이션. sa-clan-sections.jsx ClanFeed 포팅.
import { computed, ref, watch } from 'vue'
import { SA } from '@/shared/data/sa'
import type { Clan, ClanFeedItem } from '@/shared/data/clan'
import { SA } from '@/shared/mock/sa'
import type { Clan, ClanFeedItem } from '@/shared/mock/clan'
import UiPanel from '@/shared/components/UiPanel.vue'
import AppIcon from '@/shared/components/AppIcon.vue'
import ClanPager from './ClanPager.vue'
+2 -2
View File
@@ -1,8 +1,8 @@
<script setup lang="ts">
// 방명록 — 방문자 글 + 클랜원 답글. sa-clan-sections.jsx ClanGuestbook 포팅.
import { computed, ref, watch } from 'vue'
import { SA } from '@/shared/data/sa'
import type { Clan, ClanGuestItem } from '@/shared/data/clan'
import { SA } from '@/shared/mock/sa'
import type { Clan, ClanGuestItem } from '@/shared/mock/clan'
import UiPanel from '@/shared/components/UiPanel.vue'
import ClanPager from './ClanPager.vue'
+2 -2
View File
@@ -1,8 +1,8 @@
<script setup lang="ts">
// 클랜원 — 역할별 그룹 + 기여도. sa-clan-sections.jsx ClanMembers 포팅.
import { useRouter } from 'vue-router'
import { SA } from '@/shared/data/sa'
import type { Clan } from '@/shared/data/clan'
import { SA } from '@/shared/mock/sa'
import type { Clan } from '@/shared/mock/clan'
import UiPanel from '@/shared/components/UiPanel.vue'
import RankBadge from '@/shared/components/RankBadge.vue'
+1 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
// 클랜 공지 — 페이지네이션. sa-clan-sections.jsx ClanNotices 포팅.
import { computed, ref } from 'vue'
import type { Clan } from '@/shared/data/clan'
import type { Clan } from '@/shared/mock/clan'
import UiPanel from '@/shared/components/UiPanel.vue'
import ClanPager from './ClanPager.vue'
+1 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
// 클랜 일정 — 주간 정기전 + 등록/삭제(내 클랜). sa-clan-sections.jsx ClanSchedule 포팅.
import { computed, ref, watch } from 'vue'
import type { Clan, ClanScheduleItem } from '@/shared/data/clan'
import type { Clan, ClanScheduleItem } from '@/shared/mock/clan'
import UiPanel from '@/shared/components/UiPanel.vue'
interface Props {
@@ -1,7 +1,7 @@
<script setup lang="ts">
// 주간 매치 — 요일별 모드 스택 막대 + 합계. sa-clan-sections.jsx ClanWeeklyMatches 포팅.
import { computed } from 'vue'
import type { Clan, ClanActivityDay } from '@/shared/data/clan'
import type { Clan, ClanActivityDay } from '@/shared/mock/clan'
import UiPanel from '@/shared/components/UiPanel.vue'
interface Props {
+2 -2
View File
@@ -1,8 +1,8 @@
<script setup lang="ts">
// 모집 카드 — 포스터 + 클랜 정보 + 모집 현황 막대. sa-clan-plaza.jsx RecruitCard 포팅.
import { computed } from 'vue'
import { SA } from '@/shared/data/sa'
import { recruitAgeLabel, type Recruit } from '@/shared/data/clan'
import { SA } from '@/shared/mock/sa'
import { recruitAgeLabel, type Recruit } from '@/shared/mock/clan'
import ImageSlot from '@/shared/components/ImageSlot.vue'
import ClanEmblem from '@/shared/components/ClanEmblem.vue'
+2 -2
View File
@@ -1,8 +1,8 @@
<script setup lang="ts">
// 모집글 등록 모달 — 포스터/유형/조건/안내/문의채널. sa-clan-plaza.jsx RecruitCreate 포팅(데모).
import { computed, ref } from 'vue'
import { SA } from '@/shared/data/sa'
import { ClanDB, RECRUIT_TYPES } from '@/shared/data/clan'
import { SA } from '@/shared/mock/sa'
import { ClanDB, RECRUIT_TYPES } from '@/shared/mock/clan'
import ImageSlot from '@/shared/components/ImageSlot.vue'
import ClanEmblem from '@/shared/components/ClanEmblem.vue'
import ContactGlyph from './ContactGlyph.vue'
+8 -1
View File
@@ -2,7 +2,7 @@
// 모집 상세 모달 — 포스터 + 통계 + 가입 조건 + 액션. sa-clan-plaza.jsx RecruitDetail 포팅.
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { recruitAgeLabel, type Recruit } from '@/shared/data/clan'
import { recruitAgeLabel, type Recruit } from '@/shared/mock/clan'
import ImageSlot from '@/shared/components/ImageSlot.vue'
import ClanEmblem from '@/shared/components/ClanEmblem.vue'
import ContactGlyph from './ContactGlyph.vue'
@@ -364,4 +364,11 @@ function openClan() {
.rd__contact--kakao {
background: var(--kakao);
}
/* 반응형 — 가입 조건 그리드(sg-cond2 대응) 760px 이하 1열 */
@media (max-width: 760px) {
.rd__cond {
grid-template-columns: 1fr;
}
}
</style>
+1 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
// 작성자 + 서든어택 인증 마크. sa-community.jsx CmAuthor/CmVerifiedTag 포팅.
import { computed } from 'vue'
import { cmVerified } from '@/shared/data/community'
import { cmVerified } from '@/shared/mock/community'
interface Props {
name: string
@@ -1,8 +1,8 @@
<script setup lang="ts">
// 커뮤니티 글 상세 — 본문 + 추천/비추 + 댓글/답글. sa-community.jsx CmPostDetail 포팅.
import { computed, ref, watch } from 'vue'
import { SA } from '@/shared/data/sa'
import { CM_BEST_UP, cmBody, cmComments, cmPartyBadges, type CmComment, type CmPost } from '@/shared/data/community'
import { SA } from '@/shared/mock/sa'
import { CM_BEST_UP, cmBody, cmComments, cmPartyBadges, type CmComment, type CmPost } from '@/shared/mock/community'
import AppIcon from '@/shared/components/AppIcon.vue'
import CmAuthor from './CmAuthor.vue'
+2 -2
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
// 커뮤니티 목록 행. sa-community.jsx CmRow 포팅.
import { SA } from '@/shared/data/sa'
import { CM_BEST_UP, cmPartyBadges, type CmPost } from '@/shared/data/community'
import { SA } from '@/shared/mock/sa'
import { CM_BEST_UP, cmPartyBadges, type CmPost } from '@/shared/mock/community'
import CmAuthor from './CmAuthor.vue'
interface Props {
+1 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
// 커뮤니티 사이드 위젯 — 인기글/주간 베스트. sa-community.jsx CmSidePanel 포팅.
import type { CmPost } from '@/shared/data/community'
import type { CmPost } from '@/shared/mock/community'
import UiPanel from '@/shared/components/UiPanel.vue'
interface Props {
+2 -2
View File
@@ -1,8 +1,8 @@
<script setup lang="ts">
// 글쓰기 — 일반/파티 모집. sa-community.jsx CmWrite 포팅.
import { computed, ref } from 'vue'
import { SA } from '@/shared/data/sa'
import { CM_BOARDS, CM_PARTY_TYPES, type CmPost } from '@/shared/data/community'
import { SA } from '@/shared/mock/sa'
import { CM_BOARDS, CM_PARTY_TYPES, type CmPost } from '@/shared/mock/community'
interface Props {
initialBoard: string
+1 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
// 커뮤니티 미리보기 — 자유+공략 인기글 6개. sa-home.jsx Community 포팅.
import { useRouter } from 'vue-router'
import { SA } from '@/shared/data/sa'
import { SA } from '@/shared/mock/sa'
import UiPanel from '@/shared/components/UiPanel.vue'
import AppIcon from '@/shared/components/AppIcon.vue'
+15 -1
View File
@@ -2,7 +2,7 @@
// 진행중인 이벤트 — 4개씩 페이지 캐러셀 + 자동 슬라이드. sa-home.jsx EventStrip 포팅.
// 데이터: 백엔드 크롤링(/api/events/ongoing). 실패 시 더미(SA.events) 폴백.
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { SA } from '@/shared/data/sa'
import { SA } from '@/shared/mock/sa'
import { useEvents } from '@/composables/useEvents'
import ImageSlot from '@/shared/components/ImageSlot.vue'
@@ -318,4 +318,18 @@ onBeforeUnmount(stopTimer)
width: 20px;
background: var(--point);
}
/* 반응형 — 태블릿 이하: 한 페이지에 2개씩 */
@media (max-width: 960px) {
.es__page {
grid-template-columns: repeat(2, 1fr);
}
}
/* 반응형 — 모바일: 한 페이지에 1개씩 */
@media (max-width: 440px) {
.es__page {
grid-template-columns: 1fr;
}
}
</style>
+2 -2
View File
@@ -2,8 +2,8 @@
// 즐겨찾기 — 페이지네이션 목록. sa-home.jsx Favorites 포팅.
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { SA } from '@/shared/data/sa'
import type { Player } from '@/shared/data/types'
import { SA } from '@/shared/mock/sa'
import type { Player } from '@/shared/mock/types'
import UiPanel from '@/shared/components/UiPanel.vue'
import RankBadge from '@/shared/components/RankBadge.vue'
+1 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
// 홈 히어로 — 검색 밴드 + 환영 문구 + 최근검색 칩
import { SA } from '@/shared/data/sa'
import { SA } from '@/shared/mock/sa'
import SearchBar from '@/shared/components/SearchBar.vue'
import RecentChips from '@/shared/components/RecentChips.vue'
+9 -2
View File
@@ -2,9 +2,9 @@
// 폼 좋은 유저 — 연승/승률/킬뎃 탭. sa-home.jsx HotStreak 포팅.
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { SA } from '@/shared/data/sa'
import { SA } from '@/shared/mock/sa'
import { wrColor } from '@/shared/utils/stats'
import type { Player } from '@/shared/data/types'
import type { Player } from '@/shared/mock/types'
import UiPanel from '@/shared/components/UiPanel.vue'
import SegTabs from '@/shared/components/SegTabs.vue'
import RankBadge from '@/shared/components/RankBadge.vue'
@@ -189,4 +189,11 @@ function open(p: Player) {
.hs__pct--pt {
color: var(--point);
}
/* 반응형 — 좁은 화면: 카드 1열로 전환 */
@media (max-width: 760px) {
.hs__grid {
grid-template-columns: 1fr;
}
}
</style>
+1 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
// 오늘의 전장 — 총 매치/모드별/제재. sa-home.jsx LiveStats 포팅.
import { SA } from '@/shared/data/sa'
import { SA } from '@/shared/mock/sa'
import UiPanel from '@/shared/components/UiPanel.vue'
const L = SA.liveStats
+1 -1
View File
@@ -2,7 +2,7 @@
// 내 전적 요약 — 통합/시즌 토글 + 핵심 지표. sa-home.jsx MeCard 포팅.
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { SA } from '@/shared/data/sa'
import { SA } from '@/shared/mock/sa'
import { wrColor } from '@/shared/utils/stats'
import UiPanel from '@/shared/components/UiPanel.vue'
import SegTabs from '@/shared/components/SegTabs.vue'
+8 -1
View File
@@ -2,7 +2,7 @@
// 모집 중인 클랜 — 세로 포스터 4개. sa-home.jsx RecruitPosters 포팅.
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { buildRecruits } from '@/shared/data/clan'
import { buildRecruits } from '@/shared/mock/clan'
import UiPanel from '@/shared/components/UiPanel.vue'
import AppIcon from '@/shared/components/AppIcon.vue'
import ImageSlot from '@/shared/components/ImageSlot.vue'
@@ -117,4 +117,11 @@ function goClan(name: string) {
color: var(--t3);
padding-left: 27px;
}
/* 반응형 — 모바일: 포스터 2열로 축소 */
@media (max-width: 600px) {
.rp__grid {
grid-template-columns: repeat(2, 1fr);
}
}
</style>
+23 -3
View File
@@ -1,12 +1,31 @@
<script setup lang="ts">
// SA 소식 — 공지/GM이야기/업데이트 탭. sa-home.jsx SANews 포팅.
import { computed, ref } from 'vue'
import { SA } from '@/shared/data/sa'
// 데이터: 백엔드 크롤링(/api/news/sections). 실패 시 더미(SA.news) 폴백.
import { computed, onMounted, ref } from 'vue'
import { SA } from '@/shared/mock/sa'
import { useNews, type NewsItem } from '@/composables/useNews'
import UiPanel from '@/shared/components/UiPanel.vue'
import SegTabs from '@/shared/components/SegTabs.vue'
const tab = ref('공지사항')
const rows = computed(() => SA.news[tab.value] ?? [])
// 초기값은 더미 — 마운트 시 실데이터로 교체
const sections = ref<Record<string, Partial<NewsItem>[]>>(SA.news)
// 홈 소식 카드는 카테고리당 최대 5개만 표시
const rows = computed(() => (sections.value[tab.value] ?? []).slice(0, 5))
// 실데이터 로드 (크롤링). 성공 시 교체, 실패/빈 응답이면 더미 유지.
const { getSections } = useNews()
onMounted(async () => {
const data = await getSections()
// 항목이 하나라도 있는 카테고리가 있을 때만 교체
if (Object.values(data).some((list) => list.length)) {
sections.value = data
}
})
function openNews(link?: string) {
if (link) window.open(link, '_blank', 'noopener')
}
</script>
<template>
@@ -27,6 +46,7 @@ const rows = computed(() => SA.news[tab.value] ?? [])
:key="i"
class="news__row"
:class="{ 'news__row--line': i < rows.length - 1 }"
@click="openNews(r.link)"
>
<span class="news__date num">{{ r.date }}</span>
<span class="news__title">{{ r.title }}</span>
+177 -110
View File
@@ -1,37 +1,48 @@
<script setup lang="ts">
// SA 랭킹 — 통합/시즌 계급, 랭크전(솔로/파티/클랜), 공식/일반 클랜. sa-home.jsx SARanking 포팅.
import { computed, ref } from 'vue'
// SA 랭킹 — 통합/시즌 계급, 랭크전(솔로/파티/클랜), 공식/일반 클랜.
// 데이터: 백엔드 크롤링(/api/ranking/sections). 계급=승률 주지표, 티어/계급은 넥슨 원본 이미지.
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { SA } from '@/shared/data/sa'
import { useRanking, type RankingItem } from '@/composables/useRanking'
import UiPanel from '@/shared/components/UiPanel.vue'
import RankBadge from '@/shared/components/RankBadge.vue'
const router = useRouter()
const CATS = ['통합 계급', '시즌 계급', '랭크전', '공식 클랜', '클랜']
const cat = ref('통합 계급')
const mode = ref('솔로')
const tcol = computed(() => Object.fromEntries(SA.tiers.map((t) => [t.name, t.color])))
const isClass = computed(() => cat.value === '시즌 계급' || cat.value === '통합 계급')
const sections = ref<Record<string, RankingItem[]>>({})
const isClass = computed(() => cat.value === '통합 계급' || cat.value === '시즌 계급')
const isRanked = computed(() => cat.value === '랭크전')
const isRankedUser = computed(() => isRanked.value && mode.value !== '클랜')
const isRankedClan = computed(() => isRanked.value && mode.value === '클랜')
const isClan = computed(() => cat.value === '공식 클랜' || cat.value === '클랜')
const isOfficialClan = computed(() => cat.value === '공식 클랜')
const isClan = computed(() => cat.value === '클랜')
const classList = computed(() =>
cat.value === '시즌 계급' ? SA.seasonClassRanking : SA.totalClassRanking,
)
const contentList = computed(() => (mode.value === '솔로' ? SA.soloRpRanking : SA.partyRpRanking))
const clanList = computed(() =>
cat.value === '공식 클랜' ? SA.officialClanRanking : SA.generalClanRanking,
)
// 현재 카테고리/모드 → 백엔드 카테고리 키
const catKey = computed(() => {
if (cat.value === '통합 계급') return 'total_class'
if (cat.value === '시즌 계급') return 'season_class'
if (cat.value === '공식 클랜') return 'official_clan'
if (cat.value === '클랜') return 'clan'
// 랭크전
if (mode.value === '솔로') return 'ranked_solo'
if (mode.value === '파티') return 'ranked_party'
return 'ranked_clan'
})
const rows = computed(() => (sections.value[catKey.value] ?? []).slice(0, 10))
const { getSections } = useRanking()
onMounted(async () => {
const data = await getSections()
if (Object.values(data).some((list) => list.length)) {
sections.value = data
}
})
function findP(name: string) {
return SA.allPlayers.find((x) => x.name === name)
}
function openPlayer(name: string) {
const p = findP(name)
if (p) router.push({ name: 'profile', params: { name: p.name } })
router.push({ name: 'profile', params: { name } })
}
function openClan(name: string) {
router.push({ name: 'clan', params: { name } })
@@ -79,11 +90,19 @@ function openClan(name: string) {
</div>
</div>
<!-- 계급 랭킹 -->
<!-- 상태 -->
<div
v-if="!rows.length"
class="sr__empty"
>
랭킹을 집계 중입니다.
</div>
<!-- 계급 랭킹 (통합/시즌) 주지표: 승률 -->
<template v-if="isClass">
<button
v-for="(e, i) in classList"
:key="e.name"
v-for="(e, i) in rows"
:key="e.rankNo"
class="sr__row"
:class="{ 'sr__row--alt': i % 2 === 1 }"
@click="openPlayer(e.name)"
@@ -91,32 +110,30 @@ function openClan(name: string) {
<span
class="sr__no num"
:class="{ 'sr__no--top': i < 3 }"
>{{ i + 1 }}</span>
<RankBadge
:rank="findP(e.name)?.rank ?? null"
:size="28"
/>
>{{ e.rankNo }}</span>
<img
v-if="e.gradeImg"
:src="e.gradeImg"
class="sr__img"
alt="계급"
>
<div class="sr__info">
<div class="sr__name-line">
<span class="sr__name">{{ e.name }}</span>
<span
v-if="findP(e.name)?.clan"
class="sr__tag"
>{{ findP(e.name)?.clan }}</span>
<div class="sr__name">
{{ e.name }}
</div>
<div class="sr__sub">
{{ findP(e.name)?.rank.name }}
킬뎃 {{ e.kd }}
</div>
</div>
<span class="sr__val num">{{ SA.fmt(e.score) }}<span class="sr__unit"></span></span>
<span class="sr__val num">{{ e.winRate }}</span>
</button>
</template>
<!-- 랭크전(솔로/파티) -->
<!-- 랭크전 (솔로/파티) 주지표: RP, 티어 이미지 -->
<template v-if="isRankedUser">
<button
v-for="(e, i) in contentList"
:key="e.name"
v-for="(e, i) in rows"
:key="e.rankNo"
class="sr__row"
:class="{ 'sr__row--alt': i % 2 === 1 }"
@click="openPlayer(e.name)"
@@ -124,37 +141,30 @@ function openClan(name: string) {
<span
class="sr__no num"
:class="{ 'sr__no--top': i < 3 }"
>{{ i + 1 }}</span>
<div
class="sr__tier"
:style="{ background: tcol[e.tier] || 'var(--point)' }"
>{{ e.rankNo }}</span>
<img
v-if="e.tierImg"
:src="e.tierImg"
class="sr__img"
alt="티어"
>
{{ e.tier[0] }}
</div>
<div class="sr__info">
<div class="sr__name-line">
<span class="sr__name">{{ e.name }}</span>
<span
v-if="findP(e.name)?.clan"
class="sr__tag"
>{{ findP(e.name)?.clan }}</span>
<div class="sr__name">
{{ e.name }}
</div>
<div
class="sr__sub"
:style="{ color: tcol[e.tier], fontWeight: 600 }"
>
{{ e.tier }}
<div class="sr__sub">
승률 {{ e.winRate }}
</div>
</div>
<span class="sr__val num">{{ SA.fmt(e.rp) }}<span class="sr__unit">RP</span></span>
<span class="sr__val num">{{ e.rp }}<span class="sr__unit">RP</span></span>
</button>
</template>
<!-- 랭크전(클랜) -->
<!-- 랭크전 (클랜) 주지표: 클랜 RP -->
<template v-if="isRankedClan">
<button
v-for="(c, i) in SA.clanRpRanking"
:key="c.name"
v-for="(c, i) in rows"
:key="c.rankNo"
class="sr__row"
:class="{ 'sr__row--alt': i % 2 === 1 }"
@click="openClan(c.name)"
@@ -162,27 +172,41 @@ function openClan(name: string) {
<span
class="sr__no num"
:class="{ 'sr__no--top': i < 3 }"
>{{ i + 1 }}</span>
<div class="sr__clantag num">
{{ c.tag }}
>{{ c.rankNo }}</span>
<span
v-if="c.clanMark"
class="sr__mark"
>
<img
v-for="(src, idx) in c.clanMark.split('|')"
:key="idx"
:src="src"
alt="클랜마크"
>
</span>
<div
v-else
class="sr__clantag num"
>
{{ c.name.slice(0, 3) }}
</div>
<div class="sr__info">
<div class="sr__name">
{{ c.name }}
</div>
<div class="sr__sub">
{{ c.members }}
승률 {{ c.winRate }}
</div>
</div>
<span class="sr__val num">{{ SA.fmt(c.rp) }}<span class="sr__unit">RP</span></span>
<span class="sr__val num">{{ c.rp }}<span class="sr__unit">RP</span></span>
</button>
</template>
<!-- 클랜 랭킹 -->
<template v-if="isClan">
<!-- 공식 클랜 주지표: 누적점수 -->
<template v-if="isOfficialClan">
<button
v-for="(c, i) in clanList"
:key="c.name"
v-for="(c, i) in rows"
:key="c.rankNo"
class="sr__row"
:class="{ 'sr__row--alt': i % 2 === 1 }"
@click="openClan(c.name)"
@@ -190,23 +214,74 @@ function openClan(name: string) {
<span
class="sr__no num"
:class="{ 'sr__no--top': i < 3 }"
>{{ i + 1 }}</span>
<div class="sr__clantag num">
{{ c.tag }}
>{{ c.rankNo }}</span>
<span
v-if="c.clanMark"
class="sr__mark"
>
<img
v-for="(src, idx) in c.clanMark.split('|')"
:key="idx"
:src="src"
alt="클랜마크"
>
</span>
<div
v-else
class="sr__clantag num"
>
{{ c.name.slice(0, 3) }}
</div>
<div class="sr__info">
<div class="sr__name-line">
<span class="sr__name">{{ c.name }}</span>
<span
v-if="cat === '공식 클랜'"
class="sr__cert"
>인증</span>
<div class="sr__name">
{{ c.name }}
</div>
<div class="sr__sub">
{{ c.members }}
마스터 {{ c.master }}
</div>
</div>
<span class="sr__val num">{{ c.score }}<span class="sr__unit"></span></span>
</button>
</template>
<!-- 클랜 (일반) 점수 없음, 마스터 표시 -->
<template v-if="isClan">
<button
v-for="(c, i) in rows"
:key="c.rankNo"
class="sr__row"
:class="{ 'sr__row--alt': i % 2 === 1 }"
@click="openClan(c.name)"
>
<span
class="sr__no num"
:class="{ 'sr__no--top': i < 3 }"
>{{ c.rankNo }}</span>
<span
v-if="c.clanMark"
class="sr__mark"
>
<img
v-for="(src, idx) in c.clanMark.split('|')"
:key="idx"
:src="src"
alt="클랜마크"
>
</span>
<div
v-else
class="sr__clantag num"
>
{{ c.name.slice(0, 3) }}
</div>
<div class="sr__info">
<div class="sr__name">
{{ c.name }}
</div>
<div class="sr__sub">
마스터 {{ c.master }}
</div>
</div>
<span class="sr__val num">{{ SA.fmt(c.rp) }}<span class="sr__unit">RP</span></span>
</button>
</template>
</UiPanel>
@@ -268,6 +343,12 @@ function openClan(name: string) {
background: var(--point);
color: var(--point-ink);
}
.sr__empty {
padding: 32px 14px;
text-align: center;
font-size: 0.8125rem;
color: var(--t3);
}
.sr__row {
display: flex;
align-items: center;
@@ -295,17 +376,26 @@ function openClan(name: string) {
.sr__no--top {
color: var(--point);
}
.sr__tier {
.sr__img {
width: 28px;
height: 28px;
border-radius: var(--r-xs);
display: grid;
place-items: center;
color: #fff;
font-weight: 800;
font-size: 0.78125rem;
object-fit: contain;
flex-shrink: 0;
}
/* 클랜마크 — 배경(0_)+심볼(1_) 2개 이미지를 겹쳐서 표시 */
.sr__mark {
position: relative;
width: 28px;
height: 28px;
flex-shrink: 0;
}
.sr__mark img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
}
.sr__clantag {
width: 28px;
height: 28px;
@@ -322,11 +412,6 @@ function openClan(name: string) {
flex: 1;
min-width: 0;
}
.sr__name-line {
display: flex;
align-items: center;
gap: 5px;
}
.sr__name {
font-size: 0.84375rem;
color: var(--ink);
@@ -334,25 +419,7 @@ function openClan(name: string) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex-shrink: 0;
max-width: 60%;
}
.sr__tag {
font-size: 0.65625rem;
color: var(--t3);
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sr__cert {
font-size: 0.5625rem;
font-weight: 700;
color: var(--point);
border: 1px solid var(--point);
border-radius: 3px;
padding: 0 4px;
line-height: 1.5;
max-width: 100%;
}
.sr__sub {
font-size: 0.65625rem;
+1 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
// 인기 검색 — 24시간 조회수 랭킹 + 추세(▲▼/NEW). sa-home.jsx SearchRank 포팅.
import { useRouter } from 'vue-router'
import { SA } from '@/shared/data/sa'
import { SA } from '@/shared/mock/sa'
import UiPanel from '@/shared/components/UiPanel.vue'
const router = useRouter()
+1 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
// 시즌 현황 — 진행률 + 시즌 맵(5v5/3v3). sa-home.jsx SeasonCard 포팅.
import { computed, ref } from 'vue'
import { SA } from '@/shared/data/sa'
import { SA } from '@/shared/mock/sa'
import UiPanel from '@/shared/components/UiPanel.vue'
const s = SA.season
+9 -2
View File
@@ -2,8 +2,8 @@
// 서든 LIVE — 치지직/숲/유튜브 방송. 플랫폼 필터 + 시청자 합계. sa-home.jsx StreamLive 포팅.
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { SA } from '@/shared/data/sa'
import type { StreamPlatform } from '@/shared/data/types'
import { SA } from '@/shared/mock/sa'
import type { StreamPlatform } from '@/shared/mock/types'
import UiPanel from '@/shared/components/UiPanel.vue'
import SegTabs from '@/shared/components/SegTabs.vue'
import ImageSlot from '@/shared/components/ImageSlot.vue'
@@ -220,4 +220,11 @@ function openStreamer(name: string) {
color: var(--t3);
flex-shrink: 0;
}
/* 반응형 — 좁은 화면: 방송 카드 1열로 전환 */
@media (max-width: 760px) {
.sl__grid {
grid-template-columns: 1fr;
}
}
</style>
+7 -2
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
// 클랜전 전적 — 소속 클랜별 행, 클릭 시 활동 기간 펼침. sa-profile-sections.jsx ClanRecords 포팅.
import { computed, ref } from 'vue'
import type { ClanRecord, Player } from '@/shared/data/types'
import type { ClanRecord, Player } from '@/shared/mock/types'
import UiPanel from '@/shared/components/UiPanel.vue'
import AppIcon from '@/shared/components/AppIcon.vue'
import WrCell from '@/shared/components/WrCell.vue'
@@ -75,7 +75,9 @@ function recStr(d: ClanRecord) {
stroke="var(--t3)"
:style="{ transform: open === i ? 'rotate(90deg)' : 'none', transition: 'transform .15s' }"
/>
<!-- 클랜 태그/이미지 넥슨 미제공으로 숨김
<span class="cr__tag num">{{ c.tag }}</span>
-->
<span class="cr__name">{{ c.clan }}</span>
<span
v-if="i === 0"
@@ -121,7 +123,10 @@ function recStr(d: ClanRecord) {
stroke="var(--t3)"
/>
<span class="cr__period-range num">{{ pd.from }} ~ {{ pd.to }}</span>
<span class="cr__period-months"> {{ pd.months }}개월</span>
<span
v-if="pd.months > 0"
class="cr__period-months"
> {{ pd.months }}개월</span>
</div>
</div>
</td>
+3 -7
View File
@@ -1,9 +1,8 @@
<script setup lang="ts">
// 자주 함께한 유저 — 함께한 전적/승률. sa-profile-sections.jsx Companions 포팅.
import { useRouter } from 'vue-router'
import { SA } from '@/shared/data/sa'
import { wrColor } from '@/shared/utils/stats'
import type { Player } from '@/shared/data/types'
import { SA } from '@/shared/mock/sa'
import type { Player } from '@/shared/mock/types'
import UiPanel from '@/shared/components/UiPanel.vue'
import RankBadge from '@/shared/components/RankBadge.vue'
import AppIcon from '@/shared/components/AppIcon.vue'
@@ -47,10 +46,7 @@ function open(f: Player) {
함께 {{ f.together.games }}<span class="cp__mid">·</span>
<b class="num cp__w">{{ f.together.w }}</b> <b class="num cp__l">{{ f.together.l }}</b>
<span class="cp__mid">·</span>
<span
class="num cp__wr"
:style="{ color: wrColor(togetherWr(f)) }"
>승률 {{ togetherWr(f) }}%</span>
<span class="num cp__wr">승률 {{ togetherWr(f) }}%</span>
</div>
</div>
<AppIcon
+1 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
// 맵별 전적 — 매치 많은 순. sa-profile-sections.jsx MapRecords 포팅.
import { computed, ref } from 'vue'
import type { MapRecord, Player } from '@/shared/data/types'
import type { MapRecord, Player } from '@/shared/mock/types'
import UiPanel from '@/shared/components/UiPanel.vue'
import WrCell from '@/shared/components/WrCell.vue'
+1 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
// 매치 기록 — 모드 필터 + 매치 행 목록. sa-profile-sections.jsx MatchHistory 포팅.
import { computed, ref, watch } from 'vue'
import type { Player } from '@/shared/data/types'
import type { Player } from '@/shared/mock/types'
import UiPanel from '@/shared/components/UiPanel.vue'
import MatchRow from './MatchRow.vue'
+74 -19
View File
@@ -1,7 +1,10 @@
<script setup lang="ts">
// 매치 1건 행 — 승/패 + 맵/모드 + KDA, 클릭 시 양 팀 상세. sa-profile-sections.jsx MatchRow 포팅.
// 상세(맵/참가자)는 클릭(더보기) 시 넥슨 match-detail API로 lazy 로드한다.
import { computed, ref } from 'vue'
import type { MatchRecord } from '@/shared/data/types'
import { useSaMatch, type SaParticipant } from '@/composables/useSaMatch'
import { makeRank } from '@/shared/utils/adapt'
import type { MatchRecord, RosterPlayer } from '@/shared/mock/types'
import KdaChip from '@/shared/components/KdaChip.vue'
import AppIcon from '@/shared/components/AppIcon.vue'
import TeamTable from './TeamTable.vue'
@@ -12,10 +15,47 @@ interface Props {
}
const props = defineProps<Props>()
const { getMatchDetail } = useSaMatch()
const open = ref(false)
const pct = computed(() => Math.round((props.m.k / (props.m.k + props.m.d)) * 100))
const winTeam = computed(() => (props.m.winSide === 'blue' ? props.m.teams.blue : props.m.teams.red))
const loseTeam = computed(() => (props.m.winSide === 'blue' ? props.m.teams.red : props.m.teams.blue))
const loading = ref(false)
const loaded = ref(false)
const mapName = ref(props.m.map)
const winTeam = ref<RosterPlayer[]>([])
const loseTeam = ref<RosterPlayer[]>([])
const pct = computed(() => {
const total = props.m.k + props.m.d
return total ? Math.round((props.m.k / total) * 100) : 0
})
// 매치 상세 참가자 → 기존 UI 로스터(RosterPlayer)
function toRoster(p: SaParticipant): RosterPlayer {
return {
rank: makeRank(p.seasonGrade),
name: p.userName,
isOwner: false,
k: p.kill,
d: p.death,
a: p.assist,
hs: p.headshot,
dmg: p.damage,
}
}
async function toggle() {
open.value = !open.value
// 처음 펼칠 때만 상세 1회 로드 (matchId 없으면 스킵)
if (!open.value || loaded.value || !props.m.matchId) return
loading.value = true
const detail = await getMatchDetail(props.m.matchId)
if (detail) {
mapName.value = detail.matchMap || props.m.map
winTeam.value = detail.participants.filter((x) => x.matchResult === '1').map(toRoster)
loseTeam.value = detail.participants.filter((x) => x.matchResult !== '1').map(toRoster)
}
loaded.value = true
loading.value = false
}
</script>
<template>
@@ -27,7 +67,7 @@ const loseTeam = computed(() => (props.m.winSide === 'blue' ? props.m.teams.red
class="mrow__head"
:class="{ 'mrow__head--open': open }"
:style="{ borderLeftColor: m.win ? 'var(--point)' : 'var(--negative)' }"
@click="open = !open"
@click="toggle"
>
<div class="mrow__result">
<span
@@ -37,7 +77,7 @@ const loseTeam = computed(() => (props.m.winSide === 'blue' ? props.m.teams.red
</div>
<div class="mrow__map">
<div class="mrow__mapname">
{{ m.map }}
{{ mapName || '—' }}
</div>
<div class="mrow__mode">
{{ m.mode }}
@@ -65,20 +105,28 @@ const loseTeam = computed(() => (props.m.winSide === 'blue' ? props.m.teams.red
v-if="open"
class="mrow__detail"
>
<div class="mrow__team">
<TeamTable
result="승리"
color="var(--point)"
:players="winTeam"
/>
</div>
<div class="mrow__team">
<TeamTable
result="패배"
color="var(--negative)"
:players="loseTeam"
/>
<div
v-if="loading"
class="mrow__loading"
>
상세 불러오는 ...
</div>
<template v-else>
<div class="mrow__team">
<TeamTable
result="승리"
color="var(--point)"
:players="winTeam"
/>
</div>
<div class="mrow__team">
<TeamTable
result="패배"
color="var(--negative)"
:players="loseTeam"
/>
</div>
</template>
</div>
</div>
</template>
@@ -161,4 +209,11 @@ const loseTeam = computed(() => (props.m.winSide === 'blue' ? props.m.teams.red
overflow: hidden;
background: var(--surf);
}
.mrow__loading {
grid-column: 1 / -1;
text-align: center;
padding: 14px 0;
font-size: 0.78125rem;
color: var(--t3);
}
</style>
+1 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
// 모드별 전적. sa-profile-sections.jsx ModeRecords 포팅.
import type { ModeRecord, Player } from '@/shared/data/types'
import type { ModeRecord, Player } from '@/shared/mock/types'
import UiPanel from '@/shared/components/UiPanel.vue'
import WrCell from '@/shared/components/WrCell.vue'
@@ -1,9 +1,8 @@
<script setup lang="ts">
// 프로필 요약 헤더 — 계급/이름/액션 + 승률·킬뎃 링 + 9종 지표. sa-profile-sections.jsx ProfileSummary 포팅.
import { computed, ref } from 'vue'
import { SA } from '@/shared/data/sa'
import { wrColor } from '@/shared/utils/stats'
import type { Player } from '@/shared/data/types'
import { SA } from '@/shared/mock/sa'
import type { Player } from '@/shared/mock/types'
import SegTabs from '@/shared/components/SegTabs.vue'
import RankBadge from '@/shared/components/RankBadge.vue'
import WinRing from '@/shared/components/WinRing.vue'
@@ -19,14 +18,14 @@ const selRank = computed(() => (scope.value === '통합' ? props.p.rankTotal : p
const selRankNo = computed(() => (scope.value === '통합' ? props.p.rankNoTotal : props.p.rankNoSeason))
const stats = computed(() => [
{ lab: '승률', val: props.p.wr + '%', col: wrColor(props.p.wr) },
{ lab: '승률', val: props.p.wr + '%', col: 'var(--ink)' },
{ lab: '킬뎃', val: SA.kdPct(props.p.kda) + '%', col: 'var(--point)' },
{ lab: '헤드샷', val: props.p.hs + '%', col: 'var(--ink)' },
{ lab: '라이플', val: props.p.rifle + '%', col: 'var(--ink)' },
{ lab: '스나', val: props.p.sniper + '%', col: 'var(--ink)' },
{ lab: '특수', val: props.p.special + '%', col: 'var(--ink)' },
{ lab: '랭킹', val: SA.fmt(selRankNo.value) + '위', col: 'var(--point)' },
{ lab: '매너', val: props.p.manner + '', col: 'var(--ink)' },
{ lab: '매너', val: props.p.mannerGrade ?? '-', col: 'var(--ink)' },
{ lab: '접속률', val: props.p.connRate + '%', col: 'var(--ink)' },
])
</script>
+4 -3
View File
@@ -1,8 +1,8 @@
<script setup lang="ts">
// 시즌별 랭크전 전적 — 시즌 선택(통산 집계 포함) · 솔로/파티. sa-profile-sections.jsx RankedSeasons 포팅.
import { computed, ref } from 'vue'
import { SA } from '@/shared/data/sa'
import type { Player, RankedRecord } from '@/shared/data/types'
import { SA } from '@/shared/mock/sa'
import type { Player, RankedRecord } from '@/shared/mock/types'
import UiPanel from '@/shared/components/UiPanel.vue'
import WrCell from '@/shared/components/WrCell.vue'
import TierChip from '@/shared/components/TierChip.vue'
@@ -120,6 +120,7 @@ function recStr(d: RankedRecord) {
v-else
:name="d.tier"
:div="d.div"
:image="d.tierImage"
/>
</td>
<td class="ta-r td-num">
@@ -127,7 +128,7 @@ function recStr(d: RankedRecord) {
v-if="isAll"
class="rs__dash"
></span>
<span v-else>{{ SA.fmt(d.points) }}<span class="rs__unit"> </span></span>
<span v-else>{{ SA.fmt(d.points) }}<span class="rs__unit"> RP</span></span>
</td>
<td class="ta-r td-num">
{{ d.matches }}
+1 -9
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
// 최근 전적 및 동향 — 최근 승률/평균 킬뎃 링 + 최근 경기 흐름. sa-profile-sections.jsx RecentTrend 포팅.
import { computed } from 'vue'
import type { Player } from '@/shared/data/types'
import type { Player } from '@/shared/mock/types'
import UiPanel from '@/shared/components/UiPanel.vue'
import WinRing from '@/shared/components/WinRing.vue'
@@ -56,9 +56,6 @@ const flow = computed(() => recent.value.slice().reverse())
{{ m.win ? '승' : '패' }}
</span>
</div>
<div class="rt__caption">
최신 경기 흐름
</div>
</UiPanel>
</template>
@@ -102,9 +99,4 @@ const flow = computed(() => recent.value.slice().reverse())
.rt__cell--l {
background: rgba(229, 72, 77, 0.9);
}
.rt__caption {
font-size: 0.65625rem;
color: var(--t3);
margin-top: 7px;
}
</style>
+2 -2
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
// 매치 상세 팀 테이블 — 승/패 팀 로스터. sa-profile-sections.jsx TeamTable 포팅.
import { SA } from '@/shared/data/sa'
import type { RosterPlayer } from '@/shared/data/types'
import { SA } from '@/shared/mock/sa'
import type { RosterPlayer } from '@/shared/mock/types'
import RankBadge from '@/shared/components/RankBadge.vue'
interface Props {
+1 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
// 최근 동향 추세 그래프 — 승률/킬뎃 시계열 (일별/주별). sa-profile-sections.jsx TrendChart 포팅.
import { computed, ref } from 'vue'
import type { Player, TrendPoint } from '@/shared/data/types'
import type { Player, TrendPoint } from '@/shared/mock/types'
import UiPanel from '@/shared/components/UiPanel.vue'
import SegTabs from '@/shared/components/SegTabs.vue'
@@ -1,7 +1,7 @@
<script setup lang="ts">
// 클랜 엠블럼 렌더러 — 도형(방패/원/육각/마름모) + 심볼 조합. sa-clan-data.jsx Emblem 포팅.
import { computed } from 'vue'
import { EMBLEM_SHAPES, shade, type ClanEmblem } from '@/shared/data/clan'
import { EMBLEM_SHAPES, shade, type ClanEmblem } from '@/shared/mock/clan'
interface Props {
cfg?: ClanEmblem
+15 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
// 계급(랭크) 뱃지 — 방패 + 계급명 + pip(계급 표식). sa-shared.jsx RankBadge 포팅.
import { computed } from 'vue'
import type { Rank } from '@/shared/data/types'
import type { Rank } from '@/shared/mock/types'
interface Props {
rank: Rank | null
@@ -45,7 +45,17 @@ const pips = computed<Pip[]>(() => {
class="rb"
:style="{ gap: showName ? '8px' : '0' }"
>
<!-- 넥슨 메타 계급 이미지가 있으면 이미지로 표시 -->
<img
v-if="rank.image"
:src="rank.image"
:width="s"
:height="s"
:alt="rank.name"
class="rb__img"
>
<svg
v-else
:width="s"
:height="s"
:viewBox="`0 0 ${s} ${s}`"
@@ -140,6 +150,10 @@ const pips = computed<Pip[]>(() => {
display: flex;
align-items: center;
}
.rb__img {
object-fit: contain;
flex-shrink: 0;
}
.rb-name {
line-height: 1.1;
}
@@ -1,7 +1,7 @@
<script setup lang="ts">
// 최근 검색 칩 — 클릭 시 프로필로 이동. sa-shared.jsx RecentChips 포팅.
import { useRouter } from 'vue-router'
import { SA } from '@/shared/data/sa'
import { SA } from '@/shared/mock/sa'
import AppIcon from './AppIcon.vue'
interface Props {
+36 -172
View File
@@ -1,11 +1,9 @@
<script setup lang="ts">
// 검색바 — 라이브 추천 드롭다운(최근검색/매칭). 선택 시 프로필 라우트로 이동.
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
// 검색바 — 닉네임 제출 시 백엔드에서 OUID를 조회하고 프로필 라우트로 이동.
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { SA } from '@/shared/data/sa'
import type { Player } from '@/shared/data/types'
import { useSaUser } from '@/composables/useSaUser'
import AppIcon from './AppIcon.vue'
import RankBadge from './RankBadge.vue'
interface Props {
big?: boolean
@@ -15,52 +13,31 @@ withDefaults(defineProps<Props>(), {
big: false,
placeholder: '닉네임으로 전적을 검색하세요',
})
const emit = defineEmits<{ (e: 'pick', player: Player): void }>()
const router = useRouter()
const { searchOuid, loading } = useSaUser()
const q = ref('')
const open = ref(false)
const root = ref<HTMLElement | null>(null)
const matches = computed<Player[]>(() => {
const term = q.value.trim().toLowerCase()
if (!term) return []
return SA.allPlayers.filter((p) => p.name.toLowerCase().includes(term)).slice(0, 6)
})
const recents = computed<Player[]>(() =>
SA.recentSearches
.map((n) => SA.allPlayers.find((p) => p.name === n))
.filter((p): p is Player => Boolean(p)),
)
const list = computed(() => (q.value.trim() ? matches.value : recents.value))
// 닉네임 검색 제출 — 백엔드 조회 후 프로필 페이지로 이동
async function submit() {
const name = q.value.trim()
// 빈 입력이거나 검색 중이면 무시
if (!name || loading.value) return
function pick(p: Player) {
emit('pick', p)
const res = await searchOuid(name)
// 미존재 등으로 결과가 없으면 아무것도 하지 않음(에러 알림은 인터셉터가 처리)
if (!res) return
router.push({ name: 'profile', params: { name: res.userName } })
q.value = ''
open.value = false
router.push({ name: 'profile', params: { name: p.name } })
}
function submit() {
const found = matches.value[0] || SA.allPlayers.find((p) => p.name === q.value.trim())
if (found) pick(found)
else if (q.value.trim()) {
const fallback = SA.allPlayers[1] // 데모: 미존재 시 임의 유저
if (fallback) pick(fallback)
}
}
function onDocPointer(e: PointerEvent) {
if (root.value && !root.value.contains(e.target as Node)) open.value = false
}
onMounted(() => document.addEventListener('pointerdown', onDocPointer, true))
onBeforeUnmount(() => document.removeEventListener('pointerdown', onDocPointer, true))
</script>
<template>
<div
ref="root"
<form
class="sb"
:class="{ 'sb--big': big, 'sb--open': open }"
:class="{ 'sb--big': big }"
@submit.prevent="submit"
>
<div class="sb__field">
<div class="sb__icon">
@@ -74,61 +51,16 @@ onBeforeUnmount(() => document.removeEventListener('pointerdown', onDocPointer,
v-model="q"
class="sb__input"
:placeholder="placeholder"
@focus="open = true"
@input="open = true"
@keydown.enter="submit"
>
<button
type="submit"
class="sb__btn num"
@click="submit"
:disabled="loading"
>
검색
</button>
</div>
<div
v-if="open"
class="sb__pop"
>
<div class="sb__pophead">
{{ q.trim() ? `"${q}" 검색 결과` : '최근 검색한 유저' }}
</div>
<button
v-for="p in list"
:key="p.name"
class="sb__row"
@click="pick(p)"
>
<RankBadge
:rank="p.rank"
:size="32"
/>
<div class="sb__info">
<div class="sb__line">
<span class="sb__nm">{{ p.name }}</span>
<span
v-if="p.clan"
class="sb__clan"
>{{ p.clan }}</span>
</div>
<div class="sb__sub">
{{ p.rank.name }} · Lv.{{ p.level }} · 승률 {{ p.wr }}%
</div>
</div>
<AppIcon
name="chevR"
:size="15"
stroke="var(--t3)"
/>
</button>
<div
v-if="q.trim() && matches.length === 0"
class="sb__empty"
>
일치하는 유저가 없어요. 닉네임을 확인해 주세요.
</div>
</div>
</div>
</form>
</template>
<style scoped>
@@ -139,9 +71,9 @@ onBeforeUnmount(() => document.removeEventListener('pointerdown', onDocPointer,
.sb__field {
display: flex;
align-items: center;
height: 44px;
height: 2.75rem;
background: var(--surf);
border: 1.5px solid var(--line2);
border: 0.09375rem solid var(--line2);
border-radius: var(--r-xs);
box-shadow: var(--sh-card);
transition:
@@ -150,21 +82,22 @@ onBeforeUnmount(() => document.removeEventListener('pointerdown', onDocPointer,
overflow: hidden;
}
.sb--big .sb__field {
height: 58px;
height: 3.625rem;
}
.sb--open .sb__field {
/* 포커스 시 강조 — 기존 sb--open 효과를 입력 포커스로 대체 */
.sb__field:focus-within {
border-color: var(--point);
box-shadow: var(--sh-pop);
}
.sb__icon {
padding-left: 13px;
padding-left: 0.8125rem;
color: var(--t3);
display: flex;
}
.sb--big .sb__icon {
padding-left: 18px;
padding-left: 1.125rem;
}
.sb--open .sb__icon {
.sb__field:focus-within .sb__icon {
color: var(--point);
}
.sb__input {
@@ -175,13 +108,13 @@ onBeforeUnmount(() => document.removeEventListener('pointerdown', onDocPointer,
color: #15171c;
font-family: var(--font-body);
font-size: 0.90625rem;
padding: 0 11px;
padding: 0 0.6875rem;
font-weight: 500;
min-width: 0;
}
.sb--big .sb__input {
font-size: 1.0625rem;
padding: 0 16px;
padding: 0 1rem;
}
.sb__btn {
height: 100%;
@@ -191,84 +124,15 @@ onBeforeUnmount(() => document.removeEventListener('pointerdown', onDocPointer,
background: var(--point);
font-weight: 700;
font-size: 0.8125rem;
padding: 0 17px;
padding: 0 1.0625rem;
letter-spacing: 0.5px;
}
.sb__btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.sb--big .sb__btn {
font-size: 0.9375rem;
padding: 0 26px;
}
.sb__pop {
position: absolute;
top: calc(100% + 8px);
left: 0;
right: 0;
background: var(--surf);
border: 1px solid var(--line);
border-radius: var(--r-xs);
box-shadow: 0 16px 40px rgba(20, 22, 28, 0.12);
padding: 7px;
z-index: 40;
overflow: hidden;
text-align: left;
}
.sb__pophead {
font-size: 0.6875rem;
font-weight: 700;
color: var(--t3);
padding: 6px 10px 8px;
letter-spacing: 0.3px;
}
.sb__row {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
border: none;
background: transparent;
padding: 8px 10px;
border-radius: var(--r-xs);
cursor: pointer;
text-align: left;
transition: background 0.12s;
}
.sb__row:hover {
background: #f5f6f8;
}
.sb__info {
flex: 1;
min-width: 0;
}
.sb__line {
display: flex;
align-items: baseline;
gap: 6px;
}
.sb__nm {
color: #15171c;
font-weight: 600;
font-size: 0.875rem;
flex-shrink: 0;
max-width: 62%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sb__clan {
font-size: 0.6875rem;
color: var(--t2);
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sb__sub {
font-size: 0.71875rem;
color: var(--t3);
}
.sb__empty {
padding: 14px 10px;
color: var(--t3);
font-size: 0.8125rem;
padding: 0 1.625rem;
}
</style>
+18 -3
View File
@@ -1,13 +1,15 @@
<script setup lang="ts">
// 티어 칩 — 마름모 색 표식 + 티어명(+디비전). sa-profile-sections.jsx TierChipS 포팅.
// 티어 칩 — 티어 이미지(넥슨 메타) 또는 마름모 색 표식 + 티어명(+디비전). sa-profile-sections.jsx TierChipS 포팅.
import { computed } from 'vue'
import { SA } from '@/shared/data/sa'
import { SA } from '@/shared/mock/sa'
interface Props {
name: string
div?: number | null
// 넥슨 티어 메타 이미지 URL (있으면 마름모 대신 이미지로 표시)
image?: string
}
const props = withDefaults(defineProps<Props>(), { div: null })
const props = withDefaults(defineProps<Props>(), { div: null, image: '' })
const col = computed(() => {
const t = (SA.tiers || []).find((x) => x.name === props.name)
@@ -17,7 +19,14 @@ const col = computed(() => {
<template>
<span class="tc">
<img
v-if="image"
:src="image"
:alt="name"
class="tc__img"
>
<span
v-else
class="tc__mark"
:style="{ background: col }"
/>
@@ -32,6 +41,12 @@ const col = computed(() => {
gap: 7px;
white-space: nowrap;
}
.tc__img {
width: 1.375rem;
height: 1.375rem;
object-fit: contain;
flex-shrink: 0;
}
.tc__mark {
width: 9px;
height: 9px;
+2 -3
View File
@@ -1,7 +1,6 @@
<script setup lang="ts">
// 승률 링 — 도넛 게이지 + 중앙 수치. sa-shared.jsx WinRing 포팅.
import { computed } from 'vue'
import { wrColor } from '@/shared/utils/stats'
interface Props {
wr: number
@@ -14,12 +13,12 @@ const props = withDefaults(defineProps<Props>(), {
size: 64,
sw: 6,
label: '승률',
color: '', // 빈 값이면 승률 기반 색상 자동 적용
color: '', // 빈 값이면 포인트색 사용 (승률 낮을 때 빨강 표시하지 않음)
})
const radius = computed(() => (props.size - props.sw) / 2)
const circ = computed(() => 2 * Math.PI * radius.value)
const col = computed(() => props.color || wrColor(props.wr))
const col = computed(() => props.color || 'var(--point)')
const dashOffset = computed(() => circ.value * (1 - props.wr / 100))
</script>
+5 -7
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
// 승률 셀 — 가로 막대 + 퍼센트. 표 안에서 승률 표현. sa-profile-sections.jsx WrCell 포팅.
// 승률 색상 분기(높음 초록/낮음 빨강)는 제거하고 중립색으로 표시한다.
import { computed } from 'vue'
import { wrColor } from '@/shared/utils/stats'
interface Props {
wr: number
@@ -10,7 +10,6 @@ interface Props {
const props = withDefaults(defineProps<Props>(), { width: 46 })
const fill = computed(() => Math.max(0.04, Math.min(1, (props.wr - 35) / (80 - 35))))
const col = computed(() => wrColor(props.wr))
</script>
<template>
@@ -21,13 +20,10 @@ const col = computed(() => wrColor(props.wr))
>
<span
class="wrc__fill"
:style="{ width: fill * 100 + '%', background: col }"
:style="{ width: fill * 100 + '%' }"
/>
</span>
<span
class="wrc__pct num"
:style="{ color: col }"
>{{ wr }}%</span>
<span class="wrc__pct num">{{ wr }}%</span>
</div>
</template>
@@ -49,10 +45,12 @@ const col = computed(() => wrColor(props.wr))
.wrc__fill {
display: block;
height: 100%;
background: var(--point);
}
.wrc__pct {
font-weight: 700;
min-width: 36px;
text-align: right;
color: var(--ink);
}
</style>
@@ -56,4 +56,17 @@ const links = ['이용약관', '개인정보처리방침', '공지사항', '문
color: var(--t3);
line-height: 1.7;
}
/* 반응형 — 좁은 화면(<600px)에서 세로 정렬, 카피라이트 좌측 정렬 (원본 .sg-footer @600) */
@media (max-width: 600px) {
.footer__inner {
flex-direction: column;
align-items: flex-start;
gap: 14px;
}
.footer__copy {
margin-left: 0;
text-align: left;
}
}
</style>
@@ -3,10 +3,14 @@
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AppLogo from '@/shared/components/AppLogo.vue'
import { useBreakpoint } from '@/composables/useBreakpoint'
const route = useRoute()
const router = useRouter()
// 반응형 — 모바일(<760px)에서 로고 크기 축소(22→19). CSS로 처리 불가한 prop 분기.
const { mobile } = useBreakpoint()
interface NavItem {
label: string
to: string
@@ -38,7 +42,7 @@ function go(to: string) {
to="/"
class="nav__logo"
>
<AppLogo :size="22" />
<AppLogo :size="mobile ? 19 : 22" />
</RouterLink>
<nav class="nav__menu">
<button
@@ -99,4 +103,23 @@ function go(to: string) {
color: var(--point);
border-bottom-color: var(--point);
}
/* 반응형 — 모바일(<760px)에서 nav 간격/항목 축소 및 가로 스크롤 허용 (로고 크기는 prop으로 분기) */
@media (max-width: 760px) {
.nav__logo {
flex-shrink: 0;
}
.nav__menu {
margin-left: 12px;
flex: 1 1 auto;
min-width: 0;
overflow-x: auto;
}
.nav__item {
padding: 0 11px;
font-size: 0.875rem;
white-space: nowrap;
flex-shrink: 0;
}
}
</style>

Some files were not shown because too many files have changed in this diff Show More