Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fc0352cfcb | |||
| 870615219b | |||
| 594bdf9f26 | |||
| 70d84a322d | |||
| c4a93db84b | |||
| b8d636ba9d |
+3
-3
@@ -6,10 +6,10 @@
|
||||
!.env.*.example
|
||||
|
||||
# ==========================================
|
||||
# 도커 영속 데이터
|
||||
# 도커 영속 데이터 (루트 한정 — src 내부 data 폴더는 무시하지 않음)
|
||||
# ==========================================
|
||||
data/
|
||||
!data/.gitkeep
|
||||
/data/
|
||||
!/data/.gitkeep
|
||||
db-data/
|
||||
redis-data/
|
||||
|
||||
|
||||
Generated
+92
-76
@@ -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",
|
||||
|
||||
@@ -11,6 +11,14 @@ 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 { SaSeasonModule } from './modules/sa-season/sa-season.module';
|
||||
import { SaMapModule } from './modules/sa-map/sa-map.module';
|
||||
import { NexonModule } from './shared/nexon/nexon.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -60,7 +68,15 @@ import { EventsModule } from './modules/events/events.module';
|
||||
}),
|
||||
HealthModule,
|
||||
UserModule,
|
||||
NexonModule,
|
||||
EventsModule,
|
||||
NewsModule,
|
||||
RankingModule,
|
||||
SaUserModule,
|
||||
SaMetaModule,
|
||||
SaMatchModule,
|
||||
SaSeasonModule,
|
||||
SaMapModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
|
||||
@@ -13,33 +13,52 @@ import { Response } from 'express';
|
||||
export class HttpExceptionFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(HttpExceptionFilter.name);
|
||||
|
||||
// HTTP 상태코드 → (에러코드, 메시지) 매핑. 숫자 키 룩업이라 enum 비교가 없다.
|
||||
private static readonly STATUS_MAP: Record<
|
||||
number,
|
||||
{ code: string; message: string }
|
||||
> = {
|
||||
[HttpStatus.UNAUTHORIZED]: {
|
||||
code: 'AUTH_001',
|
||||
message: '로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.',
|
||||
},
|
||||
[HttpStatus.FORBIDDEN]: {
|
||||
code: 'AUTH_003',
|
||||
message: '해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다.',
|
||||
},
|
||||
[HttpStatus.NOT_FOUND]: {
|
||||
code: 'RES_001',
|
||||
message: '요청하신 정보나 페이지를 찾을 수 없습니다.',
|
||||
},
|
||||
[HttpStatus.BAD_REQUEST]: {
|
||||
code: 'VAL_001',
|
||||
message:
|
||||
'입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)',
|
||||
},
|
||||
[HttpStatus.UNPROCESSABLE_ENTITY]: {
|
||||
code: 'VAL_001',
|
||||
message:
|
||||
'입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)',
|
||||
},
|
||||
};
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
|
||||
let status: number = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
let code = 'SYS_001';
|
||||
let message = '일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.';
|
||||
let message =
|
||||
'일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.';
|
||||
|
||||
if (exception instanceof HttpException) {
|
||||
status = exception.getStatus();
|
||||
const exceptionResponse = exception.getResponse();
|
||||
|
||||
if (status === HttpStatus.UNAUTHORIZED) {
|
||||
code = 'AUTH_001';
|
||||
message = '로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.';
|
||||
} else if (status === HttpStatus.FORBIDDEN) {
|
||||
code = 'AUTH_003';
|
||||
message = '해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다.';
|
||||
} else if (status === HttpStatus.NOT_FOUND) {
|
||||
code = 'RES_001';
|
||||
message = '요청하신 정보나 페이지를 찾을 수 없습니다.';
|
||||
} else if (
|
||||
status === HttpStatus.BAD_REQUEST ||
|
||||
status === HttpStatus.UNPROCESSABLE_ENTITY
|
||||
) {
|
||||
code = 'VAL_001';
|
||||
message = '입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)';
|
||||
const mapped = HttpExceptionFilter.STATUS_MAP[status];
|
||||
if (mapped) {
|
||||
code = mapped.code;
|
||||
message = mapped.message;
|
||||
} else {
|
||||
code = 'BIZ_001';
|
||||
message = '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.';
|
||||
@@ -50,7 +69,7 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
||||
exceptionResponse !== null &&
|
||||
'message' in exceptionResponse
|
||||
) {
|
||||
const responseMessage = (exceptionResponse as { message: unknown }).message;
|
||||
const responseMessage = exceptionResponse.message;
|
||||
if (typeof responseMessage === 'string') {
|
||||
message = responseMessage;
|
||||
}
|
||||
|
||||
@@ -13,18 +13,19 @@ export interface StandardResponse<T> {
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TransformInterceptor<T>
|
||||
implements NestInterceptor<T, StandardResponse<T>>
|
||||
{
|
||||
export class TransformInterceptor<T> implements NestInterceptor<
|
||||
T,
|
||||
StandardResponse<T>
|
||||
> {
|
||||
intercept(
|
||||
_context: ExecutionContext,
|
||||
next: CallHandler,
|
||||
): Observable<StandardResponse<T>> {
|
||||
// 이미 Http 응답객체인 경우 등 예외처리가 필요할 수 있으나 기본적으로 data 래핑
|
||||
return next.handle().pipe(
|
||||
map((data) => ({
|
||||
map((data: T) => ({
|
||||
success: true,
|
||||
data: data !== undefined ? data : null,
|
||||
data: data ?? (null as T),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
+14
-6
@@ -5,7 +5,11 @@ import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import { WinstonModule } from 'nest-winston';
|
||||
import helmet from 'helmet';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import type { Request as ExpressRequest, Response as ExpressResponse } from 'express';
|
||||
import type {
|
||||
Application,
|
||||
Request as ExpressRequest,
|
||||
Response as ExpressResponse,
|
||||
} from 'express';
|
||||
|
||||
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
|
||||
@@ -17,7 +21,7 @@ async function bootstrap() {
|
||||
});
|
||||
|
||||
// 리버스 프록시(Nginx, Docker 배포 환경 등) 뒤에서 올바른 클라이언트 IP 식별 지원을 위해 설정합니다.
|
||||
const expressApp = app.getHttpAdapter().getInstance();
|
||||
const expressApp = app.getHttpAdapter().getInstance() as Application;
|
||||
expressApp.set('trust proxy', 1);
|
||||
|
||||
// 글로벌 prefix — 모든 라우트가 /api/* 로 정규화됨 (Flutter, Web 양쪽 baseURL과 일치)
|
||||
@@ -66,8 +70,9 @@ async function bootstrap() {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'SYS_001',
|
||||
message: '일시적인 시스템 오류가 발생했습니다. (요청 한도 초과) 잠시 후 다시 시도해 주세요.', // 글로벌 통합 에러 포맷
|
||||
}
|
||||
message:
|
||||
'일시적인 시스템 오류가 발생했습니다. (요청 한도 초과) 잠시 후 다시 시도해 주세요.', // 글로벌 통합 에러 포맷
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -98,6 +103,9 @@ async function bootstrap() {
|
||||
const port = Number(process.env.PORT ?? 3000);
|
||||
await app.listen(port, '0.0.0.0');
|
||||
Logger.log(`🚀 Backend running on http://localhost:${port}/api`, 'Bootstrap');
|
||||
Logger.log(`📘 Swagger docs at http://localhost:${port}/api-docs`, 'Bootstrap');
|
||||
Logger.log(
|
||||
`📘 Swagger docs at http://localhost:${port}/api-docs`,
|
||||
'Bootstrap',
|
||||
);
|
||||
}
|
||||
bootstrap();
|
||||
void bootstrap();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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,43 @@
|
||||
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,398 @@
|
||||
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,11 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
// 맵 등록 요청 DTO (관리자)
|
||||
export class CreateMapDto {
|
||||
@ApiProperty({ description: '맵 이름', example: '제3보급창고' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '맵 이름을 입력해 주세요.' })
|
||||
@MaxLength(50)
|
||||
name!: string;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
// 맵 응답 DTO
|
||||
export class MapResponseDto {
|
||||
@ApiProperty({ example: 1 })
|
||||
id!: number;
|
||||
|
||||
@ApiProperty({ example: '제3보급창고' })
|
||||
name!: string;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
|
||||
import { CreateMapDto } from './create-map.dto';
|
||||
|
||||
// 맵 수정 요청 DTO (관리자) — 모든 필드 선택적
|
||||
export class UpdateMapDto extends PartialType(CreateMapDto) {}
|
||||
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
// 서든어택 맵 — 관리자가 이름으로 등록. (5v5/3v3 구분은 시즌 설정에서 토글로 정한다)
|
||||
@Entity('sa_maps')
|
||||
@Index(['name'], { unique: true })
|
||||
export class SaMapEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number;
|
||||
|
||||
// 맵 이름 (매치 상세의 matchMap 과 동일 문자열이어야 전적 연동 가능)
|
||||
@Column({ type: 'varchar', length: 50 })
|
||||
name!: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { SaMapService } from './sa-map.service';
|
||||
import { CreateMapDto } from './dto/create-map.dto';
|
||||
import { UpdateMapDto } from './dto/update-map.dto';
|
||||
import { MapResponseDto } from './dto/map-response.dto';
|
||||
|
||||
@ApiTags('SA Map')
|
||||
@Controller('sa-maps')
|
||||
export class SaMapController {
|
||||
constructor(private readonly mapService: SaMapService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: '맵 목록 조회',
|
||||
description: '등록된 서든어택 맵 목록을 모드→이름 순으로 반환합니다.',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '맵 목록', type: [MapResponseDto] })
|
||||
findAll(): Promise<MapResponseDto[]> {
|
||||
return this.mapService.findAll();
|
||||
}
|
||||
|
||||
// ── 관리자 전용 ─────────────────────────────────────────────
|
||||
// TODO: 인증 도입 시 AdminGuard 적용 (현재는 가드 미적용)
|
||||
@Post()
|
||||
@ApiOperation({ summary: '[관리자] 맵 등록' })
|
||||
@ApiResponse({ status: 201, description: '등록된 맵', type: MapResponseDto })
|
||||
create(@Body() dto: CreateMapDto): Promise<MapResponseDto> {
|
||||
return this.mapService.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: '[관리자] 맵 수정' })
|
||||
@ApiResponse({ status: 200, description: '수정된 맵', type: MapResponseDto })
|
||||
update(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() dto: UpdateMapDto,
|
||||
): Promise<MapResponseDto> {
|
||||
return this.mapService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: '[관리자] 맵 삭제' })
|
||||
@ApiResponse({ status: 200, description: '삭제 완료' })
|
||||
remove(@Param('id', ParseIntPipe) id: number): Promise<void> {
|
||||
return this.mapService.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { SaMapEntity } from './entities/sa-map.entity';
|
||||
import { SaMapService } from './sa-map.service';
|
||||
import { SaMapController } from './sa-map.controller';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([SaMapEntity])],
|
||||
controllers: [SaMapController],
|
||||
providers: [SaMapService],
|
||||
exports: [SaMapService],
|
||||
})
|
||||
export class SaMapModule {}
|
||||
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { QueryFailedError, Repository } from 'typeorm';
|
||||
|
||||
import { SaMapEntity } from './entities/sa-map.entity';
|
||||
import { CreateMapDto } from './dto/create-map.dto';
|
||||
import { UpdateMapDto } from './dto/update-map.dto';
|
||||
import { MapResponseDto } from './dto/map-response.dto';
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// 서든어택 맵 — 관리자 등록 CRUD. 시즌 설정에서 모드별 토글 목록으로 사용된다.
|
||||
// ────────────────────────────────────────────────────────────
|
||||
@Injectable()
|
||||
export class SaMapService {
|
||||
constructor(
|
||||
@InjectRepository(SaMapEntity)
|
||||
private readonly repo: Repository<SaMapEntity>,
|
||||
) {}
|
||||
|
||||
/** 맵 목록 — 이름 순. */
|
||||
async findAll(): Promise<MapResponseDto[]> {
|
||||
const rows = await this.repo.find({ order: { name: 'ASC' } });
|
||||
return rows.map((r) => this.toDto(r));
|
||||
}
|
||||
|
||||
async create(dto: CreateMapDto): Promise<MapResponseDto> {
|
||||
try {
|
||||
const saved = await this.repo.save(this.repo.create(dto));
|
||||
return this.toDto(saved);
|
||||
} catch (e) {
|
||||
// 이름 unique 충돌
|
||||
if (e instanceof QueryFailedError) {
|
||||
throw new ConflictException('이미 등록된 맵 이름입니다.');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: number, dto: UpdateMapDto): Promise<MapResponseDto> {
|
||||
const map = await this.repo.findOne({ where: { id } });
|
||||
if (!map) {
|
||||
throw new NotFoundException('요청하신 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
Object.assign(map, dto);
|
||||
try {
|
||||
return this.toDto(await this.repo.save(map));
|
||||
} catch (e) {
|
||||
if (e instanceof QueryFailedError) {
|
||||
throw new ConflictException('이미 등록된 맵 이름입니다.');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async remove(id: number): Promise<void> {
|
||||
const res = await this.repo.delete({ id });
|
||||
if (!res.affected) {
|
||||
throw new NotFoundException('요청하신 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
private toDto(r: SaMapEntity): MapResponseDto {
|
||||
return { id: r.id, name: r.name };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
// [개발 전용] 매치 데이터 적재 현황 DTO — 테이블별 행 수
|
||||
export class DevStatsDto {
|
||||
@ApiProperty({
|
||||
description: '등록 유저 수 (sa_users) — 상세 백필 캡은 유저당 적용',
|
||||
example: 2,
|
||||
})
|
||||
users!: number;
|
||||
|
||||
@ApiProperty({ description: '매치 목록 행 수 (sa_matches)', example: 1200 })
|
||||
matches!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '상세 헤더 행 수 (sa_match_details = 맵 적재 완료 매치)',
|
||||
example: 340,
|
||||
})
|
||||
details!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '참가자 행 수 (sa_match_participants)',
|
||||
example: 3400,
|
||||
})
|
||||
participants!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '상세 적재 실패 누적 행 수 (sa_match_detail_failures)',
|
||||
example: 5,
|
||||
})
|
||||
failures!: number;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayNotEmpty,
|
||||
IsArray,
|
||||
IsString,
|
||||
} from 'class-validator';
|
||||
|
||||
// 매치 맵 일괄 확보 요청 DTO — 화면에 보이는 매치들의 상세를 동기 적재하기 위해 사용
|
||||
export class EnsureMapsDto {
|
||||
@ApiProperty({
|
||||
description: '맵 이름을 확보할 매치 ID 목록',
|
||||
type: [String],
|
||||
example: ['match_id_1', 'match_id_2'],
|
||||
})
|
||||
@IsArray()
|
||||
@ArrayNotEmpty({ message: 'matchIds를 1개 이상 입력해 주세요.' })
|
||||
// 화면에 보이는 매치만 대상이므로 과도한 요청을 막기 위해 상한을 둔다
|
||||
@ArrayMaxSize(100, {
|
||||
message: 'matchIds는 한 번에 최대 100개까지 요청할 수 있습니다.',
|
||||
})
|
||||
@IsString({ each: true })
|
||||
matchIds!: string[];
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
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,38 @@
|
||||
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;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'매치 맵 — 상세 캐시 보유 시에만 값 존재, 미보유면 빈 문자열(백그라운드 백필)',
|
||||
example: '제3보급창고',
|
||||
})
|
||||
matchMap!: string;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
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,22 @@
|
||||
import { Column, Entity, PrimaryColumn, UpdateDateColumn } from 'typeorm';
|
||||
|
||||
// 매치 상세 적재 실패 기록 — 넥슨이 상세를 끝내 제공하지 않는 매치(삭제/만료 등)의
|
||||
// 무한 재시도를 막기 위해 실패 횟수를 누적한다. 한도 초과 시 백필 대상에서 제외.
|
||||
// 적재에 성공하면 행을 삭제해 기록을 정리한다.
|
||||
@Entity('sa_match_detail_failures')
|
||||
export class SaMatchDetailFailureEntity {
|
||||
@PrimaryColumn({ type: 'varchar', length: 40 })
|
||||
matchId!: string;
|
||||
|
||||
// 누적 실패 횟수
|
||||
@Column({ type: 'int', default: 0 })
|
||||
failCount!: number;
|
||||
|
||||
// 마지막 실패 사유 (로그 추적용)
|
||||
@Column({ type: 'varchar', length: 200, default: '' })
|
||||
lastError!: string;
|
||||
|
||||
// 마지막 시도 시각
|
||||
@UpdateDateColumn()
|
||||
lastTriedAt!: Date;
|
||||
}
|
||||
@@ -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,43 @@
|
||||
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,94 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Post,
|
||||
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';
|
||||
import { EnsureMapsDto } from './dto/ensure-maps.dto';
|
||||
import { DevStatsDto } from './dto/dev-stats.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,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('details/maps')
|
||||
@ApiOperation({
|
||||
summary: '매치 맵 일괄 확보 (보이는 매치)',
|
||||
description:
|
||||
'화면에 표시되는 매치들의 맵 이름을 반환합니다. 캐시 미보유 매치는 상세를 동기로 적재한 뒤 반환하므로, 응답 시점에 보이는 매치는 맵이 채워져 있습니다.',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: 'matchId → 맵 이름 맵 (확보 실패 매치는 빈 문자열)',
|
||||
})
|
||||
ensureMaps(@Body() dto: EnsureMapsDto): Promise<Record<string, string>> {
|
||||
return this.saMatchService.ensureDetailMaps(dto.matchIds);
|
||||
}
|
||||
|
||||
@Post('dev/sync')
|
||||
@ApiOperation({
|
||||
summary: '[개발 전용] 매치 동기화 수동 트리거',
|
||||
description:
|
||||
'야간 크론(전 유저 목록 동기화 + 상세 백필)을 즉시 백그라운드로 실행합니다. 운영 환경에서는 비활성화됩니다.',
|
||||
})
|
||||
@ApiResponse({ status: 201, description: '트리거 결과' })
|
||||
devSync(): { triggered: boolean } {
|
||||
// 운영에서는 노출하지 않는다 (쿼터/부하 보호)
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
throw new NotFoundException('요청하신 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
// 응답을 막지 않도록 백그라운드 실행 (fire-and-forget)
|
||||
void this.saMatchService.handleDailyMatchSync();
|
||||
return { triggered: true };
|
||||
}
|
||||
|
||||
@Get('dev/stats')
|
||||
@ApiOperation({
|
||||
summary: '[개발 전용] 매치 데이터 적재 현황',
|
||||
description:
|
||||
'매치 목록/상세/참가자/실패 테이블의 행 수를 반환합니다. dev/sync 진행 확인용. 운영 환경에서는 비활성화됩니다.',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '적재 현황', type: DevStatsDto })
|
||||
devStats(): Promise<DevStatsDto> {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
throw new NotFoundException('요청하신 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
return this.saMatchService.getDevStats();
|
||||
}
|
||||
|
||||
@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,28 @@
|
||||
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 { SaMatchDetailFailureEntity } from './entities/sa-match-detail-failure.entity';
|
||||
import { SaUserEntity } from '../sa-user/entities/sa-user.entity';
|
||||
|
||||
// 매치 모듈 — 목록(기본 누적 + 일일 크론) + 상세(더보기 lazy). 넥슨 호출은 전역 NexonService 사용.
|
||||
// 크론에서 등록 유저(sa_users)를 읽기 위해 SaUserEntity 를 함께 등록한다.
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
SaMatchEntity,
|
||||
SaMatchDetailEntity,
|
||||
SaMatchParticipantEntity,
|
||||
SaMatchDetailFailureEntity,
|
||||
SaUserEntity,
|
||||
]),
|
||||
],
|
||||
controllers: [SaMatchController],
|
||||
providers: [SaMatchService],
|
||||
exports: [SaMatchService],
|
||||
})
|
||||
export class SaMatchModule {}
|
||||
@@ -0,0 +1,451 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { In, MoreThanOrEqual, Repository } from 'typeorm';
|
||||
|
||||
import { NexonService } from '../../shared/nexon/nexon.service';
|
||||
import { runWithConcurrency } from '../../shared/utils/concurrent';
|
||||
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';
|
||||
import { SaMatchDetailFailureEntity } from './entities/sa-match-detail-failure.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 = [
|
||||
'퀵매치 클랜전',
|
||||
'클랜 랭크전',
|
||||
'랭크전 솔로',
|
||||
'랭크전 파티',
|
||||
'토너먼트',
|
||||
];
|
||||
// 크론 백필 유저당 한도 — 개발은 넥슨 일일 쿼터(1,000건/일) 보호를 위해 20건, 운영은 무제한(null)
|
||||
private static readonly CRON_PER_USER_BACKFILL_LIMIT: number | null =
|
||||
process.env.NODE_ENV === 'production' ? null : 20;
|
||||
// 상세 적재 실패 허용 횟수 — 초과한 매치(넥슨 미제공 추정)는 백필 대상에서 영구 제외
|
||||
private static readonly MAX_DETAIL_FAILURES = 3;
|
||||
// 상세 백필 동시 워커 수 — env MATCH_BACKFILL_CONCURRENCY (기본: 운영 16 / 개발 2)
|
||||
// 각 호출은 NexonService 전역 rate limiter 를 통과하므로 동시성을 높여도 초당 한도는 안전하게 유지된다.
|
||||
private static readonly BACKFILL_CONCURRENCY = ((): number => {
|
||||
const raw = Number(process.env.MATCH_BACKFILL_CONCURRENCY);
|
||||
if (Number.isFinite(raw) && raw > 0) return Math.floor(raw);
|
||||
return process.env.NODE_ENV === 'production' ? 16 : 2;
|
||||
})();
|
||||
|
||||
// 백필 중복 실행 방지 + 진행 중 적재 공유 — matchId → 적재 Promise
|
||||
// 같은 매치를 동시에 요청하면 새 호출 대신 진행 중인 Promise 를 await 한다(중복 넥슨 호출 방지 + 정확성).
|
||||
private readonly backfillInFlight = new Map<string, Promise<void>>();
|
||||
|
||||
constructor(
|
||||
@InjectRepository(SaMatchEntity)
|
||||
private readonly matchRepo: Repository<SaMatchEntity>,
|
||||
@InjectRepository(SaMatchDetailEntity)
|
||||
private readonly detailRepo: Repository<SaMatchDetailEntity>,
|
||||
@InjectRepository(SaMatchParticipantEntity)
|
||||
private readonly participantRepo: Repository<SaMatchParticipantEntity>,
|
||||
@InjectRepository(SaMatchDetailFailureEntity)
|
||||
private readonly failureRepo: Repository<SaMatchDetailFailureEntity>,
|
||||
@InjectRepository(SaUserEntity)
|
||||
private readonly userRepo: Repository<SaUserEntity>,
|
||||
private readonly nexon: NexonService,
|
||||
) {}
|
||||
|
||||
/** [개발 전용] 매치 데이터 적재 현황 — 테이블별 행 수. */
|
||||
async getDevStats(): Promise<{
|
||||
users: number;
|
||||
matches: number;
|
||||
details: number;
|
||||
participants: number;
|
||||
failures: number;
|
||||
}> {
|
||||
const [users, matches, details, participants, failures] = await Promise.all(
|
||||
[
|
||||
this.userRepo.count(),
|
||||
this.matchRepo.count(),
|
||||
this.detailRepo.count(),
|
||||
this.participantRepo.count(),
|
||||
this.failureRepo.count(),
|
||||
],
|
||||
);
|
||||
return { users, matches, details, participants, failures };
|
||||
}
|
||||
|
||||
/** 매치 목록 조회 — 최신분 누적 후 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' },
|
||||
});
|
||||
|
||||
// 상세 캐시(sa_match_details)에 있는 맵 이름 매핑 — 없으면 빈 문자열
|
||||
const ids = saved.map((m) => m.matchId);
|
||||
const details = ids.length
|
||||
? await this.detailRepo.find({
|
||||
where: { matchId: In(ids) },
|
||||
select: ['matchId', 'matchMap'],
|
||||
})
|
||||
: [];
|
||||
const mapById = new Map(details.map((d) => [d.matchId, d.matchMap]));
|
||||
|
||||
// 상세(맵) 적재는 조회 시 자동 백필하지 않는다.
|
||||
// - 보이는 매치는 프론트가 ensureDetailMaps(경로 ②)로 동기 확보(— 노출 방지)
|
||||
// - 그 외 미보유분은 야간 크론(handleDailyMatchSync)이 전량 적재
|
||||
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,
|
||||
matchMap: mapById.get(m.matchId) ?? '',
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 주어진 매치들의 맵 이름을 확보해 반환한다. (화면에 보이는 매치 전용 — 동기)
|
||||
* - 캐시 보유분은 그대로 사용하고, 미보유분은 상세를 **기다렸다가** 적재한다.
|
||||
* - 실패 한도 초과 매치(넥슨 영구 미제공 추정)는 호출하지 않고 빈 문자열로 둔다.
|
||||
* - 반환: matchId → 맵 이름(확보 실패 시 '')
|
||||
*/
|
||||
async ensureDetailMaps(matchIds: string[]): Promise<Record<string, string>> {
|
||||
const result: Record<string, string> = {};
|
||||
const ids = Array.from(new Set(matchIds.filter((id) => id)));
|
||||
if (!ids.length) return result;
|
||||
|
||||
// 1) 이미 캐시된 헤더의 맵 사용
|
||||
const cached = await this.detailRepo.find({
|
||||
where: { matchId: In(ids) },
|
||||
select: ['matchId', 'matchMap'],
|
||||
});
|
||||
cached.forEach((c) => (result[c.matchId] = c.matchMap));
|
||||
const cachedSet = new Set(cached.map((c) => c.matchId));
|
||||
|
||||
// 2) 미보유분 중 실패 한도 초과 매치는 제외(쿼터 낭비 방지)
|
||||
const missing = ids.filter((id) => !cachedSet.has(id));
|
||||
if (missing.length) {
|
||||
const dead = await this.failureRepo.find({
|
||||
where: {
|
||||
matchId: In(missing),
|
||||
failCount: MoreThanOrEqual(SaMatchService.MAX_DETAIL_FAILURES),
|
||||
},
|
||||
select: ['matchId'],
|
||||
});
|
||||
const deadSet = new Set(dead.map((f) => f.matchId));
|
||||
const toFetch = missing.filter((id) => !deadSet.has(id));
|
||||
|
||||
// 3) 동기 적재 — 보이는 매치라 응답 전에 끝까지 기다린다(동시 워커 풀 사용)
|
||||
await this.runBackfill(toFetch);
|
||||
|
||||
// 4) 적재 결과 재조회 (동시 적재로 채워진 분까지 모두 반영)
|
||||
const filled = await this.detailRepo.find({
|
||||
where: { matchId: In(missing) },
|
||||
select: ['matchId', 'matchMap'],
|
||||
});
|
||||
filled.forEach((c) => (result[c.matchId] = c.matchMap));
|
||||
}
|
||||
|
||||
// 끝내 확보하지 못한 매치는 빈 문자열로 명시
|
||||
for (const id of ids) if (!(id in result)) result[id] = '';
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 매치 상세를 동시 워커 풀(BACKFILL_CONCURRENCY)로 적재한다. (크론·동기확보 공통)
|
||||
* - 진행 중인 매치는 건너뛰지 않고 그 Promise 를 await 한다 → 동기 확보(②) 시 누락 없음
|
||||
* - 개별 실패는 격리(실패 기록 누적)되어 전체 흐름을 끊지 않는다
|
||||
* - 각 호출은 NexonService 전역 rate limiter 를 통과하므로 초당 한도를 넘지 않는다
|
||||
*/
|
||||
private async runBackfill(matchIds: string[]): Promise<void> {
|
||||
const targets = matchIds.filter((id) => id);
|
||||
if (!targets.length) return;
|
||||
|
||||
await runWithConcurrency(
|
||||
targets,
|
||||
SaMatchService.BACKFILL_CONCURRENCY,
|
||||
(id) => this.fetchDetailOnce(id),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 매치 상세를 1회만 적재한다. 같은 매치를 동시에 요청하면 진행 중인 Promise 를 공유한다.
|
||||
* 실패는 격리(실패 기록 누적)하여 호출 측 흐름을 끊지 않는다.
|
||||
*/
|
||||
private fetchDetailOnce(matchId: string): Promise<void> {
|
||||
const existing = this.backfillInFlight.get(matchId);
|
||||
if (existing) return existing;
|
||||
|
||||
const task = (async () => {
|
||||
try {
|
||||
await this.getMatchDetail(matchId);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`매치 상세 백필 실패(matchId=${matchId}): ${(error as Error).message}`,
|
||||
);
|
||||
await this.recordDetailFailure(matchId, (error as Error).message);
|
||||
} finally {
|
||||
this.backfillInFlight.delete(matchId);
|
||||
}
|
||||
})();
|
||||
this.backfillInFlight.set(matchId, task);
|
||||
return task;
|
||||
}
|
||||
|
||||
/** 넥슨 매치 목록 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);
|
||||
}
|
||||
}
|
||||
await this.backfillMissingDetails();
|
||||
this.logger.log('일일 매치 동기화 완료');
|
||||
}
|
||||
|
||||
/**
|
||||
* 크론 백필 대상 matchId 목록 — 상세 미보유 매치를 최신순으로 수집.
|
||||
* 실패 한도 초과 매치는 제외하고, 개발 환경은 넥슨 일일 쿼터 보호를 위해
|
||||
* 유저당 한도(CRON_PER_USER_BACKFILL_LIMIT)를 적용한다.
|
||||
*/
|
||||
private async getBackfillTargets(): Promise<string[]> {
|
||||
const rows = await this.matchRepo
|
||||
.createQueryBuilder('m')
|
||||
.leftJoin(SaMatchDetailEntity, 'd', 'd.matchId = m.matchId')
|
||||
.leftJoin(SaMatchDetailFailureEntity, 'f', 'f.matchId = m.matchId')
|
||||
.where('d.matchId IS NULL')
|
||||
.andWhere('(f.matchId IS NULL OR f.failCount < :maxFail)', {
|
||||
maxFail: SaMatchService.MAX_DETAIL_FAILURES,
|
||||
})
|
||||
.select('m.matchId', 'matchId')
|
||||
.addSelect('m.ouid', 'ouid')
|
||||
.orderBy('m.dateMatch', 'DESC')
|
||||
.getRawMany<{ matchId: string; ouid: string }>();
|
||||
|
||||
const perUserLimit = SaMatchService.CRON_PER_USER_BACKFILL_LIMIT;
|
||||
const seen = new Set<string>();
|
||||
const countByUser = new Map<string, number>();
|
||||
const targets: string[] = [];
|
||||
for (const { matchId, ouid } of rows) {
|
||||
// 같은 매치를 여러 유저가 공유할 수 있으므로 matchId 기준 중복 제거
|
||||
if (seen.has(matchId)) continue;
|
||||
if (perUserLimit !== null) {
|
||||
const used = countByUser.get(ouid) ?? 0;
|
||||
if (used >= perUserLimit) continue;
|
||||
countByUser.set(ouid, used + 1);
|
||||
}
|
||||
seen.add(matchId);
|
||||
targets.push(matchId);
|
||||
}
|
||||
return targets;
|
||||
}
|
||||
|
||||
/** 상세(맵) 미보유 매치를 최신순으로 백필 — 크론 전용. (개발: 유저당 한도, 운영: 전량) */
|
||||
private async backfillMissingDetails(): Promise<void> {
|
||||
const targets = await this.getBackfillTargets();
|
||||
if (!targets.length) return;
|
||||
|
||||
const perUserLimit = SaMatchService.CRON_PER_USER_BACKFILL_LIMIT;
|
||||
this.logger.log(
|
||||
`매치 상세 백필 시작 (${targets.length}건${perUserLimit !== null ? `, 유저당 ${perUserLimit}건 한도` : ''}, 동시 ${SaMatchService.BACKFILL_CONCURRENCY})`,
|
||||
);
|
||||
// 온디맨드와 동일한 동시 워커 풀로 적재(중복 매치는 backfillInFlight 가 가드)
|
||||
await this.runBackfill(targets);
|
||||
}
|
||||
|
||||
/** 상세 적재 실패 기록 — 횟수를 누적해 한도 초과 시 백필 대상에서 제외한다. */
|
||||
private async recordDetailFailure(
|
||||
matchId: string,
|
||||
message: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const prev = await this.failureRepo.findOne({ where: { matchId } });
|
||||
await this.failureRepo.save({
|
||||
matchId,
|
||||
failCount: (prev?.failCount ?? 0) + 1,
|
||||
lastError: message.slice(0, 200),
|
||||
});
|
||||
} catch (error) {
|
||||
// 기록 실패는 백필 흐름을 끊지 않는다 (다음 시도에서 재기록)
|
||||
this.logger.warn(
|
||||
`상세 실패 기록 실패(matchId=${matchId}): ${(error as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** 매치 상세 조회 — 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);
|
||||
});
|
||||
|
||||
// 적재 성공 — 과거 실패 기록이 있었다면 정리 (일시 장애 후 복구된 매치)
|
||||
await this.failureRepo.delete({ matchId });
|
||||
|
||||
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,26 @@
|
||||
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,130 @@
|
||||
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,56 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsArray,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
|
||||
// 시즌 등록 요청 DTO (관리자)
|
||||
export class CreateSeasonDto {
|
||||
@ApiProperty({ description: '시즌명', example: '2026 시즌 1' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '시즌명을 입력해 주세요.' })
|
||||
@MaxLength(50)
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '시즌 시작일 (YYYY-MM-DD)',
|
||||
example: '2026-01-01',
|
||||
})
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/, {
|
||||
message: '시작일은 YYYY-MM-DD 형식이어야 합니다.',
|
||||
})
|
||||
startDate!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '시즌 종료일 (YYYY-MM-DD)',
|
||||
example: '2026-03-31',
|
||||
})
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/, {
|
||||
message: '종료일은 YYYY-MM-DD 형식이어야 합니다.',
|
||||
})
|
||||
endDate!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '5v5 맵 목록',
|
||||
type: [String],
|
||||
example: ['제3보급창고', '웨어하우스'],
|
||||
})
|
||||
@IsArray()
|
||||
@IsOptional()
|
||||
@IsString({ each: true })
|
||||
maps5v5?: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '3v3 맵 목록',
|
||||
type: [String],
|
||||
example: ['포트', '컨테이너'],
|
||||
})
|
||||
@IsArray()
|
||||
@IsOptional()
|
||||
@IsString({ each: true })
|
||||
maps3v3?: string[];
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
// 시즌 응답 DTO
|
||||
export class SeasonResponseDto {
|
||||
@ApiProperty({ example: 1 })
|
||||
id!: number;
|
||||
|
||||
@ApiProperty({ example: '2026 시즌 1' })
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ description: '시작일 (YYYY-MM-DD)', example: '2026-01-01' })
|
||||
startDate!: string;
|
||||
|
||||
@ApiProperty({ description: '종료일 (YYYY-MM-DD)', example: '2026-03-31' })
|
||||
endDate!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '5v5 맵 목록',
|
||||
type: [String],
|
||||
example: ['제3보급창고', '웨어하우스'],
|
||||
})
|
||||
maps5v5!: string[];
|
||||
|
||||
@ApiProperty({
|
||||
description: '3v3 맵 목록',
|
||||
type: [String],
|
||||
example: ['포트', '컨테이너'],
|
||||
})
|
||||
maps3v3!: string[];
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
|
||||
import { CreateSeasonDto } from './create-season.dto';
|
||||
|
||||
// 시즌 수정 요청 DTO (관리자) — 모든 필드 선택적
|
||||
export class UpdateSeasonDto extends PartialType(CreateSeasonDto) {}
|
||||
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
// 랭크전 시즌 — 관리자가 등록하는 시즌 기간 정의.
|
||||
// 시즌별 전적은 이 기간(startDate~endDate)으로 매치를 버킷팅해 집계한다.
|
||||
@Entity('sa_seasons')
|
||||
export class SaSeasonEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number;
|
||||
|
||||
// 시즌명 (예: '2026 시즌 1')
|
||||
@Column({ type: 'varchar', length: 50 })
|
||||
name!: string;
|
||||
|
||||
// 시즌 시작일 (YYYY-MM-DD)
|
||||
@Column({ type: 'date' })
|
||||
startDate!: string;
|
||||
|
||||
// 시즌 종료일 (YYYY-MM-DD)
|
||||
@Column({ type: 'date' })
|
||||
endDate!: string;
|
||||
|
||||
// 5v5 / 3v3 맵 목록 — 관리자가 시즌별로 설정. (simple-array: 콤마 구분 텍스트로 저장)
|
||||
@Column({ type: 'simple-array', nullable: true })
|
||||
maps5v5!: string[] | null;
|
||||
|
||||
@Column({ type: 'simple-array', nullable: true })
|
||||
maps3v3!: string[] | null;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { SaSeasonService } from './sa-season.service';
|
||||
import { CreateSeasonDto } from './dto/create-season.dto';
|
||||
import { UpdateSeasonDto } from './dto/update-season.dto';
|
||||
import { SeasonResponseDto } from './dto/season-response.dto';
|
||||
|
||||
@ApiTags('SA Season')
|
||||
@Controller('sa-seasons')
|
||||
export class SaSeasonController {
|
||||
constructor(private readonly seasonService: SaSeasonService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: '시즌 목록 조회',
|
||||
description: '등록된 랭크전 시즌 목록을 시작일 내림차순으로 반환합니다.',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '시즌 목록',
|
||||
type: [SeasonResponseDto],
|
||||
})
|
||||
findAll(): Promise<SeasonResponseDto[]> {
|
||||
return this.seasonService.findAll();
|
||||
}
|
||||
|
||||
@Get('active')
|
||||
@ApiOperation({
|
||||
summary: '현재 활성 시즌 조회',
|
||||
description:
|
||||
'오늘(KST) 날짜가 포함된 시즌을 반환합니다. 활성 시즌이 없으면 null.',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '현재 활성 시즌 (없으면 null)',
|
||||
type: SeasonResponseDto,
|
||||
})
|
||||
getActive(): Promise<SeasonResponseDto | null> {
|
||||
return this.seasonService.getActiveSeason();
|
||||
}
|
||||
|
||||
// ── 관리자 전용 ─────────────────────────────────────────────
|
||||
// TODO: 인증 도입 시 AdminGuard 적용 (현재는 가드 미적용 — 관리자 화면 연동 시 보호 필요)
|
||||
@Post()
|
||||
@ApiOperation({ summary: '[관리자] 시즌 등록' })
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: '등록된 시즌',
|
||||
type: SeasonResponseDto,
|
||||
})
|
||||
create(@Body() dto: CreateSeasonDto): Promise<SeasonResponseDto> {
|
||||
return this.seasonService.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: '[관리자] 시즌 수정' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '수정된 시즌',
|
||||
type: SeasonResponseDto,
|
||||
})
|
||||
update(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() dto: UpdateSeasonDto,
|
||||
): Promise<SeasonResponseDto> {
|
||||
return this.seasonService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: '[관리자] 시즌 삭제' })
|
||||
@ApiResponse({ status: 200, description: '삭제 완료' })
|
||||
remove(@Param('id', ParseIntPipe) id: number): Promise<void> {
|
||||
return this.seasonService.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { SaSeasonEntity } from './entities/sa-season.entity';
|
||||
import { SaSeasonService } from './sa-season.service';
|
||||
import { SaSeasonController } from './sa-season.controller';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([SaSeasonEntity])],
|
||||
controllers: [SaSeasonController],
|
||||
providers: [SaSeasonService],
|
||||
exports: [SaSeasonService],
|
||||
})
|
||||
export class SaSeasonModule {}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { SaSeasonEntity } from './entities/sa-season.entity';
|
||||
import { CreateSeasonDto } from './dto/create-season.dto';
|
||||
import { UpdateSeasonDto } from './dto/update-season.dto';
|
||||
import { SeasonResponseDto } from './dto/season-response.dto';
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// 랭크전 시즌 — 관리자가 등록/수정/삭제하는 시즌 기간 정의.
|
||||
// 시즌별 전적 집계(프론트)는 이 목록의 기간으로 매치를 버킷팅한다.
|
||||
// ────────────────────────────────────────────────────────────
|
||||
@Injectable()
|
||||
export class SaSeasonService {
|
||||
constructor(
|
||||
@InjectRepository(SaSeasonEntity)
|
||||
private readonly repo: Repository<SaSeasonEntity>,
|
||||
) {}
|
||||
|
||||
/** 시즌 목록 — 시작일 내림차순(최신 시즌 우선). */
|
||||
async findAll(): Promise<SeasonResponseDto[]> {
|
||||
const rows = await this.repo.find({ order: { startDate: 'DESC' } });
|
||||
return rows.map((r) => this.toDto(r));
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 활성 시즌 — 오늘(KST)이 [startDate, endDate]에 포함된 시즌.
|
||||
* 여러 시즌이 겹치면 시작일이 가장 늦은 것을 선택. 없으면 null.
|
||||
*/
|
||||
async getActiveSeason(): Promise<SeasonResponseDto | null> {
|
||||
const today = this.todayKst();
|
||||
const row = await this.repo
|
||||
.createQueryBuilder('s')
|
||||
.where('s.startDate <= :today AND s.endDate >= :today', { today })
|
||||
.orderBy('s.startDate', 'DESC')
|
||||
.getOne();
|
||||
return row ? this.toDto(row) : null;
|
||||
}
|
||||
|
||||
/** KST 기준 오늘 날짜 (YYYY-MM-DD) */
|
||||
private todayKst(): string {
|
||||
const kst = new Date(Date.now() + 9 * 60 * 60 * 1000);
|
||||
return kst.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
async create(dto: CreateSeasonDto): Promise<SeasonResponseDto> {
|
||||
const saved = await this.repo.save(this.repo.create(dto));
|
||||
return this.toDto(saved);
|
||||
}
|
||||
|
||||
async update(id: number, dto: UpdateSeasonDto): Promise<SeasonResponseDto> {
|
||||
const season = await this.repo.findOne({ where: { id } });
|
||||
if (!season) {
|
||||
throw new NotFoundException('요청하신 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
Object.assign(season, dto);
|
||||
return this.toDto(await this.repo.save(season));
|
||||
}
|
||||
|
||||
async remove(id: number): Promise<void> {
|
||||
const res = await this.repo.delete({ id });
|
||||
if (!res.affected) {
|
||||
throw new NotFoundException('요청하신 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
private toDto(r: SaSeasonEntity): SeasonResponseDto {
|
||||
return {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
startDate: r.startDate,
|
||||
endDate: r.endDate,
|
||||
// simple-array 는 빈 배열을 ['']로 되읽을 수 있어 빈 문자열을 거른다
|
||||
maps5v5: (r.maps5v5 ?? []).filter((m) => m),
|
||||
maps3v3: (r.maps3v3 ?? []).filter((m) => m),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
// 클랜 활동기간 DTO — 일별 스냅샷(clanName 시계열)에서 파생한 한 번의 활동 구간(run)
|
||||
export class ClanPeriodDto {
|
||||
@ApiProperty({ description: '클랜명', example: '바디' })
|
||||
clan!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'활동 시작일(YYYY-MM-DD). estimated=true면 최초 조회일(하한 — 실제 가입은 더 이전일 수 있음)',
|
||||
example: '2026-06-12',
|
||||
})
|
||||
from!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'활동 종료일(YYYY-MM-DD). 진행 중이면 오늘(표시는 ongoing 으로 판단)',
|
||||
example: '2026-06-20',
|
||||
})
|
||||
to!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'진행 중 여부 — 현재 소속 클랜이면 true(종료일 대신 "진행중"으로 표시)',
|
||||
example: true,
|
||||
})
|
||||
ongoing!: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'가입일 추정 여부 — 최초 관측 시점이라 실제 가입일은 더 이전일 수 있음(최초 기간만 true)',
|
||||
example: true,
|
||||
})
|
||||
estimated!: boolean;
|
||||
}
|
||||
@@ -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,73 @@
|
||||
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,19 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
// 시즌별 티어/점수 — 시즌 기간 내 마지막 스냅샷(시즌 종료 시점) 기준
|
||||
export class SeasonTierDto {
|
||||
@ApiProperty({ description: '시즌 ID', example: 1 })
|
||||
seasonId!: number;
|
||||
|
||||
@ApiProperty({ description: '솔로 랭크전 티어', example: '다이아몬드' })
|
||||
soloTier!: string;
|
||||
|
||||
@ApiProperty({ description: '솔로 랭크전 점수(RP)', example: 1850 })
|
||||
soloScore!: number;
|
||||
|
||||
@ApiProperty({ description: '파티 랭크전 티어', example: '플래티넘' })
|
||||
partyTier!: string;
|
||||
|
||||
@ApiProperty({ description: '파티 랭크전 점수(RP)', example: 1600 })
|
||||
partyScore!: number;
|
||||
}
|
||||
@@ -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,83 @@
|
||||
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';
|
||||
import { ClanPeriodDto } from './dto/clan-period.dto';
|
||||
import { SeasonTierDto } from './dto/season-tier.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,
|
||||
@Query('refresh') refresh?: string,
|
||||
): Promise<ProfileResponseDto> {
|
||||
// refresh=true 면 넥슨에서 강제 재동기화 (전적 갱신)
|
||||
return this.saUserService.getProfile(ouid, refresh === 'true');
|
||||
}
|
||||
|
||||
@Get(':ouid/clan-periods')
|
||||
@ApiOperation({
|
||||
summary: '클랜 활동기간 타임라인',
|
||||
description:
|
||||
'일별 스냅샷에서 파생한 클랜 활동기간 목록을 반환합니다. 최초 기간은 가입일 추정(estimated=true)이며, 이후 클랜 변경은 정확히 분리됩니다.',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '클랜 활동기간 목록',
|
||||
type: [ClanPeriodDto],
|
||||
})
|
||||
getClanPeriods(@Param('ouid') ouid: string): Promise<ClanPeriodDto[]> {
|
||||
return this.saUserService.getClanPeriods(ouid);
|
||||
}
|
||||
|
||||
@Get(':ouid/season-tiers')
|
||||
@ApiOperation({
|
||||
summary: '시즌별 티어/점수',
|
||||
description:
|
||||
'각 시즌 기간 내 마지막 스냅샷(시즌 종료 시점)의 솔로/파티 티어·점수를 반환합니다. 과거 시즌의 최종 티어를 일별 스냅샷에서 복원합니다.',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '시즌별 티어/점수 목록',
|
||||
type: [SeasonTierDto],
|
||||
})
|
||||
getSeasonTiers(@Param('ouid') ouid: string): Promise<SeasonTierDto[]> {
|
||||
return this.saUserService.getSeasonTiers(ouid);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
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';
|
||||
import { SaSeasonModule } from '../sa-season/sa-season.module';
|
||||
|
||||
// 서든어택 유저 모듈 — OUID 검색 + 종합 프로필 동기화(매일 크론) + 일별 이력
|
||||
// 넥슨 호출은 전역 NexonModule(NexonService)을 주입해 사용한다.
|
||||
// 시즌별 티어 스냅샷 조회를 위해 SaSeasonModule(SaSeasonService)을 주입한다.
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
SaUserEntity,
|
||||
SaUserProfileEntity,
|
||||
SaUserSnapshotEntity,
|
||||
]),
|
||||
SaSeasonModule,
|
||||
],
|
||||
controllers: [SaUserController],
|
||||
providers: [SaUserService],
|
||||
exports: [SaUserService],
|
||||
})
|
||||
export class SaUserModule {}
|
||||
@@ -0,0 +1,326 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Between, Repository } from 'typeorm';
|
||||
|
||||
import { NexonService } from '../../shared/nexon/nexon.service';
|
||||
import { SaSeasonService } from '../sa-season/sa-season.service';
|
||||
import { OuidResponseDto } from './dto/ouid-response.dto';
|
||||
import { ProfileResponseDto } from './dto/profile-response.dto';
|
||||
import { ClanPeriodDto } from './dto/clan-period.dto';
|
||||
import { SeasonTierDto } from './dto/season-tier.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,
|
||||
private readonly seasonService: SaSeasonService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 시즌별 티어/점수 — 각 시즌 기간 내 **마지막 스냅샷**(시즌 종료 시점)의 값.
|
||||
* 일별 크론 스냅샷을 활용해 과거 시즌의 최종 티어/점수를 보존·조회한다.
|
||||
* 해당 시즌 기간에 스냅샷이 없으면(추적 이전) 결과에서 제외된다.
|
||||
*/
|
||||
async getSeasonTiers(ouid: string): Promise<SeasonTierDto[]> {
|
||||
const seasons = await this.seasonService.findAll();
|
||||
const out: SeasonTierDto[] = [];
|
||||
for (const s of seasons) {
|
||||
const snap = await this.snapshotRepo.findOne({
|
||||
where: { ouid, snapshotDate: Between(s.startDate, s.endDate) },
|
||||
order: { snapshotDate: 'DESC' },
|
||||
select: ['soloTier', 'soloScore', 'partyTier', 'partyScore'],
|
||||
});
|
||||
if (!snap) continue;
|
||||
out.push({
|
||||
seasonId: s.id,
|
||||
soloTier: snap.soloTier,
|
||||
soloScore: snap.soloScore,
|
||||
partyTier: snap.partyTier,
|
||||
partyScore: snap.partyScore,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 닉네임으로 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회 동기화).
|
||||
* refresh=true 면 넥슨에서 강제 재동기화한다(전적 갱신 버튼).
|
||||
*/
|
||||
async getProfile(ouid: string, refresh = false): Promise<ProfileResponseDto> {
|
||||
if (refresh) {
|
||||
return this.toDto(await this.syncProfile(ouid));
|
||||
}
|
||||
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})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 클랜 활동기간 타임라인 — 일별 스냅샷(clanName 시계열)에서 파생한다.
|
||||
* - 연속 동일 clanName 을 한 활동기간(run)으로 묶는다. (clanName '' = 무소속 → 제외)
|
||||
* - run.to = 다음 세그먼트 시작일(없으면 오늘) → 변경 시점에 자동으로 기간이 갈린다.
|
||||
* - 최초 세그먼트만 estimated=true (최초 관측 시점이라 실제 가입일은 더 이전일 수 있음).
|
||||
*/
|
||||
async getClanPeriods(ouid: string): Promise<ClanPeriodDto[]> {
|
||||
const snaps = await this.snapshotRepo.find({
|
||||
where: { ouid },
|
||||
order: { snapshotDate: 'ASC' },
|
||||
select: ['snapshotDate', 'clanName'],
|
||||
});
|
||||
if (!snaps.length) return [];
|
||||
|
||||
// 연속 동일 clanName 세그먼트로 압축
|
||||
const segs: { clan: string; from: string }[] = [];
|
||||
for (const s of snaps) {
|
||||
const clan = s.clanName ?? '';
|
||||
const last = segs[segs.length - 1];
|
||||
if (!last || last.clan !== clan)
|
||||
segs.push({ clan, from: s.snapshotDate });
|
||||
}
|
||||
|
||||
// 비어있지 않은 클랜 세그먼트 → 활동기간 run
|
||||
const today = this.todayKst();
|
||||
const runs: ClanPeriodDto[] = [];
|
||||
for (let i = 0; i < segs.length; i++) {
|
||||
if (!segs[i].clan) continue;
|
||||
// 마지막 세그먼트(현재 clanName) = 진행 중. to 는 오늘이지만 진행중으로 표시한다.
|
||||
const ongoing = i === segs.length - 1;
|
||||
runs.push({
|
||||
clan: segs[i].clan,
|
||||
from: segs[i].from,
|
||||
to: ongoing ? today : segs[i + 1].from,
|
||||
ongoing,
|
||||
estimated: i === 0,
|
||||
});
|
||||
}
|
||||
return runs;
|
||||
}
|
||||
|
||||
/** 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,17 @@
|
||||
import { Body, Controller, Get, Param, ParseIntPipe, Post } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { UserService } from './user.service';
|
||||
import { CreateUserDto } from './dto/create-user.dto';
|
||||
|
||||
|
||||
@@ -5,7 +5,10 @@ import { CreateUserDto } from './dto/create-user.dto';
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
// 임시 인메모리 저장소 — Repository 도입 시 교체
|
||||
private readonly users = new Map<number, { id: number; email: string; name: string }>();
|
||||
private readonly users = new Map<
|
||||
number,
|
||||
{ id: number; email: string; name: string }
|
||||
>();
|
||||
|
||||
findOne(id: number) {
|
||||
const user = this.users.get(id);
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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(
|
||||
'요청하신 작업을 완료하지 못했습니다.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// 동시성 유틸 — 작업 목록을 바운디드 워커 풀로 처리한다.
|
||||
|
||||
/**
|
||||
* items 를 최대 concurrency 개씩 동시에 처리한다(바운디드 워커 풀).
|
||||
*
|
||||
* - 워커 수만큼만 동시에 in-flight 가 되며, 끝나는 즉시 다음 항목을 가져간다.
|
||||
* - worker 내부에서 발생한 예외는 호출 측이 직접 처리해야 한다(아래 throw 시 전체 중단).
|
||||
* 개별 실패를 격리하려면 worker 안에서 try/catch 로 감쌀 것.
|
||||
* - 외부 rate limiter(예: NexonService)와 함께 쓰면 동시성을 높여도 초당 한도는 안전하게 유지된다.
|
||||
*
|
||||
* @param items 처리할 항목 목록
|
||||
* @param concurrency 동시 워커 수 (1 이상, 항목 수로 상한)
|
||||
* @param worker 각 항목을 처리하는 비동기 함수
|
||||
*/
|
||||
export async function runWithConcurrency<T>(
|
||||
items: readonly T[],
|
||||
concurrency: number,
|
||||
worker: (item: T, index: number) => Promise<void>,
|
||||
): Promise<void> {
|
||||
if (items.length === 0) return;
|
||||
const limit = Math.max(1, Math.min(Math.floor(concurrency), items.length));
|
||||
|
||||
// 모든 워커가 공유하는 커서 — JS 단일 스레드라 cursor++ 는 원자적으로 동작한다.
|
||||
let cursor = 0;
|
||||
const runners = Array.from({ length: limit }, async () => {
|
||||
for (;;) {
|
||||
const index = cursor++;
|
||||
if (index >= items.length) return;
|
||||
await worker(items[index], index);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(runners);
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
Generated
+72
-28
@@ -8,10 +8,12 @@
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"apexcharts": "^5.15.0",
|
||||
"axios": "^1.14.0",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.31",
|
||||
"vue-router": "^4.4.5"
|
||||
"vue-router": "^4.4.5",
|
||||
"vue3-apexcharts": "^1.11.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node24": "^24.0.4",
|
||||
@@ -119,7 +121,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 +646,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
@@ -694,28 +694,27 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
||||
"version": "1.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.0.tgz",
|
||||
"integrity": "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.1",
|
||||
"@emnapi/wasi-threads": "1.2.2",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||
"version": "1.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.0.tgz",
|
||||
"integrity": "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -725,12 +724,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
||||
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
|
||||
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
@@ -1717,6 +1717,40 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.1",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
||||
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz",
|
||||
@@ -1897,7 +1931,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 +2403,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 +2600,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 +2670,6 @@
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -2732,6 +2762,12 @@
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/apexcharts": {
|
||||
"version": "5.15.0",
|
||||
"resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-5.15.0.tgz",
|
||||
"integrity": "sha512-ATswoiZi8wynKcwqf0eyE0Is5mBQoDpxZN3PlSVy41OrD+9GMBp2kZQRjyGK+5/j0GFjHkuAd7GKOrt5VMoPrw==",
|
||||
"license": "SEE LICENSE IN LICENSE"
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
@@ -2862,7 +2898,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.12",
|
||||
"caniuse-lite": "^1.0.30001782",
|
||||
@@ -2917,9 +2952,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 +3439,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 +3499,6 @@
|
||||
"integrity": "sha512-174lJKuNsuDIlLpjeXc5E2Tss8P44uIimAfGD0b90k0NoirJqpG7stLuU9Vp/9ioTOrQdWVREc4mRd1BD+CvGw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.4.0",
|
||||
"globals": "^13.24.0",
|
||||
@@ -6013,7 +6046,6 @@
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -6163,7 +6195,6 @@
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -6297,7 +6328,6 @@
|
||||
"integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.4",
|
||||
@@ -6699,7 +6729,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",
|
||||
@@ -6811,6 +6840,21 @@
|
||||
"typescript": ">=5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue3-apexcharts": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/vue3-apexcharts/-/vue3-apexcharts-1.11.1.tgz",
|
||||
"integrity": "sha512-MbN3vg8bMG19wc0Lm1HkeQvODgLm56DgpIxtNUO0xpf/JCzYWVGE4jzXp2JISzy2s3Kul1yOxNQUYsLvKQ5L9g==",
|
||||
"license": "see LICENSE in LICENSE",
|
||||
"peerDependencies": {
|
||||
"apexcharts": ">=5.10.0",
|
||||
"vue": ">=3.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"apexcharts": {
|
||||
"optional": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||
|
||||
@@ -14,10 +14,12 @@
|
||||
"format": "oxfmt src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"apexcharts": "^5.15.0",
|
||||
"axios": "^1.14.0",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.31",
|
||||
"vue-router": "^4.4.5"
|
||||
"vue-router": "^4.4.5",
|
||||
"vue3-apexcharts": "^1.11.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node24": "^24.0.4",
|
||||
|
||||
@@ -6,6 +6,10 @@ import AppNav from '@/shared/components/layout/AppNav.vue'
|
||||
import AppFooter from '@/shared/components/layout/AppFooter.vue'
|
||||
import FeedbackWidget from '@/shared/components/FeedbackWidget.vue'
|
||||
import LoginModal from '@/shared/components/LoginModal.vue'
|
||||
import DevSyncButton from '@/shared/components/DevSyncButton.vue'
|
||||
|
||||
// 개발 환경에서만 매치 동기화 수동 트리거 버튼 노출
|
||||
const isDev = import.meta.env.DEV
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -20,6 +24,7 @@ import LoginModal from '@/shared/components/LoginModal.vue'
|
||||
<!-- 전역 오버레이 -->
|
||||
<FeedbackWidget />
|
||||
<LoginModal />
|
||||
<DevSyncButton v-if="isDev" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -55,15 +55,22 @@ api.interceptors.response.use(
|
||||
// 성공 시 data 만 언래핑하여 반환
|
||||
return payload.data as never
|
||||
}
|
||||
// HTTP 200 이지만 비즈니스 로직상 실패인 경우
|
||||
// HTTP 200 이지만 비즈니스 로직상 실패인 경우 — 서버 메시지 우선, 없으면 코드 사전 매핑
|
||||
const errCode = payload.error?.code || 'BIZ_001'
|
||||
showErrorUI(ErrorCodeLexicon[errCode] ?? ErrorCodeLexicon.SYS_001 ?? '일시적인 시스템 오류가 발생했습니다.')
|
||||
showErrorUI(
|
||||
payload.error?.message
|
||||
|| ErrorCodeLexicon[errCode]
|
||||
|| ErrorCodeLexicon.SYS_001
|
||||
|| '일시적인 시스템 오류가 발생했습니다.',
|
||||
)
|
||||
return Promise.reject(payload.error)
|
||||
}
|
||||
return payload as never
|
||||
},
|
||||
(error) => {
|
||||
let errCode = 'SYS_001'
|
||||
// 백엔드 Exception Filter 가 내려준 사용자용 메시지 (점검 안내 등) — 있으면 우선 노출
|
||||
let serverMessage: string | undefined
|
||||
|
||||
if (error?.response) {
|
||||
const status: number = error.response.status
|
||||
@@ -71,13 +78,19 @@ api.interceptors.response.use(
|
||||
|
||||
if (payload?.error?.code) {
|
||||
errCode = payload.error.code
|
||||
serverMessage = payload.error.message
|
||||
} else if (status === 401) errCode = 'AUTH_001'
|
||||
else if (status === 403) errCode = 'AUTH_003'
|
||||
else if (status === 400 || status === 422) errCode = 'VAL_001'
|
||||
else if (status === 404) errCode = 'RES_001'
|
||||
}
|
||||
|
||||
showErrorUI(ErrorCodeLexicon[errCode] ?? ErrorCodeLexicon.SYS_001 ?? '일시적인 시스템 오류가 발생했습니다.')
|
||||
showErrorUI(
|
||||
serverMessage
|
||||
|| ErrorCodeLexicon[errCode]
|
||||
|| ErrorCodeLexicon.SYS_001
|
||||
|| '일시적인 시스템 오류가 발생했습니다.',
|
||||
)
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useApi } from './useApi'
|
||||
|
||||
// 서든어택 맵 (백엔드 MapResponseDto) — 5v5/3v3 구분은 시즌 설정에서 정한다
|
||||
export interface SaMap {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
// 맵 등록/수정 입력 (id 제외)
|
||||
export type MapInput = Omit<SaMap, 'id'>
|
||||
|
||||
export function useSaMap() {
|
||||
const api = useApi()
|
||||
|
||||
// 등록된 맵 목록 (모드→이름 순). 실패 시 빈 배열.
|
||||
async function getMaps(): Promise<SaMap[]> {
|
||||
try {
|
||||
return (await api.get('/sa-maps')) as unknown as SaMap[]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// [관리자] 맵 등록. 실패 시 null (인터셉터가 에러 알림 처리).
|
||||
async function createMap(input: MapInput): Promise<SaMap | null> {
|
||||
try {
|
||||
return (await api.post('/sa-maps', input)) as unknown as SaMap
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// [관리자] 맵 수정. 실패 시 null.
|
||||
async function updateMap(
|
||||
id: number,
|
||||
input: Partial<MapInput>,
|
||||
): Promise<SaMap | null> {
|
||||
try {
|
||||
return (await api.patch(`/sa-maps/${id}`, input)) as unknown as SaMap
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// [관리자] 맵 삭제. 성공 시 true.
|
||||
async function deleteMap(id: number): Promise<boolean> {
|
||||
try {
|
||||
await api.delete(`/sa-maps/${id}`)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return { getMaps, createMap, updateMap, deleteMap }
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useApi } from './useApi'
|
||||
|
||||
// 매치 목록 항목 (백엔드 MatchItemDto)
|
||||
export interface SaMatchItem {
|
||||
matchId: string
|
||||
matchType: string
|
||||
matchMode: string
|
||||
dateMatch: string
|
||||
matchResult: string
|
||||
kill: number
|
||||
death: number
|
||||
assist: number
|
||||
// 매치 맵 — 상세 캐시 보유 시에만 값 존재, 미보유면 빈 문자열
|
||||
matchMap: string
|
||||
}
|
||||
|
||||
// 매치 상세 참가자
|
||||
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[]
|
||||
}
|
||||
|
||||
// [개발 전용] 매치 데이터 적재 현황 (백엔드 DevStatsDto)
|
||||
export interface DevStats {
|
||||
users: number
|
||||
matches: number
|
||||
details: number
|
||||
participants: number
|
||||
failures: number
|
||||
}
|
||||
|
||||
// 매치 타입 (넥슨 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
|
||||
}
|
||||
}
|
||||
|
||||
// 보이는 매치들의 맵 이름 일괄 확보 — 미보유분은 서버가 동기 적재 후 반환.
|
||||
// 반환: matchId → 맵 이름(확보 실패 시 ''). 실패 시 빈 객체.
|
||||
async function ensureMatchMaps(matchIds: string[]): Promise<Record<string, string>> {
|
||||
if (!matchIds.length) return {}
|
||||
try {
|
||||
return (await api.post('/sa-matches/details/maps', {
|
||||
matchIds,
|
||||
})) as unknown as Record<string, string>
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
// [개발 전용] 매치 동기화 크론 수동 트리거. 성공 시 true (서버는 백그라운드 실행).
|
||||
async function devSync(): Promise<boolean> {
|
||||
try {
|
||||
const res = (await api.post('/sa-matches/dev/sync')) as unknown as {
|
||||
triggered: boolean
|
||||
}
|
||||
return !!res?.triggered
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// [개발 전용] 매치 데이터 적재 현황 조회. 실패 시 null.
|
||||
async function devStats(): Promise<DevStats | null> {
|
||||
try {
|
||||
return (await api.get('/sa-matches/dev/stats')) as unknown as DevStats
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return { getMatches, getMatchDetail, ensureMatchMaps, devSync, devStats }
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useApi } from './useApi'
|
||||
|
||||
// 메타 항목 (이름 → 이미지)
|
||||
export interface SaMetaItem {
|
||||
name: string
|
||||
image: string
|
||||
}
|
||||
|
||||
// 종류별 메타 { grade, season_grade, tier }
|
||||
export type SaMetaSections = Record<string, SaMetaItem[]>
|
||||
|
||||
// 메타는 거의 불변 — 모듈 스코프에 1회 캐시해 컴포넌트 간 공유한다(중복 요청 방지).
|
||||
let metaCache: SaMetaSections | null = null
|
||||
let metaPromise: Promise<SaMetaSections> | null = null
|
||||
|
||||
export function useSaMeta() {
|
||||
const api = useApi()
|
||||
|
||||
async function getMeta(): Promise<SaMetaSections> {
|
||||
if (metaCache) return metaCache
|
||||
// 동시 호출은 진행 중인 Promise 를 공유
|
||||
if (metaPromise) return metaPromise
|
||||
metaPromise = (async () => {
|
||||
try {
|
||||
const data = (await api.get('/sa-meta')) as unknown as SaMetaSections
|
||||
metaCache = data
|
||||
return data
|
||||
} catch {
|
||||
// 실패 시 캐시하지 않고 다음 호출에서 재시도
|
||||
metaPromise = null
|
||||
return {}
|
||||
}
|
||||
})()
|
||||
return metaPromise
|
||||
}
|
||||
|
||||
// 종류 + 이름으로 이미지 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 }
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useApi } from './useApi'
|
||||
|
||||
// 랭크전 시즌 (백엔드 SeasonResponseDto)
|
||||
export interface SaSeason {
|
||||
id: number
|
||||
name: string
|
||||
// 시작/종료일 (YYYY-MM-DD)
|
||||
startDate: string
|
||||
endDate: string
|
||||
// 5v5 / 3v3 맵 목록 (관리자 설정)
|
||||
maps5v5: string[]
|
||||
maps3v3: string[]
|
||||
}
|
||||
|
||||
// 시즌 등록/수정 입력 (id 제외)
|
||||
export type SeasonInput = Omit<SaSeason, 'id'>
|
||||
|
||||
export function useSaSeason() {
|
||||
const api = useApi()
|
||||
|
||||
// 등록된 시즌 목록 (시작일 내림차순). 실패 시 빈 배열.
|
||||
async function getSeasons(): Promise<SaSeason[]> {
|
||||
try {
|
||||
return (await api.get('/sa-seasons')) as unknown as SaSeason[]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// 현재 활성 시즌 (오늘 포함). 없거나 실패 시 null.
|
||||
async function getActiveSeason(): Promise<SaSeason | null> {
|
||||
try {
|
||||
return (await api.get('/sa-seasons/active')) as unknown as SaSeason | null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// [관리자] 시즌 등록. 실패 시 null (인터셉터가 에러 알림 처리).
|
||||
async function createSeason(input: SeasonInput): Promise<SaSeason | null> {
|
||||
try {
|
||||
return (await api.post('/sa-seasons', input)) as unknown as SaSeason
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// [관리자] 시즌 수정. 실패 시 null.
|
||||
async function updateSeason(
|
||||
id: number,
|
||||
input: Partial<SeasonInput>,
|
||||
): Promise<SaSeason | null> {
|
||||
try {
|
||||
return (await api.patch(`/sa-seasons/${id}`, input)) as unknown as SaSeason
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// [관리자] 시즌 삭제. 성공 시 true.
|
||||
async function deleteSeason(id: number): Promise<boolean> {
|
||||
try {
|
||||
await api.delete(`/sa-seasons/${id}`)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return { getSeasons, getActiveSeason, createSeason, updateSeason, deleteSeason }
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
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
|
||||
}
|
||||
|
||||
// 클랜 활동기간 (백엔드 ClanPeriodDto) — 일별 스냅샷에서 파생한 한 번의 활동 구간
|
||||
export interface ClanPeriod {
|
||||
clan: string
|
||||
from: string
|
||||
to: string
|
||||
// 진행 중 여부 (현재 소속 클랜이면 true — 종료일 대신 '진행중' 표시)
|
||||
ongoing: boolean
|
||||
// 가입일 추정 여부 (최초 관측 기간만 true — 실제 가입은 더 이전일 수 있음)
|
||||
estimated: boolean
|
||||
}
|
||||
|
||||
// 시즌별 티어/점수 (백엔드 SeasonTierDto) — 시즌 종료 시점 스냅샷 기준
|
||||
export interface SeasonTier {
|
||||
seasonId: number
|
||||
soloTier: string
|
||||
soloScore: number
|
||||
partyTier: string
|
||||
partyScore: number
|
||||
}
|
||||
|
||||
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 → 종합 프로필. refresh=true 면 넥슨에서 강제 재동기화(전적 갱신).
|
||||
async function getProfile(
|
||||
ouid: string,
|
||||
refresh = false,
|
||||
): Promise<SaProfile | null> {
|
||||
try {
|
||||
return (await api.get(`/sa-users/${ouid}/profile`, {
|
||||
params: refresh ? { refresh: true } : undefined,
|
||||
})) as unknown as SaProfile
|
||||
} catch (e) {
|
||||
error.value = (e as Error)?.message ?? 'unknown error'
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// OUID → 클랜 활동기간 타임라인 (실패 시 빈 배열)
|
||||
async function getClanPeriods(ouid: string): Promise<ClanPeriod[]> {
|
||||
try {
|
||||
return (await api.get(
|
||||
`/sa-users/${ouid}/clan-periods`,
|
||||
)) as unknown as ClanPeriod[]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// OUID → 시즌별 티어/점수 (시즌 종료 시점 스냅샷). 실패 시 빈 배열.
|
||||
async function getSeasonTiers(ouid: string): Promise<SeasonTier[]> {
|
||||
try {
|
||||
return (await api.get(
|
||||
`/sa-users/${ouid}/season-tiers`,
|
||||
)) as unknown as SeasonTier[]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
loading,
|
||||
error,
|
||||
searchOuid,
|
||||
getProfile,
|
||||
getClanPeriods,
|
||||
getSeasonTiers,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,28 +1,479 @@
|
||||
<script setup lang="ts">
|
||||
// 전적검색(프로필) 페이지 — 검색 서브헤더 + 요약 + 전적/통계 탭. sa-profile.jsx ProfileScreen 포팅.
|
||||
import { computed, ref } from 'vue'
|
||||
// UI 구조는 기존(mock 디자인) 그대로 유지하고, 데이터만 넥슨 실데이터를 어댑터로 변환해 주입한다.
|
||||
import { defineAsyncComponent, 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,
|
||||
type ClanPeriod,
|
||||
type SeasonTier,
|
||||
} from '@/composables/useSaUser'
|
||||
import { useSaMatch, MATCH_TYPES, type SaMatchItem } from '@/composables/useSaMatch'
|
||||
import { useSaMeta, type SaMetaSections } from '@/composables/useSaMeta'
|
||||
import { useSaSeason, type SaSeason } from '@/composables/useSaSeason'
|
||||
import { makeRank } from '@/shared/utils/adapt'
|
||||
import { timeAgo, toKstDate } from '@/shared/utils/format'
|
||||
import type {
|
||||
Player,
|
||||
MatchRecord,
|
||||
RankedRecord,
|
||||
ClanRecord,
|
||||
ModeRecord,
|
||||
MapRecord,
|
||||
TrendPoint,
|
||||
} 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'
|
||||
import ClanRecords from '@/pages/profile/ClanRecords.vue'
|
||||
import MatchHistory from '@/pages/profile/MatchHistory.vue'
|
||||
import RecentTrend from '@/pages/profile/RecentTrend.vue'
|
||||
import Companions from '@/pages/profile/Companions.vue'
|
||||
import TrendChart from '@/pages/profile/TrendChart.vue'
|
||||
// '자주 함께한 유저'는 정확성 보장 불가로 비활성화 — 매치 상세 참가자에 ouid가 없어
|
||||
// 동반자(특히 개명 시)를 신뢰성 있게 식별·병합할 수 없음. Open API에 참가자 ouid 제공 시 복구.
|
||||
// import Companions from '@/pages/profile/Companions.vue'
|
||||
// 차트 라이브러리(ApexCharts)가 무거우므로 통계 탭에서 렌더될 때만 비동기 로드
|
||||
const TrendChart = defineAsyncComponent(
|
||||
() => import('@/pages/profile/TrendChart.vue'),
|
||||
)
|
||||
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, getClanPeriods, getSeasonTiers } = useSaUser()
|
||||
const { getMatches } = useSaMatch()
|
||||
const { getMeta, imageOf } = useSaMeta()
|
||||
const { getSeasons } = useSaSeason()
|
||||
|
||||
const loading = ref(false)
|
||||
const refreshing = ref(false)
|
||||
const notFound = ref(false)
|
||||
const p = ref<Player | null>(null)
|
||||
const tab = ref('전적')
|
||||
|
||||
// 넥슨 매치타입 → 기존 UI 탭 라벨
|
||||
const TYPE_LABEL: Record<string, string> = {
|
||||
'랭크전 솔로': '솔로 랭크전',
|
||||
'랭크전 파티': '파티 랭크전',
|
||||
'퀵매치 클랜전': '클랜전',
|
||||
'클랜 랭크전': '클랜 랭크전',
|
||||
토너먼트: '토너먼트',
|
||||
}
|
||||
|
||||
// 킬뎃 %(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,
|
||||
}
|
||||
}
|
||||
|
||||
// 시즌별 랭크전 전적 구성.
|
||||
// - 시즌이 등록돼 있으면: 각 시즌 [startDate, endDate]에 드는 매치만 버킷팅해 집계.
|
||||
// 티어/티어점수는 현재 시즌=라이브 프로필, 과거 시즌=시즌 종료 스냅샷(seasonTiers). 둘 다 없으면 '—'.
|
||||
// - 시즌이 없으면: 보유 매치 전체를 단일 '현재 시즌'으로 (기존 동작 폴백).
|
||||
function buildRankedSeasons(
|
||||
pr: SaProfile,
|
||||
matches: SaMatchItem[],
|
||||
meta: SaMetaSections,
|
||||
seasons: SaSeason[],
|
||||
seasonTiers: SeasonTier[],
|
||||
): RankedSeason[] {
|
||||
// 솔로/파티 티어·점수를 받아 한 구간의 랭크전 집계 생성
|
||||
const mk = (
|
||||
items: SaMatchItem[],
|
||||
soloTier: string,
|
||||
soloScore: number,
|
||||
partyTier: string,
|
||||
partyScore: number,
|
||||
) => ({
|
||||
solo: aggRanked(
|
||||
items,
|
||||
'랭크전 솔로',
|
||||
soloTier,
|
||||
soloScore,
|
||||
soloTier ? imageOf(meta, 'tier', soloTier) : '',
|
||||
),
|
||||
party: aggRanked(
|
||||
items,
|
||||
'랭크전 파티',
|
||||
partyTier,
|
||||
partyScore,
|
||||
partyTier ? imageOf(meta, 'tier', partyTier) : '',
|
||||
),
|
||||
// 클랜 랭크전 — 티어/티어점수 API 미제공이라 항상 빈값
|
||||
clan: aggRanked(items, '클랜 랭크전', '', 0, ''),
|
||||
})
|
||||
|
||||
if (!seasons.length) {
|
||||
return [
|
||||
{
|
||||
season: '현재 시즌',
|
||||
current: true,
|
||||
hasTier: true,
|
||||
...mk(matches, pr.soloTier, pr.soloScore, pr.partyTier, pr.partyScore),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const today = toKstDate(new Date())
|
||||
const tierBySeason = new Map(seasonTiers.map((t) => [t.seasonId, t]))
|
||||
return seasons.map((s) => {
|
||||
// 매치 일시(UTC 저장)를 KST 일자로 변환해 시즌 경계와 같은 기준으로 비교
|
||||
const inSeason = matches.filter((m) => {
|
||||
const d = toKstDate(m.dateMatch)
|
||||
return d >= s.startDate && d <= s.endDate
|
||||
})
|
||||
const isCurrent = today >= s.startDate && today <= s.endDate
|
||||
const t = tierBySeason.get(s.id)
|
||||
// 현재 시즌은 라이브 프로필 티어, 과거 시즌은 시즌 종료 스냅샷 티어, 없으면 빈값('—')
|
||||
const soloTier = isCurrent ? pr.soloTier : (t?.soloTier ?? '')
|
||||
const soloScore = isCurrent ? pr.soloScore : (t?.soloScore ?? 0)
|
||||
const partyTier = isCurrent ? pr.partyTier : (t?.partyTier ?? '')
|
||||
const partyScore = isCurrent ? pr.partyScore : (t?.partyScore ?? 0)
|
||||
return {
|
||||
season: s.name,
|
||||
current: isCurrent,
|
||||
hasTier: isCurrent || !!t,
|
||||
...mk(inSeason, soloTier, soloScore, partyTier, partyScore),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 랭크전 통산 — 기간(시즌) 무관 전체 랭크전 매치 합산. 티어/점수는 통산에 없으므로 빈값.
|
||||
function buildRankedCareer(matches: SaMatchItem[]): RankedSeason {
|
||||
return {
|
||||
season: '통산',
|
||||
current: false,
|
||||
hasTier: false,
|
||||
solo: aggRanked(matches, '랭크전 솔로', '', 0, ''),
|
||||
party: aggRanked(matches, '랭크전 파티', '', 0, ''),
|
||||
clan: aggRanked(matches, '클랜 랭크전', '', 0, ''),
|
||||
}
|
||||
}
|
||||
|
||||
// 두 'YYYY-MM-DD' 사이의 개월 수(근사)
|
||||
function monthsBetween(from: string, to: string): number {
|
||||
const [fy, fm] = from.split('-').map(Number)
|
||||
const [ty, tm] = to.split('-').map(Number)
|
||||
if (!fy || !ty) return 0
|
||||
return Math.max(0, (ty - fy) * 12 + (tm - fm))
|
||||
}
|
||||
|
||||
// 클랜전 전적 — 스냅샷에서 파생한 클랜 활동기간(periods) 기준으로 집계.
|
||||
// 활동기간 [from, to] 안의 '퀵매치 클랜전'(클랜 랭크전 제외) 매치만 엄격하게 날짜 필터링한다.
|
||||
// 가입일은 추정(estimated)이라 조회일 이전 매치는 자연히 제외된다.
|
||||
function buildClanRecords(items: SaMatchItem[], periods: ClanPeriod[]): ClanRecord[] {
|
||||
if (!periods.length) return []
|
||||
|
||||
// 클랜별로 활동기간(run) 묶기
|
||||
const byClan = new Map<string, ClanPeriod[]>()
|
||||
for (const p of periods) {
|
||||
const arr = byClan.get(p.clan) ?? []
|
||||
arr.push(p)
|
||||
byClan.set(p.clan, arr)
|
||||
}
|
||||
|
||||
// 퀵매치 클랜전만 (클랜 랭크전 제외)
|
||||
const clanMatches = items.filter((m) => m.matchType === '퀵매치 클랜전')
|
||||
// ISO 일시가 활동기간 [from, to](KST 일자 기준, 양끝 포함)에 드는지
|
||||
const inRun = (iso: string, r: ClanPeriod) => {
|
||||
const d = toKstDate(iso)
|
||||
return d >= r.from && d <= r.to
|
||||
}
|
||||
|
||||
const records: ClanRecord[] = []
|
||||
for (const [clan, runs] of byClan) {
|
||||
const f = clanMatches.filter((m) => runs.some((r) => inRun(m.dateMatch, r)))
|
||||
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
|
||||
})
|
||||
records.push({
|
||||
clan,
|
||||
tag: clan.slice(0, 4),
|
||||
matches,
|
||||
w,
|
||||
l,
|
||||
wr: matches ? Math.round((w / matches) * 100) : 0,
|
||||
kd: matches ? Math.round(kdSum / matches) : 0,
|
||||
periods: runs
|
||||
.slice()
|
||||
.sort((a, b) => b.from.localeCompare(a.from))
|
||||
.map((r) => ({
|
||||
from: r.from,
|
||||
to: r.to,
|
||||
months: monthsBetween(r.from, r.to),
|
||||
ongoing: r.ongoing,
|
||||
estimated: r.estimated,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
// 최근 활동(가장 늦은 from) 순으로 정렬
|
||||
records.sort((a, b) => b.periods[0].from.localeCompare(a.periods[0].from))
|
||||
return records
|
||||
}
|
||||
|
||||
function adaptMatches(items: SaMatchItem[]): MatchRecord[] {
|
||||
return items.map((m) => ({
|
||||
matchId: m.matchId,
|
||||
mode: TYPE_LABEL[m.matchType] ?? m.matchType,
|
||||
win: m.matchResult === '1',
|
||||
map: m.matchMap ?? '',
|
||||
k: m.kill,
|
||||
d: m.death,
|
||||
a: m.assist,
|
||||
ago: timeAgo(m.dateMatch),
|
||||
winSide: 'blue',
|
||||
teams: { blue: [], red: [] },
|
||||
}))
|
||||
}
|
||||
|
||||
// 모드별 전적 — 매치 타입별 집계. (게임 모드는 폭파미션만 수집하므로 매치 타입 기준)
|
||||
// 랭크전 3종(솔로/파티/클랜 랭크전)은 0전이어도 항상 표시하고, 고정 순서로 정렬한다.
|
||||
const MODE_ORDER = ['솔로 랭크전', '파티 랭크전', '클랜 랭크전', '클랜전', '토너먼트']
|
||||
const MODE_ALWAYS = ['솔로 랭크전', '파티 랭크전', '클랜 랭크전']
|
||||
function buildModeRecords(matches: SaMatchItem[]): ModeRecord[] {
|
||||
const byType = new Map<string, { matches: number; w: number; kdSum: number }>()
|
||||
// 랭크전 3종은 0전이어도 표기되도록 미리 시드
|
||||
for (const name of MODE_ALWAYS) byType.set(name, { matches: 0, w: 0, kdSum: 0 })
|
||||
for (const m of matches) {
|
||||
const label = TYPE_LABEL[m.matchType] ?? m.matchType
|
||||
const cur = byType.get(label) ?? { matches: 0, w: 0, kdSum: 0 }
|
||||
cur.matches++
|
||||
if (m.matchResult === '1') cur.w++
|
||||
const t = m.kill + m.death
|
||||
cur.kdSum += t ? (m.kill / t) * 100 : 0
|
||||
byType.set(label, cur)
|
||||
}
|
||||
const orderIdx = (name: string) => {
|
||||
const i = MODE_ORDER.indexOf(name)
|
||||
return i === -1 ? MODE_ORDER.length : i
|
||||
}
|
||||
return [...byType.entries()]
|
||||
.map(([name, s]) => ({
|
||||
name,
|
||||
matches: s.matches,
|
||||
w: s.w,
|
||||
l: s.matches - s.w,
|
||||
wr: s.matches ? Math.round((s.w / s.matches) * 100) : 0,
|
||||
kd: s.matches ? Math.round(s.kdSum / s.matches) : 0,
|
||||
}))
|
||||
.sort((a, b) => orderIdx(a.name) - orderIdx(b.name))
|
||||
}
|
||||
|
||||
// 맵별 전적 — 상세 적재된 매치의 matchMap 으로 집계(맵 미보유=상세 미적재 매치는 제외).
|
||||
// 매치 많은 순. 커버리지는 상세 적재분에 한정(크론이 쌓일수록 완전해짐).
|
||||
function buildMapRecords(matches: SaMatchItem[]): MapRecord[] {
|
||||
const byMap = new Map<string, { matches: number; w: number; kdSum: number }>()
|
||||
for (const m of matches) {
|
||||
const map = m.matchMap
|
||||
if (!map) continue
|
||||
const cur = byMap.get(map) ?? { matches: 0, w: 0, kdSum: 0 }
|
||||
cur.matches++
|
||||
if (m.matchResult === '1') cur.w++
|
||||
const t = m.kill + m.death
|
||||
cur.kdSum += t ? (m.kill / t) * 100 : 0
|
||||
byMap.set(map, cur)
|
||||
}
|
||||
return [...byMap.entries()]
|
||||
.map(([name, s]) => ({
|
||||
name,
|
||||
matches: s.matches,
|
||||
w: s.w,
|
||||
l: s.matches - s.w,
|
||||
wr: s.matches ? Math.round((s.w / s.matches) * 100) : 0,
|
||||
kd: s.matches ? Math.round(s.kdSum / s.matches) : 0,
|
||||
}))
|
||||
.sort((a, b) => b.matches - a.matches)
|
||||
}
|
||||
|
||||
// 최근 동향 — 최근 매치를 20경기씩 묶어 버킷별 승률·킬뎃 추이(최대 200경기=10버킷).
|
||||
// 20경기 미만의 자투리 버킷은 제외(승률/킬뎃 신뢰도). 결과는 오래된→최신 순(차트 좌→우).
|
||||
function buildTrend(matches: SaMatchItem[]): TrendPoint[] {
|
||||
const BUCKET = 20
|
||||
const MAX = 200
|
||||
const recent = matches.slice(0, MAX) // 최신순(DESC)
|
||||
const fullCount = Math.floor(recent.length / BUCKET) * BUCKET
|
||||
const points: TrendPoint[] = []
|
||||
for (let i = 0; i < fullCount; i += BUCKET) {
|
||||
const chunk = recent.slice(i, i + BUCKET) // 최신쪽 20경기
|
||||
const w = chunk.filter((m) => m.matchResult === '1').length
|
||||
let kdSum = 0
|
||||
chunk.forEach((m) => {
|
||||
const t = m.kill + m.death
|
||||
kdSum += t ? (m.kill / t) * 100 : 0
|
||||
})
|
||||
points.push({
|
||||
wr: Math.round((w / BUCKET) * 100),
|
||||
kd: Math.round(kdSum / BUCKET),
|
||||
label: `${i + 1}-${i + BUCKET}`, // 최신 기준 경기 범위 (예: 1-20 = 최근 20경기)
|
||||
})
|
||||
}
|
||||
// points 는 최신→과거 순(i=0 이 최신) → 차트는 좌=과거이므로 뒤집는다
|
||||
return points.reverse()
|
||||
}
|
||||
|
||||
// 접속률 — 최근 90일 중 매치 기록이 있는 날(=접속·플레이한 날)의 비율(%).
|
||||
// 우리가 가진 가장 정확한 활동 신호가 '그 날 매치 존재' 여부다(스냅샷은 우리 크론 기록이라 활동 신호가 아님).
|
||||
function calcConnRate(matches: SaMatchItem[]): number {
|
||||
const WINDOW = 90
|
||||
const today = toKstDate(new Date())
|
||||
const from = toKstDate(new Date(Date.now() - (WINDOW - 1) * 24 * 60 * 60 * 1000))
|
||||
const days = new Set<string>()
|
||||
for (const m of matches) {
|
||||
const d = toKstDate(m.dateMatch)
|
||||
if (d >= from && d <= today) days.add(d)
|
||||
}
|
||||
return Math.round((days.size / WINDOW) * 100)
|
||||
}
|
||||
|
||||
// 넥슨 프로필/매치 → 기존 UI가 기대하는 mock Player 형태로 변환.
|
||||
// 넥슨이 제공하지 않는 항목(집계/추세/함께한 유저 등)은 빈 값으로 두고 2차에서 채운다.
|
||||
function adaptPlayer(
|
||||
pr: SaProfile,
|
||||
matches: SaMatchItem[],
|
||||
meta: SaMetaSections,
|
||||
clanPeriods: ClanPeriod[],
|
||||
seasons: SaSeason[],
|
||||
seasonTiers: SeasonTier[],
|
||||
): Player {
|
||||
const gradeImg = imageOf(meta, 'grade', pr.grade)
|
||||
const seasonImg = imageOf(meta, 'season_grade', pr.seasonGrade)
|
||||
const totalRank = makeRank(pr.grade, gradeImg)
|
||||
const seasonRank = makeRank(pr.seasonGrade, seasonImg)
|
||||
return {
|
||||
name: pr.userName,
|
||||
clan: pr.clanName || null,
|
||||
// 대표 계급 — 시즌 계급 우선(시즌 미배치 시 통합 계급으로 폴백)
|
||||
rank: pr.seasonGrade ? seasonRank : totalRank,
|
||||
rankTotal: totalRank,
|
||||
rankSeason: seasonRank,
|
||||
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: calcConnRate(matches),
|
||||
views: 0,
|
||||
contrib: 0,
|
||||
matches: adaptMatches(matches),
|
||||
rankedSeasons: buildRankedSeasons(pr, matches, meta, seasons, seasonTiers),
|
||||
rankedCareer: buildRankedCareer(matches),
|
||||
clanRecords: buildClanRecords(matches, clanPeriods),
|
||||
modeRecords: buildModeRecords(matches),
|
||||
mapRecords: buildMapRecords(matches),
|
||||
trend: buildTrend(matches),
|
||||
together: { games: 0, w: 0, l: 0 },
|
||||
}
|
||||
}
|
||||
|
||||
// opts.soft: 페이지를 비우지 않고 갱신(전적 갱신) / opts.force: 넥슨에서 프로필 강제 재동기화
|
||||
async function load(opts: { force?: boolean; soft?: boolean } = {}) {
|
||||
const name = (route.params.name as string)?.trim()
|
||||
if (!name) return
|
||||
if (opts.soft) {
|
||||
refreshing.value = true
|
||||
} else {
|
||||
loading.value = true
|
||||
notFound.value = false
|
||||
p.value = null
|
||||
}
|
||||
|
||||
const res = await searchOuid(name)
|
||||
if (!res) {
|
||||
if (!opts.soft) notFound.value = true
|
||||
loading.value = false
|
||||
refreshing.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const results = await Promise.all([
|
||||
getProfile(res.ouid, opts.force),
|
||||
getMeta(),
|
||||
getClanPeriods(res.ouid),
|
||||
getSeasons(),
|
||||
getSeasonTiers(res.ouid),
|
||||
...MATCH_TYPES.map((t) => getMatches(res.ouid, t)),
|
||||
])
|
||||
const profile = results[0] as SaProfile | null
|
||||
const meta = results[1] as SaMetaSections
|
||||
const clanPeriods = results[2] as ClanPeriod[]
|
||||
const seasons = results[3] as SaSeason[]
|
||||
const seasonTiers = results[4] as SeasonTier[]
|
||||
const matchLists = results.slice(5) as SaMatchItem[][]
|
||||
if (!profile) {
|
||||
if (!opts.soft) notFound.value = true
|
||||
loading.value = false
|
||||
refreshing.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const allMatches = matchLists
|
||||
.flat()
|
||||
.sort((a, b) => b.dateMatch.localeCompare(a.dateMatch))
|
||||
p.value = adaptPlayer(
|
||||
profile,
|
||||
allMatches,
|
||||
meta,
|
||||
clanPeriods,
|
||||
seasons,
|
||||
seasonTiers,
|
||||
)
|
||||
loading.value = false
|
||||
refreshing.value = false
|
||||
}
|
||||
|
||||
// 전적 갱신 — 페이지를 유지한 채(soft) 넥슨에서 강제 재동기화(force)
|
||||
function onRefresh() {
|
||||
if (refreshing.value) return
|
||||
void load({ force: true, soft: true })
|
||||
}
|
||||
|
||||
watch(() => route.params.name, () => load(), { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -37,49 +488,70 @@ 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"
|
||||
:refreshing="refreshing"
|
||||
@refresh="onRefresh"
|
||||
/>
|
||||
|
||||
<!-- 서브탭 -->
|
||||
<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" />
|
||||
<!-- '자주 함께한 유저' 비활성화 — 정확성 보장 불가(참가자 ouid 부재). 추후 대체 콘텐츠 예정
|
||||
<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 +579,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 +630,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>
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
// 관리자 대시보드 셸 — 좌측 사이드바(섹션 네비) + 우측 콘텐츠 슬롯. 풀 너비.
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
const NAV = [
|
||||
{ to: '/admin/maps', label: '맵 관리' },
|
||||
{ to: '/admin/seasons', label: '시즌 관리' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="adm">
|
||||
<aside class="adm__side">
|
||||
<div class="adm__brand">
|
||||
관리자
|
||||
</div>
|
||||
<nav class="adm__nav">
|
||||
<RouterLink
|
||||
v-for="n in NAV"
|
||||
:key="n.to"
|
||||
:to="n.to"
|
||||
class="adm__navitem"
|
||||
>
|
||||
{{ n.label }}
|
||||
</RouterLink>
|
||||
</nav>
|
||||
</aside>
|
||||
<main class="adm__main">
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.adm {
|
||||
display: flex;
|
||||
gap: 1.25rem;
|
||||
width: 100%;
|
||||
padding: 1.5rem 1.75rem 2.75rem;
|
||||
}
|
||||
.adm__side {
|
||||
flex-shrink: 0;
|
||||
width: 12rem;
|
||||
}
|
||||
.adm__brand {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 800;
|
||||
color: var(--t3);
|
||||
letter-spacing: 0.04em;
|
||||
padding: 0 0.75rem 0.625rem;
|
||||
}
|
||||
.adm__nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.adm__navitem {
|
||||
display: block;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: var(--r-sm);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
color: var(--t2);
|
||||
text-decoration: none;
|
||||
}
|
||||
.adm__navitem:hover {
|
||||
background: #f1f2f4;
|
||||
}
|
||||
.adm__navitem.router-link-active {
|
||||
background: var(--ink);
|
||||
color: #fff;
|
||||
}
|
||||
.adm__main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.adm {
|
||||
flex-direction: column;
|
||||
padding: 1.25rem 1rem 2.5rem;
|
||||
}
|
||||
.adm__side {
|
||||
width: 100%;
|
||||
}
|
||||
.adm__nav {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,343 @@
|
||||
<script setup lang="ts">
|
||||
// 관리자 — 서든어택 맵 이름 등록/수정/삭제. 5v5/3v3 구분은 시즌 설정에서 토글로 정한다.
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useSaMap, type SaMap } from '@/composables/useSaMap'
|
||||
import AdminLayout from './AdminLayout.vue'
|
||||
import UiPanel from '@/shared/components/UiPanel.vue'
|
||||
|
||||
const { getMaps, createMap, updateMap, deleteMap } = useSaMap()
|
||||
|
||||
const maps = ref<SaMap[]>([])
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const editingId = ref<number | null>(null)
|
||||
const name = ref('')
|
||||
|
||||
const isEditing = computed(() => editingId.value !== null)
|
||||
|
||||
async function loadMaps() {
|
||||
loading.value = true
|
||||
maps.value = await getMaps()
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
editingId.value = null
|
||||
name.value = ''
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
function startEdit(m: SaMap) {
|
||||
editingId.value = m.id
|
||||
name.value = m.name
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (saving.value) return
|
||||
if (!name.value.trim()) {
|
||||
error.value = '맵 이름을 입력해 주세요.'
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
const input = { name: name.value.trim() }
|
||||
const ok = isEditing.value
|
||||
? await updateMap(editingId.value as number, input)
|
||||
: await createMap(input)
|
||||
saving.value = false
|
||||
if (ok) {
|
||||
resetForm()
|
||||
await loadMaps()
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(m: SaMap) {
|
||||
if (!window.confirm(`'${m.name}' 맵을 삭제할까요?`)) return
|
||||
const ok = await deleteMap(m.id)
|
||||
if (ok) {
|
||||
if (editingId.value === m.id) resetForm()
|
||||
await loadMaps()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadMaps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminLayout>
|
||||
<header class="adm__head">
|
||||
<h1 class="adm__title">
|
||||
맵 관리
|
||||
</h1>
|
||||
<p class="adm__desc">
|
||||
서든어택 맵 이름을 등록합니다. 5v5 / 3v3 구분은 시즌 설정에서 이 목록을 토글로 선택합니다.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="adm__grid">
|
||||
<!-- 등록/수정 폼 -->
|
||||
<UiPanel
|
||||
:title="isEditing ? '맵 수정' : '맵 등록'"
|
||||
en="MAP"
|
||||
body-pad="18px 20px"
|
||||
>
|
||||
<form
|
||||
class="adm__form"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<div class="adm__field">
|
||||
<label
|
||||
class="adm__label"
|
||||
for="map-name"
|
||||
>맵 이름</label>
|
||||
<input
|
||||
id="map-name"
|
||||
v-model="name"
|
||||
class="adm__input"
|
||||
type="text"
|
||||
placeholder="예: 제3보급창고"
|
||||
maxlength="50"
|
||||
>
|
||||
</div>
|
||||
<p
|
||||
v-if="error"
|
||||
class="adm__error"
|
||||
role="alert"
|
||||
>
|
||||
{{ error }}
|
||||
</p>
|
||||
|
||||
<div class="adm__actions">
|
||||
<button
|
||||
v-if="isEditing"
|
||||
type="button"
|
||||
class="adm__btn"
|
||||
@click="resetForm"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="adm__btn adm__btn--primary"
|
||||
:disabled="saving"
|
||||
>
|
||||
{{ saving ? '저장 중…' : isEditing ? '수정 저장' : '등록' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</UiPanel>
|
||||
|
||||
<!-- 맵 목록 -->
|
||||
<UiPanel
|
||||
title="등록된 맵"
|
||||
en="LIST"
|
||||
body-pad="0"
|
||||
>
|
||||
<template #right>
|
||||
<span class="adm__count">{{ maps.length }}개</span>
|
||||
</template>
|
||||
<div
|
||||
v-if="loading"
|
||||
class="adm__empty"
|
||||
>
|
||||
불러오는 중…
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!maps.length"
|
||||
class="adm__empty"
|
||||
>
|
||||
등록된 맵이 없습니다.
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="rectable-scroll"
|
||||
>
|
||||
<table class="rectable map__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="map__th-no">
|
||||
#
|
||||
</th>
|
||||
<th>맵</th>
|
||||
<th class="ta-r">
|
||||
관리
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(m, i) in maps"
|
||||
:key="m.id"
|
||||
:class="{
|
||||
'map__row--alt': i % 2 === 1,
|
||||
'map__row--editing': editingId === m.id,
|
||||
}"
|
||||
>
|
||||
<td class="td-num map__no">
|
||||
{{ i + 1 }}
|
||||
</td>
|
||||
<td class="td-strong">
|
||||
{{ m.name }}
|
||||
</td>
|
||||
<td class="ta-r map__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="map__link"
|
||||
@click="startEdit(m)"
|
||||
>
|
||||
수정
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="map__link map__link--danger"
|
||||
@click="remove(m)"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</UiPanel>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.adm__head {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.adm__title {
|
||||
margin: 0;
|
||||
font-size: 1.375rem;
|
||||
font-weight: 800;
|
||||
color: var(--ink);
|
||||
}
|
||||
.adm__desc {
|
||||
margin: 0.375rem 0 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--t3);
|
||||
}
|
||||
.adm__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 22rem 1fr;
|
||||
gap: 1.25rem;
|
||||
align-items: start;
|
||||
}
|
||||
.adm__count {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--t3);
|
||||
font-weight: 600;
|
||||
}
|
||||
.adm__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.875rem;
|
||||
}
|
||||
.adm__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.adm__label {
|
||||
font-size: 0.71875rem;
|
||||
font-weight: 700;
|
||||
color: var(--t2);
|
||||
}
|
||||
.adm__input {
|
||||
padding: 0.5rem 0.625rem;
|
||||
border: 1px solid var(--line2);
|
||||
border-radius: var(--r-xs);
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--ink);
|
||||
background: var(--surf);
|
||||
}
|
||||
.adm__input:focus-visible {
|
||||
outline: 2px solid var(--point);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.adm__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.adm__btn {
|
||||
padding: 0.5rem 0.875rem;
|
||||
border: 1px solid var(--line2);
|
||||
border-radius: var(--r-xs);
|
||||
background: var(--surf);
|
||||
color: var(--t2);
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.adm__btn--primary {
|
||||
border: none;
|
||||
background: var(--ink);
|
||||
color: #fff;
|
||||
}
|
||||
.adm__btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.adm__error {
|
||||
margin: 0;
|
||||
font-size: 0.78125rem;
|
||||
font-weight: 600;
|
||||
color: var(--negative);
|
||||
}
|
||||
.adm__empty {
|
||||
padding: 1.75rem 0;
|
||||
text-align: center;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--t3);
|
||||
}
|
||||
/* 맵 목록 표 — 전적검색 모드별 통계(.rectable)와 동일 디자인 */
|
||||
.map__table tbody tr {
|
||||
border-bottom: 1px solid var(--line3);
|
||||
}
|
||||
.map__table tbody tr:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.map__row--alt {
|
||||
background: #fbfbfc;
|
||||
}
|
||||
.map__row--editing {
|
||||
background: color-mix(in srgb, var(--point) 8%, var(--surf));
|
||||
}
|
||||
.map__th-no,
|
||||
.map__no {
|
||||
width: 2.5rem;
|
||||
color: var(--t3);
|
||||
}
|
||||
.map__actions {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.map__link {
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.78125rem;
|
||||
font-weight: 700;
|
||||
color: var(--t2);
|
||||
padding: 0.25rem 0.375rem;
|
||||
}
|
||||
.map__link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.map__link--danger {
|
||||
color: var(--negative);
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.adm__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,448 @@
|
||||
<script setup lang="ts">
|
||||
// 관리자 — 시즌 관리(등록/수정/삭제 + 5v5/3v3 맵 토글 선택). 맵은 '맵 관리'에 등록된 목록에서 토글.
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useSaSeason, type SaSeason } from '@/composables/useSaSeason'
|
||||
import { useSaMap, type SaMap } from '@/composables/useSaMap'
|
||||
import AdminLayout from './AdminLayout.vue'
|
||||
import MapToggleGroup from './MapToggleGroup.vue'
|
||||
import UiPanel from '@/shared/components/UiPanel.vue'
|
||||
|
||||
const { getSeasons, createSeason, updateSeason, deleteSeason } = useSaSeason()
|
||||
const { getMaps } = useSaMap()
|
||||
|
||||
const seasons = ref<SaSeason[]>([])
|
||||
const allMaps = ref<SaMap[]>([])
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
// 폼 상태 — editingId 가 null 이면 등록, 값이 있으면 수정 모드
|
||||
const editingId = ref<number | null>(null)
|
||||
const name = ref('')
|
||||
const startDate = ref('')
|
||||
const endDate = ref('')
|
||||
const maps5v5 = ref<string[]>([])
|
||||
const maps3v3 = ref<string[]>([])
|
||||
|
||||
const isEditing = computed(() => editingId.value !== null)
|
||||
|
||||
async function loadSeasons() {
|
||||
loading.value = true
|
||||
seasons.value = await getSeasons()
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
editingId.value = null
|
||||
name.value = ''
|
||||
startDate.value = ''
|
||||
endDate.value = ''
|
||||
maps5v5.value = []
|
||||
maps3v3.value = []
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
function startEdit(s: SaSeason) {
|
||||
editingId.value = s.id
|
||||
name.value = s.name
|
||||
startDate.value = s.startDate
|
||||
endDate.value = s.endDate
|
||||
maps5v5.value = [...s.maps5v5]
|
||||
maps3v3.value = [...s.maps3v3]
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
function validate(): boolean {
|
||||
if (!name.value.trim()) {
|
||||
error.value = '시즌명을 입력해 주세요.'
|
||||
return false
|
||||
}
|
||||
if (!startDate.value || !endDate.value) {
|
||||
error.value = '시작일과 종료일을 모두 선택해 주세요.'
|
||||
return false
|
||||
}
|
||||
if (endDate.value < startDate.value) {
|
||||
error.value = '종료일은 시작일보다 빠를 수 없습니다.'
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (saving.value || !validate()) return
|
||||
saving.value = true
|
||||
const input = {
|
||||
name: name.value.trim(),
|
||||
startDate: startDate.value,
|
||||
endDate: endDate.value,
|
||||
maps5v5: maps5v5.value,
|
||||
maps3v3: maps3v3.value,
|
||||
}
|
||||
const ok = isEditing.value
|
||||
? await updateSeason(editingId.value as number, input)
|
||||
: await createSeason(input)
|
||||
saving.value = false
|
||||
if (ok) {
|
||||
resetForm()
|
||||
await loadSeasons()
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(s: SaSeason) {
|
||||
if (!window.confirm(`'${s.name}' 시즌을 삭제할까요?`)) return
|
||||
const ok = await deleteSeason(s.id)
|
||||
if (ok) {
|
||||
if (editingId.value === s.id) resetForm()
|
||||
await loadSeasons()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
allMaps.value = await getMaps()
|
||||
await loadSeasons()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminLayout>
|
||||
<header class="adm__head">
|
||||
<h1 class="adm__title">
|
||||
시즌 관리
|
||||
</h1>
|
||||
<p class="adm__desc">
|
||||
랭크전 시즌 기간과 5v5 / 3v3 맵을 등록하면, 시즌별 전적 집계의 기준이 됩니다.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="adm__grid">
|
||||
<!-- 등록/수정 폼 -->
|
||||
<UiPanel
|
||||
:title="isEditing ? '시즌 수정' : '시즌 등록'"
|
||||
en="SEASON"
|
||||
body-pad="18px 20px"
|
||||
>
|
||||
<form
|
||||
class="adm__form"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<div class="adm__field">
|
||||
<label
|
||||
class="adm__label"
|
||||
for="season-name"
|
||||
>시즌명</label>
|
||||
<input
|
||||
id="season-name"
|
||||
v-model="name"
|
||||
class="adm__input"
|
||||
type="text"
|
||||
placeholder="예: 2026 시즌 1"
|
||||
maxlength="50"
|
||||
>
|
||||
</div>
|
||||
<div class="adm__dates">
|
||||
<div class="adm__field">
|
||||
<label
|
||||
class="adm__label"
|
||||
for="season-start"
|
||||
>시작일</label>
|
||||
<input
|
||||
id="season-start"
|
||||
v-model="startDate"
|
||||
class="adm__input"
|
||||
type="date"
|
||||
>
|
||||
</div>
|
||||
<div class="adm__field">
|
||||
<label
|
||||
class="adm__label"
|
||||
for="season-end"
|
||||
>종료일</label>
|
||||
<input
|
||||
id="season-end"
|
||||
v-model="endDate"
|
||||
class="adm__input"
|
||||
type="date"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MapToggleGroup
|
||||
v-model="maps5v5"
|
||||
label="5v5 맵"
|
||||
:available="allMaps"
|
||||
/>
|
||||
<MapToggleGroup
|
||||
v-model="maps3v3"
|
||||
label="3v3 맵"
|
||||
:available="allMaps"
|
||||
/>
|
||||
|
||||
<p
|
||||
v-if="error"
|
||||
class="adm__error"
|
||||
role="alert"
|
||||
>
|
||||
{{ error }}
|
||||
</p>
|
||||
|
||||
<div class="adm__actions">
|
||||
<button
|
||||
v-if="isEditing"
|
||||
type="button"
|
||||
class="adm__btn"
|
||||
@click="resetForm"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="adm__btn adm__btn--primary"
|
||||
:disabled="saving"
|
||||
>
|
||||
{{ saving ? '저장 중…' : isEditing ? '수정 저장' : '등록' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</UiPanel>
|
||||
|
||||
<!-- 시즌 목록 -->
|
||||
<UiPanel
|
||||
title="등록된 시즌"
|
||||
en="LIST"
|
||||
body-pad="14px 16px"
|
||||
>
|
||||
<template #right>
|
||||
<span class="adm__count">{{ seasons.length }}개</span>
|
||||
</template>
|
||||
<div
|
||||
v-if="loading"
|
||||
class="adm__empty"
|
||||
>
|
||||
불러오는 중…
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!seasons.length"
|
||||
class="adm__empty"
|
||||
>
|
||||
등록된 시즌이 없습니다.
|
||||
</div>
|
||||
<table
|
||||
v-else
|
||||
class="adm__table"
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>시즌명</th>
|
||||
<th>기간</th>
|
||||
<th class="ta-c">
|
||||
맵 (5v5 / 3v3)
|
||||
</th>
|
||||
<th class="adm__th-actions">
|
||||
관리
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="s in seasons"
|
||||
:key="s.id"
|
||||
:class="{ 'adm__row--editing': editingId === s.id }"
|
||||
>
|
||||
<td class="adm__td-name">
|
||||
{{ s.name }}
|
||||
</td>
|
||||
<td class="num adm__td-period">
|
||||
{{ s.startDate }} ~ {{ s.endDate }}
|
||||
</td>
|
||||
<td class="ta-c num">
|
||||
{{ s.maps5v5.length }} / {{ s.maps3v3.length }}
|
||||
</td>
|
||||
<td class="adm__td-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="adm__link"
|
||||
@click="startEdit(s)"
|
||||
>
|
||||
수정
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="adm__link adm__link--danger"
|
||||
@click="remove(s)"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</UiPanel>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.adm__head {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.adm__title {
|
||||
margin: 0;
|
||||
font-size: 1.375rem;
|
||||
font-weight: 800;
|
||||
color: var(--ink);
|
||||
}
|
||||
.adm__desc {
|
||||
margin: 0.375rem 0 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--t3);
|
||||
}
|
||||
.adm__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 26rem 1fr;
|
||||
gap: 1.25rem;
|
||||
align-items: start;
|
||||
}
|
||||
.adm__count {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--t3);
|
||||
font-weight: 600;
|
||||
}
|
||||
.adm__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.875rem;
|
||||
}
|
||||
.adm__dates {
|
||||
display: flex;
|
||||
gap: 0.875rem;
|
||||
}
|
||||
.adm__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.adm__label {
|
||||
font-size: 0.71875rem;
|
||||
font-weight: 700;
|
||||
color: var(--t2);
|
||||
}
|
||||
.adm__input {
|
||||
padding: 0.5rem 0.625rem;
|
||||
border: 1px solid var(--line2);
|
||||
border-radius: var(--r-xs);
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--ink);
|
||||
background: var(--surf);
|
||||
}
|
||||
.adm__input:focus-visible {
|
||||
outline: 2px solid var(--point);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.adm__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.adm__btn {
|
||||
padding: 0.5rem 0.875rem;
|
||||
border: 1px solid var(--line2);
|
||||
border-radius: var(--r-xs);
|
||||
background: var(--surf);
|
||||
color: var(--t2);
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.adm__btn--primary {
|
||||
border: none;
|
||||
background: var(--ink);
|
||||
color: #fff;
|
||||
}
|
||||
.adm__btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.adm__error {
|
||||
margin: 0;
|
||||
font-size: 0.78125rem;
|
||||
font-weight: 600;
|
||||
color: var(--negative);
|
||||
}
|
||||
.adm__empty {
|
||||
padding: 1.75rem 0;
|
||||
text-align: center;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--t3);
|
||||
}
|
||||
.adm__table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.adm__table th {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
color: var(--t3);
|
||||
text-align: left;
|
||||
padding: 0.5rem 0.5rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.ta-c {
|
||||
text-align: center;
|
||||
}
|
||||
.ta-r {
|
||||
text-align: right;
|
||||
}
|
||||
/* 헤더는 `.adm__table th`(text-align:left)가 특이도상 우선하므로 동일 이상으로 재지정 */
|
||||
.adm__table th.ta-c {
|
||||
text-align: center;
|
||||
}
|
||||
.adm__table th.adm__th-actions {
|
||||
text-align: right;
|
||||
}
|
||||
.adm__table td {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--ink);
|
||||
padding: 0.625rem 0.5rem;
|
||||
border-bottom: 1px solid var(--line3);
|
||||
}
|
||||
.adm__row--editing {
|
||||
background: #f7f8f9;
|
||||
}
|
||||
.adm__td-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
.adm__td-period {
|
||||
color: var(--t2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.adm__td-actions {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.adm__link {
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.78125rem;
|
||||
font-weight: 700;
|
||||
color: var(--t2);
|
||||
padding: 0.25rem 0.375rem;
|
||||
}
|
||||
.adm__link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.adm__link--danger {
|
||||
color: var(--negative);
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.adm__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,98 @@
|
||||
<script setup lang="ts">
|
||||
// 맵 토글 그룹 — 등록된 맵(available)을 칩으로 보여주고 선택 토글. v-model: 선택된 맵 이름 string[]
|
||||
interface Props {
|
||||
label: string
|
||||
available: { name: string }[]
|
||||
modelValue: string[]
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: string[]): void
|
||||
}>()
|
||||
|
||||
function toggle(name: string) {
|
||||
const sel = props.modelValue
|
||||
emit(
|
||||
'update:modelValue',
|
||||
sel.includes(name) ? sel.filter((n) => n !== name) : [...sel, name],
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mtg">
|
||||
<div class="mtg__label">
|
||||
{{ label }}
|
||||
<span class="mtg__count">{{ modelValue.length }} / {{ available.length }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="!available.length"
|
||||
class="mtg__empty"
|
||||
>
|
||||
등록된 맵이 없습니다. ‘맵 관리’에서 먼저 등록하세요.
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="mtg__chips"
|
||||
>
|
||||
<button
|
||||
v-for="m in available"
|
||||
:key="m.name"
|
||||
type="button"
|
||||
class="mtg__chip"
|
||||
:class="{ 'mtg__chip--on': modelValue.includes(m.name) }"
|
||||
:aria-pressed="modelValue.includes(m.name)"
|
||||
@click="toggle(m.name)"
|
||||
>
|
||||
{{ m.name }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mtg {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.mtg__label {
|
||||
font-size: 0.71875rem;
|
||||
font-weight: 700;
|
||||
color: var(--t2);
|
||||
}
|
||||
.mtg__count {
|
||||
margin-left: 0.25rem;
|
||||
color: var(--point);
|
||||
}
|
||||
.mtg__empty {
|
||||
font-size: 0.71875rem;
|
||||
color: var(--t3);
|
||||
}
|
||||
.mtg__chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
/* 미선택 = 기본 태그(홈 '시즌 현황' 맵 태그 .sc__map 룩) / 선택 = 브랜드 컬러 채움 */
|
||||
.mtg__chip {
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: var(--r-xs);
|
||||
border: 1px solid var(--line2);
|
||||
background: var(--surf);
|
||||
color: var(--t2);
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.71875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mtg__chip:hover {
|
||||
border-color: var(--point);
|
||||
}
|
||||
.mtg__chip--on {
|
||||
border-color: var(--point);
|
||||
background: var(--point);
|
||||
color: var(--point-ink);
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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,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,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 {
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,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'
|
||||
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user