diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3032604 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,77 @@ +name: Spring CI + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +jobs: + build: + runs-on: ubuntu-latest + + services: + mysql: + image: mysql:8.0 + ports: + - 3306:3306 + env: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: runtracker_test + MYSQL_USER: testuser + MYSQL_PASSWORD: testpass + options: --health-cmd="mysqladmin ping --silent" --health-interval=10s --health-timeout=5s --health-retries=5 + + redis: + image: redis:7-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + env: + SPRING_DATASOURCE_URL: jdbc:mysql://localhost:3306/runtracker_test + SPRING_DATASOURCE_USERNAME: testuser + SPRING_DATASOURCE_PASSWORD: testpass + SPRING_JPA_HIBERNATE_DDL_AUTO: update + REDIS_HOST: ${{ secrets.REDIS_HOST }} + REDIS_PORT: ${{ secrets.REDIS_PORT }} + KAKAO_CLIENT_ID: ${{ secrets.KAKAO_CLIENT_ID }} + KAKAO_REDIRECT_URI: ${{ secrets.KAKAO_REDIRECT_URI }} + JWT_SECRET: ${{ secrets.JWT_SECRET }} + JWT_ACCESS_TOKEN_EXPIRATION: ${{ secrets.JWT_ACCESS_TOKEN_EXPIRATION }} + JWT_REFRESH_TOKEN_EXPIRATION: ${{ secrets.JWT_REFRESH_TOKEN_EXPIRATION }} + OAUTH2_REDIRECT_URI: ${{ secrets.OAUTH2_REDIRECT_URI }} + AUTH_KEY: ${{ secrets.AUTH_KEY }} + FCM_JSON: ${{ secrets.FCM_JSON }} + Firebase_ID: ${{ secrets.FIREBASE_PROJECT_ID }} + GOOGLE_MAP_API_KEY: ${{ secrets.GOOGLE_MAP_API_KEY }} + SPRING_DOMAIN: ${{ secrets.SPRING_DOMAIN }} + FILE_UPLOAD_DIR: ./uploads + S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }} + S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }} + S3_REGION: ${{ secrets.S3_REGION }} + S3_BUCKET: ${{ secrets.S3_BUCKET }} + S3_BASE_URL: ${{ secrets.S3_BASE_URL }} + FIREBASE_SERVICE_ACCOUNT_KEY: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_KEY }} + + steps: + - name: Checkout source code + uses: actions/checkout@v3 + + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + distribution: 'temurin' + java-version: '17' + + - name: Grant execute permission for Gradle wrapper + run: chmod +x gradlew + working-directory: runtracker + + - name: Build with Gradle + run: ./gradlew build + working-directory: runtracker diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..e729c96 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,62 @@ +name: Deploy to EC2 + +on: + push: + branches: + - develop + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Deploy to EC2 + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.EC2_SSH_KEY }} + script: | + cd ~/RunTracker-Server/runtracker + + git pull origin develop + + echo "SPRING_DATASOURCE_URL=${{ secrets.SPRING_DATASOURCE_URL }}" > .env + echo "MYSQL_ROOT_PASSWORD=${{ secrets.MYSQL_ROOT_PASSWORD }}" >> .env + echo "GOOGLE_MAP_API_KEY=${{secrets.GOOGLE_MAP_API_KEY}}" >> .env + echo "MYSQL_DATABASE=${{ secrets.MYSQL_DATABASE }}" >> .env + echo "MYSQL_USER=${{ secrets.MYSQL_USER }}" >> .env + echo "MYSQL_PASSWORD=${{ secrets.MYSQL_PASSWORD }}" >> .env + echo "SPRING_DATASOURCE_USERNAME=${{ secrets.SPRING_DATASOURCE_USERNAME }}" >> .env + echo "SPRING_DATASOURCE_PASSWORD=${{ secrets.SPRING_DATASOURCE_PASSWORD }}" >> .env + echo "JWT_SECRET=${{ secrets.JWT_SECRET }}" >> .env + echo "JWT_ACCESS_TOKEN_EXPIRATION=${{ secrets.JWT_ACCESS_TOKEN_EXPIRATION }}" >> .env + echo "JWT_REFRESH_TOKEN_EXPIRATION=${{ secrets.JWT_REFRESH_TOKEN_EXPIRATION }}" >> .env + echo "REDIS_HOST=${{ secrets.REDIS_HOST }}" >> .env + echo "REDIS_PORT=${{ secrets.REDIS_PORT }}" >> .env + echo "AUTH_KEY=${{ secrets.AUTH_KEY }}" >> .env + echo "OAUTH2_REDIRECT_URI=${{ secrets.OAUTH2_REDIRECT_URI }}" >> .env + echo "KAKAO_CLIENT_ID=${{ secrets.KAKAO_CLIENT_ID }}" >> .env + echo "KAKAO_REDIRECT_URI=${{ secrets.KAKAO_REDIRECT_URI }}" >> .env + echo "SPRING_DOMAIN=${{ secrets.SPRING_DOMAIN }}" >> .env + echo "S3_ACCESS_KEY=${{ secrets.S3_ACCESS_KEY }}" >> .env + echo "S3_SECRET_KEY=${{ secrets.S3_SECRET_KEY }}" >> .env + echo "S3_REGION=${{ secrets.S3_REGION }}" >> .env + echo "S3_BUCKET=${{ secrets.S3_BUCKET }}" >> .env + echo "S3_BASE_URL=${{ secrets.S3_BASE_URL }}" >> .env + echo "FILE_UPLOAD_DIR=/app/uploads" >> .env + echo "FIREBASE_SERVICE_ACCOUNT_KEY=${{ secrets.FIREBASE_SERVICE_ACCOUNT_KEY }}" >> .env + echo "Firebase_ID=${{ secrets.FIREBASE_PROJECT_ID }}" >> .env + + mkdir -p ./src/main/resources/firebase + echo '${{ secrets.FCM_JSON }}' > ./src/main/resources/firebase/${{ secrets.FIREBASE_SERVICE_ACCOUNT_KEY }} + + chmod +x ./gradlew + + ./gradlew build + + sudo docker compose up --build -d + sudo docker image prune -f \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..88d7090 --- /dev/null +++ b/.gitignore @@ -0,0 +1,53 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ +.DS_Store + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +### Setting ### +.env + +### Auth ### +certbot/conf +certbot/www + +### logs ### +*.log +logs/ + +### Firebase ### +**/firebase/ +runtracker-a30bb-firebase-adminsdk-fbsvc-9479026564.json \ No newline at end of file diff --git a/runtracker-prototype/.gitattributes b/runtracker-prototype/.gitattributes new file mode 100644 index 0000000..8af972c --- /dev/null +++ b/runtracker-prototype/.gitattributes @@ -0,0 +1,3 @@ +/gradlew text eol=lf +*.bat text eol=crlf +*.jar binary diff --git a/runtracker-prototype/.gitignore b/runtracker-prototype/.gitignore new file mode 100644 index 0000000..17b4262 --- /dev/null +++ b/runtracker-prototype/.gitignore @@ -0,0 +1,41 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +### Setting ### +.env +application-local.yml \ No newline at end of file diff --git a/runtracker-prototype/Dockerfile b/runtracker-prototype/Dockerfile new file mode 100644 index 0000000..622c0a4 --- /dev/null +++ b/runtracker-prototype/Dockerfile @@ -0,0 +1,5 @@ +FROM openjdk:17-alpine +LABEL authors="Mangjun" +ARG JAR_FILE=build/libs/*.jar +COPY ${JAR_FILE} app.jar +ENTRYPOINT ["java","-jar","/app.jar"] \ No newline at end of file diff --git a/runtracker-prototype/build.gradle b/runtracker-prototype/build.gradle new file mode 100644 index 0000000..ef5e98c --- /dev/null +++ b/runtracker-prototype/build.gradle @@ -0,0 +1,55 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.4.4' + id 'io.spring.dependency-management' version '1.1.7' +} + +group = 'com.runtracker-prototype' +version = '0.0.1-SNAPSHOT' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-web' + + /* Docs */ + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0' + + /* AWS */ + implementation platform('software.amazon.awssdk:bom:2.25.16') + implementation 'software.amazon.awssdk:s3' + implementation 'software.amazon.awssdk:auth' + implementation 'software.amazon.awssdk:regions' + + /* Log */ + implementation 'org.zalando:logbook-spring-boot-starter:3.9.0' + + compileOnly 'org.projectlombok:lombok' + runtimeOnly 'com.mysql:mysql-connector-j' + annotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} + +tasks.named('test') { + useJUnitPlatform() +} + +tasks.withType(JavaCompile).configureEach { + options.compilerArgs << '-parameters' +} diff --git a/runtracker-prototype/docker-compose.yml b/runtracker-prototype/docker-compose.yml new file mode 100644 index 0000000..ba612ae --- /dev/null +++ b/runtracker-prototype/docker-compose.yml @@ -0,0 +1,37 @@ +version: '3.8' + +services: + db: + image: mysql:8.0 + container_name: runtracker_proto_db + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} + MYSQL_DATABASE: ${MYSQL_DATABASE} + MYSQL_USER: ${MYSQL_USER} + MYSQL_PASSWORD: ${MYSQL_PASSWORD} + volumes: + - mysql-data:/var/lib/mysql + networks: + - runtracker_proto_net + + app: + build: + context: . + container_name: runtracker_proto_app + ports: + - "8080:8080" + depends_on: + - db + environment: + SPRING_DATASOURCE_URL: ${SPRING_DATASOURCE_URL} + SPRING_DATASOURCE_USERNAME: ${SPRING_DATASOURCE_USERNAME} + SPRING_DATASOURCE_PASSWORD: ${SPRING_DATASOURCE_PASSWORD} + networks: + - runtracker_proto_net + +volumes: + mysql-data: + +networks: + runtracker_proto_net: + driver: bridge diff --git a/runtracker-prototype/gradle/wrapper/gradle-wrapper.jar b/runtracker-prototype/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..9bbc975 Binary files /dev/null and b/runtracker-prototype/gradle/wrapper/gradle-wrapper.jar differ diff --git a/runtracker-prototype/gradle/wrapper/gradle-wrapper.properties b/runtracker-prototype/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..37f853b --- /dev/null +++ b/runtracker-prototype/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/runtracker-prototype/gradlew b/runtracker-prototype/gradlew new file mode 100755 index 0000000..faf9300 --- /dev/null +++ b/runtracker-prototype/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/runtracker-prototype/gradlew.bat b/runtracker-prototype/gradlew.bat new file mode 100644 index 0000000..9d21a21 --- /dev/null +++ b/runtracker-prototype/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/runtracker-prototype/settings.gradle b/runtracker-prototype/settings.gradle new file mode 100644 index 0000000..50085ce --- /dev/null +++ b/runtracker-prototype/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'runtracker-prototype' diff --git a/runtracker-prototype/src/main/java/com/runtracker_prototype/RuntrackerPrototypeApplication.java b/runtracker-prototype/src/main/java/com/runtracker_prototype/RuntrackerPrototypeApplication.java new file mode 100644 index 0000000..c37dc1c --- /dev/null +++ b/runtracker-prototype/src/main/java/com/runtracker_prototype/RuntrackerPrototypeApplication.java @@ -0,0 +1,13 @@ +package com.runtracker_prototype; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class RuntrackerPrototypeApplication { + + public static void main(String[] args) { + SpringApplication.run(RuntrackerPrototypeApplication.class, args); + } + +} diff --git a/runtracker-prototype/src/main/java/com/runtracker_prototype/code/CommonResponseCode.java b/runtracker-prototype/src/main/java/com/runtracker_prototype/code/CommonResponseCode.java new file mode 100644 index 0000000..21a4096 --- /dev/null +++ b/runtracker-prototype/src/main/java/com/runtracker_prototype/code/CommonResponseCode.java @@ -0,0 +1,27 @@ +package com.runtracker_prototype.code; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum CommonResponseCode implements ResponseCode { + + OK("C000", "success"), + BAD_REQUEST_ERROR("C001", "api bad request exception"), + REQUEST_BODY_MISSING_ERROR("C002", "required request body is missing"), + MISSING_REQUEST_PARAMETER_ERROR("C003", "missing servlet requestParameter exception"), + IO_ERROR("C004", "I/O exception"), + JSON_PARSE_ERROR("C005", "json parse exception"), + JACKSON_PROCESS_ERROR("C006", "com.fasterxml.jackson.core exception"), + FORBIDDEN_ERROR("C007", "forbidden exception"), + NOT_FOUND_ERROR("C008", "not found exception"), + NULL_POINT_ERROR("C009", "null point exception"), + NOT_VALID_ERROR("C010", "handle validation exception"), + NOT_VALID_HEADER_ERROR("C011", "not valid header exception"), + NOT_VALID_TIME_ERROR("C012", "Requests are not allowed before 19:00 on Mondays"), + INTERNAL_SERVER_ERROR("C999", "internal server error exception"); + + private final String statusCode; + private final String message; +} \ No newline at end of file diff --git a/runtracker-prototype/src/main/java/com/runtracker_prototype/code/DateConstants.java b/runtracker-prototype/src/main/java/com/runtracker_prototype/code/DateConstants.java new file mode 100644 index 0000000..ad5fda8 --- /dev/null +++ b/runtracker-prototype/src/main/java/com/runtracker_prototype/code/DateConstants.java @@ -0,0 +1,10 @@ +package com.runtracker_prototype.code; + +public class DateConstants { + private DateConstants() { + } + + public static final String DATE_PATTERN = "yyyy-MM-dd"; + public static final String DATETIME_PATTERN = "yyyy-MM-dd HH:mm:ss"; + public static final String TIME_ZONE = "Asia/Seoul"; +} diff --git a/runtracker-prototype/src/main/java/com/runtracker_prototype/code/ResponseCode.java b/runtracker-prototype/src/main/java/com/runtracker_prototype/code/ResponseCode.java new file mode 100644 index 0000000..891716b --- /dev/null +++ b/runtracker-prototype/src/main/java/com/runtracker_prototype/code/ResponseCode.java @@ -0,0 +1,6 @@ +package com.runtracker_prototype.code; + +public interface ResponseCode { + String getStatusCode(); + String getMessage(); +} \ No newline at end of file diff --git a/runtracker-prototype/src/main/java/com/runtracker_prototype/config/AwsS3Config.java b/runtracker-prototype/src/main/java/com/runtracker_prototype/config/AwsS3Config.java new file mode 100644 index 0000000..8f1a4fc --- /dev/null +++ b/runtracker-prototype/src/main/java/com/runtracker_prototype/config/AwsS3Config.java @@ -0,0 +1,31 @@ +package com.runtracker_prototype.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +@Configuration +public class AwsS3Config { +// +// @Value("${cloud.aws.credentials.access-key}") +// private String accessKey; +// +// @Value("${cloud.aws.credentials.secret-key}") +// private String secretKey; +// +// @Value("${cloud.aws.region.static}") +// private String region; +// +// @Bean +// public S3Presigner s3Presigner() { +// return S3Presigner.builder() +// .region(Region.of(region)) +// .credentialsProvider(StaticCredentialsProvider.create( +// AwsBasicCredentials.create(accessKey, secretKey))) +// .build(); +// } +} diff --git a/runtracker-prototype/src/main/java/com/runtracker_prototype/controller/CourseController.java b/runtracker-prototype/src/main/java/com/runtracker_prototype/controller/CourseController.java new file mode 100644 index 0000000..61c4d5c --- /dev/null +++ b/runtracker-prototype/src/main/java/com/runtracker_prototype/controller/CourseController.java @@ -0,0 +1,74 @@ +package com.runtracker_prototype.controller; + +import com.runtracker_prototype.domain.attr.Coordinate; +import com.runtracker_prototype.dto.CourseDTO; +import com.runtracker_prototype.dto.NearbyCourses; +import com.runtracker_prototype.exception.CustomException; +import com.runtracker_prototype.response.ApiResponse; +import com.runtracker_prototype.service.CourseService; +import com.runtracker_prototype.service.S3Service; +import com.runtracker_prototype.code.CommonResponseCode; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.ArrayList; +import java.util.List; + +@RestController +@RequestMapping("/proto/courses") +@RequiredArgsConstructor +@Tag(name = "Course API", description = "코스 관련 API") +public class CourseController { + + private final CourseService courseService; + private final S3Service s3Service; + + @Operation(summary = "반경 내 주변 코스들 검색", description = "현재 위치를 기준으로 반경 내 코스들의 시작점 좌표 리스트를 반환") + @GetMapping("/nearby") + public ApiResponse> getNearbyCourses( + @RequestParam("latitude") Double latitude, + @RequestParam("longitude") Double longitude, + @RequestParam(value = "radiusInMeters", defaultValue = "5000") Integer radiusInMeters) { + try { + NearbyCourses request = new NearbyCourses(); + request.setLatitude(latitude); + request.setLongitude(longitude); + request.setRadiusInMeters(radiusInMeters); + + List courses = courseService.getNearbyCourses(request); + return ApiResponse.ok(courses); + } catch (CustomException e) { + return ApiResponse.error(e.getErrorCode()); + } catch (Exception e) { + return ApiResponse.error(CommonResponseCode.INTERNAL_SERVER_ERROR); + } + } + + @Operation(summary = "자유 러닝 코스 생성", description = "자유 러닝을 기반으로 코스를 생성") + @PostMapping("/custom") + public ApiResponse addCustomCourse(@RequestBody CourseDTO courseDTO) { + try { + CourseDTO savedCourse = courseService.createCustomCourse(courseDTO); + return ApiResponse.ok(savedCourse); + } catch (CustomException e) { + return ApiResponse.error(e.getErrorCode()); + } catch (Exception e) { + return ApiResponse.error(CommonResponseCode.INTERNAL_SERVER_ERROR); + } + } + + @Operation(summary = "코스 상세 보기", description = "코스의 세부 사항을 반환") + @GetMapping("/{courseId}") + public ApiResponse getCourseById(@PathVariable("courseId") Long courseId) { + try { + CourseDTO courseDTO = courseService.getCourseById(courseId); + return ApiResponse.ok(courseDTO); + } catch (CustomException e) { + return ApiResponse.error(e.getErrorCode()); + } catch (Exception e) { + return ApiResponse.error(CommonResponseCode.INTERNAL_SERVER_ERROR); + } + } +} \ No newline at end of file diff --git a/runtracker-prototype/src/main/java/com/runtracker_prototype/controller/RecordController.java b/runtracker-prototype/src/main/java/com/runtracker_prototype/controller/RecordController.java new file mode 100644 index 0000000..eb196c0 --- /dev/null +++ b/runtracker-prototype/src/main/java/com/runtracker_prototype/controller/RecordController.java @@ -0,0 +1,61 @@ +package com.runtracker_prototype.controller; + +import com.runtracker_prototype.code.CommonResponseCode; +import com.runtracker_prototype.dto.RecordDTO; +import com.runtracker_prototype.exception.CustomException; +import com.runtracker_prototype.response.ApiResponse; +import com.runtracker_prototype.service.RecordService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/proto/records") +@RequiredArgsConstructor +@Tag(name = "Record API", description = "기록 관련 API") +public class RecordController { + + private final RecordService recordService; + + @Operation(summary = "러닝 기록 저장", description = "러닝이 끝난 후 기록을 저장 (시간은 자동으로 현재 시간으로 저장됨)") + @PostMapping + public ApiResponse saveRecord(@RequestBody RecordDTO recordDTO) { + try { + RecordDTO savedRecord = recordService.saveRecord(recordDTO); + return ApiResponse.ok(savedRecord); + } catch (CustomException e) { + return ApiResponse.error(e.getErrorCode()); + } catch (Exception e) { + return ApiResponse.error(CommonResponseCode.INTERNAL_SERVER_ERROR); + } + } + + @Operation(summary = "전체 기록 불러오기", description = "전체 기록을 시간순으로 반환") + @GetMapping + public ApiResponse> getRecords() { + try { + List records = recordService.getAllRecords(); + return ApiResponse.ok(records); + } catch (CustomException e) { + return ApiResponse.error(e.getErrorCode()); + } catch (Exception e) { + return ApiResponse.error(CommonResponseCode.INTERNAL_SERVER_ERROR); + } + } + + @Operation(summary = "코스별 기록 불러오기", description = "코스별 기록을 시간순으로 반환") + @GetMapping("/{courseId}") + public ApiResponse> getRecordsByCourseId(@PathVariable("courseId") Long courseId) { + try { + List records = recordService.getRecordsByCourseId(courseId); + return ApiResponse.ok(records); + } catch (CustomException e) { + return ApiResponse.error(e.getErrorCode()); + } catch (Exception e) { + return ApiResponse.error(CommonResponseCode.INTERNAL_SERVER_ERROR); + } + } +} diff --git a/runtracker-prototype/src/main/java/com/runtracker_prototype/domain/Course.java b/runtracker-prototype/src/main/java/com/runtracker_prototype/domain/Course.java new file mode 100644 index 0000000..cdea0b3 --- /dev/null +++ b/runtracker-prototype/src/main/java/com/runtracker_prototype/domain/Course.java @@ -0,0 +1,41 @@ +package com.runtracker_prototype.domain; + +import com.runtracker_prototype.domain.attr.Coordinate; +import com.runtracker_prototype.domain.converter.*; +import com.runtracker_prototype.domain.menu.Difficulty; +import jakarta.persistence.*; +import lombok.*; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Getter +@ToString +public class Course { + @Id + @GeneratedValue + @Column(name = "course_id") + private Long id; + + private String name; // 코스 이름 + + private String photoPath; // 코스 사진 경로(AWS S3) + + @Enumerated(EnumType.STRING) + private Difficulty difficulty; // 난이도 [초급자 : EASY, 중급자 : MEDIUM, 전문가 : HARD] + + private Boolean isCircle; // 왕복 유무 + + @Convert(converter = CoordinateConverter.class) + @Column(columnDefinition = "json") + private Coordinate startCoordinate; // 시작점 좌표 + + @Convert(converter = CoordinatesConverter.class) + @Column(columnDefinition = "json") + @Builder.Default + private List points = new ArrayList<>(); // 코스 좌표 리스트 +} diff --git a/runtracker-prototype/src/main/java/com/runtracker_prototype/domain/Record.java b/runtracker-prototype/src/main/java/com/runtracker_prototype/domain/Record.java new file mode 100644 index 0000000..7d2c314 --- /dev/null +++ b/runtracker-prototype/src/main/java/com/runtracker_prototype/domain/Record.java @@ -0,0 +1,30 @@ +package com.runtracker_prototype.domain; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Getter +@ToString +public class Record { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "record_id") + private Long id; + + private LocalDateTime time; // 걸린 시간 + + private Integer kcal; // 칼로리 + + private Integer walkCnt; // 걸음 수 + + /* 연관 관계 */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "course_id") + private Course course; +} diff --git a/runtracker-prototype/src/main/java/com/runtracker_prototype/domain/attr/Coordinate.java b/runtracker-prototype/src/main/java/com/runtracker_prototype/domain/attr/Coordinate.java new file mode 100644 index 0000000..f9af5df --- /dev/null +++ b/runtracker-prototype/src/main/java/com/runtracker_prototype/domain/attr/Coordinate.java @@ -0,0 +1,13 @@ +package com.runtracker_prototype.domain.attr; + +import lombok.*; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@ToString +public class Coordinate { + private Double lat; // 위도 + private Double lnt; // 경도 +} diff --git a/runtracker-prototype/src/main/java/com/runtracker_prototype/domain/converter/CoordinateConverter.java b/runtracker-prototype/src/main/java/com/runtracker_prototype/domain/converter/CoordinateConverter.java new file mode 100644 index 0000000..6e35675 --- /dev/null +++ b/runtracker-prototype/src/main/java/com/runtracker_prototype/domain/converter/CoordinateConverter.java @@ -0,0 +1,36 @@ +package com.runtracker_prototype.domain.converter; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.runtracker_prototype.domain.attr.Coordinate; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +import java.io.IOException; + +/** + * JSON <-> Coordinate Converter + */ + +@Converter +public class CoordinateConverter implements AttributeConverter { + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public String convertToDatabaseColumn(Coordinate attribute) { + try { + return objectMapper.writeValueAsString(attribute); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("Error converting Coordinate to JSON", e); + } + } + + @Override + public Coordinate convertToEntityAttribute(String dbData) { + try { + return objectMapper.readValue(dbData, Coordinate.class); + } catch (IOException e) { + throw new IllegalArgumentException("Error reading Coordinate from JSON", e); + } + } +} diff --git a/runtracker-prototype/src/main/java/com/runtracker_prototype/domain/converter/CoordinatesConverter.java b/runtracker-prototype/src/main/java/com/runtracker_prototype/domain/converter/CoordinatesConverter.java new file mode 100644 index 0000000..182f328 --- /dev/null +++ b/runtracker-prototype/src/main/java/com/runtracker_prototype/domain/converter/CoordinatesConverter.java @@ -0,0 +1,36 @@ +package com.runtracker_prototype.domain.converter; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.runtracker_prototype.domain.attr.Coordinate; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +import java.util.List; + +/** + * JSON <-> Coordinates Converter + */ + +@Converter +public class CoordinatesConverter implements AttributeConverter, String> { + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public String convertToDatabaseColumn(List attribute) { + try { + return objectMapper.writeValueAsString(attribute); + } catch (Exception e) { + throw new IllegalArgumentException("Error converting Coordinates to JSON", e); + } + } + + @Override + public List convertToEntityAttribute(String dbData) { + try { + return objectMapper.readValue(dbData, new TypeReference<>() {}); + } catch (Exception e) { + throw new IllegalArgumentException("Error reading Coordinates from JSON", e); + } + } +} diff --git a/runtracker-prototype/src/main/java/com/runtracker_prototype/domain/menu/Difficulty.java b/runtracker-prototype/src/main/java/com/runtracker_prototype/domain/menu/Difficulty.java new file mode 100644 index 0000000..6a41fca --- /dev/null +++ b/runtracker-prototype/src/main/java/com/runtracker_prototype/domain/menu/Difficulty.java @@ -0,0 +1,7 @@ +package com.runtracker_prototype.domain.menu; + +public enum Difficulty { + EASY, + MEDIUM, + HARD +} diff --git a/runtracker-prototype/src/main/java/com/runtracker_prototype/dto/CourseDTO.java b/runtracker-prototype/src/main/java/com/runtracker_prototype/dto/CourseDTO.java new file mode 100644 index 0000000..3ce8a78 --- /dev/null +++ b/runtracker-prototype/src/main/java/com/runtracker_prototype/dto/CourseDTO.java @@ -0,0 +1,21 @@ +package com.runtracker_prototype.dto; + +import com.runtracker_prototype.domain.attr.Coordinate; +import lombok.*; + +import java.util.ArrayList; +import java.util.List; + +@NoArgsConstructor +@AllArgsConstructor +@Getter +@Setter +@ToString +public class CourseDTO { + private Long id; // 코스 id + private String name; // 코스 이름 + private String photoPath; // 사진 경로 + private String difficulty; // 난이도 + private Coordinate startCoordinate; // 시작점 좌표 + private List points = new ArrayList<>(); // 코스 좌표 리스트 +} \ No newline at end of file diff --git a/runtracker-prototype/src/main/java/com/runtracker_prototype/dto/NearbyCourses.java b/runtracker-prototype/src/main/java/com/runtracker_prototype/dto/NearbyCourses.java new file mode 100644 index 0000000..f9c6738 --- /dev/null +++ b/runtracker-prototype/src/main/java/com/runtracker_prototype/dto/NearbyCourses.java @@ -0,0 +1,12 @@ +package com.runtracker_prototype.dto; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class NearbyCourses { + private Double latitude; // 현재 위치 위도 + private Double longitude; // 현재 위치 경도 + private Integer radiusInMeters; // 검색 반경 (미터) +} \ No newline at end of file diff --git a/runtracker-prototype/src/main/java/com/runtracker_prototype/dto/RecordDTO.java b/runtracker-prototype/src/main/java/com/runtracker_prototype/dto/RecordDTO.java new file mode 100644 index 0000000..7ce6827 --- /dev/null +++ b/runtracker-prototype/src/main/java/com/runtracker_prototype/dto/RecordDTO.java @@ -0,0 +1,26 @@ +package com.runtracker_prototype.dto; + +import com.runtracker_prototype.domain.Course; +import lombok.*; + +import java.time.LocalDateTime; + +@NoArgsConstructor +@AllArgsConstructor +@Getter +@Setter +@ToString +public class RecordDTO { + private Long id; // 기록 id + private Long courseId; // 코스 id + private LocalDateTime time; // 걸린 시간 (자동 설정) + private Integer kcal; // 칼로리 + private Integer walkCnt; // 걸음 수 + + // 기록 생성 시 사용할 생성자 + public RecordDTO(Long courseId, Integer kcal, Integer walkCnt) { + this.courseId = courseId; + this.kcal = kcal; + this.walkCnt = walkCnt; + } +} diff --git a/runtracker-prototype/src/main/java/com/runtracker_prototype/errorCode/CourseErrorCode.java b/runtracker-prototype/src/main/java/com/runtracker_prototype/errorCode/CourseErrorCode.java new file mode 100644 index 0000000..7acbd11 --- /dev/null +++ b/runtracker-prototype/src/main/java/com/runtracker_prototype/errorCode/CourseErrorCode.java @@ -0,0 +1,21 @@ +package com.runtracker_prototype.errorCode; + +import com.runtracker_prototype.code.ResponseCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum CourseErrorCode implements ResponseCode { + + INVALID_COURSE_DATA("CS001", "유효하지 않은 코스 데이터입니다"), + COURSE_CREATION_FAILED("CS002", "코스 생성에 실패했습니다"), + COURSE_NOT_FOUND("CS003", "존재하지 않는 코스입니다"), + INVALID_COORDINATE_DATA("CS004", "유효하지 않은 좌표 데이터입니다"), + NO_COURSES_FOUND("CS005", "주변에 러닝 코스가 없습니다"), + DIFFICULTY_REQUIRED("CS006", "코스 난이도는 필수 값입니다"), + INVALID_DIFFICULTY("CS007", "유효하지 않은 난이도입니다. EASY, MEDIUM, HARD 중 하나여야 합니다"); + + private final String statusCode; + private final String message; +} \ No newline at end of file diff --git a/runtracker-prototype/src/main/java/com/runtracker_prototype/errorCode/RecordErrorCode.java b/runtracker-prototype/src/main/java/com/runtracker_prototype/errorCode/RecordErrorCode.java new file mode 100644 index 0000000..f7cdaca --- /dev/null +++ b/runtracker-prototype/src/main/java/com/runtracker_prototype/errorCode/RecordErrorCode.java @@ -0,0 +1,21 @@ +package com.runtracker_prototype.errorCode; + +import com.runtracker_prototype.code.ResponseCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum RecordErrorCode implements ResponseCode { + + NO_RECORDS_FOUND("RC001", "기록이 존재하지 않습니다"), + RECORD_COURSE_ID_REQUIRED("RC002", "코스 ID는 필수 값입니다"), + RECORD_TIME_REQUIRED("RC003", "러닝 시간은 필수 값입니다"), + RECORD_KCAL_REQUIRED("RC004", "소모 칼로리는 필수 값입니다"), + RECORD_WALK_COUNT_REQUIRED("RC005", "걸음 수는 필수 값입니다"), + INVALID_DATETIME_FORMAT("RC006", "잘못된 날짜/시간 형식입니다"), + COURSE_NOT_FOUND_FOR_RECORD("RC007", "기록을 저장하려는 코스가 존재하지 않습니다"); + + private final String statusCode; + private final String message; +} diff --git a/runtracker-prototype/src/main/java/com/runtracker_prototype/exception/CourseCreationFailedException.java b/runtracker-prototype/src/main/java/com/runtracker_prototype/exception/CourseCreationFailedException.java new file mode 100644 index 0000000..c880bc3 --- /dev/null +++ b/runtracker-prototype/src/main/java/com/runtracker_prototype/exception/CourseCreationFailedException.java @@ -0,0 +1,9 @@ +package com.runtracker_prototype.exception; + +import com.runtracker_prototype.errorCode.CourseErrorCode; + +public class CourseCreationFailedException extends CustomException { + public CourseCreationFailedException() { + super(CourseErrorCode.COURSE_CREATION_FAILED); + } +} \ No newline at end of file diff --git a/runtracker-prototype/src/main/java/com/runtracker_prototype/exception/CustomException.java b/runtracker-prototype/src/main/java/com/runtracker_prototype/exception/CustomException.java new file mode 100644 index 0000000..2bc94d0 --- /dev/null +++ b/runtracker-prototype/src/main/java/com/runtracker_prototype/exception/CustomException.java @@ -0,0 +1,14 @@ +package com.runtracker_prototype.exception; + +import com.runtracker_prototype.code.ResponseCode; +import lombok.Getter; + +@Getter +public class CustomException extends RuntimeException { + private final ResponseCode errorCode; + + public CustomException(ResponseCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } +} \ No newline at end of file diff --git a/runtracker-prototype/src/main/java/com/runtracker_prototype/exception/DifficultyRequiredException.java b/runtracker-prototype/src/main/java/com/runtracker_prototype/exception/DifficultyRequiredException.java new file mode 100644 index 0000000..dedb038 --- /dev/null +++ b/runtracker-prototype/src/main/java/com/runtracker_prototype/exception/DifficultyRequiredException.java @@ -0,0 +1,9 @@ +package com.runtracker_prototype.exception; + +import com.runtracker_prototype.errorCode.CourseErrorCode; + +public class DifficultyRequiredException extends CustomException { + public DifficultyRequiredException() { + super(CourseErrorCode.DIFFICULTY_REQUIRED); + } +} \ No newline at end of file diff --git a/runtracker-prototype/src/main/java/com/runtracker_prototype/exception/InvalidDifficultyException.java b/runtracker-prototype/src/main/java/com/runtracker_prototype/exception/InvalidDifficultyException.java new file mode 100644 index 0000000..2b2f169 --- /dev/null +++ b/runtracker-prototype/src/main/java/com/runtracker_prototype/exception/InvalidDifficultyException.java @@ -0,0 +1,9 @@ +package com.runtracker_prototype.exception; + +import com.runtracker_prototype.errorCode.CourseErrorCode; + +public class InvalidDifficultyException extends CustomException { + public InvalidDifficultyException() { + super(CourseErrorCode.INVALID_DIFFICULTY); + } +} \ No newline at end of file diff --git a/runtracker-prototype/src/main/java/com/runtracker_prototype/exception/NoRecordsFoundException.java b/runtracker-prototype/src/main/java/com/runtracker_prototype/exception/NoRecordsFoundException.java new file mode 100644 index 0000000..195e4b2 --- /dev/null +++ b/runtracker-prototype/src/main/java/com/runtracker_prototype/exception/NoRecordsFoundException.java @@ -0,0 +1,9 @@ +package com.runtracker_prototype.exception; + +import com.runtracker_prototype.errorCode.RecordErrorCode; + +public class NoRecordsFoundException extends CustomException { + public NoRecordsFoundException() { + super(RecordErrorCode.NO_RECORDS_FOUND); + } +} \ No newline at end of file diff --git a/runtracker-prototype/src/main/java/com/runtracker_prototype/repository/CourseRepository.java b/runtracker-prototype/src/main/java/com/runtracker_prototype/repository/CourseRepository.java new file mode 100644 index 0000000..b4b83c5 --- /dev/null +++ b/runtracker-prototype/src/main/java/com/runtracker_prototype/repository/CourseRepository.java @@ -0,0 +1,9 @@ +package com.runtracker_prototype.repository; + +import com.runtracker_prototype.domain.Course; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface CourseRepository extends JpaRepository { +} diff --git a/runtracker-prototype/src/main/java/com/runtracker_prototype/repository/RecordRepository.java b/runtracker-prototype/src/main/java/com/runtracker_prototype/repository/RecordRepository.java new file mode 100644 index 0000000..e92d021 --- /dev/null +++ b/runtracker-prototype/src/main/java/com/runtracker_prototype/repository/RecordRepository.java @@ -0,0 +1,13 @@ +package com.runtracker_prototype.repository; + +import com.runtracker_prototype.domain.Record; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface RecordRepository extends JpaRepository { + List findAllByOrderByTimeDesc(); + List findAllByCourse_IdOrderByTimeDesc(Long courseId); +} diff --git a/runtracker-prototype/src/main/java/com/runtracker_prototype/response/ApiResponse.java b/runtracker-prototype/src/main/java/com/runtracker_prototype/response/ApiResponse.java new file mode 100644 index 0000000..a264747 --- /dev/null +++ b/runtracker-prototype/src/main/java/com/runtracker_prototype/response/ApiResponse.java @@ -0,0 +1,45 @@ +package com.runtracker_prototype.response; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import com.runtracker_prototype.code.ResponseCode; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ApiResponse { + private ResponseStatus status; + private T body; + + public static ApiResponse ok() { + return ApiResponse.ok(null); + } + + public static ApiResponse ok(T body) { + var apiResponse = new ApiResponse(); + apiResponse.status = ResponseStatus.ok(); + apiResponse.body = body; + return apiResponse; + } + + public static ApiResponse error() { + var apiResponse = new ApiResponse(); + apiResponse.status = ResponseStatus.error(); + return apiResponse; + } + + public static ApiResponse error(ResponseCode responseCode) { + var apiResponse = new ApiResponse(); + apiResponse.status = ResponseStatus.error(responseCode); + return apiResponse; + } + + public static ApiResponse error(ResponseCode responseCode, String description) { + var apiResponse = new ApiResponse(); + apiResponse.status = ResponseStatus.error(responseCode, description); + return apiResponse; + } +} \ No newline at end of file diff --git a/runtracker-prototype/src/main/java/com/runtracker_prototype/response/ResponseStatus.java b/runtracker-prototype/src/main/java/com/runtracker_prototype/response/ResponseStatus.java new file mode 100644 index 0000000..484d12f --- /dev/null +++ b/runtracker-prototype/src/main/java/com/runtracker_prototype/response/ResponseStatus.java @@ -0,0 +1,44 @@ +package com.runtracker_prototype.response; + +import com.runtracker_prototype.code.CommonResponseCode; +import com.runtracker_prototype.code.ResponseCode; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ResponseStatus { + private String statusCode; + private String message; + private String description; + + public static ResponseStatus ok() { + return ResponseStatus.builder() + .statusCode(CommonResponseCode.OK.getStatusCode()) + .message(CommonResponseCode.OK.getMessage()) + .build(); + } + + public static ResponseStatus error() { + return error(CommonResponseCode.INTERNAL_SERVER_ERROR); + } + + public static ResponseStatus error(ResponseCode responseCode) { + return ResponseStatus.builder() + .statusCode(responseCode.getStatusCode()) + .message(responseCode.getMessage()) + .build(); + } + + public static ResponseStatus error(ResponseCode responseCode, String description) { + return ResponseStatus.builder() + .statusCode(responseCode.getStatusCode()) + .message(responseCode.getMessage()) + .description(description) + .build(); + } +} \ No newline at end of file diff --git a/runtracker-prototype/src/main/java/com/runtracker_prototype/service/CourseService.java b/runtracker-prototype/src/main/java/com/runtracker_prototype/service/CourseService.java new file mode 100644 index 0000000..e406a70 --- /dev/null +++ b/runtracker-prototype/src/main/java/com/runtracker_prototype/service/CourseService.java @@ -0,0 +1,140 @@ +package com.runtracker_prototype.service; + +import com.runtracker_prototype.domain.Course; +import com.runtracker_prototype.domain.attr.Coordinate; +import com.runtracker_prototype.domain.menu.Difficulty; +import com.runtracker_prototype.dto.CourseDTO; +import com.runtracker_prototype.dto.NearbyCourses; +import com.runtracker_prototype.errorCode.CourseErrorCode; +import com.runtracker_prototype.exception.CourseCreationFailedException; +import com.runtracker_prototype.exception.CustomException; +import com.runtracker_prototype.exception.DifficultyRequiredException; +import com.runtracker_prototype.exception.InvalidDifficultyException; +import com.runtracker_prototype.repository.CourseRepository; +import com.runtracker_prototype.util.GeoUtils; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CourseService { + + private final CourseRepository courseRepository; + + @Transactional + public CourseDTO createCustomCourse(CourseDTO courseDTO) { + // 기본 유효성 검사 + if (courseDTO.getPoints() == null || courseDTO.getPoints().isEmpty()) { + throw new CustomException(CourseErrorCode.INVALID_COURSE_DATA); + } + + // 시작 좌표가 없으면 첫 번째 포인트를 시작 좌표로 설정 + if (courseDTO.getStartCoordinate() == null) { + courseDTO.setStartCoordinate(courseDTO.getPoints().get(0)); + } + + // difficulty 검증 + if (courseDTO.getDifficulty() == null) { + throw new DifficultyRequiredException(); + } + + Difficulty difficulty; + try { + difficulty = Difficulty.valueOf(courseDTO.getDifficulty()); + } catch (IllegalArgumentException e) { + throw new InvalidDifficultyException(); + } + + try { + // 코스 생성 + Course course = Course.builder() + .name(courseDTO.getName() != null ? courseDTO.getName() : "자유 러닝 코스") + .points(courseDTO.getPoints()) + .startCoordinate(courseDTO.getStartCoordinate()) + .difficulty(difficulty) + .isCircle(false) + .build(); + + Course savedCourse = courseRepository.save(course); + + // DTO 변환 + return new CourseDTO( + savedCourse.getId(), + savedCourse.getName(), + savedCourse.getPhotoPath(), + savedCourse.getDifficulty().name(), + savedCourse.getStartCoordinate(), + savedCourse.getPoints() + ); + } catch (Exception e) { + throw new CourseCreationFailedException(); + } + } + + public CourseDTO getCourseById(Long courseId) { + Course course = courseRepository.findById(courseId) + .orElseThrow(() -> new CustomException(CourseErrorCode.COURSE_NOT_FOUND)); + + return new CourseDTO( + course.getId(), + course.getName(), + course.getPhotoPath(), + course.getDifficulty().name(), + course.getStartCoordinate(), + course.getPoints() + ); + } + + public List getNearbyCourses(NearbyCourses request) { + // 현재 위치 좌표 생성 + Coordinate currentLocation = new Coordinate(request.getLatitude(), request.getLongitude()); + + // 모든 코스 조회 + List allCourses = courseRepository.findAll(); + + // 반경 내의 코스만 필터링하고 거리순으로 정렬 + List nearbyCourses = allCourses.stream() + .map(course -> { + // 코스까지의 거리 계산 + double distance = GeoUtils.calculateDistance(currentLocation, course.getStartCoordinate()); + return new CourseWithDistance(course, distance); + }) + .filter(courseWithDistance -> courseWithDistance.distance <= request.getRadiusInMeters()) + .sorted((c1, c2) -> Double.compare(c1.distance, c2.distance)) + .map(courseWithDistance -> { + Course course = courseWithDistance.course; + return new CourseDTO( + course.getId(), + course.getName(), + course.getPhotoPath(), + course.getDifficulty().name(), + course.getStartCoordinate(), + course.getPoints() + ); + }) + .collect(Collectors.toList()); + + // 주변 코스가 없으면 예외 발생 + if (nearbyCourses.isEmpty()) { + throw new CustomException(CourseErrorCode.NO_COURSES_FOUND); + } + + return nearbyCourses; + } + + // 거리 계산을 위한 내부 클래스 + private static class CourseWithDistance { + private final Course course; + private final double distance; + + public CourseWithDistance(Course course, double distance) { + this.course = course; + this.distance = distance; + } + } +} diff --git a/runtracker-prototype/src/main/java/com/runtracker_prototype/service/RecordService.java b/runtracker-prototype/src/main/java/com/runtracker_prototype/service/RecordService.java new file mode 100644 index 0000000..57f5155 --- /dev/null +++ b/runtracker-prototype/src/main/java/com/runtracker_prototype/service/RecordService.java @@ -0,0 +1,119 @@ +package com.runtracker_prototype.service; + +import com.runtracker_prototype.code.DateConstants; +import com.runtracker_prototype.domain.Course; +import com.runtracker_prototype.domain.Record; +import com.runtracker_prototype.dto.RecordDTO; +import com.runtracker_prototype.errorCode.CourseErrorCode; +import com.runtracker_prototype.errorCode.RecordErrorCode; +import com.runtracker_prototype.exception.CustomException; +import com.runtracker_prototype.repository.CourseRepository; +import com.runtracker_prototype.repository.RecordRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeParseException; +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class RecordService { + + private final RecordRepository recordRepository; + private final CourseRepository courseRepository; + + @Transactional + public RecordDTO saveRecord(RecordDTO recordDTO) { + validateRecord(recordDTO); + + Course course = courseRepository.findById(recordDTO.getCourseId()) + .orElseThrow(() -> new CustomException(RecordErrorCode.COURSE_NOT_FOUND_FOR_RECORD)); + + // 현재 시간을 Asia/Seoul 기준으로 설정 + LocalDateTime currentTime = LocalDateTime.now(ZoneId.of(DateConstants.TIME_ZONE)); + + Record record = Record.builder() + .course(course) + .time(currentTime) + .kcal(recordDTO.getKcal()) + .walkCnt(recordDTO.getWalkCnt()) + .build(); + + Record savedRecord = recordRepository.save(record); + + return new RecordDTO( + savedRecord.getId(), + savedRecord.getCourse().getId(), + savedRecord.getTime(), + savedRecord.getKcal(), + savedRecord.getWalkCnt() + ); + } + + private void validateRecord(RecordDTO recordDTO) { + if (recordDTO.getCourseId() == null) { + throw new CustomException(RecordErrorCode.RECORD_COURSE_ID_REQUIRED); + } + if (recordDTO.getKcal() == null) { + throw new CustomException(RecordErrorCode.RECORD_KCAL_REQUIRED); + } + if (recordDTO.getWalkCnt() == null) { + throw new CustomException(RecordErrorCode.RECORD_WALK_COUNT_REQUIRED); + } + } + + private LocalDateTime parseDateTime(LocalDateTime dateTime) { + try { + return dateTime.atZone(ZoneId.of("UTC")) + .withZoneSameInstant(ZoneId.of(DateConstants.TIME_ZONE)) + .toLocalDateTime(); + } catch (DateTimeParseException e) { + throw new CustomException(RecordErrorCode.INVALID_DATETIME_FORMAT); + } + } + + public List getAllRecords() { + List records = recordRepository.findAllByOrderByTimeDesc(); + + if (records.isEmpty()) { + throw new CustomException(RecordErrorCode.NO_RECORDS_FOUND); + } + + return records.stream() + .map(record -> new RecordDTO( + record.getId(), + record.getCourse().getId(), + record.getTime(), + record.getKcal(), + record.getWalkCnt() + )) + .collect(Collectors.toList()); + } + + public List getRecordsByCourseId(Long courseId) { + // 코스 존재 여부 확인 + courseRepository.findById(courseId) + .orElseThrow(() -> new CustomException(RecordErrorCode.COURSE_NOT_FOUND_FOR_RECORD)); + + List records = recordRepository.findAllByCourse_IdOrderByTimeDesc(courseId); + + if (records.isEmpty()) { + throw new CustomException(RecordErrorCode.NO_RECORDS_FOUND); + } + + return records.stream() + .map(record -> new RecordDTO( + record.getId(), + record.getCourse().getId(), + record.getTime(), + record.getKcal(), + record.getWalkCnt() + )) + .collect(Collectors.toList()); + } +} diff --git a/runtracker-prototype/src/main/java/com/runtracker_prototype/service/S3Service.java b/runtracker-prototype/src/main/java/com/runtracker_prototype/service/S3Service.java new file mode 100644 index 0000000..25fe323 --- /dev/null +++ b/runtracker-prototype/src/main/java/com/runtracker_prototype/service/S3Service.java @@ -0,0 +1,46 @@ +package com.runtracker_prototype.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.*; + +import java.net.URL; +import java.time.Duration; + + +@Service +@RequiredArgsConstructor +public class S3Service { +// private final S3Presigner s3Presigner; +// +// @Value("${cloud.aws.s3.bucket-name}") +// private String bucketName; +// +// /** +// * PreSigned URL을 생성 +// * @param objectKey 업로드할 객체(파일명) +// * @return PreSigned URL +// */ +// public URL generatePresignedUrl(String objectKey) { +// String contentType = "image/jpeg"; +// if (objectKey.endsWith(".png")) { +// contentType = "image/png"; +// } +// +// PutObjectRequest putObjectRequest = PutObjectRequest.builder() +// .bucket(bucketName) +// .key(objectKey) +// .contentType(contentType) +// .build(); +// +// PresignedPutObjectRequest preSignedRequest = s3Presigner.presignPutObject(p -> p +// .signatureDuration(Duration.ofMinutes(10)) // URL 유효 시간 +// .putObjectRequest(putObjectRequest) +// ); +// +// return preSignedRequest.url(); +// } +} diff --git a/runtracker-prototype/src/main/java/com/runtracker_prototype/util/GeoUtils.java b/runtracker-prototype/src/main/java/com/runtracker_prototype/util/GeoUtils.java new file mode 100644 index 0000000..3abd043 --- /dev/null +++ b/runtracker-prototype/src/main/java/com/runtracker_prototype/util/GeoUtils.java @@ -0,0 +1,32 @@ +package com.runtracker_prototype.util; + +import com.runtracker_prototype.domain.attr.Coordinate; + +public class GeoUtils { + private static final double EARTH_RADIUS_KM = 6371.0; // 지구 반지름 (km) + + /** + * Haversine 공식을 사용하여 두 지점 사이의 거리를 계산합니다. + * @param point1 첫 번째 좌표 + * @param point2 두 번째 좌표 + * @return 두 지점 사이의 거리 (미터) + */ + public static double calculateDistance(Coordinate point1, Coordinate point2) { + double lat1 = Math.toRadians(point1.getLat()); + double lon1 = Math.toRadians(point1.getLnt()); + double lat2 = Math.toRadians(point2.getLat()); + double lon2 = Math.toRadians(point2.getLnt()); + + double dLat = lat2 - lat1; + double dLon = lon2 - lon1; + + double a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(lat1) * Math.cos(lat2) * + Math.sin(dLon / 2) * Math.sin(dLon / 2); + + double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + + // 킬로미터를 미터로 변환 + return EARTH_RADIUS_KM * c * 1000; + } +} \ No newline at end of file diff --git a/runtracker-prototype/src/main/resources/application.yml b/runtracker-prototype/src/main/resources/application.yml new file mode 100644 index 0000000..cefd538 --- /dev/null +++ b/runtracker-prototype/src/main/resources/application.yml @@ -0,0 +1,33 @@ +spring: + # local 사용 시 + profiles: + active: local + + datasource: + url: jdbc:mysql://localhost:3306/runtracker + username: root + password: rootroot + driver-class-name: com.mysql.cj.jdbc.Driver + + jpa: + hibernate: + ddl-auto: update + show-sql: true + properties: + hibernate: + dialect: org.hibernate.dialect.MySQL8Dialect + +server: + port: 8080 + +cloud: + aws: + credentials: + access-key: ${ACCESS_KEY} + secret-key: ${SECRET_KEY} + region: + static: ${REGION} + +logging: + level: + root: info \ No newline at end of file diff --git a/runtracker-prototype/src/test/java/com/runtracker_prototype/RuntrackerPrototypeApplicationTests.java b/runtracker-prototype/src/test/java/com/runtracker_prototype/RuntrackerPrototypeApplicationTests.java new file mode 100644 index 0000000..c2997ef --- /dev/null +++ b/runtracker-prototype/src/test/java/com/runtracker_prototype/RuntrackerPrototypeApplicationTests.java @@ -0,0 +1,13 @@ +package com.runtracker_prototype; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class RuntrackerPrototypeApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/runtracker/.gitattributes b/runtracker/.gitattributes new file mode 100644 index 0000000..8af972c --- /dev/null +++ b/runtracker/.gitattributes @@ -0,0 +1,3 @@ +/gradlew text eol=lf +*.bat text eol=crlf +*.jar binary diff --git a/runtracker/.gitignore b/runtracker/.gitignore new file mode 100644 index 0000000..c76e710 --- /dev/null +++ b/runtracker/.gitignore @@ -0,0 +1,52 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +### Setting ### +.env + +### Auth ### +certbot/conf +certbot/www + +### logs ### +*.log +logs/ + +### Firebase ### +**/firebase/ +runtracker-a30bb-firebase-adminsdk-fbsvc-9479026564.json \ No newline at end of file diff --git a/runtracker/Dockerfile b/runtracker/Dockerfile new file mode 100644 index 0000000..5b749c9 --- /dev/null +++ b/runtracker/Dockerfile @@ -0,0 +1,15 @@ +# runtracker/Dockerfile +FROM openjdk:17-alpine +LABEL authors="Mangjun" + +# 작업 디렉토리 설정 +WORKDIR /app + +# JAR 파일 경로 ARG로 받기 +ARG JAR_FILE=build/libs/*.jar + +# JAR 파일을 WORKDIR(/app)로 복사 +COPY ${JAR_FILE} app.jar + +# WORKDIR(/app) 내의 app.jar 실행 +ENTRYPOINT ["java","-jar","app.jar"] \ No newline at end of file diff --git a/runtracker/build.gradle b/runtracker/build.gradle new file mode 100644 index 0000000..fcbae68 --- /dev/null +++ b/runtracker/build.gradle @@ -0,0 +1,123 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.4.4' + id 'io.spring.dependency-management' version '1.1.7' + id 'org.asciidoctor.jvm.convert' version '3.3.2' + id 'com.epages.restdocs-api-spec' version '0.18.2' +} + +group = 'com.runtracker' +version = '0.0.1-SNAPSHOT' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-websocket' + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + implementation 'me.paulschwarz:spring-dotenv:2.3.0' + + // FCM + implementation 'com.google.firebase:firebase-admin:9.2.0' + + // QueryDSL + implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' + annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta" + annotationProcessor "jakarta.annotation:jakarta.annotation-api" + annotationProcessor "jakarta.persistence:jakarta.persistence-api" + + // logbook + implementation 'org.zalando:logbook-spring-boot-starter:3.12.0' + implementation 'org.zalando:logbook-json:3.12.0' + + // Logstash + implementation 'net.logstash.logback:logstash-logback-encoder:7.4' + + // Image Processing + implementation 'com.sksamuel.scrimage:scrimage-core:4.1.1' + implementation 'com.sksamuel.scrimage:scrimage-webp:4.1.1' + + // AWS S3 + implementation 'software.amazon.awssdk:s3:2.25.0' + + compileOnly 'org.projectlombok:lombok' + developmentOnly 'org.springframework.boot:spring-boot-devtools' + runtimeOnly 'com.mysql:mysql-connector-j' + annotationProcessor 'org.projectlombok:lombok' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' + testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' + testImplementation 'com.epages:restdocs-api-spec-mockmvc:0.18.2' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} + +tasks.named('test') { + useJUnitPlatform() +} + +ext { + snippetsDir = file('build/generated-snippets') +} + +test { + outputs.dir snippetsDir +} + +asciidoctor { + inputs.dir snippetsDir + dependsOn test +} + +openapi3 { + title = 'RunTracker API Document' + description = 'RunTracker Application API Document' + version = '0.0.1' + format = 'yaml' +} + +tasks.register('copyOasToSwagger', Copy) { + delete 'src/main/resources/static/swagger-ui/openapi3.yaml' + from "${layout.buildDirectory.get()}/api-spec/openapi3.yaml" + into "${layout.buildDirectory.get()}/resources/main/static/swagger-ui/." + dependsOn 'openapi3' +} + +tasks.resolveMainClassName { + dependsOn 'copyOasToSwagger' +} + +def querydslDir = "${layout.buildDirectory.get()}/generated/querydsl" + +sourceSets { + main.java.srcDirs += [querydslDir] +} + +bootJar { + enabled = true +} + +jar { + enabled = false +} \ No newline at end of file diff --git a/runtracker/docker-compose.yml b/runtracker/docker-compose.yml new file mode 100644 index 0000000..ab689c8 --- /dev/null +++ b/runtracker/docker-compose.yml @@ -0,0 +1,67 @@ +version: '3.8' + +services: + # 1. 스프링 부트 애플리케이션 서비스 + spring: + build: + context: . # Spring Boot 프로젝트 경로 + container_name: spring + ports: + - "8080:8080" + - "80:8080" + env_file: + - .env # DB, Redis 접속 정보 등을 담을 파일 + volumes: + - ./firebase:/app/secrets + depends_on: + mysql: + condition: service_healthy + redis: + condition: service_healthy + + # 2. MySQL 데이터베이스 서비스 + mysql: + image: mysql:8.0 + container_name: mysql + ports: + - "3306:3306" + volumes: + - mysql-data:/var/lib/mysql + env_file: + - .env + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + interval: 10s + timeout: 5s + retries: 5 + command: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci + + # 3. Redis 캐시/메시징 서비스 + redis: + image: redis:alpine + container_name: redis + ports: + - "6379:6379" + volumes: + - redis-data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + + # 4. FastAPI AI 서버 서비스 (✨ 새로 추가) + ai-server: + build: + context: ./RunTracker-AI-Server + dockerfile: Dockerfile + container_name: ai-server + ports: + - "8000:8000" + +# 데이터 영속성을 위한 볼륨 정의 +volumes: + mysql-data: + redis-data: diff --git a/runtracker/gradle/wrapper/gradle-wrapper.jar b/runtracker/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..9bbc975 Binary files /dev/null and b/runtracker/gradle/wrapper/gradle-wrapper.jar differ diff --git a/runtracker/gradle/wrapper/gradle-wrapper.properties b/runtracker/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..37f853b --- /dev/null +++ b/runtracker/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/runtracker/gradlew b/runtracker/gradlew new file mode 100755 index 0000000..faf9300 --- /dev/null +++ b/runtracker/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/runtracker/gradlew.bat b/runtracker/gradlew.bat new file mode 100644 index 0000000..9d21a21 --- /dev/null +++ b/runtracker/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/runtracker/settings.gradle b/runtracker/settings.gradle new file mode 100644 index 0000000..a79022a --- /dev/null +++ b/runtracker/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'runtracker' diff --git a/runtracker/src/main/java/com/runtracker/RuntrackerApplication.java b/runtracker/src/main/java/com/runtracker/RuntrackerApplication.java new file mode 100644 index 0000000..f98bbc5 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/RuntrackerApplication.java @@ -0,0 +1,23 @@ +package com.runtracker; + +import jakarta.annotation.PostConstruct; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +import java.util.TimeZone; + +@SpringBootApplication +@EnableJpaAuditing +public class RuntrackerApplication { + + @PostConstruct + public void strted() { + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")); + } + + public static void main(String[] args) { + SpringApplication.run(RuntrackerApplication.class, args); + } + +} diff --git a/runtracker/src/main/java/com/runtracker/domain/auth/enums/AuthErrorCode.java b/runtracker/src/main/java/com/runtracker/domain/auth/enums/AuthErrorCode.java new file mode 100644 index 0000000..e8c61f1 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/auth/enums/AuthErrorCode.java @@ -0,0 +1,18 @@ +package com.runtracker.domain.auth.enums; + +import com.runtracker.global.code.ResponseCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum AuthErrorCode implements ResponseCode { + + INVALID_REFRESH_TOKEN("AT001", "Invalid refresh token"), + TOKEN_REFRESH_FAILED("AT002", "Token refresh failed"), + MEMBER_NOT_FOUND("AT003", "Member not found"); + + private final String statusCode; + + private final String message; +} diff --git a/runtracker/src/main/java/com/runtracker/domain/auth/eventHandler/OAuth2EventHandler.java b/runtracker/src/main/java/com/runtracker/domain/auth/eventHandler/OAuth2EventHandler.java new file mode 100644 index 0000000..717ba4c --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/auth/eventHandler/OAuth2EventHandler.java @@ -0,0 +1,58 @@ +package com.runtracker.domain.auth.eventHandler; + +import com.runtracker.domain.auth.eventHandler.dto.KakaoOAuth2UserInfo; +import com.runtracker.domain.member.entity.Member; +import com.runtracker.domain.member.service.MemberService; +import com.runtracker.global.jwt.JwtUtil; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; + +import java.io.IOException; + +@Slf4j +@Component +@RequiredArgsConstructor +public class OAuth2EventHandler extends SimpleUrlAuthenticationSuccessHandler { + + private final JwtUtil jwtUtil; + private final MemberService memberService; + + @Value("${app.oauth2.redirect-uri}") + private String redirectUri; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, + HttpServletResponse response, + Authentication authentication) throws IOException { + OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal(); + + log.info("OAuth2 Success - attributes: {}", oAuth2User.getAttributes()); + + KakaoOAuth2UserInfo userInfo = new KakaoOAuth2UserInfo(oAuth2User.getAttributes()); + + Member member = memberService.createOrUpdateMember( + "kakao", + userInfo.getSocialId(), + userInfo.getProfileImage(), + userInfo.getNickname() + ); + + String accessToken = jwtUtil.generateAccessToken(member.getId(), member.getSocialId()); + String refreshToken = jwtUtil.generateRefreshToken(member.getId()); + + String targetUrl = UriComponentsBuilder.fromUriString(redirectUri) + .queryParam("accessToken", accessToken) + .queryParam("refreshToken", refreshToken) + .build().toUriString(); + + getRedirectStrategy().sendRedirect(request, response, targetUrl); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/auth/eventHandler/dto/KakaoOAuth2UserInfo.java b/runtracker/src/main/java/com/runtracker/domain/auth/eventHandler/dto/KakaoOAuth2UserInfo.java new file mode 100644 index 0000000..1d9daa9 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/auth/eventHandler/dto/KakaoOAuth2UserInfo.java @@ -0,0 +1,54 @@ +package com.runtracker.domain.auth.eventHandler.dto; + +import java.util.Map; + +public class KakaoOAuth2UserInfo { + + private final Map attributes; + + public KakaoOAuth2UserInfo(Map attributes) { + this.attributes = attributes; + } + + public String getSocialId() { + Object id = attributes.get("id"); + return id != null ? id.toString() : null; + } + + public String getNickname() { + Map properties = getProperties(); + if (properties != null) { + Object nickname = properties.get("nickname"); + return nickname != null ? nickname.toString() : null; + } + return null; + } + + public String getProfileImage() { + Map properties = getProperties(); + if (properties != null) { + Object profileImage = properties.get("profile_image"); + return profileImage != null ? profileImage.toString() : null; + } + return null; + } + + public String getEmail() { + Map kakaoAccount = getKakaoAccount(); + if (kakaoAccount != null) { + Object email = kakaoAccount.get("email"); + return email != null ? email.toString() : null; + } + return null; + } + + @SuppressWarnings("unchecked") + private Map getProperties() { + return (Map) attributes.get("properties"); + } + + @SuppressWarnings("unchecked") + private Map getKakaoAccount() { + return (Map) attributes.get("kakao_account"); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/auth/exception/InvalidRefreshTokenException.java b/runtracker/src/main/java/com/runtracker/domain/auth/exception/InvalidRefreshTokenException.java new file mode 100644 index 0000000..b64af8b --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/auth/exception/InvalidRefreshTokenException.java @@ -0,0 +1,15 @@ +package com.runtracker.domain.auth.exception; + +import com.runtracker.domain.auth.enums.AuthErrorCode; +import com.runtracker.global.exception.CustomException; + +public class InvalidRefreshTokenException extends CustomException { + + public InvalidRefreshTokenException() { + super(AuthErrorCode.INVALID_REFRESH_TOKEN); + } + + public InvalidRefreshTokenException(String message) { + super(AuthErrorCode.INVALID_REFRESH_TOKEN, message); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/auth/exception/MemberNotFoundException.java b/runtracker/src/main/java/com/runtracker/domain/auth/exception/MemberNotFoundException.java new file mode 100644 index 0000000..dca7d1d --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/auth/exception/MemberNotFoundException.java @@ -0,0 +1,15 @@ +package com.runtracker.domain.auth.exception; + +import com.runtracker.domain.auth.enums.AuthErrorCode; +import com.runtracker.global.exception.CustomException; + +public class MemberNotFoundException extends CustomException { + + public MemberNotFoundException() { + super(AuthErrorCode.MEMBER_NOT_FOUND); + } + + public MemberNotFoundException(String message) { + super(AuthErrorCode.MEMBER_NOT_FOUND, message); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/auth/exception/TokenRefreshFailedException.java b/runtracker/src/main/java/com/runtracker/domain/auth/exception/TokenRefreshFailedException.java new file mode 100644 index 0000000..41185ad --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/auth/exception/TokenRefreshFailedException.java @@ -0,0 +1,15 @@ +package com.runtracker.domain.auth.exception; + +import com.runtracker.domain.auth.enums.AuthErrorCode; +import com.runtracker.global.exception.CustomException; + +public class TokenRefreshFailedException extends CustomException { + + public TokenRefreshFailedException() { + super(AuthErrorCode.TOKEN_REFRESH_FAILED); + } + + public TokenRefreshFailedException(String message) { + super(AuthErrorCode.TOKEN_REFRESH_FAILED, message); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/auth/service/OAuth2UserService.java b/runtracker/src/main/java/com/runtracker/domain/auth/service/OAuth2UserService.java new file mode 100644 index 0000000..501dbc2 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/auth/service/OAuth2UserService.java @@ -0,0 +1,24 @@ +package com.runtracker.domain.auth.service; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +public class OAuth2UserService extends DefaultOAuth2UserService { + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + OAuth2User oAuth2User = super.loadUser(userRequest); + + String registrationId = userRequest.getClientRegistration().getRegistrationId(); + log.info("OAuth2 Provider: {}", registrationId); + log.info("OAuth2User attributes: {}", oAuth2User.getAttributes()); + + return oAuth2User; + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/community/controller/CommunityController.java b/runtracker/src/main/java/com/runtracker/domain/community/controller/CommunityController.java new file mode 100644 index 0000000..5acb7f7 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/community/controller/CommunityController.java @@ -0,0 +1,124 @@ +package com.runtracker.domain.community.controller; + +import com.runtracker.domain.community.dto.CommentDTO; +import com.runtracker.domain.community.dto.PostDTO; +import com.runtracker.domain.community.dto.PostDetailDTO; +import com.runtracker.domain.community.dto.PostListDTO; + +import java.util.List; + +import com.runtracker.domain.community.service.CommunityService; +import com.runtracker.global.response.ApiResponse; +import com.runtracker.global.security.UserDetailsImpl; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/community") +@RequiredArgsConstructor +public class CommunityController { + + private final CommunityService communityService; + + @PostMapping("/posts") + public ApiResponse createPost( + @RequestBody PostDTO postDTO, + @AuthenticationPrincipal UserDetailsImpl userDetails) { + + communityService.createPost(postDTO, userDetails); + return ApiResponse.ok(); + } + + @PatchMapping("/posts/{postId}") + public ApiResponse updatePost( + @PathVariable Long postId, + @RequestBody PostDTO postDTO, + @AuthenticationPrincipal UserDetailsImpl userDetails) { + + communityService.updatePost(postId, postDTO, userDetails); + return ApiResponse.ok(); + } + + @DeleteMapping("/posts/{postId}") + public ApiResponse deletePost( + @PathVariable Long postId, + @AuthenticationPrincipal UserDetailsImpl userDetails) { + + communityService.deletePost(postId, userDetails); + return ApiResponse.ok(); + } + + @PostMapping("/posts/{postId}/like") + public ApiResponse likePost( + @PathVariable Long postId, + @AuthenticationPrincipal UserDetailsImpl userDetails) { + + communityService.likePost(postId, userDetails); + return ApiResponse.ok(); + } + + @PostMapping("/posts/{postId}/unlike") + public ApiResponse unlikePost( + @PathVariable Long postId, + @AuthenticationPrincipal UserDetailsImpl userDetails) { + + communityService.unlikePost(postId, userDetails); + return ApiResponse.ok(); + } + + @PostMapping("/posts/{postId}/comments") + public ApiResponse createComment( + @PathVariable Long postId, + @RequestBody CommentDTO commentDTO, + @AuthenticationPrincipal UserDetailsImpl userDetails) { + + communityService.createComment(postId, commentDTO, userDetails); + return ApiResponse.ok(); + } + + @PatchMapping("/comments/{commentId}") + public ApiResponse updateComment( + @PathVariable Long commentId, + @RequestBody CommentDTO commentDTO, + @AuthenticationPrincipal UserDetailsImpl userDetails) { + + communityService.updateComment(commentId, commentDTO, userDetails); + return ApiResponse.ok(); + } + + @DeleteMapping("/comments/{commentId}") + public ApiResponse deleteComment( + @PathVariable Long commentId, + @AuthenticationPrincipal UserDetailsImpl userDetails) { + + communityService.deleteComment(commentId, userDetails); + return ApiResponse.ok(); + } + + @GetMapping("/posts") + public ApiResponse> getPostList( + @AuthenticationPrincipal UserDetailsImpl userDetails) { + + List posts = communityService.getPostList(userDetails); + return ApiResponse.ok(posts); + } + + @GetMapping("/posts/{postId}") + public ApiResponse getPostDetail( + @PathVariable Long postId, + @AuthenticationPrincipal UserDetailsImpl userDetails) { + + PostDetailDTO post = communityService.getPostDetail(postId, userDetails); + return ApiResponse.ok(post); + } + + @GetMapping("/posts/search") + public ApiResponse> searchPosts( + @RequestParam String keyword, + @AuthenticationPrincipal UserDetailsImpl userDetails) { + + List posts = communityService.searchPosts(keyword, userDetails); + return ApiResponse.ok(posts); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/community/dto/CommentDTO.java b/runtracker/src/main/java/com/runtracker/domain/community/dto/CommentDTO.java new file mode 100644 index 0000000..abdc70c --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/community/dto/CommentDTO.java @@ -0,0 +1,10 @@ +package com.runtracker.domain.community.dto; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class CommentDTO { + private String comment; +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/community/dto/CommentInfoDTO.java b/runtracker/src/main/java/com/runtracker/domain/community/dto/CommentInfoDTO.java new file mode 100644 index 0000000..94c9e8a --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/community/dto/CommentInfoDTO.java @@ -0,0 +1,21 @@ +package com.runtracker.domain.community.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.runtracker.global.code.DateConstants; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +public class CommentInfoDTO { + private Long commentId; + private String comment; + private Long memberId; + private String memberName; + @JsonFormat(pattern = DateConstants.DATETIME_PATTERN) + private LocalDateTime createdAt; + @JsonFormat(pattern = DateConstants.DATETIME_PATTERN) + private LocalDateTime updatedAt; +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/community/dto/PostDTO.java b/runtracker/src/main/java/com/runtracker/domain/community/dto/PostDTO.java new file mode 100644 index 0000000..9106ce2 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/community/dto/PostDTO.java @@ -0,0 +1,19 @@ +package com.runtracker.domain.community.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PostDTO { + private String title; + private String content; + private List photos; + private RunningMetaDTO meta; +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/community/dto/PostDetailDTO.java b/runtracker/src/main/java/com/runtracker/domain/community/dto/PostDetailDTO.java new file mode 100644 index 0000000..d77332c --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/community/dto/PostDetailDTO.java @@ -0,0 +1,28 @@ +package com.runtracker.domain.community.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.runtracker.global.code.DateConstants; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@Builder +public class PostDetailDTO { + private Long postId; + private String title; + private String content; + private List photos; + private RunningMetaDTO meta; + private Long memberId; + private String memberName; + private Long likeCount; + private Boolean isLiked; + @JsonFormat(pattern = DateConstants.DATETIME_PATTERN) + private LocalDateTime createdAt; + @JsonFormat(pattern = DateConstants.DATETIME_PATTERN) + private LocalDateTime updatedAt; + private List comments; +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/community/dto/PostListDTO.java b/runtracker/src/main/java/com/runtracker/domain/community/dto/PostListDTO.java new file mode 100644 index 0000000..444b135 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/community/dto/PostListDTO.java @@ -0,0 +1,28 @@ +package com.runtracker.domain.community.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.runtracker.global.code.DateConstants; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@Builder +public class PostListDTO { + private Long postId; + private String title; + private String content; + private List photos; + private RunningMetaDTO meta; + private Long memberId; + private String memberName; + private Long likeCount; + private Long commentCount; + private Boolean isLiked; + @JsonFormat(pattern = DateConstants.DATETIME_PATTERN) + private LocalDateTime createdAt; + @JsonFormat(pattern = DateConstants.DATETIME_PATTERN) + private LocalDateTime updatedAt; +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/community/dto/RunningMetaDTO.java b/runtracker/src/main/java/com/runtracker/domain/community/dto/RunningMetaDTO.java new file mode 100644 index 0000000..47b019e --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/community/dto/RunningMetaDTO.java @@ -0,0 +1,17 @@ +package com.runtracker.domain.community.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RunningMetaDTO { + private Double distance; + private Integer time; + private Double avgPace; + private Double avgSpeed; +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/community/entity/Post.java b/runtracker/src/main/java/com/runtracker/domain/community/entity/Post.java new file mode 100644 index 0000000..161a9d5 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/community/entity/Post.java @@ -0,0 +1,68 @@ +package com.runtracker.domain.community.entity; + +import com.runtracker.global.converter.StringListConverter; +import com.runtracker.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + +import java.util.List; + +@Entity +@Table(name = "post") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class Post extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "member_id", nullable = false) + private Long memberId; + + @Column(name = "crew_id", nullable = false) + private Long crewId; + + @Column(name = "title", length = 100) + private String title; + + @Column(name = "content", columnDefinition = "TEXT") + private String content; + + @Column(name = "photos", columnDefinition = "JSON") + @Convert(converter = StringListConverter.class) + private List photos; + + @Column(name = "distance") + private Double distance; + + @Column(name = "time") + private Integer time; + + @Column(name = "avg_pace") + private Double avgPace; + + @Column(name = "avg_speed") + private Double avgSpeed; + + public void updateTitle(String title) { + this.title = title; + } + + public void updateContent(String content) { + this.content = content; + } + + public void updatePhotos(List photos) { + this.photos = photos; + } + + public void updateRunningMeta(Double distance, Integer time, Double avgPace, Double avgSpeed) { + this.distance = distance; + this.time = time; + this.avgPace = avgPace; + this.avgSpeed = avgSpeed; + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/community/entity/PostComment.java b/runtracker/src/main/java/com/runtracker/domain/community/entity/PostComment.java new file mode 100644 index 0000000..ea9aaf5 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/community/entity/PostComment.java @@ -0,0 +1,32 @@ +package com.runtracker.domain.community.entity; + +import com.runtracker.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "post_comments") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class PostComment extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "comment_id") + private Long commentId; + + @Column(name = "post_id", nullable = false) + private Long postId; + + @Column(name = "member_id", nullable = false) + private Long memberId; + + @Column(name = "comment", nullable = false, columnDefinition = "TEXT") + private String comment; + + public void updateComment(String comment) { + this.comment = comment; + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/community/entity/PostLike.java b/runtracker/src/main/java/com/runtracker/domain/community/entity/PostLike.java new file mode 100644 index 0000000..6d126b2 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/community/entity/PostLike.java @@ -0,0 +1,33 @@ +package com.runtracker.domain.community.entity; + +import com.runtracker.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + +import java.io.Serializable; + +@Entity +@Table(name = "post_likes") +@IdClass(PostLike.PostLikeId.class) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class PostLike extends BaseEntity { + + @Id + @Column(name = "member_id", nullable = false) + private Long memberId; + + @Id + @Column(name = "post_id", nullable = false) + private Long postId; + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class PostLikeId implements Serializable { + private Long memberId; + private Long postId; + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/community/enums/CommunityErrorCode.java b/runtracker/src/main/java/com/runtracker/domain/community/enums/CommunityErrorCode.java new file mode 100644 index 0000000..8704d24 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/community/enums/CommunityErrorCode.java @@ -0,0 +1,25 @@ +package com.runtracker.domain.community.enums; + +import com.runtracker.global.code.ResponseCode; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum CommunityErrorCode implements ResponseCode { + + POST_NOT_FOUND("CM001", "Post not found"), + POST_CREATION_FAILED("CM002", "Failed to create post"), + UNAUTHORIZED_POST_ACCESS("CM003", "Unauthorized access to post"), + ALREADY_LIKED_POST("CM004", "Already liked this post"), + NOT_LIKED_POST("CM005", "Not liked this post"), + COMMENT_NOT_FOUND("CM006", "PostComment not found"), + COMMENT_CREATION_FAILED("CM007", "Failed to create comment"), + UNAUTHORIZED_COMMENT_ACCESS("CM008", "Unauthorized access to comment"), + NO_POSTS_FOUND("CM009", "No posts found in this crew"), + NO_SEARCH_RESULTS("CM010", "No posts found for the given keyword"), + PHOTOS_REQUIRED("CM011", "Photos are required for post creation"); + + private final String statusCode; + private final String message; +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/community/event/PostCommentEvent.java b/runtracker/src/main/java/com/runtracker/domain/community/event/PostCommentEvent.java new file mode 100644 index 0000000..f2f9dba --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/community/event/PostCommentEvent.java @@ -0,0 +1,4 @@ +package com.runtracker.domain.community.event; + +public record PostCommentEvent(Long commenterMemberId, Long postAuthorMemberId, Long postId) { +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/community/event/PostCreateEvent.java b/runtracker/src/main/java/com/runtracker/domain/community/event/PostCreateEvent.java new file mode 100644 index 0000000..7c6488a --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/community/event/PostCreateEvent.java @@ -0,0 +1,4 @@ +package com.runtracker.domain.community.event; + +public record PostCreateEvent(Long authorMemberId, Long postId) { +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/community/event/PostDeleteEvent.java b/runtracker/src/main/java/com/runtracker/domain/community/event/PostDeleteEvent.java new file mode 100644 index 0000000..e76de0f --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/community/event/PostDeleteEvent.java @@ -0,0 +1,4 @@ +package com.runtracker.domain.community.event; + +public record PostDeleteEvent(Long authorMemberId, Long postId) { +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/community/event/PostLikeEvent.java b/runtracker/src/main/java/com/runtracker/domain/community/event/PostLikeEvent.java new file mode 100644 index 0000000..ff2668c --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/community/event/PostLikeEvent.java @@ -0,0 +1,4 @@ +package com.runtracker.domain.community.event; + +public record PostLikeEvent(Long likerMemberId, Long postAuthorMemberId, Long postId) { +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/community/event/PostUpdateEvent.java b/runtracker/src/main/java/com/runtracker/domain/community/event/PostUpdateEvent.java new file mode 100644 index 0000000..e4d79d6 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/community/event/PostUpdateEvent.java @@ -0,0 +1,4 @@ +package com.runtracker.domain.community.event; + +public record PostUpdateEvent(Long authorMemberId, Long postId) { +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/community/exception/AlreadyLikedPostException.java b/runtracker/src/main/java/com/runtracker/domain/community/exception/AlreadyLikedPostException.java new file mode 100644 index 0000000..332af95 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/community/exception/AlreadyLikedPostException.java @@ -0,0 +1,14 @@ +package com.runtracker.domain.community.exception; + +import com.runtracker.domain.community.enums.CommunityErrorCode; +import com.runtracker.global.exception.CustomException; + +public class AlreadyLikedPostException extends CustomException { + public AlreadyLikedPostException() { + super(CommunityErrorCode.ALREADY_LIKED_POST); + } + + public AlreadyLikedPostException(String message) { + super(CommunityErrorCode.ALREADY_LIKED_POST, message); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/community/exception/CommentCreationFailedException.java b/runtracker/src/main/java/com/runtracker/domain/community/exception/CommentCreationFailedException.java new file mode 100644 index 0000000..ec4372a --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/community/exception/CommentCreationFailedException.java @@ -0,0 +1,14 @@ +package com.runtracker.domain.community.exception; + +import com.runtracker.domain.community.enums.CommunityErrorCode; +import com.runtracker.global.exception.CustomException; + +public class CommentCreationFailedException extends CustomException { + public CommentCreationFailedException() { + super(CommunityErrorCode.COMMENT_CREATION_FAILED); + } + + public CommentCreationFailedException(String message) { + super(CommunityErrorCode.COMMENT_CREATION_FAILED, message); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/community/exception/CommentNotFoundException.java b/runtracker/src/main/java/com/runtracker/domain/community/exception/CommentNotFoundException.java new file mode 100644 index 0000000..14f2364 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/community/exception/CommentNotFoundException.java @@ -0,0 +1,14 @@ +package com.runtracker.domain.community.exception; + +import com.runtracker.domain.community.enums.CommunityErrorCode; +import com.runtracker.global.exception.CustomException; + +public class CommentNotFoundException extends CustomException { + public CommentNotFoundException() { + super(CommunityErrorCode.COMMENT_NOT_FOUND); + } + + public CommentNotFoundException(String message) { + super(CommunityErrorCode.COMMENT_NOT_FOUND, message); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/community/exception/NoPostsFoundException.java b/runtracker/src/main/java/com/runtracker/domain/community/exception/NoPostsFoundException.java new file mode 100644 index 0000000..f4facff --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/community/exception/NoPostsFoundException.java @@ -0,0 +1,14 @@ +package com.runtracker.domain.community.exception; + +import com.runtracker.domain.community.enums.CommunityErrorCode; +import com.runtracker.global.exception.CustomException; + +public class NoPostsFoundException extends CustomException { + public NoPostsFoundException() { + super(CommunityErrorCode.NO_POSTS_FOUND); + } + + public NoPostsFoundException(String message) { + super(CommunityErrorCode.NO_POSTS_FOUND, message); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/community/exception/NoSearchResultsException.java b/runtracker/src/main/java/com/runtracker/domain/community/exception/NoSearchResultsException.java new file mode 100644 index 0000000..ad55935 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/community/exception/NoSearchResultsException.java @@ -0,0 +1,14 @@ +package com.runtracker.domain.community.exception; + +import com.runtracker.domain.community.enums.CommunityErrorCode; +import com.runtracker.global.exception.CustomException; + +public class NoSearchResultsException extends CustomException { + public NoSearchResultsException() { + super(CommunityErrorCode.NO_SEARCH_RESULTS); + } + + public NoSearchResultsException(String message) { + super(CommunityErrorCode.NO_SEARCH_RESULTS, message); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/community/exception/NotLikedPostException.java b/runtracker/src/main/java/com/runtracker/domain/community/exception/NotLikedPostException.java new file mode 100644 index 0000000..af9f952 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/community/exception/NotLikedPostException.java @@ -0,0 +1,14 @@ +package com.runtracker.domain.community.exception; + +import com.runtracker.domain.community.enums.CommunityErrorCode; +import com.runtracker.global.exception.CustomException; + +public class NotLikedPostException extends CustomException { + public NotLikedPostException() { + super(CommunityErrorCode.NOT_LIKED_POST); + } + + public NotLikedPostException(String message) { + super(CommunityErrorCode.NOT_LIKED_POST, message); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/community/exception/PhotosRequiredException.java b/runtracker/src/main/java/com/runtracker/domain/community/exception/PhotosRequiredException.java new file mode 100644 index 0000000..b769846 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/community/exception/PhotosRequiredException.java @@ -0,0 +1,14 @@ +package com.runtracker.domain.community.exception; + +import com.runtracker.domain.community.enums.CommunityErrorCode; +import com.runtracker.global.exception.CustomException; + +public class PhotosRequiredException extends CustomException { + public PhotosRequiredException() { + super(CommunityErrorCode.PHOTOS_REQUIRED); + } + + public PhotosRequiredException(String message) { + super(CommunityErrorCode.PHOTOS_REQUIRED, message); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/community/exception/PostCreationFailedException.java b/runtracker/src/main/java/com/runtracker/domain/community/exception/PostCreationFailedException.java new file mode 100644 index 0000000..1c03680 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/community/exception/PostCreationFailedException.java @@ -0,0 +1,14 @@ +package com.runtracker.domain.community.exception; + +import com.runtracker.domain.community.enums.CommunityErrorCode; +import com.runtracker.global.exception.CustomException; + +public class PostCreationFailedException extends CustomException { + public PostCreationFailedException() { + super(CommunityErrorCode.POST_CREATION_FAILED); + } + + public PostCreationFailedException(String message) { + super(CommunityErrorCode.POST_CREATION_FAILED, message); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/community/exception/PostNotFoundException.java b/runtracker/src/main/java/com/runtracker/domain/community/exception/PostNotFoundException.java new file mode 100644 index 0000000..5f1c25f --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/community/exception/PostNotFoundException.java @@ -0,0 +1,14 @@ +package com.runtracker.domain.community.exception; + +import com.runtracker.domain.community.enums.CommunityErrorCode; +import com.runtracker.global.exception.CustomException; + +public class PostNotFoundException extends CustomException { + public PostNotFoundException() { + super(CommunityErrorCode.POST_NOT_FOUND); + } + + public PostNotFoundException(String message) { + super(CommunityErrorCode.POST_NOT_FOUND, message); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/community/exception/UnauthorizedCommentAccessException.java b/runtracker/src/main/java/com/runtracker/domain/community/exception/UnauthorizedCommentAccessException.java new file mode 100644 index 0000000..bba4189 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/community/exception/UnauthorizedCommentAccessException.java @@ -0,0 +1,14 @@ +package com.runtracker.domain.community.exception; + +import com.runtracker.domain.community.enums.CommunityErrorCode; +import com.runtracker.global.exception.CustomException; + +public class UnauthorizedCommentAccessException extends CustomException { + public UnauthorizedCommentAccessException() { + super(CommunityErrorCode.UNAUTHORIZED_COMMENT_ACCESS); + } + + public UnauthorizedCommentAccessException(String message) { + super(CommunityErrorCode.UNAUTHORIZED_COMMENT_ACCESS, message); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/community/exception/UnauthorizedPostAccessException.java b/runtracker/src/main/java/com/runtracker/domain/community/exception/UnauthorizedPostAccessException.java new file mode 100644 index 0000000..0f632ff --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/community/exception/UnauthorizedPostAccessException.java @@ -0,0 +1,14 @@ +package com.runtracker.domain.community.exception; + +import com.runtracker.domain.community.enums.CommunityErrorCode; +import com.runtracker.global.exception.CustomException; + +public class UnauthorizedPostAccessException extends CustomException { + public UnauthorizedPostAccessException() { + super(CommunityErrorCode.UNAUTHORIZED_POST_ACCESS); + } + + public UnauthorizedPostAccessException(String message) { + super(CommunityErrorCode.UNAUTHORIZED_POST_ACCESS, message); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/community/repository/CommentRepository.java b/runtracker/src/main/java/com/runtracker/domain/community/repository/CommentRepository.java new file mode 100644 index 0000000..98cdda0 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/community/repository/CommentRepository.java @@ -0,0 +1,16 @@ +package com.runtracker.domain.community.repository; + +import com.runtracker.domain.community.entity.PostComment; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface CommentRepository extends JpaRepository { + long countByPostId(Long postId); + + List findByPostIdOrderByCreatedAtAsc(Long postId); + + void deleteByMemberId(Long memberId); +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/community/repository/PostLikeRepository.java b/runtracker/src/main/java/com/runtracker/domain/community/repository/PostLikeRepository.java new file mode 100644 index 0000000..c57acdd --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/community/repository/PostLikeRepository.java @@ -0,0 +1,19 @@ +package com.runtracker.domain.community.repository; + +import com.runtracker.domain.community.entity.PostLike; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface PostLikeRepository extends JpaRepository { + boolean existsByPostIdAndMemberId(Long postId, Long memberId); + + void deleteByPostIdAndMemberId(Long postId, Long memberId); + + @Query("SELECT COUNT(pl) FROM PostLike pl WHERE pl.postId = :postId") + long countLikesByPostId(@Param("postId") Long postId); + + void deleteByMemberId(Long memberId); +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/community/repository/PostRepository.java b/runtracker/src/main/java/com/runtracker/domain/community/repository/PostRepository.java new file mode 100644 index 0000000..b12bb99 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/community/repository/PostRepository.java @@ -0,0 +1,19 @@ +package com.runtracker.domain.community.repository; + +import com.runtracker.domain.community.entity.Post; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface PostRepository extends JpaRepository { + + List findByCrewIdOrderByCreatedAtDesc(Long crewId); + + List findByCrewIdAndTitleContainingIgnoreCaseOrderByCreatedAtDesc(Long crewId, String keyword); + + void deleteByMemberId(Long memberId); +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/community/service/CommunityService.java b/runtracker/src/main/java/com/runtracker/domain/community/service/CommunityService.java new file mode 100644 index 0000000..b785de0 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/community/service/CommunityService.java @@ -0,0 +1,344 @@ +package com.runtracker.domain.community.service; + +import com.runtracker.domain.community.dto.CommentDTO; +import com.runtracker.domain.community.dto.CommentInfoDTO; +import com.runtracker.domain.community.dto.PostDTO; +import com.runtracker.domain.community.dto.PostDetailDTO; +import com.runtracker.domain.community.dto.PostListDTO; +import com.runtracker.domain.community.dto.RunningMetaDTO; +import com.runtracker.domain.community.event.PostCreateEvent; +import com.runtracker.domain.community.event.PostUpdateEvent; +import com.runtracker.domain.community.event.PostDeleteEvent; +import com.runtracker.domain.community.event.PostLikeEvent; +import com.runtracker.domain.community.event.PostCommentEvent; +import com.runtracker.domain.community.entity.Post; +import com.runtracker.domain.community.entity.PostComment; +import com.runtracker.domain.community.entity.PostLike; +import com.runtracker.domain.community.exception.AlreadyLikedPostException; +import com.runtracker.domain.community.exception.CommentCreationFailedException; +import com.runtracker.domain.community.exception.CommentNotFoundException; +import com.runtracker.domain.community.exception.NoPostsFoundException; +import com.runtracker.domain.community.exception.NoSearchResultsException; +import com.runtracker.domain.community.exception.NotLikedPostException; +import com.runtracker.domain.community.exception.PhotosRequiredException; +import com.runtracker.domain.community.exception.PostCreationFailedException; +import com.runtracker.domain.community.exception.PostNotFoundException; +import com.runtracker.domain.community.exception.UnauthorizedCommentAccessException; +import com.runtracker.domain.community.exception.UnauthorizedPostAccessException; +import com.runtracker.domain.community.repository.CommentRepository; +import com.runtracker.domain.community.repository.PostLikeRepository; +import com.runtracker.domain.community.repository.PostRepository; +import com.runtracker.domain.member.entity.Member; +import com.runtracker.domain.member.repository.MemberRepository; +import com.runtracker.global.security.CrewAuthorizationUtil; +import com.runtracker.global.security.UserDetailsImpl; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CommunityService { + + private final PostRepository postRepository; + private final PostLikeRepository postLikeRepository; + private final CommentRepository commentRepository; + private final MemberRepository memberRepository; + private final CrewAuthorizationUtil crewAuthorizationUtil; + private final ApplicationEventPublisher eventPublisher; + + @Transactional + public void createPost(PostDTO postDTO, UserDetailsImpl userDetails) { + Long crewId = userDetails.getCrewMembership().getCrewId(); + crewAuthorizationUtil.validateCrewMemberAccess(userDetails, crewId); + + if (postDTO.getPhotos() == null || postDTO.getPhotos().isEmpty()) { + throw new PhotosRequiredException(); + } + + try { + Post.PostBuilder postBuilder = Post.builder() + .memberId(userDetails.getMemberId()) + .crewId(crewId) + .title(postDTO.getTitle()) + .content(postDTO.getContent()) + .photos(postDTO.getPhotos()); + + if (postDTO.getMeta() != null) { + postBuilder.distance(postDTO.getMeta().getDistance()) + .time(postDTO.getMeta().getTime()) + .avgPace(postDTO.getMeta().getAvgPace()) + .avgSpeed(postDTO.getMeta().getAvgSpeed()); + } + + Post post = postBuilder.build(); + + postRepository.save(post); + + eventPublisher.publishEvent(new PostCreateEvent(userDetails.getMemberId(), post.getId())); + } catch (Exception e) { + throw new PostCreationFailedException(); + } + } + + @Transactional + public void updatePost(Long postId, PostDTO postDTO, UserDetailsImpl userDetails) { + Post post = postRepository.findById(postId) + .orElseThrow(PostNotFoundException::new); + + crewAuthorizationUtil.validateCrewMemberAccess(userDetails, post.getCrewId()); + + if (!post.getMemberId().equals(userDetails.getMemberId())) { + throw new UnauthorizedPostAccessException(); + } + + if (postDTO.getTitle() != null) { + post.updateTitle(postDTO.getTitle()); + } + if (postDTO.getContent() != null) { + post.updateContent(postDTO.getContent()); + } + if (postDTO.getPhotos() != null) { + post.updatePhotos(postDTO.getPhotos()); + } + if (postDTO.getMeta() != null) { + post.updateRunningMeta( + postDTO.getMeta().getDistance(), + postDTO.getMeta().getTime(), + postDTO.getMeta().getAvgPace(), + postDTO.getMeta().getAvgSpeed() + ); + } + + eventPublisher.publishEvent(new PostUpdateEvent(userDetails.getMemberId(), postId)); + } + + @Transactional + public void deletePost(Long postId, UserDetailsImpl userDetails) { + Post post = postRepository.findById(postId) + .orElseThrow(PostNotFoundException::new); + + crewAuthorizationUtil.validateCrewMemberAccess(userDetails, post.getCrewId()); + + if (!post.getMemberId().equals(userDetails.getMemberId())) { + throw new UnauthorizedPostAccessException(); + } + + postRepository.deleteById(postId); + + eventPublisher.publishEvent(new PostDeleteEvent(userDetails.getMemberId(), postId)); + } + + @Transactional + public void likePost(Long postId, UserDetailsImpl userDetails) { + Post post = postRepository.findById(postId) + .orElseThrow(PostNotFoundException::new); + + crewAuthorizationUtil.validateCrewMemberAccess(userDetails, post.getCrewId()); + + Long memberId = userDetails.getMemberId(); + + if (postLikeRepository.existsByPostIdAndMemberId(postId, memberId)) { + throw new AlreadyLikedPostException(); + } + + PostLike postLike = PostLike.builder() + .postId(postId) + .memberId(memberId) + .build(); + postLikeRepository.save(postLike); + + eventPublisher.publishEvent(new PostLikeEvent(memberId, post.getMemberId(), postId)); + } + + @Transactional + public void unlikePost(Long postId, UserDetailsImpl userDetails) { + Post post = postRepository.findById(postId) + .orElseThrow(PostNotFoundException::new); + + crewAuthorizationUtil.validateCrewMemberAccess(userDetails, post.getCrewId()); + + Long memberId = userDetails.getMemberId(); + + if (!postLikeRepository.existsByPostIdAndMemberId(postId, memberId)) { + throw new NotLikedPostException(); + } + + postLikeRepository.deleteByPostIdAndMemberId(postId, memberId); + } + + @Transactional + public void createComment(Long postId, CommentDTO commentDTO, UserDetailsImpl userDetails) { + Post post = postRepository.findById(postId) + .orElseThrow(PostNotFoundException::new); + + crewAuthorizationUtil.validateCrewMemberAccess(userDetails, post.getCrewId()); + + try { + PostComment comment = PostComment.builder() + .postId(postId) + .memberId(userDetails.getMemberId()) + .comment(commentDTO.getComment()) + .build(); + + commentRepository.save(comment); + + eventPublisher.publishEvent(new PostCommentEvent(userDetails.getMemberId(), post.getMemberId(), postId)); + } catch (Exception e) { + throw new CommentCreationFailedException(); + } + } + + @Transactional + public void updateComment(Long commentId, CommentDTO commentDTO, UserDetailsImpl userDetails) { + PostComment comment = validateCommentAccess(commentId, userDetails); + + if (commentDTO.getComment() != null) { + comment.updateComment(commentDTO.getComment()); + } + } + + @Transactional + public void deleteComment(Long commentId, UserDetailsImpl userDetails) { + validateCommentAccess(commentId, userDetails); + commentRepository.deleteById(commentId); + } + + private PostComment validateCommentAccess(Long commentId, UserDetailsImpl userDetails) { + PostComment comment = commentRepository.findById(commentId) + .orElseThrow(CommentNotFoundException::new); + + Post post = postRepository.findById(comment.getPostId()) + .orElseThrow(PostNotFoundException::new); + + crewAuthorizationUtil.validateCrewMemberAccess(userDetails, post.getCrewId()); + + if (!comment.getMemberId().equals(userDetails.getMemberId())) { + throw new UnauthorizedCommentAccessException(); + } + + return comment; + } + + public List getPostList(UserDetailsImpl userDetails) { + Long crewId = userDetails.getCrewMembership().getCrewId(); + crewAuthorizationUtil.validateCrewMemberAccess(userDetails, crewId); + + List posts = postRepository.findByCrewIdOrderByCreatedAtDesc(crewId); + if (posts.isEmpty()) { + throw new NoPostsFoundException(); + } + return convertToPostListDTO(posts, userDetails.getMemberId()); + } + + public PostDetailDTO getPostDetail(Long postId, UserDetailsImpl userDetails) { + Long crewId = userDetails.getCrewMembership().getCrewId(); + crewAuthorizationUtil.validateCrewMemberAccess(userDetails, crewId); + + Post post = postRepository.findById(postId) + .orElseThrow(PostNotFoundException::new); + + if (!post.getCrewId().equals(crewId)) { + throw new PostNotFoundException(); + } + + String memberName = memberRepository.findById(post.getMemberId()) + .map(Member::getName) + .orElse("Unknown"); + + long likeCount = postLikeRepository.countLikesByPostId(postId); + boolean isLiked = postLikeRepository.existsByPostIdAndMemberId(postId, userDetails.getMemberId()); + + List comments = commentRepository.findByPostIdOrderByCreatedAtAsc(postId); + List commentDTOs = comments.stream() + .map(comment -> { + String commentMemberName = memberRepository.findById(comment.getMemberId()) + .map(Member::getName) + .orElse("Unknown"); + + return CommentInfoDTO.builder() + .commentId(comment.getCommentId()) + .comment(comment.getComment()) + .memberId(comment.getMemberId()) + .memberName(commentMemberName) + .createdAt(comment.getCreatedAt()) + .updatedAt(comment.getUpdatedAt()) + .build(); + }) + .collect(Collectors.toList()); + + RunningMetaDTO runningMeta = RunningMetaDTO.builder() + .distance(post.getDistance()) + .time(post.getTime()) + .avgPace(post.getAvgPace()) + .avgSpeed(post.getAvgSpeed()) + .build(); + + return PostDetailDTO.builder() + .postId(post.getId()) + .title(post.getTitle()) + .content(post.getContent()) + .photos(post.getPhotos()) + .meta(runningMeta) + .memberId(post.getMemberId()) + .memberName(memberName) + .likeCount(likeCount) + .isLiked(isLiked) + .createdAt(post.getCreatedAt()) + .updatedAt(post.getUpdatedAt()) + .comments(commentDTOs) + .build(); + } + + public List searchPosts(String keyword, UserDetailsImpl userDetails) { + Long crewId = userDetails.getCrewMembership().getCrewId(); + crewAuthorizationUtil.validateCrewMemberAccess(userDetails, crewId); + + List posts = postRepository.findByCrewIdAndTitleContainingIgnoreCaseOrderByCreatedAtDesc(crewId, keyword); + if (posts.isEmpty()) { + throw new NoSearchResultsException(); + } + return convertToPostListDTO(posts, userDetails.getMemberId()); + } + + private List convertToPostListDTO(List posts, Long currentMemberId) { + return posts.stream() + .map(post -> { + String memberName = memberRepository.findById(post.getMemberId()) + .map(Member::getName) + .orElse("Unknown"); + + long likeCount = postLikeRepository.countLikesByPostId(post.getId()); + long commentCount = commentRepository.countByPostId(post.getId()); + boolean isLiked = postLikeRepository.existsByPostIdAndMemberId(post.getId(), currentMemberId); + + RunningMetaDTO runningMeta = RunningMetaDTO.builder() + .distance(post.getDistance()) + .time(post.getTime()) + .avgPace(post.getAvgPace()) + .avgSpeed(post.getAvgSpeed()) + .build(); + + return PostListDTO.builder() + .postId(post.getId()) + .title(post.getTitle()) + .content(post.getContent()) + .photos(post.getPhotos()) + .meta(runningMeta) + .memberId(post.getMemberId()) + .memberName(memberName) + .likeCount(likeCount) + .commentCount(commentCount) + .isLiked(isLiked) + .createdAt(post.getCreatedAt()) + .updatedAt(post.getUpdatedAt()) + .build(); + }) + .collect(Collectors.toList()); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/course/controller/CourseController.java b/runtracker/src/main/java/com/runtracker/domain/course/controller/CourseController.java new file mode 100644 index 0000000..09793d6 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/course/controller/CourseController.java @@ -0,0 +1,111 @@ +package com.runtracker.domain.course.controller; + +import com.runtracker.domain.course.dto.CourseDetailDTO; +import com.runtracker.domain.course.dto.CourseCreateDTO; +import com.runtracker.domain.course.dto.CourseUpdateDTO; +import com.runtracker.domain.course.dto.NearbyCoursesDTO.Response; +import com.runtracker.domain.course.dto.FinishRunning; +import com.runtracker.domain.course.service.CourseService; +import com.runtracker.global.response.ApiResponse; +import com.runtracker.global.security.UserDetailsImpl; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/courses") +@RequiredArgsConstructor +public class CourseController { + + private final CourseService courseService; + + @PostMapping("/save") + public ApiResponse saveCourse( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @RequestBody CourseCreateDTO request) { + courseService.saveCourse(userDetails.getMemberId(), request); + return ApiResponse.ok(); + } + + @GetMapping("/nearby") + public ApiResponse> getNearbyCourses( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @RequestParam Double latitude, + @RequestParam Double longitude, + @RequestParam(required = false) Integer limit) { + List courses = courseService.getNearbyCourses( + userDetails.getMemberId(), latitude, longitude, limit); + return ApiResponse.ok(courses); + } + + @GetMapping("/{courseId}") + public ApiResponse getCourseDetail( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable Long courseId) { + CourseDetailDTO courseDetail = courseService.getCourseDetail(courseId); + return ApiResponse.ok(courseDetail); + } + + @PostMapping("/{courseId}/running") + public ApiResponse startRunningWithCourse( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable Long courseId) { + courseService.startRunningCourse(userDetails.getMemberId(), courseId); + return ApiResponse.ok(); + } + + @PostMapping("/finish") + public ApiResponse finishRunning( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @RequestBody FinishRunning request) { + courseService.finishRunning(userDetails.getMemberId(), request); + return ApiResponse.ok(); + } + + @PostMapping("/test/save") + public ApiResponse saveTestCourse( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @RequestBody CourseCreateDTO request) { + courseService.saveTestCourse(userDetails.getMemberId(), request); + return ApiResponse.ok(); + } + + @GetMapping("/recommend/record") + public ApiResponse> getRecommendedCourses( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @RequestParam Double latitude, + @RequestParam Double longitude) { + List recommendedCourses = courseService.getRecommendedCourses( + userDetails.getMemberId(), latitude, longitude); + return ApiResponse.ok(recommendedCourses); + } + + @GetMapping("/recommend/setting") + public ApiResponse> getRecommendedCoursesBySetting( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @RequestParam Double latitude, + @RequestParam Double longitude) { + List recommendedCourses = courseService.getRecommendedCoursesBySetting( + userDetails.getMemberId(), latitude, longitude); + return ApiResponse.ok(recommendedCourses); + } + + @PatchMapping("/{courseId}") + public ApiResponse updateCourse( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable Long courseId, + @RequestBody CourseUpdateDTO courseUpdateDTO) { + courseService.updateCourse(userDetails.getMemberId(), courseId, courseUpdateDTO); + return ApiResponse.ok(); + } + + @DeleteMapping("/{courseId}") + public ApiResponse deleteCourse( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable Long courseId) { + courseService.deleteCourse(userDetails.getMemberId(), courseId); + return ApiResponse.ok(); + } +} diff --git a/runtracker/src/main/java/com/runtracker/domain/course/dto/CourseCreateDTO.java b/runtracker/src/main/java/com/runtracker/domain/course/dto/CourseCreateDTO.java new file mode 100644 index 0000000..93bc620 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/course/dto/CourseCreateDTO.java @@ -0,0 +1,21 @@ +package com.runtracker.domain.course.dto; + +import com.runtracker.domain.course.enums.Difficulty; +import com.runtracker.global.vo.Coordinate; +import lombok.*; + +import java.util.List; + +@NoArgsConstructor +@AllArgsConstructor +@Getter +@Setter +@Builder +public class CourseCreateDTO { + private String name; + private Difficulty difficulty; + private List path; + private Double distance; + private Boolean round; + private String region; +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/course/dto/CourseDTO.java b/runtracker/src/main/java/com/runtracker/domain/course/dto/CourseDTO.java new file mode 100644 index 0000000..4ecec26 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/course/dto/CourseDTO.java @@ -0,0 +1,27 @@ +package com.runtracker.domain.course.dto; + +import com.runtracker.domain.course.enums.Difficulty; +import com.runtracker.global.vo.Coordinate; +import lombok.*; + +import java.util.List; + +@NoArgsConstructor +@AllArgsConstructor +@Getter +@Setter +@Builder +@ToString +public class CourseDTO { + private Long id; + private Long memberId; + private String name; + private Difficulty difficulty; + private List points; + private Double startLat; + private Double startLng; + private Double distance; + private Boolean round; + private String indexs; + private String region; +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/course/dto/CourseDetailDTO.java b/runtracker/src/main/java/com/runtracker/domain/course/dto/CourseDetailDTO.java new file mode 100644 index 0000000..394e45e --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/course/dto/CourseDetailDTO.java @@ -0,0 +1,30 @@ +package com.runtracker.domain.course.dto; + +import com.runtracker.domain.course.enums.Difficulty; +import com.runtracker.global.vo.Coordinate; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CourseDetailDTO { + private Long id; + private Long memberId; + private String name; + private Difficulty difficulty; + private List points; + private Double startLat; + private Double startLng; + private Double distance; + private Boolean round; + private String region; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/course/dto/CourseUpdateDTO.java b/runtracker/src/main/java/com/runtracker/domain/course/dto/CourseUpdateDTO.java new file mode 100644 index 0000000..4bf25e8 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/course/dto/CourseUpdateDTO.java @@ -0,0 +1,15 @@ +package com.runtracker.domain.course.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CourseUpdateDTO { + private String name; + private String difficulty; +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/course/dto/FinishRunning.java b/runtracker/src/main/java/com/runtracker/domain/course/dto/FinishRunning.java new file mode 100644 index 0000000..3996744 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/course/dto/FinishRunning.java @@ -0,0 +1,39 @@ +package com.runtracker.domain.course.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.runtracker.global.code.DateConstants; +import com.runtracker.global.vo.Coordinate; +import com.runtracker.global.vo.SegmentPace; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class FinishRunning { + @DateTimeFormat(pattern = DateConstants.DATETIME_PATTERN) + @JsonFormat(pattern = DateConstants.DATETIME_PATTERN) + private LocalDateTime startedAt; + private Double distance; + private Double avgPace; + private Double avgSpeed; + private Integer kcal; + private Integer walkCnt; + private Integer avgHeartRate; + private Integer maxHeartRate; + private Integer avgCadence; + private Integer maxCadence; + private List userFinishLocation; + private List lastCoursePath; + private List segmentPaces; + private List> segmentPaths; + private String photo; + private Boolean isGoalAchieved; +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/course/dto/NearbyCoursesDTO.java b/runtracker/src/main/java/com/runtracker/domain/course/dto/NearbyCoursesDTO.java new file mode 100644 index 0000000..34a043e --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/course/dto/NearbyCoursesDTO.java @@ -0,0 +1,56 @@ +package com.runtracker.domain.course.dto; + +import com.runtracker.domain.course.enums.Difficulty; +import com.runtracker.global.vo.Coordinate; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +public class NearbyCoursesDTO { + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class Request { + private Double latitude; + private Double longitude; + private Integer radius; + private Integer limit; + } + + @Getter + @Builder + @NoArgsConstructor + public static class Response { + private Long id; + private Long memberId; + private String name; + private Difficulty difficulty; + private List points; + private Double startLat; + private Double startLng; + private Double distance; + private Boolean round; + private String region; + private Double distanceFromUser; + + public Response(Long id, Long memberId, String name, Difficulty difficulty, + List points, Double startLat, Double startLng, + Double distance, Boolean round, String region, Double distanceFromUser) { + this.id = id; + this.memberId = memberId; + this.name = name; + this.difficulty = difficulty; + this.points = points; + this.startLat = startLat; + this.startLng = startLng; + this.distance = distance; + this.round = round; + this.region = region; + this.distanceFromUser = distanceFromUser; + } + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/course/entity/Course.java b/runtracker/src/main/java/com/runtracker/domain/course/entity/Course.java new file mode 100644 index 0000000..7e7e602 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/course/entity/Course.java @@ -0,0 +1,61 @@ +package com.runtracker.domain.course.entity; + +import com.runtracker.global.converter.CoordinatesConverter; +import com.runtracker.domain.course.enums.Difficulty; +import com.runtracker.global.vo.Coordinate; +import com.runtracker.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Getter +@ToString +@Table(name = "course") +public class Course extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "member_id", nullable = false) + private Long memberId; + + @Column + private String name; + + @Enumerated(EnumType.STRING) + private Difficulty difficulty; + + @Convert(converter = CoordinatesConverter.class) + @Column(columnDefinition = "json") + @Builder.Default + private List paths = new ArrayList<>(); + + @Column(name = "start_lat") + private Double startLat; + + @Column(name = "start_lng") + private Double startLng; + + private Double distance; + + @Column(name = "round", columnDefinition = "TINYINT(1)") + private Boolean round; + + @Column(length = 100) + private String region; + + public void updateCourse(String name, Difficulty difficulty) { + if (name != null) { + this.name = name; + } + if (difficulty != null) { + this.difficulty = difficulty; + } + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/course/enums/CourseErrorCode.java b/runtracker/src/main/java/com/runtracker/domain/course/enums/CourseErrorCode.java new file mode 100644 index 0000000..e284013 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/course/enums/CourseErrorCode.java @@ -0,0 +1,31 @@ +package com.runtracker.domain.course.enums; + +import com.runtracker.global.code.ResponseCode; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum CourseErrorCode implements ResponseCode { + + COURSE_CREATION_FAILED("CR001", "Course creation failed"), + COURSE_SAVE_FAILED("CR002", "Course save failed"), + NEARBY_COURSE_MAPPING_FAILED("CR003", "Nearby course mapping failed"), + COORDINATES_PARSING_FAILED("CR004", "Coordinates parsing failed"), + COURSE_NOT_FOUND("CR005", "Course not found"), + VALIDATION_ERROR("CR006", "validation error"), + MULTIPLE_ACTIVE_RUNNING("CR007", "Multiple active running found"), + ALREADY_RUNNING("CR008", "Already running"), + INVALID_START_TIME("CR009", "Start time cannot be in the future"), + ROUTE_ANALYSIS_FAILED("CR010", "Route analysis failed"), + NO_PATH_DATA("CR011", "No path data found in course"), + GOOGLE_MAPS_API_ERROR("CR012", "Google Maps API error"), + INSUFFICIENT_PATH_DATA("CR013", "Insufficient path data for route analysis"), + RECOMMENDATION_SERVICE_UNAVAILABLE("CR014", "Recommendation service is unavailable"), + NO_RECOMMENDED_COURSES("CR015", "No recommended courses found"), + COURSE_UPDATE_FORBIDDEN("CR016", "You do not have permission to update this course"), + COURSE_DELETE_FORBIDDEN("CR017", "You do not have permission to delete this course"); + + private final String statusCode; + private final String message; +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/course/enums/Difficulty.java b/runtracker/src/main/java/com/runtracker/domain/course/enums/Difficulty.java new file mode 100644 index 0000000..24ecb2b --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/course/enums/Difficulty.java @@ -0,0 +1,5 @@ +package com.runtracker.domain.course.enums; + +public enum Difficulty { + EASY, MEDIUM, HARD +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/course/exception/AlreadyRunningException.java b/runtracker/src/main/java/com/runtracker/domain/course/exception/AlreadyRunningException.java new file mode 100644 index 0000000..ffd8629 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/course/exception/AlreadyRunningException.java @@ -0,0 +1,11 @@ +package com.runtracker.domain.course.exception; + +import com.runtracker.domain.course.enums.CourseErrorCode; +import com.runtracker.global.exception.CustomException; + +public class AlreadyRunningException extends CustomException { + + public AlreadyRunningException(String message) { + super(CourseErrorCode.ALREADY_RUNNING, message); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/course/exception/CoordinatesParsingFailedException.java b/runtracker/src/main/java/com/runtracker/domain/course/exception/CoordinatesParsingFailedException.java new file mode 100644 index 0000000..e738bf4 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/course/exception/CoordinatesParsingFailedException.java @@ -0,0 +1,15 @@ +package com.runtracker.domain.course.exception; + +import com.runtracker.domain.course.enums.CourseErrorCode; +import com.runtracker.global.exception.CustomException; + +public class CoordinatesParsingFailedException extends CustomException { + + public CoordinatesParsingFailedException() { + super(CourseErrorCode.COORDINATES_PARSING_FAILED); + } + + public CoordinatesParsingFailedException(String message) { + super(CourseErrorCode.COORDINATES_PARSING_FAILED, message); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/course/exception/CourseCreationFailedException.java b/runtracker/src/main/java/com/runtracker/domain/course/exception/CourseCreationFailedException.java new file mode 100644 index 0000000..9740bd9 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/course/exception/CourseCreationFailedException.java @@ -0,0 +1,15 @@ +package com.runtracker.domain.course.exception; + +import com.runtracker.domain.course.enums.CourseErrorCode; +import com.runtracker.global.exception.CustomException; + +public class CourseCreationFailedException extends CustomException { + + public CourseCreationFailedException() { + super(CourseErrorCode.COURSE_CREATION_FAILED); + } + + public CourseCreationFailedException(String message) { + super(CourseErrorCode.COURSE_CREATION_FAILED, message); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/course/exception/CourseDeleteForbiddenException.java b/runtracker/src/main/java/com/runtracker/domain/course/exception/CourseDeleteForbiddenException.java new file mode 100644 index 0000000..d14206e --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/course/exception/CourseDeleteForbiddenException.java @@ -0,0 +1,14 @@ +package com.runtracker.domain.course.exception; + +import com.runtracker.domain.course.enums.CourseErrorCode; +import com.runtracker.global.exception.CustomException; + +public class CourseDeleteForbiddenException extends CustomException { + public CourseDeleteForbiddenException() { + super(CourseErrorCode.COURSE_DELETE_FORBIDDEN); + } + + public CourseDeleteForbiddenException(String message) { + super(CourseErrorCode.COURSE_DELETE_FORBIDDEN, message); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/course/exception/CourseNotFoundException.java b/runtracker/src/main/java/com/runtracker/domain/course/exception/CourseNotFoundException.java new file mode 100644 index 0000000..491d3f4 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/course/exception/CourseNotFoundException.java @@ -0,0 +1,14 @@ +package com.runtracker.domain.course.exception; + +import com.runtracker.domain.course.enums.CourseErrorCode; +import com.runtracker.global.exception.CustomException; + +public class CourseNotFoundException extends CustomException { + public CourseNotFoundException() { + super(CourseErrorCode.COURSE_NOT_FOUND); + } + + public CourseNotFoundException(String message) { + super(CourseErrorCode.COURSE_NOT_FOUND, message); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/course/exception/CourseSaveFailedException.java b/runtracker/src/main/java/com/runtracker/domain/course/exception/CourseSaveFailedException.java new file mode 100644 index 0000000..fd6ecfd --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/course/exception/CourseSaveFailedException.java @@ -0,0 +1,15 @@ +package com.runtracker.domain.course.exception; + +import com.runtracker.domain.course.enums.CourseErrorCode; +import com.runtracker.global.exception.CustomException; + +public class CourseSaveFailedException extends CustomException { + + public CourseSaveFailedException() { + super(CourseErrorCode.COURSE_SAVE_FAILED); + } + + public CourseSaveFailedException(String message) { + super(CourseErrorCode.COURSE_SAVE_FAILED, message); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/course/exception/CourseUpdateForbiddenException.java b/runtracker/src/main/java/com/runtracker/domain/course/exception/CourseUpdateForbiddenException.java new file mode 100644 index 0000000..d7d36c0 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/course/exception/CourseUpdateForbiddenException.java @@ -0,0 +1,14 @@ +package com.runtracker.domain.course.exception; + +import com.runtracker.domain.course.enums.CourseErrorCode; +import com.runtracker.global.exception.CustomException; + +public class CourseUpdateForbiddenException extends CustomException { + public CourseUpdateForbiddenException() { + super(CourseErrorCode.COURSE_UPDATE_FORBIDDEN); + } + + public CourseUpdateForbiddenException(String message) { + super(CourseErrorCode.COURSE_UPDATE_FORBIDDEN, message); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/course/exception/GoogleMapsApiException.java b/runtracker/src/main/java/com/runtracker/domain/course/exception/GoogleMapsApiException.java new file mode 100644 index 0000000..085fbbf --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/course/exception/GoogleMapsApiException.java @@ -0,0 +1,10 @@ +package com.runtracker.domain.course.exception; + +import com.runtracker.domain.course.enums.CourseErrorCode; +import com.runtracker.global.exception.CustomException; + +public class GoogleMapsApiException extends CustomException { + public GoogleMapsApiException() { + super(CourseErrorCode.GOOGLE_MAPS_API_ERROR); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/course/exception/InsufficientPathDataException.java b/runtracker/src/main/java/com/runtracker/domain/course/exception/InsufficientPathDataException.java new file mode 100644 index 0000000..b61f18d --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/course/exception/InsufficientPathDataException.java @@ -0,0 +1,10 @@ +package com.runtracker.domain.course.exception; + +import com.runtracker.domain.course.enums.CourseErrorCode; +import com.runtracker.global.exception.CustomException; + +public class InsufficientPathDataException extends CustomException { + public InsufficientPathDataException() { + super(CourseErrorCode.INSUFFICIENT_PATH_DATA); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/course/exception/InvalidStartTimeException.java b/runtracker/src/main/java/com/runtracker/domain/course/exception/InvalidStartTimeException.java new file mode 100644 index 0000000..9261d20 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/course/exception/InvalidStartTimeException.java @@ -0,0 +1,14 @@ +package com.runtracker.domain.course.exception; + +import com.runtracker.domain.course.enums.CourseErrorCode; +import com.runtracker.global.exception.CustomException; + +public class InvalidStartTimeException extends CustomException { + public InvalidStartTimeException() { + super(CourseErrorCode.INVALID_START_TIME); + } + + public InvalidStartTimeException(String message) { + super(CourseErrorCode.INVALID_START_TIME, message); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/course/exception/MultipleActiveRunningException.java b/runtracker/src/main/java/com/runtracker/domain/course/exception/MultipleActiveRunningException.java new file mode 100644 index 0000000..5157ca0 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/course/exception/MultipleActiveRunningException.java @@ -0,0 +1,11 @@ +package com.runtracker.domain.course.exception; + +import com.runtracker.domain.course.enums.CourseErrorCode; +import com.runtracker.global.exception.CustomException; + +public class MultipleActiveRunningException extends CustomException { + + public MultipleActiveRunningException(String message) { + super(CourseErrorCode.MULTIPLE_ACTIVE_RUNNING, message); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/course/exception/NearbyCourseMappingFailedException.java b/runtracker/src/main/java/com/runtracker/domain/course/exception/NearbyCourseMappingFailedException.java new file mode 100644 index 0000000..8f8fdf9 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/course/exception/NearbyCourseMappingFailedException.java @@ -0,0 +1,15 @@ +package com.runtracker.domain.course.exception; + +import com.runtracker.domain.course.enums.CourseErrorCode; +import com.runtracker.global.exception.CustomException; + +public class NearbyCourseMappingFailedException extends CustomException { + + public NearbyCourseMappingFailedException() { + super(CourseErrorCode.NEARBY_COURSE_MAPPING_FAILED); + } + + public NearbyCourseMappingFailedException(String message) { + super(CourseErrorCode.NEARBY_COURSE_MAPPING_FAILED, message); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/course/exception/NoPathDataException.java b/runtracker/src/main/java/com/runtracker/domain/course/exception/NoPathDataException.java new file mode 100644 index 0000000..e429480 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/course/exception/NoPathDataException.java @@ -0,0 +1,10 @@ +package com.runtracker.domain.course.exception; + +import com.runtracker.domain.course.enums.CourseErrorCode; +import com.runtracker.global.exception.CustomException; + +public class NoPathDataException extends CustomException { + public NoPathDataException() { + super(CourseErrorCode.NO_PATH_DATA); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/course/exception/NoRecommendedCoursesException.java b/runtracker/src/main/java/com/runtracker/domain/course/exception/NoRecommendedCoursesException.java new file mode 100644 index 0000000..bc35c12 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/course/exception/NoRecommendedCoursesException.java @@ -0,0 +1,14 @@ +package com.runtracker.domain.course.exception; + +import com.runtracker.domain.course.enums.CourseErrorCode; +import com.runtracker.global.exception.CustomException; + +public class NoRecommendedCoursesException extends CustomException { + public NoRecommendedCoursesException() { + super(CourseErrorCode.NO_RECOMMENDED_COURSES); + } + + public NoRecommendedCoursesException(String message) { + super(CourseErrorCode.NO_RECOMMENDED_COURSES, message); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/course/exception/RecommendationServiceUnavailableException.java b/runtracker/src/main/java/com/runtracker/domain/course/exception/RecommendationServiceUnavailableException.java new file mode 100644 index 0000000..aa9e094 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/course/exception/RecommendationServiceUnavailableException.java @@ -0,0 +1,14 @@ +package com.runtracker.domain.course.exception; + +import com.runtracker.domain.course.enums.CourseErrorCode; +import com.runtracker.global.exception.CustomException; + +public class RecommendationServiceUnavailableException extends CustomException { + public RecommendationServiceUnavailableException() { + super(CourseErrorCode.RECOMMENDATION_SERVICE_UNAVAILABLE); + } + + public RecommendationServiceUnavailableException(String message) { + super(CourseErrorCode.RECOMMENDATION_SERVICE_UNAVAILABLE, message); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/course/exception/RouteAnalysisFailedException.java b/runtracker/src/main/java/com/runtracker/domain/course/exception/RouteAnalysisFailedException.java new file mode 100644 index 0000000..b633606 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/course/exception/RouteAnalysisFailedException.java @@ -0,0 +1,10 @@ +package com.runtracker.domain.course.exception; + +import com.runtracker.domain.course.enums.CourseErrorCode; +import com.runtracker.global.exception.CustomException; + +public class RouteAnalysisFailedException extends CustomException { + public RouteAnalysisFailedException() { + super(CourseErrorCode.ROUTE_ANALYSIS_FAILED); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/course/exception/ValidationErrorException.java b/runtracker/src/main/java/com/runtracker/domain/course/exception/ValidationErrorException.java new file mode 100644 index 0000000..8a9a39a --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/course/exception/ValidationErrorException.java @@ -0,0 +1,10 @@ +package com.runtracker.domain.course.exception; + +import com.runtracker.domain.course.enums.CourseErrorCode; +import com.runtracker.global.exception.CustomException; + +public class ValidationErrorException extends CustomException { + public ValidationErrorException(String message) { + super(CourseErrorCode.VALIDATION_ERROR, message); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/course/exception/ValidationException.java b/runtracker/src/main/java/com/runtracker/domain/course/exception/ValidationException.java new file mode 100644 index 0000000..07d495e --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/course/exception/ValidationException.java @@ -0,0 +1,11 @@ +package com.runtracker.domain.course.exception; + +import com.runtracker.domain.course.enums.CourseErrorCode; +import com.runtracker.global.exception.CustomException; + +public class ValidationException extends CustomException { + + public ValidationException(String message) { + super(CourseErrorCode.VALIDATION_ERROR, message); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/course/repository/CourseRepository.java b/runtracker/src/main/java/com/runtracker/domain/course/repository/CourseRepository.java new file mode 100644 index 0000000..53bbdd9 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/course/repository/CourseRepository.java @@ -0,0 +1,11 @@ +package com.runtracker.domain.course.repository; + +import com.runtracker.domain.course.entity.Course; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface CourseRepository extends JpaRepository, CourseRepositoryCustom { + + void deleteByMemberId(Long memberId); +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/course/repository/CourseRepositoryCustom.java b/runtracker/src/main/java/com/runtracker/domain/course/repository/CourseRepositoryCustom.java new file mode 100644 index 0000000..4a69a0a --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/course/repository/CourseRepositoryCustom.java @@ -0,0 +1,9 @@ +package com.runtracker.domain.course.repository; + +import com.runtracker.domain.course.dto.NearbyCoursesDTO.Response; + +import java.util.List; + +public interface CourseRepositoryCustom { + List findNearbyCourses(double lat, double lng, int radiusInMeters); +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/course/repository/CourseRepositoryImpl.java b/runtracker/src/main/java/com/runtracker/domain/course/repository/CourseRepositoryImpl.java new file mode 100644 index 0000000..76ebe65 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/course/repository/CourseRepositoryImpl.java @@ -0,0 +1,38 @@ +package com.runtracker.domain.course.repository; + +import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.jpa.impl.JPAQueryFactory; +import com.runtracker.domain.course.dto.NearbyCoursesDTO.Response; +import com.runtracker.domain.course.entity.QCourse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class CourseRepositoryImpl implements CourseRepositoryCustom { + private final JPAQueryFactory queryFactory; + + @Override + public List findNearbyCourses(double lat, double lng, int radiusInMeters) { + QCourse course = QCourse.course; + return queryFactory + .select(Projections.constructor(Response.class, + course.id, course.memberId, course.name, course.difficulty, + course.paths, course.startLat, course.startLng, course.distance, + course.round, course.region, + Expressions.numberTemplate(Double.class, + "ST_Distance_Sphere(POINT({0}, {1}), POINT({2}, {3}))", + lng, lat, course.startLng, course.startLat + ).as("distanceFromUser") + )) + .from(course) + .where(Expressions.numberTemplate(Double.class, + "ST_Distance_Sphere(POINT({0}, {1}), POINT({2}, {3}))", + lng, lat, course.startLng, course.startLat + ).loe(radiusInMeters)) + .fetch(); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/course/service/CourseCacheService.java b/runtracker/src/main/java/com/runtracker/domain/course/service/CourseCacheService.java new file mode 100644 index 0000000..60eccd5 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/course/service/CourseCacheService.java @@ -0,0 +1,270 @@ +package com.runtracker.domain.course.service; + +import com.runtracker.domain.course.dto.CourseDetailDTO; +import com.runtracker.domain.course.service.dto.SlotData; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.*; + +@Slf4j +@Service +@RequiredArgsConstructor +public class CourseCacheService { + + private final RedisTemplate redisTemplate; + + private static final String NEARBY_SLOTS_HASH = "nearby_slots"; + private static final String COURSE_DETAIL_HASH = "course_details"; + private static final Duration NEARBY_COURSES_TTL = Duration.ofHours(3); + private static final Duration COURSE_DETAIL_TTL = Duration.ofHours(3); + private static final double COORDINATE_ROUND_UNIT = 0.005; // 500m + private static final int MAX_NEARBY_SLOTS = 10; + private static final int MAX_COURSE_DETAIL_SLOTS = 200; + + /** + * 좌표를 그리드로 변환 (500m 그리드) + */ + private String generateLocationKey(Double latitude, Double longitude) { + double gridLat = Math.floor(latitude / COORDINATE_ROUND_UNIT) * COORDINATE_ROUND_UNIT; + double gridLng = Math.floor(longitude / COORDINATE_ROUND_UNIT) * COORDINATE_ROUND_UNIT; + return String.format("%.3f:%.3f", gridLat, gridLng); + } + + /** + * 근처 코스 ID 리스트 캐싱 (Hash 사용) + */ + public void cacheNearbyCourses(Double latitude, Double longitude, List courseIds) { + try { + String groupKey = generateLocationKey(latitude, longitude); + + SlotData slot = (SlotData) redisTemplate.opsForHash().get(NEARBY_SLOTS_HASH, groupKey); + + if (slot != null) { + slot.updateAccess(); + } else { + // 슬롯 개수 확인 + Long slotCount = redisTemplate.opsForHash().size(NEARBY_SLOTS_HASH); + if (slotCount != null && slotCount >= MAX_NEARBY_SLOTS) { + evictLeastUsedSlot(); + } + + slot = SlotData.builder() + .location(groupKey) + .courseIds(new ArrayList<>(courseIds)) + .lastAccessTime(LocalDateTime.now()) + .frequency(1) + .build(); + } + + // Hash에 단일 키 업데이트 (O(1)) + redisTemplate.opsForHash().put(NEARBY_SLOTS_HASH, groupKey, slot); + redisTemplate.expire(NEARBY_SLOTS_HASH, NEARBY_COURSES_TTL); + + log.debug("Cached nearby courses for group: {}, frequency: {}", groupKey, slot.getFrequency()); + } catch (Exception e) { + log.warn("Failed to cache nearby courses for lat: {}, lng: {}, error: {}", + latitude, longitude, e.getMessage()); + } + } + + /** + * 근처 코스 ID 리스트 조회 (Hash 사용) + */ + public List getNearbyCourseIds(Double latitude, Double longitude) { + try { + String groupKey = generateLocationKey(latitude, longitude); + SlotData slot = (SlotData) redisTemplate.opsForHash().get(NEARBY_SLOTS_HASH, groupKey); + + if (slot != null) { + slot.updateAccess(); + redisTemplate.opsForHash().put(NEARBY_SLOTS_HASH, groupKey, slot); + return slot.getCourseIds(); + } + + return null; + } catch (Exception e) { + log.warn("Failed to get nearby course IDs from cache for lat: {}, lng: {}, error: {}", + latitude, longitude, e.getMessage()); + return null; + } + } + + /** + * LRU + LFU 기반 슬롯 삭제 (Hash 사용) + */ + private void evictLeastUsedSlot() { + try { + Map allSlots = redisTemplate.opsForHash().entries(NEARBY_SLOTS_HASH); + + SlotData leastUsed = allSlots.values().stream() + .filter(obj -> obj instanceof SlotData) + .map(obj -> (SlotData) obj) + .min(Comparator + .comparing(SlotData::getFrequency) + .thenComparing(SlotData::getLastAccessTime)) + .orElse(null); + + if (leastUsed != null) { + redisTemplate.opsForHash().delete(NEARBY_SLOTS_HASH, leastUsed.getLocation()); + log.debug("Evicted least used slot: location={}, frequency={}", + leastUsed.getLocation(), leastUsed.getFrequency()); + } + } catch (Exception e) { + log.warn("Failed to evict least used slot: {}", e.getMessage()); + } + } + + /** + * 새 코스 추가 시 슬롯에 코스 ID 추가 (Hash 사용) + */ + public void addCourseToSlot(Double latitude, Double longitude, Long courseId) { + try { + String groupKey = generateLocationKey(latitude, longitude); + SlotData slot = (SlotData) redisTemplate.opsForHash().get(NEARBY_SLOTS_HASH, groupKey); + + if (slot != null) { + slot.addCourseId(courseId); + redisTemplate.opsForHash().put(NEARBY_SLOTS_HASH, groupKey, slot); + log.debug("Added course {} to slot {}", courseId, groupKey); + } else { + log.debug("No slot found for group {} to add course {}", groupKey, courseId); + } + } catch (Exception e) { + log.warn("Failed to add course to slot for lat: {}, lng: {}, courseId: {}, error: {}", + latitude, longitude, courseId, e.getMessage()); + } + } + + /** + * 코스 상세 정보 캐싱 (Hash 사용) + */ + public void cacheCourseDetail(Long courseId, CourseDetailDTO detail) { + try { + // 슬롯 개수 확인 + Long slotCount = redisTemplate.opsForHash().size(COURSE_DETAIL_HASH); + if (slotCount != null && slotCount >= MAX_COURSE_DETAIL_SLOTS) { + evictOldestCourseDetailSlot(); + } + + redisTemplate.opsForHash().put(COURSE_DETAIL_HASH, courseId.toString(), detail); + redisTemplate.expire(COURSE_DETAIL_HASH, COURSE_DETAIL_TTL); + + log.debug("Cached course detail for courseId: {}", courseId); + } catch (Exception e) { + log.warn("Failed to cache course detail for courseId: {}, error: {}", courseId, e.getMessage()); + } + } + + /** + * 코스 상세 정보 조회 (Hash 사용) + */ + public CourseDetailDTO getCourseDetail(Long courseId) { + try { + Object detail = redisTemplate.opsForHash().get(COURSE_DETAIL_HASH, courseId.toString()); + + if (detail instanceof CourseDetailDTO courseDetail) { + log.debug("Cache hit for course detail, courseId: {}", courseId); + return courseDetail; + } + + log.debug("Cache miss for course detail, courseId: {}", courseId); + return null; + } catch (Exception e) { + log.warn("Failed to get course detail from cache for courseId: {}, error: {}", courseId, e.getMessage()); + return null; + } + } + + /** + * 다중 코스 상세 정보 조회 (multiGet 사용 - 성능 최적화) + */ + public Map getMultipleCourseDetails(List courseIds) { + try { + List keys = courseIds.stream() + .map(String::valueOf) + .map(Object.class::cast) + .toList(); + + List values = redisTemplate.opsForHash().multiGet(COURSE_DETAIL_HASH, keys); + Map result = new HashMap<>(); + + for (int i = 0; i < courseIds.size(); i++) { + if (values.get(i) instanceof CourseDetailDTO detail) { + result.put(courseIds.get(i), detail); + } + } + + log.debug("Multi-get cache hit for {} courses", result.size()); + return result; + } catch (Exception e) { + log.warn("Failed to get multiple course details: {}", e.getMessage()); + return Collections.emptyMap(); + } + } + + /** + * 가장 오래된 코스 상세 슬롯 삭제 + */ + private void evictOldestCourseDetailSlot() { + try { + Map allDetails = redisTemplate.opsForHash().entries(COURSE_DETAIL_HASH); + + if (!allDetails.isEmpty()) { + // 첫 번째 항목 삭제 (FIFO) + Object firstKey = allDetails.keySet().iterator().next(); + redisTemplate.opsForHash().delete(COURSE_DETAIL_HASH, firstKey); + log.debug("Evicted oldest course detail slot: {}", firstKey); + } + } catch (Exception e) { + log.warn("Failed to evict oldest course detail slot: {}", e.getMessage()); + } + } + + /** + * 특정 코스 상세 정보 캐시 삭제 + */ + public void evictCourseDetail(Long courseId) { + try { + redisTemplate.opsForHash().delete(COURSE_DETAIL_HASH, courseId.toString()); + } catch (Exception e) { + log.warn("Failed to evict course detail cache for courseId: {}, error: {}", courseId, e.getMessage()); + } + } + + /** + * 슬롯에서 특정 코스 ID 제거 + */ + public void removeCourseFromSlot(Double latitude, Double longitude, Long courseId) { + try { + String groupKey = generateLocationKey(latitude, longitude); + SlotData slot = (SlotData) redisTemplate.opsForHash().get(NEARBY_SLOTS_HASH, groupKey); + + if (slot != null) { + slot.getCourseIds().remove(courseId); + redisTemplate.opsForHash().put(NEARBY_SLOTS_HASH, groupKey, slot); + } + } catch (Exception e) { + log.warn("Failed to remove course from slot for lat: {}, lng: {}, courseId: {}, error: {}", + latitude, longitude, courseId, e.getMessage()); + } + } + + /** + * 코스 상세 정보 업데이트 + */ + public void updateCourseDetail(Long courseId, CourseDetailDTO detail) { + try { + Object existing = redisTemplate.opsForHash().get(COURSE_DETAIL_HASH, courseId.toString()); + if (existing != null) { + redisTemplate.opsForHash().put(COURSE_DETAIL_HASH, courseId.toString(), detail); + } + } catch (Exception e) { + log.warn("Failed to update course detail cache for courseId: {}, error: {}", courseId, e.getMessage()); + } + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/course/service/CourseService.java b/runtracker/src/main/java/com/runtracker/domain/course/service/CourseService.java new file mode 100644 index 0000000..619d234 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/course/service/CourseService.java @@ -0,0 +1,619 @@ +package com.runtracker.domain.course.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.runtracker.domain.course.dto.CourseDetailDTO; +import com.runtracker.domain.course.dto.CourseCreateDTO; +import com.runtracker.domain.course.dto.CourseUpdateDTO; +import com.runtracker.domain.course.service.dto.GoogleMapsDTO; +import com.runtracker.domain.course.dto.NearbyCoursesDTO.Response; +import com.runtracker.domain.course.dto.FinishRunning; +import com.runtracker.domain.course.service.dto.RecommendationDTO; +import com.runtracker.domain.course.entity.Course; +import com.runtracker.domain.course.enums.Difficulty; +import com.runtracker.domain.course.exception.AlreadyRunningException; +import com.runtracker.domain.course.exception.CourseCreationFailedException; +import com.runtracker.domain.course.exception.CourseNotFoundException; +import com.runtracker.domain.course.exception.CourseUpdateForbiddenException; +import com.runtracker.domain.course.exception.CourseDeleteForbiddenException; +import com.runtracker.domain.course.exception.InsufficientPathDataException; +import com.runtracker.domain.course.exception.InvalidStartTimeException; +import com.runtracker.domain.course.exception.MultipleActiveRunningException; +import com.runtracker.domain.course.exception.NoRecommendedCoursesException; +import com.runtracker.domain.course.exception.ValidationErrorException; +import com.runtracker.domain.crew.service.CrewRankingService; +import com.runtracker.domain.crew.service.CrewMemberRankingService; +import com.runtracker.domain.crew.repository.CrewMemberRepository; +import com.runtracker.domain.crew.entity.CrewMember; +import com.runtracker.domain.crew.enums.CrewMemberStatus; +import com.runtracker.domain.course.repository.CourseRepository; +import com.runtracker.domain.member.entity.Member; +import com.runtracker.domain.member.exception.MemberNotFoundException; +import com.runtracker.domain.member.repository.MemberRepository; +import com.runtracker.domain.member.service.TempCalcService; +import com.runtracker.domain.record.entity.RunningRecord; +import com.runtracker.domain.record.exception.CourseNotFoundForRecordException; +import com.runtracker.domain.record.exception.RecordNotFoundException; +import com.runtracker.domain.record.repository.RecordRepository; +import com.runtracker.global.util.ImageUpload; +import com.runtracker.global.client.FastAPIClient; +import com.runtracker.global.vo.Coordinate; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional +public class CourseService { + + private final CourseRepository courseRepository; + private final MemberRepository memberRepository; + private final RecordRepository recordRepository; + private final TempCalcService temperatureCalculationService; + private final CrewRankingService crewRankingService; + private final CrewMemberRankingService crewMemberRankingService; + private final CrewMemberRepository crewMemberRepository; + private final RouteAnalysisService routeAnalysisService; + private final FastAPIClient fastAPIClient; + private final ObjectMapper objectMapper; + private final CourseCacheService courseCacheService; + private final ImageUpload imageUploadHelper; + + private void checkAlreadyRunning(Long memberId) { + List activeRecords = recordRepository.findAllByMemberIdAndFinishedAtIsNull(memberId); + if (!activeRecords.isEmpty()) { + throw new AlreadyRunningException("Member already has active running record"); + } + } + + private void createRunningRecord(Long memberId, Long courseId) { + RunningRecord runningRecord = RunningRecord.builder() + .memberId(memberId) + .courseId(courseId) + .build(); + + recordRepository.save(runningRecord); + } + + public Course saveCourse(Long memberId, CourseCreateDTO request) { + try { + validateCourseRequest(request); + checkAlreadyRunning(memberId); + + Course course = createCourseFromRequest(memberId, request, false); + Course savedCourse = courseRepository.save(course); + + createRunningRecord(memberId, savedCourse.getId()); + + if (savedCourse.getStartLat() != null && savedCourse.getStartLng() != null) { + courseCacheService.addCourseToSlot(savedCourse.getStartLat(), savedCourse.getStartLng(), savedCourse.getId()); + } + + return savedCourse; + + } catch (AlreadyRunningException | InsufficientPathDataException | ValidationErrorException e) { + throw e; + } catch (Exception e) { + log.error("Failed to save course and start running for member: {}, error: {}", + memberId, e.getMessage(), e); + throw new CourseCreationFailedException("Failed to save course and start running for member: " + memberId); + } + } + + public void saveTestCourse(Long memberId, CourseCreateDTO request) { + try { + validateCourseRequest(request); + Course course = createCourseFromRequest(memberId, request, true); + courseRepository.save(course); + + } catch (ValidationErrorException e) { + throw e; + } catch (Exception e) { + log.error("Failed to save test course for member: {}, error: {}", + memberId, e.getMessage(), e); + throw new CourseCreationFailedException("Failed to save test course for member: " + memberId); + } + } + + private void validateCourseRequest(CourseCreateDTO request) { + if (request.getName() == null || request.getName().trim().isEmpty()) { + throw new ValidationErrorException("코스 이름은 필수입니다"); + } + + if (request.getName().length() > 100) { + throw new ValidationErrorException("코스 이름은 100자 이하여야 합니다"); + } + + if (request.getPath() == null) { + throw new ValidationErrorException("경로 정보는 필수입니다"); + } + + if (request.getDistance() == null || request.getDistance() <= 0) { + throw new ValidationErrorException("거리는 0보다 커야 합니다"); + } + + if (request.getRound() == null) { + throw new ValidationErrorException("런닝 왕복 정보는 필수입니다"); + } + + if (request.getRegion() == null || request.getRegion().trim().isEmpty()) { + throw new ValidationErrorException("지역 정보는 필수입니다"); + } + } + + private Course createCourseFromRequest(Long memberId, CourseCreateDTO request, boolean allowDummyData) { + List paths = request.getPath() != null ? request.getPath() : new ArrayList<>(); + + Double startLat = null; + Double startLng = null; + + if (!paths.isEmpty()) { + Coordinate firstCoordinate = paths.get(0); + startLat = firstCoordinate.getLat(); + startLng = firstCoordinate.getLnt(); + } + + Difficulty difficulty = calculateDifficulty(paths, allowDummyData); + + return Course.builder() + .memberId(memberId) + .name(request.getName()) + .difficulty(difficulty) + .paths(paths) + .startLat(startLat) + .startLng(startLng) + .distance(request.getDistance()) + .round(request.getRound() != null ? request.getRound() : false) + .region(request.getRegion()) + .build(); + } + + private Difficulty calculateDifficulty(List paths, boolean allowDummyData) { + try { + if (paths == null || paths.isEmpty()) { + throw new InsufficientPathDataException(); + } + + GoogleMapsDTO.RouteAnalysisResult result = routeAnalysisService.analyzeRoute(paths); + return result.difficulty(); + } catch (InsufficientPathDataException e) { + if (allowDummyData) { + // TODO: 더미데이터 코스 추가하기 위해 코스가 하나만 있으면 난이도 쉬움으로 저장. 나중에 에러처리로 교체 해야함. + return Difficulty.EASY; + } else { + throw e; + } + } catch (Exception e) { + log.error("Failed to calculate difficulty: {}", e.getMessage(), e); + throw e; + } + } + + public void startRunningCourse(Long memberId, Long courseId) { + checkAlreadyRunning(memberId); + + courseRepository.findById(courseId) + .orElseThrow(() -> new CourseNotFoundException("Course not found with id: " + courseId)); + + try { + createRunningRecord(memberId, courseId); + } catch (Exception e) { + throw new CourseCreationFailedException(); + } + } + + @Transactional(readOnly = true) + public List getNearbyCourses(Long memberId, Double latitude, Double longitude, Integer limit) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new MemberNotFoundException("Member not found with id: " + memberId)); + + List cachedCourseIds = courseCacheService.getNearbyCourseIds(latitude, longitude); + if (cachedCourseIds != null) { + return getCachedNearbyCourses(cachedCourseIds, latitude, longitude, member.getRadius()); + } + + Integer radius = member.getRadius(); + + List courses = courseRepository.findNearbyCourses( + latitude, + longitude, + radius + ); + + List courseIds = courses.stream().map(Response::getId).toList(); + courseCacheService.cacheNearbyCourses(latitude, longitude, courseIds); + + return courses; + } + + private List getCachedNearbyCourses(List cachedCourseIds, Double latitude, Double longitude, Integer radius) { + List result = new ArrayList<>(); + + Map cachedDetails = courseCacheService.getMultipleCourseDetails(cachedCourseIds); + + for (Map.Entry entry : cachedDetails.entrySet()) { + Response response = convertDetailToResponse(entry.getValue(), latitude, longitude); + if (response.getDistanceFromUser() <= radius) { + result.add(response); + } + } + + List missedIds = cachedCourseIds.stream() + .filter(id -> !cachedDetails.containsKey(id)) + .toList(); + + if (!missedIds.isEmpty()) { + List courses = courseRepository.findAllById(missedIds); + for (Course course : courses) { + CourseDetailDTO detail = convertToCourseDetailDTO(course); + courseCacheService.cacheCourseDetail(course.getId(), detail); + + Response response = convertDetailToResponse(detail, latitude, longitude); + if (response.getDistanceFromUser() <= radius) { + result.add(response); + } + } + } + + return result; + } + + @Transactional(readOnly = true) + public CourseDetailDTO getCourseDetail(Long courseId) { + CourseDetailDTO cached = courseCacheService.getCourseDetail(courseId); + if (cached != null) { + return cached; + } + + Course course = courseRepository.findById(courseId) + .orElseThrow(() -> new CourseNotFoundException("Course not found with id: " + courseId)); + + CourseDetailDTO detail = convertToCourseDetailDTO(course); + + courseCacheService.cacheCourseDetail(courseId, detail); + + return detail; + } + + public CourseDetailDTO convertToCourseDetailDTO(Course course) { + return CourseDetailDTO.builder() + .id(course.getId()) + .memberId(course.getMemberId()) + .name(course.getName()) + .difficulty(course.getDifficulty()) + .points(course.getPaths()) + .startLat(course.getStartLat()) + .startLng(course.getStartLng()) + .distance(course.getDistance()) + .round(course.getRound()) + .region(course.getRegion()) + .createdAt(course.getCreatedAt()) + .updatedAt(course.getUpdatedAt()) + .build(); + } + + private Response convertDetailToResponse(CourseDetailDTO detail, Double userLat, Double userLng) { + double distanceFromUser = calculateDistance(userLat, userLng, detail.getStartLat(), detail.getStartLng()); + + return new Response( + detail.getId(), + detail.getMemberId(), + detail.getName(), + detail.getDifficulty(), + detail.getPoints(), + detail.getStartLat(), + detail.getStartLng(), + detail.getDistance(), + detail.getRound(), + detail.getRegion(), + distanceFromUser + ); + } + + private CourseDetailDTO convertResponseToCourseDetailDTO(Response response) { + return CourseDetailDTO.builder() + .id(response.getId()) + .memberId(response.getMemberId()) + .name(response.getName()) + .difficulty(response.getDifficulty()) + .points(response.getPoints()) + .startLat(response.getStartLat()) + .startLng(response.getStartLng()) + .distance(response.getDistance()) + .round(response.getRound()) + .region(response.getRegion()) + .build(); + } + + private double calculateDistance(Double lat1, Double lng1, Double lat2, Double lng2) { + if (lat1 == null || lng1 == null || lat2 == null || lng2 == null) { + return 0.0; + } + + return routeAnalysisService.haversine(lat1, lng1, lat2, lng2); + } + + @Transactional + public void finishRunning(Long memberId, FinishRunning finishRunning) { + // Todo: 나중에 런닝 상태 관리를 제대로 하고 싶으면 개인 러닝 상태관리 테이블을 하나 만들기 (findAllByMemberIdAndFinishedAtIsNull 대신에) + List activeRecords = recordRepository.findAllByMemberIdAndFinishedAtIsNull(memberId); + + if (activeRecords.isEmpty()) { + throw new RecordNotFoundException("No active running record found for member: " + memberId); + } + + if (activeRecords.size() > 1) { + throw new MultipleActiveRunningException("Multiple active running records found for member: " + memberId); + } + + RunningRecord existingRecord = activeRecords.get(0); + + LocalDateTime finishedAt = LocalDateTime.now(); + LocalDateTime startedAt = finishRunning.getStartedAt(); + + if (startedAt.isAfter(finishedAt)) { + throw new InvalidStartTimeException(); + } + + long runningTimeSeconds = Duration.between(startedAt, finishedAt).getSeconds(); + + // Base64 이미지를 URL로 변환 + if (finishRunning.getPhoto() != null) { + String photoUrl = imageUploadHelper.convertBase64ToUrlIfNeeded(finishRunning.getPhoto()); + finishRunning.setPhoto(photoUrl); + } + + Long courseId = existingRecord.getCourseId(); + + existingRecord.updateFinishRunning( + (int) runningTimeSeconds, + finishedAt, + finishRunning + ); + + recordRepository.save(existingRecord); + + if (courseId != null) { + Course course = courseRepository.findById(courseId) + .orElseThrow(() -> new CourseNotFoundForRecordException("Course not found with id: " + courseId)); + + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new MemberNotFoundException("Member not found with id: " + memberId)); + + double newTemperature = temperatureCalculationService.calculateNewTemperature( + member.getTemperature(), finishRunning.getDistance(), course.getDistance()); + + double roundedTemperature = Math.round(newTemperature * 10.0) / 10.0; + + member.updateTemperature(roundedTemperature); + memberRepository.save(member); + } + + try { + List memberCrew = crewMemberRepository.findByMemberIdAndStatus( + memberId, CrewMemberStatus.ACTIVE); + + if (!memberCrew.isEmpty()) { + Long crewId = memberCrew.get(0).getCrewId(); + LocalDate today = LocalDate.now(); + + crewRankingService.recalculateRanking(today); + crewMemberRankingService.recalculateCrewMemberRanking(crewId, today); + } + } catch (Exception e) { + log.warn("Failed to recalculate rankings after finishing running for member {}: {}", + memberId, e.getMessage()); + } + } + + private List getNearbyCoursesWithCache(Double latitude, Double longitude, Integer radius) { + List cachedCourseIds = courseCacheService.getNearbyCourseIds(latitude, longitude); + + if (cachedCourseIds != null) { + return getCachedNearbyCourses(cachedCourseIds, latitude, longitude, radius); + } + + List nearbyCourses = courseRepository.findNearbyCourses(latitude, longitude, radius); + + List courseIds = nearbyCourses.stream().map(Response::getId).toList(); + courseCacheService.cacheNearbyCourses(latitude, longitude, courseIds); + + for (Response course : nearbyCourses) { + CourseDetailDTO detail = convertResponseToCourseDetailDTO(course); + courseCacheService.cacheCourseDetail(course.getId(), detail); + } + + return nearbyCourses; + } + + @Transactional(readOnly = true) + public List getRecommendedCourses(Long memberId, Double latitude, Double longitude) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new MemberNotFoundException("Member not found with id: " + memberId)); + + List userRecords = recordRepository.findByMemberIdOrderByRunningTimeDesc(memberId); + + List nearbyCourses = getNearbyCoursesWithCache(latitude, longitude, member.getRadius()); + + Set courseIds = userRecords.stream() + .map(RunningRecord::getCourseId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + + Map courseMap = courseRepository.findAllById(courseIds).stream() + .collect(Collectors.toMap(Course::getId, Function.identity())); + + List userRecordDTOs = userRecords.stream() + .filter(record -> record.getCourseId() != null) + .map(record -> convertToUserRecordDTO(record, courseMap.get(record.getCourseId()))) + .filter(Objects::nonNull) + .toList(); + + List nearbyCourseDTOs = nearbyCourses.stream() + .map(this::convertToNearbyCourseDTO) + .toList(); + + RecommendationDTO.Request request = RecommendationDTO.Request.builder() + .userRecords(userRecordDTOs) + .nearbyCourses(nearbyCourseDTOs) + .build(); + + List recommendedCourseIds = callFastAPIRecordRecommendation(request); + + return getRecommendedCourseDetails(recommendedCourseIds); + } + + private List callFastAPIRecordRecommendation(RecommendationDTO.Request request) { + return callFastAPI("/recommend/record", request); + } + + private List callFastAPISettingRecommendation(RecommendationDTO.SettingRequest request) { + return callFastAPI("/recommend/setting", request); + } + + private List callFastAPI(String endpoint, Object request) { + try { + String rawResponse = fastAPIClient.post(endpoint, request, String.class); + + if (rawResponse == null || rawResponse.trim().isEmpty()) { + log.warn("Empty response from FastAPI {}", endpoint); + throw new NoRecommendedCoursesException(); + } + + List items = objectMapper.readValue( + rawResponse, + new TypeReference<>() {} + ); + + if (items == null || items.isEmpty()) { + log.warn("No items in FastAPI response from {}", endpoint); + throw new NoRecommendedCoursesException(); + } + return items.stream() + .map(RecommendationDTO.RecommendationItem::getCourseId) + .filter(Objects::nonNull) + .toList(); + + } catch (NoRecommendedCoursesException e) { + throw e; + } catch (Exception e) { + log.error("FastAPI {} request failed", endpoint, e); + throw new NoRecommendedCoursesException(); + } + } + + private RecommendationDTO.UserRecord convertToUserRecordDTO(RunningRecord record, Course course) { + if (course == null) return null; + + return RecommendationDTO.UserRecord.builder() + .courseId(record.getCourseId()) + .ranDistance(record.getDistance()) + .difficulty(course.getDifficulty().name()) + .latitude(course.getStartLat()) + .longitude(course.getStartLng()) + .build(); + } + + private RecommendationDTO.NearbyCourse convertToNearbyCourseDTO(Response course) { + return RecommendationDTO.NearbyCourse.builder() + .courseId(course.getId()) + .distance(course.getDistance()) + .difficulty(course.getDifficulty().name()) + .latitude(course.getStartLat()) + .longitude(course.getStartLng()) + .build(); + } + + @Transactional(readOnly = true) + public List getRecommendedCoursesBySetting(Long memberId, Double latitude, Double longitude) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new MemberNotFoundException("Member not found with id: " + memberId)); + + List nearbyCourses = getNearbyCoursesWithCache(latitude, longitude, member.getRadius()); + + List nearbyCourseDTOs = nearbyCourses.stream() + .map(this::convertToNearbyCourseDTO) + .toList(); + + RecommendationDTO.UserSetting userSetting = RecommendationDTO.UserSetting.builder() + .distance(member.getDailyDistanceGoal()) + .difficulty(member.getPreferredDifficulty()) + .build(); + + RecommendationDTO.SettingRequest request = RecommendationDTO.SettingRequest.builder() + .userSetting(userSetting) + .nearbyCourses(nearbyCourseDTOs) + .build(); + + List recommendedCourseIds = callFastAPISettingRecommendation(request); + + return getRecommendedCourseDetails(recommendedCourseIds); + } + + private List getRecommendedCourseDetails(List recommendedCourseIds) { + Map recommendedCourseMap = courseRepository.findAllById(recommendedCourseIds).stream() + .collect(Collectors.toMap(Course::getId, Function.identity())); + + return recommendedCourseIds.stream() + .map(recommendedCourseMap::get) + .filter(Objects::nonNull) + .map(this::convertToCourseDetailDTO) + .toList(); + } + + @Transactional + public void updateCourse(Long memberId, Long courseId, CourseUpdateDTO courseUpdateDTO) { + Course course = courseRepository.findById(courseId) + .orElseThrow(() -> new CourseNotFoundException("Course not found with id: " + courseId)); + + if (!course.getMemberId().equals(memberId)) { + throw new CourseUpdateForbiddenException("You do not have permission to update this course"); + } + + Difficulty difficulty = null; + if (courseUpdateDTO.getDifficulty() != null && !courseUpdateDTO.getDifficulty().trim().isEmpty()) { + try { + difficulty = Difficulty.valueOf(courseUpdateDTO.getDifficulty().toUpperCase()); + } catch (IllegalArgumentException e) { + throw new ValidationErrorException("Invalid difficulty value: " + courseUpdateDTO.getDifficulty()); + } + } + + course.updateCourse(courseUpdateDTO.getName(), difficulty); + Course updatedCourse = courseRepository.save(course); + + CourseDetailDTO updatedDetail = convertToCourseDetailDTO(updatedCourse); + courseCacheService.updateCourseDetail(courseId, updatedDetail); + } + + @Transactional + public void deleteCourse(Long memberId, Long courseId) { + Course course = courseRepository.findById(courseId) + .orElseThrow(() -> new CourseNotFoundException("Course not found with id: " + courseId)); + + if (!course.getMemberId().equals(memberId)) { + throw new CourseDeleteForbiddenException("You do not have permission to delete this course"); + } + + courseRepository.delete(course); + + courseCacheService.evictCourseDetail(courseId); + if (course.getStartLat() != null && course.getStartLng() != null) { + courseCacheService.removeCourseFromSlot(course.getStartLat(), course.getStartLng(), courseId); + } + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/course/service/RouteAnalysisService.java b/runtracker/src/main/java/com/runtracker/domain/course/service/RouteAnalysisService.java new file mode 100644 index 0000000..c02f2a0 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/course/service/RouteAnalysisService.java @@ -0,0 +1,140 @@ +package com.runtracker.domain.course.service; + +import com.runtracker.domain.course.service.dto.GoogleMapsDTO; +import com.runtracker.domain.course.entity.Course; +import com.runtracker.domain.course.enums.Difficulty; +import com.runtracker.domain.course.exception.GoogleMapsApiException; +import com.runtracker.domain.course.exception.InsufficientPathDataException; +import com.runtracker.domain.course.exception.NoPathDataException; +import com.runtracker.global.vo.Coordinate; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; +import java.util.List; +import java.util.stream.Collectors; + +@Slf4j +@Service +public class RouteAnalysisService { + + private final RestTemplate restTemplate = new RestTemplate(); + + @Value("${google.maps.api-key}") + private String apiKey; + + private static final String ELEVATION_API_URL = "https://maps.googleapis.com/maps/api/elevation/json?locations={locations}&key={apiKey}"; + + private static final double EASY_AVG_SLOPE_THRESHOLD = 3.0; + private static final double EASY_MAX_SLOPE_THRESHOLD = 7.0; + private static final double EASY_ELEVATION_THRESHOLD = 200.0; + private static final double MEDIUM_AVG_SLOPE_THRESHOLD = 6.0; + private static final double MEDIUM_MAX_SLOPE_THRESHOLD = 12.0; + private static final double MEDIUM_ELEVATION_THRESHOLD = 600.0; + + private static final int EARTH_RADIUS_M = 6371000; + private static final int MIN_ELEVATION_POINTS = 2; + + public GoogleMapsDTO.RouteAnalysisResult analyzeCourse(Course course) { + if (course.getPaths() == null || course.getPaths().isEmpty()) { + throw new NoPathDataException(); + } + + return analyzeRoute(course.getPaths()); + } + + public GoogleMapsDTO.RouteAnalysisResult analyzeRoute(List coords) { + if (coords == null || coords.isEmpty()) { + throw new NoPathDataException(); + } + + if (coords.size() < MIN_ELEVATION_POINTS) { + throw new InsufficientPathDataException(); + } + + String locationsParam = coords.stream() + .map(coord -> coord.getLat() + "," + coord.getLnt()) + .collect(Collectors.joining("|")); + + GoogleMapsDTO.ElevationResponse response; + try { + response = restTemplate.getForObject(ELEVATION_API_URL, GoogleMapsDTO.ElevationResponse.class, locationsParam, apiKey); + + if (response == null || !"OK".equals(response.status()) || + response.results() == null || response.results().size() < MIN_ELEVATION_POINTS) { + log.error("Google Maps API error - Status: {}, Results count: {}", + response != null ? response.status() : "null", + response != null && response.results() != null ? response.results().size() : "null"); + throw new GoogleMapsApiException(); + } + } catch (GoogleMapsApiException e) { + throw e; + } catch (Exception e) { + log.error("Google Maps API call failed", e); + throw new GoogleMapsApiException(); + } + + List elevationData = response.results(); + double totalDistance = 0; + double totalElevationGain = 0; + double maxSlope = 0; + + for (int i = 0; i < elevationData.size() - 1; i++) { + GoogleMapsDTO.ElevationResult start = elevationData.get(i); + GoogleMapsDTO.ElevationResult end = elevationData.get(i+1); + + if (start == null || end == null) { + log.error("Null elevation result at index {} or {}", i, i+1); + continue; + } + + if (start.location() == null || end.location() == null) { + log.error("Null location at index {} or {}", i, i+1); + continue; + } + + double distance = haversine(start.location().getLat(), start.location().getLnt(), end.location().getLat(), end.location().getLnt()); + if (distance == 0) { + log.warn("Zero distance calculated for segment {}", i + 1); + continue; + } + + totalDistance += distance; + double elevationChange = end.elevation() - start.elevation(); + + if (elevationChange > 0) { + totalElevationGain += elevationChange; + } + + double currentSlope = (elevationChange / distance) * 100; + if (currentSlope > maxSlope) { + maxSlope = currentSlope; + } + } + + double averageSlope = (totalDistance > 0) ? (totalElevationGain / totalDistance) * 100 : 0; + Difficulty difficulty = determineDifficulty(averageSlope, maxSlope, totalElevationGain); + + return new GoogleMapsDTO.RouteAnalysisResult(totalDistance, totalElevationGain, averageSlope, maxSlope, difficulty); + } + + private Difficulty determineDifficulty(double avgSlope, double maxSlope, double elevationGain) { + if (avgSlope < EASY_AVG_SLOPE_THRESHOLD && maxSlope < EASY_MAX_SLOPE_THRESHOLD && elevationGain < EASY_ELEVATION_THRESHOLD) { + return Difficulty.EASY; + } + if (avgSlope < MEDIUM_AVG_SLOPE_THRESHOLD && maxSlope < MEDIUM_MAX_SLOPE_THRESHOLD && elevationGain < MEDIUM_ELEVATION_THRESHOLD) { + return Difficulty.MEDIUM; + } + return Difficulty.HARD; + } + + public double haversine(double lat1, double lon1, double lat2, double lon2) { + double latDistance = Math.toRadians(lat2 - lat1); + double lonDistance = Math.toRadians(lon2 - lon1); + double a = Math.sin(latDistance / 2) * Math.sin(latDistance / 2) + + Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) + * Math.sin(lonDistance / 2) * Math.sin(lonDistance / 2); + double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return EARTH_RADIUS_M * c; + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/course/service/dto/GoogleMapsDTO.java b/runtracker/src/main/java/com/runtracker/domain/course/service/dto/GoogleMapsDTO.java new file mode 100644 index 0000000..fe839af --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/course/service/dto/GoogleMapsDTO.java @@ -0,0 +1,27 @@ +package com.runtracker.domain.course.service.dto; + +import com.runtracker.domain.course.enums.Difficulty; +import com.runtracker.global.vo.Coordinate; +import java.util.List; + +public class GoogleMapsDTO { + + public record ElevationResponse( + List results, + String status + ) {} + + public record ElevationResult( + double elevation, + Coordinate location, + double resolution + ) {} + + public record RouteAnalysisResult( + double totalDistanceM, + double totalElevationGainM, + double averageSlopePercent, + double maxSlopePercent, + Difficulty difficulty + ) {} +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/course/service/dto/RecommendationDTO.java b/runtracker/src/main/java/com/runtracker/domain/course/service/dto/RecommendationDTO.java new file mode 100644 index 0000000..2116567 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/course/service/dto/RecommendationDTO.java @@ -0,0 +1,107 @@ +package com.runtracker.domain.course.service.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.*; + +import java.util.List; + +public class RecommendationDTO { + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class Request { + @JsonProperty("user_records") + private List userRecords; + + @JsonProperty("nearby_courses") + private List nearbyCourses; + } + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class UserRecord { + @JsonProperty("course_id") + private Long courseId; + + @JsonProperty("ran_distance") + private Double ranDistance; + + @JsonProperty("difficulty") + private String difficulty; + + @JsonProperty("latitude") + private Double latitude; + + @JsonProperty("longitude") + private Double longitude; + } + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class NearbyCourse { + @JsonProperty("course_id") + private Long courseId; + + @JsonProperty("distance") + private Double distance; + + @JsonProperty("difficulty") + private String difficulty; + + @JsonProperty("latitude") + private Double latitude; + + @JsonProperty("longitude") + private Double longitude; + } + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class Response { + @JsonProperty("recommended_course_ids") + private List recommendedCourseIds; + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class RecommendationItem { + @JsonProperty("course_id") + private Long courseId; + + @JsonProperty("similarity") + private Double similarity; + } + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class SettingRequest { + @JsonProperty("user_setting") + private UserSetting userSetting; + + @JsonProperty("nearby_courses") + private List nearbyCourses; + } + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class UserSetting { + @JsonProperty("distance") + private Double distance; + + @JsonProperty("difficulty") + private String difficulty; + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/course/service/dto/SlotData.java b/runtracker/src/main/java/com/runtracker/domain/course/service/dto/SlotData.java new file mode 100644 index 0000000..da60a83 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/course/service/dto/SlotData.java @@ -0,0 +1,37 @@ +package com.runtracker.domain.course.service.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SlotData implements Serializable { + private String location; // 그룹 키 + @Builder.Default + private List courseIds = new ArrayList<>(); + private LocalDateTime lastAccessTime; + private Integer frequency; + + public void updateAccess() { + this.lastAccessTime = LocalDateTime.now(); + this.frequency = (this.frequency == null ? 0 : this.frequency) + 1; + } + + public void addCourseId(Long courseId) { + if (this.courseIds == null) { + this.courseIds = new ArrayList<>(); + } + if (!this.courseIds.contains(courseId)) { + this.courseIds.add(courseId); + } + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/crew/controller/CrewController.java b/runtracker/src/main/java/com/runtracker/domain/crew/controller/CrewController.java new file mode 100644 index 0000000..7f1ded5 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/crew/controller/CrewController.java @@ -0,0 +1,228 @@ +package com.runtracker.domain.crew.controller; + +import com.runtracker.domain.crew.dto.CrewApprovalDTO; +import com.runtracker.domain.crew.dto.CrewCreateDTO; +import com.runtracker.domain.crew.dto.CrewDetailDTO; +import com.runtracker.domain.crew.dto.CrewListDTO; +import com.runtracker.domain.crew.dto.CrewManagementDTO; +import com.runtracker.domain.crew.dto.CrewMemberUpdateDTO; +import com.runtracker.domain.crew.dto.CrewUpdateDTO; +import com.runtracker.domain.crew.dto.MemberProfileDTO; +import com.runtracker.domain.crew.dto.CrewRankingDTO; +import com.runtracker.domain.crew.dto.CrewMemberRankingDTO; +import com.runtracker.domain.crew.service.CrewService; +import com.runtracker.domain.crew.service.CrewRankingService; +import com.runtracker.domain.crew.service.CrewMemberRankingService; +import com.runtracker.global.response.ApiResponse; +import com.runtracker.global.security.UserDetailsImpl; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/crew") +public class CrewController { + + private final CrewService crewService; + private final CrewRankingService crewRankingService; + private final CrewMemberRankingService crewMemberRankingService; + + @PostMapping("/create") + public ApiResponse createCrew( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @RequestBody CrewCreateDTO.Request request) { + + crewService.createCrew(request, userDetails); + + return ApiResponse.ok(); + } + + @PostMapping("/join/{crewId}") + public ApiResponse applyToJoinCrew( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable Long crewId) { + + crewService.applyToJoinCrew(crewId, userDetails); + + return ApiResponse.ok(); + } + + @PostMapping("/join/cancel/{crewId}") + public ApiResponse cancelCrewApplication( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable Long crewId) { + + crewService.cancelCrewApplication(crewId, userDetails); + + return ApiResponse.ok(); + } + + @PostMapping("/approval/{crewId}") + public ApiResponse processJoinRequest( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable Long crewId, + @RequestBody CrewApprovalDTO.Request request) { + + crewService.processJoinRequest(crewId, request, userDetails); + + return ApiResponse.ok(); + } + + @PostMapping("/member/role/{crewId}") + public ApiResponse updateCrewMemberRole( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable Long crewId, + @RequestBody CrewMemberUpdateDTO.Request request) { + + crewService.updateCrewMemberRole(crewId, request, userDetails); + + return ApiResponse.ok(); + } + + @PatchMapping("/update/{crewId}") + public ApiResponse updateCrew( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable Long crewId, + @RequestBody CrewUpdateDTO.Request request) { + + crewService.updateCrew(crewId, request, userDetails); + + return ApiResponse.ok(); + } + + @DeleteMapping("/delete/{crewId}") + public ApiResponse deleteCrew( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable Long crewId) { + + crewService.deleteCrew(crewId, userDetails); + + return ApiResponse.ok(); + } + + @PostMapping("/ban/{crewId}") + public ApiResponse banCrewMember( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable Long crewId, + @RequestParam Long memberId) { + + crewService.banCrewMember(crewId, memberId, userDetails); + + return ApiResponse.ok(); + } + + @PostMapping("/leave/{crewId}") + public ApiResponse leaveCrew( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable Long crewId) { + + crewService.leaveCrew(crewId, userDetails); + + return ApiResponse.ok(); + } + + @GetMapping("/list") + public ApiResponse getCrewList( + @AuthenticationPrincipal UserDetailsImpl userDetails) { + + Long memberId = userDetails.getMemberId(); + CrewListDTO.ListResponse response = crewService.getAllCrews(); + return ApiResponse.ok(response); + } + + @GetMapping("/search") + public ApiResponse searchCrewsByName( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @RequestParam String name) { + + CrewListDTO.ListResponse response = crewService.searchCrewsByName(name); + return ApiResponse.ok(response); + } + + @GetMapping("/{crewId}") + public ApiResponse getCrewDetail( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable Long crewId) { + + Long memberId = userDetails.getMemberId(); + CrewDetailDTO.Response response = crewService.getCrewDetail(crewId); + return ApiResponse.ok(response); + } + + @GetMapping("/list/pending/{crewId}") + public ApiResponse getPendingMembers( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable Long crewId) { + + CrewManagementDTO.PendingMembersResponse response = crewService.getPendingMembers(crewId, userDetails); + + return ApiResponse.ok(response); + } + + @GetMapping("/list/banned/{crewId}") + public ApiResponse getBannedMembers( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable Long crewId) { + + CrewManagementDTO.BannedMembersResponse response = crewService.getBannedMembers(crewId, userDetails); + + return ApiResponse.ok(response); + } + + @GetMapping("/member/{memberId}") + public ApiResponse getMemberProfile( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable Long memberId) { + + MemberProfileDTO response = crewService.getMemberProfile(memberId, userDetails); + + return ApiResponse.ok(response); + } + + @GetMapping("/ranking/list") + public ApiResponse getCurrentRanking( + @AuthenticationPrincipal UserDetailsImpl userDetails) { + + LocalDate today = LocalDate.now(); + CrewRankingDTO.Response response = crewRankingService.getDailyRanking(today); + + return ApiResponse.ok(response); + } + + @PostMapping("/ranking/recalculate") + public ApiResponse recalculateRanking( + @AuthenticationPrincipal UserDetailsImpl userDetails) { + + LocalDate today = LocalDate.now(); + crewRankingService.recalculateRanking(today); + + return ApiResponse.ok(); + } + + @GetMapping("/{crewId}/member-ranking") + public ApiResponse getCrewMemberRanking( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable Long crewId) { + + LocalDate today = LocalDate.now(); + CrewMemberRankingDTO.Response response = crewMemberRankingService.getCrewMemberRanking(crewId, today); + + return ApiResponse.ok(response); + } + + @PostMapping("/{crewId}/member-ranking/recalculate") + public ApiResponse recalculateCrewMemberRanking( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable Long crewId) { + + LocalDate today = LocalDate.now(); + crewMemberRankingService.recalculateCrewMemberRanking(crewId, today); + + return ApiResponse.ok(); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/crew/dto/CrewApprovalDTO.java b/runtracker/src/main/java/com/runtracker/domain/crew/dto/CrewApprovalDTO.java new file mode 100644 index 0000000..0a25c2f --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/crew/dto/CrewApprovalDTO.java @@ -0,0 +1,15 @@ +package com.runtracker.domain.crew.dto; + +import lombok.*; + +public class CrewApprovalDTO { + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class Request { + private Long memberId; // 신청한 회원 ID + private Boolean approved; // 승인 유무 (true: 승인, false: 거절) + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/crew/dto/CrewCourseRecommendationDTO.java b/runtracker/src/main/java/com/runtracker/domain/crew/dto/CrewCourseRecommendationDTO.java new file mode 100644 index 0000000..143f164 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/crew/dto/CrewCourseRecommendationDTO.java @@ -0,0 +1,39 @@ +package com.runtracker.domain.crew.dto; + +import com.runtracker.domain.course.enums.Difficulty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +public class CrewCourseRecommendationDTO { + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class Request { + private String region; + private Double minDistance; + private Double maxDistance; + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class Response { + private Long courseId; + private String name; + private String region; + private Double distance; + private Difficulty difficulty; + private Double startLat; + private Double startLng; + private String photo; + private LocalDateTime createdAt; + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/crew/dto/CrewCreateDTO.java b/runtracker/src/main/java/com/runtracker/domain/crew/dto/CrewCreateDTO.java new file mode 100644 index 0000000..bb0ae2a --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/crew/dto/CrewCreateDTO.java @@ -0,0 +1,32 @@ +package com.runtracker.domain.crew.dto; + +import com.runtracker.domain.course.enums.Difficulty; +import com.runtracker.domain.crew.entity.Crew; +import lombok.*; + +public class CrewCreateDTO { + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class Request { + private String title; + private String photo; + private String introduce; + private String region; + private Difficulty difficulty; + + public Crew toEntity(Long leaderId) { + return Crew.builder() + .title(this.title) + .photo(this.photo) + .introduce(this.introduce) + .region(this.region) + .difficulty(this.difficulty) + .leaderId(leaderId) + .build(); + } + } + +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/crew/dto/CrewDetailDTO.java b/runtracker/src/main/java/com/runtracker/domain/crew/dto/CrewDetailDTO.java new file mode 100644 index 0000000..4b423c4 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/crew/dto/CrewDetailDTO.java @@ -0,0 +1,78 @@ +package com.runtracker.domain.crew.dto; + +import com.runtracker.domain.course.enums.Difficulty; +import com.runtracker.domain.crew.entity.Crew; +import com.runtracker.domain.crew.entity.CrewMember; +import com.runtracker.domain.crew.enums.CrewMemberStatus; +import com.runtracker.domain.member.entity.enums.MemberRole; +import lombok.*; + +import java.time.LocalDateTime; +import java.util.List; + +public class CrewDetailDTO { + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class MemberInfo { + private Long memberId; + private MemberRole role; + private CrewMemberStatus status; + private LocalDateTime joinedAt; + + public static MemberInfo from(CrewMember crewMember) { + return MemberInfo.builder() + .memberId(crewMember.getMemberId()) + .role(crewMember.getRole()) + .status(crewMember.getStatus()) + .joinedAt(crewMember.getCreatedAt()) + .build(); + } + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class Response { + private Long id; + private String title; + private String photo; + private String introduce; + private String region; + private Difficulty difficulty; + private String schedules; + private Long leaderId; + private Integer totalMemberCount; + private Integer activeMemberCount; + private List members; + private LocalDateTime createdAt; + + public static Response from(Crew crew, List allMembers) { + List activeMembers = allMembers.stream() + .filter(member -> member.getStatus() == CrewMemberStatus.ACTIVE) + .toList(); + + List memberInfos = activeMembers.stream() + .map(MemberInfo::from) + .toList(); + + return Response.builder() + .id(crew.getId()) + .title(crew.getTitle()) + .photo(crew.getPhoto()) + .introduce(crew.getIntroduce()) + .region(crew.getRegion()) + .difficulty(crew.getDifficulty()) + .schedules(crew.getSchedules()) + .leaderId(crew.getLeaderId()) + .totalMemberCount(allMembers.size()) + .activeMemberCount(activeMembers.size()) + .members(memberInfos) + .createdAt(crew.getCreatedAt()) + .build(); + } + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/crew/dto/CrewListDTO.java b/runtracker/src/main/java/com/runtracker/domain/crew/dto/CrewListDTO.java new file mode 100644 index 0000000..e9f3805 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/crew/dto/CrewListDTO.java @@ -0,0 +1,57 @@ +package com.runtracker.domain.crew.dto; + +import com.runtracker.domain.course.enums.Difficulty; +import com.runtracker.domain.crew.entity.Crew; +import lombok.*; + +import java.time.LocalDateTime; +import java.util.List; + +public class CrewListDTO { + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class Response { + private Long id; + private String title; + private String photo; + private String introduce; + private String region; + private Difficulty difficulty; + private Long leaderId; + private Integer memberCount; + private LocalDateTime createdAt; + + public static Response from(Crew crew, Integer memberCount) { + return Response.builder() + .id(crew.getId()) + .title(crew.getTitle()) + .photo(crew.getPhoto()) + .introduce(crew.getIntroduce()) + .region(crew.getRegion()) + .difficulty(crew.getDifficulty()) + .leaderId(crew.getLeaderId()) + .memberCount(memberCount) + .createdAt(crew.getCreatedAt()) + .build(); + } + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class ListResponse { + private List crews; + private Integer totalCount; + + public static ListResponse of(List crews) { + return ListResponse.builder() + .crews(crews) + .totalCount(crews.size()) + .build(); + } + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/crew/dto/CrewManagementDTO.java b/runtracker/src/main/java/com/runtracker/domain/crew/dto/CrewManagementDTO.java new file mode 100644 index 0000000..a9c37e8 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/crew/dto/CrewManagementDTO.java @@ -0,0 +1,70 @@ +package com.runtracker.domain.crew.dto; + +import com.runtracker.domain.crew.entity.CrewMember; +import com.runtracker.domain.crew.enums.CrewMemberStatus; +import com.runtracker.domain.member.entity.enums.MemberRole; +import lombok.*; + +import java.time.LocalDateTime; +import java.util.List; + +public class CrewManagementDTO { + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class MemberInfo { + private Long memberId; + private String name; + private Integer age; + private Boolean gender; + private MemberRole role; + private CrewMemberStatus status; + private LocalDateTime requestedAt; + + public static MemberInfo from(CrewMember crewMember, String name, Integer age, Boolean gender) { + return MemberInfo.builder() + .memberId(crewMember.getMemberId()) + .name(name) + .age(age) + .gender(gender) + .role(crewMember.getRole()) + .status(crewMember.getStatus()) + .requestedAt(crewMember.getCreatedAt()) + .build(); + } + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class PendingMembersResponse { + private List pendingMembers; + private Integer totalCount; + + public static PendingMembersResponse of(List pendingMembers) { + return PendingMembersResponse.builder() + .pendingMembers(pendingMembers) + .totalCount(pendingMembers.size()) + .build(); + } + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class BannedMembersResponse { + private List bannedMembers; + private Integer totalCount; + + public static BannedMembersResponse of(List bannedMembers) { + return BannedMembersResponse.builder() + .bannedMembers(bannedMembers) + .totalCount(bannedMembers.size()) + .build(); + } + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/crew/dto/CrewMemberRankingDTO.java b/runtracker/src/main/java/com/runtracker/domain/crew/dto/CrewMemberRankingDTO.java new file mode 100644 index 0000000..d02a56d --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/crew/dto/CrewMemberRankingDTO.java @@ -0,0 +1,39 @@ +package com.runtracker.domain.crew.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.runtracker.global.code.DateConstants; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +public class CrewMemberRankingDTO { + + @Getter + @Builder + public static class Response { + @JsonFormat(pattern = DateConstants.DATE_PATTERN) + private LocalDate date; + private Long crewId; + private String crewName; + private List rankings; + @JsonFormat(pattern = DateConstants.DATETIME_PATTERN) + private LocalDateTime lastUpdated; + } + + @Getter + @Builder + public static class MemberRankInfo { + private Long memberId; + private String memberName; + private String memberPhoto; + private Integer rank; + private Double totalDistance; + private Integer totalRunningTime; + private Double averageDistance; + private Integer averageRunningTime; + } + +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/crew/dto/CrewMemberUpdateDTO.java b/runtracker/src/main/java/com/runtracker/domain/crew/dto/CrewMemberUpdateDTO.java new file mode 100644 index 0000000..d818140 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/crew/dto/CrewMemberUpdateDTO.java @@ -0,0 +1,19 @@ +package com.runtracker.domain.crew.dto; + +import com.runtracker.domain.member.entity.enums.MemberRole; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +public class CrewMemberUpdateDTO { + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class Request { + private Long memberId; + private MemberRole role; + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/crew/dto/CrewRankingCacheDTO.java b/runtracker/src/main/java/com/runtracker/domain/crew/dto/CrewRankingCacheDTO.java new file mode 100644 index 0000000..5a8cf84 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/crew/dto/CrewRankingCacheDTO.java @@ -0,0 +1,11 @@ +package com.runtracker.domain.crew.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class CrewRankingCacheDTO { + private final Double totalDistance; + private final Integer totalRunningTime; +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/crew/dto/CrewRankingDTO.java b/runtracker/src/main/java/com/runtracker/domain/crew/dto/CrewRankingDTO.java new file mode 100644 index 0000000..34b5add --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/crew/dto/CrewRankingDTO.java @@ -0,0 +1,43 @@ +package com.runtracker.domain.crew.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.runtracker.domain.crew.entity.CrewRanking; +import com.runtracker.global.code.DateConstants; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +public class CrewRankingDTO { + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class CrewRankInfo { + private Long crewId; + private String crewName; + private String crewPhoto; + private Double totalDistance; + private Integer totalRunningTime; + private Integer rank; + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class Response { + @JsonFormat(pattern = DateConstants.DATE_PATTERN) + private LocalDate date; + private List rankings; + @JsonFormat(pattern = DateConstants.DATETIME_PATTERN) + private LocalDateTime lastUpdated; + + } + +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/crew/dto/CrewRankingData.java b/runtracker/src/main/java/com/runtracker/domain/crew/dto/CrewRankingData.java new file mode 100644 index 0000000..7ccdd9c --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/crew/dto/CrewRankingData.java @@ -0,0 +1,21 @@ +package com.runtracker.domain.crew.dto; + +import lombok.Getter; + +@Getter +public class CrewRankingData { + private final Long crewId; + private double totalDistance = 0.0; + private int totalRunningTime = 0; + private int participantCount = 0; + + public CrewRankingData(Long crewId) { + this.crewId = crewId; + } + + public void addRecord(double distance, int runningTime) { + this.totalDistance += distance; + this.totalRunningTime += runningTime; + this.participantCount++; + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/crew/dto/CrewUpdateDTO.java b/runtracker/src/main/java/com/runtracker/domain/crew/dto/CrewUpdateDTO.java new file mode 100644 index 0000000..80da53a --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/crew/dto/CrewUpdateDTO.java @@ -0,0 +1,22 @@ +package com.runtracker.domain.crew.dto; + +import com.runtracker.domain.course.enums.Difficulty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +public class CrewUpdateDTO { + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class Request { + private String title; + private String photo; + private String introduce; + private String region; + private Difficulty difficulty; + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/crew/dto/MemberProfileDTO.java b/runtracker/src/main/java/com/runtracker/domain/crew/dto/MemberProfileDTO.java new file mode 100644 index 0000000..131c7bd --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/crew/dto/MemberProfileDTO.java @@ -0,0 +1,36 @@ +package com.runtracker.domain.crew.dto; + +import com.runtracker.domain.member.entity.Member; +import lombok.*; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class MemberProfileDTO { + private Long memberId; + private String socialId; + private String photo; + private String name; + private String introduce; + private Integer age; + private Boolean gender; + private String region; + private String difficulty; + private Double temperature; + + public static MemberProfileDTO from(Member member) { + return MemberProfileDTO.builder() + .memberId(member.getId()) + .socialId(member.getSocialId()) + .photo(member.getPhoto()) + .name(member.getName()) + .introduce(member.getIntroduce()) + .age(member.getAge()) + .gender(member.getGender()) + .region(member.getRegion()) + .difficulty(member.getDifficulty()) + .temperature(member.getTemperature()) + .build(); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/crew/dto/MemberRankingData.java b/runtracker/src/main/java/com/runtracker/domain/crew/dto/MemberRankingData.java new file mode 100644 index 0000000..fed0d3a --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/crew/dto/MemberRankingData.java @@ -0,0 +1,21 @@ +package com.runtracker.domain.crew.dto; + +import lombok.Getter; + +@Getter +public class MemberRankingData { + private final Long memberId; + private double totalDistance = 0.0; + private int totalRunningTime = 0; + private int participationCount = 0; + + public MemberRankingData(Long memberId) { + this.memberId = memberId; + } + + public void addRecord(double distance, int runningTime) { + this.totalDistance += distance; + this.totalRunningTime += runningTime; + this.participationCount++; + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/crew/entity/Crew.java b/runtracker/src/main/java/com/runtracker/domain/crew/entity/Crew.java new file mode 100644 index 0000000..fef5a44 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/crew/entity/Crew.java @@ -0,0 +1,61 @@ +package com.runtracker.domain.crew.entity; + +import com.runtracker.domain.course.enums.Difficulty; +import com.runtracker.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "crew") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class Crew extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "title", length = 100, nullable = false) + private String title; + + @Column(name = "photo", length = 255) + private String photo; + + @Column(name = "introduce", columnDefinition = "TEXT") + private String introduce; + + @Column(name = "region", length = 100) + private String region; + + @Enumerated(EnumType.STRING) + @Column(name = "difficulty", length = 20) + private Difficulty difficulty; + + @Column(name = "schedules", columnDefinition = "JSON") + private String schedules; + + @Column(name = "leader_id", nullable = false) + private Long leaderId; + + public void updateTitle(String title) { + this.title = title; + } + + public void updatePhoto(String photo) { + this.photo = photo; + } + + public void updateIntroduce(String introduce) { + this.introduce = introduce; + } + + public void updateRegion(String region) { + this.region = region; + } + + public void updateDifficulty(Difficulty difficulty) { + this.difficulty = difficulty; + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/crew/entity/CrewMember.java b/runtracker/src/main/java/com/runtracker/domain/crew/entity/CrewMember.java new file mode 100644 index 0000000..4735934 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/crew/entity/CrewMember.java @@ -0,0 +1,52 @@ +package com.runtracker.domain.crew.entity; + +import com.runtracker.domain.crew.enums.CrewMemberStatus; +import com.runtracker.domain.member.entity.enums.MemberRole; +import com.runtracker.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "crew_member") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class CrewMember extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "crew_id", nullable = false) + private Long crewId; + + @Column(name = "member_id", nullable = false) + private Long memberId; + + @Enumerated(EnumType.STRING) + @Column(name = "role", nullable = false, length = 20) + private MemberRole role; + + @Enumerated(EnumType.STRING) + @Column(name = "status", columnDefinition = "VARCHAR(20) DEFAULT 'ACTIVE'") + @Builder.Default + private CrewMemberStatus status = CrewMemberStatus.ACTIVE; + + public void approve() { + this.status = CrewMemberStatus.ACTIVE; + this.role = MemberRole.CREW_MEMBER; + } + + public void updateRole(MemberRole role) { + this.role = role; + } + + public void ban() { + this.status = CrewMemberStatus.BANNED; + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/crew/entity/CrewMemberRanking.java b/runtracker/src/main/java/com/runtracker/domain/crew/entity/CrewMemberRanking.java new file mode 100644 index 0000000..040b50a --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/crew/entity/CrewMemberRanking.java @@ -0,0 +1,70 @@ +package com.runtracker.domain.crew.entity; + +import com.runtracker.global.entity.BaseEntity; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import jakarta.persistence.*; +import java.time.LocalDate; + +@Entity +@Table(name = "crew_member_ranking") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class CrewMemberRanking extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "date", nullable = false) + private LocalDate date; + + @Column(name = "crew_id", nullable = false) + private Long crewId; + + @Column(name = "member_id", nullable = false) + private Long memberId; + + @Column(name = "rank_position", nullable = false) + private Integer rankPosition; + + @Column(name = "total_distance", nullable = false) + private Double totalDistance; + + @Column(name = "total_running_time", nullable = false) + private Integer totalRunningTime; + + @Column(name = "participation_count", nullable = false) + private Integer participationCount; + + @Builder + public CrewMemberRanking(LocalDate date, Long crewId, Long memberId, Integer rankPosition, + Double totalDistance, Integer totalRunningTime, Integer participationCount) { + this.date = date; + this.crewId = crewId; + this.memberId = memberId; + this.rankPosition = rankPosition; + this.totalDistance = totalDistance; + this.totalRunningTime = totalRunningTime; + this.participationCount = participationCount; + } + + public void updateRankPosition(Integer rankPosition) { + this.rankPosition = rankPosition; + } + + public void addRunningRecord(Double distance, Integer runningTime) { + this.totalDistance += distance; + this.totalRunningTime += runningTime; + this.participationCount += 1; + } + + public void updateTotalData(Double totalDistance, Integer totalRunningTime, Integer participationCount) { + this.totalDistance = totalDistance; + this.totalRunningTime = totalRunningTime; + this.participationCount = participationCount; + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/crew/entity/CrewRanking.java b/runtracker/src/main/java/com/runtracker/domain/crew/entity/CrewRanking.java new file mode 100644 index 0000000..69f421f --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/crew/entity/CrewRanking.java @@ -0,0 +1,54 @@ +package com.runtracker.domain.crew.entity; + +import com.runtracker.global.entity.BaseEntity; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import jakarta.persistence.*; +import java.time.LocalDate; + +@Entity +@Table(name = "crew_ranking") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Builder +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class CrewRanking extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "date", nullable = false) + private LocalDate date; + + @Column(name = "crew_id", nullable = false) + private Long crewId; + + @Column(name = "rank_position", nullable = false) + private Integer rankPosition; + + @Column(name = "total_distance", nullable = false) + private Double totalDistance; + + @Column(name = "total_running_time", nullable = false) + private Integer totalRunningTime; + + @Column(name = "participant_count", nullable = false) + private Integer participantCount; + + public void updateRankPosition(Integer rankPosition) { + this.rankPosition = rankPosition; + } + + public void updateTotalDistance(Double totalDistance) { + this.totalDistance = totalDistance; + } + + public void updateTotalRunningTime(Integer totalRunningTime) { + this.totalRunningTime = totalRunningTime; + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/crew/enums/CrewErrorCode.java b/runtracker/src/main/java/com/runtracker/domain/crew/enums/CrewErrorCode.java new file mode 100644 index 0000000..8f3ed4a --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/crew/enums/CrewErrorCode.java @@ -0,0 +1,34 @@ +package com.runtracker.domain.crew.enums; + +import com.runtracker.global.code.ResponseCode; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum CrewErrorCode implements ResponseCode { + + MEMBER_NOT_FOUND("CR001", "Member not found"), + CREW_ALREADY_EXISTS("CR002", "Crew already exists"), + CREW_NOT_FOUND("CR003", "Crew not found"), + CREW_MEMBER_NOT_FOUND("CR004", "Crew member not found"), + ALREADY_CREW_MEMBER("CR005", "Already a crew member"), + CREW_APPLICATION_PENDING("CR006", "Crew application is pending"), + NO_PENDING_APPLICATION("CR007", "No pending application to cancel"), + NOT_CREW_LEADER("CR008", "No crew management permission"), + APPLICANT_NOT_FOUND("CR009", "Applicant not found"), + CANNOT_MODIFY_LEADER_ROLE("CR010", "Cannot modify leader role"), + SAME_ROLE_UPDATE("CR011", "Already has the same role"), + INVALID_CREW_ROLE("CR012", "Invalid crew role"), + ALREADY_JOINED_OTHER_CREW("CR013", "Already joined another crew"), + CANNOT_KICK_CREW_LEADER("CR014", "Cannot kick crew leader"), + CANNOT_KICK_YOURSELF("CR015", "Cannot kick yourself"), + CANNOT_KICK_MANAGER_AS_MANAGER("CR016", "Manager cannot kick another manager"), + BANNED_FROM_CREW("CR017", "Banned from crew"), + CANNOT_LEAVE_AS_CREW_LEADER("CR018", "Crew leader cannot leave crew"), + UNAUTHORIZED_CREW_ACCESS("CR019", "Unauthorized crew access"), + CREW_SEARCH_RESULT_NOT_FOUND("CR020", "No crews found matching the search criteria"); + + private final String statusCode; + private final String message; +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/crew/enums/CrewMemberStatus.java b/runtracker/src/main/java/com/runtracker/domain/crew/enums/CrewMemberStatus.java new file mode 100644 index 0000000..f3b7e80 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/crew/enums/CrewMemberStatus.java @@ -0,0 +1,18 @@ +package com.runtracker.domain.crew.enums; + +public enum CrewMemberStatus { + ACTIVE("활성화"), + PENDING("승인 대기"), + BANNED("차단"), + WITHDRAWN("탈퇴"); + + private final String description; + + CrewMemberStatus(String description) { + this.description = description; + } + + public String getDescription() { + return description; + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/crew/event/CrewBanEvent.java b/runtracker/src/main/java/com/runtracker/domain/crew/event/CrewBanEvent.java new file mode 100644 index 0000000..7874f97 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/crew/event/CrewBanEvent.java @@ -0,0 +1,4 @@ +package com.runtracker.domain.crew.event; + +public record CrewBanEvent(Long bannedMemberId, String crewTitle) { +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/crew/event/CrewDeleteEvent.java b/runtracker/src/main/java/com/runtracker/domain/crew/event/CrewDeleteEvent.java new file mode 100644 index 0000000..93ee600 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/crew/event/CrewDeleteEvent.java @@ -0,0 +1,6 @@ +package com.runtracker.domain.crew.event; + +import java.util.List; + +public record CrewDeleteEvent(List memberIds, String crewTitle) { +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/crew/event/CrewJoinRequestApprovalEvent.java b/runtracker/src/main/java/com/runtracker/domain/crew/event/CrewJoinRequestApprovalEvent.java new file mode 100644 index 0000000..3e90574 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/crew/event/CrewJoinRequestApprovalEvent.java @@ -0,0 +1,4 @@ +package com.runtracker.domain.crew.event; + +public record CrewJoinRequestApprovalEvent(Long approvedUserId, Long crewId, boolean isApproved) { +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/crew/event/CrewJoinRequestCancelEvent.java b/runtracker/src/main/java/com/runtracker/domain/crew/event/CrewJoinRequestCancelEvent.java new file mode 100644 index 0000000..7ceb6c0 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/crew/event/CrewJoinRequestCancelEvent.java @@ -0,0 +1,4 @@ +package com.runtracker.domain.crew.event; + +public record CrewJoinRequestCancelEvent(Long canceledUserId, Long managerId, Long crewId) { +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/crew/event/CrewJoinRequestEvent.java b/runtracker/src/main/java/com/runtracker/domain/crew/event/CrewJoinRequestEvent.java new file mode 100644 index 0000000..ea2ca33 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/crew/event/CrewJoinRequestEvent.java @@ -0,0 +1,4 @@ +package com.runtracker.domain.crew.event; + +public record CrewJoinRequestEvent(Long requestUserId, Long managerId, Long crewId) { +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/crew/event/CrewLeaveEvent.java b/runtracker/src/main/java/com/runtracker/domain/crew/event/CrewLeaveEvent.java new file mode 100644 index 0000000..e10cc3c --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/crew/event/CrewLeaveEvent.java @@ -0,0 +1,6 @@ +package com.runtracker.domain.crew.event; + +import java.util.List; + +public record CrewLeaveEvent(List managerIds, String leavingMemberName) { +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/crew/event/CrewMemberRoleUpdateEvent.java b/runtracker/src/main/java/com/runtracker/domain/crew/event/CrewMemberRoleUpdateEvent.java new file mode 100644 index 0000000..ca749c4 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/crew/event/CrewMemberRoleUpdateEvent.java @@ -0,0 +1,6 @@ +package com.runtracker.domain.crew.event; + +import com.runtracker.domain.member.entity.enums.MemberRole; + +public record CrewMemberRoleUpdateEvent(Long targetMemberId, Long crewId, MemberRole newRole) { +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/crew/exception/AlreadyCrewMemberException.java b/runtracker/src/main/java/com/runtracker/domain/crew/exception/AlreadyCrewMemberException.java new file mode 100644 index 0000000..fbed6ba --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/crew/exception/AlreadyCrewMemberException.java @@ -0,0 +1,14 @@ +package com.runtracker.domain.crew.exception; + +import com.runtracker.domain.crew.enums.CrewErrorCode; +import com.runtracker.global.exception.CustomException; + +public class AlreadyCrewMemberException extends CustomException { + public AlreadyCrewMemberException() { + super(CrewErrorCode.ALREADY_CREW_MEMBER); + } + + public AlreadyCrewMemberException(String message) { + super(CrewErrorCode.ALREADY_CREW_MEMBER, message); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/crew/exception/AlreadyJoinedOtherCrewException.java b/runtracker/src/main/java/com/runtracker/domain/crew/exception/AlreadyJoinedOtherCrewException.java new file mode 100644 index 0000000..7dd5268 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/crew/exception/AlreadyJoinedOtherCrewException.java @@ -0,0 +1,10 @@ +package com.runtracker.domain.crew.exception; + +import com.runtracker.domain.crew.enums.CrewErrorCode; +import com.runtracker.global.exception.CustomException; + +public class AlreadyJoinedOtherCrewException extends CustomException { + public AlreadyJoinedOtherCrewException() { + super(CrewErrorCode.ALREADY_JOINED_OTHER_CREW); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/crew/exception/ApplicantNotFoundException.java b/runtracker/src/main/java/com/runtracker/domain/crew/exception/ApplicantNotFoundException.java new file mode 100644 index 0000000..b701891 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/crew/exception/ApplicantNotFoundException.java @@ -0,0 +1,14 @@ +package com.runtracker.domain.crew.exception; + +import com.runtracker.domain.crew.enums.CrewErrorCode; +import com.runtracker.global.exception.CustomException; + +public class ApplicantNotFoundException extends CustomException { + public ApplicantNotFoundException() { + super(CrewErrorCode.APPLICANT_NOT_FOUND); + } + + public ApplicantNotFoundException(String message) { + super(CrewErrorCode.APPLICANT_NOT_FOUND, message); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/crew/exception/BannedFromCrewException.java b/runtracker/src/main/java/com/runtracker/domain/crew/exception/BannedFromCrewException.java new file mode 100644 index 0000000..5e73a63 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/crew/exception/BannedFromCrewException.java @@ -0,0 +1,10 @@ +package com.runtracker.domain.crew.exception; + +import com.runtracker.domain.crew.enums.CrewErrorCode; +import com.runtracker.global.exception.CustomException; + +public class BannedFromCrewException extends CustomException { + public BannedFromCrewException() { + super(CrewErrorCode.BANNED_FROM_CREW); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/crew/exception/CannotKickCrewLeaderException.java b/runtracker/src/main/java/com/runtracker/domain/crew/exception/CannotKickCrewLeaderException.java new file mode 100644 index 0000000..d54853a --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/crew/exception/CannotKickCrewLeaderException.java @@ -0,0 +1,10 @@ +package com.runtracker.domain.crew.exception; + +import com.runtracker.domain.crew.enums.CrewErrorCode; +import com.runtracker.global.exception.CustomException; + +public class CannotKickCrewLeaderException extends CustomException { + public CannotKickCrewLeaderException() { + super(CrewErrorCode.CANNOT_KICK_CREW_LEADER); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/crew/exception/CannotKickManagerAsManagerException.java b/runtracker/src/main/java/com/runtracker/domain/crew/exception/CannotKickManagerAsManagerException.java new file mode 100644 index 0000000..5bc4361 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/crew/exception/CannotKickManagerAsManagerException.java @@ -0,0 +1,10 @@ +package com.runtracker.domain.crew.exception; + +import com.runtracker.domain.crew.enums.CrewErrorCode; +import com.runtracker.global.exception.CustomException; + +public class CannotKickManagerAsManagerException extends CustomException { + public CannotKickManagerAsManagerException() { + super(CrewErrorCode.CANNOT_KICK_MANAGER_AS_MANAGER); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/crew/exception/CannotKickYourselfException.java b/runtracker/src/main/java/com/runtracker/domain/crew/exception/CannotKickYourselfException.java new file mode 100644 index 0000000..17f3ba8 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/crew/exception/CannotKickYourselfException.java @@ -0,0 +1,10 @@ +package com.runtracker.domain.crew.exception; + +import com.runtracker.domain.crew.enums.CrewErrorCode; +import com.runtracker.global.exception.CustomException; + +public class CannotKickYourselfException extends CustomException { + public CannotKickYourselfException() { + super(CrewErrorCode.CANNOT_KICK_YOURSELF); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/crew/exception/CannotLeaveAsCrewLeaderException.java b/runtracker/src/main/java/com/runtracker/domain/crew/exception/CannotLeaveAsCrewLeaderException.java new file mode 100644 index 0000000..355e627 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/crew/exception/CannotLeaveAsCrewLeaderException.java @@ -0,0 +1,14 @@ +package com.runtracker.domain.crew.exception; + +import com.runtracker.domain.crew.enums.CrewErrorCode; +import com.runtracker.global.exception.CustomException; + +public class CannotLeaveAsCrewLeaderException extends CustomException { + public CannotLeaveAsCrewLeaderException() { + super(CrewErrorCode.CANNOT_LEAVE_AS_CREW_LEADER); + } + + public CannotLeaveAsCrewLeaderException(String message) { + super(CrewErrorCode.CANNOT_LEAVE_AS_CREW_LEADER, message); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/crew/exception/CannotModifyLeaderRoleException.java b/runtracker/src/main/java/com/runtracker/domain/crew/exception/CannotModifyLeaderRoleException.java new file mode 100644 index 0000000..7deb29c --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/crew/exception/CannotModifyLeaderRoleException.java @@ -0,0 +1,10 @@ +package com.runtracker.domain.crew.exception; + +import com.runtracker.domain.crew.enums.CrewErrorCode; +import com.runtracker.global.exception.CustomException; + +public class CannotModifyLeaderRoleException extends CustomException { + public CannotModifyLeaderRoleException() { + super(CrewErrorCode.CANNOT_MODIFY_LEADER_ROLE); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/crew/exception/CrewAlreadyExistsException.java b/runtracker/src/main/java/com/runtracker/domain/crew/exception/CrewAlreadyExistsException.java new file mode 100644 index 0000000..076e0c4 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/crew/exception/CrewAlreadyExistsException.java @@ -0,0 +1,14 @@ +package com.runtracker.domain.crew.exception; + +import com.runtracker.domain.crew.enums.CrewErrorCode; +import com.runtracker.global.exception.CustomException; + +public class CrewAlreadyExistsException extends CustomException { + public CrewAlreadyExistsException() { + super(CrewErrorCode.CREW_ALREADY_EXISTS); + } + + public CrewAlreadyExistsException(String message) { + super(CrewErrorCode.CREW_ALREADY_EXISTS, message); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/crew/exception/CrewApplicationPendingException.java b/runtracker/src/main/java/com/runtracker/domain/crew/exception/CrewApplicationPendingException.java new file mode 100644 index 0000000..6582200 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/crew/exception/CrewApplicationPendingException.java @@ -0,0 +1,14 @@ +package com.runtracker.domain.crew.exception; + +import com.runtracker.domain.crew.enums.CrewErrorCode; +import com.runtracker.global.exception.CustomException; + +public class CrewApplicationPendingException extends CustomException { + public CrewApplicationPendingException() { + super(CrewErrorCode.CREW_APPLICATION_PENDING); + } + + public CrewApplicationPendingException(String message) { + super(CrewErrorCode.CREW_APPLICATION_PENDING, message); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/crew/exception/CrewMemberNotFoundException.java b/runtracker/src/main/java/com/runtracker/domain/crew/exception/CrewMemberNotFoundException.java new file mode 100644 index 0000000..dd0e9d1 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/crew/exception/CrewMemberNotFoundException.java @@ -0,0 +1,14 @@ +package com.runtracker.domain.crew.exception; + +import com.runtracker.domain.crew.enums.CrewErrorCode; +import com.runtracker.global.exception.CustomException; + +public class CrewMemberNotFoundException extends CustomException { + public CrewMemberNotFoundException() { + super(CrewErrorCode.CREW_MEMBER_NOT_FOUND); + } + + public CrewMemberNotFoundException(String message) { + super(CrewErrorCode.CREW_MEMBER_NOT_FOUND, message); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/crew/exception/CrewNotFoundException.java b/runtracker/src/main/java/com/runtracker/domain/crew/exception/CrewNotFoundException.java new file mode 100644 index 0000000..0d5dc2e --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/crew/exception/CrewNotFoundException.java @@ -0,0 +1,14 @@ +package com.runtracker.domain.crew.exception; + +import com.runtracker.domain.crew.enums.CrewErrorCode; +import com.runtracker.global.exception.CustomException; + +public class CrewNotFoundException extends CustomException { + public CrewNotFoundException() { + super(CrewErrorCode.CREW_NOT_FOUND); + } + + public CrewNotFoundException(String message) { + super(CrewErrorCode.CREW_NOT_FOUND, message); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/crew/exception/CrewSearchResultNotFoundException.java b/runtracker/src/main/java/com/runtracker/domain/crew/exception/CrewSearchResultNotFoundException.java new file mode 100644 index 0000000..ba7f987 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/crew/exception/CrewSearchResultNotFoundException.java @@ -0,0 +1,10 @@ +package com.runtracker.domain.crew.exception; + +import com.runtracker.domain.crew.enums.CrewErrorCode; +import com.runtracker.global.exception.CustomException; + +public class CrewSearchResultNotFoundException extends CustomException { + public CrewSearchResultNotFoundException() { + super(CrewErrorCode.CREW_SEARCH_RESULT_NOT_FOUND); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/crew/exception/InvalidCrewRoleException.java b/runtracker/src/main/java/com/runtracker/domain/crew/exception/InvalidCrewRoleException.java new file mode 100644 index 0000000..b00a2cf --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/crew/exception/InvalidCrewRoleException.java @@ -0,0 +1,10 @@ +package com.runtracker.domain.crew.exception; + +import com.runtracker.domain.crew.enums.CrewErrorCode; +import com.runtracker.global.exception.CustomException; + +public class InvalidCrewRoleException extends CustomException { + public InvalidCrewRoleException() { + super(CrewErrorCode.INVALID_CREW_ROLE); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/crew/exception/MemberNotFoundException.java b/runtracker/src/main/java/com/runtracker/domain/crew/exception/MemberNotFoundException.java new file mode 100644 index 0000000..bbd201c --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/crew/exception/MemberNotFoundException.java @@ -0,0 +1,14 @@ +package com.runtracker.domain.crew.exception; + +import com.runtracker.domain.crew.enums.CrewErrorCode; +import com.runtracker.global.exception.CustomException; + +public class MemberNotFoundException extends CustomException { + public MemberNotFoundException() { + super(CrewErrorCode.MEMBER_NOT_FOUND); + } + + public MemberNotFoundException(String message) { + super(CrewErrorCode.MEMBER_NOT_FOUND, message); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/crew/exception/NoPendingApplicationException.java b/runtracker/src/main/java/com/runtracker/domain/crew/exception/NoPendingApplicationException.java new file mode 100644 index 0000000..5204bbc --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/crew/exception/NoPendingApplicationException.java @@ -0,0 +1,14 @@ +package com.runtracker.domain.crew.exception; + +import com.runtracker.domain.crew.enums.CrewErrorCode; +import com.runtracker.global.exception.CustomException; + +public class NoPendingApplicationException extends CustomException { + public NoPendingApplicationException() { + super(CrewErrorCode.NO_PENDING_APPLICATION); + } + + public NoPendingApplicationException(String message) { + super(CrewErrorCode.NO_PENDING_APPLICATION, message); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/crew/exception/NotCrewLeaderException.java b/runtracker/src/main/java/com/runtracker/domain/crew/exception/NotCrewLeaderException.java new file mode 100644 index 0000000..9e64c88 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/crew/exception/NotCrewLeaderException.java @@ -0,0 +1,14 @@ +package com.runtracker.domain.crew.exception; + +import com.runtracker.domain.crew.enums.CrewErrorCode; +import com.runtracker.global.exception.CustomException; + +public class NotCrewLeaderException extends CustomException { + public NotCrewLeaderException() { + super(CrewErrorCode.NOT_CREW_LEADER); + } + + public NotCrewLeaderException(String message) { + super(CrewErrorCode.NOT_CREW_LEADER, message); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/crew/exception/SameRoleUpdateException.java b/runtracker/src/main/java/com/runtracker/domain/crew/exception/SameRoleUpdateException.java new file mode 100644 index 0000000..a6c091c --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/crew/exception/SameRoleUpdateException.java @@ -0,0 +1,10 @@ +package com.runtracker.domain.crew.exception; + +import com.runtracker.domain.crew.enums.CrewErrorCode; +import com.runtracker.global.exception.CustomException; + +public class SameRoleUpdateException extends CustomException { + public SameRoleUpdateException() { + super(CrewErrorCode.SAME_ROLE_UPDATE); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/crew/exception/UnauthorizedCrewAccessException.java b/runtracker/src/main/java/com/runtracker/domain/crew/exception/UnauthorizedCrewAccessException.java new file mode 100644 index 0000000..b769e8d --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/crew/exception/UnauthorizedCrewAccessException.java @@ -0,0 +1,11 @@ +package com.runtracker.domain.crew.exception; + +import com.runtracker.domain.crew.enums.CrewErrorCode; +import com.runtracker.global.exception.CustomException; + +public class UnauthorizedCrewAccessException extends CustomException { + + public UnauthorizedCrewAccessException() { + super(CrewErrorCode.UNAUTHORIZED_CREW_ACCESS); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/crew/repository/CrewMemberRankingRepository.java b/runtracker/src/main/java/com/runtracker/domain/crew/repository/CrewMemberRankingRepository.java new file mode 100644 index 0000000..63f3ebe --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/crew/repository/CrewMemberRankingRepository.java @@ -0,0 +1,18 @@ +package com.runtracker.domain.crew.repository; + +import com.runtracker.domain.crew.entity.CrewMemberRanking; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +@Repository +public interface CrewMemberRankingRepository extends JpaRepository { + List findByCrewIdAndDateOrderByRankPosition(Long crewId, LocalDate date); + + void deleteByMemberId(Long memberId); +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/crew/repository/CrewMemberRepository.java b/runtracker/src/main/java/com/runtracker/domain/crew/repository/CrewMemberRepository.java new file mode 100644 index 0000000..fdaddea --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/crew/repository/CrewMemberRepository.java @@ -0,0 +1,25 @@ +package com.runtracker.domain.crew.repository; + +import com.runtracker.domain.crew.entity.CrewMember; +import com.runtracker.domain.crew.enums.CrewMemberStatus; +import com.runtracker.domain.member.entity.enums.MemberRole; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface CrewMemberRepository extends JpaRepository { + Optional findByCrewIdAndMemberId(Long crewId, Long memberId); + List findByCrewId(Long crewId); + List findByMemberIdAndStatus(Long memberId, CrewMemberStatus status); + List findByCrewIdAndRoleIn(Long crewId, List roles); + + @Query("SELECT cm.memberId FROM CrewMember cm WHERE cm.crewId = :crewId AND cm.status != 'BANNED'") + List findMemberIdsByCrewId(@Param("crewId") Long crewId); + + void deleteByMemberId(Long memberId); +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/crew/repository/CrewRankingRepository.java b/runtracker/src/main/java/com/runtracker/domain/crew/repository/CrewRankingRepository.java new file mode 100644 index 0000000..835be73 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/crew/repository/CrewRankingRepository.java @@ -0,0 +1,16 @@ +package com.runtracker.domain.crew.repository; + +import com.runtracker.domain.crew.entity.CrewRanking; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +@Repository +public interface CrewRankingRepository extends JpaRepository { + List findByDateOrderByRankPosition(LocalDate date); +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/crew/repository/CrewRepository.java b/runtracker/src/main/java/com/runtracker/domain/crew/repository/CrewRepository.java new file mode 100644 index 0000000..6ffa6b3 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/crew/repository/CrewRepository.java @@ -0,0 +1,15 @@ +package com.runtracker.domain.crew.repository; + +import com.runtracker.domain.crew.entity.Crew; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface CrewRepository extends JpaRepository { + List findByLeaderId(Long leaderId); + List findByTitleContainingIgnoreCase(String title); + + void deleteByLeaderId(Long leaderId); +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/crew/service/CrewMemberRankingService.java b/runtracker/src/main/java/com/runtracker/domain/crew/service/CrewMemberRankingService.java new file mode 100644 index 0000000..71ecb01 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/crew/service/CrewMemberRankingService.java @@ -0,0 +1,218 @@ +package com.runtracker.domain.crew.service; + +import com.runtracker.domain.crew.dto.CrewMemberRankingDTO; +import com.runtracker.domain.crew.dto.MemberRankingData; +import com.runtracker.domain.crew.entity.Crew; +import com.runtracker.domain.crew.entity.CrewMember; +import com.runtracker.domain.crew.entity.CrewMemberRanking; +import com.runtracker.domain.crew.exception.CrewNotFoundException; +import com.runtracker.domain.crew.repository.*; +import com.runtracker.domain.member.entity.Member; +import com.runtracker.domain.member.repository.MemberRepository; +import com.runtracker.domain.record.entity.RunningRecord; +import com.runtracker.domain.record.repository.RecordRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.DayOfWeek; +import java.util.*; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional +public class CrewMemberRankingService { + + private final CrewMemberRankingRepository crewMemberRankingRepository; + private final CrewMemberRepository crewMemberRepository; + private final CrewRepository crewRepository; + private final RecordRepository recordRepository; + private final MemberRepository memberRepository; + + /** + * 특정 크루의 멤버 랭킹 조회 + */ + public CrewMemberRankingDTO.Response getCrewMemberRanking(Long crewId, LocalDate date) { + validateCrewExists(crewId); + + List rankings = findExistingMemberRankings(crewId, date); + + if (rankings.isEmpty()) { + calculateCrewMemberRanking(crewId, date); + rankings = findExistingMemberRankings(crewId, date); + } + + return buildMemberRankingResponse(crewId, date, rankings); + } + + /** + * 특정 크루의 멤버 랭킹 강제 재계산 + */ + public void recalculateCrewMemberRanking(Long crewId, LocalDate date) { + validateCrewExists(crewId); + calculateCrewMemberRanking(crewId, date); + } + + /** + * 크루 멤버 랭킹 계산 + */ + private void calculateCrewMemberRanking(Long crewId, LocalDate date) { + Map memberDataMap = calculateMemberRankingData(crewId, date); + saveOrUpdateMemberRankings(crewId, date, memberDataMap); + } + + /** + * 크루 내 멤버별 누적 데이터 계산 (일주일 기준) + */ + private Map calculateMemberRankingData(Long crewId, LocalDate date) { + Map memberDataMap = new HashMap<>(); + + includeAllCrewMembers(crewId, memberDataMap); + + LocalDate weekStart = date.with(DayOfWeek.MONDAY); + LocalDate weekEnd = date.with(DayOfWeek.SUNDAY); + + LocalDateTime weekStartDateTime = weekStart.atStartOfDay(); + LocalDateTime weekEndDateTime = weekEnd.plusDays(1).atStartOfDay(); + + for (Long memberId : memberDataMap.keySet()) { + MemberRankingData memberData = memberDataMap.get(memberId); + + List weeklyRecords = recordRepository.findByMemberIdAndCreatedAtBetweenAndFinishedAtIsNotNull( + memberId, weekStartDateTime, weekEndDateTime); + + for (RunningRecord record : weeklyRecords) { + if (record.getDistance() != null && record.getRunningTime() != null) { + memberData.addRecord(record.getDistance(), record.getRunningTime()); + } + } + } + + return memberDataMap; + } + + /** + * 계산된 데이터로 멤버 랭킹 저장 + */ + private void saveOrUpdateMemberRankings(Long crewId, LocalDate date, Map memberDataMap) { + Map existingRankings = getExistingMemberRankingsMap(crewId, date); + + List sortedData = memberDataMap.values().stream() + .sorted((a, b) -> Double.compare(b.getTotalDistance(), a.getTotalDistance())) + .toList(); + + for (int i = 0; i < sortedData.size(); i++) { + MemberRankingData data = sortedData.get(i); + int rank = i + 1; + + CrewMemberRanking existing = existingRankings.get(data.getMemberId()); + + if (existing == null) { + createNewMemberRanking(crewId, date, data, rank); + } else { + updateExistingMemberRanking(existing, data, rank); + } + } + } + + private List findExistingMemberRankings(Long crewId, LocalDate date) { + return crewMemberRankingRepository.findByCrewIdAndDateOrderByRankPosition(crewId, date); + } + + private CrewMemberRankingDTO.Response buildMemberRankingResponse(Long crewId, LocalDate date, List rankings) { + List memberIds = rankings.stream() + .map(CrewMemberRanking::getMemberId) + .toList(); + + Map memberMap = memberRepository.findAllById(memberIds).stream() + .collect(Collectors.toMap(Member::getId, member -> member)); + + List rankInfos = rankings.stream() + .map(ranking -> convertToMemberRankInfo(ranking, memberMap)) + .toList(); + + Optional crew = crewRepository.findById(crewId); + LocalDateTime lastUpdated = rankings.isEmpty() ? LocalDateTime.now() : + rankings.stream() + .map(CrewMemberRanking::getUpdatedAt) + .max(LocalDateTime::compareTo) + .orElse(LocalDateTime.now()); + + return CrewMemberRankingDTO.Response.builder() + .date(date) + .crewId(crewId) + .crewName(crew.map(Crew::getTitle).orElse("Unknown")) + .rankings(rankInfos) + .lastUpdated(lastUpdated) + .build(); + } + + private void includeAllCrewMembers(Long crewId, Map memberDataMap) { + crewMemberRepository.findByCrewId(crewId).stream() + .filter(crewMember -> !crewMember.getStatus().equals(com.runtracker.domain.crew.enums.CrewMemberStatus.BANNED)) + .forEach(crewMember -> + memberDataMap.putIfAbsent(crewMember.getMemberId(), new MemberRankingData(crewMember.getMemberId())) + ); + } + + private Map getExistingMemberRankingsMap(Long crewId, LocalDate date) { + return crewMemberRankingRepository.findByCrewIdAndDateOrderByRankPosition(crewId, date).stream() + .collect(Collectors.toMap(CrewMemberRanking::getMemberId, ranking -> ranking)); + } + + private void createNewMemberRanking(Long crewId, LocalDate date, MemberRankingData data, int rank) { + CrewMemberRanking newRanking = CrewMemberRanking.builder() + .date(date) + .crewId(crewId) + .memberId(data.getMemberId()) + .rankPosition(rank) + .totalDistance(data.getTotalDistance()) + .totalRunningTime(data.getTotalRunningTime()) + .participationCount(data.getParticipationCount()) + .build(); + crewMemberRankingRepository.save(newRanking); + } + + private void updateExistingMemberRanking(CrewMemberRanking existing, MemberRankingData data, int rank) { + boolean needsUpdate = !existing.getRankPosition().equals(rank) || + Math.abs(existing.getTotalDistance() - data.getTotalDistance()) > 0.01 || + !existing.getTotalRunningTime().equals(data.getTotalRunningTime()) || + !existing.getParticipationCount().equals(data.getParticipationCount()); + + if (needsUpdate) { + existing.updateRankPosition(rank); + existing.updateTotalData(data.getTotalDistance(), data.getTotalRunningTime(), data.getParticipationCount()); + crewMemberRankingRepository.save(existing); + } + } + + private void validateCrewExists(Long crewId) { + if (!crewRepository.existsById(crewId)) { + throw new CrewNotFoundException(); + } + } + + private CrewMemberRankingDTO.MemberRankInfo convertToMemberRankInfo( + CrewMemberRanking ranking, Map memberMap) { + Member member = memberMap.get(ranking.getMemberId()); + + double averageDistance = ranking.getParticipationCount() > 0 ? + ranking.getTotalDistance() / ranking.getParticipationCount() : 0.0; + int averageRunningTime = ranking.getParticipationCount() > 0 ? + ranking.getTotalRunningTime() / ranking.getParticipationCount() : 0; + + return CrewMemberRankingDTO.MemberRankInfo.builder() + .memberId(ranking.getMemberId()) + .memberName(member != null ? member.getName() : "Unknown") + .memberPhoto(member != null ? member.getPhoto() : null) + .rank(ranking.getRankPosition()) + .totalDistance(ranking.getTotalDistance()) + .totalRunningTime(ranking.getTotalRunningTime()) + .averageDistance(averageDistance) + .averageRunningTime(averageRunningTime) + .build(); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/crew/service/CrewRankingCacheService.java b/runtracker/src/main/java/com/runtracker/domain/crew/service/CrewRankingCacheService.java new file mode 100644 index 0000000..ed81842 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/crew/service/CrewRankingCacheService.java @@ -0,0 +1,101 @@ +package com.runtracker.domain.crew.service; + +import com.runtracker.domain.crew.dto.CrewRankingCacheDTO; +import com.runtracker.domain.crew.entity.CrewRanking; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.util.*; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Service +@RequiredArgsConstructor +public class CrewRankingCacheService { + + private final RedisTemplate redisTemplate; + + private static final String RANKING_KEY_PREFIX = "crew:ranking:"; + private static final long CACHE_TTL_DAYS = 1; + + /** + * 랭킹 데이터를 Redis에 저장 + */ + public void saveRankingToCache(LocalDate date, List rankings) { + String key = getRankingKey(date); + + try { + redisTemplate.delete(key); + + ZSetOperations zSetOps = redisTemplate.opsForZSet(); + + for (CrewRanking ranking : rankings) { + String member = ranking.getCrewId() + ":" + ranking.getTotalRunningTime(); + zSetOps.add(key, member, ranking.getTotalDistance()); + } + + redisTemplate.expire(key, CACHE_TTL_DAYS, TimeUnit.DAYS); + } catch (Exception e) { + log.error("Failed to save crew ranking to cache: date={}", date, e); + } + } + + /** + * Redis에서 랭킹 데이터 조회 + */ + public Map getRankingFromCache(LocalDate date) { + String key = getRankingKey(date); + + try { + ZSetOperations zSetOps = redisTemplate.opsForZSet(); + + Set> rankingsWithScores = + zSetOps.reverseRangeWithScores(key, 0, -1); + + if (rankingsWithScores == null || rankingsWithScores.isEmpty()) { + log.debug("No ranking cache found for date: {}", date); + return null; + } + + Map result = new LinkedHashMap<>(); + + for (ZSetOperations.TypedTuple tuple : rankingsWithScores) { + String member = tuple.getValue(); + Double score = tuple.getScore(); + + if (member != null && score != null) { + String[] parts = member.split(":"); + Long crewId = Long.parseLong(parts[0]); + Integer totalRunningTime = Integer.parseInt(parts[1]); + + result.put(crewId, new CrewRankingCacheDTO(score, totalRunningTime)); + } + } + + return result; + + } catch (Exception e) { + log.error("Failed to get crew ranking from cache: date={}", date, e); + return null; + } + } + + /** + * 캐시 무효화 + */ + public void invalidateCache(LocalDate date) { + String key = getRankingKey(date); + redisTemplate.delete(key); + } + + /** + * Redis 키 생성 + */ + private String getRankingKey(LocalDate date) { + return RANKING_KEY_PREFIX + date.toString(); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/crew/service/CrewRankingService.java b/runtracker/src/main/java/com/runtracker/domain/crew/service/CrewRankingService.java new file mode 100644 index 0000000..32eb13f --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/crew/service/CrewRankingService.java @@ -0,0 +1,245 @@ +package com.runtracker.domain.crew.service; + +import com.runtracker.domain.crew.dto.CrewRankingCacheDTO; +import com.runtracker.domain.crew.dto.CrewRankingDTO; +import com.runtracker.domain.crew.dto.CrewRankingData; +import com.runtracker.domain.crew.entity.Crew; +import com.runtracker.domain.crew.entity.CrewRanking; +import com.runtracker.domain.crew.repository.CrewRankingRepository; +import com.runtracker.domain.crew.repository.CrewRepository; +import com.runtracker.domain.crew.repository.CrewMemberRepository; +import com.runtracker.domain.record.repository.RecordRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional +public class CrewRankingService { + + private final CrewRankingRepository crewRankingRepository; + private final CrewRepository crewRepository; + private final CrewMemberRepository crewMemberRepository; + private final RecordRepository recordRepository; + private final CrewRankingCacheService cacheService; + + /** + * 랭킹 조회 (Cache-Aside 패턴) + */ + public CrewRankingDTO.Response getDailyRanking(LocalDate date) { + Map cachedRanking = cacheService.getRankingFromCache(date); + + if (cachedRanking != null && !cachedRanking.isEmpty()) { + return buildResponseFromCache(date, cachedRanking); + } + + List rankings = findExistingRankings(date); + + if (rankings.isEmpty()) { + rankingCalculation(date); + rankings = findExistingRankings(date); + } + + if (!rankings.isEmpty()) { + cacheService.saveRankingToCache(date, rankings); + } + + return buildResponse(date, rankings); + } + + /** + * 랭킹 강제 재계산 + */ + public void recalculateRanking(LocalDate date) { + cacheService.invalidateCache(date); + + rankingCalculation(date); + + List rankings = findExistingRankings(date); + if (!rankings.isEmpty()) { + cacheService.saveRankingToCache(date, rankings); + } + } + + /** + * 랭킹 계산 + */ + private void rankingCalculation(LocalDate date) { + Map crewDataMap = calculateCrewRankingData(date); + saveOrUpdateRankings(date, crewDataMap); + } + + /** + * 크루별 누적 데이터 계산 + */ + private Map calculateCrewRankingData(LocalDate date) { + Map crewDataMap = new HashMap<>(); + + includeAllCrews(crewDataMap); + calculateCrewRanking(date, crewDataMap); + + return crewDataMap; + } + + /** + * 계산된 데이터로 랭킹 저장/업데이트 + */ + private void saveOrUpdateRankings(LocalDate date, Map crewDataMap) { + Map existingRankings = getExistingRankingsMap(date); + + List sortedData = crewDataMap.values().stream() + .sorted((a, b) -> Double.compare(b.getTotalDistance(), a.getTotalDistance())) + .toList(); + + for (int i = 0; i < sortedData.size(); i++) { + CrewRankingData data = sortedData.get(i); + int rank = i + 1; + + CrewRanking existing = existingRankings.get(data.getCrewId()); + + if (existing == null) { + createNewRanking(date, data, rank); + } else { + updateExistingRanking(existing, data, rank); + } + } + } + + private List findExistingRankings(LocalDate date) { + return crewRankingRepository.findByDateOrderByRankPosition(date); + } + + /** + * Redis 캐시 데이터로 Response 생성 + */ + private CrewRankingDTO.Response buildResponseFromCache(LocalDate date, + Map cachedRanking) { + List crewIds = new ArrayList<>(cachedRanking.keySet()); + + Map crewMap = crewRepository.findAllById(crewIds).stream() + .collect(Collectors.toMap(Crew::getId, crew -> crew)); + + List rankInfos = new ArrayList<>(); + int rank = 1; + + for (Long crewId : crewIds) { + CrewRankingCacheDTO data = cachedRanking.get(crewId); + Crew crew = crewMap.get(crewId); + + rankInfos.add(CrewRankingDTO.CrewRankInfo.builder() + .crewId(crewId) + .crewName(crew != null ? crew.getTitle() : "Unknown") + .crewPhoto(crew != null ? crew.getPhoto() : null) + .totalDistance(data.getTotalDistance()) + .totalRunningTime(data.getTotalRunningTime()) + .rank(rank++) + .build()); + } + + return CrewRankingDTO.Response.builder() + .date(date) + .rankings(rankInfos) + .lastUpdated(LocalDateTime.now()) + .build(); + } + + private CrewRankingDTO.Response buildResponse(LocalDate date, List rankings) { + List rankInfos = rankings.stream() + .map(this::convertToRankInfo) + .toList(); + + LocalDateTime lastUpdated = rankings.isEmpty() ? LocalDateTime.now() : + rankings.stream() + .map(CrewRanking::getUpdatedAt) + .max(LocalDateTime::compareTo) + .orElse(LocalDateTime.now()); + + return CrewRankingDTO.Response.builder() + .date(date) + .rankings(rankInfos) + .lastUpdated(lastUpdated) + .build(); + } + + private void includeAllCrews(Map crewDataMap) { + crewRepository.findAll().forEach(crew -> + crewDataMap.putIfAbsent(crew.getId(), new CrewRankingData(crew.getId())) + ); + } + + /** + * 크루원들의 개인 러닝 기록을 통해 크루별 통계 계산 + */ + private void calculateCrewRanking(LocalDate date, Map crewDataMap) { + LocalDateTime startOfDay = date.atStartOfDay(); + LocalDateTime endOfDay = date.plusDays(1).atStartOfDay(); + + for (Long crewId : crewDataMap.keySet()) { + CrewRankingData crewData = crewDataMap.get(crewId); + + List memberIds = crewMemberRepository.findMemberIdsByCrewId(crewId); + + if (!memberIds.isEmpty()) { + var records = recordRepository.findByMemberIdInAndCreatedAtBetweenAndFinishedAtIsNotNull( + memberIds, startOfDay, endOfDay); + + for (var record : records) { + if (record.getDistance() != null && record.getRunningTime() != null) { + crewData.addRecord(record.getDistance(), record.getRunningTime()); + } + } + } + } + } + + private Map getExistingRankingsMap(LocalDate date) { + return crewRankingRepository.findByDateOrderByRankPosition(date).stream() + .collect(Collectors.toMap(CrewRanking::getCrewId, ranking -> ranking)); + } + + private void createNewRanking(LocalDate date, CrewRankingData data, int rank) { + CrewRanking newRanking = CrewRanking.builder() + .date(date) + .crewId(data.getCrewId()) + .rankPosition(rank) + .totalDistance(data.getTotalDistance()) + .totalRunningTime(data.getTotalRunningTime()) + .participantCount(data.getParticipantCount()) + .build(); + crewRankingRepository.save(newRanking); + } + + private void updateExistingRanking(CrewRanking existing, CrewRankingData data, int rank) { + boolean needsUpdate = existing.getRankPosition() != rank || + Math.abs(existing.getTotalDistance() - data.getTotalDistance()) > 0.01 || + !existing.getTotalRunningTime().equals(data.getTotalRunningTime()); + + if (needsUpdate) { + existing.updateRankPosition(rank); + existing.updateTotalDistance(data.getTotalDistance()); + existing.updateTotalRunningTime(data.getTotalRunningTime()); + crewRankingRepository.save(existing); + } + } + + private CrewRankingDTO.CrewRankInfo convertToRankInfo(CrewRanking crewRanking) { + Optional crew = crewRepository.findById(crewRanking.getCrewId()); + + return CrewRankingDTO.CrewRankInfo.builder() + .crewId(crewRanking.getCrewId()) + .crewName(crew.map(Crew::getTitle).orElse("Unknown")) + .crewPhoto(crew.map(Crew::getPhoto).orElse(null)) + .totalDistance(crewRanking.getTotalDistance()) + .totalRunningTime(crewRanking.getTotalRunningTime()) + .rank(crewRanking.getRankPosition()) + .build(); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/crew/service/CrewService.java b/runtracker/src/main/java/com/runtracker/domain/crew/service/CrewService.java new file mode 100644 index 0000000..adaeb95 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/crew/service/CrewService.java @@ -0,0 +1,488 @@ +package com.runtracker.domain.crew.service; + +import com.runtracker.domain.crew.dto.CrewApprovalDTO; +import com.runtracker.domain.crew.dto.CrewCreateDTO; +import com.runtracker.domain.crew.dto.CrewDetailDTO; +import com.runtracker.domain.crew.dto.CrewListDTO; +import com.runtracker.domain.crew.dto.CrewManagementDTO; +import com.runtracker.domain.crew.dto.CrewMemberUpdateDTO; +import com.runtracker.domain.crew.dto.CrewUpdateDTO; +import com.runtracker.domain.crew.dto.MemberProfileDTO; +import com.runtracker.domain.crew.entity.Crew; +import com.runtracker.domain.crew.entity.CrewMember; +import com.runtracker.domain.crew.event.CrewJoinRequestEvent; +import com.runtracker.domain.crew.event.CrewJoinRequestCancelEvent; +import com.runtracker.domain.crew.event.CrewJoinRequestApprovalEvent; +import com.runtracker.domain.crew.event.CrewMemberRoleUpdateEvent; +import com.runtracker.domain.crew.event.CrewDeleteEvent; +import com.runtracker.domain.crew.event.CrewBanEvent; +import com.runtracker.domain.crew.event.CrewLeaveEvent; +import com.runtracker.domain.crew.enums.CrewMemberStatus; +import com.runtracker.domain.crew.exception.AlreadyCrewMemberException; +import com.runtracker.domain.crew.exception.AlreadyJoinedOtherCrewException; +import com.runtracker.domain.crew.exception.ApplicantNotFoundException; +import com.runtracker.domain.crew.exception.BannedFromCrewException; +import com.runtracker.domain.crew.exception.CannotKickCrewLeaderException; +import com.runtracker.domain.crew.exception.CannotKickManagerAsManagerException; +import com.runtracker.domain.crew.exception.CannotKickYourselfException; +import com.runtracker.domain.crew.exception.CannotLeaveAsCrewLeaderException; +import com.runtracker.domain.crew.exception.CannotModifyLeaderRoleException; +import com.runtracker.domain.crew.exception.CrewAlreadyExistsException; +import com.runtracker.domain.crew.exception.CrewApplicationPendingException; +import com.runtracker.domain.crew.exception.CrewNotFoundException; +import com.runtracker.domain.crew.exception.CrewSearchResultNotFoundException; +import com.runtracker.domain.crew.exception.InvalidCrewRoleException; +import com.runtracker.domain.crew.exception.MemberNotFoundException; +import com.runtracker.domain.crew.exception.NoPendingApplicationException; +import com.runtracker.domain.crew.exception.SameRoleUpdateException; +import com.runtracker.domain.crew.repository.CrewRepository; +import com.runtracker.domain.crew.repository.CrewMemberRepository; +import com.runtracker.domain.member.entity.Member; +import com.runtracker.domain.member.entity.enums.MemberRole; +import com.runtracker.domain.member.repository.MemberRepository; +import com.runtracker.global.util.ImageUpload; +import com.runtracker.global.security.UserDetailsImpl; +import com.runtracker.global.security.CrewAuthorizationUtil; +import com.runtracker.global.jwt.service.TokenBlacklistService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional +public class CrewService { + + private final CrewRepository crewRepository; + private final CrewMemberRepository crewMemberRepository; + private final MemberRepository memberRepository; + private final CrewAuthorizationUtil authorizationUtil; + private final TokenBlacklistService tokenBlacklistService; + private final ApplicationEventPublisher eventPublisher; + private final ImageUpload imageUploadHelper; + + public void createCrew(CrewCreateDTO.Request request, UserDetailsImpl userDetails) { + memberRepository.findById(userDetails.getMemberId()) + .orElseThrow(MemberNotFoundException::new); + + List existingCrews = crewRepository.findByLeaderId(userDetails.getMemberId()); + if (!existingCrews.isEmpty()) { + throw new CrewAlreadyExistsException(); + } + + if (request.getPhoto() != null) { + String photoUrl = imageUploadHelper.convertBase64ToUrlIfNeeded(request.getPhoto()); + CrewCreateDTO.Request updatedRequest = CrewCreateDTO.Request.builder() + .title(request.getTitle()) + .photo(photoUrl) + .introduce(request.getIntroduce()) + .region(request.getRegion()) + .difficulty(request.getDifficulty()) + .build(); + Crew crew = updatedRequest.toEntity(userDetails.getMemberId()); + crewRepository.save(crew); + + CrewMember crewLeader = CrewMember.builder() + .crewId(crew.getId()) + .memberId(userDetails.getMemberId()) + .role(MemberRole.CREW_LEADER) + .status(CrewMemberStatus.ACTIVE) + .build(); + crewMemberRepository.save(crewLeader); + } else { + Crew crew = request.toEntity(userDetails.getMemberId()); + crewRepository.save(crew); + + CrewMember crewLeader = CrewMember.builder() + .crewId(crew.getId()) + .memberId(userDetails.getMemberId()) + .role(MemberRole.CREW_LEADER) + .status(CrewMemberStatus.ACTIVE) + .build(); + crewMemberRepository.save(crewLeader); + } + } + + public void applyToJoinCrew(Long crewId, UserDetailsImpl userDetails) { + memberRepository.findById(userDetails.getMemberId()) + .orElseThrow(MemberNotFoundException::new); + + crewRepository.findById(crewId) + .orElseThrow(CrewNotFoundException::new); + + List activeCrewMemberships = crewMemberRepository + .findByMemberIdAndStatus(userDetails.getMemberId(), CrewMemberStatus.ACTIVE); + if (!activeCrewMemberships.isEmpty()) { + throw new AlreadyJoinedOtherCrewException(); + } + + CrewMember existingMembership = crewMemberRepository + .findByCrewIdAndMemberId(crewId, userDetails.getMemberId()) + .orElse(null); + + if (existingMembership != null) { + if (existingMembership.getStatus() == CrewMemberStatus.ACTIVE) { + throw new AlreadyCrewMemberException(); + } + if (existingMembership.getStatus() == CrewMemberStatus.PENDING) { + throw new CrewApplicationPendingException(); + } + if (existingMembership.getStatus() == CrewMemberStatus.BANNED) { + throw new BannedFromCrewException(); + } + } + + CrewMember newApplication = CrewMember.builder() + .crewId(crewId) + .memberId(userDetails.getMemberId()) + .role(MemberRole.USER) + .status(CrewMemberStatus.PENDING) + .build(); + crewMemberRepository.save(newApplication); + + List managementMembers = getManagementMembers(crewId); + + for (CrewMember managementMember : managementMembers) { + eventPublisher.publishEvent(new CrewJoinRequestEvent( + userDetails.getMemberId(), + managementMember.getMemberId(), + crewId + )); + } + } + + public void cancelCrewApplication(Long crewId, UserDetailsImpl userDetails) { + memberRepository.findById(userDetails.getMemberId()) + .orElseThrow(MemberNotFoundException::new); + + crewRepository.findById(crewId) + .orElseThrow(CrewNotFoundException::new); + + CrewMember application = crewMemberRepository + .findByCrewIdAndMemberId(crewId, userDetails.getMemberId()) + .orElseThrow(NoPendingApplicationException::new); + + if (application.getStatus() != CrewMemberStatus.PENDING) { + throw new NoPendingApplicationException(); + } + + crewMemberRepository.delete(application); + + List managementMembers = getManagementMembers(crewId); + + for (CrewMember managementMember : managementMembers) { + eventPublisher.publishEvent(new CrewJoinRequestCancelEvent( + userDetails.getMemberId(), + managementMember.getMemberId(), + crewId + )); + } + } + + public void processJoinRequest(Long crewId, CrewApprovalDTO.Request request, UserDetailsImpl userDetails) { + crewRepository.findById(crewId) + .orElseThrow(CrewNotFoundException::new); + + authorizationUtil.validateCrewManagementPermission(userDetails, crewId); + + CrewMember applicant = crewMemberRepository + .findByCrewIdAndMemberId(crewId, request.getMemberId()) + .orElseThrow(ApplicantNotFoundException::new); + + if (applicant.getStatus() != CrewMemberStatus.PENDING) { + throw new ApplicantNotFoundException(); + } + + if (request.getApproved()) { + List activeCrewMemberships = crewMemberRepository + .findByMemberIdAndStatus(request.getMemberId(), CrewMemberStatus.ACTIVE); + if (!activeCrewMemberships.isEmpty()) { + throw new AlreadyCrewMemberException(); + } + + applicant.approve(); + crewMemberRepository.save(applicant); + + List pendingApplications = crewMemberRepository + .findByMemberIdAndStatus(request.getMemberId(), CrewMemberStatus.PENDING); + + pendingApplications.removeIf(member -> member.getCrewId().equals(crewId)); + crewMemberRepository.deleteAll(pendingApplications); + + tokenBlacklistService.invalidateUserTokens(request.getMemberId()); + } else { + crewMemberRepository.delete(applicant); + } + + eventPublisher.publishEvent(new CrewJoinRequestApprovalEvent( + request.getMemberId(), + crewId, + request.getApproved() + )); + } + + public void updateCrewMemberRole(Long crewId, CrewMemberUpdateDTO.Request request, UserDetailsImpl userDetails) { + authorizationUtil.validateCrewManagementPermission(userDetails, crewId); + + CrewMember targetMember = crewMemberRepository + .findByCrewIdAndMemberId(crewId, request.getMemberId()) + .orElseThrow(MemberNotFoundException::new); + + if (targetMember.getStatus() != CrewMemberStatus.ACTIVE) { + throw new MemberNotFoundException(); + } + + if (targetMember.getRole() == MemberRole.CREW_LEADER) { + throw new CannotModifyLeaderRoleException(); + } + + if (targetMember.getRole() == request.getRole()) { + throw new SameRoleUpdateException(); + } + + if (!isValidCrewRole(request.getRole())) { + throw new InvalidCrewRoleException(); + } + + targetMember.updateRole(request.getRole()); + crewMemberRepository.save(targetMember); + + tokenBlacklistService.invalidateUserTokens(request.getMemberId()); + + eventPublisher.publishEvent(new CrewMemberRoleUpdateEvent( + request.getMemberId(), + crewId, + request.getRole() + )); + } + + public void updateCrew(Long crewId, CrewUpdateDTO.Request request, UserDetailsImpl userDetails) { + authorizationUtil.validateCrewLeaderPermission(userDetails, crewId); + + Crew crew = crewRepository.findById(crewId) + .orElseThrow(CrewNotFoundException::new); + + if (request.getTitle() != null) { + crew.updateTitle(request.getTitle()); + } + if (request.getPhoto() != null) { + String photoUrl = imageUploadHelper.convertBase64ToUrlIfNeeded(request.getPhoto()); + crew.updatePhoto(photoUrl); + } + if (request.getIntroduce() != null) { + crew.updateIntroduce(request.getIntroduce()); + } + if (request.getRegion() != null) { + crew.updateRegion(request.getRegion()); + } + if (request.getDifficulty() != null) { + crew.updateDifficulty(request.getDifficulty()); + } + + crewRepository.save(crew); + } + + public void deleteCrew(Long crewId, UserDetailsImpl userDetails) { + authorizationUtil.validateCrewLeaderPermission(userDetails, crewId); + + Crew crew = crewRepository.findById(crewId) + .orElseThrow(CrewNotFoundException::new); + + List crewMembers = crewMemberRepository.findByCrewId(crewId); + + List memberIdsToNotify = crewMembers.stream() + .filter(member -> member.getStatus() == CrewMemberStatus.ACTIVE) + .map(CrewMember::getMemberId) + .toList(); + + eventPublisher.publishEvent(new CrewDeleteEvent(memberIdsToNotify, crew.getTitle())); + + List memberIds = crewMembers.stream() + .map(CrewMember::getMemberId) + .toList(); + tokenBlacklistService.invalidateCrewMemberTokens(crewId, memberIds); + + crewMemberRepository.deleteAll(crewMembers); + crewRepository.delete(crew); + } + + public void banCrewMember(Long crewId, Long targetMemberId, UserDetailsImpl userDetails) { + Crew crew = crewRepository.findById(crewId) + .orElseThrow(CrewNotFoundException::new); + + authorizationUtil.validateCrewManagementPermission(userDetails, crewId); + + if (targetMemberId.equals(userDetails.getMemberId())) { + throw new CannotKickYourselfException(); + } + + CrewMember targetMember = crewMemberRepository + .findByCrewIdAndMemberId(crewId, targetMemberId) + .orElseThrow(MemberNotFoundException::new); + + if (targetMember.getStatus() != CrewMemberStatus.ACTIVE) { + throw new MemberNotFoundException(); + } + + if (targetMember.getRole() == MemberRole.CREW_LEADER) { + throw new CannotKickCrewLeaderException(); + } + + boolean isManager = userDetails.getRoles().contains(MemberRole.CREW_MANAGER) && + !userDetails.getRoles().contains(MemberRole.CREW_LEADER); + + if (isManager && targetMember.getRole() == MemberRole.CREW_MANAGER) { + throw new CannotKickManagerAsManagerException(); + } + + eventPublisher.publishEvent(new CrewBanEvent(targetMemberId, crew.getTitle())); + + targetMember.ban(); + crewMemberRepository.save(targetMember); + + tokenBlacklistService.invalidateUserTokens(targetMemberId); + } + + public void leaveCrew(Long crewId, UserDetailsImpl userDetails) { + Member leavingMember = memberRepository.findById(userDetails.getMemberId()) + .orElseThrow(MemberNotFoundException::new); + + crewRepository.findById(crewId) + .orElseThrow(CrewNotFoundException::new); + + CrewMember crewMember = crewMemberRepository + .findByCrewIdAndMemberId(crewId, userDetails.getMemberId()) + .orElseThrow(MemberNotFoundException::new); + + if (crewMember.getStatus() != CrewMemberStatus.ACTIVE) { + throw new MemberNotFoundException(); + } + + if (crewMember.getRole() == MemberRole.CREW_LEADER) { + throw new CannotLeaveAsCrewLeaderException(); + } + + List managementMembers = getManagementMembers(crewId); + List managerIds = managementMembers.stream() + .map(CrewMember::getMemberId) + .toList(); + + eventPublisher.publishEvent(new CrewLeaveEvent(managerIds, leavingMember.getName())); + + crewMemberRepository.delete(crewMember); + + tokenBlacklistService.invalidateUserTokens(userDetails.getMemberId()); + } + + @Transactional(readOnly = true) + public CrewListDTO.ListResponse getAllCrews() { + List crews = crewRepository.findAll(); + return convertToCrewListResponse(crews); + } + + @Transactional(readOnly = true) + public CrewListDTO.ListResponse searchCrewsByName(String name) { + List crews = crewRepository.findByTitleContainingIgnoreCase(name); + + if (crews.isEmpty()) { + throw new CrewSearchResultNotFoundException(); + } + + return convertToCrewListResponse(crews); + } + + private CrewListDTO.ListResponse convertToCrewListResponse(List crews) { + List crewResponses = crews.stream() + .map(crew -> { + List activeMembers = crewMemberRepository.findByCrewId(crew.getId()).stream() + .filter(member -> member.getStatus() == CrewMemberStatus.ACTIVE) + .toList(); + return CrewListDTO.Response.from(crew, activeMembers.size()); + }) + .toList(); + + return CrewListDTO.ListResponse.of(crewResponses); + } + + @Transactional(readOnly = true) + public CrewDetailDTO.Response getCrewDetail(Long crewId) { + Crew crew = crewRepository.findById(crewId) + .orElseThrow(CrewNotFoundException::new); + + List allMembers = crewMemberRepository.findByCrewId(crewId); + + return CrewDetailDTO.Response.from(crew, allMembers); + } + + @Transactional(readOnly = true) + public CrewManagementDTO.PendingMembersResponse getPendingMembers(Long crewId, UserDetailsImpl userDetails) { + crewRepository.findById(crewId) + .orElseThrow(CrewNotFoundException::new); + + authorizationUtil.validateCrewManagementPermission(userDetails, crewId); + + List pendingMembers = crewMemberRepository.findByCrewId(crewId).stream() + .filter(member -> member.getStatus() == CrewMemberStatus.PENDING) + .toList(); + + List memberInfos = pendingMembers.stream() + .map(crewMember -> { + Member member = memberRepository.findById(crewMember.getMemberId()) + .orElseThrow(MemberNotFoundException::new); + return CrewManagementDTO.MemberInfo.from(crewMember, member.getName(), member.getAge(), member.getGender()); + }) + .toList(); + + return CrewManagementDTO.PendingMembersResponse.of(memberInfos); + } + + @Transactional(readOnly = true) + public CrewManagementDTO.BannedMembersResponse getBannedMembers(Long crewId, UserDetailsImpl userDetails) { + crewRepository.findById(crewId) + .orElseThrow(CrewNotFoundException::new); + + authorizationUtil.validateCrewManagementPermission(userDetails, crewId); + + List bannedMembers = crewMemberRepository.findByCrewId(crewId).stream() + .filter(member -> member.getStatus() == CrewMemberStatus.BANNED) + .toList(); + + List memberInfos = bannedMembers.stream() + .map(crewMember -> { + Member member = memberRepository.findById(crewMember.getMemberId()) + .orElseThrow(MemberNotFoundException::new); + return CrewManagementDTO.MemberInfo.from(crewMember, member.getName(), member.getAge(), member.getGender()); + }) + .toList(); + + return CrewManagementDTO.BannedMembersResponse.of(memberInfos); + } + + @Transactional(readOnly = true) + public MemberProfileDTO getMemberProfile(Long targetMemberId, UserDetailsImpl userDetails) { + memberRepository.findById(userDetails.getMemberId()) + .orElseThrow(MemberNotFoundException::new); + + Member targetMember = memberRepository.findById(targetMemberId) + .orElseThrow(MemberNotFoundException::new); + + return MemberProfileDTO.from(targetMember); + } + + private boolean isValidCrewRole(MemberRole role) { + return role == MemberRole.CREW_MEMBER || role == MemberRole.CREW_MANAGER; + } + + private List getManagementMembers(Long crewId) { + return crewMemberRepository.findByCrewId(crewId).stream() + .filter(member -> member.getStatus() == CrewMemberStatus.ACTIVE) + .filter(member -> member.getRole() == MemberRole.CREW_LEADER || + member.getRole() == MemberRole.CREW_MANAGER) + .toList(); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/member/controller/MemberController.java b/runtracker/src/main/java/com/runtracker/domain/member/controller/MemberController.java new file mode 100644 index 0000000..4187703 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/member/controller/MemberController.java @@ -0,0 +1,129 @@ +package com.runtracker.domain.member.controller; + +import com.runtracker.domain.member.service.dto.LoginTokenDto; +import com.runtracker.domain.member.service.MemberService; +import com.runtracker.domain.member.service.AuthService; +import com.runtracker.domain.member.entity.Member; +import com.runtracker.domain.member.dto.MemberUpdateDTO; +import com.runtracker.domain.member.dto.NotificationSettingDTO; +import com.runtracker.domain.member.dto.RunningBackupDTO; +import com.runtracker.domain.member.dto.FcmTokenDTO; +import com.runtracker.domain.member.dto.RunningSettingDTO; +import com.runtracker.domain.member.dto.MemberProfileDTO; +import com.runtracker.global.jwt.dto.TokenDataDto; +import com.runtracker.global.response.ApiResponse; +import com.runtracker.global.security.UserDetailsImpl; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import jakarta.validation.Valid; + + +@Slf4j +@RestController +@RequestMapping("/api/members") +@RequiredArgsConstructor +public class MemberController { + + private final MemberService memberService; + private final AuthService authService; + + @PostMapping("/refresh") + public ApiResponse refreshToken(@Valid @RequestBody LoginTokenDto.RefreshTokenRequest request) { + return ApiResponse.ok(authService.refreshToken(request.getRefreshToken())); + } + + /** + * 이름으로 소셜 ID 검색 + */ + @GetMapping("/search-name") + public ApiResponse findSocialIdByName(@RequestParam("name") String name) { + return ApiResponse.ok(memberService.findMemberByName(name)); + } + + /** + * 테스트용: 소셜 ID로 사용자 검색 후 토큰 발급 + */ + @PostMapping("/test-login") + public ApiResponse testLogin(@Valid @RequestBody LoginTokenDto.SocialIdLoginRequest request) { + return ApiResponse.ok(authService.testLoginBySocialId(request.getSocialId(), request.getKey())); + } + + @PostMapping("/logout") + public ApiResponse logout(@AuthenticationPrincipal UserDetailsImpl userDetails) { + memberService.logout(userDetails.getMemberId()); + return ApiResponse.ok(); + } + + @DeleteMapping("/withdrawal") + public ApiResponse withdrawMember(@AuthenticationPrincipal UserDetailsImpl userDetails) { + memberService.withdrawMember(userDetails.getMemberId()); + return ApiResponse.ok(); + } + + @GetMapping("/profile") + public ApiResponse getProfile(@AuthenticationPrincipal UserDetailsImpl userDetails) { + Member member = memberService.getMemberById(userDetails.getMemberId()); + return ApiResponse.ok(MemberProfileDTO.Response.from(member)); + } + + @PatchMapping("/update") + public ApiResponse updateProfile(@AuthenticationPrincipal UserDetailsImpl userDetails, + @Valid @RequestBody MemberUpdateDTO.Request request) { + memberService.updateProfile(userDetails.getMemberId(), request); + return ApiResponse.ok(); + } + + @PatchMapping("/notification") + public ApiResponse updateNotificationSetting(@AuthenticationPrincipal UserDetailsImpl userDetails, + @Valid @RequestBody NotificationSettingDTO.Request request) { + memberService.updateNotificationSetting(userDetails.getMemberId(), request); + return ApiResponse.ok(); + } + + @GetMapping("/running-setting") + public ApiResponse getRunningSetting(@AuthenticationPrincipal UserDetailsImpl userDetails) { + RunningSettingDTO.Response response = memberService.getRunningSetting(userDetails.getMemberId()); + return ApiResponse.ok(response); + } + + @PatchMapping("/running-setting") + public ApiResponse updateRunningSetting(@AuthenticationPrincipal UserDetailsImpl userDetails, + @Valid @RequestBody RunningSettingDTO.Request request) { + memberService.updateRunningSetting(userDetails.getMemberId(), request); + return ApiResponse.ok(); + } + + @PostMapping("/backup") + public ApiResponse createOrUpdateBackup(@AuthenticationPrincipal UserDetailsImpl userDetails) { + memberService.createBackup(userDetails.getMemberId()); + return ApiResponse.ok(); + } + + @PostMapping("/restore") + public ApiResponse restoreRunningRecords(@AuthenticationPrincipal UserDetailsImpl userDetails) { + memberService.restoreRunningRecords(userDetails.getMemberId()); + return ApiResponse.ok(); + } + + @GetMapping("/backup/info") + public ApiResponse getBackupInfo(@AuthenticationPrincipal UserDetailsImpl userDetails) { + RunningBackupDTO.BackupInfo backupInfo = memberService.getBackupInfo(userDetails.getMemberId()); + return ApiResponse.ok(backupInfo); + } + + @PostMapping("/fcm-token") + public ApiResponse registerFcmToken(@AuthenticationPrincipal UserDetailsImpl userDetails, + @Valid @RequestBody FcmTokenDTO.Request fcmTokenDTO) { + memberService.updateFcmToken(userDetails.getMemberId(), fcmTokenDTO.getFcmToken()); + return ApiResponse.ok(); + } + + @PostMapping("/fcm-token/remove") + public ApiResponse removeFcmToken(@AuthenticationPrincipal UserDetailsImpl userDetails) { + memberService.removeFcmToken(userDetails.getMemberId()); + return ApiResponse.ok(); + } + +} diff --git a/runtracker/src/main/java/com/runtracker/domain/member/dto/FcmTokenDTO.java b/runtracker/src/main/java/com/runtracker/domain/member/dto/FcmTokenDTO.java new file mode 100644 index 0000000..dfb22b9 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/member/dto/FcmTokenDTO.java @@ -0,0 +1,15 @@ +package com.runtracker.domain.member.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +public class FcmTokenDTO { + + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class Request { + private String fcmToken; + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/member/dto/MemberCreateDTO.java b/runtracker/src/main/java/com/runtracker/domain/member/dto/MemberCreateDTO.java new file mode 100644 index 0000000..b8e11c6 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/member/dto/MemberCreateDTO.java @@ -0,0 +1,25 @@ +package com.runtracker.domain.member.dto; + +import lombok.*; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class MemberCreateDTO { + private String socialAttr; + private String socialId; + private String photo; + private String name; + private String introduce; + private Integer age; + private Boolean gender; + private String region; + private String difficulty; + private Double temperature; + private Integer point; + private Boolean searchBlock; + private Boolean profileBlock; + private Boolean notifyBlock; + private Integer radius; +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/member/dto/MemberProfileDTO.java b/runtracker/src/main/java/com/runtracker/domain/member/dto/MemberProfileDTO.java new file mode 100644 index 0000000..ea49cee --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/member/dto/MemberProfileDTO.java @@ -0,0 +1,53 @@ +package com.runtracker.domain.member.dto; + +import com.runtracker.domain.member.entity.Member; +import lombok.*; + +public class MemberProfileDTO { + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class Response { + private Long id; + private String socialAttr; + private String socialId; + private String photo; + private String name; + private String introduce; + private Integer age; + private Boolean gender; + private String region; + private String difficulty; + private Double temperature; + private Integer point; + private Boolean searchBlock; + private Boolean profileBlock; + private Boolean notifyBlock; + private String createdAt; + private String updatedAt; + + public static Response from(Member member) { + return Response.builder() + .id(member.getId()) + .socialAttr(member.getSocialAttr()) + .socialId(member.getSocialId()) + .photo(member.getPhoto()) + .name(member.getName()) + .introduce(member.getIntroduce()) + .age(member.getAge()) + .gender(member.getGender()) + .region(member.getRegion()) + .difficulty(member.getDifficulty()) + .temperature(member.getTemperature()) + .point(member.getPoint()) + .searchBlock(member.getSearchBlock()) + .profileBlock(member.getProfileBlock()) + .notifyBlock(member.getNotifyBlock()) + .createdAt(member.getCreatedAt() != null ? member.getCreatedAt().toString() : null) + .updatedAt(member.getUpdatedAt() != null ? member.getUpdatedAt().toString() : null) + .build(); + } + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/member/dto/MemberUpdateDTO.java b/runtracker/src/main/java/com/runtracker/domain/member/dto/MemberUpdateDTO.java new file mode 100644 index 0000000..e1fc622 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/member/dto/MemberUpdateDTO.java @@ -0,0 +1,22 @@ +package com.runtracker.domain.member.dto; + +import lombok.*; + +public class MemberUpdateDTO { + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class Request { + private String photo; + private String name; + private String introduce; + private String region; + private String difficulty; + private Integer age; + private Boolean gender; + private Boolean searchBlock; + private Boolean profileBlock; + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/member/dto/NotificationSettingDTO.java b/runtracker/src/main/java/com/runtracker/domain/member/dto/NotificationSettingDTO.java new file mode 100644 index 0000000..13bbee5 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/member/dto/NotificationSettingDTO.java @@ -0,0 +1,14 @@ +package com.runtracker.domain.member.dto; + +import lombok.*; + +public class NotificationSettingDTO { + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class Request { + private Boolean notifyBlock; + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/member/dto/RunningBackupDTO.java b/runtracker/src/main/java/com/runtracker/domain/member/dto/RunningBackupDTO.java new file mode 100644 index 0000000..33b6039 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/member/dto/RunningBackupDTO.java @@ -0,0 +1,68 @@ +package com.runtracker.domain.member.dto; + +import com.runtracker.global.vo.Coordinate; +import com.runtracker.global.vo.SegmentPace; +import lombok.*; + +import java.time.LocalDateTime; +import java.util.List; + +public class RunningBackupDTO { + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class MemberBackupData { + private Long id; + private String name; + private String socialId; + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class RecordBackupData { + private Long id; + private Long memberId; + private Long courseId; + private Integer runningTime; + private LocalDateTime startedAt; + private LocalDateTime finishedAt; + private Double distance; + private Double avgPace; + private Double avgSpeed; + private Integer kcal; + private Integer walkCnt; + private Integer avgHeartRate; + private Integer maxHeartRate; + private Integer avgCadence; + private Integer maxCadence; + private List path; + private List segmentPaces; + private List> segmentPaths; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class BackupData { + private MemberBackupData member; + private List runningRecords; + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class BackupInfo { + private Long backupId; + private String backupType; + private Integer recordCount; + private String updatedAt; + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/member/dto/RunningSettingDTO.java b/runtracker/src/main/java/com/runtracker/domain/member/dto/RunningSettingDTO.java new file mode 100644 index 0000000..32cae42 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/member/dto/RunningSettingDTO.java @@ -0,0 +1,50 @@ +package com.runtracker.domain.member.dto; + +import com.runtracker.domain.member.entity.Member; +import lombok.*; + +public class RunningSettingDTO { + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class Request { + private Double dailyDistanceGoal; + private Integer monthlyRunCountGoal; + private String preferredDifficulty; + private Boolean autoPause; + private String mapStyle; + private Integer radius; + private Integer paceUnit; + private Boolean ttsEnabled; + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class Response { + private Double dailyDistanceGoal; + private Integer monthlyRunCountGoal; + private String preferredDifficulty; + private Boolean autoPause; + private String mapStyle; + private Integer radius; + private Integer paceUnit; + private Boolean ttsEnabled; + + public static Response from(Member member) { + return Response.builder() + .dailyDistanceGoal(member.getDailyDistanceGoal()) + .monthlyRunCountGoal(member.getMonthlyRunCountGoal()) + .preferredDifficulty(member.getPreferredDifficulty()) + .autoPause(member.getAutoPause()) + .mapStyle(member.getMapStyle()) + .radius(member.getRadius()) + .paceUnit(member.getPaceUnit()) + .ttsEnabled(member.getTtsEnabled()) + .build(); + } + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/member/entity/FcmToken.java b/runtracker/src/main/java/com/runtracker/domain/member/entity/FcmToken.java new file mode 100644 index 0000000..c6f3315 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/member/entity/FcmToken.java @@ -0,0 +1,35 @@ +package com.runtracker.domain.member.entity; + +import com.runtracker.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "fcm_token") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class FcmToken extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "member_id", nullable = false, unique = true) + private Long memberId; + + @Column(name = "token", length = 500) + private String token; + + @Builder + public FcmToken(Long memberId, String token) { + this.memberId = memberId; + this.token = token; + } + + public void updateToken(String token) { + this.token = token; + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/member/entity/Member.java b/runtracker/src/main/java/com/runtracker/domain/member/entity/Member.java new file mode 100644 index 0000000..0a2bdc7 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/member/entity/Member.java @@ -0,0 +1,144 @@ +package com.runtracker.domain.member.entity; + +import com.runtracker.global.entity.BaseEntity; +import com.runtracker.domain.member.dto.MemberCreateDTO; +import com.runtracker.domain.member.dto.MemberUpdateDTO; +import com.runtracker.domain.member.dto.RunningSettingDTO; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "member") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Member extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "social_attr", length = 20) + private String socialAttr; + + @Column(name = "social_id", unique = true, nullable = false) + private String socialId; + + @Column(name = "photo") + private String photo; + + @Column(name = "name", length = 50) + private String name; + + @Column(name = "introduce", columnDefinition = "TEXT") + private String introduce; + + @Column(name = "age") + private Integer age; + + @Column(name = "gender", columnDefinition = "TINYINT(1)") + private Boolean gender; + + @Column(name = "region", length = 100) + private String region; + + @Column(name = "difficulty", length = 20) + private String difficulty; + + @Column(name = "temperature", columnDefinition = "DOUBLE DEFAULT 36.5") + private Double temperature = 36.5; + + @Column(name = "point", columnDefinition = "INT DEFAULT 0") + private Integer point = 0; + + @Column(name = "search_block", columnDefinition = "BOOLEAN DEFAULT FALSE") + private Boolean searchBlock = false; + + @Column(name = "profile_block", columnDefinition = "BOOLEAN DEFAULT FALSE") + private Boolean profileBlock = false; + + @Column(name = "notify_block", columnDefinition = "BOOLEAN DEFAULT TRUE") + private Boolean notifyBlock = true; + + @Column(name = "radius", columnDefinition = "INT DEFAULT 500") + private Integer radius = 500; + + @Column(name = "daily_distance_goal", columnDefinition = "DOUBLE") + private Double dailyDistanceGoal; + + @Column(name = "monthly_run_count_goal", columnDefinition = "INT") + private Integer monthlyRunCountGoal; + + @Column(name = "preferred_difficulty", length = 20) + private String preferredDifficulty; + + @Column(name = "auto_pause", columnDefinition = "BOOLEAN DEFAULT TRUE") + private Boolean autoPause = true; + + @Column(name = "map_style", columnDefinition = "VARCHAR(50) DEFAULT 'STANDARD'") + private String mapStyle = "STANDARD"; + + @Column(name = "pace_unit", columnDefinition = "INT") + private Integer paceUnit; + + @Column(name = "tts_enabled", columnDefinition = "BOOLEAN DEFAULT TRUE") + private Boolean ttsEnabled = true; + + @Builder + public Member(MemberCreateDTO dto) { + this.socialAttr = dto.getSocialAttr(); + this.socialId = dto.getSocialId(); + this.photo = dto.getPhoto(); + this.name = dto.getName(); + this.introduce = dto.getIntroduce(); + this.age = dto.getAge(); + this.gender = dto.getGender(); + this.region = dto.getRegion(); + this.difficulty = dto.getDifficulty(); + this.temperature = dto.getTemperature() != null ? dto.getTemperature() : 36.5; + this.point = dto.getPoint() != null ? dto.getPoint() : 0; + this.searchBlock = dto.getSearchBlock() != null ? dto.getSearchBlock() : false; + this.profileBlock = dto.getProfileBlock() != null ? dto.getProfileBlock() : false; + this.notifyBlock = dto.getNotifyBlock() != null ? dto.getNotifyBlock() : true; + this.radius = dto.getRadius() != null ? dto.getRadius() : 500; + } + + public void updateProfile(MemberUpdateDTO.Request dto) { + if (dto.getPhoto() != null) this.photo = dto.getPhoto(); + if (dto.getName() != null) this.name = dto.getName(); + if (dto.getIntroduce() != null) this.introduce = dto.getIntroduce(); + if (dto.getAge() != null) this.age = dto.getAge(); + if (dto.getGender() != null) this.gender = dto.getGender(); + if (dto.getRegion() != null) this.region = dto.getRegion(); + if (dto.getDifficulty() != null) this.difficulty = dto.getDifficulty(); + if (dto.getSearchBlock() != null) this.searchBlock = dto.getSearchBlock(); + if (dto.getProfileBlock() != null) this.profileBlock = dto.getProfileBlock(); + } + + public void updatePhoto(String photo) { + this.photo = photo; + } + + public void updateTemperature(Double temperature) { + this.temperature = temperature; + } + + public void updateNotificationSetting(Boolean notifyBlock) { + if (notifyBlock != null) { + this.notifyBlock = notifyBlock; + } + } + + public void updateRunningSetting(RunningSettingDTO.Request runningSettingDTO) { + if (runningSettingDTO.getDailyDistanceGoal() != null) this.dailyDistanceGoal = runningSettingDTO.getDailyDistanceGoal(); + if (runningSettingDTO.getMonthlyRunCountGoal() != null) this.monthlyRunCountGoal = runningSettingDTO.getMonthlyRunCountGoal(); + if (runningSettingDTO.getPreferredDifficulty() != null) this.preferredDifficulty = runningSettingDTO.getPreferredDifficulty(); + if (runningSettingDTO.getAutoPause() != null) this.autoPause = runningSettingDTO.getAutoPause(); + if (runningSettingDTO.getMapStyle() != null) this.mapStyle = runningSettingDTO.getMapStyle(); + if (runningSettingDTO.getRadius() != null) this.radius = runningSettingDTO.getRadius(); + if (runningSettingDTO.getPaceUnit() != null) this.paceUnit = runningSettingDTO.getPaceUnit(); + if (runningSettingDTO.getTtsEnabled() != null) this.ttsEnabled = runningSettingDTO.getTtsEnabled(); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/member/entity/RunningBackup.java b/runtracker/src/main/java/com/runtracker/domain/member/entity/RunningBackup.java new file mode 100644 index 0000000..732b415 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/member/entity/RunningBackup.java @@ -0,0 +1,38 @@ +package com.runtracker.domain.member.entity; + +import com.runtracker.domain.member.enums.BackupType; +import com.runtracker.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "running_backup") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class RunningBackup extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "member_id", nullable = false) + private Long memberId; + + @Column(name = "backup_data", columnDefinition = "JSON") + private String backupData; + + @Enumerated(EnumType.STRING) + @Column(name = "backup_type", nullable = false) + private BackupType backupType; + + public void updateBackupData(String backupData) { + this.backupData = backupData; + this.backupType = BackupType.ORIGINAL; + } + + public void markAsRestored() { + this.backupType = BackupType.RESTORED; + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/member/entity/enums/MemberRole.java b/runtracker/src/main/java/com/runtracker/domain/member/entity/enums/MemberRole.java new file mode 100644 index 0000000..11407b9 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/member/entity/enums/MemberRole.java @@ -0,0 +1,23 @@ +package com.runtracker.domain.member.entity.enums; + +public enum MemberRole { + USER("일반 사용자"), + CREW_LEADER("크루장"), + CREW_MANAGER("크루 매니저"), + CREW_MEMBER("크루원"), + ADMIN("관리자"); + + private final String description; + + MemberRole(String description) { + this.description = description; + } + + public String getDescription() { + return description; + } + + public String getAuthority() { + return "ROLE_" + this.name(); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/member/enums/BackupType.java b/runtracker/src/main/java/com/runtracker/domain/member/enums/BackupType.java new file mode 100644 index 0000000..fd33dd1 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/member/enums/BackupType.java @@ -0,0 +1,13 @@ +package com.runtracker.domain.member.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum BackupType { + ORIGINAL("저장된 백업"), + RESTORED("복원된 백업"); + + private final String description; +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/member/enums/MapStyle.java b/runtracker/src/main/java/com/runtracker/domain/member/enums/MapStyle.java new file mode 100644 index 0000000..e11fe91 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/member/enums/MapStyle.java @@ -0,0 +1,14 @@ +package com.runtracker.domain.member.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum MapStyle { + STANDARD("기본 지도"), + SATELLITE("위성 지도"), + HYBRID("하이브리드 지도"); + + private final String description; +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/member/enums/MemberErrorCode.java b/runtracker/src/main/java/com/runtracker/domain/member/enums/MemberErrorCode.java new file mode 100644 index 0000000..3e8b104 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/member/enums/MemberErrorCode.java @@ -0,0 +1,22 @@ +package com.runtracker.domain.member.enums; + +import com.runtracker.global.code.ResponseCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum MemberErrorCode implements ResponseCode { + + MEMBER_NOT_FOUND("M001", "Member not found"), + MEMBER_WITHDRAWAL_FAILED("M002", "Member withdrawal failed"), + INVALID_DIFFICULTY("M003", "Invalid difficulty value"), + BACKUP_NOT_FOUND("M004", "Backup not found"), + BACKUP_SERIALIZATION_FAILED("M005", "Failed to serialize backup data"), + BACKUP_DESERIALIZATION_FAILED("M006", "Failed to deserialize backup data"), + BACKUP_ALREADY_RESTORED("M007", "Backup has already been restored"), + INVALID_MAP_STYLE("M008", "Invalid map style value"); + + private final String statusCode; + private final String message; +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/member/exception/BackupAlreadyRestoredException.java b/runtracker/src/main/java/com/runtracker/domain/member/exception/BackupAlreadyRestoredException.java new file mode 100644 index 0000000..a434d98 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/member/exception/BackupAlreadyRestoredException.java @@ -0,0 +1,10 @@ +package com.runtracker.domain.member.exception; + +import com.runtracker.domain.member.enums.MemberErrorCode; +import com.runtracker.global.exception.CustomException; + +public class BackupAlreadyRestoredException extends CustomException { + public BackupAlreadyRestoredException(String message) { + super(MemberErrorCode.BACKUP_ALREADY_RESTORED, message); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/member/exception/BackupDeserializationException.java b/runtracker/src/main/java/com/runtracker/domain/member/exception/BackupDeserializationException.java new file mode 100644 index 0000000..02d2d8f --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/member/exception/BackupDeserializationException.java @@ -0,0 +1,10 @@ +package com.runtracker.domain.member.exception; + +import com.runtracker.domain.member.enums.MemberErrorCode; +import com.runtracker.global.exception.CustomException; + +public class BackupDeserializationException extends CustomException { + public BackupDeserializationException(String message) { + super(MemberErrorCode.BACKUP_DESERIALIZATION_FAILED, message); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/member/exception/BackupNotFoundException.java b/runtracker/src/main/java/com/runtracker/domain/member/exception/BackupNotFoundException.java new file mode 100644 index 0000000..78b6eb4 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/member/exception/BackupNotFoundException.java @@ -0,0 +1,10 @@ +package com.runtracker.domain.member.exception; + +import com.runtracker.domain.member.enums.MemberErrorCode; +import com.runtracker.global.exception.CustomException; + +public class BackupNotFoundException extends CustomException { + public BackupNotFoundException(String message) { + super(MemberErrorCode.BACKUP_NOT_FOUND, message); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/member/exception/BackupSerializationException.java b/runtracker/src/main/java/com/runtracker/domain/member/exception/BackupSerializationException.java new file mode 100644 index 0000000..26a015a --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/member/exception/BackupSerializationException.java @@ -0,0 +1,10 @@ +package com.runtracker.domain.member.exception; + +import com.runtracker.domain.member.enums.MemberErrorCode; +import com.runtracker.global.exception.CustomException; + +public class BackupSerializationException extends CustomException { + public BackupSerializationException(String message) { + super(MemberErrorCode.BACKUP_SERIALIZATION_FAILED, message); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/member/exception/InvalidDifficultyException.java b/runtracker/src/main/java/com/runtracker/domain/member/exception/InvalidDifficultyException.java new file mode 100644 index 0000000..d3d9236 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/member/exception/InvalidDifficultyException.java @@ -0,0 +1,15 @@ +package com.runtracker.domain.member.exception; + +import com.runtracker.domain.member.enums.MemberErrorCode; +import com.runtracker.global.exception.CustomException; + +public class InvalidDifficultyException extends CustomException { + + public InvalidDifficultyException() { + super(MemberErrorCode.INVALID_DIFFICULTY); + } + + public InvalidDifficultyException(String message) { + super(MemberErrorCode.INVALID_DIFFICULTY, message); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/member/exception/InvalidMapStyleException.java b/runtracker/src/main/java/com/runtracker/domain/member/exception/InvalidMapStyleException.java new file mode 100644 index 0000000..6ecd0cf --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/member/exception/InvalidMapStyleException.java @@ -0,0 +1,15 @@ +package com.runtracker.domain.member.exception; + +import com.runtracker.domain.member.enums.MemberErrorCode; +import com.runtracker.global.exception.CustomException; + +public class InvalidMapStyleException extends CustomException { + + public InvalidMapStyleException() { + super(MemberErrorCode.INVALID_MAP_STYLE); + } + + public InvalidMapStyleException(String message) { + super(MemberErrorCode.INVALID_MAP_STYLE, message); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/member/exception/MemberNotFoundException.java b/runtracker/src/main/java/com/runtracker/domain/member/exception/MemberNotFoundException.java new file mode 100644 index 0000000..342b688 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/member/exception/MemberNotFoundException.java @@ -0,0 +1,15 @@ +package com.runtracker.domain.member.exception; + +import com.runtracker.domain.member.enums.MemberErrorCode; +import com.runtracker.global.exception.CustomException; + +public class MemberNotFoundException extends CustomException { + + public MemberNotFoundException() { + super(MemberErrorCode.MEMBER_NOT_FOUND); + } + + public MemberNotFoundException(String message) { + super(MemberErrorCode.MEMBER_NOT_FOUND, message); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/member/exception/MemberWithdrawalFailedException.java b/runtracker/src/main/java/com/runtracker/domain/member/exception/MemberWithdrawalFailedException.java new file mode 100644 index 0000000..1934278 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/member/exception/MemberWithdrawalFailedException.java @@ -0,0 +1,15 @@ +package com.runtracker.domain.member.exception; + +import com.runtracker.domain.member.enums.MemberErrorCode; +import com.runtracker.global.exception.CustomException; + +public class MemberWithdrawalFailedException extends CustomException { + + public MemberWithdrawalFailedException() { + super(MemberErrorCode.MEMBER_WITHDRAWAL_FAILED); + } + + public MemberWithdrawalFailedException(String message) { + super(MemberErrorCode.MEMBER_WITHDRAWAL_FAILED, message); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/member/repository/FcmTokenRepository.java b/runtracker/src/main/java/com/runtracker/domain/member/repository/FcmTokenRepository.java new file mode 100644 index 0000000..3087327 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/member/repository/FcmTokenRepository.java @@ -0,0 +1,21 @@ +package com.runtracker.domain.member.repository; + +import com.runtracker.domain.member.entity.FcmToken; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface FcmTokenRepository extends JpaRepository { + + Optional findByMemberId(Long memberId); + + @Query("SELECT f.token FROM FcmToken f WHERE f.memberId = :memberId") + Optional findTokenByMemberId(@Param("memberId") Long memberId); + + void deleteByMemberId(Long memberId); +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/member/repository/MemberRepository.java b/runtracker/src/main/java/com/runtracker/domain/member/repository/MemberRepository.java new file mode 100644 index 0000000..62116c6 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/member/repository/MemberRepository.java @@ -0,0 +1,13 @@ +package com.runtracker.domain.member.repository; + +import com.runtracker.domain.member.entity.Member; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface MemberRepository extends JpaRepository { + Optional findBySocialId(String socialId); + Optional findByName(String name); +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/member/repository/RunningBackupRepository.java b/runtracker/src/main/java/com/runtracker/domain/member/repository/RunningBackupRepository.java new file mode 100644 index 0000000..5d6caa1 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/member/repository/RunningBackupRepository.java @@ -0,0 +1,14 @@ +package com.runtracker.domain.member.repository; + +import com.runtracker.domain.member.entity.RunningBackup; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface RunningBackupRepository extends JpaRepository { + Optional findByMemberId(Long memberId); + + void deleteByMemberId(Long memberId); +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/member/service/AuthService.java b/runtracker/src/main/java/com/runtracker/domain/member/service/AuthService.java new file mode 100644 index 0000000..0504f4d --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/member/service/AuthService.java @@ -0,0 +1,51 @@ +package com.runtracker.domain.member.service; + +import com.runtracker.domain.member.entity.Member; +import com.runtracker.domain.member.service.dto.LoginTokenDto; +import com.runtracker.global.jwt.JwtUtil; +import com.runtracker.global.jwt.dto.TokenDataDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AuthService { + + private final MemberService memberService; + private final JwtUtil jwtUtil; + + @Value("${app.auth.key}") + private String authKey; + + @Transactional + public TokenDataDto refreshToken(String refreshToken) { + return jwtUtil.refreshToken(refreshToken); + } + + @Transactional + public LoginTokenDto testLoginBySocialId(String socialId, String key) { + validateAuthKey(key); + + Member member = memberService.getMemberBySocialId(socialId); + + log.info("test login by socialId - userId: {}", member.getId()); + + TokenDataDto tokenData = jwtUtil.createTokenData(member.getId(), member.getSocialId()); + + return LoginTokenDto.builder() + .userId(member.getId()) + .socialId(member.getSocialId()) + .tokenData(tokenData) + .build(); + } + + private void validateAuthKey(String key) { + if (!authKey.equals(key)) { + throw new RuntimeException("Invalid authentication key"); + } + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/member/service/MemberService.java b/runtracker/src/main/java/com/runtracker/domain/member/service/MemberService.java new file mode 100644 index 0000000..03c905e --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/member/service/MemberService.java @@ -0,0 +1,419 @@ +package com.runtracker.domain.member.service; + +import com.runtracker.domain.member.entity.FcmToken; +import com.runtracker.domain.member.repository.FcmTokenRepository; +import com.runtracker.domain.member.service.dto.LoginTokenDto; +import com.runtracker.domain.member.entity.Member; +import com.runtracker.domain.member.entity.RunningBackup; +import com.runtracker.domain.member.repository.MemberRepository; +import com.runtracker.domain.member.repository.RunningBackupRepository; +import com.runtracker.domain.course.repository.CourseRepository; +import com.runtracker.domain.record.repository.RecordRepository; +import com.runtracker.domain.crew.repository.CrewRepository; +import com.runtracker.domain.crew.repository.CrewMemberRepository; +import com.runtracker.domain.crew.repository.CrewMemberRankingRepository; +import com.runtracker.domain.community.repository.PostRepository; +import com.runtracker.domain.community.repository.CommentRepository; +import com.runtracker.domain.community.repository.PostLikeRepository; +import com.runtracker.domain.schedule.repository.ScheduleRepository; +import com.runtracker.domain.record.entity.RunningRecord; +import com.runtracker.domain.member.exception.MemberNotFoundException; +import com.runtracker.domain.member.exception.InvalidDifficultyException; +import com.runtracker.domain.member.exception.InvalidMapStyleException; +import com.runtracker.domain.member.exception.BackupNotFoundException; +import com.runtracker.domain.member.exception.BackupSerializationException; +import com.runtracker.domain.member.exception.BackupDeserializationException; +import com.runtracker.domain.member.exception.BackupAlreadyRestoredException; +import com.runtracker.domain.member.dto.MemberUpdateDTO; +import com.runtracker.domain.member.dto.MemberCreateDTO; +import com.runtracker.domain.member.dto.NotificationSettingDTO; +import com.runtracker.domain.member.dto.RunningBackupDTO; +import com.runtracker.domain.member.dto.RunningSettingDTO; +import com.runtracker.domain.member.enums.BackupType; +import com.runtracker.domain.member.enums.MapStyle; +import com.runtracker.domain.course.enums.Difficulty; +import com.runtracker.global.util.ImageUpload; +import com.runtracker.global.jwt.JwtUtil; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import java.util.List; +import java.util.Optional; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MemberService { + + private final MemberRepository memberRepository; + private final CourseRepository courseRepository; + private final RecordRepository recordRepository; + private final RunningBackupRepository backupRepository; + private final FcmTokenRepository fcmTokenRepository; + private final CrewRepository crewRepository; + private final CrewMemberRepository crewMemberRepository; + private final CrewMemberRankingRepository crewMemberRankingRepository; + private final PostRepository postRepository; + private final CommentRepository commentRepository; + private final PostLikeRepository postLikeRepository; + private final ScheduleRepository scheduleRepository; + private final ObjectMapper objectMapper; + private final JwtUtil jwtUtil; + private final ImageUpload imageUploadHelper; + + @Transactional + public Member createOrUpdateMember(String socialAttr, String socialId, + String photo, String name) { + Optional existingMember = memberRepository.findBySocialId(socialId); + + if (existingMember.isPresent()) { + Member member = existingMember.get(); + if (photo != null) { + member.updatePhoto(photo); + } + return member; + } + + MemberCreateDTO memberCreateDTO = MemberCreateDTO.builder() + .socialAttr(socialAttr) + .socialId(socialId) + .photo(photo) + .name(name) + .build(); + + Member newMember = new Member(memberCreateDTO); + return memberRepository.save(newMember); + } + + + public Member getMemberByName(String name) { + return memberRepository.findByName(name) + .orElseThrow(() -> new MemberNotFoundException("Member not found with name: " + name)); + } + + public Member getMemberBySocialId(String socialId) { + return memberRepository.findBySocialId(socialId) + .orElseThrow(() -> new MemberNotFoundException("Member not found with socialId: " + socialId)); + } + + @Transactional(readOnly = true) + public LoginTokenDto.MemberSearchResult findMemberByName(String name) { + Member member = getMemberByName(name); + + log.info("find member by name - userId: {}, socialId: {}", member.getId(), member.getSocialId()); + + return LoginTokenDto.MemberSearchResult.builder() + .userId(member.getId()) + .socialId(member.getSocialId()) + .build(); + } + + @Transactional + public void logout(Long memberId) { + // 현재 요청에서 토큰 추출 + ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attributes != null) { + String authHeader = attributes.getRequest().getHeader("Authorization"); + if (authHeader != null && authHeader.startsWith("Bearer ")) { + String token = authHeader.substring(7); + try { + jwtUtil.blacklistToken(token); + } catch (Exception e) { + log.error("Failed to blacklist token for user: {}", memberId, e); + } + } + } + + removeFcmToken(memberId); + } + + public Member getMemberById(Long memberId) { + return memberRepository.findById(memberId) + .orElseThrow(() -> new MemberNotFoundException("Member not found with id: " + memberId)); + } + + @Transactional + public Member updateProfile(Long memberId, MemberUpdateDTO.Request memberUpdateDTO) { + Member member = getMemberById(memberId); + + if (memberUpdateDTO.getDifficulty() != null) { + validateDifficulty(memberUpdateDTO.getDifficulty()); + } + + if (memberUpdateDTO.getPhoto() != null) { + String photoUrl = imageUploadHelper.convertBase64ToUrlIfNeeded(memberUpdateDTO.getPhoto()); + MemberUpdateDTO.Request updatedDTO = MemberUpdateDTO.Request.builder() + .photo(photoUrl) + .name(memberUpdateDTO.getName()) + .introduce(memberUpdateDTO.getIntroduce()) + .region(memberUpdateDTO.getRegion()) + .difficulty(memberUpdateDTO.getDifficulty()) + .age(memberUpdateDTO.getAge()) + .gender(memberUpdateDTO.getGender()) + .searchBlock(memberUpdateDTO.getSearchBlock()) + .profileBlock(memberUpdateDTO.getProfileBlock()) + .build(); + member.updateProfile(updatedDTO); + } else { + member.updateProfile(memberUpdateDTO); + } + + return member; + } + + private void validateDifficulty(String difficulty) { + try { + Difficulty.valueOf(difficulty); + } catch (IllegalArgumentException e) { + throw new InvalidDifficultyException("Invalid difficulty value. Must be one of: EASY, MEDIUM, HARD"); + } + } + + @Transactional + public void updateNotificationSetting(Long memberId, NotificationSettingDTO.Request request) { + Member member = getMemberById(memberId); + member.updateNotificationSetting(request.getNotifyBlock()); + } + + @Transactional(readOnly = true) + public RunningSettingDTO.Response getRunningSetting(Long memberId) { + Member member = getMemberById(memberId); + return RunningSettingDTO.Response.from(member); + } + + @Transactional + public void updateRunningSetting(Long memberId, RunningSettingDTO.Request runningSettingDTO) { + Member member = getMemberById(memberId); + + if (runningSettingDTO.getPreferredDifficulty() != null) { + validateDifficulty(runningSettingDTO.getPreferredDifficulty()); + } + + if (runningSettingDTO.getMapStyle() != null) { + validateMapStyle(runningSettingDTO.getMapStyle()); + } + + member.updateRunningSetting(runningSettingDTO); + } + + private void validateMapStyle(String mapStyle) { + try { + MapStyle.valueOf(mapStyle); + } catch (IllegalArgumentException e) { + throw new InvalidMapStyleException("Invalid map style value. Must be one of: STANDARD, SATELLITE, HYBRID"); + } + } + + @Transactional + public void updateFcmToken(Long memberId, String token) { + Optional existingToken = fcmTokenRepository.findByMemberId(memberId); + + if (existingToken.isPresent()) { + existingToken.get().updateToken(token); + } else { + FcmToken newToken = FcmToken.builder() + .memberId(memberId) + .token(token) + .build(); + fcmTokenRepository.save(newToken); + } + } + + @Transactional + public void removeFcmToken(Long memberId) { + fcmTokenRepository.findByMemberId(memberId) + .ifPresent(token -> token.updateToken(null)); + } + + public Optional getFcmToken(Long memberId) { + return fcmTokenRepository.findTokenByMemberId(memberId); + } + + @Transactional + public void withdrawMember(Long memberId) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new MemberNotFoundException("Member not found with id: " + memberId)); + + ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attributes != null) { + String authHeader = attributes.getRequest().getHeader("Authorization"); + if (authHeader != null && authHeader.startsWith("Bearer ")) { + String token = authHeader.substring(7); + try { + jwtUtil.blacklistToken(token); + } catch (Exception e) { + log.error("Failed to blacklist token for withdrawing user: {}", memberId, e); + } + } + } + + fcmTokenRepository.deleteByMemberId(memberId); + postLikeRepository.deleteByMemberId(memberId); + commentRepository.deleteByMemberId(memberId); + postRepository.deleteByMemberId(memberId); + crewMemberRankingRepository.deleteByMemberId(memberId); + crewMemberRepository.deleteByMemberId(memberId); + crewRepository.deleteByLeaderId(memberId); + scheduleRepository.deleteByMemberId(memberId); + recordRepository.deleteByMemberId(memberId); + backupRepository.deleteByMemberId(memberId); + courseRepository.deleteByMemberId(memberId); + memberRepository.delete(member); + } + + @Transactional + public void createBackup(Long memberId) { + try { + Member member = getMemberById(memberId); + List runningRecords = recordRepository.findByMemberId(memberId); + + RunningBackupDTO.MemberBackupData memberBackupData = RunningBackupDTO.MemberBackupData.builder() + .id(member.getId()) + .name(member.getName()) + .socialId(member.getSocialId()) + .build(); + + List recordBackupDataList = runningRecords.stream() + .map(record -> RunningBackupDTO.RecordBackupData.builder() + .id(record.getId()) + .memberId(record.getMemberId()) + .courseId(record.getCourseId()) + .runningTime(record.getRunningTime()) + .startedAt(record.getStartedAt()) + .finishedAt(record.getFinishedAt()) + .distance(record.getDistance()) + .avgPace(record.getAvgPace()) + .avgSpeed(record.getAvgSpeed()) + .kcal(record.getKcal()) + .walkCnt(record.getWalkCnt()) + .avgHeartRate(record.getAvgHeartRate()) + .maxHeartRate(record.getMaxHeartRate()) + .avgCadence(record.getAvgCadence()) + .maxCadence(record.getMaxCadence()) + .path(record.getUserFinishLocation()) + .segmentPaces(record.getSegmentPaces()) + .segmentPaths(record.getSegmentPaths()) + .createdAt(record.getCreatedAt()) + .updatedAt(record.getUpdatedAt()) + .build()) + .toList(); + + RunningBackupDTO.BackupData backupData = RunningBackupDTO.BackupData.builder() + .member(memberBackupData) + .runningRecords(recordBackupDataList) + .build(); + + String backupDataJson = objectMapper.writeValueAsString(backupData); + + Optional existingBackup = backupRepository.findByMemberId(memberId); + + if (existingBackup.isPresent()) { + existingBackup.get().updateBackupData(backupDataJson); + } else { + RunningBackup newBackup = RunningBackup.builder() + .memberId(memberId) + .backupData(backupDataJson) + .backupType(BackupType.ORIGINAL) + .build(); + backupRepository.save(newBackup); + } + + } catch (JsonProcessingException e) { + throw new BackupSerializationException("Failed to serialize backup data for member: " + memberId); + } catch (Exception e) { + throw new BackupSerializationException("Failed to create backup for member: " + memberId); + } + } + + @Transactional + public void restoreRunningRecords(Long memberId) { + Optional backup = backupRepository.findByMemberId(memberId); + + if (backup.isEmpty()) { + throw new BackupNotFoundException("No backup found for member: " + memberId); + } + + RunningBackup backupEntity = backup.get(); + + if (BackupType.RESTORED.equals(backupEntity.getBackupType())) { + throw new BackupAlreadyRestoredException("This backup has already been restored"); + } + + try { + RunningBackupDTO.BackupData backupData = objectMapper.readValue( + backup.get().getBackupData(), + new TypeReference<>() {} + ); + + List currentRecords = recordRepository.findByMemberId(memberId); + + for (RunningBackupDTO.RecordBackupData backupRecord : backupData.getRunningRecords()) { + boolean recordExists = currentRecords.stream() + .anyMatch(current -> current.getId().equals(backupRecord.getId())); + + if (!recordExists) { + RunningRecord newRecord = RunningRecord.builder() + .memberId(backupRecord.getMemberId()) + .courseId(backupRecord.getCourseId()) + .runningTime(backupRecord.getRunningTime()) + .startedAt(backupRecord.getStartedAt()) + .finishedAt(backupRecord.getFinishedAt()) + .distance(backupRecord.getDistance()) + .avgPace(backupRecord.getAvgPace()) + .avgSpeed(backupRecord.getAvgSpeed()) + .kcal(backupRecord.getKcal()) + .walkCnt(backupRecord.getWalkCnt()) + .avgHeartRate(backupRecord.getAvgHeartRate()) + .maxHeartRate(backupRecord.getMaxHeartRate()) + .avgCadence(backupRecord.getAvgCadence()) + .maxCadence(backupRecord.getMaxCadence()) + .userFinishLocation(backupRecord.getPath()) + .segmentPaces(backupRecord.getSegmentPaces()) + .segmentPaths(backupRecord.getSegmentPaths()) + .build(); + recordRepository.save(newRecord); + } + } + + backupEntity.markAsRestored(); + + } catch (JsonProcessingException e) { + throw new BackupDeserializationException("Failed to deserialize backup data for member: " + memberId); + } + } + + @Transactional(readOnly = true) + public RunningBackupDTO.BackupInfo getBackupInfo(Long memberId) { + Optional backup = backupRepository.findByMemberId(memberId); + + if (backup.isEmpty()) { + throw new BackupNotFoundException("No backup found for member: " + memberId); + } + + RunningBackup backupEntity = backup.get(); + + try { + RunningBackupDTO.BackupData backupData = objectMapper.readValue( + backupEntity.getBackupData(), + new TypeReference<>() {} + ); + + return RunningBackupDTO.BackupInfo.builder() + .backupId(backupEntity.getId()) + .backupType(backupEntity.getBackupType().name()) + .recordCount(backupData.getRunningRecords().size()) + .updatedAt(backupEntity.getUpdatedAt().toString()) + .build(); + } catch (JsonProcessingException e) { + throw new BackupDeserializationException("Failed to parse backup data for member: " + memberId); + } + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/member/service/TempCalcService.java b/runtracker/src/main/java/com/runtracker/domain/member/service/TempCalcService.java new file mode 100644 index 0000000..4b51548 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/member/service/TempCalcService.java @@ -0,0 +1,42 @@ +package com.runtracker.domain.member.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class TempCalcService { + + private static final double BASE_TEMPERATURE_INCREASE = 0.1; // 기본 온도 증가량 + private static final double COMPLETION_MULTIPLIER = 1.0; // 완주 시 배수 + private static final double INCOMPLETE_MULTIPLIER = 0.7; // 미완주 시 배수 + private static final double COMPLETION_THRESHOLD = 0.9; // 완주 판정 기준 (90%) + + // 완주 여부 판정 + public boolean isCompleted(double completionRate) { + return completionRate >= COMPLETION_THRESHOLD; + } + + /** + * 러닝 기록에 따른 온도 계산 + * + * @param currentTemperature 현재 온도 + * @param actualDistance 실제 러닝한 거리 + * @param courseDistance 코스의 총 거리 + * @return 새로운 온도 (최대 100도) + */ + public double calculateNewTemperature(double currentTemperature, double actualDistance, double courseDistance) { + if (courseDistance <= 0) { + return currentTemperature; + } + + double completionRate = actualDistance / courseDistance; + double multiplier = isCompleted(completionRate) ? COMPLETION_MULTIPLIER : INCOMPLETE_MULTIPLIER; + double distanceFactor = Math.min(actualDistance / 1000.0, 10.0); + + double temperatureIncrease = BASE_TEMPERATURE_INCREASE * multiplier * (1 + distanceFactor * 0.1); + double newTemperature = currentTemperature + temperatureIncrease; + + return Math.min(newTemperature, 100.0); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/member/service/dto/LoginTokenDto.java b/runtracker/src/main/java/com/runtracker/domain/member/service/dto/LoginTokenDto.java new file mode 100644 index 0000000..625126c --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/member/service/dto/LoginTokenDto.java @@ -0,0 +1,36 @@ +package com.runtracker.domain.member.service.dto; + +import com.runtracker.global.jwt.dto.TokenDataDto; +import jakarta.validation.constraints.NotBlank; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class LoginTokenDto { + private Long userId; + private String socialId; + private TokenDataDto tokenData; + + @Getter + public static class SocialIdLoginRequest { + @NotBlank(message = "소셜 ID는 필수입니다") + private String socialId; + + @NotBlank(message = "인증 키는 필수입니다") + private String key; + } + + @Getter + @Builder + public static class MemberSearchResult { + private Long userId; + private String socialId; + } + + @Getter + public static class RefreshTokenRequest { + @NotBlank(message = "리프레시 토큰은 필수입니다") + private String refreshToken; + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/notification/eventHandler/NotificationEventHandler.java b/runtracker/src/main/java/com/runtracker/domain/notification/eventHandler/NotificationEventHandler.java new file mode 100644 index 0000000..1cfe255 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/notification/eventHandler/NotificationEventHandler.java @@ -0,0 +1,287 @@ +package com.runtracker.domain.notification.eventHandler; + +import com.runtracker.domain.crew.event.CrewJoinRequestEvent; +import com.runtracker.domain.crew.event.CrewJoinRequestCancelEvent; +import com.runtracker.domain.crew.event.CrewJoinRequestApprovalEvent; +import com.runtracker.domain.crew.event.CrewMemberRoleUpdateEvent; +import com.runtracker.domain.crew.event.CrewDeleteEvent; +import com.runtracker.domain.crew.event.CrewBanEvent; +import com.runtracker.domain.crew.event.CrewLeaveEvent; +import com.runtracker.domain.community.event.PostCreateEvent; +import com.runtracker.domain.community.event.PostUpdateEvent; +import com.runtracker.domain.community.event.PostDeleteEvent; +import com.runtracker.domain.community.event.PostLikeEvent; +import com.runtracker.domain.community.event.PostCommentEvent; +import com.runtracker.domain.schedule.event.ScheduleCreateEvent; +import com.runtracker.domain.schedule.event.ScheduleUpdateEvent; +import com.runtracker.domain.schedule.event.ScheduleDeleteEvent; +import com.runtracker.domain.schedule.event.ScheduleJoinEvent; +import com.runtracker.domain.schedule.event.ScheduleCancelEvent; +import com.runtracker.domain.notification.service.NotificationService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +@Slf4j +public class NotificationEventHandler { + + private final NotificationService notificationService; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void sendCrewJoinRequestNotification(CrewJoinRequestEvent event) { + try { + notificationService.notifyCrewJoinRequest(event.requestUserId(), event.managerId(), event.crewId()); + } catch (Exception e) { + log.error("Failed to send crew join request notification - requestUserId: {}, managerId: {}, crewId: {}, error: {}", + event.requestUserId(), + event.managerId(), + event.crewId(), + e.getMessage()); + throw e; + } + } + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void sendCrewJoinRequestCancelNotification(CrewJoinRequestCancelEvent event) { + try { + notificationService.notifyCrewJoinRequestCancel(event.canceledUserId(), event.managerId(), event.crewId()); + } catch (Exception e) { + log.error("Failed to send crew join request cancel notification - canceledUserId: {}, managerId: {}, crewId: {}, error: {}", + event.canceledUserId(), + event.managerId(), + event.crewId(), + e.getMessage()); + throw e; + } + } + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void sendCrewJoinRequestApprovalNotification(CrewJoinRequestApprovalEvent event) { + try { + notificationService.notifyCrewJoinRequestApproval(event.approvedUserId(), event.crewId(), event.isApproved()); + } catch (Exception e) { + log.error("Failed to send crew join request approval notification - approvedUserId: {}, crewId: {}, isApproved: {}, error: {}", + event.approvedUserId(), + event.crewId(), + event.isApproved(), + e.getMessage()); + throw e; + } + } + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void sendCrewMemberRoleUpdateNotification(CrewMemberRoleUpdateEvent event) { + try { + notificationService.notifyCrewMemberRoleUpdate(event.targetMemberId(), event.crewId(), event.newRole()); + } catch (Exception e) { + log.error("Failed to send crew member role update notification - targetMemberId: {}, crewId: {}, newRole: {}, error: {}", + event.targetMemberId(), + event.crewId(), + event.newRole(), + e.getMessage()); + throw e; + } + } + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void sendCrewDeleteNotification(CrewDeleteEvent event) { + try { + for (Long memberId : event.memberIds()) { + notificationService.notifyCrewDeletion(memberId, event.crewTitle()); + } + } catch (Exception e) { + log.error("Failed to send crew delete notification - memberIds: {}, crewTitle: {}, error: {}", + event.memberIds(), + event.crewTitle(), + e.getMessage()); + throw e; + } + } + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void sendCrewBanNotification(CrewBanEvent event) { + try { + notificationService.notifyCrewBan(event.bannedMemberId(), event.crewTitle()); + } catch (Exception e) { + log.error("Failed to send crew ban notification - bannedMemberId: {}, crewTitle: {}, error: {}", + event.bannedMemberId(), + event.crewTitle(), + e.getMessage()); + throw e; + } + } + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void sendCrewLeaveNotification(CrewLeaveEvent event) { + try { + for (Long managerId : event.managerIds()) { + notificationService.notifyCrewLeave(managerId, event.leavingMemberName()); + } + } catch (Exception e) { + log.error("Failed to send crew leave notification - managerIds: {}, leavingMemberName: {}, error: {}", + event.managerIds(), + event.leavingMemberName(), + e.getMessage()); + throw e; + } + } + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void sendPostLikeNotification(PostLikeEvent event) { + try { + notificationService.notifyPostLike(event.likerMemberId(), event.postAuthorMemberId()); + } catch (Exception e) { + log.error("Failed to send post like notification - likerMemberId: {}, postAuthorMemberId: {}, postId: {}, error: {}", + event.likerMemberId(), + event.postAuthorMemberId(), + event.postId(), + e.getMessage()); + throw e; + } + } + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void sendPostCommentNotification(PostCommentEvent event) { + try { + notificationService.notifyPostComment(event.commenterMemberId(), event.postAuthorMemberId()); + } catch (Exception e) { + log.error("Failed to send post comment notification - commenterMemberId: {}, postAuthorMemberId: {}, postId: {}, error: {}", + event.commenterMemberId(), + event.postAuthorMemberId(), + event.postId(), + e.getMessage()); + throw e; + } + } + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void sendPostCreateNotification(PostCreateEvent event) { + try { + notificationService.notifyPostCreate(event.authorMemberId()); + } catch (Exception e) { + log.error("Failed to send post create notification - authorMemberId: {}, postId: {}, error: {}", + event.authorMemberId(), + event.postId(), + e.getMessage()); + throw e; + } + } + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void sendPostUpdateNotification(PostUpdateEvent event) { + try { + notificationService.notifyPostUpdate(event.authorMemberId()); + } catch (Exception e) { + log.error("Failed to send post update notification - authorMemberId: {}, postId: {}, error: {}", + event.authorMemberId(), + event.postId(), + e.getMessage()); + throw e; + } + } + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void sendPostDeleteNotification(PostDeleteEvent event) { + try { + notificationService.notifyPostDelete(event.authorMemberId()); + } catch (Exception e) { + log.error("Failed to send post delete notification - authorMemberId: {}, postId: {}, error: {}", + event.authorMemberId(), + event.postId(), + e.getMessage()); + throw e; + } + } + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void sendScheduleCreateNotification(ScheduleCreateEvent event) { + try { + notificationService.notifyScheduleCreation(event.creatorId(), event.crewId(), event.scheduleTitle()); + } catch (Exception e) { + log.error("Failed to send schedule creation notification - creatorId: {}, crewId: {}, scheduleTitle: {}, error: {}", + event.creatorId(), + event.crewId(), + event.scheduleTitle(), + e.getMessage()); + throw e; + } + } + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void sendScheduleUpdateNotification(ScheduleUpdateEvent event) { + try { + notificationService.notifyScheduleUpdate(event.updaterId(), event.crewId(), event.scheduleTitle()); + } catch (Exception e) { + log.error("Failed to send schedule update notification - updaterId: {}, crewId: {}, scheduleTitle: {}, error: {}", + event.updaterId(), + event.crewId(), + event.scheduleTitle(), + e.getMessage()); + throw e; + } + } + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void sendScheduleDeleteNotification(ScheduleDeleteEvent event) { + try { + notificationService.notifyScheduleDelete(event.deleterId(), event.crewId(), event.scheduleTitle()); + } catch (Exception e) { + log.error("Failed to send schedule delete notification - deleterId: {}, crewId: {}, scheduleTitle: {}, error: {}", + event.deleterId(), + event.crewId(), + event.scheduleTitle(), + e.getMessage()); + throw e; + } + } + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void sendScheduleJoinNotification(ScheduleJoinEvent event) { + try { + notificationService.notifyScheduleJoin(event.participantId(), event.crewId(), event.scheduleTitle()); + } catch (Exception e) { + log.error("Failed to send schedule join notification - participantId: {}, crewId: {}, scheduleTitle: {}, error: {}", + event.participantId(), + event.crewId(), + event.scheduleTitle(), + e.getMessage()); + throw e; + } + } + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void sendScheduleCancelNotification(ScheduleCancelEvent event) { + try { + notificationService.notifyScheduleCancel(event.participantId(), event.crewId(), event.scheduleTitle()); + } catch (Exception e) { + log.error("Failed to send schedule cancel notification - participantId: {}, crewId: {}, scheduleTitle: {}, error: {}", + event.participantId(), + event.crewId(), + event.scheduleTitle(), + e.getMessage()); + throw e; + } + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/notification/service/NotificationService.java b/runtracker/src/main/java/com/runtracker/domain/notification/service/NotificationService.java new file mode 100644 index 0000000..d499504 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/notification/service/NotificationService.java @@ -0,0 +1,350 @@ +package com.runtracker.domain.notification.service; + +import com.runtracker.domain.member.entity.Member; +import com.runtracker.domain.member.repository.MemberRepository; +import com.runtracker.domain.crew.entity.Crew; +import com.runtracker.domain.crew.repository.CrewRepository; +import com.runtracker.domain.crew.entity.CrewMember; +import com.runtracker.domain.crew.repository.CrewMemberRepository; + +import java.util.List; +import com.runtracker.domain.member.entity.enums.MemberRole; +import com.runtracker.domain.member.service.MemberService; +import com.runtracker.global.fcm.FcmClient; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import com.runtracker.global.util.message.Messages; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Slf4j +public class NotificationService { + + private final ApplicationEventPublisher eventPublisher; + private final FcmClient fcmClient; + private final Messages messages; + private final MemberRepository memberRepository; + private final CrewRepository crewRepository; + private final CrewMemberRepository crewMemberRepository; + private final MemberService memberService; + + @Transactional + public void notifyCrewJoinRequest(Long requestUserId, Long managerId, Long crewId) { + Member requestUser = memberRepository.findById(requestUserId).orElse(null); + if (requestUser == null) { + return; + } + + String title = messages.get("notify.crew.join.title"); + String content = messages.get("notify.crew.join.content", requestUser.getName()); + + String fcmToken = memberService.getFcmToken(managerId).orElse(null); + if (fcmToken == null || fcmToken.trim().isEmpty()) { + log.info("FCM token not found for manager - skipping join request notification: managerId={}", managerId); + return; + } + + fcmClient.send(title, content, fcmToken); + } + + @Transactional + public void notifyCrewJoinRequestCancel(Long canceledUserId, Long managerId, Long crewId) { + Member canceledUser = memberRepository.findById(canceledUserId).orElse(null); + if (canceledUser == null) { + return; + } + + String title = messages.get("notify.crew.cancel.title"); + String content = messages.get("notify.crew.cancel.content", canceledUser.getName()); + + String fcmToken = memberService.getFcmToken(managerId).orElse(null); + if (fcmToken == null || fcmToken.trim().isEmpty()) { + log.info("FCM token not found for manager - skipping cancel notification: managerId={}", managerId); + return; + } + + fcmClient.send(title, content, fcmToken); + } + + @Transactional + public void notifyCrewJoinRequestApproval(Long approvedUserId, Long crewId, boolean isApproved) { + Crew crew = crewRepository.findById(crewId).orElse(null); + if (crew == null) { + return; + } + + String title = isApproved ? + messages.get("notify.crew.approved.title") : + messages.get("notify.crew.rejected.title"); + String content = isApproved ? + messages.get("notify.crew.approved.content", crew.getTitle()) : + messages.get("notify.crew.rejected.content", crew.getTitle()); + + String fcmToken = memberService.getFcmToken(approvedUserId).orElse(null); + if (fcmToken == null || fcmToken.trim().isEmpty()) { + log.info("FCM token not found for approved user - skipping approval notification: approvedUserId={}", approvedUserId); + return; + } + + fcmClient.send(title, content, fcmToken); + } + + @Transactional + public void notifyCrewMemberRoleUpdate(Long targetMemberId, Long crewId, MemberRole newRole) { + Crew crew = crewRepository.findById(crewId).orElse(null); + if (crew == null) { + return; + } + + String roleDisplayName = getRoleDisplayName(newRole); + String title = messages.get("notify.crew.role.update.title"); + String content = messages.get("notify.crew.role.update.content", crew.getTitle(), roleDisplayName); + + String fcmToken = memberService.getFcmToken(targetMemberId).orElse(null); + if (fcmToken == null || fcmToken.trim().isEmpty()) { + log.info("FCM token not found for target member - skipping role update notification: targetMemberId={}", targetMemberId); + return; + } + + fcmClient.send(title, content, fcmToken); + } + + @Transactional + public void notifyCrewDeletion(Long memberId, String crewTitle) { + String title = messages.get("notify.crew.delete.title"); + String content = messages.get("notify.crew.delete.content", crewTitle); + + String fcmToken = memberService.getFcmToken(memberId).orElse(null); + if (fcmToken == null || fcmToken.trim().isEmpty()) { + log.info("FCM token not found for member - skipping crew deletion notification: memberId={}", memberId); + return; + } + + fcmClient.send(title, content, fcmToken); + } + + @Transactional + public void notifyCrewBan(Long memberId, String crewTitle) { + String title = messages.get("notify.crew.ban.title"); + String content = messages.get("notify.crew.ban.content", crewTitle); + + String fcmToken = memberService.getFcmToken(memberId).orElse(null); + if (fcmToken == null || fcmToken.trim().isEmpty()) { + log.info("FCM token not found for member - skipping crew ban notification: memberId={}", memberId); + return; + } + + fcmClient.send(title, content, fcmToken); + } + + @Transactional + public void notifyCrewLeave(Long managerId, String leavingMemberName) { + String title = messages.get("notify.crew.leave.title"); + String content = messages.get("notify.crew.leave.content", leavingMemberName); + + String fcmToken = memberService.getFcmToken(managerId).orElse(null); + if (fcmToken == null || fcmToken.trim().isEmpty()) { + log.info("FCM token not found for manager - skipping crew leave notification: managerId={}", managerId); + return; + } + + fcmClient.send(title, content, fcmToken); + } + + @Transactional + public void notifyPostLike(Long likerMemberId, Long postAuthorMemberId) { + if (likerMemberId.equals(postAuthorMemberId)) { + return; + } + + Member likerMember = memberRepository.findById(likerMemberId).orElse(null); + if (likerMember == null) { + return; + } + + String title = messages.get("notify.post.like.title"); + String content = messages.get("notify.post.like.content", likerMember.getName()); + + String fcmToken = memberService.getFcmToken(postAuthorMemberId).orElse(null); + if (fcmToken == null || fcmToken.trim().isEmpty()) { + log.info("FCM token not found for post author - skipping post like notification: postAuthorMemberId={}", postAuthorMemberId); + return; + } + + fcmClient.send(title, content, fcmToken); + } + + @Transactional + public void notifyPostComment(Long commenterMemberId, Long postAuthorMemberId) { + if (commenterMemberId.equals(postAuthorMemberId)) { + return; + } + + Member commenterMember = memberRepository.findById(commenterMemberId).orElse(null); + if (commenterMember == null) { + return; + } + + String title = messages.get("notify.post.comment.title"); + String content = messages.get("notify.post.comment.content", commenterMember.getName()); + + String fcmToken = memberService.getFcmToken(postAuthorMemberId).orElse(null); + if (fcmToken == null || fcmToken.trim().isEmpty()) { + log.info("FCM token not found for post author - skipping post comment notification: postAuthorMemberId={}", postAuthorMemberId); + return; + } + + fcmClient.send(title, content, fcmToken); + } + + @Transactional + public void notifyPostCreate(Long authorMemberId) { + String title = messages.get("notify.post.create.title"); + String content = messages.get("notify.post.create.content"); + + String fcmToken = memberService.getFcmToken(authorMemberId).orElse(null); + if (fcmToken == null || fcmToken.trim().isEmpty()) { + log.info("FCM token not found for post author - skipping post create notification: authorMemberId={}", authorMemberId); + return; + } + + fcmClient.send(title, content, fcmToken); + } + + @Transactional + public void notifyPostUpdate(Long authorMemberId) { + String title = messages.get("notify.post.update.title"); + String content = messages.get("notify.post.update.content"); + + String fcmToken = memberService.getFcmToken(authorMemberId).orElse(null); + if (fcmToken == null || fcmToken.trim().isEmpty()) { + log.info("FCM token not found for post author - skipping post update notification: authorMemberId={}", authorMemberId); + return; + } + + fcmClient.send(title, content, fcmToken); + } + + @Transactional + public void notifyPostDelete(Long authorMemberId) { + String title = messages.get("notify.post.delete.title"); + String content = messages.get("notify.post.delete.content"); + + String fcmToken = memberService.getFcmToken(authorMemberId).orElse(null); + if (fcmToken == null || fcmToken.trim().isEmpty()) { + log.info("FCM token not found for post author - skipping post delete notification: authorMemberId={}", authorMemberId); + return; + } + + fcmClient.send(title, content, fcmToken); + } + + @Transactional + public void notifyScheduleCreation(Long creatorId, Long crewId, String scheduleTitle) { + Member creator = memberRepository.findById(creatorId).orElseThrow(); + List crewMembers = crewMemberRepository.findByCrewId(crewId); + + String title = messages.get("notify.schedule.create.title"); + String content = messages.get("notify.schedule.create.content", creator.getName(), scheduleTitle); + + for (CrewMember crewMember : crewMembers) { + String fcmToken = memberService.getFcmToken(crewMember.getMemberId()).orElse(null); + if (fcmToken == null || fcmToken.trim().isEmpty()) { + log.info("FCM token not found for crew member - skipping schedule creation notification: memberId={}", crewMember.getMemberId()); + continue; + } + + fcmClient.send(title, content, fcmToken); + } + } + + @Transactional + public void notifyScheduleUpdate(Long updaterId, Long crewId, String scheduleTitle) { + Member updater = memberRepository.findById(updaterId).orElseThrow(); + List crewMembers = crewMemberRepository.findByCrewId(crewId); + + String title = messages.get("notify.schedule.update.title"); + String content = messages.get("notify.schedule.update.content", updater.getName(), scheduleTitle); + + for (CrewMember crewMember : crewMembers) { + String fcmToken = memberService.getFcmToken(crewMember.getMemberId()).orElse(null); + if (fcmToken == null || fcmToken.trim().isEmpty()) { + log.info("FCM token not found for crew member - skipping schedule update notification: memberId={}", crewMember.getMemberId()); + continue; + } + + fcmClient.send(title, content, fcmToken); + } + } + + @Transactional + public void notifyScheduleDelete(Long deleterId, Long crewId, String scheduleTitle) { + Member deleter = memberRepository.findById(deleterId).orElseThrow(); + List crewMembers = crewMemberRepository.findByCrewId(crewId); + + String title = messages.get("notify.schedule.delete.title"); + String content = messages.get("notify.schedule.delete.content", deleter.getName(), scheduleTitle); + + for (CrewMember crewMember : crewMembers) { + String fcmToken = memberService.getFcmToken(crewMember.getMemberId()).orElse(null); + if (fcmToken == null || fcmToken.trim().isEmpty()) { + log.info("FCM token not found for crew member - skipping schedule delete notification: memberId={}", crewMember.getMemberId()); + continue; + } + + fcmClient.send(title, content, fcmToken); + } + } + + @Transactional + public void notifyScheduleJoin(Long participantId, Long crewId, String scheduleTitle) { + Member participant = memberRepository.findById(participantId).orElseThrow(); + List managers = crewMemberRepository.findByCrewIdAndRoleIn(crewId, + List.of(MemberRole.CREW_LEADER, MemberRole.CREW_MANAGER)); + + String title = messages.get("notify.schedule.join.title"); + String content = messages.get("notify.schedule.join.content", participant.getName(), scheduleTitle); + + for (CrewMember manager : managers) { + String fcmToken = memberService.getFcmToken(manager.getMemberId()).orElse(null); + if (fcmToken == null || fcmToken.trim().isEmpty()) { + log.info("FCM token not found for manager - skipping schedule join notification: memberId={}", manager.getMemberId()); + continue; + } + + fcmClient.send(title, content, fcmToken); + } + } + + @Transactional + public void notifyScheduleCancel(Long participantId, Long crewId, String scheduleTitle) { + Member participant = memberRepository.findById(participantId).orElseThrow(); + List managers = crewMemberRepository.findByCrewIdAndRoleIn(crewId, + List.of(MemberRole.CREW_LEADER, MemberRole.CREW_MANAGER)); + + String title = messages.get("notify.schedule.cancel.title"); + String content = messages.get("notify.schedule.cancel.content", participant.getName(), scheduleTitle); + + for (CrewMember manager : managers) { + String fcmToken = memberService.getFcmToken(manager.getMemberId()).orElse(null); + if (fcmToken == null || fcmToken.trim().isEmpty()) { + log.info("FCM token not found for manager - skipping schedule cancel notification: memberId={}", manager.getMemberId()); + continue; + } + + fcmClient.send(title, content, fcmToken); + } + } + + private String getRoleDisplayName(MemberRole role) { + return switch (role) { + case CREW_LEADER -> "크루장"; + case CREW_MANAGER -> "크루 매니저"; + case CREW_MEMBER -> "크루 멤버"; + default -> "일반 유저"; + }; + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/record/controller/RecordController.java b/runtracker/src/main/java/com/runtracker/domain/record/controller/RecordController.java new file mode 100644 index 0000000..f9ac4ac --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/record/controller/RecordController.java @@ -0,0 +1,55 @@ +package com.runtracker.domain.record.controller; + +import com.runtracker.domain.record.dto.RunningRecordDTO; +import com.runtracker.domain.record.service.RecordService; +import com.runtracker.global.code.DateConstants; +import com.runtracker.global.response.ApiResponse; +import com.runtracker.global.security.UserDetailsImpl; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; +import java.util.List; + +@RestController +@RequestMapping("/api/records") +@RequiredArgsConstructor +public class RecordController { + + private final RecordService recordService; + + @GetMapping("/summary") + public ApiResponse> getRunningRecordsSummary( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @RequestParam(required = true) String type, + @RequestParam(required = true) @DateTimeFormat(pattern = DateConstants.DATE_PATTERN) LocalDate date, + @RequestParam(required = false) @DateTimeFormat(pattern = DateConstants.DATE_PATTERN) LocalDate endDate) { + List records = recordService.getRunningRecordsSummary(userDetails.getMemberId(), type, date, endDate); + return ApiResponse.ok(records); + } + + @GetMapping("/user") + public ApiResponse> getAllRunningRecords( + @AuthenticationPrincipal UserDetailsImpl userDetails) { + List records = recordService.getAllRunningRecords(userDetails.getMemberId()); + return ApiResponse.ok(records); + } + + @GetMapping("/{recordId}") + public ApiResponse getRunningRecord( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable Long recordId) { + RunningRecordDTO record = recordService.getRunningRecordById(userDetails.getMemberId(), recordId); + return ApiResponse.ok(record); + } + + @DeleteMapping("/{recordId}") + public ApiResponse deleteRecord( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable Long recordId) { + recordService.deleteRecord(userDetails.getMemberId(), recordId); + return ApiResponse.ok(); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/record/dto/RunningRecordDTO.java b/runtracker/src/main/java/com/runtracker/domain/record/dto/RunningRecordDTO.java new file mode 100644 index 0000000..fbf18ac --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/record/dto/RunningRecordDTO.java @@ -0,0 +1,97 @@ +package com.runtracker.domain.record.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.runtracker.domain.record.entity.RunningRecord; +import com.runtracker.global.code.DateConstants; +import com.runtracker.global.vo.Coordinate; +import com.runtracker.global.vo.SegmentPace; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class RunningRecordDTO { + + private Long id; + private Long courseId; + + private Integer runningTime; + + @DateTimeFormat(pattern = DateConstants.DATETIME_PATTERN) + @JsonFormat(pattern = DateConstants.DATETIME_PATTERN) + private LocalDateTime startedAt; + + @DateTimeFormat(pattern = DateConstants.DATETIME_PATTERN) + @JsonFormat(pattern = DateConstants.DATETIME_PATTERN) + private LocalDateTime finishedAt; + + private Double distance; + private Double avgPace; + private Double avgSpeed; + private Integer kcal; + private Integer walkCnt; + private Integer avgHeartRate; + private Integer maxHeartRate; + private Integer avgCadence; + private Integer maxCadence; + private List path; + private List segmentPaces; + private List> segmentPaths; + + public static RunningRecordDTO from(RunningRecord runningRecord) { + return new RunningRecordDTO( + runningRecord.getId(), + runningRecord.getCourseId(), + runningRecord.getRunningTime(), + runningRecord.getStartedAt(), + runningRecord.getFinishedAt(), + runningRecord.getDistance(), + runningRecord.getAvgPace(), + runningRecord.getAvgSpeed(), + runningRecord.getKcal(), + runningRecord.getWalkCnt(), + runningRecord.getAvgHeartRate(), + runningRecord.getMaxHeartRate(), + runningRecord.getAvgCadence(), + runningRecord.getMaxCadence(), + // summary나 유저 기록 전체 조회에선 경로 제공하지 않음 (기록 상세 보기에서만 제공) + null, + null, + null + ); + } + + public static RunningRecordDTO fromWithCalculatedPath(RunningRecord runningRecord, List calculatedPath) { + return new RunningRecordDTO( + runningRecord.getId(), + runningRecord.getCourseId(), + runningRecord.getRunningTime(), + runningRecord.getStartedAt(), + runningRecord.getFinishedAt(), + runningRecord.getDistance(), + runningRecord.getAvgPace(), + runningRecord.getAvgSpeed(), + runningRecord.getKcal(), + runningRecord.getWalkCnt(), + runningRecord.getAvgHeartRate(), + runningRecord.getMaxHeartRate(), + runningRecord.getAvgCadence(), + runningRecord.getMaxCadence(), + calculatedPath != null ? calculatedPath : new ArrayList<>(), + runningRecord.getSegmentPaces() != null ? runningRecord.getSegmentPaces() : new ArrayList<>(), + runningRecord.getSegmentPaths() != null ? runningRecord.getSegmentPaths() : new ArrayList<>() + ); + } + +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/record/entity/RunningRecord.java b/runtracker/src/main/java/com/runtracker/domain/record/entity/RunningRecord.java new file mode 100644 index 0000000..4c54db2 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/record/entity/RunningRecord.java @@ -0,0 +1,117 @@ +package com.runtracker.domain.record.entity; + +import com.runtracker.global.entity.BaseEntity; +import com.runtracker.global.vo.Coordinate; +import com.runtracker.global.vo.SegmentPace; +import com.runtracker.global.converter.CoordinatesConverter; +import com.runtracker.global.converter.SegmentPacesConverter; +import com.runtracker.global.converter.SegmentPathsConverter; +import com.runtracker.domain.course.dto.FinishRunning; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "running_record") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class RunningRecord extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "member_id", nullable = false) + private Long memberId; + + @Column(name = "course_id", nullable = false) + private Long courseId; + + @Column(name = "running_time") + private Integer runningTime; + + @Column(name = "started_at") + private LocalDateTime startedAt; + + @Column(name = "finished_at") + private LocalDateTime finishedAt; + + @Column + private String photo; + + @Column(name = "is_goal_achieved", columnDefinition = "TINYINT(1)") + private Boolean isGoalAchieved; + + @Column(name = "distance") + private Double distance; + + @Column(name = "avgPace") + private Double avgPace; + + @Column(name = "avgSpeed") + private Double avgSpeed; + + @Column(name = "kcal") + private Integer kcal; + + @Column(name = "walkCnt") + private Integer walkCnt; + + @Column(name = "avgHeartRate") + private Integer avgHeartRate; + + @Column(name = "maxHeartRate") + private Integer maxHeartRate; + + @Column(name = "avgCadence") + private Integer avgCadence; + + @Column(name = "maxCadence") + private Integer maxCadence; + + @Convert(converter = CoordinatesConverter.class) + @Column(columnDefinition = "json") + @Builder.Default + private List userFinishLocation = new ArrayList<>(); + + @Convert(converter = CoordinatesConverter.class) + @Column(columnDefinition = "json") + @Builder.Default + private List lastCoursePath = new ArrayList<>(); + + @Convert(converter = SegmentPacesConverter.class) + @Column(columnDefinition = "json") + @Builder.Default + private List segmentPaces = new ArrayList<>(); + + @Convert(converter = SegmentPathsConverter.class) + @Column(columnDefinition = "json") + @Builder.Default + private List> segmentPaths = new ArrayList<>(); + + public void updateFinishRunning(Integer runningTime, LocalDateTime finishedAt, FinishRunning finishRunning) { + this.runningTime = runningTime; + this.startedAt = finishRunning.getStartedAt(); + this.finishedAt = finishedAt; + this.distance = finishRunning.getDistance(); + this.avgPace = finishRunning.getAvgPace(); + this.avgSpeed = finishRunning.getAvgSpeed(); + this.kcal = finishRunning.getKcal(); + this.walkCnt = finishRunning.getWalkCnt(); + this.avgHeartRate = finishRunning.getAvgHeartRate(); + this.maxHeartRate = finishRunning.getMaxHeartRate(); + this.avgCadence = finishRunning.getAvgCadence(); + this.maxCadence = finishRunning.getMaxCadence(); + this.userFinishLocation = finishRunning.getUserFinishLocation() != null ? finishRunning.getUserFinishLocation() : new ArrayList<>(); + this.lastCoursePath = finishRunning.getLastCoursePath() != null ? finishRunning.getLastCoursePath() : new ArrayList<>(); + this.segmentPaces = finishRunning.getSegmentPaces() != null ? finishRunning.getSegmentPaces() : new ArrayList<>(); + this.segmentPaths = finishRunning.getSegmentPaths() != null ? finishRunning.getSegmentPaths() : new ArrayList<>(); + this.photo = finishRunning.getPhoto(); + this.isGoalAchieved = finishRunning.getIsGoalAchieved(); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/record/enums/RecordErrorCode.java b/runtracker/src/main/java/com/runtracker/domain/record/enums/RecordErrorCode.java new file mode 100644 index 0000000..64a5388 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/record/enums/RecordErrorCode.java @@ -0,0 +1,21 @@ +package com.runtracker.domain.record.enums; + +import com.runtracker.global.code.ResponseCode; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum RecordErrorCode implements ResponseCode { + + COURSE_NOT_FOUND_FOR_RECORD("RC001", "Course not found for record"), + INVALID_DATE_RANGE("RC002", "Start date must be before or equal to end date"), + DATE_RANGE_TOO_LARGE("RC003", "Date range cannot exceed 365 days"), + DATE_PARAMETER_REQUIRED("RC004", "Date parameters are required"), + RECORD_NOT_FOUND("RC005", "Running record not found"), + UNAUTHORIZED_RECORD_ACCESS("RC006", "Unauthorized access to running record"), + INVALID_SUMMARY_TYPE("RC007", "Invalid summary type"); + + private final String statusCode; + private final String message; +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/record/exception/CourseNotFoundForRecordException.java b/runtracker/src/main/java/com/runtracker/domain/record/exception/CourseNotFoundForRecordException.java new file mode 100644 index 0000000..98f29a6 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/record/exception/CourseNotFoundForRecordException.java @@ -0,0 +1,14 @@ +package com.runtracker.domain.record.exception; + +import com.runtracker.domain.record.enums.RecordErrorCode; +import com.runtracker.global.exception.CustomException; + +public class CourseNotFoundForRecordException extends CustomException { + public CourseNotFoundForRecordException() { + super(RecordErrorCode.COURSE_NOT_FOUND_FOR_RECORD); + } + + public CourseNotFoundForRecordException(String message) { + super(RecordErrorCode.COURSE_NOT_FOUND_FOR_RECORD, message); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/record/exception/DateParameterRequiredException.java b/runtracker/src/main/java/com/runtracker/domain/record/exception/DateParameterRequiredException.java new file mode 100644 index 0000000..fed7545 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/record/exception/DateParameterRequiredException.java @@ -0,0 +1,14 @@ +package com.runtracker.domain.record.exception; + +import com.runtracker.domain.record.enums.RecordErrorCode; +import com.runtracker.global.exception.CustomException; + +public class DateParameterRequiredException extends CustomException { + public DateParameterRequiredException() { + super(RecordErrorCode.DATE_PARAMETER_REQUIRED); + } + + public DateParameterRequiredException(String message) { + super(RecordErrorCode.DATE_PARAMETER_REQUIRED, message); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/record/exception/DateRangeTooLargeException.java b/runtracker/src/main/java/com/runtracker/domain/record/exception/DateRangeTooLargeException.java new file mode 100644 index 0000000..a172d93 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/record/exception/DateRangeTooLargeException.java @@ -0,0 +1,14 @@ +package com.runtracker.domain.record.exception; + +import com.runtracker.domain.record.enums.RecordErrorCode; +import com.runtracker.global.exception.CustomException; + +public class DateRangeTooLargeException extends CustomException { + public DateRangeTooLargeException() { + super(RecordErrorCode.DATE_RANGE_TOO_LARGE); + } + + public DateRangeTooLargeException(String message) { + super(RecordErrorCode.DATE_RANGE_TOO_LARGE, message); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/record/exception/InvalidDateRangeException.java b/runtracker/src/main/java/com/runtracker/domain/record/exception/InvalidDateRangeException.java new file mode 100644 index 0000000..01c0422 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/record/exception/InvalidDateRangeException.java @@ -0,0 +1,14 @@ +package com.runtracker.domain.record.exception; + +import com.runtracker.domain.record.enums.RecordErrorCode; +import com.runtracker.global.exception.CustomException; + +public class InvalidDateRangeException extends CustomException { + public InvalidDateRangeException() { + super(RecordErrorCode.INVALID_DATE_RANGE); + } + + public InvalidDateRangeException(String message) { + super(RecordErrorCode.INVALID_DATE_RANGE, message); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/record/exception/InvalidSummaryTypeException.java b/runtracker/src/main/java/com/runtracker/domain/record/exception/InvalidSummaryTypeException.java new file mode 100644 index 0000000..46e92f7 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/record/exception/InvalidSummaryTypeException.java @@ -0,0 +1,10 @@ +package com.runtracker.domain.record.exception; + +import com.runtracker.domain.record.enums.RecordErrorCode; +import com.runtracker.global.exception.CustomException; + +public class InvalidSummaryTypeException extends CustomException { + public InvalidSummaryTypeException(String message) { + super(RecordErrorCode.INVALID_SUMMARY_TYPE, message); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/record/exception/RecordNotFoundException.java b/runtracker/src/main/java/com/runtracker/domain/record/exception/RecordNotFoundException.java new file mode 100644 index 0000000..e483ba2 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/record/exception/RecordNotFoundException.java @@ -0,0 +1,11 @@ +package com.runtracker.domain.record.exception; + +import com.runtracker.domain.record.enums.RecordErrorCode; +import com.runtracker.global.exception.CustomException; + +public class RecordNotFoundException extends CustomException { + + public RecordNotFoundException(String message) { + super(RecordErrorCode.RECORD_NOT_FOUND, message); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/record/exception/UnauthorizedRecordAccessException.java b/runtracker/src/main/java/com/runtracker/domain/record/exception/UnauthorizedRecordAccessException.java new file mode 100644 index 0000000..5a3a15f --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/record/exception/UnauthorizedRecordAccessException.java @@ -0,0 +1,11 @@ +package com.runtracker.domain.record.exception; + +import com.runtracker.domain.record.enums.RecordErrorCode; +import com.runtracker.global.exception.CustomException; + +public class UnauthorizedRecordAccessException extends CustomException { + + public UnauthorizedRecordAccessException(String message) { + super(RecordErrorCode.UNAUTHORIZED_RECORD_ACCESS, message); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/record/repository/RecordRepository.java b/runtracker/src/main/java/com/runtracker/domain/record/repository/RecordRepository.java new file mode 100644 index 0000000..c4a41b2 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/record/repository/RecordRepository.java @@ -0,0 +1,33 @@ +package com.runtracker.domain.record.repository; + +import com.runtracker.domain.record.entity.RunningRecord; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@Repository +public interface RecordRepository extends JpaRepository { + + List findByMemberId(Long memberId); + + List findByMemberIdOrderByRunningTimeDesc(Long memberId); + + @Query("SELECT r FROM RunningRecord r WHERE r.memberId = :memberId AND DATE(r.startedAt) BETWEEN :startDate AND :endDate ORDER BY r.startedAt DESC") + List findByMemberIdAndRunningTimeBetween(@Param("memberId") Long memberId, @Param("startDate") LocalDate startDate, @Param("endDate") LocalDate endDate); + + List findAllByMemberIdAndFinishedAtIsNull(Long memberId); + + List findByMemberIdInAndCreatedAtBetweenAndFinishedAtIsNotNull(List memberIds, LocalDateTime startDateTime, LocalDateTime endDateTime); + + List findByMemberIdAndCreatedAtBetweenAndFinishedAtIsNotNull(Long memberId, LocalDateTime startDateTime, LocalDateTime endDateTime); + + void deleteByMemberId(Long memberId); +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/record/service/RecordService.java b/runtracker/src/main/java/com/runtracker/domain/record/service/RecordService.java new file mode 100644 index 0000000..16f751f --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/record/service/RecordService.java @@ -0,0 +1,226 @@ +package com.runtracker.domain.record.service; + +import com.runtracker.domain.record.dto.RunningRecordDTO; +import com.runtracker.domain.record.entity.RunningRecord; +import com.runtracker.domain.record.exception.DateParameterRequiredException; +import com.runtracker.domain.record.exception.DateRangeTooLargeException; +import com.runtracker.domain.record.exception.InvalidDateRangeException; +import com.runtracker.domain.record.exception.InvalidSummaryTypeException; +import com.runtracker.domain.record.exception.RecordNotFoundException; +import com.runtracker.domain.record.repository.RecordRepository; +import com.runtracker.domain.course.entity.Course; +import com.runtracker.domain.course.repository.CourseRepository; +import com.runtracker.global.vo.Coordinate; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.time.temporal.TemporalAdjusters; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class RecordService { + + private final RecordRepository recordRepository; + private final CourseRepository courseRepository; + + @Transactional(readOnly = true) + public List getAllRunningRecords(Long memberId) { + List records = recordRepository.findByMemberIdOrderByRunningTimeDesc(memberId); + + if (records.isEmpty()) { + throw new RecordNotFoundException("No running records found for member " + memberId); + } + + return records.stream() + .map(RunningRecordDTO::from) + .collect(Collectors.toList()); + } + + @Transactional(readOnly = true) + public RunningRecordDTO getRunningRecordById(Long memberId, Long recordId) { + RunningRecord record = recordRepository.findById(recordId) + .orElseThrow(() -> new RecordNotFoundException("Running record not found with id: " + recordId)); + + if (!record.getMemberId().equals(memberId)) { + throw new RecordNotFoundException("Running record not found with id: " + recordId); + } + + List calculatedPath = calculateFullPath(record); + return RunningRecordDTO.fromWithCalculatedPath(record, calculatedPath); + } + + private List calculateFullPath(RunningRecord record) { + List fullPath = new ArrayList<>(); + + if (record.getCourseId() != null) { + Optional courseOpt = courseRepository.findById(record.getCourseId()); + if (courseOpt.isPresent()) { + Course course = courseOpt.get(); + List coursePaths = course.getPaths(); + List lastCoursePath = record.getLastCoursePath(); + List userFinishLocation = record.getUserFinishLocation(); + + if (coursePaths != null && !coursePaths.isEmpty()) { + if (lastCoursePath != null && !lastCoursePath.isEmpty()) { + int lastIndex = findLastPathIndex(coursePaths, lastCoursePath); + if (lastIndex >= 0) { + fullPath.addAll(coursePaths.subList(0, lastIndex + 1)); + } else { + fullPath.addAll(coursePaths); + } + } else { + fullPath.addAll(coursePaths); + } + } + + if (userFinishLocation != null && !userFinishLocation.isEmpty()) { + fullPath.addAll(userFinishLocation); + } + } + } else { + if (record.getUserFinishLocation() != null) { + fullPath.addAll(record.getUserFinishLocation()); + } + } + + return fullPath; + } + + private int findLastPathIndex(List coursePaths, List lastCoursePath) { + if (lastCoursePath == null || lastCoursePath.isEmpty() || coursePaths == null || coursePaths.isEmpty()) { + return -1; + } + + Coordinate lastPoint = lastCoursePath.get(lastCoursePath.size() - 1); + + for (int i = coursePaths.size() - 1; i >= 0; i--) { + Coordinate coursePoint = coursePaths.get(i); + if (isCoordinateMatch(coursePoint, lastPoint)) { + return i; + } + } + + return -1; + } + + private boolean isCoordinateMatch(Coordinate coord1, Coordinate coord2) { + if (coord1 == null || coord2 == null) { + return false; + } + + final double TOLERANCE = 0.0001; + return Math.abs(coord1.getLat() - coord2.getLat()) < TOLERANCE && + Math.abs(coord1.getLnt() - coord2.getLnt()) < TOLERANCE; + } + + @Transactional(readOnly = true) + public List getRunningRecordsSummary(Long memberId, String type, LocalDate date, LocalDate endDate) { + List records; + String lowerType = type.toLowerCase(); + + if ("date".equals(lowerType)) { + if (endDate == null) { + throw new DateParameterRequiredException("End date is required for date range query"); + } + records = getRecordsByDateRange(memberId, date, endDate); + } else if ("week".equals(lowerType)) { + records = getRecordsByWeek(memberId, date); + } else if ("month".equals(lowerType)) { + records = getRecordsByMonth(memberId, date); + } else { + throw new InvalidSummaryTypeException("Invalid type. Must be 'date', 'week', or 'month'"); + } + + if (records.isEmpty()) { + throw new RecordNotFoundException("No running records found for member " + memberId + " for " + type); + } + + return records.stream() + .map(RunningRecordDTO::from) + .collect(Collectors.toList()); + } + + private List getRecordsByDateRange(Long memberId, LocalDate startDate, LocalDate endDate) { + validateDateRange(startDate, endDate); + return recordRepository.findByMemberIdAndRunningTimeBetween(memberId, startDate, endDate); + } + + private List getRecordsByWeek(Long memberId, LocalDate weekDate) { + validateWeekDate(weekDate); + LocalDate weekStart = getWeekStart(weekDate); + LocalDate weekEnd = getWeekEnd(weekDate); + return recordRepository.findByMemberIdAndRunningTimeBetween(memberId, weekStart, weekEnd); + } + + private List getRecordsByMonth(Long memberId, LocalDate monthDate) { + validateMonthDate(monthDate); + LocalDate monthStart = getMonthStart(monthDate); + LocalDate monthEnd = getMonthEnd(monthDate); + return recordRepository.findByMemberIdAndRunningTimeBetween(memberId, monthStart, monthEnd); + } + + private void validateDateRange(LocalDate startDate, LocalDate endDate) { + if (startDate == null || endDate == null) { + throw new DateParameterRequiredException("Both startDate and endDate are required"); + } + + if (startDate.isAfter(endDate)) { + throw new InvalidDateRangeException("Start date must be before or equal to end date. Start: " + startDate + ", End: " + endDate); + } + + long daysBetween = ChronoUnit.DAYS.between(startDate, endDate); + if (daysBetween > 365) { + throw new DateRangeTooLargeException("Date range cannot exceed 365 days. Current range: " + daysBetween + " days"); + } + } + + private void validateWeekDate(LocalDate weekDate) { + if (weekDate == null) { + throw new DateParameterRequiredException("Week date parameter is required"); + } + + } + + private void validateMonthDate(LocalDate monthDate) { + if (monthDate == null) { + throw new DateParameterRequiredException("Month date parameter is required"); + } + + } + + public static LocalDate getWeekStart(LocalDate date) { + return date.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); + } + + public static LocalDate getWeekEnd(LocalDate date) { + return date.with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY)); + } + + public static LocalDate getMonthStart(LocalDate date) { + return date.with(TemporalAdjusters.firstDayOfMonth()); + } + + public static LocalDate getMonthEnd(LocalDate date) { + return date.with(TemporalAdjusters.lastDayOfMonth()); + } + + @Transactional + public void deleteRecord(Long memberId, Long recordId) { + RunningRecord record = recordRepository.findById(recordId) + .orElseThrow(() -> new RecordNotFoundException("Running record not found with id: " + recordId)); + + if (!record.getMemberId().equals(memberId)) { + throw new RecordNotFoundException("Running record not found with id: " + recordId); + } + + recordRepository.delete(record); + } +} diff --git a/runtracker/src/main/java/com/runtracker/domain/schedule/controller/ScheduleController.java b/runtracker/src/main/java/com/runtracker/domain/schedule/controller/ScheduleController.java new file mode 100644 index 0000000..fd14e8d --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/schedule/controller/ScheduleController.java @@ -0,0 +1,85 @@ +package com.runtracker.domain.schedule.controller; + +import com.runtracker.domain.schedule.dto.ScheduleCreateDTO; +import com.runtracker.domain.schedule.dto.ScheduleDetailDTO; +import com.runtracker.domain.schedule.dto.ScheduleListDTO; +import com.runtracker.domain.schedule.dto.ScheduleParticipantDTO; +import com.runtracker.domain.schedule.dto.ScheduleUpdateDTO; +import com.runtracker.domain.schedule.service.ScheduleService; +import com.runtracker.global.response.ApiResponse; +import com.runtracker.global.security.UserDetailsImpl; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/schedules") +@RequiredArgsConstructor +public class ScheduleController { + + private final ScheduleService scheduleService; + + @PostMapping("/create") + public ApiResponse createSchedule( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @RequestBody ScheduleCreateDTO scheduleCreateDTO) { + scheduleService.createSchedule(scheduleCreateDTO, userDetails); + return ApiResponse.ok(); + } + + @GetMapping("/list") + public ApiResponse getCrewSchedules( + @AuthenticationPrincipal UserDetailsImpl userDetails) { + ScheduleListDTO.ListResponse response = scheduleService.getCrewSchedulesByMemberId(userDetails); + return ApiResponse.ok(response); + } + + @GetMapping("/{scheduleId}") + public ApiResponse getScheduleDetail( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable Long scheduleId) { + ScheduleDetailDTO.Response response = scheduleService.getScheduleDetail(scheduleId, userDetails); + return ApiResponse.ok(response); + } + + @PatchMapping("/update/{scheduleId}") + public ApiResponse updateSchedule( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable Long scheduleId, + @RequestBody ScheduleUpdateDTO scheduleUpdateDTO) { + scheduleService.updateSchedule(scheduleId, scheduleUpdateDTO, userDetails); + return ApiResponse.ok(); + } + + @DeleteMapping("/delete/{scheduleId}") + public ApiResponse deleteSchedule( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable Long scheduleId) { + scheduleService.deleteSchedule(scheduleId, userDetails); + return ApiResponse.ok(); + } + + @PostMapping("/join/{scheduleId}") + public ApiResponse joinSchedule( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable Long scheduleId) { + scheduleService.joinSchedule(scheduleId, userDetails); + return ApiResponse.ok(); + } + + @PostMapping("/cancel/{scheduleId}") + public ApiResponse cancelSchedule( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable Long scheduleId) { + scheduleService.cancelSchedule(scheduleId, userDetails); + return ApiResponse.ok(); + } + + @GetMapping("/participants/{scheduleId}") + public ApiResponse getScheduleParticipants( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable Long scheduleId) { + ScheduleParticipantDTO.ListResponse response = scheduleService.getScheduleParticipants(scheduleId, userDetails); + return ApiResponse.ok(response); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/schedule/dto/ScheduleCreateDTO.java b/runtracker/src/main/java/com/runtracker/domain/schedule/dto/ScheduleCreateDTO.java new file mode 100644 index 0000000..dd46ff4 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/schedule/dto/ScheduleCreateDTO.java @@ -0,0 +1,16 @@ +package com.runtracker.domain.schedule.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class ScheduleCreateDTO { + + private Long crewId; + private String date; + private String title; + private String content; +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/schedule/dto/ScheduleDetailDTO.java b/runtracker/src/main/java/com/runtracker/domain/schedule/dto/ScheduleDetailDTO.java new file mode 100644 index 0000000..ed52606 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/schedule/dto/ScheduleDetailDTO.java @@ -0,0 +1,41 @@ +package com.runtracker.domain.schedule.dto; + +import com.runtracker.domain.schedule.entity.Schedule; +import com.runtracker.global.code.DateConstants; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.format.DateTimeFormatter; + +public class ScheduleDetailDTO { + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class Response { + private Long id; + private Long crewId; + private Long memberId; + private String date; + private String title; + private String content; + private String creatorName; + + public static Response from(Schedule schedule, String creatorName) { + DateTimeFormatter formatter = DateTimeFormatter.ofPattern(DateConstants.SHORT_DATETIME_PATTERN); + + return Response.builder() + .id(schedule.getId()) + .crewId(schedule.getCrewId()) + .memberId(schedule.getMemberId()) + .date(schedule.getDate() != null ? schedule.getDate().format(formatter) : null) + .title(schedule.getTitle()) + .content(schedule.getContent()) + .creatorName(creatorName) + .build(); + } + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/schedule/dto/ScheduleListDTO.java b/runtracker/src/main/java/com/runtracker/domain/schedule/dto/ScheduleListDTO.java new file mode 100644 index 0000000..c822b87 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/schedule/dto/ScheduleListDTO.java @@ -0,0 +1,52 @@ +package com.runtracker.domain.schedule.dto; + +import com.runtracker.domain.schedule.entity.Schedule; +import com.runtracker.global.code.DateConstants; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.format.DateTimeFormatter; +import java.util.List; + +public class ScheduleListDTO { + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class Response { + private Long id; + private String date; + private String title; + private String content; + private String creatorName; + + public static Response from(Schedule schedule, String creatorName) { + DateTimeFormatter formatter = DateTimeFormatter.ofPattern(DateConstants.SHORT_DATETIME_PATTERN); + + return Response.builder() + .id(schedule.getId()) + .date(schedule.getDate() != null ? schedule.getDate().format(formatter) : null) + .title(schedule.getTitle()) + .content(schedule.getContent()) + .creatorName(creatorName) + .build(); + } + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class ListResponse { + private List schedules; + + public static ListResponse of(List schedules) { + return ListResponse.builder() + .schedules(schedules) + .build(); + } + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/schedule/dto/ScheduleParticipantDTO.java b/runtracker/src/main/java/com/runtracker/domain/schedule/dto/ScheduleParticipantDTO.java new file mode 100644 index 0000000..7c54eec --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/schedule/dto/ScheduleParticipantDTO.java @@ -0,0 +1,38 @@ +package com.runtracker.domain.schedule.dto; + +import com.runtracker.domain.member.entity.enums.MemberRole; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +public class ScheduleParticipantDTO { + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class Response { + private Long memberId; + private String memberName; + private MemberRole role; + } + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class ListResponse { + private List participants; + private int participantCount; + + public static ListResponse of(List participants) { + return ListResponse.builder() + .participants(participants) + .participantCount(participants.size()) + .build(); + } + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/schedule/dto/ScheduleUpdateDTO.java b/runtracker/src/main/java/com/runtracker/domain/schedule/dto/ScheduleUpdateDTO.java new file mode 100644 index 0000000..22178cc --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/schedule/dto/ScheduleUpdateDTO.java @@ -0,0 +1,15 @@ +package com.runtracker.domain.schedule.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class ScheduleUpdateDTO { + + private String date; + private String title; + private String content; +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/schedule/entity/Schedule.java b/runtracker/src/main/java/com/runtracker/domain/schedule/entity/Schedule.java new file mode 100644 index 0000000..3ca71a8 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/schedule/entity/Schedule.java @@ -0,0 +1,90 @@ +package com.runtracker.domain.schedule.entity; + +import com.runtracker.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.*; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "schedule") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class Schedule extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "crew_id", nullable = false) + private Long crewId; + + @Column(name = "member_id", nullable = false) + private Long memberId; + + @Column(name = "date") + private LocalDateTime date; + + @Column(name = "title", length = 100) + private String title; + + @Column(name = "content", columnDefinition = "TEXT") + private String content; + + @Column(name = "members", columnDefinition = "JSON") + private String members; + + public void updateSchedule(LocalDateTime date, String title, String content) { + if (date != null) { + this.date = date; + } + if (title != null && !title.trim().isEmpty()) { + this.title = title; + } + if (content != null) { + this.content = content; + } + } + + public void joinSchedule(Long memberId) { + List memberList = getParticipants(); + if (!memberList.contains(memberId)) { + memberList.add(memberId); + updateMembersJson(memberList); + } + } + + public void cancelSchedule(Long memberId) { + List memberList = getParticipants(); + memberList.remove(memberId); + updateMembersJson(memberList); + } + + public List getParticipants() { + if (members == null || members.trim().isEmpty()) { + return new ArrayList<>(); + } + + try { + ObjectMapper objectMapper = new ObjectMapper(); + return objectMapper.readValue(members, new TypeReference>() {}); + } catch (Exception e) { + return new ArrayList<>(); + } + } + + private void updateMembersJson(List memberList) { + try { + ObjectMapper objectMapper = new ObjectMapper(); + this.members = objectMapper.writeValueAsString(memberList); + } catch (Exception e) { + this.members = "[]"; + } + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/schedule/enums/ScheduleErrorCode.java b/runtracker/src/main/java/com/runtracker/domain/schedule/enums/ScheduleErrorCode.java new file mode 100644 index 0000000..0c4b9f3 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/schedule/enums/ScheduleErrorCode.java @@ -0,0 +1,19 @@ +package com.runtracker.domain.schedule.enums; + +import com.runtracker.global.code.ResponseCode; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum ScheduleErrorCode implements ResponseCode { + + SCHEDULE_NOT_FOUND("SH001", "Schedule not found"), + SCHEDULE_CREATION_FAILED("SH002", "Schedule creation failed"), + INVALID_SCHEDULE_DATE("SH003", "Invalid schedule date"), + UNAUTHORIZED_SCHEDULE_ACCESS("SH004", "Unauthorized schedule access"); + + private final String statusCode; + private final String message; +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/schedule/event/ScheduleCancelEvent.java b/runtracker/src/main/java/com/runtracker/domain/schedule/event/ScheduleCancelEvent.java new file mode 100644 index 0000000..b175694 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/schedule/event/ScheduleCancelEvent.java @@ -0,0 +1,4 @@ +package com.runtracker.domain.schedule.event; + +public record ScheduleCancelEvent(Long participantId, Long crewId, String scheduleTitle) { +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/schedule/event/ScheduleCreateEvent.java b/runtracker/src/main/java/com/runtracker/domain/schedule/event/ScheduleCreateEvent.java new file mode 100644 index 0000000..56e16f0 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/schedule/event/ScheduleCreateEvent.java @@ -0,0 +1,4 @@ +package com.runtracker.domain.schedule.event; + +public record ScheduleCreateEvent(Long creatorId, Long crewId, String scheduleTitle) { +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/schedule/event/ScheduleDeleteEvent.java b/runtracker/src/main/java/com/runtracker/domain/schedule/event/ScheduleDeleteEvent.java new file mode 100644 index 0000000..82b995c --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/schedule/event/ScheduleDeleteEvent.java @@ -0,0 +1,4 @@ +package com.runtracker.domain.schedule.event; + +public record ScheduleDeleteEvent(Long deleterId, Long crewId, String scheduleTitle) { +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/schedule/event/ScheduleJoinEvent.java b/runtracker/src/main/java/com/runtracker/domain/schedule/event/ScheduleJoinEvent.java new file mode 100644 index 0000000..f7f4632 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/schedule/event/ScheduleJoinEvent.java @@ -0,0 +1,4 @@ +package com.runtracker.domain.schedule.event; + +public record ScheduleJoinEvent(Long participantId, Long crewId, String scheduleTitle) { +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/schedule/event/ScheduleUpdateEvent.java b/runtracker/src/main/java/com/runtracker/domain/schedule/event/ScheduleUpdateEvent.java new file mode 100644 index 0000000..218515c --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/schedule/event/ScheduleUpdateEvent.java @@ -0,0 +1,4 @@ +package com.runtracker.domain.schedule.event; + +public record ScheduleUpdateEvent(Long updaterId, Long crewId, String scheduleTitle) { +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/schedule/exception/InvalidScheduleDateException.java b/runtracker/src/main/java/com/runtracker/domain/schedule/exception/InvalidScheduleDateException.java new file mode 100644 index 0000000..9b506a3 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/schedule/exception/InvalidScheduleDateException.java @@ -0,0 +1,14 @@ +package com.runtracker.domain.schedule.exception; + +import com.runtracker.domain.schedule.enums.ScheduleErrorCode; +import com.runtracker.global.exception.CustomException; + +public class InvalidScheduleDateException extends CustomException { + public InvalidScheduleDateException() { + super(ScheduleErrorCode.INVALID_SCHEDULE_DATE); + } + + public InvalidScheduleDateException(String message) { + super(ScheduleErrorCode.INVALID_SCHEDULE_DATE, message); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/schedule/exception/ScheduleCreationFailedException.java b/runtracker/src/main/java/com/runtracker/domain/schedule/exception/ScheduleCreationFailedException.java new file mode 100644 index 0000000..31e2ff3 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/schedule/exception/ScheduleCreationFailedException.java @@ -0,0 +1,10 @@ +package com.runtracker.domain.schedule.exception; + +import com.runtracker.domain.schedule.enums.ScheduleErrorCode; +import com.runtracker.global.exception.CustomException; + +public class ScheduleCreationFailedException extends CustomException { + public ScheduleCreationFailedException() { + super(ScheduleErrorCode.SCHEDULE_CREATION_FAILED); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/schedule/exception/ScheduleNotFoundException.java b/runtracker/src/main/java/com/runtracker/domain/schedule/exception/ScheduleNotFoundException.java new file mode 100644 index 0000000..103f27f --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/schedule/exception/ScheduleNotFoundException.java @@ -0,0 +1,10 @@ +package com.runtracker.domain.schedule.exception; + +import com.runtracker.domain.schedule.enums.ScheduleErrorCode; +import com.runtracker.global.exception.CustomException; + +public class ScheduleNotFoundException extends CustomException { + public ScheduleNotFoundException() { + super(ScheduleErrorCode.SCHEDULE_NOT_FOUND); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/schedule/exception/UnauthorizedScheduleAccessException.java b/runtracker/src/main/java/com/runtracker/domain/schedule/exception/UnauthorizedScheduleAccessException.java new file mode 100644 index 0000000..6045386 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/schedule/exception/UnauthorizedScheduleAccessException.java @@ -0,0 +1,14 @@ +package com.runtracker.domain.schedule.exception; + +import com.runtracker.domain.schedule.enums.ScheduleErrorCode; +import com.runtracker.global.exception.CustomException; + +public class UnauthorizedScheduleAccessException extends CustomException { + public UnauthorizedScheduleAccessException() { + super(ScheduleErrorCode.UNAUTHORIZED_SCHEDULE_ACCESS); + } + + public UnauthorizedScheduleAccessException(String message) { + super(ScheduleErrorCode.UNAUTHORIZED_SCHEDULE_ACCESS, message); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/schedule/repository/ScheduleRepository.java b/runtracker/src/main/java/com/runtracker/domain/schedule/repository/ScheduleRepository.java new file mode 100644 index 0000000..706179d --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/schedule/repository/ScheduleRepository.java @@ -0,0 +1,14 @@ +package com.runtracker.domain.schedule.repository; + +import com.runtracker.domain.schedule.entity.Schedule; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface ScheduleRepository extends JpaRepository { + List findByCrewIdOrderByDateAsc(Long crewId); + + void deleteByMemberId(Long memberId); +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/schedule/service/ScheduleService.java b/runtracker/src/main/java/com/runtracker/domain/schedule/service/ScheduleService.java new file mode 100644 index 0000000..98d29b8 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/schedule/service/ScheduleService.java @@ -0,0 +1,242 @@ +package com.runtracker.domain.schedule.service; + +import com.runtracker.domain.crew.entity.CrewMember; +import com.runtracker.domain.crew.repository.CrewMemberRepository; +import com.runtracker.global.security.UserDetailsImpl; +import com.runtracker.global.security.CrewAuthorizationUtil; +import com.runtracker.global.security.dto.CrewMembership; +import com.runtracker.domain.member.entity.Member; +import com.runtracker.domain.member.entity.enums.MemberRole; +import com.runtracker.domain.member.repository.MemberRepository; +import com.runtracker.domain.schedule.dto.ScheduleCreateDTO; +import com.runtracker.domain.schedule.dto.ScheduleDetailDTO; +import com.runtracker.domain.schedule.dto.ScheduleListDTO; +import com.runtracker.domain.schedule.dto.ScheduleParticipantDTO; +import com.runtracker.domain.schedule.dto.ScheduleUpdateDTO; +import com.runtracker.domain.schedule.entity.Schedule; +import com.runtracker.domain.schedule.enums.ScheduleErrorCode; +import com.runtracker.domain.schedule.exception.InvalidScheduleDateException; +import com.runtracker.domain.schedule.exception.ScheduleNotFoundException; +import com.runtracker.domain.schedule.exception.UnauthorizedScheduleAccessException; +import com.runtracker.domain.schedule.repository.ScheduleRepository; +import com.runtracker.global.code.DateConstants; +import com.runtracker.global.exception.CustomException; +import com.runtracker.domain.schedule.event.ScheduleCreateEvent; +import com.runtracker.domain.schedule.event.ScheduleUpdateEvent; +import com.runtracker.domain.schedule.event.ScheduleDeleteEvent; +import com.runtracker.domain.schedule.event.ScheduleJoinEvent; +import com.runtracker.domain.schedule.event.ScheduleCancelEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Slf4j +public class ScheduleService { + + private final ScheduleRepository scheduleRepository; + private final CrewMemberRepository crewMemberRepository; + private final MemberRepository memberRepository; + private final CrewAuthorizationUtil authorizationUtil; + private final ApplicationEventPublisher eventPublisher; + + @Transactional + public Long createSchedule(ScheduleCreateDTO scheduleCreateDTO, UserDetailsImpl userDetails) { + authorizationUtil.validateCrewManagementPermission(userDetails, scheduleCreateDTO.getCrewId()); + LocalDateTime parsedDate = parseAndValidateDate(scheduleCreateDTO.getDate()); + + Schedule schedule = Schedule.builder() + .crewId(scheduleCreateDTO.getCrewId()) + .memberId(userDetails.getMemberId()) + .date(parsedDate) + .title(scheduleCreateDTO.getTitle()) + .content(scheduleCreateDTO.getContent()) + .build(); + + Schedule savedSchedule = scheduleRepository.save(schedule); + + eventPublisher.publishEvent(new ScheduleCreateEvent( + userDetails.getMemberId(), + scheduleCreateDTO.getCrewId(), + scheduleCreateDTO.getTitle() + )); + + return savedSchedule.getId(); + } + + private LocalDateTime parseAndValidateDate(String dateString) { + if (dateString == null || dateString.trim().isEmpty()) { + throw new InvalidScheduleDateException("Date string is null or empty"); + } + + try { + DateTimeFormatter formatter = DateTimeFormatter.ofPattern(DateConstants.SHORT_DATETIME_PATTERN); + LocalDateTime parsedDate = LocalDateTime.parse(dateString, formatter); + + LocalDateTime now = LocalDateTime.now(ZoneId.of(DateConstants.TIME_ZONE)); + if (parsedDate.isBefore(now)) { + throw new InvalidScheduleDateException("Schedule date cannot be in the past"); + } + + return parsedDate; + } catch (InvalidScheduleDateException e) { + throw e; + } catch (Exception e) { + throw new InvalidScheduleDateException("Invalid date format: " + dateString); + } + } + + @Transactional(readOnly = true) + public ScheduleListDTO.ListResponse getCrewSchedules(Long crewId) { + List schedules = scheduleRepository.findByCrewIdOrderByDateAsc(crewId); + + List scheduleResponses = schedules.stream() + .map(schedule -> { + Member creator = memberRepository.findById(schedule.getMemberId()) + .orElse(null); + String creatorName = creator != null ? creator.getName() : "알 수 없음"; + return ScheduleListDTO.Response.from(schedule, creatorName); + }) + .toList(); + + return ScheduleListDTO.ListResponse.of(scheduleResponses); + } + + @Transactional(readOnly = true) + public ScheduleListDTO.ListResponse getCrewSchedulesByMemberId(UserDetailsImpl userDetails) { + CrewMembership membership = userDetails.getCrewMembership(); + if (membership == null) { + throw new UnauthorizedScheduleAccessException("User is not a member of any crew"); + } + + Long crewId = membership.getCrewId(); + authorizationUtil.validateCrewMemberAccess(userDetails, crewId); + + return getCrewSchedules(crewId); + } + + @Transactional(readOnly = true) + public ScheduleDetailDTO.Response getScheduleDetail(Long scheduleId, UserDetailsImpl userDetails) { + Schedule schedule = scheduleRepository.findById(scheduleId) + .orElseThrow(ScheduleNotFoundException::new); + + authorizationUtil.validateCrewMemberAccess(userDetails, schedule.getCrewId()); + + Member creator = memberRepository.findById(schedule.getMemberId()) + .orElse(null); + String creatorName = creator != null ? creator.getName() : "알 수 없음"; + + return ScheduleDetailDTO.Response.from(schedule, creatorName); + } + + @Transactional + public void updateSchedule(Long scheduleId, ScheduleUpdateDTO scheduleUpdateDTO, UserDetailsImpl userDetails) { + Schedule schedule = scheduleRepository.findById(scheduleId) + .orElseThrow(ScheduleNotFoundException::new); + + authorizationUtil.validateCrewManagementPermission(userDetails, schedule.getCrewId()); + + LocalDateTime parsedDate = null; + if (scheduleUpdateDTO.getDate() != null && !scheduleUpdateDTO.getDate().trim().isEmpty()) { + parsedDate = parseAndValidateDate(scheduleUpdateDTO.getDate()); + } + + String titleToUpdate = scheduleUpdateDTO.getTitle() != null ? scheduleUpdateDTO.getTitle() : schedule.getTitle(); + + schedule.updateSchedule(parsedDate, scheduleUpdateDTO.getTitle(), scheduleUpdateDTO.getContent()); + + eventPublisher.publishEvent(new ScheduleUpdateEvent( + userDetails.getMemberId(), + schedule.getCrewId(), + titleToUpdate + )); + } + + @Transactional + public void deleteSchedule(Long scheduleId, UserDetailsImpl userDetails) { + Schedule schedule = scheduleRepository.findById(scheduleId) + .orElseThrow(ScheduleNotFoundException::new); + + authorizationUtil.validateCrewManagementPermission(userDetails, schedule.getCrewId()); + + eventPublisher.publishEvent(new ScheduleDeleteEvent( + userDetails.getMemberId(), + schedule.getCrewId(), + schedule.getTitle() + )); + + scheduleRepository.delete(schedule); + } + + @Transactional + public void joinSchedule(Long scheduleId, UserDetailsImpl userDetails) { + Schedule schedule = scheduleRepository.findById(scheduleId) + .orElseThrow(ScheduleNotFoundException::new); + + authorizationUtil.validateCrewMemberAccess(userDetails, schedule.getCrewId()); + + schedule.joinSchedule(userDetails.getMemberId()); + + eventPublisher.publishEvent(new ScheduleJoinEvent( + userDetails.getMemberId(), + schedule.getCrewId(), + schedule.getTitle() + )); + } + + @Transactional + public void cancelSchedule(Long scheduleId, UserDetailsImpl userDetails) { + Schedule schedule = scheduleRepository.findById(scheduleId) + .orElseThrow(ScheduleNotFoundException::new); + + authorizationUtil.validateCrewMemberAccess(userDetails, schedule.getCrewId()); + + schedule.cancelSchedule(userDetails.getMemberId()); + + eventPublisher.publishEvent(new ScheduleCancelEvent( + userDetails.getMemberId(), + schedule.getCrewId(), + schedule.getTitle() + )); + } + + @Transactional(readOnly = true) + public ScheduleParticipantDTO.ListResponse getScheduleParticipants(Long scheduleId, UserDetailsImpl userDetails) { + Schedule schedule = scheduleRepository.findById(scheduleId) + .orElseThrow(ScheduleNotFoundException::new); + + authorizationUtil.validateCrewMemberAccess(userDetails, schedule.getCrewId()); + + List participantIds = schedule.getParticipants(); + + List participantResponses = participantIds.stream() + .map(participantId -> { + Member member = memberRepository.findById(participantId) + .orElse(null); + String memberName = member != null ? member.getName() : "알 수 없음"; + + MemberRole role = crewMemberRepository.findByCrewIdAndMemberId(schedule.getCrewId(), participantId) + .map(CrewMember::getRole) + .orElse(MemberRole.CREW_MEMBER); + + return ScheduleParticipantDTO.Response.builder() + .memberId(participantId) + .memberName(memberName) + .role(role) + .build(); + }) + .toList(); + + return ScheduleParticipantDTO.ListResponse.of(participantResponses); + } + + +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/upload/controller/UploadController.java b/runtracker/src/main/java/com/runtracker/domain/upload/controller/UploadController.java new file mode 100644 index 0000000..3f287d4 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/upload/controller/UploadController.java @@ -0,0 +1,40 @@ +package com.runtracker.domain.upload.controller; + +import com.runtracker.domain.upload.service.FileStorageService; +import com.runtracker.global.response.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.net.URI; +import java.util.HashMap; +import java.util.Map; + +@RestController +@RequestMapping("/api/upload") +@RequiredArgsConstructor +public class UploadController { + + private final FileStorageService fileStorageService; + + @PostMapping("/image") + public ApiResponse> uploadImage( + @RequestParam("file") MultipartFile file) { + + String fileUrl = fileStorageService.uploadImage(file); + + Map response = new HashMap<>(); + response.put("url", fileUrl); + + return ApiResponse.ok(response); + } + + @GetMapping("/image/{filename:.+}") + public ResponseEntity getImage(@PathVariable String filename) { + return ResponseEntity.status(HttpStatus.MOVED_PERMANENTLY) + .location(URI.create(fileStorageService.getImageUrl(filename))) + .build(); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/upload/enums/UploadErrorCode.java b/runtracker/src/main/java/com/runtracker/domain/upload/enums/UploadErrorCode.java new file mode 100644 index 0000000..913c0be --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/upload/enums/UploadErrorCode.java @@ -0,0 +1,21 @@ +package com.runtracker.domain.upload.enums; + +import com.runtracker.global.code.ResponseCode; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum UploadErrorCode implements ResponseCode { + + FILE_IS_EMPTY("UP001", "File is empty"), + INVALID_FILE_TYPE("UP002", "Invalid file type. Only image files are allowed"), + INVALID_FILE_NAME("UP003", "Invalid file name"), + FILE_STORAGE_FAILED("UP004", "Failed to store file"), + FILE_NOT_FOUND("UP005", "File not found"), + IMAGE_CONVERSION_FAILED("UP006", "Failed to convert image to WebP format"), + IMAGE_RESIZE_FAILED("UP007", "Failed to resize image"); + + private final String statusCode; + private final String message; +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/upload/exception/FileIsEmptyException.java b/runtracker/src/main/java/com/runtracker/domain/upload/exception/FileIsEmptyException.java new file mode 100644 index 0000000..7ef14bd --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/upload/exception/FileIsEmptyException.java @@ -0,0 +1,14 @@ +package com.runtracker.domain.upload.exception; + +import com.runtracker.domain.upload.enums.UploadErrorCode; +import com.runtracker.global.exception.CustomException; + +public class FileIsEmptyException extends CustomException { + public FileIsEmptyException() { + super(UploadErrorCode.FILE_IS_EMPTY); + } + + public FileIsEmptyException(String message) { + super(UploadErrorCode.FILE_IS_EMPTY, message); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/upload/exception/FileNotFoundException.java b/runtracker/src/main/java/com/runtracker/domain/upload/exception/FileNotFoundException.java new file mode 100644 index 0000000..0c27012 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/upload/exception/FileNotFoundException.java @@ -0,0 +1,14 @@ +package com.runtracker.domain.upload.exception; + +import com.runtracker.domain.upload.enums.UploadErrorCode; +import com.runtracker.global.exception.CustomException; + +public class FileNotFoundException extends CustomException { + public FileNotFoundException() { + super(UploadErrorCode.FILE_NOT_FOUND); + } + + public FileNotFoundException(String message) { + super(UploadErrorCode.FILE_NOT_FOUND, message); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/upload/exception/FileStorageFailedException.java b/runtracker/src/main/java/com/runtracker/domain/upload/exception/FileStorageFailedException.java new file mode 100644 index 0000000..0ab586e --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/upload/exception/FileStorageFailedException.java @@ -0,0 +1,14 @@ +package com.runtracker.domain.upload.exception; + +import com.runtracker.domain.upload.enums.UploadErrorCode; +import com.runtracker.global.exception.CustomException; + +public class FileStorageFailedException extends CustomException { + public FileStorageFailedException() { + super(UploadErrorCode.FILE_STORAGE_FAILED); + } + + public FileStorageFailedException(String message) { + super(UploadErrorCode.FILE_STORAGE_FAILED, message); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/upload/exception/ImageConversionFailedException.java b/runtracker/src/main/java/com/runtracker/domain/upload/exception/ImageConversionFailedException.java new file mode 100644 index 0000000..66f70d7 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/upload/exception/ImageConversionFailedException.java @@ -0,0 +1,14 @@ +package com.runtracker.domain.upload.exception; + +import com.runtracker.domain.upload.enums.UploadErrorCode; +import com.runtracker.global.exception.CustomException; + +public class ImageConversionFailedException extends CustomException { + public ImageConversionFailedException() { + super(UploadErrorCode.IMAGE_CONVERSION_FAILED); + } + + public ImageConversionFailedException(String message) { + super(UploadErrorCode.IMAGE_CONVERSION_FAILED, message); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/upload/exception/InvalidFileNameException.java b/runtracker/src/main/java/com/runtracker/domain/upload/exception/InvalidFileNameException.java new file mode 100644 index 0000000..802fbbf --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/upload/exception/InvalidFileNameException.java @@ -0,0 +1,14 @@ +package com.runtracker.domain.upload.exception; + +import com.runtracker.domain.upload.enums.UploadErrorCode; +import com.runtracker.global.exception.CustomException; + +public class InvalidFileNameException extends CustomException { + public InvalidFileNameException() { + super(UploadErrorCode.INVALID_FILE_NAME); + } + + public InvalidFileNameException(String message) { + super(UploadErrorCode.INVALID_FILE_NAME, message); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/upload/exception/InvalidFileTypeException.java b/runtracker/src/main/java/com/runtracker/domain/upload/exception/InvalidFileTypeException.java new file mode 100644 index 0000000..9a681aa --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/upload/exception/InvalidFileTypeException.java @@ -0,0 +1,14 @@ +package com.runtracker.domain.upload.exception; + +import com.runtracker.domain.upload.enums.UploadErrorCode; +import com.runtracker.global.exception.CustomException; + +public class InvalidFileTypeException extends CustomException { + public InvalidFileTypeException() { + super(UploadErrorCode.INVALID_FILE_TYPE); + } + + public InvalidFileTypeException(String message) { + super(UploadErrorCode.INVALID_FILE_TYPE, message); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/domain/upload/service/FileStorageService.java b/runtracker/src/main/java/com/runtracker/domain/upload/service/FileStorageService.java new file mode 100644 index 0000000..82b176d --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/domain/upload/service/FileStorageService.java @@ -0,0 +1,116 @@ +package com.runtracker.domain.upload.service; + +import com.runtracker.domain.upload.exception.*; +import com.runtracker.global.util.ImageConverter; +import com.runtracker.global.config.S3Config; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Base64; +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +public class FileStorageService { + + private final S3Client s3Client; + private final S3Config s3Config; + + // 이미지 파일을 업로드하고 URL을 반환 + public String uploadImage(MultipartFile file) { + if (file.isEmpty()) { + throw new FileIsEmptyException(); + } + + String contentType = file.getContentType(); + if (contentType == null || !contentType.startsWith("image/")) { + throw new InvalidFileTypeException(); + } + + return storeFile(file); + } + + // 파일을 WebP 포맷으로 변환하여 S3에 업로드 + private String storeFile(MultipartFile file) { + String originalFilename = StringUtils.cleanPath(file.getOriginalFilename()); + + if (originalFilename.contains("..")) { + throw new InvalidFileNameException(); + } + + try { + String storedFilename = UUID.randomUUID() + ".webp"; + + InputStream webpInputStream = ImageConverter.convertToWebP(file); + byte[] fileBytes = webpInputStream.readAllBytes(); + + uploadToS3(storedFilename, fileBytes); + + return s3Config.getBaseUrl() + "/" + storedFilename; + + } catch (IOException ex) { + log.error("Failed to store file: {}", originalFilename, ex); + throw new FileStorageFailedException(originalFilename); + } + } + + private void uploadToS3(String filename, byte[] fileBytes) { + try { + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(s3Config.getBucketName()) + .key(filename) + .contentType("image/webp") + .build(); + + s3Client.putObject(putObjectRequest, RequestBody.fromBytes(fileBytes)); + log.info("File uploaded to S3: {}", filename); + } catch (Exception ex) { + log.error("Failed to upload file to S3: {}", filename, ex); + throw new FileStorageFailedException(filename); + } + } + + // Base64 인코딩된 이미지를 S3에 업로드하고 URL을 반환 + public String uploadBase64Image(String base64Data) { + if (base64Data == null || base64Data.trim().isEmpty()) { + return null; + } + + try { + String base64Image = base64Data; + if (base64Data.contains(",")) { + base64Image = base64Data.split(",")[1]; + } + + byte[] imageBytes = Base64.getDecoder().decode(base64Image); + + String storedFilename = UUID.randomUUID() + ".webp"; + + InputStream imageInputStream = new ByteArrayInputStream(imageBytes); + InputStream webpInputStream = ImageConverter.convertToWebP(imageInputStream); + byte[] webpBytes = webpInputStream.readAllBytes(); + + uploadToS3(storedFilename, webpBytes); + + return s3Config.getBaseUrl() + "/" + storedFilename; + + } catch (Exception ex) { + log.error("Failed to store base64 image", ex); + throw new FileStorageFailedException("base64-image"); + } + } + + public String getImageUrl(String filename) { + return s3Config.getBaseUrl() + "/" + filename; + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/global/client/FastAPIClient.java b/runtracker/src/main/java/com/runtracker/global/client/FastAPIClient.java new file mode 100644 index 0000000..e2e7a8b --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/global/client/FastAPIClient.java @@ -0,0 +1,65 @@ +package com.runtracker.global.client; + +import com.runtracker.global.code.CommonResponseCode; +import com.runtracker.global.exception.CustomException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +@Slf4j +@Component +public class FastAPIClient { + + @Value("${app.domain}") + private String appDomain; + + private final RestTemplate restTemplate = new RestTemplate(); + + private String getBaseUrl() { + return appDomain + ":8000"; + } + + public R get(String endpoint, Class responseType) { + try { + String url = getBaseUrl() + endpoint; + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity entity = new HttpEntity<>(headers); + + ResponseEntity response = restTemplate.exchange(url, HttpMethod.GET, entity, responseType); + + return response.getBody(); + + } catch (Exception e) { + log.error("Failed to call FastAPI GET {}: {}", endpoint, e.getMessage(), e); + throw new CustomException(CommonResponseCode.EXTERNAL_API_ERROR, "FastAPI request failed: " + endpoint); + } + } + + public R post(String endpoint, T requestBody, Class responseType) { + try { + String url = getBaseUrl() + endpoint; + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity entity = new HttpEntity<>(requestBody, headers); + + ResponseEntity response = restTemplate.postForEntity(url, entity, responseType); + + return response.getBody(); + + } catch (Exception e) { + log.error("Failed to call FastAPI POST {}: {}", endpoint, e.getMessage(), e); + throw new CustomException(CommonResponseCode.EXTERNAL_API_ERROR, "FastAPI request failed: " + endpoint); + } + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/global/code/CommonResponseCode.java b/runtracker/src/main/java/com/runtracker/global/code/CommonResponseCode.java new file mode 100644 index 0000000..372543d --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/global/code/CommonResponseCode.java @@ -0,0 +1,25 @@ +package com.runtracker.global.code; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum CommonResponseCode implements ResponseCode { + + OK("C000", "success"), + BAD_REQUEST_ERROR("C001", "api bad request exception"), + REQUEST_BODY_MISSING_ERROR("C002", "required request body is missing"), + MISSING_REQUEST_PARAMETER_ERROR("C003", "missing servlet requestParameter exception"), + FORBIDDEN_ERROR("C004", "forbidden exception"), + NULL_POINT_ERROR("C005", "null point exception"), + NOT_FOUND_ERROR("C006", "not found exception"), + NOT_VALID_ERROR("C007", "handle validation exception"), + NOT_VALID_HEADER_ERROR("C008", "not valid header exception"), + EXTERNAL_API_ERROR("C009", "external api request failed"), + INTERNAL_SERVER_ERROR("C999", "internal server error exception") + ; + + private final String statusCode; + private final String message; +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/global/code/DateConstants.java b/runtracker/src/main/java/com/runtracker/global/code/DateConstants.java new file mode 100644 index 0000000..273aba7 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/global/code/DateConstants.java @@ -0,0 +1,11 @@ +package com.runtracker.global.code; + +public class DateConstants { + private DateConstants() { + } + + public static final String DATE_PATTERN = "yyyy-MM-dd"; + public static final String DATETIME_PATTERN = "yyyy-MM-dd HH:mm:ss"; + public static final String SHORT_DATETIME_PATTERN = "yyyy-MM-dd HH:mm"; + public static final String TIME_ZONE = "Asia/Seoul"; +} diff --git a/runtracker/src/main/java/com/runtracker/global/code/ResponseCode.java b/runtracker/src/main/java/com/runtracker/global/code/ResponseCode.java new file mode 100644 index 0000000..f14a83d --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/global/code/ResponseCode.java @@ -0,0 +1,6 @@ +package com.runtracker.global.code; + +public interface ResponseCode { + String getStatusCode(); + String getMessage(); +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/global/config/CustomJsonHttpLogFormatter.java b/runtracker/src/main/java/com/runtracker/global/config/CustomJsonHttpLogFormatter.java new file mode 100644 index 0000000..27ee566 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/global/config/CustomJsonHttpLogFormatter.java @@ -0,0 +1,88 @@ +package com.runtracker.global.config; + +import org.slf4j.MDC; +import org.zalando.logbook.HttpLogFormatter; +import org.zalando.logbook.Precorrelation; +import org.zalando.logbook.Correlation; +import org.zalando.logbook.HttpRequest; +import org.zalando.logbook.HttpResponse; +import org.zalando.logbook.json.JsonHttpLogFormatter; + +import java.io.IOException; + +public class CustomJsonHttpLogFormatter implements HttpLogFormatter { + private static final String MDC_METHOD = "logbook.method"; + private static final String MDC_PATH = "logbook.path"; + private static final String MDC_QUERY = "logbook.query"; + private static final String MDC_TRACE_ID = "traceId"; + + private final JsonHttpLogFormatter delegate = new JsonHttpLogFormatter(); + + @Override + public String format(Precorrelation precorrelation, HttpRequest request) throws IOException { + try { + MDC.put(MDC_METHOD, request.getMethod()); + MDC.put(MDC_PATH, request.getPath()); + MDC.put(MDC_QUERY, request.getQuery()); + + String traceId = MDC.get(MDC_TRACE_ID); + if (traceId == null) { + traceId = precorrelation.getId(); + } + + String message = String.format("[REQUEST] %s %s%s [%s]", + request.getMethod(), + request.getPath(), + request.getQuery().isEmpty() ? "" : "?" + request.getQuery(), + traceId); + + String json = delegate.format(precorrelation, request); + return String.format("{\"level\":\"INFO\",\"message\":%s,%s", escape(message), json.substring(1)); + } catch (Exception e) { + clearMDC(); + throw e; + } + } + + @Override + public String format(Correlation correlation, HttpResponse response) throws IOException { + try { + String path = MDC.get(MDC_PATH); + String query = MDC.get(MDC_QUERY); + String traceId = MDC.get(MDC_TRACE_ID); + + String message = String.format("[RESPONSE] %d %s%s [%s]", + response.getStatus(), + path != null ? path : "UNKNOWN", + (query != null && !query.isEmpty()) ? "?" + query : "", + traceId != null ? traceId : correlation.getId()); + + String json = delegate.format(correlation, response); + + String level; + int status = response.getStatus(); + if (status >= 500) { + level = "ERROR"; + } else if (status >= 400) { + level = "WARN"; + } else { + level = "INFO"; + } + + return String.format("{\"level\":\"%s\",\"message\":%s,%s", level, escape(message), json.substring(1)); + } finally { + MDC.clear(); + } + } + + private void clearMDC() { + MDC.remove(MDC_METHOD); + MDC.remove(MDC_PATH); + MDC.remove(MDC_QUERY); + MDC.remove(MDC_TRACE_ID); + } + + private String escape(String s) { + return "\"" + s.replace("\"", "\\\"") + "\""; + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/global/config/FileUploadConfig.java b/runtracker/src/main/java/com/runtracker/global/config/FileUploadConfig.java new file mode 100644 index 0000000..ddbecd6 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/global/config/FileUploadConfig.java @@ -0,0 +1,27 @@ +package com.runtracker.global.config; + +import lombok.Getter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; + +import jakarta.annotation.PostConstruct; +import java.io.File; + +@Getter +@Configuration +public class FileUploadConfig { + + @Value("${file.upload-dir}") + private String uploadDir; + + @Value("${file.base-url}") + private String baseUrl; + + @PostConstruct + public void init() { + File uploadDirectory = new File(uploadDir); + if (!uploadDirectory.exists() && !uploadDirectory.mkdirs()) { + throw new IllegalStateException("Failed to create upload directory: " + uploadDir); + } + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/global/config/LogbookConfig.java b/runtracker/src/main/java/com/runtracker/global/config/LogbookConfig.java new file mode 100644 index 0000000..c79fc5c --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/global/config/LogbookConfig.java @@ -0,0 +1,115 @@ +package com.runtracker.global.config; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.zalando.logbook.*; +import org.zalando.logbook.core.DefaultSink; +import java.util.List; +import java.util.function.Predicate; + +@Configuration +public class LogbookConfig { + + private static final String TRACE_ID_HEADER = "X-Trace-Id"; + private static final String TRACE_ID_DataDog_HEADER = "x-datadog-trace-id"; + private static final String MDC_TRACE_ID = "traceId"; + + @Bean + public Logbook jsonLogbook() { + Predicate customCondition = request -> { + String path = request.getPath(); + return !( + "OPTIONS".equals(request.getMethod()) || + path.contains("/health") || + path.contains("/health_check") || + path.contains("/actuator") || + path.contains("/static") || + path.contains("/swagger-ui") || + path.contains("/swagger-resources") || + path.contains("/v3/api-docs") || + path.contains("/v2/api-docs") || + path.contains("/webjars") || + path.endsWith(".js") || + path.endsWith(".css") || + path.endsWith(".png") || + path.endsWith(".ico") || + path.endsWith(".html") || + path.equals("/favicon-16x16.png") || + path.equals("/favicon-32x32.png") || + path.equals("/swagger-ui.html") + ); + }; + + HttpLogFormatter formatter = new CustomJsonHttpLogFormatter(); + HttpLogWriter writer = new CustomHttpLogWriter(); + Sink sink = new DefaultSink(formatter, writer); + + return Logbook.builder() + .condition(customCondition) + .correlationId(new CustomCorrelationId()) + .sink(sink) + .bodyFilter((contentType, body) -> maskData(body)) + .build(); + } + + private static class CustomHttpLogWriter implements HttpLogWriter { + private static final Logger logger = LoggerFactory.getLogger("org.zalando.logbook"); + + @Override + public void write(Precorrelation precorrelation, String request) { + logger.trace(request); + } + + @Override + public void write(Correlation correlation, String response) { + logger.trace(response); + } + } + + private static class CustomCorrelationId implements CorrelationId { + @Override + public String generate(HttpRequest request) { + String existingTraceId = MDC.get(MDC_TRACE_ID); + if (existingTraceId != null && !existingTraceId.isEmpty()) { + return existingTraceId; + } + + String traceId; + + List traceIds = request.getHeaders().get(TRACE_ID_HEADER); + if (traceIds != null && !traceIds.isEmpty()) { + traceId = traceIds.get(0); + } else { + traceIds = request.getHeaders().get(TRACE_ID_DataDog_HEADER); + if (traceIds != null && !traceIds.isEmpty()) { + traceId = traceIds.get(0); + } else { + traceId = java.util.UUID.randomUUID().toString(); + } + } + + MDC.put(MDC_TRACE_ID, traceId); + + return traceId; + } + } + + private String maskData(String body) { + if (body == null || body.isEmpty()) { + return body; + } + + body = body.replaceAll( + "(?i)(\"[^\"]*name[^\"]*\"\\s*:\\s*\")([가-힣])[가-힣]+(\"?)", + "$1$2**$3"); + + body = body.replaceAll( + "(?i)(\"[^\"]*phone[^\"]*\"\\s*:\\s*\")([0-9]{3})[0-9]{4}([0-9]{4})(\")", + "$1$2****$3$4"); + + return body; + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/global/config/PrettyHttpLogFormatter.java b/runtracker/src/main/java/com/runtracker/global/config/PrettyHttpLogFormatter.java new file mode 100644 index 0000000..adcba7e --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/global/config/PrettyHttpLogFormatter.java @@ -0,0 +1,47 @@ +package com.runtracker.global.config; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.zalando.logbook.*; +import org.zalando.logbook.core.DefaultHttpLogFormatter; + +import java.io.IOException; + +public class PrettyHttpLogFormatter implements HttpLogFormatter { + private static final ObjectMapper MAPPER = new ObjectMapper(); + private final DefaultHttpLogFormatter delegate = new DefaultHttpLogFormatter(); + + @Override + public String format(Precorrelation precorrelation, HttpRequest request) throws IOException { + return delegate.format(precorrelation, request); + } + + @Override + public String format(Correlation correlation, HttpResponse response) throws IOException { + String originalResponse = delegate.format(correlation, response); + return prettifyJson(originalResponse); + } + + private String prettifyJson(String logMessage) { + try { + if (logMessage.contains("{") && logMessage.contains("}")) { + int jsonStart = logMessage.indexOf("{"); + int jsonEnd = logMessage.lastIndexOf("}") + 1; + + if (jsonStart != -1 && jsonEnd > jsonStart) { + String beforeJson = logMessage.substring(0, jsonStart); + String jsonPart = logMessage.substring(jsonStart, jsonEnd); + String afterJson = logMessage.substring(jsonEnd); + + JsonNode jsonNode = MAPPER.readTree(jsonPart); + String prettyJson = MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(jsonNode); + + return beforeJson + "\n" + prettyJson + afterJson; + } + } + return logMessage; + } catch (Exception e) { + return logMessage; + } + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/global/config/QueryDslConfig.java b/runtracker/src/main/java/com/runtracker/global/config/QueryDslConfig.java new file mode 100644 index 0000000..f3c9c28 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/global/config/QueryDslConfig.java @@ -0,0 +1,19 @@ +package com.runtracker.global.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class QueryDslConfig { + + @PersistenceContext + private EntityManager entityManager; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(entityManager); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/global/config/RedisConfig.java b/runtracker/src/main/java/com/runtracker/global/config/RedisConfig.java new file mode 100644 index 0000000..e63be93 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/global/config/RedisConfig.java @@ -0,0 +1,62 @@ +package com.runtracker.global.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + + @Value("${REDIS_HOST}") + private String redisHost; + + @Value("${REDIS_PORT}") + private int redisPort; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + LettuceConnectionFactory factory = new LettuceConnectionFactory(redisHost, redisPort); + factory.afterPropertiesSet(); + return factory; + } + + @Bean + public RedisTemplate redisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory()); + + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new StringRedisSerializer()); + redisTemplate.setHashKeySerializer(new StringRedisSerializer()); + redisTemplate.setHashValueSerializer(new StringRedisSerializer()); + + return redisTemplate; + } + + @Bean + public RedisTemplate objectRedisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory()); + + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer(objectMapper); + + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(serializer); + redisTemplate.setHashKeySerializer(new StringRedisSerializer()); + redisTemplate.setHashValueSerializer(serializer); + + return redisTemplate; + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/global/config/S3Config.java b/runtracker/src/main/java/com/runtracker/global/config/S3Config.java new file mode 100644 index 0000000..ba8768b --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/global/config/S3Config.java @@ -0,0 +1,42 @@ +package com.runtracker.global.config; + +import lombok.Getter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; + +@Getter +@Configuration +public class S3Config { + + @Value("${aws.access-key}") + private String accessKey; + + @Value("${aws.secret-key}") + private String secretKey; + + @Value("${aws.region}") + private String region; + + @Value("${aws.s3.bucket-name}") + private String bucketName; + + @Value("${aws.s3.base-url}") + private String baseUrl; + + @Bean + public S3Client s3Client() { + return S3Client.builder() + .region(Region.of(region)) + .credentialsProvider( + StaticCredentialsProvider.create( + AwsBasicCredentials.create(accessKey, secretKey) + ) + ) + .build(); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/global/config/SecurityConfig.java b/runtracker/src/main/java/com/runtracker/global/config/SecurityConfig.java new file mode 100644 index 0000000..dad3b5a --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/global/config/SecurityConfig.java @@ -0,0 +1,89 @@ +package com.runtracker.global.config; + +import com.runtracker.global.jwt.JwtAuthenticationFilter; +import com.runtracker.global.jwt.JwtUtil; +import com.runtracker.global.security.UserDetailsServiceImpl; +import com.runtracker.global.jwt.service.TokenBlacklistService; +import com.runtracker.domain.auth.eventHandler.OAuth2EventHandler; +import com.runtracker.domain.auth.service.OAuth2UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.security.core.AuthenticationException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; + +import java.util.Arrays; +import java.util.List; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtUtil jwtUtil; + private final UserDetailsServiceImpl userDetailsService; + private final TokenBlacklistService tokenBlacklistService; + private final OAuth2EventHandler oAuth2SuccessHandler; + + private static final List EXCLUDE_PATHS = Arrays.asList( + "/swagger-ui/**", "/swagger-ui.html", "/api-docs/**", "/v3/api-docs/**", + "/static/**", "/webjars/**", + "/login/oauth2/**", "/oauth2/**", + "/actuator/**", "/health", "/error", "/favicon.ico", + "/api/members/search-name", "/api/members/test-login", "/api/members/refresh", + "/api/upload/image/**" + ); + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(csrf -> csrf.disable()) + .formLogin(formLogin -> formLogin.disable()) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(authorize -> authorize + .requestMatchers(EXCLUDE_PATHS.toArray(new String[0])).permitAll() + .anyRequest().authenticated() + ) + .oauth2Login(oauth2Login -> oauth2Login + .successHandler(oAuth2SuccessHandler) + .userInfoEndpoint(userInfo -> userInfo + .userService(customOAuth2UserService()) + ) + ) + .exceptionHandling(exceptions -> exceptions + .authenticationEntryPoint(customAuthenticationEntryPoint()) + ) + .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public JwtAuthenticationFilter jwtAuthenticationFilter() { + return new JwtAuthenticationFilter(jwtUtil, userDetailsService, tokenBlacklistService, EXCLUDE_PATHS); + } + + @Bean + public OAuth2UserService customOAuth2UserService() { + return new OAuth2UserService(); + } + + @Bean + public AuthenticationEntryPoint customAuthenticationEntryPoint() { + return (HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) -> { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write( + "{\"status\":{\"statusCode\":\"C401\",\"message\":\"Unauthorized\",\"description\":\"" + authException.getMessage() + "\"}}" + ); + }; + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/global/config/WebConfig.java b/runtracker/src/main/java/com/runtracker/global/config/WebConfig.java new file mode 100644 index 0000000..8be9392 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/global/config/WebConfig.java @@ -0,0 +1,18 @@ +package com.runtracker.global.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +@EnableAsync +public class WebConfig implements WebMvcConfigurer { + + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + registry.addResourceHandler("/static/**") + .addResourceLocations("classpath:/static/") + .setCachePeriod(0); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/global/converter/CoordinatesConverter.java b/runtracker/src/main/java/com/runtracker/global/converter/CoordinatesConverter.java new file mode 100644 index 0000000..3b84cb9 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/global/converter/CoordinatesConverter.java @@ -0,0 +1,45 @@ +package com.runtracker.global.converter; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.runtracker.global.vo.Coordinate; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +import lombok.extern.slf4j.Slf4j; + +import java.util.ArrayList; +import java.util.List; + +@Converter +@Slf4j +public class CoordinatesConverter implements AttributeConverter, String> { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public String convertToDatabaseColumn(List coordinates) { + if (coordinates == null || coordinates.isEmpty()) { + return null; + } + try { + return objectMapper.writeValueAsString(coordinates); + } catch (JsonProcessingException e) { + log.error("JSON writing error", e); + return null; + } + } + + @Override + public List convertToEntityAttribute(String dbData) { + if (dbData == null) { + return new ArrayList<>(); + } + try { + return objectMapper.readValue(dbData, new TypeReference>() {}); + } catch (JsonProcessingException e) { + log.error("JSON reading error", e); + return new ArrayList<>(); + } + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/global/converter/SegmentPacesConverter.java b/runtracker/src/main/java/com/runtracker/global/converter/SegmentPacesConverter.java new file mode 100644 index 0000000..51844d0 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/global/converter/SegmentPacesConverter.java @@ -0,0 +1,45 @@ +package com.runtracker.global.converter; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.runtracker.global.vo.SegmentPace; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +import lombok.extern.slf4j.Slf4j; + +import java.util.ArrayList; +import java.util.List; + +@Converter +@Slf4j +public class SegmentPacesConverter implements AttributeConverter, String> { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public String convertToDatabaseColumn(List segmentPaces) { + if (segmentPaces == null || segmentPaces.isEmpty()) { + return null; + } + try { + return objectMapper.writeValueAsString(segmentPaces); + } catch (JsonProcessingException e) { + log.error("JSON writing error", e); + return null; + } + } + + @Override + public List convertToEntityAttribute(String dbData) { + if (dbData == null) { + return new ArrayList<>(); + } + try { + return objectMapper.readValue(dbData, new TypeReference>() {}); + } catch (JsonProcessingException e) { + log.error("JSON reading error", e); + return new ArrayList<>(); + } + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/global/converter/SegmentPathsConverter.java b/runtracker/src/main/java/com/runtracker/global/converter/SegmentPathsConverter.java new file mode 100644 index 0000000..73c4cf1 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/global/converter/SegmentPathsConverter.java @@ -0,0 +1,45 @@ +package com.runtracker.global.converter; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.runtracker.global.vo.Coordinate; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +import lombok.extern.slf4j.Slf4j; + +import java.util.ArrayList; +import java.util.List; + +@Converter +@Slf4j +public class SegmentPathsConverter implements AttributeConverter>, String> { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public String convertToDatabaseColumn(List> segmentPaths) { + if (segmentPaths == null || segmentPaths.isEmpty()) { + return null; + } + try { + return objectMapper.writeValueAsString(segmentPaths); + } catch (JsonProcessingException e) { + log.error("JSON writing error", e); + return null; + } + } + + @Override + public List> convertToEntityAttribute(String dbData) { + if (dbData == null) { + return new ArrayList<>(); + } + try { + return objectMapper.readValue(dbData, new TypeReference>>() {}); + } catch (JsonProcessingException e) { + log.error("JSON reading error", e); + return new ArrayList<>(); + } + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/global/converter/StringListConverter.java b/runtracker/src/main/java/com/runtracker/global/converter/StringListConverter.java new file mode 100644 index 0000000..f63fc93 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/global/converter/StringListConverter.java @@ -0,0 +1,44 @@ +package com.runtracker.global.converter; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.util.List; + +@Slf4j +@Converter +public class StringListConverter implements AttributeConverter, String> { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public String convertToDatabaseColumn(List attribute) { + if (attribute == null || attribute.isEmpty()) { + return null; + } + try { + return objectMapper.writeValueAsString(attribute); + } catch (JsonProcessingException e) { + log.error("Error converting list to JSON string", e); + return null; + } + } + + @Override + public List convertToEntityAttribute(String dbData) { + if (dbData == null || dbData.isEmpty()) { + return null; + } + try { + return objectMapper.readValue(dbData, new TypeReference>() {}); + } catch (IOException e) { + log.error("Error converting JSON string to list", e); + return null; + } + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/global/entity/BaseEntity.java b/runtracker/src/main/java/com/runtracker/global/entity/BaseEntity.java new file mode 100644 index 0000000..4795e84 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/global/entity/BaseEntity.java @@ -0,0 +1,22 @@ +package com.runtracker.global.entity; + +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseEntity { + + @CreatedDate + private LocalDateTime createdAt; + + @LastModifiedDate + private LocalDateTime updatedAt; +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/global/exception/CustomException.java b/runtracker/src/main/java/com/runtracker/global/exception/CustomException.java new file mode 100644 index 0000000..56fb138 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/global/exception/CustomException.java @@ -0,0 +1,20 @@ +package com.runtracker.global.exception; + +import com.runtracker.global.code.ResponseCode; +import lombok.Getter; + +@Getter +public class CustomException extends RuntimeException { + + private final ResponseCode responseCode; + + public CustomException(ResponseCode responseCode) { + super(responseCode.getMessage()); + this.responseCode = responseCode; + } + + public CustomException(ResponseCode responseCode, String message) { + super(message); + this.responseCode = responseCode; + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/global/exception/GlobalExceptionHandler.java b/runtracker/src/main/java/com/runtracker/global/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..6e8099e --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/global/exception/GlobalExceptionHandler.java @@ -0,0 +1,108 @@ +package com.runtracker.global.exception; + +import com.runtracker.global.response.ApiResponse; +import com.runtracker.global.code.CommonResponseCode; +import lombok.extern.slf4j.Slf4j; +import org.hibernate.exception.ConstraintViolationException; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.HttpStatus; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.resource.NoResourceFoundException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.validation.FieldError; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + @ResponseStatus(HttpStatus.METHOD_NOT_ALLOWED) + public ApiResponse handleMethodNotAllowed(HttpRequestMethodNotSupportedException e) { + log.warn("HTTP Method not allowed: {}", e.getMessage()); + return ApiResponse.error(CommonResponseCode.BAD_REQUEST_ERROR, "Method Not Allowed"); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ApiResponse handleValidationException(MethodArgumentNotValidException e) { + log.warn("Validation failed: {}", e.getMessage()); + + StringBuilder errorMessage = new StringBuilder(); + for (FieldError fieldError : e.getBindingResult().getFieldErrors()) { + errorMessage.append(fieldError.getDefaultMessage()).append(", "); + } + + String message = !errorMessage.isEmpty() + ? errorMessage.substring(0, errorMessage.length() - 2) + : "유효하지 않은 요청입니다"; + + return ApiResponse.error(CommonResponseCode.NOT_VALID_ERROR, message); + } + + @ExceptionHandler(MissingServletRequestParameterException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ApiResponse handleMissingParameter(MissingServletRequestParameterException e) { + log.warn("Missing required parameter: {}", e.getMessage()); + String message = "Missing required parameter : " + e.getParameterName(); + return ApiResponse.error(CommonResponseCode.NOT_VALID_ERROR, message); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ApiResponse handleHttpMessageNotReadable(HttpMessageNotReadableException e) { + log.warn("Request body missing or malformed: {}", e.getMessage()); + String message = "Required request body is missing or malformed"; + return ApiResponse.error(CommonResponseCode.NOT_VALID_ERROR, message); + } + + @ExceptionHandler(NoResourceFoundException.class) + @ResponseStatus(HttpStatus.NOT_FOUND) + public ApiResponse handleNoResourceFoundException(NoResourceFoundException e) { + log.warn("Resource not found: {}", e.getMessage()); + return ApiResponse.error(CommonResponseCode.NOT_FOUND_ERROR); + } + + @ExceptionHandler(DataIntegrityViolationException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ApiResponse handleDataIntegrityViolationException(DataIntegrityViolationException e) { + log.warn("Data integrity violation: {}", e.getMessage()); + + // 유니크 제약 조건 위반 + if (e.getCause() instanceof ConstraintViolationException) { + return ApiResponse.error(CommonResponseCode.BAD_REQUEST_ERROR, "중복된 요청입니다."); + } + + // 그 외 무결성 제약 위반 + return ApiResponse.error(CommonResponseCode.BAD_REQUEST_ERROR, "데이터 무결성 예외가 발생했습니다."); + } + + @ExceptionHandler(CustomException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ApiResponse handleCustomException(CustomException e) { + log.warn("Custom exception occurred: {}", e.getMessage()); + if (!e.getMessage().equals(e.getResponseCode().getMessage())) { + return ApiResponse.error(e.getResponseCode(), e.getMessage()); + } else { + return ApiResponse.error(e.getResponseCode()); + } + } + + @ExceptionHandler(RuntimeException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ApiResponse handleRuntimeException(RuntimeException e) { + log.warn("Runtime exception occurred: {}", e.getMessage()); + return ApiResponse.error(CommonResponseCode.BAD_REQUEST_ERROR, e.getMessage()); + } + + @ExceptionHandler(Exception.class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public ApiResponse handleException(Exception e) { + log.error("Exception occurred: ", e); + return ApiResponse.error(CommonResponseCode.INTERNAL_SERVER_ERROR); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/global/fcm/FcmClient.java b/runtracker/src/main/java/com/runtracker/global/fcm/FcmClient.java new file mode 100644 index 0000000..04442f1 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/global/fcm/FcmClient.java @@ -0,0 +1,35 @@ +package com.runtracker.global.fcm; + +import com.google.firebase.messaging.AndroidConfig; +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.Message; +import com.google.firebase.messaging.Notification; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +public class FcmClient { + + public Boolean send(String title, String body, String token) { + try { + Message message = Message.builder() + .setToken(token) + .setNotification(Notification.builder() + .setTitle(title) + .setBody(body) + .build()) + .setAndroidConfig(AndroidConfig.builder() + .setPriority(AndroidConfig.Priority.HIGH) + .build()) + .build(); + + String response = FirebaseMessaging.getInstance().sendAsync(message).get(); + + return response != null && !response.isEmpty(); + } catch (Exception e) { + log.error("FCM 메시지 발송 실패 Error: {}", e.getMessage()); + return false; + } + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/global/fcm/FirebaseConfig.java b/runtracker/src/main/java/com/runtracker/global/fcm/FirebaseConfig.java new file mode 100644 index 0000000..aa3e307 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/global/fcm/FirebaseConfig.java @@ -0,0 +1,61 @@ +package com.runtracker.global.fcm; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.messaging.FirebaseMessaging; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ClassPathResource; +import lombok.extern.slf4j.Slf4j; + +import javax.annotation.PostConstruct; +import java.io.IOException; + +@Slf4j +@Configuration +public class FirebaseConfig { + + @Value("${firebase.service-account-key:#{null}}") + private String serviceAccountKeyPath; + + @Value("${firebase.project_id:#{null}}") + private String firebaseProjectId; + + @PostConstruct + public void initialize() { + try { + GoogleCredentials googleCredentials; + String firebaseJson = System.getenv("FCM_JSON"); + + if (firebaseJson != null && !firebaseJson.isEmpty()) { + googleCredentials = GoogleCredentials.fromStream( + new java.io.ByteArrayInputStream(firebaseJson.getBytes()) + ); + } else if (serviceAccountKeyPath != null && !serviceAccountKeyPath.isEmpty()) { + String firebasePath = "firebase/" + serviceAccountKeyPath; + googleCredentials = GoogleCredentials + .fromStream(new ClassPathResource(firebasePath).getInputStream()); + } else { + throw new RuntimeException("Firebase service account key not found. Please set FCM_JSON or FIREBASE_SERVICE_ACCOUNT_KEY"); + } + + FirebaseOptions options = FirebaseOptions.builder() + .setCredentials(googleCredentials) + .setProjectId(firebaseProjectId) + .build(); + + if (FirebaseApp.getApps().isEmpty()) { + FirebaseApp.initializeApp(options); + } + } catch (IOException e) { + throw new RuntimeException("Firebase initialization failed", e); + } + } + + @Bean + public FirebaseMessaging firebaseMessaging() { + return FirebaseMessaging.getInstance(); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/global/fcm/dto/FcmTokenDto.java b/runtracker/src/main/java/com/runtracker/global/fcm/dto/FcmTokenDto.java new file mode 100644 index 0000000..f1d0977 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/global/fcm/dto/FcmTokenDto.java @@ -0,0 +1,47 @@ +package com.runtracker.global.fcm.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class FcmTokenDto { + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class TokenWithPhoneNumber { + private String phoneNumber; + private String fcmToken; + } + + private List tokenPairs; + + public static TokenWithPhoneNumber of(String phoneNumber, String fcmToken) { + return TokenWithPhoneNumber.builder() + .phoneNumber(phoneNumber) + .fcmToken(fcmToken) + .build(); + } + + public static FcmTokenDto of(String phoneNumber, List fcmTokens) { + if (fcmTokens == null || fcmTokens.isEmpty()) { + return FcmTokenDto.builder().tokenPairs(List.of()).build(); + } + + List tokenPairs = fcmTokens.stream() + .map(token -> of(phoneNumber, token)) + .toList(); + + return FcmTokenDto.builder() + .tokenPairs(tokenPairs) + .build(); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/global/jwt/JwtAuthenticationFilter.java b/runtracker/src/main/java/com/runtracker/global/jwt/JwtAuthenticationFilter.java new file mode 100644 index 0000000..fcd9f1a --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/global/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,131 @@ +package com.runtracker.global.jwt; + +import com.runtracker.global.jwt.exception.ExpiredJwtTokenException; +import com.runtracker.global.jwt.exception.InvalidJwtTokenException; +import com.runtracker.global.jwt.exception.JwtClaimsEmptyException; +import com.runtracker.global.jwt.exception.UnsupportedJwtTokenException; +import com.runtracker.global.security.UserDetailsServiceImpl; +import com.runtracker.global.jwt.service.TokenBlacklistService; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; +import org.springframework.util.AntPathMatcher; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; +import java.io.IOException; +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtUtil jwtUtil; + private final UserDetailsServiceImpl userDetailsService; + private final TokenBlacklistService tokenBlacklistService; + private final List excludePaths; + private final AntPathMatcher pathMatcher = new AntPathMatcher(); + + private static final String AUTHORIZATION_HEADER = "Authorization"; + private static final String BEARER_PREFIX = "Bearer "; + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + try { + // Skip authentication for excluded paths + if (shouldSkipAuthentication(request)) { + filterChain.doFilter(request, response); + return; + } + + String token = extractTokenFromRequest(request); + + if (StringUtils.hasText(token)) { + authenticateToken(token); + } + + filterChain.doFilter(request, response); + + } catch (InvalidJwtTokenException e) { + handleJwtException(response, "J002", "Invalid JWT token", e.getMessage()); + } catch (ExpiredJwtTokenException e) { + handleJwtException(response, "J001", "Expired JWT token", e.getMessage()); + } catch (UnsupportedJwtTokenException e) { + handleJwtException(response, "J004", "Unsupported JWT token", e.getMessage()); + } catch (JwtClaimsEmptyException e) { + handleJwtException(response, "J003", "JWT Claims is empty", e.getMessage()); + } catch (Exception e) { + log.error("Unexpected error in JWT filter: {}", e.getMessage()); + SecurityContextHolder.clearContext(); + handleException(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "C999", "Internal server error", e.getMessage()); + } + } + + private boolean shouldSkipAuthentication(HttpServletRequest request) { + String requestURI = request.getRequestURI(); + + if (excludePaths.contains("*")) { + return true; + } + + return excludePaths.stream().anyMatch(path -> + pathMatcher.match(path, requestURI) + ); + } + + private void authenticateToken(String token) { + jwtUtil.validateToken(token); + + if (tokenBlacklistService.isBlacklisted(token)) { + throw new InvalidJwtTokenException(); + } + + Long memberId = jwtUtil.getMemberIdFromToken(token); + + UserDetails userDetails = userDetailsService.loadUserByUsername(String.valueOf(memberId)); + + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); + + SecurityContextHolder.getContext().setAuthentication(authentication); + log.debug("Set Authentication to SecurityContext for member: {}", memberId); + } + + private String extractTokenFromRequest(HttpServletRequest request) { + String bearerToken = request.getHeader(AUTHORIZATION_HEADER); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) { + return bearerToken.substring(BEARER_PREFIX.length()); + } + return null; + } + + private void handleJwtException(HttpServletResponse response, String statusCode, String message, String description) throws IOException { + log.error("JWT authentication failed - {}: {}", message, description); + SecurityContextHolder.clearContext(); + handleException(response, HttpServletResponse.SC_UNAUTHORIZED, statusCode, message, description); + } + + private void handleException(HttpServletResponse response, int status, String statusCode, String message, String description) throws IOException { + response.setStatus(status); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write(String.format(""" + { + "status": { + "statusCode": "%s", + "message": "%s", + "description": "%s" + } + } + """, statusCode, message, description)); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/global/jwt/JwtUtil.java b/runtracker/src/main/java/com/runtracker/global/jwt/JwtUtil.java new file mode 100644 index 0000000..306efd1 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/global/jwt/JwtUtil.java @@ -0,0 +1,157 @@ +package com.runtracker.global.jwt; + +import com.runtracker.global.jwt.dto.TokenDataDto; +import com.runtracker.global.jwt.exception.ExpiredJwtTokenException; +import com.runtracker.global.jwt.exception.InvalidJwtTokenException; +import com.runtracker.global.jwt.exception.JwtClaimsEmptyException; +import com.runtracker.global.jwt.exception.UnsupportedJwtTokenException; +import com.runtracker.global.jwt.service.TokenBlacklistService; +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Date; + +@Slf4j +@Component +public class JwtUtil { + + private final SecretKey secretKey; + private final long accessTokenExpiration; + private final long refreshTokenExpiration; + private final TokenBlacklistService tokenBlacklistService; + + public JwtUtil(@Value("${jwt.secret}") String secret, + @Value("${jwt.access-token-expiration}") long accessTokenExpiration, + @Value("${jwt.refresh-token-expiration}") long refreshTokenExpiration, + TokenBlacklistService tokenBlacklistService) { + this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + this.accessTokenExpiration = accessTokenExpiration; + this.refreshTokenExpiration = refreshTokenExpiration; + this.tokenBlacklistService = tokenBlacklistService; + } + + public String generateAccessToken(Long memberId, String socialId) { + Date now = new Date(); + Date expiration = new Date(now.getTime() + accessTokenExpiration); + + String token = Jwts.builder() + .setSubject(memberId.toString()) + .claim("socialId", socialId) + .setIssuedAt(now) + .setExpiration(expiration) + .signWith(secretKey, SignatureAlgorithm.HS256) + .compact(); + + tokenBlacklistService.trackUserToken(memberId, token, + java.time.Duration.ofMillis(accessTokenExpiration)); + + return token; + } + + public String generateRefreshToken(Long memberId) { + Date now = new Date(); + Date expiration = new Date(now.getTime() + refreshTokenExpiration); + + String token = Jwts.builder() + .setSubject(memberId.toString()) + .setIssuedAt(now) + .setExpiration(expiration) + .signWith(secretKey, SignatureAlgorithm.HS256) + .compact(); + + tokenBlacklistService.trackUserToken(memberId, token, + java.time.Duration.ofMillis(refreshTokenExpiration)); + + return token; + } + + public Claims parseToken(String token) { + try { + return Jwts.parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token) + .getBody(); + } catch (ExpiredJwtException e) { + log.error("JWT token expired: {}", e.getMessage()); + throw new ExpiredJwtTokenException(); + } catch (MalformedJwtException e) { + log.error("Malformed JWT token: {}", e.getMessage()); + throw new InvalidJwtTokenException(); + } catch (UnsupportedJwtException e) { + log.error("Unsupported JWT token: {}", e.getMessage()); + throw new UnsupportedJwtTokenException(); + } catch (IllegalArgumentException e) { + log.error("JWT claims string is empty: {}", e.getMessage()); + throw new JwtClaimsEmptyException(); + } catch (JwtException e) { + log.error("Invalid JWT token: {}", e.getMessage()); + throw new InvalidJwtTokenException(); + } + } + + public void validateToken(String token) { + try { + if (tokenBlacklistService.isBlacklisted(token)) { + log.warn("Token is blacklisted"); + throw new InvalidJwtTokenException(); + } + + parseToken(token); + } catch (ExpiredJwtTokenException | UnsupportedJwtTokenException | + JwtClaimsEmptyException | InvalidJwtTokenException e) { + throw e; + } + } + + public void blacklistToken(String token) { + try { + Claims claims = parseToken(token); + long expirationTime = claims.getExpiration().getTime(); + tokenBlacklistService.blacklistToken(token, expirationTime); + } catch (Exception e) { + throw new RuntimeException("Failed to blacklist token", e); + } + } + + public Long getMemberIdFromToken(String token) { + Claims claims = parseToken(token); + return Long.valueOf(claims.getSubject()); + } + + public String getSocialIdFromToken(String token) { + Claims claims = parseToken(token); + return claims.get("socialId", String.class); + } + + public TokenDataDto createTokenData(Long memberId, String socialId) { + Date accessTokenExpiry = new Date(System.currentTimeMillis() + accessTokenExpiration); + Date refreshTokenExpiry = new Date(System.currentTimeMillis() + refreshTokenExpiration); + + String accessToken = generateAccessToken(memberId, socialId); + String refreshToken = generateRefreshToken(memberId); + + return TokenDataDto.builder() + .grantType("Bearer") + .accessToken(accessToken) + .refreshToken(refreshToken) + .accessTokenExpiredAt(accessTokenExpiry.getTime()) + .refreshTokenExpiredAt(refreshTokenExpiry.getTime()) + .build(); + } + + public TokenDataDto refreshToken(String refreshToken) { + validateToken(refreshToken); + + Claims claims = parseToken(refreshToken); + Long memberId = Long.valueOf(claims.getSubject()); + String socialId = claims.get("socialId", String.class); + + return createTokenData(memberId, socialId); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/global/jwt/dto/TokenDataDto.java b/runtracker/src/main/java/com/runtracker/global/jwt/dto/TokenDataDto.java new file mode 100644 index 0000000..96319e5 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/global/jwt/dto/TokenDataDto.java @@ -0,0 +1,18 @@ +package com.runtracker.global.jwt.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TokenDataDto { + private String grantType; + private String accessToken; + private String refreshToken; + private Long accessTokenExpiredAt; + private Long refreshTokenExpiredAt; +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/global/jwt/enums/JwtErrorCode.java b/runtracker/src/main/java/com/runtracker/global/jwt/enums/JwtErrorCode.java new file mode 100644 index 0000000..2ef2297 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/global/jwt/enums/JwtErrorCode.java @@ -0,0 +1,18 @@ +package com.runtracker.global.jwt.enums; + +import com.runtracker.global.code.ResponseCode; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum JwtErrorCode implements ResponseCode { + + EXPIRED_JWT_TOKEN("J001", "Expired JWT token"), + INVALID_JWT_TOKEN("J002", "Invalid JWT token"), + JWT_CLAIMS_EMPTY("J003", "Jwt Claims is empty"), + UNSUPPORTED_JWT_TOKEN("J004", "Unsupported JWT token"); + + private final String statusCode; + private final String message; +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/global/jwt/exception/ExpiredJwtTokenException.java b/runtracker/src/main/java/com/runtracker/global/jwt/exception/ExpiredJwtTokenException.java new file mode 100644 index 0000000..49e5cbd --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/global/jwt/exception/ExpiredJwtTokenException.java @@ -0,0 +1,10 @@ +package com.runtracker.global.jwt.exception; + +import com.runtracker.global.exception.CustomException; +import com.runtracker.global.jwt.enums.JwtErrorCode; + +public class ExpiredJwtTokenException extends CustomException { + public ExpiredJwtTokenException() { + super(JwtErrorCode.EXPIRED_JWT_TOKEN); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/global/jwt/exception/InvalidJwtTokenException.java b/runtracker/src/main/java/com/runtracker/global/jwt/exception/InvalidJwtTokenException.java new file mode 100644 index 0000000..f4e7f93 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/global/jwt/exception/InvalidJwtTokenException.java @@ -0,0 +1,10 @@ +package com.runtracker.global.jwt.exception; + +import com.runtracker.global.exception.CustomException; +import com.runtracker.global.jwt.enums.JwtErrorCode; + +public class InvalidJwtTokenException extends CustomException { + public InvalidJwtTokenException() { + super(JwtErrorCode.INVALID_JWT_TOKEN); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/global/jwt/exception/JwtClaimsEmptyException.java b/runtracker/src/main/java/com/runtracker/global/jwt/exception/JwtClaimsEmptyException.java new file mode 100644 index 0000000..6b8d4fa --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/global/jwt/exception/JwtClaimsEmptyException.java @@ -0,0 +1,10 @@ +package com.runtracker.global.jwt.exception; + +import com.runtracker.global.exception.CustomException; +import com.runtracker.global.jwt.enums.JwtErrorCode; + +public class JwtClaimsEmptyException extends CustomException { + public JwtClaimsEmptyException() { + super(JwtErrorCode.JWT_CLAIMS_EMPTY); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/global/jwt/exception/UnsupportedJwtTokenException.java b/runtracker/src/main/java/com/runtracker/global/jwt/exception/UnsupportedJwtTokenException.java new file mode 100644 index 0000000..e60f371 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/global/jwt/exception/UnsupportedJwtTokenException.java @@ -0,0 +1,10 @@ +package com.runtracker.global.jwt.exception; + +import com.runtracker.global.exception.CustomException; +import com.runtracker.global.jwt.enums.JwtErrorCode; + +public class UnsupportedJwtTokenException extends CustomException { + public UnsupportedJwtTokenException() { + super(JwtErrorCode.UNSUPPORTED_JWT_TOKEN); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/global/jwt/service/TokenBlacklistService.java b/runtracker/src/main/java/com/runtracker/global/jwt/service/TokenBlacklistService.java new file mode 100644 index 0000000..c163113 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/global/jwt/service/TokenBlacklistService.java @@ -0,0 +1,143 @@ +package com.runtracker.global.jwt.service; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.time.Duration; + +@Slf4j +@Service +public class TokenBlacklistService { + + private final RedisTemplate redisTemplate; + private final SecretKey secretKey; + private static final String BLACKLIST_PREFIX = "blacklist:token:"; + private static final String USER_TOKENS_PREFIX = "user:tokens:"; + + public TokenBlacklistService(RedisTemplate redisTemplate, + @Value("${jwt.secret}") String secret) { + this.redisTemplate = redisTemplate; + this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + } + + /** + * 토큰을 블랙리스트에 추가 + */ + public void blacklistToken(String token, long expirationTime) { + try { + long currentTime = System.currentTimeMillis(); + long remainingTime = expirationTime - currentTime; + + if (remainingTime > 0) { + String key = BLACKLIST_PREFIX + token; + Duration ttl = Duration.ofMillis(remainingTime); + redisTemplate.opsForValue().set(key, "blacklisted", ttl); + log.info("Token blacklisted successfully with TTL: {} ms", remainingTime); + } + } catch (Exception e) { + throw new RuntimeException("Failed to blacklist token", e); + } + } + + /** + * 토큰이 블랙리스트에 있는지 확인 + */ + public boolean isBlacklisted(String token) { + try { + String key = BLACKLIST_PREFIX + token; + return redisTemplate.hasKey(key); + } catch (Exception e) { + log.error("Failed to check token blacklist status", e); + return false; + } + } + + /** + * 특정 토큰을 블랙리스트에서 제거 (테스트용) + */ + public void removeFromBlacklist(String token) { + try { + String key = BLACKLIST_PREFIX + token; + redisTemplate.delete(key); + } catch (Exception e) { + log.error("Failed to remove token from blacklist", e); + } + } + + /** + * 사용자의 토큰을 추적 리스트에 추가 + */ + public void trackUserToken(Long memberId, String token, Duration expiration) { + try { + String userTokensKey = USER_TOKENS_PREFIX + memberId; + redisTemplate.opsForList().leftPush(userTokensKey, token); + redisTemplate.expire(userTokensKey, expiration); + } catch (Exception e) { + log.error("Failed to track token for user: {}", memberId, e); + } + } + + /** + * 특정 사용자의 모든 토큰 무효화 + */ + public void invalidateUserTokens(Long memberId) { + try { + String userTokensKey = USER_TOKENS_PREFIX + memberId; + + // 사용자의 모든 토큰을 가져와서 블랙리스트에 추가 + Long listSize = redisTemplate.opsForList().size(userTokensKey); + if (listSize != null && listSize > 0) { + while (listSize > 0) { + String token = redisTemplate.opsForList().rightPop(userTokensKey); + if (token != null) { + long expirationTime = extractExpirationTime(token); + blacklistToken(token, expirationTime); + } + listSize = redisTemplate.opsForList().size(userTokensKey); + if (listSize == null) break; + } + } + + redisTemplate.delete(userTokensKey); + } catch (Exception e) { + log.error("Failed to invalidate user tokens for user: {}", memberId, e); + } + } + + /** + * 특정 크루의 모든 멤버 토큰 무효화 + */ + public void invalidateCrewMemberTokens(Long crewId, java.util.List memberIds) { + for (Long memberId : memberIds) { + invalidateUserTokens(memberId); + } + + log.info("Completed token invalidation for crew: {} ({} members processed)", crewId, memberIds.size()); + } + + /** + * JWT 토큰에서 만료시간 추출 + */ + private long extractExpirationTime(String token) { + try { + Claims claims = Jwts.parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token) + .getBody(); + + return claims.getExpiration().getTime(); + } catch (Exception e) { + log.error("Failed to extract expiration time from token, using default: {}", e.getMessage()); + return System.currentTimeMillis() + 3600000; + } + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/global/response/ApiResponse.java b/runtracker/src/main/java/com/runtracker/global/response/ApiResponse.java new file mode 100644 index 0000000..26f482c --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/global/response/ApiResponse.java @@ -0,0 +1,45 @@ +package com.runtracker.global.response; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.runtracker.global.code.ResponseCode; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ApiResponse { + private ResponseStatus status; + private T body; + + public static ApiResponse ok() { + return ApiResponse.ok(null); + } + + public static ApiResponse ok(T body) { + var apiResponse = new ApiResponse(); + apiResponse.status = ResponseStatus.ok(); + apiResponse.body = body; + return apiResponse; + } + + public static ApiResponse error() { + var apiResponse = new ApiResponse(); + apiResponse.status = ResponseStatus.error(); + return apiResponse; + } + + public static ApiResponse error(ResponseCode responseCode) { + var apiResponse = new ApiResponse(); + apiResponse.status = ResponseStatus.error(responseCode); + return apiResponse; + } + + public static ApiResponse error(ResponseCode responseCode, String description) { + var apiResponse = new ApiResponse(); + apiResponse.status = ResponseStatus.error(responseCode, description); + return apiResponse; + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/global/response/ResponseStatus.java b/runtracker/src/main/java/com/runtracker/global/response/ResponseStatus.java new file mode 100644 index 0000000..0f4aefb --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/global/response/ResponseStatus.java @@ -0,0 +1,44 @@ +package com.runtracker.global.response; + +import com.runtracker.global.code.CommonResponseCode; +import com.runtracker.global.code.ResponseCode; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ResponseStatus { + private String statusCode; + private String message; + private String description; + + public static ResponseStatus ok() { + return ResponseStatus.builder() + .statusCode(CommonResponseCode.OK.getStatusCode()) + .message(CommonResponseCode.OK.getMessage()) + .build(); + } + + public static ResponseStatus error() { + return error(CommonResponseCode.INTERNAL_SERVER_ERROR); + } + + public static ResponseStatus error(ResponseCode responseCode) { + return ResponseStatus.builder() + .statusCode(responseCode.getStatusCode()) + .message(responseCode.getMessage()) + .build(); + } + + public static ResponseStatus error(ResponseCode responseCode, String description) { + return ResponseStatus.builder() + .statusCode(responseCode.getStatusCode()) + .message(responseCode.getMessage()) + .description(description) + .build(); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/global/security/CrewAuthorizationUtil.java b/runtracker/src/main/java/com/runtracker/global/security/CrewAuthorizationUtil.java new file mode 100644 index 0000000..22a8901 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/global/security/CrewAuthorizationUtil.java @@ -0,0 +1,65 @@ +package com.runtracker.global.security; + +import com.runtracker.domain.crew.exception.NotCrewLeaderException; +import com.runtracker.domain.crew.exception.UnauthorizedCrewAccessException; +import com.runtracker.domain.member.entity.enums.MemberRole; +import com.runtracker.global.security.dto.CrewMembership; +import org.springframework.stereotype.Component; + +@Component +public class CrewAuthorizationUtil { + + /** + * 크루장 권한 검증 + */ + public void validateCrewLeaderPermission(UserDetailsImpl userDetails, Long crewId) { + CrewMembership membership = userDetails.getCrewMembership(); + + if (membership == null) { + throw new NotCrewLeaderException(); + } + + if (!membership.getCrewId().equals(crewId)) { + throw new NotCrewLeaderException(); + } + + if (membership.getRole() != MemberRole.CREW_LEADER) { + throw new NotCrewLeaderException(); + } + } + + /** + * 크루 관리 권한 검증 + */ + public void validateCrewManagementPermission(UserDetailsImpl userDetails, Long crewId) { + CrewMembership membership = userDetails.getCrewMembership(); + + if (membership == null) { + throw new NotCrewLeaderException(); + } + + if (!membership.getCrewId().equals(crewId)) { + throw new NotCrewLeaderException(); + } + + if (membership.getRole() != MemberRole.CREW_LEADER && + membership.getRole() != MemberRole.CREW_MANAGER) { + throw new NotCrewLeaderException(); + } + } + + /** + * 크루 멤버 접근 권한 검증 + */ + public void validateCrewMemberAccess(UserDetailsImpl userDetails, Long crewId) { + CrewMembership membership = userDetails.getCrewMembership(); + + if (membership == null) { + throw new UnauthorizedCrewAccessException(); + } + + if (!membership.getCrewId().equals(crewId)) { + throw new UnauthorizedCrewAccessException(); + } + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/global/security/UserDetailsImpl.java b/runtracker/src/main/java/com/runtracker/global/security/UserDetailsImpl.java new file mode 100644 index 0000000..86d5331 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/global/security/UserDetailsImpl.java @@ -0,0 +1,62 @@ +package com.runtracker.global.security; + +import com.runtracker.domain.member.entity.enums.MemberRole; +import com.runtracker.global.security.dto.CrewMembership; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.List; +import java.util.stream.Collectors; + +@Builder +@Getter +@AllArgsConstructor +public class UserDetailsImpl implements UserDetails { + + private Long memberId; + private String socialId; + private List roles; + private CrewMembership crewMembership; + + @Override + public Collection getAuthorities() { + return this.roles.stream() + .map(role -> new SimpleGrantedAuthority(role.getAuthority())) + .collect(Collectors.toList()); + } + + @Override + public String getPassword() { + return ""; + } + + @Override + public String getUsername() { + return String.valueOf(this.memberId); + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/global/security/UserDetailsServiceImpl.java b/runtracker/src/main/java/com/runtracker/global/security/UserDetailsServiceImpl.java new file mode 100644 index 0000000..c9c793a --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/global/security/UserDetailsServiceImpl.java @@ -0,0 +1,57 @@ +package com.runtracker.global.security; + +import com.runtracker.domain.crew.enums.CrewMemberStatus; +import com.runtracker.domain.crew.repository.CrewMemberRepository; +import com.runtracker.domain.member.entity.Member; +import com.runtracker.domain.member.entity.enums.MemberRole; +import com.runtracker.domain.member.repository.MemberRepository; +import com.runtracker.global.security.dto.CrewMembership; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class UserDetailsServiceImpl implements UserDetailsService { + + private final MemberRepository memberRepository; + private final CrewMemberRepository crewMemberRepository; + + @Override + @Transactional(readOnly = true) + public UserDetails loadUserByUsername(String memberId) throws UsernameNotFoundException { + Member member = memberRepository.findById(Long.valueOf(memberId)) + .orElseThrow(() -> new UsernameNotFoundException("Failed to load user by memberId: " + memberId)); + + return createUserDetails(member); + } + + private UserDetails createUserDetails(Member member) { + List roles = new ArrayList<>(); + roles.add(MemberRole.USER); + + // 활성 크루 멤버십 조회 (한 명당 크루 1개만 가능) + CrewMembership crewMembership = crewMemberRepository + .findByMemberIdAndStatus(member.getId(), CrewMemberStatus.ACTIVE) + .stream() + .findFirst() + .map(crewMember -> CrewMembership.builder() + .crewId(crewMember.getCrewId()) + .role(crewMember.getRole()) + .build()) + .orElse(null); + + return UserDetailsImpl.builder() + .memberId(member.getId()) + .socialId(member.getSocialId()) + .roles(roles) + .crewMembership(crewMembership) + .build(); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/global/security/dto/CrewMembership.java b/runtracker/src/main/java/com/runtracker/global/security/dto/CrewMembership.java new file mode 100644 index 0000000..eac6306 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/global/security/dto/CrewMembership.java @@ -0,0 +1,16 @@ +package com.runtracker.global.security.dto; + +import com.runtracker.domain.member.entity.enums.MemberRole; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Builder +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class CrewMembership { + private Long crewId; + private MemberRole role; +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/global/util/ImageConverter.java b/runtracker/src/main/java/com/runtracker/global/util/ImageConverter.java new file mode 100644 index 0000000..b89876a --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/global/util/ImageConverter.java @@ -0,0 +1,81 @@ +package com.runtracker.global.util; + +import com.runtracker.domain.upload.exception.ImageConversionFailedException; +import com.sksamuel.scrimage.ImmutableImage; +import com.sksamuel.scrimage.webp.WebpWriter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.multipart.MultipartFile; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; + + +@Slf4j +public class ImageConverter { + + private static final int DEFAULT_QUALITY = 80; + private static final int MAX_WIDTH = 1920; + private static final int MAX_HEIGHT = 1920; + + // MultipartFile을 WebP 포맷의 InputStream으로 변환 + public static InputStream convertToWebP(MultipartFile file) { + return convertToWebP(file, DEFAULT_QUALITY); + } + + // MultipartFile을 WebP 포맷의 InputStream으로 변환 + public static InputStream convertToWebP(MultipartFile file, int quality) { + try { + ImmutableImage image = ImmutableImage.loader().fromBytes(file.getBytes()); + return processImageConversion(image, quality); + } catch (Exception e) { + log.error("Failed to convert image to WebP: {}", file.getOriginalFilename(), e); + throw new ImageConversionFailedException(file.getOriginalFilename()); + } + } + + // InputStream을 WebP 포맷의 InputStream으로 변환 + public static InputStream convertToWebP(InputStream inputStream) { + return convertToWebP(inputStream, DEFAULT_QUALITY); + } + + // InputStream을 WebP 포맷의 InputStream으로 변환 + public static InputStream convertToWebP(InputStream inputStream, int quality) { + try { + ImmutableImage image = ImmutableImage.loader().fromStream(inputStream); + return processImageConversion(image, quality); + } catch (Exception e) { + log.error("Failed to convert image to WebP from InputStream", e); + throw new ImageConversionFailedException("input-stream"); + } + } + + // 이미지 처리 및 변환 + private static InputStream processImageConversion(ImmutableImage image, int quality) throws Exception { + image = resizeIfNeeded(image); + return convertImageToWebP(image, quality); + } + + // 이미지 리사이징이 필요한 경우 리사이징 수행 + private static ImmutableImage resizeIfNeeded(ImmutableImage image) { + if (image.width > MAX_WIDTH || image.height > MAX_HEIGHT) { + int originalWidth = image.width; + int originalHeight = image.height; + + double scale = Math.min((double) MAX_WIDTH / image.width, (double) MAX_HEIGHT / image.height); + int newWidth = (int) (image.width * scale); + int newHeight = (int) (image.height * scale); + + image = image.scaleTo(newWidth, newHeight); + log.info("Image resized from {}x{} to {}x{}", originalWidth, originalHeight, newWidth, newHeight); + } + return image; + } + + // ImmutableImage를 WebP 포맷의 InputStream으로 변환 + private static InputStream convertImageToWebP(ImmutableImage image, int quality) throws IOException { + WebpWriter writer = WebpWriter.DEFAULT.withQ(quality); + byte[] webpBytes = image.bytes(writer); + return new ByteArrayInputStream(webpBytes); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/global/util/ImageUpload.java b/runtracker/src/main/java/com/runtracker/global/util/ImageUpload.java new file mode 100644 index 0000000..1abb0ad --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/global/util/ImageUpload.java @@ -0,0 +1,33 @@ +package com.runtracker.global.util; + +import com.runtracker.domain.upload.service.FileStorageService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ImageUpload { + + private final FileStorageService fileStorageService; + + // Base64 이미지 데이터를 URL로 변환 + public String convertBase64ToUrlIfNeeded(String data) { + if (data == null || data.trim().isEmpty()) { + return null; + } + + try { + // Base64 데이터인지 확인 + if (data.startsWith("data:image") || data.length() > 500) { + return fileStorageService.uploadBase64Image(data); + } + // 이미 URL인 경우 그대로 반환 + return data; + } catch (Exception e) { + log.error("Failed to convert Base64 image to URL", e); + return data; + } + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/global/util/message/Messages.java b/runtracker/src/main/java/com/runtracker/global/util/message/Messages.java new file mode 100644 index 0000000..a6bc39d --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/global/util/message/Messages.java @@ -0,0 +1,33 @@ +package com.runtracker.global.util.message; + +import jakarta.annotation.PostConstruct; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import org.springframework.context.MessageSource; +import org.springframework.context.support.MessageSourceAccessor; +import org.springframework.stereotype.Component; + +import java.util.Arrays; +import java.util.Locale; + +@RequiredArgsConstructor +@Component +public class Messages { + + private final MessageSource messageSource; + + private MessageSourceAccessor accessor; + + @PostConstruct + private void init() { + accessor = new MessageSourceAccessor(messageSource, Locale.getDefault()); + } + + public String get(String code) { + return accessor.getMessage(code); + } + + public String get(@NonNull String code, @NonNull Object... messages) { + return accessor.getMessage(code, Arrays.stream(messages).toArray()); + } +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/global/vo/Coordinate.java b/runtracker/src/main/java/com/runtracker/global/vo/Coordinate.java new file mode 100644 index 0000000..fac8fd9 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/global/vo/Coordinate.java @@ -0,0 +1,17 @@ +package com.runtracker.global.vo; + +import com.fasterxml.jackson.annotation.JsonAlias; +import lombok.*; + +@NoArgsConstructor +@AllArgsConstructor +@Getter +@Setter +@Builder +@ToString +public class Coordinate { + private Double lat; + + @JsonAlias({"lng"}) + private Double lnt; +} \ No newline at end of file diff --git a/runtracker/src/main/java/com/runtracker/global/vo/SegmentPace.java b/runtracker/src/main/java/com/runtracker/global/vo/SegmentPace.java new file mode 100644 index 0000000..1cb5393 --- /dev/null +++ b/runtracker/src/main/java/com/runtracker/global/vo/SegmentPace.java @@ -0,0 +1,14 @@ +package com.runtracker.global.vo; + +import lombok.*; + +@NoArgsConstructor +@AllArgsConstructor +@Getter +@Setter +@Builder +@ToString +public class SegmentPace { + private Double distance; + private Integer time; +} \ No newline at end of file diff --git a/runtracker/src/main/resources/application.yml b/runtracker/src/main/resources/application.yml new file mode 100644 index 0000000..258337d --- /dev/null +++ b/runtracker/src/main/resources/application.yml @@ -0,0 +1,89 @@ +spring: + config: + import: optional:file:.env[.properties] + + datasource: + url: ${SPRING_DATASOURCE_URL} + username: ${SPRING_DATASOURCE_USERNAME} + password: ${SPRING_DATASOURCE_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + + jpa: + hibernate: + ddl-auto: update + show-sql: true + properties: + hibernate: + dialect: org.hibernate.dialect.MySQL8Dialect + + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + jedis: + pool: + max-active: 8 + max-idle: 8 + min-idle: 0 + timeout: 3000ms + + servlet: + multipart: + enabled: true + max-file-size: 10MB + max-request-size: 10MB + + security: + oauth2: + client: + registration: + kakao: + client-id: ${KAKAO_CLIENT_ID} + redirect-uri: ${KAKAO_REDIRECT_URI} + authorization-grant-type: authorization_code + scope: profile_nickname,profile_image + client-name: Kakao + client-authentication-method: none + provider: + kakao: + authorization-uri: https://kauth.kakao.com/oauth/authorize + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me + user-name-attribute: id + +server: + port: ${SPRING_PORT:8080} + +jwt: + secret: ${JWT_SECRET} + access-token-expiration: ${JWT_ACCESS_TOKEN_EXPIRATION} + refresh-token-expiration: ${JWT_REFRESH_TOKEN_EXPIRATION} + +app: + domain: ${SPRING_DOMAIN} + oauth2: + redirect-uri: ${OAUTH2_REDIRECT_URI} + auth: + key: ${AUTH_KEY} + +firebase: + service-account-key: ${FIREBASE_SERVICE_ACCOUNT_KEY:} + url: https://fcm.googleapis.com/v1/projects/${Firebase_ID}/messages:send + google_api: https://www.googleapis.com/auth/cloud-platform + project_id: ${Firebase_ID} + +google: + maps: + api-key: ${GOOGLE_MAP_API_KEY} + +file: + upload-dir: ${FILE_UPLOAD_DIR} + base-url: ${SPRING_DOMAIN} + +aws: + access-key: ${S3_ACCESS_KEY} + secret-key: ${S3_SECRET_KEY} + region: ${S3_REGION} + s3: + bucket-name: ${S3_BUCKET} + base-url: ${S3_BASE_URL} \ No newline at end of file diff --git a/runtracker/src/main/resources/logback-spring.xml b/runtracker/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..528c2eb --- /dev/null +++ b/runtracker/src/main/resources/logback-spring.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + [%d{HH:mm:ss.SSS}] %clr(%-5level) %clr(${PID:-}){magenta} %clr(---){faint} + %clr([%15.15thread]){faint} %clr(%-40.40logger{36}){cyan} %clr(:){faint} %msg%n + + + + + + + ${LOG_PATH}/app.log + + ${LOG_PATH}/app-%d{yyyy-MM-dd}.log + 30 + + + [%d{HH:mm:ss.SSS}] %-5level ${PID:-} --- [%15.15thread] %-40.40logger{36} : %msg%n + + + + + + + + + + + + + + + diff --git a/runtracker/src/main/resources/messages.properties b/runtracker/src/main/resources/messages.properties new file mode 100644 index 0000000..e17d641 --- /dev/null +++ b/runtracker/src/main/resources/messages.properties @@ -0,0 +1,36 @@ +notify.crew.join.title=크루 가입 요청 알림 +notify.crew.join.content={0}님이 크루 가입을 요청했습니다. +notify.crew.cancel.title=크루 가입 취소 알림 +notify.crew.cancel.content={0}님이 크루 가입 요청을 취소했습니다. +notify.crew.approved.title=크루 가입 승인 +notify.crew.approved.content={0}크루 가입이 승인되었습니다. +notify.crew.rejected.title=크루 가입 거절 +notify.crew.rejected.content={0}크루 가입이 거절되었습니다. +notify.crew.role.update.title=크루 권한 변경 +notify.crew.role.update.content={0}크루에서 회원님의 권한이 {1}(으)로 변경되었습니다. +notify.crew.delete.title=크루 삭제 알림 +notify.crew.delete.content={0}크루가 삭제되었습니다. +notify.crew.ban.title=크루 추방 알림 +notify.crew.ban.content={0}크루에서 추방되었습니다. +notify.crew.leave.title=크루 탈퇴 알림 +notify.crew.leave.content={0}님이 크루를 떠났습니다. +notify.post.create.title=게시물 작성 완료 +notify.post.create.content=게시물이 성공적으로 작성되었습니다. +notify.post.update.title=게시물 수정 완료 +notify.post.update.content=게시물이 성공적으로 수정되었습니다. +notify.post.delete.title=게시물 삭제 완료 +notify.post.delete.content=게시물이 성공적으로 삭제되었습니다. +notify.post.like.title= 게시물 좋아요 알림 +notify.post.like.content={0}님이 회원님의 게시물에 좋아요를 눌렀습니다. +notify.post.comment.title=게시물 댓글 알림 +notify.post.comment.content={0}님이 회원님의 게시물에 댓글을 남겼습니다. +notify.schedule.create.title=새로운 일정 알림 +notify.schedule.create.content={0}님이 새로운 일정 {1}을(를) 등록했습니다. +notify.schedule.update.title=일정 수정 알림 +notify.schedule.update.content={0}님이 '{1}' 일정을 수정했습니다. +notify.schedule.delete.title=일정 삭제 알림 +notify.schedule.delete.content={0}님이 '{1}' 일정을 삭제했습니다. +notify.schedule.join.title=일정 참여 알림 +notify.schedule.join.content={0}님이 '{1}' 일정에 참여했습니다. +notify.schedule.cancel.title=일정 취소 알림 +notify.schedule.cancel.content={0}님이 '{1}' 일정 참여를 취소했습니다. \ No newline at end of file diff --git a/runtracker/src/main/resources/static/favicon-16x16.png b/runtracker/src/main/resources/static/favicon-16x16.png new file mode 100644 index 0000000..8b194e6 Binary files /dev/null and b/runtracker/src/main/resources/static/favicon-16x16.png differ diff --git a/runtracker/src/main/resources/static/favicon-32x32.png b/runtracker/src/main/resources/static/favicon-32x32.png new file mode 100644 index 0000000..249737f Binary files /dev/null and b/runtracker/src/main/resources/static/favicon-32x32.png differ diff --git a/runtracker/src/main/resources/static/index.css b/runtracker/src/main/resources/static/index.css new file mode 100644 index 0000000..f2376fd --- /dev/null +++ b/runtracker/src/main/resources/static/index.css @@ -0,0 +1,16 @@ +html { + box-sizing: border-box; + overflow: -moz-scrollbars-vertical; + overflow-y: scroll; +} + +*, +*:before, +*:after { + box-sizing: inherit; +} + +body { + margin: 0; + background: #fafafa; +} diff --git a/runtracker/src/main/resources/static/swagger-initializer.js b/runtracker/src/main/resources/static/swagger-initializer.js new file mode 100644 index 0000000..bdbe451 --- /dev/null +++ b/runtracker/src/main/resources/static/swagger-initializer.js @@ -0,0 +1,20 @@ +window.onload = function() { + // + + // the following lines will be replaced by docker/configurator, when it runs in a docker-container + window.ui = SwaggerUIBundle({ + url: "./swagger-ui/openapi3.yaml", + dom_id: '#swagger-ui', + deepLinking: true, + presets: [ + SwaggerUIBundle.presets.apis, + SwaggerUIStandalonePreset + ], + plugins: [ + SwaggerUIBundle.plugins.DownloadUrl + ], + layout: "StandaloneLayout" + }); + + // +}; diff --git a/runtracker/src/main/resources/static/swagger-ui-bundle.js b/runtracker/src/main/resources/static/swagger-ui-bundle.js new file mode 100644 index 0000000..ded1fe0 --- /dev/null +++ b/runtracker/src/main/resources/static/swagger-ui-bundle.js @@ -0,0 +1,2 @@ +/*! For license information please see swagger-ui-bundle.js.LICENSE.txt */ +!function webpackUniversalModuleDefinition(o,s){"object"==typeof exports&&"object"==typeof module?module.exports=s():"function"==typeof define&&define.amd?define([],s):"object"==typeof exports?exports.SwaggerUIBundle=s():o.SwaggerUIBundle=s()}(this,(()=>(()=>{var o,s,i={69119:(o,s)=>{"use strict";Object.defineProperty(s,"__esModule",{value:!0}),s.BLANK_URL=s.relativeFirstCharacters=s.whitespaceEscapeCharsRegex=s.urlSchemeRegex=s.ctrlCharactersRegex=s.htmlCtrlEntityRegex=s.htmlEntitiesRegex=s.invalidProtocolRegex=void 0,s.invalidProtocolRegex=/^([^\w]*)(javascript|data|vbscript)/im,s.htmlEntitiesRegex=/&#(\w+)(^\w|;)?/g,s.htmlCtrlEntityRegex=/&(newline|tab);/gi,s.ctrlCharactersRegex=/[\u0000-\u001F\u007F-\u009F\u2000-\u200D\uFEFF]/gim,s.urlSchemeRegex=/^.+(:|:)/gim,s.whitespaceEscapeCharsRegex=/(\\|%5[cC])((%(6[eE]|72|74))|[nrt])/g,s.relativeFirstCharacters=[".","/"],s.BLANK_URL="about:blank"},16750:(o,s,i)=>{"use strict";s.J=void 0;var u=i(69119);function decodeURI(o){try{return decodeURIComponent(o)}catch(s){return o}}s.J=function sanitizeUrl(o){if(!o)return u.BLANK_URL;var s,i,_=decodeURI(o);do{s=(_=decodeURI(_=(i=_,i.replace(u.ctrlCharactersRegex,"").replace(u.htmlEntitiesRegex,(function(o,s){return String.fromCharCode(s)}))).replace(u.htmlCtrlEntityRegex,"").replace(u.ctrlCharactersRegex,"").replace(u.whitespaceEscapeCharsRegex,"").trim())).match(u.ctrlCharactersRegex)||_.match(u.htmlEntitiesRegex)||_.match(u.htmlCtrlEntityRegex)||_.match(u.whitespaceEscapeCharsRegex)}while(s&&s.length>0);var w=_;if(!w)return u.BLANK_URL;if(function isRelativeUrlWithoutProtocol(o){return u.relativeFirstCharacters.indexOf(o[0])>-1}(w))return w;var x=w.match(u.urlSchemeRegex);if(!x)return w;var C=x[0];return u.invalidProtocolRegex.test(C)?u.BLANK_URL:w}},67526:(o,s)=>{"use strict";s.byteLength=function byteLength(o){var s=getLens(o),i=s[0],u=s[1];return 3*(i+u)/4-u},s.toByteArray=function toByteArray(o){var s,i,w=getLens(o),x=w[0],C=w[1],j=new _(function _byteLength(o,s,i){return 3*(s+i)/4-i}(0,x,C)),L=0,B=C>0?x-4:x;for(i=0;i>16&255,j[L++]=s>>8&255,j[L++]=255&s;2===C&&(s=u[o.charCodeAt(i)]<<2|u[o.charCodeAt(i+1)]>>4,j[L++]=255&s);1===C&&(s=u[o.charCodeAt(i)]<<10|u[o.charCodeAt(i+1)]<<4|u[o.charCodeAt(i+2)]>>2,j[L++]=s>>8&255,j[L++]=255&s);return j},s.fromByteArray=function fromByteArray(o){for(var s,u=o.length,_=u%3,w=[],x=16383,C=0,j=u-_;Cj?j:C+x));1===_?(s=o[u-1],w.push(i[s>>2]+i[s<<4&63]+"==")):2===_&&(s=(o[u-2]<<8)+o[u-1],w.push(i[s>>10]+i[s>>4&63]+i[s<<2&63]+"="));return w.join("")};for(var i=[],u=[],_="undefined"!=typeof Uint8Array?Uint8Array:Array,w="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",x=0;x<64;++x)i[x]=w[x],u[w.charCodeAt(x)]=x;function getLens(o){var s=o.length;if(s%4>0)throw new Error("Invalid string. Length must be a multiple of 4");var i=o.indexOf("=");return-1===i&&(i=s),[i,i===s?0:4-i%4]}function encodeChunk(o,s,u){for(var _,w,x=[],C=s;C>18&63]+i[w>>12&63]+i[w>>6&63]+i[63&w]);return x.join("")}u["-".charCodeAt(0)]=62,u["_".charCodeAt(0)]=63},48287:(o,s,i)=>{"use strict";const u=i(67526),_=i(251),w="function"==typeof Symbol&&"function"==typeof Symbol.for?Symbol.for("nodejs.util.inspect.custom"):null;s.Buffer=Buffer,s.SlowBuffer=function SlowBuffer(o){+o!=o&&(o=0);return Buffer.alloc(+o)},s.INSPECT_MAX_BYTES=50;const x=2147483647;function createBuffer(o){if(o>x)throw new RangeError('The value "'+o+'" is invalid for option "size"');const s=new Uint8Array(o);return Object.setPrototypeOf(s,Buffer.prototype),s}function Buffer(o,s,i){if("number"==typeof o){if("string"==typeof s)throw new TypeError('The "string" argument must be of type string. Received type number');return allocUnsafe(o)}return from(o,s,i)}function from(o,s,i){if("string"==typeof o)return function fromString(o,s){"string"==typeof s&&""!==s||(s="utf8");if(!Buffer.isEncoding(s))throw new TypeError("Unknown encoding: "+s);const i=0|byteLength(o,s);let u=createBuffer(i);const _=u.write(o,s);_!==i&&(u=u.slice(0,_));return u}(o,s);if(ArrayBuffer.isView(o))return function fromArrayView(o){if(isInstance(o,Uint8Array)){const s=new Uint8Array(o);return fromArrayBuffer(s.buffer,s.byteOffset,s.byteLength)}return fromArrayLike(o)}(o);if(null==o)throw new TypeError("The first argument must be one of type string, Buffer, ArrayBuffer, Array, or Array-like Object. Received type "+typeof o);if(isInstance(o,ArrayBuffer)||o&&isInstance(o.buffer,ArrayBuffer))return fromArrayBuffer(o,s,i);if("undefined"!=typeof SharedArrayBuffer&&(isInstance(o,SharedArrayBuffer)||o&&isInstance(o.buffer,SharedArrayBuffer)))return fromArrayBuffer(o,s,i);if("number"==typeof o)throw new TypeError('The "value" argument must not be of type number. Received type number');const u=o.valueOf&&o.valueOf();if(null!=u&&u!==o)return Buffer.from(u,s,i);const _=function fromObject(o){if(Buffer.isBuffer(o)){const s=0|checked(o.length),i=createBuffer(s);return 0===i.length||o.copy(i,0,0,s),i}if(void 0!==o.length)return"number"!=typeof o.length||numberIsNaN(o.length)?createBuffer(0):fromArrayLike(o);if("Buffer"===o.type&&Array.isArray(o.data))return fromArrayLike(o.data)}(o);if(_)return _;if("undefined"!=typeof Symbol&&null!=Symbol.toPrimitive&&"function"==typeof o[Symbol.toPrimitive])return Buffer.from(o[Symbol.toPrimitive]("string"),s,i);throw new TypeError("The first argument must be one of type string, Buffer, ArrayBuffer, Array, or Array-like Object. Received type "+typeof o)}function assertSize(o){if("number"!=typeof o)throw new TypeError('"size" argument must be of type number');if(o<0)throw new RangeError('The value "'+o+'" is invalid for option "size"')}function allocUnsafe(o){return assertSize(o),createBuffer(o<0?0:0|checked(o))}function fromArrayLike(o){const s=o.length<0?0:0|checked(o.length),i=createBuffer(s);for(let u=0;u=x)throw new RangeError("Attempt to allocate Buffer larger than maximum size: 0x"+x.toString(16)+" bytes");return 0|o}function byteLength(o,s){if(Buffer.isBuffer(o))return o.length;if(ArrayBuffer.isView(o)||isInstance(o,ArrayBuffer))return o.byteLength;if("string"!=typeof o)throw new TypeError('The "string" argument must be one of type string, Buffer, or ArrayBuffer. Received type '+typeof o);const i=o.length,u=arguments.length>2&&!0===arguments[2];if(!u&&0===i)return 0;let _=!1;for(;;)switch(s){case"ascii":case"latin1":case"binary":return i;case"utf8":case"utf-8":return utf8ToBytes(o).length;case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return 2*i;case"hex":return i>>>1;case"base64":return base64ToBytes(o).length;default:if(_)return u?-1:utf8ToBytes(o).length;s=(""+s).toLowerCase(),_=!0}}function slowToString(o,s,i){let u=!1;if((void 0===s||s<0)&&(s=0),s>this.length)return"";if((void 0===i||i>this.length)&&(i=this.length),i<=0)return"";if((i>>>=0)<=(s>>>=0))return"";for(o||(o="utf8");;)switch(o){case"hex":return hexSlice(this,s,i);case"utf8":case"utf-8":return utf8Slice(this,s,i);case"ascii":return asciiSlice(this,s,i);case"latin1":case"binary":return latin1Slice(this,s,i);case"base64":return base64Slice(this,s,i);case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return utf16leSlice(this,s,i);default:if(u)throw new TypeError("Unknown encoding: "+o);o=(o+"").toLowerCase(),u=!0}}function swap(o,s,i){const u=o[s];o[s]=o[i],o[i]=u}function bidirectionalIndexOf(o,s,i,u,_){if(0===o.length)return-1;if("string"==typeof i?(u=i,i=0):i>2147483647?i=2147483647:i<-2147483648&&(i=-2147483648),numberIsNaN(i=+i)&&(i=_?0:o.length-1),i<0&&(i=o.length+i),i>=o.length){if(_)return-1;i=o.length-1}else if(i<0){if(!_)return-1;i=0}if("string"==typeof s&&(s=Buffer.from(s,u)),Buffer.isBuffer(s))return 0===s.length?-1:arrayIndexOf(o,s,i,u,_);if("number"==typeof s)return s&=255,"function"==typeof Uint8Array.prototype.indexOf?_?Uint8Array.prototype.indexOf.call(o,s,i):Uint8Array.prototype.lastIndexOf.call(o,s,i):arrayIndexOf(o,[s],i,u,_);throw new TypeError("val must be string, number or Buffer")}function arrayIndexOf(o,s,i,u,_){let w,x=1,C=o.length,j=s.length;if(void 0!==u&&("ucs2"===(u=String(u).toLowerCase())||"ucs-2"===u||"utf16le"===u||"utf-16le"===u)){if(o.length<2||s.length<2)return-1;x=2,C/=2,j/=2,i/=2}function read(o,s){return 1===x?o[s]:o.readUInt16BE(s*x)}if(_){let u=-1;for(w=i;wC&&(i=C-j),w=i;w>=0;w--){let i=!0;for(let u=0;u_&&(u=_):u=_;const w=s.length;let x;for(u>w/2&&(u=w/2),x=0;x>8,_=i%256,w.push(_),w.push(u);return w}(s,o.length-i),o,i,u)}function base64Slice(o,s,i){return 0===s&&i===o.length?u.fromByteArray(o):u.fromByteArray(o.slice(s,i))}function utf8Slice(o,s,i){i=Math.min(o.length,i);const u=[];let _=s;for(;_239?4:s>223?3:s>191?2:1;if(_+x<=i){let i,u,C,j;switch(x){case 1:s<128&&(w=s);break;case 2:i=o[_+1],128==(192&i)&&(j=(31&s)<<6|63&i,j>127&&(w=j));break;case 3:i=o[_+1],u=o[_+2],128==(192&i)&&128==(192&u)&&(j=(15&s)<<12|(63&i)<<6|63&u,j>2047&&(j<55296||j>57343)&&(w=j));break;case 4:i=o[_+1],u=o[_+2],C=o[_+3],128==(192&i)&&128==(192&u)&&128==(192&C)&&(j=(15&s)<<18|(63&i)<<12|(63&u)<<6|63&C,j>65535&&j<1114112&&(w=j))}}null===w?(w=65533,x=1):w>65535&&(w-=65536,u.push(w>>>10&1023|55296),w=56320|1023&w),u.push(w),_+=x}return function decodeCodePointsArray(o){const s=o.length;if(s<=C)return String.fromCharCode.apply(String,o);let i="",u=0;for(;uu.length?(Buffer.isBuffer(s)||(s=Buffer.from(s)),s.copy(u,_)):Uint8Array.prototype.set.call(u,s,_);else{if(!Buffer.isBuffer(s))throw new TypeError('"list" argument must be an Array of Buffers');s.copy(u,_)}_+=s.length}return u},Buffer.byteLength=byteLength,Buffer.prototype._isBuffer=!0,Buffer.prototype.swap16=function swap16(){const o=this.length;if(o%2!=0)throw new RangeError("Buffer size must be a multiple of 16-bits");for(let s=0;si&&(o+=" ... "),""},w&&(Buffer.prototype[w]=Buffer.prototype.inspect),Buffer.prototype.compare=function compare(o,s,i,u,_){if(isInstance(o,Uint8Array)&&(o=Buffer.from(o,o.offset,o.byteLength)),!Buffer.isBuffer(o))throw new TypeError('The "target" argument must be one of type Buffer or Uint8Array. Received type '+typeof o);if(void 0===s&&(s=0),void 0===i&&(i=o?o.length:0),void 0===u&&(u=0),void 0===_&&(_=this.length),s<0||i>o.length||u<0||_>this.length)throw new RangeError("out of range index");if(u>=_&&s>=i)return 0;if(u>=_)return-1;if(s>=i)return 1;if(this===o)return 0;let w=(_>>>=0)-(u>>>=0),x=(i>>>=0)-(s>>>=0);const C=Math.min(w,x),j=this.slice(u,_),L=o.slice(s,i);for(let o=0;o>>=0,isFinite(i)?(i>>>=0,void 0===u&&(u="utf8")):(u=i,i=void 0)}const _=this.length-s;if((void 0===i||i>_)&&(i=_),o.length>0&&(i<0||s<0)||s>this.length)throw new RangeError("Attempt to write outside buffer bounds");u||(u="utf8");let w=!1;for(;;)switch(u){case"hex":return hexWrite(this,o,s,i);case"utf8":case"utf-8":return utf8Write(this,o,s,i);case"ascii":case"latin1":case"binary":return asciiWrite(this,o,s,i);case"base64":return base64Write(this,o,s,i);case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return ucs2Write(this,o,s,i);default:if(w)throw new TypeError("Unknown encoding: "+u);u=(""+u).toLowerCase(),w=!0}},Buffer.prototype.toJSON=function toJSON(){return{type:"Buffer",data:Array.prototype.slice.call(this._arr||this,0)}};const C=4096;function asciiSlice(o,s,i){let u="";i=Math.min(o.length,i);for(let _=s;_u)&&(i=u);let _="";for(let u=s;ui)throw new RangeError("Trying to access beyond buffer length")}function checkInt(o,s,i,u,_,w){if(!Buffer.isBuffer(o))throw new TypeError('"buffer" argument must be a Buffer instance');if(s>_||so.length)throw new RangeError("Index out of range")}function wrtBigUInt64LE(o,s,i,u,_){checkIntBI(s,u,_,o,i,7);let w=Number(s&BigInt(4294967295));o[i++]=w,w>>=8,o[i++]=w,w>>=8,o[i++]=w,w>>=8,o[i++]=w;let x=Number(s>>BigInt(32)&BigInt(4294967295));return o[i++]=x,x>>=8,o[i++]=x,x>>=8,o[i++]=x,x>>=8,o[i++]=x,i}function wrtBigUInt64BE(o,s,i,u,_){checkIntBI(s,u,_,o,i,7);let w=Number(s&BigInt(4294967295));o[i+7]=w,w>>=8,o[i+6]=w,w>>=8,o[i+5]=w,w>>=8,o[i+4]=w;let x=Number(s>>BigInt(32)&BigInt(4294967295));return o[i+3]=x,x>>=8,o[i+2]=x,x>>=8,o[i+1]=x,x>>=8,o[i]=x,i+8}function checkIEEE754(o,s,i,u,_,w){if(i+u>o.length)throw new RangeError("Index out of range");if(i<0)throw new RangeError("Index out of range")}function writeFloat(o,s,i,u,w){return s=+s,i>>>=0,w||checkIEEE754(o,0,i,4),_.write(o,s,i,u,23,4),i+4}function writeDouble(o,s,i,u,w){return s=+s,i>>>=0,w||checkIEEE754(o,0,i,8),_.write(o,s,i,u,52,8),i+8}Buffer.prototype.slice=function slice(o,s){const i=this.length;(o=~~o)<0?(o+=i)<0&&(o=0):o>i&&(o=i),(s=void 0===s?i:~~s)<0?(s+=i)<0&&(s=0):s>i&&(s=i),s>>=0,s>>>=0,i||checkOffset(o,s,this.length);let u=this[o],_=1,w=0;for(;++w>>=0,s>>>=0,i||checkOffset(o,s,this.length);let u=this[o+--s],_=1;for(;s>0&&(_*=256);)u+=this[o+--s]*_;return u},Buffer.prototype.readUint8=Buffer.prototype.readUInt8=function readUInt8(o,s){return o>>>=0,s||checkOffset(o,1,this.length),this[o]},Buffer.prototype.readUint16LE=Buffer.prototype.readUInt16LE=function readUInt16LE(o,s){return o>>>=0,s||checkOffset(o,2,this.length),this[o]|this[o+1]<<8},Buffer.prototype.readUint16BE=Buffer.prototype.readUInt16BE=function readUInt16BE(o,s){return o>>>=0,s||checkOffset(o,2,this.length),this[o]<<8|this[o+1]},Buffer.prototype.readUint32LE=Buffer.prototype.readUInt32LE=function readUInt32LE(o,s){return o>>>=0,s||checkOffset(o,4,this.length),(this[o]|this[o+1]<<8|this[o+2]<<16)+16777216*this[o+3]},Buffer.prototype.readUint32BE=Buffer.prototype.readUInt32BE=function readUInt32BE(o,s){return o>>>=0,s||checkOffset(o,4,this.length),16777216*this[o]+(this[o+1]<<16|this[o+2]<<8|this[o+3])},Buffer.prototype.readBigUInt64LE=defineBigIntMethod((function readBigUInt64LE(o){validateNumber(o>>>=0,"offset");const s=this[o],i=this[o+7];void 0!==s&&void 0!==i||boundsError(o,this.length-8);const u=s+256*this[++o]+65536*this[++o]+this[++o]*2**24,_=this[++o]+256*this[++o]+65536*this[++o]+i*2**24;return BigInt(u)+(BigInt(_)<>>=0,"offset");const s=this[o],i=this[o+7];void 0!==s&&void 0!==i||boundsError(o,this.length-8);const u=s*2**24+65536*this[++o]+256*this[++o]+this[++o],_=this[++o]*2**24+65536*this[++o]+256*this[++o]+i;return(BigInt(u)<>>=0,s>>>=0,i||checkOffset(o,s,this.length);let u=this[o],_=1,w=0;for(;++w=_&&(u-=Math.pow(2,8*s)),u},Buffer.prototype.readIntBE=function readIntBE(o,s,i){o>>>=0,s>>>=0,i||checkOffset(o,s,this.length);let u=s,_=1,w=this[o+--u];for(;u>0&&(_*=256);)w+=this[o+--u]*_;return _*=128,w>=_&&(w-=Math.pow(2,8*s)),w},Buffer.prototype.readInt8=function readInt8(o,s){return o>>>=0,s||checkOffset(o,1,this.length),128&this[o]?-1*(255-this[o]+1):this[o]},Buffer.prototype.readInt16LE=function readInt16LE(o,s){o>>>=0,s||checkOffset(o,2,this.length);const i=this[o]|this[o+1]<<8;return 32768&i?4294901760|i:i},Buffer.prototype.readInt16BE=function readInt16BE(o,s){o>>>=0,s||checkOffset(o,2,this.length);const i=this[o+1]|this[o]<<8;return 32768&i?4294901760|i:i},Buffer.prototype.readInt32LE=function readInt32LE(o,s){return o>>>=0,s||checkOffset(o,4,this.length),this[o]|this[o+1]<<8|this[o+2]<<16|this[o+3]<<24},Buffer.prototype.readInt32BE=function readInt32BE(o,s){return o>>>=0,s||checkOffset(o,4,this.length),this[o]<<24|this[o+1]<<16|this[o+2]<<8|this[o+3]},Buffer.prototype.readBigInt64LE=defineBigIntMethod((function readBigInt64LE(o){validateNumber(o>>>=0,"offset");const s=this[o],i=this[o+7];void 0!==s&&void 0!==i||boundsError(o,this.length-8);const u=this[o+4]+256*this[o+5]+65536*this[o+6]+(i<<24);return(BigInt(u)<>>=0,"offset");const s=this[o],i=this[o+7];void 0!==s&&void 0!==i||boundsError(o,this.length-8);const u=(s<<24)+65536*this[++o]+256*this[++o]+this[++o];return(BigInt(u)<>>=0,s||checkOffset(o,4,this.length),_.read(this,o,!0,23,4)},Buffer.prototype.readFloatBE=function readFloatBE(o,s){return o>>>=0,s||checkOffset(o,4,this.length),_.read(this,o,!1,23,4)},Buffer.prototype.readDoubleLE=function readDoubleLE(o,s){return o>>>=0,s||checkOffset(o,8,this.length),_.read(this,o,!0,52,8)},Buffer.prototype.readDoubleBE=function readDoubleBE(o,s){return o>>>=0,s||checkOffset(o,8,this.length),_.read(this,o,!1,52,8)},Buffer.prototype.writeUintLE=Buffer.prototype.writeUIntLE=function writeUIntLE(o,s,i,u){if(o=+o,s>>>=0,i>>>=0,!u){checkInt(this,o,s,i,Math.pow(2,8*i)-1,0)}let _=1,w=0;for(this[s]=255&o;++w>>=0,i>>>=0,!u){checkInt(this,o,s,i,Math.pow(2,8*i)-1,0)}let _=i-1,w=1;for(this[s+_]=255&o;--_>=0&&(w*=256);)this[s+_]=o/w&255;return s+i},Buffer.prototype.writeUint8=Buffer.prototype.writeUInt8=function writeUInt8(o,s,i){return o=+o,s>>>=0,i||checkInt(this,o,s,1,255,0),this[s]=255&o,s+1},Buffer.prototype.writeUint16LE=Buffer.prototype.writeUInt16LE=function writeUInt16LE(o,s,i){return o=+o,s>>>=0,i||checkInt(this,o,s,2,65535,0),this[s]=255&o,this[s+1]=o>>>8,s+2},Buffer.prototype.writeUint16BE=Buffer.prototype.writeUInt16BE=function writeUInt16BE(o,s,i){return o=+o,s>>>=0,i||checkInt(this,o,s,2,65535,0),this[s]=o>>>8,this[s+1]=255&o,s+2},Buffer.prototype.writeUint32LE=Buffer.prototype.writeUInt32LE=function writeUInt32LE(o,s,i){return o=+o,s>>>=0,i||checkInt(this,o,s,4,4294967295,0),this[s+3]=o>>>24,this[s+2]=o>>>16,this[s+1]=o>>>8,this[s]=255&o,s+4},Buffer.prototype.writeUint32BE=Buffer.prototype.writeUInt32BE=function writeUInt32BE(o,s,i){return o=+o,s>>>=0,i||checkInt(this,o,s,4,4294967295,0),this[s]=o>>>24,this[s+1]=o>>>16,this[s+2]=o>>>8,this[s+3]=255&o,s+4},Buffer.prototype.writeBigUInt64LE=defineBigIntMethod((function writeBigUInt64LE(o,s=0){return wrtBigUInt64LE(this,o,s,BigInt(0),BigInt("0xffffffffffffffff"))})),Buffer.prototype.writeBigUInt64BE=defineBigIntMethod((function writeBigUInt64BE(o,s=0){return wrtBigUInt64BE(this,o,s,BigInt(0),BigInt("0xffffffffffffffff"))})),Buffer.prototype.writeIntLE=function writeIntLE(o,s,i,u){if(o=+o,s>>>=0,!u){const u=Math.pow(2,8*i-1);checkInt(this,o,s,i,u-1,-u)}let _=0,w=1,x=0;for(this[s]=255&o;++_>>=0,!u){const u=Math.pow(2,8*i-1);checkInt(this,o,s,i,u-1,-u)}let _=i-1,w=1,x=0;for(this[s+_]=255&o;--_>=0&&(w*=256);)o<0&&0===x&&0!==this[s+_+1]&&(x=1),this[s+_]=(o/w|0)-x&255;return s+i},Buffer.prototype.writeInt8=function writeInt8(o,s,i){return o=+o,s>>>=0,i||checkInt(this,o,s,1,127,-128),o<0&&(o=255+o+1),this[s]=255&o,s+1},Buffer.prototype.writeInt16LE=function writeInt16LE(o,s,i){return o=+o,s>>>=0,i||checkInt(this,o,s,2,32767,-32768),this[s]=255&o,this[s+1]=o>>>8,s+2},Buffer.prototype.writeInt16BE=function writeInt16BE(o,s,i){return o=+o,s>>>=0,i||checkInt(this,o,s,2,32767,-32768),this[s]=o>>>8,this[s+1]=255&o,s+2},Buffer.prototype.writeInt32LE=function writeInt32LE(o,s,i){return o=+o,s>>>=0,i||checkInt(this,o,s,4,2147483647,-2147483648),this[s]=255&o,this[s+1]=o>>>8,this[s+2]=o>>>16,this[s+3]=o>>>24,s+4},Buffer.prototype.writeInt32BE=function writeInt32BE(o,s,i){return o=+o,s>>>=0,i||checkInt(this,o,s,4,2147483647,-2147483648),o<0&&(o=4294967295+o+1),this[s]=o>>>24,this[s+1]=o>>>16,this[s+2]=o>>>8,this[s+3]=255&o,s+4},Buffer.prototype.writeBigInt64LE=defineBigIntMethod((function writeBigInt64LE(o,s=0){return wrtBigUInt64LE(this,o,s,-BigInt("0x8000000000000000"),BigInt("0x7fffffffffffffff"))})),Buffer.prototype.writeBigInt64BE=defineBigIntMethod((function writeBigInt64BE(o,s=0){return wrtBigUInt64BE(this,o,s,-BigInt("0x8000000000000000"),BigInt("0x7fffffffffffffff"))})),Buffer.prototype.writeFloatLE=function writeFloatLE(o,s,i){return writeFloat(this,o,s,!0,i)},Buffer.prototype.writeFloatBE=function writeFloatBE(o,s,i){return writeFloat(this,o,s,!1,i)},Buffer.prototype.writeDoubleLE=function writeDoubleLE(o,s,i){return writeDouble(this,o,s,!0,i)},Buffer.prototype.writeDoubleBE=function writeDoubleBE(o,s,i){return writeDouble(this,o,s,!1,i)},Buffer.prototype.copy=function copy(o,s,i,u){if(!Buffer.isBuffer(o))throw new TypeError("argument should be a Buffer");if(i||(i=0),u||0===u||(u=this.length),s>=o.length&&(s=o.length),s||(s=0),u>0&&u=this.length)throw new RangeError("Index out of range");if(u<0)throw new RangeError("sourceEnd out of bounds");u>this.length&&(u=this.length),o.length-s>>=0,i=void 0===i?this.length:i>>>0,o||(o=0),"number"==typeof o)for(_=s;_=u+4;i-=3)s=`_${o.slice(i-3,i)}${s}`;return`${o.slice(0,i)}${s}`}function checkIntBI(o,s,i,u,_,w){if(o>i||o3?0===s||s===BigInt(0)?`>= 0${u} and < 2${u} ** ${8*(w+1)}${u}`:`>= -(2${u} ** ${8*(w+1)-1}${u}) and < 2 ** ${8*(w+1)-1}${u}`:`>= ${s}${u} and <= ${i}${u}`,new j.ERR_OUT_OF_RANGE("value",_,o)}!function checkBounds(o,s,i){validateNumber(s,"offset"),void 0!==o[s]&&void 0!==o[s+i]||boundsError(s,o.length-(i+1))}(u,_,w)}function validateNumber(o,s){if("number"!=typeof o)throw new j.ERR_INVALID_ARG_TYPE(s,"number",o)}function boundsError(o,s,i){if(Math.floor(o)!==o)throw validateNumber(o,i),new j.ERR_OUT_OF_RANGE(i||"offset","an integer",o);if(s<0)throw new j.ERR_BUFFER_OUT_OF_BOUNDS;throw new j.ERR_OUT_OF_RANGE(i||"offset",`>= ${i?1:0} and <= ${s}`,o)}E("ERR_BUFFER_OUT_OF_BOUNDS",(function(o){return o?`${o} is outside of buffer bounds`:"Attempt to access memory outside buffer bounds"}),RangeError),E("ERR_INVALID_ARG_TYPE",(function(o,s){return`The "${o}" argument must be of type number. Received type ${typeof s}`}),TypeError),E("ERR_OUT_OF_RANGE",(function(o,s,i){let u=`The value of "${o}" is out of range.`,_=i;return Number.isInteger(i)&&Math.abs(i)>2**32?_=addNumericalSeparator(String(i)):"bigint"==typeof i&&(_=String(i),(i>BigInt(2)**BigInt(32)||i<-(BigInt(2)**BigInt(32)))&&(_=addNumericalSeparator(_)),_+="n"),u+=` It must be ${s}. Received ${_}`,u}),RangeError);const L=/[^+/0-9A-Za-z-_]/g;function utf8ToBytes(o,s){let i;s=s||1/0;const u=o.length;let _=null;const w=[];for(let x=0;x55295&&i<57344){if(!_){if(i>56319){(s-=3)>-1&&w.push(239,191,189);continue}if(x+1===u){(s-=3)>-1&&w.push(239,191,189);continue}_=i;continue}if(i<56320){(s-=3)>-1&&w.push(239,191,189),_=i;continue}i=65536+(_-55296<<10|i-56320)}else _&&(s-=3)>-1&&w.push(239,191,189);if(_=null,i<128){if((s-=1)<0)break;w.push(i)}else if(i<2048){if((s-=2)<0)break;w.push(i>>6|192,63&i|128)}else if(i<65536){if((s-=3)<0)break;w.push(i>>12|224,i>>6&63|128,63&i|128)}else{if(!(i<1114112))throw new Error("Invalid code point");if((s-=4)<0)break;w.push(i>>18|240,i>>12&63|128,i>>6&63|128,63&i|128)}}return w}function base64ToBytes(o){return u.toByteArray(function base64clean(o){if((o=(o=o.split("=")[0]).trim().replace(L,"")).length<2)return"";for(;o.length%4!=0;)o+="=";return o}(o))}function blitBuffer(o,s,i,u){let _;for(_=0;_=s.length||_>=o.length);++_)s[_+i]=o[_];return _}function isInstance(o,s){return o instanceof s||null!=o&&null!=o.constructor&&null!=o.constructor.name&&o.constructor.name===s.name}function numberIsNaN(o){return o!=o}const B=function(){const o="0123456789abcdef",s=new Array(256);for(let i=0;i<16;++i){const u=16*i;for(let _=0;_<16;++_)s[u+_]=o[i]+o[_]}return s}();function defineBigIntMethod(o){return"undefined"==typeof BigInt?BufferBigIntNotDefined:o}function BufferBigIntNotDefined(){throw new Error("BigInt not supported")}},38075:(o,s,i)=>{"use strict";var u=i(70453),_=i(10487),w=_(u("String.prototype.indexOf"));o.exports=function callBoundIntrinsic(o,s){var i=u(o,!!s);return"function"==typeof i&&w(o,".prototype.")>-1?_(i):i}},10487:(o,s,i)=>{"use strict";var u=i(66743),_=i(70453),w=i(96897),x=i(69675),C=_("%Function.prototype.apply%"),j=_("%Function.prototype.call%"),L=_("%Reflect.apply%",!0)||u.call(j,C),B=i(30655),$=_("%Math.max%");o.exports=function callBind(o){if("function"!=typeof o)throw new x("a function is required");var s=L(u,j,arguments);return w(s,1+$(0,o.length-(arguments.length-1)),!0)};var V=function applyBind(){return L(u,C,arguments)};B?B(o.exports,"apply",{value:V}):o.exports.apply=V},57427:(o,s)=>{"use strict";s.parse=function parse(o,s){if("string"!=typeof o)throw new TypeError("argument str must be a string");var i={},u=(s||{}).decode||decode,_=0;for(;_{"use strict";var u=i(16426),_={"text/plain":"Text","text/html":"Url",default:"Text"};o.exports=function copy(o,s){var i,w,x,C,j,L,B=!1;s||(s={}),i=s.debug||!1;try{if(x=u(),C=document.createRange(),j=document.getSelection(),(L=document.createElement("span")).textContent=o,L.ariaHidden="true",L.style.all="unset",L.style.position="fixed",L.style.top=0,L.style.clip="rect(0, 0, 0, 0)",L.style.whiteSpace="pre",L.style.webkitUserSelect="text",L.style.MozUserSelect="text",L.style.msUserSelect="text",L.style.userSelect="text",L.addEventListener("copy",(function(u){if(u.stopPropagation(),s.format)if(u.preventDefault(),void 0===u.clipboardData){i&&console.warn("unable to use e.clipboardData"),i&&console.warn("trying IE specific stuff"),window.clipboardData.clearData();var w=_[s.format]||_.default;window.clipboardData.setData(w,o)}else u.clipboardData.clearData(),u.clipboardData.setData(s.format,o);s.onCopy&&(u.preventDefault(),s.onCopy(u.clipboardData))})),document.body.appendChild(L),C.selectNodeContents(L),j.addRange(C),!document.execCommand("copy"))throw new Error("copy command was unsuccessful");B=!0}catch(u){i&&console.error("unable to copy using execCommand: ",u),i&&console.warn("trying IE specific stuff");try{window.clipboardData.setData(s.format||"text",o),s.onCopy&&s.onCopy(window.clipboardData),B=!0}catch(u){i&&console.error("unable to copy using clipboardData: ",u),i&&console.error("falling back to prompt"),w=function format(o){var s=(/mac os x/i.test(navigator.userAgent)?"⌘":"Ctrl")+"+C";return o.replace(/#{\s*key\s*}/g,s)}("message"in s?s.message:"Copy to clipboard: #{key}, Enter"),window.prompt(w,o)}}finally{j&&("function"==typeof j.removeRange?j.removeRange(C):j.removeAllRanges()),L&&document.body.removeChild(L),x()}return B}},2205:function(o,s,i){var u;u=void 0!==i.g?i.g:this,o.exports=function(o){if(o.CSS&&o.CSS.escape)return o.CSS.escape;var cssEscape=function(o){if(0==arguments.length)throw new TypeError("`CSS.escape` requires an argument.");for(var s,i=String(o),u=i.length,_=-1,w="",x=i.charCodeAt(0);++_=1&&s<=31||127==s||0==_&&s>=48&&s<=57||1==_&&s>=48&&s<=57&&45==x?"\\"+s.toString(16)+" ":0==_&&1==u&&45==s||!(s>=128||45==s||95==s||s>=48&&s<=57||s>=65&&s<=90||s>=97&&s<=122)?"\\"+i.charAt(_):i.charAt(_):w+="�";return w};return o.CSS||(o.CSS={}),o.CSS.escape=cssEscape,cssEscape}(u)},81919:(o,s,i)=>{"use strict";var u=i(48287).Buffer;function isSpecificValue(o){return o instanceof u||o instanceof Date||o instanceof RegExp}function cloneSpecificValue(o){if(o instanceof u){var s=u.alloc?u.alloc(o.length):new u(o.length);return o.copy(s),s}if(o instanceof Date)return new Date(o.getTime());if(o instanceof RegExp)return new RegExp(o);throw new Error("Unexpected situation")}function deepCloneArray(o){var s=[];return o.forEach((function(o,i){"object"==typeof o&&null!==o?Array.isArray(o)?s[i]=deepCloneArray(o):isSpecificValue(o)?s[i]=cloneSpecificValue(o):s[i]=_({},o):s[i]=o})),s}function safeGetProperty(o,s){return"__proto__"===s?void 0:o[s]}var _=o.exports=function(){if(arguments.length<1||"object"!=typeof arguments[0])return!1;if(arguments.length<2)return arguments[0];var o,s,i=arguments[0];return Array.prototype.slice.call(arguments,1).forEach((function(u){"object"!=typeof u||null===u||Array.isArray(u)||Object.keys(u).forEach((function(w){return s=safeGetProperty(i,w),(o=safeGetProperty(u,w))===i?void 0:"object"!=typeof o||null===o?void(i[w]=o):Array.isArray(o)?void(i[w]=deepCloneArray(o)):isSpecificValue(o)?void(i[w]=cloneSpecificValue(o)):"object"!=typeof s||null===s||Array.isArray(s)?void(i[w]=_({},o)):void(i[w]=_(s,o))}))})),i}},14744:o=>{"use strict";var s=function isMergeableObject(o){return function isNonNullObject(o){return!!o&&"object"==typeof o}(o)&&!function isSpecial(o){var s=Object.prototype.toString.call(o);return"[object RegExp]"===s||"[object Date]"===s||function isReactElement(o){return o.$$typeof===i}(o)}(o)};var i="function"==typeof Symbol&&Symbol.for?Symbol.for("react.element"):60103;function cloneUnlessOtherwiseSpecified(o,s){return!1!==s.clone&&s.isMergeableObject(o)?deepmerge(function emptyTarget(o){return Array.isArray(o)?[]:{}}(o),o,s):o}function defaultArrayMerge(o,s,i){return o.concat(s).map((function(o){return cloneUnlessOtherwiseSpecified(o,i)}))}function getKeys(o){return Object.keys(o).concat(function getEnumerableOwnPropertySymbols(o){return Object.getOwnPropertySymbols?Object.getOwnPropertySymbols(o).filter((function(s){return Object.propertyIsEnumerable.call(o,s)})):[]}(o))}function propertyIsOnObject(o,s){try{return s in o}catch(o){return!1}}function mergeObject(o,s,i){var u={};return i.isMergeableObject(o)&&getKeys(o).forEach((function(s){u[s]=cloneUnlessOtherwiseSpecified(o[s],i)})),getKeys(s).forEach((function(_){(function propertyIsUnsafe(o,s){return propertyIsOnObject(o,s)&&!(Object.hasOwnProperty.call(o,s)&&Object.propertyIsEnumerable.call(o,s))})(o,_)||(propertyIsOnObject(o,_)&&i.isMergeableObject(s[_])?u[_]=function getMergeFunction(o,s){if(!s.customMerge)return deepmerge;var i=s.customMerge(o);return"function"==typeof i?i:deepmerge}(_,i)(o[_],s[_],i):u[_]=cloneUnlessOtherwiseSpecified(s[_],i))})),u}function deepmerge(o,i,u){(u=u||{}).arrayMerge=u.arrayMerge||defaultArrayMerge,u.isMergeableObject=u.isMergeableObject||s,u.cloneUnlessOtherwiseSpecified=cloneUnlessOtherwiseSpecified;var _=Array.isArray(i);return _===Array.isArray(o)?_?u.arrayMerge(o,i,u):mergeObject(o,i,u):cloneUnlessOtherwiseSpecified(i,u)}deepmerge.all=function deepmergeAll(o,s){if(!Array.isArray(o))throw new Error("first argument should be an array");return o.reduce((function(o,i){return deepmerge(o,i,s)}),{})};var u=deepmerge;o.exports=u},30041:(o,s,i)=>{"use strict";var u=i(30655),_=i(58068),w=i(69675),x=i(75795);o.exports=function defineDataProperty(o,s,i){if(!o||"object"!=typeof o&&"function"!=typeof o)throw new w("`obj` must be an object or a function`");if("string"!=typeof s&&"symbol"!=typeof s)throw new w("`property` must be a string or a symbol`");if(arguments.length>3&&"boolean"!=typeof arguments[3]&&null!==arguments[3])throw new w("`nonEnumerable`, if provided, must be a boolean or null");if(arguments.length>4&&"boolean"!=typeof arguments[4]&&null!==arguments[4])throw new w("`nonWritable`, if provided, must be a boolean or null");if(arguments.length>5&&"boolean"!=typeof arguments[5]&&null!==arguments[5])throw new w("`nonConfigurable`, if provided, must be a boolean or null");if(arguments.length>6&&"boolean"!=typeof arguments[6])throw new w("`loose`, if provided, must be a boolean");var C=arguments.length>3?arguments[3]:null,j=arguments.length>4?arguments[4]:null,L=arguments.length>5?arguments[5]:null,B=arguments.length>6&&arguments[6],$=!!x&&x(o,s);if(u)u(o,s,{configurable:null===L&&$?$.configurable:!L,enumerable:null===C&&$?$.enumerable:!C,value:i,writable:null===j&&$?$.writable:!j});else{if(!B&&(C||j||L))throw new _("This environment does not support defining a property as non-configurable, non-writable, or non-enumerable.");o[s]=i}}},42838:function(o){o.exports=function(){"use strict";const{entries:o,setPrototypeOf:s,isFrozen:i,getPrototypeOf:u,getOwnPropertyDescriptor:_}=Object;let{freeze:w,seal:x,create:C}=Object,{apply:j,construct:L}="undefined"!=typeof Reflect&&Reflect;w||(w=function freeze(o){return o}),x||(x=function seal(o){return o}),j||(j=function apply(o,s,i){return o.apply(s,i)}),L||(L=function construct(o,s){return new o(...s)});const B=unapply(Array.prototype.forEach),$=unapply(Array.prototype.pop),V=unapply(Array.prototype.push),U=unapply(String.prototype.toLowerCase),z=unapply(String.prototype.toString),Y=unapply(String.prototype.match),Z=unapply(String.prototype.replace),ee=unapply(String.prototype.indexOf),ie=unapply(String.prototype.trim),ae=unapply(Object.prototype.hasOwnProperty),ce=unapply(RegExp.prototype.test),le=unconstruct(TypeError);function numberIsNaN(o){return"number"==typeof o&&isNaN(o)}function unapply(o){return function(s){for(var i=arguments.length,u=new Array(i>1?i-1:0),_=1;_2&&void 0!==arguments[2]?arguments[2]:U;s&&s(o,null);let w=u.length;for(;w--;){let s=u[w];if("string"==typeof s){const o=_(s);o!==s&&(i(u)||(u[w]=o),s=o)}o[s]=!0}return o}function cleanArray(o){for(let s=0;s/gm),$e=x(/\${[\w\W]*}/gm),ze=x(/^data-[\-\w.\u00B7-\uFFFF]/),We=x(/^aria-[\-\w]+$/),He=x(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i),Ye=x(/^(?:\w+script|data):/i),Xe=x(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g),Qe=x(/^html$/i),et=x(/^[a-z][.\w]*(-[.\w]+)+$/i);var tt=Object.freeze({__proto__:null,MUSTACHE_EXPR:Re,ERB_EXPR:qe,TMPLIT_EXPR:$e,DATA_ATTR:ze,ARIA_ATTR:We,IS_ALLOWED_URI:He,IS_SCRIPT_OR_DATA:Ye,ATTR_WHITESPACE:Xe,DOCTYPE_NAME:Qe,CUSTOM_ELEMENT:et});const rt={element:1,attribute:2,text:3,cdataSection:4,entityReference:5,entityNode:6,progressingInstruction:7,comment:8,document:9,documentType:10,documentFragment:11,notation:12},nt=function getGlobal(){return"undefined"==typeof window?null:window},ot=function _createTrustedTypesPolicy(o,s){if("object"!=typeof o||"function"!=typeof o.createPolicy)return null;let i=null;const u="data-tt-policy-suffix";s&&s.hasAttribute(u)&&(i=s.getAttribute(u));const _="dompurify"+(i?"#"+i:"");try{return o.createPolicy(_,{createHTML:o=>o,createScriptURL:o=>o})}catch(o){return console.warn("TrustedTypes policy "+_+" could not be created."),null}};function createDOMPurify(){let s=arguments.length>0&&void 0!==arguments[0]?arguments[0]:nt();const DOMPurify=o=>createDOMPurify(o);if(DOMPurify.version="3.1.4",DOMPurify.removed=[],!s||!s.document||s.document.nodeType!==rt.document)return DOMPurify.isSupported=!1,DOMPurify;let{document:i}=s;const u=i,_=u.currentScript,{DocumentFragment:x,HTMLTemplateElement:j,Node:L,Element:Re,NodeFilter:qe,NamedNodeMap:$e=s.NamedNodeMap||s.MozNamedAttrMap,HTMLFormElement:ze,DOMParser:We,trustedTypes:Ye}=s,Xe=Re.prototype,et=lookupGetter(Xe,"cloneNode"),st=lookupGetter(Xe,"nextSibling"),it=lookupGetter(Xe,"childNodes"),at=lookupGetter(Xe,"parentNode");if("function"==typeof j){const o=i.createElement("template");o.content&&o.content.ownerDocument&&(i=o.content.ownerDocument)}let ct,lt="";const{implementation:ut,createNodeIterator:pt,createDocumentFragment:ht,getElementsByTagName:dt}=i,{importNode:mt}=u;let gt={};DOMPurify.isSupported="function"==typeof o&&"function"==typeof at&&ut&&void 0!==ut.createHTMLDocument;const{MUSTACHE_EXPR:yt,ERB_EXPR:vt,TMPLIT_EXPR:bt,DATA_ATTR:_t,ARIA_ATTR:Et,IS_SCRIPT_OR_DATA:wt,ATTR_WHITESPACE:St,CUSTOM_ELEMENT:xt}=tt;let{IS_ALLOWED_URI:kt}=tt,Ot=null;const Ct=addToSet({},[...pe,...de,...fe,...be,...we]);let At=null;const jt=addToSet({},[...Se,...xe,...Pe,...Te]);let Pt=Object.seal(C(null,{tagNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},allowCustomizedBuiltInElements:{writable:!0,configurable:!1,enumerable:!0,value:!1}})),It=null,Mt=null,Nt=!0,Tt=!0,Rt=!1,Dt=!0,Lt=!1,Bt=!0,Ft=!1,qt=!1,$t=!1,Vt=!1,Ut=!1,zt=!1,Wt=!0,Kt=!1;const Ht="user-content-";let Jt=!0,Gt=!1,Yt={},Xt=null;const Qt=addToSet({},["annotation-xml","audio","colgroup","desc","foreignobject","head","iframe","math","mi","mn","mo","ms","mtext","noembed","noframes","noscript","plaintext","script","style","svg","template","thead","title","video","xmp"]);let Zt=null;const er=addToSet({},["audio","video","img","source","image","track"]);let tr=null;const rr=addToSet({},["alt","class","for","id","label","name","pattern","placeholder","role","summary","title","value","style","xmlns"]),nr="http://www.w3.org/1998/Math/MathML",sr="http://www.w3.org/2000/svg",ir="http://www.w3.org/1999/xhtml";let ar=ir,cr=!1,lr=null;const ur=addToSet({},[nr,sr,ir],z);let pr=null;const dr=["application/xhtml+xml","text/html"],fr="text/html";let mr=null,gr=null;const yr=255,vr=i.createElement("form"),br=function isRegexOrFunction(o){return o instanceof RegExp||o instanceof Function},_r=function _parseConfig(){let o=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};if(!gr||gr!==o){if(o&&"object"==typeof o||(o={}),o=clone(o),pr=-1===dr.indexOf(o.PARSER_MEDIA_TYPE)?fr:o.PARSER_MEDIA_TYPE,mr="application/xhtml+xml"===pr?z:U,Ot=ae(o,"ALLOWED_TAGS")?addToSet({},o.ALLOWED_TAGS,mr):Ct,At=ae(o,"ALLOWED_ATTR")?addToSet({},o.ALLOWED_ATTR,mr):jt,lr=ae(o,"ALLOWED_NAMESPACES")?addToSet({},o.ALLOWED_NAMESPACES,z):ur,tr=ae(o,"ADD_URI_SAFE_ATTR")?addToSet(clone(rr),o.ADD_URI_SAFE_ATTR,mr):rr,Zt=ae(o,"ADD_DATA_URI_TAGS")?addToSet(clone(er),o.ADD_DATA_URI_TAGS,mr):er,Xt=ae(o,"FORBID_CONTENTS")?addToSet({},o.FORBID_CONTENTS,mr):Qt,It=ae(o,"FORBID_TAGS")?addToSet({},o.FORBID_TAGS,mr):{},Mt=ae(o,"FORBID_ATTR")?addToSet({},o.FORBID_ATTR,mr):{},Yt=!!ae(o,"USE_PROFILES")&&o.USE_PROFILES,Nt=!1!==o.ALLOW_ARIA_ATTR,Tt=!1!==o.ALLOW_DATA_ATTR,Rt=o.ALLOW_UNKNOWN_PROTOCOLS||!1,Dt=!1!==o.ALLOW_SELF_CLOSE_IN_ATTR,Lt=o.SAFE_FOR_TEMPLATES||!1,Bt=!1!==o.SAFE_FOR_XML,Ft=o.WHOLE_DOCUMENT||!1,Vt=o.RETURN_DOM||!1,Ut=o.RETURN_DOM_FRAGMENT||!1,zt=o.RETURN_TRUSTED_TYPE||!1,$t=o.FORCE_BODY||!1,Wt=!1!==o.SANITIZE_DOM,Kt=o.SANITIZE_NAMED_PROPS||!1,Jt=!1!==o.KEEP_CONTENT,Gt=o.IN_PLACE||!1,kt=o.ALLOWED_URI_REGEXP||He,ar=o.NAMESPACE||ir,Pt=o.CUSTOM_ELEMENT_HANDLING||{},o.CUSTOM_ELEMENT_HANDLING&&br(o.CUSTOM_ELEMENT_HANDLING.tagNameCheck)&&(Pt.tagNameCheck=o.CUSTOM_ELEMENT_HANDLING.tagNameCheck),o.CUSTOM_ELEMENT_HANDLING&&br(o.CUSTOM_ELEMENT_HANDLING.attributeNameCheck)&&(Pt.attributeNameCheck=o.CUSTOM_ELEMENT_HANDLING.attributeNameCheck),o.CUSTOM_ELEMENT_HANDLING&&"boolean"==typeof o.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements&&(Pt.allowCustomizedBuiltInElements=o.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements),Lt&&(Tt=!1),Ut&&(Vt=!0),Yt&&(Ot=addToSet({},we),At=[],!0===Yt.html&&(addToSet(Ot,pe),addToSet(At,Se)),!0===Yt.svg&&(addToSet(Ot,de),addToSet(At,xe),addToSet(At,Te)),!0===Yt.svgFilters&&(addToSet(Ot,fe),addToSet(At,xe),addToSet(At,Te)),!0===Yt.mathMl&&(addToSet(Ot,be),addToSet(At,Pe),addToSet(At,Te))),o.ADD_TAGS&&(Ot===Ct&&(Ot=clone(Ot)),addToSet(Ot,o.ADD_TAGS,mr)),o.ADD_ATTR&&(At===jt&&(At=clone(At)),addToSet(At,o.ADD_ATTR,mr)),o.ADD_URI_SAFE_ATTR&&addToSet(tr,o.ADD_URI_SAFE_ATTR,mr),o.FORBID_CONTENTS&&(Xt===Qt&&(Xt=clone(Xt)),addToSet(Xt,o.FORBID_CONTENTS,mr)),Jt&&(Ot["#text"]=!0),Ft&&addToSet(Ot,["html","head","body"]),Ot.table&&(addToSet(Ot,["tbody"]),delete It.tbody),o.TRUSTED_TYPES_POLICY){if("function"!=typeof o.TRUSTED_TYPES_POLICY.createHTML)throw le('TRUSTED_TYPES_POLICY configuration option must provide a "createHTML" hook.');if("function"!=typeof o.TRUSTED_TYPES_POLICY.createScriptURL)throw le('TRUSTED_TYPES_POLICY configuration option must provide a "createScriptURL" hook.');ct=o.TRUSTED_TYPES_POLICY,lt=ct.createHTML("")}else void 0===ct&&(ct=ot(Ye,_)),null!==ct&&"string"==typeof lt&&(lt=ct.createHTML(""));w&&w(o),gr=o}},Er=addToSet({},["mi","mo","mn","ms","mtext"]),wr=addToSet({},["foreignobject","annotation-xml"]),Sr=addToSet({},["title","style","font","a","script"]),xr=addToSet({},[...de,...fe,...ye]),kr=addToSet({},[...be,..._e]),Or=function _checkValidNamespace(o){let s=at(o);s&&s.tagName||(s={namespaceURI:ar,tagName:"template"});const i=U(o.tagName),u=U(s.tagName);return!!lr[o.namespaceURI]&&(o.namespaceURI===sr?s.namespaceURI===ir?"svg"===i:s.namespaceURI===nr?"svg"===i&&("annotation-xml"===u||Er[u]):Boolean(xr[i]):o.namespaceURI===nr?s.namespaceURI===ir?"math"===i:s.namespaceURI===sr?"math"===i&&wr[u]:Boolean(kr[i]):o.namespaceURI===ir?!(s.namespaceURI===sr&&!wr[u])&&!(s.namespaceURI===nr&&!Er[u])&&!kr[i]&&(Sr[i]||!xr[i]):!("application/xhtml+xml"!==pr||!lr[o.namespaceURI]))},Cr=function _forceRemove(o){V(DOMPurify.removed,{element:o});try{o.parentNode.removeChild(o)}catch(s){o.remove()}},Ar=function _removeAttribute(o,s){try{V(DOMPurify.removed,{attribute:s.getAttributeNode(o),from:s})}catch(o){V(DOMPurify.removed,{attribute:null,from:s})}if(s.removeAttribute(o),"is"===o&&!At[o])if(Vt||Ut)try{Cr(s)}catch(o){}else try{s.setAttribute(o,"")}catch(o){}},jr=function _initDocument(o){let s=null,u=null;if($t)o=""+o;else{const s=Y(o,/^[\r\n\t ]+/);u=s&&s[0]}"application/xhtml+xml"===pr&&ar===ir&&(o=''+o+"");const _=ct?ct.createHTML(o):o;if(ar===ir)try{s=(new We).parseFromString(_,pr)}catch(o){}if(!s||!s.documentElement){s=ut.createDocument(ar,"template",null);try{s.documentElement.innerHTML=cr?lt:_}catch(o){}}const w=s.body||s.documentElement;return o&&u&&w.insertBefore(i.createTextNode(u),w.childNodes[0]||null),ar===ir?dt.call(s,Ft?"html":"body")[0]:Ft?s.documentElement:w},Pr=function _createNodeIterator(o){return pt.call(o.ownerDocument||o,o,qe.SHOW_ELEMENT|qe.SHOW_COMMENT|qe.SHOW_TEXT|qe.SHOW_PROCESSING_INSTRUCTION|qe.SHOW_CDATA_SECTION,null)},Ir=function _isClobbered(o){return o instanceof ze&&(void 0!==o.__depth&&"number"!=typeof o.__depth||void 0!==o.__removalCount&&"number"!=typeof o.__removalCount||"string"!=typeof o.nodeName||"string"!=typeof o.textContent||"function"!=typeof o.removeChild||!(o.attributes instanceof $e)||"function"!=typeof o.removeAttribute||"function"!=typeof o.setAttribute||"string"!=typeof o.namespaceURI||"function"!=typeof o.insertBefore||"function"!=typeof o.hasChildNodes)},Mr=function _isNode(o){return"function"==typeof L&&o instanceof L},Nr=function _executeHook(o,s,i){gt[o]&&B(gt[o],(o=>{o.call(DOMPurify,s,i,gr)}))},Tr=function _sanitizeElements(o){let s=null;if(Nr("beforeSanitizeElements",o,null),Ir(o))return Cr(o),!0;const i=mr(o.nodeName);if(Nr("uponSanitizeElement",o,{tagName:i,allowedTags:Ot}),o.hasChildNodes()&&!Mr(o.firstElementChild)&&ce(/<[/\w]/g,o.innerHTML)&&ce(/<[/\w]/g,o.textContent))return Cr(o),!0;if(o.nodeType===rt.progressingInstruction)return Cr(o),!0;if(Bt&&o.nodeType===rt.comment&&ce(/<[/\w]/g,o.data))return Cr(o),!0;if(!Ot[i]||It[i]){if(!It[i]&&Dr(i)){if(Pt.tagNameCheck instanceof RegExp&&ce(Pt.tagNameCheck,i))return!1;if(Pt.tagNameCheck instanceof Function&&Pt.tagNameCheck(i))return!1}if(Jt&&!Xt[i]){const s=at(o)||o.parentNode,i=it(o)||o.childNodes;if(i&&s)for(let u=i.length-1;u>=0;--u){const _=et(i[u],!0);_.__removalCount=(o.__removalCount||0)+1,s.insertBefore(_,st(o))}}return Cr(o),!0}return o instanceof Re&&!Or(o)?(Cr(o),!0):"noscript"!==i&&"noembed"!==i&&"noframes"!==i||!ce(/<\/no(script|embed|frames)/i,o.innerHTML)?(Lt&&o.nodeType===rt.text&&(s=o.textContent,B([yt,vt,bt],(o=>{s=Z(s,o," ")})),o.textContent!==s&&(V(DOMPurify.removed,{element:o.cloneNode()}),o.textContent=s)),Nr("afterSanitizeElements",o,null),!1):(Cr(o),!0)},Rr=function _isValidAttribute(o,s,u){if(Wt&&("id"===s||"name"===s)&&(u in i||u in vr||"__depth"===u||"__removalCount"===u))return!1;if(Tt&&!Mt[s]&&ce(_t,s));else if(Nt&&ce(Et,s));else if(!At[s]||Mt[s]){if(!(Dr(o)&&(Pt.tagNameCheck instanceof RegExp&&ce(Pt.tagNameCheck,o)||Pt.tagNameCheck instanceof Function&&Pt.tagNameCheck(o))&&(Pt.attributeNameCheck instanceof RegExp&&ce(Pt.attributeNameCheck,s)||Pt.attributeNameCheck instanceof Function&&Pt.attributeNameCheck(s))||"is"===s&&Pt.allowCustomizedBuiltInElements&&(Pt.tagNameCheck instanceof RegExp&&ce(Pt.tagNameCheck,u)||Pt.tagNameCheck instanceof Function&&Pt.tagNameCheck(u))))return!1}else if(tr[s]);else if(ce(kt,Z(u,St,"")));else if("src"!==s&&"xlink:href"!==s&&"href"!==s||"script"===o||0!==ee(u,"data:")||!Zt[o])if(Rt&&!ce(wt,Z(u,St,"")));else if(u)return!1;return!0},Dr=function _isBasicCustomElement(o){return"annotation-xml"!==o&&Y(o,xt)},Lr=function _sanitizeAttributes(o){Nr("beforeSanitizeAttributes",o,null);const{attributes:s}=o;if(!s)return;const i={attrName:"",attrValue:"",keepAttr:!0,allowedAttributes:At};let u=s.length;for(;u--;){const _=s[u],{name:w,namespaceURI:x,value:C}=_,j=mr(w);let L="value"===w?C:ie(C);if(i.attrName=j,i.attrValue=L,i.keepAttr=!0,i.forceKeepAttr=void 0,Nr("uponSanitizeAttribute",o,i),L=i.attrValue,i.forceKeepAttr)continue;if(Ar(w,o),!i.keepAttr)continue;if(!Dt&&ce(/\/>/i,L)){Ar(w,o);continue}if(Bt&&ce(/((--!?|])>)|<\/(style|title)/i,L)){Ar(w,o);continue}Lt&&B([yt,vt,bt],(o=>{L=Z(L,o," ")}));const V=mr(o.nodeName);if(Rr(V,j,L)){if(!Kt||"id"!==j&&"name"!==j||(Ar(w,o),L=Ht+L),ct&&"object"==typeof Ye&&"function"==typeof Ye.getAttributeType)if(x);else switch(Ye.getAttributeType(V,j)){case"TrustedHTML":L=ct.createHTML(L);break;case"TrustedScriptURL":L=ct.createScriptURL(L)}try{x?o.setAttributeNS(x,w,L):o.setAttribute(w,L),Ir(o)?Cr(o):$(DOMPurify.removed)}catch(o){}}}Nr("afterSanitizeAttributes",o,null)},Br=function _sanitizeShadowDOM(o){let s=null;const i=Pr(o);for(Nr("beforeSanitizeShadowDOM",o,null);s=i.nextNode();){if(Nr("uponSanitizeShadowNode",s,null),Tr(s))continue;const o=at(s);s.nodeType===rt.element&&(o&&o.__depth?s.__depth=(s.__removalCount||0)+o.__depth+1:s.__depth=1),(s.__depth>=yr||s.__depth<0||numberIsNaN(s.__depth))&&Cr(s),s.content instanceof x&&(s.content.__depth=s.__depth,_sanitizeShadowDOM(s.content)),Lr(s)}Nr("afterSanitizeShadowDOM",o,null)};return DOMPurify.sanitize=function(o){let s=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},i=null,_=null,w=null,C=null;if(cr=!o,cr&&(o="\x3c!--\x3e"),"string"!=typeof o&&!Mr(o)){if("function"!=typeof o.toString)throw le("toString is not a function");if("string"!=typeof(o=o.toString()))throw le("dirty is not a string, aborting")}if(!DOMPurify.isSupported)return o;if(qt||_r(s),DOMPurify.removed=[],"string"==typeof o&&(Gt=!1),Gt){if(o.nodeName){const s=mr(o.nodeName);if(!Ot[s]||It[s])throw le("root node is forbidden and cannot be sanitized in-place")}}else if(o instanceof L)i=jr("\x3c!----\x3e"),_=i.ownerDocument.importNode(o,!0),_.nodeType===rt.element&&"BODY"===_.nodeName||"HTML"===_.nodeName?i=_:i.appendChild(_);else{if(!Vt&&!Lt&&!Ft&&-1===o.indexOf("<"))return ct&&zt?ct.createHTML(o):o;if(i=jr(o),!i)return Vt?null:zt?lt:""}i&&$t&&Cr(i.firstChild);const j=Pr(Gt?o:i);for(;w=j.nextNode();){if(Tr(w))continue;const o=at(w);w.nodeType===rt.element&&(o&&o.__depth?w.__depth=(w.__removalCount||0)+o.__depth+1:w.__depth=1),(w.__depth>=yr||w.__depth<0||numberIsNaN(w.__depth))&&Cr(w),w.content instanceof x&&(w.content.__depth=w.__depth,Br(w.content)),Lr(w)}if(Gt)return o;if(Vt){if(Ut)for(C=ht.call(i.ownerDocument);i.firstChild;)C.appendChild(i.firstChild);else C=i;return(At.shadowroot||At.shadowrootmode)&&(C=mt.call(u,C,!0)),C}let $=Ft?i.outerHTML:i.innerHTML;return Ft&&Ot["!doctype"]&&i.ownerDocument&&i.ownerDocument.doctype&&i.ownerDocument.doctype.name&&ce(Qe,i.ownerDocument.doctype.name)&&($="\n"+$),Lt&&B([yt,vt,bt],(o=>{$=Z($,o," ")})),ct&&zt?ct.createHTML($):$},DOMPurify.setConfig=function(){_r(arguments.length>0&&void 0!==arguments[0]?arguments[0]:{}),qt=!0},DOMPurify.clearConfig=function(){gr=null,qt=!1},DOMPurify.isValidAttribute=function(o,s,i){gr||_r({});const u=mr(o),_=mr(s);return Rr(u,_,i)},DOMPurify.addHook=function(o,s){"function"==typeof s&&(gt[o]=gt[o]||[],V(gt[o],s))},DOMPurify.removeHook=function(o){if(gt[o])return $(gt[o])},DOMPurify.removeHooks=function(o){gt[o]&&(gt[o]=[])},DOMPurify.removeAllHooks=function(){gt={}},DOMPurify}return createDOMPurify()}()},78004:o=>{"use strict";class SubRange{constructor(o,s){this.low=o,this.high=s,this.length=1+s-o}overlaps(o){return!(this.higho.high)}touches(o){return!(this.high+1o.high)}add(o){return new SubRange(Math.min(this.low,o.low),Math.max(this.high,o.high))}subtract(o){return o.low<=this.low&&o.high>=this.high?[]:o.low>this.low&&o.higho+s.length),0)}add(o,s){var _add=o=>{for(var s=0;s{for(var s=0;s{for(var s=0;s{for(var i=s.low;i<=s.high;)o.push(i),i++;return o}),[])}subranges(){return this.ranges.map((o=>({low:o.low,high:o.high,length:1+o.high-o.low})))}}o.exports=DRange},30655:(o,s,i)=>{"use strict";var u=i(70453)("%Object.defineProperty%",!0)||!1;if(u)try{u({},"a",{value:1})}catch(o){u=!1}o.exports=u},41237:o=>{"use strict";o.exports=EvalError},69383:o=>{"use strict";o.exports=Error},79290:o=>{"use strict";o.exports=RangeError},79538:o=>{"use strict";o.exports=ReferenceError},58068:o=>{"use strict";o.exports=SyntaxError},69675:o=>{"use strict";o.exports=TypeError},35345:o=>{"use strict";o.exports=URIError},37007:o=>{"use strict";var s,i="object"==typeof Reflect?Reflect:null,u=i&&"function"==typeof i.apply?i.apply:function ReflectApply(o,s,i){return Function.prototype.apply.call(o,s,i)};s=i&&"function"==typeof i.ownKeys?i.ownKeys:Object.getOwnPropertySymbols?function ReflectOwnKeys(o){return Object.getOwnPropertyNames(o).concat(Object.getOwnPropertySymbols(o))}:function ReflectOwnKeys(o){return Object.getOwnPropertyNames(o)};var _=Number.isNaN||function NumberIsNaN(o){return o!=o};function EventEmitter(){EventEmitter.init.call(this)}o.exports=EventEmitter,o.exports.once=function once(o,s){return new Promise((function(i,u){function errorListener(i){o.removeListener(s,resolver),u(i)}function resolver(){"function"==typeof o.removeListener&&o.removeListener("error",errorListener),i([].slice.call(arguments))}eventTargetAgnosticAddListener(o,s,resolver,{once:!0}),"error"!==s&&function addErrorHandlerIfEventEmitter(o,s,i){"function"==typeof o.on&&eventTargetAgnosticAddListener(o,"error",s,i)}(o,errorListener,{once:!0})}))},EventEmitter.EventEmitter=EventEmitter,EventEmitter.prototype._events=void 0,EventEmitter.prototype._eventsCount=0,EventEmitter.prototype._maxListeners=void 0;var w=10;function checkListener(o){if("function"!=typeof o)throw new TypeError('The "listener" argument must be of type Function. Received type '+typeof o)}function _getMaxListeners(o){return void 0===o._maxListeners?EventEmitter.defaultMaxListeners:o._maxListeners}function _addListener(o,s,i,u){var _,w,x;if(checkListener(i),void 0===(w=o._events)?(w=o._events=Object.create(null),o._eventsCount=0):(void 0!==w.newListener&&(o.emit("newListener",s,i.listener?i.listener:i),w=o._events),x=w[s]),void 0===x)x=w[s]=i,++o._eventsCount;else if("function"==typeof x?x=w[s]=u?[i,x]:[x,i]:u?x.unshift(i):x.push(i),(_=_getMaxListeners(o))>0&&x.length>_&&!x.warned){x.warned=!0;var C=new Error("Possible EventEmitter memory leak detected. "+x.length+" "+String(s)+" listeners added. Use emitter.setMaxListeners() to increase limit");C.name="MaxListenersExceededWarning",C.emitter=o,C.type=s,C.count=x.length,function ProcessEmitWarning(o){console&&console.warn&&console.warn(o)}(C)}return o}function onceWrapper(){if(!this.fired)return this.target.removeListener(this.type,this.wrapFn),this.fired=!0,0===arguments.length?this.listener.call(this.target):this.listener.apply(this.target,arguments)}function _onceWrap(o,s,i){var u={fired:!1,wrapFn:void 0,target:o,type:s,listener:i},_=onceWrapper.bind(u);return _.listener=i,u.wrapFn=_,_}function _listeners(o,s,i){var u=o._events;if(void 0===u)return[];var _=u[s];return void 0===_?[]:"function"==typeof _?i?[_.listener||_]:[_]:i?function unwrapListeners(o){for(var s=new Array(o.length),i=0;i0&&(x=s[0]),x instanceof Error)throw x;var C=new Error("Unhandled error."+(x?" ("+x.message+")":""));throw C.context=x,C}var j=w[o];if(void 0===j)return!1;if("function"==typeof j)u(j,this,s);else{var L=j.length,B=arrayClone(j,L);for(i=0;i=0;w--)if(i[w]===s||i[w].listener===s){x=i[w].listener,_=w;break}if(_<0)return this;0===_?i.shift():function spliceOne(o,s){for(;s+1=0;u--)this.removeListener(o,s[u]);return this},EventEmitter.prototype.listeners=function listeners(o){return _listeners(this,o,!0)},EventEmitter.prototype.rawListeners=function rawListeners(o){return _listeners(this,o,!1)},EventEmitter.listenerCount=function(o,s){return"function"==typeof o.listenerCount?o.listenerCount(s):listenerCount.call(o,s)},EventEmitter.prototype.listenerCount=listenerCount,EventEmitter.prototype.eventNames=function eventNames(){return this._eventsCount>0?s(this._events):[]}},85587:(o,s,i)=>{"use strict";var u=i(26311),_=create(Error);function create(o){return FormattedError.displayName=o.displayName||o.name,FormattedError;function FormattedError(s){return s&&(s=u.apply(null,arguments)),new o(s)}}o.exports=_,_.eval=create(EvalError),_.range=create(RangeError),_.reference=create(ReferenceError),_.syntax=create(SyntaxError),_.type=create(TypeError),_.uri=create(URIError),_.create=create},26311:o=>{!function(){var s;function format(o){for(var s,i,u,_,w=1,x=[].slice.call(arguments),C=0,j=o.length,L="",B=!1,$=!1,nextArg=function(){return x[w++]},slurpNumber=function(){for(var i="";/\d/.test(o[C]);)i+=o[C++],s=o[C];return i.length>0?parseInt(i):null};C{"use strict";var s=Object.prototype.toString,i=Math.max,u=function concatty(o,s){for(var i=[],u=0;u{"use strict";var u=i(89353);o.exports=Function.prototype.bind||u},70453:(o,s,i)=>{"use strict";var u,_=i(69383),w=i(41237),x=i(79290),C=i(79538),j=i(58068),L=i(69675),B=i(35345),$=Function,getEvalledConstructor=function(o){try{return $('"use strict"; return ('+o+").constructor;")()}catch(o){}},V=Object.getOwnPropertyDescriptor;if(V)try{V({},"")}catch(o){V=null}var throwTypeError=function(){throw new L},U=V?function(){try{return throwTypeError}catch(o){try{return V(arguments,"callee").get}catch(o){return throwTypeError}}}():throwTypeError,z=i(64039)(),Y=i(80024)(),Z=Object.getPrototypeOf||(Y?function(o){return o.__proto__}:null),ee={},ie="undefined"!=typeof Uint8Array&&Z?Z(Uint8Array):u,ae={__proto__:null,"%AggregateError%":"undefined"==typeof AggregateError?u:AggregateError,"%Array%":Array,"%ArrayBuffer%":"undefined"==typeof ArrayBuffer?u:ArrayBuffer,"%ArrayIteratorPrototype%":z&&Z?Z([][Symbol.iterator]()):u,"%AsyncFromSyncIteratorPrototype%":u,"%AsyncFunction%":ee,"%AsyncGenerator%":ee,"%AsyncGeneratorFunction%":ee,"%AsyncIteratorPrototype%":ee,"%Atomics%":"undefined"==typeof Atomics?u:Atomics,"%BigInt%":"undefined"==typeof BigInt?u:BigInt,"%BigInt64Array%":"undefined"==typeof BigInt64Array?u:BigInt64Array,"%BigUint64Array%":"undefined"==typeof BigUint64Array?u:BigUint64Array,"%Boolean%":Boolean,"%DataView%":"undefined"==typeof DataView?u:DataView,"%Date%":Date,"%decodeURI%":decodeURI,"%decodeURIComponent%":decodeURIComponent,"%encodeURI%":encodeURI,"%encodeURIComponent%":encodeURIComponent,"%Error%":_,"%eval%":eval,"%EvalError%":w,"%Float32Array%":"undefined"==typeof Float32Array?u:Float32Array,"%Float64Array%":"undefined"==typeof Float64Array?u:Float64Array,"%FinalizationRegistry%":"undefined"==typeof FinalizationRegistry?u:FinalizationRegistry,"%Function%":$,"%GeneratorFunction%":ee,"%Int8Array%":"undefined"==typeof Int8Array?u:Int8Array,"%Int16Array%":"undefined"==typeof Int16Array?u:Int16Array,"%Int32Array%":"undefined"==typeof Int32Array?u:Int32Array,"%isFinite%":isFinite,"%isNaN%":isNaN,"%IteratorPrototype%":z&&Z?Z(Z([][Symbol.iterator]())):u,"%JSON%":"object"==typeof JSON?JSON:u,"%Map%":"undefined"==typeof Map?u:Map,"%MapIteratorPrototype%":"undefined"!=typeof Map&&z&&Z?Z((new Map)[Symbol.iterator]()):u,"%Math%":Math,"%Number%":Number,"%Object%":Object,"%parseFloat%":parseFloat,"%parseInt%":parseInt,"%Promise%":"undefined"==typeof Promise?u:Promise,"%Proxy%":"undefined"==typeof Proxy?u:Proxy,"%RangeError%":x,"%ReferenceError%":C,"%Reflect%":"undefined"==typeof Reflect?u:Reflect,"%RegExp%":RegExp,"%Set%":"undefined"==typeof Set?u:Set,"%SetIteratorPrototype%":"undefined"!=typeof Set&&z&&Z?Z((new Set)[Symbol.iterator]()):u,"%SharedArrayBuffer%":"undefined"==typeof SharedArrayBuffer?u:SharedArrayBuffer,"%String%":String,"%StringIteratorPrototype%":z&&Z?Z(""[Symbol.iterator]()):u,"%Symbol%":z?Symbol:u,"%SyntaxError%":j,"%ThrowTypeError%":U,"%TypedArray%":ie,"%TypeError%":L,"%Uint8Array%":"undefined"==typeof Uint8Array?u:Uint8Array,"%Uint8ClampedArray%":"undefined"==typeof Uint8ClampedArray?u:Uint8ClampedArray,"%Uint16Array%":"undefined"==typeof Uint16Array?u:Uint16Array,"%Uint32Array%":"undefined"==typeof Uint32Array?u:Uint32Array,"%URIError%":B,"%WeakMap%":"undefined"==typeof WeakMap?u:WeakMap,"%WeakRef%":"undefined"==typeof WeakRef?u:WeakRef,"%WeakSet%":"undefined"==typeof WeakSet?u:WeakSet};if(Z)try{null.error}catch(o){var ce=Z(Z(o));ae["%Error.prototype%"]=ce}var le=function doEval(o){var s;if("%AsyncFunction%"===o)s=getEvalledConstructor("async function () {}");else if("%GeneratorFunction%"===o)s=getEvalledConstructor("function* () {}");else if("%AsyncGeneratorFunction%"===o)s=getEvalledConstructor("async function* () {}");else if("%AsyncGenerator%"===o){var i=doEval("%AsyncGeneratorFunction%");i&&(s=i.prototype)}else if("%AsyncIteratorPrototype%"===o){var u=doEval("%AsyncGenerator%");u&&Z&&(s=Z(u.prototype))}return ae[o]=s,s},pe={__proto__:null,"%ArrayBufferPrototype%":["ArrayBuffer","prototype"],"%ArrayPrototype%":["Array","prototype"],"%ArrayProto_entries%":["Array","prototype","entries"],"%ArrayProto_forEach%":["Array","prototype","forEach"],"%ArrayProto_keys%":["Array","prototype","keys"],"%ArrayProto_values%":["Array","prototype","values"],"%AsyncFunctionPrototype%":["AsyncFunction","prototype"],"%AsyncGenerator%":["AsyncGeneratorFunction","prototype"],"%AsyncGeneratorPrototype%":["AsyncGeneratorFunction","prototype","prototype"],"%BooleanPrototype%":["Boolean","prototype"],"%DataViewPrototype%":["DataView","prototype"],"%DatePrototype%":["Date","prototype"],"%ErrorPrototype%":["Error","prototype"],"%EvalErrorPrototype%":["EvalError","prototype"],"%Float32ArrayPrototype%":["Float32Array","prototype"],"%Float64ArrayPrototype%":["Float64Array","prototype"],"%FunctionPrototype%":["Function","prototype"],"%Generator%":["GeneratorFunction","prototype"],"%GeneratorPrototype%":["GeneratorFunction","prototype","prototype"],"%Int8ArrayPrototype%":["Int8Array","prototype"],"%Int16ArrayPrototype%":["Int16Array","prototype"],"%Int32ArrayPrototype%":["Int32Array","prototype"],"%JSONParse%":["JSON","parse"],"%JSONStringify%":["JSON","stringify"],"%MapPrototype%":["Map","prototype"],"%NumberPrototype%":["Number","prototype"],"%ObjectPrototype%":["Object","prototype"],"%ObjProto_toString%":["Object","prototype","toString"],"%ObjProto_valueOf%":["Object","prototype","valueOf"],"%PromisePrototype%":["Promise","prototype"],"%PromiseProto_then%":["Promise","prototype","then"],"%Promise_all%":["Promise","all"],"%Promise_reject%":["Promise","reject"],"%Promise_resolve%":["Promise","resolve"],"%RangeErrorPrototype%":["RangeError","prototype"],"%ReferenceErrorPrototype%":["ReferenceError","prototype"],"%RegExpPrototype%":["RegExp","prototype"],"%SetPrototype%":["Set","prototype"],"%SharedArrayBufferPrototype%":["SharedArrayBuffer","prototype"],"%StringPrototype%":["String","prototype"],"%SymbolPrototype%":["Symbol","prototype"],"%SyntaxErrorPrototype%":["SyntaxError","prototype"],"%TypedArrayPrototype%":["TypedArray","prototype"],"%TypeErrorPrototype%":["TypeError","prototype"],"%Uint8ArrayPrototype%":["Uint8Array","prototype"],"%Uint8ClampedArrayPrototype%":["Uint8ClampedArray","prototype"],"%Uint16ArrayPrototype%":["Uint16Array","prototype"],"%Uint32ArrayPrototype%":["Uint32Array","prototype"],"%URIErrorPrototype%":["URIError","prototype"],"%WeakMapPrototype%":["WeakMap","prototype"],"%WeakSetPrototype%":["WeakSet","prototype"]},de=i(66743),fe=i(9957),ye=de.call(Function.call,Array.prototype.concat),be=de.call(Function.apply,Array.prototype.splice),_e=de.call(Function.call,String.prototype.replace),we=de.call(Function.call,String.prototype.slice),Se=de.call(Function.call,RegExp.prototype.exec),xe=/[^%.[\]]+|\[(?:(-?\d+(?:\.\d+)?)|(["'])((?:(?!\2)[^\\]|\\.)*?)\2)\]|(?=(?:\.|\[\])(?:\.|\[\]|%$))/g,Pe=/\\(\\)?/g,Te=function getBaseIntrinsic(o,s){var i,u=o;if(fe(pe,u)&&(u="%"+(i=pe[u])[0]+"%"),fe(ae,u)){var _=ae[u];if(_===ee&&(_=le(u)),void 0===_&&!s)throw new L("intrinsic "+o+" exists, but is not available. Please file an issue!");return{alias:i,name:u,value:_}}throw new j("intrinsic "+o+" does not exist!")};o.exports=function GetIntrinsic(o,s){if("string"!=typeof o||0===o.length)throw new L("intrinsic name must be a non-empty string");if(arguments.length>1&&"boolean"!=typeof s)throw new L('"allowMissing" argument must be a boolean');if(null===Se(/^%?[^%]*%?$/,o))throw new j("`%` may not be present anywhere but at the beginning and end of the intrinsic name");var i=function stringToPath(o){var s=we(o,0,1),i=we(o,-1);if("%"===s&&"%"!==i)throw new j("invalid intrinsic syntax, expected closing `%`");if("%"===i&&"%"!==s)throw new j("invalid intrinsic syntax, expected opening `%`");var u=[];return _e(o,xe,(function(o,s,i,_){u[u.length]=i?_e(_,Pe,"$1"):s||o})),u}(o),u=i.length>0?i[0]:"",_=Te("%"+u+"%",s),w=_.name,x=_.value,C=!1,B=_.alias;B&&(u=B[0],be(i,ye([0,1],B)));for(var $=1,U=!0;$=i.length){var ee=V(x,z);x=(U=!!ee)&&"get"in ee&&!("originalValue"in ee.get)?ee.get:x[z]}else U=fe(x,z),x=x[z];U&&!C&&(ae[w]=x)}}return x}},75795:(o,s,i)=>{"use strict";var u=i(70453)("%Object.getOwnPropertyDescriptor%",!0);if(u)try{u([],"length")}catch(o){u=null}o.exports=u},30592:(o,s,i)=>{"use strict";var u=i(30655),_=function hasPropertyDescriptors(){return!!u};_.hasArrayLengthDefineBug=function hasArrayLengthDefineBug(){if(!u)return null;try{return 1!==u([],"length",{value:1}).length}catch(o){return!0}},o.exports=_},80024:o=>{"use strict";var s={__proto__:null,foo:{}},i=Object;o.exports=function hasProto(){return{__proto__:s}.foo===s.foo&&!(s instanceof i)}},64039:(o,s,i)=>{"use strict";var u="undefined"!=typeof Symbol&&Symbol,_=i(41333);o.exports=function hasNativeSymbols(){return"function"==typeof u&&("function"==typeof Symbol&&("symbol"==typeof u("foo")&&("symbol"==typeof Symbol("bar")&&_())))}},41333:o=>{"use strict";o.exports=function hasSymbols(){if("function"!=typeof Symbol||"function"!=typeof Object.getOwnPropertySymbols)return!1;if("symbol"==typeof Symbol.iterator)return!0;var o={},s=Symbol("test"),i=Object(s);if("string"==typeof s)return!1;if("[object Symbol]"!==Object.prototype.toString.call(s))return!1;if("[object Symbol]"!==Object.prototype.toString.call(i))return!1;for(s in o[s]=42,o)return!1;if("function"==typeof Object.keys&&0!==Object.keys(o).length)return!1;if("function"==typeof Object.getOwnPropertyNames&&0!==Object.getOwnPropertyNames(o).length)return!1;var u=Object.getOwnPropertySymbols(o);if(1!==u.length||u[0]!==s)return!1;if(!Object.prototype.propertyIsEnumerable.call(o,s))return!1;if("function"==typeof Object.getOwnPropertyDescriptor){var _=Object.getOwnPropertyDescriptor(o,s);if(42!==_.value||!0!==_.enumerable)return!1}return!0}},9957:(o,s,i)=>{"use strict";var u=Function.prototype.call,_=Object.prototype.hasOwnProperty,w=i(66743);o.exports=w.call(u,_)},45981:o=>{function deepFreeze(o){return o instanceof Map?o.clear=o.delete=o.set=function(){throw new Error("map is read-only")}:o instanceof Set&&(o.add=o.clear=o.delete=function(){throw new Error("set is read-only")}),Object.freeze(o),Object.getOwnPropertyNames(o).forEach((function(s){var i=o[s];"object"!=typeof i||Object.isFrozen(i)||deepFreeze(i)})),o}var s=deepFreeze,i=deepFreeze;s.default=i;class Response{constructor(o){void 0===o.data&&(o.data={}),this.data=o.data,this.isMatchIgnored=!1}ignoreMatch(){this.isMatchIgnored=!0}}function escapeHTML(o){return o.replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'")}function inherit(o,...s){const i=Object.create(null);for(const s in o)i[s]=o[s];return s.forEach((function(o){for(const s in o)i[s]=o[s]})),i}const emitsWrappingTags=o=>!!o.kind;class HTMLRenderer{constructor(o,s){this.buffer="",this.classPrefix=s.classPrefix,o.walk(this)}addText(o){this.buffer+=escapeHTML(o)}openNode(o){if(!emitsWrappingTags(o))return;let s=o.kind;o.sublanguage||(s=`${this.classPrefix}${s}`),this.span(s)}closeNode(o){emitsWrappingTags(o)&&(this.buffer+="")}value(){return this.buffer}span(o){this.buffer+=``}}class TokenTree{constructor(){this.rootNode={children:[]},this.stack=[this.rootNode]}get top(){return this.stack[this.stack.length-1]}get root(){return this.rootNode}add(o){this.top.children.push(o)}openNode(o){const s={kind:o,children:[]};this.add(s),this.stack.push(s)}closeNode(){if(this.stack.length>1)return this.stack.pop()}closeAllNodes(){for(;this.closeNode(););}toJSON(){return JSON.stringify(this.rootNode,null,4)}walk(o){return this.constructor._walk(o,this.rootNode)}static _walk(o,s){return"string"==typeof s?o.addText(s):s.children&&(o.openNode(s),s.children.forEach((s=>this._walk(o,s))),o.closeNode(s)),o}static _collapse(o){"string"!=typeof o&&o.children&&(o.children.every((o=>"string"==typeof o))?o.children=[o.children.join("")]:o.children.forEach((o=>{TokenTree._collapse(o)})))}}class TokenTreeEmitter extends TokenTree{constructor(o){super(),this.options=o}addKeyword(o,s){""!==o&&(this.openNode(s),this.addText(o),this.closeNode())}addText(o){""!==o&&this.add(o)}addSublanguage(o,s){const i=o.root;i.kind=s,i.sublanguage=!0,this.add(i)}toHTML(){return new HTMLRenderer(this,this.options).value()}finalize(){return!0}}function source(o){return o?"string"==typeof o?o:o.source:null}const u=/\[(?:[^\\\]]|\\.)*\]|\(\??|\\([1-9][0-9]*)|\\./;const _="[a-zA-Z]\\w*",w="[a-zA-Z_]\\w*",x="\\b\\d+(\\.\\d+)?",C="(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)",j="\\b(0b[01]+)",L={begin:"\\\\[\\s\\S]",relevance:0},B={className:"string",begin:"'",end:"'",illegal:"\\n",contains:[L]},$={className:"string",begin:'"',end:'"',illegal:"\\n",contains:[L]},V={begin:/\b(a|an|the|are|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such|will|you|your|they|like|more)\b/},COMMENT=function(o,s,i={}){const u=inherit({className:"comment",begin:o,end:s,contains:[]},i);return u.contains.push(V),u.contains.push({className:"doctag",begin:"(?:TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):",relevance:0}),u},U=COMMENT("//","$"),z=COMMENT("/\\*","\\*/"),Y=COMMENT("#","$"),Z={className:"number",begin:x,relevance:0},ee={className:"number",begin:C,relevance:0},ie={className:"number",begin:j,relevance:0},ae={className:"number",begin:x+"(%|em|ex|ch|rem|vw|vh|vmin|vmax|cm|mm|in|pt|pc|px|deg|grad|rad|turn|s|ms|Hz|kHz|dpi|dpcm|dppx)?",relevance:0},ce={begin:/(?=\/[^/\n]*\/)/,contains:[{className:"regexp",begin:/\//,end:/\/[gimuy]*/,illegal:/\n/,contains:[L,{begin:/\[/,end:/\]/,relevance:0,contains:[L]}]}]},le={className:"title",begin:_,relevance:0},pe={className:"title",begin:w,relevance:0},de={begin:"\\.\\s*"+w,relevance:0};var fe=Object.freeze({__proto__:null,MATCH_NOTHING_RE:/\b\B/,IDENT_RE:_,UNDERSCORE_IDENT_RE:w,NUMBER_RE:x,C_NUMBER_RE:C,BINARY_NUMBER_RE:j,RE_STARTERS_RE:"!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~",SHEBANG:(o={})=>{const s=/^#![ ]*\//;return o.binary&&(o.begin=function concat(...o){return o.map((o=>source(o))).join("")}(s,/.*\b/,o.binary,/\b.*/)),inherit({className:"meta",begin:s,end:/$/,relevance:0,"on:begin":(o,s)=>{0!==o.index&&s.ignoreMatch()}},o)},BACKSLASH_ESCAPE:L,APOS_STRING_MODE:B,QUOTE_STRING_MODE:$,PHRASAL_WORDS_MODE:V,COMMENT,C_LINE_COMMENT_MODE:U,C_BLOCK_COMMENT_MODE:z,HASH_COMMENT_MODE:Y,NUMBER_MODE:Z,C_NUMBER_MODE:ee,BINARY_NUMBER_MODE:ie,CSS_NUMBER_MODE:ae,REGEXP_MODE:ce,TITLE_MODE:le,UNDERSCORE_TITLE_MODE:pe,METHOD_GUARD:de,END_SAME_AS_BEGIN:function(o){return Object.assign(o,{"on:begin":(o,s)=>{s.data._beginMatch=o[1]},"on:end":(o,s)=>{s.data._beginMatch!==o[1]&&s.ignoreMatch()}})}});function skipIfhasPrecedingDot(o,s){"."===o.input[o.index-1]&&s.ignoreMatch()}function beginKeywords(o,s){s&&o.beginKeywords&&(o.begin="\\b("+o.beginKeywords.split(" ").join("|")+")(?!\\.)(?=\\b|\\s)",o.__beforeBegin=skipIfhasPrecedingDot,o.keywords=o.keywords||o.beginKeywords,delete o.beginKeywords,void 0===o.relevance&&(o.relevance=0))}function compileIllegal(o,s){Array.isArray(o.illegal)&&(o.illegal=function either(...o){return"("+o.map((o=>source(o))).join("|")+")"}(...o.illegal))}function compileMatch(o,s){if(o.match){if(o.begin||o.end)throw new Error("begin & end are not supported with match");o.begin=o.match,delete o.match}}function compileRelevance(o,s){void 0===o.relevance&&(o.relevance=1)}const ye=["of","and","for","in","not","or","if","then","parent","list","value"],be="keyword";function compileKeywords(o,s,i=be){const u={};return"string"==typeof o?compileList(i,o.split(" ")):Array.isArray(o)?compileList(i,o):Object.keys(o).forEach((function(i){Object.assign(u,compileKeywords(o[i],s,i))})),u;function compileList(o,i){s&&(i=i.map((o=>o.toLowerCase()))),i.forEach((function(s){const i=s.split("|");u[i[0]]=[o,scoreForKeyword(i[0],i[1])]}))}}function scoreForKeyword(o,s){return s?Number(s):function commonKeyword(o){return ye.includes(o.toLowerCase())}(o)?0:1}function compileLanguage(o,{plugins:s}){function langRe(s,i){return new RegExp(source(s),"m"+(o.case_insensitive?"i":"")+(i?"g":""))}class MultiRegex{constructor(){this.matchIndexes={},this.regexes=[],this.matchAt=1,this.position=0}addRule(o,s){s.position=this.position++,this.matchIndexes[this.matchAt]=s,this.regexes.push([s,o]),this.matchAt+=function countMatchGroups(o){return new RegExp(o.toString()+"|").exec("").length-1}(o)+1}compile(){0===this.regexes.length&&(this.exec=()=>null);const o=this.regexes.map((o=>o[1]));this.matcherRe=langRe(function join(o,s="|"){let i=0;return o.map((o=>{i+=1;const s=i;let _=source(o),w="";for(;_.length>0;){const o=u.exec(_);if(!o){w+=_;break}w+=_.substring(0,o.index),_=_.substring(o.index+o[0].length),"\\"===o[0][0]&&o[1]?w+="\\"+String(Number(o[1])+s):(w+=o[0],"("===o[0]&&i++)}return w})).map((o=>`(${o})`)).join(s)}(o),!0),this.lastIndex=0}exec(o){this.matcherRe.lastIndex=this.lastIndex;const s=this.matcherRe.exec(o);if(!s)return null;const i=s.findIndex(((o,s)=>s>0&&void 0!==o)),u=this.matchIndexes[i];return s.splice(0,i),Object.assign(s,u)}}class ResumableMultiRegex{constructor(){this.rules=[],this.multiRegexes=[],this.count=0,this.lastIndex=0,this.regexIndex=0}getMatcher(o){if(this.multiRegexes[o])return this.multiRegexes[o];const s=new MultiRegex;return this.rules.slice(o).forEach((([o,i])=>s.addRule(o,i))),s.compile(),this.multiRegexes[o]=s,s}resumingScanAtSamePosition(){return 0!==this.regexIndex}considerAll(){this.regexIndex=0}addRule(o,s){this.rules.push([o,s]),"begin"===s.type&&this.count++}exec(o){const s=this.getMatcher(this.regexIndex);s.lastIndex=this.lastIndex;let i=s.exec(o);if(this.resumingScanAtSamePosition())if(i&&i.index===this.lastIndex);else{const s=this.getMatcher(0);s.lastIndex=this.lastIndex+1,i=s.exec(o)}return i&&(this.regexIndex+=i.position+1,this.regexIndex===this.count&&this.considerAll()),i}}if(o.compilerExtensions||(o.compilerExtensions=[]),o.contains&&o.contains.includes("self"))throw new Error("ERR: contains `self` is not supported at the top-level of a language. See documentation.");return o.classNameAliases=inherit(o.classNameAliases||{}),function compileMode(s,i){const u=s;if(s.isCompiled)return u;[compileMatch].forEach((o=>o(s,i))),o.compilerExtensions.forEach((o=>o(s,i))),s.__beforeBegin=null,[beginKeywords,compileIllegal,compileRelevance].forEach((o=>o(s,i))),s.isCompiled=!0;let _=null;if("object"==typeof s.keywords&&(_=s.keywords.$pattern,delete s.keywords.$pattern),s.keywords&&(s.keywords=compileKeywords(s.keywords,o.case_insensitive)),s.lexemes&&_)throw new Error("ERR: Prefer `keywords.$pattern` to `mode.lexemes`, BOTH are not allowed. (see mode reference) ");return _=_||s.lexemes||/\w+/,u.keywordPatternRe=langRe(_,!0),i&&(s.begin||(s.begin=/\B|\b/),u.beginRe=langRe(s.begin),s.endSameAsBegin&&(s.end=s.begin),s.end||s.endsWithParent||(s.end=/\B|\b/),s.end&&(u.endRe=langRe(s.end)),u.terminatorEnd=source(s.end)||"",s.endsWithParent&&i.terminatorEnd&&(u.terminatorEnd+=(s.end?"|":"")+i.terminatorEnd)),s.illegal&&(u.illegalRe=langRe(s.illegal)),s.contains||(s.contains=[]),s.contains=[].concat(...s.contains.map((function(o){return function expandOrCloneMode(o){o.variants&&!o.cachedVariants&&(o.cachedVariants=o.variants.map((function(s){return inherit(o,{variants:null},s)})));if(o.cachedVariants)return o.cachedVariants;if(dependencyOnParent(o))return inherit(o,{starts:o.starts?inherit(o.starts):null});if(Object.isFrozen(o))return inherit(o);return o}("self"===o?s:o)}))),s.contains.forEach((function(o){compileMode(o,u)})),s.starts&&compileMode(s.starts,i),u.matcher=function buildModeRegex(o){const s=new ResumableMultiRegex;return o.contains.forEach((o=>s.addRule(o.begin,{rule:o,type:"begin"}))),o.terminatorEnd&&s.addRule(o.terminatorEnd,{type:"end"}),o.illegal&&s.addRule(o.illegal,{type:"illegal"}),s}(u),u}(o)}function dependencyOnParent(o){return!!o&&(o.endsWithParent||dependencyOnParent(o.starts))}function BuildVuePlugin(o){const s={props:["language","code","autodetect"],data:function(){return{detectedLanguage:"",unknownLanguage:!1}},computed:{className(){return this.unknownLanguage?"":"hljs "+this.detectedLanguage},highlighted(){if(!this.autoDetect&&!o.getLanguage(this.language))return console.warn(`The language "${this.language}" you specified could not be found.`),this.unknownLanguage=!0,escapeHTML(this.code);let s={};return this.autoDetect?(s=o.highlightAuto(this.code),this.detectedLanguage=s.language):(s=o.highlight(this.language,this.code,this.ignoreIllegals),this.detectedLanguage=this.language),s.value},autoDetect(){return!this.language||function hasValueOrEmptyAttribute(o){return Boolean(o||""===o)}(this.autodetect)},ignoreIllegals:()=>!0},render(o){return o("pre",{},[o("code",{class:this.className,domProps:{innerHTML:this.highlighted}})])}};return{Component:s,VuePlugin:{install(o){o.component("highlightjs",s)}}}}const _e={"after:highlightElement":({el:o,result:s,text:i})=>{const u=nodeStream(o);if(!u.length)return;const _=document.createElement("div");_.innerHTML=s.value,s.value=function mergeStreams(o,s,i){let u=0,_="";const w=[];function selectStream(){return o.length&&s.length?o[0].offset!==s[0].offset?o[0].offset"}function close(o){_+=""}function render(o){("start"===o.event?open:close)(o.node)}for(;o.length||s.length;){let s=selectStream();if(_+=escapeHTML(i.substring(u,s[0].offset)),u=s[0].offset,s===o){w.reverse().forEach(close);do{render(s.splice(0,1)[0]),s=selectStream()}while(s===o&&s.length&&s[0].offset===u);w.reverse().forEach(open)}else"start"===s[0].event?w.push(s[0].node):w.pop(),render(s.splice(0,1)[0])}return _+escapeHTML(i.substr(u))}(u,nodeStream(_),i)}};function tag(o){return o.nodeName.toLowerCase()}function nodeStream(o){const s=[];return function _nodeStream(o,i){for(let u=o.firstChild;u;u=u.nextSibling)3===u.nodeType?i+=u.nodeValue.length:1===u.nodeType&&(s.push({event:"start",offset:i,node:u}),i=_nodeStream(u,i),tag(u).match(/br|hr|img|input/)||s.push({event:"stop",offset:i,node:u}));return i}(o,0),s}const we={},error=o=>{console.error(o)},warn=(o,...s)=>{console.log(`WARN: ${o}`,...s)},deprecated=(o,s)=>{we[`${o}/${s}`]||(console.log(`Deprecated as of ${o}. ${s}`),we[`${o}/${s}`]=!0)},Se=escapeHTML,xe=inherit,Pe=Symbol("nomatch");var Te=function(o){const i=Object.create(null),u=Object.create(null),_=[];let w=!0;const x=/(^(<[^>]+>|\t|)+|\n)/gm,C="Could not find the language '{}', did you forget to load/include a language module?",j={disableAutodetect:!0,name:"Plain text",contains:[]};let L={noHighlightRe:/^(no-?highlight)$/i,languageDetectRe:/\blang(?:uage)?-([\w-]+)\b/i,classPrefix:"hljs-",tabReplace:null,useBR:!1,languages:null,__emitter:TokenTreeEmitter};function shouldNotHighlight(o){return L.noHighlightRe.test(o)}function highlight(o,s,i,u){let _="",w="";"object"==typeof s?(_=o,i=s.ignoreIllegals,w=s.language,u=void 0):(deprecated("10.7.0","highlight(lang, code, ...args) has been deprecated."),deprecated("10.7.0","Please use highlight(code, options) instead.\nhttps://github.com/highlightjs/highlight.js/issues/2277"),w=o,_=s);const x={code:_,language:w};fire("before:highlight",x);const C=x.result?x.result:_highlight(x.language,x.code,i,u);return C.code=x.code,fire("after:highlight",C),C}function _highlight(o,s,u,x){function keywordData(o,s){const i=B.case_insensitive?s[0].toLowerCase():s[0];return Object.prototype.hasOwnProperty.call(o.keywords,i)&&o.keywords[i]}function processBuffer(){null!=U.subLanguage?function processSubLanguage(){if(""===Z)return;let o=null;if("string"==typeof U.subLanguage){if(!i[U.subLanguage])return void Y.addText(Z);o=_highlight(U.subLanguage,Z,!0,z[U.subLanguage]),z[U.subLanguage]=o.top}else o=highlightAuto(Z,U.subLanguage.length?U.subLanguage:null);U.relevance>0&&(ee+=o.relevance),Y.addSublanguage(o.emitter,o.language)}():function processKeywords(){if(!U.keywords)return void Y.addText(Z);let o=0;U.keywordPatternRe.lastIndex=0;let s=U.keywordPatternRe.exec(Z),i="";for(;s;){i+=Z.substring(o,s.index);const u=keywordData(U,s);if(u){const[o,_]=u;if(Y.addText(i),i="",ee+=_,o.startsWith("_"))i+=s[0];else{const i=B.classNameAliases[o]||o;Y.addKeyword(s[0],i)}}else i+=s[0];o=U.keywordPatternRe.lastIndex,s=U.keywordPatternRe.exec(Z)}i+=Z.substr(o),Y.addText(i)}(),Z=""}function startNewMode(o){return o.className&&Y.openNode(B.classNameAliases[o.className]||o.className),U=Object.create(o,{parent:{value:U}}),U}function endOfMode(o,s,i){let u=function startsWith(o,s){const i=o&&o.exec(s);return i&&0===i.index}(o.endRe,i);if(u){if(o["on:end"]){const i=new Response(o);o["on:end"](s,i),i.isMatchIgnored&&(u=!1)}if(u){for(;o.endsParent&&o.parent;)o=o.parent;return o}}if(o.endsWithParent)return endOfMode(o.parent,s,i)}function doIgnore(o){return 0===U.matcher.regexIndex?(Z+=o[0],1):(ce=!0,0)}function doBeginMatch(o){const s=o[0],i=o.rule,u=new Response(i),_=[i.__beforeBegin,i["on:begin"]];for(const i of _)if(i&&(i(o,u),u.isMatchIgnored))return doIgnore(s);return i&&i.endSameAsBegin&&(i.endRe=function escape(o){return new RegExp(o.replace(/[-/\\^$*+?.()|[\]{}]/g,"\\$&"),"m")}(s)),i.skip?Z+=s:(i.excludeBegin&&(Z+=s),processBuffer(),i.returnBegin||i.excludeBegin||(Z=s)),startNewMode(i),i.returnBegin?0:s.length}function doEndMatch(o){const i=o[0],u=s.substr(o.index),_=endOfMode(U,o,u);if(!_)return Pe;const w=U;w.skip?Z+=i:(w.returnEnd||w.excludeEnd||(Z+=i),processBuffer(),w.excludeEnd&&(Z=i));do{U.className&&Y.closeNode(),U.skip||U.subLanguage||(ee+=U.relevance),U=U.parent}while(U!==_.parent);return _.starts&&(_.endSameAsBegin&&(_.starts.endRe=_.endRe),startNewMode(_.starts)),w.returnEnd?0:i.length}let j={};function processLexeme(i,_){const x=_&&_[0];if(Z+=i,null==x)return processBuffer(),0;if("begin"===j.type&&"end"===_.type&&j.index===_.index&&""===x){if(Z+=s.slice(_.index,_.index+1),!w){const s=new Error("0 width match regex");throw s.languageName=o,s.badRule=j.rule,s}return 1}if(j=_,"begin"===_.type)return doBeginMatch(_);if("illegal"===_.type&&!u){const o=new Error('Illegal lexeme "'+x+'" for mode "'+(U.className||"")+'"');throw o.mode=U,o}if("end"===_.type){const o=doEndMatch(_);if(o!==Pe)return o}if("illegal"===_.type&&""===x)return 1;if(ae>1e5&&ae>3*_.index){throw new Error("potential infinite loop, way more iterations than matches")}return Z+=x,x.length}const B=getLanguage(o);if(!B)throw error(C.replace("{}",o)),new Error('Unknown language: "'+o+'"');const $=compileLanguage(B,{plugins:_});let V="",U=x||$;const z={},Y=new L.__emitter(L);!function processContinuations(){const o=[];for(let s=U;s!==B;s=s.parent)s.className&&o.unshift(s.className);o.forEach((o=>Y.openNode(o)))}();let Z="",ee=0,ie=0,ae=0,ce=!1;try{for(U.matcher.considerAll();;){ae++,ce?ce=!1:U.matcher.considerAll(),U.matcher.lastIndex=ie;const o=U.matcher.exec(s);if(!o)break;const i=processLexeme(s.substring(ie,o.index),o);ie=o.index+i}return processLexeme(s.substr(ie)),Y.closeAllNodes(),Y.finalize(),V=Y.toHTML(),{relevance:Math.floor(ee),value:V,language:o,illegal:!1,emitter:Y,top:U}}catch(i){if(i.message&&i.message.includes("Illegal"))return{illegal:!0,illegalBy:{msg:i.message,context:s.slice(ie-100,ie+100),mode:i.mode},sofar:V,relevance:0,value:Se(s),emitter:Y};if(w)return{illegal:!1,relevance:0,value:Se(s),emitter:Y,language:o,top:U,errorRaised:i};throw i}}function highlightAuto(o,s){s=s||L.languages||Object.keys(i);const u=function justTextHighlightResult(o){const s={relevance:0,emitter:new L.__emitter(L),value:Se(o),illegal:!1,top:j};return s.emitter.addText(o),s}(o),_=s.filter(getLanguage).filter(autoDetection).map((s=>_highlight(s,o,!1)));_.unshift(u);const w=_.sort(((o,s)=>{if(o.relevance!==s.relevance)return s.relevance-o.relevance;if(o.language&&s.language){if(getLanguage(o.language).supersetOf===s.language)return 1;if(getLanguage(s.language).supersetOf===o.language)return-1}return 0})),[x,C]=w,B=x;return B.second_best=C,B}const B={"before:highlightElement":({el:o})=>{L.useBR&&(o.innerHTML=o.innerHTML.replace(/\n/g,"").replace(//g,"\n"))},"after:highlightElement":({result:o})=>{L.useBR&&(o.value=o.value.replace(/\n/g,"
"))}},$=/^(<[^>]+>|\t)+/gm,V={"after:highlightElement":({result:o})=>{L.tabReplace&&(o.value=o.value.replace($,(o=>o.replace(/\t/g,L.tabReplace))))}};function highlightElement(o){let s=null;const i=function blockLanguage(o){let s=o.className+" ";s+=o.parentNode?o.parentNode.className:"";const i=L.languageDetectRe.exec(s);if(i){const s=getLanguage(i[1]);return s||(warn(C.replace("{}",i[1])),warn("Falling back to no-highlight mode for this block.",o)),s?i[1]:"no-highlight"}return s.split(/\s+/).find((o=>shouldNotHighlight(o)||getLanguage(o)))}(o);if(shouldNotHighlight(i))return;fire("before:highlightElement",{el:o,language:i}),s=o;const _=s.textContent,w=i?highlight(_,{language:i,ignoreIllegals:!0}):highlightAuto(_);fire("after:highlightElement",{el:o,result:w,text:_}),o.innerHTML=w.value,function updateClassName(o,s,i){const _=s?u[s]:i;o.classList.add("hljs"),_&&o.classList.add(_)}(o,i,w.language),o.result={language:w.language,re:w.relevance,relavance:w.relevance},w.second_best&&(o.second_best={language:w.second_best.language,re:w.second_best.relevance,relavance:w.second_best.relevance})}const initHighlighting=()=>{if(initHighlighting.called)return;initHighlighting.called=!0,deprecated("10.6.0","initHighlighting() is deprecated. Use highlightAll() instead.");document.querySelectorAll("pre code").forEach(highlightElement)};let U=!1;function highlightAll(){if("loading"===document.readyState)return void(U=!0);document.querySelectorAll("pre code").forEach(highlightElement)}function getLanguage(o){return o=(o||"").toLowerCase(),i[o]||i[u[o]]}function registerAliases(o,{languageName:s}){"string"==typeof o&&(o=[o]),o.forEach((o=>{u[o.toLowerCase()]=s}))}function autoDetection(o){const s=getLanguage(o);return s&&!s.disableAutodetect}function fire(o,s){const i=o;_.forEach((function(o){o[i]&&o[i](s)}))}"undefined"!=typeof window&&window.addEventListener&&window.addEventListener("DOMContentLoaded",(function boot(){U&&highlightAll()}),!1),Object.assign(o,{highlight,highlightAuto,highlightAll,fixMarkup:function deprecateFixMarkup(o){return deprecated("10.2.0","fixMarkup will be removed entirely in v11.0"),deprecated("10.2.0","Please see https://github.com/highlightjs/highlight.js/issues/2534"),function fixMarkup(o){return L.tabReplace||L.useBR?o.replace(x,(o=>"\n"===o?L.useBR?"
":o:L.tabReplace?o.replace(/\t/g,L.tabReplace):o)):o}(o)},highlightElement,highlightBlock:function deprecateHighlightBlock(o){return deprecated("10.7.0","highlightBlock will be removed entirely in v12.0"),deprecated("10.7.0","Please use highlightElement now."),highlightElement(o)},configure:function configure(o){o.useBR&&(deprecated("10.3.0","'useBR' will be removed entirely in v11.0"),deprecated("10.3.0","Please see https://github.com/highlightjs/highlight.js/issues/2559")),L=xe(L,o)},initHighlighting,initHighlightingOnLoad:function initHighlightingOnLoad(){deprecated("10.6.0","initHighlightingOnLoad() is deprecated. Use highlightAll() instead."),U=!0},registerLanguage:function registerLanguage(s,u){let _=null;try{_=u(o)}catch(o){if(error("Language definition for '{}' could not be registered.".replace("{}",s)),!w)throw o;error(o),_=j}_.name||(_.name=s),i[s]=_,_.rawDefinition=u.bind(null,o),_.aliases&®isterAliases(_.aliases,{languageName:s})},unregisterLanguage:function unregisterLanguage(o){delete i[o];for(const s of Object.keys(u))u[s]===o&&delete u[s]},listLanguages:function listLanguages(){return Object.keys(i)},getLanguage,registerAliases,requireLanguage:function requireLanguage(o){deprecated("10.4.0","requireLanguage will be removed entirely in v11."),deprecated("10.4.0","Please see https://github.com/highlightjs/highlight.js/pull/2844");const s=getLanguage(o);if(s)return s;throw new Error("The '{}' language is required, but not loaded.".replace("{}",o))},autoDetection,inherit:xe,addPlugin:function addPlugin(o){!function upgradePluginAPI(o){o["before:highlightBlock"]&&!o["before:highlightElement"]&&(o["before:highlightElement"]=s=>{o["before:highlightBlock"](Object.assign({block:s.el},s))}),o["after:highlightBlock"]&&!o["after:highlightElement"]&&(o["after:highlightElement"]=s=>{o["after:highlightBlock"](Object.assign({block:s.el},s))})}(o),_.push(o)},vuePlugin:BuildVuePlugin(o).VuePlugin}),o.debugMode=function(){w=!1},o.safeMode=function(){w=!0},o.versionString="10.7.3";for(const o in fe)"object"==typeof fe[o]&&s(fe[o]);return Object.assign(o,fe),o.addPlugin(B),o.addPlugin(_e),o.addPlugin(V),o}({});o.exports=Te},35344:o=>{function concat(...o){return o.map((o=>function source(o){return o?"string"==typeof o?o:o.source:null}(o))).join("")}o.exports=function bash(o){const s={},i={begin:/\$\{/,end:/\}/,contains:["self",{begin:/:-/,contains:[s]}]};Object.assign(s,{className:"variable",variants:[{begin:concat(/\$[\w\d#@][\w\d_]*/,"(?![\\w\\d])(?![$])")},i]});const u={className:"subst",begin:/\$\(/,end:/\)/,contains:[o.BACKSLASH_ESCAPE]},_={begin:/<<-?\s*(?=\w+)/,starts:{contains:[o.END_SAME_AS_BEGIN({begin:/(\w+)/,end:/(\w+)/,className:"string"})]}},w={className:"string",begin:/"/,end:/"/,contains:[o.BACKSLASH_ESCAPE,s,u]};u.contains.push(w);const x={begin:/\$\(\(/,end:/\)\)/,contains:[{begin:/\d+#[0-9a-f]+/,className:"number"},o.NUMBER_MODE,s]},C=o.SHEBANG({binary:`(${["fish","bash","zsh","sh","csh","ksh","tcsh","dash","scsh"].join("|")})`,relevance:10}),j={className:"function",begin:/\w[\w\d_]*\s*\(\s*\)\s*\{/,returnBegin:!0,contains:[o.inherit(o.TITLE_MODE,{begin:/\w[\w\d_]*/})],relevance:0};return{name:"Bash",aliases:["sh","zsh"],keywords:{$pattern:/\b[a-z._-]+\b/,keyword:"if then else elif fi for while in do done case esac function",literal:"true false",built_in:"break cd continue eval exec exit export getopts hash pwd readonly return shift test times trap umask unset alias bind builtin caller command declare echo enable help let local logout mapfile printf read readarray source type typeset ulimit unalias set shopt autoload bg bindkey bye cap chdir clone comparguments compcall compctl compdescribe compfiles compgroups compquote comptags comptry compvalues dirs disable disown echotc echoti emulate fc fg float functions getcap getln history integer jobs kill limit log noglob popd print pushd pushln rehash sched setcap setopt stat suspend ttyctl unfunction unhash unlimit unsetopt vared wait whence where which zcompile zformat zftp zle zmodload zparseopts zprof zpty zregexparse zsocket zstyle ztcp"},contains:[C,o.SHEBANG(),j,x,o.HASH_COMMENT_MODE,_,w,{className:"",begin:/\\"/},{className:"string",begin:/'/,end:/'/},s]}}},73402:o=>{function concat(...o){return o.map((o=>function source(o){return o?"string"==typeof o?o:o.source:null}(o))).join("")}o.exports=function http(o){const s="HTTP/(2|1\\.[01])",i={className:"attribute",begin:concat("^",/[A-Za-z][A-Za-z0-9-]*/,"(?=\\:\\s)"),starts:{contains:[{className:"punctuation",begin:/: /,relevance:0,starts:{end:"$",relevance:0}}]}},u=[i,{begin:"\\n\\n",starts:{subLanguage:[],endsWithParent:!0}}];return{name:"HTTP",aliases:["https"],illegal:/\S/,contains:[{begin:"^(?="+s+" \\d{3})",end:/$/,contains:[{className:"meta",begin:s},{className:"number",begin:"\\b\\d{3}\\b"}],starts:{end:/\b\B/,illegal:/\S/,contains:u}},{begin:"(?=^[A-Z]+ (.*?) "+s+"$)",end:/$/,contains:[{className:"string",begin:" ",end:" ",excludeBegin:!0,excludeEnd:!0},{className:"meta",begin:s},{className:"keyword",begin:"[A-Z]+"}],starts:{end:/\b\B/,illegal:/\S/,contains:u}},o.inherit(i,{relevance:0})]}}},95089:o=>{const s="[A-Za-z$_][0-9A-Za-z$_]*",i=["as","in","of","if","for","while","finally","var","new","function","do","return","void","else","break","catch","instanceof","with","throw","case","default","try","switch","continue","typeof","delete","let","yield","const","class","debugger","async","await","static","import","from","export","extends"],u=["true","false","null","undefined","NaN","Infinity"],_=[].concat(["setInterval","setTimeout","clearInterval","clearTimeout","require","exports","eval","isFinite","isNaN","parseFloat","parseInt","decodeURI","decodeURIComponent","encodeURI","encodeURIComponent","escape","unescape"],["arguments","this","super","console","window","document","localStorage","module","global"],["Intl","DataView","Number","Math","Date","String","RegExp","Object","Function","Boolean","Error","Symbol","Set","Map","WeakSet","WeakMap","Proxy","Reflect","JSON","Promise","Float64Array","Int16Array","Int32Array","Int8Array","Uint16Array","Uint32Array","Float32Array","Array","Uint8Array","Uint8ClampedArray","ArrayBuffer","BigInt64Array","BigUint64Array","BigInt"],["EvalError","InternalError","RangeError","ReferenceError","SyntaxError","TypeError","URIError"]);function lookahead(o){return concat("(?=",o,")")}function concat(...o){return o.map((o=>function source(o){return o?"string"==typeof o?o:o.source:null}(o))).join("")}o.exports=function javascript(o){const w=s,x="<>",C="",j={begin:/<[A-Za-z0-9\\._:-]+/,end:/\/[A-Za-z0-9\\._:-]+>|\/>/,isTrulyOpeningTag:(o,s)=>{const i=o[0].length+o.index,u=o.input[i];"<"!==u?">"===u&&(((o,{after:s})=>{const i="",returnBegin:!0,end:"\\s*=>",contains:[{className:"params",variants:[{begin:o.UNDERSCORE_IDENT_RE,relevance:0},{className:null,begin:/\(\s*\)/,skip:!0},{begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:L,contains:le}]}]},{begin:/,/,relevance:0},{className:"",begin:/\s/,end:/\s*/,skip:!0},{variants:[{begin:x,end:C},{begin:j.begin,"on:begin":j.isTrulyOpeningTag,end:j.end}],subLanguage:"xml",contains:[{begin:j.begin,end:j.end,skip:!0,contains:["self"]}]}],relevance:0},{className:"function",beginKeywords:"function",end:/[{;]/,excludeEnd:!0,keywords:L,contains:["self",o.inherit(o.TITLE_MODE,{begin:w}),pe],illegal:/%/},{beginKeywords:"while if switch catch for"},{className:"function",begin:o.UNDERSCORE_IDENT_RE+"\\([^()]*(\\([^()]*(\\([^()]*\\)[^()]*)*\\)[^()]*)*\\)\\s*\\{",returnBegin:!0,contains:[pe,o.inherit(o.TITLE_MODE,{begin:w})]},{variants:[{begin:"\\."+w},{begin:"\\$"+w}],relevance:0},{className:"class",beginKeywords:"class",end:/[{;=]/,excludeEnd:!0,illegal:/[:"[\]]/,contains:[{beginKeywords:"extends"},o.UNDERSCORE_TITLE_MODE]},{begin:/\b(?=constructor)/,end:/[{;]/,excludeEnd:!0,contains:[o.inherit(o.TITLE_MODE,{begin:w}),"self",pe]},{begin:"(get|set)\\s+(?="+w+"\\()",end:/\{/,keywords:"get set",contains:[o.inherit(o.TITLE_MODE,{begin:w}),{begin:/\(\)/},pe]},{begin:/\$[(.]/}]}}},65772:o=>{o.exports=function json(o){const s={literal:"true false null"},i=[o.C_LINE_COMMENT_MODE,o.C_BLOCK_COMMENT_MODE],u=[o.QUOTE_STRING_MODE,o.C_NUMBER_MODE],_={end:",",endsWithParent:!0,excludeEnd:!0,contains:u,keywords:s},w={begin:/\{/,end:/\}/,contains:[{className:"attr",begin:/"/,end:/"/,contains:[o.BACKSLASH_ESCAPE],illegal:"\\n"},o.inherit(_,{begin:/:/})].concat(i),illegal:"\\S"},x={begin:"\\[",end:"\\]",contains:[o.inherit(_)],illegal:"\\S"};return u.push(w,x),i.forEach((function(o){u.push(o)})),{name:"JSON",contains:u,keywords:s,illegal:"\\S"}}},26571:o=>{o.exports=function powershell(o){const s={$pattern:/-?[A-z\.\-]+\b/,keyword:"if else foreach return do while until elseif begin for trap data dynamicparam end break throw param continue finally in switch exit filter try process catch hidden static parameter",built_in:"ac asnp cat cd CFS chdir clc clear clhy cli clp cls clv cnsn compare copy cp cpi cpp curl cvpa dbp del diff dir dnsn ebp echo|0 epal epcsv epsn erase etsn exsn fc fhx fl ft fw gal gbp gc gcb gci gcm gcs gdr gerr ghy gi gin gjb gl gm gmo gp gps gpv group gsn gsnp gsv gtz gu gv gwmi h history icm iex ihy ii ipal ipcsv ipmo ipsn irm ise iwmi iwr kill lp ls man md measure mi mount move mp mv nal ndr ni nmo npssc nsn nv ogv oh popd ps pushd pwd r rbp rcjb rcsn rd rdr ren ri rjb rm rmdir rmo rni rnp rp rsn rsnp rujb rv rvpa rwmi sajb sal saps sasv sbp sc scb select set shcm si sl sleep sls sort sp spjb spps spsv start stz sujb sv swmi tee trcm type wget where wjb write"},i={begin:"`[\\s\\S]",relevance:0},u={className:"variable",variants:[{begin:/\$\B/},{className:"keyword",begin:/\$this/},{begin:/\$[\w\d][\w\d_:]*/}]},_={className:"string",variants:[{begin:/"/,end:/"/},{begin:/@"/,end:/^"@/}],contains:[i,u,{className:"variable",begin:/\$[A-z]/,end:/[^A-z]/}]},w={className:"string",variants:[{begin:/'/,end:/'/},{begin:/@'/,end:/^'@/}]},x=o.inherit(o.COMMENT(null,null),{variants:[{begin:/#/,end:/$/},{begin:/<#/,end:/#>/}],contains:[{className:"doctag",variants:[{begin:/\.(synopsis|description|example|inputs|outputs|notes|link|component|role|functionality)/},{begin:/\.(parameter|forwardhelptargetname|forwardhelpcategory|remotehelprunspace|externalhelp)\s+\S+/}]}]}),C={className:"built_in",variants:[{begin:"(".concat("Add|Clear|Close|Copy|Enter|Exit|Find|Format|Get|Hide|Join|Lock|Move|New|Open|Optimize|Pop|Push|Redo|Remove|Rename|Reset|Resize|Search|Select|Set|Show|Skip|Split|Step|Switch|Undo|Unlock|Watch|Backup|Checkpoint|Compare|Compress|Convert|ConvertFrom|ConvertTo|Dismount|Edit|Expand|Export|Group|Import|Initialize|Limit|Merge|Mount|Out|Publish|Restore|Save|Sync|Unpublish|Update|Approve|Assert|Build|Complete|Confirm|Deny|Deploy|Disable|Enable|Install|Invoke|Register|Request|Restart|Resume|Start|Stop|Submit|Suspend|Uninstall|Unregister|Wait|Debug|Measure|Ping|Repair|Resolve|Test|Trace|Connect|Disconnect|Read|Receive|Send|Write|Block|Grant|Protect|Revoke|Unblock|Unprotect|Use|ForEach|Sort|Tee|Where",")+(-)[\\w\\d]+")}]},j={className:"class",beginKeywords:"class enum",end:/\s*[{]/,excludeEnd:!0,relevance:0,contains:[o.TITLE_MODE]},L={className:"function",begin:/function\s+/,end:/\s*\{|$/,excludeEnd:!0,returnBegin:!0,relevance:0,contains:[{begin:"function",relevance:0,className:"keyword"},{className:"title",begin:/\w[\w\d]*((-)[\w\d]+)*/,relevance:0},{begin:/\(/,end:/\)/,className:"params",relevance:0,contains:[u]}]},B={begin:/using\s/,end:/$/,returnBegin:!0,contains:[_,w,{className:"keyword",begin:/(using|assembly|command|module|namespace|type)/}]},$={variants:[{className:"operator",begin:"(".concat("-and|-as|-band|-bnot|-bor|-bxor|-casesensitive|-ccontains|-ceq|-cge|-cgt|-cle|-clike|-clt|-cmatch|-cne|-cnotcontains|-cnotlike|-cnotmatch|-contains|-creplace|-csplit|-eq|-exact|-f|-file|-ge|-gt|-icontains|-ieq|-ige|-igt|-ile|-ilike|-ilt|-imatch|-in|-ine|-inotcontains|-inotlike|-inotmatch|-ireplace|-is|-isnot|-isplit|-join|-le|-like|-lt|-match|-ne|-not|-notcontains|-notin|-notlike|-notmatch|-or|-regex|-replace|-shl|-shr|-split|-wildcard|-xor",")\\b")},{className:"literal",begin:/(-)[\w\d]+/,relevance:0}]},V={className:"function",begin:/\[.*\]\s*[\w]+[ ]??\(/,end:/$/,returnBegin:!0,relevance:0,contains:[{className:"keyword",begin:"(".concat(s.keyword.toString().replace(/\s/g,"|"),")\\b"),endsParent:!0,relevance:0},o.inherit(o.TITLE_MODE,{endsParent:!0})]},U=[V,x,i,o.NUMBER_MODE,_,w,C,u,{className:"literal",begin:/\$(null|true|false)\b/},{className:"selector-tag",begin:/@\B/,relevance:0}],z={begin:/\[/,end:/\]/,excludeBegin:!0,excludeEnd:!0,relevance:0,contains:[].concat("self",U,{begin:"("+["string","char","byte","int","long","bool","decimal","single","double","DateTime","xml","array","hashtable","void"].join("|")+")",className:"built_in",relevance:0},{className:"type",begin:/[\.\w\d]+/,relevance:0})};return V.contains.unshift(z),{name:"PowerShell",aliases:["ps","ps1"],case_insensitive:!0,keywords:s,contains:U.concat(j,L,B,$,z)}}},17285:o=>{function source(o){return o?"string"==typeof o?o:o.source:null}function lookahead(o){return concat("(?=",o,")")}function concat(...o){return o.map((o=>source(o))).join("")}function either(...o){return"("+o.map((o=>source(o))).join("|")+")"}o.exports=function xml(o){const s=concat(/[A-Z_]/,function optional(o){return concat("(",o,")?")}(/[A-Z0-9_.-]*:/),/[A-Z0-9_.-]*/),i={className:"symbol",begin:/&[a-z]+;|&#[0-9]+;|&#x[a-f0-9]+;/},u={begin:/\s/,contains:[{className:"meta-keyword",begin:/#?[a-z_][a-z1-9_-]+/,illegal:/\n/}]},_=o.inherit(u,{begin:/\(/,end:/\)/}),w=o.inherit(o.APOS_STRING_MODE,{className:"meta-string"}),x=o.inherit(o.QUOTE_STRING_MODE,{className:"meta-string"}),C={endsWithParent:!0,illegal:/`]+/}]}]}]};return{name:"HTML, XML",aliases:["html","xhtml","rss","atom","xjb","xsd","xsl","plist","wsf","svg"],case_insensitive:!0,contains:[{className:"meta",begin://,relevance:10,contains:[u,x,w,_,{begin:/\[/,end:/\]/,contains:[{className:"meta",begin://,contains:[u,_,x,w]}]}]},o.COMMENT(//,{relevance:10}),{begin://,relevance:10},i,{className:"meta",begin:/<\?xml/,end:/\?>/,relevance:10},{className:"tag",begin:/)/,end:/>/,keywords:{name:"style"},contains:[C],starts:{end:/<\/style>/,returnEnd:!0,subLanguage:["css","xml"]}},{className:"tag",begin:/)/,end:/>/,keywords:{name:"script"},contains:[C],starts:{end:/<\/script>/,returnEnd:!0,subLanguage:["javascript","handlebars","xml"]}},{className:"tag",begin:/<>|<\/>/},{className:"tag",begin:concat(//,/>/,/\s/)))),end:/\/?>/,contains:[{className:"name",begin:s,relevance:0,starts:C}]},{className:"tag",begin:concat(/<\//,lookahead(concat(s,/>/))),contains:[{className:"name",begin:s,relevance:0},{begin:/>/,relevance:0,endsParent:!0}]}]}}},17533:o=>{o.exports=function yaml(o){var s="true false yes no null",i="[\\w#;/?:@&=+$,.~*'()[\\]]+",u={className:"string",relevance:0,variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/\S+/}],contains:[o.BACKSLASH_ESCAPE,{className:"template-variable",variants:[{begin:/\{\{/,end:/\}\}/},{begin:/%\{/,end:/\}/}]}]},_=o.inherit(u,{variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/[^\s,{}[\]]+/}]}),w={className:"number",begin:"\\b[0-9]{4}(-[0-9][0-9]){0,2}([Tt \\t][0-9][0-9]?(:[0-9][0-9]){2})?(\\.[0-9]*)?([ \\t])*(Z|[-+][0-9][0-9]?(:[0-9][0-9])?)?\\b"},x={end:",",endsWithParent:!0,excludeEnd:!0,keywords:s,relevance:0},C={begin:/\{/,end:/\}/,contains:[x],illegal:"\\n",relevance:0},j={begin:"\\[",end:"\\]",contains:[x],illegal:"\\n",relevance:0},L=[{className:"attr",variants:[{begin:"\\w[\\w :\\/.-]*:(?=[ \t]|$)"},{begin:'"\\w[\\w :\\/.-]*":(?=[ \t]|$)'},{begin:"'\\w[\\w :\\/.-]*':(?=[ \t]|$)"}]},{className:"meta",begin:"^---\\s*$",relevance:10},{className:"string",begin:"[\\|>]([1-9]?[+-])?[ ]*\\n( +)[^ ][^\\n]*\\n(\\2[^\\n]+\\n?)*"},{begin:"<%[%=-]?",end:"[%-]?%>",subLanguage:"ruby",excludeBegin:!0,excludeEnd:!0,relevance:0},{className:"type",begin:"!\\w+!"+i},{className:"type",begin:"!<"+i+">"},{className:"type",begin:"!"+i},{className:"type",begin:"!!"+i},{className:"meta",begin:"&"+o.UNDERSCORE_IDENT_RE+"$"},{className:"meta",begin:"\\*"+o.UNDERSCORE_IDENT_RE+"$"},{className:"bullet",begin:"-(?=[ ]|$)",relevance:0},o.HASH_COMMENT_MODE,{beginKeywords:s,keywords:{literal:s}},w,{className:"number",begin:o.C_NUMBER_RE+"\\b",relevance:0},C,j,u],B=[...L];return B.pop(),B.push(_),x.contains=B,{name:"YAML",case_insensitive:!0,aliases:["yml"],contains:L}}},251:(o,s)=>{s.read=function(o,s,i,u,_){var w,x,C=8*_-u-1,j=(1<>1,B=-7,$=i?_-1:0,V=i?-1:1,U=o[s+$];for($+=V,w=U&(1<<-B)-1,U>>=-B,B+=C;B>0;w=256*w+o[s+$],$+=V,B-=8);for(x=w&(1<<-B)-1,w>>=-B,B+=u;B>0;x=256*x+o[s+$],$+=V,B-=8);if(0===w)w=1-L;else{if(w===j)return x?NaN:1/0*(U?-1:1);x+=Math.pow(2,u),w-=L}return(U?-1:1)*x*Math.pow(2,w-u)},s.write=function(o,s,i,u,_,w){var x,C,j,L=8*w-_-1,B=(1<>1,V=23===_?Math.pow(2,-24)-Math.pow(2,-77):0,U=u?0:w-1,z=u?1:-1,Y=s<0||0===s&&1/s<0?1:0;for(s=Math.abs(s),isNaN(s)||s===1/0?(C=isNaN(s)?1:0,x=B):(x=Math.floor(Math.log(s)/Math.LN2),s*(j=Math.pow(2,-x))<1&&(x--,j*=2),(s+=x+$>=1?V/j:V*Math.pow(2,1-$))*j>=2&&(x++,j/=2),x+$>=B?(C=0,x=B):x+$>=1?(C=(s*j-1)*Math.pow(2,_),x+=$):(C=s*Math.pow(2,$-1)*Math.pow(2,_),x=0));_>=8;o[i+U]=255&C,U+=z,C/=256,_-=8);for(x=x<<_|C,L+=_;L>0;o[i+U]=255&x,U+=z,x/=256,L-=8);o[i+U-z]|=128*Y}},9404:function(o){o.exports=function(){"use strict";var o=Array.prototype.slice;function createClass(o,s){s&&(o.prototype=Object.create(s.prototype)),o.prototype.constructor=o}function Iterable(o){return isIterable(o)?o:Seq(o)}function KeyedIterable(o){return isKeyed(o)?o:KeyedSeq(o)}function IndexedIterable(o){return isIndexed(o)?o:IndexedSeq(o)}function SetIterable(o){return isIterable(o)&&!isAssociative(o)?o:SetSeq(o)}function isIterable(o){return!(!o||!o[s])}function isKeyed(o){return!(!o||!o[i])}function isIndexed(o){return!(!o||!o[u])}function isAssociative(o){return isKeyed(o)||isIndexed(o)}function isOrdered(o){return!(!o||!o[_])}createClass(KeyedIterable,Iterable),createClass(IndexedIterable,Iterable),createClass(SetIterable,Iterable),Iterable.isIterable=isIterable,Iterable.isKeyed=isKeyed,Iterable.isIndexed=isIndexed,Iterable.isAssociative=isAssociative,Iterable.isOrdered=isOrdered,Iterable.Keyed=KeyedIterable,Iterable.Indexed=IndexedIterable,Iterable.Set=SetIterable;var s="@@__IMMUTABLE_ITERABLE__@@",i="@@__IMMUTABLE_KEYED__@@",u="@@__IMMUTABLE_INDEXED__@@",_="@@__IMMUTABLE_ORDERED__@@",w="delete",x=5,C=1<>>0;if(""+i!==s||4294967295===i)return NaN;s=i}return s<0?ensureSize(o)+s:s}function returnTrue(){return!0}function wholeSlice(o,s,i){return(0===o||void 0!==i&&o<=-i)&&(void 0===s||void 0!==i&&s>=i)}function resolveBegin(o,s){return resolveIndex(o,s,0)}function resolveEnd(o,s){return resolveIndex(o,s,s)}function resolveIndex(o,s,i){return void 0===o?i:o<0?Math.max(0,s+o):void 0===s?o:Math.min(s,o)}var V=0,U=1,z=2,Y="function"==typeof Symbol&&Symbol.iterator,Z="@@iterator",ee=Y||Z;function Iterator(o){this.next=o}function iteratorValue(o,s,i,u){var _=0===o?s:1===o?i:[s,i];return u?u.value=_:u={value:_,done:!1},u}function iteratorDone(){return{value:void 0,done:!0}}function hasIterator(o){return!!getIteratorFn(o)}function isIterator(o){return o&&"function"==typeof o.next}function getIterator(o){var s=getIteratorFn(o);return s&&s.call(o)}function getIteratorFn(o){var s=o&&(Y&&o[Y]||o[Z]);if("function"==typeof s)return s}function isArrayLike(o){return o&&"number"==typeof o.length}function Seq(o){return null==o?emptySequence():isIterable(o)?o.toSeq():seqFromValue(o)}function KeyedSeq(o){return null==o?emptySequence().toKeyedSeq():isIterable(o)?isKeyed(o)?o.toSeq():o.fromEntrySeq():keyedSeqFromValue(o)}function IndexedSeq(o){return null==o?emptySequence():isIterable(o)?isKeyed(o)?o.entrySeq():o.toIndexedSeq():indexedSeqFromValue(o)}function SetSeq(o){return(null==o?emptySequence():isIterable(o)?isKeyed(o)?o.entrySeq():o:indexedSeqFromValue(o)).toSetSeq()}Iterator.prototype.toString=function(){return"[Iterator]"},Iterator.KEYS=V,Iterator.VALUES=U,Iterator.ENTRIES=z,Iterator.prototype.inspect=Iterator.prototype.toSource=function(){return this.toString()},Iterator.prototype[ee]=function(){return this},createClass(Seq,Iterable),Seq.of=function(){return Seq(arguments)},Seq.prototype.toSeq=function(){return this},Seq.prototype.toString=function(){return this.__toString("Seq {","}")},Seq.prototype.cacheResult=function(){return!this._cache&&this.__iterateUncached&&(this._cache=this.entrySeq().toArray(),this.size=this._cache.length),this},Seq.prototype.__iterate=function(o,s){return seqIterate(this,o,s,!0)},Seq.prototype.__iterator=function(o,s){return seqIterator(this,o,s,!0)},createClass(KeyedSeq,Seq),KeyedSeq.prototype.toKeyedSeq=function(){return this},createClass(IndexedSeq,Seq),IndexedSeq.of=function(){return IndexedSeq(arguments)},IndexedSeq.prototype.toIndexedSeq=function(){return this},IndexedSeq.prototype.toString=function(){return this.__toString("Seq [","]")},IndexedSeq.prototype.__iterate=function(o,s){return seqIterate(this,o,s,!1)},IndexedSeq.prototype.__iterator=function(o,s){return seqIterator(this,o,s,!1)},createClass(SetSeq,Seq),SetSeq.of=function(){return SetSeq(arguments)},SetSeq.prototype.toSetSeq=function(){return this},Seq.isSeq=isSeq,Seq.Keyed=KeyedSeq,Seq.Set=SetSeq,Seq.Indexed=IndexedSeq;var ie,ae,ce,le="@@__IMMUTABLE_SEQ__@@";function ArraySeq(o){this._array=o,this.size=o.length}function ObjectSeq(o){var s=Object.keys(o);this._object=o,this._keys=s,this.size=s.length}function IterableSeq(o){this._iterable=o,this.size=o.length||o.size}function IteratorSeq(o){this._iterator=o,this._iteratorCache=[]}function isSeq(o){return!(!o||!o[le])}function emptySequence(){return ie||(ie=new ArraySeq([]))}function keyedSeqFromValue(o){var s=Array.isArray(o)?new ArraySeq(o).fromEntrySeq():isIterator(o)?new IteratorSeq(o).fromEntrySeq():hasIterator(o)?new IterableSeq(o).fromEntrySeq():"object"==typeof o?new ObjectSeq(o):void 0;if(!s)throw new TypeError("Expected Array or iterable object of [k, v] entries, or keyed object: "+o);return s}function indexedSeqFromValue(o){var s=maybeIndexedSeqFromValue(o);if(!s)throw new TypeError("Expected Array or iterable object of values: "+o);return s}function seqFromValue(o){var s=maybeIndexedSeqFromValue(o)||"object"==typeof o&&new ObjectSeq(o);if(!s)throw new TypeError("Expected Array or iterable object of values, or keyed object: "+o);return s}function maybeIndexedSeqFromValue(o){return isArrayLike(o)?new ArraySeq(o):isIterator(o)?new IteratorSeq(o):hasIterator(o)?new IterableSeq(o):void 0}function seqIterate(o,s,i,u){var _=o._cache;if(_){for(var w=_.length-1,x=0;x<=w;x++){var C=_[i?w-x:x];if(!1===s(C[1],u?C[0]:x,o))return x+1}return x}return o.__iterateUncached(s,i)}function seqIterator(o,s,i,u){var _=o._cache;if(_){var w=_.length-1,x=0;return new Iterator((function(){var o=_[i?w-x:x];return x++>w?iteratorDone():iteratorValue(s,u?o[0]:x-1,o[1])}))}return o.__iteratorUncached(s,i)}function fromJS(o,s){return s?fromJSWith(s,o,"",{"":o}):fromJSDefault(o)}function fromJSWith(o,s,i,u){return Array.isArray(s)?o.call(u,i,IndexedSeq(s).map((function(i,u){return fromJSWith(o,i,u,s)}))):isPlainObj(s)?o.call(u,i,KeyedSeq(s).map((function(i,u){return fromJSWith(o,i,u,s)}))):s}function fromJSDefault(o){return Array.isArray(o)?IndexedSeq(o).map(fromJSDefault).toList():isPlainObj(o)?KeyedSeq(o).map(fromJSDefault).toMap():o}function isPlainObj(o){return o&&(o.constructor===Object||void 0===o.constructor)}function is(o,s){if(o===s||o!=o&&s!=s)return!0;if(!o||!s)return!1;if("function"==typeof o.valueOf&&"function"==typeof s.valueOf){if((o=o.valueOf())===(s=s.valueOf())||o!=o&&s!=s)return!0;if(!o||!s)return!1}return!("function"!=typeof o.equals||"function"!=typeof s.equals||!o.equals(s))}function deepEqual(o,s){if(o===s)return!0;if(!isIterable(s)||void 0!==o.size&&void 0!==s.size&&o.size!==s.size||void 0!==o.__hash&&void 0!==s.__hash&&o.__hash!==s.__hash||isKeyed(o)!==isKeyed(s)||isIndexed(o)!==isIndexed(s)||isOrdered(o)!==isOrdered(s))return!1;if(0===o.size&&0===s.size)return!0;var i=!isAssociative(o);if(isOrdered(o)){var u=o.entries();return s.every((function(o,s){var _=u.next().value;return _&&is(_[1],o)&&(i||is(_[0],s))}))&&u.next().done}var _=!1;if(void 0===o.size)if(void 0===s.size)"function"==typeof o.cacheResult&&o.cacheResult();else{_=!0;var w=o;o=s,s=w}var x=!0,C=s.__iterate((function(s,u){if(i?!o.has(s):_?!is(s,o.get(u,L)):!is(o.get(u,L),s))return x=!1,!1}));return x&&o.size===C}function Repeat(o,s){if(!(this instanceof Repeat))return new Repeat(o,s);if(this._value=o,this.size=void 0===s?1/0:Math.max(0,s),0===this.size){if(ae)return ae;ae=this}}function invariant(o,s){if(!o)throw new Error(s)}function Range(o,s,i){if(!(this instanceof Range))return new Range(o,s,i);if(invariant(0!==i,"Cannot step a Range by 0"),o=o||0,void 0===s&&(s=1/0),i=void 0===i?1:Math.abs(i),su?iteratorDone():iteratorValue(o,_,i[s?u-_++:_++])}))},createClass(ObjectSeq,KeyedSeq),ObjectSeq.prototype.get=function(o,s){return void 0===s||this.has(o)?this._object[o]:s},ObjectSeq.prototype.has=function(o){return this._object.hasOwnProperty(o)},ObjectSeq.prototype.__iterate=function(o,s){for(var i=this._object,u=this._keys,_=u.length-1,w=0;w<=_;w++){var x=u[s?_-w:w];if(!1===o(i[x],x,this))return w+1}return w},ObjectSeq.prototype.__iterator=function(o,s){var i=this._object,u=this._keys,_=u.length-1,w=0;return new Iterator((function(){var x=u[s?_-w:w];return w++>_?iteratorDone():iteratorValue(o,x,i[x])}))},ObjectSeq.prototype[_]=!0,createClass(IterableSeq,IndexedSeq),IterableSeq.prototype.__iterateUncached=function(o,s){if(s)return this.cacheResult().__iterate(o,s);var i=getIterator(this._iterable),u=0;if(isIterator(i))for(var _;!(_=i.next()).done&&!1!==o(_.value,u++,this););return u},IterableSeq.prototype.__iteratorUncached=function(o,s){if(s)return this.cacheResult().__iterator(o,s);var i=getIterator(this._iterable);if(!isIterator(i))return new Iterator(iteratorDone);var u=0;return new Iterator((function(){var s=i.next();return s.done?s:iteratorValue(o,u++,s.value)}))},createClass(IteratorSeq,IndexedSeq),IteratorSeq.prototype.__iterateUncached=function(o,s){if(s)return this.cacheResult().__iterate(o,s);for(var i,u=this._iterator,_=this._iteratorCache,w=0;w<_.length;)if(!1===o(_[w],w++,this))return w;for(;!(i=u.next()).done;){var x=i.value;if(_[w]=x,!1===o(x,w++,this))break}return w},IteratorSeq.prototype.__iteratorUncached=function(o,s){if(s)return this.cacheResult().__iterator(o,s);var i=this._iterator,u=this._iteratorCache,_=0;return new Iterator((function(){if(_>=u.length){var s=i.next();if(s.done)return s;u[_]=s.value}return iteratorValue(o,_,u[_++])}))},createClass(Repeat,IndexedSeq),Repeat.prototype.toString=function(){return 0===this.size?"Repeat []":"Repeat [ "+this._value+" "+this.size+" times ]"},Repeat.prototype.get=function(o,s){return this.has(o)?this._value:s},Repeat.prototype.includes=function(o){return is(this._value,o)},Repeat.prototype.slice=function(o,s){var i=this.size;return wholeSlice(o,s,i)?this:new Repeat(this._value,resolveEnd(s,i)-resolveBegin(o,i))},Repeat.prototype.reverse=function(){return this},Repeat.prototype.indexOf=function(o){return is(this._value,o)?0:-1},Repeat.prototype.lastIndexOf=function(o){return is(this._value,o)?this.size:-1},Repeat.prototype.__iterate=function(o,s){for(var i=0;i=0&&s=0&&ii?iteratorDone():iteratorValue(o,w++,x)}))},Range.prototype.equals=function(o){return o instanceof Range?this._start===o._start&&this._end===o._end&&this._step===o._step:deepEqual(this,o)},createClass(Collection,Iterable),createClass(KeyedCollection,Collection),createClass(IndexedCollection,Collection),createClass(SetCollection,Collection),Collection.Keyed=KeyedCollection,Collection.Indexed=IndexedCollection,Collection.Set=SetCollection;var pe="function"==typeof Math.imul&&-2===Math.imul(4294967295,2)?Math.imul:function imul(o,s){var i=65535&(o|=0),u=65535&(s|=0);return i*u+((o>>>16)*u+i*(s>>>16)<<16>>>0)|0};function smi(o){return o>>>1&1073741824|3221225471&o}function hash(o){if(!1===o||null==o)return 0;if("function"==typeof o.valueOf&&(!1===(o=o.valueOf())||null==o))return 0;if(!0===o)return 1;var s=typeof o;if("number"===s){if(o!=o||o===1/0)return 0;var i=0|o;for(i!==o&&(i^=4294967295*o);o>4294967295;)i^=o/=4294967295;return smi(i)}if("string"===s)return o.length>Se?cachedHashString(o):hashString(o);if("function"==typeof o.hashCode)return o.hashCode();if("object"===s)return hashJSObj(o);if("function"==typeof o.toString)return hashString(o.toString());throw new Error("Value type "+s+" cannot be hashed.")}function cachedHashString(o){var s=Te[o];return void 0===s&&(s=hashString(o),Pe===xe&&(Pe=0,Te={}),Pe++,Te[o]=s),s}function hashString(o){for(var s=0,i=0;i0)switch(o.nodeType){case 1:return o.uniqueID;case 9:return o.documentElement&&o.documentElement.uniqueID}}var ye,be="function"==typeof WeakMap;be&&(ye=new WeakMap);var _e=0,we="__immutablehash__";"function"==typeof Symbol&&(we=Symbol(we));var Se=16,xe=255,Pe=0,Te={};function assertNotInfinite(o){invariant(o!==1/0,"Cannot perform this action with an infinite size.")}function Map(o){return null==o?emptyMap():isMap(o)&&!isOrdered(o)?o:emptyMap().withMutations((function(s){var i=KeyedIterable(o);assertNotInfinite(i.size),i.forEach((function(o,i){return s.set(i,o)}))}))}function isMap(o){return!(!o||!o[qe])}createClass(Map,KeyedCollection),Map.of=function(){var s=o.call(arguments,0);return emptyMap().withMutations((function(o){for(var i=0;i=s.length)throw new Error("Missing value for key: "+s[i]);o.set(s[i],s[i+1])}}))},Map.prototype.toString=function(){return this.__toString("Map {","}")},Map.prototype.get=function(o,s){return this._root?this._root.get(0,void 0,o,s):s},Map.prototype.set=function(o,s){return updateMap(this,o,s)},Map.prototype.setIn=function(o,s){return this.updateIn(o,L,(function(){return s}))},Map.prototype.remove=function(o){return updateMap(this,o,L)},Map.prototype.deleteIn=function(o){return this.updateIn(o,(function(){return L}))},Map.prototype.update=function(o,s,i){return 1===arguments.length?o(this):this.updateIn([o],s,i)},Map.prototype.updateIn=function(o,s,i){i||(i=s,s=void 0);var u=updateInDeepMap(this,forceIterator(o),s,i);return u===L?void 0:u},Map.prototype.clear=function(){return 0===this.size?this:this.__ownerID?(this.size=0,this._root=null,this.__hash=void 0,this.__altered=!0,this):emptyMap()},Map.prototype.merge=function(){return mergeIntoMapWith(this,void 0,arguments)},Map.prototype.mergeWith=function(s){return mergeIntoMapWith(this,s,o.call(arguments,1))},Map.prototype.mergeIn=function(s){var i=o.call(arguments,1);return this.updateIn(s,emptyMap(),(function(o){return"function"==typeof o.merge?o.merge.apply(o,i):i[i.length-1]}))},Map.prototype.mergeDeep=function(){return mergeIntoMapWith(this,deepMerger,arguments)},Map.prototype.mergeDeepWith=function(s){var i=o.call(arguments,1);return mergeIntoMapWith(this,deepMergerWith(s),i)},Map.prototype.mergeDeepIn=function(s){var i=o.call(arguments,1);return this.updateIn(s,emptyMap(),(function(o){return"function"==typeof o.mergeDeep?o.mergeDeep.apply(o,i):i[i.length-1]}))},Map.prototype.sort=function(o){return OrderedMap(sortFactory(this,o))},Map.prototype.sortBy=function(o,s){return OrderedMap(sortFactory(this,s,o))},Map.prototype.withMutations=function(o){var s=this.asMutable();return o(s),s.wasAltered()?s.__ensureOwner(this.__ownerID):this},Map.prototype.asMutable=function(){return this.__ownerID?this:this.__ensureOwner(new OwnerID)},Map.prototype.asImmutable=function(){return this.__ensureOwner()},Map.prototype.wasAltered=function(){return this.__altered},Map.prototype.__iterator=function(o,s){return new MapIterator(this,o,s)},Map.prototype.__iterate=function(o,s){var i=this,u=0;return this._root&&this._root.iterate((function(s){return u++,o(s[1],s[0],i)}),s),u},Map.prototype.__ensureOwner=function(o){return o===this.__ownerID?this:o?makeMap(this.size,this._root,o,this.__hash):(this.__ownerID=o,this.__altered=!1,this)},Map.isMap=isMap;var Re,qe="@@__IMMUTABLE_MAP__@@",$e=Map.prototype;function ArrayMapNode(o,s){this.ownerID=o,this.entries=s}function BitmapIndexedNode(o,s,i){this.ownerID=o,this.bitmap=s,this.nodes=i}function HashArrayMapNode(o,s,i){this.ownerID=o,this.count=s,this.nodes=i}function HashCollisionNode(o,s,i){this.ownerID=o,this.keyHash=s,this.entries=i}function ValueNode(o,s,i){this.ownerID=o,this.keyHash=s,this.entry=i}function MapIterator(o,s,i){this._type=s,this._reverse=i,this._stack=o._root&&mapIteratorFrame(o._root)}function mapIteratorValue(o,s){return iteratorValue(o,s[0],s[1])}function mapIteratorFrame(o,s){return{node:o,index:0,__prev:s}}function makeMap(o,s,i,u){var _=Object.create($e);return _.size=o,_._root=s,_.__ownerID=i,_.__hash=u,_.__altered=!1,_}function emptyMap(){return Re||(Re=makeMap(0))}function updateMap(o,s,i){var u,_;if(o._root){var w=MakeRef(B),x=MakeRef($);if(u=updateNode(o._root,o.__ownerID,0,void 0,s,i,w,x),!x.value)return o;_=o.size+(w.value?i===L?-1:1:0)}else{if(i===L)return o;_=1,u=new ArrayMapNode(o.__ownerID,[[s,i]])}return o.__ownerID?(o.size=_,o._root=u,o.__hash=void 0,o.__altered=!0,o):u?makeMap(_,u):emptyMap()}function updateNode(o,s,i,u,_,w,x,C){return o?o.update(s,i,u,_,w,x,C):w===L?o:(SetRef(C),SetRef(x),new ValueNode(s,u,[_,w]))}function isLeafNode(o){return o.constructor===ValueNode||o.constructor===HashCollisionNode}function mergeIntoNode(o,s,i,u,_){if(o.keyHash===u)return new HashCollisionNode(s,u,[o.entry,_]);var w,C=(0===i?o.keyHash:o.keyHash>>>i)&j,L=(0===i?u:u>>>i)&j;return new BitmapIndexedNode(s,1<>>=1)x[j]=1&i?s[w++]:void 0;return x[u]=_,new HashArrayMapNode(o,w+1,x)}function mergeIntoMapWith(o,s,i){for(var u=[],_=0;_>1&1431655765))+(o>>2&858993459))+(o>>4)&252645135,o+=o>>8,127&(o+=o>>16)}function setIn(o,s,i,u){var _=u?o:arrCopy(o);return _[s]=i,_}function spliceIn(o,s,i,u){var _=o.length+1;if(u&&s+1===_)return o[s]=i,o;for(var w=new Array(_),x=0,C=0;C<_;C++)C===s?(w[C]=i,x=-1):w[C]=o[C+x];return w}function spliceOut(o,s,i){var u=o.length-1;if(i&&s===u)return o.pop(),o;for(var _=new Array(u),w=0,x=0;x=ze)return createNodes(o,j,u,_);var U=o&&o===this.ownerID,z=U?j:arrCopy(j);return V?C?B===$-1?z.pop():z[B]=z.pop():z[B]=[u,_]:z.push([u,_]),U?(this.entries=z,this):new ArrayMapNode(o,z)}},BitmapIndexedNode.prototype.get=function(o,s,i,u){void 0===s&&(s=hash(i));var _=1<<((0===o?s:s>>>o)&j),w=this.bitmap;return w&_?this.nodes[popCount(w&_-1)].get(o+x,s,i,u):u},BitmapIndexedNode.prototype.update=function(o,s,i,u,_,w,C){void 0===i&&(i=hash(u));var B=(0===s?i:i>>>s)&j,$=1<=We)return expandNodes(o,Y,V,B,ee);if(U&&!ee&&2===Y.length&&isLeafNode(Y[1^z]))return Y[1^z];if(U&&ee&&1===Y.length&&isLeafNode(ee))return ee;var ie=o&&o===this.ownerID,ae=U?ee?V:V^$:V|$,ce=U?ee?setIn(Y,z,ee,ie):spliceOut(Y,z,ie):spliceIn(Y,z,ee,ie);return ie?(this.bitmap=ae,this.nodes=ce,this):new BitmapIndexedNode(o,ae,ce)},HashArrayMapNode.prototype.get=function(o,s,i,u){void 0===s&&(s=hash(i));var _=(0===o?s:s>>>o)&j,w=this.nodes[_];return w?w.get(o+x,s,i,u):u},HashArrayMapNode.prototype.update=function(o,s,i,u,_,w,C){void 0===i&&(i=hash(u));var B=(0===s?i:i>>>s)&j,$=_===L,V=this.nodes,U=V[B];if($&&!U)return this;var z=updateNode(U,o,s+x,i,u,_,w,C);if(z===U)return this;var Y=this.count;if(U){if(!z&&--Y0&&u=0&&o>>s&j;if(u>=this.array.length)return new VNode([],o);var _,w=0===u;if(s>0){var C=this.array[u];if((_=C&&C.removeBefore(o,s-x,i))===C&&w)return this}if(w&&!_)return this;var L=editableVNode(this,o);if(!w)for(var B=0;B>>s&j;if(_>=this.array.length)return this;if(s>0){var w=this.array[_];if((u=w&&w.removeAfter(o,s-x,i))===w&&_===this.array.length-1)return this}var C=editableVNode(this,o);return C.array.splice(_+1),u&&(C.array[_]=u),C};var Qe,et,tt={};function iterateList(o,s){var i=o._origin,u=o._capacity,_=getTailOffset(u),w=o._tail;return iterateNodeOrLeaf(o._root,o._level,0);function iterateNodeOrLeaf(o,s,i){return 0===s?iterateLeaf(o,i):iterateNode(o,s,i)}function iterateLeaf(o,x){var j=x===_?w&&w.array:o&&o.array,L=x>i?0:i-x,B=u-x;return B>C&&(B=C),function(){if(L===B)return tt;var o=s?--B:L++;return j&&j[o]}}function iterateNode(o,_,w){var j,L=o&&o.array,B=w>i?0:i-w>>_,$=1+(u-w>>_);return $>C&&($=C),function(){for(;;){if(j){var o=j();if(o!==tt)return o;j=null}if(B===$)return tt;var i=s?--$:B++;j=iterateNodeOrLeaf(L&&L[i],_-x,w+(i<<_))}}}}function makeList(o,s,i,u,_,w,x){var C=Object.create(Xe);return C.size=s-o,C._origin=o,C._capacity=s,C._level=i,C._root=u,C._tail=_,C.__ownerID=w,C.__hash=x,C.__altered=!1,C}function emptyList(){return Qe||(Qe=makeList(0,0,x))}function updateList(o,s,i){if((s=wrapIndex(o,s))!=s)return o;if(s>=o.size||s<0)return o.withMutations((function(o){s<0?setListBounds(o,s).set(0,i):setListBounds(o,0,s+1).set(s,i)}));s+=o._origin;var u=o._tail,_=o._root,w=MakeRef($);return s>=getTailOffset(o._capacity)?u=updateVNode(u,o.__ownerID,0,s,i,w):_=updateVNode(_,o.__ownerID,o._level,s,i,w),w.value?o.__ownerID?(o._root=_,o._tail=u,o.__hash=void 0,o.__altered=!0,o):makeList(o._origin,o._capacity,o._level,_,u):o}function updateVNode(o,s,i,u,_,w){var C,L=u>>>i&j,B=o&&L0){var $=o&&o.array[L],V=updateVNode($,s,i-x,u,_,w);return V===$?o:((C=editableVNode(o,s)).array[L]=V,C)}return B&&o.array[L]===_?o:(SetRef(w),C=editableVNode(o,s),void 0===_&&L===C.array.length-1?C.array.pop():C.array[L]=_,C)}function editableVNode(o,s){return s&&o&&s===o.ownerID?o:new VNode(o?o.array.slice():[],s)}function listNodeFor(o,s){if(s>=getTailOffset(o._capacity))return o._tail;if(s<1<0;)i=i.array[s>>>u&j],u-=x;return i}}function setListBounds(o,s,i){void 0!==s&&(s|=0),void 0!==i&&(i|=0);var u=o.__ownerID||new OwnerID,_=o._origin,w=o._capacity,C=_+s,L=void 0===i?w:i<0?w+i:_+i;if(C===_&&L===w)return o;if(C>=L)return o.clear();for(var B=o._level,$=o._root,V=0;C+V<0;)$=new VNode($&&$.array.length?[void 0,$]:[],u),V+=1<<(B+=x);V&&(C+=V,_+=V,L+=V,w+=V);for(var U=getTailOffset(w),z=getTailOffset(L);z>=1<U?new VNode([],u):Y;if(Y&&z>U&&Cx;ie-=x){var ae=U>>>ie&j;ee=ee.array[ae]=editableVNode(ee.array[ae],u)}ee.array[U>>>x&j]=Y}if(L=z)C-=z,L-=z,B=x,$=null,Z=Z&&Z.removeBefore(u,0,C);else if(C>_||z>>B&j;if(ce!==z>>>B&j)break;ce&&(V+=(1<_&&($=$.removeBefore(u,B,C-V)),$&&z_&&(_=C.size),isIterable(x)||(C=C.map((function(o){return fromJS(o)}))),u.push(C)}return _>o.size&&(o=o.setSize(_)),mergeIntoCollectionWith(o,s,u)}function getTailOffset(o){return o>>x<=C&&x.size>=2*w.size?(u=(_=x.filter((function(o,s){return void 0!==o&&j!==s}))).toKeyedSeq().map((function(o){return o[0]})).flip().toMap(),o.__ownerID&&(u.__ownerID=_.__ownerID=o.__ownerID)):(u=w.remove(s),_=j===x.size-1?x.pop():x.set(j,void 0))}else if(B){if(i===x.get(j)[1])return o;u=w,_=x.set(j,[s,i])}else u=w.set(s,x.size),_=x.set(x.size,[s,i]);return o.__ownerID?(o.size=u.size,o._map=u,o._list=_,o.__hash=void 0,o):makeOrderedMap(u,_)}function ToKeyedSequence(o,s){this._iter=o,this._useKeys=s,this.size=o.size}function ToIndexedSequence(o){this._iter=o,this.size=o.size}function ToSetSequence(o){this._iter=o,this.size=o.size}function FromEntriesSequence(o){this._iter=o,this.size=o.size}function flipFactory(o){var s=makeSequence(o);return s._iter=o,s.size=o.size,s.flip=function(){return o},s.reverse=function(){var s=o.reverse.apply(this);return s.flip=function(){return o.reverse()},s},s.has=function(s){return o.includes(s)},s.includes=function(s){return o.has(s)},s.cacheResult=cacheResultThrough,s.__iterateUncached=function(s,i){var u=this;return o.__iterate((function(o,i){return!1!==s(i,o,u)}),i)},s.__iteratorUncached=function(s,i){if(s===z){var u=o.__iterator(s,i);return new Iterator((function(){var o=u.next();if(!o.done){var s=o.value[0];o.value[0]=o.value[1],o.value[1]=s}return o}))}return o.__iterator(s===U?V:U,i)},s}function mapFactory(o,s,i){var u=makeSequence(o);return u.size=o.size,u.has=function(s){return o.has(s)},u.get=function(u,_){var w=o.get(u,L);return w===L?_:s.call(i,w,u,o)},u.__iterateUncached=function(u,_){var w=this;return o.__iterate((function(o,_,x){return!1!==u(s.call(i,o,_,x),_,w)}),_)},u.__iteratorUncached=function(u,_){var w=o.__iterator(z,_);return new Iterator((function(){var _=w.next();if(_.done)return _;var x=_.value,C=x[0];return iteratorValue(u,C,s.call(i,x[1],C,o),_)}))},u}function reverseFactory(o,s){var i=makeSequence(o);return i._iter=o,i.size=o.size,i.reverse=function(){return o},o.flip&&(i.flip=function(){var s=flipFactory(o);return s.reverse=function(){return o.flip()},s}),i.get=function(i,u){return o.get(s?i:-1-i,u)},i.has=function(i){return o.has(s?i:-1-i)},i.includes=function(s){return o.includes(s)},i.cacheResult=cacheResultThrough,i.__iterate=function(s,i){var u=this;return o.__iterate((function(o,i){return s(o,i,u)}),!i)},i.__iterator=function(s,i){return o.__iterator(s,!i)},i}function filterFactory(o,s,i,u){var _=makeSequence(o);return u&&(_.has=function(u){var _=o.get(u,L);return _!==L&&!!s.call(i,_,u,o)},_.get=function(u,_){var w=o.get(u,L);return w!==L&&s.call(i,w,u,o)?w:_}),_.__iterateUncached=function(_,w){var x=this,C=0;return o.__iterate((function(o,w,j){if(s.call(i,o,w,j))return C++,_(o,u?w:C-1,x)}),w),C},_.__iteratorUncached=function(_,w){var x=o.__iterator(z,w),C=0;return new Iterator((function(){for(;;){var w=x.next();if(w.done)return w;var j=w.value,L=j[0],B=j[1];if(s.call(i,B,L,o))return iteratorValue(_,u?L:C++,B,w)}}))},_}function countByFactory(o,s,i){var u=Map().asMutable();return o.__iterate((function(_,w){u.update(s.call(i,_,w,o),0,(function(o){return o+1}))})),u.asImmutable()}function groupByFactory(o,s,i){var u=isKeyed(o),_=(isOrdered(o)?OrderedMap():Map()).asMutable();o.__iterate((function(w,x){_.update(s.call(i,w,x,o),(function(o){return(o=o||[]).push(u?[x,w]:w),o}))}));var w=iterableClass(o);return _.map((function(s){return reify(o,w(s))}))}function sliceFactory(o,s,i,u){var _=o.size;if(void 0!==s&&(s|=0),void 0!==i&&(i===1/0?i=_:i|=0),wholeSlice(s,i,_))return o;var w=resolveBegin(s,_),x=resolveEnd(i,_);if(w!=w||x!=x)return sliceFactory(o.toSeq().cacheResult(),s,i,u);var C,j=x-w;j==j&&(C=j<0?0:j);var L=makeSequence(o);return L.size=0===C?C:o.size&&C||void 0,!u&&isSeq(o)&&C>=0&&(L.get=function(s,i){return(s=wrapIndex(this,s))>=0&&sC)return iteratorDone();var o=_.next();return u||s===U?o:iteratorValue(s,j-1,s===V?void 0:o.value[1],o)}))},L}function takeWhileFactory(o,s,i){var u=makeSequence(o);return u.__iterateUncached=function(u,_){var w=this;if(_)return this.cacheResult().__iterate(u,_);var x=0;return o.__iterate((function(o,_,C){return s.call(i,o,_,C)&&++x&&u(o,_,w)})),x},u.__iteratorUncached=function(u,_){var w=this;if(_)return this.cacheResult().__iterator(u,_);var x=o.__iterator(z,_),C=!0;return new Iterator((function(){if(!C)return iteratorDone();var o=x.next();if(o.done)return o;var _=o.value,j=_[0],L=_[1];return s.call(i,L,j,w)?u===z?o:iteratorValue(u,j,L,o):(C=!1,iteratorDone())}))},u}function skipWhileFactory(o,s,i,u){var _=makeSequence(o);return _.__iterateUncached=function(_,w){var x=this;if(w)return this.cacheResult().__iterate(_,w);var C=!0,j=0;return o.__iterate((function(o,w,L){if(!C||!(C=s.call(i,o,w,L)))return j++,_(o,u?w:j-1,x)})),j},_.__iteratorUncached=function(_,w){var x=this;if(w)return this.cacheResult().__iterator(_,w);var C=o.__iterator(z,w),j=!0,L=0;return new Iterator((function(){var o,w,B;do{if((o=C.next()).done)return u||_===U?o:iteratorValue(_,L++,_===V?void 0:o.value[1],o);var $=o.value;w=$[0],B=$[1],j&&(j=s.call(i,B,w,x))}while(j);return _===z?o:iteratorValue(_,w,B,o)}))},_}function concatFactory(o,s){var i=isKeyed(o),u=[o].concat(s).map((function(o){return isIterable(o)?i&&(o=KeyedIterable(o)):o=i?keyedSeqFromValue(o):indexedSeqFromValue(Array.isArray(o)?o:[o]),o})).filter((function(o){return 0!==o.size}));if(0===u.length)return o;if(1===u.length){var _=u[0];if(_===o||i&&isKeyed(_)||isIndexed(o)&&isIndexed(_))return _}var w=new ArraySeq(u);return i?w=w.toKeyedSeq():isIndexed(o)||(w=w.toSetSeq()),(w=w.flatten(!0)).size=u.reduce((function(o,s){if(void 0!==o){var i=s.size;if(void 0!==i)return o+i}}),0),w}function flattenFactory(o,s,i){var u=makeSequence(o);return u.__iterateUncached=function(u,_){var w=0,x=!1;function flatDeep(o,C){var j=this;o.__iterate((function(o,_){return(!s||C0}function zipWithFactory(o,s,i){var u=makeSequence(o);return u.size=new ArraySeq(i).map((function(o){return o.size})).min(),u.__iterate=function(o,s){for(var i,u=this.__iterator(U,s),_=0;!(i=u.next()).done&&!1!==o(i.value,_++,this););return _},u.__iteratorUncached=function(o,u){var _=i.map((function(o){return o=Iterable(o),getIterator(u?o.reverse():o)})),w=0,x=!1;return new Iterator((function(){var i;return x||(i=_.map((function(o){return o.next()})),x=i.some((function(o){return o.done}))),x?iteratorDone():iteratorValue(o,w++,s.apply(null,i.map((function(o){return o.value}))))}))},u}function reify(o,s){return isSeq(o)?s:o.constructor(s)}function validateEntry(o){if(o!==Object(o))throw new TypeError("Expected [K, V] tuple: "+o)}function resolveSize(o){return assertNotInfinite(o.size),ensureSize(o)}function iterableClass(o){return isKeyed(o)?KeyedIterable:isIndexed(o)?IndexedIterable:SetIterable}function makeSequence(o){return Object.create((isKeyed(o)?KeyedSeq:isIndexed(o)?IndexedSeq:SetSeq).prototype)}function cacheResultThrough(){return this._iter.cacheResult?(this._iter.cacheResult(),this.size=this._iter.size,this):Seq.prototype.cacheResult.call(this)}function defaultComparator(o,s){return o>s?1:o=0;i--)s={value:arguments[i],next:s};return this.__ownerID?(this.size=o,this._head=s,this.__hash=void 0,this.__altered=!0,this):makeStack(o,s)},Stack.prototype.pushAll=function(o){if(0===(o=IndexedIterable(o)).size)return this;assertNotInfinite(o.size);var s=this.size,i=this._head;return o.reverse().forEach((function(o){s++,i={value:o,next:i}})),this.__ownerID?(this.size=s,this._head=i,this.__hash=void 0,this.__altered=!0,this):makeStack(s,i)},Stack.prototype.pop=function(){return this.slice(1)},Stack.prototype.unshift=function(){return this.push.apply(this,arguments)},Stack.prototype.unshiftAll=function(o){return this.pushAll(o)},Stack.prototype.shift=function(){return this.pop.apply(this,arguments)},Stack.prototype.clear=function(){return 0===this.size?this:this.__ownerID?(this.size=0,this._head=void 0,this.__hash=void 0,this.__altered=!0,this):emptyStack()},Stack.prototype.slice=function(o,s){if(wholeSlice(o,s,this.size))return this;var i=resolveBegin(o,this.size);if(resolveEnd(s,this.size)!==this.size)return IndexedCollection.prototype.slice.call(this,o,s);for(var u=this.size-i,_=this._head;i--;)_=_.next;return this.__ownerID?(this.size=u,this._head=_,this.__hash=void 0,this.__altered=!0,this):makeStack(u,_)},Stack.prototype.__ensureOwner=function(o){return o===this.__ownerID?this:o?makeStack(this.size,this._head,o,this.__hash):(this.__ownerID=o,this.__altered=!1,this)},Stack.prototype.__iterate=function(o,s){if(s)return this.reverse().__iterate(o);for(var i=0,u=this._head;u&&!1!==o(u.value,i++,this);)u=u.next;return i},Stack.prototype.__iterator=function(o,s){if(s)return this.reverse().__iterator(o);var i=0,u=this._head;return new Iterator((function(){if(u){var s=u.value;return u=u.next,iteratorValue(o,i++,s)}return iteratorDone()}))},Stack.isStack=isStack;var ct,lt="@@__IMMUTABLE_STACK__@@",ut=Stack.prototype;function makeStack(o,s,i,u){var _=Object.create(ut);return _.size=o,_._head=s,_.__ownerID=i,_.__hash=u,_.__altered=!1,_}function emptyStack(){return ct||(ct=makeStack(0))}function mixin(o,s){var keyCopier=function(i){o.prototype[i]=s[i]};return Object.keys(s).forEach(keyCopier),Object.getOwnPropertySymbols&&Object.getOwnPropertySymbols(s).forEach(keyCopier),o}ut[lt]=!0,ut.withMutations=$e.withMutations,ut.asMutable=$e.asMutable,ut.asImmutable=$e.asImmutable,ut.wasAltered=$e.wasAltered,Iterable.Iterator=Iterator,mixin(Iterable,{toArray:function(){assertNotInfinite(this.size);var o=new Array(this.size||0);return this.valueSeq().__iterate((function(s,i){o[i]=s})),o},toIndexedSeq:function(){return new ToIndexedSequence(this)},toJS:function(){return this.toSeq().map((function(o){return o&&"function"==typeof o.toJS?o.toJS():o})).__toJS()},toJSON:function(){return this.toSeq().map((function(o){return o&&"function"==typeof o.toJSON?o.toJSON():o})).__toJS()},toKeyedSeq:function(){return new ToKeyedSequence(this,!0)},toMap:function(){return Map(this.toKeyedSeq())},toObject:function(){assertNotInfinite(this.size);var o={};return this.__iterate((function(s,i){o[i]=s})),o},toOrderedMap:function(){return OrderedMap(this.toKeyedSeq())},toOrderedSet:function(){return OrderedSet(isKeyed(this)?this.valueSeq():this)},toSet:function(){return Set(isKeyed(this)?this.valueSeq():this)},toSetSeq:function(){return new ToSetSequence(this)},toSeq:function(){return isIndexed(this)?this.toIndexedSeq():isKeyed(this)?this.toKeyedSeq():this.toSetSeq()},toStack:function(){return Stack(isKeyed(this)?this.valueSeq():this)},toList:function(){return List(isKeyed(this)?this.valueSeq():this)},toString:function(){return"[Iterable]"},__toString:function(o,s){return 0===this.size?o+s:o+" "+this.toSeq().map(this.__toStringMapper).join(", ")+" "+s},concat:function(){return reify(this,concatFactory(this,o.call(arguments,0)))},includes:function(o){return this.some((function(s){return is(s,o)}))},entries:function(){return this.__iterator(z)},every:function(o,s){assertNotInfinite(this.size);var i=!0;return this.__iterate((function(u,_,w){if(!o.call(s,u,_,w))return i=!1,!1})),i},filter:function(o,s){return reify(this,filterFactory(this,o,s,!0))},find:function(o,s,i){var u=this.findEntry(o,s);return u?u[1]:i},forEach:function(o,s){return assertNotInfinite(this.size),this.__iterate(s?o.bind(s):o)},join:function(o){assertNotInfinite(this.size),o=void 0!==o?""+o:",";var s="",i=!0;return this.__iterate((function(u){i?i=!1:s+=o,s+=null!=u?u.toString():""})),s},keys:function(){return this.__iterator(V)},map:function(o,s){return reify(this,mapFactory(this,o,s))},reduce:function(o,s,i){var u,_;return assertNotInfinite(this.size),arguments.length<2?_=!0:u=s,this.__iterate((function(s,w,x){_?(_=!1,u=s):u=o.call(i,u,s,w,x)})),u},reduceRight:function(o,s,i){var u=this.toKeyedSeq().reverse();return u.reduce.apply(u,arguments)},reverse:function(){return reify(this,reverseFactory(this,!0))},slice:function(o,s){return reify(this,sliceFactory(this,o,s,!0))},some:function(o,s){return!this.every(not(o),s)},sort:function(o){return reify(this,sortFactory(this,o))},values:function(){return this.__iterator(U)},butLast:function(){return this.slice(0,-1)},isEmpty:function(){return void 0!==this.size?0===this.size:!this.some((function(){return!0}))},count:function(o,s){return ensureSize(o?this.toSeq().filter(o,s):this)},countBy:function(o,s){return countByFactory(this,o,s)},equals:function(o){return deepEqual(this,o)},entrySeq:function(){var o=this;if(o._cache)return new ArraySeq(o._cache);var s=o.toSeq().map(entryMapper).toIndexedSeq();return s.fromEntrySeq=function(){return o.toSeq()},s},filterNot:function(o,s){return this.filter(not(o),s)},findEntry:function(o,s,i){var u=i;return this.__iterate((function(i,_,w){if(o.call(s,i,_,w))return u=[_,i],!1})),u},findKey:function(o,s){var i=this.findEntry(o,s);return i&&i[0]},findLast:function(o,s,i){return this.toKeyedSeq().reverse().find(o,s,i)},findLastEntry:function(o,s,i){return this.toKeyedSeq().reverse().findEntry(o,s,i)},findLastKey:function(o,s){return this.toKeyedSeq().reverse().findKey(o,s)},first:function(){return this.find(returnTrue)},flatMap:function(o,s){return reify(this,flatMapFactory(this,o,s))},flatten:function(o){return reify(this,flattenFactory(this,o,!0))},fromEntrySeq:function(){return new FromEntriesSequence(this)},get:function(o,s){return this.find((function(s,i){return is(i,o)}),void 0,s)},getIn:function(o,s){for(var i,u=this,_=forceIterator(o);!(i=_.next()).done;){var w=i.value;if((u=u&&u.get?u.get(w,L):L)===L)return s}return u},groupBy:function(o,s){return groupByFactory(this,o,s)},has:function(o){return this.get(o,L)!==L},hasIn:function(o){return this.getIn(o,L)!==L},isSubset:function(o){return o="function"==typeof o.includes?o:Iterable(o),this.every((function(s){return o.includes(s)}))},isSuperset:function(o){return(o="function"==typeof o.isSubset?o:Iterable(o)).isSubset(this)},keyOf:function(o){return this.findKey((function(s){return is(s,o)}))},keySeq:function(){return this.toSeq().map(keyMapper).toIndexedSeq()},last:function(){return this.toSeq().reverse().first()},lastKeyOf:function(o){return this.toKeyedSeq().reverse().keyOf(o)},max:function(o){return maxFactory(this,o)},maxBy:function(o,s){return maxFactory(this,s,o)},min:function(o){return maxFactory(this,o?neg(o):defaultNegComparator)},minBy:function(o,s){return maxFactory(this,s?neg(s):defaultNegComparator,o)},rest:function(){return this.slice(1)},skip:function(o){return this.slice(Math.max(0,o))},skipLast:function(o){return reify(this,this.toSeq().reverse().skip(o).reverse())},skipWhile:function(o,s){return reify(this,skipWhileFactory(this,o,s,!0))},skipUntil:function(o,s){return this.skipWhile(not(o),s)},sortBy:function(o,s){return reify(this,sortFactory(this,s,o))},take:function(o){return this.slice(0,Math.max(0,o))},takeLast:function(o){return reify(this,this.toSeq().reverse().take(o).reverse())},takeWhile:function(o,s){return reify(this,takeWhileFactory(this,o,s))},takeUntil:function(o,s){return this.takeWhile(not(o),s)},valueSeq:function(){return this.toIndexedSeq()},hashCode:function(){return this.__hash||(this.__hash=hashIterable(this))}});var pt=Iterable.prototype;pt[s]=!0,pt[ee]=pt.values,pt.__toJS=pt.toArray,pt.__toStringMapper=quoteString,pt.inspect=pt.toSource=function(){return this.toString()},pt.chain=pt.flatMap,pt.contains=pt.includes,mixin(KeyedIterable,{flip:function(){return reify(this,flipFactory(this))},mapEntries:function(o,s){var i=this,u=0;return reify(this,this.toSeq().map((function(_,w){return o.call(s,[w,_],u++,i)})).fromEntrySeq())},mapKeys:function(o,s){var i=this;return reify(this,this.toSeq().flip().map((function(u,_){return o.call(s,u,_,i)})).flip())}});var ht=KeyedIterable.prototype;function keyMapper(o,s){return s}function entryMapper(o,s){return[s,o]}function not(o){return function(){return!o.apply(this,arguments)}}function neg(o){return function(){return-o.apply(this,arguments)}}function quoteString(o){return"string"==typeof o?JSON.stringify(o):String(o)}function defaultZipper(){return arrCopy(arguments)}function defaultNegComparator(o,s){return os?-1:0}function hashIterable(o){if(o.size===1/0)return 0;var s=isOrdered(o),i=isKeyed(o),u=s?1:0;return murmurHashOfSize(o.__iterate(i?s?function(o,s){u=31*u+hashMerge(hash(o),hash(s))|0}:function(o,s){u=u+hashMerge(hash(o),hash(s))|0}:s?function(o){u=31*u+hash(o)|0}:function(o){u=u+hash(o)|0}),u)}function murmurHashOfSize(o,s){return s=pe(s,3432918353),s=pe(s<<15|s>>>-15,461845907),s=pe(s<<13|s>>>-13,5),s=pe((s=s+3864292196^o)^s>>>16,2246822507),s=smi((s=pe(s^s>>>13,3266489909))^s>>>16)}function hashMerge(o,s){return o^s+2654435769+(o<<6)+(o>>2)}return ht[i]=!0,ht[ee]=pt.entries,ht.__toJS=pt.toObject,ht.__toStringMapper=function(o,s){return JSON.stringify(s)+": "+quoteString(o)},mixin(IndexedIterable,{toKeyedSeq:function(){return new ToKeyedSequence(this,!1)},filter:function(o,s){return reify(this,filterFactory(this,o,s,!1))},findIndex:function(o,s){var i=this.findEntry(o,s);return i?i[0]:-1},indexOf:function(o){var s=this.keyOf(o);return void 0===s?-1:s},lastIndexOf:function(o){var s=this.lastKeyOf(o);return void 0===s?-1:s},reverse:function(){return reify(this,reverseFactory(this,!1))},slice:function(o,s){return reify(this,sliceFactory(this,o,s,!1))},splice:function(o,s){var i=arguments.length;if(s=Math.max(0|s,0),0===i||2===i&&!s)return this;o=resolveBegin(o,o<0?this.count():this.size);var u=this.slice(0,o);return reify(this,1===i?u:u.concat(arrCopy(arguments,2),this.slice(o+s)))},findLastIndex:function(o,s){var i=this.findLastEntry(o,s);return i?i[0]:-1},first:function(){return this.get(0)},flatten:function(o){return reify(this,flattenFactory(this,o,!1))},get:function(o,s){return(o=wrapIndex(this,o))<0||this.size===1/0||void 0!==this.size&&o>this.size?s:this.find((function(s,i){return i===o}),void 0,s)},has:function(o){return(o=wrapIndex(this,o))>=0&&(void 0!==this.size?this.size===1/0||o{"function"==typeof Object.create?o.exports=function inherits(o,s){s&&(o.super_=s,o.prototype=Object.create(s.prototype,{constructor:{value:o,enumerable:!1,writable:!0,configurable:!0}}))}:o.exports=function inherits(o,s){if(s){o.super_=s;var TempCtor=function(){};TempCtor.prototype=s.prototype,o.prototype=new TempCtor,o.prototype.constructor=o}}},5419:o=>{o.exports=function(o,s,i,u){var _=new Blob(void 0!==u?[u,o]:[o],{type:i||"application/octet-stream"});if(void 0!==window.navigator.msSaveBlob)window.navigator.msSaveBlob(_,s);else{var w=window.URL&&window.URL.createObjectURL?window.URL.createObjectURL(_):window.webkitURL.createObjectURL(_),x=document.createElement("a");x.style.display="none",x.href=w,x.setAttribute("download",s),void 0===x.download&&x.setAttribute("target","_blank"),document.body.appendChild(x),x.click(),setTimeout((function(){document.body.removeChild(x),window.URL.revokeObjectURL(w)}),200)}}},20181:(o,s,i)=>{var u=NaN,_="[object Symbol]",w=/^\s+|\s+$/g,x=/^[-+]0x[0-9a-f]+$/i,C=/^0b[01]+$/i,j=/^0o[0-7]+$/i,L=parseInt,B="object"==typeof i.g&&i.g&&i.g.Object===Object&&i.g,$="object"==typeof self&&self&&self.Object===Object&&self,V=B||$||Function("return this")(),U=Object.prototype.toString,z=Math.max,Y=Math.min,now=function(){return V.Date.now()};function isObject(o){var s=typeof o;return!!o&&("object"==s||"function"==s)}function toNumber(o){if("number"==typeof o)return o;if(function isSymbol(o){return"symbol"==typeof o||function isObjectLike(o){return!!o&&"object"==typeof o}(o)&&U.call(o)==_}(o))return u;if(isObject(o)){var s="function"==typeof o.valueOf?o.valueOf():o;o=isObject(s)?s+"":s}if("string"!=typeof o)return 0===o?o:+o;o=o.replace(w,"");var i=C.test(o);return i||j.test(o)?L(o.slice(2),i?2:8):x.test(o)?u:+o}o.exports=function debounce(o,s,i){var u,_,w,x,C,j,L=0,B=!1,$=!1,V=!0;if("function"!=typeof o)throw new TypeError("Expected a function");function invokeFunc(s){var i=u,w=_;return u=_=void 0,L=s,x=o.apply(w,i)}function shouldInvoke(o){var i=o-j;return void 0===j||i>=s||i<0||$&&o-L>=w}function timerExpired(){var o=now();if(shouldInvoke(o))return trailingEdge(o);C=setTimeout(timerExpired,function remainingWait(o){var i=s-(o-j);return $?Y(i,w-(o-L)):i}(o))}function trailingEdge(o){return C=void 0,V&&u?invokeFunc(o):(u=_=void 0,x)}function debounced(){var o=now(),i=shouldInvoke(o);if(u=arguments,_=this,j=o,i){if(void 0===C)return function leadingEdge(o){return L=o,C=setTimeout(timerExpired,s),B?invokeFunc(o):x}(j);if($)return C=setTimeout(timerExpired,s),invokeFunc(j)}return void 0===C&&(C=setTimeout(timerExpired,s)),x}return s=toNumber(s)||0,isObject(i)&&(B=!!i.leading,w=($="maxWait"in i)?z(toNumber(i.maxWait)||0,s):w,V="trailing"in i?!!i.trailing:V),debounced.cancel=function cancel(){void 0!==C&&clearTimeout(C),L=0,u=j=_=C=void 0},debounced.flush=function flush(){return void 0===C?x:trailingEdge(now())},debounced}},55580:(o,s,i)=>{var u=i(56110)(i(9325),"DataView");o.exports=u},21549:(o,s,i)=>{var u=i(22032),_=i(63862),w=i(66721),x=i(12749),C=i(35749);function Hash(o){var s=-1,i=null==o?0:o.length;for(this.clear();++s{var u=i(39344),_=i(94033);function LazyWrapper(o){this.__wrapped__=o,this.__actions__=[],this.__dir__=1,this.__filtered__=!1,this.__iteratees__=[],this.__takeCount__=4294967295,this.__views__=[]}LazyWrapper.prototype=u(_.prototype),LazyWrapper.prototype.constructor=LazyWrapper,o.exports=LazyWrapper},80079:(o,s,i)=>{var u=i(63702),_=i(70080),w=i(24739),x=i(48655),C=i(31175);function ListCache(o){var s=-1,i=null==o?0:o.length;for(this.clear();++s{var u=i(39344),_=i(94033);function LodashWrapper(o,s){this.__wrapped__=o,this.__actions__=[],this.__chain__=!!s,this.__index__=0,this.__values__=void 0}LodashWrapper.prototype=u(_.prototype),LodashWrapper.prototype.constructor=LodashWrapper,o.exports=LodashWrapper},68223:(o,s,i)=>{var u=i(56110)(i(9325),"Map");o.exports=u},53661:(o,s,i)=>{var u=i(63040),_=i(17670),w=i(90289),x=i(4509),C=i(72949);function MapCache(o){var s=-1,i=null==o?0:o.length;for(this.clear();++s{var u=i(56110)(i(9325),"Promise");o.exports=u},76545:(o,s,i)=>{var u=i(56110)(i(9325),"Set");o.exports=u},38859:(o,s,i)=>{var u=i(53661),_=i(31380),w=i(51459);function SetCache(o){var s=-1,i=null==o?0:o.length;for(this.__data__=new u;++s{var u=i(80079),_=i(51420),w=i(90938),x=i(63605),C=i(29817),j=i(80945);function Stack(o){var s=this.__data__=new u(o);this.size=s.size}Stack.prototype.clear=_,Stack.prototype.delete=w,Stack.prototype.get=x,Stack.prototype.has=C,Stack.prototype.set=j,o.exports=Stack},51873:(o,s,i)=>{var u=i(9325).Symbol;o.exports=u},37828:(o,s,i)=>{var u=i(9325).Uint8Array;o.exports=u},28303:(o,s,i)=>{var u=i(56110)(i(9325),"WeakMap");o.exports=u},91033:o=>{o.exports=function apply(o,s,i){switch(i.length){case 0:return o.call(s);case 1:return o.call(s,i[0]);case 2:return o.call(s,i[0],i[1]);case 3:return o.call(s,i[0],i[1],i[2])}return o.apply(s,i)}},83729:o=>{o.exports=function arrayEach(o,s){for(var i=-1,u=null==o?0:o.length;++i{o.exports=function arrayFilter(o,s){for(var i=-1,u=null==o?0:o.length,_=0,w=[];++i{var u=i(96131);o.exports=function arrayIncludes(o,s){return!!(null==o?0:o.length)&&u(o,s,0)>-1}},70695:(o,s,i)=>{var u=i(78096),_=i(72428),w=i(56449),x=i(3656),C=i(30361),j=i(37167),L=Object.prototype.hasOwnProperty;o.exports=function arrayLikeKeys(o,s){var i=w(o),B=!i&&_(o),$=!i&&!B&&x(o),V=!i&&!B&&!$&&j(o),U=i||B||$||V,z=U?u(o.length,String):[],Y=z.length;for(var Z in o)!s&&!L.call(o,Z)||U&&("length"==Z||$&&("offset"==Z||"parent"==Z)||V&&("buffer"==Z||"byteLength"==Z||"byteOffset"==Z)||C(Z,Y))||z.push(Z);return z}},34932:o=>{o.exports=function arrayMap(o,s){for(var i=-1,u=null==o?0:o.length,_=Array(u);++i{o.exports=function arrayPush(o,s){for(var i=-1,u=s.length,_=o.length;++i{o.exports=function arrayReduce(o,s,i,u){var _=-1,w=null==o?0:o.length;for(u&&w&&(i=o[++_]);++_{o.exports=function arraySome(o,s){for(var i=-1,u=null==o?0:o.length;++i{o.exports=function asciiToArray(o){return o.split("")}},1733:o=>{var s=/[^\x00-\x2f\x3a-\x40\x5b-\x60\x7b-\x7f]+/g;o.exports=function asciiWords(o){return o.match(s)||[]}},87805:(o,s,i)=>{var u=i(43360),_=i(75288);o.exports=function assignMergeValue(o,s,i){(void 0!==i&&!_(o[s],i)||void 0===i&&!(s in o))&&u(o,s,i)}},16547:(o,s,i)=>{var u=i(43360),_=i(75288),w=Object.prototype.hasOwnProperty;o.exports=function assignValue(o,s,i){var x=o[s];w.call(o,s)&&_(x,i)&&(void 0!==i||s in o)||u(o,s,i)}},26025:(o,s,i)=>{var u=i(75288);o.exports=function assocIndexOf(o,s){for(var i=o.length;i--;)if(u(o[i][0],s))return i;return-1}},74733:(o,s,i)=>{var u=i(21791),_=i(95950);o.exports=function baseAssign(o,s){return o&&u(s,_(s),o)}},43838:(o,s,i)=>{var u=i(21791),_=i(37241);o.exports=function baseAssignIn(o,s){return o&&u(s,_(s),o)}},43360:(o,s,i)=>{var u=i(93243);o.exports=function baseAssignValue(o,s,i){"__proto__"==s&&u?u(o,s,{configurable:!0,enumerable:!0,value:i,writable:!0}):o[s]=i}},9999:(o,s,i)=>{var u=i(37217),_=i(83729),w=i(16547),x=i(74733),C=i(43838),j=i(93290),L=i(23007),B=i(92271),$=i(48948),V=i(50002),U=i(83349),z=i(5861),Y=i(76189),Z=i(77199),ee=i(35529),ie=i(56449),ae=i(3656),ce=i(87730),le=i(23805),pe=i(38440),de=i(95950),fe=i(37241),ye="[object Arguments]",be="[object Function]",_e="[object Object]",we={};we[ye]=we["[object Array]"]=we["[object ArrayBuffer]"]=we["[object DataView]"]=we["[object Boolean]"]=we["[object Date]"]=we["[object Float32Array]"]=we["[object Float64Array]"]=we["[object Int8Array]"]=we["[object Int16Array]"]=we["[object Int32Array]"]=we["[object Map]"]=we["[object Number]"]=we[_e]=we["[object RegExp]"]=we["[object Set]"]=we["[object String]"]=we["[object Symbol]"]=we["[object Uint8Array]"]=we["[object Uint8ClampedArray]"]=we["[object Uint16Array]"]=we["[object Uint32Array]"]=!0,we["[object Error]"]=we[be]=we["[object WeakMap]"]=!1,o.exports=function baseClone(o,s,i,Se,xe,Pe){var Te,Re=1&s,qe=2&s,$e=4&s;if(i&&(Te=xe?i(o,Se,xe,Pe):i(o)),void 0!==Te)return Te;if(!le(o))return o;var ze=ie(o);if(ze){if(Te=Y(o),!Re)return L(o,Te)}else{var We=z(o),He=We==be||"[object GeneratorFunction]"==We;if(ae(o))return j(o,Re);if(We==_e||We==ye||He&&!xe){if(Te=qe||He?{}:ee(o),!Re)return qe?$(o,C(Te,o)):B(o,x(Te,o))}else{if(!we[We])return xe?o:{};Te=Z(o,We,Re)}}Pe||(Pe=new u);var Ye=Pe.get(o);if(Ye)return Ye;Pe.set(o,Te),pe(o)?o.forEach((function(u){Te.add(baseClone(u,s,i,u,o,Pe))})):ce(o)&&o.forEach((function(u,_){Te.set(_,baseClone(u,s,i,_,o,Pe))}));var Xe=ze?void 0:($e?qe?U:V:qe?fe:de)(o);return _(Xe||o,(function(u,_){Xe&&(u=o[_=u]),w(Te,_,baseClone(u,s,i,_,o,Pe))})),Te}},39344:(o,s,i)=>{var u=i(23805),_=Object.create,w=function(){function object(){}return function(o){if(!u(o))return{};if(_)return _(o);object.prototype=o;var s=new object;return object.prototype=void 0,s}}();o.exports=w},80909:(o,s,i)=>{var u=i(30641),_=i(38329)(u);o.exports=_},2523:o=>{o.exports=function baseFindIndex(o,s,i,u){for(var _=o.length,w=i+(u?1:-1);u?w--:++w<_;)if(s(o[w],w,o))return w;return-1}},83120:(o,s,i)=>{var u=i(14528),_=i(45891);o.exports=function baseFlatten(o,s,i,w,x){var C=-1,j=o.length;for(i||(i=_),x||(x=[]);++C0&&i(L)?s>1?baseFlatten(L,s-1,i,w,x):u(x,L):w||(x[x.length]=L)}return x}},86649:(o,s,i)=>{var u=i(83221)();o.exports=u},30641:(o,s,i)=>{var u=i(86649),_=i(95950);o.exports=function baseForOwn(o,s){return o&&u(o,s,_)}},47422:(o,s,i)=>{var u=i(31769),_=i(77797);o.exports=function baseGet(o,s){for(var i=0,w=(s=u(s,o)).length;null!=o&&i{var u=i(14528),_=i(56449);o.exports=function baseGetAllKeys(o,s,i){var w=s(o);return _(o)?w:u(w,i(o))}},72552:(o,s,i)=>{var u=i(51873),_=i(659),w=i(59350),x=u?u.toStringTag:void 0;o.exports=function baseGetTag(o){return null==o?void 0===o?"[object Undefined]":"[object Null]":x&&x in Object(o)?_(o):w(o)}},20426:o=>{var s=Object.prototype.hasOwnProperty;o.exports=function baseHas(o,i){return null!=o&&s.call(o,i)}},28077:o=>{o.exports=function baseHasIn(o,s){return null!=o&&s in Object(o)}},96131:(o,s,i)=>{var u=i(2523),_=i(85463),w=i(76959);o.exports=function baseIndexOf(o,s,i){return s==s?w(o,s,i):u(o,_,i)}},27534:(o,s,i)=>{var u=i(72552),_=i(40346);o.exports=function baseIsArguments(o){return _(o)&&"[object Arguments]"==u(o)}},60270:(o,s,i)=>{var u=i(87068),_=i(40346);o.exports=function baseIsEqual(o,s,i,w,x){return o===s||(null==o||null==s||!_(o)&&!_(s)?o!=o&&s!=s:u(o,s,i,w,baseIsEqual,x))}},87068:(o,s,i)=>{var u=i(37217),_=i(25911),w=i(21986),x=i(50689),C=i(5861),j=i(56449),L=i(3656),B=i(37167),$="[object Arguments]",V="[object Array]",U="[object Object]",z=Object.prototype.hasOwnProperty;o.exports=function baseIsEqualDeep(o,s,i,Y,Z,ee){var ie=j(o),ae=j(s),ce=ie?V:C(o),le=ae?V:C(s),pe=(ce=ce==$?U:ce)==U,de=(le=le==$?U:le)==U,fe=ce==le;if(fe&&L(o)){if(!L(s))return!1;ie=!0,pe=!1}if(fe&&!pe)return ee||(ee=new u),ie||B(o)?_(o,s,i,Y,Z,ee):w(o,s,ce,i,Y,Z,ee);if(!(1&i)){var ye=pe&&z.call(o,"__wrapped__"),be=de&&z.call(s,"__wrapped__");if(ye||be){var _e=ye?o.value():o,we=be?s.value():s;return ee||(ee=new u),Z(_e,we,i,Y,ee)}}return!!fe&&(ee||(ee=new u),x(o,s,i,Y,Z,ee))}},29172:(o,s,i)=>{var u=i(5861),_=i(40346);o.exports=function baseIsMap(o){return _(o)&&"[object Map]"==u(o)}},41799:(o,s,i)=>{var u=i(37217),_=i(60270);o.exports=function baseIsMatch(o,s,i,w){var x=i.length,C=x,j=!w;if(null==o)return!C;for(o=Object(o);x--;){var L=i[x];if(j&&L[2]?L[1]!==o[L[0]]:!(L[0]in o))return!1}for(;++x{o.exports=function baseIsNaN(o){return o!=o}},45083:(o,s,i)=>{var u=i(1882),_=i(87296),w=i(23805),x=i(47473),C=/^\[object .+?Constructor\]$/,j=Function.prototype,L=Object.prototype,B=j.toString,$=L.hasOwnProperty,V=RegExp("^"+B.call($).replace(/[\\^$.*+?()[\]{}|]/g,"\\$&").replace(/hasOwnProperty|(function).*?(?=\\\()| for .+?(?=\\\])/g,"$1.*?")+"$");o.exports=function baseIsNative(o){return!(!w(o)||_(o))&&(u(o)?V:C).test(x(o))}},16038:(o,s,i)=>{var u=i(5861),_=i(40346);o.exports=function baseIsSet(o){return _(o)&&"[object Set]"==u(o)}},4901:(o,s,i)=>{var u=i(72552),_=i(30294),w=i(40346),x={};x["[object Float32Array]"]=x["[object Float64Array]"]=x["[object Int8Array]"]=x["[object Int16Array]"]=x["[object Int32Array]"]=x["[object Uint8Array]"]=x["[object Uint8ClampedArray]"]=x["[object Uint16Array]"]=x["[object Uint32Array]"]=!0,x["[object Arguments]"]=x["[object Array]"]=x["[object ArrayBuffer]"]=x["[object Boolean]"]=x["[object DataView]"]=x["[object Date]"]=x["[object Error]"]=x["[object Function]"]=x["[object Map]"]=x["[object Number]"]=x["[object Object]"]=x["[object RegExp]"]=x["[object Set]"]=x["[object String]"]=x["[object WeakMap]"]=!1,o.exports=function baseIsTypedArray(o){return w(o)&&_(o.length)&&!!x[u(o)]}},15389:(o,s,i)=>{var u=i(93663),_=i(87978),w=i(83488),x=i(56449),C=i(50583);o.exports=function baseIteratee(o){return"function"==typeof o?o:null==o?w:"object"==typeof o?x(o)?_(o[0],o[1]):u(o):C(o)}},88984:(o,s,i)=>{var u=i(55527),_=i(3650),w=Object.prototype.hasOwnProperty;o.exports=function baseKeys(o){if(!u(o))return _(o);var s=[];for(var i in Object(o))w.call(o,i)&&"constructor"!=i&&s.push(i);return s}},72903:(o,s,i)=>{var u=i(23805),_=i(55527),w=i(90181),x=Object.prototype.hasOwnProperty;o.exports=function baseKeysIn(o){if(!u(o))return w(o);var s=_(o),i=[];for(var C in o)("constructor"!=C||!s&&x.call(o,C))&&i.push(C);return i}},94033:o=>{o.exports=function baseLodash(){}},93663:(o,s,i)=>{var u=i(41799),_=i(10776),w=i(67197);o.exports=function baseMatches(o){var s=_(o);return 1==s.length&&s[0][2]?w(s[0][0],s[0][1]):function(i){return i===o||u(i,o,s)}}},87978:(o,s,i)=>{var u=i(60270),_=i(58156),w=i(80631),x=i(28586),C=i(30756),j=i(67197),L=i(77797);o.exports=function baseMatchesProperty(o,s){return x(o)&&C(s)?j(L(o),s):function(i){var x=_(i,o);return void 0===x&&x===s?w(i,o):u(s,x,3)}}},85250:(o,s,i)=>{var u=i(37217),_=i(87805),w=i(86649),x=i(42824),C=i(23805),j=i(37241),L=i(14974);o.exports=function baseMerge(o,s,i,B,$){o!==s&&w(s,(function(w,j){if($||($=new u),C(w))x(o,s,j,i,baseMerge,B,$);else{var V=B?B(L(o,j),w,j+"",o,s,$):void 0;void 0===V&&(V=w),_(o,j,V)}}),j)}},42824:(o,s,i)=>{var u=i(87805),_=i(93290),w=i(71961),x=i(23007),C=i(35529),j=i(72428),L=i(56449),B=i(83693),$=i(3656),V=i(1882),U=i(23805),z=i(11331),Y=i(37167),Z=i(14974),ee=i(69884);o.exports=function baseMergeDeep(o,s,i,ie,ae,ce,le){var pe=Z(o,i),de=Z(s,i),fe=le.get(de);if(fe)u(o,i,fe);else{var ye=ce?ce(pe,de,i+"",o,s,le):void 0,be=void 0===ye;if(be){var _e=L(de),we=!_e&&$(de),Se=!_e&&!we&&Y(de);ye=de,_e||we||Se?L(pe)?ye=pe:B(pe)?ye=x(pe):we?(be=!1,ye=_(de,!0)):Se?(be=!1,ye=w(de,!0)):ye=[]:z(de)||j(de)?(ye=pe,j(pe)?ye=ee(pe):U(pe)&&!V(pe)||(ye=C(de))):be=!1}be&&(le.set(de,ye),ae(ye,de,ie,ce,le),le.delete(de)),u(o,i,ye)}}},47237:o=>{o.exports=function baseProperty(o){return function(s){return null==s?void 0:s[o]}}},17255:(o,s,i)=>{var u=i(47422);o.exports=function basePropertyDeep(o){return function(s){return u(s,o)}}},54552:o=>{o.exports=function basePropertyOf(o){return function(s){return null==o?void 0:o[s]}}},85558:o=>{o.exports=function baseReduce(o,s,i,u,_){return _(o,(function(o,_,w){i=u?(u=!1,o):s(i,o,_,w)})),i}},69302:(o,s,i)=>{var u=i(83488),_=i(56757),w=i(32865);o.exports=function baseRest(o,s){return w(_(o,s,u),o+"")}},73170:(o,s,i)=>{var u=i(16547),_=i(31769),w=i(30361),x=i(23805),C=i(77797);o.exports=function baseSet(o,s,i,j){if(!x(o))return o;for(var L=-1,B=(s=_(s,o)).length,$=B-1,V=o;null!=V&&++L{var u=i(83488),_=i(48152),w=_?function(o,s){return _.set(o,s),o}:u;o.exports=w},19570:(o,s,i)=>{var u=i(37334),_=i(93243),w=i(83488),x=_?function(o,s){return _(o,"toString",{configurable:!0,enumerable:!1,value:u(s),writable:!0})}:w;o.exports=x},25160:o=>{o.exports=function baseSlice(o,s,i){var u=-1,_=o.length;s<0&&(s=-s>_?0:_+s),(i=i>_?_:i)<0&&(i+=_),_=s>i?0:i-s>>>0,s>>>=0;for(var w=Array(_);++u<_;)w[u]=o[u+s];return w}},90916:(o,s,i)=>{var u=i(80909);o.exports=function baseSome(o,s){var i;return u(o,(function(o,u,_){return!(i=s(o,u,_))})),!!i}},78096:o=>{o.exports=function baseTimes(o,s){for(var i=-1,u=Array(o);++i{var u=i(51873),_=i(34932),w=i(56449),x=i(44394),C=u?u.prototype:void 0,j=C?C.toString:void 0;o.exports=function baseToString(o){if("string"==typeof o)return o;if(w(o))return _(o,baseToString)+"";if(x(o))return j?j.call(o):"";var s=o+"";return"0"==s&&1/o==-1/0?"-0":s}},54128:(o,s,i)=>{var u=i(31800),_=/^\s+/;o.exports=function baseTrim(o){return o?o.slice(0,u(o)+1).replace(_,""):o}},27301:o=>{o.exports=function baseUnary(o){return function(s){return o(s)}}},19931:(o,s,i)=>{var u=i(31769),_=i(68090),w=i(68969),x=i(77797);o.exports=function baseUnset(o,s){return s=u(s,o),null==(o=w(o,s))||delete o[x(_(s))]}},51234:o=>{o.exports=function baseZipObject(o,s,i){for(var u=-1,_=o.length,w=s.length,x={};++u<_;){var C=u{o.exports=function cacheHas(o,s){return o.has(s)}},31769:(o,s,i)=>{var u=i(56449),_=i(28586),w=i(61802),x=i(13222);o.exports=function castPath(o,s){return u(o)?o:_(o,s)?[o]:w(x(o))}},28754:(o,s,i)=>{var u=i(25160);o.exports=function castSlice(o,s,i){var _=o.length;return i=void 0===i?_:i,!s&&i>=_?o:u(o,s,i)}},49653:(o,s,i)=>{var u=i(37828);o.exports=function cloneArrayBuffer(o){var s=new o.constructor(o.byteLength);return new u(s).set(new u(o)),s}},93290:(o,s,i)=>{o=i.nmd(o);var u=i(9325),_=s&&!s.nodeType&&s,w=_&&o&&!o.nodeType&&o,x=w&&w.exports===_?u.Buffer:void 0,C=x?x.allocUnsafe:void 0;o.exports=function cloneBuffer(o,s){if(s)return o.slice();var i=o.length,u=C?C(i):new o.constructor(i);return o.copy(u),u}},76169:(o,s,i)=>{var u=i(49653);o.exports=function cloneDataView(o,s){var i=s?u(o.buffer):o.buffer;return new o.constructor(i,o.byteOffset,o.byteLength)}},73201:o=>{var s=/\w*$/;o.exports=function cloneRegExp(o){var i=new o.constructor(o.source,s.exec(o));return i.lastIndex=o.lastIndex,i}},93736:(o,s,i)=>{var u=i(51873),_=u?u.prototype:void 0,w=_?_.valueOf:void 0;o.exports=function cloneSymbol(o){return w?Object(w.call(o)):{}}},71961:(o,s,i)=>{var u=i(49653);o.exports=function cloneTypedArray(o,s){var i=s?u(o.buffer):o.buffer;return new o.constructor(i,o.byteOffset,o.length)}},91596:o=>{var s=Math.max;o.exports=function composeArgs(o,i,u,_){for(var w=-1,x=o.length,C=u.length,j=-1,L=i.length,B=s(x-C,0),$=Array(L+B),V=!_;++j{var s=Math.max;o.exports=function composeArgsRight(o,i,u,_){for(var w=-1,x=o.length,C=-1,j=u.length,L=-1,B=i.length,$=s(x-j,0),V=Array($+B),U=!_;++w<$;)V[w]=o[w];for(var z=w;++L{o.exports=function copyArray(o,s){var i=-1,u=o.length;for(s||(s=Array(u));++i{var u=i(16547),_=i(43360);o.exports=function copyObject(o,s,i,w){var x=!i;i||(i={});for(var C=-1,j=s.length;++C{var u=i(21791),_=i(4664);o.exports=function copySymbols(o,s){return u(o,_(o),s)}},48948:(o,s,i)=>{var u=i(21791),_=i(86375);o.exports=function copySymbolsIn(o,s){return u(o,_(o),s)}},55481:(o,s,i)=>{var u=i(9325)["__core-js_shared__"];o.exports=u},58523:o=>{o.exports=function countHolders(o,s){for(var i=o.length,u=0;i--;)o[i]===s&&++u;return u}},20999:(o,s,i)=>{var u=i(69302),_=i(36800);o.exports=function createAssigner(o){return u((function(s,i){var u=-1,w=i.length,x=w>1?i[w-1]:void 0,C=w>2?i[2]:void 0;for(x=o.length>3&&"function"==typeof x?(w--,x):void 0,C&&_(i[0],i[1],C)&&(x=w<3?void 0:x,w=1),s=Object(s);++u{var u=i(64894);o.exports=function createBaseEach(o,s){return function(i,_){if(null==i)return i;if(!u(i))return o(i,_);for(var w=i.length,x=s?w:-1,C=Object(i);(s?x--:++x{o.exports=function createBaseFor(o){return function(s,i,u){for(var _=-1,w=Object(s),x=u(s),C=x.length;C--;){var j=x[o?C:++_];if(!1===i(w[j],j,w))break}return s}}},11842:(o,s,i)=>{var u=i(82819),_=i(9325);o.exports=function createBind(o,s,i){var w=1&s,x=u(o);return function wrapper(){return(this&&this!==_&&this instanceof wrapper?x:o).apply(w?i:this,arguments)}}},12507:(o,s,i)=>{var u=i(28754),_=i(49698),w=i(63912),x=i(13222);o.exports=function createCaseFirst(o){return function(s){s=x(s);var i=_(s)?w(s):void 0,C=i?i[0]:s.charAt(0),j=i?u(i,1).join(""):s.slice(1);return C[o]()+j}}},45539:(o,s,i)=>{var u=i(40882),_=i(50828),w=i(66645),x=RegExp("['’]","g");o.exports=function createCompounder(o){return function(s){return u(w(_(s).replace(x,"")),o,"")}}},82819:(o,s,i)=>{var u=i(39344),_=i(23805);o.exports=function createCtor(o){return function(){var s=arguments;switch(s.length){case 0:return new o;case 1:return new o(s[0]);case 2:return new o(s[0],s[1]);case 3:return new o(s[0],s[1],s[2]);case 4:return new o(s[0],s[1],s[2],s[3]);case 5:return new o(s[0],s[1],s[2],s[3],s[4]);case 6:return new o(s[0],s[1],s[2],s[3],s[4],s[5]);case 7:return new o(s[0],s[1],s[2],s[3],s[4],s[5],s[6])}var i=u(o.prototype),w=o.apply(i,s);return _(w)?w:i}}},77078:(o,s,i)=>{var u=i(91033),_=i(82819),w=i(37471),x=i(18073),C=i(11287),j=i(36306),L=i(9325);o.exports=function createCurry(o,s,i){var B=_(o);return function wrapper(){for(var _=arguments.length,$=Array(_),V=_,U=C(wrapper);V--;)$[V]=arguments[V];var z=_<3&&$[0]!==U&&$[_-1]!==U?[]:j($,U);return(_-=z.length){var u=i(15389),_=i(64894),w=i(95950);o.exports=function createFind(o){return function(s,i,x){var C=Object(s);if(!_(s)){var j=u(i,3);s=w(s),i=function(o){return j(C[o],o,C)}}var L=o(s,i,x);return L>-1?C[j?s[L]:L]:void 0}}},37471:(o,s,i)=>{var u=i(91596),_=i(53320),w=i(58523),x=i(82819),C=i(18073),j=i(11287),L=i(68294),B=i(36306),$=i(9325);o.exports=function createHybrid(o,s,i,V,U,z,Y,Z,ee,ie){var ae=128&s,ce=1&s,le=2&s,pe=24&s,de=512&s,fe=le?void 0:x(o);return function wrapper(){for(var ye=arguments.length,be=Array(ye),_e=ye;_e--;)be[_e]=arguments[_e];if(pe)var we=j(wrapper),Se=w(be,we);if(V&&(be=u(be,V,U,pe)),z&&(be=_(be,z,Y,pe)),ye-=Se,pe&&ye1&&be.reverse(),ae&&ee{var u=i(91033),_=i(82819),w=i(9325);o.exports=function createPartial(o,s,i,x){var C=1&s,j=_(o);return function wrapper(){for(var s=-1,_=arguments.length,L=-1,B=x.length,$=Array(B+_),V=this&&this!==w&&this instanceof wrapper?j:o;++L{var u=i(85087),_=i(54641),w=i(70981);o.exports=function createRecurry(o,s,i,x,C,j,L,B,$,V){var U=8&s;s|=U?32:64,4&(s&=~(U?64:32))||(s&=-4);var z=[o,s,C,U?j:void 0,U?L:void 0,U?void 0:j,U?void 0:L,B,$,V],Y=i.apply(void 0,z);return u(o)&&_(Y,z),Y.placeholder=x,w(Y,o,s)}},66977:(o,s,i)=>{var u=i(68882),_=i(11842),w=i(77078),x=i(37471),C=i(24168),j=i(37381),L=i(3209),B=i(54641),$=i(70981),V=i(61489),U=Math.max;o.exports=function createWrap(o,s,i,z,Y,Z,ee,ie){var ae=2&s;if(!ae&&"function"!=typeof o)throw new TypeError("Expected a function");var ce=z?z.length:0;if(ce||(s&=-97,z=Y=void 0),ee=void 0===ee?ee:U(V(ee),0),ie=void 0===ie?ie:V(ie),ce-=Y?Y.length:0,64&s){var le=z,pe=Y;z=Y=void 0}var de=ae?void 0:j(o),fe=[o,s,i,z,Y,le,pe,Z,ee,ie];if(de&&L(fe,de),o=fe[0],s=fe[1],i=fe[2],z=fe[3],Y=fe[4],!(ie=fe[9]=void 0===fe[9]?ae?0:o.length:U(fe[9]-ce,0))&&24&s&&(s&=-25),s&&1!=s)ye=8==s||16==s?w(o,s,ie):32!=s&&33!=s||Y.length?x.apply(void 0,fe):C(o,s,i,z);else var ye=_(o,s,i);return $((de?u:B)(ye,fe),o,s)}},53138:(o,s,i)=>{var u=i(11331);o.exports=function customOmitClone(o){return u(o)?void 0:o}},24647:(o,s,i)=>{var u=i(54552)({À:"A",Á:"A",Â:"A",Ã:"A",Ä:"A",Å:"A",à:"a",á:"a",â:"a",ã:"a",ä:"a",å:"a",Ç:"C",ç:"c",Ð:"D",ð:"d",È:"E",É:"E",Ê:"E",Ë:"E",è:"e",é:"e",ê:"e",ë:"e",Ì:"I",Í:"I",Î:"I",Ï:"I",ì:"i",í:"i",î:"i",ï:"i",Ñ:"N",ñ:"n",Ò:"O",Ó:"O",Ô:"O",Õ:"O",Ö:"O",Ø:"O",ò:"o",ó:"o",ô:"o",õ:"o",ö:"o",ø:"o",Ù:"U",Ú:"U",Û:"U",Ü:"U",ù:"u",ú:"u",û:"u",ü:"u",Ý:"Y",ý:"y",ÿ:"y",Æ:"Ae",æ:"ae",Þ:"Th",þ:"th",ß:"ss",Ā:"A",Ă:"A",Ą:"A",ā:"a",ă:"a",ą:"a",Ć:"C",Ĉ:"C",Ċ:"C",Č:"C",ć:"c",ĉ:"c",ċ:"c",č:"c",Ď:"D",Đ:"D",ď:"d",đ:"d",Ē:"E",Ĕ:"E",Ė:"E",Ę:"E",Ě:"E",ē:"e",ĕ:"e",ė:"e",ę:"e",ě:"e",Ĝ:"G",Ğ:"G",Ġ:"G",Ģ:"G",ĝ:"g",ğ:"g",ġ:"g",ģ:"g",Ĥ:"H",Ħ:"H",ĥ:"h",ħ:"h",Ĩ:"I",Ī:"I",Ĭ:"I",Į:"I",İ:"I",ĩ:"i",ī:"i",ĭ:"i",į:"i",ı:"i",Ĵ:"J",ĵ:"j",Ķ:"K",ķ:"k",ĸ:"k",Ĺ:"L",Ļ:"L",Ľ:"L",Ŀ:"L",Ł:"L",ĺ:"l",ļ:"l",ľ:"l",ŀ:"l",ł:"l",Ń:"N",Ņ:"N",Ň:"N",Ŋ:"N",ń:"n",ņ:"n",ň:"n",ŋ:"n",Ō:"O",Ŏ:"O",Ő:"O",ō:"o",ŏ:"o",ő:"o",Ŕ:"R",Ŗ:"R",Ř:"R",ŕ:"r",ŗ:"r",ř:"r",Ś:"S",Ŝ:"S",Ş:"S",Š:"S",ś:"s",ŝ:"s",ş:"s",š:"s",Ţ:"T",Ť:"T",Ŧ:"T",ţ:"t",ť:"t",ŧ:"t",Ũ:"U",Ū:"U",Ŭ:"U",Ů:"U",Ű:"U",Ų:"U",ũ:"u",ū:"u",ŭ:"u",ů:"u",ű:"u",ų:"u",Ŵ:"W",ŵ:"w",Ŷ:"Y",ŷ:"y",Ÿ:"Y",Ź:"Z",Ż:"Z",Ž:"Z",ź:"z",ż:"z",ž:"z",IJ:"IJ",ij:"ij",Œ:"Oe",œ:"oe",ʼn:"'n",ſ:"s"});o.exports=u},93243:(o,s,i)=>{var u=i(56110),_=function(){try{var o=u(Object,"defineProperty");return o({},"",{}),o}catch(o){}}();o.exports=_},25911:(o,s,i)=>{var u=i(38859),_=i(14248),w=i(19219);o.exports=function equalArrays(o,s,i,x,C,j){var L=1&i,B=o.length,$=s.length;if(B!=$&&!(L&&$>B))return!1;var V=j.get(o),U=j.get(s);if(V&&U)return V==s&&U==o;var z=-1,Y=!0,Z=2&i?new u:void 0;for(j.set(o,s),j.set(s,o);++z{var u=i(51873),_=i(37828),w=i(75288),x=i(25911),C=i(20317),j=i(84247),L=u?u.prototype:void 0,B=L?L.valueOf:void 0;o.exports=function equalByTag(o,s,i,u,L,$,V){switch(i){case"[object DataView]":if(o.byteLength!=s.byteLength||o.byteOffset!=s.byteOffset)return!1;o=o.buffer,s=s.buffer;case"[object ArrayBuffer]":return!(o.byteLength!=s.byteLength||!$(new _(o),new _(s)));case"[object Boolean]":case"[object Date]":case"[object Number]":return w(+o,+s);case"[object Error]":return o.name==s.name&&o.message==s.message;case"[object RegExp]":case"[object String]":return o==s+"";case"[object Map]":var U=C;case"[object Set]":var z=1&u;if(U||(U=j),o.size!=s.size&&!z)return!1;var Y=V.get(o);if(Y)return Y==s;u|=2,V.set(o,s);var Z=x(U(o),U(s),u,L,$,V);return V.delete(o),Z;case"[object Symbol]":if(B)return B.call(o)==B.call(s)}return!1}},50689:(o,s,i)=>{var u=i(50002),_=Object.prototype.hasOwnProperty;o.exports=function equalObjects(o,s,i,w,x,C){var j=1&i,L=u(o),B=L.length;if(B!=u(s).length&&!j)return!1;for(var $=B;$--;){var V=L[$];if(!(j?V in s:_.call(s,V)))return!1}var U=C.get(o),z=C.get(s);if(U&&z)return U==s&&z==o;var Y=!0;C.set(o,s),C.set(s,o);for(var Z=j;++${var u=i(35970),_=i(56757),w=i(32865);o.exports=function flatRest(o){return w(_(o,void 0,u),o+"")}},34840:(o,s,i)=>{var u="object"==typeof i.g&&i.g&&i.g.Object===Object&&i.g;o.exports=u},50002:(o,s,i)=>{var u=i(82199),_=i(4664),w=i(95950);o.exports=function getAllKeys(o){return u(o,w,_)}},83349:(o,s,i)=>{var u=i(82199),_=i(86375),w=i(37241);o.exports=function getAllKeysIn(o){return u(o,w,_)}},37381:(o,s,i)=>{var u=i(48152),_=i(63950),w=u?function(o){return u.get(o)}:_;o.exports=w},62284:(o,s,i)=>{var u=i(84629),_=Object.prototype.hasOwnProperty;o.exports=function getFuncName(o){for(var s=o.name+"",i=u[s],w=_.call(u,s)?i.length:0;w--;){var x=i[w],C=x.func;if(null==C||C==o)return x.name}return s}},11287:o=>{o.exports=function getHolder(o){return o.placeholder}},12651:(o,s,i)=>{var u=i(74218);o.exports=function getMapData(o,s){var i=o.__data__;return u(s)?i["string"==typeof s?"string":"hash"]:i.map}},10776:(o,s,i)=>{var u=i(30756),_=i(95950);o.exports=function getMatchData(o){for(var s=_(o),i=s.length;i--;){var w=s[i],x=o[w];s[i]=[w,x,u(x)]}return s}},56110:(o,s,i)=>{var u=i(45083),_=i(10392);o.exports=function getNative(o,s){var i=_(o,s);return u(i)?i:void 0}},28879:(o,s,i)=>{var u=i(74335)(Object.getPrototypeOf,Object);o.exports=u},659:(o,s,i)=>{var u=i(51873),_=Object.prototype,w=_.hasOwnProperty,x=_.toString,C=u?u.toStringTag:void 0;o.exports=function getRawTag(o){var s=w.call(o,C),i=o[C];try{o[C]=void 0;var u=!0}catch(o){}var _=x.call(o);return u&&(s?o[C]=i:delete o[C]),_}},4664:(o,s,i)=>{var u=i(79770),_=i(63345),w=Object.prototype.propertyIsEnumerable,x=Object.getOwnPropertySymbols,C=x?function(o){return null==o?[]:(o=Object(o),u(x(o),(function(s){return w.call(o,s)})))}:_;o.exports=C},86375:(o,s,i)=>{var u=i(14528),_=i(28879),w=i(4664),x=i(63345),C=Object.getOwnPropertySymbols?function(o){for(var s=[];o;)u(s,w(o)),o=_(o);return s}:x;o.exports=C},5861:(o,s,i)=>{var u=i(55580),_=i(68223),w=i(32804),x=i(76545),C=i(28303),j=i(72552),L=i(47473),B="[object Map]",$="[object Promise]",V="[object Set]",U="[object WeakMap]",z="[object DataView]",Y=L(u),Z=L(_),ee=L(w),ie=L(x),ae=L(C),ce=j;(u&&ce(new u(new ArrayBuffer(1)))!=z||_&&ce(new _)!=B||w&&ce(w.resolve())!=$||x&&ce(new x)!=V||C&&ce(new C)!=U)&&(ce=function(o){var s=j(o),i="[object Object]"==s?o.constructor:void 0,u=i?L(i):"";if(u)switch(u){case Y:return z;case Z:return B;case ee:return $;case ie:return V;case ae:return U}return s}),o.exports=ce},10392:o=>{o.exports=function getValue(o,s){return null==o?void 0:o[s]}},75251:o=>{var s=/\{\n\/\* \[wrapped with (.+)\] \*/,i=/,? & /;o.exports=function getWrapDetails(o){var u=o.match(s);return u?u[1].split(i):[]}},49326:(o,s,i)=>{var u=i(31769),_=i(72428),w=i(56449),x=i(30361),C=i(30294),j=i(77797);o.exports=function hasPath(o,s,i){for(var L=-1,B=(s=u(s,o)).length,$=!1;++L{var s=RegExp("[\\u200d\\ud800-\\udfff\\u0300-\\u036f\\ufe20-\\ufe2f\\u20d0-\\u20ff\\ufe0e\\ufe0f]");o.exports=function hasUnicode(o){return s.test(o)}},45434:o=>{var s=/[a-z][A-Z]|[A-Z]{2}[a-z]|[0-9][a-zA-Z]|[a-zA-Z][0-9]|[^a-zA-Z0-9 ]/;o.exports=function hasUnicodeWord(o){return s.test(o)}},22032:(o,s,i)=>{var u=i(81042);o.exports=function hashClear(){this.__data__=u?u(null):{},this.size=0}},63862:o=>{o.exports=function hashDelete(o){var s=this.has(o)&&delete this.__data__[o];return this.size-=s?1:0,s}},66721:(o,s,i)=>{var u=i(81042),_=Object.prototype.hasOwnProperty;o.exports=function hashGet(o){var s=this.__data__;if(u){var i=s[o];return"__lodash_hash_undefined__"===i?void 0:i}return _.call(s,o)?s[o]:void 0}},12749:(o,s,i)=>{var u=i(81042),_=Object.prototype.hasOwnProperty;o.exports=function hashHas(o){var s=this.__data__;return u?void 0!==s[o]:_.call(s,o)}},35749:(o,s,i)=>{var u=i(81042);o.exports=function hashSet(o,s){var i=this.__data__;return this.size+=this.has(o)?0:1,i[o]=u&&void 0===s?"__lodash_hash_undefined__":s,this}},76189:o=>{var s=Object.prototype.hasOwnProperty;o.exports=function initCloneArray(o){var i=o.length,u=new o.constructor(i);return i&&"string"==typeof o[0]&&s.call(o,"index")&&(u.index=o.index,u.input=o.input),u}},77199:(o,s,i)=>{var u=i(49653),_=i(76169),w=i(73201),x=i(93736),C=i(71961);o.exports=function initCloneByTag(o,s,i){var j=o.constructor;switch(s){case"[object ArrayBuffer]":return u(o);case"[object Boolean]":case"[object Date]":return new j(+o);case"[object DataView]":return _(o,i);case"[object Float32Array]":case"[object Float64Array]":case"[object Int8Array]":case"[object Int16Array]":case"[object Int32Array]":case"[object Uint8Array]":case"[object Uint8ClampedArray]":case"[object Uint16Array]":case"[object Uint32Array]":return C(o,i);case"[object Map]":case"[object Set]":return new j;case"[object Number]":case"[object String]":return new j(o);case"[object RegExp]":return w(o);case"[object Symbol]":return x(o)}}},35529:(o,s,i)=>{var u=i(39344),_=i(28879),w=i(55527);o.exports=function initCloneObject(o){return"function"!=typeof o.constructor||w(o)?{}:u(_(o))}},62060:o=>{var s=/\{(?:\n\/\* \[wrapped with .+\] \*\/)?\n?/;o.exports=function insertWrapDetails(o,i){var u=i.length;if(!u)return o;var _=u-1;return i[_]=(u>1?"& ":"")+i[_],i=i.join(u>2?", ":" "),o.replace(s,"{\n/* [wrapped with "+i+"] */\n")}},45891:(o,s,i)=>{var u=i(51873),_=i(72428),w=i(56449),x=u?u.isConcatSpreadable:void 0;o.exports=function isFlattenable(o){return w(o)||_(o)||!!(x&&o&&o[x])}},30361:o=>{var s=/^(?:0|[1-9]\d*)$/;o.exports=function isIndex(o,i){var u=typeof o;return!!(i=null==i?9007199254740991:i)&&("number"==u||"symbol"!=u&&s.test(o))&&o>-1&&o%1==0&&o{var u=i(75288),_=i(64894),w=i(30361),x=i(23805);o.exports=function isIterateeCall(o,s,i){if(!x(i))return!1;var C=typeof s;return!!("number"==C?_(i)&&w(s,i.length):"string"==C&&s in i)&&u(i[s],o)}},28586:(o,s,i)=>{var u=i(56449),_=i(44394),w=/\.|\[(?:[^[\]]*|(["'])(?:(?!\1)[^\\]|\\.)*?\1)\]/,x=/^\w*$/;o.exports=function isKey(o,s){if(u(o))return!1;var i=typeof o;return!("number"!=i&&"symbol"!=i&&"boolean"!=i&&null!=o&&!_(o))||(x.test(o)||!w.test(o)||null!=s&&o in Object(s))}},74218:o=>{o.exports=function isKeyable(o){var s=typeof o;return"string"==s||"number"==s||"symbol"==s||"boolean"==s?"__proto__"!==o:null===o}},85087:(o,s,i)=>{var u=i(30980),_=i(37381),w=i(62284),x=i(53758);o.exports=function isLaziable(o){var s=w(o),i=x[s];if("function"!=typeof i||!(s in u.prototype))return!1;if(o===i)return!0;var C=_(i);return!!C&&o===C[0]}},87296:(o,s,i)=>{var u,_=i(55481),w=(u=/[^.]+$/.exec(_&&_.keys&&_.keys.IE_PROTO||""))?"Symbol(src)_1."+u:"";o.exports=function isMasked(o){return!!w&&w in o}},55527:o=>{var s=Object.prototype;o.exports=function isPrototype(o){var i=o&&o.constructor;return o===("function"==typeof i&&i.prototype||s)}},30756:(o,s,i)=>{var u=i(23805);o.exports=function isStrictComparable(o){return o==o&&!u(o)}},63702:o=>{o.exports=function listCacheClear(){this.__data__=[],this.size=0}},70080:(o,s,i)=>{var u=i(26025),_=Array.prototype.splice;o.exports=function listCacheDelete(o){var s=this.__data__,i=u(s,o);return!(i<0)&&(i==s.length-1?s.pop():_.call(s,i,1),--this.size,!0)}},24739:(o,s,i)=>{var u=i(26025);o.exports=function listCacheGet(o){var s=this.__data__,i=u(s,o);return i<0?void 0:s[i][1]}},48655:(o,s,i)=>{var u=i(26025);o.exports=function listCacheHas(o){return u(this.__data__,o)>-1}},31175:(o,s,i)=>{var u=i(26025);o.exports=function listCacheSet(o,s){var i=this.__data__,_=u(i,o);return _<0?(++this.size,i.push([o,s])):i[_][1]=s,this}},63040:(o,s,i)=>{var u=i(21549),_=i(80079),w=i(68223);o.exports=function mapCacheClear(){this.size=0,this.__data__={hash:new u,map:new(w||_),string:new u}}},17670:(o,s,i)=>{var u=i(12651);o.exports=function mapCacheDelete(o){var s=u(this,o).delete(o);return this.size-=s?1:0,s}},90289:(o,s,i)=>{var u=i(12651);o.exports=function mapCacheGet(o){return u(this,o).get(o)}},4509:(o,s,i)=>{var u=i(12651);o.exports=function mapCacheHas(o){return u(this,o).has(o)}},72949:(o,s,i)=>{var u=i(12651);o.exports=function mapCacheSet(o,s){var i=u(this,o),_=i.size;return i.set(o,s),this.size+=i.size==_?0:1,this}},20317:o=>{o.exports=function mapToArray(o){var s=-1,i=Array(o.size);return o.forEach((function(o,u){i[++s]=[u,o]})),i}},67197:o=>{o.exports=function matchesStrictComparable(o,s){return function(i){return null!=i&&(i[o]===s&&(void 0!==s||o in Object(i)))}}},62224:(o,s,i)=>{var u=i(50104);o.exports=function memoizeCapped(o){var s=u(o,(function(o){return 500===i.size&&i.clear(),o})),i=s.cache;return s}},3209:(o,s,i)=>{var u=i(91596),_=i(53320),w=i(36306),x="__lodash_placeholder__",C=128,j=Math.min;o.exports=function mergeData(o,s){var i=o[1],L=s[1],B=i|L,$=B<131,V=L==C&&8==i||L==C&&256==i&&o[7].length<=s[8]||384==L&&s[7].length<=s[8]&&8==i;if(!$&&!V)return o;1&L&&(o[2]=s[2],B|=1&i?0:4);var U=s[3];if(U){var z=o[3];o[3]=z?u(z,U,s[4]):U,o[4]=z?w(o[3],x):s[4]}return(U=s[5])&&(z=o[5],o[5]=z?_(z,U,s[6]):U,o[6]=z?w(o[5],x):s[6]),(U=s[7])&&(o[7]=U),L&C&&(o[8]=null==o[8]?s[8]:j(o[8],s[8])),null==o[9]&&(o[9]=s[9]),o[0]=s[0],o[1]=B,o}},48152:(o,s,i)=>{var u=i(28303),_=u&&new u;o.exports=_},81042:(o,s,i)=>{var u=i(56110)(Object,"create");o.exports=u},3650:(o,s,i)=>{var u=i(74335)(Object.keys,Object);o.exports=u},90181:o=>{o.exports=function nativeKeysIn(o){var s=[];if(null!=o)for(var i in Object(o))s.push(i);return s}},86009:(o,s,i)=>{o=i.nmd(o);var u=i(34840),_=s&&!s.nodeType&&s,w=_&&o&&!o.nodeType&&o,x=w&&w.exports===_&&u.process,C=function(){try{var o=w&&w.require&&w.require("util").types;return o||x&&x.binding&&x.binding("util")}catch(o){}}();o.exports=C},59350:o=>{var s=Object.prototype.toString;o.exports=function objectToString(o){return s.call(o)}},74335:o=>{o.exports=function overArg(o,s){return function(i){return o(s(i))}}},56757:(o,s,i)=>{var u=i(91033),_=Math.max;o.exports=function overRest(o,s,i){return s=_(void 0===s?o.length-1:s,0),function(){for(var w=arguments,x=-1,C=_(w.length-s,0),j=Array(C);++x{var u=i(47422),_=i(25160);o.exports=function parent(o,s){return s.length<2?o:u(o,_(s,0,-1))}},84629:o=>{o.exports={}},68294:(o,s,i)=>{var u=i(23007),_=i(30361),w=Math.min;o.exports=function reorder(o,s){for(var i=o.length,x=w(s.length,i),C=u(o);x--;){var j=s[x];o[x]=_(j,i)?C[j]:void 0}return o}},36306:o=>{var s="__lodash_placeholder__";o.exports=function replaceHolders(o,i){for(var u=-1,_=o.length,w=0,x=[];++u<_;){var C=o[u];C!==i&&C!==s||(o[u]=s,x[w++]=u)}return x}},9325:(o,s,i)=>{var u=i(34840),_="object"==typeof self&&self&&self.Object===Object&&self,w=u||_||Function("return this")();o.exports=w},14974:o=>{o.exports=function safeGet(o,s){if(("constructor"!==s||"function"!=typeof o[s])&&"__proto__"!=s)return o[s]}},31380:o=>{o.exports=function setCacheAdd(o){return this.__data__.set(o,"__lodash_hash_undefined__"),this}},51459:o=>{o.exports=function setCacheHas(o){return this.__data__.has(o)}},54641:(o,s,i)=>{var u=i(68882),_=i(51811)(u);o.exports=_},84247:o=>{o.exports=function setToArray(o){var s=-1,i=Array(o.size);return o.forEach((function(o){i[++s]=o})),i}},32865:(o,s,i)=>{var u=i(19570),_=i(51811)(u);o.exports=_},70981:(o,s,i)=>{var u=i(75251),_=i(62060),w=i(32865),x=i(75948);o.exports=function setWrapToString(o,s,i){var C=s+"";return w(o,_(C,x(u(C),i)))}},51811:o=>{var s=Date.now;o.exports=function shortOut(o){var i=0,u=0;return function(){var _=s(),w=16-(_-u);if(u=_,w>0){if(++i>=800)return arguments[0]}else i=0;return o.apply(void 0,arguments)}}},51420:(o,s,i)=>{var u=i(80079);o.exports=function stackClear(){this.__data__=new u,this.size=0}},90938:o=>{o.exports=function stackDelete(o){var s=this.__data__,i=s.delete(o);return this.size=s.size,i}},63605:o=>{o.exports=function stackGet(o){return this.__data__.get(o)}},29817:o=>{o.exports=function stackHas(o){return this.__data__.has(o)}},80945:(o,s,i)=>{var u=i(80079),_=i(68223),w=i(53661);o.exports=function stackSet(o,s){var i=this.__data__;if(i instanceof u){var x=i.__data__;if(!_||x.length<199)return x.push([o,s]),this.size=++i.size,this;i=this.__data__=new w(x)}return i.set(o,s),this.size=i.size,this}},76959:o=>{o.exports=function strictIndexOf(o,s,i){for(var u=i-1,_=o.length;++u<_;)if(o[u]===s)return u;return-1}},63912:(o,s,i)=>{var u=i(61074),_=i(49698),w=i(42054);o.exports=function stringToArray(o){return _(o)?w(o):u(o)}},61802:(o,s,i)=>{var u=i(62224),_=/[^.[\]]+|\[(?:(-?\d+(?:\.\d+)?)|(["'])((?:(?!\2)[^\\]|\\.)*?)\2)\]|(?=(?:\.|\[\])(?:\.|\[\]|$))/g,w=/\\(\\)?/g,x=u((function(o){var s=[];return 46===o.charCodeAt(0)&&s.push(""),o.replace(_,(function(o,i,u,_){s.push(u?_.replace(w,"$1"):i||o)})),s}));o.exports=x},77797:(o,s,i)=>{var u=i(44394);o.exports=function toKey(o){if("string"==typeof o||u(o))return o;var s=o+"";return"0"==s&&1/o==-1/0?"-0":s}},47473:o=>{var s=Function.prototype.toString;o.exports=function toSource(o){if(null!=o){try{return s.call(o)}catch(o){}try{return o+""}catch(o){}}return""}},31800:o=>{var s=/\s/;o.exports=function trimmedEndIndex(o){for(var i=o.length;i--&&s.test(o.charAt(i)););return i}},42054:o=>{var s="\\ud800-\\udfff",i="["+s+"]",u="[\\u0300-\\u036f\\ufe20-\\ufe2f\\u20d0-\\u20ff]",_="\\ud83c[\\udffb-\\udfff]",w="[^"+s+"]",x="(?:\\ud83c[\\udde6-\\uddff]){2}",C="[\\ud800-\\udbff][\\udc00-\\udfff]",j="(?:"+u+"|"+_+")"+"?",L="[\\ufe0e\\ufe0f]?",B=L+j+("(?:\\u200d(?:"+[w,x,C].join("|")+")"+L+j+")*"),$="(?:"+[w+u+"?",u,x,C,i].join("|")+")",V=RegExp(_+"(?="+_+")|"+$+B,"g");o.exports=function unicodeToArray(o){return o.match(V)||[]}},22225:o=>{var s="\\ud800-\\udfff",i="\\u2700-\\u27bf",u="a-z\\xdf-\\xf6\\xf8-\\xff",_="A-Z\\xc0-\\xd6\\xd8-\\xde",w="\\xac\\xb1\\xd7\\xf7\\x00-\\x2f\\x3a-\\x40\\x5b-\\x60\\x7b-\\xbf\\u2000-\\u206f \\t\\x0b\\f\\xa0\\ufeff\\n\\r\\u2028\\u2029\\u1680\\u180e\\u2000\\u2001\\u2002\\u2003\\u2004\\u2005\\u2006\\u2007\\u2008\\u2009\\u200a\\u202f\\u205f\\u3000",x="["+w+"]",C="\\d+",j="["+i+"]",L="["+u+"]",B="[^"+s+w+C+i+u+_+"]",$="(?:\\ud83c[\\udde6-\\uddff]){2}",V="[\\ud800-\\udbff][\\udc00-\\udfff]",U="["+_+"]",z="(?:"+L+"|"+B+")",Y="(?:"+U+"|"+B+")",Z="(?:['’](?:d|ll|m|re|s|t|ve))?",ee="(?:['’](?:D|LL|M|RE|S|T|VE))?",ie="(?:[\\u0300-\\u036f\\ufe20-\\ufe2f\\u20d0-\\u20ff]|\\ud83c[\\udffb-\\udfff])?",ae="[\\ufe0e\\ufe0f]?",ce=ae+ie+("(?:\\u200d(?:"+["[^"+s+"]",$,V].join("|")+")"+ae+ie+")*"),le="(?:"+[j,$,V].join("|")+")"+ce,pe=RegExp([U+"?"+L+"+"+Z+"(?="+[x,U,"$"].join("|")+")",Y+"+"+ee+"(?="+[x,U+z,"$"].join("|")+")",U+"?"+z+"+"+Z,U+"+"+ee,"\\d*(?:1ST|2ND|3RD|(?![123])\\dTH)(?=\\b|[a-z_])","\\d*(?:1st|2nd|3rd|(?![123])\\dth)(?=\\b|[A-Z_])",C,le].join("|"),"g");o.exports=function unicodeWords(o){return o.match(pe)||[]}},75948:(o,s,i)=>{var u=i(83729),_=i(15325),w=[["ary",128],["bind",1],["bindKey",2],["curry",8],["curryRight",16],["flip",512],["partial",32],["partialRight",64],["rearg",256]];o.exports=function updateWrapDetails(o,s){return u(w,(function(i){var u="_."+i[0];s&i[1]&&!_(o,u)&&o.push(u)})),o.sort()}},80257:(o,s,i)=>{var u=i(30980),_=i(56017),w=i(23007);o.exports=function wrapperClone(o){if(o instanceof u)return o.clone();var s=new _(o.__wrapped__,o.__chain__);return s.__actions__=w(o.__actions__),s.__index__=o.__index__,s.__values__=o.__values__,s}},64626:(o,s,i)=>{var u=i(66977);o.exports=function ary(o,s,i){return s=i?void 0:s,s=o&&null==s?o.length:s,u(o,128,void 0,void 0,void 0,void 0,s)}},84058:(o,s,i)=>{var u=i(14792),_=i(45539)((function(o,s,i){return s=s.toLowerCase(),o+(i?u(s):s)}));o.exports=_},14792:(o,s,i)=>{var u=i(13222),_=i(55808);o.exports=function capitalize(o){return _(u(o).toLowerCase())}},32629:(o,s,i)=>{var u=i(9999);o.exports=function clone(o){return u(o,4)}},37334:o=>{o.exports=function constant(o){return function(){return o}}},49747:(o,s,i)=>{var u=i(66977);function curry(o,s,i){var _=u(o,8,void 0,void 0,void 0,void 0,void 0,s=i?void 0:s);return _.placeholder=curry.placeholder,_}curry.placeholder={},o.exports=curry},38221:(o,s,i)=>{var u=i(23805),_=i(10124),w=i(99374),x=Math.max,C=Math.min;o.exports=function debounce(o,s,i){var j,L,B,$,V,U,z=0,Y=!1,Z=!1,ee=!0;if("function"!=typeof o)throw new TypeError("Expected a function");function invokeFunc(s){var i=j,u=L;return j=L=void 0,z=s,$=o.apply(u,i)}function shouldInvoke(o){var i=o-U;return void 0===U||i>=s||i<0||Z&&o-z>=B}function timerExpired(){var o=_();if(shouldInvoke(o))return trailingEdge(o);V=setTimeout(timerExpired,function remainingWait(o){var i=s-(o-U);return Z?C(i,B-(o-z)):i}(o))}function trailingEdge(o){return V=void 0,ee&&j?invokeFunc(o):(j=L=void 0,$)}function debounced(){var o=_(),i=shouldInvoke(o);if(j=arguments,L=this,U=o,i){if(void 0===V)return function leadingEdge(o){return z=o,V=setTimeout(timerExpired,s),Y?invokeFunc(o):$}(U);if(Z)return clearTimeout(V),V=setTimeout(timerExpired,s),invokeFunc(U)}return void 0===V&&(V=setTimeout(timerExpired,s)),$}return s=w(s)||0,u(i)&&(Y=!!i.leading,B=(Z="maxWait"in i)?x(w(i.maxWait)||0,s):B,ee="trailing"in i?!!i.trailing:ee),debounced.cancel=function cancel(){void 0!==V&&clearTimeout(V),z=0,j=U=L=V=void 0},debounced.flush=function flush(){return void 0===V?$:trailingEdge(_())},debounced}},50828:(o,s,i)=>{var u=i(24647),_=i(13222),w=/[\xc0-\xd6\xd8-\xf6\xf8-\xff\u0100-\u017f]/g,x=RegExp("[\\u0300-\\u036f\\ufe20-\\ufe2f\\u20d0-\\u20ff]","g");o.exports=function deburr(o){return(o=_(o))&&o.replace(w,u).replace(x,"")}},75288:o=>{o.exports=function eq(o,s){return o===s||o!=o&&s!=s}},60680:(o,s,i)=>{var u=i(13222),_=/[\\^$.*+?()[\]{}|]/g,w=RegExp(_.source);o.exports=function escapeRegExp(o){return(o=u(o))&&w.test(o)?o.replace(_,"\\$&"):o}},7309:(o,s,i)=>{var u=i(62006)(i(24713));o.exports=u},24713:(o,s,i)=>{var u=i(2523),_=i(15389),w=i(61489),x=Math.max;o.exports=function findIndex(o,s,i){var C=null==o?0:o.length;if(!C)return-1;var j=null==i?0:w(i);return j<0&&(j=x(C+j,0)),u(o,_(s,3),j)}},35970:(o,s,i)=>{var u=i(83120);o.exports=function flatten(o){return(null==o?0:o.length)?u(o,1):[]}},73424:(o,s,i)=>{var u=i(16962),_=i(2874),w=Array.prototype.push;function baseAry(o,s){return 2==s?function(s,i){return o(s,i)}:function(s){return o(s)}}function cloneArray(o){for(var s=o?o.length:0,i=Array(s);s--;)i[s]=o[s];return i}function wrapImmutable(o,s){return function(){var i=arguments.length;if(i){for(var u=Array(i);i--;)u[i]=arguments[i];var _=u[0]=s.apply(void 0,u);return o.apply(void 0,u),_}}}o.exports=function baseConvert(o,s,i,x){var C="function"==typeof s,j=s===Object(s);if(j&&(x=i,i=s,s=void 0),null==i)throw new TypeError;x||(x={});var L={cap:!("cap"in x)||x.cap,curry:!("curry"in x)||x.curry,fixed:!("fixed"in x)||x.fixed,immutable:!("immutable"in x)||x.immutable,rearg:!("rearg"in x)||x.rearg},B=C?i:_,$="curry"in x&&x.curry,V="fixed"in x&&x.fixed,U="rearg"in x&&x.rearg,z=C?i.runInContext():void 0,Y=C?i:{ary:o.ary,assign:o.assign,clone:o.clone,curry:o.curry,forEach:o.forEach,isArray:o.isArray,isError:o.isError,isFunction:o.isFunction,isWeakMap:o.isWeakMap,iteratee:o.iteratee,keys:o.keys,rearg:o.rearg,toInteger:o.toInteger,toPath:o.toPath},Z=Y.ary,ee=Y.assign,ie=Y.clone,ae=Y.curry,ce=Y.forEach,le=Y.isArray,pe=Y.isError,de=Y.isFunction,fe=Y.isWeakMap,ye=Y.keys,be=Y.rearg,_e=Y.toInteger,we=Y.toPath,Se=ye(u.aryMethod),xe={castArray:function(o){return function(){var s=arguments[0];return le(s)?o(cloneArray(s)):o.apply(void 0,arguments)}},iteratee:function(o){return function(){var s=arguments[1],i=o(arguments[0],s),u=i.length;return L.cap&&"number"==typeof s?(s=s>2?s-2:1,u&&u<=s?i:baseAry(i,s)):i}},mixin:function(o){return function(s){var i=this;if(!de(i))return o(i,Object(s));var u=[];return ce(ye(s),(function(o){de(s[o])&&u.push([o,i.prototype[o]])})),o(i,Object(s)),ce(u,(function(o){var s=o[1];de(s)?i.prototype[o[0]]=s:delete i.prototype[o[0]]})),i}},nthArg:function(o){return function(s){var i=s<0?1:_e(s)+1;return ae(o(s),i)}},rearg:function(o){return function(s,i){var u=i?i.length:0;return ae(o(s,i),u)}},runInContext:function(s){return function(i){return baseConvert(o,s(i),x)}}};function castCap(o,s){if(L.cap){var i=u.iterateeRearg[o];if(i)return function iterateeRearg(o,s){return overArg(o,(function(o){var i=s.length;return function baseArity(o,s){return 2==s?function(s,i){return o.apply(void 0,arguments)}:function(s){return o.apply(void 0,arguments)}}(be(baseAry(o,i),s),i)}))}(s,i);var _=!C&&u.iterateeAry[o];if(_)return function iterateeAry(o,s){return overArg(o,(function(o){return"function"==typeof o?baseAry(o,s):o}))}(s,_)}return s}function castFixed(o,s,i){if(L.fixed&&(V||!u.skipFixed[o])){var _=u.methodSpread[o],x=_&&_.start;return void 0===x?Z(s,i):function flatSpread(o,s){return function(){for(var i=arguments.length,u=i-1,_=Array(i);i--;)_[i]=arguments[i];var x=_[s],C=_.slice(0,s);return x&&w.apply(C,x),s!=u&&w.apply(C,_.slice(s+1)),o.apply(this,C)}}(s,x)}return s}function castRearg(o,s,i){return L.rearg&&i>1&&(U||!u.skipRearg[o])?be(s,u.methodRearg[o]||u.aryRearg[i]):s}function cloneByPath(o,s){for(var i=-1,u=(s=we(s)).length,_=u-1,w=ie(Object(o)),x=w;null!=x&&++i1?ae(s,i):s}(0,_=castCap(w,_),o),!1}})),!_})),_||(_=x),_==s&&(_=$?ae(_,1):function(){return s.apply(this,arguments)}),_.convert=createConverter(w,s),_.placeholder=s.placeholder=i,_}if(!j)return wrap(s,i,B);var Pe=i,Te=[];return ce(Se,(function(o){ce(u.aryMethod[o],(function(o){var s=Pe[u.remap[o]||o];s&&Te.push([o,wrap(o,s,Pe)])}))})),ce(ye(Pe),(function(o){var s=Pe[o];if("function"==typeof s){for(var i=Te.length;i--;)if(Te[i][0]==o)return;s.convert=createConverter(o,s),Te.push([o,s])}})),ce(Te,(function(o){Pe[o[0]]=o[1]})),Pe.convert=function convertLib(o){return Pe.runInContext.convert(o)(void 0)},Pe.placeholder=Pe,ce(ye(Pe),(function(o){ce(u.realToAlias[o]||[],(function(s){Pe[s]=Pe[o]}))})),Pe}},16962:(o,s)=>{s.aliasToReal={each:"forEach",eachRight:"forEachRight",entries:"toPairs",entriesIn:"toPairsIn",extend:"assignIn",extendAll:"assignInAll",extendAllWith:"assignInAllWith",extendWith:"assignInWith",first:"head",conforms:"conformsTo",matches:"isMatch",property:"get",__:"placeholder",F:"stubFalse",T:"stubTrue",all:"every",allPass:"overEvery",always:"constant",any:"some",anyPass:"overSome",apply:"spread",assoc:"set",assocPath:"set",complement:"negate",compose:"flowRight",contains:"includes",dissoc:"unset",dissocPath:"unset",dropLast:"dropRight",dropLastWhile:"dropRightWhile",equals:"isEqual",identical:"eq",indexBy:"keyBy",init:"initial",invertObj:"invert",juxt:"over",omitAll:"omit",nAry:"ary",path:"get",pathEq:"matchesProperty",pathOr:"getOr",paths:"at",pickAll:"pick",pipe:"flow",pluck:"map",prop:"get",propEq:"matchesProperty",propOr:"getOr",props:"at",symmetricDifference:"xor",symmetricDifferenceBy:"xorBy",symmetricDifferenceWith:"xorWith",takeLast:"takeRight",takeLastWhile:"takeRightWhile",unapply:"rest",unnest:"flatten",useWith:"overArgs",where:"conformsTo",whereEq:"isMatch",zipObj:"zipObject"},s.aryMethod={1:["assignAll","assignInAll","attempt","castArray","ceil","create","curry","curryRight","defaultsAll","defaultsDeepAll","floor","flow","flowRight","fromPairs","invert","iteratee","memoize","method","mergeAll","methodOf","mixin","nthArg","over","overEvery","overSome","rest","reverse","round","runInContext","spread","template","trim","trimEnd","trimStart","uniqueId","words","zipAll"],2:["add","after","ary","assign","assignAllWith","assignIn","assignInAllWith","at","before","bind","bindAll","bindKey","chunk","cloneDeepWith","cloneWith","concat","conformsTo","countBy","curryN","curryRightN","debounce","defaults","defaultsDeep","defaultTo","delay","difference","divide","drop","dropRight","dropRightWhile","dropWhile","endsWith","eq","every","filter","find","findIndex","findKey","findLast","findLastIndex","findLastKey","flatMap","flatMapDeep","flattenDepth","forEach","forEachRight","forIn","forInRight","forOwn","forOwnRight","get","groupBy","gt","gte","has","hasIn","includes","indexOf","intersection","invertBy","invoke","invokeMap","isEqual","isMatch","join","keyBy","lastIndexOf","lt","lte","map","mapKeys","mapValues","matchesProperty","maxBy","meanBy","merge","mergeAllWith","minBy","multiply","nth","omit","omitBy","overArgs","pad","padEnd","padStart","parseInt","partial","partialRight","partition","pick","pickBy","propertyOf","pull","pullAll","pullAt","random","range","rangeRight","rearg","reject","remove","repeat","restFrom","result","sampleSize","some","sortBy","sortedIndex","sortedIndexOf","sortedLastIndex","sortedLastIndexOf","sortedUniqBy","split","spreadFrom","startsWith","subtract","sumBy","take","takeRight","takeRightWhile","takeWhile","tap","throttle","thru","times","trimChars","trimCharsEnd","trimCharsStart","truncate","union","uniqBy","uniqWith","unset","unzipWith","without","wrap","xor","zip","zipObject","zipObjectDeep"],3:["assignInWith","assignWith","clamp","differenceBy","differenceWith","findFrom","findIndexFrom","findLastFrom","findLastIndexFrom","getOr","includesFrom","indexOfFrom","inRange","intersectionBy","intersectionWith","invokeArgs","invokeArgsMap","isEqualWith","isMatchWith","flatMapDepth","lastIndexOfFrom","mergeWith","orderBy","padChars","padCharsEnd","padCharsStart","pullAllBy","pullAllWith","rangeStep","rangeStepRight","reduce","reduceRight","replace","set","slice","sortedIndexBy","sortedLastIndexBy","transform","unionBy","unionWith","update","xorBy","xorWith","zipWith"],4:["fill","setWith","updateWith"]},s.aryRearg={2:[1,0],3:[2,0,1],4:[3,2,0,1]},s.iterateeAry={dropRightWhile:1,dropWhile:1,every:1,filter:1,find:1,findFrom:1,findIndex:1,findIndexFrom:1,findKey:1,findLast:1,findLastFrom:1,findLastIndex:1,findLastIndexFrom:1,findLastKey:1,flatMap:1,flatMapDeep:1,flatMapDepth:1,forEach:1,forEachRight:1,forIn:1,forInRight:1,forOwn:1,forOwnRight:1,map:1,mapKeys:1,mapValues:1,partition:1,reduce:2,reduceRight:2,reject:1,remove:1,some:1,takeRightWhile:1,takeWhile:1,times:1,transform:2},s.iterateeRearg={mapKeys:[1],reduceRight:[1,0]},s.methodRearg={assignInAllWith:[1,0],assignInWith:[1,2,0],assignAllWith:[1,0],assignWith:[1,2,0],differenceBy:[1,2,0],differenceWith:[1,2,0],getOr:[2,1,0],intersectionBy:[1,2,0],intersectionWith:[1,2,0],isEqualWith:[1,2,0],isMatchWith:[2,1,0],mergeAllWith:[1,0],mergeWith:[1,2,0],padChars:[2,1,0],padCharsEnd:[2,1,0],padCharsStart:[2,1,0],pullAllBy:[2,1,0],pullAllWith:[2,1,0],rangeStep:[1,2,0],rangeStepRight:[1,2,0],setWith:[3,1,2,0],sortedIndexBy:[2,1,0],sortedLastIndexBy:[2,1,0],unionBy:[1,2,0],unionWith:[1,2,0],updateWith:[3,1,2,0],xorBy:[1,2,0],xorWith:[1,2,0],zipWith:[1,2,0]},s.methodSpread={assignAll:{start:0},assignAllWith:{start:0},assignInAll:{start:0},assignInAllWith:{start:0},defaultsAll:{start:0},defaultsDeepAll:{start:0},invokeArgs:{start:2},invokeArgsMap:{start:2},mergeAll:{start:0},mergeAllWith:{start:0},partial:{start:1},partialRight:{start:1},without:{start:1},zipAll:{start:0}},s.mutate={array:{fill:!0,pull:!0,pullAll:!0,pullAllBy:!0,pullAllWith:!0,pullAt:!0,remove:!0,reverse:!0},object:{assign:!0,assignAll:!0,assignAllWith:!0,assignIn:!0,assignInAll:!0,assignInAllWith:!0,assignInWith:!0,assignWith:!0,defaults:!0,defaultsAll:!0,defaultsDeep:!0,defaultsDeepAll:!0,merge:!0,mergeAll:!0,mergeAllWith:!0,mergeWith:!0},set:{set:!0,setWith:!0,unset:!0,update:!0,updateWith:!0}},s.realToAlias=function(){var o=Object.prototype.hasOwnProperty,i=s.aliasToReal,u={};for(var _ in i){var w=i[_];o.call(u,w)?u[w].push(_):u[w]=[_]}return u}(),s.remap={assignAll:"assign",assignAllWith:"assignWith",assignInAll:"assignIn",assignInAllWith:"assignInWith",curryN:"curry",curryRightN:"curryRight",defaultsAll:"defaults",defaultsDeepAll:"defaultsDeep",findFrom:"find",findIndexFrom:"findIndex",findLastFrom:"findLast",findLastIndexFrom:"findLastIndex",getOr:"get",includesFrom:"includes",indexOfFrom:"indexOf",invokeArgs:"invoke",invokeArgsMap:"invokeMap",lastIndexOfFrom:"lastIndexOf",mergeAll:"merge",mergeAllWith:"mergeWith",padChars:"pad",padCharsEnd:"padEnd",padCharsStart:"padStart",propertyOf:"get",rangeStep:"range",rangeStepRight:"rangeRight",restFrom:"rest",spreadFrom:"spread",trimChars:"trim",trimCharsEnd:"trimEnd",trimCharsStart:"trimStart",zipAll:"zip"},s.skipFixed={castArray:!0,flow:!0,flowRight:!0,iteratee:!0,mixin:!0,rearg:!0,runInContext:!0},s.skipRearg={add:!0,assign:!0,assignIn:!0,bind:!0,bindKey:!0,concat:!0,difference:!0,divide:!0,eq:!0,gt:!0,gte:!0,isEqual:!0,lt:!0,lte:!0,matchesProperty:!0,merge:!0,multiply:!0,overArgs:!0,partial:!0,partialRight:!0,propertyOf:!0,random:!0,range:!0,rangeRight:!0,subtract:!0,zip:!0,zipObject:!0,zipObjectDeep:!0}},47934:(o,s,i)=>{o.exports={ary:i(64626),assign:i(74733),clone:i(32629),curry:i(49747),forEach:i(83729),isArray:i(56449),isError:i(23546),isFunction:i(1882),isWeakMap:i(47886),iteratee:i(33855),keys:i(88984),rearg:i(84195),toInteger:i(61489),toPath:i(42072)}},56367:(o,s,i)=>{o.exports=i(77731)},79920:(o,s,i)=>{var u=i(73424),_=i(47934);o.exports=function convert(o,s,i){return u(_,o,s,i)}},2874:o=>{o.exports={}},77731:(o,s,i)=>{var u=i(79920)("set",i(63560));u.placeholder=i(2874),o.exports=u},58156:(o,s,i)=>{var u=i(47422);o.exports=function get(o,s,i){var _=null==o?void 0:u(o,s);return void 0===_?i:_}},61448:(o,s,i)=>{var u=i(20426),_=i(49326);o.exports=function has(o,s){return null!=o&&_(o,s,u)}},80631:(o,s,i)=>{var u=i(28077),_=i(49326);o.exports=function hasIn(o,s){return null!=o&&_(o,s,u)}},83488:o=>{o.exports=function identity(o){return o}},72428:(o,s,i)=>{var u=i(27534),_=i(40346),w=Object.prototype,x=w.hasOwnProperty,C=w.propertyIsEnumerable,j=u(function(){return arguments}())?u:function(o){return _(o)&&x.call(o,"callee")&&!C.call(o,"callee")};o.exports=j},56449:o=>{var s=Array.isArray;o.exports=s},64894:(o,s,i)=>{var u=i(1882),_=i(30294);o.exports=function isArrayLike(o){return null!=o&&_(o.length)&&!u(o)}},83693:(o,s,i)=>{var u=i(64894),_=i(40346);o.exports=function isArrayLikeObject(o){return _(o)&&u(o)}},53812:(o,s,i)=>{var u=i(72552),_=i(40346);o.exports=function isBoolean(o){return!0===o||!1===o||_(o)&&"[object Boolean]"==u(o)}},3656:(o,s,i)=>{o=i.nmd(o);var u=i(9325),_=i(89935),w=s&&!s.nodeType&&s,x=w&&o&&!o.nodeType&&o,C=x&&x.exports===w?u.Buffer:void 0,j=(C?C.isBuffer:void 0)||_;o.exports=j},62193:(o,s,i)=>{var u=i(88984),_=i(5861),w=i(72428),x=i(56449),C=i(64894),j=i(3656),L=i(55527),B=i(37167),$=Object.prototype.hasOwnProperty;o.exports=function isEmpty(o){if(null==o)return!0;if(C(o)&&(x(o)||"string"==typeof o||"function"==typeof o.splice||j(o)||B(o)||w(o)))return!o.length;var s=_(o);if("[object Map]"==s||"[object Set]"==s)return!o.size;if(L(o))return!u(o).length;for(var i in o)if($.call(o,i))return!1;return!0}},2404:(o,s,i)=>{var u=i(60270);o.exports=function isEqual(o,s){return u(o,s)}},23546:(o,s,i)=>{var u=i(72552),_=i(40346),w=i(11331);o.exports=function isError(o){if(!_(o))return!1;var s=u(o);return"[object Error]"==s||"[object DOMException]"==s||"string"==typeof o.message&&"string"==typeof o.name&&!w(o)}},1882:(o,s,i)=>{var u=i(72552),_=i(23805);o.exports=function isFunction(o){if(!_(o))return!1;var s=u(o);return"[object Function]"==s||"[object GeneratorFunction]"==s||"[object AsyncFunction]"==s||"[object Proxy]"==s}},30294:o=>{o.exports=function isLength(o){return"number"==typeof o&&o>-1&&o%1==0&&o<=9007199254740991}},87730:(o,s,i)=>{var u=i(29172),_=i(27301),w=i(86009),x=w&&w.isMap,C=x?_(x):u;o.exports=C},5187:o=>{o.exports=function isNull(o){return null===o}},98023:(o,s,i)=>{var u=i(72552),_=i(40346);o.exports=function isNumber(o){return"number"==typeof o||_(o)&&"[object Number]"==u(o)}},23805:o=>{o.exports=function isObject(o){var s=typeof o;return null!=o&&("object"==s||"function"==s)}},40346:o=>{o.exports=function isObjectLike(o){return null!=o&&"object"==typeof o}},11331:(o,s,i)=>{var u=i(72552),_=i(28879),w=i(40346),x=Function.prototype,C=Object.prototype,j=x.toString,L=C.hasOwnProperty,B=j.call(Object);o.exports=function isPlainObject(o){if(!w(o)||"[object Object]"!=u(o))return!1;var s=_(o);if(null===s)return!0;var i=L.call(s,"constructor")&&s.constructor;return"function"==typeof i&&i instanceof i&&j.call(i)==B}},38440:(o,s,i)=>{var u=i(16038),_=i(27301),w=i(86009),x=w&&w.isSet,C=x?_(x):u;o.exports=C},85015:(o,s,i)=>{var u=i(72552),_=i(56449),w=i(40346);o.exports=function isString(o){return"string"==typeof o||!_(o)&&w(o)&&"[object String]"==u(o)}},44394:(o,s,i)=>{var u=i(72552),_=i(40346);o.exports=function isSymbol(o){return"symbol"==typeof o||_(o)&&"[object Symbol]"==u(o)}},37167:(o,s,i)=>{var u=i(4901),_=i(27301),w=i(86009),x=w&&w.isTypedArray,C=x?_(x):u;o.exports=C},47886:(o,s,i)=>{var u=i(5861),_=i(40346);o.exports=function isWeakMap(o){return _(o)&&"[object WeakMap]"==u(o)}},33855:(o,s,i)=>{var u=i(9999),_=i(15389);o.exports=function iteratee(o){return _("function"==typeof o?o:u(o,1))}},95950:(o,s,i)=>{var u=i(70695),_=i(88984),w=i(64894);o.exports=function keys(o){return w(o)?u(o):_(o)}},37241:(o,s,i)=>{var u=i(70695),_=i(72903),w=i(64894);o.exports=function keysIn(o){return w(o)?u(o,!0):_(o)}},68090:o=>{o.exports=function last(o){var s=null==o?0:o.length;return s?o[s-1]:void 0}},50104:(o,s,i)=>{var u=i(53661);function memoize(o,s){if("function"!=typeof o||null!=s&&"function"!=typeof s)throw new TypeError("Expected a function");var memoized=function(){var i=arguments,u=s?s.apply(this,i):i[0],_=memoized.cache;if(_.has(u))return _.get(u);var w=o.apply(this,i);return memoized.cache=_.set(u,w)||_,w};return memoized.cache=new(memoize.Cache||u),memoized}memoize.Cache=u,o.exports=memoize},55364:(o,s,i)=>{var u=i(85250),_=i(20999)((function(o,s,i){u(o,s,i)}));o.exports=_},6048:o=>{o.exports=function negate(o){if("function"!=typeof o)throw new TypeError("Expected a function");return function(){var s=arguments;switch(s.length){case 0:return!o.call(this);case 1:return!o.call(this,s[0]);case 2:return!o.call(this,s[0],s[1]);case 3:return!o.call(this,s[0],s[1],s[2])}return!o.apply(this,s)}}},63950:o=>{o.exports=function noop(){}},10124:(o,s,i)=>{var u=i(9325);o.exports=function(){return u.Date.now()}},90179:(o,s,i)=>{var u=i(34932),_=i(9999),w=i(19931),x=i(31769),C=i(21791),j=i(53138),L=i(38816),B=i(83349),$=L((function(o,s){var i={};if(null==o)return i;var L=!1;s=u(s,(function(s){return s=x(s,o),L||(L=s.length>1),s})),C(o,B(o),i),L&&(i=_(i,7,j));for(var $=s.length;$--;)w(i,s[$]);return i}));o.exports=$},50583:(o,s,i)=>{var u=i(47237),_=i(17255),w=i(28586),x=i(77797);o.exports=function property(o){return w(o)?u(x(o)):_(o)}},84195:(o,s,i)=>{var u=i(66977),_=i(38816),w=_((function(o,s){return u(o,256,void 0,void 0,void 0,s)}));o.exports=w},40860:(o,s,i)=>{var u=i(40882),_=i(80909),w=i(15389),x=i(85558),C=i(56449);o.exports=function reduce(o,s,i){var j=C(o)?u:x,L=arguments.length<3;return j(o,w(s,4),i,L,_)}},63560:(o,s,i)=>{var u=i(73170);o.exports=function set(o,s,i){return null==o?o:u(o,s,i)}},42426:(o,s,i)=>{var u=i(14248),_=i(15389),w=i(90916),x=i(56449),C=i(36800);o.exports=function some(o,s,i){var j=x(o)?u:w;return i&&C(o,s,i)&&(s=void 0),j(o,_(s,3))}},63345:o=>{o.exports=function stubArray(){return[]}},89935:o=>{o.exports=function stubFalse(){return!1}},17400:(o,s,i)=>{var u=i(99374),_=1/0;o.exports=function toFinite(o){return o?(o=u(o))===_||o===-1/0?17976931348623157e292*(o<0?-1:1):o==o?o:0:0===o?o:0}},61489:(o,s,i)=>{var u=i(17400);o.exports=function toInteger(o){var s=u(o),i=s%1;return s==s?i?s-i:s:0}},80218:(o,s,i)=>{var u=i(13222);o.exports=function toLower(o){return u(o).toLowerCase()}},99374:(o,s,i)=>{var u=i(54128),_=i(23805),w=i(44394),x=/^[-+]0x[0-9a-f]+$/i,C=/^0b[01]+$/i,j=/^0o[0-7]+$/i,L=parseInt;o.exports=function toNumber(o){if("number"==typeof o)return o;if(w(o))return NaN;if(_(o)){var s="function"==typeof o.valueOf?o.valueOf():o;o=_(s)?s+"":s}if("string"!=typeof o)return 0===o?o:+o;o=u(o);var i=C.test(o);return i||j.test(o)?L(o.slice(2),i?2:8):x.test(o)?NaN:+o}},42072:(o,s,i)=>{var u=i(34932),_=i(23007),w=i(56449),x=i(44394),C=i(61802),j=i(77797),L=i(13222);o.exports=function toPath(o){return w(o)?u(o,j):x(o)?[o]:_(C(L(o)))}},69884:(o,s,i)=>{var u=i(21791),_=i(37241);o.exports=function toPlainObject(o){return u(o,_(o))}},13222:(o,s,i)=>{var u=i(77556);o.exports=function toString(o){return null==o?"":u(o)}},55808:(o,s,i)=>{var u=i(12507)("toUpperCase");o.exports=u},66645:(o,s,i)=>{var u=i(1733),_=i(45434),w=i(13222),x=i(22225);o.exports=function words(o,s,i){return o=w(o),void 0===(s=i?void 0:s)?_(o)?x(o):u(o):o.match(s)||[]}},53758:(o,s,i)=>{var u=i(30980),_=i(56017),w=i(94033),x=i(56449),C=i(40346),j=i(80257),L=Object.prototype.hasOwnProperty;function lodash(o){if(C(o)&&!x(o)&&!(o instanceof u)){if(o instanceof _)return o;if(L.call(o,"__wrapped__"))return j(o)}return new _(o)}lodash.prototype=w.prototype,lodash.prototype.constructor=lodash,o.exports=lodash},47248:(o,s,i)=>{var u=i(16547),_=i(51234);o.exports=function zipObject(o,s){return _(o||[],s||[],u)}},43768:(o,s,i)=>{"use strict";var u=i(45981),_=i(85587);s.highlight=highlight,s.highlightAuto=function highlightAuto(o,s){var i,x,C,j,L=s||{},B=L.subset||u.listLanguages(),$=L.prefix,V=B.length,U=-1;null==$&&($=w);if("string"!=typeof o)throw _("Expected `string` for value, got `%s`",o);x={relevance:0,language:null,value:[]},i={relevance:0,language:null,value:[]};for(;++Ux.relevance&&(x=C),C.relevance>i.relevance&&(x=i,i=C));x.language&&(i.secondBest=x);return i},s.registerLanguage=function registerLanguage(o,s){u.registerLanguage(o,s)},s.listLanguages=function listLanguages(){return u.listLanguages()},s.registerAlias=function registerAlias(o,s){var i,_=o;s&&((_={})[o]=s);for(i in _)u.registerAliases(_[i],{languageName:i})},Emitter.prototype.addText=function text(o){var s,i,u=this.stack;if(""===o)return;s=u[u.length-1],(i=s.children[s.children.length-1])&&"text"===i.type?i.value+=o:s.children.push({type:"text",value:o})},Emitter.prototype.addKeyword=function addKeyword(o,s){this.openNode(s),this.addText(o),this.closeNode()},Emitter.prototype.addSublanguage=function addSublanguage(o,s){var i=this.stack,u=i[i.length-1],_=o.rootNode.children,w=s?{type:"element",tagName:"span",properties:{className:[s]},children:_}:_;u.children=u.children.concat(w)},Emitter.prototype.openNode=function open(o){var s=this.stack,i=this.options.classPrefix+o,u=s[s.length-1],_={type:"element",tagName:"span",properties:{className:[i]},children:[]};u.children.push(_),s.push(_)},Emitter.prototype.closeNode=function close(){this.stack.pop()},Emitter.prototype.closeAllNodes=noop,Emitter.prototype.finalize=noop,Emitter.prototype.toHTML=function toHtmlNoop(){return""};var w="hljs-";function highlight(o,s,i){var x,C=u.configure({}),j=(i||{}).prefix;if("string"!=typeof o)throw _("Expected `string` for name, got `%s`",o);if(!u.getLanguage(o))throw _("Unknown language: `%s` is not registered",o);if("string"!=typeof s)throw _("Expected `string` for value, got `%s`",s);if(null==j&&(j=w),u.configure({__emitter:Emitter,classPrefix:j}),x=u.highlight(s,{language:o,ignoreIllegals:!0}),u.configure(C||{}),x.errorRaised)throw x.errorRaised;return{relevance:x.relevance,language:x.language,value:x.emitter.rootNode.children}}function Emitter(o){this.options=o,this.rootNode={children:[]},this.stack=[this.rootNode]}function noop(){}},92340:(o,s,i)=>{const u=i(6048);function coerceElementMatchingCallback(o){return"string"==typeof o?s=>s.element===o:o.constructor&&o.extend?s=>s instanceof o:o}class ArraySlice{constructor(o){this.elements=o||[]}toValue(){return this.elements.map((o=>o.toValue()))}map(o,s){return this.elements.map(o,s)}flatMap(o,s){return this.map(o,s).reduce(((o,s)=>o.concat(s)),[])}compactMap(o,s){const i=[];return this.forEach((u=>{const _=o.bind(s)(u);_&&i.push(_)})),i}filter(o,s){return o=coerceElementMatchingCallback(o),new ArraySlice(this.elements.filter(o,s))}reject(o,s){return o=coerceElementMatchingCallback(o),new ArraySlice(this.elements.filter(u(o),s))}find(o,s){return o=coerceElementMatchingCallback(o),this.elements.find(o,s)}forEach(o,s){this.elements.forEach(o,s)}reduce(o,s){return this.elements.reduce(o,s)}includes(o){return this.elements.some((s=>s.equals(o)))}shift(){return this.elements.shift()}unshift(o){this.elements.unshift(this.refract(o))}push(o){return this.elements.push(this.refract(o)),this}add(o){this.push(o)}get(o){return this.elements[o]}getValue(o){const s=this.elements[o];if(s)return s.toValue()}get length(){return this.elements.length}get isEmpty(){return 0===this.elements.length}get first(){return this.elements[0]}}"undefined"!=typeof Symbol&&(ArraySlice.prototype[Symbol.iterator]=function symbol(){return this.elements[Symbol.iterator]()}),o.exports=ArraySlice},55973:o=>{class KeyValuePair{constructor(o,s){this.key=o,this.value=s}clone(){const o=new KeyValuePair;return this.key&&(o.key=this.key.clone()),this.value&&(o.value=this.value.clone()),o}}o.exports=KeyValuePair},3110:(o,s,i)=>{const u=i(5187),_=i(85015),w=i(98023),x=i(53812),C=i(23805),j=i(85105),L=i(86804);class Namespace{constructor(o){this.elementMap={},this.elementDetection=[],this.Element=L.Element,this.KeyValuePair=L.KeyValuePair,o&&o.noDefault||this.useDefault(),this._attributeElementKeys=[],this._attributeElementArrayKeys=[]}use(o){return o.namespace&&o.namespace({base:this}),o.load&&o.load({base:this}),this}useDefault(){return this.register("null",L.NullElement).register("string",L.StringElement).register("number",L.NumberElement).register("boolean",L.BooleanElement).register("array",L.ArrayElement).register("object",L.ObjectElement).register("member",L.MemberElement).register("ref",L.RefElement).register("link",L.LinkElement),this.detect(u,L.NullElement,!1).detect(_,L.StringElement,!1).detect(w,L.NumberElement,!1).detect(x,L.BooleanElement,!1).detect(Array.isArray,L.ArrayElement,!1).detect(C,L.ObjectElement,!1),this}register(o,s){return this._elements=void 0,this.elementMap[o]=s,this}unregister(o){return this._elements=void 0,delete this.elementMap[o],this}detect(o,s,i){return void 0===i||i?this.elementDetection.unshift([o,s]):this.elementDetection.push([o,s]),this}toElement(o){if(o instanceof this.Element)return o;let s;for(let i=0;i{const s=o[0].toUpperCase()+o.substr(1);this._elements[s]=this.elementMap[o]}))),this._elements}get serialiser(){return new j(this)}}j.prototype.Namespace=Namespace,o.exports=Namespace},10866:(o,s,i)=>{const u=i(6048),_=i(92340);class ObjectSlice extends _{map(o,s){return this.elements.map((i=>o.bind(s)(i.value,i.key,i)))}filter(o,s){return new ObjectSlice(this.elements.filter((i=>o.bind(s)(i.value,i.key,i))))}reject(o,s){return this.filter(u(o.bind(s)))}forEach(o,s){return this.elements.forEach(((i,u)=>{o.bind(s)(i.value,i.key,i,u)}))}keys(){return this.map(((o,s)=>s.toValue()))}values(){return this.map((o=>o.toValue()))}}o.exports=ObjectSlice},86804:(o,s,i)=>{const u=i(10316),_=i(41067),w=i(71167),x=i(40239),C=i(12242),j=i(6233),L=i(87726),B=i(61045),$=i(86303),V=i(14540),U=i(92340),z=i(10866),Y=i(55973);function refract(o){if(o instanceof u)return o;if("string"==typeof o)return new w(o);if("number"==typeof o)return new x(o);if("boolean"==typeof o)return new C(o);if(null===o)return new _;if(Array.isArray(o))return new j(o.map(refract));if("object"==typeof o){return new B(o)}return o}u.prototype.ObjectElement=B,u.prototype.RefElement=V,u.prototype.MemberElement=L,u.prototype.refract=refract,U.prototype.refract=refract,o.exports={Element:u,NullElement:_,StringElement:w,NumberElement:x,BooleanElement:C,ArrayElement:j,MemberElement:L,ObjectElement:B,LinkElement:$,RefElement:V,refract,ArraySlice:U,ObjectSlice:z,KeyValuePair:Y}},86303:(o,s,i)=>{const u=i(10316);o.exports=class LinkElement extends u{constructor(o,s,i){super(o||[],s,i),this.element="link"}get relation(){return this.attributes.get("relation")}set relation(o){this.attributes.set("relation",o)}get href(){return this.attributes.get("href")}set href(o){this.attributes.set("href",o)}}},14540:(o,s,i)=>{const u=i(10316);o.exports=class RefElement extends u{constructor(o,s,i){super(o||[],s,i),this.element="ref",this.path||(this.path="element")}get path(){return this.attributes.get("path")}set path(o){this.attributes.set("path",o)}}},34035:(o,s,i)=>{const u=i(3110),_=i(86804);s.g$=u,s.KeyValuePair=i(55973),s.G6=_.ArraySlice,s.ot=_.ObjectSlice,s.Hg=_.Element,s.Om=_.StringElement,s.kT=_.NumberElement,s.bd=_.BooleanElement,s.Os=_.NullElement,s.wE=_.ArrayElement,s.Sh=_.ObjectElement,s.Pr=_.MemberElement,s.sI=_.RefElement,s.Ft=_.LinkElement,s.e=_.refract,i(85105),i(75147)},6233:(o,s,i)=>{const u=i(6048),_=i(10316),w=i(92340);class ArrayElement extends _{constructor(o,s,i){super(o||[],s,i),this.element="array"}primitive(){return"array"}get(o){return this.content[o]}getValue(o){const s=this.get(o);if(s)return s.toValue()}getIndex(o){return this.content[o]}set(o,s){return this.content[o]=this.refract(s),this}remove(o){const s=this.content.splice(o,1);return s.length?s[0]:null}map(o,s){return this.content.map(o,s)}flatMap(o,s){return this.map(o,s).reduce(((o,s)=>o.concat(s)),[])}compactMap(o,s){const i=[];return this.forEach((u=>{const _=o.bind(s)(u);_&&i.push(_)})),i}filter(o,s){return new w(this.content.filter(o,s))}reject(o,s){return this.filter(u(o),s)}reduce(o,s){let i,u;void 0!==s?(i=0,u=this.refract(s)):(i=1,u="object"===this.primitive()?this.first.value:this.first);for(let s=i;s{o.bind(s)(i,this.refract(u))}))}shift(){return this.content.shift()}unshift(o){this.content.unshift(this.refract(o))}push(o){return this.content.push(this.refract(o)),this}add(o){this.push(o)}findElements(o,s){const i=s||{},u=!!i.recursive,_=void 0===i.results?[]:i.results;return this.forEach(((s,i,w)=>{u&&void 0!==s.findElements&&s.findElements(o,{results:_,recursive:u}),o(s,i,w)&&_.push(s)})),_}find(o){return new w(this.findElements(o,{recursive:!0}))}findByElement(o){return this.find((s=>s.element===o))}findByClass(o){return this.find((s=>s.classes.includes(o)))}getById(o){return this.find((s=>s.id.toValue()===o)).first}includes(o){return this.content.some((s=>s.equals(o)))}contains(o){return this.includes(o)}empty(){return new this.constructor([])}"fantasy-land/empty"(){return this.empty()}concat(o){return new this.constructor(this.content.concat(o.content))}"fantasy-land/concat"(o){return this.concat(o)}"fantasy-land/map"(o){return new this.constructor(this.map(o))}"fantasy-land/chain"(o){return this.map((s=>o(s)),this).reduce(((o,s)=>o.concat(s)),this.empty())}"fantasy-land/filter"(o){return new this.constructor(this.content.filter(o))}"fantasy-land/reduce"(o,s){return this.content.reduce(o,s)}get length(){return this.content.length}get isEmpty(){return 0===this.content.length}get first(){return this.getIndex(0)}get second(){return this.getIndex(1)}get last(){return this.getIndex(this.length-1)}}ArrayElement.empty=function empty(){return new this},ArrayElement["fantasy-land/empty"]=ArrayElement.empty,"undefined"!=typeof Symbol&&(ArrayElement.prototype[Symbol.iterator]=function symbol(){return this.content[Symbol.iterator]()}),o.exports=ArrayElement},12242:(o,s,i)=>{const u=i(10316);o.exports=class BooleanElement extends u{constructor(o,s,i){super(o,s,i),this.element="boolean"}primitive(){return"boolean"}}},10316:(o,s,i)=>{const u=i(2404),_=i(55973),w=i(92340);class Element{constructor(o,s,i){s&&(this.meta=s),i&&(this.attributes=i),this.content=o}freeze(){Object.isFrozen(this)||(this._meta&&(this.meta.parent=this,this.meta.freeze()),this._attributes&&(this.attributes.parent=this,this.attributes.freeze()),this.children.forEach((o=>{o.parent=this,o.freeze()}),this),this.content&&Array.isArray(this.content)&&Object.freeze(this.content),Object.freeze(this))}primitive(){}clone(){const o=new this.constructor;return o.element=this.element,this.meta.length&&(o._meta=this.meta.clone()),this.attributes.length&&(o._attributes=this.attributes.clone()),this.content?this.content.clone?o.content=this.content.clone():Array.isArray(this.content)?o.content=this.content.map((o=>o.clone())):o.content=this.content:o.content=this.content,o}toValue(){return this.content instanceof Element?this.content.toValue():this.content instanceof _?{key:this.content.key.toValue(),value:this.content.value?this.content.value.toValue():void 0}:this.content&&this.content.map?this.content.map((o=>o.toValue()),this):this.content}toRef(o){if(""===this.id.toValue())throw Error("Cannot create reference to an element that does not contain an ID");const s=new this.RefElement(this.id.toValue());return o&&(s.path=o),s}findRecursive(...o){if(arguments.length>1&&!this.isFrozen)throw new Error("Cannot find recursive with multiple element names without first freezing the element. Call `element.freeze()`");const s=o.pop();let i=new w;const append=(o,s)=>(o.push(s),o),checkElement=(o,i)=>{i.element===s&&o.push(i);const u=i.findRecursive(s);return u&&u.reduce(append,o),i.content instanceof _&&(i.content.key&&checkElement(o,i.content.key),i.content.value&&checkElement(o,i.content.value)),o};return this.content&&(this.content.element&&checkElement(i,this.content),Array.isArray(this.content)&&this.content.reduce(checkElement,i)),o.isEmpty||(i=i.filter((s=>{let i=s.parents.map((o=>o.element));for(const s in o){const u=o[s],_=i.indexOf(u);if(-1===_)return!1;i=i.splice(0,_)}return!0}))),i}set(o){return this.content=o,this}equals(o){return u(this.toValue(),o)}getMetaProperty(o,s){if(!this.meta.hasKey(o)){if(this.isFrozen){const o=this.refract(s);return o.freeze(),o}this.meta.set(o,s)}return this.meta.get(o)}setMetaProperty(o,s){this.meta.set(o,s)}get element(){return this._storedElement||"element"}set element(o){this._storedElement=o}get content(){return this._content}set content(o){if(o instanceof Element)this._content=o;else if(o instanceof w)this.content=o.elements;else if("string"==typeof o||"number"==typeof o||"boolean"==typeof o||"null"===o||null==o)this._content=o;else if(o instanceof _)this._content=o;else if(Array.isArray(o))this._content=o.map(this.refract);else{if("object"!=typeof o)throw new Error("Cannot set content to given value");this._content=Object.keys(o).map((s=>new this.MemberElement(s,o[s])))}}get meta(){if(!this._meta){if(this.isFrozen){const o=new this.ObjectElement;return o.freeze(),o}this._meta=new this.ObjectElement}return this._meta}set meta(o){o instanceof this.ObjectElement?this._meta=o:this.meta.set(o||{})}get attributes(){if(!this._attributes){if(this.isFrozen){const o=new this.ObjectElement;return o.freeze(),o}this._attributes=new this.ObjectElement}return this._attributes}set attributes(o){o instanceof this.ObjectElement?this._attributes=o:this.attributes.set(o||{})}get id(){return this.getMetaProperty("id","")}set id(o){this.setMetaProperty("id",o)}get classes(){return this.getMetaProperty("classes",[])}set classes(o){this.setMetaProperty("classes",o)}get title(){return this.getMetaProperty("title","")}set title(o){this.setMetaProperty("title",o)}get description(){return this.getMetaProperty("description","")}set description(o){this.setMetaProperty("description",o)}get links(){return this.getMetaProperty("links",[])}set links(o){this.setMetaProperty("links",o)}get isFrozen(){return Object.isFrozen(this)}get parents(){let{parent:o}=this;const s=new w;for(;o;)s.push(o),o=o.parent;return s}get children(){if(Array.isArray(this.content))return new w(this.content);if(this.content instanceof _){const o=new w([this.content.key]);return this.content.value&&o.push(this.content.value),o}return this.content instanceof Element?new w([this.content]):new w}get recursiveChildren(){const o=new w;return this.children.forEach((s=>{o.push(s),s.recursiveChildren.forEach((s=>{o.push(s)}))})),o}}o.exports=Element},87726:(o,s,i)=>{const u=i(55973),_=i(10316);o.exports=class MemberElement extends _{constructor(o,s,i,_){super(new u,i,_),this.element="member",this.key=o,this.value=s}get key(){return this.content.key}set key(o){this.content.key=this.refract(o)}get value(){return this.content.value}set value(o){this.content.value=this.refract(o)}}},41067:(o,s,i)=>{const u=i(10316);o.exports=class NullElement extends u{constructor(o,s,i){super(o||null,s,i),this.element="null"}primitive(){return"null"}set(){return new Error("Cannot set the value of null")}}},40239:(o,s,i)=>{const u=i(10316);o.exports=class NumberElement extends u{constructor(o,s,i){super(o,s,i),this.element="number"}primitive(){return"number"}}},61045:(o,s,i)=>{const u=i(6048),_=i(23805),w=i(6233),x=i(87726),C=i(10866);o.exports=class ObjectElement extends w{constructor(o,s,i){super(o||[],s,i),this.element="object"}primitive(){return"object"}toValue(){return this.content.reduce(((o,s)=>(o[s.key.toValue()]=s.value?s.value.toValue():void 0,o)),{})}get(o){const s=this.getMember(o);if(s)return s.value}getMember(o){if(void 0!==o)return this.content.find((s=>s.key.toValue()===o))}remove(o){let s=null;return this.content=this.content.filter((i=>i.key.toValue()!==o||(s=i,!1))),s}getKey(o){const s=this.getMember(o);if(s)return s.key}set(o,s){if(_(o))return Object.keys(o).forEach((s=>{this.set(s,o[s])})),this;const i=o,u=this.getMember(i);return u?u.value=s:this.content.push(new x(i,s)),this}keys(){return this.content.map((o=>o.key.toValue()))}values(){return this.content.map((o=>o.value.toValue()))}hasKey(o){return this.content.some((s=>s.key.equals(o)))}items(){return this.content.map((o=>[o.key.toValue(),o.value.toValue()]))}map(o,s){return this.content.map((i=>o.bind(s)(i.value,i.key,i)))}compactMap(o,s){const i=[];return this.forEach(((u,_,w)=>{const x=o.bind(s)(u,_,w);x&&i.push(x)})),i}filter(o,s){return new C(this.content).filter(o,s)}reject(o,s){return this.filter(u(o),s)}forEach(o,s){return this.content.forEach((i=>o.bind(s)(i.value,i.key,i)))}}},71167:(o,s,i)=>{const u=i(10316);o.exports=class StringElement extends u{constructor(o,s,i){super(o,s,i),this.element="string"}primitive(){return"string"}get length(){return this.content.length}}},75147:(o,s,i)=>{const u=i(85105);o.exports=class JSON06Serialiser extends u{serialise(o){if(!(o instanceof this.namespace.elements.Element))throw new TypeError(`Given element \`${o}\` is not an Element instance`);let s;o._attributes&&o.attributes.get("variable")&&(s=o.attributes.get("variable"));const i={element:o.element};o._meta&&o._meta.length>0&&(i.meta=this.serialiseObject(o.meta));const u="enum"===o.element||-1!==o.attributes.keys().indexOf("enumerations");if(u){const s=this.enumSerialiseAttributes(o);s&&(i.attributes=s)}else if(o._attributes&&o._attributes.length>0){let{attributes:u}=o;u.get("metadata")&&(u=u.clone(),u.set("meta",u.get("metadata")),u.remove("metadata")),"member"===o.element&&s&&(u=u.clone(),u.remove("variable")),u.length>0&&(i.attributes=this.serialiseObject(u))}if(u)i.content=this.enumSerialiseContent(o,i);else if(this[`${o.element}SerialiseContent`])i.content=this[`${o.element}SerialiseContent`](o,i);else if(void 0!==o.content){let u;s&&o.content.key?(u=o.content.clone(),u.key.attributes.set("variable",s),u=this.serialiseContent(u)):u=this.serialiseContent(o.content),this.shouldSerialiseContent(o,u)&&(i.content=u)}else this.shouldSerialiseContent(o,o.content)&&o instanceof this.namespace.elements.Array&&(i.content=[]);return i}shouldSerialiseContent(o,s){return"parseResult"===o.element||"httpRequest"===o.element||"httpResponse"===o.element||"category"===o.element||"link"===o.element||void 0!==s&&(!Array.isArray(s)||0!==s.length)}refSerialiseContent(o,s){return delete s.attributes,{href:o.toValue(),path:o.path.toValue()}}sourceMapSerialiseContent(o){return o.toValue()}dataStructureSerialiseContent(o){return[this.serialiseContent(o.content)]}enumSerialiseAttributes(o){const s=o.attributes.clone(),i=s.remove("enumerations")||new this.namespace.elements.Array([]),u=s.get("default");let _=s.get("samples")||new this.namespace.elements.Array([]);if(u&&u.content&&(u.content.attributes&&u.content.attributes.remove("typeAttributes"),s.set("default",new this.namespace.elements.Array([u.content]))),_.forEach((o=>{o.content&&o.content.element&&o.content.attributes.remove("typeAttributes")})),o.content&&0!==i.length&&_.unshift(o.content),_=_.map((o=>o instanceof this.namespace.elements.Array?[o]:new this.namespace.elements.Array([o.content]))),_.length&&s.set("samples",_),s.length>0)return this.serialiseObject(s)}enumSerialiseContent(o){if(o._attributes){const s=o.attributes.get("enumerations");if(s&&s.length>0)return s.content.map((o=>{const s=o.clone();return s.attributes.remove("typeAttributes"),this.serialise(s)}))}if(o.content){const s=o.content.clone();return s.attributes.remove("typeAttributes"),[this.serialise(s)]}return[]}deserialise(o){if("string"==typeof o)return new this.namespace.elements.String(o);if("number"==typeof o)return new this.namespace.elements.Number(o);if("boolean"==typeof o)return new this.namespace.elements.Boolean(o);if(null===o)return new this.namespace.elements.Null;if(Array.isArray(o))return new this.namespace.elements.Array(o.map(this.deserialise,this));const s=this.namespace.getElementClass(o.element),i=new s;i.element!==o.element&&(i.element=o.element),o.meta&&this.deserialiseObject(o.meta,i.meta),o.attributes&&this.deserialiseObject(o.attributes,i.attributes);const u=this.deserialiseContent(o.content);if(void 0===u&&null!==i.content||(i.content=u),"enum"===i.element){i.content&&i.attributes.set("enumerations",i.content);let o=i.attributes.get("samples");if(i.attributes.remove("samples"),o){const u=o;o=new this.namespace.elements.Array,u.forEach((u=>{u.forEach((u=>{const _=new s(u);_.element=i.element,o.push(_)}))}));const _=o.shift();i.content=_?_.content:void 0,i.attributes.set("samples",o)}else i.content=void 0;let u=i.attributes.get("default");if(u&&u.length>0){u=u.get(0);const o=new s(u);o.element=i.element,i.attributes.set("default",o)}}else if("dataStructure"===i.element&&Array.isArray(i.content))[i.content]=i.content;else if("category"===i.element){const o=i.attributes.get("meta");o&&(i.attributes.set("metadata",o),i.attributes.remove("meta"))}else"member"===i.element&&i.key&&i.key._attributes&&i.key._attributes.getValue("variable")&&(i.attributes.set("variable",i.key.attributes.get("variable")),i.key.attributes.remove("variable"));return i}serialiseContent(o){if(o instanceof this.namespace.elements.Element)return this.serialise(o);if(o instanceof this.namespace.KeyValuePair){const s={key:this.serialise(o.key)};return o.value&&(s.value=this.serialise(o.value)),s}return o&&o.map?o.map(this.serialise,this):o}deserialiseContent(o){if(o){if(o.element)return this.deserialise(o);if(o.key){const s=new this.namespace.KeyValuePair(this.deserialise(o.key));return o.value&&(s.value=this.deserialise(o.value)),s}if(o.map)return o.map(this.deserialise,this)}return o}shouldRefract(o){return!!(o._attributes&&o.attributes.keys().length||o._meta&&o.meta.keys().length)||"enum"!==o.element&&(o.element!==o.primitive()||"member"===o.element)}convertKeyToRefract(o,s){return this.shouldRefract(s)?this.serialise(s):"enum"===s.element?this.serialiseEnum(s):"array"===s.element?s.map((s=>this.shouldRefract(s)||"default"===o?this.serialise(s):"array"===s.element||"object"===s.element||"enum"===s.element?s.children.map((o=>this.serialise(o))):s.toValue())):"object"===s.element?(s.content||[]).map(this.serialise,this):s.toValue()}serialiseEnum(o){return o.children.map((o=>this.serialise(o)))}serialiseObject(o){const s={};return o.forEach(((o,i)=>{if(o){const u=i.toValue();s[u]=this.convertKeyToRefract(u,o)}})),s}deserialiseObject(o,s){Object.keys(o).forEach((i=>{s.set(i,this.deserialise(o[i]))}))}}},85105:o=>{o.exports=class JSONSerialiser{constructor(o){this.namespace=o||new this.Namespace}serialise(o){if(!(o instanceof this.namespace.elements.Element))throw new TypeError(`Given element \`${o}\` is not an Element instance`);const s={element:o.element};o._meta&&o._meta.length>0&&(s.meta=this.serialiseObject(o.meta)),o._attributes&&o._attributes.length>0&&(s.attributes=this.serialiseObject(o.attributes));const i=this.serialiseContent(o.content);return void 0!==i&&(s.content=i),s}deserialise(o){if(!o.element)throw new Error("Given value is not an object containing an element name");const s=new(this.namespace.getElementClass(o.element));s.element!==o.element&&(s.element=o.element),o.meta&&this.deserialiseObject(o.meta,s.meta),o.attributes&&this.deserialiseObject(o.attributes,s.attributes);const i=this.deserialiseContent(o.content);return void 0===i&&null!==s.content||(s.content=i),s}serialiseContent(o){if(o instanceof this.namespace.elements.Element)return this.serialise(o);if(o instanceof this.namespace.KeyValuePair){const s={key:this.serialise(o.key)};return o.value&&(s.value=this.serialise(o.value)),s}if(o&&o.map){if(0===o.length)return;return o.map(this.serialise,this)}return o}deserialiseContent(o){if(o){if(o.element)return this.deserialise(o);if(o.key){const s=new this.namespace.KeyValuePair(this.deserialise(o.key));return o.value&&(s.value=this.deserialise(o.value)),s}if(o.map)return o.map(this.deserialise,this)}return o}serialiseObject(o){const s={};if(o.forEach(((o,i)=>{o&&(s[i.toValue()]=this.serialise(o))})),0!==Object.keys(s).length)return s}deserialiseObject(o,s){Object.keys(o).forEach((i=>{s.set(i,this.deserialise(o[i]))}))}}},58859:(o,s,i)=>{var u="function"==typeof Map&&Map.prototype,_=Object.getOwnPropertyDescriptor&&u?Object.getOwnPropertyDescriptor(Map.prototype,"size"):null,w=u&&_&&"function"==typeof _.get?_.get:null,x=u&&Map.prototype.forEach,C="function"==typeof Set&&Set.prototype,j=Object.getOwnPropertyDescriptor&&C?Object.getOwnPropertyDescriptor(Set.prototype,"size"):null,L=C&&j&&"function"==typeof j.get?j.get:null,B=C&&Set.prototype.forEach,$="function"==typeof WeakMap&&WeakMap.prototype?WeakMap.prototype.has:null,V="function"==typeof WeakSet&&WeakSet.prototype?WeakSet.prototype.has:null,U="function"==typeof WeakRef&&WeakRef.prototype?WeakRef.prototype.deref:null,z=Boolean.prototype.valueOf,Y=Object.prototype.toString,Z=Function.prototype.toString,ee=String.prototype.match,ie=String.prototype.slice,ae=String.prototype.replace,ce=String.prototype.toUpperCase,le=String.prototype.toLowerCase,pe=RegExp.prototype.test,de=Array.prototype.concat,fe=Array.prototype.join,ye=Array.prototype.slice,be=Math.floor,_e="function"==typeof BigInt?BigInt.prototype.valueOf:null,we=Object.getOwnPropertySymbols,Se="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?Symbol.prototype.toString:null,xe="function"==typeof Symbol&&"object"==typeof Symbol.iterator,Pe="function"==typeof Symbol&&Symbol.toStringTag&&(typeof Symbol.toStringTag===xe||"symbol")?Symbol.toStringTag:null,Te=Object.prototype.propertyIsEnumerable,Re=("function"==typeof Reflect?Reflect.getPrototypeOf:Object.getPrototypeOf)||([].__proto__===Array.prototype?function(o){return o.__proto__}:null);function addNumericSeparator(o,s){if(o===1/0||o===-1/0||o!=o||o&&o>-1e3&&o<1e3||pe.call(/e/,s))return s;var i=/[0-9](?=(?:[0-9]{3})+(?![0-9]))/g;if("number"==typeof o){var u=o<0?-be(-o):be(o);if(u!==o){var _=String(u),w=ie.call(s,_.length+1);return ae.call(_,i,"$&_")+"."+ae.call(ae.call(w,/([0-9]{3})/g,"$&_"),/_$/,"")}}return ae.call(s,i,"$&_")}var qe=i(42634),$e=qe.custom,ze=isSymbol($e)?$e:null;function wrapQuotes(o,s,i){var u="double"===(i.quoteStyle||s)?'"':"'";return u+o+u}function quote(o){return ae.call(String(o),/"/g,""")}function isArray(o){return!("[object Array]"!==toStr(o)||Pe&&"object"==typeof o&&Pe in o)}function isRegExp(o){return!("[object RegExp]"!==toStr(o)||Pe&&"object"==typeof o&&Pe in o)}function isSymbol(o){if(xe)return o&&"object"==typeof o&&o instanceof Symbol;if("symbol"==typeof o)return!0;if(!o||"object"!=typeof o||!Se)return!1;try{return Se.call(o),!0}catch(o){}return!1}o.exports=function inspect_(o,s,u,_){var C=s||{};if(has(C,"quoteStyle")&&"single"!==C.quoteStyle&&"double"!==C.quoteStyle)throw new TypeError('option "quoteStyle" must be "single" or "double"');if(has(C,"maxStringLength")&&("number"==typeof C.maxStringLength?C.maxStringLength<0&&C.maxStringLength!==1/0:null!==C.maxStringLength))throw new TypeError('option "maxStringLength", if provided, must be a positive integer, Infinity, or `null`');var j=!has(C,"customInspect")||C.customInspect;if("boolean"!=typeof j&&"symbol"!==j)throw new TypeError("option \"customInspect\", if provided, must be `true`, `false`, or `'symbol'`");if(has(C,"indent")&&null!==C.indent&&"\t"!==C.indent&&!(parseInt(C.indent,10)===C.indent&&C.indent>0))throw new TypeError('option "indent" must be "\\t", an integer > 0, or `null`');if(has(C,"numericSeparator")&&"boolean"!=typeof C.numericSeparator)throw new TypeError('option "numericSeparator", if provided, must be `true` or `false`');var Y=C.numericSeparator;if(void 0===o)return"undefined";if(null===o)return"null";if("boolean"==typeof o)return o?"true":"false";if("string"==typeof o)return inspectString(o,C);if("number"==typeof o){if(0===o)return 1/0/o>0?"0":"-0";var ce=String(o);return Y?addNumericSeparator(o,ce):ce}if("bigint"==typeof o){var pe=String(o)+"n";return Y?addNumericSeparator(o,pe):pe}var be=void 0===C.depth?5:C.depth;if(void 0===u&&(u=0),u>=be&&be>0&&"object"==typeof o)return isArray(o)?"[Array]":"[Object]";var we=function getIndent(o,s){var i;if("\t"===o.indent)i="\t";else{if(!("number"==typeof o.indent&&o.indent>0))return null;i=fe.call(Array(o.indent+1)," ")}return{base:i,prev:fe.call(Array(s+1),i)}}(C,u);if(void 0===_)_=[];else if(indexOf(_,o)>=0)return"[Circular]";function inspect(o,s,i){if(s&&(_=ye.call(_)).push(s),i){var w={depth:C.depth};return has(C,"quoteStyle")&&(w.quoteStyle=C.quoteStyle),inspect_(o,w,u+1,_)}return inspect_(o,C,u+1,_)}if("function"==typeof o&&!isRegExp(o)){var $e=function nameOf(o){if(o.name)return o.name;var s=ee.call(Z.call(o),/^function\s*([\w$]+)/);if(s)return s[1];return null}(o),We=arrObjKeys(o,inspect);return"[Function"+($e?": "+$e:" (anonymous)")+"]"+(We.length>0?" { "+fe.call(We,", ")+" }":"")}if(isSymbol(o)){var He=xe?ae.call(String(o),/^(Symbol\(.*\))_[^)]*$/,"$1"):Se.call(o);return"object"!=typeof o||xe?He:markBoxed(He)}if(function isElement(o){if(!o||"object"!=typeof o)return!1;if("undefined"!=typeof HTMLElement&&o instanceof HTMLElement)return!0;return"string"==typeof o.nodeName&&"function"==typeof o.getAttribute}(o)){for(var Ye="<"+le.call(String(o.nodeName)),Xe=o.attributes||[],Qe=0;Qe"}if(isArray(o)){if(0===o.length)return"[]";var et=arrObjKeys(o,inspect);return we&&!function singleLineValues(o){for(var s=0;s=0)return!1;return!0}(et)?"["+indentedJoin(et,we)+"]":"[ "+fe.call(et,", ")+" ]"}if(function isError(o){return!("[object Error]"!==toStr(o)||Pe&&"object"==typeof o&&Pe in o)}(o)){var tt=arrObjKeys(o,inspect);return"cause"in Error.prototype||!("cause"in o)||Te.call(o,"cause")?0===tt.length?"["+String(o)+"]":"{ ["+String(o)+"] "+fe.call(tt,", ")+" }":"{ ["+String(o)+"] "+fe.call(de.call("[cause]: "+inspect(o.cause),tt),", ")+" }"}if("object"==typeof o&&j){if(ze&&"function"==typeof o[ze]&&qe)return qe(o,{depth:be-u});if("symbol"!==j&&"function"==typeof o.inspect)return o.inspect()}if(function isMap(o){if(!w||!o||"object"!=typeof o)return!1;try{w.call(o);try{L.call(o)}catch(o){return!0}return o instanceof Map}catch(o){}return!1}(o)){var rt=[];return x&&x.call(o,(function(s,i){rt.push(inspect(i,o,!0)+" => "+inspect(s,o))})),collectionOf("Map",w.call(o),rt,we)}if(function isSet(o){if(!L||!o||"object"!=typeof o)return!1;try{L.call(o);try{w.call(o)}catch(o){return!0}return o instanceof Set}catch(o){}return!1}(o)){var nt=[];return B&&B.call(o,(function(s){nt.push(inspect(s,o))})),collectionOf("Set",L.call(o),nt,we)}if(function isWeakMap(o){if(!$||!o||"object"!=typeof o)return!1;try{$.call(o,$);try{V.call(o,V)}catch(o){return!0}return o instanceof WeakMap}catch(o){}return!1}(o))return weakCollectionOf("WeakMap");if(function isWeakSet(o){if(!V||!o||"object"!=typeof o)return!1;try{V.call(o,V);try{$.call(o,$)}catch(o){return!0}return o instanceof WeakSet}catch(o){}return!1}(o))return weakCollectionOf("WeakSet");if(function isWeakRef(o){if(!U||!o||"object"!=typeof o)return!1;try{return U.call(o),!0}catch(o){}return!1}(o))return weakCollectionOf("WeakRef");if(function isNumber(o){return!("[object Number]"!==toStr(o)||Pe&&"object"==typeof o&&Pe in o)}(o))return markBoxed(inspect(Number(o)));if(function isBigInt(o){if(!o||"object"!=typeof o||!_e)return!1;try{return _e.call(o),!0}catch(o){}return!1}(o))return markBoxed(inspect(_e.call(o)));if(function isBoolean(o){return!("[object Boolean]"!==toStr(o)||Pe&&"object"==typeof o&&Pe in o)}(o))return markBoxed(z.call(o));if(function isString(o){return!("[object String]"!==toStr(o)||Pe&&"object"==typeof o&&Pe in o)}(o))return markBoxed(inspect(String(o)));if("undefined"!=typeof window&&o===window)return"{ [object Window] }";if(o===i.g)return"{ [object globalThis] }";if(!function isDate(o){return!("[object Date]"!==toStr(o)||Pe&&"object"==typeof o&&Pe in o)}(o)&&!isRegExp(o)){var ot=arrObjKeys(o,inspect),st=Re?Re(o)===Object.prototype:o instanceof Object||o.constructor===Object,it=o instanceof Object?"":"null prototype",at=!st&&Pe&&Object(o)===o&&Pe in o?ie.call(toStr(o),8,-1):it?"Object":"",ct=(st||"function"!=typeof o.constructor?"":o.constructor.name?o.constructor.name+" ":"")+(at||it?"["+fe.call(de.call([],at||[],it||[]),": ")+"] ":"");return 0===ot.length?ct+"{}":we?ct+"{"+indentedJoin(ot,we)+"}":ct+"{ "+fe.call(ot,", ")+" }"}return String(o)};var We=Object.prototype.hasOwnProperty||function(o){return o in this};function has(o,s){return We.call(o,s)}function toStr(o){return Y.call(o)}function indexOf(o,s){if(o.indexOf)return o.indexOf(s);for(var i=0,u=o.length;is.maxStringLength){var i=o.length-s.maxStringLength,u="... "+i+" more character"+(i>1?"s":"");return inspectString(ie.call(o,0,s.maxStringLength),s)+u}return wrapQuotes(ae.call(ae.call(o,/(['\\])/g,"\\$1"),/[\x00-\x1f]/g,lowbyte),"single",s)}function lowbyte(o){var s=o.charCodeAt(0),i={8:"b",9:"t",10:"n",12:"f",13:"r"}[s];return i?"\\"+i:"\\x"+(s<16?"0":"")+ce.call(s.toString(16))}function markBoxed(o){return"Object("+o+")"}function weakCollectionOf(o){return o+" { ? }"}function collectionOf(o,s,i,u){return o+" ("+s+") {"+(u?indentedJoin(i,u):fe.call(i,", "))+"}"}function indentedJoin(o,s){if(0===o.length)return"";var i="\n"+s.prev+s.base;return i+fe.call(o,","+i)+"\n"+s.prev}function arrObjKeys(o,s){var i=isArray(o),u=[];if(i){u.length=o.length;for(var _=0;_{var s,i,u=o.exports={};function defaultSetTimout(){throw new Error("setTimeout has not been defined")}function defaultClearTimeout(){throw new Error("clearTimeout has not been defined")}function runTimeout(o){if(s===setTimeout)return setTimeout(o,0);if((s===defaultSetTimout||!s)&&setTimeout)return s=setTimeout,setTimeout(o,0);try{return s(o,0)}catch(i){try{return s.call(null,o,0)}catch(i){return s.call(this,o,0)}}}!function(){try{s="function"==typeof setTimeout?setTimeout:defaultSetTimout}catch(o){s=defaultSetTimout}try{i="function"==typeof clearTimeout?clearTimeout:defaultClearTimeout}catch(o){i=defaultClearTimeout}}();var _,w=[],x=!1,C=-1;function cleanUpNextTick(){x&&_&&(x=!1,_.length?w=_.concat(w):C=-1,w.length&&drainQueue())}function drainQueue(){if(!x){var o=runTimeout(cleanUpNextTick);x=!0;for(var s=w.length;s;){for(_=w,w=[];++C1)for(var i=1;i{"use strict";var u=i(6925);function emptyFunction(){}function emptyFunctionWithReset(){}emptyFunctionWithReset.resetWarningCache=emptyFunction,o.exports=function(){function shim(o,s,i,_,w,x){if(x!==u){var C=new Error("Calling PropTypes validators directly is not supported by the `prop-types` package. Use PropTypes.checkPropTypes() to call them. Read more at http://fb.me/use-check-prop-types");throw C.name="Invariant Violation",C}}function getShim(){return shim}shim.isRequired=shim;var o={array:shim,bigint:shim,bool:shim,func:shim,number:shim,object:shim,string:shim,symbol:shim,any:shim,arrayOf:getShim,element:shim,elementType:shim,instanceOf:getShim,node:shim,objectOf:getShim,oneOf:getShim,oneOfType:getShim,shape:getShim,exact:getShim,checkPropTypes:emptyFunctionWithReset,resetWarningCache:emptyFunction};return o.PropTypes=o,o}},5556:(o,s,i)=>{o.exports=i(2694)()},6925:o=>{"use strict";o.exports="SECRET_DO_NOT_PASS_THIS_OR_YOU_WILL_BE_FIRED"},74765:o=>{"use strict";var s=String.prototype.replace,i=/%20/g,u="RFC1738",_="RFC3986";o.exports={default:_,formatters:{RFC1738:function(o){return s.call(o,i,"+")},RFC3986:function(o){return String(o)}},RFC1738:u,RFC3986:_}},55373:(o,s,i)=>{"use strict";var u=i(98636),_=i(62642),w=i(74765);o.exports={formats:w,parse:_,stringify:u}},62642:(o,s,i)=>{"use strict";var u=i(37720),_=Object.prototype.hasOwnProperty,w=Array.isArray,x={allowDots:!1,allowPrototypes:!1,allowSparse:!1,arrayLimit:20,charset:"utf-8",charsetSentinel:!1,comma:!1,decoder:u.decode,delimiter:"&",depth:5,ignoreQueryPrefix:!1,interpretNumericEntities:!1,parameterLimit:1e3,parseArrays:!0,plainObjects:!1,strictNullHandling:!1},interpretNumericEntities=function(o){return o.replace(/&#(\d+);/g,(function(o,s){return String.fromCharCode(parseInt(s,10))}))},parseArrayValue=function(o,s){return o&&"string"==typeof o&&s.comma&&o.indexOf(",")>-1?o.split(","):o},C=function parseQueryStringKeys(o,s,i,u){if(o){var w=i.allowDots?o.replace(/\.([^.[]+)/g,"[$1]"):o,x=/(\[[^[\]]*])/g,C=i.depth>0&&/(\[[^[\]]*])/.exec(w),j=C?w.slice(0,C.index):w,L=[];if(j){if(!i.plainObjects&&_.call(Object.prototype,j)&&!i.allowPrototypes)return;L.push(j)}for(var B=0;i.depth>0&&null!==(C=x.exec(w))&&B=0;--w){var x,C=o[w];if("[]"===C&&i.parseArrays)x=[].concat(_);else{x=i.plainObjects?Object.create(null):{};var j="["===C.charAt(0)&&"]"===C.charAt(C.length-1)?C.slice(1,-1):C,L=parseInt(j,10);i.parseArrays||""!==j?!isNaN(L)&&C!==j&&String(L)===j&&L>=0&&i.parseArrays&&L<=i.arrayLimit?(x=[])[L]=_:"__proto__"!==j&&(x[j]=_):x={0:_}}_=x}return _}(L,s,i,u)}};o.exports=function(o,s){var i=function normalizeParseOptions(o){if(!o)return x;if(null!==o.decoder&&void 0!==o.decoder&&"function"!=typeof o.decoder)throw new TypeError("Decoder has to be a function.");if(void 0!==o.charset&&"utf-8"!==o.charset&&"iso-8859-1"!==o.charset)throw new TypeError("The charset option must be either utf-8, iso-8859-1, or undefined");var s=void 0===o.charset?x.charset:o.charset;return{allowDots:void 0===o.allowDots?x.allowDots:!!o.allowDots,allowPrototypes:"boolean"==typeof o.allowPrototypes?o.allowPrototypes:x.allowPrototypes,allowSparse:"boolean"==typeof o.allowSparse?o.allowSparse:x.allowSparse,arrayLimit:"number"==typeof o.arrayLimit?o.arrayLimit:x.arrayLimit,charset:s,charsetSentinel:"boolean"==typeof o.charsetSentinel?o.charsetSentinel:x.charsetSentinel,comma:"boolean"==typeof o.comma?o.comma:x.comma,decoder:"function"==typeof o.decoder?o.decoder:x.decoder,delimiter:"string"==typeof o.delimiter||u.isRegExp(o.delimiter)?o.delimiter:x.delimiter,depth:"number"==typeof o.depth||!1===o.depth?+o.depth:x.depth,ignoreQueryPrefix:!0===o.ignoreQueryPrefix,interpretNumericEntities:"boolean"==typeof o.interpretNumericEntities?o.interpretNumericEntities:x.interpretNumericEntities,parameterLimit:"number"==typeof o.parameterLimit?o.parameterLimit:x.parameterLimit,parseArrays:!1!==o.parseArrays,plainObjects:"boolean"==typeof o.plainObjects?o.plainObjects:x.plainObjects,strictNullHandling:"boolean"==typeof o.strictNullHandling?o.strictNullHandling:x.strictNullHandling}}(s);if(""===o||null==o)return i.plainObjects?Object.create(null):{};for(var j="string"==typeof o?function parseQueryStringValues(o,s){var i,C={},j=s.ignoreQueryPrefix?o.replace(/^\?/,""):o,L=s.parameterLimit===1/0?void 0:s.parameterLimit,B=j.split(s.delimiter,L),$=-1,V=s.charset;if(s.charsetSentinel)for(i=0;i-1&&(z=w(z)?[z]:z),_.call(C,U)?C[U]=u.combine(C[U],z):C[U]=z}return C}(o,i):o,L=i.plainObjects?Object.create(null):{},B=Object.keys(j),$=0;${"use strict";var u=i(920),_=i(37720),w=i(74765),x=Object.prototype.hasOwnProperty,C={brackets:function brackets(o){return o+"[]"},comma:"comma",indices:function indices(o,s){return o+"["+s+"]"},repeat:function repeat(o){return o}},j=Array.isArray,L=String.prototype.split,B=Array.prototype.push,pushToArray=function(o,s){B.apply(o,j(s)?s:[s])},$=Date.prototype.toISOString,V=w.default,U={addQueryPrefix:!1,allowDots:!1,charset:"utf-8",charsetSentinel:!1,delimiter:"&",encode:!0,encoder:_.encode,encodeValuesOnly:!1,format:V,formatter:w.formatters[V],indices:!1,serializeDate:function serializeDate(o){return $.call(o)},skipNulls:!1,strictNullHandling:!1},z={},Y=function stringify(o,s,i,w,x,C,B,$,V,Y,Z,ee,ie,ae,ce,le){for(var pe=o,de=le,fe=0,ye=!1;void 0!==(de=de.get(z))&&!ye;){var be=de.get(o);if(fe+=1,void 0!==be){if(be===fe)throw new RangeError("Cyclic object value");ye=!0}void 0===de.get(z)&&(fe=0)}if("function"==typeof $?pe=$(s,pe):pe instanceof Date?pe=Z(pe):"comma"===i&&j(pe)&&(pe=_.maybeMap(pe,(function(o){return o instanceof Date?Z(o):o}))),null===pe){if(x)return B&&!ae?B(s,U.encoder,ce,"key",ee):s;pe=""}if(function isNonNullishPrimitive(o){return"string"==typeof o||"number"==typeof o||"boolean"==typeof o||"symbol"==typeof o||"bigint"==typeof o}(pe)||_.isBuffer(pe)){if(B){var _e=ae?s:B(s,U.encoder,ce,"key",ee);if("comma"===i&&ae){for(var we=L.call(String(pe),","),Se="",xe=0;xe0?pe.join(",")||null:void 0}];else if(j($))Pe=$;else{var Re=Object.keys(pe);Pe=V?Re.sort(V):Re}for(var qe=w&&j(pe)&&1===pe.length?s+"[]":s,$e=0;$e0?ce+ae:""}},37720:(o,s,i)=>{"use strict";var u=i(74765),_=Object.prototype.hasOwnProperty,w=Array.isArray,x=function(){for(var o=[],s=0;s<256;++s)o.push("%"+((s<16?"0":"")+s.toString(16)).toUpperCase());return o}(),C=function arrayToObject(o,s){for(var i=s&&s.plainObjects?Object.create(null):{},u=0;u1;){var s=o.pop(),i=s.obj[s.prop];if(w(i)){for(var u=[],_=0;_=48&&B<=57||B>=65&&B<=90||B>=97&&B<=122||w===u.RFC1738&&(40===B||41===B)?j+=C.charAt(L):B<128?j+=x[B]:B<2048?j+=x[192|B>>6]+x[128|63&B]:B<55296||B>=57344?j+=x[224|B>>12]+x[128|B>>6&63]+x[128|63&B]:(L+=1,B=65536+((1023&B)<<10|1023&C.charCodeAt(L)),j+=x[240|B>>18]+x[128|B>>12&63]+x[128|B>>6&63]+x[128|63&B])}return j},isBuffer:function isBuffer(o){return!(!o||"object"!=typeof o)&&!!(o.constructor&&o.constructor.isBuffer&&o.constructor.isBuffer(o))},isRegExp:function isRegExp(o){return"[object RegExp]"===Object.prototype.toString.call(o)},maybeMap:function maybeMap(o,s){if(w(o)){for(var i=[],u=0;u{"use strict";var i=Object.prototype.hasOwnProperty;function decode(o){try{return decodeURIComponent(o.replace(/\+/g," "))}catch(o){return null}}function encode(o){try{return encodeURIComponent(o)}catch(o){return null}}s.stringify=function querystringify(o,s){s=s||"";var u,_,w=[];for(_ in"string"!=typeof s&&(s="?"),o)if(i.call(o,_)){if((u=o[_])||null!=u&&!isNaN(u)||(u=""),_=encode(_),u=encode(u),null===_||null===u)continue;w.push(_+"="+u)}return w.length?s+w.join("&"):""},s.parse=function querystring(o){for(var s,i=/([^=?#&]+)=?([^&]*)/g,u={};s=i.exec(o);){var _=decode(s[1]),w=decode(s[2]);null===_||null===w||_ in u||(u[_]=w)}return u}},41859:(o,s,i)=>{const u=i(27096),_=i(78004),w=u.types;o.exports=class RandExp{constructor(o,s){if(this._setDefaults(o),o instanceof RegExp)this.ignoreCase=o.ignoreCase,this.multiline=o.multiline,o=o.source;else{if("string"!=typeof o)throw new Error("Expected a regexp or string");this.ignoreCase=s&&-1!==s.indexOf("i"),this.multiline=s&&-1!==s.indexOf("m")}this.tokens=u(o)}_setDefaults(o){this.max=null!=o.max?o.max:null!=RandExp.prototype.max?RandExp.prototype.max:100,this.defaultRange=o.defaultRange?o.defaultRange:this.defaultRange.clone(),o.randInt&&(this.randInt=o.randInt)}gen(){return this._gen(this.tokens,[])}_gen(o,s){var i,u,_,x,C;switch(o.type){case w.ROOT:case w.GROUP:if(o.followedBy||o.notFollowedBy)return"";for(o.remember&&void 0===o.groupNumber&&(o.groupNumber=s.push(null)-1),u="",x=0,C=(i=o.options?this._randSelect(o.options):o.stack).length;x{"use strict";var u=i(65606),_=65536,w=4294967295;var x=i(92861).Buffer,C=i.g.crypto||i.g.msCrypto;C&&C.getRandomValues?o.exports=function randomBytes(o,s){if(o>w)throw new RangeError("requested too many random bytes");var i=x.allocUnsafe(o);if(o>0)if(o>_)for(var j=0;j{"use strict";function _typeof(o){return _typeof="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(o){return typeof o}:function(o){return o&&"function"==typeof Symbol&&o.constructor===Symbol&&o!==Symbol.prototype?"symbol":typeof o},_typeof(o)}Object.defineProperty(s,"__esModule",{value:!0}),s.CopyToClipboard=void 0;var u=_interopRequireDefault(i(96540)),_=_interopRequireDefault(i(17965)),w=["text","onCopy","options","children"];function _interopRequireDefault(o){return o&&o.__esModule?o:{default:o}}function ownKeys(o,s){var i=Object.keys(o);if(Object.getOwnPropertySymbols){var u=Object.getOwnPropertySymbols(o);s&&(u=u.filter((function(s){return Object.getOwnPropertyDescriptor(o,s).enumerable}))),i.push.apply(i,u)}return i}function _objectSpread(o){for(var s=1;s=0||(_[i]=o[i]);return _}(o,s);if(Object.getOwnPropertySymbols){var w=Object.getOwnPropertySymbols(o);for(u=0;u=0||Object.prototype.propertyIsEnumerable.call(o,i)&&(_[i]=o[i])}return _}function _defineProperties(o,s){for(var i=0;i{"use strict";var u=i(25264).CopyToClipboard;u.CopyToClipboard=u,o.exports=u},81214:(o,s,i)=>{"use strict";function _typeof(o){return _typeof="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(o){return typeof o}:function(o){return o&&"function"==typeof Symbol&&o.constructor===Symbol&&o!==Symbol.prototype?"symbol":typeof o},_typeof(o)}Object.defineProperty(s,"__esModule",{value:!0}),s.DebounceInput=void 0;var u=_interopRequireDefault(i(96540)),_=_interopRequireDefault(i(20181)),w=["element","onChange","value","minLength","debounceTimeout","forceNotifyByEnter","forceNotifyOnBlur","onKeyDown","onBlur","inputRef"];function _interopRequireDefault(o){return o&&o.__esModule?o:{default:o}}function _objectWithoutProperties(o,s){if(null==o)return{};var i,u,_=function _objectWithoutPropertiesLoose(o,s){if(null==o)return{};var i,u,_={},w=Object.keys(o);for(u=0;u=0||(_[i]=o[i]);return _}(o,s);if(Object.getOwnPropertySymbols){var w=Object.getOwnPropertySymbols(o);for(u=0;u=0||Object.prototype.propertyIsEnumerable.call(o,i)&&(_[i]=o[i])}return _}function ownKeys(o,s){var i=Object.keys(o);if(Object.getOwnPropertySymbols){var u=Object.getOwnPropertySymbols(o);s&&(u=u.filter((function(s){return Object.getOwnPropertyDescriptor(o,s).enumerable}))),i.push.apply(i,u)}return i}function _objectSpread(o){for(var s=1;s=u?i.notify(o):s.length>_.length&&i.notify(_objectSpread(_objectSpread({},o),{},{target:_objectSpread(_objectSpread({},o.target),{},{value:""})}))}))})),_defineProperty(_assertThisInitialized(i),"onKeyDown",(function(o){"Enter"===o.key&&i.forceNotify(o);var s=i.props.onKeyDown;s&&(o.persist(),s(o))})),_defineProperty(_assertThisInitialized(i),"onBlur",(function(o){i.forceNotify(o);var s=i.props.onBlur;s&&(o.persist(),s(o))})),_defineProperty(_assertThisInitialized(i),"createNotifier",(function(o){if(o<0)i.notify=function(){return null};else if(0===o)i.notify=i.doNotify;else{var s=(0,_.default)((function(o){i.isDebouncing=!1,i.doNotify(o)}),o);i.notify=function(o){i.isDebouncing=!0,s(o)},i.flush=function(){return s.flush()},i.cancel=function(){i.isDebouncing=!1,s.cancel()}}})),_defineProperty(_assertThisInitialized(i),"doNotify",(function(){i.props.onChange.apply(void 0,arguments)})),_defineProperty(_assertThisInitialized(i),"forceNotify",(function(o){var s=i.props.debounceTimeout;if(i.isDebouncing||!(s>0)){i.cancel&&i.cancel();var u=i.state.value,_=i.props.minLength;u.length>=_?i.doNotify(o):i.doNotify(_objectSpread(_objectSpread({},o),{},{target:_objectSpread(_objectSpread({},o.target),{},{value:u})}))}})),i.isDebouncing=!1,i.state={value:void 0===o.value||null===o.value?"":o.value};var u=i.props.debounceTimeout;return i.createNotifier(u),i}return function _createClass(o,s,i){return s&&_defineProperties(o.prototype,s),i&&_defineProperties(o,i),Object.defineProperty(o,"prototype",{writable:!1}),o}(DebounceInput,[{key:"componentDidUpdate",value:function componentDidUpdate(o){if(!this.isDebouncing){var s=this.props,i=s.value,u=s.debounceTimeout,_=o.debounceTimeout,w=o.value,x=this.state.value;void 0!==i&&w!==i&&x!==i&&this.setState({value:i}),u!==_&&this.createNotifier(u)}}},{key:"componentWillUnmount",value:function componentWillUnmount(){this.flush&&this.flush()}},{key:"render",value:function render(){var o,s,i=this.props,_=i.element,x=(i.onChange,i.value,i.minLength,i.debounceTimeout,i.forceNotifyByEnter),C=i.forceNotifyOnBlur,j=i.onKeyDown,L=i.onBlur,B=i.inputRef,$=_objectWithoutProperties(i,w),V=this.state.value;o=x?{onKeyDown:this.onKeyDown}:j?{onKeyDown:j}:{},s=C?{onBlur:this.onBlur}:L?{onBlur:L}:{};var U=B?{ref:B}:{};return u.default.createElement(_,_objectSpread(_objectSpread(_objectSpread(_objectSpread({},$),{},{onChange:this.onChange,value:V},o),s),U))}}]),DebounceInput}(u.default.PureComponent);s.DebounceInput=x,_defineProperty(x,"defaultProps",{element:"input",type:"text",onKeyDown:void 0,onBlur:void 0,value:void 0,minLength:0,debounceTimeout:100,forceNotifyByEnter:!0,forceNotifyOnBlur:!0,inputRef:void 0})},24677:(o,s,i)=>{"use strict";var u=i(81214).DebounceInput;u.DebounceInput=u,o.exports=u},22551:(o,s,i)=>{"use strict";var u=i(96540),_=i(69982);function p(o){for(var s="https://reactjs.org/docs/error-decoder.html?invariant="+o,i=1;i