├── .github
└── workflows
│ ├── deploy.yml
│ └── ktlint.yml
├── .gitignore
├── Dockerfile
├── README.md
├── build.gradle.kts
├── docker
└── docker-compose.yml
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── graphql.config.yml
├── settings.gradle.kts
└── src
├── main
├── kotlin
│ └── com
│ │ └── doongjun
│ │ └── commitmon
│ │ ├── CommitmonApplication.kt
│ │ ├── animation
│ │ └── AdventureController.kt
│ │ ├── api
│ │ ├── AccountController.kt
│ │ ├── CommitmonController.kt
│ │ ├── MeController.kt
│ │ └── data
│ │ │ ├── ChangeCommitmonRequest.kt
│ │ │ ├── CommitmonResponse.kt
│ │ │ ├── MeDetailResponse.kt
│ │ │ ├── MeResponse.kt
│ │ │ ├── RedirectDestination.kt
│ │ │ ├── RefreshTokenRequest.kt
│ │ │ └── RefreshTokenResponse.kt
│ │ ├── app
│ │ ├── AccountFacade.kt
│ │ ├── AdventureFacade.kt
│ │ ├── GithubOAuth2Service.kt
│ │ ├── GithubService.kt
│ │ ├── Theme.kt
│ │ ├── UserFetchType.kt
│ │ ├── UserService.kt
│ │ └── data
│ │ │ ├── AuthDto.kt
│ │ │ ├── CreateUserDto.kt
│ │ │ ├── GetSimpleUserDto.kt
│ │ │ ├── GetUserCommitInfo.kt
│ │ │ ├── GetUserDto.kt
│ │ │ ├── GetUserFollowInfoDto.kt
│ │ │ └── UpdateUserDto.kt
│ │ ├── config
│ │ ├── AsyncConfig.kt
│ │ ├── CacheConfig.kt
│ │ ├── JpaConfig.kt
│ │ ├── RedisConfig.kt
│ │ ├── SwaggerConfig.kt
│ │ ├── ThymeleafConfig.kt
│ │ ├── WebClientConfig.kt
│ │ └── security
│ │ │ ├── JwtFilter.kt
│ │ │ ├── RefreshToken.kt
│ │ │ ├── RefreshTokenRepository.kt
│ │ │ ├── RestAccessDeniedHandler.kt
│ │ │ ├── RestAuthenticationEntryPoint.kt
│ │ │ ├── SecurityConfig.kt
│ │ │ └── TokenProvider.kt
│ │ ├── core
│ │ ├── AdventureGenerator.kt
│ │ ├── BaseEntity.kt
│ │ ├── NoArgs.kt
│ │ └── error
│ │ │ ├── AnimationExceptionHandler.kt
│ │ │ ├── GlobalExceptionHandler.kt
│ │ │ └── response
│ │ │ ├── ErrorCode.kt
│ │ │ └── ErrorResponse.kt
│ │ ├── domain
│ │ ├── Commitmon.kt
│ │ ├── CommitmonLevel.kt
│ │ ├── Follow.kt
│ │ ├── User.kt
│ │ └── UserRepository.kt
│ │ ├── event
│ │ ├── ApplicationEventListener.kt
│ │ └── UpdateUserInfo.kt
│ │ ├── extension
│ │ ├── EnumExtension.kt
│ │ ├── SecurityExtension.kt
│ │ └── StringExtension.kt
│ │ └── infra
│ │ ├── GithubGraphqlApi.kt
│ │ ├── GithubOAuth2Api.kt
│ │ ├── GithubRestApi.kt
│ │ └── data
│ │ ├── GraphqlRequest.kt
│ │ ├── GraphqlResponse.kt
│ │ ├── OAuthLoginResponse.kt
│ │ ├── UserCommitSearchResponse.kt
│ │ ├── UserFollowInfoResponse.kt
│ │ ├── UserFollowInfoVariables.kt
│ │ ├── UserFollowersResponse.kt
│ │ ├── UserFollowingResponse.kt
│ │ └── UserInfoResponse.kt
└── resources
│ ├── application.yml
│ ├── graphql
│ ├── user-follow-info-query.graphql
│ ├── user-followers-query.graphql
│ └── user-following-query.graphql
│ ├── static
│ └── theme
│ │ ├── desert.svg
│ │ ├── grassland.svg
│ │ └── transparent.svg
│ └── templates
│ ├── adventure.svg
│ ├── asset
│ ├── agumon.svg
│ ├── angemon.svg
│ ├── angewomon.svg
│ ├── atlurkabuterimon.svg
│ ├── birdramon.svg
│ ├── botamon.svg
│ ├── bubbmon.svg
│ ├── egg.svg
│ ├── gabumon.svg
│ ├── garudamon.svg
│ ├── garurumon.svg
│ ├── gatomon.svg
│ ├── gomamon.svg
│ ├── greymon.svg
│ ├── herculeskabuterimon.svg
│ ├── holyangemon.svg
│ ├── holydramon.svg
│ ├── ikkakumon.svg
│ ├── kabuterimon.svg
│ ├── koromon.svg
│ ├── lilymon.svg
│ ├── metalgarurumon.svg
│ ├── metalgreymon.svg
│ ├── motimon.svg
│ ├── nyaromon.svg
│ ├── nyokimon.svg
│ ├── palmon.svg
│ ├── patamon.svg
│ ├── phoenixmon.svg
│ ├── pichimon.svg
│ ├── piyomon.svg
│ ├── poyomon.svg
│ ├── pukamon.svg
│ ├── punimon.svg
│ ├── pyokomon.svg
│ ├── rosemon.svg
│ ├── salamon.svg
│ ├── seraphimon.svg
│ ├── snowbotamon.svg
│ ├── tanemon.svg
│ ├── tentomon.svg
│ ├── togemon.svg
│ ├── tokomon.svg
│ ├── tsunomon.svg
│ ├── vikemon.svg
│ ├── wargreymon.svg
│ ├── weregarurumon.svg
│ ├── yuramon.svg
│ └── zudomon.svg
│ └── error.svg
└── test
├── kotlin
└── com
│ └── doongjun
│ └── commitmon
│ ├── app
│ ├── BaseAppTest.kt
│ ├── GithubServiceTest.kt
│ └── UserServiceTest.kt
│ ├── container
│ ├── TestMariaDBContainer.kt
│ └── TestRedisContainer.kt
│ └── domain
│ └── UserTest.kt
└── resources
└── application-test.yml
/.github/workflows/deploy.yml:
--------------------------------------------------------------------------------
1 | name: SpringBoot APP CI/CD
2 | on:
3 | push:
4 | branches: [ main ]
5 | jobs:
6 | deploy:
7 | name: Build Image
8 | runs-on: ubuntu-latest
9 | steps:
10 | - name: Check out code
11 | uses: actions/checkout@v2
12 | - name: Configure AWS credentials
13 | uses: aws-actions/configure-aws-credentials@v2
14 | with:
15 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
16 | aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
17 | aws-region: ap-northeast-2
18 | - name: Login to Amazon ECR
19 | id: login-ecr
20 | uses: aws-actions/amazon-ecr-login@v1
21 | - name: Build, tag, and push image to Amazon ECR
22 | env:
23 | ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
24 | ECR_REPOSITORY: commitmon
25 | IMAGE_TAG: ${{ github.run_number }}
26 | run: |
27 | docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG -t $ECR_REGISTRY/$ECR_REPOSITORY:latest .
28 | docker push $ECR_REGISTRY/$ECR_REPOSITORY --all-tags
29 | - name: Deploy to Amazon EC2
30 | run: |
31 | aws deploy create-deployment \
32 | --application-name ${{ secrets.AWS_CODE_DEPLOY_APPLICATION }} \
33 | --deployment-config-name CodeDeployDefault.OneAtATime \
34 | --deployment-group-name ${{ secrets.AWS_CODE_DEPLOY_GROUP }} \
35 | --description "Deploy commitmon" \
36 | --s3-location bucket=${{ secrets.AWS_S3_BUCKET_NAME }},key=${{ secrets.AWS_REVISION_S3_KEY }},bundleType=tar
--------------------------------------------------------------------------------
/.github/workflows/ktlint.yml:
--------------------------------------------------------------------------------
1 | name: reviewdog
2 | on: [pull_request]
3 | jobs:
4 | ktlint:
5 | name: Check Code Quality
6 | runs-on: ubuntu-latest
7 |
8 | steps:
9 | - name: Clone repo
10 | uses: actions/checkout@master
11 | with:
12 | fetch-depth: 1
13 | - name: ktlint
14 | uses: ScaCap/action-ktlint@master
15 | with:
16 | github_token: ${{ secrets.github_token }}
17 | reporter: github-pr-check
18 | fail_on_error: true
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | HELP.md
2 | .gradle
3 | build/
4 | !gradle/wrapper/gradle-wrapper.jar
5 | !**/src/main/**/build/
6 | !**/src/test/**/build/
7 |
8 | ### STS ###
9 | .apt_generated
10 | .classpath
11 | .factorypath
12 | .project
13 | .settings
14 | .springBeans
15 | .sts4-cache
16 | bin/
17 | !**/src/main/**/bin/
18 | !**/src/test/**/bin/
19 |
20 | ### IntelliJ IDEA ###
21 | .idea
22 | *.iws
23 | *.iml
24 | *.ipr
25 | out/
26 | !**/src/main/**/out/
27 | !**/src/test/**/out/
28 |
29 | ### NetBeans ###
30 | /nbproject/private/
31 | /nbbuild/
32 | /dist/
33 | /nbdist/
34 | /.nb-gradle/
35 |
36 | ### VS Code ###
37 | .vscode/
38 |
39 | ### Kotlin ###
40 | .kotlin
41 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM openjdk:21-jdk AS TEMP_BUILD_IMAGE
2 | MAINTAINER doongjun.kim@gmail.com
3 | ENV APP_HOME=/app
4 | WORKDIR $APP_HOME
5 | COPY . ./
6 | RUN microdnf install findutils
7 | RUN ./gradlew build -x test --stacktrace
8 |
9 | FROM openjdk:21-jdk
10 | ENV ARTIFACT_NAME=commitmon-0.0.1-SNAPSHOT.jar
11 | ENV APP_HOME=/app
12 | WORKDIR $APP_HOME
13 | COPY --from=TEMP_BUILD_IMAGE $APP_HOME/build/libs/$ARTIFACT_NAME .
14 | EXPOSE 8080
15 | CMD ["java", "-jar", "commitmon-0.0.1-SNAPSHOT.jar"]
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Commitmon Adventure
2 |
3 |
4 |
5 |
6 |
7 |
Welcome to the Digital World: Growing with your Commit
8 |
9 | Get Your Own
Commitmon and Level Up With Your Skills!
10 |
11 | Displays the
Commitmon of friends who are following each other.
12 |
13 | [🌟Thank You For Your Star! 🌟](https://github.com/doongjun/commitmon/stargazers)
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | ## Getting Start
22 |
23 | #### Image URL
24 |
25 | ```shell
26 | https://commitmon.me/adventure?username=${username}&theme=${theme}&userFetchType=${userFetchType}
27 | ```
28 |
29 | | Parameter | Type | Description | Default Value |
30 | |:----------------|:----------------------------------------------------------------------------------|:-------------------------------|:--------------|
31 | | `username` | `string` | **Required**. Your Github Name | - |
32 | | `theme` | `grassland` \| `desert` \| `transparent` | Theme Of Commitmon. | `grassland` |
33 | | `userFetchType` | `all`(following and followers) \| `mutual`(following each other) \| `solo`(alone) | Type of Friends to fetch | `mutual` |
34 |
35 | Actually, you can use the image URL directly in the markdown file.
36 |
37 | ```markdown
38 |
39 |
40 |
41 | ```
42 |
43 | ## Tips
44 |
45 | - The Digimon is generated and fixed when the image is first loaded.
46 | - The number of commits is updated periodically . If it's not reflected in real time, please wait a moment.
47 |
48 | ## Digimon Encyclopedia
49 |
50 | | BABY | IN_TRAINING | ROOKIE | CHAMPION | PERFECT | ULTIMATE |
51 | |:-----------------------------------------------------------------------------------------------:|:--------------------------------------------------------------------------------------------:|:--------------------------------------------------------------------------------------------:|:-----------------------------------------------------------------------------------------------:|:----------------------------------------------------------------------------------------------------:|:-------------------------------------------------------------------------------------------------------:|
52 | |  |  |  |  |  |  |
53 | |  |  |  |  |  |  |
54 | |  |  |  |  |  |  |
55 | |  |  |  |  |  |  |
56 | |  |  |  |  |  |  |
57 | |  |  |  |  |  |  |
58 | |  |  |  |  |  |  |
59 | |  |  |  |  |  |  |
60 |
61 | and more....
62 |
63 | ## Commitmon Change Request
64 |
65 | If you'd like to change your Commitmon, please send an email to **[doongjun.kim@gmail.com](mailto:doongjun.kim@gmail.com)**.
66 | We'll be happy to assist you!
67 |
68 | ## Image License Information
69 |
70 | The images are provided by Loco .
71 |
72 | ##
73 |
74 |
75 |
76 | If you have any ideas or discover a bug, please issue it or contribute.
77 | We will do our best to make it better.
78 |
79 | [](https://hits.seeyoufarm.com)
80 | 
81 |
82 |
83 |
--------------------------------------------------------------------------------
/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | kotlin("jvm") version "1.9.25"
3 | kotlin("plugin.spring") version "1.9.25"
4 | kotlin("plugin.jpa") version "1.9.25"
5 | kotlin("plugin.allopen") version "1.9.25"
6 | kotlin("plugin.noarg") version "1.9.25"
7 | id("org.springframework.boot") version "3.3.3"
8 | id("io.spring.dependency-management") version "1.1.6"
9 | id("org.jlleitschuh.gradle.ktlint") version "12.1.1"
10 | }
11 |
12 | allOpen {
13 | annotation("jakarta.persistence.Entity")
14 | annotation("jakarta.persistence.MappedSuperclass")
15 | annotation("jakarta.persistence.Embeddable")
16 | }
17 |
18 | noArg {
19 | annotation("jakarta.persistence.Entity")
20 | annotation("jakarta.persistence.MappedSuperclass")
21 | annotation("jakarta.persistence.Embeddable")
22 | annotations("com.doongjun.commitmon.core.NoArgs")
23 | }
24 |
25 | group = "com.doongjun"
26 | version = "0.0.1-SNAPSHOT"
27 |
28 | java {
29 | toolchain {
30 | languageVersion = JavaLanguageVersion.of(21)
31 | }
32 | }
33 |
34 | repositories {
35 | mavenCentral()
36 | }
37 |
38 | dependencies {
39 | implementation("org.springframework.boot:spring-boot-starter-web")
40 | implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
41 | implementation("org.jetbrains.kotlin:kotlin-reflect")
42 | implementation("org.springframework.boot:spring-boot-starter-webflux")
43 | implementation("org.springframework.boot:spring-boot-starter-data-jpa")
44 | implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
45 | implementation("org.springframework.boot:spring-boot-starter-data-redis")
46 | implementation("org.springframework.boot:spring-boot-starter-security")
47 | implementation("org.springframework.boot:spring-boot-starter-validation")
48 | implementation("com.github.f4b6a3:tsid-creator:5.2.6")
49 |
50 | runtimeOnly("org.mariadb.jdbc:mariadb-java-client")
51 |
52 | implementation("io.jsonwebtoken:jjwt-api:0.12.6")
53 | implementation("io.jsonwebtoken:jjwt-impl:0.12.6")
54 | runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.6")
55 |
56 | implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2")
57 |
58 | testImplementation("org.springframework.boot:spring-boot-starter-test")
59 | testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
60 | testImplementation("org.mockito.kotlin:mockito-kotlin:5.4.0")
61 | testImplementation("org.testcontainers:junit-jupiter:1.20.1")
62 | testImplementation("org.testcontainers:testcontainers:1.20.1")
63 | testImplementation("org.testcontainers:mariadb:1.20.1")
64 | testRuntimeOnly("org.junit.platform:junit-platform-launcher")
65 | }
66 |
67 | kotlin {
68 | compilerOptions {
69 | freeCompilerArgs.addAll("-Xjsr305=strict")
70 | }
71 | }
72 |
73 | tasks.withType {
74 | useJUnitPlatform()
75 | }
76 |
--------------------------------------------------------------------------------
/docker/docker-compose.yml:
--------------------------------------------------------------------------------
1 | services:
2 | mariadb:
3 | image: mariadb:11.5.2
4 | container_name: mariadb
5 | restart: always
6 | environment:
7 | - MARIADB_ROOT_PASSWORD=root
8 | - MARIADB_DATABASE=commitmon
9 | ports:
10 | - 3306:3306
11 | redis:
12 | image: redis:7.4.0
13 | container_name: redis
14 | restart: always
15 | ports:
16 | - 6379:6379
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/doongjun/commitmon/28a64cf3f945f50e60446d86c6bbe1043b457be7/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | distributionBase=GRADLE_USER_HOME
2 | distributionPath=wrapper/dists
3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-bin.zip
4 | networkTimeout=10000
5 | validateDistributionUrl=true
6 | zipStoreBase=GRADLE_USER_HOME
7 | zipStorePath=wrapper/dists
8 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 | @rem SPDX-License-Identifier: Apache-2.0
17 | @rem
18 |
19 | @if "%DEBUG%"=="" @echo off
20 | @rem ##########################################################################
21 | @rem
22 | @rem Gradle startup script for Windows
23 | @rem
24 | @rem ##########################################################################
25 |
26 | @rem Set local scope for the variables with windows NT shell
27 | if "%OS%"=="Windows_NT" setlocal
28 |
29 | set DIRNAME=%~dp0
30 | if "%DIRNAME%"=="" set DIRNAME=.
31 | @rem This is normally unused
32 | set APP_BASE_NAME=%~n0
33 | set APP_HOME=%DIRNAME%
34 |
35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
37 |
38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
40 |
41 | @rem Find java.exe
42 | if defined JAVA_HOME goto findJavaFromJavaHome
43 |
44 | set JAVA_EXE=java.exe
45 | %JAVA_EXE% -version >NUL 2>&1
46 | if %ERRORLEVEL% equ 0 goto execute
47 |
48 | echo. 1>&2
49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
50 | echo. 1>&2
51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2
52 | echo location of your Java installation. 1>&2
53 |
54 | goto fail
55 |
56 | :findJavaFromJavaHome
57 | set JAVA_HOME=%JAVA_HOME:"=%
58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
59 |
60 | if exist "%JAVA_EXE%" goto execute
61 |
62 | echo. 1>&2
63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
64 | echo. 1>&2
65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2
66 | echo location of your Java installation. 1>&2
67 |
68 | goto fail
69 |
70 | :execute
71 | @rem Setup the command line
72 |
73 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
74 |
75 |
76 | @rem Execute Gradle
77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
78 |
79 | :end
80 | @rem End local scope for the variables with windows NT shell
81 | if %ERRORLEVEL% equ 0 goto mainEnd
82 |
83 | :fail
84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
85 | rem the _cmd.exe /c_ return code!
86 | set EXIT_CODE=%ERRORLEVEL%
87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1
88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
89 | exit /b %EXIT_CODE%
90 |
91 | :mainEnd
92 | if "%OS%"=="Windows_NT" endlocal
93 |
94 | :omega
95 |
--------------------------------------------------------------------------------
/graphql.config.yml:
--------------------------------------------------------------------------------
1 | schema:
2 | - https://api.github.com/graphql:
3 | headers:
4 | Authorization: Bearer ${TOKEN}
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | rootProject.name = "commitmon"
2 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/CommitmonApplication.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon
2 |
3 | import org.springframework.boot.autoconfigure.SpringBootApplication
4 | import org.springframework.boot.runApplication
5 |
6 | @SpringBootApplication
7 | class CommitmonApplication
8 |
9 | fun main(args: Array) {
10 | runApplication(*args)
11 | }
12 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/animation/AdventureController.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.animation
2 |
3 | import com.doongjun.commitmon.app.AdventureFacade
4 | import com.doongjun.commitmon.app.Theme
5 | import com.doongjun.commitmon.app.UserFetchType
6 | import com.doongjun.commitmon.domain.Commitmon
7 | import com.doongjun.commitmon.extension.currentUserId
8 | import com.doongjun.commitmon.extension.findBy
9 | import io.swagger.v3.oas.annotations.Operation
10 | import jakarta.servlet.http.HttpServletResponse
11 | import org.springframework.http.HttpHeaders
12 | import org.springframework.web.bind.annotation.GetMapping
13 | import org.springframework.web.bind.annotation.RequestParam
14 | import org.springframework.web.bind.annotation.RestController
15 |
16 | @RestController
17 | class AdventureController(
18 | private val adventureFacade: AdventureFacade,
19 | ) {
20 | @Operation(summary = "유저 애니메이션 SVG")
21 | @GetMapping("/adventure", produces = ["image/svg+xml"])
22 | fun getAdventure(
23 | @RequestParam username: String,
24 | @RequestParam(required = false) theme: String?,
25 | @RequestParam(required = false) userFetchType: String?,
26 | response: HttpServletResponse,
27 | ): String {
28 | response.apply {
29 | setHeader(HttpHeaders.CACHE_CONTROL, "no-cache, no-store, must-revalidate, max-age=3600")
30 | setHeader(HttpHeaders.PRAGMA, "no-cache")
31 | }
32 | return adventureFacade.getAnimation(
33 | username = username,
34 | theme = Theme::assetName.findBy(theme?.lowercase()),
35 | userFetchType = UserFetchType::title.findBy(userFetchType?.lowercase()),
36 | )
37 | }
38 |
39 | @Operation(summary = "로그인 유저 애니메이션 미리보기 SVG")
40 | @GetMapping("/adventure/preview", produces = ["image/svg+xml"])
41 | fun getAdventurePreview(
42 | @RequestParam(required = false) commitmon: Commitmon?,
43 | @RequestParam(defaultValue = "GRASSLAND") theme: Theme,
44 | @RequestParam(defaultValue = "SOLO") userFetchType: UserFetchType,
45 | response: HttpServletResponse,
46 | ): String =
47 | adventureFacade.getAnimationPreview(
48 | userId = currentUserId!!,
49 | commitmon = commitmon,
50 | theme = theme,
51 | userFetchType = userFetchType,
52 | )
53 | }
54 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/api/AccountController.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.api
2 |
3 | import com.doongjun.commitmon.api.data.RedirectDestination
4 | import com.doongjun.commitmon.api.data.RefreshTokenRequest
5 | import com.doongjun.commitmon.api.data.RefreshTokenResponse
6 | import com.doongjun.commitmon.app.AccountFacade
7 | import com.doongjun.commitmon.app.GithubOAuth2Service
8 | import io.swagger.v3.oas.annotations.Operation
9 | import jakarta.validation.Valid
10 | import org.springframework.http.HttpHeaders
11 | import org.springframework.http.HttpStatus
12 | import org.springframework.http.ResponseEntity
13 | import org.springframework.web.bind.annotation.GetMapping
14 | import org.springframework.web.bind.annotation.PathVariable
15 | import org.springframework.web.bind.annotation.PostMapping
16 | import org.springframework.web.bind.annotation.RequestBody
17 | import org.springframework.web.bind.annotation.RequestHeader
18 | import org.springframework.web.bind.annotation.RequestMapping
19 | import org.springframework.web.bind.annotation.RequestParam
20 | import org.springframework.web.bind.annotation.RestController
21 |
22 | @RestController
23 | @RequestMapping("/api/v1/account")
24 | class AccountController(
25 | private val accountFacade: AccountFacade,
26 | private val githubOAuth2Service: GithubOAuth2Service,
27 | ) {
28 | @Operation(summary = "로그인")
29 | @GetMapping("/login")
30 | fun login(
31 | @RequestHeader("Redirect-Destination", defaultValue = "LOCAL") destination: RedirectDestination,
32 | ): ResponseEntity =
33 | ResponseEntity
34 | .status(HttpStatus.MOVED_PERMANENTLY)
35 | .header(
36 | HttpHeaders.LOCATION,
37 | githubOAuth2Service.getRedirectUrl(destination),
38 | ).build()
39 |
40 | @Operation(summary = "로그인 Callback (시스템에서 호출)")
41 | @GetMapping("/oauth/github/callback/{destination}")
42 | fun loginCallback(
43 | @PathVariable destination: RedirectDestination,
44 | @RequestParam code: String,
45 | ): ResponseEntity {
46 | val (accessToken, refreshToken) = accountFacade.login(code)
47 | return ResponseEntity
48 | .status(HttpStatus.TEMPORARY_REDIRECT)
49 | .header(
50 | HttpHeaders.LOCATION,
51 | destination.getClientUrl(accessToken, refreshToken),
52 | ).build()
53 | }
54 |
55 | @Operation(summary = "토큰 갱신")
56 | @PostMapping("/refresh")
57 | fun refresh(
58 | @Valid @RequestBody request: RefreshTokenRequest,
59 | ): ResponseEntity {
60 | val (accessToken, refreshToken) = accountFacade.refresh(token = request.refreshToken!!)
61 | return ResponseEntity.ok(RefreshTokenResponse.of(accessToken, refreshToken))
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/api/CommitmonController.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.api
2 |
3 | import com.doongjun.commitmon.api.data.CommitmonResponse
4 | import io.swagger.v3.oas.annotations.Operation
5 | import org.springframework.web.bind.annotation.GetMapping
6 | import org.springframework.web.bind.annotation.RequestMapping
7 | import org.springframework.web.bind.annotation.RestController
8 |
9 | @RestController
10 | @RequestMapping("/api/v1/commitmons")
11 | class CommitmonController {
12 | @Operation(summary = "커밋몬 목록 조회")
13 | @GetMapping
14 | fun getAll(): CommitmonResponse = CommitmonResponse.of()
15 | }
16 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/api/MeController.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.api
2 |
3 | import com.doongjun.commitmon.api.data.ChangeCommitmonRequest
4 | import com.doongjun.commitmon.api.data.MeDetailResponse
5 | import com.doongjun.commitmon.api.data.MeResponse
6 | import com.doongjun.commitmon.app.UserFetchType
7 | import com.doongjun.commitmon.app.UserService
8 | import com.doongjun.commitmon.extension.currentUserId
9 | import io.swagger.v3.oas.annotations.Operation
10 | import jakarta.validation.Valid
11 | import org.springframework.web.bind.annotation.GetMapping
12 | import org.springframework.web.bind.annotation.PostMapping
13 | import org.springframework.web.bind.annotation.RequestBody
14 | import org.springframework.web.bind.annotation.RequestMapping
15 | import org.springframework.web.bind.annotation.RestController
16 |
17 | @RestController
18 | @RequestMapping("/api/v1/me")
19 | class MeController(
20 | private val userService: UserService,
21 | ) {
22 | @Operation(summary = "로그인 유저 조회")
23 | @GetMapping
24 | fun get(): MeResponse =
25 | MeResponse.from(
26 | user = userService.getSimple(currentUserId!!),
27 | )
28 |
29 | @Operation(summary = "로그인 유저 조회 (팔로워, 팔로잉 포함)")
30 | @GetMapping("/detail")
31 | fun getDetail(): MeDetailResponse =
32 | MeDetailResponse.from(
33 | user =
34 | userService.get(
35 | id = currentUserId!!,
36 | userFetchType = UserFetchType.ALL,
37 | ),
38 | )
39 |
40 | @Operation(summary = "로그인 유저 커밋몬 변경")
41 | @PostMapping("/commitmon/change")
42 | fun changeCommitmon(
43 | @Valid @RequestBody request: ChangeCommitmonRequest,
44 | ) {
45 | userService.changeCommitmon(
46 | id = currentUserId!!,
47 | commitmon = request.commitmon!!,
48 | )
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/api/data/ChangeCommitmonRequest.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.api.data
2 |
3 | import com.doongjun.commitmon.domain.Commitmon
4 | import jakarta.validation.constraints.NotNull
5 |
6 | data class ChangeCommitmonRequest(
7 | @field:NotNull
8 | val commitmon: Commitmon?,
9 | )
10 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/api/data/CommitmonResponse.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.api.data
2 |
3 | import com.doongjun.commitmon.domain.Commitmon
4 | import com.doongjun.commitmon.domain.CommitmonLevel
5 |
6 | data class CommitmonResponse(
7 | val commitmons: List,
8 | ) {
9 | data class CommitmonData(
10 | val commitmon: Commitmon,
11 | val name: String,
12 | val nameKo: String,
13 | val level: CommitmonLevel,
14 | val seed: Int,
15 | val imageUrl: String,
16 | )
17 |
18 | companion object {
19 | private val commitmons =
20 | Commitmon.entries.filter { it != Commitmon.EGG }.map {
21 | CommitmonData(
22 | commitmon = it,
23 | name = it.name.lowercase(),
24 | nameKo = it.nameKo,
25 | level = it.level,
26 | seed = it.seed ?: 0,
27 | imageUrl = "https://s3-commitmon.s3.ap-northeast-2.amazonaws.com/static/${it.assetName}.png",
28 | )
29 | }
30 |
31 | fun of(): CommitmonResponse = CommitmonResponse(commitmons)
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/api/data/MeDetailResponse.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.api.data
2 |
3 | import com.doongjun.commitmon.app.data.GetSimpleUserDto
4 | import com.doongjun.commitmon.app.data.GetUserDto
5 | import com.doongjun.commitmon.domain.Commitmon
6 |
7 | data class MeDetailResponse(
8 | val id: Long,
9 | val name: String,
10 | val totalCommitCount: Long,
11 | val commitmon: Commitmon,
12 | val exp: Int = 0,
13 | val followers: List,
14 | val following: List,
15 | ) {
16 | companion object {
17 | fun from(user: GetUserDto): MeDetailResponse =
18 | MeDetailResponse(
19 | id = user.id,
20 | name = user.name,
21 | totalCommitCount = user.totalCommitCount,
22 | commitmon = user.commitmon,
23 | exp = user.exp,
24 | followers = user.followers,
25 | following = user.following,
26 | )
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/api/data/MeResponse.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.api.data
2 |
3 | import com.doongjun.commitmon.app.data.GetSimpleUserDto
4 | import com.doongjun.commitmon.domain.Commitmon
5 |
6 | data class MeResponse(
7 | val id: Long,
8 | val name: String,
9 | val totalCommitCount: Long,
10 | val commitmon: Commitmon,
11 | val exp: Int = 0,
12 | ) {
13 | companion object {
14 | fun from(user: GetSimpleUserDto): MeResponse =
15 | MeResponse(
16 | id = user.id,
17 | name = user.name,
18 | totalCommitCount = user.totalCommitCount,
19 | commitmon = user.commitmon,
20 | exp = user.exp,
21 | )
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/api/data/RedirectDestination.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.api.data
2 |
3 | enum class RedirectDestination(
4 | val callbackUrl: String,
5 | private val clientUrl: String,
6 | ) {
7 | PRODUCTION(
8 | "https://commitmon.me/api/v1/account/oauth/github/callback/PRODUCTION",
9 | "https://commitmon-client.vercel.app/account",
10 | ),
11 | LOCAL(
12 | "https://commitmon.me/api/v1/account/oauth/github/callback/LOCAL",
13 | "http://localhost:3000/account",
14 | ),
15 | ;
16 |
17 | fun getClientUrl(
18 | accessToken: String,
19 | refreshToken: String,
20 | ): String = "$clientUrl?accessToken=$accessToken&refreshToken=$refreshToken"
21 | }
22 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/api/data/RefreshTokenRequest.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.api.data
2 |
3 | import jakarta.validation.constraints.NotNull
4 |
5 | data class RefreshTokenRequest(
6 | @field:NotNull
7 | val refreshToken: String?,
8 | )
9 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/api/data/RefreshTokenResponse.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.api.data
2 |
3 | data class RefreshTokenResponse(
4 | val accessToken: String,
5 | val refreshToken: String,
6 | ) {
7 | companion object {
8 | fun of(
9 | accessToken: String,
10 | refreshToken: String,
11 | ): RefreshTokenResponse = RefreshTokenResponse(accessToken, refreshToken)
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/app/AccountFacade.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.app
2 |
3 | import com.doongjun.commitmon.app.data.AuthDto
4 | import com.doongjun.commitmon.app.data.CreateUserDto
5 | import com.doongjun.commitmon.app.data.GetUserDto
6 | import com.doongjun.commitmon.config.security.TokenProvider
7 | import org.springframework.security.authentication.AccountExpiredException
8 | import org.springframework.stereotype.Component
9 |
10 | @Component
11 | class AccountFacade(
12 | private val userService: UserService,
13 | private val githubService: GithubService,
14 | private val githubOAuth2Service: GithubOAuth2Service,
15 | private val tokenProvider: TokenProvider,
16 | ) {
17 | fun login(code: String): AuthDto {
18 | val userLogin = githubOAuth2Service.getUserLogin(code)
19 | val user = getOrCreateUser(userLogin)
20 |
21 | return AuthDto(
22 | accessToken = tokenProvider.createAccessToken(user.id),
23 | refreshToken = tokenProvider.createRefreshToken(user.id),
24 | )
25 | }
26 |
27 | private fun getOrCreateUser(username: String): GetUserDto =
28 | runCatching {
29 | userService.getByName(
30 | name = username,
31 | userFetchType = UserFetchType.SOLO,
32 | )
33 | }.getOrElse {
34 | val (totalCommitCount) = githubService.getUserCommitInfo(username)
35 | val (followerNames, followingNames) = githubService.getUserFollowInfo(username, 100)
36 | val userId =
37 | CreateUserDto(
38 | name = username,
39 | totalCommitCount = totalCommitCount,
40 | followerNames = followerNames,
41 | followingNames = followingNames,
42 | ).let { dto ->
43 | userService.create(dto)
44 | }
45 | userService.get(userId, UserFetchType.SOLO)
46 | }
47 |
48 | fun refresh(token: String): AuthDto {
49 | val refreshToken =
50 | tokenProvider.getRefreshToken(token)
51 | ?: throw AccountExpiredException("Refresh token is expired")
52 |
53 | return AuthDto(
54 | accessToken = tokenProvider.createAccessToken(refreshToken.userId!!),
55 | refreshToken =
56 | when (refreshToken.isLessThanWeek()) {
57 | true -> {
58 | val user = userService.get(refreshToken.userId, UserFetchType.SOLO)
59 | tokenProvider.expireRefreshToken(token)
60 | tokenProvider.createRefreshToken(user.id)
61 | }
62 | false -> refreshToken.token!!
63 | },
64 | )
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/app/AdventureFacade.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.app
2 |
3 | import com.doongjun.commitmon.app.data.CreateUserDto
4 | import com.doongjun.commitmon.app.data.GetUserDto
5 | import com.doongjun.commitmon.core.AdventureGenerator
6 | import com.doongjun.commitmon.domain.Commitmon
7 | import com.doongjun.commitmon.event.UpdateUserInfo
8 | import org.slf4j.LoggerFactory
9 | import org.springframework.context.ApplicationEventPublisher
10 | import org.springframework.core.io.ClassPathResource
11 | import org.springframework.stereotype.Component
12 | import org.thymeleaf.context.Context
13 | import org.thymeleaf.spring6.SpringTemplateEngine
14 | import java.nio.charset.Charset
15 |
16 | @Component
17 | class AdventureFacade(
18 | private val userService: UserService,
19 | private val githubService: GithubService,
20 | private val svgTemplateEngine: SpringTemplateEngine,
21 | private val publisher: ApplicationEventPublisher,
22 | ) {
23 | private val log = LoggerFactory.getLogger(javaClass)
24 |
25 | fun getAnimation(
26 | username: String,
27 | theme: Theme?,
28 | userFetchType: UserFetchType?,
29 | ): String =
30 | createAnimation(
31 | user =
32 | getOrCreateUser(
33 | username = username,
34 | userFetchType = userFetchType ?: UserFetchType.MUTUAL,
35 | ),
36 | theme = theme ?: Theme.GRASSLAND,
37 | )
38 |
39 | fun getAnimationPreview(
40 | userId: Long,
41 | commitmon: Commitmon?,
42 | theme: Theme,
43 | userFetchType: UserFetchType,
44 | ): String {
45 | val user =
46 | userService.get(
47 | id = userId,
48 | userFetchType = userFetchType,
49 | )
50 |
51 | user.commitmon = commitmon ?: user.commitmon
52 |
53 | return createAnimation(
54 | user = user,
55 | theme = theme,
56 | )
57 | }
58 |
59 | private fun getOrCreateUser(
60 | username: String,
61 | userFetchType: UserFetchType,
62 | ): GetUserDto =
63 | runCatching {
64 | userService.getByName(
65 | name = username,
66 | userFetchType = userFetchType,
67 | )
68 | }.onSuccess { existsUser ->
69 | publisher.publishEvent(UpdateUserInfo(existsUser.id))
70 | }.getOrElse {
71 | log.info("Creating user: $username")
72 | val (totalCommitCount) = githubService.getUserCommitInfo(username)
73 | val (followerNames, followingNames) = githubService.getUserFollowInfo(username, 100)
74 | val userId =
75 | CreateUserDto(
76 | name = username,
77 | totalCommitCount = totalCommitCount,
78 | followerNames = followerNames,
79 | followingNames = followingNames,
80 | ).let { dto ->
81 | userService.create(dto)
82 | }
83 |
84 | log.info("User created: $username")
85 | userService.get(userId, userFetchType)
86 | }
87 |
88 | private fun createAnimation(
89 | user: GetUserDto,
90 | theme: Theme,
91 | ): String {
92 | val templates =
93 | (user.fetchedUsers + user.toSimple()).joinToString { u ->
94 | svgTemplateEngine.process(
95 | "asset/${u.commitmon.assetName}",
96 | Context().apply {
97 | setVariable("id", u.id)
98 | setVariable("motion", AdventureGenerator.generateMotion())
99 | setVariable("y", AdventureGenerator.generateY(u.commitmon.isFlying))
100 | setVariable("username", u.name)
101 | setVariable("exp", u.exp)
102 | },
103 | )
104 | }
105 |
106 | return svgTemplateEngine.process(
107 | "adventure",
108 | Context().apply {
109 | setVariable("username", user.name)
110 | setVariable("totalCommitCount", user.totalCommitCount)
111 | setVariable(
112 | "theme",
113 | ClassPathResource(
114 | "static/theme/${theme.assetName}.svg",
115 | ).getContentAsString(Charset.defaultCharset()),
116 | )
117 | setVariable("templates", templates)
118 | },
119 | )
120 | }
121 | }
122 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/app/GithubOAuth2Service.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.app
2 |
3 | import com.doongjun.commitmon.api.data.RedirectDestination
4 | import com.doongjun.commitmon.infra.GithubOAuth2Api
5 | import com.doongjun.commitmon.infra.GithubRestApi
6 | import org.springframework.beans.factory.annotation.Value
7 | import org.springframework.stereotype.Service
8 |
9 | @Service
10 | class GithubOAuth2Service(
11 | private val githubOAuth2Api: GithubOAuth2Api,
12 | private val githubRestApi: GithubRestApi,
13 | @Value("\${app.github.oauth2.base-url}")
14 | private val githubOAuth2BaseUrl: String,
15 | @Value("\${app.github.oauth2.client-id}")
16 | private val githubClientId: String,
17 | @Value("\${app.github.oauth2.client-secret}")
18 | private val githubClientSecret: String,
19 | ) {
20 | fun getRedirectUrl(destination: RedirectDestination): String =
21 | "$githubOAuth2BaseUrl/authorize?client_id=$githubClientId&redirect_uri=${destination.callbackUrl}"
22 |
23 | fun getUserLogin(code: String): String {
24 | val userToken =
25 | githubOAuth2Api
26 | .fetchAccessToken(
27 | code = code,
28 | clientId = githubClientId,
29 | clientSecret = githubClientSecret,
30 | ).accessToken
31 |
32 | return githubRestApi.fetchUserInfo(userToken)
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/app/GithubService.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.app
2 |
3 | import com.doongjun.commitmon.app.data.GetUserCommitInfo
4 | import com.doongjun.commitmon.app.data.GetUserFollowInfoDto
5 | import com.doongjun.commitmon.infra.GithubGraphqlApi
6 | import com.doongjun.commitmon.infra.GithubRestApi
7 | import com.doongjun.commitmon.infra.data.FollowInfo
8 | import org.springframework.cache.annotation.Cacheable
9 | import org.springframework.stereotype.Service
10 |
11 | @Service
12 | class GithubService(
13 | private val githubRestApi: GithubRestApi,
14 | private val githubGraphqlApi: GithubGraphqlApi,
15 | ) {
16 | @Cacheable(value = ["userCommitInfo"], key = "#username")
17 | fun getUserCommitInfo(username: String): GetUserCommitInfo {
18 | val (totalCount) = githubRestApi.fetchUserCommitSearchInfo(username)
19 |
20 | return GetUserCommitInfo(
21 | totalCommitCount = totalCount,
22 | )
23 | }
24 |
25 | @Cacheable(value = ["userFollowInfo"], key = "#username")
26 | fun getUserFollowInfo(
27 | username: String,
28 | size: Int,
29 | ): GetUserFollowInfoDto {
30 | if (size > 100) {
31 | throw IllegalArgumentException("Size should be less than or equal to 100")
32 | }
33 |
34 | val (followers, following) =
35 | githubGraphqlApi
36 | .fetchUserFollowInfo(username, size)
37 |
38 | return GetUserFollowInfoDto(
39 | followerNames = followers.nodes.map { it.login },
40 | followingNames = following.nodes.map { it.login },
41 | )
42 | }
43 |
44 | fun getAllUserFollowInfo(
45 | username: String,
46 | pageSize: Int,
47 | ): GetUserFollowInfoDto {
48 | val (followers, following) =
49 | githubGraphqlApi
50 | .fetchUserFollowInfo(username, pageSize)
51 |
52 | return GetUserFollowInfoDto(
53 | followerNames =
54 | fetchAllNames(followers) { cursor ->
55 | githubGraphqlApi
56 | .fetchUserFollowers(username, pageSize, cursor)
57 | .followers
58 | },
59 | followingNames =
60 | fetchAllNames(following) { cursor ->
61 | githubGraphqlApi
62 | .fetchUserFollowing(username, pageSize, cursor)
63 | .following
64 | },
65 | )
66 | }
67 |
68 | private fun fetchAllNames(
69 | initialInfo: FollowInfo,
70 | fetchMore: (String?) -> FollowInfo,
71 | ): List {
72 | val names = initialInfo.nodes.map { it.login }.toMutableList()
73 | var hasNextPage = initialInfo.pageInfo.hasNextPage
74 | var cursor = initialInfo.pageInfo.endCursor
75 |
76 | while (hasNextPage) {
77 | val nextInfo = fetchMore(cursor)
78 | names.addAll(nextInfo.nodes.map { it.login })
79 |
80 | hasNextPage = nextInfo.pageInfo.hasNextPage
81 | cursor = nextInfo.pageInfo.endCursor
82 | }
83 |
84 | return names
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/app/Theme.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.app
2 |
3 | enum class Theme(
4 | val assetName: String,
5 | ) {
6 | DESERT("desert"),
7 | GRASSLAND("grassland"),
8 | TRANSPARENT("transparent"),
9 | }
10 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/app/UserFetchType.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.app
2 |
3 | enum class UserFetchType(
4 | val title: String,
5 | ) {
6 | ALL("all"),
7 | MUTUAL("mutual"),
8 | SOLO("solo"),
9 | }
10 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/app/UserService.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.app
2 |
3 | import com.doongjun.commitmon.app.data.CreateUserDto
4 | import com.doongjun.commitmon.app.data.GetSimpleUserDto
5 | import com.doongjun.commitmon.app.data.GetUserDto
6 | import com.doongjun.commitmon.app.data.UpdateUserDto
7 | import com.doongjun.commitmon.domain.Commitmon
8 | import com.doongjun.commitmon.domain.User
9 | import com.doongjun.commitmon.domain.UserRepository
10 | import org.springframework.cache.CacheManager
11 | import org.springframework.cache.annotation.Cacheable
12 | import org.springframework.data.repository.findByIdOrNull
13 | import org.springframework.stereotype.Service
14 | import org.springframework.transaction.annotation.Transactional
15 |
16 | @Service
17 | @Transactional
18 | class UserService(
19 | private val userRepository: UserRepository,
20 | private val cacheManager: CacheManager,
21 | ) {
22 | @Transactional(readOnly = true)
23 | fun existsByName(name: String) = userRepository.existsByName(name)
24 |
25 | @Transactional(readOnly = true)
26 | fun getSimple(id: Long): GetSimpleUserDto =
27 | userRepository.findByIdOrNull(id)?.let { user -> GetSimpleUserDto.from(user) }
28 | ?: throw NoSuchElementException("Failed to fetch user by id: $id")
29 |
30 | @Transactional(readOnly = true)
31 | fun get(
32 | id: Long,
33 | userFetchType: UserFetchType,
34 | ): GetUserDto =
35 | userRepository.findByIdOrNull(id)?.let { user -> GetUserDto.from(user, userFetchType) }
36 | ?: throw NoSuchElementException("Failed to fetch user by id: $id")
37 |
38 | @Cacheable(value = ["userInfo"], key = "#name + '-' + #userFetchType.title")
39 | @Transactional(readOnly = true)
40 | fun getByName(
41 | name: String,
42 | userFetchType: UserFetchType,
43 | ): GetUserDto =
44 | userRepository.findByName(name)?.let { user -> GetUserDto.from(user, userFetchType) }
45 | ?: throw NoSuchElementException("Failed to fetch user by name: $name")
46 |
47 | fun create(dto: CreateUserDto): Long {
48 | val user =
49 | userRepository.save(
50 | User(
51 | name = dto.name,
52 | totalCommitCount = dto.totalCommitCount,
53 | followers = userRepository.findAllByNameIn(dto.followerNames),
54 | following = userRepository.findAllByNameIn(dto.followingNames),
55 | ),
56 | )
57 | return user.id
58 | }
59 |
60 | fun update(
61 | id: Long,
62 | dto: UpdateUserDto,
63 | ) {
64 | val user =
65 | userRepository.findByIdOrNull(id)
66 | ?: throw NoSuchElementException("Failed to fetch user by id: $id")
67 |
68 | user.update(
69 | totalCommitCount = dto.totalCommitCount,
70 | followers = userRepository.findAllByNameIn(dto.followerNames),
71 | following = userRepository.findAllByNameIn(dto.followingNames),
72 | )
73 |
74 | userRepository.save(user)
75 | }
76 |
77 | fun changeCommitmon(
78 | id: Long,
79 | commitmon: Commitmon,
80 | ) {
81 | val user =
82 | userRepository.findByIdOrNull(id)
83 | ?: throw NoSuchElementException("Failed to fetch user by id: $id")
84 |
85 | user.changeCommitmon(commitmon)
86 |
87 | userRepository.save(user)
88 |
89 | clearCache(user.name)
90 | }
91 |
92 | private fun clearCache(name: String) {
93 | val cache = cacheManager.getCache("userInfo")
94 | UserFetchType.entries.forEach { userFetchType ->
95 | cache?.evictIfPresent(name + '-' + userFetchType.title)
96 | }
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/app/data/AuthDto.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.app.data
2 |
3 | data class AuthDto(
4 | val accessToken: String,
5 | val refreshToken: String,
6 | )
7 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/app/data/CreateUserDto.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.app.data
2 |
3 | data class CreateUserDto(
4 | val name: String,
5 | val totalCommitCount: Long,
6 | val followerNames: List,
7 | val followingNames: List,
8 | )
9 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/app/data/GetSimpleUserDto.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.app.data
2 |
3 | import com.doongjun.commitmon.core.NoArgs
4 | import com.doongjun.commitmon.domain.Commitmon
5 | import com.doongjun.commitmon.domain.User
6 |
7 | @NoArgs
8 | data class GetSimpleUserDto(
9 | val id: Long,
10 | val name: String,
11 | val totalCommitCount: Long,
12 | val commitmon: Commitmon,
13 | val exp: Int = 0,
14 | ) {
15 | companion object {
16 | fun from(user: User): GetSimpleUserDto =
17 | GetSimpleUserDto(
18 | id = user.id,
19 | name = user.name,
20 | totalCommitCount = user.totalCommitCount,
21 | commitmon = user.commitmon,
22 | exp = user.exp,
23 | )
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/app/data/GetUserCommitInfo.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.app.data
2 |
3 | import com.doongjun.commitmon.core.NoArgs
4 | import java.io.Serializable
5 |
6 | @NoArgs
7 | data class GetUserCommitInfo(
8 | val totalCommitCount: Long,
9 | ) : Serializable
10 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/app/data/GetUserDto.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.app.data
2 |
3 | import com.doongjun.commitmon.app.UserFetchType
4 | import com.doongjun.commitmon.core.NoArgs
5 | import com.doongjun.commitmon.domain.Commitmon
6 | import com.doongjun.commitmon.domain.User
7 | import com.fasterxml.jackson.annotation.JsonIgnoreProperties
8 |
9 | @NoArgs
10 | @JsonIgnoreProperties(value = ["followerIds", "followingIds", "followers", "following"])
11 | data class GetUserDto(
12 | val id: Long,
13 | val name: String,
14 | val totalCommitCount: Long,
15 | var commitmon: Commitmon,
16 | val exp: Int = 0,
17 | val fetchedUsers: List,
18 | private val followerIds: List,
19 | private val followingIds: List,
20 | ) {
21 | val followers: List by lazy {
22 | fetchedUsers.filter { it.id in followerIds }
23 | }
24 | val following: List by lazy {
25 | fetchedUsers.filter { it.id in followingIds }
26 | }
27 |
28 | fun toSimple(): GetSimpleUserDto =
29 | GetSimpleUserDto(
30 | id = id,
31 | name = name,
32 | totalCommitCount = totalCommitCount,
33 | commitmon = commitmon,
34 | exp = exp,
35 | )
36 |
37 | companion object {
38 | fun from(
39 | user: User,
40 | userFetchType: UserFetchType,
41 | ): GetUserDto =
42 | GetUserDto(
43 | id = user.id,
44 | name = user.name,
45 | totalCommitCount = user.totalCommitCount,
46 | commitmon = user.commitmon,
47 | exp = user.exp,
48 | fetchedUsers =
49 | when (userFetchType) {
50 | UserFetchType.ALL ->
51 | listOf(user.followers, user.following)
52 | .flatten()
53 | .distinct()
54 | .map { GetSimpleUserDto.from(it) }
55 | UserFetchType.MUTUAL ->
56 | user.mutualFollowers
57 | .map { GetSimpleUserDto.from(it) }
58 | UserFetchType.SOLO -> emptyList()
59 | },
60 | followerIds = user.followers.map { it.id },
61 | followingIds = user.following.map { it.id },
62 | )
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/app/data/GetUserFollowInfoDto.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.app.data
2 |
3 | import com.doongjun.commitmon.core.NoArgs
4 |
5 | @NoArgs
6 | data class GetUserFollowInfoDto(
7 | val followerNames: List,
8 | val followingNames: List,
9 | )
10 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/app/data/UpdateUserDto.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.app.data
2 |
3 | data class UpdateUserDto(
4 | val totalCommitCount: Long,
5 | val followerNames: List,
6 | val followingNames: List,
7 | )
8 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/config/AsyncConfig.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.config
2 |
3 | import org.springframework.context.annotation.Bean
4 | import org.springframework.context.annotation.Configuration
5 | import org.springframework.scheduling.annotation.EnableAsync
6 | import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor
7 |
8 | @EnableAsync
9 | @Configuration
10 | class AsyncConfig {
11 | @Bean
12 | fun taskExecutor(): ThreadPoolTaskExecutor {
13 | val executor = ThreadPoolTaskExecutor()
14 | executor.setThreadNamePrefix("async-executor-")
15 | executor.corePoolSize = POOL_SIZE
16 | executor.maxPoolSize = POOL_SIZE * 2
17 | executor.queueCapacity = QUEUE_SIZE
18 | executor.setWaitForTasksToCompleteOnShutdown(true)
19 | executor.initialize()
20 | return executor
21 | }
22 |
23 | companion object {
24 | private const val POOL_SIZE = 3
25 | private const val QUEUE_SIZE = 3
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/config/CacheConfig.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.config
2 |
3 | import org.springframework.boot.autoconfigure.cache.RedisCacheManagerBuilderCustomizer
4 | import org.springframework.cache.annotation.EnableCaching
5 | import org.springframework.context.annotation.Bean
6 | import org.springframework.context.annotation.Configuration
7 | import org.springframework.data.redis.cache.CacheKeyPrefix
8 | import org.springframework.data.redis.cache.RedisCacheConfiguration
9 | import org.springframework.data.redis.cache.RedisCacheManager.RedisCacheManagerBuilder
10 | import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer
11 | import org.springframework.data.redis.serializer.RedisSerializationContext
12 | import org.springframework.data.redis.serializer.StringRedisSerializer
13 | import java.time.Duration
14 |
15 | @EnableCaching
16 | @Configuration
17 | class CacheConfig {
18 | @Bean
19 | fun redisCacheManagerBuilderCustomizer(): RedisCacheManagerBuilderCustomizer =
20 | RedisCacheManagerBuilderCustomizer { builder: RedisCacheManagerBuilder ->
21 | builder
22 | .withCacheConfiguration(
23 | "userInfo",
24 | RedisCacheConfiguration
25 | .defaultCacheConfig()
26 | .computePrefixWith(CacheKeyPrefix.simple())
27 | .entryTtl(Duration.ofMinutes(5))
28 | .disableCachingNullValues()
29 | .serializeKeysWith(
30 | RedisSerializationContext.SerializationPair.fromSerializer(StringRedisSerializer()),
31 | ).serializeValuesWith(
32 | RedisSerializationContext.SerializationPair.fromSerializer(GenericJackson2JsonRedisSerializer()),
33 | ),
34 | ).withCacheConfiguration(
35 | "userCommitInfo",
36 | RedisCacheConfiguration
37 | .defaultCacheConfig()
38 | .computePrefixWith(CacheKeyPrefix.simple())
39 | .entryTtl(Duration.ofMinutes(5))
40 | .disableCachingNullValues()
41 | .serializeKeysWith(
42 | RedisSerializationContext.SerializationPair.fromSerializer(StringRedisSerializer()),
43 | ).serializeValuesWith(
44 | RedisSerializationContext.SerializationPair.fromSerializer(GenericJackson2JsonRedisSerializer()),
45 | ),
46 | ).withCacheConfiguration(
47 | "userFollowInfo",
48 | RedisCacheConfiguration
49 | .defaultCacheConfig()
50 | .computePrefixWith(CacheKeyPrefix.simple())
51 | .entryTtl(Duration.ofMinutes(60))
52 | .disableCachingNullValues()
53 | .serializeKeysWith(
54 | RedisSerializationContext.SerializationPair.fromSerializer(StringRedisSerializer()),
55 | ).serializeValuesWith(
56 | RedisSerializationContext.SerializationPair.fromSerializer(GenericJackson2JsonRedisSerializer()),
57 | ),
58 | )
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/config/JpaConfig.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.config
2 |
3 | import org.springframework.context.annotation.Configuration
4 | import org.springframework.data.jpa.repository.config.EnableJpaAuditing
5 |
6 | @Configuration
7 | @EnableJpaAuditing
8 | class JpaConfig
9 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/config/RedisConfig.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.config
2 |
3 | import org.springframework.beans.factory.annotation.Value
4 | import org.springframework.context.annotation.Bean
5 | import org.springframework.context.annotation.Configuration
6 | import org.springframework.data.redis.connection.RedisStandaloneConfiguration
7 | import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory
8 |
9 | @Configuration
10 | class RedisConfig(
11 | @Value("\${spring.data.redis.host}") private val host: String,
12 | @Value("\${spring.data.redis.port}") private val port: Int,
13 | ) {
14 | @Bean
15 | fun redisConnectionFactory() = LettuceConnectionFactory(RedisStandaloneConfiguration(host, port))
16 | }
17 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/config/SwaggerConfig.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.config
2 |
3 | import io.swagger.v3.oas.annotations.OpenAPIDefinition
4 | import io.swagger.v3.oas.annotations.enums.SecuritySchemeType
5 | import io.swagger.v3.oas.annotations.info.Info
6 | import io.swagger.v3.oas.annotations.security.SecurityRequirement
7 | import io.swagger.v3.oas.annotations.security.SecurityScheme
8 | import io.swagger.v3.oas.annotations.servers.Server
9 | import org.springdoc.core.models.GroupedOpenApi
10 | import org.springframework.context.annotation.Bean
11 | import org.springframework.context.annotation.Configuration
12 |
13 | @OpenAPIDefinition(
14 | info =
15 | Info(
16 | title = "Commitmon API",
17 | description = "API Documents",
18 | version = "v1.0.0",
19 | ),
20 | security = [SecurityRequirement(name = "Bearer Authentication")],
21 | servers = [
22 | Server(url = "https://commitmon.me"),
23 | Server(url = "http://localhost:8080"),
24 | ],
25 | )
26 | @SecurityScheme(
27 | name = "Bearer Authentication",
28 | type = SecuritySchemeType.HTTP,
29 | bearerFormat = "JWT",
30 | scheme = "bearer",
31 | )
32 | @Configuration
33 | class SwaggerConfig {
34 | @Bean
35 | fun api(): GroupedOpenApi =
36 | GroupedOpenApi
37 | .builder()
38 | .group("api-v1-definition")
39 | .pathsToMatch("/api/**")
40 | .build()
41 |
42 | @Bean
43 | fun animation(): GroupedOpenApi =
44 | GroupedOpenApi
45 | .builder()
46 | .group("animation-definition")
47 | .pathsToMatch("/adventure/**")
48 | .build()
49 | }
50 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/config/ThymeleafConfig.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.config
2 |
3 | import org.springframework.context.annotation.Bean
4 | import org.springframework.context.annotation.Configuration
5 | import org.thymeleaf.spring6.SpringTemplateEngine
6 | import org.thymeleaf.spring6.templateresolver.SpringResourceTemplateResolver
7 | import org.thymeleaf.templatemode.TemplateMode
8 |
9 | @Configuration
10 | class ThymeleafConfig {
11 | @Bean
12 | fun svgTemplateEngine(springResourceTemplateResolver: SpringResourceTemplateResolver): SpringTemplateEngine =
13 | SpringTemplateEngine().apply {
14 | setTemplateResolver(springResourceTemplateResolver)
15 | }
16 |
17 | @Bean
18 | fun springResourceTemplateResolver(): SpringResourceTemplateResolver =
19 | SpringResourceTemplateResolver().apply {
20 | prefix = "classpath:/templates/"
21 | suffix = ".svg"
22 | templateMode = TemplateMode.TEXT
23 | characterEncoding = "UTF-8"
24 | isCacheable = false
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/config/WebClientConfig.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.config
2 |
3 | import org.springframework.beans.factory.annotation.Value
4 | import org.springframework.context.annotation.Bean
5 | import org.springframework.context.annotation.Configuration
6 | import org.springframework.http.HttpHeaders
7 | import org.springframework.http.MediaType
8 | import org.springframework.web.reactive.function.client.WebClient
9 | import org.springframework.web.util.DefaultUriBuilderFactory
10 |
11 | @Configuration
12 | class WebClientConfig {
13 | @Bean(name = ["githubGraphqlWebClient"])
14 | fun githubGraphqlWebClient(
15 | @Value("\${app.github.token}") token: String,
16 | @Value("\${app.github.base-url}") baseUrl: String,
17 | ): WebClient =
18 | WebClient
19 | .builder()
20 | .baseUrl("$baseUrl/graphql")
21 | .defaultHeaders { httpHeaders ->
22 | httpHeaders.add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
23 | httpHeaders.add(HttpHeaders.AUTHORIZATION, "Bearer $token")
24 | }.build()
25 |
26 | @Bean(name = ["githubRestWebClient"])
27 | fun githubRestWebClient(
28 | @Value("\${app.github.base-url}") baseUrl: String,
29 | ): WebClient =
30 | WebClient
31 | .builder()
32 | .uriBuilderFactory(DefaultUriBuilderFactory(baseUrl))
33 | .baseUrl(baseUrl)
34 | .defaultHeaders { httpHeaders ->
35 | httpHeaders.add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
36 | }.build()
37 |
38 | @Bean(name = ["githubOAuth2WebClient"])
39 | fun githubOAuth2WebClient(
40 | @Value("\${app.github.oauth2.base-url}") baseUrl: String,
41 | ): WebClient =
42 | WebClient
43 | .builder()
44 | .uriBuilderFactory(DefaultUriBuilderFactory(baseUrl))
45 | .baseUrl(baseUrl)
46 | .defaultHeaders { httpHeaders ->
47 | httpHeaders.add(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
48 | }.build()
49 | }
50 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/config/security/JwtFilter.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.config.security
2 |
3 | import jakarta.servlet.FilterChain
4 | import jakarta.servlet.http.HttpServletRequest
5 | import jakarta.servlet.http.HttpServletResponse
6 | import org.springframework.http.HttpHeaders
7 | import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
8 | import org.springframework.security.core.authority.SimpleGrantedAuthority
9 | import org.springframework.security.core.context.SecurityContextHolder
10 | import org.springframework.security.web.authentication.WebAuthenticationDetailsSource
11 | import org.springframework.security.web.context.RequestAttributeSecurityContextRepository
12 | import org.springframework.web.filter.OncePerRequestFilter
13 |
14 | class JwtFilter(
15 | private val tokenProvider: TokenProvider,
16 | ) : OncePerRequestFilter() {
17 | private val repository = RequestAttributeSecurityContextRepository()
18 |
19 | override fun doFilterInternal(
20 | request: HttpServletRequest,
21 | response: HttpServletResponse,
22 | filterChain: FilterChain,
23 | ) {
24 | val token = resolveToken(request)
25 |
26 | if (!token.isNullOrEmpty() && tokenProvider.validateAccessToken(token)) {
27 | val userId = tokenProvider.extractUserId(token)
28 |
29 | val authenticationToken =
30 | UsernamePasswordAuthenticationToken(
31 | userId,
32 | null,
33 | listOf(SimpleGrantedAuthority("ROLE_USER")),
34 | )
35 |
36 | authenticationToken.details = WebAuthenticationDetailsSource().buildDetails(request)
37 | SecurityContextHolder.getContext().authentication = authenticationToken
38 | repository.saveContext(SecurityContextHolder.getContext(), request, response)
39 | }
40 |
41 | filterChain.doFilter(request, response)
42 | }
43 |
44 | private fun resolveToken(request: HttpServletRequest): String? {
45 | val authorization = request.getHeader(HttpHeaders.AUTHORIZATION)
46 | if (!authorization.isNullOrEmpty() && authorization.startsWith("Bearer ")) {
47 | return authorization.substring(7)
48 | }
49 |
50 | return null
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/config/security/RefreshToken.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.config.security
2 |
3 | import org.springframework.data.annotation.Id
4 | import org.springframework.data.redis.core.RedisHash
5 | import org.springframework.data.redis.core.TimeToLive
6 | import java.util.UUID
7 |
8 | @RedisHash(value = "refreshToken")
9 | data class RefreshToken(
10 | @Id
11 | var token: String? = UUID.randomUUID().toString(),
12 | @TimeToLive
13 | val ttl: Long = 1209600,
14 | val userId: Long?,
15 | ) {
16 | fun isLessThanWeek(): Boolean = ttl < 604800
17 | }
18 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/config/security/RefreshTokenRepository.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.config.security
2 |
3 | import org.springframework.data.repository.CrudRepository
4 |
5 | interface RefreshTokenRepository : CrudRepository
6 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/config/security/RestAccessDeniedHandler.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.config.security
2 |
3 | import com.doongjun.commitmon.core.error.response.ErrorCode
4 | import com.doongjun.commitmon.core.error.response.ErrorResponse
5 | import com.doongjun.commitmon.extension.convertToString
6 | import jakarta.servlet.http.HttpServletRequest
7 | import jakarta.servlet.http.HttpServletResponse
8 | import org.springframework.security.access.AccessDeniedException
9 | import org.springframework.security.web.access.AccessDeniedHandler
10 |
11 | class RestAccessDeniedHandler : AccessDeniedHandler {
12 | override fun handle(
13 | request: HttpServletRequest?,
14 | response: HttpServletResponse,
15 | e: AccessDeniedException,
16 | ) {
17 | val errorResponse = ErrorResponse.of(ErrorCode.ACCESS_DENIED)
18 | response.contentType = "application/json"
19 | response.status = HttpServletResponse.SC_FORBIDDEN
20 | response.writer?.write(errorResponse.convertToString())
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/config/security/RestAuthenticationEntryPoint.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.config.security
2 |
3 | import com.doongjun.commitmon.core.error.response.ErrorCode
4 | import com.doongjun.commitmon.core.error.response.ErrorResponse
5 | import com.doongjun.commitmon.extension.convertToString
6 | import jakarta.servlet.http.HttpServletRequest
7 | import jakarta.servlet.http.HttpServletResponse
8 | import org.springframework.security.core.AuthenticationException
9 | import org.springframework.security.web.AuthenticationEntryPoint
10 |
11 | class RestAuthenticationEntryPoint : AuthenticationEntryPoint {
12 | override fun commence(
13 | request: HttpServletRequest,
14 | response: HttpServletResponse,
15 | e: AuthenticationException,
16 | ) {
17 | val errorResponse = ErrorResponse.of(ErrorCode.UNAUTHORIZED)
18 | response.contentType = "application/json"
19 | response.status = HttpServletResponse.SC_UNAUTHORIZED
20 | response.writer?.write(errorResponse.convertToString())
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/config/security/SecurityConfig.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.config.security
2 |
3 | import org.springframework.context.annotation.Bean
4 | import org.springframework.context.annotation.Configuration
5 | import org.springframework.security.config.annotation.web.builders.HttpSecurity
6 | import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
7 | import org.springframework.security.config.http.SessionCreationPolicy
8 | import org.springframework.security.web.SecurityFilterChain
9 | import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
10 |
11 | @Configuration
12 | @EnableWebSecurity
13 | class SecurityConfig(
14 | private val tokenProvider: TokenProvider,
15 | ) {
16 | @Bean
17 | fun filterChain(http: HttpSecurity): SecurityFilterChain {
18 | http
19 | .csrf { it.disable() }
20 | .formLogin { it.disable() }
21 | .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
22 | .addFilterBefore(JwtFilter(tokenProvider), UsernamePasswordAuthenticationFilter::class.java)
23 | .authorizeHttpRequests { authorize ->
24 | authorize
25 | .requestMatchers("/swagger-ui/**", "/v3/api-docs/**")
26 | .permitAll()
27 | .requestMatchers("/adventure")
28 | .permitAll()
29 | .requestMatchers("/api/v1/account/**")
30 | .permitAll()
31 | .requestMatchers("/api/v1/commitmons/**")
32 | .permitAll()
33 | .anyRequest()
34 | .authenticated()
35 | }.exceptionHandling { exception ->
36 | exception
37 | .accessDeniedHandler(RestAccessDeniedHandler())
38 | .authenticationEntryPoint(RestAuthenticationEntryPoint())
39 | }
40 |
41 | return http.build()
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/config/security/TokenProvider.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.config.security
2 |
3 | import io.jsonwebtoken.ExpiredJwtException
4 | import io.jsonwebtoken.Jwts
5 | import io.jsonwebtoken.MalformedJwtException
6 | import io.jsonwebtoken.UnsupportedJwtException
7 | import io.jsonwebtoken.io.Decoders
8 | import io.jsonwebtoken.security.Keys
9 | import org.slf4j.LoggerFactory
10 | import org.springframework.beans.factory.annotation.Value
11 | import org.springframework.data.repository.findByIdOrNull
12 | import org.springframework.stereotype.Component
13 | import java.util.Date
14 |
15 | @Component
16 | class TokenProvider(
17 | private val refreshTokenRepository: RefreshTokenRepository,
18 | @Value("\${app.auth.jwt.base64-secret}")
19 | private val base64Secret: String,
20 | @Value("\${app.auth.jwt.expired-ms}")
21 | private val jwtExpiredMs: Long,
22 | @Value("\${app.auth.refresh-token.expired-ms}")
23 | private val refreshTokenExpiredMs: Long,
24 | ) {
25 | private val log = LoggerFactory.getLogger(javaClass)
26 |
27 | fun createAccessToken(userId: Long): String {
28 | val now = Date()
29 | val expiration = Date(now.time + jwtExpiredMs)
30 |
31 | return Jwts
32 | .builder()
33 | .claims(mapOf("sub" to userId.toString()))
34 | .issuedAt(now)
35 | .expiration(expiration)
36 | .signWith(Keys.hmacShaKeyFor(Decoders.BASE64.decode(base64Secret)))
37 | .compact()
38 | }
39 |
40 | fun extractUserId(accessToken: String): Long {
41 | val claims =
42 | Jwts
43 | .parser()
44 | .verifyWith(Keys.hmacShaKeyFor(Decoders.BASE64.decode(base64Secret)))
45 | .build()
46 | .parseSignedClaims(accessToken)
47 | .payload
48 | return claims["sub"].toString().toLong()
49 | }
50 |
51 | fun validateAccessToken(accessToken: String): Boolean {
52 | try {
53 | Jwts
54 | .parser()
55 | .verifyWith(Keys.hmacShaKeyFor(Decoders.BASE64.decode(base64Secret)))
56 | .build()
57 | .parseSignedClaims(accessToken)
58 |
59 | return true
60 | } catch (ex: MalformedJwtException) {
61 | log.error("Invalid JWT token")
62 | } catch (ex: ExpiredJwtException) {
63 | log.error("Expired JWT token")
64 | } catch (ex: UnsupportedJwtException) {
65 | log.error("Unsupported JWT token")
66 | } catch (ex: IllegalArgumentException) {
67 | log.error("JWT claims string is empty.")
68 | }
69 | return false
70 | }
71 |
72 | fun createRefreshToken(userId: Long) =
73 | refreshTokenRepository
74 | .save(
75 | RefreshToken(userId = userId, ttl = refreshTokenExpiredMs / 1000),
76 | ).token!!
77 |
78 | fun expireRefreshToken(refreshToken: String) = refreshTokenRepository.deleteById(refreshToken)
79 |
80 | fun getRefreshToken(refreshToken: String) = refreshTokenRepository.findByIdOrNull(refreshToken)
81 | }
82 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/core/AdventureGenerator.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.core
2 |
3 | import java.util.concurrent.ThreadLocalRandom
4 |
5 | class AdventureGenerator {
6 | companion object {
7 | private val random = ThreadLocalRandom.current()
8 |
9 | fun generateY(isFlying: Boolean): Int = if (isFlying) random.nextInt(5, 15) else random.nextInt(30, 70)
10 |
11 | fun generateMotion(): Motion {
12 | val duration = random.nextInt(30, 180)
13 | val toRight = random.nextBoolean()
14 | val d = if (toRight) -1 else 1
15 |
16 | val x1 = random.nextFloat(20f, 80f)
17 | val x2 = if (toRight) x1 + random.nextFloat() * (80 - x1) else x1 - random.nextFloat() * x1
18 | val x3 = if (toRight) x2 + random.nextFloat() * (80 - x2) else x2 - random.nextFloat() * x2
19 | val positions =
20 | listOf(
21 | Position(x1, d),
22 | Position(x2, d),
23 | Position(x3, d),
24 | Position(x3, d * -1),
25 | Position(if (toRight) random.nextFloat(x1, x3) else random.nextFloat(x3, x1), d * -1),
26 | Position(x1, d * -1),
27 | )
28 |
29 | return Motion(
30 | duration,
31 | positions,
32 | )
33 | }
34 | }
35 |
36 | data class Motion(
37 | val duration: Int,
38 | val positions: List,
39 | val namePositions: List =
40 | positions.map {
41 | NamePosition(
42 | x = if (it.direction == -1) it.x - 3.5f else it.x - 0.3f,
43 | )
44 | },
45 | )
46 |
47 | data class Position(
48 | val x: Float,
49 | val direction: Int,
50 | )
51 |
52 | data class NamePosition(
53 | val x: Float,
54 | )
55 | }
56 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/core/BaseEntity.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.core
2 |
3 | import com.github.f4b6a3.tsid.TsidCreator
4 | import jakarta.persistence.Column
5 | import jakarta.persistence.EntityListeners
6 | import jakarta.persistence.Id
7 | import jakarta.persistence.MappedSuperclass
8 | import jakarta.persistence.PostLoad
9 | import jakarta.persistence.PostPersist
10 | import org.hibernate.proxy.HibernateProxy
11 | import org.springframework.data.annotation.CreatedDate
12 | import org.springframework.data.annotation.LastModifiedDate
13 | import org.springframework.data.domain.Persistable
14 | import org.springframework.data.jpa.domain.support.AuditingEntityListener
15 | import java.io.Serializable
16 | import java.time.Instant
17 | import java.util.Objects
18 |
19 | @MappedSuperclass
20 | @EntityListeners(AuditingEntityListener::class)
21 | class BaseEntity : Persistable {
22 | @Id
23 | @Column(name = "id")
24 | private val id: Long = TsidCreator.getTsid().toLong()
25 |
26 | @CreatedDate
27 | @Column(nullable = false, updatable = false)
28 | var createdDate: Instant = Instant.now()
29 | protected set
30 |
31 | @LastModifiedDate
32 | @Column(nullable = false)
33 | var lastModifiedDate: Instant = Instant.now()
34 | protected set
35 |
36 | @Transient
37 | private var isNew = true
38 |
39 | override fun getId() = id
40 |
41 | override fun isNew() = isNew
42 |
43 | override fun equals(other: Any?): Boolean {
44 | if (other == null) {
45 | return false
46 | }
47 |
48 | if (other !is HibernateProxy && this::class != other::class) {
49 | return false
50 | }
51 |
52 | return id == getIdentifier(other)
53 | }
54 |
55 | private fun getIdentifier(obj: Any): Serializable =
56 | if (obj is HibernateProxy) {
57 | obj.hibernateLazyInitializer.identifier as Serializable
58 | } else {
59 | (obj as BaseEntity).id
60 | }
61 |
62 | override fun hashCode() = Objects.hashCode(id)
63 |
64 | @PostPersist
65 | @PostLoad
66 | protected fun load() {
67 | isNew = false
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/core/NoArgs.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.core
2 |
3 | annotation class NoArgs
4 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/core/error/AnimationExceptionHandler.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.core.error
2 |
3 | import com.doongjun.commitmon.animation.AdventureController
4 | import org.springframework.http.MediaType
5 | import org.springframework.http.ResponseEntity
6 | import org.springframework.web.bind.MissingServletRequestParameterException
7 | import org.springframework.web.bind.annotation.ExceptionHandler
8 | import org.springframework.web.bind.annotation.RestControllerAdvice
9 | import org.springframework.web.context.request.WebRequest
10 | import org.thymeleaf.context.Context
11 | import org.thymeleaf.spring6.SpringTemplateEngine
12 |
13 | @RestControllerAdvice(basePackageClasses = [AdventureController::class])
14 | class AnimationExceptionHandler(
15 | private val svgTemplateEngine: SpringTemplateEngine,
16 | ) {
17 | @ExceptionHandler(Exception::class)
18 | fun handleException(e: Exception): ResponseEntity =
19 | getErrorSvg("An error occurred while processing the request.").let { svg ->
20 | ResponseEntity
21 | .internalServerError()
22 | .contentType(MediaType("image", "svg+xml"))
23 | .body(svg)
24 | }
25 |
26 | @ExceptionHandler(MissingServletRequestParameterException::class)
27 | fun handleMissingServletRequestParameterException(e: MissingServletRequestParameterException): ResponseEntity =
28 | getErrorSvg(e.message).let { svg ->
29 | ResponseEntity
30 | .badRequest()
31 | .contentType(MediaType("image", "svg+xml"))
32 | .body(svg)
33 | }
34 |
35 | @ExceptionHandler(IllegalArgumentException::class)
36 | fun handleIllegalArgumentException(
37 | e: IllegalArgumentException,
38 | request: WebRequest,
39 | ): ResponseEntity =
40 | getErrorSvg("Could not resolve to a User with the username of '${request.getParameter("username")}'.").let { svg ->
41 | ResponseEntity
42 | .badRequest()
43 | .contentType(MediaType("image", "svg+xml"))
44 | .body(svg)
45 | }
46 |
47 | private fun getErrorSvg(errorMessage: String): String =
48 | svgTemplateEngine
49 | .process(
50 | "error",
51 | Context().apply {
52 | setVariable("errorMessage", errorMessage)
53 | },
54 | )
55 | }
56 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/core/error/GlobalExceptionHandler.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.core.error
2 |
3 | import com.doongjun.commitmon.core.error.response.ErrorCode
4 | import com.doongjun.commitmon.core.error.response.ErrorResponse
5 | import org.slf4j.LoggerFactory
6 | import org.springframework.http.HttpStatus
7 | import org.springframework.http.ResponseEntity
8 | import org.springframework.http.converter.HttpMessageNotReadableException
9 | import org.springframework.security.access.AccessDeniedException
10 | import org.springframework.security.authentication.AccountExpiredException
11 | import org.springframework.validation.BindException
12 | import org.springframework.web.HttpRequestMethodNotSupportedException
13 | import org.springframework.web.bind.MethodArgumentNotValidException
14 | import org.springframework.web.bind.MissingServletRequestParameterException
15 | import org.springframework.web.bind.annotation.ExceptionHandler
16 | import org.springframework.web.bind.annotation.RestController
17 | import org.springframework.web.bind.annotation.RestControllerAdvice
18 | import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException
19 |
20 | @RestControllerAdvice(annotations = [RestController::class])
21 | class GlobalExceptionHandler {
22 | private val log = LoggerFactory.getLogger(javaClass)
23 |
24 | @ExceptionHandler(Exception::class)
25 | protected fun handleException(e: Exception): ResponseEntity {
26 | log.error("Exception", e)
27 | val response = ErrorResponse.of(ErrorCode.INTERNAL_SERVER_ERROR)
28 | return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response)
29 | }
30 |
31 | @ExceptionHandler(BindException::class)
32 | protected fun handleBindException(e: BindException): ResponseEntity {
33 | log.error("BindException", e)
34 | val response = ErrorResponse.of(ErrorCode.INVALID_INPUT_VALUE, e.bindingResult)
35 | return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response)
36 | }
37 |
38 | @ExceptionHandler(MissingServletRequestParameterException::class)
39 | protected fun handleMissingServletRequestParameterException(e: MissingServletRequestParameterException): ResponseEntity {
40 | log.error("MethodArgumentTypeMismatchException", e)
41 | val response = ErrorResponse.of(e)
42 | return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response)
43 | }
44 |
45 | @ExceptionHandler(MethodArgumentTypeMismatchException::class)
46 | protected fun handleMethodArgumentTypeMismatchException(e: MethodArgumentTypeMismatchException): ResponseEntity {
47 | log.error("MethodArgumentTypeMismatchException", e)
48 | val response = ErrorResponse.of(e)
49 | return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response)
50 | }
51 |
52 | @ExceptionHandler(HttpRequestMethodNotSupportedException::class)
53 | protected fun handleHttpRequestMethodNotSupportedException(e: HttpRequestMethodNotSupportedException): ResponseEntity {
54 | log.error("HttpRequestMethodNotSupportedException", e)
55 | val response = ErrorResponse.of(ErrorCode.METHOD_NOT_ALLOWED)
56 | return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED).body(response)
57 | }
58 |
59 | @ExceptionHandler(HttpMessageNotReadableException::class)
60 | protected fun handleHttpMessageNotReadableException(e: HttpMessageNotReadableException): ResponseEntity {
61 | log.error("HttpMessageNotReadableException", e)
62 | val response = ErrorResponse.of(ErrorCode.INVALID_INPUT_VALUE)
63 | return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response)
64 | }
65 |
66 | @ExceptionHandler(MethodArgumentNotValidException::class)
67 | protected fun handleMethodArgumentNotValidException(e: MethodArgumentNotValidException): ResponseEntity {
68 | log.error("MethodArgumentNotValidException", e)
69 | val response = ErrorResponse.of(ErrorCode.INVALID_INPUT_VALUE, e.bindingResult)
70 | return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response)
71 | }
72 |
73 | @ExceptionHandler(AccessDeniedException::class)
74 | protected fun handleAccessDeniedException(e: AccessDeniedException): ResponseEntity {
75 | log.error("AccessDeniedException", e)
76 | val response = ErrorResponse.of(ErrorCode.ACCESS_DENIED)
77 | return ResponseEntity.status(HttpStatus.FORBIDDEN).body(response)
78 | }
79 |
80 | @ExceptionHandler(AccountExpiredException::class)
81 | protected fun handleAccountExpiredException(e: AccountExpiredException): ResponseEntity {
82 | log.error("AccountExpiredException", e)
83 | val response = ErrorResponse.of(ErrorCode.UNAUTHORIZED)
84 | return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(response)
85 | }
86 |
87 | @ExceptionHandler(IllegalArgumentException::class)
88 | protected fun handleIllegalArgumentException(e: IllegalArgumentException): ResponseEntity {
89 | log.error("IllegalArgumentException", e)
90 | val response = ErrorResponse.of(ErrorCode.INVALID_INPUT_VALUE)
91 | return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response)
92 | }
93 |
94 | @ExceptionHandler(NoSuchElementException::class)
95 | protected fun handleNoSuchElementException(e: NoSuchElementException): ResponseEntity {
96 | log.error("NoSuchElementException", e)
97 | val response = ErrorResponse.of(ErrorCode.NOT_FOUND)
98 | return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response)
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/core/error/response/ErrorCode.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.core.error.response
2 |
3 | enum class ErrorCode(
4 | val status: Int,
5 | val code: String,
6 | val message: String,
7 | ) {
8 | INVALID_INPUT_VALUE(400, "A001", "Invalid input value."),
9 | METHOD_NOT_ALLOWED(405, "A002", "Invalid input value."),
10 | INTERNAL_SERVER_ERROR(500, "A003", "Server Error."),
11 | NOT_FOUND(404, "A004", "Not Found."),
12 | INVALID_TYPE_VALUE(400, "A005", "Invalid type value."),
13 | ACCESS_DENIED(403, "A006", "Access is denied."),
14 | UNAUTHORIZED(401, "A007", "Unauthorized."),
15 | }
16 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/core/error/response/ErrorResponse.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.core.error.response
2 |
3 | import org.springframework.validation.BindingResult
4 | import org.springframework.web.bind.MissingServletRequestParameterException
5 | import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException
6 |
7 | data class ErrorResponse private constructor(
8 | val message: String,
9 | val status: Int,
10 | val code: String,
11 | val errors: List,
12 | ) {
13 | companion object {
14 | fun of(errorCode: ErrorCode) = ErrorResponse(errorCode)
15 |
16 | fun of(
17 | errorCode: ErrorCode,
18 | bindingResult: BindingResult,
19 | ) = ErrorResponse(errorCode, FieldError.of(bindingResult))
20 |
21 | fun of(
22 | errorCode: ErrorCode,
23 | errors: List,
24 | ) = ErrorResponse(errorCode, errors)
25 |
26 | fun of(e: MethodArgumentTypeMismatchException) =
27 | ErrorResponse(ErrorCode.INVALID_TYPE_VALUE, FieldError.of(e.name, e.value.toString(), e.errorCode))
28 |
29 | fun of(e: MissingServletRequestParameterException) =
30 | ErrorResponse(ErrorCode.INVALID_INPUT_VALUE, FieldError.of(e.parameterName, null, e.message))
31 | }
32 |
33 | private constructor(errorCode: ErrorCode, errors: List = emptyList()) : this(
34 | message = errorCode.message,
35 | status = errorCode.status,
36 | code = errorCode.code,
37 | errors = errors,
38 | )
39 | }
40 |
41 | data class FieldError private constructor(
42 | val field: String? = "",
43 | val value: String? = "",
44 | val reason: String? = "",
45 | ) {
46 | companion object {
47 | fun of(
48 | field: String?,
49 | value: String?,
50 | reason: String?,
51 | ) = listOf(FieldError(field, value, reason))
52 |
53 | fun of(bindingResult: BindingResult) =
54 | bindingResult.fieldErrors.map { error ->
55 | FieldError(error.field, error.rejectedValue?.toString(), error.defaultMessage)
56 | }
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/domain/CommitmonLevel.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.domain
2 |
3 | enum class CommitmonLevel(
4 | val exp: Long,
5 | val order: Int,
6 | ) {
7 | EGG(0, 0), // 알
8 | BABY(100, 1), // 유아기
9 | IN_TRAINING(200, 2), // 유년기
10 | ROOKIE(400, 3), // 성장기
11 | CHAMPION(800, 4), // 성숙기
12 | PERFECT(1600, 5), // 완전체
13 | ULTIMATE(3200, 6), // 궁극체
14 | ;
15 |
16 | fun nextLevel(): CommitmonLevel = entries.firstOrNull { it.order == this.order + 1 } ?: this
17 |
18 | companion object {
19 | fun fromExp(exp: Long): CommitmonLevel = entries.last { exp >= it.exp }
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/domain/Follow.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.domain
2 |
3 | import com.doongjun.commitmon.core.BaseEntity
4 | import jakarta.persistence.Entity
5 | import jakarta.persistence.FetchType.LAZY
6 | import jakarta.persistence.Index
7 | import jakarta.persistence.JoinColumn
8 | import jakarta.persistence.ManyToOne
9 | import jakarta.persistence.Table
10 | import jakarta.persistence.UniqueConstraint
11 |
12 | @Entity
13 | @Table(
14 | name = "follow",
15 | uniqueConstraints = [
16 | UniqueConstraint(name = "UNQ_FOLLOW_FOLLOWER_FOLLOWING", columnNames = ["follower_id", "following_id"]),
17 | ],
18 | indexes = [
19 | Index(name = "IDX_FOLLOW_FOLLOWER_ID", columnList = "follower_id"),
20 | Index(name = "IDX_FOLLOW_FOLLOWING_ID", columnList = "following_id"),
21 | ],
22 | )
23 | class Follow(
24 | @ManyToOne(fetch = LAZY)
25 | @JoinColumn(name = "follower_id")
26 | val follower: User,
27 | @ManyToOne(fetch = LAZY)
28 | @JoinColumn(name = "following_id")
29 | val following: User,
30 | ) : BaseEntity()
31 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/domain/User.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.domain
2 |
3 | import com.doongjun.commitmon.core.BaseEntity
4 | import jakarta.persistence.CascadeType.ALL
5 | import jakarta.persistence.Column
6 | import jakarta.persistence.Entity
7 | import jakarta.persistence.EnumType
8 | import jakarta.persistence.Enumerated
9 | import jakarta.persistence.FetchType.LAZY
10 | import jakarta.persistence.Index
11 | import jakarta.persistence.OneToMany
12 | import jakarta.persistence.Table
13 |
14 | @Entity
15 | @Table(
16 | name = "`user`",
17 | indexes = [
18 | Index(name = "IDX_USER_NAME", columnList = "name", unique = true),
19 | ],
20 | )
21 | class User(
22 | @Column(name = "name", unique = true, nullable = false)
23 | val name: String,
24 | totalCommitCount: Long = 0,
25 | followers: List = emptyList(),
26 | following: List = emptyList(),
27 | ) : BaseEntity() {
28 | @Column(name = "total_commit_count", nullable = false)
29 | var totalCommitCount: Long = totalCommitCount
30 | protected set
31 |
32 | @Enumerated(EnumType.STRING)
33 | @Column(name = "commitmon", nullable = false)
34 | var commitmon: Commitmon = Commitmon.randomCommitmon(CommitmonLevel.fromExp(totalCommitCount))
35 | protected set
36 |
37 | @Column(name = "auto_level_up", nullable = false)
38 | var autoLevelUp: Boolean = true
39 |
40 | @OneToMany(mappedBy = "follower", fetch = LAZY, cascade = [ALL], orphanRemoval = true)
41 | protected val mutableFollowers: MutableSet = toFollowers(followers).toMutableSet()
42 | val followers: List get() = mutableFollowers.map { it.following }
43 |
44 | @OneToMany(mappedBy = "following", fetch = LAZY, cascade = [ALL], orphanRemoval = true)
45 | protected val mutableFollowing: MutableSet = toFollowing(following).toMutableSet()
46 | val following: List get() = mutableFollowing.map { it.follower }
47 |
48 | val mutualFollowers: List get() = following.filter { it in followers }
49 |
50 | val exp: Int get() {
51 | return if (commitmon.level == CommitmonLevel.ULTIMATE) {
52 | 100
53 | } else {
54 | ((this.totalCommitCount - commitmon.level.exp).toDouble() / (commitmon.level.nextLevel().exp - commitmon.level.exp) * 100)
55 | .toInt()
56 | }
57 | }
58 |
59 | fun update(
60 | totalCommitCount: Long,
61 | followers: List,
62 | following: List,
63 | ) {
64 | this.totalCommitCount = totalCommitCount
65 | updateFollowers(followers)
66 | updateFollowing(following)
67 |
68 | if (this.autoLevelUp) {
69 | autoLevelUpCommitmon(CommitmonLevel.fromExp(totalCommitCount))
70 | }
71 | }
72 |
73 | fun changeCommitmon(commitmon: Commitmon) {
74 | val currentLevel = CommitmonLevel.fromExp(totalCommitCount)
75 | if (commitmon.level.order > currentLevel.order) {
76 | throw IllegalArgumentException("Cannot change to higher level")
77 | }
78 |
79 | this.commitmon = commitmon
80 | this.autoLevelUp = commitmon.level == currentLevel
81 | }
82 |
83 | private fun autoLevelUpCommitmon(level: CommitmonLevel) {
84 | if (this.commitmon.level != level) {
85 | this.commitmon = Commitmon.randomLevelTreeCommitmon(level, this.commitmon)
86 | }
87 | }
88 |
89 | private fun updateFollowers(followers: List) {
90 | val removals = this.followers - followers.toSet()
91 | val additions = followers - this.followers.toSet()
92 | mutableFollowers.removeAll { it.following in removals }
93 | mutableFollowers.addAll(toFollowers(additions))
94 | }
95 |
96 | private fun updateFollowing(following: List) {
97 | val removals = this.following - following.toSet()
98 | val additions = following - this.following.toSet()
99 | mutableFollowing.removeAll { it.follower in removals }
100 | mutableFollowing.addAll(toFollowing(additions))
101 | }
102 |
103 | private fun toFollowers(followers: List) = followers.map { Follow(follower = this, following = it) }.toMutableSet()
104 |
105 | private fun toFollowing(following: List) = following.map { Follow(follower = it, following = this) }.toMutableSet()
106 | }
107 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/domain/UserRepository.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.domain
2 |
3 | import org.springframework.data.jpa.repository.JpaRepository
4 | import org.springframework.stereotype.Repository
5 |
6 | @Repository
7 | interface UserRepository : JpaRepository {
8 | fun findByName(name: String): User?
9 |
10 | fun findAllByNameIn(names: List): List
11 |
12 | fun existsByName(name: String): Boolean
13 | }
14 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/event/ApplicationEventListener.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.event
2 |
3 | import com.doongjun.commitmon.app.GithubService
4 | import com.doongjun.commitmon.app.UserFetchType
5 | import com.doongjun.commitmon.app.UserService
6 | import com.doongjun.commitmon.app.data.UpdateUserDto
7 | import org.slf4j.LoggerFactory
8 | import org.springframework.context.event.EventListener
9 | import org.springframework.scheduling.annotation.Async
10 | import org.springframework.stereotype.Component
11 |
12 | @Component
13 | class ApplicationEventListener(
14 | private val userService: UserService,
15 | private val githubService: GithubService,
16 | ) {
17 | private val log = LoggerFactory.getLogger(javaClass)
18 |
19 | @Async
20 | @EventListener(UpdateUserInfo::class)
21 | fun handleUpdateUserInfo(event: UpdateUserInfo) {
22 | val user = userService.get(event.userId, UserFetchType.SOLO)
23 |
24 | log.info("Updating user info: ${user.name}")
25 | val (totalCommitCount) = githubService.getUserCommitInfo(user.name)
26 | val (followerNames, followingNames) = githubService.getUserFollowInfo(user.name, 100)
27 |
28 | UpdateUserDto(
29 | totalCommitCount = totalCommitCount,
30 | followerNames = followerNames,
31 | followingNames = followingNames,
32 | ).let { dto ->
33 | userService.update(user.id, dto)
34 | }
35 | log.info("User info updated: ${user.name}")
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/event/UpdateUserInfo.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.event
2 |
3 | data class UpdateUserInfo(
4 | val userId: Long,
5 | )
6 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/extension/EnumExtension.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.extension
2 |
3 | inline infix fun , V> ((E) -> V).findBy(value: V): E? = enumValues().firstOrNull { this(it) == value }
4 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/extension/SecurityExtension.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.extension
2 |
3 | import org.springframework.security.core.context.SecurityContextHolder
4 |
5 | val currentUserId get() = SecurityContextHolder.getContext().authentication?.principal as Long?
6 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/extension/StringExtension.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.extension
2 |
3 | import com.fasterxml.jackson.core.type.TypeReference
4 | import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
5 |
6 | fun Any.convertToString(): String = jacksonObjectMapper().writeValueAsString(this)
7 |
8 | fun String.convertToObject(): T {
9 | val typeReference = object : TypeReference() {}
10 | return jacksonObjectMapper().readValue(this, typeReference)
11 | }
12 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/infra/GithubGraphqlApi.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.infra
2 |
3 | import com.doongjun.commitmon.infra.data.GraphqlRequest
4 | import com.doongjun.commitmon.infra.data.GraphqlResponse
5 | import com.doongjun.commitmon.infra.data.UserFollowInfoResponse
6 | import com.doongjun.commitmon.infra.data.UserFollowInfoVariables
7 | import com.doongjun.commitmon.infra.data.UserFollowersResponse
8 | import com.doongjun.commitmon.infra.data.UserFollowingResponse
9 | import org.slf4j.LoggerFactory
10 | import org.springframework.core.ParameterizedTypeReference
11 | import org.springframework.core.io.ClassPathResource
12 | import org.springframework.stereotype.Component
13 | import org.springframework.web.reactive.function.client.WebClient
14 | import java.nio.charset.Charset
15 |
16 | @Component
17 | class GithubGraphqlApi(
18 | private val githubGraphqlWebClient: WebClient,
19 | ) {
20 | private val log = LoggerFactory.getLogger(javaClass)
21 |
22 | companion object {
23 | private val userFollowInfoQuery =
24 | ClassPathResource(
25 | "graphql/user-follow-info-query.graphql",
26 | ).getContentAsString(Charset.defaultCharset())
27 |
28 | private val userFollowersQuery =
29 | ClassPathResource(
30 | "graphql/user-followers-query.graphql",
31 | ).getContentAsString(Charset.defaultCharset())
32 |
33 | private val userFollowingQuery =
34 | ClassPathResource(
35 | "graphql/user-following-query.graphql",
36 | ).getContentAsString(Charset.defaultCharset())
37 | }
38 |
39 | fun fetchUserFollowInfo(
40 | username: String,
41 | size: Int,
42 | ): UserFollowInfoResponse.User {
43 | val variables =
44 | UserFollowInfoVariables(
45 | login = username,
46 | first = size,
47 | )
48 | val requestBody =
49 | GraphqlRequest(
50 | query = userFollowInfoQuery,
51 | variables = variables,
52 | )
53 | val response =
54 | githubGraphqlWebClient
55 | .post()
56 | .bodyValue(requestBody)
57 | .retrieve()
58 | .bodyToMono(object : ParameterizedTypeReference>() {})
59 | .onErrorMap { error ->
60 | log.error(error.message)
61 | throw IllegalArgumentException("Failed to fetch user follow info: $error")
62 | }.block()!!
63 |
64 | if (isError(response)) {
65 | log.error("Failed to fetch user follow info: ${response.errors}")
66 | throw IllegalArgumentException("Failed to fetch user follow info: ${response.errors}")
67 | }
68 |
69 | return response.data.user!!
70 | }
71 |
72 | fun fetchUserFollowers(
73 | username: String,
74 | size: Int,
75 | after: String? = null,
76 | ): UserFollowersResponse.User {
77 | val variables =
78 | UserFollowInfoVariables(
79 | login = username,
80 | first = size,
81 | after = after,
82 | )
83 | val requestBody =
84 | GraphqlRequest(
85 | query = userFollowersQuery,
86 | variables = variables,
87 | )
88 | val response =
89 | githubGraphqlWebClient
90 | .post()
91 | .bodyValue(requestBody)
92 | .retrieve()
93 | .bodyToMono(object : ParameterizedTypeReference>() {})
94 | .onErrorMap { error ->
95 | log.error(error.message)
96 | throw IllegalArgumentException("Failed to fetch user followers: $error")
97 | }.block()!!
98 |
99 | if (isError(response)) {
100 | log.error("Failed to fetch user followers: ${response.errors}")
101 | throw IllegalArgumentException("Failed to fetch user followers: ${response.errors}")
102 | }
103 |
104 | return response.data.user!!
105 | }
106 |
107 | fun fetchUserFollowing(
108 | username: String,
109 | size: Int,
110 | after: String? = null,
111 | ): UserFollowingResponse.User {
112 | val variables =
113 | UserFollowInfoVariables(
114 | login = username,
115 | first = size,
116 | after = after,
117 | )
118 | val requestBody =
119 | GraphqlRequest(
120 | query = userFollowingQuery,
121 | variables = variables,
122 | )
123 | val response =
124 | githubGraphqlWebClient
125 | .post()
126 | .bodyValue(requestBody)
127 | .retrieve()
128 | .bodyToMono(object : ParameterizedTypeReference>() {})
129 | .onErrorMap { error ->
130 | log.error(error.message)
131 | throw IllegalArgumentException("Failed to fetch user following: $error")
132 | }.block()!!
133 |
134 | if (isError(response)) {
135 | log.error("Failed to fetch user following: ${response.errors}")
136 | throw IllegalArgumentException("Failed to fetch user following: ${response.errors}")
137 | }
138 |
139 | return response.data.user!!
140 | }
141 |
142 | private fun isError(response: GraphqlResponse<*>): Boolean = response.errors != null
143 | }
144 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/infra/GithubOAuth2Api.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.infra
2 |
3 | import com.doongjun.commitmon.infra.data.OAuthLoginResponse
4 | import org.slf4j.LoggerFactory
5 | import org.springframework.stereotype.Component
6 | import org.springframework.web.reactive.function.client.WebClient
7 |
8 | @Component
9 | class GithubOAuth2Api(
10 | private val githubOAuth2WebClient: WebClient,
11 | ) {
12 | private val log = LoggerFactory.getLogger(javaClass)
13 |
14 | fun fetchAccessToken(
15 | code: String,
16 | clientId: String,
17 | clientSecret: String,
18 | ): OAuthLoginResponse =
19 | githubOAuth2WebClient
20 | .post()
21 | .uri { uriBuilder ->
22 | uriBuilder
23 | .path("/access_token")
24 | .queryParam("client_id", clientId)
25 | .queryParam("client_secret", clientSecret)
26 | .queryParam("code", code)
27 | .build()
28 | }.retrieve()
29 | .bodyToMono(OAuthLoginResponse::class.java)
30 | .onErrorMap { error ->
31 | log.error(error.message)
32 | throw IllegalArgumentException("Failed to fetch access token: $error")
33 | }.block()!!
34 | }
35 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/infra/GithubRestApi.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.infra
2 |
3 | import com.doongjun.commitmon.infra.data.UserCommitSearchResponse
4 | import com.doongjun.commitmon.infra.data.UserInfoResponse
5 | import org.slf4j.LoggerFactory
6 | import org.springframework.beans.factory.annotation.Value
7 | import org.springframework.stereotype.Component
8 | import org.springframework.web.reactive.function.client.WebClient
9 |
10 | @Component
11 | class GithubRestApi(
12 | private val githubRestWebClient: WebClient,
13 | @Value("\${app.github.token}")
14 | private val githubToken: String,
15 | ) {
16 | private val log = LoggerFactory.getLogger(javaClass)
17 |
18 | fun fetchUserInfo(userToken: String): String =
19 | githubRestWebClient
20 | .get()
21 | .uri { uriBuilder ->
22 | uriBuilder
23 | .path("/user")
24 | .build()
25 | }.headers { headers ->
26 | headers.add("Authorization", "Bearer $userToken")
27 | }.retrieve()
28 | .bodyToMono(UserInfoResponse::class.java)
29 | .onErrorMap { error ->
30 | log.error(error.message)
31 | throw IllegalArgumentException("Failed to fetch user: $error")
32 | }.block()!!
33 | .login
34 |
35 | fun fetchUserCommitSearchInfo(username: String): UserCommitSearchResponse =
36 | githubRestWebClient
37 | .get()
38 | .uri { uriBuilder ->
39 | uriBuilder
40 | .path("/search/commits")
41 | .queryParam("q", "author:$username")
42 | .queryParam("per_page", 1)
43 | .build()
44 | }.headers { headers ->
45 | headers.add("Authorization", "Bearer $githubToken")
46 | }.retrieve()
47 | .bodyToMono(UserCommitSearchResponse::class.java)
48 | .onErrorMap { error ->
49 | log.error(error.message)
50 | throw IllegalArgumentException("Failed to fetch user commit count: $error")
51 | }.block()!!
52 | }
53 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/infra/data/GraphqlRequest.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.infra.data
2 |
3 | data class GraphqlRequest(
4 | val query: String,
5 | val variables: T,
6 | )
7 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/infra/data/GraphqlResponse.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.infra.data
2 |
3 | data class GraphqlResponse(
4 | val data: T,
5 | val errors: List?,
6 | )
7 |
8 | data class GraphqlError(
9 | val message: String,
10 | val type: String,
11 | )
12 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/infra/data/OAuthLoginResponse.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.infra.data
2 |
3 | import com.fasterxml.jackson.annotation.JsonProperty
4 |
5 | data class OAuthLoginResponse(
6 | @JsonProperty("access_token")
7 | val accessToken: String,
8 | )
9 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/infra/data/UserCommitSearchResponse.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.infra.data
2 |
3 | import com.fasterxml.jackson.annotation.JsonProperty
4 |
5 | data class UserCommitSearchResponse(
6 | @JsonProperty("total_count")
7 | val totalCount: Long,
8 | )
9 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/infra/data/UserFollowInfoResponse.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.infra.data
2 |
3 | data class UserFollowInfoResponse(
4 | val user: User?,
5 | ) {
6 | data class User(
7 | val followers: FollowInfo,
8 | val following: FollowInfo,
9 | )
10 | }
11 |
12 | data class FollowInfo(
13 | val totalCount: Int,
14 | val pageInfo: FollowPageInfo,
15 | val nodes: List,
16 | )
17 |
18 | data class FollowPageInfo(
19 | val hasNextPage: Boolean,
20 | val endCursor: String?,
21 | )
22 |
23 | data class FollowNode(
24 | val login: String,
25 | )
26 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/infra/data/UserFollowInfoVariables.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.infra.data
2 |
3 | data class UserFollowInfoVariables(
4 | val login: String,
5 | val first: Int,
6 | val after: String? = null,
7 | )
8 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/infra/data/UserFollowersResponse.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.infra.data
2 |
3 | data class UserFollowersResponse(
4 | val user: User?,
5 | ) {
6 | data class User(
7 | val followers: FollowInfo,
8 | )
9 | }
10 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/infra/data/UserFollowingResponse.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.infra.data
2 |
3 | data class UserFollowingResponse(
4 | val user: User?,
5 | ) {
6 | data class User(
7 | val following: FollowInfo,
8 | )
9 | }
10 |
--------------------------------------------------------------------------------
/src/main/kotlin/com/doongjun/commitmon/infra/data/UserInfoResponse.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.infra.data
2 |
3 | data class UserInfoResponse(
4 | val login: String,
5 | )
6 |
--------------------------------------------------------------------------------
/src/main/resources/application.yml:
--------------------------------------------------------------------------------
1 | spring:
2 | datasource:
3 | url: ${DB_URL}
4 | username: ${DB_USERNAME}
5 | password: ${DB_PASSWORD}
6 | driver-class-name: org.mariadb.jdbc.Driver
7 | jpa:
8 | hibernate:
9 | ddl-auto: update
10 | properties:
11 | hibernate:
12 | default_batch_fetch_size: 100
13 | dialect: org.hibernate.dialect.MariaDBDialect
14 | data:
15 | redis:
16 | host: ${REDIS_HOST}
17 | port: 6379
18 |
19 | server:
20 | tomcat:
21 | relaxed-query-chars: "{,}"
22 |
23 | app:
24 | auth:
25 | jwt:
26 | base64-secret: ${JWT_SECRET}
27 | expired-ms: 3600000 # 1hour
28 | refresh-token:
29 | expired-ms: 1209600000 # 2weeks
30 | github:
31 | token: ${GITHUB_TOKEN}
32 | base-url: https://api.github.com
33 | oauth2:
34 | base-url: https://github.com/login/oauth
35 | client-id: ${GITHUB_CLIENT_ID}
36 | client-secret: ${GITHUB_CLIENT_SECRET}
--------------------------------------------------------------------------------
/src/main/resources/graphql/user-follow-info-query.graphql:
--------------------------------------------------------------------------------
1 | query(
2 | $login: String!,
3 | $first: Int
4 | ) {
5 | user(login: $login) {
6 | followers(first: $first) {
7 | pageInfo {
8 | hasNextPage
9 | endCursor
10 | }
11 | nodes {
12 | login
13 | }
14 | }
15 | following(first: $first) {
16 | pageInfo {
17 | hasNextPage
18 | endCursor
19 | }
20 | nodes {
21 | login
22 | }
23 | }
24 | }
25 | }
--------------------------------------------------------------------------------
/src/main/resources/graphql/user-followers-query.graphql:
--------------------------------------------------------------------------------
1 | query(
2 | $login: String!,
3 | $first: Int,
4 | $after: String
5 | ) {
6 | user(login: $login) {
7 | followers(first: $first, after: $after) {
8 | pageInfo {
9 | hasNextPage
10 | endCursor
11 | }
12 | nodes {
13 | login
14 | }
15 | }
16 | }
17 | }
--------------------------------------------------------------------------------
/src/main/resources/graphql/user-following-query.graphql:
--------------------------------------------------------------------------------
1 | query(
2 | $login: String!,
3 | $first: Int,
4 | $after: String
5 | ) {
6 | user(login: $login) {
7 | following(first: $first, after: $after) {
8 | pageInfo {
9 | hasNextPage
10 | endCursor
11 | }
12 | nodes {
13 | login
14 | }
15 | }
16 | }
17 | }
--------------------------------------------------------------------------------
/src/main/resources/static/theme/desert.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/src/main/resources/static/theme/grassland.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/src/main/resources/static/theme/transparent.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/src/main/resources/templates/asset/botamon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
8 |
10 |
11 |
12 |
14 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
25 |
26 |
27 | [[${#strings.abbreviate(username, 12)}]]
28 |
29 |
30 |
31 |
32 |
33 |
34 |
94 |
95 |
--------------------------------------------------------------------------------
/src/main/resources/templates/asset/bubbmon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
8 |
10 |
11 |
12 |
13 |
14 |
16 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
29 |
30 |
31 | [[${#strings.abbreviate(username, 12)}]]
32 |
33 |
34 |
35 |
36 |
37 |
38 |
98 |
99 |
--------------------------------------------------------------------------------
/src/main/resources/templates/asset/koromon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
8 |
10 |
11 |
12 |
13 |
15 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
27 |
28 |
29 | [[${#strings.abbreviate(username, 12)}]]
30 |
31 |
32 |
33 |
34 |
35 |
36 |
96 |
97 |
--------------------------------------------------------------------------------
/src/main/resources/templates/asset/nyokimon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
8 |
10 |
12 |
14 |
16 |
17 |
18 |
20 |
22 |
24 |
26 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
37 |
38 |
39 | [[${#strings.abbreviate(username, 12)}]]
40 |
41 |
42 |
43 |
44 |
45 |
46 |
106 |
107 |
--------------------------------------------------------------------------------
/src/main/resources/templates/asset/patamon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
8 |
10 |
11 |
13 |
14 |
15 |
16 |
18 |
20 |
21 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
33 |
34 |
35 | [[${#strings.abbreviate(username, 12)}]]
36 |
37 |
38 |
39 |
40 |
41 |
42 |
102 |
103 |
--------------------------------------------------------------------------------
/src/main/resources/templates/asset/pichimon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
8 |
10 |
11 |
12 |
13 |
15 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
27 |
28 |
29 | [[${#strings.abbreviate(username, 12)}]]
30 |
31 |
32 |
33 |
34 |
35 |
36 |
96 |
97 |
--------------------------------------------------------------------------------
/src/main/resources/templates/asset/poyomon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
8 |
10 |
11 |
12 |
13 |
15 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
27 |
28 |
29 | [[${#strings.abbreviate(username, 12)}]]
30 |
31 |
32 |
33 |
34 |
35 |
36 |
96 |
97 |
--------------------------------------------------------------------------------
/src/main/resources/templates/asset/punimon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
8 |
10 |
11 |
12 |
13 |
15 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
27 |
28 |
29 | [[${#strings.abbreviate(username, 12)}]]
30 |
31 |
32 |
33 |
34 |
35 |
36 |
96 |
97 |
--------------------------------------------------------------------------------
/src/main/resources/templates/asset/pyokomon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
8 |
9 |
10 |
12 |
14 |
16 |
17 |
18 |
19 |
21 |
22 |
23 |
25 |
27 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
39 |
40 |
41 | [[${#strings.abbreviate(username, 12)}]]
42 |
43 |
44 |
45 |
46 |
47 |
48 |
108 |
109 |
--------------------------------------------------------------------------------
/src/main/resources/templates/asset/snowbotamon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
8 |
10 |
11 |
12 |
13 |
15 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
27 |
28 |
29 | [[${#strings.abbreviate(username, 12)}]]
30 |
31 |
32 |
33 |
34 |
35 |
36 |
96 |
97 |
--------------------------------------------------------------------------------
/src/main/resources/templates/asset/tsunomon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
8 |
10 |
12 |
14 |
15 |
16 |
17 |
19 |
21 |
23 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
35 |
36 |
37 | [[${#strings.abbreviate(username, 12)}]]
38 |
39 |
40 |
41 |
42 |
43 |
44 |
105 |
106 |
--------------------------------------------------------------------------------
/src/main/resources/templates/asset/yuramon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
8 |
10 |
11 |
13 |
15 |
16 |
17 |
18 |
19 |
21 |
23 |
24 |
26 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
39 |
40 |
41 | [[${#strings.abbreviate(username, 12)}]]
42 |
43 |
44 |
45 |
46 |
47 |
48 |
108 |
109 |
--------------------------------------------------------------------------------
/src/test/kotlin/com/doongjun/commitmon/app/BaseAppTest.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.app
2 |
3 | import com.doongjun.commitmon.container.TestMariaDBContainer
4 | import com.doongjun.commitmon.container.TestRedisContainer
5 | import jakarta.persistence.EntityManager
6 | import jakarta.persistence.PersistenceContext
7 | import org.springframework.boot.test.context.SpringBootTest
8 | import org.springframework.test.context.ActiveProfiles
9 | import org.springframework.transaction.annotation.Transactional
10 | import org.testcontainers.junit.jupiter.Container
11 | import org.testcontainers.junit.jupiter.Testcontainers
12 |
13 | @ActiveProfiles("test")
14 | @Testcontainers
15 | @Transactional
16 | @SpringBootTest
17 | abstract class BaseAppTest {
18 | @PersistenceContext
19 | private lateinit var entityManager: EntityManager
20 |
21 | protected fun clear() {
22 | entityManager.flush()
23 | entityManager.clear()
24 | }
25 |
26 | companion object {
27 | @Container
28 | val mariaDBContainer = TestMariaDBContainer.getInstance()
29 |
30 | @Container
31 | val redisContainer = TestRedisContainer.getInstance()
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/test/kotlin/com/doongjun/commitmon/container/TestMariaDBContainer.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.container
2 |
3 | import org.testcontainers.containers.MariaDBContainer
4 |
5 | class TestMariaDBContainer : MariaDBContainer("mariadb:11.5.2") {
6 | companion object {
7 | private lateinit var container: TestMariaDBContainer
8 |
9 | fun getInstance(): TestMariaDBContainer {
10 | if (!Companion::container.isInitialized) {
11 | container = TestMariaDBContainer()
12 | }
13 | return container
14 | }
15 | }
16 |
17 | override fun start() {
18 | super.start()
19 | System.setProperty("DB_URL", container.jdbcUrl)
20 | System.setProperty("DB_USERNAME", container.username)
21 | System.setProperty("DB_PASSWORD", container.password)
22 | }
23 |
24 | override fun stop() {
25 | // do nothing, JVM handles shut down
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/test/kotlin/com/doongjun/commitmon/container/TestRedisContainer.kt:
--------------------------------------------------------------------------------
1 | package com.doongjun.commitmon.container
2 |
3 | import org.testcontainers.containers.GenericContainer
4 | import org.testcontainers.utility.DockerImageName
5 |
6 | class TestRedisContainer : GenericContainer(DockerImageName.parse("redis:7.4.0")) {
7 | companion object {
8 | private lateinit var container: TestRedisContainer
9 |
10 | fun getInstance(): TestRedisContainer {
11 | if (!Companion::container.isInitialized) {
12 | container = TestRedisContainer().withExposedPorts(6379)
13 | }
14 | return container
15 | }
16 | }
17 |
18 | override fun start() {
19 | super.start()
20 | System.setProperty("REDIS_HOST", container.host)
21 | System.setProperty("REDIS_PORT", container.getMappedPort(6379).toString())
22 | }
23 |
24 | override fun stop() {
25 | // do nothing, JVM handles shut down
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/test/resources/application-test.yml:
--------------------------------------------------------------------------------
1 | spring:
2 | datasource:
3 | url: ${DB_URL}
4 | username: ${DB_USERNAME}
5 | password: ${DB_PASSWORD}
6 | driver-class-name: org.mariadb.jdbc.Driver
7 | jpa:
8 | hibernate:
9 | ddl-auto: create
10 | properties:
11 | hibernate:
12 | default_batch_fetch_size: 100
13 | dialect: org.hibernate.dialect.MariaDBDialect
14 | show_sql: true
15 | format_sql: true
16 | data:
17 | redis:
18 | host: ${REDIS_HOST}
19 | port: ${REDIS_PORT}
20 |
21 | server:
22 | tomcat:
23 | relaxed-query-chars: "{,}"
24 |
25 | logging:
26 | level:
27 | org.hibernate.orm.jdbc.bind: TRACE
28 |
29 | app:
30 | auth:
31 | jwt:
32 | base64-secret: 12345
33 | expired-ms: 1000
34 | refresh-token:
35 | expired-ms: 1000
36 | github:
37 | token: 12345
38 | base-url: https://api.github.com
39 | oauth2:
40 | base-url: https://github.com/login/oauth
41 | client-id: 12345
42 | client-secret: 12345
--------------------------------------------------------------------------------