diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 98731c4..bdb1f19 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -133,6 +133,11 @@ jobs: --from-literal=google-client-id="${{ secrets.GOOGLE_CLIENT_ID }}" \ --from-literal=google-client-secret="${{ secrets.GOOGLE_CLIENT_SECRET }}" \ --from-literal=google-redirect-uri="${{ secrets.GOOGLE_REDIRECT_URI }}" \ + --from-literal=aws-access-key-id="${{ secrets.AWS_ACCESS_KEY_ID }}" \ + --from-literal=aws-secret-access-key="${{ secrets.AWS_SECRET_ACCESS_KEY }}" \ + --from-literal=aws-region="${{ secrets.AWS_REGION }}" \ + --from-literal=aws-s3-bucket="${{ secrets.AWS_S3_BUCKET }}" \ + --namespace=$NAMESPACE \ --dry-run=client -o yaml | kubectl apply -f - diff --git a/Dockerfile b/Dockerfile index 0e203a1..3072734 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,9 +11,13 @@ RUN apk add --no-cache tzdata \ ENV TZ=Asia/Seoul # Install dependencies for development and canvas build +# 기존 줄에서 py3-distutils 제거 RUN apk add --no-cache \ git \ python3 \ + py3-pip \ + py3-setuptools \ + py3-wheel \ make \ g++ \ cairo-dev \ @@ -22,7 +26,6 @@ RUN apk add --no-cache \ giflib-dev \ pixman-dev \ vips-dev - # Copy package files COPY package*.json ./ @@ -48,9 +51,13 @@ RUN apk add --no-cache tzdata \ ENV TZ=Asia/Seoul # Install dependencies for development and canvas build +# 기존 줄에서 py3-distutils 제거 RUN apk add --no-cache \ git \ python3 \ + py3-pip \ + py3-setuptools \ + py3-wheel \ make \ g++ \ cairo-dev \ @@ -60,7 +67,6 @@ RUN apk add --no-cache \ pixman-dev \ vips-dev - # Install dumb-init and curl for proper signal handling and health checks RUN apk add --no-cache dumb-init curl diff --git a/Dockerfile.dev b/Dockerfile.dev index d5fc785..76b90c8 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -11,9 +11,13 @@ RUN apk add --no-cache tzdata \ ENV TZ=Asia/Seoul # Install dependencies for development and canvas build +# 기존 줄에서 py3-distutils 제거 RUN apk add --no-cache \ git \ python3 \ + py3-pip \ + py3-setuptools \ + py3-wheel \ make \ g++ \ cairo-dev \ @@ -22,7 +26,6 @@ RUN apk add --no-cache \ giflib-dev \ pixman-dev \ vips-dev - # Copy package files COPY package*.json ./ diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml index ced0925..b238c86 100644 --- a/k8s/deployment.yaml +++ b/k8s/deployment.yaml @@ -7,7 +7,7 @@ metadata: app: nestjs-app component: api spec: - replicas: 3 + replicas: 6 revisionHistoryLimit: 2 selector: matchLabels: @@ -144,6 +144,26 @@ spec: secretKeyRef: name: app-secrets key: google-redirect-uri + - name: AWS_S3_BUCKET + valueFrom: + secretKeyRef: + name: app-secrets + key: aws-s3-bucket + - name: AWS_ACCESS_KEY_ID + valueFrom: + secretKeyRef: + name: app-secrets + key: aws-access-key-id + - name: AWS_SECRET_ACCESS_KEY + valueFrom: + secretKeyRef: + name: app-secrets + key: aws-secret-access-key + - name: AWS_REGION + valueFrom: + secretKeyRef: + name: app-secrets + key: aws-region resources: requests: memory: '512Mi' diff --git a/k8s/hpa.yaml b/k8s/hpa.yaml index 5db141e..d38c9af 100644 --- a/k8s/hpa.yaml +++ b/k8s/hpa.yaml @@ -8,39 +8,39 @@ spec: apiVersion: apps/v1 kind: Deployment name: nestjs-app - minReplicas: 3 + minReplicas: 6 maxReplicas: 10 metrics: - - type: Resource - resource: - name: cpu - target: - type: Utilization - averageUtilization: 70 - - type: Resource - resource: - name: memory - target: - type: Utilization - averageUtilization: 80 + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: 80 behavior: scaleDown: stabilizationWindowSeconds: 300 policies: - - type: Percent - value: 50 - periodSeconds: 60 - - type: Pods - value: 2 - periodSeconds: 60 + - type: Percent + value: 50 + periodSeconds: 60 + - type: Pods + value: 2 + periodSeconds: 60 selectPolicy: Min scaleUp: stabilizationWindowSeconds: 60 policies: - - type: Percent - value: 100 - periodSeconds: 60 - - type: Pods - value: 4 - periodSeconds: 60 + - type: Percent + value: 100 + periodSeconds: 60 + - type: Pods + value: 4 + periodSeconds: 60 selectPolicy: Max diff --git a/k8s/ingress.yaml b/k8s/ingress.yaml index 177a1b3..65ee653 100644 --- a/k8s/ingress.yaml +++ b/k8s/ingress.yaml @@ -9,31 +9,31 @@ metadata: alb.ingress.kubernetes.io/scheme: internet-facing alb.ingress.kubernetes.io/target-type: ip alb.ingress.kubernetes.io/load-balancer-name: nestjs-eks-alb - + # SSL/TLS 설정 alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:ap-northeast-2:863518449560:certificate/b9aec76a-3962-418e-a03a-4827ef59dd4c alb.ingress.kubernetes.io/listen-ports: '[{"HTTP": 80}, {"HTTPS": 443}]' alb.ingress.kubernetes.io/ssl-policy: ELBSecurityPolicy-TLS-1-2-2017-01 - + # Backend 프로토콜 설정 alb.ingress.kubernetes.io/backend-protocol: HTTP - + # 헬스체크 설정 alb.ingress.kubernetes.io/healthcheck-path: /health - alb.ingress.kubernetes.io/healthcheck-interval-seconds: "30" - alb.ingress.kubernetes.io/healthcheck-timeout-seconds: "5" - alb.ingress.kubernetes.io/healthy-threshold-count: "2" - alb.ingress.kubernetes.io/unhealthy-threshold-count: "3" - + alb.ingress.kubernetes.io/healthcheck-interval-seconds: '30' + alb.ingress.kubernetes.io/healthcheck-timeout-seconds: '5' + alb.ingress.kubernetes.io/healthy-threshold-count: '2' + alb.ingress.kubernetes.io/unhealthy-threshold-count: '3' + # Socket.IO/WebSocket 지원을 위한 ALB 속성 - alb.ingress.kubernetes.io/load-balancer-attributes: routing.http2.enabled=false,idle_timeout.timeout_seconds=300 - + alb.ingress.kubernetes.io/load-balancer-attributes: routing.http2.enabled=true,idle_timeout.timeout_seconds=300 + # Sticky Session 설정 (Target Group 레벨) alb.ingress.kubernetes.io/target-group-attributes: stickiness.enabled=true,stickiness.type=lb_cookie,stickiness.lb_cookie.duration_seconds=86400 - + # Sticky Session 강화 (Actions 레벨) alb.ingress.kubernetes.io/actions.forward-single: '{"type":"forward","forwardConfig":{"targetGroups":[{"serviceName":"nestjs-service","servicePort":80,"weight":100}],"targetGroupStickinessConfig":{"enabled":true,"durationSeconds":86400}}}' - + # 태그 설정 alb.ingress.kubernetes.io/tags: Environment=production,Application=nestjs-app diff --git a/package-lock.json b/package-lock.json index f1462b5..26e8f54 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "bull": "^4.16.5", "bullmq": "^5.56.0", "canvas": "^3.1.2", + "class-transformer": "^0.5.1", "class-validator": "^0.14.2", "cookie-parser": "^1.4.7", "dayjs": "^1.11.13", @@ -67,6 +68,7 @@ "jest": "^29.7.0", "prettier": "^3.4.2", "source-map-support": "^0.5.21", + "sqlite3": "^5.1.7", "supertest": "^7.0.0", "ts-jest": "^29.2.5", "ts-loader": "^9.5.2", @@ -1806,6 +1808,14 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@gar/promisify": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", + "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -4236,6 +4246,48 @@ "node": ">= 8" } }, + "node_modules/@npmcli/fs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", + "integrity": "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "@gar/promisify": "^1.0.1", + "semver": "^7.3.5" + } + }, + "node_modules/@npmcli/move-file": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.1.2.tgz", + "integrity": "sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==", + "deprecated": "This functionality has been moved to @npmcli/fs", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "mkdirp": "^1.0.4", + "rimraf": "^3.0.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@npmcli/move-file/node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "optional": true, + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@nuxt/opencollective": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@nuxt/opencollective/-/opencollective-0.4.1.tgz", @@ -5425,6 +5477,17 @@ "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==" }, + "node_modules/@tootallnate/once": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", + "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6" + } + }, "node_modules/@tsconfig/node10": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", @@ -6737,6 +6800,14 @@ "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", "dev": true }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "dev": true, + "license": "ISC", + "optional": true + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -6791,6 +6862,35 @@ "node": ">= 14" } }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -6952,6 +7052,14 @@ "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==" }, + "node_modules/aproba": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", + "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", + "dev": true, + "license": "ISC", + "optional": true + }, "node_modules/arch": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/arch/-/arch-3.0.0.tgz", @@ -6972,6 +7080,22 @@ } ] }, + "node_modules/are-we-there-yet": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", + "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", + "deprecated": "This package is no longer supported.", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -7224,6 +7348,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -7451,6 +7585,121 @@ "node": ">= 0.8" } }, + "node_modules/cacache": { + "version": "15.3.0", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-15.3.0.tgz", + "integrity": "sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "@npmcli/fs": "^1.0.0", + "@npmcli/move-file": "^1.0.1", + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "glob": "^7.1.4", + "infer-owner": "^1.0.4", + "lru-cache": "^6.0.0", + "minipass": "^3.1.1", + "minipass-collect": "^1.0.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.2", + "mkdirp": "^1.0.3", + "p-map": "^4.0.0", + "promise-inflight": "^1.0.1", + "rimraf": "^3.0.2", + "ssri": "^8.0.1", + "tar": "^6.0.2", + "unique-filename": "^1.1.1" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/cacache/node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "dev": true, + "license": "ISC", + "optional": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/cacache/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/cacache/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cacache/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cacache/node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "optional": true, + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cacache/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC", + "optional": true + }, "node_modules/cacheable-lookup": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz", @@ -7639,6 +7888,12 @@ "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", "dev": true }, + "node_modules/class-transformer": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", + "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", + "license": "MIT" + }, "node_modules/class-validator": { "version": "0.14.2", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.2.tgz", @@ -7650,6 +7905,17 @@ "validator": "^13.9.0" } }, + "node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, "node_modules/cli-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", @@ -7819,6 +8085,17 @@ "simple-swizzle": "^0.2.2" } }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "color-support": "bin.js" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -7892,6 +8169,14 @@ "node": "^14.18.0 || >=16.10.0" } }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "dev": true, + "license": "ISC", + "optional": true + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -8190,6 +8475,14 @@ "node": ">=0.4.0" } }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/denque": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", @@ -8358,6 +8651,17 @@ "node": ">= 0.8" } }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -8469,6 +8773,25 @@ "node": ">=10.13.0" } }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -9116,6 +9439,13 @@ "url": "https://github.com/sindresorhus/file-type?sponsor=1" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "devOptional": true, + "license": "MIT" + }, "node_modules/filelist": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", @@ -9421,6 +9751,39 @@ "node": ">=12" } }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs-minipass/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "devOptional": true, + "license": "ISC" + }, "node_modules/fs-monkey": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.6.tgz", @@ -9455,27 +9818,82 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/gaxios": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.1.tgz", - "integrity": "sha512-Odju3uBUJyVCkW64nLD4wKLhbh93bh6vIg/ZIXkWiLPBrdgtc65+tls/qml+un3pr6JqYVFDZbbmLDQT68rTOQ==", - "license": "Apache-2.0", + "node_modules/gauge": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", + "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", + "deprecated": "This package is no longer supported.", + "dev": true, + "license": "ISC", + "optional": true, "dependencies": { - "extend": "^3.0.2", - "https-proxy-agent": "^7.0.1", - "node-fetch": "^3.3.2" + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.3", + "console-control-strings": "^1.1.0", + "has-unicode": "^2.0.1", + "signal-exit": "^3.0.7", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.5" }, "engines": { - "node": ">=18" + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, - "node_modules/gcp-metadata": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-7.0.1.tgz", - "integrity": "sha512-UcO3kefx6dCcZkgcTGgVOTFb7b1LlQ02hY1omMjjrrBzkajRMCFgYOjs7J71WqnuG1k2b+9ppGL7FsOfhZMQKQ==", - "license": "Apache-2.0", - "dependencies": { - "gaxios": "^7.0.0", + "node_modules/gauge/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/gauge/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/gauge/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/gaxios": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.1.tgz", + "integrity": "sha512-Odju3uBUJyVCkW64nLD4wKLhbh93bh6vIg/ZIXkWiLPBrdgtc65+tls/qml+un3pr6JqYVFDZbbmLDQT68rTOQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-7.0.1.tgz", + "integrity": "sha512-UcO3kefx6dCcZkgcTGgVOTFb7b1LlQ02hY1omMjjrrBzkajRMCFgYOjs7J71WqnuG1k2b+9ppGL7FsOfhZMQKQ==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" }, @@ -9815,6 +10233,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "dev": true, + "license": "ISC", + "optional": true + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -9861,6 +10287,36 @@ "node": ">= 0.8" } }, + "node_modules/http-proxy-agent": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", + "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tootallnate/once": "1", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/http2-wrapper": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.2.1.tgz", @@ -9896,6 +10352,17 @@ "node": ">=10.17.0" } }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -9979,6 +10446,25 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/infer-owner": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", + "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", + "dev": true, + "license": "ISC", + "optional": true + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -10033,6 +10519,29 @@ "url": "https://opencollective.com/ioredis" } }, + "node_modules/ip-address": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", + "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "jsbn": "1.1.0", + "sprintf-js": "^1.1.3" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/ip-address/node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -10109,6 +10618,14 @@ "node": ">=8" } }, + "node_modules/is-lambda": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", + "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -10922,6 +11439,14 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsbn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", + "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -11290,6 +11815,111 @@ "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", "devOptional": true }, + "node_modules/make-fetch-happen": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz", + "integrity": "sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "agentkeepalive": "^4.1.3", + "cacache": "^15.2.0", + "http-cache-semantics": "^4.1.0", + "http-proxy-agent": "^4.0.1", + "https-proxy-agent": "^5.0.0", + "is-lambda": "^1.0.1", + "lru-cache": "^6.0.0", + "minipass": "^3.1.3", + "minipass-collect": "^1.0.2", + "minipass-fetch": "^1.3.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.2", + "promise-retry": "^2.0.1", + "socks-proxy-agent": "^6.0.0", + "ssri": "^8.0.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/make-fetch-happen/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/make-fetch-happen/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/make-fetch-happen/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/make-fetch-happen/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/make-fetch-happen/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/make-fetch-happen/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC", + "optional": true + }, "node_modules/makeerror": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", @@ -11467,6 +12097,225 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/minipass-collect": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", + "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-collect/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-collect/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/minipass-fetch": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-1.4.1.tgz", + "integrity": "sha512-CGH1eblLq26Y15+Azk7ey4xh0J/XfJfrCox5LDJiKqI2Q2iwOLOKrlmIaODiSQS8d18jalF6y2K2ePUm0CmShw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "minipass": "^3.1.0", + "minipass-sized": "^1.0.3", + "minizlib": "^2.0.0" + }, + "engines": { + "node": ">=8" + }, + "optionalDependencies": { + "encoding": "^0.1.12" + } + }, + "node_modules/minipass-fetch/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-fetch/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/minipass-flush": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", + "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-flush/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-flush/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/minipass-sized": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", + "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "devOptional": true, + "license": "ISC" + }, "node_modules/mkdirp": { "version": "0.5.6", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", @@ -11673,12 +12522,38 @@ "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/node-gyp": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-8.4.1.tgz", + "integrity": "sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "env-paths": "^2.2.0", + "glob": "^7.1.4", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^9.1.0", + "nopt": "^5.0.0", + "npmlog": "^6.0.0", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.2", + "which": "^2.0.2" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-fetch" + "engines": { + "node": ">= 10.12.0" } }, "node_modules/node-gyp-build-optional-packages": { @@ -11696,6 +12571,29 @@ "node-gyp-build-optional-packages-test": "build-test.js" } }, + "node_modules/node-gyp/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -11708,6 +12606,23 @@ "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", "dev": true }, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -11747,6 +12662,24 @@ "node": ">=8" } }, + "node_modules/npmlog": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", + "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", + "deprecated": "This package is no longer supported.", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "are-we-there-yet": "^3.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^4.0.3", + "set-blocking": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -11918,6 +12851,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-map": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", @@ -12429,6 +13379,29 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/promise-inflight": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", + "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -12779,6 +13752,17 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 4" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -12789,6 +13773,47 @@ "node": ">=0.10.0" } }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -12981,6 +14006,14 @@ "node": ">= 18" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "dev": true, + "license": "ISC", + "optional": true + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -13215,6 +14248,18 @@ "node": ">=8" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, "node_modules/socket.io": { "version": "4.8.1", "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz", @@ -13350,6 +14395,52 @@ "node": ">= 0.6" } }, + "node_modules/socks": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.6.tgz", + "integrity": "sha512-pe4Y2yzru68lXCb38aAqRf5gvN8YdjP1lok5o0J7BOHljkyCGKVz7H3vpVIXKD27rj2giOJ7DwVyk/GWrPHDWA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "ip-address": "^9.0.5", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-6.2.1.tgz", + "integrity": "sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "^6.0.2", + "debug": "^4.3.3", + "socks": "^2.6.2" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/socks-proxy-agent/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/sort-keys": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/sort-keys/-/sort-keys-1.1.2.tgz", @@ -13434,6 +14525,67 @@ "node": ">=14" } }, + "node_modules/sqlite3": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.7.tgz", + "integrity": "sha512-GGIyOiFaG+TUra3JIfkI/zGP8yZYLPQ0pl1bH+ODjiX57sPhrLU5sQJn1y9bDKZUFYkX1crlrPfSYt0BKKdkog==", + "devOptional": true, + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "bindings": "^1.5.0", + "node-addon-api": "^7.0.0", + "prebuild-install": "^7.1.1", + "tar": "^6.1.11" + }, + "optionalDependencies": { + "node-gyp": "8.x" + }, + "peerDependencies": { + "node-gyp": "8.x" + }, + "peerDependenciesMeta": { + "node-gyp": { + "optional": true + } + } + }, + "node_modules/ssri": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", + "integrity": "sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.1.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/ssri/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ssri/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC", + "optional": true + }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -13812,6 +14964,24 @@ "node": ">=6" } }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/tar-fs": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz", @@ -13851,6 +15021,46 @@ "streamx": "^2.15.0" } }, + "node_modules/tar/node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "devOptional": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "devOptional": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/tar/node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "devOptional": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "devOptional": true, + "license": "ISC" + }, "node_modules/terser": { "version": "5.43.1", "resolved": "https://registry.npmjs.org/terser/-/terser-5.43.1.tgz", @@ -14666,6 +15876,28 @@ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" }, + "node_modules/unique-filename": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz", + "integrity": "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "unique-slug": "^2.0.0" + } + }, + "node_modules/unique-slug": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.2.tgz", + "integrity": "sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "imurmurhash": "^0.1.4" + } + }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -15040,6 +16272,17 @@ "node": ">= 8" } }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index 3080145..04d6a0c 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "bull": "^4.16.5", "bullmq": "^5.56.0", "canvas": "^3.1.2", + "class-transformer": "^0.5.1", "class-validator": "^0.14.2", "cookie-parser": "^1.4.7", "dayjs": "^1.11.13", @@ -79,6 +80,7 @@ "jest": "^29.7.0", "prettier": "^3.4.2", "source-map-support": "^0.5.21", + "sqlite3": "^5.1.7", "supertest": "^7.0.0", "ts-jest": "^29.2.5", "ts-loader": "^9.5.2", diff --git a/sql/schema/create_db.sql b/sql/schema/create_db.sql new file mode 100644 index 0000000..0519ecb --- /dev/null +++ b/sql/schema/create_db.sql @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/sql/schema/schema.sql b/sql/schema/schema.sql index c0320a2..a261e14 100644 --- a/sql/schema/schema.sql +++ b/sql/schema/schema.sql @@ -63,7 +63,8 @@ create table if not exists user_canvas id bigserial, user_id bigint not null, canvas_id integer not null, - count integer default 0 not null, + try_count integer default 0 not null, + own_count integer default null, joined_at timestamp default CURRENT_TIMESTAMP not null, primary key (id), unique(user_id, canvas_id), @@ -82,7 +83,7 @@ create table if not exists groups name varchar(20) not null, created_at timestamp not null, updated_at timestamp not null, - max_participants int not null check (max_participants >= 1 and max_participants <= 200), + max_participants int not null check (max_participants >= 1 and max_participants <= 1000), current_participants_count int not null default 1, canvas_id bigint not null, made_by bigint not null, @@ -138,47 +139,42 @@ create table if not exists chats alter table chats owner to pixel_user; --- 관리자 계정 seed (email=pickpx0617@gmail.com, user_name=gmg team) -INSERT INTO users (email, password, created_at, updated_at, user_name) -VALUES ('pickpx0617@gmail.com', NULL, '2025-06-17 00:00:00.000000', '2025-06-17 00:00:00.000000', 'gmg team') -ON CONFLICT (email) DO NOTHING; - +-- 캔버스 히스토리 CREATE TABLE IF NOT EXISTS canvas_history ( canvas_id INTEGER PRIMARY KEY, - participant_count INTEGER NOT NULL DEFAULT 1, - attempt_count INTEGER NOT NULL DEFAULT 1, - top_participant_id BIGINT, - top_participant_attempts INTEGER, - top_pixel_owner_id BIGINT, - top_pixel_count INTEGER, + participant_count INTEGER NOT NULL DEFAULT 0, + total_try_count INTEGER NOT NULL DEFAULT 0, + top_try_user_id BIGINT, + top_try_user_count INTEGER, + top_own_user_id BIGINT, + top_own_user_count INTEGER, + image_url VARCHAR(1024), + captured_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (canvas_id) REFERENCES canvases(id), - FOREIGN KEY (top_participant_id) REFERENCES users(id) ON DELETE SET NULL, - FOREIGN KEY (top_pixel_owner_id) REFERENCES users(id) ON DELETE SET NULL + FOREIGN KEY (top_try_user_id) REFERENCES users(id) ON DELETE SET NULL, + FOREIGN KEY (top_own_user_id) REFERENCES users(id) ON DELETE SET NULL ); --- 캔버스 이미지 상태 스냅샷 저장 테이블 -CREATE TABLE IF NOT EXISTS image_history ( - id bigserial PRIMARY KEY, - canvas_history_id INTEGER NOT NULL, - image_url VARCHAR(1024) NOT NULL, - captured_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (canvas_history_id) REFERENCES canvas_history(canvas_id) ON DELETE CASCADE -); +-- 관리자 계정 seed (email=pickpx0617@gmail.com, user_name=gmg team) +INSERT INTO users (email, password, created_at, updated_at, user_name) +VALUES ('pickpx0617@gmail.com', NULL, '2025-06-17 00:00:00.000000', '2025-06-17 00:00:00.000000', 'gmg team') +ON CONFLICT (email) DO NOTHING; -- 문제 은행 CREATE TABLE IF NOT EXISTS questions ( - id bigserial PRIMARY KEY, - content TEXT NOT NULL, - answer INTEGER NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + id bigint PRIMARY KEY, + question TEXT NOT NULL, + options TEXT[] NOT NULL, + answer INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS question_user ( id bigserial PRIMARY KEY, user_id BIGINT NOT NULL, + canvas_id BIGINT NOT NULL, question_id BIGINT NOT NULL, submitted_answer INTEGER, - is_correct BOOLEAN NOT NULL, + is_correct BOOLEAN NOT NULL default true, submitted_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY (question_id) REFERENCES questions(id) ON DELETE CASCADE @@ -191,8 +187,12 @@ CREATE TABLE IF NOT EXISTS game_user_result ( canvas_id INTEGER NOT NULL, rank INTEGER, assigned_color VARCHAR(7), + life INTEGER DEFAULT 2, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, - FOREIGN KEY (canvas_id) REFERENCES canvases(id) ON DELETE CASCADE, - UNIQUE (user_id, canvas_id) -); \ No newline at end of file + FOREIGN KEY (canvas_id) REFERENCES canvases(id) ON DELETE CASCADE +); + +-- game_user_result 중복 방지 인덱스 +CREATE UNIQUE INDEX IF NOT EXISTS idx_game_user_result_user_canvas + ON game_user_result (user_id, canvas_id); \ No newline at end of file diff --git a/src/app.gateway.ts b/src/app.gateway.ts index e7f548f..814b8dc 100644 --- a/src/app.gateway.ts +++ b/src/app.gateway.ts @@ -104,6 +104,7 @@ export class AppGateway // 서버 시작 시 이전 소켓 ID들 정리 private async cleanupOldSockets() { try { + // 기존 active_sockets 삭제 const oldSocketCount = await this.redis.scard('active_sockets'); if (oldSocketCount > 0) { await this.redis.del('active_sockets'); @@ -111,8 +112,16 @@ export class AppGateway `[AppGateway] 서버 시작 시 이전 소켓 ID ${oldSocketCount}개 정리됨` ); } + // canvas:*:sockets 키 모두 삭제 + const canvasSocketKeys = await this.redis.keys('canvas:*:sockets'); + if (canvasSocketKeys.length > 0) { + await this.redis.del(...canvasSocketKeys); + console.log( + `[AppGateway] 서버 시작 시 canvas:*:sockets 키 ${canvasSocketKeys.length}개 삭제 완료` + ); + } } catch (error) { - console.error('[AppGateway] 이전 소켓 정리 중 에러:', error); + console.error('[AppGateway] 이전 소켓/canvas 정리 중 에러:', error); } } diff --git a/src/app.module.ts b/src/app.module.ts index b49d46e..16b165f 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -18,13 +18,13 @@ import { GroupUser } from './entity/GroupUser.entity'; import { HttpModule } from '@nestjs/axios'; import { AppGateway } from './app.gateway'; import { AwsModule } from './aws/aws.module'; -import { ScheduleModule } from '@nestjs/schedule'; import { PixelModule } from './pixel/pixel.module'; import { CanvasHistory } from './canvas/entity/canvasHistory.entity'; -import { ImageHistory } from './canvas/entity/imageHistory.entity'; -import { Question } from './entity/questions.entity'; +import { Question } from './game/entity/questions.entity'; import { QuestionUser } from './game/entity/question_user.entity'; import { GameUserResult } from './game/entity/game_result.entity'; +import { GameController } from './game/game.controller'; +import { GameModule } from './game/game.module'; @Module({ imports: [ @@ -57,7 +57,6 @@ import { GameUserResult } from './game/entity/game_result.entity'; Group, GroupUser, CanvasHistory, - ImageHistory, Question, QuestionUser, GameUserResult, @@ -89,7 +88,6 @@ import { GameUserResult } from './game/entity/game_result.entity'; Group, GroupUser, CanvasHistory, - ImageHistory, GameUserResult, Question, QuestionUser, @@ -100,7 +98,6 @@ import { GameUserResult } from './game/entity/game_result.entity'; }, inject: [ConfigService], }), - ScheduleModule.forRoot(), RedisModule, CanvasModule, DatabaseModule, @@ -110,8 +107,9 @@ import { GameUserResult } from './game/entity/game_result.entity'; HttpModule, AwsModule, PixelModule, + GameModule, ], - controllers: [AppController], + controllers: [AppController, GameController], providers: [ AppService, // Gateway 초기화 순서 보장 diff --git a/src/auth/jwt.strategy.ts b/src/auth/jwt.strategy.ts index e1954f2..fb90707 100644 --- a/src/auth/jwt.strategy.ts +++ b/src/auth/jwt.strategy.ts @@ -30,7 +30,6 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { if (!user) { throw new UnauthorizedException('Invalid token'); } - console.log('in validate: ', user); return { _id: user.id }; } catch (err) { console.log(err); diff --git a/src/aws/aws.service.ts b/src/aws/aws.service.ts index 8fb3867..60ace1a 100644 --- a/src/aws/aws.service.ts +++ b/src/aws/aws.service.ts @@ -4,6 +4,7 @@ import { PutObjectCommandInput, PutObjectCommand, DeleteObjectCommand, + GetObjectCommand, } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import * as dotenv from 'dotenv'; @@ -64,4 +65,16 @@ export class AwsService { }) ); } + + async getPreSignedUrl(key: string): Promise { + const command = new GetObjectCommand({ + Bucket: process.env.AWS_S3_BUCKET!, + Key: key, + }); + + const url = await getSignedUrl(this.s3, command, { + expiresIn: 3000, + }); + return url; + } } diff --git a/src/canvas/batch/canvasHistory.batch.ts b/src/canvas/batch/canvasHistory.batch.ts index 298d808..7356ee8 100644 --- a/src/canvas/batch/canvasHistory.batch.ts +++ b/src/canvas/batch/canvasHistory.batch.ts @@ -1,34 +1,29 @@ import { Injectable } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; -import { InjectQueue } from '@nestjs/bull'; -import { Queue } from 'bull'; import { Canvas } from '../entity/canvas.entity'; import { CanvasService } from '../canvas.service'; +import { + isEndingWithOneDay, + putJobOnAlarmQueue3SecsBeforeStart, + putJobOnAlarmQueueBeforeStart30s, + putJobOnAlarmQueueThreeSecBeforeEnd, +} from 'src/util/alarmGenerator.util'; @Injectable() export class CanvasHistoryBatch { - constructor( - private readonly canvasService: CanvasService, - @InjectQueue('canvas-history') private readonly historyQueue: Queue - ) {} + constructor(private readonly canvasService: CanvasService) {} @Cron('0 0 * * *') // 매일 자정에 실행 async handleCanvasHistoryBatch() { const canvases: Canvas[] = - await this.canvasService.findCanvasesEndingWithinDays(3); + await this.canvasService.findCanvasesEndingWithinDays(1); for (const canvas of canvases) { - const jobId = `${canvas.id}`; - const now = Date.now(); - const endedAtTime = new Date(canvas.endedAt).getTime(); - const delay = endedAtTime - now; - await this.historyQueue.add( - 'canvas-history', - { canvas_id: canvas.id }, - { - jobId, - delay, - } - ); + await isEndingWithOneDay(canvas); + if (canvas.type.startsWith('game_')) { + await putJobOnAlarmQueue3SecsBeforeStart(canvas); + await putJobOnAlarmQueueBeforeStart30s(canvas); + await putJobOnAlarmQueueThreeSecBeforeEnd(canvas); + } } } } diff --git a/src/canvas/canvas-history.service.ts b/src/canvas/canvas-history.service.ts new file mode 100644 index 0000000..bf73b3c --- /dev/null +++ b/src/canvas/canvas-history.service.ts @@ -0,0 +1,177 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, DataSource } from 'typeorm'; +import { Canvas } from './entity/canvas.entity'; +import { UserCanvas } from '../entity/UserCanvas.entity'; +import { Pixel } from '../pixel/entity/pixel.entity'; +import { CanvasHistory } from './entity/canvasHistory.entity'; +import { User } from '../user/entity/user.entity'; +import { AwsService } from '../aws/aws.service'; + +@Injectable() +export class CanvasHistoryService { + constructor( + @InjectRepository(Canvas) + private readonly canvasRepository: Repository, + @InjectRepository(UserCanvas) + private readonly userCanvasRepository: Repository, + @InjectRepository(Pixel) + private readonly pixelRepository: Repository, + @InjectRepository(CanvasHistory) + private readonly canvasHistoryRepository: Repository, + @InjectRepository(User) + private readonly userRepository: Repository, + private readonly dataSource: DataSource, + private readonly awsService: AwsService // AwsService DI 추가 + ) {} + + /** + * 캔버스 종료 시 히스토리 데이터 생성 + * 최적화된 단순 쿼리로 처리 + * 동점자 처리: joined_at 빠른 순, user_id 작은 순 + */ + async createCanvasHistory(canvasId: number): Promise { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + // 1. 캔버스 정보 조회 + const canvas = await this.canvasRepository.findOne({ + where: { id: canvasId } + }); + if (!canvas) throw new Error('Canvas not found'); + + // 2. 기본 통계 데이터 조회 + const basicStatsQuery = ` + SELECT + COUNT(DISTINCT uc.user_id) as participant_count, + SUM(uc.try_count) as total_try_count + FROM user_canvas uc + WHERE uc.canvas_id = $1 + `; + const basicStats = await queryRunner.query(basicStatsQuery, [canvasId]); + + // 3. top_try_user 조회 (인덱스 활용) + const topTryUserQuery = ` + SELECT uc.user_id, uc.try_count + FROM user_canvas uc + WHERE uc.canvas_id = $1 AND uc.try_count > 0 + ORDER BY uc.try_count DESC, uc.joined_at ASC, uc.user_id ASC + LIMIT 1 + `; + const topTryUser = await queryRunner.query(topTryUserQuery, [canvasId]); + + // 4. top_own_user 조회 (인덱스 활용) + const topOwnUserQuery = ` + SELECT + p.owner as user_id, + COUNT(*) as own_count + FROM pixels p + WHERE p.canvas_id = $1 AND p.owner IS NOT NULL + GROUP BY p.owner + ORDER BY COUNT(*) DESC, + (SELECT joined_at FROM user_canvas WHERE user_id = p.owner AND canvas_id = $1) ASC, + p.owner ASC + LIMIT 1 + `; + const topOwnUser = await queryRunner.query(topOwnUserQuery, [canvasId]); + + // 5. own_count 업데이트 (최적화된 배치 업데이트) + const updateOwnCountQuery = ` + UPDATE user_canvas uc + SET own_count = COALESCE( + (SELECT COUNT(*) FROM pixels p WHERE p.owner = uc.user_id AND p.canvas_id = uc.canvas_id), + 0 + ) + WHERE uc.canvas_id = $1 + `; + await queryRunner.query(updateOwnCountQuery, [canvasId]); + + // 6. CanvasHistory 생성 + const canvasHistory = this.canvasHistoryRepository.create({ + canvasId, + participantCount: basicStats[0]?.participant_count || 0, + totalTryCount: basicStats[0]?.total_try_count || 0, + topTryUserId: topTryUser[0]?.user_id || null, + topTryUserCount: topTryUser[0]?.try_count || null, + topOwnUserId: topOwnUser[0]?.user_id || null, + topOwnUserCount: topOwnUser[0]?.own_count || null + }); + + await this.canvasHistoryRepository.save(canvasHistory); + await queryRunner.commitTransaction(); + + console.log(`[CanvasHistoryService] 캔버스 ${canvasId} 히스토리 생성 완료 (최적화됨)`); + } catch (error) { + await queryRunner.rollbackTransaction(); + console.error(`[CanvasHistoryService] 캔버스 ${canvasId} 히스토리 생성 실패:`, error); + throw error; + } finally { + await queryRunner.release(); + } + } + + /** + * 갤러리 API용 데이터 조회 + */ + async getGalleryData(): Promise { + const query = ` + SELECT + c.id, + c.title, + c.type, + c.created_at, + c.ended_at, + c.size_x, + c.size_y, + ch.participant_count, + ch.total_try_count, + ch.top_try_user_count, + ch.top_own_user_count, + ch.image_url as image_url, + top_try_user.user_name as top_try_user_name, + top_own_user.user_name as top_own_user_name + FROM canvases c + LEFT JOIN canvas_history ch ON c.id = ch.canvas_id + LEFT JOIN users top_try_user ON ch.top_try_user_id = top_try_user.id + LEFT JOIN users top_own_user ON ch.top_own_user_id = top_own_user.id + WHERE c.ended_at IS NOT NULL + AND c.ended_at <= (NOW() AT TIME ZONE 'Asia/Seoul') + AND c.type IN ('event_common', 'event_colorlimit', 'game_calculation') + ORDER BY c.ended_at DESC + `; + + const results = await this.dataSource.query(query); + // presigned URL 변환 (비동기 map) + return await Promise.all(results.map(async row => { + let presignedUrl: string | null = null; + if (row.image_url) { + try { + presignedUrl = await this.awsService.getPreSignedUrl(row.image_url); + console.log(`[GalleryData] presignedUrl 생성: key=${row.image_url}, url=${presignedUrl}`); + } catch (e) { + console.error(`[GalleryData] presignedUrl 생성 실패: key=${row.image_url}`, e); + presignedUrl = null; + } + } else { + console.log(`[GalleryData] image_url 없음: row=`, row); + } + return { + image_url: presignedUrl, + title: row.title, + type: row.type, + created_at: row.created_at, + ended_at: row.ended_at, + size_x: row.size_x, + size_y: row.size_y, + participant_count: row.participant_count, + total_try_count: row.total_try_count, + top_try_user_name: row.top_try_user_name ?? null, + top_try_user_count: row.top_try_user_count ?? null, + top_own_user_name: row.top_own_user_name ?? null, + top_own_user_count: row.top_own_user_count ?? null + }; + })); + } +} \ No newline at end of file diff --git a/src/canvas/canvas.controller.ts b/src/canvas/canvas.controller.ts index 4543365..bee0dd3 100644 --- a/src/canvas/canvas.controller.ts +++ b/src/canvas/canvas.controller.ts @@ -11,7 +11,6 @@ import { } from '@nestjs/common'; import { CanvasService } from './canvas.service'; import { createCanvasDto } from './dto/create_canvas_dto.dto'; -import { PixelInfo } from '../interface/PixelInfo.interface'; import { ApiTags, ApiOperation, @@ -23,7 +22,7 @@ import { import { Response } from 'express'; import * as zlib from 'zlib'; -@ApiTags('api/canvas') +@ApiTags('canvas') @Controller('api/canvas') export class CanvasController { constructor(private readonly canvasService: CanvasService) {} diff --git a/src/canvas/canvas.gateway.ts b/src/canvas/canvas.gateway.ts index 4da518f..36e046b 100644 --- a/src/canvas/canvas.gateway.ts +++ b/src/canvas/canvas.gateway.ts @@ -11,8 +11,10 @@ import { CanvasService } from './canvas.service'; import Redis from 'ioredis'; import { Inject } from '@nestjs/common'; import { DrawPixelResponse } from '../interface/DrawPixelResponse.interface'; -import { PixelUpdateEvent } from '../interface/PixelInfo.interface'; import { createAdapter } from '@socket.io/redis-adapter'; +import { setSocketServer } from '../socket/socket.manager'; +import { GameLogicService } from '../game/game-logic.service'; +import { GameStateService } from '../game/game-state.service'; interface SocketUser { id: number; @@ -36,12 +38,14 @@ export class CanvasGateway implements OnGatewayInit { constructor( private readonly canvasService: CanvasService, @Inject('REDIS_CLIENT') - private readonly redis: Redis + private readonly redis: Redis, + private readonly gameLogicService: GameLogicService, // 게임 특화 로직 주입 + private readonly gameStateService: GameStateService, // 게임 상태 관리 주입 ) {} afterInit(server: Server) { console.log('[CanvasGateway] afterInit 메서드 호출됨'); - + setSocketServer(this.server); // AppGateway 초기화 완료 대기 setTimeout(() => { this.initializeRedisAdapter(server); @@ -102,7 +106,9 @@ export class CanvasGateway implements OnGatewayInit { } // Redis 세션에서 전체 유저 정보 가져오기 (username 등 활용 가능) - private async getUserInfoFromClient(client: Socket): Promise { + private async getUserInfoFromClient( + client: Socket + ): Promise { try { const sessionKey = `socket:${client.id}:user`; const userData = await this.redis.get(sessionKey); @@ -191,6 +197,32 @@ export class CanvasGateway implements OnGatewayInit { `[CanvasGateway] 소켓 ${client.id}가 캔버스 ${canvasId}에 참여함 (로그인: ${userId ? '예' : '아니오'})` ); + // 게임 캔버스인 경우 유저 초기화 및 색 배정 + if (userId) { + const canvasType = await this.canvasService.getCanvasType(data.canvas_id); + if (canvasType === 'game_calculation') { + // 캔버스 종료 상태 체크 + const canvasInfo = await this.canvasService.getCanvasById(data.canvas_id); + const now = new Date(); + if (canvasInfo?.metaData?.endedAt && now > canvasInfo.metaData.endedAt) { + // 이미 종료된 캔버스라면 결과 브로드캐스트 트리거 + await this.gameLogicService.forceGameEnd(data.canvas_id, this.server); + client.emit('game_error', { message: '게임이 이미 종료되었습니다. 결과를 확인하세요.' }); + return; + } + // 게임 캔버스: 유저 상태 초기화 (life=2, try_count=0, own_count=0, dead=false) + await this.gameLogicService.initializeUserForGame(data.canvas_id, String(userId)); + + // 색 배정 (중복 방지) + const existingColor = await this.gameStateService.getUserColor(data.canvas_id, String(userId)); + if (!existingColor) { + const color = await this.assignUniqueColor(data.canvas_id, String(userId)); + await this.gameStateService.setUserColor(data.canvas_id, String(userId), color); + console.log(`[CanvasGateway] 유저 ${userId}에게 색 ${color} 배정 완료`); + } + } + } + // 쿨다운 정보 자동 푸시 if (userId && data.canvas_id) { try { @@ -206,6 +238,29 @@ export class CanvasGateway implements OnGatewayInit { } } + // 중복되지 않는 색 배정 + private async assignUniqueColor(canvasId: string, userId: string): Promise { + const { generatorColor } = await import('../util/colorGenerator.util'); + const maxColors = 1000; + // 1. 이미 사용 중인 색상 집합 + const allUsers = await this.gameStateService.getAllUsersInGame(canvasId); + const usedColors = new Set(); + for (const uid of allUsers) { + if (uid === userId) continue; + const userColor = await this.gameStateService.getUserColor(canvasId, uid); + if (userColor) usedColors.add(userColor); + } + // 2. 미사용 색상 인덱스 리스트 + for (let i = 0; i < maxColors; i++) { + const color = generatorColor(i, maxColors); + if (!usedColors.has(color)) { + return color; + } + } + // 3. 모든 색상이 다 쓰였으면 fallback + return '#ffffff'; + } + // 픽셀 그리기 요청 @SubscribeMessage('draw_pixel_simul') async handleDrawPixelForSimulator( @@ -261,4 +316,22 @@ export class CanvasGateway implements OnGatewayInit { console.error('[Gateway] 픽셀 그리기 에러:', error); } } + + @SubscribeMessage('send_result') + async handleSendResult( + @MessageBody() data: { canvas_id: string; x: number; y: number; color: string; result: boolean }, + @ConnectedSocket() client: Socket + ) { + console.log('[CanvasGateway] send_result 이벤트 진입', data); + const canvasType = await this.canvasService.getCanvasType(data.canvas_id); + console.log('[CanvasGateway] 캔버스 타입:', canvasType); + if (canvasType === 'game_calculation') { + console.log('[CanvasGateway] gameLogicService.handleSendResult 호출'); + await this.gameLogicService.handleSendResult(data, client, this.server); + console.log('[CanvasGateway] gameLogicService.handleSendResult 완료'); + return; + } + console.log('[CanvasGateway] 일반/이벤트 캔버스 - send_result 무시'); + // (일반/이벤트 캔버스에서는 무시) + } } diff --git a/src/canvas/canvas.module.ts b/src/canvas/canvas.module.ts index cdc7181..88988c3 100644 --- a/src/canvas/canvas.module.ts +++ b/src/canvas/canvas.module.ts @@ -2,25 +2,29 @@ import { Module, forwardRef } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Canvas } from './entity/canvas.entity'; import { Pixel } from '../pixel/entity/pixel.entity'; -import { User } from '../user/entity/user.entity'; import { CanvasService } from './canvas.service'; import { CanvasController } from './canvas.controller'; import { CanvasGateway } from './canvas.gateway'; import { Group } from '../group/entity/group.entity'; // 추가 -import { CanvasHistory } from './entity/canvasHistory.entity'; -import { ImageHistory } from './entity/imageHistory.entity'; import { UserCanvas } from '../entity/UserCanvas.entity'; +import { CanvasHistory } from './entity/canvasHistory.entity'; +import { User } from '../user/entity/user.entity'; import { JwtModule } from '@nestjs/jwt'; import { AuthModule } from '../auth/auth.module'; -import { BullModule } from '@nestjs/bull'; -import { redisConnection } from '../queues/bullmq.config'; import { GroupModule } from '../group/group.module'; import { PixelModule } from '../pixel/pixel.module'; import { CanvasStrategyFactory } from './strategy/createFactory.factory'; -import { PublicCanvasStrategy } from './strategy/publicCanvasStrategy.strategy'; -import { EventCanvasStrategy } from './strategy/eventCanvasStrategy.strategy'; -import { GameCanvasStrategy } from './strategy/gameCanvasStrategy.strategy'; +import { CanvasHistoryService } from './canvas-history.service'; +import { GalleryController } from './gallery.controller'; import { UserModule } from '../user/user.module'; +import { GameModule } from '../game/game.module'; +import { PublicCanvasStrategy } from './strategy/publicCanvasStrategy.strategy'; +import { GameCalculationCanvasStrategy } from './strategy/gameCalculationCanvasStrategy.strategy'; +import { EventCommonCanvasStrategy } from './strategy/eventCommonCanvasStrategy.strategy'; +import { EventColorLimitCanvasStrategy } from './strategy/eventColorLimitCanvasStrategy.strategy'; +import { AwsModule } from '../aws/aws.module'; +import { ScheduleModule } from '@nestjs/schedule'; +import { CanvasHistoryBatch } from './batch/canvasHistory.batch'; @Module({ imports: [ @@ -32,30 +36,28 @@ import { UserModule } from '../user/user.module'; Pixel, Group, UserCanvas, - ImageHistory, CanvasHistory, ]), + ScheduleModule.forRoot(), JwtModule.register({}), AuthModule, - BullModule.registerQueueAsync({ - name: 'canvas-history', - useFactory: () => ({ - name: 'canvas-history', - connection: redisConnection, - }), - }), GroupModule, PixelModule, + forwardRef(() => GameModule), // GameModule 추가 + AwsModule, // AwsModule 추가 ], - controllers: [CanvasController], + controllers: [CanvasController, GalleryController], providers: [ CanvasService, CanvasGateway, CanvasStrategyFactory, PublicCanvasStrategy, - GameCanvasStrategy, - EventCanvasStrategy, + GameCalculationCanvasStrategy, + EventCommonCanvasStrategy, + EventColorLimitCanvasStrategy, + CanvasHistoryService, + CanvasHistoryBatch, ], - exports: [CanvasService, BullModule], + exports: [CanvasService, CanvasHistoryService], }) export class CanvasModule {} diff --git a/src/canvas/canvas.service.ts b/src/canvas/canvas.service.ts index 544d8d5..07e379a 100644 --- a/src/canvas/canvas.service.ts +++ b/src/canvas/canvas.service.ts @@ -4,7 +4,6 @@ import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; import { DataSource, Repository } from 'typeorm'; import { Canvas } from './entity/canvas.entity'; import Redis from 'ioredis'; -import { Group } from '../group/entity/group.entity'; import { UserCanvas } from '../entity/UserCanvas.entity'; import { CanvasInfo } from '../interface/CanvasInfo.interface'; import { DrawPixelResponse } from '../interface/DrawPixelResponse.interface'; @@ -42,6 +41,9 @@ export class CanvasService { color: string; userId: number; }): Promise { + console.log( + `[tryDrawPixel] 호출: canvas_id=${canvas_id}, x=${x}, y=${y}, color=${color}, userId=${userId}` + ); try { const hashKey = `canvas:${canvas_id}`; const field = `${x}:${y}`; @@ -235,7 +237,7 @@ export class CanvasService { async generateCanvasHistory(canvas: Canvas): Promise { await this.historyRepository.save({ - canvas_id: canvas.id, + canvasId: canvas.id, }); } async findCanvasesEndingWithinDays(day: number) { @@ -250,7 +252,7 @@ export class CanvasService { } // 캔버스 활성 상태 체크 (Redis 캐시 우선, DB 폴백) - private async isCanvasActive(canvasId: number): Promise { + async isCanvasActive(canvasId: number): Promise { try { // 1. Redis 캐시에서 조회 const cacheKey = `canvas:active:${canvasId}`; @@ -319,6 +321,9 @@ export class CanvasService { color: string; userId: number; }): Promise { + console.log( + `[applyDrawPixel] 호출: canvas_id=${canvas_id}, x=${x}, y=${y}, color=${color}, userId=${userId}` + ); // 픽셀 단위 분산락 (동시성 제어) const lockKey = `lock:${canvas_id}:${x}:${y}`; const lockUser = userId.toString(); @@ -334,12 +339,15 @@ export class CanvasService { ); if (!is_locked) { + console.warn( + `[applyDrawPixel] 동시성 발생! canvas-id : ${canvas_id}, ${x}:${y}` + ); // 이미 다른 사용자가 락을 선점한 경우 - console.warn(`동시성 발생! canvas-id : ${canvas_id}, ${x}:${y}`); return false; } try { + console.log(`[applyDrawPixel] 락 획득, tryDrawPixel 호출`); // 실제 픽셀 저장 return await this.tryDrawPixel({ canvas_id, x, y, color, userId }); } finally { @@ -387,6 +395,15 @@ export class CanvasService { }; } + // 캔버스 타입 조회 메서드 추가 + async getCanvasType(canvas_id: string): Promise { + if (!canvas_id) return null; + const idNum = Number(canvas_id); + if (isNaN(idNum)) return null; + const meta = await this.canvasRepository.findOneBy({ id: idNum }); + return meta?.type ?? null; + } + // 쿨다운 적용 픽셀 그리기 async applyDrawPixelWithCooldown({ canvas_id, @@ -401,6 +418,43 @@ export class CanvasService { color: string; userId: number; }): Promise { + // 캔버스 타입 조회 + const canvasType = await this.getCanvasType(canvas_id); + // 게임 모드면 쿨다운 1초, 그 외는 10초 + const cooldownSeconds = canvasType === 'game_calculation' ? 1 : 10; + console.log( + `[CanvasService] 쿨다운 설정: canvasType=${canvasType}, cooldownSeconds=${cooldownSeconds}` + ); + + // 게임 캔버스인 경우 게임 시간 체크 + if (canvasType === 'game_calculation') { + const canvasInfo = await this.getCanvasById(canvas_id); + const now = new Date(); + + if ( + canvasInfo?.metaData?.startedAt && + now < canvasInfo.metaData.startedAt + ) { + console.log( + `[CanvasService] 게임 시작 전 색칠 시도 차단: userId=${userId}, canvasId=${canvas_id}` + ); + return { + success: false, + message: '게임이 아직 시작되지 않았습니다.', + }; + } + + if (canvasInfo?.metaData?.endedAt && now > canvasInfo.metaData.endedAt) { + console.log( + `[CanvasService] 게임 종료 후 색칠 시도 차단: userId=${userId}, canvasId=${canvas_id}` + ); + return { + success: false, + message: '게임이 이미 종료되었습니다.', + }; + } + } + // 캔버스 활성 상태 먼저 체크 const isActive = await this.isCanvasActive(parseInt(canvas_id)); console.log( @@ -416,12 +470,17 @@ export class CanvasService { }; } const cooldownKey = `cooldown:${userId}:${canvas_id}`; - const cooldownSeconds = 10; // 남은 쿨다운 확인 (Redis TTL 사용) const ttl = await this.redisClient.ttl(cooldownKey); + console.log( + `[CanvasService] 쿨다운 확인: userId=${userId}, canvasId=${canvas_id}, ttl=${ttl}` + ); if (ttl > 0) { + console.log( + `[CanvasService] 쿨다운 중: userId=${userId}, remaining=${ttl}` + ); return { success: false, message: '쿨다운 중', remaining: ttl }; } @@ -434,20 +493,26 @@ export class CanvasService { userId, }); + if (result) { + // 쿨다운 설정 + await this.redisClient.setex(cooldownKey, cooldownSeconds, '1'); + console.log( + `[CanvasService] 쿨다운 설정 완료: userId=${userId}, canvasId=${canvas_id}, seconds=${cooldownSeconds}` + ); + } + // draw-pixel 이벤트가 처리되었으므로 user_canvas의 count를 1 증가 try { await this.incrementUserCanvasCount(userId, parseInt(canvas_id)); + console.log( + `[CanvasService] 유저 캔버스 카운트 증가 완료: userId=${userId}, canvasId=${canvas_id}` + ); } catch (error) { console.error('사용자 캔버스 카운트 증가 실패:', error); // 카운트 증가 실패는 로그만 남기고 픽셀 그리기는 계속 진행 } - if (result) { - await this.redisClient.setex(cooldownKey, cooldownSeconds, '1'); - return { success: true, cooldown: cooldownSeconds }; - } else { - return { success: false, message: '픽셀 저장 실패' }; - } + return { success: result, message: result ? '성공' : '실패' }; } // 쿨다운 적용 픽셀 그리기 for simulation @@ -499,7 +564,7 @@ export class CanvasService { } } - // user_canvas 테이블의 count를 1씩 증가시키는 메서드 + // user_canvas 테이블의 try_count를 1씩 증가시키는 메서드 private async incrementUserCanvasCount( userId: number, canvasId: number @@ -514,25 +579,25 @@ export class CanvasService { }); if (userCanvas) { - // 기존 레코드가 있으면 count를 1 증가 - userCanvas.count += 1; + // 기존 레코드가 있으면 try_count를 1 증가 + userCanvas.tryCount += 1; await this.userCanvasRepository.save(userCanvas); } else { - // 레코드가 없으면 새로 생성 (count = 1) + // 레코드가 없으면 새로 생성 (try_count = 1) userCanvas = this.userCanvasRepository.create({ user: { id: userId }, canvas: { id: canvasId }, - count: 1, + tryCount: 1, joinedAt: new Date(), }); await this.userCanvasRepository.save(userCanvas); } console.log( - `사용자 ${userId}의 캔버스 ${canvasId} 카운트 증가: ${userCanvas.count}` + `사용자 ${userId}의 캔버스 ${canvasId} try_count 증가: ${userCanvas.tryCount}` ); } catch (error) { - console.error('user_canvas 카운트 증가 중 오류:', error); + console.error('user_canvas try_count 증가 중 오류:', error); throw error; } } @@ -548,4 +613,20 @@ export class CanvasService { console.log(`사용자 ${userId}, 캔버스 ${canvasId} - 남은 시간: ${ttl}초`); return ttl > 0 ? ttl : 0; // 초 } + + async isActiveGameCanvas(canvasId: number): Promise { + const canvas = await this.canvasRepository.findOne({ + where: { id: canvasId }, + }); + + if (!canvas) throw new NotFoundException('캔버스 정보가 없습니다.'); + + const now = Date.now(); // 현재 시각 (timestamp) + const startedAt = canvas.startedAt.getTime(); // Date → timestamp + + // 시작 전에만 입장 가능 + const isActive = now <= startedAt; + + return isActive; + } } diff --git a/src/canvas/dto/create_canvas_dto.dto.ts b/src/canvas/dto/create_canvas_dto.dto.ts index 4f5d339..bf5a42a 100644 --- a/src/canvas/dto/create_canvas_dto.dto.ts +++ b/src/canvas/dto/create_canvas_dto.dto.ts @@ -1,9 +1,16 @@ /* eslint-disable @typescript-eslint/no-unsafe-call */ -import { IsString, IsNotEmpty, IsNumber, IsIn, IsDate } from 'class-validator'; +import { + IsString, + IsNotEmpty, + IsNumber, + Matches, + IsDate, +} from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; import * as dayjs from 'dayjs'; import * as utc from 'dayjs/plugin/utc'; import * as timezone from 'dayjs/plugin/timezone'; +import { Type } from 'class-transformer'; dayjs.extend(utc); dayjs.extend(timezone); @@ -21,7 +28,10 @@ export class createCanvasDto { description: '캔버스 타입입니다. enum 값이 아닌 string으로 받습니다. 현재는 public / event로만 저장 가능', }) - @IsIn(['public', 'event'], { message: '타입은 비워둘 수 없습니다.' }) + @IsString() + @Matches(/^public$|^event_.*$|^game_.*$/, { + message: 'type must be public, event_*, or game_*', + }) type: 'public' | 'event'; @ApiProperty({ @@ -44,6 +54,7 @@ export class createCanvasDto { example: dayjs().tz('Asia/Seoul').format(), description: '캔버스 시작일 (ISO8601 형식)', }) + @Type(() => Date) @IsNotEmpty() @IsDate() startedAt: Date; @@ -53,6 +64,7 @@ export class createCanvasDto { description: '캔버스 종료일 (ISO8601 형식, 상시 캔버스는 null 가능)', required: false, }) + @Type(() => Date) @IsDate() endedAt: Date; } diff --git a/src/canvas/entity/canvas.entity.ts b/src/canvas/entity/canvas.entity.ts index 60937b1..cd64615 100644 --- a/src/canvas/entity/canvas.entity.ts +++ b/src/canvas/entity/canvas.entity.ts @@ -19,9 +19,9 @@ export class Canvas { @Column({ type: 'text', - enum: ['public', 'event', 'game'], + enum: ['public', 'event_common', 'event_colorlimit', 'game_calculation'], }) - type: 'public' | 'event' | 'game'; + type: 'public' | 'event_common' | 'event_colorlimit' | 'game_calculation'; @Column({ type: 'timestamp', name: 'created_at' }) createdAt: Date; diff --git a/src/canvas/entity/canvasHistory.entity.ts b/src/canvas/entity/canvasHistory.entity.ts index c4c6b85..6bc36a8 100644 --- a/src/canvas/entity/canvasHistory.entity.ts +++ b/src/canvas/entity/canvasHistory.entity.ts @@ -1,46 +1,45 @@ -import { - Entity, - PrimaryColumn, - Column, - OneToOne, - JoinColumn, - ManyToOne, -} from 'typeorm'; +import { Entity, PrimaryColumn, Column, ManyToOne, JoinColumn } from 'typeorm'; import { Canvas } from './canvas.entity'; -import { User } from 'src/user/entity/user.entity'; +import { User } from '../../user/entity/user.entity'; @Entity('canvas_history') export class CanvasHistory { @PrimaryColumn({ name: 'canvas_id' }) - canvas_id: number; + canvasId: number; - @Column({ type: 'int' }) - participant_count: number; + @Column({ name: 'participant_count', type: 'integer', default: 0 }) + participantCount: number; - @Column({ type: 'int' }) - attempt_count: number; + @Column({ name: 'total_try_count', type: 'integer', default: 0 }) + totalTryCount: number; - @Column({ type: 'bigint' }) - top_participant_id: number; + @Column({ name: 'top_try_user_id', type: 'bigint', nullable: true }) + topTryUserId: number | null; - @Column({ type: 'bigint' }) - top_pixel_owner_id: number; + @Column({ name: 'top_try_user_count', type: 'integer', nullable: true }) + topTryUserCount: number | null; - @Column({ type: 'int' }) - top_participant_attempts: number; + @Column({ name: 'top_own_user_id', type: 'bigint', nullable: true }) + topOwnUserId: number | null; - @Column({ type: 'int' }) - top_pixel_count: number; + @Column({ name: 'top_own_user_count', type: 'integer', nullable: true }) + topOwnUserCount: number | null; - @OneToOne(() => Canvas) + @Column({ name: 'image_url', type: 'varchar', length: 1024 }) + img_url: string; + + @Column({ name: 'captured_at', type: 'timestamp' }) + caputred_at: Date; + + @ManyToOne(() => Canvas, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'canvas_id' }) canvas: Canvas; - @ManyToOne(() => User, (user) => user.top_participant_history) - @JoinColumn({ name: 'top_participant_id' }) - top_participant: User; + @ManyToOne(() => User, { onDelete: 'SET NULL' }) + @JoinColumn({ name: 'top_try_user_id' }) + topTryUser: User | null; - @ManyToOne(() => User, (user) => user.top_pixel_owner_history) - @JoinColumn({ name: 'top_pixel_owner_id' }) - top_pixel_owner: User; + @ManyToOne(() => User, { onDelete: 'SET NULL' }) + @JoinColumn({ name: 'top_own_user_id' }) + topOwnUser: User | null; } diff --git a/src/canvas/entity/imageHistory.entity.ts b/src/canvas/entity/imageHistory.entity.ts deleted file mode 100644 index 9a8fd06..0000000 --- a/src/canvas/entity/imageHistory.entity.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - ManyToOne, - JoinColumn, -} from 'typeorm'; -import { CanvasHistory } from './canvasHistory.entity'; - -@Entity('image_history') -export class ImageHistory { - @PrimaryGeneratedColumn() - id: number; - - // @Column({ name: 'canvas_history_id', type: 'bigint' }) - // canvas_history_id: number; - - @Column({ name: 'image_url', type: 'varchar', length: 1024 }) - image_url: string; - - @Column({ name: 'captured_at', type: 'timestamp' }) - captured_at: Date; - - @ManyToOne(() => CanvasHistory, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'canvas_history_id' }) - canvasHistory: CanvasHistory; -} diff --git a/src/canvas/gallery.controller.ts b/src/canvas/gallery.controller.ts new file mode 100644 index 0000000..48e47b2 --- /dev/null +++ b/src/canvas/gallery.controller.ts @@ -0,0 +1,111 @@ +import { Controller, Get, HttpException, HttpStatus } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiProperty } from '@nestjs/swagger'; +import { CanvasHistoryService } from './canvas-history.service'; + +// Swagger DTO 클래스들 +class GalleryItemDto { + @ApiProperty({ description: '캔버스 이미지 URL', example: 'https://s3.amazonaws.com/bucket/history/1/image.png' }) + image_url: string; + + @ApiProperty({ description: '캔버스 제목', example: '고양이캔버스' }) + title: string; + + @ApiProperty({ description: '캔버스 타입', example: 'event', enum: ['event', 'game'] }) + type: string; + + @ApiProperty({ description: '캔버스 생성 시간', example: '2025-07-20T09:30:00Z' }) + created_at: string; + + @ApiProperty({ description: '캔버스 종료 시간', example: '2025-07-20T09:30:00Z' }) + ended_at: string; + + @ApiProperty({ description: '캔버스 가로 크기', example: 200 }) + size_x: number; + + @ApiProperty({ description: '캔버스 세로 크기', example: 150 }) + size_y: number; + + @ApiProperty({ description: '참여자 수', example: 10 }) + participant_count: number; + + @ApiProperty({ description: '전체 색칠 시도 수', example: 1500 }) + total_try_count: number; + + @ApiProperty({ description: '가장 많이 색칠 시도한 사용자 닉네임', example: 'user123' }) + top_try_user_name: string; + + @ApiProperty({ description: '가장 많이 색칠 시도한 사용자의 시도 수', example: 45 }) + top_try_user_count: number; + + @ApiProperty({ description: '가장 많이 픽셀을 소유한 사용자 닉네임', example: 'user456' }) + top_own_user_name: string; + + @ApiProperty({ description: '가장 많이 픽셀을 소유한 사용자의 소유 수', example: 23 }) + top_own_user_count: number; +} + +class GalleryResponseDto { + @ApiProperty({ description: '요청 성공 여부', example: true }) + isSuccess: boolean; + + @ApiProperty({ description: '응답 코드', example: '200' }) + code: string; + + @ApiProperty({ description: '응답 메시지', example: '요청에 성공하였습니다.' }) + message: string; + + @ApiProperty({ description: '갤러리 데이터 배열', type: [GalleryItemDto] }) + data: GalleryItemDto[]; +} + +@ApiTags('api/gallery') +@Controller('api/gallery') +export class GalleryController { + constructor( + private readonly canvasHistoryService: CanvasHistoryService + ) {} + + @Get() + @ApiOperation({ + summary: '갤러리 데이터 조회', + description: '종료된 이벤트/게임 캔버스의 갤러리 데이터를 조회합니다. 각 캔버스의 통계 정보와 이미지 URL을 포함합니다.' + }) + @ApiResponse({ + status: 200, + description: '갤러리 데이터 조회 성공', + type: GalleryResponseDto + }) + @ApiResponse({ + status: 500, + description: '서버 내부 오류', + schema: { + type: 'object', + properties: { + isSuccess: { type: 'boolean', example: false }, + code: { type: 'string', example: '500' }, + message: { type: 'string', example: '갤러리 데이터 조회 중 오류가 발생했습니다.' } + } + } + }) + async getGallery(): Promise { + try { + const galleryData = await this.canvasHistoryService.getGalleryData(); + return { + isSuccess: true, + code: '200', + message: '요청에 성공하였습니다.', + data: galleryData + }; + } catch (error) { + console.error('[GalleryController] 갤러리 데이터 조회 실패:', error); + throw new HttpException( + { + isSuccess: false, + code: '500', + message: '갤러리 데이터 조회 중 오류가 발생했습니다.', + }, + HttpStatus.INTERNAL_SERVER_ERROR + ); + } + } +} \ No newline at end of file diff --git a/src/canvas/processor/canvasStart.processor.ts b/src/canvas/processor/canvasStart.processor.ts index 98d0dab..ba32f46 100644 --- a/src/canvas/processor/canvasStart.processor.ts +++ b/src/canvas/processor/canvasStart.processor.ts @@ -1,10 +1,15 @@ -// import { Process, Processor } from '@nestjs/bull'; -// import { CanvasGateway } from '../canvas.gateway'; +import { Process, Processor } from '@nestjs/bull'; +import { CanvasGateway } from '../canvas.gateway'; -// @Processor('canvas-start') -// export class CavnasStartProcessor { -// constructor(private readonly gateway: CanvasGateway) {} +@Processor('canvas-start') +export class CanvasStartProcessor { + constructor(private readonly gateway: CanvasGateway) {} -// @Process('canvas-start') - -// } + @Process('canvas-start') + handleCanvasStart(job) { + const data = job.data; + console.log('시작 알림 발송'); + this.gateway.server.emit('start_canvas', data); + console.log('시작 알림 발송 완료'); + } +} diff --git a/src/canvas/strategy/AbstractCanvasStrategy.strategy.ts b/src/canvas/strategy/AbstractCanvasStrategy.strategy.ts index c3bee36..81a35cb 100644 --- a/src/canvas/strategy/AbstractCanvasStrategy.strategy.ts +++ b/src/canvas/strategy/AbstractCanvasStrategy.strategy.ts @@ -3,7 +3,6 @@ import { Canvas } from '../entity/canvas.entity'; import { PixelService } from '../../pixel/pixel.service'; import { GroupService } from '../../group/group.service'; import { CanvasService } from '../canvas.service'; -import { historyQueue } from '../../queues/bullmq.queue'; export abstract class AbstractCanvasStrategy { constructor( @@ -18,21 +17,4 @@ export abstract class AbstractCanvasStrategy { await this.groupService.setGroupMadeBy(defaultGroup, 1, canvas.id); await this.canvasService.generateCanvasHistory(canvas); } - - async isEndingWithOneDay(canvas: Canvas) { - const now = Date.now(); - const endedAtTime = new Date(canvas.endedAt).getTime(); - const delay = endedAtTime - now; - - // 1일 이내 종료되는 경우 → 큐에 바로 등록 - const ONE_DAYS = 1000 * 60 * 60 * 24 * 1; - const jobId = `history-${canvas.id}`; - if (delay > 0 && delay <= ONE_DAYS) { - await historyQueue.add( - 'canvas-history', - { canvas_id: canvas.id }, - { jobId: jobId, delay } - ); - } - } } diff --git a/src/canvas/strategy/createFactory.factory.ts b/src/canvas/strategy/createFactory.factory.ts index 80186bf..94ec9d1 100644 --- a/src/canvas/strategy/createFactory.factory.ts +++ b/src/canvas/strategy/createFactory.factory.ts @@ -1,28 +1,31 @@ import { Injectable } from '@nestjs/common'; import { CanvasCreationStrategy } from '../interface/canvasCreateStrategy.interface'; import { PublicCanvasStrategy } from './publicCanvasStrategy.strategy'; -import { EventCanvasStrategy } from './eventCanvasStrategy.strategy'; -import { GameCanvasStrategy } from './gameCanvasStrategy.strategy'; +import { EventCommonCanvasStrategy } from './eventCommonCanvasStrategy.strategy'; +import { EventColorLimitCanvasStrategy } from './eventColorLimitCanvasStrategy.strategy'; +import { GameCalculationCanvasStrategy } from './gameCalculationCanvasStrategy.strategy'; @Injectable() export class CanvasStrategyFactory { constructor( private readonly publicStrategy: PublicCanvasStrategy, - private readonly eventStrategy: EventCanvasStrategy, - // gameStrategy 등 추가 가능 - private readonly gameStrategy: GameCanvasStrategy + private readonly eventCommonStrategy: EventCommonCanvasStrategy, + private readonly eventColorLimitStrategy: EventColorLimitCanvasStrategy, + private readonly gameCalculationStrategy: GameCalculationCanvasStrategy ) {} getStrategy(type: string): CanvasCreationStrategy { switch (type) { - case 'game': - return this.gameStrategy; - case 'event': - return this.eventStrategy; + case 'game_calculation': + return this.gameCalculationStrategy; + case 'event_common': + return this.eventCommonStrategy; + case 'event_colorlimit': + return this.eventColorLimitStrategy; case 'public': return this.publicStrategy; default: - return this.eventStrategy; + return this.eventCommonStrategy; } } } diff --git a/src/canvas/strategy/gameCanvasStrategy.strategy.ts b/src/canvas/strategy/eventColorLimitCanvasStrategy.strategy.ts similarity index 77% rename from src/canvas/strategy/gameCanvasStrategy.strategy.ts rename to src/canvas/strategy/eventColorLimitCanvasStrategy.strategy.ts index cb22675..371be7a 100644 --- a/src/canvas/strategy/gameCanvasStrategy.strategy.ts +++ b/src/canvas/strategy/eventColorLimitCanvasStrategy.strategy.ts @@ -1,34 +1,37 @@ import { Injectable, Inject, forwardRef } from '@nestjs/common'; -import { CanvasCreationStrategy } from '../interface/canvasCreateStrategy.interface'; -import { createCanvasDto } from '../dto/create_canvas_dto.dto'; -import { Canvas } from '../entity/canvas.entity'; import { InjectRepository } from '@nestjs/typeorm'; +import { Canvas } from '../entity/canvas.entity'; +import { GroupService } from '../../group/group.service'; import { Repository } from 'typeorm'; -import { AbstractCanvasStrategy } from './AbstractCanvasStrategy.strategy'; +import { CanvasCreationStrategy } from '../interface/canvasCreateStrategy.interface'; +import { createCanvasDto } from '../dto/create_canvas_dto.dto'; import { PixelService } from '../../pixel/pixel.service'; -import { GroupService } from '../../group/group.service'; import { CanvasService } from '../canvas.service'; +import { AbstractCanvasStrategy } from './AbstractCanvasStrategy.strategy'; +import { isEndingWithOneDay } from '../../util/alarmGenerator.util'; @Injectable() -export class GameCanvasStrategy +export class EventColorLimitCanvasStrategy extends AbstractCanvasStrategy implements CanvasCreationStrategy { constructor( @InjectRepository(Canvas) private readonly canvasRepository: Repository, - pixelService: PixelService, + groupService: GroupService, @Inject(forwardRef(() => CanvasService)) canvasService: CanvasService, - groupService: GroupService + pixelService: PixelService ) { super(pixelService, canvasService, groupService); } - async create(createCanvasDto: createCanvasDto): Promise { - const { title, size_x, size_y, startedAt, endedAt } = createCanvasDto; + + async create(dto: createCanvasDto): Promise { + const { title, size_x, size_y, startedAt, endedAt } = dto; + const canvas = this.canvasRepository.create({ title, - type: 'game', + type: 'event_colorlimit', sizeX: size_x, sizeY: size_y, createdAt: new Date(), @@ -37,7 +40,7 @@ export class GameCanvasStrategy }); const newCanvas = await this.canvasRepository.save(canvas); await this.runPostCreationSteps(newCanvas); - await this.isEndingWithOneDay(newCanvas); + await isEndingWithOneDay(newCanvas); return newCanvas; } } diff --git a/src/canvas/strategy/eventCanvasStrategy.strategy.ts b/src/canvas/strategy/eventCommonCanvasStrategy.strategy.ts similarity index 88% rename from src/canvas/strategy/eventCanvasStrategy.strategy.ts rename to src/canvas/strategy/eventCommonCanvasStrategy.strategy.ts index 5f8b813..54c6ea8 100644 --- a/src/canvas/strategy/eventCanvasStrategy.strategy.ts +++ b/src/canvas/strategy/eventCommonCanvasStrategy.strategy.ts @@ -8,9 +8,9 @@ import { createCanvasDto } from '../dto/create_canvas_dto.dto'; import { PixelService } from '../../pixel/pixel.service'; import { CanvasService } from '../canvas.service'; import { AbstractCanvasStrategy } from './AbstractCanvasStrategy.strategy'; - +import { isEndingWithOneDay } from '../../util/alarmGenerator.util'; @Injectable() -export class EventCanvasStrategy +export class EventCommonCanvasStrategy extends AbstractCanvasStrategy implements CanvasCreationStrategy { @@ -30,7 +30,7 @@ export class EventCanvasStrategy const canvas = this.canvasRepository.create({ title, - type: 'event', + type: 'event_common', sizeX: size_x, sizeY: size_y, createdAt: new Date(), @@ -39,7 +39,7 @@ export class EventCanvasStrategy }); const newCanvas = await this.canvasRepository.save(canvas); await this.runPostCreationSteps(newCanvas); - await this.isEndingWithOneDay(newCanvas); + await isEndingWithOneDay(newCanvas); return newCanvas; } } diff --git a/src/canvas/strategy/gameCalculationCanvasStrategy.strategy.ts b/src/canvas/strategy/gameCalculationCanvasStrategy.strategy.ts new file mode 100644 index 0000000..0a95606 --- /dev/null +++ b/src/canvas/strategy/gameCalculationCanvasStrategy.strategy.ts @@ -0,0 +1,54 @@ +import { Injectable, Inject, forwardRef } from '@nestjs/common'; +import { CanvasCreationStrategy } from '../interface/canvasCreateStrategy.interface'; +import { createCanvasDto } from '../dto/create_canvas_dto.dto'; +import { Canvas } from '../entity/canvas.entity'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AbstractCanvasStrategy } from './AbstractCanvasStrategy.strategy'; +import { PixelService } from '../../pixel/pixel.service'; +import { GroupService } from '../../group/group.service'; +import { CanvasService } from '../canvas.service'; +import { + isEndingWithOneDay, + putJobOnAlarmQueue3SecsBeforeStart, + putJobOnAlarmQueueBeforeStart30s, + putJobOnAlarmQueueThreeSecBeforeEnd, + putJobOnAlarmQueueGameEnd, +} from '../../util/alarmGenerator.util'; + +@Injectable() +export class GameCalculationCanvasStrategy + extends AbstractCanvasStrategy + implements CanvasCreationStrategy +{ + constructor( + @InjectRepository(Canvas) + private readonly canvasRepository: Repository, + pixelService: PixelService, + @Inject(forwardRef(() => CanvasService)) + canvasService: CanvasService, + groupService: GroupService + ) { + super(pixelService, canvasService, groupService); + } + async create(createCanvasDto: createCanvasDto): Promise { + const { title, size_x, size_y, startedAt, endedAt } = createCanvasDto; + const canvas = this.canvasRepository.create({ + title, + type: 'game_calculation', + sizeX: size_x, + sizeY: size_y, + createdAt: new Date(), + startedAt, + endedAt, + }); + const newCanvas = await this.canvasRepository.save(canvas); + await this.runPostCreationSteps(newCanvas); + await isEndingWithOneDay(newCanvas); + await putJobOnAlarmQueueBeforeStart30s(newCanvas); + await putJobOnAlarmQueue3SecsBeforeStart(newCanvas); + await putJobOnAlarmQueueThreeSecBeforeEnd(newCanvas); + await putJobOnAlarmQueueGameEnd(newCanvas); + return newCanvas; + } +} diff --git a/src/data-source.ts b/src/data-source.ts index cc30d94..3845bd5 100644 --- a/src/data-source.ts +++ b/src/data-source.ts @@ -9,8 +9,7 @@ import { Chat } from './group/entity/chat.entity'; import { GroupUser } from './entity/GroupUser.entity'; import * as dotenv from 'dotenv'; import { CanvasHistory } from './canvas/entity/canvasHistory.entity'; -import { ImageHistory } from './canvas/entity/imageHistory.entity'; -import { Question } from './entity/questions.entity'; +import { Question } from './game/entity/questions.entity'; import { QuestionUser } from './game/entity/question_user.entity'; import { GameUserResult } from './game/entity/game_result.entity'; @@ -45,7 +44,6 @@ export const AppDataSource = new DataSource({ Chat, GroupUser, CanvasHistory, - ImageHistory, Question, QuestionUser, GameUserResult, diff --git a/src/database/database.module.ts b/src/database/database.module.ts index 19592e9..fc46e0f 100644 --- a/src/database/database.module.ts +++ b/src/database/database.module.ts @@ -6,10 +6,10 @@ import { AppDataSource } from '../data-source'; @Module({ providers: [ { - provide: DataSource, + provide: 'DATA_SOURCE', // 토큰 문자열로 변경경 useValue: AppDataSource, }, ], - exports: [DataSource], + exports: ['DATA_SOURCE'], }) export class DatabaseModule {} diff --git a/src/entity/UserCanvas.entity.ts b/src/entity/UserCanvas.entity.ts index 03116a3..a855d2f 100644 --- a/src/entity/UserCanvas.entity.ts +++ b/src/entity/UserCanvas.entity.ts @@ -21,8 +21,11 @@ export class UserCanvas { @JoinColumn({ name: 'canvas_id' }) canvas: Canvas; - @Column({ name: 'count', type: 'integer', default: 0 }) - count: number; + @Column({ name: 'try_count', type: 'integer', default: 0 }) + tryCount: number; + + @Column({ name: 'own_count', type: 'integer', nullable: true }) + ownCount: number | null; @Column({ name: 'joined_at', type: 'timestamp' }) joinedAt: Date; diff --git a/src/entity/questions.entity.ts b/src/entity/questions.entity.ts deleted file mode 100644 index 92f74f8..0000000 --- a/src/entity/questions.entity.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from 'typeorm'; -import { QuestionUser } from '../game/entity/question_user.entity'; - -@Entity('questions') -export class Question { - @PrimaryGeneratedColumn() - id: number; - - @Column({ name: 'context', type: 'varchar', length: 200 }) - context: string; - - @Column({ name: 'answer', type: 'int' }) - answer: number; - - @Column({ - name: 'created_at', - type: 'timestamp', - default: () => 'CURRENT_TIMESTAMP', - }) - createdAt: Date; - - @OneToMany(() => QuestionUser, (qu) => qu.questions) - questionUser: QuestionUser; -} diff --git a/src/game/dto/uploadQuestion.dto.ts b/src/game/dto/uploadQuestion.dto.ts new file mode 100644 index 0000000..a23c592 --- /dev/null +++ b/src/game/dto/uploadQuestion.dto.ts @@ -0,0 +1,20 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNumber, IsString, IsArray } from 'class-validator'; + +export class UploadQuestionDto { + @ApiProperty() + @IsString() + id: string; + + @ApiProperty() + @IsString() + question: string; + + @ApiProperty() + @IsArray() + options: string[]; + + @ApiProperty() + @IsNumber() + answer: number; +} diff --git a/src/game/dto/waitingResponse.dto.ts b/src/game/dto/waitingResponse.dto.ts new file mode 100644 index 0000000..709f909 --- /dev/null +++ b/src/game/dto/waitingResponse.dto.ts @@ -0,0 +1,28 @@ +class WaitingResponseDto { + success: boolean = false; + data?: GameResponseData; +} + +class QuestionDto { + id: number; + question: string; + options: string[]; + answer: number; +} + +class GameResponseData { + canvas_id: string; + title: string; + type: string; + startedAt: Date; + endedAt: Date; + canvasSize: Size; + questions: QuestionDto[]; + color: string; +} + +class Size { + width: number; + height: number; +} +export { WaitingResponseDto, QuestionDto, GameResponseData, Size }; diff --git a/src/game/entity/game_result.entity.ts b/src/game/entity/game_result.entity.ts index 4aabe91..cd1d283 100644 --- a/src/game/entity/game_result.entity.ts +++ b/src/game/entity/game_result.entity.ts @@ -13,18 +13,15 @@ export class GameUserResult { @PrimaryGeneratedColumn() id: number; - @Column({ name: 'user_id', type: 'bigint' }) - userId: number; - - @Column({ name: 'canvas_id', type: 'bigint' }) - canvasId: number; - @Column({ name: 'rank', type: 'int' }) rank: number; @Column({ name: 'assigned_color', type: 'varchar' }) color: string; + @Column({ name: 'life', type: 'int', default: 2 }) + life: number; + @Column({ name: 'created_at', type: 'timestamp' }) createdAt: Date; diff --git a/src/game/entity/question_user.entity.ts b/src/game/entity/question_user.entity.ts index 9b67fcd..d9d51b6 100644 --- a/src/game/entity/question_user.entity.ts +++ b/src/game/entity/question_user.entity.ts @@ -6,7 +6,7 @@ import { JoinColumn, } from 'typeorm'; import { User } from '../../user/entity/user.entity'; -import { Question } from '../../entity/questions.entity'; +import { Question } from './questions.entity'; @Entity('question_user') export class QuestionUser { @@ -19,6 +19,9 @@ export class QuestionUser { @Column({ name: 'question_id', type: 'bigint' }) questionId: number; + @Column({ name: 'canvas_id', type: 'bigint' }) + canvasId: number; + @Column({ name: 'submitted_answer', type: 'int' }) answer: number; diff --git a/src/game/entity/questions.entity.ts b/src/game/entity/questions.entity.ts new file mode 100644 index 0000000..0f897dd --- /dev/null +++ b/src/game/entity/questions.entity.ts @@ -0,0 +1,20 @@ +import { Entity, Column, OneToMany, PrimaryColumn } from 'typeorm'; +import { QuestionUser } from './question_user.entity'; + +@Entity('questions') +export class Question { + @PrimaryColumn() + id: number; + + @Column({ name: 'question', type: 'varchar', length: 200 }) + question: string; + + @Column({ name: 'options', type: 'text', array: true }) + options: string[]; + + @Column({ name: 'answer', type: 'int' }) + answer: number; + + @OneToMany(() => QuestionUser, (qu) => qu.questions) + questionUser: QuestionUser[]; +} diff --git a/src/game/game-flush.service.ts b/src/game/game-flush.service.ts new file mode 100644 index 0000000..7133bb8 --- /dev/null +++ b/src/game/game-flush.service.ts @@ -0,0 +1,136 @@ +// Redis에 임시로 저장된 "dirty" 데이터(변경된 픽셀, 유저 상태)를 +// 주기적으로 DB에 일괄 반영(Flush) 하는 역할. +// 1초마다 또는 10개 이상 변경 시 batch로 DB update. + +import { Injectable, Inject } from '@nestjs/common'; +import Redis from 'ioredis'; +import { DataSource } from 'typeorm'; + +@Injectable() +export class GameFlushService { + constructor( + @Inject('REDIS_CLIENT') private readonly redis: Redis, + @Inject('DATA_SOURCE') private readonly dataSource: DataSource, + ) {} + + // 픽셀 dirty set에 추가 + async addDirtyPixel(canvasId: string, x: number, y: number) { + await this.redis.sadd(`dirty_pixels:${canvasId}`, `${x}:${y}`); + } + + // 유저 dirty set에 추가 + async addDirtyUser(canvasId: string, userId: string) { + await this.redis.sadd(`dirty_users:${canvasId}`, userId); + await this.redis.expire(`dirty_users:${canvasId}`, 3600); + } + + // 픽셀 batch flush + async flushDirtyPixels(canvasId: string) { + if (!this.dataSource.isInitialized) { + console.warn('[GameFlushService] DataSource not initialized. Re-initializing...'); + await this.dataSource.initialize(); + } + const dirtySetKey = `dirty_pixels:${canvasId}`; + const fields = await this.redis.smembers(dirtySetKey); + if (fields.length === 0) return; + const hashKey = `canvas:${canvasId}`; + const pipeline = this.redis.pipeline(); + for (const field of fields) { + pipeline.hget(hashKey, field); + } + const results = await pipeline.exec(); + // DB 일괄 update + if (!results) return; + for (let i = 0; i < fields.length; i++) { + const [x, y] = fields[i].split(':').map(Number); + const redisResult = results[i]; + if (!redisResult || typeof redisResult[1] !== 'string') continue; + const value = redisResult[1]; + if (value) { + const [color, owner] = value.split('|'); + await this.dataSource.query( + 'UPDATE pixels SET color=$1, owner=$2 WHERE canvas_id=$3 AND x=$4 AND y=$5', + [color, owner || null, canvasId, x, y] + ); + } + } + await this.redis.del(dirtySetKey); + } + + // 유저 batch flush + async flushDirtyUsers(canvasId: string) { + if (!this.dataSource.isInitialized) { + console.warn('[GameFlushService] DataSource not initialized. Re-initializing...'); + await this.dataSource.initialize(); + } + const dirtySetKey = `dirty_users:${canvasId}`; + await this.redis.expire(dirtySetKey, 3600); + const userIds = await this.redis.smembers(dirtySetKey); + if (userIds.length === 0) { + console.log(`[GameFlushService] flush할 유저 없음: canvasId=${canvasId}`); + return; + } + + console.log(`[GameFlushService] 유저 flush 시작: canvasId=${canvasId}, users=${userIds.length}명`); + + for (const userId of userIds) { + // own_count, try_count, dead, life 등 Redis에서 조회 + const [ownCount, tryCount, dead, life] = await Promise.all([ + this.redis.hget(`game:${canvasId}:user:${userId}`, 'own_count'), + this.redis.hget(`game:${canvasId}:user:${userId}`, 'try_count'), + this.redis.hget(`game:${canvasId}:user:${userId}`, 'dead'), + this.redis.hget(`game:${canvasId}:user:${userId}`, 'life'), + ]); + + console.log(`[GameFlushService] 유저 상태 조회: userId=${userId}, own_count=${ownCount}, try_count=${tryCount}, dead=${dead}, life=${life}`); + + // user_canvas 테이블에 UPSERT + await this.dataSource.query( + `INSERT INTO user_canvas (user_id, canvas_id, own_count, try_count, joined_at) + VALUES ($1, $2, $3, $4, NOW()) + ON CONFLICT (user_id, canvas_id) + DO UPDATE SET own_count = $3, try_count = $4`, + [userId, canvasId, ownCount || 0, tryCount || 0] + ); + + // game_user_result에도 life 반영 (있을 경우) + await this.dataSource.query( + 'UPDATE game_user_result SET life=$1 WHERE canvas_id=$2 AND user_id=$3', + [life || 2, canvasId, userId] + ); + + console.log(`[GameFlushService] 유저 상태 flush 완료: userId=${userId}, own_count=${ownCount}, try_count=${tryCount}, life=${life}`); + } + await this.redis.del(dirtySetKey); + console.log(`[GameFlushService] 유저 flush 완료: canvasId=${canvasId}, 총 ${userIds.length}명`); + } + + // 1초마다 또는 batch 10개마다 flush + async flushLoop(canvasId: string) { + console.log(`[GameFlushService] flush 루프 시작: canvasId=${canvasId}`); + setInterval(async () => { + const pixelCount = await this.redis.scard(`dirty_pixels:${canvasId}`); + const userCount = await this.redis.scard(`dirty_users:${canvasId}`); + + // 게임에서는 더 자주 flush (1개 이상이면 flush) + if (pixelCount >= 10) { + console.log(`[GameFlushService] 픽셀 배치 flush 실행: canvasId=${canvasId}, count=${pixelCount}`); + await this.flushDirtyPixels(canvasId); + } + if (userCount >= 10) { + console.log(`[GameFlushService] 유저 배치 flush 실행: canvasId=${canvasId}, count=${userCount}`); + await this.flushDirtyUsers(canvasId); + } + + // 30초마다 강제 flush (안전장치) + const now = Date.now(); + const lastForceFlush = await this.redis.get(`last_force_flush:${canvasId}`); + if (!lastForceFlush || (now - parseInt(lastForceFlush)) > 30000) { + console.log(`[GameFlushService] 강제 flush 실행: canvasId=${canvasId}`); + await this.flushDirtyPixels(canvasId); + await this.flushDirtyUsers(canvasId); + await this.redis.setex(`last_force_flush:${canvasId}`, 60, now.toString()); + } + }, 1000); + } +} \ No newline at end of file diff --git a/src/game/game-logic.service.ts b/src/game/game-logic.service.ts new file mode 100644 index 0000000..58278f3 --- /dev/null +++ b/src/game/game-logic.service.ts @@ -0,0 +1,556 @@ +// 게임 캔버스(특히 game_calculation 타입)의 핵심 게임 로직 전담 +// 정답/오답 처리(색칠, 소유권 이동, own_count/try_count 관리) +// 오답 시 라이프 차감 및 사망 처리(픽셀 해제, 사망자 브로드캐스트) +// 게임 특화된 상태 변화(예: dead_user, send_result 등) 소켓 이벤트 처리 +// CanvasService, GameStateService, GamePixelService 등과 연동하여 +// 픽셀 상태, 유저 상태, 사망 처리 등 게임에 필요한 모든 상태 변화 관리 + +import { Injectable, Inject, forwardRef } from '@nestjs/common'; +import { Server, Socket } from 'socket.io'; +import { CanvasService } from '../canvas/canvas.service'; +import { GameStateService } from './game-state.service'; +import { GamePixelService } from './game-pixel.service'; +import { GameFlushService } from './game-flush.service'; +import Redis from 'ioredis'; +import { DataSource } from 'typeorm'; + +@Injectable() +export class GameLogicService { + constructor( + @Inject(forwardRef(() => CanvasService)) + private readonly canvasService: CanvasService, + private readonly gameStateService: GameStateService, + private readonly gamePixelService: GamePixelService, + private readonly gameFlushService: GameFlushService, + @Inject('REDIS_CLIENT') private readonly redis: Redis, // Redis 인스턴스 주입 + private readonly dataSource: DataSource // DataSource 주입 + ) {} + + async handleSendResult( + data: { + canvas_id: string; + x: number; + y: number; + color: string; + result: boolean; + }, + client: Socket, + server: Server + ) { + // 유저 인증 + const userId = await this.getUserIdFromClient(client); + console.log(`[handleSendResult] 호출: userId=${userId}, data=`, data); + if (!userId) { + client.emit('auth_error', { message: '인증 필요' }); + return; + } + // 캔버스 종료 시간 체크 (색칠 차단) + const canvasInfo = await this.canvasService.getCanvasById(data.canvas_id); + const now = new Date(); + if (canvasInfo?.metaData?.endedAt && now > canvasInfo.metaData.endedAt) { + console.warn( + `[handleSendResult] 종료된 캔버스에 색칠 시도 차단: userId=${userId}, canvas_id=${data.canvas_id}` + ); + client.emit('game_error', { + message: '게임이 이미 종료되었습니다. 결과를 확인하세요.', + }); + // 강제 게임 종료 트리거 + await this.forceGameEnd(data.canvas_id, server); + return; + } + // 사망자 처리 + const isDead = await this.gameStateService.getUserDead( + data.canvas_id, + userId + ); + if (isDead) { + console.warn( + `[handleSendResult] 사망자 색칠 시도: userId=${userId}, canvas_id=${data.canvas_id}, x=${data.x}, y=${data.y}` + ); + client.emit('game_error', { + message: '이미 사망한 유저입니다. 색칠이 불가합니다.', + }); + return; + } + // 픽셀 정보 조회 (owner/color는 더이상 분기에서 사용하지 않음) + const allPixels = await this.canvasService.getAllPixels(data.canvas_id); + const found = allPixels.find((p) => p.x === data.x && p.y === data.y); + const pixel = { + owner: found?.owner ? String(found.owner) : null, + color: found?.color || '#000000', + }; + + // game_user_result 보장 (없으면 생성) + await this.insertUserToGameResult(data.canvas_id, userId); + + // result 값만으로 분기 + await this.gameStateService.incrUserTryCount(data.canvas_id, userId); + if (data.result) { + // 정답: 기존 owner own_count-1, 새로운 owner own_count+1, 색칠 + console.log('[handleSendResult] 정답 처리: applyDrawPixel 호출 전', { + canvas_id: data.canvas_id, + x: data.x, + y: data.y, + color: data.color, + userId, + }); + if (pixel.owner && pixel.owner !== userId) { + await this.gameStateService.decrUserOwnCount( + data.canvas_id, + pixel.owner + ); + await this.gameFlushService.addDirtyUser(data.canvas_id, pixel.owner); + } + await this.gameStateService.incrUserOwnCount(data.canvas_id, userId); + await this.gameFlushService.addDirtyUser(data.canvas_id, userId); + await this.canvasService.applyDrawPixel({ + canvas_id: data.canvas_id, + x: data.x, + y: data.y, + color: data.color, + userId: Number(userId), + }); + server.to(`canvas_${data.canvas_id}`).emit('pixel_update', { + x: data.x, + y: data.y, + color: data.color, + }); + // 정답 처리 후에도 종료 시간 체크 및 강제 종료 + const canvasInfoAfter = await this.canvasService.getCanvasById( + data.canvas_id + ); + const nowAfter = new Date(); + if ( + canvasInfoAfter?.metaData?.endedAt && + nowAfter > canvasInfoAfter.metaData.endedAt + ) { + console.warn( + `[handleSendResult] 정답 처리 후 종료 시간 만료 감지, forceGameEnd 호출: canvas_id=${data.canvas_id}` + ); + await this.forceGameEnd(data.canvas_id, server); + } + return; + } else { + // 오답/타임오버: 라이프 차감 + const life = await this.gameStateService.decrUserLife( + data.canvas_id, + userId + ); + await this.gameFlushService.addDirtyUser(data.canvas_id, userId); + if (life <= 0) { + // 사망 처리: 픽셀 자유화, dead_user 브로드캐스트 + const freedPixels = await this.gamePixelService.freeAllPixelsOfUser( + data.canvas_id, + userId + ); + await this.gameStateService.setUserDead(data.canvas_id, userId, true); + await this.gameStateService.addDeadUser(data.canvas_id, userId); + // own_count 0으로 강제 세팅 + await this.gameStateService.setUserOwnCount(data.canvas_id, userId, 0); + await this.gameFlushService.addDirtyUser(data.canvas_id, userId); + server.to(`canvas_${data.canvas_id}`).emit('dead_user', { + username: await this.getUserNameById(userId), + pixels: freedPixels.map((p) => ({ + x: p.x, + y: p.y, + color: '#000000', + })), + count: freedPixels.length, + }); + client.emit('dead_notice', { message: '사망하셨습니다.' }); + + // --- 게임 종료 및 결과 브로드캐스트 --- + // 모든 유저, 사망자 목록 조회 + const allUserIds = await this.gameStateService.getAllUsersInGame( + data.canvas_id + ); + const deadUserIds = await this.gameStateService.getAllDeadUsers( + data.canvas_id + ); + // 생존자 = 전체 - 사망자 + const aliveUserIds = allUserIds.filter( + (uid) => !deadUserIds.includes(uid) + ); + // 게임 종료 조건: 생존자가 없거나 게임 시간이 지났을 때 + const canvasInfo = await this.canvasService.getCanvasById( + data.canvas_id + ); + const now = new Date(); + const isGameTimeOver = + canvasInfo?.metaData?.endedAt && now > canvasInfo.metaData.endedAt; + console.log( + `[GameLogicService] 게임 종료 조건 체크: canvasId=${data.canvas_id}, 생존자=${aliveUserIds.length}명, 게임시간종료=${isGameTimeOver}` + ); + // 남은 생존자가 없거나 게임 시간이 지났으면 게임 종료 + if (aliveUserIds.length === 0 || isGameTimeOver) { + console.log( + `[GameLogicService] 게임 종료 조건 만족: 생존자=${aliveUserIds.length}명, 시간종료=${isGameTimeOver}` + ); + // 랭킹 계산 시간 측정 시작 + const rankingStart = Date.now(); + // 유저별 own_count, try_count, dead 여부 조회 + const userStats = await Promise.all( + allUserIds.map(async (uid) => { + const [own, tr, dead] = await Promise.all([ + this.gameStateService.getUserOwnCount(data.canvas_id, uid), + this.gameStateService.getUserTryCount(data.canvas_id, uid), + this.gameStateService.getUserDead(data.canvas_id, uid), + ]); + const username = await this.getUserNameById(uid); // 닉네임 조회 + return { + username, + own_count: own, + try_count: tr, + dead, + }; + }) + ); + // 랭킹 산정 + console.log( + `[GameLogicService] 게임 종료 - 랭킹 계산 시작: canvasId=${data.canvas_id}, 전체 유저=${allUserIds.length}, 사망자=${deadUserIds.length}, 생존자=${aliveUserIds.length}` + ); + const ranked = this.calculateRanking(userStats); + console.log( + `[GameLogicService] 랭킹 계산 완료:`, + ranked.map( + (r) => + `${r.username}(rank=${r.rank}, own=${r.own_count}, try=${r.try_count}, dead=${r.dead})` + ) + ); + // 랭킹 계산 시간 측정 끝 + const rankingEnd = Date.now(); + console.log( + `[GameLogicService] 랭킹 계산 및 결과 브로드캐스트 준비까지 소요: ${rankingEnd - rankingStart}ms` + ); + // 게임 결과를 DB에 저장 (rank만 업데이트) + await this.updateGameResults(data.canvas_id, ranked); + // 워커 큐에 canvas-history 잡 추가 + try { + const { historyQueue } = await import('../queues/bullmq.queue'); + // 캔버스 정보 조회 (크기 등 필요시) + const canvasInfo = await this.canvasService.getCanvasById( + data.canvas_id + ); + const meta = canvasInfo?.metaData; + await historyQueue.add('canvas-history', { + canvas_id: data.canvas_id, + size_x: meta?.sizeX, + size_y: meta?.sizeY, + type: meta?.type, + startedAt: meta?.startedAt, + endedAt: meta?.endedAt, + created_at: meta?.createdAt, + updated_at: new Date(), + }); + console.log( + `[GameLogicService] 워커 큐에 canvas-history 잡 추가 완료: canvasId=${data.canvas_id}` + ); + } catch (e) { + console.error( + `[GameLogicService] 워커 큐에 canvas-history 잡 추가 실패: canvasId=${data.canvas_id}`, + e + ); + } + server + .to(`canvas_${data.canvas_id}`) + .emit('game_result', { results: ranked }); + console.log( + `[GameLogicService] 게임 결과 브로드캐스트 완료: canvasId=${data.canvas_id}` + ); + } + // --- + } + return; + } + } + + // 게임 시작 시 유저 초기화 (life=2, try_count=0, own_count=0, dead=false) + async initializeUserForGame(canvasId: string, userId: string) { + console.log( + `[GameLogicService] 유저 초기화: canvasId=${canvasId}, userId=${userId}` + ); + + // 유저를 게임에 추가 + await this.gameStateService.addUserToGame(canvasId, userId); + + // 유저 상태 초기화 + await this.gameStateService.setUserLife(canvasId, userId, 2); + await this.gameStateService.setUserDead(canvasId, userId, false); + + // try_count, own_count는 0으로 시작 (이미 기본값) + console.log( + `[GameLogicService] 유저 초기화 완료: userId=${userId}, life=2` + ); + + // game_user_result 테이블에 유저 정보 삽입 + await this.insertUserToGameResult(canvasId, userId); + + // flush 루프 시작 (한 번만 시작되도록) + const flushKey = `flush_started:${canvasId}`; + const isFlushStarted = await this.redis.get(flushKey); + if (!isFlushStarted) { + await this.gameFlushService.flushLoop(canvasId); + await this.redis.setex(flushKey, 3600, '1'); // 1시간 동안 유지 + console.log(`[GameLogicService] flush 루프 시작: canvasId=${canvasId}`); + } + } + + // game_user_result 테이블에 유저 정보 삽입 + private async insertUserToGameResult(canvasId: string, userId: string) { + try { + const username = await this.getUserNameById(userId); + let color = await this.gameStateService.getUserColor(canvasId, userId); + // 색이 없으면 기본 색 생성 + if (!color) { + const { generatorColor } = await import('../util/colorGenerator.util'); + color = generatorColor(1000); + await this.gameStateService.setUserColor(canvasId, userId, color); + console.log( + `[GameLogicService] 기본 색 생성 및 저장: userId=${userId}, color=${color}` + ); + } + await this.dataSource.query( + `INSERT INTO game_user_result (user_id, canvas_id, assigned_color, life, created_at) + VALUES ($1, $2, $3, $4, NOW())`, + [userId, canvasId, color, 2] + ); + console.log( + `[GameLogicService] game_user_result 삽입 완료: userId=${userId}, username=${username}, color=${color}` + ); + } catch (error) { + console.error( + `[GameLogicService] game_user_result 삽입 실패: userId=${userId}, canvasId=${canvasId}, 에러:`, + error + ); + } + } + + // 유저 id 추출 (canvas.gateway와 동일하게 구현) + private async getUserIdFromClient(client: Socket): Promise { + try { + const sessionKey = `socket:${client.id}:user`; + const userData = await this.redis.get(sessionKey); // Redis에서 직접 조회 + if (!userData) return null; + const user = JSON.parse(userData); + return String(user.id ?? user.userId); + } catch { + return null; + } + } + + // 유저 이름 조회 (anvas.gateway.ts와 동일하게) + private async getUserNameById(userId: string): Promise { + try { + // Redis에 저장된 모든 소켓 id를 가져와서 username을 찾는다 + const keys = await this.redis.keys('socket:*:user'); + for (const key of keys) { + const userData = await this.redis.get(key); + if (!userData) continue; + const user = JSON.parse(userData); + if (String(user.id ?? user.userId) === String(userId)) { + return user.username || String(userId); + } + } + return String(userId); + } catch { + return String(userId); + } + } + + // 유저 id를 이름으로 찾는 메서드 (Redis에서 사용) + private async getUserIdById(username: string): Promise { + try { + // Redis에 저장된 모든 소켓 id를 가져와서 userId를 찾는다 + const keys = await this.redis.keys('socket:*:user'); + for (const key of keys) { + const userData = await this.redis.get(key); + if (!userData) continue; + const user = JSON.parse(userData); + if (user.username === username) { + return String(user.id ?? user.userId); + } + } + return null; + } catch { + return null; + } + } + + // 게임 결과를 DB에 저장 (rank만 업데이트) + private async updateGameResults(canvasId: string, results: any[]) { + try { + console.log( + `[GameLogicService] DB에 랭킹 업데이트 시작: canvasId=${canvasId}, 결과 수=${results.length}` + ); + + // 모든 유저의 userId를 한 번에 조회 + const allUserIds = + await this.gameStateService.getAllUsersInGame(canvasId); + const usernameToUserIdMap = new Map(); + + // username -> userId 매핑 생성 + for (const userId of allUserIds) { + const username = await this.getUserNameById(userId); + usernameToUserIdMap.set(username, userId); + } + + // 배치 업데이트를 위한 쿼리 준비 + const updatePromises = results.map(async (result) => { + const userId = usernameToUserIdMap.get(result.username); + if (!userId) { + console.warn( + `[GameLogicService] userId를 찾을 수 없음: username=${result.username}` + ); + return; + } + + // game_user_result 테이블에 rank 업데이트 + await this.dataSource.query( + `UPDATE game_user_result SET rank = $1 WHERE user_id = $2 AND canvas_id = $3`, + [result.rank, userId, canvasId] + ); + + console.log( + `[GameLogicService] 랭킹 업데이트 완료: userId=${userId}, username=${result.username}, rank=${result.rank}` + ); + }); + + // 모든 업데이트를 병렬로 실행 + await Promise.all(updatePromises); + + console.log( + `[GameLogicService] 모든 랭킹 업데이트 완료: canvasId=${canvasId}` + ); + } catch (error) { + console.error(`[GameLogicService] 랭킹 업데이트 실패:`, error); + } + } + + // canvas_history 생성 + private async createCanvasHistory(canvasId: string) { + try { + console.log( + `[GameLogicService] canvas_history 생성 시작: canvasId=${canvasId}` + ); + + // 기존 canvas_history가 있는지 확인 + const existingHistory = await this.dataSource.query( + 'SELECT id FROM canvas_history WHERE canvas_id = $1', + [canvasId] + ); + + if (existingHistory.length > 0) { + console.log( + `[GameLogicService] canvas_history 이미 존재: canvasId=${canvasId}` + ); + return; + } + + // canvas_history 생성 + await this.dataSource.query( + `INSERT INTO canvas_history (canvas_id, created_at) + VALUES ($1, NOW())`, + [canvasId] + ); + + console.log( + `[GameLogicService] canvas_history 생성 완료: canvasId=${canvasId}` + ); + } catch (error) { + console.error( + `[GameLogicService] canvas_history 생성 실패: canvasId=${canvasId}`, + error + ); + } + } + + // 캔버스 종료(시간 만료) 시 강제 게임 종료 및 결과 브로드캐스트 + async forceGameEnd(canvasId: string, server?: any) { + try { + // 게임 종료 직전 모든 유저 상태 DB 반영 (life 등) + if (this.gameFlushService && typeof this.gameFlushService.flushDirtyUsers === 'function') { + await this.gameFlushService.flushDirtyUsers(canvasId); + } + // 랭킹 계산 시간 측정 시작 + const rankingStart = Date.now(); + // 모든 유저, 사망자 목록 조회 + const allUserIds = await this.gameStateService.getAllUsersInGame(canvasId); + const deadUserIds = await this.gameStateService.getAllDeadUsers(canvasId); + const aliveUserIds = allUserIds.filter(uid => !deadUserIds.includes(uid)); + // 유저별 own_count, try_count, dead 여부 조회 + const userStats = await Promise.all( + allUserIds.map(async uid => { + const [own, tr, dead] = await Promise.all([ + this.gameStateService.getUserOwnCount(canvasId, uid), + this.gameStateService.getUserTryCount(canvasId, uid), + this.gameStateService.getUserDead(canvasId, uid), + ]); + const username = await this.getUserNameById(uid); // 닉네임 조회 + return { + username, + own_count: own, + try_count: tr, + dead, + userId: uid, + }; + }) + ); + // 랭킹 산정 + const ranked = this.calculateRanking(userStats); + // 랭킹 계산 시간 측정 끝 + const rankingEnd = Date.now(); + // 게임 결과를 DB에 저장 (rank만 업데이트, userId 직접 사용) + await this.updateGameResultsByUserId(canvasId, ranked); + // canvas-history 잡 추가 완전 제거 (alarm.worker/배치에서만 관리) + // 결과 브로드캐스트 + if (server) { + server.to(`canvas_${canvasId}`).emit('game_result', { results: ranked }); + console.log(`[GameLogicService] forceGameEnd: 게임 결과 브로드캐스트 완료: canvasId=${canvasId}`); + } + } catch (err) { + console.error(`[GameLogicService] forceGameEnd 에러: canvasId=${canvasId}`, err); + } + } + + // 게임 결과를 DB에 저장 (rank만 업데이트, userId 직접 사용) + private async updateGameResultsByUserId(canvasId: string, results: any[]) { + try { + console.log(`[GameLogicService] DB에 랭킹 업데이트 시작: canvasId=${canvasId}, 결과 수=${results.length}`); + // 배치 업데이트를 위한 쿼리 준비 + const updatePromises = results.map(async (result) => { + const userId = result.userId; + if (!userId) { + console.warn(`[GameLogicService] userId 없음: result=`, result); + return; + } + // game_user_result 테이블에 rank 업데이트 + await this.dataSource.query( + `UPDATE game_user_result SET rank = $1 WHERE user_id = $2 AND canvas_id = $3`, + [result.rank, userId, canvasId] + ); + console.log(`[GameLogicService] 랭킹 업데이트 완료: userId=${userId}, rank=${result.rank}`); + }); + await Promise.all(updatePromises); + console.log(`[GameLogicService] 모든 랭킹 업데이트 완료: canvasId=${canvasId}`); + } catch (error) { + console.error(`[GameLogicService] 랭킹 업데이트 실패:`, error); + } + } + + // 랭킹 산정(정렬) 로직 분리 + private calculateRanking(userStats: any[]): any[] { + return userStats + .map((u) => ({ ...u })) + .sort((a, b) => { + // 소유수 내림차순 + if (b.own_count !== a.own_count) return b.own_count - a.own_count; + // 시도수 내림차순 + if (b.try_count !== a.try_count) return b.try_count - a.try_count; + // userId 오름차순 (숫자 비교) + const aId = typeof a.userId === 'number' ? a.userId : parseInt(a.userId, 10); + const bId = typeof b.userId === 'number' ? b.userId : parseInt(b.userId, 10); + return aId - bId; + }) + .map((u, i) => ({ ...u, rank: i + 1 })); + } +} diff --git a/src/game/game-pixel.service.ts b/src/game/game-pixel.service.ts new file mode 100644 index 0000000..6008d44 --- /dev/null +++ b/src/game/game-pixel.service.ts @@ -0,0 +1,55 @@ +// 캔버스의 픽셀 정보(특정 유저가 소유한 픽셀 전체 등)를 Redis/DB에서 조회. +// 유저가 사망할 때 해당 유저의 모든 픽셀을 "자유화(주인 없음, 검정색)" 처리. + +import { Injectable, Inject, forwardRef } from '@nestjs/common'; +import Redis from 'ioredis'; +import { CanvasService } from '../canvas/canvas.service'; + +@Injectable() +export class GamePixelService { + constructor( + @Inject('REDIS_CLIENT') private readonly redis: Redis, + @Inject(forwardRef(() => CanvasService)) private readonly canvasService: CanvasService, + ) {} + + async getCanvasSize(canvasId: string): Promise<{ sizeX: number; sizeY: number }> { + const sizeX = parseInt(await this.redis.get(`canvas:${canvasId}:sizeX`) || '100', 10); + const sizeY = parseInt(await this.redis.get(`canvas:${canvasId}:sizeY`) || '100', 10); + return { sizeX, sizeY }; + } + + async freeAllPixelsOfUser(canvasId: string, userId: string | number): Promise<{ x: number; y: number; color: string }[]> { + // 픽셀 정보 조회 (Redis 우선, 없으면 DB에서 가져와 캐싱) + let pixels: { x: number; y: number; color: string; owner: number | null }[] = []; + try { + pixels = await this.canvasService.getAllPixels(canvasId); + } catch (e) { + pixels = []; + } + + const freedPixels: { x: number; y: number; color: string }[] = []; + const pipeline = this.redis.pipeline(); + + for (const pixel of pixels) { + if (String(pixel.owner) === String(userId)) { + // Redis에서 픽셀 정보 업데이트 (검은색, owner null) + const hashKey = `canvas:${canvasId}`; + const field = `${pixel.x}:${pixel.y}`; + const pixelData = `#000000|`; // owner 없음 + + pipeline.hset(hashKey, field, pixelData); + pipeline.sadd(`dirty_pixels:${canvasId}`, field); + + freedPixels.push({ x: pixel.x, y: pixel.y, color: '#000000' }); + } + } + + // Redis 파이프라인 실행 + if (freedPixels.length > 0) { + await pipeline.exec(); + console.log(`[GamePixelService] 유저 ${userId}의 픽셀 ${freedPixels.length}개 자유화 완료`); + } + + return freedPixels; + } +} \ No newline at end of file diff --git a/src/game/game-state.service.ts b/src/game/game-state.service.ts new file mode 100644 index 0000000..3015b07 --- /dev/null +++ b/src/game/game-state.service.ts @@ -0,0 +1,120 @@ +// 게임 내 유저의 상태(목숨, 소유 픽셀 수, 시도 횟수, 색상, 사망 여부, 참가자/사망자 목록 등)를 +// Redis에 저장/조회/증가/감소하는 유틸리티. + +import { Injectable, Inject } from '@nestjs/common'; +import Redis from 'ioredis'; + +@Injectable() +export class GameStateService { + constructor( + @Inject('REDIS_CLIENT') + private readonly redis: Redis, + ) {} + + // 목숨 + async setUserLife(canvasId: string, userId: string, value: number) { + await this.redis.hset(`game:${canvasId}:user:${userId}`, 'life', value); + await this.redis.expire(`game:${canvasId}:user:${userId}`, 3600); + console.log(`[GameStateService] 유저 목숨 설정: canvasId=${canvasId}, userId=${userId}, life=${value}`); + } + async getUserLife(canvasId: string, userId: string): Promise { + const life = parseInt(await this.redis.hget(`game:${canvasId}:user:${userId}`, 'life') || '0', 10); + console.log(`[GameStateService] 유저 목숨 조회: canvasId=${canvasId}, userId=${userId}, life=${life}`); + return life; + } + async decrUserLife(canvasId: string, userId: string): Promise { + const key = `game:${canvasId}:user:${userId}`; + const life = await this.redis.hincrby(key, 'life', -1); + await this.redis.expire(key, 3600); + console.log(`[GameStateService] 유저 목숨 차감: canvasId=${canvasId}, userId=${userId}, life=${life}`); + return life; + } + + // 사망 여부 + async setUserDead(canvasId: string, userId: string, value: boolean) { + await this.redis.hset(`game:${canvasId}:user:${userId}`, 'dead', value ? 1 : 0); + await this.redis.expire(`game:${canvasId}:user:${userId}`, 3600); + console.log(`[GameStateService] 유저 사망 상태 설정: canvasId=${canvasId}, userId=${userId}, dead=${value}`); + } + async getUserDead(canvasId: string, userId: string): Promise { + const dead = (await this.redis.hget(`game:${canvasId}:user:${userId}`, 'dead')) === '1'; + console.log(`[GameStateService] 유저 사망 상태 조회: canvasId=${canvasId}, userId=${userId}, dead=${dead}`); + return dead; + } + + // try_count + async incrUserTryCount(canvasId: string, userId: string): Promise { + const key = `game:${canvasId}:user:${userId}`; + const val = await this.redis.hincrby(key, 'try_count', 1); + await this.redis.expire(key, 3600); + console.log(`[GameStateService] 유저 시도 횟수 증가: canvasId=${canvasId}, userId=${userId}, try_count=${val}`); + return val; + } + async getUserTryCount(canvasId: string, userId: string): Promise { + const tryCount = parseInt(await this.redis.hget(`game:${canvasId}:user:${userId}`, 'try_count') || '0', 10); + console.log(`[GameStateService] 유저 시도 횟수 조회: canvasId=${canvasId}, userId=${userId}, try_count=${tryCount}`); + return tryCount; + } + + // own_count + async incrUserOwnCount(canvasId: string, userId: string): Promise { + const key = `game:${canvasId}:user:${userId}`; + const val = await this.redis.hincrby(key, 'own_count', 1); + await this.redis.expire(key, 3600); + console.log(`[GameStateService] 유저 소유 픽셀 증가: canvasId=${canvasId}, userId=${userId}, own_count=${val}`); + return val; + } + async decrUserOwnCount(canvasId: string, userId: string): Promise { + const key = `game:${canvasId}:user:${userId}`; + const val = await this.redis.hincrby(key, 'own_count', -1); + await this.redis.expire(key, 3600); + console.log(`[GameStateService] 유저 소유 픽셀 감소: canvasId=${canvasId}, userId=${userId}, own_count=${val}`); + return val; + } + async getUserOwnCount(canvasId: string, userId: string): Promise { + const ownCount = parseInt(await this.redis.hget(`game:${canvasId}:user:${userId}`, 'own_count') || '0', 10); + console.log(`[GameStateService] 유저 소유 픽셀 조회: canvasId=${canvasId}, userId=${userId}, own_count=${ownCount}`); + return ownCount; + } + async setUserOwnCount(canvasId: string, userId: string, value: number) { + await this.redis.hset(`game:${canvasId}:user:${userId}`, 'own_count', value); + await this.redis.expire(`game:${canvasId}:user:${userId}`, 3600); + console.log(`[GameStateService] 유저 소유 픽셀 강제 세팅: canvasId=${canvasId}, userId=${userId}, own_count=${value}`); + } + + // 색상 + async setUserColor(canvasId: string, userId: string, color: string) { + await this.redis.hset(`game:${canvasId}:user:${userId}`, 'color', color); + await this.redis.expire(`game:${canvasId}:user:${userId}`, 3600); + console.log(`[GameStateService] 유저 색상 설정: canvasId=${canvasId}, userId=${userId}, color=${color}`); + } + async getUserColor(canvasId: string, userId: string): Promise { + const color = await this.redis.hget(`game:${canvasId}:user:${userId}`, 'color') || ''; + console.log(`[GameStateService] 유저 색상 조회: canvasId=${canvasId}, userId=${userId}, color=${color}`); + return color; + } + + // 유저 목록 + async addUserToGame(canvasId: string, userId: string) { + await this.redis.sadd(`game:${canvasId}:users`, userId); + await this.redis.expire(`game:${canvasId}:users`, 3600); + console.log(`[GameStateService] 유저 게임 참가: canvasId=${canvasId}, userId=${userId}`); + } + async getAllUsersInGame(canvasId: string): Promise { + const users = await this.redis.smembers(`game:${canvasId}:users`); + console.log(`[GameStateService] 게임 참가 유저 목록 조회: canvasId=${canvasId}, users=${users.length}명`); + return users; + } + + // 사망자 목록 + async addDeadUser(canvasId: string, userId: string) { + await this.redis.sadd(`game:${canvasId}:dead_users`, userId); + await this.redis.expire(`game:${canvasId}:dead_users`, 3600); + console.log(`[GameStateService] 사망자 추가: canvasId=${canvasId}, userId=${userId}`); + } + async getAllDeadUsers(canvasId: string): Promise { + const deadUsers = await this.redis.smembers(`game:${canvasId}:dead_users`); + console.log(`[GameStateService] 사망자 목록 조회: canvasId=${canvasId}, deadUsers=${deadUsers.length}명`); + return deadUsers; + } +} \ No newline at end of file diff --git a/src/game/game.controller.spec.ts b/src/game/game.controller.spec.ts new file mode 100644 index 0000000..f70c47c --- /dev/null +++ b/src/game/game.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { GameController } from './game.controller'; + +describe('GameController', () => { + let controller: GameController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [GameController], + }).compile(); + + controller = module.get(GameController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/src/game/game.controller.ts b/src/game/game.controller.ts new file mode 100644 index 0000000..ebde1b3 --- /dev/null +++ b/src/game/game.controller.ts @@ -0,0 +1,100 @@ +import { + Controller, + Get, + UseGuards, + Query, + Req, + Post, + Body, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { WaitingResponseDto, QuestionDto } from './dto/waitingResponse.dto'; +import { GameService } from './game.service'; +import { generatorColor } from '../util/colorGenerator.util'; +import { JwtAuthGuard } from '../auth/jwt.guard'; +import { AuthRequest } from '../interface/AuthRequest.interface'; +import { UploadQuestionDto } from './dto/uploadQuestion.dto'; +import { GameStateService } from './game-state.service'; + +interface UploadRequet { + questions: UploadQuestionDto[]; +} + +@ApiTags('canvas') +@Controller('api/game') +export class GameController { + constructor( + private readonly gameService: GameService, + private readonly gameStateService: GameStateService, + ) {} + + @Get('waitingroom') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + async waitingGame( + @Req() req: AuthRequest, + @Query('canvasId') canvasId: string + ) { + console.log(`[GameController] 대기실 요청: canvasId=${canvasId}`); + try { + const user_id = req.user?._id; + console.log(`[GameController] 유저 정보: userId=${user_id}`); + + // 1. 현재 캔버스에 참가한 모든 유저 목록 조회 + const allUsers = await this.gameStateService.getAllUsersInGame(canvasId); + // 2. 유저 인덱스(idx)와 전체 인원(maxPeople) 계산 + const idx = allUsers.findIndex((id) => String(id) === String(user_id)); + const maxPeople = 1000; + // 3. 중복 없는 색상 배정 + const color = generatorColor(idx >= 0 ? idx : allUsers.length, maxPeople); + console.log(`[GameController] 색 생성: idx=${idx}, color=${color}`); + + const questions: QuestionDto[] = await this.gameService.getQuestions(); + console.log( + `[GameController] 문제 조회: questions=${questions.length}개` + ); + + const data = await this.gameService.getData(canvasId, color, questions); + console.log( + `[GameController] 게임 데이터 조회 완료: canvasId=${canvasId}` + ); + + console.log('waiting room data: ', data); + + await this.gameService.setGameReady(color, user_id, canvasId, questions); + console.log( + `[GameController] 게임 준비 완료: userId=${user_id}, canvasId=${canvasId}` + ); + + try { + const resposne = new WaitingResponseDto(); + resposne.data = data; + resposne.success = true; + console.log(`[GameController] 응답 생성 완료: success=true`); + return resposne; + } catch (err) { + console.error(`[GameController] 응답 생성 실패:`, err); + const res = new WaitingResponseDto(); + res.success = false; + return res; + } + } catch (Err) { + console.error( + `[GameController] 대기실 요청 처리 실패: canvasId=${canvasId}`, + Err + ); + throw Err; + } + } + + @Post('upload/question') + async uploadQuestions(@Body() data: UploadRequet) { + try { + await this.gameService.uploadQuestions(data.questions); + return { success: true }; + } catch (err) { + console.error(`[GameController] 문제 업로드 실패:`, err); + return { success: false }; + } + } +} diff --git a/src/game/game.module.ts b/src/game/game.module.ts new file mode 100644 index 0000000..e29f3fe --- /dev/null +++ b/src/game/game.module.ts @@ -0,0 +1,39 @@ +import { Module, forwardRef } from '@nestjs/common'; +import { GameLogicService } from './game-logic.service'; +import { GameStateService } from './game-state.service'; +import { GamePixelService } from './game-pixel.service'; +import { GameFlushService } from './game-flush.service'; +import { GameController } from './game.controller'; +import { GameService } from './game.service'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { GameUserResult } from './entity/game_result.entity'; +import { Question } from 'src/game/entity/questions.entity'; +import { QuestionUser } from './entity/question_user.entity'; +import { AuthModule } from '../auth/auth.module'; +import { CanvasModule } from '../canvas/canvas.module'; +import { DatabaseModule } from '../database/database.module'; + +@Module({ + imports: [ + forwardRef(() => AuthModule), + forwardRef(() => CanvasModule), + TypeOrmModule.forFeature([GameUserResult, Question, QuestionUser]), + DatabaseModule, + ], + providers: [ + GameLogicService, + GameStateService, + GamePixelService, + GameFlushService, + GameService, + ], + controllers: [GameController], + exports: [ + GameLogicService, + GameStateService, + GamePixelService, + GameFlushService, + GameService, + ], +}) +export class GameModule {} diff --git a/src/game/game.service.spec.ts b/src/game/game.service.spec.ts new file mode 100644 index 0000000..f4a1db7 --- /dev/null +++ b/src/game/game.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { GameService } from './game.service'; + +describe('GameService', () => { + let service: GameService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [GameService], + }).compile(); + + service = module.get(GameService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/src/game/game.service.ts b/src/game/game.service.ts new file mode 100644 index 0000000..bf1f3c1 --- /dev/null +++ b/src/game/game.service.ts @@ -0,0 +1,141 @@ +import { + Injectable, + NotFoundException, + InternalServerErrorException, +} from '@nestjs/common'; +import { Question } from './entity/questions.entity'; +import { GameUserResult } from '../game/entity/game_result.entity'; +import { QuestionUser } from './entity/question_user.entity'; +import { InjectRepository, InjectDataSource } from '@nestjs/typeorm'; +import { Repository, DataSource } from 'typeorm'; +import { QuestionDto, GameResponseData, Size } from './dto/waitingResponse.dto'; +import { CanvasService } from '../canvas/canvas.service'; +import { UploadQuestionDto } from './dto/uploadQuestion.dto'; + +@Injectable() +export class GameService { + constructor( + @InjectRepository(Question) + private readonly questionRepository: Repository, + @InjectRepository(GameUserResult) + private readonly gameUserResultRepository: Repository, + @InjectRepository(QuestionUser) + private readonly questionUserRepository: Repository, + private readonly canvasService: CanvasService, + @InjectDataSource() + private readonly dataSource: DataSource + ) {} + + async getQuestions(): Promise { + const questions: QuestionDto[] = await this.dataSource.query( + 'select id, question, options, answer from questions order by RANDOM()' + ); + return questions; + } + + async getData( + canvasId: string, + color: string, + questions: QuestionDto[] + ): Promise { + const result = await this.canvasService.isActiveGameCanvas( + Number(canvasId) + ); + if (!result) throw new NotFoundException('캔버스 활성상태가 아닙니다.'); + const canvas = await this.canvasService.getCanvasById(canvasId); + if (!canvas || !canvas.metaData) + throw new NotFoundException('캔버스가 존재하지 않습니다.'); + const res = new GameResponseData(); + res.canvas_id = canvas?.canvas_id; + res.color = color; + res.startedAt = canvas.metaData?.startedAt; + res.endedAt = canvas.metaData?.endedAt; + res.title = canvas.metaData?.title; + res.type = canvas.metaData?.type; + res.questions = questions; + res.canvasSize = new Size(); + res.canvasSize.width = canvas.metaData?.sizeX; + res.canvasSize.height = canvas.metaData?.sizeY; + return res; + } + + async setGameReady( + color: string, + user_id: number, + canvasId: string, + questions: QuestionDto[] + ): Promise { + console.log( + `[GameService] 게임 준비 시작: userId=${user_id}, canvasId=${canvasId}, color=${color}, questions=${questions.length}개` + ); + try { + await this.dataSource.transaction(async (manager) => { + // TypeORM save 제거, 직접 쿼리만 사용 + await manager.query( + `INSERT INTO game_user_result (user_id, canvas_id, assigned_color, rank, life, created_at) + VALUES ($1, $2, $3, $4, $5, NOW()) + ON CONFLICT (user_id, canvas_id) DO NOTHING`, + [user_id, canvasId, color, 0, 2] + ); + console.log( + `[GameService] game_user_result 직접 쿼리로 저장: user_id=${user_id}, canvas_id=${canvasId}, color=${color}` + ); + + console.log( + `[GameService] question_user 저장 시작: userId=${user_id}, questions=${questions.length}개` + ); + await manager + .createQueryBuilder() + .insert() + .into(QuestionUser) + .values( + questions.map((question) => ({ + userId: user_id, + canvasId: Number(canvasId), + questionId: question.id, + isCorrect: true, + })) + ) + .execute(); + console.log(`[GameService] question_user 저장 완료: userId=${user_id}`); + }); + console.log( + `[GameService] 게임 준비 완료: userId=${user_id}, canvasId=${canvasId}` + ); + } catch (err) { + console.error( + `[GameService] 게임 준비 실패: userId=${user_id}, canvasId=${canvasId}`, + err + ); + throw new InternalServerErrorException( + '게임 준비 중 오류가 발생했습니다.' + ); + } + } + + async uploadQuestions(questions: UploadQuestionDto[]) { + try { + await this.questionRepository + .createQueryBuilder() + .insert() + .into(Question) + .values( + questions.map((question) => ({ + id: Number(question.id), + question: question.question, + options: question.options, + answer: question.answer, + })) + ) + .execute(); + console.log( + `[GameService] 문제 업로드 완료: questions=${questions.length}개` + ); + } catch (err) { + console.error(`[GameService] 문제 업로드 실패:`, err); + throw new InternalServerErrorException( + '문제 업로드 중 오류가 발생했습니다.' + ); + } + } +} diff --git a/src/group/dto/create-group.dto.ts b/src/group/dto/create-group.dto.ts index 0139a1f..b557cee 100644 --- a/src/group/dto/create-group.dto.ts +++ b/src/group/dto/create-group.dto.ts @@ -1,18 +1,30 @@ import { ApiProperty } from '@nestjs/swagger'; +import { IsInt, Min, Max, IsNotEmpty, IsString } from 'class-validator'; +import { Type } from 'class-transformer'; export class CreateGroupDto { @ApiProperty({ example: 'team', description: '그룹 이름입니다.', }) + @IsNotEmpty() + @IsString() name: string; + @ApiProperty({ example: '15', description: '최대 그룹 인원. 최대 100명 가능', }) - maxParticipants: string; + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + maxParticipants: number; + @ApiProperty({ example: '1', description: '캔버스 id.', }) + @IsNotEmpty() canvasId: string; } diff --git a/src/group/group.controller.ts b/src/group/group.controller.ts index 869388a..b51ee44 100644 --- a/src/group/group.controller.ts +++ b/src/group/group.controller.ts @@ -152,6 +152,8 @@ export class GroupController { user.id, Number(canvasId) ); + + console.log('all groups : ', groups); return { success: true, status: '200', @@ -354,6 +356,7 @@ export class GroupController { response.isSuccess = true; response.message = '그룹 참여에 성공하였습니다.'; response.data = await this.groupService.getGroupList(canvas_id, _id); + console.log('response: ', response.data); return response; } catch (err) { if (err instanceof HttpException) { @@ -513,4 +516,11 @@ export class GroupController { ); } } + + @Get('test') + async testAPI(@Query('group_id') group_id: string) { + const result = await this.groupService.getOverlayData(group_id); + console.log('result: ', result); + console.log('result[0]: ', result[0]); + } } diff --git a/src/group/group.gateway.ts b/src/group/group.gateway.ts index 7af687f..e2284be 100644 --- a/src/group/group.gateway.ts +++ b/src/group/group.gateway.ts @@ -188,11 +188,8 @@ export class GroupGateway implements OnGatewayInit { try { const overlay = await this.groupService.getOverlayData(data.group_id); - console.log('이미지 전송 데이터 조회 완료'); - console.log(overlay[0]); if (overlay[0].url != null) { client.emit('send_img', overlay[0]); - console.log('이미지 전송 완료'); } } catch (err) { console.log(err); diff --git a/src/group/group.service.ts b/src/group/group.service.ts index 9813071..ae72b92 100644 --- a/src/group/group.service.ts +++ b/src/group/group.service.ts @@ -5,7 +5,7 @@ import { NotFoundException, Inject, } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; +import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; import { QueryFailedError, Repository, DataSource } from 'typeorm'; import { Group } from './entity/group.entity'; import { Canvas } from '../canvas/entity/canvas.entity'; @@ -17,10 +17,7 @@ import { CreatePreSignedUrl } from './dto/create_url.dto'; import { AwsService } from '../aws/aws.service'; import { randomUUID } from 'crypto'; import { Overlay } from '../group/dto/overlay.dto'; -import { - constructS3PublicUrl, - extractKeyFromPresignedUrl, -} from '../util/urlParsing.util'; +import { extractKeyFromPresignedUrl } from '../util/urlParsing.util'; interface OverlayData { url: string; @@ -37,6 +34,7 @@ export class GroupService { private readonly groupRepository: Repository, @InjectRepository(User) private readonly userRepository: Repository, + @InjectDataSource() private readonly dataSource: DataSource, private readonly awsService: AwsService, // === 통합 Redis 클라이언트 === @@ -140,11 +138,10 @@ export class GroupService { async createGroup( groupName: string, - maxParticipants: string, + maxParticipants: number, canvasId: string, _id: number ) { - const max = Number(maxParticipants); const canvas_id = Number(canvasId); try { @@ -173,7 +170,7 @@ export class GroupService { const group = manager.create(Group, { name: groupName, - maxParticipants: max, + maxParticipants: maxParticipants, createdAt: new Date(), updatedAt: new Date(), madeBy: made_by_user.id, @@ -456,16 +453,12 @@ export class GroupService { const oldURL = group.url; if (oldURL != null) { - const key = extractKeyFromPresignedUrl(oldURL); - await this.awsService.deleteObject(key); + // const key = extractKeyFromPresignedUrl(oldURL); + await this.awsService.deleteObject(oldURL); } const pathname = extractKeyFromPresignedUrl(overlay.url); - const objectURL = constructS3PublicUrl( - process.env.AWS_S3_BUCKET!, - process.env.AWS_REGION!, - pathname - ); + const objectURL = await this.awsService.getPreSignedUrl(pathname); const overlayData = { url: objectURL, @@ -475,7 +468,15 @@ export class GroupService { width: overlay.width, }; - await this.updateGroupOverlayToDB(group, overlayData); + const saveData = { + url: pathname, + x: overlay.x, + y: overlay.y, + height: overlay.height, + width: overlay.width, + }; + + this.updateGroupOverlayToDB(group, saveData); return overlayData; } @@ -484,6 +485,8 @@ export class GroupService { `select url, overlay_x as x, overlay_y as y, overlay_height as height, overlay_width as width from groups where id=$1::integer`, [Number(group_id)] ); + const realURL = await this.awsService.getPreSignedUrl(result[0].url); + result[0].url = realURL; return result; } @@ -501,7 +504,7 @@ export class GroupService { name: '전체', createdAt: canvas.createdAt, updatedAt: canvas.createdAt, - maxParticipants: 100, // 전체 채팅 최대 인원(추후 변경 가능) + maxParticipants: 200, // 전체 채팅 최대 인원(추후 변경 가능) currentParticipantsCount: 1, canvasId: canvas.id, madeBy: 1, // 1번 관리자 계정으로 고정 diff --git a/src/main.ts b/src/main.ts index 7d84e09..1c65c41 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,6 +4,7 @@ import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { IoAdapter } from '@nestjs/platform-socket.io'; import { Express } from 'express'; import * as dotenv from 'dotenv'; +import './worker/alarm.worker'; import { ValidationPipe } from '@nestjs/common'; const cookieParser = require('cookie-parser'); diff --git a/src/queues/bullmq.config.ts b/src/queues/bullmq.config.ts index 8a72daf..0330c24 100644 --- a/src/queues/bullmq.config.ts +++ b/src/queues/bullmq.config.ts @@ -4,7 +4,7 @@ export const redisConnection: RedisOptions = { host: process.env.REDIS_HOST || 'redis', port: parseInt(process.env.REDIS_PORT || '6379', 10), password: process.env.REDIS_PASSWORD || undefined, - lazyConnect: true, + lazyConnect: false, connectTimeout: 10000, commandTimeout: 20000, }; diff --git a/src/queues/bullmq.queue.ts b/src/queues/bullmq.queue.ts index a6e4298..bee93dc 100644 --- a/src/queues/bullmq.queue.ts +++ b/src/queues/bullmq.queue.ts @@ -27,7 +27,7 @@ const historyQueue = new Queue('canvas-history', { }, }); -const startQueue = new Queue('canvas-start', { +const alarmQueue = new Queue('canvas-alarm', { connection: redisConnection, defaultJobOptions: { removeOnComplete: true, @@ -39,4 +39,4 @@ const startQueue = new Queue('canvas-start', { }, }); -export { pixelQueue, historyQueue, startQueue }; +export { pixelQueue, historyQueue, alarmQueue }; diff --git a/src/queues/bullmq.worker.ts b/src/queues/bullmq.worker.ts index b0fee16..99101b8 100644 --- a/src/queues/bullmq.worker.ts +++ b/src/queues/bullmq.worker.ts @@ -6,70 +6,10 @@ import { Pixel } from '../pixel/entity/pixel.entity'; import Redis from 'ioredis'; import { Chat } from '../group/entity/chat.entity'; import { PixelInfo } from '../interface/PixelInfo.interface'; -import { Canvas } from '../canvas/entity/canvas.entity'; -import { generatorPixelToImg } from '../util/imageGenerator.util'; -import { randomUUID } from 'crypto'; -import { uploadBufferToS3 } from '../util/s3UploadFile.util'; -import { ImageHistory } from '../canvas/entity/imageHistory.entity'; -import { CanvasHistory } from '../canvas/entity/canvasHistory.entity'; +import '../../src/worker/history.worker'; config(); -const canvasRepository = AppDataSource.getRepository(Canvas); -const pixelRepository = AppDataSource.getRepository(Pixel); -const historyRepository = AppDataSource.getRepository(CanvasHistory); -const imgRepository = AppDataSource.getRepository(ImageHistory); - -const historyWorker = new Worker( - 'canvas-history', - async (job) => { - console.time('history start'); - const { canvas_id } = job.data; - console.log(canvas_id); - const canvas = await canvasRepository.findOne({ - where: { id: Number(canvas_id) }, - }); - if (!canvas) throw new Error('Canvas not found'); - const pixelData: { x: number; y: number; color: string }[] = - await pixelRepository.query( - 'select x, y, color from pixels where canvas_id = $1::INTEGER', - [Number(canvas_id)] - ); - - const buffer = await generatorPixelToImg( - pixelData, - canvas.sizeX, - canvas.sizeY - ); - const contentType = 'image/png'; - const key = `history/${canvas_id}/${randomUUID()}.png`; - await uploadBufferToS3(buffer, key, contentType); - console.timeEnd('history start'); - - const history = await historyRepository.findOne({ - where: { canvas_id: Number(canvas_id) }, - }); - - if (!history) throw new Error('CanvasHistory not found'); - - await imgRepository.save({ - canvasHistory: history, - image_url: key, - captured_at: new Date(), - }); - }, - { - concurrency: 4, - connection: { - ...redisConnection, - commandTimeout: 30000, - connectTimeout: 30000, - }, - removeOnComplete: { count: 100 }, - removeOnFail: { count: 50 }, - } -); -console.log('[Worker] Canvas history worker 초기화 완료, 대기 중...'); type PixelGenerationJobData = { canvas_id: number; size_x: number; @@ -523,15 +463,7 @@ void (async () => { worker.on('error', (err) => { console.error('[Worker] 워커 에러:', err); }); - historyWorker.on('completed', (job) => { - console.log(`[HistoryWorker] Job 완료: ${job.id}`); - }); - historyWorker.on('failed', (job, err) => { - console.error(`[HistoryWorker] Job 실패: ${job?.id}`, err); - }); - historyWorker.on('error', (err) => { - console.error('[HistoryWorker] 워커 에러:', err); - }); + console.log('[Worker] 워커 시작 완료, job 대기 중...'); } catch (error) { console.error('[Worker] 초기화 실패:', error); @@ -565,4 +497,4 @@ export async function publishChatMessage(groupId: number, chatData: any) { } } -export { worker, historyWorker }; +export { worker }; diff --git a/src/socket/socket.manager.ts b/src/socket/socket.manager.ts new file mode 100644 index 0000000..a17d872 --- /dev/null +++ b/src/socket/socket.manager.ts @@ -0,0 +1,13 @@ +// src/socket/socket.manager.ts +import { Server } from 'socket.io'; + +let io: Server; + +export function setSocketServer(server: Server) { + io = server; +} + +export function getSocketServer(): Server { + if (!io) throw new Error('Socket server not initialized'); + return io; +} diff --git a/src/user/dto/user_info_dto.dto.ts b/src/user/dto/user_info_dto.dto.ts index 522eb43..da9f9ae 100644 --- a/src/user/dto/user_info_dto.dto.ts +++ b/src/user/dto/user_info_dto.dto.ts @@ -1,32 +1,71 @@ import { ApiProperty } from '@nestjs/swagger'; class userCanvasInfo { - @ApiProperty() + @ApiProperty({ example: 1 }) canvasId: number; - @ApiProperty() + @ApiProperty({ example: 'My First Canvas' }) title: string; - @ApiProperty() + @ApiProperty({ example: '2025-01-15T09:30:00Z' }) created_at: Date; - @ApiProperty() + @ApiProperty({ example: '2025-07-15T00:00:00Z' }) + started_at: Date; + + @ApiProperty({ example: '2025-08-01T00:00:00Z' }) + ended_at: Date; + + @ApiProperty({ example: 100 }) size_x: number; - @ApiProperty() + + @ApiProperty({ example: 100 }) size_y: number; + + @ApiProperty({ example: 15 }) + try_count: number; + + @ApiProperty({ nullable: true, example: 7, description: '캔버스 종료 전에는 null, 종료 후에는 본인이 소유한 픽셀 수' }) + own_count: number | null; } class UserInfoResponseDto { - @ApiProperty() + @ApiProperty({ example: 'user@example.com' }) email: string; - @ApiProperty() + @ApiProperty({ example: '2025-01-01T00:00:00Z' }) createdAt: Date; - @ApiProperty() + @ApiProperty({ example: 'nickname123' }) nickName: string; - @ApiProperty() + @ApiProperty({ + type: [userCanvasInfo], + example: [ + { + canvasId: 1, + title: 'My First Canvas', + created_at: '2025-01-15T09:30:00Z', + started_at: '2025-07-15T00:00:00Z', + ended_at: '2025-08-01T00:00:00Z', + size_x: 100, + size_y: 100, + try_count: 15, + own_count: 7 + }, + { + canvasId: 2, + title: 'Project Artwork', + created_at: '2025-02-20T14:00:00Z', + started_at: '2025-07-15T00:00:00Z', + ended_at: '2025-08-01T00:00:00Z', + size_x: 200, + size_y: 150, + try_count: 3, + own_count: null + } + ] + }) canvases: userCanvasInfo[]; } diff --git a/src/user/entity/user.entity.ts b/src/user/entity/user.entity.ts index 41e072d..7f38268 100644 --- a/src/user/entity/user.entity.ts +++ b/src/user/entity/user.entity.ts @@ -4,7 +4,7 @@ import { Pixel } from '../../pixel/entity/pixel.entity'; import { GroupUser } from '../../entity/GroupUser.entity'; import { CanvasHistory } from '../../canvas/entity/canvasHistory.entity'; import { QuestionUser } from '../../game/entity/question_user.entity'; -import { GameUserResult } from 'src/game/entity/game_result.entity'; +import { GameUserResult } from '../../game/entity/game_result.entity'; @Entity('users') export class User { @@ -32,10 +32,10 @@ export class User { @OneToMany(() => GroupUser, (gu) => gu.user) groupUsers: GroupUser[]; - @OneToMany(() => CanvasHistory, (history) => history.top_participant) + @OneToMany(() => CanvasHistory, (history) => history.topTryUser) top_participant_history: CanvasHistory[]; - @OneToMany(() => CanvasHistory, (history) => history.top_pixel_owner) + @OneToMany(() => CanvasHistory, (history) => history.topOwnUser) top_pixel_owner_history: CanvasHistory[]; @OneToMany(() => QuestionUser, (qu) => qu.user) diff --git a/src/user/user.module.ts b/src/user/user.module.ts index dc53242..19e69ad 100644 --- a/src/user/user.module.ts +++ b/src/user/user.module.ts @@ -7,7 +7,7 @@ import { HttpModule } from '@nestjs/axios'; import { AuthModule } from '../auth/auth.module'; import { QuestionUser } from '../game/entity/question_user.entity'; import { CanvasHistory } from '../canvas/entity/canvasHistory.entity'; -import { CanvasModule } from 'src/canvas/canvas.module'; +import { CanvasModule } from '../canvas/canvas.module'; @Module({ imports: [ TypeOrmModule.forFeature([User, CanvasHistory, QuestionUser]), diff --git a/src/user/user.service.ts b/src/user/user.service.ts index d5dc297..41dbf52 100644 --- a/src/user/user.service.ts +++ b/src/user/user.service.ts @@ -225,6 +225,11 @@ export class UserService { ended_at: uc.canvas.endedAt, size_x: uc.canvas.sizeX, size_y: uc.canvas.sizeY, + try_count: uc.tryCount, + own_count: + uc.canvas.endedAt && uc.canvas.type !== 'public' + ? uc.ownCount + : null, })); return { email: user.email, diff --git a/src/util/alarmGenerator.util.ts b/src/util/alarmGenerator.util.ts new file mode 100644 index 0000000..9ec424b --- /dev/null +++ b/src/util/alarmGenerator.util.ts @@ -0,0 +1,134 @@ +import { historyQueue, alarmQueue } from '../queues/bullmq.queue'; +import { Canvas } from '../canvas/entity/canvas.entity'; + +async function isEndingWithOneDay(canvas: Canvas) { + const now = Date.now(); + const endedAtTime = new Date(canvas.endedAt).getTime(); + const delay = endedAtTime - now; + + // 1일 이내 종료되는 경우 → 큐에 바로 등록 + const ONE_DAYS = 1000 * 60 * 60 * 24 * 1; + const jobId = `history-${canvas.id}`; + + if (delay > 0 && delay <= ONE_DAYS) { + await historyQueue.add( + 'canvas-history', + { + canvas_id: canvas.id, + size_x: canvas.sizeX, + size_y: canvas.sizeY, + type: canvas.type, + }, + { jobId: jobId, delay } + ); + } +} + +async function putJobOnAlarmQueueThreeSecBeforeEnd(canvas: Canvas) { + const now = Date.now(); + const endedAtTime = new Date(canvas.endedAt).getTime(); + const delay = endedAtTime - now - 3 * 1000; + + console.log('before end : ', delay); + + // 1일 이내 종료되는 경우 → 큐에 바로 등록 + const ONE_DAYS = 1000 * 60 * 60 * 24 * 1; + const jobId = `3sec-before-end-${canvas.id}`; + + if (delay > 0 && delay <= ONE_DAYS) { + await alarmQueue.add( + '3sec-before-end', + { + canvas_id: canvas.id, + title: canvas.title, + endedAt: canvas.endedAt, + }, + { jobId: jobId, delay } + ); + } +} + +async function putJobOnAlarmQueueBeforeStart30s(canvas: Canvas) { + const now = Date.now(); + const startTime = new Date(canvas.startedAt).getTime(); + const delay = startTime - now - 30 * 1000; + const Id = `30sec-before-start-${canvas.id}`; + + console.log('30s before start : ', delay); + // 1일 이내 종료되는 경우 → 큐에 바로 등록 + const ONE_DAYS = 1000 * 60 * 60 * 24 * 1; + + if (delay > 0 && delay <= ONE_DAYS) { + try { + await alarmQueue.add( + '30sec-before-start', + { + canvas_id: canvas.id, + title: canvas.title, + startedAt: canvas.startedAt, + }, + { + jobId: Id, + delay, + } + ); + } catch (err) { + console.error('startQueue.add 중 오류 발생:', err); + } + } +} + +async function putJobOnAlarmQueue3SecsBeforeStart(canvas: Canvas) { + const now = Date.now(); + const startTime = new Date(canvas.startedAt).getTime(); + const delay = startTime - now - 3 * 1000; + const Id = `3sec-before-start-${canvas.id}`; + const ONE_DAYS = 1000 * 60 * 60 * 24 * 1; + console.log('3s before start : ', delay); + if (delay > 0 && delay <= ONE_DAYS) { + try { + await alarmQueue.add( + '3sec-before-start', + { + canvas_id: canvas.id, + title: canvas.title, + startedAt: canvas.startedAt, + }, + { + jobId: Id, + delay, + } + ); + } catch (err) { + console.log(err); + } + } +} + +async function putJobOnAlarmQueueGameEnd(canvas: Canvas) { + if (canvas.type !== 'game_calculation') return; + const now = Date.now(); + const endedAtTime = new Date(canvas.endedAt).getTime(); + const delay = endedAtTime - now; + const ONE_DAYS = 1000 * 60 * 60 * 24 * 1; + const jobId = `game-end-${canvas.id}`; + if (delay > 0 && delay <= ONE_DAYS) { + await alarmQueue.add( + 'game-end', + { + canvas_id: canvas.id, + title: canvas.title, + endedAt: canvas.endedAt, + }, + { jobId: jobId, delay } + ); + } +} + +export { + putJobOnAlarmQueue3SecsBeforeStart, + putJobOnAlarmQueueBeforeStart30s, + putJobOnAlarmQueueThreeSecBeforeEnd, + isEndingWithOneDay, + putJobOnAlarmQueueGameEnd, +}; diff --git a/src/util/colorGenerator.util.ts b/src/util/colorGenerator.util.ts new file mode 100644 index 0000000..9415b17 --- /dev/null +++ b/src/util/colorGenerator.util.ts @@ -0,0 +1,99 @@ +import * as crypto from 'crypto'; + +/** + * 기존 색상 생성 로직 (주석처리) + */ +// function uuidToIndex(uuid: string, maxIndex: number = 1000): number { +// const hash = crypto.createHash('md5').update(uuid).digest('hex'); +// const hashPrefix = hash.slice(0, 8); +// const numeric = parseInt(hashPrefix, 16); +// return numeric % maxIndex; +// } +// function hslToHex(h: number, s: number, l: number): string { +// s /= 100; +// l /= 100; +// const k = (n: number) => (n + h / 30) % 12; +// const a = s * Math.min(l, 1 - l); +// const f = (n: number) => +// Math.round( +// 255 * (l - a * Math.max(-1, Math.min(Math.min(k(n) - 3, 9 - k(n)), 1))) +// ); +// return `#${[f(0), f(8), f(4)].map((x) => x.toString(16).padStart(2, '0')).join('')}`; +// } +// function generateHexColor(index: number, maxIndex: number): string { +// const hue = Math.floor((360 * index) / maxIndex); // 0 ~ 359 +// return hslToHex(hue, 70, 60); // 고정된 채도/명도에서 색상만 변화 +// } +// export function generatorColor(maxIndex: number = 1000) { +// const uuid = crypto.randomUUID(); // UUID 생성 +// const index = uuidToIndex(uuid, maxIndex); +// const color = generateHexColor(index, maxIndex); +// return color; +// } + + +//구분 잘 가는 HEX 팔레트 (검은색 제외, 30개) +const HEX_PALETTE = [ + '#e6194b', '#3cb44b', '#ffe119', '#4363d8', '#f58231', '#911eb4', + '#46f0f0', '#f032e6', '#bcf60c', '#fabebe', '#008080', '#e6beff', + '#9a6324', '#fffac8', '#800000', '#aaffc3', '#808000', '#ffd8b1', + '#000075', '#808080', '#a9a9a9', '#ffd700', '#00ff00', '#00ced1', + '#ff1493', '#7cfc00', '#ff4500', '#4682b4', '#dda0dd', '#bdb76b', + '#ff6347', // ... 필요시 더 추가 +].filter(c => c.toLowerCase() !== '#000000'); // 검은색 배제 + +// HSL의 Hue, Saturation, Lightness 조합 +function hslToHex(h: number, s: number, l: number): string { + s /= 100; + l /= 100; + const k = (n: number) => (n + h / 30) % 12; + const a = s * Math.min(l, 1 - l); + const f = (n: number) => + Math.round( + 255 * (l - a * Math.max(-1, Math.min(Math.min(k(n) - 3, 9 - k(n)), 1))) + ); + return `#${[f(0), f(8), f(4)].map((x) => x.toString(16).padStart(2, '0')).join('')}`; +} + +function getDistinctColorIndex(max: number): number { + // 골고루 섞인 인덱스(0, max/2, max/4, 3*max/4, ...) + // Van der Corput sequence 등도 가능하지만, 간단히 섞음 + const idx = Math.floor(Math.random() * max); + return ((idx * 37) % max); // 37은 200 이하에서 최대한 골고루 분포 +} + +export function generatorColor(idx: number, maxPeople: number = 1000): string { + // 1. HEX 팔레트 우선 배정 + if (idx < HEX_PALETTE.length) { + return HEX_PALETTE[idx]; + } + // 2. 부족하면 HSL 조합으로 생성 (검은색만 배제) + const hues = [0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330]; + const sats = [40, 60, 80, 100, 85]; + const lights = [35, 50, 65, 80, 90]; + const total = hues.length * sats.length * lights.length; + if (idx < HEX_PALETTE.length + total) { + const hIdx = Math.floor((idx - HEX_PALETTE.length) / (sats.length * lights.length)) % hues.length; + const sIdx = Math.floor((idx - HEX_PALETTE.length) / lights.length) % sats.length; + const lIdx = (idx - HEX_PALETTE.length) % lights.length; + const h = hues[hIdx]; + const s = sats[sIdx]; + const l = lights[lIdx]; + const color = hslToHex(h, s, l); + if (color.toLowerCase() === '#000000') return '#0074d9'; + return color; + } + // 3. 1000명까지: 기존 색상에서 약간씩 변형 (중복 최소화) + // 기존 HSL 조합을 재활용하되, idx에 따라 H/S/L을 소폭 변화 + const baseIdx = (idx - HEX_PALETTE.length - total) % total; + const hIdx = Math.floor(baseIdx / (sats.length * lights.length)) % hues.length; + const sIdx = Math.floor(baseIdx / lights.length) % sats.length; + const lIdx = baseIdx % lights.length; + // idx에 따라 약간의 변화(최대 10도, 5%, 5%) + const h = (hues[hIdx] + ((idx % 10) * 3)) % 360; + const s = Math.min(100, sats[sIdx] + (idx % 5)); + const l = Math.min(95, lights[lIdx] + (idx % 5)); + const color = hslToHex(h, s, l); + if (color.toLowerCase() === '#000000') return '#ffffff'; + return color; +} diff --git a/src/worker/alarm.worker.ts b/src/worker/alarm.worker.ts new file mode 100644 index 0000000..771f4e3 --- /dev/null +++ b/src/worker/alarm.worker.ts @@ -0,0 +1,118 @@ +// src/queues/start.worker.ts +import { Job, Worker } from 'bullmq'; +import { redisConnection } from '../queues/bullmq.config'; +import { Server } from 'socket.io'; +import { getSocketServer } from '../socket/socket.manager'; +// Nest DI를 위한 추가 import +import { NestFactory } from '@nestjs/core'; +import { AppModule } from '../app.module'; +import { GameLogicService } from '../game/game-logic.service'; +let gameLogicService: GameLogicService | null = null; +let nestApp: any = null; +async function getGameLogicService(): Promise { + if (gameLogicService) return gameLogicService; + if (!nestApp) { + nestApp = await NestFactory.createApplicationContext(AppModule, { logger: false }); + } + gameLogicService = nestApp.get(GameLogicService); + if (!gameLogicService) throw new Error('GameLogicService DI 실패'); + return gameLogicService; +} + +interface JobData { + canvas_id: number; + title: string; + startedAt?: Date; + endedAt?: Date; +} + +const alarmWorker = new Worker( + 'canvas-alarm', + async (job: Job) => { + const io: Server = getSocketServer(); // 소켓 서버 가져오기 + const data: JobData = job.data as JobData; + switch (job.name) { + case '3sec-before-end': + return handleThreeSecBeforeEnd(data, io); + case '30sec-before-start': + return handleThirtySecBeforeStart(data, io); + case '3sec-before-start': + return handleThreeSecBeforeStart(data, io); + case 'game-end': + return handleGameEnd(data, io); + default: + console.warn(`Unhandled job name: ${job.name}`); + } + }, + { connection: redisConnection } +); + +function handleThirtySecBeforeStart(data: JobData, io: Server) { + const { canvas_id, title, startedAt } = data; + console.log('30초 전 알람 실행'); + io.emit('canvas_open_alarm', { + canvas_id: canvas_id, + title: title, + started_at: startedAt, + server_time: new Date(), + remain_time: 30, + }); + console.log('30초 전 알람 발송'); +} + +function handleThreeSecBeforeEnd(data: JobData, io: Server) { + const { canvas_id, title, endedAt } = data; + const id = `canvas_${canvas_id}`; + + console.log('끝나기 3초전 알람 발행'); + io.to(id).emit('canvas_close_alarm', { + canvas_id: canvas_id, + title: title, + ended_at: endedAt, + server_time: new Date(), + remain_time: 3, + }); + console.log('끝나기 3초전 알람 발송'); +} + +function handleThreeSecBeforeStart(data: JobData, io: Server) { + const { canvas_id, title, startedAt } = data; + const id = `canvas_${canvas_id}`; + + console.log('시작 3초전 알람 발행'); + io.to(id).emit('canvas_open_alarm', { + canvas_id: canvas_id, + title: title, + started_at: startedAt, + server_time: new Date(), + remain_time: 3, + }); + console.log('시작 3초전 알람 발송'); +} + +async function handleGameEnd(data: JobData, io: Server) { + try { + // GameLogicService DI로 가져오기 + const gameLogic = (await getGameLogicService())!; + // 캔버스 타입이 game_calculation인지 확인하려면, gameLogicService의 canvasService 사용 + const canvasInfo = await gameLogic['canvasService'].getCanvasById(String(data.canvas_id)); + if (canvasInfo?.metaData?.type !== 'game_calculation') { + console.log(`[alarm.worker] game-end: 캔버스 타입이 game_calculation이 아님, 무시`); + return; + } + console.log(`[alarm.worker] game-end: forceGameEnd 호출: canvas_id=${data.canvas_id}`); + await gameLogic.forceGameEnd(String(data.canvas_id), io); + console.log(`[alarm.worker] game-end: forceGameEnd 완료: canvas_id=${data.canvas_id}`); + } catch (err) { + console.error(`[alarm.worker] game-end: forceGameEnd 에러:`, err); + } +} + +alarmWorker.on('completed', (job) => { + console.log(`✅ [Worker] Job completed: ${job.id}`); +}); + +alarmWorker.on('failed', (job, err) => { + console.error(`❌ [Worker] Job failed: ${job?.id}`, err); +}); +export { alarmWorker }; diff --git a/src/worker/history.worker.ts b/src/worker/history.worker.ts index d37a3b0..92ed5d5 100644 --- a/src/worker/history.worker.ts +++ b/src/worker/history.worker.ts @@ -1,40 +1,174 @@ -import { Worker } from 'bullmq'; -import { Canvas } from '../canvas/entity/canvas.entity'; +import { Worker, Job } from 'bullmq'; import { Pixel } from '../pixel/entity/pixel.entity'; import { AppDataSource } from '../data-source'; import { generatorPixelToImg } from '../util/imageGenerator.util'; -import { AwsService } from '../aws/aws.service'; +import { uploadBufferToS3 } from '../util/s3UploadFile.util'; import { randomUUID } from 'crypto'; import { redisConnection } from '../queues/bullmq.config'; +import { CanvasHistory } from '../canvas/entity/canvasHistory.entity'; -const canvasRepository = AppDataSource.getRepository(Canvas); const pixelRepository = AppDataSource.getRepository(Pixel); -const awsService = new AwsService(); +const historyRepository = AppDataSource.getRepository(CanvasHistory); const historyWorker = new Worker( 'canvas-history', - async (job) => { - const canvas_id = job.id; - const canvas = await canvasRepository.findOne({ - where: { id: Number(canvas_id) }, - }); - if (!canvas) throw new Error('Canvas not found'); + async (job: Job) => { + console.time('history start'); + const { canvas_id, size_x, size_y, type } = job.data; + + if (!job.data) throw new Error('job.data is undefined'); + const pixelData: { x: number; y: number; color: string }[] = await pixelRepository.query( - 'select x, y, color from pixels where cavans_id = $1::intger', - [Number(canvas_id)] + 'select x, y, color from pixels where canvas_id = $1::INTEGER', + [canvas_id] ); - - const buffer = await generatorPixelToImg( - pixelData, - canvas.sizeX, - canvas.sizeY - ); + const buffer = await generatorPixelToImg(pixelData, size_x, size_y); const contentType = 'image/png'; const key = `history/${canvas_id}/${randomUUID()}.png`; - await awsService.uploadFile(buffer, key, contentType); + await uploadBufferToS3(buffer, key, contentType); + console.timeEnd('history start'); + + const history = await historyRepository.findOne({ + where: { canvas: { id: Number(canvas_id) } }, + }); + + if (!history) throw new Error('CanvasHistory not found'); + + history.img_url = key; + history.caputred_at = new Date(); + + await historyRepository.save(history); + + // 캔버스 히스토리 데이터 생성 (public이 아닌 캔버스만) + if (type !== 'public') { + try { + // CanvasHistoryService를 직접 호출하는 대신 SQL로 처리 + await createCanvasHistoryData(canvas_id); + console.log( + `[HistoryWorker] 캔버스 ${canvas_id} 히스토리 데이터 생성 완료` + ); + } catch (error) { + console.error( + `[HistoryWorker] 캔버스 ${canvas_id} 히스토리 데이터 생성 실패:`, + error + ); + } + } }, - { concurrency: 4, connection: redisConnection } + { + concurrency: 4, + connection: { + ...redisConnection, + commandTimeout: 30000, + connectTimeout: 30000, + }, + removeOnComplete: { count: 100 }, + removeOnFail: { count: 50 }, + } ); +historyWorker.on('completed', (job) => { + console.log(`[HistoryWorker] Job 완료: ${job.id}`); +}); +historyWorker.on('failed', (job, err) => { + console.error(`[HistoryWorker] Job 실패: ${job?.id}`, err); +}); +historyWorker.on('error', (err) => { + console.error('[HistoryWorker] 워커 에러:', err); +}); + +// 캔버스 히스토리 데이터 생성 함수 +async function createCanvasHistoryData(canvasId: number): Promise { + const queryRunner = AppDataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + // 1. 기본 통계 데이터 조회 (최적화된 단순 쿼리) + const basicStatsQuery = ` + SELECT + COUNT(DISTINCT uc.user_id) as participant_count, + SUM(uc.try_count) as total_try_count + FROM user_canvas uc + WHERE uc.canvas_id = $1 + `; + const basicStats = await queryRunner.query(basicStatsQuery, [canvasId]); + + // 2. top_try_user 조회 (인덱스 활용) + const topTryUserQuery = ` + SELECT uc.user_id, uc.try_count + FROM user_canvas uc + WHERE uc.canvas_id = $1 AND uc.try_count > 0 + ORDER BY uc.try_count DESC, uc.joined_at ASC, uc.user_id ASC + LIMIT 1 + `; + const topTryUser = await queryRunner.query(topTryUserQuery, [canvasId]); + + // 3. top_own_user 조회 (인덱스 활용) + const topOwnUserQuery = ` + SELECT + p.owner as user_id, + COUNT(*) as own_count + FROM pixels p + WHERE p.canvas_id = $1 AND p.owner IS NOT NULL + GROUP BY p.owner + ORDER BY COUNT(*) DESC, + (SELECT joined_at FROM user_canvas WHERE user_id = p.owner AND canvas_id = $1) ASC, + p.owner ASC + LIMIT 1 + `; + const topOwnUser = await queryRunner.query(topOwnUserQuery, [canvasId]); + + // 4. own_count 업데이트 (최적화된 배치 업데이트) + const updateOwnCountQuery = ` + UPDATE user_canvas uc + SET own_count = COALESCE( + (SELECT COUNT(*) FROM pixels p WHERE p.owner = uc.user_id AND p.canvas_id = uc.canvas_id), + 0 + ) + WHERE uc.canvas_id = $1 + `; + await queryRunner.query(updateOwnCountQuery, [canvasId]); + + // 5. CanvasHistory 생성 또는 업데이트 + const upsertHistoryQuery = ` + INSERT INTO canvas_history ( + canvas_id, participant_count, total_try_count, + top_try_user_id, top_try_user_count, top_own_user_id, top_own_user_count + ) VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (canvas_id) DO UPDATE SET + participant_count = EXCLUDED.participant_count, + total_try_count = EXCLUDED.total_try_count, + top_try_user_id = EXCLUDED.top_try_user_id, + top_try_user_count = EXCLUDED.top_try_user_count, + top_own_user_id = EXCLUDED.top_own_user_id, + top_own_user_count = EXCLUDED.top_own_user_count + `; + + await queryRunner.query(upsertHistoryQuery, [ + canvasId, + basicStats[0]?.participant_count || 0, + basicStats[0]?.total_try_count || 0, + topTryUser[0]?.user_id || null, + topTryUser[0]?.try_count || null, + topOwnUser[0]?.user_id || null, + topOwnUser[0]?.own_count || null, + ]); + + await queryRunner.commitTransaction(); + console.log( + `[Worker] 캔버스 ${canvasId} 히스토리 데이터 생성 완료 (최적화됨)` + ); + } catch (error) { + await queryRunner.rollbackTransaction(); + console.error( + `[Worker] 캔버스 ${canvasId} 히스토리 데이터 생성 실패:`, + error + ); + throw error; + } finally { + await queryRunner.release(); + } +} export { historyWorker }; diff --git a/src/worker/start.worker.ts b/src/worker/start.worker.ts deleted file mode 100644 index e69de29..0000000